Уважаемые читатели, в этой статье я хочу рассказать о том, что происходит внутри 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№1AMD Ryzen 9 5950X16 / 323,4 ГГц базовых и до 4,9 в турбоAVX264 МБ№2Intel Core i9-10900KF10 / 203,7 ГГц базовых и до 5,3 в турбоAVX220 МБ№32 × Intel Xeon Silver 4314 (Ice Lake)2×16 / 642,4 ГГц базовых и до 3,4 в турбоAVX-51224 МБ на сокет№4Intel Xeon W-2255 (Cascade Lake)10 / 203,7 ГГц базовых и до 4,5 в турбо (4,7 с Turbo Boost Max 3.0)AVX-51219,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Полная таблица по методам на примере машины №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: -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.Count: 8 Vector.Count: 8Vector512.IsHardwareAccelerated: Vector512.IsHardwareAccelerated: True FalseSum() внутри работает на Vector, а его ширина по дефолту — 256 бит даже при живом AVX-512. Переменная DOTNET_MaxVectorTBitWidth=512 (issue #104978 — именно она управляет шириной Vector) на 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 в одну...