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
+35 -8
View File
@@ -51,6 +51,13 @@ pub enum EventKind {
#[derive(Clone, Debug)]
struct Item {
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,
}
@@ -62,7 +69,7 @@ impl Item {
agents: &CompetitorStore<T, D>,
) -> Rating<T, D> {
let r = &agents[self.agent].rating;
let skill = skills.get(self.agent).unwrap();
let skill = skills.at(self.slot);
if forward {
Rating::new(skill.forward, r.beta, r.drift)
@@ -157,9 +164,9 @@ impl Event {
for (t, team) in self.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
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;
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = fresh;
}
}
@@ -297,14 +304,16 @@ impl<T: Time> TimeSlice<T> {
for idx in this_agent {
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) {
skill.elapsed = elapsed;
skill.forward = agents[*idx].receive(&self.time);
skill.forward = forward;
} else {
self.skills.insert(
*idx,
Skill {
forward: agents[*idx].receive(&self.time),
forward,
backward: N_INF,
likelihood: N_INF,
elapsed,
@@ -313,6 +322,8 @@ impl<T: Time> TimeSlice<T> {
}
}
let skills = &self.skills;
let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event
.iter()
@@ -322,6 +333,11 @@ impl<T: Time> TimeSlice<T> {
.iter()
.map(|&agent| Item {
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,
})
.collect::<Vec<_>>();
@@ -408,10 +424,10 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.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 =
(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];
}
}
@@ -633,7 +649,7 @@ impl<T: Time> TimeSlice<T> {
None => rating.prior,
};
scratch.skills.insert(
let slot = scratch.skills.insert(
agent,
Skill {
forward,
@@ -642,6 +658,17 @@ impl<T: Time> TimeSlice<T> {
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);