SIMD
The fifth value type, v128, holds 128 bits that instructions treat as a
vector of lanes: sixteen 8-bit integers, eight 16-bit, four 32-bit or two
64-bit integers, or four f32s or two f64s. One instruction operates on
every lane at once. For the numeric loops at the heart of a game engine,
such as vector maths, particle updates, skinning, audio mixing and image
processing, that can mean a speedup of two to four times. This chapter
covers the instruction set and how to use it, then the relaxed SIMD
instructions added in WebAssembly 3.0.
#One type, many shapes19.1
A v128 has no lane shape of its own. As with signedness for integers, the
instruction says how to interpret the bits, through a prefix naming the
shape:
| Shape | Lanes |
|---|---|
i8x16 |
sixteen 8-bit integers |
i16x8 |
eight 16-bit integers |
i32x4 |
four 32-bit integers |
i64x2 |
two 64-bit integers |
f32x4 |
four f32s |
f64x2 |
two f64s |
v128.const writes a constant in any shape, and extract_lane reads one
lane out as a scalar:
(module
(import "env" "print_i32" (func $print (param i32)))
(func (export "main")
(local $v v128)
(local.set $v (i32x4.add
(v128.const i32x4 1 2 3 4)
(v128.const i32x4 10 20 30 40)))
(call $print (i32x4.extract_lane 0 (local.get $v)))
(call $print (i32x4.extract_lane 3 (local.get $v)))
;; the same 128 bits, read as sixteen bytes: lane 4 is the low byte of the second i32
(call $print (i8x16.extract_lane_u 4 (local.get $v)))))
11 44 22
Lane indices are immediates: fixed in the code, not computed at runtime.
Lane 0 is at the lowest address when a vector is stored to memory, so a
v128.load of an array of four f32s puts element 0 in lane 0.
#The instructions19.2
The SIMD instruction set has about two hundred instructions, all behind
the fd prefix. They fall into a few groups:
| Group | Examples |
|---|---|
| construct | v128.const, i32x4.splat (copy a scalar into every lane) |
| lanes | extract_lane, replace_lane |
| memory | v128.load, v128.store, v128.load32_splat, v128.load8x8_s (widen as it loads), v128.load32_lane |
| arithmetic | add, sub, mul, neg, abs, min, max, avgr_u, saturating add_sat_s and sub_sat_u |
| floating point | f32x4.div, sqrt, ceil, floor, nearest, pmin, pmax |
| comparison | eq, lt_s, gt, …, giving all-ones or all-zeros per lane |
| bitwise | v128.and, or, xor, not, andnot, bitselect |
| tests | v128.any_true, i32x4.all_true, i32x4.bitmask |
| rearrange | i8x16.shuffle (16 immediate lane indices), i8x16.swizzle (indices from a vector) |
| convert | f32x4.convert_i32x4_s, i32x4.trunc_sat_f32x4_s, extend_low, narrow |
| specialised | i32x4.dot_i16x8_s, i16x8.q15mulr_sat_s, extmul |
Comparisons produce masks, not booleans: each lane becomes all ones if the
comparison holds and all zeros if not. Masks combine with bitselect to
choose between two vectors lane by lane, which is how branches are
removed from vectorised code:
(module
(import "env" "print_f32" (func $print (param f32)))
(func (export "main")
(local $x v128)
(local $mask v128)
(local $clamped v128)
;; clamp negative lanes to zero, without a branch
(local.set $x (v128.const f32x4 -1.5 2.5 -3.5 4.5))
(local.set $mask (f32x4.lt (local.get $x) (v128.const f32x4 0 0 0 0)))
(local.set $clamped (v128.bitselect
(v128.const f32x4 0 0 0 0) ;; where the mask is set
(local.get $x) ;; where it is clear
(local.get $mask)))
(call $print (f32x4.extract_lane 0 (local.get $clamped)))
(call $print (f32x4.extract_lane 1 (local.get $clamped)))
(call $print (f32x4.extract_lane 2 (local.get $clamped)))))
0 2.5 0
(This particular clamp is also f32x4.max with zero, but the masking
pattern works for any condition.)
#Vector maths19.3
Games are full of 4-component vectors and 4×4 matrices, and an f32x4
holds a whole vector. Here is a matrix-vector product, with the matrix
stored in column-major order as graphics APIs expect: the result is each
column scaled by one component of the vector, summed.
(module
(import "env" "print_f32" (func $print (param f32)))
(memory 1)
;; a matrix that scales by 2 and translates by (10, 20, 30), column-major,
;; then the vector (1, 2, 3, 1)
(data (i32.const 0)
"\00\00\00\40" "\00\00\00\00" "\00\00\00\00" "\00\00\00\00" ;; column 0: 2 0 0 0
"\00\00\00\00" "\00\00\00\40" "\00\00\00\00" "\00\00\00\00" ;; column 1: 0 2 0 0
"\00\00\00\00" "\00\00\00\00" "\00\00\00\40" "\00\00\00\00" ;; column 2: 0 0 2 0
"\00\00\20\41" "\00\00\a0\41" "\00\00\f0\41" "\00\00\80\3f") ;; column 3: 10 20 30 1
(data (i32.const 64) "\00\00\80\3f" "\00\00\00\40" "\00\00\40\40" "\00\00\80\3f")
;; out = m * v, for a matrix at $m and vectors at $v and $out
(func $mul_mat4_vec4 (param $m i32) (param $v i32) (param $out i32)
(v128.store (local.get $out)
(f32x4.add
(f32x4.add
(f32x4.mul (v128.load offset=0 (local.get $m)) (v128.load32_splat offset=0 (local.get $v)))
(f32x4.mul (v128.load offset=16 (local.get $m)) (v128.load32_splat offset=4 (local.get $v))))
(f32x4.add
(f32x4.mul (v128.load offset=32 (local.get $m)) (v128.load32_splat offset=8 (local.get $v)))
(f32x4.mul (v128.load offset=48 (local.get $m)) (v128.load32_splat offset=12 (local.get $v)))))))
(func (export "main")
(call $mul_mat4_vec4 (i32.const 0) (i32.const 64) (i32.const 128))
(call $print (f32.load offset=0 (i32.const 128)))
(call $print (f32.load offset=4 (i32.const 128)))
(call $print (f32.load offset=8 (i32.const 128)))
(call $print (f32.load offset=12 (i32.const 128)))))
12 24 36 1
v128.load32_splat loads one f32 and copies it into all four lanes,
which is exactly what scaling a column by one component needs. The whole
product is four loads of columns, four splat-loads, four multiplies and
three adds.
#Structure of arrays19.4
Vectorising one 4-component vector at a time, as above, uses the lanes well only when the data happens to have four components. The bigger wins come from processing four items at a time: four particles, four vertices, four samples. That needs the data laid out as a structure of arrays, with all the x coordinates together, then all the y coordinates, rather than an array of structures:
array of structures: x0 y0 z0 x1 y1 z1 x2 y2 z2 x3 y3 z3 ...
structure of arrays: x0 x1 x2 x3 ... y0 y1 y2 y3 ... z0 z1 z2 z3 ...
Then a loop advances through each array four elements at a time. Here is
a particle update, x += vx * dt, for one coordinate:
(module
(import "env" "print_f32" (func $print (param f32)))
(memory 1)
;; 8 positions at 0, 8 velocities at 1024
(func $init
(local $i i32)
(loop $l
(f32.store (i32.shl (local.get $i) (i32.const 2)) (f32.convert_i32_u (local.get $i)))
(f32.store offset=1024 (i32.shl (local.get $i) (i32.const 2)) (f32.const 10))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const 8)))))
;; x[i] += vx[i] * dt for n particles, n a multiple of 4
(func $integrate (param $n i32) (param $dt f32)
(local $p i32)
(local $end i32)
(local $dt4 v128)
(local.set $dt4 (f32x4.splat (local.get $dt)))
(local.set $end (i32.shl (local.get $n) (i32.const 2)))
(loop $l
(v128.store (local.get $p)
(f32x4.add
(v128.load (local.get $p))
(f32x4.mul (v128.load offset=1024 (local.get $p)) (local.get $dt4))))
(local.set $p (i32.add (local.get $p) (i32.const 16)))
(br_if $l (i32.lt_u (local.get $p) (local.get $end)))))
(func (export "main")
(call $init)
(call $integrate (i32.const 8) (f32.const 0.5))
(call $print (f32.load (i32.const 0)))
(call $print (f32.load (i32.const 28)))))
5 12
A real version handles a count that is not a multiple of four, either by padding the arrays or with a scalar loop for the remainder.
#SIMD and the boundary19.5
A v128 cannot cross into JavaScript: a function with a v128 parameter
or result is callable only from WebAssembly, and calling it from
JavaScript is a TypeError. SIMD code reads its input from memory and
writes its output to memory, and the boundary sees only addresses and
counts.
#Relaxed SIMD19.6
The original SIMD instructions are fully deterministic, which sometimes
costs speed. A fused multiply-add, for instance, must not be used for
f32x4.mul followed by f32x4.add, because rounding once instead of
twice gives different results. Some operations, such as a float-to-int
conversion of out-of-range lanes, have no single fast implementation on
both x86 and ARM, so the deterministic version is slow on one of them.
Relaxed SIMD, in WebAssembly 3.0, adds instructions that allow a small, documented set of results, so each engine can use its hardware's fastest instruction:
| Instruction | Relaxation |
|---|---|
f32x4.relaxed_madd, relaxed_nmadd |
fused or unfused multiply-add |
i32x4.relaxed_trunc_f32x4_s |
out-of-range lanes give any of a few values |
i8x16.relaxed_swizzle |
out-of-range indices give zero or another lane |
f32x4.relaxed_min, relaxed_max |
NaN and signed zero handling may differ |
i32x4.relaxed_laneselect |
uses the whole lane mask or just its top bit |
i16x8.relaxed_dot_i8x16_i7x16_s |
dot products of small integers |
(module
(import "env" "print_f32" (func $print (param f32)))
(func (export "main")
;; a*b + c, fused or not: the engine chooses
(call $print (f32x4.extract_lane 0
(f32x4.relaxed_madd
(v128.const f32x4 1.5 0 0 0)
(v128.const f32x4 2 0 0 0)
(v128.const f32x4 1 0 0 0))))))
4
For this input both answers are the same, which is the common case: the results differ only in rounding of the last bit, or for lanes that are out of range. For graphics and audio, relaxed SIMD is almost always fine. For anything that must be reproducible across machines, such as lockstep networked physics, use the deterministic instructions.
#What you have seen19.7
v128 holds a 128-bit vector, and each instruction names the lane shape
it works with. SIMD instructions cover arithmetic, comparisons that
produce masks, lane selection, shuffles, conversions and loads that splat
or widen. Vector maths maps naturally onto f32x4, and the big wins come
from processing four items at once over data stored as a structure of
arrays. Vectors stay inside WebAssembly. Relaxed SIMD trades bit-exact
results for speed.
The next chapter covers exception handling.