библиотека — параллелизм на уровне данныхlibrary — data-level parallelism
Переносимый векторный тип (P1928, наследник std::experimental::simd из Parallelism TS2), отображающий арифметические операции на реальные SIMD-инструкции процессора (SSE/AVX на x86, NEON на ARM) на этапе компиляции — без ручных интринсиков и без надежды на угадывание автовекторизатора.A portable vector type (P1928, the successor to std::experimental::simd from the Parallelism TS2) that maps arithmetic operations onto the CPU's actual SIMD instructions (SSE/AVX on x86, NEON on ARM) at compile time — with no hand-written intrinsics and no hoping the auto-vectorizer guesses right.
std::simd<T> — тип, представляющий несколько значений типа T, обрабатываемых одной инструкцией процессора. std::simd<float> без явного размера использует "родную" ширину для целевой платформы и флагов компиляции (например, 8 float на AVX2), а std::simd<float, 8> — ширину, зафиксированную явно вне зависимости от платформы (с эмуляцией через несколько инструкций или скалярный fallback, если оборудование не поддерживает такую ширину нативно). Арифметические операторы (+, *, сравнения) перегружены так, что применяются поэлементно ко всем "полосам" (lanes) значения сразу, компилируясь в одну векторную инструкцию вместо цикла со скалярными.A std::simd<T> is a type representing several values of type T processed by a single CPU instruction. std::simd<float> with no explicit size uses the "native" width for the target platform and compilation flags (e.g. 8 floats on AVX2), while std::simd<float, 8> uses a width fixed explicitly regardless of platform (emulated via multiple instructions, or a scalar fallback, if the hardware has no native support for that width). Arithmetic operators (+, *, comparisons) are overloaded to apply elementwise across all "lanes" of the value at once, compiling down to a single vector instruction instead of a loop of scalar ones.
#include <immintrin.h>
// работает только на AVX2, отдельный путь нужен для NEON/AVX-512/SSE// works only on AVX2, a separate path is needed for NEON/AVX-512/SSE
void add_arrays(const float* a, const float* b, float* out, std::size_t n) {
std::size_t i = 0;
for (; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(out + i, _mm256_add_ps(va, vb));
}
for (; i < n; ++i) out[i] = a[i] + b[i]; // хвост вручную// the tail, handled by hand
}
void add_arrays(const float* a, const float* b, float* out, std::size_t n) {
std::size_t width = std::simd<float>::size();
std::size_t i = 0;
for (; i + width <= n; i += width) {
std::simd<float> va(a + i, std::element_aligned);
std::simd<float> vb(b + i, std::element_aligned);
(va + vb).copy_to(out + i, std::element_aligned); // одна и та же ширина везде: SSE, AVX2, AVX-512, NEON// same width everywhere: SSE, AVX2, AVX-512, NEON
}
for (; i < n; ++i) out[i] = a[i] + b[i]; // хвост всё ещё нужен// a tail is still needed
}
До std::simd переносимая векторизация означала выбор из трёх неидеальных вариантов:Before std::simd, portable vectorization meant choosing among three imperfect options:
Компилятор может векторизовать простой цикл сам, но небольшое изменение кода — добавленный вызов функции, изменённый порядок операций, псевдоним указателей, который компилятор не может исключить — может тихо отключить векторизацию. Никакой ошибки компиляции при этом не возникает, просто код становится медленнее, и заметить это можно только профилированием.A compiler can vectorize a simple loop on its own, but a small code change — an added function call, a reordered operation, pointer aliasing the compiler can't rule out — can silently disable vectorization. No compile error results; the code just gets slower, and the only way to notice is profiling.
_mm256_add_ps и подобные функции дают полный контроль и предсказуемый код, но результат работает только на конкретном наборе инструкций — переносимость на другую архитектуру (или даже на другой уровень расширений той же архитектуры, AVX2 против AVX-512) требует отдельного, часто #ifdef-огороженного пути кода на каждый случай.Functions like _mm256_add_ps give full control and predictable codegen, but the result only works on one specific instruction set — porting to another architecture (or even another extension level of the same architecture, AVX2 versus AVX-512) requires a separate, often #ifdef-fenced code path for each case.
Highway, xsimd, Vc (прямой предок этого предложения) уже решали ровно эту задачу — один переносимый тип, компилирующийся в нужные инструкции — но ценой внешней зависимости. std::simd переносит ту же идею внутрь стандартной библиотеки: один портируемый тип с явным контролем через fixed-width вариант, когда он нужен, и со скалярным fallback-поведением там, где у целевой платформы нет подходящей аппаратной ширины.Highway, xsimd, Vc (the direct ancestor of this proposal) already solved exactly this problem — one portable type that compiles down to the right instructions — but at the cost of an external dependency. std::simd brings the same idea into the standard library: one portable type with explicit control via the fixed-width variant when needed, and scalar fallback behavior where the target platform has no matching hardware width.
Базовый рабочий цикл: объявить std::simd<float>, загрузить данные из непрерывного диапазона, выполнить поэлементную арифметику, свернуть результат горизонтальной редукцией (std::reduce над simd-объектом или выделенная операция редукции), и, там где нужно условное поведение по полосам, использовать маскированную операцию (в духе where/select) вместо ветвления.The basic working cycle: declare a std::simd<float>, load data from a contiguous range, perform elementwise arithmetic, collapse the result with a horizontal reduction (std::reduce over a simd object, or a dedicated reduce operation), and, wherever per-lane conditional behavior is needed, use a masked operation (in the spirit of where/select) instead of branching.
#include <simd>
std::simd<float> a([](std::size_t i) { return static_cast<float>(i); }); // генератор-конструктор// generator constructor
std::simd<float> b(2.0f); // широковещательный конструктор — все полосы равны 2.0f// broadcasting constructor — every lane equals 2.0f
std::simd<float> sum = a + b;
std::simd<float> product = a * b;
float total = std::reduce(product); // горизонтальная редукция — одно скалярное число// a horizontal reduction — a single scalar number
// маскированная операция: по полосам, где a > 10, взять b, иначе a — без ветвления// a masked operation: for lanes where a > 10, take b, otherwise a — no branching
auto mask = a > std::simd<float>(10.0f);
std::simd<float> clamped = std::simd_select(mask, b, a);
Точная форма API (имена simd_select/where, точки настройки конструкторов) на момент написания ещё может измениться в финальной формулировке стандарта — в примерах ниже используется семантика предложения P1928, а не гарантированно окончательные имена функций.The exact shape of the API (the names simd_select/where, constructor customization points) may still shift in the final standard wording as of this writing — the examples below use the semantics of proposal P1928, not necessarily the final function names.
Обработка std::simd<float>::size() элементов за одну итерацию цикла вместо одного — базовый паттерн, лежащий в основе почти любого использования simd.Processing std::simd<float>::size() elements per loop iteration instead of one — the basic pattern underlying almost every use of simd.
void vector_add(const float* a, const float* b, float* out, std::size_t n) {
constexpr std::size_t width = std::simd<float>::size();
std::size_t i = 0;
for (; i + width <= n; i += width) {
std::simd<float> va(a + i, std::element_aligned);
std::simd<float> vb(b + i, std::element_aligned);
(va + vb).copy_to(out + i, std::element_aligned);
}
for (; i < n; ++i) out[i] = a[i] + b[i]; // хвост, не кратный ширине// the remainder not divisible by the width
}
Сумма поэлементных произведений двух массивов — умножение векторизовано, а горизонтальная редукция сворачивает результат в одно число.The sum of elementwise products of two arrays — the multiplication is vectorized, and a horizontal reduction collapses the result into a single number.
float dot_product(const float* a, const float* b, std::size_t n) {
constexpr std::size_t width = std::simd<float>::size();
std::simd<float> acc(0.0f);
std::size_t i = 0;
for (; i + width <= n; i += width) {
std::simd<float> va(a + i, std::element_aligned);
std::simd<float> vb(b + i, std::element_aligned);
acc += va * vb; // накопление по полосам, редукция только один раз в конце// per-lane accumulation, reduction only once at the end
}
float total = std::reduce(acc);
for (; i < n; ++i) total += a[i] * b[i];
return total;
}
Ограничение каждого элемента массива диапазоном [lo, hi] с помощью select по полосам вместо if на каждый элемент — типичная замена скалярной ветки на маскированную операцию.Clamping every array element to the [lo, hi] range using a per-lane select instead of an if per element — a typical replacement of a scalar branch with a masked operation.
void clamp_array(float* data, std::size_t n, float lo, float hi) {
constexpr std::size_t width = std::simd<float>::size();
std::simd<float> vlo(lo), vhi(hi);
std::size_t i = 0;
for (; i + width <= n; i += width) {
std::simd<float> v(data + i, std::element_aligned);
v = std::simd_select(v < vlo, vlo, v); // без ветвления по полосам// no per-lane branching
v = std::simd_select(v > vhi, vhi, v);
v.copy_to(data + i, std::element_aligned);
}
for (; i < n; ++i) data[i] = std::clamp(data[i], lo, hi);
}
Поканальная поправка яркости у большого массива пикселей — типичный throughput-bound цикл: работа простая, но объём данных огромен, и разница между скалярным и векторизованным вариантом напрямую видна в FPS/времени обработки кадра.Per-channel brightness adjustment over a large pixel array — a typical throughput-bound loop: the work is simple, but the data volume is huge, and the difference between the scalar and vectorized version shows up directly in FPS/frame processing time.
void adjust_brightness(std::uint8_t* pixels, std::size_t count, int delta) {
using simd_u8 = std::simd<std::uint8_t>;
constexpr std::size_t width = simd_u8::size();
simd_u8 vdelta(static_cast<std::uint8_t>(delta));
std::size_t i = 0;
for (; i + width <= count; i += width) {
simd_u8 v(pixels + i, std::element_aligned);
(v + vdelta).copy_to(pixels + i, std::element_aligned); // одна инструкция на width пикселей// one instruction per width pixels
}
for (; i < count; ++i) pixels[i] = static_cast<std::uint8_t>(pixels[i] + delta);
}
std::simd<T> без явного размера имеет ширину, зависящую от целевой архитектуры и флагов компиляции (-mavx2 против сборки по умолчанию без него дают разное size() для одного и того же кода). Код, которому переносимо нужна конкретная ширина, должен использовать fixed-width вариант (std::simd<T, N>) или запрашивать size() программно на этапе компиляции — не зашивать число вроде "8" в код напрямую.A std::simd<T> with no explicit size has a width that depends on the target architecture and compilation flags (-mavx2 versus a default build with it absent give different size() for the same code). Code that portably needs a specific width should use the fixed-width variant (std::simd<T, N>) or query size() programmatically at compile time — not hardcode a number like "8" directly.
Выгода от simd зависит от непрерывности данных в памяти и от того, что тело цикла не содержит зависящего от данных ветвления, которое нельзя выразить маской. Цикл с ранним выходом или сильно расходящимся по элементам управляющим потоком не получит выгоды и, скорее всего, потребует переработки алгоритма ещё до попытки векторизации.The benefit of simd depends on data being contiguous in memory and on the loop body having no data-dependent branching that can't be expressed as a mask. A loop with an early exit or heavily per-element-divergent control flow gets no benefit and will likely need restructuring before vectorization is even attempted.
simd-типы не превращают в векторные вызовы функции внутри тела цикла (например, std::sin на каждую полосу), если для simd-типа не существует специально векторизованной перегрузки соответствующей математической функции. Подмешивание обычного скалярного библиотечного вызова в simd-цикл незаметно откатывается к поэлементным скалярным вызовам, теряя большую часть выгоды.simd types don't turn function calls inside the loop body into vector calls (for example, calling std::sin per lane) unless a specially vectorized overload of that math function exists for the simd type. Mixing an ordinary scalar library call into a simd loop silently falls back to per-lane scalar calls, losing much of the benefit.