Что такое Native AOT и чего он вам стоит?
Native AOT компилирует ваше .NET-приложение в единый автономный нативный бинарь без JIT, обеспечивая быстрый запуск и небольшое потребление памяти. Цена этого: цепочка инструментов C на этапе компиляции, более медленные публикации, сборки под каждый RID, отсутствие рефлексии и Reflection.Emit, обязательный тримминг и отсутствие Dynamic PGO. Вот полный баланс.
Native AOT - это модель публикации .NET, которая заранее компилирует всё ваше приложение плюс урезанную копию среды выполнения в единый автономный нативный исполняемый файл. Получившееся приложение не имеет JIT-компилятора, поэтому быстро запускается и использует меньше памяти, и оно работает на машинах, где среда выполнения .NET не установлена. Цена платится в трёх валютах: трение на этапе компиляции (вам нужна цепочка инструментов C, публикации идут медленнее, и каждая сборка нацелена на одну операционную систему плюс архитектуру), потеря возможностей во время выполнения (никакого кода, зависящего от рефлексии, никакого System.Reflection.Emit, никакой динамической загрузки сборок, тримминг обязателен) и небольшой, часто незаметный, удар по пропускной способности, потому что AOT-код никогда не получает повторную оптимизацию на основе профиля. Стоит ли этот размен того, полностью зависит от формы вашего развёртывания, а не от числа из бенчмарка. Эта статья - полный баланс, чтобы вы могли решить, прежде чем включать переключатель.
Всё здесь нацелено на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11 (11.0.100). Сам Native AOT появился в .NET 7, а поддержка ASP.NET Core пришла в .NET 8, поэтому механика ниже применима начиная с .NET 8, если не указана конкретная версия.
Что на самом деле означает “заранее” здесь
Обычное .NET-приложение поставляется как IL (промежуточный язык). Во время выполнения JIT-компилятор (just-in-time) превращает этот IL в нативный машинный код лениво, по одному методу за раз, при первом вызове каждого метода. Вот почему свежезапущенный .NET-процесс немного медленный на первых запросах: он компилирует сам себя по ходу дела. Среда выполнения, GC и JIT должны присутствовать на машине, чтобы это работало.
Native AOT полностью убирает JIT из уравнения. Когда вы запускаете dotnet publish с <PublishAot>true</PublishAot>, SDK запускает ILC, AOT-компилятор, который компилирует весь ваш IL, весь IL ваших зависимостей и урезанную версию среды выполнения CoreCLR в единый нативный бинарь. Как говорит обзор развёртывания Native AOT от Microsoft, такие приложения “имеют более быстрое время запуска и меньший объём занимаемой памяти” и “могут работать в ограниченных средах, где JIT не разрешён”.
Минимальное включение - это одно свойство MSBuild и идентификатор среды выполнения:
<!-- .NET 11, C# 14. Enables ILC at publish and turns on AOT analysis while editing. -->
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
# .NET 11 SDK 11.0.100. The -r RID is mandatory: AOT output is platform-specific.
dotnet publish -c Release -r linux-x64
Вывод в каталоге публикации - это единый исполняемый файл, содержащий всё необходимое для работы, “включая урезанную версию среды выполнения coreclr”. Нет отдельной среды выполнения для установки, и нет JIT внутри бинаря. Эта фраза - вся возможность целиком, и она же - вся цена. Каждое ограничение ниже вытекает из “нет JIT во время выполнения”.
Счёт на этапе компиляции
Прежде чем вы напишете строку кода, Native AOT меняет то, что требуется вашей сборочной машине и вашему CI.
Вам нужна нативная цепочка инструментов C. ILC производит объектный код, который должен быть скомпонован в настоящий исполняемый файл операционной системы компоновщиком платформы, поэтому предварительные требования не подлежат обсуждению для каждой ОС. На Windows вам нужна Visual Studio 2022 или новее с рабочей нагрузкой “Разработка классических приложений на C++”. На Linux вы устанавливаете clang и заголовки разработки zlib (sudo apt-get install clang zlib1g-dev на Ubuntu, sudo dnf install clang zlib-devel на Fedora и RHEL, sudo apk add clang build-base zlib-dev на Alpine). На macOS вам нужны Command Line Tools для Xcode. Простого образа SDK dotnet больше недостаточно для ваших сборочных агентов; вам нужно встроить цепочку инструментов и в образ CI.
Публикации идут медленнее. Компиляция всей программы плюс тримминг плюс нативная компоновка - это намного больше работы, чем эмиссия IL. Публикация, которая занимает несколько секунд для приложения, зависящего от фреймворка, может занять минуты под AOT, и она масштабируется с размером вашего графа зависимостей. Это налог на каждую публикацию, а не на каждый запуск, но он достаточно реален, чтобы вы обычно не запускали AOT на каждой сборке внутреннего цикла, а только при публикации.
Каждая сборка идёт под каждый RID. Вывод AOT работает только на операционной системе и архитектуре CPU, под которые вы компилировали. Бинарь, скомпилированный под win-x64, не работает на linux-arm64, и точка. Хуже именно на Linux: бинарь, собранный на данной версии дистрибутива, работает только на этой версии или новее. Документация прямо говорит, что “бинарь Native AOT, произведённый на Ubuntu 20.04, будет работать на Ubuntu 20.04 и новее, но не будет работать на Ubuntu 18.04”. Если вы поставляете на несколько платформ, вам нужна матрица сборки, одна публикация на каждый RID. .NET 9 расширил поддерживаемые цели, добавив Windows/Linux x86 и 32-битный Arm в дополнение к x64 и Arm64, которые поддерживал .NET 8.
Сравните это с приложением на JIT, зависящим от фреймворка, где единственная сборка работает на любой машине с соответствующей средой выполнения .NET. Эта переносимость - одна из вещей, от которых вы отказываетесь.
Возможности времени выполнения, от которых вы отказываетесь
Это та часть, которая решает судьбу большинства проектов, потому что потери - это не “медленнее”, а “не работает, и шаг публикации вас предупредит”. Поскольку JIT отсутствует, всё, что зависит от генерации или обнаружения кода во время выполнения, исключено. Прямо из официальных ограничений:
- Никакой динамической загрузки, например
Assembly.LoadFile. Архитектуры плагинов, которые сканируют папку в поисках DLL и загружают их во время выполнения, не могут работать, потому что код никогда не был скомпилирован в бинарь. - Никакой генерации кода во время выполнения, например
System.Reflection.Emit. Это незаметно выбивает удивительно большую часть экосистемы: библиотеки динамических прокси (Castle DynamicProxy), некоторые фреймворки для мокинга и любой сериализатор или маппер, который эмитирует IL ради скорости. - Никакого C++/CLI и, на Windows, никакого встроенного COM.
- Тримминг обязателен. Триммер удаляет любой код, достижимость которого он не может доказать. Неограниченная рефлексия (
Type.GetType("SomeName")из строки, обходыGetProperties(),Activator.CreateInstance(someType)) срывает этот анализ, поэтому код, сильно зависящий от рефлексии, либо нуждается в аннотациях, либо должен быть заменён генератором исходного кода. - Упаковка в один файл подразумевается, что несёт собственные известные несовместимости (API, предполагающие наличие
.dllна диске,Assembly.Location, возвращающий пустоту, и так далее). System.Linq.Expressionsвсегда выполняется интерпретируемо. Они не могут быть скомпилированы во время выполнения, потому что для этого нужен JIT, поэтому код, сильно зависящий от деревьев выражений, продолжает работать, но работает медленнее, чем на хосте с JIT.
Самое важное практическое правило: компилятор вам скажет. “Процесс публикации анализирует весь проект и его зависимости на предмет возможных ограничений. Предупреждения выдаются для каждого ограничения, с которым опубликованное приложение могло бы столкнуться во время выполнения.” Эти предупреждения - IL2026 (требует неиспользуемого кода, проблема тримминга) и IL3050 (требует динамического кода, проблема AOT). Считайте чистый dotnet publish с нулём предупреждений IL2026/IL3050 вашим сигналом “идти или не идти”, а не документацию. Если вы не можете дойти до нуля, не публикуйте через AOT.
Способ дойти до нуля почти всегда состоит в замене рефлексии генерацией кода на этапе компиляции. Сгенерированный из исходников System.Text.Json - канонический пример: вместо рефлексии над вашим DTO во время выполнения генератор эмитирует код сериализации на этапе компиляции. Если термин для вас новый, что такое генератор исходного кода и когда он вам нужен - правильный первый шаг, потому что под AOT они перестают быть приятным дополнением и становятся единственным способом, которым некоторые библиотеки вообще работают.
Стоимость пропускной способности, о которой никто не упоминает
Есть стоимость, которую скрывают заголовки про запуск и размер. JIT-процесс компилирует ваш код не один раз. С .NET 8 Dynamic PGO (оптимизация на основе профиля) включён по умолчанию: пока ваше приложение работает, среда выполнения записывает, какие типы реально проходят через виртуальные вызовы и какие ветви горячие, затем перекомпилирует эти методы на уровне 1, используя этот реальный профиль. Это информация, которой не может обладать ни один компилятор, работающий заранее, потому что она существует только пока работает именно ваша рабочая нагрузка.
Код Native AOT зафиксирован на этапе публикации. Он никогда не получает повторную оптимизацию уровня 1 и никогда не получает PGO. Для горячего цикла, ограниченного CPU и работающего часами, хорошо разогретый JIT-процесс может обойти по чистой пропускной способности тот же код, скомпилированный с AOT, хотя AOT-процесс запустился за долю того времени. AOT обменивает пик на длинном хвосте на плоскую, предсказуемую, быструю с первой инструкции кривую. Измеренный разрыв невелик (небольшой процент в бенчмарке JSON API), но он реален и идёт в противоположном направлении от всего остального, что даёт AOT. Полные числа есть в сравнении Native AOT vs ReadyToRun vs JIT, которое сопоставляет запуск, пропускную способность и размер лицом к лицу.
Ещё один нюанс размера: дженерики. “Для обобщённых параметров, подставленных аргументами типа-структуры, генерируется специализированный код для каждой инстанциации.” JIT генерирует их по требованию; AOT предварительно генерирует их все. Если вы инстанцируете много дженериков с типами-значениями, бинарь растёт. Бинари AOT малы в обычном случае (минимальный API около 10-13 МБ), но библиотека с обилием дженериков может раздуть его сильнее, чем вы ожидаете.
Что эта стоимость вам покупает
Преимущества подлинные и для правильной рабочей нагрузки решающие. Запуск - это заголовок: минимальный API с Native AOT запускается примерно втрое быстрее того же приложения на чистом JIT, потому что нет разогрева JIT и нет загрузки сборок. Объём занимаемой памяти падает более чем вдвое, потому что процесс не несёт JIT, IL или метаданные, нужные для его компиляции. И поскольку вывод автономен, единица развёртывания - это единственный небольшой бинарь без среды выполнения для установки, поэтому команды существенно сокращают размер образов контейнеров при переходе.
Другое преимущество категориальное, а не количественное: AOT-приложения “могут работать в ограниченных средах, где JIT не разрешён”. Некоторые заблокированные среды выполнения контейнеров и политики безопасности запрещают страницы памяти с правами на запись и исполнение, которые нужны JIT. AOT - единственная модель развёртывания .NET, которая там вообще работает.
Вот почему оптимальная ниша - это вычисления типа scale-to-zero и высокой плотности. На функции с оплатой за запрос (AWS Lambda, Azure Functions Consumption, Cloud Run, масштабированный до нуля) холодный старт доминирует и в SLO по задержке, и в счёте, поэтому выигрыш в запуске в 3 раза стоит большой боли на этапе компиляции. Руководство по холодному старту для AWS Lambda на .NET 11 проходит точный путь AOT на Lambda. На долгоживущем сервисе, ограниченном CPU, с горсткой инстансов запуск амортизируется до нуля, и вы отказывались бы от Dynamic PGO ради преимущества, за которое платите один раз, поэтому чистый JIT обычно побеждает.
Как решать, не гадая
Запустите анализ, прежде чем на что-либо соглашаться. Самый дешёвый тест - выставить <PublishAot>true</PublishAot> и запустить публикацию против вашего реального графа зависимостей:
# .NET 11. Surfaces every IL2026 / IL3050 across your whole dependency tree.
dotnet publish -c Release -r linux-x64 -o ./publish
Если это возвращается с предупреждениями, которые вы не можете убрать аннотациями, AOT пока не жизнеспособен для этой кодовой базы, и у вас есть ответ ценой одной публикации. ASP.NET Core заостряет вопрос: контроллеры MVC (AddControllers), Razor Pages и серверные хабы SignalR несовместимы с AOT в .NET 11, тогда как минимальные API и gRPC совместимы. Если вам нужен полный рецепт чистой сборки (хост CreateSlimBuilder, JSON, сгенерированный из исходников, подводные камни проектов-библиотек), как использовать Native AOT с минимальными API ASP.NET Core - это пошаговое руководство. А когда несовместимый с AOT API проскальзывает мимо анализатора и взрывается только во время выполнения, исправление возникающего PlatformNotSupportedException покрывает самый частый сбой.
Короткое правило решения: тянитесь к Native AOT, когда время запуска, объём занимаемой памяти, размер развёртывания или работа без JIT являются доминирующим ограничением, и dotnet publish сообщает о нуле предупреждений AOT по всему вашему графу зависимостей. Оставайтесь на чистом JIT, когда пиковая установившаяся пропускная способность важнее запуска, когда вы поставляете один артефакт на несколько платформ или когда какая-то несущая зависимость нуждается в рефлексии или Reflection.Emit, которые вы не можете заменить. Native AOT - это не более быстрый dotnet publish; это другой контракт развёртывания, и перечисленные выше затраты - это условия этого контракта. Прочитайте их, прежде чем подписывать.
Связанное
- Native AOT vs ReadyToRun vs JIT в .NET 11: что вам стоит поставлять? подкрепляет компромиссы по запуску, пропускной способности и размеру жёсткими числами из бенчмарков.
- Как использовать Native AOT с минимальными API ASP.NET Core - это пошаговое руководство по чистой сборке, когда вы уже решили её поставлять.
- Что такое генератор исходного кода и когда он мне нужен? объясняет генерацию кода на этапе компиляции, которая заменяет рефлексию, запрещённую AOT.
- Как сократить время холодного старта для AWS Lambda на .NET 11 - это сценарий scale-to-zero, где выигрыш AOT в запуске окупает себя.
- Исправление: PlatformNotSupportedException в Native AOT покрывает сбой во время выполнения, когда несовместимый с AOT API проскальзывает через чистую сборку.
Источники
- Обзор развёртывания Native AOT, MS Learn (предварительные требования, ограничения, ограничения по RID и платформе, поведение одного файла и дженериков).
- Поддержка Native AOT в ASP.NET Core, MS Learn (поддерживаемые и не поддерживаемые веб-возможности).
- Несовместимости тримминга, MS Learn (почему тримминг обязателен и что он ломает).
- Conversation about PGO, .NET Blog (устройство Dynamic PGO и почему AOT обходится без него).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.