Ускоряем запуск приложений с .NET 6, .NET на холодильнике и многое другое
Введение
Данная статья содержит небольшое введение в JIT-компиляцию и .NET Core (отныне .NET 5, .NET 6 и так далее), а также несколько практических примеров ускорения запуска приложений на .NET. Данные советы могут быть полезны как для приложений, запускаемых на больших многоядерных x64 серверах, так и для приложений, запускаемых на ARM чипах с малым числом ядер. Например, подобные оптимизации используются в операционной системе Tizen, об этом далее.
Эта статья написана в рамках подготовки к моему выступлению 15 сентября на Samsung Open Source Conference Russia 2021. Конференция открытая, будет проходить онлайн. Регистрируйтесь и сможете оставить вопрос мне и другим спикерам на сайте конференции https://sosconrussia.net.
.NET Core
Что такое .NET Core? Это кроссплатформенная виртуальная машина и фреймворк для нескольких языков программирования (C#, F#, VB.NET), имеющие открытый исходный код. Среди поддерживаемых платформ - Windows, Linux и macOS, а среди поддерживаемых архитектур - x86, x64, arm и arm64.
.NET Core пришел на смену .NET Framework и сейчас крайне активно развивается. Например, число ежемесячных коммитов в мастер в июне-июле 2021 года более 550 штук, а число авторов коммитов достигает 160. А вот так выглядит план релизов вплоть до 2023 года (источник):
Кроме того, с каждым релизом улучшается производительность .NET Core, об этом команда Microsoft много пишет в своем блоге. Например, в статье про .NET 5 приведены сравнения .NET Framework 4.8, .NET Core 3.1 и .NET 5.0 на различных бенчмарках, из которых отчетливо видно превосходство .NET 5 во всех сценариях. Множество из показанных оптимизаций касаются JIT компилятора, о котором поговорим далее. Таким образом, .NET является крайне перспективной платформой для разработки.
Из названия .NET Core исчезло слово Core начиная с версии .NET 5 (дата релиза: 10.11.2020), которая ознаменовала собой объединение платформы .NEТ (включая, например, переезд как репозиториев .NET Core, так и Mono в один репозиторий dotnet/runtime на гитхабе). Целью объединенной платформы было взятие лучшего из .NET Core, .NET Framework, Xamarin и Mono, а также создание одной общей виртуальной машины и фреймворка, которые имели бы одинаковое поведение и пользовательский опыт. .NET 6, выход которого ожидается в ноябре 2021 года, продолжит развитие в направлении унификации платформы .NET и, кроме того, станет LTS релизом со сроком поддержки 3 года.
Ключевыми факторами в применении .NET Core на различных платформах являются его открытость и высокая производительность. Именно поэтому, начиная с версии 4.0, операционная система Tizen поставляется с .NET Core. Наша команда в Московском Исследовательском центре Samsung (Samsung Research Russia) произвела множество улучшений и оптимизаций в .NET Core, которые сейчас активно используются в девайсах на операционной системе Tizen: умных часах, телевизорах и даже холодильниках!
Например, на CES 2021 была представлена новая версия холодильника Samsung Family Hub, приложения для которого можно разрабатывать на C# (источник).
Подходы к улучшению времени запуска приложения
Из чего состоит время запуска приложения? В общем случае это время компиляции кода плюс время исполнения кода.
Для полностью компилируемых языков вроде C/C++ время компиляции полностью исчезает из времени запуска, так как компиляция выполняется заранее (например, с помощью gcc или clang), и такой подход называется Ahead-Of-Time компиляцией (AOT). В таком сценарии компиляция может занимать существенное время, выполняя множество различных оптимизаций кода. В результате этого конечные пользователи получают максимальную производительность, а длительное время компиляции влияет только на разработчиков:
Однако у данного подхода есть и свои недостатки. Например, после AOT компиляции получается высоко-оптимизированный машинный код, привязанный к целевой платформе. Данный код нельзя будет перенести и запустить на машине с другой архитектурой (без использования эмулятора). Следовательно, компиляцию будет необходимо выполнять для каждой платформы заново.
Альтернативным подходом является Just-In-Time компиляция (JIT), при которой виртуальная машина (vm или runtime) получает на вход или текст программы (как, например, виртуальные машины V8, JavaScriptCore или Dart), или некоторое внутреннее представление (как, например, .NET). Соответственно, запуск приложения на таких виртуальных машинах будет включать в себя не только работу скомпилированного кода приложения, но и компиляцию этого кода.
У JIT компиляции есть свои плюсы. Например, нет необходимости перекомпилировать приложение под каждую платформу, приложение сразу является переносимым (без использования платформо-специфичного API) и поддерживает все платформы, на которых работает виртуальная машина. Такой подход снимает существенную часть обязанностей с разработчиков приложений и перекладывает их на разработчиков виртуальной машины, тем самым ускоряя разработку.
Однако у JIT компиляции есть и свои недостатки. Например, чтобы добиться хорошего времени запуска приложения, нужно искать баланс между временем компиляции и временем выполнения кода. Как правило, оказывается выгоднее быстро получить неоптимальный код и начать его исполнять, чем долго оптимизировать код и только затем переходить к его выполнению. В некотором смысле, при таком подходе производительностью после запуска жертвуют в угоду быстрому запуску, но и эту проблему можно решить. Многие виртуальные машины с JIT компиляторами имеют несколько уровней компиляции (Tiered JIT), где каждый последующий уровень выдает более качественный код ценой более долгой компиляции. Ключевым моментом в такой схеме является переход кода с одного уровня на другой, который выполняется только для часто выполняемого кода, также называемого "горячим". Функция или инструкция становится "горячей" после достижения определенного числа вызовов или исполнений. Кроме того, в таких многоуровневых схемах часто используется интерпретатор, который позволяет начать выполнение кода немедленно, пусть и достаточно неторопливо. Например, в современных виртуальных машинах для JavaScript используется 3-4 уровня (V8, JavaScriptCore), в .NET Core используется 2 уровня.
Еще одной важной особенностью JIT компиляции является то, что компилируются только те функции, которые исполняются. Более того, для высоко-динамических языков наподобие JavaScript широко используется спекулятивная компиляция, при которой собирается профиль приложения, как то типы, используемые функцией. Затем функция компилируется только для данных типов, с откатом на более ранний уровень Tiered JIT, если спекуляция оказалась неверной и пришедшие типы по факту оказались другими. Вкупе с обильным применением оптимизаций на высоких уровнях Tiered JIT это позволяет добиться очень высокопроизводительного кода. Такая компиляция является формой Profile Guided Optimization (PGO), подобные технологии разрабатываются также и в .NET Core.
Существуют и более изощренные подходы. Если выше рассматривался так называемый method JIT, где единицой компиляции является функция, то существуют еще tracing JIT компиляторы (например, LuaJit или TraceMonkey из Firefox в прошлом), которые записывают последовательность исполняемых инструкций. Эта последовательность затем компилируется в машинный код, при необходимости возвращаясь на более ранний уровень Tiered JIT, если перестают выполняться спекулятивные условия. Плюсом такого подхода является то, что существенно упрощается поток управления и что компилируется только тот код, который реально выполнялся.
Но и это еще не все, некоторые виртуальные машины идут дальше. Можно выполнять компиляцию трассирующим JIT самого интерпретатора какого-либо языка, такой подход еще называется MetaJit. Так, например, работает PyPy, интерпретатор Python, написанный на RPython, который затем с помощью tracing JIT компилируется в нативный код. Такая платформа позволяет создавать интерпретаторы других языков на RPython и затем сразу же получать tracing JIT как бонус.
Вернемся к AOT, можно ли его совместить с JIT? Ответ утвердительный, однако, имеются нюансы. Чтобы добиться максимальной эффективности от компиляции выполняемой заранее, необходимо не полагаться на спекулятивную информацию из собранных профилей и создавать обобщенный код, способный работать с любыми типами. Такая AOT компиляция существенно снижает время компиляции во время запуска, но не убирает ее полностью (например, в C# можно создавать собственные типы прямо во время работы, используя reflection). Кроме того, время выполнения такого кода может вырасти в сравнении с кодом, создаваемым JIT компилятором во время запуска даже без спекуляций (нельзя использовать адреса напрямую, не все можно инлайнить). Использование Tiered JIT в таком сценарии помогает избежать проблем с выполнением неэффективного кода, например, .NET Core рассматривает AOT код как первый уровень Tiered JIT (Tier0), при необходимости перекомпилируя его на втором уровне Tiered JIT (Tier1). Если же выполнение пользовательского кода занимает небольшую часть времени запуска, то замедление выполнения этого кода будет незаметно.
Для виртуальных машин также возникает дополнительный аспект, влияющий на время запуска приложения и связанный с созданием метаданных типов. Если AOT файлы содержат только скомпилированный код, то создание типов для этого кода будет происходить во время запуска. Таким образом, для ускорения запуска нужно улучшать суммарное время "компиляция + создание типов + выполнение", и для типов, например, может использоваться какой-либо вариант кэша, который совмещает как AOT код, так и предсозданные типы.
Улучшаем время запуска приложений на .NET 6
Перейдем к ускорению запуска приложений на .NET 6. Для начала попробуем запустить простой Hello World на Linux:
using System;
namespace hwapp
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
Для создания dll нужно установить dotnet sdk, например, можно взять .NET 5 SDK. После этого:
user@linux$ mkdir hwapp
user@linux$ cd hwapp
user@linux$ dotnet new console
Затем необходимо подменить содержимое Program.cs
нашей тестовой программой и запустить билд dll:
user@linux$ dotnet build -c Release
Если dotnet runtime также установлен, то далее можно выполнить просто dotnet run
. Мы же пойдем другим путем, собрав ветку .NET 6 своими руками. Для этого нужно скачать исходники и запустить билд (предварительно установив все необходимые зависимости):
user@linux$ cd ..
user@linux$ git clone https://github.com/dotnet/runtime
user@linux$ cd runtime
user@linux$ git checkout release/6.0
user@linux$ ./build.sh --arch x64 --runtimeConfiguration Release --librariesConfiguration Release --subset clr+libs
После этого нужно собрать все артефакты вместе, например, в директории overlay_x64
:
user@linux$ mkdir overlay_x64
user@linux$ cd overlay_x64
user@linux$ cp ../artifacts/bin/coreclr/Linux.x64.Release/{*.so,*.dll,corerun,crossgen2} . -r
user@linux$ cp ../artifacts/bin/coreclr/Linux.x64.Release/IL/System.Private.CoreLib.dll .
user@linux$ cp ../artifacts/bin/microsoft.netcore.app.runtime.linux-x64/Release/runtimes/linux-x64/lib/net6.0/*.dll .
user@linux$ cp ../artifacts/bin/native/net6.0-Linux-Release-x64/*.so .
После этого можно выполнить запуск:
user@linux$ ./corerun ../../hwapp/bin/Release/netcoreapp2.2/hwapp.dll
Hello World!
Ура, мы запустили .NET 6. Время запуска hello world на моей машине составило 91 миллисекунду. Важно, что в таком сетапе полностью отсутствуют AOT образы.
Tiered JIT
В .NET Core начиная с версии 2.1 добавлен Tiered JIT, состоящий из двух уровней: быстрый JIT без оптимизаций (Tier0) и медленный JIT с оптимизациями (Tier1). На самом деле как такового разделения между ними нет, потому что используется один и тот же JIT компилятор, но с разными флагами (CLFLG_MINOPT
и CLFLG_MAXOPT
). На Tier0 также используются некоторые оптимизации, которые позволяют улучшить итоговый код, несущественно увеличив время компиляции. Кроме того, при некоторых условиях Tiered JIT для функции может быть вообще выключен, тем самым для такой функции будет сразу создаваться оптимизированный код.
Tier0 компиляция позволяет получить приемлемый код достаточно быстро, чтобы приложение могло как можно быстрее запуститься, а Tier1 компиляция позволяет получить высоко-оптимизированный код для функций, которые вызываются достаточно часто. Tier1 JIT запускается в фоновом потоке, поэтому работа Tier0 кода не останавливается, а переключение между Tier0 и Tier1 происходит между запусками функции. На работу Tiered JIT компилятора можно повлиять, например, с помощью переменных окружения. На Linux этот способ работает как с командой dotnet
, так и c непосредственным вызовом corerun
.
COMPlus_TC_QuickJitForLoops
Если время запуска приложения является самой важной метрикой и во время запуска приложения выполняется не так много кода самого приложения, то можно пожертвовать некоторой производительностью после запуска на относительно коротком промежутке времени. По умолчанию в .NET Core выключен COMPlus_TC_QuickJitForLoops
. Это означает, что для функций с циклами Tiered JIT выключен, для них сразу создается оптимизированный код, и такая компиляция занимает больше времени, чем компиляция неоптимизированного кода.
В определенный момент Tier1 JIT запустится для "горячих" функций c циклами, минимизировав разрыв в производительности с запуском без COMPlus_TC_QuickJitForLoops=1
. Кроме того, в будущем, возможно, будет расширен набор платформ, поддерживающих On-Stack-Replacement (OSR), который позволит переключить выполнение функции с неоптимизированного кода на оптимизированный прямо во время ее работы, без необходимости выхода из нее. Это крайне важно как раз для функций с циклами, которые могут выполняться достаточно долго.
Запустим наш пример:
user@linux$ COMPlus_TC_QuickJitForLoops=1 ./corerun ../../hwapp/bin/Release/netcoreapp2.2/hwapp.dll
Hello World!
Время запуска на моей машине составило 72 миллисекунды, то есть мы смогли ускорить время выполнения почти на 21%. Это ожидаемый результат, во-первых, потому что отстуствуют AOT образы, а во-вторых, потому что приложение завершается очень быстро и гораздо выгоднее как можно быстрее получить машинный код.
Таким образом, COMPlus_TC_QuickJitForLoops=1
должен использоваться с осторожностью и после предварительных замеров, однако, в некоторых ситуациях позволяет добиться ускорения. Например, включение данной опции на Tizen для мобильных девайсов на ARM позволяет добиться уменьшения времени запуска в среднем на 6% для приложений на Xamarin, даже при наличии AOT образов для всех используемых приложением dll.
COMPlus_TC_CallCountThreshold и COMPlus_TieredCompilation
Tiered JIT включен по умолчанию (COMPlus_TieredCompilation=1
) и число вызовов функции для выполнения Tier1 компиляции составляет 30 (COMPlus_TC_CallCountThreshold=30
). Эти опции также можно настроить, и, потенциально, на low-end девайсах с одним CPU фоновый поток Tiered JIT может мешать основному потоку. В таких случаях может быть полезно как полностью отключить Tiered компиляцию, так и просто увеличить лимит, начиная с которого выполняется Tier1 компиляция. Увеличение лимита позволит добиться как более быстрого запуска приложения, так и высокой производительности в дальнейшем, так как "горячие" функции рано или поздно будут скомпилированы на уровне Tier1. Аналогичным образом в каком-то случае может оказаться выгоднее понизить значение COMPlus_TC_CallCountThreshold
.
Запустим наш пример:
user@linux$ COMPlus_TieredCompilation=0 ./corerun ../../hwapp/bin/Release/netcoreapp2.2/hwapp.dll
Hello World!
Время запуска на моей машине составило 102 миллисекунды, что ожидаемо, так как фоновому потоку Tier1 JIT на моей машине ничего не мешало. Установка COMPlus_TC_CallCountThreshold
не дала какого-либо видимого эффекта.
AOT
.NET 6 поддерживает AOT компиляцию модуля ABC.dll в ABC.ni.dll в формате Ready-To-Run (R2R) с помощью AOT компилятора crossgen2 (aot). Получаемый файл ABC.ni.dll так же как и ABC.dll имеет PE формат, в одной из секций которого хранится машинный код.
Ключевой особенностью данного формата является независимость одного модуля ni.dll от версий других модулей (аналогично независимости обычных модулей от версий друг друга). Это достигается за счет сохранения обобщенного кода во время AOT компиляции и отсутствия зависимых от версии рантайма данных (например, кэша типов) в ni.dll. Например, для дженериков, параметризированных ссылочными типами, создается обобщенный код, способный во время запуска вызывать методы для конкретного типа без существенной просадки производительности. Подобный подход радикально упрощает обновление части dll без необходимости длительного ожидания пересоздания AOT образов для всех dll. Кроме того, ni.dll содержит CIL код из обычной dll, таким образом, последняя может полностью заменяться на первую на диске.
Использование AOT компиляции существенно улучшает время запуска приложения за счет сокращения числа компиляций. Однако, не все функции могут быть сохранены в ni.dll, что иногда является следствием того, что модули могут обновляться независимо друг от друга. Например, в .NET Core дженерики, параметризованные типами-значениями из одного модуля, не могут быть скомпилированы во время AOT компиляции другого модуля (Mono поддерживает компиляцию таких дженериков, однако, получаемый машинный код становится существенно сложнее).
Чтобы решить эту проблему, в .NET Core есть понятие version bubble, который можно рассматривать как множество модулей, обновляемых одновременно. Внутри этого множества можно выполнять оптимизации, которые не учитывают независимость от версий других модулей из множества. Это позволяет, например, AOT компилировать дженерики, параметризованные типами-значениями из другого модуля из этого множества.
Посмотрим, как пользоваться AOT компилятором crossgen2 в .NET 6 вручную. Crossgen2 сам является приложением, написанным на C#, вызывающим тот же JIT компилятор, что и виртуальная машина во время запуска. Это порождает интересную ситуацию, при которой в процессе AOT компилятора будут находиться две библиотеки JIT компилятора: одну AOT компилятор использует для создания кода, а другая используется в виртуальной машине для запуска самого crossgen2. Более того, можно создать AOT код для самого AOT компилятора!
Рассмотрим наш пример. Во-первых, нужно скомпилировать используемые dll фреймворка, для начала сделаем это только для System.Private.CoreLib.dll:
user@linux$ ./corerun `pwd`/crossgen2/crossgen2.dll -r:`pwd`/*.dll -O --compilebubblegenerics --inputbubble -o:`pwd`/System.Private.CoreLib.ni.dll System.Private.CoreLib.dll
user@linux$ mv System.Private.CoreLib.dll System.Private.CoreLib.dll.bak
user@linux$ mv System.Private.CoreLib.ni.dll System.Private.CoreLib.dll
Время запуска на моей машине составило всего 46 миллисекунд. Если же скомпилировать все dll, то время запуска составит 34 миллисекунды!
Обратите внимание на опции --compilebubblegenerics
и --inputbubble
, которые включают все используемые dll в version bubble. Опция -O
включает оптимизации, а -r
задает пути к используемым dll.
Tizen
Аналогичным образом при использовании AOT компилятора на операционной системе Tizen время запуска Xamarin приложений существенно уменьшается. Как было сказано ранее, R2R формат содержит только машинный код (в дополнение к CIL) и не содержит кэш типов. Поэтому создание метаданных типов будет занимать часть времени запуска приложений.
На Xamarin приложениях на Tizen распределение времени запуска выглядит примерно следующим образом:
В категории "Остальное" большую часть времени занимает работа скомпилированного кода приложения, который может также уходить в нативный API операционной системы для отрисовки графики и т.д. Таким образом, задача уменьшения времени, затрачиваемого во время старта приложения на создание типов, является такой же приоритетной на Tizen, как и сокращение времени компиляции.
One more thing...
Существует еще одна опция для улучшения времени запуска приложения: компиляция в фоновом потоке по собранному ранее профилю приложения (еще одна форма PGO). При таком подходе на первом запуске приложения можно собрать его профиль, а на втором при запуске виртуальной машины запустить дополнительный фоновый поток, который будет компилировать функции из профиля. Потенциально к моменту вызова функции ее код уже будет скомпилирован в фоновом потоке. Кроме того, эту технику можно объединить с AOT и Tiered JIT.
.NET Core уже имеет подобный API и компоненту под названием MultiCoreJit, доставшиеся в наследство от .NET Framework. Наша команда улучшила данную технологию, расширив ее для уменьшения времени, затрачиваемого на создание метаданных, что позволило добиться ускорения запуска Xamarin приложений на Tizen на 22% даже с имеющимися AOT образами. Эта оптимизация может быть полезна не только на Tizen, и я расскажу о ней подробнее в своем выступлении на конференции SOSCON Russia 2021. Приходите послушать!
Об авторе
Глеб Балыков, Expert Engineer, Platform Lab Team, Московский Исследовательский центр Samsung.
Автор выражает большую признательность рецензенту из Московского Исследовательского центра Samsung Солдатову Александру.