Start Debugging

Цикл по ref T против индексации Span<T> в C#: почему версия со span не медленнее, а часто быстрее в .NET 10

В .NET 10 обычный цикл for по span[i] с границей span.Length компилируется в цикл без проверки границ, со снижением стоимости операций (strength reduction) и обратным отсчётом. Написанный вручную цикл с Unsafe.Add(ref r, i) и индексом int сохраняет расширение знака и сравнение при счёте вверх и в замерах оказался на 2-5% медленнее. Бенчмарки и дизассемблер JIT для arm64 и x64 на .NET 10.0.10.

В .NET 10 пишите цикл как for (int i = 0; i < span.Length; i++) sum += span[i]; и на этом останавливайтесь. JIT доказывает, что каждый индекс в допустимом диапазоне, убирает проверку границ, переписывает индекс в байтовое смещение, которое увеличивается на 4 на каждой итерации (strength reduction), и превращает цикл в обратный счётчик, заканчивающийся одной парой dec/jne (или sub/cbnz на Arm64). “Более быстрая” версия, написанная вручную, sum += Unsafe.Add(ref r, i) с индексом int, такой обработки не получает. Она сохраняет счётчик int, на каждой итерации расширяет его со знаком до машинного размера смещения и сравнивает с длиной в конце цикла. В моих замерах на .NET 10.0.10 это сделало цикл с Unsafe.Add на 2.4% медленнее при 4,096 элементах и на 3.6% медленнее при 1,000,000 элементов, чем безопасный цикл по span. Если перевести счётчик ref-цикла на nint, разрыв полностью исчезает, а обход со сдвигом указателя в ref сравнивается со span-циклом. Так что код с ref T в .NET 10 может только сравняться со span-циклом. Выигрыша он не даёт, а безопасность памяти отбирает.

Всё ниже измерено с BenchmarkDotNet 0.15.8 на .NET 10.0.10 (SDK 10.0.302), C# 14, на Apple M4 под macOS 27.0.1. Листинги для Arm64 это реальный код Tier1 из этого запуска, полученный через DOTNET_JitDisasm. Листинги для x64 получены из crossgen2 10.0.10 с целью win-x64: он использует тот же RyuJIT с полными оптимизациями, но без Dynamic PGO. Оптимизации циклов, которые объясняют результат, появились в .NET 9, поэтому всё сказанное здесь верно и для .NET 9.

Сравнение в одной таблице

Форма циклаПроверка границ в циклеАрифметика индекса на итерацию4,096 int1,000,000 int
for по array[i]УбранаСмещение со strength reduction, обратный отсчёт1,035.6 ns251.9 us
for по span[i], граница span.LengthУбранаСмещение со strength reduction, обратный отсчёт1,035.9 ns254.4 us
foreach (ref readonly int x in span)УбранаТо же, что выше1,035.6 ns254.5 us
for по span[i], граница это параметр nВынесена из цикла клонированием циклаТо же, что выше, в быстром клоне1,035.4 ns254.5 us
Unsafe.Add(ref r, i), int iНет (её и не было)Расширение знака + сравнение при счёте вверх1,060.1 ns263.7 us
Unsafe.Add(ref r, i), nint iНетStrength reduction, обратный отсчёт1,036.1 ns254.7 us
Обход ref с Unsafe.IsAddressLessThanНетУказатель с постинкрементом1,035.3 ns251.4 us
Enumerable.Sum(int[])н/дВекторизовано506.9 ns128.6 us

Каждый цикл суммирует int[]. Погрешности везде меньше 1.5 ns при 4,096 и меньше 0.8 us при миллионе. Строки со span и ref я прогнал повторно в отдельной сессии BenchmarkDotNet, и все средние значения совпали с первым запуском в пределах 0.6 ns (4,096) и 0.1 us (миллион). Последняя строка нужна для масштаба: самый быстрый скалярный цикл всё равно вдвое медленнее векторизованного.

Два цикла, которые обычно сравнивают

Вопрос обычно возникает после того, как человек прочитал исходники BCL, увидел повсюду ref T и Unsafe.Add и переписал горячий цикл по их образцу. Вот две формы рядом:

// .NET 10.0.10, C# 14
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

static int SpanFor(ReadOnlySpan<int> s)
{
    int sum = 0;
    for (int i = 0; i < s.Length; i++) sum += s[i];
    return sum;
}

static int RefAdd(ReadOnlySpan<int> s)
{
    int sum = 0;
    ref int r = ref MemoryMarshal.GetReference(s);
    for (int i = 0; i < s.Length; i++) sum += Unsafe.Add(ref r, i);
    return sum;
}

Логика RefAdd такова: у s[i] есть проверка границ, у Unsafe.Add нет, значит версия с ref должна быть быстрее. В .NET Core 2.1 эта логика часто была верной. С тех пор JIT научился убирать проверку границ именно в такой форме цикла, а в .NET 9 освоил ещё три приёма, которые работают, только когда он полностью понимает переменную индекса. Цикл по span получает их все. Цикл с Unsafe.Add под шаблон не подпадает.

Что JIT делает со span-циклом

Вот код Tier1 для Arm64 для SpanFor, прямо из DOTNET_JitDisasm на .NET 10.0.10:

; Kernels:SpanFor(System.ReadOnlySpan`1[int]):int (Tier1), .NET 10.0.10 Arm64
            mov     w2, wzr            ; sum = 0
            cmp     w1, #0             ; length <= 0 ?
            ble     G_M000_IG05
            mov     x3, xzr            ; byte offset = 0
G_M000_IG04:
            ldr     w4, [x0, x3]       ; load s[i]
            add     w2, w4, w2         ; sum += ...
            add     x3, x3, #4         ; offset += 4
            sub     w1, w1, #1         ; remaining--
            cbnz    w1, G_M000_IG04    ; loop while remaining != 0

В этом цикле видны три отдельные оптимизации:

  1. Устранение проверки границ. Нет ни сравнения с длиной, ни вызова CORINFO_HELP_RNGCHKFAIL. Поскольку i начинается с 0, только растёт и ограничен условием i < s.Length, анализ диапазонов в JIT доказывает, что s[i] всегда допустим. Это та часть, которую знают все, и для span она работает с .NET Core 2.1.
  2. Strength reduction. В исходном коде i умножается на 4, чтобы получить адрес. Цикл же держит байтовое смещение в x3 и прибавляет к нему 4. В заметках о среде выполнения .NET 9 это описано как “автоматическое” преобразование индексного доступа в такую форму.
  3. Направление счётчика цикла. Исходного i больше нет. Когда адрес перестаёт зависеть от i, счётчик управляет только числом итераций, поэтому JIT считает длину вниз до нуля и завершает цикл через cbnz вместо отдельных сравнения и перехода.

На x64 тот же метод компилируется в четыре инструкции в теле цикла:

; Kernels:SpanFor, crossgen2 10.0.10, win-x64, full opts
G_M000_IG04:
       add      edx, dword ptr [rax+r8]
       add      r8, 4
       dec      ecx
       jne      SHORT G_M000_IG04

Это примерно настолько плотно, насколько вообще может быть скалярный цикл суммирования. Не осталось ни одной инструкции, которую мог бы убрать переписанный на ref вариант.

Что JIT делает с циклом на Unsafe.Add

Теперь RefAdd, та же машина, тот же запуск:

; Kernels:RefAdd(System.ReadOnlySpan`1[int]):int (Tier1), .NET 10.0.10 Arm64
G_M000_IG03:
            ldr     w4, [x0, w3, SXTW #2]   ; load r[(nint)i * 4]
            add     w2, w4, w2
            add     w3, w3, #1              ; i++
            cmp     w3, w1                  ; i < length ?
            blt     G_M000_IG03
; Kernels:RefAdd, crossgen2 10.0.10, win-x64, full opts
G_M000_IG05:
       movsxd   r8, ecx                     ; sign-extend i to 64 bits
       add      esi, dword ptr [rax+4*r8]
       inc      ecx
       cmp      ecx, edx
       jl       SHORT G_M000_IG05

В цикле нет проверки границ, но в span-цикле её тоже не было, так что это ничего не даёт. Потеряны же strength reduction и обратный отсчёт. Unsafe.Add<T>(ref T source, int elementOffset) преобразует смещение int в машинное целое до масштабирования. Это расширение знака стоит между индуктивной переменной и адресом, и JIT через него не переписывает. Поэтому цикл хранит i как 32-битный счётчик, заново вычисляет по нему адрес на каждой итерации (SXTW #2 на Arm64, явный movsxd на x64) и нуждается в настоящем сравнении с длиной в конце.

На Arm64 расширение знака свёрнуто в режим адресации загрузки, так что число инструкций совпадает со span-циклом. Тем не менее он стабильно оказывался медленнее в обоих запусках. Видимое отличие это выход из цикла: явный cmp увеличенного счётчика с длиной вместо объединённого cbnz обратного счётчика. Глубже уровня инструкций я не профилировал, поэтому считайте это вероятной причиной, а не доказанной. На x64 просто одна лишняя инструкция на элемент. Ни та, ни другая разница не велика, и в этом суть: версия, написанная вручную, стоит вам безопасности и всё равно проигрывает несколько процентов.

Как исправить ref-цикл, если он действительно нужен

Есть два способа написать ref-цикл, равный span-циклу. Первый: сделать индекс машинного размера, чтобы расширять со знаком было нечего:

// .NET 10.0.10, C# 14
static int RefAddNint(ReadOnlySpan<int> s)
{
    int sum = 0;
    ref int r = ref MemoryMarshal.GetReference(s);
    for (nint i = 0; i < s.Length; i++) sum += Unsafe.Add(ref r, i);
    return sum;
}

Код Tier1 для Arm64 здесь инструкция в инструкцию совпадает с SpanFor (ldr w4, [x0, x3], add x3, x3, #4, sub x1, x1, #1, cbnz), а в бенчмарке получилось 1,036.1 ns против 1,035.7 ns у span-цикла. Вывод crossgen2 для x64 тоже даёт то же тело из четырёх инструкций.

Второй: обход со сдвигом указателя, который используется во множестве старого высокопроизводительного кода:

// .NET 10.0.10, C# 14
static int RefWalk(ReadOnlySpan<int> s)
{
    int sum = 0;
    ref int p = ref MemoryMarshal.GetReference(s);
    ref int end = ref Unsafe.Add(ref p, s.Length);
    while (Unsafe.IsAddressLessThan(ref p, ref end))
    {
        sum += p;
        p = ref Unsafe.Add(ref p, 1);
    }
    return sum;
}

На Arm64 он использует постиндексную адресацию (ldr w3, [x0], #0x04, загрузка и сдвиг одной инструкцией), так что тело цикла состоит из четырёх инструкций вместо пяти. Это была самая быстрая строка при миллионе элементов, примерно на 1%, что укладывается в шум, который вы увидите между двумя разными машинами. Это лучший случай для написанного вручную ref-кода в .NET 10: ничья с разницей в 1% в обмен на код, где ошибка на единицу читает или пишет память за пределами span без всякого исключения. Заметьте также, что end это управляемая ссылка на позицию на один элемент за концом буфера. Рекомендации по небезопасному коду говорят не создавать недопустимые управляемые указатели, даже если вы их никогда не разыменовываете, а точные правила допустимости оставляют ECMA-335. Оказываться в этом углу ради выигрыша в 1% не стоит.

Если вы пишете foreach (ref readonly int x in span), потому что слышали, что это “ref-цикл”, он компилируется в тот же код, что и индексный span-цикл. Компилятор C# преобразует foreach по span в индексный for по span, так что JIT видит ту же форму.

Где span-цикл теряет преимущество

Span-цикл быстр, потому что JIT может доказать о нём нужные факты. Нарушьте доказательство, и форма цикла изменится. Самый частый способ: ограничить цикл чем-то, кроме span.Length:

// .NET 10.0.10, C# 14
static int SpanForN(ReadOnlySpan<int> s, int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++) sum += s[i];   // n is not s.Length
    return sum;
}

Теперь s[i] действительно может выйти за границы. JIT на Tier1 справляется с этим клонированием цикла: он выдаёт проверку n <= s.Length перед циклом, быстрый клон без проверки границ на случай, когда она выполняется, и медленный клон, который проверяет каждую итерацию и бросает IndexOutOfRangeException на первом же неверном индексе. Быстрый клон в моём листинге для Arm64 идентичен SpanFor, поэтому строка в бенчмарке и совпадает. Вывод crossgen2 для x64 для того же метода не клонировал цикл и оставил в нём cmp/jae. Так что если такой метод выполняется из кода ReadyToRun, пока его не заменит ярусная компиляция, проверку он оплачивает. Решение: самому дать JIT доказательство, s = s[..n]; перед циклом, а затем идти до s.Length. Срез делает одну проверку границ заранее, и каждая последующая итерация обходится бесплатно.

Тот же приём важен, когда участвуют два span:

// .NET 10.0.10, C# 14
static void CopyX2(ReadOnlySpan<int> src, Span<int> dst)
{
    dst = dst[..src.Length];                    // one check here...
    for (int i = 0; i < src.Length; i++)
        dst[i] = src[i] * 2;                    // ...none in the loop
}

Без среза dst[i] не покрывается условием i < src.Length. Tier1 клонировал и этот цикл (cmp w1, w3 / bgt перед входом выбирает копирование с проверками или без), поэтому все три варианта (без среза, со срезом и вариант с Unsafe.Add и ручной проверкой длины) при 4,096 элементах уложились в 1% друг от друга. При 64 элементах версия с Unsafe.Add и предварительной проверкой с ArgumentException была самой медленной из трёх: 20.1 ns против 16.8 ns у span-цикла со срезом. Срез всё равно стоит писать, потому что он не зависит от эвристик клонирования и говорит о том, что требуется методу.

Если вы хотите увидеть проверки сами, предыдущая статья об устранении проверок границ при доступе с индексом с конца в .NET 11 показывает, как читать CORINFO_HELP_RNGCHKFAIL в листинге, а просмотрщик ASM в Rider показывает тот же код без переменных окружения.

Как проверить это на своём коде

Собственные рекомендации Microsoft в рекомендациях по небезопасному коду на этот счёт прямолинейны: выгоды от небезопасного устранения проверок границ “часто временны”, так что проверьте, нужно ли это ещё текущему JIT, и перепишите на безопасный код, если нет. Для проверки достаточно двух команд:

# .NET 10 SDK, any OS
DOTNET_JitDisasm="SpanFor RefAdd" DOTNET_JitStdOutFile=jit.txt dotnet run -c Release
dotnet run -c Release -- --filter '*SpanFor*' '*RefAdd*'   # BenchmarkDotNet

Что будет вводить вас в заблуждение по ходу дела:

Что выбрать

Берите по умолчанию индексный цикл по span (или массиву) с границей .Length. Он безопасен, именно под него настроен оптимизатор циклов JIT, и в .NET 10 ни в одном из этих замеров он не медленнее вариантов с ref. Когда граница другая, сначала сделайте срез, чтобы граница стала .Length.

К ref T и Unsafe.Add обращайтесь, только когда у вас есть листинг, показывающий проверку границ, которую JIT не может убрать, и бенчмарк, показывающий, что эта проверка важна. Тогда используйте индекс nint или обход указателем, а не индекс int, иначе вы потеряете strength reduction, которую span-цикл получил бесплатно. Если вы принимаете решения о ref и срезах для всего API, List<T> против Span<T> против ReadOnlySpan<T> рассматривает, какой тип принимать в первую очередь, что такое Span<T> и когда он ускоряет код описывает два других места, где span действительно окупается, а обход двух коллекций синхронно без аллокаций применяет приём “срез для доказательства границ” к циклу с двумя входами.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад