Implement short-circuiting, iterative SEE (bench 5697813)

This commit is contained in:
2025-01-20 21:05:13 +01:00
parent 885ab69740
commit 8fdf99e07a
6 changed files with 151 additions and 158 deletions

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "networks"]
path = networks
url = git@git.nocturn9x.space/heimdall-engine/networks
url = git@git.nocturn9x.space:heimdall-engine/networks

View File

@@ -87,6 +87,18 @@ func highestSquare*(self: Bitboard): Square {.inline.} =
result = Square(self.countLeadingZeroBits().uint8 xor 0x3f)
func lowestBit*(self: Bitboard): Bitboard {.inline.} =
## Returns the least significant bit of the bitboard
result = self and Bitboard(-cast[int64](self))
func resetLSB*(self: Bitboard): Bitboard {.inline.} =
## Resets the least significant bit of the given
## bitboard (only makes sense if used with popLSB
## earlier)
result = self and Bitboard(-cast[int64](self - 1))
func getFileMask*(file: int): Bitboard {.inline.} = Bitboard(0x101010101010101'u64) shl file.uint64
func getRankMask*(rank: int): Bitboard {.inline.} = Bitboard(0xff) shl uint64(8 * rank)
func toBitboard*(square: SomeInteger): Bitboard {.inline.} = Bitboard(1'u64) shl square.uint64

View File

@@ -26,7 +26,6 @@ import heimdall/pieces
import heimdall/moves
import heimdall/position
import heimdall/rays
import heimdall/see
import heimdall/datagen/marlinformat
@@ -638,34 +637,6 @@ const drawnFens = [("4k3/2b5/8/8/8/5B2/8/4K3 w - - 0 1", false), # KBvKB (curr
]
const seeFens = [("4R3/2r3p1/5bk1/1p1r3p/p2PR1P1/P1BK1P2/1P6/8 b - - 0 1", createMove("h5", "g4", Capture), 0),
("4R3/2r3p1/5bk1/1p1r1p1p/p2PR1P1/P1BK1P2/1P6/8 b - - 0 1", createMove("h5", "g4", Capture), 0),
("4r1k1/5pp1/nbp4p/1p2p2q/1P2P1b1/1BP2N1P/1B2QPPK/3R4 b - - 0 1", createMove("g4", "f3", Capture), Knight.getStaticPieceScore() - Bishop.getStaticPieceScore()),
("2r1r1k1/pp1bppbp/3p1np1/q3P3/2P2P2/1P2B3/P1N1B1PP/2RQ1RK1 b - - 0 1", createMove("d6", "e5", Capture) , Pawn.getStaticPieceScore()),
("7r/5qpk/p1Qp1b1p/3r3n/BB3p2/5p2/P1P2P2/4RK1R w - - 0 1", createMove("e1", "e8"), 0),
("6rr/6pk/p1Qp1b1p/2n5/1B3p2/5p2/P1P2P2/4RK1R w - - 0 1", createMove("e1", "e8"), -Rook.getStaticPieceScore()),
("7r/5qpk/2Qp1b1p/1N1r3n/BB3p2/5p2/P1P2P2/4RK1R w - - 0 1", createMove("e1", "e8"), -Rook.getStaticPieceScore()),
("6RR/4bP2/8/8/5r2/3K4/5p2/4k3 w - - 0 1", createMove("f7", "f8", PromoteToQueen), Bishop.getStaticPieceScore() - Pawn.getStaticPieceScore()),
("6RR/4bP2/8/8/5r2/3K4/5p2/4k3 w - - 0 1", createMove("f7", "f8", PromoteToKnight), Knight.getStaticPieceScore() - Pawn.getStaticPieceScore()),
("7R/4bP2/8/8/1q6/3K4/5p2/4k3 w - - 0 1", createMove("f7", "f8", PromoteToRook), -Pawn.getStaticPieceScore()),
("8/4kp2/2npp3/1Nn5/1p2PQP1/7q/1PP1B3/4KR1r b - - 0 1", createMove("h1", "f1", Capture), 0),
("8/4kp2/2npp3/1Nn5/1p2P1P1/7q/1PP1B3/4KR1r b - - 0 1", createMove("h1", "f1", Capture), 0),
("2r2r1k/6bp/p7/2q2p1Q/3PpP2/1B6/P5PP/2RR3K b - - 0 1", createMove("c5", "c1", Capture), 2 * Rook.getStaticPieceScore() - Queen.getStaticPieceScore()),
("r2qk1nr/pp2ppbp/2b3p1/2p1p3/8/2N2N2/PPPP1PPP/R1BQR1K1 w qk - 0 1", createMove("f3", "e5", Capture), Pawn.getStaticPieceScore()),
("6r1/4kq2/b2p1p2/p1pPb3/p1P2B1Q/2P4P/2B1R1P1/6K1 w - - 0 1", createMove("f4", "e5", Capture), 0),
("3q2nk/pb1r1p2/np6/3P2Pp/2p1P3/2R4B/PQ3P1P/3R2K1 w - h6 0 1", createMove("g5", "h6", EnPassant), 0),
("3q2nk/pb1r1p2/np6/3P2Pp/2p1P3/2R1B2B/PQ3P1P/3R2K1 w - h6 0 1", createMove("g5", "h6", EnPassant), Pawn.getStaticPieceScore()),
("2r4r/1P4pk/p2p1b1p/7n/BB3p2/2R2p2/P1P2P2/4RK2 w - - 0 1", createMove("c3", "c8", Capture), Rook.getStaticPieceScore()),
("2r5/1P4pk/p2p1b1p/5b1n/BB3p2/2R2p2/P1P2P2/4RK2 w - - 0 1", createMove("c3", "c8", Capture), Rook.getStaticPieceScore()),
("2r4k/2r4p/p7/2b2p1b/4pP2/1BR5/P1R3PP/2Q4K w - - 0 1", createMove("c3", "c5", Capture), Bishop.getStaticPieceScore()),
("8/pp6/2pkp3/4bp2/2R3b1/2P5/PP4B1/1K6 w - - 0 1", createMove("g2", "c6", Capture), Pawn.getStaticPieceScore() - Bishop.getStaticPieceScore()),
("4q3/1p1pr1k1/1B2rp2/6p1/p3PP2/P3R1P1/1P2R1K1/4Q3 b - - 0 1", createMove("e6", "e4", Capture), Pawn.getStaticPieceScore()-Rook.getStaticPieceScore()),
("4q3/1p1pr1kb/1B2rp2/6p1/p3PP2/P3R1P1/1P2R1K1/4Q3 b - - 0 1", createMove("h7", "e4", Capture), Pawn.getStaticPieceScore()),
("r1q1r1k1/pb1nppbp/1p3np1/1Pp1N3/3pNP2/B2P2PP/P3P1B1/2R1QRK1 w - c6 0 11", createMove("b5", "c6", EnPassant), Pawn.getStaticPieceScore()),
("r3k2r/p1ppqpb1/Bn2pnp1/3PN3/1p2P3/2N2Q2/PPPB1PpP/R3K2R w QKqk - 0 2", createMove("a6", "f1"), Pawn.getStaticPieceScore() - Bishop.getStaticPieceScore())
]
proc basicTests* =
# Test the FEN parser
@@ -693,11 +664,6 @@ proc basicTests* =
for (fen, isDrawn) in drawnFens:
doAssert newChessboardFromFEN(fen).isInsufficientMaterial() == isDrawn, &"draw check failed for {fen} (expected {isDrawn})"
# Test SEE scores
for (fen, move, expected) in seeFens:
let res = loadFEN(fen).see(move)
doAssert res == expected, &"SEE test failed for {fen} ({move}): expected {expected}, got {res}"
var board = newDefaultChessboard()
# Ensure correct number of pieces
testPieceCount(board, Pawn, White, 8)

View File

@@ -95,6 +95,7 @@ func getMaterial*(self: Position): int {.inline.} =
self.getBitboard(Rook).countSquares() * 5 +
self.getBitboard(Queen).countSquares() * 9
func getOccupancyFor*(self: Position, color: PieceColor): Bitboard {.inline.} =
## Get the occupancy bitboard for every piece of the given color
result = self.colors[color]
@@ -111,6 +112,12 @@ proc getPawnAttackers*(self: Position, square: Square, attacker: PieceColor): Bi
return self.getBitboard(Pawn, attacker) and getPawnAttackers(attacker, square)
proc getPawnAttackers*(self: Position, square: Square, attacker: PieceColor, occupancy: Bitboard): Bitboard {.inline.} =
## Returns the locations of the pawns attacking the given square
## with the given occupancy
return (self.getBitboard(Pawn, attacker) and occupancy) and getPawnAttackers(attacker, square)
proc getKingAttacker*(self: Position, square: Square, attacker: PieceColor): Bitboard {.inline.} =
## Returns the location of the king if it is attacking the given square
result = Bitboard(0)
@@ -124,18 +131,34 @@ proc getKingAttacker*(self: Position, square: Square, attacker: PieceColor): Bit
return king
proc getKingAttacker*(self: Position, square: Square, attacker: PieceColor, occupancy: Bitboard): Bitboard {.inline.} =
## Returns the location of the king if it is attacking the given square
## given the provided occupancy
result = Bitboard(0)
let king = self.getBitboard(King, attacker) and occupancy
if king == 0:
# The king is not included in the occupancy
return
if (getKingMoves(king.toSquare()) and square.toBitboard()) != 0:
return king
func getKnightAttackers*(self: Position, square: Square, attacker: PieceColor): Bitboard {.inline.} =
## Returns the locations of the knights attacking the given square
return getKnightMoves(square) and self.getBitboard(Knight, attacker)
return getKnightMoves(square) and self.getBitboard(Knight, attacker)
proc getSlidingAttackers*(self: Position, square: Square, attacker: PieceColor): Bitboard {.inline.} =
func getKnightAttackers*(self: Position, square: Square, attacker: PieceColor, occupancy: Bitboard): Bitboard {.inline.} =
## Returns the locations of the knights attacking the given square
return getKnightMoves(square) and (self.getBitboard(Knight) and occupancy)
proc getSlidingAttackers*(self: Position, square: Square, attacker: PieceColor, occupancy: Bitboard): Bitboard {.inline.} =
## Returns the locations of the sliding pieces attacking the given square
let
queens = self.getBitboard(Queen, attacker)
rooks = self.getBitboard(Rook, attacker) or queens
bishops = self.getBitboard(Bishop, attacker) or queens
occupancy = self.getOccupancy()
result = getBishopMoves(square, occupancy) and (bishops or queens)
result = result or getRookMoves(square, occupancy) and (rooks or queens)
@@ -147,7 +170,26 @@ proc getAttackersTo*(self: Position, square: Square, attacker: PieceColor): Bitb
result = self.getPawnAttackers(square, attacker)
result = result or self.getKingAttacker(square, attacker)
result = result or self.getKnightAttackers(square, attacker)
result = result or self.getSlidingAttackers(square, attacker)
result = result or self.getSlidingAttackers(square, attacker, self.getOccupancy())
proc getAttackersTo*(self: Position, square: Square, attacker: PieceColor, occupancy: Bitboard): Bitboard {.inline.} =
## Computes the attackers bitboard for the given square from
## the given side using the provided occupancy bitboard instead
## of the one in the position
result = self.getPawnAttackers(square, attacker, occupancy)
result = result or self.getKingAttacker(square, attacker, occupancy)
result = result or self.getKnightAttackers(square, attacker, occupancy)
result = result or self.getSlidingAttackers(square, attacker, occupancy)
proc getAttackersTo*(self: Position, square: Square, occupancy: Bitboard): Bitboard {.inline.} =
## Computes the attackers bitboard for the given square for both
## sides using the provided occupancy bitboard
result = self.getPawnAttackers(square, White, occupancy) or self.getPawnAttackers(square, Black, occupancy)
result = result or self.getKingAttacker(square, White, occupancy) or self.getKingAttacker(square, Black, occupancy)
result = result or self.getKnightAttackers(square, White, occupancy) or self.getKnightAttackers(square, Black, occupancy)
result = result or self.getSlidingAttackers(square, White, occupancy) or self.getSlidingAttackers(square, Black, occupancy)
proc isOccupancyAttacked*(self: Position, square: Square, occupancy: Bitboard): bool {.inline.} =
@@ -167,12 +209,12 @@ proc isOccupancyAttacked*(self: Position, square: Square, occupancy: Bitboard):
nonSideToMove = self.sideToMove.opposite()
knights = self.getBitboard(Knight, nonSideToMove)
if (getKnightMoves(square) and knights) != 0:
if (getKnightMoves(square) and knights and occupancy) != 0:
return true
let king = self.getBitboard(King, nonSideToMove)
if (getKingMoves(square) and king) != 0:
if (getKingMoves(square) and king and occupancy) != 0:
return true
let
@@ -187,7 +229,7 @@ proc isOccupancyAttacked*(self: Position, square: Square, occupancy: Bitboard):
if (getRookMoves(square, occupancy) and rooks) != 0:
return true
if self.getPawnAttackers(square, nonSideToMove) != 0:
if self.getPawnAttackers(square, nonSideToMove, occupancy) != 0:
return true

View File

@@ -324,13 +324,12 @@ proc getEstimatedMoveScore(self: SearchManager, hashMove: Move, move: Move, ply:
# Good/bad tacticals
if move.isTactical():
let seeScore = self.board.positions[^1].see(move)
# Prioritize good exchanges (see > 0)
result += seeScore
let winning = self.board.positions[^1].see(move, 1)
if move.isCapture():
# Add capthist score
result += self.getHistoryScore(sideToMove, move)
if seeScore < 0:
if not winning:
# Prioritize good exchanges (see > 0)
if move.isCapture(): # TODO: En passant!
# Prioritize attacking our opponent's
# most valuable pieces
@@ -692,13 +691,13 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
alpha = max(alpha, staticEval)
bestMove = hashMove
for move in self.pickMoves(hashMove, ply, qsearch=true):
let seeScore = self.board.position.see(move)
let winning = self.board.position.see(move, 1)
# Skip bad captures (gains 52.9 +/- 25.2)
if seeScore < 0:
if not winning:
continue
# Qsearch futility pruning: similar to FP in regular search, but we skip moves
# that gain no material instead of just moves that don't improve alpha
if not self.board.inCheck() and staticEval + self.parameters.qsearchFpEvalMargin <= alpha and seeScore < 1:
if not self.board.inCheck() and staticEval + self.parameters.qsearchFpEvalMargin <= alpha and not winning:
continue
let kingSq = self.board.getBitboard(King, self.board.sideToMove).toSquare()
self.stack[ply].move = move
@@ -979,9 +978,8 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
continue
if not root and isNotMated and depth <= self.parameters.seePruningMaxDepth and (move.isQuiet() or move.isCapture() or move.isEnPassant()):
# SEE pruning: prune moves with a bad SEE score
let seeScore = self.board.positions[^1].see(move)
let margin = -depth * (if move.isQuiet(): self.parameters.seePruningQuietMargin else: self.parameters.seePruningCaptureMargin)
if seeScore < margin:
if not self.board.positions[^1].see(move, margin):
inc(i)
continue
var singular = 0

View File

@@ -13,14 +13,14 @@
# limitations under the License.
## Implementation of Static Exchange Evaluation
import std/algorithm
import heimdall/position
import heimdall/pieces
import heimdall/board
const PIECE_SCORES: array[PieceKind.Pawn..PieceKind.King, int] = [100, 450, 450, 650, 1250, 100000]
func getStaticPieceScore*(kind: PieceKind): int =
## Returns a static score for the given piece
@@ -28,23 +28,7 @@ func getStaticPieceScore*(kind: PieceKind): int =
## as well as general usage of SEE much more
## sane, because if SEE(move) == 0 then we know
## the capture sequence is balanced
case kind:
of Pawn:
return 100
of Knight:
return 450
of Bishop:
return 450
of Rook:
return 650
of Queen:
return 1250
of King:
# The king has a REALLY large value so
# that capturing it is always losing
return 100000
else:
return 0
return PIECE_SCORES[kind]
func getStaticPieceScore*(piece: Piece): int {.inline.} =
@@ -56,99 +40,90 @@ func getStaticPieceScore*(piece: Piece): int {.inline.} =
return piece.kind.getStaticPieceScore()
func pickLeastValuableAttacker(position: Position, attackers: Bitboard): Square {.inline.} =
## Returns the square in the given position containing the lowest
## value piece in the given attackers bitboard
if attackers == 0:
return nullSquare()
var attacks: array[16, tuple[score: int, square: Square]]
var count = 0
for i, attacker in attackers:
attacks[i] = (position.getPiece(attacker).getStaticPieceScore(), attacker)
inc(count)
proc orderer(a, b: tuple[score: int, square: Square]): int {.closure.} =
return cmp(a.score, b.score)
attacks.toOpenArray(0, count - 1).sort(orderer)
return attacks[0].square
proc see(position: var Position, square: Square): int =
## Recursive implementation of static exchange evaluation
let sideToMove = position.sideToMove
let attackers = position.getAttackersTo(square, sideToMove)
if attackers == 0:
func gain(position: Position, move: Move): int =
## Returns how much a single move gains in terms
## of static material value
if move.isCastling():
return 0
if move.isEnPassant():
return getStaticPieceScore(Pawn)
result = getStaticPieceScore(position.getPiece(move.targetSquare))
if move.isPromotion():
result += getStaticPieceScore(move.getPromotionType().promotionToPiece()) - getStaticPieceScore(Pawn)
func popLeastValuable(position: Position, occupancy: var Bitboard, attackers: Bitboard, stm: PieceColor): PieceKind =
## Returns the piece type in the given position containing the lowest
## value victim in the given attackers bitboard
for kind in PieceKind.all():
let board = attackers and position.getBitboard(kind)
if board != 0:
occupancy = occupancy xor board.lowestBit()
return kind
return PieceKind.Empty
proc see*(position: Position, move: Move, threshold: int): bool =
## Statically evaluates a sequence of exchanges
## starting from the given one and returns whether
## the exchange can beat the given (positive!) threshold.
## A sequence of moves leading to a losing capture (score < 0)
## will short-circuit and return false regardless of the value
## of the threshold
# Yoinked from Stormphrax
var score = gain(position, move) - threshold
if score < 0:
return false
var next = if move.isPromotion(): move.getPromotionType().promotionToPiece() else: position.getPiece(move.startSquare).kind
score -= next.getStaticPieceScore()
if score > 0:
return true
let
attacker = position.pickLeastValuableAttacker(attackers)
attackerPiece = position.getPiece(attacker)
queens = position.getBitboard(Queen)
bishops = queens or position.getBitboard(Bishop)
rooks = queens or position.getBitboard(Rook)
var
victimPiece = position.getPiece(square)
victim = victimPiece.getStaticPieceScore()
occupancy = position.getOccupancy() xor move.startSquare.toBitboard() xor move.targetSquare.toBitboard()
stm = position.sideToMove.opposite()
attackers = position.getAttackersTo(move.targetSquare, occupancy)
if victimPiece != nullPiece():
position.removePiece(square)
position.movePiece(attacker, square)
# En passant capture
if attackerPiece.kind == Pawn and square == position.enPassantSquare:
let
epTarget = position.enPassantSquare.toBitboard()
epPawn = epTarget.backwardRelativeTo(sideToMove).toSquare()
if position.getPiece(epPawn) != nullPiece():
victimPiece = position.getPiece(epPawn)
victim = victimPiece.getStaticPieceScore()
position.removePiece(epPawn)
while true:
let friendlyAttackers = attackers and position.getOccupancyFor(stm)
# Capture with promotion
if attackerPiece.kind == Pawn and getRankMask(rankFromSquare(square)) == attackerPiece.color.getEighthRank():
# SEE is meant to simulate the best possible sequence of moves, so we always
# promote to a queen
position.removePiece(square)
position.spawnPiece(square, Piece(kind: Queen, color: sideToMove))
result = Queen.getStaticPieceScore() - Pawn.getStaticPieceScore()
position.sideToMove = position.sideToMove.opposite()
# We don't want to lose material, so the maximum score is
# zero
result = max(0, result + victim - position.see(square))
proc see*(position: Position, move: Move): int =
## Statically evaluates a sequence of exchanges
## starting from the given one
var position = position
var capturedPiece = position.getPiece(move.targetSquare)
if move.isCapture():
position.removePiece(move.targetSquare)
if move.isEnPassant():
let
epTarget = position.enPassantSquare.toBitboard()
epPawn = epTarget.backwardRelativeTo(position.sideToMove).toSquare()
capturedPiece = Piece(kind: Pawn, color: position.sideToMove.opposite())
position.removePiece(epPawn)
if move.isPromotion():
position.removePiece(move.startSquare)
var promoted = Piece(color: position.sideToMove)
case move.getPromotionType():
of PromoteToKnight:
promoted.kind = Knight
of PromoteToBishop:
promoted.kind = Bishop
of PromoteToRook:
promoted.kind = Rook
of PromoteToQueen:
promoted.kind = Queen
else:
discard # Unreachable
if friendlyAttackers == 0:
break
position.spawnPiece(move.targetSquare, promoted)
result += promoted.getStaticPieceScore() - Pawn.getStaticPieceScore()
if position.getPiece(move.targetSquare) == nullPiece():
position.movePiece(move.startSquare, move.targetSquare)
position.sideToMove = position.sideToMove.opposite()
result += capturedPiece.getStaticPieceScore() - position.see(move.targetSquare)
next = position.popLeastValuable(occupancy, friendlyAttackers, stm)
# Diagonal/orthogonal captures can add new diagonal/orthogonal attackers,
# so handle this
if next in [PieceKind.Pawn, PieceKind.Queen, PieceKind.Bishop]:
attackers = attackers or (getBishopMoves(move.targetSquare, occupancy) and bishops)
elif next in [PieceKind.Rook, PieceKind.Queen]:
attackers = attackers or (getRookMoves(move.targetSquare, occupancy) and rooks)
attackers = attackers and occupancy
score = -score - 1 - next.getStaticPieceScore()
stm = stm.opposite()
if score >= 0:
if next == PieceKind.King and (attackers and position.getOccupancyFor(stm)) != 0:
# Can't capture with the king if the other side has defenders on the
# target square
stm = stm.opposite()
# We beat the threshold, hooray!
break
return position.sideToMove != stm