Initial work on transposition table and repetition detection

Signed-off-by: Mattia Giambirtone <nocturn9x@nocturn9x.space>
This commit is contained in:
Mattia Giambirtone 2024-04-25 23:41:25 +02:00
parent ca0f0a4bf5
commit 95780b3236
10 changed files with 155 additions and 56 deletions

View File

@ -41,8 +41,6 @@ type
position*: Position position*: Position
# List of all previously reached positions # List of all previously reached positions
positions*: seq[Position] positions*: seq[Position]
# Zobrist hash of the given position
zobristKey*: ZobristKey
# A bunch of simple utility functions and forward declarations # A bunch of simple utility functions and forward declarations
@ -590,25 +588,40 @@ proc toFEN*(self: Chessboard): string =
result &= $self.position.fullMoveCount 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) = proc hash*(self: Chessboard) =
## Computes the zobrist hash of the current ## Computes the zobrist hash of the current
## position ## position
self.zobristKey = ZobristKey(0) self.position.zobristKey = ZobristKey(0)
if self.position.sideToMove == Black: if self.position.sideToMove == Black:
self.zobristKey = self.zobristKey xor getBlackToMoveKey() self.position.zobristKey = self.position.zobristKey xor getBlackToMoveKey()
for sq in self.getOccupancy(): 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: 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: 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: 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: 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(): if self.position.enPassantSquare != nullSquare():
self.zobristKey = self.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare))

View File

@ -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 = proc getPieceValue(kind: PieceKind): Score =
## Returns the absolute value of a piece ## Returns the absolute value of a piece

View File

@ -18,6 +18,7 @@ import board
import std/strformat import std/strformat
import std/strutils
proc testPiece(piece: Piece, kind: PieceKind, color: PieceColor) = 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()})" doAssert false, &"bitboard.len() ({i}) != squares.len() ({squares.len()})"
const fens = staticRead("../../tests/all.txt").splitLines()
proc basicTests* = proc basicTests* =
for fen in fens:
doAssert fen == newChessboardFromFEN(fen).toFEN()
var b = newDefaultChessboard() var b = newDefaultChessboard()
# Ensure correct number of pieces # Ensure correct number of pieces
testPieceCount(b, Pawn, White, 8) testPieceCount(b, Pawn, White, 8)

View File

@ -277,9 +277,10 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
if self.position.halfMoveClock >= 100: if self.position.halfMoveClock >= 100:
# Draw by 50-move rule # Draw by 50-move rule
return return
let sideToMove = self.position.sideToMove
# TODO: Check for draw by insufficient material # 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) self.generateKingMoves(moves)
if self.position.checkers.countSquares() > 1: if self.position.checkers.countSquares() > 1:
# King is in double check: no need to generate any more # 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 ## performing legality checks. Can be used in
## performance-critical paths where a move is ## performance-critical paths where a move is
## already known to be legal (i.e. during search) ## already known to be legal (i.e. during search)
# Record final position for future reference # Record final position for future reference
self.positions.add(self.position) self.positions.add(self.position)
@ -426,6 +427,7 @@ proc doMove*(self: Chessboard, move: Move) =
self.updateChecksAndPins() self.updateChecksAndPins()
# Update zobrist key # Update zobrist key
self.hash() self.hash()
discard self.drawByRepetition()
proc isLegal*(self: Chessboard, move: Move): bool {.inline.} = 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.} = proc makeMove*(self: Chessboard, move: Move): Move {.discardable.} =
## Makes a move on the board ## Makes a move on the board
result = move result = move
echo move
# Updates checks and pins for the side to move # Updates checks and pins for the side to move
if not self.isLegal(move): if not self.isLegal(move):
return nullMove() return nullMove()

View File

@ -14,7 +14,7 @@
import bitboards import bitboards
import magics import magics
import pieces import pieces
import zobrist
type type
@ -50,6 +50,10 @@ type
orthogonalPins*: Bitboard # Pinned orthogonally (by a queen or rook) orthogonalPins*: Bitboard # Pinned orthogonally (by a queen or rook)
# Pieces checking the current side to move # Pieces checking the current side to move
checkers*: Bitboard checkers*: Bitboard
# Zobrist hash of this position
zobristKey*: ZobristKey
# Cached result of drawByRepetition()
repetitionDraw*: bool
func getKingStartingSquare*(color: PieceColor): Square {.inline.} = func getKingStartingSquare*(color: PieceColor): Square {.inline.} =

View File

@ -25,11 +25,6 @@ import std/monotimes
import std/strformat import std/strformat
func lowestEval*: Score {.inline.} = Score(20_000)
func highestEval*: Score {.inline.} = Score(-20_000)
func mateScore*: Score {.inline.} = lowestEval()
type type
SearchManager* = ref object SearchManager* = ref object
## A simple state storage ## A simple state storage
@ -130,23 +125,26 @@ proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.
for i, move in moves: for i, move in moves:
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves: if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
continue continue
self.board.makeMove(move) self.board.doMove(move)
inc(self.nodeCount) inc(self.nodeCount)
# Find the best move for us (worst move # Find the best move for us (worst move
# for our opponent, hence the negative sign) # 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() self.board.unmakeMove()
# When a search is cancelled or times out, we need # When a search is cancelled or times out, we need
# to make sure the entire call stack unwindss back # to make sure the entire call stack unwindss back
# to the root move. This is why the check is duplicated # to the root move. This is why the check is duplicated
if self.shouldStop(): if self.shouldStop():
return return
bestScore = max(eval, bestScore) bestScore = max(score, bestScore)
if eval >= beta: if score >= beta:
# This move was too good for us, opponent will not search it # This move was too good for us, opponent will not search it
break break
if eval > alpha: if score > alpha:
alpha = eval alpha = score
if ply == 0: if ply == 0:
self.bestMoveRoot = move self.bestMoveRoot = move
return bestScore return bestScore
@ -175,12 +173,16 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes:
# Search the previous best move first # Search the previous best move first
self.previousBestMove = self.bestMoveRoot self.previousBestMove = self.bestMoveRoot
self.search(i, 0, lowestEval(), highestEval()) 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 # Since we always search the best move from the
# previous iteration, we can use partial search # previous iteration, we can use partial search
# results: the engine will either not have changed # results: the engine will either not have changed
# its mind, or it will have found an even better move # its mind, or it will have found an even better move
# in the meantime, which we should obviously use! # in the meantime, which we should obviously use!
result = self.bestMoveRoot result = self.bestMoveRoot
if self.shouldStop(): if shouldStop:
break break

View File

@ -15,9 +15,11 @@
## Implementation of a transposition table ## Implementation of a transposition table
import zobrist import zobrist
import pieces
import eval
import moves import moves
import eval
import nint128
type type
@ -28,22 +30,55 @@ type
LowerBound = 1'i8 LowerBound = 1'i8
UpperBound = 2'i8 UpperBound = 2'i8
TTEntry = object TTEntry* {.packed.} = object
## An entry in the transposition table ## An entry in the transposition table
flag: TTentryFlag flag*: TTentryFlag
# Scores are int32s for convenience (less chance # Scores are int32s for convenience (less chance
# of overflows and stuff), but they are capped to # of overflows and stuff), but they are capped to
# fit into an int16 # fit into an int16
score: int16 score*: int16
hash: ZobristKey hash*: ZobristKey
bestMove: Move bestMove*: Move
TTable = object TTable = ref object
data: ptr UncheckedArray[TTEntry] data: ptr UncheckedArray[TTEntry]
# Just for statistical purposes
collisions: uint32
overwrites: uint32
# Size metadata
size: uint64 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)

View File

@ -447,7 +447,9 @@ proc commandLoop*: int =
of "quit": of "quit":
return 0 return 0
of "zobrist": of "zobrist":
echo board.zobristKey.uint64 echo board.position.zobristKey.uint64
of "rep":
echo board.position.repetitionDraw
else: else:
echo &"Unknown command '{cmd[0]}'. Type 'help' for more information." echo &"Unknown command '{cmd[0]}'. Type 'help' for more information."
except IOError: except IOError:

View File

@ -29,6 +29,7 @@ type
board: Chessboard board: Chessboard
searching: bool searching: bool
currentSearch: SearchManager currentSearch: SearchManager
hashTableSize: uint64
UCICommandType = enum UCICommandType = enum
Unknown, Unknown,
@ -44,10 +45,13 @@ type
UCICommand = object UCICommand = object
case kind: UCICommandType case kind: UCICommandType
of Debug: of Debug:
value: bool on: bool
of Position: of Position:
fen: string fen: string
moves: seq[string] moves: seq[string]
of SetOption:
name: string
value: string
of Unknown: of Unknown:
reason: string reason: string
of Go: 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 piece.kind == King and startSquare == session.board.position.sideToMove.getKingStartingSquare():
if targetSquare in [piece.kingSideCastling(), piece.queenSideCastling()]: if targetSquare in [piece.kingSideCastling(), piece.queenSideCastling()]:
flags.add(Castle) 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) flags.add(EnPassant)
result.move = createMove(startSquare, targetSquare, flags) result.move = createMove(startSquare, targetSquare, flags)
@ -220,15 +225,14 @@ proc handleUCIPositionCommand(session: UCISession, command: seq[string]): UCICom
of "moves": of "moves":
var j = i + 1 var j = i + 1
while j < args.len(): while j < args.len():
while j < args.len(): let r = handleUCIMove(session, args[j])
let r = handleUCIMove(session, args[j]) if r.move == nullMove():
if r.move == nullMove(): if r.cmd.reason.len() > 0:
if r.cmd.reason.len() > 0: return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})")
return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})") else:
else: return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid")
return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid") result.moves.add(args[j])
result.moves.add(args[j]) inc(j)
inc(j)
inc(i) inc(i)
else: else:
return UCICommand(kind: Unknown, reason: &"unknown subcomponent '{command[1]}'") return UCICommand(kind: Unknown, reason: &"unknown subcomponent '{command[1]}'")
@ -254,15 +258,29 @@ proc parseUCICommand(session: UCISession, command: string): UCICommand =
return return
case cmd[current + 1]: case cmd[current + 1]:
of "on": of "on":
return UCICommand(kind: Debug, value: true) return UCICommand(kind: Debug, on: true)
of "off": of "off":
return UCICommand(kind: Debug, value: false) return UCICommand(kind: Debug, on: false)
else: else:
return return
of "position": of "position":
return session.handleUCIPositionCommand(cmd) return session.handleUCIPositionCommand(cmd)
of "go": of "go":
return session.handleUCIGoCommand(cmd) 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: else:
# Unknown UCI commands should be ignored. Attempt # Unknown UCI commands should be ignored. Attempt
# to make sense of the input regardless # to make sense of the input regardless
@ -304,6 +322,7 @@ proc startUCISession* =
## Begins listening for UCI commands ## Begins listening for UCI commands
echo "id name Nimfish 0.1" echo "id name Nimfish 0.1"
echo "id author Nocturn9x & Contributors (see LICENSE)" echo "id author Nocturn9x & Contributors (see LICENSE)"
echo "option name Hash type spin default 64 min 1 max 33554432"
echo "uciok" echo "uciok"
var var
cmd: UCICommand cmd: UCICommand
@ -329,7 +348,7 @@ proc startUCISession* =
of IsReady: of IsReady:
echo "readyok" echo "readyok"
of Debug: of Debug:
session.debug = cmd.value session.debug = cmd.on
of NewGame: of NewGame:
session.board = newDefaultChessboard() session.board = newDefaultChessboard()
of Go: of Go:
@ -337,7 +356,15 @@ proc startUCISession* =
createThread(thread, bestMove, (session, cmd)) createThread(thread, bestMove, (session, cmd))
of Stop: of Stop:
if session.searching: 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: of Position:
discard discard
else: else:

View File

@ -26,6 +26,7 @@ type
func `xor`*(a, b: ZobristKey): ZobristKey = ZobristKey(a.uint64 xor b.uint64) 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] = proc computeZobristKeys: array[781, ZobristKey] =