14. SIMD. Single Instruction Multiple Data
В предыдущих лекциях, посвященных конвейеризации, внеочередному исполнению и 13. Предсказание ветвлений и Спекулятивное исполнение, мы наблюдали, как кремниевые инженеры выжимают последние капли производительности из классического последовательного кода. Этот подход называется ILP (Instruction-Level Parallelism) — параллелизм на уровне инструкций.
Однако у ILP есть физические пределы: зависимости между инструкциями, размер окна внеочередного исполнения, задержки памяти — и, не в последнюю очередь, декодер команд.
Дешифратор x86 — это сложнейший, горячий и прожорливый узел процессора.
Представьте типичную задачу бэкенда: парсинг входящего JSON-пакета, фильтрацию массива или сложение двух числовых срезов:
for i := 0; i < 1000; i++ {
c[i] = a[i] + b[i]
}
Для процессора этот невинный цикл означает необходимость выбрать из памяти, декодировать, переименовать регистры и прогнать через конвейер минимум 3 000 – 4 000 инструкций:
- прочитать
a[i]; - прочитать
b[i]; - выполнить сложение
a + b; - записать результат в
c[i]; - инкрементировать счетчик
i++; - сравнить с границей
i < 1000; - выполнить условный переход
JNE.
Повторять один и тот же бюрократический ритуал декодирования тысячу раз ради примитивного сложения — чудовищное расточительство кремниевых ресурсов.
Что, если сказать процессору:
«Вот тебе одна-единственная инструкция сложения. Но на входы подаются не два одиноких числа, а сразу два непрерывных массива по 16 чисел в каждом. Сложи их все параллельно за один электрический такт!»
Эта парадигма называется DLP (Data-Level Parallelism — Параллелизм на уровне данных).
А ее аппаратное воплощение в современных процессорах носит имя SIMD.
Что такое SIMD?
В 1966 году профессор Стэнфорда Майкл Флинн предложил фундаментальную классификацию компьютерных архитектур (таксономия Флинна).
По способу взаимодействия потока команд и потока данных компьютеры делятся на четыре класса:
flowchart TD
subgraph SISD ["SISD (Скалярный процессор)"]
direction TB
Instr1["Одна инструкция ADD RAX, RBX"] --> ALU1["Скалярное ALU (64 бита)"]
D_A1["Число A (64 бита)"] --> ALU1
D_B1["Число B (64 бита)"] --> ALU1
ALU1 --> Res1["Один результат C (64 бита)"]
end
subgraph SIMD ["SIMD (Векторный процессор)"]
direction TB
InstrV["Одна инструкция VPADDQ YMM0, YMM1, YMM2"] --> ALU_V["Широкое векторное ALU (256 бит)"]
D_AV["Вектор A: [A0, A1, A2, A3]"] --> ALU_V
D_BV["Вектор B: [B0, B1, B2, B3]"] --> ALU_V
ALU_V --> ResV["Вектор ответов: [C0, C1, C2, C3]"]
end
classDef sisd fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
classDef simd fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
class SISD,Instr1,ALU1,D_A1,D_B1,Res1 sisd;
class SIMD,InstrV,ALU_V,D_AV,D_BV,ResV simd;
- SISD (Single Instruction, Single Data):
Классический скалярный процессор. Обычная инструкцияADD RAX, RBXберет одно 64-битное число, складывает его с другим 64-битным числом и выдает ровно один результат. - SIMD (Single Instruction, Multiple Data):
Векторный процессор. Одна векторная микрокоманда (например,VPADDQ) за один такт складывает целые массивы чисел, задействуя широкие параллельные сумматоры на кристалле. - MISD (Multiple Instruction, Single Data):
Редкая архитектура высокой надежности (космические аппараты, шаттлы NASA), где несколько независимых процессоров выполняют разные инструкции над одним потоком данных для перекрестной проверки сбоев. - MIMD (Multiple Instruction, Multiple Data):
Многоядерные процессоры и распределенные кластеры, где каждое ядро выполняет свою независимую программу над своими данными.
Под капотом: Векторные регистры
Как процессору удается удерживать и складывать столько чисел одновременно?
В статье 6. Анатомия CPU. Datapath, Control Unit и Register File мы изучили регистры общего назначения (GPR), такие как RAX или RSP. Их физическая ширина в архитектуре x86-64 строго ограничена 64 битами (8 байт).
Чтобы не ломать старый софт, инженеры добавили в ядро процессора параллельный векторный регистровый файл (Vector Register File) и независимые сверхширокие векторные ALU:
flowchart TD
subgraph Evolution ["Эволюция ширины векторных регистров x86-64"]
direction TB
GPR["GPR (RAX, RBX): 64 бита (8 байт) - 1x int64"]
XMM["XMM (SSE): 128 бит (16 байт) - 2x int64 / 4x int32 / 16x byte"]
YMM["YMM (AVX2): 256 бит (32 байта) - 4x int64 / 8x int32 / 32x byte"]
ZMM["ZMM (AVX-512): 512 бит (64 байта) - 8x int64 / 16x int32 / 64x byte (Размер Cache Line!)"]
end
classDef gpr fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef xmm fill:#1e293b,stroke:#6366f1,stroke-width:1px,color:#f8fafc;
classDef ymm fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
classDef zmm fill:#0f172a,stroke:#f43f5e,stroke-width:1px,color:#f8fafc;
class GPR gpr;
class XMM xmm;
class YMM ymm;
class ZMM zmm;
XMMрегистры (SSE — Streaming SIMD Extensions):
Ширина 128 бит (16 байт). В процессоре их 16 штук: отX0доX15. В один регистр XMM помещается:- два числа
int64илиfloat64; - четыре числа
int32илиfloat32; - шестнадцать символов типа
byte!
- два числа
YMMрегистры (AVX / AVX2):
Ширина 256 бит (32 байта). Нижняя половина каждого YMM-регистра прозрачно совпадает с соответствующим регистром XMM.ZMMрегистры (AVX-512):
Ширина 512 бит (64 байта). В один такой регистр за раз помещается ровно 64 байта — это размер стандартной строки кэша процессора (Cache Line)!
SIMD в Go: Механическая симпатия
В языках C, C++ и Rust компиляторы (Clang, GCC) оснащены агрессивными оптимизаторами-автовекторизаторами. Вы пишете обычный цикл for, включаете флаг -O3, и компилятор сам превращает его в SIMD-инструкции.
Но значит ли это, что Go работает медленно? Вовсе нет!
Вся стандартная библиотека Go пропитана SIMD-оптимизациями. Но написаны они вручную на ассемблере Plan 9 лучшими инженерами ядра Go.
Пакеты bytes (bytes.Equal, bytes.Index), strings, crypto (аппаратное шифрование AES-NI, SHA-extensions), хэш-таблицы (Swiss Tables в Go 1.24) работают с запредельной скоростью именно благодаря SIMD.
Как выглядит векторное сравнение байт на уровне железа
Взглянем на упрощённый фрагмент в духе ассемблерных функций пакета internal/bytealg (например, compare_amd64.s; bytes.Equal в современном Go сводится к сравнению строк string(a) == string(b), которое компилируется в вызов memequal), сравнивающий первые 16 байт двух слайсов:
// AX указывает на слайс A, BX указывает на слайс B
MOVOU (AX), X0 // Загружаем 16 байт (128 бит) из слайса A в векторный регистр X0
MOVOU (BX), X1 // Загружаем 16 байт из слайса B в векторный регистр X1
PCMPEQB X1, X0 // Векторное сравнение (Packed Compare Equal Bytes)
PMOVMSKB X0, DX // Извлекаем маску знаков: если все 16 байт совпали, DX = 0xFFFF
CMPL DX, $0xffff // Одно сравнение вместо шестнадцати
Обратите внимание на красоту решения (в реальных функциях вокруг этого фрагмента есть цикл по блокам и обработка «хвоста» — здесь показано только ядро):
- Нет побайтового цикла: 16 байт обрабатываются одной векторной операцией;
- Нет ветвления на каждый байт: предсказателю переходов достаётся одна проверка на блок, а не шестнадцать;
- Все 16 байт сравниваются одновременно одной инструкцией (
PCMPEQB), а не шестнадцатью скалярными.
Использование SIMD в высоконагруженном бэкенде
Если ваш сервис занимается обработкой огромных массивов информации (In-Memory базы данных, парсинг логов, фильтрация сетевого трафика, поиск похожих векторов для AI-эмбеддингов), обычного скалярного Go будет недостаточно.
В экосистеме Go существуют признанные высокопроизводительные решения, выжимающие SIMD до предела:
simdjson-go:
Порт знаменитой библиотекиsimdjsonДаниэля Лемира. Она парсит гигабайты JSON в секунду! Алгоритм загружает в регистры 64 байта текста за раз и с помощью векторных масок мгновенно находит все спецсимволы: кавычки", двоеточия, открывающие скобки{.- Аналитические СУБД и TSDB (например, VictoriaMetrics, ClickHouse):
Системы такого класса активно применяют SIMD для сканирования и фильтрации колонок. Но не все они написаны на Go: ClickHouse — это C++, а VictoriaMetrics (Go) использует собственный формат сжатия временных рядов (дельты, десятичное представление значений иzstd), а не Gorilla XOR. - Векторный поиск (Vector Similarity / Embeddings):
Вычисление косинусного расстояния (Cosine Similarity) между 1536-мерными векторами OpenAI на чистом Go может работать в разы медленнее, чем на ассемблере с использованием векторных инструкций FMA (Fused Multiply-Add) (точный множитель зависит от CPU и размерности).
Итог
- DLP (Data-Level Parallelism): альтернатива усложнению конвейера. Одна векторная инструкция управляет массивом параллельных сумматоров, применяя операцию к сотням бит данных одновременно.
- Таксономия Флинна: разделяет скалярные процессоры (SISD) и векторные вычислители (SIMD).
- Векторные регистры: специализированные широкие хранилища процессора —
XMM(128 бит / 16 байт),YMM(256 бит / 32 байта) иZMM(512 бит / 64 байта, точно под размер Cache Line). - Реальность Go: компилятор Go не умеет автоматически векторизовать код при сборке (
cmd/compileне имеет автовекторизатора). Векторная мощь стандартных пакетов (bytes,strings,crypto) реализована через рукописный ассемблер Plan 9 (PCMPEQB,MOVOU). - Цена широкого SIMD: использование 512-битных регистров раздувает контекст потока ОС до 2.5 КБ (
XSAVEпри переключении потоков выполняет ядро, а не рантайм Go), а также может снижать частоту ядра, что в высококонкурентных бэкендах способно приводить к росту p99-латентности.
Широкие 512-битные регистры звучат потрясающе. Возникает логичный вопрос: почему инженеры не сделают регистры шириной 2048 или 4096 бит, чтобы обрабатывать целые мегабайты за такт?
Оказывается, законы термодинамики кремния суровы: широкие векторные вычисления потребляют столько энергии, что заставляют процессоры буквально плавиться и сбрасывать тактовую частоту.
В следующей лекции мы детально разберем эволюцию AVX и скрытые физические ловушки векторных вычислений: 15. AVX, AVX2, AVX-512 и ограничения SIMD.