From cdd49ede733ea4cbd0ac10766c3afadea85a3817 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:40:58 +0000 Subject: [PATCH 1/2] simd: seeded group sum tells an empty group from a zero sum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit masked_group_sum_seeded_i32 and _via: a slot still holding the caller's `empty` marker has seen no row, so the first selected row REPLACES the marker and later rows add (wrapping_add, as the plain sum). The plain sum cannot separate an empty group from one whose rows cancel to 0. `empty` = i64::MIN is the intended marker: a sum of n i32 stays strictly above it for every n < 2^32, which leaves a symmetric real range ±(2^63 - 1). The doc names the one reachable case (exactly 2^32 rows of i32::MIN) so a caller with that row count bounds it or picks another marker. One walker, as the rest of the family: two named closures over group_walk. Tests: both addresses against the scalar oracle at every length, plus the cancel-vs-empty case and both VIA drops. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- src/simd.rs | 2 + src/simd_masking_ops.rs | 131 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/simd.rs b/src/simd.rs index 2a3f064e..eec1608e 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -834,6 +834,8 @@ pub use crate::simd_masking_ops::{ masked_group_min_i32_via, masked_group_sum_i32, masked_group_sum_i32_via, + masked_group_sum_seeded_i32, + masked_group_sum_seeded_i32_via, masked_key_run_count_u32, masked_max_i32, masked_min_i32, diff --git a/src/simd_masking_ops.rs b/src/simd_masking_ops.rs index 7508f3d5..41cc4f15 100644 --- a/src/simd_masking_ops.rs +++ b/src/simd_masking_ops.rs @@ -1234,6 +1234,85 @@ pub fn masked_group_sum_i32_via(mask_words: &[u64], index: &[u32], table: &[u32] ); } +/// [`masked_group_sum_i32`] that can tell an EMPTY group from a group that +/// sums to zero. A slot still holding the caller's `empty` marker has seen +/// no row: the first selected row landing there REPLACES the marker, and +/// every later row adds to it (`wrapping_add`, as the plain sum). +/// +/// The plain sum cannot make this distinction: an empty group and a group +/// whose rows cancel (`+5`, `-5`) both end at `0`. Seed `out` with `empty` +/// once, and afterwards a slot equals `empty` exactly when no row reached it. +/// +/// # Choosing `empty` +/// +/// `empty` must be a value no real partial sum can reach. `i64::MIN` is the +/// natural choice: a sum of `n` `i32`s is bounded by `n × 2^31`, which stays +/// strictly above `i64::MIN` for every `n < 2^32`. Reserving it leaves a +/// symmetric range `±(2^63 − 1)` for real sums, closed under negation. A +/// caller that sums `2^32` or more rows into one slot must pick another +/// marker or bound its row count, because at exactly `2^32` rows of +/// `i32::MIN` a real sum lands on `i64::MIN`. +/// +/// # Panics +/// +/// Panics if `keys.len() != values.len()`, or if `mask_words.len() < +/// values.len().div_ceil(64)`. +/// +/// # Examples +/// +/// ``` +/// use ndarray::simd::masked_group_sum_seeded_i32; +/// +/// let mask = [0b0111u64]; // rows 0, 1, 2 +/// let keys = [0u32, 0, 2, 1]; +/// let values = [5i32, -5, 7, 9]; +/// let mut out = [i64::MIN; 3]; +/// masked_group_sum_seeded_i32(&mask, &keys, &values, i64::MIN, &mut out); +/// // group 0 cancels to 0 (non-empty); group 1 saw no selected row. +/// assert_eq!(out, [0, i64::MIN, 7]); +/// ``` +#[inline] +pub fn masked_group_sum_seeded_i32(mask_words: &[u64], keys: &[u32], values: &[i32], empty: i64, out: &mut [i64]) { + assert_eq!(keys.len(), values.len(), "masked_group_sum_seeded_i32: keys/values length mismatch"); + group_walk( + "masked_group_sum_seeded_i32", + mask_words, + values.len(), + GroupKeyAddr::Resident(keys), + out, + |slot, i| { + let v = values[i] as i64; + *slot = if *slot == empty { v } else { slot.wrapping_add(v) }; + }, + ); +} + +/// [`masked_group_sum_seeded_i32`] with the group of row `i` read as +/// `table[index[i]]`, zero-fallback at both hops exactly as +/// [`masked_group_sum_i32_via`]. The same `empty` marker rule applies. +/// +/// # Panics +/// +/// Panics if `index.len() != values.len()`, or if `mask_words.len() < +/// values.len().div_ceil(64)`. +#[inline] +pub fn masked_group_sum_seeded_i32_via( + mask_words: &[u64], index: &[u32], table: &[u32], values: &[i32], empty: i64, out: &mut [i64], +) { + assert_eq!(index.len(), values.len(), "masked_group_sum_seeded_i32_via: index/values length mismatch"); + group_walk( + "masked_group_sum_seeded_i32_via", + mask_words, + values.len(), + GroupKeyAddr::Via { index, table }, + out, + |slot, i| { + let v = values[i] as i64; + *slot = if *slot == empty { v } else { slot.wrapping_add(v) }; + }, + ); +} + // ── The keyed-reduction family ─────────────────────────────────────────── // // Every keyed reduction in this file is the SAME walk: visit the rows the @@ -6864,6 +6943,58 @@ mod group_family_tests { assert_eq!(s, [i64::MIN]); } + /// The seeded sum equals the scalar oracle on both addresses at every + /// length, where a group is present exactly when some selected row + /// resolved to it; an empty group keeps the marker. + #[test] + fn seeded_sum_matches_the_reference_and_marks_empty_groups() { + const E: i64 = i64::MIN; + for &n in LENS { + let fx = fixture(n, 0xface ^ n as u64); + for (name, key) in [("resident", key_resident as fn(&Fx, usize) -> Option), ("via", key_via)] { + let mut want = vec![E; GROUPS]; + for i in 0..n { + if selected(&fx, i) { + if let Some(k) = key(&fx, i) { + let v = fx.values[i] as i64; + want[k] = if want[k] == E { v } else { want[k] + v }; + } + } + } + let mut got = vec![E; GROUPS]; + if name == "resident" { + masked_group_sum_seeded_i32(&fx.mask, &fx.keys, &fx.values, E, &mut got); + } else { + masked_group_sum_seeded_i32_via(&fx.mask, &fx.index, &fx.table, &fx.values, E, &mut got); + } + assert_eq!(got, want, "{name} n={n}"); + } + } + } + + /// The case the plain sum cannot express: a group whose rows cancel to 0 + /// is NOT empty, and a group no row reached IS. Both hops of the VIA + /// path drop a row rather than mark its group present. + #[test] + fn a_cancelling_group_is_present_and_an_unreached_group_is_empty() { + let mask = [0b1111u64]; + let keys = [0u32, 0, 9, 2]; + let values = [5i32, -5, 1, 3]; + let mut out = [i64::MIN; 3]; + masked_group_sum_seeded_i32(&mask, &keys, &values, i64::MIN, &mut out); + assert_eq!(out, [0, i64::MIN, 3], "key 9 is past the universe and is dropped"); + + let mut plain = [0i64; 3]; + masked_group_sum_i32(&mask, &keys, &values, &mut plain); + assert_eq!(plain[0], plain[1], "the plain sum cannot tell cancel from empty"); + + let index = [0u32, 7, 1]; + let table = [1u32, 5]; + let mut via = [i64::MIN; 3]; + masked_group_sum_seeded_i32_via(&[0b111], &index, &table, &[4, 8, 6], i64::MIN, &mut via); + assert_eq!(via, [i64::MIN, 4, i64::MIN], "index 7 misses the table; table 5 misses the universe"); + } + /// A mask shorter than the row count is refused, and the panic names the /// public function the caller used, not the shared walker. #[test] From 9ca80485c4314d93bda9c202ff41dc0593ece907 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:03:03 +0000 Subject: [PATCH 2/2] simd: name the reserved-code sum as a `_sym` family, never a caller-chosen marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit masked_group_sum_seeded_i32{,_via}(…, empty, …) let any caller pick any marker, so a full-range caller could reserve a real value by accident. The reservation is now opt-in by NAME only: - masked_group_sum_sym_i32{,_via}: symmetric range ±(2^63−1), with SYM_EMPTY_I64 = i64::MIN reserved as "no row reached this group" — the 4-bit −7..+7 + NaN reading, never the −8..+7 one. - Every unsuffixed reduction keeps the full two's-complement range and never treats i64::MIN specially. A new test pins that: the full-range sum adds to an i64::MIN slot where the _sym sum replaces it. The rule is stated once, on SYM_EMPTY_I64. One fold closure serves both key addresses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- src/simd.rs | 5 +- src/simd_masking_ops.rs | 123 ++++++++++++++++++++++++++-------------- 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/src/simd.rs b/src/simd.rs index eec1608e..eefdcbaa 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -834,8 +834,8 @@ pub use crate::simd_masking_ops::{ masked_group_min_i32_via, masked_group_sum_i32, masked_group_sum_i32_via, - masked_group_sum_seeded_i32, - masked_group_sum_seeded_i32_via, + masked_group_sum_sym_i32, + masked_group_sum_sym_i32_via, masked_key_run_count_u32, masked_max_i32, masked_min_i32, @@ -855,6 +855,7 @@ pub use crate::simd_masking_ops::{ ternary_match_u64_to_mask_under, KeyRunCarry, MortonDir, + SYM_EMPTY_I64, }; // The popcount that closes the loop on the masks above: `mask_count` in ABI // terms. Already public at `ndarray::bitwise::popcount_batch_u64`; re-exported diff --git a/src/simd_masking_ops.rs b/src/simd_masking_ops.rs index 41cc4f15..8a2c4945 100644 --- a/src/simd_masking_ops.rs +++ b/src/simd_masking_ops.rs @@ -1234,24 +1234,40 @@ pub fn masked_group_sum_i32_via(mask_words: &[u64], index: &[u32], table: &[u32] ); } -/// [`masked_group_sum_i32`] that can tell an EMPTY group from a group that -/// sums to zero. A slot still holding the caller's `empty` marker has seen -/// no row: the first selected row landing there REPLACES the marker, and -/// every later row adds to it (`wrapping_add`, as the plain sum). +/// The reserved code of the `_sym` family: the one `i64` a `_sym` fold +/// never produces as a real value, marking "no row reached this group". /// -/// The plain sum cannot make this distinction: an empty group and a group -/// whose rows cancel (`+5`, `-5`) both end at `0`. Seed `out` with `empty` -/// once, and afterwards a slot equals `empty` exactly when no row reached it. +/// # Full range versus `_sym` (NORMATIVE for this file) /// -/// # Choosing `empty` +/// Every masked reduction in this file WITHOUT a `_sym` suffix uses the full +/// two's-complement range: every `i64` it writes is a real value, `i64::MIN` +/// included. Nothing there reserves a code, so a caller that treats the +/// output as plain integers is always right. /// -/// `empty` must be a value no real partial sum can reach. `i64::MIN` is the -/// natural choice: a sum of `n` `i32`s is bounded by `n × 2^31`, which stays -/// strictly above `i64::MIN` for every `n < 2^32`. Reserving it leaves a -/// symmetric range `±(2^63 − 1)` for real sums, closed under negation. A -/// caller that sums `2^32` or more rows into one slot must pick another -/// marker or bound its row count, because at exactly `2^32` rows of -/// `i32::MIN` a real sum lands on `i64::MIN`. +/// A function WITH the `_sym` suffix uses the symmetric range +/// `±(2^63 − 1)` and reserves `i64::MIN` (this constant) as "empty", the way +/// a 4-bit code read as `−7..+7` frees `−8` for a NaN. The reservation is +/// opt-in by name only: no full-range function ever interprets `i64::MIN` +/// specially, and a `_sym` output must never be handed to code that assumes +/// full range without first mapping `SYM_EMPTY_I64` away. +pub const SYM_EMPTY_I64: i64 = i64::MIN; + +/// [`masked_group_sum_i32`] in the symmetric range: tells an EMPTY group +/// from a group that sums to zero. A slot still holding [`SYM_EMPTY_I64`] +/// has seen no row: the first selected row landing there REPLACES the +/// marker, and every later row adds to it (`wrapping_add`, as the plain sum). +/// +/// The full-range sum cannot make this distinction: an empty group and a +/// group whose rows cancel (`+5`, `-5`) both end at `0`. Fill `out` with +/// [`SYM_EMPTY_I64`] once; afterwards a slot equals it exactly when no row +/// reached it. +/// +/// # Row bound +/// +/// A sum of `n` `i32`s is bounded by `n × 2^31`, which stays strictly above +/// `i64::MIN` for every `n < 2^32`. At exactly `2^32` rows of `i32::MIN` a +/// real sum would land on the reserved code, so a caller folding `2^32` or +/// more rows into one slot must bound its row count. /// /// # Panics /// @@ -1261,58 +1277,63 @@ pub fn masked_group_sum_i32_via(mask_words: &[u64], index: &[u32], table: &[u32] /// # Examples /// /// ``` -/// use ndarray::simd::masked_group_sum_seeded_i32; +/// use ndarray::simd::{masked_group_sum_sym_i32, SYM_EMPTY_I64}; /// /// let mask = [0b0111u64]; // rows 0, 1, 2 /// let keys = [0u32, 0, 2, 1]; /// let values = [5i32, -5, 7, 9]; -/// let mut out = [i64::MIN; 3]; -/// masked_group_sum_seeded_i32(&mask, &keys, &values, i64::MIN, &mut out); +/// let mut out = [SYM_EMPTY_I64; 3]; +/// masked_group_sum_sym_i32(&mask, &keys, &values, &mut out); /// // group 0 cancels to 0 (non-empty); group 1 saw no selected row. -/// assert_eq!(out, [0, i64::MIN, 7]); +/// assert_eq!(out, [0, SYM_EMPTY_I64, 7]); /// ``` #[inline] -pub fn masked_group_sum_seeded_i32(mask_words: &[u64], keys: &[u32], values: &[i32], empty: i64, out: &mut [i64]) { - assert_eq!(keys.len(), values.len(), "masked_group_sum_seeded_i32: keys/values length mismatch"); +pub fn masked_group_sum_sym_i32(mask_words: &[u64], keys: &[u32], values: &[i32], out: &mut [i64]) { + assert_eq!(keys.len(), values.len(), "masked_group_sum_sym_i32: keys/values length mismatch"); group_walk( - "masked_group_sum_seeded_i32", + "masked_group_sum_sym_i32", mask_words, values.len(), GroupKeyAddr::Resident(keys), out, - |slot, i| { - let v = values[i] as i64; - *slot = if *slot == empty { v } else { slot.wrapping_add(v) }; - }, + sym_sum_fold(values), ); } -/// [`masked_group_sum_seeded_i32`] with the group of row `i` read as +/// [`masked_group_sum_sym_i32`] with the group of row `i` read as /// `table[index[i]]`, zero-fallback at both hops exactly as -/// [`masked_group_sum_i32_via`]. The same `empty` marker rule applies. +/// [`masked_group_sum_i32_via`]. Same reserved code, same row bound. /// /// # Panics /// /// Panics if `index.len() != values.len()`, or if `mask_words.len() < /// values.len().div_ceil(64)`. #[inline] -pub fn masked_group_sum_seeded_i32_via( - mask_words: &[u64], index: &[u32], table: &[u32], values: &[i32], empty: i64, out: &mut [i64], -) { - assert_eq!(index.len(), values.len(), "masked_group_sum_seeded_i32_via: index/values length mismatch"); +pub fn masked_group_sum_sym_i32_via(mask_words: &[u64], index: &[u32], table: &[u32], values: &[i32], out: &mut [i64]) { + assert_eq!(index.len(), values.len(), "masked_group_sum_sym_i32_via: index/values length mismatch"); group_walk( - "masked_group_sum_seeded_i32_via", + "masked_group_sum_sym_i32_via", mask_words, values.len(), GroupKeyAddr::Via { index, table }, out, - |slot, i| { - let v = values[i] as i64; - *slot = if *slot == empty { v } else { slot.wrapping_add(v) }; - }, + sym_sum_fold(values), ); } +/// The one `_sym` sum fold, shared by both key addresses. +#[inline(always)] +fn sym_sum_fold(values: &[i32]) -> impl FnMut(&mut i64, usize) + '_ { + move |slot, i| { + let v = values[i] as i64; + *slot = if *slot == SYM_EMPTY_I64 { + v + } else { + slot.wrapping_add(v) + }; + } +} + // ── The keyed-reduction family ─────────────────────────────────────────── // // Every keyed reduction in this file is the SAME walk: visit the rows the @@ -6943,12 +6964,12 @@ mod group_family_tests { assert_eq!(s, [i64::MIN]); } - /// The seeded sum equals the scalar oracle on both addresses at every + /// The `_sym` sum equals the scalar oracle on both addresses at every /// length, where a group is present exactly when some selected row /// resolved to it; an empty group keeps the marker. #[test] - fn seeded_sum_matches_the_reference_and_marks_empty_groups() { - const E: i64 = i64::MIN; + fn sym_sum_matches_the_reference_and_marks_empty_groups() { + const E: i64 = SYM_EMPTY_I64; for &n in LENS { let fx = fixture(n, 0xface ^ n as u64); for (name, key) in [("resident", key_resident as fn(&Fx, usize) -> Option), ("via", key_via)] { @@ -6963,9 +6984,9 @@ mod group_family_tests { } let mut got = vec![E; GROUPS]; if name == "resident" { - masked_group_sum_seeded_i32(&fx.mask, &fx.keys, &fx.values, E, &mut got); + masked_group_sum_sym_i32(&fx.mask, &fx.keys, &fx.values, &mut got); } else { - masked_group_sum_seeded_i32_via(&fx.mask, &fx.index, &fx.table, &fx.values, E, &mut got); + masked_group_sum_sym_i32_via(&fx.mask, &fx.index, &fx.table, &fx.values, &mut got); } assert_eq!(got, want, "{name} n={n}"); } @@ -6981,7 +7002,7 @@ mod group_family_tests { let keys = [0u32, 0, 9, 2]; let values = [5i32, -5, 1, 3]; let mut out = [i64::MIN; 3]; - masked_group_sum_seeded_i32(&mask, &keys, &values, i64::MIN, &mut out); + masked_group_sum_sym_i32(&mask, &keys, &values, &mut out); assert_eq!(out, [0, i64::MIN, 3], "key 9 is past the universe and is dropped"); let mut plain = [0i64; 3]; @@ -6991,10 +7012,24 @@ mod group_family_tests { let index = [0u32, 7, 1]; let table = [1u32, 5]; let mut via = [i64::MIN; 3]; - masked_group_sum_seeded_i32_via(&[0b111], &index, &table, &[4, 8, 6], i64::MIN, &mut via); + masked_group_sum_sym_i32_via(&[0b111], &index, &table, &[4, 8, 6], &mut via); assert_eq!(via, [i64::MIN, 4, i64::MIN], "index 7 misses the table; table 5 misses the universe"); } + /// The reservation is opt-in by NAME: a full-range function treats + /// `i64::MIN` as an ordinary value. A slot already holding it is added to, + /// never replaced, so an unsuffixed caller can never trip the `_sym` rule. + #[test] + fn full_range_sum_never_treats_the_sym_code_as_empty() { + let mut plain = [SYM_EMPTY_I64; 1]; + masked_group_sum_i32(&[0b1], &[0], &[5], &mut plain); + assert_eq!(plain, [SYM_EMPTY_I64 + 5], "full range adds to i64::MIN"); + + let mut sym = [SYM_EMPTY_I64; 1]; + masked_group_sum_sym_i32(&[0b1], &[0], &[5], &mut sym); + assert_eq!(sym, [5], "_sym replaces its reserved code"); + } + /// A mask shorter than the row count is refused, and the panic names the /// public function the caller used, not the shared walker. #[test]