From 7edbaa7753280841ecc93dfbe201aacabd35dd4c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 29 Aug 2026 22:03:03 -0400 Subject: [PATCH] perf(candidates): split the volley memo into what fires and what it does The fast key memoised a whole volley, so side and target id were in a key they do not affect: 454,024 entries, ~36 MiB, consulted twice an exchange against an L2 of one or two. Split in two, level one keyed on the geometry and to-hit terms alone with an interned shot list as its value: 119,507 geometries, 1,071 shot lists and 12,024 outcomes. 2x off the sweep. --- crates/sds-core/examples/perf.rs | 6 + crates/sds-core/src/volley.rs | 445 ++++++++++++++++++++++++++----- 2 files changed, 377 insertions(+), 74 deletions(-) diff --git a/crates/sds-core/examples/perf.rs b/crates/sds-core/examples/perf.rs index 54c736a..b8c89f4 100644 --- a/crates/sds-core/examples/perf.rs +++ b/crates/sds-core/examples/perf.rs @@ -922,6 +922,12 @@ sweep fixture: {} stands x {} enemies x {positions} positions = {} pairs", 100.0 * swept.sparsity(), swept.footprint() / 1024 ); + let (geometries, lists, outcomes) = cache.residency(); + println!( + " volley memo residency: {geometries} geometries, {lists} shot lists, \ + {outcomes} outcomes, {} refused", + cache.stats().refused + ); let stats = cache.stats(); let asked = stats.hits + stats.misses; println!( diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index d95fdd5..a29992a 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -601,6 +601,10 @@ pub struct VolleyStats { pub nothing_bears: u64, /// Answered from the memo. pub hits: u64, + /// Pairs whose [`FiresKey`] or [`LandKey`] would not pack, and so took the + /// slow path. Correct but not memoised; a non-zero count means a field is + /// too narrow for the board or the modifiers in play. + pub refused: u64, /// Actually solved. pub misses: u64, /// Wall clock in `gather` against wall clock in the memo lookup, in @@ -633,12 +637,20 @@ pub const TIMING_STRIDE: u64 = 64; pub struct VolleyCache { ev: EvCache, outcomes: BTreeMap, - /// The first-level memo. See [`FastKey`]: a hit here never builds a - /// `VolleyKey`, never walks the weapons and never builds a profile. + /// Level one: geometry to what fires. See [`FiresKey`]. A hit here never + /// walks the weapons, never builds a profile and never allocates. /// /// A `HashMap` on a packed integer, and nothing iterates it, so no result - /// depends on its order. - fast: std::collections::HashMap, + /// depends on its order. Same for the two below. + fires: std::collections::HashMap, + /// The interned shot lists level one indexes into. + fires_table: Vec, + /// The other direction, for interning: a shot list to its index. + fires_index: std::collections::HashMap, + /// Level two: what a shot list does to one target from one side. See + /// [`LandKey`]. The heat is already in the outcome here, because heat is a + /// function of the shot list and the twist and both are named by the key. + lands: std::collections::HashMap, stats: VolleyStats, } @@ -657,6 +669,17 @@ impl VolleyCache { self.stats } + /// How many entries each level of the fast path holds: + /// `(geometries, distinct shot lists, outcomes)`. + /// + /// The measurement the two-level split exists for. One level keyed on + /// `(geometry, side, target)` held all three counts multiplied together in + /// one map, and the map was larger than the last level of cache; these three + /// are what say it still is not. + pub fn residency(&self) -> (usize, usize, usize) { + (self.fires.len(), self.fires_table.len(), self.lands.len()) + } + /// The estimate for a key, computed at most once. pub fn outcome(&mut self, key: &VolleyKey) -> VolleyOutcome { if let Some(outcome) = self.outcomes.get(key) { @@ -673,10 +696,33 @@ impl VolleyCache { pub fn clear(&mut self) { self.ev.clear(); self.outcomes.clear(); - // The first-level memo holds the target's armour state through the - // profile the slow path built from it, exactly as `outcomes` does, so it - // has the same lifetime and goes at the same moment. - self.fast.clear(); + // Level two holds the target's armour state through the profile the + // slow path built from it, exactly as `outcomes` does, so it has the + // same lifetime and goes at the same moment. Level one holds only + // geometry and would survive, but it indexes into `fires_table` and the + // two must not be cleared apart. + self.fires.clear(); + self.fires_table.clear(); + self.fires_index.clear(); + self.lands.clear(); + } + + /// The index of a shot list, interning it if it is new. + /// + /// Takes the list by value and only clones it when it is new - 1,071 times + /// over a unit-decision against 119,507 asks - so a repeat costs a hash and + /// a compare and no allocation. + fn intern(&mut self, fires: Fires) -> u32 { + if let Some(&id) = self.fires_index.get(&fires) { + return id; + } + let id = self.fires_table.len() as u32; + // `FIRES_NOTHING` is the one index that is not a shot list. A table + // that reached it would answer "nothing bears" for a volley that fires. + assert!(id != FIRES_NOTHING, "interned shot lists overflowed"); + self.fires_table.push(fires.clone()); + self.fires_index.insert(fires, id); + id } } @@ -1004,11 +1050,20 @@ pub fn best_volley( /// that is 469ns a gather against 594ns for the whole memoised call. This key is /// the same question asked before any of that happens. /// -/// **What determines a volley, given a fixed weapon list.** The geometry of the -/// pair, our facing and whether we may twist; the four fixed to-hit terms; the -/// side the shot lands on; and which unit is being shot at. Nothing else in -/// `gather` reads anything else: elevation, height and `is_mek` reach the answer -/// only through the line modifier, which is inside `other`. +/// **What determines what a volley *fires*, given a fixed weapon list.** The +/// geometry of the pair, our facing and whether we may twist, and the four fixed +/// to-hit terms. Nothing else in [`gather`] reaches the shot lists: elevation, +/// height and `is_mek` arrive only through the line modifier, which is inside +/// `other`. +/// +/// **Which side the shot lands on and which unit is being shot are not in +/// here**, and that is the whole point. They decide what the volley *does* - +/// the hit table and the armour pool - and not one weapon's bearing, bracket or +/// to-hit. Keying them here made one entry per `(geometry, side, target)` where +/// the geometry alone will do: measured over one 8v8 unit-decision, 454,024 +/// entries against 119,507, each carrying a whole [`VolleyOutcome`]. That map is +/// 36 MiB against an 18 MiB L3, so every one of its two lookups an exchange was +/// a trip to memory. See [`LandKey`] for the half that was split off. /// /// **The geometry is a cube delta**, from [`crate::hex::cube_delta`], and not an /// absolute pair of hexes. `crate::arc` works in offset coordinates where column @@ -1022,18 +1077,34 @@ pub fn best_volley( /// our guns for the outgoing half and with each enemy's for the incoming one, so /// a key without it would answer one unit's question with another's loadout. /// -/// Packed rather than derived so the map hashes sixteen bytes instead of forty, -/// and [`FastKey::pack`] returns `None` rather than truncating: a field out of -/// range falls back to the slow path, which is always correct. +/// Packed into one word so the map hashes eight bytes instead of forty and an +/// entry is sixteen, and [`FiresKey::pack`] returns `None` rather than +/// truncating: a field out of range falls back to the slow path, which is always +/// correct. [`VolleyStats::refused`] counts those, so a board or a modifier that +/// outgrew a field says so instead of quietly costing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct FastKey(u128); +pub struct FiresKey(u64); + +/// Centre a signed value in `bits` and refuse it if it does not fit. +/// +/// Shared by both keys so the two packings cannot drift in how they refuse. +#[inline] +fn field(value: i32, bits: u32) -> Option { + let span = 1i64 << bits; + let shifted = i64::from(value) + span / 2; + (shifted >= 0 && shifted < span).then_some(shifted as u64) +} -impl FastKey { +impl FiresKey { /// Pack, or `None` if anything is out of the range the packing allows. + /// + /// Sixty-three bits of the sixty-four. The widths are far wider than a + /// board, a gunnery or a movement modifier needs - a cube delta of +/-512 + /// is four map sheets in any direction - so an unexpected value cannot + /// alias another key, only refuse. #[allow(clippy::too_many_arguments)] fn pack( loadout: i32, - target: i32, delta: (i32, i32), facing: i32, can_twist: bool, @@ -1042,41 +1113,92 @@ impl FastKey { attacker_movement: i32, target_movement: i32, other: i32, - side: los::Side, ) -> Option { - // Every field checked, and a miss here costs the slow path rather than a - // wrong answer. The widths are far wider than a board or a to-hit needs; - // they exist so that an unexpected value cannot alias another key. - let field = |v: i32, bits: u32| -> Option { - let span = 1i64 << bits; - let shifted = i64::from(v) + span / 2; - (shifted >= 0 && shifted < span).then_some(shifted as u128) - }; - let mut packed: u128 = 0; - let mut put = |value: u128, bits: u32| { + let mut packed: u64 = 0; + let mut put = |value: u64, bits: u32| { packed = (packed << bits) | value; }; - put(field(loadout, 20)?, 20); - put(field(target, 20)?, 20); - put(field(delta.0, 12)?, 12); - put(field(delta.1, 12)?, 12); - put(field(facing.rem_euclid(6), 4)?, 4); - put(u128::from(can_twist), 1); - put(u128::from(extended_twist), 1); - put(field(gunnery, 8)?, 8); - put(field(attacker_movement, 8)?, 8); - put(field(target_movement, 8)?, 8); - put(field(other, 12)?, 12); - put(side as u128, 2); + put(field(loadout, 16)?, 16); + put(field(delta.0, 10)?, 10); + put(field(delta.1, 10)?, 10); + put(u64::from(facing.rem_euclid(6) as u32), 3); + put(u64::from(can_twist), 1); + put(u64::from(extended_twist), 1); + put(field(gunnery, 5)?, 5); + put(field(attacker_movement, 4)?, 4); + put(field(target_movement, 5)?, 5); + put(field(other, 8)?, 8); Some(Self(packed)) } } -/// A hasher for [`FastKey`], which is one packed integer and nothing else. +/// What a volley *does*, given what it fires: the second level. +/// +/// `(shot lists, side, target)`. The shot lists are [`FiresKey`]'s answer, +/// interned to a dense index - 1,071 distinct lists over a whole unit-decision, +/// because a shot list is a handful of `ShotKey`s and most geometries produce +/// one of a few - so this key is three small integers and the map behind it is +/// 12,024 entries rather than 454,024. +/// +/// The target is named by id rather than by its [`LocationProfile`]: the profile +/// is a function of `(unit, side)` and the cache lives one turn, exactly the +/// assumption the single-level key already made. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LandKey(u64); + +impl LandKey { + /// Pack, or `None` if the target id does not fit. + /// + /// `fires` is our own dense index and `side` is a two-bit enum, so only the + /// target can refuse. + fn pack(fires: u32, target: i32, side: los::Side) -> Option { + let fires = u64::from(fires); + // Our own table's index. It cannot reach this without the interning + // table being larger than any board could make it, but the key would + // alias rather than refuse, so it is checked. + if fires >= 1 << 24 { + return None; + } + Some(Self( + (fires << 26) | (field(target, 24)? << 2) | (side as u64), + )) + } +} + +/// One geometry's answer to "what fires": a shot list per twist, and the heat +/// each costs. +/// +/// This is [`Gathered`] with the target taken out of it - the same `options` and +/// `heats`, and no `side` and no [`LocationProfile`]. Interned, so a +/// [`FiresKey`] entry is a four-byte index rather than two vectors. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Fires { + options: Vec, + heats: Vec, +} + +impl Fires { + /// The heat of the option at `twist`, or zero if no option has that twist. + /// The same rule as [`Gathered::heat_at`], over the same two vectors. + fn heat_at(&self, twist: i32) -> i32 { + self.options + .iter() + .position(|option| option.twist == twist) + .map(|at| self.heats[at]) + .unwrap_or(0) + } +} + +/// The [`FiresKey`] value for a geometry where no weapon bears at any twist. +/// +/// In band rather than an `Option`, so the map's value stays four bytes. +const FIRES_NOTHING: u32 = u32::MAX; + +/// A hasher for [`FiresKey`] and [`LandKey`], each one packed integer. /// -/// SipHash over sixteen bytes is more than a memo lookup needs. The obvious +/// SipHash over eight bytes is more than a memo lookup needs. The obvious /// alternative - `los::BuildFx`, the multiply-rotate already in this crate - was -/// tried and measured **15% slower**, because `FastKey` packs its +/// tried and measured **15% slower**, because a packed key puts its /// high-entropy fields at fixed bit positions and a weak mixer clusters on /// exactly that layout, trading cheaper hashing for longer probes. /// @@ -1126,10 +1248,6 @@ impl std::hash::Hasher for Mix { } } -/// What the fast path remembers: the side a volley lands on and its outcome, or -/// `None` for a pair where nothing bears. -type FastAnswer = Option<(los::Side, VolleyOutcome)>; - /// The line modifier for a pair, or `None` when there is no usable line. /// /// The only part of answering a volley that touches the board, pulled out so @@ -1215,9 +1333,8 @@ pub fn best_volley_with_line( }; let side = los::side_table(target.hex, target.facing, state.hex); - let key = FastKey::pack( + let fires_key = FiresKey::pack( loadout, - target.unit.id, hex::cube_delta(state.hex, target.hex), state.facing, state.can_twist, @@ -1226,38 +1343,85 @@ pub fn best_volley_with_line( state.gait.to_hit(), los::target_movement_modifier(target.hexes_moved, target.jumped), line_modifier + los::target_terrain_modifier(los_cache.board().hex(target.hex)), - side, ); - if let Some(key) = key { - if let Some(answer) = cache.fast.get(&key) { + // Level one. What fires is a function of the geometry and the fixed + // modifiers alone, so this map has one entry per shape rather than one per + // (shape, side, target), and its value is a four-byte index. + let fires = match fires_key { + Some(key) => cache.fires.get(&key).copied(), + None => None, + }; + + if let Some(id) = fires { + if id == FIRES_NOTHING { cache.stats.asked += 1; cache.stats.hits += 1; - return match answer { - Some((side, outcome)) => (Ok(*side), *outcome), - None => { - cache.stats.nothing_bears += 1; - ( - Err(NoVolley::NothingBears), - VolleyOutcome::nothing(state.facing), - ) - } - }; + cache.stats.nothing_bears += 1; + return ( + Err(NoVolley::NothingBears), + VolleyOutcome::nothing(state.facing), + ); } + // Level two. Small enough to stay in cache: the shot list is already an + // index, and the only things added are which unit is being shot and + // from which side. + let land = LandKey::pack(id, target.unit.id, side); + if let Some(land) = land { + if let Some(outcome) = cache.lands.get(&land) { + cache.stats.asked += 1; + cache.stats.hits += 1; + return (Ok(side), *outcome); + } + } else { + cache.stats.refused += 1; + } + // A shot list we know, at a target we have not yet asked about. The + // weapons do not need walking again - only the damage does. + cache.stats.asked += 1; + let options = cache.fires_table[id as usize].options.clone(); + let key = VolleyKey { + side, + profile: LocationProfile::of(target.unit, side), + options, + }; + let mut outcome = cache.outcome(&key); + outcome.heat = cache.fires_table[id as usize].heat_at(outcome.twist); + if let Some(land) = land { + cache.lands.insert(land, outcome); + } + return (Ok(side), outcome); + } + + if fires_key.is_none() { + cache.stats.refused += 1; } // The slow path is the whole of the old one, counters included, so there is // one implementation of what a volley is and this only ever decides whether // to ask it. - let (full, outcome) = best_volley_keyed(cache, los_cache, state, target, weapons); - let answer: FastAnswer = match &full { - Ok(k) => Some((k.side, outcome)), - Err(_) => None, - }; - if let Some(key) = key { - cache.fast.insert(key, answer); + match solved(cache, los_cache, state, target, weapons) { + Ok((gathered, outcome)) => { + let answer_side = gathered.key.side; + if let Some(key) = fires_key { + let id = cache.intern(Fires { + options: gathered.key.options, + heats: gathered.heats, + }); + cache.fires.insert(key, id); + if let Some(land) = LandKey::pack(id, target.unit.id, answer_side) { + cache.lands.insert(land, outcome); + } + } + (Ok(answer_side), outcome) + } + Err(reason) => { + if let (Some(key), NoVolley::NothingBears) = (fires_key, reason) { + cache.fires.insert(key, FIRES_NOTHING); + } + (Err(reason), VolleyOutcome::nothing(state.facing)) + } } - (full.map(|k| k.side), outcome) } /// The same, with the key it was answered from. @@ -1271,6 +1435,24 @@ pub fn best_volley_keyed( target: &TargetState<'_>, weapons: &[MountedWeapon], ) -> (Result, VolleyOutcome) { + match solved(cache, los_cache, state, target, weapons) { + Ok((gathered, outcome)) => (Ok(gathered.key), outcome), + Err(reason) => (Err(reason), VolleyOutcome::nothing(state.facing)), + } +} + +/// [`best_volley_keyed`] with what `gather` read still attached. +/// +/// The one implementation of what a volley is. `best_volley_keyed` is this with +/// the shot lists dropped, and the fast path is this with them kept and +/// interned - so there is no second reading of the weapons anywhere. +fn solved( + cache: &mut VolleyCache, + los_cache: &LosCache, + state: &Firer, + target: &TargetState<'_>, + weapons: &[MountedWeapon], +) -> Result<(Gathered, VolleyOutcome), NoVolley> { cache.stats.asked += 1; // Sampled, and on a fixed stride: see `VolleyStats::gather_nanos`. let timed = cache.stats.asked.is_multiple_of(TIMING_STRIDE); @@ -1291,7 +1473,7 @@ pub fn best_volley_keyed( cache.stats.lookup_nanos += clock.elapsed().as_nanos() as u64 * TIMING_STRIDE; } outcome.heat = gathered.heat_at(outcome.twist); - (Ok(gathered.key), outcome) + Ok((gathered, outcome)) } Err(reason) => { match reason { @@ -1299,7 +1481,7 @@ pub fn best_volley_keyed( NoVolley::NoLine => cache.stats.no_line += 1, NoVolley::NothingBears => cache.stats.nothing_bears += 1, } - (Err(reason), VolleyOutcome::nothing(state.facing)) + Err(reason) } } } @@ -2483,4 +2665,119 @@ mod tests { fast_cache.stats().hits ); } + + /// The same equivalence over several targets, which is what the two-level + /// split is about: one geometry answers for all of them, and only the + /// damage half is asked per target. + /// + /// Four units, distinct ids and distinct armour, so level two cannot be + /// answered by level one and the corpus has to reach both. The assertions + /// at the end are the ones that make agreement mean something: both + /// branches exercised, both levels hit, no key refused, and level one + /// strictly smaller than level two - a split that collapsed nothing would + /// pass everything above and still be pointless. + #[test] + fn the_split_memo_answers_exactly_what_the_slow_one_does() { + let board = wooded(20, 20, &[(10, 9), (11, 11), (9, 12)], 2); + let los = cache(&board); + let mut units = battered(); + for (at, unit) in units.iter_mut().enumerate() { + unit.id = 40 + at as i32; + } + let weapons = [ + gun_at(1, 10.0, MekLocation::RightTorso), + gun_at(2, 5.0, MekLocation::LeftArm), + gun_at(3, 4.0, MekLocation::RightLeg), + ]; + + let mut agreed = 0; + let mut fired = 0; + let mut blocked = 0; + let mut nothing_bears = 0; + // Two caches, so neither route can answer out of the other's memo. + let mut fast_cache = VolleyCache::new(); + let mut slow_cache = VolleyCache::new(); + for fx in 8..12 { + for fy in 8..12 { + for facing in 0..6 { + for tx in 6..14 { + for ty in 6..14 { + for gait in [Gait::Still, Gait::Ran] { + for unit in &units { + let state = Firer { + gait, + ..firer(fx, fy, facing) + }; + let there = target(unit, tx, ty, 2); + let (fast, fast_outcome) = best_volley_fast( + &mut fast_cache, + &los, + &state, + &there, + &weapons, + 7, + ); + let (slow, slow_outcome) = best_volley_keyed( + &mut slow_cache, + &los, + &state, + &there, + &weapons, + ); + let where_ = format!( + "({fx},{fy}) f{facing} -> ({tx},{ty}) unit {}", + unit.id + ); + assert_eq!( + fast, + slow.map(|k| k.side), + "side or reason differs at {where_}" + ); + assert_eq!( + fast_outcome, slow_outcome, + "outcome differs at {where_}" + ); + agreed += 1; + match fast { + Ok(_) => fired += 1, + Err(NoVolley::NothingBears) => { + nothing_bears += 1; + blocked += 1; + } + Err(_) => blocked += 1, + } + } + } + } + } + } + } + } + + assert!(agreed > 10000, "corpus too small: {agreed}"); + assert!(fired > 1000, "nothing ever fired: {fired}"); + assert!(blocked > 500, "nothing was ever blocked: {blocked}"); + assert!( + nothing_bears > 100, + "the arc prune never fired, so the level-one sentinel is untested: {nothing_bears}" + ); + let stats = fast_cache.stats(); + assert!(stats.hits > 5000, "the fast path never hit: {}", stats.hits); + assert_eq!(stats.refused, 0, "a key would not pack"); + let (geometries, lists, outcomes) = fast_cache.residency(); + assert!( + lists < geometries, + "interning collapsed nothing: {lists} lists over {geometries} geometries" + ); + // The point of the split, as an assertion: the map that carries a whole + // `VolleyOutcome` is the *smaller* of the two. Before it, one map held + // an outcome for every `(geometry, side, target)`, so it was necessarily + // the larger. On the 8v8 sweep this reads 12,024 outcomes against + // 119,507 geometries. + assert!( + outcomes < geometries, + "the fat map is still the big one: {outcomes} outcomes against \ + {geometries} geometries" + ); + } } -- 2.51.2