Когда дизасм доказал, что компилятор не векторизует горячий цикл, и решение писать asm подтверждено — встаёт вопрос ABI: как Go передаёт аргументы в регистры, что такое стаб-функция и как устроен .s-файл.
Третья статья серии «Go assembly» — практическая: пишем dot product на AVX2 руками для amd64, переносим на arm64/NEON и обязательно добавляем pure-Go fallback для архитектур, которые не учли.
В статье
- Регистровый ABI: почему рукописный asm — это ABI0
- Структура пакета: стаб,
.s-файлы, fallback - Пишем AVX2 руками: разбор
dot_amd64.sпо блокам - Масштаб на arm64/NEON: тот же алгоритм, другие мнемоники
- Как это ломается:
go vetи кросс-сборка - Выводы
Весь код ниже — со стенда серии в cookbook-репозитории, каталог dotprod/. Числа сняты на хосте AMD Ryzen 7 5800X3D, Go 1.26.3, windows/amd64. Сквозной пример — всё то же скалярное произведение []float64 (dot product), знакомое по статье №1 и разобранное в дизасме в статье №2; там мы доказали, что компилятор его не векторизует. Теперь — векторизуем сами.
Регистровый ABI: почему рукописный asm — это ABI0
Прежде чем писать первую строчку .s, нужно понять одну вещь про то, как Go передаёт аргументы в функции. С Go 1.17 это работает не так, как многие ожидают, и именно от этого недопонимания рождаются самые загадочные ошибки в рукописном ассемблере.
Два ABI. ABI (Application Binary Interface) — это соглашение о том, где физически лежат аргументы и результаты при вызове функции. В Go их два:
ABIInternal— регистровый ABI (regabi), появившийся в Go 1.17. Обычный Go-код передаёт аргументы и результаты через регистры (AX,BX,CX, … на amd64), а не через стек. Это то, что вы видели в дизасмеdotGenericв статье №2: в заголовке функции стоял флагABIInternal, а аргументы приезжали в регистрах.ABI0— старое, «доregabi» соглашение: все аргументы и результаты — на стеке, адресуются через псевдорегистрFP(a+0(FP),b+8(FP)и так далее).
Ключевой факт: рукописный .s пишется под ABI0. Когда вы объявляете аргументы через FP-смещения, ассемблер Go считает функцию ABI0. То есть внутри .s-файла мы живём в мире «аргументы на стеке» — ровно как до Go 1.17. Это не устаревший способ, а осознанное решение: FP-смещения стабильны, понятны и не зависят от того, какие регистры regabi выделил под конкретную сигнатуру.
Как ABI0-функция стыкуется с regabi-вызовом. Возникает вопрос: если Go-код вызывает функцию по ABIInternal (аргументы в регистрах), а тело написано под ABI0 (аргументы на стеке) — как они находят друг друга? Ответ: компилятор сам генерирует переходник (wrapper). Увидев Go-объявление функции без тела (стаб, о нём ниже), он порождает обёртку, которая берёт аргументы из регистров ABIInternal, раскладывает их на стек по ABI0 и вызывает ваше рукописное тело. Именно поэтому в дизасме из статьи №2 символ рукописной функции назывался dotprod.dotAVX2.abi0 — суффикс .abi0 и есть маркер того, что это ABI0-тело, к которому компилятор пристроил ABIInternal-мост.
Практический вывод простой и приятный: вам не нужно думать про регистровый ABI, когда вы пишете .s. Пишете FP-смещения по-старому, а мост компилятор построит сам. Вся сложность regabi спрятана за автогенерируемым переходником.
Паттерн «стаб на Go + .s-файл». Рукописную функцию нельзя вызвать из Go напрямую — у неё нет Go-тела, компилятору нечего проверять по типам. Поэтому её объявляют дважды:
- В
.go-файле — стаб: сигнатура функции без тела. Это то, что видит компилятор Go: имя, типы аргументов, тип результата. - В
.s-файле — тело: собственно ассемблер под тем же именем.
Линковщик сводит их по имени символа. Стаб dotAVX2 из стенда выглядит так:
//go:noescape
func dotAVX2(a, b *float64, n int) float64Тело без { … } — это и есть стаб: «функция с таким именем и такой сигнатурой существует, тело ищи в .s-файле пакета». Сигнатура стаба и заголовок в .s обязаны совпадать до последнего байта — иначе FP-смещения разъедутся, и go vet (или, хуже, рантайм) это заметит. К тому, как именно ломается расхождение, вернёмся в конце.
//go:noescape — почему он тут почти обязателен. Директива говорит компилятору: указатели-аргументы этой функции не убегают (не escape) — функция не сохранит их где-то, что переживёт вызов. Тонкость в том, что у стаба нет Go-тела, поэтому анализ убегания (escape analysis) не может ничего доказать сам и вынужден действовать консервативно: раз не доказано обратное — считать, что указатели убегают. А «убегают» означает, что резервные массивы a и b придётся размещать на куче. Для функции, которую зовут в горячем цикле, это лишние аллокации на пустом месте. //go:noescape снимает эту консервативность вручную — и бенчмарк из статьи №4 подтверждает результат: 0 B/op, ноль аллокаций. Ответственность за корректность директивы — на вас: если рукописный код всё-таки где-то сохранит переданный указатель, //go:noescape превратится в тихую порчу памяти.
Структура пакета: стаб, .s-файлы, fallback
Одна и та же функция Dot должна собираться на любой архитектуре: на amd64 — через AVX2, на arm64 — через NEON, а на всём остальном — через чистый Go. Механизм, который это разводит, — build tags и суффиксы имён файлов. Компилятор Go включает в сборку ровно те файлы, чьи теги подходят под целевую платформу, и игнорирует остальные.
Раскладка пакета dotprod:
выбор по build-тегам"} B -->|"GOARCH=amd64"| A1 B -->|"GOARCH=arm64"| R1 B -->|"иначе"| P subgraph amd["сборка под amd64"] A1["dot_amd64.go
Dot + стаб dotAVX2
//go:build amd64"] A2["dot_amd64.s
тело dotAVX2 — AVX2"] A1 -. "стаб ↔ тело" .-> A2 end subgraph arm["сборка под arm64"] R1["dot_arm64.go
Dot + стаб dotNEON
//go:build arm64"] R2["dot_arm64.s
тело dotNEON — NEON"] R1 -. "стаб ↔ тело" .-> R2 end subgraph rest["сборка под всё остальное"] P["dot_purego.go
Dot → dotGeneric
//go:build !amd64 && !arm64"] end A1 -->|"fallback без AVX2/FMA"| G["dot_generic_impl.go
dotGeneric — golden, без тегов
всегда в сборке"] R1 -.->|"в сборке, но не вызывается"| G P -->|"вызывает"| G
flowchart TD
B{"go build:
выбор по build-тегам"}
B -->|"GOARCH=amd64"| A1
B -->|"GOARCH=arm64"| R1
B -->|"иначе"| P
subgraph amd["сборка под amd64"]
A1["dot_amd64.go
Dot + стаб dotAVX2
//go:build amd64"]
A2["dot_amd64.s
тело dotAVX2 — AVX2"]
A1 -. "стаб ↔ тело" .-> A2
end
subgraph arm["сборка под arm64"]
R1["dot_arm64.go
Dot + стаб dotNEON
//go:build arm64"]
R2["dot_arm64.s
тело dotNEON — NEON"]
R1 -. "стаб ↔ тело" .-> R2
end
subgraph rest["сборка под всё остальное"]
P["dot_purego.go
Dot → dotGeneric
//go:build !amd64 && !arm64"]
end
A1 -->|"fallback без AVX2/FMA"| G["dot_generic_impl.go
dotGeneric — golden, без тегов
всегда в сборке"]
R1 -.->|"в сборке, но не вызывается"| G
P -->|"вызывает"| G
dot_generic_impl.go— эталонная функцияdotGeneric(golden), без build tags: компилируется всегда, на всех архитектурах. Служит и образцом корректности в тестах, и телом fallback-а.dot_amd64.go— диспетчерDotдля amd64 + стабdotAVX2. Тег//go:build amd64.dot_amd64.s— рукописное телоdotAVX2(AVX2). Суффикс_amd64в имени файла — сам по себе build-tag: файл берётся только под amd64.dot_arm64.go— диспетчерDotдля arm64 + стабdotNEON. Тег//go:build arm64.dot_arm64.s— рукописное телоdotNEON(NEON). Суффикс_arm64— тоже неявный тег.dot_purego.go— fallbackDotдля всех прочих архитектур. Тег//go:build !amd64 && !arm64.
Как теги выбирают ровно один Dot. Функция Dot объявлена в трёх файлах (dot_amd64.go, dot_arm64.go, dot_purego.go), но их build-теги взаимоисключающие: под любую конкретную архитектуру условие истинно ровно у одного файла. Собираем под amd64 — берётся dot_amd64.go (и его .s), два других отсекаются. Под arm64 — dot_arm64.go. Под riscv64, скажем, — ни amd64, ни arm64 не истинны, поэтому !amd64 && !arm64 даёт Dot из dot_purego.go. Двойного объявления не возникает никогда — что и требуется, иначе линковщик упал бы на дубликате символа.
Вот диспетчер для amd64 целиком:
//go:build amd64
package dotprod
import "golang.org/x/sys/cpu"
//go:noescape
func dotAVX2(a, b *float64, n int) float64
// Dot использует AVX2+FMA, если процессор их поддерживает, иначе — generic.
func Dot(a, b []float64) float64 {
if len(a) != len(b) {
panic("dotprod: length mismatch")
}
if len(a) == 0 {
return 0
}
if cpu.X86.HasAVX2 && cpu.X86.HasFMA {
return dotAVX2(&a[0], &b[0], len(a))
}
return dotGeneric(a, b)
}Здесь два защитных слоя. Во-первых, проверка фич процессора в рантайме: cpu.X86.HasAVX2 && cpu.X86.HasFMA (из golang.org/x/sys/cpu) спрашивает у железа, поддерживает ли оно AVX2 и FMA. Не всякий amd64-процессор их имеет — старые CPU без AVX2 существуют, и вызвать на них VFMADD231PD означало бы SIGILL (недопустимая инструкция). Если фич нет — тихо откатываемся на dotGeneric. Во-вторых, &a[0] и &b[0] — передаём asm-функции указатели на начало данных и длину; это то самое место, где важен //go:noescape из прошлого раздела.
Обязательный pure-Go fallback. Файл dot_purego.go — не роскошь, а условие того, что пакет вообще соберётся под неучтённую архитектуру:
//go:build !amd64 && !arm64
package dotprod
// Dot вычисляет скалярное произведение a и b (равной длины).
// На архитектурах без asm-реализации используется чистый Go.
func Dot(a, b []float64) float64 {
if len(a) != len(b) {
panic("dotprod: length mismatch")
}
return dotGeneric(a, b)
}Уберите этот файл — и сборка под, скажем, riscv64 или wasm упадёт: Dot не определён ни в одном подходящем файле, потому что dot_amd64.go и dot_arm64.go отсечены своими тегами. Тег !amd64 && !arm64 ловит ровно «все остальные». Это правило без исключений: у каждой публичной функции, реализованной в asm, должен быть чистый Go-путь под неучтённые архитектуры — иначе вы молча ломаете сборку на всём, что не предусмотрели. Разберём это подробнее в разделе про поломки.
Пишем AVX2 руками: разбор dot_amd64.s по блокам
Теперь — само тело. Вот dot_amd64.s целиком, как он лежит на стенде; ниже разберём по блокам.
#include "textflag.h"
// func dotAVX2(a, b *float64, n int) float64
// Аргументы: a+0(FP), b+8(FP), n+16(FP); результат ret+24(FP).
TEXT ·dotAVX2(SB), NOSPLIT, $0-32
MOVQ a+0(FP), AX // AX = &a[0]
MOVQ b+8(FP), BX // BX = &b[0]
MOVQ n+16(FP), CX // CX = n
VXORPD Y0, Y0, Y0 // Y0 = аккумулятор (4 частичные суммы)
MOVQ CX, DX
SHRQ $2, DX // DX = n / 4 (полных блоков по 4)
JZ tail
blockloop:
VMOVUPD (AX), Y1 // Y1 = a[i..i+3]
VMOVUPD (BX), Y2 // Y2 = b[i..i+3]
VFMADD231PD Y2, Y1, Y0 // Y0 += Y1 * Y2 (порядок операндов проверяется тестом корректности)
ADDQ $32, AX
ADDQ $32, BX
DECQ DX
JNZ blockloop
// горизонтальная свёртка Y0 (4 double) -> X0[0]
VEXTRACTF128 $1, Y0, X1
VADDPD X1, X0, X0
VHADDPD X0, X0, X0 // X0[0] = сумма всех четырёх
tail:
ANDQ $3, CX // CX = n % 4 (остаток)
JZ done
tailloop:
VMOVSD (AX), X1
VMOVSD (BX), X2
VFMADD231SD X2, X1, X0 // X0[0] += X1 * X2 (скаляр)
ADDQ $8, AX
ADDQ $8, BX
DECQ CX
JNZ tailloop
done:
VMOVSD X0, ret+24(FP)
VZEROUPPER // сброс верхних AVX-состояний перед возвратом
RETПятьдесят строк, разбираем сверху вниз.
Заголовок и загрузка аргументов. TEXT ·dotAVX2(SB), NOSPLIT, $0-32 — начало функции. $0-32 — это $framesize-argsize: 0 байт локального кадра (нам не нужен стек под локали, всё живёт в регистрах) и 32 байта аргументов. Откуда 32: a *float64 (8) + b *float64 (8) + n int (8) + ret float64 (8) = 32. Флаг NOSPLIT запрещает вставку проверки роста стека в пролог — это законно для короткой функции без вызовов. Три MOVQ разбирают аргументы через FP: указатель a в AX, b в BX, длину n в CX. Обратите внимание — это чистый ABI0: всё через FP, ровно как обсуждалось выше.
Обнуление аккумулятора. VXORPD Y0, Y0, Y0 — XOR регистра Y0 с самим собой даёт ноль. Y0 — 256-битный AVX-регистр, четыре float64 разом; это наш аккумулятор из четырёх частичных сумм. Идея всего алгоритма: считать не одну бегущую сумму, а четыре параллельно, по одной на каждую «дорожку» (lane) вектора, и свернуть их в одно число в самом конце.
Сколько полных блоков. MOVQ CX, DX копирует длину, SHRQ $2, DX — сдвиг вправо на 2 бита, то есть целочисленное деление на 4: DX = число полных блоков по 4 элемента. JZ tail — если полных блоков нет (n < 4), сразу прыгаем на скалярный хвост.
Горячий цикл — сердце ускорения. Три инструкции на итерацию:
VMOVUPD (AX), Y1 // Y1 = a[i..i+3]
VMOVUPD (BX), Y2 // Y2 = b[i..i+3]
VFMADD231PD Y2, Y1, Y0 // Y0 += Y1 * Y2VMOVUPD (AX), Y1 грузит четыре подряд идущих float64 из a в Y1 (UPD — Unaligned Packed Double, «невыровненная упаковка double»; невыровненная — потому что срез может начинаться с любого адреса). То же для b в Y2. Дальше — ключевая инструкция серии: VFMADD231PD Y2, Y1, Y0. Это fused multiply-add (слитое умножение-сложение): за одну инструкцию она делает Y0 = Y1*Y2 + Y0, причём над всеми четырьмя дорожками сразу, и с единственным округлением на всю операцию (в этом и смысл «fused» — промежуточное произведение не округляется).
Про порядок операндов VFMADD231PD Y2, Y1, Y0 стоит сказать отдельно, потому что это классический источник ошибок. В Plan 9-синтаксисе (src → dst, разбирали в статье №2) он читается так: перемножаются два первых операнда, результат прибавляется к третьему, который и есть приёмник. Число 231 в мнемонике кодирует, какие операнды перемножаются, а какой аккумулирует — это часть стандарта FMA3, и Go-ассемблер её принимает как есть. Итог: Y0 += Y1 * Y2. Перепутать операнды здесь легко, а ошибка коварна: регистры остаются валидными, кадр и смещения в порядке — код соберётся и пройдёт go vet, но будет молча считать не то. Ловит такую подмену не go vet, а тест корректности, сверяющий результат asm с эталонным dotGeneric (про разделение обязанностей go vet и теста — в разделе про поломки).
Хвост цикла — арифметика указателей: ADDQ $32, AX / ADDQ $32, BX двигают указатели на 32 байта (4 × 8 = один обработанный блок), DECQ DX уменьшает счётчик блоков, JNZ blockloop крутит цикл, пока блоки есть.
Горизонтальная свёртка. После цикла в Y0 лежат четыре частичные суммы — их надо сложить в одно число. Векторные архитектуры не умеют «сложить дорожки между собой» одной дешёвой инструкцией, поэтому свёртка (reduction) делается в несколько шагов:
VEXTRACTF128 $1, Y0, X1
VADDPD X1, X0, X0
VHADDPD X0, X0, X0 // X0[0] = сумма всех четырёхVEXTRACTF128 $1, Y0, X1 вынимает верхнюю 128-битную половину Y0 (две старшие дорожки) в X1; нижняя половина Y0 — это X0 (у AVX младшие 128 бит Y-регистра и есть одноимённый X-регистр). VADDPD X1, X0, X0 складывает две половины попарно: теперь в X0 две суммы (дорожка0+дорожка2, дорожка1+дорожка3). Осталось сложить эти две — VHADDPD X0, X0, X0 (Horizontal ADD Packed Double) складывает соседние элементы внутри регистра, и в X0[0] оказывается сумма всех четырёх исходных дорожек. Свёртка — не бесплатна: на маленьких n её фиксированная стоимость съедает выигрыш от векторизации. Насколько именно — вопрос замера, а не интуиции; честные цифры с оговорками про размер входа в статье №4.
Скалярный хвост. Если n не кратно 4, остаётся 1–3 необработанных элемента:
tail:
ANDQ $3, CX // CX = n % 4 (остаток)
JZ done
tailloop:
VMOVSD (AX), X1
VMOVSD (BX), X2
VFMADD231SD X2, X1, X0 // X0[0] += X1 * X2 (скаляр)
ADDQ $8, AX
ADDQ $8, BX
DECQ CX
JNZ tailloopANDQ $3, CX — это n % 4 (маска младших двух бит), JZ done пропускает хвост, если остатка нет. Внутри — та же FMA, но скалярная: VMOVSD грузит один float64 (Scalar Double), а VFMADD231SD X2, X1, X0 делает X0[0] += X1*X2 над одним числом. Обратите внимание, что аккумулятор X0 тут тот же, что после свёртки, — хвост дописывает свои произведения к уже посчитанной сумме блоков. Шаг указателя — $8 (один float64).
Возврат и VZEROUPPER — почему это важно. Финал:
done:
VMOVSD X0, ret+24(FP)
VZEROUPPER // сброс верхних AVX-состояний перед возвратом
RETVMOVSD X0, ret+24(FP) кладёт результат (X0[0]) в ячейку возврата — смещение 24 совпадает с сигнатурой (ret+24(FP) после трёх 8-байтовых аргументов). А VZEROUPPER перед RET — не косметика, а важная деталь производительности. Когда код смешивает AVX-инструкции (256 бит, Y-регистры) и «унаследованный» SSE-код (128 бит, X-регистры), процессор при переходах между ними может влетать в дорогой штраф за переключение состояния AVX↔SSE (AVX-SSE transition penalty): сохранение и восстановление верхних 128 бит YMM-регистров стоит десятков тактов на каждом переходе. VZEROUPPER обнуляет верхние половины всех YMM-регистров и переводит процессор в «чистое» SSE-состояние, снимая этот штраф для вызывающего кода, который может быть скомпилирован под SSE. Правило простое и его стоит запомнить намертво: любая функция, использовавшая Y-регистры, обязана выполнить VZEROUPPER перед возвратом. Забыть его — значит замедлить не саму функцию, а весь код вокруг неё, и отладить такое крайне тяжело: сама функция быстрая, а тормозит почему-то соседний SSE-код.
Масштаб на arm64/NEON: тот же алгоритм, другие мнемоники
Перенос на arm64 — это тот же алгоритм (векторный аккумулятор + свёртка + скалярный хвост), но на другом наборе инструкций. У ARMv8 SIMD-расширение называется NEON, векторные регистры 128-битные (V0–V31), то есть вмещают два float64, а не четыре как AVX2. Соответственно горячий цикл обрабатывает пары.
Важное отличие в диспетчере: NEON — часть базового ARMv8, он есть на любом arm64-процессоре, поэтому рантайм-проверки фич (как cpu.X86.HasAVX2 на amd64) не нужно. Dot на arm64 зовёт dotNEON безусловно:
//go:build arm64
package dotprod
//go:noescape
func dotNEON(a, b *float64, n int) float64
// Dot на arm64 использует NEON (128-бит, 2 float64 за такт). NEON есть в
// базовом ARMv8, отдельный детект фич не нужен.
func Dot(a, b []float64) float64 {
if len(a) != len(b) {
panic("dotprod: length mismatch")
}
if len(a) == 0 {
return 0
}
return dotNEON(&a[0], &b[0], len(a))
}Сравните с amd64-версией выше: там был слой if cpu.X86.HasAVX2 && cpu.X86.HasFMA, здесь его нет — контракт платформы гарантирует NEON, откатываться не на что и незачем.
Само тело:
#include "textflag.h"
// func dotNEON(a, b *float64, n int) float64
// Аргументы: a+0(FP), b+8(FP), n+16(FP); результат ret+24(FP).
//
// ПРИМЕЧАНИЕ (кросс-сборка без arm-железа): семантика инструкций сверена
// построчно с cmd/internal/obj/arm64/doc.go и реальным использованием в
// GOROOT/src/runtime и GOROOT/src/math/big на amd64-хосте (исполнить нельзя,
// только собрать+vet). См. отчёт задачи за разбором операндов.
TEXT ·dotNEON(SB), NOSPLIT, $0-32
MOVD a+0(FP), R0 // R0 = &a[0]
MOVD b+8(FP), R1 // R1 = &b[0]
MOVD n+16(FP), R2 // R2 = n
VEOR V0.B16, V0.B16, V0.B16 // V0 = аккумулятор (2 частичные суммы), обнулён
LSR $1, R2, R3 // R3 = n / 2 (полных пар)
CBZ R3, tail // если пар нет, V0 всё ещё 0 — редукция не нужна
pairloop:
VLD1.P 16(R0), [V1.D2] // V1 = a[i..i+1], пост-инкремент R0 += 16
VLD1.P 16(R1), [V2.D2] // V2 = b[i..i+1], пост-инкремент R1 += 16
// VFMLA Vm, Vn, Vd => Vd += Vn*Vm (doc.go: "VFMLA V29.S2,V20.S2,V14.S2
// <=> fmla v14.2s,v20.2s,v29.2s", т.е. fmla Vd,Vn,Vm). Здесь Vm=V2,
// Vn=V1, Vd=V0 => V0 += V1*V2.
VFMLA V2.D2, V1.D2, V0.D2
SUB $1, R3, R3
CBNZ R3, pairloop
// Горизонтальная сумма пары V0.D[0]+V0.D[1] -> F0.
// В Go arm64 asm НЕТ мнемоники FADDP/VFADDP (векторный float pairwise
// add) — есть только VADDP, которая кодирует ЦЕЛОЧИСЛЕННЫЙ ADDP
// (U=0 в Advanced-SIMD-encoding), а не FADDP (U=1); для float64 это
// дало бы неверный результат (побитовое сложение мантисс). Поэтому
// вместо pairwise-add инструкции переносим старшую половину в
// скалярный регистр и складываем через FADDD:
// VMOV Vn.T[i], Vd.T[j] — операнды в порядке src, dst (doc.go:
// "VMOV V13.B[1], R20 <=> mov x20, v13.b[1]"), т.е. VMOV V0.D[1],
// V1.D[0] копирует старшую половину V0 в младшую половину V1 —
// V1 и F1 это один физический регистр (doc.go: "Bn,Hn,Dn,Sn,Qn
// инструкции пишутся как Fn во float-инструкциях и как Vn в SIMD"),
// так что после этого F1 = V0.D[1] как обычный double.
VMOV V0.D[1], V1.D[0]
// 2-операндная FADDD аккумулирует в правый операнд (F0 += F1), форма
// подтверждена в GOROOT/src/math/exp_arm64.s: "FADDD F1, F0".
FADDD F1, F0 // F0 = V0.D[0] + V0.D[1] (полная сумма пар)
tail:
AND $1, R2, R4 // R4 = n % 2 (0 или 1)
CBZ R4, done
FMOVD (R0), F1
FMOVD (R1), F2
// FMADDD Fm, Fa, Fn, Fd => Fd = Fa + Fn*Fm (doc.go: "FMADDD F30,F20,
// F3,F29 <=> fmadd d29,d3,d30,d20", т.е. fmadd Fd,Fn,Fm,Fa). Здесь
// Fm=F1, Fa=F0, Fn=F2, Fd=F0 => F0 = F0 + F2*F1.
FMADDD F1, F0, F2, F0
done:
FMOVD F0, ret+24(FP)
RETЛогика узнаётся сразу — те же четыре блока, что на amd64. MOVD разбирает аргументы через FP (на arm64 MOVD — это 64-битный move). VEOR V0.B16, V0.B16, V0.B16 — XOR регистра с собой, обнуление аккумулятора (.B16 — трактовка как 16 байт). LSR $1, R2, R3 — сдвиг на 1 = деление на 2, число пар. Загрузка VLD1.P 16(R0), [V1.D2] грузит два float64 (.D2 — два элемента типа double) с пост-инкрементом указателя на 16 байт. VFMLA V2.D2, V1.D2, V0.D2 — тот же fused multiply-add, что VFMADD231PD на amd64: V0 += V1*V2 над двумя дорожками. Скалярный хвост через FMADDD дописывает единственный оставшийся элемент, если n нечётно.
Свёртка и настоящая ловушка кросс-архитектурного переноса. Вот на чём реально спотыкается автор при написании arm64-версии — на горизонтальной сумме пары. Инстинктивно хочется взять «парное сложение float» — на ARM это инструкция FADDP. Но в Go-ассемблере arm64 мнемоники FADDP/VFADDP для этой цели нет. Есть VADDP, и она выглядит подходящей — но кодирует целочисленный ADDP (бит U=0 в кодировке Advanced SIMD), а не плавающий FADDP (U=1). Разница молчаливо-катастрофична: применить VADDP к float64-данным — значит сложить их побитово как целые, что даёт бессмысленный результат (складываются биты мантисс и экспонент, а не числа). Причём компилятор не возразит — инструкция валидна, просто делает не то. Поэтому свёртка обходится без pairwise-add:
VMOV V0.D[1], V1.D[0]
FADDD F1, F0 // F0 = V0.D[0] + V0.D[1]VMOV V0.D[1], V1.D[0] копирует старшую дорожку аккумулятора (V0.D[1]) в младшую половину скретч-регистра V1 (порядок операндов src → dst). Тонкость arm64: V1 и F1 — один физический регистр, только F-имя используется во float-инструкциях, а V-имя — в SIMD. Поэтому после VMOV в F1 лежит бывшая V0.D[1] как обычный double. А V0.D[0] — это F0. Дальше обычное скалярное FADDD F1, F0 (двухоперандная форма, аккумулирует в правый: F0 = F0 + F1) складывает две половины. Результат — полная сумма пар в F0.
Всё это не догадки: семантика каждой инструкции сверена по cmd/internal/obj/arm64/doc.go и реальному использованию в GOROOT/src/runtime, GOROOT/src/math (форма FADDD F1, F0 подтверждена прямой цитатой из math/exp_arm64.s). Что подводит к честной оговорке — следующий раздел.
Как это ломается: go vet и кросс-сборка
Рукописный ассемблер снимает страховку компилятора: типов больше нет, за корректность смещений и регистров отвечаете вы. Хорошая новость — часть ошибок ловится автоматически, ещё до запуска. Разберём три типичных способа сломать asm-пакет и чем каждый ловится.
1. Расхождение сигнатуры стаба и .s-заголовка. Стаб func dotAVX2(a, b *float64, n int) float64 задаёт 32 байта аргументов; заголовок $0-32 в .s обязан этому соответствовать, а каждое FP-смещение — попадать в свой аргумент (a+0, b+8, n+16, ret+24). Поменяли сигнатуру стаба (скажем, добавили аргумент), а .s забыли — и смещения поехали: ret+24(FP) теперь пишет не туда. Это первая и главная линия обороны — go vet. Он специально проверяет asm-файлы против Go-стабов: сверяет размеры аргументов, имена и смещения FP, и ругается на несоответствие. Именно поэтому go vet — не опциональная придирка, а обязательный шаг для любого пакета с asm:
go vet ./dotprod/А вот семантику go vet не проверяет: перепутанный порядок операндов FMA он не поймает — регистры валидны, размеры и кадр в порядке, для asmdecl-проверки всё чисто, а результат молча неверен. Такую ошибку ловит только тест корректности: на стенде TestDotMatchesGeneric сверяет результат каждой asm-версии с эталонным dotGeneric на случайных входах. Отсюда разделение обязанностей: go vet отвечает за механику (кадр, смещения, сигнатуру стаба), тест — за семантику (правильный ли ответ вообще).
2. Забытый fallback. Уберите dot_purego.go — и go build под amd64/arm64 продолжит работать (у них свои файлы), а вот сборка под неучтённую архитектуру рухнет: Dot не определён. Ошибка проявится не там, где ошиблись, и не у вас — а у того, кто соберёт проект под riscv64 или скомпилирует в wasm. Отсюда правило из раздела про структуру: pure-Go путь под !amd64 && !arm64 — обязателен всегда.
3. Неверный кадр ($framesize-argsize). Ошибиться в $0-32 — указать не тот размер аргументов или ненулевой кадр без нужды — значит либо получить диагностику go vet, либо (в худшем случае) порчу стека в рантайме. Здесь тоже первым срабатывает go vet, сверяя argsize с сигнатурой стаба.
Кросс-сборка как проверка arm64 без arm64-железа. Написать NEON-версию можно и на amd64-хосте — Go умеет кросс-компилировать и кросс-vet-ить. Это ровно то, чем проверялся dot_arm64.s на стенде:
GOARCH=arm64 go build ./dotprod/
GOARCH=arm64 go vet ./dotprod/GOARCH=arm64 go build соберёт пакет под arm64 (проверит, что ассемблер понимает все мнемоники и .s компилируется), а GOARCH=arm64 go vet дополнительно сверит стаб dotNEON с заголовком в .s. Это ловит опечатки в мнемониках, несуществующие инструкции (та самая история с FADDP/VFADDP из прошлого раздела всплыла именно на сборке) и расхождения сигнатур — всё без ARM-процессора.
Но честно: кросс-сборка — не исполнение. И здесь важная оговорка, которую нельзя проглотить. dot_arm64.s на стенде был собран и проверен go vet, но ни разу не исполнен на реальном ARM-железе — arm64-процессора (или qemu) под рукой не было. Семантика каждой инструкции сверена построчно с cmd/internal/obj/arm64/doc.go и реальным кодом в GOROOT — но это ручная проверка, а не прогнанный go test -race с числами, как на amd64. Поэтому про amd64-версию можно говорить «работает, вот бенчмарки» (они реальны, статья №4), а про arm64 — только «корректно собирается, проходит vet и семантически выверена по документации». Это разные уровни уверенности, и подменять один другим — ровно тот сорт нечестности, которого серия избегает. Если у вас есть ARM-железо — прогоните на нём go test -race ./dotprod/ перед тем, как полагаться на NEON-путь в бою.
Выводы
- Рукописный asm — это ABI0. Пишете FP-смещения по-старому, а мост к регистровому
ABIInternal(regabi, с Go 1.17) компилятор строит сам — маркер.abi0в дизасме именно об этом. Думать про регистровый ABI внутри.sне нужно. - Паттерн — стаб на Go + тело в
.s. Сигнатуры обязаны совпадать до байта;//go:noescapeснимает лишние аллокации, но перекладывает ответственность за корректность на вас. - Build tags выбирают ровно один
Dot. Взаимоисключающие теги (amd64/arm64/!amd64 && !arm64) разводят реализации; pure-Go fallback под неучтённые архитектуры — обязателен, иначе тихо ломается сборка. - AVX2: аккумулятор в
Y,VFMADD231PD(Y0 += Y1*Y2над 4 double), свёртка, скалярный хвост,VZEROUPPERпередRET. ЗабытьVZEROUPPER— замедлить весь код вокруг функции. - arm64/NEON — тот же алгоритм, 2 double на регистр. Ловушка:
FADDP/VFADDPв Go-ассемблере arm64 нет,VADDP— это целочисленный add; свёртка идёт черезVMOV+ скалярныйFADDD. go vet— первая линия обороны. Сверяет стаб с.s;GOARCH=arm64 go buildиGOARCH=arm64 go vetпроверяют arm64 без железа — но это сборка, не исполнение: NEON-путь на стенде выверен, но на ARM не прогонялся.
Заключительная статья серии — «avo, генерация и честный замер»: тот же AVX2-цикл, но сгенерированный avo вместо ручного письма, честное сравнение (наивный Go / рукописный AVX2 / avo) и чек-лист «когда asm НЕ надо», возвращающий к привратнику серии. Весь код — в cookbook.
Комментарии