CodeRigor

FREE LESSON

SVE: vector-length-agnostic SIMD

Write SIMD code that runs at any vector length from 128 to 2048 bits: svcnt*, while-predicates instead of scalar tails, merging vs zeroing predication, predicated loads, widening loads and reductions.

Every SIMD skill so far baked a width into the code: 32 bytes per AVX2 step, 16 per NEON step, then a scalar tail for the rest. SVE (the Scalable Vector Extension) removes both. The hardware picks a vector length anywhere from 128 to 2048 bits (a multiple of 128), your code asks for it at run time, and predicates switch off the lanes past the end, so the last partial vector runs through the same loop body as all the others.

machine SVE vector length
Neoverse N2 / V2 (e.g. Graviton4, Grace) 128 bits
Neoverse V1 (Graviton3) 256 bits
Fujitsu A64FX (Fugaku) 512 bits
this course's qemu (-cpu max) 512 bits by default, 128–2048 on request

SVE is optional, so — like AVX2 — enable it per function: __attribute__((target("+sve"))), and include <arm_sve.h>.

1. Registers: z, p and the length

Scalable registers (drawn at VL = 512)
z registers are VL bits wide — 128 to 2048, chosen by the hardware. The low 128 bits of z0 are NEON's v0. Each predicate has one bit per byte of z.
Scalable registers (drawn at VL = 512)
z registers are VL bits wide — 128 to 2048, chosen by the hardware. The low 128 bits of z0 are NEON's v0. Each predicate has one bit per byte of z.

Code never knows VL at compile time. It asks:

Text
svcntb()  bytes per vector          (VL / 8)
svcnth()  16-bit lanes              svcntw()  32-bit lanes     svcntd()  64-bit lanes
C++
#include <arm_sve.h>
#include <cstdio>
#include <sys/prctl.h>

__attribute__((target("+sve"))) void show() {
    std::printf("VL = %d bits: %d bytes, %d int32 lanes, %d int64 lanes\n",
                (int)svcntb() * 8, (int)svcntb(), (int)svcntw(), (int)svcntd());
}

int main() {
    show();
    prctl(PR_SVE_SET_VL, 16);   // Linux lets a process lower its vector length
    show();
}

The SVE types (svint32_t, svfloat32_t, svbool_t…) are sizeless: no sizeof, no arrays of them, no struct members. They live in locals, parameters and return values only — the compiler can't lay out an object whose size it doesn't know.

2. The loop: predicates instead of tails

C++
for (std::size_t i = 0; i < n; i += svcntw()) {   // step: one vector of int32
    svbool_t pg = svwhilelt_b32(i, n);             // lane k active while i + k < n
    svint32_t v = svld1(pg, p + i);                // inactive lanes: not read (and set to 0)
    …
    svst1(pg, out + i, result);                    // inactive lanes: not written
}
svwhilelt_b32(i, 37) at VL = 512 (16 lanes)
Lane k is active while i + k < n. The last iteration just has fewer active lanes — no tail loop, no read or write past element 36.
svwhilelt_b32(i, 37) at VL = 512 (16 lanes)
Lane k is active while i + k < n. The last iteration just has fewer active lanes — no tail loop, no read or write past element 36.

svwhilelt_b32(i, n) is "while less than": lane k is on iff i + k < n. Predicated loads and stores don't touch inactive lanes, so the loop never reads or writes past the end — the same code is correct for every n and every VL. svptrue_b32() makes all lanes active; use it only when you know a whole vector is valid.

C++
#include <arm_sve.h>
#include <cstdint>
#include <cstdio>

__attribute__((target("+sve")))
void axpy(std::int32_t a, const std::int32_t* x, std::int32_t* y, std::size_t n) {
    for (std::size_t i = 0; i < n; i += svcntw()) {
        svbool_t pg = svwhilelt_b32(i, n);
        svint32_t vy = svld1(pg, y + i);
        svst1(pg, y + i, svmla_x(pg, vy, svld1(pg, x + i), a));   // y + x·a
    }
}

int main() {
    std::int32_t x[37], y[38];
    for (int i = 0; i < 37; ++i) { x[i] = i; y[i] = 1; }
    y[37] = -999;                                  // guard: must survive
    axpy(2, x, y, 37);
    std::printf("y[0]=%d y[36]=%d guard=%d\n", y[0], y[36], y[37]);
}

3. Merging, zeroing, don't-care

Most arithmetic intrinsics come in three predicated forms:

Text
svadd_m(pg, a, b)   inactive lanes keep a        (merging)     add z0.s, p0/m, z0.s, z1.s
svadd_z(pg, a, b)   inactive lanes become 0      (zeroing)     movprfx z0.s, p0/z, z0.s ; add …
svadd_x(pg, a, b)   inactive lanes: unspecified  (don't care)  add z0.s, z0.s, z1.s
The three forms on the last, partial iteration
Lanes 2 and 3 are inactive. _m keeps a there, _z writes 0, _x leaves whatever is cheapest — fine only if you never look at those lanes again.
The three forms on the last, partial iteration
Lanes 2 and 3 are inactive. _m keeps a there, _z writes 0, _x leaves whatever is cheapest — fine only if you never look at those lanes again.

Choose by what happens to the inactive lanes afterwards:

  • results you'll store under the same predicate → _x (cheapest: the compiler may drop the predicate entirely);
  • accumulators carried across iterations → _m. With _z, the last partial iteration would wipe the inactive lanes of the accumulator and lose everything they had summed;
  • _z when you need clean zeros, e.g. before an unpredicated reduction.

4. Compares produce predicates

svcmpgt(pg, v, t) returns an svbool_t, not a vector of all-ones lanes — the job x86 and NEON give to masks in vector registers (and to movemask) belongs to predicate registers here:

Text
svcmpgt / svcmpeq / …     predicate of lanes where the compare holds
svcntp_b32(pg, p)         count of active lanes of p      (cntp)   — popcount of a mask
svptest_any(pg, p)        any lane true?                  (ptest + b.any)
svbrkb / svlastb          lanes before the first true one / last active element
svsel(p, a, b)            per-lane select                 (sel)
Compares make predicates, and predicates have their own instructions
svcmpgt(pg, v, 5) sets a predicate bit per lane; svcntp counts them (3), svptest_any asks whether any is set, svbrkb keeps the lanes before the first true one.
Compares make predicates, and predicates have their own instructions
svcmpgt(pg, v, 5) sets a predicate bit per lane; svcntp counts them (3), svptest_any asks whether any is set, svbrkb keeps the lanes before the first true one.

5. Widening and reductions

  • Widening loads: svld1sw_s64(pg, p) loads int32s and sign-extends each into an int64 lane (ld1sw z.d); svld1ub_u32 loads bytes into 32-bit lanes. So a 64-bit accumulation steps by svcntd() with svwhilelt_b64.
  • Reductions take a predicate and ignore inactive lanes: svaddv, svmaxv, svminv, svandv… svaddv on 8/16/32-bit integer lanes returns a 64-bit sum (saddv d0, p0, z0.s) — but only the final reduction widens; lanes you accumulated in 32 bits may already have wrapped.
  • Floating-point svaddv is a tree reduction (faddv), so its rounding differs from a sequential loop; svadda (fadda) adds strictly in order.

6. Loops that can't know n: first-faulting loads

A vectorized strlen doesn't know where the string ends, so reading a whole vector might cross into an unmapped page. SVE's answer is the first-faulting load svldff1: lanes up to the first one that would fault are loaded, and instead of trapping, the faulting lane and everything after it are marked inactive in the FFR (svrdffr). The loop processes the lanes it got and continues from there. NEON code has to align its loads to stay within a page instead.

svldff1 near the end of a page
Instead of trapping, the lane that would fault and every lane after it are marked off in the FFR. The loop examines the 5 bytes it got — here it finds the '\0' and stops; otherwise it would continue from the first lane it didn't get. NEON avoids the fault only by aligning its loads.
svldff1 near the end of a page
Instead of trapping, the lane that would fault and every lane after it are marked off in the FFR. The loop examines the 5 bytes it got — here it finds the '\0' and stops; otherwise it would continue from the first lane it didn't get. NEON avoids the fault only by aligning its loads.

7. The compiler and SVE

Predication makes auto-vectorization cheaper: there's no epilogue to justify, so GCC's -O2 cost model vectorizes loops for SVE that it leaves scalar for NEON (an unknown-n a[i] += b[i] is the classic case). The generated loop looks exactly like the hand-written one:

Assembly
        whilelo p7.s, wzr, w2          // first predicate
.L8:    ld1w    z31.s, p7/z, [x0, x3, lsl 2]
        ld1w    z30.s, p7/z, [x1, x3, lsl 2]
        add     z30.s, z30.s, z31.s
        st1w    z30.s, p7, [x0, x3, lsl 2]
        add     x3, x3, x4             // x4 = cntw
        whilelo p7.s, w3, w2           // next predicate, sets flags
        b.any   .L8                    // loop while any lane is active

Use the explorer's AArch64 target with -march=armv8.2-a+sve to watch loops vectorize; intrinsics remain the tool for what the vectorizer won't do (early exits with first-faulting loads, predicate tricks, gathers, specific widening).

SVE2 (Armv9) extends SVE to most of NEON's integer and DSP operations (widening/narrowing pairs, complex arithmetic, match/histcnt for string and histogram work), making SVE a full replacement rather than an HPC add-on. SME adds matrix tiles on top — a topic of its own.

8. Testing vector-length agnostic code

Code that passes at one VL can still assume it. Linux lets a process change its own vector length with prctl(PR_SVE_SET_VL, bytes), and qemu emulates every length up to 2048 bits, so this skill's tests rerun everything at 128, 256, 384, 512, 1024 and 2048 bits. A hard-coded i += 16 passes at 512 and fails everywhere else.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

John Linford introduces vector-length-agnostic SVE programming and predication. Connect the examples to the lesson’s while predicates, predicated loads and tail handling; keep correctness separate from native hardware timing.