diff options
| author | Scott Gasch <[email protected]> | 2026-10-09 14:18:52 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-09 14:18:52 -0700 |
| commit | fbdcb9d76dd2444d8bc631331eaeb9435cc247f8 (patch) | |
| tree | 602ab360462d6f23188ba51f962c4abb2dc6c7e5 /src/search.c | |
| parent | 73b4afb12abf7cc442831dea42b41b63a2ce07be (diff) | |
* Grab bag of eval.c changes.
* Don't bother not hashing when moves are pruned.
* Prune moves move librerally with LMP.
Inexplicably, this is slightly shallower than HEAD (despite pruning
MORE). It may be because it is slightly slower.
It consistently beats HEAD in match_play.py though (3x now).
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 315 |
1 files changed, 72 insertions, 243 deletions
diff --git a/src/search.c b/src/search.c index fcf8907..0ac0037 100644 --- a/src/search.c +++ b/src/search.c @@ -447,10 +447,43 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { POSITION *pos = &ctx->sPosition; PLY_INFO *pi = &ctx->sPlyInfo[ctx->uPly]; +#ifdef DEBUG + ASSERT(IS_VALID_SCORE(iAlpha)); + ASSERT(IS_VALID_SCORE(iBeta)); + ASSERT(iAlpha < iBeta); + ASSERT(ctx->uPly > 0); + memcpy(&pi->sPosition, pos, sizeof(POSITION)); +#endif CUMULATIVE_SEARCH_FLAGS *pf = &ctx->sSearchFlags; + ASSERT(IS_VALID_FLAG(pf->fAvoidNullmove)); + ASSERT(IS_VALID_FLAG(pf->fVerifyNullmove)); + + // Jump directly to Qsearch if remaining depth is low enough. + // This is the only place Qsearch is entered. Lowered from ONE_PLY to + // THREE_QUARTERS_PLY to match the check extension's new flat amount + // (searchsup.c's ComputeMoveExtension) -- a lone check (or short run + // of them) still buys exactly one extra full-width ply as before, but + // a long unbroken chain now pays QUARTER_PLY of real cost per check + // instead of extending for free. root.c compensates by trimming the + // same QUARTER_PLY off the per-iteration depth budget so this doesn't + // just add a blanket 1/4 ply to every search. + SCORE iBestScore; + if (uDepth < THREE_QUARTERS_PLY) + { + pf->fCouldStandPat[BLACK] = pf->fCouldStandPat[WHITE] = FALSE; + pf->uQsearchNodes = pf->uQsearchDepth = 0; + pf->uQsearchCheckDepth = QPLIES_OF_NON_CAPTURE_CHECKS; + pi->fInQsearch = TRUE; + iBestScore = QSearch(ctx, iAlpha, iBeta); + ASSERT(pf->uQsearchNodes < 20000); + ASSERT(pf->uQsearchDepth == 0); + goto end; + } + pi->fInQsearch = FALSE; + iBestScore = -INFINITY_SCORE; MOVE mvLast = (pi-1)->mv; - SCORE iBestScore = -INFINITY_SCORE; - SCORE iInitialAlpha; + ASSERT((mvLast.uMove != 0) || (pf->fAvoidNullmove == TRUE)); + SCORE iInitialAlpha = iAlpha; SCORE iEval; MOVE mv, mvBest, mvHash; ULONG x = 0; @@ -507,12 +540,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // LMR-specific rather than part of the shared "unremarkable move" // definition gate-1 describes. FLAG fMoveIsPruneReduceEligible = FALSE; + ULONG uRawDepthB; #ifdef PERF_COUNTERS - // See the node-type counter comment below: guards the surprise - // counters at end: so a node short-circuited by CommonSearchInit - // (and therefore never typed for this visit) cannot be scored - // against a stale eNodeType left by a previous node at this ply. - FLAG fNoteTypedThisCall = FALSE; FLAG fThisMoveIsCountermoveMatch = FALSE; ULONG uCMEvidenceBucket = 0; ULONG uThisMoveHangBucket = 0; @@ -536,7 +565,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uStage = TRY_HASH_MOVE; ULONG u; FLAG fTryEFPHere = FALSE; - FLAG fAnyMovePruned = FALSE; FLAG fThisMoveEFPPruned; FLAG fThisMoveLMPPruned; SCORE iMoveSee; @@ -549,40 +577,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, UINT64 uNullCalibRealLoopNodeCost = 0; UINT64 uNullCalibNodesBeforeRealLoop = 0; #endif -#ifdef DEBUG - ASSERT(IS_VALID_SCORE(iAlpha)); - ASSERT(IS_VALID_SCORE(iBeta)); - ASSERT(iAlpha < iBeta); - ASSERT(ctx->uPly > 0); - ASSERT((mvLast.uMove != 0) || (pf->fAvoidNullmove == TRUE)); - ASSERT(IS_VALID_FLAG(pf->fAvoidNullmove)); - ASSERT(IS_VALID_FLAG(pf->fVerifyNullmove)); - memcpy(&pi->sPosition, pos, sizeof(POSITION)); -#endif mvBest.uMove = 0; - // Jump directly to Qsearch if remaining depth is low enough. - // This is the only place Qsearch is entered. Lowered from ONE_PLY to - // THREE_QUARTERS_PLY to match the check extension's new flat amount - // (searchsup.c's ComputeMoveExtension) -- a lone check (or short run - // of them) still buys exactly one extra full-width ply as before, but - // a long unbroken chain now pays QUARTER_PLY of real cost per check - // instead of extending for free. root.c compensates by trimming the - // same QUARTER_PLY off the per-iteration depth budget so this doesn't - // just add a blanket 1/4 ply to every search. - if (uDepth < THREE_QUARTERS_PLY) - { - pf->fCouldStandPat[BLACK] = pf->fCouldStandPat[WHITE] = FALSE; - pf->uQsearchNodes = pf->uQsearchDepth = 0; - pf->uQsearchCheckDepth = QPLIES_OF_NON_CAPTURE_CHECKS; - pi->fInQsearch = TRUE; - iBestScore = QSearch(ctx, iAlpha, iBeta); - ASSERT(pf->uQsearchNodes < 20000); - ASSERT(pf->uQsearchDepth == 0); - goto end; - } - pi->fInQsearch = FALSE; - // Common initialization code (which may cause a cutoff or change the // bounds or decide that we need to stop searching now). if (TRUE == CommonSearchInit(ctx, @@ -593,41 +589,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, goto end; } -#ifdef PERF_COUNTERS - // 2026-10-03: denominator for the node-type surprise line, counted - // HERE rather than inside CommonSearchInit, for two reasons that - // were both silently corrupting that metric: - // - // 1. FULL-WIDTH ONLY. CommonSearchInit is the single funnel both - // Search() and QSearch() pass through, so counting there swept - // in qsearch -- ~84% of the tree -- and a qsearch node whose - // stand-pat already beats beta returns iBestScore >= iBeta - // immediately. Typed ALL, that lands in "ALL failed high", - // making the headline number mostly a measure of stand-pat - // frequency. The question the line's name asks is about - // full-width node-type prediction, so qsearch is excluded from - // both numerator (below) and denominator (here). - // 2. NEVER STALE. Every early-exit path in CommonSearchInit (hash - // cutoff, repetition draw, ply/depth limit, EGTB hit) returns - // TRUE *before* pi->eNodeType is assigned, and the caller's - // `goto end` lands squarely on the surprise counters -- which - // then scored the node against whatever type the PREVIOUS node - // at this ply happened to leave behind. That is what produced - // a "PV failed high/low = 106.2% of PV nodes" reading: more - // surprises than nodes of that type, which is impossible - // unless some were attributed to a type the node never had. - // Reaching this line means CommonSearchInit returned FALSE, - // i.e. the type really was assigned for THIS visit. - // - // NOTE: the NODE_TYPE enum and every behavioral consumer of it - // (all of which only ever test against NODE_PV) are untouched. - fNoteTypedThisCall = TRUE; - ASSERT((ULONG)pi->eNodeType < 3); - INC(ctx->sCounters.tree.u64NodeTypeCount[(ULONG)pi->eNodeType]); -#endif - DTEnterNode(ctx, uDepth, FALSE, iAlpha, iBeta); - iInitialAlpha = iAlpha; pi->eMoveScoringScheme = MOVE_SCORING_NORMAL; ASSERT((IS_CHECKING_MOVE(mvLast) && (TRUE == pi->fInCheck)) || (!IS_CHECKING_MOVE(mvLast) && (FALSE == pi->fInCheck))); @@ -1025,6 +987,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, fTryEFPHere = TRUE; } uStage++; + uRawDepthB = DepthBucket(uDepth / ONE_PLY); ASSERT(x == ctx->sMoveStack.uBegin[ctx->uPly]); // fall through @@ -1032,8 +995,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if (x < ctx->sMoveStack.uEnd[ctx->uPly]) { ASSERT(x >= ctx->sMoveStack.uBegin[ctx->uPly]); - - fIsLeftoverMove = FALSE; #ifdef PERF_COUNTERS // Reset every iteration, BEFORE the LMR decision // block (below) can set it for this move -- without @@ -1297,18 +1258,23 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uFHAttempts = 0; ULONG uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uFHAttempts); + // Our 4D prediction table, given: late, hang, + // remaining depth, and gap to alpha will give us a + // pessimistic FH% prediction for the move. Compute + // the buckets needed to look it up. Note: depth + // bucket is per-node and was already computed (once) + // above. + // The amount of material this move seems to hang. SCORE iHangCp = EstimateHangingMaterialCp(ctx, mv, (x - 1), bbMinorAttacksSnapshot, bbRookAttacksSnapshot); - - // Our 4D prediction table, given: late, hang, - // remaining depth, and gap to alpha will give us a - // pessimistic FH% prediction for the move. Compute - // the buckets needed to look it up. ULONG uRawDumbB = HangCpBucket(iHangCp); - ULONG uRawDepthB = DepthBucket(uDepth / ONE_PLY); + + // Gap from iEval to current iAlpha bucket. ULONG uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval)); + + // Late move number bucket. ULONG uRawLateB = MoveNumBucket(uLegalMoves); // Given the above signals, the (pessimistic) @@ -1340,12 +1306,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uFHAttempts, uFHPctThousandths, uPredictedFH); -#ifdef PERF_COUNTERS - if (TRUE == fThisMoveLMPPruned) - { - INC(ctx->sCounters.tree.u64LMPPruned); - } -#endif // Extended futility pruning (EFP); this is an idea // proposed by Ernst Heinz (Scalable Search in @@ -1376,10 +1336,10 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, (uFHPctThousandths <= EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS); fThisMoveEFPPruned = fThisMoveEFPPruned && - (uPredictedFH == 0xFFFF || uPredictedFH < 100); + (uPredictedFH == 0xFFFF || uPredictedFH < 200); #if 0 if (fThisMoveEFPPruned && - uPredictedFH != 0xFFFF && // JOINT_TABLE_SENTINEL -- fix me + uPredictedFH != 0xFFFF && uPredictedFH > 100) { // Which branch of EstimateHangingMaterialCp @@ -1414,7 +1374,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, default: szHangSource = "UNKNOWN eMoveScoringScheme"; break; } } - Trace("Board _AFTER_ the move (%s):\n", MoveToIcs(mv)); DumpPosition(pos); Trace("iEval = %d\n", iEval); @@ -1431,12 +1390,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, #endif } } -#ifdef PERF_COUNTERS - if (TRUE == fThisMoveEFPPruned) - { - INC(ctx->sCounters.tree.u64EFPPruned); - } -#endif // If neither LMP nor EFP fired, we are going to search below // this move. But if the move is very late, we might reduce @@ -1473,7 +1426,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if ((TRUE == fThisMoveEFPPruned) || (TRUE == fThisMoveLMPPruned)) { - fAnyMovePruned = TRUE; +#ifdef PERF_COUNTERS + if (fThisMoveEFPPruned) + { + INC(ctx->sCounters.tree.u64EFPPruned); + } + else if (fThisMoveLMPPruned) + { + INC(ctx->sCounters.tree.u64LMPPruned); + } +#endif UnmakeMove(ctx, mv); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); continue; @@ -2008,33 +1970,19 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if (iAlpha != iInitialAlpha) { ASSERT(mvBest.uMove != 0); - if (!IS_CAPTURE_OR_PROMOTION(mvBest)) - { - UpdateDynamicMoveOrdering(ctx, - uDepth, - mvBest, - iBestScore, - 0); - } - if (FALSE == fAnyMovePruned) - { - StoreExactScore(mvBest, - pos, - iBestScore, - uDepth, - fThreat, - ctx->uPly); - } - else - { - StoreLowerBound(mvBest, - pos, - iBestScore, - uDepth, - fThreat); - } + UpdateDynamicMoveOrdering(ctx, + uDepth, + mvBest, + iBestScore, + 0); + StoreExactScore(mvBest, + pos, + iBestScore, + uDepth, + fThreat, + ctx->uPly); } - else if (FALSE == fAnyMovePruned) + else { // IDEA: "I am very well aware of the fact, that the scores // you get back outside of the window, are not trustable at @@ -2066,54 +2014,6 @@ 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 - // fNodeTypedThisVisit: a node short-circuited by CommonSearchInit - // (hash cutoff, repetition draw, ply/depth limit, EGTB hit) returns - // here via `goto end` WITHOUT pi->eNodeType having been assigned for - // this visit, so scoring it would attribute the outcome to whatever - // type the previous node at this ply left behind. That is what made - // this line report "PV failed high/low = 106.2% of PV nodes" -- - // more surprises than nodes of that type. - if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore) && - (TRUE == fNoteTypedThisCall)) - { - // 2026-10-03: compare against iInitialAlpha, NOT iAlpha. - // search.c:2048 raises iAlpha IN PLACE as moves improve the - // score, so by the time we reach end: a node that SUCCESSFULLY - // raised alpha has iAlpha == iBestScore -- and the old - // (iBestScore <= iAlpha) test then read TRUE, scoring the - // ordinary success case as a "fail low". That is why the PV row - // reported 99.1% surprised: it was measuring "alpha was raised" - // rather than "the node-type prediction was wrong". - // iInitialAlpha is the node's alpha on entry (stashed at - // search.c:626 and already used for exactly this distinction at - // search.c:2105), which is what a fail-low must be judged - // against. - FLAG fFailedLow = (iBestScore <= iInitialAlpha); - 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 #ifdef TEST_NULL // Commit the calibration record stashed at the diagnostic probe // site above, now that iBestScore's fate against iBeta (this @@ -2973,15 +2873,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, #endif FLAG fIncludeChecks; FLAG fOrigStandPat = ctx->sSearchFlags.fCouldStandPat[pos->uToMove]; -#ifdef PERF_COUNTERS - // Entry alpha, for the qsearch node-type accounting at end:. QSearch - // raises iAlpha IN PLACE (the stand-pat branch and the move loop - // both do), so a fail-low test against the live iAlpha would score - // every successful alpha-raise as a fail-low -- the same defect that - // made Search()'s PV row read 99.1%. - SCORE iQEntryAlpha = iAlpha; - FLAG fQNodeTyped = FALSE; -#endif static ULONG _WhatToGen[] = { GENERATE_CAPTURES_PROMS, @@ -3006,13 +2897,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, { goto end; } -#ifdef PERF_COUNTERS - // Reaching here means CommonSearchInit returned FALSE, i.e. it - // really assigned pi->eNodeType for THIS visit. - fQNodeTyped = TRUE; - ASSERT((ULONG)pi->eNodeType < 3); - INC(ctx->sCounters.tree.u64QNodeTypeCount[(ULONG)pi->eNodeType]); -#endif DTEnterNode(ctx, 0, TRUE, iAlpha, iBeta); // Probe interior node recognizers. Qsearch runs at the deepest, @@ -3349,61 +3233,6 @@ 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 - // Qsearch half of the node-type accounting -- see chess.h's - // u64QNodeTypeCount comment for why this is counted separately - // rather than folded into the full-width counters (it used to be - // folded in, which made those rates mostly a measure of stand-pat - // frequency) and why dropping it entirely broke the CUT-FL/ALL-FH - // identity. - if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore) && - (TRUE == fQNodeTyped)) - { - FLAG fQLow = (iBestScore <= iQEntryAlpha); - FLAG fQHigh = (iBestScore >= iBeta); - - switch (pi->eNodeType) - { - case NODE_PV: - if (fQLow || fQHigh) - { - INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_PV]); - } - break; - case NODE_ALL: - if (fQHigh) - { - INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_ALL]); - } - break; - case NODE_CUT: - if (fQLow) - { - INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_CUT]); - } - break; - } - } -#endif -// 2026-10-03: QSearch's copy of the node-type surprise counters was -// MOVED to its own slots (above), not deleted. CommonSearchInit types qsearch nodes too (it is the one -// funnel both Search() and QSearch() pass through), and qsearch is ~84% -// of the tree -- so these counters were dominated by it. Specifically: a -// qsearch node whose stand-pat score already beats beta returns -// iBestScore >= iBeta immediately, and if that node happened to be typed -// ALL it was scored as "ALL failed high". A stand-pat cutoff is the most -// ordinary event in qsearch, not a node-type misprediction, which is why -// the line reported ~47% of all nodes as ALL-failed-high and looked -// absurd. The question the line's name asks is about FULL-WIDTH -// node-type prediction, so qsearch is now excluded from both the -// numerator (here) and the denominator (u64NodeTypeCount, incremented in -// Search() only). -// -// The NODE_TYPE enum itself is deliberately unchanged -- adding a -// NODE_QNODE value was considered and rejected, because eNodeType feeds -// eParentType chaining in CommonSearchInit and every behavioral consumer -// tests against NODE_PV; excluding qsearch from the instrumentation gets -// the same signal with zero risk to any of that. // Note: iBestScore can be +INFINITY_SCORE or -INFINITY_SCORE here even in the // absence of a legitimate mate detected if we disallowed stand |
