Часть 2. Векторизация и SIMD в Go: ускорение поиска и сравнения в массивах
- пятница, 9 мая 2025 г. в 00:00:07
Ускорить простые задачи, вроде поиска в массиве и сравнения слайсов, поможет мощь SIMD. Эти векторные инструкции, которые обрабатывают десятки байт данных за один такт процессора, отличная замена традиционным циклам. Во второй части статьи мы погружаемся глубже в практическое применение SIMD в Go-ассемблере, реализуем функцию SliceContainsV1 и изучим, как с помощью VADD, VDUP и других инструкций можно добиться 10–14-кратного ускорения простых задач.
Из этой статьи вы узнаете:
Как устроено сравнение массивов с помощью SIMD-инструкций;
Почему векторизация быстрее бинарного поиска;
Как правильно работать с регистрами, фреймами и указателями в Go-ассемблере;
Что нужно учесть при переносимости и поддержке низкоуровневого кода;
Когда ассемблер оправдан и безопасен в реальных проектах на Go.
Информация будет актуальна как разработчикам, оптимизирующим критически важный код, так и тем, кто хочет глубже понять архитектуру выполнения и взаимодействие с «железом».
В первой части статьи мы разобрали саму идею ускорения кода на Go с помощью ассемблера. А в этой разберём её практическое применение.
Начнём с простой задачи — сложение двух слайсов. Представьте, что у нас есть два слайса, и мы хотим сложить их покомпонентно, сохраняя результат в третьем (distance).
Здесь мы используем инструкцию VADD
, которая позволяет складывать элементы поштучно в векторных регистрах. Читаем данные из слайсов не по одному элементу, а сразу блоками (например, по 16 байт). Складываем эти блоки векторно, используя VADD
. Записываем результат обратно в distance
.
Чтобы упростить реализацию, рассмотрим выравненные слайсы с длиной, кратной 16 байтам. Это позволит избежать сложных проверок «остатка» (corner case) и работать с чистыми векторными операциями.
Напомню, как выглядит структура слайсов в Go под капотом:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
В структуре слайса есть три ключевых поля: указатель на данные (начало массива), длина (количество элементов) и ёмкость (capacity). Но в данном случае использовать будем только первые два — указатель и длину.
Как мы получаем эти значения в ассемблере? Go передаёт аргументы функции через стек (Frame Pointer — FP
). Смещение 0 от FP
содержит указатель на данные → кладём его в R0
Смещение 8 содержит длину → кладём в R1
. Теперь у нас есть два регистра (R0
, R1
), с которыми мы можем работать дальше!
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
MOVD data+0(FP), R0
MOVD data+8(FP), R1
Давайте, наконец, писать код.
#include "textflag.h"
// func vectorAdditionV1(first, second, dst []uint8)
TEXT ·vectorAdditionV1(SB), NOSPLIT, $0
LDP first_base+0(FP), (R0, R1)
LDP second_base+24(FP), (R2, R3)
LDP dst_base+48(FP), (R4, R5)
MOVD $0, R7
loop:
CMP R5, R7
BGE done
Подключаем стандартные директивы. Например, чтобы использовать NOSPLIT
(чтобы избежать автоматического выделения стека), нужно включить нужные макросы.
Объявляем функцию. Используем TEXT
, затем имя функции и директивы. Это точка входа, где начинается исполнение кода функции.
Инициализируем регистры. Записываем указатель на данные в один регистр (например, R0
). Записываем длину массива в другой (R1
). Эти значения нам нужны, чтобы работать с массивами.
Готовим цикл. Дикрементируем счётчик итераций (например, R2
). Используем инструкции сравнения и branch
(переходы) для организации цикла.
Итеративная обработка. В цикле будем загружать данные, выполнять вычисления и записывать результат в distance slice
.
Дальше самая важная часть текущей функции:
Загрузка кода. Используем VLD1
, чтобы загрузить 16 байт в векторные регистры. Это позволяет сразу взять «кусочек» данных из первого и второго массива.
Векторное сложение. Применяем VADD
, чтобы сложить соответствующие элементы векторов. Результат записывается в V3
.
Запись результата в память. Используем VST1
, чтобы сохранить результат обратно в distance slice
. Данные кладём по указателю из R4
.
Обновляем указатели, увеличиваем длину и переходим к следующей итерации цикла.
Цикл повторяется, пока не обработаем весь массив.
Запустив бенчмарк, видим ускорение кода в 11 раз благодаря векторизации!
Очевидно, что приведённый пример — достаточно простой и даже немного «игрушечный». Но стоит немного расширить его масштаб, и становится ясно: как только мы заменим сложение на более сложные операции, например, умножение, всё сводится к вычислениям с матрицами. А это уже основа многих серьёзных областей:
Машинное обучение — большинство алгоритмов (например, градиентный спуск или полиномиальная регрессия) опираются на операции с матрицами.
Обработка изображений — любые фильтры, преобразования и трансформации реализуются через матричные вычисления.
Графические и игровые движки — перемещения объектов, освещение, анимации — всё это активно использует линейную алгебру.
По сути, матричные операции — это фундамент, на котором строится современный анализ данных, компьютерное зрение и графика. Именно линейная алгебра делает возможной работу с этими технологиями.
Предлагаю перейти к чему-то более практическому — реализуем проект, который можно будет использовать на практике без подключения графических движков и других тяжёлых зависимостей.
Наша цель — постепенно разобраться во всех этапах разработки и шаг за шагом прийти к работающему решению. Такой подход поможет не только понять, как всё устроено внутри, но и получить полезный прикладной инструмент.
Реализация Slice Contains на Go выглядит примерно так:
// 16-bit alignment for example
func Contains(s []uint8, target uint8) bool {
for _, e := range s {
if e == target {
return true
}
}
return false
}
Мы последовательно проходим по срезу (слайсу) и сравниваем каждый элемент с заданным значением. Если находим совпадение — сразу возвращаем true
. Если до конца перебора совпадений не найдено — возвращаем false
.
Представим, что для удобства реализации у нас есть инвариант: длина слайса всегда кратна 16, то есть len(slice) % 16 == 0
. Это упростит дальнейшую логику и оптимизацию.
А теперь представьте, что у нас есть «волшебная» функция compare
— чёрный ящик, который умеет быстро обрабатывать слайсы длиной ровно 16 элементов. Например, она может сравнивать или выполнять сложные операции над такими фрагментами слайса за один вызов.
Что, если использовать эту функцию как строительный блок для ускорения всей обработки?
// 16-bit alignment to simplify
func ContainsSIMD(s []uint8, target uint8) bool {
for i := 0; i < len(s); i += 16 {
if compare(
s[i:i+16],
Duplicate(target, 16),
) != 0 {
return true
}
}
return false
}
Мы передаём в функцию compare
слайс из 16 элементов — сначала один блок, затем следующий. Грубо говоря, говорим ей: «Пожалуйста, сравни эти два участка».
В качестве второго аргумента используем слайс duplicate
, который содержит повторяющиеся значения нашего целевого элемента (target
). Таким образом, мы расширяем target до слайса длиной 16, чтобы корректно сравнивать его с другими 16-элементными блоками.
Конечно, теоретически мы могли бы заранее подготовить такой дублированный слайс — создать его отдельно перед вызовами. Но в рамках текущего подхода делаем это «на лету», чтобы быстрее протестировать идею и упростить реализацию.
Идея заключается в следующем: мы хотим реализовать функцию, которая способна сравнивать 16 элементов за один шаг — без явного цикла, с использованием всего одного if.
Такое поведение возможно благодаря векторизации — технологии, которая позволяет процессору выполнять одну и ту же операцию сразу над несколькими элементами данных (например, с помощью SIMD-инструкций).
Наша цель — написать эту функцию таким образом, чтобы она принимала два слайса по 16 элементов, сравнивала их одновременно, и мгновенно возвращала результат. Это позволит значительно ускорить обработку больших массивов данных, особенно в задачах, где много однотипных сравнений.
В нашей реализации мы будем использовать векторную инструкцию compare
, которая позволяет производить параллельные сравнения.
Основная идея: у нас есть два слайса, и мы проходим по ним «окнами» — фрагментами длиной по 16 байт. На каждой итерации мы берём текущее окно из первого и второго слайса, передаём их в векторную функцию compare
, и получаем результат в виде векторного регистра, заполненного единицами и нулями — по блоку битов, в зависимости от результата сравнения.
Если целевая архитектура поддерживает более широкие регистры, например, 512 бит, — то размер окна можно было бы увеличить до 64 байт. Однако в этом примере используется 128-битные регистры.
Таким образом, весь процесс сравнения происходит без традиционного цикла по каждому элементу — всё делается в один векторный вызов.
Теперь возникает вопрос: как определить, было ли совпадение хотя бы в одном из 16 элементов, которые мы сравнили с помощью векторной инструкции compare
?
Ответ простой — сворачиваем результат сравнения. Векторный регистр, который мы получили, содержит единицы и нули: 1 — если элементы совпали, 0 — если нет. Если хотя бы один элемент был равен целевому значению (target
), сумма всех значений в регистре будет больше нуля.
Таким образом, логика будет следующей:
Выполнить векторное сравнение для текущего окна.
Свернуть результат.
Если сумма > 0 — значит, совпадение найдено, возвращаем true
.
Если не найдено — продолжаем искать в следующем окне.
Теперь можно приступить к реализации функции SliceContainsV1
, которая будет использовать этот подход.
func SliceContainsV0(s []uint8, target uint8) bool {
return slices.Contains(s, target)
}
func SliceContainsV1(s []uint8, target uint8) bool
Идея такая же:
//func SliceContainsV1(s []uint8, target uint8) bool
TEXT ·SliceContainsV1(SB), NOSPLIT, $0
LDP slice_base+0(FP), (R0, R1)
MOVB target+24(FP), R2
VDUP R2, V1.B16
loop:
CBZ R1, no
VLD1.P 16(R0), [V2.B16]
VCMEQ V1.B16, V2.B16, V3.B16
VADDV V3.B16, V2
VMOV V2.H[0], R4
На первом этапе мы загружаем в регистры указатели на данные и значения длины слайсов. Это необходимо, чтобы управлять смещениями, обновлять счётчики и корректно извлекать данные при проходе по массиву.
Затем используем инструкцию VDUP
(Vector Duplicate) — она позволяет создать вектор, где все элементы равны нашему целевому значению (target)
. Этот вектор мы будем использовать как опорный: каждое окно из данных мы будем сравнивать именно с ним.
После подготовки — переходим к циклу. Логика в цикле та же, что мы описывали выше:
Загружаем очередное окно из слайса — 16 байт данных.
Выполняем векторное сравнение этого окна с нашим target-вектором, используя SIMD-инструкцию.
Получаем результат сравнения — вектор с единицами и нулями.
Сворачиваем его (например, через ORR
или суммирование).
Если хотя бы один бит установлен (т.е. есть совпадение) — завершаем выполнение и возвращаем true
.
Если совпадений нет — переходим к следующему окну.
Вся эта схема — основа нашей оптимизированной функции SliceContainsV1
, реализованной с использованием векторизации.
На первой итерации мы берём первое окно из данных и сравниваем его с вектором, заполненным целевым значением (target
), который мы заранее подготовили с помощью инструкции VDUP
. Это дублированное значение записано во все элементы векторного регистра.
После применения SIMD-инструкции сравнения мы получаем векторный регистр с результатами сравнения — условно можно представить его как «жёлто-зелёный» регистр, где:
1 (или "зелёный") — элементы совпали с target
,
0 (или "жёлтый") — нет совпадения.
Далее выполняем свёртку (например, логическим OR
или суммированием), чтобы понять: было ли хотя бы одно совпадение в этом окне? Если результат больше нуля, значит — совпадение найдено.
Чтобы выполнить это условие в обычной логике if
, мы перемещаем результат из векторного регистра в обычный регистр с помощью инструкции VMOV
— и уже после этого сравниваем его обычной командой CMP
или аналогичной.
Если значение больше нуля — значит, матч найден, и можно немедленно вернуть true
.
Таким образом, проверка выполняется эффективно, без цикла по каждому элементу, и с минимальной задержкой.
После этого сравнили:
Если векторная свёртка даёт нулевой результат — это значит, что совпадений не найдено в текущем окне. В этом случае продолжаем итерацию: переходим к следующему 16-байтному фрагменту, если элементы ещё остались.
Если же совпадение обнаружено (значение после свёртки больше нуля), мы немедленно возвращаем true
— цель достигнута.
Таким образом, мы реализуем эффективную функцию sliceContains
, основанную на векторизации. Если запустить бенчмарк этой реализации, то окажется, что она работает примерно в 14 раз быстрее по сравнению с обычной циклической проверкой — и это действительно впечатляющий прирост производительности.
Конечно, эффективность может зависеть от типа данных и целевой архитектуры, но в целом — это отличный пример того, как низкоуровневая оптимизация и SIMD-инструкции позволяют добиться значительного ускорения в реальных задачах.
Важно понять саму идею: использование векторных инструкций — это универсальный инструмент, который может ускорить практически любую операцию, связанную с математикой, обработкой массивов или поиском значений, если задача позволяет это применить.
Векторизация особенно эффективна в тех случаях, где операция одинакова над множеством элементов — например, в сравнении, суммировании, фильтрации, проверке условий.
Более того, если вы примените такую оптимизированную функцию contains
даже к отсортированному массиву, то на небольших размерах данных она способна опережать бинарный поиск по скорости.
Да, несмотря на логарифмическую сложность бинарного поиска, затраты на условные переходы и кеш-промахи делают его менее выгодным при малом объёме данных. В то время как векторные инструкции обрабатывают данные «в лоб» — и это реально быстрее.
Такой подход действительно используется на практике в системах, где важна производительность, например, в игровых движках, мультимедийных библиотеках, базах данных и высокочастотной обработке данных.
Это пример того, как низкоуровневая оптимизация может принести реальную пользу в Go. Но возможности не ограничиваются только векторизацией — во третьей части рассмотрим, какие ещё аппаратные инструменты можно использовать, и как управлять сложными случаями, включая поддержку и переносимость таких решений.
На текущий момент стандартный компилятор Go не поддерживает автоматическую векторизацию. Известно, что работа в этом направлении ведётся, но встроенной поддержки пока нет.
Ассемблер может быть полезен для оптимизации узких мест, когда стандартные средства языка не дают нужной производительности. Он позволяет использовать низкоуровневые инструкции, которые компилятор не применяет автоматически.
Важно подходить к оптимизации осознанно: использовать ассемблер имеет смысл только в случаях, когда обнаружена реальная производительная проблема, и есть понимание, как её устранить.
Поскольку ассемблер платформозависим, рекомендуется:
Иметь fallback-реализацию на Go, которую можно использовать в случае необходимости.
Писать тесты для обеих реализаций, чтобы убедиться в корректности поведения.
Такой подход помогает безопасно использовать ассемблер в производственном коде и контролировать его влияние на поведение системы.
Часть 1 | Часть 2 | Часть 3 (TBA)
Поизучать:
Код на Github — всё, что я тут показал
Посмотреть:
Почитать:
Про идею ускорения с помощью Go-ассемблера в первой части статьи
Про расширенные техники для ускорения векторизации на Go в третьей части