diff --git a/.claude/blackboard.md b/.claude/blackboard.md index 8d733430..b6aade00 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -1,3 +1,10 @@ +## 2026-10-16 — `eq_u32_via_lane_to_mask`: the lane-valued twin of `eq_u32_via_to_mask` + +- **Added:** `simd_masking_ops::eq_u32_via_lane_to_mask(index, table, local, out_words)`, re-exported from `ndarray::simd`; row i is set when `index[i] < table.len()` and `table[index[i]] == local[i]`. Parity group 0xD41. +- **Why:** lance-graph D-ASM-11A (`.claude/plans/wcoj-closing-atoms-v1.md` §2). A cyclic join's closing atom compares a row's own lane with a foreign lane read through an address lane; with a constant compare it needs one disjunct per value (measured there: 66 ops at |D| = 16, 1,026 at 256). Consumer: lance-graph-mask-risc `run_pred`. +- **Scalar, stated:** the address comes out of another array. The sibling's note said "no vector gather on any backend"; corrected: an `unsafe`, unbounded `f32` gather exists (`F32x16::gather`), but no bounds-masked `u32` gather-and-compare wrapper, which the zero-fallback contract needs. Whether one pays is unmeasured (lance-graph TD-NO-BOUNDED-U32-GATHER-1). +- **Verified:** 5 unit tests + doctest; disable run (compare every row against `local[0]`) turned 2 of them red; `simd-masking-parity` OK on the v4 arm (`avx512f=true`), which checks portability of the one scalar body, not a backend divergence; clippy `-D warnings` clean. + ## 2026-10-14 — Haiku cards for supervised harvest and mapping - Five Haiku cards added (`harvest-scout`, `census-counter`, `quote-harvester`, `substrate-mapper`, `spec-editor`); rules in `.claude/rules/agent-model-tiers.md`, CLAUDE.md Agent Protocol item 7. No `guarded-executor`: only the orchestrator compiles here. diff --git a/crates/simd-masking-parity/src/lib.rs b/crates/simd-masking-parity/src/lib.rs index 8a6517ad..d316ce5b 100644 --- a/crates/simd-masking-parity/src/lib.rs +++ b/crates/simd-masking-parity/src/lib.rs @@ -27,7 +27,7 @@ //! the index-addressed permutation/scatter family); `0xD3x` the //! index-addressed `masked_group_sum_i32_via` (two-hop zero-fallback); `0xD4x` //! `eq_u32_via_to_mask` (the same index lane, packed as a predicate rather than -//! folded into a sum); `0xExx` the `F64x8` lane compares (all six relations +//! folded into a sum), `0xD41` its lane-valued twin `eq_u32_via_lane_to_mask`; `0xExx` the `F64x8` lane compares (all six relations //! over every pair of 16 IEEE edge values: NaN, ±0, ±inf, subnormal); `0x73x` / `0x74x` / `0x750` the strided 16-byte register match (all sixteen //! bytes; strides 16 / 20 / 32 / 512 from first offsets 0..15, care bits in //! bytes 12..16); `0xFxx` the 16-lane byte vectors `I8x16` / `U8x16` (wrapping @@ -35,6 +35,7 @@ //! `selfcheck()` (the wasm cdylib export, driven by `run.mjs`) both call //! [`run`]. +use ndarray::simd::eq_u32_via_lane_to_mask; use ndarray::simd::{ blend_i32, eq_i32_to_mask, eq_i32_to_mask_under, eq_u32_strided_to_mask, eq_u32_to_mask, eq_u32_to_mask_under, eq_u32_via_to_mask, eq_u64_to_mask, eq_u8_to_mask, ge_i32_to_mask, ge_i32_to_mask_under, ge_u64_to_mask, @@ -1788,6 +1789,20 @@ fn check_gather_scatter_group() -> Result<(), u32> { return Err(0xD40); } + // ── eq_u32_via_lane_to_mask ─────────────────────────────────────── + // The lane-valued twin: each row's addressed entry must equal that + // row's own `local[i]`, not one constant. + let local: Vec = (0..n).map(|_| (rng.next() % 5) as u32).collect(); + let mut via_lane_mask = vec![u64::MAX; out_len]; + eq_u32_via_lane_to_mask(&index2, &table, &local, &mut via_lane_mask); + let want_via_lane_mask = reference_mask(n, out_len, |i| { + let k = index2[i] as usize; + k < table.len() && table[k] == local[i] + }); + if via_lane_mask != want_via_lane_mask { + return Err(0xD41); + } + // ── masked_key_run_count_u32 ───────────────────────────────────── // A key-ORDERED lane (sorted with repeats), folded in uneven tiles // with the carry threaded through; the reference is a plain diff --git a/src/simd.rs b/src/simd.rs index 3c618191..71f40af4 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -841,6 +841,7 @@ pub use crate::simd_masking_ops::{ eq_u32_strided_to_mask, eq_u32_to_mask, eq_u32_to_mask_under, + eq_u32_via_lane_to_mask, eq_u32_via_to_mask, eq_u64_to_mask, eq_u8_to_mask, diff --git a/src/simd_masking_ops.rs b/src/simd_masking_ops.rs index fc3dff9c..f5578b87 100644 --- a/src/simd_masking_ops.rs +++ b/src/simd_masking_ops.rs @@ -3113,11 +3113,14 @@ pub fn fold_bounded_cross_power_sums_tiles( /// /// # Vectorisation, honestly /// -/// **Scalar, and by necessity, not oversight** — same shape as -/// [`mask_gather_u32`]: the address read from `table` is `index[i]`, a -/// value out of another array, so there is no vector gather over -/// individual predicate results on any of this crate's backends. Cost is -/// `O(index.len())`; there is no input mask to skip zero words against. +/// **Scalar** — same shape as [`mask_gather_u32`]: the address read from +/// `table` is `index[i]`, a value out of another array. Several backends +/// carry an `unsafe`, unbounded `f32` gather (`F32x16::gather`), but there is +/// no bounds-masked `u32` gather-and-compare wrapper, and the zero-fallback +/// contract above needs one: an out-of-range index must read as no match, +/// not as memory. Whether such a wrapper pays on random access is +/// unmeasured. Cost is `O(index.len())`; there is no input mask to skip zero +/// words against. /// /// # Panics /// @@ -3165,6 +3168,75 @@ pub fn eq_u32_via_to_mask(index: &[u32], table: &[u32], v: u32, out_words: &mut } } +/// Packs `index[i] < table.len() && table[index[i]] == local[i]` into +/// `out_words`, one bit per row `i < index.len()`, LSB-first: the lane-valued +/// twin of [`eq_u32_via_to_mask`]. The value a row's addressed entry must +/// equal is that row's own `local[i]`, not one constant for every row. +/// +/// This closes a cyclic join in one pass: with `index = rb`, `table = s_dst` +/// and `local = tc`, row `a` is set exactly when `s_dst[rb[a]] == tc[a]`, +/// which no single-constant predicate can express without one disjunct per +/// value. +/// +/// Out-of-range rule, output contract and tail handling are those of +/// [`eq_u32_via_to_mask`]: `index[i] >= table.len()` never matches; every +/// output word is overwritten; bits past `index.len()` and surplus words are +/// written `0`. +/// +/// # Vectorisation, honestly +/// +/// Scalar, for the reason [`eq_u32_via_to_mask`] gives: the address comes out +/// of another array, and there is no bounds-masked `u32` gather-and-compare +/// wrapper on any backend. The scalar body is the realization on every target. +/// +/// # Panics +/// +/// Panics if `local.len() != index.len()`, or if +/// `out_words.len() < mask_words_for(index.len())`. +/// +/// # Examples +/// +/// ``` +/// use ndarray::simd::eq_u32_via_lane_to_mask; +/// +/// let index = [0u32, 1, 5, 1]; +/// let table = [7u32, 3]; +/// let local = [7u32, 4, 7, 3]; +/// let mut out = [u64::MAX]; +/// eq_u32_via_lane_to_mask(&index, &table, &local, &mut out); +/// // row 0: table[0] = 7 = local; row 1: 3 != 4; row 2: out of range; +/// // row 3: table[1] = 3 = local. +/// assert_eq!(out[0], 0b1001); +/// ``` +#[inline] +pub fn eq_u32_via_lane_to_mask(index: &[u32], table: &[u32], local: &[u32], out_words: &mut [u64]) { + let n = index.len(); + assert_eq!(local.len(), n, "eq_u32_via_lane_to_mask: local.len()={} != index.len()={}", local.len(), n); + let words = mask_words_for(n); + assert!( + out_words.len() >= words, + "eq_u32_via_lane_to_mask: out_words.len()={} < required {}", + out_words.len(), + words + ); + + for (w, out_word) in out_words.iter_mut().enumerate().take(words) { + let base = w * 64; + let live = (n - base).min(64); + let mut acc = 0u64; + for lane in 0..live { + let addr = index[base + lane] as usize; + if addr < table.len() && table[addr] == local[base + lane] { + acc |= 1u64 << lane; + } + } + *out_word = acc; + } + for w in out_words.iter_mut().skip(words) { + *w = 0; + } +} + /// Carry of [`masked_key_run_count_u32`] across calls: the key of the run /// that is open at the end of the last call, and whether that run has /// already seen a selected element. Two scalar fields, O(1) and @@ -7042,6 +7114,89 @@ mod tests { eq_u32_via_to_mask(&idx, &table, 0, &mut out); } + // ── eq_u32_via_lane_to_mask ── + + #[test] + fn eq_u32_via_lane_to_mask_matches_naive_reference_across_the_tail() { + for &n in &[0usize, 1, 63, 64, 65, 130, 1000] { + let mut seed = 0x5EED_0D41_u64 ^ n as u64; + let table_len = 13usize; + let table: Vec = (0..table_len) + .map(|_| (splitmix(&mut seed) % 4) as u32) + .collect(); + let idx: Vec = (0..n) + .map(|i| { + if i % 5 == 0 { + (table_len + 2 + i) as u32 + } else { + (splitmix(&mut seed) % table_len as u64) as u32 + } + }) + .collect(); + let local: Vec = (0..n).map(|_| (splitmix(&mut seed) % 4) as u32).collect(); + let want_bits: Vec = (0..n) + .map(|i| { + let k = idx[i] as usize; + k < table.len() && table[k] == local[i] + }) + .collect(); + let want = bits_to_words(&want_bits); + let out_words = n.div_ceil(64).max(1); + let mut out = vec![u64::MAX; out_words + 1]; + eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out); + assert_eq!(&out[..want.len()], &want[..], "mismatch at n={n}"); + assert_eq!(out[out_words], 0, "surplus word must be cleared at n={n}"); + if n >= 10 { + assert!(want_bits.iter().any(|&b| b), "fixture at n={n} has no match"); + assert!(want_bits.iter().any(|&b| !b), "fixture at n={n} has no non-match"); + } + } + } + + /// The compare reads each row's OWN value. A kernel that compared every + /// row against one constant would agree on rows whose local value + /// happens to be that constant, so the fixture gives every row a + /// different local value and every addressed entry the matching one. + #[test] + fn eq_u32_via_lane_to_mask_compares_each_row_against_its_own_lane() { + let table: Vec = (0..64).map(|k| 100 + k).collect(); + let idx: Vec = (0..64).collect(); + let local: Vec = (0..64).map(|i| 100 + i).collect(); + let mut out = [0u64; 1]; + eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out); + assert_eq!(out[0], u64::MAX, "every row equals its own addressed entry"); + // Shift the local lane by one: no row matches any more. + let shifted: Vec = local.iter().map(|v| v + 1).collect(); + eq_u32_via_lane_to_mask(&idx, &table, &shifted, &mut out); + assert_eq!(out[0], 0, "no row equals its addressed entry after the shift"); + } + + #[test] + fn eq_u32_via_lane_to_mask_out_of_range_never_matches() { + let idx = [1u32, 9, u32::MAX]; + let table = [0u32]; + let local = [0u32, 0, 0]; + let mut out = [u64::MAX]; + eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out); + assert_eq!(out[0], 0, "no index names a row of `table`"); + } + + #[test] + #[should_panic(expected = "local.len()")] + fn eq_u32_via_lane_to_mask_rejects_a_short_local_lane() { + let mut out = [0u64; 1]; + eq_u32_via_lane_to_mask(&[0, 0], &[0], &[0], &mut out); + } + + #[test] + #[should_panic(expected = "out_words.len()")] + fn eq_u32_via_lane_to_mask_rejects_short_out_buffer() { + let idx = vec![0u32; 65]; + let local = vec![0u32; 65]; + let mut out = [0u64; 1]; + eq_u32_via_lane_to_mask(&idx, &[0], &local, &mut out); + } + // ── 2026-09-13 additions: the closed comparison family, complement/xor/ // any/all, care-masked register match, masked min/max, blend ──