From 7292356948fa3ad37bbfc60cf70f79c44e17a615 Mon Sep 17 00:00:00 2001 From: nocturn9x Date: Mon, 6 May 2024 23:48:40 +0200 Subject: [PATCH] Fix crashing, implement LMR and tempo bonus --- Chess/nim.cfg | 2 +- Chess/nimfish/nimfishpkg/board.nim | 4 + Chess/nimfish/nimfishpkg/eval.nim | 3 + Chess/nimfish/nimfishpkg/moves.nim | 3 +- Chess/nimfish/nimfishpkg/search.nim | 160 +++++++++++++++++----------- Chess/nimfish/nimfishpkg/tui.nim | 1 + Chess/nimfish/nimfishpkg/uci.nim | 20 ++-- Chess/tests/suite.py | 3 +- 8 files changed, 123 insertions(+), 73 deletions(-) diff --git a/Chess/nim.cfg b/Chess/nim.cfg index deabbbd..59fc441 100644 --- a/Chess/nim.cfg +++ b/Chess/nim.cfg @@ -4,4 +4,4 @@ --passL:"-flto -lmimalloc" --passC:"-flto -march=native -mtune=native" -d:useMalloc ---mm:atomicArc \ No newline at end of file +--mm:atomicArc diff --git a/Chess/nimfish/nimfishpkg/board.nim b/Chess/nimfish/nimfishpkg/board.nim index f4e7348..144c24d 100644 --- a/Chess/nimfish/nimfishpkg/board.nim +++ b/Chess/nimfish/nimfishpkg/board.nim @@ -98,6 +98,10 @@ func drawnByRepetition*(self: Chessboard): bool = inc(count) if count == 2: return true + if self.positions[i].halfMoveClock == 0: + # Position was reached via a pawn move or + # capture: cannot repeat beyond this point! + return false dec(i) diff --git a/Chess/nimfish/nimfishpkg/eval.nim b/Chess/nimfish/nimfishpkg/eval.nim index d79f41c..66cf88c 100644 --- a/Chess/nimfish/nimfishpkg/eval.nim +++ b/Chess/nimfish/nimfishpkg/eval.nim @@ -21,6 +21,7 @@ type # Stolen from https://www.chessprogramming.org/PeSTO's_Evaluation_Function const + TEMPO_BONUS = Score(10) PAWN_MIDDLEGAME_SCORES: array[Square(0)..Square(63), Score] = [ 0, 0, 0, 0, 0, 0, 0, 0, 98, 134, 61, 95, 68, 126, 34, -11, @@ -311,3 +312,5 @@ proc evaluate*(position: Position): Score = result = position.evaluateMaterial() when defined(evalPawns): result += position.evaluatePawnStructure() + # Tempo bonus: gains 19.5 +/- 13.7 + result += TEMPO_BONUS diff --git a/Chess/nimfish/nimfishpkg/moves.nim b/Chess/nimfish/nimfishpkg/moves.nim index c2511d3..7bfab51 100644 --- a/Chess/nimfish/nimfishpkg/moves.nim +++ b/Chess/nimfish/nimfishpkg/moves.nim @@ -40,7 +40,7 @@ type targetSquare*: Square flags*: uint16 - MoveList* = ref object + MoveList* = object ## A list of moves data*: array[218, Move] len: int8 @@ -203,6 +203,5 @@ func toAlgebraic*(self: Move): string = proc newMoveList*: MoveList = - new(result) for i in 0..result.data.high(): result.data[i] = nullMove() \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/search.nim b/Chess/nimfish/nimfishpkg/search.nim index db574ef..e7d0ecd 100644 --- a/Chess/nimfish/nimfishpkg/search.nim +++ b/Chess/nimfish/nimfishpkg/search.nim @@ -19,6 +19,7 @@ import eval import transpositions +import std/math import std/times import std/atomics import std/algorithm @@ -26,38 +27,54 @@ import std/monotimes import std/strformat -const NUM_KILLERS = 2 +const NUM_KILLERS* = 2 +const MAX_DEPTH* = 255 + + +func computeLMRTable: array[MAX_DEPTH, array[218, int]] {.compileTime.} = + ## Precomputes the table containing reduction offsets at compile + ## time + for i in 1..result.high(): + for j in 1..result[0].high(): + result[i][j] = round(0.8 + ln(i.float) * ln(j.float) * 0.4).int + + +const LMR_TABLE {.used.} = computeLMRTable() type HistoryTable* = array[PieceColor.White..PieceColor.Black, array[Square(0)..Square(63), array[Square(0)..Square(63), Score]]] - KillersTable* = seq[array[NUM_KILLERS, Move]] + KillersTable* = array[MAX_DEPTH, array[NUM_KILLERS, Move]] SearchManager* = object ## A simple state storage ## for our search searchFlag: ptr Atomic[bool] stopFlag: ptr Atomic[bool] board: Chessboard - bestMoveRoot: Move bestRootScore: Score searchStart: MonoTime hardLimit: MonoTime softLimit: MonoTime nodeCount: uint64 maxNodes: uint64 - currentMove: Move - currentMoveNumber: int searchMoves: seq[Move] - previousBestMove: Move transpositionTable: ptr TTable history: ptr HistoryTable killers: ptr KillersTable + # We keep one extra entry so we don't need any special casing + # inside the search function when constructing pv lines + pvMoves: array[MAX_DEPTH + 1, array[MAX_DEPTH + 1, Move]] + selectiveDepth: int proc newSearchManager*(board: Chessboard, transpositions: ptr TTable, stopFlag, searchFlag: ptr Atomic[bool], history: ptr HistoryTable, killers: ptr KillersTable): SearchManager = - result = SearchManager(board: board, bestMoveRoot: nullMove(), transpositionTable: transpositions, stopFlag: stopFlag, + result = SearchManager(board: board, transpositionTable: transpositions, stopFlag: stopFlag, searchFlag: searchFlag, history: history, killers: killers) + for i in 0..MAX_DEPTH: + for j in 0..MAX_DEPTH: + result.pvMoves[i][j] = nullMove() + proc isSearching*(self: SearchManager): bool = @@ -91,14 +108,11 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): Score = sideToMove = self.board.position.sideToMove nonSideToMove = sideToMove.opposite() - if self.previousBestMove != nullMove() and move == self.previousBestMove: - return highestEval() + 1 - let query = self.transpositionTable[].get(self.board.position.zobristKey) if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move: - return highestEval() - 1 + return highestEval() + 1 - if self.isKillerMove(move, ply): + if ply > 0 and self.isKillerMove(move, ply): result += self.board.position.getPieceScore(move.startSquare) * 5 if not move.isCapture(): @@ -156,10 +170,14 @@ proc log(self: SearchManager, depth: int) = let elapsedMsec = self.elapsedTime().uint64 nps = 1000 * (self.nodeCount div max(elapsedMsec, 1)) - var logMsg = &"info depth {depth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}" - logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()}" - if self.bestMoveRoot != nullMove(): - logMsg &= &" score cp {self.bestRootScore} pv {self.bestMoveRoot.toAlgebraic()}" + var logMsg = &"info depth {depth} seldepth {self.selectiveDepth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}" + logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()} score cp {self.bestRootScore}" + if self.pvMoves[0][0] != nullMove(): + logMsg &= " pv " + for move in self.pvMoves[0]: + if move == nullMove(): + break + logMsg &= &"{move.toAlgebraic()} " echo logMsg @@ -186,6 +204,23 @@ proc getSearchExtension(self: SearchManager, move: Move): int {.used.} = return 1 +proc getReduction(self: SearchManager, move: Move, depth, ply, moveNumber: int, isPV: bool): int = + ## Returns the amount a search depth can be reduced to + + # Move reduction gains: 54.1 +/- 25.5 + if moveNumber > 3 and depth > 2: + result = LMR_TABLE[depth][moveNumber] + if not isPV: + # Reduce non PV nodes more + inc(result) + + if self.board.inCheck(): + # Reduce less when opponent is in check + dec(result) + # Keep the reduction in the right range + result = result.clamp(0, depth - 1) + + proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score = ## Negamax search with a/b pruning that is restricted to ## capture moves (commonly called quiescent search). The @@ -202,7 +237,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score = ## exist if self.shouldStop(): return - if ply == 127: + if ply == MAX_DEPTH: return Score(0) let score = self.board.position.evaluate() if score >= beta: @@ -234,7 +269,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score = return bestScore -proc storeKillerMove(self: SearchManager, ply: int, move: Move) = +proc storeKillerMove(self: SearchManager, ply: int, move: Move) {.used.} = ## Stores a killer move into our killers table at the given ## ply @@ -249,25 +284,21 @@ proc storeKillerMove(self: SearchManager, ply: int, move: Move) = # Shift moves one spot down self.killers[][ply][j + 1] = self.killers[][ply][j]; dec(j) - self.killers[][ply][0] = move; + self.killers[][ply][0] = move -proc shouldReduce(self: SearchManager, move: Move, depth, moveNumber: int): bool = - ## Returns whether the search should be reduced at the given - ## depth and move number - return defined(searchLMR) and moveNumber >= 5 and depth > 3 and not move.isCapture() - - -proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} = +proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV: bool): Score {.discardable.} = ## Negamax search with various optimizations and search features + + # Clear the PV table + for i in 0..MAX_DEPTH: + self.pvMoves[ply][i] = nullMove() if depth > 1 and self.shouldStop(): # We do not let ourselves get cancelled at depth # one because then we wouldn't have a move to return. # In practice this should not be a problem return - when defined(killers): - if self.killers[].high() < ply: - self.killers[].add([nullMove(), nullMove()]) + self.selectiveDepth = max(self.selectiveDepth, ply) if ply > 0: let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8) if query.success: @@ -285,7 +316,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score if depth == 0: # Quiescent search gain: 264.8 +/- 71.6 return self.qsearch(0, alpha, beta) - var + var moves = newMoveList() depth = depth bestMove = nullMove() @@ -305,14 +336,14 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score var alpha = alpha let sideToMove = self.board.position.sideToMove - nonSideToMove = sideToMove.opposite() + nonSideToMove {.used.} = sideToMove.opposite() for i, move in moves: if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves: continue self.board.doMove(move) - self.currentMove = move - self.currentMoveNumber = i - let extension = self.getSearchExtension(move) + let + extension = self.getSearchExtension(move) + reduction = self.getReduction(move, depth, ply, i, isPV) inc(self.nodeCount) # Find the best move for us (worst move # for our opponent, hence the negative sign) @@ -321,30 +352,30 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score if i == 0: # Due to our move ordering scheme, the first move is always the "best", so # search it always at full depth with the full search window - score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha) - elif extension == 0 and self.shouldReduce(move, depth, i): + score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha, isPV) + elif reduction > 0: # Late Move Reductions: assume our move orderer did a good job, # so it is not worth it to look at all moves at the same depth equally. # If this move turns out to be better than we expected, we'll re-search # it at full depth - const reduction = 1 + # We first do a null-window search to see if there's a move that beats alpha # (we don't care about the actual value, so we search in the range [alpha, alpha + 1] # to increase the number of cutoffs) - score = -self.search(depth - 1 - reduction, ply + 1, -alpha - 1, -alpha) + score = -self.search(depth - 1 - reduction, ply + 1, -alpha - 1, -alpha, isPV=false) # If the null window search beats alpha, we do a full window reduced search to get a # better feel for the actual score of the position. If the score turns out to beat alpha # (but not beta) again, we'll re-search this at full depth later if score > alpha: - score = -self.search(depth - 1 - reduction, ply + 1, -beta, -alpha) + score = -self.search(depth - 1 - reduction, ply + 1, -beta, -alpha, isPV=false) else: # Move wasn't reduced, just do a null window search - score = -self.search(depth - 1 + extension, ply + 1, -alpha - 1, -alpha) + score = -self.search(depth - 1 + extension, ply + 1, -alpha - 1, -alpha, isPV=false) if i > 0 and score > alpha and score < beta: - # The move failed high (and not low, which would mean it was too good for us and + # The position failed high (and not low, which would mean it was too good for us and # our opponent wouldn't let us play it) in the null window search, search it # again with the full depth and full window - score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha) + score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha, isPV) self.board.unmakeMove() # When a search is cancelled or times out, we need # to make sure the entire call stack unwinds back @@ -356,7 +387,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score if not move.isCapture() and self.history[][sideToMove][move.startSquare][move.targetSquare] < highestEval(): # History euristic: keep track of moves that caused a beta cutoff and order # them early in subsequent searches, as they might be really good later. A - # quadratic bonus wrt. depth is usually the bonus that is used (though some + # quadratic bonus wrt. depth is usually the value that is used (though some # engines, namely Stockfish, use a linear bonus. Maybe we can investigate this) self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(depth * depth) # Killer move heuristic: store moves that caused a beta cutoff according to the distance from @@ -369,8 +400,17 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score alpha = score bestMove = move if ply == 0: - self.bestMoveRoot = move - self.bestRootScore = bestScore + self.bestRootScore = score + if isPV: + # This loop is why pvMoves has one extra move. + # We can just do ply + 1 and i + 1 without ever + # fearing about buffer overflows + for i, pv in self.pvMoves[ply + 1]: + if pv == nullMove(): + self.pvMoves[ply][i + 1] = nullMove() + break + self.pvMoves[ply][i + 1] = pv + self.pvMoves[ply][0] = move # TODO # else: # when defined(noScaleHistory): @@ -397,15 +437,14 @@ proc findBestMove*(self: var SearchManager, timeRemaining, increment: int64, max ## nodes. If searchMoves is provided and is not empty, search will ## be restricted to the moves in the list. Note that regardless of ## any time limitations or explicit cancellations, the search will - ## not stop until it has at least cleared depth one + ## not stop until it has at least cleared depth one. Search depth + ## is always constrained to at most MAX_DEPTH ply from the root # Apparently negative remaining time is a thing. Welp let maxSearchTime = max(1, (timeRemaining div 10) + (increment div 2)) softLimit = maxSearchTime div 3 - echo maxSearchTime - self.bestMoveRoot = nullMove() - result = self.bestMoveRoot + result = nullMove() self.maxNodes = maxNodes self.searchMoves = searchMoves self.searchStart = getMonoTime() @@ -416,23 +455,18 @@ proc findBestMove*(self: var SearchManager, timeRemaining, increment: int64, max maxDepth = 30 self.searchFlag[].store(true) # Iterative deepening loop - for i in 1..maxDepth: - # Search the previous best move first - self.previousBestMove = self.bestMoveRoot - self.search(i, 0, lowestEval(), highestEval()) - # Since we always search the best move from the - # previous iteration, we can use partial search - # results: the engine will either not have changed - # its mind, or it will have found an even better move - # in the meantime, which we should obviously use! - result = self.bestMoveRoot + for i in 1..min(MAX_DEPTH, maxDepth): + self.search(i, 0, lowestEval(), highestEval(), true) + if self.pvMoves[0][0] != nullMove(): + result = self.pvMoves[0][0] + if self.shouldStop(): + self.log(i - 1) + break + self.log(i) # Soft time management: don't start a new search iteration # if the soft limit has expired, as it is unlikely to complete # anyway - if self.shouldStop() or getMonoTime() >= self.softLimit: - self.log(i - 1) + if getMonoTime() >= self.softLimit: break - else: - self.log(i) self.searchFlag[].store(false) self.stopFlag[].store(false) diff --git a/Chess/nimfish/nimfishpkg/tui.nim b/Chess/nimfish/nimfishpkg/tui.nim index 3a436bd..011910d 100644 --- a/Chess/nimfish/nimfishpkg/tui.nim +++ b/Chess/nimfish/nimfishpkg/tui.nim @@ -388,6 +388,7 @@ proc commandLoop*: int = of "skip": board.position.sideToMove = board.position.sideToMove.opposite() board.position.updateChecksAndPins() + board.position.hash() of "go": handleGoCommand(board, cmd) of "position", "pos": diff --git a/Chess/nimfish/nimfishpkg/uci.nim b/Chess/nimfish/nimfishpkg/uci.nim index f0587d6..7a6736c 100644 --- a/Chess/nimfish/nimfishpkg/uci.nim +++ b/Chess/nimfish/nimfishpkg/uci.nim @@ -351,6 +351,10 @@ proc startUCISession* = session.transpositionTable[] = newTranspositionTable(session.hashTableSize * 1024 * 1024) session.historyTable = cast[ptr HistoryTable](alloc0(sizeof(HistoryTable))) session.killerMoves = cast[ptr KillersTable](alloc0(sizeof(KillersTable))) + # Initialize killer move array + for i in 0.. int: stop = timeit.default_timer() pool.shutdown(cancel_futures=True) print(f"\r[S] Interrupted\033[K") - print(f"[S] Ran {i} tests at depth {args.ply} in {stop - start:.2f} seconds ({len(successful)} successful, {len(failed)} failed)") + total = len(successful) + len(failed) + print(f"[S] Ran {total} tests at depth {args.ply} in {stop - start:.2f} seconds ({len(successful)} successful, {len(failed)} failed)") if failed and args.show_failures: print("[S] The following FENs failed to pass the test:\n\t", end="") print("\n\t".join(failed))