Skip to content

Commit 538495e

Browse files
committed
ci: wasm SIMD parity+correctness gate (build + run under node)
The x86 `cargo test` suite never touches `simd_wasm.rs` (cfg'd to wasm32), so wasm SIMD correctness was guarded only by a one-off node run recorded in the blackboard (commit 500d57e, "51 numeric checks") — no regression gate. A silent wasm rotate/xor bug would corrupt browser ChaCha with zero CI signal. Make that standing: - crates/wasm-simd-parity/ — a cdylib that depends on the REAL ndarray (no copy → no drift) and calls `ndarray::simd::{U32x16, F32x16, I8x16}`, self-checking each lane's arithmetic against the scalar reference in-module: U32x16 ARX triple (add/xor/rotate_left, consts 0/1/7/8/12/16/24/31 + roundtrip), F32x16 (splat/roundtrip/add/reduce_sum), I8x16 (roundtrip/add). `selfcheck()` returns a distinct code per failing lane+op. WORKSPACE-EXCLUDED so it has zero effect on the x86 jobs; built only by the gate. - scripts/wasm-parity.sh — builds it for wasm32-unknown-unknown +simd128 (the correctness/integration half: building the harness builds ndarray for wasm), then runs the selfcheck under node asserting rc==0 (the parity half). - ci.yaml `wasm_simd` job (in the `conclusion` needs) — installs the wasm target + node 22 and runs the script. The parallel of the existing `nostd` target-guard job, one tier over. Extend the per-lane blocks in lib.rs whenever a new ndarray::simd lane lands. Verified locally: `./scripts/wasm-parity.sh` → "wasm SIMD parity: OK (rc=0)"; x86 workspace lib build clean (exclude respected); ci.yaml valid YAML. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6pT32kk6pnuAAqR3JiYqu
1 parent 98f28f5 commit 538495e

7 files changed

Lines changed: 391 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ jobs:
112112
# crates that have no business existing in the nostd build.
113113
cargo rustc -p ndarray "--target=${{ matrix.target }}" --no-default-features --features portable-atomic-critical-section
114114
115+
wasm_simd:
116+
# The x86 `cargo test` suite never touches `simd_wasm.rs` (it is cfg'd to
117+
# wasm32), so wasm SIMD correctness is otherwise unverified. Build the real
118+
# `ndarray::simd` wasm types (+simd128) via the excluded `wasm-simd-parity`
119+
# cdylib and run its numeric selfcheck under node — both integration (the
120+
# lib compiles for wasm32+simd128) and parity (each lane == scalar). The
121+
# standing version of the one-off node check from commit 500d57e6.
122+
runs-on: ubuntu-latest
123+
name: wasm-simd/parity-node
124+
steps:
125+
- uses: actions/checkout@v4
126+
- uses: dtolnay/rust-toolchain@stable
127+
with:
128+
targets: wasm32-unknown-unknown
129+
# rust-toolchain.toml pins 1.95.0 — install the wasm target for the pinned
130+
# toolchain too (dtolnay installs for `stable`, which may differ).
131+
- run: rustup target add wasm32-unknown-unknown
132+
- uses: actions/setup-node@v4
133+
with:
134+
node-version: "22"
135+
- name: wasm SIMD parity (build + node run)
136+
run: ./scripts/wasm-parity.sh
137+
115138
tests:
116139
runs-on: ubuntu-latest
117140
needs: pass-msrv
@@ -335,6 +358,7 @@ jobs:
335358
- clippy
336359
- format
337360
- nostd
361+
- wasm_simd
338362
- tests
339363
- native-backend
340364
- hpc-stream-parallel

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ members = [
407407
"ndarray-rand",
408408
"crates/*",
409409
]
410-
exclude = ["crates/burn"]
410+
exclude = ["crates/burn", "crates/wasm-simd-parity"]
411411
default-members = [
412412
".",
413413
"ndarray-rand",

crates/wasm-simd-parity/Cargo.lock

Lines changed: 192 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wasm-simd-parity/Cargo.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# wasm-simd-parity — the run-under-node gate for the wasm SIMD tier.
2+
#
3+
# The x86 `cargo test` suite never touches `simd_wasm.rs` (it is `cfg`'d to
4+
# wasm32), so wasm SIMD correctness is otherwise unverified by CI. This crate
5+
# is a cdylib that calls the REAL `ndarray::simd` wasm types (no copy → no
6+
# drift) and self-checks each lane's arithmetic against scalar in-module; the
7+
# `.wasm` is built with `+simd128` and run under node, asserting rc == 0.
8+
#
9+
# EXCLUDED from the workspace (see root Cargo.toml `exclude`) so it has zero
10+
# effect on the x86 jobs; the CI `wasm_simd` job builds it via --manifest-path.
11+
[package]
12+
name = "wasm-simd-parity"
13+
version = "0.0.0"
14+
edition = "2021"
15+
publish = false
16+
17+
[lib]
18+
crate-type = ["cdylib"]
19+
20+
[dependencies]
21+
ndarray = { path = "../.." }
22+
23+
[profile.release]
24+
panic = "abort"
25+
opt-level = "z"

crates/wasm-simd-parity/run.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Node harness for the wasm SIMD parity gate: instantiate the .wasm and assert
2+
// selfcheck() == 0. A nonzero rc names the failing lane+op (see lib.rs codes).
3+
import { readFileSync } from 'node:fs';
4+
const wasmPath = process.argv[2];
5+
if (!wasmPath) {
6+
console.error('usage: node run.mjs <path-to.wasm>');
7+
process.exit(2);
8+
}
9+
const { instance } = await WebAssembly.instantiate(readFileSync(wasmPath), {});
10+
const rc = instance.exports.selfcheck();
11+
if (rc === 0) {
12+
console.log('wasm SIMD parity: OK (selfcheck rc=0)');
13+
process.exit(0);
14+
} else {
15+
console.error(`wasm SIMD parity: FAIL — selfcheck rc=${rc} (see crates/wasm-simd-parity/src/lib.rs return codes)`);
16+
process.exit(1);
17+
}

crates/wasm-simd-parity/src/lib.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! Run-under-node parity gate for the wasm SIMD tier.
2+
//!
3+
//! `#[no_mangle] selfcheck()` runs each wasm SIMD lane's arithmetic against the
4+
//! scalar `u32`/`f32`/`i8` reference **in the same module**, returning `0` iff
5+
//! every lane is bit-identical. The companion `run.mjs` instantiates the
6+
//! `.wasm` and asserts `selfcheck() == 0`; `scripts/wasm-parity.sh` wires the
7+
//! build + node run, and the CI `wasm_simd` job runs the script. This is the
8+
//! standing version of the one-off node check from commit 500d57e6 — extend the
9+
//! per-lane blocks below whenever a new `ndarray::simd` lane lands.
10+
//!
11+
//! The whole crate is gated to `wasm32 + simd128` so it compiles to nothing on
12+
//! any other target (it is workspace-excluded and only built by the CI job).
13+
#![cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
14+
15+
use ndarray::simd::{F32x16, I8x16, U32x16};
16+
17+
/// Distinct nonzero return codes make a CI failure point at the exact lane+op.
18+
#[no_mangle]
19+
pub extern "C" fn selfcheck() -> u32 {
20+
if let Err(code) = check_u32x16() {
21+
return code;
22+
}
23+
if let Err(code) = check_f32x16() {
24+
return code;
25+
}
26+
if let Err(code) = check_i8x16() {
27+
return code;
28+
}
29+
0
30+
}
31+
32+
/// `U32x16` ARX triple (Add / BitXor / rotate_left) — the ChaCha20/BLAKE lane.
33+
fn check_u32x16() -> Result<(), u32> {
34+
let a_arr: [u32; 16] = [
35+
0x0000_0000, 0xFFFF_FFFF, 0x0000_0001, 0x8000_0000, 0x1234_5678, 0x9ABC_DEF0, 0xDEAD_BEEF, 0xCAFE_BABE,
36+
0x0F0F_0F0F, 0xF0F0_F0F0, 0x5555_5555, 0xAAAA_AAAA, 0x0000_00FF, 0xFF00_0000, 0x0101_0101, 0x8080_8080,
37+
];
38+
let b_arr: [u32; 16] = [
39+
0x9E37_79B9, 0x1111_1111, 0xDEAD_C0DE, 0x0BAD_F00D, 0x7FFF_FFFF, 0x0000_0000, 0xFFFF_FFFF, 0x1357_9BDF,
40+
0x2468_ACE0, 0xFEDC_BA98, 0x0000_0010, 0x0000_001F, 0xABCD_EF01, 0x1020_4080, 0x0F0F_F0F0, 0xC0DE_CAFE,
41+
];
42+
// from_array / to_array roundtrip (lane ordering).
43+
if U32x16::from_array(a_arr).to_array() != a_arr {
44+
return Err(10);
45+
}
46+
let a = U32x16::from_array(a_arr);
47+
let b = U32x16::from_array(b_arr);
48+
let add = (a + b).to_array();
49+
let xor = (a ^ b).to_array();
50+
for i in 0..16 {
51+
if add[i] != a_arr[i].wrapping_add(b_arr[i]) {
52+
return Err(11);
53+
}
54+
if xor[i] != a_arr[i] ^ b_arr[i] {
55+
return Err(12);
56+
}
57+
}
58+
// ARX rotate — ChaCha20 uses 16/12/8/7; edges included.
59+
for &n in &[0u32, 1, 7, 8, 12, 16, 24, 31] {
60+
let r = a.rotate_left(n).to_array();
61+
for i in 0..16 {
62+
if r[i] != a_arr[i].rotate_left(n) {
63+
return Err(13);
64+
}
65+
}
66+
}
67+
Ok(())
68+
}
69+
70+
/// `F32x16` — the float hot-path lane (splat / roundtrip / add / reduce_sum).
71+
fn check_f32x16() -> Result<(), u32> {
72+
let data: [f32; 16] = [
73+
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
74+
];
75+
if F32x16::from_array(data).to_array() != data {
76+
return Err(20);
77+
}
78+
let a = F32x16::from_array(data);
79+
let b = F32x16::splat(2.0);
80+
let sum = (a + b).to_array();
81+
for i in 0..16 {
82+
if sum[i] != data[i] + 2.0 {
83+
return Err(21);
84+
}
85+
}
86+
// 0+1+…+15 = 120.
87+
if F32x16::from_array(data).reduce_sum() != 120.0 {
88+
return Err(22);
89+
}
90+
Ok(())
91+
}
92+
93+
/// `I8x16` — the byte lane (roundtrip / add).
94+
fn check_i8x16() -> Result<(), u32> {
95+
let a_arr: [i8; 16] = [-128, -1, 0, 1, 127, 2, -2, 3, -3, 42, -42, 100, -100, 7, -7, 64];
96+
let b_arr: [i8; 16] = [1, 1, 1, 1, 1, -1, -1, -1, 5, -5, 10, -10, 25, -25, 63, -64];
97+
if I8x16::from_array(a_arr).to_array() != a_arr {
98+
return Err(30);
99+
}
100+
let sum = I8x16::from_array(a_arr).add(I8x16::from_array(b_arr)).to_array();
101+
for i in 0..16 {
102+
if sum[i] != a_arr[i].wrapping_add(b_arr[i]) {
103+
return Err(31);
104+
}
105+
}
106+
Ok(())
107+
}

0 commit comments

Comments
 (0)