summaryrefslogtreecommitdiff
path: root/src/eval.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
committerScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
commit9cf14e14e6999420f234cda951894337e034f57c (patch)
treed4845d0cf127ef4226a5cd2e60472ee750213614 /src/eval.c
parentc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (diff)
NNUE: re-fit raw-to-score calibration, NNU4 weight format, super-lazy default-on, lazy probe measured net-negative
Fits an affine (a + b*raw) correction into every exported .nnue file (export_nnue_weights.py's CalibrateScoreMapping) so the engine's consumed centipawn output tracks Stockfish's real score scale rather than the net's nominal raw WDL-sigmoid output -- training variance alone shifts this mapping across retrains even with an identical recipe, so it's re-fit and baked in per export rather than assumed constant. Weight format bumped to "NNU4" to carry the two new floats; older NNU1/2/3 files are no longer readable. Super-lazy (material-only) pre-filter promoted to default-on (g_fUseNnueSuperLazy = TRUE) with a recalibrated per-material-bucket margin table (measured against the recalibrated net, 16-shard cross_sectional_200k.ep_ sample) -- real, measured nps win across all three curated suites. The separate lazy-probe gate (accumulator-based early exit) is measured net-negative on real search traffic at this net's probe-vs-forward latency ratio and stays off by default (g_fUseNnueLazyProbe = FALSE); code kept live since margin tuning could still flip this, per docs/NNUE.md. Also: direct per-call latency instrumentation for NnueProbeScore/ NnueForward (root.c, EVAL_TIME build) to make the lazy-probe breakeven math measurable directly instead of inferred; QSearch futility margins given real-data-derived NNUE_FULL/NNUE_LAZY starting points. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval.c')
-rw-r--r--src/eval.c129
1 files changed, 102 insertions, 27 deletions
diff --git a/src/eval.c b/src/eval.c
index de59dd3..0f2a556 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -7239,23 +7239,86 @@ Return value:
if ((TRUE == g_fUseNnueEval) && (TRUE == g_fNnueWeightsLoaded))
{
// p98 of |NnueForward() - NnueProbeScore()| per material
- // bucket. Superseded 2026-10-15: originally derived from a
- // 91M-sample population over three curated tactical suites
- // (sd 10) -- replaced same day by a far larger and more
- // representative 3.09-BILLION-sample population, 16 parallel
- // shards of 12,500 positions each (200,000 total) from
- // tests/cross_sectional_200k.ep_ at sd 6. Per-suite tactical
- // positions are a narrow, biased slice (selected for sharp
- // tactics); the cross-sectional pool's much broader position
- // variety surfaced real outlier swings the narrower sample
- // never saw (max up to 534cp here vs ~472cp before) while
- // landing close to the same per-bucket numbers overall (mostly
- // 0-10cp different) -- see docs/NNUE.md's "Lazy-evaluation
- // tiering plan". Same bucketing as _MaterialBucket (combined
- // army scaler, width BASE_MARGIN_MAT_WIDTH).
+ // bucket. Superseded 2026-10-20: the previous table (180, 200,
+ // 190, 180, 170, 160, 150, 150) was fit against an earlier net
+ // generation, before that day's K/lambda/loss-exponent
+ // recalibration (see docs/NNUE.md's "keeping this stable
+ // across retrains" conversation) widened the net's raw output
+ // scale to actually track Stockfish's real score magnitude --
+ // the old, narrower-scale margins would have been unsafe (too
+ // tight) against the new, wider-scale net. Re-measured the
+ // same way (16 parallel shards of 12,500 positions each,
+ // 200,000 total, from tests/cross_sectional_200k.ep_ at sd 6,
+ // ~100-435M swing samples per bucket) against the recalibrated
+ // net (annotate_pgns/runs/sf_20261020_recal/nnue_weights.nnue)
+ // and its freshly-refit probe -- see
+ // annotate_pgns/fit_lazy_probe.py and aggregate_marginsafety.py.
+ // Must be re-measured again for any future net whose raw
+ // output scale changes meaningfully (a new K, a materially
+ // different training recipe) -- not assumed to stay valid
+ // forever just because the recipe nominally stayed the same
+ // (see that same conversation for why). Same bucketing as
+ // _MaterialBucket (combined army scaler, width
+ // BASE_MARGIN_MAT_WIDTH).
+ // 2026-10-20 status: lazy probe gate measured net NEGATIVE on
+ // real ECM search traffic at this margin table (p98, from the
+ // real cross_sectional_200k.ep_/16-shard run) -- hit rate ~1%
+ // overall, far under the ~6.6% breakeven implied by
+ // NnueProbeScore's own ~272cyc vs NnueForward's ~4100cyc
+ // latency (measured directly, see root.c's EVAL_TIME-gated
+ // "NNUE eval primitive latency" report). A p95 guess (NOT from
+ // this real run -- its per-shard raw histograms no longer
+ // exist on disk; computed instead from a much smaller
+ // leftover file of unknown/mismatched provenance, see git
+ // history) didn't clearly fix it either, and came back noisier
+ // than p98 on nps in several configs. g_fUseNnueLazyProbe
+ // stays FALSE by default (nnueeval.c) pending a real
+ // hit-rate-targeted per-bucket retune (not yet done -- see
+ // docs/NNUE.md) measured against ordinary search traffic
+ // (twic_sample.ep_-like), not just ECM's narrower tactical
+ // windows. Code kept live (not deleted) since the mechanism is
+ // cheap to leave dormant and the latency math says a real win
+ // is plausible with the right margins -- just not demonstrated
+ // yet. This table is the one real, validated data point (p98,
+ // safety-only objective) and is what a future retune should
+ // start from and compare against.
static const SCORE iNnueLazyMarginByMatBucket[BASE_MARGIN_MAT_BUCKETS] =
{
- 180, 200, 190, 180, 170, 160, 150, 150,
+ 300, 320, 410, 640, 770, 770, 540, 230,
+ };
+ // p95 experiment (2026-10-20) -- see the status comment above
+ // for why this isn't trusted as a real calibration. Kept here
+ // for reference/reproducibility, not as a ready-to-flip
+ // alternative.
+ // static const SCORE iNnueLazyMarginByMatBucketP95Guess[BASE_MARGIN_MAT_BUCKETS] =
+ // {
+ // 180, 240, 520, 530, 670, 720, 480, 310,
+ // };
+
+ // 2026-10-20: NNUE super-lazy's own per-bucket table, same
+ // calibration run (cross_sectional_200k.ep_, sd 6, same
+ // _MaterialBucket) as iNnueLazyMarginByMatBucket above --
+ // p98 of |NnueForward() - pos->iMaterialBalance[uToMove]|.
+ // Previously a single flat runtime override
+ // (g_iNnueSuperLazyMargin) with no per-bucket fit at all; per
+ // docs/NNUE.md's "Side finding" (2026-10-20), NNUE super-lazy's
+ // real measured swing is only ~30-40% looser than the real
+ // probe's, same order of magnitude, not "too wild to use" as
+ // an earlier (differently-scaled-net) measurement had
+ // suggested. No bucket excluded outright the way classic
+ // HCE's super-lazy excludes material bucket 0 (eval.c's
+ // fBareKingEitherSide/uSuperLazyMatBucket==0 guard) --
+ // that exclusion was driven by a specific *exceeded-rate*
+ // finding (6.8% blow-through from pawn-structure blindness,
+ // since HCE's super-lazy runs before _EvalPawns), not by p98
+ // magnitude alone, and NNUE's bucket 0 p98 here (420) is in
+ // fact the second-lowest of the 8, not an outlier. No
+ // equivalent exceeded-rate check has been run for NNUE's
+ // super-lazy yet, though -- this is "nothing visible says
+ // disable a bucket," not "confirmed safe at every bucket."
+ static const SCORE iNnueSuperLazyMarginByMatBucket[BASE_MARGIN_MAT_BUCKETS] =
+ {
+ 420, 450, 520, 730, 860, 850, 640, 290,
};
#ifdef CALIBRATE_MARGIN_SAFETY
@@ -7307,20 +7370,27 @@ Return value:
// entirely -- this is the actual speed win. Otherwise (gate
// disabled, probe not loaded, or inconclusive), fall through
// to the full pass exactly as before this feature existed.
- // Super-lazy pre-filter, experimental -- 2026-10-16: cheapest
- // possible check, pure material balance, no net evaluation at
- // all. Previously measurement-only (CALIBRATE_MARGIN_SAFETY
- // above records the real swing but always used an infinite
- // sentinel margin, i.e. never actually gated). Deliberately
- // loose/uncalibrated here per the "safety doesn't win games"
- // experiment -- g_iNnueSuperLazyMargin is a flat runtime
- // override (see nnueeval.c), not a per-bucket fit.
+ // Super-lazy pre-filter -- cheapest possible check, pure
+ // material balance, no net evaluation at all. Previously
+ // measurement-only (CALIBRATE_MARGIN_SAFETY above records the
+ // real swing but always used an infinite sentinel margin, i.e.
+ // never actually gated), then briefly a single uncalibrated
+ // flat runtime override. 2026-10-20: now a real per-material-
+ // bucket table (iNnueSuperLazyMarginByMatBucket, above),
+ // calibrated the same way/same run as the lazy probe's own
+ // table -- g_iNnueSuperLazyMargin remains available as an
+ // explicit flat override (>= 0) for experimentation, matching
+ // g_iNnueLazyMarginOverride's convention; < 0 (the default)
+ // uses the calibrated table.
if (TRUE == g_fUseNnueSuperLazy)
{
SCORE iSuperLazyScore = pos->iMaterialBalance[pos->uToMove];
+ SCORE iSuperLazyMargin = (g_iNnueSuperLazyMargin >= 0) ?
+ g_iNnueSuperLazyMargin :
+ iNnueSuperLazyMarginByMatBucket[_MaterialBucket(pos)];
- if (((iSuperLazyScore - g_iNnueSuperLazyMargin) > iBeta) ||
- ((iSuperLazyScore + g_iNnueSuperLazyMargin) < iAlpha))
+ if (((iSuperLazyScore - iSuperLazyMargin) > iBeta) ||
+ ((iSuperLazyScore + iSuperLazyMargin) < iAlpha))
{
// Feed the same aggregate counters root.c's "Eval exit
// breakdown" line reads -- otherwise that line stays
@@ -7339,7 +7409,10 @@ Return value:
}
if ((TRUE == g_fUseNnueLazyProbe) && (TRUE == g_fNnueProbeLoaded))
{
- SCORE iProbeScore = NnueProbeScore(ctx);
+ SCORE iProbeScore;
+ INC(ctx->sCounters.tree.u64NnueProbeAttempts);
+ TIMED_EVAL_CALL(ctx, u64CyclesNnueProbeCall,
+ iProbeScore = NnueProbeScore(ctx));
SCORE iMargin = (g_iNnueLazyMarginOverride >= 0) ?
(SCORE)g_iNnueLazyMarginOverride :
iNnueLazyMarginByMatBucket[_MaterialBucket(pos)];
@@ -7359,7 +7432,9 @@ Return value:
}
}
{
- SCORE iNnueScore = NnueForward(ctx);
+ SCORE iNnueScore;
+ TIMED_EVAL_CALL(ctx, u64CyclesNnueForwardCall,
+ iNnueScore = NnueForward(ctx));
INC(ctx->sCounters.tree.u64FullEvals);
ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_NNUE_FULL;
g_u64NnueFullEvalsByBucket[_MaterialBucket(pos)]++;