From 95780b32365d8013455ec40d492dded8ec773184 Mon Sep 17 00:00:00 2001 From: Mattia Giambirtone Date: Thu, 25 Apr 2024 23:41:25 +0200 Subject: [PATCH] Initial work on transposition table and repetition detection Signed-off-by: Mattia Giambirtone --- Chess/nimfish/nimfishpkg/board.nim | 33 +++++++---- Chess/nimfish/nimfishpkg/eval.nim | 4 ++ Chess/nimfish/nimfishpkg/misc.nim | 8 +++ Chess/nimfish/nimfishpkg/movegen.nim | 9 ++- Chess/nimfish/nimfishpkg/position.nim | 6 +- Chess/nimfish/nimfishpkg/search.nim | 28 +++++----- Chess/nimfish/nimfishpkg/transpositions.nim | 61 ++++++++++++++++----- Chess/nimfish/nimfishpkg/tui.nim | 4 +- Chess/nimfish/nimfishpkg/uci.nim | 57 ++++++++++++++----- Chess/nimfish/nimfishpkg/zobrist.nim | 1 + 10 files changed, 155 insertions(+), 56 deletions(-) diff --git a/Chess/nimfish/nimfishpkg/board.nim b/Chess/nimfish/nimfishpkg/board.nim index 7be0a80..5c7d4e9 100644 --- a/Chess/nimfish/nimfishpkg/board.nim +++ b/Chess/nimfish/nimfishpkg/board.nim @@ -41,8 +41,6 @@ type position*: Position # List of all previously reached positions positions*: seq[Position] - # Zobrist hash of the given position - zobristKey*: ZobristKey # A bunch of simple utility functions and forward declarations @@ -590,25 +588,40 @@ proc toFEN*(self: Chessboard): string = result &= $self.position.fullMoveCount +proc drawByRepetition*(self: Chessboard): bool = + ## Returns whether the current position is a draw + ## by repetition + # Naive version. TODO: Improve + var i = self.positions.high() + var count = 0 + while i > 0: + if count == 2: + self.position.repetitionDraw = true + return true + if self.position.zobristKey == self.positions[i].zobristKey: + inc(count) + dec(i) + + proc hash*(self: Chessboard) = ## Computes the zobrist hash of the current ## position - self.zobristKey = ZobristKey(0) + self.position.zobristKey = ZobristKey(0) if self.position.sideToMove == Black: - self.zobristKey = self.zobristKey xor getBlackToMoveKey() + self.position.zobristKey = self.position.zobristKey xor getBlackToMoveKey() for sq in self.getOccupancy(): - self.zobristKey = self.zobristKey xor self.getPiece(sq).getKey(sq) + self.position.zobristKey = self.position.zobristKey xor self.getPiece(sq).getKey(sq) if self.position.castlingAvailability[White.int].king: - self.zobristKey = self.zobristKey xor getKingSideCastlingKey(White) + self.position.zobristKey = self.position.zobristKey xor getKingSideCastlingKey(White) if self.position.castlingAvailability[White.int].queen: - self.zobristKey = self.zobristKey xor getQueenSideCastlingKey(White) + self.position.zobristKey = self.position.zobristKey xor getQueenSideCastlingKey(White) if self.position.castlingAvailability[Black.int].king: - self.zobristKey = self.zobristKey xor getKingSideCastlingKey(Black) + self.position.zobristKey = self.position.zobristKey xor getKingSideCastlingKey(Black) if self.position.castlingAvailability[Black.int].king: - self.zobristKey = self.zobristKey xor getQueenSideCastlingKey(Black) + self.position.zobristKey = self.position.zobristKey xor getQueenSideCastlingKey(Black) if self.position.enPassantSquare != nullSquare(): - self.zobristKey = self.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) \ No newline at end of file + self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/eval.nim b/Chess/nimfish/nimfishpkg/eval.nim index 0b4357f..75a1751 100644 --- a/Chess/nimfish/nimfishpkg/eval.nim +++ b/Chess/nimfish/nimfishpkg/eval.nim @@ -172,6 +172,10 @@ const ] +func lowestEval*: Score {.inline.} = Score(-20_000) +func highestEval*: Score {.inline.} = Score(20_000) +func mateScore*: Score {.inline.} = lowestEval() + proc getPieceValue(kind: PieceKind): Score = ## Returns the absolute value of a piece diff --git a/Chess/nimfish/nimfishpkg/misc.nim b/Chess/nimfish/nimfishpkg/misc.nim index f902669..8f044b2 100644 --- a/Chess/nimfish/nimfishpkg/misc.nim +++ b/Chess/nimfish/nimfishpkg/misc.nim @@ -18,6 +18,7 @@ import board import std/strformat +import std/strutils proc testPiece(piece: Piece, kind: PieceKind, color: PieceColor) = @@ -36,7 +37,14 @@ proc testPieceBitboard(bitboard: Bitboard, squares: seq[Square]) = doAssert false, &"bitboard.len() ({i}) != squares.len() ({squares.len()})" +const fens = staticRead("../../tests/all.txt").splitLines() + + proc basicTests* = + + for fen in fens: + doAssert fen == newChessboardFromFEN(fen).toFEN() + var b = newDefaultChessboard() # Ensure correct number of pieces testPieceCount(b, Pawn, White, 8) diff --git a/Chess/nimfish/nimfishpkg/movegen.nim b/Chess/nimfish/nimfishpkg/movegen.nim index 22f783e..96707b4 100644 --- a/Chess/nimfish/nimfishpkg/movegen.nim +++ b/Chess/nimfish/nimfishpkg/movegen.nim @@ -277,9 +277,10 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) = if self.position.halfMoveClock >= 100: # Draw by 50-move rule return - let sideToMove = self.position.sideToMove # TODO: Check for draw by insufficient material - # TODO: Check for repetitions (requires zobrist hashing + table) + if self.position.repetitionDraw: + return + let sideToMove = self.position.sideToMove self.generateKingMoves(moves) if self.position.checkers.countSquares() > 1: # King is in double check: no need to generate any more @@ -331,7 +332,7 @@ proc doMove*(self: Chessboard, move: Move) = ## performing legality checks. Can be used in ## performance-critical paths where a move is ## already known to be legal (i.e. during search) - + # Record final position for future reference self.positions.add(self.position) @@ -426,6 +427,7 @@ proc doMove*(self: Chessboard, move: Move) = self.updateChecksAndPins() # Update zobrist key self.hash() + discard self.drawByRepetition() proc isLegal*(self: Chessboard, move: Move): bool {.inline.} = @@ -438,6 +440,7 @@ proc isLegal*(self: Chessboard, move: Move): bool {.inline.} = proc makeMove*(self: Chessboard, move: Move): Move {.discardable.} = ## Makes a move on the board result = move + echo move # Updates checks and pins for the side to move if not self.isLegal(move): return nullMove() diff --git a/Chess/nimfish/nimfishpkg/position.nim b/Chess/nimfish/nimfishpkg/position.nim index 81c1dac..3f1645d 100644 --- a/Chess/nimfish/nimfishpkg/position.nim +++ b/Chess/nimfish/nimfishpkg/position.nim @@ -14,7 +14,7 @@ import bitboards import magics import pieces - +import zobrist type @@ -50,6 +50,10 @@ type orthogonalPins*: Bitboard # Pinned orthogonally (by a queen or rook) # Pieces checking the current side to move checkers*: Bitboard + # Zobrist hash of this position + zobristKey*: ZobristKey + # Cached result of drawByRepetition() + repetitionDraw*: bool func getKingStartingSquare*(color: PieceColor): Square {.inline.} = diff --git a/Chess/nimfish/nimfishpkg/search.nim b/Chess/nimfish/nimfishpkg/search.nim index 070106e..ee4c47d 100644 --- a/Chess/nimfish/nimfishpkg/search.nim +++ b/Chess/nimfish/nimfishpkg/search.nim @@ -25,11 +25,6 @@ import std/monotimes import std/strformat -func lowestEval*: Score {.inline.} = Score(20_000) -func highestEval*: Score {.inline.} = Score(-20_000) -func mateScore*: Score {.inline.} = lowestEval() - - type SearchManager* = ref object ## A simple state storage @@ -130,23 +125,26 @@ proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {. for i, move in moves: if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves: continue - self.board.makeMove(move) + self.board.doMove(move) inc(self.nodeCount) # Find the best move for us (worst move # for our opponent, hence the negative sign) - let eval = -self.search(depth - 1, ply + 1, -beta, -alpha) + var score = -self.search(depth - 1, ply + 1, -beta, -alpha) + if self.board.position.repetitionDraw: + # Drawing by repetition is *bad* + score = lowestEval() div 2 self.board.unmakeMove() # When a search is cancelled or times out, we need # to make sure the entire call stack unwindss back # to the root move. This is why the check is duplicated if self.shouldStop(): return - bestScore = max(eval, bestScore) - if eval >= beta: + bestScore = max(score, bestScore) + if score >= beta: # This move was too good for us, opponent will not search it break - if eval > alpha: - alpha = eval + if score > alpha: + alpha = score if ply == 0: self.bestMoveRoot = move return bestScore @@ -175,12 +173,16 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes: # Search the previous best move first self.previousBestMove = self.bestMoveRoot self.search(i, 0, lowestEval(), highestEval()) - self.log(i) + let shouldStop = self.shouldStop() + if shouldStop: + self.log(i - 1) + else: + self.log(i) # 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 - if self.shouldStop(): + if shouldStop: break diff --git a/Chess/nimfish/nimfishpkg/transpositions.nim b/Chess/nimfish/nimfishpkg/transpositions.nim index d58786f..e44ac0c 100644 --- a/Chess/nimfish/nimfishpkg/transpositions.nim +++ b/Chess/nimfish/nimfishpkg/transpositions.nim @@ -15,9 +15,11 @@ ## Implementation of a transposition table import zobrist -import pieces -import eval import moves +import eval + + +import nint128 type @@ -28,22 +30,55 @@ type LowerBound = 1'i8 UpperBound = 2'i8 - TTEntry = object + TTEntry* {.packed.} = object ## An entry in the transposition table - flag: TTentryFlag + flag*: TTentryFlag # Scores are int32s for convenience (less chance # of overflows and stuff), but they are capped to # fit into an int16 - score: int16 - hash: ZobristKey - bestMove: Move + score*: int16 + hash*: ZobristKey + bestMove*: Move - TTable = object + TTable = ref object data: ptr UncheckedArray[TTEntry] - # Just for statistical purposes - collisions: uint32 - overwrites: uint32 - # Size metadata size: uint64 - occupancy: uint64 + +proc newTranspositionTable(size: uint64): TTable = + ## Initializes a new transposition table of + ## size bytes + let size = size div sizeof(TTEntry).uint64 + result.data = cast[ptr UncheckedArray[TTEntry]](alloc(size)) + + +func getIndex(self: TTable, key: ZobristKey): uint64 = + ## Retrieves the index of the given + ## zobrist key in our transposition table + + # Apparently this is a trick to get fast arbitrary indexing into the + # TT even when its size is not a multiple of 2. The alternative would + # be a modulo operation (slooow) or restricting the TT size to be a + # multiple of 2 and replacing x mod y with x and 1 (fast!), but thanks + # to @ciekce on the Engine Programming discord we now have neither of + # those limitations. Also, source: https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/ + result = (u128(key.uint64) * u128(self.size)).hi + + +func store(self: TTable, score: Score, hash: ZobristKey, bestMove: Move, flag: TTentryFlag) = + ## Stores an entry in the transposition table + self.data[self.getIndex(hash)] = TTEntry(flag: flag, score: int16(score), hash: hash, bestMove: bestMove) + + +func get(self: TTable, hash: ZobristKey): tuple[success: bool, entry: TTEntry] = + ## Attempts to get the entry with the given + ## zobrist key in the transposition table. + ## The success parameter is set to false upon + ## detection of a hash collision and the result + ## should be considered invalid unless it's true + result.entry = self.data[self.getIndex(hash)] + result.success = result.entry.hash == hash + + +proc `destroy=`(self: TTable) = + dealloc(self.data) diff --git a/Chess/nimfish/nimfishpkg/tui.nim b/Chess/nimfish/nimfishpkg/tui.nim index 879e2e0..df65718 100644 --- a/Chess/nimfish/nimfishpkg/tui.nim +++ b/Chess/nimfish/nimfishpkg/tui.nim @@ -447,7 +447,9 @@ proc commandLoop*: int = of "quit": return 0 of "zobrist": - echo board.zobristKey.uint64 + echo board.position.zobristKey.uint64 + of "rep": + echo board.position.repetitionDraw else: echo &"Unknown command '{cmd[0]}'. Type 'help' for more information." except IOError: diff --git a/Chess/nimfish/nimfishpkg/uci.nim b/Chess/nimfish/nimfishpkg/uci.nim index 243e2e0..0c6b4bb 100644 --- a/Chess/nimfish/nimfishpkg/uci.nim +++ b/Chess/nimfish/nimfishpkg/uci.nim @@ -29,6 +29,7 @@ type board: Chessboard searching: bool currentSearch: SearchManager + hashTableSize: uint64 UCICommandType = enum Unknown, @@ -44,10 +45,13 @@ type UCICommand = object case kind: UCICommandType of Debug: - value: bool + on: bool of Position: fen: string moves: seq[string] + of SetOption: + name: string + value: string of Unknown: reason: string of Go: @@ -106,7 +110,8 @@ proc parseUCIMove(session: UCISession, move: string): tuple[move: Move, command: if piece.kind == King and startSquare == session.board.position.sideToMove.getKingStartingSquare(): if targetSquare in [piece.kingSideCastling(), piece.queenSideCastling()]: flags.add(Castle) - elif targetSquare == session.board.position.enPassantSquare: + elif piece.kind == Pawn and targetSquare == session.board.position.enPassantSquare: + # I hate en passant I hate en passant I hate en passant I hate en passant I hate en passant I hate en passant flags.add(EnPassant) result.move = createMove(startSquare, targetSquare, flags) @@ -220,15 +225,14 @@ proc handleUCIPositionCommand(session: UCISession, command: seq[string]): UCICom of "moves": var j = i + 1 while j < args.len(): - while j < args.len(): - let r = handleUCIMove(session, args[j]) - if r.move == nullMove(): - if r.cmd.reason.len() > 0: - return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})") - else: - return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid") - result.moves.add(args[j]) - inc(j) + let r = handleUCIMove(session, args[j]) + if r.move == nullMove(): + if r.cmd.reason.len() > 0: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})") + else: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid") + result.moves.add(args[j]) + inc(j) inc(i) else: return UCICommand(kind: Unknown, reason: &"unknown subcomponent '{command[1]}'") @@ -254,15 +258,29 @@ proc parseUCICommand(session: UCISession, command: string): UCICommand = return case cmd[current + 1]: of "on": - return UCICommand(kind: Debug, value: true) + return UCICommand(kind: Debug, on: true) of "off": - return UCICommand(kind: Debug, value: false) + return UCICommand(kind: Debug, on: false) else: return of "position": return session.handleUCIPositionCommand(cmd) of "go": return session.handleUCIGoCommand(cmd) + of "setoption": + result = UCICommand(kind: SetOption) + while current < cmd.len(): + inc(current) + case cmd[current]: + of "name": + inc(current) + result.name = cmd[current] + of "value": + inc(current) + result.value = cmd[current] + else: + discard + else: # Unknown UCI commands should be ignored. Attempt # to make sense of the input regardless @@ -304,6 +322,7 @@ proc startUCISession* = ## Begins listening for UCI commands echo "id name Nimfish 0.1" echo "id author Nocturn9x & Contributors (see LICENSE)" + echo "option name Hash type spin default 64 min 1 max 33554432" echo "uciok" var cmd: UCICommand @@ -329,7 +348,7 @@ proc startUCISession* = of IsReady: echo "readyok" of Debug: - session.debug = cmd.value + session.debug = cmd.on of NewGame: session.board = newDefaultChessboard() of Go: @@ -337,7 +356,15 @@ proc startUCISession* = createThread(thread, bestMove, (session, cmd)) of Stop: if session.searching: - session.currentSearch.stopFlag.store(true) + session.currentSearch.stopFlag.store(true) + of SetOption: + case cmd.name: + of "Hash": + session.hashTableSize = cmd.value.parseBiggestUInt() + if session.debug: + echo &"info string set TT hash table size to {session.hashTableSize} MiB" + else: + discard of Position: discard else: diff --git a/Chess/nimfish/nimfishpkg/zobrist.nim b/Chess/nimfish/nimfishpkg/zobrist.nim index ef21c38..6209ff1 100644 --- a/Chess/nimfish/nimfishpkg/zobrist.nim +++ b/Chess/nimfish/nimfishpkg/zobrist.nim @@ -26,6 +26,7 @@ type func `xor`*(a, b: ZobristKey): ZobristKey = ZobristKey(a.uint64 xor b.uint64) +func `==`*(a, b: ZobristKey): bool = a.uint64 == b.uint64 proc computeZobristKeys: array[781, ZobristKey] =