A compile-time SIMD engine for C++ — a header-only vector library that maps one source text onto 128/256/512-bit hardware, plus an optional hand-written assembly layer that exists mainly to answer whether it earns its keep.
Vectis is a C++20/23 library that is ready for C++26, not a C++26 library. That distinction is the most important thing on this page and is explained under The C++26 question below.
#include <vectis/simd/vec.hpp>
#include <vectis/simd/math.hpp>
using namespace vectis;
// One line, three machines:
using v16 = f32<16>; // 1 ZMM on AVX-512, 2 YMM on AVX2, 16 floats scalar
static_assert(v16::num_regs == 16 / native_width_v<float>);
auto a = v16::load(a_ptr);
auto b = v16::load(b_ptr);
math::rsqrt(a * b + a).store(out_ptr);#include <vectis/soa/reflect.hpp> // C++26 reflection; needs GCC 16 -freflection
struct Particle { float x, y, z, vx, vy, vz; };
using L = vectis::reflected_layout<Particle>; // the struct describes itself
using F_x = L::field_at<0>;
vectis::soa_array<L> particles(aos_ptr, count); // AoS -> SoA
particles.for_each_block<16>([&](std::size_t i) { // vectorised kernel over it
particles.store<F_x, 16>(i, particles.load<F_x, 16>(i) +
particles.load<F_vx, 16>(i) * dt);
});There is no field list, no annotation, no macro and no code generator anywhere in
that. Reflection reads the struct, filters out members that cannot be lanes, and
the container sees an ordinary layout - so every kernel, test and benchmark
written against the hand-written path works unchanged. Mixed { float a; int n; char tag; double d; bool flag; long counter; } yields exactly a, n, d, counter: char is a byte and bool is a predicate, so both are skipped and the
order of the rest is preserved.
Without reflection (soa/soa.hpp) the same thing is spelled with one
pointer-to-member per field, which is what C++20 toolchains get:
using L = vectis::mem_layout<Particle, &Particle::x, &Particle::y, ...>;| Status | |
|---|---|
Scalar / AVX2 / AVX-512 backends for f32, f64, i32, u32, i64, u64 |
working |
| Multi-register vectors, partial-register tails, padded lanes | working, canary-tested |
std::simd-shaped API: masks, select, all/any/none/bits |
working |
AoS → SoA with real reflection (GCC 16, -freflection) |
working — no field list, no annotations |
| AoS → SoA without reflection, via pointer-to-member NTTPs | working |
Assembly layer (GAS .S, AVX2): dot, saxpy, sum |
working, bit-parity tested |
| Tests | 25 registered / ~73 000 assertions on GCC 15 — 2 skip without AVX-512, 1 without reflection; 30 / ~81 000 on GCC 16 |
| AVX-512 execution | real, on the runs whose runner has AVX-512 — see below |
The development machine is an Intel i5-6500T (Skylake, 2015): AVX2, FMA, BMI2, AES, PCLMULQDQ — and no AVX-512 at all. So:
- AVX2 is the ceiling for anything measured in this repository, and a
tier-
avx512binary run here dies with SIGILL, which is the expected result and not a bug. - The AVX-512 backend is compiled and linked on every tier, and its code
generation is verified — it emits
zmm,vpcmp,vrsqrt14psandvpternlogd— but whether it executes is decided somewhere else.
AVX-512 does execute in CI, but not on every run. GitHub's ubuntu-24.04
pool is mixed. Some runners are AMD EPYC 7763 — Zen 3, so AVX-512F/BW/CD/DQ/VL,
and not the Zen 4-only extensions — and some expose no AVX-512 in
/proc/cpuinfo at all; a job cannot choose which machine it gets. Across five
runs on 2026-09-20 and 21, the tier=avx512 job executed its tests on two of
them and built without running on the other three.
What that buys, and what it does not:
- On a runner that has it, the widest backend runs for real. The battery lives
in its own translation unit compiled with
-mavx512*and is entered through the caller'scpu::has(isa_level::avx512), so those runs genuinely exercise 512-bit mask registers, the reduction paths and the 64-bit integer operations AVX2 lacks — not merely their compilation. - On a runner that does not, the same binary builds and the battery skips
loudly.
vectis_testsexits 0 either way unless it is given--fail-on-skip, so a greenctestis not by itself evidence that AVX-512 ran. The CI stepDid the AVX-512 battery execute?is what settles that, and it fails if the battery skips on a host that reported AVX-512. - Nothing is measured for AVX-512: the benchmark job builds at tier
avx2. Every performance number below is an AVX2 number.
The working set is also small — 32 KiB L1d, 256 KiB L2, 6 MiB L3 — and the
governor is powersave, so absolute timings below are noisy and optimistic
comparisons should be read as ratios, not as benchmarks of your hardware.
The brief asked for std::experimental::simd, C++26 reflection, and concepts.
What is actually available changed while this was being built, so here is the
state of each, measured rather than assumed:
| Feature | GCC 15.2 | GCC 16 | Consequence |
|---|---|---|---|
| Reflection (P2996 core) | absent | works, behind -freflection |
AoS→SoA needs no field list at all |
__cpp_impl_reflection |
undefined | 202506 when the flag is on |
the header detects the feature by itself |
std::simd (P1928, C++26) |
absent | <simd> exists but __glibcxx_simd is off, so it compiles to nothing |
Vectis ships its own vector type |
<experimental/simd> (TS) |
absent | works — native_simd<float> gives 8 lanes on AVX2 |
usable as an independent cross-check |
| Concepts (C++20) | works | works | used pervasively, and load-bearing |
-std=c++26 |
__cplusplus = 202400 |
__cplusplus = 202400 |
"C++26" here means the C++2c draft, not the published standard |
So reflection is real and Vectis uses it; std::simd is still not usable in
either compiler, which is why the vector type is built directly on intrinsics.
That turned out to be the better position anyway: the mask representation, the
capability concepts and the partial-register model are design decisions a
std::simd wrapper would not have been able to make.
The language floor stays C++20. Reflection is a feature the library
discovers, not one it requires: soa/reflect.hpp compiles to nothing without
-freflection, the same layout written with pointers-to-member is the C++20
answer to the identical question, and both are tested. That is why the CI matrix
still runs C++20, 23 and 26 — a library that needs the newest compiler to build
at all is a library most people cannot use.
Enabling it is deliberately not something a consumer has to arrange: the header
keys off __cpp_impl_reflection, which appears only when the flag is on. Add
-freflection and the reflection path lights up; do not, and nothing breaks.
Vectors are N lanes, not one register. f32<16> is two YMMs on AVX2 and one
ZMM on AVX-512, and every kernel written against it works on both. Lane counts
that do not fill the last register are allowed and padded: f32<6> on AVX2 is
one YMM with two dead lanes and exactly six observable ones. Loads, stores,
to_array, bits() and every reduction respect that boundary — a mask over 5
lanes reports 5 bits, never the 8 the register holds, and there is a test that
fails if it ever does.
The scalar backend is the correctness oracle. It is written with plain C++
operators that deliberately mirror hardware semantics rather than <cmath>:
min(a,b) is (a<b)?a:b, not std::fmin, because they disagree on NaN and on
the sign of zero. Every vector backend is compared against it lane by lane, bit
pattern by bit pattern.
Capability concepts instead of silent fallbacks. AVX2 has no 64-bit
multiply, no 64-bit min/max and no ordered 64-bit compare. A kernel constrained
on BackendHasMul<int64_t, avx2_abi> simply does not exist there — a compile
error at the call site, not a scalar emulation that quietly runs at a quarter
speed.
No sse42 tier. A 128-bit backend would be a third parallel implementation of every operation, to serve hardware that in practice always has AVX2 (Haswell, 2013). The multi-register path is already exercised by wide vectors at the scalar and AVX2 tiers, so the coverage is not lost, only the duplication.
The brief asked for hand-written assembly "squeezing the absolute physical
maximum" out of the SIMD generation. Three kernels were written in GAS .S
(src/asm/avx2_kernels.S), each with an intrinsics twin built to the same
shape — four accumulators, 32 floats per iteration — so the comparison isolates
the one variable that matters.
dot_f32, n = 1024 floats, L1-resident, min of 21 runs:
| kernel | ns | cycles/element | speedup |
|---|---|---|---|
| scalar | 1491 | 4.92 | 1.00x |
| scalar-4acc | 679 | 1.73 | 2.05x |
| intrinsics | 107 | 0.338 | 13.93x |
| asm | 107 | 0.333 | 13.93x |
Assembly ties intrinsics exactly. The generated inner loops are
instruction-for-instruction identical — both compile to four vmovups /
vfmadd231ps / four vmovups per 32 elements. Where assembly appeared to win
by 1.3x (saxpy at n=1024), part of the cause was not better code: the C++
version was missing restrict, so GCC inserted runtime alias checks and a
peeled prologue. The shipped twin carries VECTIS_RESTRICT now, and the gap at
that size narrowed without closing — 100 ns against 132 ns, min of 21 runs,
1.28x, measured on the development machine with the current sources. What is
left is a small-n effect that has not been explained; it disappears at
DRAM-resident sizes, where assembly sometimes loses (0.92x).
The bigger finding is in the table's first two rows. The 13.9x speedup is not
about vector width. A plain scalar loop with four independent accumulators —
still IEEE-exact, still no -ffast-math, just a different summation order the
programmer chose — already captures 2.05x. Compiling the naive loop with
-ffast-math gets 6.9x, leaving under 2.3x attributable to width and FMA.
That is the real value proposition of an explicit SIMD engine: it is not that the lanes are wider, it is that you are allowed to choose the reduction order, portably and explicitly. A compiler cannot reassociate your floating-point sum without a flag that also turns off IEEE semantics everywhere else in the translation unit.
Recommendation: do not add assembly by default. It costs a second implementation of every kernel, a bit-parity test to keep the two honest, and an ABI boundary that cannot be inlined — and the measurements here show no return. Keep the layer, keep the parity test, and reach for it only when a profile shows a specific kernel where the compiler's register allocation is the problem.
bench_soa.cpp, particle integration over 7 fields, AoS scalar against SoA
vectorised:
| working set | AoS scalar | SoA n=8 | SoA n=16 | AoS→SoA conversion |
|---|---|---|---|---|
| 28 KiB (L1) | 1.00x | 1.61x | 1.37x | 1.00x |
| 448 KiB (L2/L3) | 1.00x | 1.41x | 1.28x | 0.49x |
| 7 MiB (L3) | 1.00x | 0.95x | 0.96x | 0.23x |
| 112 MiB (DRAM) | 1.00x | 0.95x | 0.87x | 0.24x |
Two conclusions, both unwelcome if you were hoping for a blanket win:
- SoA pays only while the data is L1/L2-resident (1.3–1.6x). Past the last cache level everything is bandwidth-bound, and AoS — one stream instead of seven — ties or beats it.
- The conversion costs 2–4x a kernel pass at large sizes, because a strided
gather is inherently slower than a linear sweep. Converting inside the hot
loop is a pessimisation. It only pays when the data stays SoA across
several kernels, which is what
soa_arrayis for: it owns storage rather than being a view.
(An earlier version of this benchmark reported conversion at 127 cycles per
element. That was measuring seven malloc/free pairs per call, not the
conversion. The harness now pre-allocates, and the note is kept because a
measurement artefact that absurd is worth remembering.)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DVECTIS_ISA_TIER=avx2
cmake --build build --parallel
ctest --test-dir build --output-on-failure
./build/tools/vectis-cpuinfoOne knob controls the ISA tier, because the two things it must control — the
-m flags and the ceiling the library may emit — must never disagree:
VECTIS_ISA_TIER |
Flags | Meaning |
|---|---|---|
auto (default) |
-march=x86-64-v3 |
portable baseline; AVX-512 reachable only via runtime-checked target attributes |
native |
-march=native |
fastest this exact CPU can do |
scalar |
-mno-avx -mno-avx2 -mno-fma |
reference path, the correctness oracle |
avx2 |
-march=x86-64-v3 -mno-avx512* |
256-bit ceiling |
avx512 |
-march=x86-64-v4 |
512-bit ceiling; needs AVX-512 hardware to run |
Benchmarks: ./build/bench/bench_simd, bench_soa, bench_asm (add --quick
for CI-sized runs).
find_package(vectis 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE vectis::vectis)The package supplies the include path and a C++20 floor, and nothing else.
In particular it does not impose -march or any warning flags on you: those
are your build's decisions, and a library that overrides them is a library
people work around.
The consequence is worth stating plainly, because it surprises people: a
consumer that passes no -march at all gets the scalar backend, since
native_abi follows whatever the compiler was told. That is correct but slow.
Pass -march=x86-64-v3 (AVX2, universal since Haswell) or
-DVECTIS_ISA_TIER in your own build to get vector code. vectis-cpuinfo
prints which tier a binary ended up with.
Two workflows, with different jobs:
-
ci.yml— the gate, on every push and pull request. ISA tiers, seven compilers, three language standards, six optimisation levels, standalone header compilation across two compilers × three standards × three tiers, an install-and-find_packageconsumer test, assembly parity with the layer both on and off, and aarch64 plus two macOS runners. Every job names what it defends against. A finalgatejob aggregates them for branch protection.One rule learned from a red run: a capability check must live in code compiled for the lowest tier you support. The AVX-512 tests used to guard themselves from inside the AVX-512 translation unit, where the check itself may be compiled to AVX-512 instructions and can fault before it returns false. The check now happens in the portable unit, which crosses into the AVX-512 unit through an exported call only after the answer is known.
-
quality.yml— the deeper pass, on pull requests and weekly. ASan+UBSan, TSan,_GLIBCXX_DEBUG,_GLIBCXX_ASSERTIONS,_FORTIFY_SOURCE=3, clang-tidy, cppcheck, GCC's-fanalyzer, coverage, and a benchmark smoke run.
The rule throughout: build always, run when the hardware allows. A tier
that cannot execute on a runner is still compiled, because a compile error is a
bug; only the execution is skipped, and the job says so in its summary rather
than passing quietly. Where the hardware is present, a skip is a failure
instead: Did the AVX-512 battery execute? re-runs the 512-bit battery with
--fail-on-skip on every tier, so "the widest backend ran" is checked rather
than assumed, and a run that reports AVX-512 but skips is red. The hardware
job reports the runner's CPU first, because every performance claim in this
README is meaningless without it.
Steps that depend on a tool which was unavailable on the development machine —
valgrind, clang-tidy, cppcheck — are marked continue-on-error: true and say so
in their comments. A check nobody has seen pass is not a gate; it is a red
herring that teaches people to ignore CI. Promote them once they have run green.
Three translation units exist for the backends, and that is not an accident:
GCC declares _mm512_cmplt_ps_mask with a different, reduced signature when
AVX512F is disabled, so merely mentioning it — inside a function nobody calls —
is a hard compile error. The AVX-512 backend therefore cannot sit unexecuted
inside an AVX2 binary; it needs a TU compiled for it and entered behind
cpu::has(isa_level::avx512).
The payoff is that one test binary exercises all three backends on any host, whatever tier the library was configured for. A scalar-tier build still proves the AVX2 and AVX-512 kernels are correct; it just cannot run all of them, and it says so loudly instead of skipping silently.
include/vectis/
core/config.hpp compiler / arch / ISA detection, target attributes
core/cpu.hpp runtime CPUID feature detection
core/concepts.hpp SimdVec, SimdMask, Field, SoaLayout
simd/abi.hpp register-file strategy tags
simd/backend.hpp the primitive protocol + capability concepts
simd/backend_scalar.hpp reference implementation (the oracle)
simd/backend_avx2.hpp 256-bit, including its documented gaps
simd/backend_avx512.hpp 512-bit, with real mask registers
simd/vec.hpp basic_vec<T, N, Abi> — multi-register folds
simd/mask.hpp per-lane predicates, padding-safe
simd/math.hpp rsqrt/rcp (refined), clamp/lerp/saturate/smoothstep
simd/reduce.hpp reductions, dot/length/normalize, chunked iteration
soa/soa.hpp AoS -> SoA via pointer-to-member descriptors
soa/reflect.hpp the same, read from the struct itself (needs P2996)
simd/detail.hpp bit-level and wrapping-arithmetic helpers
simd/backends.hpp one include point for every available backend
kernels/asm_kernels.hpp asm declarations + intrinsics twins
src/asm/avx2_kernels.S hand-written kernels (GAS, AT&T syntax)
tests/ registered suites; one battery shared across three TUs
bench/ asm-vs-intrinsics, vector-vs-scalar, SoA
tools/cpuinfo.cpp what this binary will use, and what the CPU can run
- AVX-512 execution coverage in CI is probabilistic. GitHub's runner pool is
mixed, so whether a given run exercises the 512-bit backend depends on which
machine a job landed on. Every run says which happened — the summary reports
the battery's own output, and a skip on a host that has AVX-512 fails the job
— but a single green run is not proof that it ran. Its performance stays
unmeasured either way: the benchmark job builds at tier
avx2. - Reflection needs
-freflection, which is GCC-only today. Clang has no P2996 implementation in any released version, so the reflection path is exercised on exactly one compiler. The pointer-to-member path is what everything else uses, and it is the one the CI matrix covers broadly. - No
exp/log/sin/cos. These need integer↔float lane conversions and a round-to-nearest in the backend protocol, plus range reduction. Doing them badly is worse than not doing them; they are a deliberate omission, not an oversight. - Reductions stage through memory. A shuffle tree would avoid the
store-forwarding stall, at the cost of a different shuffle per lane width and
element type. It is measured in
bench_simdrather than assumed. iota(first, step)requires a backend multiply, so it does not exist for 64-bit lanes on AVX2. Constrained rather than emulated.- No gather/scatter for strided access, no
int8/int16lanes, no compress/expand — all present on AVX-512 and all still on the table. - No ARM NEON backend. The scalar path carries aarch64 — and CI checks that it does — but there is no vector backend there yet.
- Use
<experimental/simd>as an independent oracle. It works on GCC 16, it is a separately written implementation of the same operations, and comparing against it would be a second opinion on every kernel - stronger than the scalar oracle, which is ours and shares our assumptions. - Make the AVX-512 coverage deterministic, then measure it. Today the widest
backend runs on the runs that happen to land on a machine with AVX-512, and
only compiles on the rest — coverage by luck. A self-hosted runner, or a
cloud instance on Sapphire Rapids, Ice Lake or Zen 4/5, would make it every
run, and would let the benchmark job build a tier-
avx512binary: the only way the numbers above stop being AVX2 numbers. - Add
int8/int16lanes withvpermb/vpshufb— the crypto and compression workloads in the original brief need them, and byte permutes are where AVX-512's advantage over AVX2 is largest. - A shuffle-tree reduction, benchmarked against the current one.
- An ARM NEON backend, now that the protocol and its capability concepts are settled.