Разработка и реализация основ компилятора с# с помощью Microsoft Phoenix

УДК 004.415

М.Ю. Ефимов

РАЗРАБОТКА И РЕАЛИЗАцИЯ ОСНОВ КОМПИЛЯТОРА С# С ПОМОЩЬЮ MICROSOFT PHOENIX

Компиляцию кода .NET-совместимых языков, к которым относится C#, можно разделить на два этапа. Каждый этап представляет собой отдельный компилятор. Первый этап компиляции, который инициирует программист в процессе разработки программы, преобразует код на исходном языке в код на специальном платформо-независимом языке MSIL (Microsoft Intermediate Language). Будем называть его MSIL-компилятор. Второй этап компиляции, - JIT (Just In Time)-компилятор, - выполняет виртуальная машина .NET непосредственно в момент выполнения программы, преобразуя код, полученный на первом этапе, в набор машинных инструкций.

На данный момент наиболее известны следующие компиляторы языка С#: Microsoft Visual C# и Mono.

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

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

Хотя фаза оптимизации в MSIL-компиляторе компании Microsoft недостаточно документирована, тем не менее, опытным путем удалось установить, что этот компилятор выполняет на первом этапе лишь наиболее простые оптимизации - свертку констант и устранение «мертвого» кода, предоставляя всю основную работу по оптимизации программы JIT-компилятору.

В документации Mono указано, что на этапе MSIL-компиляции, аналогично компилятору Microsoft Visual C#, выполняется свертка констант и устранение «мертвого» кода.

Цель данной работы - разработка агрессивно оптимизирующего MSIL-компилятора языка C#, т. е. компилятора, выполняющего максимально возможный объем платформо-независимых оптимизаций на первом этапе компиляции. Такой компилятор может привести к более эффективному выполнению целевой программы по нескольким причинам. Во-первых, итоговый PE-файл может иметь меньший объем, что позволяет быстрее прочитать его с диска. Во-вторых, JIT-компиляция может выполняться быстрее вследствие отсутствия необходимости выполнения некоторых оптимизаций.

Для создания компилятора был использован инструмент Microsoft Phoenix [1], являющийся новейшим средством для разработки компиляторов и приложений для анализа, оптимизации и тестирования программ. Эта система позволила существенно упростить создание компилятора, путем разработки полноценного внутреннего представления кода программы и автоматизации фазы генерации кода.

Фазы компилятора. Концептуально компилятор работает пофазно, причем в процессе каждой фазы происходит преобразование исходной программы из одного представления в другое. Основные фазы компилятора показаны на рис. 1.

Лексический и синтаксический анализ. Основные задачи лексического и синтаксического анализа:

распознавание входного потока символов; построение абстрактного синтаксического дерева;

обнаружение лексических и синтаксических ошибок.

Для упрощения создания лексического и синтаксического анализаторов автором статьи был использован генератор синтаксических анализаторов ANTLR [2]. Важная отличительная черта ANTLR - возможность генерации кода на .NET-совместимых языках, в частности, на языке С#. Эта возможность позволила сократить количество промежуточных состояний компилятора

Рис. 1. Фазы работы компилятора

и полностью реализовать компилятор на языке C#. Кроме того, ANTLR генерирует модуль LL-разбора входной грамматики, что позволяет легко отлаживать сгенерированный код.

После выявления ошибок ANTLR использует встроенный алгоритм восстановления. При возникновении ошибки этот алгоритм пытается добавить один пропущенный символ (лексему) из входного потока или удалить один лишний символ. Если такое преобразование позволяет «исправить» входной поток, разбор продолжается. Иначе входные символы пропускаются до следующего «синхронизирующего» символа.

Семантический анализ. Семантический анализ осуществляется над абстрактным синтаксическим деревом, полученным от ANTLR. На этом этапе каждый элемент (узел) дерева дополняется различными атрибутами, описывающими семантику языка. Для этого производится несколько обходов дерева, каждый из которых вычисляет определенный набор атрибутов.

В качестве примера такого обхода можно привести вычисление типов. Оно выполняется для всех узлов дерева, являющихся выражениями. Для каждого такого узла выводится тип выражения на основе типов его аргументов, в соответствии с семантическими правилами языка. Если выведенный тип выражения не совпадает с ожидаемым типом, производится вставка нового узла дерева, представляющего собой неявное преобразование (cast) выведенного типа в ожидаемый. Если такое преобразование невозможно, пользователю выдается семантическая ошибка.

Примером такого неявного преобразования может служить специфичная для C# пара преобразований boxing/unboxing. Это преобразование позволяет использовать типы, передающиеся по значению (например, структуры или примитивные типы) в контексте, где используются ссылочные типы (например, классы).

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

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

Microsoft Phoenix в качестве промежуточного кода использует Phoenix Intermediate Representation (IR), который представляет собой ключевое понятие Microsoft Phoenix и является фундаментальным представлением семантики программы. Phoenix IR предоставляет набор фабрик классов для создания полноценного универсального внутреннего представления абстрактного синтаксического дерева.

Phoenix IR является многоуровневым. Существует четыре уровня абстракции:

HIR (High-level IR) - представление высшего уровня;

MIR (Mid-level IR) - представление среднего уровня;

LIR (Low-level IR) - представление нижнего уровня;

EIR (Encoded IR) - представление уровня машинного кода.

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

Процесс работы Microsoft Phoenix состоит из нескольких этапов, называемых «фазами» (Phases). Каждая фаза выполняет определенный набор

действий над внутренним представлением дерева. Среди различных фаз существуют фазы для изменения уровня внутреннего представления.

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

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

В рамках данной статьи автор реализовал оптимизации, улучшающие производительность программы по обоим критериям, поскольку вы-

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

Главное правило оптимизации заключается в том, что она не должна изменять поведение программы. То есть оптимизация должна быть «безопасной». Если, например, в цикле выполняется инвариантное присваивание x=y/z, можно было бы вынести его из цикла и выполнить его, таким образом, только один раз. Однако такая оптимизация может изменить поведение программы, т. к., если z = 0, а цикл ни разу не выполнится, оптимизированный вариант программы сгенерирует исключение, в то время как неоптимизиро-ванный вариант отработает нормально.

Основная часть оптимизаций выполняется на промежуточном внутреннем представлении MIR. Каждая оптимизация представляет собой отдельную «фазу» работы Microsoft Phoenix.

Формально оптимизации можно разделить на два типа: процедурные и межпроцедурные. Единицей процедурных оптимизаций является процедура, в то время как межпроцедурные оптимизации рассматривают всю программу в целом.

Для осуществления процедурных оптимизаций необходима информация о потоке управления программы и манипуляциях, проводимых над данными. Для удобного представления этой информации используется граф потоков (Flow Graph). Граф потоков разделяет код процедуры на блоки. Каждый блок представляет собой прямолинейный участок кода, который можно выполнить только последовательно с первой до последней команды, входящих в него. То есть блок не имеет внутри условных переходов и циклов. Ребро из блока A в блок B существует тогда и только тогда, когда существует набор входных параметров процедуры, при котором после последней инструкции блока A выполнится первая инструкция блока B. Таким образом, граф потоков представляет собой ориентированный граф, в котором узлами являются блоки, а ребрами - возможные переходы между блоками.

Пример графа потоков представлен на рис. 2.

Ниже кратко опишем применяемые оптимизации.

public int f(int x) {

int a = 2; int b = 2; int с = 0;

if (x > 5) {

c = a + b;

}

else {

c = a  b;

}

return c;

}

entry

1

а = 2; с = 6 = 2; = 0;

c = a + b;

c = a • b;

return c;

Рис. 2. Пример графа потоков для процедуры

Свертка констант (Constant Folding) - самая простая оптимизация. Во время компиляции она вычисляет выражения, аргументы которых являются константами. Таким образом, например, выражение 2*2 заменяется на константу 4. Эта оптимизация является вспомогательной и выполняется каждый раз при необходимости.

Оптимизация алгебраических упрощений (Algebraic Simplification) использует различные алгебраические правила и свойства для уменьшения количества операций в выражении. Например, выражение а + 0 заменяется на а. Эта оптимизация, аналогично предыдущей, - вспомогательная, вызывается каждый раз при необходимости.

Перечисление значений (Value Numbering) позволяет определить, что два вычисления являются эквивалентными, и удалить одно из них. В результате применения этой оптимизации пара инструкций а = x+1; b = x+1 заменится на а = x+1; b = а.

Распространение копий (Copy Propagation) представляет собой трансформацию, которая при наличии инструкции x = у заменяет дальнейшие использования x на у. Эта оптимизация выполняется в два этапа. Сначала для каждого блока графа потоков выполняется локальная оптимизация, затем выполняется глобальная оптимизация для всей процедуры.

Распространение констант (Constant Propagation) при наличии инструкции x = c, где x - переменная, а c - константа, заменяет все ис-

пользования x на c, пока переменной x не будет присвоено новое значение.

Сокращение избыточности (Partial-Redundancy Elimination) представляет собой оптимизацию, которая вставляет и удаляет выражения таким образом, чтобы каждый путь в графе потоков имел наименьшее возможное количество одинаковых выражений. Частными случаями этой оптимизации являются выделение общих подвыражений (Common-Subexpression Elimination) и вынос констант за пределы цикла (Loop-Invariant Code Motion). Эта оптимизация может несколько увеличить размер кода программы, однако обычно это увеличение несущественно.

Устранение недостижимого кода (Unreachable-Code Elimination) удаляет код, который никогда не выполняется.

Упрощение условных переходов (If Simplifications) удаляет условные переходы, зависящие от констант, а также упрощает условные переходы, не имеющие инструкций в одной из своих веток.

Упрощение циклов (Loop Simplifications) удаляет пустые циклы, если вычисление его условий не имеет сторонних действий.

Устранение «мертвого» кода (Dead-Code Elimination) удаляет все переменные и инструкции, не влияющие на результат работы процедуры или какие-либо внешние объекты.

Вставка процедур (Procedure Integration) позволяет заменить вызов функции ее непосред-

entry

1

a = 2; с = b = 2; 0;

entry

r

return 4;

с = 4;

с = 4;

return с;

Рис. 3. Применение оптимизаций

ственным кодом. На данный момент реализована вставка процедур, имеющих небольшое количество инструкций, сравнимое с количеством входных аргументов, поскольку интеграция таких процедур не увеличит размер кода программы.

На рис. 3 показано последовательное применение оптимизаций к графу потоков.

Сначала выполняется распространение констант, которое заменит использование переменных а и Ь на константу 2, после которого вызовется вспомогательная оптимизация свертки констант. При этом граф потоков примет вид, указанный на рис. 3, слева. Затем опять отработает распространение констант: удалит инструкции из ветвей условного перехода. После этого отработает оптимизация по упрощению условных переходов: удалит его, т. к. обе его ветки пустые. Далее выполнится устранение «мертвого» кода: удалит инструкции в верхнем блоке, после чего граф потоков примет вид, указанный на рис. 3, справа. Теперь везде, где будет использоваться функция f, будет применена оптимизация вставки процедур, которая заменит ее использование на константу 4. Данный пример демонстрирует, что результат выполнения конкретной оптимизации существенно зависит от результатов выполнения других оптимизаций. Таким образом, результирующий код программы зависит не только от набора проведенных оптимизаций, но и от порядка их выполнения.

В итоге, код для функции f, полученный на выходе разработанного компилятора, состоит из двух строк, в то время как код этой же функции на выходе компилятора Microsoft Visual C# состоит из 16 строк.

Генерация кода. Генерация кода представляет собой заключительную фазу работы Microsoft Phoenix. При этом, на основе внутреннего представления создается PE-файл, частью которого является MSIL-код программы.

В представленной работе реализована основная часть оптимизаций, увеличивающих скорость выполнения программы, но не увеличивающих размер кода программы. Выполнение этих оптимизаций на стадии MSIL- компилятора дает некоторый прирост производительности программы в сравнении с компилятором Microsoft Visual C#. В дальнейшем планируется реализация оптимизаций, увеличивающих размер кода, и исследование влияния таких оптимизаций на скорость работы JIT-компилятора и программы в целом.

Кроме того, на данный момент поддержан не весь синтаксис языка C#. В текущей версии программы не поддерживаются делегаты и обобщения (шаблоны). В качестве возможного варианта развития проекта также рассматривается создание GUI-оболочки для компилятора, либо интеграция компилятора в Microsoft Visual Studio.

СПИСОК ЛИТЕРАТУРЫ

1. Microsoft Phoenix [Электронный ресурс] http:// connect.microsoft.com/Phoenix

2. ANTLR Parser Generator [Электронный ресурс] http://www.antlr.org

3. Aho, A.V. Compilers: Principles, Techniques & Tools [Текст]/Л.УЛ^, M.S.Lam, R.Sethi, J.D.Ullman; 2ed.-Addison-Wesley, 2007.

4. Muchnick, Steven S. Advanced Compiler Design & Implementation ^KCTySteven S. Muchnick.-Morgan Kaufmann Publishers, 1997

5. ECMA International [Электронный ресурс] http:// www.ecma-international.org/

УДК 004.415.5

Н.В. Воинов, В.П. Котляров

ПРИМЕНЕНИЕ МЕТОДА ЭВРИСТИК ДЛЯ СОЗДАНИЯ ОПТИМАЛЬНОГО НАБОРА ТЕСТОВЫХ СцЕНАРИЕВ

Стоимость исправления ошибок на фазах жизненного цикла создания программного продукта экспоненциально растет [1]. Фаза разработки требований и спецификаций наиболее эффективна для идентификации и исправления ошибок [2]. Следовательно, предотвращение ошибок в требованиях и обнаружение их на ранних этапах проекта сильно уменьшает объем корректировок продукта и, таким образом, сокращает общую стоимость разработки ПО.

Решением может служить подход тестирования на основе модели (model based testing). В голове разработчика и тестировщика всегда присутствует та или иная «модель» устройства программы, а также «модель» ее желаемого поведения, исходя из которой, в частности, составляются списки проверяемых свойств и создаются соответствующие тестовые примеры.

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

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

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

Базовые протоколы. Базовый протокол является формальной записью утверждения о некоторых событиях, которые должны произойти в про-