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>:Nlanes of the number typeT.Mask<N>:Nbooleans, 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
-
Tis an integer or floating-point type:byte,char,short,ushort,int,uint,long,ulong,floatordouble. These conform to the module’sLanetrait; 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' -
Nis 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 thecountproperty 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
| Make | Vector<T, N>() (every lane 0), Vector<T, N>(x) (every lane x) |
| Lanes | v[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 |
| Floats | square_root, floor, ceiling |
| Both | min, max, abs |
| Horizontal | sum, minimum, maximum |
| Comparisons | equal, 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) |
| Rearranging | shuffle(indices), reverse, interleave_low, interleave_high |
| Types | convert<U>() (lane by lane, like as), reinterpret<U, M>() (the same bits) |
| Memory | Vector<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-andabs, 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. -
shuffletakes each index moduloN, so every index picks a lane. With constant indices an optimized build makes it one shuffle instruction. -
convertfrom 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,floorandceilingonly 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.