Назад к блогу

Платформо-независимый SIMD в Go: что приносит эксперимент в Go 1.27

Платформо-независимый SIMD в Go: что приносит эксперимент в Go 1.27

Эксперимент в Go 1.27 обещает переносимый SIMD: вместо привязки к конкретным инструкциям процессора появится общий слой, который сам подбирает нужный векторный бэкенд. Разбор объясняет, как компилятор специализирует код под разные длины векторов и почему это может изменить подход к оптимизации в Go.

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 трактует 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 появится в следующем релизе; пока сумму всех элементов вектора приходится получать через сохранение в срез и скалярный цикл.

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

Источники

Похожее