Дядя Хашимото сказал разобраться, я попытался. Речь про его эссе «Everyone Should Know SIMD»: SIMD должен знать каждый разработчик; примеры там на Zig, но суть от языка не зависит. Мой язык — Go, и в февральском обзоре Go 1.26 я написал про новый SIMD-пакет грустную строчку: «поддержка ARM64 планируется в будущих версиях».
Будущее пришло быстрее, чем я ждал: в go1.27rc2 пакет simd стал портируемым, и один Go-код разворачивается в Neon на Apple Silicon и в AVX2 на Intel. У меня под рукой обе машины: Mac на M3 Pro, где собиралась первая версия этих бенчей, и Linux-десктоп на i9-14900KF. Впереди проверка обещания портируемости на живом железе: один исходник, два дизассемблера, две пачки замеров. Ассемблер писать не будем, весь код запускается копипастой; целиком он лежит в репозитории dsbasko/sandbox-simd.

TL;DR
-
В Go 1.27 пакет
simdстал портируемым: векторные операции на чистом Go, один исходник на arm64 и amd64. Включается флагомGOEXPERIMENT=simd. -
Проверил на двух машинах. Mac (M3 Pro): Neon, 4 полосы float32, инструкция
FADD.S4. i9-14900KF: AVX2, 8 полос,VADDPS, плюс мёртвая ветка AVX-512 в бинарнике: Intel выжгла его у потребительских 12-14 поколений. Ветку я всё равно запустил, через эмулятор. -
Сумма
[]float32: на Mac стабильные 4,2-4,5x от 4 КБ до 256 МБ; у i9 разброс от 5x в кэше до 2,2x на тех же 256 МБ, а на тысяче элементов вектор проигрывает ручным аккумуляторам. -
8 полос не дали 8x, и это главный урок: большую часть ускорения приносит разрыв цепочки зависимостей, а не ширина регистра.
Go двенадцать лет не умел в SIMD. Теперь умеет на обеих моих машинах
SIMD — это Single Instruction, Multiple Data: одна инструкция процессора обрабатывает не одно число, а сразу пачку. Идея старая, инструкции в процессорах есть давно, но в Go до них было не дотянуться.
Дотянуться пытались через боль. Классический путь: пишешь функцию на C++ с интринзиками, компилируешь, вытаскиваешь ассемблер, конвертишь его в формат Go-ассемблера отдельным инструментом. Один разработчик описал финал этого квеста в обсуждении на Hacker News коротко: «I never got my function to work in go despite the C++ code working fine… not really a production ready option for Go» (HN). То есть даже рабочий C++ не гарантировал рабочий Go.
В Go 1.26 (февраль 2026) появился первый экспериментальный пакет simd/archsimd, и только под amd64. Все, кто на Apple Silicon, остались за бортом: в разборе фич 1.26 от Avito боль сформулировали прямо, «для ARM придётся дублировать логику». Я в своём обзоре отделался строчкой про «планируется». А в 1.27 приехал портируемый пакет simd, который подстраивается под железо сам; историю хранят proposal #73787 и #78902.
Звучит подозрительно хорошо: двенадцать лет никакого SIMD, и вдруг сразу «один код на все архитектуры». Такое проверяют дизассемблером, и у меня две подопытные машины. Но сначала два коротких ликбеза: что процессор делает, когда складывает числа, и куда в этой картине встаёт SIMD. Без них цифры дальше выглядят фокусом.
Ликбез: как процессор складывает числа
Если регистры и такты для тебя рабочие будни — смело листай до установки go1.27rc2, тут ликбез для тех, кто ниже Go не спускался.
Процессор не исполняет Go. Компилятор переводит программу в машинные инструкции — элементарные команды уровня «возьми число из памяти», «сложи два числа», «сравни», «положи обратно». Что бы ни делал твой сервис, для ядра это поток таких шагов.
Считает процессор не в памяти. Вся арифметика происходит в регистрах, ячейках внутри самого ядра. Их немного, счёт идёт на десятки, зато работают они на скорости ядра; поход в оперативную память на их фоне выглядит командировкой на сотни тактов. Такт — это один тик внутренних часов процессора, таких тиков в секунду миллиарды; за такт ядро успевает выполнить одну или несколько простых инструкций.
Обычный регистр вмещает одно число, 32 или 64 бита. Наш будущий цикл s += x глазами железа: загрузить элемент слайса из памяти в регистр, прибавить к регистру-сумме, перейти к следующему. В псевдоинструкциях:
LOAD r1 <- xs[i] // элемент слайса из памяти в регистрFADD s <- s + r1 // прибавить к регистру-сумме// ...и так на каждый из миллиона элементов
Одно число — одна инструкция сложения. Такую работу называют скалярной: скаляр — одиночное значение, в противовес вектору, пачке значений. Отсюда имя sumScalar у первого бенчмарка ниже.
Векторные регистры и полосы — вся суть SIMD
Рядом со скалярными регистрами в ядре лежит второй набор, векторные. Та же ячейка, только шире: у Neon на Apple Silicon 128 бит. float32 занимает 32 бита, значит в один векторный регистр четыре таких числа встают бок о бок. Эти позиции называют полосами, в английских текстах lanes.
Векторная инструкция делает одно действие сразу со всеми полосами. Загрузил [1, 2, 3, 4] в один регистр и [10, 20, 30, 40] в другой, дал одну команду сложения, и в регистре-результате лежит [11, 22, 33, 44]. Четыре сложения ценой одной инструкции. Ровно это зашифровано в аббревиатуре: Single Instruction, Multiple Data — одна инструкция, много данных.

Ширина векторного регистра зависит от процессора:
|
Набор инструкций |
Железо |
Ширина |
float32 за инструкцию |
|---|---|---|---|
|
Neon |
arm64, включая Apple Silicon |
128 бит |
4 |
|
SSE |
amd64 |
128 бит |
4 |
|
AVX2 |
amd64 |
256 бит |
8 |
|
AVX-512 |
часть amd64 |
512 бит |
16 |
Мне из этой таблицы достались две строки: Neon на Mac с четырьмя полосами и AVX2 на i9 с восемью. AVX-512 на моём i9-14900KF нет, хотя это топовый камень четырнадцатого поколения: Intel отключила его у всей потребительской линейки. За этим стоит история про два сорта ядер, дойдём до неё у диспетчера.
Правая колонка задаёт теоретический потолок ускорения: больше полос, чем влезает в регистр, за инструкцию не сложить. Реальность скромнее: Mitchell Hashimoto, который векторизовал терминал Ghostty, приводит для AVX2 не теоретические 8x, а реальные «more like a 5x». Куда утекает разница, увидим на собственных бенчмарках.
И это не экзотика. Векторные инструкции есть в процессоре, на котором ты сейчас читаешь статью. Go ими давно пользуется: в стандартной библиотеке почти 25 тысяч строк x86-ассемблера, большая часть в криптографии. Написаны они руками, потому что из обычного Go-кода до этих инструкций было не добраться.
SIMD — фича железа, а не языка; вопрос только в том, дотянется ли язык. С 1.27 дотягивается. Теперь пощупаем.
Ставим go1.27rc2: команды одни на macOS и Linux
Ставится RC как любой нестабильный тулчейн Go, отдельным бинарником рядом с основным; команды одинаковые на обеих системах:
go install golang.org/dl/go1.27rc2@latestgo1.27rc2 download
Теперь пробуем импортировать simd и собрать. И сразу получаем в лоб:
imports simd: build constraints exclude all Go files in .../src/simd
Это тот момент, где многие решают, что «SIMD в Go не работает», и закрывают вкладку. На самом деле пакет спрятан за экспериментальным флагом. Добавляешь GOEXPERIMENT=simd, и всё собирается:
GOEXPERIMENT=simd go1.27rc2 run .
Флаг придётся передавать и при сборке, и при тестах, и при бенчмарках. Забудешь — вернётся та же ошибка про build constraints.
Базовый цикл: сумма миллиона float32 на двух машинах
Прежде чем ускорять, надо померить, что ускоряем. Вот наивная сумма из sum.go, её и возьмём за точку отсчёта:
// sum.gofunc sumScalar(xs []float32) float32 {var s float32for _, x := range xs {s += x}return s}
Гоним бенчмарк на миллионе элементов, сами бенчи — в sum_test.go:
GOEXPERIMENT=simd go1.27rc2 test -bench='Sum/Scalar$/n=1000000$' -benchmem .
Паттерн перегружен не случайно: go test режет его по слэшам, каждая часть матчит свой уровень имени BenchmarkSum/Scalar/n=1000000. Напишешь короче, вроде -bench=Scalar/n=1000000, и получишь PASS с нулём запущенных бенчмарков, без единого предупреждения. Об этот молчаливый PASS я и споткнулся, перенося бенчи на вторую машину.
Вывод на Mac:
## Вывод на MacBenchmarkSum/Scalar/n=1000000-11 1604 697574 ns/op 5734 MB/s## Он же на i9BenchmarkSum/Scalar/n=1000000-32 3396 349010 ns/op 11461.00 MB/s
Семьсот микросекунд против трёхсот пятидесяти: десктоп вдвое быстрее ещё до всякого SIMD, и ничего векторного тут нет, P-ядро i9 разгоняется до 6 ГГц и берёт частотой. Суффиксы -11 и -32 в имени показывают GOMAXPROCS двух машин, сам бенчмарк однопоточный. Запусти у себя — порядок будет тот же, точные числа другие.
Первый вектор: тот же код, обе машины
Векторная версия всюду строится по одному шаблону. Завести вектор-аккумулятор, идти по слайсу кусками шириной в вектор, складывать куски векторно. В конце — свести полосы в одно число и добить скалярный хвост. Вот главный цикл:
// sum.gofunc sumSIMD(xs []float32) float32 {var acc simd.Float32s // вектор-аккумулятор, все полосы = 0w := acc.Len() // сколько float32 влезает в векторi := 0for ; i+w <= len(xs); i += w {acc = acc.Add(simd.LoadFloat32s(xs[i:])) // сложить w чисел разом} // ... свод полос и хвост - ниже
simd.Float32s — вектор из float32 неизвестной наперёд длины. LoadFloat32s берёт из слайса ровно w элементов и грузит их в вектор, Add складывает два вектора поэлементно, Len() говорит, сколько элементов влезло. Обрати внимание: w не константа, мы спрашиваем её у пакета в рантайме. Почему так устроено, разберёмся дальше: это ключевой момент статьи.
После цикла в acc лежит w частичных сумм. Их надо сложить между собой, а потом добрать элементы, не влезшие в последний полный вектор:
// sum.go (продолжение sumSIMD)lanes := make([]float32, w)acc.Store(lanes) // выгрузить полосы в обычный слайсvar s float32for _, v := range lanes { // сложить полосы между собойs += v}for ; i < len(xs); i++ { // хвост: остаток короче вектораs += xs[i]}return s}

Чему равен w? Спроси у пакета:
fmt.Println(simd.VectorBitSize(), simd.Float32s{}.Len())// Mac: 128 4// i9: 256 8
Один исходник без единой правки даёт разную ширину вектора; в репозитории эта проба оформлена тестом runtimecheck_test.go. Бенчмарк, сначала Mac:
BenchmarkSum/SIMD/n=1000000-11 7308 163353 ns/op 24486 MB/s
700 микросекунд превратились в 163: больше чем вчетверо, и никакого ассемблера. Теперь i9:
BenchmarkSum/SIMD/n=1000000-32 17768 69508 ns/op 57547.05 MB/s
349 стали 69,5, ровно 5x.
Тут останови себя и сделай ставку. На Mac четыре полосы дали 4,2x, почти потолок из таблицы ширин. У i9 полос восемь, а вышло 5x: куда делись ещё три икса? Сформулируй версию и держи её до секции про декомпозицию, там будет чем проверить.
Туториал на этом закрыт: векторный код работает на обеих машинах. Дальше разбор, что под капотом.
Дизассемблер: FADD против VADDPS
Заглянем, во что компилятор превратил Add на каждой машине. Команды одинаковые, различается вывод. Mac:
GOEXPERIMENT=simd go1.27rc2 test -c -gcflags='-l' -o /tmp/t.test .go1.27rc2 tool objdump /tmp/t.test | grep 'FADD .*\.S4'## FADD V1.S4, V0.S4, V0.S4
FADD складывает float, V0 и V1 — 128-битные регистры Neon. .S4 значит «четыре 32-битных числа»: одна инструкция складывает четыре пары float32 разом. Отсюда и Len() == 4: те же 128 бит / 32 бита из таблицы ширин.
Те же две команды на i9:
go1.27rc2 tool objdump /tmp/t.test | grep 'VADDPS Y'## VADDPS Y1, Y0, Y0
Та же строка Go — другой родной язык. VADDPS складывает упакованные float на x86, а Y0 и Y1 — 256-битные регистры AVX2: восемь полос за инструкцию, как и обещал Len() == 8. Мне понадобилось увидеть обе строки своими глазами, чтобы окончательно поверить.

Диспетчер: switch, который лежит в каждом бинарнике
Вернёмся к w := acc.Len(). Почему ширина вектора приходит из вызова метода, а не из константы? Потому что бинарник несёт реализации под все ширины сразу. На i9 это видно тем же grep, но без фильтра по Y:
go1.27rc2 tool objdump /tmp/t.test | grep VADDPS## VADDPS X1, X0, X0 // 128 бит, SSE: 4 x float32## VADDPS Y1, Y0, Y0 // 256 бит, AVX2: 8 x float32## VADDPS Z1, Z0, Z0 // 512 бит, AVX-512: 16 x float32
В первой версии статьи я добывал эту тройку кросс-компиляцией с Mac и разглядывал как теорию; теперь она нативная. Компилятор собрал из моей sumSIMD четыре ветки: @simd0 (скалярная эмуляция), @simd128, @simd256, @simd512. А сверху положил диспетчер, и это буквально switch. Вот он в objdump, служебные строки я вырезал:
CALL simd.VectorBitSize(SB)CMPQ AX, $0x80 // 128? -> проверить Emulated -> @simd0 либо @simd128CMPQ AX, $0x100 // 256? -> CALL gosimd.sumSIMD@simd256CMPQ AX, $0x200 // 512? -> CALL gosimd.sumSIMD@simd512
Обычные сравнения с константами, читаются глазами. Рантайм при старте спрашивает процессор, что тот умеет; на моём i9 VectorBitSize() возвращает 256, поэтому живёт ветка @simd256. Двенадцать лет в Go нельзя было получить векторную инструкцию без ассемблера, а теперь go build молча раскладывает по бинарнику четыре реализации и сам их коммутирует. От этой буквальности я до сих пор слегка в восторге.

А ветка @simd512 на этой машине — мёртвый груз, и это не жадность конкретной модели. У потребительских Intel с двенадцатого поколения два сорта ядер, и экономичные E-ядра AVX-512 не умеют. Планировщик ОС не обещает, что векторный поток попадёт на P-ядро. Поэтому Intel сначала прятала AVX-512 из CPUID, а с 2022-го отключает его на производстве; 13-е и 14-е поколения решение унаследовали.
Я проверил всерьёз, прежде чем писать «мёртвый груз». Прямое исполнение zmm-инструкции на этом i9 заканчивается сигналом Illegal instruction, и пути назад нет. Ранним Alder Lake помогали старый BIOS и отключение E-ядер, но для 13/14 поколений микрокода с поддержкой AVX-512 никогда не выпускалось (хроника). Самое обидное: на die shots блоки AVX-512 в P-ядрах Raptor Cove видны, железо физически лежит в кремнии, но фьюзы выжжены необратимо. Шестнадцать полос для десктопного Intel пока теория.
Здесь же расскажу, как я чуть не соврал в первой версии. На Mac я дизассемблировал не ту ветку, а фолбэк @simd0, который тоже всегда лежит в бинарнике. Увидел скалярные инструкции и уверенно решил, что на arm64 всё эмуляция. Спасла проверка:
fmt.Println(simd.Emulated())// false - и на Mac, и на i9
Emulated() вернул false: работает настоящее железо, а я смотрел на код, который в бинарнике лежит, но не исполняется. Мораль: споришь с оптимизатором — проверяй ветку, которая реально исполняется, а не первую попавшуюся в дизассемблере.
Оживляем мёртвую ветку: эмулятор и потайная ручка
Ветку @simd512 я всё-таки запустил, причём на этой же машине. Помог Intel SDE, эмулятор, которым пользуется и сама команда Go, когда тестирует AVX-512-код без железа. Под sde64 -spr (эмуляция серверного Sapphire Rapids) диспетчер выбирает @simd512: VectorBitSize() возвращает 512, Len() даёт 16, сумма сходится с нативной. Гистограмма исполненных инструкций (sde64 -mix) показывает vaddps zmm ровно 62 500 раз, миллион элементов по 16 полос. Всё честно.
Для бенчмарков эмулятор бесполезен: тайминги под ним не настоящие. Но проверить корректность 512-битной ветки можно, не вставая из-за стола. И ловушка в тему: под SDE simd.Emulated() возвращает false, пакет не отличает эмулятор от железа, ведь SDE подменяет CPUID. Emulated() отвечает на вопрос «эмулирует ли пакет вектор чистым Go», а не «настоящий ли у тебя процессор».
Вниз по веткам можно ходить и без эмулятора. В исходниках пакета нашлась недокументированная ручка GODEBUG=simd=N: simd=128 заставляет диспетчер взять ветку @simd128, а simd=0 включает эмуляцию @simd0. Эмуляция отрезвляет: 1,14 миллисекунды на тот же миллион, в три с лишним раза медленнее наивного скаляра. Фолбэк существует ради переносимости, не скорости. А вот simd=512 на железе без AVX-512 честно паникует:
panic: Requested GODEBUG=gosimd=512 is larger than the simd length (256) supported on this cpu
Форсировать можно только вниз, вверх — никак. Число с ручки simd=128 пригодится в следующей секции.
Откуда ускорение: раскладываю на обеих машинах
Пора вскрывать ставку из туториала. Чтобы понять, за что реально заплачено ускорением, я держу в бенчах третью версию: тоже скалярную, но с четырьмя аккумуляторами вручную.
sumScalar4: четыре аккумулятора вручную
// sum.gofunc sumScalar4(xs []float32) float32 {var s0, s1, s2, s3 float32i := 0for ; i+4 <= len(xs); i += 4 {s0 += xs[i]s1 += xs[i+1]s2 += xs[i+2]s3 += xs[i+3]}s := s0 + s1 + s2 + s3for ; i < len(xs); i++ {s += xs[i]}return s}
Никакого SIMD, обычные float32, но считаем в четыре независимые переменные. Все три версии на миллионе элементов, медианы серии прогонов:
|
машина |
scalar |
scalar4 |
simd |
|---|---|---|---|
|
Mac |
700 µs |
324 µs (2,2x) |
166 µs (4,2x) |
|
i9 |
349 µs |
113 µs (3,1x) |
69,5 µs (5,0x) |
Восьми иксов нет ни в одной клетке, зато посмотри на колонку scalar4. Ручные четыре аккумулятора без всякого SIMD дают на Mac половину «векторного» выигрыша, а на i9 даже больше половины: 3,1x из 5.
Почему один аккумулятор медленный? В цикле s += x каждое сложение обязано дождаться предыдущего: чтобы прибавить следующее число, нужна готовая сумма от прошлого шага. Получается цепочка, где операции стоят в очереди, хотя блоков сложения в ядре несколько и они умеют работать параллельно. Четыре аккумулятора рвут цепочку на четыре независимые, и простаивающим блокам появляется чем заняться.

Закономерный вопрос: если четыре аккумулятора быстрее, почему компилятор сам так не делает? Потому что для float это меняет результат. Сложение чисел с плавающей точкой не ассоциативно: (a + b) + c и a + (b + c) могут дать разные последние биты из-за округления. Переставить порядок суммирования значит молча изменить ответ, и Go на это не идёт без твоего разрешения. Пишешь четыре аккумулятора или берёшь вектор — даёшь разрешение явно.
А дальше у машин дороги расходятся. На Mac вектор поверх scalar4 добавляет ещё два раза: четыре полосы работают почти в полный рост. На i9 выходит лишь 1,6x при восьми полосах.
Вклад ширины можно измерить и в чистом виде, той самой ручкой из прошлой секции. Принудительная четырёхполосная ветка @simd128 на i9 даёт 134 микросекунды против 69,5 у родных восьми полос: удвоение ширины приносит честные 1,9x. И заметь: четырёхполосный вектор проигрывает четырём ручным аккумуляторам, 134 против 113 микросекунд. Обвязка векторного цикла не бесплатна.
Я проверил и восемь ручных аккумуляторов, sumScalar8 лежит в том же sum.go. На i9 прироста над scalar4 нет: четыре независимые цепочки уже кормят блоки сложения этого ядра досыта, дальше лимитом становятся пропускная способность самих блоков и обвязка цикла. А Mac восемь цепочек прожевал. В свежем прогоне scalar8 срезал время scalar4 ещё почти вдвое: 180 микросекунд против 346. До вектора осталось четыре процента — у того 173. Раскормленный скаляр на M3 Pro практически догоняет SIMD. Полосы перестают конвертироваться в иксы задолго до восьми; сколько независимых цепочек прожуёт ядро — у каждого ядра своё.
Ставка вскрыта: «8 полос — будет 8x» не работает, потому что и скаляр, и вектор упираются в одно и то же, в латентность сложения и цепочку зависимостей. SIMD ценен тем, что одним движением и рвёт цепочку, и складывает пачками. Но большую часть ускорения на обеих моих машинах принёс разрыв цепочки.
Напоследок симметрия, которой я не ожидал (расчёт по открытым таблицам микроархитектур, не замер). P-ядро Raptor Lake умеет два векторных сложения за такт по 8 полос, ядро M1 — четыре конвейера по 4 полосы. И там и там выходит 16 float32 за такт; разницу в моих таблицах делают частота и подвоз данных. Те же конвейеры объясняют и аппетит к цепочкам: два порта i9 насытились четырьмя аккумуляторами, четыре конвейера Mac переварили и восемь.
P-ядра, E-ядра и куда сядет твой бенчмарк
Сначала про второй сорт ядер, память следом. У 14900KF 8 производительных P-ядер (до 6,0 ГГц) и 16 экономичных E (4,4 ГГц); AVX2 умеют оба, ветка @simd256 исполняется на любом. Пиную бенчмарк на конкретные ядра:
GOEXPERIMENT=simd taskset --cpu-list 8 go1.27rc2 test -bench='Sum/.*/n=1000000$' .GOEXPERIMENT=simd taskset --cpu-list 16 go1.27rc2 test -bench='Sum/.*/n=1000000$' .
|
ядро |
scalar |
scalar4 |
simd |
simd к scalar |
|---|---|---|---|---|
|
P, 6,0 ГГц |
352 µs |
110 µs |
68 µs |
5,2x |
|
E, 4,4 ГГц |
684 µs |
217 µs |
117 µs |
5,9x |
Первое наблюдение практическое: непинованный бенчмарк на гибридном процессоре — лотерея. Планировщик волен уронить поток на E-ядро посреди прогона; в чужих замерах пиновка сдвигала числа на 10-25%. Мне повезло: непинованные прогоны совпали с P-ядром. Закладываться на такое везение в CI я бы не стал.

Второе забавное: E-ядро на скаляре выдаёт почти в точности мой Mac, 684 против 700 микросекунд, случайное совпадение двух конкретных чипов. Зато на SIMD обгоняет Mac в 1,4 раза. Восемь полос перевешивают даже то, что 256-битную операцию E-ядро внутри честно пилит на две 128-битные: так устроен Gracemont.
У Mac лотерея своя. M3 Pro — тоже гибрид, 5 P-ядер и 6 E-ядер, но taskset в macOS нет: ядро для потока выбирает планировщик по QoS-классу процесса. Утилита taskpolicy -c background помечает процесс фоновым, и система уводит его на E-ядра. Вектор замедляется вчетверо, 717 микросекунд против 173, скаляр почти так же; ускорение внутри E-ядра остаётся ~4x. Тот же бинарник, та же машина, другой QoS. Запустишь Go-утилиту фоновым классом — все её вектора поедут на экономичных ядрах.
Стена памяти: i9 упёрся, Mac нет даже на 256 МБ
В первой версии я написал: на Mac стены памяти не увидел даже на 32 мегабайтах, ускорение держалось около 4x. И отделался оговоркой «на другом железе потолок реален, меряй сам», честной, но бездоказательной. Теперь у меня есть железо, где стену видно. Тот же бенч на i9 с ростом размера, отдельный прогон:
|
элементов |
данных |
scalar |
scalar4 |
simd |
simd к scalar |
|---|---|---|---|---|---|
|
1 млн |
4 МБ |
352 µs |
111 µs |
68 µs |
5,1x |
|
8 млн |
32 МБ |
2,89 ms |
1,30 ms |
1,03 ms |
2,8x |
|
32 млн |
128 МБ |
11,7 ms |
6,23 ms |
5,44 ms |
2,1x |
|
64 млн |
256 МБ |
23,4 ms |
12,6 ms |
10,8 ms |
2,2x |
4 МБ живут в кэше: там SIMD качает 57 ГБ/с и даёт свои 5x. 32 МБ уже граница, ведь L3 у 14900KF как раз 36 МБ, и ускорение сложилось вдвое. Дальше начинается оперативная память, и всё выравнивается: SIMD выжимает ~24 ГБ/с, scalar4 около 20, разница тает до полутора десятков процентов. Каким регистром складывать, почти неважно: числа не успевают подъезжать, больше одно ядро из DRAM не вытянет.
Осталось дожать Mac до тех же 256 МБ. Свежий прогон на M3 Pro:
|
элементов |
данных |
scalar |
scalar4 |
simd |
simd к scalar |
|---|---|---|---|---|---|
|
1 млн |
4 МБ |
776 µs |
346 µs |
173 µs |
4,5x |
|
8 млн |
32 МБ |
6,0 ms |
2,83 ms |
1,43 ms |
4,2x |
|
32 млн |
128 МБ |
24,1 ms |
11,0 ms |
5,46 ms |
4,4x |
|
64 млн |
256 МБ |
46,3 ms |
22,0 ms |
10,9 ms |
4,3x |
Стены нет. Те же 4,2-4,5x на любом размере, и SIMD что из кэша, что из DRAM качает одни и те же ~23 ГБ/с.

Причина видна по цифрам, а не по вере в широкую шину. Apple заявляет для M3 Pro 150 ГБ/с унифицированной памяти, мой i9 в двухканале DDR5 умеет ~90, но дело не только в полосе. Моя векторная сумма ест ~23 ГБ/с — Mac столько подвозит из DRAM не напрягаясь, и код упирается в цепочку сложений, а не в память. На i9 аппетит SIMD в кэше — 57 ГБ/с, а DRAM отдаёт одному ядру только ~24: за границей L3 узким местом становится подвоз.
Вывод прежний, но теперь с доказательством с обеих сторон: меряй на своих размерах и своём железе. За «5x» и «2x» стоит один и тот же код на одной машине. На соседней тот же код держит 4x на любом размере, потому что упирается в такты, а не в байты.
Где SIMD не помогает
Теперь честная часть. SIMD — не кнопка «сделать быстро», и мест, где он не окупается, за неделю с двумя машинами накопилось.
Мелкие слайсы. У векторной версии есть фиксированная цена входа: диспетчер, свод полос, скалярный хвост. На тысяче элементов i9 показывает 152 ns у simd против 117 ns у scalar4, ручные аккумуляторы быстрее вектора. На Mac, для контраста, simd выигрывал и там: 156 против 313 ns у scalar4. Даже scalar8 вектор не догнал: 176 против 166 ns в свежем прогоне. Одна и та же функция на коротких данных где-то окупается, где-то нет; без замера не угадаешь.
Обвязка дорожает вместе с задачей. Первый подход к снаряду был ещё в телеграм-посте — косинусная близость эмбеддингов на том же i9, вектора по 768 float32, код на archsimd (cosine_amd64.go). Скаляр выдал 332 ns на пару, вектор с FMA и четырьмя аккумуляторами на каждую сумму — 341 ns. Косинус тащит три суммы за проход — скалярное произведение и две нормы — и их свод плюс хвост съедают всю ширину. Чистый dot product на тех же данных векторизовался в 1,6 раза, косинус окупился лишь на 12 тысячах элементов: 1,8 µs против 5,1.
Компилятор сам не векторизует. «Пиши обычный цикл, компилятор всё сделает» — это не про Go, и решение сознательное: автовекторизация суммы float меняет порядок сложений, а с ним результат. Там, где автовекторизация есть (LLVM, GCC), она хрупкая: стоит появиться раннему break или работе через указатели, и оптимизация молча отваливается; Хашимото называет компиляторы «very poor at it». Рассчитывать на неё нельзя.
Хвост массива — источник тонких багов. Если длина не делится на ширину вектора, остаток обрабатывается отдельно; у нас это скалярный for в конце. Соблазн запихнуть фиктивные элементы и сделать ещё одну векторную итерацию ломается о запись за границу массива: Store в чужую память кончается в лучшем случае паникой, в худшем порчей данных. Хвост дописывают скаляром, и точка.
Граница Go и ассемблера может съесть весь выигрыш. Это про старый путь с ручными asm-вставками. В одном обсуждении человек разгонял поиск по ДНК, и SIMD-версия вышла в разы медленнее. Функция вызывалась сотни тысяч раз, и на каждом переходе Go-ассемблер-Go процессор платил за VZEROUPPER; AVX2-вариант оказался медленнее SSSE3, накладные расходы сожрали всю ширину регистров.
Портируемый simd этой границы не имеет: это обычные Go-функции с интринзиками внутри. Но урок общий: мелкая функция в горячем цикле теряет на вызовах больше, чем выигрывает на векторах; см. пункт про мелкие слайсы.
Компилятор и без тебя неплох. В том же HN-треде подметили: в одном из хвалёных примеров ручного SIMD компилятор сам вытянул 77% производительности. Прежде чем векторизовать, проверь, не хватает ли чистого скалярного кода, иногда с парой лишних аккумуляторов.
Брать ли это в прод сейчас
Короткий ответ — нет, и теперь у «нет» две причины вместо одной.
Первая: Go 1.27 ещё не вышел. Я гоняю go1.27rc2, финальный релиз ждут в августе 2026-го (для тех кто читает из будущего); всё, что здесь написано, остаётся снимком release candidate.
Вторая: «экспериментальный» стоит понимать буквально. Флаг GOEXPERIMENT=simd обязателен; предложение включить SIMD на amd64 по умолчанию (#78979) висит в статусе Hold, в 1.27 оно не попало. API нестабилен не формально: у сдвигов ShiftAll{Left,Right} аргумент меняется с uint64 на uint8, и примеры из статей про 1.26 местами уже не собираются. На arm64 пока только 128-битные векторы. А низкоуровневый archsimd требует плясок: marselester ловил лишние bounds-check и вычищал их через unsafe.
А вот идеи под всем этим — почему компилятор не векторизует, откуда на самом деле берётся ускорение, где память бьёт по рукам — переживут и релиз, и смену сигнатур.
Выводы
К SIMD стоит тянуться, когда совпадают три вещи: есть горячий узкий цикл; узкое место — процессор, а не память; и это измерено, а не додумано.
После двух машин я бы расшифровал «измерено». На гибридном CPU меряй с пиновкой, иначе сравниваешь погоду. На своих размерах данных, по обе стороны кэша: мои 5x и 2,2x выдал один код на одной машине. И на том железе, где коду жить: вектор на тысяче элементов окупался на Mac и не окупался на i9, а стену памяти показал только i9.
Цифры между архитектурами не переносятся — переносится код, и это главная новость Go 1.27. Одна функция без правок разложилась в Neon и AVX2, сама выбрала ширину вектора и не попросила ни строчки ассемблера. В обзоре 1.26 я писал «поддержка ARM64 планируется», а теперь проверил обещание на собственных двух машинах ещё до релиза. Работает.
Там, где вектор не окупается, лучший «SIMD» — ручные аккумуляторы: четыре штуки на i9 дают 3x из 5, восемь на M3 Pro подходят к вектору вплотную. И читаются они любым разработчиком.
Весь код и бенчи с обеих машин лежат в репозитории dsbasko/sandbox-simd: клонируй, ставь go1.27rc2, гоняй на своём железе. Команды запуска и все ручки из статьи собраны в README. Особенно интересны цифры с процессоров, которых у меня нет. Ветку @simd512 я проверил только на корректность под эмулятором, поэтому живой AVX-512 (серверные Xeon, AMD Zen 4 и 5) и свежие M4 ценнее всего. Что почитать дальше:
-
proposal #73787 и #78902: дизайн и история пакета из первых рук;
-
«Everyone Should Know SIMD» Mitchell Hashimoto: то самое эссе, с которого началась эта статья, и лучшее общее введение в тему;
-
пакет
simd/archsimd, если нужен контроль над конкретными инструкциями, а не портируемость.
Источники
-
Код и бенчи статьи: https://github.com/dsbasko/sandbox-simd
-
Go 1.27 release notes и загрузки: https://go.dev/doc/go1.27 , https://go.dev/dl/
-
Go 1.26 release notes и блог: https://go.dev/doc/go1.26 , https://go.dev/blog/go1.26
-
Proposals: портируемый пакет https://github.com/golang/go/issues/73787 , https://github.com/golang/go/issues/78902 ; включение по умолчанию https://github.com/golang/go/issues/78979
-
Mitchell Hashimoto, «Everyone Should Know SIMD»: https://mitchellh.com/writing/everyone-should-know-simd
-
Обсуждение на Hacker News: https://news.ycombinator.com/item?id=49010648
-
Про границу Go и ассемблера: https://github.com/golang/go/issues/77647
-
marselester, разбор archsimd: https://marselester.com/go-archsimd-preview.html
-
Отключение AVX-512 на гибридных Intel: https://www.anandtech.com/show/17047/the-intel-12th-gen-core-i912900k-review-hybrid-performance-brings-hybrid-complexity/2 , https://www.tomshardware.com/news/intel-bios-update-disables-alder-lake-avx-512 ; хроника и батчи: https://github.com/zingaburga/alderlake_avx512/wiki ; die shots 13900K: https://www.tomshardware.com/news/core-i9-13900k-uncovered-in-new-die-shots
-
Intel SDE (эмулятор): https://www.intel.com/content/www/us/en/download/684897/intel-software-development-emulator.html ; Go-команда тестирует AVX-512 под SDE: https://github.com/golang/go/issues/79931
-
Ручка GODEBUG=simd в исходниках пакета: https://go.googlesource.com/go/+/refs/heads/master/src/simd/midway_common.go
-
Микроархитектура: Golden Cove https://chipsandcheese.com/p/popping-the-hood-on-golden-cove , Gracemont https://chipsandcheese.com/p/gracemont-revenge-of-the-atom-cores , Apple M1 Firestorm https://dougallj.github.io/applecpu/firestorm-simd.html
-
Пиновка бенчмарков на гибридных CPU: https://strebkov.dev/posts/shard-your-locks/
-
Память Apple M3 Pro (150 ГБ/с, спека MacBook Pro 14″): https://support.apple.com/en-us/117736
-
Обзоры Go 1.26 на Хабре: мой https://habr.com/ru/articles/995906/ , Avito https://habr.com/ru/companies/avito/articles/1000616/
-
Мой телеграм-пост с косинусной близостью на archsimd (замеры на i9-14900KF): https://t.me/bascodeCh/105
ссылка на оригинал статьи https://habr.com/ru/articles/1062972/