Привет!

Разберём 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, чтобы лучше разобраться в концепциях

По итогам урока

  1. Овладеете тонкостями работы с highload API

  2. Разберетесь в частых ошибках при проектировании такого решения

  3. Будете готовы к коварным вопросам интервьюера

Также бонусом получите чек-лист прохождения секции system design и чек-лист прохождения скрининга в Авито.

Подробности доступны на странице урока.

Справочная информация:
Материалы algocode включены в программу курса OTUS «Golang Developer. Professional».

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


  1. DoctorKrolic
    16.12.2025 12:02

    JIT, видя горячий код, применит векторизацию через SIMD, развернёт цикл, уберёт лишние проверки границ, использует конкретные инструкции вашего CPU

    Из 4 утверждений 2 однозначно ложны, 1 верно лишь с весомыми оговорками, а одно вообще не подразумевает конкретики. По порядку:

    применит векторизацию

    В .NET JIT нет автовекторизации. Разработчики JIT утверждают (если хотите проверить, найдите дискорд-сервер C#, зайдите в канал allow-unsafe-blocks и спросите сами), что автовекторизация не несёт весомых преимуществ в реальном коде и скорее проявляет себя в синтетических бенчмарках, причём даже в них, зная конкретные ограничения целевой задачи, разработчик вручную может написать намного более оптимальный код. По сему автовекторизации нет и она не планируется к добавлению

    развернёт цикл

    Это очень сильно зависит от того, какие данные поступают на вход. Если там преобладающее количество раз прилетает массив на 3 элемента, JIT, скорее всего, сделает отдельную ветку для этого случая, в которой полностью развернёт цикл. В тексте это звучит как будто такая оптимизация будет применена почти всегда, что вводит читателя в заблуждение

    уберёт лишние проверки границ

    Опять вводите в заблуждение. Перебрать массив в цикле по его длине - пожалуй, самый частый статически определимый паттерн при работе с циклами. Проверка выхода за границы в таком случае будет удалена и без динамических данных об исполнении

    использует конкретные инструкции вашего CPU

    У вас есть процессор на архитектуре x86, под которую собираете R2R, какие "конкретные" инструкции в данном случае и для приведённого в примере кода вы имеете в виду? Всякие popcnt и им подобные расширения x86, которые появились в процессорах не сразу, здесь не применимы. Векторные расширения обсудили выше. По итогу остаётся фраза ради фразы. Как будто нейронка среднестатистического текста про оптимизации налила


    1. Nagg
      16.12.2025 12:02

      В .NET JIT нет автовекторизации

      Это не правда. Вы имели ввиду авто-векторизацию циклов. И это не бесполезно, просто в дотнете на любой чих есть апишка под любой вид операции над массивом, и она уже обмазана симдами. Общей векторизации (ее еще называют SLP vectorization) в джите дофига, просто циклы не разворачивает

      По сему автовекторизации нет и она не планируется к добавлению

      Одна из причин, почему векторизация циклов очень сложна к реализации - это то, что модель памяти запрещает мержить обращения к массиву в симд если нарушится атомарность, из-за чего единственный вариант - пинить массив (чтобы гц не мог изменить выравнивание), выравнивать по границе размера вектора и потом оптимизировать. Никто не сказал что ее точно не будет, не воспринимайте болтавню в дискорде как последнюю инстанцию

      У вас есть процессор на архитектуре x86, под которую собираете R2R, какие "конкретные" инструкции в данном случае и для приведённого в примере кода вы имеете в виду? Всякие popcnt и им подобные расширения x86, которые появились в процессорах не сразу, здесь не применимы.

      R2R не обязательно компилировать под генерик процессор, в крайнем случае, на старом процессоре рантайм просто инвалидирует те Р2Р коды, которые требуют ISA которых нет. Более того, в .NET 11 сильно подняли baseline в R2R, там вроде и popcnt и AVX есть теперь (а значит и VEX).


  1. 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р он не нужен и ему не надо ничего триммить