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 | |
| 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')
| -rw-r--r-- | src/GNUmakefile | 16 | ||||
| -rwxr-xr-x | src/chess.h | 16 | ||||
| -rw-r--r-- | src/move.c | 2 | ||||
| -rw-r--r-- | src/movesup.c | 26 | ||||
| -rw-r--r-- | src/search.c | 110 | ||||
| -rw-r--r-- | src/searchsup.c | 85 |
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, @@ -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: |
