Генерация промежуточного кода и основы оптимизации
Мы прошли долгий путь. Наш компилятор уже умеет читать текст (лексер), понимать структуру предложений (парсер) и даже проверять смысл написанного (семантический анализатор). На выходе предыдущего этапа мы получили проверенное, аннотированное типами Абстрактное Синтаксическое Дерево (AST).
Казалось бы, можно сразу переводить AST в машинный код (нули и единицы) для процессора. Но в профессиональной разработке компиляторов так делают редко. Вместо этого вводится еще один слой абстракции — Промежуточное представление (Intermediate Representation, IR).
В этой статье мы разберем, зачем нужен этот «язык-посредник», как превратить дерево в линейный код и как сделать программу быстрее еще до того, как она коснется реального железа.
Зачем нужен промежуточный код?
Представьте, что вы хотите создать переводчик, который знает 5 исходных языков (английский, французский, немецкий, испанский, русский) и умеет переводить на 5 целевых языков (китайский, японский, корейский, хинди, арабский).
Если писать прямой переводчик для каждой пары «язык-язык», вам придется написать различных программ. Это называется проблемой , где — количество исходных языков (frontend), а — количество целевых архитектур (backend).
Решение — использовать промежуточный язык (например, эсперанто или упрощенный английский). Тогда вам нужно написать всего 5 переводчиков в промежуточный язык и 5 переводчиков из него. Итого программ.
В компиляторах роль такого «эсперанто» играет IR. Это позволяет:
Модульность: Можно подключить новый язык (например, Rust) к существующему бэкенду (например, LLVM), просто написав генератор IR.
Оптимизация: Можно написать алгоритмы оптимизации один раз для IR, и они будут работать для любого языка, который компилируется в этот IR.!Иллюстрация того, как промежуточное представление связывает множество языков с множеством архитектур процессоров
Виды промежуточных представлений
IR бывает разным, в зависимости от целей компилятора:
* Древовидное (Tree-based): Очень похоже на AST, но упрощенное. Удобно для высокоуровневых оптимизаций.
* Стековое (Stack-based): Код выглядит как инструкции для стековой машины. Пример: Java Bytecode или .NET CIL.
* Линейное (Linear): Похоже на ассемблер, но с бесконечным количеством регистров. Самый популярный формат для оптимизирующих компиляторов.
Мы сосредоточимся на линейном представлении, а именно на Трехадресном коде.
Трехадресный код (Three-Address Code, 3AC)
Трехадресный код — это последовательность инструкций, где каждая инструкция выполняет ровно одну операцию и имеет не более трех операндов (обычно два аргумента и один результат).
Общий вид инструкции:
Где:
* — переменная, куда записывается результат.
* — операнды (переменные или константы).
* — операция (сложение, вычитание, умножение и т.д.).
Пример преобразования
Возьмем выражение на языке высокого уровня:
В AST это дерево, где приоритет умножения задан структурой. В 3AC мы должны явно разбить это на шаги, вводя временные переменные (обозначим их ):
Более сложный пример с условием:
В 3AC появляются метки (Labels) и переходы (Jumps):
Такой код намного проще анализировать машине, чем вложенные конструкции if-else или циклы while.
Базовые блоки и Граф потока управления (CFG)
Для анализа программы линейный список инструкций разбивают на Базовые блоки (Basic Blocks).
Базовый блок — это последовательность инструкций, в которую управление попадает только в начало и выходит только из конца. Внутри блока нет ветвлений и меток, на которые можно прыгнуть извне.
Когда мы соединяем эти блоки стрелками (переходами), получается Граф потока управления (Control Flow Graph, CFG).
!Граф потока управления, показывающий логическую структуру программы
CFG — это карта программы, по которой «путешествуют» алгоритмы оптимизации.
SSA: Статическая форма единственного присваивания
Современные компиляторы (GCC, LLVM, Go) используют улучшенную версию 3AC, которая называется SSA (Static Single Assignment).
Главное правило SSA: Каждой переменной значение присваивается ровно один раз.
Если в исходном коде мы меняем переменную:
То в SSA мы создаем новые версии этой переменной:
Где:
* — первая версия переменной .
* — вторая версия переменной .
* — первая версия переменной .
Это сильно упрощает анализ. Если мы видим использование , мы точно знаем, где оно было определено, и нам не нужно сканировать весь код вверх, чтобы проверить, не изменилось ли значение.
Но что делать с ветвлениями? Если меняется и в if, и в else?
В SSA вводится специальная -функция (Фи-функция). Она «выбирает» правильную версию переменной в зависимости от того, откуда пришло управление.
Где означает: «Если мы пришли по левой ветке графа, возьми , если по правой — ». Результат записывается в новую версию .
Основы оптимизации
Имея IR (особенно в форме SSA), мы можем применять алгоритмы оптимизации. Оптимизации делятся на локальные (внутри одного базового блока) и глобальные (во всей функции).
Рассмотрим самые популярные и простые методы.
1. Свертка констант (Constant Folding)
Если операнды операции известны на этапе компиляции, мы можем вычислить результат сразу.
До:
После:
2. Распространение констант (Constant Propagation)
Если мы знаем, что переменная равна константе, мы заменяем все ее использования на эту константу.
До:
После:
3. Удаление мертвого кода (Dead Code Elimination)
Код, который вычисляет значение, которое нигде не используется, или код, который никогда не будет выполнен, можно удалить.
До:
После:
4. Удаление общих подвыражений (Common Subexpression Elimination)
Если одно и то же выражение вычисляется дважды и переменные не менялись, можно использовать старый результат.
До:
После:
Где и — переменные, значения которых не менялись между операциями.
Заключение
Генерация промежуточного кода — это поворотный момент в жизни компилятора. Мы уходим от синтаксиса конкретного языка и переходим к универсальной логике вычислений. Использование IR и форм типа SSA позволяет нам применять мощные математические алгоритмы для улучшения кода.
Теперь, когда наш код оптимизирован и «причесан», он готов к финальному этапу — превращению в инструкции конкретного процессора. В следующей статье мы поговорим о генерации целевого кода и распределении регистров.