summaryrefslogtreecommitdiff
path: root/src/nnueeval.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-15 16:26:09 -0700
committerScott Gasch <[email protected]>2026-10-15 16:26:09 -0700
commit1ea81bae240912efb8cebabae35fe58bc5d12afb (patch)
treee43b3bbb179601595a47f7c5c4661a297a8b4664 /src/nnueeval.c
parent3d55dc94497661b1577109b42a94cc3dba1860fb (diff)
Wire NNUE eval into real search path; fix critical perf regression
Load-and-call infra: export_nnue_weights.py-produced weight files can now be loaded at runtime (nnueeval.c's NnueLoadWeights, "nnue load" command, --nnueweights CLI flag for scripted match-play), and the FC head (NnueForward) runs the real forward pass against the live accumulator. Real integration: Eval() now short-circuits to NnueForward when g_fUseNnueEval && g_fNnueWeightsLoaded, so ./typhoon can actually play on the NNUE eval -- ./eval_tune/test_vs_head.sh works unmodified to compare it against the old HCE. ReInitializeSearcherContext (root.c) gained a defensive accumulator recompute hook covering setboard's downstream effect on the next go/ponder; it only fires when both kings are validly placed, since testrecogn.c's synthetic positions don't always have one at the moment this is called. Critical fix: the NNUE accumulator maintenance added to move.c's 8 primitives (and MakeMove/UnmakeMove's king-move backup/restore) was running unconditionally, on the mistaken assumption this was cheap. It cost ~50% of engine throughput even with NNUE off (673k nps measured vs the expected 1.4-1.5M baseline). Gated all of it behind g_fUseNnueEval; classic HCE is back to ~1.52M nps, NNUE eval runs at a fair ~409k nps (expected for an unquantized float32 forward pass). This gating also exposed that testnnue.c's TestNnueAccumulator never set g_fUseNnueEval, so its own direct NnueAccumRecompute calls raced ahead of now-gated-off incremental maintenance; fixed by having the test set/restore the flag around its run. board.c: added en passant legality check to IsBoardLegal (rank vs side-to-move, origin-square occupancy) -- a real, previously-latent bug surfaced while building the NNUE test fixtures, unrelated to NNUE itself. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/nnueeval.c')
-rw-r--r--src/nnueeval.c210
1 files changed, 210 insertions, 0 deletions
diff --git a/src/nnueeval.c b/src/nnueeval.c
new file mode 100644
index 0000000..6ee57d5
--- /dev/null
+++ b/src/nnueeval.c
@@ -0,0 +1,210 @@
+/**
+
+Copyright (c) Scott Gasch
+
+Module Name:
+
+ nnueeval.c
+
+Abstract:
+
+ The FC head (2*L1 -> L2 -> L3 -> 1) that sits on top of the two
+ accumulator halves maintained by nnueaccum.c, plus the loader for
+ a real trained checkpoint exported by annotate_pgns/
+ export_nnue_weights.py. Reference-only right now -- not wired
+ into Eval()/search, just enough to prove weights can actually be
+ loaded and forward-propagated against a real position (see
+ command.c's "nnue load"/"dump nnueeval").
+
+ Not hot-path (unlike nnueaccum.c's Add/Remove, called from every
+ MakeMove) -- this only runs once per position actually evaluated,
+ so it's written straight-line/unoptimized, same spirit as
+ nnuefeatures.c's reference dump.
+
+Author:
+
+ Scott Gasch ([email protected]) 15 Oct 2026
+
+**/
+
+#include "chess.h"
+
+float g_rgfNnueFc1Weight[NNUE_L2][2 * NNUE_ACCUMULATOR_SIZE];
+float g_rgfNnueFc1Bias[NNUE_L2];
+float g_rgfNnueFc2Weight[NNUE_L3][NNUE_L2];
+float g_rgfNnueFc2Bias[NNUE_L3];
+float g_rgfNnueFc3Weight[NNUE_L3];
+float g_rgfNnueFc3Bias;
+FLAG g_fNnueWeightsLoaded = FALSE;
+FLAG g_fUseNnueEval = FALSE;
+
+
+FLAG
+NnueLoadWeights(CHAR *szFilename)
+/**
+
+Routine description:
+
+ Loads a weights file written by annotate_pgns/
+ export_nnue_weights.py: a 4-byte magic, a 4xuint32 dimension
+ header (sanity-checked against this build's own NNUE_DIMENSIONS/
+ NNUE_ACCUMULATOR_SIZE/NNUE_L2/NNUE_L3 so a mismatched export can't
+ silently misread), then the eight weight/bias arrays in a fixed
+ order. g_fNnueWeightsLoaded is FALSE on any failure (including a
+ partial read -- no half-loaded state is left live).
+
+Parameters:
+
+ CHAR *szFilename
+
+Return value:
+
+ FLAG : TRUE on success
+
+**/
+{
+ FILE *fp;
+ CHAR szMagic[4];
+ ULONG rguDims[4];
+
+ g_fNnueWeightsLoaded = FALSE;
+ fp = fopen(szFilename, "rb");
+ if (NULL == fp)
+ {
+ Trace("NnueLoadWeights: could not open %s\n", szFilename);
+ return FALSE;
+ }
+
+ if ((1 != fread(szMagic, 4, 1, fp)) ||
+ (0 != memcmp(szMagic, "NNU1", 4)))
+ {
+ Trace("NnueLoadWeights: %s is not a valid NNUE weights file "
+ "(bad magic)\n", szFilename);
+ fclose(fp);
+ return FALSE;
+ }
+
+ if (1 != fread(rguDims, sizeof(rguDims), 1, fp))
+ {
+ Trace("NnueLoadWeights: %s truncated (header)\n", szFilename);
+ fclose(fp);
+ return FALSE;
+ }
+ if ((rguDims[0] != NNUE_DIMENSIONS) ||
+ (rguDims[1] != NNUE_ACCUMULATOR_SIZE) ||
+ (rguDims[2] != NNUE_L2) ||
+ (rguDims[3] != NNUE_L3))
+ {
+ Trace("NnueLoadWeights: %s dims (%lu,%lu,%lu,%lu) don't match "
+ "this build's (%lu,%lu,%lu,%lu)\n", szFilename,
+ rguDims[0], rguDims[1], rguDims[2], rguDims[3],
+ (ULONG)NNUE_DIMENSIONS, (ULONG)NNUE_ACCUMULATOR_SIZE,
+ (ULONG)NNUE_L2, (ULONG)NNUE_L3);
+ fclose(fp);
+ return FALSE;
+ }
+
+#define READ_OR_FAIL(ptr, count) \
+ do { \
+ if ((size_t)(count) != fread((ptr), sizeof(float), (count), fp)) \
+ { \
+ Trace("NnueLoadWeights: %s truncated\n", szFilename); \
+ fclose(fp); \
+ return FALSE; \
+ } \
+ } while (0)
+
+ READ_OR_FAIL(g_rgfNnueWeight, (size_t)NNUE_DIMENSIONS * NNUE_ACCUMULATOR_SIZE);
+ READ_OR_FAIL(g_rgfNnueBias, NNUE_ACCUMULATOR_SIZE);
+ READ_OR_FAIL(g_rgfNnueFc1Weight, (size_t)NNUE_L2 * 2 * NNUE_ACCUMULATOR_SIZE);
+ READ_OR_FAIL(g_rgfNnueFc1Bias, NNUE_L2);
+ READ_OR_FAIL(g_rgfNnueFc2Weight, (size_t)NNUE_L3 * NNUE_L2);
+ READ_OR_FAIL(g_rgfNnueFc2Bias, NNUE_L3);
+ READ_OR_FAIL(g_rgfNnueFc3Weight, NNUE_L3);
+ READ_OR_FAIL(&g_rgfNnueFc3Bias, 1);
+
+#undef READ_OR_FAIL
+
+ fclose(fp);
+ g_fNnueWeightsLoaded = TRUE;
+ Trace("NnueLoadWeights: loaded %s\n", szFilename);
+ return TRUE;
+}
+
+
+SCORE
+NnueForward(SEARCHER_THREAD_CONTEXT *ctx)
+/**
+
+Routine description:
+
+ Runs the full forward pass -- ReLU(accumulator), own-perspective-
+ first concatenation by side to move (matching nnue_model.py's own
+ convention so the single scalar output is side-to-move-relative
+ by construction), then the dense 2*L1 -> L2 -> L3 -> 1 FC head --
+ against ctx->sNnueAccum as it currently stands. Caller's
+ responsibility to make sure that accumulator is actually correct
+ for ctx->sPosition first (a fresh NnueAccumRecompute for both
+ colors, if there's any doubt) -- this function trusts it as
+ given.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx
+
+Return value:
+
+ SCORE : side-to-move-relative, in centipawns (the net's own raw
+ output is in pawn units, same scale as the training label's
+ score field -- see annotate_pgns/fit_k_and_label.py)
+
+**/
+{
+ POSITION *pos = &ctx->sPosition;
+ ULONG uStm = pos->uToMove;
+ ULONG uOther = FLIP(uStm);
+ float rgfConcat[2 * NNUE_ACCUMULATOR_SIZE];
+ float rgfL2[NNUE_L2];
+ float rgfL3[NNUE_L3];
+ float fOut;
+ ULONG i, j;
+
+ for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++)
+ {
+ float v = ctx->sNnueAccum[uStm].rgfValue[i];
+ rgfConcat[i] = (v > 0.0f) ? v : 0.0f;
+ }
+ for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++)
+ {
+ float v = ctx->sNnueAccum[uOther].rgfValue[i];
+ rgfConcat[NNUE_ACCUMULATOR_SIZE + i] = (v > 0.0f) ? v : 0.0f;
+ }
+
+ for (j = 0; j < NNUE_L2; j++)
+ {
+ float s = g_rgfNnueFc1Bias[j];
+ for (i = 0; i < 2 * NNUE_ACCUMULATOR_SIZE; i++)
+ {
+ s += g_rgfNnueFc1Weight[j][i] * rgfConcat[i];
+ }
+ rgfL2[j] = (s > 0.0f) ? s : 0.0f;
+ }
+
+ for (j = 0; j < NNUE_L3; j++)
+ {
+ float s = g_rgfNnueFc2Bias[j];
+ for (i = 0; i < NNUE_L2; i++)
+ {
+ s += g_rgfNnueFc2Weight[j][i] * rgfL2[i];
+ }
+ rgfL3[j] = (s > 0.0f) ? s : 0.0f;
+ }
+
+ fOut = g_rgfNnueFc3Bias;
+ for (i = 0; i < NNUE_L3; i++)
+ {
+ fOut += g_rgfNnueFc3Weight[i] * rgfL3[i];
+ }
+
+ return (SCORE)(fOut * 100.0f);
+}