Skip to content

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-arithmetic is a recognized wasm target feature in our pinned toolchain.
  • LLVM 21 lowers our actual bnum I256/I512 checked_mul to the wide ops with only -C target-feature=+wide-arithmetic. No bnum changes 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.

  1. wasm-bindgen cannot process a wide-arithmetic module. CLEARED 2026-07-31. The production wasm goes through wasm-bindgen for the IfcAPI glue. At =0.2.106 (the pin when this was written) building pkg-wide failed in the bindgen step — failed to parse code section: wide arithmetic support is not enabled, its walrus parser rejecting the new opcodes. The pin is now =0.2.126 and pkg-wide builds; see the bump note below. (The benches always sidestepped this: plain cdylib, no bindgen — which is why they built and ran throughout.)
  2. 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.ts API 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.json still matches, so emitted mesh bytes did not move,
  • default, BUILD_THREADED=1 and BUILD_WIDE=1 bundles all build, the latter two passing their own litmus checks,
  • serde-wasm-bindgen 0.6.5 and wasm-bindgen-rayon 1.3.0 needed 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 throws invalid numeric opcode: 0xfc13,
  • node --v8-options lists 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).

  1. Build a third bundle packages/wasm/pkg-wide. Same wasm-pack invocation as the default pkg, with +wide-arithmetic added to the default flags (.cargo/config.toml already sets +simd128). Off by default, behind BUILD_WIDE=1 in scripts/build-wasm.sh (added in this change). CI/Vercel build it alongside pkg once we flip it on.

  2. 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 just add128: 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.yml probes with, so CI and runtime agree by construction. Drop this into packages/geometry/src/wasm-features.ts when 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;
}
  1. Select the bundle URL. geometry.worker.ts already accepts an init wasmUrl (it otherwise falls back to new URL('ifc-lite_bg.wasm', import.meta.url)). When the probe is true, pass the pkg-wide wasm URL through the existing wasmUrls plumbing in geometry-parallel.ts; otherwise pass pkg. No engine without the feature ever loads the wide module, so it is safe to ship eagerly.

  2. Bundling. Vite copies both pkg and pkg-wide wasm as assets (same as the threaded bundle). The JS glue is identical between bundles; only the .wasm differs, so the JS is shared and only the chosen .wasm is 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:

  1. Build pkg-wide bundle (wasm-bindgen + wide-arithmetic) — runs the repo's own BUILD_WIDE=1 scripts/build-wasm.sh path unmodified (same -C target-feature=+simd128,+wide-arithmetic RUSTFLAGS documented above, plus that script's own wasm-tools-verified litmus check that the bundle actually contains wide ops). This is blocker 1 (wasm-bindgen/walrus): it failed until 2026-07-31 because rust/wasm-bindings/Cargo.toml pinned wasm-bindgen = "=0.2.106", whose bundled walrus predates the parser fix; the pin is now =0.2.126 and this gate passes. wasm-bindgen-cli first depends on walrus ^0.26.0 in 0.2.115 (0.2.114 and earlier are still on walrus ^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.
  2. 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_determinism against the pinned rust/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 via NODE_ARGS (a direct node argv: NODE_OPTIONS only 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's node-version: latest probe would report first — NODE_ARGS is 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.
  3. Wide-op emission count — informational only (continue-on-error), counts mul_wide/add128/sub128 in the built bundle via wasm-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:

  1. Manifest diff — step 2 fails with a report naming positions_hash / indices_origin_hash / normals_hash rather 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 flipping BUILD_WIDE on.
  2. Build regression — a walrus/parse error. This used to be the status quo (blocker 1) but is not any more: since the =0.2.126 pin 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 keeps BUILD_WIDE blocked 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.
  3. 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_WIDE blocked 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's wasm-bindgen pin bumped to =0.2.126, past the walrus-0.26 release, in its own reviewed PR, with wasm-bindgen-futures / wasm-bindgen-test / js-sys / web-sys moved in lockstep as required.
  • Blocker 2 cleared: WebAssembly.validate() of the probe in packages/geometry/src/wasm-features.ts (see the Delivery plan above) returns true on 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 in wide-arithmetic.yml uses 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-wide bundle selection (Delivery plan steps 2-4 above) and re-run the viewer's own benchmark/E2E suite before flipping BUILD_WIDE=1 on by default in scripts/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.