Уважаемые читатели, в этой статье я хочу рассказать о том, что происходит внутри 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() на всех четырёх машинах.
Полная таблица по методам на примере машины №3 — Sum() держится впереди и на List, и через интерфейс:
Отдельная строка в таблице — 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%, на десктопах — в пределах шума.
Граница: итератор через yield — до ×32
Векторизация кончается там, где кончается span: TryGetSpan внутри Sum() (Sum.cs) массив под итератором не видит, и остаётся путь через энумератор — MoveNext и Current на каждый элемент, плюс 48 байт на сам энумератор при каждом вызове. Итератор (yield return по тому же массиву) — ровно этот случай:
На больших массивах разрыв цикла и Sum() сокращается по другой причине — оба упираются в память:
История 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 ширину не меняет, и время не меняется тоже.
Два уточнения. Первое: на .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) и снятие дизасма
Ссылки
-
Репозиторий статьи: LinqSumProof — код, дизасм, готовые прогоны
-
PR #84519 — векторизация Enumerable.Sum для int/long (.NET 8)
-
Sum.cs — SumSignedIntegersVectorized с контролем переполнения
-
Breaking change .NET 8: порядок сложения в Sum и OverflowException
-
Issue #92230 — обсуждение изменившегося поведения переполнения
-
Hardware Intrinsics in .NET 8 — Vector512, даунклок и CPUID-проверка
-
Issue #104978 — DOTNET_MaxVectorTBitWidth управляет шириной Vector<T>
Всем удачи и до новых встреч!
PS. Если у вас есть свои замеры Sum против циклов — особенно на Cascade Lake со снятым запретом под реальной смешанной нагрузкой — поделитесь в комментах.
ссылка на оригинал статьи https://habr.com/ru/articles/1060470/