Просто утверждение было слишком общее и (как минимум поэтому) слишком некорректное.
С точки зрения перфоманса, несколько долгих задач будут гораздо эффективнее множества коротких - расходы (как процессора, так и памяти) на постановку задачи в очередь, создание стейт-машины, таска, регистрации комплишна, переключение контекста (опционально), GC, чтобы собрать весь этот мусор и ещё множества необходимых операций часто гораздо выше, чем время и ресурсы необходимые на выполнение самой полезной нагрузки.
Так что ещё раз: с точки зрения чистого перфоманса, несколько долгих задач будут гораздо выгоднее множества коротких.
А дальше, конечно, начинаются нюансы - что важнее, пропускная способность, общее время выполнения или "отзывчивость", сколько (много ли) задач нужно создать "авансом" или есть возможность организовать ограниченный буфер создаваемых задач (backpressure) и т.д., и т.п.
Но, повторюсь: с точки зрения чистой производительности главное правило такое: всё, что можно собирать в пакет и запускать как единый таск - должно собираться в пакет, а уж потом нужно смотреть, что из важных характеристик пострадало и корректировать.
Если вкратце, то здесь два аспекта: девиртуальзация и обход боксинг.
С девиртуализацией всё просто: void Method(IInterface strategy) и void Method<T>(T strategy) where T : IInterface в рантайме будут скомпилированы в машкод по-разному. Вариант с дженериком может быть полностью девиртуализирован JITом (зависит от конкретной имплементации T, конечно, но исходим из того, что T написан правильно с точки зрения перфоманса). JIT в .нете становится умнее и старается девиртуализировать и интерфейсы/абстрактные классы/виртуальниые методы и даже делегаты на горячем пути, но это всё эвристики и без гарантий.
Боксинг: выше я писал "структуры всюду, где только можно (и имеет смысл)". "Всюду" означает как для данных, так и для логики, отличный пример - енумераторы. Стандартный пример - енумератор класса List<>, реализован как struct. Для того, чтобы он не был boxed, вызывающий код/JIT должны знать, что это List<T>.Enumerator, а не IEnumerator<T>, возвращаемый из IEnumerable<T>.GetEnumerator().
Таким же образом реализуем "стратегии" - struct имплементации интерфейсов IStrategy. new Struct()/default(Struct) создаётся на стеке, не нагружает GC. Главное, избежать боксинга, а значит передавать не как IStrategy, а как T where T : IStrategy.
Теперь представляем себе, что в некоторые методы или в качестве генерик-аргументов типа мы должны передать несколько таких разных "T", каждый из которых имеет вложенные генерик-аргументы.
Так получаются "десятки генерик-аргументов" о которых я писал. "Десятки", наверное, перебор, но по 10+ у нас бывало.
Да вот именно, что как раз подобные низкоуровневые хаки и были, я как увидел шапку статьи - так сразу зашел почитать, хотя от Го далёк.
Конретно у нас "хаки" были вроде девиртуализации любыми способами (отказ от интерфейсов, виртуальных методов, делегатов, десятки генерик-аргументов - правда генерики в том числе для обхода возможного боксинга), структуры всюду, где только можно (и имеет смысл) в обмен на удобство наследования и переиспользование кода, поиск оптимальных размеров структур и хаки с паддингами/выравниванием, небезопасные балк-операции прямо над памятью вместо обращению к полям/пропертям, ручной инлайнинг повсюду (привет дублированию кода), небезопасное приведение типов (практически всюду), обход проверок границ, арифметика с указателями, ин-проц и ин-мемори всего, что только возможно, практически весь горячий путь не следовал вообще никаким "хорошим паттернам и практикам", а точнее - весь был написан вопреки. Это только первое, что пришло на ум. Хватит? ;)
Алгоритмы и архитектура (уровня классов/модулей/внутреннего дата-флоу, в первую очередь) тоже, конечно.
Доля "хаков" в приросте производительности была действительно львиная.
...что-то заигрался я в бенчмарки, но тут интересное:
NET8
При добавлении заранее отсортированных элементов у SortedList скорость ожидаемо растёт, а у SortedDictionary - неожиданно падает. Хотя если подумать - слишком часто приходится перебалансировать дерево.
В общем, нюансов у этих Sorted* масса, как я уже писал: если нужна производительность - скорее всего придётся писать своё специализированное.
А, ну и да - SortedDictionary выделяет память в куче при каждом добавлении - это означает, что его не имеет особого смысла пулить/переиспользовать, а вот SortedList - стоит.
Плюс у SortedList можно установить Capacity, и он не будет выделять новые массивы при добалении элементов в рамках установленной capacity.
Ну и ещё одна мелочь - внутри у SortedList не список, а два массива - ключи и значения.
SortedList может выиграть (за счет более компактного представления в памяти) в сценарии, когда
SortedList выигрывает в любом сценарии, когда нам нужна именно отсортированная коллекция - он гораздо лучше при перечислении (плюс предоставляет доступ по индексу). SortedDictionary при каждом вызове GetEnumerator создаёт Stack и в дальнейшем его использует, что может приводить к выделению одного или более массивов в куче - в зависимости от глубины дерева.
Если же нам не нужен доступ перечислением - то нам не нужны и SortedList/SortedDictionary, логично?
Но и при добавлении и поиске в NET6/NET7 SortedList БЫЛ лучше - я поднял некоторые свои бенчмарки и обнаружил, что в NET8 SortedDictionary наконец-то стал быстрее при добавлении и поиске - приятный сюрприз, нужно будет посмотреть, что они изменили.
О чём я ещё забыл и сейчас обнаружил - SortedList тоже выделяет память в куче при каждом вызове GetEnumerator - но выделяет константный объём, 48 байтов. Не помню и не смотрел, что именно он выделяет, но это в любом случае лучше, чем Stack в SortedDictionary.
В качестве обмена опытом: в сценариях, где важна [даже относительно] высокая производительность SortedDictionary не подходит: производительность добавления ожидаемая, но на каждое добавление происходить выделение памяти, скорость поиска ожидаемо невысокая, перечисление медленное, при перечислении выделяет память.
SortedList, к слову, тоже так себе. Не помню уже кто был быстрее, кто медленнее, помоему SortedList побыстрее, но точно размещает меньше индивидуальных инстанцев в куче.
При необходимости рекомендую проанализировать сценарий и написать свой специализированный компонент.
С другой стороны, в клиентском приложении на ЮИ не на горячем пути вполне применим.
Возможно не стоит, а возможно и стоит - написание руками класса с полем и методом, повторяющим то, что сделал бы компилятор для лямбды - это разумно/упрощает/стоит того?
Я склоняюсь к тому, что универсального ответат нет, зато выбор есть.
И что из этого неправда?
Так а что имеется ввиду под "плохо переваривает долгие CPU-bound задачи"?
Вообще, долгая CPU-bound - это идеальная задача с точки зрения производительности, часто недостижимая.
О. не туда ответил, оказывается. См. ниже.
https://habr.com/ru/companies/skbkontur/articles/832742/comments/#comment_27113534
Просто утверждение было слишком общее и (как минимум поэтому) слишком некорректное.
С точки зрения перфоманса, несколько долгих задач будут гораздо эффективнее множества коротких - расходы (как процессора, так и памяти) на постановку задачи в очередь, создание стейт-машины, таска, регистрации комплишна, переключение контекста (опционально), GC, чтобы собрать весь этот мусор и ещё множества необходимых операций часто гораздо выше, чем время и ресурсы необходимые на выполнение самой полезной нагрузки.
Так что ещё раз: с точки зрения чистого перфоманса, несколько долгих задач будут гораздо выгоднее множества коротких.
А дальше, конечно, начинаются нюансы - что важнее, пропускная способность, общее время выполнения или "отзывчивость", сколько (много ли) задач нужно создать "авансом" или есть возможность организовать ограниченный буфер создаваемых задач (backpressure) и т.д., и т.п.
Но, повторюсь: с точки зрения чистой производительности главное правило такое: всё, что можно собирать в пакет и запускать как единый таск - должно собираться в пакет, а уж потом нужно смотреть, что из важных характеристик пострадало и корректировать.
А можно немного раскрыть мысль?
...а где девиртуализация - там и возможный автоматический инлайнинг на уровне JITа.
А где структуры - там и локальность данных.
Нахлынули воспоминания ;)
Ускорение здесь линейно от количества вызовов горячего пути для получения результата. И если вызовов миллионы или миллиарды - запросто.
С#.
Если вкратце, то здесь два аспекта: девиртуальзация и обход боксинг.
С девиртуализацией всё просто: void Method(IInterface strategy) и void Method<T>(T strategy) where T : IInterface в рантайме будут скомпилированы в машкод по-разному. Вариант с дженериком может быть полностью девиртуализирован JITом (зависит от конкретной имплементации T, конечно, но исходим из того, что T написан правильно с точки зрения перфоманса). JIT в .нете становится умнее и старается девиртуализировать и интерфейсы/абстрактные классы/виртуальниые методы и даже делегаты на горячем пути, но это всё эвристики и без гарантий.
Боксинг: выше я писал "структуры всюду, где только можно (и имеет смысл)". "Всюду" означает как для данных, так и для логики, отличный пример - енумераторы. Стандартный пример - енумератор класса List<>, реализован как struct. Для того, чтобы он не был boxed, вызывающий код/JIT должны знать, что это List<T>.Enumerator, а не IEnumerator<T>, возвращаемый из IEnumerable<T>.GetEnumerator().
Таким же образом реализуем "стратегии" - struct имплементации интерфейсов IStrategy. new Struct()/default(Struct) создаётся на стеке, не нагружает GC. Главное, избежать боксинга, а значит передавать не как IStrategy, а как T where T : IStrategy.
Теперь представляем себе, что в некоторые методы или в качестве генерик-аргументов типа мы должны передать несколько таких разных "T", каждый из которых имеет вложенные генерик-аргументы.
Так получаются "десятки генерик-аргументов" о которых я писал. "Десятки", наверное, перебор, но по 10+ у нас бывало.
...про кэш-лайны и локальность данных ещё забыл ;)
Да вот именно, что как раз подобные низкоуровневые хаки и были, я как увидел шапку статьи - так сразу зашел почитать, хотя от Го далёк.
Конретно у нас "хаки" были вроде девиртуализации любыми способами (отказ от интерфейсов, виртуальных методов, делегатов, десятки генерик-аргументов - правда генерики в том числе для обхода возможного боксинга), структуры всюду, где только можно (и имеет смысл) в обмен на удобство наследования и переиспользование кода, поиск оптимальных размеров структур и хаки с паддингами/выравниванием, небезопасные балк-операции прямо над памятью вместо обращению к полям/пропертям, ручной инлайнинг повсюду (привет дублированию кода), небезопасное приведение типов (практически всюду), обход проверок границ, арифметика с указателями, ин-проц и ин-мемори всего, что только возможно, практически весь горячий путь не следовал вообще никаким "хорошим паттернам и практикам", а точнее - весь был написан вопреки.
Это только первое, что пришло на ум. Хватит? ;)
Алгоритмы и архитектура (уровня классов/модулей/внутреннего дата-флоу, в первую очередь) тоже, конечно.
Доля "хаков" в приросте производительности была действительно львиная.
Да легко: финансовые/математические/физические симуляции, аггрегации портфелей индивидуальных рисков и тому подобное.
Уменьшение времени выполнения с суток до часов, а то и минут - лично наблюдал.
Или ускорение превью рана с минут до секунд(ы).
Что, если не это пример кардинального улучшения юзер экспириенса и экономии.
А в чём суть возражений?
...что-то заигрался я в бенчмарки, но тут интересное:
При добавлении заранее отсортированных элементов у SortedList скорость ожидаемо растёт, а у SortedDictionary - неожиданно падает. Хотя если подумать - слишком часто приходится перебалансировать дерево.
В общем, нюансов у этих Sorted* масса, как я уже писал: если нужна производительность - скорее всего придётся писать своё специализированное.
А, ну и да - SortedDictionary выделяет память в куче при каждом добавлении - это означает, что его не имеет особого смысла пулить/переиспользовать, а вот SortedList - стоит.
Плюс у SortedList можно установить Capacity, и он не будет выделять новые массивы при добалении элементов в рамках установленной capacity.
Ну и ещё одна мелочь - внутри у SortedList не список, а два массива - ключи и значения.
SortedList выигрывает в любом сценарии, когда нам нужна именно отсортированная коллекция - он гораздо лучше при перечислении (плюс предоставляет доступ по индексу). SortedDictionary при каждом вызове GetEnumerator создаёт Stack и в дальнейшем его использует, что может приводить к выделению одного или более массивов в куче - в зависимости от глубины дерева.
Если же нам не нужен доступ перечислением - то нам не нужны и SortedList/SortedDictionary, логично?
Но и при добавлении и поиске в NET6/NET7 SortedList БЫЛ лучше - я поднял некоторые свои бенчмарки и обнаружил, что в NET8 SortedDictionary наконец-то стал быстрее при добавлении и поиске - приятный сюрприз, нужно будет посмотреть, что они изменили.
О чём я ещё забыл и сейчас обнаружил - SortedList тоже выделяет память в куче при каждом вызове GetEnumerator - но выделяет константный объём, 48 байтов. Не помню и не смотрел, что именно он выделяет, но это в любом случае лучше, чем Stack в SortedDictionary.
Результат бенчмарка для NET8 и NET7:
Накладными расходами, очевидно.
В качестве обмена опытом: в сценариях, где важна [даже относительно] высокая производительность SortedDictionary не подходит: производительность добавления ожидаемая, но на каждое добавление происходить выделение памяти, скорость поиска ожидаемо невысокая, перечисление медленное, при перечислении выделяет память.
SortedList, к слову, тоже так себе. Не помню уже кто был быстрее, кто медленнее, помоему SortedList побыстрее, но точно размещает меньше индивидуальных инстанцев в куче.
При необходимости рекомендую проанализировать сценарий и написать свой специализированный компонент.
С другой стороны, в клиентском приложении на ЮИ не на горячем пути вполне применим.
Поддерживаю вопрос товарища @dopusteam
У меня есть несколько догадок касательно того, что имелось ввиду, но ни в одной не уверен.
А когда локальная переменная передаётся как ref - она перестаёт работать как локальная?
Для рантайма она больше не локальная переменная, но это скрыто и незаметно (пока не упираемся в особые случаи типа ref [struct]). Ну, чтож.
Возможно не стоит, а возможно и стоит - написание руками класса с полем и методом, повторяющим то, что сделал бы компилятор для лямбды - это разумно/упрощает/стоит того?
Я склоняюсь к тому, что универсального ответат нет, зато выбор есть.