Назад к блогу

Go 1.27: экспериментальные SIMD-векторные API для разных архитектур

Go 1.27: экспериментальные SIMD-векторные API для разных архитектур

В Go 1.27 появился экспериментальный флаг GOEXPERIMENT=simd, впервые приносящий SIMD в язык без обязательного ассемблера: двухуровневый API из низкоуровневых архитектурно-специфичных интринсиков и переносимого векторного слоя поверх них. Статья разбирает устройство пакетов simd и simd/archsimd, работу трёх генераторов кода и то, что авторы осознанно оставили за рамками эксперимента.

До сих пор всякий, кому нужен был 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 или amd64 AVX/AVX2/AVX512) через соответствующие типы из 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, SME) не поддерживаются: в 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-зависимые инициализаторы переменных и изменённые имена в трассировках стека.

Где смотреть в коде

Источники

Похожее