summaryrefslogtreecommitdiff
path: root/src/search.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-13 23:22:10 -0700
committerScott Gasch <[email protected]>2026-10-13 23:22:10 -0700
commitdad4620a92f51a1d0825fc99fa98fd2b16c0f280 (patch)
tree865a0f210b6d3ad49779617ba73a3e04c02aad4e /src/search.c
parent7400001fc2d43a507b54281f9860c60177860fe5 (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.c110
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);