Уважаемые читатели, в этой статье я хочу рассказать о проверках границ массива и представить свои выводы. Каждое обращение arr[i] обязано проверить индекс, иначе IndexOutOfRangeException — а JIT старается эти проверки убирать, когда может доказать, что выход за границы невозможен. Я взял несколько обычных циклов и посмотрел в машинный код на .net 8, 9, 10 и 11 одновременно. Результат разложился по версиям так, как я не ожидал.

Будет три истории:

  • Проверки границ умирают поколениями: один цикл JIT дожал в .NET 9, ещё один — в .NET 10, а обход массива с конца — из issue, открытого в 2017 году — дожали только сейчас, в .NET 11. Всё сверим с вмерженными PR.

  • Один цикл эти чистки пережил. Он отличается от классического for только оператором в условии — и таскает проверку на каждой итерации даже на .NET 11.

  • Сколько мы на этом теряем. Тут меня ждали два сюрприза: проверка, которая есть в бинарнике, но не отнимает ни наносекунды — и проверка, которая на одной и той же машине в одном прогоне отнимает +30%, а в другом −5%. Оба случая разберём по машинному коду.

Все замеры на BenchmarkDotNet v0.15.8, машинный код снят переменной окружения DOTNET_JitDisasm без сторонних инструментов, полный код на гите — каждый может повторить и сделать свои выводы. Весь код взят дословно из PR и issue dotnet/runtime, ссылки прямо по тексту и списком в конце.

Тесты гонялись на четырёх машинах:

Машина

CPU

ОС

.NET 10 / 9 / 8

№1

AMD Ryzen 9 5950X, 16 ядер

Windows 10

10.0.5 / 9.0.15 / 8.0.14

№2

Intel Core i9-10900KF, 10 ядер

Windows 10

10.0.9 / 9.0.17 / 8.0.28

№3

2 x Intel Xeon Silver 4314, 32 ядра

Windows Server 2022

10.0.1 / 9.0.5 / 8.0.16

№4

2 x Intel Xeon Silver 4314, 32 ядра

Windows Server 2022

10.0.1 / 9.0.5 / 8.0.16

.NET 11 на всех четырёх — 11.0.100-preview.5.26302.115. Это превью, и в бенчмарках его нет: BenchmarkDotNet 0.15.8 пока не собирает проекты под .NET 11 (BenchmarkDotNet #3017), поэтому одиннадцатый участвует только в дизасме — там BenchmarkDotNet не нужен вообще.

Немного теории

Проверка границ в машинном коде — это пара инструкций перед чтением элемента: cmp индекса с длиной и jae на холодный хвост CORINFO_HELP_RNGCHKFAIL, который бросает исключение. По этим двум приметам проверку легко найти в листинге: либо она есть, либо её нет. Снимается листинг так:

dotnet build -c Release
set DOTNET_TieredCompilation=0
set DOTNET_JitDisasm=LessLoop NotEqualLoop ReverseLoop
bin\Release\net11.0\Disasm.exe > disasm_net11.txt 2>&1

Пара уточнений по замерам, чтобы сразу снять вопросы:

  • Дизасм снят без PGO, бенчмарки — с PGO. DOTNET_TieredCompilation=0 отключает не только ярусы, но и динамический PGO, так что в листингах — финальный FullOpts-код без профиля. А бенчмарки специально гонялись с настройками по умолчанию (tiering и PGO включены) — и цена проверки там видна. Значит PGO её тоже не убирает: дизасм и бенчмарк подтверждают друг друга с двух сторон.

  • Методы в тестах помечены NoInlining — только чтобы каждый печатался в листинге отдельно и под своим именем. На сам цикл и проверку инлайн не влияет.

Дальше все утверждения «проверка есть / проверки нет» — результат именно такого прогона на всех четырёх машинах и всех четырёх рантаймах. Картина на всех машинах совпала до инструкции, так что таблицы ниже — общие.

История первая: проверки умирают поколениями

Первый пациент — из PR #100777 (jakobbotsch, команда JIT), дожат в .NET 9:

static int UnsignedWhile(int i, int[] indices)
{
    while ((uint)i < (uint)indices.Length)
    {
        i = indices[i];
    }

    return i;
}

Беззнаковое сравнение само доказывает 0 <= i < Length, но до .NET 9 анализ диапазонов это доказать не мог — мешало чрезмерно осторожное «а вдруг переполнение», это прямо обсуждается в PR. У меня на .net 8 в цикле сидит cmp/jae, на .net 9/10/11 — чисто.

Второй пациент — из PR #115980 (EgorBo, команда JIT), дожат в .NET 10:

static bool PrefixSlash(string prefix, string path)
{
    if (prefix.Length < path.Length)
    {
        return path[prefix.Length] == '/';
    }
  
    return false;
}

Условие prefix.Length < path.Length уже доказывает, что индекс валиден. Дизасм на .net 9: сначала cmp ecx, r8d — ваше условие, а ниже тот же самый cmp ecx, r8d ещё раз, теперь как проверка границ с jae на RNGCHKFAIL. На .net 10/11 дубль исчез, метод похудел с 53 до 28 байт — ровно как в диффе PR.

Обход с конца: восемь лет в трекере — и дожали

Дальше — цикл, который обходит массив с конца.

static int ReverseLoop(byte[] src)
{
    int sum = 0;
    for (int i = src.Length - 1; i >= 0; i--)
    {
        sum += src[i];
    }

    return sum;
}

Индекс идёт от Length-1 вниз до нуля — глазами видно, что выход за границы невозможен. А JIT этого доказать не мог: на это есть issue #9505, открытый в 2017 году. В треде соседнего issue #84697 пользователь hughbe отдельно замечает, что это распространённый паттерн. И вот результат моего прогона: на .net 8, 9 и 10 проверка в методе есть — а на .NET 11 её нет. Issue восьмилетней давности дожали прямо сейчас, и это видно на живых машинах. То же самое с родственным циклом while (i != 0) sum += src[--i] из того же треда.

Итог по всем циклам (наличие проверки в методе; все 4 машины дали одинаково):

Рис. 1. Проверка границ в машинном коде по версиям .NET — одинаково на всех четырёх машинах
Рис. 1. Проверка границ в машинном коде по версиям .NET — одинаково на всех четырёх машинах

Плюс два контроля, чтобы инструменту можно было верить: метод arr[0] + arr[arr.Length - 1], где JIT издавна оставляет одну проверку вместо двух — так и есть на всех рантаймах; и метод с индексом из параметра, где проверку убрать нельзя — она честно стоит везде, включая .NET 11.

История вторая: цикл, который пережил всех

Последняя строка таблицы. Два цикла, которые делают одно и то же:

// А: классика
for (int i = 0; i < src.Length; i++)
    sum += src[i];
 
// Б: то же самое, но != вместо <
for (int i = 0; i != src.Length; i++)
    sum += src[i];

Про вариант Б есть issue #84697 (автор xtqqczze, апрель 2023). Дизасм на .NET 11, машина №1 — проверка на месте:

; NotEqualLoop, .NET 11
IG03:  cmp      edx, r8d          ; проверка границ - живая
       jae      SHORT IG05        ; на каждой итерации
       movzx    r10, byte ptr [rcx+rdx+0x10]
       add      eax, r10d
       inc      edx
       cmp      r8d, edx
       jne      SHORT IG03
...
IG05:  call     CORINFO_HELP_RNGCHKFAIL

Тот же перебор с < на том же .NET 11 — тело без единой проверки:

; LessLoop, .NET 11
IG03:  movzx    r10, byte ptr [rcx+rdx+0x10]
       add      eax, r10d
       inc      edx
       cmp      r8d, edx
       jg       SHORT IG03

Причина по сути техническая: для < анализ диапазонов легко выводит «внутри тела i всегда меньше Length», а для != тот же вывод требует отдельного рассуждения о монотонности счётчика — и эта ветка в оптимизаторе не реализована. И это не какой-то скрытый баг: команда JIT сама ведёт сводный трекер таких пропусков — issue #109677 (EgorBo), а в треде #84697 EgorBo подключает BruceForstall. Пропуск известен, но до него пока не добрались.

История третья: сколько мы на этом теряем

Меряю четыре цикла на массивах 1К (влезает в L1), 64К (L2), 1М (L3) и 16М (уже память), рантаймы 8/9/10, baseline — классический цикл с <. И тут два сюрприза.

Рис. 2. Четыре цикла на машине №2: 65 536 элементов, время и отношение к baseline
Рис. 2. Четыре цикла на машине №2: 65 536 элементов, время и отношение к baseline

Сюрприз первый: проверка, которая ничего не отнимает

Обратный цикл на .net 8/9/10 — проверка в методе есть (мы это видели в дизасме), а время идеально совпадает с baseline: соотношение 0.94–1.07 на всех машинах и размерах, то есть в пределах шума.

Рис. 3. Обход с конца: отношение к baseline — проверка в коде есть, времени не отнимает
Рис. 3. Обход с конца: отношение к baseline — проверка в коде есть, времени не отнимает

Как так? Ответ в листинге: JIT ещё с .net 8 клонирует этот цикл — генерирует две копии. Быстрая (в неё попадает нормальный массив) — без проверки. Медленная, с проверкой на каждой итерации — запасной путь на случай, если предусловие на входе не сошлось. RNGCHKFAIL в бинарнике есть, но горячий путь чистый — вы платите не временем, а размером кода: метод на .net 10 занимает 85 байт.

На .NET 11, где безопасность этого цикла научились доказывать напрямую, запасная копия стала не нужна — её выкинули целиком: 67 байт, тот же быстрый цикл.

Мораль простая: увидели RNGCHKFAIL в листинге — не спешите с выводами, сначала посмотрите, в каком пути он стоит. В горячем — платите временем, в запасном — только размером кода.

Сюрприз второй: проверка ценой от −5% до +31%

А вот у цикла с != проверка сидит прямо в горячем пути — и вот что она отнимает относительно baseline (65536 элементов):

Рис. 4. NotEqual_Loop на четырёх машинах: время / отношение к baseline
Рис. 4. NotEqual_Loop на четырёх машинах: время / отношение к baseline
Рис. 5. Цена живой проверки: цикл i != len против i < len, 65 536 элементов, четыре машины
Рис. 5. Цена живой проверки: цикл i != len против i < len, 65 536 элементов, четыре машины

Сервера и i9 платят +6–31%, стабильно и на всех размерах вплоть до 16 МБ — цикл суммирования байтов даже на большом массиве не упирается в память, и цена проверки не прячется. А Ryzen... на Ryzen цикл с проверкой быстрее чистого — 0.92–0.95, и это не шум: на 16 МБ в абсолюте 3.74 ms против 4.06 ms. Причём в предыдущем прогоне (другая сборка того же кода) всё было наоборот: Ryzen платил +30%, а i9 — ноль.

Разгадка нашлась в тех же листингах, в строчках align. JIT выравнивает начало цикла NotEqualLoop на 32 байта (паддинг 15 байт, цикл ложится на адрес 0x0020), а короткий цикл LessLoop оставляет как есть — на адресе 0x000D, поперёк границы выборки. То есть «грязный» цикл лежит в памяти ровно, а «чистый» — криво. На Zen 3 невыровненность короткого цикла обходится дороже, чем лишняя пара cmp/jae в выровненном — и цикл с проверкой выигрывает.

Пересобрали проект — методы легли по другим адресам, и расклад поменялся на противоположный. Сервера при этом стабильны в обоих прогонах. Само выравнивание циклов — документированная эвристика JIT (официальный пост Kunal Pathak про loop alignment, ссылка в конце), так что align-строки в листинге — не магия, а её следы.

Отсюда третья мораль: цена живой проверки на коротком горячем цикле лежит ровно в той зоне, где решает то, как код лёг в память. В бинарнике проверка есть всегда; заплатите ли вы за неё +30% или ничего — зависит от микроархитектуры и размещения кода. Поэтому главные таблицы в статье — про наличие инструкций, а время — с этой оговоркой.

Бонус: цикл, который обогнал baseline

И последняя странность прогона: цикл while (i != 0) src[--i] на обоих Xeon на .net 9/10 оказался заметно быстрее baseline — 26.6 µs против 33.6 у чистого цикла (на .net 8 было 37.6, медленнее). Полез в дифф листингов net8/net9 — и разгадка нашлась в одной строчке:

; .net 8:
       mov      r10d, r8d                        ; zero-extend индекса
       movzx    r10, byte ptr [rcx+r10+0x10]
; .net 9:
       movzx    r10, byte ptr [rcx+r8+0x10]      ; индекс напрямую
Рис. 6. while (i != 0) src[--i] на Xeon: минус 29% в .NET 9 за счёт IV widening
Рис. 6. while (i != 0) src[--i] на Xeon: минус 29% в .NET 9 за счёт IV widening

В .NET 9 у JIT появилась новая фаза оптимизации индукционных переменных (PR #97865, jakobbotsch), и в её составе — induction variable widening: 32-битный счётчик цикла расширяется до 64 бит на всё тело, и zero-extension перед каждым обращением к массиву становится не нужен.

Стивен Тауб в Performance Improvements in .NET 9 описывает ровно этот эффект. Минус одна инструкция — но она стояла на критическом пути перед загрузкой из памяти, и на Ice Lake это дало −29% времени. Запрос на эту оптимизацию, к слову, висел в трекере с незапамятных времён (issue #7312).

Выводы: чем грозит и что делать

  • Проверки границ меняются от версии к версии: один цикл дожали в .NET 9, другой в .NET 10, обход с конца из issue 2017 года — в .NET 11. Апгрейд рантайма убирает проверки из кода без единой правки.

  • Никогда не пишите for (i = 0; i != len; i++). Это единственный из всех проверенных циклов, переживший чистки вплоть до .NET 11: проверка на каждой итерации, на серверных Xeon и i9 — стабильные +6–31% к горячему циклу. В горячем коде — канонический for (int i = 0; i < arr.Length; i++) по локальной переменной массива: его JIT гарантированно дожимает во всех рантаймах.

  • Обход с конца до .NET 11 живёт за счёт клонирования: время не страдает, но платите размером кода (85 байт против 67 за метод из четырёх строк). На .NET 11 — бесплатно.

  • Микробенчмарки коротких циклов чувствительны к выравниванию кода: одна и та же лишняя инструкция на одной машине отнимает +30%, на другой −5%. Не переносите одну цифру с одной машины на всё железо; наличие проверки смотрите в дизасме, цену — на своём железе.

Код из статьи

  • BoundsCheckProof — бенчмарк (4 цикла, лесенка размеров под кэши, мульти-таргет net8/9/10), снятие дизасма (snap.bat, все 8 методов), готовые листинги net8–net11 и результаты прогонов со всех четырёх машин.

Ссылки

Всем удачи и до новых встреч!

PS: все таблицы и листинги — реальные прогоны на четырёх машинах из таблицы в начале, картина по наличию проверок на всех четырёх совпала до инструкции. На вашем железе абсолютные цифры будут свои — важно не «сколько наносекунд», а есть ли cmp/jae в вашем горячем цикле.

Если у вас цена != окажется другой — пишите в комментариях с версией рантайма и моделью CPU. NativeAOT я не гонял: устранение проверок там статическое и картина может отличаться — если прогоните мой код под AOT, поделитесь результатом.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Каким циклом вы чаще всего обходите массив?
26.67%for (i = 0; i < arr.Length; i++)4
46.67%foreach7
0%Обратный for0
26.67%LINQ и не парюсь4
0%Свой вариант — напишу в комментах0
Проголосовали 15 пользователей. Воздержался 1 пользователь.