Skip to content

Commit 13ef875

Browse files
committed
simd: masked_group_sum_i32_via — keyed group-sum with the key read through a fk hop
`out[remap[index[i]]] += values[i]` over the mask, one pass. The shape of `SUM(line.amount) … GROUP BY partner.country`: the key lives on the foreign table and is reached through line.partner_id. The indirection is fused into the reduction so a remapped u32 key lane of N — exactly the intermediate the fold exists to avoid — is never materialised. Zero-fallback at both hops. 6 unit tests (agreement with the plain form on a precomputed key lane at n = 0/1/63/64/65/67/130, both drop arms, a 67-row tail against a naive reference with bad addresses at both hops), fourth arm of parity group 13 (0xD30/0xD31), first-hop guard disable-verified red then green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG
1 parent 4170e58 commit 13ef875

4 files changed

Lines changed: 268 additions & 6 deletions

File tree

‎.claude/blackboard.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Added `mask_gather_u32`, `mask_scatter_or_u32`, `masked_group_sum_i32` to `simd_
44
All three are deliberately scalar bit-walks — permutations/scatters indexed by `index`/`keys` data, not a fixed stride, so none of this crate's backends can vector-load them (same shape as `masked_strided_group_sum`, which says so in its own doc).
55
`masked_strided_group_sum` is NOT a keyed group-by and never was — it sums one record's own byte-groups into a single scalar with no key at all; zero callers of either are affected by this addition.
66
Parity: `check_gather_scatter_group` (0xDxx) in `crates/simd-masking-parity`, against naive per-element references, disable-verified red-then-green.
7+
`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).
78
Consumer: lance-graph-mask-risc `Gather`/`ScatterOr`/`GroupSum` (landing next).
89

910
## 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

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

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
//! family (`eq`/`ne`/`gt`/`ge`/`lt`/`le`, N2/N3 — built earlier but never
2424
//! exercised by this program until now), `0xDxx` the data-indexed
2525
//! permutation/scatter family (`mask_gather_u32`/`mask_scatter_or_u32`/
26-
//! `masked_group_sum_i32`, for lance-graph-mask-risc's Gather/ScatterOr/
27-
//! GroupSum verbs). `main.rs` (native / qemu) and
26+
//! `masked_group_sum_i32`/`masked_group_sum_i32_via`, for
27+
//! 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
2829
//! `selfcheck()` (the wasm cdylib export, driven by `run.mjs`) both call
2930
//! [`run`].
3031
@@ -35,10 +36,11 @@ use ndarray::simd::{
3536
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,
3637
mask_and_assign, mask_andnot, mask_andnot_assign, mask_any, mask_gather_u32, mask_not, mask_not_assign, mask_or,
3738
mask_or_assign, mask_scatter_or_u32, mask_set_range, mask_shift_morton, mask_ternlog, mask_ternlog_assign,
38-
mask_xor, mask_xor_assign, masked_group_sum_i32, masked_max_i32, masked_min_i32, masked_strided_group_sum,
39-
masked_sum_i32, ne_i32_to_mask, ne_i32_to_mask_under, ne_u32_to_mask, ne_u32_to_mask_under, ne_u64_to_mask,
40-
ne_u8_to_mask, ternary_match_strided_to_mask, ternary_match_u32_to_mask, ternary_match_u32_to_mask_under,
41-
ternary_match_u64_to_mask, ternary_match_u64_to_mask_under, ternlog, I32x16, MortonDir, U32x16, U64x8,
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,
4244
};
4345

4446
/// Number of check groups [`run`] executes (for the log line only).
@@ -1223,6 +1225,54 @@ fn check_gather_scatter_group() -> Result<(), u32> {
12231225
if group_out[n_groups] != 0 {
12241226
return Err(0xD21);
12251227
}
1228+
1229+
// ── masked_group_sum_i32_via ─────────────────────────────────────
1230+
// Same n_groups/mask_bits/values as above, but the key is reached
1231+
// through a second-hop `index -> remap` lane rather than a direct
1232+
// `keys` lane — out-of-range addresses are mixed in at BOTH hops.
1233+
let n_partners = 8usize;
1234+
// Every fourth fk is deliberately out of range for `remap`.
1235+
let index: Vec<u32> = (0..n)
1236+
.map(|i| {
1237+
if i % 4 == 0 {
1238+
(n_partners as u64 + 6 + i as u64) as u32
1239+
} else {
1240+
(rng.next() % n_partners as u64) as u32
1241+
}
1242+
})
1243+
.collect();
1244+
// Every third partner deliberately resolves out of range for `out`.
1245+
let remap: Vec<u32> = (0..n_partners)
1246+
.map(|p| {
1247+
if p % 3 == 0 {
1248+
(n_groups as u64 + 4) as u32
1249+
} else {
1250+
(rng.next() % n_groups as u64) as u32
1251+
}
1252+
})
1253+
.collect();
1254+
let mut via_out = vec![-1i64; n_groups + 1]; // garbage + one unreferenced slot
1255+
masked_group_sum_i32_via(&mask_bits, &index, &remap, &values, &mut via_out);
1256+
let mut want_via = vec![0i64; n_groups];
1257+
for i in 0..n {
1258+
if (mask_bits[i / 64] >> (i % 64)) & 1 != 1 {
1259+
continue;
1260+
}
1261+
let fk = index[i] as usize;
1262+
if fk >= remap.len() {
1263+
continue;
1264+
}
1265+
let k = remap[fk] as usize;
1266+
if k < n_groups {
1267+
want_via[k] = want_via[k].wrapping_add(values[i] as i64);
1268+
}
1269+
}
1270+
if via_out[..n_groups] != want_via[..] {
1271+
return Err(0xD30);
1272+
}
1273+
if via_out[n_groups] != 0 {
1274+
return Err(0xD31);
1275+
}
12261276
}
12271277
Ok(())
12281278
}

‎src/simd.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,7 @@ pub use crate::simd_masking_ops::{
826826
mask_xor,
827827
mask_xor_assign,
828828
masked_group_sum_i32,
829+
masked_group_sum_i32_via,
829830
masked_max_i32,
830831
masked_min_i32,
831832
masked_strided_group_sum,

‎src/simd_masking_ops.rs‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,90 @@ pub fn masked_group_sum_i32(mask_words: &[u64], keys: &[u32], values: &[i32], ou
11241124
}
11251125
}
11261126

1127+
/// Like [`masked_group_sum_i32`], but the group key of row `i` is
1128+
/// `remap[index[i]]` — the key lives on a FOREIGN table reached through a
1129+
/// foreign-key lane: `SUM(line.amount) GROUP BY partner.country` is
1130+
/// `index = line.partner_id`, `remap = partner.country`, exactly the
1131+
/// `mask_gather_u32`/`mask_scatter_or_u32` fk-lane shape applied to the
1132+
/// group-sum's key instead of to a mask bit.
1133+
///
1134+
/// **Zero-fallback at BOTH hops, same rule as [`mask_gather_u32`]'s
1135+
/// out-of-range read**: `index[i] >= remap.len()` drops row `i` (the fk
1136+
/// names no row on the foreign table); `remap[index[i]] as usize >=
1137+
/// out.len()` drops it too (the resolved key names no group). Neither is
1138+
/// an error — an unminted address is not a group, at either hop.
1139+
///
1140+
/// # Why the indirection is fused here
1141+
///
1142+
/// Materialising `remap[index[i]]` into its own `Vec<u32>` of length `N`
1143+
/// first and then calling [`masked_group_sum_i32`] on that would allocate
1144+
/// and fully populate exactly the intermediate key lane this fold exists
1145+
/// to avoid — one fused scan over the selected rows costs no more than the
1146+
/// naive two-hop lookup per selected row, with no second array in between.
1147+
///
1148+
/// `out` is fully overwritten (zeroed first); overflow wraps the same way
1149+
/// as [`masked_group_sum_i32`] (widened to `i64`, `wrapping_add`), and the
1150+
/// mask tail is clamped identically.
1151+
///
1152+
/// # Panics
1153+
///
1154+
/// Panics if `index.len() != values.len()`, or if `mask_words.len() <
1155+
/// values.len().div_ceil(64)`.
1156+
///
1157+
/// # Examples
1158+
///
1159+
/// ```
1160+
/// use ndarray::simd::masked_group_sum_i32_via;
1161+
///
1162+
/// // Two lines reference partner 0 (country 0); one references partner 5,
1163+
/// // which is out of range for `remap` and is dropped at the first hop.
1164+
/// let mask = [0b111u64];
1165+
/// let index = [0u32, 0, 5];
1166+
/// let remap = [0u32]; // partner 0 -> country 0
1167+
/// let values = [10i32, 20, 999];
1168+
/// let mut out = [0i64; 1];
1169+
/// masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
1170+
/// assert_eq!(out, [30]);
1171+
/// ```
1172+
#[inline]
1173+
pub fn masked_group_sum_i32_via(mask_words: &[u64], index: &[u32], remap: &[u32], values: &[i32], out: &mut [i64]) {
1174+
assert_eq!(index.len(), values.len(), "masked_group_sum_i32_via: index/values length mismatch");
1175+
let n = values.len();
1176+
let words = mask_words_for(n);
1177+
assert!(
1178+
mask_words.len() >= words,
1179+
"masked_group_sum_i32_via: mask_words.len()={} < required {}",
1180+
mask_words.len(),
1181+
words
1182+
);
1183+
1184+
for o in out.iter_mut() {
1185+
*o = 0;
1186+
}
1187+
for (w, &word) in mask_words.iter().take(words).enumerate() {
1188+
let base = w * 64;
1189+
let mut bits = word;
1190+
// Same tail clamp as masked_group_sum_i32.
1191+
let valid = n - base;
1192+
if valid < 64 {
1193+
bits &= (1u64 << valid) - 1;
1194+
}
1195+
while bits != 0 {
1196+
let lane = bits.trailing_zeros() as usize;
1197+
bits &= bits - 1;
1198+
let i = base + lane;
1199+
let fk = index[i] as usize;
1200+
if fk >= remap.len() {
1201+
continue;
1202+
}
1203+
let k = remap[fk] as usize;
1204+
if k < out.len() {
1205+
out[k] = out[k].wrapping_add(values[i] as i64);
1206+
}
1207+
}
1208+
}
1209+
}
1210+
11271211
// ────────────────────────────────────────────────────────────────────────
11281212
// The closed comparison family + mask complement/xor/any + care-masked
11291213
// register match + masked min/max + blend (the DuckDB-vector-execution set,
@@ -4301,6 +4385,132 @@ mod tests {
43014385
masked_group_sum_i32(&mask, &keys, &values, &mut out);
43024386
}
43034387

4388+
// ── masked_group_sum_i32_via ──
4389+
4390+
#[test]
4391+
fn masked_group_sum_i32_via_agrees_with_the_plain_form_on_a_precomputed_key_lane() {
4392+
for &n in &[0usize, 1, 63, 64, 65, 67, 130] {
4393+
let mut seed = 0x2222_4444_6666_8888u64;
4394+
let n_partners = 9usize;
4395+
let n_groups = 5usize;
4396+
let mask_bits: Vec<bool> = (0..n).map(|_| splitmix(&mut seed) & 1 == 1).collect();
4397+
let mask = bits_to_words(&mask_bits);
4398+
let index: Vec<u32> = (0..n)
4399+
.map(|_| (splitmix(&mut seed) % n_partners as u64) as u32)
4400+
.collect();
4401+
let remap: Vec<u32> = (0..n_partners)
4402+
.map(|_| (splitmix(&mut seed) % n_groups as u64) as u32)
4403+
.collect();
4404+
let values: Vec<i32> = (0..n).map(|_| (splitmix(&mut seed) as i32) / 2).collect();
4405+
4406+
// The naive two-hop key lane, materialised, fed to the plain form.
4407+
let keys: Vec<u32> = index.iter().map(|&fk| remap[fk as usize]).collect();
4408+
let mut want = vec![0i64; n_groups];
4409+
masked_group_sum_i32(&mask, &keys, &values, &mut want);
4410+
4411+
let mut got = vec![-1i64; n_groups];
4412+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut got);
4413+
assert_eq!(got, want, "via mismatched the plain two-hop-materialised form at n={n}");
4414+
}
4415+
}
4416+
4417+
#[test]
4418+
fn masked_group_sum_i32_via_drops_at_the_first_hop_when_index_names_no_partner() {
4419+
// Row 1's fk (5) is out of range for a 2-entry remap — dropped before
4420+
// remap is ever consulted.
4421+
let mask = [0b111u64];
4422+
let index = [0u32, 5, 1];
4423+
let remap = [0u32, 1];
4424+
let values = [10i32, 999, 20];
4425+
let mut out = [0i64; 2];
4426+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
4427+
assert_eq!(out, [10, 20], "the out-of-range fk contributes nothing");
4428+
}
4429+
4430+
#[test]
4431+
fn masked_group_sum_i32_via_drops_at_the_second_hop_when_remap_names_no_group() {
4432+
// Row 1's partner (1) resolves via remap to group 9, out of range for
4433+
// a 2-slot out — dropped after the fk resolves cleanly.
4434+
let mask = [0b111u64];
4435+
let index = [0u32, 1, 0];
4436+
let remap = [0u32, 9]; // partner 1 -> group 9 (out of range)
4437+
let values = [10i32, 999, 20];
4438+
let mut out = [0i64; 2];
4439+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
4440+
assert_eq!(out, [30, 0], "the second-hop out-of-range key contributes nothing");
4441+
}
4442+
4443+
#[test]
4444+
fn masked_group_sum_i32_via_matches_naive_reference_at_the_67_row_tail() {
4445+
let n = 67usize;
4446+
let mut seed = 0xABCD_EF01_2345_6789u64;
4447+
let n_partners = 6usize;
4448+
let n_groups = 4usize;
4449+
let mask_bits: Vec<bool> = (0..n).map(|_| splitmix(&mut seed) & 1 == 1).collect();
4450+
let mask = bits_to_words(&mask_bits);
4451+
// Every fifth fk deliberately out of range for `remap`.
4452+
let index: Vec<u32> = (0..n)
4453+
.map(|i| {
4454+
if i % 5 == 0 {
4455+
(n_partners as u64 + 2 + i as u64) as u32
4456+
} else {
4457+
(splitmix(&mut seed) % n_partners as u64) as u32
4458+
}
4459+
})
4460+
.collect();
4461+
// Every third partner deliberately maps out of range for `out`.
4462+
let remap: Vec<u32> = (0..n_partners)
4463+
.map(|p| {
4464+
if p % 3 == 0 {
4465+
(n_groups as u64 + 1) as u32
4466+
} else {
4467+
(splitmix(&mut seed) % n_groups as u64) as u32
4468+
}
4469+
})
4470+
.collect();
4471+
let values: Vec<i32> = (0..n).map(|_| (splitmix(&mut seed) as i32) / 2).collect();
4472+
4473+
let mut want = vec![0i64; n_groups];
4474+
for i in 0..n {
4475+
if !mask_bits[i] {
4476+
continue;
4477+
}
4478+
let fk = index[i] as usize;
4479+
if fk >= remap.len() {
4480+
continue;
4481+
}
4482+
let k = remap[fk] as usize;
4483+
if k < n_groups {
4484+
want[k] = want[k].wrapping_add(values[i] as i64);
4485+
}
4486+
}
4487+
let mut got = vec![-1i64; n_groups];
4488+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut got);
4489+
assert_eq!(got, want);
4490+
}
4491+
4492+
#[test]
4493+
#[should_panic(expected = "index/values length mismatch")]
4494+
fn masked_group_sum_i32_via_rejects_mismatched_index_and_values() {
4495+
let mask = [0b1u64];
4496+
let index = [0u32, 1];
4497+
let remap = [0u32];
4498+
let values = [10i32];
4499+
let mut out = [0i64; 1];
4500+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
4501+
}
4502+
4503+
#[test]
4504+
#[should_panic(expected = "mask_words.len()")]
4505+
fn masked_group_sum_i32_via_rejects_short_mask_buffer() {
4506+
let mask = [0u64; 1]; // covers only 64 rows
4507+
let index = vec![0u32; 65];
4508+
let remap = vec![0u32; 1];
4509+
let values = vec![0i32; 65];
4510+
let mut out = [0i64; 1];
4511+
masked_group_sum_i32_via(&mask, &index, &remap, &values, &mut out);
4512+
}
4513+
43044514
// ── 2026-09-13 additions: the closed comparison family, complement/xor/
43054515
// any/all, care-masked register match, masked min/max, blend ──
43064516

0 commit comments

Comments
 (0)