diff --git a/.claude/board/entries/2026-10-24-drf-p2-breakeven.md b/.claude/board/entries/2026-10-24-drf-p2-breakeven.md index 11abf4e8c..47aa67fc1 100644 --- a/.claude/board/entries/2026-10-24-drf-p2-breakeven.md +++ b/.claude/board/entries/2026-10-24-drf-p2-breakeven.md @@ -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). diff --git a/crates/drf-focus-probe/examples/p2_breakeven.rs b/crates/drf-focus-probe/examples/p2_breakeven.rs index 8ecc792bc..9f8445fb0 100644 --- a/crates/drf-focus-probe/examples/p2_breakeven.rs +++ b/crates/drf-focus-probe/examples/p2_breakeven.rs @@ -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 = (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 { + (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") { @@ -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] { @@ -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::() { - 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| 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 ); }