Цикл по 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 int | 1,000,000 int |
|---|---|---|---|---|
for по array[i] | Убрана | Смещение со strength reduction, обратный отсчёт | 1,035.6 ns | 251.9 us |
for по span[i], граница span.Length | Убрана | Смещение со strength reduction, обратный отсчёт | 1,035.9 ns | 254.4 us |
foreach (ref readonly int x in span) | Убрана | То же, что выше | 1,035.6 ns | 254.5 us |
for по span[i], граница это параметр n | Вынесена из цикла клонированием цикла | То же, что выше, в быстром клоне | 1,035.4 ns | 254.5 us |
Unsafe.Add(ref r, i), int i | Нет (её и не было) | Расширение знака + сравнение при счёте вверх | 1,060.1 ns | 263.7 us |
Unsafe.Add(ref r, i), nint i | Нет | Strength reduction, обратный отсчёт | 1,036.1 ns | 254.7 us |
Обход ref с Unsafe.IsAddressLessThan | Нет | Указатель с постинкрементом | 1,035.3 ns | 251.4 us |
Enumerable.Sum(int[]) | н/д | Векторизовано | 506.9 ns | 128.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
В этом цикле видны три отдельные оптимизации:
- Устранение проверки границ. Нет ни сравнения с длиной, ни вызова
CORINFO_HELP_RNGCHKFAIL. Посколькуiначинается с 0, только растёт и ограничен условиемi < s.Length, анализ диапазонов в JIT доказывает, чтоs[i]всегда допустим. Это та часть, которую знают все, и для span она работает с .NET Core 2.1. - Strength reduction. В исходном коде
iумножается на 4, чтобы получить адрес. Цикл же держит байтовое смещение вx3и прибавляет к нему 4. В заметках о среде выполнения .NET 9 это описано как “автоматическое” преобразование индексного доступа в такую форму. - Направление счётчика цикла. Исходного
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
Что будет вводить вас в заблуждение по ходу дела:
- Код Tier0. Первый листинг, выведенный для метода, это неоптимизированный код Tier0, где
s[i]это настоящий вызов с настоящей проверкой. Ищите листинг с пометкойTier1. Статья о том, как работает ярусная компиляция, объясняет, когда выполняется каждый ярус и что OSR делает с долго работающими циклами. - Листинги ReadyToRun и crossgen2. В предкомпилированном коде нет PGO, и он может сохранять вызовы, которые Tier1 встраивает. В моих листингах для x64
MemoryMarshal.GetReferenceвызывается не встроенно, скорее всего потому, что crossgen2 не встраивает через границу пузыря версий вSystem.Private.CoreLib. Тела циклов по-прежнему репрезентативны, а пролог нет. - Микробенчмарки, скрывающие цикл. Пометьте ядро
[MethodImpl(MethodImplOptions.NoInlining)], чтобы JIT компилировал его как отдельный метод, и читайте листинг этого метода, а не обёртки бенчмарка. - Неверная база сравнения. Если настоящая цель это пропускная способность, сравнивайте с векторизованной версией.
Enumerable.Sumнаint[]работал вдвое быстрее любого скалярного цикла здесь, аVector<T>илиTensorPrimitivesприводят к этому же для пользовательских операций.
Что выбрать
Берите по умолчанию индексный цикл по 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 действительно окупается, а обход двух коллекций синхронно без аллокаций применяет приём “срез для доказательства границ” к циклу с двумя входами.
Источники
- What’s new in the .NET 9 runtime: loop optimizations (расширение индуктивной переменной, постиндексная адресация, strength reduction, направление счётчика цикла)
- What’s new in the .NET 10 runtime (улучшенная инверсия циклов, от которой зависят клонирование циклов и оптимизации индуктивных переменных)
- Unsafe code best practices (раздел 9, небезопасное устранение проверок границ; раздел 4, недопустимые управляемые указатели)
- Справочник по API класса
Unsafe - BenchmarkDotNet
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.