Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 70 additions & 12 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
use std::time::{Duration, Instant};

use crate::chess::core::Move;
use crate::chess::game::{self, Game};
Expand Down Expand Up @@ -47,6 +47,12 @@ struct SearchThread {
infinite: bool,
/// The position the search ran from.
spec: PositionSpec,
/// Whether the search is currently pondering (running on the opponent's
/// clock).
pondering: bool,
/// Time budget to apply when a `ponderhit` switches the search to the
/// engine's own clock.
ponder_budget: Option<Duration>,
}

/// The Engine connects everything together and handles commands sent by UCI
Expand All @@ -64,6 +70,8 @@ pub struct Engine<R: BufRead, W: Write + Send + 'static> {
/// Number of search threads. Only single-threaded search is implemented so
/// far.
threads: usize,
/// Number of principal variations to report (the UCI `MultiPV` option).
multipv: usize,
/// Tree kept from the previous search for reuse.
reusable: Option<Reusable>,
/// UCI commands will be read from this stream.
Expand All @@ -85,6 +93,7 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
debug: false,
hash_size_mb: 16,
threads: 1,
multipv: 1,
reusable: None,
input,
out: Arc::new(Mutex::new(out)),
Expand Down Expand Up @@ -137,6 +146,10 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
}
self.go(&go);
}
Command::PonderHit => self.ponder_hit(),
// The engine does not require registration; acknowledge and
// move on.
Command::Register => {}
Command::Stop => self.stop_search(),
Command::Quit => {
// Let a search with finite limits run to completion so
Expand Down Expand Up @@ -177,7 +190,10 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
self.send(&format!("id author {}", env!("CARGO_PKG_AUTHORS")))?;
self.send("option name Hash type spin default 16 min 1 max 1048576")?;
self.send("option name Threads type spin default 1 min 1 max 1")?;
self.send("option name MultiPV type spin default 1 min 1 max 218")?;
self.send("option name Ponder type check default false")?;
self.send("option name SyzygyTablebase type string default <empty>")?;
self.send("option name Clear Hash type button")?;
self.send("uciok")?;
Ok(())
}
Expand All @@ -202,6 +218,15 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
}
self.threads = 1;
}
(uci::EngineOption::MultiPv, uci::OptionValue::Integer(lines)) => {
self.multipv = lines.clamp(1, 218);
}
// Ponder capability is declared for GUIs; the engine reacts to
// `go ponder`, so the value itself needs no state.
(uci::EngineOption::Ponder, uci::OptionValue::Bool(_)) => {}
(uci::EngineOption::ClearHash, uci::OptionValue::Button) => {
self.reusable = None;
}
(uci::EngineOption::SyzygyTablebase, uci::OptionValue::String(path)) => {
self.set_tablebase(&path)?;
}
Expand Down Expand Up @@ -310,17 +335,31 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
fn go(&mut self, go: &uci::Go) {
self.stop_search();

let limits = self.limits(go);
let mut limits = self.limits(go);
// Pondering runs on the opponent's clock: search without a time limit
// until a `ponderhit` applies the budget or a `stop` ends it.
let ponder_budget = if go.ponder {
limits.move_time.take()
} else {
None
};
if go.ponder {
limits.infinite = true;
}
let infinite = limits.infinite;

let tree = self.reuse_tree();
let node_budget = mcts::node_budget(self.hash_size_mb);
let config = mcts::Config {
node_budget: mcts::node_budget(self.hash_size_mb),
multipv: self.multipv,
};
let stop = Arc::new(AtomicBool::new(false));
let game = self.game.clone();
let out = Arc::clone(&self.out);
let search_stop = Arc::clone(&stop);

let handle = thread::spawn(move || {
let result = mcts::search(&game, tree, node_budget, &limits, &search_stop, |info| {
let result = mcts::search(&game, tree, &config, &limits, &search_stop, |info| {
let mut out = out.lock().expect("output lock poisoned");
let _ = writeln!(out, "{info}");
let _ = out.flush();
Expand All @@ -341,9 +380,30 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
handle,
infinite,
spec: self.spec.clone(),
pondering: go.ponder,
ponder_budget,
});
}

/// Handles `ponderhit`: the pondered move was played, so the search now
/// runs on the engine's own clock. A timer thread stops it once the
/// move-time budget elapses; without a budget it continues until a
/// `stop`.
fn ponder_hit(&mut self) {
if let Some(search) = &mut self.search
&& search.pondering
{
search.pondering = false;
if let Some(budget) = search.ponder_budget.take() {
let stop = Arc::clone(&search.stop);
thread::spawn(move || {
thread::sleep(budget);
stop.store(true, Ordering::Relaxed);
});
}
}
}

/// Returns the tree to seed the next search: the previous search's subtree
/// re-rooted at the current position when the game continued along the same
/// line, otherwise a fresh tree.
Expand Down Expand Up @@ -408,6 +468,7 @@ impl<R: BufRead, W: Write + Send + 'static> Engine<R, W> {
self.send(&format!("info string debug {}", self.debug))?;
self.send(&format!("info string option Hash {}", self.hash_size_mb))?;
self.send(&format!("info string option Threads {}", self.threads))?;
self.send(&format!("info string option MultiPV {}", self.multipv))?;
self.send(&format!(
"info string option SyzygyTablebase {}",
self.game.tablebase().map_or_else(
Expand Down Expand Up @@ -473,14 +534,11 @@ pub fn openbench() {
..mcts::Limits::default()
};
let stop = AtomicBool::new(false);
let result = mcts::search(
&game,
Tree::new(),
mcts::node_budget(16),
&limits,
&stop,
|_| {},
);
let config = mcts::Config {
node_budget: mcts::node_budget(16),
multipv: 1,
};
let result = mcts::search(&game, Tree::new(), &config, &limits, &stop, |_| {});
total_nodes += result.nodes;
println!(
"info string bench bestmove {} for {fen}",
Expand Down
144 changes: 108 additions & 36 deletions src/engine/uci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ pub(super) enum Command {
},
NewGame,
Go(Go),
/// The move the engine was pondering on was played; the ponder search
/// should continue on the engine's own clock.
PonderHit,
/// Copy-protection registration. The engine does not require it, so this is
/// ignored.
Register,
Stop,
Quit,
/// This is an extension to the UCI protocol useful for debugging. The
Expand All @@ -39,19 +45,27 @@ pub(super) struct Go {
pub(super) depth: Option<u32>,
pub(super) nodes: Option<u64>,
pub(super) infinite: bool,
/// The search runs on the opponent's clock until a `ponderhit` or `stop`.
pub(super) ponder: bool,
}

#[derive(Debug, PartialEq)]
pub(super) enum EngineOption {
Hash,
SyzygyTablebase,
Threads,
MultiPv,
Ponder,
ClearHash,
}

#[derive(Debug, PartialEq)]
pub(super) enum OptionValue {
Integer(usize),
String(String),
Bool(bool),
/// A `button`-typed option, which carries no value.
Button,
}

fn parse_go(parts: &[&str]) -> Command {
Expand All @@ -75,9 +89,13 @@ fn parse_go(parts: &[&str]) -> Command {
i += 1;
continue;
}
// Tokens without a value (e.g. "ponder") and unsupported ones are
// skipped one at a time so that they can not swallow a keyword that
// follows them.
"ponder" => {
go.ponder = true;
i += 1;
continue;
}
// Unsupported tokens are skipped one at a time so that they can not
// swallow a keyword that follows them.
_ => {
i += 1;
continue;
Expand All @@ -90,39 +108,47 @@ fn parse_go(parts: &[&str]) -> Command {
}

fn parse_setoption(parts: &[&str]) -> Command {
if parts.len() > 3 && parts[1] == "name" {
let name_end = parts
.iter()
.position(|&x| x == "value")
.unwrap_or(parts.len());
let option = parts[2..name_end].join(" ");
let option = match option.as_str() {
"Hash" => EngineOption::Hash,
"SyzygyTablebase" => EngineOption::SyzygyTablebase,
"Threads" => EngineOption::Threads,
_ => return Command::Unknown(parts.join(" ")),
};
let value = if name_end + 1 < parts.len() {
match option {
EngineOption::Hash | EngineOption::Threads => parts[name_end + 1]
.parse::<usize>()
.ok()
.map(OptionValue::Integer),
EngineOption::SyzygyTablebase => {
Some(OptionValue::String(parts[name_end + 1..].join(" ")))
}
let unknown = || Command::Unknown(parts.join(" "));
if parts.len() < 3 || parts[1] != "name" {
return unknown();
}

// `setoption name <name> [value <value>]`.
let value_pos = parts.iter().position(|&token| token == "value");
let name = parts[2..value_pos.unwrap_or(parts.len())].join(" ");
let value = value_pos.map(|pos| parts[pos + 1..].join(" "));

let option = match name.as_str() {
"Hash" => EngineOption::Hash,
"SyzygyTablebase" => EngineOption::SyzygyTablebase,
"Threads" => EngineOption::Threads,
"MultiPV" => EngineOption::MultiPv,
"Ponder" => EngineOption::Ponder,
"Clear Hash" => EngineOption::ClearHash,
_ => return unknown(),
};

// A `button` option carries no value; everything else requires one.
let value = match option {
EngineOption::ClearHash => OptionValue::Button,
EngineOption::Hash | EngineOption::Threads | EngineOption::MultiPv => {
match value.and_then(|value| value.parse().ok()) {
Some(number) => OptionValue::Integer(number),
None => return unknown(),
}
} else {
None
};
if let Some(value) = value {
Command::SetOption { option, value }
} else {
Command::Unknown(parts.join(" "))
}
} else {
Command::Unknown(parts.join(" "))
}
EngineOption::SyzygyTablebase => match value {
Some(path) => OptionValue::String(path),
None => return unknown(),
},
EngineOption::Ponder => match value.as_deref() {
Some("true") => OptionValue::Bool(true),
Some("false") => OptionValue::Bool(false),
_ => return unknown(),
},
};

Command::SetOption { option, value }
}

fn parse_setposition(parts: &[&str]) -> Command {
Expand Down Expand Up @@ -158,6 +184,8 @@ impl Command {
"position" => parse_setposition(&parts),
"ucinewgame" => Self::NewGame,
"go" => parse_go(&parts),
"ponderhit" => Self::PonderHit,
"register" => Self::Register,
"stop" => Self::Stop,
"quit" => Self::Quit,
"state" => Self::State,
Expand Down Expand Up @@ -220,6 +248,50 @@ mod tests {
);
}

#[test]
fn parse_setoption_extended() {
assert_eq!(
Command::parse("setoption name MultiPV value 4"),
Command::SetOption {
option: EngineOption::MultiPv,
value: OptionValue::Integer(4)
}
);
assert_eq!(
Command::parse("setoption name Ponder value true"),
Command::SetOption {
option: EngineOption::Ponder,
value: OptionValue::Bool(true)
}
);
// "Clear Hash" is a button: it has a multi-word name and no value.
assert_eq!(
Command::parse("setoption name Clear Hash"),
Command::SetOption {
option: EngineOption::ClearHash,
value: OptionValue::Button
}
);
}

#[test]
fn parse_ponderhit_and_register() {
assert_eq!(Command::parse("ponderhit"), Command::PonderHit);
assert_eq!(Command::parse("register later"), Command::Register);
}

#[test]
fn parse_go_ponder() {
assert_eq!(
Command::parse("go ponder wtime 1000"),
Command::Go(Go {
wtime: Some(Duration::from_millis(1000)),
ponder: true,
..Go::default()
})
);
}

#[test]
fn parse_position() {
assert_eq!(
Expand Down Expand Up @@ -266,13 +338,13 @@ mod tests {
})
);

// "infinite" and "ponder" have no value: the keywords that follow them
// should still be picked up.
// A valueless keyword must not swallow the keyword that follows it.
assert_eq!(
Command::parse("go ponder wtime 1000 btime 2000"),
Command::Go(Go {
wtime: Some(Duration::from_millis(1000)),
btime: Some(Duration::from_millis(2000)),
ponder: true,
..Go::default()
})
);
Expand Down
Loading
Loading