diff options
| author | Scott Gasch <[email protected]> | 2026-09-13 15:03:09 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-13 15:03:09 -0700 |
| commit | 78d1889fe38db1fd45b58509c8b3bd535ce691b7 (patch) | |
| tree | 994a213816c19c6654faf89eca74880f0fa79f47 /src | |
| parent | d2699c47d6d0abf0f018e5ba37d4f1bfe05ecd33 (diff) | |
Fix several MP/qsearch correctness bugs found by auditing ctx->uPly
indexing and per-thread state across MakeMove/recursion boundaries;
add a position-signature stamp so full-eval-only data can be validated
cheaply instead of approximated.
- chess.h/searchsup.c/search.c/root.c: add NODE_TYPE (PV/CUT/ALL)
classification, set centrally in CommonSearchInit, replacing the old
binary fPvNode flag. Fixes split.c's node-type check reading stale
data on helper threads (they never call CommonSearchInit for the
split-point ply) and removes a redundant, always-true parent-type
check in search.c's own LMR gate.
- split.c: StartParallelSearch now copies the split point's eNodeType
into SPLIT_INFO for helper threads to read (mirroring the existing
iEval/iImprovement pattern); a split-node fail-high is now detected
explicitly in search.c's move loop instead of falling through to the
generic no_more_moves classifier, which misread it as a fail-low
(skipping killer/history updates and storing an inverted-sense upper
bound instead of a lower bound); ThreadUnderTerminatingSplit now
checks all nested split pointers instead of just the first, so a
thread notices when an inner split terminates while it's still
helping an outer one; MAX_SPLITS bumped 20->64 for higher thread
counts.
- chess.h/searchsup.c/search.c: convert
SEARCHER_THREAD_CONTEXT.uLastPositionalSource (a single per-thread
scalar) to PLY_INFO.uLastPositionalSource (per-ply). The scalar
version was clobbered whenever QSearch's own move loop recursed
before a later iteration read it back, corrupting which qsearch
futility margin got applied; also fixes the same hazard in the
newly-added check-evasion futility prune.
- chess.h/generate.c/searchsup.c: generalize move-scoring interpretation
via a new MOVE_SCORING_SCHEME tag (per-ply, set by whichever
generate.c routine last scored that ply's moves) and a single shared
DecodeMoveValueEstimate, replacing scheme-blind decode logic in
ComputeMoveScore and _ShouldWeConsiderThisMove.
- chess.h/eval.c/dynamic.c: stamp PLY_INFO.u64FullEvalSig with the
position signature whenever a node completes a full (non-lazy) Eval()
call. cEnprise/pEnprise/cTrapped/pTrapped/iKingScore/the eval-scoped
bb*Attacks bitboards are only valid when this matches the position's
live signature; _EnpriseSlotValid/_TrappedSlotValid now compare sigs
directly instead of re-checking piece identity on the live board.
- searchsup.c: CommonSearchInit clears pi->mvBest on both its IsDraw and
too-deep early-exit paths to prevent a later sibling node from reading
stale mvBest data left by an unrelated earlier visit to the same ply.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C9r36BydPnfpyYLFhkTo6d
Diffstat (limited to 'src')
| -rwxr-xr-x | src/chess.h | 179 | ||||
| -rw-r--r-- | src/dynamic.c | 18 | ||||
| -rw-r--r-- | src/eval.c | 32 | ||||
| -rw-r--r-- | src/generate.c | 8 | ||||
| -rw-r--r-- | src/root.c | 14 | ||||
| -rw-r--r-- | src/search.c | 189 | ||||
| -rw-r--r-- | src/searchsup.c | 265 | ||||
| -rw-r--r-- | src/split.c | 24 |
8 files changed, 604 insertions, 125 deletions
diff --git a/src/chess.h b/src/chess.h index 5f9f10c..2681701 100755 --- a/src/chess.h +++ b/src/chess.h @@ -688,6 +688,19 @@ typedef struct _POSITION // Bishop's x-ray chains arbitrarily deep, same as rook's/queen's, // not the single-hop approximation it originally shipped with (see // EVAL.md's progress log for why that was revisited). + // + // REQUIRES SIG MATCH before any reader outside eval.c trusts these: + // compare ctx->sPlyInfo[ctx->uPly].u64FullEvalSig (PLY_INFO, chess.h) + // against this position's own live (u64NonPawnSig ^ u64PawnSig). + // On a lazy/super-lazy Eval() exit these are left completely + // unpopulated -- not merely stale, genuinely never cleared or + // written that call (see Eval()'s own pawn-eval-deferred comment) -- + // and even on a full eval they describe whatever position is + // current in ctx->sPosition at the moment Eval() returns, which + // stops being true the instant the next MakeMove runs. Currently + // only read from inside the same Eval() call that wrote them + // (_EvalKing, _WhoControlsSquareFast); any future reader elsewhere + // (search.c, see.c, generate.c) must sig-check first. BITBOARD bbPawnAttacks[2]; BITBOARD bbMinorAttacks[2]; BITBOARD bbMinorXrayAttacks[2]; @@ -941,6 +954,19 @@ typedef struct _COUNTERS UINT64 u64TooDeepPlyLimit; UINT64 u64TooDeepQsearchExtendLimit; UINT64 u64LeafCount; + // How often a node's actual outcome contradicts its assumed + // NODE_TYPE (chess.h) -- a real, if imperfect, sanity check on + // the classic PVS node typing. Counted at every Search()/ + // QSearch() exit, using the window it was actually asked to + // search (iAlpha/iBeta at that point, not whatever the initial + // caller-supplied window was, since mate-distance pruning can + // narrow it before this node's own outcome is known). Some + // "surprises" (e.g. an immediate hash-table cutoff) are + // legitimate and expected, not actual instability, so these + // counts are a rough signal, not a bug indicator by themselves. + UINT64 u64PVNodeSurprised; // PV node failed high or low + UINT64 u64AllNodeFailedHigh; // ALL node (expected fail-low) failed high + UINT64 u64CutNodeFailedLow; // CUT node (expected fail-high) failed low UINT64 u64TerminalPositionCount; UINT64 u64BetaCutoffs; UINT64 u64BetaCutoffsOnFirstMove; @@ -1138,6 +1164,26 @@ typedef struct _CUMULATIVE_SEARCH_FLAGS } CUMULATIVE_SEARCH_FLAGS; +// Classic PVS node typing. PV: this node's own window was wide +// (iBeta != iAlpha + 1), computed for free from the window it was +// called with. CUT/ALL only matter when NOT PV, and can't be derived +// from window width alone (both are null-window scouts) -- set +// centrally in CommonSearchInit (searchsup.c, covers both Search() and +// QSearch() from one place) per the standard alternation: the first +// child of a PV node is PV; every other child of a PV node is CUT +// (expected to fail high); the first child of a CUT node is ALL +// (expected to fail low, must search everything); children of an ALL +// node are CUT again. A narrow window's type therefore depends only on +// the PARENT's own type, not on which move number got us here -- no +// need to thread "was this the first move" through the caller. +typedef enum +{ + NODE_PV, + NODE_CUT, + NODE_ALL +} +NODE_TYPE; + // ---------------------------------------------------------------------- // // Searcher thread record @@ -1167,6 +1213,18 @@ typedef struct _SPLIT_INFO SCORE iBeta; // beta at split SCORE iEval; // static eval at split SCORE iImprovement; // eval improvement since ply-2 (may be negative) + NODE_TYPE eNodeType; // the split point's own node type -- + // copied here rather than read off a + // helper thread's own ctx->sPlyInfo, + // since a helper thread never actually + // calls Search()/CommonSearchInit for + // the split-point ply itself (it just + // replays MakeMove along mvPathToHere + // to reconstruct board state), so that + // slot in ITS OWN persistent context is + // always stale leftover data from + // whatever unrelated node that thread + // last helped with CUMULATIVE_SEARCH_FLAGS sSearchFlags; // flags at split time // output from the split node @@ -1182,6 +1240,37 @@ typedef struct _SPLIT_INFO } SPLIT_INFO; +// Which of several distinct move-scoring conventions currently +// populates this ply's sMoveStack[].iValue -- generate.c's four +// schemes each pack tier-flag bits (SORT_THESE_FIRST, FIRST_KILLER, +// etc.) the same way, so a raw tier/leftover classification +// (iValue < GOOD_MOVE) already works correctly regardless of which of +// those four is active. What does NOT generalize is treating iValue +// as a material/eval-axis estimate: each scheme bakes in different +// ordering-only bias terms on top of the real SEE/material value (see +// ComputeMoveScore's DecodeMoveValueEstimate, searchsup.c), so any +// caller that needs an actual value (not just a tier) must know which +// scheme it's decoding. MOVE_SCORING_ROOT and MOVE_SCORING_IID_RESCORED +// aren't tier-flag-encoded at all -- both are already real quantities +// (a node-count/score composite, or a genuine searched eval-axis +// score respectively), never meaningful as a generate.c-style tier. +typedef enum +{ + MOVE_SCORING_NORMAL, // _ScoreAllMoves + MOVE_SCORING_ESCAPE, // _ScoreAllEscapes + MOVE_SCORING_QSEARCH_CHECKS, // _ScoreQSearchMovesInclChecks + MOVE_SCORING_QSEARCH_NO_CHECKS, // _ScoreQSearchMovesNoChecks + MOVE_SCORING_IID_RESCORED, // RescoreMovesViaSearch -- a real + // eval-axis score already, trust + // iValue as-is + MOVE_SCORING_ROOT // root.c's own next-iteration + // move-ordering composite + // ((subtree node count >> 9, + // capped) + iScore) -- only ever + // populates ply 0's move stack +} +MOVE_SCORING_SCHEME; + typedef struct _PLY_INFO { #ifdef DEBUG @@ -1193,17 +1282,55 @@ typedef struct _PLY_INFO INT iExtensionAmount; FLAG fInCheck; FLAG fInQsearch; - FLAG fPvNode; // this node's own window was wide - FLAG fMovesRescoredByIID; // sMoveStack[uPly].iValue holds - // real search scores from - // RescoreMovesViaSearch, not - // generate.c's ordering encoding + NODE_TYPE eNodeType; // classic PVS node typing -- + // see the enum's own comment + MOVE_SCORING_SCHEME eMoveScoringScheme; // which convention scored + // sMoveStack[uPly] -- see + // the enum's own comment MOVE mv; MOVE mvBest; MOVE PV[MAX_PLY_PER_SEARCH]; - SCORE iKingScore[2]; + // Stamped with (pos->u64NonPawnSig ^ pos->u64PawnSig) the instant a + // full (not lazy/super-lazy) Eval() call finishes for the position + // currently sitting at this ply -- see Eval()'s own comment near + // where it's written. A handful of fields below are only ever + // populated on that same full-eval path (iKingScore) or only + // trustworthy when read back against the exact position that set + // them (ctx->cEnprise/pEnprise/cTrapped/pTrapped, indexed by ply + // alongside this struct -- see chess.h's SEARCHER_THREAD_CONTEXT). + // Every such field is marked "REQUIRES SIG MATCH" below; before + // trusting one, compare this stamp against the CURRENT position's + // live signature (NOT against u64Sig just below -- that field holds + // a different thing, the position last recorded as this ply was + // being LEFT via SaveCurrentPositionHistoryInContext, not the + // position currently occupying it while Eval() is mid-flight). + // Nonzero-but-wrong is exactly as unsafe as zero: a plain FALSE + // default at struct-zero-init time (sig 0) already reads as "no + // match" against any real position, so no separate valid/invalid + // flag is needed. + UINT64 u64FullEvalSig; + // Which Eval() exit tier supplied the piPositional value most + // recently returned for THIS ply -- set inside Eval() itself (all + // three exit points), read back in QSearch/QSearchFromCheckNoStandPat + // to index FUTILITY_BASE_MARGIN_BY_SOURCE. Per-ply, not a single + // per-thread scalar, precisely because it used to be one + // (SEARCHER_THREAD_CONTEXT.uLastPositionalSource) and that was a + // real, live bug: QSearch's own move loop recurses into QSearch() + // again for earlier moves before reading this back for later ones, + // and that recursive call's own nested Eval() at ctx->uPly+1 would + // clobber a single shared scalar -- indexing the margin table with + // whatever tier the deepest just-completed call happened to use, + // not the tier that actually produced this ply's own iEval/ + // rgiPositional. Per-ply storage makes that impossible by + // construction: a deeper node's Eval() call writes to a different + // array slot entirely. + ULONG uLastPositionalSource; + SCORE iKingScore[2]; // REQUIRES SIG MATCH + // (u64FullEvalSig) -- + // only written on + // Eval()'s full path UINT64 u64NonPawnSig; UINT64 u64PawnSig; UINT64 u64Sig; @@ -1265,6 +1392,18 @@ typedef struct _SEARCHER_THREAD_CONTEXT MOVE mvNullmoveQuietRefutations[MAX_PLY_PER_SEARCH]; MOVE mvCounter[COUNTER_MOVE_TABLE_SIZE][2]; // counter-move table UCHAR uCounterDepth[COUNTER_MOVE_TABLE_SIZE]; // ply depth slot 0 was set at + // REQUIRES SIG MATCH (sPlyInfo[uPly].u64FullEvalSig, chess.h's + // PLY_INFO) before trusting any of these four -- only written on + // Eval()'s full path (eval.c's _EvalLookForDanger/ + // _EvalTrappedPieces), by whichever node last ran a full eval at + // this ply (possibly this node, possibly an earlier sibling that + // visited the same ply slot for a different position -- see + // ValueOfMaterialInTroubleDespiteMove's own comment on that). Was + // previously guarded by _EnpriseSlotValid/_TrappedSlotValid + // re-checking piece identity on the live board instead; that's a + // decent but imprecise proxy (a coincidentally-matching piece on + // the same square in an unrelated position at this ply reads as + // "fresh") -- a direct sig compare is both cheaper and exact. COOR cEnprise[MAX_PLY_PER_SEARCH][2]; // en prise piece hints PIECE pEnprise[MAX_PLY_PER_SEARCH][2]; COOR cTrapped[MAX_PLY_PER_SEARCH]; // trapped piece hint @@ -1301,14 +1440,6 @@ typedef struct _SEARCHER_THREAD_CONTEXT // called on. FLAG fCalibrateCandidate; #endif - // Which Eval() exit tier supplied the piPositional value most - // recently returned to this thread -- set inside Eval() itself - // (all three exit points), read right after the Eval() call in - // QSearch. Was CALIBRATE_QSEARCH_FUTILITY-only; promoted to - // unconditional 2026-09-08 since QSearch's real futility margin - // now indexes FUTILITY_BASE_MARGIN_BY_SOURCE with it, not just - // the calibration harness. See EVAL_POSITIONAL_SOURCE_* below. - ULONG uLastPositionalSource; #ifdef CALIBRATE_QSEARCH_FUTILITY // TRUE for the duration of a diagnostic "what if we hadn't pruned // this move" re-search -- search.c's qsearch futility gate checks @@ -1996,8 +2127,11 @@ extern PIECE_DATA g_PieceData[8]; // winning/even captures'/promotions' iValue -- harmless for sorting, but // wrong once the value is used on the eval axis (compared against // material-scale margins). Back it out to recover the pure SEE/ -// material-diff value. ComputeMoveScore() (searchsup.c) already does this; -// _ShouldWeConsiderThisMove() (search.c) reads iValue raw and needs it too. +// material-diff value. Used only inside DecodeMoveValueEstimate +// (searchsup.c) -- that function is the single place that knows which of +// generate.c's several distinct scoring conventions (MOVE_SCORING_SCHEME) +// a given iValue was packed under and decodes accordingly; don't apply +// this macro to a raw iValue anywhere else. #define MOVE_SCORE_ORDERING_BIAS(mv) \ (PIECE_VALUE_OVER_100((mv).pCaptured) + 120 + \ PIECE_VALUE_OVER_100((mv).pPromoted) - \ @@ -2901,10 +3035,10 @@ IsDraw(SEARCHER_THREAD_CONTEXT *ctx); #define QPLIES_OF_NON_CAPTURE_CHECKS (2) // Which Eval() exit tier supplied a given piPositional value -- -// SEARCHER_THREAD_CONTEXT.uLastPositionalSource above. Was diagnostic- -// only (CALIBRATE_QSEARCH_FUTILITY); promoted to unconditional -// 2026-09-08 once FUTILITY_BASE_MARGIN_BY_SOURCE (search.c) made it -// load-bearing for real search behavior, not just measurement. +// PLY_INFO.uLastPositionalSource above. Was diagnostic-only +// (CALIBRATE_QSEARCH_FUTILITY); promoted to unconditional 2026-09-08 +// once FUTILITY_BASE_MARGIN_BY_SOURCE (search.c) made it load-bearing +// for real search behavior, not just measurement. #define EVAL_POSITIONAL_SOURCE_FULL (0) #define EVAL_POSITIONAL_SOURCE_LAZY (1) #define EVAL_POSITIONAL_SOURCE_SUPERLAZY (2) @@ -2955,6 +3089,11 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, IN MOVE mv, IN ULONG uMoveNum); +SCORE +DecodeMoveValueEstimate(IN MOVE mv, + IN SCORE iRawValue, + IN MOVE_SCORING_SCHEME eScheme); + FLAG ThreadUnderTerminatingSplit(SEARCHER_THREAD_CONTEXT *); diff --git a/src/dynamic.c b/src/dynamic.c index 992b842..9c68993 100644 --- a/src/dynamic.c +++ b/src/dynamic.c @@ -538,8 +538,16 @@ _EnpriseSlotValid(SEARCHER_THREAD_CONTEXT *ctx, ULONG uSlot, ULONG uSide) ULONG uPly = ctx->uPly; COOR c = ctx->cEnprise[uPly][uSlot]; - return(IS_ON_BOARD(c) && - (ctx->sPosition.rgSquare[c].pPiece == ctx->pEnprise[uPly][uSlot]) && + // Was a live-board piece-identity re-check (same piece still on + // that square); replaced with a direct signature compare against + // the full eval that populated this slot (see PLY_INFO's + // u64FullEvalSig comment, chess.h) -- cheaper, and exact rather + // than an approximation that could false-positive on an unrelated + // position coincidentally holding the same piece on the same + // square at this ply. + return((ctx->sPlyInfo[uPly].u64FullEvalSig == + (ctx->sPosition.u64NonPawnSig ^ ctx->sPosition.u64PawnSig)) && + IS_ON_BOARD(c) && (GET_COLOR(ctx->pEnprise[uPly][uSlot]) == uSide)); } @@ -550,8 +558,10 @@ _TrappedSlotValid(SEARCHER_THREAD_CONTEXT *ctx, ULONG uSide) ULONG uPly = ctx->uPly; COOR c = ctx->cTrapped[uPly]; - return(IS_ON_BOARD(c) && - (ctx->sPosition.rgSquare[c].pPiece == ctx->pTrapped[uPly]) && + // See _EnpriseSlotValid's comment -- same replacement. + return((ctx->sPlyInfo[uPly].u64FullEvalSig == + (ctx->sPosition.u64NonPawnSig ^ ctx->sPosition.u64PawnSig)) && + IS_ON_BOARD(c) && (GET_COLOR(ctx->pTrapped[uPly]) == uSide)); } @@ -5385,7 +5385,7 @@ Return value: // govern from here. (*piPositional)[WHITE] = 0; (*piPositional)[BLACK] = 0; - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; } #ifdef EVAL_TIME ctx->sCounters.tree.u64CyclesEvalSuperLazy += @@ -5419,7 +5419,7 @@ Return value: { (*piPositional)[WHITE] = 0; (*piPositional)[BLACK] = 0; - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; } #ifdef EVAL_TIME ctx->sCounters.tree.u64CyclesEvalSuperLazy += @@ -5632,7 +5632,7 @@ Return value: { (*piPositional)[WHITE] = iAlphaMargin; (*piPositional)[BLACK] = iAlphaMargin; - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; } #ifdef EVAL_TIME ctx->sCounters.tree.u64CyclesEvalPreLazy += @@ -5665,7 +5665,7 @@ Return value: { (*piPositional)[WHITE] = iBetaMargin; (*piPositional)[BLACK] = iBetaMargin; - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; } #ifdef EVAL_TIME ctx->sCounters.tree.u64CyclesEvalPreLazy += @@ -5963,15 +5963,27 @@ Return value: // _EvalLookForDanger/_EvalTrappedPieces only ever *add* a hint // when they find one; a full eval that finds nothing this time // would otherwise leave a stale, unrelated hint from an earlier - // visit to this ply sitting here (still piece-identity-valid by - // coincidence). Clear this ply's self-danger slots first so a - // clean full eval reliably means a clean slate. + // visit to this ply sitting here. Clear this ply's self-danger + // slots first so a clean full eval reliably means a clean slate. // ctx->cEnprise[ctx->uPly][0] = ctx->cEnprise[ctx->uPly][1] = ILLEGAL_COOR; ctx->cTrapped[ctx->uPly] = ILLEGAL_COOR; _EvalLookForDanger(ctx); _EvalTrappedPieces(ctx); + // We are now committed to the full-eval path (no more lazy/ + // super-lazy exits below this point) -- stamp this ply's + // PLY_INFO with the position we just fully evaluated so every + // "REQUIRES SIG MATCH" field this call populates (iKingScore + // below; cEnprise/cTrapped above; the eval-scoped bb*Attacks + // tables) can be validated by a downstream reader with one cheap + // compare against this position's live signature, instead of the + // old per-field piece-identity re-check. See PLY_INFO's own + // comment in chess.h for why this, not u64Sig, is the field to + // compare against. + ctx->sPlyInfo[ctx->uPly].u64FullEvalSig = + (pos->u64NonPawnSig ^ pos->u64PawnSig); + // TODO: endgame-specific knowledge? e.g. B over N in an endgame // with 2 pawn wings? @@ -6031,7 +6043,7 @@ Return value: MAX(pos->iScore[BLACK] - (SCORE)(pos->uPawnMaterial[BLACK] + pos->uNonPawnMaterial[BLACK]), -(FUTILITY_BASE_MARGIN_FULL / 2)); - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_FULL; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_FULL; } g_Options.iLastEvalScore = iScoreForSideToMove; @@ -6069,7 +6081,7 @@ Return value: // purposes, not what a normal build actually returns here // -- fix it back up to match the restored (super-lazy) // values. - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY; } } else if (TRUE == fWouldHaveExited) @@ -6098,7 +6110,7 @@ Return value: { (*piPositional)[WHITE] = iSavedLazyPositional; (*piPositional)[BLACK] = iSavedLazyPositional; - ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY; } } #endif diff --git a/src/generate.c b/src/generate.c index 7cc28d0..a04b4c6 100644 --- a/src/generate.c +++ b/src/generate.c @@ -4063,6 +4063,8 @@ Return value: ULONG uCounterMoveContKey = 0; FLAG fHaveCounterMove = FALSE; + ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_NORMAL; + // // We have generated all moves here. We also know that we are not // escaping from check. There can be a hash move -- if there is @@ -4337,6 +4339,8 @@ Return value: // and we should not generate it again. // + ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_ESCAPE; + ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY); for (u = pStack->uBegin[uPly]; u < pStack->uEnd[uPly]; @@ -4554,6 +4558,8 @@ Return value: ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY); ASSERT(uPly >= 1); + ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_QSEARCH_CHECKS; + for (u = pStack->uBegin[uPly]; u < pStack->uEnd[uPly]; u++) @@ -4703,6 +4709,8 @@ Return value: // ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY); + ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_QSEARCH_NO_CHECKS; + #ifdef DEBUG uColor = pos->uToMove; #endif @@ -468,6 +468,13 @@ Return value: ASSERT(d); Trace("Null move cutoff rate: %5.3f percent.\n", ((n / d) * 100.0)); +#ifdef PERF_COUNTERS + Trace("Node-type surprises: PV failed high/low=%llu, ALL failed high=%llu, " + "CUT failed low=%llu.\n", + ctx->sCounters.tree.u64PVNodeSurprised, + ctx->sCounters.tree.u64AllNodeFailedHigh, + ctx->sCounters.tree.u64CutNodeFailedLow); +#endif if (ctx->sCounters.egtb.uProbes > 0) { n = (double)(ctx->sCounters.egtb.uHits); @@ -806,6 +813,13 @@ Return value: UINT64 u64StartingNodeCount; ULONG uNumLegalMoves; + // This function's own move loop overwrites sMoveStack[ctx->uPly]'s + // iValue with a node-count/score composite for next-iteration move + // ordering -- not a generate.c tier encoding, and not meaningful to + // ComputeMoveScore's decoder (see MOVE_SCORING_SCHEME's own comment, + // chess.h). + pi->eMoveScoringScheme = MOVE_SCORING_ROOT; + #ifdef DEBUG POSITION *pos = &ctx->sPosition; ASSERT(IS_VALID_SCORE(iAlpha)); diff --git a/src/search.c b/src/search.c index 8ee2d3b..4e89a2c 100644 --- a/src/search.c +++ b/src/search.c @@ -235,8 +235,13 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } DTEnterNode(ctx, uDepth, FALSE, iAlpha, iBeta); iInitialAlpha = iAlpha; - pi->fPvNode = (iBeta != iAlpha + 1); - pi->fMovesRescoredByIID = FALSE; + // pi->eNodeType is set centrally by CommonSearchInit (see its own + // comment) -- covers both Search() and QSearch() from one place. + // Safe default until the real move-generation/scoring call below + // sets this to whatever scheme actually ran (generate.c's four + // _Score* functions and RescoreMovesViaSearch are the authoritative + // setters -- see MOVE_SCORING_SCHEME's own comment in chess.h). + pi->eMoveScoringScheme = MOVE_SCORING_NORMAL; ASSERT((IS_CHECKING_MOVE(mvLast) && (TRUE == pi->fInCheck)) || (!IS_CHECKING_MOVE(mvLast) && (FALSE == pi->fInCheck))); @@ -472,7 +477,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // we generated at this ply using a shallower // search. "Internal Iterative Deepening" or // something like it. - if ((TRUE == pi->fPvNode) && + if ((NODE_PV == pi->eNodeType) && (mvHash.uMove == 0) && (ctx->sMoveStack.mvf[x].iValue < SORT_THESE_FIRST) && (0 == (ctx->sMoveStack.mvf[x].iValue & @@ -530,7 +535,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // this closes a real gap rather than relying on it not // mattering in practice. ASSERT(!uFutilityMargin); - if ((FALSE == pi->fPvNode) && + if ((NODE_PV != pi->eNodeType) && (iOrigExtend == 0) && (ctx->uPly >= 2) && (ctx->sPlyInfo[ctx->uPly - 2].iExtensionAmount <= 0)) @@ -590,7 +595,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // never classifies an IID-rescored move as a // leftover (matches pre-retirement behavior). fIsLeftoverMove = FALSE; - if (TRUE == pi->fMovesRescoredByIID) + if (MOVE_SCORING_IID_RESCORED == pi->eMoveScoringScheme) { SelectBestNoHistory(ctx, x); } @@ -650,7 +655,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // g_fCanSplit[] indexing ever evaluates it. if (((uLegalMoves >= 2) && fIsLeftoverMove) && (0 != g_uNumHelpersAvailable) && - (FALSE == pi->fMovesRescoredByIID) && + (MOVE_SCORING_IID_RESCORED != pi->eMoveScoringScheme) && (0 == uFutilityMargin) && (uDepth >= ONE_PLY) && (TRUE == g_fCanSplit[uDepth / ONE_PLY - 1]) && @@ -681,6 +686,52 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if (IS_VALID_SCORE(iScore)) { pi->mvBest = mvBest; + if (iBestScore >= iBeta) + { + // + // The split failed high. StartParallelSearch leaves + // iAlpha untouched on this path (see its own + // comment), so falling through to no_more_moves' + // generic iAlpha-moved classifier would misread + // this as a fail-low/exact-score case -- skipping + // UpdateDynamicMoveOrdering entirely and storing + // the fail-high score as an (inverted-sense, + // unsound) upper bound instead of a lower bound. + // Handle it explicitly here instead, matching the + // non-split fail-high branch above -- except for + // uCurrent: mvBest may have been found by a helper + // thread with its own separate ctx->sMoveStack, not + // this (originating) thread's, so passing this + // thread's own move index (the non-split call's + // convention) doesn't identify mvBest's position in + // any coherent move array. Pass 0 to skip the + // "decrement history for moves tried earlier at + // this node" loop entirely -- there's no single + // thread-local move sequence to decrement across a + // split, so it's dropped rather than fed a bogus + // index (which previously tripped this exact + // assert in dynamic.c: mvf[uCurrent] wasn't + // mvBest). The killer/counter-move/history-increment + // side of the update below is unaffected. + // + if (!IS_CAPTURE_OR_PROMOTION(mvBest)) + { + UpdateDynamicMoveOrdering(ctx, + uDepth, + mvBest, + iBestScore, + 0); + } + StoreLowerBound(mvBest, pos, iBestScore, uDepth, fThreat); + if (mvBest.pCaptured && !IS_PAWN(mvBest.pCaptured) && + (iBeta < +NMATE)) + { + ASSERT(ctx->uPly > 0); + RecordEnprisePieceAtPly(ctx, ctx->uPly - 1, + mvBest.cTo); + } + goto end; + } goto no_more_moves; } else @@ -735,8 +786,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // Decide how much (if any) to reduce this move's depth -- // graded LMR. if ((uDepth > TWO_PLY) && - !pi->fPvNode && - !(ctx->sPlyInfo[ctx->uPly - 1].fPvNode) && + (NODE_PV != pi->eNodeType) && (uLegalMoves > 5) && (0 == iExtend) && (!IS_ESCAPING_CHECK(mv)) && @@ -757,7 +807,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { ASSERT(iExtend == 0); iExtend = iLMR; - pi->iExtensionAmount = iLMR; } } @@ -828,6 +877,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, INC(ctx->sCounters.tree.u64CMEvidenceTries[uCMEvidenceBucket]); } #endif + // Record the extension/reduction actually applied to + // THIS move, unconditionally -- not just when it's + // negative. Leaving this stale from whatever earlier + // move at this ply last happened to set it let a child + // node checking "was my parent's move reduced" via + // (pi-1)->iExtensionAmount < 0 read a leftover value + // from a completely different sibling move, not the + // one actually played. + pi->iExtensionAmount = iExtend; + // Compute the next search depth for this move/subtree. uNextDepth = uDepth - ONE_PLY + iExtend; if (uNextDepth >= MAX_DEPTH_PER_SEARCH) uNextDepth = 0; @@ -1046,6 +1105,34 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(IS_VALID_SCORE(iAlpha)); ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); +#ifdef PERF_COUNTERS + if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore)) + { + FLAG fFailedLow = (iBestScore <= iAlpha); + FLAG fFailedHigh = (iBestScore >= iBeta); + switch (pi->eNodeType) + { + case NODE_PV: + if (fFailedLow || fFailedHigh) + { + INC(ctx->sCounters.tree.u64PVNodeSurprised); + } + break; + case NODE_ALL: + if (fFailedHigh) + { + INC(ctx->sCounters.tree.u64AllNodeFailedHigh); + } + break; + case NODE_CUT: + if (fFailedLow) + { + INC(ctx->sCounters.tree.u64CutNodeFailedLow); + } + break; + } + } +#endif DTLeaveNode(ctx, FALSE, iBestScore, mvBest); return(iBestScore); } @@ -1400,9 +1487,17 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, if (iMoveValue >= SORT_THESE_FIRST) { fHaveMoveValue = TRUE; - iMoveValue &= STRIP_OFF_FLAGS; - ASSERT(iMoveValue >= 0); - iMoveValue -= MOVE_SCORE_ORDERING_BIAS(mv); + // This function is only ever called outside of check (see + // the ASSERTs above), so uMoveNum always indexes one of + // QSearch's two schemes -- never MOVE_SCORING_ESCAPE. Which + // of the two matters here: InclChecks bakes in a +4 + // checking-move nudge NoChecks doesn't, so a raw + // MOVE_SCORE_ORDERING_BIAS subtraction alone (correct for + // NoChecks) silently over-values a checking capture scored + // under InclChecks by 4. DecodeMoveValueEstimate (searchsup.c) + // is the one place that knows the difference. + iMoveValue = DecodeMoveValueEstimate( + mv, iMoveValue, ctx->sPlyInfo[ctx->uPly].eMoveScoringScheme); if (mv.pCaptured) { // If there are very few pieces left on the board, @@ -1572,7 +1667,7 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, // hundreds of centipawns short of the bar, regular-lazy was already // close to safe (1.26% down to 0.43%), super-lazy was only risky very // close to the bar (7.96% at distance 0-25, 0.54% by 400+). Indexed -// by ctx->uLastPositionalSource (set inside Eval() -- see +// by PLY_INFO.uLastPositionalSource (set inside Eval() -- see // EVAL_POSITIONAL_SOURCE_* in chess.h). Provisional, derived from one // run at sd 6; re-derive with `calibrate qsearchfutility` if search // behavior affecting typical qsearch iEval/iFutility gaps changes. @@ -1691,7 +1786,8 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, if (FALSE == fEvalComputed) { iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional); - ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); + ASSERT(ctx->sPlyInfo[ctx->uPly].uLastPositionalSource < + EVAL_POSITIONAL_SOURCE_COUNT); fEvalComputed = TRUE; } @@ -1705,9 +1801,16 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, // so this reuses the same margin rather than inventing a // separate, unvalidated one -- better to be at least as // conservative as the calibrated case than to guess looser. + // uLastPositionalSource is read off this ply's own + // PLY_INFO slot, not a per-thread scalar, so a recursive + // QSearch() call for an earlier move in this same loop + // (which writes ctx->sPlyInfo[ctx->uPly+1], a different + // slot) can't clobber it before a later iteration reads it + // back. if (iEval < iAlpha && ((iEval + PIECE_VALUE(mv.pCaptured)) + rgiPositional[pos->uToMove] + - FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource]) < iAlpha) + FUTILITY_BASE_MARGIN_BY_SOURCE[ + ctx->sPlyInfo[ctx->uPly].uLastPositionalSource]) < iAlpha) { continue; } @@ -1761,6 +1864,13 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, end: ASSERT((uLegalMoves > 0) || (iBestScore <= -NMATE)); ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING); + // No node-type-surprise counting here (unlike Search()/QSearch()'s + // own "end:" labels) -- this function has exactly one call site + // (QSearch(), which does `goto end` immediately after calling this + // at the SAME ply, no MakeMove in between) and QSearch()'s own + // "end:" block already counts that identical (iBestScore, ctx->uPly, + // eNodeType) outcome; counting here too would double-count every + // in-check qsearch node. return(iBestScore); } @@ -1882,7 +1992,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(!InCheck(pos, pos->uToMove)); pi->iEval = iEval = iBestScore = Eval(ctx, iAlpha, iBeta, &rgiPositional); - // If that Eval (above) was full (i.e. not lazy) it may have set // en prise and trapped piece indicators. Likewise, other nodes // at this depth may have set en prise piece hints. If these are @@ -1948,9 +2057,9 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, // would be for the qsearch-futility calibration harness. iFutility = -NMATE; #else - ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); + ASSERT(pi->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); iFutility = iAlpha - - (FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource] + + (FUTILITY_BASE_MARGIN_BY_SOURCE[pi->uLastPositionalSource] + rgiPositional[pos->uToMove]) - iEval; iFutility = MAX0(iFutility); #endif @@ -2004,7 +2113,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, , iAlpha , iBeta , rgiPositional[pos->uToMove] - , ctx->uLastPositionalSource + , pi->uLastPositionalSource #endif )) { @@ -2070,11 +2179,20 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, // Readjust futility margin here; it can be wider now. if (iAlpha < +NMATE) { - ASSERT(ctx->uLastPositionalSource < + // pi->uLastPositionalSource is this ply's + // own PLY_INFO slot -- the recursive + // QSearch() call just above (for the move + // that raised alpha) wrote its own nested + // Eval() results to ctx->uPly+1, a + // different slot, so this one is + // untouched and still describes the same + // Eval() call that produced iEval/ + // rgiPositional above. + ASSERT(pi->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); iFutility = (iAlpha - (FUTILITY_BASE_MARGIN_BY_SOURCE[ - ctx->uLastPositionalSource] + + pi->uLastPositionalSource] + rgiPositional[pos->uToMove]) - iEval); iFutility = MAX0(iFutility); @@ -2091,6 +2209,35 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING); DTLeaveNode(ctx, TRUE, iBestScore, pi->mvBest); +#ifdef PERF_COUNTERS + // See Search()'s own "end:" label for the full rationale. + if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore)) + { + FLAG fFailedLow = (iBestScore <= iAlpha); + FLAG fFailedHigh = (iBestScore >= iBeta); + switch (pi->eNodeType) + { + case NODE_PV: + if (fFailedLow || fFailedHigh) + { + INC(ctx->sCounters.tree.u64PVNodeSurprised); + } + break; + case NODE_ALL: + if (fFailedHigh) + { + INC(ctx->sCounters.tree.u64AllNodeFailedHigh); + } + break; + case NODE_CUT: + if (fFailedLow) + { + INC(ctx->sCounters.tree.u64CutNodeFailedLow); + } + break; + } + } +#endif // Note: iBestScore can be +INFINITY or -INFINITY here even in the // absence of a legitimate mate detected if we disallowed stand diff --git a/src/searchsup.c b/src/searchsup.c index 41415dc..32e1be8 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -201,12 +201,22 @@ ThreadUnderTerminatingSplit(IN SEARCHER_THREAD_CONTEXT *ctx) { ULONG u; + // + // A thread can be nested in more than one split at once (a helper + // thread can itself call StartParallelSearch while inside + // HelpSearch for an outer split), so pSplitInfo[] is a stack, not + // a single slot -- slot 0 is the outermost split this thread is + // part of, later slots are progressively more nested ones. Any of + // them terminating means this thread's current work is moot, so + // all populated slots must be checked, not just the first one + // found. + // for (u = 0; u < NUM_SPLIT_PTRS_IN_CONTEXT; u++) { - if (ctx->pSplitInfo[u] != NULL) + if ((ctx->pSplitInfo[u] != NULL) && + (ctx->pSplitInfo[u]->fTerminate == TRUE)) { - if (ctx->pSplitInfo[u]->fTerminate == TRUE) return(TRUE); - return(FALSE); + return(TRUE); } } return(FALSE); @@ -260,63 +270,66 @@ Return value: SCORE -ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, - IN MOVE mv, - IN ULONG uMoveNum) +DecodeMoveValueEstimate(IN MOVE mv, + IN SCORE iRawValue, + IN MOVE_SCORING_SCHEME eScheme) +/** + +Routine description: + + generate.c has (at least) four distinct conventions for packing + sMoveStack[].iValue (see MOVE_SCORING_SCHEME's own comment, chess.h), + plus two more that aren't generate.c tier encodings at all + (RescoreMovesViaSearch's real eval-axis score, and root.c's node- + count/score composite). This function is the one place that knows + how to turn a raw iValue back into an eval-axis (100=1-pawn) + material estimate, given which scheme produced it -- every caller + that needs an actual value rather than just a tier/leftover + classification should go through here rather than inlining its own + guess at the encoding. + +Parameters: + + IN MOVE mv, + IN SCORE iRawValue, + IN MOVE_SCORING_SCHEME eScheme + +Return value: + + SCORE + +**/ { - SCORE iMoveScore = (PIECE_VALUE(mv.pCaptured) + - PIECE_VALUE(mv.pPromoted)); - if (uMoveNum != (ULONG)-1) + switch (eScheme) { - ASSERT(uMoveNum < MAX_MOVE_STACK); - ASSERT(IS_SAME_MOVE(mv, ctx->sMoveStack.mvf[uMoveNum].mv)); - ASSERT(ctx->uPly > 0); - iMoveScore = ctx->sMoveStack.mvf[uMoveNum].iValue; - // Every current caller (search.c's EFP check, and - // ComputeMoveExtension's two ComputeMoveScore() call sites) only - // reaches here after MakeMove(ctx, mv) has already succeeded, so - // ctx->uPly is always the *child's* ply here -- uMoveNum indexes - // the parent's move list, i.e. ctx->uPly - 1, not ctx->uPly. That's - // the ply RescoreMovesViaSearch (if it ran) would have rescored, - // so that's the flag to check. - if (TRUE == ctx->sPlyInfo[ctx->uPly - 1].fMovesRescoredByIID) - { - // RescoreMovesViaSearch already put a real, searched eval-axis - // score here -- better than SEE/MVV-LVA, since it reflects an - // entire subtree, not just the immediate exchange. Trust it - // exactly as-is; don't run it through the capture-bias - // subtraction or collapse it via MIN0, both of which assume - // generate.c's ordering-encoded format, which this isn't. - } - else if ((iMoveScore >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv)) - { - ASSERT(iMoveScore > 0); - iMoveScore &= STRIP_OFF_FLAGS; + case MOVE_SCORING_IID_RESCORED: + // Already a real, searched eval-axis score (reflects an + // entire subtree, not just the immediate exchange) -- + // trust it exactly as-is. + return(iRawValue); - // _ScoreAllMoves (generate.c) bakes a flat +120 move-ordering - // bias plus small MVV-LVA tie-break nudges - // (PIECE_VALUE_OVER_100 terms) on top of the real SEE/ - // material-diff value for winning/even captures and - // promotions -- harmless for sorting (every capture gets - // the same treatment), but this function's callers use the - // result as an eval-axis quantity (compared against - // material-scale margins in futility/extension code), so - // back the ordering-only bias back out to recover the pure - // SEE/material-diff value, same axis as the raw - // PIECE_VALUE() fallback below. - iMoveScore -= (PIECE_VALUE_OVER_100(mv.pCaptured) + 120 + - PIECE_VALUE_OVER_100(mv.pPromoted) - - PIECE_VALUE_OVER_100(mv.pMoved)); - } - else - { - // All quiet moves (including killer/killer-mate quiet moves, - // which can reach SORT_THESE_FIRST or the killer-tier bits via - // generate.c's ordering bonuses with no relation to a move's - // real eval-axis value) collapse to 0 here -- correct, since - // this function's contract is "estimate the move's value on - // the 100=1-pawn axis," and a quiet move's ordering bonus - // carries no such estimate. + case MOVE_SCORING_NORMAL: + case MOVE_SCORING_QSEARCH_NO_CHECKS: + // Identical +120 flat move-ordering bias plus small + // MVV-LVA tie-break nudges for winning/even captures and + // promotions -- harmless for sorting, but wrong on the + // eval axis until backed out. + if ((iRawValue >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv)) + { + ASSERT(iRawValue > 0); + iRawValue &= STRIP_OFF_FLAGS; + iRawValue -= MOVE_SCORE_ORDERING_BIAS(mv); + return(iRawValue); + } + // All quiet moves (including killer/killer-mate quiet + // moves, which can reach SORT_THESE_FIRST or the killer- + // tier bits via generate.c's ordering bonuses with no + // relation to a move's real eval-axis value) collapse to + // 0 here -- correct, since a quiet move's ordering bonus + // carries no such estimate. A genuinely negative raw value + // (a losing capture, scored without any tier flag) passes + // through MIN0 unchanged, preserving its real (negative) + // material estimate. // // FUTURE WORK: 0 is uniform across every quiet move, which // throws away signal we plausibly have. Two candidate @@ -334,8 +347,90 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, // moves -- eval.c's real value is far more than per-square // tables (mobility, king safety, pawn structure), so this // would only ever be directionally suggestive, not exact. - iMoveScore = MIN0(iMoveScore); - } + return(MIN0(iRawValue)); + + case MOVE_SCORING_QSEARCH_CHECKS: + // Same +120 convention, plus two QSearch-only ordering + // nudges baked into the same value by + // _ScoreQSearchMovesInclChecks: +4 for a checking move, + // +64 for a same-square recapture. The checking-move term + // is backed out here; the same-square-recapture term isn't + // (this function doesn't receive mvLast) -- a known, + // small (<=64, well under a minor piece) residual + // imprecision for that specific case, not silently wrong + // by an unbounded amount the way the pre-fix code was for + // escapes. + if ((iRawValue >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv)) + { + ASSERT(iRawValue > 0); + iRawValue &= STRIP_OFF_FLAGS; + iRawValue -= MOVE_SCORE_ORDERING_BIAS(mv); + if (IS_CHECKING_MOVE(mv)) + { + iRawValue -= 4; + } + return(iRawValue); + } + return(MIN0(iRawValue)); + + case MOVE_SCORING_ESCAPE: + // _ScoreAllEscapes' winning-capture encoding uses a + // DIFFERENT bias than _ScoreAllMoves (no +120) plus an + // IS_KING(mv.pMoved) right-shift that's lossy -- can't be + // inverted exactly. Even captures are tagged FIRST_KILLER, + // not SORT_THESE_FIRST, so they would not even reach a + // capture-decode branch keyed on that bit. Rather than try + // to invert either packed encoding, just recompute the + // cheap raw material estimate directly from the move's own + // fields -- exactly what _ScoreAllEscapes itself computes + // as ITS OWN first-pass estimate before ever calling SEE + // (deliberately not re-invoking SEE here -- same axis, + // just cheaper and not dependent on reversing a lossy + // encoding). + if (IS_CAPTURE_OR_PROMOTION(mv)) + { + return((SCORE)(PIECE_VALUE(mv.pCaptured) - + PIECE_VALUE(mv.pMoved))); + } + return(0); + + case MOVE_SCORING_ROOT: + default: + // Not a generate.c tier encoding at all -- a node-count/ + // score composite (root.c), only ever populates ply 0's + // move stack. No current caller asks to decode a root-ply + // move here; fail loudly rather than silently return a + // number that looks plausible but isn't. + ASSERT(FALSE); + return(0); + } +} + + +SCORE +ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, + IN MOVE mv, + IN ULONG uMoveNum) +{ + SCORE iMoveScore = (PIECE_VALUE(mv.pCaptured) + + PIECE_VALUE(mv.pPromoted)); + if (uMoveNum != (ULONG)-1) + { + ASSERT(uMoveNum < MAX_MOVE_STACK); + ASSERT(IS_SAME_MOVE(mv, ctx->sMoveStack.mvf[uMoveNum].mv)); + ASSERT(ctx->uPly > 0); + // Every current caller (search.c's EFP check, and + // ComputeMoveExtension's two ComputeMoveScore() call sites) only + // reaches here after MakeMove(ctx, mv) has already succeeded, so + // ctx->uPly is always the *child's* ply here -- uMoveNum indexes + // the parent's move list, i.e. ctx->uPly - 1, not ctx->uPly. That's + // the ply whatever scoring pass ran (generate.c's scorers, + // RescoreMovesViaSearch) actually scored, so that's the ply + // whose eMoveScoringScheme is authoritative here. + iMoveScore = DecodeMoveValueEstimate( + mv, + ctx->sMoveStack.mvf[uMoveNum].iValue, + ctx->sPlyInfo[ctx->uPly - 1].eMoveScoringScheme); } return(iMoveScore); } @@ -735,7 +830,7 @@ Return value: { ctx->sMoveStack.mvf[x].iValue = rgScores[x - uBegin]; } - ctx->sPlyInfo[ctx->uPly].fMovesRescoredByIID = TRUE; + ctx->sPlyInfo[ctx->uPly].eMoveScoringScheme = MOVE_SCORING_IID_RESCORED; } else { @@ -750,11 +845,11 @@ Return value: // ordering value completely untouched and just bump uBest into // killer-tier territory so the normal (non-rescored) selection // path -- SelectBestWithHistory/NoHistory -- picks it first, the - // same trick generate.c uses for a real killer move. Do NOT set - // fMovesRescoredByIID: this ply's iValue is back to being - // generate.c's ordering encoding, not real scores, for every - // move including uBest (whose bump is itself just an ordering - // flag, not part of its own eval-axis value). + // same trick generate.c uses for a real killer move. Do NOT + // change eMoveScoringScheme: this ply's iValue is back to being + // whatever generate.c scheme originally scored it, not real + // scores, for every move including uBest (whose bump is itself + // just an ordering flag, not part of its own eval-axis value). ctx->sMoveStack.mvf[uBest].iValue |= FIRST_KILLER; } @@ -1028,6 +1123,15 @@ Return value: // if (TRUE == IsDraw(ctx)) { + // Every early exit from this function must clear pi->mvBest + // itself, since the real reset further down is never reached + // from here. Found via a DEBUG-build assertion failure: + // TryNullmovePruning reading (pi+1)->mvBest as if it reflected + // the null-move probe's own search, when it was actually stale + // data left by a completely unrelated earlier node that once + // occupied the same ply slot and bailed out via this exact path + // without ever being cleared. + pi->mvBest.uMove = 0; INC(ctx->sCounters.tree.u64LeafCount); *piScore = g_iDrawScore[ctx->sPosition.uToMove]; if ((*piAlpha < *piScore) && (*piScore < *piBeta)) @@ -1084,6 +1188,8 @@ Return value: *piScore = Eval(ctx, *piAlpha, *piBeta, NULL); pi->iEval = *piScore; } + // See the IsDraw branch above -- same staleness hazard, same fix. + pi->mvBest.uMove = 0; DTTRACE("<toodeep/>"); goto end; } @@ -1131,6 +1237,33 @@ Return value: pi->iAlpha = *piAlpha; pi->iBeta = *piBeta; #endif + // Classic PVS node typing (see NODE_TYPE's definition in chess.h). + // Centralized here rather than in each caller's move loop + // (search.c, root.c, split.c) since CommonSearchInit is the one + // place both Search() and QSearch() funnel through -- a wide + // window is unambiguously PV regardless of move number (this + // covers "first move of a PV node" for free, no need to track + // which move number got us here); a narrow window's type depends + // only on the PARENT's own type, not on move number either: a CUT + // parent's children are ALL (expected to fail low, must search + // everything to prove it), and both a PV parent's non-first + // children AND an ALL parent's children are CUT (expected to fail + // high). The ctx->uPly==0 guard is defensive only -- root is + // always called with a wide window in practice, so the width check + // above already handles it. + if (*piBeta != *piAlpha + 1) + { + pi->eNodeType = NODE_PV; + } + else if (ctx->uPly == 0) + { + pi->eNodeType = NODE_CUT; + } + else + { + NODE_TYPE eParentType = ctx->sPlyInfo[ctx->uPly - 1].eNodeType; + pi->eNodeType = (NODE_CUT == eParentType) ? NODE_ALL : NODE_CUT; + } fRet = FALSE; end: @@ -1182,7 +1315,7 @@ FLAG WeShouldTryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, (pos->uNonPawnCount[pos->uToMove][0] > 2) && (FALSE == pi->fInCheck) && (iBeta != +INFINITY) && - (FALSE == pi->fPvNode)) + (NODE_PV != pi->eNodeType)) { if (uNullDepth <= 6 * ONE_PLY) { diff --git a/src/split.c b/src/split.c index f8da7ed..3a69ab4 100644 --- a/src/split.c +++ b/src/split.c @@ -81,7 +81,13 @@ Revision History: extern ULONG g_uIterateDepth; ULONG g_uNumHelperThreads = 0; -#define MAX_SPLITS (20) +// Sized for up to 64 threads (--cpus' documented max, main.c) each +// holding a handful of nested split pointers (NUM_SPLIT_PTRS_IN_CONTEXT, +// chess.h) at once -- exhausting this pool just degrades gracefully +// (StartParallelSearch falls back to serial search), but a ceiling +// tuned for a handful of threads bites harder the more of them are +// actually configured, and each SPLIT_INFO is only a few KB. +#define MAX_SPLITS (64) #define IDLE ((ULONG)-1) void @@ -570,6 +576,7 @@ Return value: sizeof(POSITION)); g_SplitInfo[u].iEval = ctx->sPlyInfo[ctx->uPly].iEval; g_SplitInfo[u].iImprovement = iImprovement; + g_SplitInfo[u].eNodeType = ctx->sPlyInfo[ctx->uPly].eNodeType; #if DEBUG g_SplitInfo[u].uSplitPly = ctx->uPly; memcpy(&(g_SplitInfo[u].sSplitPosition), @@ -886,7 +893,17 @@ Return value: if (iScore >= g_SplitInfo[u].iBeta) { // - // We failed high so we want to update the split node + // We failed high so we want to update the split node. + // Do NOT touch g_SplitInfo[u].iAlpha here -- the caller + // (StartParallelSearch's caller in search.c) asserts + // iAlpha stays < iBeta after a split returns, and + // detects fail-high purely via iBestScore/iScore, not + // via alpha having moved. (See search.c's handling of + // this return for the other half of this fix: it must + // special-case iBestScore >= iBeta itself rather than + // falling into the generic no_more_moves classifier, + // which infers "exact score" vs. "fail-high" purely + // from whether alpha moved.) // g_SplitInfo[u].fTerminate = TRUE; g_SplitInfo[u].PV[0] = NULLMOVE; @@ -1152,8 +1169,7 @@ Return value: // the deterministic missing-gate bug it actually was. // if ((uDepth > TWO_PLY) && - !(ctx->sPlyInfo[ctx->uPly].fPvNode) && - !(ctx->sPlyInfo[ctx->uPly - 1].fPvNode) && + (NODE_PV != g_SplitInfo[u].eNodeType) && ((g_SplitInfo[u].uAlreadyDone + uMoveNum + 1) > 5) && (0 == iExtend) && (!IS_ESCAPING_CHECK(mv)) && |
