Привет!
Разберём ReadyToRun (R2R) — технологию предкомпиляции в .NET. Многие включают её, надеясь на супер ускорение, а потом удивляются результатам. Посмотрим, как это работает на самом деле и где реально помогает.
ReadyToRun — это AOT-компиляция для .NET. Обычно приложение поставляется в IL-коде, который JIT превращает в машинный код во время выполнения. R2R компилирует код заранее при публикации проекта — в итоговых DLL лежит и IL, и готовые машинные инструкции. При запуске CLR просто берёт нативный код без пауз на компиляцию.
Включается элементарно:
dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true
Или в csproj:
<PropertyGroup> <PublishReadyToRun>true</PublishReadyToRun> </PropertyGroup>
После этого ваши библиотеки раздуваются в 2-3 раза по размеру. DLL весом 500 КБ превращается в 1.5 МБ. Причина проста, внутри теперь два представления кода. IL остаётся для совместимости и fallback, а рядом лежит нативный код для целевой платформы.
Казалось бы, какая разница, место на диске дешёвое. Но эти мегабайты нужно прочитать с диска при загрузке приложения. На HDD это медленно. Получается парадокс,экономим время на JIT-компиляции, но тратим на чтение больших файлов. В реальности простое ASP.NET Core приложение без R2R весит 12 МБ и загружается за 180 мс, а с R2R 34 МБ за 210 мс. Само приложение запускается медленнее, зато первый полезный код выполняется на 50 мс быстрее. Итоговый выигрыш зависит от сценария.
Дальше интереснее.
R2R-код оптимизирован хуже JIT-версии. Crossgen2 не знает, на каком процессоре будет код, какие данные в кеше, какие ветки горячие. Он делает консервативные предположения и выдаёт усреднённую версию. JIT работает в runtime и видит реальность — модель CPU, паттерны вызовов, типы через интерфейсы. Благодаря этому JIT создаёт более быстрый код для конкретного случая.
Простой пример. Есть метод суммирования массива:
public int Sum(int[] array) { int sum = 0; for (int i = 0; i < array.Length; i++) sum += array[i]; return sum; }
R2R скомпилирует обычный скалярный цикл. JIT, видя горячий код, применит векторизацию через SIMD, развернёт цикл, уберёт лишние проверки границ, использует конкретные инструкции вашего CPU. Разница — в 3-4 раза по скорости на длинных массивах.
.NET понимает эту проблему и использует Tiered Compilation. Без R2R при первом вызове метода JIT быстро компилирует его с минимальными оптимизациями (Tier 0), а когда метод становится горячим (30+ вызовов), перекомпилирует с агрессивными оптимизациями (Tier 1). С R2R при первом вызове используется готовый R2R-код (условный Tier 0), а при нагреве JIT всё равно перекомпилирует его в Tier 1, выбрасывая R2R-версию. То есть R2R такая вот временная затычка для холодного старта, а не финальная версия кода.
Внутри CLR это выглядит примерно так:
if (method.HasR2RCode && method.CallCount < 30) return method.R2RNativeCode; else if (method.CallCount >= 30) return JitCompiler.CompileTier1(method, profilingData);
В .NET 6+ есть Dynamic PGO — JIT собирает статистику во время работы, какие ветки if чаще, какие типы через интерфейсы и применяет эти знания при перекомпиляции в Tier 1.
С R2R интересная ситуация, библиот��ки .NET уже содержат статический PGO. Microsoft прогнала стандартную библиотеку через тысячи тестов, собрала профили и вшила в R2R-код. Поэтому даже R2R-версия List<T> или JsonSerializer уже прилично оптимизирована. Ваш код такого профиля не имеет, Crossgen2 не знает, как вы используете свои классы, и генерирует консервативную версию. Dynamic PGO работает поверх R2R: горячий метод всё равно перекомпилируется с учётом реальных данных.
Можно отключить R2R для эксперимента:
$env:DOTNET_ReadyToRun = "0" $env:DOTNET_TieredPGO = "1" dotnet run -c Release
Теперь весь код компилируется JIT с нуля, и видна чистая картина без влияния предкомпиляции.
Не весь код можно предкомпилировать. Crossgen2 пропускает дженерики из других сборок (если в сборке A есть MyClass<T>, а в сборке B используется MyClass<CustomType>, инстанциацию нельзя предкомпилировать в A, компилятор не знает о CustomType), динамический код через Reflection.Emit, некоторые P/Invoke сигнатуры, аппаратные интринсики без явного указания целевой платформы, Expression Trees. Даже с включённым R2R часть кода всё равно JIT-ится.
В .NET 6 появился Composite R2R, все сборки компилируются в один огромный native-образ:
<PropertyGroup> <PublishReadyToRun>true</PublishReadyToRun> <PublishReadyToRunComposite>true</PublishReadyToRunComposite> </PropertyGroup>
Фича в cross-assembly inlining. В обычном R2R каждая DLL компилируется отдельно, и если метод из Assembly1.dll вызывает маленький метод из Assembly2.dll, inlining невозможен. В Composite R2R Crossgen2 видит все границы и может агрессивно инлайнить через сборки.
Например:
// MyLib.dll public static class Helper { public static int Add(int a, int b) => a + b; } // MyApp.exe int result = Helper.Add(5, 10);
С обычным R2R это вызов функции. С Composite R2R компилятор инлайнит Add прямо в точку вызова. Минусы: размер увеличивается в 5-10 раз, компиляция вместо 30 секунд занимает 5 минут, теряется модульность. Composite R2R имеет смысл для self-contained приложений на Linux, embedded систем с ограниченными ресурсами, CLI-утилит где критична каждая миллисекунда старта. Для обычных веб-приложений это избыточно.
R2R неплохо помогает в serverless и FaaS, каждый холодный старт стоит денег. Лямбда на .NET 8 с R2R стартует на 40-60% быстрее. CLI-утилиты где пользователь набрал команду, получил результат, процесс завершился, никакого steady state, только холодный старт. Desktop-приложения WPF/WinForms где пользователь нажимает кнопку раз в минуту, и каждое нажатие может быть холодным для конкретного кода. Микросервисы с коротким временем жизни, контейнер живёт 5-10 минут и обрабатывает 50-100 запросов.
R2R не нужен или вреден для long-running сервисов, Web API работает неделями без перезапуска и обрабатывает миллионы запросов, старт занимает 0.001% времени жизни. CPU-intensive вычисления, научные расчёты, обработка больших данных, криптография — здесь важна скорость основного алгоритма, JIT с PGO и SIMD обгонит R2R. Приложения с большим количеством reflection — ORM с динамической генерацией запросов или DI-контейнер создающий прокси в runtime, R2R не поможет. Embedded системы с медленным диском — парадоксально, но на старой microSD чтение раздувшихся R2R-библиотек может занять больше времени, чем JIT-компиляция.
Можно включить R2R только для некоторых сборок:
<ItemGroup> <PublishReadyToRunExclude Include="SlowLib.dll" /> </ItemGroup>
Хорошо, если одна библиотека раздувается непропорционально. Можно скомпилировать R2R на Windows для Linux:
dotnet publish -r linux-x64 -p:PublishReadyToRun=true
Crossgen2 генерирует код для другой платформы, но тестировать нужно на целевой ОС.
Проверить, что R2R работает:
DOTNET_JitDisasm=MyMethod dotnet run
Если в выводе нет сообщений о JIT-компиляции метода, значит он взят из R2R. Или используйте dotnet-pgo:
dotnet tool install --global dotnet-pgo dotnet-pgo print-pgo-data MyApp.dll
Покажет, какие методы имеют R2R-версии и PGO-данные.
Crossgen2 — это переписанный с нуля компилятор R2R в .NET 5+. Старый Crossgen из .NET Core 2.x-3.x был медленный и глючный. Crossgen2 быстрее в 2-3 раза, использует современный JIT-бэкенд, поддерживает Composite R2R, лучше обрабатывает дженерики с меньшим fallback на JIT, имеет встроенный PGO и может использовать .mibc файлы с профилями.
Распространённые заблуждения. R2R не делает код быстрее — он делает старт быстрее, сам код может быть медленнее JIT-версии. JIT остаётся и активно работае, перекомпилирует горячие методы, обрабатывает дженерики, Reflection.Emit. R2R не уменьшает потребление памяти, наоборот, R2R-код занимает больше памяти, так как содержит и IL, и native-код. Composite R2R не всегда лучше обычного, он полезен только в узких сценариях, для большинства приложений избыточен. R2R и Native AOT это разные вещи,Native AOT в .NET 7+ вообще убирает JIT и производит чисто нативный бинарник, R2R это гибрид native-кода и JIT.
Включайте R2R если приложение запускается часто и живёт недолго, важна задержка первого запроса, serverless/FaaS сценарии, CLI-утилиты, desktop-приложения с UI. Не включайте для long-running сервисов работающих днями, CPU-интенсивных вычислений, приложений с динамическим кодом через Reflection и CodeGen, при ограничениях по размеру дистрибутива.
Можно комбинировать:
<PropertyGroup> <PublishReadyToRun>true</PublishReadyToRun> <PublishReadyToRunComposite>false</PublishReadyToRunComposite> <TieredPGO>true</TieredPGO> </PropertyGroup>
Получаете быстрый старт от R2R плюс агрессивную оптимизацию от Dynamic PGO на важных путях.
Заключение
ReadyToRun и Crossgen2 решают конкретную проблему — медленный холодный старт .NET-приложений. Решают хорошо, но ценой увеличения размера и некоторой потери производительности на длинной дистанции. Главное правило, измеряйте реальные сценарии. Включите R2R, прогоните бенчмарки, сравните с baseline. Иногда выигрыш 50%, иногда 5%, иногда проигрыш. Зависит от конкретного приложения. Базовые библиотеки .NET уже поставляются с R2R+PGO, так что даже без явного включения флага получаете часть фич. Дальше решайте сами — нужен дополнительный буст на старте или лучше положиться на JIT, который выжмет максимум из железа на длинной дистанции.
Миллион запросов без падений: Как контролировать асинхронный API
OTUS совместно с экспертами платформы algocode проведёт открытый онлайн-урок, посвященный разбору кейса работы с нагруженным API.
Дата и время: 18 декабря в 19:00 по Мск
Формат: онлайн
Спикер: Даниил — Team Lead в крупной технологической компании, имеет более 5 лет опыта и провёл свыше 50 технических собеседований.
О чём поговорим на уроке:
Как масштабировать такое решение
Какие подводные камни существуют
Как все это работает под капотом
В рамках урока также погрузимся в детали computer science, чтобы лучше разобраться в концепциях
По итогам урока
Овладеете тонкостями работы с highload API
Разберетесь в частых ошибках при проектировании такого решения
Будете готовы к коварным вопросам интервьюера
Также бонусом получите чек-лист прохождения секции system design и чек-лист прохождения скрининга в Авито.
Подробности доступны на странице урока.
Справочная информация:
Материалы algocode включены в программу курса OTUS «Golang Developer. Professional».
