feat(search): add pvs search with quiescence and pruning
This commit is contained in:
1 parent
1f8e60c168
commit
2a9bf0c22f
1 file changed
+390
+390
@@ -0,0 +1,390 @@
|
|||||||
|
package gambit
|
||||||
|
|
||||||
|
final case class SearchResult(bestMove: Int, score: Int, depth: Int, nodes: Long, pv: List[Int])
|
||||||
|
|
||||||
|
final class Searcher:
|
||||||
|
import Searcher.*
|
||||||
|
|
||||||
|
val tt = new TranspositionTable()
|
||||||
|
var nodes: Long = 0L
|
||||||
|
var useTt: Boolean = true
|
||||||
|
var useNullMove: Boolean = true
|
||||||
|
var onIteration: (Int, Int, Long, Long, List[Int]) => Unit = (_, _, _, _, _) => ()
|
||||||
|
|
||||||
|
private val maxPly = 128
|
||||||
|
private val killers = Array.ofDim[Int](maxPly, 2)
|
||||||
|
private val historyTable = Array.ofDim[Int](2, 128 * 128)
|
||||||
|
private val pathHashes = new Array[Long](4096)
|
||||||
|
private val staticEvals = new Array[Int](maxPly + 4)
|
||||||
|
private var historyLen = 0
|
||||||
|
var useLmr: Boolean = true
|
||||||
|
var usePruning: Boolean = true
|
||||||
|
|
||||||
|
private var deadline = 0L
|
||||||
|
private var useTime = false
|
||||||
|
@volatile private var stopped = false
|
||||||
|
private var startNanos = 0L
|
||||||
|
|
||||||
|
def stop(): Unit = stopped = true
|
||||||
|
|
||||||
|
def setGameHistory(hashes: Array[Long], len: Int): Unit =
|
||||||
|
var i = 0
|
||||||
|
while i < len do
|
||||||
|
pathHashes(i) = hashes(i)
|
||||||
|
i += 1
|
||||||
|
historyLen = len
|
||||||
|
|
||||||
|
def wouldRepeat(pos: Position, ancestors: Array[Long]): Boolean =
|
||||||
|
setGameHistory(ancestors, ancestors.length)
|
||||||
|
pathHashes(historyLen) = pos.hash
|
||||||
|
isRepetition(pos, 0)
|
||||||
|
|
||||||
|
def search(pos: Position, maxDepth: Int, timeMs: Long): SearchResult =
|
||||||
|
newSearch()
|
||||||
|
useTime = timeMs > 0
|
||||||
|
startNanos = System.nanoTime()
|
||||||
|
deadline = startNanos + timeMs * 1000000L
|
||||||
|
val depthCap = math.min(maxDepth, maxPly - 1)
|
||||||
|
|
||||||
|
var bestMove = 0
|
||||||
|
var bestScore = 0
|
||||||
|
var pv: List[Int] = Nil
|
||||||
|
var completed = 0
|
||||||
|
var window = AspirationWindow
|
||||||
|
var depth = 1
|
||||||
|
while depth <= depthCap && !stopped do
|
||||||
|
val useAspiration = depth >= 4
|
||||||
|
val alpha0 = if useAspiration then bestScore - window else -Infinity
|
||||||
|
val beta0 = if useAspiration then bestScore + window else Infinity
|
||||||
|
var (score, move) = searchRoot(pos, depth, alpha0, beta0)
|
||||||
|
if !stopped && (score <= alpha0 || score >= beta0) then
|
||||||
|
val res = searchRoot(pos, depth, -Infinity, Infinity)
|
||||||
|
score = res._1
|
||||||
|
move = res._2
|
||||||
|
if !stopped then
|
||||||
|
bestMove = move
|
||||||
|
bestScore = score
|
||||||
|
completed = depth
|
||||||
|
pv = extractPv(pos, depth)
|
||||||
|
onIteration(depth, bestScore, nodes, elapsedMs(), pv)
|
||||||
|
depth += 1
|
||||||
|
SearchResult(bestMove, bestScore, completed, nodes, pv)
|
||||||
|
|
||||||
|
def bestMove(pos: Position, depth: Int): (Int, Int) =
|
||||||
|
historyLen = 0
|
||||||
|
val r = search(pos, depth, 0)
|
||||||
|
(r.bestMove, r.score)
|
||||||
|
|
||||||
|
private def newSearch(): Unit =
|
||||||
|
nodes = 0L
|
||||||
|
stopped = false
|
||||||
|
var p = 0
|
||||||
|
while p < maxPly do
|
||||||
|
killers(p)(0) = 0
|
||||||
|
killers(p)(1) = 0
|
||||||
|
p += 1
|
||||||
|
java.util.Arrays.fill(historyTable(0), 0)
|
||||||
|
java.util.Arrays.fill(historyTable(1), 0)
|
||||||
|
|
||||||
|
private def elapsedMs(): Long = (System.nanoTime() - startNanos) / 1000000L
|
||||||
|
|
||||||
|
private def searchRoot(pos: Position, depth: Int, alpha0: Int, beta: Int): (Int, Int) =
|
||||||
|
pathHashes(historyLen) = pos.hash
|
||||||
|
val moves = new MoveList()
|
||||||
|
MoveGen.generateLegal(pos, moves)
|
||||||
|
val ttMove = if useTt then tt.probeMove(pos.hash) else 0
|
||||||
|
orderMoves(pos, moves, ttMove, 0)
|
||||||
|
|
||||||
|
var alpha = alpha0
|
||||||
|
var best = -Infinity
|
||||||
|
var bestMove = if moves.size > 0 then moves(0) else 0
|
||||||
|
var i = 0
|
||||||
|
while i < moves.size && !stopped do
|
||||||
|
val m = moves(i)
|
||||||
|
val undo = pos.make(m)
|
||||||
|
var score =
|
||||||
|
if i == 0 then -negamax(pos, depth - 1, -beta, -alpha, 1)
|
||||||
|
else
|
||||||
|
val trial = -negamax(pos, depth - 1, -alpha - 1, -alpha, 1)
|
||||||
|
if !stopped && trial > alpha && trial < beta then -negamax(pos, depth - 1, -beta, -alpha, 1)
|
||||||
|
else trial
|
||||||
|
pos.unmake(m, undo)
|
||||||
|
if !stopped then
|
||||||
|
if score > best then
|
||||||
|
best = score
|
||||||
|
bestMove = m
|
||||||
|
if best > alpha then alpha = best
|
||||||
|
if alpha >= beta then i = moves.size
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if !stopped && useTt then
|
||||||
|
val flag = if best <= alpha0 then TT.Upper else if best >= beta then TT.Lower else TT.Exact
|
||||||
|
tt.store(pos.hash, depth, best, flag, bestMove, 0)
|
||||||
|
(best, bestMove)
|
||||||
|
|
||||||
|
private def negamax(pos: Position, depth0: Int, alpha0: Int, beta: Int, ply: Int): Int =
|
||||||
|
nodes += 1
|
||||||
|
if useTime && (nodes & 2047L) == 0L && System.nanoTime() >= deadline then stopped = true
|
||||||
|
if stopped then return 0
|
||||||
|
|
||||||
|
pathHashes(historyLen + ply) = pos.hash
|
||||||
|
if isRepetition(pos, ply) || Draw.isFiftyMove(pos) then return DrawScore
|
||||||
|
|
||||||
|
val pvNode = beta - alpha0 > 1
|
||||||
|
val side = pos.sideToMove
|
||||||
|
val inChk = pos.inCheck(side)
|
||||||
|
val depth = if inChk && ply < maxPly - 1 then depth0 + 1 else depth0
|
||||||
|
|
||||||
|
val ttScore = if useTt then tt.probe(pos.hash, depth, alpha0, beta, ply) else TT.Unknown
|
||||||
|
val ttMove = if useTt then tt.hitMove else 0
|
||||||
|
if ttScore != TT.Unknown && !pvNode then return ttScore
|
||||||
|
|
||||||
|
if depth <= 0 then return quiescence(pos, alpha0, beta, ply)
|
||||||
|
|
||||||
|
val notMateBound = beta < TT.MateBound && beta > -TT.MateBound
|
||||||
|
val staticEval = if inChk then -Infinity else Eval.evaluate(pos)
|
||||||
|
staticEvals(ply) = staticEval
|
||||||
|
val improving = !inChk && ply >= 2 && staticEval > staticEvals(ply - 2)
|
||||||
|
|
||||||
|
if usePruning && !pvNode && !inChk && notMateBound then
|
||||||
|
if depth <= SearchParams.reverseFutilityDepth
|
||||||
|
&& staticEval - SearchParams.reverseFutilityMargin * depth >= beta
|
||||||
|
then return staticEval - SearchParams.reverseFutilityMargin * depth
|
||||||
|
|
||||||
|
if depth <= SearchParams.razorDepth
|
||||||
|
&& staticEval + SearchParams.razorMargin(depth) <= alpha0
|
||||||
|
then
|
||||||
|
val razorScore = quiescence(pos, alpha0, alpha0 + 1, ply)
|
||||||
|
if razorScore <= alpha0 then return razorScore
|
||||||
|
|
||||||
|
if useNullMove && !pvNode && !inChk && depth >= 3 && notMateBound
|
||||||
|
&& staticEval >= beta && hasNonPawnMaterial(pos, side)
|
||||||
|
then
|
||||||
|
val reduction = 2 + depth / 6
|
||||||
|
val nullState = makeNull(pos)
|
||||||
|
val nullScore = -negamax(pos, depth - 1 - reduction, -beta, -beta + 1, ply + 1)
|
||||||
|
unmakeNull(pos, nullState)
|
||||||
|
if stopped then return 0
|
||||||
|
if nullScore >= beta then return beta
|
||||||
|
|
||||||
|
val moves = new MoveList()
|
||||||
|
MoveGen.generateLegal(pos, moves)
|
||||||
|
if moves.size == 0 then
|
||||||
|
if inChk then return -MateValue + ply else return 0
|
||||||
|
|
||||||
|
orderMoves(pos, moves, ttMove, ply)
|
||||||
|
val futilityBase = staticEval + SearchParams.futilityMargin * depth
|
||||||
|
var alpha = alpha0
|
||||||
|
var best = -Infinity
|
||||||
|
var bestMove = 0
|
||||||
|
var cutoff = false
|
||||||
|
var searched = 0
|
||||||
|
var i = 0
|
||||||
|
while i < moves.size && !cutoff do
|
||||||
|
val m = moves(i)
|
||||||
|
val quiet = !Move.isCapture(m) && !Move.isPromotion(m)
|
||||||
|
val undo = pos.make(m)
|
||||||
|
val givesCheck = pos.inCheck(pos.sideToMove)
|
||||||
|
|
||||||
|
val prune =
|
||||||
|
usePruning && !pvNode && !inChk && quiet && !givesCheck && searched > 0 && best > -TT.MateBound &&
|
||||||
|
((depth <= SearchParams.lmpDepth && i >= SearchParams.lmpBase(if improving then 1 else 0)(depth))
|
||||||
|
|| (depth <= SearchParams.futilityDepth && futilityBase <= alpha))
|
||||||
|
|
||||||
|
if prune then pos.unmake(m, undo)
|
||||||
|
else
|
||||||
|
val reduction =
|
||||||
|
if useLmr && depth >= SearchParams.lmrMinDepth && searched >= SearchParams.lmrMinIndex
|
||||||
|
&& quiet && !inChk && !givesCheck
|
||||||
|
then
|
||||||
|
var r = SearchParams.lmrReduction(depth, searched)
|
||||||
|
if !pvNode then r += 1
|
||||||
|
if m == killers(if ply < maxPly then ply else maxPly - 1)(0) then r -= 1
|
||||||
|
math.max(0, math.min(r, depth - 2))
|
||||||
|
else 0
|
||||||
|
|
||||||
|
val score =
|
||||||
|
if searched == 0 then -negamax(pos, depth - 1, -beta, -alpha, ply + 1)
|
||||||
|
else
|
||||||
|
var trial = -negamax(pos, depth - 1 - reduction, -alpha - 1, -alpha, ply + 1)
|
||||||
|
if !stopped && reduction > 0 && trial > alpha then
|
||||||
|
trial = -negamax(pos, depth - 1, -alpha - 1, -alpha, ply + 1)
|
||||||
|
if !stopped && trial > alpha && trial < beta then
|
||||||
|
trial = -negamax(pos, depth - 1, -beta, -alpha, ply + 1)
|
||||||
|
trial
|
||||||
|
pos.unmake(m, undo)
|
||||||
|
searched += 1
|
||||||
|
if stopped then return 0
|
||||||
|
if score > best then
|
||||||
|
best = score
|
||||||
|
bestMove = m
|
||||||
|
if best > alpha then alpha = best
|
||||||
|
if alpha >= beta then
|
||||||
|
cutoff = true
|
||||||
|
if quiet then
|
||||||
|
recordKiller(ply, m)
|
||||||
|
recordHistory(side, m, depth)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if searched == 0 then
|
||||||
|
if inChk then return -MateValue + ply else return DrawScore
|
||||||
|
|
||||||
|
if useTt then
|
||||||
|
val flag = if best <= alpha0 then TT.Upper else if cutoff then TT.Lower else TT.Exact
|
||||||
|
tt.store(pos.hash, depth, best, flag, bestMove, ply)
|
||||||
|
best
|
||||||
|
|
||||||
|
private def makeNull(pos: Position): (Int, Long) =
|
||||||
|
val savedEp = pos.epSquare
|
||||||
|
val savedHash = pos.hash
|
||||||
|
if savedEp >= 0 then pos.hash ^= Zobrist.epFileKeys(Square.fileOf(savedEp))
|
||||||
|
pos.epSquare = -1
|
||||||
|
pos.sideToMove = pos.sideToMove ^ 1
|
||||||
|
pos.hash ^= Zobrist.sideKey
|
||||||
|
(savedEp, savedHash)
|
||||||
|
|
||||||
|
private def unmakeNull(pos: Position, state: (Int, Long)): Unit =
|
||||||
|
pos.sideToMove = pos.sideToMove ^ 1
|
||||||
|
pos.epSquare = state._1
|
||||||
|
pos.hash = state._2
|
||||||
|
|
||||||
|
private def hasNonPawnMaterial(pos: Position, color: Int): Boolean =
|
||||||
|
val board = pos.board
|
||||||
|
var sq = 0
|
||||||
|
while sq < 128 do
|
||||||
|
if Square.onBoard(sq) then
|
||||||
|
val p = board(sq)
|
||||||
|
if p != Piece.Empty && (p >> 3) == color then
|
||||||
|
val pt = p & 7
|
||||||
|
if pt == Piece.Knight || pt == Piece.Bishop || pt == Piece.Rook || pt == Piece.Queen then
|
||||||
|
return true
|
||||||
|
sq += 1
|
||||||
|
false
|
||||||
|
|
||||||
|
private def quiescence(pos: Position, alpha0: Int, beta: Int): Int =
|
||||||
|
nodes += 1
|
||||||
|
val standPat = Eval.evaluate(pos)
|
||||||
|
if standPat >= beta then return beta
|
||||||
|
var alpha = math.max(alpha0, standPat)
|
||||||
|
|
||||||
|
val moves = new MoveList()
|
||||||
|
MoveGen.generatePseudo(pos, moves, capturesOnly = true)
|
||||||
|
orderCaptures(pos, moves)
|
||||||
|
val mover = pos.sideToMove
|
||||||
|
var i = 0
|
||||||
|
while i < moves.size do
|
||||||
|
val m = moves(i)
|
||||||
|
val undo = pos.make(m)
|
||||||
|
if pos.isAttacked(pos.kingSquare(mover), pos.sideToMove) then pos.unmake(m, undo)
|
||||||
|
else
|
||||||
|
val score = -quiescence(pos, -beta, -alpha)
|
||||||
|
pos.unmake(m, undo)
|
||||||
|
if score >= beta then return beta
|
||||||
|
if score > alpha then alpha = score
|
||||||
|
i += 1
|
||||||
|
alpha
|
||||||
|
|
||||||
|
private def isRepetition(pos: Position, ply: Int): Boolean =
|
||||||
|
val current = historyLen + ply
|
||||||
|
val stop = math.max(0, current - pos.halfmove)
|
||||||
|
var i = current - 2
|
||||||
|
while i >= stop do
|
||||||
|
if pathHashes(i) == pos.hash then return true
|
||||||
|
i -= 2
|
||||||
|
false
|
||||||
|
|
||||||
|
private def recordKiller(ply: Int, m: Int): Unit =
|
||||||
|
if ply < maxPly && killers(ply)(0) != m then
|
||||||
|
killers(ply)(1) = killers(ply)(0)
|
||||||
|
killers(ply)(0) = m
|
||||||
|
|
||||||
|
private def recordHistory(side: Int, m: Int, depth: Int): Unit =
|
||||||
|
historyTable(side)(Move.from(m) * 128 + Move.to(m)) += depth * depth
|
||||||
|
|
||||||
|
private def orderMoves(pos: Position, moves: MoveList, ttMove: Int, ply: Int): Unit =
|
||||||
|
val n = moves.size
|
||||||
|
val scores = new Array[Int](n)
|
||||||
|
val side = pos.sideToMove
|
||||||
|
val kply = if ply < maxPly then ply else maxPly - 1
|
||||||
|
val k0 = killers(kply)(0)
|
||||||
|
val k1 = killers(kply)(1)
|
||||||
|
var i = 0
|
||||||
|
while i < n do
|
||||||
|
val m = moves(i)
|
||||||
|
scores(i) =
|
||||||
|
if m == ttMove && ttMove != 0 then 2000000
|
||||||
|
else if Move.isCapture(m) then 1000000 + mvvLva(pos, m)
|
||||||
|
else if Move.isPromotion(m) then 900000 + Eval.pieceValue(Move.promo(m))
|
||||||
|
else if m == k0 && k0 != 0 then 800000
|
||||||
|
else if m == k1 && k1 != 0 then 700000
|
||||||
|
else historyTable(side)(Move.from(m) * 128 + Move.to(m))
|
||||||
|
i += 1
|
||||||
|
sortByScores(moves, scores)
|
||||||
|
|
||||||
|
private def orderCaptures(pos: Position, moves: MoveList): Unit =
|
||||||
|
val n = moves.size
|
||||||
|
val scores = new Array[Int](n)
|
||||||
|
var i = 0
|
||||||
|
while i < n do
|
||||||
|
val m = moves(i)
|
||||||
|
scores(i) =
|
||||||
|
if Move.isCapture(m) then mvvLva(pos, m)
|
||||||
|
else if Move.isPromotion(m) then Eval.pieceValue(Move.promo(m))
|
||||||
|
else 0
|
||||||
|
i += 1
|
||||||
|
sortByScores(moves, scores)
|
||||||
|
|
||||||
|
private def sortByScores(moves: MoveList, scores: Array[Int]): Unit =
|
||||||
|
val n = moves.size
|
||||||
|
var i = 0
|
||||||
|
while i < n do
|
||||||
|
var bestIdx = i
|
||||||
|
var j = i + 1
|
||||||
|
while j < n do
|
||||||
|
if scores(j) > scores(bestIdx) then bestIdx = j
|
||||||
|
j += 1
|
||||||
|
if bestIdx != i then
|
||||||
|
val tmpMove = moves(i)
|
||||||
|
moves(i) = moves(bestIdx)
|
||||||
|
moves(bestIdx) = tmpMove
|
||||||
|
val tmpScore = scores(i)
|
||||||
|
scores(i) = scores(bestIdx)
|
||||||
|
scores(bestIdx) = tmpScore
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
private def mvvLva(pos: Position, m: Int): Int =
|
||||||
|
val victim = if Move.isEnPassant(m) then Piece.Pawn else Piece.typeOf(pos.board(Move.to(m)))
|
||||||
|
val attacker = Piece.typeOf(pos.board(Move.from(m)))
|
||||||
|
Eval.pieceValue(victim) * 16 - Eval.pieceValue(attacker)
|
||||||
|
|
||||||
|
private def extractPv(pos: Position, maxLen: Int): List[Int] =
|
||||||
|
val out = List.newBuilder[Int]
|
||||||
|
val trail = scala.collection.mutable.ArrayDeque[(Int, Undo)]()
|
||||||
|
var steps = 0
|
||||||
|
var continue = true
|
||||||
|
while continue && steps < maxLen do
|
||||||
|
val m = tt.probeMove(pos.hash)
|
||||||
|
if m == 0 || !isLegal(pos, m) then continue = false
|
||||||
|
else
|
||||||
|
out += m
|
||||||
|
trail.append((m, pos.make(m)))
|
||||||
|
steps += 1
|
||||||
|
while trail.nonEmpty do
|
||||||
|
val (m, u) = trail.removeLast()
|
||||||
|
pos.unmake(m, u)
|
||||||
|
out.result()
|
||||||
|
|
||||||
|
private def isLegal(pos: Position, m: Int): Boolean =
|
||||||
|
val legal = new MoveList()
|
||||||
|
MoveGen.generateLegal(pos, legal)
|
||||||
|
var i = 0
|
||||||
|
while i < legal.size do
|
||||||
|
if legal(i) == m then return true
|
||||||
|
i += 1
|
||||||
|
false
|
||||||
|
|
||||||
|
object Searcher:
|
||||||
|
val MateValue = 30000
|
||||||
|
val Infinity = 1000000
|
||||||
|
val DrawScore = 0
|
||||||
|
val AspirationWindow = 25
|
||||||
Reference in new issue
Block a user