Skip to main content
rustwasmsimd
15 min read

What Rust's +simd128 Actually Changed in My WebAssembly

Rust's +simd128 produced 205 static SIMD instructions in my aggregate module, with none in the Welford loop I measured.

By Pallav

The Rust build script already had -C target-feature=+simd128. A comment above the statistics function described a SIMD pre-scan. That looked like enough evidence to call the WebAssembly build accelerated. It was not enough evidence to say what the compiler had emitted.

I tested that assumption in vizcrush, my Rust-to-WebAssembly data-processing library: two real loops, one compiler flag, and a pinned source revision. This article follows the emitted code and the generated JavaScript call boundary; the launch story covers the broader backend decisions.

Results at a glance

The SIMD counts are static instruction occurrences in emitted code. They do not count executed work or estimate a speedup.

ScopeEvidenceOutcome
LTTBSame hash; 0 SIMDNo code change
Aggregate205 static SIMD ops in 19 functionsChanges elsewhere
Stats0 SIMD in WelfordNo consistent timing gain

The timings are from three independent Node processes at 1M and 10M elements. They include copies and output consumption; the method and per-process variability are below. Build inspection and timing samples were collected for this article, separately from the earlier ADR measurements.

The two Rust loops

LTTB serves here as a negative control for flag-induced code changes: its disabled and enabled builds are byte-identical. Largest-Triangle-Three-Buckets (LTTB) reduces a time series by selecting one point per bucket. Each selection uses the previous selected point and the average of the next bucket. Here is the candidate loop, unchanged from lttb.rs. The variables are initialized by the enclosing bucket loop:

crates/vizcrush-downsample/src/lttb.rs (excerpt)
rust
let mut max_area = -1.0;
let mut max_idx = bucket_start;

for j in bucket_start..bucket_end {
    // Triangle area = 0.5 * |x_a(y_b - y_c) + x_b(y_c - y_a) + x_c(y_a - y_b)|
    let area = ((prev_selected_x - avg_x) * (y[j] - prev_selected_y)
        - (prev_selected_x - x[j]) * (avg_y - prev_selected_y))
        .abs();

    if area > max_area {
        max_area = area;
        max_idx = j;
    }
}

There are several dependencies to preserve. max_area and max_idx travel together. The strict > comparison keeps the first candidate when areas tie. Outside this excerpt, avg_x and avg_y are sequential floating-point sums, and the next bucket depends on this bucket’s selected point. Reordering the sums can change an area enough to change a selection.

The second operation is summary statistics. Its exported signature is pub fn compute_stats(data: &[f64]) -> Vec<f64>. It returns count, minimum, maximum, mean, sample standard deviation, and sample variance. The main loop combines finite-value filtering with Welford’s online update:

crates/vizcrush-aggregate/src/stats.rs (excerpt, one comment line omitted)
rust
for &v in data {
    if !v.is_finite() {
        continue;
    }
    count += 1;
    if v < min {
        min = v;
    }
    if v > max {
        max = v;
    }
    // Welford's online update
    let delta = v - mean;
    mean += delta / count as f64;
    let delta2 = v - mean;
    m2 += delta * delta2;
}

In the surrounding Rust code, mean and m2 start at zero. Each iteration reads the previous mean, updates it, then uses that updated mean to update m2. A SIMD implementation that computes partial statistics in lanes and merges them would need a different reduction structure, with numerical behavior to validate. Enabling an instruction set does not perform that redesign for me.

Arrows carry (n, mean, M2). Each lane consumes its inputs in order, then the states merge. This two-lane redesign changes the reduction order; it was not generated in this build.

For two nonempty groups of finite values, let delta = meanB - meanA and n = nA + nB. Their merged mean is meanA + delta × nB / n; their merged M2 is M2A + M2B + delta² × nA × nB / n. These pairwise moment formulas allow independent partial statistics: Pébay’s Sandia report. Empty lanes need separate handling, and each lane must preserve the finite-value filtering.

Parallel Welford is therefore mathematically possible. In floating-point arithmetic, the changed order can produce different rounding from the original recurrence. A lane implementation needs explicit state and merge logic plus numerical tests; enabling relaxed reassociation does not guarantee that LLVM will discover this redesign. The diagram illustrates those dependencies rather than diagnosing a specific missed-vectorization pass.

Build twice, with the compiler held fixed

Rust documents -C target-feature=+simd128 as enabling SIMD support for a compilation. It also exposes explicit intrinsics through core::arch::wasm32. The global flag does not rebuild the precompiled standard library. None of those facts guarantees that a particular loop becomes vector code: Rust’s wasm32 SIMD documentation.

For this experiment I pinned source commit 85b22ea, Rust 1.94.1 (LLVM 21.1.8), wasm-bindgen CLI 0.2.115, and Binaryen 129 for disassembly. The workspace release profile sets opt-level = 3, lto = true, codegen-units = 1, and strip = true. The lockfile is retained and cargo build uses --locked.

bash
# From the pinned vizcrush checkout; use separate target directories.
RUSTFLAGS="-C target-feature=-simd128" \
  CARGO_TARGET_DIR="$PWD/target-scalar" \
  cargo +1.94.1 build --locked --release \
  --target wasm32-unknown-unknown \
  -p vizcrush-downsample -p vizcrush-aggregate

RUSTFLAGS="-C target-feature=+simd128" \
  CARGO_TARGET_DIR="$PWD/target-simd" \
  cargo +1.94.1 build --locked --release \
  --target wasm32-unknown-unknown \
  -p vizcrush-downsample -p vizcrush-aggregate

The disabled arm uses -simd128, rather than merely omitting +simd128. I then run the same bindgen version with --target nodejs on both builds. All sizes and hashes below refer to the resulting *_bg.wasm files. There is deliberately no wasm-opt step in this experiment: changing the Rust flag is the variable under test. These are experimental artifacts, not hashes of the shipped web-target packages.

bash
wasm-bindgen --target nodejs --out-dir evidence/simd \
  target-simd/wasm32-unknown-unknown/release/vizcrush_aggregate.wasm
wasm-dis evidence/simd/vizcrush_aggregate_bg.wasm \
  -o evidence/simd/aggregate.wat
shasum -a 256 evidence/simd/vizcrush_aggregate_bg.wasm

What changed in the binaries

Crate / flagBytes after bindgenStatic SIMD instructions
downsample / disabled24,0310
downsample / enabled24,0310
aggregate / disabled88,7470
aggregate / enabled87,811205

Both downsample builds have the same SHA-256, shown below. That establishes byte identity. Disassembly independently establishes zero SIMD instructions. These are separate checks: equal hashes alone would also be possible for two binaries that both contained SIMD. The inspection artifact records all four hashes and complete opcode counts.

text
3759bbbbcf12fae0a21aa7c7d12c292372aed42f247c7c93d652a08b68dba412

Of the aggregate module’s 205 static SIMD instructions, 68 are v128.load and 80 are v128.store, with lane loads, integer operations, shuffles, and constants making up the rest. There are no instructions from the f32x4 or f64x2 families.

The aggregate crate contains sketches, percentiles, streaming operations, and other exports as well as compute_stats. In this disassembly, (export "compute_stats" (func $107)) identifies the binding wrapper, which calls $43 for the statistics implementation. That function contains the scalar f64 recurrence and zero SIMD instructions. The generated names $107 and $43 are specific to these artifacts, not stable API names. You can inspect the full aggregate WAT, gzip-compressed and downsample WAT, gzip-compressed.

Here is the delta = v - mean part of the actual $43 body. $9 holds the input value, $7 the running mean, and $10 receives the scalar difference:

aggregate.wat: function $43 (excerpt)
wasm
(local.tee $10
 (f64.sub
  (local.get $9)
  (local.get $7)
 )
)

The complete $43 body contains two f64.add, two f64.sub, two f64.div, and one f64.mul instruction, including the variance calculation outside the loop. Its SIMD count is zero, as is the wrapper’s. The excerpt makes the scalar update visible; the full-function inventory establishes the absence of SIMD.

Where the 205 instructions went

To attach names to those instructions, I rebuilt the final aggregate crate with stripping disabled while keeping its dependencies at the original release profile. I retained its name section without the extra target-feature metadata that would change bindgen’s ABI transforms. The companion’s executable code section is byte-identical to the original: 81,894 bytes. This permits mapping the original function bodies to their Rust names; the companion was not used for timings.

Function groupStatic SIMD instructionsWork represented
Rust sorting helpers104Record loads and stores
TDigest::flush22Centroid movement
KLL query/count functions34Integer reductions and result setup
HyperLogLog::estimate13Counting zero registers
Constructors and initialization32Object copies, state resets, NaN fills
Total205Across 19 function bodies

The sorting and t-digest instructions move records using v128.load and v128.store; that does not imply vectorized floating-point comparisons or centroid arithmetic. KLL does contain integer SIMD work: its total-weight sums use i64x2.add, and its retained-item count uses i32x4.add. In HyperLogLog, the vectorized work counts zero registers for the small-range correction; its floating-point reciprocal-power sum remains distinct.

The full function inventory records every original WAT identifier, Rust symbol, and opcode count for all 211 module-level functions, plus the export table that maps compute_stats to $107. The attribution build script and comparison helper reproduce the mapping and reject differing executable sections.

Time the boundary you actually call

The Rust signature can hide an important cost from a JavaScript reader:

rust
#[wasm_bindgen]
pub fn lttb(x: &[f64], y: &[f64], threshold: usize) -> Vec<f64> {
    // Rust borrows slices that already live in Wasm linear memory.
    // The algorithm returns interleaved x/y coordinates.
    // ...
}

This is a signature sketch, not a complete function. The borrowed slices describe Rust’s view inside the call. They do not promise that JavaScript’s existing buffers are borrowed without copying. The generated glue for these builds contains:

generated wasm-bindgen glue
javascript
function passArrayF64ToWasm0(arg, malloc) {
    const ptr = malloc(arg.length * 8, 8) >>> 0;
    getFloat64ArrayMemory0().set(arg, ptr / 8);
    WASM_VECTOR_LEN = arg.length;
    return ptr;
}

The LTTB wrapper calls this helper once for x and once for y, invokes the Wasm export, then copies the returned coordinates with .slice() before freeing the output allocation. The statistics wrapper follows the same pattern with one input. The web-target bindings that ship in the npm packages come from the same tool, run by the production build script; they are build output rather than committed files, with the same allocation and copy path. The wasm-bindgen number-slice reference documents the supported interface.

The timings below call the generated Node wrappers directly. They include input allocation/copy, Rust execution, output copy/free, and a checksum that reads every output element. They exclude module loading and instantiation, input generation, and vizcrush’s higher-level public API dispatch and result shaping. Calling this a “raw export” without explaining that boundary would imply a cleaner kernel-only measurement than it is. The launch post described its SIMD comparison that way; those calls went through this same wrapper boundary, and that post now says so.

How large is the copy cost?

The earlier browser/Node campaign includes a useful scale check: two Float64Array.set() calls into preallocated scratch arrays, compared with the wrapped LTTB call. In its Node 24.14.1 arm on darwin/arm64, the medians were:

LTTB inputBulk-copy proxyWrapped callProxy / call
100K points0.0192 ms0.1668 ms11.5%
1M points0.3047 ms1.6610 ms18.3%

These figures come from 15 timed blocks after three warmup blocks, using 300 calls per block at 100K and 30 at 1M. The denominator includes Rust work, bindgen marshalling, and full-output consumption. Both sample series are retained in the campaign’s Node artifact, with the exact copy proxy in protocol.mjs.

This is a ratio of separately timed operations, not an instrumented fraction of the call. The proxy excludes allocation, output copying, and the rest of the glue; its buffers and cache behavior also differ from a real invocation. Subtracting it from the wrapped time would not isolate the kernel. It shows that copying has measurable cost in this LTTB case without establishing that FFI dominates or explains the SIMD result.

Keep this context separate from the new SIMD comparison below. Both use the same seeded input series, because the harness below imports the campaign’s generator, but the campaign measures the shipped web-target artifacts while this article’s comparison measures Node-target experimental builds. There is no corresponding statistics or 10M copy measurement in that artifact, so I do not extrapolate these percentages to them.

Fresh measurements, with retained samples

This run used Node v24.14.1 (V8 13.6.233.17-node.44) on Apple M3 Pro, darwin/arm64. The harness starts three independent Node processes. Each cell warms up both variants, then alternates their measurement order across nine timed blocks. Every result is fully consumed. A full elementwise equality check must pass before a cell is timed. Input is the campaign’s seeded random walk, generated by importing its makeSeries rather than restating the protocol here; LTTB reduces it to 1,000 points.

OperationInputDisabled ms: median [range]Enabled ms: median [range]Enabled / disabled range
lttb1M1.522 [1.474–1.540]1.531 [1.477–1.537]0.994–1.010
compute_stats1M4.131 [3.964–4.220]4.060 [3.979–4.241]0.983–1.005
lttb10M15.556 [15.062–15.605]15.455 [14.958–15.511]0.993–0.994
compute_stats10M40.457 [40.078–40.532]40.317 [39.990–40.680]0.995–1.005

Each time cell shows the median of three per-process medians, followed by their observed min–max range in brackets. These ranges describe variation between launches, not confidence intervals. The ratio column gives the min–max of the three per-process enabled/disabled ratios; below 1 means the enabled build took less time. Blocks contain 20 calls at 1M and three at 10M. The per-process summary, raw blocks and environment, and Node harness allow both levels of variability to be inspected. The summary is derived from the raw file by summarize.mjs, which also records the per-process ratios behind the last column.

Across these cells, per-process enabled/disabled ratios range from 0.983 to 1.010. The small differences do not show a consistent advantage for enabling SIMD. This is descriptive evidence from three launches, not a statistical equivalence test: I have not established a bound on every possible speedup. Only finite synthetic inputs were timed, and power state, background load, and garbage collection were not controlled.

What the compiler result does not explain

My earlier diagnosis, in ADR 0002 and the launch post, blamed branches. That was too broad. LLVM supports if-conversion and multiple forms of reduction, and its vectorizers consider both legality and profitability. Floating-point reductions also have ordering constraints: reassociating a sum may change its result. Those are relevant constraints to investigate, not a compiler remark proving why this particular loop remained scalar. See LLVM’s vectorizer documentation and its fast-math flag definitions.

A memory-bound workload can gain little from faster arithmetic if data movement dominates. A dependent reduction can instead be limited by the recurrence. Copies and allocations can dilute a kernel improvement at the JavaScript boundary. All are plausible mechanisms in the right workload. Equal wall-clock timings alone do not identify which one dominates here. I did not collect bandwidth counters, isolate allocator cost, or benchmark a persistent-buffer kernel-only interface, so this article does not claim to have established memory-bandwidth saturation. ADR 0002 and the launch post gave that saturation as the reason to defer intrinsics; this article withdraws the reason and keeps the deferral, pending the experiments below.

The build decision, and what would change it

ADR 0002 records the practical decision: remove the unsupported SIMD performance claim and the nonexistent pre-scan description, retain the flag, and defer handwritten intrinsics. The production build script also makes Binaryen required unless explicitly skipped. That later optimization stage is separate from the compiler-flag experiment here.

The enabled aggregate module requires a runtime that supports Wasm SIMD even when a caller only wants its scalar statistics loop: compatibility is a module-level constraint. Rust’s deployment guidance discusses this requirement. A deployment can select a separately built scalar bundle before instantiation, or keep a JavaScript fallback. Vizcrush ships one Wasm bundle per crate; its loader and dispatcher return to JavaScript when the module fails to load. The scalar build in this experiment is not a second shipped bundle.

Before testing another reduction, define its numerical contract. LTTB needs tie handling and point-selection checks; statistics needs non-finite values and numerically difficult inputs. Then use the following sequence to decide which optimization earns implementation effort.

How I would apply this workflow next

  1. Isolate the boundary first. Prototype persistent Wasm input buffers and a pointer-level call on these same kernels. Measure the resident-input call and the full caller path separately, while preserving allocation ownership and complete output consumption. If only the resident-input path improves, investigate amortizing transfers before changing arithmetic.
  2. Benchmark a vector-friendly aggregate separately. Try explicit lane-local Welford states with a pairwise merge, or another aggregate with independent arithmetic. Validate the agreed numerical contract and inspect the named hot loop. Compare it with the original algorithm at both boundaries; accept a redesign only when its useful speedup repeats.
  3. Consider explicit SIMD after profiling. If a proven hot loop has independent work and generated code still misses that opportunity, test intrinsics against the best scalar implementation. If the end-to-end improvement disappears, keep the simpler path. Record the compiler, engine, input, and measured boundary with any gain.

These are proposed experiments, not optimizations implemented or speedups established by this article. Repeat the build, inspect, validate, and measure sequence for each change.

Reproduce this investigation

Install Rust 1.94.1 with the wasm32-unknown-unknown target, wasm-bindgen-cli 0.2.115, Binaryen 129 (wasm-dis), Python 3, and Node 24.14.1. The build script below compiles dependencies into separate target directories and does not modify shipped package artifacts. Run these commands in Bash:

bash
git clone https://github.com/debug-diary-1/vizcrush.git
cd vizcrush
git checkout 85b22eaacb64ce5039bbe7dbe482bdbc608a45ff
rustup toolchain install 1.94.1 --profile minimal \
  --target wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.115 --locked

mkdir -p article-tools
for file in build.sh inspect.py bench.mjs summarize.mjs attribute.sh attribute.py; do
  curl -fL "https://www.debugdiary.dev/articles/rust-simd128/$file" \
    -o "article-tools/$file"
done
# Inspect the downloaded scripts, then run:
bash article-tools/build.sh "$PWD/simd-evidence"
python3 article-tools/inspect.py "$PWD/simd-evidence" > inspection.json
node article-tools/bench.mjs "$PWD/simd-evidence" > timings.json
node article-tools/summarize.mjs timings.json > process-summary.json

# Optional named companion, with executable-section equality checks:
bash article-tools/attribute.sh "$PWD/simd-evidence"

The build script and opcode inspection script specify the stages used above. Binaryen’s folded WAT lets the inspection script count instruction operators while excluding bare type declarations. The supplied compressed WAT files preserve the inspected enabled builds. Different compiler or bindgen versions can change bytes and function numbering, which is why the versions are part of the result: the build script records the toolchain it ran in toolchain.json, and the harness copies that record into timings.json instead of restating versions by hand.

The existing browser measurement campaign answers a different question: WebAssembly versus JavaScript across engines and Chromium versions. Its shared input protocol, full-output checksums, and retained samples informed this comparison, but its browser numbers are not evidence for the SIMD-on versus SIMD-off table here.

For this build, +simd128 left one crate unchanged and put vector instructions into another without vectorizing the statistics recurrence. The build flag was the beginning of the investigation. The useful evidence came from checking which code changed, where it ran, and what the caller paid.