summaryrefslogtreecommitdiff
path: root/src/chess.h
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/chess.h
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (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-xsrc/chess.h163
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