summaryrefslogtreecommitdiff
path: root/src/root.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
committerScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
commit8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch)
tree7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/root.c
parent9d1e32db698855b939c4bc5df6b6a82509dd381c (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.c214
1 files changed, 143 insertions, 71 deletions
diff --git a/src/root.c b/src/root.c
index 5b51009..d5780e4 100644
--- a/src/root.c
+++ b/src/root.c
@@ -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;
}