Просто утверждение было слишком общее и (как минимум поэтому) слишком некорректное.
С точки зрения перфоманса, несколько долгих задач будут гораздо эффективнее множества коротких - расходы (как процессора, так и памяти) на постановку задачи в очередь, создание стейт-машины, таска, регистрации комплишна, переключение контекста (опционально), 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 побыстрее, но точно размещает меньше индивидуальных инстанцев в куче.
При необходимости рекомендую проанализировать сценарий и написать свой специализированный компонент.
С другой стороны, в клиентском приложении на ЮИ не на горячем пути вполне применим.
Возможно не стоит, а возможно и стоит - написание руками класса с полем и методом, повторяющим то, что сделал бы компилятор для лямбды - это разумно/упрощает/стоит того?
Я склоняюсь к тому, что универсального ответат нет, зато выбор есть.
Про замыкания, ну бокс бог с ним - не будем называть это прямо "боксингом"
Не "не будем", а "нельзя".
Боксинг - хоть в .НЕТ, хоть в абстрактном CS-определении - это не "копировние из стека в кучу" и кроме аллокации тянет за собой и вычислительные расходы.
В случае с захватом этого не происходит.
Более того, и самой локальной переменной на стеке вообще [в большинстве случаев] не окажется, она сразу будет определена в классе, созданном компилятором для лямбды.
но факт, что (возможно, нежелательное) копирование из стека в кучу при замыкании вполне возможно
Не "вполне возможно", а обязательно произойдёт, бай дизайн, так сказать.
When a callvirtmethod instruction has been prefixed by constrainedthisType, the instruction is executed as follows:
If thisType is a reference type (as opposed to a value type) then ptr is dereferenced and passed as the 'this' pointer to the callvirt of method.
If thisType is a value type and thisType implements method then ptr is passed unmodified as the 'this' pointer to a callmethod instruction, for the implementation of method by thisType.
If thisType is a value type and thisType does not implement method then ptr is dereferenced, boxed, and passed as the 'this' pointer to the callvirtmethod instruction.
This last case can occur only when method was defined on Object, ValueType, or Enum and not overridden by thisType. In this case, the boxing causes a copy of the original object to be made. However, because none of the methods of Object, ValueType, and Enum modify the state of the object, this fact cannot be detected.
Но ок, box в IL действительно нет, спасибо за напоминание.
О. не туда ответил, оказывается. См. ниже.
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]). Ну, чтож.
Возможно не стоит, а возможно и стоит - написание руками класса с полем и методом, повторяющим то, что сделал бы компилятор для лямбды - это разумно/упрощает/стоит того?
Я склоняюсь к тому, что универсального ответат нет, зато выбор есть.
Не "не будем", а "нельзя".
Боксинг - хоть в .НЕТ, хоть в абстрактном CS-определении - это не "копировние из стека в кучу" и кроме аллокации тянет за собой и вычислительные расходы.
В случае с захватом этого не происходит.
Более того, и самой локальной переменной на стеке вообще [в большинстве случаев] не окажется, она сразу будет определена в классе, созданном компилятором для лямбды.
Не "вполне возможно", а обязательно произойдёт, бай дизайн, так сказать.
Ноль вопросов, никаких личностей, просто я (лично) считаю, что учиться никогда не поздно и не зазорно, а иногда ещё и нужно ;)
Вообще, не сказал бы, что прям неявно:
https://learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes.constrained?view=net-8.0&redirectedfrom=MSDN
Но ок, box в IL действительно нет, спасибо за напоминание.
Тем не менее, что насчёт обсуждаемого случая?
Есть там цитата?