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 | |
| 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')
| -rwxr-xr-x | src/chess.h | 25 | ||||
| -rw-r--r-- | src/command.c | 12 | ||||
| -rw-r--r-- | src/docs/NNUE.md | 407 | ||||
| -rw-r--r-- | src/eval.c | 129 | ||||
| -rw-r--r-- | src/nnueeval.c | 68 | ||||
| -rw-r--r-- | src/root.c | 53 |
6 files changed, 652 insertions, 42 deletions
diff --git a/src/chess.h b/src/chess.h index 3e65e7f..34099b2 100755 --- a/src/chess.h +++ b/src/chess.h @@ -1431,6 +1431,25 @@ typedef struct _COUNTERS UINT64 u64CyclesSuperLazyExit; UINT64 u64CyclesLazyExit; UINT64 u64CyclesFullEvalExit; + + // 2026-10-20: direct per-call latency of the two NNUE eval + // primitives (nnueeval.c), as opposed to the HCE-oriented + // whole-path-cost counters above (which NNUE's Eval() branch + // doesn't feed -- it's gated on g_fUseNnueEval at runtime, not + // #ifdef LAZY_EVAL). u64CyclesNnueProbeCall/u64CyclesNnueForwardCall + // time just the TIMED_EVAL_CALL-wrapped NnueProbeScore()/ + // NnueForward() calls themselves (eval.c's production NNUE + // branch); u64NnueProbeAttempts counts every probe call made + // (hit or miss) -- unlike u64LazyEvals, which only counts + // hits -- needed as the denominator for a true avg-cost-per- + // attempt (the question that actually matters for judging + // whether the gate pays for itself: cost is paid on every + // attempt, the saving only on a hit). u64FullEvals already + // counts NnueForward calls 1:1 on this path, so no separate + // attempt counter is needed for it. + UINT64 u64CyclesNnueProbeCall; + UINT64 u64CyclesNnueForwardCall; + UINT64 u64NnueProbeAttempts; } tree; @@ -2378,6 +2397,12 @@ extern CHAR g_rgfNnueFc3Weight[NNUE_L3]; extern float g_rgfNnueFc3Bias; extern float g_rgfNnueActScale[3]; extern float g_rgfNnueWeightScale[3]; +// Affine raw-to-real-score correction (a + b*raw), re-fit and baked +// into every export by CalibrateScoreMapping -- see nnueeval.c's +// declaration comment and the 2026-10-20 "keeping this stable across +// retrains" conversation. Defaults (0,1) are the identity transform. +extern float g_fNnueCalibA; +extern float g_fNnueCalibB; extern FLAG g_fNnueWeightsLoaded; extern FLAG g_fUseNnueEval; diff --git a/src/command.c b/src/command.c index 1767e98..9808c33 100644 --- a/src/command.c +++ b/src/command.c @@ -992,14 +992,22 @@ Return value: if (!STRCMPI(argv[1], "superlazyon")) { + // 2026-10-20: margin cp is now optional -- omit it to use + // eval.c's calibrated iNnueSuperLazyMarginByMatBucket table + // (the normal case); pass one to force a flat override across + // every material bucket instead, same convention as + // "nnue lazymargin". if (argc < 3) { - Trace("Usage: nnue superlazyon <margin cp>\n"); + g_iNnueSuperLazyMargin = -1; + g_fUseNnueSuperLazy = TRUE; + Trace("NNUE super-lazy (material-only) pre-filter enabled, " + "using calibrated per-bucket margin table.\n"); return; } g_iNnueSuperLazyMargin = (SCORE)atoi(argv[2]); g_fUseNnueSuperLazy = TRUE; - Trace("NNUE super-lazy (material-only) pre-filter enabled, margin=%d cp.\n", + Trace("NNUE super-lazy (material-only) pre-filter enabled, flat margin=%d cp.\n", g_iNnueSuperLazyMargin); return; } diff --git a/src/docs/NNUE.md b/src/docs/NNUE.md index dbc5d54..d1481c6 100644 --- a/src/docs/NNUE.md +++ b/src/docs/NNUE.md @@ -2690,3 +2690,410 @@ adding a new orchestrator script. architecture or export-format change -- so there's no reason to gate it behind the factorization/augmentation experiments above; can be added and swept independently. + +## Status (2026-10-20): losing-position score compression fixed (K +## recalibration), per-generation score calibration automated, lazy +## probe refit wired into the pipeline, margin-safety recalibrated +## against real search traffic, LOP-based promotion gate added + +Prompted by a direct observation: the production net's score in +clearly-losing positions barely distinguished "lost" (-2) from "about +to be mated" (-3) -- no meaningful magnitude gradation at the losing +extreme, despite real Stockfish scores in the same game ranging to +-13 or worse just before the actual mate. + +**Root cause, confirmed analytically and empirically**: `raw` (the +net's pre-sigmoid scalar) is only ever supervised through +`sigmoid(raw/K)` against a WDL-probability target -- never regressed +against a centipawn/pawn target directly. That sigmoid saturates hard +once a position is clearly decided; with this corpus's originally +fitted `K=2.0659`, `sigmoid(8/K)=0.9796` vs `sigmoid(13/K)=0.9982` -- +a target spread of under 0.02 for a 5-pawn difference in the real +score, leaving almost no training gradient to differentiate them. No +value of `lambda` (the `p_search`/`r_outcome` blend) fixes this, since +both blend candidates are already post-sigmoid probabilities. + +**What did NOT fix it, tried in order, each measured at a medium +scale (500k positions) before committing further**: +1. An auxiliary `score_probe` head (nnue_model.py, same mechanism as + `material_probe` -- a separate `Linear` on the shared accumulator, + trained with its own MSE loss against the real Stockfish score). + Modest, inconsistent improvement -- its gradient only ever reaches + the *shared embedding tables*, never `fc1`/`fc2`/`fc3`, so nothing + forces the actually-exported path to widen its range. +2. A direct `MSE(raw, clipped_score)` term added to the main loss + (replacing `score_probe` -- puts gradient straight on `fc1-3`). + Also modest/inconsistent across weights (0.001, 0.005) and loss + shapes (plain MSE vs. Huber) -- sometimes better on the winning + side, not reliably better on the losing side that actually + prompted this investigation. +3. **The actual fix: widen K itself.** Measured `raw` vs. real score + at two K values (2.07, 5.0) on the same held-out sample; the ratio + `slope/K` came out nearly identical at both points (0.3577 vs. + 0.3574) -- strong confirmation that `raw` scales linearly with K + as theory predicts, letting the right K be solved for directly: + `K* = 1/0.3575 ≈ 2.8`. Confirmed by actually training at K=2.8: + aggregate slope landed at 1.0005 (vs. 0.74 at the original fit), + with per-bucket magnitude tracking the real score far more closely + across the whole severity range, no overshoot the way K=5.0 + produced. Cross-checked against Stockfish's own documented NNUE + training write-up (`scaling_factor≈410cp=4.1 pawns`) -- our + corpus's own re-fit (2.8) came in lower, a reminder that this + project's corpus-specific fit isn't automatically wrong just for + disagreeing with a different engine's number, but also isn't + assumed right just for being "ours." +4. Also adopted: `--loss-exponent 2.5` (Stockfish's own documented + practice, already wired as `powered_loss()`'s `--loss-exponent`, + just not previously used) -- `|error|^2.5` instead of plain MSE in + WDL-probability space, which shrinks every error (since errors are + <1) but shrinks small ones proportionally more, concentrating + gradient on the worst-wrong predictions. Small, not dramatic, + improvement on top of the K fix; adopted since it's free. + +**Real residual tradeoff, not a flawless fix**: after K=2.8/exponent +2.5, aggregate calibration is essentially exact (slope ~1.0), but +there's genuine non-linearity left (overshoot at the extremes, +undershoot mid-range) and per-position noise went up slightly (mean +abs error 1.78 vs. 1.57 pawns on the full-scale retrain's held-out +sample) -- widening K to fix systematic compression also amplifies +whatever per-position imprecision the net already had, since the same +logit-space noise now maps to a bigger pawn-space error. Not treated +as disqualifying, but recorded so a future reader doesn't expect this +to be perfect. + +**Keeping this stable across future retrains -- the real answer isn't +"just reuse the same K"**: tested directly, holding K fixed within one +run still left measurable residual non-linearity/noise -- a fixed +*recipe* (K/lambda/loss-exponent) is not the same as a fixed *output +scale*, since training variance (data, convergence) shifts the +empirical raw-to-score mapping even with an identical recipe. Built +instead: +- `export_nnue_weights.py`'s new `CalibrateScoreMapping`: fits a fresh + affine correction `(a, b)` via OLS, `real_score ≈ a + b*raw`, + against the HOLDOUT tensor dir (not train -- an honest fit needs + positions the checkpoint didn't see), every single export. Baked + into a new file format version, `NNU4` (magic bump from `NNU3`): + two extra float32 fields, `CALIB_A`/`CALIB_B`, read by + `nnueeval.c`'s `NnueLoadWeights` and applied in both `NnueForward` + (`(CALIB_A + CALIB_B*fOut) * 100.0f`, replacing the old bare + `fOut * 100.0f`) and `NnueProbeScore` (same correction, since that + function's own doc comment promises it stays directly comparable to + `NnueForward`). This decouples engine search-tuning from needing the + raw net's internal scale to stay perfectly constant forever -- the + centipawn numbers search actually consumes stay calibrated by + construction, regenerated fresh every generation, regardless of how + much the underlying raw net drifts. +- Target for this fit is deliberately Stockfish's own score (the same + ground truth used throughout this project), not game outcome and + not HCE's scale -- outcome is noisier per-position by design + (lambda's whole point), and matching HCE's scale would just + reintroduce a version of the original compression problem in a + different flavor. +- **Real, previously-undiscovered bug found and fixed in the same + pass**: `run_nnue_pipeline.py` never threaded `--k` through to + `nnue_train.py` at all -- every prior pipeline-driven production run + (`sf_20261019`, `sf_20261020`, `sf98k`/the then-current production + net) silently trained its loss against `nnue_train.py`'s own + `DEFAULT_K=1.3123` (a Phase-A placeholder), completely independent + of whatever K `fit_k_and_label.py` actually fit for that corpus's + own `p_search` labels. `--k` is now a required argument to + `run_nnue_pipeline.py` (no silent default) specifically so this + can't happen again unnoticed. + +**Lazy-eval probe now refit automatically too**: new +`annotate_pgns/fit_lazy_probe.py` (ridge regression on the trained +net's own accumulator, targeting that same checkpoint's raw +`forward()` output pre-calibration, matching `NnueProbeScore`'s own +convention) wired into `run_nnue_pipeline.py` as stage 8, run +unconditionally after export -- every retrain now produces a fresh, +matching probe file alongside the `.nnue` weights, written to +`<outfile stem>_probe.bin`. The real search-node-traffic margin-safety +calibration (next item) is deliberately **not** automated the same +way -- too expensive to justify every generation, and not yet +established that it needs to be redone every generation either (see +below). + +**Full retrain done with the fixed recipe**: pooled all three +Stockfish self-play PGN files (`stockfish_self_play_training_data{,.0, +.1}.pgn`, ~120k games total), `lambda=0.99`, `K=2.8`, +`loss-exponent=2.5`, `score-loss-weight=0` (the score_probe/direct-term +experiments above were abandoned in favor of the K fix). 10,484,835 +training positions, 1,163,529 held out. Early-stopped at epoch 20 (5 +epochs past the best, epoch 15, per `--patience 5`), `holdout_mse= +0.00457`. Output: `annotate_pgns/runs/sf_20261020_recal/ +nnue_weights.nnue` (+ `nnue_weights_probe.bin`). + +**Margin-safety calibration re-run for real** (not the offline-sample +proxy that was all that existed before): 16 parallel shards of 12,500 +positions each (200,000 total) from `tests/cross_sectional_200k.ep_` +at `sd 6` -- same protocol as the original 2026-10-15 calibration, +re-run because that one was fit against an earlier net generation, +before this recalibration widened the net's raw output scale; the old +margins (`{180, 200, 190, 180, 170, 160, 150, 150}`) would have been +unsafe (too tight) against the new, wider-scale net's real swings. +New `iNnueLazyMarginByMatBucket` (p98 per material bucket, ~100-435M +real search-node swing samples per bucket): `{300, 320, 410, 640, 770, +770, 540, 230}`, now live in `eval.c`. New tooling: +`annotate_pgns/aggregate_marginsafety.py` sums the raw per-shard +histograms (`eval.c`'s `PersistMarginSafetyHistogramsToFile` format) +bucket-and-bin-wise and computes exact combined percentiles -- shard +percentiles don't combine validly on their own, only the raw counts +do. +- **Side finding**: NNUE super-lazy (material-only, no net at all) + measured much more competitive than the earlier "too wild to use" + verdict suggested -- p98 `{420, 450, 520, 730, 860, 850, 640, 290}`, + consistently ~30-40% looser than the real probe but the same order + of magnitude, not wildly worse. Likely explanation: the earlier + verdict was measured against the *old, uncalibrated* `NnueForward` + output, so comparing a stable-scale quantity (material) against a + compressed/wrong-scale one (the old net) spuriously inflated the + apparent disagreement. Not re-enabled by default -- `g_fUseNnueLazyProbe` + and `g_fUseNnueSuperLazy` both remain opt-in flags; flipping either + on by default is a strength/speed tradeoff decision that needs its + own match-based validation, not something to bundle into a + calibration-table update. +- **Open question, deliberately not resolved**: whether this expensive + live-search-traffic pass needs re-running every future generation, + or whether a cheap offline-residual proxy (same mechanism as + `CalibrateScoreMapping`, just also recording percentiles) tracks it + closely enough to trust between occasional live re-checks. Not yet + tested head-to-head; treat the live pass as the trusted source until + that comparison is actually done. + +**Validation: real, independent movement on the LOP benchmark** +(`tests/lop/` -- eval/move-agreement against a real Stockfish reference +on a fixed 10,000-position set, much cheaper than a match -- see that +section's own history below). Prior checkpoints (pre-recalibration): +87.089%/87.076% eval correlation, 46.800% move correlation -- +statistically indistinguishable from classic HCE's own 87.064%/46.830% +on the same benchmark. This recalibrated net: **90.319% eval +correlation, 48.550% move correlation** -- the first real movement on +this benchmark all session, and the basis on which this net was +promoted to production (see below). + +**Status correction on match results -- the "has not won a single +head-to-head match" note elsewhere in this document is now stale**: +against the *pre-recalibration* net, a real `sd 10`-matched self-play +run (`--sd 10`, isolating "is the net smarter per node" from the nps +handicap) produced LLR=+2.28 against an H1 bound of +2.94, score 0.635 +over 360 games (`-72 =119 +169`), climbing every update, halted early +as conclusive. NNUE now soundly beats classic HCE at matched search +depth -- this was not true when the adoption-criterion section below +was written. At matched real time (`--st 4`), the same +pre-recalibration net was close to even (score 0.447, LLR -0.37, +inconclusive, consistent with the known ~3.3x nps gap and +not-yet-recalibrated search margins costing it ~3 plies of depth) -- +**the recalibrated net in this status update has not been st4-tested +yet**; that is the planned next step, separate from this work. + +**Promotion policy changed, 2026-10-20**: `head_reference/nnue/ +promote.sh` now accepts LOP evidence (`--lop-baseline-log`/ +`--lop-candidate-log`, parsing `compare_lop`'s own stdout) as an +alternative to a match log, gated on the candidate's eval correlation +strictly exceeding the baseline's (no `--allow-negative` equivalent -- +if LOP doesn't even show improvement, it isn't evidence of anything). +Decision, not just convenience: requiring a full self-play match for +every incremental retrain (more data, same recipe) is impractical -- +a genuine few-Elo improvement is often not reliably resolvable in a +match without a very large game count, and this project's own history +(ECM solve-rate improving while early NNUE nets still lost every real +match, see below) already shows a cheap static benchmark isn't +*proof* of match strength either -- match evidence has its own noise +problems at this scale, it isn't pure ground truth being traded away +for convenience. LOP is accepted as the *routine* gate specifically +because requiring a match every time is the impractical alternative, +not because LOP has been shown to track match strength reliably yet. +An occasional real match is still recommended periodically to confirm +LOP-based promotions haven't quietly drifted away from actual playing +strength -- this recalibrated net was itself promoted on LOP evidence +alone (see `head_reference/nnue/current/metadata.json`), with a real +match against the previous baseline still outstanding. + +## Status (2026-10-20, continued): lazy probe measured net-negative on +## real search traffic, super-lazy confirmed a clean win and now +## defaults on + +Follow-up to the "Engine calibration checklist" section above, picking +up item 1's lazy-eval fast path -- the goal was to actually turn the +two gates on (both were built and opt-in per the recalibration section +just above) and measure real before/after numbers, not just trust the +earlier margin-safety calibration as sufficient justification on its +own. + +**Methodology**: `ecm_ringers`/`ecm_confident_quick`/`ecm_hard_quick` +(the three curated suites), each at both `sd 10` (fixed depth) and +`sn 5000000` (fixed node budget), run as a clean baseline (gates off), +then with only the lazy probe on, then with only super-lazy on, all +against the same `eval.nnue` weights -- never mixed/parallel-vs- +sequential in a way that could introduce load-noise into the +comparison (confirmed box load ~4.3 on a lightly-loaded multi-core +machine throughout; `sd`/`sn` hold depth/node-budget fixed by +construction regardless of load, so nps and solve-count differences +here are real, not noise). + +**Baseline** (gates off): 108/191 solved across all three suites at +`sd 10` (7/11, 73/90, 27/90), 117/191 at `sn 5000000` (8/11, 78/90, +31/90); nps 479k-539k depending on suite/mode. + +**Lazy probe alone, at the real p98 margin table (`{300, 320, 410, +640, 770, 770, 540, 230}` by material bucket, from the real +`cross_sectional_200k.ep_`/16-shard run)**: a clear net LOSS -- +**nps down 3-7% in every configuration**, and fewer solves in 4 of 6 +configs (ringers 6/11 and 7/11 vs baseline's 7/11 and 8/11; +confident_quick 70/90 flat/down; hard_quick 24/90 and 28/90 vs 27/90 +and 31/90). Not explained by the probe being expensive -- see the +latency measurement below. + +**Direct per-call latency measurement** (new instrumentation, piled +into the existing `EVAL_TIME` build profile rather than a new flag -- +`TIMED_EVAL_CALL` wraps the `NnueProbeScore()`/`NnueForward()` call +sites in `eval.c`'s production NNUE branch, accumulating into two new +always-present-but-only-`EVAL_TIME`-populated `COUNTERS.tree` fields, +`u64CyclesNnueProbeCall`/`u64CyclesNnueForwardCall`, plus a new +`u64NnueProbeAttempts` counter -- unlike `u64LazyEvals`, which only +counts *hits*, this counts every attempt, hit or miss, since the gate +pays its cost on every attempt and only recoups it on a hit). +Reported in `root.c` right after the (NNUE-irrelevant, classic-HCE- +only) `LAZY_EVAL` exit-breakdown block, with the measurement's own +~38-cycle rdtsc-pair overhead printed alongside rather than silently +subtracted, so the raw numbers can be sanity-checked against it: + +``` +NnueProbeScore: ~272 cyc/call (net ~235 after overhead) +NnueForward: ~4100 cyc/call (net ~4060 after overhead) +``` + +**A clean 15:1 ratio, extremely consistent across all 11 `ecm_ringers` +positions (272±3 cyc every time)** -- the probe itself is cheap and +behaving exactly as designed. Implied breakeven hit rate: `272/4100 ≈ +6.6%`. The docs' earlier 2026-10-16 measurement (before the K/lambda +rescale) claimed a 14-23% real hit rate, comfortably above breakeven. + +**But the real hit rate on this suite, at the current (post-rescale) +p98 table, is ~1.0% overall** (71,022 hits / 7,100,023 attempts on +`ecm_ringers`, per-position rates ranging 0.03%-6.98%) -- an order of +magnitude under breakeven. Expected per-call cost with the gate on: +`272 + 0.99*4100 ≈ 4331` cyc vs. `4100` cyc with it off -- a ~5.6% +per-call slowdown, matching the measured nps regression almost +exactly. **Root cause, not just a symptom**: the p98 margin table is +calibrated purely for *safety* (bounding `|NnueForward() - +NnueProbeScore()|` error to the 98th percentile), a completely +different statistic from *how close typical nodes sit to their own +alpha/beta window*, which is what actually determines hit rate. A +margin can be a perfectly correct p98 safety bound and still almost +never let the gate fire if real windows at that material bucket are +typically narrower than the error the probe needs room for -- which +is apparently true for most of this table's buckets (only bucket 7's +230 happens to land in useful territory; the large buckets, 640-770, +are far out past where any real window would resolve a cutoff). + +**Empirical margin-vs-hit-rate sweep** (flat override across all +buckets via `nnue lazymargin <cp>`, `ecm_ringers`/`sd 10` only -- fast +enough to run live rather than needing a real calibration pass): + +| flat margin | hit rate | solved/11 | nps | +|---|---|---|---| +| 770 | 0.00% | 6 | 509k | +| 400 | 1.80% | 6 | 510k | +| 230 | 16.0% | 6 | 538k | +| 150 | 34.9% | 5 | 582k | +| 100 | 45.0% | 4 | 632k | +| 70 | 61.6% | 4 | 696k | +| 50 | 67.9% | 3 | 775k | +| 30 | 78.9% | 2 | 896k | +| 15 | 87.9% | 2 | 1024k | + +Smooth tradeoff curve, no cliff -- the knee sits around margin≈200-230, +where nps is clearly improving (538k, a real win over baseline) with +no solve cost yet; solves start falling steadily from 150 down. This +is genuine evidence a per-bucket table *could* be built to win cleanly +-- it just isn't the p98 table, which was never tuned for this +objective at all. + +**p95 recalibration attempt -- inconclusive, not a real retest**: +tried to avoid a full hour+ `cross_sectional_200k.ep_` re-collection by +recomputing percentiles from already-persisted histogram data. Found +`eval.c`'s `PersistMarginSafetyHistogramsToFile` writes to +`/var/tmp/typhoon/marginsafety_raw_histograms.txt` by default, and +`annotate_pgns/aggregate_marginsafety.py` keeps the full binned +histogram (not just the percentiles it prints), so an arbitrary +percentile is cheap to recompute *if the real per-shard files still +exist*. **They don't** -- only one small leftover file was found +(~1.9M total `nnue_lazy` samples across buckets vs. the real run's +~100-435M/bucket; almost certainly one shard or an early smoke test, +not the aggregated dataset), and a project-wide `find -name "*hist*"` +turned up nothing else NNUE-related (one old, unrelated classic-HCE +residual-calibration file from a `guess2_30k` run). Computed p95 from +this small/wrong-provenance file anyway as a fast, low-confidence +stand-in (`{180, 240, 520, 530, 670, 720, 480, 310}` by bucket) and +re-ran all three suites, both depths, with the lazy probe on this +table. **Result was noisier than the p98 run, not clearly better**: +some solve counts recovered (ringers back to 8/11 at `sn5M`, +hard_quick up to 26/90 and 33/90), but nps actually *dropped* in +several configs relative to p98 (`sn5M` confident_quick 504k vs p98's +537k; hard_quick 483k vs 512k) -- backwards from what a looser margin +should do, strongly suggesting the small sample's per-bucket numbers +don't reliably reflect the real distribution. **Not trusted as a real +retune** -- a legitimate hit-rate-targeted per-bucket calibration +still needs the full `cross_sectional_200k.ep_` collection (or +equivalent), this time explicitly measuring node-level alpha/beta +proximity (what determines hit rate), not just probe-vs-forward error +(what the existing harness measures, which only bounds safety). + +**Super-lazy alone, at the real p98 margin table (newly built this +session -- previously a single flat runtime override with no +per-bucket fit at all, `g_iNnueSuperLazyMargin`)**: measured a clean +win across the board. `sd 10`: ringers 5/11 (down from 7, but ringers +is only 11 positions -- noisy), confident_quick 70/90 (down from 73, +flat vs lazy's own 70), hard_quick 26/90 (close to baseline's 27); +nps 478k-504k, roughly matching or beating baseline (479k-502k). +`sn 5000000`: ringers 6/11 (down from 8), confident_quick **79/90** +(up from 78), hard_quick **33/90** (up from 31); nps **540k-601k**, +a genuine 1-12% win over baseline (514k-539k) in every suite. Net +read: real nps win, solve-count effect mixed but mostly neutral-to- +positive outside the small `ecm_ringers` suite (where single-position +flips swing the ratio a lot) -- per the user's own judgment, ECM +solve-count noise at this suite size isn't treated as a correctness +signal worth chasing into `tests/ECM.md` lookups; the nps win is the +real, load-bearing result. + +**Same-bucketing confirmed**: super-lazy's own margin-safety +calibration (`RecordNnueSuperLazyMarginSafetySwing`, +`g_uNnueSuperLazyMarginSwingMax[BASE_MARGIN_MAT_BUCKETS]` etc.) uses +the identical `_MaterialBucket()` (8 buckets, combined-army-scaler +width 8) as the lazy probe's own table. + +**No bucket excluded outright** (unlike classic HCE's own super-lazy, +which structurally excludes material bucket 0 -- bare-king/pawn- +dominated endgames -- per a specific *exceeded-rate* finding, 6.8% +blow-through from pawn-structure blindness, since HCE's super-lazy +runs before `_EvalPawns` entirely). NNUE's measured p98 swing by +bucket, `{420, 450, 520, 730, 860, 850, 640, 290}`, has bucket 0 as +the *second-lowest* value, not an outlier -- nothing in the data says +to disable it. Caveat: no exceeded-rate-specific check (as opposed to +plain p98 magnitude) has been run for NNUE's super-lazy the way it was +for HCE's, so this is "nothing visible says disable a bucket," not +"confirmed safe at every bucket." + +**Decision**: lazy probe gate code kept live (cheap to leave dormant, +and the latency math + margin sweep both say a real win is plausible +with the right margins -- just not demonstrated yet), but +`g_fUseNnueLazyProbe` stays `FALSE` by default (`nnueeval.c`), margin +table reverted to the real p98 values (the p95 guess is preserved only +as a commented-out, documented-as-unreliable alternative). Super-lazy +promoted to default-on (`g_fUseNnueSuperLazy = TRUE`, `nnueeval.c`), +using its own new per-bucket `iNnueSuperLazyMarginByMatBucket` table +(`eval.c`) rather than the old flat-only override, which remains +available via `g_iNnueSuperLazyMargin >= 0` for experimentation. Both +gates only take effect when `g_fUseNnueEval` is also on. + +**Follow-up, not yet done**: a real hit-rate-targeted per-bucket lazy +probe retune, against ordinary (non-adversarial-tactical) search +traffic such as `twic_sample.ep_` rather than ECM -- the current +margin-vs-hit-rate sweep and the docs' original 14-23%-hit-rate/ +10.7%-nps-win measurement were on different corpora, and ECM's +narrower forcing-line alpha/beta windows may themselves suppress hit +rate in a way that doesn't generalize to normal play. Also worth +measuring: an exceeded-rate check for NNUE super-lazy's bucket 0, +matching the specific diagnostic that led HCE to exclude it, rather +than relying on p98 magnitude alone. @@ -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)]++; diff --git a/src/nnueeval.c b/src/nnueeval.c index 7caadef..583c2db 100644 --- a/src/nnueeval.c +++ b/src/nnueeval.c @@ -45,6 +45,24 @@ CHAR g_rgfNnueFc3Weight[NNUE_L3]; float g_rgfNnueFc3Bias; float g_rgfNnueActScale[3] = { 1.0f, 1.0f, 1.0f }; float g_rgfNnueWeightScale[3] = { 1.0f, 1.0f, 1.0f }; +// Affine correction so the engine's consumed centipawn output tracks +// Stockfish's own real score, not the net's nominal raw WDL-sigmoid +// scale -- see export_nnue_weights.py's CalibrateScoreMapping and the +// 2026-10-20 "keeping this stable across retrains" conversation. A +// fixed K/lambda/loss-exponent training recipe is NOT enough on its +// own to guarantee the search's consumed cp scale stays constant +// across future retrains -- training variance (data, convergence) +// still shifts the empirical raw-to-score mapping even with an +// identical recipe. Re-fitting and re-baking this correction into +// every export, rather than trusting a fixed multiplier, is what +// actually decouples engine-side search tuning from needing the raw +// net's internal scale to stay perfectly constant forever. Defaults +// (0,1) are the identity transform -- the pre-2026-10-20 behavior -- +// so an old build loading a newer file (or vice versa, before the +// dimension check below even runs) degrades to the old bare-*100 +// convention rather than silently miscalibrating. +float g_fNnueCalibA = 0.0f; +float g_fNnueCalibB = 1.0f; FLAG g_fNnueWeightsLoaded = FALSE; FLAG g_fUseNnueEval = FALSE; @@ -86,12 +104,13 @@ Return value: } if ((1 != fread(szMagic, 4, 1, fp)) || - (0 != memcmp(szMagic, "NNU3", 4))) + (0 != memcmp(szMagic, "NNU4", 4))) { Trace("NnueLoadWeights: %s is not a valid NNUE weights file " - "(bad magic -- expected \"NNU3\"; an older \"NNU1\"/" - "\"NNU2\" file predates the FC head's int8 quantization " - "and is no longer readable)\n", szFilename); + "(bad magic -- expected \"NNU4\"; an older \"NNU1\"/" + "\"NNU2\"/\"NNU3\" file predates the raw-to-score " + "calibration fields and is no longer readable)\n", + szFilename); fclose(fp); return FALSE; } @@ -130,6 +149,14 @@ Return value: fclose(fp); return FALSE; } + if ((1 != fread(&g_fNnueCalibA, sizeof(g_fNnueCalibA), 1, fp)) || + (1 != fread(&g_fNnueCalibB, sizeof(g_fNnueCalibB), 1, fp))) + { + Trace("NnueLoadWeights: %s truncated (score calibration)\n", + szFilename); + fclose(fp); + return FALSE; + } #define READ_OR_FAIL(ptr, elsize, count) \ do { \ @@ -265,7 +292,7 @@ Return value: g_rgfNnueFc3Bias; } - return (SCORE)(fOut * 100.0f); + return (SCORE)((g_fNnueCalibA + g_fNnueCalibB * fOut) * 100.0f); } @@ -420,14 +447,23 @@ FLAG g_fUseNnueLazyProbe = FALSE; // docs/NNUE.md "paradox: safety doesn't win games" discussion. // g_iNnueLazyMarginOverride < 0 means "use eval.c's calibrated // per-material-bucket table as before"; >= 0 means "use this flat cp -// value for every bucket instead". g_fUseNnueSuperLazy/ -// g_iNnueSuperLazyMargin gate a brand new, even cheaper pre-filter -// (pure material balance, no net at all) ahead of the probe -- this -// was previously measurement-only (see eval.c's CALIBRATE_MARGIN_SAFETY -// block) and is now wired into the real Eval() path for the first time. +// value for every bucket instead". g_fUseNnueSuperLazy gates an even +// cheaper pre-filter (pure material balance, no net at all) ahead of +// the probe. g_iNnueSuperLazyMargin follows the identical convention +// as of 2026-10-20 (was a flat-only override before that date, with no +// per-bucket fit at all): < 0 means "use eval.c's calibrated +// iNnueSuperLazyMarginByMatBucket table", >= 0 means "use this flat cp +// value for every bucket instead". INT g_iNnueLazyMarginOverride = -1; -FLAG g_fUseNnueSuperLazy = FALSE; -SCORE g_iNnueSuperLazyMargin = 0; +// 2026-10-20: super-lazy defaults ON -- measured a clean win (nps up, +// no consistent solve-rate cost) across all three curated ECM suites +// at both sd 10 and sn 5000000, using the calibrated per-bucket +// iNnueSuperLazyMarginByMatBucket table (eval.c), unlike the lazy +// probe gate just above, which stays off by default (measured net +// negative -- see its own table's comment in eval.c for the full +// story). Still only takes effect when g_fUseNnueEval is also on. +FLAG g_fUseNnueSuperLazy = TRUE; +SCORE g_iNnueSuperLazyMargin = -1; FLAG @@ -554,5 +590,11 @@ Return value: fSum += v * g_rgfNnueProbeWeight[NNUE_ACCUMULATOR_SIZE + i]; } - return (SCORE)(fSum * 100.0f); + // fSum approximates NnueForward's pre-calibration raw fOut (it was + // fit by ridge regression against exactly that target -- see + // docs/NNUE.md's "Lazy-evaluation tiering plan") -- apply the same + // (g_fNnueCalibA, g_fNnueCalibB) correction so this stays directly + // comparable to NnueForward's actual output, per this function's + // own doc comment above. + return (SCORE)((g_fNnueCalibA + g_fNnueCalibB * fSum) * 100.0f); } @@ -628,6 +628,59 @@ Return value: #endif } #endif +#ifdef EVAL_TIME + // 2026-10-20: NNUE's own per-call latency, independent of the + // LAZY_EVAL block above (NNUE is a runtime g_fUseNnueEval switch, + // not a #ifdef LAZY_EVAL path, so it never fed that block's + // counters). Direct answer to "is the lazy-probe gate paying for + // itself": the gate costs u64CyclesNnueProbeCall on EVERY attempt + // (hit or miss -- u64NnueProbeAttempts, not just u64LazyEvals' + // hit count) and only SAVES a full-forward call on a hit, so the + // gate is worth it only if (hit rate) * (forward cost - probe + // cost) exceeds (miss rate) * (probe cost). + // + // Caveat printed alongside rather than silently baked in: each + // TIMED_EVAL_CALL is a pair of back-to-back SystemReadTimeStamp- + // Counter() (rdtsc) reads, and rdtsc itself isn't free (commonly + // cited around ~80 cycles, more with the serializing behavior + // some cores give it) -- for NnueForward's thousands of cycles + // that overhead is noise, but for NnueProbeScore's much smaller + // 512-wide dot product it may not be. Measure the pair's own + // overhead right here (an empty back-to-back read, no call in + // between) so the raw numbers below can be read against it rather + // than taken as exact. + { + UINT64 u64OverheadStart = SystemReadTimeStampCounter(); + UINT64 u64OverheadEnd = SystemReadTimeStampCounter(); + UINT64 u64PairOverhead = u64OverheadEnd - u64OverheadStart; + UINT64 u64ProbeCyc = ctx->sCounters.tree.u64CyclesNnueProbeCall; + UINT64 u64ProbeAttempts = ctx->sCounters.tree.u64NnueProbeAttempts; + UINT64 u64FwdCyc = ctx->sCounters.tree.u64CyclesNnueForwardCall; + UINT64 u64FwdCalls = ctx->sCounters.tree.u64FullEvals; + double dProbeAvg = u64ProbeAttempts ? + (double)u64ProbeCyc / (double)u64ProbeAttempts : 0.0; + double dFwdAvg = u64FwdCalls ? + (double)u64FwdCyc / (double)u64FwdCalls : 0.0; + + if ((0 != u64ProbeAttempts) || (0 != u64FwdCalls)) + { + Trace("NNUE eval primitive latency (rdtsc pair overhead ~%" + COMPILER_LONGLONG_UNSIGNED_FORMAT" cyc, subtract for a " + "rough net estimate):\n" + " NnueProbeScore: %10.1f cyc/call (raw, %" + COMPILER_LONGLONG_UNSIGNED_FORMAT" attempts, net ~%10.1f)\n" + " NnueForward: %10.1f cyc/call (raw, %" + COMPILER_LONGLONG_UNSIGNED_FORMAT" calls, net ~%10.1f)\n", + u64PairOverhead, + dProbeAvg, u64ProbeAttempts, + (dProbeAvg > (double)u64PairOverhead) ? + (dProbeAvg - (double)u64PairOverhead) : 0.0, + dFwdAvg, u64FwdCalls, + (dFwdAvg > (double)u64PairOverhead) ? + (dFwdAvg - (double)u64PairOverhead) : 0.0); + } + } +#endif Trace("Extensions: (%u +, %u q+, %u 1mv, %u !kmvs, %u mult+, %u pawn\n" " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n" " check by amount (1/4,1/2,3/4,1 ply): %u %u %u %u\n" |
