В прошлый раз мы гоняли
hello world в цикле и увидели путь горячего метода: Tier0 → Instrumented Tier0 → Tier1. Но в той же сводке JIT были и другие строки, которые я тогда не показал:Program:Hello() [Tier0]
Program:Hello() [Instrumented Tier0]
System.Console:WriteLine(System.String) [Instrumented Tier1]
Program:Hello() [Tier1]
System.Console:WriteLine(System.String) [Tier1]
В одной программе два метода дошли до Tier1 разными путями:
Hello() прошёл через Tier0 и Instrumented Tier0, а Console.WriteLine обошёлся без Tier0 и сразу попал в Instrumented Tier1 🤔 Разберёмся, от чего это зависит 👇📦 Код скомпилирован заранее
Console.WriteLine — метод из библиотек самого .NET, а они поставляются не только с IL, но и с готовым машинным кодом. Такой формат называется ReadyToRun (R2R), и при первом вызове JIT-комплияция этому методу не нужнаГотовый код оптимизирован, но слабее, чем умеет JIT, и не знает, как метод работает именно в твоей программе. Поэтому горячий метод JIT всё равно перекомпилирует. Но откатываться ради профиля на медленный Tier0 было бы жалко, так что профиль собирают прямо на оптимизированном коде со счётчиками
Это
Instrumented Tier1 — разновидность Tier1, в которой тоже собирается профиль, но уже на оптимизированном коде🔁 В методе долгий цикл
Возьмём метод, который вызывается один раз, но крутит внутри цикл заданное число итераций. Сколько будет итераций, JIT заранее не знает, он видит только, что в методе есть цикл
static long LoopOnce(int n)
{
long t = 0;
for (int i = 0; i < n; i++) t += i;
return t;
}
Допустим, вызван
LoopOnce(1_000_000_000). На счётчике вызовов метода тут будет единица (сам метод же вызван один раз), однако тело цикла выполнится миллиард раз. И даже если метод потом будут вызывать снова и он дорастёт до Tier1, оптимизированный код достанется только новым вызовам, а этот миллиард итераций отработал бы на медленном коде. Но в сводке видно другое:Program:LoopOnce(int) [Instrumented Tier0]
Program:LoopOnce(int) [Tier1-OSR @0x10]
Обычного
Tier0 тут нет, потому что метод с циклом может разогреться из-за самого цикла, поэтому профиль для него начинает собираться с первого же вызова метода (Instrumented Tier0)А ещё в сам цикл встроен счётчик итераций. Примерно через 10 000 итераций JIT компилирует оптимизированную версию метода, и выполнение переключается на неё прямо посреди цикла: новый код подхватывает текущие значения
i и t и продолжает с того же места. Это место видно и в сводке: @0x10 — позиция в IL, где проверяется условие i < n 🦘Такая версия метода называется
Tier1-OSR — другая разновидность Tier1. OSR означает On-Stack Replacement, «замена на стеке»: код метода подменяется, пока его вызов ещё лежит на стеке, то есть прямо во время выполнения 🤯⏩ Можно обойтись без сбора профиля?
Можно. Как я говорил в прошлый раз, с выключенным профилированием (
DOTNET_TieredPGO=0) метод идёт напрямую Tier0 → Tier1А если пометить наш
Hello() атрибутом [MethodImpl(MethodImplOptions.AggressiveOptimization)], пропадёт и Tier0. JIT сразу скомпилирует метод со всеми оптимизациями, и в сводке останется одна строка:Program:Hello() [FullOpts]
FullOpts — это полная оптимизация вне всяких уровней, но по догадкам из вида кода, а не по реальному поведению 🤷🅰️ Что с этим делать
🟢 Tier0 → Instrumented Tier0 → Tier1 — путь по умолчанию. Готовый R2R-код пропускает Tier0, а долгий цикл получает Tier1 прямо на ходу
🟢 ReadyToRun ускоряет старт, но горячие методы JIT всё равно доработает
🟢 AggressiveOptimization редко бывает уместен: метод с ним лишается профилирования и оптимизируется по наитию, а не по профилю использованию🧠
🧑💻dp🥁
#dotnet #csharp #инженерныештучки #heavywednesday
