p99 ×4 после деплоя на ровном месте: как Dynamic PGO в .NET 9 роняет ваш сервис по первым 30 вызовам

от автора

Самый дорогой баг в .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.cppconst 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 под HealthCheckHandler5      │   45    │ боевой трафик пошёл, начались промахи fast path10     │   62    │ branch predictor устаканился, но indirect call остался30     │   68    │ плато: тот же IL крутится по slow path60     │   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

Маска 0x1000JitKeyword, уровень 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, дампы или жалобы клиентов. Особенно интересны истории, где “перезапустите под” срабатывало — и где не срабатывало. Инструменты пока сырые, и любой полевой опыт здесь на вес золота.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1044148/