diff options
| author | Scott Gasch <[email protected]> | 2026-10-13 23:22:10 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-13 23:22:10 -0700 |
| commit | dad4620a92f51a1d0825fc99fa98fd2b16c0f280 (patch) | |
| tree | 865a0f210b6d3ad49779617ba73a3e04c02aad4e /src/search.c | |
| parent | 7400001fc2d43a507b54281f9860c60177860fe5 (diff) | |
Add alpha-side static null-move skip gate and Search() perf hoists
Reuses the joint FH% table on the iEval <= iAlpha side to skip the
null-move probe attempt when it's predicted unlikely to succeed
(EV-neutral: the real move loop still runs regardless). Also hoists
several node-constant computations (ImproveBucket, MP split-eligibility
checks) out of the per-move loop in Search(), and adds GNUmakefile
overrides for sweeping NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS and
SKIP_SELECT_AFTER_N_MOVES from the command line.
Self-play vs. head is trending positive overnight (1020 games,
llr=+1.36, score=0.527) but not yet conclusive -- committing now to
checkpoint the change while the match continues.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 110 |
1 files changed, 96 insertions, 14 deletions
diff --git a/src/search.c b/src/search.c index ae6ba01..0a32dcc 100644 --- a/src/search.c +++ b/src/search.c @@ -84,6 +84,21 @@ extern FLAG g_fCanSplit[MAX_PLY_PER_SEARCH]; #define EFP_FH_MIN_SAMPLES (5) #define EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS (10000) // 10% +// 2026-10-13: "skip trying null-move at all" on the iEval <= iAlpha +// side (alpha-side table, EV-neutral -- the real move loop still runs +// and finds the correct score either way; we only forgo the cheap +// null-move probe on the rare occasion it would have worked). Units +// are thousandths of a percent (same convention as everywhere else +// here). 300 is the validated default -- swept against both halves +// of ecm.ep_ at sd6/sd10, spot-checked at sd16, and against 1000 +// cross_sectional_1.ep_ positions; consistently in the plateau past +// the knee with no solve-count cost anywhere tested. Still overridable +// for further sweeping; 0 disables entirely. See +// NULLMOVE_SKIP_CALIBRATION.md for the original methodology. +#ifndef NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS +#define NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS (300) +#endif + // 2026-10-02: the joint-table-driven graduated-LMR decision itself // (bucket helpers, _JointTablePredictedFHThousandths/_JointTableExtraPlies, // JOINT_EXTRA_TIER*_THOUSANDTHS, the floor/cap clamp) moved into @@ -542,6 +557,17 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // definition gate-1 describes. FLAG fMoveIsPruneReduceEligible = FALSE; ULONG uRawDepthB; +#ifdef MP + // 2026-10-13: perf hoist -- every term here is fixed for this + // whole node (uDepth is a parameter, never reassigned anywhere in + // Search(); pi->eMoveScoringScheme is set once, before the move + // loop, and never touched again in our build profile -- DO_IID, + // the only other writer mid-node, isn't compiled in). Computed + // once below (PREPARE_TO_TRY_MOVES) instead of re-deriving the + // same division/array-lookup/subtraction on every single + // move-loop iteration's split-eligibility check. + FLAG fNodeSplitEligible; +#endif #ifdef MOVE_ORDERING_EVIDENCE_COLLECTION FLAG fThisMoveIsCountermoveMatch = FALSE; ULONG uCMEvidenceBucket = 0; @@ -730,6 +756,14 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { iImprovement = (iEval - ctx->sPlyInfo[ctx->uPly - 2].iEval); } + // 2026-10-13: perf hoist -- iImprovement is fixed for this whole + // node (set once above, never reassigned anywhere in the move + // loop), so its bucket is too. Was being recomputed inside the + // per-move pruning block for every eligible move; compute once + // here instead and reuse. Search() is the hottest function in the + // engine -- this repeated work was pure waste on a provably + // node-constant value. + ULONG uRawImproveB = ImproveBucket(iImprovement); // 2026-10-10: opponent's own eval-trend, for the beta-side static- // NM table below (g_uBetaFHThousandths) -- needs the move before // the mover's own, hence uPly - 1/uPly - 3 rather than iImprovement's @@ -857,7 +891,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, (iEval > iBeta)) { ULONG uStaticNMDepthB = DepthBucket(uDepth / ONE_PLY); - ULONG uRawImproveB = ImproveBucket(iImprovement); ULONG uRawEnemyImproveB = ImproveBucket(iEnemyImprovement); INC(ctx->sCounters.tree.u64QuickNullMoves); @@ -875,8 +908,47 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } } + // 2026-10-13: should we even bother trying null-move here? Opposite + // direction from the static-NM block above (iEval <= iAlpha, not + // iEval > iBeta) -- reuse the alpha/leftover-move table + // (g_uJointFHThousandths via _JointTablePredictedFHThousandths) as + // a proxy for "would a free tempo even reach beta here," using + // hang=0 ("nothing hung" -- a made-up placeholder, not a real + // per-move estimate; there is no move here, just the node itself) + // and late=1 ("early move" proxy, same reasoning). Computes its + // own depth bucket locally (same pattern as the static-NM block's + // uStaticNMDepthB above) -- the function-scope uRawDepthB isn't + // assigned until the move loop below, so reading it here would be + // reading an uninitialized value; a stale comment previously + // claimed otherwise. iImprovement is the same per-node value used + // everywhere else. Unlike the static-NM cutoff, this is EV-neutral: + // if wrong, the real move loop below still runs and finds the + // correct score -- we only forgo the cheap null-move probe on the + // rare occasion it would have succeeded. + FLAG fSkipNullForLowFH = FALSE; +#if (NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS > 0) + if (iEval <= iAlpha) + { + ULONG uNullSkipDepthB = DepthBucket(uDepth / ONE_PLY); + ULONG uRawGapAlphaB = GapCpBucket((SCORE)(iAlpha - iEval)); + USHORT uAlphaPredictedFH = _JointTablePredictedFHThousandths( + uNullSkipDepthB, + 0, // hang bucket: assume nothing hung + uRawGapAlphaB, + 1, // late bucket: "early move" proxy + uRawImproveB); + if ((uAlphaPredictedFH != 0xFFFF) && + (uAlphaPredictedFH < NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS)) + { + fSkipNullForLowFH = TRUE; + INC(ctx->sCounters.tree.u64NullMoveSkippedLowFH); + } + } +#endif + if (!fSkipNull && !fThreat && + (FALSE == fSkipNullForLowFH) && WeShouldTryNullmovePruning(ctx, iAlpha, iBeta, @@ -1038,6 +1110,18 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } uStage++; uRawDepthB = DepthBucket(uDepth / ONE_PLY); +#ifdef MP + // See fNodeSplitEligible's declaration comment -- same + // four conditions the move loop used to re-check on + // every iteration (uDepth>=ONE_PLY guards the array + // index exactly as before: g_fCanSplit[] is never + // indexed when it's false, short-circuit preserved). + fNodeSplitEligible = + (MOVE_SCORING_IID_RESCORED != pi->eMoveScoringScheme) && + (uDepth >= ONE_PLY) && + (TRUE == g_fCanSplit[uDepth / ONE_PLY - 1]) && + (MOVE_COUNT(ctx, ctx->uPly) > 4); +#endif ASSERT(x == ctx->sMoveStack.uBegin[ctx->uPly]); // fall through @@ -1143,19 +1227,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(SanityCheckMove(pos, mv)); #ifdef MP - // Can we search the remaining moves in parallel? Note: - // uDepth can legitimately be < ONE_PLY here (fractional - // depth from a reduction) -- uDepth/ONE_PLY - 1 would - // underflow (ULONG) in that case, which is exactly why the - // uDepth >= ONE_PLY check below short-circuits before the - // g_fCanSplit[] indexing ever evaluates it. + // Can we search the remaining moves in parallel? fNodeSplitEligible + // (hoisted above, PREPARE_TO_TRY_MOVES) folds the uDepth>=ONE_PLY / + // g_fCanSplit[] / MOVE_COUNT / eMoveScoringScheme checks -- all + // node-constant, see its own declaration comment. Only + // uLegalMoves/fIsLeftoverMove (genuinely per-move) and + // g_uNumHelpersAvailable (genuinely dynamic, mutated by other + // threads) still need a fresh check every iteration. if (((uLegalMoves >= 2) && fIsLeftoverMove) && (0 != g_uNumHelpersAvailable) && - (MOVE_SCORING_IID_RESCORED != pi->eMoveScoringScheme) && - (FALSE == fTryEFPHere) && - (uDepth >= ONE_PLY) && - (TRUE == g_fCanSplit[uDepth / ONE_PLY - 1]) && - (MOVE_COUNT(ctx, ctx->uPly) > 4)) + fNodeSplitEligible) { ASSERT(pf->fAvoidNullmove == FALSE); ASSERT(uStage == TRY_GENERATED_MOVES); @@ -1337,8 +1418,9 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // meaning "I don't know". This happens when there's // not enough data (never, anymore) or when the // buckets are noisy (e.g. "dumb" bucket 1 is crazy, - // no signal). - ULONG uRawImproveB = ImproveBucket(iImprovement); + // no signal). uRawImproveB is node-level, hoisted + // above (not recomputed here -- see its declaration + // comment). ULONG uPredictedFH = _JointTablePredictedFHThousandths( uRawDepthB, uRawDumbB, uRawGapB, uRawLateB, uRawImproveB); |
