diff options
| author | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
| commit | 8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch) | |
| tree | 7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/root.c | |
| parent | 9d1e32db698855b939c4bc5df6b6a82509dd381c (diff) | |
Fix ONE_LEGAL_MOVE hash-move-shrink bug, tighten extension taper, add
a scoped check-evasion qsearch futility prune, and fix a pre-existing
PV-corruption crash.
- chess.h/generate.c: ONE_LEGAL_MOVE now reads a uTrueEscapeCount
snapshot taken before _ScoreAllEscapes excludes an already-tried
hash move, instead of the live post-exclusion MOVE_COUNT -- the old
version misreported "one legal reply to check" whenever the hash
move happened to be one of exactly two legal replies.
- root.c: g_uSoftExtendLimit/g_uHardExtendLimit taper onset moved from
2x/4x nominal search depth to 1.5x/3x (uniform 0.75x scaling of the
same band shape) -- a forcing chain of check/singular-reply
extensions could previously run fully unthrottled out to double the
nominal depth before any taper at all kicked in.
- search.c: QSearchFromCheckNoStandPat gains a scoped futility prune
for check-evasion replies, active only when this side already had a
stand-pat opportunity earlier in the line (fCouldStandPat). Only
applies to non-checking moves, uses the same calibrated
FUTILITY_BASE_MARGIN_BY_SOURCE table normal QSearch already trusts
(not a separate, unvalidated margin), computes its Eval() call
lazily (only once a non-checking candidate is actually reached, not
unconditionally per node), and never fires until at least one legal
move has already been searched (uLegalMoves > 0), so it can never
discard every reply to a check.
- search.c/searchsup.c: split CommonSearchInit's "too deep" bailout
into two separately-counted triggers (the rare MAX_PLY_PER_SEARCH-1
array-safety ceiling vs. the much more frequently-hit
g_uHardExtendLimit, now reachable sooner given the tightened taper
above) and surfaced both counts in root.c's per-search report.
- searchsup.c: UpdatePV no longer copies a child ply's stale PV tail
forward after writing a sentinel move (HASHMOVE/RECOGNMOVE/
DRAWMOVE/MATEMOVE) into the current ply's slot -- sPlyInfo[] is
reused across unrelated nodes at the same ply, so an earlier,
unrelated node's leftover sentinel in that slot could reproduce the
same sentinel value twice in a row and trip WalkPV's terminator
assert (util.c:1093), reproducing under MP via debug_smoke_test.sh.
Pre-existing, confirmed on unmodified HEAD; not introduced by any of
the above.
- eval_tune/match_play.py: EngineStats now tracks running average
search depth (already-parsed per-move data, just never surfaced)
and prints it alongside nps/ebf in the periodic status line, not
just the final summary. Added a ^C handler that dumps the same
CANDIDATE_SCORE/CANDIDATE_STATS/HEAD_STATS summary from whatever
games completed so far, then exits immediately rather than waiting
for the ThreadPoolExecutor's default wait=True teardown to let
in-flight games finish naturally.
Validated: precommit_check.sh (self-test + DEBUG smoke test) passing
throughout; self-play gauntlet (match_play.py --st 1) trending net
positive against HEAD through 1000 games.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TBtwX9aPrvwiM3gh4VnnZi
Diffstat (limited to 'src/root.c')
| -rw-r--r-- | src/root.c | 214 |
1 files changed, 143 insertions, 71 deletions
@@ -460,19 +460,9 @@ Return value: ASSERT(d); n = (double)(ctx->sCounters.pawnhash.u64Hits); Trace("Pawn hash hitrate: %5.3f percent.\n", (n/d) * 100.0); - // EXPERIMENTAL, measurement-only -- see eval.c's Eval() / - // rgKingSafetyHashProbe. - d = (double)(ctx->sCounters.kingsafetyhash.u64Probes) + 1; - ASSERT(d); - n = (double)(ctx->sCounters.kingsafetyhash.u64Hits); - Trace("[EXPERIMENTAL] King safety hash: %5.3f%% hitrate, " - "avg %.1f cyc hit / %.1f cyc miss (%" COMPILER_LONGLONG_UNSIGNED_FORMAT - " probes)\n", - (n/d) * 100.0, - (n > 0 ? (double)ctx->sCounters.kingsafetyhash.u64CyclesHit / n : 0.0), - ((d - 1 - n) > 0 ? - (double)ctx->sCounters.kingsafetyhash.u64CyclesMiss / (d - 1 - n) : 0.0), - ctx->sCounters.kingsafetyhash.u64Probes); + // [EXPERIMENTAL] King safety hash hit/miss cycle detail is printed + // below, next to the pawn hash cycle breakdown (EVAL_TIME section), + // rather than here. n = (double)(ctx->sCounters.tree.u64NullMoveSuccess); d = (double)(ctx->sCounters.tree.u64NullMoves) + 1; ASSERT(d); @@ -564,35 +554,47 @@ Return value: double dFull = (double)ctx->sCounters.tree.u64FullEvals; double dRealTotal = dSuperLazy + dLazy + dFull; d = dRealTotal + 1; - Trace("Eval exit breakdown: (%5.2f%% super lazy, %5.2f%% lazy, " - "%5.2f%% full)\n", - (dSuperLazy / d) * 100.0, - (dLazy / d) * 100.0, - (dFull / d) * 100.0); #ifdef EVAL_TIME { UINT64 u64SLCyc = ctx->sCounters.tree.u64CyclesSuperLazyExit; UINT64 u64LCyc = ctx->sCounters.tree.u64CyclesLazyExit; UINT64 u64FCyc = ctx->sCounters.tree.u64CyclesFullEvalExit; UINT64 u64AllCyc = ctx->sCounters.tree.u64CyclesInEval; + // Eval exit breakdown (call-count share) folded in here + // alongside the cycle-cost share, so both facets of "how + // much does each exit path matter" live in one place + // instead of two separate, easy-to-miss Trace blocks. Trace("Avg. cpu cycles in eval, by exit path:\n" - " super lazy: %8.1f (%5.1f%% of total eval cycles)\n" - " lazy: %8.1f (%5.1f%% of total eval cycles)\n" - " full: %8.1f (%5.1f%% of total eval cycles)\n" - " overall: %8.1f\n", + " super lazy: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n" + " lazy: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n" + " full: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n" + " overall: %8.1f cyc/call\n", (dSuperLazy ? (double)u64SLCyc / dSuperLazy : 0.0), + (dSuperLazy / d) * 100.0, (u64AllCyc ? 100.0 * (double)u64SLCyc / (double)u64AllCyc : 0.0), (dLazy ? (double)u64LCyc / dLazy : 0.0), + (dLazy / d) * 100.0, (u64AllCyc ? 100.0 * (double)u64LCyc / (double)u64AllCyc : 0.0), (dFull ? (double)u64FCyc / dFull : 0.0), + (dFull / d) * 100.0, (u64AllCyc ? 100.0 * (double)u64FCyc / (double)u64AllCyc : 0.0), (dRealTotal ? (double)u64AllCyc / dRealTotal : 0.0)); } +#else + Trace("Eval exit breakdown: (%5.2f%% super lazy, %5.2f%% lazy, " + "%5.2f%% full)\n", + (dSuperLazy / d) * 100.0, + (dLazy / d) * 100.0, + (dFull / d) * 100.0); #endif } #endif Trace("Extensions: (%u +, %u q+, %u 1mv, %u !kmvs, %u mult+, %u pawn\n" - " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n", + " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n" + " %u / %llu qsearch nodes (%.1f%%) were in-check " + "evasion nodes\n" + " too-deep bailout: %llu ply-limit, %llu " + "qsearch-hard-extend-limit\n", ctx->sCounters.extension.uCheck, ctx->sCounters.extension.uQExtend, ctx->sCounters.extension.uOneLegalMove, @@ -604,7 +606,14 @@ Return value: ctx->sCounters.extension.uSingularReplyToCheck, ctx->sCounters.extension.uEndgame, ctx->sCounters.extension.uBotvinnikMarkoff, - ctx->sCounters.extension.uRecapture); + ctx->sCounters.extension.uRecapture, + ctx->sCounters.extension.uQExtendChaseNodes, + ctx->sCounters.tree.u64QNodeCount, + (ctx->sCounters.tree.u64QNodeCount ? + (100.0 * ctx->sCounters.extension.uQExtendChaseNodes / + (double)ctx->sCounters.tree.u64QNodeCount) : 0.0), + ctx->sCounters.tree.u64TooDeepPlyLimit, + ctx->sCounters.tree.u64TooDeepQsearchExtendLimit); #ifdef EVAL_TIME { // @@ -626,64 +635,102 @@ Return value: // // u64PreLazy already contains u64Pawns as a subset (pawns // runs nested inside the pre-lazy segment, not after it) -- - // split it out here rather than double-counting both. + // it's used below only for u64Named's total, not displayed + // directly (its sub-segments are each measured and displayed + // on their own: super-lazy, material setup, passer/badtrade/ + // bishop, pawns, lazy decision). // UINT64 u64PreLazy = ctx->sCounters.tree.u64CyclesEvalPreLazy; - UINT64 u64PreLazyOther = (u64PreLazy >= u64Pawns) ? - (u64PreLazy - u64Pawns) : 0; UINT64 u64PostMisc = ctx->sCounters.tree.u64CyclesEvalPostLazyMisc; UINT64 u64AttackPop = ctx->sCounters.tree.u64CyclesEvalAttackTablePop; + UINT64 u64Drawish = ctx->sCounters.tree.u64CyclesEvalDrawish; UINT64 u64Named = (u64PreLazy + u64Knight + u64Bishop + u64Rook + u64Queen + u64King + u64PostMisc + - u64AttackPop); + u64AttackPop + u64Drawish); UINT64 u64Total = ctx->sCounters.tree.u64CyclesInEval; UINT64 u64Unaccounted = (u64Total >= u64Named) ? (u64Total - u64Named) : 0; + UINT64 u64LazyDecision = ctx->sCounters.tree.u64CyclesEvalLazyDecision; + UINT64 u64CKSD = ctx->sCounters.tree.u64CyclesEvalCountKingSafetyDefects; + UINT64 u64SuperLazy = ctx->sCounters.tree.u64CyclesEvalSuperLazy; + UINT64 u64MaterialSetup = ctx->sCounters.tree.u64CyclesEvalMaterialSetup; + UINT64 u64PasserBadtradeBishop = + ctx->sCounters.tree.u64CyclesEvalPasserBadtradeBishop; - Trace(" eval breakdown (%% of total cycles in eval):\n"); - Trace(" pre-lazy (always paid): %5.1f%% " - "(of which pawns: %5.1f%%, other pre-lazy: %5.1f%%)\n", - (u64Total ? (100.0 * (double)u64PreLazy / (double)u64Total) : 0.0), - (u64Total ? (100.0 * (double)u64Pawns / (double)u64Total) : 0.0), - (u64Total ? (100.0 * (double)u64PreLazyOther / (double)u64Total) : 0.0)); + // + // Reorganized (2026-09-09) into one major section per exit + // path, each listing its own segments in the chronological + // order Eval() actually executes them, since a flat dump of + // every segment side by side no longer maps to which exit + // path pays for what -- a super-lazy exit call never reaches + // pawns/pieces at all, and a lazy exit call never reaches the + // per-piece/attack-table segments. Percentages are still each + // segment's share of u64Total (total cycles across every + // Eval() call of any exit path), not renormalized per + // section, since these segments' own counters are accumulated + // across all calls that reach them (not split out per exit + // path) -- e.g. "pawns" below is paid by both lazy-exit and + // full-eval calls combined. + // + Trace("eval breakdown (%% of total cycles in eval), " + "by exit-path phase, chronological within each:\n"); + Trace(" [super-lazy] (paid by every call)\n"); + Trace(" material/army-scaler setup: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64MaterialSetup / (double)u64Total) : 0.0)); + Trace(" super-lazy material check: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64SuperLazy / (double)u64Total) : 0.0)); + Trace(" [lazy+] (paid by lazy-exit and full-eval calls)\n"); + Trace(" pawns: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64Pawns / (double)u64Total) : 0.0)); + Trace(" passer-races/bad-trades/bishop-pairs: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64PasserBadtradeBishop / (double)u64Total) : 0.0)); + Trace(" lazy gate + EstimatePositionalScore: %5.1f%% " + "(of which CountKingSafetyDefects: %5.1f%%)\n", + (u64Total ? (100.0 * (double)u64LazyDecision / (double)u64Total) : 0.0), + (u64Total ? (100.0 * (double)u64CKSD / (double)u64Total) : 0.0)); + Trace(" [full-only] (paid only by full-eval calls)\n"); + Trace(" attack-table population: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64AttackPop / (double)u64Total) : 0.0)); Trace(" knight: %5.1f%% bishop: %5.1f%% rook: %5.1f%%\n", (u64Total ? (100.0 * (double)u64Knight / (double)u64Total) : 0.0), (u64Total ? (100.0 * (double)u64Bishop / (double)u64Total) : 0.0), (u64Total ? (100.0 * (double)u64Rook / (double)u64Total) : 0.0)); - Trace(" queen: %5.1f%% king: %5.1f%% post-lazy misc: %5.1f%%\n", + Trace(" queen: %5.1f%% king: %5.1f%%\n", (u64Total ? (100.0 * (double)u64Queen / (double)u64Total) : 0.0), - (u64Total ? (100.0 * (double)u64King / (double)u64Total) : 0.0), + (u64Total ? (100.0 * (double)u64King / (double)u64Total) : 0.0)); + Trace(" post-lazy misc (passers/danger/trapped/scalers): %5.1f%%\n", (u64Total ? (100.0 * (double)u64PostMisc / (double)u64Total) : 0.0)); - Trace(" attack-table pop: %5.1f%% unaccounted: %5.1f%%\n", - (u64Total ? (100.0 * (double)u64AttackPop / (double)u64Total) : 0.0), + Trace(" drawish-situation scaling: %5.1f%%\n", + (u64Total ? (100.0 * (double)u64Drawish / (double)u64Total) : 0.0)); + // "Unaccounted" here is mostly EVAL_TIME's own instrumentation + // overhead, not missing eval work: each per-piece + // TIMED_EVAL_CALL (knight/bishop/rook/queen/king) reads the + // cycle counter twice, and on a full board that's 2 reads x + // up to ~16 pieces per Eval() call. A one-off diagnostic + // (2026-09-09, since reverted) wrapped the whole piece-eval + // section in a single outer timer and compared it against the + // sum of the per-piece buckets -- the difference (i.e. the + // extra reads' own cost, not algorithmic eval time) accounted + // for the large majority of this percentage on a full board; + // true untimed code (bit-scan loop control, small setup/tail + // arithmetic) was a small remainder. Re-run that diagnostic + // (wrap the piece-eval loops end to end in one blanket timer, + // diff against the named per-piece sum) before assuming a + // change to this number reflects a real difference in eval + // cost. + Trace(" unaccounted: %5.1f%%\n", (u64Total ? (100.0 * (double)u64Unaccounted / (double)u64Total) : 0.0)); { - UINT64 u64LazyDecision = ctx->sCounters.tree.u64CyclesEvalLazyDecision; - UINT64 u64CKSD = ctx->sCounters.tree.u64CyclesEvalCountKingSafetyDefects; - UINT64 u64Storm = ctx->sCounters.tree.u64CyclesEvalFileStormDefects; - UINT64 u64SuperLazy = ctx->sCounters.tree.u64CyclesEvalSuperLazy; - UINT64 u64PreLazyRest = (u64PreLazyOther >= u64LazyDecision) ? - (u64PreLazyOther - u64LazyDecision) : 0; - u64PreLazyRest = (u64PreLazyRest >= u64SuperLazy) ? - (u64PreLazyRest - u64SuperLazy) : 0; - Trace(" -- of which, pre-lazy breakdown --\n"); - Trace(" super lazy check: %5.1f%%\n", - (u64Total ? (100.0 * (double)u64SuperLazy / (double)u64Total) : 0.0)); - Trace(" material/passers/badtrades/bishoppairs: %5.1f%%\n", - (u64Total ? (100.0 * (double)u64PreLazyRest / (double)u64Total) : 0.0)); - Trace(" lazy gate + EstimatePositionalScore: %5.1f%% " - "(of which CountKingSafetyDefects: %5.1f%%, " - "_GetFileStormDefects: %5.1f%%)\n", - (u64Total ? (100.0 * (double)u64LazyDecision / (double)u64Total) : 0.0), - (u64Total ? (100.0 * (double)u64CKSD / (double)u64Total) : 0.0), - (u64Total ? (100.0 * (double)u64Storm / (double)u64Total) : 0.0)); - } - { UINT64 u64PHits = ctx->sCounters.tree.u64CountEvalPawnsHit; UINT64 u64PMisses = ctx->sCounters.tree.u64CountEvalPawnsMiss; UINT64 u64PHitCycles = ctx->sCounters.tree.u64CyclesEvalPawnsHit; UINT64 u64PMissCycles = ctx->sCounters.tree.u64CyclesEvalPawnsMiss; - Trace(" pawn hash: %" COMPILER_LONGLONG_UNSIGNED_FORMAT + UINT64 u64KSProbes = ctx->sCounters.kingsafetyhash.u64Probes; + UINT64 u64KSHits = ctx->sCounters.kingsafetyhash.u64Hits; + UINT64 u64KSMisses = (u64KSProbes >= u64KSHits) ? + (u64KSProbes - u64KSHits) : 0; + Trace("hash probes (avg cycles per probe):\n"); + Trace(" pawn hash: %" COMPILER_LONGLONG_UNSIGNED_FORMAT " hits (avg %.1f cyc), %" COMPILER_LONGLONG_UNSIGNED_FORMAT " misses (avg %.1f cyc), hit rate %.2f%%\n", u64PHits, @@ -693,6 +740,22 @@ Return value: ((u64PHits + u64PMisses) ? (100.0 * (double)u64PHits / (double)(u64PHits + u64PMisses)) : 0.0)); + // Keeping this counter around: see eval.c's Eval() / + // rgKingSafetyHashProbe. + Trace(" king safety hash: %" + COMPILER_LONGLONG_UNSIGNED_FORMAT + " hits (avg %.1f cyc), %" + COMPILER_LONGLONG_UNSIGNED_FORMAT + " misses (avg %.1f cyc), hit rate %.2f%%\n", + u64KSHits, + (u64KSHits ? (double)ctx->sCounters.kingsafetyhash.u64CyclesHit / + (double)u64KSHits : 0.0), + u64KSMisses, + (u64KSMisses ? (double)ctx->sCounters.kingsafetyhash.u64CyclesMiss / + (double)u64KSMisses : 0.0), + (u64KSProbes ? + (100.0 * (double)u64KSHits / (double)u64KSProbes) : + 0.0)); } } #endif @@ -1083,14 +1146,23 @@ _IterateSetSearchGlobals(ULONG uDepth) // For use in scaling back extensions that are very far from // the root. // - // Iterate g_Soft g_Hard - // | | | | | | | | | - // 0 1 2 2.5 3 3.5 4 (x Iterate) - // |-------------------|----|----|---------|----... - // |full full full full|-1/4|-1/2|-3/4 -3/4|none... + // 2026-09-10: onset moved in from 2x/4x iterate depth to 1.5x/3x + // (a uniform 0.75x scaling of the old 2x..4x span) -- a forcing + // chain of check/singular-reply extensions could previously run + // completely unthrottled out to double the nominal search depth + // before any taper at all kicked in, which is exactly the shape + // of runaway seen on check-heavy positions (see CLAUDE.md's + // 2026-09 extension-tuning notes). Same taper shape, just starts + // sooner. + // + // Iterate g_Soft g_Hard + // | | | | | | | | + // 0 1 1.5 1.875 2.25 3 (x Iterate) + // |----------------|-----|-----|-------|----... + // |full full full |-1/4 |-1/2 |-3/4 |none... // - g_uSoftExtendLimit = g_uIterateDepth * 2; - g_uHardExtendLimit = g_uIterateDepth * 4; + g_uSoftExtendLimit = g_uIterateDepth * 3 / 2; + g_uHardExtendLimit = g_uIterateDepth * 3; // g_uExtensionReduction[]'s own bands, below, must stay reachable // within [0, MAX_PLY_PER_SEARCH) regardless of how deep g_uIterateDepth @@ -1106,19 +1178,19 @@ _IterateSetSearchGlobals(ULONG uDepth) // it would be an unrelated, unintended behavior change. { ULONG uEffIterateDepth = MIN(g_uIterateDepth, (MAX_PLY_PER_SEARCH - 4) / 4); - ULONG uEffSoftLimit = uEffIterateDepth * 2; - ULONG uEffHardLimit = uEffIterateDepth * 4; + ULONG uEffSoftLimit = uEffIterateDepth * 3 / 2; + ULONG uEffHardLimit = uEffIterateDepth * 3; for (u = 0; u < MAX_PLY_PER_SEARCH; u++) { if (u < uEffSoftLimit) { g_uExtensionReduction[u] = 0; } - else if (u < (uEffSoftLimit + uEffIterateDepth / 2)) + else if (u < (uEffSoftLimit + (uEffIterateDepth * 3 / 8))) { g_uExtensionReduction[u] = QUARTER_PLY; } - else if (u < (uEffSoftLimit + uEffIterateDepth)) + else if (u < (uEffSoftLimit + (uEffIterateDepth * 3 / 4))) { g_uExtensionReduction[u] = HALF_PLY; } |
