language

SIMD

Single instruction, multiple data via WebAssembly’s native 128-bit vector type.

A v128 value holds 128 bits, which the f32x4 and i32x4 builtins read as four 32 bit lanes. One SIMD instruction works on all four lanes at once, so a loop over floats can handle four of them per step. v128 works as locals, params, returns, and class fields.

The wasmSimd setting

SIMD is on by default in a Rive project. wasmSimd in rive.yaml chooses which SIMD the module may use:

A script that uses no SIMD builds the same either way. Jobs, workers and audio processors build with the same setting as the main module. See Build Settings.

What is supported

The compiler has part of WebAssembly SIMD:

There are no shifts, no rounding, no conversions between float and integer lanes, no lt, gt, le or ge on i32x4, and no loads that splat, widen or zero the other lanes. There is no v128.const either, so there are no v128 globals: build a constant vector with splat and replace_lane.

v128.load, v128.store and the lane loads and stores read and write raw memory, so a function that calls them must be unsafe. A lane index is a constant below the lane count of its width.

Keeping a scalar path

A project built with wasmSimd: off has no SIMD, so code that must also run there keeps a scalar path beside the SIMD one. Two rules make one source serve both builds:

Make the scalar path add things up in the same order as the SIMD one, so both builds give the same results.

integrate.as — one source, both builds
// Scalar with wasmSimd: off, four lanes wide otherwise (this example
// compiles with SIMD on); the dead branch never compiles, so the
// scalar module has no v128 and loads in browsers without SIMD.
// Raw loads put this behind the unsafe gate.
export unsafe function integrate(xs: usize, vs: usize,
                                 dt: f32, count: i32): void {
    if (ASC_FEATURE_SIMD) {
        for (let i = 0; i < count; i += 4) {
            let x = v128.load(xs + (<usize>i << 2));
            let v = v128.load(vs + (<usize>i << 2));
            v128.store(xs + (<usize>i << 2),
                       f32x4.add(x, f32x4.mul(v, f32x4.splat(dt))));
        }
        return;
    }
    for (let i = 0; i < count; i++) {
        let p = xs + (<usize>i << 2);
        store<f32>(p, load<f32>(p) + load<f32>(vs + (<usize>i << 2)) * dt);
    }
}

export unsafe function demo(): f32 {
    let xs = __alloc(16 << 2);
    let vs = __alloc(16 << 2);
    for (let i = 0; i < 16; i++) {
        store<f32>(xs + (<usize>i << 2), f32(i));
        store<f32>(vs + (<usize>i << 2), 0.5);
    }
    integrate(xs, vs, 2.0, 16);
    let sum: f32 = 0;
    for (let i = 0; i < 16; i++) {
        sum += load<f32>(xs + (<usize>i << 2));
    }
    return sum;   // 120 + 16 × 0.5 × 2.0 = 136, both builds
}
lanes.as — helpers that need SIMD on
// f32x4.min/max differ from the scalar ternary on NaN and ±0;
// compare + bitselect matches it bit for bit, so the SIMD and
// scalar builds give the same results.
// v128 in a signature compiles only with SIMD on.
function min4(a: v128, b: v128): v128 {
    return v128.bitselect(a, b, f32x4.le(a, b));
}

export function step(px: f32, py: f32): f32 {
    let c = f32x4.splat(px);
    c = f32x4.replace_lane(c, 1, py);
    let best = min4(c, f32x4.splat(100.0));
    if (ASC_FEATURE_RELAXED_SIMD) {
        // One rounding instead of two: results can differ from
        // mul+add, and by platform.
        best = f32x4.relaxed_madd(best, f32x4.splat(0.5), f32x4.splat(1.0));
    } else {
        best = f32x4.add(f32x4.mul(best, f32x4.splat(0.5)), f32x4.splat(1.0));
    }
    return f32x4.extract_lane(best, 0);
}

Shuffles

shuffle.as — lane shuffles and byte swizzles
// A shuffle picks lanes from its two operands laid end to end: 0..7 for
// f32 lanes, 0..31 for bytes. Every lane index is a compile-time constant
// and every width lowers to the one wasm byte shuffle. i8x16.swizzle takes
// its byte selectors from a vector at run time; a selector past 15 reads 0.
export function spread(): f32 {
    // Two points as (x0, y0, x1, y1); a 2D transform wants the xs and the
    // ys spread across lanes, one shuffle each.
    let p = f32x4.replace_lane(f32x4.replace_lane(
        f32x4.replace_lane(f32x4.splat(1), 1, 2), 2, 3), 3, 4);
    let xs = v128.shuffle<f32>(p, p, 0, 0, 2, 2);   // (1, 1, 3, 3)
    let ys = v128.shuffle<f32>(p, p, 1, 1, 3, 3);   // (2, 2, 4, 4)
    let r = f32x4.add(f32x4.mul(xs, f32x4.splat(10)), ys);
    return f32x4.extract_lane(r, 2) + f32x4.extract_lane(r, 3);   // 34 + 34
}

export function reverse(): i32 {
    // Byte i holds i; lanes 16..31 would read from the second operand.
    let v = i32x4.replace_lane(i32x4.replace_lane(i32x4.replace_lane(
        i32x4.splat(0x03020100), 1, 0x07060504), 2, 0x0b0a0908), 3, 0x0f0e0d0c);
    let r = i8x16.shuffle(v, v, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0);
    return i32x4.extract_lane(r, 0);   // bytes 15..12, 0x0c0d0e0f
}

A lane index that is not a constant, or is outside the doubled lane count, is a compile error at that argument; v128.shuffle without its lane type is one too.

Relaxed SIMD

wasmSimd: relaxed adds f32x4.relaxed_madd, which computes a * b + c, and f32x4.relaxed_nmadd, which computes -(a * b) + c, and sets ASC_FEATURE_RELAXED_SIMD to 1. A fused multiply-add can round once where a multiply and an add round twice, so its results can differ slightly from mul and add, and from one platform to another. Keep to mul and add where the output must match everywhere, such as an audio processor whose rendered file is a test reference.