Как писать свой asm: calling convention, ABI, две архитектуры

Как писать собственный Go-ассемблер: регистровый ABI (ABIInternal/ABI0), структура пакета со стабом и .s-файлами, рукописный AVX2 для dot product на amd64 и его перенос на arm64/NEON с обязательным pure-Go fallback

Когда дизасм доказал, что компилятор не векторизует горячий цикл, и решение писать asm подтверждено — встаёт вопрос ABI: как Go передаёт аргументы в регистры, что такое стаб-функция и как устроен .s-файл.

Третья статья серии «Go assembly» — практическая: пишем dot product на AVX2 руками для amd64, переносим на arm64/NEON и обязательно добавляем pure-Go fallback для архитектур, которые не учли.

gopher за верстаком пишет assembly.s между двумя наковальнями — «amd64 / AVX2» (векторная ширина 256 бит) и «arm64 / NEON» (128 бит); вверху чарт calling convention с регистрами обеих архитектур; внизу верёвочная страховочная сеть с надписью «pure-Go fallback»; чек-лист Correctness/Alignment/ABI/Performance/Fallback

В статье

Весь код ниже — со стенда серии в 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-тела, компилятору нечего проверять по типам. Поэтому её объявляют дважды:

  1. В .go-файле — стаб: сигнатура функции без тела. Это то, что видит компилятор Go: имя, типы аргументов, тип результата.
  2. В .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:

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

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
Раскладка пакета dotprod: диспетчер + стаб ↔ .s-тело, а выбор файла делают build-теги — ровно один Dot на архитектуру
  • 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 — fallback Dot для всех прочих архитектур. Тег //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 * Y2

VMOVUPD (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  tailloop

ANDQ $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-состояний перед возвратом
	RET

VMOVSD 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-битные (V0V31), то есть вмещают два 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.

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

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

Комментарии