В этой части (первая тут) мы поговорим о структуре 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
Они поддерживаются только в ассемблерных файлах. Как я уже показывал ранее, первый макрос, который я использовал, спрятал все имена регистров процессора.
// Супермакрос, который принимает все свободные регистры
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; /* MOVSD t0, pmres; попробуем MOVQ для XMM */
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, X0;
MUL_ADD( 8(py), mreg, X1, x0, x1, x2, x3, t1, t2, t3, t4, t0, t5, t6, t7)
MUL_ADD( 16(py), mreg, X2, x0, x1, x2, x3, t2, t3, t4, t0, t1, t5, t6, t7)
MUL_ADD( 24(py), mreg, X3, 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);
// Используем регистры X0-X3 как временное хранилище
#define MUL_MEGA1(px, py, pres, t0, t1, t2, t3, t4, t5, t6, t7, x0, x1, x2, x3)
MOVD X0, x0;
MOVD X1, x1;
MOVD X2, x2;
MOVD X3, x3;
ZERO_MEM8(0(pres))
MUL_MEGA2(px, py, pres, t0, t1, t2, t3, t4, t5, t6, t7, x0, x1, x2, x3)
TEXT ·MulAsm(SB), NOSPLIT, $0-24
// Выделяем место на стеке:
// - 0 байт: нет сохранения callee-saved регистров
// Загружаем параметры
MOVQ x+0(FP), AX // AX = x (временный указатель)
MOVQ y+8(FP), BX // BX = y (указатель на y)
MOVQ res+16(FP), CX // CX = res (используем для указателя на 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 – доступных регистров уже меньше, и мы делаем умножения парами, а потом прибавляем частичные произведения к общей сумме, вновь освобождая регистры, а также выталкиваем последнее слово в результат, также освобождая регистры.
Результаты тестов
Практика показывает, что тестов небольших функций стоит использовать астрономичекие значения количества повторов. Так как иначе любой параллельный процесс или всплеск вычислительной активности от браузера может внести существенные искажения в результаты.
Если у вас (даже на сильно многоядерном 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-код мы вручную включаем полный текст всех внутренних вызываемых функций (заинлюживаем). Код при этом становится некрасивым, но значительно более быстрым.
Это подтверждает мой тезис о том, что код на Го можно разогнать почти до уровня ассемблера. Но 22% выигрыша — это тоже немалый выигрыш, в особенности для «горячего» цикла.
Интересно, что использование GOAMD64=g3 не дало никакого выигрыша, что подтверждает то, что Go пока не имеет использовать инструкции MULXQ из набора BMI2 для продвинутого умножения.
В следующей статье мы рассмотрим особенности портирования и написания кода под архитектуру arm64.
Слава Ассемблеру!
© 2025 ООО «МТ ФИНАНС»
Автор: inetstar


