diff options
| author | Scott Gasch <[email protected]> | 2026-10-20 18:37:20 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-20 18:37:20 -0700 |
| commit | 9cf14e14e6999420f234cda951894337e034f57c (patch) | |
| tree | d4845d0cf127ef4226a5cd2e60472ee750213614 /src/eval.c | |
| parent | c8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (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.c | 129 |
1 files changed, 102 insertions, 27 deletions
@@ -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)]++; |
