
Дядя Хашимото сказал разобраться, я попытался. Дядя не абы кто: Mitchell Hashimoto, сооснователь HashiCorp и автор Terraform с Vagrant, так что отмахнуться было трудно. Речь про его эссе «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 (архитектура ARM: Apple Silicon, смартфоны, часть серверов) и amd64 (Intel и AMD). Включается флагом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, архитектуру процессоров Intel и AMD. Все, кто на Apple Silicon с его архитектурой arm64, остались за бортом: в разборе фич 1.26 от Avito боль сформулировали прямо, «для ARM придётся дублировать логику». Я в своём обзоре отделался строчкой про «планируется». А в 1.27 приехал портируемый пакет simd, который подстраивается под железо сам; историю хранят proposal #73787 и #78902.
Звучит подозрительно хорошо: двенадцать лет никакого SIMD, и вдруг сразу «один код на все архитектуры». Такое проверяют дизассемблером (инструмент, который показывает машинные инструкции внутри собранного бинарника), и у меня две подопытные машины. Но сначала два коротких ликбеза: что процессор делает, когда складывает числа, и куда в этой картине встаёт SIMD. Без них цифры дальше выглядят фокусом.
Ликбез: как процессор складывает числа
Если регистры и такты для тебя рабочие будни, смело листай до установки go1.27rc2, тут ликбез для тех, кто ниже Go не спускался.
Процессор не исполняет Go. Компилятор переводит программу в машинные инструкции, элементарные команды уровня «возьми число из памяти», «сложи два числа», «сравни», «положи обратно». Что бы ни делал твой сервис, для ядра это поток таких шагов.
Считает процессор не в памяти. Вся арифметика происходит в регистрах, ячейках внутри самого ядра. Их немного, счёт идёт на десятки, зато работают они на скорости ядра; поход в оперативную память на их фоне выглядит командировкой на сотни тактов. Тактом называют один тик внутренних часов процессора; таких тиков в секунду миллиарды, и за такт ядро успевает выполнить одну или несколько простых инструкций.
Обычный регистр вмещает одно число, 32 или 64 бита. Наш будущий цикл s += x глазами железа: загрузить элемент слайса из памяти в регистр, прибавить к регистру-сумме, перейти к следующему. В псевдоинструкциях (LOAD грузит из памяти, FADD складывает дробные числа, сокращение от float add):
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: одна инструкция, много данных.

Ширина векторного регистра зависит от процессора. Наборы векторных инструкций у каждой архитектуры свои: у ARM это Neon, у x86 их сменилось несколько поколений (SSE, AVX2, AVX-512; число 512 означает ширину регистра в битах):
Набор инструкций | Железо | Ширина | 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
Ставится она как любой нестабильный тулчейн Go: отдельным бинарником рядом с основным, команды одинаковые на обеих системах:
go install golang.org/dl/go1.27rc2@latest go1.27rc2 download
Теперь пробуем импортировать simd и собрать. И сразу получаем в лоб:
imports simd: build constraints exclude all Go files in .../src/simd
Это тот момент, где многие решают, что «SIMD в Go не работает», и закрывают вкладку. На самом деле пакет спрятан за экспериментальным флагом. Build constraints задают условия, при которых файл участвует в сборке; без флага Go исключает из неё все файлы пакета simd. Добавляешь GOEXPERIMENT=simd, и всё собирается:
GOEXPERIMENT=simd go1.27rc2 run .
Флаг придётся передавать и при сборке, и при тестах, и при бенчмарках. Забудешь, и вернётся та же ошибка про build constraints.
Базовый цикл: сумма миллиона float32 на двух машинах
Прежде чем ускорять, надо померить, что ускоряем. Вот наивная сумма из sum.go, её и возьмём за точку отсчёта:
// sum.go func sumScalar(xs []float32) float32 { var s float32 for _, 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:
BenchmarkSum/Scalar/n=1000000-11 1604 697574 ns/op 5734 MB/s
Читается так: первое число говорит, сколько прогонов успел сделать бенчмарк. ns/op измеряет наносекунды на один вызов, здесь ~700 микросекунд на проход по миллиону элементов. MB/s показывает, с какой скоростью функция прожёвывает данные.
Он же на i9:
BenchmarkSum/Scalar/n=1000000-32 3396 349010 ns/op 11461.00 MB/s
Семьсот микросекунд против трёхсот пятидесяти: десктоп вдвое быстрее ещё до всякого SIMD, и ничего векторного тут нет. Напрашивается ответ: производительное P-ядро i9 разгоняется до 6 ГГц и берёт частотой. У гибридных Intel два сорта ядер, мощные P и экономичные E; им ниже посвящена отдельная секция. Запомни его: в секции «Откуда ускорение» я пересчитаю оба числа в такты, и частота окажется виновата только наполовину.
Суффиксы -11 и -32 в имени показывают GOMAXPROCS двух машин (число логических процессоров, доступных Go); сам бенчмарк однопоточный. Запусти у себя: порядок будет тот же, точные числа другие.
Первый вектор: тот же код, обе машины
Векторная версия всюду строится по одному шаблону. Завести вектор-аккумулятор (переменную, в которой копится сумма), идти по слайсу кусками шириной в вектор, складывать куски векторно. В конце свести полосы в одно число и добить скалярный хвост. Вот главный цикл:
// sum.go func sumSIMD(xs []float32) float32 { var acc simd.Float32s // вектор-аккумулятор, все полосы = 0 w := acc.Len() // сколько float32 влезает в вектор i := 0 for ; 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 float32 for _, 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 на каждой машине. Поможет objdump, дизассемблер из комплекта Go: он печатает машинные инструкции готового бинарника. Флаг -gcflags='-l' запрещает компилятору инлайнить функции: иначе sumSIMD растворится в вызывающем коде и grep её не найдёт. Команды одинаковые, различается вывод. 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: имя читается по частям, V векторная форма, ADD сложение, PS packed single, пачка float32. За 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
В первой версии статьи я добывал эту тройку кросс-компиляцией (собирал amd64-бинарник на Mac, не имея железа, где он запустится) и разглядывал как теорию; теперь она нативная. Компилятор собрал из моей sumSIMD четыре ветки: @simd0 (скалярная эмуляция), @simd128, @simd256, @simd512. А сверху положил диспетчер, и это буквально switch. Вот он в objdump, служебные строки я вырезал:
CALL simd.VectorBitSize(SB) CMPQ AX, $0x80 // 128? -> проверить Emulated -> @simd0 либо @simd128 CMPQ AX, $0x100 // 256? -> CALL gosimd.sumSIMD@simd256 CMPQ AX, $0x200 // 512? -> CALL gosimd.sumSIMD@simd512
Обычные сравнения с константами, читаются глазами. Рантайм при старте спрашивает процессор, что тот умеет; у x86 для этого есть инструкция CPUID: программа спрашивает, процессор отвечает списком поддерживаемых фич. На моём i9 VectorBitSize() возвращает 256, поэтому живёт ветка @simd256. Двенадцать лет в Go нельзя было получить векторную инструкцию без ассемблера, а теперь go build молча раскладывает по бинарнику четыре реализации и сам их коммутирует. От этой буквальности я до сих пор слегка в восторге.

А ветка @simd512 на этой машине лежит мёртвым грузом, и это не жадность конкретной модели. У потребительских Intel с двенадцатого поколения два сорта ядер, и экономичные E-ядра AVX-512 не умеют. Планировщик ОС не обещает, что векторный поток попадёт на P-ядро. Поэтому Intel сначала прятала AVX-512 из CPUID, а с 2022-го отключает его на производстве; 13-е и 14-е поколения решение унаследовали.
Я проверил всерьёз, прежде чем писать «мёртвый груз». Прямое исполнение инструкции с 512-битным регистром zmm на этом i9 заканчивается сигналом Illegal instruction («недопустимая инструкция»), и пути назад нет. Ранним Alder Lake (так называлось 12-е поколение) помогали старый 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, Software Development Emulator: это эмулятор x86-процессоров от самой Intel; им пользуется и команда 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 (через переменную окружения GODEBUG Go включает отладочные режимы рантайма): 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.go func sumScalar4(xs []float32) float32 { var s0, s1, s2, s3 float32 i := 0 for ; 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 + s3 for ; 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.
Тогда я проверил двенадцать, ports_test.go: sumScalarAcc12 обгоняет вектор с одним аккумулятором, 159 микросекунд против 164 в одном и том же прогоне. Скаляр. Быстрее векторной версии. Без единой SIMD-инструкции. Почему предел именно на дюжине, объясню ниже, у конвейеров. Пока запомни сам факт: полосы перестают конвертироваться в иксы задолго до восьми, а сколько цепочек прожуёт ядро, у каждого своё.

Ставка вскрыта: «8 полос дадут 8x» не работает, потому что и скаляр, и вектор упираются в одно и то же, в латентность сложения и цепочку зависимостей. Латентностью называют число тактов от старта инструкции до готового результата: пока сумма не готова, следующее звено цепочки не стартует. SIMD ценен тем, что одним движением и рвёт цепочку, и складывает пачками. Но большую часть ускорения на обеих моих машинах принёс разрыв цепочки.
Обещанный пересчёт в такты. На i9 перемерил скаляр с сэмплером частоты: ядро держало 5,70 ГГц, вышло 2,03 такта на элемент. Это ровно латентность инструкции ADDSS (скалярное float-сложение x86, тот самый s += x) по справочнику uops.info с замерами всех x86-инструкций.
На Mac сэмплера частоты нет, а powermetrics (штатная утилита macOS, умеющая показывать частоту ядер) хочет root. Выкрутился цепочкой целочисленных сложений: у add x,x,#1 латентность ровно один такт на любом arm64, так что время звена такой цепочки равно периоду такта. Частотомер из шестнадцати строк (freq_neon.c) намерил 4,03 ГГц. Go-скаляр при этой частоте тратит 2,7-2,8 такта на элемент, чистая регистровая цепочка FADD без Go-обвязки укладывается в 2,66. Кстати, это меньше трёх. Таблицы dougallj, открытые реверс-инженерные замеры Apple-ядер, дают для M1 латентность 3; таблиц для M3 нет, и, судя по замеру, сложение у него на треть такта быстрее.
Вот и вся разгадка базового цикла: двукратная разница скаляров получается из 1,4x частоты, умноженных на ~1,4x латентности сложения.
Внеочередное исполнение (out-of-order: ядро само переставляет независимые инструкции, чтобы не простаивать) цепочке, кстати, не помогает совсем. Переставлять float-сложения железу запрещено так же, как компилятору, и цикл идёт строго со скоростью латентности.
С вектором интереснее. VADDPS с той же латентностью 2 могла бы прокручивать итерацию за два такта, а тратит 3,2. Дизассемблер горячего цикла (того, где программа проводит основное время) показывает куда: на одну VADDPS приходится 17 инструкций обвязки, это проверки границ слайса (bounds check) и пересчёт указателя на каждой итерации. Считаем: 8 полос × 2,03/3,21 = 5,06x. Бенчмарк показал ровно 5,0x: иксы не «утекли», их съела обвязка.
На Mac обвязка даже чуть короче, 15 инструкций на итерацию, я их пересчитал в дизассемблере. Но декодер (входная часть ядра, разбирающая поток инструкций перед исполнением) у M3 глотает по восемь за такт, и вся обвязка укладывается в неполные два такта, пока цепочка ждёт свои 2,66. Обвязка прячется под латентность целиком; замер подтверждает: 2,6 такта на векторное сложение, ровно латентность. Отсюда ровные 4x Mac; у i9 цепочка короче собственной обвязки, отсюда 5 вместо 8.
Напоследок симметрия, которой я не ожидал; в первой публикации здесь стояла оговорка «расчёт по открытым таблицам микроархитектур, не замер», теперь это замер, причём с обеих сторон. P-ядро Raptor Lake умеет два векторных сложения за такт по 8 полос, у ядра M-серии четыре конвейера по 4 полосы. Конвейером тут зовётся тот самый блок сложения из начала секции, у производителей для него разные имена. И там и там выходит 16 float32 за такт.
Подтверждают это чистые регистровые цепочки без Go-обвязки: microbench/ports.c для i9 и ports_neon.c для Mac. Признаюсь: на C я не пишу, обе микробенчилки мне помог собрать Claude; код лежит в репозитории, проверяй. На i9 скаляр упирается в 2 сложения за такт хоть с 4, хоть с 12 аккумуляторами. Причина в портах, входах в исполнительные блоки ядра: сколько портов умеют операцию, столько её экземпляров стартует за такт. Портов с float-сложением (FP, floating point) у i9 два, p1 и p5 в нумерации Intel, и VADDPS живёт на них же (uops.info). Вектор выжимает 2,02 инструкции за такт, все 16 полос.
На Mac картинка зеркальная: и скалярный fadd, и векторный fadd .4s выходят на плато 4 инструкции за такт. Вектору это даёт 15,9 float32 за такт: пик M3, который я раньше брал из таблиц M1, теперь снят с самого M3. Потолок «оптимальный вектор против оптимального скаляра» по портам выходит честным: 8x на i9 и 4x на Mac. За моими 5x против наивного цикла и 1,6x против scalar4 стоит цена обвязки и одного аккумулятора, не железа.
Те же конвейеры объясняют и аппетит к цепочкам: цепочек нужно «число блоков × латентность». Два порта i9 с латентностью 2 насытились четырьмя аккумуляторами, а четырём конвейерам Mac с их почти тремя тактами впору дюжина; потому scalar8 там и не потолок.

Аккумуляторы помогают и вектору. sumSIMD4acc из того же ports_test.go разгоняет Mac со 164 до 102 микросекунд: 6,8x над наивным циклом при четырёх полосах. Иксов больше, чем полос: «потолок ширины» отсчитывается от честного скаляра, а не от наивного. Только в порты я так и не упёрся: лучший Go-вектор берёт 2,4 элемента за такт из 16 возможных по портам, остальное съедает обвязка, которую генератор кода Go вешает на каждую загрузку. И скаляр, и вектор в Go останавливаются о собственный фронтенд (часть ядра, что читает и подаёт инструкции на исполнение) задолго до пределов кремния.
P-ядра, E-ядра и куда сядет твой бенчмарк
Сначала про второй сорт ядер, память следом. У 14900KF 8 производительных P-ядер (P значит performance, до 6,0 ГГц) и 16 экономичных E-ядер (E значит efficiency, 4,4 ГГц); AVX2 умеют оба, ветка @simd256 исполняется на любом. Линуксовая утилита taskset привязывает процесс к выбранным ядрам, это называют пиновкой. Пиную бенчмарк:
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, микроархитектура E-ядер.
У Mac лотерея своя. M3 Pro тоже гибрид, 5 P-ядер и 6 E-ядер, но taskset в macOS нет: ядро для потока выбирает планировщик по QoS-классу процесса. QoS расшифровывается как Quality of Service: приоритет, который macOS назначает каждому процессу, от интерактивного до фонового. Утилита taskpolicy -c background помечает процесс фоновым, и система уводит его на E-ядра. Вектор замедляется вчетверо, 717 микросекунд против 173, скаляр почти так же; ускорение внутри E-ядра остаётся ~4x. Тот же бинарник, та же машина, другой QoS. Запустишь Go-утилиту фоновым классом, и все её вектора поедут на экономичных ядрах.
Стена памяти: i9 упёрся, Mac до своей не доехал
В первой версии я написал: на 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 МБ, и ускорение сложилось вдвое. Дальше начинается оперативная память, она же DRAM, и всё выравнивается: 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 ГБ/с. Но «не видно» не значит «нет». Одно ядро M-серии умеет тянуть из DRAM порядка сотни ГБ/с (AnandTech намерил ~102 на M1 Max), а моя сумма просит 23. До собственной стены Mac попросту не доезжает: его держит цепочка сложений.

Причина видна по цифрам, а не по вере в широкую шину. Apple заявляет для M3 Pro 150 ГБ/с унифицированной памяти (общая для CPU и GPU, распаяна рядом с чипом); мой i9 с двумя каналами DDR5 умеет ~90. Но дело не только в пиковой пропускной способности. Моя векторная сумма ест ~23 ГБ/с: Mac столько подвозит из DRAM не напрягаясь, и код упирается в цепочку сложений, а не в память. На i9 в кэше SIMD хочет 57 ГБ/с, а DRAM отдаёт одному ядру только ~24: за границей L3 узким местом становится подвоз.
Арифметика на полях: даже стукнувшись в стену, i9 обрабатывает элемент из DRAM за 0,169 нс; Mac из своего кэша тратит 0,173. Упёршийся в память десктоп всё ещё не медленнее.
Вывод прежний, но теперь с доказательством с обеих сторон: меряй на своих размерах и своём железе. За «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 (fused multiply-add: умножение и сложение одной инструкцией) и четырьмя аккумуляторами на каждую сумму показал 341 ns. Косинус тащит три суммы за проход (скалярное произведение и две нормы, то есть длины векторов), и их свод плюс хвост съедают всю ширину. Чистый dot product, одно скалярное произведение без нормировок, на тех же данных векторизовался в 1,6 раза, косинус окупился лишь на 12 тысячах элементов: 1,8 µs против 5,1.
Компилятор сам не векторизует. «Пиши обычный цикл, компилятор всё сделает» к Go не относится, и решение сознательное: автовекторизация суммы float меняет порядок сложений, а с ним результат. Там, где автовекторизация есть (в LLVM и GCC, компиляторах мира C и C++), она хрупкая: стоит появиться раннему break или работе через указатели, и оптимизация молча отваливается; Хашимото называет компиляторы «very poor at it», очень плохими в этом деле. Рассчитывать на неё нельзя.
В хвосте массива живут тонкие баги. Если длина не делится на ширину вектора, остаток обрабатывается отдельно; у нас это скалярный for в конце. Соблазн запихнуть фиктивные элементы и сделать ещё одну векторную итерацию ломается о запись за границу массива: Store в чужую память кончается в лучшем случае паникой, в худшем порчей данных. Хвост дописывают скаляром, и точка.
Граница Go и ассемблера может съесть весь выигрыш. Это про старый путь с ручными asm-вставками. В одном обсуждении человек разгонял поиск по ДНК, и SIMD-версия вышла в разы медленнее. Функция вызывалась сотни тысяч раз, и на каждом переходе Go-ассемблер-Go процессор платил за VZEROUPPER, служебную инструкцию, которую x86 требует на границе между AVX- и SSE-кодом. AVX2-вариант оказался медленнее SSSE3, старого 128-битного набора инструкций: накладные расходы сожрали всю ширину регистров.
Портируемый 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. И не путай ровные иксы с быстрым чипом. За стабильными 4x Mac стоит цепочка, работающая на латентности сложения; в пересчёте на такты 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, если нужен контроль над конкретными инструкциями, а не портируемость.
UPD 26.07.
Mad__Max в комментариях справедливо пнул: пересчитайте обе машины в такты. Спасибо. Из пинка выросли правки: арифметика «частота × латентность» в базовом цикле и декомпозиции, замер портов вместо оговорки «расчёт», новые microbench/ports.c и ports_test.go в репозитории. Тем же вечером добрал Mac-сторону: NEON-микробенч ports_neon.c и частотомер freq_neon.c на целочисленной цепочке, ведь powermetrics без root недоступен. Пик M3 и латентность FADD теперь замер, а не расчёт, и двенадцать аккумуляторов обогнали вектор. Выводы не поменялись, объяснение «куда делись иксы» стало точным.
