summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/GNUmakefile16
-rwxr-xr-xsrc/chess.h16
-rw-r--r--src/move.c2
-rw-r--r--src/movesup.c26
-rw-r--r--src/search.c110
-rw-r--r--src/searchsup.c85
6 files changed, 217 insertions, 38 deletions
diff --git a/src/GNUmakefile b/src/GNUmakefile
index 5879d31..2cd1462 100644
--- a/src/GNUmakefile
+++ b/src/GNUmakefile
@@ -292,6 +292,22 @@ ifdef PRUNE_LIVE
PROFILE += -DPRUNE_LIVE=$(PRUNE_LIVE)
endif
+# 2026-10-13: override search.c's NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS
+# default (300, validated -- see search.c's own comment) from the
+# command line for sweeping, e.g. `gmake ... NULLMOVE_SKIP_CEILING=1000`
+# -- search.c's #define is guarded with #ifndef so this wins without
+# editing the source.
+ifdef NULLMOVE_SKIP_CEILING
+PROFILE += -DNULLMOVE_SKIP_FH_CEILING_THOUSANDTHS=$(NULLMOVE_SKIP_CEILING)
+endif
+
+# 2026-10-13: override movesup.c's SelectBestWithHistory skip-
+# selection-sort threshold for sweeping, e.g.
+# `gmake ... SKIP_SELECT_N=6`.
+ifdef SKIP_SELECT_N
+PROFILE += -DSKIP_SELECT_AFTER_N_MOVES=$(SKIP_SELECT_N)
+endif
+
ifdef NEUTRALIZE_NULL_BAILOUT
PROFILE += -DNEUTRALIZE_NULL_BAILOUT
endif
diff --git a/src/chess.h b/src/chess.h
index 91c6a0c..94c9db4 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -1353,9 +1353,22 @@ typedef struct _COUNTERS
// 2026-10-10: static (score-only, no verification search)
// null-move cutoff, gated on iEval > iBeta and the beta-side
// FH% table (g_uBetaFHThousandths) predicting a cutoff with
- // >95% confidence -- see search.c's fTryBetaPruningHere block.
+ // >95% confidence -- see search.c's static-NM block.
UINT64 u64QuickNullMoves;
UINT64 u64QuickNullMoveSuccess;
+
+ // 2026-10-13: opposite-direction (iEval <= iAlpha) check --
+ // reuses the alpha/leftover-move table (g_uJointFHThousandths)
+ // as a proxy for "would a free tempo even reach beta here," to
+ // decide whether trying the real null-move probe is worth its
+ // own cost. EV-neutral: the real move loop always still runs
+ // and finds the correct score regardless -- this only skips
+ // the probe attempt on the (hopefully rare) occasion it would
+ // have succeeded. No companion "success" counter for the same
+ // reason u64NullMoveSkippedLowFH never had one before: skipping
+ // is never wrong in the score sense, only in the opportunity-
+ // cost sense, which isn't separately observable here.
+ UINT64 u64NullMoveSkippedLowFH;
#ifdef TEST_NULL
// Precision/recall calibration for the null-move try/avoid
// decision. Indexed by _NullCalibBucketIndex() (search.c).
@@ -1687,6 +1700,7 @@ typedef struct _PLY_INFO
SCORE iBeta;
#endif
SCORE iEval;
+
// 2026-09-17: per-color iPositional from the SAME Eval() call that
// set iEval above -- previously discarded (Search() called Eval()
// with piPositional==NULL). Stored per-ply, right next to iEval,
diff --git a/src/move.c b/src/move.c
index 23d7fdd..5be13f0 100644
--- a/src/move.c
+++ b/src/move.c
@@ -927,7 +927,7 @@ Return value:
ctx->sPlyInfo[ctx->uPly].mv = mv;
ctx->uPly++;
ASSERT(ctx->uPly < MAX_PLY_PER_SEARCH);
-
+
//
// Make sure the move doesn't leave his own king in check.
//
diff --git a/src/movesup.c b/src/movesup.c
index d3d2a74..4b86947 100644
--- a/src/movesup.c
+++ b/src/movesup.c
@@ -23,6 +23,18 @@ Revision History:
#include "chess.h"
+// 2026-10-13: SelectBestWithHistory's skip-selection-sort threshold --
+// see its own comment. 6 is the chosen default: swept 2,4,6,8,12,20
+// against both ECM halves at sd10 (noisy, non-monotonic, but 6/8/12
+// all looked solid) and against 1000 cross_sectional_2.ep_ positions
+// (cleaner signal at this larger, non-tactical sample -- 6/8/12 all
+// real small wins on both nps and node count; 10 was consistently the
+// worst of the four across both samples). Still overridable for
+// further sweeping.
+#ifndef SKIP_SELECT_AFTER_N_MOVES
+#define SKIP_SELECT_AFTER_N_MOVES (6)
+#endif
+
// Unlike GetAttacks (a real asm symbol in a different file, so
// chess.h's macro-swap never touches its own definition),
// ExposesCheck/FasterExposesCheck/ExposesCheckEp's mailbox
@@ -1430,6 +1442,20 @@ Return value:
ASSERT(u >= ctx->sMoveStack.uBegin[ctx->uPly]);
ASSERT(u < uEnd);
+ // 2026-10-13: sweep knob -- once we've already tried this many
+ // moves at a node the cheap eNodeType guess calls ALL (expected
+ // to search everything anyway), skip the scan/swap below entirely
+ // and just take whatever's at the front in generation order. #5
+ // hottest function in the engine by cycle share; this is the
+ // whole point of the experiment -- skip the O(remaining) scan,
+ // not shrink it. 0xFFFFFFFF (effectively disabled) is the default
+ // so baseline behavior is unchanged unless swept.
+ if ((NODE_ALL == ctx->sPlyInfo[ctx->uPly].eNodeType) &&
+ ((u - ctx->sMoveStack.uBegin[ctx->uPly]) >= SKIP_SELECT_AFTER_N_MOVES))
+ {
+ return;
+ }
+
// Flee-to-safety: a small, same-tier selection-time nudge (not a
// GOOD_MOVE promotion -- that class was retired, see generate.c's
// RETIRED comment) for a quiet move whose origin square is
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);
diff --git a/src/searchsup.c b/src/searchsup.c
index 66851a5..53e7a85 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -503,6 +503,7 @@ GapBetaBucket(SCORE iGapBeta)
return MINU(u, GAP_BETA_BUCKETS - 1);
}
+
// See chess.h's MOVE_NUM_BUCKETS comment. uMoveNum is 1-based (the
// caller passes uLegalMoves, right after MakeMove succeeded) -- index
// 0 is never produced.
@@ -1040,10 +1041,24 @@ Return value:
return(iLocalExtend);
}
-#define LMP_BASE_MOVENUM (8) // never prune before this many
- // legal moves already tried
-#define LMP_PER_PLY_MOVENUM (1) // + this many moves of extra
- // slack per remaining ply
+// 2026-10-13: LMP_BASE_MOVENUM/LMP_PER_PLY_MOVENUM/LMP_MAX_REMAINING_DEPTH
+// are NOT wired into ShouldLatePrune below -- there is no move-number
+// floor and no depth ceiling in this function today, despite this
+// comment block and the docstring below once describing four checks.
+// uLegalMoves is still a parameter (kept for signature/call-site
+// stability and because MoveNumBucket already folds move number into
+// uPredictedFH upstream, in search.c, before this function ever sees
+// it) but it is unread here. LMP_MAX_REMAINING_DEPTH never existed as
+// code, only as a comment; LMP_BASE_MOVENUM/LMP_PER_PLY_MOVENUM are
+// defined and then never referenced -- dead macros, not a disabled
+// feature flag. Left defined rather than deleted only because a
+// future re-introduction of an explicit floor/ceiling (e.g. if the
+// table's move-number axis turns out too coarse near the root once
+// EFP is folded into this function -- see the EFP/LMP merge
+// discussion) would want exactly these names back. If that doesn't
+// happen, delete them.
+#define LMP_BASE_MOVENUM (8) // currently unused, see above
+#define LMP_PER_PLY_MOVENUM (1) // currently unused, see above
#define LMP_DYNAMIC_MIN_SAMPLES (1) // well-evidenced, not a guess
#define LMP_DYNAMIC_MAX_PCT_THOUSANDTHS (1)
@@ -1068,33 +1083,59 @@ Routine description:
rather than recomputing them -- search.c's move loop already looks
them up once per move for LMP/LMR/EFP to share.
- Four remaining checks, any one of which refuses to prune:
- 1. Depth ceiling (LMP_MAX_REMAINING_DEPTH) -- see the KNOWN ISSUE
- comment above this function: backwards relative to our own
- joint-table evidence, kept as a placeholder pending a per-cell
- sample-count re-bake, not a validated bound.
- 2. Move-number floor (LMP_BASE_MOVENUM + per-ply slack) -- don't
- gamble this early in the move list regardless of what either
- FH signal says.
- 3. Dynamic signal: any real, nonzero observed fail-high history
- on this exact move identity.
- 4. Static signal: the joint table's own prediction for this
- move's situation, outside the strictest (safest) tier, or no
- data at all (sentinel) -- unlike GetLMRReduction's more
- permissive treatment of a sentinel for mere reduction, overt
- pruning requires real, favorable data.
+ 2026-10-13: corrected from an earlier version of this comment that
+ claimed four checks including a move-number floor and a depth
+ ceiling -- neither exists in the body below (see the dead-macro
+ note above). As actually implemented, there are only two checks,
+ either of which refuses to prune:
+ 1. Dynamic signal: real, well-evidenced fail-high history on
+ this exact move identity that isn't essentially zero -- the
+ ultra-safe tail, not "probably fine."
+ 2. Static signal: the joint table's own prediction
+ (uPredictedFH) for this move's (depth, hang, gap, late,
+ improve) situation, checked against a per-remaining-depth
+ threshold (uLMPStaticMaxPctThousandths[]) that tightens as
+ remaining depth grows -- unlike GetLMRReduction's more
+ permissive treatment of missing data for mere reduction,
+ overt pruning requires a favorable prediction.
+ There is accordingly no explicit guard today against pruning the
+ 8th move at the root the instant the table happens to look
+ favorable, nor any floor stopping this from firing arbitrarily
+ deep in the tree -- both are purely emergent from
+ uLMPStaticMaxPctThousandths[]'s own shape (very permissive near
+ remaining depth 0, essentially 0 by remaining depth 10) rather
+ than from any dedicated gate. If that turns out to be too loose in
+ practice (e.g. once EFP's margin-based alpha-side pruning is
+ folded into this same table-driven path and starts operating at
+ the 1-2-ply horizon EFP currently owns), re-introduce an explicit
+ floor/ceiling using the macros above rather than reshaping the
+ static-threshold array to fake one.
+
+ The uPredictedFH == JOINT_TABLE_SENTINEL check below is currently
+ unreachable in practice: g_uJointFHThousandths (data.c) is fully
+ backoff-smoothed (shrinkage to a coarser marginal rather than a
+ missing-data sentinel) as of the splice-dual bake that added the
+ improve axis -- "No 0xffff sentinels remain" per that table's own
+ header comment. Kept as a defensive check (cheap, and correct even
+ if the table is ever rebaked with real gaps again), not because
+ it's expected to fire today.
Parameters:
ctx : current searcher thread context (uPly only,
for the sanity ASSERT).
uRemainingDepth : remaining depth below this move.
- uLegalMoves : legal moves tried at this node so far.
+ uLegalMoves : legal moves tried at this node so far --
+ currently unused by this function (see the
+ dead-macro note above); kept in the
+ signature for call-site stability.
uFHAttempts : this move identity's dynamic sample count.
uFHPctThousandths : this move identity's dynamic fail-high rate.
uPredictedFH : the joint table's static prediction for this
- move's (depth, hang, gap, late) situation, or
- JOINT_TABLE_SENTINEL if no data.
+ move's (depth, hang, gap, late, improve)
+ situation, or JOINT_TABLE_SENTINEL if no data
+ (see note above on why this is effectively
+ dead today).
Return value: