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"
--passC:"-flto -march=native -mtune=native"
-d:useMalloc
--mm:atomicArc
--mm:atomicArc

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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":

View File

@ -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..<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
# 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
@ -384,16 +388,20 @@ proc startUCISession* =
session.position = startpos()
session.history = @[]
of Go:
when not defined(noScaleHistory):
# Scale our history coefficients
for color in PieceColor.White..PieceColor.Black:
for source in Square(0)..Square(63):
for target in Square(0)..Square(63):
session.historyTable[][color][source][target] = session.historyTable[][color][source][target] div 2
# when not defined(noScaleHistory):
# Scale our history coefficients
for color in PieceColor.White..PieceColor.Black:
for source in Square(0)..Square(63):
for target in Square(0)..Square(63):
session.historyTable[][color][source][target] = session.historyTable[][color][source][target] div 2
if searchThread.running:
joinThread(searchThread)
createThread(searchThread, bestMove, (session, cmd))
if session.debug:
echo "info string search started"
of Stop:
if not session.searchFlag[].load():
continue
session.stopFlag[].store(true)
joinThread(searchThread)
if session.debug:

View File

@ -55,7 +55,8 @@ def main(args: Namespace) -> 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))