В Go 1.27 завезли SIMD. На Mac он дал ровные 4x, на Intel i9 то 2x, то 5x

от автора

Дядя Хашимото сказал разобраться, я попытался. Речь про его эссе «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://habr.com/ru/articles/1062972/