Fix crashing, implement LMR and tempo bonus

This commit is contained in:
Mattia Giambirtone 2024-05-06 23:48:40 +02:00
parent 2a1f020edd
commit 7292356948
8 changed files with 123 additions and 73 deletions

View File

@ -4,4 +4,4 @@
--passL:"-flto -lmimalloc" --passL:"-flto -lmimalloc"
--passC:"-flto -march=native -mtune=native" --passC:"-flto -march=native -mtune=native"
-d:useMalloc -d:useMalloc
--mm:atomicArc --mm:atomicArc

View File

@ -98,6 +98,10 @@ func drawnByRepetition*(self: Chessboard): bool =
inc(count) inc(count)
if count == 2: if count == 2:
return true 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) dec(i)

View File

@ -21,6 +21,7 @@ type
# Stolen from https://www.chessprogramming.org/PeSTO's_Evaluation_Function # Stolen from https://www.chessprogramming.org/PeSTO's_Evaluation_Function
const const
TEMPO_BONUS = Score(10)
PAWN_MIDDLEGAME_SCORES: array[Square(0)..Square(63), Score] = [ PAWN_MIDDLEGAME_SCORES: array[Square(0)..Square(63), Score] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
98, 134, 61, 95, 68, 126, 34, -11, 98, 134, 61, 95, 68, 126, 34, -11,
@ -311,3 +312,5 @@ proc evaluate*(position: Position): Score =
result = position.evaluateMaterial() result = position.evaluateMaterial()
when defined(evalPawns): when defined(evalPawns):
result += position.evaluatePawnStructure() result += position.evaluatePawnStructure()
# Tempo bonus: gains 19.5 +/- 13.7
result += TEMPO_BONUS

View File

@ -40,7 +40,7 @@ type
targetSquare*: Square targetSquare*: Square
flags*: uint16 flags*: uint16
MoveList* = ref object MoveList* = object
## A list of moves ## A list of moves
data*: array[218, Move] data*: array[218, Move]
len: int8 len: int8
@ -203,6 +203,5 @@ func toAlgebraic*(self: Move): string =
proc newMoveList*: MoveList = proc newMoveList*: MoveList =
new(result)
for i in 0..result.data.high(): for i in 0..result.data.high():
result.data[i] = nullMove() result.data[i] = nullMove()

View File

@ -19,6 +19,7 @@ import eval
import transpositions import transpositions
import std/math
import std/times import std/times
import std/atomics import std/atomics
import std/algorithm import std/algorithm
@ -26,38 +27,54 @@ import std/monotimes
import std/strformat 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 type
HistoryTable* = array[PieceColor.White..PieceColor.Black, array[Square(0)..Square(63), array[Square(0)..Square(63), Score]]] 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 SearchManager* = object
## A simple state storage ## A simple state storage
## for our search ## for our search
searchFlag: ptr Atomic[bool] searchFlag: ptr Atomic[bool]
stopFlag: ptr Atomic[bool] stopFlag: ptr Atomic[bool]
board: Chessboard board: Chessboard
bestMoveRoot: Move
bestRootScore: Score bestRootScore: Score
searchStart: MonoTime searchStart: MonoTime
hardLimit: MonoTime hardLimit: MonoTime
softLimit: MonoTime softLimit: MonoTime
nodeCount: uint64 nodeCount: uint64
maxNodes: uint64 maxNodes: uint64
currentMove: Move
currentMoveNumber: int
searchMoves: seq[Move] searchMoves: seq[Move]
previousBestMove: Move
transpositionTable: ptr TTable transpositionTable: ptr TTable
history: ptr HistoryTable history: ptr HistoryTable
killers: ptr KillersTable 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], proc newSearchManager*(board: Chessboard, transpositions: ptr TTable, stopFlag, searchFlag: ptr Atomic[bool],
history: ptr HistoryTable, killers: ptr KillersTable): SearchManager = 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) 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 = proc isSearching*(self: SearchManager): bool =
@ -91,14 +108,11 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): Score =
sideToMove = self.board.position.sideToMove sideToMove = self.board.position.sideToMove
nonSideToMove = sideToMove.opposite() nonSideToMove = sideToMove.opposite()
if self.previousBestMove != nullMove() and move == self.previousBestMove:
return highestEval() + 1
let query = self.transpositionTable[].get(self.board.position.zobristKey) let query = self.transpositionTable[].get(self.board.position.zobristKey)
if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move: 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 result += self.board.position.getPieceScore(move.startSquare) * 5
if not move.isCapture(): if not move.isCapture():
@ -156,10 +170,14 @@ proc log(self: SearchManager, depth: int) =
let let
elapsedMsec = self.elapsedTime().uint64 elapsedMsec = self.elapsedTime().uint64
nps = 1000 * (self.nodeCount div max(elapsedMsec, 1)) nps = 1000 * (self.nodeCount div max(elapsedMsec, 1))
var logMsg = &"info depth {depth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}" var logMsg = &"info depth {depth} seldepth {self.selectiveDepth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}"
logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()}" logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()} score cp {self.bestRootScore}"
if self.bestMoveRoot != nullMove(): if self.pvMoves[0][0] != nullMove():
logMsg &= &" score cp {self.bestRootScore} pv {self.bestMoveRoot.toAlgebraic()}" logMsg &= " pv "
for move in self.pvMoves[0]:
if move == nullMove():
break
logMsg &= &"{move.toAlgebraic()} "
echo logMsg echo logMsg
@ -186,6 +204,23 @@ proc getSearchExtension(self: SearchManager, move: Move): int {.used.} =
return 1 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 = proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
## Negamax search with a/b pruning that is restricted to ## Negamax search with a/b pruning that is restricted to
## capture moves (commonly called quiescent search). The ## capture moves (commonly called quiescent search). The
@ -202,7 +237,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
## exist ## exist
if self.shouldStop(): if self.shouldStop():
return return
if ply == 127: if ply == MAX_DEPTH:
return Score(0) return Score(0)
let score = self.board.position.evaluate() let score = self.board.position.evaluate()
if score >= beta: if score >= beta:
@ -234,7 +269,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
return bestScore 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 ## Stores a killer move into our killers table at the given
## ply ## ply
@ -249,25 +284,21 @@ proc storeKillerMove(self: SearchManager, ply: int, move: Move) =
# Shift moves one spot down # Shift moves one spot down
self.killers[][ply][j + 1] = self.killers[][ply][j]; self.killers[][ply][j + 1] = self.killers[][ply][j];
dec(j) dec(j)
self.killers[][ply][0] = move; self.killers[][ply][0] = move
proc shouldReduce(self: SearchManager, move: Move, depth, moveNumber: int): bool = proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV: bool): Score {.discardable.} =
## 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.} =
## Negamax search with various optimizations and search features ## 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(): if depth > 1 and self.shouldStop():
# We do not let ourselves get cancelled at depth # We do not let ourselves get cancelled at depth
# one because then we wouldn't have a move to return. # one because then we wouldn't have a move to return.
# In practice this should not be a problem # In practice this should not be a problem
return return
when defined(killers): self.selectiveDepth = max(self.selectiveDepth, ply)
if self.killers[].high() < ply:
self.killers[].add([nullMove(), nullMove()])
if ply > 0: if ply > 0:
let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8) let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8)
if query.success: if query.success:
@ -285,7 +316,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
if depth == 0: if depth == 0:
# Quiescent search gain: 264.8 +/- 71.6 # Quiescent search gain: 264.8 +/- 71.6
return self.qsearch(0, alpha, beta) return self.qsearch(0, alpha, beta)
var var
moves = newMoveList() moves = newMoveList()
depth = depth depth = depth
bestMove = nullMove() bestMove = nullMove()
@ -305,14 +336,14 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
var alpha = alpha var alpha = alpha
let let
sideToMove = self.board.position.sideToMove sideToMove = self.board.position.sideToMove
nonSideToMove = sideToMove.opposite() nonSideToMove {.used.} = sideToMove.opposite()
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.doMove(move) self.board.doMove(move)
self.currentMove = move let
self.currentMoveNumber = i extension = self.getSearchExtension(move)
let extension = self.getSearchExtension(move) reduction = self.getReduction(move, depth, ply, i, isPV)
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)
@ -321,30 +352,30 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
if i == 0: if i == 0:
# Due to our move ordering scheme, the first move is always the "best", so # 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 # search it always at full depth with the full search window
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha) score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha, isPV)
elif extension == 0 and self.shouldReduce(move, depth, i): elif reduction > 0:
# Late Move Reductions: assume our move orderer did a good job, # 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. # 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 # If this move turns out to be better than we expected, we'll re-search
# it at full depth # 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 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] # (we don't care about the actual value, so we search in the range [alpha, alpha + 1]
# to increase the number of cutoffs) # 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 # 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 # 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 # (but not beta) again, we'll re-search this at full depth later
if score > alpha: 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: else:
# Move wasn't reduced, just do a null window search # 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: 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 # our opponent wouldn't let us play it) in the null window search, search it
# again with the full depth and full window # 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() 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 unwinds back # 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(): 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 # 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 # 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) # engines, namely Stockfish, use a linear bonus. Maybe we can investigate this)
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(depth * depth) 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 # 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 alpha = score
bestMove = move bestMove = move
if ply == 0: if ply == 0:
self.bestMoveRoot = move self.bestRootScore = score
self.bestRootScore = bestScore 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 # TODO
# else: # else:
# when defined(noScaleHistory): # 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 ## nodes. If searchMoves is provided and is not empty, search will
## be restricted to the moves in the list. Note that regardless of ## be restricted to the moves in the list. Note that regardless of
## any time limitations or explicit cancellations, the search will ## 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 # Apparently negative remaining time is a thing. Welp
let let
maxSearchTime = max(1, (timeRemaining div 10) + (increment div 2)) maxSearchTime = max(1, (timeRemaining div 10) + (increment div 2))
softLimit = maxSearchTime div 3 softLimit = maxSearchTime div 3
echo maxSearchTime result = nullMove()
self.bestMoveRoot = nullMove()
result = self.bestMoveRoot
self.maxNodes = maxNodes self.maxNodes = maxNodes
self.searchMoves = searchMoves self.searchMoves = searchMoves
self.searchStart = getMonoTime() self.searchStart = getMonoTime()
@ -416,23 +455,18 @@ proc findBestMove*(self: var SearchManager, timeRemaining, increment: int64, max
maxDepth = 30 maxDepth = 30
self.searchFlag[].store(true) self.searchFlag[].store(true)
# Iterative deepening loop # Iterative deepening loop
for i in 1..maxDepth: for i in 1..min(MAX_DEPTH, maxDepth):
# Search the previous best move first self.search(i, 0, lowestEval(), highestEval(), true)
self.previousBestMove = self.bestMoveRoot if self.pvMoves[0][0] != nullMove():
self.search(i, 0, lowestEval(), highestEval()) result = self.pvMoves[0][0]
# Since we always search the best move from the if self.shouldStop():
# previous iteration, we can use partial search self.log(i - 1)
# results: the engine will either not have changed break
# its mind, or it will have found an even better move self.log(i)
# in the meantime, which we should obviously use!
result = self.bestMoveRoot
# Soft time management: don't start a new search iteration # Soft time management: don't start a new search iteration
# if the soft limit has expired, as it is unlikely to complete # if the soft limit has expired, as it is unlikely to complete
# anyway # anyway
if self.shouldStop() or getMonoTime() >= self.softLimit: if getMonoTime() >= self.softLimit:
self.log(i - 1)
break break
else:
self.log(i)
self.searchFlag[].store(false) self.searchFlag[].store(false)
self.stopFlag[].store(false) self.stopFlag[].store(false)

View File

@ -388,6 +388,7 @@ proc commandLoop*: int =
of "skip": of "skip":
board.position.sideToMove = board.position.sideToMove.opposite() board.position.sideToMove = board.position.sideToMove.opposite()
board.position.updateChecksAndPins() board.position.updateChecksAndPins()
board.position.hash()
of "go": of "go":
handleGoCommand(board, cmd) handleGoCommand(board, cmd)
of "position", "pos": of "position", "pos":

View File

@ -351,6 +351,10 @@ proc startUCISession* =
session.transpositionTable[] = newTranspositionTable(session.hashTableSize * 1024 * 1024) session.transpositionTable[] = newTranspositionTable(session.hashTableSize * 1024 * 1024)
session.historyTable = cast[ptr HistoryTable](alloc0(sizeof(HistoryTable))) session.historyTable = cast[ptr HistoryTable](alloc0(sizeof(HistoryTable)))
session.killerMoves = cast[ptr KillersTable](alloc0(sizeof(KillersTable))) session.killerMoves = cast[ptr KillersTable](alloc0(sizeof(KillersTable)))
# Initialize killer move array
for i in 0..<MAX_DEPTH:
for j in 0..<NUM_KILLERS:
session.killerMoves[i][j] = nullMove()
# Fun fact, nim doesn't collect the memory of thread vars. Another stupid fucking design pitfall # Fun fact, nim doesn't collect the memory of thread vars. Another stupid fucking design pitfall
# of nim's AWESOME threading model. Someone is getting a pipebomb in their mailbox about this, mark # of nim's AWESOME threading model. Someone is getting a pipebomb in their mailbox about this, mark
# my fucking words. (for legal purposes THAT IS A JOKE). See https://github.com/nim-lang/Nim/issues/23165 # my fucking words. (for legal purposes THAT IS A JOKE). See https://github.com/nim-lang/Nim/issues/23165
@ -384,16 +388,20 @@ proc startUCISession* =
session.position = startpos() session.position = startpos()
session.history = @[] session.history = @[]
of Go: of Go:
when not defined(noScaleHistory): # when not defined(noScaleHistory):
# Scale our history coefficients # Scale our history coefficients
for color in PieceColor.White..PieceColor.Black: for color in PieceColor.White..PieceColor.Black:
for source in Square(0)..Square(63): for source in Square(0)..Square(63):
for target in Square(0)..Square(63): for target in Square(0)..Square(63):
session.historyTable[][color][source][target] = session.historyTable[][color][source][target] div 2 session.historyTable[][color][source][target] = session.historyTable[][color][source][target] div 2
if searchThread.running:
joinThread(searchThread)
createThread(searchThread, bestMove, (session, cmd)) createThread(searchThread, bestMove, (session, cmd))
if session.debug: if session.debug:
echo "info string search started" echo "info string search started"
of Stop: of Stop:
if not session.searchFlag[].load():
continue
session.stopFlag[].store(true) session.stopFlag[].store(true)
joinThread(searchThread) joinThread(searchThread)
if session.debug: if session.debug:

View File

@ -55,7 +55,8 @@ def main(args: Namespace) -> int:
stop = timeit.default_timer() stop = timeit.default_timer()
pool.shutdown(cancel_futures=True) pool.shutdown(cancel_futures=True)
print(f"\r[S] Interrupted\033[K") 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: if failed and args.show_failures:
print("[S] The following FENs failed to pass the test:\n\t", end="") print("[S] The following FENs failed to pass the test:\n\t", end="")
print("\n\t".join(failed)) print("\n\t".join(failed))