Skip to content

Commit b4bda4c

Browse files
committed
simd: eq_u32_via_to_mask — an equality predicate read through a foreign key
`out[i] = fk[i] < foreign.len() && foreign[fk[i]] == v`, one pass, zero fallback on an out-of-range key. This is the join filter in factored form: the consumer needs neither a predicate plane over the foreign table nor a gathered mask over its own rows. Parity check 0xD40 in group 13. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG
1 parent ad8bfa6 commit b4bda4c

4 files changed

Lines changed: 223 additions & 11 deletions

File tree

‎.claude/blackboard.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Parity: `check_gather_scatter_group` (0xDxx) in `crates/simd-masking-parity`, ag
77
`masked_group_sum_i32_via(mask, index, remap, values, out)` — the same one-pass keyed sum with the key read through a foreign-key hop (`SUM(line.amount) GROUP BY partner.country`); the indirection is fused so no remapped key lane of N is ever materialised. Fourth arm of the same parity group (0xD3x).
88
Consumer: lance-graph-mask-risc `Gather`/`ScatterOr`/`GroupSum` (landing next).
99
⊘ CORRECTED (operator ruling, same day): `mask_scatter_or_u32`, `masked_group_sum_i32`, and `masked_group_sum_i32_via` no longer zero `out`/`out_words` on entry — a fold kernel accumulates into the caller's demanded sink, and the caller zeroes it once. `mask_gather_u32` is unaffected (its destination is its own output tile). Each function's doc comment now states "accumulates into `out`; the caller zeroes `out` once before the first call"; the surplus/unreferenced-slot assertions in the tail tests were reworded to "untouched, stays as the caller left it" rather than "cleared". Every unit test whose `out` buffer relied on the old zeroing was given an explicit zeroed prefill in its arrange step, and each of the three functions got two new two-sided tests (a preloaded slot/bit that survives untouched alongside the call's own contribution landing correctly; a second call with a different mask summing/unioning on top of the first) — disable-verified red-then-green by temporarily restoring the whole-buffer zero in each function in turn. The parity crate's `check_gather_scatter_group` (0xDxx) now zeroes `out`/`out2` explicitly before its from-zero reference checks and adds one accumulation check per function (0xD11/0xD22/0xD32) that preloads `out` and asserts the call adds on top.
10+
Fifth arm, same parity group: `eq_u32_via_to_mask(fk, foreign, v, out_words)` — the same fk lane evaluated as a join-filter PREDICATE packed into a mask rather than folded into a sum (`fk[i] < foreign.len() && foreign[fk[i]] == v`, zero-fallback at the out-of-range hop, full overwrite of tail/surplus like `mask_gather_u32`); parity check `0xD40`, disable-verified red-then-green by flipping `==` to `!=` in the kernel and back.
1011

1112
## 2026-09-17 (19) — G8 named: a tree-depth column (`lzcnt(bswap(x)) >> 2`) is the missing primitive for basin-local ranking; popcount is only its tie-break
1213

‎crates/simd-masking-parity/src/lib.rs‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,24 @@
2525
//! permutation/scatter family (`mask_gather_u32`/`mask_scatter_or_u32`/
2626
//! `masked_group_sum_i32`/`masked_group_sum_i32_via`, for
2727
//! lance-graph-mask-risc's Gather/ScatterOr/GroupSum verbs); `0xD3x` the
28-
//! fk-indirected `masked_group_sum_i32_via` (two-hop zero-fallback). `main.rs` (native / qemu) and
28+
//! fk-indirected `masked_group_sum_i32_via` (two-hop zero-fallback); `0xD4x`
29+
//! `eq_u32_via_to_mask` (the same fk lane, packed as a predicate rather than
30+
//! folded into a sum). `main.rs` (native / qemu) and
2931
//! `selfcheck()` (the wasm cdylib export, driven by `run.mjs`) both call
3032
//! [`run`].
3133
3234
use ndarray::simd::{
3335
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,
34-
eq_u64_to_mask, eq_u8_to_mask, ge_i32_to_mask, ge_i32_to_mask_under, ge_u64_to_mask, ge_u8_to_mask, gt_i32_to_mask,
35-
gt_i32_to_mask_under, gt_u64_to_mask, gt_u8_to_mask, le_i32_to_mask, le_i32_to_mask_under, le_u64_to_mask,
36-
le_u8_to_mask, lt_i32_to_mask, lt_i32_to_mask_under, lt_u64_to_mask, lt_u8_to_mask, mask_all, mask_and,
37-
mask_and_assign, mask_andnot, mask_andnot_assign, mask_any, mask_gather_u32, mask_not, mask_not_assign, mask_or,
38-
mask_or_assign, mask_scatter_or_u32, mask_set_range, mask_shift_morton, mask_ternlog, mask_ternlog_assign,
39-
mask_xor, mask_xor_assign, masked_group_sum_i32, masked_group_sum_i32_via, masked_max_i32, masked_min_i32,
40-
masked_strided_group_sum, masked_sum_i32, ne_i32_to_mask, ne_i32_to_mask_under, ne_u32_to_mask,
41-
ne_u32_to_mask_under, ne_u64_to_mask, ne_u8_to_mask, ternary_match_strided_to_mask, ternary_match_u32_to_mask,
42-
ternary_match_u32_to_mask_under, ternary_match_u64_to_mask, ternary_match_u64_to_mask_under, ternlog, I32x16,
43-
MortonDir, U32x16, U64x8,
36+
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,
37+
ge_u8_to_mask, gt_i32_to_mask, gt_i32_to_mask_under, gt_u64_to_mask, gt_u8_to_mask, le_i32_to_mask,
38+
le_i32_to_mask_under, le_u64_to_mask, le_u8_to_mask, lt_i32_to_mask, lt_i32_to_mask_under, lt_u64_to_mask,
39+
lt_u8_to_mask, mask_all, mask_and, mask_and_assign, mask_andnot, mask_andnot_assign, mask_any, mask_gather_u32,
40+
mask_not, mask_not_assign, mask_or, mask_or_assign, mask_scatter_or_u32, mask_set_range, mask_shift_morton,
41+
mask_ternlog, mask_ternlog_assign, mask_xor, mask_xor_assign, masked_group_sum_i32, masked_group_sum_i32_via,
42+
masked_max_i32, masked_min_i32, masked_strided_group_sum, masked_sum_i32, ne_i32_to_mask, ne_i32_to_mask_under,
43+
ne_u32_to_mask, ne_u32_to_mask_under, ne_u64_to_mask, ne_u8_to_mask, ternary_match_strided_to_mask,
44+
ternary_match_u32_to_mask, ternary_match_u32_to_mask_under, ternary_match_u64_to_mask,
45+
ternary_match_u64_to_mask_under, ternlog, I32x16, MortonDir, U32x16, U64x8,
4446
};
4547

4648
/// Number of check groups [`run`] executes (for the log line only).
@@ -1331,6 +1333,33 @@ fn check_gather_scatter_group() -> Result<(), u32> {
13311333
return Err(0xD32);
13321334
}
13331335
}
1336+
1337+
// ── eq_u32_via_to_mask ────────────────────────────────────────────
1338+
// A predicate evaluated through the same fk lane `masked_group_sum_i32_via`
1339+
// uses for its key, but packed into a bitmask rather than folded into a
1340+
// sum: `fk[i] < foreign.len() && foreign[fk[i]] == v`.
1341+
let foreign_len = 9usize;
1342+
let foreign: Vec<u32> = (0..foreign_len).map(|_| (rng.next() % 5) as u32).collect();
1343+
let v = 2u32;
1344+
// Every fourth key is deliberately out of range for `foreign`.
1345+
let fk: Vec<u32> = (0..n)
1346+
.map(|i| {
1347+
if i % 4 == 0 {
1348+
(foreign_len as u64 + 6 + i as u64) as u32
1349+
} else {
1350+
(rng.next() % foreign_len as u64) as u32
1351+
}
1352+
})
1353+
.collect();
1354+
let mut via_mask = vec![u64::MAX; out_len]; // dirty, over-long
1355+
eq_u32_via_to_mask(&fk, &foreign, v, &mut via_mask);
1356+
let want_via_mask = reference_mask(n, out_len, |i| {
1357+
let k = fk[i] as usize;
1358+
k < foreign.len() && foreign[k] == v
1359+
});
1360+
if via_mask != want_via_mask {
1361+
return Err(0xD40);
1362+
}
13341363
}
13351364
Ok(())
13361365
}

‎src/simd.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ pub use crate::simd_masking_ops::{
789789
eq_u32_strided_to_mask,
790790
eq_u32_to_mask,
791791
eq_u32_to_mask_under,
792+
eq_u32_via_to_mask,
792793
eq_u64_to_mask,
793794
eq_u8_to_mask,
794795
ge_i32_to_mask,

‎src/simd_masking_ops.rs‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,94 @@ pub fn masked_group_sum_i32_via(mask_words: &[u64], index: &[u32], remap: &[u32]
12071207
}
12081208
}
12091209

1210+
/// Packs `fk[i] < foreign.len() && foreign[fk[i]] == v` into `out_words`,
1211+
/// one bit per row `i < fk.len()`, LSB-first — a join-filter predicate
1212+
/// evaluated **through a foreign key**, with no gathered mask and no
1213+
/// materialised foreign predicate plane in between.
1214+
///
1215+
/// `WHERE partner.country = v` filtered from the `line` side, without
1216+
/// first computing `country_of_line[i] = country[partner_id[i]]` into its
1217+
/// own array and then comparing that: `fk = line.partner_id`,
1218+
/// `foreign = partner.country`, and this fuses the gather-then-compare
1219+
/// into one pass over `fk`, the same fusion [`masked_group_sum_i32_via`]
1220+
/// applies to a fk-indirected group key rather than a fk-indirected
1221+
/// predicate.
1222+
///
1223+
/// **Zero-fallback, same rule as [`mask_gather_u32`]'s out-of-range
1224+
/// read**: `fk[i] >= foreign.len()` means row `i`'s key names no foreign
1225+
/// row, so it does not match — not an error, not a panic, just `false` for
1226+
/// that bit. A key that names no row is not a match, the same way an
1227+
/// unminted classid is not a class.
1228+
///
1229+
/// `out_words` is **fully overwritten**, not OR-ed into; trailing bits
1230+
/// beyond `fk.len()`, and any surplus words past `mask_words_for(fk.len())`,
1231+
/// are written `0` — this writes exactly its own output tile and nothing
1232+
/// past it, the same contract as [`mask_gather_u32`].
1233+
///
1234+
/// # Why this lives HERE
1235+
///
1236+
/// [`mask_gather_u32`] reads a *mask bit* through an index; this reads an
1237+
/// *equality predicate* through an index, and belongs beside it for the
1238+
/// same reason: a consumer hand-rolling `foreign[fk[i]] == v` in a loop is
1239+
/// exactly the polyfill bypass the "all SIMD from `ndarray::simd`"
1240+
/// invariant exists to prevent, and it is the gather half of
1241+
/// [`masked_group_sum_i32_via`]'s two-hop shape applied to a predicate
1242+
/// instead of a sum.
1243+
///
1244+
/// # Vectorisation, honestly
1245+
///
1246+
/// **Scalar, and by necessity, not oversight** — same shape as
1247+
/// [`mask_gather_u32`]: the address read from `foreign` is `fk[i]`, a
1248+
/// value out of another array, so there is no vector gather over
1249+
/// individual predicate results on any of this crate's backends. Cost is
1250+
/// `O(fk.len())`; there is no input mask to skip zero words against.
1251+
///
1252+
/// # Panics
1253+
///
1254+
/// Panics if `out_words.len() < mask_words_for(fk.len())`.
1255+
///
1256+
/// # Examples
1257+
///
1258+
/// ```
1259+
/// use ndarray::simd::eq_u32_via_to_mask;
1260+
///
1261+
/// // partner 0 -> country 7, partner 1 -> country 3; row 2's fk (5) is
1262+
/// // out of range for `foreign` and never matches.
1263+
/// let fk = [0u32, 1, 5, 0];
1264+
/// let foreign = [7u32, 3];
1265+
/// let mut out = [u64::MAX]; // dirty tail must be overwritten
1266+
/// eq_u32_via_to_mask(&fk, &foreign, 7, &mut out);
1267+
/// // rows 0 and 3 resolve to country 7; row 1 resolves to 3; row 2 drops.
1268+
/// assert_eq!(out[0], 0b1001);
1269+
/// ```
1270+
#[inline]
1271+
pub fn eq_u32_via_to_mask(fk: &[u32], foreign: &[u32], v: u32, out_words: &mut [u64]) {
1272+
let n = fk.len();
1273+
let words = mask_words_for(n);
1274+
assert!(
1275+
out_words.len() >= words,
1276+
"eq_u32_via_to_mask: out_words.len()={} < required {}",
1277+
out_words.len(),
1278+
words
1279+
);
1280+
1281+
for (w, out_word) in out_words.iter_mut().enumerate().take(words) {
1282+
let base = w * 64;
1283+
let live = (n - base).min(64);
1284+
let mut acc = 0u64;
1285+
for lane in 0..live {
1286+
let key = fk[base + lane] as usize;
1287+
if key < foreign.len() && foreign[key] == v {
1288+
acc |= 1u64 << lane;
1289+
}
1290+
}
1291+
*out_word = acc;
1292+
}
1293+
for w in out_words.iter_mut().skip(words) {
1294+
*w = 0;
1295+
}
1296+
}
1297+
12101298
// ────────────────────────────────────────────────────────────────────────
12111299
// The closed comparison family + mask complement/xor/any + care-masked
12121300
// register match + masked min/max + blend (the DuckDB-vector-execution set,
@@ -4582,6 +4670,99 @@ mod tests {
45824670
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
45834671
}
45844672

4673+
// ── eq_u32_via_to_mask ──
4674+
4675+
fn naive_eq_via(fk: &[u32], foreign: &[u32], v: u32) -> Vec<bool> {
4676+
fk.iter()
4677+
.map(|&k| {
4678+
let k = k as usize;
4679+
k < foreign.len() && foreign[k] == v
4680+
})
4681+
.collect()
4682+
}
4683+
4684+
#[test]
4685+
fn eq_u32_via_to_mask_matches_naive_reference_across_the_tail() {
4686+
for &n in &[0usize, 1, 63, 64, 65, 130, 1000] {
4687+
let mut seed = 0xACE1_2345_6789_BEEFu64;
4688+
let foreign_len = 17usize;
4689+
let foreign: Vec<u32> = (0..foreign_len)
4690+
.map(|_| (splitmix(&mut seed) % 5) as u32)
4691+
.collect();
4692+
let v = 2u32;
4693+
// A third of keys are deliberately out of range; the rest hit
4694+
// `foreign`, so both the match and no-match arms are genuinely
4695+
// exercised (not merely plausible).
4696+
let fk: Vec<u32> = (0..n)
4697+
.map(|i| {
4698+
if i % 3 == 0 {
4699+
(foreign_len as u64 + 3 + i as u64) as u32
4700+
} else {
4701+
(splitmix(&mut seed) % foreign_len as u64) as u32
4702+
}
4703+
})
4704+
.collect();
4705+
let want_bits = naive_eq_via(&fk, &foreign, v);
4706+
let want = bits_to_words(&want_bits);
4707+
let out_words = n.div_ceil(64).max(1);
4708+
let mut out = vec![0xFFFF_FFFF_FFFF_FFFFu64; out_words + 1]; // dirty, over-long
4709+
eq_u32_via_to_mask(&fk, &foreign, v, &mut out);
4710+
assert_eq!(&out[..want.len()], &want[..], "eq_u32_via_to_mask mismatch at n={n}");
4711+
assert_eq!(out[out_words], 0, "surplus word must be cleared at n={n}");
4712+
if n >= 10 {
4713+
// Anti-vacuity (skipped at tiny n, where a single fixture
4714+
// cannot be relied on to hit both arms): this fixture must
4715+
// actually contain both a match and a non-match, or the
4716+
// comparison above proves nothing about which arm is
4717+
// exercised.
4718+
assert!(want_bits.iter().any(|&b| b), "fixture at n={n} has no matching row at all");
4719+
assert!(want_bits.iter().any(|&b| !b), "fixture at n={n} has no non-matching row at all");
4720+
}
4721+
}
4722+
}
4723+
4724+
#[test]
4725+
fn eq_u32_via_to_mask_out_of_range_fk_never_matches_even_when_foreign_0_equals_v() {
4726+
let fk = [5u32, 10, 100, u32::MAX];
4727+
let foreign = [7u32]; // foreign[0] == v, but every fk above is >= 1
4728+
let mut out = [0u64; 1];
4729+
eq_u32_via_to_mask(&fk, &foreign, 7, &mut out);
4730+
assert_eq!(out[0], 0, "every fk names no row in `foreign`, so nothing may match");
4731+
}
4732+
4733+
#[test]
4734+
fn eq_u32_via_to_mask_tail_and_surplus_words_are_cleared_not_left_dirty() {
4735+
let fk = [0u32, 0, 0, 0, 0]; // n = 5, one word; foreign[0] == v for all
4736+
let foreign = [9u32];
4737+
let mut out = [0xFFFF_FFFF_FFFF_FFFFu64; 3]; // one live word + two surplus
4738+
eq_u32_via_to_mask(&fk, &foreign, 9, &mut out);
4739+
assert_eq!(out[0], 0b11111, "the five live rows should be set");
4740+
assert_eq!(out[0] & !0b11111, 0, "bits past n=5 in the live word must be zero, not dirty");
4741+
assert_eq!(out[1], 0, "surplus word 1 must be cleared");
4742+
assert_eq!(out[2], 0, "surplus word 2 must be cleared");
4743+
}
4744+
4745+
#[test]
4746+
fn eq_u32_via_to_mask_empty_foreign_yields_all_zero_mask_for_nonempty_fk() {
4747+
// Every fk names a row, but `foreign` is empty, so every key is out
4748+
// of range: the whole mask must be false, not a panic and not a
4749+
// vacuous "unreachable, so anything goes".
4750+
let fk = [0u32, 1, 2, 3, 4, 5, 6, 7];
4751+
let foreign: [u32; 0] = [];
4752+
let mut out = [0xFFFF_FFFF_FFFF_FFFFu64; 1];
4753+
eq_u32_via_to_mask(&fk, &foreign, 0, &mut out);
4754+
assert_eq!(out[0], 0, "an empty foreign table matches nothing");
4755+
}
4756+
4757+
#[test]
4758+
#[should_panic(expected = "out_words.len()")]
4759+
fn eq_u32_via_to_mask_rejects_short_out_buffer() {
4760+
let fk = vec![0u32; 65]; // needs 2 words
4761+
let foreign = [0u32];
4762+
let mut out = [0u64; 1];
4763+
eq_u32_via_to_mask(&fk, &foreign, 0, &mut out);
4764+
}
4765+
45854766
// ── 2026-09-13 additions: the closed comparison family, complement/xor/
45864767
// any/all, care-masked register match, masked min/max, blend ──
45874768

0 commit comments

Comments
 (0)