Chapter 19

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.