Skip to content

Commit ebb9fc4

Browse files
committed
hpc::perm: the field as an exposure; one fold over all 4096 cells
Every field cell is a gather, so a fold over the whole field of anything that depends only on the (output lane, source lane) pair equals one weighted fold over 64x64 pairs. Exposure is that kernel, built from the two 64-entry tables in 64*64 + 64^3 increments, independent of how much data is folded against it. PermTable12::relative_field(q) re-bases the field onto a fixed map: the relative map of cell (h,l) factors as (q . hi[h]^-1) . lo[l]^-1, so the re-based field is again two 64-entry tables (128 compositions once). examples/perm_field_probe asks one question of the whole field and compares 4096 standalone per-cell folds with one hot fold (N = 64 blocks, release, one run, AVX-512 VBMI host): shared basis Count 4096 compositions / 786432 shuffles -> 0 / 0 different bases Count 16.8M evals -> 262144 evals, 0 shuffles output-mask Count 16.8M evals -> 266240 evals, 0 shuffles ordered Keep owes 4096 x N output blocks either way Dedup over 300 distinct cells in a 20000-request stream was slower than composing per request (594 us vs 471 us): a composition is one byte permute, so the set bookkeeping costs more than it saves here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm
1 parent d24e3fa commit ebb9fc4

3 files changed

Lines changed: 569 additions & 0 deletions

File tree

‎Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ required-features = ["std"]
6363
name = "ternlog_fold_probe"
6464
required-features = ["std"]
6565

66+
[[example]]
67+
name = "perm_field_probe"
68+
required-features = ["std"]
69+
6670
[[example]]
6771
name = "ternlog_popcnt_gap_probe"
6872
required-features = ["std"]

‎examples/perm_field_probe.rs‎

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
//! One question over the whole 64×64 permutation field, answered by one fold.
2+
//!
3+
//! The expression is `f = ternlog(0x96)` (a XOR b XOR c) over a population of
4+
//! `N` 64-byte blocks. Each question is asked of the WHOLE field — all 4096
5+
//! cells of a `PermTable12` — and has one answer. Two executions:
6+
//!
7+
//! - **per-cell**: 4096 standalone folds. Each composes its cell's map,
8+
//! shuffles the operands into that coordinate system, evaluates, and adds its
9+
//! answer to the total.
10+
//! - **hot fold**: the data and the accumulator stay resident, and the field
11+
//! streams through the one running fold as relations. Nothing is constructed
12+
//! per cell. A shared basis disappears under an invariant terminal. A
13+
//! relative basis or an output-coordinate mask stays in the fold through the
14+
//! field's exposure (a 64×64 kernel built from the two 64-entry tables),
15+
//! never as per-cell work.
16+
//!
17+
//! Ordered Keep is the one terminal that owes per-cell output. It is included
18+
//! to show where the per-cell cost genuinely remains.
19+
//!
20+
//! Every hot-fold answer is asserted equal to the per-cell answer. The
21+
//! counters count what this probe itself does: `evals` is the number of
22+
//! expression/predicate evaluations inside the fold, which is the incremental
23+
//! work that remains.
24+
//!
25+
//! ```sh
26+
//! cargo run --release --example perm_field_probe
27+
//! ```
28+
29+
use ndarray::hpc::perm::{LaneOp, LaneTernlog, Perm64, PermBatch, PermTable12, Schedule};
30+
use std::time::Instant;
31+
32+
const N: usize = 64;
33+
const CELLS: u16 = 4096;
34+
type Block = [u8; 64];
35+
36+
#[derive(Default, Clone, Copy)]
37+
struct Counters {
38+
compositions: u64,
39+
shuffles: u64,
40+
evals: u64,
41+
bytes_written: u64,
42+
}
43+
44+
struct Run<'a> {
45+
t: &'a PermTable12,
46+
c: Counters,
47+
}
48+
49+
impl Run<'_> {
50+
fn compose(&mut self, code: u16) -> Perm64 {
51+
self.c.compositions += 1;
52+
self.t.for_code(code)
53+
}
54+
fn shuffle(&mut self, p: Perm64, b: &Block) -> Block {
55+
self.c.shuffles += 1;
56+
self.c.bytes_written += 64;
57+
p.materialize(b)
58+
}
59+
fn f(&mut self, a: &Block, b: &Block, c: &Block) -> Block {
60+
self.c.evals += 64;
61+
LaneTernlog(0x96).apply([a, b, c])
62+
}
63+
}
64+
65+
fn nonzero(v: &Block) -> u64 {
66+
v.iter().filter(|&&x| x != 0).count() as u64
67+
}
68+
69+
fn splitmix(s: &mut u64) -> u64 {
70+
*s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
71+
let mut z = *s;
72+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
73+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
74+
z ^ (z >> 31)
75+
}
76+
77+
fn shuffled(seed: u64) -> Perm64 {
78+
let mut s = seed;
79+
let mut idx: [u8; 64] = core::array::from_fn(|i| i as u8);
80+
for i in (1..64).rev() {
81+
idx.swap(i, (splitmix(&mut s) % (i as u64 + 1)) as usize);
82+
}
83+
Perm64::from_indices(idx).unwrap()
84+
}
85+
86+
/// Sparse-ish bytes, so Count and Any are not saturated.
87+
fn population(seed: u64) -> Vec<Block> {
88+
let mut s = seed;
89+
(0..N)
90+
.map(|_| {
91+
core::array::from_fn(|_| {
92+
if splitmix(&mut s).is_multiple_of(3) {
93+
(splitmix(&mut s) & 0xFF) as u8
94+
} else {
95+
0
96+
}
97+
})
98+
})
99+
.collect()
100+
}
101+
102+
struct Row {
103+
name: &'static str,
104+
c: Counters,
105+
micros: f64,
106+
}
107+
108+
fn run<T>(t: &PermTable12, name: &'static str, body: impl FnOnce(&mut Run) -> T) -> (T, Row) {
109+
let mut r = Run {
110+
t,
111+
c: Counters::default(),
112+
};
113+
let start = Instant::now();
114+
let out = body(&mut r);
115+
let micros = start.elapsed().as_secs_f64() * 1e6;
116+
(out, Row { name, c: r.c, micros })
117+
}
118+
119+
fn report(case: &str, answer: &str, rows: &[Row]) {
120+
println!("\n## {case}\nanswer: {answer} (all executions equal, asserted)");
121+
println!(
122+
"{:<10} {:>12} {:>10} {:>12} {:>13} {:>11}",
123+
"execution", "compositions", "shuffles", "evals", "bytes_written", "µs"
124+
);
125+
for r in rows {
126+
println!(
127+
"{:<10} {:>12} {:>10} {:>12} {:>13} {:>11.1}",
128+
r.name, r.c.compositions, r.c.shuffles, r.c.evals, r.c.bytes_written, r.micros
129+
);
130+
}
131+
}
132+
133+
fn main() {
134+
let table = PermTable12::new(core::array::from_fn(|i| shuffled(100 + i as u64)));
135+
let (a, b, c) = (population(1), population(2), population(3));
136+
println!("field: 4096 cells; population: {N} blocks per operand");
137+
138+
// ---- 1. Shared basis, Count over the field ---------------------------
139+
// Σ over cells of Count(f(P·a, P·b, P·c)).
140+
{
141+
let (per_cell, r1) = run(&table, "per-cell", |r| {
142+
let mut total = 0u64;
143+
for code in 0..CELLS {
144+
let p = r.compose(code);
145+
for i in 0..N {
146+
let (pa, pb, pc) = (r.shuffle(p, &a[i]), r.shuffle(p, &b[i]), r.shuffle(p, &c[i]));
147+
total += nonzero(&r.f(&pa, &pb, &pc));
148+
}
149+
}
150+
total
151+
});
152+
let (hot, r2) = run(&table, "hot fold", |r| {
153+
// Equivariance moves P outside f, and Count removes it: every cell
154+
// contributes the same amount. One pass, then scale.
155+
let once: u64 = (0..N).map(|i| nonzero(&r.f(&a[i], &b[i], &c[i]))).sum();
156+
once * CELLS as u64
157+
});
158+
assert_eq!(hot, per_cell);
159+
assert_eq!(r2.c.compositions + r2.c.shuffles, 0);
160+
report("shared basis, field-wide Count", &hot.to_string(), &[r1, r2]);
161+
}
162+
163+
// ---- 2. Shared basis, Any over the field (stops at the first hit) -----
164+
{
165+
let (per_cell, r1) = run(&table, "per-cell", |r| {
166+
for code in 0..CELLS {
167+
let p = r.compose(code);
168+
for i in 0..N {
169+
let (pa, pb, pc) = (r.shuffle(p, &a[i]), r.shuffle(p, &b[i]), r.shuffle(p, &c[i]));
170+
if nonzero(&r.f(&pa, &pb, &pc)) > 0 {
171+
return true;
172+
}
173+
}
174+
}
175+
false
176+
});
177+
let (hot, r2) = run(&table, "hot fold", |r| (0..N).any(|i| nonzero(&r.f(&a[i], &b[i], &c[i])) > 0));
178+
assert_eq!(hot, per_cell);
179+
report("shared basis, field-wide Any (early exit)", &hot.to_string(), &[r1, r2]);
180+
}
181+
182+
// ---- 3. Different bases, Count over the field -------------------------
183+
// a is seen through P(cell); b and c through one fixed Q. The relation
184+
// R = Q relative to P changes with the cell and survives Count, so it
185+
// stays in the fold as the re-based field's exposure.
186+
{
187+
let q = table.for_code(0x5A5);
188+
let (per_cell, r1) = run(&table, "per-cell", |r| {
189+
let mut total = 0u64;
190+
for code in 0..CELLS {
191+
let p = r.compose(code);
192+
for i in 0..N {
193+
let (pa, qb, qc) = (r.shuffle(p, &a[i]), r.shuffle(q, &b[i]), r.shuffle(q, &c[i]));
194+
total += nonzero(&r.f(&pa, &qb, &qc));
195+
}
196+
}
197+
total
198+
});
199+
let (hot, r2) = run(&table, "hot fold", |r| {
200+
let e = r.t.relative_field(q).exposure();
201+
r.c.compositions += 128; // building the two re-based 64-entry tables
202+
let mut total = 0u64;
203+
for i in 0..N {
204+
let (ab, bb, cb) = (&a[i], &b[i], &c[i]);
205+
let mut evals = 0u64;
206+
total += e.count_where(|lane, src| {
207+
evals += 1;
208+
ab[lane] ^ bb[src] ^ cb[src] != 0
209+
});
210+
r.c.evals += evals;
211+
}
212+
total
213+
});
214+
assert_eq!(hot, per_cell);
215+
report("different bases (P per cell, fixed Q), field-wide Count", &hot.to_string(), &[r1, r2]);
216+
}
217+
218+
// ---- 4. Mask fixed in OUTPUT coordinates, Count over the field ---------
219+
// Σ over cells of Count(M ∧ P·f(a,b,c)). M does not move with P; it takes
220+
// part in the fold through the exposure instead of per-cell transport.
221+
{
222+
let masks: Vec<u64> = {
223+
let mut s = 77;
224+
(0..N).map(|_| splitmix(&mut s)).collect()
225+
};
226+
let (per_cell, r1) = run(&table, "per-cell", |r| {
227+
let mut total = 0u64;
228+
for code in 0..CELLS {
229+
let p = r.compose(code);
230+
for i in 0..N {
231+
let (pa, pb, pc) = (r.shuffle(p, &a[i]), r.shuffle(p, &b[i]), r.shuffle(p, &c[i]));
232+
let v = r.f(&pa, &pb, &pc);
233+
total += (0..64)
234+
.filter(|&l| masks[i] >> l & 1 == 1 && v[l] != 0)
235+
.count() as u64;
236+
}
237+
}
238+
total
239+
});
240+
let (hot, r2) = run(&table, "hot fold", |r| {
241+
let e = r.t.exposure();
242+
let mut total = 0u64;
243+
for i in 0..N {
244+
let v = r.f(&a[i], &b[i], &c[i]);
245+
let m = masks[i];
246+
let mut evals = 0u64;
247+
total += e.count_where(|lane, src| {
248+
evals += 1;
249+
m >> lane & 1 == 1 && v[src] != 0
250+
});
251+
r.c.evals += evals;
252+
}
253+
total
254+
});
255+
assert_eq!(hot, per_cell);
256+
report("mask fixed in output coordinates, field-wide Count", &hot.to_string(), &[r1, r2]);
257+
}
258+
259+
// ---- 5. Ordered Keep: the one terminal that owes per-cell output ------
260+
{
261+
let (per_cell, r1) = run(&table, "per-cell", |r| {
262+
let mut out = Vec::with_capacity(CELLS as usize * N);
263+
for code in 0..CELLS {
264+
let p = r.compose(code);
265+
for i in 0..N {
266+
let (pa, pb, pc) = (r.shuffle(p, &a[i]), r.shuffle(p, &b[i]), r.shuffle(p, &c[i]));
267+
let v = r.f(&pa, &pb, &pc);
268+
out.push(v);
269+
r.c.bytes_written += 64;
270+
}
271+
}
272+
out
273+
});
274+
let (hot, r2) = run(&table, "hot fold", |r| {
275+
// Evaluate once in the shared basis; each cell is owed only its
276+
// coordinates of the result.
277+
let v: Vec<Block> = (0..N).map(|i| r.f(&a[i], &b[i], &c[i])).collect();
278+
let mut out = Vec::with_capacity(CELLS as usize * N);
279+
for code in 0..CELLS {
280+
let p = r.compose(code);
281+
for blk in &v {
282+
out.push(r.shuffle(p, blk));
283+
}
284+
}
285+
out
286+
});
287+
assert_eq!(hot, per_cell);
288+
report("shared basis, ordered Keep (per-cell output owed)", "4096 × N blocks", &[r1, r2]);
289+
}
290+
291+
// ---- 6. Duplicate-heavy request stream, ordered Keep ------------------
292+
{
293+
let mut s = 5;
294+
let stream: Vec<u16> = (0..20_000)
295+
.map(|_| (splitmix(&mut s) % 300) as u16 * 13 % CELLS)
296+
.collect();
297+
let src = LaneTernlog(0x96).apply([&a[0], &b[0], &c[0]]);
298+
let (per_request, r1) = run(&table, "per-cell", |r| {
299+
stream
300+
.iter()
301+
.map(|&code| {
302+
let p = r.compose(code);
303+
r.shuffle(p, &src)
304+
})
305+
.collect::<Vec<Block>>()
306+
});
307+
let (dedup, r2) = run(&table, "dedup", |r| {
308+
let composed = PermBatch::new(&stream, Schedule::Deduplicate).compose(r.t);
309+
r.c.compositions += composed.compositions() as u64;
310+
let mut out = vec![[0u8; 64]; stream.len()];
311+
composed.materialize_into(&src, &mut out);
312+
r.c.shuffles += out.len() as u64;
313+
r.c.bytes_written += out.len() as u64 * 64;
314+
out
315+
});
316+
assert_eq!(dedup, per_request, "multiplicity and order must survive deduplication");
317+
let distinct = PermBatch::new(&stream, Schedule::Deduplicate)
318+
.field()
319+
.unwrap()
320+
.len();
321+
report(
322+
&format!("{} requests over {distinct} distinct cells, ordered Keep", stream.len()),
323+
"one block per request, in request order",
324+
&[r1, r2],
325+
);
326+
}
327+
}

0 commit comments

Comments
 (0)