Implement null move pruning (+34.5 +/- 19.6), minor changes and fixes

This commit is contained in:
Mattia Giambirtone 2024-05-10 11:21:57 +02:00
parent 887e2a64a3
commit b9fbb9eb3f
2 changed files with 75 additions and 18 deletions

View File

@ -516,6 +516,7 @@ proc makeNullMove*(self: var Chessboard) =
## search. The move can be undone via unmakeMove ## search. The move can be undone via unmakeMove
self.positions.add(self.position) self.positions.add(self.position)
self.position.sideToMove = self.position.sideToMove.opposite() self.position.sideToMove = self.position.sideToMove.opposite()
self.position.enPassantSquare = nullSquare()
self.position.fromNull = true self.position.fromNull = true
self.position.updateChecksAndPins() self.position.updateChecksAndPins()
self.position.hash() self.position.hash()

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
## Implementation of negamax with a/b pruning ## Search routines for Nimfish
import board import board
import movegen import movegen
import eval import eval
@ -27,9 +27,19 @@ import std/monotimes
import std/strformat import std/strformat
const const
# Constants to configure how aggressively
# NMP reduces the search depth
# Reduce search depth by at least this value
NMP_BASE_REDUCTION = 3
# Reduce search depth proportionally to the
# current depth divided by this value, plus
# the base reduction
NMP_DEPTH_REDUCTION {.used.} = 3
NUM_KILLERS* = 2 NUM_KILLERS* = 2
MAX_DEPTH* = 255 MAX_DEPTH* = 255
# Constants used during move ordering
MVV_LVA_MULTIPLIER = 10 MVV_LVA_MULTIPLIER = 10
PROMOTION_MULTIPLIER = 2 PROMOTION_MULTIPLIER = 2
# These offsets are used in the move # These offsets are used in the move
@ -120,8 +130,9 @@ proc storeHistoryScore(self: var SearchManager, sideToMove: PieceColor, move: Mo
## table ## table
# We use this formula to evenly spread the improvement the more we increase it while # We use this formula to evenly spread the improvement the more we increase it while
# keeping it constrained to a maximum value so it doesn't overflow # keeping it constrained to a maximum value so it doesn't overflow. It's also helpful
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(bonus) - abs(score.int32) * score div highestEval() # for when we'll eventually implement history malus and use negative bonuses
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(bonus) - abs(bonus.int32) * score div highestEval()
func isTactical(self: Move): bool = func isTactical(self: Move): bool =
@ -135,6 +146,9 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
## during move ordering ## during move ordering
let sideToMove = self.board.position.sideToMove let sideToMove = self.board.position.sideToMove
# Here we don't care that the move from the TT comes from a deeper search
# than ours, but we're interested in the previous best move so that we can
# search that first for our principal variation
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 TTMOVE_OFFSET return TTMOVE_OFFSET
@ -145,14 +159,15 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
if move.isTactical(): if move.isTactical():
if move.isCapture(): if move.isCapture():
# Implementation of MVVLVA: Most Valuable Victim Least Valuable Attacker # Implementation of MVVLVA: Most Valuable Victim Least Valuable Aggressor.
# We prioritize moves that capture the most valuable pieces, and as a # 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 # second goal we want to use our least valuable pieces to do so (this
# is why we multiply the score of the captured piece by 10, to give # is why we multiply the score of the captured piece by a constant, to give
# it priority) # it priority)
let capturedScore = MVV_LVA_MULTIPLIER * self.board.position.getPieceScore(move.targetSquare) let capturedScore = MVV_LVA_MULTIPLIER * self.board.position.getPieceScore(move.targetSquare)
result = capturedScore - self.board.position.getPieceScore(move.startSquare) result = capturedScore - self.board.position.getPieceScore(move.startSquare)
# If the capture is also a promotion we want to give it an even bigger bonus
if move.isPromotion(): if move.isPromotion():
var piece: Piece var piece: Piece
case move.getPromotionType(): case move.getPromotionType():
@ -336,7 +351,13 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
# In practice this should not be a problem # In practice this should not be a problem
return return
self.selectiveDepth = max(self.selectiveDepth, ply) self.selectiveDepth = max(self.selectiveDepth, ply)
if self.board.isDrawn():
return Score(0)
if depth <= 0:
# Quiescent search gain: 264.8 +/- 71.6
return self.qsearch(0, alpha, beta)
if ply > 0: if ply > 0:
# Probe the transposition table to see if we can cause an early cutoff
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:
case query.entry.flag: case query.entry.flag:
@ -348,11 +369,46 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
of UpperBound: of UpperBound:
if query.entry.score <= alpha: if query.entry.score <= alpha:
return query.entry.score return query.entry.score
if self.board.isDrawn(): if not isPV and depth > 2 and self.board.canNullMove() and self.board.position.evaluate() >= beta:
return Score(0) # Null move pruning: it is reasonable to assume that
if depth == 0: # it is always better to make a move than not to do
# Quiescent search gain: 264.8 +/- 71.6 # so (with some exceptions noted below). To take advantage
return self.qsearch(0, alpha, beta) # of this assumption, we bend the rules a little and perform
# a so-called "null move", basically passing our turn doing
# nothing, and then perform a shallower search for our opponent.
# If the shallow search fails high (i.e. produces a beta cutoff),
# then it is useless for us to search this position any further
# and we can just return the score outright. Since we only care about
# whether the opponent can beat beta and not the actual value, we
# can do a null window search and save some time, too. There are a
# few rules that need to be followed to use NMP properly, though: we
# must not be in check and we also must have not null-moved before
# (that's what board.canNullMove() is checking) and the static
# evaluation of the position needs to already be better than or
# equal to beta
let
friendlyPawns = self.board.position.getBitboard(Pawn, self.board.position.sideToMove)
friendlyKing = self.board.position.getBitboard(King, self.board.position.sideToMove)
friendlyPieces = self.board.position.getOccupancyFor(self.board.position.sideToMove)
if friendlyPieces != (friendlyKing or friendlyPawns):
# NMP is disabled in endgame positions where only kings
# and (friendly) pawns are left because those are the ones
# where it is most likely that the null move assumption will
# not hold true due to zugzwang (fancy engines do zugzwang
# verification, but I literally cba to do that)
self.board.makeNullMove()
# We perform a shallower search because otherwise there would be no point in
# doing NMP at all!
var reduction: int
when defined(NMP2):
# Reduce more based on depth
reduction = NMP_BASE_REDUCTION + depth div NMP_DEPTH_REDUCTION
else:
reduction = NMP_BASE_REDUCTION
let score = -self.search(depth - reduction, ply + 1, -beta + 1, -beta, isPV=false)
self.board.unmakeMove()
if score >= beta:
return score
var var
moves = newMoveList() moves = newMoveList()
depth = depth depth = depth
@ -373,7 +429,6 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
var alpha = alpha var alpha = alpha
let let
sideToMove = self.board.position.sideToMove sideToMove = self.board.position.sideToMove
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
@ -448,6 +503,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
break break
self.pvMoves[ply][i + 1] = pv self.pvMoves[ply][i + 1] = pv
self.pvMoves[ply][0] = move self.pvMoves[ply][0] = move
# Store the best move in the transposition table so we can find it later
let nodeType = if bestScore >= beta: LowerBound elif bestScore <= alpha: UpperBound else: Exact let nodeType = if bestScore >= beta: LowerBound elif bestScore <= alpha: UpperBound else: Exact
self.transpositionTable[].store(depth.uint8, bestScore, self.board.position.zobristKey, bestMove, nodeType) self.transpositionTable[].store(depth.uint8, bestScore, self.board.position.zobristKey, bestMove, nodeType)
@ -459,7 +515,7 @@ proc aspirationWindow(self: var SearchManager, score: Score, depth: int): Score
# alpha-beta bounds and widen them as needed (i.e. when the score # alpha-beta bounds and widen them as needed (i.e. when the score
# goes beyond the window) to increase the number of cutoffs # goes beyond the window) to increase the number of cutoffs
var var
delta = Score(20) delta = Score(25)
alpha = max(lowestEval(), score - delta) alpha = max(lowestEval(), score - delta)
beta = min(highestEval(), score + delta) beta = min(highestEval(), score + delta)
searchDepth = depth searchDepth = depth
@ -468,18 +524,18 @@ proc aspirationWindow(self: var SearchManager, score: Score, depth: int): Score
# Score is outside window bounds, widen the one that # Score is outside window bounds, widen the one that
# we got past to get a better result # we got past to get a better result
if score <= alpha: if score <= alpha:
beta = (alpha + beta) div 2 # beta = (alpha + beta) div 2
alpha = max(lowestEval(), alpha - delta) alpha = max(lowestEval(), alpha - delta)
searchDepth = depth # searchDepth = depth
elif score >= beta: elif score >= beta:
beta = min(highestEval(), beta + delta) beta = min(highestEval(), beta + delta)
if searchDepth > 1: # if searchDepth > 1:
searchDepth = searchDepth - 1 # searchDepth = searchDepth - 1
else: else:
# Value was within the alpha-beta bounds, we're done # Value was within the alpha-beta bounds, we're done
break break
# Try again with larger window # Try again with larger window
delta += delta div 2 delta += delta
# TODO: Tune this # TODO: Tune this
if delta >= Score(500): if delta >= Score(500):
# Window got too wide, give up and search with the full range # Window got too wide, give up and search with the full range