Skip to content

feat(wasm): SIMD128 kernels for l2_squared/inner_product distance (#675)#686

Open
ohdearquant wants to merge 1 commit into
ruvnet:mainfrom
ohdearquant:feat/wasm-simd128
Open

feat(wasm): SIMD128 kernels for l2_squared/inner_product distance (#675)#686
ohdearquant wants to merge 1 commit into
ruvnet:mainfrom
ohdearquant:feat/wasm-simd128

Conversation

@ohdearquant

Copy link
Copy Markdown
Contributor

PR authored by an AI agent on behalf of @ohdearquant.

What

Adds wasm32 SIMD128 kernels (core::arch::wasm32, v128/f32x4 lanes) for the
two hot distance primitives in ruvector-diskann's dispatch chain — l2_squared
and inner_product — gated under #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))], following the exact dispatch shape the crate's existing simd
feature (SimSIMD, native NEON/AVX2/AVX-512) already uses. Native builds are
unaffected: the new code is compiled out entirely off wasm32.

PQ asymmetric-distance table construction (ProductQuantizer::build_distance_table
in pq.rs) calls l2_squared in its inner loop and speeds up transparently
through the same dispatch — no separate kernel or code change needed there.

Why (#675)

Today, wasm32 builds of this crate always take the scalar fallback
(scalar_l2_squared / scalar_inner_product) because the simd feature routes
through simsimd, which only targets NEON/AVX2/AVX-512 — not wasm32. This adds
the missing leg of the dispatch chain: GPU → SimSIMD (native) → WASM SIMD128
(wasm32 + simd128) → scalar
.

Gate (pre-registered before any measurement)

PASS iff:

  1. Correctness: wasm-pack test --node (SIMD128-enabled build) — every
    SIMD128-vs-scalar comparison across dims {0, 1, 3, 5, 7, 384, 768, 1000, 1023, 1024} (covers empty/short/non-multiple-of-4/production-size) has
    max |diff| < 1e-6, on randomized inputs (seeded PRNG for reproducibility).
  2. Speed: geometric mean of scalar_time / simd128_time across the 6-cell
    grid {l2_squared, inner_product} × {384, 768, 1024} dims, measured in the
    same Node process / same wasm instance (both code paths compiled into one
    simd128-enabled binary, so the comparison isn't confounded by two separate
    builds) with a warmup phase before timing, is ≥ 1.2×.

If PASS: this PR is opened with the full table below. If FAIL: no PR is opened;
the numbers + analysis are committed to AB_675.md on the branch instead.

Correctness-gate amendment (made before the speed run, once the correctness
tests were first executed):
a flat < 1e-6 absolute tolerance turned out to
be numerically unreachable — e.g. l2_squared at dim=8 gave scalar=433.04245,
simd128=433.04248, diff≈3.05e-5, which fails < 1e-6 outright. This isn't a
correctness bug: the scalar path sums with 4 interleaved f32 accumulators, the
simd128 path with 2 v128 (8-wide) accumulators plus a horizontal-sum tail, so
the two reduction trees add terms in different orders. f32 addition isn't
associative, so reordering shifts rounding by a few ULPs, and a fixed absolute
bound can't hold across the requested magnitude range (results up to ~10^5 for
dim=1024). Switched to the standard combined tolerance
(|diff| <= atol + rtol * max(|a|,|b|), atol=rtol=1e-5, numpy-allclose
style) before running any correctness or speed numbers on the full dim grid —
i.e. this was a tolerance-formula fix caught by the very first correctness run
(which failed loudly on the fixed absolute bound), not a threshold relaxed
after seeing the speed result. Observed relative error is ~1.1e-7 (essentially
f32 machine epsilon) — a real bug (wrong lane order, dropped remainder tail)
would show relative error orders of magnitude larger than that, so this bound
stays tight.

A/B table

Measured via RUSTFLAGS="-C target-feature=+simd128" wasm-pack test --node --release -- --no-default-features -- --nocapture (Node v25.6.0, macOS
aarch64). Both scalar_* and wasm_simd128_* are compiled into one
simd128-enabled release binary and called back-to-back from the same wasm
instance — release profile is opt-level = 3, lto = "fat", codegen-units = 1
(workspace [profile.release]; the debug/default profile is not
representative here — it left the unsafe intrinsic calls uninlined and
measured simd128 as ~8x slower, corrected by switching to --release).
Each cell: 2,000 warmup iterations, then 5 rounds of 300,000 timed iterations
(median of 5 taken), wall time via the global performance.now() (sub-ms
resolution; Date.now()'s 1ms granularity was too coarse at these iteration
counts and was replaced before the numbers below were collected).

op dim iters/round scalar (median, ms) simd128 (median, ms) speedup
l2_squared 384 300,000 64.3161 15.5382 4.14x
inner_product 384 300,000 67.0228 16.7800 3.99x
l2_squared 768 300,000 132.4645 32.1247 4.12x
inner_product 768 300,000 130.3100 29.6100 4.40x
l2_squared 1024 300,000 172.0167 38.1842 4.50x
inner_product 1024 300,000 176.6326 44.7888 3.94x

Geometric mean speedup: 4.18x (gate: ≥ 1.2x) — PASS.

Correctness tests

crates/ruvector-diskann/src/distance.rs, mod wasm_simd128_tests (gated
#[cfg(all(test, target_arch = "wasm32", target_feature = "simd128"))],
wasm-bindgen-test, runs under Node by default):

  • l2_squared_matches_scalar_across_dims / inner_product_matches_scalar_across_dims:
    seeded-PRNG random vectors (rand::rngs::StdRng, fixed seeds) at dims
    {0, 1, 2, 3, 4, 5, 7, 8, 9, 384, 768, 1000, 1023, 1024} — empty, sub-lane,
    4/8-lane boundaries, non-multiples-of-4, and production embedding sizes —
    scalar vs simd128 compared with the combined tolerance above. All pass;
    max observed relative error ~1.1e-7.
  • identical_vectors_are_zero_distance: wasm_simd128_l2_squared(a, a) < 1e-10.

Run recipe (also in the doc-comment above the test module):

RUSTFLAGS="-C target-feature=+simd128" wasm-pack test --node crates/ruvector-diskann --release -- --no-default-features

Native builds are unaffected — the new code (kernels + tests) is entirely
cfg'd out off wasm32; cargo test -p ruvector-diskann (17 tests),
cargo fmt -p ruvector-diskann -- --check, and cargo clippy -p ruvector-diskann --all-targets all pass unchanged. cargo clippy -p ruvector-diskann --target wasm32-unknown-unknown --no-default-features --all-targets (with RUSTFLAGS="-C target-feature=+simd128") is also clean.
The default wasm32-unknown-unknown build (no simd128 target-feature) still
compiles and takes the scalar fallback, unchanged.

Cargo.toml note

rand (used by PQ k-means training) pulls in getrandom 0.2 transitively,
which needs its "js" feature to build for wasm32-unknown-unknown at all —
this blocked the crate from compiling for wasm32 even before this PR's
kernel code, independent of SIMD128. Added the same
getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] }
shim ruvector-wasm/Cargo.toml already carries for the same transitive
dependency, target-gated to wasm32 only (zero effect on native builds).
Without it, none of this PR's wasm32 tests could actually run.

Fixes #675

Adds wasm32 SIMD128 kernels (core::arch::wasm32, v128/f32x4 lanes) for
l2_squared and inner_product in ruvector-diskann's distance dispatch,
gated under #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))],
mirroring the existing simd feature's dispatch shape (SimSIMD on native).
Dispatch priority becomes: GPU -> SimSIMD (native) -> WASM SIMD128 -> scalar.

PQ asymmetric-distance table construction speeds up transparently through
the same l2_squared dispatch (no separate kernel needed there).

Adds a getrandom 0.2 "js" feature shim (wasm32-only dependency, mirrors
ruvector-wasm/Cargo.toml) required for the crate to compile for wasm32 at
all -- rand's transitive getrandom dependency otherwise fails the build.

Correctness (wasm-bindgen-test, wasm-pack test --node) and an A/B timing
report cover dims {0,1,2,3,4,5,7,8,9,384,768,1000,1023,1024}; measured
geometric-mean speedup 4.18x across {l2_squared, inner_product} x
{384,768,1024} dims in Node (release build). Native builds are unaffected;
the new code is entirely cfg'd out off wasm32.

Fixes ruvnet#675
@ohdearquant ohdearquant changed the title feat(wasm): SIMD128 kernels for dot/L2/cosine/normalize (#675) feat(wasm): SIMD128 kernels for l2_squared/inner_product distance (#675) Jul 12, 2026
@ohdearquant

Copy link
Copy Markdown
Contributor Author

CI note — the two red checks on this PR are repo-level, not from this diff:

  • Clippy (deny warnings): fails in crates/ruvector-rabitq/src/persist.rs:194 (useless_borrows_in_formatting on MAGIC, &magic). That line is present on main today, so the job is red independent of this branch. Happy to fold the one-line fix (&magicmagic) in here if you'd like the deny-warnings job green.
  • dependency-review (PRs only): errors with "Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled." That is a Settings → Code security & analysis toggle on the repo, unrelated to any diff — it will fail on every PR until enabled.

Every build/test/audit job relevant to this change passes.

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.

diskann: wasm builds silently fall back to scalar distance — add wasm32 SIMD128 kernels

1 participant