Предыдущая статья серии про производительность в Go — «Профилирование и бенчмаркинг» — научила честно измерять и находить горячую точку. Иногда после этого выясняется, что компилятор уже выжал из чистого Go всё, что мог, а требование к производительности осталось. Тогда встаёт вопрос про ассемблер — и первый шаг здесь не «как писать», а «точно ли он нужен».
Серия «Go assembly» — честный привратник: сначала докажи горячую точку профилем, проверь, не векторизует ли уже компилятор, померь — и только тогда пиши asm. Открывающая статья показывает, где ассемблер реально работает в stdlib, что он даёт такого, чего не может чистый Go, и знакомит со сквозным примером серии — dot product для []float64, который проведём через все четыре статьи: от наивного Go до ручного AVX2, avo-генерации и честного бенчмарка.
В статье
- Привратник: сперва профиль, потом asm
- Где живёт asm в stdlib
- Что даёт asm, чего не может чистый Go
- Сквозной пример серии: наивный dot product
- Честная граница: чаще всего — «не пиши asm»
- Выводы
Привратник: сперва профиль, потом asm
Ассемблер в Go — не про «сделать код быстрее вообще», а про одну узкую, доказанную горячую точку, где компилятор уже упёрся в потолок. Поэтому у серии жёсткий порядок действий, и первый шаг — не редактор с .s-файлом, а профиль.
- Профиль доказал горячую точку. Пока
pprofне показал, что вот эта функция ест заметную долю времени всего сервиса, никакого asm. Как это делается — в «Профилировании и бенчмаркинге»: сначала profiler отвечает «где», потом benchmark меряет конкретную замену. - Компилятор ещё не сделал это за тебя. Часть «ускорений», которые хочется написать руками, компилятор уже встраивает как интринсики (об этом ниже). Нужно убедиться, что в горячем цикле их нет — иначе asm просто повторит уже сделанную работу.
- Есть чем померить до и после. Без воспроизводимого бенчмарка «стало быстрее» — это ощущение, а не факт. Честный замер сквозного примера этой серии разбирается в статье №4.
Если хоть один пункт не выполнен — писать asm рано. Тот же порядок удобно представить воронкой: каждый шаг отсеивает часть кандидатов, и до .s-файла доходит лишь то, что прошло все три фильтра.
Эта статья — про то, где ассемблер в 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 байта за инструкцию) бьёт побайтовый цикл.runtime—memmove,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/bits→POPCNTи подобные) — сначала проверьте, не решена ли задача без вас. - Настоящая причина писать asm — доступ к аппаратным инструкциям (SIMD, FMA, AES-NI, CRC32), которых нет в языке, и ручной контроль над векторизацией, которую компилятор Go принципиально не делает.
- Сквозной пример серии — скалярное произведение
[]float64. Компилятор оставляет его скалярным; дальше мы это докажем (№2), ускорим руками (№3) и честно померим (№4). - Дефолтный ответ на «переписать ли на asm?» — «нет». Сперва профиль, алгоритм, аллокации и интринсики; ассемблер — последним, под цифру, а не под ощущение.
Комментарии