Не надо добавлять 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#. Понятно зачем и почему - но всё равно хотелось бы ещё чуть больше свободы.
А что вы строите на исключениях? 99% "ошибок" - это логика, требующая обработки.
В моём понимании (и практике) ровно наоборот - условные "99% ошибок" внутри процессинга - это прерывающие исключения. Всё, что не прерывает - это нормальный случай, он выбирается по условиям/анализу/валидации параметров-данных. Или тот самый Try*-pattern.
Да, бывают, опять же, внешние неконтролируемые зависимости с высокой частотой throw по любому поводу - приходится заменять или минимизировать ущерб.
Одноразовые (per API call/button press) проверки - вообще, как правило, не влияют на картину throughput, могут быть построены как угодно (до известной степени, конечно).
...если что - обработка миллиардов итераций per-API-call (в том числе с необходимостью обработки исключений на горячем пути) - специфика моих систем в последние долгие уже годы, так что "цену" разных мелочей я прочувствовал и понимаю. И как показывает моя практика - оптимизация нормального/expected случая "весит" куда больше, чем возможные исключения.
Как вы себе представляете работу try/catch без throw где-то внутри?
Не "без throw где-то внутри", а "без try-catch внутри длинного цикла". Запросто, try-catch снаружи цикла, если нужно прервать цикл при исключении. Если нужно "пропускать" "исключительные" итерации и продолжать дальше - внешний while/do-while/или-даже-for, внутри подготовка условий и контекста для внутреннего цикла, try-catch, внутри него - внутренний цикл по подготовленным условиям и контексту. Вариантов масса.
Обработка миллиона записей (а это немного на самом деле, бывает и кратно больше) где могут случаться какие-то нештатные ситуации, которые нужно отложить для последующего ручного разбора и идти дальше.
И таких "исключительных" записей ожидается больше, чем погрешность от общего количества? - тогда это выглядит не как исключительный случай, а как вариант нормы - здесь просится валидация данных итерации и выбор одного из путей обработки или условный if (!TryProcess(...)) { ... } - зачем строить такую логику на исключениях? Внешние неконтролируемые зависимости внутри цикла? - переписать согласно предыдущей рекомендации, прогнать нагрузочные - если всё ещё неприемлемо - заменить зависимости на самописные/более подходящие.
В любом случае, этот пример - хорошая иллюстрация использования неподходящей техники, а не ущербности исключений.
Даже не вспоминаем о возможном "лишнем" обращении к незакешированной памяти для обеспечения чистой арифметики, с этим и так понятно.
Но вот у меня есть живой пример, в котором с помощью ИФа можно ИНОГДА избавиться от простого 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#. Понятно зачем и почему - но всё равно хотелось бы ещё чуть больше свободы.
В моём понимании (и практике) ровно наоборот - условные "99% ошибок" внутри процессинга - это прерывающие исключения.
Всё, что не прерывает - это нормальный случай, он выбирается по условиям/анализу/валидации параметров-данных. Или тот самый Try*-pattern.
Да, бывают, опять же, внешние неконтролируемые зависимости с высокой частотой throw по любому поводу - приходится заменять или минимизировать ущерб.
Одноразовые (per API call/button press) проверки - вообще, как правило, не влияют на картину throughput, могут быть построены как угодно (до известной степени, конечно).
...если что - обработка миллиардов итераций per-API-call (в том числе с необходимостью обработки исключений на горячем пути) - специфика моих систем в последние долгие уже годы, так что "цену" разных мелочей я прочувствовал и понимаю. И как показывает моя практика - оптимизация нормального/expected случая "весит" куда больше, чем возможные исключения.
Не "без throw где-то внутри", а "без try-catch внутри длинного цикла".
Запросто, try-catch снаружи цикла, если нужно прервать цикл при исключении. Если нужно "пропускать" "исключительные" итерации и продолжать дальше - внешний while/do-while/или-даже-for, внутри подготовка условий и контекста для внутреннего цикла, try-catch, внутри него - внутренний цикл по подготовленным условиям и контексту.
Вариантов масса.
И таких "исключительных" записей ожидается больше, чем погрешность от общего количества? - тогда это выглядит не как исключительный случай, а как вариант нормы - здесь просится валидация данных итерации и выбор одного из путей обработки или условный if (!TryProcess(...)) { ... } - зачем строить такую логику на исключениях?
Внешние неконтролируемые зависимости внутри цикла? - переписать согласно предыдущей рекомендации, прогнать нагрузочные - если всё ещё неприемлемо - заменить зависимости на самописные/более подходящие.
В любом случае, этот пример - хорошая иллюстрация использования неподходящей техники, а не ущербности исключений.