Бенчмаркая Sum: ускорил циклом — замедлил в ×4,7

от автора

Уважаемые читатели, в этой статье я хочу рассказать о том, что происходит внутри values.Sum() в современном .NET — там нашлись векторные инструкции, контроль переполнения и список процессоров, которым рантайм намеренно ограничивает ширину вектора, — и представить свои выводы.

В прошлых статьях серии самописные циклы уже проигрывали BCL в поиске по строке, JIT сам выкидывал проверки границ, а foreach прятал аллокации. Тут случай интереснее: values.Sum() — это LINQ, который при оптимизации первым делом меняют на цикл.

Как оказалось, на суммировании массива LINQ обгоняет обычный for!

Не потому что цикл плохой, а потому что с .NET 8 Sum() на int и long векторизован (PR dotnet/runtime #84519, автор brantburnett, ревью tannergooding), а RyuJIT произвольный цикл не векторизует: в моих листингах SumFor остаётся скаляром на всех рантаймах вплоть до .NET 11, запрос на автовекторизацию висит с 2019 (issue #12466).

Будет 3 истории:

  • LINQ обгоняет цикл — как одна строка получила vpaddd, а обычный for остался со скалярным add, и какой получилась разница на четырёх машинах;

  • гарантии под капотом — Sum() обязан кидать OverflowException, и векторному коду приходится добавлять отдельные инструкции контроля переполнения; заодно официальный breaking change: порядок сложения изменился, и OverflowException теперь срабатывает на других данных;

  • AVX-512, который .NET прячет — почему один Xeon получает 512 бит по флагу, а второму рантайм режет ширину намеренно, и что дало снятие запрета.

Код, скрипты снятия дизасма и готовые прогоны всех машин — в репозитории LinqSumProof.

Тесты гонялись на четырёх машинах:

CPU

Ядра/потоки

Частота

Вектора

L3

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц базовых и до 4,9 в турбо

AVX2

64 МБ

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц базовых и до 5,3 в турбо

AVX2

20 МБ

№3

2 × Intel Xeon Silver 4314 (Ice Lake)

2×16 / 64

2,4 ГГц базовых и до 3,4 в турбо

AVX-512

24 МБ на сокет

№4

Intel Xeon W-2255 (Cascade Lake)

10 / 20

3,7 ГГц базовых и до 4,5 в турбо (4,7 с Turbo Boost Max 3.0)

AVX-512

19,25 МБ

История 1. LINQ обгоняет цикл

Обычный цикл:

[MethodImpl(MethodImplOptions.NoInlining)]public static int SumFor(int[] values){    int sum = 0;    for (int i = 0; i < values.Length; i++)    {        sum += values[i];    }     return sum;}

Вызов Sum():

[MethodImpl(MethodImplOptions.NoInlining)]public static int SumLinq(int[] values) => values.Sum();

Внутри Sum() с .NET 8 живёт проверка: если источник — массив или List, он превращается в ReadOnlySpan и уходит в векторный метод SumSignedIntegersVectorized (Sum.cs в исходниках рантайма). Смотрим машинный код. Цикл — скаляр, одно сложение за итерацию (здесь и дальше листинги сокращены до смысловых строк):

; SumFor, i9-10900KF, .NET 10, disasm_net10.txtG_M000_IG04:       add      eax, dword ptr [rcx]   ; sum += values[i]       add      rcx, 4                 ; следующий элемент       dec      edx       jne      SHORT G_M000_IG04      ; ни ymm, ни zmm

А внутри Sum() — вектор, восемь int за инструкцию:

; SumSignedIntegersVectorized, i9-10900KF, .NET 10       vmovups  ymm2, ymmword ptr [rax+4*rdx]   ; загрузили 8 int       vpaddd   ymm3, ymm0, ymm2                ; сложили 8 разом       vpxor    ymm0, ymm0, ymm3                ; про эти три строки -       vpxor    ymm2, ymm2, ymm3                ; во второй истории       vpand    ymm0, ymm2, ymm0

До ×4,7 за полный проход

На 65 536 элементах (данные — случайные значения 0..15 с фиксированным seed, сумма гарантированно влезает в int) цикл проигрывает Sum() на всех четырёх машинах.

Свой цикл против Sum() на 65 536 элементах: разрыв от ×2,82 до ×4,67

Свой цикл против Sum() на 65 536 элементах: разрыв от ×2,82 до ×4,67

Полная таблица по методам на примере машины №3 — Sum() держится впереди и на List, и через интерфейс:

Все методы бенчмарка на машине №3 (пара Xeon 4314), 65 536 элементов, три рантайма

Все методы бенчмарка на машине №3 (пара Xeon 4314), 65 536 элементов, три рантайма

Отдельная строка в таблице — Sum_Tensor: это TensorPrimitives.Sum из пакета System.Numerics.Tensors, тоже векторный, но без checked-семантики. К нему вернусь в выводах.

Сразу отвечу на очевидные возражения:

  • «Разверни цикл — догонишь»: развёрнутый на четыре аккумулятора SumForUnrolled в бенче есть, в листинге это всё те же скалярные add, и на 65 536 он медленнее Sum() в 1,8–2,6 раза. Правда, на массивах из 32 элементов развёрнутый скаляр Sum() обгоняет — ×0,86–0,97 на трёх машинах из четырёх: у векторного пути есть стартовая подготовка и финальная горизонтальная сумма, и на 32 элементах они заметнее самого прохода.

  • «У меня переменная типа IEnumerable, а не массив»: у Sum() проверка типа рантаймная, SumLinqInterface с тем же массивом под интерфейсом идёт вровень с прямым вызовом (×0,97–1,02). Это контраст с foreach из прошлой статьи, где всё решал тип переменной на этапе компиляции.

  • «LINQ аллоцирует»: на массиве — ноль, колонка Allocated пустая, энумератор не создаётся, работа идёт по span.

Про эволюцию рантаймов: на обоих Xeon Sum() с восьмёрки до десятки ускорился на 14–17%, на десктопах — в пределах шума.

Sum() на .NET 8 / 9 / 10: заметный прирост только на серверах

Sum() на .NET 8 / 9 / 10: заметный прирост только на серверах

Граница: итератор через yield — до ×32

Векторизация кончается там, где кончается span: TryGetSpan внутри Sum() (Sum.cs) массив под итератором не видит, и остаётся путь через энумератор — MoveNext и Current на каждый элемент, плюс 48 байт на сам энумератор при каждом вызове. Итератор (yield return по тому же массиву) — ровно этот случай:

Sum() по yield-итератору: от ×18 до ×32 к Sum() по массиву

Sum() по yield-итератору: от ×18 до ×32 к Sum() по массиву

На больших массивах разрыв цикла и Sum() сокращается по другой причине — оба упираются в память:

Разрыв по длинам: на 16 МБ массив выпадает из кэша — кроме Ryzen с его 64 МБ L3

Разрыв по длинам: на 16 МБ массив выпадает из кэша — кроме Ryzen с его 64 МБ L3

История 2. Checked и контроль переполнения

Сравнение выше на самом деле не совсем на равных: Sum() по контракту кидает OverflowException, а sum += values[i] в C# по дефолту переполняется молча. Уравниваем — checked-цикл:

[MethodImpl(MethodImplOptions.NoInlining)]public static int SumForChecked(int[] values){    int sum = 0;    for (int i = 0; i < values.Length; i++)    {        checked        {            sum += values[i];        }    }     return sum;}

В скаляре гарантия — это одна условная ветка на каждое сложение:

; SumForChecked, i9-10900KF, .NET 10       add      eax, dword ptr [rcx]       jo       SHORT G_M000_IG06      ; переполнение -> throw

На 65 536 checked-цикл идёт вровень с обычным (например, на паре Xeon 4314 — 31,6 мкс против 30,6, разница порядка 3%) и проигрывает Sum() до ×4,84. То есть отставание цикла — не плата за checked, а отсутствие векторов.

А как эту гарантию выполняет векторный код? У vpaddd нет флага переполнения для отдельных элементов вектора. Поэтому рядом с вектором-счётчиком BCL держит второй вектор — контроль переполнения: те самые три строки из листинга первой истории. После каждого сложения туда по знаковым битам записывается, не сменился ли знак там, где не должен был (сложили два положительных — получили отрицательное).

Проверка выполняется в конце каждой итерации развёрнутого цикла — раз на четыре вектора — и ещё раз после хвостовых векторов: было переполнение хотя бы в одной позиции — OverflowException. Финальное сложение позиций аккумулятора в одно число тоже идёт в checked, поэлементно.

Breaking change: то же исключение, другие места

У векторного сложения другой порядок: элементы распределяются по позициям вектора, и промежуточные суммы копятся не подряд по массиву, а по этим позициям.

Официально задокументировано как breaking change .NET 8: OverflowException может сработать там, где на .NET 7 молчал, и наоборот (обсуждение — issue #92230). Живое демо лежит в репо (Disasm.exe overflow): массив из 128 пар (int.MaxValue, int.MinValue). Последовательная checked-сумма держится около нуля и возвращает −128.

Векторный Sum() раскладывает элементы по позициям вектора, все int.MaxValue попадают в одни и те же позиции — и уже вторая итерация складывает MaxValue с MaxValue:

SumForChecked: -128Sum():         OverflowException

Контрольный запуск с DOTNET_EnableHWIntrinsic=0 (официальная переменная, отключающая векторизацию) возвращает скалярный путь — и исключение исчезает, Sum() тоже отвечает −128. Если в проде есть код, который ловит OverflowException от Sum() как сигнал — после апгрейда на .NET 8+ исключение может возникать на других данных.

История 3. AVX-512, который .NET прячет

Оба сервера умеют AVX-512, но .NET 10 видит их по-разному — вывод утилиты Disasm из репо:

; 2 x Xeon 4314 (Ice Lake)           ; Xeon W-2255 (Cascade Lake)Avx512BW:                  True      Avx512BW:                  TrueVector<int>.Count:         8         Vector<int>.Count:         8Vector512.IsHardwareAccelerated:     Vector512.IsHardwareAccelerated:                           True                                 False

Sum() внутри работает на Vector<T>, а его ширина по дефолту — 256 бит даже при живом AVX-512. Переменная DOTNET_MaxVectorTBitWidth=512 (issue #104978 — именно она управляет шириной Vector<T>) на Ice Lake реально раздвигает вектор: Count становится 16, в листинге Sum() появляется zmm, и на 65 536 это минус 14% времени (6,55 → 5,63 мкс). А на Cascade Lake — ничего: Count как был 8, так и остался, листинг на ymm.

Это осознанное решение: в рантайм зашита CPUID-проверка, которая ставит флаг CORJIT_FLAG_VECTOR512_THROTTLING для Skylake Server, Cascade Lake и Cooper Lake (issue #88233, код пришёл в PR #86655).

Причина — в официальном разборе intrinsics в .NET 8: на этих семействах 512-битные инструкции заметно снижают частоту ядра, поэтому Vector512.IsHardwareAccelerated там по дефолту false — со ссылкой на Intel Optimization Reference Manual, раздел 2.5.3 Skylake Server Power Management. Ice Lake под эту проверку не попадает: там снижение частоты от 512-битных инструкций значительно меньше.

Снимаем запрет: +38% на Cascade Lake

Запрет обходится второй переменной: DOTNET_PreferredVectorBitWidth=512. С парой переменных W-2255 наконец показывает Count = 16, и Sum() пересаживается на zmm — причём контроль переполнения на AVX-512 стал короче: из тройки vpxor/vpxor/vpand на каждое сложение остаются две инструкции — vpxord и vpternlogd, которая объединяет второй ксор и vpand в одну операцию (тернарная логика, три операнда за инструкцию):

; SumSignedIntegersVectorized, Xeon W-2255, .NET 10,; DOTNET_MaxVectorTBitWidth=512 + DOTNET_PreferredVectorBitWidth=512       vmovups  zmm2, zmmword ptr [rax+4*rdx]   ; 16 int разом       vpaddd   zmm3, zmm0, zmm2       vpxord   zmm2, zmm2, zmm3       vpternlogd zmm0, zmm2, zmm3, 72          ; второй ксор и vpand одной инструкцией

Цифры: 5,21 мкс в дефолте → 3,78 мкс с принудительным zmm, в 1,38 раза быстрее. Средний столбец — контроль: одна MaxVectorTBitWidth на Cascade Lake ширину не меняет, и время не меняется тоже.

Xeon W-2255: дефолт, полумера и снятый запрет — минус 27% времени у Sum()

Xeon W-2255: дефолт, полумера и снятый запрет — минус 27% времени у Sum()

Два уточнения. Первое: на .NET 8 это не работает вообще — Vector<T> шире 256 бит восьмёрка не умеет ни с какими флагами (проверено на обеих машинах: Count = 8, листинг на ymm; поддержку широкого Vector<T> дорабатывали уже позже — PR #111472).

Второе: мой бенчмарк выполняет 512-битные инструкции непрерывно, а снижение частоты на Cascade Lake сильнее всего проявляется в смешанном коде — после 512-битных инструкций ядро какое-то время работает на пониженной частоте, и это затрагивает весь код, который выполняется следом. Поэтому +38% на изолированном векторном методе — измеренный факт, а включать переменную для всего приложения имеет смысл только после замера полной нагрузки. Дефолт .NET выбран именно из этих соображений.

Выводы

По цифрам:

  • свой for вместо Sum() на массиве или List — до ×4,67 на полном проходе (65 536 элементов), и checked тут ни при чём: checked-цикл отстаёт так же, до ×4,84;

  • Sum() по yield-итератору поверх готовых данных — до ×32 плюс 48 байт аллокаций на вызов;

  • код, который ловит OverflowException от Sum(), после .NET 8 может возникать на других данных — изменение порядка сложения задокументировано как breaking change;

  • на Skylake Server / Cascade Lake / Cooper Lake AVX-512 в Vector<T>-путях BCL не используется: рантайм держит 256 бит из-за снижения частоты.

Что делать на практике:

  • суммы, минимумы, максимумы по массивам и спискам — отдавать BCL: Sum() быстрее самописного цикла и не аллоцирует; переписывать LINQ на циклы «ради скорости» здесь — ухудшение;

  • если данные уже в массиве — не заворачивать их в yield-итераторы перед агрегацией;

  • горячим участкам без требования OverflowException — TensorPrimitives.Sum (быстрее Sum() в 1,7–3,2 раза на 65 536: без контроля переполнения, но и без исключения — переполнение молча заворачивается) или свой Vector256 (в 1,3–1,9 раза) с тем же ограничением;

  • float — отдельный случай: Sum() на float не векторизован, потому что порядок сложения менять нельзя (итог у float зависит от порядка). Выигрыша от Sum() здесь нет — на длинных массивах он идёт вровень со скалярным циклом (×1,01–1,14), так что выбор между циклом и Sum() для float — вопрос читабельности, не скорости;

  • на Cascade Lake переменные DOTNET_MaxVectorTBitWidth=512 и DOTNET_PreferredVectorBitWidth=512 включают 512-битный путь и дают измеренные +38% на векторном методе; включать их для всего приложения имеет смысл только после замера полной нагрузки — причины в третьей истории.

Код из статьи

  • LinqSumProof — бенчмарк (Sum() против циклов, checked, yield-итератор, свой Vector256, TensorPrimitives, ширина Vector, четыре длины, net8/9/10) и снятие дизасма

Ссылки

Всем удачи и до новых встреч!

PS. Если у вас есть свои замеры Sum против циклов — особенно на Cascade Lake со снятым запретом под реальной смешанной нагрузкой — поделитесь в комментах.

ссылка на оригинал статьи https://habr.com/ru/articles/1060470/