Start Debugging

Что такое многоуровневая компиляция и как о ней рассуждать?

Многоуровневая компиляция позволяет JIT в .NET компилировать каждый метод дважды: сначала быстро и без оптимизаций, чтобы приложение запустилось, а затем ещё раз с полными оптимизациями, когда среда выполнения узнаёт, что метод горячий. Здесь показано, как tier 0, tier 1, замена на стеке и Dynamic PGO складываются вместе, и как их наблюдать и настраивать.

Многоуровневая компиляция - это стратегия среды выполнения .NET компилировать каждый метод более одного раза. Когда метод выполняется впервые, JIT-компилятор (just-in-time) быстро и почти без оптимизаций создаёт код «tier 0», чтобы приложение запускалось быстро. Если позже этот метод окажется горячим (вызван не менее 30 раз или крутится в длинном цикле), фоновый поток перекомпилирует его как полностью оптимизированный код «tier 1» и незаметно подменяет. Вы получаете и скорость запуска, и пропускную способность в установившемся режиме из одного и того же бинарного файла, не выбирая между ними. Функция включена по умолчанию начиная с .NET Core 3.0, а в .NET 8 и более поздних версиях она также питает профилировщик (Dynamic PGO), который делает код tier 1 умнее, чем смог бы статический компилятор. Эта статья объясняет подвижные части и то, как рассуждать о них при профилировании или измерениях.

Всё здесь ориентировано на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11 (11.0.100), но механика стабильна с .NET 7, когда замена на стеке и быстрый JIT циклов стали поведением по умолчанию на x64 и arm64. Когда поведение зависит от конкретной версии, я это отмечаю.

Почему среда выполнения компилирует один и тот же метод дважды

JIT-компилятор сталкивается с дилеммой каждый раз, когда видит метод впервые. Он может скомпилировать этот метод медленно и создать превосходный машинный код, что отлично для метода, выполняющегося миллион раз, но расточительно для метода, выполняющегося один раз при запуске. Или он может скомпилировать быстро и создать посредственный код, что отлично для запуска, но оставляет пропускную способность неиспользованной на горячих путях.

Компиляторы ahead-of-time обходят это, оптимизируя всё заранее, и платят за это временем сборки и большими бинарными файлами. Чистый JIT, который всегда оптимизирует, платит за это медленным запуском, потому что реальное приложение JIT-компилирует тысячи методов, прежде чем обслужить свой первый запрос. Многоуровневая компиляция отказывается выбирать. Она сначала компилирует быстро, чтобы процесс пришёл в движение, затем наблюдает, какие методы действительно важны, и перекомпилирует только их дорогим оптимизатором. Подавляющее большинство методов в любом процессе выполняется несколько раз и никогда не заслуживает tier 1, так что бюджет оптимизатора идёт туда, где он окупается.

Tier 0 и tier 1: что «быстро» и «оптимизированно» означают на самом деле

Tier 0 создаётся «quick JIT». Компилятор пропускает большинство оптимизаций: без агрессивного инлайнинга, без разворачивания циклов, с минимальным распределением регистров. Он выдаёт прямолинейный машинный код за долю того времени, которое заняла бы полная компиляция. Полученный код корректен и часто в несколько раз медленнее оптимизированного кода, но он существует почти мгновенно.

Tier 1 - это полный оптимизатор. Он выполняет инлайнинг, устраняет проверки границ там, где может доказать, что они избыточны, выносит инварианты из циклов и (начиная с .NET 8) использует данные профиля, собранные, пока работал tier 0. Компиляция tier 1 гораздо медленнее в создании, и именно поэтому среда выполнения тратит её только на методы, которые доказали, что они того стоят.

Есть третий источник кода, участвующий в той же системе: ReadyToRun (R2R). Сборки фреймворка поставляются с предварительно скомпилированным кодом R2R, чтобы среде выполнения не приходилось JIT-компилировать string.Substring при каждом запуске. Код R2R трактуется как предварительно испечённый уровень, который всё ещё может быть заменён ещё лучшей версией tier 1, как только метод окажется горячим в вашей конкретной рабочей нагрузке. Вот почему отключение многоуровневой компиляции иногда может сделать приложение медленнее, а не быстрее: вы теряете возможность переоптимизировать методы фреймворка с вашим профилем.

Порог счётчика вызовов и задержка перехода на уровни

Метод не перепрыгивает на tier 1 в тот момент, когда становится тёплым. Продвижение регулируют два механизма.

Первый - счётчик вызовов. Каждый метод tier 0 имеет счётчик, и как только он был вызван 30 раз (значение по умолчанию DOTNET_TC_CallCountThreshold), он ставится в очередь на перекомпиляцию в tier 1. Тридцать намеренно мало; смысл в том, чтобы поймать всё, что выполняется многократно, не дожидаясь, пока оно отработает тысячи раз в медленном коде.

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

Перекомпиляция происходит в фоновом потоке, взятом из пула потоков, работающем срезами примерно по 10 мс за раз, чтобы он никогда не монополизировал рабочий поток. Ваш горячий метод продолжает выполнять свой код tier 0, пока оптимизированная версия не будет готова, после чего среда выполнения атомарно перенаправляет будущие вызовы на код tier 1. Ничто не блокируется, и ни один выполняющийся вызов не прерывается.

// .NET 11, C# 14.
// Call this in a tight loop and the runtime will promote it to tier 1
// after ~30 calls, once startup has settled. You will not see the
// promotion in the method's behavior, only in its speed.
static long SumOfSquares(int n)
{
    long acc = 0;
    for (int i = 0; i < n; i++)
        acc += (long)i * i;
    return acc;
}

Замена на стеке: как выбраться из горячего цикла, который никогда не возвращает управление

У механизма счётчика вызовов есть очевидный пробел. Как быть с методом, который вызывается ровно один раз, но затем крутится в цикле в течение всей жизни процесса? Подумайте о Main или о воркере, который вечно итерирует по очереди. Его счётчик вызовов равен 1, так что он застрял бы в медленном коде tier 0 навсегда.

Замена на стеке (OSR, on-stack replacement) закрывает этот пробел. Когда quick JIT выдаёт код tier 0 для метода, содержащего цикл, он также вставляет небольшой счётчик на обратной дуге цикла (переход обратно к началу). Если эта обратная дуга выполнится достаточно раз, среда выполнения компилирует оптимизированную версию метода и переносит выполнение в неё на лету, заменяя выполняющийся кадр стека на месте. Цикл, начавшийся в tier 0, завершается в tier 1, при этом метод так и не вернул управление.

OSR - это то, что вообще делает безопасным quick JIT-компиляцию методов с циклами. До появления OSR среда выполнения отказывалась применять quick JIT к любому методу, содержащему цикл (старое значение по умолчанию false для TieredCompilationQuickJitForLoops), потому что длинный цикл, застрявший в неоптимизированном коде, был настоящим обрывом производительности. PR #65675 в dotnet/runtime включил по умолчанию как DOTNET_TC_QuickJitForLoops, так и DOTNET_TC_OnStackReplacement для x64 и arm64 в .NET 7, так что сегодня почти все методы начинают в tier 0, и среда выполнения полагается на OSR, чтобы спасти всё, что застряло в длинном цикле. Результатом стало улучшение запуска примерно на 25% в приложениях с интенсивным JIT и на 10-30% лучшее время до первого запроса в бенчмарках TechEmpower, согласно отчёту о производительности .NET 7.

Dynamic PGO: проход tier 0 также профилирует

Вот часть, которая удивляет людей. В .NET 8 и более поздних версиях Dynamic PGO (profile-guided optimization) включён по умолчанию, и именно он делает весь двухпроходной дизайн чем-то большим, чем просто «скомпилировать дважды».

Когда Dynamic PGO активен, код tier 0 не только неоптимизирован, но и инструментирован. Он записывает дешёвые факты о том, как метод действительно ведёт себя: какие конкретные типы появляются на месте виртуального вызова, какая ветвь if берётся чаще всего, сколько раз итерирует цикл. Когда метод продвигается на tier 1, оптимизатор читает этот профиль и специализирует код под то, что действительно происходило во время выполнения. Виртуальный вызов, целью которого в 99% случаев был List<int>, получает этот путь инлайненным, защищённым проверкой типа (защищённая девиртуализация). Ветвь, которая берётся почти никогда, выносится из горячего пути.

Это оптимизация, которую статический компилятор выполнить не может, потому что она зависит от данных, существующих только пока работает ваша конкретная рабочая нагрузка. Именно поэтому многоуровневая компиляция плюс Dynamic PGO может превзойти полностью оптимизированную с самого начала конфигурацию по реальной пропускной способности, а не только по запуску. Вы можете отключить его для сравнения:

<!-- .NET 11, C# 14. Disables Dynamic PGO. Default is enabled since .NET 8. -->
<PropertyGroup>
  <TieredPGO>false</TieredPGO>
</PropertyGroup>

Компромисс в том, что инструментированный код tier 0 немного медленнее и немного больше, чем простой tier 0, а результат tier 1 лучше. Для сервера, работающего часами, это лёгкая победа. Для короткоживущей функции, которая завершается раньше, чем что-либо достигнет tier 1, инструментирование - чистая цена без вознаграждения, что является одной из причин, по которым чувствительные к холодному старту рабочие нагрузки иногда это отключают. Более полное рассмотрение этого сценария есть в руководстве по сокращению времени холодного старта для AWS Lambda на .NET 11.

Как увидеть переход между уровнями в действии

Рассуждать о многоуровневой компиляции гораздо проще, когда вы можете её увидеть. Среда выполнения выдаёт события ETW/EventPipe для каждой JIT-компиляции, помеченные уровнем, так что dotnet-trace может показать вам переходы:

# .NET 11 SDK 11.0.100. Capture JIT and tiering events for a running process.
dotnet-trace collect --process-id 1234 \
  --providers Microsoft-Windows-DotNETRuntime:0x1000:5

События MethodJittingStarted и MethodLoadVerbose несут поле уровня оптимизации, так что метод, появляющийся дважды, сначала как tier 0, а затем как tier 1, - это то самое продвижение, которое вы ищете. Пошаговое руководство по чтению этих трассировок смотрите в статье о профилировании приложения .NET с помощью dotnet-trace.

Если вы хотите увидеть машинный код, который JIT создал на каждом уровне, DOTNET_JitDisasmSummary=1 печатает по одной строке на метод с его уровнем, а установка DOTNET_JitDisasm в имя метода выдаёт сам ассемблер. Инструменты вроде просмотрщика дизассемблирования Rider 2026.1 показывают тот же вывод без борьбы с переменными среды. Сравнение дизассемблирования tier 0 и tier 1 одного и того же метода - самый быстрый способ выработать интуицию о том, что даёт «оптимизированный».

Ловушка измерений, в которую попадают все

Самая частая ошибка при многоуровневой компиляции - измерять код tier 0 по случайности. Если вы пишете цикл с секундомером, который выполняет ваш метод несколько тысяч раз и усредняет результат, ранние итерации выполняются в медленном tier 0, продвижение происходит на полпути, и ваше среднее - бессмысленная смесь двух разных кусков кода.

Исправление - прогрев. Выполните метод достаточно раз, чтобы запустить продвижение, дождитесь завершения фоновой компиляции и только затем начинайте измерять. Именно это делает за вас BenchmarkDotNet: он выполняет фазу прогрева, определяет, когда времена стабилизируются, и измеряет код tier 1 в установившемся режиме. Не создавайте вручную микробенчмарк с Stopwatch и не доверяйте числу; вы почти наверняка измеряете не тот уровень. Если необходимо, вы можете форсировать вопрос, установив DOTNET_TieredCompilation=0, чтобы всё компилировалось сразу в оптимизированный код, но это меняет поведение запуска и тоже не соответствует тому, как ваше приложение работает в продакшене.

Регуляторы и когда к ним действительно стоит прикасаться

Значения по умолчанию хороши. Команда .NET настраивает их на огромном наборе реальных приложений, и честный ответ для большинства проектов - оставить каждую настройку в покое. При этом рычаги существуют не просто так:

Все они соответствуют свойствам MSBuild в .csproj и ключам System.Runtime.* в runtimeconfig.json, задокументированным в справочнике Microsoft по настройкам конфигурации компиляции. Если вы взвешиваете многоуровневый JIT против полного отказа от JIT, компромиссы описаны в Native AOT против ReadyToRun против чистого JIT и в более глубоком разборе того, во что вам обходится Native AOT, поскольку код AOT вообще никогда не получает переоптимизацию Dynamic PGO.

Ментальная модель, которую стоит удержать: ваш процесс запускается в дешёвом, быстром в создании коде tier 0, который незаметно делает заметки; небольшое число методов доказывает, что они горячие; фоновый поток переписывает именно эти методы полным оптимизатором, опираясь на заметки; а OSR ловит всё, что застряло в цикле, который счёт вызовов пропустил бы. Как только вы сможете увидеть это в трассировке, многоуровневая компиляция перестанет быть чёрным ящиком и станет инструментом, о котором вы можете рассуждать.

Похожее

Источники

Comments

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

< Назад