Merge pull request #19 from JoshHeaps/feature/customChessEngine

Add killer-move ordering and a search benchmark
This commit is contained in:
Josh Heaps
2026-06-09 19:03:21 -06:00
committed by GitHub
3 changed files with 251 additions and 19 deletions
+115
View File
@@ -0,0 +1,115 @@
<#
.SYNOPSIS
Times the search before vs after killer-move ordering.
.DESCRIPTION
Compiles two standalone bench binaries from the current engine source — one with killer
ordering disabled (the pre-killer baseline, via /DBENCH_DISABLE_KILLERS) and one with it
on — then runs each benchmark position in its own process (cold transposition table) and
reports wall-clock search time for both, plus the speedup. Timing is measured inside the
engine around engine_best_move, so process startup isn't counted. Each position is run
-Reps times and the fastest run is kept, to cut scheduling noise.
.PARAMETER Skill
Search difficulty / max depth. Default 8.
.PARAMETER Positions
Which position indices to run (0=kiwipete, 1=ruy, 2=sicilian). Default: all.
.PARAMETER Reps
Runs per position per variant; the minimum time is reported. Default 3.
.PARAMETER ShowDepths
Also print every per-depth line the engine emits.
.EXAMPLE
.\bench.ps1
.\bench.ps1 -Skill 9 -Reps 5 -Positions 0,2
#>
[CmdletBinding()]
param(
[int]$Skill = 8,
[int[]]$Positions,
[int]$Reps = 3,
[switch]$ShowDepths
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$src = Join-Path $root 'src'
$inc = Join-Path $root 'include'
$build = Join-Path $root 'build'
if (-not (Test-Path $build)) { New-Item -ItemType Directory -Path $build | Out-Null }
# --- enter a VS dev shell so cl is on PATH ---
$devShell = "D:\Program Files\Visual Studio 2026\Common7\Tools\Launch-VsDevShell.ps1"
if (-not (Test-Path $devShell)) { throw "VS dev shell not found at $devShell" }
& $devShell -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null
$engineSources = 'chess_engine.cpp','bitboard.cpp','zobrist.cpp','position.cpp','movegen.cpp','uci.cpp' |
ForEach-Object { Join-Path $src $_ }
$benchMain = Join-Path $root 'test\bench_main.cpp'
function Build-Variant([string]$exe, [string[]]$extraDefs) {
$clArgs = @('/nologo','/std:c++17','/O2','/EHsc','/arch:AVX2','/DCHESS_ENGINE_BUILD','/DNDEBUG') +
$extraDefs + @("/I$inc","/I$src", $benchMain) + $engineSources + @("/Fe:$exe", "/Fo:$build\")
& cl @clArgs | Out-Null
if (-not (Test-Path $exe)) { throw "compile failed: $exe" }
}
$exeBefore = Join-Path $build 'bench_before.exe' # killers disabled (pre-killer baseline)
$exeAfter = Join-Path $build 'bench_after.exe' # killers enabled (current)
Write-Host "Compiling both variants..." -ForegroundColor Cyan
Build-Variant $exeBefore @('/DBENCH_DISABLE_KILLERS')
Build-Variant $exeAfter @()
$nodePattern = '^depth (\d+) nodes (\d+) best (\S+) score (-?\d+)$'
$timePattern = 'time_ms=(\d+)'
function Run-One([string]$exe, [int]$pos) {
$nodes = 0; $best = '?'; $bestMs = [long]::MaxValue
for ($r = 0; $r -lt $Reps; $r++) {
$err = [System.IO.Path]::GetTempFileName()
$out = [System.IO.Path]::GetTempFileName()
Start-Process -FilePath $exe -ArgumentList $pos,$Skill -NoNewWindow -Wait `
-RedirectStandardError $err -RedirectStandardOutput $out | Out-Null
$lines = Get-Content $err
Remove-Item $err,$out -Force -ErrorAction SilentlyContinue
if ($ShowDepths) { $lines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } }
$deepest = $lines | Select-String $nodePattern | Select-Object -Last 1
if ($deepest) { $nodes = [long]$deepest.Matches.Groups[2].Value; $best = $deepest.Matches.Groups[3].Value }
$tm = $lines | Select-String $timePattern | Select-Object -Last 1
if ($tm) { $ms = [long]$tm.Matches.Groups[1].Value; if ($ms -lt $bestMs) { $bestMs = $ms } }
}
return [pscustomobject]@{ Nodes = $nodes; Best = $best; Ms = $bestMs }
}
if (-not $Positions) { $Positions = 0,1,2 }
$names = @('kiwipete','ruy','sicilian')
$rows = @()
foreach ($i in $Positions) {
$b = Run-One $exeBefore $i
$a = Run-One $exeAfter $i
$speedup = if ($a.Ms -gt 0) { [math]::Round($b.Ms / $a.Ms, 2) } else { 0 }
$rows += [pscustomobject]@{
Position = $names[$i]
'ms (before)' = $b.Ms
'ms (after)' = $a.Ms
Speedup = "${speedup}x"
'nodes before' = $b.Nodes
'nodes after' = $a.Nodes
}
}
Write-Host ""
Write-Host "Skill $Skill, best of $Reps runs (before = no killers, after = killers)" -ForegroundColor Cyan
$rows | Format-Table -AutoSize
$tb = ($rows | Measure-Object -Property 'ms (before)' -Sum).Sum
$ta = ($rows | Measure-Object -Property 'ms (after)' -Sum).Sum
$tot = if ($ta -gt 0) { [math]::Round($tb / $ta, 2) } else { 0 }
Write-Host ("TOTAL {0} ms -> {1} ms ({2}x faster)" -f $tb, $ta, $tot)
+72 -19
View File
@@ -199,7 +199,7 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const
int score = 100;
if (isPassed && !isBlocked)
score += squaresToPromotion * 10; // Bonus for passed pawns, more as they get closer to promotion
score += (6 - squaresToPromotion) * 100; // Bonus for passed pawns, more as they get closer to promotion
if (isDoubled)
score -= 20; // Penalty for doubled pawns
if (isBlocked)
@@ -208,6 +208,16 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const
return score;
}
static int castleIncentive(const chess::Position& pos, chess::Color c) {
if (!pos.pieces(chess::QUEEN))
return 0;
chess::Square k = pos.king_square(c);
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
: (k == chess::G8 || k == chess::C8);
return castled ? 600 : 0;
}
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
int score = 0;
switch (chess::type_of(pc)) {
@@ -216,11 +226,14 @@ static int evaluatePiece(const chess::Position& pos, const chess::Square& s, con
case chess::BISHOP: score = 330; break;
case chess::ROOK: score = 500; break;
case chess::QUEEN: score = 900; break;
case chess::KING: score = castleIncentive(pos, c); break;
default: return 0;
}
score += center_multiplier(s);
score += piece_mobility(pos, s, pc, c) * 10;
if (pc != chess::B_PAWN && pc != chess::W_PAWN)
score += piece_mobility(pos, s, pc, c) * 25;
return score;
}
@@ -272,13 +285,15 @@ static int piece_value(chess::PieceType pt) {
}
}
/* Heuristic for searching the most promising moves first, which makes alpha-beta
* prune far more. The TT's best move (if any) goes first, then checks, then captures
* by MVV-LVA (grab the most valuable victim with the least valuable attacker).
* `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */
static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove, bool scoreChecks) {
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
* cut a sibling), then the remaining quiet moves. `killers` points at this ply's two-entry
* slot; `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */
static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove,
const chess::Move* killers, bool scoreChecks) {
if (m == ttMove)
return 2000000; /* dwarfs any capture/check score below */
return 2000000; /* dwarfs any capture/killer/check score below */
int score = 0;
@@ -286,11 +301,26 @@ static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove,
score += 1000;
chess::Piece victim = pos.piece_on(m.to());
#ifdef BENCH_DISABLE_KILLERS
/* Benchmark A/B only (defined by bench.ps1): the pre-killer ordering — captures by
* MVV-LVA above quiet moves, no killer band — so the script can time the killer speedup. */
(void)killers;
if (victim != chess::NO_PIECE)
score += 100 + 10 * piece_value(chess::type_of(victim))
- piece_value(chess::type_of(pos.piece_on(m.from())));
else if (m.type() == chess::EN_PASSANT)
score += 100 + 10 * piece_value(chess::PAWN);
#else
if (victim != chess::NO_PIECE)
score += 100000 + 10 * piece_value(chess::type_of(victim))
- piece_value(chess::type_of(pos.piece_on(m.from())));
else if (m.type() == chess::EN_PASSANT)
score += 100000 + 10 * piece_value(chess::PAWN);
else if (m == killers[0])
score += 90000; /* quiet move that beta-cut a sibling at this ply */
else if (m == killers[1])
score += 80000;
#endif
return score;
}
@@ -298,12 +328,13 @@ static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove,
/* Sort the move list in place, best-scoring first. Scores are computed once up
* front so gives_check isn't re-evaluated on every comparison. ttMove may be
* MOVE_NONE, in which case no move matches it and ordering falls back to captures. */
static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove, bool scoreChecks) {
static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove,
const chess::Move* killers, bool scoreChecks) {
struct ScoredMove { int score; chess::Move move; };
ScoredMove scored[256];
for (int i = 0; i < moves.size(); i++)
scored[i] = { order_score(pos, moves.moves[i], ttMove, scoreChecks), moves.moves[i] };
scored[i] = { order_score(pos, moves.moves[i], ttMove, killers, scoreChecks), moves.moves[i] };
std::sort(scored, scored + moves.size(),
[](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; });
@@ -330,13 +361,25 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
return CHESS_OK; /* TODO: store options */
}
/* Per-search scratch, threaded through the recursion. Kept off global scope so two engine
* handles can search concurrently without sharing node counts or killer tables. killers[ply]
* holds up to two quiet moves that recently caused a beta cutoff at that ply; trying them
* early (right after captures) prunes far more — the quiet-move ordering the search otherwise
* lacks. */
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
struct SearchContext {
uint64_t nodes = 0;
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
};
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
* bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is
* distance from the root (mate scoring only). Scores are side-to-move-relative.
* Fail-soft: returns the true best found even outside [alpha, beta]. */
static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
int alpha, int beta, bool whiteToMove, uint64_t& nodes) {
nodes++;
int alpha, int beta, bool whiteToMove, SearchContext& ctx) {
ctx.nodes++;
/* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition
* history, so this must come before both the leaf eval and any TT probe. */
@@ -375,7 +418,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
if (moves.size() == 0)
return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */
order_moves(pos, moves, ttMove, depth <= 2);
order_moves(pos, moves, ttMove, ctx.killers[ply], depth <= 2);
const int alphaOrig = alpha;
int best = -INF;
@@ -384,7 +427,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
for (int i = 0; i < moves.size(); i++) {
chess::Move move = moves.moves[i];
pos.do_move(move);
int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, nodes);
int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, ctx);
pos.undo_move(move);
if (score > best) {
@@ -393,9 +436,19 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
}
if (best > alpha)
alpha = best;
if (best >= beta)
if (best >= beta) {
/* A quiet move good enough to fail high here is a strong candidate in sibling
* lines at this ply — remember it as a killer. pos is back to pre-move state
* after undo_move, so piece_on(to) still flags a capture correctly. */
bool isCapture = pos.piece_on(move.to()) != chess::NO_PIECE
|| move.type() == chess::EN_PASSANT;
if (!isCapture && ply < MAX_PLY && ctx.killers[ply][0] != move) {
ctx.killers[ply][1] = ctx.killers[ply][0];
ctx.killers[ply][0] = move;
}
break; /* fail-high cutoff */
}
}
Bound flag = best <= alphaOrig ? Bound::UPPER
: best >= beta ? Bound::LOWER
@@ -448,7 +501,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
if (moves.size() == 0)
return CHESS_ERR_NO_MOVE;
uint64_t nodes = 0;
SearchContext ctx;
int maxDepth = depth_for_skill(engine->skill);
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
@@ -460,12 +513,12 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
chess::Move iterBest = bestMove;
int iterScore = -INF;
order_moves(pos, moves, iterBest, true);
order_moves(pos, moves, iterBest, ctx.killers[0], true);
for (int i = 0; i < moves.size(); i++) {
chess::Move move = moves.moves[i];
pos.do_move(move);
int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, nodes);
int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, ctx);
pos.undo_move(move);
if (score > iterScore) {
@@ -479,7 +532,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
bestMove = iterBest; /* commit only a fully completed iteration */
std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n",
d, static_cast<unsigned long long>(nodes),
d, static_cast<unsigned long long>(ctx.nodes),
chess::move_to_uci(iterBest).c_str(), iterScore);
}
+64
View File
@@ -0,0 +1,64 @@
/* Search benchmark harness. Drives engine_best_move on a fixed set of tactical positions
* and lets the engine print its per-depth node counts (to stderr). Run ONE position per
* process so each search starts with a cold transposition table — the shared TT is global
* and would otherwise carry over between positions and skew the counts. bench.ps1 loops the
* indices for you and tabulates the deepest line per position.
*
* bench [index] [skill]
* index : position to run (0-based). Omit to run them all in this one process.
* skill : search difficulty / max depth (default 8). */
#include "chess_engine.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
namespace {
struct Position { const char* name; const char* fen; };
const Position kPositions[] = {
{ "kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" },
{ "ruy", "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 1" },
{ "sicilian", "2rq1rk1/pp1bppbp/2np1np1/8/3NP3/2N1BP2/PPPQ2PP/2KR1B1R w - - 0 1" },
};
const int kPositionCount = static_cast<int>(sizeof(kPositions) / sizeof(kPositions[0]));
void run(int index, const char* options) {
EngineHandle engine = engine_create(options);
if (!engine) {
std::fprintf(stderr, "engine_create failed\n");
return;
}
char move[16];
std::fprintf(stderr, "### %d %s\n", index, kPositions[index].name);
auto start = std::chrono::steady_clock::now();
int rc = engine_best_move(engine, kPositions[index].fen, "", move, sizeof(move));
auto elapsed = std::chrono::steady_clock::now() - start;
long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
std::fprintf(stderr, "rc=%d best=%s time_ms=%lld\n", rc, move, ms);
engine_destroy(engine);
}
} // namespace
int main(int argc, char** argv) {
int index = argc > 1 ? std::atoi(argv[1]) : -1;
int skill = argc > 2 ? std::atoi(argv[2]) : 8;
char options[32];
std::snprintf(options, sizeof(options), "skill=%d", skill);
if (index >= 0 && index < kPositionCount) {
run(index, options);
return 0;
}
for (int i = 0; i < kPositionCount; i++)
run(i, options);
return 0;
}