WASM wide-arithmetic: measured win + delivery plan¶
Status: measurement + design (2026-06-27) Goal: close the in-browser WASM-vs-native gap on exact-CSG/brep-heavy models without an install or an upload (the hard client-side constraint), by building the geometry kernel with the WASM wide-arithmetic proposal.
Why this exists¶
The exact pure-Rust CSG kernel (rust/geometry/src/kernel/) runs a predicate
cascade over bnum fixed-width integers (I256/I512/I1024/I2048). On native those
are stack-allocated checked arithmetic; on wasm32 there is no wide-integer
hardware, so bnum's 64x64->128 limb products compile to __multi3 libcalls.
That is the structural reason heavy CSG/brep models are slower in the browser
than natively. The WASM wide-arithmetic proposal adds i64.mul_wide_s/u,
i64.add128, i64.sub128, which map those limb ops to single instructions.
Measured (Apple M4, rustc 1.93-nightly / LLVM 21.1.5, wasmtime 46 / Cranelift)¶
Built with -C target-feature=+wide-arithmetic; verified the ops are emitted
(wasm-tools print | grep) and that output is byte-identical to baseline.
Predicate microbench (a 3x3 determinant = orient3d core, the exact hot path):
| tier | baseline wasm | +wide-arith | speedup | vs native |
|---|---|---|---|---|
| I256 (common) | 438 ns | 232 ns | 1.9x | within 1.23x of native |
| I512 (cache) | 1093 ns | 358 ns | 3.1x | at native parity |
End-to-end CSG (a real slab-minus-9-boxes void cut through
mesh_bridge::subtract_many: arrangement + predicates + retriangulation):
| build | ms/cut | vs native |
|---|---|---|
| native | 10.25 | 1.0x |
| baseline wasm | 23.16 | 2.26x slower |
| +wide-arith wasm | 13.58 | 1.33x slower |
End-to-end speedup 1.71x — lower than the predicate-only number because a
real cut also spends time in arrangement bookkeeping / retriangulation /
allocation that wide-arith does not touch. Mechanism confirmed: 1447 wide ops in
the wide build, 0 in baseline (which instead emits __multi3 libcalls).
Bottom line: wide-arithmetic takes in-browser exact CSG from ~2.3x slower-than-native to ~1.3x, with zero algorithm change and no install/upload. Native already beats web-ifc on the Tekla model (2.9s vs 4.9s), so closing the wasm gap this far makes the in-browser path competitive on exactly the models ThatOpen wins today.
Toolchain status: reachable now¶
wide-arithmeticis a recognized wasm target feature in our pinned toolchain.- LLVM 21 lowers our actual
bnumI256/I512checked_multo the wide ops with only-C target-feature=+wide-arithmetic. Nobnumchanges required. - At the kernel level there is no blocker: the win is real today on a plain
cdylib(the benches above).
Delivery status: blocker 1 CLEARED, blocker 2 still blocks (updated 2026-07-31)¶
Shipping it to browser users was blocked on TWO upstream items. Blocker 1 is now cleared; blocker 2 still gates shipping.
wasm-bindgen cannot process a wide-arithmetic module.CLEARED 2026-07-31. The production wasm goes throughwasm-bindgenfor theIfcAPIglue. At=0.2.106(the pin when this was written) buildingpkg-widefailed in the bindgen step —failed to parse code section: wide arithmetic support is not enabled, itswalrusparser rejecting the new opcodes. The pin is now=0.2.126andpkg-widebuilds; see the bump note below. (The benches always sidestepped this: plaincdylib, no bindgen — which is why they built and ran throughout.)- The V8 builds we tested do not support it. V8 12.4 (Node 22) and V8 14.6
(Node 26.5.1) both reject the module (
WebAssembly.validate()-> false, "invalid numeric opcode 0xfc13"). No browser release was measured directly: Chrome and Edge ship their own V8 builds, and Firefox (SpiderMonkey) and Safari (JavaScriptCore) are different engines entirely. Treat every browser as unverified rather than inferring from these numbers, and see the per-engine bar in the checklist below.
Status re-check (2026-07-16): blocker 1 is CLEARED upstream — walrus
merged wide-arith parsing (wasm-bindgen/walrus#306, released in walrus 0.26.0,
2026-03-25) and current wasm-bindgen 0.2.126 depends on walrus 0.26.1, so
bumping our pinned =0.2.106 would let pkg-wide build. Blocker 2 still
stands: V8 rejects the module (Firefox and Safari unverified — see the
2026-07-31 measurement below). (This entry also recorded that V8
had the implementation behind a default-off
--experimental-wasm-wide-arithmetic flag. That was wrong — see the 2026-07-31
measurement below; no such flag exists.) Verdict at the time:
track-and-adopt — do NOT pay a wasm-bindgen major-pin bump for a bundle no
browser can run; re-check when V8 stages/ships the flag on by default.
Bump taken (2026-07-31): the pin moved to =0.2.126 (with js-sys/web-sys
=0.3.103, wasm-bindgen-futures =0.4.76, wasm-bindgen-test =0.3.76, which
move in lockstep because js-sys/web-sys pin wasm-bindgen exactly). The earlier
verdict assumed the bump carried a real cost; measured, it did not:
- exported
.d.tsAPI is unchanged — 557 normalised signature units, identical before and after; the 3.2k-line file diff is indentation, member ordering and doc comments, - the pinned
mesh_determinism.wasm32.jsonstill matches, so emitted mesh bytes did not move, - default,
BUILD_THREADED=1andBUILD_WIDE=1bundles all build, the latter two passing their own litmus checks, serde-wasm-bindgen 0.6.5andwasm-bindgen-rayon 1.3.0needed no change.
Blocker 2 is untouched and still gates SHIPPING.
Blocker 2, measured (2026-07-31). Clearing blocker 1 let the workflow reach
gate 2 for the first time, which exposed the gate as broken. It passed
NODE_ARGS=--experimental-wasm-wide-arithmetic unconditionally; that flag has
never existed in any Node, so node exited with bad option: ... during argv
parsing, before loading any wasm. Gate 2 had therefore never measured engine
support at all. The signal was also inverted: a shipped V8 feature drops its
experimental flag, so on the day wide-arithmetic lands the hard-coded flag would
still be bad option and the lane would stay red exactly when it should turn
actionable.
Measured directly instead, with a 102-byte module using every wide op the
bundle emits (i64.add128, i64.sub128, i64.mul_wide_s, i64.mul_wide_u),
on two V8 versions: locally on Node 22 (V8 12.4) and in CI on Node 26.5.1
(V8 14.6.202.34) — the lane deliberately runs the newest Node so the verdict
reflects current V8 rather than a frozen LTS line. Chrome and Edge ship V8 in
that range but were not themselves measured; SpiderMonkey and
JavaScriptCore are unrelated engines and say nothing about this result:
WebAssembly.validate->false; compiling it throwsinvalid numeric opcode: 0xfc13,node --v8-optionslists no wide-arithmetic flag of any name.
So blocker 2 is real and unambiguous on V8, just not for the reason previously recorded: V8 does not implement the opcodes here, staged or otherwise. Firefox and Safari were not measured; the flip-the-flag checklist below still requires confirming each shipping engine on its own rather than inferring parity. The workflow now probes the engine at runtime rather than assuming a flag, and treats blocked-on-engine as the expected, GREEN state — a permanently red lane is one nobody reads, which is how the bogus flag survived in the first place. Red now means the state moved:
- build red = a TOOLCHAIN regression, not the old known-blocked state: the pin moved below 0.2.115, a wasm-bindgen release lost wide-arith parsing, or a nightly bump changed the required link flags (see the caveat below).
- tripwire red = the engine now ACCEPTS wide-arithmetic. Good news, and the signal the workflow exists to deliver.
- determinism red = wide-arithmetic changed the kernel's exact-predicate output. Must not ship. Only reachable once the engine runs the module.
Only the parser blocker is cleared. Do not read any of this as wide-arithmetic being shippable.
Nightly caveat for the next toolchain bump: since wasm-bindgen 0.2.122,
threaded builds on nightlies dated 2026-05-06 or later additionally require
-C link-arg=--export=__heap_base. The pinned nightly-2025-11-15 predates
that, so BUILD_THREADED=1 works today without it. Whoever bumps the nightly
must add that flag in scripts/build-wasm.sh and rust/csg-thread-bench/.
Net: the lever is proven and worth tracking, but not shippable now. The plan
below is the design to wire once BOTH clear. Once wired, the runtime
feature-detect will make this a safe, zero-cost no-op for every user: the wide
.wasm would never be fetched while the probe returns false, which it does on
V8 today. The probe is per-engine by design, so any engine that does implement
the opcodes would upgrade itself without further work here. None of this is
wired yet — see the delivery plan below.
When might it ship, and can we work around it?¶
Timeline (engine survey researched 2026-06; V8 re-measured 2026-07-31): wide-arithmetic is Phase 3 (implementation phase, not yet a finished standard = Phase 4). A 2026 runtime survey found only Wasmtime and Wasmer run a full wide-arith build in stable releases; no stable browser ships it. Measured 2026-07-31: V8 rejects the opcodes outright and exposes no flag to enable them, so the earlier "prototyped behind a flag" reading of V8 was wrong. Firefox and Safari were not measured. Realistically: per-engine shipping through 2026-2027, "Baseline" (all three engines, safe to rely on) later still. So this is a track-and-adopt lever, not a near-term one.
Working around the browser blocker: there is no useful polyfill — emulating
the instructions in wasm IS the slow __multi3 path we are trying to escape. The
only safe handling is the feature-detect + fallback above (zero regression,
auto-upgrade per engine as each ships). For users today it yields no in-browser
speedup.
The bigger lever that does work in today's browsers is threads (in-instance
rayon), and it is already measured — see docs/architecture/csg-threading-design.md
+ the rust/csg-thread-bench rung-2 result: threaded WASM scales the CSG step
2.9-4.2x at 8 threads (atomics tax ~0%, output byte-identical) and 1.6-1.9x
end-to-end on the current exact-CSG kernel. The pkg-threaded bundle is built
(#1255) but not yet wired into runtime selection. It works in today's
Chrome/Firefox (cross-origin isolated; the viewer already sets COOP/COEP; Safari
lacks credentialless COI and falls back to the plain bundle). wide-arith is
additive on top later (a threaded+wide bundle combines both target-features).
Working around the wasm-bindgen blocker: split the CSG kernel into a separate
plain-cdylib wasm (no wasm-bindgen) built with +wide-arithmetic, exposing the
boolean over linear memory (the csgbench crate is a proof this builds and runs).
The main bindgen bundle stays non-wide; JS loads the kernel module only when the
probe passes. This sidesteps wasm-bindgen entirely — but only matters once a
browser supports the proposal, so it is not worth building ahead of that. The
alternative is to wait for a wasm-bindgen / walrus release that enables
wide-arith parsing.
Delivery plan (wire once unblocked)¶
This reuses the exact pattern already in place for the threaded second bundle
(packages/wasm/pkg-threaded, built off-by-default in scripts/build-wasm.sh).
-
Build a third bundle
packages/wasm/pkg-wide. Samewasm-packinvocation as the defaultpkg, with+wide-arithmeticadded to the default flags (.cargo/config.tomlalready sets+simd128). Off by default, behindBUILD_WIDE=1inscripts/build-wasm.sh(added in this change). CI/Vercel build it alongsidepkgonce we flip it on. -
Feature-detect at runtime with a tiny
WebAssembly.validate()probe of a 102-byte module exercising every wide op the bundle emits —i64.add128,i64.sub128,i64.mul_wide_s,i64.mul_wide_u. It must be all four, not justadd128: an engine shipping a partial implementation would otherwise pass the probe and then fail on a bundle it cannot run. This is the same module.github/workflows/wide-arithmetic.ymlprobes with, so CI and runtime agree by construction. Drop this intopackages/geometry/src/wasm-features.tswhen wiring selection:
// (module
// (func (param i64 i64 i64 i64) (result i64 i64) local.get 0..3 i64.add128)
// (func (param i64 i64 i64 i64) (result i64 i64) local.get 0..3 i64.sub128)
// (func (param i64 i64) (result i64 i64) local.get 0..1 i64.mul_wide_s)
// (func (param i64 i64) (result i64 i64) local.get 0..1 i64.mul_wide_u))
// generated via `wasm-tools parse`
const PROBE = new Uint8Array([
0,97,115,109,1,0,0,0,1,17,2,96,4,126,126,126,126,2,126,126,96,2,126,
126,2,126,126,3,5,4,0,0,1,1,7,19,4,1,97,0,0,1,115,0,1,2,109,115,0,2,
2,109,117,0,3,10,45,4,12,0,32,0,32,1,32,2,32,3,252,19,11,12,0,32,0,
32,1,32,2,32,3,252,20,11,8,0,32,0,32,1,252,21,11,8,0,32,0,32,1,252,
22,11,
]);
let cached: boolean | undefined;
export function supportsWideArithmetic(): boolean {
if (cached === undefined) {
try { cached = typeof WebAssembly !== 'undefined' && WebAssembly.validate(PROBE); }
catch { cached = false; }
}
return cached;
}
-
Select the bundle URL.
geometry.worker.tsalready accepts an initwasmUrl(it otherwise falls back tonew URL('ifc-lite_bg.wasm', import.meta.url)). When the probe is true, pass thepkg-widewasm URL through the existingwasmUrlsplumbing ingeometry-parallel.ts; otherwise passpkg. No engine without the feature ever loads the wide module, so it is safe to ship eagerly. -
Bundling. Vite copies both
pkgandpkg-widewasm as assets (same as the threaded bundle). The JS glue is identical between bundles; only the.wasmdiffers, so the JS is shared and only the chosen.wasmis fetched.
Net: one extra .wasm artifact + a ~102-byte feature probe + a one-line URL
choice. Users on engines with wide-arithmetic transparently get ~1.7x faster
in-browser CSG; everyone else is unaffected.
Reproduce¶
Two standalone bench crates (predicate microbench + end-to-end CSG) live in the
profiling repo under wide-arith/. Each builds twice and runs under wasmtime:
cargo build --release --lib --target wasm32-unknown-unknown
RUSTFLAGS="-C target-feature=+wide-arithmetic" \
cargo build --release --lib --target wasm32-unknown-unknown --target-dir target-wide
wasm-tools print target-wide/wasm32-unknown-unknown/release/*.wasm | grep -c 'mul_wide\|add128'
wasmtime run -W wide-arithmetic=y --invoke <fn> target-wide/.../*.wasm <args>
CI tripwire (.github/workflows/wide-arithmetic.yml)¶
Both blockers above are external (an upstream crate version, engine support for
the opcodes) — the kind of thing that silently goes stale if the only record of
it is this doc. wide-arithmetic.yml turns them into a monitored,
weekly-scheduled + workflow_dispatch CI lane (never on pull_request — this
must not become a required or noisy check), so a status change shows up as a
colour change rather than as something we have to remember to re-check.
Note the direction, which is not the obvious one: the known-blocked state is green, so the lane is quiet until reality moves. A change turns it red — including the good news that an engine now runs the module. A permanently red lane is one nobody reads, which is exactly how a bogus flag name survived in this workflow undetected until 2026-07-31.
What it does, in three named steps so a failure alone (no log-diving) says which gate tripped:
- Build pkg-wide bundle (wasm-bindgen + wide-arithmetic) — runs the
repo's own
BUILD_WIDE=1 scripts/build-wasm.shpath unmodified (same-C target-feature=+simd128,+wide-arithmeticRUSTFLAGS documented above, plus that script's ownwasm-tools-verified litmus check that the bundle actually contains wide ops). This is blocker 1 (wasm-bindgen/walrus): it failed until 2026-07-31 becauserust/wasm-bindings/Cargo.tomlpinnedwasm-bindgen = "=0.2.106", whose bundledwalruspredates the parser fix; the pin is now=0.2.126and this gate passes.wasm-bindgen-clifirst depends onwalrus ^0.26.0in 0.2.115 (0.2.114 and earlier are still onwalrus ^0.25.1; verified against the crates.io dependency data per release), so 0.2.115 is the floor for a bump that can clear this gate. Turning this step green requires an actual version-bump PR — out of scope for the CI lane itself, by design: a green run here is proof the bump landed, not a workaround for it not having landed. - wasm32 mesh-determinism vs pinned manifest (wide-arithmetic bundle) —
reuses the exact check
determinism.yml's wasm32 job already runs (wasm-pack test --node rust/wasm-bindings --test mesh_determinismagainst the pinnedrust/processing/tests/manifests/mesh_determinism.wasm32.json), compiled with the same wide-arithmetic RUSTFLAGS. It runs only when the probe step reports the engine accepts every wide op the bundle emits —WebAssembly.validate()validates, it does not execute, so the probe is a validation check and this determinism step is the execution check. Without that gate it would measure engine support rather than determinism. When the probe does find support behind a flag, that flag is passed viaNODE_ARGS(a direct node argv:NODE_OPTIONSonly honours an allowlisted subset of Node/V8 options and Node refuses to start when given one outside it, whereas arguments passed directly on the command line bypass that allowlist); when the feature is on by default — which is what the lane'snode-version: latestprobe would report first —NODE_ARGSis empty. Treat a green run here as an experimental compatibility signal: it says some V8 accepts and correctly executes the module, which is an early proxy for blocker 2, not evidence that any browser ships the feature. Default availability in Chrome, Firefox, Safari and Edge is gated separately by the shipping-engine validation in the flip-the-flag checklist below, which remains the authoritative bar. This determinism step is additionally gated on the pkg-wide build succeeding, so it is skipped — not failed — when gate 1 is red: it compiles the test crate with the same toolchain, so after a build failure it could only fail for the same reason. Gate 2 stays independently legible regardless — that is the probe step's job, and the probe does not depend on the build at all. - Wide-op emission count — informational only (
continue-on-error), countsmul_wide/add128/sub128in the built bundle viawasm-tools print. Confirms the mechanism is present, not a timing measurement — there is no in-repo wasm timing harness for this (the predicate/e2e benches above live in a separate profiling repo), so this is deliberately not a perf regression gate.
Reading a failure. Three categories, and only the first is a determinism regression:
- Manifest diff — step 2 fails with a report naming
positions_hash/indices_origin_hash/normals_hashrather than a parse/instantiate error. Both gates cleared far enough to execute the kernel under wide-arithmetic and the emitted bytes diverged from the pinned wasm32 manifest. This is a real determinism regression and blocks flippingBUILD_WIDEon. - Build regression — a walrus/parse error. This used to be the status quo
(blocker 1) but is not any more: since the
=0.2.126pin the toolchain can parse a wide-arithmetic code section, so a parse failure now means the pin regressed or a wasm-bindgen release lost that support. It keepsBUILD_WIDEblocked and needs fixing, it is not something to wait out. Blocker 2, by contrast, no longer shows up as a failure at all: the probe step detects it and records it in the step summary while the job stays green, so the lane is quiet until something actually changes. A validate/instantiate/invalid-opcode error reaching the determinism step would mean the probe and the real module disagree — suspect the probe, not the kernel. - Unclassified — anything else: compilation errors unrelated to walrus,
the test binary failing to run, Node refusing to start, script or
toolchain breakage, dependency resolution failures, runner/infrastructure
errors. These are not determinism regressions, but they also are not
passes: an unclassified failure means the lane did not actually exercise
the kernel, so it leaves
BUILD_WIDEblocked exactly as a category-1 failure does. Diagnose the lane before reading anything into its colour.
Flip-the-flag shipping checklist. Note that the lane being green is not the trigger — green is the blocked state. The trigger is the lane turning red with the engine-support tripwire firing:
- Blocker 1 cleared (2026-07-31):
rust/wasm-bindings/Cargo.toml'swasm-bindgenpin bumped to=0.2.126, past the walrus-0.26 release, in its own reviewed PR, withwasm-bindgen-futures/wasm-bindgen-test/js-sys/web-sysmoved in lockstep as required. - Blocker 2 cleared:
WebAssembly.validate()of the probe inpackages/geometry/src/wasm-features.ts(see the Delivery plan above) returnstrueon a genuinely shipping release — no experimental flags enabled — of both Chrome/Edge and Firefox, the two engines the viewer must support without a fallback path. Confirm Safari explicitly rather than assuming parity. The probe must exercise every wide op the bundle emits (i64.add128,i64.sub128,i64.mul_wide_s,i64.mul_wide_u), not just one, so a partial implementation cannot read as support — the CI probe inwide-arithmetic.ymluses exactly such a module. - This workflow's determinism check actually ran and passed with no manifest diff — not just "the job was green." The job is green while blocked on the engine, so its colour alone is not the bar; confirm the probe reported support and the determinism step executed.
- Wire the runtime probe +
pkg-widebundle selection (Delivery plan steps 2-4 above) and re-run the viewer's own benchmark/E2E suite before flippingBUILD_WIDE=1on by default inscripts/build-wasm.sh/apps/viewer's build. - Re-pin the wasm32 determinism manifest is not expected to be needed (that is the whole point of step 2 passing) — if it turns out to be needed, treat that as the regression the checklist above exists to catch, not a routine re-pin.