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:
2026-08-27 18:01:29 +02:00
co-authored by Claude Opus 5
parent 1ac3b21db5
commit 6d2573b92e
3 changed files with 196 additions and 64 deletions
+43
View File
@@ -1026,6 +1026,49 @@ mod tests {
arena::ScratchArena, 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( fn make_events_1v1(
pairs: &[(&'static str, &'static str)], pairs: &[(&'static str, &'static str)],
outcomes: &[Outcome], outcomes: &[Outcome],
+118 -56
View File
@@ -1,15 +1,27 @@
use std::collections::HashMap;
use crate::{Index, time_slice::Skill}; use crate::{Index, time_slice::Skill};
/// Dense Vec-backed store for per-agent skill state within a `TimeSlice`. /// Compact per-slice store for skill state, addressed by a slice-local slot.
/// ///
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the inner /// `skills` holds one entry per competitor **in this slice**, so memory is
/// convergence loop. Uses a parallel `present` mask so iteration skips /// O(competitors in the slice). It used to be a dense `Vec<Skill>` indexed by
/// absent slots without incurring per-slot Option overhead in the hot path. /// the global `Index.0`, which made a slice's footprint O(largest index it
/// touches): a single 1v1 game between competitors 19998 and 19999 reserved
/// 20,000 slots.
///
/// The dense layout existed to keep `HashMap` hashing out of the inner
/// convergence loop, and that property is preserved. `slots` is consulted only
/// while building a slice; every hot-path access goes through
/// [`SkillStore::at`] / [`SkillStore::at_mut`] with a slot resolved once at
/// ingestion and cached on the event's `Item`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct SkillStore { pub struct SkillStore {
skills: Vec<Skill>, skills: Vec<Skill>,
present: Vec<bool>, /// Slot -> global index, parallel to `skills`, so iteration can report the
n_present: usize, /// global index without a reverse lookup.
indices: Vec<Index>,
slots: HashMap<Index, u32>,
} }
impl SkillStore { impl SkillStore {
@@ -17,73 +29,99 @@ impl SkillStore {
Self::default() Self::default()
} }
fn ensure_capacity(&mut self, idx: usize) { /// Resolve a global index to this slice's slot, if the competitor is here.
if idx >= self.skills.len() { ///
self.skills.resize_with(idx + 1, Skill::default); /// This hashes. Call it at ingestion and cache the result; do not call it
self.present.resize(idx + 1, false); /// from the convergence loop.
} pub fn slot_of(&self, idx: Index) -> Option<u32> {
self.slots.get(&idx).copied()
} }
pub fn insert(&mut self, idx: Index, skill: Skill) { /// Skill at a slot resolved earlier by [`SkillStore::slot_of`].
self.ensure_capacity(idx.0); ///
if !self.present[idx.0] { /// # Panics
self.n_present += 1; ///
/// Panics if `slot` is out of range, which means it came from a different
/// slice's store.
pub fn at(&self, slot: u32) -> &Skill {
&self.skills[slot as usize]
}
/// Mutable counterpart to [`SkillStore::at`].
///
/// # Panics
///
/// Panics if `slot` is out of range.
pub fn at_mut(&mut self, slot: u32) -> &mut Skill {
&mut self.skills[slot as usize]
}
/// Insert or overwrite a competitor's skill, returning its slot.
pub fn insert(&mut self, idx: Index, skill: Skill) -> u32 {
match self.slots.get(&idx) {
Some(&slot) => {
self.skills[slot as usize] = skill;
slot
}
None => {
let slot = u32::try_from(self.skills.len())
.expect("a time slice cannot hold more than u32::MAX competitors");
self.skills.push(skill);
self.indices.push(idx);
self.slots.insert(idx, slot);
slot
}
} }
self.skills[idx.0] = skill;
self.present[idx.0] = true;
} }
pub fn get(&self, idx: Index) -> Option<&Skill> { pub fn get(&self, idx: Index) -> Option<&Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { self.slot_of(idx).map(|slot| self.at(slot))
Some(&self.skills[idx.0])
} else {
None
}
}
/// Whether a slot is occupied. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
/// Number of occupied slots. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.n_present
} }
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> { pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { self.slot_of(idx)
Some(&mut self.skills[idx.0]) .map(|slot| &mut self.skills[slot as usize])
} else {
None
}
} }
/// Whether a competitor is present in this slice. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
self.slots.contains_key(&idx)
}
/// Number of competitors in this slice. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.skills.len()
}
/// Slots actually allocated — the quantity #17 is about, and NOT the same
/// as `len` for every possible implementation.
///
/// A store indexed by the global `Index` must report `max_index + 1` here
/// while reporting the true competitor count from `len`, which is exactly
/// how the original defect hid. Tests that mean to pin the footprint must
/// assert on this.
#[cfg(test)]
pub fn allocated_slots(&self) -> usize {
self.skills.len()
}
/// Iterate in slot order — the order competitors were first seen in this
/// slice. Deterministic for a given event order, which is what the
/// cross-thread determinism test relies on.
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| { self.indices.iter().copied().zip(self.skills.iter())
if p {
Some((Index(i), &self.skills[i]))
} else {
None
}
})
} }
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> { pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
self.skills self.indices.iter().copied().zip(self.skills.iter_mut())
.iter_mut()
.zip(self.present.iter())
.enumerate()
.filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None })
} }
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ { pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
self.present self.indices.iter().copied()
.iter()
.enumerate()
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
} }
} }
@@ -109,7 +147,7 @@ mod tests {
} }
#[test] #[test]
fn iter_skips_absent_slots() { fn iter_reports_global_indices() {
let mut store = SkillStore::new(); let mut store = SkillStore::new();
store.insert(Index(0), Skill::default()); store.insert(Index(0), Skill::default());
store.insert(Index(5), Skill::default()); store.insert(Index(5), Skill::default());
@@ -124,4 +162,28 @@ mod tests {
store.insert(Index(2), Skill::default()); store.insert(Index(2), Skill::default());
assert_eq!(store.len(), 1); assert_eq!(store.len(), 1);
} }
/// The defect in #17: a slice holding two competitors must cost the same
/// whether their indices are small or large.
#[test]
fn footprint_is_independent_of_index_magnitude() {
let mut low = SkillStore::new();
low.insert(Index(0), Skill::default());
low.insert(Index(1), Skill::default());
let mut high = SkillStore::new();
high.insert(Index(19_998), Skill::default());
high.insert(Index(19_999), Skill::default());
assert_eq!(low.len(), high.len());
assert_eq!(low.skills.capacity(), high.skills.capacity());
}
#[test]
fn slot_survives_reinsert() {
let mut store = SkillStore::new();
let first = store.insert(Index(7), Skill::default());
let again = store.insert(Index(7), Skill::default());
assert_eq!(first, again);
}
} }
+35 -8
View File
@@ -51,6 +51,13 @@ pub enum EventKind {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Item { struct Item {
agent: Index, agent: Index,
/// This competitor's slot in the owning slice's `SkillStore`, resolved
/// once at ingestion.
///
/// The convergence loop reaches skills through this rather than through
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
/// that the store is compact rather than indexed by the global `Index`.
slot: u32,
likelihood: Gaussian, likelihood: Gaussian,
} }
@@ -62,7 +69,7 @@ impl Item {
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
) -> Rating<T, D> { ) -> Rating<T, D> {
let r = &agents[self.agent].rating; let r = &agents[self.agent].rating;
let skill = skills.get(self.agent).unwrap(); let skill = skills.at(self.slot);
if forward { if forward {
Rating::new(skill.forward, r.beta, r.drift) Rating::new(skill.forward, r.beta, r.drift)
@@ -157,9 +164,9 @@ impl Event {
for (t, team) in self.teams.iter_mut().enumerate() { for (t, team) in self.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i]; let fresh = update.likelihoods[t][i];
let old_likelihood = skills.get(item.agent).unwrap().likelihood; let old_likelihood = skills.at(item.slot).likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * fresh; let new_likelihood = (old_likelihood / item.likelihood) * fresh;
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood; skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = fresh; item.likelihood = fresh;
} }
} }
@@ -297,14 +304,16 @@ impl<T: Time> TimeSlice<T> {
for idx in this_agent { for idx in this_agent {
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time); let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
let forward = agents[*idx].receive(&self.time);
if let Some(skill) = self.skills.get_mut(*idx) { if let Some(skill) = self.skills.get_mut(*idx) {
skill.elapsed = elapsed; skill.elapsed = elapsed;
skill.forward = agents[*idx].receive(&self.time); skill.forward = forward;
} else { } else {
self.skills.insert( self.skills.insert(
*idx, *idx,
Skill { Skill {
forward: agents[*idx].receive(&self.time), forward,
backward: N_INF, backward: N_INF,
likelihood: N_INF, likelihood: N_INF,
elapsed, elapsed,
@@ -313,6 +322,8 @@ impl<T: Time> TimeSlice<T> {
} }
} }
let skills = &self.skills;
let events = composition.iter().enumerate().map(|(e, event)| { let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event let teams = event
.iter() .iter()
@@ -322,6 +333,11 @@ impl<T: Time> TimeSlice<T> {
.iter() .iter()
.map(|&agent| Item { .map(|&agent| Item {
agent, agent,
// Every participant was inserted into `skills`
// just above, so the slot always resolves.
slot: skills
.slot_of(agent)
.expect("participant must be present in the slice store"),
likelihood: N_INF, likelihood: N_INF,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -408,10 +424,10 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.iter_mut().enumerate() { for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood; let old_likelihood = self.skills.at(item.slot).likelihood;
let new_likelihood = let new_likelihood =
(old_likelihood / item.likelihood) * g.likelihoods[t][i]; (old_likelihood / item.likelihood) * g.likelihoods[t][i];
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood; self.skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i]; item.likelihood = g.likelihoods[t][i];
} }
} }
@@ -633,7 +649,7 @@ impl<T: Time> TimeSlice<T> {
None => rating.prior, None => rating.prior,
}; };
scratch.skills.insert( let slot = scratch.skills.insert(
agent, agent,
Skill { Skill {
forward, forward,
@@ -642,6 +658,17 @@ impl<T: Time> TimeSlice<T> {
elapsed: skill.elapsed, elapsed: skill.elapsed,
}, },
); );
// The cloned events carry slots resolved against the REAL store, so
// the scratch must assign the same ones. It does because `iter()`
// yields slot order and `insert` allocates slots in call order —
// but that is a coupling between two types, so pin it here rather
// than leave it to be rediscovered after it breaks.
debug_assert_eq!(
Some(slot),
self.skills.slot_of(agent),
"scratch slot must match the real slice's slot for {agent:?}"
);
} }
scratch.iterate_to_convergence(agents); scratch.iterate_to_convergence(agents);