diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index f875d9e37..07fd9e11d 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -28,9 +28,9 @@ Docs #1756; lab crate `drf-focus-probe` #1762–#1771 (workspace-excluded, no CI |---|---|---|---| | **D-DRF-1** | reference semantics: geometry, focus lease, lookahead prefetch, foreign-key cache exception, dirty-row writeback, learning overlay | Shipped, docs only (#1756 `06adcbce`) | falsifier matrix pre-registered; run so far (lab): G1/G3/G4/G6, L1–L3, P1–P2, W1–W4, W6–W8. NOT RUN: G2/G5/G7, L4, P3, M1–M2, W5, R1–R7 | | **DRF-P1** | two-focus lazy load / lease / prefetch / last-reader eviction | Shipped, lab (#1762 `62d6c70f`; key gains `facet`, #1769 `94d254a1`) | L1–L3, G1/G3/G4/G6, P1 green; `entries/2026-10-24-drf-p1-focus-lease-probe.md`, `entries/2026-10-25-drf-focuskey-facet.md` | -| **DRF-P2** | 64-thought foreign-table A/B/C (direct+prefetch vs gather+reuse vs gather+reuse+writeback) | Partly measured, lab (#1763 `e8aabd95`, #1764 `2bfb2908`) | break-even 1–3 folds warm and cold; A1/A3 NOT RUN, so the exception's lapse rule is not decided; `entries/2026-10-24-drf-p2-breakeven.md` | +| **DRF-P2** | 64-thought foreign-table A/B/C (direct+prefetch vs gather+reuse vs gather+reuse+writeback) | Partly measured, lab (#1763 `e8aabd95`, #1764 `2bfb2908`) | break-even 1–3 folds warm and cold; A1/A3 NOT RUN (they need a real workload), so the exception's lapse rule is not decided; gather cost rises with distinct pages at constant cache lines and balanced offsets (flat to 2 048 pages, 2.5-3x by 4 096), but the original 1 024 vs 65 536 gap also changed the line count and is not decomposed; 2 MiB-page control and counters not run; `entries/2026-10-24-drf-p2-breakeven.md`, `entries/2026-10-25-drf-tlb-probe.md` | | **DRF-W** | writeback W1–W4, W6–W8: per-row coalesce, conflict refusal, handoff to the real CycleWriter, lost-ack resubmit, no-change cycle, eviction budget | Shipped, lab (#1765–#1768, #1770, #1771; `22bac9d3`…`332b420c`) | W5 (descriptor-only cast) NOT RUN; fake WAL stands in for Lance; `entries/2026-10-25-drf-w1-writeback.md`, `-w3-ordered-apply`, `-w4-handoff`, `-w8-budget` | -| **DRF-CHURN** | eviction victim order under churn (LRU vs MRU) | In PR (#1772) | measured: neither wins both cyclic and phased; default unchanged; `entries/2026-10-25-drf-churn-policy.md` (on #1772) | +| **DRF-CHURN** | eviction victim order under churn (LRU vs MRU) | Shipped, lab (#1772) | measured: neither wins both cyclic and phased; default unchanged; `entries/2026-10-25-drf-churn-policy.md` | | **DRF-P3** | relationship cube / slices and typed dirty-row reduction oracle | Proposed | R1–R3, W1–W3; no Bag/NULL/evidence loss | | **DRF-P4** | optional plasticity overlay + ONNX candidate stream | Proposed | R4–R7; base DR revision bit-identical | 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 47aa67fc1..f87a6e42d 100644 --- a/.claude/board/entries/2026-10-24-drf-p2-breakeven.md +++ b/.claude/board/entries/2026-10-24-drf-p2-breakeven.md @@ -39,3 +39,8 @@ Fold cost: before ndarray #376, `masked_sum_i32` walked set bits one by one; ful 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). + +Correction (2026-10-25, see `2026-10-25-drf-tlb-probe.md`): the "same cache-line footprint" and +"whatever the row count" statements above are wrong for the 1 024-row random case (about 1 005 +distinct lines against about 3 970 at 65 536 rows). The 3.8 vs 23 µs gap is not attributable to +TLB alone. diff --git a/.claude/board/entries/2026-10-25-drf-tlb-probe.md b/.claude/board/entries/2026-10-25-drf-tlb-probe.md new file mode 100644 index 000000000..a9161e892 --- /dev/null +++ b/.claude/board/entries/2026-10-25-drf-tlb-probe.md @@ -0,0 +1,71 @@ +# D-DRF-TLB — gather cost against distinct pages touched (lab, reported not gated) + +**Status:** MEASURED (lab), PARTLY EXPLAINED. **Probe:** `crates/drf-focus-probe/examples/tlb_probe.rs` +(`cargo run --release --example tlb_probe`). Narrows, does not close, the OPEN point in +`2026-10-24-drf-p2-breakeven.md` (warm 65 536-row build 23 µs vs 3.8 µs at 1 024 rows): that +comparison was not at constant cache lines (see Correction), so this probe shows a locality +effect at constant lines, not the full cause of the original gap. +Host: Xeon 2.1 GHz, L2 8 MiB (4 instances), L3 260 MiB, THP `madvise` (so the 32 MiB row +buffer was read back from `/proc/self/smaps` at run time: KernelPageSize 4 kB, AnonHugePages +0 kB, so the policy line alone is not the evidence; no 2 MiB control was run). Page, line and +within-page offset counts come from the real row addresses, and the probe prints the 8-bin +within-page offset histogram (512 per bin for `balanced` and `shuffled`; all 4096 in one bin +for `skewed` at P = 4096). + +Pre-registered in the file header before the first run: H_TLB (build rises with distinct pages +past the second-level TLB reach) against H_other (flat in pages; cache lines set the cost). +Slots fixed at 4 096. For P >= 512 the distinct cache-line count is constant at 4 096. + +## Two generators (added after review) + +The first generator, `skewed`, read row `(i % P) * 8 + (i / P) % 8`. That makes the number of +distinct within-page offsets fall with P (8 at 512 pages, 4 at 1 024, 2 at 2 048, 1 at 4 096), +so cache-set coverage changed with P along with the page count. The `balanced` generator +rotates the offset by page (`(visit + page % 8) % 8`), so all 8 offsets are used at every P. + +Warm build ns (median of 401; `balanced` over three native runs and one `x86-64-v3` run): + +| pages | skewed (1 run) | balanced | +|---|---|---| +| 512 | 12 200 | 8 500-11 100 | +| 1024 | 17 700 | 8 800-10 400 | +| 1536 | 39 800 (outlier) | 9 000-9 700 | +| 2048 | 18 100 | 9 900-13 900 | +| 3072 | 29 700 | 22 400-25 200 | +| 4096 | 27 600 | 26 700-32 100 | + +Cold builds (one run per row, `balanced`) rise 63 000 -> 150 000 ns over 512 -> 4 096 pages. + +- **H_other rejected.** At constant 4 096 lines and balanced offsets the warm build is about + 9-10 µs up to 2 048 pages and 22-32 µs at 3 072-4 096: 2.5-3x. Pages matter on their own. +- **The 512 -> 1 024 step depends on the generator.** It is gone under `balanced` (9 µs -> + 9-10 µs); with `skewed` the 1 024-page build was 17.7 µs. The change alters which lines are + selected, not only their set coverage, so this is consistent with cache-set conflict but does + not prove it. +- **Shape under `balanced`:** flat to 2 048 pages, rise between 2 048 and 3 072. That is + consistent with second-level TLB reach, but no counter or huge-page control confirms it. +- **Visit order does not create the shape.** A `shuffled` arm (same ordinals as `balanced`, + seeded shuffle, one run) gives 9.7 µs at 2 048 pages, 15.3 µs at 3 072 and 25.3 µs at + 4 096: the same flat-then-rise shape. +- **Not shown:** that huge pages remove the effect (the 2 MiB arm needs an aligned `madvise` + allocation); any hardware-counter evidence (perf is not usable here, `perf_event_paranoid` = + 2 and no `perf`); the cause of the cold-build rise. The cost-model quantity may be the + translation working set (page reuse distance), not the page count; that is a hypothesis, not + a result, and no optimization rule follows from it. + +## Correction to `2026-10-24-drf-p2-breakeven.md` + +That entry says a focus touches "one cache line per distinct ordinal: 248-256 KiB here, +whatever the row count" and compares 1 024 and 65 536 rows as "same cache-line footprint". +Distinct rows drawn by 4 096 random slots from N rows is about N(1 - e^(-4096/N)): about 1 005 +at N = 1 024 (about 63 KiB of lines) and about 3 970 at N = 65 536 (about 248 KiB). So the +original 3.8 µs vs 23 µs comparison also changed the line count roughly fourfold, and the +duplicates at 1 024 rows were served from lines already loaded. The gap combines more distinct +lines, fewer repeated addresses and more page dispersion; this probe isolates only the last. + +Consequence for the cost model: the focus build is not a function of slot count alone, and +a clustered map is cheaper than a scattered one with the same slots. How much is pages and how +much is lines in the original gap is not separated. + +OPEN: the 2 MiB-page control (two-factor with visit order); hardware counters; page reuse +distance vs page count; the original 1 024 vs 65 536 gap decomposed into lines vs pages. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index baf6fd483..54cd2705c 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -472 entries, 2026-08-06 .. 2026-10-25. +473 entries, 2026-08-06 .. 2026-10-25. | date | entry id | finding | file | |---|---|---|---| @@ -33,6 +33,7 @@ exactly one of them, never both. | 2026-10-25 | `D-DRF-W4` | | [2026-10-25-drf-w4-handoff.md](2026-10-25-drf-w4-handoff.md) | | 2026-10-25 | `D-DRF-W3` | | [2026-10-25-drf-w3-ordered-apply.md](2026-10-25-drf-w3-ordered-apply.md) | | 2026-10-25 | `D-DRF-W1` | | [2026-10-25-drf-w1-writeback.md](2026-10-25-drf-w1-writeback.md) | +| 2026-10-25 | `D-DRF-TLB` | | [2026-10-25-drf-tlb-probe.md](2026-10-25-drf-tlb-probe.md) | | 2026-10-25 | `D-DRF-G-FACET` | | [2026-10-25-drf-focuskey-facet.md](2026-10-25-drf-focuskey-facet.md) | | 2026-10-25 | `D-DRF-CHURN` | | [2026-10-25-drf-churn-policy.md](2026-10-25-drf-churn-policy.md) | | 2026-10-24 | `tanh-precision-symmetric-i8` | | [2026-10-24-tanh-precision-symmetric-i8.md](2026-10-24-tanh-precision-symmetric-i8.md) | diff --git a/crates/drf-focus-probe/examples/tlb_probe.rs b/crates/drf-focus-probe/examples/tlb_probe.rs new file mode 100644 index 000000000..9cf757be9 --- /dev/null +++ b/crates/drf-focus-probe/examples/tlb_probe.rs @@ -0,0 +1,181 @@ +//! D-DRF-P2 follow-up: does the 65 536-row build cost track distinct 4 KiB pages touched? +//! +//! `p2_breakeven` measured the warm gather at 3.8 µs for 1 024 rows and 23 µs for 65 536 rows +//! with the same 4 096 slots. This probe holds the slot count fixed, builds maps that touch a +//! chosen number P of distinct pages, and reports the gather cost against P. Reported, not gated. +//! +//! PRE-REGISTERED (before the first run), per build on a cold manager, warm caches: +//! H_TLB build ns rises with P once P passes the second-level TLB reach (about 1.5-2 K +//! entries on this class of host), so the P = 4096 build is clearly above the +//! P = 512 build, even though both touch 4096 distinct cache lines. +//! H_other build ns is flat in P over 512..4096 (cache lines, not pages, set the cost). +//! Within P >= 512 the distinct cache-line count is constant at 4096, so that range isolates +//! the page effect. Below 512 the line count also falls; those rows are shown but do not +//! decide. Page counts are computed from the real row addresses, not assumed from the row size. +//! cargo run --release --example tlb_probe +use drf_focus_probe::*; +use std::collections::HashSet; +use std::hint::black_box; +use std::sync::Arc; +use std::time::Instant; + +const OFF: usize = 16; +const NROWS: usize = 65536; +const PAGE: usize = 4096; + +fn median_ns(reps: usize, mut f: impl FnMut()) -> f64 { + let mut v: Vec = (0..reps) + .map(|_| { + 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] +} + +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] +} + +/// Kernel page size and huge-page bytes for the mapping holding `addr`, read from smaps. Policy +/// (`transparent_hugepage/enabled`) does not say how a given buffer is actually backed. +fn backing(addr: usize) -> String { + let Ok(text) = std::fs::read_to_string("/proc/self/smaps") else { + return "smaps unreadable".into(); + }; + let mut hit = false; + let mut out = String::from("mapping not found"); + for line in text.lines() { + if let Some((range, _)) = line.split_once(' ') + && let Some((lo, hi)) = range.split_once('-') + && let (Ok(lo), Ok(hi)) = (usize::from_str_radix(lo, 16), usize::from_str_radix(hi, 16)) + { + hit = (lo..hi).contains(&addr); + continue; + } + if hit && (line.starts_with("KernelPageSize") || line.starts_with("AnonHugePages")) { + if out == "mapping not found" { + out.clear(); + } + out.push_str(&line.split_whitespace().collect::>().join(" ")); + out.push_str("; "); + } + } + out +} + +fn main() { + let thp = std::fs::read_to_string("/sys/kernel/mm/transparent_hugepage/enabled") + .unwrap_or_else(|_| "unreadable".into()); + println!("transparent_hugepage: {}", thp.trim()); + let mut evict_buf = vec![0u64; 64 << 20]; + let rows: Vec = (0..NROWS) + .map(|i| { + let mut r = [0u8; ROW]; + r[OFF..OFF + 4].copy_from_slice(&((i as i32) * 3 - 7).to_le_bytes()); + r + }) + .collect(); + println!("row buffer backing: {}", backing(rows.as_ptr() as usize)); + let soa = Soa { + rows: &rows, + id: SourceId { + dataset: 1, + branch: 1, + version: 1, + index_seal: 7, + }, + }; + let facet = Facet::new(OFF).unwrap(); + println!( + "slots={SLOTS} rows={NROWS} ({} MiB) facet=i32@{OFF}", + (NROWS * ROW) >> 20 + ); + println!( + "{:>6} {:>8} {:>7} {:>9} {:>9} {:>9} {:>9}", + "P_req", "pages", "lines", "build_w", "build_c", "ns/slot_w", "ns/page_w" + ); + // Two generators over the same P. "skewed": slot i reads row (i % P) * 8 + (i / P) % 8, so the + // number of distinct within-page offsets shrinks as P grows (8 at P=512, 1 at P=4096) and + // cache-set coverage changes with P. "balanced": the offset is rotated by page, so every + // offset is used and the offsets-per-page spread no longer depends on P. + for arm in ["skewed", "balanced", "shuffled"] { + println!("-- {arm}"); + println!( + "{:>6} {:>7} {:>7} {:>5} {:>9} {:>9} {:>9}", + "P_req", "pages", "lines", "offs", "build_w", "build_c", "ns/slot_w" + ); + for p_req in [64usize, 128, 256, 512, 1024, 1536, 2048, 3072, 4096] { + let mut ord = Box::new([0u16; SLOTS]); + for (i, o) in ord.iter_mut().enumerate() { + let (page, visit) = (i % p_req, i / p_req); + let off = if arm == "skewed" { + visit % 8 + } else { + (visit + page % 8) % 8 + }; + *o = ((page * 8 + off) % NROWS) as u16; + } + if arm == "shuffled" { + // Same set of ordinals as `balanced`, seeded Fisher-Yates visit order. + let mut x = 0x9E37_79B9_7F4A_7C15u64 ^ p_req as u64; + for i in (1..SLOTS).rev() { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + ord.swap(i, (x % (i as u64 + 1)) as usize); + } + } + let addr = |o: u16| rows.as_ptr() as usize + o as usize * ROW + OFF; + let pages: HashSet = ord.iter().map(|&o| addr(o) / PAGE).collect(); + let lines: HashSet = ord.iter().map(|&o| addr(o) / 64).collect(); + let offs: HashSet = ord.iter().map(|&o| (addr(o) % PAGE) / 64).collect(); + let mut hist = [0usize; 8]; + for &o in ord.iter() { + hist[(addr(o) % PAGE) / 512] += 1; + } + let map = Arc::new(SealedMap::new(MapId(p_req as u64), 7, ord)); + let warm = median_ns(401, || { + let mgr = FocusManager::new(); + black_box(mgr.load(&soa, &map, Scope(1), facet).unwrap()); + }); + let cold = median_cold_ns(21, &mut evict_buf, || { + let mgr = FocusManager::new(); + black_box(mgr.load(&soa, &map, Scope(1), facet).unwrap()); + }); + println!( + "{:>6} {:>7} {:>7} {:>5} {:>9.0} {:>9.0} {:>9.2}", + p_req, + pages.len(), + lines.len(), + offs.len(), + warm, + cold, + warm / SLOTS as f64 + ); + if p_req == 4096 || p_req == 512 { + println!(" offset-bin histogram (8 x 512 B): {hist:?}"); + } + } + } +}