Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SIMD vectors

A SIMD vector holds several numbers of one type, its lanes, and computes on all of them with one instruction. The simd module has two types:

  • Vector<T, N>: N lanes of the number type T.
  • Mask<N>: N booleans, what a lane-wise comparison gives.
import simd;

# The index of the first byte of `text` that is not ASCII, or its size:
# sixteen bytes per step, then one at a time for the rest.
first_non_ascii(text: String): ulong {
    var bytes = unsafe_cast<__ptr<byte>>(text.c_str());
    ulong i = 0;
    while (i + 16 <= text.size) {
        var high = simd.Vector<byte, 16>.load(bytes + i).greater_equal(0x80 as byte).to_bits();
        if (high != 0) {
            return i + high.trailing_zeros();
        }
        i = i + 16;
    }
    while (i < text.size && bytes[i] < 0x80) {
        i = i + 1;
    }
    return i;
}

main(): int {
    # A vector holds `N` lanes and computes on all of them at once.
    var a = simd.Vector<int, 4>(10);
    a[1] = 20;
    var b = a * 2 + 1;
    assert(b[0] == 21 && b[1] == 41 && b.sum() == 104, "lane-wise arithmetic");

    # A comparison gives a mask, one bool per lane.
    var big = b.greater(30);
    assert(big.to_bits() == 2 && big.any() && !big.all(), "a mask");
    var capped = big.select(simd.Vector<int, 4>(30), b);
    assert(capped.maximum() == 30, "select");

    # Floats, and conversions between lane types.
    var halves = a.convert<float>() * 0.5;
    assert(halves[1] == 10.0 && halves.square_root()[0] < 2.3, "floats");

    assert(first_non_ascii("plain ascii, then caf\u{e9}") == 21, "a scan");
    assert(first_non_ascii("short") == 5, "no match");
    return 0;
}

Bring them in with import simd; and write simd.Vector<byte, 16>, or import [Vector, Mask] from simd; and write Vector<byte, 16>.

Lane types and counts

  • T is an integer or floating-point type: byte, char, short, ushort, int, uint, long, ulong, float or double. These conform to the module’s Lane trait; any other type is an error at the type argument:

    main.pep:3:25: error: 'String' does not conform to trait 'Lane', required by 'T' in 'Vector'
    
  • N is written as a number, a power of two from 1 to 64:

    main.pep:3:30: error: lane count of 'Vector<int, 3>' must be a power of two from 1 to 64, not '3'
    

    A number is a type argument only here, for a lane count; it is not a general constant parameter, so code cannot compute with N (use the count property for the lane count as a value).

  • Sixteen bytes (Vector<byte, 16>, Vector<short, 8>, Vector<int, 4>, Vector<float, 4>, Vector<double, 2>) fits a vector register on every target Pepper supports and is the width to reach for first. A wider vector works everywhere: where the target’s registers are narrower, each operation takes several instructions.

A vector is a value struct: it is stored inline, copied like a number, and safe to share between threads (a [Shared] struct’s field, a module constant).

Operations

MakeVector<T, N>() (every lane 0), Vector<T, N>(x) (every lane x)
Lanesv[i], v[i] = x (panics when i >= N), v.count
Arithmetic+ - *, unary -, with a vector or a scalar: v * 2
Division/ %: integer lanes panic on a zero divisor lane, and on the lowest value divided by -1; float lanes follow IEEE
Bitwise (integers)& | ^ ~, << >> by an int count (panics outside 0.. the lane width; >> is arithmetic for signed T), rotate_left, rotate_right
Floatssquare_root, floor, ceiling
Bothmin, max, abs
Horizontalsum, minimum, maximum
Comparisonsequal, not_equal, less, less_equal, greater, greater_equal, with a vector or a scalar, giving a Mask<N>
Whole vector== (every lane equal), != (some lane differs)
Rearrangingshuffle(indices), reverse, interleave_low, interleave_high
Typesconvert<U>() (lane by lane, like as), reinterpret<U, M>() (the same bits)
MemoryVector<T, N>.load(array, index), v.store(array, index) (panic unless index + N <= array.count); load, store, load_aligned, store_aligned on a __ptr<T>

A mask has any(), all(), & | ^ ~, m[i], select(a, b) (each lane from a where the mask is true, else from b) and to_bits(), which packs lane i into bit i of a ulong. With trailing_zeros() (see integer bit methods) that finds the first true lane, which is how a scan for a byte, or for a byte that is not ASCII, finds its match sixteen bytes at a time.

Some differences from scalar arithmetic are deliberate:

  • Integer lanes wrap around on overflow in +, -, *, unary - and abs, as vector instructions do; scalar arithmetic panics instead.

  • Comparisons that give a lane per lane are methods, because a comparison operator must return bool (see operators). == and != compare whole vectors.

  • shuffle takes each index modulo N, so every index picks a lane. With constant indices an optimized build makes it one shuffle instruction.

  • convert from float to integer saturates: a lane outside the target type’s range becomes its nearest value, and NaN becomes 0.

  • The bitwise operators, shifts and rotates exist only for integer lanes, and square_root, floor and ceiling only for float lanes:

    main.pep:4:15: error: no member named 'square_root' in 'Vector<int, 4>'
    

Targets and optimization

The operations compile to LLVM vector instructions, which the target turns into its own: SSE2 on x86-64 (wider with -march=x86-64-v3 for AVX2, see pep-build), NEON on arm64, and WebAssembly’s simd128 with -msimd128. A target without vector instructions, such as wasm32-wasip1 without -msimd128, computes the same results one lane at a time. Nothing picks instructions at run time: the -m options decide.

Each operation is a small method that the compiler always inlines, so an optimized build (-O2) of a loop over vectors has no calls in it, only the vector instructions. At -O0 it still runs, more slowly, like the rest of an unoptimized program.