Теория оптимизирующих компиляторов: от IR до машинного кода

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

1. Промежуточные представления (IR) и форма статического единственного присваивания (SSA)

Промежуточные представления (IR) и форма статического единственного присваивания (SSA)

Компилятор — это не просто черный ящик, который превращает исходный код в исполняемый файл. Это сложная инженерная система, состоящая из множества этапов трансформации данных. Чтобы эффективно анализировать и оптимизировать код, компиляторы используют специальные структуры данных, называемые промежуточными представлениями (Intermediate Representation, IR).

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

Архитектура современного компилятора

Традиционно архитектуру компилятора делят на три большие части:

  • Frontend (Фронтенд): Отвечает за понимание исходного языка. Он проводит лексический и синтаксический анализ, проверяет типы и генерирует первичное промежуточное представление.
  • Middle-end (Оптимизатор): Работает исключительно с IR. Здесь происходят все магические превращения: удаление мертвого кода, развертывание циклов, распространение констант. Оптимизатор не знает, был ли исходный код написан на C++, Rust или Swift.
  • Backend (Бэкенд): Превращает оптимизированное IR в машинный код конкретного процессора (x86, ARM, RISC-V).
  • !Классическая трехфазная архитектура компилятора

    Такое разделение решает фундаментальную проблему масштабируемости, известную как проблема . Если у нас есть языков программирования и целевых архитектур процессоров, то без общего IR нам пришлось бы писать компиляторов (для каждого языка под каждый процессор).

    Использование общего IR меняет формулу сложности:

    где — количество фронтендов (языков), а — количество бэкендов (архитектур). Достаточно написать один фронтенд для нового языка, чтобы он сразу заработал на всех архитектурах, поддерживаемых бэкендом.

    Виды промежуточных представлений

    IR бывает разным в зависимости от целей. Обычно выделяют три уровня:

    * Высокоуровневое IR (HIR): Сохраняет структуру исходного языка (циклы for, while, структуры). Примером служит AST (Абстрактное синтаксическое дерево). * Среднеуровневое IR (MIR): Теряет синтаксический сахар, превращая циклы в переходы (goto), но сохраняет типы данных и бесконечное количество виртуальных регистров. Именно здесь работает большинство оптимизаций. * Низкоуровневое IR (LIR): Максимально близко к ассемблеру. Виртуальные регистры заменяются на физические, учитываются особенности конкретного процессора.

    Трехадресный код (Three-Address Code)

    Стандартом де-факто для оптимизаций является трехадресный код. Это формат, в котором каждая инструкция имеет не более трех операндов: один для результата и два для аргументов.

    Пример выражения на языке высокого уровня:

    В трехадресном коде это выражение разбивается на атомарные операции с использованием временных переменных:

    Здесь t1 и t2 — это временные переменные (виртуальные регистры). Такая структура упрощает анализ, так как компилятору не нужно обходить сложные деревья выражений; он работает с плоским списком простых инструкций.

    Проблема анализа потока данных

    Представьте, что вы пишете оптимизатор, который должен заменить использование переменной на константу (Constant Propagation). Рассмотрим код:

    Какое значение попадет в y? Очевидно, что 2. Но для компьютера это неочевидно. Чтобы понять это, компилятор должен просканировать весь код и найти последнее присваивание x перед его использованием. Если код содержит ветвления и циклы, задача становится крайне сложной. Переменная x меняет свое значение в разных местах, и отслеживать ее актуальное состояние — ресурсоемкая задача.

    Здесь на сцену выходит SSA.

    Static Single Assignment (SSA)

    Форма статического единственного присваивания (SSA) — это свойство промежуточного представления, которое требует, чтобы каждой переменной значение присваивалось ровно один раз.

    Как это возможно, если в программировании переменные постоянно меняются? Мы используем версионирование. Вместо того чтобы перезаписывать x, мы создаем новую версию переменной (x_1, x_2, x_3) при каждом присваивании.

    Преобразуем предыдущий пример в SSA:

    Теперь связь между определением и использованием (Def-Use chain) очевидна. В строке 3 используется x_2, и есть только одно место в программе, где x_2 определяется (строка 2). Нам не нужно сканировать код вверх или вниз — связь жестко зафиксирована именем переменной.

    -функция (Фи-функция)

    Самая большая сложность SSA возникает при ветвлении потока управления (Control Flow). Рассмотрим пример с условием:

    Если мы применим версионирование:

    В точке слияния потоков (после if/else) мы не знаем, по какому пути пошла программа. Чтобы решить эту проблему, в SSA вводится специальная инструкция: -функция.

    Корректный SSA для примера выше:

    !Использование Phi-функции в точке слияния потоков управления

    Математически это записывается так:

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

    Важно понимать: -функция не существует в реальном процессоре. Это абстракция для анализатора. На этапе генерации машинного кода (в бэкенде) -функции заменяются на обычные инструкции пересылки данных (move) или просто исчезают благодаря грамотному распределению регистров.

    Преимущества SSA для оптимизаций

    Переход к SSA делает многие сложные алгоритмы оптимизации тривиальными.

    1. Удаление мертвого кода (Dead Code Elimination)

    В SSA, если переменная v_1 определена, но нигде не используется (нет инструкций, где она стоит справа), инструкцию v_1 = ... можно смело удалять. В обычном коде пришлось бы проверять, не переопределяется ли переменная позже.

    2. Распространение констант (Constant Propagation)

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

    Пример:

    Компилятор видит a_1, заменяет на 5. Видит b_1, заменяет на 10. Получает c_1 = 5 + 10. Вычисляет c_1 = 15. Весь блок кода сворачивается в одну константу.

    3. Нумерация значений (Value Numbering)

    SSA позволяет легко находить и удалять дублирующиеся вычисления.

    Так как a_1 и b_1 никогда не меняются (свойство SSA), то a_1 + b_1 всегда дает один и тот же результат. Компилятор видит, что правые части идентичны, и заменяет код на:

    Пример из реального мира: LLVM IR

    LLVM — это самый популярный современный фреймворк для создания компиляторов (используется в Clang, Rust, Swift). Его IR полностью построен на SSA.

    Вот как выглядит простая функция на C:

    А вот её (упрощенное) представление в LLVM IR:

    Обратите внимание на инструкцию phi. Она явно говорит: "Если мы пришли из метки %if.then, то результат %a. Если из %if.else, то результат %b".

    Итоги

  • IR — это универсальный язык компилятора. Он позволяет отделить фронтенд (парсинг языка) от бэкенда (генерации машинного кода), решая проблему совместимости языков и архитектур.
  • Трехадресный код упрощает структуру программы до последовательности простых операций, что идеально подходит для анализа.
  • SSA (Static Single Assignment) — ключевая форма IR, где каждая переменная присваивается ровно один раз. Это достигается путем версионирования переменных.
  • -функция необходима в SSA для обработки точек слияния потока управления (например, после if или в циклах), выбирая правильную версию переменной в зависимости от пути выполнения.
  • Использование SSA кардинально упрощает и ускоряет алгоритмы оптимизации, такие как удаление мертвого кода и распространение констант.
  • 2. Анализ потока данных и распространение констант

    Анализ потока данных и распространение констант

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

    Сердцем оптимизирующего компилятора является анализ потока данных (Data Flow Analysis). Это теоретический фреймворк, который позволяет компилятору отвечать на вопросы вроде: «Может ли эта переменная иметь значение null?», «Будет ли это выражение всегда равно 5?» или «Используется ли эта переменная в будущем?».

    В этой статье мы разберем механику анализа потока данных на примере одной из самых мощных оптимизаций — Sparse Conditional Constant Propagation (SCCP) (Разреженное условное распространение констант).

    Фундамент: Теория решеток (Lattice Theory)

    Чтобы компьютер мог рассуждать о значениях переменных, нам нужно формализовать понятие «знания» о переменной. В анализе распространения констант мы не просто знаем значение числа, мы оцениваем степень нашей уверенности в том, является ли переменная константой.

    Для этого используется математическая структура, называемая полурешеткой.

    Представьте, что для каждой переменной в программе мы храним одно из трех состояний:

  • Top (, «Вершина»): Мы пока ничего не знаем о переменной. В начале анализа мы оптимистично предполагаем, что все переменные могут быть константами. Это состояние «неопределенности», но с надеждой на лучшее.
  • Constant (): Мы доказали, что переменная имеет конкретное неизменное значение (например, 42, 0, -1).
  • Bottom (, «Дно»): Мы доказали, что переменная не является константой. Ее значение известно только во время выполнения (например, оно пришло из ввода пользователя или является результатом сложного вычисления).
  • !Решетка состояний для анализа констант: движение возможно только сверху вниз

    Правило монотонности

    Ключевое правило анализа: информация может двигаться по решетке только вниз.

    * Если переменная была , она может стать константой или сразу . * Если переменная была константой , она может стать (если мы найдем путь, где она равна ). * Если переменная стала , она навсегда останется . Мы не можем «забыть», что переменная не является константой.

    Это свойство гарантирует, что наш алгоритм анализа когда-нибудь остановится (сойдется), а не будет бесконечно переключать состояния.

    Функция встречи (Meet Operator)

    В программе часто встречаются точки слияния потока управления (например, после if-else или в циклах). В SSA-форме эти точки представлены -функциями. Как нам определить состояние переменной, если по одной ветке приходит константа 5, а по другой — константа 10?

    Для этого используется операция Meet (обозначается ). Она объединяет информацию из двух источников.

    Правила для распространения констант:

  • (Если один путь неизвестен, результат определяется вторым путем).
  • (Если оба пути дают одинаковую константу, результат — эта константа).
  • , если (Если константы разные, результат — не константа).
  • (Если хотя бы один путь не константа, результат — не константа).
  • Пример:

    Здесь мы объединяем константу 5 и константу 10. Так как значения не равны, мы делаем вывод, что результат не может быть одной фиксированной константой.

    Простой алгоритм распространения констант

    Классический итеративный алгоритм работает следующим образом:

  • Инициализируем все переменные значением (кроме входных аргументов функции, они сразу ).
  • Помещаем все инструкции в рабочий список (Worklist).
  • Пока список не пуст:
  • * Берем инструкцию. * Вычисляем новое значение результата на основе текущих значений операндов. * Если значение результата изменилось (понизилось в решетке), добавляем в список все инструкции, которые используют этот результат.

    Рассмотрим пример:

  • Изначально x, y, z имеют статус .
  • Обрабатываем x = 10. Операнд — литерал 10. Статус x меняется с на Constant(10).
  • Обрабатываем y = 20. Статус y меняется на Constant(20).
  • Обрабатываем z = x + y. Мы видим, что x=10, y=20. Выполняем сложение: . Статус z меняется на Constant(30).
  • Теперь компилятор может заменить z на 30 во всем коде.

    Проблема условных переходов

    Простой алгоритм имеет фатальный недостаток: он анализирует все пути, даже те, которые никогда не выполнятся. Рассмотрим код:

    В SSA это будет выглядеть примерно так (упрощенно):

    Простой алгоритм увидит y_1 = 42 и y_2 = 100. В точке слияния phi(42, 100) он применит правило . В итоге компилятор решит, что y_3 — не константа.

    Но мы-то видим, что x всегда 10, условие 10 < 5 всегда ложно, и программа всегда пойдет по ветке else. Значит, y всегда 100.

    Чтобы решить эту проблему, нам нужен более умный алгоритм: SCCP.

    Sparse Conditional Constant Propagation (SCCP)

    SCCP — это «золотой стандарт» распространения констант. Он объединяет две задачи: поиск констант и удаление мертвого кода.

    Идея SCCP заключается в том, что мы отслеживаем не только значения переменных, но и исполняемость ребер графа потока управления (CFG).

    Состояние алгоритма

    У нас есть два набора данных:

  • LatticeCell: Значение для каждой переменной ().
  • ExecutableFlag: Флаг для каждого ребра графа (исполняется/не исполняется). Изначально все ребра считаются неисполняемыми, кроме входа в функцию.
  • Рабочие списки

    Алгоритм использует два рабочих списка:

  • FlowWorklist: Список ребер графа, которые стали исполняемыми.
  • SSAWorklist: Список инструкций, которые нужно пересчитать (потому что изменились значения их операндов).
  • Логика работы

    Когда мы обрабатываем инструкцию ветвления (например, if (val) ...), мы проверяем состояние переменной val:

    * Если val — это Constant(True): Мы помечаем как исполняемое только ребро Then. Ребро Else остается мертвым. * Если val — это Constant(False): Помечаем только ребро Else. * Если val — это (неизвестно): Мы вынуждены пометить оба ребра как исполняемые. * Если val — это : Мы ничего не делаем, ждем, пока val станет известен.

    Самое важное правило для -функций в SCCP:

    Где — это последовательное применение операции Meet, но только для тех операндов, которые приходят по исполняемым ребрам. Если ребро помечено как «мертвое», значение, приходящее по нему, игнорируется (считается ).

    Пример работы SCCP

    Вернемся к нашему примеру с if (x < 5).

  • Инициализация: Все переменные , все ребра мертвые. В FlowWorklist добавляем входное ребро.
  • Шаг 1: Заходим в блок. Видим x_1 = 10. x_1 становится Constant(10).
  • Шаг 2: Доходим до if (x_1 < 5). Вычисляем условие: — это False.
  • Шаг 3: Так как условие — константа False, мы добавляем в FlowWorklist только ребро Else. Ребро True остается мертвым.
  • Шаг 4: Идем по ребру Else. Видим y_2 = 100. y_2 становится Constant(100).
  • Шаг 5: Доходим до Label_Merge. Здесь стоит y_3 = phi(y_1, y_2).
  • * y_1 приходит из ветки True. Но ребро True не исполняемое. Мы игнорируем y_1. * y_2 приходит из ветки Else. Ребро исполняемое. Значение 100. * Результат : просто 100.

    Итог: y_3 признается константой 100. Компилятор может удалить весь if, переменную x и просто вернуть 100.

    Циклы и оптимизм

    Почему важно начинать с (оптимизма)? Рассмотрим цикл:

    В SSA:

  • В начале i_1 = .
  • Вычисляем phi(i_0, i_2). i_0 = 0. i_2 пока (мы его еще не обработали).
  • . Значит, i_1 становится Constant(0).
  • Обрабатываем тело. i_2 присваивается 0.
  • Снова вычисляем phi. Теперь i_2 = 0.
  • . Значение i_1 остается Constant(0).
  • Если бы мы начали пессимистично (считая все ), мы бы сразу сказали, что i_1 — это , так как оно меняется в цикле, и упустили бы оптимизацию.

    Итоги

  • Анализ потока данных использует теорию решеток для формального доказательства свойств программы. Значения движутся от (неизвестно/оптимистично) к (не константа/пессимистично).
  • Операция Meet () определяет, как объединять противоречивую информацию в точках слияния потока управления.
  • Простое распространение констант неэффективно при наличии мертвого кода, так как учитывает значения из недостижимых веток.
  • SCCP (Sparse Conditional Constant Propagation) решает эту проблему, отслеживая исполняемость ребер графа. -функции учитывают значения только из «живых» путей.
  • Использование SSA критически важно для эффективности этих алгоритмов, так как позволяет использовать разреженные (sparse) структуры данных, не требующие анализа всего кода на каждом шаге.