Start Debugging

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 ms84 ms37 ms
Пропускная способность в установившемся режиме412k req/s410k req/s396k req/s
Рабочий набор после прогрева41 MB39 MB18 MB
Размер публикации (приложение)4.3 MB + общая среда выполнения91 MB13 MB

Четыре вывода. Первый: Native AOT стартует примерно в 3 раза быстрее классического JIT и использует меньше половины памяти, и именно поэтому он правильный инструмент для функций со scale-to-zero и плотно упакованных контейнерных хостов. Второй: ReadyToRun закрывает большую часть разрыва в запуске (примерно на 30% быстрее классического JIT), не трогая ваш код и не теряя ни одной возможности среды выполнения. Третий: в установившемся режиме все три сходятся: JIT и R2R идентичны, потому что горячие методы R2R переджитятся с PGO, а Native AOT отстаёт на несколько процентов именно потому, что у него нет PGO. Четвёртый: история с размером публикации контринтуитивна: зависимый от фреймворка JIT поставляет наименьшее приложение, но требует среды выполнения на машине; Native AOT поставляет небольшой автономный файл; автономный R2R самый большой, потому что упаковывает фреймворк и несёт как IL, так и нативный код.

Деталь, которая решает за вас

Большинство команд так и не доходят до взвешивания бенчмарка, потому что одно жёсткое ограничение вынуждает выбор:

Рекомендация, повторно

Для долгоживущего сервиса ASP.NET Core или worker’а на .NET 11, где важна пропускная способность, а запуск оплачивается один раз: оставайтесь на классическом JIT. Он вариант по умолчанию не зря, и Dynamic PGO делает его победителем в установившемся режиме. По желанию добавьте <PublishReadyToRun>true</PublishReadyToRun>, если задержка первого запроса после развёртывания — заметная проблема; это ничего не стоит в возможностях и сходится к тому же пику.

Для нагрузок, чувствительных к запуску или ограниченных по памяти, особенно функций со scale-to-zero и плотно упакованных контейнеров: используйте Native AOT тогда и только тогда, когда dotnet publish сообщает о нуле предупреждений AOT по всему дереву зависимостей. Выигрыши в запуске и памяти большие и реальные. Если не получается убрать предупреждения, откатывайтесь к ReadyToRun, который даёт большую часть выигрыша в запуске без какого-либо риска совместимости.

Для одного артефакта, который должен работать на нескольких платформах: зависимый от фреймворка классический JIT, и точка. Это единственная модель, которая поставляет одну сборку для всех.

Похожее

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад