diff --git a/arche-core/src/board.rs b/arche-core/src/board.rs index 043cdc73..813341a8 100644 --- a/arche-core/src/board.rs +++ b/arche-core/src/board.rs @@ -2339,7 +2339,7 @@ impl Board { if piece == Piece::Pawn { self.pawn_key ^= piece_key; } - self.eval.count::(row, piece, color); + self.eval.count::(row, piece); let board = &mut self.pieces[piece as usize]; if SET { diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index a5c59d76..ee34584b 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -14,7 +14,9 @@ use crate::play::Play; use crate::recorder::{Sampler, Window}; use crate::reduction; use crate::residual::{Sample, Shortcut}; -use crate::transposition::{DEFAULT_TABLE_BYTES, Probe, SignatureCounters, TranspositionTable}; +use crate::transposition::{ + DEFAULT_TABLE_BYTES, NO_EVAL, Probe, SignatureCounters, TranspositionTable, +}; use crate::value::{ MateDistanceWindow, Taint, Value, below_the_mate_window, is_mate, mate_distance_window, }; @@ -1862,7 +1864,7 @@ impl AlphaBeta { if score > alpha { if score >= beta { let value = taint.stamp(score); - self.store_cutoff(m, value, 0); + self.store_cutoff(m, value, 0, standing.unwrap_or(NO_EVAL)); return Ok(value); } alpha = score; @@ -1876,7 +1878,13 @@ impl AlphaBeta { let value = taint.stamp(best); if let Some(play) = best_move { - self.store_answer(play, value, 0, alpha != old_alpha); + self.store_answer( + play, + value, + 0, + alpha != old_alpha, + standing.unwrap_or(NO_EVAL), + ); } Ok(value) } @@ -1906,7 +1914,9 @@ impl AlphaBeta { /// leaves whatever it read in the node's taint. /// /// `eval_memo` is filled wherever the gates passed and an evaluation - /// was read, fired or not, so the move loop does not evaluate twice. + /// was read, fired or not, so the move loop does not evaluate twice. It + /// arrives filled where the node's table entry held the evaluation, and + /// is then read rather than computed. /// Alpha is read only for the open window and by the sampler. // two arguments past clippy's limit: the root bounds and the evaluation // handed back to the loop. @@ -1939,7 +1949,10 @@ impl AlphaBeta { { return Ok(None); } - let eval = self.eval(); + let eval = match *eval_memo { + Some(eval) => eval, + None => self.eval(), + }; *eval_memo = Some(eval); // the margin proves `eval - margin` as a lower bound, and fail soft @@ -2121,10 +2134,11 @@ impl AlphaBeta { taint: Taint, score: Score, depth: u8, + static_eval: Score, ) -> Value { self.remember_cutoff(m, tried, depth); let value = taint.stamp(score); - self.store_cutoff(m, value, depth); + self.store_cutoff(m, value, depth, static_eval); value } @@ -2133,7 +2147,12 @@ impl AlphaBeta { /// sort at all, and the tree searched is unchanged. A cutoff answers /// the node. Otherwise the node absorbs what the move scored, or /// nothing when the move was not legal here. - fn search_table_move(&mut self, tt: Play, node: &mut Node) -> Result, Aborted> { + fn search_table_move( + &mut self, + tt: Play, + node: &mut Node, + static_eval: Score, + ) -> Result, Aborted> { let Some(value) = self.search_child( &tt, node.alpha, @@ -2160,6 +2179,7 @@ impl AlphaBeta { node.taint, value.score, node.depth, + static_eval, ))) } @@ -2318,11 +2338,11 @@ impl AlphaBeta { } /// A cutoff's value to the table, when the taint policy allows. - fn store_cutoff(&mut self, m: &Play, value: Value, depth: u8) { + fn store_cutoff(&mut self, m: &Play, value: Value, depth: u8, static_eval: Score) { if self.keeps(value) { - let landed = self - .transpositions - .record_cutoff(&self.board, *m, value, depth); + let landed = + self.transpositions + .record_cutoff(&self.board, *m, value, depth, static_eval); self.ghi.count_store(landed, value); } } @@ -2331,14 +2351,21 @@ impl AlphaBeta { /// best move with its score where a move raised alpha, and as a /// ceiling where none did, the move beside it then being only the one /// that came closest. - fn store_answer(&mut self, play: Play, value: Value, depth: u8, raised_alpha: bool) { + fn store_answer( + &mut self, + play: Play, + value: Value, + depth: u8, + raised_alpha: bool, + static_eval: Score, + ) { if self.keeps(value) { let landed = if raised_alpha { self.transpositions - .record_best(&self.board, play, value, depth) + .record_best(&self.board, play, value, depth, static_eval) } else { self.transpositions - .record_ceiling(&self.board, play, value, depth) + .record_ceiling(&self.board, play, value, depth, static_eval) }; self.ghi.count_store(landed, value); } @@ -2434,8 +2461,9 @@ impl AlphaBeta { }; let mut taint = Taint::default(); // the node's static evaluation, filled by the shortcuts and read by - // the late move decision - let mut eval: Option = None; + // the late move decision, or found in the table's entry + let table_eval = self.transpositions.probed_eval(self.board.key); + let mut eval: Option = (table_eval != NO_EVAL).then_some(table_eval); if let Some(value) = self.shortcuts( alpha, beta, @@ -2448,6 +2476,8 @@ impl AlphaBeta { )? { return Ok(value); } + // what the node's stores carry into the table + let static_eval = eval.unwrap_or(NO_EVAL); let table_move = pv_play.filter(|tt| self.board.is_pseudo_legal(tt)); let mut node = Node::open( @@ -2462,7 +2492,7 @@ impl AlphaBeta { taint, ); if let Some(tt) = table_move { - if let Some(value) = self.search_table_move(tt, &mut node)? { + if let Some(value) = self.search_table_move(tt, &mut node, static_eval)? { return Ok(value); } } @@ -2532,7 +2562,7 @@ impl AlphaBeta { .enumerate() .filter(|(place, _)| made.holds(*place)) .map(|(_, tried)| tried); - return Ok(self.cutoff(m, tried, node.taint, value.score, depth)); + return Ok(self.cutoff(m, tried, node.taint, value.score, depth, static_eval)); } Reached::Alpha => { // the dropped moves stay dropped only while alpha is @@ -2570,7 +2600,7 @@ impl AlphaBeta { .best_move .expect("a legal move was found, so one of them is best"); let value = node.taint.stamp(node.best); - self.store_answer(play, value, depth, node.raised_alpha()); + self.store_answer(play, value, depth, node.raised_alpha(), static_eval); Ok(value) } diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 3dd3cf1e..272d4583 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -44,6 +44,48 @@ mod search { AlphaBeta::with_table_bytes(board, TABLE_BYTES) } + /// A full width node takes its static evaluation from the entry its + /// probe finds, so what the search stores beside a position has to be + /// that position's evaluation. After a search, every position within two + /// plies of the root whose entry holds one is held to the evaluation + /// computed afresh. + #[test] + fn a_table_entry_holds_its_own_positions_evaluation() { + fn stored(e: &AlphaBeta, board: &Board) -> Option { + let _ = e + .transpositions + .probe(board, Score::MIN + 1, Score::MAX - 1, 0, false, false); + let found = e.transpositions.probed_eval(board.key); + (found != crate::transposition::NO_EVAL).then_some(found) + } + for fen in fens::CORE { + let mut e = engine(Board::from_fen(fen).unwrap()); + completed(e.search(6)); + let mut board = e.board.clone(); + let mut positions = vec![board.clone()]; + for m in &board.generate_moves() { + if board.make_move(m) { + positions.push(board.clone()); + for reply in &board.generate_moves() { + if board.make_move(reply) { + positions.push(board.clone()); + board.undo_move(); + } + } + board.undo_move(); + } + } + let mut held = 0; + for position in &positions { + if let Some(found) = stored(&e, position) { + assert_eq!(found, crate::eval::eval(position), "{}", fen); + held += 1; + } + } + assert!(held > 0, "no entry held an evaluation in {}", fen); + } + } + #[test] fn a_resized_table_is_the_size_asked_for_and_still_searched_on() { let mut e = engine(Board::new()); @@ -247,10 +289,13 @@ mod search { let game = Board::from_fen("k7/8/8/3q4/8/8/3R4/K7 w - - 0 1").unwrap(); let mut e = engine(game); let quiet = play_named(&e.board, "a1b1"); - assert!( - e.transpositions - .record_best(&e.board, quiet, Value::clean(0), 14) - ); + assert!(e.transpositions.record_best( + &e.board, + quiet, + Value::clean(0), + 14, + crate::transposition::NO_EVAL + )); let result = completed(e.search(2)); let takes = play_named(&e.board, "d2d5"); assert_eq!(result.best_move, takes); @@ -289,20 +334,26 @@ mod search { let (best, best_score) = scored[2]; let mut e = engine(Board::from_fen(FEN).unwrap()); - assert!( - e.transpositions - .record_best(&e.board, middle, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &e.board, + middle, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); let result = completed(e.search(1)); assert_eq!(result.best_move, best); assert_eq!(result.score, best_score); assert_eq!(e.nodes, 9); let mut e = engine(Board::from_fen(FEN).unwrap()); - assert!( - e.transpositions - .record_best(&e.board, best, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &e.board, + best, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); let result = completed(e.search(1)); assert_eq!(result.best_move, best); assert_eq!(result.score, best_score); @@ -340,7 +391,8 @@ mod search { &e.board, reply, Value::clean(-alpha - 1), - SEEDED_DEPTH + SEEDED_DEPTH, + crate::transposition::NO_EVAL )); let Ok(value) = e.windowed(alpha, beta, 2, &later(0), RootBounds::Neither) else { panic!("an unlimited search aborted"); @@ -1555,10 +1607,13 @@ mod search { let mut trees = Vec::new(); for play in captures { let mut e = reference(board.clone()); - assert!( - e.transpositions - .record_best(&e.board, play, Value::tainted(0), 1) - ); + assert!(e.transpositions.record_best( + &e.board, + play, + Value::tainted(0), + 1, + crate::transposition::NO_EVAL + )); e.quiescence_value(); assert_eq!(e.ghi().refused_cutoffs, 1, "the seed was not refused"); trees.push(e.nodes); @@ -1583,18 +1638,24 @@ mod search { assert!(board.make_move(&play), "failed to play {}", name); } let any = play_named(&board, "b1c1"); - assert!( - e.transpositions - .record_best(&board, any, Value::tainted(0), 9) - ); + assert!(e.transpositions.record_best( + &board, + any, + Value::tainted(0), + 9, + crate::transposition::NO_EVAL + )); // the root's entry names the king move, so the seeded line is // searched first, at the open window, before standing pat could // end the frame let king = play_named(&e.board, "a1b1"); - assert!( - e.transpositions - .record_best(&e.board, king, Value::clean(0), 9) - ); + assert!(e.transpositions.record_best( + &e.board, + king, + Value::clean(0), + 9, + crate::transposition::NO_EVAL + )); // the seeding went straight into the table, which counts // nothing, so every tainted store here is the search's completed(e.search(1)); @@ -2529,10 +2590,13 @@ mod search { let mut board = e.board.clone(); for name in cycle.iter().cycle().take(16) { let play = play_named(&board, name); - assert!( - e.transpositions - .record_best(&board, play, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &board, + play, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); assert!(board.make_move(&play), "failed to play {}", name); } @@ -2546,10 +2610,13 @@ mod search { let mut board = e.board.clone(); for name in ["c3d4", "f8g8"] { let play = play_named(&board, name); - assert!( - e.transpositions - .record_best(&board, play, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &board, + play, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); assert!(board.make_move(&play), "failed to play {}", name); } assert!(board.fifty_move_expired()); @@ -2564,10 +2631,13 @@ mod search { let a2 = 8; let a5 = 32; let colliding = Play::new(a2, a5, None, None, false, false); - assert!( - e.transpositions - .record_best(&e.board, colliding, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &e.board, + colliding, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); assert_eq!(format!("{}", e.pv_line()), ""); } @@ -2578,10 +2648,13 @@ mod search { // what the engine means to play let mut e = engine(Board::new()); let play = play_named(&e.board, "e2e4"); - assert!( - e.transpositions - .record_best(&e.board, play, Value::clean(0), 0) - ); + assert!(e.transpositions.record_best( + &e.board, + play, + Value::clean(0), + 0, + crate::transposition::NO_EVAL + )); assert_eq!(format!("{}", e.pv_line()), ""); } @@ -2593,10 +2666,13 @@ mod search { let board = Board::from_fen("4r2k/8/8/8/8/8/4N3/4K3 w - - 0 1").unwrap(); let mut e = engine(board); let pinned = play_named(&e.board, "e2d4"); - assert!( - e.transpositions - .record_best(&e.board, pinned, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &e.board, + pinned, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); assert_eq!(format!("{}", e.pv_line()), ""); } @@ -2633,10 +2709,13 @@ mod search { } let play = chosen.unwrap_or_else(|| panic!("nothing carries the line on at ply {}", ply)); - assert!( - e.transpositions - .record_best(&board, play, Value::clean(0), SEEDED_DEPTH) - ); + assert!(e.transpositions.record_best( + &board, + play, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); assert!(board.make_move(&play), "failed to play {}", play); } @@ -3761,10 +3840,16 @@ mod node { let step = play_named(&e.board, "e1d1"); assert!(e.board.is_pseudo_legal(&pinned) && e.board.is_pseudo_legal(&step)); let mut node = open(2, -20_000, 20_000, RootBounds::Neither); - assert!(matches!(e.search_table_move(pinned, &mut node), Ok(None))); + assert!(matches!( + e.search_table_move(pinned, &mut node, crate::transposition::NO_EVAL), + Ok(None) + )); assert_eq!(node.searched, 0, "the illegal move was counted"); assert!(!node.raised_alpha()); - assert!(matches!(e.search_table_move(step, &mut node), Ok(None))); + assert!(matches!( + e.search_table_move(step, &mut node, crate::transposition::NO_EVAL), + Ok(None) + )); assert_eq!(node.searched, 1, "the table's move was not counted"); } } diff --git a/arche-core/src/eval/cache.rs b/arche-core/src/eval/cache.rs index 40a48dc2..69751259 100644 --- a/arche-core/src/eval/cache.rs +++ b/arche-core/src/eval/cache.rs @@ -15,7 +15,9 @@ struct Entry { packed: i32, } -/// What one term has already worked out, `1 << BITS` scores of it. +/// What one term has already worked out, `SLOTS` scores of it, in an array of +/// that length rather than a slice, so the slot's mask bounds its index and a +/// probe carries no bounds check. /// /// Direct mapped and never cleared. An entry is only ever read against the /// key that wrote it, so a stale one is a miss rather than a wrong answer. @@ -24,26 +26,30 @@ struct Entry { /// /// Owned by the searcher rather than by the board, because it is scratch and /// not position. -pub(super) struct Cache { - entries: Box<[Entry]>, +pub(super) struct Cache { + entries: Box<[Entry; SLOTS]>, } -impl Default for Cache { +impl Default for Cache { fn default() -> Self { // an empty entry is key zero holding zero. A pawnless board's pawn // key is zero and its pawn structure scores zero, so it reads the // entry correctly; any other key of zero is the same sixty four bit // coincidence a wrong hit needs anywhere else in the table + let entries = vec![Entry { key: 0, packed: 0 }; Self::SLOTS].into_boxed_slice(); Cache { - entries: vec![Entry { key: 0, packed: 0 }; Self::SLOTS].into_boxed_slice(), + entries: entries.try_into().unwrap_or_else(|_| unreachable!()), } } } -impl Cache { +impl Cache { /// How many scores the table holds. A power of two, so the index is a /// mask rather than a remainder. - pub(super) const SLOTS: usize = 1 << BITS; + pub(super) const SLOTS: usize = { + assert!(SLOTS.is_power_of_two()); + SLOTS + }; /// What `key` stands for, read back where the table holds it and folded /// where it does not. @@ -87,13 +93,13 @@ mod tests { /// with no pawns, whose key is zero and whose counts are zero. #[test] fn a_score_is_folded_once_and_read_back_under_the_key_that_wrote_it() { - const BITS: usize = 4; - let mut cache: Cache = Cache::default(); - assert_eq!(cache.entries.len(), Cache::::SLOTS); + const SLOTS: usize = 16; + let mut cache: Cache = Cache::default(); + assert_eq!(cache.entries.len(), Cache::::SLOTS); let key = 0x0123_4567_89ab_cdefu64; let shares = key ^ (1 << 63); - let slot = (key as usize) & (Cache::::SLOTS - 1); - assert_eq!((shares as usize) & (Cache::::SLOTS - 1), slot); + let slot = (key as usize) & (Cache::::SLOTS - 1); + assert_eq!((shares as usize) & (Cache::::SLOTS - 1), slot); assert_eq!(cache.entries[slot].key, 0, "the slot starts empty"); assert_eq!(cache.entries[slot].packed, 0, "holding nothing"); diff --git a/arche-core/src/eval/factors.rs b/arche-core/src/eval/factors.rs index 92e9baab..9d74faa4 100644 --- a/arche-core/src/eval/factors.rs +++ b/arche-core/src/eval/factors.rs @@ -14,9 +14,12 @@ //! i < j //! ``` //! -//! so the board keeps `s` and `D` for each perspective and the leaf reads two -//! sums of squares. White's perspective less black's is the term, which makes -//! it white relative like the rest of the accumulator. It is not tapered. +//! White's perspective less black's is the term, which makes it white +//! relative like the rest of the accumulator. It is not tapered. The two sums +//! of squares are read as one product, `‖s_w‖² - ‖s_b‖² = (s_w + s_b)·(s_w - +//! s_b)`, so the board keeps the sum and the difference of the two +//! perspectives' `s`, and `D_w - D_b`, and the leaf reads one dot product +//! where it read two. //! //! At a `RANK` of 0 every array here has no length and [`Machine::score`] //! answers 0 before reading anything, so the term compiles away. The shipped @@ -151,15 +154,18 @@ const fn lane_bounds() -> [i64; RANK] { bounds } -/// Whether no legal position can overflow the arithmetic: every lane fits -/// i16, and a perspective's sum of squares fits i32. `D` fits with it, since -/// `Σ_i q_i,r²` is at most `(Σ_i |q_i,r|)²` in every lane. +/// Whether no legal position can overflow the arithmetic: every lane of the +/// two perspectives' sum and difference fits i16 (each is at most twice a +/// perspective's lane), and a perspective's sum of squares fits i32. `D` fits +/// with it, since `Σ_i q_i,r²` is at most `(Σ_i |q_i,r|)²` in every lane. The +/// product of the sum and the difference is the difference of two sums of +/// squares that each fit i32, so it fits i32 too, and so does `D_w - D_b`. const fn in_range() -> bool { let bounds = lane_bounds(); let mut squares = 0; let mut lane = 0; while lane != RANK { - if bounds[lane] > i16::MAX as i64 { + if 2 * bounds[lane] > i16::MAX as i64 { return false; } squares += bounds[lane] * bounds[lane]; @@ -190,7 +196,9 @@ pub(crate) fn row(feature: usize) -> &'static [i16; RANK] { &FACTORS[feature] } -/// The term's incremental state, one per perspective, indexed by `Color`. +/// The term's incremental state: the two perspectives' sums added and +/// subtracted (white's less black's), and their diagonals subtracted the +/// same way. /// /// The lanes wrap rather than check: a position with more pieces than a /// legal one can stand (a fen can state one) scores wrongly rather than @@ -198,17 +206,16 @@ pub(crate) fn row(feature: usize) -> &'static [i16; RANK] { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) struct Machine { sums: [[i16; RANK]; 2], - diagonal: [[i32; LIVE]; 2], + diagonal: [i32; LIVE], } impl Machine { pub(crate) const EMPTY: Self = Self { sums: [[0; RANK]; 2], - diagonal: [[0; LIVE]; 2], + diagonal: [0; LIVE], }; - /// A piece counted on to or off of a square, both perspectives read from - /// the piece's one row. + /// A piece counted on to or off of a square, from the piece's one row. #[inline(always)] pub(crate) fn count(&mut self, row: &super::Row) { // at rank 0 a row holds no lanes and the loops below walk nothing; @@ -224,13 +231,13 @@ impl Machine { sum.wrapping_sub(factor) }; } - for (sum, &diagonal) in self.diagonal[at].iter_mut().zip(&row.diagonal[at]) { - *sum = if SET { - sum.wrapping_add(diagonal) - } else { - sum.wrapping_sub(diagonal) - }; - } + } + for (sum, &diagonal) in self.diagonal.iter_mut().zip(&row.diagonal) { + *sum = if SET { + sum.wrapping_add(diagonal) + } else { + sum.wrapping_sub(diagonal) + }; } } @@ -249,18 +256,22 @@ impl Machine { { *sum = sum.wrapping_add(on.wrapping_sub(off)); } - for ((sum, &off), &on) in self.diagonal[at] - .iter_mut() - .zip(&left.diagonal[at]) - .zip(&arrived.diagonal[at]) - { - *sum = sum.wrapping_add(on.wrapping_sub(off)); - } + } + for ((sum, &off), &on) in self + .diagonal + .iter_mut() + .zip(&left.diagonal) + .zip(&arrived.diagonal) + { + *sum = sum.wrapping_add(on.wrapping_sub(off)); } } /// The state the pieces deserve, summed from the table directly rather /// than through `count`, for `Accumulator::recomputed`. + // index loops on purpose: the iterator form clippy asks for changed + // how llvm compiled the search, 0.2% to 0.8% more of its instructions + #[allow(clippy::needless_range_loop)] pub(crate) fn of(pieces: impl Iterator) -> Self { let mut sums = [[0_i16; RANK]; 2]; let mut diagonal = [[0_i32; LIVE]; 2]; @@ -280,7 +291,16 @@ impl Machine { } } } - Self { sums, diagonal } + let (white, black) = (Color::White as usize, Color::Black as usize); + let mut kept = Self::EMPTY; + for lane in 0..RANK { + kept.sums[0][lane] = sums[white][lane].wrapping_add(sums[black][lane]); + kept.sums[1][lane] = sums[white][lane].wrapping_sub(sums[black][lane]); + } + for slot in 0..LIVE { + kept.diagonal[slot] = diagonal[white][slot].wrapping_sub(diagonal[black][slot]); + } + kept } /// The term, white relative, in centipawns. @@ -289,16 +309,18 @@ impl Machine { if RANK == 0 { return 0; } - let doubled = |at: usize| { - let squares = self.sums[at].iter().fold(0_i32, |total, &sum| { - total.wrapping_add(i32::from(sum) * i32::from(sum)) + // `‖s_w‖² - ‖s_b‖²`, which fits i32 (see `in_range`), so the wrapping + // sum lands on it + let squares = self.sums[0] + .iter() + .zip(&self.sums[1]) + .fold(0_i32, |total, (&sum, &difference)| { + total.wrapping_add(i32::from(sum) * i32::from(difference)) }); - let diagonal: i32 = self.diagonal[at].iter().sum(); - i64::from(squares) - i64::from(diagonal) - }; + let diagonal: i32 = self.diagonal.iter().sum(); // truncated toward zero, so a mirrored position scores the exact // negation of its mirror - ((doubled(Color::White as usize) - doubled(Color::Black as usize)) / (2 * Q * Q)) as i32 + ((i64::from(squares) - i64::from(diagonal)) / (2 * Q * Q)) as i32 } } diff --git a/arche-core/src/eval/mod.rs b/arche-core/src/eval/mod.rs index 7b815ffc..27d72a09 100644 --- a/arche-core/src/eval/mod.rs +++ b/arche-core/src/eval/mod.rs @@ -48,9 +48,14 @@ const MATERIAL: [u32; 6] = [100, 310, 320, 500, 900, 10000]; #[derive(Debug, Clone, Copy)] #[repr(C, align(32))] pub(crate) struct Row { + /// The piece's factors from white's side and black's, added and + /// subtracted, as `factors::Machine` keeps them. pub(crate) lanes: [[i16; factors::RANK]; 2], - pub(crate) diagonal: [[i32; factors::LIVE]; 2], + /// White's diagonal less black's. + pub(crate) diagonal: [i32; factors::LIVE], pub(crate) psqt: i32, + /// The piece's material, signed from white's side like `psqt`. + pub(crate) material: i32, pub(crate) key: u64, } @@ -59,12 +64,12 @@ pub(crate) static ROWS: [Row; 768] = rows(); const fn rows() -> [Row; 768] { let empty = Row { lanes: [[0; factors::RANK]; 2], - diagonal: [[0; factors::LIVE]; 2], + diagonal: [0; factors::LIVE], psqt: 0, + material: 0, key: 0, }; let mut out = [empty; 768]; - let colors = [Color::Black, Color::White]; let pieces = [ Piece::Pawn, Piece::Knight, @@ -84,18 +89,31 @@ const fn rows() -> [Row; 768] { let mut square = 0; while square < 64 { let row = &mut out[table * 64 + square]; - let mut at = 0; - while at < 2 { - let feature = factors::feature(colors[at], square as u8, piece, color); - row.lanes[at] = factors::FACTORS[feature]; - row.diagonal[at] = factors::DIAGONAL[feature]; - at += 1; + let white = factors::feature(Color::White, square as u8, piece, color); + let black = factors::feature(Color::Black, square as u8, piece, color); + let mut lane = 0; + while lane < factors::RANK { + let (w, b) = (factors::FACTORS[white][lane], factors::FACTORS[black][lane]); + row.lanes[0][lane] = w.wrapping_add(b); + row.lanes[1][lane] = w.wrapping_sub(b); + lane += 1; + } + let mut slot = 0; + while slot < factors::LIVE { + row.diagonal[slot] = + factors::DIAGONAL[white][slot].wrapping_sub(factors::DIAGONAL[black][slot]); + slot += 1; } let value = PieceSquareTables::TABLES.value_at(table, square); row.psqt = match color { Color::White => value, Color::Black => -value, }; + let material = MATERIAL[piece as usize] as i32; + row.material = match color { + Color::White => material, + Color::Black => -material, + }; row.key = crate::zobrist::Zobrist::TABLE.piece_key_at(table, square); square += 1; } @@ -209,10 +227,11 @@ const fn widest() -> usize { /// What the two remembered terms answer, asked of whatever the caller is /// carrying, so the cached evaluation and the uncached one are one [`sum`] /// rather than two kept saying the same thing. A term that learns to remember -/// itself adds a method here and an implementation in each of the two below. +/// itself is added into `tables` in each of the two below, and can share the +/// shelter's entry only if the shelter's key covers everything it reads. trait Memo { - fn shelter(&mut self, board: &Board) -> i32; - fn pawn_structure(&mut self, board: &Board) -> i32; + /// The shelter and the pawn structure, added. + fn tables(&mut self, board: &Board) -> i32; } /// The memo that remembers nothing, which is what [`eval`] hands the sum. @@ -220,13 +239,8 @@ struct NoMemo; impl Memo for NoMemo { #[inline] - fn shelter(&mut self, board: &Board) -> i32 { - shelter::fold(board) - } - - #[inline] - fn pawn_structure(&mut self, board: &Board) -> i32 { - pawn_structure::fold(board) + fn tables(&mut self, board: &Board) -> i32 { + shelter::fold(board) + pawn_structure::fold(board) } } @@ -234,9 +248,11 @@ impl Memo for NoMemo { /// again at every leaf. One value, so a term that learns to remember itself /// is a field here rather than a parameter everywhere a score is asked for. /// -/// Two tables rather than one wider entry under the shelter's key: one probe -/// for both would recompute the pawn structure on every king move, the half -/// of the shelter's key that term does not need. Measured on the fitted build. +/// The shelter's table holds the two terms added, under the shelter's key, +/// which covers everything either reads, so a hit there is the one probe. +/// The pawn table stays behind it: a key of the shelter's alone would +/// recompute the pawn structure on every king move, the half of the +/// shelter's key that term does not need. Measured on the fitted build. #[derive(Default)] pub(crate) struct Caches { shelter: ShelterCache, @@ -244,21 +260,17 @@ pub(crate) struct Caches { } /// The shelter's table, as wide as that term measured it wants. -type ShelterCache = Cache<{ shelter::CACHE_BITS }>; +type ShelterCache = Cache<{ 1 << shelter::CACHE_BITS }>; /// The pawn structure's, on its own key and its own measurement. -type PawnCache = Cache<{ pawn_structure::CACHE_BITS }>; +type PawnCache = Cache<{ 1 << pawn_structure::CACHE_BITS }>; impl Memo for Caches { #[inline] - fn shelter(&mut self, board: &Board) -> i32 { - self.shelter - .get(shelter::key(board), || shelter::fold(board)) - } - - #[inline] - fn pawn_structure(&mut self, board: &Board) -> i32 { - self.pawns - .get(board.pawn_key, || pawn_structure::fold(board)) + fn tables(&mut self, board: &Board) -> i32 { + let pawns = &mut self.pawns; + self.shelter.get(shelter::key(board), || { + shelter::fold(board) + pawns.get(board.pawn_key, || pawn_structure::fold(board)) + }) } } @@ -282,10 +294,11 @@ fn sum(board: &Board, memo: &mut impl Memo) -> Score { if board.drawn_by_material() { return 0; } + // probed before the walk, where llvm had held the tables across it + let tables = memo.tables(board); let walk = |color| attack_score::<{ mobility::SCORED_KINDS }, { king_attack::SCORED }>(board, color); - let leaf = - walk(Color::White) - walk(Color::Black) + memo.shelter(board) + memo.pawn_structure(board); + let leaf = walk(Color::White) - walk(Color::Black) + tables; board.eval.score(board.active_color, leaf) } @@ -384,9 +397,8 @@ pub(crate) fn eval_cached(board: &Board, caches: &mut Caches) -> Score { /// copies back the state saved before the move. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) struct Accumulator { - /// Each side's material, indexed by `Color`'s discriminant: an index is a - /// load where a match on the colour was a branch. - material: [u32; 2], + /// White's material less black's, the one way the score reads it. + material: i32, /// Piece square score, as the packed pair of midgame and endgame halves /// the tables hold (`psqt::pack`), so carrying both phases costs one add. psqt: i32, @@ -402,7 +414,7 @@ pub(crate) struct Accumulator { impl Accumulator { /// A board with nothing on it scores nothing. pub(crate) const EMPTY: Self = Self { - material: [0; 2], + material: 0, psqt: 0, phase: 0, machine: factors::Machine::EMPTY, @@ -411,17 +423,16 @@ impl Accumulator { /// Count a piece on to or off of a square, from its row. The row's pair /// is negated whole for black, and negating the sum negates both halves. #[inline(always)] - pub(crate) fn count(&mut self, row: &Row, piece: Piece, color: Color) { + pub(crate) fn count(&mut self, row: &Row, piece: Piece) { let phase = PHASE_WEIGHTS[piece as usize]; - let value = MATERIAL[piece as usize]; if SET { self.psqt += row.psqt; self.phase += phase; - self.material[color as usize] += value; + self.material += row.material; } else { self.psqt -= row.psqt; self.phase -= phase; - self.material[color as usize] -= value; + self.material -= row.material; } self.machine.count::(row); } @@ -453,7 +464,11 @@ impl Accumulator { Color::White => recomputed.psqt += psqt, Color::Black => recomputed.psqt -= psqt, } - recomputed.material[color as usize] += MATERIAL[piece as usize]; + let value = MATERIAL[piece as usize] as i32; + match color { + Color::White => recomputed.material += value, + Color::Black => recomputed.material -= value, + } recomputed.phase += PHASE_WEIGHTS[piece as usize]; } } @@ -465,14 +480,12 @@ impl Accumulator { /// board in; the state check then compares the seeding against an /// implementation that did not do it. pub(crate) fn seed_material(&mut self, (white, black): (u32, u32)) { - self.material[Color::White as usize] = white; - self.material[Color::Black as usize] = black; + self.material = white as i32 - black as i32; } /// What white stands ahead by, for the board's debug print. pub(crate) fn material_difference(&self) -> i64 { - i64::from(self.material[Color::White as usize]) - - i64::from(self.material[Color::Black as usize]) + i64::from(self.material) } /// The score from `side`'s point of view. @@ -498,10 +511,7 @@ impl Accumulator { (mg_value(tapered) * phase + eg_value(tapered) * (TOTAL_PHASE - phase)) / TOTAL_PHASE; // the pair term is not tapered, so it joins material outside the // divide - let eval = (self.material[Color::White as usize] as i32 - - self.material[Color::Black as usize] as i32 - + scaled - + self.machine.score()) as Score; + let eval = (self.material + scaled + self.machine.score()) as Score; match side { Color::White => eval, Color::Black => -eval, @@ -568,12 +578,10 @@ mod evaluate { let mut board = Board::from_fen(fen).unwrap(); for m in &board.generate_moves() { if board.make_move(m) { + let (white, black) = board.material_value(); assert_eq!( - ( - board.eval.material[crate::misc::Color::White as usize], - board.eval.material[crate::misc::Color::Black as usize] - ), - board.material_value(), + board.eval.material, + white as i32 - black as i32, "{} in {}", m, fen @@ -756,8 +764,7 @@ mod evaluate { inside, beside, "the two divides agree here, so this position says nothing" ); - let material = accumulator.material[Color::White as usize] as i32 - - accumulator.material[Color::Black as usize] as i32; + let material = accumulator.material; // the pair term is outside the divide, beside material assert_eq!( accumulator.score(Color::White, mobility), @@ -889,9 +896,10 @@ mod evaluate { /// is the difference between the two caches and why the pawn structure /// could be cached in the commit that introduced it. /// - /// Each table is then read under its own term's key. Both hold the - /// position before the move. After it the pawn table still answers, and - /// the shelter table has to fold again. + /// The shelter's table holds the two terms added and the pawn table the + /// pawn structure, each under its own key. Both hold the position before + /// the move. After it the pawn table still answers, and the shelter's + /// table has to fold the shelter again. #[test] fn a_king_move_keeps_the_pawn_entry_and_loses_the_shelter_one() { let board = Board::from_fen("4k3/pppppppp/8/8/8/8/PPPPPPPP/4K3 w - - 0 1").unwrap(); @@ -910,18 +918,15 @@ mod evaluate { // would read as a hit wherever it landed assert_ne!(board.pawn_key, 0); let mut caches = Caches::default(); + let both = |board: &Board| shelter::fold(board) + pawn_structure::fold(board); assert_eq!( - Memo::shelter(&mut caches, &board), - shelter::fold(&board), + Memo::tables(&mut caches, &board), + both(&board), "the first probe folds" ); - assert_eq!( - Memo::pawn_structure(&mut caches, &board), - pawn_structure::fold(&board) - ); assert_eq!( caches.shelter.stored(shelter::key(&board)), - Some(shelter::fold(&board)) + Some(both(&board)) ); assert_eq!( caches.pawns.stored(board.pawn_key), @@ -938,10 +943,10 @@ mod evaluate { None, "and the shelter table does not" ); - assert_eq!(Memo::shelter(&mut caches, &moved), shelter::fold(&moved)); + assert_eq!(Memo::tables(&mut caches, &moved), both(&moved)); assert_eq!( caches.shelter.stored(shelter::key(&moved)), - Some(shelter::fold(&moved)) + Some(both(&moved)) ); } diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index f7475f87..9290ff23 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -261,9 +261,10 @@ pub(crate) struct Search<'a> { /// alpha. The list is not held either, since the loop sorts it under the /// decision; the calls that read it are handed it. pub(crate) struct Rules { - /// The node's static evaluation: what the shortcuts read, or none - /// until the first move that needs it. The recorders take their own, - /// so a node the gate scores nothing at never computes one. + /// The node's static evaluation: what the table's entry held or the + /// shortcuts read, or none until the first move that needs it. The + /// recorders take their own, so a node the gate scores nothing at never + /// computes one. pub(crate) eval: Option, /// The node's history denominator. Held rather than walked again so /// that the row a staging records says what the gate scored: a child @@ -277,8 +278,8 @@ pub(crate) struct Rules { impl Rules { /// The rules once the node's table move has been searched, with `eval` - /// as the shortcuts left it. The two rule halves are read off the - /// node's facts here. + /// as the table and the shortcuts left it. The two rule halves are read + /// off the node's facts here. pub(crate) fn new(search: &Search, node: &Node, eval: Option) -> Self { Self { eval, diff --git a/arche-core/src/transposition.rs b/arche-core/src/transposition.rs index 71ac7731..aad8e622 100644 --- a/arche-core/src/transposition.rs +++ b/arche-core/src/transposition.rs @@ -154,9 +154,9 @@ impl SignatureCounters { /// The ground truth the table does not keep: the full key of every entry, /// written whenever one lands. Allocated only when the bench asks for the /// audit, so an ordinary search carries a null pointer and one predictable -/// branch a probe. The unused `static_eval` bytes are no use for this: -/// sixteen more bits of signature would still alias where sixty four -/// cannot. +/// branch a probe. The `static_eval` bytes would be no use for this even if +/// they were free: sixteen more bits of signature would still alias where +/// sixty four cannot. #[derive(Debug)] struct Audit { /// One key an entry, at the bucket's index times four plus the entry @@ -196,6 +196,9 @@ impl Audit { struct Pv { play: Play, score: Score, + /// The static evaluation of the position the entry is for, or + /// `NO_EVAL` when the node that stored it did not evaluate. + static_eval: Score, /// True if the score flowed from a repetition or fifty move draw below /// it, so it describes the path taken to this position and not the /// position. What the flag does not cover is under known limitations in @@ -255,9 +258,10 @@ pub enum Probe { /// /// The flags byte holds the bound in its low two bits, the taint in the /// third and the generation in the top five; generation zero is a slot -/// never written. Two bytes are set aside for the static evaluation, which -/// the shelved correction history arm (docs/ROADMAP.md) would store, so the -/// layout and every node count change once rather than twice. +/// never written. The last two bytes are the static evaluation of the +/// position, or `NO_EVAL` where the node that stored the entry did not +/// evaluate, so a full width node that finds its entry need not evaluate +/// again. #[derive(Copy, Clone, Debug)] #[repr(C)] struct Entry { @@ -266,8 +270,7 @@ struct Entry { score: Score, depth: u8, flags: u8, - #[allow(dead_code)] - static_eval: i16, + static_eval: Score, } // A probe reads one cache line for all four entries, and every pinned node @@ -325,7 +328,7 @@ impl Entry { score: pv.score, depth: pv.depth, flags: (pv.bound as u8) | (u8::from(pv.tainted) << 2) | (generation << 3), - static_eval: 0, + static_eval: pv.static_eval, } } @@ -334,6 +337,7 @@ impl Entry { Pv { play: self.play, score: self.score, + static_eval: self.static_eval, depth: self.depth, bound: Bound::from_bits(self.flags), tainted: self.flags & 0b100 != 0, @@ -371,7 +375,7 @@ struct Rest { score: Score, depth: u8, flags: u8, - static_eval: i16, + static_eval: Score, } const _: () = assert!(mem::size_of::() == 12); @@ -641,8 +645,25 @@ pub struct TranspositionTable { /// The full keys of the entries, or none, which is what every table an /// engine plays with holds. `audit_signatures` fills it in. audit: Option>, + /// The static evaluation the last probe found in its entry, or + /// `NO_EVAL` on a miss. A cell, since the probe reads the table. + probed_eval: Cell, + /// The key the last probe asked for, so a debug build can tell a read + /// of `probed_eval` that another probe came between. + #[cfg(debug_assertions)] + probed_key: Cell, } +/// What an entry holds for the static evaluation when the node that stored +/// it did not evaluate: a node in check, one the shortcuts' gates turned +/// away before they evaluated, and the root's own stores. No evaluation +/// comes near it. +/// +/// An entry holds the raw evaluation, which is a function of the position +/// alone. A correction that reads anything else (the line, the history, the +/// fifty move counter) is applied after the read and never stored. +pub(crate) const NO_EVAL: Score = Score::MIN; + /// The sizes `up_to_bytes` tries, largest first: the size asked for, then /// half of it each time, ending at one bucket, since `with_capacity` rounds /// every smaller size up to one. @@ -686,6 +707,9 @@ impl TranspositionTable { generation: 1, replaceable: replaceable_under(1), audit: None, + probed_eval: Cell::new(NO_EVAL), + #[cfg(debug_assertions)] + probed_key: Cell::new(0), }) } @@ -975,23 +999,56 @@ impl TranspositionTable { /// crossed. Each `record_` method reports whether the entry landed. #[must_use] #[inline(always)] - pub fn record_cutoff(&mut self, board: &Board, play: Play, floor: Value, depth: u8) -> bool { - self.set(board.key, entry(board, play, floor, depth, Bound::Lower)) + pub fn record_cutoff( + &mut self, + board: &Board, + play: Play, + floor: Value, + depth: u8, + static_eval: Score, + ) -> bool { + let pv = Pv { + static_eval, + ..entry(board, play, floor, depth, Bound::Lower) + }; + self.set(board.key, pv) } /// Every move here fell short of the window: the score is a ceiling, /// and the move is the one that came closest, worth trying first next /// time though it proved nothing. #[must_use] - pub fn record_ceiling(&mut self, board: &Board, play: Play, ceiling: Value, depth: u8) -> bool { - self.set(board.key, entry(board, play, ceiling, depth, Bound::Upper)) + pub fn record_ceiling( + &mut self, + board: &Board, + play: Play, + ceiling: Value, + depth: u8, + static_eval: Score, + ) -> bool { + let pv = Pv { + static_eval, + ..entry(board, play, ceiling, depth, Bound::Upper) + }; + self.set(board.key, pv) } /// The best move found by searching all of them here, with its exact /// score. #[must_use] - pub fn record_best(&mut self, board: &Board, play: Play, score: Value, depth: u8) -> bool { - self.set(board.key, entry(board, play, score, depth, Bound::Exact)) + pub fn record_best( + &mut self, + board: &Board, + play: Play, + score: Value, + depth: u8, + static_eval: Score, + ) -> bool { + let pv = Pv { + static_eval, + ..entry(board, play, score, depth, Bound::Exact) + }; + self.set(board.key, pv) } /// The move the engine is about to answer with, stored past the depth @@ -1034,9 +1091,13 @@ impl TranspositionTable { guard_rule50: bool, ) -> Probe { let (found, foreign) = self.get_audited(board.key); + #[cfg(debug_assertions)] + self.probed_key.set(board.key); let Some(pv) = found else { + self.probed_eval.set(NO_EVAL); return Probe::Miss; }; + self.probed_eval.set(pv.static_eval); if pv.depth >= depth { let score = score_from_tt(pv.score, board.line_ply); let cuts = match pv.bound { @@ -1062,6 +1123,19 @@ impl TranspositionTable { Probe::Order(pv.play) } + /// The static evaluation the last probe found in the entry of the + /// position `key` names, or `NO_EVAL` where it found none or the entry + /// holds none. The last probe must have been of `key`: a search between + /// the two would leave its own evaluation here. + #[inline(always)] + pub(crate) fn probed_eval(&self, key: u64) -> Score { + #[cfg(debug_assertions)] + assert_eq!(self.probed_key.get(), key, "another probe came between"); + #[cfg(not(debug_assertions))] + let _ = key; + self.probed_eval.get() + } + /// The move to try first here, whatever wrote it, quiescence included. #[inline] pub fn ordering_play(&self, board: &Board) -> Option { @@ -1085,6 +1159,7 @@ fn entry(board: &Board, play: Play, value: Value, depth: u8, bound: Bound) -> Pv Pv { play, depth, + static_eval: NO_EVAL, score: score_to_tt(value.score, board.line_ply), bound, tainted: value.tainted, @@ -1105,6 +1180,7 @@ mod tests { fn new_pv(bound: Bound, depth: u8) -> Pv { Pv { play: Play::new(0, 1, None, None, false, false), + static_eval: super::NO_EVAL, score: 0, depth, bound, @@ -1202,6 +1278,7 @@ mod tests { (4, Bound::Ordering), ] { let pv = Pv { + static_eval: super::NO_EVAL, play: Play::new( 63, 7, @@ -1301,7 +1378,7 @@ mod tests { let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); - assert!(table.record_ceiling(&board, play, Value::clean(-50), 5)); + assert!(table.record_ceiling(&board, play, Value::clean(-50), 5, super::NO_EVAL)); match table.probe(&board, -10, 10, 5, true, false) { Probe::Cut(value) => assert_eq!(value, Value::clean(-50)), other => panic!("a ceiling under alpha did not cut: {other:?}"), @@ -1347,7 +1424,7 @@ mod tests { let mut board = crate::board::Board::new(); board.line_ply = STORED_AT; - assert!(table.record_best(&board, play, stored, 5)); + assert!(table.record_best(&board, play, stored, 5, super::NO_EVAL)); board.line_ply = PROBED_AT; match table.probe(&board, Score::MIN + 1, Score::MAX - 1, 5, false, false) { @@ -1367,7 +1444,7 @@ mod tests { let fresh = crate::board::Board::from_fen("4k3/8/8/8/8/8/8/4K3 w - - 0 1").unwrap(); let near = crate::board::Board::from_fen("4k3/8/8/8/8/8/8/4K3 w - - 96 112").unwrap(); let play = Play::new(0, 1, None, None, false, false); - assert!(table.record_best(&fresh, play, Value::clean(0), 5)); + assert!(table.record_best(&fresh, play, Value::clean(0), 5, super::NO_EVAL)); assert!(matches!( table.probe(&fresh, -10, 10, 5, false, true), Probe::Cut(_) @@ -1386,7 +1463,7 @@ mod tests { let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); - assert!(table.record_best(&board, play, Value::tainted(0), 5)); + assert!(table.record_best(&board, play, Value::tainted(0), 5, super::NO_EVAL)); match table.probe(&board, -10, 10, 5, false, false) { Probe::Cut(value) => assert_eq!(value, Value::tainted(0)), other => panic!("a trusting probe did not cut: {other:?}"), @@ -1408,7 +1485,7 @@ mod tests { let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); - assert!(table.record_best(&board, play, Value::clean(20), 5)); + assert!(table.record_best(&board, play, Value::clean(20), 5, super::NO_EVAL)); let shared = &table; assert!(matches!( shared.probe(&board, -10, 10, 5, true, false), @@ -1433,10 +1510,10 @@ mod tests { let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); assert!( - !table.record_cutoff(&board, play, Value::tainted(0), 1), + !table.record_cutoff(&board, play, Value::tainted(0), 1, super::NO_EVAL), "a turned away store said it landed" ); - assert!(table.record_cutoff(&board, play, Value::tainted(0), 9)); + assert!(table.record_cutoff(&board, play, Value::tainted(0), 9, super::NO_EVAL)); // the root's stores go past the contest, so they always land let mut table = full_bucket(20); assert!(table.record_answer(&board, play, Value::clean(0), 1)); @@ -1705,7 +1782,7 @@ mod tests { assert!(table.audit_signatures()); let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); - assert!(table.record_best(&board, play, Value::clean(20), 5)); + assert!(table.record_best(&board, play, Value::clean(20), 5, super::NO_EVAL)); // a key differing above the slice, sharing the one bucket's index let mut twin = board.clone(); twin.key ^= 1 << 63; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6d9bdbbe..a8a044e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,14 +111,14 @@ term over every pair of pieces. pruning leaves a node. - **transposition.rs**: The transposition table: a cache of positions searched before, keyed by zobrist hash, holding the score and best move - found last time. Entries are 16 bytes, four to a cache line, replaced by - age and depth; the four keys' slices sit side by side at the head of the - line, so one compare reads them all. A hit can answer a node outright or - just say which move to try first. Tainted scores are by default trusted - anyway, except close to the fifty move horizon where every cutoff is - refused; ROADMAP.md has the match that chose that, and the reference - search keeps the refusal. Outside its signature audit the table counts - nothing; ghi.rs does. + found last time and the position's static evaluation. Entries are 16 + bytes, four to a cache line, replaced by age and depth; the four keys' + slices sit side by side at the head of the line, so one compare reads + them all. A hit can answer a node outright or just say which move to try + first. Tainted scores are by default trusted anyway, except close to the + fifty move horizon where every cutoff is refused; ROADMAP.md has the + match that chose that, and the reference search keeps the refusal. + Outside its signature audit the table counts nothing; ghi.rs does. - **ghi.rs**: What the search counts of its use of the table, draw taint included. The searcher owns the counts, so they survive a new table. The bench prints them. @@ -137,26 +137,30 @@ term over every pair of pieces. once for both mobility and the king attack zone. - **factors.rs**: The pair term, a factorization machine over the piece square features: a weight for every pair of pieces, as the inner product - of two rows of sixteen factors. The accumulator keeps each perspective's - sum of the rows, so the leaf reads two sums of squares. The table is - `factors16.rs`, generated by a fitting program outside the repository; - the `machine-test` feature swaps in a seeded rank 8 table so the tests - check the term on a table no fit chose. - - **cache.rs**: The direct mapped cache a remembered term is kept in, one - per term, holding a score under the whole of its key. + of two rows of sixteen factors. The accumulator keeps the sum and the + difference of the two perspectives' sums of the rows, so the leaf reads + one dot product of the two rather than two sums of squares. The table + is `factors16.rs`, generated by a fitting program outside the + repository; the `machine-test` feature swaps in a seeded rank 8 table so + the tests check the term on a table no fit chose. + - **cache.rs**: The direct mapped cache the remembered terms are kept in, + holding a score under the whole of its key. The searcher has two: one + under the shelter's key holding the shelter and the pawn structure + added, and one under the pawn key holding the pawn structure alone. - **mobility.rs**: How many squares each side's pieces cover. Read at every leaf and not remembered, since a piece that moves changes what every slider looking through its square sees. - **shelter.rs**: What stands between each king and the board, its own pawns and the enemy pawns coming for it. Computed at the leaf and then - remembered under the pawns and the two king squares it is a function of, - in a small table the searcher owns, because a king move rewrites a whole - side's reading and there is nothing there to keep in step. + remembered, with the pawn structure added, under the pawns and the two + king squares it is a function of, in a small table the searcher owns, + because a king move rewrites a whole side's reading and there is nothing + there to keep in step. - **pawn_structure.rs**: Each side's passed pawns by rank, its isolated - pawns and its doubled ones. Remembered the same way in a table of its - own, under the pawn key alone: it reads neither king, so what misses is a - pawn move and the capture of a pawn, where the shelter's key misses on a - king move as well. + pawns and its doubled ones. Remembered in the shelter's entry, and in a + table of its own under the pawn key alone, which is asked only when the + shelter's entry misses: it reads neither king, so a king move that + misses the shelter's entry still finds it there. - **king_attack.rs**: How many squares of the enemy king's ring each side's knights, bishops, rooks and queens attack, off the same attack sets mobility walks but with nothing taken out of them. Read at every leaf and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dc446c81..c2ce60ae 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -516,20 +516,12 @@ of these again without saying what is different this time. the whole saving is bounded at 0.385% of the run and writing the sets costs 0.781%, because the stand pat cuts three quarters of quiescence evaluations before they generate anything at all. -- Storing the static evaluation in the table entry's two reserved bytes, so a node whose - probe hits reads it rather than scoring the position again. Not built: counted over the - bench before anything was written. 2,116,844 evaluations, 1,892,140 of them quiescence's - stand pat, which probes the table after the stand pat because three quarters of those - nodes cut off on it and never probe, and a probe moved in front of it would pay a table - line at 1.4 million nodes that mostly store nothing. Of the 224,704 evaluations at the - full width shortcuts, 32,807 stood at a node whose probe had hit, so the stored value - could spare at most 1.5% of the evaluations, about 0.3% of the run, before the layout - change every pinned count is counted against. The same count found the late move gate - scoring 884 nodes the shortcuts had already scored, through the uncached `eval::eval`; - `shortcuts` now hands back the evaluation it read and the move loop seeds the decision's - memo with it. On the bench at `54d85b9` the gate then asked for an evaluation at - 2,515,532 decisions and used the uncached door 671 times, against 23,717,724 - evaluations over the run; the tree has moved since and these have not been taken again. +- The late move gate scoring nodes the shortcuts had already scored. A count over the bench + found it scoring 884 such nodes through the uncached `eval::eval`; `shortcuts` now hands + back the evaluation it read and the move loop seeds the decision's memo with it. On the + bench at `54d85b9` the gate then asked for an evaluation at 2,515,532 decisions and used + the uncached door 671 times, against 23,717,724 evaluations over the run; the tree has + moved since and these have not been taken again. - Lazy mobility, leaving the term out at the quiescence stand pat when the rest of the score already clears beta by a margin. Built and played at three margins, 6,000 games at 10+0.1 under sprt [0, 10]: +1 ±8 over 4,000 games at a margin of a hundred, and nothing