Massive eval improvement. Perform search in a separate thread. Implement more UCI commands

This commit is contained in:
Mattia Giambirtone 2024-04-25 15:20:55 +02:00
parent 629718a54c
commit 720645092e
7 changed files with 429 additions and 79 deletions

View File

@ -3,3 +3,4 @@
-d:danger
--passL:"-flto"
--passC:"-Ofast -flto -march=native -mtune=native"
--threads:on

View File

@ -222,7 +222,7 @@ proc removePiece*(self: Chessboard, square: Square) =
## Removes a piece from the board, updating necessary
## metadata
when not defined(danger):
let Piece = self.getPiece(square)
let piece = self.getPiece(square)
doAssert piece.kind != Empty and piece.color != None, self.toFEN()
self.removePieceFromBitboard(square)
self.grid[square] = nullPiece()

View File

@ -16,7 +16,161 @@
import board
type
Score* = int16
Score* = int32
# Stolen from https://www.chessprogramming.org/PeSTO's_Evaluation_Function
const
PAWN_MIDDLEGAME_SCORES: array[64, Score] = [
0, 0, 0, 0, 0, 0, 0, 0,
98, 134, 61, 95, 68, 126, 34, -11,
-6, 7, 26, 31, 65, 56, 25, -20,
-14, 13, 6, 21, 23, 12, 17, -23,
-27, -2, -5, 12, 17, 6, 10, -25,
-26, -4, -4, -10, 3, 3, 33, -12,
-35, -1, -20, -23, -15, 24, 38, -22,
0, 0, 0, 0, 0, 0, 0, 0,
]
PAWN_ENDGAME_SCORES: array[64, Score] = [
0, 0, 0, 0, 0, 0, 0, 0,
178, 173, 158, 134, 147, 132, 165, 187,
94, 100, 85, 67, 56, 53, 82, 84,
32, 24, 13, 5, -2, 4, 17, 17,
13, 9, -3, -7, -7, -8, 3, -1,
4, 7, -6, 1, 0, -5, -1, -8,
13, 8, 8, 10, 13, 0, 2, -7,
0, 0, 0, 0, 0, 0, 0, 0,
]
KNIGHT_MIDDLEGAME_SCORES: array[64, Score] = [
-167, -89, -34, -49, 61, -97, -15, -107,
-73, -41, 72, 36, 23, 62, 7, -17,
-47, 60, 37, 65, 84, 129, 73, 44,
-9, 17, 19, 53, 37, 69, 18, 22,
-13, 4, 16, 13, 28, 19, 21, -8,
-23, -9, 12, 10, 19, 17, 25, -16,
-29, -53, -12, -3, -1, 18, -14, -19,
-105, -21, -58, -33, -17, -28, -19, -23,
]
KNIGHT_ENDGAME_SCORES: array[64, Score] = [
-58, -38, -13, -28, -31, -27, -63, -99,
-25, -8, -25, -2, -9, -25, -24, -52,
-24, -20, 10, 9, -1, -9, -19, -41,
-17, 3, 22, 22, 22, 11, 8, -18,
-18, -6, 16, 25, 16, 17, 4, -18,
-23, -3, -1, 15, 10, -3, -20, -22,
-42, -20, -10, -5, -2, -20, -23, -44,
-29, -51, -23, -15, -22, -18, -50, -64,
]
BISHOP_MIDDLEGAME_SCORES: array[64, Score] = [
-29, 4, -82, -37, -25, -42, 7, -8,
-26, 16, -18, -13, 30, 59, 18, -47,
-16, 37, 43, 40, 35, 50, 37, -2,
-4, 5, 19, 50, 37, 37, 7, -2,
-6, 13, 13, 26, 34, 12, 10, 4,
0, 15, 15, 15, 14, 27, 18, 10,
4, 15, 16, 0, 7, 21, 33, 1,
-33, -3, -14, -21, -13, -12, -39, -21,
]
BISHOP_ENDGAME_SCORES: array[64, Score] = [
-14, -21, -11, -8, -7, -9, -17, -24,
-8, -4, 7, -12, -3, -13, -4, -14,
2, -8, 0, -1, -2, 6, 0, 4,
-3, 9, 12, 9, 14, 10, 3, 2,
-6, 3, 13, 19, 7, 10, -3, -9,
-12, -3, 8, 10, 13, 3, -7, -15,
-14, -18, -7, -1, 4, -9, -15, -27,
-23, -9, -23, -5, -9, -16, -5, -17,
]
ROOK_MIDDLEGAME_SCORES: array[64, Score] = [
32, 42, 32, 51, 63, 9, 31, 43,
27, 32, 58, 62, 80, 67, 26, 44,
-5, 19, 26, 36, 17, 45, 61, 16,
-24, -11, 7, 26, 24, 35, -8, -20,
-36, -26, -12, -1, 9, -7, 6, -23,
-45, -25, -16, -17, 3, 0, -5, -33,
-44, -16, -20, -9, -1, 11, -6, -71,
-19, -13, 1, 17, 16, 7, -37, -26,
]
ROOK_ENDGAME_SCORES: array[64, Score] = [
13, 10, 18, 15, 12, 12, 8, 5,
11, 13, 13, 11, -3, 3, 8, 3,
7, 7, 7, 5, 4, -3, -5, -3,
4, 3, 13, 1, 2, 1, -1, 2,
3, 5, 8, 4, -5, -6, -8, -11,
-4, 0, -5, -1, -7, -12, -8, -16,
-6, -6, 0, 2, -9, -9, -11, -3,
-9, 2, 3, -1, -5, -13, 4, -20,
]
QUEEN_MIDDLEGAME_SCORES: array[64, Score] = [
-28, 0, 29, 12, 59, 44, 43, 45,
-24, -39, -5, 1, -16, 57, 28, 54,
-13, -17, 7, 8, 29, 56, 47, 57,
-27, -27, -16, -16, -1, 17, -2, 1,
-9, -26, -9, -10, -2, -4, 3, -3,
-14, 2, -11, -2, -5, 2, 14, 5,
-35, -8, 11, 2, 8, 15, -3, 1,
-1, -18, -9, 10, -15, -25, -31, -50,
]
QUEEN_ENDGAME_SCORES: array[64, Score] = [
-9, 22, 22, 27, 27, 19, 10, 20,
-17, 20, 32, 41, 58, 25, 30, 0,
-20, 6, 9, 49, 47, 35, 19, 9,
3, 22, 24, 45, 57, 40, 57, 36,
-18, 28, 19, 47, 31, 34, 39, 23,
-16, -27, 15, 6, 9, 17, 10, 5,
-22, -23, -30, -16, -16, -23, -36, -32,
-33, -28, -22, -43, -5, -32, -20, -41,
]
KING_MIDDLEGAME_SCORES: array[64, Score] = [
-65, 23, 16, -15, -56, -34, 2, 13,
29, -1, -20, -7, -8, -4, -38, -29,
-9, 24, 2, -16, -20, 6, 22, -22,
-17, -20, -12, -27, -30, -25, -14, -36,
-49, -1, -27, -39, -46, -44, -33, -51,
-14, -14, -22, -46, -44, -30, -15, -27,
1, 7, -8, -64, -43, -16, 9, 8,
-15, 36, 12, -54, 8, -28, 24, 14,
]
KING_ENDGAME_SCORES: array[64, Score] = [
-74, -35, -18, -18, -11, 15, 4, -17,
-12, 17, 14, 17, 17, 38, 23, 11,
10, 17, 23, 15, 20, 45, 44, 13,
-8, 22, 24, 27, 26, 33, 26, 3,
-18, -4, 21, 24, 27, 23, 9, -11,
-19, -3, 11, 21, 23, 16, 7, -9,
-27, -11, 4, 13, 14, 4, -5, -17,
-53, -34, -21, -11, -28, -14, -24, -43
]
MIDDLEGAME_TABLES: array[6, array[64, Score]] = [
BISHOP_MIDDLEGAME_SCORES,
KING_MIDDLEGAME_SCORES,
KNIGHT_MIDDLEGAME_SCORES,
PAWN_MIDDLEGAME_SCORES,
QUEEN_MIDDLEGAME_SCORES,
ROOK_MIDDLEGAME_SCORES
]
ENDGAME_TABLES: array[6, array[64, Score]] = [
BISHOP_ENDGAME_SCORES,
KING_ENDGAME_SCORES,
KNIGHT_ENDGAME_SCORES,
PAWN_ENDGAME_SCORES,
QUEEN_ENDGAME_SCORES,
ROOK_ENDGAME_SCORES
]
proc getPieceValue(kind: PieceKind): Score =
@ -36,12 +190,68 @@ proc getPieceValue(kind: PieceKind): Score =
discard
proc getPieceScore(board: Chessboard, square: Square): Score =
## Returns the value of the piece located at
proc getPieceScore*(board: Chessboard, square: Square): Score =
## Returns the value of the piece located at
## the given square
return board.getPiece(square).kind.getPieceValue()
proc getEstimatedMoveScore*(board: Chessboard, move: Move): Score =
## Returns an estimated static score for the move
result = Score(0)
if move.isCapture():
# Implementation of MVVLVA: Most Valuable Victim Least Valuable Attacker
# We prioritize moves that capture the most valuable pieces, and as a
# second goal we want to use our least valuable pieces to do so (this
# is why we multiply the piece of the captured score by 100, to give
# it priority)
result = 100 * board.getPieceScore(move.targetSquare) -
board.getPieceScore(move.startSquare)
proc getGamePhase(board: Chessboard): int =
## Computes the game phase according to
## how many pieces are left on the board
result = 0
for sq in board.getOccupancy():
case board.getPiece(sq).kind:
of Bishop, Knight:
inc(result)
of Queen:
inc(result, 4)
of Rook:
inc(result, 2)
else:
discard
# Caps the value in case of early
# promotions
result = min(24, result)
proc evaluatePiecePositions(board: ChessBoard): Score =
## Returns the evaluation of the current
## material's position relative to white
let
middleGamePhase = board.getGamePhase()
endGamePhase = 24 - middleGamePhase
var
# White, Black
middleGameScores: array[2, Score] = [0, 0]
endGameScores: array[2, Score] = [0, 0]
for sq in board.getOccupancy():
let piece = board.getPiece(sq)
middleGameScores[piece.color.int] += MIDDLEGAME_TABLES[piece.kind.int][sq.int]
endGameScores[piece.color.int] += ENDGAME_TABLES[piece.kind.int][sq.int]
let
sideToMove = board.position.sideToMove
nonSideToMove = sideToMove.opposite()
middleGameScore = middleGameScores[sideToMove.int] - middleGameScores[nonSideToMove.int]
endGameScore = endGameScores[sideToMove.int] - endGameScores[nonSideToMove.int]
result = Score((middleGameScore * middleGamePhase + endGameScore * endGamePhase) div 24)
proc evaluateMaterial(board: ChessBoard): Score =
## Returns the material evaluation of the
@ -50,20 +260,20 @@ proc evaluateMaterial(board: ChessBoard): Score =
var
whiteScore: Score
blackScore: Score
for sq in board.getOccupancyFor(White):
whiteScore += board.getPieceScore(sq)
for sq in board.getOccupancyFor(Black):
blackScore += board.getPieceScore(sq)
result = whiteScore - blackScore
if board.position.sideToMove == Black:
result *= -1
proc evaluate*(board: Chessboard): Score =
## Evaluates the current position
result = board.evaluateMaterial()
result = board.evaluateMaterial()
result += board.evaluatePiecePositions()

View File

@ -285,7 +285,7 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
# King is in double check: no need to generate any more
# moves
return
self.generateCastling(moves)
# We pass a mask to our move generators to remove stuff

View File

@ -40,11 +40,12 @@ type
targetSquare*: Square
flags*: uint16
MoveList* = object
MoveList* = ref object
## A list of moves
data: array[218, Move]
data*: array[218, Move]
len: int8
func `[]`*(self: MoveList, i: SomeInteger): Move =
when not defined(danger):
if i >= self.len:

View File

@ -18,12 +18,16 @@ import movegen
import eval
import std/times
import std/atomics
import std/algorithm
import std/monotimes
import std/strformat
func lowestEval*: Score {.inline.} = Score(-32000'i16)
func highestEval*: Score {.inline.} = Score(32000'i16)
func mateScore*: Score {.inline.} = lowestEval() - Score(1)
func lowestEval*: Score {.inline.} = Score(int32.low() + 1_000_000)
func highestEval*: Score {.inline.} = Score(int32.high() - 1_000_000)
func mateScore*: Score {.inline.} = lowestEval()
type
@ -31,20 +35,72 @@ type
## A simple state storage
## for our search
stopFlag*: Atomic[bool] # Can be used to cancel the search from another thread
bestMove*: Move
board: Chessboard
bestMoveRoot: Move
searchStart: MonoTime
searchDeadline: MonoTime
nodeCount: uint64
maxNodes: uint64
searchMoves: seq[Move]
proc search*(self: SearchManager, board: Chessboard, depth, ply: int, alpha, beta: Score): Score {.discardable.} =
## Simple negamax search with alpha-beta pruning
if self.stopFlag.load():
proc newSearchManager*(board: Chessboard): SearchManager =
new(result)
result.board = board
result.bestMoveRoot = nullMove()
result.searchMoves = @[]
proc reorderMoves(self: SearchManager, moves: var MoveList) =
## Reorders the list of moves in-place, trying
## to place the best ones first
proc orderer(a, b: Move): int {.closure.} =
return cmp(self.board.getEstimatedMoveScore(a), self.board.getEstimatedMoveScore(b))
moves.data.sort(orderer, SortOrder.Descending)
proc timedOut(self: SearchManager): bool = getMonoTime() >= self.searchDeadline
proc cancelled(self: SearchManager): bool = self.stopFlag.load()
proc log(self: SearchManager, depth: int) =
let
elapsed = getMonoTime() - self.searchStart
elapsedMsec = elapsed.inMilliseconds.uint64
nps = 1000 * (self.nodeCount div max(elapsedMsec, 1))
var logMsg = &"info depth {depth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}"
if self.bestMoveRoot != nullMove():
logMsg &= &" pv {self.bestMoveRoot.toAlgebraic()}"
echo logMsg
proc shouldStop(self: SearchManager): bool =
## Returns whether searching should
## stop
if self.cancelled():
# Search has been cancelled!
return true
if self.timedOut():
# We ran out of time!
return true
if self.maxNodes > 0 and self.nodeCount >= self.maxNodes:
# Ran out of nodes
return true
proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} =
## Simple negamax search with alpha-beta pruning
if self.shouldStop():
return
if depth == 0:
return board.evaluate()
return self.board.evaluate()
var moves = MoveList()
board.generateMoves(moves)
self.board.generateMoves(moves)
self.reorderMoves(moves)
if moves.len() == 0:
if board.inCheck():
if self.board.inCheck():
# Checkmate! We add the current ply
# because mating in 3 is better than
# mating in 5 (and conversely being
@ -55,13 +111,20 @@ proc search*(self: SearchManager, board: Chessboard, depth, ply: int, alpha, bet
return Score(0)
var bestScore = lowestEval()
var alpha = alpha
for move in moves:
board.makeMove(move)
for i, move in moves:
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
continue
self.board.makeMove(move)
inc(self.nodeCount)
# Find the best move for us (worst move
# for our opponent, hence the negative sign)
let eval = -self.search(board, depth - 1, ply + 1, -beta, -alpha)
board.unmakeMove()
let eval = -self.search(depth - 1, ply + 1, -beta, -alpha)
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:
# This move was too good for us, opponent will not search it
@ -69,6 +132,32 @@ proc search*(self: SearchManager, board: Chessboard, depth, ply: int, alpha, bet
if eval > alpha:
alpha = eval
if ply == 0:
self.bestMove = move
self.bestMoveRoot = move
return bestScore
return bestScore
proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes: uint64, searchMoves: seq[Move]): Move =
## Finds the best move in the current position
## and returns it, limiting search time to
## maxSearchTime milliseconds and to maxDepth
## ply (if maxDepth is -1, a reasonable limit
## is picked). If maxNodes is supplied and is nonzero,
## search will stop once it has analyzed the given number
## of nodes. If searchMoves is provided and is not empty,
## search will be restricted to the moves in the list
self.bestMoveRoot = nullMove()
result = self.bestMoveRoot
self.maxNodes = maxNodes
self.searchMoves = searchMoves
self.searchStart = getMonoTime()
self.searchDeadline = self.searchStart + initDuration(milliseconds=maxSearchTime)
var maxDepth = maxDepth
if maxDepth == -1:
maxDepth = 30
# Iterative deepening loop
for i in 1..maxDepth:
self.search(i, 0, lowestEval(), highestEval())
self.log(i)
if self.shouldStop():
break
result = self.bestMoveRoot

View File

@ -15,10 +15,7 @@
## Implementation of a UCI compatible server
import std/strutils
import std/strformat
import std/random
randomize()
import std/atomics
import board
@ -60,44 +57,13 @@ type
movesToGo: int
depth: int
moveTime: int
nodes: uint64
searchmoves: seq[Move]
else:
discard
proc handleUCIGoCommand(session: UCISession, command: seq[string]): UCICommand =
result = UCICommand(kind: Go)
result.wtime = -1
result.btime = -1
result.winc = 0
result.binc = 0
result.movesToGo = 0
result.depth = 0
result.moveTime = -1
var
current = 0
while current < command.len():
case command[current]:
of "infinite":
discard
of "wtime":
result.wtime = command[current + 1].parseInt()
of "btime":
result.btime = command[current + 1].parseInt()
of "winc":
result.winc = command[current + 1].parseInt()
of "binc":
result.binc = command[current + 1].parseInt()
of "result.movestogo":
result.movesToGo = command[current + 1].parseInt()
of "depth":
result.depth = command[current + 1].parseInt()
of "movetime":
result.moveTime = command[current + 1].parseInt()
else:
return
proc handleUCIMove(session: UCISession, move: string): tuple[move: Move, cmd: UCICommand] {.discardable.} =
proc parseUCIMove(session: UCISession, move: string): tuple[move: Move, command: UCICommand] =
var
startSquare: Square
targetSquare: Square
@ -141,10 +107,68 @@ proc handleUCIMove(session: UCISession, move: string): tuple[move: Move, cmd: UC
flags.add(Castle)
elif targetSquare == session.board.position.enPassantSquare:
flags.add(EnPassant)
let move = createMove(startSquare, targetSquare, flags)
result.move = createMove(startSquare, targetSquare, flags)
proc handleUCIMove(session: UCISession, move: string): tuple[move: Move, cmd: UCICommand] {.discardable.} =
if session.debug:
echo &"info string making move {move}"
result.move = session.board.makeMove(move)
let
r = session.parseUCIMove(move)
move = r.move
command = r.command
if move == nullMove():
return (move, command)
else:
result.move = session.board.makeMove(move)
proc handleUCIGoCommand(session: UCISession, command: seq[string]): UCICommand =
result = UCICommand(kind: Go)
result.wtime = 0
result.btime = 0
result.winc = 0
result.binc = 0
result.movesToGo = 0
result.depth = -1
result.moveTime = -1
result.nodes = 0
var
current = 1 # Skip the "go"
while current < command.len():
let flag = command[current]
inc(current)
case flag:
of "infinite":
result.wtime = int32.high()
result.btime = int32.high()
of "wtime":
result.wtime = command[current].parseInt()
of "btime":
result.btime = command[current].parseInt()
of "winc":
result.winc = command[current].parseInt()
of "binc":
result.binc = command[current].parseInt()
of "movestogo":
result.movesToGo = command[current].parseInt()
of "depth":
result.depth = command[current].parseInt()
of "movetime":
result.moveTime = command[current].parseInt()
of "nodes":
result.nodes = command[current].parseBiggestUInt()
of "searchmoves":
while current < command.len():
inc(current)
if command[current] == "":
break
let move = session.parseUCIMove(command[current]).move
if move == nullMove():
return UCICommand(kind: Unknown, reason: &"invalid move '{command[current]}' for searchmoves")
result.searchmoves.add(move)
else:
discard
proc handleUCIPositionCommand(session: UCISession, command: seq[string]): UCICommand =
@ -244,15 +268,36 @@ proc parseUCICommand(session: UCISession, command: string): UCICommand =
inc(current)
proc bestMove(session: UCISession, command: UCICommand) =
proc bestMove(args: tuple[session: UCISession, command: UCICommand]) {.thread.} =
## Finds the best move in the current position
session.searching = true
session.currentSearch = SearchManager()
session.currentSearch.search(session.board, 6, 0, lowestEval(), highestEval())
session.searching = false
let move = session.currentSearch.bestMove
echo &"bestmove {move.toAlgebraic()}"
{.cast(gcsafe).}:
setControlCHook(proc () {.noconv.} = quit(0))
# Yes yes nim sure this isn't gcsafe. Now stfu and spawn a thread
var session = args.session
var command = args.command
session.searching = true
session.currentSearch = newSearchManager(session.board)
session.currentSearch.stopFlag.store(false)
var
timeRemaining = (if session.board.position.sideToMove == White: command.wtime else: command.btime)
increment = (if session.board.position.sideToMove == White: command.winc else: command.binc)
maxTime = (timeRemaining div 20) + (increment div 2)
# Buffer to avoid loosing on time
if maxTime == 0:
maxTime = int32.high()
else:
maxTime -= 100
if command.moveTime != -1:
maxTime = command.moveTime
if session.debug:
echo &"info string starting search to depth {command.depth} for at most {maxTime} ms and {command.nodes} nodes"
if session.debug and command.searchmoves.len() > 0:
echo &"""info string restricting search to: {command.searchmoves.join(" ")}"""
var move = session.currentSearch.findBestMove(maxTime, command.depth, command.nodes, command.searchmoves)
session.searching = false
echo &"bestmove {move.toAlgebraic()}"
proc startUCISession* =
## Begins listening for UCI commands
@ -287,7 +332,11 @@ proc startUCISession* =
of NewGame:
session.board = newDefaultChessboard()
of Go:
session.bestMove(cmd)
var thread: Thread[tuple[session: UCISession, command: UCICommand]]
createThread(thread, bestMove, (session, cmd))
of Stop:
if session.searching:
session.currentSearch.stopFlag.store(true)
of Position:
discard
else: