Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vectis

CI Quality License

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.


What it does

#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, ...>;

What works, verified

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 hardware this was built on

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-avx512 binary 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, vrsqrt14ps and vpternlogd — 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's cpu::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_tests exits 0 either way unless it is given --fail-on-skip, so a green ctest is not by itself evidence that AVX-512 ran. The CI step Did 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 C++26 question, answered honestly

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.


Design decisions worth knowing

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 assembly verdict

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.

The SoA verdict

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:

  1. 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.
  2. 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_array is 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.)


Building

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-cpuinfo

One 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).

Using it from another project

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.

Continuous integration

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_package consumer test, assembly parity with the layer both on and off, and aarch64 plus two macOS runners. Every job names what it defends against. A final gate job 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.

Test layout, and why it is split

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.


Repository layout

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

Known limitations

  • 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_simd rather 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/int16 lanes, 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.

Next steps, in the order they would pay off

  1. 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.
  2. 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-avx512 binary: the only way the numbers above stop being AVX2 numbers.
  3. Add int8/int16 lanes with vpermb/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.
  4. A shuffle-tree reduction, benchmarked against the current one.
  5. An ARM NEON backend, now that the protocol and its capability concepts are settled.

About

Header-only C++ SIMD engine: one vec<T,N> across scalar, AVX2 and AVX-512, AoS to SoA without reflection, and measured verdicts on when hand-written assembly and SoA layouts actually pay.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages