Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/blackboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -4005,3 +4005,8 @@ laws, ExtentUnsupported, row bounds) and WITNESS in OrderedLaneWitness; no new t
| P2 | E2 as the AVX-512-without-VNNI U9 kernel (joins #393's ADOPT-LAB row) | low | integration owner |
| P3 | AVX2 P1 anomaly: setup+exec 8 ns apart, 42 ns together | low | asm of the combined path |
| P4 | RPR stale cases X1-X9 / #1848 B0 fixture can reuse the A5 scalar oracle | high | lance-graph owners |

## 2026-10-30 — D-BINARY-ABACUS-POLYFILL-0
- PR A #402 (draft): `half_add_mask` / `full_add_mask` in `simd_masking_ops`, re-exported; vectorized `pow2n_from_int` (vsexp 4x, vserf 2.4x on AVX-512); `U32x16` `Shl`/`Shr<Self>` added to AVX2, AVX, NEON, wasm, nightly.
- Lab: `Abacus<BITS>` with transactional refusal in `examples/u9_domino`; cost equal to eager F. PR B and PR C deferred: no consumer, planes already cheap with existing words. Report: `.claude/knowledge/binary-abacus-polyfill-probe.md`.
- Loose end: NEON `simd_exp_f32` has ~47 scalar fcmp/csel (pre-existing, select/compare path); AVX2 mask packing for `select` still lane by lane.
96 changes: 96 additions & 0 deletions .claude/knowledge/binary-abacus-polyfill-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# D-BINARY-ABACUS-POLYFILL-0: binary positional arithmetic over the facade

Status: PR A shipped as #402 (draft). PR B and PR C deferred, with reasons
below. Host: AVX-512 VNNI Xeon. Date: 2026-10-30.

## What shipped (PR A, #402)

- `ndarray::simd::half_add_mask(a, carry) = (a ^ carry, a & carry)` and
`full_add_mask(a, b, c) = (a ^ b ^ c, majority)`: the #391 `flip` and
#389 `carry_save3` laws, moved unchanged into `simd_masking_ops` and
re-exported. A `u64` is one significance column of 64 independent lanes;
the carry belongs one column up in the same lane.
- The in-flight exponent change: `pow2n_from_int` no longer goes through
`to_array`. It clamps `n` to [-126, 127], adds `2^23 + 127` so the biased
exponent sits in the mantissa field, and shifts the bits left by 23. That
needed `U32x16 << U32x16` on every backend; it existed only on AVX-512
and scalar. Lowering, timings and disable runs are in #402.

## Candidate C: `Abacus<BITS>` (lab only, `examples/u9_domino/domino.rs`)

- F's eager ripple, built from `half_add_mask`.
- Each of the 64 lanes is an independent unsigned counter of `BITS` bits.
`1 <= BITS <= 122`, so a lane value (< 2^122) and the 64-lane total
(< 2^128) are both exact in `u128`.
- `try_add_at(significance, lanes)` is transactional. On overflow the
touched columns are restored by recomputing the carry chain forward from
the input mask: `old_k = new_k ^ m_k`, `m_{k+1} = old_k & m_k`. The
success path keeps no undo log. A significance at or past `BITS` is
refused without touching anything.
- F (`Eager`) is NOT transactional: on overflow its lower columns stay
updated (its doc says so). C is the first counter in the fixtures that
refuses without side effects.

Tests (`tests/u9_domino.rs`, 6 new):
- lanes 0/31/32/63 alone;
- carry chains of 1, 2, 8, 16, 32 and 64 columns in those lanes;
- repeated carries into one column add up;
- refusal on overflow and on an out-of-range significance leaves the
counter unchanged;
- 24,000 random steps against 64 `u128` counters, with zero, all-ones,
alternating, single-lane and random masks, refusals included
(accepted > 5000 and refused > 1000 asserted);
- reversed traversal gives identical columns.

Disable runs, each red: C1 no rollback; C2 rollback stops after one
column; C3 no significance range check; C4 carry moved to the neighbouring
lane.

## Cost gate (ns per add, 4096 adds, median of 2001 runs; range over 3 processes)

| tier | workload | A native per-lane | B = F eager | C abacus |
|---|---|---|---|---|
| AVX-512 VNNI | dense all-ones | 8.79 – 9.08 | 2.46 – 2.66 | 2.27 – 2.35 |
| | random masks | 8.78 – 8.80 | 7.79 – 8.05 | 8.34 – 8.80 |
| | sparse one lane | 8.02 – 9.08 | 2.14 – 2.44 | 2.21 – 3.90 |
| | weighted (2^0/16/17/32) | 8.79 – 9.39 | 6.99 – 7.69 | 7.27 – 9.96 |
| AVX-512, no VNNI | dense | 8.01 – 8.78 | 2.39 – 2.45 | 2.21 – 2.22 |
| | random | 8.02 – 9.39 | 7.56 – 8.50 | 6.88 – 7.84 |
| | sparse | 8.78 – 8.79 | 2.30 – 2.35 | 3.72 – 5.26 |
| | weighted | 8.78 – 9.09 | 7.09 – 8.24 | 6.89 – 7.19 |
| AVX2 | dense | 9.04 – 9.92 | 2.25 – 2.52 | 2.33 – 2.43 |
| | random | 9.69 – 10.23 | 7.22 – 8.32 | 8.53 – 10.57 |
| | sparse | 9.91 – 10.33 | 2.09 – 2.36 | 2.29 – 3.42 |
| | weighted | 9.91 – 11.59 | 7.39 – 7.59 | 8.08 – 8.21 |

Reading it:
- Both bit-plane counters beat 64 per-lane adds by about 4x on dense and
single-lane masks and are within 0.75 – 1.1x on random masks, the same
shape #391 found.
- C costs the same as F on dense masks. On sparse masks it is slower and
noisier (most visibly 3.7 – 5.3 vs 2.3 ns on AVX-512 without VNNI),
although the rollback never runs in these rows. Not diagnosed further.
- Not timed: AVX, SSE2 and NEON (qemu only here) and wasm (node). The
exactness tests run on every tier; timings from emulators mean nothing.
- D (U9 byte-dot) and E (vertical masked byte sum) are the existing
kernels for their consumers (#388, #390, #393). The counter is not a
replacement for either and was not imposed on them.

## Verdicts

- **PR B (public `BinaryAbacus64`): DEFER.** Semantics are settled and
tested, and the cost matches the eager domino. What is missing is a real
consumer: nothing in ndarray, and no lance-graph caller found, counts
64-lane masks at chosen significances. A public type waits for one. C
stays in the fixture as the reference.
- **PR C (facet bit-plane helper): DEFER, not built.** The eight planes of
a `U8x64` are already one AND and one `cmpeq_mask` each on existing
facade words (`examples/u9_abacus/abacus.rs::planes`). #389 measured
plane preparation at 3.5 ns per block for both operands on VNNI against
the full B kernel at 2.9 ns, and reuse never broke even. There are not
two actual consumers of prepared planes to amortize across. A helper
would duplicate an existing two-word expression.
- Presence stays with the caller. The counters carry no presence; a lane
that was never touched and a lane that summed to zero both read 0, so a
consumer that needs the distinction keeps its own presence plane (as
`lut9` and Codec9 do).
12 changes: 12 additions & 0 deletions crates/wasm-simd-parity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ fn check_u32x16() -> Result<(), u32> {
}
}
}
// Per-lane variable shifts: a distinct count in every lane.
let n_arr: [u32; 16] = core::array::from_fn(|i| (i as u32 * 7) % 32);
let n = U32x16::from_array(n_arr);
let (l, r) = ((a << n).to_array(), (a >> n).to_array());
for i in 0..16 {
if l[i] != a_arr[i] << n_arr[i] {
return Err(14);
}
if r[i] != a_arr[i] >> n_arr[i] {
return Err(15);
}
}
Ok(())
}

Expand Down
106 changes: 106 additions & 0 deletions examples/u9_domino/domino.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,112 @@ impl<const N: usize> SubstLazy<N> {
}
}

/// Why [`Abacus::try_add_at`] refused. The counter is unchanged either way.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AbacusError {
/// `significance >= BITS`.
SignificanceOutOfRange { significance: usize, bits: usize },
/// A carry left the highest column in these lanes.
Overflow { lanes: u64 },
}

/// C (D-BINARY-ABACUS-POLYFILL-0 candidate): F's eager ripple built from
/// `ndarray::simd::half_add_mask`, with transactional overflow refusal.
///
/// Each of the 64 lanes is an independent unsigned counter of `BITS` bits.
/// `BITS <= 122`, so a lane value (< 2^122) and the total over 64 lanes
/// (< 2^128) are both exact in `u128`.
///
/// The success path is F's. On overflow the touched columns are restored by
/// recomputing the carry chain forward from the input mask:
/// `old_k = new_k ^ m_k` and `m_{k+1} = old_k & m_k`. No undo log is kept.
#[derive(Clone)]
pub struct Abacus<const BITS: usize> {
col: [u64; BITS],
}

impl<const BITS: usize> Abacus<BITS> {
const BOUND: () = assert!(BITS >= 1 && BITS <= 122, "Abacus: 1 <= BITS <= 122");

pub fn new() -> Self {
let () = Self::BOUND;
Self { col: [0; BITS] }
}

/// Adds `2^significance` to every lane set in `lanes`.
#[inline(always)]
pub fn try_add_at(&mut self, significance: usize, lanes: u64) -> Result<(), AbacusError> {
if significance >= BITS {
return Err(AbacusError::SignificanceOutOfRange {
significance,
bits: BITS,
});
}
let (mut k, mut m) = (significance, lanes);
while m != 0 {
if k == BITS {
self.undo(significance, lanes);
return Err(AbacusError::Overflow { lanes: m });
}
let (next, carry) = ndarray::simd::half_add_mask(self.col[k], m);
self.col[k] = next;
m = carry;
k += 1;
}
Ok(())
}

/// Restores every column from `start` up after an add of `lanes` whose
/// carry escaped the top. Cold: runs only on refusal.
#[cold]
fn undo(&mut self, start: usize, lanes: u64) {
let mut m = lanes;
for k in start..BITS {
let old = self.col[k] ^ m;
self.col[k] = old;
m &= old;
if m == 0 {
break;
}
}
}

/// Value of lane `lane` (< 64).
pub fn lane_value(&self, lane: usize) -> u128 {
(0..BITS)
.map(|k| u128::from(self.col[k] >> lane & 1) << k)
.sum()
}

/// Sum over all 64 lanes.
pub fn total(&self) -> u128 {
(0..BITS)
.map(|k| u128::from(self.col[k].count_ones()) << k)
.sum()
}

pub fn columns(&self) -> [u64; BITS] {
self.col
}
}

impl<const N: usize> Counter<N> for Abacus<N> {
fn new() -> Self {
Abacus::new()
}
#[inline(always)]
fn add_at(&mut self, k: usize, m: u64) -> Result<(), Overflow> {
self.try_add_at(k, m).map_err(|e| match e {
AbacusError::Overflow { lanes } => Overflow(lanes),
AbacusError::SignificanceOutOfRange { .. } => Overflow(m),
})
}
fn columns(&self) -> [u64; N] {
self.col
}
fn normalize(&mut self) {}
}

/// Native baseline: 64 per-lane integers.
#[derive(Clone)]
pub struct Native {
Expand Down
4 changes: 3 additions & 1 deletion examples/u9_domino/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ fn counters(label: &str, adds: &[(usize, u64)], runs: usize) {
}
n.lanes.iter().map(|&x| u128::from(x)).sum()
};
let c = || run::<Abacus<N>>(adds);
let (f, p, l, pl) = (
|| run::<Eager<N>>(adds),
|| run::<Subst<N>>(adds),
Expand All @@ -142,7 +143,7 @@ fn counters(label: &str, adds: &[(usize, u64)], runs: usize) {
if fits {
assert_eq!(native(), want_total, "native");
}
for (name, g) in [("F", &f as &dyn Fn() -> u128), ("P", &p), ("L", &l), ("PL", &pl)] {
for (name, g) in [("F", &f as &dyn Fn() -> u128), ("C", &c), ("P", &p), ("L", &l), ("PL", &pl)] {
assert_eq!(g(), want_total, "{name}");
}
println!("-- B, {label}: {} adds", adds.len());
Expand All @@ -152,6 +153,7 @@ fn counters(label: &str, adds: &[(usize, u64)], runs: usize) {
None
};
time("F eager", adds.len(), runs, "add", b, &f);
time("C abacus: F + transactional refusal", adds.len(), runs, "add", b, &c);
time("P EMPTY substitution", adds.len(), runs, "add", b, &p);
time("L lazy FULL", adds.len(), runs, "add", b, &l);
time("PL both", adds.len(), runs, "add", b, &pl);
Expand Down
54 changes: 46 additions & 8 deletions src/simd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,14 +639,14 @@ const TWO_POW_NEG_64: f32 = 5.421_011e-20;
#[inline(always)]
#[allow(dead_code)]
fn pow2n_from_int(n: F32x16) -> F32x16 {
let arr = n.to_array();
let mut out = [0.0f32; 16];
for i in 0..16 {
let ni = (arr[i] as i32).clamp(-126, 127);
let bits = ((ni + 127) as u32) << 23;
out[i] = f32::from_bits(bits);
}
F32x16::from_array(out)
// `n` is integral (it comes from `round`), so after the clamp
// `2^23 + 127 + n` is an exact f32 whose mantissa field holds the biased
// exponent `k = n + 127` in [1, 254] and whose exponent field is
// 0x4B00_0000. Shifting the bits left by 23 pushes 0x4B00_0000 out of the
// word and moves `k` into the exponent field: the bits of 2^n. No lane
// leaves its register.
let biased = n.simd_clamp(F32x16::splat(-126.0), F32x16::splat(127.0)) + F32x16::splat(8_388_608.0 + 127.0);
F32x16::from_bits(biased.to_bits() << U32x16::splat(23))
}

/// Fast natural log for F32x16.
Expand Down Expand Up @@ -1042,6 +1042,9 @@ pub use crate::simd_masking_ops::{
BOUNDED_TILE_ROWS,
SYM_EMPTY_I64,
};
// Binary positional adders over 64 one-bit lanes (the #389/#391 `carry_save3`
// and `flip` laws).
pub use crate::simd_masking_ops::{full_add_mask, half_add_mask};
#[cfg(feature = "std")]
pub use crate::simd_stride::{lut9, lut_u8, FACET_BYTES};
// The popcount that closes the loop on the masks above: `mask_count` in ABI
Expand Down Expand Up @@ -1800,6 +1803,41 @@ mod tests {
assert_eq!(&out[..16], &data[..16]);
}

#[test]
fn u32x16_variable_shifts_match_per_lane_shifts() {
// Distinct value and count per lane, every count 0..32 used, so a
// backend that shifted by lane 0's count or a uniform count fails.
for base in 0..2u32 {
let a: [u32; 16] = core::array::from_fn(|i| 0x9E37_79B9u32.rotate_left(i as u32 * 5 + base));
let n: [u32; 16] = core::array::from_fn(|i| (i as u32 * 7 + base * 16) % 32);
let (va, vn) = (U32x16::from_array(a), U32x16::from_array(n));
let l = (va << vn).to_array();
let r = (va >> vn).to_array();
for i in 0..16 {
assert_eq!(l[i], a[i] << n[i], "shl lane {i}");
assert_eq!(r[i], a[i] >> n[i], "shr lane {i}");
}
}
}

#[test]
fn pow2n_from_int_is_exactly_two_to_the_clamped_n() {
// Every integral n in [-200, 200] (so both clamp ends are crossed),
// checked bit for bit against the scalar exponent-field rule.
let ns: Vec<f32> = (-200..=200).map(|n| n as f32).collect();
for chunk in ns.chunks(16) {
let mut lanes = [0.0f32; 16];
lanes[..chunk.len()].copy_from_slice(chunk);
let got = pow2n_from_int(F32x16::from_array(lanes)).to_array();
for (&n, &g) in lanes.iter().zip(&got) {
let k = ((n as i32).clamp(-126, 127) + 127) as u32;
assert_eq!(g.to_bits(), k << 23, "n={n}");
}
}
assert_eq!(pow2n_from_int(F32x16::splat(0.0)).to_array(), [1.0; 16]);
assert_eq!(pow2n_from_int(F32x16::splat(-126.0)).to_array(), [f32::MIN_POSITIVE; 16]);
}

#[test]
fn simd_exp_f32_basic() {
let zero = F32x16::splat(0.0);
Expand Down
24 changes: 23 additions & 1 deletion src/simd_avx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use core::ops::{
Add, AddAssign, BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Mul, Not, Shl, Shr, Sub, SubAssign,
};

use crate::simd_avx2::{F32x16, I32x16, I8x64, U16x16, U16x32, U64x8, U8x32, U8x64};
use crate::simd_avx2::{F32x16, I32x16, I8x64, U16x16, U16x32, U32x16, U64x8, U8x32, U8x64};
use crate::simd_avx512::{F32x8, I16x16, I8x32};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -308,6 +308,28 @@ impl Shr<Self> for U64x8 {
}
}

/// Lane-wise variable left shift. Counts of 32 or more zero the lane, as
/// `VPSLLVD` does on the AVX2 arm. No SSE/AVX1 form exists, so this is per
/// lane, like `U64x8`'s above.
impl Shl<Self> for U32x16 {
type Output = Self;
#[inline(always)]
fn shl(self, rhs: Self) -> Self {
debug_assert!(rhs.0.iter().all(|&n| n < 32), "U32x16 shift counts are a caller contract: < 32");
Self(core::array::from_fn(|i| if rhs.0[i] < 32 { self.0[i] << rhs.0[i] } else { 0 }))
}
}

/// Lane-wise variable right shift; see the `Shl<Self>` impl above.
impl Shr<Self> for U32x16 {
type Output = Self;
#[inline(always)]
fn shr(self, rhs: Self) -> Self {
debug_assert!(rhs.0.iter().all(|&n| n < 32), "U32x16 shift counts are a caller contract: < 32");
Self(core::array::from_fn(|i| if rhs.0[i] < 32 { self.0[i] >> rhs.0[i] } else { 0 }))
}
}

// ---------------------------------------------------------------------------
// U8x64 (array-backed)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading