Назад к блогу

Что нового в производительности .NET 11

Что нового в производительности .NET 11

В .NET 11 работа над производительностью сосредоточена в JIT-компиляторе и runtime: девиртуализация вызовов, устранение проверок границ, векторные операции и новый механизм runtime async, меняющий форму генерируемого IL. Разбор показывает, какие именно решения принимает компилятор и при каких условиях они дают выигрыш. Это полезно тем, кто хочет понимать, откуда берётся прирост скорости и как на него влияет структура кода.

В .NET 11 основные изменения в производительности сосредоточены в JIT-компиляторе: девиртуализация вызовов, устранение проверок границ, распространение утверждений, работа с векторами и write barrier. Отдельно стоит runtime async — механизм, который меняет саму форму генерируемого IL для асинхронных методов. Ниже разобрана механика каждого изменения: что именно JIT делает иначе, при каких условиях и почему это даёт выигрыш.

Guarded devirtualization: «угадай и проверь»

Обычный виртуальный вызов устроен так: JIT загружает указатель на таблицу методов объекта (этот указатель лежит в начале каждого объекта .NET), индексирует таблицу по известному слоту метода и вызывает найденный указатель функции. За один такой вызов платятся три зависимых разыменования памяти и косвенный вызов, потому что процессор не знает заранее, куда пойдёт вызов. Из-за косвенности JIT не может заинлайнить вызываемого — а инлайнинг важен не столько экономией на вызове, сколько тем, что он открывает код вызываемого для тех же оптимизаций, что применяются к вызывающему: распространение констант, удаление мёртвого кода, устранение проверок границ, дальнейшая девиртуализация.

Guarded devirtualization (GDV) заменяет косвенный вызов на прямую проверку типа и прямой вызов. В рантайме проверка сводится к сравнению результата animal?.GetType() с typeof(Dog): если типы совпали, выполняется девиртуализованный и инлайнируемый вызов ((Dog)animal).Speak(), иначе — исходный виртуальный вызов animal.Speak(), который, как ожидается, редок. Именно этот шаблон даёт значительную часть прироста пропускной способности на реальных нагрузках.

Когда JIT девиртуализирует без проверки

Прямая девиртуализация без guard-проверки возможна, когда тип объекта this известен точно. JIT получает класс объекта, и если класс неизвестен, прямой девиртуализации нет — вместо неё вызывается guarded devirtualization. Если класс известен, JIT берёт его атрибуты и вычисляет финальность класса по флагу CORINFO_FLG_FINAL; затем через разрешение виртуального метода получает вызываемый метод и его финальность по тому же флагу. Условие прямой девиртуализации:

canDevirtualize = isExact || objClassIsFinal || (!isInterface && derivedMethodIsFinal);

То есть девиртуализация без проверки разрешена, когда тип this точный, либо класс this финальный, либо — для не-интерфейсного вызова — найденный метод финальный. Отдельного условия про sealed-класс или inlineable-фабрику нет: финальность класса выражается флагом CORINFO_FLG_FINAL, а точность типа — значением isExact.

Для NativeAOT есть отдельный путь: при отсутствии PGO-данных и включённом JitEnableExactDevirtualization JIT запрашивает точные классы, реализующие базовый класс. Если их число равно нулю, отрицательно или превышает лимит проверок типов, кандидаты не добавляются. Иначе для каждого точного класса проверяется, что он не абстрактный, и через разрешение виртуального метода определяется вызываемый метод.

Как выбирается единственный GDV-кандидат

JIT получает из PGO-профиля список вероятных классов и список вероятных методов. Если нет ни классов, ни методов, кандидаты не рассматриваются вовсе. В обычном режиме приоритет отдаётся классам, и порог правдоподобия зависит от того, сколько догадок разрешено. правдоподобие берётся из PGO-профиля:

Число догадок[[term:helper-вызовывызовы, которые JIT обслуживает через вспомогательные хелперы рантайма; сюда относятся касты]][[term:интерфейсные вызовывызовы через интерфейс, где реализация выбирается по фактическому типу объекта]][[term:виртуальные вызовывызовы виртуального метода, где реализация выбирается по фактическому типу объекта]]
1502530
2401520
больше 2101010

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

Стресс-режим — это режим тестирования, в котором JIT намеренно выбирает кандидатов случайным образом, чтобы проверить корректность механизма на нетипичных сочетаниях типов. В нём JIT выбирает один случайный индекс из общего числа классов и методов и заполняет ровно одного кандидата с правдоподобием 100. Ограничение одной догадкой объясняется тем, что при текущей реализации можно отказаться от всех кандидатов, если хотя бы один из них не инлайнится, — поэтому не хотят снижать покрытие тестирования.

Runtime async: другой IL для асинхронных методов

Классический async-метод разворачивается компилятором C# в машину состояний: у метода появляется сгенерированный тип <CallerAsync>d__0, интерфейс IAsyncStateMachine, метод MoveNext, билдер AsyncTaskMethodBuilder<int> и атрибут AsyncStateMachineAttribute.

При включённом runtime async компилятор генерирует другой IL: у метода появляется модификатор async (новый бит MethodImpl), а тело вызывает хелперы AsyncHelpers. Из IL исчезают все перечисленные выше сгенерированные сущности. Тело метода сводится к вызову CalleeAsync(), вызову AsyncHelpers::Await<int32> и ret. Для SizeProbe.dll это уменьшает размер сборки с 10,752 байт до 5,632 байт — до 0.52 от исходного.

Включается runtime async на уровне приложения через feature switch в csproj:

<Features>$(Features);runtime-async=on</Features>

При сборке свойство RuntimeAsync принимает значения true и false. Нового синтаксиса C# не требуется, поэтому LangVersion=preview и EnablePreviewFeatures не нужны. На уровне отдельного метода runtime async отключается атрибутом RuntimeAsyncMethodGeneration(false), который хранит переданное булево значение в свойстве RuntimeAsync.

Вложенные цепочки await: сколько аллокаций устраняется

В классическом варианте каждый async-метод имеет собственную сгенерированную машину состояний и собственный task-подобный результат. Runtime async распознаёт соседний шаблон «вызвать метод, возвращающий Task, и сразу его await» и может вызвать AsyncCall-вариант вызываемого метода.

Для цепочки A→B→C при синхронном завершении C значение int проходит через B и A как обычное значение, и только внешняя граница превращает его в Task<int>, обещанный исходному вызывающему. При приостановке C runtime связывает состояние продолжения для всей цепочки и возобновляет его без промежуточного Task<int> на каждом напрямую слитом ребре.

Оптимизация не устраняет все аллокации: если потребитель сохраняет task в коллекции, вручную подключает continuation или иным образом наблюдает task как объект, этот объект всё ещё нужен.

Проверки границ: range analysis и assertion propagation

JIT выводит из идиомы for (int i = 0; i < array.Length; i++), что внутри тела цикла i гарантированно лежит в диапазоне [0, array.Length), и поэтому не генерирует проверку границ для array[i]. Range analysis отслеживает для каждой переменной интервал с верхней и нижней границами: истинная ветвь x < 5 даёт верхнюю границу 4, истинная ветвь x > 2 — нижнюю границу 3.

Для x != 5 исключаемое значение сужает диапазон с той стороны, где оно стоит: при текущем диапазоне [5, 10] он становится [6, 10], при [0, 5] — [0, 4]. В .NET 11 изменение dotnet/runtime#121273 заменяет прежнюю логику, которая просто пропускала !=-ассерции, на продуктивное рассуждение: проверяется, находится ли исключаемая константа на краю текущего диапазона, и если да, добавляются новые уточнения.

Assertion machinery переносит факты между базовыми блоками: факт из блока A доходит до блока B, если A доминирует над B, то есть единственный путь в B проходит через A. Внутри одного блока факты, установленные ранее, обрабатывались отдельно — этот пробел закрывает dotnet/runtime#121527. Value numbering дополняет это, позволяя распознать, что два выражения вычисляют одно и то же значение, даже если они в разных местах или используют разные переменные. Вместе это даёт удаление избыточных null- и bounds-проверок, свёртку условий в константы и переиспользование повторных вычислений. Изменение dotnet/runtime#125215 позволяет JIT удерживать и эффективно находить больше ассерций в крупных методах.

Клонирование проверок диапазона

Фаза optRangeCheckCloning группирует проверки границ из одного базового блока, выдаёт одну защиту для наибольшего требуемого диапазона и дублирует затронутый код в быстрый путь, где отдельные проверки можно удалить, и в запасной путь, где они остаются. Давнее ограничение состояло в том, что клонирование отказывалось обрабатывать последний оператор терминаторного блока. В методе static int ArrayAccess(int[] abcd) => abcd[0] + abcd[1] + abcd[2] + abcd[3]; все четыре обращения к массиву находятся в операторе return, то есть в последнем операторе блока возврата, поэтому ничего не клонировалось и горячий путь сохранял четыре отдельные проверки границ.

В .NET 11 изменение dotnet/runtime#124705 снимает это ограничение, делая оператор return пригодным для клонирования и позволяя одной защите быстрого пути покрыть все четыре обращения. Изменение dotnet/runtime#127439 заставляет JIT объединять такие проверки внутри базового блока, усиливая первую проверку до наибольшего константного индекса и удаляя остальные. Для цикла генерируется эквивалент if ((uint)count <= (uint)values.Length): в ветви then выполняется клонированный цикл без проверок границ на каждой итерации, в ветви else остаётся исходный проверяемый цикл, сохраняющий исключительное поведение для некорректных входных данных.

Диапазоны для Span.Slice и чтения целых

JIT выводит диапазоны для Span.Slice и ReadInt32BigEndian, используя два улучшения анализа. Первое — распознавание тождества x - (x + a) == -a. Без него length - (length - 4) — просто непрозрачное вычитание двух выражений без очевидного константного результата. С тождеством JIT распознаёт внутреннее (length - 4) как length + (-4), применяет тождество при x == length и a == -4 и получает -(-4) == 4, после чего проверка ReadInt32BigEndian против 4 превращается в 4 >= 4 — тривиально истинное.

Второе — Slice(start) должен устанавливать, что start лежит между нулём и длиной span. Когда start равен length - 4, существующая защита length >= 4 доказывает неотрицательность результата, а вычитание положительной константы означает, что результат не превысит length; улучшенный анализ связывает эту защиту с вычитанием и удаляет проверку Slice.

Проверка span.Length >= 4 ранее не устраняла две дополнительные проверки: Span.Slice начинается с if ((uint)start > (uint)_length) ThrowHelper.ThrowArgumentOutOfRangeException();, и, хотя проверки длины должно было хватать, две дополнительные проверки всё равно оставались. Оба исправления вместе (dotnet/runtime#122040 и dotnet/runtime#127117) устраняют обе. Это особенно полезно для парсеров, реализаций сетевых протоколов и криптографического кода, которые на горячих путях часто читают последние N байт буфера.

Свёртка SequenceEqual и векторные константы

Когда оба входа SequenceEqual известны на этапе компиляции, JIT может сравнить их при компиляции и заменить всю операцию константой true или false. Это лишь первый шаг: результат может упростить последующий код, даже если это вектор, поскольку распространение утверждений расширено на 128-битные целочисленные векторные константы. Если ветвь устанавливает, что вектор равен нулю, его использования внутри ветви заменяются нулём и упрощаются так же, как скалярные значения. В ветке нуля JIT сворачивает создание маски, AndNot и извлечение дорожки, сокращая метод Arm64 с 68 до 56 байт. Это применимо к целочисленным векторам до 128 бит; равенство с плавающей точкой имеет дополнительные семантики NaN и знакового нуля, которые препятствуют такому рассуждению.

Цепочки сравнений в одну беззнаковую проверку

JIT распознаёт цепочки сравнений на равенство с нулём, например value == 0 || value == 1 || value == 2, и заменяет их одной безветочной проверкой (uint)value <= 2. Беззнаковое сравнение при этом отсекает отрицательные значения: при трактовке как unsigned любое отрицательное int больше верхней границы. Для диапазонов, не начинающихся с нуля, оптимизация расширена: x == 3 || x == 4 || x == 5 превращается в (uint)(x - 3) <= 2.

В .NET 10 JIT уже объединял первые четыре сравнения, но всё ещё требовал ветку и отдельное сравнение для 4. В .NET 11 вся цепочка распознаётся как одна беззнаковая проверка диапазона, что сокращает метод с 24 до 13 байт. Итоговый x64-код читает значение, сравнивает его с 5, устанавливает младший байт результата и расширяет его до полного регистра:

cmp eax,5
setb al
movzx eax,al

Peephole-оптимизации

Peephole-оптимизации заменяют короткий распознаваемый шаблон на эквивалентный более дешёвый. В .NET 11 добавлены следующие:

  • 255 - x для byte эквивалентно x ^ 255 — оба переворачивают все восемь бит, но второе позволяет заменить отрицание и сложение на xor, убрав инструкцию. Аналогично -1 - x превращается в эквивалент ~x. В примере цикла .NET 11 теряет отдельные negate и add.
  • Сравнение sbyte с константой, помещающейся в восемь бит, идёт напрямую, без знакового расширения.
  • CompareWithZero меняет and на устанавливающую флаги ands и отбрасывает последующий cmp.
  • ExtractBits заменяет сдвиг и маску на ubfx.
  • TruncateAfterWidening убирает sxtw, расширявший значение до 64 бит только для немедленного усечения через sxtb.
  • ToScalar для вектора 64-битных целых использует fmov Xd, Dn вместо инструкции извлечения дорожки umov: в обоих случаях нулевая дорожка перемещается в регистр общего назначения, но fmov — более прямая форма.

Каждый из этих примеров убирает одну инструкцию.

Intrinsics и SIMD

Для Vector<T>.Sum в .NET 11 перестановки и сложения выполняются параллельно над обеими 128-битными половинами как 256-битные инструкции, после чего остаётся объединить только две уже редуцированные половины. В .NET 10 каждая половина редуцировалась отдельно, и затем складывались два скалярных результата.

Скалярные преобразования между Half и float используют инструкции F16C vcvtps2ph и vcvtph2ps при включённом AVX2. Для UInt32→Single время выполнения уменьшилось с 4.752 ns до 2.403 ns (Ratio 0.51), а размер кода вырос с 37 B до 43 B.

Изменение dotnet/runtime#126062 не преобразует векторный селектор в AVX-512 маск-регистр, когда итоговой операции всё ещё нужна векторная форма. В таких случаях применяется более простая форма vblendvps, избегающая лишней операции с k-регистром. Маскированная EVEX-форма выглядит современнее, но когда маска и так приходит из вектора, векторная последовательность blend на пять байт короче и не пишет маск-регистр; k-регистров всего 8, и на некоторых микроархитектурах есть конкуренция за порты для инструкций, которые их пишут.

Изменение dotnet/runtime#129852 снимает прежний потолок размера 128 бит для Vector<T> на Arm64 и позволяет среде выполнения задавать размер типа исходя из фактической длины SVE-вектора процесса. Масштабируемый Vector<T> остаётся экспериментальным и отключённым по умолчанию в .NET 11, поэтому изменение расширяет возможности экспериментального режима, а не ускоряет конфигурацию по умолчанию. Оно важно для пользовательских числовых типов, которые хранят те же данные, что и аппаратный вектор, но предоставляют именованные поля или предметно-ориентированные операции: например, тип из двух смежных double можно биткастом преобразовать в Vector128<double>, выполнить SIMD-операцию и биткастом вернуть обратно.

Write barriers и копирование структур

JIT расширяет вызовы runtime-хелпера записи в массив на отдельные операции, делая видимыми проверку ковариантности и write barrier, и при известном точном типе массива может устранить проверку ковариантности и оптимизировать барьер.

Для копирования структур JIT расширяет анализ heap-назначения с отдельных записей на копии целых структур, а специализированный хелпер, копировавший по одному reference-полю, заменяется на reference-записи и векторные записи для не-reference данных. Это позволяет выбрать более эффективные write barriers и скопировать остаток смешанной структуры через SIMD.

Удаление избыточных null-check безопасно, потому что при копировании структур с object-ссылками lowering добавлял пробы и для источника, и для назначения, не сохраняя сведений о том, может ли адрес фактически отказать; теперь эмитятся только нужные пробы. Когда _src и _dst — поля одного объекта, после проверки адреса источника уже установлено, что объект не null, поэтому проверка адреса назначения не даёт дополнительной информации и удаляется.

Runtime knowledge и frozen data

JIT может оптимизировать только те факты, которые знает. Часть фактов — это контракты, поставляемые рантаймом. Например, зная, какие хелперы имеют побочные эффекты, JIT понимает, можно ли переносить или повторно использовать результат их вызова, не меняя поведение программы. Зная длину только что выделенной строки, JIT может подставить её как константу и упростить зависящий от неё код. Зная, будет ли объект данных когда-либо перемещаться, JIT решает, можно ли кэшировать адрес этого объекта и переиспользовать его между обращениями.

Для обобщённого виртуального вызова вида baseReference.Foo<string>() может потребоваться помощь рантайма, чтобы найти реализацию и для фактического типа объекта, и для generic-аргумента. Если такой lookup выглядит как имеющий произвольные побочные эффекты, JIT обязан выполнить его точно там, где он встречается, а не полагаться на кэшированный ответ от предыдущего lookup.

В .NET 11 изменение dotnet/runtime#122017 учит JIT точнее определять, какие исключения могут бросать эти рантайм-хелперы и есть ли у них иные побочные эффекты. Тогда JIT может разделять повторные lookup'ы или выносить неизменяющийся lookup из цикла. В бенчмарке GvmCseHoist повторные lookup'ы вне цикла разделяются, а lookup внутри цикла выполняется один раз, а не десять раз.

Tail merging и удаление мёртвого кода

Tail merging. В .NET 10 она объединяла только пути, завершающиеся одним исходом, а в .NET 11 объединяет и пути с противоположным исходом, что позволяет одному битовому тесту обработать все четыре значения.

Удаление мёртвого кода опирается на анализ достижимости: вызов, который никогда не возвращает управление (например, всегда бросающий исключение), делает весь последующий код недостижимым. В .NET 11 после инлайнинга фаза dotnet/runtime#128513 удаляет оставшиеся операторы и исходящие пути из такого блока и помечает его как заканчивающийся throw, делая мёртвый код видимым достаточно рано для последующих чистящих проходов. В компиляторе это реализовано фазами PHASE_POST_INLINE_NORETURN (очистка после no-return вызовов, появившихся из-за инлайнинга), PHASE_DFS_BLOCKS1/2 (построение пост-порядка и удаление мёртвых блоков) и PHASE_HEAD_TAIL_MERGE (собственно tail merge).

Как сравнивали и что получилось

Почти все бенчмарки используют BenchmarkDotNet и написаны самодостаточными, чтобы их можно было воспроизвести. Большинство сравнивают один и тот же код на .NET 10 и .NET 11. Проект создаётся командой dotnet new console -o benchmarks, в csproj задаются целевые платформы <TargetFrameworks>net11.0;net10.0</TargetFrameworks>, а версия System-пакетов выбирается по условию в зависимости от целевой платформы. Сравнение двух рантаймов запускается командой dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0, которая собирает в Release и выдаёт side-by-side сравнение. Другая форма — dotnet run -c Release -f net11.0 --filter "*" — применяется, когда сравниваются два подхода в коде на одном рантайме.

Отдельные бенчмарки требуют своей конфигурации. Для runtime async включается feature switch runtime-async=on, а классические варианты внутри класса помечены атрибутом RuntimeAsyncMethodGeneration(false). Бенчмарк конвертации uint в float/double запускается с 32-битным x86 dotnet и отключённым AVX-512 (DOTNET_EnableAVX512=0). Бенчмарк ToUpperInvariant запускается с DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. Для бенчмарка размера процесса в csproj добавляются PublishAot=true, InvariantGlobalization=true и AssemblyName=ProcessSize. Бенчмарк MemoryCache запускается отдельно для каждой целевой платформы, чтобы каждая использовала соответствующий пакет System.Runtime.Caching.

Результаты по сценариям

Nullable boxing (JIT):

БенчмаркВерсияMeanRatioAllocatedAlloc Ratio
BoxNullableNull.NET 10.02.095 ns1.00––
BoxNullableNull.NET 11.01.764 ns0.84––
BoxNullableValue.NET 10.09.213 ns1.0024 B1.00
BoxNullableValue.NET 11.04.126 ns0.4524 B1.00
FormatNullableInt.NET 10.09.583 ns1.0024 B1.00
FormatNullableInt.NET 11.01.987 ns0.21–0

Runtime async:

БенчмаркMeanRatioAllocatedAlloc Ratio
ClassicCompleted21.221 ns1.00144 B1.00
RuntimeCompleted6.151 ns0.290 B0.00
ClassicYielding254.139 ns1.00248 B1.00
RuntimeYielding116.927 ns0.46168 B0.68

Вложенные цепочки await (Depth, Yield=False):

DepthВерсияMeanRatioAllocatedAlloc Ratio
1Classic4.727 μs1.001.6 KB1.00
1Runtime3.558 μs0.751.16 KB0.72
10Classic19.469 μs1.0015.13 KB1.00
10Runtime5.885 μs0.302.13 KB0.14
30Classic51.302 μs1.0084.2 KB1.00
30Runtime10.721 μs0.215.71 KB0.07

Проверки границ:

БенчмаркВерсияMeanRatio
ContainsPercentFF.NET 10.0232.1 ns1.00
ContainsPercentFF.NET 11.0194.9 ns0.84

Intrinsics (преобразования в Int64/UInt64):

БенчмаркВерсияMeanRatioCode Size
SingleToInt64.NET 10.05.103 ns1.0031 B
SingleToInt64.NET 11.02.093 ns0.4154 B
SingleToUInt64.NET 10.04.810 ns1.0031 B
SingleToUInt64.NET 11.01.366 ns0.2834 B
DoubleToInt64.NET 10.04.834 ns1.0031 B
DoubleToInt64.NET 11.02.101 ns0.4354 B
DoubleToUInt64.NET 10.04.663 ns1.0031 B
DoubleToUInt64.NET 11.01.363 ns0.2934 B

Ковариантные записи (heap-destination analysis):

БенчмаркВерсияMeanRatio
CovariantStore_Loop.NET 10.010.85 μs1.00
CovariantStore_Loop.NET 11.06.042 μs0.56

Runtime knowledge (устранение повторных lookup'ов):

БенчмаркВерсияMeanRatio
GvmCseHoist.NET 10.045.75 ns1.00
GvmCseHoist.NET 11.024.06 ns0.53

CollectGen0:

БенчмаркHandlesВерсияMeanRatio
CollectGen0100000.NET 10.01.522 ms1.00
CollectGen0100000.NET 11.0255.5 μs0.17
CollectGen01000000.NET 10.010.737 ms1.00
CollectGen01000000.NET 11.0310.7 μs0.029

Оговорки

Приведённые измерения — это микробенчмарки, многие из которых измеряют операции настолько короткие, что их не заметит мигание глаза. Результаты будут различаться в зависимости от оборудования, ОС, конфигурации рантайма, того, что ещё делает машина в этот момент, и даже от ретроградного Меркурия. Эти цифры не показывают универсальных или гарантированных значений для любой системы — они демонстрируют отдельные улучшения в конкретных условиях.

Что из этого следует на практике

  • Девиртуализация работает по профилю, а не по типам. JIT делает одну догадку о доминирующем типе, и если она не подтверждается, вызов идёт по медленному пути. Код, где один тип встречается в подавляющем большинстве случаев, выигрывает; код с равномерным распределением типов остаётся на виртуальном вызове.
  • Финальность — рычаг. Пометив класс sealed или метод final, вы даёте JIT право девиртуализировать вызов без проверки типа вообще. Точный тип this и финальность класса или метода — три условия, при которых guard не нужен.
  • Runtime async меняет не только скорость, но и размер. Исчезновение сгенерированной машины состояний сокращает сборку примерно вдвое на измеренном примере. Но если task наблюдается как объект — сохраняется в коллекции или получает continuation вручную — аллокация остаётся.
  • Чем глубже цепочка await, тем больше выигрыш. На глубине 1 экономия аллокаций умеренная, на глубине 30 — почти десятикратная. Если у вас длинные цепочки последовательных await, runtime async даёт наибольший эффект.
  • Проверки границ и диапазоны — это про идиомы. JIT узнаёт стандартный цикл for (i = 0; i < array.Length; i++) и цепочки равенств. Код, написанный в этих формах, получает оптимизации бесплатно; эквивалентный по смыслу, но иначе записанный — нет.
  • Структуры с GC-ссылками копируются дешевле. Расширение анализа heap-назначения на целые структуры и замена пофайлового копирования reference-полей на векторные записи для не-reference данных уменьшают число проб и позволяют выбрать более эффективный write barrier.
  • Ошибки в рассуждениях о диапазонах стоят дорого. Тождество x - (x + a) == -a и связывание защиты length >= 4 с вычитанием убирают проверки, которые раньше оставались, несмотря на достаточность внешней проверки. Если вы пишете парсер или сетевой протокол с чтением последних N байт буфера, эти улучшения сработают на вашем горячем пути.

Где смотреть в коде

Источники

Похожее