diff --git a/native/chess_engine/bench.ps1 b/native/chess_engine/bench.ps1 new file mode 100644 index 0000000..074b50a --- /dev/null +++ b/native/chess_engine/bench.ps1 @@ -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) diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index 6a113f9..740531e 100644 --- a/native/chess_engine/src/chess_engine.cpp +++ b/native/chess_engine/src/chess_engine.cpp @@ -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,8 +436,18 @@ 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 @@ -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(nodes), + d, static_cast(ctx.nodes), chess::move_to_uci(iterBest).c_str(), iterScore); } diff --git a/native/chess_engine/test/bench_main.cpp b/native/chess_engine/test/bench_main.cpp new file mode 100644 index 0000000..58d66b6 --- /dev/null +++ b/native/chess_engine/test/bench_main.cpp @@ -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 +#include +#include + +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(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(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; +}