Март 2016
Том 31 номер 3
Компиляторы - Оптимизация управляемого кода на основе профиля с применением фоновой JIT
Хейди Брейс | Март 2016
Продукты и технологии:
Microsoft .NET Framework, JIT, C#, PerfView
В статье рассматриваются:
- издержки, вносимые вызовом JIT-компилятором для трансляции IL-кода;
- применение фоновой JIT для ускорения запуска в распространенных сценариях применения;
- детальное описание того, как фоновая JIT скрывает издержки JIT-компиляции в фоновом потоке;
- случаи, в которых следует использовать фоновую JIT;
- применение PerfView для оценки выигрыша от фоновой JIT.
Некоторые оптимизации производительности, выполняемые компилятором, всегда являются хорошими. То есть независимо от того, какой код реально работает в период выполнения, такая оптимизация повысит производительность. Рассмотрим, к примеру, раскрытие цикла (loop unrolling), которое обеспечивает векторизацию. Эта оптимизация преобразует цикл так, чтобы вместо выполнения одной операции в теле цикла над единственным набором операндов (скажем, сложения двух целочисленных значений, хранящихся в разных массивах) та же операция выполнялась над несколькими наборами операндов одновременно (допустим, сложение четырех пар целочисленных значений).
С другой стороны, есть крайне важные оптимизации, которые компилятор выполняет с применением эвристики. То есть компилятор не знает наверняка, что эти оптимизации будут действительно хорошо работать для кода, который запускается в период выполнения. Две наиболее важные оптимизации, подпадающие под эту категорию, — распределение регистров (register allocation) и подстановку функций (замену вызовов телами функций) (function inlining). Вы можете помочь компилятору принимать более эффективные решения в осуществлении таких оптимизаций: прогоните программу один или более раз и передавайте ей типичный пользовательский ввод, в то же время протоколируя, какой код при этом выполняется.
Информация, собранная о выполнении приложении, называется профилем. Компилятор может использовать этот профиль, чтобы сделать некоторые оптимизации более эффективными, иногда обеспечивая значительное ускорение работы кода. Этот метод называют оптимизацией на основе профиля (profile-guided optimization, PGO). Вы должны применять этот метод, если вы написали читаемый, простой в сопровождении код, использующий хорошие алгоритмы, добились максимальной степени локальности доступа к данным, свели к минимуму конкуренцию на блокировках и включили все возможные оптимизации компилятором, но все еще не удовлетворены полученной производительностью. В целом, PGO можно задействовать для улучшения других характеристик кода — не только производительности. Однако метод, обсуждаемый в этой статье, применим лишь для повышения производительности.
Я подробно рассмотрел PGO для неуправляемого кода (native PGO) в компиляторе Microsoft Visual C++ в прошлогодней статье (msdn.com/magazine/mt422584). Для тех, кто читал ту статью, у меня есть кое-какие интересные новости. Использовать PGO для управляемого кода (managed PGO, MPGO) стало легче. В частности, функциональность, которую я намерен обсудить в этой статье, а именно фоновую JIT (также называемую JIT на многоядерных процессорах), является намного более простой. Однако это статья для «продвинутых» программистов. Группа CLR написала вводную статью в своем блоге три года назад (bit.ly/1ZnIj9y). Фоновая JIT поддерживается в Microsoft .NET Framework 4.5 и более поздних версиях.
Существует три метода PGO для управляемого кода.
- Скомпилируйте управляемый код в двоичный с помощью Ngen.exe (процесс, известный как preJIT), а затем используйте Mpgo.exe для генерации профилей, представляющих распространенные сценарии применения, которые можно использовать для оптимизации производительности двоичного кода. Это аналогично PGO для неуправляемого кода. Я буду называть этот метод статической MPGO.
- Когда какой-либо IL-метод (Intermediate Language) должен быть JIT-скомпилирован, генерируйте оснащенный двоичный код, который протоколирует информацию в период выполнения о том, какие части метода реально выполняются. Затем используйте этот профиль в памяти для повторной JIT-компиляции IL-метода, чтобы сгенерировать двоичный код с высокой степенью оптимизации. Это также похоже на PGO для неуправляемого кода за исключением того, что все происходит в период выполнения. Я буду называть этот метод динамической MPGO.
- Используйте фоновую JIT, чтобы в максимальной мере скрыть издержки JIT за счет интеллектуальной JIT-компиляции IL-методов до того, как они впервые будут выполнены. В идеале, к моменту первого вызова метод уже должен быть JIT-скомпилирован и не должно быть необходимости в ожидании JIT-компиляции этого метода.
Интересно, что все эти методы были введены в .NET Framework 4.5, и более поздние версии тоже поддерживают их. Статическая MPGO работает только с неуправляемыми образами, сгенерированными Ngen.exe. Динамическая MPGO, напротив, работает только с IL-методами. По возможности используйте Ngen.exe для генерации неуправляемых образов и оптимизируйте их, применяя статическую MPGO, поскольку этот метод гораздо проще и в то же время дает существенное прирост быстродействия кода. Третий метод, фоновая JIT, сильно отличается от первых двух методов, так как он уменьшает издержки JIT-компиляции, а не повышает производительность сгенерированного двоичного кода, а значит, может применяться совместно с одних из первых двух методов. Однако использование одной только фоновой JIT иногда может быть очень выгодным и увеличивать скорость запуска приложения или производительность в конкретном распространенном сценарии вплоть до 50%, что совсем немало. Эта статья посвящена исключительно фоновой JIT. В следующем разделе я рассмотрю традиционный способ JIT-компиляции IL-методов и то, как это влияет на производительность. Потом мы обсудим, как работает фоновая JIT, почему она работает именно так и как правильно использовать ее.
Традиционная JIT-компиляция
По-видимому, у вас уже есть базовое представление о том, как работает JIT-компилятор в .NET, так как этот процесс описан во множестве статей. Однако я хотел бы вернуться к этому предмету и рассказать о нем чуть подробнее и точнее (не особо углубляясь) перед тем, как перейти к фоновой JIT-компиляции, чтобы вам было легче следовать за мной в следующем разделе.
Рассмотрим пример на рис. 1. T0 — это основной поток. Светло-серые части потока указывают, что этот поток выполняет код приложения и работает на полной скорости. Допустим, что T0 выполняет некий метод, который уже был JIT-скомпилирован (самая верхняя светло-серая часть), и что следующей инструкцией является вызов IL-метода M0. Поскольку M0 будет выполняться впервые и поскольку он представлен в IL, он должен быть скомпилирован в двоичный код, которые процессор сможет исполнить. По этой причине, когда исполняется инструкция вызова, вызывается функция, известная как JIT IL-заглушка (stub). Эта функция в конечном счете вызывает JIT-компилятор для трансляции IL-кода M0 и возвращает адрес сгенерированного двоичного кода. Эта работа не имеет отношения к самому приложению и представлена на иллюстрации черной частью T0, указывающей, что это издержки. К счастью, участок памяти, где хранится адрес JIT IL-заглушки, будет обновлен адресом соответствующего двоичного кода, так что следующие вызовы той же функции будут выполняться с полной скоростью.
Рис. 1. Издержки традиционной JIT при выполнении управляемого кода
Timeline | Временная шкала |
Теперь после возврата из M0 выполняется кое-какой другой код, который уже был JIT-скомпилирован, а затем вызывается IL-метод M1. Как и в случае M0, вызывается JIT IL-заглушка, которая в свою очередь вызывает JIT-компилятор для трансляции метода и возврата адреса его двоичного кода. После возврата из M1 выполняется очередная порция двоичного кода, а затем начинают работать еще два потока, T1 и T2. И вот здесь происходят интересные вещи.
После выполнения методов, которые уже были JIT-скомпилированы, T1 и T2 вызовут IL-метод M3, еще ни разу не вызывавшийся, и поэтому он должен быть JIT-скомпилирован. На внутреннем уровне JIT-компилятор поддерживает список всех JIT-компилируемых методов. По одному списку поддерживается для каждого AppDomain и еще один для общего кода. Этот список защищается блокировкой, равно как и каждый его элемент, чтобы несколько потоков одновременно могло безопасно участвовать в JIT-компиляции. В данном случае один поток (скажем, T1) будет JIT-компилировать метод и тратить время, выполняя работу, которая никак не относится к приложению, а T2 не будет делать ничего, ожидая освобождения блокировки, — ему просто нечего делать, пока не станет доступным двоичный код M3. В то же время T0 будет компилировать M2. Когда поток заканчивает JIT-компиляцию метода, он заменяет адрес JIT IL-заглушки адресом двоичного кода, освобождает блокировки и выполняет метод. Заметьте, что T2 в конечном счете проснется и просто выполнит M3.
Остальной код, выполняемый этими потоками, показан светло-серыми полосками на рис. 1. Это означает, что приложение выполняется с полной скоростью. Даже когда начинает выполняться новый поток, T3, все методы, которые ему нужно исполнить, уже были JIT-скомпилированы, а значит, он тоже работает с полной скоростью. Конечная производительность становится очень близкой к таковой для неуправляемого кода.
Грубо говоря, длительность каждого из черных сегментов зависит в основном от времени, затрачиваемого на JIT-компиляцию метода, которое в свою очередь зависит от того, насколько большой и сложный этот метод. Так что это время может варьироваться от нескольких миллисекунд до десятков миллисекунд (исключая время загрузки любых требуемых сборок или модулей). Если запуск приложения требует первого выполнения менее сотни методов, это не проблема. Но если впервые приходится выполнять сотни или тысячи методов, суммарное влияние всех черных сегментов может оказаться весьма значительным, особенно когда время JIT-компиляции метода сравнимо со временем его выполнения; это приведет к замедлению скорости работы приложения на десятки процентов. Например, если приложению нужно выполнять при запуске тысячи разных методов со средним временем JIT-компиляции в 3 мс, то на запуск уйдет три секунды. Это уже существенно. И это плохо для вашего бизнеса, потому что ваши клиенты будут разочарованы.
Заметьте: существует вероятность того, что один и тот же метод будет одновременно JIT-компилироваться более чем одним потоком. Также возможно, что первая попытка JIT-компиляции провалится, а вторая окажется успешной. Наконец, не исключено, что метод, который уже был JIT-скомпилирован, подвергнется повторной JIT-компиляции. Однако все эти случаи выходят за рамки данной статьи, и вам незачем знать о них при использовании фоновой JIT.
Фоновая JIT-компиляция
Издержки JIT-компиляции, рассмотренные в предыдущем разделе, нельзя ни предотвратить, ни существенно уменьшить. Вы должны JIT-компилировать IL-методы, чтобы выполнить их. Но вы можете изменить время, в которое возникают эти издержки. Важно понимать, что вместо ожидания первого вызова IL-метода для его JIT-компиляции можно JIT-компилировать этот метод заранее, чтобы к моменту его вызова двоичный код уже был сгенерирован. Если вы сделаете это правильно, все потоки, показанные на рис. 1, будут светло-серыми и будут выполняться с полной скоростью так, будто вы запускаете неуправляемый образ, созданный NGEN, или даже еще быстрее. Но прежде потребуется решить две проблемы.
Первая проблема заключается в следующем. Если вы намерены JIT-компилировать метод до того, как он понадобится, в каком потоке вы будете это делать? Нетрудно увидеть, что лучший способ решить эту проблему — создать выделенный поток, выполняемый в фоне и JIT-компилирующий методы, которые скорее всего должны выполняться как можно быстрее. Как следствие это возможно только при доступности минимум двух ядер (как почти всегда и бывает), чтобы издержки JIT-компиляции скрывались за счет перекрытия с выполнением кода приложения.
Вторая проблема: как узнать, какой метод нужно JIT-компилировать следующим до его первого вызова? Учтите, что в каждом методе, как правило, есть вызовы методов по условию, поэтому нельзя просто JIT-скомпилировать все методы, которые могут быть вызваны, или слишком увлекаться упреждающим выбором методов, подлежащих JIT-компиляции следующими. Это весьма вероятно приведет к тому, что поток фоновой JIT очень быстро отстанет от потоков приложения. И вот здесь в игру вступают профили. Сначала вы осуществляете тренировочный запуск приложения и любых распространенных сценариев применения, протоколируя, какие методы JIT-компилировались и в каком порядке, причем делаете это отдельно для каждого сценария. Затем вы можете опубликовать приложение вместе с записанными профилями, чтобы при его выполнении на компьютере пользователя минимизировать издержки JIT-компиляции повремени на настенных часах (именно так пользователи воспринимают время и производительность). Эта функциональность называется фоновой JIT, и вы можете использовать ее с минимумом усилий со своей стороны.
Если приложению нужно выполнять при запуске тысячи разных методов со среднем временем JIT-компиляции в 3 мс, то на запуск уйдет три секунды. Это уже существенно.
В предыдущем разделе вы видели, как JIT-компилятор может JIT-компилировать разные методы параллельно в разных потоках. Поэтому с технической точки зрения, даже традиционная JIT уже использует несколько ядер. К сожалению, в документации MSDN эта функциональность называется многоядерной JIT (multicore JIT), исходя из требования к наличию минимум двух ядер, а не основываясь на ее определяющей характеристике. Я использую название «фоновая JIT», потому что это правильный термин. В PerfView встроена поддержка этой функциональности и используется именно такое название — фоновая JIT. Заметьте, что «многоядерная JIT» — термин, применявшийся в Microsoft на ранних этапах разработки. В остальной части этого раздела я рассмотрю все, что необходимо сделать, чтобы задействовать этот метод в коде, и то, как он изменяет традиционную модель JIT. Я также покажу, как использовать PerfView для замера выигрыша от фоновой JIT, когда вы будете применять ее в собственных приложениях.
Для использования фоновой JIT нужно сообщить исполняющей среде, куда следует поместить профили (по одному для каждого сценария применения, который инициирует большой объем JIT-компиляции). Кроме того, необходимо проинформировать исполняющую среду о том, какой профиль следует использовать, чтобы она считала нужный профиль и определила, какие методы требуется компилировать в фоновом потоке. Это, конечно, требуется проделать до запуска соответствующего сценария применения.
Чтобы указать, куда помещать профили, вызовите метод System.Runtime.ProfileOptimization.SetProfileRoot, определенный в mscorlib.dll. Этот метод выглядит так:
public static void SetProfileRoot(string directoryPath);
Предназначение единственного параметра, directoryPath, — задание каталога, откуда будут считываться все профили или куда они будут записываться. Эффект дает лишь первый вызов данного метода в том же самом AppDomain, а все последующие вызовы игнорируются (однако тот же путь может использоваться разными AppDomain). Кроме того, если в процессоре компьютера нет хотя бы двух ядер, любой вызов SetProfileRoot игнорируется. Единственное, что делает этот метод, — сохраняет указанный каталог во внутренней переменной, чтобы потом использовать его всякий раз, когда это понадобится. Данный метод обычно вызывается исполняемым файлом (EXE) процесса при инициализации. Общие библиотеки не должны вызывать его. Вы можете вызвать этот метод в любой момент, пока выполняется приложение, но до любого вызова метода ProfileOptimization.StartProfile, который выглядит следующим образом:
public static void StartProfile(string profile);
Когда приложение собирается пройти путь исполнения, производительность которого вы хотели бы оптимизировать (например, запуск), вызовите этот метод и передайте ему имя и расширение файла профиля. Если такого файла нет, профиль записывается и сохраняется в файле с указанным именем в папке, заданной при вызове SetProfileRoot. Этот процесс называется записью профиля (profile recording). Если указанный файл существует и содержит допустимый профиль фоновой JIT, то фоновая JIT происходит в выделенном фоновом потоке и компилирует методы, выбираемые согласно содержимому профиля. Этот процесс называется воспроизведением профиля (profile playing). При воспроизведении профиля поведение, выказываемое приложением, по-прежнему будет записываться, а исходный профиль будет заменен.
Воспроизвести профиль без записи нельзя; в настоящее время это просто не поддерживается. Вы можете вызывать StartProfile несколько раз, задавая разные профили, подходящие для разных путей исполнения. Этот метод не действует, если он был вызван до инициализации корня профилей с помощью SetProfileRoot. Кроме того, оба метода не действуют, если переданный аргумент недопустим. По сути, эти методы не генерируют никаких исключений и не возвращают никаких кодов ошибок, чтобы не повлиять негативным образом на поведение приложений. Оба они безопасны в многопоточной среде, как и любой другой статический метод в инфраструктуре.
Для использования фоновой JIT нужно сообщить исполняющей среде, куда следует поместить профили (по одному для каждого сценария применения, который инициирует большой объем JIT-компиляции). Кроме того, необходимо проинформировать исполняющую среду о том, какой профиль следует использовать, чтобы она считала нужный профиль и определила, какие методы требуется компилировать в фоновом потоке.
Например, если вы хотите ускорить запуск, вызывайте эти два метода в самом начале функции main. Если вы хотите увеличить производительность в конкретном сценарии применения, вызовите, когда ожидается, что пользователь инициирует этот сценарий, а SetProfileRoot вызывайте в любой момент до StartProfile. Помните, что все происходит локально в каждом AppDomain.
Вот и все, что нужно для использования фоновой JIT в вашем коде. Это настолько просто, что вы можете опробовать ее, даже не особо задумываясь о том, будет ли она полезна вам. Потом вы сможете замерить выигрыш в быстродействии и решить, стоит ли применять ее. Если выигрыш составит хотя бы 15%, использовать фоновую JIT стоит. В ином случае выбор за вами. Теперь я подробно объясню, как она работает.
При каждом вызове StartProfile запускаются следующие операции в контексте AppDomain, где в настоящее время выполняется код.
- Все содержимое файла профиля (если он есть) копируется в память. Затем файл закрывается.
- Если это не первый вызов StartProfile и она уже успешно вызывалась, фоновый поток JIT-компиляции уже должен работать. В этом случае данный поток завершается и создается новый фоновый поток. После этого поток, вызвавший StartProfile, возвращает управление вызвавшему его коду.
- Этот этап происходит в фоновом потоке JIT. Профиль разбирается. Записанные методы JIT-компилируются в том порядке, в котором они записывались, — последовательно и предельно быстро. Этот этап замещает процесс воспроизведения профиля.
Вот и все, что касается фонового потока. Если он закончил JIT-компиляцию всех записанных методов, то просто автоматически завершится. Если что-то пойдет не так при разборе или JIT-компиляции методов, поток тоже «молча» завершится. Если какая-то сборка или модуль, который не загружен и требуется для JIT-компиляции метода, он так и не будет загружен, а метод останется не скомпилированным. Фоновая JIT спроектирована так, чтобы по возможности не изменять поведение программы. При загрузке какого-либо модуля выполняется его конструктор. Кроме того, когда модуль не удается найти, запускаются обратные вызовы, зарегистрированные на событие System.Reflection.Assembly.ModuleResolve. Поэтому, если фоновый поток загрузит какой-то модуль до того момента, когда он должен был бы загружаться, поведение этих функций может измениться. То же самое относится к обратным вызовам, зарегистрированным на событие System.AppDomain.AssemblyLoad. Поскольку фоновая JIT не загружает необходимые ей модули, не исключено, что она не сможет скомпилировать многие из записанных методов, что даст в итоге весьма скромный выигрыш.
Возможно, вас интересует, а почему бы не создать более одного фонового потока для JIT-компиляции большего количества методов? Что ж, во-первых, эти потоки сильно нагружают процессор и поэтому могут конкурировать с потоками приложения. Во-вторых, чем больше этих потоков, тем выше уровень конкуренции при синхронизации потоков. В-третьих, не исключено, что методы будут JIT-скомпилированы, но никогда не будет вызваны ни одним из потоков приложения. И напротив, может быть впервые вызван какой-то метод, который даже не записан в профиле, или же вызван до того, как будет JIT-скомпилирован одним из фоновых потоков. Из-за этих проблем наличие более одного фонового потока может оказаться не очень полезным. Однако группа CLR, возможно, придумает что-то в будущем (особенно в тех случаях, когда можно ослабить ограничение на загрузку модулей). А теперь пора обсудить, что происходит в потоках приложения, включая процесс записи профиля.
На рис. 2 показан тот же пример, что и на рис. 1, но с включенной фоновой JIT. То есть имеется фоновый поток, который JIT-компилирует методы M0, M1, M3 и M2 — именно в таком порядке. Заметьте, что этот фоновый поток устраивает гонки с потоками приложения T0, T1, T2 и T3. Фоновый поток должен JIT-компилировать каждый метод до его первого вызова любым потоком приложения. В следующем обсуждении предполагается, что это верно для M0, M1 и M3, а для M2 — не совсем.
Рис. 2. Пример, демонстрирующий оптимизацию с помощью фоновой JIT в сравнении с рис. 1
Timeline | Временная шкала |
T0 | T0 |
T1 | T1 |
T2 | T2 |
T3 | T3 |
Background JIT Thread | Фоновый поток JIT-компиляции |
M0 | M0 |
M1 | M1 |
M2 | M2 |
M3 | M3 |
Когда T0 собирается вызвать M0, фоновый поток уже JIT-скомпилировал его. Однако адрес метода пока не записан и содержимое соответствующего участка памяти указывает на JIT IL-заглушку. Фоновый поток мог бы обновить адрес, но не делает этого, чтобы определить впоследствии, вызывался ли данный метод. Эта информация используется группой CLR для оценки фоновой JIT. Поэтому происходит вызов JIT IL-заглушки, которая видит, что метод уже скомпилирован в фоновом потоке. Единственное, что она делает, — обновляет адрес и запускает метод. Заметьте, что издержки JIT-компиляции полностью исключаются из этого потока. M1 обрабатывается точно так же, когда вызывается из T0. Это же относится к M3, когда он вызывается в T1. Но, когда T2 вызывает M3 (см. рис. 1), адрес метода был очень быстро обновлен потоком T1, и поэтому он напрямую вызывает реальный двоичный код метода. Затем T0 вызывает M2. Однако фоновый поток JIT еще не закончил JIT-компиляцию этого метода, а значит, T0 ожидает на JIT-блокировке метода. По окончании JIT-компиляции метода, T0 пробуждается и вызывает его.
Я еще не рассказал, как методы записываются в профиль. Также вполне вероятно, что поток приложения вызовет какой-нибудь метод, к JIT-компиляции которого фоновый поток даже не приступал (или вообще не будет JIT-компилировать его, потому что он отсутствует в профиле). Я собрал операции, выполняемые в потоке приложения, когда тот вызывает статический или динамический IL-метод, JIT-компиляция которого еще не осуществлялась, в следующий алгоритм.
- Захватывается блокировка JIT-списка для того AppDomain, где находится метод.
- Если двоичный код уже сгенерирован каким-то другим потоком приложения, эта блокировка освобождается и происходит переход в п. 13.
- В список добавляется новый элемент, представляющий рабочий поток JIT для метода, если его еще нет. Если он существует, увеличивается его счетчик ссылок.
- Освобождается блокировка JIT-списка.
- Захватывается блокировка JIT для метода.
- Если двоичный код уже сгенерирован каким-то другим потоком приложения, происходит переход в п. 11.
- Если метод не поддерживается фоновой JIT, этот этап пропускается. В настоящее время фоновая JIT поддерживает только статически генерируемые IL-методы, определенные в сборках, которые не были загружены System.Reflection.Assembly.Load. Далее, если метод поддерживается, проверяется, не был ли он уже JIT-скомпилирован фоновым потоком. Если скомпилирован, метод записывается в профиль и происходит переход в п. 9. Иначе алгоритм переходит к следующему этапу.
- JIT-компиляция метода. JIT-компилятор анализирует IL-код метода, определяет все необходимые типы, удостоверяется, что загружены все требуемые сборки и что созданы все нужные объекты типов. Если что-то идет не так, генерируется исключение. Этот этап создает самую большую часть издержек.
- Заменяется адрес JIT IL-заглушки на адрес собственно двоичного кода метода.
- Если метод был JIT-скомпилирован потоком приложения, а не фоновым потоком JIT, присутствует активное средство записи фоновой JIT и метод поддерживается фоновой JIT; этот метод записывается в профиль в памяти. В профиле соблюдается порядок, в котором методы JIT-компилировались. Заметьте, что сгенерированный двоичный код не записывается.
- Освобождается JIT-блокировка метода.
- Используя блокировку списка, безопасно уменьшается счетчик ссылок метода. Если он обнуляется, соответствующий элемент удаляется.
- Выполняется код метода.
Процесс записи при фоновой JIT завершается, когда возникает любая из следующих ситуаций:
- AppDomain, связанный с диспетчером фоновой JIT, выгружается по какой-либо причине;
- StartProfile снова вызывается в том же AppDomain;
- частота JIT-компиляции методов в потоках приложения становится очень малой. Это указывает на то, что приложение достигло стабильного состояния, где JIT-компиляция требуется редко. Любые методы, JIT-компилируемые после этого момента, не входят в сферу действия фоновой JIT;
- достигнут один из лимитов записи. Максимальное число модулей равно 512, максимальное количество методов — 16 384, а самая большая длительность непрерывной записи — одна минута.
Когда процесс записи завершается, записанный в память профиль копируется в указанный файл. Тем самым при следующем запуске приложение подхватит этот профиль, отражающий поведение, которое было проявлено приложением при его прошлом выполнении. Как уже упоминалось, профили всегда перезаписываются. Если вы хотите сохранить текущий профиль, то должны вручную скопировать его до вызова StartProfile. Размер профиля обычно не превышает нескольких десятков килобайт.
Прежде чем завершить этот раздел, я хотел бы поговорить о выборе корней профилей. В случае клиентских приложений вы можете указать либо каталог, специфичный для пользователя, либо каталог, относящийся к приложению, — в зависимости от того, нужны вам разные наборы профилей для разных пользователей или только один набор профилей для всех пользователей. В случае приложений ASP.NET и Silverlight вы, по-видимому, будете использовать каталог, относящийся к приложению. По сути, начиная с ASP.NET 4.5 и Silverlight 4.5, фоновая JIT работает по умолчанию, и профили хранятся с приложением. Исполняющая среда будет вести себя так, будто вы вызвали SetProfileRoot и StartProfile в методе main, поэтому вам не придется что-либо делать, чтобы задействовать эту функциональность. Но вы по-прежнему можете вызывать StartProfile, как было описано ранее. Автоматическую фоновую JIT можно отключить, установив флаг profileGuidedOptimizations в None в файле веб-конфигурации, как описано в «An Easy Solution for Improving App Launch Performance» (bit.ly/1ZnIj9y). Еще одно значение, которое может принимать этот флаг, — All; оно включает фоновую JIT (действует по умолчанию).
Фоновая JIT-компиляция в действии
Фоновая JIT является провайдером Event Tracing for Windows (ETW). То есть она сообщает о ряде событий, относящихся к этой функциональности, потребителям ETW, таким как Windows Performance Recorder и PerfView. Эти события позволяют диагностировать любую неэффективность или сбои, происходящие при фоновой JIT-компиляции. В частности, можно определить, сколько методов было скомпилировано в фоновом потоке и каково общее время JIT-компиляции этих методов. Вы можете скачать PerfView по ссылке bit.ly/1PpJUpv (установка не требуется, просто распакуйте архив и запустите исполняемый файл). Для демонстрации я буду использовать следующий простой код:
class Program {
const int OneSecond = 1000;
static void PrintHelloWorld() {
Console.WriteLine("Hello, World!");
}
static void Main() {
ProfileOptimization.SetProfileRoot(
@"C:\Users\Hadi\Desktop");
ProfileOptimization.StartProfile("HelloWorld Profile");
Thread.Sleep(OneSecond);
PrintHelloWorld();
}
}
Теперь, когда вы сгенерировали профиль для фоновой JIT-компиляции, используйте PerfView для запуска и профилирования своего исполняемого файла. |
В функции main вызываются SetProfileRoot и StartProfile для настройки фоновой JIT. Поток отправляется спать в течение примерно одной секунды, а затем вызывается метод PrintHelloWorld. Это метод лишь вызывает Console.WriteLine и возвращает управление. Скомпилируйте этот код в файл исполняемого IL-кода. Заметьте, что Console.WriteLine не требует JIT-компиляции, поскольку он уже скомпилирован с помощью NGEN при установке .NET Framework в вашу систему.
Используйте PerfView для запуска и профилирования исполняемого файла (подробнее о том, как это делается, см. в «Improving Your App’s Performance with PerfView» по ссылке bit.ly/1nabIYC или в Channel 9 PerfView Tutorial по ссылке bit.ly/23fwp6r). Не забудьте установить флажок Background JIT (требуется только в .NET Framework 4.5 и 4.5.1), чтобы включить захват событий от этой функциональности. Подождите, пока PerfView не закончит свою работу, а затем откройте страницу JITStats (рис. 3); PerfView сообщит вам, что процесс не использует фоновую JIT-компиляцию. Дело в том, что при первом запуске должен быть сгенерирован профиль.
Рис. 3. Местонахождение JITStats в PerfView
Теперь, когда вы сгенерировали профиль для фоновой JIT-компиляции, используйте PerfView для запуска и профилирования своего исполняемого файла. Однако на этот раз, когда вы откроете страницу JITStats, вы увидите, что один метод, PrintHelloWorld, был скомпилирован в фоновом потоке JIT-компиляции, а еще один, Main, — не был. Она также сообщает вам, что около 92% времени JIT-компиляции, потраченной на компиляцию всех IL-методов, происходило в потоках приложения. Отчет PerfView покажет список всех методов, которые были JIT-скомпилированы, размеры каждого метода в IL- и двоичном коде, какой поток выполнял JIT-компиляцию метода, и другую информацию. Кроме того, вы можете легко обращаться к полному набору информации о событиях фоновой JIT. Однако из-за нехватки места в статье я не стану вдаваться в подробности.
Вероятно, вас интересует смысл «усыпления» на одну секунду. Это необходимо для того, чтобы добиться JIT-компиляции PrintHelloWorld в фоновом потоке. Иначе компиляция метода скорее всего началась бы в потоке приложения. Другими словами, вы должны вызывать StartProfile достаточно рано, чтобы фоновый поток успевал опережать потоки приложения.
Заключение
Фоновая JIT является оптимизацией на основе профилей, поддерживаемой в .NET Framework 4.5 и выше. В этой статье было рассмотрено почти все, что вам нужно знать об этой функциональности. Я подробно объяснил, зачем нужна эта оптимизация, как она работает и как правильно использовать ее в вашем коде. Применяйте эту функциональность, когда работа с NGEN неудобна или невозможна. Поскольку фоновая JIT настолько проста в использовании, вы можете опробовать ее, даже не особо задумываясь о том, будет ли она полезна вам. Потом вы сможете замерить выигрыш в быстродействии и решить, стоит ли применять ее. В ином случае вы можете легко удалить ее. Microsoft использовала фоновую JIT для ускорения запуска некоторых из своих приложений. Надеюсь, что вам тоже удастся эффективно задействовать эту компиляцию в своих приложениях, чтобы добиться значительного ускорения запуска приложения и повышения производительности в сценариях применения с интенсивным использованием JIT-компиляции.
Хейди Брейс (Hadi Brais) работает над докторской диссертацией в Индийском технологическом институте Дели (Indian Institute of Technology Delhi, IITD), исследует оптимизации компилятора для технологий памяти следующего поколения. Большую часть времени проводит в написании кода на C/C++/C# и глубоко копается в исполняющих средах, инфраструктурах и архитектурах компиляторов. Ведет блог hadibrais.wordpress.com. С ним можно связаться по адресу hadi.b@live.com.
Выражаю благодарность за рецензирование статьи эксперту Microsoft Вэнсу Моррисону (Vance Morrison).