Go assembly: зачем оно вообще и где живёт в stdlib

Зачем в Go может понадобиться ассемблер, где он реально работает в stdlib (crypto, math/bits, bytes.IndexByte, runtime) и почему в 99% случаев правильный ответ — не писать asm. Знакомство со сквозным примером серии — dot product для []float64

Предыдущая статья серии про производительность в Go — «Профилирование и бенчмаркинг» — научила честно измерять и находить горячую точку. Иногда после этого выясняется, что компилятор уже выжал из чистого Go всё, что мог, а требование к производительности осталось. Тогда встаёт вопрос про ассемблер — и первый шаг здесь не «как писать», а «точно ли он нужен».

Серия «Go assembly» — честный привратник: сначала докажи горячую точку профилем, проверь, не векторизует ли уже компилятор, померь — и только тогда пиши asm. Открывающая статья показывает, где ассемблер реально работает в stdlib, что он даёт такого, чего не может чистый Go, и знакомит со сквозным примером серии — dot product для []float64, который проведём через все четыре статьи: от наивного Go до ручного AVX2, avo-генерации и честного бенчмарка.

Ретрофутуристская мастерская: gopher-исследователь с фонарём и защитными очками у картотеки «The Go Standard Library» с ящиками crypto, bytes, runtime, math/bits; в витрине под стеклом самоцветы аппаратных инструкций AES-NI и SIMD; таблички «assembly language is a scalpel, not a sledgehammer» и «narrow, proven, hot spots only»

В статье

Привратник: сперва профиль, потом asm

Ассемблер в Go — не про «сделать код быстрее вообще», а про одну узкую, доказанную горячую точку, где компилятор уже упёрся в потолок. Поэтому у серии жёсткий порядок действий, и первый шаг — не редактор с .s-файлом, а профиль.

  1. Профиль доказал горячую точку. Пока pprof не показал, что вот эта функция ест заметную долю времени всего сервиса, никакого asm. Как это делается — в «Профилировании и бенчмаркинге»: сначала profiler отвечает «где», потом benchmark меряет конкретную замену.
  2. Компилятор ещё не сделал это за тебя. Часть «ускорений», которые хочется написать руками, компилятор уже встраивает как интринсики (об этом ниже). Нужно убедиться, что в горячем цикле их нет — иначе asm просто повторит уже сделанную работу.
  3. Есть чем померить до и после. Без воспроизводимого бенчмарка «стало быстрее» — это ощущение, а не факт. Честный замер сквозного примера этой серии разбирается в статье №4.

Если хоть один пункт не выполнен — писать asm рано. Тот же порядок удобно представить воронкой: каждый шаг отсеивает часть кандидатов, и до .s-файла доходит лишь то, что прошло все три фильтра.

Воронка: доказать необходимость, потом писать asmШаг 1профиль доказал горячую точкуШаг 2компилятор ещё не сделал это самШаг 3есть бенчмарк: замер до и последаписать asmруками или через avoлюбой «нет»на шаге —не пиши asm,чистый Go

Эта статья — про то, где ассемблер в Go действительно уместен, что он даёт и почему в подавляющем большинстве случаев правильный ответ всё-таки «не пиши asm».

Где живёт asm в stdlib

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

  • crypto/* — AES, SHA, ChaCha20, elliptic curves. Криптография обрабатывает большие потоки байт, и разница между аппаратной инструкцией (AES-NI, SHA-расширения) и её эмуляцией на чистом Go — это разы, а иногда порядки. Здесь asm окупается почти всегда.
  • internal/bytealg — сюда вынесены горячие примитивы вроде bytes.IndexByte/strings.IndexByte. Поиск байта в буфере — это ровно тот случай, где SIMD (обработать 16–32 байта за инструкцию) бьёт побайтовый цикл.
  • runtimememmove, memclr, барьеры, планировщик. Самый низ, где Go разговаривает с железом напрямую, и без ассемблера просто не обойтись.
  • math/bits — особый случай. Тут почти нет .s-файлов: bits.OnesCount, bits.TrailingZeros, bits.LeadingZeros компилятор превращает в одиночные инструкции процессора (POPCNT, TZCNT, LZCNT) как интринсики. Это «asm без asm»: аппаратная инструкция без единой строки на ассемблере — компилятор подставляет её сам. Хороший повод сначала проверить, не решена ли задача уже на уровне компилятора.

Всё это можно увидеть своими глазами, не выходя из терминала. Сигнатуры и документация — через go doc, а сами ассемблерные реализации лежат в дереве исходников Go под именами *_amd64.s, *_arm64.s и т. п.

# документация к пакету — видно, какие функции есть
go doc math/bits
go doc bytes IndexByte

# сами ассемблерные файлы в дереве исходников Go
# (в свежих Go крипто-asm уехал под FIPS-модуль crypto/internal/fips140)
ls "$(go env GOROOT)/src/crypto/internal/fips140/aes/"*_amd64.s
ls "$(go env GOROOT)/src/internal/bytealg/"*_amd64.s
ls "$(go env GOROOT)/src/runtime/"memmove_*.s

Пересказывать эти листинги здесь смысла нет — они большие и заточены под конкретный алгоритм. Важен вывод: stdlib пишет asm ровно там, где узкое место доказано и универсально, и нигде больше. Тот же принцип применим и к прикладному коду.

Что даёт asm, чего не может чистый Go

Если компилятор уже умеет интринсики, зачем вообще спускаться на ассемблер? Затем, что интринсики покрывают лишь узкий набор операций, а за его пределами остаётся то, до чего чистый Go дотянуться не может.

  • Аппаратные инструкции без интринсика. У Go нет способа из обычного кода сказать «сложи-умножь эти четыре float64 за одну FMA-инструкцию» или «посчитай CRC32 аппаратно». math/bits покрывает горстку операций (счёт битов, сдвиги) — всё остальное из мира SIMD (AVX2/AVX-512 на amd64, NEON/SVE на arm64), FMA, AES-NI, аппаратный CRC32 доступно только через ассемблер.
  • Ручной контроль над векторизацией. Компилятор Go не автовекторизует обычные циклы. Там, где C-компилятор с -O2 может сам развернуть цикл в SIMD, Go оставит скалярный код — и это осознанное решение команды Go в пользу простоты и предсказуемости компилятора. Значит, если хочется обрабатывать несколько элементов за инструкцию, вектор придётся выписать руками (или сгенерировать — см. статью №4).
  • Точное управление регистрами и раскладкой. В горячем ядре иногда важно, что именно лежит в регистрах, как выровнены данные, где происходит горизонтальная свёртка. Ассемблер даёт этот контроль целиком — ценой того, что за корректность теперь отвечаете вы, а не компилятор.

Это и есть честная причина писать asm: не «Go медленный», а «конкретная аппаратная инструкция мне нужна, а языкового способа её выразить нет». Всё остальное — микрооптимизации, которые лучше оставить компилятору.

Сквозной пример серии: наивный dot product

Чтобы разговор был предметным, всю серию мы проведём через один маленький алгоритм — скалярное произведение двух срезов []float64. Он идеален для темы: тривиальный по смыслу, укладывается в несколько строк, идеально ложится на SIMD (независимые умножения-сложения) — и при этом компилятор его не векторизует. Живой стенд с кодом, тестами и бенчмарками — в репозитории digital-cookbook (go-asm/, собран на Go 1.25+, числа сняты на Go 1.26.3).

Вот эталонная реализация на чистом Go — та самая, которую мы пронесём через все четыре статьи как образец корректности и точку отсчёта:

package dotprod

// dotGeneric — эталонная (golden) реализация. Есть на всех архитектурах,
// служит и fallback-ом, и образцом корректности для asm-версий в тестах.
func dotGeneric(a, b []float64) float64 {
	var s float64
	for i := range a {
		s += a[i] * b[i]
	}
	return s
}

Идиоматичнее некуда: цикл, умножение, накопление суммы. Проблема в том, что компилятор превращает это тело в скалярные SSE2-инструкции — MULSD/ADDSD, по одному float64 за раз, — а не в векторные VFMADD…/VMULPD…, которые молотили бы по четыре числа за такт. Никакой автовекторизации: за неё в Go рассчитывать нельзя. Мы это докажем дизассемблером в следующей статье — «Как читать дизассемблер», где вытащим листинг этой функции и покажем скалярные инструкции построчно.

Дальше по серии этот же алгоритм оживёт в разных воплощениях:

  • №2 — читаем дизассемблер dotGeneric и убеждаемся, что вектора нет (а заодно узнаём, что даже go tool objdump умеет врать на экзотических инструкциях).
  • №3 — пишем AVX2+FMA-версию руками (dot_amd64.s) и NEON для arm64 (dot_arm64.s), с обязательным pure-Go fallback (dotGeneric) для прочих архитектур — иначе пакет просто не соберётся.
  • №4 — генерируем ту же реализацию через avo вместо ручного .s и честно меряем выигрыш через benchstat — с оговорками, где он реален, а где съедается накладными расходами.

Конкретные цифры ускорения оставим статье №4: их нужно приводить с контекстом (размер входа, доверительный интервал, накладные расходы вызова), иначе они вводят в заблуждение. Пока достаточно знать: скалярный цикл выше — наша база, и дальше мы будем разгонять именно его, каждый раз сверяясь с ним на корректность.

Честная граница: чаще всего — «не пиши asm»

Теперь самое важное в открывающей статье — граница. Всё вышесказанное легко прочитать как приглашение переписать горячий цикл на ассемблере. В подавляющем большинстве случаев это ошибка.

Ассемблер в Go дорог не в момент написания, а в момент жизни кода:

  • Он архитектурно-зависимый. Одна .s-версия на amd64 не работает на arm64. Наш пример поэтому существует в трёх воплощениях (dot_amd64.s, dot_arm64.s и чисто-Go fallback) — три реализации вместо одной, и каждую надо поддерживать.
  • Компилятор больше вам не помогает. Проверки границ срезов, escape-анализ, безопасность по памяти, соглашения GC о регистрах — всё это теперь на вас. Ошибка здесь — не паника с понятным стеком, а тихое повреждение памяти.
  • Инструменты видят хуже. Отладчики, профайлеры, даже штатный дизассемблер работают с рукописным asm заметно менее гладко, чем с обычным Go (в статье №2 увидим, как go tool objdump не смог декодировать FMA3-инструкции и напечатал мусор).
  • Выигрыш не гарантирован. На маленьких входах фиксированные накладные расходы вызова и горизонтальной свёртки могут съесть весь эффект от векторизации — иногда до нуля. Выигрыш появляется только когда данных достаточно, чтобы эти расходы амортизировать. Без замера вы этого не узнаете.

Поэтому дефолт — не писать asm, а сначала: убрать аллокации, поправить алгоритм, дать компилятору PGO, проверить, нет ли уже готового интринсика. И только когда профиль доказал горячую точку, компилятор выжат досуха, а бенчмарк готов ловить регрессии — можно спускаться ниже. Развёрнутый чек-лист «когда НЕ надо» — в статье №4, вместе с честными цифрами.

Это, кстати, та же дисциплина, что и в серии про конкурентность в Go: сложный низкоуровневый инструмент оправдан только под доказанную нужду, а по умолчанию выигрывает более простое и безопасное решение.

Выводы

  • Ассемблер в Go — рабочий инструмент для узких, доказанных горячих точек, а не признак «продвинутости». Он живёт в stdlib (crypto/*, internal/bytealg, runtime) ровно там, где узкое место универсально.
  • Часть «ускорений» компилятор уже делает сам через интринсики (math/bitsPOPCNT и подобные) — сначала проверьте, не решена ли задача без вас.
  • Настоящая причина писать asm — доступ к аппаратным инструкциям (SIMD, FMA, AES-NI, CRC32), которых нет в языке, и ручной контроль над векторизацией, которую компилятор Go принципиально не делает.
  • Сквозной пример серии — скалярное произведение []float64. Компилятор оставляет его скалярным; дальше мы это докажем (№2), ускорим руками (№3) и честно померим (№4).
  • Дефолтный ответ на «переписать ли на asm?» — «нет». Сперва профиль, алгоритм, аллокации и интринсики; ассемблер — последним, под цифру, а не под ощущение.

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

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

Комментарии