В компиляторе gc для описания Static Single Assignment (SSA) правил оптимизаций используется специальный Лисп-подобный предметно-ориентированный язык (DSL). Предлагаю разобрать основные элементы этого языка, его особенности и ограничения.
В качестве упражнения, добавим в Go компилятор генерацию инструкции, которую он раньше не генерировал, оптимизируя выражение a*b+c. Это первая статья из серии про внутренности Go SSA compiler backend, поэтому помимо обзора самого DSL описания правил мы рассмотрим связанные компоненты, чтобы создать необходимую базу для нашей следующей сессии. Frontend Go компилятора заканчивается на моменте генерации SSA представления из аннотированного AST. Функции, ответственные за конвертацию можно найти в cmd/compile/internal/gc/ssa.go. Точкой входа в SSA backend является функция ssa.Compile, определённая в файле cmd/compile/internal/ssa/compile.go. Если по мере прочтения статьи вы нашли совершенно непонятный для вас термин, стоит сообщить об этом, возможно он будет добавлен в эту таблицу. SSA оптимизатор Go состоит из нескольких фаз, каждая из которых выполняет проходы по компилируемой функции. Некоторые фазы используют так называемые "rewrite rules", правила преобразования одних SSA последовательностей в другие, потенциально более оптимальные. Правила преобразования описываются с помощью S-выражений. Элементы этих выражений — ssa.Value. В простейшем случае эти правила позволяют заменить один ssa.Value на другой. Например, код ниже сворачивает умножение 8-битных констант: Есть две основные категории SSA значений: высокоуровневые, почти не зависящие от целевой машины и те, что архитектурно-специфичны (обычно отображаются на машинные инструкции 1-в-1). Оптимизации описываются в терминах этих двух категорий. Сначала высокоуровневые и общие для всех архитектур, затем платформенно-ориентированные. Весь код, связанный с правилами, лежит в cmd/compile/internal/ssa/gen. Мы будем рассматривать два набора: После нескольких первых фаз, которые работают над абстрактной машиной, выполняется так называемый lowering, в результате которого происходит переход из genericOps в набор конкретной архитектуры. В нашем примере это будет AMD64Ops. После этого момента, все последующие фазы оперируют над представлением из второй категории. После оптимизатора в игру вступает кодогенератор. Для AMD64 реализацию кодогенерации можно найти в пакете cmd/compile/internal/amd64. Задача кодогенератора — заменить ssa.Block и ssa.Value в последовательность obj.Prog, передаваемые ассемблеру. Ассемблер соберёт машинный код, который будет готов к исполнению после линковки.  Если файлы с определениями операций имеют название вида "${ARCH}Ops.go", то правила оптимизаций размещаются в "${ARCH}.Rules". Высокоуровневые правила выполняют простейшие преобразования, большую часть сворачивания константных выражений, а также некоторые преобразования, которые упрощают последующую обработку. Каждый файл с низкоуровневыми правилами состоит из двух частей: Пример сведения операции к машинной: Именно в низкоуровневых оптимизациях выполняется основное количество важных оптимизаций, таких как снижение стоимости операций, частичное встраивание и утилизация возможностей доступных в процессоре режимов адресации памяти. Операции имеют мнемоническое имя, которое обычно называется опкодом (opcode). Опкоды архитектурно-зависимых операций, как правило, отражают названия реальных инструкций. Базовая грамматика описана в rulegen.go: Стоит также упомянуть, что внутри .Rules файлов разрешены "//" комментарии. Разберём простой пример, который содержит все эти элементы: Данное правило преобразует x+0 в x. Всё внутри секции условий — это обычный Go код,
разве что ограниченный до выражений, результатом которых должен быть bool.
Можно вызывать предикаты, определённые в rewrite.go. Кроме обычных опкодов, могут использоваться комбинации, которые порождают несколько правил: Особенности переменных в шаблонах (S-выражения слева от ->): Если AuxInt не указан (выражение в квадратных скобках), то правило будет срабатывать на любом значении AuxInt. Аналогично с {}-параметрами (о них ниже). Имя v означает самую внешнюю захваченную форму.
Например, для выражения (ADDQconst (SUBQconst x)) внешней формой является ADDQconst. Переменные можно использовать несколько раз, это позволяет требовать соответствия нескольких частей S-выражения между собой: В некоторых случаях требуется явно указывать тип генерируемой и/или сопостовляемой формы.
Указывается тип в "треугольных скобках", как аргумент-тип в шаблонах C++: Кроме типов, существуют "символы" (или, более универсально — Aux свойства). Семантика Aux и AuxInt сильно варьируется от операции к операции. Лучшим источником документации в данном случае являются *Ops.go файлы. У каждого определения опкода opData есть поле aux, которое описывает как именно интерпретировать эти поля. Для описания типов используется пакет cmd/compile/internal/types. Некоторые типы специфичны для SSA бэкенда, например types.TypeFlags, остальные общие между cmd/compile/internal/gc и cmd/compile/internal/ssa. Существует особый тип types.TypeMem, который выполняет сразу несколько функций: Подобно особому значению OpPhi, память интерпретируется исключительным образом во многих фазах оптимизатора. Phi имеет роль, которая немного меняется от фазы к фазе. В самом начале работы SSA части компилятора, Phi служит своей классической цели и выражает выбор значения в зависимости от пути исполнения, который привёл нас к этому значению. Например, если в блок есть два прыжка, и оба модифицируют память, то блок-назначение получит память равную (Phi mem1 mem2). Циклы также протаскивают Phi значение. Другим особым типом является упомянутый выше types.TypeFlags. Этот тип описывает генерацию инструкцией CPU флагов. При этом инструкции, вроде ADDQ, хоть и генерируют флаги, не имеют тип types.Flags, но отмечены атрибутом clobberFlags. types.Flags используется для выделения результата инструкций вроде CMPQ, которые не пишут результат ни в один из своих явных операндов, а только обновляют внутреннее состояние процессора, которое может использоваться следующей инструкцией. Инструкции, вроде SETL позволяют "прочитать" флаги и вернуть их в виде ssa.Value, который может быть размещён в регистре. Допустим, у нас есть такая программа (example.go): Мы можем просмотреть SSA код, который сгенерирован для функции fusedMulAdd: Теперь проверьте рабочую (текущую) директорию: Символ ~r3 переименован в ret для выразительности. Вот так выглядит SSA программа для fusedMulAdd после фазы lower (ssa.html):  Если вам почему-то захотелось это скопировать: Переводя это в S-выражения: Так выглядит вывод ssa.html для фазы regalloc.  На процессорах с FMA мы можем вычислять a*c + b за одну инструкцию вместо двух. Возьмём за основу CL117295 за авторством Ильи Токаря. Для вашего удобства, я подготовил минимальный diff патч.
 В файле compile/internal/ssa/gen/AMD64Ops.go найдите переменную-слайс AMD64ops и добавьте туда новый элемент (в любое место): Поскольку ранее (fp,fp,fp -> fp) операций не было, нужно определить новый спецификатор: Более правильная реализация не была бы безусловной и проверяла бы доступность FMA. Мы будем считать, что на нашей целевой машине FMA точно есть. В компиляторе для подобных проверок используется config: Если в системе доступен lscpu, то, например так: Теперь в функцию ssaGenValue, определённую в файле compile/internal/amd64/ssa.go, нужно добавить кодогенерацию для FMASD: Теперь всё готово, чтобы проверять работу нашей новой оптимизации. Добавлять новые инструкции приходится очень редко, обычно новые оптимизации делают на основе уже определённых SSA операций. Первым шагом является генерация Go кода из обновлённых gen/AMD64Ops.go и gen/AMD64.Rules. Далее нужно собрать наш новый компилятор: Теперь при компиляции того же примера, мы получим иной машинный код: Теперь, когда самая сложная работа сделана, поговорим о базовых блоках. Значения, которые мы оптимизировали выше, находятся в блоках, а блоки находятся в функции. Блоки, как и ssa.Value, бывают абстрактными и машино-зависимыми. Все блоки имеют ровно одну точку входа и от 0 до 2 блоков-назначений (зависит от типа блока). Самыми простыми блоками являются If, Exit и Plain: Вот простые примеры переписывания абстрактных блоков в блоки AMD64: Тему блоков более подробно будем рассматривать в контексте других оптимизирующих фаз в SSA части компилятора. У SSA бэкенда есть свои преимущества. Некоторые оптимизации выполнимы за O(1). Однако есть и недостатки, из-за которых одного лишь SSA оптимизатора будет маловато, по крайней мере пока не изменятся некоторые аспекты его реализации. Предположим, вы хотите выполнять слияние вызовов append: На момент, когда SSA сгенерирован, высокоуровневая структура кода утрачена и append, будучи не обычной функцией, уже встроен в тело содержащего блока. Вам придётся писать громоздкое правило, которое захватывает всю сгенерированную для append последовательность операций. Если говорить конкретно о .Rules, то в этом DSL довольно слабая работа с блоками (ssa.Block). Любая нетривиальная оптимизация, связанная с блоками, невозможна для выражения на этом языке. Частичное обновление блока невозможно. Выбрасывать блоки тоже невозможно (но есть хак в виде First блока, который используется для удаления мёртвого кода). Если придумаете какие-то крутые правила оптимизации, смело высылайте на go-review.googlesource.com. Буду рад произвести ревью (добавляйте в CC iskander.sharipov@intel.com). Счастливого хакинга компилятора!  Примеры хороших патчей в Go, которые добавляли или изменяли SSA rules: Не так давно появился README документ для описания SSA части компилятора.
Рекомендуется к прочтению. Making developer tools & video games