diff options
| author | Scott Gasch <[email protected]> | 2026-10-15 16:26:09 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-15 16:26:09 -0700 |
| commit | 1ea81bae240912efb8cebabae35fe58bc5d12afb (patch) | |
| tree | e43b3bbb179601595a47f7c5c4661a297a8b4664 /src/nnueeval.c | |
| parent | 3d55dc94497661b1577109b42a94cc3dba1860fb (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.c | 210 |
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); +} |
