Оптимизирующий Just-In-Time компилятор для академической версии. Net(sscli)

С. А. Чилингарова

ОПТИМИЗИРУЮЩИЙ JUST-IN-TIME КОМПИЛЯТОР ДЛЯ АКАДЕМИЧЕСКОЙ ВЕРСИИ .NET

1. Введение. Оптимизирующий Just-In-Time (JIT) компилятор является важной составной частью современных виртуальных машин, таких как Java или .NET. Использование оптимизаций на этапе преобразования байт-кода (или CIL-кода) в машинный код при правильной организации подсистемы JIT-компиляции повышает производительность в несколько раз, по сравнению с виртуальной машиной, применяющей только интерпретатор или простой неоптимизирующий компилятор [1, 2]. Внедрение оптимизирующих JIT-компиляторов, первые из которых были созданы в конце 1990-х годов, в существующие Java (и затем CLI) виртуальные машины, во многом отвечает за конкурентоспособность концепции виртуальной машины, исполняющей платформенно-независимый промежуточный код, в мире современных информационных технологий.

Задача внедрения оптимизаций в динамический компилятор (ЛТ-компилятор) шире, чем задача добавления оптимизирующих преобразований и оценки их влияния на генерируемый код. Эффективность оптимизаций, производимых динамическим компилятором, работающим во время выполнения (с точки зрения производительности всей системы), существенно зависит также от скорости выполнения самих оптимизирующих преобразований и правильного выбора методов для оптимизации (принцип 20/80). Таким образом, задача построения оптимизирующего JIT-компилятора является по сути задачей построения системы, включающей средства для выделения наиболее часто исполняемых методов (профайлер), средства для хранения и обработки этой информации, управляющий центр, принимающий решение о компиляции данного метода с тем или иным уровнем оптимизации, и один или несколько оптимизирующих компиляторов, различающихся количеством и сложностью производимых оптимизиций.

В настоящей статье описаны архитектура и реализация подсистемы оптимизирующей динамической компиляции для платформы SSCLI (Rotor) 2.0, некоммерческого аналога .NET, выпущенной Microsoft для научно-исследовательских целей. Оригинальная версия SSCLI реализует только базовые свойства виртуальной машины и не включает оптимизирующий JIT-компилятор. Рассмотрены как архитектурное решение самой системы оптимизирующей динамической компиляции, так и вопросы интеграции с существующими элементами SSCLI.

2. Архитектура и принципы работы подсистемы оптимизирующей JIT-компиляции для Rotor. В основе архитектурного решения лежит принцип выборочной многоуровневой компиляции, успешно использовавшийся также в других работах [1-3]. Так как оптимизирующие преобразования замедляют работу компилятора, компиляция всех методов с полной оптимизацией для JIT-компилятора неэффективна [1]. Оптимизация может дать выигрыш, если она применяется только к наиболее часто выполняющимся методам. За сбор информации о таких методах отвечает профайлер, либо внедряющий в код постоянно работающий инструментарий [1], либо собирающий статистические данные [1, 4]. Первый вариант обычно применяется только для интерпретируемого или неоптимизированного кода.

© С. А. Чилингарова, 2008

CIL

(Common

Intermediate

Language)

Машинный

код

Рис.. 1. Архитектура подсистемы оптимизирующей ЛТ-компиляции для SSCLI 2.0.

Части виртуальной машины, присутствующие! в поставляемом релизе SSCLI 2.0, окрашены серым.

Основные части подсистемы оптимизирующей динамической компиляции для Rotor [5] и их взаимодействие изображены на рис. 1. Базовый однопроходовый компилятор (FJIT) является частью поставляемой версии виртуальной машины, в которой он применяется для компиляции всех методов при их первом вызове. FJIT последовательно переводит каждую инструкцию CIL непосредственно в машинный код, не производит никаких оптимизаций и пе использует промежуточное представление. В пашей системе базовый компилятор дополнен двухуровневым оптимизирующим компилятором. Методы для оптимизирующей компиляции выбираются контроллером на основании данных профилирования.

Данные о частоте вызовов методов и структуре вызовов собираются выборочным (sampling) профайлером [6], который, взаимодействуя с управляемыми потоками SSCLI, делает периодические выборки состояния стека, анализирует их и сохраняет полученную информацию в структурированном виде в хранилище данных профилирования. Для получения выборки состояния стека используется уже существующий в оригинальной версии SSCLI механизм просмотра стека, применяемый для демонстрации состояния стека при отладке и разворачивании стека в случае исключений.

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

исполняемый код в метаданных метода и выставляет уровень компиляции в данных профилирования .

3. Профилирование и хранение профиля выполнения программы. В качестве средства профилирования был избран выборочный (sampling) профайлер [6], как инструмент, позволяющий получить статистически точные данные о частоте и контексте вызовов методов и вместе с тем являющийся достаточно «легковесным», чтобы не перегружать программу. Профайлер собирает и сохраняет данные как о количестве вызовов каждого метода, который попал в выборку, для выбора методов-кандидатов на оптимизирующую компиляцию первого уровня, так и о контексте вызова методов (какие методы какими методами вызываются), нужные для определения методов-кандидатов на де-виртуализацию и inline-подстановку на 2-м уровне оптимизации. Профайлер сохраняет собранные данные в виде компактной структуры, названной нами Call Context Мар (СС-Мар), которая позволяет быстро получить наиболее полные данные как о возможных (согласно данным профилирования) контекстах вызова конкретного метода, так и о методах, вызываемых из него [6].

Фрагмент структуры СС-Мар в схематичном виде приведен на рис. 2. Стрелками обозначены ссылки одного узла на другой. Узлы типа MethodProfile хранят абсолютный счетчик вызовов данного метода, ссылки на узлы, описывающие вызовы других методов из данного метода, и информацию о контексте вызовов данного метода. Сама структура СС-Мар представляет собой хеш-таблицу, хранящую ссылки на узлы типа MethodProfile, где в качестве ключа используется код описания данного метода в метаданных.

Узлы типа Callee содержат счетчик вызовов одного метода другим в данной конкретной точке кода. Узел типа Callee ссылается на узел типа MethodProfile, относящийся к вызываемому методу. Перейдя по этой ссылке, можно получить информацию о том, в каком еще контексте вызывался данный метод. Узлы типа CallSite содержат информацию о точке вызова, их основная функция - предоставить информацию о том, как много различных функций (методов разных классов-потомков данного базового класса) вызывалось в данной точке в случае вызова виртуального метода, на основании которой контроллер может принять решение о де-виртуализации или inline-подстановке. От каждого узла типа Callee строится дерево узлов типа CallSiteRef и CalleeRef. Эти узлы содержат счетчики вызовов методов в каждом конкретном контексте и дают информацию о количестве разных методов, вызванных в этой точке в данном конкретном контексте. Например, на рис. 2 самое левое дерево описывает последовательность вызовов Лн>Вн>( '—И). а среднее - последовательность вызовов Вн>( '—И). Если узел типа Callee для метода С в среднем дереве содержит информацию обо всех обнаруженных вызовах метода С из метода В, то узел типа CalleeRef в левом дереве - только о вызовах метода С из метода В, когда сам метод В был вызван из метода А. Такая структура позволяет легко выделить наиболее часто выполняющуюся последовательность методов любой длины и вместе с тем не содержит избыточных данных (как например, было бы в случае хранения каждого счетчика для каждой обнаруженной цепочки вызовов). Подробное сравнение структуры СС-Мар с другими способами хранения информации о контексте приведено в работе [6].

Сброс счетчиков выполнения после рекомпиляции, упомянутый в [5], больше не производится. От этой схемы решено было отказаться, чтобы не искажать данные об относительной частоте вызовов различных методов в одном контексте. Вместо этого предполагается реализовать периодический сброс всех счетчиков на одну и ту же величину, что позволит своевременно избавляться от устаревших данных и решить проблему превышения порога редко вызываемыми методами в достаточно долго выполняющейся программе, не искажая общей картины.

Рис. 2. Фрагмент структуры Call Context Map.

Локальный буфер

Локальная очередь

Рис. 3. Строение и принцип работы профайлера.

Архитектура и схема работы профайлера представлена на рис. 3. Снимки стека для управляемого потока SSCLI делаются самим потоком. Поток-маркировщик профайлера периодически помечает все «живые» управляемые потоки, после чего каждый поток делает снимок своего стека, когда достигает так называемой «безопасной точки» (safe point). Safe point - оригинальное понятие Rotor, где «безопасные точки» используются, например, для остановки потоков на время сборки мусора. Оно определяется как точка, в которой поток может приостановить свою работу на какое-то время и передать управление некоторой системной функции. При достижении такой точки поток проверяет, не требуют ли какие-нибудь системные службы его остановки и не нужно ли произвести какое-либо системное действие (что идентифицируется установкой соответствующего флага состояния у потока, как объекта). Наша система профилирования интегрирована в данный механизм. Если флаг профилирования установлен, поток делает снимок своего стека, используя для этого существующую в Rotor функцию просмотра стека, и сохраняет его в своем локальном буфере. Информация в буфер записывается в «сыром» виде: просто как последовательность ссылок на метаданные обнаруженных методов и смещений в коде вызывающих методов. После этого копия буфера ставится в локальную очередь, поток освобождается и может продолжать свою работу. Преобразование «сырых» выборок в удобную для просмотра структуру СС-Мар производится затем профайлером в отдельном потоке.

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

4. Оптимизации 1-го уровня. Выбор оптимизаций продиктован соотношением предполагаемой «выгоды» от конкретного варианта оптимизации (ускорение, уменьшение размера кода) и его сложности, с точки зрения, в первую очередь времени выполнения. Время является наиболее дефицитным ресурсом для JIT-компилятора виртуальной машины, запущенной на сервере или обычном рабочем компьютере. Слишком сложные, хотя и эффективные, оптимизации, примененные к значительному числу методов, могут привести к тому, что прирост общей производительности будет небольшим или даже нулевым, так как оптимизации, производимые JIT-компилятором во время выполнения, будут сами по себе отнимать много процессорного времени. Таким образом, оптимизации, производимые JIT-компилятором, особенно на 1-м уровне, где может обрабатываться относительно много методов (по принципу 20/80 — около 20% всех методов, которые выполняются 80% времени), должны быть прежде всего простыми.

Другие известные характерные свойства виртуальных машин и их промежуточного кода - несоответствие между стековой архитектурой виртуальной машины и стандартной регистровой архитектурой современных физических машин, а также присутствие большого числа маленьких тривиальных методов (таких как getter и setter) - определяют две наиболее простые и «прибыльные» оптимизации 1-го уровня. Это устранение избыточного копирования, которое является следствием отображения стековой архитектуры на регистровую, и inline-подстановки тривиальных методов. Другой особенностью управляемого кода являются проверки на null, границы массивов и пр. Многие из этих проверок можно исключить за один проход методом распространения констант.

Рис. 4• Дерево зависимостей переменных внутри цикла.

LInv (loop invariant) - величина, инвариантная внутри цикла. IV (induction variable) - переменная индукции, RC (reduction candidate) - выражение-кандидат на преобразование типа а + кх => Ь + у, где х и у - переменные индукции, к, а, и Ь - инварианты внутри цикла.

Вопрос о включении более сложных оптимизаций был решен на основе анализа экспериментальных данных. В результате анализа и обобщения практических данных об эффективности различных видов оптимизаций на разных уровнях [1, 2, 7, 8], а также с учетом соображений, приведенных выше, для 1-го уровня оптимизирующей компиляции были выбраны:

1) оптимизации, производимые «на лету», во время преобразования CIL в промежуточное представление компилятора: распространение и свертка констант, исключение избыточного копирования, избыточных проверок на тип, null и границы массива в рамках одного базового блока;

2) распространение и свертка констант, исключения избыточного копирования и избыточных проверок в рамках всей процедуры с использованием метода нумерации величин (value numbering) [9];

3) быстрая оптимизация циклов - вынесение инвариантов за рамки цикла;

4) inline-подстановка тривиальных методов (методов, код которых меньше длины последовательности вызова), для которых известен класс объекта.

Раньше оптимизации циклов, как правило, относили на более высокие уровни, рассматривая их как дорогостоящие [2, 7]. Вместе с тем простые оптимизации, такие как вынос инвариантов и некоторых вычислений за границы цикла, могут быть очень эффективными в случае большого числа итераций. В данной работе применяется алгоритм быстрой оптимизации цикла, позволяющий выделить и вынести за границы цикла инварианты и инвариантные вычисления за один проход с использованием дополнительной структуры данных - дерева зависимостей переменных внутри цикла [5]. Дерево зависимостей изображено в схематичном виде на рис. 4. На входе в цикл все величины считаются инвариантными. Если в процессе просмотра инструкций цикла встречается присвоение, которое делает некую величину не инвариантной (и также не позволяет считать ее переменной индукции - IV на рис. 4), соответствующий узел и все поддерево зависимых от него величин удаляются. Если это присвоение вида а а ± к, где к — инвариант, величина квалифицируется, как переменная индукции, и дерево с учетом этого перестраивается. На выходе из цикла только обнаруженные инварианты считаются «живыми», что позволяет провести все преобразования в рамках value numbering за один проход, если циклы предварительно идентифицированы.

Для идентификации циклов используется механизм поиска путей от узла-цели ветви перехода назад (backward branch) к ее исходящему узлу [10]. В отличие от [10],

поиск производится только, если исходящий узел (назовем его В) не является пост-доминатором узла-цели ветви перехода (обозначим его А). В этом случае все пути от узла А естественным образом считаются принадлежащими циклу до достижения узла В. Если строить дерево пост-доминирования без учета переходов по исключениям, большое число циклов в реальных программах попадает в эту категорию (такое дерево можно строить одновременно с деревом пост-доминирования с учетом всех переходов) . Если переходы по исключениям являются точками выхода из цикла, пути из них исследуются стандартным способом (см. [10]). Относительно редкий в реальных программах (но не невозможный) случай перехода назад в точку, которая уже находится внутри некоторого цикла, обрабатывается следующим образом: назовем узлом С исходящий узел ветви перехода, узлом А - вход в цикл, узлом В - исходящий узел ветви перехода назад в цикле; тогда данная структура рассматривается оптимизатором как один цикл от А до С. Только те величины и выражения, которые являются инвариантами во всех блоках, входящих в возможные пути от А к С, будут считаться инвариантами «живыми» на выходе из С. Такое упрощение может, теоретически, несколько снижать возможности оптимизации для описанного сложного случая, но так как он скорее исключение, чем правило, им можно пренебречь.

Алгоритм нумерации величин подобен описанному в [9], за исключением того, что проход «снизу вверх» (upward lookup) не производится, так как важнейшую его функцию — вынесение инвариантов за пределы цикла — выполняет алгоритм оптимизации циклов. Так же, как ив [9], переменные и выражения группируются по их значению и выбирается переменная-«лидер», которая подставляется на место любого выражения из группы.

5. Оптимизации 2-го уровня. Основное отличие 2-го (планируемого) уровня оптимизации - в стратегии де-виртуализации и inline-подстановок. Если на первом уровне inline-подстановки делаются только для тривиальных методов, то на втором планируется использовать все возможности, предоставляемые структурой СС-Мар, для планирования оптимизаций на уровне нескольких методов на основании данных профилирования. Такого рода оптимизации показывают себя эффективными на высоких уровнях оптимизации [2, 7].

Summary

Chilingarova S. A. Optimizing Just-In-Time compiler for academic version of .NET (SSCLI).

The design and implementation of the optimizing Just-In-Time compilation subsystem for the open .NET-like platform SSCLI 2.0 are described. The subsystem includes a profiler, a controller, and a 2-level optimizing compiler. The architecture and interaction of the subsystem parts are shown, optimizations choice is proved.

Литература

1. Suganuma Т., Yasue Т., Kawahito M. et al. A dynamic optimization framework for a Java Just-In-Time compiler // ACM Conference on Object-Oriented Programming Systems, Languages, and Applications (OOPSLA). October 2001. P. 180-194.

2. Grove D., Hind M. The Design and Implementation of the Jikes RVM Optimizing Compiler // OOPSLA ’02 Tutorial. 2002, November 5. 118 p.

3. The Java HotSpot™Virtual Machine, v. 1.4.1, d. 2: A Technical White Paper. Sun Microsystems. September 2002. 28 p.

4. Arnold M. Online Profiling and Feedback-Directed Optimization of Java: PhD thesis, Rutgers University. October 2002. 132 p.

5. Chilingarova S. Optimizing JIT-compilation subsystem for Rotor 2.0. // Companion to the 21th Annual ACM SIGPLAN Conference OOPSLA’2006. October 22-26 2006. P. 754-755.

6. Chilingarova S., Safonov V. Sampling profiler for Rotor as part of optimizing Compilation System // .NET Technologies 2006 - Short papers conference proceedings. 2006. P. 43-50.

7. Suganuma Т., Ogasawara Т., Kawachiya K. et al. Evolution of a Java just-in-time compiler for IA-32 platforms // IBM Journal of Research and Development. 2004. Vol. 48, N 5/6 (September/November). P. 767-795.

8. Ali-Reza Adl-Tabatabai, Jay Bharadwaj, Dong-Yuan Chen et al. The Star JIT Dynamic Compiler - A Performance Study on the Itanium Architecture // 2nd Workshop on Managed Runtime Environments. 2004. 27 p.

9. Van Drunen Т., Hosking A. Value-based partial redundancy elimination // Intern. Conference on Compiler Construction, European Joint Conferences on Theory and Practice of Software. Barcelona, Spain, June 2004. P. 167-184.

10. Silberman P. Loop Detection // Uninformed. May 2005. Vol. 1. 17 p. (www.

uninformed.org)

Статья рекомендована к печати проф. Л. А. Петросяном.

Статья принята к печати 4 декабря 2007 г.