itmo_conspects

Лекция 7. Компилятор в виртуальной машине

Когда виртуальная машина компилирует программу, она не преобразует её напрямую из байткода или AST в машинный код. Реализация современных JIT/AOT-компиляторов устроена иначе: они используют промежуточное представление программы - IR (Intermediate Representation). Промежуточное представление - это набор инструкций, графов, блоков, который достаточно далёк от конкретных особенностей аппаратной платформы, но уже достаточно формален для того, чтобы над ним выполнять оптимизации

Промежуточное представление надо превратить в реальный машинный код - набор инструкций процессора с конкретной архитектурой (arm, x86, RISC-V и других). Программа, которая переводит промежуточное представление к конкретному бинарному коду называется бекендом компилятора

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

  1. Анализ времени жизни (Liveness Analysis) - определение жизненных диапазонов значений
  2. Аллокация регистров (Register Allocation) - выбор, какие значения будут жить в регистрах, а какие - в памяти
  3. Генерация кода (Code Generation) - превращение IR в реальный машинный код с учётом всех особенностей архитектуры

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

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

И именно здесь возникает фундаментальная проблема: в промежуточном представлении значений может быть сотни, а аппаратных регистров - меньше тридцати, например, ARM64 предоставляет 31 регистр общего назначения

В промежуточном представлении легко создать 300 временных значений, но в реальном процессоре такое невозможно

Компилятору требуется понять:

Анализ времени жизни

Если некоторая инструкция создала значение A, то это значение имеет время жизни с момента определения до последнего использования. Но, так как программа состоит из блоков, между которыми есть переходы, циклы, ветвления, то нельзя просто определить, в какой момент значение A больше не нужно; нужно понять:

Это приводит к важному определению: значение A живо в точке P, если существует путь от P до какого-либо использования A

Не важно, реальный ли это путь или потенциальный, поэтому говорят, что статическая живость - это приближение реального поведения программы

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


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

Это не просто список блоков, а порядок, в котором:

Алгоритм расчёта живости работает с конца в начало, а регистровый аллокатор типа линейного сканирования ожидает, что программа развёрнута в линейную последовательность инструкций. Линейная нумерация - это своего рода временная шкала, вдоль которой мы будем измерять жизнь значений

Далее анализ живости выполняется в несколько фаз:

  1. Обход графа потока управления идёт в обратном линейном порядке блоков

    Для каждого блока вычисляется набор живых значений (live-set) - множество значений, которые должны быть живы в начале блока.

    Набор живых значений блока складывается из:

    1. объединения наборов живых значений всех блоков-последователей
    2. всех реальных аргументов фи-функции этих последователей

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

    После определения набора живых значений блока каждая инструкция из набора получает живой диапазон, включающий весь блок: [block.begin, block.end)

  2. Теперь внутри блока мы анализируем инструкции, начиная с конца:

    1. Если инструкция определяет значение, то интервал времени жизни этого значения должен начинаться здесь - это минимальная точка его жизни
    2. Мы исключаем эту инструкцию из набора живых значений, потому что её значение уже учтено
    3. Каждый операнд инструкции добавляется в набор живых значений (если он не был там)
    4. Для каждого такого значения расширяется его отрезок времени жизни: оно должно быть живо как минимум до точки использования

    Здесь впервые начинает образовываться структура живых интервалов: значения, которые используются позже, живут дольше; значения, которые не используются, постепенно покидают набор живых значений

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

  3. Если блок является заголовком цикла, то все значения, находящиеся в наборе живых значений в начале блока, обязаны перекрывать весь цикл. Это означает, что отрезок времени жизни расширяется до [loop_header.begin, loop_end).

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

    Этот шаг обеспечивает целостность данных в циклах для любого алгоритма распределения регистров

Аллокация регистров

Теперь, когда мы знаем, где каждое значение живёт, можно распределять регистры.

У процессора мало регистров. Например:

Если бы регистров было 200–300, аллокация регистров не была бы нужна, но добавление регистров дорого само по себе:

Основная цель аллокатора регистров - минимизировать обращения к памяти, то есть вытеснений значений с регистров (spill) и внесений значений (fill)

Тут есть несколько подходов, но самым простым является линейное сканирование (Linear Scan). Линейное сканирование:

Простая версия линейного сканирования выглядит так:

  1. Интервалы значений сортируются по точке начала
  2. Ведётся список активных интервалов - тех, которые сейчас живы
  3. Когда начинается новый интервал:

    • Если есть свободный регистр - выдаём его
    • Если регистров нет - необходимо вытеснить одно из активных значений

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

Лучше выбирать интервал с самой далёкой следующей точкой использования. Это позволяет:

Ещё одна важная оптимизация: не нужно вытеснять весь интервал целиком, можно разбить его на части. Это радикально уменьшает количество вытеснение и внесение и часто спасает производительность

Выбор стратегии вытеснения должен быть правильным. Если алгоритм выбрал неудачное значение для вытеснения, начинается эффект домино:

Генерация кода

Генерация кода - последняя стадия бекенда. Она использует ранее полученные артефакты:

Генерация кода работает так:

  1. Формируются пролог и эпилог: выделяется стековый фрейм и так далее
  2. Выделяются временные регистры, которые не участвуют в регаллоке
  3. Генерируется код блоков в линейном порядке, вставляя необходимые переходы и метки
  4. Инструкции промежуточного представления преобразуются в инструкции целевой архитектуры, учитывая:

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

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

Стековый фрейм (или фрейм вызова) содержит:

Особенно важно то, что адресация к слотам происходит через регистры sp или fp, поэтому генератор кода обязан аккуратно работать с этими регистрами


Однако, вместо бекенда можно использовать LLVM () - проект программной инфраструктуры для создания компиляторов, однако есть ключевые проблемы:

  1. Она слишком тяжёлая для JIT-компиляции: огромные структуры данных, много стадий оптимизации
  2. Нельзя менять основу промежуточного представления: если виртуальная машина использует собственную модель промежуточного представления, то нужно подстраиваться под представление LLVM, что дорого и неудобно
  3. Промежуточное представление LLVM IR имеет строгие инварианты, которые JIT-компилятор нарушать не хочет
  4. Сильная зависимость от внешнего проекта: для критически важного элемента виртуальной машины это опасно
  5. Размер кода и время компиляции с LLVM существенно больше, чем у лёгких аллокаторов регистров вроде линейного сканирования
  6. В AOT-компиляции LLVM подходит отлично, но в JIT-компиляции - слишком тяжеловесен