Skip to content

Commit

Permalink
Merge branch 'feature/stats'
Browse files Browse the repository at this point in the history
bench: 8240023
  • Loading branch information
dannyhammer committed Jan 5, 2025
2 parents 2e13663 + 4a7a30e commit a213783
Showing 1 changed file with 126 additions and 34 deletions.
160 changes: 126 additions & 34 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,41 @@ impl Default for SearchParameters {
}
}

/// Parameters for the various features used to enhance the efficiency of a search.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct SearchStats {
/// Number of moves searched until a beta cutoff occurred during the a/b search.
///
/// This is cumulative, and is increased by `moves_made` on every beta cutoff.
beta_cutoff_indices: usize,

/// Number of beta cutoffs that occurred during the a/b search.
num_beta_cutoffs: usize,

/// Number of moves searched until a beta cutoff occurred during the quiescent search.
///
/// This is cumulative, and is increased by `moves_made` on every beta cutoff.
qs_beta_cutoff_indices: usize,

/// Number of beta cutoffs that occurred during the quiescent search.
qs_num_beta_cutoffs: usize,

/// Number of null moves made in attempts to perform NMP.
nmp_attempts: usize,

/// Number of times NMP succeeded in producing a cutoff.
nmp_cutoffs: usize,

/// Number of nodes searched in qsearch.
qs_nodes: usize,

// Number of nodes searched with a zero-window within the a/b search.
zw_nodes: usize,

// Number of nodes searched with a zero-window within the quiescent search.
zw_qs_nodes: usize,
}

/// Executes a search on a game of chess.
pub struct Search<'a, Log, V> {
/// An atomic flag to determine if the search should be cancelled at any time.
Expand All @@ -518,9 +553,14 @@ pub struct Search<'a, Log, V> {
/// Configuration variables for this instance of the search.
config: SearchConfig,

/// Information collected that is returned at the conclusion of the search.
/// Essential information collected that is returned at the conclusion of the search.
result: SearchResult,

/// Non-essential information collected during the search.
///
/// This information is printed when the `debug` UCI option is set.
stats: SearchStats,

/// Previous positions encountered during search.
prev_positions: Vec<Position>,

Expand Down Expand Up @@ -559,6 +599,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
history,
params,
result: SearchResult::default(),
stats: SearchStats::default(),
variant: PhantomData,
log: PhantomData,
}
Expand Down Expand Up @@ -637,33 +678,6 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
_ => self.iterative_deepening(game),
}

// Debug info about the termination of the search.
if Log::DEBUG {
if let Err(reason) = self.search_cancelled() {
if let Some(bestmove) = self.result.bestmove() {
self.send_string(format!(
"Search cancelled during depth {} while evaluating {} with score {}. Reason: {reason}",
self.result.depth,
V::fmt_move(bestmove),
self.result.score,
));
} else {
self.send_string(format!(
"Search cancelled during depth {} with score {} and no bestmove. Reason: {reason}",
self.result.depth, self.result.score,
));
}
}

let hits = self.ttable.hits;
let reads = self.ttable.reads;
let writes = self.ttable.writes;
let hit_rate = (hits as f32 / reads as f32 * 100.0).min(0.0);
let collisions = self.ttable.collisions;
let info = format!("TT stats: {hits} hits / {reads} reads ({hit_rate:.2}% hit rate), {writes} writes, {collisions} collisions");
self.send_string(info);
}

// Sanity check: If no bestmove, but there is a legal move, update bestmove.
if self.result.bestmove().is_none() {
if let Some(first) = moves.first().copied() {
Expand All @@ -676,6 +690,68 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
if Log::INFO {
self.send_search_info(); // UCI spec states to send one last `info` before `bestmove`.

// Debug info about the termination of the search.
if Log::DEBUG {
if let Err(reason) = self.search_cancelled() {
if let Some(bestmove) = self.result.bestmove() {
self.send_string(format!(
"Search cancelled during depth {} while evaluating {} with score {}. Reason: {reason}",
self.result.depth,
V::fmt_move(bestmove),
self.result.score,
));
} else {
self.send_string(format!(
"Search cancelled during depth {} with score {} and no bestmove. Reason: {reason}",
self.result.depth, self.result.score,
));
}
}

let hits = self.ttable.hits;
let reads = self.ttable.reads;
let writes = self.ttable.writes;
let hit_rate = (hits as f32 / reads as f32 * 100.0).min(0.0);
let collisions = self.ttable.collisions;
let info = format!("TT stats: {hits} hits / {reads} reads ({hit_rate:.2}% hit rate), {writes} writes, {collisions} collisions");
self.send_string(info);

// Send search stats
let ebf = (self.result.nodes as f32).powf(1.0 / self.result.depth.plies() as f32);
self.send_string(format!("branching factor: {ebf:.2}"));

let ab_bci =
self.stats.beta_cutoff_indices as f32 / self.stats.num_beta_cutoffs as f32;
self.send_string(format!("avg beta cutoff index (AB): {ab_bci:.2}"));

let qs_bci = self.stats.qs_beta_cutoff_indices as f32
/ self.stats.qs_num_beta_cutoffs as f32;
self.send_string(format!("avg beta cutoff index (QS): {qs_bci:.2}"));

let nmp_cutoff_rate =
self.stats.nmp_cutoffs as f32 / self.stats.nmp_attempts as f32 * 100.0;
self.send_string(format!("nmp cutoff rate: {nmp_cutoff_rate:.1}%"));

let qs_node_percent = self.stats.qs_nodes as f32 / self.result.nodes as f32 * 100.0;
self.send_string(format!(
"qs nodes: {} ({qs_node_percent:.1}%)",
self.stats.qs_nodes
));

let zw_node_percent = self.stats.zw_nodes as f32 / self.result.nodes as f32 * 100.0;
self.send_string(format!(
"zw nodes (AB): {} ({zw_node_percent:.1}%)",
self.stats.zw_nodes
));

let zw_qs_node_percent =
self.stats.zw_qs_nodes as f32 / self.stats.qs_nodes as f32 * 100.0;
self.send_string(format!(
"zw nodes (QS): {} ({zw_qs_node_percent:.1}%)",
self.stats.zw_qs_nodes
));
}

// TODO: On a `go infinite` search, we should *only* send `bestmove` after `stop` is received, regardless of whether the search has concluded
self.send_response(UciResponse::BestMove {
bestmove: self.result.bestmove().map(V::fmt_move),
Expand Down Expand Up @@ -822,6 +898,9 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
) -> Result<Score, SearchCancelled> {
self.search_cancelled()?; // Exit early if search is terminated.

// Zero window node
self.stats.zw_nodes += !Node::PV as usize;

/****************************************************************************************************
* Quiescence Search: https://www.chessprogramming.org/Quiescence_Search
*
Expand Down Expand Up @@ -932,7 +1011,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
* Primary move loop
****************************************************************************************************/

for (i, mv) in moves.iter().enumerate() {
for (i, mv) in moves.iter().copied().enumerate() {
/****************************************************************************************************
* Move-Loop Pruning techniques
****************************************************************************************************/
Expand All @@ -951,7 +1030,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
}

// Copy-make the new position
let new = game.with_move_made(*mv);
let new = game.with_move_made(mv);
let mut score = Score::DRAW;

// The local PV is different for every node search after this one, so we must reset it in between recursive calls.
Expand Down Expand Up @@ -1041,12 +1120,12 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
// PV found
if score > bounds.alpha {
bounds.alpha = score;
bestmove = Some(*mv);
bestmove = Some(mv);

// Only extend the PV if we're in a PV node
if Node::PV {
// assert_pv_is_legal(game, *mv, &local_pv);
pv.extend(*mv, &local_pv);
pv.extend(mv, &local_pv);
}
}

Expand All @@ -1064,13 +1143,18 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {

// Only update quiet moves
if mv.is_quiet() {
self.history.update(game, mv, bonus);
self.history.update(game, &mv, bonus);
}

// Apply a penalty to all quiets searched so far
for mv in moves[..i].iter().filter(|mv| mv.is_quiet()) {
self.history.update(game, mv, -bonus);
}

// Record beta cutoff stats
self.stats.beta_cutoff_indices += i;
self.stats.num_beta_cutoffs += 1;

break;
}
}
Expand Down Expand Up @@ -1165,7 +1249,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
* Primary move loop
****************************************************************************************************/

for mv in moves {
for (i, mv) in moves.iter().copied().enumerate() {
// The local PV is different for every node search after this one, so we must reset it in between recursive calls.
local_pv.clear();

Expand All @@ -1186,6 +1270,8 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
self.search_cancelled()?; // Exit early if search is terminated.

self.result.nodes += 1; // We've now searched this node
self.stats.qs_nodes += 1;
self.stats.zw_qs_nodes += !Node::PV as usize;

self.prev_positions.pop();
}
Expand All @@ -1211,6 +1297,10 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {

// Fail high
if score >= bounds.beta {
// Record beta cutoff stats
self.stats.qs_beta_cutoff_indices += i;
self.stats.qs_num_beta_cutoffs += 1;

break;
}
}
Expand Down Expand Up @@ -1407,6 +1497,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {
&& non_king_pawn_material.is_nonempty(); // Can't play nullmove if insufficient material (only Kings and Pawns)

if can_perform_nmp {
self.stats.nmp_attempts += 1;
let null_game = game.with_nullmove_made();
// Record this position in our stack, for repetition detection
self.prev_positions.push(*null_game.position());
Expand All @@ -1425,6 +1516,7 @@ impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> {

// If making the nullmove produces a cutoff, we can assume that a full-depth search would also produce a cutoff
if score >= bounds.beta {
self.stats.nmp_cutoffs += 1;
return Ok(Some(score));
}
}
Expand Down

0 comments on commit a213783

Please sign in to comment.