Уважаемые читатели, в этой статье я хочу рассказать о том, что происходит внутри values.Sum() в современном .NET — там нашлись векторные инструкции, контроль переполнения и список процессоров, которым рантайм намеренно ограничивает ширину вектора, — и представить свои выводы.В прошлых статьях серии самописные циклы уже проигрывали BCL в поиске по строке, JIT сам выкидывал проверки границ, а foreach прятал аллокации. Тут случай интереснее: values.Sum() — это LINQ, который при оптимизации первым делом меняют на цикл. Читать далее
Уважаемые читатели, в этой статье я хочу рассказать о том, что происходит внутри 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 МБ |
Обычный цикл:
[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.txt
G_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
Полная таблица по методам на примере машины №3 — Sum() держится впереди и на List, и через интерфейс:

Все методы бенчмарка на машине №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: заметный прирост только на серверах
Граница: итератор через yield — до ×32Векторизация кончается там, где кончается span: TryGetSpan внутри Sum() (Sum.cs) массив под итератором не видит, и остаётся путь через энумератор — MoveNext и Current на каждый элемент, плюс 48 байт на сам энумератор при каждом вызове. Итератор (yield return по тому же массиву) — ровно этот случай:

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

Разрыв по длинам: на 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: -128
Sum(): 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: True
Vector<int>.Count: 8 Vector<int>.Count: 8
Vector512.IsHardwareAccelerated: Vector512.IsHardwareAccelerated:
True FalseSum() внутри работает на 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()
Два уточнения. Первое: на .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 со снятым запретом под реальной смешанной нагрузкой — поделитесь в комментах.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
12.5%for вместо Sum() — переписывал ради скорости1
0%Массив, завёрнутый в IEnumerable перед Sum()0
0%Логика, завязанная на OverflowException от Sum()0
0%Свой SIMD для агрегатов0
50%Ничего из списка: агрегаты на BCL4
37.5%Пошёл проверять свой код3
Проголосовали 8 пользователей. Воздержался 1 пользователь.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Бенчмаркая проверки границ: фикс, который шёл 8 лет, и проверка, которая жива до сих пор | 0 | 11.86 | 14-07-2026 |
| 2 | Бенчмаркая регресс LINQ: обещали −19%, на четырёх машинах намерил +31% | 0 | 14.72 | 18-07-2026 |
| 3 | Бенчмаркая foreach и энумераторы: аллокации, которые прятались 10 лет | 0 | 9.45 | 11-07-2026 |
| 4 | Ленивый LINQ: разбираем yield и ленивые вычисления по кирпичикам | 0 | 7.44 | 21-07-2026 |
| 5 | Бенчмаркая поиск по строке: самописные циклы проигрывают от ×14 до ×154 | 0 | 8.91 | 15-07-2026 |
| 6 | Ленивый LINQ: разбираем yield и ленивые вычисления по кирпичикам. Часть 2 | 1 | 7.52 | 24-07-2026 |
| 7 | [Перевод] Паттерны доступа к данным, которые выбесят ваш процессор | 0 | 7 | 03-07-2026 |
| 8 | Я ненавижу AutoMapper | 0 | 7.7 | 02-07-2026 |
| 9 | Как стримить данные в ASP.NET и как их принять | 0 | 8.03 | 30-06-2026 |
| 10 | In-memory база врёт: 5 расхождений с продовой БД | 0 | 7 | 07-07-2026 |