Привет!
Разберём 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».
Комментарии (3)

Nagg
16.12.2025 12:02а когда метод становится горячим (30+ вызовов), перекомпилирует с агрессивными оптимизациями
На самом деле эвристика гораздо сложнее, там рантайму нужно еще найти окно когда процессор не занят другими компиляциями уже 100мс и т.п.
Microsoft прогнала стандартную библиотеку через тысячи тестов
Там на самом деле не такой большой статик-профиль собирается, пару приложений всего :-)
В обычном R2R каждая DLL компилируется отдельно, и если метод из Assembly1.dll вызывает маленький метод из Assembly2.dll, inlining невозможен
И без композитного режима, у crossgen есть флаг, который разрешает такой инлайниг, там что-то типа -opt-cross-module=
R2R не нужен или вреден для long-running сервисов
Не очень понял почему вреден. Всё горячее, по вашим же словам, все равно откомпилируется в Tier1.
DOTNET_JitDisasm=MyMethod dotnet run
не советую так делать, потому что dotnet run вызовет мсбилд/нугет/розлин и вы можете получить дизасм оттуда если там тоже есть метод с таким именем (если не полное имя указываете)
R2R и Native AOT это разные вещи,Native AOT в .NET 7+ вообще убирает JIT и производит чисто нативный бинарник, R2R это гибрид native-кода и JIT.
+/- одно и то же, даже кодобаза та же. Оба использует тот же джит для АОТ кода, разница в том что у наот есть точный депенденси анализ, а р2р он не нужен и ему не надо ничего триммить
DoctorKrolic
Из 4 утверждений 2 однозначно ложны, 1 верно лишь с весомыми оговорками, а одно вообще не подразумевает конкретики. По порядку:
В .NET JIT нет автовекторизации. Разработчики JIT утверждают (если хотите проверить, найдите дискорд-сервер C#, зайдите в канал allow-unsafe-blocks и спросите сами), что автовекторизация не несёт весомых преимуществ в реальном коде и скорее проявляет себя в синтетических бенчмарках, причём даже в них, зная конкретные ограничения целевой задачи, разработчик вручную может написать намного более оптимальный код. По сему автовекторизации нет и она не планируется к добавлению
Это очень сильно зависит от того, какие данные поступают на вход. Если там преобладающее количество раз прилетает массив на 3 элемента, JIT, скорее всего, сделает отдельную ветку для этого случая, в которой полностью развернёт цикл. В тексте это звучит как будто такая оптимизация будет применена почти всегда, что вводит читателя в заблуждение
Опять вводите в заблуждение. Перебрать массив в цикле по его длине - пожалуй, самый частый статически определимый паттерн при работе с циклами. Проверка выхода за границы в таком случае будет удалена и без динамических данных об исполнении
У вас есть процессор на архитектуре x86, под которую собираете R2R, какие "конкретные" инструкции в данном случае и для приведённого в примере кода вы имеете в виду? Всякие popcnt и им подобные расширения x86, которые появились в процессорах не сразу, здесь не применимы. Векторные расширения обсудили выше. По итогу остаётся фраза ради фразы. Как будто нейронка среднестатистического текста про оптимизации налила
Nagg
Это не правда. Вы имели ввиду авто-векторизацию циклов. И это не бесполезно, просто в дотнете на любой чих есть апишка под любой вид операции над массивом, и она уже обмазана симдами. Общей векторизации (ее еще называют SLP vectorization) в джите дофига, просто циклы не разворачивает
Одна из причин, почему векторизация циклов очень сложна к реализации - это то, что модель памяти запрещает мержить обращения к массиву в симд если нарушится атомарность, из-за чего единственный вариант - пинить массив (чтобы гц не мог изменить выравнивание), выравнивать по границе размера вектора и потом оптимизировать. Никто не сказал что ее точно не будет, не воспринимайте болтавню в дискорде как последнюю инстанцию
R2R не обязательно компилировать под генерик процессор, в крайнем случае, на старом процессоре рантайм просто инвалидирует те Р2Р коды, которые требуют ISA которых нет. Более того, в .NET 11 сильно подняли baseline в R2R, там вроде и popcnt и AVX есть теперь (а значит и VEX).