Компиляция с опережением времени (AOT) была частью .NET с момента выхода v1 .NET framework. В платформе .NET была технология под названием NGEN, которая позволяла предварительно создавать собственный код и структуры данных во время установки программы .NET в глобальный кэш сборок. NGEN создал кэш кода и структур данных, которые потребуются среде выполнения для запуска установленной программы. Кэш не был полным - среда выполнения возвращалась к своевременной (JIT) компиляции и загрузке, когда это было необходимо, но таким образом можно было бы заранее скомпилировать большую часть типичных приложений.

Среда выполнения Mono (синоним Xamarin для многих) затем расширила этот подход к кэшированию, сделав возможным запуск без своевременной генерации кода во время выполнения. Mono добился этого, вложив больше средств в предварительную генерацию кода для дженериков и различных заглушек, которые не учитывала NGEN.

Хотя в некотором смысле это можно назвать предварительной компиляцией, она сильно отличается от того, как заранее компилируются C, Go, Swift или Rust. Реализация компиляции AOT в основных средах выполнения .NET оставляет много преимуществ AOT на столе. В этой статье мы рассмотрим эти преимущества.

JIT против AOT

Распространенное заблуждение состоит в том, что единственная разница между своевременным и опережающим временем выполнения заключается во времени генерации собственного кода. Среда выполнения JIT-компиляции будет генерировать собственный код по запросу - когда ваше приложение развернуто и работает в целевой среде. Среда выполнения, компилирующая AOT, предварительно генерирует собственный код как часть сборки приложения.

Источник этого заблуждения кроется в том, как обычные среды выполнения реализовывали опережающую компиляцию в прошлом: добавляли собственный код в сборку .NET и считали это хорошей. Компилятор AOT может сделать больше.

Прежде чем углубляться в детали, давайте посмотрим на жизнь приложения C #.

Жизненный цикл приложения .NET

Когда компилятор C # компилирует ваш исходный код, он генерирует сборки IL. Сборка IL - это файл EXE или DLL, содержимое которого разделено на две категории: код вашего приложения в CIL (машинный код для абстрактного процессора) и метаданные о вашем коде (имена типов, их базовый класс, интерфейсы, методы, поля и т. д.).

Метаданные очень похожи на набор таблиц базы данных с информацией о типах, полях и методах, определенных в модуле, но также ссылаются на типы, поля и методы, определенные в других модулях IL. Следующая схема отражает суть этого:

У формата IL двоякие преимущества:

  • Это не зависит от оборудования или ОС, на которых будет работать код, и
  • Имеет отличную устойчивость к версиям

Устойчивость версий обусловлена ​​тем фактом, что CIL является довольно высокоуровневым промежуточным языком - в нем есть такие инструкции, как «загрузить поле X для типа Y» или «вызвать метод U для типа V». Подробная информация о том, что выполняют эти инструкции, закодирована в метаданных.

Богатство метаданных также означает, что тип может объявить, что он является производным от типа с именем «List» в пространстве имен «System.Collections» в сборке «System.Collections», и решение того, что это означает, будет происходить во время выполнения путем поиска имя в сборке «System.Collections». Определение базового типа может измениться (например, могут быть добавлены новые методы и поля) без необходимости перекомпиляции сборки, которая определяет производный от него тип.

Формат IL захватывает вашу программу на очень высоком уровне. Если вы когда-либо использовали такой инструмент, как ILSpy, вы, вероятно, видели, что он может генерировать почти идеальный исходный код C # из сборок IL. Формат IL близок к двоичной кодировке исходного файла.

Чего не хватает в сборке IL

Также интересно посмотреть, чего нет в сборке IL. Вы не найдете этих вещей в сборке IL, созданной компилятором C #:

  • Машинный код для конкретной архитектуры ЦП: инструкции IL не могут быть выполнены напрямую на любом процессоре. Их нужно интерпретировать или компилировать.
  • Структуры данных, позволяющие эффективно выполнять программу.

Второй момент может потребовать небольшого пояснения. Хотя знать список полей и их имена в типе приятно, когда мы хотим, например, разместить новый экземпляр этого типа в куче сборщика мусора (используя ключевое слово «новый» в C #), нам нужно знать размер типа в байтах. Это можно вычислить, перейдя по списку полей, вычислив размеры типов полей (потенциально рекурсивно) и проделав то же самое для базовых классов. Это большая работа - большая часть ее связана с поиском названий вещей во многих сборках.

Своевременно скомпилированная среда выполнения .NET обычно имеет этап, называемый «загрузка типа», на котором альтернативное представление каждого типа строится в структуре данных, выделенной во время выполнения. На этом этапе он будет вычислять информацию, необходимую для эффективного выполнения программы: помимо размера типа, он будет включать такую ​​информацию, как список смещений в экземплярах данного типа, которые содержат указатели GC (необходимые для GC), или таблица виртуальных методов, реализованных по типу (необходима для виртуальных вызовов). Структура данных, выделенная во время выполнения, также будет иметь указатель на метаданные IL для доступа к некоторым редко используемым вещам.

Не все AOT равны

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

Тот факт, что основные среды выполнения .NET используют метаданные формата IL даже в режиме AOT, имеет смысл с эволюционной точки зрения: эти среды выполнения построены на концепциях, представленных в метаданных IL, поскольку они запускались как среды выполнения точно в срок. Создание кеша для самой дорогой вещи (кода) - это логический шаг эволюции, не требующий серьезной перестройки среды выполнения.

Однако сохранение формата IL требует затрат. Форматы файлов IL созданы для обеспечения независимости оборудования и устойчивости к версиям. Предварительно сгенерировав код заранее, мы отказываемся от этих аспектов, так почему же мы сохраняем существующие форматы?

Когда около 11 лет назад команда среды выполнения .NET начала искать в этом направлении проект Redhawk, было ясно, что преобразование существующей среды выполнения CLR в форму, оптимизированную для AOT, будет непомерно дорогостоящим. Так родилась новая среда выполнения, оптимизированная для AOT.

Среда выполнения .NET для AOT

Проект Redhawk взял многоразовые части CLR (например, сборщик мусора - см. Ссылки на FEATURE_REDHAWK в исходном коде GC CoreCLR) и построил вокруг него минимальное время выполнения. Эта минимальная среда выполнения, созданная для проекта Redhawk, позже стала основой .NET Native и CoreRT. Вы все еще можете найти ссылки на Redhawk в дереве исходных текстов CoreRT на GitHub.

Когда было объявлено о .NET Native, это принесло 60% прирост времени запуска по сравнению с NGEN. Эти улучшения времени запуска стали возможными благодаря использованию среды выполнения и форматов файлов, оптимизированных для предварительной компиляции.

Помните схему того, как программы представлены в сборке IL? Вот как они выглядят в CoreRT после досрочной компиляции:

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

Минимальные структуры данных в схеме, такие как структура EEType, описывающая тип System.String, содержат минимальный объем данных, необходимых для запуска программы .NET. Например. поле RelatedType в EEType позволяет преобразовать экземпляр System.String в System.Object. Слоты Vtable поддерживают вызовы виртуальных методов. BaseSize поддерживает размещение объектов и сборку мусора.

Декомпиляция программы в этом представлении в исходную форму представляет тот же уровень сложности, что и, например, декомпиляция C ++.

Структуры данных, с которыми оперирует эта минимальная среда выполнения .NET, на самом деле очень похожи на структуры данных, с которыми будет работать библиотека времени выполнения C ++ - как и размер среды выполнения. В минимальной конфигурации CoreRT может скомпилироваться в автономный исполняемый файл размером ~ 400 КБ, который включает в себя полную среду выполнения и сборщик мусора (данные о размере указаны для x64 - 32-битные целевые объекты могут быть меньше этого). GC, используемый в этой конфигурации, по-прежнему тот же GC, который обрабатывает гигабайтные рабочие нагрузки в Azure.

Время запуска

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

Улучшение времени запуска на 60%, которое наблюдалось для универсальных приложений Windows с .NET Native, распространяется и на другие типы рабочих нагрузок. Вот как выглядит время запуска ASP.NET с эталоном, используемым командой CoreCLR:

Теперь время запуска не имеет большого значения, если ваш процесс выполняется долго - например, веб-сервер. Это начинает иметь значение, когда конечные пользователи подвергаются задержке: время запуска - это время, когда пользователи смотрят на курсор в виде песочных часов в настольном приложении, заставку загрузки приложения в мобильном приложении или пустую веб-страницу в веб-приложении.

Время до первой инструкции пользователя

Интересной метрикой является то, сколько времени проходит от времени создания процесса до выполнения первой строки вашего Main (). Прежде чем среда выполнения сможет выполнить первую строку вашего кода, должно произойти множество вещей. На самом деле это довольно просто измерить, если вам нужно только приблизительное число - поместите вызов API times (в Linux) или GetProcessTimes (в Windows) в качестве первой строки вашего Main ().

Этот API дает вам информацию о том, сколько работы фреймворк проделал до того, как приступил к выполнению первой строки вашей программы. Для таких языков, как C, это число обычно будет 0 - первая строка вашей программы будет запущена до того, как ОС получит возможность обновить статистику. Если вы создаете приложения для командной строки, вам нужно, чтобы это число было 0. Эти числа складываются.

Вот как выглядит время до первой инструкции для различных сред выполнения .NET:

Номер для CoreRT равен нулю. Ваше приложение запускается так же быстро, как и приложение, написанное на C.

Размеры выходных данных компиляции

Большая разница между средами выполнения JIT и AOT заключается в размерах автономных развертываний. CoreRT извлекает выгоду из того факта, что большая часть того, что считается «средой выполнения» в других средах выполнения .NET (и написано на C / C ++), фактически написано на C # на CoreRT. Управляемый код можно связать, если приложение его не использует. Для традиционных сред выполнения часть времени выполнения представляет фиксированные затраты, которые нельзя адаптировать для каждого приложения. Благодаря этой настройке развертывания AOT могут быть значительно меньше:

А как насчет отражения?

С размышлениями все становится интереснее. Хотя ЦП не волнует, как вы называете свои методы, а компилятор AOT может не выдавать эту информацию, API отражения позволяют найти любой тип, метод или поле по имени и предоставить доступ к дополнительной информации об этих объектах. , например подпись метода или имена параметров метода.

Компилятор CoreRT решает эту проблему, сохраняя информацию об отражении на стороне - эта информация не требуется для запуска программы, и ее выдача необязательна. Мы можем назвать эти дополнительные данные «налогом на отражение». Компилятор AOT может позволить вам не платить за него, если вы его не используете.

Без данных отражения возможности отражения становятся ограниченными: можно по-прежнему использовать typeof, вызывать Object.GetType (), проверять базовые типы и реализованные интерфейсы (поскольку среда выполнения по-прежнему нуждается в этом для работы приведения типов), но список методов и полей, или имена типов становятся невидимыми.

Налог на отражение - это неизведанная территория в .NET: поскольку ни CoreCLR, ни Mono не могут работать без метаданных IL, исключение метаданных не является вариантом для основных сред выполнения. Но это может быть дверью к размерам развертывания менее мегабайта, что особенно важно для таких целей, как WebAssembly.

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

Как насчет динамического кода?

.NET предоставляет несколько возможностей, позволяющих генерировать новый код во время выполнения. Будь то Reflection.Emit, Assembly.LoadFrom или даже что-то более простое, как MakeGenericType и MakeGenericMethod. Это создает проблему для среды выполнения AOT, потому что эти вещи не могут быть выполнены заранее по определению (или, по крайней мере, не могут быть выполнены для всех программ).

Здесь среда выполнения AOT также вынуждена выполнять загрузку точно в срок. Важным моментом является то, что в среде выполнения AOT-first нужно оплачивать своевременные затраты только в том случае, если они явно используют динамические функции: не вызывают динамические API-интерфейсы и отсутствуют накладные расходы времени выполнения.

CoreRT в настоящее время имеет интерпретатор прототипа и прототип JIT, показывающие, что динамический код не является ограничивающим фактором для среды выполнения, разработанной для AOT.

Так что же такое CoreRT?

CoreRT - экспериментальная кроссплатформенная среда выполнения .NET с открытым исходным кодом, специализирующаяся на компиляции AOT. Хотя CoreRT (в целом) имеет ярлык экспериментальный, многие части CoreRT поставляются в составе поддерживаемых продуктов. CoreRT берет части .NET Native и части CoreCLR и объединяет их. Примерный процент того, что CoreRT разделяет с CoreCLR и .NET Native, показан на картинке слева.

Благодаря этой настройке CoreRT улучшается каждый раз, когда кто-то улучшает CoreLib в CoreCLR или реализует новую оптимизацию в RyuJIT.

Если вы хотите попробовать CoreRT, вы можете опубликовать приложение .NET Core с помощью CoreRT, просто добавив ссылку на новый пакет NuGet. Инструкции для этого находятся здесь.

JIT для AOT так же, как бензиновый двигатель для электрического двигателя

Технические характеристики заблаговременно составленных и точно скомпилированных значений времени работы можно сравнить с характеристиками электрического и бензинового двигателя автомобиля.

  • Электродвигатели генерируют движение, а не тепло. Своевременно скомпилированное приложение .NET будет тратить значительное количество ресурсов на выполнение действий, которые поддерживают выполнение вашего кода, но не на самом деле запускают ваш код.
  • На более низких оборотах электродвигатели развивают больший крутящий момент, чем бензиновые. Это делает их намного лучше при разгоне. В приложении, скомпилированном AOT, пиковая пропускная способность доступна немедленно, и ваше приложение сразу же запускается на полной скорости. Бензиновый двигатель в конечном итоге превзойдет электрический двигатель, как и высокооктановый двигатель, скомпилированный точно в срок.
  • Электродвигатели попроще. Если посмотреть на высокооктановую среду выполнения, скомпилированную «точно в срок» с динамической перекомпиляцией, многоуровневым JITting и заменой стека, начинает обнаруживаться множество сложностей. Эти сложности усложняют задачу разработчику среды выполнения, но также и пользователю среды выполнения. Вкус собственного кода, выполняемого в производственной среде, зависит от динамической настройки среды выполнения, выполняемой на основе прошлых характеристик программы.

И бензиновые, и электрические двигатели имеют свое применение и свое место. Приятно иметь доступные параметры и хорошо, что они есть в .NET.