Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Дайджесты новостей
Редакционная иллюстрация перехода от фрагментированного ассемблерного кода к единому кроссплатформенному пакету simd в Go 1.27.

Портативный SIMD в Go 1.27: векторные вычисления без ассемблера

Параллельные вычисления на уровне инструкций процессора (SIMD — Single Instruction, Multiple Data) лежат в основе высокой производительности в числовой обработке данных. Современные процессоры x86-64 и ARM оснащены векторными регистрами шириной 128, 256 и 512 бит. За один такт процессор может выполнить одну операцию — сложение, умножение, сравнение — сразу над массивом из 4, 8 или 16 чисел с плавающей запятой.

Для разработчиков на Go доступ к этим возможностям долгое время оставался серьезным вызовом. Стандартный компилятор Go исторически не предоставлял платформенных интринсиков. Инженерам приходилось писать ассемблерные функции в файлах .s либо генерировать машинный код внешними утилитами. В экспериментальном релизе Go 1.27 представлен пакет simd, призванный изменить эту практику.

Почему ручной ассемблер стал тупиком

Ассемблерные функции в Go имеют заметные издержки. Они не встраиваются компилятором по месту вызова (inlining), поэтому при каждом вызове среда выполнения переключает контекст и сохраняет регистры. Для коротких операций стоимость вызова нередко нивелирует аппаратный выигрыш.

Второй проблемой оставалась фрагментация кодовой базы. Чтобы реализовать векторное ускорение, разработчик создавал и поддерживал несколько реализаций одного алгоритма:

  • под AVX2 для архитектуры amd64;
  • под AVX-512 для серверов с современными процессорами Intel Xeon и AMD EPYC;
  • под расширение NEON для процессоров arm64 (Apple Silicon, AWS Graviton);
  • скалярный код на чистом Go для остальных платформ.

Любая правка в логике алгоритма требовала синхронного обновления ассемблерного кода на разных диалектах. В Go 1.26 появился низкоуровневый пакет simd/archsimd для amd64, но он сохранял жесткую привязку к типам конкретной разрядности. В Go 1.27 команда Дэвида Чейза и Цзюньяна Шао из Google предложила переносимый пакет simd.

Концепция Size-Agnostic: устройство нового API

В основу нового пакета легла концепция векторных вычислений без фиксированного размера (Size-Agnostic SIMD), вдохновленная библиотекой Google Highway для C++. Пакет вводит абстрактные типы во множественном числе:

  • simd.Float32s и simd.Float64s — для вещественных чисел;
  • simd.Int32s, simd.Int64s, simd.Uint8s — для целых чисел;
  • simd.Mask8s, simd.Mask32s — для масок логических условий.

Архитектурная схема Size-Agnostic SIMD в Go 1.27 с адаптивным вектором simd.Float32s, масштабирующимся под регистры NEON, AVX2 и AVX-512.

Количество элементов в векторе не фиксируется в коде. Оно определяется на целевой платформе методом .Len(). На процессоре со 128-битными инструкциями ARM NEON или WebAssembly SIMD вектор simd.Float32s вмещает 4 элемента, на AVX2 — 8 элементов, а на AVX-512 — 16 элементов. Компилятор преобразует методы simd напрямую в процессорные инструкции с поддержкой инлайнинга.

Практический сценарий: вычисление скалярного произведения

Рассмотрим задачу вычисления скалярного произведения двух срезов float32, востребованную в машинном обучении и поиске по эмбеддингам. Алгоритм обрабатывает данные блоками длиной acc.Len(), используя метод MulAdd (Fused Multiply-Add):

package main

import (
    "fmt"
    "simd"
)

// InnerProduct вычисляет скалярное произведение двух срезов через SIMD.
func InnerProduct(a, b []float32) float32 {
    if len(a) != len(b) || len(a) == 0 {
        return 0
    }

    var acc simd.Float32s
    step := acc.Len()
    i := 0

    // Основной цикл: параллельная обработка полных векторных регистров
    for i = 0; i <= len(a)-step; i += step {
        va := simd.LoadFloat32s(a[i : i+step])
        vb := simd.LoadFloat32s(b[i : i+step])
        acc = va.MulAdd(vb, acc)
    }

    // Обработка некратного остатка массива через частичную загрузку
    if i < len(a) {
        va, _ := simd.LoadFloat32sPart(a[i:])
        vb, _ := simd.LoadFloat32sPart(b[i:])
        acc = va.MulAdd(vb, acc)
    }

    return horizontalSum(acc)
}

func horizontalSum(v simd.Float32s) float32 {
    buf := make([]float32, v.Len())
    v.Store(buf)
    var total float32
    for _, val := range buf {
        total += val
    }
    return total
}

func main() {
    vecA := []float32{1.5, 2.0, 3.5, 4.0, 5.5, 6.0, 7.5, 8.0, 9.5}
    vecB := []float32{2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0}
    fmt.Printf("Скалярное произведение: %.2f\n", InnerProduct(vecA, vecB))
}

Конструкция simd.LoadFloat32sPart(a[i:]) безопасно загружает элементы неполного хвоста среза и заполняет остальные позиции нулями. Операция MulAdd выполняется над хвостом за один векторный шаг без риска выхода за границы памяти.

Унифицированное маскирование и условные операции

Разные архитектуры реализуют условные операции над векторами по-разному. В SSE, AVX и ARM NEON маски хранятся в обычных векторных регистрах в виде побайтовых битовых масок. В x86 AVX-512 используются выделенные регистры предикатов (k0-k7), а в ARM SVE — предикатные биты на байт.

Пакет simd объединяет эти механизмы за абстрактными типами масок. Методы сравнения возвращают маску, передаваемую в метод IfElse. Продемонстрируем это на примере функции отсечения отрицательных чисел (ReLU):

package main

import (
    "fmt"
    "simd"
)

// ClampNegative заменяет отрицательные числа в срезе нулями.
func ClampNegative(data []float32) {
    var v simd.Float32s
    step := v.Len()
    zero := simd.BroadcastFloat32s(0.0)
    i := 0

    for i = 0; i <= len(data)-step; i += step {
        chunk := simd.LoadFloat32s(data[i : i+step])
        
        // Создаем маску: true там, где элемент меньше нуля
        mask := chunk.Less(zero)
        
        // Условная подстановка: если mask == true, выбираем zero, иначе chunk
        clamped := zero.IfElse(mask, chunk)
        clamped.Store(data[i : i+step])
    }

    for ; i < len(data); i++ {
        if data[i] < 0 {
            data[i] = 0
        }
    }
}

func main() {
    values := []float32{3.2, -1.5, 0.0, -8.4, 12.1, -0.7, 5.6}
    ClampNegative(values)
    fmt.Printf("Результат: %v\n", values)
}

Метод simd.BroadcastFloat32s(0.0) размножает ноль по всему вектору. Вызов chunk.Less(zero) формирует платформенную маску, а zero.IfElse(mask, chunk) транслируется в аппаратную инструкцию слияния (blend), исключая ветвления процессора.

Экосистема и сборщик мусора Green Tea

Пакет simd применяется и для оптимизации самого рантайма Go. В экспериментальном сборщике мусора Green Tea векторные инструкции используются для параллельного сканирования диапазонов адресов в памяти. Это сократило длительность пауз Stop-The-World в высоконагруженных сервисах.

Кроме рантайма, переносимый SIMD востребован в векторных базах данных для расчета расстояний между эмбеддингами без CGO, в криптографических библиотеках и алгоритмах сжатия данных.

Ограничения и запуск эксперимента

Для использования пакета в Go 1.27 требуется флаг компиляции GOEXPERIMENT=simd:

GOEXPERIMENT=simd go build -o app ./main.go

Текущие особенности реализации:

  1. Горизонтальные редукции: в Go 1.27 нет встроенной функции суммирования элементов одного вектора (ReduceSum); сворачивание выполняется через выгрузку в буфер. Добавление редукций намечено на Go 1.28.
  2. Программная эмуляция: на платформах без аппаратного SIMD методы транслируются в скалярные циклы на чистом Go, сохраняя работоспособность с небольшими накладными расходами.
  3. Экспериментальный статус: сигнатуры методов могут корректироваться по итогам тестирования сообществом.
Ключевой вывод

Переносимый пакет simd дает разработчикам на Go возможность писать компактный и безопасный код с аппаратным ускорением на AVX, NEON и WebAssembly, не прибегая к ручному ассемблеру.