From 153ce8a84ac8679897074e6ec21291317c48e0dd Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:20:54 -0500 Subject: [PATCH 01/18] Eval: implement 4PC king safety, endgame activity, pins, X-rays and castling rights Introduce phase-tapered king evaluation for Red/Yellow and Blue/Green; oriented shield/wing safety, hostile attack zones and mate-threat pattern penalties, true slider-ray pins and X-rays, and validated castling-right bonuses. Add nine UCI-tunable evaluation parameters and dedicated regression tests. --- CMakeLists.txt | 8 + docs/king-evaluation.md | 62 ++++++++ include/ironphoenix/king_eval.hpp | 13 ++ include/ironphoenix/tuning.hpp | 9 ++ src/eval.cpp | 9 +- src/king_eval.cpp | 246 ++++++++++++++++++++++++++++++ src/tuning.cpp | 49 +++++- tests/king_features.cpp | 206 +++++++++++++++++++++++++ 8 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 docs/king-evaluation.md create mode 100644 include/ironphoenix/king_eval.hpp create mode 100644 src/king_eval.cpp create mode 100644 tests/king_features.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8372b9f..1541e6a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(ironphoenix_core STATIC src/tt.cpp src/eval.cpp src/bishop_eval.cpp + src/king_eval.cpp src/nnue.cpp src/tuning.cpp src/search.cpp @@ -34,6 +35,7 @@ add_library(ironphoenix_core STATIC src/uci.cpp include/ironphoenix/eval.hpp include/ironphoenix/bishop_eval.hpp + include/ironphoenix/king_eval.hpp include/ironphoenix/search_stack.hpp include/ironphoenix/lmr.hpp include/ironphoenix/nnue.hpp @@ -217,6 +219,12 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_knight_outpost PRIVATE cxx_std_20) add_test(NAME ironphoenix_knight_outpost COMMAND ironphoenix_knight_outpost) + add_executable(ironphoenix_king_features tests/king_features.cpp) + target_link_libraries(ironphoenix_king_features PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_king_features PRIVATE cxx_std_20) + add_test(NAME ironphoenix_king_features COMMAND ironphoenix_king_features) + set_target_properties(ironphoenix_king_features PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_bishop_features tests/bishop_features.cpp) target_link_libraries(ironphoenix_bishop_features PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_bishop_features PRIVATE cxx_std_20) diff --git a/docs/king-evaluation.md b/docs/king-evaluation.md new file mode 100644 index 0000000..afd3cfc --- /dev/null +++ b/docs/king-evaluation.md @@ -0,0 +1,62 @@ +# 4PC Teams King Evaluation + +This branch adds original HCE heuristics inspired by Chess Programming Wiki +[King Safety], [King Centralization], [Mate at a Glance], [Pin], [X-ray] +and [Castling Rights], adapted to Red+Yellow vs Blue+Green and the 14x14 board. + +These are evaluation weights, not illegal move pruning or forced mate detection. + +## Features +- **Middlegame shield**: three oriented forward files/ranks from each king, allowing nearby allied pawn shelter and penalizing gaps. +- **Attacked king zone**: reused team attack maps from the existing mobility stage, covering both enemy colors. +- **Enemy pawn storm**: nearby enemy pawn advances toward a home king. +- **Mate at a glance**: a *king-danger proxy* for an already attacked king with two or fewer unattacked empty adjacent escape squares. This is not a legal mate detector and **never outputs a terminal mate score**. +- **Endgame centralization**: tapered central proximity reward based on surviving enemy non-pawn material. +- **Pawnless wing**: endgame penalty for a wing king when neither teammate has pawns on that wing, with seat-orientated transverse axes. +- **Absolute pins**: a friendly piece shielding a king from an enemy bishop/rook/queen on a real uninterrupted 4PC ray. +- **X-ray pressure**: an allied piece shielding a queen from an enemy slider. No invalid cross-corner rays. +- **Castling rights**: middlegame bonus only when a configured lane has the correct live right bit and original king/rook in place. Does not assume that immediate castling is currently legal. + +The middlegame fraction is based on remaining **enemy** non-pawn material; +the endgame fraction is complementary. No change to the actual rules, moves, +mate scores, TT, or search. NNUE continues to bypass HCE as before. + +## New UCI parameters + +| Option | Default | +|---|---:| +| EvalKingShieldPenalty | 10 | +| EvalKingZoneAttackPenalty | 6 | +| EvalKingPawnStormPenalty | 5 | +| EvalKingMateThreatPenalty | 30 | +| EvalKingCentralizationBonus | 4 | +| EvalKingPawnlessWingPenalty | 14 | +| EvalKingPinPenalty | 14 | +| EvalKingXrayPenalty | 8 | +| EvalKingCastlingRightsBonus | 8 | + +Each can be set to 0 to disable a feature and tuned separately through +Tunex. These defaults are *experimental* and require independent SPRT. +All previously tuned Search/Eval parameters remain intact. + +HCE king terms are deliberately computed after the shared mobility/attack +stage. Reuse of existing attack maps limits extra full-board scans, but rays, +phase calculations and pawn scans cost NPS. Profile on real 4PC positions. + +## Validation + +The king-feature regression test covers: all nine parameter boundaries and +UCI declarations; material-phase king shield and pawn storm; threat proxy and +zone attacks; endgame centralization and wing pawns; pins and queen X-rays; +castling right preconditions; and team-relative sign reversal. + +Run CMake/CTest on Linux and Windows before testing Elo. +Compare both **NPS and paired WDL/SPRT** against \`main\`, ideally varying +one new king term at a time. + +[King Safety]: https://chessprogramming.org/King_Safety +[King Centralization]: https://chessprogramming.org/King_Centralization +[Mate at a Glance]: https://chessprogramming.org/Mate_at_a_Glance +[Pin]: https://chessprogramming.org/Pin +[X-ray]: https://chessprogramming.org/X-ray +[Castling Rights]: https://chessprogramming.org/Castling_Rights diff --git a/include/ironphoenix/king_eval.hpp b/include/ironphoenix/king_eval.hpp new file mode 100644 index 0000000..0e964fe --- /dev/null +++ b/include/ironphoenix/king_eval.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "bitboard.hpp" + +namespace ironphoenix { +class Position; +namespace Eval { + // Red+Yellow advantage minus Blue+Green advantage. + // Supplied attack maps are collected by the existing mobility evaluator. + [[nodiscard]] int kingFeatureDifference(const Position& pos, + const Bitboard (&teamAttacks)[2]) noexcept; +} +} diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 2c14704..7ce4880 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -55,6 +55,15 @@ namespace ironphoenix { int bishopColorWeaknessPenalty = 3; int bishopFianchettoBonus = 5; int bishopReturningBonus = 22; + int kingShieldPenalty = 10; + int kingZoneAttackPenalty = 6; + int kingPawnStormPenalty = 5; + int kingMateThreatPenalty = 30; + int kingCentralizationBonus = 4; + int kingPawnlessWingPenalty = 14; + int kingPinPenalty = 14; + int kingXrayPenalty = 8; + int kingCastlingRightsBonus = 8; }; [[nodiscard]] SearchParameters& search() noexcept; diff --git a/src/eval.cpp b/src/eval.cpp index 2f3ad84..1afd499 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -1,5 +1,6 @@ #include "ironphoenix/eval.hpp" #include "ironphoenix/bishop_eval.hpp" +#include "ironphoenix/king_eval.hpp" #include "ironphoenix/geometry.hpp" #include "ironphoenix/nnue.hpp" @@ -119,7 +120,9 @@ namespace ironphoenix::Eval { Bitboard* teamBishopAttacks) noexcept { const Bitboard blockedByTeam = teamOccupancy(pos, c); const auto& tune = Tuning::eval(); - const bool scoreThreats = tune.attackingPiecesWeight != 0; + const bool scoreThreats = tune.attackingPiecesWeight != 0 + || tune.kingZoneAttackPenalty != 0 + || tune.kingMateThreatPenalty != 0; const bool scoreBishopWeakness = tune.bishopColorWeaknessPenalty != 0 && pos.isAlive(c); @@ -362,7 +365,9 @@ namespace ironphoenix::Eval { // Team-wise complementary bishop coverage and four seat-oriented // pawn/king structures. All new feature weights default to zero. - const int score = team0 - team1 + bishopFeatureDifference(pos, bishopAttacks); + const int score = team0 - team1 + + bishopFeatureDifference(pos, bishopAttacks) + + kingFeatureDifference(pos, attacks); return teamOf(pos.sideToMove()) == 0 ? score : -score; } diff --git a/src/king_eval.cpp b/src/king_eval.cpp new file mode 100644 index 0000000..835a4af --- /dev/null +++ b/src/king_eval.cpp @@ -0,0 +1,246 @@ +#include "ironphoenix/king_eval.hpp" + +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include + +namespace ironphoenix::Eval { +namespace { + +constexpr int PHASE_MAX = 24; // two opposing armies: Q=4, R=2, B/N=1 + +int enemyPhase(const Position& pos, Color defender) noexcept { + int phase = 0; + for (unsigned i = 0; i < COLOR_NB; ++i) { + const auto foe = static_cast(i); + if (!pos.isAlive(foe) || !enemy(defender, foe)) + continue; + phase += 4 * pos.pieces(foe, QUEEN).popcount() + + 2 * pos.pieces(foe, ROOK).popcount() + + pos.pieces(foe, BISHOP).popcount() + + pos.pieces(foe, KNIGHT).popcount(); + } + return std::min(PHASE_MAX, phase); +} + +int homeDepth(Color c, Square sq) noexcept { + switch (c) { + case RED: return Geometry::rankOf(sq); + case BLUE: return Geometry::fileOf(sq); + case YELLOW: return BOARD_RANKS - 1 - Geometry::rankOf(sq); + case GREEN: return BOARD_FILES - 1 - Geometry::fileOf(sq); + } + return 0; +} + +int lateral(Color c, Square sq) noexcept { + return (c == RED || c == YELLOW) + ? Geometry::fileOf(sq) : Geometry::rankOf(sq); +} + +int centralDistance(Square sq) noexcept { + const int f = Geometry::fileOf(sq), r = Geometry::rankOf(sq); + return std::min(std::abs(f - 6), std::abs(f - 7)) + + std::min(std::abs(r - 6), std::abs(r - 7)); +} + +Square sideSquare(Color c, Square king, int offset) noexcept { + const int file = Geometry::fileOf(king), rank = Geometry::rankOf(king); + const int f = file + ((c == RED || c == YELLOW) ? offset : 0); + const int r = rank + ((c == BLUE || c == GREEN) ? offset : 0); + return Geometry::validXY(f, r) ? Geometry::square(f, r) : SQ_NONE; +} + +bool friendlyPawn(const Position& pos, Color c, Square sq) noexcept { + const Piece p = pos.pieceAt(sq); + return p != NO_PIECE && typeOf(p) == PAWN && allied(c, colorOf(p)); +} + +int shieldMissing(const Position& pos, Color c, Square king) noexcept { + if (homeDepth(c, king) > 4) + return 0; // A wandering king does not have a meaningful home shield. + int missing = 0; + for (int offset = -1; offset <= 1; ++offset) { + const Square start = sideSquare(c, king, offset); + if (start == SQ_NONE) + continue; + const Square first = Geometry::step(start, Geometry::pawnForward(c)); + const Square second = first == SQ_NONE + ? SQ_NONE : Geometry::step(first, Geometry::pawnForward(c)); + if ((first == SQ_NONE || !friendlyPawn(pos, c, first)) + && (second == SQ_NONE || !friendlyPawn(pos, c, second))) + ++missing; + } + return missing; +} + +int enemyPawnStorm(const Position& pos, Color c, Square king) noexcept { + if (homeDepth(c, king) > 4) + return 0; + int nearby = 0; + for (unsigned i = 0; i < COLOR_NB; ++i) { + const auto foe = static_cast(i); + if (!pos.isAlive(foe) || !enemy(c, foe)) + continue; + Bitboard pawns = pos.pieces(foe, PAWN); + while (pawns) { + const Square pawn = popLsb(pawns); + const int dx = std::abs(int(Geometry::fileOf(pawn)) - int(Geometry::fileOf(king))); + const int dy = std::abs(int(Geometry::rankOf(pawn)) - int(Geometry::rankOf(king))); + if (std::max(dx, dy) <= 3) + ++nearby; + } + } + return std::min(4, nearby); +} + +bool pawnOnKingWing(const Position& pos, Color c, Square king) noexcept { + const int across = lateral(c, king); + if (across > 4 && across < 9) + return true; // Not on a wing. + for (unsigned i = 0; i < COLOR_NB; ++i) { + const auto owner = static_cast(i); + if (!pos.isAlive(owner) || !allied(c, owner)) + continue; + Bitboard pawns = pos.pieces(owner, PAWN); + while (pawns) { + const int lane = lateral(c, popLsb(pawns)); + if (across <= 4 ? lane <= 4 : lane >= 9) + return true; + } + } + return false; +} + +int castleRights(const Position& pos, Color c) noexcept { + int available = 0; + for (unsigned lane = 0; lane < 2; ++lane) { + const CastleLane& def = pos.castleDefinition(c, lane); + if (!def.valid() || def.rightBit >= 8 + || !(pos.castlingRights() & (1u << def.rightBit))) + continue; + if (pos.kingSquare(c) == def.kingFrom + && pos.pieceAt(def.kingFrom) == makePiece(c, KING) + && pos.pieceAt(def.rookFrom) == makePiece(c, ROOK)) + ++available; + } + return available; +} + +// Count absolute pins of friendly material to a king. For x-rays, count +// friendly pieces shielding a friendly queen from an enemy slider. +// No ray can cross a corner hole: Geometry::step returns SQ_NONE. +int rayPressure(const Position& pos, Color c, Square target) noexcept { + int count = 0; + for (unsigned d = NORTH; d <= SOUTH_WEST; ++d) { + const auto dir = static_cast(d); + Square cur = Geometry::step(target, dir); + while (cur != SQ_NONE && pos.pieceAt(cur) == NO_PIECE) + cur = Geometry::step(cur, dir); + if (cur == SQ_NONE) + continue; + const Piece screen = pos.pieceAt(cur); + if (!allied(c, colorOf(screen)) || typeOf(screen) == KING) + continue; + cur = Geometry::step(cur, dir); + while (cur != SQ_NONE && pos.pieceAt(cur) == NO_PIECE) + cur = Geometry::step(cur, dir); + if (cur == SQ_NONE) + continue; + const Piece slider = pos.pieceAt(cur); + if (!enemy(c, colorOf(slider))) + continue; + const auto pt = typeOf(slider); + const bool orthogonal = d <= WEST; + if (pt == QUEEN || (orthogonal && pt == ROOK) + || (!orthogonal && pt == BISHOP)) + ++count; + } + return std::min(4, count); +} + +int kingScore(const Position& pos, Color c, const Bitboard (&attacks)[2]) noexcept { + if (!pos.isAlive(c)) + return 0; + const Square king = pos.kingSquare(c); + if (king == SQ_NONE) + return 0; + + const auto& w = Tuning::eval(); + const int phase = enemyPhase(pos, c); + const int endPhase = PHASE_MAX - phase; + const int team = teamOf(c); + int score = 0; + + if (phase > 0) { + int danger = 0; + if (w.kingShieldPenalty) + danger += w.kingShieldPenalty * shieldMissing(pos, c, king); + if (w.kingPawnStormPenalty) + danger += w.kingPawnStormPenalty * enemyPawnStorm(pos, c, king); + + const Bitboard zone = Geometry::KingAttacks[king]; + const Bitboard hostile = attacks[team ^ 1]; + const int controlled = (zone & hostile).popcount(); + if (w.kingZoneAttackPenalty) + danger += w.kingZoneAttackPenalty * std::min(8, controlled); + + // "Mate at a glance" is a king-danger pattern, NOT a forced mate + // or terminal score. Search decides whether king capture is legal. + if (w.kingMateThreatPenalty && hostile.test(king)) { + const int escapes = (zone & ~pos.occupancy() & ~hostile).popcount(); + if (escapes <= 2) + danger += w.kingMateThreatPenalty * (3 - escapes); + } + + if (w.kingCastlingRightsBonus) + score += phase * w.kingCastlingRightsBonus * castleRights(pos, c) / PHASE_MAX; + + if (w.kingPinPenalty) + danger += w.kingPinPenalty * rayPressure(pos, c, king); + + score -= danger * phase / PHASE_MAX; + } + + if (endPhase > 0) { + if (w.kingCentralizationBonus) + score += w.kingCentralizationBonus + * (12 - centralDistance(king)) * endPhase / PHASE_MAX; + if (w.kingPawnlessWingPenalty && !pawnOnKingWing(pos, c, king)) + score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + } + + if (w.kingXrayPenalty) { + Bitboard queens = pos.pieces(c, QUEEN); + int rays = 0; + while (queens) + rays += rayPressure(pos, c, popLsb(queens)); + score -= w.kingXrayPenalty * std::min(4, rays); + } + return score; +} + +} // namespace + +int kingFeatureDifference(const Position& pos, + const Bitboard (&teamAttacks)[2]) noexcept { + const auto& w = Tuning::eval(); + if (!(w.kingShieldPenalty || w.kingPawnStormPenalty + || w.kingZoneAttackPenalty || w.kingMateThreatPenalty + || w.kingCentralizationBonus || w.kingPawnlessWingPenalty + || w.kingPinPenalty || w.kingXrayPenalty + || w.kingCastlingRightsBonus)) + return 0; + + int teamScore[2]{}; + for (unsigned i = 0; i < COLOR_NB; ++i) { + const auto c = static_cast(i); + teamScore[teamOf(c)] += kingScore(pos, c, teamAttacks); + } + return teamScore[0] - teamScore[1]; +} + +} // namespace ironphoenix::Eval diff --git a/src/tuning.cpp b/src/tuning.cpp index 1bd6b06..0ae9498 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -79,7 +79,16 @@ namespace ironphoenix::Tuning { || name == "EvalBishopVsKnightBonus" || name == "EvalBishopColorWeaknessPenalty" || name == "EvalBishopFianchettoBonus" - || name == "EvalBishopReturningBonus"; + || name == "EvalBishopReturningBonus" + || name == "EvalKingShieldPenalty" + || name == "EvalKingZoneAttackPenalty" + || name == "EvalKingPawnStormPenalty" + || name == "EvalKingMateThreatPenalty" + || name == "EvalKingCentralizationBonus" + || name == "EvalKingPawnlessWingPenalty" + || name == "EvalKingPinPenalty" + || name == "EvalKingXrayPenalty" + || name == "EvalKingCastlingRightsBonus"; } bool isLmrParameter(std::string_view name) noexcept { @@ -218,6 +227,24 @@ namespace ironphoenix::Tuning { p.bishopFianchettoBonus = value; else if (name == "EvalBishopReturningBonus" && inRange(value, 0, 100)) p.bishopReturningBonus = value; + else if (name == "EvalKingShieldPenalty" && inRange(value, 0, 200)) + p.kingShieldPenalty = value; + else if (name == "EvalKingZoneAttackPenalty" && inRange(value, 0, 200)) + p.kingZoneAttackPenalty = value; + else if (name == "EvalKingPawnStormPenalty" && inRange(value, 0, 200)) + p.kingPawnStormPenalty = value; + else if (name == "EvalKingMateThreatPenalty" && inRange(value, 0, 1000)) + p.kingMateThreatPenalty = value; + else if (name == "EvalKingCentralizationBonus" && inRange(value, 0, 200)) + p.kingCentralizationBonus = value; + else if (name == "EvalKingPawnlessWingPenalty" && inRange(value, 0, 200)) + p.kingPawnlessWingPenalty = value; + else if (name == "EvalKingPinPenalty" && inRange(value, 0, 200)) + p.kingPinPenalty = value; + else if (name == "EvalKingXrayPenalty" && inRange(value, 0, 200)) + p.kingXrayPenalty = value; + else if (name == "EvalKingCastlingRightsBonus" && inRange(value, 0, 200)) + p.kingCastlingRightsBonus = value; else return false; @@ -314,6 +341,15 @@ namespace ironphoenix::Tuning { << "option name EvalBishopColorWeaknessPenalty type spin default " << defaults.bishopColorWeaknessPenalty << " min 0 max 100\n" << "option name EvalBishopFianchettoBonus type spin default " << defaults.bishopFianchettoBonus << " min 0 max 100\n" << "option name EvalBishopReturningBonus type spin default " << defaults.bishopReturningBonus << " min 0 max 100\n" + << "option name EvalKingShieldPenalty type spin default " << defaults.kingShieldPenalty << " min 0 max 200\n" + << "option name EvalKingZoneAttackPenalty type spin default " << defaults.kingZoneAttackPenalty << " min 0 max 200\n" + << "option name EvalKingPawnStormPenalty type spin default " << defaults.kingPawnStormPenalty << " min 0 max 200\n" + << "option name EvalKingMateThreatPenalty type spin default " << defaults.kingMateThreatPenalty << " min 0 max 1000\n" + << "option name EvalKingCentralizationBonus type spin default " << defaults.kingCentralizationBonus << " min 0 max 200\n" + << "option name EvalKingPawnlessWingPenalty type spin default " << defaults.kingPawnlessWingPenalty << " min 0 max 200\n" + << "option name EvalKingPinPenalty type spin default " << defaults.kingPinPenalty << " min 0 max 200\n" + << "option name EvalKingXrayPenalty type spin default " << defaults.kingXrayPenalty << " min 0 max 200\n" + << "option name EvalKingCastlingRightsBonus type spin default " << defaults.kingCastlingRightsBonus << " min 0 max 200\n" << "option name ResetTuning type button\n"; } @@ -363,7 +399,16 @@ namespace ironphoenix::Tuning { << " EvalBishopVsKnightBonus=" << e.bishopVsKnightBonus << " EvalBishopColorWeaknessPenalty=" << e.bishopColorWeaknessPenalty << " EvalBishopFianchettoBonus=" << e.bishopFianchettoBonus - << " EvalBishopReturningBonus=" << e.bishopReturningBonus << '\n'; + << " EvalBishopReturningBonus=" << e.bishopReturningBonus + << " EvalKingShieldPenalty=" << e.kingShieldPenalty + << " EvalKingZoneAttackPenalty=" << e.kingZoneAttackPenalty + << " EvalKingPawnStormPenalty=" << e.kingPawnStormPenalty + << " EvalKingMateThreatPenalty=" << e.kingMateThreatPenalty + << " EvalKingCentralizationBonus=" << e.kingCentralizationBonus + << " EvalKingPawnlessWingPenalty=" << e.kingPawnlessWingPenalty + << " EvalKingPinPenalty=" << e.kingPinPenalty + << " EvalKingXrayPenalty=" << e.kingXrayPenalty + << " EvalKingCastlingRightsBonus=" << e.kingCastlingRightsBonus << '\n'; } } // namespace ironphoenix::Tuning diff --git a/tests/king_features.cpp b/tests/king_features.cpp new file mode 100644 index 0000000..f90d9e5 --- /dev/null +++ b/tests/king_features.cpp @@ -0,0 +1,206 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace { +int failures = 0; +void expect(bool ok, const char* reason) { + if (!ok) { ++failures; std::cerr << "FAIL: " << reason << '\n'; } +} +Square sq(char file, int rank) { return Geometry::square(file - 'a', rank - 1); } + +Position kings() { + Position p; + p.clear(); + p.placePiece(sq('g', 1), makePiece(RED, KING)); + p.placePiece(sq('a', 7), makePiece(BLUE, KING)); + p.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + p.placePiece(sq('n', 7), makePiece(GREEN, KING)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} + +using P = Tuning::EvalParameters; +int contribution(Position p, int P::*field, int amount) { + const P original = Tuning::eval(); + Tuning::eval().*field = 0; + const int without = Eval::evaluate(p); + Tuning::eval().*field = amount; + const int with = Eval::evaluate(p); + Tuning::eval() = original; + return with - without; +} + +void testOptions() { + Tuning::resetEval(); + const P defaults{}; + struct Weight { const char* name; int P::*field; int value; int max; }; + const Weight parameters[] = { + {"EvalKingShieldPenalty", &P::kingShieldPenalty, 10, 200}, + {"EvalKingZoneAttackPenalty", &P::kingZoneAttackPenalty, 6, 200}, + {"EvalKingPawnStormPenalty", &P::kingPawnStormPenalty, 5, 200}, + {"EvalKingMateThreatPenalty", &P::kingMateThreatPenalty, 30, 1000}, + {"EvalKingCentralizationBonus", &P::kingCentralizationBonus, 4, 200}, + {"EvalKingPawnlessWingPenalty", &P::kingPawnlessWingPenalty, 14, 200}, + {"EvalKingPinPenalty", &P::kingPinPenalty, 14, 200}, + {"EvalKingXrayPenalty", &P::kingXrayPenalty, 8, 200}, + {"EvalKingCastlingRightsBonus", &P::kingCastlingRightsBonus, 8, 200}, + }; + std::ostringstream uci; + Tuning::printUciOptions(uci); + for (const auto& p : parameters) { + expect(Tuning::eval().*(p.field) == p.value, "feature starts at default"); + expect(uci.str().find(std::string("option name ") + p.name + + " type spin default " + std::to_string(p.value) + + " min 0 max " + std::to_string(p.max) + "\n") + != std::string::npos, "UCI option format/default valid"); + expect(Tuning::setEvalParameter(p.name, p.max), "accept upper bound"); + expect(Tuning::eval().*(p.field) == p.max, "new weight stored"); + expect(!Tuning::setEvalParameter(p.name, p.max+1), "reject upper overflow"); + expect(!Tuning::setEvalParameter(p.name, -1), "reject negative weight"); + } + Tuning::resetEval(); + for (const auto& p : parameters) + expect(Tuning::eval().*(p.field) == defaults.*(p.field), + "reset restores current default"); + std::ostringstream params; + Tuning::printCurrent(params); + expect(params.str().find("EvalKingPinPenalty=14") != std::string::npos, + "tuneparams reports king weights"); +} + +void testMiddlegame() { + auto pos = kings(); + // Only enemy non-pawn pieces enable the middlegame safety blend. + pos.placePiece(sq('c', 9), makePiece(BLUE, QUEEN)); + pos.placePiece(sq('l', 9), makePiece(GREEN, QUEEN)); + pos.finalizeSetup(); + + const int noShield = contribution(pos, &P::kingShieldPenalty, 100); + expect(noShield < 0, "missing shield penalizes Red king"); + pos.placePiece(sq('f', 2), makePiece(RED, PAWN)); + pos.placePiece(sq('g', 2), makePiece(RED, PAWN)); + pos.placePiece(sq('h', 2), makePiece(RED, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingShieldPenalty, 100) > noShield, + "three forward shield pawns protect Red king"); + + pos.erasePiece(sq('f', 2)); + pos.erasePiece(sq('g', 2)); + pos.erasePiece(sq('h', 2)); + pos.placePiece(sq('g', 3), makePiece(BLUE, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPawnStormPenalty, 100) < 0, + "nearby enemy pawn penalizes Red king"); + + pos.setSideToMove(BLUE); + pos.finalizeSetup(); + const int blue = contribution(pos, &P::kingPawnStormPenalty, 100); + pos.setSideToMove(RED); + pos.finalizeSetup(); + expect(blue == -contribution(pos, &P::kingPawnStormPenalty, 100), + "king features obey team-relative perspective"); +} + +void testKingZoneMatePressure() { + auto pos = kings(); + pos.erasePiece(sq('g', 1)); + pos.placePiece(sq('d', 1), makePiece(RED, KING)); + pos.placePiece(sq('d', 6), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingZoneAttackPenalty, 100) < 0, + "enemy rook controls squares around the king"); + expect(contribution(pos, &P::kingMateThreatPenalty, 100) < 0, + "constrained checked king receives at-a-glance danger penalty"); +} + +void testEndgame() { + auto pos = kings(); + const int home = contribution(pos, &P::kingCentralizationBonus, 12); + pos.erasePiece(sq('g', 1)); + pos.placePiece(sq('g', 7), makePiece(RED, KING)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCentralizationBonus, 12) > home, + "endgame king centralization rewards active king"); + + pos.erasePiece(sq('g', 7)); + pos.placePiece(sq('d', 1), makePiece(RED, KING)); + pos.finalizeSetup(); + const int wing = contribution(pos, &P::kingPawnlessWingPenalty, 100); + expect(wing < 0, "pawnless wing penalizes endgame king"); + pos.placePiece(sq('d', 2), makePiece(RED, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPawnlessWingPenalty, 100) > wing, + "pawn on same wing removes penalty"); +} + +void testPinsAndXRays() { + auto pos = kings(); + pos.placePiece(sq('g', 3), makePiece(RED, KNIGHT)); + pos.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPinPenalty, 100) < 0, + "rook pins a piece to Red king"); + pos.erasePiece(sq('g', 8)); + pos.placePiece(sq('i', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPinPenalty, 100) == 0, + "off-ray rook cannot pin"); + + pos = kings(); + pos.placePiece(sq('g', 2), makePiece(RED, QUEEN)); + pos.placePiece(sq('g', 3), makePiece(RED, PAWN)); + pos.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingXrayPenalty, 100) < 0, + "friendly pawn screens own queen from enemy rook X-ray"); + pos.erasePiece(sq('g', 3)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingXrayPenalty, 100) == 0, + "no X-ray penalty without a screening piece"); +} + +void testCastlingRights() { + auto pos = kings(); + pos.placePiece(sq('d', 1), makePiece(RED, ROOK)); + pos.placePiece(sq('k', 9), makePiece(BLUE, QUEEN)); + pos.configureCastle(RED, 0, + CastleLane{sq('g', 1), sq('d', 1), sq('i', 1), sq('h', 1), 0}); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) == 0, + "castle geometry alone does not grant rights bonus"); + pos.setCastlingRights(1); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) > 0, + "real castling right plus king and rook earns bonus"); + pos.erasePiece(sq('d', 1)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) == 0, + "missing rook invalidates bonus despite stale rights flag"); +} + +} // namespace + +int main() { + Geometry::init(); + expect(!NNUE::loaded(), "king evaluation features apply to HCE"); + testOptions(); + testMiddlegame(); + testKingZoneMatePressure(); + testEndgame(); + testPinsAndXRays(); + testCastlingRights(); + Tuning::resetEval(); + if (!failures) std::cout << "4PC Teams king evaluation tests: PASS\n"; + return failures ? 1 : 0; +} From 41a2d04eaa4c2a5cbf4efde76e675b6b6556a966 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:33:13 -0500 Subject: [PATCH 02/18] Perf: make king HCE bitboard-centric with cached square masks and shared phase Replace per-king per-pawn scans and directional empty-square walks with immutable shield/storm/wing masks and slider lookup rays. Accumulate material phases/armies once per position, reuse occupancy and attack maps; keep all nine tunable king heuristics and their scale. --- src/king_eval.cpp | 340 ++++++++++++++++++++++++++++------------------ 1 file changed, 206 insertions(+), 134 deletions(-) diff --git a/src/king_eval.cpp b/src/king_eval.cpp index 835a4af..a6facc4 100644 --- a/src/king_eval.cpp +++ b/src/king_eval.cpp @@ -5,26 +5,13 @@ #include "ironphoenix/tuning.hpp" #include +#include #include namespace ironphoenix::Eval { namespace { -constexpr int PHASE_MAX = 24; // two opposing armies: Q=4, R=2, B/N=1 - -int enemyPhase(const Position& pos, Color defender) noexcept { - int phase = 0; - for (unsigned i = 0; i < COLOR_NB; ++i) { - const auto foe = static_cast(i); - if (!pos.isAlive(foe) || !enemy(defender, foe)) - continue; - phase += 4 * pos.pieces(foe, QUEEN).popcount() - + 2 * pos.pieces(foe, ROOK).popcount() - + pos.pieces(foe, BISHOP).popcount() - + pos.pieces(foe, KNIGHT).popcount(); - } - return std::min(PHASE_MAX, phase); -} +constexpr int PHASE_MAX = 24; // Opponent pair: Q=4, R=2, B/N=1 int homeDepth(Color c, Square sq) noexcept { switch (c) { @@ -41,81 +28,93 @@ int lateral(Color c, Square sq) noexcept { ? Geometry::fileOf(sq) : Geometry::rankOf(sq); } -int centralDistance(Square sq) noexcept { - const int f = Geometry::fileOf(sq), r = Geometry::rankOf(sq); - return std::min(std::abs(f - 6), std::abs(f - 7)) - + std::min(std::abs(r - 6), std::abs(r - 7)); -} - Square sideSquare(Color c, Square king, int offset) noexcept { - const int file = Geometry::fileOf(king), rank = Geometry::rankOf(king); - const int f = file + ((c == RED || c == YELLOW) ? offset : 0); - const int r = rank + ((c == BLUE || c == GREEN) ? offset : 0); - return Geometry::validXY(f, r) ? Geometry::square(f, r) : SQ_NONE; + const int f = Geometry::fileOf(king); + const int r = Geometry::rankOf(king); + const int x = f + ((c == RED || c == YELLOW) ? offset : 0); + const int y = r + ((c == BLUE || c == GREEN) ? offset : 0); + return Geometry::validXY(x, y) ? Geometry::square(x, y) : SQ_NONE; } -bool friendlyPawn(const Position& pos, Color c, Square sq) noexcept { - const Piece p = pos.pieceAt(sq); - return p != NO_PIECE && typeOf(p) == PAWN && allied(c, colorOf(p)); -} +// Geometry depends only on king square and seat. Build the tiny lookup once, +// after Geometry::init(), rather than recomputing it for millions of evals. +// The C++11 function-local static initialization is thread-safe for Lazy SMP. +struct KingMasks { + Bitboard storm{}; + std::array, COLOR_NB> shield{}; + std::array, COLOR_NB> shieldLane{}; + std::array home{}; + std::array lateral{}; + std::uint8_t centralDistance = 0; +}; -int shieldMissing(const Position& pos, Color c, Square king) noexcept { - if (homeDepth(c, king) > 4) - return 0; // A wandering king does not have a meaningful home shield. - int missing = 0; - for (int offset = -1; offset <= 1; ++offset) { - const Square start = sideSquare(c, king, offset); - if (start == SQ_NONE) - continue; - const Square first = Geometry::step(start, Geometry::pawnForward(c)); - const Square second = first == SQ_NONE - ? SQ_NONE : Geometry::step(first, Geometry::pawnForward(c)); - if ((first == SQ_NONE || !friendlyPawn(pos, c, first)) - && (second == SQ_NONE || !friendlyPawn(pos, c, second))) - ++missing; - } - return missing; -} +struct Lookup { + std::array kings{}; + std::array, COLOR_NB> wings{}; -int enemyPawnStorm(const Position& pos, Color c, Square king) noexcept { - if (homeDepth(c, king) > 4) - return 0; - int nearby = 0; - for (unsigned i = 0; i < COLOR_NB; ++i) { - const auto foe = static_cast(i); - if (!pos.isAlive(foe) || !enemy(c, foe)) - continue; - Bitboard pawns = pos.pieces(foe, PAWN); - while (pawns) { - const Square pawn = popLsb(pawns); - const int dx = std::abs(int(Geometry::fileOf(pawn)) - int(Geometry::fileOf(king))); - const int dy = std::abs(int(Geometry::rankOf(pawn)) - int(Geometry::rankOf(king))); - if (std::max(dx, dy) <= 3) - ++nearby; - } - } - return std::min(4, nearby); -} + Lookup() noexcept { + for (unsigned i = 0; i < SQUARE_NB; ++i) { + const Square sq = static_cast(i); + const int f = Geometry::fileOf(sq), r = Geometry::rankOf(sq); + if (!Geometry::validXY(f, r)) + continue; + auto& m = kings[i]; + m.centralDistance = static_cast( + std::min(std::abs(f - 6), std::abs(f - 7)) + + std::min(std::abs(r - 6), std::abs(r - 7))); -bool pawnOnKingWing(const Position& pos, Color c, Square king) noexcept { - const int across = lateral(c, king); - if (across > 4 && across < 9) - return true; // Not on a wing. - for (unsigned i = 0; i < COLOR_NB; ++i) { - const auto owner = static_cast(i); - if (!pos.isAlive(owner) || !allied(c, owner)) - continue; - Bitboard pawns = pos.pieces(owner, PAWN); - while (pawns) { - const int lane = lateral(c, popLsb(pawns)); - if (across <= 4 ? lane <= 4 : lane >= 9) - return true; + for (int dx = -3; dx <= 3; ++dx) { + for (int dy = -3; dy <= 3; ++dy) { + if (Geometry::validXY(f + dx, r + dy)) + m.storm.set(Geometry::square(f + dx, r + dy)); + } + } + + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const auto c = static_cast(ci); + m.home[ci] = static_cast(homeDepth(c, sq)); + m.lateral[ci] = static_cast(lateral(c, sq)); + if (m.home[ci] > 4) + continue; + for (int lane = -1; lane <= 1; ++lane) { + const Square start = sideSquare(c, sq, lane); + if (start == SQ_NONE) + continue; + m.shieldLane[ci][lane + 1] = true; + const Square first = Geometry::step(start, Geometry::pawnForward(c)); + if (first != SQ_NONE) { + m.shield[ci][lane + 1].set(first); + const Square second = Geometry::step(first, Geometry::pawnForward(c)); + if (second != SQ_NONE) + m.shield[ci][lane + 1].set(second); + } + } + } + } + for (unsigned sq = 0; sq < SQUARE_NB; ++sq) { + const int f = Geometry::fileOf(static_cast(sq)); + const int r = Geometry::rankOf(static_cast(sq)); + if (!Geometry::validXY(f, r)) + continue; + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const int across = lateral(static_cast(ci), static_cast(sq)); + if (across <= 4) + wings[ci][0].set(static_cast(sq)); + if (across >= 9) + wings[ci][1].set(static_cast(sq)); + } } } - return false; +}; + +const Lookup& geometryMasks() noexcept { + static const Lookup lookup{}; + return lookup; } int castleRights(const Position& pos, Color c) noexcept { + if (pos.castlingRights() == 0) + return 0; int available = 0; for (unsigned lane = 0; lane < 2; ++lane) { const CastleLane& def = pos.castleDefinition(c, lane); @@ -130,94 +129,165 @@ int castleRights(const Position& pos, Color c) noexcept { return available; } -// Count absolute pins of friendly material to a king. For x-rays, count -// friendly pieces shielding a friendly queen from an enemy slider. -// No ray can cross a corner hole: Geometry::step returns SQ_NONE. -int rayPressure(const Position& pos, Color c, Square target) noexcept { - int count = 0; - for (unsigned d = NORTH; d <= SOUTH_WEST; ++d) { - const auto dir = static_cast(d); - Square cur = Geometry::step(target, dir); - while (cur != SQ_NONE && pos.pieceAt(cur) == NO_PIECE) - cur = Geometry::step(cur, dir); - if (cur == SQ_NONE) - continue; - const Piece screen = pos.pieceAt(cur); - if (!allied(c, colorOf(screen)) || typeOf(screen) == KING) - continue; - cur = Geometry::step(cur, dir); - while (cur != SQ_NONE && pos.pieceAt(cur) == NO_PIECE) - cur = Geometry::step(cur, dir); - if (cur == SQ_NONE) - continue; - const Piece slider = pos.pieceAt(cur); - if (!enemy(c, colorOf(slider))) +struct TeamData { + Bitboard pawns{}; + Bitboard occupants{}; + Bitboard orthogonalSliders{}; + Bitboard diagonalSliders{}; + int phase = 0; +}; + +struct FastContext { + TeamData teams[2]{}; + Bitboard occupancy{}; +}; + +FastContext makeContext(const Position& pos) noexcept { + FastContext ctx{}; + ctx.occupancy = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color c = static_cast(ci); + if (!pos.isAlive(c)) continue; - const auto pt = typeOf(slider); - const bool orthogonal = d <= WEST; - if (pt == QUEEN || (orthogonal && pt == ROOK) - || (!orthogonal && pt == BISHOP)) - ++count; + auto& t = ctx.teams[teamOf(c)]; + const Bitboard queens = pos.pieces(c, QUEEN); + const Bitboard rooks = pos.pieces(c, ROOK); + const Bitboard bishops = pos.pieces(c, BISHOP); + const Bitboard knights = pos.pieces(c, KNIGHT); + t.pawns |= pos.pieces(c, PAWN); + t.occupants |= pos.occupancy(c); + t.orthogonalSliders |= rooks | queens; + t.diagonalSliders |= bishops | queens; + t.phase += 4 * queens.popcount() + 2 * rooks.popcount() + + bishops.popcount() + knights.popcount(); + } + ctx.teams[0].phase = std::min(PHASE_MAX, ctx.teams[0].phase); + ctx.teams[1].phase = std::min(PHASE_MAX, ctx.teams[1].phase); + return ctx; +} + +// A screened friendly piece in a slider attack ray is a true geometric +// pin (to a king) or x-ray (to a queen). Current-occupancy rook/bishop +// lookups identify the FIRST occupied square in all directions at once; +// only candidate friendly screens then need a second ray lookup. +int rayPressure(const Position& pos, Square target, + Bitboard friends, const TeamData& opponents, + Bitboard occupancy) noexcept { + int count = 0; + if (opponents.orthogonalSliders) { + Bitboard screens = pos.rookAttacks(target) & friends; + while (screens) { + const Square screen = popLsb(screens); + if (typeOf(pos.pieceAt(screen)) == KING) + continue; + const int f = Geometry::fileOf(screen) - int(Geometry::fileOf(target)); + const int r = Geometry::rankOf(screen) - int(Geometry::rankOf(target)); + const Direction dir = f ? (f > 0 ? EAST : WEST) + : (r > 0 ? NORTH : SOUTH); + if (Geometry::rayAttack(screen, dir, occupancy) + & opponents.orthogonalSliders) + ++count; + } + } + if (opponents.diagonalSliders) { + Bitboard screens = pos.bishopAttacks(target) & friends; + while (screens) { + const Square screen = popLsb(screens); + if (typeOf(pos.pieceAt(screen)) == KING) + continue; + const int f = Geometry::fileOf(screen) - int(Geometry::fileOf(target)); + const int r = Geometry::rankOf(screen) - int(Geometry::rankOf(target)); + const Direction dir = f > 0 + ? (r > 0 ? NORTH_EAST : SOUTH_EAST) + : (r > 0 ? NORTH_WEST : SOUTH_WEST); + if (Geometry::rayAttack(screen, dir, occupancy) + & opponents.diagonalSliders) + ++count; + } } return std::min(4, count); } -int kingScore(const Position& pos, Color c, const Bitboard (&attacks)[2]) noexcept { +int kingScore(const Position& pos, Color c, + const Bitboard (&attacks)[2], const FastContext& ctx, + const Lookup& lookup, const Tuning::EvalParameters& w) noexcept { if (!pos.isAlive(c)) return 0; const Square king = pos.kingSquare(c); if (king == SQ_NONE) return 0; - const auto& w = Tuning::eval(); - const int phase = enemyPhase(pos, c); - const int endPhase = PHASE_MAX - phase; + const unsigned ci = static_cast(c); const int team = teamOf(c); + const TeamData& own = ctx.teams[team]; + const TeamData& foe = ctx.teams[team ^ 1]; + const KingMasks& m = lookup.kings[king]; + const int phase = foe.phase; + const int endPhase = PHASE_MAX - phase; int score = 0; - if (phase > 0) { + if (phase) { int danger = 0; - if (w.kingShieldPenalty) - danger += w.kingShieldPenalty * shieldMissing(pos, c, king); - if (w.kingPawnStormPenalty) - danger += w.kingPawnStormPenalty * enemyPawnStorm(pos, c, king); + if (m.home[ci] <= 4) { + if (w.kingShieldPenalty) { + int missing = 0; + for (int lane = 0; lane < 3; ++lane) + missing += m.shieldLane[ci][lane] + && !(m.shield[ci][lane] & own.pawns); + danger += w.kingShieldPenalty * missing; + } + if (w.kingPawnStormPenalty) + danger += w.kingPawnStormPenalty + * std::min(4, (m.storm & foe.pawns).popcount()); + } - const Bitboard zone = Geometry::KingAttacks[king]; const Bitboard hostile = attacks[team ^ 1]; - const int controlled = (zone & hostile).popcount(); + const Bitboard zone = Geometry::KingAttacks[king]; if (w.kingZoneAttackPenalty) - danger += w.kingZoneAttackPenalty * std::min(8, controlled); + danger += w.kingZoneAttackPenalty + * std::min(8, (zone & hostile).popcount()); - // "Mate at a glance" is a king-danger pattern, NOT a forced mate - // or terminal score. Search decides whether king capture is legal. + // This is a heuristic danger pattern, not a mate conclusion. if (w.kingMateThreatPenalty && hostile.test(king)) { - const int escapes = (zone & ~pos.occupancy() & ~hostile).popcount(); + const int escapes = (zone & ~ctx.occupancy & ~hostile).popcount(); if (escapes <= 2) danger += w.kingMateThreatPenalty * (3 - escapes); } - if (w.kingCastlingRightsBonus) - score += phase * w.kingCastlingRightsBonus * castleRights(pos, c) / PHASE_MAX; + if (w.kingCastlingRightsBonus && pos.castlingRights()) + score += phase * w.kingCastlingRightsBonus + * castleRights(pos, c) / PHASE_MAX; if (w.kingPinPenalty) - danger += w.kingPinPenalty * rayPressure(pos, c, king); + danger += w.kingPinPenalty + * rayPressure(pos, king, own.occupants, foe, ctx.occupancy); score -= danger * phase / PHASE_MAX; } - if (endPhase > 0) { + if (endPhase) { if (w.kingCentralizationBonus) score += w.kingCentralizationBonus - * (12 - centralDistance(king)) * endPhase / PHASE_MAX; - if (w.kingPawnlessWingPenalty && !pawnOnKingWing(pos, c, king)) - score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + * (12 - int(m.centralDistance)) * endPhase / PHASE_MAX; + + if (w.kingPawnlessWingPenalty) { + const int across = m.lateral[ci]; + if (across <= 4) { + if (!(lookup.wings[ci][0] & own.pawns)) + score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + } else if (across >= 9) { + if (!(lookup.wings[ci][1] & own.pawns)) + score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + } + } } - if (w.kingXrayPenalty) { + if (w.kingXrayPenalty && (foe.orthogonalSliders || foe.diagonalSliders)) { Bitboard queens = pos.pieces(c, QUEEN); int rays = 0; while (queens) - rays += rayPressure(pos, c, popLsb(queens)); + rays += rayPressure(pos, popLsb(queens), own.occupants, + foe, ctx.occupancy); score -= w.kingXrayPenalty * std::min(4, rays); } return score; @@ -235,12 +305,14 @@ int kingFeatureDifference(const Position& pos, || w.kingCastlingRightsBonus)) return 0; - int teamScore[2]{}; + const FastContext ctx = makeContext(pos); + const Lookup& lookup = geometryMasks(); + int teams[2]{}; for (unsigned i = 0; i < COLOR_NB; ++i) { - const auto c = static_cast(i); - teamScore[teamOf(c)] += kingScore(pos, c, teamAttacks); + const Color c = static_cast(i); + teams[teamOf(c)] += kingScore(pos, c, teamAttacks, ctx, lookup, w); } - return teamScore[0] - teamScore[1]; + return teams[0] - teams[1]; } } // namespace ironphoenix::Eval From f62ce3e7b90804821f8b73b129aaca1d50f63617 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:35:40 -0500 Subject: [PATCH 03/18] Bench: isolate HCE king feature groups with same-runner NPS comparisons Benchmark pinned slider pressure, pawn-zone safety, king-zone danger and all king heuristics disabled against the tuned combination. Restore evaluation parameters afterward. --- tests/nps_bench.cpp | 55 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index e01f4a4..f9377ad 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -1,6 +1,7 @@ #include "ironphoenix/geometry.hpp" #include "ironphoenix/position.hpp" #include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" #include #include @@ -96,7 +97,59 @@ int main(int argc, char** argv) { << "Last completed iteration: " << lastNodes << " nodes, " << lastNps << " NPS\n" << "Engine finished: " << (finished ? "yes" : "no (time limit)") << '\n'; + // Compare feature groups on the same runner and search position. Never + // treat absolute CI NPS as cross-machine evidence; compare relative changes. + const auto original = Tuning::eval(); + auto measure = [&](const char* label) { + auto probe = std::make_unique(); + SearchLimits probeLimits{}; + probeLimits.nodes = requested; + std::ostringstream trace; + const auto probeStart = std::chrono::steady_clock::now(); + probe->start(pos, probeLimits, trace); + while (probe->searching() + && std::chrono::steady_clock::now() - probeStart < std::chrono::seconds(120)) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const bool done = !probe->searching(); + probe->stopAndWait(); + std::uint64_t nps = 0; + const std::string output = trace.str(); + for (auto it = std::sregex_iterator(output.begin(), output.end(), info); + it != std::sregex_iterator(); ++it) + nps = std::stoull((*it)[2].str()); + std::cout << "King feature NPS: " << label << " = " << nps << '\n'; + return done; + }; + + Tuning::eval().kingPinPenalty = 0; + Tuning::eval().kingXrayPenalty = 0; + const bool pinsDone = measure("pin-and-xray-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingShieldPenalty = 0; + Tuning::eval().kingPawnStormPenalty = 0; + Tuning::eval().kingPawnlessWingPenalty = 0; + const bool pawnsDone = measure("shield-storm-wing-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingZoneAttackPenalty = 0; + Tuning::eval().kingMateThreatPenalty = 0; + const bool threatDone = measure("king-zone-and-mate-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingShieldPenalty = 0; + Tuning::eval().kingZoneAttackPenalty = 0; + Tuning::eval().kingPawnStormPenalty = 0; + Tuning::eval().kingMateThreatPenalty = 0; + Tuning::eval().kingCentralizationBonus = 0; + Tuning::eval().kingPawnlessWingPenalty = 0; + Tuning::eval().kingPinPenalty = 0; + Tuning::eval().kingXrayPenalty = 0; + Tuning::eval().kingCastlingRightsBonus = 0; + const bool noKingDone = measure("all-new-king-features-disabled"); + Tuning::eval() = original; + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. - return finished ? 0 : 1; + return (finished && pinsDone && pawnsDone && threatDone && noKingDone) ? 0 : 1; } From f85aeff47436af81beb0900c6ca1a1791b4a30b3 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:38:37 -0500 Subject: [PATCH 04/18] Perf: reuse slider mobility attack maps to prefilter king pin and X-ray rays Build slider-only team attacks alongside existing mobility maps and intersect candidate screens with these attacks. Avoid expensive directional x-ray probes when no opposing slider actually attacks friendly screening material; preserve all king-evaluation terms and existing tunables. --- include/ironphoenix/king_eval.hpp | 3 ++- src/eval.cpp | 11 ++++++++--- src/king_eval.cpp | 28 ++++++++++++++++++---------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/include/ironphoenix/king_eval.hpp b/include/ironphoenix/king_eval.hpp index 0e964fe..a938b6a 100644 --- a/include/ironphoenix/king_eval.hpp +++ b/include/ironphoenix/king_eval.hpp @@ -8,6 +8,7 @@ namespace Eval { // Red+Yellow advantage minus Blue+Green advantage. // Supplied attack maps are collected by the existing mobility evaluator. [[nodiscard]] int kingFeatureDifference(const Position& pos, - const Bitboard (&teamAttacks)[2]) noexcept; + const Bitboard (&teamAttacks)[2], + const Bitboard (&sliderAttacks)[2]) noexcept; } } diff --git a/src/eval.cpp b/src/eval.cpp index 1afd499..8f4cc98 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -117,7 +117,8 @@ namespace ironphoenix::Eval { int mobilityForColor(const Position& pos, Color c, Bitboard& teamAttacks, unsigned& teamOutposts, - Bitboard* teamBishopAttacks) noexcept { + Bitboard* teamBishopAttacks, + Bitboard& teamSliderAttacks) noexcept { const Bitboard blockedByTeam = teamOccupancy(pos, c); const auto& tune = Tuning::eval(); const bool scoreThreats = tune.attackingPiecesWeight != 0 @@ -167,6 +168,8 @@ namespace ironphoenix::Eval { ++teamOutposts; if (scoreThreats) teamAttacks |= attacks; + if (pt != KNIGHT) + teamSliderAttacks |= attacks; } }; @@ -338,6 +341,7 @@ namespace ironphoenix::Eval { int handcraftedEvaluate(const Position& pos) noexcept { int team0 = 0, team1 = 0; Bitboard attacks[2]{}; + Bitboard sliderAttacks[2]{}; Bitboard bishopAttacks[2][2]{}; unsigned knightOutposts[2]{}; @@ -345,7 +349,8 @@ namespace ironphoenix::Eval { const Color c = static_cast(ci); const int score = materialForColor(pos, c) + mobilityForColor(pos, c, attacks[teamOf(c)], - knightOutposts[teamOf(c)], bishopAttacks[teamOf(c)]) + knightOutposts[teamOf(c)], bishopAttacks[teamOf(c)], + sliderAttacks[teamOf(c)]) + knightDevelopmentForColor(pos, c); if (teamOf(c) == 0) team0 += score; @@ -367,7 +372,7 @@ namespace ironphoenix::Eval { // pawn/king structures. All new feature weights default to zero. const int score = team0 - team1 + bishopFeatureDifference(pos, bishopAttacks) - + kingFeatureDifference(pos, attacks); + + kingFeatureDifference(pos, attacks, sliderAttacks); return teamOf(pos.sideToMove()) == 0 ? score : -score; } diff --git a/src/king_eval.cpp b/src/king_eval.cpp index a6facc4..f3d7ae6 100644 --- a/src/king_eval.cpp +++ b/src/king_eval.cpp @@ -172,10 +172,10 @@ FastContext makeContext(const Position& pos) noexcept { // only candidate friendly screens then need a second ray lookup. int rayPressure(const Position& pos, Square target, Bitboard friends, const TeamData& opponents, - Bitboard occupancy) noexcept { + Bitboard occupancy, Bitboard sliderAttacks) noexcept { int count = 0; if (opponents.orthogonalSliders) { - Bitboard screens = pos.rookAttacks(target) & friends; + Bitboard screens = pos.rookAttacks(target) & friends & sliderAttacks; while (screens) { const Square screen = popLsb(screens); if (typeOf(pos.pieceAt(screen)) == KING) @@ -190,7 +190,7 @@ int rayPressure(const Position& pos, Square target, } } if (opponents.diagonalSliders) { - Bitboard screens = pos.bishopAttacks(target) & friends; + Bitboard screens = pos.bishopAttacks(target) & friends & sliderAttacks; while (screens) { const Square screen = popLsb(screens); if (typeOf(pos.pieceAt(screen)) == KING) @@ -209,7 +209,8 @@ int rayPressure(const Position& pos, Square target, } int kingScore(const Position& pos, Color c, - const Bitboard (&attacks)[2], const FastContext& ctx, + const Bitboard (&attacks)[2], const Bitboard (&sliderAttacks)[2], + const FastContext& ctx, const Lookup& lookup, const Tuning::EvalParameters& w) noexcept { if (!pos.isAlive(c)) return 0; @@ -258,9 +259,13 @@ int kingScore(const Position& pos, Color c, score += phase * w.kingCastlingRightsBonus * castleRights(pos, c) / PHASE_MAX; - if (w.kingPinPenalty) + // Every absolute pin needs a friendly screen already attacked + // by an enemy slider. Use existing mobility attack maps to avoid + // unnecessary x-ray geometry in the common case. + if (w.kingPinPenalty && (sliderAttacks[team ^ 1] & own.occupants)) danger += w.kingPinPenalty - * rayPressure(pos, king, own.occupants, foe, ctx.occupancy); + * rayPressure(pos, king, own.occupants, foe, + ctx.occupancy, sliderAttacks[team ^ 1]); score -= danger * phase / PHASE_MAX; } @@ -282,12 +287,13 @@ int kingScore(const Position& pos, Color c, } } - if (w.kingXrayPenalty && (foe.orthogonalSliders || foe.diagonalSliders)) { + if (w.kingXrayPenalty && (foe.orthogonalSliders || foe.diagonalSliders) + && (sliderAttacks[team ^ 1] & own.occupants)) { Bitboard queens = pos.pieces(c, QUEEN); int rays = 0; while (queens) rays += rayPressure(pos, popLsb(queens), own.occupants, - foe, ctx.occupancy); + foe, ctx.occupancy, sliderAttacks[team ^ 1]); score -= w.kingXrayPenalty * std::min(4, rays); } return score; @@ -296,7 +302,8 @@ int kingScore(const Position& pos, Color c, } // namespace int kingFeatureDifference(const Position& pos, - const Bitboard (&teamAttacks)[2]) noexcept { + const Bitboard (&teamAttacks)[2], + const Bitboard (&sliderAttacks)[2]) noexcept { const auto& w = Tuning::eval(); if (!(w.kingShieldPenalty || w.kingPawnStormPenalty || w.kingZoneAttackPenalty || w.kingMateThreatPenalty @@ -310,7 +317,8 @@ int kingFeatureDifference(const Position& pos, int teams[2]{}; for (unsigned i = 0; i < COLOR_NB; ++i) { const Color c = static_cast(i); - teams[teamOf(c)] += kingScore(pos, c, teamAttacks, ctx, lookup, w); + teams[teamOf(c)] += kingScore(pos, c, teamAttacks, sliderAttacks, + ctx, lookup, w); } return teams[0] - teams[1]; } From debc3467ef755daa0c6b436c2df1c499757d2d94 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:46:21 -0500 Subject: [PATCH 05/18] Perf: avoid full givesCheck simulation for geometrically impossible quiet moves Keep check move ordering identical using conservative source/destination collinearity tests and one per-node existing-check query. Add SearchFastCheckPrefilter (default on) for A/B testing, strict ordering/metadata regression across real successor states, and same-runner NPS comparison. --- CMakeLists.txt | 6 ++ include/ironphoenix/search.hpp | 1 + include/ironphoenix/tuning.hpp | 3 + src/search.cpp | 94 +++++++++++++++++++++++- src/tuning.cpp | 9 ++- tests/check_order.cpp | 128 +++++++++++++++++++++++++++++++++ tests/nps_bench.cpp | 7 +- 7 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 tests/check_order.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1541e6a..fbc365b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,6 +219,12 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_knight_outpost PRIVATE cxx_std_20) add_test(NAME ironphoenix_knight_outpost COMMAND ironphoenix_knight_outpost) + add_executable(ironphoenix_check_order tests/check_order.cpp) + target_link_libraries(ironphoenix_check_order PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_check_order PRIVATE cxx_std_20) + add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) + set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_king_features tests/king_features.cpp) target_link_libraries(ironphoenix_king_features PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_king_features PRIVATE cxx_std_20) diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index c638319..c268adf 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -73,6 +73,7 @@ namespace ironphoenix { // Exposed only to the mate-stop regression test. friend struct MateStopTestAccess; friend struct ReverseFutilityTestAccess; + friend struct CheckOrderTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 7ce4880..c55d70c 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -31,6 +31,9 @@ namespace ironphoenix { int rfpMarginPerDepth = 141; int rfpMinMargin = 93; int rfpMaxImprovingDiscount = 82; + // 1 = cheaply reject geometrically impossible quiet checks. + // 0 = original full givesCheck() for A/B benchmarking. + int fastCheckPrefilter = 1; }; struct EvalParameters final { diff --git a/src/search.cpp b/src/search.cpp index 640ca22..cc3a024 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -45,6 +46,66 @@ namespace ironphoenix { return static_cast(pieces); } + // Conservative cheap prefilter for expensive quiet-move givesCheck(). + // Only return false if neither enemy king can be checked by the moved + // piece nor unmasked by vacating the source square. Special moves and + // pre-existing checks are handled by the caller with full givesCheck(). + IRONPHOENIX_FORCE_INLINE bool quietMayGiveCheck( + const Position& pos, Move move) noexcept { + const Piece moving = pos.pieceAt(move.from()); + if (!moving) + return true; + const Color us = colorOf(moving); + const PieceType type = typeOf(moving); + const Square from = move.from(), to = move.to(); + const int fromF = Geometry::fileOf(from), fromR = Geometry::rankOf(from); + const int toF = Geometry::fileOf(to), toR = Geometry::rankOf(to); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color opponent = static_cast(ci); + if (!enemy(us, opponent) || !pos.isAlive(opponent)) + continue; + const Square king = pos.kingSquare(opponent); + if (king == SQ_NONE) + continue; + const int kf = Geometry::fileOf(king), kr = Geometry::rankOf(king); + const int fdx = fromF - kf, fdy = fromR - kr; + // Uncovering a friendly slider check requires vacating a + // square collinear with its target king. + if (!fdx || !fdy || std::abs(fdx) == std::abs(fdy)) + return true; + + const int dx = toF - kf, dy = toR - kr; + const bool orthogonal = !dx || !dy; + const bool diagonal = std::abs(dx) == std::abs(dy); + switch (type) { + case PAWN: + if (Geometry::PawnAttacks[us][to].test(king)) + return true; + break; + case KNIGHT: + if (Geometry::KnightAttacks[to].test(king)) + return true; + break; + case BISHOP: + if (diagonal) return true; + break; + case ROOK: + if (orthogonal) return true; + break; + case QUEEN: + if (orthogonal || diagonal) return true; + break; + case KING: + if (Geometry::KingAttacks[to].test(king)) + return true; + break; + default: + return true; + } + } + return false; + } + IRONPHOENIX_FORCE_INLINE int captureOrderingScore(Piece movingPiece, Move move) noexcept { // Full four-player SEE is expensive enough that using it on every // generated capture purely for ordering costs substantial NPS. @@ -456,6 +517,8 @@ namespace ironphoenix { std::array scored; const std::size_t count = static_cast(end - begin); + bool existingCheckKnown = false; + bool existingCheck = false; for (std::size_t i = 0; i < count; ++i) { const Move move = begin[i]; const bool terminal = move.isCapture() && isTerminalKingCapture(pos, move); @@ -465,7 +528,36 @@ namespace ironphoenix { // check metadata for pruning safety. const bool needCheck = givesCheckBits != nullptr || ((!move.isCapture() && !move.isPromotion()) && move != ttMove); - const bool givesCheck = !terminal && needCheck && pos.givesCheck(move); + bool givesCheck = false; + if (!terminal && needCheck) { + bool possible = true; + if (Tuning::search().fastCheckPrefilter + && !move.isCapture() && !move.isPromotion() + && !move.isEnPassant() && !move.isCastle() + && !quietMayGiveCheck(pos, move)) { + // The old givesCheck() also reports checks that were + // already present before a quiet move. Compute this once + // per node so the fast prefilter is semantically exact. + if (!existingCheckKnown) { + existingCheckKnown = true; + const Color mover = pos.sideToMove(); + const Bitboard occ = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color foe = static_cast(ci); + if (!enemy(mover, foe) || !pos.isAlive(foe)) + continue; + const Square king = pos.kingSquare(foe); + if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) { + existingCheck = true; + break; + } + } + } + possible = existingCheck; + } + if (possible) + givesCheck = pos.givesCheck(move); + } scored[i].score = moveScore(pos, move, ttMove, ply, givesCheck); scored[i].move = move; diff --git a/src/tuning.cpp b/src/tuning.cpp index 0ae9498..7e01706 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -55,7 +55,8 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMaxDepth" || name == "SearchRFPMarginPerDepth" || name == "SearchRFPMinMargin" - || name == "SearchRFPMaxImprovingDiscount"; + || name == "SearchRFPMaxImprovingDiscount" + || name == "SearchFastCheckPrefilter"; } bool isEvalParameter(std::string_view name) noexcept { @@ -173,6 +174,8 @@ namespace ironphoenix::Tuning { p.rfpMinMargin = value; else if (name == "SearchRFPMaxImprovingDiscount" && inRange(value, 0, 5000)) p.rfpMaxImprovingDiscount = value; + else if (name == "SearchFastCheckPrefilter" && inRange(value, 0, 1)) + p.fastCheckPrefilter = value; else changed = false; @@ -320,6 +323,7 @@ namespace ironphoenix::Tuning { << "option name SearchRFPMarginPerDepth type spin default " << searchDefaults.rfpMarginPerDepth << " min 0 max 5000\n" << "option name SearchRFPMinMargin type spin default " << searchDefaults.rfpMinMargin << " min 0 max 5000\n" << "option name SearchRFPMaxImprovingDiscount type spin default " << searchDefaults.rfpMaxImprovingDiscount << " min 0 max 5000\n" + << "option name SearchFastCheckPrefilter type spin default " << searchDefaults.fastCheckPrefilter << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -377,7 +381,8 @@ namespace ironphoenix::Tuning { << " SearchRFPMaxDepth=" << s.rfpMaxDepth << " SearchRFPMarginPerDepth=" << s.rfpMarginPerDepth << " SearchRFPMinMargin=" << s.rfpMinMargin - << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount << '\n'; + << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount + << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/check_order.cpp b/tests/check_order.cpp new file mode 100644 index 0000000..4f9bb44 --- /dev/null +++ b/tests/check_order.cpp @@ -0,0 +1,128 @@ +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace ironphoenix { +struct CheckOrderTestAccess { + static void order(SearchEngine& s, const Position& pos, MoveList& moves, + std::array& scores, + std::array& checks) { + s.orderMoves(pos, moves.begin(), moves.end(), Move{}, 1, + scores.data(), checks.data()); + } +}; +} + +namespace { +int failures=0; +void require(bool ok, const char* reason) { + if (!ok) { ++failures; std::cerr << "FAIL: " << reason << '\n'; } +} +Square sq(char f, int r) { return Geometry::square(f-'a', r-1); } + +Position setup() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED, KING)); + p.placePiece(sq('a',7), makePiece(BLUE, KING)); + p.placePiece(sq('g',14), makePiece(YELLOW, KING)); + p.placePiece(sq('n',7), makePiece(GREEN, KING)); + p.placePiece(sq('e',5), makePiece(RED, BISHOP)); + p.placePiece(sq('h',6), makePiece(RED, ROOK)); + p.placePiece(sq('h',4), makePiece(RED, KNIGHT)); + p.placePiece(sq('i',11), makePiece(YELLOW, QUEEN)); + p.placePiece(sq('f',10), makePiece(YELLOW, BISHOP)); + p.placePiece(sq('d',8), makePiece(BLUE, ROOK)); + p.placePiece(sq('c',6), makePiece(BLUE, KNIGHT)); + p.placePiece(sq('k',9), makePiece(GREEN, QUEEN)); + p.placePiece(sq('j',8), makePiece(GREEN, BISHOP)); + p.placePiece(sq('g',3), makePiece(RED, PAWN)); + p.placePiece(sq('g',12), makePiece(YELLOW, PAWN)); + p.placePiece(sq('c',7), makePiece(BLUE, PAWN)); + p.placePiece(sq('l',7), makePiece(GREEN, PAWN)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} + +void compare(const Position& p, SearchEngine& engine) { + MoveList original; + generatePseudoLegalMoves(p, original); + if (!original.size) + return; + MoveList slow=original; + MoveList fast=original; + std::array scoresSlow{},scoresFast{}; + std::array checkSlow{},checkFast{}; + + Tuning::search().fastCheckPrefilter=0; + CheckOrderTestAccess::order(engine,p,slow,scoresSlow,checkSlow); + Tuning::search().fastCheckPrefilter=1; + CheckOrderTestAccess::order(engine,p,fast,scoresFast,checkFast); + + require(fast.size == slow.size, "move count unchanged"); + for (std::size_t i=0;i(); + Position p=setup(); + for (unsigned ci=0;ci(ci)); + p.finalizeSetup(); + compare(p,*engine); + } + + // Already existing rook check must not be falsely filtered out. + p=setup(); + p.placePiece(sq('c',9), makePiece(RED, ROOK)); + p.setSideToMove(RED); + p.finalizeSetup(); + compare(p,*engine); + + // Compare multiple legal 4-player successor states. + p=setup(); + for (int ply=0;ply<18;++ply) { + compare(p,*engine); + MoveList legal; + generateLegalMoves(p, legal); + Move selected{}; + for(std::size_t k=0;k(ply)*7u)%legal.size]; + if (!isTerminalKingCapture(p,m)) { selected=m; break; } + } + if (!selected) break; + StateInfo state{}; + p.makeMove(selected,state); + require(p.verify(), "position valid after selected legal move"); + } + Tuning::search()=defaults; +} +} +int main() { + Geometry::init(); + test(); + if (!failures) std::cout<<"Check ordering fast prefilter: PASS\n"; + return failures?1:0; +} diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index f9377ad..4378a1d 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -100,6 +100,7 @@ int main(int argc, char** argv) { // Compare feature groups on the same runner and search position. Never // treat absolute CI NPS as cross-machine evidence; compare relative changes. const auto original = Tuning::eval(); + const auto originalSearch = Tuning::search(); auto measure = [&](const char* label) { auto probe = std::make_unique(); SearchLimits probeLimits{}; @@ -148,8 +149,12 @@ int main(int argc, char** argv) { Tuning::eval().kingCastlingRightsBonus = 0; const bool noKingDone = measure("all-new-king-features-disabled"); Tuning::eval() = original; + Tuning::search().fastCheckPrefilter = 0; + const bool originalOrderingDone = measure("fast-check-prefilter-disabled"); + Tuning::search() = originalSearch; // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. - return (finished && pinsDone && pawnsDone && threatDone && noKingDone) ? 0 : 1; + return (finished && pinsDone && pawnsDone && threatDone + && noKingDone && originalOrderingDone) ? 0 : 1; } From a18465610195f1d879492ebe3ba6fcf070953380 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:51:14 -0500 Subject: [PATCH 06/18] Perf: apply conservative check prefilter to non-PV LMP/HMP candidates Avoid full simulated-check queries for geometrically impossible quiet moves during pruning, preserving pre-existing opponent checks with a cached per-node test. Add fixed-depth score/bestmove equivalence coverage against the original ordering path. --- src/search.cpp | 50 +++++++++++++++++++++++++++++++------------ tests/check_order.cpp | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index cc3a024..a782061 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -106,6 +106,21 @@ namespace ironphoenix { return false; } + IRONPHOENIX_FORCE_INLINE bool moverAlreadyChecksEnemy( + const Position& pos) noexcept { + const Color mover = pos.sideToMove(); + const Bitboard occ = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color foe = static_cast(ci); + if (!enemy(mover, foe) || !pos.isAlive(foe)) + continue; + const Square king = pos.kingSquare(foe); + if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) + return true; + } + return false; + } + IRONPHOENIX_FORCE_INLINE int captureOrderingScore(Piece movingPiece, Move move) noexcept { // Full four-player SEE is expensive enough that using it on every // generated capture purely for ordering costs substantial NPS. @@ -540,18 +555,7 @@ namespace ironphoenix { // per node so the fast prefilter is semantically exact. if (!existingCheckKnown) { existingCheckKnown = true; - const Color mover = pos.sideToMove(); - const Bitboard occ = pos.occupancy(); - for (unsigned ci = 0; ci < COLOR_NB; ++ci) { - const Color foe = static_cast(ci); - if (!enemy(mover, foe) || !pos.isAlive(foe)) - continue; - const Square king = pos.kingSquare(foe); - if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) { - existingCheck = true; - break; - } - } + existingCheck = moverAlreadyChecksEnemy(pos); } possible = existingCheck; } @@ -1001,6 +1005,11 @@ namespace ironphoenix { int bestScore = -INF; Move bestMove{}; + // Both non-PV move-level HMP/LMP and SEE exclusions may need a + // check query. Cache pre-existing checks once for geometric fast paths. + bool existingCheckKnown = false; + bool existingCheck = false; + // Quiet failures grow from the front and capture failures from the back, // replacing two 512-Move arrays with one without changing history updates. std::array failedMoves; @@ -1058,8 +1067,21 @@ namespace ironphoenix { && depth <= tuning.seePruneMaxDepth && move.isCapture() && !move.isPromotion(); - const bool givesCheckMove = (lmpCandidate || hmpCandidate || seeCandidate) - && !terminalKingCapture && pos.givesCheck(move); + bool givesCheckMove = false; + if ((lmpCandidate || hmpCandidate || seeCandidate) && !terminalKingCapture) { + bool checkPossible = true; + if (quiet && !move.isCastle() && !move.isEnPassant() + && Tuning::search().fastCheckPrefilter + && !quietMayGiveCheck(pos, move)) { + if (!existingCheckKnown) { + existingCheckKnown = true; + existingCheck = moverAlreadyChecksEnemy(pos); + } + checkPossible = existingCheck; + } + if (checkPossible) + givesCheckMove = pos.givesCheck(move); + } // Late Move Pruning (LMP): keep checking moves and all exclusions. if (lmpCandidate && !givesCheckMove) diff --git a/tests/check_order.cpp b/tests/check_order.cpp index 4f9bb44..a465088 100644 --- a/tests/check_order.cpp +++ b/tests/check_order.cpp @@ -4,6 +4,11 @@ #include "ironphoenix/tuning.hpp" #include +#include +#include +#include +#include +#include #include #include #include @@ -80,6 +85,37 @@ void compare(const Position& p, SearchEngine& engine) { } } +std::pair runFixedDepth(int fast) { + Tuning::search().fastCheckPrefilter=fast; + auto engine=std::make_unique(); + SearchLimits limits{}; + limits.depth=3; + std::ostringstream out; + engine->start(setup(), limits, out); + const auto start=std::chrono::steady_clock::now(); + while (engine->searching() && + std::chrono::steady_clock::now()-start < std::chrono::seconds(12)) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + require(!engine->searching(), "depth-3 search completed"); + engine->stopAndWait(); + const std::string result=out.str(); + const std::size_t best=result.rfind("bestmove "); + require(best!=std::string::npos, "bestmove emitted"); + const std::string finalMove=best==std::string::npos ? "" : + result.substr(best, result.find('\n',best)-best); + + // Ignore elapsed time/NPS, which are nondeterministic across searches. + const std::size_t info=result.rfind("info depth 3 "); + require(info!=std::string::npos,"third-depth info emitted"); + const std::size_t score=info==std::string::npos ? std::string::npos : + result.find(" score ",info); + const std::size_t nodes=score==std::string::npos ? score : + result.find(" nodes ",score); + const std::string finalScore=(score==std::string::npos || nodes==std::string::npos) + ? "" : result.substr(score,nodes-score); + return {finalMove,finalScore}; +} + void test() { const auto defaults=Tuning::search(); require(defaults.fastCheckPrefilter==1, "fast check default enabled"); @@ -117,6 +153,10 @@ void test() { p.makeMove(selected,state); require(p.verify(), "position valid after selected legal move"); } + const auto originalSearch=runFixedDepth(0); + const auto optimizedSearch=runFixedDepth(1); + require(originalSearch==optimizedSearch, + "fast prefilter preserves depth-3 bestmove and score"); Tuning::search()=defaults; } } From 843cc13e33d46d296b55b5106037faadabf60e11 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:53:08 -0500 Subject: [PATCH 07/18] Perf: revert redundant non-PV check prefilter after same-runner benchmark The hot-loop extension brought no measurable NPS improvement (831k vs 834k with it off) and added extra per-node attack checks. Keep the verified PV move-ordering shortcut and all faster king-evaluation features. --- src/search.cpp | 50 ++++++++++++++------------------------------------ 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index a782061..cc3a024 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -106,21 +106,6 @@ namespace ironphoenix { return false; } - IRONPHOENIX_FORCE_INLINE bool moverAlreadyChecksEnemy( - const Position& pos) noexcept { - const Color mover = pos.sideToMove(); - const Bitboard occ = pos.occupancy(); - for (unsigned ci = 0; ci < COLOR_NB; ++ci) { - const Color foe = static_cast(ci); - if (!enemy(mover, foe) || !pos.isAlive(foe)) - continue; - const Square king = pos.kingSquare(foe); - if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) - return true; - } - return false; - } - IRONPHOENIX_FORCE_INLINE int captureOrderingScore(Piece movingPiece, Move move) noexcept { // Full four-player SEE is expensive enough that using it on every // generated capture purely for ordering costs substantial NPS. @@ -555,7 +540,18 @@ namespace ironphoenix { // per node so the fast prefilter is semantically exact. if (!existingCheckKnown) { existingCheckKnown = true; - existingCheck = moverAlreadyChecksEnemy(pos); + const Color mover = pos.sideToMove(); + const Bitboard occ = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color foe = static_cast(ci); + if (!enemy(mover, foe) || !pos.isAlive(foe)) + continue; + const Square king = pos.kingSquare(foe); + if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) { + existingCheck = true; + break; + } + } } possible = existingCheck; } @@ -1005,11 +1001,6 @@ namespace ironphoenix { int bestScore = -INF; Move bestMove{}; - // Both non-PV move-level HMP/LMP and SEE exclusions may need a - // check query. Cache pre-existing checks once for geometric fast paths. - bool existingCheckKnown = false; - bool existingCheck = false; - // Quiet failures grow from the front and capture failures from the back, // replacing two 512-Move arrays with one without changing history updates. std::array failedMoves; @@ -1067,21 +1058,8 @@ namespace ironphoenix { && depth <= tuning.seePruneMaxDepth && move.isCapture() && !move.isPromotion(); - bool givesCheckMove = false; - if ((lmpCandidate || hmpCandidate || seeCandidate) && !terminalKingCapture) { - bool checkPossible = true; - if (quiet && !move.isCastle() && !move.isEnPassant() - && Tuning::search().fastCheckPrefilter - && !quietMayGiveCheck(pos, move)) { - if (!existingCheckKnown) { - existingCheckKnown = true; - existingCheck = moverAlreadyChecksEnemy(pos); - } - checkPossible = existingCheck; - } - if (checkPossible) - givesCheckMove = pos.givesCheck(move); - } + const bool givesCheckMove = (lmpCandidate || hmpCandidate || seeCandidate) + && !terminalKingCapture && pos.givesCheck(move); // Late Move Pruning (LMP): keep checking moves and all exclusions. if (lmpCandidate && !givesCheckMove) From c1882546f82e79bdbbe2d55586524e91a52dc6c5 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 11:55:00 -0500 Subject: [PATCH 08/18] Build: add optional native-CPU Release optimization for local speed Add opt-in -march=native GCC/Clang or MSVC /arch:AVX2 for Release, including header-inline bitboard paths. Keep portable CI defaults and document reproducible same-machine HCE/king feature throughput tests. --- CMakeLists.txt | 19 ++++++++++++++ docs/performance.md | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/performance.md diff --git a/CMakeLists.txt b/CMakeLists.txt index fbc365b..e07c6b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ set_property(GLOBAL PROPERTY USE_FOLDERS ON) option(IRONPHOENIX_BUILD_TESTS "Build IronPhoenix correctness tests" ON) option(IRONPHOENIX_BUILD_BENCHMARK "Build IronPhoenix microbenchmark" ON) option(IRONPHOENIX_BUILD_DATASET_GENERATOR "Build PhoenixNet dataset generator" ON) +option(IRONPHOENIX_NATIVE_CPU "Optimize Release/RelWithDebInfo for the build host CPU; nonportable binaries" OFF) add_library(ironphoenix_core STATIC src/geometry.cpp @@ -52,6 +53,24 @@ target_include_directories(ironphoenix_core target_compile_features(ironphoenix_core PUBLIC cxx_std_20) target_link_libraries(ironphoenix_core PUBLIC Threads::Threads) +# Only enable for a binary that will run on the same CPU generation used to +# build it. Keep CI/distribution generic so older systems are not exposed to +# unsupported instructions. PUBLIC propagates ISA flags to tests/apps because +# Bitboard intrinsics are also header-inlined. +if(IRONPHOENIX_NATIVE_CPU) + if(MSVC) + target_compile_options(ironphoenix_core PUBLIC + $<$:/arch:AVX2> + $<$:/arch:AVX2> + ) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(ironphoenix_core PUBLIC + $<$:-march=native> + $<$:-march=native> + ) + endif() +endif() + if(MSVC) target_compile_options(ironphoenix_core PRIVATE /W4 diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..c3c9d13 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,64 @@ +# IronPhoenix performance experiments + +Branch: \`feature/king-evaluation\`. Keep \`main\` stable until Elo tests. + +## Changes + +- Precomputed immutable, seat-aware king shield/storm/wing masks. +- Hoisted four-army material phase, slider presence, team pawns and occupancy + out of the per-king evaluation path. +- Reuse the existing mobility slider-attack maps to avoid pin/X-ray probes + if no opposing slider attacks an allied screening piece. +- Fast (exact) geometric prefilter for PV quiet move check ordering. Discovered + checks and checks that already exist are handled by the full evaluator. +- UCI \`SearchFastCheckPrefilter\` defaults to 1; use 0 for A/B comparison. + A broader version in non-PV pruning was tested and **reverted** because it + showed no measurable benefit on the same runner. +- Benchmark \`ironphoenix_nps_bench\` now measures feature groups in addition + to its original fixed-position search throughput. + +## Native CPU Release build (opt-in) + +CMake option: \`IRONPHOENIX_NATIVE_CPU=ON\` (default OFF). + +**Windows (PowerShell / Visual Studio generator):** + +\`\`\`powershell +cmake -S . -B build-native -DIRONPHOENIX_NATIVE_CPU=ON +cmake --build build-native --config Release --parallel +.\build-native\Release\ironphoenix_nps_bench.exe +\`\`\` + +**Linux (GCC/Clang):** + +\`\`\`bash +cmake -S . -B build-native -DCMAKE_BUILD_TYPE=Release -DIRONPHOENIX_NATIVE_CPU=ON +cmake --build build-native --parallel +./build-native/ironphoenix_nps_bench +\`\`\` + +GCC/Clang use \`-march=native\`; MSVC uses \`/arch:AVX2\` for Release +and RelWithDebInfo. The MSVC option is a broad, predictable instruction-set +target rather than necessarily the exact highest available CPU features. + +**Do not redistribute a native build to arbitrary computers**: it may use +instructions unsupported by another CPU. Keep portable builds with the +option OFF. + +## How to judge speed + +1. Run portable and native binaries on the **same computer** with the + same node limit and fixture. +2. Repeat each benchmark (e.g., five times), compare median elapsed time + and NPS rather than a single run. +3. Run both with \`Threads=1\` when comparing per-core changes; then compare + total throughput with \`Threads=4\`, \`8\`, etc. +4. Judge search strength separately via paired SPRT. Higher NPS alone is + not evidence that a pruning change increases Elo. + +CI cloud runners have different CPU allocations, so absolute NPS between +different workflow runs is not a valid speedup measurement. The benchmark's +same-runner feature variants provide better relative evidence. + +This feature branch retains all new king-evaluation terms. NNUE still +bypasses HCE; the HCE speed work helps only when NNUE is disabled. From 74799048a2d0acbdfc1f3d74eb894ef118b9c526 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 13:18:59 -0500 Subject: [PATCH 09/18] Perf: trial exact per-worker evaluation cache with search-local invalidation Cache full HCE scores for repeated Zobrist positions using private 16K-entry direct-mapped tables, invalidate each new search/variant, expose SearchEvalCache (default 1) and compare on/off with fixed-position same-runner NPS diagnostics. --- include/ironphoenix/search.hpp | 15 +++++++++++++++ include/ironphoenix/tuning.hpp | 3 +++ src/multipv.cpp | 6 ++++++ src/search.cpp | 24 +++++++++++++++++++++++- src/tuning.cpp | 9 +++++++-- tests/nps_bench.cpp | 8 +++++++- 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index c268adf..e423180 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -98,6 +98,21 @@ namespace ironphoenix { bool helperMode_ = false; int helperIndex_ = 0; int threads_ = 1; + + // Search-local, worker-private HCE cache. Lazily invalidated by epoch + // at every start, so changed tuning/network settings never leave stale + // evaluations. Avoid contention between Lazy SMP workers. + static constexpr std::size_t EVAL_CACHE_CAPACITY = 1u << 14; + struct EvalCacheEntry { + Key key = 0; + int score = 0; + std::uint32_t epoch = 0; + }; + mutable std::array evalCache_{}; + mutable std::uint64_t evalCacheHits_ = 0; + std::uint32_t evalCacheEpoch_ = 1; + bool evalCacheActive_ = false; + HistoryTables history_{}; std::thread worker_; std::atomic stopRequested_{ false }; diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index c55d70c..476e165 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -34,6 +34,9 @@ namespace ironphoenix { // 1 = cheaply reject geometrically impossible quiet checks. // 0 = original full givesCheck() for A/B benchmarking. int fastCheckPrefilter = 1; + // Exact full-evaluation cache, private to each search worker. + // Set 0 to disable for NPS/SPRT comparisons. + int evalCacheEnabled = 1; }; struct EvalParameters final { diff --git a/src/multipv.cpp b/src/multipv.cpp index f72b89c..789ee89 100644 --- a/src/multipv.cpp +++ b/src/multipv.cpp @@ -37,6 +37,12 @@ void SearchEngine::startMultiPV( multiPV = std::clamp(multiPV, 2, MAX_MULTI_PV); stopAndWait(); + if (++evalCacheEpoch_ == 0) { + evalCacheEpoch_ = 1; + evalCache_.fill(EvalCacheEntry{}); + } + evalCacheHits_ = 0; + evalCacheActive_ = true; stopRequested_.store(false, std::memory_order_relaxed); ponderHitRequested_.store(false, std::memory_order_relaxed); searching_.store(true, std::memory_order_relaxed); diff --git a/src/search.cpp b/src/search.cpp index cc3a024..2f6486a 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -151,6 +151,14 @@ namespace ironphoenix { void SearchEngine::start(Position position, SearchLimits limits, std::ostream& out) { stopAndWait(); + // One search always sees one set of tuning weights; do not reuse HCE + // entries across successive searches or engine option changes. + if (++evalCacheEpoch_ == 0) { + evalCacheEpoch_ = 1; + evalCache_.fill(EvalCacheEntry{}); + } + evalCacheHits_ = 0; + evalCacheActive_ = true; stopRequested_.store(false, std::memory_order_relaxed); ponderHitRequested_.store(false, std::memory_order_relaxed); searching_.store(true, std::memory_order_relaxed); @@ -445,7 +453,21 @@ namespace ironphoenix { } int SearchEngine::evaluate(const Position& pos) const noexcept { - return Eval::evaluate(pos); + if (!evalCacheActive_ || !Tuning::search().evalCacheEnabled) + return Eval::evaluate(pos); + + const Key key = pos.key(); + EvalCacheEntry& entry = + evalCache_[static_cast(key) & (EVAL_CACHE_CAPACITY - 1)]; + if (entry.epoch == evalCacheEpoch_ && entry.key == key) { + ++evalCacheHits_; + return entry.score; + } + const int score = Eval::evaluate(pos); + entry.key = key; + entry.score = score; + entry.epoch = evalCacheEpoch_; + return score; } int SearchEngine::historyBonus(int depth) const noexcept { diff --git a/src/tuning.cpp b/src/tuning.cpp index 7e01706..388eb01 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -56,7 +56,8 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMarginPerDepth" || name == "SearchRFPMinMargin" || name == "SearchRFPMaxImprovingDiscount" - || name == "SearchFastCheckPrefilter"; + || name == "SearchFastCheckPrefilter" + || name == "SearchEvalCache"; } bool isEvalParameter(std::string_view name) noexcept { @@ -176,6 +177,8 @@ namespace ironphoenix::Tuning { p.rfpMaxImprovingDiscount = value; else if (name == "SearchFastCheckPrefilter" && inRange(value, 0, 1)) p.fastCheckPrefilter = value; + else if (name == "SearchEvalCache" && inRange(value, 0, 1)) + p.evalCacheEnabled = value; else changed = false; @@ -324,6 +327,7 @@ namespace ironphoenix::Tuning { << "option name SearchRFPMinMargin type spin default " << searchDefaults.rfpMinMargin << " min 0 max 5000\n" << "option name SearchRFPMaxImprovingDiscount type spin default " << searchDefaults.rfpMaxImprovingDiscount << " min 0 max 5000\n" << "option name SearchFastCheckPrefilter type spin default " << searchDefaults.fastCheckPrefilter << " min 0 max 1\n" + << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -382,7 +386,8 @@ namespace ironphoenix::Tuning { << " SearchRFPMarginPerDepth=" << s.rfpMarginPerDepth << " SearchRFPMinMargin=" << s.rfpMinMargin << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount - << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << '\n'; + << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter + << " SearchEvalCache=" << s.evalCacheEnabled << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index 4378a1d..1e13057 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -153,8 +153,14 @@ int main(int argc, char** argv) { const bool originalOrderingDone = measure("fast-check-prefilter-disabled"); Tuning::search() = originalSearch; + Tuning::search().evalCacheEnabled = 0; + const bool cacheDisabledDone = measure("eval-cache-disabled"); + Tuning::search() = originalSearch; + const bool cacheEnabledDone = measure("eval-cache-enabled"); + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. return (finished && pinsDone && pawnsDone && threatDone - && noKingDone && originalOrderingDone) ? 0 : 1; + && noKingDone && originalOrderingDone + && cacheDisabledDone && cacheEnabledDone) ? 0 : 1; } From 7d77cecb86e50f13525b2d59e0d869ac72c7fee9 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 13:21:44 -0500 Subject: [PATCH 10/18] Perf: remove lock construction from single-thread TT probe/store Inline the unlocked cluster operation and acquire striped mutexes only for concurrent SMP, preserving synchronization. Cache replacement priority inside TT store to avoid repeat scoring. --- src/tt.cpp | 107 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/src/tt.cpp b/src/tt.cpp index 40b7855..7686fc5 100644 --- a/src/tt.cpp +++ b/src/tt.cpp @@ -87,21 +87,28 @@ bool TranspositionTable::probe(Key key, int ply, TTProbe& out) const noexcept { return false; const std::size_t index = static_cast(key) & mask_; - std::unique_lock guard(locks_[index % LOCK_STRIPES], std::defer_lock); - if (concurrent_) guard.lock(); - const Cluster& cluster = table_[index]; - for (const Entry& entry : cluster.entry) { - if (entry.key == key && entry.key != 0) { - out.hit = true; - out.move = Move(entry.move); - out.score = scoreFromTT(entry.score, ply); - out.depth = entry.depth; - out.bound = boundOf(entry); - out.pvNode = pvOf(entry); - return true; + // The common Threads=1 path should not even construct a lock. + // Concurrent Lazy SMP workers lock the same cluster stripe for probes + // and writes, so entries remain coherent across cores. + const auto probeCluster = [&]() noexcept { + const Cluster& cluster = table_[index]; + for (const Entry& entry : cluster.entry) { + if (entry.key == key && entry.key != 0) { + out.hit = true; + out.move = Move(entry.move); + out.score = scoreFromTT(entry.score, ply); + out.depth = entry.depth; + out.bound = boundOf(entry); + out.pvNode = pvOf(entry); + return true; + } } - } - return false; + return false; + }; + if (!concurrent_) + return probeCluster(); + std::lock_guard lock(locks_[index % LOCK_STRIPES]); + return probeCluster(); } void TranspositionTable::store(Key key, @@ -115,42 +122,48 @@ void TranspositionTable::store(Key key, return; const std::size_t index = static_cast(key) & mask_; - std::unique_lock guard(locks_[index % LOCK_STRIPES], std::defer_lock); - if (concurrent_) guard.lock(); - Cluster& cluster = table_[index]; - Entry* replace = &cluster.entry[0]; - - for (Entry& entry : cluster.entry) { - if (entry.key == key) { - replace = &entry; - break; - } - if (entry.key == 0) { - replace = &entry; - break; + const auto storeCluster = [&]() noexcept { + Cluster& cluster = table_[index]; + Entry* replace = &cluster.entry[0]; + const auto replacementScore = [&](const Entry& entry) noexcept { + const unsigned age = (generation_ - generationOf(entry)) + & (GENERATION_MOD - 1u); + return static_cast(entry.depth) - static_cast(age) * 4 + + (pvOf(entry) ? 2 : 0); + }; + int replaceValue = replacementScore(*replace); + for (Entry& entry : cluster.entry) { + if (entry.key == key || entry.key == 0) { + replace = &entry; + break; + } + const int value = replacementScore(entry); + if (value < replaceValue) { + replaceValue = value; + replace = &entry; + } } - const unsigned age = (generation_ - generationOf(entry)) & (GENERATION_MOD - 1u); - const int value = static_cast(entry.depth) - static_cast(age) * 4 + (pvOf(entry) ? 2 : 0); - - const unsigned replaceAge = (generation_ - generationOf(*replace)) & (GENERATION_MOD - 1u); - const int replaceValue = static_cast(replace->depth) - static_cast(replaceAge) * 4 - + (pvOf(*replace) ? 2 : 0); - if (value < replaceValue) - replace = &entry; + Move move = bestMove; + if (!move && replace->key == key) + move = Move(replace->move); + + const int ttScore = scoreToTT(score, ply); + replace->key = key; + replace->move = move.v; + replace->score = static_cast(std::clamp(ttScore, -32767, 32767)); + replace->depth = static_cast(std::clamp(depth, -1, 127)); + replace->flags = static_cast( + static_cast(bound) + | (pvNode ? PV_MASK : 0u) + | ((generation_ & (GENERATION_MOD - 1u)) << GENERATION_SHIFT)); + }; + if (!concurrent_) { + storeCluster(); + return; } - - if (!bestMove && replace->key == key) - bestMove = Move(replace->move); - - const int ttScore = scoreToTT(score, ply); - replace->key = key; - replace->move = bestMove.v; - replace->score = static_cast(std::clamp(ttScore, -32767, 32767)); - replace->depth = static_cast(std::clamp(depth, -1, 127)); - replace->flags = static_cast(static_cast(bound) - | (pvNode ? PV_MASK : 0u) - | ((generation_ & (GENERATION_MOD - 1u)) << GENERATION_SHIFT)); + std::lock_guard lock(locks_[index % LOCK_STRIPES]); + storeCluster(); } int TranspositionTable::hashfull() const noexcept { From 4412a1b0eee54f251e7dcb9c3aea05afad4e112b Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 13:23:16 -0500 Subject: [PATCH 11/18] Tests: validate eval cache exactness, option bounds and epoch invalidation Exercise cache hits, disabled mode, side-to-move Zobrist keys and stale score avoidance when tuning weights change between searches. --- CMakeLists.txt | 6 ++ include/ironphoenix/search.hpp | 1 + tests/eval_cache.cpp | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/eval_cache.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e07c6b1..d0bfb12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -244,6 +244,12 @@ if(IRONPHOENIX_BUILD_TESTS) add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_eval_cache tests/eval_cache.cpp) + target_link_libraries(ironphoenix_eval_cache PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_eval_cache PRIVATE cxx_std_20) + add_test(NAME ironphoenix_eval_cache COMMAND ironphoenix_eval_cache) + set_target_properties(ironphoenix_eval_cache PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_king_features tests/king_features.cpp) target_link_libraries(ironphoenix_king_features PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_king_features PRIVATE cxx_std_20) diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index e423180..4a837f4 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -74,6 +74,7 @@ namespace ironphoenix { friend struct MateStopTestAccess; friend struct ReverseFutilityTestAccess; friend struct CheckOrderTestAccess; + friend struct EvalCacheTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { diff --git a/tests/eval_cache.cpp b/tests/eval_cache.cpp new file mode 100644 index 0000000..643561a --- /dev/null +++ b/tests/eval_cache.cpp @@ -0,0 +1,109 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include +#include + +using namespace ironphoenix; +namespace ironphoenix { +struct EvalCacheTestAccess { + static int eval(SearchEngine& engine, const Position& p) { + return engine.evaluate(p); + } + static std::uint64_t hits(const SearchEngine& engine) { + return engine.evalCacheHits_; + } + static void newSearch(SearchEngine& engine) { + ++engine.evalCacheEpoch_; + engine.evalCacheActive_ = true; + engine.evalCacheHits_ = 0; + } +}; +} +namespace { +int failures=0; +void expect(bool okay, const char* label) { + if (!okay) { ++failures; std::cerr << "FAIL: " << label << '\n'; } +} +Square sq(char f, int r) { return Geometry::square(f-'a',r-1); } +Position setup() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED,KING)); + p.placePiece(sq('a',7), makePiece(BLUE,KING)); + p.placePiece(sq('g',14), makePiece(YELLOW,KING)); + p.placePiece(sq('n',7), makePiece(GREEN,KING)); + p.placePiece(sq('e',5), makePiece(RED,KNIGHT)); + p.placePiece(sq('d',8), makePiece(BLUE,BISHOP)); + p.placePiece(sq('f',10), makePiece(YELLOW,ROOK)); + p.placePiece(sq('j',8), makePiece(GREEN,QUEEN)); + p.placePiece(sq('f',3), makePiece(RED,PAWN)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} +void test() { + expect(!NNUE::loaded(), "test exact HCE scores"); + Tuning::resetSearch(); + Tuning::resetEval(); + auto engine=std::make_unique(); + auto p=setup(); + const int baseline=Eval::evaluate(p); + + expect(Tuning::search().evalCacheEnabled==1, "default enabled"); + expect(Tuning::setSearchParameter("SearchEvalCache",0),"UCI accepts disable"); + expect(Tuning::setSearchParameter("SearchEvalCache",1),"UCI accepts enable"); + expect(!Tuning::setSearchParameter("SearchEvalCache",2),"UCI rejects invalid option"); + std::ostringstream options; + Tuning::printUciOptions(options); + expect(options.str().find("option name SearchEvalCache type spin default 1 min 0 max 1") + != std::string::npos, "UCI option advertised"); + + // Per-worker entries start inactive; the first call cannot hit stale data. + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"inactive cache exact"); + expect(EvalCacheTestAccess::hits(*engine)==0, "inactive cache no hits"); + EvalCacheTestAccess::newSearch(*engine); + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"first lookup full eval"); + expect(EvalCacheTestAccess::hits(*engine)==0, "first lookup misses"); + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"second lookup exact cache"); + expect(EvalCacheTestAccess::hits(*engine)==1, "repeated key hits"); + + p.setSideToMove(BLUE); + p.finalizeSetup(); + expect(EvalCacheTestAccess::eval(*engine,p)==Eval::evaluate(p), + "side-to-move key changes cached score"); + + p.setSideToMove(RED); + p.finalizeSetup(); + const auto original = Tuning::eval(); + Tuning::eval().knightMobility += 15; + const int changed = Eval::evaluate(p); + expect(changed != baseline, "changing eval weight affects fixture score"); + // Options are only modified after stop; the next search bumps epoch. + EvalCacheTestAccess::newSearch(*engine); + expect(EvalCacheTestAccess::eval(*engine,p)==changed, + "new search never reuses stale evaluation across tuning changes"); + + Tuning::search().evalCacheEnabled=0; + const int raw=EvalCacheTestAccess::eval(*engine,p); + expect(raw==Eval::evaluate(p),"disabled cache returns exact evaluation"); + expect(EvalCacheTestAccess::hits(*engine)==0, + "disabled cache never returns hits"); + + Tuning::eval()=original; + Tuning::resetSearch(); +} +} +int main() { + Geometry::init(); + test(); + if(!failures) std::cout<<"Exact search-local eval cache tests: PASS\n"; + return failures ? 1 : 0; +} From b8186f5470e239833f6b5a7adf741a230bffad32 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 13:28:08 -0500 Subject: [PATCH 12/18] Perf: skip provably redundant king-in-check tests after ordinary moves Use 4PC rank/file/diagonal alignment to verify only moves that could uncover an enemy slider attack on a previously safe king; retain full tests for checks, king moves, en passant and castling. Apply in negamax and qsearch, add SearchFastKingLegality A/B option, property-style legality and depth-three score tests, and same-runner NPS diagnostic. --- CMakeLists.txt | 7 ++ include/ironphoenix/king_legality.hpp | 27 +++++ include/ironphoenix/tuning.hpp | 3 + src/search.cpp | 15 ++- src/tuning.cpp | 9 +- tests/king_legality.cpp | 146 ++++++++++++++++++++++++++ tests/nps_bench.cpp | 8 +- 7 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 include/ironphoenix/king_legality.hpp create mode 100644 tests/king_legality.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d0bfb12..44cf853 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ add_library(ironphoenix_core STATIC include/ironphoenix/lmr.hpp include/ironphoenix/nnue.hpp include/ironphoenix/tuning.hpp + include/ironphoenix/king_legality.hpp ) add_library(IronPhoenix::Core ALIAS ironphoenix_core) @@ -244,6 +245,12 @@ if(IRONPHOENIX_BUILD_TESTS) add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_king_legality tests/king_legality.cpp) + target_link_libraries(ironphoenix_king_legality PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_king_legality PRIVATE cxx_std_20) + add_test(NAME ironphoenix_king_legality COMMAND ironphoenix_king_legality) + set_target_properties(ironphoenix_king_legality PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_eval_cache tests/eval_cache.cpp) target_link_libraries(ironphoenix_eval_cache PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_eval_cache PRIVATE cxx_std_20) diff --git a/include/ironphoenix/king_legality.hpp b/include/ironphoenix/king_legality.hpp new file mode 100644 index 0000000..12f1384 --- /dev/null +++ b/include/ironphoenix/king_legality.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "position.hpp" + +namespace ironphoenix { + +// Only slider attacks can become newly exposed when an ordinary move vacates +// one source square. If that source is not on any rank/file/diagonal passing +// through our king, then an already-safe king cannot become checked. +// Castling/EP remove other pieces and king moves relocate the target, so they +// always require full validation. This returns true conservatively. +[[nodiscard]] IRONPHOENIX_FORCE_INLINE bool kingCheckCanChange( + const Position& pos, Move move) noexcept { + if (move.isEnPassant() || move.isCastle()) + return true; + const Piece moving = pos.pieceAt(move.from()); + if (moving == NO_PIECE || typeOf(moving) == KING) + return true; + const Square king = pos.kingSquare(pos.sideToMove()); + if (king == SQ_NONE) + return true; + for (unsigned family = 0; family < Geometry::LINE_FAMILY_NB; ++family) + if (Geometry::LineId[family][king] == Geometry::LineId[family][move.from()]) + return true; + return false; +} + +} // namespace ironphoenix diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 476e165..fc2d91a 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -37,6 +37,9 @@ namespace ironphoenix { // Exact full-evaluation cache, private to each search worker. // Set 0 to disable for NPS/SPRT comparisons. int evalCacheEnabled = 1; + // Geometrically skip redundant inCheck(mover) after safe moves. + // 0 forces original full move-by-move legality verification. + int fastKingLegality = 1; }; struct EvalParameters final { diff --git a/src/search.cpp b/src/search.cpp index 2f6486a..8a58905 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -2,6 +2,7 @@ #include "ironphoenix/eval.hpp" #include "ironphoenix/lmr.hpp" +#include "ironphoenix/king_legality.hpp" #include "ironphoenix/tuning.hpp" #include "ironphoenix/movegen.hpp" @@ -768,10 +769,17 @@ namespace ironphoenix { pvLength_[ply + 1] = ply + 1; } else { + // A non-king move from a square outside every king ray + // cannot expose a new enemy attack on an already-safe king. + // King moves, EP, castling, checks and aligned sources keep + // full verification. All subsequent move/search logic is same. + const bool verifyKing = inCheckNode + || !Tuning::search().fastKingLegality + || kingCheckCanChange(pos, move); StateInfo st; pos.makeMove(move, st); - if (pos.inCheck(us)) { + if (verifyKing && pos.inCheck(us)) { legal = false; } else { @@ -1104,9 +1112,12 @@ namespace ironphoenix { pvLength_[ply + 1] = ply + 1; } else { + const bool verifyKing = inCheckNode + || !tuning.fastKingLegality + || kingCheckCanChange(pos, move); StateInfo st; pos.makeMove(move, st); - if (pos.inCheck(us)) { + if (verifyKing && pos.inCheck(us)) { legal = false; } else { diff --git a/src/tuning.cpp b/src/tuning.cpp index 388eb01..1f4dcf6 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -57,7 +57,8 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMinMargin" || name == "SearchRFPMaxImprovingDiscount" || name == "SearchFastCheckPrefilter" - || name == "SearchEvalCache"; + || name == "SearchEvalCache" + || name == "SearchFastKingLegality"; } bool isEvalParameter(std::string_view name) noexcept { @@ -179,6 +180,8 @@ namespace ironphoenix::Tuning { p.fastCheckPrefilter = value; else if (name == "SearchEvalCache" && inRange(value, 0, 1)) p.evalCacheEnabled = value; + else if (name == "SearchFastKingLegality" && inRange(value, 0, 1)) + p.fastKingLegality = value; else changed = false; @@ -328,6 +331,7 @@ namespace ironphoenix::Tuning { << "option name SearchRFPMaxImprovingDiscount type spin default " << searchDefaults.rfpMaxImprovingDiscount << " min 0 max 5000\n" << "option name SearchFastCheckPrefilter type spin default " << searchDefaults.fastCheckPrefilter << " min 0 max 1\n" << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" + << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -387,7 +391,8 @@ namespace ironphoenix::Tuning { << " SearchRFPMinMargin=" << s.rfpMinMargin << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter - << " SearchEvalCache=" << s.evalCacheEnabled << '\n'; + << " SearchEvalCache=" << s.evalCacheEnabled + << " SearchFastKingLegality=" << s.fastKingLegality << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/king_legality.cpp b/tests/king_legality.cpp new file mode 100644 index 0000000..600501a --- /dev/null +++ b/tests/king_legality.cpp @@ -0,0 +1,146 @@ +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/king_legality.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace ironphoenix; +namespace { +int failures=0; +std::uint64_t checked=0, skipped=0; +void expect(bool ok,const char* why) { + if(!ok){ ++failures; std::cerr<<"FAIL: "< depth3(int on) { + Tuning::search().fastKingLegality=on; + auto engine=std::make_unique(); + SearchLimits limits{}; + limits.depth=3; + std::ostringstream out; + engine->start(fixture(),limits,out); + const auto until=std::chrono::steady_clock::now()+std::chrono::seconds(15); + while (engine->searching() && std::chrono::steady_clock::now()searching(),"depth3 terminates"); + engine->stopAndWait(); + const auto text=out.str(); + const auto b=text.rfind("bestmove "); + expect(b!=std::string::npos,"bestmove output present"); + const std::string best=b==std::string::npos?"":text.substr( + b,text.find('\n',b)-b); + const auto i=text.rfind("info depth 3 "); + expect(i!=std::string::npos,"depth3 info present"); + const auto a=i==std::string::npos?i:text.find(" score ",i); + const auto z=a==std::string::npos?a:text.find(" nodes ",a); + const std::string score=(a==std::string::npos || z==std::string::npos) + ?"":text.substr(a,z-a); + return {best,score}; +} + +void test() { + Tuning::resetSearch(); + expect(Tuning::search().fastKingLegality==1,"enabled by default"); + expect(Tuning::setSearchParameter("SearchFastKingLegality",0),"disable accepted"); + expect(Tuning::setSearchParameter("SearchFastKingLegality",1),"enable accepted"); + expect(!Tuning::setSearchParameter("SearchFastKingLegality",2),"invalid setting rejected"); + std::ostringstream options; + Tuning::printUciOptions(options); + expect(options.str().find( + "option name SearchFastKingLegality type spin default 1 min 0 max 1") + != std::string::npos,"UCI setting advertised"); + + Position p=fixture(); + for (unsigned ci=0;ci(ci)); + p.finalizeSetup(); + verifyPosition(p); + } + p=fixture(); + for(int ply=0;ply<24;++ply) { + verifyPosition(p); + MoveList moves; + generateLegalMoves(p,moves); + Move selected{}; + for(std::size_t n=0;n(ply)*11)%moves.size]; + if (!(move.isCapture() && isTerminalKingCapture(p,move))) { + selected=move; + break; + } + } + if(!selected)break; + StateInfo state{}; + p.makeMove(selected,state); + expect(p.verify(),"successor position valid"); + } + expect(skipped>100,"many moves skip redundant king check"); + expect(checked>500,"hundreds of 4PC pseudo-legal moves validated"); + const auto original=depth3(0); + const auto optimized=depth3(1); + expect(original==optimized, + "depth3 bestmove/score unchanged by optimized king legality"); + Tuning::resetSearch(); +} +} +int main(){ + Geometry::init(); + test(); + if(!failures) + std::cout<<"Fast 4PC king legality tests: PASS ("< Date: Fri, 23 Oct 2026 13:32:09 -0500 Subject: [PATCH 13/18] Docs: describe tested eval cache, king-legality and TT hot-path optimizations Document A/B UCI switches, correctness boundaries and single-runner diagnostic comparisons, with explicit caution about CI variance and Elo. --- docs/performance.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index c3c9d13..d15f997 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -60,5 +60,18 @@ CI cloud runners have different CPU allocations, so absolute NPS between different workflow runs is not a valid speedup measurement. The benchmark's same-runner feature variants provide better relative evidence. +Recent **single Linux CI runner** experimental comparisons (not user-machine +NPS and not proven Elo gains): + +| Switch | Disabled | Enabled | Speedup | +|---|---:|---:|---:| +| `SearchEvalCache` | 598,037 | 612,344 | about 2.4% | +| `SearchFastKingLegality` | 856,053 | 861,818 | about 0.7% | + +Benchmark runs use the same fixed 4PC fixture, node budget and worker count, +but the repeated variant searches occur sequentially, so CPU variability and +hash/iteration rounding still affect short measurements. Run longer tests +locally, repeat trials, and rely on medians. + This feature branch retains all new king-evaluation terms. NNUE still bypasses HCE; the HCE speed work helps only when NNUE is disabled. From d8e35b836b429b6f6a9f2555bdcbac2571be35cd Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 14:09:32 -0500 Subject: [PATCH 14/18] Perf experiment: cache exact 4PC pawn-attack unions per worker Use small thread-local per-color pawn-structure cache to avoid rescanning pawns during HCE. Add SearchPawnAttackCache UCI A/B switch, score-equivalence tests across changing pawn structures and four-player move sequences, and same-runner NPS diagnostics. --- CMakeLists.txt | 6 ++ include/ironphoenix/tuning.hpp | 2 + src/eval.cpp | 52 ++++++++++++++-- src/tuning.cpp | 9 ++- tests/nps_bench.cpp | 8 ++- tests/pawn_cache.cpp | 107 +++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/pawn_cache.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 44cf853..fc1c337 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,12 @@ if(IRONPHOENIX_BUILD_TESTS) add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_pawn_cache tests/pawn_cache.cpp) + target_link_libraries(ironphoenix_pawn_cache PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_pawn_cache PRIVATE cxx_std_20) + add_test(NAME ironphoenix_pawn_cache COMMAND ironphoenix_pawn_cache) + set_target_properties(ironphoenix_pawn_cache PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_king_legality tests/king_legality.cpp) target_link_libraries(ironphoenix_king_legality PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_king_legality PRIVATE cxx_std_20) diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index fc2d91a..212daa5 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -40,6 +40,8 @@ namespace ironphoenix { // Geometrically skip redundant inCheck(mover) after safe moves. // 0 forces original full move-by-move legality verification. int fastKingLegality = 1; + // Exact union of pawn attack maps, cached per worker. + int pawnAttackCache = 1; }; struct EvalParameters final { diff --git a/src/eval.cpp b/src/eval.cpp index 8f4cc98..17040dd 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -8,6 +8,8 @@ #include "ironphoenix/tuning.hpp" #include +#include +#include #include namespace ironphoenix::Eval { @@ -115,6 +117,49 @@ namespace ironphoenix::Eval { && static_cast(Geometry::KnightEnemyProximity[sq] & enemyMaterial); } + // Pawn attacks are determined solely by pawn positions and seat. + // Cache the union, not evaluation scores. Pawn structures change + // much less frequently than the rest of the 4PC position. + // thread_local keeps this lock-free under Lazy SMP. + IRONPHOENIX_FORCE_INLINE Bitboard pawnAttackUnion( + Color c, Bitboard pawns) noexcept { + constexpr std::size_t SLOTS = 256; + struct Entry { + Bitboard key{}; + Bitboard attacks{}; + bool valid = false; + }; + static thread_local std::array, COLOR_NB> cache{}; + + auto compute = [&]() noexcept { + Bitboard attacks{}; + Bitboard remaining = pawns; + while (remaining) + attacks |= Geometry::PawnAttacks[c][popLsb(remaining)]; + return attacks; + }; + + if (!Tuning::search().pawnAttackCache) + return compute(); + + // Mix all three 64-bit words so promoted or remote pawns + // cannot alias simply due to their placement on the 14x14 board. + std::uint64_t hash = pawns.w0 ^ (pawns.w1 * 0x9E3779B97F4A7C15ULL) + ^ (pawns.w2 * 0xBF58476D1CE4E5B9ULL); + hash ^= hash >> 30; + hash *= 0xBF58476D1CE4E5B9ULL; + hash ^= hash >> 27; + auto& slot = cache[static_cast(c)] + [static_cast(hash) & (SLOTS - 1)]; + if (slot.valid && slot.key == pawns) + return slot.attacks; + + slot.key = pawns; + slot.attacks = compute(); + slot.valid = true; + return slot.attacks; + } + int mobilityForColor(const Position& pos, Color c, Bitboard& teamAttacks, unsigned& teamOutposts, Bitboard* teamBishopAttacks, @@ -179,11 +224,8 @@ namespace ironphoenix::Eval { scorePieces(QUEEN); // Pawns' capture directions are threats, not mobility. - if (scoreThreats) { - Bitboard pawns = pos.pieces(c, PAWN); - while (pawns) - teamAttacks |= Geometry::PawnAttacks[c][popLsb(pawns)]; - } + if (scoreThreats) + teamAttacks |= pawnAttackUnion(c, pos.pieces(c, PAWN)); return score; } diff --git a/src/tuning.cpp b/src/tuning.cpp index 1f4dcf6..e3d049b 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -58,7 +58,8 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMaxImprovingDiscount" || name == "SearchFastCheckPrefilter" || name == "SearchEvalCache" - || name == "SearchFastKingLegality"; + || name == "SearchFastKingLegality" + || name == "SearchPawnAttackCache"; } bool isEvalParameter(std::string_view name) noexcept { @@ -182,6 +183,8 @@ namespace ironphoenix::Tuning { p.evalCacheEnabled = value; else if (name == "SearchFastKingLegality" && inRange(value, 0, 1)) p.fastKingLegality = value; + else if (name == "SearchPawnAttackCache" && inRange(value, 0, 1)) + p.pawnAttackCache = value; else changed = false; @@ -332,6 +335,7 @@ namespace ironphoenix::Tuning { << "option name SearchFastCheckPrefilter type spin default " << searchDefaults.fastCheckPrefilter << " min 0 max 1\n" << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" + << "option name SearchPawnAttackCache type spin default " << searchDefaults.pawnAttackCache << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -392,7 +396,8 @@ namespace ironphoenix::Tuning { << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << " SearchEvalCache=" << s.evalCacheEnabled - << " SearchFastKingLegality=" << s.fastKingLegality << '\n'; + << " SearchFastKingLegality=" << s.fastKingLegality + << " SearchPawnAttackCache=" << s.pawnAttackCache << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index d0ff4f5..3ab0685 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -163,10 +163,16 @@ int main(int argc, char** argv) { Tuning::search() = originalSearch; const bool fastLegalityDone = measure("fast-king-legality"); + Tuning::search().pawnAttackCache = 0; + const bool pawnCacheOffDone = measure("pawn-attack-cache-disabled"); + Tuning::search() = originalSearch; + const bool pawnCacheOnDone = measure("pawn-attack-cache-enabled"); + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. return (finished && pinsDone && pawnsDone && threatDone && noKingDone && originalOrderingDone && cacheDisabledDone && cacheEnabledDone - && fullLegalityDone && fastLegalityDone) ? 0 : 1; + && fullLegalityDone && fastLegalityDone + && pawnCacheOffDone && pawnCacheOnDone) ? 0 : 1; } diff --git a/tests/pawn_cache.cpp b/tests/pawn_cache.cpp new file mode 100644 index 0000000..d04fe9d --- /dev/null +++ b/tests/pawn_cache.cpp @@ -0,0 +1,107 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include + +using namespace ironphoenix; +namespace { +int failures=0; +void check(bool condition, const char* message) { + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} +Square sq(char f, int r) { return Geometry::square(f - 'a', r - 1); } +Position fixture() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED,KING)); + p.placePiece(sq('a',7), makePiece(BLUE,KING)); + p.placePiece(sq('g',14), makePiece(YELLOW,KING)); + p.placePiece(sq('n',7), makePiece(GREEN,KING)); + p.placePiece(sq('e',5), makePiece(RED, KNIGHT)); + p.placePiece(sq('h',11), makePiece(YELLOW, BISHOP)); + p.placePiece(sq('c',6), makePiece(BLUE, ROOK)); + p.placePiece(sq('k',8), makePiece(GREEN, QUEEN)); + for (char file: {'d','e','f','h','i','j'}) { + p.placePiece(sq(file,3), makePiece(RED,PAWN)); + p.placePiece(sq(file,12), makePiece(YELLOW,PAWN)); + } + for (int rank: {4,5,6,8,9,10}) { + p.placePiece(sq('c',rank), makePiece(BLUE,PAWN)); + p.placePiece(sq('l',rank), makePiece(GREEN,PAWN)); + } + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} +void compare(const Position& pos) { + Tuning::search().pawnAttackCache=0; + const int reference=Eval::evaluate(pos); + Tuning::search().pawnAttackCache=1; + const int cached=Eval::evaluate(pos); + check(reference==cached, "cached and raw HCE evaluation exactly equal"); + check(Eval::evaluate(pos)==cached,"second evaluation matches cached result"); +} +void test() { + check(!NNUE::loaded(),"test uses HCE"); + Tuning::resetSearch(); + Tuning::resetEval(); + check(Tuning::search().pawnAttackCache==1,"default enabled"); + check(Tuning::setSearchParameter("SearchPawnAttackCache",0),"disable works"); + check(Tuning::setSearchParameter("SearchPawnAttackCache",1),"enable works"); + check(!Tuning::setSearchParameter("SearchPawnAttackCache",2),"invalid value rejected"); + std::ostringstream options; + Tuning::printUciOptions(options); + check(options.str().find("option name SearchPawnAttackCache type spin default 1 min 0 max 1") + != std::string::npos,"UCI option exposed"); + + Position p=fixture(); + for(unsigned ci=0;ci(ci)); + p.finalizeSetup(); + compare(p); + } + p=fixture(); + for(int i=0;i<65;++i) { + compare(p); + MoveList legal; + generateLegalMoves(p, legal); + if(!legal.size) break; + Move chosen{}; + for (std::size_t j=0;j(i)*13u)%legal.size]; + if (!(move.isCapture() && isTerminalKingCapture(p,move))) { + chosen=move; + break; + } + } + if (!chosen) break; + StateInfo st{}; + p.makeMove(chosen,st); + check(p.verify(),"successor valid"); + } + // Explicitly change a pawn bitboard and test the new cache key. + p=fixture(); + p.erasePiece(sq('d',3)); + p.finalizeSetup(); + compare(p); + p.placePiece(sq('d',3), makePiece(RED,PAWN)); + p.finalizeSetup(); + compare(p); + Tuning::resetSearch(); +} +} +int main() { + Geometry::init(); + test(); + if(!failures)std::cout<<"Pawn attack union cache: PASS\n"; + return failures?1:0; +} From 663439d58cb6cd47003a723a67b135c1e04c3005 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 14:13:13 -0500 Subject: [PATCH 15/18] Perf experiment: lazily select tactical moves in non-PV quiescence Avoid full capture sorting before potential beta cutoffs when tactical lists are long. Keep PV and check-evasion ordering unchanged; add SearchQSearchLazyOrdering A/B switch, QSearch score equivalence tests, and diagnostic throughput comparison. --- CMakeLists.txt | 6 ++ include/ironphoenix/search.hpp | 1 + include/ironphoenix/tuning.hpp | 2 + src/search.cpp | 28 ++++++++- src/tuning.cpp | 9 ++- tests/nps_bench.cpp | 8 ++- tests/qsearch_order.cpp | 105 +++++++++++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 tests/qsearch_order.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fc1c337..ba66dec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,12 @@ if(IRONPHOENIX_BUILD_TESTS) add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_qsearch_order tests/qsearch_order.cpp) + target_link_libraries(ironphoenix_qsearch_order PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_qsearch_order PRIVATE cxx_std_20) + add_test(NAME ironphoenix_qsearch_order COMMAND ironphoenix_qsearch_order) + set_target_properties(ironphoenix_qsearch_order PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_pawn_cache tests/pawn_cache.cpp) target_link_libraries(ironphoenix_pawn_cache PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_pawn_cache PRIVATE cxx_std_20) diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 4a837f4..3311254 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -75,6 +75,7 @@ namespace ironphoenix { friend struct ReverseFutilityTestAccess; friend struct CheckOrderTestAccess; friend struct EvalCacheTestAccess; + friend struct QSearchOrderTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 212daa5..d347d1e 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -42,6 +42,8 @@ namespace ironphoenix { int fastKingLegality = 1; // Exact union of pawn attack maps, cached per worker. int pawnAttackCache = 1; + // Lazy pick tactical QSearch moves; 0 retains full sorting. + int qsearchLazyOrdering = 1; }; struct EvalParameters final { diff --git a/src/search.cpp b/src/search.cpp index 8a58905..1d701f0 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -749,14 +749,38 @@ namespace ironphoenix { else generateTacticalMoves(pos, moves); - orderMoves(pos, moves.begin(), moves.end(), ttMove, ply); + // Non-PV, non-checking QSearch often terminates after a small + // tactical prefix. Score once, then find the best remaining move + // only when needed; avoid a full sort on early beta cutoffs. + // Keep full sorting for PVs/check evasions and short tactical lists. + const bool lazyTactical = !pvNode && !inCheckNode + && Tuning::search().qsearchLazyOrdering && moves.size >= 8; + std::array tacticalScores; + if (lazyTactical) { + for (std::size_t i = 0; i < moves.size; ++i) + tacticalScores[i] = moveScore(pos, moves.moves[i], ttMove, + ply, false); + } else { + orderMoves(pos, moves.begin(), moves.end(), ttMove, ply); + } const Color us = pos.sideToMove(); int legalMoves = 0; - for (Move move : moves) { + for (std::size_t moveIndex = 0; moveIndex < moves.size; ++moveIndex) { if (shouldStop()) break; + if (lazyTactical) { + std::size_t best = moveIndex; + for (std::size_t j = moveIndex + 1; j < moves.size; ++j) + if (tacticalScores[j] > tacticalScores[best]) + best = j; + if (best != moveIndex) { + std::swap(moves.moves[moveIndex], moves.moves[best]); + std::swap(tacticalScores[moveIndex], tacticalScores[best]); + } + } + const Move move = moves.moves[moveIndex]; ss.currentMove = move; const bool terminalKingCapture = move.isCapture() && isTerminalKingCapture(pos, move); diff --git a/src/tuning.cpp b/src/tuning.cpp index e3d049b..5cd4444 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -59,7 +59,8 @@ namespace ironphoenix::Tuning { || name == "SearchFastCheckPrefilter" || name == "SearchEvalCache" || name == "SearchFastKingLegality" - || name == "SearchPawnAttackCache"; + || name == "SearchPawnAttackCache" + || name == "SearchQSearchLazyOrdering"; } bool isEvalParameter(std::string_view name) noexcept { @@ -185,6 +186,8 @@ namespace ironphoenix::Tuning { p.fastKingLegality = value; else if (name == "SearchPawnAttackCache" && inRange(value, 0, 1)) p.pawnAttackCache = value; + else if (name == "SearchQSearchLazyOrdering" && inRange(value, 0, 1)) + p.qsearchLazyOrdering = value; else changed = false; @@ -336,6 +339,7 @@ namespace ironphoenix::Tuning { << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" << "option name SearchPawnAttackCache type spin default " << searchDefaults.pawnAttackCache << " min 0 max 1\n" + << "option name SearchQSearchLazyOrdering type spin default " << searchDefaults.qsearchLazyOrdering << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -397,7 +401,8 @@ namespace ironphoenix::Tuning { << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << " SearchEvalCache=" << s.evalCacheEnabled << " SearchFastKingLegality=" << s.fastKingLegality - << " SearchPawnAttackCache=" << s.pawnAttackCache << '\n'; + << " SearchPawnAttackCache=" << s.pawnAttackCache + << " SearchQSearchLazyOrdering=" << s.qsearchLazyOrdering << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index 3ab0685..1621b2d 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -168,11 +168,17 @@ int main(int argc, char** argv) { Tuning::search() = originalSearch; const bool pawnCacheOnDone = measure("pawn-attack-cache-enabled"); + Tuning::search().qsearchLazyOrdering = 0; + const bool qsearchFullDone = measure("qsearch-full-sort"); + Tuning::search() = originalSearch; + const bool qsearchLazyDone = measure("qsearch-lazy-pick"); + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. return (finished && pinsDone && pawnsDone && threatDone && noKingDone && originalOrderingDone && cacheDisabledDone && cacheEnabledDone && fullLegalityDone && fastLegalityDone - && pawnCacheOffDone && pawnCacheOnDone) ? 0 : 1; + && pawnCacheOffDone && pawnCacheOnDone + && qsearchFullDone && qsearchLazyDone) ? 0 : 1; } diff --git a/tests/qsearch_order.cpp b/tests/qsearch_order.cpp new file mode 100644 index 0000000..2334194 --- /dev/null +++ b/tests/qsearch_order.cpp @@ -0,0 +1,105 @@ +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include + +using namespace ironphoenix; +namespace ironphoenix { +struct QSearchOrderTestAccess { + static int score(SearchEngine& engine, Position& pos) { + engine.stopRequested_.store(false, std::memory_order_relaxed); + engine.limits_ = SearchLimits{}; + return engine.qsearch(pos, -20000, 20000, 1, false); + } +}; +} +namespace { +int failures=0; +void check(bool ok,const char* msg) { + if(!ok) { ++failures; std::cerr<<"FAIL: "<(); + const int baseline=QSearchOrderTestAccess::score(*standard,p); + Tuning::search().qsearchLazyOrdering=1; + auto optimized=std::make_unique(); + const int actual=QSearchOrderTestAccess::score(*optimized,p); + check(baseline==actual, "QSearch score unchanged with lazy ordering"); + check(p.key()==key && p.verify(),"qsearch preserves caller position"); +} +void test() { + Tuning::resetSearch(); + check(Tuning::search().qsearchLazyOrdering==1,"default enabled"); + check(Tuning::setSearchParameter("SearchQSearchLazyOrdering",0),"disable accepted"); + check(Tuning::setSearchParameter("SearchQSearchLazyOrdering",1),"enable accepted"); + check(!Tuning::setSearchParameter("SearchQSearchLazyOrdering",2),"invalid value rejected"); + std::ostringstream opts; + Tuning::printUciOptions(opts); + check(opts.str().find( + "option name SearchQSearchLazyOrdering type spin default 1 min 0 max 1") + != std::string::npos, "UCI option published"); + Position p=fixture(); + compare(p); + for(unsigned ci=0;ci(ci)); + p.finalizeSetup(); + compare(p); + } + // Check on several legal successor positions, not just the original + // cross-board position. Skip terminal king captures in the fixture. + p=fixture(); + for(int ply=0;ply<6;++ply) { + compare(p); + MoveList moves; + generateLegalMoves(p,moves); + Move selected{}; + for(std::size_t i=0;i(ply)*5u)%moves.size]; + if (!(m.isCapture() && isTerminalKingCapture(p,m))) { + selected=m; + break; + } + } + if (!selected) break; + StateInfo st{}; + p.makeMove(selected,st); + check(p.verify(),"successor valid"); + } + Tuning::resetSearch(); +} +} +int main() { + Geometry::init(); + test(); + if(!failures) std::cout<<"QSearch tactical ordering: PASS\n"; + return failures?1:0; +} From 24469cf8ed44e8756718066a6835c12f686e1897 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 14:15:21 -0500 Subject: [PATCH 16/18] Perf: revert lazy QSearch picking after identical-runner NPS regression Full tactical sorting scored 516,048 NPS against lazy picking 512,945 NPS in the fixed fixture; keep verified thread-local pawn-attack cache instead. --- CMakeLists.txt | 6 -- include/ironphoenix/search.hpp | 1 - include/ironphoenix/tuning.hpp | 2 - src/search.cpp | 28 +-------- src/tuning.cpp | 9 +-- tests/nps_bench.cpp | 8 +-- tests/qsearch_order.cpp | 105 --------------------------------- 7 files changed, 5 insertions(+), 154 deletions(-) delete mode 100644 tests/qsearch_order.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ba66dec..fc1c337 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,12 +245,6 @@ if(IRONPHOENIX_BUILD_TESTS) add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") - add_executable(ironphoenix_qsearch_order tests/qsearch_order.cpp) - target_link_libraries(ironphoenix_qsearch_order PRIVATE IronPhoenix::Core) - target_compile_features(ironphoenix_qsearch_order PRIVATE cxx_std_20) - add_test(NAME ironphoenix_qsearch_order COMMAND ironphoenix_qsearch_order) - set_target_properties(ironphoenix_qsearch_order PROPERTIES FOLDER "Tests") - add_executable(ironphoenix_pawn_cache tests/pawn_cache.cpp) target_link_libraries(ironphoenix_pawn_cache PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_pawn_cache PRIVATE cxx_std_20) diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 3311254..4a837f4 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -75,7 +75,6 @@ namespace ironphoenix { friend struct ReverseFutilityTestAccess; friend struct CheckOrderTestAccess; friend struct EvalCacheTestAccess; - friend struct QSearchOrderTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index d347d1e..212daa5 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -42,8 +42,6 @@ namespace ironphoenix { int fastKingLegality = 1; // Exact union of pawn attack maps, cached per worker. int pawnAttackCache = 1; - // Lazy pick tactical QSearch moves; 0 retains full sorting. - int qsearchLazyOrdering = 1; }; struct EvalParameters final { diff --git a/src/search.cpp b/src/search.cpp index 1d701f0..8a58905 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -749,38 +749,14 @@ namespace ironphoenix { else generateTacticalMoves(pos, moves); - // Non-PV, non-checking QSearch often terminates after a small - // tactical prefix. Score once, then find the best remaining move - // only when needed; avoid a full sort on early beta cutoffs. - // Keep full sorting for PVs/check evasions and short tactical lists. - const bool lazyTactical = !pvNode && !inCheckNode - && Tuning::search().qsearchLazyOrdering && moves.size >= 8; - std::array tacticalScores; - if (lazyTactical) { - for (std::size_t i = 0; i < moves.size; ++i) - tacticalScores[i] = moveScore(pos, moves.moves[i], ttMove, - ply, false); - } else { - orderMoves(pos, moves.begin(), moves.end(), ttMove, ply); - } + orderMoves(pos, moves.begin(), moves.end(), ttMove, ply); const Color us = pos.sideToMove(); int legalMoves = 0; - for (std::size_t moveIndex = 0; moveIndex < moves.size; ++moveIndex) { + for (Move move : moves) { if (shouldStop()) break; - if (lazyTactical) { - std::size_t best = moveIndex; - for (std::size_t j = moveIndex + 1; j < moves.size; ++j) - if (tacticalScores[j] > tacticalScores[best]) - best = j; - if (best != moveIndex) { - std::swap(moves.moves[moveIndex], moves.moves[best]); - std::swap(tacticalScores[moveIndex], tacticalScores[best]); - } - } - const Move move = moves.moves[moveIndex]; ss.currentMove = move; const bool terminalKingCapture = move.isCapture() && isTerminalKingCapture(pos, move); diff --git a/src/tuning.cpp b/src/tuning.cpp index 5cd4444..e3d049b 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -59,8 +59,7 @@ namespace ironphoenix::Tuning { || name == "SearchFastCheckPrefilter" || name == "SearchEvalCache" || name == "SearchFastKingLegality" - || name == "SearchPawnAttackCache" - || name == "SearchQSearchLazyOrdering"; + || name == "SearchPawnAttackCache"; } bool isEvalParameter(std::string_view name) noexcept { @@ -186,8 +185,6 @@ namespace ironphoenix::Tuning { p.fastKingLegality = value; else if (name == "SearchPawnAttackCache" && inRange(value, 0, 1)) p.pawnAttackCache = value; - else if (name == "SearchQSearchLazyOrdering" && inRange(value, 0, 1)) - p.qsearchLazyOrdering = value; else changed = false; @@ -339,7 +336,6 @@ namespace ironphoenix::Tuning { << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" << "option name SearchPawnAttackCache type spin default " << searchDefaults.pawnAttackCache << " min 0 max 1\n" - << "option name SearchQSearchLazyOrdering type spin default " << searchDefaults.qsearchLazyOrdering << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -401,8 +397,7 @@ namespace ironphoenix::Tuning { << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << " SearchEvalCache=" << s.evalCacheEnabled << " SearchFastKingLegality=" << s.fastKingLegality - << " SearchPawnAttackCache=" << s.pawnAttackCache - << " SearchQSearchLazyOrdering=" << s.qsearchLazyOrdering << '\n'; + << " SearchPawnAttackCache=" << s.pawnAttackCache << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index 1621b2d..3ab0685 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -168,17 +168,11 @@ int main(int argc, char** argv) { Tuning::search() = originalSearch; const bool pawnCacheOnDone = measure("pawn-attack-cache-enabled"); - Tuning::search().qsearchLazyOrdering = 0; - const bool qsearchFullDone = measure("qsearch-full-sort"); - Tuning::search() = originalSearch; - const bool qsearchLazyDone = measure("qsearch-lazy-pick"); - // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. return (finished && pinsDone && pawnsDone && threatDone && noKingDone && originalOrderingDone && cacheDisabledDone && cacheEnabledDone && fullLegalityDone && fastLegalityDone - && pawnCacheOffDone && pawnCacheOnDone - && qsearchFullDone && qsearchLazyDone) ? 0 : 1; + && pawnCacheOffDone && pawnCacheOnDone) ? 0 : 1; } diff --git a/tests/qsearch_order.cpp b/tests/qsearch_order.cpp deleted file mode 100644 index 2334194..0000000 --- a/tests/qsearch_order.cpp +++ /dev/null @@ -1,105 +0,0 @@ -#include "ironphoenix/geometry.hpp" -#include "ironphoenix/movegen.hpp" -#include "ironphoenix/position.hpp" -#include "ironphoenix/search.hpp" -#include "ironphoenix/tuning.hpp" - -#include -#include -#include -#include - -using namespace ironphoenix; -namespace ironphoenix { -struct QSearchOrderTestAccess { - static int score(SearchEngine& engine, Position& pos) { - engine.stopRequested_.store(false, std::memory_order_relaxed); - engine.limits_ = SearchLimits{}; - return engine.qsearch(pos, -20000, 20000, 1, false); - } -}; -} -namespace { -int failures=0; -void check(bool ok,const char* msg) { - if(!ok) { ++failures; std::cerr<<"FAIL: "<(); - const int baseline=QSearchOrderTestAccess::score(*standard,p); - Tuning::search().qsearchLazyOrdering=1; - auto optimized=std::make_unique(); - const int actual=QSearchOrderTestAccess::score(*optimized,p); - check(baseline==actual, "QSearch score unchanged with lazy ordering"); - check(p.key()==key && p.verify(),"qsearch preserves caller position"); -} -void test() { - Tuning::resetSearch(); - check(Tuning::search().qsearchLazyOrdering==1,"default enabled"); - check(Tuning::setSearchParameter("SearchQSearchLazyOrdering",0),"disable accepted"); - check(Tuning::setSearchParameter("SearchQSearchLazyOrdering",1),"enable accepted"); - check(!Tuning::setSearchParameter("SearchQSearchLazyOrdering",2),"invalid value rejected"); - std::ostringstream opts; - Tuning::printUciOptions(opts); - check(opts.str().find( - "option name SearchQSearchLazyOrdering type spin default 1 min 0 max 1") - != std::string::npos, "UCI option published"); - Position p=fixture(); - compare(p); - for(unsigned ci=0;ci(ci)); - p.finalizeSetup(); - compare(p); - } - // Check on several legal successor positions, not just the original - // cross-board position. Skip terminal king captures in the fixture. - p=fixture(); - for(int ply=0;ply<6;++ply) { - compare(p); - MoveList moves; - generateLegalMoves(p,moves); - Move selected{}; - for(std::size_t i=0;i(ply)*5u)%moves.size]; - if (!(m.isCapture() && isTerminalKingCapture(p,m))) { - selected=m; - break; - } - } - if (!selected) break; - StateInfo st{}; - p.makeMove(selected,st); - check(p.verify(),"successor valid"); - } - Tuning::resetSearch(); -} -} -int main() { - Geometry::init(); - test(); - if(!failures) std::cout<<"QSearch tactical ordering: PASS\n"; - return failures?1:0; -} From 193e15722c6e39b480984213cf6cf19b980baf7d Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 14:19:15 -0500 Subject: [PATCH 17/18] Perf experiment: skip impossible pin/X-ray ray scans via precomputed king-alignment masks Before expensive occupancy-dependent king/queen slider probes, reject directions without any geometrically aligned opponent slider. Preserve exact scores with SearchKingRayPrefilter on/off tests and same-runner throughput comparisons. --- include/ironphoenix/tuning.hpp | 2 ++ src/king_eval.cpp | 21 +++++++++++++++++++-- src/tuning.cpp | 9 +++++++-- tests/king_features.cpp | 13 +++++++++++++ tests/nps_bench.cpp | 8 +++++++- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 212daa5..fdb9a19 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -42,6 +42,8 @@ namespace ironphoenix { int fastKingLegality = 1; // Exact union of pawn attack maps, cached per worker. int pawnAttackCache = 1; + // Skip impossible slider rays in HCE pins/X-ray scoring. + int kingRayPrefilter = 1; }; struct EvalParameters final { diff --git a/src/king_eval.cpp b/src/king_eval.cpp index f3d7ae6..2826aaa 100644 --- a/src/king_eval.cpp +++ b/src/king_eval.cpp @@ -41,6 +41,8 @@ Square sideSquare(Color c, Square king, int offset) noexcept { // The C++11 function-local static initialization is thread-safe for Lazy SMP. struct KingMasks { Bitboard storm{}; + Bitboard rookRays{}; + Bitboard bishopRays{}; std::array, COLOR_NB> shield{}; std::array, COLOR_NB> shieldLane{}; std::array home{}; @@ -59,6 +61,14 @@ struct Lookup { if (!Geometry::validXY(f, r)) continue; auto& m = kings[i]; + m.rookRays = Geometry::Ray[NORTH][sq] + | Geometry::Ray[SOUTH][sq] + | Geometry::Ray[EAST][sq] + | Geometry::Ray[WEST][sq]; + m.bishopRays = Geometry::Ray[NORTH_EAST][sq] + | Geometry::Ray[NORTH_WEST][sq] + | Geometry::Ray[SOUTH_EAST][sq] + | Geometry::Ray[SOUTH_WEST][sq]; m.centralDistance = static_cast( std::min(std::abs(f - 6), std::abs(f - 7)) + std::min(std::abs(r - 6), std::abs(r - 7))); @@ -174,7 +184,12 @@ int rayPressure(const Position& pos, Square target, Bitboard friends, const TeamData& opponents, Bitboard occupancy, Bitboard sliderAttacks) noexcept { int count = 0; - if (opponents.orthogonalSliders) { + const auto& kingMasks = geometryMasks().kings[target]; + // An x-ray/pin on a king or queen requires a hostile slider already + // collinear with it. If none exists, skip occupancy-dependent ray work. + if (opponents.orthogonalSliders + && (!Tuning::search().kingRayPrefilter + || (opponents.orthogonalSliders & kingMasks.rookRays))) { Bitboard screens = pos.rookAttacks(target) & friends & sliderAttacks; while (screens) { const Square screen = popLsb(screens); @@ -189,7 +204,9 @@ int rayPressure(const Position& pos, Square target, ++count; } } - if (opponents.diagonalSliders) { + if (opponents.diagonalSliders + && (!Tuning::search().kingRayPrefilter + || (opponents.diagonalSliders & kingMasks.bishopRays))) { Bitboard screens = pos.bishopAttacks(target) & friends & sliderAttacks; while (screens) { const Square screen = popLsb(screens); diff --git a/src/tuning.cpp b/src/tuning.cpp index e3d049b..aa616e2 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -59,7 +59,8 @@ namespace ironphoenix::Tuning { || name == "SearchFastCheckPrefilter" || name == "SearchEvalCache" || name == "SearchFastKingLegality" - || name == "SearchPawnAttackCache"; + || name == "SearchPawnAttackCache" + || name == "SearchKingRayPrefilter"; } bool isEvalParameter(std::string_view name) noexcept { @@ -185,6 +186,8 @@ namespace ironphoenix::Tuning { p.fastKingLegality = value; else if (name == "SearchPawnAttackCache" && inRange(value, 0, 1)) p.pawnAttackCache = value; + else if (name == "SearchKingRayPrefilter" && inRange(value, 0, 1)) + p.kingRayPrefilter = value; else changed = false; @@ -336,6 +339,7 @@ namespace ironphoenix::Tuning { << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" << "option name SearchPawnAttackCache type spin default " << searchDefaults.pawnAttackCache << " min 0 max 1\n" + << "option name SearchKingRayPrefilter type spin default " << searchDefaults.kingRayPrefilter << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -397,7 +401,8 @@ namespace ironphoenix::Tuning { << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter << " SearchEvalCache=" << s.evalCacheEnabled << " SearchFastKingLegality=" << s.fastKingLegality - << " SearchPawnAttackCache=" << s.pawnAttackCache << '\n'; + << " SearchPawnAttackCache=" << s.pawnAttackCache + << " SearchKingRayPrefilter=" << s.kingRayPrefilter << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/king_features.cpp b/tests/king_features.cpp index f90d9e5..32c0c25 100644 --- a/tests/king_features.cpp +++ b/tests/king_features.cpp @@ -193,6 +193,19 @@ void testCastlingRights() { int main() { Geometry::init(); + // Compare the same exact HCE scores across the geometric ray prefilter + // on hand-built king, pin, and queen X-ray positions. + auto kingTest = kings(); + kingTest.placePiece(sq('g', 3), makePiece(RED, KNIGHT)); + kingTest.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + kingTest.placePiece(sq('j', 9), makePiece(GREEN, QUEEN)); + kingTest.finalizeSetup(); + Tuning::search().kingRayPrefilter = 0; + const int withoutRayPrefilter = Eval::evaluate(kingTest); + Tuning::search().kingRayPrefilter = 1; + expect(Eval::evaluate(kingTest) == withoutRayPrefilter, + "ray prefilter preserves exact king pin/xray score"); + Tuning::resetSearch(); expect(!NNUE::loaded(), "king evaluation features apply to HCE"); testOptions(); testMiddlegame(); diff --git a/tests/nps_bench.cpp b/tests/nps_bench.cpp index 3ab0685..259ed4a 100644 --- a/tests/nps_bench.cpp +++ b/tests/nps_bench.cpp @@ -168,11 +168,17 @@ int main(int argc, char** argv) { Tuning::search() = originalSearch; const bool pawnCacheOnDone = measure("pawn-attack-cache-enabled"); + Tuning::search().kingRayPrefilter = 0; + const bool rayOffDone = measure("king-ray-prefilter-disabled"); + Tuning::search() = originalSearch; + const bool rayOnDone = measure("king-ray-prefilter-enabled"); + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. return (finished && pinsDone && pawnsDone && threatDone && noKingDone && originalOrderingDone && cacheDisabledDone && cacheEnabledDone && fullLegalityDone && fastLegalityDone - && pawnCacheOffDone && pawnCacheOnDone) ? 0 : 1; + && pawnCacheOffDone && pawnCacheOnDone + && rayOffDone && rayOnDone) ? 0 : 1; } From 16b2157dc14af93844aec62ec9173cfe42feb5d9 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 23 Oct 2026 14:24:32 -0500 Subject: [PATCH 18/18] Tests/docs: strengthen king ray equivalence and record measured speedups Test four moving-color perspectives and screened queen X-rays; document A/B UCI options, diagnostic 2.7% pawn cache and 5.5% ray-mask gains, reverted QSearch sorting trial and optional MSVC IPO experiment. --- docs/performance.md | 38 ++++++++++++++++++++++++++++++++++++++ tests/king_features.cpp | 21 ++++++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index d15f997..df5654b 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -17,6 +17,44 @@ Branch: \`feature/king-evaluation\`. Keep \`main\` stable until Elo tests. - Benchmark \`ironphoenix_nps_bench\` now measures feature groups in addition to its original fixed-position search throughput. +## Latest exact evaluation speedups + +- `SearchPawnAttackCache=1` (default): Each worker caches the union + of each color's pawn attack bitboards by **all three pawn-bitboard words**. + Pawn structures often repeat even when non-pawn pieces change. Values + remain exact; the thread-local cache cannot race between SMP workers. +- `SearchKingRayPrefilter=1` (default): Precomputed geometric rank/file/ + diagonal rays reject king and queen pin/X-ray probes when no opposing + slider is even aligned with the target. All four-color evaluation + scores are preserved. +- Quiescence **lazy tactical picking was tested then reverted**, because + it measured 512,945 NPS versus 516,048 NPS with ordinary full sorting. + +Diagnostic same-runner Linux CI comparisons (individual experiments, +not additive or directly predictive of your CPU): + +| Optimization | Disabled | Enabled | Difference | +|---|---:|---:|---:| +| Pawn attack cache | 510,898 | 524,508 | +2.7% | +| King ray prefilter | 509,880 | 537,731 | +5.5% | + +Both switches accept `0` to disable and `1` to enable in UCI. These +are HCE optimizations; loaded NNUE bypasses handcrafted evaluation. + +### Additional build experiment: whole-program optimization + +CMake supports interprocedural optimization through its built-in option. +On MSVC, test a separate Release build with + +```powershell +cmake -S . -B build-ipo -DIRONPHOENIX_NATIVE_CPU=ON -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON +cmake --build build-ipo --config Release --parallel +``` + +This may improve inlining across translation units, but has **not** been +benchmarked or validated for this repository's toolchain. Compare to +the native Release build with IPO disabled on the same machine. + ## Native CPU Release build (opt-in) CMake option: \`IRONPHOENIX_NATIVE_CPU=ON\` (default OFF). diff --git a/tests/king_features.cpp b/tests/king_features.cpp index 32c0c25..87faf7c 100644 --- a/tests/king_features.cpp +++ b/tests/king_features.cpp @@ -200,11 +200,26 @@ int main() { kingTest.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); kingTest.placePiece(sq('j', 9), makePiece(GREEN, QUEEN)); kingTest.finalizeSetup(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + kingTest.setSideToMove(static_cast(ci)); + kingTest.finalizeSetup(); + Tuning::search().kingRayPrefilter = 0; + const int reference = Eval::evaluate(kingTest); + Tuning::search().kingRayPrefilter = 1; + expect(Eval::evaluate(kingTest) == reference, + "ray prefilter preserves score for each moving color"); + } + auto queenXray = kings(); + queenXray.placePiece(sq('g', 2), makePiece(RED, QUEEN)); + queenXray.placePiece(sq('g', 3), makePiece(RED, PAWN)); + queenXray.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + queenXray.placePiece(sq('i', 9), makePiece(GREEN, BISHOP)); + queenXray.finalizeSetup(); Tuning::search().kingRayPrefilter = 0; - const int withoutRayPrefilter = Eval::evaluate(kingTest); + const int queenWithout = Eval::evaluate(queenXray); Tuning::search().kingRayPrefilter = 1; - expect(Eval::evaluate(kingTest) == withoutRayPrefilter, - "ray prefilter preserves exact king pin/xray score"); + expect(Eval::evaluate(queenXray) == queenWithout, + "queen X-ray score unchanged with geometric prefilter"); Tuning::resetSearch(); expect(!NNUE::loaded(), "king evaluation features apply to HCE"); testOptions();