Уважаемые читатели, в этой статье я хочу рассказать о проверках границ массива и представить свои выводы. Каждое обращение 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 машины дали одинаково):

Плюс два контроля, чтобы инструменту можно было верить: метод 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 — классический цикл с <. И тут два сюрприза.

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

Как так? Ответ в листинге: JIT ещё с .net 8 клонирует этот цикл — генерирует две копии. Быстрая (в неё попадает нормальный массив) — без проверки. Медленная, с проверкой на каждой итерации — запасной путь на случай, если предусловие на входе не сошлось. RNGCHKFAIL в бинарнике есть, но горячий путь чистый — вы платите не временем, а размером кода: метод на .net 10 занимает 85 байт.
На .NET 11, где безопасность этого цикла научились доказывать напрямую, запасная копия стала не нужна — её выкинули целиком: 67 байт, тот же быстрый цикл.
Мораль простая: увидели RNGCHKFAIL в листинге — не спешите с выводами, сначала посмотрите, в каком пути он стоит. В горячем — платите временем, в запасном — только размером кода.
Сюрприз второй: проверка ценой от −5% до +31%
А вот у цикла с != проверка сидит прямо в горячем пути — и вот что она отнимает относительно baseline (65536 элементов):


Сервера и 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](https://habrastorage.org/r/w1560/getpro/habr/upload_files/22b/467/4ee/22b4674eec70ad4207a21926b47dd03f.png)
В .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 и результаты прогонов со всех четырёх машин.
Ссылки
PR #100777 — jakobbotsch: bounds check для (uint)i < (uint)len, вошло в .NET 9
PR #115980 — EgorBo: устранение проверки для s[p.Length], вошло в .NET 10
Issue #9505 — обратная итерация, открыт в 2017, дожат в .NET 11
Issue #84697 — xtqqczze: цикл i != Length; в треде hughbe и EgorBo (cc BruceForstall)
Issue #109677 — EgorBo: сводный трекер неубранных проверок у команды JIT
PR #97865 — фаза оптимизации индукционных переменных (IV widening), .NET 9
Stephen Toub — Performance Improvements in .NET 9, раздел про induction variables
Kunal Pathak — Loop alignment in .NET 6, эвристика выравнивания циклов
BenchmarkDotNet #3017 — почему .NET 11 пока нельзя гонять в BDN
Всем удачи и до новых встреч!
PS: все таблицы и листинги — реальные прогоны на четырёх машинах из таблицы в начале, картина по наличию проверок на всех четырёх совпала до инструкции. На вашем железе абсолютные цифры будут свои — важно не «сколько наносекунд», а есть ли cmp/jae в вашем горячем цикле.
Если у вас цена != окажется другой — пишите в комментариях с версией рантайма и моделью CPU. NativeAOT я не гонял: устранение проверок там статическое и картина может отличаться — если прогоните мой код под AOT, поделитесь результатом.
