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:
+35
-8
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user