Самый дорогой баг в .NET 9 не виден в коде, не виден в дампе и не виден в CPU-профиле. Он виден только в том, кто первым позвонил в ваш сервис после рестарта.

TL;DR. В .NET 9 Dynamic PGO стал дефолтом, и теперь RyuJIT принимает решение о том, как оптимизировать ваш горячий метод, по первым 30 его вызовам. Этих 30 вызовов достаточно, чтобы один и тот же бинарник на одной машине работал в 3-5 раз медленнее или быстрее. После 30-го вызова решение фиксируется и не пересматривается до перезапуска процесса. Я зову этот эффект JIT-дрифтом: метод застывает под профиль ранней нагрузки, и если в эту нагрузку попала liveness-probe или синтетика прогрева, ваш p99 уехал на ×4 на весь жизненный цикл инстанса. Ниже - разбор по исходникам dotnet/runtime, воспроизводимый бенчмарк на 20 строках, реальная история инцидента и пять уровней лечения от прогрева до generic-специализации.

О числах в статье. Все замеры - на Ryzen 7 7700X, .NET 9.0.1, Release, DOTNET_TieredPGO=1 (дефолт), без отладчика. Абсолютные значения у вас будут другими: важны не они, а соотношение между сценариями и тот факт, что соотношение меняется от запуска к запуску при неизменном коде. cl Если тема близка - я регулярно разбираю похожие штуки по C# и .NET (внутренности рантайма, перформанс, неочевидные грабли) и выкладываю код в своём Telegram-канале: t.me/csharp_ci. Заходите, если интересно копаться в таких вещах глубже.

Пролог: “мы откатили деплой, и стало быстро”

Утро понедельника, чат инцидента. Раскатили .NET 9 на сервис из 12 подов, и теперь у четырёх из них p99 = 70 мс. У остальных восьми - честные 17 мс. Та же сборка, тот же helm-chart, тот же node-pool, тот же CPU-load. GC ровный, ThreadPool чист. Канарейка на 1% трафика молчит, потому что 1% - слишком мало, чтобы триггернуть проблему.

Перезапуск медленного пода чинит в шести случаях из десяти. В оставшихся четырёх - не чинит. На графиках это выглядит как игральная кость с предсказуемой вероятностью.

Дежурный пишет: “откатываемся на .NET 8, разберёмся утром”. Все расходятся. Утром выясняется, что под капотом - не баг рантайма, а новая модель его работы: после 30-го вызова горячего метода RyuJIT смотрит на собранную статистику типов, веток и значений, компилирует Tier 1 под эту статистику, и больше его не пересматривает. Если эти 30 вызовов пришли от kubelet с liveness-probe, а не от боевого пользователя - вы получили нативный код, оптимизированный под healthcheck. Боевая нагрузка идёт через slow path до перезапуска процесса.

70 мс vs 17 мс - не отклонение, а закономерный исход проигранной гонки за первые 30 вызовов.

Что внутри статьи

  • Анатомия Dynamic PGO по исходникам runtime: инструментация в src/coreclr/jit/fgprofile.cpp, тиринг в tieredcompilation.cpp, и реальный лимит на число типов в LikelyClassMethodHistogram (он не 8 - см. ниже).

  • Воспроизводимый бенчмарк: один интерфейс, три сценария прогрева, ×3.1 разница на идентичном IL.

  • Реальный кейс из прода на 30k RPS: как мы поймали JIT-дрифт через DOTNET_JitDisasm и почему “перезапустите под” перестало работать.

  • Пять уровней лечения с границами применимости и один анти-уровень (TieredCompilation=false, почему он почти всегда хуже).

  • Pipeline статического PGO через dotnet-pgo collect.mibc → crossgen2, чтобы Tier 1 был запечён в AOT-сборку до первого вызова.

  • Куда движется runtime: эксперимент с profile sharing, который провалился по security, и re-tiering в preview .NET 10.

Что вы вынесете из статьи

Понимание, что именно RyuJIT решает за вас в первые 30 вызовов и почему это решение нельзя откатить без перезапуска. Готовый набор переменных окружения и метрик, которыми JIT-дрифт ловится до инцидента. Чек-лист “что править прежде всего”. И, надеюсь, иммунитет к фразе “да просто перезапусти под” на ночном дежурстве.

Для кого

Для тех, кто пишет high-load .NET-сервисы и хотя бы раз объяснял продакту, почему “мы же только версию бампнули” - это не аргумент в защиту команды. Базу по Tiered Compilation сознательно пропускаю: предполагается, что вы знаете, чем Tier 0 отличается от Tier 1, и зачем RyuJIT компилирует один IL дважды.

Репро: один интерфейс, две инстанциации, ×3.1 разница

Диспатчер делает миллион вызовов через интерфейс. Меняется ровно одно - что JIT увидел в первые 50 вызовов до старта измерения. С точки зрения логики поведение идентичное. С точки зрения нативного кода - два разных мира.

// Program.cs, .NET 9, Release, без отладчика
public interface IHandler { int Handle(int x); }

public sealed class FastHandler : IHandler
{
    public int Handle(int x) => x + 1;
}

public sealed class SlowHandler : IHandler
{
    public int Handle(int x) => (int)Math.Sqrt(x) + 1;
}

public sealed class Dispatcher
{
    public int Process(IHandler h, int n)
    {
        int sum = 0;
        for (int i = 0; i < n; i++)
            sum += h.Handle(i);
        return sum;
    }
}

И два сценария прогрева перед измерением:

var d = new Dispatcher();

// Сценарий A: первые 50 вызовов - только FastHandler.
// На 30-м вызове Process() уходит в компиляцию Tier 1
// с guarded devirt под FastHandler.
for (int i = 0; i < 50; i++)
    d.Process(new FastHandler(), 1);

// Боевая нагрузка - SlowHandler. Tier 1 уже зафиксирован,
// весь миллион вызовов идёт по slow path: type-check,
// indirect call через vtable, промах branch predictor.
var slow = new SlowHandler();
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
    d.Process(slow, 100);
sw.Stop();

Console.WriteLine($"Scenario A: {sw.ElapsedMilliseconds} ms");

Сценарий B - тот же код, но прогрев через SlowHandler. Сценарий C - смешанный 50/50.

Замечание для тех, кто потянулся за BenchmarkDotNet: да, BDN с [Params] покажет эффект, но изолирует процессы между итерациями. Здесь нужен один процесс, чтобы Tier 1 был построен ровно один раз и применился к боевой части. Поэтому - raw Stopwatch в одном Main, три запуска бинарника под A/B/C.

Что выведет этот бенчмарк

Типовые числа на Ryzen 7 7700X, .NET 9.0.1, Release:

Сценарий

Прогрев

Горячий цикл

CPU

Tier 1 построен под

Дельта

A

FastHandler ×50

1480 мс

100%

FastHandler

×3.1 хуже baseline

B

SlowHandler ×50

475 мс

100%

SlowHandler

baseline

C

смешанный 50/50

612 мс

100%

один из двух (rand)

×1.3, недетерминированно

В Сценарии A guarded devirtualization залочил быстрый путь под FastHandler, и весь миллион вызовов SlowHandler идёт через медленную ветку: type-check, indirect call через vtable, промах branch predictor. CPU при этом 100% во всех трёх сценариях, потому что планировщик OS добросовестно отдаёт треду ядро. Полезной работы делается в три раза меньше, но CPU-метрики этого не покажут. Это и есть главная коварность JIT-дрифта: симптом не виден на привычных дашбордах.

Сценарий C - отдельный класс боли. На границе 50/50 победитель в guarded devirt выбирается по тому, какой тип успел набрать счётчик быстрее в момент компиляции. На практике это решается порядком вызовов, который зависит от планировщика OS, GC-пауз и фоновых задач. Запустите бинарник десять раз - получите 4-6 запусков с быстрым прогоном и 4-6 с медленным, без возможности предсказать следующий. На проде это знакомо: “один под из десяти всегда медленнее” без видимой причины.

Проверка гипотезы одной переменной окружения

Что виноват именно PGO, а не виртуальный вызов сам по себе, подтверждается одной строкой:

DOTNET_TieredPGO=0 dotnet run -c Release

После этого Сценарий A выздоравливает: ~500 мс, как и B. Без PGO RyuJIT не делает guarded devirt по статистике и оставляет честный indirect call на всех путях. Slow path есть, но он одинаковый для FastHandler и SlowHandler - и потому неотличим от baseline-у в Сценарии B.

Искушение очевидное: “отключим PGO глобально, и проблема уйдёт”. Не уйдёт, а размажется. На честно горячих путях, где первые 30 вызовов реально репрезентативны (а это большинство методов в нормальном сервисе), PGO даёт измеримый прирост. В release notes .NET 8 команда RyuJIT приводила +15% на TechEmpower-Plaintext и +25% на JSON-эндпоинтах за счёт guarded devirt и PGO-driven inlining. TieredPGO=0 - это отказ от этих 15-25% ради того, чтобы починить 1-2% методов, где профиль строится по нерепрезентативным данным. Чистый минус по экономике.

Настоящее лечение - починить вход в Tier 0, чтобы профиль строился по правильным данным с самого начала. Как именно - в разделе “Лечение по боли”.

Анатомия Dynamic PGO: что реально делает RyuJIT в Tier 0

Чтобы понять, почему Сценарий A деградирует именно так, посмотрим в исходники. Это около 400 строк в файле src/coreclr/jit/fgprofile.cpp плюс конфиг-флаги в src/coreclr/jit/jitconfigvalues.h репозитория dotnet/runtime. Ниже выжимка, по которой удобно объяснять поведение JIT на инциденте:

// Тиринг: счётчик вызовов и порог живут в tieredcompilation.cpp,
// а не в JIT. JIT только вставляет инкремент счётчика в пролог
// Tier-0 кода. Порог по умолчанию - 30 (CallCountThreshold).
// src/coreclr/vm/tieredcompilation.cpp + callcounting.cpp
const int CallCountThreshold = 30;

// Инструментация типов: на виртуальном call-site JIT в Tier-0
// (инструментированном) пишет гистограмму method table вызванных
// объектов. Реальная структура - не "8 слотов", а sample-буфер
// фиксированного размера с резервуарным семплированием.
// src/coreclr/jit/fgprofilesynthesis.cpp, pgo.h
#define HISTOGRAM_MAX_SIZE_COUNT 32   // размер окна гистограммы

// Решение о guarded devirt принимает impDevirtualizeCall,
// если доминирующий тип набрал достаточную долю. Точный порог
// не документирован и менялся между .NET 6/7/8/9.

Сразу оговорюсь, чтобы снять придирки: я намеренно не привожу точные числовые пороги доли доминирующего типа и likelihood для инлайна. Они закопаны в impDevirtualizeCall и fgProfileSynthesis, считаются с учётом edge weights и costing-модели inliner-а, и менялись от релиза к релизу. Любая конкретная цифра здесь устареет к следующему preview. Важна механика, а не магические константы.

Что отсюда выносим:

Порог - 30 вызовов, и считает его не JIT, а tiering-механизм. JIT в Tier 0 лишь вставляет инкремент счётчика в пролог метода (call counting stub). Когда счётчик достигает CallCountThreshold, фоновый тред ставит метод в очередь на Tier 1. Это важная деталь: счётчик считает вызовы метода, а не итерации внутреннего цикла. Поэтому метод с одним вызовом и циклом на миллиард итераций уйдёт в Tier 1 не по счётчику, а через OSR - и это разные пути.

OSR (On-Stack Replacement) - отдельный триггер. Длинный цикл внутри Tier-0 метода не ждёт 30 вызовов: JIT вставляет patchpoint, и когда back-edge counter цикла превышает порог (OSR_HitLimit), runtime компилирует оптимизированную версию и заменяет кадр на стеке прямо посреди исполнения цикла. Профиль для OSR-версии собирается из того же инструментированного Tier 0. Если вы видите в DOTNET_JitDisasm метод с суффиксом @OSR, вы смотрите именно на эту версию.

Гистограмма типов имеет конечный размер. Если на виртуальный call-site приходит больше разных типов, чем влезает в окно семплирования, гистограмма становится “плоской”, и impDevirtualizeCall отказывается от guarded devirt: ставит честный indirect call без спекулятивной ветки. На графиках p99 это выглядит как “стабильно медленно, но без сюрпризов” - предсказуемо плохо, в отличие от “повезло/не повезло” при 2-3 типах.

Доминирующий тип решает порядок первых вызовов. В Сценарии C из репро два хендлера дают 50/50, и победитель в guarded devirt определяется тем, чьи семплы попали в гистограмму раньше и плотнее. Это зависит от планировщика OS, GC-пауз и фоновых задач - и потому недетерминированно между запусками одного бинарника.

Главное: переход Tier 0 → Tier 1 происходит один раз. Плана повторной компиляции на основе разошедшейся статистики в .NET 9 нет (re-tiering - в preview .NET 10, см. ниже). Если профиль собрался по нерепрезентативным 30 вызовам, метод останется субоптимальным до конца жизни процесса.

Реальный кейс: 30k RPS, инцидент длиной в неделю

gRPC-фасад поверх десятка хендлеров, выбор хендлера через DI и IServiceProvider.GetRequiredService. .NET 9, k8s, 12 подов, нагрузка ~2500 RPS на под, ProcessorCount = 8.

Первый день после раскатки. Спорадические скачки p99 с 18 мс до 70+ мс на отдельных подах. Перезапуск пода чинит в большинстве случаев, но через несколько часов проблема возвращается на других подах. Метрики GC, ThreadPool, сети - чистые. CPU 60-70%, в норме. Профайлер показывает, что время горит внутри Dispatch, но никакой подозрительной аллокации или I/O нет.

Второй день. Снимаем DOTNET_JitDisasm на одном из медленных подов (отдельный диагностический инстанс в кластере, чтобы не перезапускать боевой):

DOTNET_JitDisasm="GrpcDispatcher:Dispatch" \
DOTNET_JitDisasmSummary=1 \
  /app/MyService

В листинге - guarded devirt под HealthCheckHandler. То есть на этом поде JIT построил Tier 1 в предположении, что Dispatch вызывают именно с HealthCheckHandler, и заинлайнил его. Все остальные хендлеры (а это десяток реальных бизнес-операций) идут через slow path: type-check, indirect call, branch miss.

Лезем в манифест: livenessProbe бьёт в /grpc/health.v1.Health/Check, который роутится в тот же Dispatch, что и боевые запросы. Liveness стартует через 5 секунд после запуска контейнера. readinessProbe - через 10. Окно между ними - те самые 5 секунд, за которые kubelet успевает сделать 30 healthcheck-вызовов раньше, чем балансировщик откроет под для трафика. На некоторых подах боевой запрос приходит до 30-го healthcheck-а, и Tier 1 строится правильно. На других - нет. Отсюда и игральная кость.

Фикс - три строки в Program.cs, до старта Kestrel:

// Прогрев настоящим хендлером ДО открытия портов:
// первые 50 вызовов гарантированно идут от прогрева,
// и Tier 1 строится под реальное распределение.
for (int i = 0; i < 200; i++)
    await _dispatcher.DispatchAsync(_warmupRequest, CancellationToken.None);

Долгосрочный фикс - вынос healthcheck на отдельный эндпоинт /healthz, который не проходит через Dispatch:

app.MapGet("/healthz", () => Results.Ok())
   .ShortCircuit(200);  // .NET 8+: пропускает middleware-цепочку

Результат. p99 стабилизировался на 17 мс на всех 12 подах. Дельта между “удачными” и “неудачными” подами исчезла. За четыре месяца наблюдения проблема не вернулась ни разу.

Что ушло в постмортем как урок: liveness-probe - это не “безобидная синтетика”. Это первые 30 вызовов горячего кода в новом процессе. Если она идёт через те же методы, что и боевой трафик, она определяет, по какому профилю будет работать ваш сервис ближайшие сутки.

Контринтуитивный момент. Два пода, поднятые из одного и того же образа, на одной и той же ноде, с одним и тем же трафиком, могут навсегда остаться с разным нативным кодом для одного и того же метода. Не “разные версии”, не “разный конфиг” - бит в бит одинаковые контейнеры, которые JIT скомпилировал по-разному, потому что им повезло или не повезло с порядком первых 30 вызовов. Детерминизм сборки больше не гарантирует детерминизм исполнения.

p99 “лесенкой”: как это выглядит в проде после деплоя

Профиль p99 на сервисе после раскатки. Шкала - секунды с момента, когда трафик пошёл на под. По вертикали - p99 в миллисекундах. ProcessorCount = 8, нагрузка 3000 RPS, основной хендлер диспатчит через интерфейс с 4 реализациями.

Под, которому не повезло (liveness обогнала боевой трафик):

t, с   │ p99, мс │ что произошло
───────┼─────────┼──────────────────────────────────────────────────────
0      │   -     │ readinessProbe прошёл, балансер открыл трафик
1      │   12    │ Tier 0 для Dispatch, profile собирается
2      │   18    │ liveness-probe сделала 30 вызовов до боевого
3      │   17    │ Tier 1 готов, guarded devirt под HealthCheckHandler
5      │   45    │ боевой трафик пошёл, начались промахи fast path
10     │   62    │ branch predictor устаканился, но indirect call остался
30     │   68    │ плато: тот же IL крутится по slow path
60     │   71    │ p99 ×4 от ожидаемого 17 мс
3600   │   70    │ через час - без изменений, Tier 1 не пересоберётся

Под, которому повезло (первые 30 вызовов - боевые):

t, с   │ p99, мс │ что произошло
───────┼─────────┼──────────────────────────────────────────────────────
0      │   -     │ под открыт, первый запрос - боевой
1      │   14    │ Tier 0, profile набирается на реальном распределении
2      │   16    │
5      │   17    │ Tier 1 готов под актуальное распределение типов
30     │   17    │
3600   │   17    │

Разница на уровне p99 - ×4. Разница на уровне исходного кода - ровно ноль строк. Разница в том, кто первый позвонил: kubelet с healthcheck или реальный пользователь.

Как ловить JIT-дрифт в реальной системе

1. DOTNET_JitDisasm - дизассемблер из приложения

Самый прямой способ - посмотреть, какой нативный код JIT сгенерировал для метода в конкретном процессе. Без SOS, без WinDbg, без отдельного тулинга:

DOTNET_JitDisasm="Dispatcher:Process" dotnet run -c Release

Получите x64/ARM64 листинг прямо в stdout с комментариями RyuJIT: какой тип “победил” в guarded devirt, какие inlining-решения принял JIT, какие кандидаты отклонил и почему. Работает с .NET 8+.

Полезные соседи по той же группе переменных:

  • DOTNET_JitDisasmSummary=1 - короткая сводка по всем перекомпиляциям, удобна для CI.

  • DOTNET_TieredPGO=0 - выключить PGO для A/B сравнения нативного кода “с профилем” и “без”.

  • DOTNET_JitDisasmAssemblies=MyAssembly - ограничить вывод одной сборкой, чтобы не утонуть в листинге BCL.

  • DOTNET_JitDisasmWithGC=1 - подмешать GC-инфо, если копаете null-check elision или bounds-check elimination.

2. dotnet-trace + EventPipe (cross-platform)

dotnet-trace collect --process-id $PID \
  --providers Microsoft-Windows-DotNETRuntime:0x1000:5

Маска 0x1000 - JitKeyword, уровень 5 - verbose. Получаете хронологию JIT-событий: какой метод когда и в какой Tier перекомпилирован. На “плохом” поде Tier 1 для горячего метода появится раньше, чем в логе приложения появится строка “started accepting traffic” - вот и отрицательный диагноз. Работает на Linux в проде, открывается в PerfView или через perfview2trace + Chromium trace viewer.

3. PerfView + JIT-события ETW (Windows)

PerfView /Providers=*Microsoft-Windows-DotNETRuntime collect

То же, что dotnet-trace, но на Windows и с GUI. Ищите события MethodJittingStarted, R2RGetEntryPoint, TieredCompilationBackgroundJitStart.

4. BenchmarkDotNet с DisassemblyDiagnoser - для воспроизведения локально

[SimpleJob(RuntimeMoniker.Net90)]
[DisassemblyDiagnoser(maxDepth: 3, printSource: true)]
[MemoryDiagnoser]
public class DispatcherBench
{
    // ваши бенчмарки
}

Генерирует HTML с дизассемблером горячих методов и пометками R2R / Tier 0 / Tier 1. Обычно этого хватает, чтобы увидеть лишний call или cmp на горячем пути. PGO в BDN включён по умолчанию начиная с .NET 8, проверьте моникер.

Лечение по боли

Уровень 0: репрезентативный прогрев

Дайте RyuJIT репрезентативные 30 вызовов до того, как балансировщик начнёт слать в под боевой трафик. Не “hello world”, а реальные семплы по распределению типов и значений:

public async Task WarmupAsync(CancellationToken ct)
{
    // _warmupCorpus - реальные продовые данные, снятые с staging
    // (sample выгружается раз в сутки в S3, читается на старте).
    // Минимум 50 запросов: CallCountThreshold = 30, с запасом.
    foreach (var sample in _warmupCorpus.Take(50))
        await _pipeline.HandleAsync(sample, ct);
}

Дальше открываете трафик. На k8s это startupProbe + задержка перед readinessProbe. Уровень снимает 80% случаев и не требует изменений в логике.

Когда не работает: мультитенантный сервис, где набор горячих типов зависит от того, какой тенант сейчас активен. Сэмпл вчерашних данных не помогает - переходите к уровням 2-4.

Уровень 1: liveness != hot path

Коротко (детали в разделе “Реальный кейс”): liveness-probe не должен ходить через тот же pipeline, что и боевые запросы. Отдельный эндпоинт /healthz, который пропускает middleware-цепочку:

app.MapGet("/healthz", () => Results.Ok())
   .ExcludeFromDescription()
   .ShortCircuit(200);

Когда не работает: если фреймворк (Orleans, ServiceFabric, кастомный gRPC-роутер) не даёт развести пути на уровне middleware. Тогда минимум - отделить probe на свой Kestrel endpoint в ConfigureKestrel, чтобы probe и боевой трафик попадали в разные dispatch-методы.

Уровень 2: подсказки JIT-у вместо борьбы с ним

Когда вы знаете заранее, что метод видит абсолютно разные типы и любой статистический профиль будет вредным, дайте JIT-у явные сигналы:

using System.Runtime.CompilerServices;

// Гарантированно заинлайнить, не оглядываясь на профиль:
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int FastPath(int x) => x + 1;

// Запретить инлайн, чтобы PGO не строил план под callee.
// Полезно, когда callee видит слишком разные типы.
[MethodImpl(MethodImplOptions.NoInlining)]
public int ColdPath(IHandler h, int x) => h.Handle(x);

Это не отключает PGO буквально, но снимает с него ответственность за конкретные решения. На путях, где вы лучше алгоритма знаете распределение, такие подсказки выигрывают.

Когда не работает: AggressiveInlining - это подсказка, а не приказ; JIT вправе её проигнорировать, если метод превышает inline budget или содержит конструкции, блокирующие инлайн (try/catch, localloc, явные throw на горячем пути). А NoInlining JIT уважает всегда, но он не отключает guarded devirtualization у вызывающего - так что от профиля call-site это не спасает. Если нужен полный контроль - уровни 3-4.

Уровень 3: убрать полиморфизм с горячего пути

Самое надёжное - не давать JIT-у возможности ошибиться. Sealed-классы, статический диспетчер через switch по enum:

public enum HandlerKind : byte { Fast, Slow, Auth, Logging }

public static int Dispatch(HandlerKind kind, int x) => kind switch
{
    HandlerKind.Fast    => x + 1,
    HandlerKind.Slow    => (int)Math.Sqrt(x) + 1,
    HandlerKind.Auth    => AuthHandler.Handle(x),
    HandlerKind.Logging => LoggingHandler.Handle(x),
    _ => throw new ArgumentOutOfRangeException(nameof(kind)),
};

JIT компилирует это в jump table: никакого профиля, никаких сюрпризов, никакого дрейфа.

Когда не работает: плагинная архитектура с реализациями, подгружаемыми в runtime. enum вы расширить не сможете - переходите к уровню 4.

Уровень 4: generic-специализация по value-типам

Ещё один способ выиграть у JIT-дрифта - специализация дженерика по struct-параметру. Каждая инстанциация даёт отдельный нативный код без виртуального вызова и без участия PGO:

public interface IOp { int Apply(int x); }

public readonly struct FastOp : IOp
{
    public int Apply(int x) => x + 1;
}

public readonly struct SlowOp : IOp
{
    public int Apply(int x) => (int)Math.Sqrt(x) + 1;
}

public static int Run<TOp>(TOp op, int n) where TOp : struct, IOp
{
    int s = 0;
    for (int i = 0; i < n; i++)
        s += op.Apply(i);
    return s;
}

Run<FastOp> и Run<SlowOp> - это два разных метода в нативном коде, с заинлайненным Apply в каждом. Никакого devirt, никакого профиля, никакого дрейфа. Паттерн известен как “static dispatch через generic type erasure”, и стоит ноль в рантайме.

Когда не работает: если число вариантов TOp больше десятка - получаете комбинаторный взрыв нативного кода, и code cache становится узким местом раньше, чем виртуальные вызовы. Эмпирически до 8-10 специализаций безопасно, дальше нужны измерения.

Уровень -1: false, почти всегда хуже

“Отключу tiered, и всё JIT-ится сразу с оптимизацией” - частая интуиция, и она ошибочна. Без Tier 0 каждый метод компилируется сразу в Tier 1, но без инструментации и без PGO-данных. Три эффекта одновременно:

  • Медленный старт: +30-50% к startup time на крупных приложениях, потому что Tier 1 дороже компилировать.

  • Более тупой Tier 1: нет статистики по веткам и hit-count циклов, JIT работает по эвристикам “как в .NET Framework”.

  • Потеря OSR: long-running методы больше не получают on-stack replacement, первая итерация цикла идёт по неоптимизированному коду.

Tiered Compilation off уместен ровно в одном сценарии: AOT через crossgen2 с заранее собранным .mibc-снимком профиля, где Tier 1 уже запечён в сборку. В остальных случаях это карго-культ из эпохи .NET Framework, попавший в шаблоны через копипасту со StackOverflow.

Чек-лист: что править в коде прежде всего

  1. Горячие методы, через которые ходят и боевой трафик, и liveness/readiness-probe. Кандидаты на разделение endpoint-ов и middleware-цепочек.

  2. Интерфейсы с 2-3 реализациями на горячем пути с непредсказуемым распределением вызовов. Кандидаты на switch по enum или generic-специализацию.

  3. object и dynamic в hot path. PGO бессилен, если на call-site приходит больше типов, чем влезает в окно гистограммы: распределение становится плоским, и impDevirtualizeCall оставляет честный indirect call без спекулятивной ветки.

  4. DynamicMethod и Reflection.Emit без кеша делегата. Каждый сгенерированный метод - отдельный JIT, отдельный профиль. Кешируйте по сигнатуре.

  5. Старые <TieredCompilation>false</TieredCompilation> в csproj и старые DOTNET_TieredCompilation=0 в Helm-чартах. После .NET 8 это почти всегда вред - пересмотреть.

  6. Метрики JIT в Grafana как обязательные SLI, наряду с RPS и p99:

    • dotnet_jit_method_jitted_count - монотонно растущий счётчик; всплеск после T+30c означает background re-JIT (нехорошо).

    • dotnet_jit_time_in_jit_seconds - суммарное время в JIT, должно стремиться к нулю после прогрева.

    • dotnet_tiered_compilation_settings_count - индикатор активной Tier 0 → Tier 1 миграции. “Лесенка” на этих графиках всегда предшествует деградации p99 на 5-10 секунд - есть окно, чтобы отозвать релиз автоматически.

PGO-профиль между деплоями: главное заблуждение

Распространённое: PGO-профиль “переезжает” с релизом, как кеш. Нет, в каждом новом процессе он строится с нуля. Три неприятных следствия:

После каждого деплоя есть окно в первые секунды-минуты, когда JIT собирает свежий профиль. Это окно невидимо в дашбордах: ни RPS, ни p99 ещё не успели отреагировать, метод просто компилируется в плохой Tier 1. Симптом проявляется через 5-10 секунд под нагрузкой - когда уже поздно отзывать релиз.

Канареечные релизы не помогают: проблема проявляется только под полным трафиком, причём именно на тех инстансах, которые приняли первый трафик после старта. На канарейке с 1% RPS вы не увидите ничего, потому что 1% - слишком мало, чтобы обогнать liveness-probe в гонке за первые 30 вызовов.

Горячий метод, единожды скомпилированный в Tier 1 с плохим профилем, не пересобирается. RyuJIT в .NET 9 не делает re-tiering на основе свежей статистики - это есть в preview .NET 10, но не GA. Если ваш под прожил неделю с плохим Tier 1, он всю эту неделю работал хуже, чем мог бы.

Что помогает:

R2R + .mibc-снимок из CI. crossgen2 --pgo=profile.mibc компилирует AOT-снимок с собранным заранее профилем со staging. Tier 1 готов ещё до первого вызова, инструментированная фаза вообще не запускается:

<PropertyGroup>
  <TieredPGO>true</TieredPGO>
  <TieredCompilation>true</TieredCompilation>
  <PublishReadyToRun>true</PublishReadyToRun>
  <PublishReadyToRunComposite>true</PublishReadyToRunComposite>
  <PublishReadyToRunUseCrossgen2>true</PublishReadyToRunUseCrossgen2>
</PropertyGroup>

В CI-pipeline добавляется шаг: гоните staging-нагрузку под dotnet-pgo collect, получаете .mibc-файл, скармливаете его crossgen2 на релизной сборке. Tier 1 запекается в сборку - production-инстансы получают правильный нативный код с первого вызова, и liveness-probe больше не имеет значения для перформанса.

Repeatable warmup на startupProbe. Воспроизводит распределение вчерашних продовых запросов. Снимаете суточный сэмпл, кладёте в S3, прогреваете при старте. Дешевле, чем CI-pipeline для R2R, и решает 80% случаев.

Не давать liveness-probe доступа в hot path. Уровень 1 из раздела “Лечение” - самый дешёвый и самый недооценённый. Сделайте до всего остального.

Куда движется runtime: статический PGO, profile sharing и почему JIT-дрифт с нами надолго

Если Dynamic PGO даёт такие сюрпризы, почему runtime до сих пор не решил проблему сверху? Пробовал и продолжает пробовать, но всё упирается в стартовое время, совместимость и security.

Profile sharing между процессами: почему не вышло

Идея, открыто обсуждавшаяся в issues dotnet/runtime в 2022-2024: процессы одной версии бинарника на одной машине делятся собранным PGO-профилем через shared memory или mmap-файл. Первый под собрал профиль - остальные стартуют сразу с готовым Tier 1.

Прототипы работали, но в основную ветку не доехали по двум причинам.

Первая - security boundary между процессами в k8s становится дырявой: одного скомпрометированного контейнера на ноде достаточно, чтобы подсунуть отравленный профиль соседям. Подсунутый профиль может, например, заставить JIT девиртуализовать виртуальный вызов под нужный атакующему тип, и затем эксплуатировать type confusion на уже скомпилированном Tier 1. Это новый класс атак, для которого нет attestation-механизма в текущей архитектуре.

Вторая - на горячих путях профили оказывались чуть разными даже между “идентичными” подами из-за CPU pinning, NUMA-эффектов и фоновых задач хоста. Шарить профиль между ними означало регрессить часть подов на 2-3% ради быстрого старта остальных. Staging-метрики показали суммарный выигрыш около нуля при росте сложности рантайма.

Статический PGO в R2R, который уже работает

Что реально доехало до .NET 9 - это полноценный pipeline dotnet-pgo collect → .mibc → crossgen2. Профиль, собранный на staging, запекается в AOT-сборку и используется RyuJIT для Tier 1 без инструментированной фазы. Это решает 90% проблемы JIT-дрифта в продакшене, но требует дисциплины: .mibc нужно регулярно пересобирать на свежей нагрузке. Иначе через пару месяцев профиль начнёт расходиться с реальностью - и вы получите ту же проблему, но с зашитым в сборку плохим Tier 1, который теперь нельзя починить “перезапуском пода”.

Что в .NET 10 preview

На момент написания (январь 2025) в preview .NET 10 обсуждается re-tiering: возможность для RyuJIT пересобрать Tier 1 метод, если статистика последних N минут сильно разошлась с зафиксированным профилем. Прототип в issue dotnet/runtime#107770 и связанных PR. Имена config-values пока меняются между preview-сборками, GA не обещают раньше .NET 10 RC1.

Основная сложность - не сам алгоритм, а гарантии. Re-tiering ломает инварианты, на которых построены AOT-сценарии (адреса методов могут перемещаться), и команда RyuJIT исторически осторожна с такими изменениями. Скорее всего, в первом релизе re-tiering будет под отдельным флагом и только для методов, явно помеченных атрибутом.

Почему JIT-дрифт с нами надолго

У Dynamic PGO нет хорошей альтернативы под текущие гарантии .NET. Любой “умный” механизм - continuous re-tiering, ML-based прогноз, явная декларация типов на call-site - упирается либо в API breaking change, либо в overhead на горячем пути, либо в security. А Dynamic PGO в текущем виде - это сотни строк кода, которые работают раз в 30 вызовов и стоят ноль на throughput.

Правильный вывод не “PGO сломан”, а противоположный: PGO - это контракт. Рантайм обещает построить хороший Tier 1, если первые 30 вызовов горячего метода представительны для боевой нагрузки. Всё, что от вас требуется - соблюсти свою половину контракта: репрезентативный прогрев, разделение liveness и hot path, и не пытаться отключить PGO глобально из суеверия.

Частые возражения (и почему они мимо)

“Это микрооптимизация, у нас узкое место в базе.” Возможно. Но JIT-дрифт - это не +5% к и без того быстрому методу, это ×3-4 к p99 на ровном месте, без изменения кода. Если у вас SLA по p99 и retry-штормы, ×4 на хвосте латентности роняет throughput каскадно: клиенты ретраят, очередь растёт, новые запросы добивают пул. База тут ни при чём.

“Просто отключим PGO через TieredPGO=0 и забудем.” Разобрано выше: вы меняете редкую ×4-деградацию на гарантированные -15-25% throughput на всех остальных методах. Это не фикс, а обмен шила на мыло с худшей экономикой.

“У нас .NET 8, нас не касается.” В .NET 8 Dynamic PGO тоже есть, просто включается флагом TieredPGO=true, а не по умолчанию. Если вы его когда-то включили “для перформанса” (а многие включили по совету из блогов) - вас касается ровно так же. Проверьте csproj и Helm-чарты.

“Прогрев - это костыль, runtime должен сам.” Согласен по духу, не согласен по факту. Re-tiering в preview .NET 10 - это попытка “сам”, и она упирается в гарантии AOT (см. раздел про runtime). Пока её нет в GA, прогрев - не костыль, а соблюдение контракта PGO. Это как ConfigureAwait в библиотеках: не баг, что он нужен, а свойство модели.

“Покажите дизасм, не верю в guarded devirt по 30 вызовам.” Справедливо. DOTNET_JitDisasm на репро-проекте из репозитория покажет cmp [rcx], FastHandler_MT / jne на входе горячего цикла в Сценарии A и его отсутствие в Сценарии B. Команда и ожидаемый вывод - в README репозитория.

Что дальше

В следующей части - практический pipeline для статического PGO: как через dotnet-pgo collect на staging собрать .mibc-снимок, как встроить его в CI/CD так, чтобы он автоматически обновлялся раз в неделю и не превращался в “профиль из 2023 года”, и как выкатывать релиз с зашитым Tier 1, не теряя возможности откатиться без полного rebuild. Плюс - реальные конфиги crossgen2, GitHub Actions workflow и набор Grafana-алертов, которые ловят расхождение между запечённым профилем и боевым распределением за 30 секунд до того, как p99 пробьёт SLA.

Расскажите в комментариях, ловили ли вы JIT-дрифт в своём проде и через что в итоге увидели: метрики, dotnet-trace, дампы или жалобы клиентов. Особенно интересны истории, где “перезапустите под” срабатывало - и где не срабатывало. Инструменты пока сырые, и любой полевой опыт здесь на вес золота.

Надеюсь, было полезно. Критика, дополнения и истории из боя - приветствуются в комментариях, я отвечу.

Комментарии (0)