До сих пор всякий, кому нужен был SIMD в Go, писал ассемблер — это и было основным трением. Аппаратные SIMD-операции непереносимы по своей природе: разные архитектуры поддерживают разные размеры векторов и разные операции, иногда с разными представлениями. Переносимый API поверх этого построить хочется, но в C++ он опирается на архитектурно-специфичные интринсикифункции, которые почти один в один повторяют машинные инструкции и встраиваются компилятором прямо в код, которых в Go до сих пор не было. В Go 1.27 появился эксперимент GOEXPERIMENT=simd с двумя уровнями API сразу — и разбираться в том, как эти уровни соотносятся, что генерируется и что осознанно оставлено за бортом, приходится по исходникам и документации.
Зачем два уровня вместо одного API
Выбран двухуровневый подход: низкоуровневый архитектурно-специфичный API с интринсиками плюс высокоуровневый портируемый векторный API. Низкоуровневые интринсики близко повторяют машинные инструкции (большинство компилируется в одну инструкцию) и служат строительными блоками для высокого уровня. Ожидается, что большинство кода обработки данных обойдётся высокоуровневым портируемым API и получит хорошую производительность; низкоуровневый нужен для редких архитектурно-специфичных операций.
Аналогия прямая: низкоуровневый API — это syscallпакет для прямого доступа к системным вызовам, куда заглядывают редко и по необходимости, а высокоуровневый — osпакет для обычной работы с ОС, которым пользуется почти весь код.
Устройство пакетов simd и simd/archsimd
Пакет simd реализует переносимые, не зависящие от размера вектора типы. Для каждого примитивного числового типа, кроме complex64 и complex128, есть тип с суффиксом s: имя примитива с заглавной буквы плюс s — например, Int8s, Uint16s, Float64s. Кроме того, есть mask-типы: они представляют булевы маски для векторов соответствующей ширины элемента (например, Mask8s — маска для Int8s/Uint8s) и используются в условных операциях над векторами.
Пакет simd/archsimd — низкоуровневая инфраструктура, на которой строится simd, по сути слой интринсиков. Здесь типы привязаны к конкретной архитектуре и форме вектора: Float32x4, Int32x8, Uint8x16, Mask32x4.
Переход между уровнями выполняется функциями ToArch и <Types>FromArch: ToArch превращает переносимый вектор в arch-специфичный, а <Types>FromArch — обратно; в шаблоне имени <Types> заменяется на конкретный тип вектора, для которого выполняется обратное преобразование.
Поля переносимых типов (Float32s, Int8s, Mask8s) не экспортированы, потому что их представление скрыто и зависит от аппаратуры: SIMD-типы либо реализуются аппаратно (например, arm64 Neonрасширение SIMD-инструкций в процессорах ARM или amd64 AVX/AVX2/AVX512семейство расширений SIMD-инструкций в процессорах x86) через соответствующие типы из simd/archsimd, либо эмулируются на чистом Go, а длина вектора определяется во время выполнения программы. Arch-типы (Float32x4, Int8x16) имеют экспортированные поля, поскольку задают конкретную форму и тип элементов вектора: archsimd использует отдельные struct-типы, чтобы сигнализировать форму и тип элементов, и определяет операции как методы на этих типах.
Генерация кода: midway, simdgen, wasmgen
Три генератора порождают разные файлы из разных входных описаний.
midway порождает файлы decls_*.go (например, decls_amd64.go и decls_wasm.go) в пакете bridge — внутреннем слое-прослойке, который отделяет пакет simd от archsimd: файлы объявляют типы-обёртки над archsimd и функции Load/Broadcast, которые просто перенаправляют вызовы.
simdgen порождает файлы ops_*.go (ops_amd64.go, ops_arm64.go) в пакете archsimd, беря входные YAML-описания go_amd64.yaml/go_arm64.yaml вместе с types.yaml и categories.yaml и путями к описаниям ISA (XED_PATH, ARM64_ISA_PATH).
wasmgen порождает ops_wasm.go в пакете archsimd, где методы описаны через инструкции WebAssembly (например, I8x16Abs, V128And).
Во всех шапках стоит DO NOT EDIT, потому что файлы созданы автоматически и правки в них будут перезаписаны при следующем запуске генератора.
Переинтерпретация битов: reinterpret против convert
Переинтерпретации — это приведение типов без затрат. В Go 1.26 это делалось методами As<Type>, но они страдали от квадратичного роста числа пар типов и не обобщались на векторы без фиксированной ширины. В Go 1.27 их заменили на композируемые zero-cost конверсии.
ToBits() переинтерпретирует знаковый целочисленный или float-вектор как беззнаковый целочисленный вектор той же ширины элемента (Int32x4.ToBits() -> Uint32x4), а BitsToInt32() / BitsToFloat32() переводят обратно. ReshapeToUint<W>s() на беззнаковых векторах меняет ширину элемента в пределах той же ширины регистра. Например, если x — это archsimd.Uint8x16, то x.ReshapeToUint32s().BitsToFloat32() переинтерпретирует биты x как archsimd.Float32x4 при нулевой стоимости во время выполнения. Некоторые такие конверсии вообще не требуют генерации машинной инструкции.
Загрузка, сохранение, частичные операции
Load* загружает срез элементов в вектор. Part-вариант возвращает сам вектор и число фактически загруженных элементов:
func LoadFloat32sPart([]float32) (Float32s, int)Store записывает элементы вектора в срез, а StorePart возвращает число записанных элементов.
Для фиксированных массивов применяется суффикс Array и передаётся указатель на массив: archsimd.LoadFloat32x4Array(y *[4]float32) и x.StoreArray(y *[4]float32).
Маски и условные операции
Mask8s — это тип, представляющий булеву маску для векторов Int8s/Uint8s. Внутреннее устройство скрыто. Над масками определены побитовые операции: And возвращает побитовое И двух масок, Or — побитовое ИЛИ, Xor — побитовое исключающее ИЛИ.
Маски — отдельный тип, а не просто вектор, потому что они несут булеву семантику для конкретной ширины элемента, а не значения этого типа.
Насыщение, усечение и округление
Add складывает соответствующие элементы двух векторов без насыщения, AddSaturated — с насыщением, то есть с ограничением результата диапазоном типа. Для целочисленных векторов AddSaturated реализован через инструкции VPADDSB/VPADDSW (знаковые) и VPADDUSB/VPADDUSW (беззнаковые), а обычный Add — через VPADDB/VPADDW/VPADDD/VPADDQ.
Из операций преобразования приведены TruncTo* (TruncToInt8, TruncToInt16, TruncToInt32): они усекают значения элементов к меньшему целому типу, результаты упаковываются в младшие элементы возвращаемого вектора, а старшие обнуляются.
Round округляет элементы до ближайшего целого, при равном удалении — к чётному. RoundScaled округляет с заданной точностью, принимая параметр prec uint8; неконстантное значение prec может значительно ухудшить производительность. RoundScaledResidue возвращает разницу после округления с заданной точностью. TruncScaled усекает элементы с заданной точностью, TruncScaledResidue — разницу после усечения с заданной точностью.
Сдвиги, ротации и Concat-варианты
ShiftAllLeft/ShiftAllRight сдвигают каждый элемент вектора на одну и ту же величину shift, тогда как ShiftLeft/ShiftRight сдвигают каждый элемент x[i] на свою величину shift[i] — поэлементно. Для ShiftAllLeft: сдвигает каждый элемент x влево на y бит; если y больше ширины элемента, результат 0. Для ShiftLeft: сдвигает x[i] влево на y[i] бит; если y[i] больше ширины элемента, результат 0. ShiftAllRight выполняет арифметический сдвиг вправо: если y больше ширины элемента, результат 0 или -1.
Варианты *ConcatMod16/32/64 сдвигают x[i] влево на shift%16 (или %32, %64) и заполняют освободившиеся младшие биты старшими битами y[i]:
z[i] = concat(x[i], y[i]) << (shift%16)Для неконстантного shift предупреждают: значение может значительно ухудшить производительность операции.
Специализированные операции: AES, SHA, GF(2^8), carryless multiply
AESEncryptLastRound — метод для Uint8x64, выполняющий последовательность операций AES по FIPS 197: result = AddRoundKey((ShiftRows(SubBytes(x))), y), где x — массив состояния, а y — используемый фрагмент массива w. Соответствует инструкции VAESENCLAST с требованием AVX512VAES.
SHA256Message2 — метод для Uint32x4, выполняющий sigma и добавление 3 в алгоритме SHA256 по FIPS 180-4, где x — результат шага 2, y = {0, 0, W14, W15}, результат = {W16, W17, W18, W19}. Это инструкция SHA256MSG2 с требованием SHA.
GaloisFieldMul — метод для Uint8x16/Uint8x32/Uint8x64, возвращающий (x * y) mod P в GF(2^8), где характеристический многочлен P = x^8 + x^4 + x^3 + x + 1. Это инструкция VGF2P8MULB с требованием AVX512GFNI.
GaloisFieldAffineTransform — метод для тех же типов, возвращающий аффинное преобразование A * x + b в GF(2^8): каждый элемент A трактуется как 8x8-матрица битов, каждый элемент x — как 8-элементный вектор битов, b — тоже 8-элементный вектор битов, результат z[i] = A[i/8] * x[i] + b. Это инструкция VGF2P8AFFINEQB с требованием AVX512GFNI.
CarrylessMultiplyEven и CarrylessMultiplyOdd — методы для Uint64s, принимающие y Uint64s и возвращающие Uint64s.
Все эти методы вынесены в отдельные объявления, потому что каждый привязан к конкретной ассемблерной инструкции и конкретному набору CPU-функций, а не к общему API.
Кросс-архитектурные различия
Наборы типов различаются по архитектурам. На amd64 объявлены векторы вплоть до 512-битных (Float32x16, Int8x64, Float64x8). На arm64 — только 128-битные (Float32x4, Float64x2, Int8x16, Int16x8, Int32x4, Int64x2). На wasm — тоже только 128-битные.
На wasm, помимо 128-битных числовых типов, объявлены маски Mask8x16, Mask16x8, Mask32x4, Mask64x2 и беззнаковые типы Uint8x16…Uint64x2 с Load/Broadcast.
Операции, доступные только на amd64, включают AES-раунды над Uint8x16, Uint8x32 и Uint8x64. На arm64 доступны насыщающие сложения AddSaturated для знаковых и беззнаковых типов.
Определение возможностей железа во время выполнения
Emulated() возвращает булево значение: выполняются ли операции simd эмуляцией или на реальном векторном оборудовании. HasHardwareCarrylessMultiply() тоже возвращает bool и сообщает, есть ли на платформе аппаратная реализация умножения без переноса. При стандартных настройках GODEBUG=simd значение false означает лишь эмуляцию и медленную работу, а при нестандартных настройках может указывать на возможную недостающую инструкцию, выполнение которой приведёт к сбою «SIGILL». VectorBitSize() возвращает int — размер вектора в битах.
Эти проверки позволяют компилятору выбирать реализацию: при эмуляции или отсутствии аппаратного умножения без переноса используется эмуляция, при наличии аппаратной поддержки — аппаратная реализация.
Включение и тестирование эксперимента
Чтобы включить экспериментальную поддержку SIMD, нужно задать переменную окружения GOEXPERIMENT=simd при запуске тестов:
GOEXPERIMENT=simd go test simd/archsimd/...Кросс-тестирование выполняется установкой GOARCH: так можно тестировать wasm через WASI-рантайм, а также amd64 через эмуляцию Rosetta на Apple Silicon.
В Go 1.27 GOEXPERIMENT=simd поддерживает amd64 (AVX, AVX2 и AVX-512), arm64 (NEON) и wasm (WebAssembly 128-bit SIMD) из коробки.
Ограничение эмуляции связано с тем, что большие составные типы размещаются в памяти, а не в регистрах (известная проблема #24416). Поскольку SIMD-векторы занимают 16–64 байта, их выгрузка в стек вредит производительности сильнее, чем выгрузка скалярных значений.
Практический пример: Transpose8 и битовые матрицы
Transpose8 принимает восемь отдельных векторов Int32x8 (a0…a7) и возвращает восемь (b0…b7). Восемь отдельных параметров, а не массив или структура, — именно из-за того, что ABI и SSA-бэкенд Go размещают большие составные типы в памяти, а не в регистрах, а вытеснение SIMD-векторов на стек вредит производительности сильнее, чем вытеснение скалярных значений.
Реализация сначала проверяет поддержку AVX2: при её отсутствии выполняется медленная эмуляция, иначе выполняются перестановки. Сначала пары строк объединяются поэлементно: младшие половины двух векторов чередуются между собой, затем так же чередуются их старшие половины. Полученные результаты переставляются по группам скалярных элементов, а на последнем шаге из них собираются итоговые b0…b7 перестановкой 128-битных половин.
8x8-битная матрица упаковывается построчно в один uint64. В примере с антидиагональю эта матрица задаётся значением 0x8040201008040201 и применяется через GaloisFieldAffineTransform для обращения битов: можно умножить каждый байт на 8x8 антидиагональную единичную матрицу, чтобы обратить все 8 бит каждого из 64 байт за одну инструкцию. Матрица транслируется в вектор через BroadcastUint64x8, применяется к загруженным байтам, результат сохраняется через Store.
Компромиссы дизайна и границы API
Матричные расширения (AMXнабор инструкций для матричных операций на процессорах Intel, SMEрасширение ARM для матричных операций) не поддерживаются: в Go ещё не определились, как эффективно представлять матрицы, но могут поддержать в будущем.
Scalable vectors (SVE, RVV) пока не входят в текущий API, так как archsimd поддерживает только фиксированную ширину. Для масштабируемых векторов размер нельзя определить на этапе компиляции, и теоретически он может быть очень большим. Поддержка SVE/SVE2 и масштабируемых типов запланирована: идёт работа над завершением поддержки arm64 SVE и SVE2 в archsimd с введением width-agnostic типов вроде archsimd.Float32s, опирающихся напрямую на аппаратные SVE-регистры и предикаты.
Переносимость обеспечивается за счёт того, что низкоуровневый API служит строительным блоком для высокоуровневого переносимого API. При этом строгая или максимальная переносимость не является целью: когда операция поддерживается на нескольких платформах, для неё предполагается переносимый API, но операции, не поддерживаемые аппаратно, в большинстве случаев не эмулируются.
Известные проблемы и будущая работа
Ранние пользователи помогли найти баги, включая #77582, и указать места, где можно улучшить генерацию кода или удобство API.
В разделе «Bugs» документации simd перечислены три проблемы: вызовы не работают и возможны другие баги; инициализаторы переменных, зависящие от SIMD, не работают; изменённые имена могут появляться в трассировках стека и при отладке.
В Future work заявлено завершение поддержки arm64 SVE и SVE2 в archsimd с введением масштабируемых векторных типов, таких как archsimd.Float32s, и подключение их к simd. Также планируется расширить archsimd на riscv64, ppc64, s390x и loong64, улучшить продвижение регистров для составных типов и продолжать дополнять инструкции и оптимизации компилятора по отзывам сообщества.
Строковые представления и отладка
Для векторных типов String() копирует элементы вектора в массив соответствующего размера через StoreArray и возвращает строку из этого среза с помощью sliceToString. Так поступают все векторные типы — Int16x8, Int32x4, Int64x2, Uint8x16, Uint16x8, Uint32x4, Uint64x2, Float32x4, Float64x2.
Для масок String() сначала преобразует маску в соответствующий целочисленный вектор через ToIntNxM, затем применяет Neg() (побитовое отрицание, превращающее 0/1 в 0/-1 или наоборот), после чего сохраняет результат в массив и возвращает строку. Например, для Mask8x16:
var s [16]int8
x.ToInt8x16().Neg().StoreArray(&s)
return sliceToString(s[:])Таким образом, для масок выводятся значения после Neg(), что даёт -1 для установленных битов и 0 для снятых, тогда как для векторов выводятся исходные значения элементов. Формат строки (скобки, десятичные или hex) определяется функцией sliceToString.
Что из этого следует на практике
- Пишите на
simd, спускайтесь вarchsimdтолько при необходимости. Ожидается, что большинство кода обработки данных обойдётся высокоуровневым портируемым API; низкоуровневый нужен для редких архитектурно-специфичных операций. Аналогия сosиsyscallздесь работает буквально. - Переносимость — best-effort, а не гарантия. Операции, не поддерживаемые аппаратно, в большинстве случаев не эмулируются. Если код опирается на AES-раунды или GF(2^8)-операции, он не заработает на arm64 и wasm.
- Проверяйте
Emulated()иHasHardwareCarrylessMultiply()в рантайме. При стандартных настройкахGODEBUG=simdзначениеfalseозначает лишь медленную эмуляцию, но при нестандартных — возможенSIGILLна отсутствующей инструкции. - Не собирайте векторы в структуры и массивы. ABI и SSA-бэкенд размещают большие составные типы в памяти, а не в регистрах; вытеснение 16–64-байтных векторов на стек бьёт по производительности сильнее, чем вытеснение скаляров. Отсюда и восемь отдельных параметров у
Transpose8. - Переинтерпретация бесплатна, но требует композиции. Вместо взрывного числа
As<Type>-методов из Go 1.26 используйте цепочкиToBits/ReshapeToUint<W>s/BitsTo*— на некоторых архитектурах они вообще не порождают машинной инструкции. - Неконстантные параметры
precиshiftстоят дорого. Документация прямо предупреждает о значительном падении производительности дляRoundScaledи Concat-сдвигов при неконстантных значениях. - Эксперимент сырой. В разделе «Bugs» перечислены неработающие вызовы, неработающие SIMD-зависимые инициализаторы переменных и изменённые имена в трассировках стека.
Где смотреть в коде
- decls_amd64.go: LoadFloat32x4
- ops_wasm.go: ToInt16x8
- decls_arm64.go: LoadFloat32x4
- decls_wasm.go: LoadFloat32x4
- decls_wasm.go: LoadInt64x2
- ops_wasm.go: StoreArray