From ba49e0a775267a536ea97bf241cf9b45d1af4fd4 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Mon, 16 Feb 2026 00:57:48 -0800 Subject: [PATCH] Add killer move heuristic for move ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track 2 non-capture moves per ply that caused beta cutoffs (killer moves). These are tried after captures but before other quiet moves, improving move ordering and increasing cutoff rates. Benchmark (depth 4, 48 positions): - Nodes: 4,760,507 → 2,880,079 (−39.5%) - NPS: 22,634 → 24,985 (+10.4%) - Time: 210.32s → 115.27s (−45.2%) --- moonfish/engines/alpha_beta.py | 45 +++++++++++++++++++++++++++++----- moonfish/move_ordering.py | 30 ++++++++++++++++++++--- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index bc97538..2fd9b56 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -15,6 +15,9 @@ NEG_INF = float("-inf") NULL_MOVE = Move.null() +# Number of killer moves to store per ply +NUM_KILLERS = 2 + class AlphaBeta: """ @@ -168,6 +171,19 @@ def quiescence_search( return best_score + def _store_killer( + self, killers: list[list[Move | None]], ply: int, move: Move + ) -> None: + """Store a killer move at the given ply, shifting the existing one.""" + if ply >= len(killers): + return + # Don't store duplicates + if killers[ply][0] == move: + return + # Shift: slot 1 gets old slot 0, slot 0 gets new move + killers[ply][1] = killers[ply][0] + killers[ply][0] = move + def negamax( self, board: Board, @@ -176,6 +192,8 @@ def negamax( cache: DictProxy | CACHE_KEY, alpha: float = NEG_INF, beta: float = INF, + ply: int = 0, + killers: list[list[Move | None]] | None = None, ) -> tuple[float | int, Move | None]: """ This functions receives a board, depth and a player; and it returns @@ -198,10 +216,10 @@ def negamax( - null_move: if we want to use null move pruning - cache: a shared hash table to store the best move for each board state and depth. - - alpha: best score for the maximizing player (best choice - (highest value) we've found along the path for max) - - beta: best score for the minimizing player (best choice - (lowest value) we've found along the path for min). + - alpha: best score for the maximizing player + - beta: best score for the minimizing player + - ply: current ply from root (for killer move indexing) + - killers: killer move table [ply][slot] -> Move Returns: - best_score, best_move: returns best move that it found and its value. @@ -250,6 +268,8 @@ def negamax( cache, -beta, -beta + 1, + ply + 1, + killers, )[0] board.pop() if board_score >= beta: @@ -260,7 +280,10 @@ def negamax( # initializing best_score best_score = NEG_INF - moves = organize_moves(board) + + # Get killer moves for this ply + ply_killers = killers[ply] if killers is not None and ply < len(killers) else None + moves = organize_moves(board, killers=ply_killers) for move in moves: # make the move @@ -273,6 +296,8 @@ def negamax( cache, -beta, -alpha, + ply + 1, + killers, )[0] if board_score > self.config.checkmate_threshold: board_score -= 1 @@ -284,6 +309,9 @@ def negamax( # beta-cutoff if board_score >= beta: + # Store killer move for quiet moves that cause cutoff + if killers is not None and not board.is_capture(move): + self._store_killer(killers, ply, move) cache[cache_key] = (board_score, move) return board_score, move @@ -314,8 +342,13 @@ def search_move(self, board: Board) -> Move: # create shared cache cache: CACHE_KEY = {} + # Initialize killer move table: max_depth + some margin for extensions + max_ply = self.config.negamax_depth + 20 + killers: list[list[Move | None]] = [[None, None] for _ in range(max_ply)] + best_move = self.negamax( - board, self.config.negamax_depth, self.config.null_move, cache + board, self.config.negamax_depth, self.config.null_move, cache, + killers=killers, )[1] assert best_move is not None, "Best move from root should not be None" return best_move diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index 630ec81..9963ad6 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -5,31 +5,53 @@ from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase -def organize_moves(board: Board): +def organize_moves( + board: Board, + killers: "list[Move | None] | None" = None, +): """ This function receives a board and it returns a list of all the possible moves for the current player, sorted by importance. - It sends capturing moves at the starting positions in - the array (to try to increase pruning and do so earlier). + Order: captures first, then killer moves, then remaining quiet moves. Arguments: - board: chess board state + - killers: list of killer moves for the current ply (tried after captures) Returns: - legal_moves: list of all the possible moves for the current player. """ non_captures = [] captures = [] + killer_set = set() + + # Build set of killer moves for fast lookup + if killers is not None: + for k in killers: + if k is not None: + killer_set.add(k) for move in board.legal_moves: if board.is_capture(move): captures.append(move) + elif move in killer_set: + # Skip killers from non_captures; we'll insert them after captures + continue else: non_captures.append(move) random.shuffle(captures) random.shuffle(non_captures) - return captures + non_captures + + # Insert legal killer moves between captures and quiet moves + killer_moves = [] + if killers is not None: + legal_moves_set = set(board.legal_moves) + for k in killers: + if k is not None and k in legal_moves_set and not board.is_capture(k): + killer_moves.append(k) + + return captures + killer_moves + non_captures def is_tactical_move(board: Board, move: Move) -> bool: