Я не так просто поинтересовался об изначальном примере. Там компилятору вообще нечего решать, кроме как счётчик заменить на обратный (думаю, что многие компиляторы так и делают, JIT .NETa - точно) и, возможно, цикл развернуть - всё остальное до самого времени исполнения неясно.
То есть, здесь только разработчик может знать, что значения. к примеру, отсортированы - и тогда нужен ИФ с ранним выходом, или "почти отсортированы", и тогда код как в примере будет оптимальным, или вообще непредсказуемые, и тогда нужен оптимизированный вариант из примера. Тут только JIT PGO мог бы по ходу дела переписывать асм, вопрос - не было бы это дороже, чем прогонять цикл как написано.
А так-то да, "его разработчики подумали" о многом. иной раз читаешь релиз ноутс и диву даёшься. Но всего на их уровне не придумаешь.
Не надо добавлять if-ы, чтобы избежать немного арифметики. Вот главное, что нужно
Даже не вспоминаем о возможном "лишнем" обращении к незакешированной памяти для обеспечения чистой арифметики, с этим и так понятно.
Но вот у меня есть живой пример, в котором с помощью ИФа можно ИНОГДА избавиться от простого int64 idiv над операндами, гарантированно лежащими в регистре и кеш-линии. И оно того стоит - любые бенчмарки и реальность это подтверждают.
Однако если мы говорим не про лабораторный эксперимент с использованием ГСЧ, как правило, эти последовательности не берутся из воздуха. Они хранятся в какой-нибудь базе и уже упорядочены.
Далеко не всегда хранятся, далеко не всегда в базе, и даже если и упорядочены - далеко не всегда по тому критерию(ям), по которому(ым) у нас условие(я).
Зависит от компилятора/JITа, но на уровне команд процессоров Интела, вполне может компилироваться как бранчлесс. Однако тут есть нюанс: сначала оба операнда читаются из памяти, потом один из них выбирается. Если чтение не из кешей - то бранч может оказаться выгоднее.
Отличный вопрос, меня он тоже тревожит. По опыту - нет, хотя объяснить я себе этого не могу. Но: с обратным циклом и пойнтер-арифметикой (ref var curr + MemoryMarshal + Unsafe.Add) можно и от регистра для exit condition избавиться, и доступ прямой последовательный сохранить. И JIT именно так и делает, когда решает, что может поменять направление счётчика.
У ArrayPool.Shared достаточно низкий лимит количества массивов в пуле. Проверьте, возможно у вас одномоментно требуется сверх того и пул продолжает выделять и выбрасывать слишком много инстансов.
Если так - стоит попробовать использовать ArrayPool.Create со своими настройками. Но он - ConfigurableArrayPool заметно медленнее в многопоточном high throughput. Нужно будет отбенчмаркать.
В моём случае переход со стандарного ConfigurableArrayPool на самописную адаптацию стандартного же SharedArrayPool дал очень ощутимый прирост. Но я микро и нано секунды ловлю, на уровне миллисекунд может и не заметно разницы в скорости.
Такими темпами мы сейчас перепишем вашу систему на .НЕТ и отпрофилируем заодно.
Валидация тяжелая, IsValid не возвращает деталей - ок, усложним по-быстрому, будем кешировать и переиспользовать известные детали/проблемы процессинга:
struct ItemProcessingContext
{
// Содержит поля и флаги, в которых мы можем сохранять
// результаты различных этапов процессинга записи
}
...
ItemProcessingContext context = default;
if (!TryProcess(item, ref context)) Postpone(item, ref context);
или
ItemProcessingContext context = default;
if (IsValid(item, ref context)) Process(item, ref context);
else Postpone(item, ref context);
или
ItemProcessingContext context = default;
if (TryGetStrategy(item, ref context, out var strategy))
{
strategy.Process(item, ref context);
}
else
{
// внутри, возможно, будет throw. А может и нет.
ProcessUnexpectedCase(item, ref context);
}
// если дошли сюда - можем проанализировать накопленный контекст
// и сделать ещё что-нибудь, если вдруг нужно
PostProcessContext(item, ref context);
Ну и не забываем, что каждый уровень должен решать возникающие проблемы в рамках своей ответственности, всё неожиданное выбрасывать наверх.
БД не сумела сохранить данные? - ретрай и если снова не удалось - исключение. Кэш уже содержит запись для ключа? - молча игнорирует. А другая имплементация, возможно, кидает DuplicateKeyException.
И так далее.
Сути не меняет: Если у нас есть известный случай и мы его поддерживаем - нормальная его обработка проходит без исключений. Неизвестный случай или ошибка процессинга, о котором мы знаем, как сообщить на уровень выше (наш контракт имеет необходимые для этого свойства/типы) - нормальный процессинг, не знаем, как сообщить наверх - исключение.
Доведём мой подход до абсолюта: "идеал" по умолчанию - "все" методы возвращают void, со своими проблемами справляются сами, выполненный метод означает успех, иначе - исключение, означает нерешенную проблему. Простой линейный контрол флоу.
А уже в дальнейшем, для обеспечения необходимой гибкости/производительности начинаем вводить необходимые типы/коды возвратов, шаред контексты, стадии обработки, ветвления и прочее, и прочее - в минимально необходимых рамках "нормально" поддерживаемых случаев.
Так, ну раз пошла такая пьянка, то давайте уже и я отвечу взаимностью и разверну немного свою т.з.
Перво-наперво - теги у статьи .NET/C#, с этих позиций я и выступаю, понимаю, что могут быть рантаймы/архитектуры, где всё по-другому.
Повторю, да, throw в .NET достаточно/относительно тяжелый, нужно это учитывать, но при правильном применение - более чем годный. Правильное применение - это не пытаться использовать исключения как оператор ветвления.
Дальше, как я вижу предложенный пример с "миллионами записей, некоторые из них нужно отложить". На примитивном уровне - это два варианта данных, причём для обоих у меня есть алгоритм обработки. Поэтому код выглядел бы как-то так:
if (!TryProcess(item)) Postpone(item);
или
if (IsValid(item)) Process(item);
else Postpone(item);
А вот если Process или Postpone не справились - то они выкидывают исключение, которое улетает выше - и там уже, возможно, мы решаем что с этим делать. Потому, что это явно случай, к которому мы не готовы, не важно по какой причине, но что делать мы на этом уровне не знаем. (кстати, TryProcess хоть и не должен бы, но тоже может выкинуть исключение, если там совсем что-то непредвиденное)
А на уровне выше, может и знаем - например, пропускаем эту запись и идём дальше (как я описывал выше), а может и вообще всё рушим и перезапускаем машину ;)
Но важно то, что если таких случаев становится слишком много, то мы их анализируем и добавляем обработчик(и), тем самым опять снижаем частоту исключений. И код становится примерно таким:
if (TryGetStrategy(item, out var strategy))
{
strategy.Process(item);
}
else
{
throw new UnexpectedDataException(item);
}
Кстати, выбранная стратегия тоже может выкинуть исключение, если она по какой-либо причине не справилась, логика выше по стеку остаётся такой же, как и была.
Иначе говоря: поддерживать все известные случаи, в неизвестном - выкидывать исключение, но таких случаев должно оставаться как можно меньше.
Вот. А дальше? Данные невалидны. Что дальше? Вернуть какой-то result или бросить исключение через throw? Вот о чем разговор.
В общем случае так: если у меня есть обработчик для такого [невалидного] состояния - вызвать его, иначе - исключение, всё прерывается, исключение улетает выше - может быть там знают, что с ним делать.
В случае с параметрами метода если не хотим дефенсив копи делаем параметр ref readonly вместо in.
Это ошибка, причём потенциально весьма дорогая: поведение in и ref readonly с точки зрения defensive copy - идентичное, копия создаётся.
Зашита от defensive copy - readonly struct, прямое чтение полей, пометки методов/пропертей как readonly или магия Unsafe.AsRef. Ну, или передача как ref-параметр. Других вариантов нет.
...Да, и вот кстати: в C# использую goto не так, чтобы часто, но регулярно - сильно упрощает жизнь в некоторых случаях, часть из которых уже упомянули.
Считаю его незаменимым инструментом, хоть и излишне "задушенным" в C#. Понятно зачем и почему - но всё равно хотелось бы ещё чуть больше свободы.
Векторизировать условное поэлементное копирование?
Я не так просто поинтересовался об изначальном примере. Там компилятору вообще нечего решать, кроме как счётчик заменить на обратный (думаю, что многие компиляторы так и делают, JIT .NETa - точно) и, возможно, цикл развернуть - всё остальное до самого времени исполнения неясно.
То есть, здесь только разработчик может знать, что значения. к примеру, отсортированы - и тогда нужен ИФ с ранним выходом, или "почти отсортированы", и тогда код как в примере будет оптимальным, или вообще непредсказуемые, и тогда нужен оптимизированный вариант из примера.
Тут только JIT PGO мог бы по ходу дела переписывать асм, вопрос - не было бы это дороже, чем прогонять цикл как написано.
А так-то да, "его разработчики подумали" о многом. иной раз читаешь релиз ноутс и диву даёшься. Но всего на их уровне не придумаешь.
А о чём конкретно в случае изначального примера должен думать разработчик компилятора?
И что в нём такого, что его нужно прям анализировать и предсказывать [особенно Инженеру встраиваемых систем (младшему)]?
Даже не вспоминаем о возможном "лишнем" обращении к незакешированной памяти для обеспечения чистой арифметики, с этим и так понятно.
Но вот у меня есть живой пример, в котором с помощью ИФа можно ИНОГДА избавиться от простого int64 idiv над операндами, гарантированно лежащими в регистре и кеш-линии. И оно того стоит - любые бенчмарки и реальность это подтверждают.
Так что не всякая арифметика одинаково полезна.
Далеко не всегда хранятся, далеко не всегда в базе, и даже если и упорядочены - далеко не всегда по тому критерию(ям), по которому(ым) у нас условие(я).
Зависит от компилятора/JITа, но на уровне команд процессоров Интела, вполне может компилироваться как бранчлесс.
Однако тут есть нюанс: сначала оба операнда читаются из памяти, потом один из них выбирается. Если чтение не из кешей - то бранч может оказаться выгоднее.
Отличный вопрос, меня он тоже тревожит. По опыту - нет, хотя объяснить я себе этого не могу.
Но: с обратным циклом и пойнтер-арифметикой (ref var curr + MemoryMarshal + Unsafe.Add) можно и от регистра для exit condition избавиться, и доступ прямой последовательный сохранить. И JIT именно так и делает, когда решает, что может поменять направление счётчика.
справедливости ради в этом примере оба массива практически с гарантией окажутся в кешах.
Есть. Даже c# код под Интел экономит регистр на обратных циклах.
Типа такого будет:
На х86/64 тоже, чего бы ему не сэкономить.
дел
Бывает.
Поведение странное только если вдруг упёрся в производительность, но тогда сам бог велел изучить вопрос.
А пока не упёрся - что бывает в 99% случаев - отличный прозрачный safeguard.
И как предлагаете IList и ReadOnlyCollection приводить к спану?
Такого прикола нет, есть прикол с не-readonly структурами в readonly контексте.
Читайте документацию, она прикольная.
Вообще-то, "мы" ещё как пишем, потому, что OrderBy - это куча аллокаций, да ещё и приведение к IEnumerable<> со всеми вытекающими.
"Мы" и поиск, случается, пишем, и вообще много всего, что есть в стандартной библиотеке.
Так что "мы" разные.
У ArrayPool.Shared достаточно низкий лимит количества массивов в пуле.
Проверьте, возможно у вас одномоментно требуется сверх того и пул продолжает выделять и выбрасывать слишком много инстансов.
Если так - стоит попробовать использовать ArrayPool.Create со своими настройками. Но он - ConfigurableArrayPool заметно медленнее в многопоточном high throughput. Нужно будет отбенчмаркать.
В моём случае переход со стандарного ConfigurableArrayPool на самописную адаптацию стандартного же SharedArrayPool дал очень ощутимый прирост. Но я микро и нано секунды ловлю, на уровне миллисекунд может и не заметно разницы в скорости.
Такими темпами мы сейчас перепишем вашу систему на .НЕТ и отпрофилируем заодно.
Валидация тяжелая, IsValid не возвращает деталей - ок, усложним по-быстрому, будем кешировать и переиспользовать известные детали/проблемы процессинга:
Ну и не забываем, что каждый уровень должен решать возникающие проблемы в рамках своей ответственности, всё неожиданное выбрасывать наверх.
БД не сумела сохранить данные? - ретрай и если снова не удалось - исключение.
Кэш уже содержит запись для ключа? - молча игнорирует.
А другая имплементация, возможно, кидает DuplicateKeyException.
И так далее.
Сути не меняет:
Если у нас есть известный случай и мы его поддерживаем - нормальная его обработка проходит без исключений.
Неизвестный случай или ошибка процессинга, о котором мы знаем, как сообщить на уровень выше (наш контракт имеет необходимые для этого свойства/типы) - нормальный процессинг, не знаем, как сообщить наверх - исключение.
Доведём мой подход до абсолюта: "идеал" по умолчанию - "все" методы возвращают void, со своими проблемами справляются сами, выполненный метод означает успех, иначе - исключение, означает нерешенную проблему. Простой линейный контрол флоу.
А уже в дальнейшем, для обеспечения необходимой гибкости/производительности начинаем вводить необходимые типы/коды возвратов, шаред контексты, стадии обработки, ветвления и прочее, и прочее - в минимально необходимых рамках "нормально" поддерживаемых случаев.
Так, ну раз пошла такая пьянка, то давайте уже и я отвечу взаимностью и разверну немного свою т.з.
Перво-наперво - теги у статьи .NET/C#, с этих позиций я и выступаю, понимаю, что могут быть рантаймы/архитектуры, где всё по-другому.
Повторю, да, throw в .NET достаточно/относительно тяжелый, нужно это учитывать, но при правильном применение - более чем годный.
Правильное применение - это не пытаться использовать исключения как оператор ветвления.
Дальше, как я вижу предложенный пример с "миллионами записей, некоторые из них нужно отложить".
На примитивном уровне - это два варианта данных, причём для обоих у меня есть алгоритм обработки. Поэтому код выглядел бы как-то так:
или
А вот если Process или Postpone не справились - то они выкидывают исключение, которое улетает выше - и там уже, возможно, мы решаем что с этим делать.
Потому, что это явно случай, к которому мы не готовы, не важно по какой причине, но что делать мы на этом уровне не знаем.
(кстати, TryProcess хоть и не должен бы, но тоже может выкинуть исключение, если там совсем что-то непредвиденное)
А на уровне выше, может и знаем - например, пропускаем эту запись и идём дальше (как я описывал выше), а может и вообще всё рушим и перезапускаем машину ;)
Но важно то, что если таких случаев становится слишком много, то мы их анализируем и добавляем обработчик(и), тем самым опять снижаем частоту исключений.
И код становится примерно таким:
Кстати, выбранная стратегия тоже может выкинуть исключение, если она по какой-либо причине не справилась, логика выше по стеку остаётся такой же, как и была.
Иначе говоря: поддерживать все известные случаи, в неизвестном - выкидывать исключение, но таких случаев должно оставаться как можно меньше.
Как-то так.
В общем случае так: если у меня есть обработчик для такого [невалидного] состояния - вызвать его, иначе - исключение, всё прерывается, исключение улетает выше - может быть там знают, что с ним делать.
Это ошибка, причём потенциально весьма дорогая: поведение in и ref readonly с точки зрения defensive copy - идентичное, копия создаётся.
Зашита от defensive copy - readonly struct, прямое чтение полей, пометки методов/пропертей как readonly или магия Unsafe.AsRef.
Ну, или передача как ref-параметр.
Других вариантов нет.
...Да, и вот кстати: в C# использую goto не так, чтобы часто, но регулярно - сильно упрощает жизнь в некоторых случаях, часть из которых уже упомянули.
Считаю его незаменимым инструментом, хоть и излишне "задушенным" в C#. Понятно зачем и почему - но всё равно хотелось бы ещё чуть больше свободы.