Skip to content

Commit 500d57e

Browse files
committed
feat(simd): real WASM SIMD128 backend (fill simd_wasm.rs scalar scaffolding)
`src/simd_wasm.rs` was commented-out scaffolding; wasm32 fell back to the pure-scalar SIMD types. Fill it with a native `core::arch::wasm32` v128 backend, mirroring `simd_neon::aarch64_simd`'s proven split (native v128 for the float/byte hot path, scalar fallback for the long tail). simd_wasm::wasm32_simd (gated wasm32 + target_feature="simd128"): - F32x16 / F64x8 as [v128;4] + F32Mask16 / F64Mask8 — full API parity with the scalar macro (arith/reduce/min-max/clamp/compare-mask/to-from-bits/ cast/round/floor/sqrt/abs/mul_add + operator impls + Mask::select). - I8x16 (one v128) = union of the scalar + NEON method sets (add/sub/min/max/ cmp_gt + from_i4_packed_u64/lane_i8/saturating_abs). - v128 hot kernels mirroring the NEON ones: dot_f32x4, popcount_u8x16, hamming_u8x16, hamming_u8x64 (Fingerprint<256> distance via i8x16_popcnt), base17_l1 (i16->i32 sign-extend before subtract, no overflow), codebook_gather_f32x4, bf16_to_f32_batch. - mul_add: f32x4_relaxed_madd under +relaxed-simd, else mul+add (base simd128 has no FMA). round = f32x4_nearest (ties-even, =NEON). NaN in min/max follows IEEE (=NEON). All cross-backend divergences documented. Dispatch (simd.rs): wasm32+simd128 arm re-exports the 8 native names from wasm32_simd and the remainder from scalar; the full-scalar fallback arm now excludes that case. Added wasm32 PREFERRED_*_LANES (128-bit widths) and .cargo/config-wasm.toml (-Ctarget-feature=+simd128). Also unblock the wasm build (pre-existing x86-only AMX leaks, not the SIMD scaffolding): gate the x86-only amx_matmul / simd_amx re-exports in simd.rs to target_arch="x86_64", and route backend::gemm_bf16 through the portable hpc::quantized::bf16_gemm_f32 on non-x86 (bit-equivalent to the AMX dispatcher's own scalar fallback). x86 paths are untouched by construction. Verified: cargo build -p ndarray --lib for wasm32 (+simd128 native / scalar / no_std) and x86_64 all green; a faithful standalone copy of wasm32_simd run under node passes 51 numeric checks (mask bit-patterns, saturating_abs(i8::MIN)=127, Hamming, Base17 incl. |a-b|=60000 overflow, bf16 shift); 217 SIMD + 85 backend/bf16 x86 tests pass; clippy -D warnings and fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSk1qJ28eh6A2hd4JUWFRr
1 parent 72ecee0 commit 500d57e

5 files changed

Lines changed: 1256 additions & 163 deletions

File tree

.cargo/config-wasm.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# WebAssembly with SIMD128 — enables the native v128 SIMD backend
2+
# (`src/simd_wasm.rs::wasm32_simd`) instead of the pure-scalar fallback.
3+
#
4+
# Use with:
5+
# cargo --config .cargo/config-wasm.toml build -p ndarray --lib --target wasm32-unknown-unknown
6+
#
7+
# Equivalent env form:
8+
# RUSTFLAGS='-Ctarget-feature=+simd128' cargo build -p ndarray --lib --target wasm32-unknown-unknown
9+
#
10+
# The `simd128` target feature is what gates the `wasm32_simd` module and the
11+
# `simd.rs` dispatch arm that re-exports its `F32x16` / `F64x8` / `I8x16`
12+
# types. Without it, `wasm32` falls back to the portable scalar SIMD types
13+
# (still correct, just not vectorized). Add `+relaxed-simd` as well to light
14+
# up the fused `f32x4_relaxed_madd` path in `mul_add`:
15+
#
16+
# rustflags = ["-Ctarget-feature=+simd128,+relaxed-simd"]
17+
#
18+
# Applies to both wasm32-unknown-unknown and wasm32-wasip1.
19+
[target.wasm32-unknown-unknown]
20+
rustflags = ["-Ctarget-feature=+simd128"]
21+
22+
[target.wasm32-wasip1]
23+
rustflags = ["-Ctarget-feature=+simd128"]

.claude/blackboard.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,85 @@
33
> **Read this first.** The "Polyglot Notebook" architecture below is a
44
> separate/older program, not the current epoch.
55
6+
## 2026-06-28 — WASM SIMD128 backend filled in (`src/simd_wasm.rs`)
7+
8+
Replaced the commented-out scaffolding in `src/simd_wasm.rs` with a real
9+
`core::arch::wasm32` SIMD128 backend, mirroring `simd_neon::aarch64_simd`'s
10+
proven split (native v128 for the float/byte hot path, scalar fallback for
11+
the long tail). Branch `claude/ndarray-wasm-scalar-zr9n46`.
12+
13+
**`src/simd_wasm.rs::wasm32_simd`** (gated `#[cfg(all(target_arch="wasm32",
14+
target_feature="simd128"))]`):
15+
- `F32x16` / `F64x8` as `[v128;4]` + `F32Mask16` / `F64Mask8` — full API
16+
parity with the scalar macro (splat/from_slice/from_array/to_array/
17+
copy_to_slice/reduce_{sum,min,max}/abs/sqrt/round/floor/mul_add/
18+
simd_{min,max,clamp,lt,le,gt,ge,eq,ne}/to_bits/from_bits/cast_i32 +
19+
Add/Sub/Mul/Div/*Assign/Neg/Debug/PartialEq/Default + Mask::select).
20+
- `I8x16` (one `v128`) = UNION of the scalar + NEON method sets
21+
(add/sub/min/max/cmp_gt + from_i4_packed_u64/lane_i8/saturating_abs)
22+
so consumers are portable across every backend.
23+
- Free hot-kernels (v128 counterparts to the NEON kernels):
24+
`dot_f32x4_wasm`, `popcount_u8x16_wasm`, `hamming_u8x16_wasm`,
25+
`hamming_u8x64_wasm` (Fingerprint<256> distance via `i8x16_popcnt`),
26+
`base17_l1_wasm`, `codebook_gather_f32x4_wasm`, `bf16_to_f32_batch_wasm`.
27+
- `mul_add`: `f32x4_relaxed_madd` under `+relaxed-simd`, else mul+add
28+
(base simd128 has no FMA). `round()` = `f32x4_nearest` (ties-even, =NEON).
29+
NaN in simd_min/max follows IEEE (NaN-propagating, =NEON); the existing
30+
`simd_exp_f32` NaN save/restore already absorbs this. All documented.
31+
32+
**Dispatch (`src/simd.rs`):** new `target_arch="wasm32" + target_feature=
33+
"simd128"` arm re-exports the 8 native names from `wasm32_simd` and the
34+
remainder from `scalar`; the "Other non-x86" arm now excludes that case
35+
(wasm-without-simd128 + riscv etc. stay full-scalar). Added wasm32
36+
`PREFERRED_*_LANES` arms (F32=4/F64=2/U64=2/I16=8, 128-bit widths) and a
37+
`.cargo/config-wasm.toml` (`-Ctarget-feature=+simd128`).
38+
39+
**Unblocked the wasm build (pre-existing x86 leaks, not SIMD-scaffolding):**
40+
the crate did NOT compile for wasm at all — `src/simd.rs` re-exported the
41+
x86-only `amx_matmul` / `simd_amx` modules unconditionally, and
42+
`backend::gemm_bf16` called `amx_matmul::matmul_bf16_to_f32` directly.
43+
Gated both re-exports to `#[cfg(target_arch="x86_64")]`; split `gemm_bf16`
44+
into the IDENTICAL x86 AMX path + a non-x86 branch routing through the
45+
portable `hpc::quantized::bf16_gemm_f32(.., 1.0, 0.0)` (the same scalar
46+
reference the AMX dispatcher itself falls back to → bit-equivalent). x86
47+
behavior is untouched by construction (the original block now lives under
48+
`cfg(target_arch="x86_64")`).
49+
50+
[VERIFICATION] (1) `cargo build -p ndarray --lib` for wasm32 **+simd128**
51+
(native) AND **without** simd128 (scalar) AND **--no-default-features**
52+
(no_std) AND x86_64 default — all green. (2) A standalone faithful copy of
53+
`wasm32_simd` built to wasm32+simd128 and run under **node**: 51 numeric
54+
checks (incl. exact mask bit-patterns, saturating_abs(i8::MIN)=127,
55+
Hamming=512, Base17 vs scalar incl. a pathological |a-b|=60000 overflow
56+
case, bf16 shift) all PASS. (3) x86 regression: 217 SIMD tests + 85
57+
backend/bf16 tests pass; `clippy -p ndarray --lib -- -D warnings` clean;
58+
`fmt --check` clean. Harness: `/tmp/.../scratchpad/wasmverify`.
59+
60+
[ADVERSARIAL REVIEW] Ran a 3-angle Opus review (cfg-gating / intrinsic-
61+
semantics / x86-regression). x86-regression = PASS (x86 path byte-identical;
62+
non-x86 bf16 fallback bit-equivalent). Two findings resolved: (P0 cfg-gating
63+
"no_std arm break") = **false positive**`pub mod simd` is itself
64+
`#[cfg(feature="std")]` (lib.rs:239), so the native wasm arm is transitively
65+
std-gated; `--no-default-features` wasm build is clean (empirically
66+
confirmed). (P1 base17 i16 wrap) = **real, fixed**`base17_l1_wasm` now
67+
sign-extends i16→i32 via `i32x4_extend_{low,high}_i16x8` BEFORE the subtract,
68+
so `|a-b|` is computed in i32 and matches the scalar reference for the full
69+
i16 range (the prior i16-domain abs-diff, like NEON's `vabdq_s16`, wrapped at
70+
|a-b|>i16::MAX). Doc nits (mul_add ULP wording, reduce_sum order, Tier-enum
71+
comment) also tightened.
72+
73+
[NOTE] The stale top-of-CLAUDE.md "Build currently fails (exit 101)" no
74+
longer reproduces — x86 lib builds clean this turn.
75+
76+
[LOOSE END] Full-crate (workspace) wasm build still blocked by `getrandom
77+
0.3` (via `ndarray-rand`/`numeric-tests`, members that depend ON ndarray)
78+
needing the `wasm_js` backend — orthogonal to this work; `-p ndarray --lib`
79+
is the correct wasm surface and it is green. `bf16_to_f32_batch_wasm` is
80+
provided + tested but NOT wired into the `bf16_to_f32_batch` dispatch (left
81+
scalar to keep the BF16 path untouched); wire it if a wasm BF16 hot path
82+
appears. Native U8x64/I32x16/U64x8 stay scalar on wasm (same as NEON keeps
83+
them scalar) — the free Hamming/Base17 kernels cover those hot paths.
84+
685
## 2026-06-17 — DECISION: HHTL fork ladder coded in `hpc::entropy_ladder` (CONJECTURE)
786

887
Reified the operator's standing idea — *if the orthogonal (helix/CAM-PQ)

src/backend/mod.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,12 @@ pub fn gemm_i8(a: &[u8], b: &[i8], c: &mut [i32], m: usize, n: usize, k: usize)
210210
#[allow(clippy::needless_return)]
211211
pub fn gemm_bf16(a: &[u16], b: &[u16], c: &mut [f32], m: usize, n: usize, k: usize) {
212212
// Reinterpret u16 slices as BF16 slices (repr(transparent))
213-
#[cfg(feature = "std")]
213+
//
214+
// x86_64: route through the ArrayView2-based AMX dispatcher
215+
// (`amx_matmul::matmul_bf16_to_f32` = AMX TDPBF16PS → AVX-512 VDPBF16PS →
216+
// scalar tiled `bf16_gemm_f32`). That module is `#[cfg(target_arch =
217+
// "x86_64")]`, so off x86 we call the same scalar reference directly.
218+
#[cfg(all(feature = "std", target_arch = "x86_64"))]
214219
{
215220
use crate::{ArrayView2, ArrayViewMut2};
216221

@@ -235,6 +240,22 @@ pub fn gemm_bf16(a: &[u16], b: &[u16], c: &mut [f32], m: usize, n: usize, k: usi
235240
crate::hpc::amx_matmul::matmul_bf16_to_f32(lhs, rhs, out).expect("gemm_bf16: matmul shape contract");
236241
return;
237242
}
243+
// Non-x86 std hosts (aarch64 / wasm32 / riscv …): the AMX tile path is
244+
// x86-only; route through the portable scalar reference
245+
// `crate::hpc::quantized::bf16_gemm_f32` (alpha = 1, beta = 0 → C
246+
// overwritten), bit-equivalent to the scalar fallback the x86 dispatcher
247+
// takes on non-AMX silicon.
248+
#[cfg(all(feature = "std", not(target_arch = "x86_64")))]
249+
{
250+
let a_bf16: &[crate::hpc::quantized::BF16] =
251+
// SAFETY: BF16 is #[repr(transparent)] over u16; bit pattern preserved.
252+
unsafe { core::slice::from_raw_parts(a.as_ptr() as *const crate::hpc::quantized::BF16, a.len()) };
253+
let b_bf16: &[crate::hpc::quantized::BF16] =
254+
// SAFETY: same repr(transparent) invariant as `a_bf16` above.
255+
unsafe { core::slice::from_raw_parts(b.as_ptr() as *const crate::hpc::quantized::BF16, b.len()) };
256+
crate::hpc::quantized::bf16_gemm_f32(&a_bf16[..m * k], &b_bf16[..k * n], &mut c[..m * n], m, n, k, 1.0, 0.0);
257+
return;
258+
}
238259
#[cfg(not(feature = "std"))]
239260
{
240261
let _ = (a, b, c, m, n, k);

src/simd.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ use std::sync::LazyLock;
1212
// `detect_tier()`'s feature-detection blocks are `target_arch = "x86_64"`
1313
// or `"aarch64"` gated, both false on i686. Without `dead_code` allowance
1414
// the `-D warnings` build fails with `variants ... are never constructed`.
15+
// Note: this `Tier` enum is *runtime* dispatch only. On `wasm32 +
16+
// target_feature = "simd128"` the SIMD *types* are NOT scalar — they come
17+
// from the compile-time `simd_wasm::wasm32_simd` v128 backend (re-exported
18+
// below); `detect_tier()` simply has no wasm arm, so the runtime tier stays
19+
// `Scalar`.
1520
#[allow(dead_code)]
1621
#[derive(Clone, Copy, PartialEq, Debug)]
1722
#[repr(u8)]
@@ -156,7 +161,9 @@ pub const PREFERRED_F64_LANES: usize = 8;
156161
pub const PREFERRED_F64_LANES: usize = 4;
157162
#[cfg(target_arch = "aarch64")]
158163
pub const PREFERRED_F64_LANES: usize = 2; // NEON: float64x2_t = 2 × f64
159-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
164+
#[cfg(target_arch = "wasm32")]
165+
pub const PREFERRED_F64_LANES: usize = 2; // WASM SIMD128: f64x2 = 2 × f64
166+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "wasm32")))]
160167
pub const PREFERRED_F64_LANES: usize = 4; // scalar fallback: same as AVX2 shape
161168

162169
/// Preferred f32 SIMD width.
@@ -167,7 +174,9 @@ pub const PREFERRED_F32_LANES: usize = 16;
167174
pub const PREFERRED_F32_LANES: usize = 8;
168175
#[cfg(target_arch = "aarch64")]
169176
pub const PREFERRED_F32_LANES: usize = 4; // NEON: float32x4_t = 4 × f32
170-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
177+
#[cfg(target_arch = "wasm32")]
178+
pub const PREFERRED_F32_LANES: usize = 4; // WASM SIMD128: f32x4 = 4 × f32
179+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "wasm32")))]
171180
pub const PREFERRED_F32_LANES: usize = 8;
172181

173182
/// Preferred u64 SIMD width.
@@ -178,7 +187,9 @@ pub const PREFERRED_U64_LANES: usize = 8;
178187
pub const PREFERRED_U64_LANES: usize = 4;
179188
#[cfg(target_arch = "aarch64")]
180189
pub const PREFERRED_U64_LANES: usize = 2; // NEON: uint64x2_t
181-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
190+
#[cfg(target_arch = "wasm32")]
191+
pub const PREFERRED_U64_LANES: usize = 2; // WASM SIMD128: i64x2 = 2 × u64
192+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "wasm32")))]
182193
pub const PREFERRED_U64_LANES: usize = 4;
183194

184195
/// Preferred i16 SIMD width (for Base17 L1 on i16[17]).
@@ -191,7 +202,9 @@ pub const PREFERRED_I16_LANES: usize = 32;
191202
pub const PREFERRED_I16_LANES: usize = 16;
192203
#[cfg(target_arch = "aarch64")]
193204
pub const PREFERRED_I16_LANES: usize = 8; // NEON: int16x8_t
194-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
205+
#[cfg(target_arch = "wasm32")]
206+
pub const PREFERRED_I16_LANES: usize = 8; // WASM SIMD128: i16x8 = 8 × i16
207+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "wasm32")))]
195208
pub const PREFERRED_I16_LANES: usize = 16;
196209

197210
// ============================================================================
@@ -376,10 +389,28 @@ pub use scalar::{
376389
I64x4, I64x8, U16x16, U16x32, U32x16, U32x8, U64x4, U64x8, U8x64,
377390
};
378391

379-
// Other non-x86 targets (wasm, riscv, etc.): full scalar fallback.
392+
// wasm32 + simd128: the native v128 float hot path (F32x16 / F64x8 + masks)
393+
// and native I8x16 come from `simd_wasm::wasm32_simd`; the long-tail integer
394+
// and 256-bit-shaped types come from the scalar fallback. Same split
395+
// `simd_neon` uses on aarch64 (native float kernels, scalar for the rest).
396+
// The `wasm32_simd` module only exists under `target_feature = "simd128"`,
397+
// so this arm is gated identically.
398+
#[cfg(all(target_arch = "wasm32", target_feature = "simd128", not(feature = "nightly-simd")))]
399+
pub use crate::simd_wasm::wasm32_simd::{f32x16, f64x8, i8x16, F32Mask16, F32x16, F64Mask8, F64x8, I8x16};
400+
#[cfg(all(target_arch = "wasm32", target_feature = "simd128", not(feature = "nightly-simd")))]
401+
pub use scalar::{
402+
batch_packed_i4_16, f32x8, f64x4, i16x16, i16x32, i32x16, i32x8, i64x4, i64x8, i8x32, i8x64, palette_lookup_u8x8,
403+
prefetch_read_t0, prefetch_read_t1, prefetch_read_t2, u16x16, u16x8, u32x16, u32x8, u64x4, u64x8, u8x64, u8x8,
404+
F32x8, F64x4, I16x16, I16x32, I32x16, I32x8, I64x4, I64x8, I8x32, I8x64, U16x16, U16x32, U16x8, U32x16, U32x8,
405+
U64x4, U64x8, U8x64, U8x8,
406+
};
407+
408+
// Other non-x86 targets — wasm32 without simd128, riscv, etc.: full scalar
409+
// fallback. Excludes the wasm32+simd128 case handled by the native arm above.
380410
#[cfg(all(
381411
not(target_arch = "x86_64"),
382412
not(target_arch = "aarch64"),
413+
not(all(target_arch = "wasm32", target_feature = "simd128")),
383414
not(feature = "nightly-simd")
384415
))]
385416
pub use scalar::{
@@ -577,11 +608,16 @@ pub use crate::hpc::heel_f64x8::cosine_f32_to_f64_simd;
577608
// whole AMX ladder through the canonical `ndarray::simd::*` import (W1a)
578609
// without dipping into `crate::hpc::amx_matmul` directly. `amx_available()`
579610
// exposes the runtime tier check for reporting.
580-
#[cfg(feature = "std")]
611+
// AMX is x86_64-only (the `amx_matmul` / `simd_amx` modules are
612+
// `#[cfg(target_arch = "x86_64")]`), so these re-exports are arch-gated.
613+
// Off x86 the cross-platform entry points are `backend::gemm_i8` /
614+
// `backend::gemm_bf16` (portable scalar / NEON / wasm-SIMD paths).
615+
#[cfg(all(feature = "std", target_arch = "x86_64"))]
581616
pub use crate::hpc::amx_matmul::{amx_available, matmul_i8_to_i32};
582617
// CPU-generation detection (cached): SPR / EMR / GNR / Sierra Forest. Lets a
583618
// consumer report which silicon a run landed on and distinguish "no AMX
584619
// silicon" from "AMX present but not OS-enabled" — both surface via `amx_report`.
620+
#[cfg(target_arch = "x86_64")]
585621
pub use crate::simd_amx::{amx_report, cpu_model, CpuModel};
586622

587623
// Elementwise slice ops — polyfill-dispatched (F32x16/F64x8 chunks + scalar tail).

0 commit comments

Comments
 (0)