avo, генерация и честный замер: а стоило ли

Генерация Go-ассемблера через avo вместо ручного письма, честный замер наивного Go против рукописного AVX2 через benchstat и чек-лист «когда asm не нужен» — возврат к привратнику серии

Рукописный ассемблер хрупкий: любая правка регистров вручную — риск опечатки, а поддержка под несколько архитектур быстро превращается в самостоятельную нагрузку.

Заключительная статья серии «Go assembly» разбирает avo — генератор .s-файлов на Go — и подводит честный итог: замер наивной версии против рукописного AVX2 через benchstat, а также чек-лист «когда asm НЕ надо», который возвращает к привратнику серии.

gopher взвешивает на весах светящийся куб «FAST SIMD» против гири «COST OF OWNERSHIP», на подставке надпись «worth it?»; слева машина-генератор avo печатает ленту .s; справа дашборд benchmarks BEFORE (Pure Go) vs AFTER (asm) с подписью «no numbers here, only trends matter»; таблички «measure, then decide» и «data over dogma»

В статье

Весь код и числа ниже — со стенда серии в 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 в горячем цикле, та же свёртка VEXTRACTF128VADDPDVHADDPD, тот же скалярный 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×. И это не случайность, а закономерность. На восьми элементах фиксированная стоимость вызова — настройка цикла, горизонтальная свёртка (VEXTRACTF128VADDPDVHADDPD), обработка хвоста — не успевает окупиться самим векторным умножением: полезной работы слишком мало. Мало того, погрешность здесь огромна — ± 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 писать не надо. Достаточно одного из них, чтобы остановиться.

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"]

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 можно, только пройдя все четыре ворот
  • Место вызова не горячее. Профиль не показал эту функцию в топе — 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-репозитории.

Обсуждение в Telegram

Присоединиться →

Комментарии