diff --git a/.claude/board/entries/2026-10-25-drf-churn-policy.md b/.claude/board/entries/2026-10-25-drf-churn-policy.md new file mode 100644 index 000000000..1f7c79c43 --- /dev/null +++ b/.claude/board/entries/2026-10-25-drf-churn-policy.md @@ -0,0 +1,24 @@ +# D-DRF-CHURN — LRU vs MRU victim order under focus churn (lab) + +**Status:** MEASURED (lab, `cargo test`; no CI job runs this crate). +**Code:** `crates/drf-focus-probe/src/lib.rs` `Evict`, `FocusManager::evict_to_budget_by` +(`evict_to_budget` stays LRU). **Tests:** `tests/integration/budget.rs` `churn_*`. +Follows `2026-10-25-drf-w8-budget.md` (its OPEN point on the policy). + +One eviction pass after every load, 3 foci. Builds counted; each expected count was worked out +by hand before the run, and all matched. + +| workload | budget | LRU | MRU | +|---|---|---|---| +| cyclic ABC x10 (30 loads) | 3 | 3 | 3 | +| cyclic ABC x10 | 2 | 30 | 12 | +| cyclic ABC x10 | 1 | 30 | 21 | +| phased AAAA BBBB CCCC x2 (24 loads) | 2 | 6 | 10 | + +- Neither order wins both workloads. MRU keeps two of three cyclic foci hot; under phased use it + evicts the focus just loaded, so every load in the third phase rebuilds. +- **Disable, red:** making `Evict::Mru` sort like LRU turns both `churn_*` comparison tests red. + +OPEN: which access pattern thought workloads have is still unmeasured, so no default change. +A scan-resistant policy (one that adapts between the two) or a direct-read fallback are the +next candidates; neither is tried here. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 9ac475956..baf6fd483 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. -471 entries, 2026-08-06 .. 2026-10-25. +472 entries, 2026-08-06 .. 2026-10-25. | date | entry id | finding | file | |---|---|---|---| @@ -34,6 +34,7 @@ exactly one of them, never both. | 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-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) | | 2026-10-24 | `D-DRF-P2` | | [2026-10-24-drf-p2-breakeven.md](2026-10-24-drf-p2-breakeven.md) | | 2026-10-24 | `D-DRF-P1` | | [2026-10-24-drf-p1-focus-lease-probe.md](2026-10-24-drf-p1-focus-lease-probe.md) | diff --git a/crates/drf-focus-probe/src/lib.rs b/crates/drf-focus-probe/src/lib.rs index 2d641126a..0db9f46d4 100644 --- a/crates/drf-focus-probe/src/lib.rs +++ b/crates/drf-focus-probe/src/lib.rs @@ -198,6 +198,15 @@ pub struct BudgetReport { pub over_budget: bool, } +/// Victim order for [`FocusManager::evict_to_budget_by`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Evict { + /// Least recently loaded first. + Lru, + /// Most recently loaded first. + Mru, +} + struct Entry { slot: Slot, /// Manager tick of the most recent `load` of this key. @@ -291,6 +300,12 @@ impl FocusManager { /// being built counts as pinned. An evicted focus is rebuilt on its next /// `load`. pub fn evict_to_budget(&self, budget_bytes: usize) -> BudgetReport { + self.evict_to_budget_by(budget_bytes, Evict::Lru) + } + + /// [`evict_to_budget`](Self::evict_to_budget) with the victim order + /// chosen by `order`. Pinning and the stop rule are the same. + pub fn evict_to_budget_by(&self, budget_bytes: usize, order: Evict) -> BudgetReport { let mut g = self.entries.lock().unwrap_or_else(|e| e.into_inner()); let mut resident = 0; let mut pinned = 0; @@ -309,7 +324,10 @@ impl FocusManager { None => pinned += FOCUS_BYTES, } } - victims.sort_unstable_by_key(|v| v.0); + match order { + Evict::Lru => victims.sort_unstable_by_key(|v| v.0), + Evict::Mru => victims.sort_unstable_by_key(|v| std::cmp::Reverse(v.0)), + } let mut evicted = 0; for (_, k) in victims { if resident <= budget_bytes { diff --git a/crates/drf-focus-probe/tests/integration/budget.rs b/crates/drf-focus-probe/tests/integration/budget.rs index 23bedf3f2..2c9eaaa37 100644 --- a/crates/drf-focus-probe/tests/integration/budget.rs +++ b/crates/drf-focus-probe/tests/integration/budget.rs @@ -138,3 +138,49 @@ fn churn_cyclic_switching_rebuilds_every_load_once_the_budget_is_one_short() { assert_eq!(cyclic_builds(3, 2, 10), 30, "every load is a rebuild"); assert_eq!(cyclic_builds(3, 1, 10), 30); } + +/// Builds for a load sequence (indices into `k` maps) with an eviction pass +/// after every load. +fn builds_for(k: u64, seq: &[usize], budget_foci: usize, order: Evict) -> usize { + let r = rows(); + let s = soa(&r); + let maps: Vec<_> = (1..=k).map(map).collect(); + let mgr = FocusManager::new(); + for &i in seq { + drop(mgr.load(&s, &maps[i], Scope(1), facet()).unwrap()); + mgr.evict_to_budget_by(budget_foci * FOCUS_BYTES, order); + } + mgr.builds() +} + +fn cycle(rounds: usize) -> Vec { + (0..rounds).flat_map(|_| [0, 1, 2]).collect() +} + +/// AAAA BBBB CCCC, `rounds` times. +fn phases(rounds: usize) -> Vec { + (0..rounds) + .flat_map(|_| [0, 1, 2]) + .flat_map(|i| [i; 4]) + .collect() +} + +#[test] +fn churn_mru_keeps_part_of_a_cycle_that_lru_loses_whole() { + // 30 cyclic loads over 3 foci. Expected counts worked out by hand before + // the run: MRU evicts the focus just loaded, so the other two stay hot. + assert_eq!(builds_for(3, &cycle(10), 2, Evict::Lru), 30); + assert_eq!(builds_for(3, &cycle(10), 2, Evict::Mru), 12); + assert_eq!(builds_for(3, &cycle(10), 1, Evict::Lru), 30); + assert_eq!(builds_for(3, &cycle(10), 1, Evict::Mru), 21); + // Both policies agree when the set fits. + assert_eq!(builds_for(3, &cycle(10), 3, Evict::Mru), 3); +} + +#[test] +fn churn_lru_wins_when_foci_are_used_in_phases() { + // AAAA BBBB CCCC twice, budget 2. MRU evicts C right after each load, so + // every C load in the phase rebuilds; LRU rebuilds once per phase. + assert_eq!(builds_for(3, &phases(2), 2, Evict::Lru), 6); + assert_eq!(builds_for(3, &phases(2), 2, Evict::Mru), 10); +}