perf: make the per-slice SkillStore compact instead of dense
Closes #17. Each `TimeSlice` owned a `Vec<Skill>` indexed by the GLOBAL `Index.0`, so a slice's footprint was O(largest index it touches) rather than O(competitors in it). Two competitors at 19998/19999 reserved 20,000 slots per slice; the same games between indices 0 and 1 reserved two. The store is now a compact `Vec<Skill>` plus a `HashMap<Index, u32>` slot map and a parallel `Vec<Index>` for iteration. The hash is paid once at ingestion: each event's `Item` caches its slot, and the convergence loop reaches skills through `at`/`at_mut` by slot, so no hashing enters the hot path — which is the property the dense layout existed to provide. Measured on the issue's own workload (200 slices, one 1v1 each, 20,000-key roster, release, peak RSS): indices 0 / 1 52 MB -> 5.55 MB indices 19998 / 19999 309 MB -> 8.39 MB The 257 MB gap is now 2.8 MB, and that residual is CompetitorStore, which is also dense over the global index but is a single store for the whole history rather than one per slice — so it does not multiply. Left alone deliberately. Benchmarks, against the pre-change code: Batch::iteration +2.4% (regressed) history_converge x3 -18.8%, -21.6%, -21.7% (improved) The three convergence benchmarks are the realistic workload and they gain ~20% from the better locality of a compact store. The micro-benchmark loses 2.4% because `Item` grew eight bytes for the cached slot; `agent` cannot be dropped to compensate, since `within_prior` still needs the global index to reach the competitor's rating. I judged 2.4% on one micro-benchmark an acceptable price for ~20% on the real ones plus the memory fix, but it is a regression against #17's stated "no regression" criterion, so it is called out rather than buried. The regression test asserts on a new test-only `allocated_slots()`, not on `len()`. That distinction is load-bearing: the old dense store reported the true competitor count from `len()` while allocating max_index+1 slots, so a test written against `len()` would have passed on the defect. Mutation-proved by re-adding the dense padding, which fails it. One coupling is now pinned by a debug_assert: `filtered_step` clones events whose `Item`s carry slots resolved against the REAL store, so its scratch store must assign identical slots. It does, because `iter()` yields slot order and `insert` allocates in call order — but that is an invariant across two types, so it is asserted rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
This commit is contained in:
@@ -1026,6 +1026,49 @@ mod tests {
|
||||
arena::ScratchArena,
|
||||
};
|
||||
|
||||
/// #17: a slice's footprint must be O(competitors in the slice), not
|
||||
/// O(largest global index it touches). The store used to be a dense
|
||||
/// `Vec<Skill>` indexed by `Index.0`, so the same two-competitor games cost
|
||||
/// 20,000 slots per slice when the competitors sat at the top of a large
|
||||
/// roster. Measured end to end, peak RSS was 309 MB against 52 MB.
|
||||
#[test]
|
||||
fn per_slice_footprint_is_independent_of_index_magnitude() {
|
||||
fn total_skill_slots(high_indices: bool) -> usize {
|
||||
let mut h: History<i64, ConstantDrift, NullObserver, String> =
|
||||
History::builder_with_key().build();
|
||||
|
||||
for i in 0..2_000 {
|
||||
h.intern(&format!("k{i:05}"));
|
||||
}
|
||||
|
||||
let (a, b) = if high_indices {
|
||||
("k01998".to_string(), "k01999".to_string())
|
||||
} else {
|
||||
("k00000".to_string(), "k00001".to_string())
|
||||
};
|
||||
|
||||
for time in 1..=20i64 {
|
||||
h.record_winner(&a, &b, time).unwrap();
|
||||
}
|
||||
|
||||
h.time_slices
|
||||
.iter()
|
||||
.map(|ts| ts.skills.allocated_slots())
|
||||
.sum()
|
||||
}
|
||||
|
||||
let low = total_skill_slots(false);
|
||||
let high = total_skill_slots(true);
|
||||
|
||||
assert_eq!(low, high, "footprint must not depend on index magnitude");
|
||||
|
||||
// A dense store over a 2,000-key roster would allocate 20 x 2,000.
|
||||
assert!(
|
||||
high < 1_000,
|
||||
"20 slices of 2 competitors allocated {high} slots"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_events_1v1(
|
||||
pairs: &[(&'static str, &'static str)],
|
||||
outcomes: &[Outcome],
|
||||
|
||||
Reference in New Issue
Block a user