Рукописный ассемблер хрупкий: любая правка регистров вручную — риск опечатки, а поддержка под несколько архитектур быстро превращается в самостоятельную нагрузку.
Заключительная статья серии «Go assembly» разбирает avo — генератор .s-файлов на Go — и подводит честный итог: замер наивной версии против рукописного AVX2 через benchstat, а также чек-лист «когда asm НЕ надо», который возвращает к привратнику серии.
В статье
- Проблема рукописного asm — и что предлагает avo
- Тот же dot product через avo: генератор и обёртка
- Честный бенч: реальные числа amd64
- arm64/NEON: собрано и выверено, но не прогнано
- Стоимость владения: чем платишь за asm
- Когда НЕ надо: финальный чек-лист
- Выводы
Весь код и числа ниже — со стенда серии в cookbook-репозитории, каталог dotprod/avo/. Бенчмарки сняты на хосте AMD Ryzen 7 5800X3D, Go 1.26.3, windows/amd64 — те же условия, что в статье №3. Сквозной пример — всё то же скалярное произведение []float64 (dot product), которое серия провела от наивного Go через дизасм до рукописного AVX2. Теперь — тот же AVX2, но сгенерированный, и, наконец, замер, ради которого всё затевалось.
Проблема рукописного asm — и что предлагает avo
Статья №3 показала рукописный dot_amd64.s целиком — несколько десятков строк, где вручную розданы регистры, посчитаны FP-смещения, расставлены переходы. Он работает и он быстр. Но у ручного .s есть три системных недостатка, и все три вылезают не на первой строчке, а через полгода поддержки.
- Хрупкость. Каждый регистр, каждое смещение, каждая метка написаны руками. Опечатка в
VFMADD231PD Y2, Y1, Y0(переставить операнды) даёт не ошибку компиляции, а тихо неверный результат: его не поймают ни компилятор, ниgo vet— ловит только тест корректности. Компилятор типов больше не страхует:.sдля него — почти чёрный ящик. - Нечитаемость.
.sчитается как есть — без имён переменных, без структур, без выражений. Логика «загрузить блок, умножить-сложить, сдвинуть указатель» размазана по десятку мнемоник, и связать их в голове — отдельная работа при каждом возврате к коду. - Привязка к архитектуре и её диалекту. amd64 и arm64 — это два разных
.sс разными наборами инструкций (VFMADD231PDпротивVFMLA) и своими подводными камнями (та самая история сFADDP/VADDPна arm64). Каждую архитектуру пишут и сопровождают отдельно.
avo меняет способ производства .s. avo (mmcloughlin/avo) — это библиотека, на которой пишут программу на Go, а она печатает .s-файл. То есть ассемблер не пишут руками — его генерируют. Вместо того чтобы вручную выбирать регистр под аккумулятор, вы пишете acc := YMM() — и avo сам назначит конкретный Y-регистр; вместо ручного подсчёта ret+24(FP) вы пишете Store(low, ReturnIndex(0)) — и avo вычислит смещение из сигнатуры. Инструкции — обычные функции Go (VFMADD231PD(...)), а значит подчиняются автодополнению IDE, проверке типов на уровне Go и обычному рефакторингу.
Что это даёт по сравнению с ручным письмом:
- Типобезопасность на уровне генератора. Аргументы инструкций — Go-значения; перепутать регистр с непонятным типом сложнее, а сигнатуру avo знает и раскладывает
FP-смещения сам. - Читаемость. Генератор — это структурированный Go-код с именами (
blocks,acc,rem), циклами и метками-строками; читается ближе к алгоритму, чем к дампу инструкций. - Единый источник. Один генератор порождает
.sдетерминированно; правка алгоритма — правка Go-кода, а не ручная переразметка ассемблера.
Но avo — не бесплатный обед, и об этом честно в разделе про стоимость владения: появляется build-time зависимость (сам avo и его транзитивные пакеты), а сгенерированные артефакты (.s и стаб) всё равно надо коммитить в репозиторий и держать в актуальном состоянии. avo убирает боль ручного письма, но добавляет свою инфраструктуру.
Тот же dot product через avo: генератор и обёртка
Вот генератор целиком — это и есть «программа, которая печатает ассемблер». Обратите внимание на тег //go:build ignore в шапке: файл не входит в обычную сборку пакета, он запускается отдельной командой go run.
//go:build ignore
// gen.go — генератор AVX2-реализации dot product через avo.
// Запуск: go run gen.go -out dotavo_amd64.s -stubs dotavo_stub_amd64.go
package main
import (
. "github.com/mmcloughlin/avo/build"
. "github.com/mmcloughlin/avo/operand"
)
func main() {
TEXT("dotAvoAVX2", NOSPLIT, "func(a, b *float64, n int) float64")
Doc("dotAvoAVX2 — сгенерированное avo скалярное произведение (AVX2+FMA).")
a := Load(Param("a"), GP64())
b := Load(Param("b"), GP64())
n := Load(Param("n"), GP64())
acc := YMM()
VXORPD(acc, acc, acc)
blocks := GP64()
MOVQ(n, blocks)
SHRQ(Imm(2), blocks) // n / 4
Label("blockloop")
CMPQ(blocks, Imm(0))
JE(LabelRef("reduce"))
ya, yb := YMM(), YMM()
VMOVUPD(Mem{Base: a}, ya)
VMOVUPD(Mem{Base: b}, yb)
VFMADD231PD(yb, ya, acc)
ADDQ(Imm(32), a)
ADDQ(Imm(32), b)
DECQ(blocks)
JMP(LabelRef("blockloop"))
Label("reduce")
low := XMM()
high := XMM()
VEXTRACTF128(Imm(1), acc, high)
VADDPD(high, acc.AsX(), low)
VHADDPD(low, low, low)
Label("tail")
rem := GP64()
MOVQ(n, rem)
ANDQ(Imm(3), rem)
Label("tailloop")
CMPQ(rem, Imm(0))
JE(LabelRef("done"))
xa, xb := XMM(), XMM()
VMOVSD(Mem{Base: a}, xa)
VMOVSD(Mem{Base: b}, xb)
VFMADD231SD(xb, xa, low)
ADDQ(Imm(8), a)
ADDQ(Imm(8), b)
DECQ(rem)
JMP(LabelRef("tailloop"))
Label("done")
Store(low, ReturnIndex(0))
VZEROUPPER()
RET()
Generate()
}Читается почти как псевдокод алгоритма. Load(Param("a"), GP64()) — «возьми аргумент a и положи в какой-нибудь 64-битный регистр общего назначения»; какой именно — решит avo. acc := YMM() — «дай YMM-регистр под аккумулятор». Горячий цикл — VMOVUPD двух блоков и VFMADD231PD(yb, ya, acc) — тот же, что мы писали руками, но регистры здесь Go-значения ya, yb, acc, а не буквы Y1, Y2, Y0. Метки — строки (Label("blockloop"), JE(LabelRef("reduce"))), переходы avo сведёт сам. Финал — Store(low, ReturnIndex(0)) вместо ручного VMOVSD X0, ret+24(FP): смещение возврата avo посчитает из сигнатуры, объявленной в TEXT(...). Обязательный VZEROUPPER() перед RET() — ровно та же дисциплина, что в рукописной версии; avo её не навязывает, помнить про неё всё ещё нужно вам.
Сгенерированный .s подключается к пакету обычной Go-обёрткой — она диспетчеризует так же, как рукописный Dot из статьи №3:
//go:build amd64
// Package avo содержит avo-сгенерированную версию dot product.
package avo
//go:generate go run gen.go -out dotavo_amd64.s -stubs dotavo_stub_amd64.go
import "golang.org/x/sys/cpu"
// DotAvo — публичная обёртка над сгенерированным ядром.
func DotAvo(a, b []float64) float64 {
if len(a) != len(b) {
panic("dotprod/avo: length mismatch")
}
if len(a) == 0 {
return 0
}
if cpu.X86.HasAVX2 && cpu.X86.HasFMA {
return dotAvoAVX2(&a[0], &b[0], len(a))
}
return dotAvoGeneric(a, b)
}
func dotAvoGeneric(a, b []float64) float64 {
var s float64
for i := range a {
s += a[i] * b[i]
}
return s
}Структура знакомая: проверка длины, ранний выход на пустом входе, рантайм-детект фич cpu.X86.HasAVX2 && cpu.X86.HasFMA и pure-Go fallback dotAvoGeneric. Директива //go:generate go run gen.go -out dotavo_amd64.s -stubs dotavo_stub_amd64.go фиксирует, как перегенерировать артефакты: go generate ./... запустит gen.go, который перепишет dotavo_amd64.s (тело) и dotavo_stub_amd64.go (стаб-объявление func dotAvoAVX2(...), по которому Go видит сигнатуру).
Одна честная деталь, которую нельзя замазать: строка паники здесь другая. Рукописный пакет dotprod паникует строкой "dotprod: length mismatch", а avo-обёртка — "dotprod/avo: length mismatch", с суффиксом /avo. Это два разных пакета с двумя разными точками входа (Dot и DotAvo), и их сообщения об ошибке не идентичны — стоит помнить, если сравниваете поведение бок о бок или пишете тесты на текст паники.
А вот что совпадает — горячее ядро. Ключевые AVX/FMA-инструкции сгенерированного dotavo_amd64.s — те же, что в рукописном dot_amd64.s из статьи №3: тот же VXORPD под аккумулятор, тот же VFMADD231PD Y2, Y1, Y0 в горячем цикле, та же свёртка VEXTRACTF128 → VADDPD → VHADDPD, тот же скалярный VFMADD231SD в хвосте, тот же VZEROUPPER перед RET. Обвязку — форму цикла (avo ставит проверку счётчика сверху, а не снизу), раскладку регистров, кодировку финального store — генератор выбирает свою; но арифметика горячего пути идентична до инструкции. Это и есть весь смысл упражнения: avo — не «другой, магически более быстрый» способ, а другой способ написать то же самое. Раз горячий путь тот же, производительность практически совпадает — что подводит нас к замеру.
Честный бенч: реальные числа amd64
Вся серия строилась на обещании: числа настоящие, снятые на реальном железе, без округлений в свою пользу. Вот они. Бенчмарк гоняет две реализации — наивную dotGeneric (тот самый идиоматичный Go-цикл, который компилятор не векторизует) и AVX2 — на четырёх размерах входа: n = 8, 256, 1024, 65536. Шесть прогонов на комбинацию, сведённых benchstat:
goos: windows
goarch: amd64
pkg: tech.khorost/go-asm-cookbook/dotprod
cpu: AMD Ryzen 7 5800X3D 8-Core Processor
│ reading/listings/bench.txt │
│ sec/op │
DotGeneric/n=8-16 7.200n ± 29%
DotGeneric/n=256-16 252.6n ± 6%
DotGeneric/n=1024-16 979.2n ± 5%
DotGeneric/n=65536-16 64.03µ ± 6%
DotAVX2/n=8-16 4.655n ± 4%
DotAVX2/n=256-16 62.92n ± 3%
DotAVX2/n=1024-16 253.2n ± 6%
DotAVX2/n=65536-16 16.51µ ± 1%
geomean 329.7nТот же прогон в терминах пропускной способности (B/s) — сколько байт входных данных обрабатывается в секунду:
│ reading/listings/bench.txt │
│ B/s │
DotGeneric/n=8-16 16.65Gi ± 39%
DotGeneric/n=256-16 15.11Gi ± 6%
DotGeneric/n=1024-16 15.58Gi ± 4%
DotGeneric/n=65536-16 15.25Gi ± 6%
DotAVX2/n=8-16 25.60Gi ± 4%
DotAVX2/n=256-16 60.63Gi ± 3%
DotAVX2/n=1024-16 60.25Gi ± 5%
DotAVX2/n=65536-16 59.15Gi ± 1%Обе реализации — 0 B/op, 0 allocs/op на всех размерах: ни generic-цикл, ни AVX2 не аллоцируют (//go:noescape из статьи №3 сделал своё дело). Разница — только в скорости. Посчитаем ускорение (generic ÷ AVX2) по колонке sec/op:
| n | DotGeneric | DotAVX2 | Ускорение |
|---|---|---|---|
| 8 | 7.200 ns | 4.655 ns | 1.5467× |
| 256 | 252.6 ns | 62.92 ns | 4.0146× |
| 1024 | 979.2 ns | 253.2 ns | 3.8672× |
| 65536 | 64.03 µs | 16.51 µs | 3.8783× |
И вот здесь — честный вывод серии, ради которого писались все три предыдущие статьи:
- На больших векторах (
n ≥ 256) выигрыш сильный и стабильный: ~3.87–4.01×. Это близко к теоретическому потолку ×4 —YMM-регистр обрабатывает четыреfloat64за одну FMA-операцию, и на достаточно длинных данных фиксированные накладные расходы амортизируются, оставляя почти чистое четырёхкратное ускорение. Погрешность на этих строках плотная (±6%и лучше), числам можно верить. - На маленьком
n = 8выигрыш слабый и шумный: всего ~1.55×. И это не случайность, а закономерность. На восьми элементах фиксированная стоимость вызова — настройка цикла, горизонтальная свёртка (VEXTRACTF128→VADDPD→VHADDPD), обработка хвоста — не успевает окупиться самим векторным умножением: полезной работы слишком мало. Мало того, погрешность здесь огромна —± 29%уDotGeneric/n=8против± 4%уDotAVX2/n=8, — так что и сама цифра 1.55× шумная, полагаться на неё как на точную нельзя. Единственный честный вывод для малых входов: ощутимого выигрыша нет.
Отсюда правило, которое серия просит забрать с собой: нельзя заявлять «ускорение в 4 раза» без оговорки про размер входа. Четырёхкратное ускорение существует — но только когда n достаточно велик, чтобы амортизировать фиксированную стоимость вызова и свёртки. На типичном коротком векторе весь труд по написанию AVX2 может дать полтора раза в пределах шума — то есть практически ничего. Замер это показал; интуиция бы соврала в обе стороны.
Про avo-версию отдельных бенчмарк-чисел здесь нет намеренно — и это тоже честность, а не пробел. Как показано выше, у сгенерированного dotAvoAVX2 ключевые арифметические инструкции горячего пути — те же, что в рукописном dotAVX2 (форма цикла и раскладка регистров у avo свои); печатать «независимый» бенчмарк по сути того же горячего пути значило бы измерять шум и выдавать его за отдельный результат. avo — способ произвести тот же AVX2, а не другой алгоритм, поэтому его производительность ожидаемо того же порядка, что и в колонке DotAVX2 выше.
arm64/NEON: собрано и выверено, но не прогнано
Серия писала и NEON-версию для arm64 — тот же алгоритм на 128-битных V-регистрах (два float64 за такт вместо четырёх). Здесь придётся сказать прямо и без обиняков: чисел по arm64/NEON в этой статье нет, и это не забывчивость.
dot_arm64.s на стенде был кросс-собран и проверен go vet с amd64-хоста (GOARCH=arm64 go build ./... && GOARCH=arm64 go vet ./...), а семантика каждой инструкции сверена построчно с документацией ассемблера (cmd/internal/obj/arm64/doc.go) и реальным использованием в стандартной библиотеке (например, GOROOT/src/math/exp_arm64.s). Но на реальном ARM-железе он ни разу не исполнялся — arm64-процессора (или эмулятора) под рукой не было. А раз код не исполнялся, то и бенчмарка не было; выдумывать «примерно такое же ускорение, наверное ×2» — ровно тот сорт нечестности, которого серия избегает от первой статьи.
Поэтому про две архитектуры серия говорит на разных уровнях уверенности, и подменять один другим нельзя:
- amd64 — «работает, вот бенчмарки»: числа в разделе выше сняты на реальном Ryzen 7 5800X3D, воспроизводимы командой
go test -bench . ./dotprod/. - arm64 — «корректно собирается, проходит
vet, семантически выверено по документации»: но не измерено. Есть ли на NEON выигрыш, и какой именно — открытый вопрос до прогона на железе.
Если у вас есть ARM-машина (Apple Silicon, Graviton, Ampere, Raspberry Pi 5) — склонируйте cookbook и прогоните go test -bench . ./dotprod/ на ней сами. Это и есть приглашение: числа для arm64 существуют только там, где кто-то их реально снял, — и здесь этого честно не сделано.
Стоимость владения: чем платишь за asm
Допустим, замер показал сильное ускорение на ваших реальных размерах входа, и asm оправдан. Даже тогда — цена не заканчивается на «написать и закоммитить». У ассемблера (что рукописного, что avo-генерированного) есть постоянная стоимость владения, которую платят годами:
- Portability-долг. Каждая архитектура — отдельная реализация. amd64 и arm64 — это два
.s(или два прогона генератора), плюс обязательный pure-Go fallback под всё неучтённое (правило из статьи №3). Появится новая целевая платформа — под неё либо пишут asm заново, либо она молча катится на медленный generic. Один Go-цикл покрыл бы всё разом. - Привязка к версии Go и тулчейну. Ассемблер живёт близко к внутренностям: регистровый ABI, набор мнемоник, поведение
go vet. Всё это между версиями Go в принципе может меняться. avo добавляет свой пласт: это build-time зависимость (github.com/mmcloughlin/avo), которая должна поддерживать вашу версию Go и нужные инструкции; апгрейд Go иногда тянет за собой апгрейд avo и перегенерацию артефактов. - Кто это будет сопровождать. Рукописный AVX2 читает и правит не всякий в команде — это узкий навык. avo смягчает (генератор — обычный Go), но не убирает: чтобы понять, что генерирует
gen.go, всё равно нужно знать, что делаетVFMADD231PDи зачемVZEROUPPER. Уйдёт человек, который это писал, — останется код, который боятся трогать. - Читаемость и артефакты в репозитории. Даже с avo в репозиторий коммитятся сгенерированные
.sи стаб (с шапкойCode generated … DO NOT EDIT). Их нельзя править руками — только перегенерировать; значит, в дереве живут файлы, которые надо держать синхронными с генератором и не забыть перегенерировать при правках. Плюс сам генератор как ещё один файл, который надо понимать.
Вывод не «asm — зло», а трезвый: ускорение в 4× на горячем пути может стоить своих денег; те же 4× на пути, который зовут дважды за запрос, — почти наверняка нет. Стоимость владения постоянна и не зависит от того, помогает asm или нет; оправдывает её только доказанный, крупный, устойчивый выигрыш на реальной нагрузке.
Когда НЕ надо: финальный чек-лист
Серия открывалась привратником: сначала докажи горячую точку профилем, проверь, не векторизует ли уже компилятор, померь — и только потом пиши asm. Закрывается она тем же привратником, но с другой стороны: вот сигналы, что asm писать не надо. Достаточно одного из них, чтобы остановиться.
горячую точку?"} Q1 -->|"нет"| NO["Не писать asm —
оставить чистый Go"] Q1 -->|"да"| Q2{"Компилятор уже векторизует
или есть интринсик?"} Q2 -->|"да"| NO Q2 -->|"нет"| Q3{"Бенчмарк дал кратное
ускорение на реальных
размерах входа?"} Q3 -->|"нет, только проценты"| NO Q3 -->|"да, в разы"| Q4{"Команда потянет
сопровождение asm?"} Q4 -->|"нет"| NO Q4 -->|"да"| YES["Писать asm —
руками или через avo"]
flowchart TD
S(["Задумались про asm"]) --> Q1{"Профиль доказал
горячую точку?"}
Q1 -->|"нет"| NO["Не писать asm —
оставить чистый Go"]
Q1 -->|"да"| Q2{"Компилятор уже векторизует
или есть интринсик?"}
Q2 -->|"да"| NO
Q2 -->|"нет"| Q3{"Бенчмарк дал кратное
ускорение на реальных
размерах входа?"}
Q3 -->|"нет, только проценты"| NO
Q3 -->|"да, в разы"| Q4{"Команда потянет
сопровождение asm?"}
Q4 -->|"нет"| NO
Q4 -->|"да"| YES["Писать asm —
руками или через avo"]
- Место вызова не горячее. Профиль не показал эту функцию в топе — asm не даст ничего, что заметит пользователь, а стоимость владения вы заплатите полную. Нет замеренной горячей точки — нет разговора. Профилирование — отдельная статья серии Go, и это буквально первый шаг, а не последний.
- Компилятор уже справляется — или есть готовый интринсик. Если Go уже векторизует нужный цикл, или в stdlib/
math/math/bitsесть интринсик под вашу операцию — ваш рукописный asm в лучшем случае повторит готовое, в худшем окажется медленнее и с багами. Проверьте дизасм (как в статье №2) до того, как писать. (В нашем случае компилятор циклs += a[i]*b[i]не векторизует — потому пример и годится; но это надо было доказать, а не предположить.) - Ускорение — не крупный множитель. Замер дал +15%, а не ×4? На фоне стоимости владения такой выигрыш почти никогда не окупается. asm имеет смысл, когда речь о разах, а не о процентах, — и когда эти разы держатся на реальных размерах входа, а не только на синтетическом максимуме (вспомните ×1.55 на
n=8против ×4 наn≥256— замер выше). - Нет замеренной горячей точки вообще. Тонкое отличие от первого пункта: иногда asm пишут «на всякий случай, вдруг пригодится», без единого профиля и бенчмарка. Это оптимизация под ощущение, а не под цифру. Без бенчмарка вы даже не заметите, если сделаете медленнее.
- Команда не сможет это сопровождать. Если поддержать рукописный ассемблер сможете только вы — вы создаёте зону, куда остальные боятся заходить. Уйдёте в отпуск (или из компании) — останется код, который никто не рискнёт тронуть. Иногда честнее оставить чуть более медленный, но понятный всей команде Go.
Если хоть один пункт про вас — ответ «не писать asm». А если ни один — тогда порядок действий из первой статьи в полном виде: профиль → проверить автовекторизацию/интринсики → убрать аллокации и поправить алгоритм → написать бенчмарк, который поймает регрессию → и только теперь спускаться в .s (руками или через avo). Ассемблер — последний инструмент под доказанную цифру, а не первый под азарт.
Выводы
- avo генерирует
.sиз программы на Go — регистры,FP-смещения и метки считает сам. Это убирает хрупкость и нечитаемость ручного письма, но добавляет свою цену: build-time зависимость и сгенерированные артефакты (.s+ стаб), которые всё равно коммитятся и держатся в актуальном виде. - Ключевые инструкции горячего пути у сгенерированного ядра — те же, что в рукописном AVX2 из статьи №3: тот же
VFMADD231PD, та же свёртка, тот жеVZEROUPPER(форма цикла и регистры avo выбирает свои). avo — другой способ написать то же самое, а не более быстрый алгоритм; поэтому производительность ожидаемо близка. - Честный замер (Ryzen 7 5800X3D, Go 1.26.3, windows/amd64): ~3.87–4.01× на
n ≥ 256(близко к теоретическому ×4), но всего ~1.55× и в пределах шума наn = 8. Заявлять плоское «×4» без оговорки про размер входа — нечестно: на коротких векторах фиксированная стоимость вызова и свёртки съедает выигрыш. - arm64/NEON собран, прошёл
vetи семантически выверен, но НЕ исполнен на железе — поэтому чисел по нему здесь нет и быть не может. Есть ARM — прогоните cookbook сами. - Стоимость владения asm постоянна (portability-долг, привязка к версии Go/avo, узость навыка, генерируемые артефакты) и не зависит от того, помогает он или нет. Оправдывает её только доказанный крупный устойчивый выигрыш.
- Чек-лист «когда НЕ надо»: редкое место вызова; компилятор уже векторизует или есть интринсик; ускорение не кратное; нет замеренной горячей точки; команда не потянет сопровождение. Хоть один пункт — стоп. Возврат к привратнику: профиль → автовекторизация → замер → и только потом asm.
На этом серия «Go assembly» закрыта: от «где вообще живёт asm и зачем» через чтение дизассемблера и рукописный AVX2/NEON — к генерации и честной цифре. Если после всех четырёх статей ваш вывод — «в моём случае asm не нужен», серия сработала правильно: это и есть чаще всего верный ответ. Смежная тема, где та же дисциплина «сначала померь» решает не меньше, — конкурентность в Go. Весь код серии — в cookbook-репозитории.
Комментарии