diff --git a/crates/sds-core/examples/perf.rs b/crates/sds-core/examples/perf.rs index b8c89f4..d8fb12f 100644 --- a/crates/sds-core/examples/perf.rs +++ b/crates/sds-core/examples/perf.rs @@ -928,6 +928,14 @@ sweep fixture: {} stands x {} enemies x {positions} positions = {} pairs", {outcomes} outcomes, {} refused", cache.stats().refused ); + let (cells, mapped, bytes) = cache.dense_residency(); + println!( + " level one: {cells} dense cells, {mapped} mapped, {} KiB table, \ + {} read, {} fell through", + bytes / 1024, + cache.stats().dense_read, + cache.stats().dense_missed + ); 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 a29992a..6a47770 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -607,6 +607,11 @@ pub struct VolleyStats { pub refused: u64, /// Actually solved. pub misses: u64, + /// Level-one lookups the dense table answered, and lookups it could not + /// address and which fell through to the packed-key map. A zero on the + /// first is a table that is allocated and never read. + pub dense_read: u64, + pub dense_missed: u64, /// Wall clock in `gather` against wall clock in the memo lookup, in /// nanoseconds. /// @@ -643,6 +648,11 @@ pub struct VolleyCache { /// A `HashMap` on a packed integer, and nothing iterates it, so no result /// depends on its order. Same for the two below. fires: std::collections::HashMap, + /// Level one again, dense and indexed rather than hashed, in front of the + /// map above. See [`DenseFires`]: the map is what answers a geometry the + /// table cannot address, and every geometry is answered by exactly one of + /// them. + dense: DenseFires, /// The interned shot lists level one indexes into. fires_table: Vec, /// The other direction, for interning: a shot list to its index. @@ -677,7 +687,28 @@ impl VolleyCache { /// 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()) + ( + self.fires.len() + self.dense.written, + self.fires_table.len(), + self.lands.len(), + ) + } + + /// How level one is split between the dense table and the map behind it: + /// `(cells written, map entries, bytes the table's arenas hold)`. + /// + /// The measurement this table exists for. A large map entry count against a + /// small cell count means the spans in [`DenseFires`] are too narrow for the + /// board in play and the hot lookup is still hashing. + pub fn dense_residency(&self) -> (usize, usize, usize) { + let bytes = self + .dense + .slabs + .iter() + .flatten() + .map(|slab| (slab.cells.len() + slab.blocks.len()) * std::mem::size_of::()) + .sum(); + (self.dense.written, self.fires.len(), bytes) } /// The estimate for a key, computed at most once. @@ -702,6 +733,7 @@ impl VolleyCache { // geometry and would survive, but it indexes into `fires_table` and the // two must not be cleared apart. self.fires.clear(); + self.dense.clear(); self.fires_table.clear(); self.fires_index.clear(); self.lands.clear(); @@ -717,9 +749,11 @@ impl VolleyCache { 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"); + // `FIRES_NOTHING` is not a shot list, and `SLOT_UNKNOWN` is the dense + // table's empty cell and the lower of the two. A table that reached + // either would answer "nothing bears", or nothing at all, for a volley + // that fires. + assert!(id < SLOT_UNKNOWN, "interned shot lists overflowed"); self.fires_table.push(fires.clone()); self.fires_index.insert(fires, id); id @@ -1194,6 +1228,233 @@ impl Fires { /// In band rather than an `Option`, so the map's value stays four bytes. const FIRES_NOTHING: u32 = u32::MAX; +/// Level one as a dense indexed table, in front of [`VolleyCache::fires`]. +/// +/// The one thing that has repeatedly paid off in this sweep is residency and +/// indexing rather than arithmetic: `stands::LineTable` won 15-19% by being +/// dense and indexed by hex, and the same idea as a `HashMap` measured nothing. +/// This is `LineTable`'s shape applied to level one. +/// +/// The key [`FiresKey`] packs splits into three parts by how fast each moves in +/// a sweep. `loadout`, `gunnery` and the two twist flags are fixed for a firing +/// unit, so they select a **slab**. `facing` and the attacker's movement +/// modifier are fixed for a stand, and the target's movement modifier and the +/// `other` terms take a handful of values, so together they select a **block**. +/// What is left is the cube delta, which is what actually varies as the loop +/// walks one enemy's positions - so the delta grid is the innermost, contiguous +/// dimension, and one stand against one enemy touches a small contiguous patch +/// of it. +/// +/// Blocks are allocated on first use out of one arena per slab, so the table +/// costs what the sweep reaches rather than what it could address. +/// +/// **Nothing here can change an answer.** A lookup that falls outside any of +/// the spans returns [`SLOT_UNKNOWN`] and the caller drops through to the +/// packed-key map, which drops through to the slow path. A delta outside the +/// grid is never folded onto another cell. +#[derive(Debug, Default)] +struct DenseFires { + /// The board the spans were sized against. A different one rebuilds. + board: (i32, i32), + /// Cube delta spans, and the offset that puts delta zero in the middle. + dq_span: usize, + dr_span: usize, + dq_zero: i32, + dr_zero: i32, + /// Entries in one block's delta grid: `dq_span * dr_span`. + grid: usize, + /// Firer id to slab. Sparse and small; ids are unit ids. + slabs: Vec>>, + /// Cells written, for `residency`. + written: usize, +} + +/// One firing unit's half of [`DenseFires`]. +#[derive(Debug)] +struct DenseSlab { + /// The rest of the fixed part of the key. A firer whose gunnery or twist + /// changed inside one turn would otherwise read another one's answers, so + /// a mismatch refuses rather than reuses. + gunnery: i32, + can_twist: bool, + extended_twist: bool, + /// Block to its offset in `cells`, or [`NO_BLOCK`]. + blocks: Box<[u32]>, + cells: Vec, +} + +/// A cell nothing has written yet. Distinct from [`FIRES_NOTHING`], which is a +/// resolved answer meaning no weapon bears. +const SLOT_UNKNOWN: u32 = u32::MAX - 1; +/// A block that has not been allocated out of the slab's arena. +const NO_BLOCK: u32 = u32::MAX; + +/// The spans of the small fields the block index is built from. Each is wider +/// than anything measured on a real board - an 8v8 sweep reads facings 0-5, +/// attacker movement 0-2, target movement 0-3 and `other` 0-5 - and a value +/// past the end refuses instead of aliasing. +const DENSE_FACINGS: usize = 6; +const DENSE_ATTACKER_MOVES: usize = 8; +const DENSE_TARGET_MOVES: usize = 8; +const DENSE_OTHERS: usize = 8; +const DENSE_BLOCKS: usize = + DENSE_FACINGS * DENSE_ATTACKER_MOVES * DENSE_TARGET_MOVES * DENSE_OTHERS; +/// Firer ids past this take the packed-key map. Unit ids are small. +const DENSE_MAX_LOADOUT: i32 = 1024; +/// A delta grid larger than this is not worth allocating; the board that needed +/// one is far past anything played here and the packed-key map still answers. +const DENSE_MAX_GRID: usize = 1 << 18; + +/// Where one lookup lands: which slab, which block, which cell. +#[derive(Debug, Clone, Copy)] +struct DenseSpot { + slab: usize, + block: usize, + cell: usize, +} + +impl DenseFires { + /// Size the delta grid against the board, if it is not already. + /// + /// `hex::cube_delta` is `(x, y - (x - (x & 1)) / 2)` differenced, so `dq` + /// spans the width and `dr` spans the height plus half the width. Both + /// bounds are exact for any two hexes on the board. + fn size_to(&mut self, board: &crate::los::LosBoard) { + let dims = (board.width(), board.height()); + if self.board == dims && self.grid != 0 { + return; + } + self.slabs.clear(); + self.written = 0; + self.board = dims; + let (width, height) = dims; + let dq_reach = width.max(1) - 1; + let dr_reach = (height.max(1) - 1) + width.max(1) / 2 + 1; + self.dq_zero = dq_reach; + self.dr_zero = dr_reach; + self.dq_span = (2 * dq_reach + 1) as usize; + self.dr_span = (2 * dr_reach + 1) as usize; + let grid = self.dq_span.saturating_mul(self.dr_span); + self.grid = if grid <= DENSE_MAX_GRID { grid } else { 0 }; + } + + /// The cell a lookup wants, or `None` if any field is outside the table. + /// + /// Refusing is always safe: the caller falls through to a path that is + /// correct for every input. + #[allow(clippy::too_many_arguments)] + #[inline] + fn spot( + &mut self, + loadout: i32, + gunnery: i32, + can_twist: bool, + extended_twist: bool, + facing: i32, + attacker_movement: i32, + target_movement: i32, + other: i32, + delta: (i32, i32), + ) -> Option { + if self.grid == 0 || !(0..DENSE_MAX_LOADOUT).contains(&loadout) { + return None; + } + let attacker = usize::try_from(attacker_movement).ok()?; + let target = usize::try_from(target_movement).ok()?; + let other = usize::try_from(other).ok()?; + if attacker >= DENSE_ATTACKER_MOVES || target >= DENSE_TARGET_MOVES || other >= DENSE_OTHERS + { + return None; + } + let dq = usize::try_from(delta.0 + self.dq_zero).ok()?; + let dr = usize::try_from(delta.1 + self.dr_zero).ok()?; + if dq >= self.dq_span || dr >= self.dr_span { + return None; + } + let facing = facing.rem_euclid(6) as usize; + let block = ((facing * DENSE_ATTACKER_MOVES + attacker) * DENSE_TARGET_MOVES + target) + * DENSE_OTHERS + + other; + + let slab = loadout as usize; + if slab >= self.slabs.len() { + self.slabs.resize_with(slab + 1, || None); + } + match &self.slabs[slab] { + // The fixed part of the key must match, or this firer is not the + // one the slab was filled for. + Some(held) + if held.gunnery != gunnery + || held.can_twist != can_twist + || held.extended_twist != extended_twist => + { + return None + } + Some(_) => {} + None => { + self.slabs[slab] = Some(Box::new(DenseSlab { + gunnery, + can_twist, + extended_twist, + blocks: vec![NO_BLOCK; DENSE_BLOCKS].into_boxed_slice(), + cells: Vec::new(), + })); + } + } + Some(DenseSpot { + slab, + block, + cell: dr * self.dq_span + dq, + }) + } + + /// What the table holds there, or [`SLOT_UNKNOWN`]. + #[inline] + fn get(&self, spot: DenseSpot) -> u32 { + let slab = match &self.slabs[spot.slab] { + Some(slab) => slab, + None => return SLOT_UNKNOWN, + }; + let offset = slab.blocks[spot.block]; + if offset == NO_BLOCK { + return SLOT_UNKNOWN; + } + slab.cells[offset as usize + spot.cell] + } + + /// Record an answer, allocating the block on first use. + fn put(&mut self, spot: DenseSpot, value: u32) { + let grid = self.grid; + let Some(slab) = self.slabs[spot.slab].as_mut() else { + return; + }; + let mut offset = slab.blocks[spot.block]; + if offset == NO_BLOCK { + let at = slab.cells.len(); + // The arena is addressed by a `u32`. A slab that filled every block + // of the largest grid allowed would still fit, but the check is + // here rather than argued about. + if at + grid > u32::MAX as usize { + return; + } + slab.cells.resize(at + grid, SLOT_UNKNOWN); + offset = at as u32; + slab.blocks[spot.block] = offset; + } + let at = offset as usize + spot.cell; + let fresh = slab.cells[at] == SLOT_UNKNOWN; + slab.cells[at] = value; + if fresh { + self.written += 1; + } + } + + fn clear(&mut self) { + self.slabs.clear(); + self.written = 0; + } +} + /// A hasher for [`FiresKey`] and [`LandKey`], each one packed integer. /// /// SipHash over eight bytes is more than a memo lookup needs. The obvious @@ -1333,24 +1594,56 @@ pub fn best_volley_with_line( }; let side = los::side_table(target.hex, target.facing, state.hex); - let fires_key = FiresKey::pack( + let delta = hex::cube_delta(state.hex, target.hex); + let attacker_movement = state.gait.to_hit(); + let target_movement = los::target_movement_modifier(target.hexes_moved, target.jumped); + let other = line_modifier + los::target_terrain_modifier(los_cache.board().hex(target.hex)); + + // Level one. What fires is a function of the geometry and the fixed + // modifiers alone, so level one has one entry per shape rather than one per + // (shape, side, target), and its value is a four-byte index. + // + // The dense table first, and the packed key is only built when the table + // cannot address the geometry - so the common lookup is an array index and + // pays for neither the packing nor a hash. See [`DenseFires`]. + cache.dense.size_to(los_cache.board()); + let spot = cache.dense.spot( loadout, - hex::cube_delta(state.hex, target.hex), - state.facing, + state.base_to_hit, state.can_twist, state.extended_twist, - state.base_to_hit, - 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)), + state.facing, + attacker_movement, + target_movement, + other, + delta, ); - // 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, + let (fires, fires_key) = match spot { + Some(spot) => { + cache.stats.dense_read += 1; + let held = cache.dense.get(spot); + ((held != SLOT_UNKNOWN).then_some(held), None) + } + None => { + cache.stats.dense_missed += 1; + let key = FiresKey::pack( + loadout, + delta, + state.facing, + state.can_twist, + state.extended_twist, + state.base_to_hit, + attacker_movement, + target_movement, + other, + ); + let held = match key { + Some(key) => cache.fires.get(&key).copied(), + None => None, + }; + (held, key) + } }; if let Some(id) = fires { @@ -1393,7 +1686,7 @@ pub fn best_volley_with_line( return (Ok(side), outcome); } - if fires_key.is_none() { + if spot.is_none() && fires_key.is_none() { cache.stats.refused += 1; } @@ -1403,12 +1696,13 @@ pub fn best_volley_with_line( match solved(cache, los_cache, state, target, weapons) { Ok((gathered, outcome)) => { let answer_side = gathered.key.side; - if let Some(key) = fires_key { + if spot.is_some() || fires_key.is_some() { + let key = fires_key; let id = cache.intern(Fires { options: gathered.key.options, heats: gathered.heats, }); - cache.fires.insert(key, id); + remember(cache, spot, key, id); if let Some(land) = LandKey::pack(id, target.unit.id, answer_side) { cache.lands.insert(land, outcome); } @@ -1416,8 +1710,8 @@ pub fn best_volley_with_line( (Ok(answer_side), outcome) } Err(reason) => { - if let (Some(key), NoVolley::NothingBears) = (fires_key, reason) { - cache.fires.insert(key, FIRES_NOTHING); + if let NoVolley::NothingBears = reason { + remember(cache, spot, fires_key, FIRES_NOTHING); } (Err(reason), VolleyOutcome::nothing(state.facing)) } @@ -1441,6 +1735,20 @@ pub fn best_volley_keyed( } } +/// Record a level-one answer wherever it belongs. +/// +/// Exactly one of the two holds any given geometry: the dense table when it can +/// address it, the packed-key map when it cannot. Writing both would double the +/// memory for no second answer, and writing neither would make the geometry +/// cost a `gather` every time it came round. +fn remember(cache: &mut VolleyCache, spot: Option, key: Option, id: u32) { + if let Some(spot) = spot { + cache.dense.put(spot, id); + } else if let Some(key) = key { + cache.fires.insert(key, id); + } +} + /// [`best_volley_keyed`] with what `gather` read still attached. /// /// The one implementation of what a volley is. `best_volley_keyed` is this with @@ -2780,4 +3088,201 @@ mod tests { {geometries} geometries" ); } + + /// The dense level-one table must never disagree with the slow path. + /// + /// The safety argument for [`DenseFires`], and a test rather than an + /// argument for the same reason [`FiresKey`]'s is: the table claims that + /// `(loadout, gunnery, twist, facing, attacker movement, target movement, + /// other, delta)` determines what fires, and an index that silently + /// collapsed two of those would still answer, just wrongly. + /// + /// Several targets, because side and profile live in level two: a + /// single-target corpus cannot tell a collapsed key from a correct one. + /// The gaits and facings move the block index, the target hexes move the + /// delta and the woods move `other`, so the corpus reaches many blocks + /// rather than hammering one cell. + #[test] + fn the_dense_table_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::Walked, 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} {gait:?} -> ({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, + } + } + } + } + } + } + } + } + + // Agreement over a corpus that reached neither branch would mean + // nothing, so the shape of the corpus is asserted too. + 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 dense sentinel is untested: {nothing_bears}" + ); + + // And the table has to have been the thing answering. Without these the + // test above passes just as happily against a table that is allocated, + // never written and never read. + let stats = fast_cache.stats(); + assert!( + stats.dense_read > 10000, + "level one never reached the dense table: {}", + stats.dense_read + ); + assert_eq!( + stats.dense_missed, 0, + "the table could not address this board, so the map answered instead" + ); + assert_eq!(stats.refused, 0, "a key would not pack"); + let (cells, mapped, bytes) = fast_cache.dense_residency(); + assert!(cells > 1000, "the table holds nothing: {cells} cells"); + assert_eq!( + mapped, 0, + "level one still has entries in the map: {mapped}" + ); + assert!(bytes > 0, "the table allocated nothing"); + } + + /// A geometry the table cannot address still gets the right answer. + /// + /// The fall-through is the whole reason refusing is safe, and a firer id + /// past [`DENSE_MAX_LOADOUT`] is the cheapest way to force every lookup + /// down it. Same corpus shape as above, so what is compared is the path and + /// not the question. + #[test] + fn a_geometry_the_table_cannot_address_falls_through_to_the_same_answer() { + let board = wooded(20, 20, &[(10, 9), (11, 11)], 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), + ]; + + let mut fast_cache = VolleyCache::new(); + let mut slow_cache = VolleyCache::new(); + let mut fired = 0; + let mut blocked = 0; + 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 unit in &units { + let state = 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, + DENSE_MAX_LOADOUT + 5, + ); + let (slow, slow_outcome) = best_volley_keyed( + &mut slow_cache, + &los, + &state, + &there, + &weapons, + ); + assert_eq!(fast, slow.map(|k| k.side)); + assert_eq!(fast_outcome, slow_outcome); + match fast { + Ok(_) => fired += 1, + Err(_) => blocked += 1, + } + } + } + } + } + } + } + + assert!(fired > 500, "nothing ever fired: {fired}"); + assert!(blocked > 500, "nothing was ever blocked: {blocked}"); + let stats = fast_cache.stats(); + assert_eq!(stats.dense_read, 0, "the table answered after all"); + assert!(stats.dense_missed > 5000, "the corpus was too small"); + assert_eq!(stats.refused, 0, "the packed key would not hold it either"); + let (cells, mapped, _) = fast_cache.dense_residency(); + assert_eq!( + cells, 0, + "the table was written on the path that refuses it" + ); + assert!(mapped > 100, "the map behind it holds nothing: {mapped}"); + } }