Что такое PGO в .NET и нужно ли его включать?
PGO (оптимизация на основе профиля) позволяет JIT-компилятору .NET специализировать горячий код под типы и ветви, которые действительно встречаются в вашей нагрузке. Dynamic PGO включён по умолчанию начиная с .NET 8, поэтому в .NET 8 и более поздних версиях включать его не нужно. Вот что он делает, как увидеть его эффект и те редкие случаи, когда стоит тронуть настройку.
PGO расшифровывается как оптимизация на основе профиля: компилятор собирает профиль того, как ваш код на самом деле выполняется, а затем использует этот профиль, чтобы сгенерировать более быстрый машинный код для путей, которые важны. В .NET JIT делает это во время выполнения, режим под названием Dynamic PGO, и короткий ответ на вопрос “нужно ли его включать?” — нет, больше не нужно. Dynamic PGO включён по умолчанию начиная с .NET 8, поэтому в .NET 8, .NET 9, .NET 10 и .NET 11 он у вас уже есть. К переключателю вы обращаетесь, только если работаете на .NET 6 или 7 (где он был выключен по умолчанию), или если у вас необычная, очень короткоживущая нагрузка, в которой вы хотите его выключить. Эта статья объясняет, что даёт профиль, как убедиться, что он работает, и те немногие случаи, когда стоит тронуть настройку.
Всё здесь нацелено на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11 (11.0.100), но поведение “включено по умолчанию” держится с .NET 8, а лежащий в основе механизм стабилен с тех пор, как .NET 7 его укрепил. Там, где поведение зависит от конкретной версии, я это отмечаю.
Что на самом деле означает “на основе профиля”
Традиционному оптимизирующему компилятору приходится гадать. Когда он видит вызов виртуального метода, он не может знать, какой конкретный тип появится во время выполнения, поэтому выдаёт общий косвенный вызов. Когда он видит if, он не знает, какая ветвь горячая, поэтому размещает обе так, как будто они равновероятны. Когда он видит цикл, он не знает, выполнится ли тот дважды или два миллиона раз. Эти догадки обычно приемлемы, а иногда оставляют много производительности на столе.
Оптимизация на основе профиля убирает гадание. Она запускает программу, записывает, что действительно произошло в каждой из этих точек принятия решения, и возвращает эти данные оптимизатору. Теперь компилятор знает, что конкретная точка вызова была List<int> в 98% случаев, что конкретная ветвь берётся раз на тысячу итераций, что конкретный цикл действительно горячий. Он может специализировать код под реальность, а не подстраховываться на все возможности.
Классический предварительный PGO делает это в двух отдельных сборках: инструментированная сборка, которую вы запускаете на репрезентативной нагрузке, чтобы получить профиль, а затем вторая сборка, потребляющая профиль. Так делали инструментальные цепочки C и C++ десятилетиями, и это мощно, но неудобно, потому что вам нужно захватить нагрузку, похожую на продакшн, и пересобрать. Dynamic PGO в .NET сливает обе фазы в один выполняющийся процесс, что и делает его тем, что можно просто оставить включённым.
Как Dynamic PGO опирается на многоуровневую компиляцию
Dynamic PGO — не отдельный проход, прикрученный к среде выполнения. Он едет верхом на механизме, который JIT уже использует, чтобы компилировать горячие методы дважды. Если вы ещё не усвоили эту модель двух проходов, статья о том, что такое многоуровневая компиляция и как о ней рассуждать, — это предпосылка; очень короткая версия в том, что методы сначала компилируются как быстрый, неоптимизированный код “tier 0”, и только горячие пересобираются как полностью оптимизированный код “tier 1” в фоне.
Dynamic PGO использует этот первый проход. Когда он активен, код tier 0, который выдаёт JIT, не просто неоптимизирован — он инструментирован. Компилятор вставляет дешёвые зонды, которые считают, как часто выполняется каждый базовый блок, а в точках виртуальных и интерфейсных вызовов строят небольшую гистограмму того, какие конкретные типы действительно приходят. Пока ваше приложение прогревается, этот код tier 0 тихо собирает профиль вашей конкретной нагрузки. Когда метод позже повышается до tier 1, оптимизатор читает собранный профиль и специализирует код tier 1 соответственно.
Этот момент времени — весь фокус. Профиль собирается с реальной нагрузки, на реальной машине, за мгновения до того, как будет произведён оптимизированный код, поэтому нет отдельной инструментированной сборки и нет проблемы репрезентативной нагрузки. Цена в том, что инструментированный код tier 0 немного медленнее и немного больше, чем обычный tier 0, что важно только в течение краткого окна до повышения.
Оптимизация, которая окупает всё остальное: направляемая девиртуализация
Самое ценное, что даёт Dynamic PGO, — направляемая девиртуализация (GDV). Виртуальные и интерфейсные вызовы дороги не потому, что сам косвенный переход медленный, а потому, что JIT не может проинлайнить сквозь них, а инлайнинг — это то, откуда большинство других оптимизаций получают своё преимущество. Если компилятор не может заглянуть внутрь вызова, он не может сворачивать константы через него, выносить работу из него или устранять избыточные проверки вокруг него.
Имея на руках профиль типов, JIT может вставить быстрый путь. Он выдаёт явную проверку типа, который доминировал в профиле, и если проверка проходит, выполняет проинлайненную, полностью оптимизированную копию тела этого метода; если не проходит, откатывается к обычному виртуальному вызову. Рассмотрим цикл по IEnumerable<int>, который на самом деле всегда List<int>:
// .NET 11, C# 14.
// Dynamic PGO learns that `items` is a List<int> at this call site
// and inlines the enumerator's MoveNext/Current behind a type guard.
static long Sum(IEnumerable<int> items)
{
long total = 0;
foreach (int x in items) // virtual GetEnumerator/MoveNext without PGO
total += x;
return total;
}
Без PGO foreach проходит через интерфейсную диспетчеризацию на каждом элементе. С PGO JIT угадывает List<int>, страхует догадку одной проверкой типа и инлайнит перечислитель, так что тело цикла становится плотной арифметикой целых чисел без выделений памяти. Начиная с .NET 8 JIT может даже выдать более одной страховки в точке, которая видит два доминирующих типа, хотя множественный GDV выключен по умолчанию и спрятан за настройкой конфигурации. Команда .NET измеряла методы фреймворка, где один только GDV сократил время выполнения примерно на 40%, и сообщала о реальных выигрышах в продакшене: миграция Bing на .NET 8 отнесла падение циклов CPU на запрос на 13%, снижение доли запросов, затронутых GC gen0/gen1, на 20% и падение некоторых внутренних задержек более чем на 25% в значительной мере на то, что Dynamic PGO включён по умолчанию.
Нужно ли его включать? Ответ по версиям
Вот та часть, о которой на самом деле спрашивает поисковый запрос.
- .NET 8, 9, 10 и 11: нет. Dynamic PGO включён по умолчанию. Вы не делаете ничего. Если
<TieredCompilation>в значении по умолчанию (включено), у вас уже есть PGO. - .NET 7: он существует и хорошо работает, но выключен по умолчанию. Включите настройкой ниже, если хотите.
- .NET 6: здесь он появился как эксперимент, выключен по умолчанию и менее зрелый. Вы можете включить его, но относитесь к нему как к предварительному качеству.
- .NET 5 и ранее: Dynamic PGO не существует.
Чтобы включить его там, где это не значение по умолчанию, задайте свойство MSBuild в файле проекта:
<!-- .NET 7 (or .NET 6). Opts into Dynamic PGO, which is off by default there. -->
<!-- Unnecessary on .NET 8+, where it is already enabled. -->
<PropertyGroup>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
Или задайте переменную окружения, что удобно для A/B-тестирования без пересборки:
# Enable Dynamic PGO for a single run (any supported .NET version).
DOTNET_TieredPGO=1 ./myapp
Свойство MSBuild, ключ runtimeconfig.json (System.Runtime.TieredPGO) и переменная окружения DOTNET_TieredPGO управляют одним и тем же переключателем, задокументированным в справочнике Microsoft по настройкам конфигурации компиляции. Поскольку Dynamic PGO зависит от многоуровневой компиляции, отключение многоуровневой компиляции (DOTNET_TieredCompilation=0) как побочный эффект отключает и Dynamic PGO; нет прохода профилирования, если нет tier 0.
Когда его можно обоснованно выключить
Оставить PGO включённым — правильный выбор почти для любого приложения, но есть одно честное исключение: процесс, который завершается прежде, чем его горячие методы вообще достигнут tier 1. Короткоживущая CLI, serverless-функция, обслуживающая один запрос и умирающая, AWS Lambda с агрессивным таймаутом. В таких нагрузках инструментированный код tier 0 выполняется, платит небольшой налог на профилирование, и процесс завершается прежде, чем произойдёт какая-либо пересборка в tier 1, так что вы заплатили за профиль, который так и не потратили.
<!-- .NET 11, C# 14. Turns Dynamic PGO OFF. -->
<!-- Only sensible for ultra-short-lived processes that never reach tier 1. -->
<PropertyGroup>
<TieredPGO>false</TieredPGO>
</PropertyGroup>
Даже здесь не полагайтесь на предположения; измеряйте. Многие чувствительные к холодному старту нагрузки получают больший выигрыш от совершенно иной стратегии, такой как образы ReadyToRun или Native AOT, чем от переключения PGO. Компромиссы именно для холодного старта проработаны в руководстве по сокращению времени холодного старта AWS Lambda на .NET 11. А если вы рассматриваете полный отказ от JIT, учтите, что код Native AOT никогда не получает переоптимизацию Dynamic PGO во время выполнения, потому что в предварительно скомпилированном бинарнике нет прохода профилирования tier 0.
Static PGO: профиль, который поставляется с фреймворком
Dynamic PGO — не единственный PGO в .NET. Собственные предварительно скомпилированные образы ReadyToRun фреймворка собираются со static PGO: Microsoft собирает профиль офлайн, сохраняет его как файл .mibc и передаёт компилятору crossgen2, так что даже предварительно скомпилированный код фреймворка располагается согласно репрезентативному профилю. Вот почему string, List<T> и остальная базовая библиотека классов приходят уже настроенными, прежде чем ваше приложение выполнит хоть одну строку.
Вы можете сделать то же для собственных сборок AOT или ReadyToRun: захватите профиль инструментом, произведите .mibc и передайте его crossgen2. Это нишевый и продвинутый рабочий процесс, в основном актуальный, если вы публикуете Native AOT и хотите раскладку на основе профиля без JIT во время выполнения. Для подавляющего большинства серверных и десктопных приложений, которые сохраняют JIT, Dynamic PGO даёт тот же класс выгоды автоматически и подстраивается под реальную нагрузку, а не под консервированный профиль, что строго лучше, чем статический профиль, запечённый во время компиляции. Выбор между JIT-с-PGO и моделями AOT — тема статьи Native AOT vs ReadyToRun vs чистый JIT в .NET 11.
Как убедиться, что PGO действительно что-то делает
Поскольку PGO по замыслу тих, естественный вопрос — как вы узнаёте, что он работает. Два подхода.
Первый — посмотреть на машинный код. Установка DOTNET_JitDisasm в имя метода выгружает ассемблер tier 1, и метод, прошедший через GDV, показывает характерную форму: сравнение типа, проинлайненный быстрый путь и запасной вызов. Если вы отключите PGO и выгрузите снова, страховка и проинлайненное тело исчезнут, и останется чистый виртуальный вызов. Это “до и после” — самое ясное доказательство того, что профиль изменил вывод.
# .NET 11 SDK 11.0.100. Dump the JIT's assembly for a specific method,
# then compare with DOTNET_TieredPGO=0 to see the guarded fast path vanish.
DOTNET_JitDisasm=Sum DOTNET_TieredPGO=1 ./myapp
Второй подход — измерять пропускную способность правильно, что означает измерять код tier 1 в установившемся состоянии, а не прогрев tier 0, который ему предшествует. Не пишите вручную цикл со Stopwatch; используйте BenchmarkDotNet, который прогревается, пока времена не стабилизируются, и может прогнать один и тот же бенчмарк с PGO включённым и выключенным, чтобы вы сравнивали сравнимое. Если вы хотите увидеть, как события уровней и JIT проходят в реальном процессе, статья о профилировании приложения .NET с dotnet-trace проходит через захват провайдера среды выполнения, который сообщает уровень оптимизации каждого метода.
Мысленная модель, которую стоит держать: PGO — это причина, по которой замысел .NET “компилировать горячие методы дважды” производит код, который статический компилятор не может повторить, потому что вторая компиляция информирована профилем вашей реальной нагрузки. В .NET 8 и более поздних он уже включён, уже подстраивается и уже окупает себя. Вам не нужно его включать. Вам нужно лишь знать, что он есть, чтобы при бенчмаркинге вы измеряли оптимизированный уровень и приписывали профилю ту скорость, которую он заработал.
Related
- Что такое многоуровневая компиляция и как о ней рассуждать?
- Что такое Native AOT и во что он вам обходится?
- Native AOT vs ReadyToRun vs чистый JIT в .NET 11
- Как профилировать приложение .NET с dotnet-trace и читать вывод
- Как сократить время холодного старта AWS Lambda на .NET 11
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.