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
47 changes: 29 additions & 18 deletions .claude/board/entries/2026-10-24-drf-p2-breakeven.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,39 @@
**Status:** MEASURED (lab). **Probe:** `crates/drf-focus-probe/examples/p2_breakeven.rs`.
**Command:** `cargo run --release --example p2_breakeven` (native AVX-512) and with
`RUSTFLAGS="-C target-cpu=x86-64-v3"` (AVX2). Medians, ns per call, 4096-slot focus,
512 B rows, i32 facet, direct lookahead 16. Both paths asserted equal first.
512 B rows, i32 facet, direct lookahead 16, ndarray #376 fold body. Both paths asserted equal first.
Host: Xeon 2.1 GHz, L2 8 MiB (4 instances), L3 260 MiB.

| rows (MiB) | map | density | build | fold | direct | N* |
|---|---|---|---|---|---|---|
| 1024 (0.5) | random | 1.00 | 3 827 | 2 341 | 8 198 | 1 |
| 1024 (0.5) | random | 0.25 | 3 824 | 475 | 5 502 | 1 |
| 65536 (32) | random | 1.00 | 20 733 | 2 323 | 20 227 | 2 |
| 65536 (32) | random | 0.25 | 19 924 | 516 | 17 514 | 2 |
| 65536 (32) | clustered | 1.00 | 16 642 | 2 312 | 15 457 | 2 |
**Warm vs cold.** A focus touches one cache line per distinct ordinal: 248–256 KiB here,
whatever the row count. So repeated reps are **warm** even for 65 536 rows (32 MiB of rows).
The **cold** arm streams a 512 MiB buffer (twice the L3) outside the timed region before
each rep (21 reps). The first version of this entry called the 65 536-row warm numbers
"DRAM"; that was wrong (codex review on #1763).

AVX2 rows agree within ~10%. Clustered 1024-row cases match random 1024 (same N*).
| rows | map | dens | build warm | fold warm | direct warm | build cold | direct cold | N* warm | N* cold |
|---|---|---|---|---|---|---|---|---|---|
| 1024 | random | 1.00 | 3 804 | 690 | 8 239 | 33 623 | 35 972 | 1 | 1 |
| 1024 | random | 0.25 | 3 821 | 429 | 5 271 | 34 666 | 28 748 | 1 | 3 |
| 65536 | random | 1.00 | 23 256 | 670 | 20 982 | 137 961 | 147 286 | 2 | 1 |
| 65536 | random | 0.25 | 23 638 | 430 | 20 611 | 143 249 | 141 826 | 2 | 2 |
| 65536 | clustered | 1.00 | 15 572 | 690 | 15 020 | 79 300 | 80 321 | 2 | 1 |

- **Break-even:** the focus pays off from the 1st (L2-resident) or 2nd (32 MiB, DRAM)
repeated fold of the same focus. No win when a focus is folded only once on DRAM.
- **A2** at N*: 0.20–0.67 — below the pre-registered 70–90% band, because N* is so small;
A2 rises with N (N·pop / (N·pop + 4096)). At N=64 full mask, A2 = 0.98.
AVX2 rows agree within ~15%. N* cold = smallest N with `build_cold + N·fold < direct_cold +
(N−1)·direct_warm`: the first touch is cold on both paths, later reads are warm.

- **Break-even:** 1–3 folds of one focus in every cell, warm or cold. The cold first touch
(≈30 µs at 1024 rows, 80–150 µs at 65 536) costs both paths about the same. Cold, the
full-mask cells already win at one fold (e.g. 65 536 random: 137 961 + 670 = 138 631 ns
vs 147 286 ns direct); the 25 % cells need 2–3, because a sparse direct read is cheaper.
- **A2** at cold N*: 0.19–0.67 — below the pre-registered 70–90 % band because N* is so small;
A2 = N·pop / (N·pop + 4096) rises with N (0.98 at N = 64, full mask).
- **A1, A3:** NOT RUN (no writeback, no learning in this probe).
- **P2 falsifier:** predicted-unused focus + 63 abandoned switches all reclaimed by
`evict_unused`; held foci stay correct. Disable (keep all entries) → red.

Fold cost: `ndarray::simd::masked_sum_i32` walked set bits one by one, ≈0.57 ns per
selected slot. ndarray #376 (D-MPF-2b hybrid body, threshold 16) re-measured with the same
probe: full-mask fold 2 341 → 650 ns (AVX-512), 2 306 → 559 ns (AVX2); 25 % masks unchanged
(~470 ns). N* stays 1–2; with the cheaper fold it reaches 1 for full masks on AVX2 at 32 MiB.
OPEN: cold page faults, focus churn under a memory budget, and dirty-row writeback (A1, W1–W8).
Fold cost: before ndarray #376, `masked_sum_i32` walked set bits one by one; full-mask fold
2 341 → 650 ns (AVX-512), 2 306 → 559 ns (AVX2); 25 % masks unchanged (~470 ns).

OPEN: the warm 65 536-row build (23 µs vs 3.8 µs at 1024 rows, same cache-line footprint)
suggests TLB misses across 32 MiB of 4 KiB pages; not measured. Also open: focus churn under
a memory budget, and dirty-row writeback (A1, W1–W8).
86 changes: 72 additions & 14 deletions crates/drf-focus-probe/examples/p2_breakeven.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,39 @@ fn median_ns(reps: usize, mut f: impl FnMut()) -> f64 {
v[reps / 2]
}

/// Stream a buffer twice the size of the last-level cache, outside the timed
/// region, so the rows a cold rep touches come from DRAM.
fn evict(buf: &mut [u64]) {
let mut acc = 0u64;
for (i, v) in buf.iter_mut().enumerate() {
*v = v.wrapping_add(i as u64);
acc ^= *v;
}
black_box(acc);
}

fn median_cold_ns(reps: usize, buf: &mut [u64], mut f: impl FnMut()) -> f64 {
let mut v: Vec<f64> = (0..reps)
.map(|_| {
evict(buf);
let t = Instant::now();
f();
t.elapsed().as_nanos() as f64
})
.collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v[reps / 2]
}

/// Smallest N with focus (build + N folds) below direct (first read at `d0`,
/// later reads at `d`). `None` if it never crosses within 10 000.
fn crossover(build: f64, fold: f64, d0: f64, d: f64) -> Option<u64> {
(1..=10_000u64).find(|&n| build + n as f64 * fold < d0 + (n - 1) as f64 * d)
}

fn main() {
// 512 MiB: twice the 260 MiB L3 on the measuring host.
let mut evict_buf = vec![0u64; 64 << 20];
let isa = if cfg!(target_feature = "avx512f") {
"avx512f"
} else if cfg!(target_feature = "avx2") {
Expand All @@ -40,9 +72,21 @@ fn main() {
"scalar"
};
println!("isa={isa} slots={SLOTS} row={ROW}B facet=i32@{OFF}");
println!("warm = repeated reps, rows stay cached; cold = 512 MiB evicted before each rep");
println!(
"{:<10} {:>7} {:<10} {:>6} {:>11} {:>9} {:>11} {:>7} {:>12}",
"rows", "MiB", "map", "dens", "build_ns", "fold_ns", "direct_ns", "N*", "A2@N*"
"{:<6} {:>8} {:<10} {:>5} {:>9} {:>7} {:>9} {:>9} {:>11} {:>6} {:>6} {:>7}",
"rows",
"lineKiB",
"map",
"dens",
"build_w",
"fold_w",
"direct_w",
"build_c",
"direct_c",
"N*_w",
"N*_c",
"A2@N*c"
);
let mut st = 0x9E37_79B9_7F4A_7C15u64;
for nrows in [1024usize, 65536] {
Expand Down Expand Up @@ -104,25 +148,39 @@ fn main() {
focus.sum_where(&mask),
direct_sum_where(&soa, &map, facet, &mask, 16).unwrap()
);
let nstar = if direct > fold {
format!("{}", (build / (direct - fold)).ceil() as u64)
} else {
"never".into()
};
let a2 = match nstar.parse::<f64>() {
Ok(n) => format!("{:.3}", n * pop as f64 / (n * pop as f64 + SLOTS as f64)),
Err(_) => "-".into(),
};
let build_c = median_cold_ns(21, &mut evict_buf, || {
let mgr = FocusManager::new();
black_box(mgr.load(&soa, &map, Scope(1), facet).unwrap());
});
let direct_c = median_cold_ns(21, &mut evict_buf, || {
black_box(direct_sum_where(&soa, &map, facet, black_box(&mask), 16).unwrap());
});
let show = |n: Option<u64>| n.map_or("never".to_string(), |n| n.to_string());
let nw = crossover(build, fold, direct, direct);
let nc = crossover(build_c, fold, direct_c, direct);
let a2 = nc.map_or("-".into(), |n| {
let r = n as f64 * pop as f64;
format!("{:.3}", r / (r + SLOTS as f64))
});
let mut seen = vec![false; nrows];
let lines = map
.ord
.iter()
.filter(|&&o| !std::mem::replace(&mut seen[o as usize], true))
.count();
println!(
"{:<10} {:>7.1} {:<10} {:>6.2} {:>11.0} {:>9.0} {:>11.0} {:>7} {:>12}",
"{:<6} {:>8} {:<10} {:>5.2} {:>9.0} {:>7.0} {:>9.0} {:>9.0} {:>11.0} {:>6} {:>6} {:>7}",
nrows,
(nrows * ROW) as f64 / (1 << 20) as f64,
lines * 64 / 1024,
mname,
pop as f64 / SLOTS as f64,
build,
fold,
direct,
nstar,
build_c,
direct_c,
show(nw),
show(nc),
a2
);
}
Expand Down
Loading