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

alex_tulski
25.07.2026 21:35Спасибо за статью, очень интересно и подробно описано.
Если не секрет, где на практике планируете использовать SIMD?

dsbasko Автор
25.07.2026 21:35Спасибо за теплые слова!
Сейчас больша часть времени уходит на доработку систем SSO и AMC (access control management), и кандидата я вижу именно в них. Деталей рассказать не могу, но область назову.
Горячий пусть авторизации - это проверка прав и согласий на каждый запрос. По сути пересечения множеств коротких слов, пачками до тысячи элементов.
Горячий узкий цикл с данными, которые влезают в кэш. В целом там все готово, осталось замерить на реальном железе, у нас на серверах кстати поддерживается AVX-512.Если будут интересные цифры, доработаю статью.

Mad__Max
25.07.2026 21:35“Реальность скромнее: Mitchell Hashimoto, который векторизовал терминал Ghostty, приводит для AVX2 не теоретические 8x, а реальные «more like a 5x». Куда утекает разница, увидим на собственных бенчмарках.”
Тут останови себя и сделай ставку. На Mac четыре полосы дали 4,2x, почти потолок из таблицы ширин. У i9 полос восемь, а вышло 5x: куда делись ещё три икса? Сформулируй версию и держи её до секции про декомпозицию, там будет чем проверить.
Никуда она не “утекает”, она наоборот “притекает” - из завышенных ожиданий и неверных предположений. С чего вообще взято, что на SIMD ускорение по сравнению со скаляром должно быть именно х8? Оно идет из неверного предположения, что скаляр выполняется строго в жесткой цепочке, одна операция строго за другой четко как записано в алгоритме составленной программы, с темпом исполнения не более 1 инструкции за 1 такт ЦП над 1 числом за раз. А в SIMD 256 - 1 инструкция сразу на 8 fp32 числах упакованных в 256 бит вектор. И неверные, завышенные ожидая проистекающие из этого: хочу получить х8 от использования SIMD.
Но извините, практически все современные процессоры кроме некоторых суперупрощенных встраиваемых решений типа микроконтроллеров (начиная еще с самого первого Pentium!) это не скалярные процессоры, а суперскалярные. Это означает, что они могут (и постоянно это делают на практике) выполнять больше чем 1 скалярную инструкцию за такт даже без всяких векторов, SIMD/MIMD и прочих оптимизаций и расширенных наборов команд. И даже при наличии сильной взаимозависимости в обрабатываемых данных. И имеют различные встроенные оптимизации для этого, работающие при этом на аппаратном уровне ядра самого ЦП, без необходимости какого-либо участия программиста или компилятора в этом. Это в частности “спекулятивное исполнение”, включая предсказание ветвлений (и очень длинные линейные циклы как в подобном тесте - простейший случай для них) и внеочередное исполнение(out-of-order execution), когда инструкции на исполняющих устройствах ЦП по факту исполняются совсем не в том порядке как они записаны в программе программистом. А потом уже только на “выходе” (перед записью результата в память) переупорядочиваются заново, для сохранения правильных результатов вычислений ожидаемых от выполняющейся программы.
Насколько помню в этом поколении процессоров Intel имеется сразу по 6 портов для выполнения скалярных инструкций с плавающей точкой(а общее, вместе с целочисленными и загрузкой/выгрузкой ЕМНИП вообще 11 или 12). И теоретический темп выполнения скаляра при условии “расшивки зависимостей” и своевременного “подвоза” данных из памяти это соответственно 6 скалярных инструкций/такт над fp32 данными.
Для AVX2 же теоретический максимум: 2х256/32 = 16 операций над fp32 за такт. Т.к. у этих процессоров только по 2 штуки 256 битных SIMD модуля на ядро и они могут выполнять 256 битные векторные инструкции с темпом не выше 2 штук/такт.
Так что теоретический потолок ускорения от применения SIMD по сравнению с “правильно” приготовленным скаляром вообще совсем не х8, а всего лишь 16/6 = ~2.66x
Что вы собственно и продемонстрировали не только в теории, но и на практике в варианте с несколькими аккумуляторами для промежуточных сложений помогая ЦП эффективно развязать зависимости в данных (но он делает это и без вас даже в базовом варианте – просто намного менее эффективно чем это может сделать программист понимающий весь алгоритм и цель обработки данных в целом). У вас разница скаляр /SIMD даже меньше получилась (чуть меньше 2х), но это из-за того, что как вы верно подметили использование SIMD не “бесплатно” - есть еще и “накладные расходы”. А сам основной цикл(без учета “накладных”) думаю, выполняется с разницей в скорости близкой как раз к этим теоретическим 2.6х.
Семьсот микросекунд против трёхсот пятидесяти: десктоп вдвое быстрее ещё до всякого SIMD, и ничего векторного тут нет, P-ядро i9 разгоняется до 6 ГГц и берёт частотой.
Вот только разница в частоте у этих процессоров 4 ГГц vs 6 ГГц . И это еще только при условии разгона до максимального турбобуста во время тестов - если нет, то разница частот будет еще меньше, базовая частота например вообще у Mac выше: 3.6 ГГц vs 3.2 ГГц. Т.е. максимум это +50% в пользу Intel по частоте: 6/4=150%. А вот разница в скорости реальных вычислений - все +100% в пользу Intel: 697574/349010 = 199.9%.
В других местах это тоже прослеживается. В варианте с SIMD теоретический потолок у Intel тоже всего на 50% выше как максимум. Т.к. общая теоретическая пиковая пропускная способность SIMD блоков у этих процессоров вообще по факту одинаковая несмотря на разницу в максимальной “ширине” SIMD: 2х256 bit SIMD блока в Intel или 4х128 bit SIMD в Mac дают одинаковый теоретический “потолок” в 16 32fp/такт. А разница по частоте - не более 50%. Так что в операций/сек потолок тоже не более чем на 50% отличается.
Разница же в реальной скорости работы в SIMD режиме оказывается даже больше чем 100% наблюдавшихся в скалярном: у вас получилось 57547/24486 = 235% (+135% в пользу Intel).
Ну и по “стене памяти”. У вас вроде бы выходит, что у Мас как бы нет “стены памяти”. Но это просто потому, что он до своей собственной “стены памяти” не достал “даже в прыжке с разгона”: из-за относительно невысокой скорости вычислений ядром он просто не доходит до того момента когда загрузка данных из памяти станет узким местом (т.к. узкое место у него в другом месте - ядро, а не память). А вот Intel – допрыгнул (и стукнулся в нее при больших объемах тестовых данных). Но так даже с уже серьезно упавшей производительностью при чтении данных уже из основной памяти без ограничений по их объему, Intel все-равно продолжает их обрабатывать даже немного быстрее чем Mac берущий компактный набор данных из своего локального L2-L3 кэша!
При чтении статьи помимо действительно хорошего обзора готовящихся SIMD обновлений в Go ненавязчиво складывается впечатление/продвигается мысль “вот посмотрите какой процессор у Mac” получился эффективный, а Intel работает как-то не особо хорошо". Хотя по факту все полученные и приведенные в статье данные вообще-то показывают как раз ровно обратное: конкретная микроархитектура Intel работает наоборот намного эффективнее, чем у Mac M3. По крайней мере пока речь идет именно о производительности(Mac вероятно энергоэффективнее и “холоднее” и может выдать больше операций/Вт, но тут это вообще не проверялось и не сравнивалось). И позволяет на практике намного ближе подбираться к своему теоретическому максимуму заданному базовыми аппаратными ограничениями: в виде количества исполняющих вычислительных блоков + тактовая частота. А вот Mac использует имеющиеся у него аппаратные ресурсы не особо эффективно.

dsbasko Автор
25.07.2026 21:35Спасибо за разбор, это лучший фидбек который статья могла получить, развернуто и с цифрами. Часть пунктов настолько справедлива что уйдет в правки статьи. По части принесу встречные замеры.
«Берет частотой» признаю, моя недоработка. Частота объясняет примерно 1.5x из двукратной разницы на скаляре, 6.0 ГГц против ~4.05. Остальное добирает латентность цепочки FADD, 2 такта у Raptor Cove против 3 у M3 (uops.info по ADDSS и таблицы dougallj по Firestorm). Пересчитал свои же числа, на i9 выходит ~2 такта на элемент (в чистом замере при 5.7 ГГц ровно 2.03), на Mac ~2.8. То есть i9 и правда делает больше работы за такт а не только чаще тикает. Этого пересчета в статье нет, и зря.
Про стену памяти соглашусь с акцентом. Mac не «не имеет стены», он до нее не доезжает. Я писал что код упирается в цепочку сложений а не в память, но ваша формулировка честнее моего заголовка. Ядро M-серии тянет из DRAM порядка сотни ГБ/с (Anandtech намерял ~102 на M1 Max), моя сумма ест 23-24. Проверил и пункт про DRAM против кэша, у i9 из памяти 0.167-0.169 нс на элемент, у Mac из кэша 0.173. Действительно не медленее. Вот это я упустил полностью, даже не думал в эту сторону.
Теперь с чем не соглашусь, это «6 портов для скалярных FP» и потолок 16/6 ≈ 2.66x.
По uops.info скалярный ADDSS и векторный VADDPS ymm у Golden/Raptor Cove живут на одних и тех же двух портах p1 и p5. У обоих латентность 2 и темп 2 инструкции за такт. Шестерка похоже пришла из соседней ячейки памяти, пять целочисленных ALU или общие 12 портов ядра.
Проверил и железом на том же P-ядре что в статье. Чистые регистровые цепочки на C без обвязки Go, частота во время прогона 5.70 ГГц по сэмплеру.
sc_add_1 2.00 такта/инстр латентность ADDSS sc_add_4..12 1.98-2.01 инстр/такт плато, 2 порта v_add_4 2.02 инстр/такт = 16.1 fp32/тактУмей скаляр 6 сложений за такт, двенадцать независимых цепочек показали бы 0.029 нс на сложение. Показывают 0.087. Так что портовый потолок вектор/скаляр получается 16/2, те самые 8x. Только это потолок пропускной способности когда зависимости развязаны с обеих сторон, а не «сколько даст вектор поверх наивного цикла». Кстати практикой 2.66x тоже не подтверждается, если развязать цепочки в обеих версиях, мои Go-замеры дают 1.4-1.8x, еще меньше.
Похожая история с «процессор развязывает зависимости и без вас, просто менее эффективно». С этой цепочкой так не получается. Замер дает 2.03 такта на элемент, ровно латентность сложения, аппаратной помощи ноль тактов. OoO прячет под цепочку загрузки и счетчик цикла, но переставлять сложения float ему запрещено, поменяется результат округления. Поэтому аккумуляторы не подсказка процессору, а разрешение на переассоциацию которое может выдать только программист.
Откуда тогда 5x вместо 8x. Дизассемблировал горячий цикл sumSIMD@simd256, на одну VADDPS там 17 инструкций, bounds check и пересчет указателя слайса на каждой итерации. Итерация выходит за 3.2 такта вместо латентностных двух. 8 полос × 2.03/3.21 = 5.06x, сходится с бенчмарком до второго знака. На Mac та же обвязка прячется под латентность 3, отсюда его ровные 4x. Вобщем виноват кодоген simd-пакета, а не порты.
Про впечатление «Mac эффективный, а Intel не очень». Такой задумки не было, но перечитал заголовок и секцию про стену вашими глазами и вижу откуда оно берется.
Фидбек воистину полезный и с ним я что-то буду делать. Спокойно перемерю все еще раз и доработаю статью, добавлю пересчет на такт с латентностями и честный разбор куда деваются иксы. Заодно хочу добрать замер с 12 аккумуляторами на Mac, поидее M3 с латентностью 3 и четырьмя FP-конвейерами должен раскрыться именно там, на это я не расчитывал когда писал про восемь. Еще раз спасибо за пункт про DRAM против кэша и за пинок пересчитать все в тактах, об этом я не подумал.
Такие комментарии могут быть ценнее самой статьи.

beeruser
25.07.2026 21:35Про стену памяти соглашусь с акцентом. Mac не «не имеет стены», он до нее не доезжает. Я писал что код упирается в цепочку сложений а не в память, но ваша формулировка честнее моего заголовка.
Очень сомнительно. M1 и далее выполняют 16 fmad операций за такт (4 блока NEON), т.е. 256ГФлопс. В таком простом цикле не должно быть никаких проблем достигнуть пика. Т.е. i9 и M3 не отличаются по производительности вычислений на такт.
Заодно хочу добрать замер с 12 аккумуляторами на Mac
Вам нужно 4 simd аккумулятора.
плюс мёртвая ветка AVX-512 в бинарнике
На AMD эта ветка не мёртвая :)

dsbasko Автор
25.07.2026 21:35Пик у меня замерен на этом самом M3, а не взят из таблиц. Шестнадцать независимых цепочек fmla дают ровно 4 инструкции за такт, те самые 16 fmad по fp32. Только выходит 129 ГФлопс на ядро, не 256: 16 x 2 флопа x 4.03 ГГц.
Проблема достигнуть пика в простом цикле есть, причем у любого языка. Пику нужны 4 независимые инструкции каждый такт, а сумма тащит одну цепочку, где следующее сложение ждет предыдущее. Потолок такой цепочки равен 1/латентность, то есть 0.38 инструкции за такт, девять процентов пика, хоть на ручном ассемблере. Чтобы прокормить все блоки, аккумуляторов нужно блоки x латентность, около двенадцати, и плато в замере наступает как раз на двенадцати цепочках (microbench/ports_neon.c в репозитории). Сам компилятор так разложить цикл не имеет права: переассоциация float меняет результат, Go под честным IEEE 754 на нее не идет.
Что цикл упирается не в память, видно на одном массиве 4 МБ. Наивный цикл 693 мкс, шестнадцать скалярных аккумуляторов 155, вектор с четырьмя 102. Чтения во всех трех версиях одни и те же, ускорение в 6.8 раза дала одна лишь перестановка сложений.
С вашими 4 simd-аккумуляторами в Go так и вышло: версия с четырьмя лучшая на миллионе, 102 мкс, замер уже уехал в статью. Но работает четверка не потому, что блоков четыре. Чистым цепочкам для насыщения нужно двенадцать, просто Go-вектор упирается в обвязку раньше, чем в порты, и восемь аккумуляторов на миллионе уже чуть медленнее четырех. По пикам i9 и M3 равны, об этом в статье прямо написано. В этой задаче их различает латентность звена цепочки, 2.03 против 2.66, оба числа замерены.
Про AMD согласен, поэтому в конце статьи и стоит просьба к владельцам Zen 4/5 прогнать бенчи. Ну и касаемо AMD. Мерил на том что есть :-)

KestutisS
25.07.2026 21:35Просто мысли: я не знаю, как данные процессоры работает если есть выравнивание не такое, как требуется. Itanium, как понимаю, бросал ошибку при не соответствии, x86 процессоры обычно старались подстраиваться, но у этого была цена (в такты или из части). Возможно из-за этого "работает, но не так скоро".

dsbasko Автор
25.07.2026 21:35Проверил дизасмом на обеих машинах, выравнивание оказалось ни при чем: загрузки его попросту не требуют. Go гарантирует слайсу float32 только 4 байта, поэтому компилятор выровненных инструкций и не генерирует. На arm64 в горячем цикле стоит FMOVQ без требований к адресу, на amd64 загрузка вообще слита со сложением в VADDPS 0(DX), Y1, Y1, а VEX-кодировке все равно, выровнен ли адрес. Про Itanium вы правы, тот бросал фолт. Современные x86 и arm64 прощают, небольшой штраф остается лишь на пересечении кэш-линии.
Причем в этой задаче загрузки стоят ноль тактов, что видно из замера: Go-цикл со всей обвязкой идет 2.62-2.65 такта на вектор, а голая регистровая цепочка fadd без обращений к памяти идет 2.66. За «работает, но не так скоро» отвечает цепочка зависимостей. На одном и том же массиве 4 МБ наивный цикл занимает 693 мкс, двенадцать скалярных аккумуляторов 159, вектор 102, хотя загрузки и выравнивание везде одинаковые, менялся только порядок сложений. Подробный разбор в секции про декомпозицию и в UPD.

vcKomm
25.07.2026 21:35Разбор хороший, но сама статья тоже клодом написана — панибратство, характерные обороты. И чем ближе к концу, тем меньше редактуры.

unxed
25.07.2026 21:35И без SIMD добивался сишной скорости (вот, например, упаковка в LZMA быстрее 7z). Офигеть что я смогу творить ТЕПЕРЬ :)
// поскольку я пилю клон Far на go, задачки заставить гошный код работать как минимум не медленнее сей у меня случаются регулярно
unxed
25.07.2026 21:35Или вот ещё красивая история про go-оптимизацию, тем кто зайдёт в коменты к этому посту должно быть интересно: портировал zlib (мне нужен был побитово совместимый с ней поток на выходе) транспиляцией через wasm2go + оптимизации горячих мест поверх, скорость на выходе чуть выше сишной:
https://github.com/unxed/zlib4go
AnruKitataze
Мое уважение за огромную качественную проделанную работу! И рад этой новости. На маках, конечно, прод у меня на работе не крутится, но всегда приятно, когда твоё железо используется более грамотно и эффективно. Круто!
dsbasko Автор
Спасибо за теплые слова! Я очень рад, что смог нанести вам пользу :-)