
В этой части (первая тут) мы поговорим о структуре Go-программы с использованием ассемблера, о хитростях макросов. Будем писать дальше нашу ассемблерную функцию.
Структура Go-программы с поддержкой ассемблера
Всегда пишем ускоряемую функцию на чистом Go
Я понимаю ваше желание написать сразу сверхбыструю функцию на ассемблере. Но...
Правилом хорошего тона будет всегда иметь версию нужной нам функции на чистом Go. Это позволит нашей программе быть скомпилированной на любой платформе, поддерживаемой компилятором Go.
Также де-факто это задокументирует вашу функцию, так как код на ассемблере недалеко ушёл от китайской грамоты по понятности для русскоязычного человека.
В стандартной библиотеке Go принято для таких файлов давать окончание в имени _purego.go.
Build-тэги
Это специальные конструкции в начале файлов, которые говорят компилятору, какие файлы использовать для сборки.
Некоторые тэги компилятор Go устанавливает автоматически, например, для GOOS=linux, GOARCH=amd64 автоматически устанавливаются:
linux,
amd64,
gc (компилятор Go, не gccgo),
cgo, если он включён.
Для _purego.go-файла в шапке мы пишем:
//go:build !asm
А в шапке ассемблерного файла с окончанием _amd64.s пишем:
//go:build asm
// +build asm
Нам не нужно добавлять билд-тэг amd64. Окончание файла автоматически делает этот файл платформозависимым.
Соответственно, при сборке мы указываем go build -tags=asm ..., если нам нужна ассемблерная версия, и без указания тэгов, если нам нужна go-версия функции.
Пара слов о CGO
Если у вас иногда используется интерфейс CGO для доступа к стандартной библиотеке Си или для вызова функций из сторонних C-библиотек, а иногда нет, то вы можете управлять этим процессом при сборке через переменную среды для компилятора CGO_ENABLED=0 go build ..... Это для какой-то версии ваших бинарников отключит использование CGO (например, на той платформе, для которой у вас нет нужных C-библиотек).
Но использование CGO чрезвычайно осложняет кросс-компиляцию, так как компилятору нужно иметь доступ ко всем версиям нужным ему библиотек при сборке.
Поэтому очень желательно вообще обойтись без CGO.
Итак, если мы пишем ускоряемые функции на ассемблере, то ничего делать специально нам не нужно. И использовать переменную CGO_ENABLED тоже не нужно.
Однако если мы создаём кросс-компилируемую программу, то CGO_ENABLED=0 можно ставить на всякий случай. Так как в этом случае гарантиров��нно бинарник будет скомпилирован без всяких зависимостей.
Или же при первых признаках проблем (например, ошибка gcc not found) нужно установить CGO_ENABLED=0. На моей версии Go 1.25.3 кросс-компиляция хорошо проходит и без этого, но на предыдущей версии Go переменная CGO_ENABLED=0 была обязательной для кросс-компиляции.
Наша структура файлов
bint_common.go(определение структуры, общие функции и методы);bint_mul_purego.go(билд-тэг !asm);bint_mul_asm.go(тут мы просто пишем заголовок функции на Go, билд-тэг asm);bint_mul_amd64.s(ассемблерная реализация, билд-тэг asm).
Макросы — начало пути к продуктивности
Писать на чистом ассемблере очень муторно. Крутые программисты стараются как можно быстрее поднять уровень абстракции, чтобы писать более продуктивно.
Вы не раз слышали фразу zero-cost abstraction, такая характеристика считается несомненным достоинством языка программирования. Это значит, что в данном языке использование высокоуровневых абстракций, облегчающих программирование, не приводит к более медленному исполняемому коду.
Макросы можно вполне использовать как абстракции с нулевой стоимостью. После препроцессинга они разворачиваются в чистый код.
История, как с помощью макросов был написан интерпретатор языка J размером с одну страницу A4
Интерпретатор языка J (потомок APL) был написан за одну субботу Кеннетом Айверсоном (создатель APL, лауреат Turing Award) и его другом Артуром Уитни в 1989 году.
Этот интерпретатор умещался на одной странице (~80 строк).
Конечно, сейчас его нельзя рассматривать как хороший пример понятного и поддерживаемого кода. Но в те времена, когда память мерялась единицами килобайт они специально использовали короткие имена переменных.
Его ученик Роберт Хьюи (Robert Hui) потратил не менее недели, чтобы разобраться в нём. В процессе чего словил многочисленные инсайты.
Суть была в том, что вся низкоуровневая машинерия (работа с памятью, Си-синтаксис и прочее) была спрятана в макросах. И с каждым следующим макросом уровень абстракции поднимался всё выше.
Макросы — это не просто про то, чтобы не дублировать одинаковый код. Они могут быть использованы как метаязык, как средство для повышения уровня абстракции низкоуровневых языков.
Эта история достойна отдельной статьи. Айверсон доказал, что программирование — это не про объём кода, а про выразительность идей.
Его принципы:
Notation as a tool of thought — нотация как инструмент мышления;
Expressivity over verbosity — выразительность важнее многословия;
Composition over inheritance — композиция важнее наследования (за 30 лет до Go!).
Макросы в Go
Они реализованы по стандартам языка Си, поэтому я и привёл эту историю про одностраничный интерпретатор.
Они поддерживаются только в ассемблерных файлах.
Макрос — это не только автозамена одного определённого значения на другое. Они могут быть как функция — у них могут быть входящие параметры с понятными именами. И, соответственно, внутри макроса мы уже будет работать с именами переменных, а не именами регистров (AX, BX, CX, DX ...), которые нам ничего не говорят.
Макросы могут быть вложенными. Но в Go они не могут быть рекурсивными.
Есть ещё одна хитрость, когда мы можем использовать параметр для того, чтобы вызвать произвольный макрос.
Вот пример, когда на вход мы подаём любой обычный регистр, а на выходе получаем название младших 32-х бит этого регистра, с которыми можно потом выполнять отдельные инструкции.
Мы используем конструкцию синтеза нового имени макроса и его последующего вызова FIRST_PART##param.
Пример:
// В Go ассемблере для 32-битных операций используются EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP
// и R8D, R9D, R10D, R11D, R12D, R13D, R14D, R15D для расширенных регистров
#define REG_TO_32_AX EAX
#define REG_TO_32_BX EBX
#define REG_TO_32_CX ECX
#define REG_TO_32_DX EDX
#define REG_TO_32_SI ESI
#define REG_TO_32_DI EDI
#define REG_TO_32_BP EBP
#define REG_TO_32_R8 R8D
#define REG_TO_32_R9 R9D
#define REG_TO_32_R10 R10D
#define REG_TO_32_R11 R11D
#define REG_TO_32_R12 R12D
#define REG_TO_32_R13 R13D
#define REG_TO_32_R14 R14D
#define REG_TO_32_R15 R15D
// Вспомогательный макрос для получения 32-битной версии регистра
#define REG32(reg) REG_TO_32_##reg
Нам это не понадобится, но я это показал, что на макросах можно сделать что-то подобное вызову функции по указателю, когда будет вызвана заранее неизвестная функция (в нашем случае макрос) в зависимости от входящего параметра.
Пишем макросы для реализации умножения
Как я уже показывал ранее, первый макрос, который я использовал, спрятал все имена регистров процессора.
// Супермакрос, который принимает все свободные регистры
KMUL_MEGA1(AX, BX, CX, DX, SI, DI, R8, R9, R10, R11, R12, R13, R14, R15, BP)
В дальнейшем имена регистров уже не используются. Это делает код менее привязанным к архитек��уре и проще портируемым. Мы можем дальше использовать имена параметров у макросов как имена переменных, что облегчает понимание кода.
Вот полный ассемблерный код функции умножения 256-битных чисел (результат 512 бит).
В нём используются инструкции MULXQ для умножения из набора BMI2. Они позволяют записывать результат в любые регистры общего назначения (кроме регистров-источников)
#define MUL_ADD0(pymem, ymul, x0, x1, x2, x3, t0, t1, t2, t3, t4, t5, t6, t7) \
MOVQ pymem, ymul; \
MULXQ x0, t0, t5; \
MULXQ x1, t1, t6; \
MULXQ x2, t2, t7; \
MULXQ x3, t3, t4; \
ADDQ t5, t1; \
ADCQ t6, t2; \
ADCQ t7, t3; \
ADCQ $0, t4
#define MUL_ADD(pymem, ymul, pmres, x0, x1, x2, x3, t0, t1, t2, t3, t4, t5, t6, t7) \
MOVQ pymem, ymul; \
MULXQ x0, t4, t5; \
MULXQ x2, t6, t7; \
ADDQ t4, t0; \
MOVD t0, pmres; \
ADCQ t5, t1; \
ADCQ t6, t2; \
ADCQ t7, t3; \
MOVQ $0, t4; \
ADCQ $0, t4; \
MULXQ x1, t0, t5; \
MULXQ x3, t6, t7; \
ADDQ t0, t1; \
ADCQ t5, t2; \
ADCQ t6, t3; \
ADCQ t7, t4;
#define MUL_MEGA2(t0, py, pres, mreg, t1, t2, t3, t4, t5, t6, t7, x0, x1, x2, x3) \
MUL_ADD0( 0(py), mreg, x0, x1, x2, x3, t0, t1, t2, t3, t4, t5, t6, t7) \
MOVQ t0, 0(pres); \
MUL_ADD( 8(py), mreg, 8(pres), x0, x1, x2, x3, t1, t2, t3, t4, t0, t5, t6, t7) \
MUL_ADD( 16(py), mreg, 16(pres), x0, x1, x2, x3, t2, t3, t4, t0, t1, t5, t6, t7) \
MUL_ADD( 24(py), mreg, 24(pres), x0, x1, x2, x3, t3, t4, t0, t1, t2, t5, t6, t7) \
MOVQ t4, 32(pres); \
MOVQ t0, 40(pres); \
MOVQ t1, 48(pres); \
MOVQ t2, 56(pres);
#define MUL_MEGA1(px, py, pres, t0, t1, t2, t3, t4, t5, t6, t7, x0, x1, x2, x3) \
MOVQ (px), x0 \
MOVQ 8(px), x1 \
MOVQ 16(px), x2 \
MOVQ 24(px), x3 \
ZERO_MEM8(0(pres)) \
MUL_MEGA2(px, py, pres, t0, t1, t2, t3, t4, t5, t6, t7, x0, x1, x2, x3)
TEXT ·Mul(SB), NOSPLIT, $0-24
// Выделяем место на стеке:
// - 0 байт: нет сохранения callee-saved регистров
// Загружаем параметры
MOVQ x+8(FP), AX // AX = x (временный указатель)
MOVQ y+16(FP), BX // BX = y (указатель на y)
MOVQ z+0(FP), CX // CX = z (callee-saved, используем для указателя на res)
MUL_MEGA1(AX, BX, CX, DX, SI, DI, R8, R9, R10, R11, R12, R13, R14, R15, BP)
RET
Поскольку свободных регистров всего 15, то мы пишем разные версии макросов в зависимости от их заполнения. MUL_ADD0 — в этом макросе мы запускаем подряд 4 умножения в разных регистрах. Они будут выполнены параллельно, так как сейчас в процессорах несколько блоков для умножения.
MUL_ADD - доступных регистров уже меньше, и мы делаем умножения парами, а потом прибавляем частичные произведения к общей сумме, вновь освобождая регистры, а также выталкиваем последнее слово в результат, также освобождая регистры.
Отладка макросов
При возникновении ошибки компилятор будет ругаться на развёрнутый код, а не на конкретную строчку в вашем макросе. Пожалуй, это самый большой минус макросов. Это очень затрудняет отладку.
Поэтому иногда нужно посмотреть на развёрнутый код макросов. Это можно сделать так:
go tool asm -I $(go env GOROOT)/src/runtime -S -debug bint_mul_amd64.s
Развернуть макрос можно и Си-препроцессором, но вывод будет менее красивый:
cpp -I $(go env GOROOT)/src/runtime -I . bint_mul_amd64.s
Результаты тестов
Практика показывает, что тестов небольших функций стоит использовать астрономичекие значения количества повторов. Так как иначе любой параллельный процесс или всплеск вычислительной активности от браузера может внести существенные искажения в результаты.
Если у вас (даже на сильно многоядерном CPU) параллельно что-то считается, то результат может быть искажён КРАТНО.
Я использую для тестирования маленьких функций число повторов равное 100 миллионам.
Пример:
go test -tags asm -run ^$ -bench BenchmarkMul$ -benchtime=100000000x
Итак для наивной Go-реализации бенчмарк показал 22.81 нс/оп, а для нашей ассемблерной функции 8.83 нс/оп. Ассемблер быстрее в 2.58 раза.
Но если мы применим оптимизационные техники для Go из моей статьи «Выжимаем из Go скорость до последних наносекунд», то получим в Go скорость 10.76 нс/оп. Ассемблер будет всего на 22% быстрее. Это происходит за счёт того, что в Go-код MulOpt мы вручную включаем полный текст всех внутренних вызываемых функций (заинлюживаем). Это про функцию MulOpt отсюда.
Код при этом становится некрасивым, но значительно более быстрым.
Go и include
Это один из существенных недостатков Go — отсутствие директивы go:include. Фанаты языка оправдываются тем, что трудно будет делать defer.
Включение функций (инклюдинг) делается автоматически только для маленьких функций. Есть директива go:noinclude с помощью которой можно вовсе запретить включение.
Как по мне нужно было добавить go:include, но просто выдавать ошибку на функциях, где есть defer.
Это подтверждает мой тезис о том, что код на Го можно разогнать почти до уровня ассемблера. Но 22% выигрыша — это тоже немалый выигрыш, в особенности для «горячего» цикла.
Интересно, что использование GOAMD64=g3 не дало никакого выигрыша, что подтверждает то, что Go пока не имеет использовать инструкции MULXQ из набора BMI2 для продвинутого умножения.
В следующей статье мы рассмотрим особенности портирования и написания кода под архитектуру arm64.
Слава Ассемблеру!
© 2025 ООО «МТ ФИНАНС»