Go добавляет SIMD не как набор инструкций под конкретный процессор, а как переносимый слой поверх очень разных векторных расширений. Разбираем, почему фиксированные размеры векторов убрали из системы типов, как компилятор специализирует код под длину вектора и как устроены маски, эмуляция и переключение через GODEBUG.
Почему фиксированный размер вектора не годится
Векторные расширения различаются сразу по нескольким измерениям: размер вектора, обработка масок и набор операций. По размеру картина такая:
- один фиксированный размер: wasm, PowerPC и s390x — 128 бит;
- несколько фиксированных размеров: amd64 — 128, 256 и 512; loong64 — 128 и 256;
- riscv64 — векторы неопределённого размера от 128 до 65536 бит, длина ограничена степенями двойки;
- Arm64 — один фиксированный размер (128 бит, NEON) и один переменный (128–2048 бит, только степени двойки, SVE).
Даже внутри одной архитектуры конкретный экземпляр требует проверок возможностей: amd64 — это AVX, AVX2 или AVX512? Arm64 — NEON или SVE? Если SVE, то какого размера и какой вариант: SVE, SVE2 или SVE2.1?
Именно поэтому новый пакет simd убирает векторы фиксированного размера из системы типов и поддерживает только операции, входящие в пересечение всех платформ. Пробелы в пересечении заполняются эффективной эмуляцией через другие SIMD-инструкции. На платформах без SIMD-инструкций или без поддержки низкоуровневых интринсиковфункция или метод, которую компилятор распознаёт и заменяет конкретной машинной инструкцией все операции эмулируются, поэтому код с пакетом simd всегда будет работать.
Два уровня API
Предлагается двухуровневый подход: низкоуровневый архитектурно-специфичный API с интринсиками и высокоуровневый переносимый векторный API. Аналогия из issue: низкоуровневый API подобен пакету syscall, а высокоуровневый — пакету os.
Низкоуровневые интринсики близко повторяют машинные инструкции (большинство компилируется в одну инструкцию) и служат строительными блоками для высокоуровневого API. Ожидается, что большинство кода обработки данных сможет использовать только высокоуровневый переносимый API и достигать хорошей производительности; низкоуровневый API предназначен для опытных пользователей при необходимости необычных архитектурно-специфичных операций.
Для перехода между уровнями каждый векторный тип в simd имеет метод ToArch(), возвращающий any, который можно привести к архитектурно-специфичному типу, а обратно — через функции simd.<SimdType>FromArch.
AST-специализация: как компилятор размножает код
AST-перезаписьпреобразование синтаксического дерева программы на этапе компиляции создаёт несколько специализированных копий функций, переменных и типов, которые упоминают типы simd, заменяя эти типы ссылками на размерно-специализированные типы. Каждая копия получает суффикс вида @simdNNN, где NNN — длина вектора (128, 256 или 512) либо 0, что означает эмуляцию.
Функции, которые упоминают simd внутри, но не в своей сигнатуре, превращаются в обёртки: они переключаются по уровню SIMD, определённому при старте программы, и вызывают подходящую специализированную версию. Специализированные функции вызывают другие специализированные функции напрямую, без накладных расходов на диспетчеризацию (и, возможно, с инлайнингом).
Отсюда же — ответ на вопрос, почему преобразование интерфейсов и type switch в коде с simd-типами оказывается эффективным. Выглядит это неэффективно, но компиляторная реализация simd специализирует код и оптимизирует type switch прочь.
Интринсики: методы как машинные инструкции
Компилятор распознаёт методы векторных типов как интринсик и компилирует их в соответствующую машинную инструкцию. Сами векторные типы распознаются как специальные: для их представления и передачи используются векторные регистры.
Операции определяются как методы на векторных типах. Имена операций не привязаны к архитектуре, а имя машинной инструкции указывается в комментарии для удобства поиска. Например, на AMD64 метод Add, выполняющий поэлементное сложение, компилируется в VPADDD. Для сравнения на равенство указана инструкция VCMPEQD, для сужения до uint16 — VPMOVDW.
Маски: непрозрачный тип с выбором представления
Маски представлены как непрозрачные типы, потому что их внутреннее представление зависит от архитектуры и уровня возможностей CPU:
- на AVX512 маска — один бит на элемент вектора в mask-регистре (K-регистр);
- на AVX2 — в обычном векторном регистре по элементу на элемент вектора;
- на ARM64 SVE — один бит на байт вектора.
Компилятор сам выбирает подходящее для программы представление. Маску можно использовать в операции, которая маскирует элементы, в логических операциях между масками, а также явно преобразовать в вектор. Создаются маски операциями сравнения (например, Equal возвращает Mask32x4), из вектора — преобразованием вектора в маску, и из битового шаблона — построением маски по заданному набору битов. Прямое использование масок иными способами не рекомендуется.
Компилятор выбирает представление в зависимости от того, как маска потребляется, и по возможности удаляет конверсии между представлениями. Для Equal с последующим преобразованием маски обратно в вектор выбирается VCMPEQD, дающий результат сразу в векторном регистре; для Equal с последующим сложением элементов, отмеченных маской, выбирается VPCMPD, дающий результат в K-регистре, который потребляется инструкцией VPADDD.
ToMask и BitSelect: разница семантики
ToMask() трактует «не ноль» как «установлено»: любой ненулевой байт превращается в маску. IfElse/BitSelect работают иначе — позволяют передать вектор и побитово превратить его в маску через 1.
BitSelect реализован как x.And(bitMask).Or(y.And(bitMask.Not())): он берёт биты из x там, где в bitMask установлены единицы, и биты из y там, где в bitMask нули. And(bitMask) оставляет только выбранные биты x, а y.And(bitMask.Not()) — только биты y на местах нулей маски. ToMask() для побитового выбора даёт неверный результат, потому что приводит значение к 0/1 по принципу «не ноль», то есть устанавливает все ненулевые дорожки в 1, а не сохраняет исходную битовую маску.
В примере с arm64 правильный результат получается добавлением mask.And(archsimd.BroadcastUint8x16(1)), что даёт {0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}, а ToMask() даёт {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1} — и итоговый результат меняется на {42,106,106,106,106,106,106,106,106,106,106,106,106,106,106,106}. Lane-boolean семантика ToMask() намеренна для масок, полученных из сравнений, но это неподходящий инструмент для сырого побитового выбора. Предлагается сворачивать (x&m)|(y&~m) в BIF/BIT/BSL.
Загрузка, хранение, broadcast
Объявлены функции загрузки LoadX и LoadXPart, а также BroadcastX; для типов с суффиксом nclm — отдельные LoadXnclm, LoadXnclmPart и BroadcastXnclm.
LoadX принимает срез и возвращает вектор соответствующего типа. LoadXPart отличается тем, что возвращает не только вектор, но и целое число — сколько элементов загружено. Функции с суффиксом nclm отличаются только типом возвращаемого значения: LoadFloat32x4nclm возвращает Float32x4nclm, а LoadFloat32x4nclmPart — (Float32x4nclm, int), при этом внутри вызываются те же archsimd.LoadFloat32x4 и archsimd.LoadFloat32x4Part. BroadcastX и BroadcastXnclm принимают скаляр и возвращают вектор соответствующего типа.
Операции над срезами выводятся из примитивов Load/Store: загрузка/сохранение из/в срез — ожидаемо частые полезные операции. Пример вывода: LoadUint32x4FromSlice(s []uint32) Uint32x4 возвращает LoadFloat64x4((*[4]uint32)(s)), то есть срез преобразуется в указатель на массив и передаётся в примитив Load.
В issue обсуждались варианты именования: LoadUint32x4FromSlice назван длинным, предлагалось назвать его LoadUint32x4, а форму с указателем на массив — LoadUint32x4Ptr. Другой вариант — сделать загрузку методом с фиктивным получателем: Load(*[4]uint32) и LoadFromSlice([]uint32). При этом примитивы заданы как функция LoadUint32x4(*[4]uint32) Uint32x4 и метод Store(*[4]uint32).
Арифметика с насыщением
Операции с насыщением вычисляют результат с ограничением в границах типа: AddSaturated даёт z[i] = sat(x[i] + y[i]), а ScaleSaturated — z[i] = sat(x[i] * 2^scale[i]). Для ScaleSaturated положительные показатели scale сдвигают влево с насыщением, отрицательные — вправо (арифметически для знаковых, логически для беззнаковых).
SaturateToInt8/Int16/Uint16 и подобные преобразуют элементы к более узкому типу с насыщением (знаковым или беззнаковым), упаковывая результаты в младшие элементы, а старшие обнуляя.
Расширение и сужение
MulWidenLo и MulWidenHi вычисляют произведение элементов с удвоением разрядности: MulWidenLo берёт нижние половины x и y, MulWidenHi — верхние. Для Int8x16 результат — Int16x8; для Int32x4 MulWidenLo даёт Int64x2, и MulWidenHi тоже Int64x2, но по верхним элементам. MulWidenEven перемножает чётные элементы с расширением, например Int32x4 → Int64x2.
ExtendLo2ToInt64 знаково расширяет два младших элемента до int64: принимает Int32x4, возвращает Int64x2. ExtendToInt32 знаково расширяет все элементы до int32, например Int16x8 → Int32x8. ConvertLo2ToFloat64 преобразует два младших элемента в float64: принимает Uint32x4, возвращает Float64x2.
Перестановки
Permuteоперация перестановки элементов вектора по заданным индексам выполняет перестановку элементов вектора x по индексам: z[i] = x[indices[i] % len(x)] — индекс берётся по модулю длины вектора, что гарантирует попадание в диапазон. PermuteOrZeroоперация перестановки, обнуляющая результат при отрицательном индексе отличается тем, что при отрицательном индексе результат обнуляется: если индекс неотрицателен, элемент берётся по модулю длины вектора, иначе результат равен нулю.
ConcatPermuteоперация полной перестановки по объединению двух векторов выполняет полную перестановку по объединению двух векторов: result = {xy[indices[0]], xy[indices[1]], ..., xy[indices[n]]}, где xy — конкатенация x (нижняя половина) и y (верхняя половина); используются только нужные биты для представления индекса xy.
ConcatPermute128Scalarsоперация перестановки 128-битных элементов двух 256-битных векторов трактует 256-битные векторы x и y как единый вектор из четырёх 128-битных элементов и возвращает 256-битный результат из двух элементов, заданных lo и hi. Значения lo и hi должны быть от 0 до 3 включительно, иначе возможна паника во время выполнения, а неконстантные lo, hi могут значительно ухудшить производительность.
Сдвиги
ShiftAllLeft сдвигает каждый элемент x влево на одну и ту же константу shift: z[i] = x[i] << shift. ShiftLeft сдвигает каждый элемент на своё покомпонентное значение: z[i] = x[i] << shift[i]. У обоих вариантов при превышении ширины элемента результат равен 0.
Варианты ConcatMod16/32/64 отличаются тем, что сдвиг берётся по модулю ширины (16, 32 или 64), а освободившиеся младшие биты заполняются старшими битами второго вектора y: z[i] = concat(x[i], y[i]) << (shift%16). Для покомпонентного сдвига аналогично: z[i] = concat(x[i], y[i]) << (shift[i]%16).
Lookup, Compress и Expand
LookupOrZero определён как метод для Int8x16: возвращает элементы x, выбранные по индексам из i, а если индекс вне диапазона — результат равен 0:
if 0 <= indices[i] && indices[i] < len(table) {
result[i] = table[indices[i]]
} else {
result[i] = 0
}Compress упаковывает элементы x, отмеченные маской, в младшие индексированные элементы результата, а оставшиеся элементы обнуляются. Expand выполняет обратную операцию: разворачивает младшие элементы x в позиции, отмеченные маской, результата. Для Compress указана инструкция VPCOMPRESSB с требуемой возможностью CPU AVX512VBMI2, для Expand — VPEXPANDD с AVX512.
Reinterpret и reshape
Методы reinterpret/reshape помечены как Deprecated и переинтерпретируют биты вектора без изменения значений: AsUint16x8 у Float32x4 возвращает x.ToBits().ReshapeToUint16s(), а AsInt32x4 у Uint8x16 возвращает x.ReshapeToUint32s().BitsToInt32().
Базовые операции — ToBits (возвращает IEEE 754 представление каждого элемента), BitsTo{Int<N>,Float<N>} (переинтерпретирует биты в другой тип того же размера) и ReshapeToUint8s/16s/32s/64s (переинтерпретирует биты x как вектор с другим числом элементов; и элементы, и биты каждого элемента трактуются в little endian). Например, ReshapeToUint16s у Uint8x16 даёт Uint16x8, ReshapeToUint32s — Uint32x4, ReshapeToUint64s — Uint64x2, а ReshapeToUint8s у Uint32x8 даёт Uint8x32. Соответствие реальным инструкциям указано только для TruncateToUint16 (VPMOVDW) и ExtendToUint64 (VPMOVZXDQ) у Uint32x4.
В issue обсуждалось, что TruncateToUint16 и TruncateToUint8, показанные как возвращающие Uint16x8 и Uint8x16, должны ли на самом деле давать Uint16x4 и Uint8x4, иначе это скорее операция Reinterpret. Приводился пример, где large.TruncateToUint16().Store(p) сохраняет результат в *Uint16x4, и это работает, даже если Uint16x4 хранится в младшей части более крупного регистра. Для случая, когда результат действительно нужен в младшей половине более крупного значения, предлагалось вызывать DoubleUpWithZeros.
Эмуляция и пересечение API
Механизм пересечения API начинается с того, что в пакет simd включаются только операции, которые хорошо работают на большинстве архитектур: загрузки, сохранения, арифметика и сравнения (но не все сравнения). Простое пересечение методов SIMD разных архитектур оставляет пробелы, которые заполняются эмуляциями в архитектурно-специфичных API archsimd.
Эмуляции бывают разной сложности — от тривиальных, где разные типы компилируются в одну инструкцию (например, Int8x16.Add и Uint8x16.Add), до составных из 2–3 инструкций: скалярный сдвиг эмулируется векторным сдвигом, а отсутствующие беззнаковые сравнения — знаковым сравнением плюс двумя XOR с константой. Для важных, но не всегда поддерживаемых инструкций применяется эмуляция, причём для криптографического применения её время выполнения не зависит от входных данных. Вместо примитивных инструкций вроде «add pairs» в следующем релизе будет предоставлена более высокоуровневая операция sum reduction, что также изолирует пользователей от зависимости от длины вектора.
OnesCountEmulated как общий резерв
OnesCountEmulated — общая резервная реализация подсчёта единичных битов для каждого элемента вектора simd.Int8s, не использующая аппаратный SIMD. Она помечена build-тегом //go:build goexperiment.simd, то есть доступна во всех сборках с включённым экспериментом simd. Работает так: вектор преобразуется в биты, перекладывается в два uint64, затем над каждым из них выполняется классическая битовая свёртка по маскам m1, m2, m4, после чего результат загружается обратно как uint64-вектор и преобразуется в Int8s.
Для платформ без аппаратного SIMD (всё, кроме amd64, wasm и arm64) OnesCount под тегом //go:build goexperiment.simd && !(amd64 || wasm || arm64) просто вызывает OnesCountEmulated. На wasm и arm64 OnesCount под тегом //go:build goexperiment.simd && (wasm || arm64) пытается привести вектор к archsimd.Int8x16 и вызвать аппаратный x.OnesCount(), а в default-ветке (в том числе при GODEBUG=simd=0) откатывается на OnesCountEmulated. Эмуляция служит общим fallback-ом: она используется и как основная реализация там, где аппаратного SIMD нет, и как запасной путь в реализациях с аппаратным SIMD.
Настройки: GODEBUG и GOEXPERIMENT
Поведение simd управляется переменной окружения GODEBUG, которую можно задать перед запуском программы; она меняет использование аппаратной поддержки, чтобы было проще тестировать код на разных конфигурациях.
GODEBUG=simd=0— использовать эмуляцию SIMD-операций даже при наличии аппаратной поддержки.GODEBUG=simd=128— использовать 128-битные векторы и их возможности; если возможностей нет, паника немедленно.GODEBUG=simd=256иsimd=512— использовать векторы соответствующей длины и их возможности, если это возможно.
Флаг сборки goexperiment.simd включает код, относящийся к simd, как показано в директивах //go:build goexperiment.simd.
Ограничения Go 1.27 и планы на Go 1.28
В первом экспериментальном релизе пакета simd в Go 1.27 одним из ограничений названо отсутствие общего способа суммировать все элементы вектора. В следующем релизе появится ReduceSum, которым можно будет заменить sum.
Для Go 1.28 планируется:
- добавить поддержку SVE в
archsimdи, как надеются, вsimd; - добавить дополнительные SIMD-операции, например OnesCount, операции с масками, операции редукции и операции перемешивания векторов;
- включить небольшое число «feature variants», чтобы не откатываться к полной эмуляции на платформах с аппаратной векторной реализацией, но без одной или нескольких операций, например Raspberry Pi.
Примеры
innerProduct
В примере innerProduct объявляется векторная переменная var a simd.Float32s — аккумулятор; его длина задаётся методом a.Len(). Основной цикл идёт шагами по a.Len(): на каждой итерации simd.LoadFloat32s(x[i : i+a.Len()]) загружает полный вектор из среза x, аналогично для y, после чего a = u.MulAdd(v, a) выполняет умножение с накоплением (u*v + a) за одну операцию. Остаток, не поместившийся в полный вектор, обрабатывается через simd.LoadFloat32sPart(x[i:]), который возвращает частично заполненный вектор и число загруженных элементов, и снова применяется MulAdd.
Функция sum демонстрирует ограничение первого релиза: горизонтального суммирования всех элементов вектора в пакете нет, поэтому sum выделяет срез make([]float32, x.Len()), сохраняет туда вектор через x.Store(s) и складывает элементы скалярным циклом.
popcnt4x16 на amd64 и OnesCount на wasm/arm64
Разные реализации OnesCount выбираются на этапе сборки через build-теги, а внутри выбранного файла — через type switch по конкретному архитектурному типу, полученному из v.ToArch().
На amd64 файл собирается с тегом //go:build goexperiment.simd && amd64, и в нём switch различает archsimd.Int8x16, archsimd.Int8x32 и archsimd.Int8x64: для первых двух строится таблица popcnt4x16/popcnt4x32 и считается число единичных бит через перестановки по младшим и старшим полубайтам, а для Int8x64 вызывается встроенный x.OnesCount(). В amd64-версии ветка default тоже вызывает OnesCountEmulated(v) — это эмуляция при GODEBUG=simd=0.
На wasm и arm64 файл собирается с тегом //go:build goexperiment.simd && (wasm || arm64), и там switch обрабатывает только archsimd.Int8x16, сразу возвращая x.OnesCount(), потому что NEON и Wasm поддерживают Int8s.OnesCount(). На прочих архитектурах файл с тегом //go:build goexperiment.simd && !(amd64 || wasm || arm64) возвращает OnesCountEmulated(v) без switch.
Проверка не-ASCII и не-алфавитных символов
Greater вызывается как next.Greater(s0) и возвращает маску, элементы которой показывают, где next > s0; Equal вызывается как vals.Equal(s1) и возвращает маску, элементы которой показывают, где vals == s1. Результаты сохраняются в hasNonAscii и hasNonAlphabet, затем печатаются, а также печатается их объединение через Or.
Вывод масок показывает: hasNonAlphabet содержит единицы начиная с 12-й позиции (элементы, равные s1), hasNonAscii полностью состоит из нулей, а объединение hasNonAlphabet|hasNonAscii совпадает с hasNonAlphabet, поскольку вторая маска нулевая.
Как сравнивали и что получилось
Описан один бенчмарк — BenchmarkVpsumdSIMD. Он объявляет переменную simd.Uint64s, четыре входных значения uint64 (w, x, y, z) и в цикле b.Loop() вызывает vpsumd3(w, x, y, z), сохраняя результат в lo, hi, а затем в sinkLo, sinkHi. Числом итераций управляет b.Loop() автоматически.
В теле бенчмарка объявлена переменная var _ simd.Uint64s с комментарием, что она нужна, чтобы цикл бенчмарка вызывал специализированную vpsumd3 напрямую. Это работает потому, что AST-перезапись создаёт специализированные копии функций, переменных и типов, упоминающих типы simd, а специализированные функции вызывают другие специализированные функции напрямую, без накладных расходов на диспетчеризацию. Упоминание типа simd поднимает диспетчеризацию выше и убирает её из горячего цикла.
В коде эксперимент включается тегом goexperiment.simd, а поведение на разных конфигурациях задаётся через GODEBUG: simd=0 включает эмуляцию, simd=128/256/512 используют соответствующие векторы, варианты с плюсом разрешают работу при отсутствии части возможностей.
Что из этого следует на практике
- Размер вектора не попадает в типы. Код с пакетом
simdкомпилируется один раз и работает на всех платформах: недостающие операции эмулируются, а на платформах без SIMD эмулируется всё. - Диспетчеризация по уровню SIMD происходит один раз при старте программы. Функции, упоминающие
simdвнутри, но не в сигнатуре, становятся обёртками; специализированные версии вызывают друг друга напрямую. Если диспетчеризация оказалась «слишком низко» в вычислении, лишнее упоминание типаsimdв теле поднимает её выше. - Маски — непрозрачны, и их представление выбирает компилятор. Он смотрит, как маска потребляется, и по возможности удаляет конверсии. Прямое использование масок иными способами не рекомендуется.
ToMask()иBitSelect— разные инструменты.ToMask()приводит к 0/1 по принципу «не ноль» и подходит для масок, полученных сравнением; для сырого побитового выбора нуженBitSelectили явноеAndс единицей.- Поведение можно менять без пересборки.
GODEBUG=simd=0заставляет использовать эмуляцию даже при наличии аппаратной поддержки, аsimd=128/256/512и варианты с плюсом задают уровень и допуск к отсутствующим возможностям. - Горизонтального суммирования в Go 1.27 нет.
ReduceSumпоявится в следующем релизе; пока сумму всех элементов вектора приходится получать через сохранение в срез и скалярный цикл.