From 9e7784f699d4011214ab9e58bc3b26a8ef024489 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:31:00 +0000 Subject: [PATCH] perf(magic): Read every magic at a fixed shift with the squares' tables overlapping Each square's index was shifted by a count loaded from a per square array, and at x86-64-v2 that shift goes through cl. Every straight index is now read at twelve bits and every diagonal one at nine, so the shift is a constant and the shift array goes. A fixed width alone needs a block of 4,096 slots a square, 2 MiB for the straight table, and that was measured before: fewer instructions, but last level misses up a third. At the fixed width most of a square's slots are empty, so here the blocks overlap. Each square starts at an offset chosen so its slots land on slots no other square uses, or on one holding the same attack set. The tables reach 114,947 and 5,423 entries (940 KiB, against 841 KiB before) and stay in the same power of two arrays, so the probe keeps its mask and carries no bounds check. The magics and offsets are new. The test side search now places them, widest mask first, greedily, from the same generator and seed, and const evaluation still checks every configuration as it builds the table. Under cachegrind, against f424675 and with the last level set to one core's L2: the games suite -0.577% of instructions with last level read misses -0.85%, the bench -0.786%. The fixed shift without the overlap read -0.188%, +3.34% and -0.277%. The clock on a shared four core machine could not resolve it either way: +2.2% (+0.4% to +4.4%) over 15 rounds and -0.7% (-3.2% to +1.5%) over 60. The trailer is the 60. Bench: 11088536 Speed: -0.7% (bench nps, 95% interval -3.2% to +1.5%, 60 interleaved rounds over shuffled layouts vs f424675) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018YMhHrVdXrSLCTea5LmtbH --- arche-core/src/magic.rs | 336 +++++++++++++++++++++++++++------------- 1 file changed, 225 insertions(+), 111 deletions(-) diff --git a/arche-core/src/magic.rs b/arche-core/src/magic.rs index c2fd1403..74df9823 100644 --- a/arche-core/src/magic.rs +++ b/arche-core/src/magic.rs @@ -97,7 +97,7 @@ pub(crate) static MAGIC: Magic = Magic::new(); /// square of each, since a piece there blocks nothing behind it, and less the /// square the slider stands on. /// -/// Every bit dropped halves the square's block of the table. +/// Every bit dropped halves the slots the square fills in the table. const fn blocker_mask(mailbox: &BaseConversions, from: u8, directions: [isize; 4]) -> u64 { let mut mask = 0u64; let mut d = 0; @@ -169,78 +169,84 @@ fn blocker_configurations(mask: u64) -> Vec { .collect() } -/// How wide each kind's attack table is: the sum over the squares of two to the -/// power of the bits in that square's blocker mask. Stated rather than counted -/// because an array is sized before it is filled; `new` asserts that the masks -/// fill it exactly, so a wrong number fails the build. -const STRAIGHT_ATTACKS: usize = 102_400; -const DIAGONAL_ATTACKS: usize = 5_248; +/// How many bits every square's index takes, for each kind: the widest +/// mask's. One shift for all sixty four squares is a constant, so a probe +/// needs no per square shift. +const STRAIGHT_BITS: u32 = 12; +const DIAGONAL_BITS: u32 = 9; + +/// How far each kind's table reaches: one past the highest slot any square +/// writes. Stated rather than counted because an array is sized before it is +/// filled; `new` asserts the reach, so a wrong number fails the build. +const STRAIGHT_ATTACKS: usize = 114_947; +const DIAGONAL_ATTACKS: usize = 5_423; /// The arrays those tables live in, each the next power of two up. A probe /// masks its index to the array's length, which is a bound llvm can prove, so /// the probe carries no bounds check. The mask never changes an index, since -/// every index a probe computes is one `new` wrote, below the table's width. +/// every index a probe computes is one `new` wrote, below the table's reach. const STRAIGHT_LEN: usize = 131_072; const DIAGONAL_LEN: usize = 8_192; -/// One slider kind's lookup tables. Every square's attack sets sit end to end -/// in a single array, with `offsets` saying where each square's block starts. -struct SliderTables { +/// One slider kind's lookup tables, every square's attack sets in one array. +/// +/// At a fixed width a square with a narrower mask than the widest leaves most +/// of its `1 << BITS` slots empty, so the squares' blocks overlap: each starts +/// at `offsets[square]`, chosen with the magics, so that its slots land on +/// slots no other square uses, or on one holding the same attack set. +struct SliderTables { blocker_masks: [u64; 64], magics: [u64; 64], - /// `64 - bits` for each square, so a probe shifts without subtracting - /// first. - shifts: [u8; 64], offsets: [u32; 64], attacks: [u64; LEN], } -impl SliderTables { - const fn new(directions: [isize; 4], magics: [u64; 64]) -> Self { +impl SliderTables { + const fn new(directions: [isize; 4], magics: [u64; 64], offsets: [u32; 64]) -> Self { assert!( LEN.is_power_of_two() && LEN >= ATTACKS, - "the array must be a power of two at least the table's width" + "the array must be a power of two at least the table's reach" ); let mut blocker_masks = [0u64; 64]; - let mut shifts = [0u8; 64]; - let mut offsets = [0u32; 64]; let mut attacks = [0u64; LEN]; - let mut filled = 0usize; + let mut reach = 0usize; let mailbox = BASE_CONVERSIONS; let mut square = 0u8; while square < 64 { let i = square as usize; let mask = blocker_mask(&mailbox, square, directions); - let bits = mask.count_ones(); + assert!(mask.count_ones() <= BITS, "a mask is wider than the index"); blocker_masks[i] = mask; - shifts[i] = 64 - bits as u8; - offsets[i] = filled as u32; - let configurations = 1u64 << bits; + let configurations = 1u64 << mask.count_ones(); let mut configuration = 0u64; while configuration < configurations { let blockers = blocker_configuration(mask, configuration); - let index = (blockers.wrapping_mul(magics[i]) >> shifts[i]) as usize; + let index = (blockers.wrapping_mul(magics[i]) >> (64 - BITS)) as usize; + let slot = offsets[i] as usize + index; let moves = attacks_from(&mailbox, square, blockers, directions); - // two configurations may share an index only when they admit - // the same moves, which is what makes the magic a valid one - assert!(attacks[filled + index] == 0 || attacks[filled + index] == moves); - attacks[filled + index] = moves; + // two configurations may share a slot only when they admit the + // same moves, whether they come from one square or two (every + // attack set has a bit, so an empty slot reads zero) + assert!(slot < ATTACKS, "a slot is past the table's reach"); + assert!(attacks[slot] == 0 || attacks[slot] == moves); + attacks[slot] = moves; + if slot >= reach { + reach = slot + 1; + } configuration += 1; } - filled += configurations as usize; square += 1; } assert!( - filled == ATTACKS, - "the table is not the width the masks ask for" + reach == ATTACKS, + "the table does not reach as far as stated" ); Self { blocker_masks, magics, - shifts, offsets, attacks, } @@ -250,21 +256,29 @@ impl SliderTables { fn attacks(&self, square: u8, occupied: u64) -> u64 { let i = square as usize; let blockers = occupied & self.blocker_masks[i]; - let index = blockers.wrapping_mul(self.magics[i]) >> self.shifts[i]; + let index = blockers.wrapping_mul(self.magics[i]) >> (64 - BITS); self.attacks[(self.offsets[i] as usize + index as usize) & (LEN - 1)] } } pub struct Magic { - straight: SliderTables, - diagonal: SliderTables, + straight: SliderTables, + diagonal: SliderTables, } impl Magic { pub const fn new() -> Self { Self { - straight: SliderTables::new(BaseConversions::STRAIGHT_STEPS, STRAIGHT_MAGICS), - diagonal: SliderTables::new(BaseConversions::DIAGONAL_STEPS, DIAGONAL_MAGICS), + straight: SliderTables::new( + BaseConversions::STRAIGHT_STEPS, + STRAIGHT_MAGICS, + STRAIGHT_OFFSETS, + ), + diagonal: SliderTables::new( + BaseConversions::DIAGONAL_STEPS, + DIAGONAL_MAGICS, + DIAGONAL_OFFSETS, + ), } } @@ -281,49 +295,73 @@ impl Magic { #[rustfmt::skip] pub const STRAIGHT_MAGICS: [u64; 64] = [ - 0x0080052033400282, 0x0240009004402000, 0x03000c4010200100, 0x8200104020060008, - 0x820020020004d830, 0x018004000d120080, 0x0100408401000200, 0x2600010860804a04, - 0x8000800082204010, 0x0011004001002080, 0x00010054c0200100, 0x2001001900201000, - 0x2006000600595020, 0x0101001c000a0900, 0x001c004d108a0804, 0x0011000500028042, - 0x2410308004884000, 0x0204808020014002, 0x1220808020005000, 0x031012000a0060c2, - 0x0e88008018240081, 0x2002808024000200, 0x0001a40010020881, 0x0ca0220008840641, - 0x4000882080004001, 0x0088802100400100, 0x0180408200120021, 0x0c280080801a1000, - 0x0554008080050800, 0x0100540080800600, 0x0008080c00910210, 0x000400420008812c, - 0x8200400081800220, 0x5210012000400640, 0x0000803202004020, 0x4003041001000860, - 0x0a00080080804400, 0x2500a04028011004, 0x0500108204000809, 0x48028241020000a4, - 0x08c0018040288000, 0x001002200040c001, 0x2488200010008080, 0x0038100009010060, - 0x2418011100290004, 0x40a0820004008080, 0x8001000200050024, 0x00000c1048820011, - 0x0020800044611100, 0x2080288100400100, 0x800120030110c100, 0x2102401202600a00, - 0x0692050008009100, 0x1004010002004040, 0x0200225938100c00, 0x8000204484011200, - 0x2100410020800a51, 0x0010210840001181, 0x02c0404b20001101, 0x004810000844a101, - 0x1041004402100801, 0x0041002804001e85, 0x0a08c8020104b004, 0x0000002144059102, + 0x0080052033400282, 0x0020001000080020, 0x8040080050004004, 0x0040040048004002, + 0x0020010020020404, 0x00400100020000c0, 0x0040010000400080, 0x0200010081402204, + 0x1400400020001048, 0x0280100400080090, 0x3000080402010008, 0x0100200400200201, + 0x0100200100200200, 0x0400202000800100, 0xc000202000400080, 0x0000400020800040, + 0x4040002000100020, 0x1090000802040009, 0x2004000802010088, 0x0004002002002144, + 0x0212001001001080, 0x2029002020008001, 0x0000801020004010, 0x1020802000400020, + 0x0200200040001040, 0x2000080010040010, 0x0800880040044010, 0x84000400200200a1, + 0x4000010020020020, 0x0040008020200100, 0x2000400040008001, 0x00c4400040002080, + 0x1c40001000200021, 0x0000080400100012, 0x4044000800404010, 0x8002000400202002, + 0x8000200100200200, 0x4000200080200100, 0x0200008000404001, 0x0080882000200040, + 0xc110204104000803, 0x0010080102000400, 0x0002c00409004010, 0x0042000204002020, + 0x2000a20001002020, 0x0000410000802020, 0x0020401080004001, 0x004000a002009001, + 0x0040012000100020, 0x8000080210040010, 0x6404010200080008, 0x0000040020020020, + 0x0000020020010020, 0x0040200080010020, 0x0210200040008020, 0x1080802000400020, + 0x0201401100220082, 0x0800081081002041, 0x0010050840802012, 0x0000401064200802, + 0x0400041020080102, 0x0000100081040802, 0x8000010400480482, 0x0421030460840042, +]; + +#[rustfmt::skip] +pub const STRAIGHT_OFFSETS: [u32; 64] = [ + 0, 16384, 18432, 20480, 22560, 24616, 26920, 4096, + 28968, 76881, 77921, 78946, 79970, 80994, 82018, 31016, + 33320, 83042, 84066, 85090, 86274, 87298, 88322, 35368, + 37417, 89346, 90370, 91618, 92706, 93730, 94754, 39465, + 41769, 95778, 96802, 98082, 99106, 100130, 101154, 43817, + 45930, 102178, 103203, 104451, 105571, 106627, 107715, 48000, + 50432, 108739, 109827, 110851, 111875, 112899, 113923, 52496, + 8192, 54545, 58321, 62305, 66145, 69729, 73041, 12288, ]; #[rustfmt::skip] pub const DIAGONAL_MAGICS: [u64; 64] = [ - 0x0050050a08020022, 0x8010211504288002, 0x1804040282100000, 0x0282208204308812, - 0x81011140001024a0, 0x0400900421000008, 0x10c08290886001c6, 0x0421010042200410, - 0x0500100a10030204, 0x1802021002020040, 0x0001480208420050, 0x60a0480604408008, - 0x0004041420448344, 0x0000020202200008, 0x0300820802021080, 0x00022a0244124884, - 0x0005102048220808, 0x0002100810214200, 0x4802000400240102, 0x0004000124008000, - 0x001010060210200b, 0x0006000101018285, 0x8004000184011810, 0x8008802a0c440211, - 0x500c400030100901, 0x0010120e04040404, 0x0084020044006402, 0x4408080004202060, - 0x034f010040304001, 0x00010a0007080100, 0x0444010084090101, 0x8000870000640201, - 0x3010102604080801, 0x8808080204188600, 0x2b04042200841400, 0x00402020a0480080, - 0x0140058020020020, 0xa020008100020901, 0x9088080882811182, 0x00824140408b0400, - 0x2804122004001111, 0x2054011908483084, 0x00822018c8011000, 0x0800032124000803, - 0x0000401040910100, 0x002000b000800040, 0x0002a80212800420, 0x0202020246000100, - 0x4090a18820502180, 0x00208404220a4808, 0x0002020100c80000, 0x0008008041108800, - 0x6000801042020010, 0x8041481001160100, 0x2550e06801014080, 0x082830008a10480e, - 0x504200210110b008, 0x24212114008c0404, 0x0048080204ea0810, 0x082001108094040a, - 0x9020010812020200, 0x6000002004100094, 0x0001045044080080, 0x0d20022218010310, + 0x0000820081000408, 0x0000208080100402, 0x0000401020214000, 0x4800804810102904, + 0x4020440200600020, 0x088041008050c800, 0x0020104202004012, 0x0004402080480040, + 0x3864804100401040, 0x000a008100100410, 0x0002804040082000, 0x4412808048101000, + 0x010040440200a000, 0x2006004100804020, 0x0804001100820040, 0x024400201042002a, + 0x2004080100208010, 0x0000200080801040, 0x0002000400100409, 0x0200200200801080, + 0x0e00240080840140, 0x0000120080808800, 0x2008086040208020, 0x0000040008208010, + 0x020202000100401a, 0x0000802402002020, 0x0012004582008008, 0x0040040000410220, + 0x8200840000802008, 0x2000208000404028, 0x1120200400208022, 0xc24040042808200e, + 0x01008200b0010020, 0x5000804108004010, 0x0420808084004200, 0x1000c04800008200, + 0x00042500102400c0, 0x8204008008208020, 0x0001000820201010, 0x0000082020800808, + 0x01c0208200800040, 0x100840404040c021, 0x2000804100800040, 0xa000008840200040, + 0x0800020041000010, 0x4804080020080041, 0x0200404080202004, 0x4000202040104002, + 0x0c40802101004040, 0x00c0104100402800, 0x40c0001010806000, 0x2054830408403007, + 0x0048020100202210, 0x2002820040102003, 0x8000420020401000, 0x2000208020400400, + 0x8a00801082010040, 0x0000001011010020, 0x0200044808208060, 0x448020a008084030, + 0x4000138001002020, 0x3058010040810010, 0x2100040100200810, 0x0001010010808004, +]; + +#[rustfmt::skip] +pub const DIAGONAL_OFFSETS: [u32; 64] = [ + 3599, 3855, 3887, 3919, 3951, 3998, 4016, 3663, + 4078, 4110, 4142, 4174, 4206, 4253, 4271, 4317, + 4379, 4411, 2048, 2176, 2304, 2434, 4443, 4475, + 4507, 4539, 2570, 0, 512, 2698, 4571, 4603, + 4635, 4667, 2826, 1024, 1536, 2954, 4699, 4731, + 4763, 4795, 3082, 3210, 3343, 3471, 4827, 4859, + 4891, 4893, 4955, 4987, 5048, 5080, 5112, 5144, + 3665, 5176, 5234, 5266, 5327, 5359, 5391, 3791, ]; #[cfg(test)] mod tests { use super::{ - BASE_CONVERSIONS, BaseConversions, MAGIC, attacks_from, blocker_configurations, - blocker_mask, + BASE_CONVERSIONS, BaseConversions, DIAGONAL_BITS, MAGIC, STRAIGHT_BITS, attacks_from, + blocker_configurations, blocker_mask, }; use crate::misc::split_mix; @@ -331,11 +369,10 @@ mod tests { const DIAGONAL: [isize; 4] = BaseConversions::DIAGONAL_STEPS; /// Everything a magic for one square has to map: each blocker - /// configuration, the attacks it admits, and how wide the index is. + /// configuration and the attacks it admits. struct Cases { blockers: Vec, attacks: Vec, - bits: u8, } fn cases(square: u8, directions: [isize; 4]) -> Cases { @@ -345,11 +382,7 @@ mod tests { .iter() .map(|&b| attacks_from(&BASE_CONVERSIONS, square, b, directions)) .collect(); - Cases { - blockers, - attacks, - bits: mask.count_ones() as u8, - } + Cases { blockers, attacks } } /// Three draws anded together, which leaves each bit set with probability @@ -364,31 +397,104 @@ mod tests { a & b & c } - /// True if this magic maps every blocker configuration for the square onto - /// a collision free index. Two configurations may share an index only when - /// they have the same attack set, which is harmless. - fn is_valid(magic: u64, cases: &Cases) -> bool { - let mut table = vec![0u64; 1usize << cases.bits]; - let shift = 64 - cases.bits; + /// The slots a magic fills at `bits`, in index order with the attacks + /// each holds, or nothing if two configurations that admit different + /// moves land on one slot. `scratch` is `1 << bits` zeros and is left so. + fn slots( + magic: u64, + cases: &Cases, + bits: u32, + scratch: &mut [u64], + ) -> Option> { + let mut used = Vec::new(); + let mut valid = true; for (blocker, &attacks) in cases.blockers.iter().zip(&cases.attacks) { - let index = (blocker.wrapping_mul(magic) >> shift) as usize; - if table[index] != 0 && table[index] != attacks { - return false; + let index = (blocker.wrapping_mul(magic) >> (64 - bits)) as usize; + if scratch[index] == 0 { + scratch[index] = attacks; + used.push(index); + } else if scratch[index] != attacks { + valid = false; + break; } - table[index] = attacks; } - true + let mut filled: Vec<(usize, u64)> = used.iter().map(|&i| (i, scratch[i])).collect(); + used.iter().for_each(|&i| scratch[i] = 0); + filled.sort_unstable(); + valid.then_some(filled) } - /// The search that produced the committed magics. Kept here so they can be - /// regenerated, and compiled with the tests so it cannot rot. - fn find_magic(state: &mut u64, cases: &Cases) -> u64 { - loop { - let candidate = sparse_candidate(state); - if is_valid(candidate, cases) { - return candidate; + /// How many candidates each square draws, and how many of the valid ones + /// with the narrowest spread of slots it tries to place. + const DRAWS: usize = 4_000_000; + const PLACED: usize = 30; + + /// The search that produced the committed magics and offsets. Kept here + /// so they can be regenerated, and compiled with the tests so it cannot + /// rot. + /// + /// The squares go widest mask first. Each draws its candidates and keeps + /// the valid ones whose slots spread least. It places each of those at its + /// lowest offset that collides with nothing already placed (a slot holding + /// the same attacks is no collision), and takes the one that ends the table + /// soonest, the lower offset on a tie. Greedy, so the table is not as short + /// as a longer search could make it, only short enough. + fn place(state: &mut u64, directions: [isize; 4], bits: u32) -> ([u64; 64], [u32; 64], usize) { + let all: Vec = (0..64).map(|square| cases(square, directions)).collect(); + let mut order: Vec = (0..64).collect(); + order.sort_by_key(|&square| std::cmp::Reverse(all[square].blockers.len())); + + let mut scratch = vec![0u64; 1 << bits]; + let mut table = vec![0u64; 64 << bits]; + let (mut magics, mut offsets, mut reach) = ([0u64; 64], [0u32; 64], 0); + for square in order { + // each valid magic with how far its slots spread, the slots + // themselves found again for the few that are kept + let mut spreads = Vec::new(); + for _ in 0..DRAWS { + let magic = sparse_candidate(state); + if let Some(filled) = slots(magic, &all[square], bits, &mut scratch) { + spreads.push((filled[filled.len() - 1].0 - filled[0].0, magic)); + } + } + spreads.sort_by_key(|c| c.0); + spreads.truncate(PLACED); + let candidates: Vec<_> = spreads + .into_iter() + .map(|(spread, magic)| { + let filled = slots(magic, &all[square], bits, &mut scratch).unwrap(); + (spread, magic, filled) + }) + .collect(); + assert!( + !candidates.is_empty(), + "square {} drew no valid magic", + square + ); + + let mut best: Option<(usize, usize, usize)> = None; // reach, offset, which + for (which, (_, _, filled)) in candidates.iter().enumerate() { + let offset = (0..) + .find(|&o| { + filled + .iter() + .all(|&(i, a)| table[o + i] == 0 || table[o + i] == a) + }) + .unwrap(); + let ends = reach.max(offset + filled[filled.len() - 1].0 + 1); + if best.is_none_or(|b| (ends, offset) < (b.0, b.1)) { + best = Some((ends, offset, which)); + } + } + let (ends, offset, which) = best.unwrap(); + for &(i, a) in &candidates[which].2 { + table[offset + i] = a; } + magics[square] = candidates[which].1; + offsets[square] = offset as u32; + reach = ends; } + (magics, offsets, reach) } /// The assert in `SliderTables::new` already fails the build on a magic @@ -425,31 +531,39 @@ mod tests { } /// Prints a fresh set of constants to paste into this file. Ignored because - /// it is only needed if the blocker masks or the table layout change. + /// it is only needed if the blocker masks or the table layout change, and + /// it takes about a quarter of an hour. /// - /// cargo test -p arche-core regenerate_magics -- --ignored --nocapture + /// cargo test --release -p arche-core regenerate_magics -- --ignored --nocapture #[test] #[ignore = "prints replacement constants, see the doc comment"] fn regenerate_magics() { let mut state: u64 = 102938423890384; - - // interleaved per square, the order the original code consumed the - // generator in, so this reproduces the committed values - let mut straight = Vec::with_capacity(64); - let mut diagonal = Vec::with_capacity(64); - for square in 0..64u8 { - straight.push(find_magic(&mut state, &cases(square, STRAIGHT))); - diagonal.push(find_magic(&mut state, &cases(square, DIAGONAL))); - } - - for (name, magics) in [("STRAIGHT_MAGICS", straight), ("DIAGONAL_MAGICS", diagonal)] { + let (straight, straight_offsets, straight_reach) = + place(&mut state, STRAIGHT, STRAIGHT_BITS); + let (diagonal, diagonal_offsets, diagonal_reach) = + place(&mut state, DIAGONAL, DIAGONAL_BITS); + + println!("const STRAIGHT_ATTACKS: usize = {};", straight_reach); + println!("const DIAGONAL_ATTACKS: usize = {};", diagonal_reach); + for (name, magics, offsets) in [ + ("STRAIGHT", straight, straight_offsets), + ("DIAGONAL", diagonal, diagonal_offsets), + ] { println!("#[rustfmt::skip]"); - println!("pub const {}: [u64; 64] = [", name); + println!("pub const {}_MAGICS: [u64; 64] = [", name); for chunk in magics.chunks(4) { let line: Vec = chunk.iter().map(|m| format!("0x{:016x}", m)).collect(); println!(" {},", line.join(", ")); } println!("];"); + println!("#[rustfmt::skip]"); + println!("pub const {}_OFFSETS: [u32; 64] = [", name); + for chunk in offsets.chunks(8) { + let line: Vec = chunk.iter().map(|o| format!("{:>6}", o)).collect(); + println!(" {},", line.join(", ")); + } + println!("];"); } } }