Дядя Хашимото сказал разобраться, я попытался. Дядя не абы кто: 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 теперь замер, а не расчёт, и двенадцать аккумуляторов обогнали вектор. Выводы не поменялись, объяснение «куда делись иксы» стало точным.