Раскрытие макросов
Раскрытие макросов (англ. macro expansion) исторически является первым и достаточно простым подходом к порождению инструкций. Зачастую реализация разделяется на две части: непосредственно макросы-шаблоны и процедура, которая применяет эти макросы к коду (macro expander). За счет этого разделения первая часть может быть специализирована под различные архитектуры, в то время как вторая может быть написана один раз для всех архитектур.
Преимущества: просто и прямолинейно.
Пример раскрытия макросов для архитектуры RISC-V. Одной инструкции языка Си слева соответствуют от 1 до 3 инструкций ассемблера.
int a = 1; | li r1, 1 |
int b = a+4; | addi r2, r1, 4 |
p[4] = b; | lw r3, @p ; адрес начала массива addi r4, r3, 4*8 sw r4, r5 |
Наивное раскрытие макросов
Одной из первых работ по порождению кода с помощью макросов является SIMCMP (SIMple CoMPiler) :cite:`Orgass1969ABF`. В этом проекте код программы читался строчка за строчкой, и на ходу порождался машинный код. Сделано это для того, чтобы писать компилятор языка на самом этом языке (англ. bootstraping).
Ниже можно найти пример спецификации в системе SIMCMP :cite:`Orgass1969ABF`.
* = CAR.*. I = CDR('21) CDR('11) = CAR(I). .X A = CAR B. I = CDR(38) CDR(36) = CAR(I)
Другой пример --- GCL :cite:`Elson1970`, который использовался в компиляторе PL/1 и код порождался из деревьев абстрактного синтаксиса (англ. abstract syntax tree, AST). По сравнению с чтением программы построчно, AST гарантирует, что программа написана без синтаксических ошибок, что упрощает задачу порождения кода.
% !TEX TS-program = xelatex % !TeX spellcheck = ru_RU % !TEX root = sel1.tex \documentclass[tikz,border=3.14mm]{standalone} \usepackage{tikz} \usetikzlibrary{graphs, positioning} \def\mynode#1#2 { \node[box] (b#1) {#2}; \node [right,inner xsep=.5em , outer sep=0pt,text height=1ex,text depth=.0ex] (caption#1) at ([shift={(-1em,0pt)}]b#1.north west) {#1}; } \begin{document} \begin{tikzpicture}[nodes={draw, circle}, ->] \node{$\times$} child { node {+} child { node {a} } child { node {b} } } child { node {2} }; \end{tikzpicture} \begin{tikzpicture}[nodes={draw, circle}, ->] \node{+} child { node {c} } child { node {$\times$} child { node {a} } child { node {b} } }; \end{tikzpicture} \tikzset{ op/.style={draw, circle}, rand/.style={draw, circle}, lab/.style={} } \begin{tikzpicture} \draw[help lines,step=5mm,gray!20] (-4,-4) grid (4,3); \node[op] (t1) at (0,0) { = }; \node[rand,above left=of t1] () { 0 }; \node[op,above right=of t1] (c3) { {\tiny mod} }; \node[op,above left=of c3] (c0) { 2 }; \draw[red] (c3) to [bend right=10] (t1); % \begin{scope}[yshift=8cm,node distance=2cm and 1cm] % \draw[help lines,step=5mm,gray!20] (-4,-4) grid (4,3); % \node[draw] (a) at (0,0) {a}; % \foreach \pos in {above,above right,right,below right,below,below left,left,above left} % \node[draw,\pos = of a] () {\pos}; % \end{scope} \end{tikzpicture} \end{document}
Дерево выражений
add t0, r1, r2 mulw t0, t0, 2
Промежуточные представления вместо деревьев абстрактного синтаксиса
Первые компиляторы занимались порождением кода непосредственно на основе команд на языке программирования. Это прямолинейный подход, который не может анализировать исходную программу в целом, а только по отдельным инструкциям. К тому же оно привязывает порождение кода (т.е. компилятор) к конкретному языку программирования.
Более удачным вариантом является порождение кода из деревьев абстрактного синтаксиса. В наши дни из AST порождается из специальное представления программ, в которых совершаются различные оптимизации. Примерами таких представлений могут быть ANF, SSA и C--.
Одно из первых промежуточных представлений было разработано :cite:`wilcox1971` для компилятора PL/C, где AST преобразовывалось в SLM-инструкции (англ. source level machine). Порождатель кода отображает SLM-инструкции в машинные, используя правила на языке ICL (Interpretative Codeing Language). На практике оказалось, что такие правила очень сложно писать, потому что много тонкостей (разные виды адресации, местоположения данных) надо поддерживать вручную.
ADDB BR A,ADDB1 Если A в регистре, переход на ADDB1 BR B,ADDB2 Если B в регистре, переход на ADDB2 LGPR A Породить код, загружающий A в регистр ADDB1 BR B,ADDB3 Если B в регистре, переход на ADDB3 GRX A,A,B Породить A+B B ADDB4 Слияние ADDB3 GRR AR,A,B Породить A+B ADDB4 FREE B Освободить ресурсы, связанные с B ADDB5 POP 1 Удалить дескриптор для B со стэка EXIT ADDB2 GRI A,B,A Породить A+B FREE A Освободить ресурсы, связанные с A SET A,B Удалить дескриптор для A со стэка B ADDB5 Слияние
Порождение макросов из описания целевой машины
Реалистичные компиляторы с какого-то момента времени должны начать поддерживать несколько целевых машин. Проблемы с рукописными макросами начинаются, если машины начинают существенно различаться между собой. Например, бывают разные классы регистров (TODO ссылка), в которые можно класть только данные определенного вида, или которые нельзя использовать одновременно, или некоторые архитектуры могут не иметь подходящих команд, и для выполнения операции над данными из DRAM необходимо задействовать дополнительный регистр.
Доступ к данным по указателю на стеке для RISC-V64 и AMD64
В примере выше мы обращаемся к элементу на расстоянии 8 байт от вершины стека. В архитектуре AMD64 мы можем сделать это непосредственно, в RISCV64 необходимо пользоваться промежуточным регистром. При генерации кода с помощью макросов приходится одновременно заниматься и распределением регистров, что усложняется задачу порождения оптимального кода.
Писать макросы руками сложно, хотелось бы иметь генератор, который по описанию машины порождает соответствующие макросы. Одна из первых попыток :cite:`Miller1971` сделать это была система Dmacs. Она предлагала два проприетарных языка: первый (Machine-Independent Macro Language (MIML)) определят 2-адресные команды, которые являлись представлением программы, а второй (Object Machine Macro Language (OMML)) декларативный язык использовался, чтобы преобразовывать MIML команды в ассемблерный код.
1: SS C,J 2: IMUL 1,D 3: IADD 2,B 4: SS A,I 5: ASSG 4,3 rclass REG: r2, r3, r4, r5, r6 rclass FREG: fr0, fr2, fr4, fr6 ... rpath WORD -> REG: L REG,WORD rpath REG -> WORD: ST REG,WORD rpath FREG -> WORD: LE FREG,WORD rpath WORD -> FREG: STE FREG,WORD ... ISUB s1 ,s2 from REG(s1),REG(s2) emit SR s1 ,s2 from REG(s1),WORD(s2) emit S s1 ,s2 resultresultREG(s1) REG(s2) FMUL m1, m2 (commutative) from FREG(m1),FREG(m2) emit MER m1 ,m2 from FREG(m1),WORD(m2) emit ME m1 ,m2 resultresultFREG(m1) FREG(m1)
Использование peephole-оптимизаций
Основным недостатком подхода на основе раскрытия макросов является то, что отдельные части IR раскрываются без учета рядом находящихся частей IR. Попытаться обойти этот недостаток можно с помощью peephole (в перевода на русский --- "глазок") оптимизаций. Их суть заключается в том, что выбирается "окно" небольшого размера, которое двигают по порожденному коду и пытаются объединить видимые инструкции. Данный метод может применяться и в отрыве от выбора инструкций, к уже порожденному коду. Одним из самых известных применений являются "супер оптимизаторы" :cite:`Massalin1987`, например Souper :cite:`Souper2018`. Идея подхода заключается кодировании семантики текущего набора инструкций в представление, понятное SMT-решателям, и затем нахождение минимальной программы с такой же семантикой с помощью синтеза программ (англ. Counter Example Guided Inductive Synthesis, CEGIS). К сожалению, Souper поддерживает набор инструкций размером только в несколько десятков, и масштабирование этого подхода на разнообразные архитектуры является предметом дальнейших исследований.
Оптимизации методом peephole можно использовать :cite:`Davidson1984` и в контексте выбора инструкций, такой подход используется в компиляторе GCC :cite:`Stallman1988`. Суть подхода заключается в том, что раскрытие макросов порождает не код целевой машины, а некоторое описание на языке RTL (англ. Register Transfer List). В примере ниже трехадресная инструкций сложения складывает константу imm с регистром r_s и сохраняет результат в r_d, выставляя флаг нуля Z.
RTL(add) = \begin{cases} r_d & \leftarrow r_s + imm \\ Z & \leftarrow (r_s + imm) \Leftrightarrow 0 \end{cases}
В предлагаемом подходе представление программы с помощью правил RTL превращается в описание "эффекта" этой программы. В отличие других подходов на основе макросов распределения регистров не происходит. Все используемые регистры --- виртуальные, предполагается, что их бесконечно много. После раскрытия макросов и до распределения регистров запускается так называемый комбинатор (англ. combiner), который пытается объединить несколько RTL описаний в большее RTL-описание, соответствующее какой-то инструкции целевой архитектуры. Чтобы такой подход работал, надо поддерживать инвариант, что все RTL-описания выразимы с помощью одной инструкции целевой архитектуры.
Теоретически, такой подход позволяет порождать код, рассматривая не одну команду языка программирования, а сразу несколько, даже лежащих в разных блоках потока управления. Сложность порожденных инструкций сильно зависит от размера "окна" оптимизатора, так, например, не получится породить инструкции, соответствующие трём RTL, если мы смотрим только на два RTL.