УДК 004.051
Д. Д. Томашев, H.H. Ефанов
Московский физико-технический институт (национальный исследовательский университет)
Верификатор промежуточного представления компилятора OpenArkCompiler
Рассматривается задача верификации промежуточного представления оптимизирующего компилятора. Несмотря на обилие разработанных решений в сфере статической верификации, было решено создать собственное средство верификации, которое отвечает условиям легкой расширяемости и удобства для разработчика компилятора. Для этих целей был создан язык, на котором предполагается описывать условия корректности генерируемых инструкций. Чтобы автоматизировать процесс верификации, был реализован транслятор языка на основе контекстно-свободной грамматики и алгоритма перевода выражений в обратную польскую запись. Полученный верификатор был протестирован на прежуточном представлении Мар1еШ компилятора ОрепАгкСотрПег и добавлен в него как обязательная часть компиляции.
Ключевые слова: статическая верификация, компиляторы, промежуточные представления, контекстно-свободные грамматики, инструменты разработчика
D. D. Tomashev, N. N. Efanov Moscow Institute of Physics and Technology
OpenArkCompiler intermediate representation verifier
We consider the problem of checking the intermediate representation of compilers. Despite the abundance of already developed solutions in the field of static verification, it is decided to create a verification tool that meets the conditions of an easy expansion of abilities and convenience for a compiler developer. For these purposes, a language is created in which the description of conditions implies the correctness of developed instructions. To automate the verification process a translator is also implemented for the language based on a context-free grammar and an algorithm of reverse polish notation. The resulting verifier is tested by intermediate representation MapleIR, which is part of OpenArk compiler and added to repository as a mandatory part of the compilation.
Key words: static verification, compilers, intermediate representation, context-free grammars, toolchain
© Томашев Д. Д., Ефанов H.H., 2023
(с) Федеральное государственное автономное образовательное учреждение высшего образования
«Московский физико-технический инстритут (национальный исследовательский университет)», 2023
1. Введение
Создание компилятора языка программирования - весьма трудоемкая задача. Количество программ, которые могут быть написаны на языке общего назначения, бесконечно, а программист, ведущий на нём разработку, может допустить ошибку в любом месте программы. Современный компилятор должен уметь не только корректно транслировать программы в бинарный код, но и выдавать информативные сообщения об ошибках, указывающие, что именно и в каком месте программы разработчик сделал неправильно. Кроме того, компилятор должен поддерживать оптимизации кода, при необходимости уменьшающие время его исполнения и итоговый размер бинарного файла, а также сохранять информацию о программе в специальных файлах и структурах, позволяющих выполнять отладку программы.
Как и любое программное обеспечение, компилятор может содержать ошибки в своей реализации. Следовательно, возникает проблема обнаружения таких ошибок и их локализации в коде. Существуют два способа решения этой проблемы: тестирование и верификация. Тестирование, в смысле проверки работы на избранном наборе программ с известными входными данными и результатами работы, позволяет понять, насколько правильно части компилятора работают в совокупности, а также продемонстрировать его функциональность на конкретных примерах. Однако такой способ проверки не позволяет локализировать конкретные ошибки, происходящие при комплилировании: простейшая программа, состоящая всего из нескольких строк, проходит ряд этапов компиляции: лексический анализ, синтаксический анализ, семантический анализ, генерацию промежуточного кода, оптимизацию и генерацию бинарного кода [1]. На основе тестов не представляется возможным понять, на каком именно этапе произошел сбой. Существует и другое средство проверки корретности программы: верификация, которая разделяется на формальную и неформальную [2]. Формальная верификация использует математический аппарат для формулирования условий корректности программы и для математически строгой проверки программы на соответствие спецификации. Для компилятора формальная верификация представляется излишне трудоемкой задачей ввиду сложности самого компилятора и проблемы представления спецификации, написанной на естественном языке в математических терминах.
В силу указанных недостатков существующих подходов, было решено разработать собственное средство неформальной верификации, которое, с одной стороны, позволяет разработчику на простом языке описывать требования, которым должен удовлетворять компилятор, и в то же время позволяет покрыть все условия, описывающие корректность его работы в смысле генерации отдельных программных конструкций промежуточного представления. Для этих целей были созданы контекстно-свободный язык верификационных проверок и транслятор к нему, который во время компиляции тестовой программы решает, является ли вывод компилятора корректным.
Верификатор создается для верификации компилятора OpenArkCompiler [3], компилятора с открытым исходным кодом, который оптимизирован под обработку программ, написанных для использования в мультимедийных системах. На момент написания статьи в компиляторе используется фронтенд компилятора clang [4], что, считая синтаксическую структуру AST-дерева clang основой для дальнейных преобразований программ, позволяет сузить задачу верификации до миддленда и бекенда. Метод, который предлагается в данной работе, реализован для миддленда, однако может быть расширен и на бекенд. Верификатор создан для промежуточного представления MapleIR, перевод программы в которое является частью процесса компиляции в OpenArkCompiler - опен-сорс компилятора, ориентированного на обработку программ, используемых в мультимедийных системах и средствах беспроводной связи.
2. Предлагаемый способ верификации
Особенностью уномянутохх) MapleIR является ei'o мши'оуровневоеть. Такой тин IR позволяет сохранить больше семантики программы в метаданных и сложных структурах, таких как циклы, структуры, сложные структуры передачи управления. Одним из примеров последних является инструкция rangegoto. Ее фунционал повторяет конструкцию switch-case в языке С. В процессе обработки MapleIR сложные инструкции выражаются через простые для упрощения перевода промежуточно!^ представления в бинарный код. Такой подход называется понижение(англ. lowering) и используется, например, в компиляторе языка D [5].
2.1. Проблема формулирования условий корректности
Обратимся к снецикации MapleIR [3], чтобы определить, как сформировать условия, отражающие корректность промежуточно!^ кода. Рассмотрим инструкцию cvt. На сайте репозитория указано:
syntax: cvt <to—typo <from—typo ( opinio )
Convert the operand value from cfrom—typo to <to— type>.
This instruction must not be used If the sizes of the two types are
the same and the conversion does not result in altering the bit
contents
В случае инструкции cvt верификация означает ее проверку на соответствие спецификации. Рассмотрим еще одну инструкцию, add:
syntax: add cprim—tvpe> ( opinio . <opndl>) Perform the addition of the two operands
В данном случае спецификация не накладывает никаких о!'раничений на инструкцию. Однако ее корректное поведение можно выразить несколькими условиями. Во-первых, результат инструкции должен быть представим типом prim-tvpe. Во-вторых, мы можем запретить операции при некоторых значениях полей, например, если opndO и opndl разных типов. Таким образом, верификатор должен не только проверять спецификацию, но и поддерживать введение широких) ряда условий на инструкцию.
2.2. Структуры компилятора, содержащие семантику инструкций
Для понимания доступной на этапе оптимизационных проходов информации об операциях в IR обратимся к классам компилятора, отвечающим за промежуточное представление. Рассмотрим их на примере упомянутых выше инструкций. Для инструкции cvt иерархия наследования имеет следующий вид.
Рис. 1. Иерархия для класса инструкции cvt
Рис. 2. Иерархия для класса инструкции add
Как видно, для инструкций ограничения, которые можно на них наложить, сводятся к всевозможным условиям на соответствующие поля типа int или double, поскольку указатели на операнды в классах BvnarvOpnds и UnarvNode в случае примитивных типов также содержат только поля типа int или double (или их вариаций с разной битовой длиной).
2.3. Язык верификационных проверок
Суммируя вышесказанное, переформулируем задачу следующим образом: реализовать средство верификации, которое позволяло бы налагать на атрибуты инструкций и их операндов ограничения любых видов, выраженные в арифметических операциях и операциях сравнения. Поскольку разрабатываемое средство верификации должно предосталять разработчику полную свободу в определении условий корректности промежуточного кода, было принято решение создать простой язык ограничений, в терминах которого программист описывает, что и как нужно проверять.
Здесь и далее под программой, за исключением некоторых случаев, оговоренных отдельно, следует понимать программу, описывающую верификационные проверки.
Соответствующую программу предлагается писать в отдельном файле. Синтаксическая структура файла будет иметь вид
<instruction_ 1 >: <cond_l_l>; <cond_l_2>; ...; <cond_l_{N_l}>; <instruction_2 >: <cond_2_l>; <cond_2_2>; ...; <cond_2_{N_2}>;
<instruction_M >: <cond_{M_l} >; <cond_{M_2}>; ...; <cond_M_{N_n}>;
Рассмотрим подробнее, что может включать в себя <cond>. Для того чтобы обеспечить полноту наложения условий на различные атрибуты операндов и инструкций, необходимо разработать средство обращения к ним. В нашем языке указания на поля производятся по следующему правилу:
<operand— number >. attribute—name
Так, $1.value означает доступ к полю value первого аргумента инструкции. Возможно также и обращение к некоторым полям операции через $0, однако не все поля могут быть известны на момент проверки. Примером такого поля служит value, для нахождения которого нужно исполнить инструкцию. В общем случае для решения этой проблемы достаточно написать программный код для каждой операции промежуточного представления, эмулирующий ее работу, однако это выходит за рамки настоящей работы.
Кроме обращения к атрибутам операндов и операций, в тексте программы поддерживаются типовые и численные константы.
2.4. Построение транслятора языка
Введение языка предполагает написание для него лексера и парсера, которые переводят текст программы в некоторое внутреннее представление, удобное для анализа и вычисления. Теория реализации языков программирования [1,6,7] имеет стандартные алгоритмы для реализации этих компонент транслятора. Лексер построен на основе регулярных выражений [8]. Каждую лексему можно описать детерминированным конечным автоматом
(ДКА), соответствующим регулярному выражению. После этого все полученные конечные автоматы объединяются, для них строятся состояния и таблица переходов. В процессе лек-синга ДКА меняет свое состояние по каждому входному символу либо выдает ошибку, если по нему нельзя перейти ни в какое следующее состояние.
Парсер получает на вход последовательность лексем и преобразовывает ее в обратную польскую запись (ОПЗ) [9]. Такой подход позволяет учесть приоритет арифметических операций и операций сложения, а также значительно упрощает разработку, поскольку ОПЗ является давно изученным подходом и в открытых источниках существует множество примеров ее реализации.
Сразу после парсинга невозможно приступить к вычислению выражений в ОПЗ, поскольку на этом этапе отсутствует информация о значениях атрибутов, участвующих в записанных в программе правилах. Чтобы получить информацию о них, описанную трансляцию языка предлагается провести в специальном оптимизационном проходе, называемом верификационным. Это не только дает доступ к атрибутам инструкции во время ее компиляции, но и предоставляет возможность проверять IR после каждого другого прохода, таким образом упрощая поиск ошибок в них. В каждом классе, реализующем оптимизацию, содержится ссылка на текущий компилируемый модуль программы (не верификационной программы, а той, над которой работает компилятор), который является экземпляром класса MIRModule. Это аналог класса Module в LLVM [10]. Он содержит в себе объекты классов TvpeCvtNode, BinarvNode и других классов операций, поля которых и используются при верификации.
С помощью объекта класса MIRModule совершается проход по всем инструкциям, пока не встречается одна из тех, что подлежат верификации. После этого начинается вычисление условий корректности, записанных в ОПЗ. Это происходит с помощью стековой машины, хранящей в себе лексемы. Если в процессе вычисления встречена лексема с типом lexem_t::arg, то происходит обращение к указателю на текущую верифицируемую инструкцию и заполняются нужные для проверки атрибуты. Ошибки накапливаются в логгер и по завершении прохода могут быть выведены на стандартный выход.
Описанный подход обладает набором преимуществ. Во-первых, формулирование условий верификации происходит простым, интуитивным образом. С разработчика, таким образом, снимается необходимость разбираться в коде компилятора и переписывать его каждый раз, когда он хочет изменить параметры корректности инструкций. Во-вторых, семантика всех инструкций содержится в полях примитивных типов. Придуманное решение, таким образом, обеспечивает возможность описания всех возможных условий верификации.
3. Практическая реализация алгоритма 3.1. Внедрение метода в OpenArkCompiler
Для выполнения поставленных задач проведено исследование возможностей OpenArkCompiler в части анализа и модифицирования MapleIR, а также изучены возможности механизма оптимизационных проходов. Каждый оптимизационный проход представляет собой класс, который содержит функцию, которая подготавливает окружение прохода и запускает его. Кроме того, каждый такой класс хранит в себе ссылку на класс MIRModule, который содержит в себе семантическую информацию о текущем компилируемом модуле.
Описанное устройство OpenArkCompiler позволяет написать верификационный проход. Для поддержания полиморфизма была разработана система классов, описывающих лексемы. Их иерархия приведена на рис. 3. Использованные для обозначения типа лексем классы перечисления приведены на рис. 4.
Все лексемы хранятся в виде указателей на объекты класса ConditionLexem. При этом на самом деле объект, на который ссылается указатель, может быть объектом любого приведенного на схеме класса. Чтобы получать доступ ко всем полям соответствующего класса,
BraketLexem
braket: braket_t
ConditionLexem
type: lexem_t
ValueLexem
value: std::variant<uint64_t, int64_t, double> attr: arg_value_attr
ArgLexem
id: size_t type: TypeLexem value: ValueLexem
Рис. 3. Иерархия классов лексем
в реализации описанного ранее метода используется механизм динамического приведения. Все, что нужно хранить в ConditionLexem - это информация о том, к какому классу приводить указатель на него. Она находится в поле type.
en inn class brake! ^t {I. J SR. RJ5R. I.J'CR. RJ'GR) :
enum class tvpe^t {none, u8, ul6, u32, u64, i8 , i16 , i32 , i64 , £32
£64, £128};
enum class operation^t {less , greater , lesseq , greatereq , equal ,
notequal , plus, minus, mult, div , and^op, or_op , not^op};
enum class lexem^t {value, type, braket , operation, arg};
enum class arg^attr {none, value, type};
enum class arg_value_attr {none, value};
enum class arg_tvpe_attr {none, type, min, max};
Рис. 4. Типы лексем
Большинство лексем различимы по первому символу а остальные различимы по двум символам (например, ! и !=, <= и <), поэтому лексер реализован следующим образом: по первым символам определяет, какую лексему он считывает, и проверяет, что последующие символы ей соответствуют. Так, лексер проходится по всем входным символам и на выходе
выдает хэш таблицу, ключом которой является название инструкции, а значением - вектор из лексем.
Следующий шаг алгоритма - переписать все инструкции в таблице в обратную польскую запись. Для этого используется алгоритм из [9] с небольшими дополнениями, а именно, лексемы ValueLexem, TvpeLexem и ArgLexem рассматриваются как обычные числа.
После описанных шагов из MapleIR представления выделяются те инструкции, которые участвуют в верификации и начинается вычисление верификационных выражаний. Используется преимущество обратной польской записи: операнды в ней стоят в порядке вычисления выражения на стековой машине. Производится обход по формуле в ОПЗ и последоватьльно исполняются операции над операндами, заранее помещенные в стек.
Однако, чтобы вычислить значение выражения, необходимы значения операндов инструкций, участвующих в условии верификации (напр. $1.type). Для этого при формировании операндов операции над элементами стека используется объект класса MIRModule, так как через него доступны значения операндов инструкции. Правила построения MapleIR таковы, что каждая инструкция может содержать в качестве операндов другие инструкции, которые тоже могут содержать инструкции как операнды, и т.д. Поэтому не представляется корректным взятие напрямую значений из аргументов инструкции, перед этим нужно вычислить все инструкции над этим аргументом.
Приведенное соображение демонстрирует пример на рис. 5.
muí i64 (
cvt i 6 4 i 3 2 (dread i32 %i„46_3 ) , constval i64 4)) ,
Рис. 5. Пример вложенных инструкций
Перед взятием первого аргумента muí необходимо вычислить инструкцию cvt. Поэтому производится рекурсивный обход вглубь, пока не будет обнаружено константное значение. Затем каждая вышестоящая инструкция исполняется, и значение операнда передается для вычисления верификационной проверки.
Все ошибки заносятся в логгер и по завершении верификационного прохода выводятся на стандарный выход как предупреждения.
3.2. Применение верификатора
В настоящее время в OpenArkCompiler ведется работа над поддержкой типа long double, который занимает в компиляторе 128 бит, поэтому верификатор тестировался на наборе тестов из репозитория gcc [11], нацеленных на тип long double, а именно:
inf— l.c, inf— 2. с, inf— З.с, fp—cmp — З.с, 20011123.с, рг36332.с, fp—cmp—81. с, fp—cmp—41. с, copvsign2.c, рг29302 —l.c
Верификатор запускался сразу после фронтенда. На трех тестах, а именно copysign2.c, inf — 2.с, inf — З.с, верификатор выдал предупреждение:
"VERIFIER WARNING: instruction cvt at line <line_number> doesn't pass verifier checks"
Остальные тесты были верифицированы без ошибок. При анализе MapleIR представления указанных программ было обнаружено, что инструкция cvt на указанной строке имеет вид:
cvt f 128 f 128 (neg f 128 (constval f 64 nan)), что противоречит спецификации MapleIR: типы в cvt должны быть разной битовой длины. В ходе исправления бага было обнаружено, что фронтенд, обрабатывая константное значение пап, присваивал ему тип £64 вместо £128, поскольку не были реализованы функции
__builtin^nanl и__builtin_huge_vall, использующиеся в этих тестах. Указанный баг был
исправлен, изменения влиты в основную ветку OpenArkCompiler.
4. Заключение и дальнейшие пути развития
В работе рассмотрена проблема верификации промежуточного представления MapleIR, выявлены его особенности, отличия от LLVM IR и связанные с этим сложности реализации компилятора. Проведен обзор методов статической верификации и разработан метод верификации, дающий широкие возможности по переопределению условий верификации для любой инструкции. Рассмотрены его преимущества и недостатки, а также определена область его применения. Предложен и реализован алгоритм на языке С++, который был внедрен в существующий процесс компиляции. Это стало возможным благодаря поддержке в OpenArkCompiler механизма добавления в компилятор оптимизационных проходов. Созданный верификатор решил поставленные в работе задачи, что подтверждается рядом тестов.
Верификатор имеет множество направлений для улучшения. На данном этапе разработки поддерживаются всего пять инструкций, чего недостаточно для оценки корректности генерируемого MapleIR. Необходимо вести работу по расширению списка верифицируемых инструкций. Потенциально разработанное средство проверки корректности способно включить все операции в MapleIR. Еще одна область совершенствования верификатора -это добавление более семантически сложных конструкций, например, условных проверок через ключевое слово if, которое позволяло бы переходить к вычислению условия верификации только при определенных ограничениях на аттрибуты. Следующее направление развития - это расширение возможностей языка проверок через увеличение количества атрибутов у аргументов и расширение поддерживаемых типов. Кроме всего перечисленного, в верификаторе отсутствует проверка корректности входных арифметических выражений. Реализация парсера, который бы анализировал верификационную программу на предмет ошибок также является возможным улучшением приведенного решения.
Список литературы
1. Mogensen Т.Е. Introduction to compiler design. Springer, 2017.
2. Камкип A.C. Введение в формальные методы верификации программ. Москва : МАКС Пресс, 2018.
3. OpenArkCompiler gitee // Online, 2022.
gitee.com/openarkcompiler/OpenArkCompiler/blob/master
4. Официальная страница clang // Online, 2022. clang.llvm.org
5. Bright W., Alexandrescu A., Parker M. Origins of the D programming language // Proceedings of the ACM on Programming Languages. 2020. V. 4, N HOPL. P. 1-38.
6. Ахо А., Ульман Д. Теория синтаксического анализа, перевода и компиляции. Т. 1, 2. Москва : Мир, 1978.
7. Gruñe D. [et al.}. Modern compiler design. Springer Science k, Business Media, 2012.
8. Рубцов A.A. Заметки и задачи о регулярных языках и конечных автоматах // Online, 2018. http://www.rubtsov.su/public/books/zz-a5-online.pdf
9. Hamblin C.L. Translation to and from Polish Notation // The Computer Journal. 1962. V. 5, N 3. P. 210-213.
10. Официальная страница LLVM // Online, 2022. llvm.org
11. GCC тесты для floatl28 // Online, 2022. https://github.com/gcc-mirror/gcc/tree/ 16e2427f50c208dfe07d07f18009969502c25dc8/gcc/testsuite/gcc.c-torture/ execute/ieee
References
1. Mogensen Т.Е. Introduction to compiler design. Springer, 2017.
2. A.S. Kamkin Introduction to formal methods of program verification. Moscow : MAKS Press, 2018.
3. OpenArkCompiler gitee. Online, 2022.
gitee.com/openarkcompiler/OpenArkCompiler/blob/master
4. Clang official page // Online, 2022. clang.llvm.org
5. Bright W., Alexandrescu A., Parker M. Origins of the D programming language. Proceedings of the ACM on Programming Languages. 2020. V. 4, N HOPL. P. 1-38.
6. Aho A. V., Ullman J. D. The theory of parsing, translation, and compiling. V. 1, 2. Moscow : Mir, 1978.
7. Gruñe D., et al, Modern compiler design. Springer Science k, Business Media, 2012.
8. Rubtsov A.A. Notes and tasks about regular languages and state machines. Online, 2018. www.rubtsov.su/public/books/zz-a5-online.pdf
9. Hamblin C.L. Translation to and from Polish Notation. The Computer Journal. 1962. V. 5, N 3. P. 210-213.
10. LLVM official page // Online, 2022. llvm.org
11. GCC тесты для floatl28. Online, 2022. https://github.com/gcc-mirror/gcc/tree/ 16e2427f50c208dfe07d07f18009969502c25dc8/gcc/testsuite/gcc.c-torture/ execute/ieee
Поступим в редакцию 14-12.2022