Skip to content

spike: wasm SIMD tree matrix multiply - #51

Draft
krispya wants to merge 8 commits into
mainfrom
claude/wasm-matrix-mult-simd-5pqf4h
Draft

krispya wants to merge 8 commits into
mainfrom
claude/wasm-matrix-mult-simd-5pqf4h

Conversation

@krispya

@krispya krispya commented Sep 7, 2026

Copy link
Copy Markdown
Member

A spike exploring a minimal wasm module for world[i] = world[parent[i]] * local[i] over a parent/child graph. Everything lives in spikes/wasm-mat4/, plus a labs bench and tests. Nothing is wired into src/.

Not for merge as-is. Opened to review the design and the numbers.

Result

~4.0x over the current plain-array path, from benches/wasm/tree.bench.ts at N=4096 over a 4-ary tree:

bench µs/iter vs js plain
js plain arrays ~138 1.00x
wasm scalar ~113 1.22x
wasm simd ~34 ~4.0x

The flat layout is worth ~1.2x and SIMD ~3.2x on top. Non-SIMD wasm barely beats JS, so the fallback for engines without SIMD should be the existing JS rather than a second binary.

Design

  • Flat AoS is also the fastest layout, not a concession to GPU interop. An SoA kernel needing no lane broadcasts measured 0.17x, since it runs 48 concurrent streams over 768KB where AoS touches three contiguous cache lines per node.
  • The simple loop wins. Sibling grouping, restrict, 2x unrolling and -O3 all measured neutral or worse. -Oz matches -O3 at half the size.
  • Under 4KB, so WebAssembly.Module compiles synchronously on the main thread and nothing in the API is async. tree.wasm is 394 bytes; tree.mjs inlines both kernels as base64, so there is no fetch or bundler plugin.
  • The module allocates. A Float32Array view onto wasm memory measures the same as one on its own ArrayBuffer, and memory is sized once because memory.grow detaches every view handed out.
  • f32 is fine for transform trees. Drift stays near f32 epsilon down a 255-deep chain, since rotations are well conditioned.

Safety

The kernel treats caller indices as untrusted. One unsigned compare p >= (unsigned)i rejects a root, a forward reference and an out-of-range index together, all treated as roots, so 0 <= p < i < n always holds and no read leaves the buffer. Costs no bytes and no measurable time. 11 tests cover propagation against the f64 mat4.multiply reference, the malformed-tree guards and the bounds checks.

Caveats

  • Measured on one container labs reports as unstable (2.05GHz, ±7.5% comparison resolution). Only the SIMD result is comfortably outside that.
  • The relaxed-SIMD kernel is unproven. It measures 1.05–1.14x against that ±7.5% floor. It is opt-in, and its results differ between engines since fusing is optional.
  • No browser, ARM or Safari coverage, which is where relaxed-SIMD support is in doubt.
  • The spike is C because this container had clang and no Zig; the README argues for Zig in production.
  • .wasm files are committed. embed.mjs --check guards the inlined base64 against drift, but nothing rebuilds them in CI yet.

Open questions

  • Separate math/wasm entrypoint, or a separate package so core stays artifact-free?
  • Ship both kernels (~1KB of base64 for an unproven path) or strict only?
  • Does flattening a scene graph into parents-before-children order belong here or in a renderer integration?

🤖 Generated with Claude Code

https://claude.ai/code/session_01BWQBg51kiyNdHJT3WLfwZj


Generated by Claude Code

Explores a minimal wasm module for batched mat4 work. Not wired into src.

The library's Mat4 is a plain JS array of doubles, so any wasm path has to
marshal into linear memory first. Measured at N=4096, resident buffers give
3.84x over the current JS multiply while marshalling plain arrays in and out
gives 0.44x, so batch only APIs over caller resident memory are the only
shape that pays.

Also measures that flat layout alone accounts for 1.4x and SIMD a further
3.0x, which rules out shipping a scalar wasm fallback.

Includes a build script, the committed 1226 byte module so the bench runs
without clang, and a README covering language choice and three.js
integration points.
…llocator

The skill already puts wasm crossing state in a typed array from the start, so
residency is the house pattern rather than an obstacle. Measuring that properly
found three tiers, not two: element-wise marshalling is 0.44x, a caller's own
Float32Array copied in bulk is 1.92x, and a view onto wasm memory is 3.24x.

The bulk memcpy costs about 70% on top of the kernel, so accepting a foreign
Float32Array roughly halves the win. Since a view onto wasm memory measures the
same as an ordinary Float32Array for JS to read and write, the module should be
the allocator and callers should source long lived buffers from it.

That also forces a no grow rule, since memory.grow detaches every held view,
which matches the skill's preallocate to capacity guidance.
Assumes a flat array of matrices throughout, one contiguous Float32Array of 16
floats per matrix, which is what instanceMatrix and boneMatrices already are.

Measured AoS against an SoA kernel that needs no lane broadcasts, to check
whether the layout GPU interop forces is costing anything. It is not. SoA is
5.9x slower, because one AoS matrix is exactly one cache line and a multiply
touches three contiguously, where SoA runs 48 concurrent streams over 768KB
and thrashes L2.

Tree reduction and a 2x unroll measure neutral and slower respectively, so the
loop stays simple. Switched the variant harness to min of trials after a single
timed run put the tree reduction anywhere from 0.97x to 1.15x.

Adds mat4.mjs, a 5.4KB single file with the wasm inlined as base64 and no fetch
or async, taking Float32Array views and deriving pointers from byteOffset so a
non resident buffer is rejected rather than misread.
Refocuses the spike on world[i] = world[parent[i]] * local[i] and drops the
pairwise batch, compose and broadcast kernels along with the three.js notes.

Measures 4.67x over the library's current plain array representation on a 4-ary
tree, flat in cost per node from 1K to 262K nodes, so the parent gather does not
become a scaling problem.

The kernel issues about 56 uops per node and runs at roughly 4 uops per cycle,
so it is issue bound. That model predicts the results: relaxed simd fma removes
12 of those uops and gains the predicted 1.2x, while sibling grouping, restrict,
unrolling and -O3 all measure neutral or worse. Sibling grouping was the most
promising idea and it fails, at 1.01x on the shape it was designed for and 21%
slower on short runs.

Fma gain tracks the same model, 1.16x to 1.28x on bushy trees but only 1.04x on
chains, which are latency bound on the parent dependency instead.

Also corrects the earlier precision note. Drift stays near f32 epsilon down a
255 deep chain of realistic transforms, since rotations are well conditioned.
The earlier 1e-3 figure came from random matrices, which are not transforms.
…ed loop

Moves the head to head onto @pmndrs/labs, which isolates each bench in its own
worker, interleaves their blocks, detects dead code elimination and reports
machine stability. All five comparators live in one file so the interleaving
cancels drift between them, which matters on this container.

Uses the real mat4.multiply from src for the plain array baseline rather than a
transcription, and checks every kernel against an f64 reference.

Checksums go through the snapshot hook so they stay out of the timed region.
Timing them was making the wasm benches look several times slower than the JS
ones, since 65536 element reads swamp the kernel.

Adds a scalar twin of the kernel as a diagnostic, loaded from disk rather than
inlined, so the flat layout can be priced separately from SIMD.
The kernel took raw parent indices and trusted them. An index past capacity but
still inside wasm memory read a neighbouring slot and returned wrong values with
no error, and update(count) with count above capacity wrote over the parent
array before eventually trapping.

Both are now impossible. One unsigned compare, p >= (unsigned)i, rejects a root,
a forward reference and an out of range index together, and treats each as a
root, so 0 <= p < i < n always holds and no read leaves the buffer. The module
is the same 394 bytes.

createTree rejects a capacity that is not a non negative integer, or larger than
a 4GiB wasm memory holds, and reports a failed grow. update and validate reject
a count outside [0, capacity], and a rejected update leaves the tree untouched.

Adds embed.mjs, which regenerates the base64 inlined in tree.mjs from the .wasm
files and verifies them with --check, so the two cannot drift. build.sh runs it.

Adds 14 tests covering propagation against the f64 mat4.multiply reference, the
malformed tree guards, and the validation.
The committed readme carried several claims that better measurement did not
support. Corrected against seven labs runs:

- the speedup is about 4.0x over the plain array path, not 4.67x
- plain arrays and a flat Float32Array measure the same in JS, so the earlier
  claim that plain is 1.4x faster was a harness artifact
- the split is roughly 1.2x from the flat layout and 3.2x from SIMD, not the
  0.95x and 4.57x an ad hoc harness reported
- the relaxed simd gain measures 1.05x to 1.14x against a +-7.5% comparison
  resolution, so it is unproven here rather than a 1.2x win

Drops the issue bound argument entirely. It assumed a 3GHz clock that was never
checked, and labs reports the machine at 2.05GHz, where the uop count it rested
on would need more issue width than the core has. The empirical results it was
offered to explain stand on their own.

Says plainly that the machine is unstable, what the noise floor is, and which
results sit outside it. Marks the readme's remaining small effects as measured
with the earlier harness.

Removes mat4.mjs and bench.mjs. The first is the superseded batch API from
before the scope narrowed to trees, unsafe and untested, and the second is the
hand rolled harness that benches/wasm/tree.bench.ts replaced.
Comments. The relaxed madd comment still asserted the kernel was issue bound and
that fma was the only lever, which the labs measurements did not support. It now
describes what the macro does and why the build is opt in, with no rationale it
cannot back. The bench comment about the snapshot described a change rather than
the mechanism, so it describes the mechanism.

Consts. MAX_CAPACITY and F32_TOLERANCE were module scope and each used in one
place, so both are inlined where they are read, the capacity bound beside the
guard it belongs to and the tolerance beside the comparison it governs. N in the
bench stays, matching transform-hierarchy.bench.ts, and DEPTH stays in the
precision script where it reads through six uses.

Tests. Trimmed from 14 to 11 by folding the thin assertions into the cases that
already made them and dropping the view shape test, which duplicated what the
propagation tests exercise and came closest to testing internals. What is left
documents the feature and keeps every guard against the out of bounds paths.

Also adds tree.d.mts and scalar.d.mts. Both tsconfigs are strict and cover the
test and bench that import these, so without declarations they resolved to any.
@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d8b68e9

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants