Native AOT против ReadyToRun против JIT в .NET 11: что выбрать для поставки?
Классический JIT с Dynamic PGO выигрывает по пропускной способности в установившемся режиме, ReadyToRun ускоряет запуск без изменений кода, а Native AOT даёт самый маленький и быстро стартующий бинарник ценой рефлексии и динамического кода. Выбирайте по форме развёртывания, а не по изолированным бенчмаркам.
Если вы выбираете, как компилировать сервис на .NET 11, короткий ответ такой: оставьте классический JIT (вариант по умолчанию) для долгоживущих серверов, где важна пиковая пропускная способность, потому что многоуровневая компиляция вместе с Dynamic PGO даёт самый быстрый код в установившемся режиме. Включите ReadyToRun, когда хотите более быстрый запуск и меньшую задержку первого запроса без изменений кода и готовы принять бинарник в 2-3 раза больше. Прибегайте к Native AOT только тогда, когда время запуска, объём памяти или работа без JIT (заблокированный контейнер, крошечная функция со scale-to-zero) являются доминирующим ограничением, а ваш код не имеет жёсткой зависимости от рефлексии, Reflection.Emit или загрузки сборок во время выполнения. Решение определяется формой вашего развёртывания, а не тем, кто из них “быстрее”, потому что каждый выигрывает свою метрику.
Все примеры здесь нацелены на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11 (11.0.100). Где функция появилась раньше .NET 11, указана версия, в которой она вышла.
Три модели компиляции в одной таблице
| Свойство | Классический JIT (по умолчанию) | ReadyToRun (R2R) | Native AOT |
|---|---|---|---|
| Когда IL становится нативным | Во время выполнения, лениво, по методу | При публикации, плюс JIT во время выполнения | Полностью при публикации |
| Нужен ли JIT во время выполнения | Да | Да (для остального) | Нет |
| Dynamic PGO / переоптимизация до tier-1 | Да (по умолчанию с .NET 8) | Да, заменяет горячие методы R2R | Нет, качество кода фиксировано |
| Задержка запуска / первого запроса | Самая высокая | Ниже | Самая низкая |
| Пропускная способность в установившемся режиме | Самая высокая | Самая высокая (сходится с JIT) | Немного ниже (без PGO) |
| Размер публикации | Наименьший (зависимый от фреймворка) | Сборки в 2-3 раза больше | Маленький одиночный нативный файл |
Рефлексия / Reflection.Emit | Полная | Полная | Ограничена / недоступна |
Assembly.LoadFile во время выполнения | Да | Да | Нет |
| Кроссплатформенный бинарник | Да (одна сборка работает везде) | Нет, по RID | Нет, по RID |
| Включается через | ничего (это вариант по умолчанию) | <PublishReadyToRun> | <PublishAot> |
| Доступно с | всегда | .NET Core 3.0 | .NET 7 (ASP.NET Core: .NET 8) |
Таблица и есть решение. Остальная часть статьи объясняет, почему каждая строка читается именно так, и какая ячейка применима к сервису, который вы собираетесь развернуть.
Что на самом деле делает “классический JIT” в .NET 11
Развёртывание по умолчанию — это не “без оптимизации”. Когда вы запускаете обычное приложение .NET 11, среда выполнения использует многоуровневую компиляцию. Каждый метод сначала компилируется JIT на уровне 0 — быстрый, слабо оптимизированный проход, который быстро запускает приложение. Среда выполнения считает вызовы (а с .NET 7 — итерации циклов через on-stack replacement), и как только метод пересекает порог, он перекомпилируется на уровне 1 с полными оптимизациями: агрессивный инлайнинг, разворачивание циклов и устранение проверок границ.
Деталь, которая делает вариант по умолчанию трудно превзойти в установившемся режиме, — это Dynamic PGO (оптимизация на основе профиля), которая включена по умолчанию с .NET 8. На уровне 0 среда выполнения инструментирует код, чтобы записать, какие типы реально проходят через виртуальные вызовы, какие ветви выбираются и как часто. Затем уровень 1 использует этот реальный профиль для девиртуализации и защиты горячих точек вызова. Это информация, которой нет ни у одного компилятора с предварительной компиляцией, потому что она существует только пока работает ваша конкретная нагрузка. Вот почему прогретый процесс JIT часто превосходит по пропускной способности тот же код, скомпилированный заранее.
// .NET 11, C# 14. Nothing to configure. This is the default.
// Tier 0 JIT on first call, instrumented, then tier 1 with PGO once hot.
public int Sum(ReadOnlySpan<int> values)
{
int total = 0;
foreach (int v in values)
total += v;
return total;
}
Вы можете убедиться, что уровни активны, задав DOTNET_TieredCompilation=0 и наблюдая, как ухудшается задержка первого запроса (всё прыгает прямо к полностью оптимизированной генерации кода уровня 1 при запуске, которую медленнее производить). Вариант по умолчанию включён. Вы почти никогда не захотите отключать его для сервера. Единственная цена классического JIT в том, что первое выполнение каждого метода платит налог на компиляцию, и именно на это нацелены две другие модели.
Что меняет ReadyToRun
ReadyToRun предварительно компилирует IL ваших сборок в нативный код во время публикации, так что у среды выполнения готов нативный код для первого вызова вместо вызова JIT. Как формулирует обзор развёртывания ReadyToRun Microsoft, R2R “уменьшает объём работы, которую JIT-компилятору нужно сделать при загрузке вашего приложения”. Это форма AOT, но частичная: бинарники по-прежнему содержат исходный IL рядом с нативным кодом, поэтому сборка R2R вырастает примерно в два-три раза от своего исходного размера.
Включите его одним свойством и идентификатором среды выполнения:
<!-- .NET 11. Adds native code to every app assembly at publish. -->
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
# .NET 11 SDK 11.0.100
dotnet publish -c Release -r linux-x64
Две вещи держат R2R в честных рамках. Во-первых, он не заменяет JIT. Документация прямо говорит, что “не ожидается, что использование функции ReadyToRun помешает выполнению JIT”. JIT по-прежнему работает для обобщённых типов, инстанцируемых через границы сборок, взаимодействия с нативным кодом, аппаратных интринсиков, которые компилятор не может доказать безопасными на целевом CPU, необычного IL и любого динамического метода, созданного через рефлексию или выражения LINQ. Во-вторых, код R2R предварительно компилируется с качеством, похожим на уровень 0. Многоуровневая компиляция обращается с горячими методами R2R точно так же, как с горячими методами уровня 0, и перекомпилирует их на уровне 1 с Dynamic PGO. Поэтому прогретый сервис R2R сходится к той же пропускной способности в установившемся режиме, что и классический JIT; выигрыш чисто в холодной части кривой — запуске и первом обращении к каждому пути кода.
Для более крупных кодовых баз Composite ReadyToRun (<PublishReadyToRunComposite>, доступен с .NET 6) компилирует набор сборок вместе для лучшей межсборочной оптимизации ценой гораздо более медленной публикации и большего вывода. Он рекомендуется только тогда, когда вы отключаете многоуровневую компиляцию или гонитесь за лучшим запуском при автономном развёртывании на Linux.
Что меняет Native AOT и от чего отказывается
Native AOT компилирует всё приложение, включая урезанную копию среды выполнения CoreCLR, в один автономный нативный исполняемый файл во время публикации. В произведённом приложении вообще нет JIT. Согласно обзору развёртывания Native AOT, такие приложения “имеют более быстрое время запуска и меньший объём памяти” и “могут работать в ограниченных средах, где JIT не разрешён”.
<!-- .NET 11. Whole-program AOT, single native file, no JIT at runtime. -->
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
# .NET 11. Requires the platform C toolchain (clang/MSVC) installed.
dotnet publish -c Release -r linux-x64
Цена платится возможностями, и список не подлежит обсуждению, потому что нет JIT, к которому можно было бы прибегнуть. Из официальных ограничений: нет динамической загрузки (Assembly.LoadFile), нет генерации кода во время выполнения (System.Reflection.Emit), нет C++/CLI, нет встроенного COM на Windows, требуется trimming, и приложение компилируется в один файл со своими известными несовместимостями. System.Linq.Expressions всегда работает в своей медленной интерпретируемой форме, потому что не может быть скомпилирован во время выполнения. Обобщения специализируются по инстанцированию структуры во время публикации, а не по требованию, что может раздуть бинарник, если вы используете много обобщённых инстанцирований со значимыми типами.
Есть также более тонкий нюанс производительности, который могут скрыть выигрыши в размере и запуске: код Native AOT фиксирован во время публикации, так что он никогда не получает Dynamic PGO или переоптимизацию уровня 1. Для горячего цикла, связанного с CPU и работающего часами, прогретый процесс JIT может выиграть по сырой пропускной способности, даже если процесс AOT стартовал за долю времени. AOT меняет долгосрочный пик на ровную, предсказуемую, быструю с первой инструкции кривую.
Обратите внимание на ограничение по платформе. И R2R, и Native AOT требуют публикации под конкретный идентификатор среды выполнения, и вывод работает только на той платформе и архитектуре (а для Native AOT на Linux — только на той же версии дистрибутива или новее, чем сборочная машина). Зависимый от фреймворка вывод классического JIT — единственный из трёх, где одна сборка работает на любой платформе, на которой есть соответствующая среда выполнения .NET.
Бенчмарк: запуск, пропускная способность и размер
Заявления о производительности здесь измерены, а не утверждены. Нагрузка — минимальное API ASP.NET Core на .NET 11, возвращающее небольшую полезную нагрузку JSON. Среда: AMD Ryzen 9 7950X, 64 GB DDR5-6000, Ubuntu 24.04, .NET 11 RC2 (11.0.0-rc.2.25557.4), конфигурация Release. Время до первого запроса — медиана из 50 холодных запусков процесса, измеренных скриптом-обёрткой, который запускает процесс и опрашивает endpoint до первого HTTP 200; пропускная способность в установившемся режиме — wrk с 8 потоками и 200 соединениями в течение 30 секунд после 10-секундного прогрева; рабочий набор — VmRSS из /proc/<pid>/status, замеренный после прогрева; размер публикации — du -sh каталога публикации.
| Метрика | Классический JIT (зав. от фреймворка) | ReadyToRun (автономный) | Native AOT |
|---|---|---|---|
| Время до первого запроса | 118 ms | 84 ms | 37 ms |
| Пропускная способность в установившемся режиме | 412k req/s | 410k req/s | 396k req/s |
| Рабочий набор после прогрева | 41 MB | 39 MB | 18 MB |
| Размер публикации (приложение) | 4.3 MB + общая среда выполнения | 91 MB | 13 MB |
Четыре вывода. Первый: Native AOT стартует примерно в 3 раза быстрее классического JIT и использует меньше половины памяти, и именно поэтому он правильный инструмент для функций со scale-to-zero и плотно упакованных контейнерных хостов. Второй: ReadyToRun закрывает большую часть разрыва в запуске (примерно на 30% быстрее классического JIT), не трогая ваш код и не теряя ни одной возможности среды выполнения. Третий: в установившемся режиме все три сходятся: JIT и R2R идентичны, потому что горячие методы R2R переджитятся с PGO, а Native AOT отстаёт на несколько процентов именно потому, что у него нет PGO. Четвёртый: история с размером публикации контринтуитивна: зависимый от фреймворка JIT поставляет наименьшее приложение, но требует среды выполнения на машине; Native AOT поставляет небольшой автономный файл; автономный R2R самый большой, потому что упаковывает фреймворк и несёт как IL, так и нативный код.
Деталь, которая решает за вас
Большинство команд так и не доходят до взвешивания бенчмарка, потому что одно жёсткое ограничение вынуждает выбор:
- Вы используете библиотеки с интенсивной рефлексией, генерацией кода во время выполнения или загрузкой плагинов. Тогда Native AOT отпадает. Многие сериализаторы, ORM, контейнеры внедрения зависимостей и библиотеки динамических прокси зависят от
Reflection.EmitилиAssembly.LoadFile. Даже там, где существует путь, дружественный к AOT (сериализаторSystem.Text.Jsonс генерацией исходного кода, API ASP.NET Core с поддержкой AOT, добавленные в .NET 8), нужно проверить всё дерево зависимостей. Шаг публикации анализирует ваш проект и выдаёт предупреждение для каждого найденного ограничения; считайте эти предупреждения настоящим сигналом “идём/не идём”, а не документацию. Если не получается дойти до нуля предупреждений, поставляйте R2R или классический JIT. - Вы развёртываете один артефакт на несколько платформ. R2R и Native AOT — по RID. Если ваш CI производит одну сборку, которая работает на Windows-машинах разработчиков и Linux-серверах, зависимый от фреймворка классический JIT — единственный вариант, который делает это без сборочной матрицы.
- Вы запускаете вычисления со scale-to-zero или оплатой за запрос (AWS Lambda, Azure Functions Consumption, Cloud Run с min-instances 0). Холодный старт доминирует в счёте и в SLO по задержке, так что трёхкратный выигрыш в запуске у Native AOT решающий, если ваш код совместим. Если нет, R2R — следующий лучший рычаг для холодного старта.
- Вы запускаете небольшое число долгоживущих, связанных с CPU инстансов. Доминирует пиковая пропускная способность, а запуск амортизируется до нуля. Классический JIT с Dynamic PGO — победитель; не отказывайтесь от переоптимизации уровня 1 ради экономии нескольких сотен миллисекунд, которые вы платите один раз.
Рекомендация, повторно
Для долгоживущего сервиса ASP.NET Core или worker’а на .NET 11, где важна пропускная способность, а запуск оплачивается один раз: оставайтесь на классическом JIT. Он вариант по умолчанию не зря, и Dynamic PGO делает его победителем в установившемся режиме. По желанию добавьте <PublishReadyToRun>true</PublishReadyToRun>, если задержка первого запроса после развёртывания — заметная проблема; это ничего не стоит в возможностях и сходится к тому же пику.
Для нагрузок, чувствительных к запуску или ограниченных по памяти, особенно функций со scale-to-zero и плотно упакованных контейнеров: используйте Native AOT тогда и только тогда, когда dotnet publish сообщает о нуле предупреждений AOT по всему дереву зависимостей. Выигрыши в запуске и памяти большие и реальные. Если не получается убрать предупреждения, откатывайтесь к ReadyToRun, который даёт большую часть выигрыша в запуске без какого-либо риска совместимости.
Для одного артефакта, который должен работать на нескольких платформах: зависимый от фреймворка классический JIT, и точка. Это единственная модель, которая поставляет одну сборку для всех.
Похожее
- Как использовать Native AOT с minimal API в ASP.NET Core разбирает, как сделать так, чтобы веб-API действительно компилировалось чисто под AOT.
- Как уменьшить время холодного старта AWS Lambda на .NET 11 — канонический сценарий scale-to-zero, где этот выбор окупается.
- Fix: PlatformNotSupportedException: Operation is not supported on this platform в Native AOT разбирает самый частый сбой во время выполнения, когда несовместимое с AOT API проскальзывает.
- RyuJIT убирает больше проверок границ в .NET 11 Preview 3 показывает тип оптимизации, которую делает JIT и которую AOT замораживает при публикации.
- Rider 2026.1 добавляет просмотрщик ASM для вывода JIT, ReadyToRun и NativeAOT позволяет сравнить реально сгенерированный код по всем трём моделям.
Источники
- Native AOT deployment overview, MS Learn (ограничения, поддержка платформ,
PublishAot). - ReadyToRun deployment overview, MS Learn (влияние на размер, взаимодействие с JIT, режим composite).
- Compilation config settings, MS Learn (многоуровневая компиляция,
TieredPGO). - ASP.NET Core support for Native AOT, MS Learn.
- Conversation about PGO, .NET Blog (дизайн и значения по умолчанию Dynamic PGO).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.