diff options
| author | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
| commit | c8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch) | |
| tree | b6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/chess.h | |
| parent | 3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff) | |
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop)
Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm
feature set, int16/int8 quantization, per-ply accumulator backup for
king moves, and a lazy-eval-ready integration into search.c/eval.c.
Adds the full annotate_pgns/ training pipeline (extraction, K-fit/
labeling, quiet filtering, tensor building, training, export) plus an
end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling
(sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop:
a 10k-position list-of-positions benchmark comparing engine eval rank
and move choice against pre-computed Stockfish/lc0 references, much
cheaper than a full self-play match for day-to-day progress checks.
First sd10-matched self-play result against classic HCE: LLR +2.28
(H1=+2.94), -72=119+169 over 360 games, confirming the net is
"smarter per node" at matched depth. A real-time (--st 4) match is
currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x
nps gap against an unrecalibrated search), consistent with
NNUE.md's adoption-criterion section -- pruning/reduction constants
still need retuning for the new eval's score scale before a fair
real-time comparison is possible.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/chess.h')
| -rwxr-xr-x | src/chess.h | 163 |
1 files changed, 142 insertions, 21 deletions
diff --git a/src/chess.h b/src/chess.h index cfe1d4e..3e65e7f 100755 --- a/src/chess.h +++ b/src/chess.h @@ -1630,22 +1630,25 @@ MOVE_SCORING_SCHEME; // ever applied where the accumulator is actually read (the FC head), // never where it's maintained. // -// Deliberately float, not yet the int16 the final architecture -// decision calls for (NNUE.md's "Quantization" section) -- that needs -// a calibrated per-layer scale from the quantization-aware export -// pass, which doesn't exist yet. This branch's job is proving the -// incremental-maintenance plumbing (king-move backup/restore nesting, -// capture/promotion edge cases) is correct; swapping the element type -// is a later, decoupled, mechanical follow-up once real quantized -// weights exist -- not something to conflate with debugging the -// harder plumbing now. +// Int32, not float: the embedding table (g_rgfNnueWeight) is +// quantized to SHORT at FT_SCALE (see export_nnue_weights.py / +// g_fNnueFtScale), and summing up to 32 active features' SHORT rows +// can exceed SHORT's range even though each individual row value +// fits -- measured worst-case real accumulator magnitude (NNUE.md's +// "Quantization safety-checked" note) is ~26 in float-equivalent +// units, i.e. ~106,500 at the chosen scale, well past MAX_SHORT. +// INT headroom makes this a non-issue. Converted back to float +// (divided by g_fNnueFtScale) only where the accumulator is actually +// read (the FC head, nnueeval.c's NnueForward) -- never where it's +// maintained, so incremental MakeMove/UnmakeMove updates stay purely +// additive integer arithmetic (exact, no float drift). #define NNUE_ACCUMULATOR_SIZE (256) // L1, nnue_model.py #define NNUE_L2 (32) // nnue_model.py's L2 #define NNUE_L3 (32) // nnue_model.py's L3 typedef struct _NNUE_ACCUMULATOR { - float rgfValue[NNUE_ACCUMULATOR_SIZE]; + INT rgiValue[NNUE_ACCUMULATOR_SIZE]; } NNUE_ACCUMULATOR; @@ -2127,6 +2130,18 @@ typedef struct _GAME_OPTIONS FLAG fStatusLine; FLAG fFastScript; INT iResignThreshold; + // Minimum elapsed time (seconds, since this move's search started) + // before UtilPrintPV (util.c) will emit a PV line at all -- default + // 0.35 matches the historical hardcoded constant. 2026-10-16: a + // search that finishes a move's ENTIRE iterative-deepening loop + // faster than this (common at low sd/st, e.g. scripted self-play + // corpus generation) gets NO PV line at all, not a delayed one -- + // match_play.py's "no eval captured for this move" case. Exposed as + // a runtime "set PostThrottle <seconds>" variable (vars.c) rather + // than hand-editing the constant, specifically so a fast scripted + // run can set it to 0 without changing default interactive/xboard + // behavior or risking a forgotten permanent edit. + double dPostThrottleSec; enum { @@ -2307,13 +2322,20 @@ NnueFeatureIndex(ULONG uPerspective, COOR cKingOwn, COOR cPieceSquare, PIECE pPi return idx; } -// Placeholder embedding table -- NOT yet loaded from a real trained -// checkpoint (see docs/NNUE.md's "not yet built: a weight-loading -// mechanism"). Zero-initialized by C's own static/global default, so -// every accumulator value is 0 until real weights are loaded; this is -// enough to prove the incremental-maintenance plumbing is correct. -extern float g_rgfNnueWeight[NNUE_DIMENSIONS][NNUE_ACCUMULATOR_SIZE]; -extern float g_rgfNnueBias[NNUE_ACCUMULATOR_SIZE]; +// Embedding table, quantized to SHORT (see docs/NNUE.md's +// "Quantization plan" -- this is the per-move hot path table that +// dominates memory bandwidth; halving it from float to SHORT is the +// whole point). Zero-initialized by C's own static/global default +// until a real exported checkpoint is loaded via NnueLoadWeights. +// g_fNnueFtScale is the fixed-point scale applied by +// export_nnue_weights.py's QuantizeFt before rounding to SHORT; +// NnueForward divides it back out when converting the accumulator +// (NNUE_ACCUMULATOR.rgiValue, INT) to float for the FC head. Defaults +// to 1.0f so the all-zero placeholder table behaves sanely (0/1.0 == +// 0) before any real weights are loaded. +extern SHORT g_rgfNnueWeight[NNUE_DIMENSIONS][NNUE_ACCUMULATOR_SIZE]; +extern SHORT g_rgfNnueBias[NNUE_ACCUMULATOR_SIZE]; +extern float g_fNnueFtScale; void NnueAccumAddFeature(NNUE_ACCUMULATOR *pAccum, ULONG uPerspective, @@ -2336,12 +2358,26 @@ NnueAccumRecompute(NNUE_ACCUMULATOR *pAccum, POSITION *pos, ULONG uPerspective); // (callers should, before trusting its output for anything beyond // plumbing validation). // -extern float g_rgfNnueFc1Weight[NNUE_L2][2 * NNUE_ACCUMULATOR_SIZE]; +// FC head weights, quantized to CHAR (int8, signed) per +// docs/NNUE.md's "Quantization plan" step 2 -- biases stay float32 +// (tiny arrays, added post-dequant, not worth quantizing). +// g_rgfNnueActScale[0..2] (AS1,AS2,AS3) are the per-stage uint8 +// fixed-point scales used to requantize each ReLU'd activation before +// the next layer's int8 dot product; g_rgfNnueWeightScale[0..2] +// (WS1,WS2,WS3) are the matching per-layer weight scales. Both are +// read from the export file's header (NnueLoadWeights) -- see +// annotate_pgns/export_nnue_weights.py's CalibrateActivationScales, +// which derives AS1-3 from real sampled positions (not a theoretical +// bound) since these activations' true range isn't knowable from the +// weights alone. +extern CHAR g_rgfNnueFc1Weight[NNUE_L2][2 * NNUE_ACCUMULATOR_SIZE]; extern float g_rgfNnueFc1Bias[NNUE_L2]; -extern float g_rgfNnueFc2Weight[NNUE_L3][NNUE_L2]; +extern CHAR g_rgfNnueFc2Weight[NNUE_L3][NNUE_L2]; extern float g_rgfNnueFc2Bias[NNUE_L3]; -extern float g_rgfNnueFc3Weight[NNUE_L3]; +extern CHAR g_rgfNnueFc3Weight[NNUE_L3]; extern float g_rgfNnueFc3Bias; +extern float g_rgfNnueActScale[3]; +extern float g_rgfNnueWeightScale[3]; extern FLAG g_fNnueWeightsLoaded; extern FLAG g_fUseNnueEval; @@ -2351,6 +2387,41 @@ NnueLoadWeights(CHAR *szFilename); SCORE NnueForward(SEARCHER_THREAD_CONTEXT *ctx); +void +NnueDumpActivationStats(SEARCHER_THREAD_CONTEXT *ctx); + +// Calibration-only lazy-eval probe (docs/NNUE.md's "Lazy-evaluation +// tiering plan") -- a linear regression fit over fc1's own input (the +// ReLU'd, concatenated accumulator), separate from the production +// .nnue weight file/format entirely. NnueProbeScore is cheap (one +// 512-wide dot product, no fc1/fc2/fc3) and exists purely to measure +// how well it tracks NnueForward's real output, via the +// CALIBRATE_MARGIN_SAFETY harness in eval.c -- not yet used to skip +// any real computation. +extern FLAG g_fNnueProbeLoaded; + +// Separate from g_fUseNnueEval/g_fNnueProbeLoaded -- controls whether +// Eval()'s NNUE short-circuit actually TRUSTS the probe-based gate to +// skip NnueForward (vs. just measuring it under CALIBRATE_MARGIN_ +// SAFETY). Defaults FALSE, same convention as g_fUseNnueEval itself -- +// an explicit opt-in, not auto-enabled just because weights/probe are +// loaded. See docs/NNUE.md's "Lazy-evaluation tiering plan". +extern FLAG g_fUseNnueLazyProbe; + +// Runtime experimentation knobs -- see nnueeval.c's comment. Flat +// override for the lazy probe's margin (< 0 = use the calibrated +// per-bucket table) and the super-lazy (material-only, no net) +// pre-filter's own on/off + margin. +extern INT g_iNnueLazyMarginOverride; +extern FLAG g_fUseNnueSuperLazy; +extern SCORE g_iNnueSuperLazyMargin; + +FLAG +NnueProbeLoadWeights(CHAR *szFilename); + +SCORE +NnueProbeScore(SEARCHER_THREAD_CONTEXT *ctx); + #ifdef PERF_COUNTERS #define INC(x) ((x) += 1) #else @@ -3925,7 +3996,19 @@ IsDraw(SEARCHER_THREAD_CONTEXT *ctx); #define EVAL_POSITIONAL_SOURCE_FULL (0) #define EVAL_POSITIONAL_SOURCE_LAZY (1) #define EVAL_POSITIONAL_SOURCE_SUPERLAZY (2) -#define EVAL_POSITIONAL_SOURCE_COUNT (3) +// Added 2026-10-16 (docs/NNUE.md's "Lazy-evaluation tiering plan"): +// Eval()'s NNUE short-circuit never set uLastPositionalSource at all +// before this -- every NNUE-sourced score silently inherited whatever +// stale value a prior classic-Eval() call left in that ply's PLY_INFO +// slot (or the C zero-init default, EVAL_POSITIONAL_SOURCE_FULL, +// if none ever ran), so QSearch futility pruning was using an +// unexamined, accidentally-conservative margin for every NNUE score, +// with no distinction between the full forward pass (exact) and the +// lazy accumulator-probe exit (real, measured error). These two give +// each NNUE source its own tier, same as the three classic ones. +#define EVAL_POSITIONAL_SOURCE_NNUE_FULL (3) +#define EVAL_POSITIONAL_SOURCE_NNUE_LAZY (4) +#define EVAL_POSITIONAL_SOURCE_COUNT (5) // Per-tier qsearch futility base margins -- see search.c's // FUTILITY_BASE_MARGIN_BY_SOURCE for the full derivation. Named here @@ -3967,6 +4050,44 @@ IsDraw(SEARCHER_THREAD_CONTEXT *ctx); // qsearchfutility` if iPositional's construction changes again. #define FUTILITY_BASE_MARGIN_LAZY (350) #define FUTILITY_BASE_MARGIN_SUPERLAZY (175) +// Placeholder starting points, 2026-10-16 -- NOT yet calibrated via +// `calibrate qsearchfutility` against real data (see +// EVAL_POSITIONAL_SOURCE_NNUE_FULL/NNUE_LAZY's own comment). NNUE_FULL +// starts equal to classic FULL (both are exact, non-approximate static +// evals -- a reasonable prior pending real measurement); NNUE_LAZY +// starts equal to classic LAZY (both are an approximate exit one tier +// down from exact). Re-derive both for real before trusting them. +// 2026-10-16: first real data (twic_sample.ep_, 1500 positions, sd 6) +// showed NNUE_FULL's 7.41% surprise rate at 450 in the checking- +// capture/promo gate. Widening to 650 was then tried and tested as +// NO-OP (identical 7.41%/n) -- root-caused to iFutility's MAX0 clamp +// saturating this source's population 100.00% of the time (mean +// pre-clamp -589.3) -- the margin constant is never actually read for +// ~all of this test pool, so neither 450 nor 650 is "validated," both +// are simply untested by this data. Reverted to 450 (matches classic +// FULL) as the more defensible default pending real evidence either +// way: when NNUE_FULL surprises DO occur (in whatever small +// non-saturated slice exists, not yet isolated), their magnitude runs +// smaller than classic FULL's own surprises (mean 477.5 vs 938.7cp, +// >100cp 9.88% vs 28.74%, twic_sample.ep_ sd6) -- no evidence NNUE's +// rare misses are more expensive, so no reason to prefer a wider, +// unvalidated number over HCE's own long-trusted one. +// +// TODO: find and calibrate against the actual non-saturated NNUE_FULL +// population specifically (likely nodes where iEval sits close to +// iAlpha rather than comfortably above it -- the saturated case is +// "iEval way above iAlpha", so look at the complement) before trusting +// any NNUE_FULL margin as real, NNUE-specific-validated rather than a +// borrowed HCE default. +// +// NNUE_LAZY=200 (tightened from 350) IS genuinely validated -- real +// headroom confirmed (only 24.76% of its population even hits the +// clamp, unlike NNUE_FULL's 100%), surprise rate landed in a sane +// 1.3-2.1% range on real data, and its own surprise magnitudes +// (mean 5085.3, >100cp 19.45%) run well below classic regular lazy's +// (mean 11030.2, >100cp 69.73%). Keep this one. +#define FUTILITY_BASE_MARGIN_NNUE_FULL (450) +#define FUTILITY_BASE_MARGIN_NNUE_LAZY (200) // Measured: disabling this entirely (see lmr_testing/RESULTS.md) is a // clear net loss across ringers/confident_quick/hard_quick, so IID itself |
