Shipping SIMD and Baseline Builds Together
This guide covers the deployment side of SIMD: producing two artifacts from one source in one build, picking between them at load time, caching them correctly, and keeping the rarely-taken branch honest.
A module containing vector instructions is rejected outright by an engine without the proposal, so there is no graceful degradation inside a single binary. The two-artifact pattern is the standard answer, and it is cheap — the second build is one command with one flag removed — provided the selection and the testing are set up deliberately rather than as an afterthought.
Prerequisites
- [ ] A kernel that benefits from SIMD, verified by measurement rather than assumption
- [ ] A build script you can run twice with different flags
- [ ] A place to put two hashed artifacts and serve them with
application/wasm - [ ] A test suite that can run against either artifact
Step-by-step procedure
1. Produce both artifacts in one build script
#!/usr/bin/env bash
set -euo pipefail
OUT=dist/wasm
mkdir -p "$OUT"
cargo build --release --target wasm32-unknown-unknown
wasm-opt target/wasm32-unknown-unknown/release/kernel.wasm -Oz -o "$OUT/kernel.wasm"
RUSTFLAGS="-C target-feature=+simd128" \
cargo build --release --target wasm32-unknown-unknown
wasm-opt target/wasm32-unknown-unknown/release/kernel.wasm -Oz --enable-simd -o "$OUT/kernel.simd.wasm"
wasm-validate "$OUT/kernel.wasm" && wasm-validate "$OUT/kernel.simd.wasm"
Note the two wasm-opt invocations differ: the SIMD one needs --enable-simd or it will refuse its
input. A shared script keeps the two flag sets from drifting apart, which is the main risk in a
two-artifact setup.
2. Select at load time
import { features } from "./features.js"; // the probe from the detection guide
const url = features.simd
? new URL("./wasm/kernel.simd.wasm", import.meta.url)
: new URL("./wasm/kernel.wasm", import.meta.url);
export const kernel = await WebAssembly.instantiateStreaming(fetch(url), imports);
Importing the URL rather than hard-coding a path keeps the bundler’s content hashes intact, which matters more here than usual because two files now have to stay in sync with one loader.
3. Serve both with long-lived caching
Both files are content-hashed, so they can be cached indefinitely:
/wasm/kernel-9f1c2a.wasm Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
/wasm/kernel.simd-4b7e0d.wasm Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
A visitor downloads exactly one of them and keeps it across deploys until the kernel itself changes.
4. Test both in CI
node test/run.mjs dist/wasm/kernel.wasm # scalar
node test/run.mjs dist/wasm/kernel.simd.wasm # vector — same assertions
One test file, two arguments. The assertions must be identical, because the whole premise is that the two artifacts compute the same thing.
5. Add an override for manual testing
const forced = new URLSearchParams(location.search).get("wasm");
const useSimd = forced ? forced === "simd" : features.simd;
Without this, nobody on the team ever exercises the scalar path in a browser, and it rots.
Expected output
A deploy contains two binaries whose sizes differ modestly, and the network panel shows exactly one being fetched:
$ ls -l dist/wasm
-rw-r--r-- 1 dev dev 71204 kernel-9f1c2a.wasm
-rw-r--r-- 1 dev dev 74918 kernel.simd-4b7e0d.wasm
# Chrome, network panel
kernel.simd-4b7e0d.wasm 74.9 kB application/wasm from disk cache
# same page with ?wasm=scalar
kernel-9f1c2a.wasm 71.2 kB application/wasm 200
The size difference — about 5% here — is the vector bodies plus their scalar tails. That is the normal range for a module with a few vectorised kernels; a module where most of the code vectorises will show more.
Gotchas
The loader fetches the wrong build. The probe ran after the URL was decided, or a bundler inlined a path. Log which URL was chosen during development; a one-line console message saves a lot of confusion.
The SIMD artifact fails to validate in production and not locally. Your development browser
supports a proposal the target audience’s does not — or wasm-opt introduced an instruction the probe
does not test. Probe for every feature the build uses.
Both artifacts are identical in size. RUSTFLAGS is set globally, so both builds vectorised. Set
it inline in the one command rather than in .cargo/config.toml.
The scalar path breaks and nobody notices for months. No test ran against it, and no developer ever loaded it. The CI matrix and the query-parameter override above exist precisely to prevent this.
Cache headers differ between the two files. Then one of them re-downloads on every deploy. Whatever policy you set, set it for the directory, not per file.
Performance note
The two-artifact approach costs one extra build — seconds in CI — and one extra file in storage. It costs a visitor nothing measurable, because the probe is a validation of thirty bytes and only one binary is ever fetched. Compared with the alternatives it is clearly the best trade: shipping only the SIMD build excludes users outright, and shipping only the scalar build leaves a 2–3× improvement on the table for everyone else.
There is one place where the cost is real: cold cache on a first visit still downloads a slightly larger binary for SIMD-capable users, typically a few per cent. If the kernel is small relative to the rest of the page, that is noise. If it is the dominant asset and the speedup is marginal, measure whether the vector build earns its extra bytes before shipping both.
Keeping the two artifacts honest
The risk in a two-artifact deployment is not that the mechanism breaks — it is that the two builds diverge without anyone noticing. Three cheap habits prevent that.
Build both in the same script, always. A build that can produce one without the other invites a deploy where the scalar artifact is stale. If the script emits both or fails, that cannot happen:
set -euo pipefail
build_variant() { # $1 = suffix, $2 = extra RUSTFLAGS
RUSTFLAGS="${2:-}" cargo build --release --target wasm32-unknown-unknown
wasm-opt "target/wasm32-unknown-unknown/release/kernel.wasm" \
-Oz ${3:-} -o "dist/wasm/kernel${1}.wasm"
}
build_variant "" "" ""
build_variant ".simd" "-C target-feature=+simd128" "--enable-simd"
Assert they agree. One test file, run twice, comparing output against fixed expected values. If a change makes the vector path diverge, the build fails before anyone sees it in a browser.
Report which one loaded. A single telemetry field recording the artifact in use tells you the real split across your audience, which is what eventually justifies retiring the baseline build. Without it, that decision is made on browser-support tables rather than on your own users.
When one build is the better answer
Two artifacts is the default recommendation, not a rule. Shipping only the baseline build is right when the kernel is a small part of the workload and the vector version measured under 1.5× — the complexity buys nothing measurable. Shipping only the SIMD build is defensible when you control the environment entirely: an internal tool, an Electron application, a platform with a known engine. In that case the feature probe becomes a diagnostic rather than a branch, reporting a clear message instead of an unexplained failure.
The case that is almost never right is shipping only the SIMD build to a general web audience without a probe. The failure mode is a module that does not load at all, with an error most users cannot act on, for a saving of one build step. Whatever else you decide, keep the detection.
Frequently Asked Questions
Can I put both builds in one file and pick a function at run time? No. Validation is whole-module, so a file containing any vector instruction is rejected entirely on an engine without support. The split has to be at the file level.
What about three builds — scalar, SIMD, and SIMD plus threads? Possible, and it is where the combinatorics start to hurt: each capability doubles the matrix and the least-used branch gets the least testing. Two is usually the right ceiling; see the fallback discussion in polyfills, alternatives & fallbacks.
Does this interact with module caching? Yes, favourably. Each artifact has its own cache key, so a returning visitor reuses whichever they downloaded before — see caching compiled Wasm modules in IndexedDB for keeping the compiled form as well.
How do I know when the baseline build can be retired? From the telemetry field recording which artifact loaded. When the baseline share falls low enough that the maintenance outweighs the affected users — a judgement only you can make — dropping it is a small change, because the loader already handles the single-artifact case by construction.
Can a CDN serve the right variant automatically? Not reliably — the decision depends on engine capability rather than on anything in the request headers. Deciding in the client, after a probe, is the only approach that is correct for every visitor.
Related
- Wasm SIMD & vectorized computation — the feature these builds differ by.
- Detecting SIMD support at runtime — the probe that drives the selection.
- Bundling Wasm ESM with Vite — keeping hashed URLs correct through a bundler.
- Cross-platform build automation — running the two builds in one pipeline.
← Back to Wasm SIMD & Vectorized Computation