diff options
| author | Scott Gasch <[email protected]> | 2026-10-15 15:21:26 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-15 15:21:26 -0700 |
| commit | 2bf4eb27a473089b9b30726a678fbac27d0f8a69 (patch) | |
| tree | 23e7271eab238cf2fa97128d902c0c828e7d1583 /src | |
| parent | ac7572166bd24868fcaf5980a91bb82fe61ba677 (diff) | |
NNUE accumulator incremental maintenance: MakeMove/UnmakeMove hookup
Wires the live NNUE accumulator (SEARCHER_THREAD_CONTEXT.sNnueAccum[2],
added in the previous commit) into real incremental maintenance,
hooked directly into the piece-movement primitives rather than
re-deriving move type from MOVE in a separate pass:
- chess.h: shared HalfKAv2_hm feature-index formula/constants
(NnueFeatureIndex and friends), moved here from nnuefeatures.c so
the reference dump and the real hot-path code can never drift
apart. Prototypes for NnueAccumAddFeature/RemoveFeature/Recompute
and the (still placeholder, zero-initialized) embedding table.
LiftPiece/PlacePiece/SlidePiece/SlidePawn/SlidePieceWithoutSigs
signatures gain an NNUE_ACCUMULATOR sNnueAccum[2] parameter.
- nnueaccum.c (new): the embedding table storage and the three
accumulator operations. Written as straight-line 256-float loops,
structured for -O3 -ffast-math auto-vectorization on this box's
AVX2+FMA target; explicit SIMD intrinsics and real weight loading
are separate, deliberately deferred follow-ups.
- move.c: all 8 Lift/Place/Slide(WithoutSigs) primitives now keep
sNnueAccum incrementally correct for both perspectives on every
call -- except when the piece is a king, where each primitive skips
its own-color perspective entirely (that perspective needs a full
recompute, not a delta, since every HalfKAv2_hm feature in it is
own-king-relative; computing an incremental update for it only to
discard it unread would be wasted work). MakeMove backs up that
perspective into the current ply's PLY_INFO.sNnueAccumBackup before
a king moves (castling or a plain step) and overwrites it via
NnueAccumRecompute once the board reflects the final position;
UnmakeMove restores it from the same backup slot. DEBUG builds tag
the backup with the color it belongs to and poison it after
consumption, release builds carry no overhead for either.
- nnuefeatures.c: now a thin board-walking wrapper around the shared
chess.h formula instead of its own duplicated copy.
- testmove.c: TestLiftPlaceSlidePiece passes a local (unread) dummy
accumulator pair -- the only caller of these primitives outside
move.c, and it never needed NNUE awareness, just a real argument.
Verified via the full precommit_check.sh (all 4 gates, including the
new combined DEBUG=1 TEST=1 self-test, which exercises MakeMove/
UnmakeMove heavily via TestMoveGenerator's perft counts and
TestLegalMoveGenerator) -- confirms nothing crashes and no existing
invariant breaks. Does NOT yet confirm the actual property that
matters (incrementally-maintained accumulator == recompute-from-
scratch) -- no test exercises that yet; next step.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/GNUmakefile | 2 | ||||
| -rw-r--r-- | src/TODO | 5 | ||||
| -rwxr-xr-x | src/chess.h | 137 | ||||
| -rw-r--r-- | src/move.c | 227 | ||||
| -rw-r--r-- | src/nnueaccum.c | 148 | ||||
| -rw-r--r-- | src/nnuefeatures.c | 124 | ||||
| -rw-r--r-- | src/testmove.c | 17 |
7 files changed, 493 insertions, 167 deletions
diff --git a/src/GNUmakefile b/src/GNUmakefile index 66455e6..3cfee6c 100644 --- a/src/GNUmakefile +++ b/src/GNUmakefile @@ -345,7 +345,7 @@ OBJS = main.o root.o search.o searchsup.o draw.o dynamic.o \ input.o vars.o util.o unix.o gamelist.o mersenne.o \ sig.o piece.o ics.o san.o fen.o book.o bench.o board.o \ data.o probe.o fathom.o recogn.o list.o x64.o \ - nnuefeatures.o + nnuefeatures.o nnueaccum.o ifdef TEST # ---> .o, not .c! <--- @@ -66,3 +66,8 @@ Poor king play -- tell it to support own passers or blockade enemy passers once king activity phase starts. Self-play lite and heavy. + +Valgrind in debug_smoke_test.sh. + +Remove all line-ending whitespace in one commit. + diff --git a/src/chess.h b/src/chess.h index 29833fb..d0cbd34 100755 --- a/src/chess.h +++ b/src/chess.h @@ -1648,6 +1648,11 @@ typedef struct _NNUE_ACCUMULATOR } NNUE_ACCUMULATOR; +// +// NNUE feature-index formula and accumulator-maintenance prototypes +// live further down in this header (after ASSERT is defined) -- see +// the block near ASSERT's own definition. + typedef struct _PLY_INFO { #ifdef DEBUG @@ -2205,6 +2210,121 @@ _assert(CHAR *szFile, ULONG uLine); #define VERIFY(x) x; #endif // DEBUG +// HalfKAv2_hm feature-index formula, shared verbatim between the +// reference-only dump path (nnuefeatures.c's DumpNnueFeatures) and the +// real, hot-path MakeMove/UnmakeMove accumulator maintenance (move.c) +// -- defined once, here, so the two can never silently drift apart. +// See docs/NNUE.md's "Canonical HalfKAv2_hm feature-indexing spec" +// for the derivation; this is just the formula, no commentary +// repeated here. +// +#define NNUE_SQUARE_NB (64) +#define NNUE_PS_W_PAWN (0 * NNUE_SQUARE_NB) +#define NNUE_PS_B_PAWN (1 * NNUE_SQUARE_NB) +#define NNUE_PS_W_KNIGHT (2 * NNUE_SQUARE_NB) +#define NNUE_PS_B_KNIGHT (3 * NNUE_SQUARE_NB) +#define NNUE_PS_W_BISHOP (4 * NNUE_SQUARE_NB) +#define NNUE_PS_B_BISHOP (5 * NNUE_SQUARE_NB) +#define NNUE_PS_W_ROOK (6 * NNUE_SQUARE_NB) +#define NNUE_PS_B_ROOK (7 * NNUE_SQUARE_NB) +#define NNUE_PS_W_QUEEN (8 * NNUE_SQUARE_NB) +#define NNUE_PS_B_QUEEN (9 * NNUE_SQUARE_NB) +#define NNUE_PS_KING (10 * NNUE_SQUARE_NB) +#define NNUE_PS_NB (11 * NNUE_SQUARE_NB) // 704 +#define NNUE_DIMENSIONS ((NNUE_SQUARE_NB * NNUE_PS_NB) / 2) // 22528 + +static int FORCEINLINE +_NnueCoorToSq64(COOR c) +{ + return (int)FILE(c) + 8 * ((int)RANK(c) - 1); +} + +static int FORCEINLINE +_NnueKingBucket(ULONG uPerspective, int sq64) +{ + int file = sq64 & 7; + int rank0 = sq64 >> 3; + int fileQuartet = (file < 4) ? file : (7 - file); + int rankGroup = (uPerspective == WHITE) ? (7 - rank0) : rank0; + return rankGroup * 4 + fileQuartet; +} + +static int FORCEINLINE +_NnueOrientMask(ULONG uPerspective, int ksq64) +{ + int file = ksq64 & 7; + if (uPerspective == WHITE) + { + return (file < 4) ? 7 : 0; + } + return (file < 4) ? 63 : 56; +} + +static int FORCEINLINE +_NnuePiecePlane(ULONG uPerspective, ULONG uPieceColor, ULONG uPieceType) +{ + FLAG fOwn; + + if (uPieceType == KING) + { + return NNUE_PS_KING; + } + fOwn = (uPieceColor == uPerspective); + switch (uPieceType) + { + case PAWN: return fOwn ? NNUE_PS_W_PAWN : NNUE_PS_B_PAWN; + case KNIGHT: return fOwn ? NNUE_PS_W_KNIGHT : NNUE_PS_B_KNIGHT; + case BISHOP: return fOwn ? NNUE_PS_W_BISHOP : NNUE_PS_B_BISHOP; + case ROOK: return fOwn ? NNUE_PS_W_ROOK : NNUE_PS_B_ROOK; + case QUEEN: return fOwn ? NNUE_PS_W_QUEEN : NNUE_PS_B_QUEEN; + default: + ASSERT(FALSE); + return 0; + } +} + +// Full feature-index formula: given perspective uPerspective, that +// perspective's OWN king square cKingOwn (unoriented, the real board +// square), and some piece pPiece sitting on cPieceSquare, returns the +// active HalfKAv2_hm feature index. cPieceSquare == cKingOwn is valid +// (every king is also a feature, via NNUE_PS_KING) and so is pPiece +// being the OPPONENT's king. __attribute__((unused)) because not +// every TU that includes chess.h calls this directly (some only use +// the higher-level NnueAccum* wrappers in nnueaccum.c) and it's a +// static-in-header function, so each such TU would otherwise warn. +static int FORCEINLINE __attribute__((unused)) +NnueFeatureIndex(ULONG uPerspective, COOR cKingOwn, COOR cPieceSquare, PIECE pPiece) +{ + int ksq64 = _NnueCoorToSq64(cKingOwn); + int orientMask = _NnueOrientMask(uPerspective, ksq64); + int kingBucketOffset = _NnueKingBucket(uPerspective, ksq64) * NNUE_PS_NB; + int sq64 = _NnueCoorToSq64(cPieceSquare); + int idx = (sq64 ^ orientMask) + + _NnuePiecePlane(uPerspective, PIECE_COLOR(pPiece), PIECE_TYPE(pPiece)) + + kingBucketOffset; + ASSERT((idx >= 0) && (idx < NNUE_DIMENSIONS)); + 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]; + +void +NnueAccumAddFeature(NNUE_ACCUMULATOR *pAccum, ULONG uPerspective, + COOR cKingOwn, COOR cPieceSquare, PIECE pPiece); + +void +NnueAccumRemoveFeature(NNUE_ACCUMULATOR *pAccum, ULONG uPerspective, + COOR cKingOwn, COOR cPieceSquare, PIECE pPiece); + +void +NnueAccumRecompute(NNUE_ACCUMULATOR *pAccum, POSITION *pos, ULONG uPerspective); + #ifdef PERF_COUNTERS #define INC(x) ((x) += 1) #else @@ -2791,17 +2911,26 @@ SetRootToInitialPosition(void); // // move.c // +// Each takes the mover's live NNUE_ACCUMULATOR pair (normally +// ctx->sNnueAccum) and keeps it incrementally up to date for whatever +// square(s) this call changes, for BOTH perspectives, unconditionally +// -- including when the piece involved is a king (never true for +// Lift/Place, which never touch kings; see MakeMove/UnmakeMove's own +// comments on why a king move's OWN-perspective accumulator needs a +// full recompute/restore layered on top at the call site, not inside +// these primitives). +// void -SlidePiece(POSITION *pos, COOR cFrom, COOR cTo); +SlidePiece(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]); void -SlidePawn(POSITION *pos, COOR cFrom, COOR cTo); +SlidePawn(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]); PIECE -LiftPiece(POSITION *pos, COOR cSquare); +LiftPiece(POSITION *pos, COOR cSquare, NNUE_ACCUMULATOR sNnueAccum[2]); void -PlacePiece(POSITION *pos, COOR cSquare, PIECE pPiece); +PlacePiece(POSITION *pos, COOR cSquare, PIECE pPiece, NNUE_ACCUMULATOR sNnueAccum[2]); FLAG MakeMove(SEARCHER_THREAD_CONTEXT *ctx, @@ -22,8 +22,8 @@ Revision History: #include "chess.h" -void -SlidePiece(POSITION *pos, COOR cFrom, COOR cTo) +void +SlidePiece(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -36,6 +36,12 @@ Parameters: POSITION *pos : the board COOR cFrom : square moving from COOR cTo : square moving to + NNUE_ACCUMULATOR sNnueAccum[2] : mover's live accumulator pair, + kept incrementally up to date for both perspectives -- even + when p is a king, in which case the caller (MakeMove) is + expected to treat that one perspective's result here as + transient/wrong and immediately overwrite it via + NnueAccumRecompute; see chess.h's prototype comment. Return value: @@ -80,13 +86,38 @@ Return value: pos->rgSquare[cTo].uIndex = INVALID_PIECE_INDEX; #ifdef DEBUG pos->rgSquare[cFrom].uIndex = INVALID_PIECE_INDEX; +#endif + // A king move is about to have ITS OWN perspective fully + // overwritten by a recompute at the MakeMove/UnmakeMove call + // site right after this returns (every HalfKAv2_hm feature in + // that perspective depends on its own king square -- see + // chess.h's NNUE_ACCUMULATOR comment) -- skip that perspective's + // update here entirely rather than computing it only to discard + // it unread. The opponent's perspective is unaffected by whose + // king this is and still needs the ordinary incremental update. + // Single branch, predicts near-perfectly (king moves are a small + // minority of all SlidePiece calls). + if (IS_KING(p)) + { + ULONG xC = FLIP(c); + NnueAccumRemoveFeature(&sNnueAccum[xC], xC, pos->cKingSq[xC], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[xC], xC, pos->cKingSq[xC], cTo, p); + } + else + { + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cTo, p); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cTo, p); + } +#ifdef DEBUG VerifyPositionConsistency(pos, FALSE); #endif } -void -SlidePawn(POSITION *pos, COOR cFrom, COOR cTo) +void +SlidePawn(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -99,6 +130,9 @@ Parameters: POSITION *pos : the board COOR cFrom : square moving from COOR cTo : square moving to + NNUE_ACCUMULATOR sNnueAccum[2] : mover's live accumulator pair -- + see SlidePiece's comment (a pawn is never a king, so there's + no own-perspective-recompute exception to worry about here). Return value: @@ -133,13 +167,19 @@ Return value: pos->rgSquare[cTo].uIndex = INVALID_PIECE_INDEX; #ifdef DEBUG pos->rgSquare[cFrom].uIndex = INVALID_PIECE_INDEX; +#endif + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cTo, p); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cTo, p); +#ifdef DEBUG VerifyPositionConsistency(pos, FALSE); #endif } void -SlidePieceWithoutSigs(POSITION *pos, COOR cFrom, COOR cTo) +SlidePieceWithoutSigs(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -149,11 +189,20 @@ Routine description: of the routine doesn't maintain the consistency of the position signatures and should therefore only be called from UnmakeMove. + Note: unlike signatures, the NNUE accumulator has no equivalent + cheap "restore wholesale from a saved scalar" trick for a + non-king-move's opponent-perspective update, so sNnueAccum is + still kept incrementally correct here even though sigs aren't -- + see SlidePiece's comment for the king-move exception, handled the + same way at the UnmakeMove call site (restore-from-backup + immediately after, for the castling color's own perspective). + Parameters: POSITION *pos : the board COOR cFrom : square moving from COOR cTo : square moving to + NNUE_ACCUMULATOR sNnueAccum[2] : mover's live accumulator pair Return value: @@ -190,11 +239,25 @@ Return value: #ifdef DEBUG pos->rgSquare[cFrom].uIndex = INVALID_PIECE_INDEX; #endif + // See SlidePiece's comment on the same check. + if (IS_KING(p)) + { + ULONG xC = FLIP(c); + NnueAccumRemoveFeature(&sNnueAccum[xC], xC, pos->cKingSq[xC], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[xC], xC, pos->cKingSq[xC], cTo, p); + } + else + { + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cTo, p); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cTo, p); + } } void -SlidePawnWithoutSigs(POSITION *pos, COOR cFrom, COOR cTo) +SlidePawnWithoutSigs(POSITION *pos, COOR cFrom, COOR cTo, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -203,12 +266,15 @@ Routine description: faster than LiftPiece / PlacePiece. Also note that this version of the routine doesn't maintain the consistency of the position signatures and should therefore only be called from UnmakeMove. + See SlidePieceWithoutSigs's comment on why sNnueAccum is still + kept correct here regardless. Parameters: POSITION *pos : the board COOR cFrom : square moving from COOR cTo : square moving to + NNUE_ACCUMULATOR sNnueAccum[2] : mover's live accumulator pair Return value: @@ -241,11 +307,15 @@ Return value: #ifdef DEBUG pos->rgSquare[cFrom].uIndex = INVALID_PIECE_INDEX; #endif + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cTo, p); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cFrom, p); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cTo, p); } PIECE -LiftPiece(POSITION *pos, COOR cSquare) +LiftPiece(POSITION *pos, COOR cSquare, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -257,6 +327,10 @@ Parameters: POSITION *pos, COOR cSquare + NNUE_ACCUMULATOR sNnueAccum[2] : mover's live accumulator pair -- + a king is never lifted (see ASSERT below, kings are never + captured), so there's no own-perspective exception to worry + about here, unlike SlidePiece. Return value: @@ -323,6 +397,8 @@ Return value: pos->bbPieces[color][u] &= ~COOR_TO_BB(cSquare); } + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cSquare, pLifted); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cSquare, pLifted); #ifdef DEBUG VerifyPositionConsistency(pos, FALSE); #endif @@ -331,7 +407,7 @@ Return value: PIECE -LiftPieceWithoutSigs(POSITION *pos, COOR cSquare) +LiftPieceWithoutSigs(POSITION *pos, COOR cSquare, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -409,12 +485,14 @@ Return value: pos->bbPieces[color][u] &= ~COOR_TO_BB(cSquare); } + NnueAccumRemoveFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cSquare, pLifted); + NnueAccumRemoveFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cSquare, pLifted); return(pLifted); } void -PlacePiece(POSITION *pos, COOR cSquare, PIECE pPiece) +PlacePiece(POSITION *pos, COOR cSquare, PIECE pPiece, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -485,6 +563,8 @@ Return value: // pos->rgSquare[cSquare].pPiece = pPiece; + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cSquare, pPiece); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cSquare, pPiece); #ifdef DEBUG VerifyPositionConsistency(pos, FALSE); #endif @@ -492,7 +572,7 @@ Return value: void -PlacePieceWithoutSigs(POSITION *pos, COOR cSquare, PIECE pPiece) +PlacePieceWithoutSigs(POSITION *pos, COOR cSquare, PIECE pPiece, NNUE_ACCUMULATOR sNnueAccum[2]) /** Routine description: @@ -562,10 +642,13 @@ Return value: // Place the piece on the board // pos->rgSquare[cSquare].pPiece = pPiece; + + NnueAccumAddFeature(&sNnueAccum[WHITE], WHITE, pos->cKingSq[WHITE], cSquare, pPiece); + NnueAccumAddFeature(&sNnueAccum[BLACK], BLACK, pos->cKingSq[BLACK], cSquare, pPiece); } -void +void SaveCurrentPositionHistoryInContext(SEARCHER_THREAD_CONTEXT *ctx, MOVE mv) /** @@ -712,10 +795,10 @@ Return value: { ASSERT(!IS_PAWN(pPromoted)); ASSERT(GET_COLOR(pPromoted) == uColor); - pPiece = LiftPiece(pos, mv.cFrom); - pCaptured = LiftPiece(pos, mv.cTo); + pPiece = LiftPiece(pos, mv.cFrom, ctx->sNnueAccum); + pCaptured = LiftPiece(pos, mv.cTo, ctx->sNnueAccum); ASSERT(!pCaptured || (OPPOSITE_COLORS(pCaptured, pPiece))); - PlacePiece(pos, mv.cTo, pPromoted); + PlacePiece(pos, mv.cTo, pPromoted, ctx->sNnueAccum); pos->uFifty = 0; goto update_castle_and_clear_ep; } @@ -727,12 +810,12 @@ Return value: { ASSERT(mv.cTo == pos->cEpSquare); pCaptured = LiftPiece(pos, mv.cTo + - iSign[uColor] * 16); + iSign[uColor] * 16, ctx->sNnueAccum); ASSERT(IS_PAWN(pCaptured)); ASSERT(OPPOSITE_COLORS(pPiece, pCaptured)); ASSERT(IS_EMPTY(pos->rgSquare[mv.cTo].pPiece)); ASSERT(IS_PAWN(pPiece)); - SlidePawn(pos, mv.cFrom, mv.cTo); + SlidePawn(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); pos->uFifty = 0; goto clear_ep; } @@ -750,9 +833,9 @@ Return value: ASSERT(IS_EMPTY(pos->rgSquare[c].pPiece)); ASSERT(IS_EMPTY(pos->rgSquare[mv.cTo].pPiece)); ASSERT(IS_PAWN(pPiece)); - SlidePawn(pos, mv.cFrom, mv.cTo); + SlidePawn(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); pos->uFifty = 0; - + // // Only change the position signature to reflect the // possible en-passant if there's an enemy who can @@ -858,8 +941,29 @@ Return value: ASSERT(IS_EMPTY(pos->rgSquare[mv.cTo].pPiece)); ASSERT(IS_ROOK(pos->rgSquare[cRookFrom].pPiece)); ASSERT(IS_EMPTY(pos->rgSquare[cRookTo].pPiece)); - SlidePiece(pos, mv.cFrom, mv.cTo); // king - SlidePiece(pos, cRookFrom, cRookTo); // rook + // Back up uColor's pre-move accumulator before touching + // the board: every HalfKAv2_hm feature in THAT + // perspective depends on its own king square, so the + // king's move below invalidates the whole thing, not + // just a delta -- see chess.h's NNUE_ACCUMULATOR comment. + // Indexed by ply (not a single shared slot) so nested + // own-king moves at different plies never collide, same + // reasoning as cTrapped/cEnprise's existing per-ply + // indexing. + ctx->sPlyInfo[ctx->uPly].sNnueAccumBackup = ctx->sNnueAccum[uColor]; +#ifdef DEBUG + ctx->sPlyInfo[ctx->uPly].uNnueAccumBackupColor = uColor; +#endif + SlidePiece(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); // king + SlidePiece(pos, cRookFrom, cRookTo, ctx->sNnueAccum); // rook + // Both slides above already skipped uColor's own + // perspective (SlidePiece's own IS_KING check) and kept + // xColor's perspective incrementally correct. Recompute + // uColor's perspective from scratch against the + // now-fully-updated board (king AND rook already in + // their final squares) -- the only correct way to update + // it, not just the cheapest. + NnueAccumRecompute(&ctx->sNnueAccum[uColor], pos, uColor); pos->uFifty = 0; goto clear_ep; } @@ -879,19 +983,30 @@ Return value: if (pCaptured) { pos->uFifty = 0; - pCaptured = LiftPiece(pos, mv.cTo); + pCaptured = LiftPiece(pos, mv.cTo, ctx->sNnueAccum); ASSERT(!IS_KING(pCaptured)); ASSERT(!IS_EMPTY(pCaptured)); ASSERT(OPPOSITE_COLORS(pCaptured, pPiece)); } - if (IS_PAWN(pPiece)) + if (IS_PAWN(pPiece)) { pos->uFifty = 0; - SlidePawn(pos, mv.cFrom, mv.cTo); + SlidePawn(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); + } + else if (IS_KING(pPiece)) + { + // Ordinary (non-castling) king step -- same backup/recompute + // wrapper as the castling branch above, see its comment. + ctx->sPlyInfo[ctx->uPly].sNnueAccumBackup = ctx->sNnueAccum[uColor]; +#ifdef DEBUG + ctx->sPlyInfo[ctx->uPly].uNnueAccumBackupColor = uColor; +#endif + SlidePiece(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); + NnueAccumRecompute(&ctx->sNnueAccum[uColor], pos, uColor); } - else + else { - SlidePiece(pos, mv.cFrom, mv.cTo); + SlidePiece(pos, mv.cFrom, mv.cTo, ctx->sNnueAccum); } update_castle_and_clear_ep: @@ -1156,26 +1271,26 @@ Return value: // No need to call SlidePiece here, the signatures will // get reset to their pre-move state below // - SlidePieceWithoutSigs(pos, D1, A1); // rook - SlidePieceWithoutSigs(pos, C1, E1); // king + SlidePieceWithoutSigs(pos, D1, A1, ctx->sNnueAccum); // rook + SlidePieceWithoutSigs(pos, C1, E1, ctx->sNnueAccum); // king ASSERT(IS_KING(pos->rgSquare[E1].pPiece)); ASSERT(IS_ROOK(pos->rgSquare[A1].pPiece)); break; case G1: - SlidePieceWithoutSigs(pos, F1, H1); // rook - SlidePieceWithoutSigs(pos, G1, E1); // king + SlidePieceWithoutSigs(pos, F1, H1, ctx->sNnueAccum); // rook + SlidePieceWithoutSigs(pos, G1, E1, ctx->sNnueAccum); // king ASSERT(IS_KING(pos->rgSquare[E1].pPiece)); ASSERT(IS_ROOK(pos->rgSquare[H1].pPiece)); break; case C8: - SlidePieceWithoutSigs(pos, D8, A8); // rook - SlidePieceWithoutSigs(pos, C8, E8); // king + SlidePieceWithoutSigs(pos, D8, A8, ctx->sNnueAccum); // rook + SlidePieceWithoutSigs(pos, C8, E8, ctx->sNnueAccum); // king ASSERT(IS_KING(pos->rgSquare[E8].pPiece)); ASSERT(IS_ROOK(pos->rgSquare[A8].pPiece)); break; case G8: - SlidePieceWithoutSigs(pos, F8, H8); // rook - SlidePieceWithoutSigs(pos, G8, E8); // king + SlidePieceWithoutSigs(pos, F8, H8, ctx->sNnueAccum); // rook + SlidePieceWithoutSigs(pos, G8, E8, ctx->sNnueAccum); // king ASSERT(IS_KING(pos->rgSquare[E8].pPiece)); ASSERT(IS_ROOK(pos->rgSquare[H8].pPiece)); break; @@ -1197,21 +1312,29 @@ Return value: // LiftPiece/PlacePiece must be done here to maintain // piece list integrity. // - (void)LiftPieceWithoutSigs(pos, mv.cTo); + (void)LiftPieceWithoutSigs(pos, mv.cTo, ctx->sNnueAccum); pPiece |= 2; pPiece &= 3; ASSERT(pPiece == mv.pMoved); - PlacePieceWithoutSigs(pos, mv.cFrom, pPiece); + PlacePieceWithoutSigs(pos, mv.cFrom, pPiece, ctx->sNnueAccum); } else { if (IS_PAWN(pPiece)) { - SlidePawnWithoutSigs(pos, mv.cTo, mv.cFrom); + SlidePawnWithoutSigs(pos, mv.cTo, mv.cFrom, ctx->sNnueAccum); } else { - SlidePieceWithoutSigs(pos, mv.cTo, mv.cFrom); + // IS_KING(pPiece) here means an ordinary (non-castling) + // king step undo -- SlidePieceWithoutSigs's own + // IS_KING check already skips uColor's own + // perspective; the restore-from-backup for that + // perspective happens once, generically, below + // (restore_saved_position_data), after ctx->uPly's + // decrement lines up with the same ply MakeMove wrote + // the backup to. + SlidePieceWithoutSigs(pos, mv.cTo, mv.cFrom, ctx->sNnueAccum); } } @@ -1237,16 +1360,16 @@ Return value: iSign[GET_COLOR(mv.pMoved)]].pPiece)); PlacePieceWithoutSigs(pos, mv.cTo + 16 * iSign[GET_COLOR(mv.pMoved)], - pCaptured); + pCaptured, ctx->sNnueAccum); } else { // // Normal capture... resurrect the dead piece where the // killer moved. - // + // ASSERT(IS_EMPTY(pos->rgSquare[mv.cTo].pPiece)); - PlacePieceWithoutSigs(pos, mv.cTo, pCaptured); + PlacePieceWithoutSigs(pos, mv.cTo, pCaptured, ctx->sNnueAccum); } } } @@ -1256,6 +1379,30 @@ Return value: ASSERT(ctx->uPly < MAX_PLY_PER_SEARCH); pi = &ctx->sPlyInfo[ctx->uPly]; + + // If mv moved a king (castling or plain step), that color's own + // NNUE accumulator was never incrementally maintained above -- + // every SlidePiece/SlidePieceWithoutSigs call for a king move + // deliberately skips its own perspective (see SlidePiece's + // comment) since MakeMove always overwrote it via + // NnueAccumRecompute instead. Restore it here from the backup + // MakeMove saved at this exact ply before making the move, the + // same way pos->u64NonPawnSig etc. are restored from pi just + // below -- not via any incremental undo, there isn't one. + if ((mv.uMove != 0) && IS_KING(mv.pMoved)) + { + ULONG uMoverColor = GET_COLOR(mv.pMoved); +#ifdef DEBUG + ASSERT(pi->uNnueAccumBackupColor == uMoverColor); +#endif + ctx->sNnueAccum[uMoverColor] = pi->sNnueAccumBackup; +#ifdef DEBUG + // Poison, not a real "clear" anything downstream depends on + // -- see PLY_INFO's sNnueAccumBackup comment (chess.h). + memset(&pi->sNnueAccumBackup, 0xCC, sizeof(pi->sNnueAccumBackup)); +#endif + } + pos->u64NonPawnSig = pi->u64NonPawnSig; pos->u64PawnSig = pi->u64PawnSig; ASSERT((pos->u64NonPawnSig & WHITE) == FLIP(pos->uToMove)); diff --git a/src/nnueaccum.c b/src/nnueaccum.c new file mode 100644 index 0000000..8f01692 --- /dev/null +++ b/src/nnueaccum.c @@ -0,0 +1,148 @@ +/** + +Copyright (c) Scott Gasch + +Module Name: + + nnueaccum.c + +Abstract: + + Real NNUE accumulator maintenance: the embedding-table storage and + the three operations (add one feature's row, remove one feature's + row, recompute an entire perspective from scratch) that move.c's + MakeMove/UnmakeMove hook into LiftPiece/PlacePiece/SlidePiece/ + SlidePawn/SlidePieceWithoutSigs use to keep + SEARCHER_THREAD_CONTEXT.sNnueAccum[2] incrementally up to date. + + Hot path -- called from the piece-movement primitives, which run + millions of times/sec in a real search. Kept branch-light: Add/ + Remove are a single feature-index computation (chess.h's + NnueFeatureIndex, shared with nnuefeatures.c's reference dump) and + a straight-line 256-float add/subtract loop, written so -O3 + -ffast-math (this project's release profile) has the best shot at + auto-vectorizing it on this box's confirmed AVX2+FMA target. + Explicit SIMD intrinsics are an explicitly deferred follow-up (see + docs/NNUE.md) once there's a correct baseline to profile against, + not something to reach for pre-emptively here. + +Author: + + Scott Gasch ([email protected]) 15 Oct 2026 + +**/ + +#include "chess.h" + +// Placeholder embedding table -- see chess.h's extern declaration and +// its own comment. Zero-initialized (C's static/global default), so +// every accumulator is currently all-zero regardless of position; +// real weight loading (reading annotate_pgns/nnue_train.py's trained +// checkpoint) is separate, not-yet-built follow-up work. This is +// sufficient to prove incremental-vs-recompute-from-scratch +// correctness, which only depends on both paths reading the SAME +// table consistently, not on its values being meaningful yet. +float g_rgfNnueWeight[NNUE_DIMENSIONS][NNUE_ACCUMULATOR_SIZE]; +float g_rgfNnueBias[NNUE_ACCUMULATOR_SIZE]; + +void +NnueAccumAddFeature(NNUE_ACCUMULATOR *pAccum, ULONG uPerspective, + COOR cKingOwn, COOR cPieceSquare, PIECE pPiece) +/** + +Routine description: + + Adds one feature's weight row into *pAccum (in place). Hot path. + +**/ +{ + int idx = NnueFeatureIndex(uPerspective, cKingOwn, cPieceSquare, pPiece); + const float *pRow = g_rgfNnueWeight[idx]; + ULONG i; + + for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) + { + pAccum->rgfValue[i] += pRow[i]; + } +} + + +void +NnueAccumRemoveFeature(NNUE_ACCUMULATOR *pAccum, ULONG uPerspective, + COOR cKingOwn, COOR cPieceSquare, PIECE pPiece) +/** + +Routine description: + + Subtracts one feature's weight row from *pAccum (in place). Hot + path -- the exact inverse of NnueAccumAddFeature, so that applying + Add then Remove (or Remove then Add) for the same arguments is a + no-op up to float rounding. + +**/ +{ + int idx = NnueFeatureIndex(uPerspective, cKingOwn, cPieceSquare, pPiece); + const float *pRow = g_rgfNnueWeight[idx]; + ULONG i; + + for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) + { + pAccum->rgfValue[i] -= pRow[i]; + } +} + + +void +NnueAccumRecompute(NNUE_ACCUMULATOR *pAccum, POSITION *pos, ULONG uPerspective) +/** + +Routine description: + + Fully recomputes *pAccum from scratch against the CURRENT board in + *pos, for perspective uPerspective -- the only correct way to + update a perspective after its OWN king moves (every feature's + king-bucket/orientation changes, not just the king's own feature; + see chess.h's NNUE_ACCUMULATOR comment). Not hot in the same sense + as Add/Remove -- only called on king moves, a small fraction of + all moves in a tree -- so this walks the whole board rather than + needing a piece list. + +Parameters: + + NNUE_ACCUMULATOR *pAccum : accumulator to overwrite + POSITION *pos : current board (already reflects the move) + ULONG uPerspective : which color's accumulator this is + +**/ +{ + COOR cKing = pos->cKingSq[uPerspective]; + int f, r; + ULONG i; + + ASSERT(IS_ON_BOARD(cKing)); + ASSERT(IS_KING(pos->rgSquare[cKing].pPiece)); + ASSERT(PIECE_COLOR(pos->rgSquare[cKing].pPiece) == uPerspective); + + for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) + { + pAccum->rgfValue[i] = g_rgfNnueBias[i]; + } + + // 0x88 COORs aren't a simple monotonic range (A1=0x70 > H8=0x07), + // so walk file/rank explicitly -- same idiom nnuefeatures.c's + // ActiveFeatureIndices already uses. + for (f = 0; f < 8; f++) + { + for (r = 1; r <= 8; r++) + { + COOR c = FILE_RANK_TO_COOR(f, r); + PIECE p = pos->rgSquare[c].pPiece; + + if (IS_EMPTY(p)) + { + continue; + } + NnueAccumAddFeature(pAccum, uPerspective, cKing, c, p); + } + } +} diff --git a/src/nnuefeatures.c b/src/nnuefeatures.c index c43e267..4035905 100644 --- a/src/nnuefeatures.c +++ b/src/nnuefeatures.c @@ -19,13 +19,12 @@ Abstract: This is deliberately NOT wired into search/eval -- no accumulator, no incremental maintenance, nothing performance-sensitive. It exists purely so `dump nnuefeatures` can print the exact same - active-feature-index set the eventual real C inference code must - produce, so a Python/C cross-checker has a C-side ground truth to - diff against before any of that real code exists. The *real* - MakeMove/UnmakeMove accumulator plumbing (per NNUE.md's - sequencing, gated behind a dedicated branch, not started) will - reuse this same index math, not this file's dump-command - plumbing. + active-feature-index set the real C inference code produces, so a + Python/C cross-checker has a C-side ground truth to diff against. + The actual feature-index formula (NnueFeatureIndex) now lives in + chess.h, shared verbatim with the real MakeMove/UnmakeMove + accumulator maintenance (move.c) -- this file only walks the board + and calls it, so the two can never silently drift apart. Author: @@ -35,102 +34,6 @@ Author: #include "chess.h" -// -// HalfKAv2_hm constants, verbatim from Stockfish's half_ka_v2_hm.h. -// PS_NB counts 5 non-king piece types x 2 "us/them" roles x 64 -// squares, plus one shared 64-square king plane (own king and enemy -// king features both land in this one plane, distinguished only by -// which square is set). -// -#define NNUE_SQUARE_NB (64) -#define NNUE_PS_W_PAWN (0 * NNUE_SQUARE_NB) -#define NNUE_PS_B_PAWN (1 * NNUE_SQUARE_NB) -#define NNUE_PS_W_KNIGHT (2 * NNUE_SQUARE_NB) -#define NNUE_PS_B_KNIGHT (3 * NNUE_SQUARE_NB) -#define NNUE_PS_W_BISHOP (4 * NNUE_SQUARE_NB) -#define NNUE_PS_B_BISHOP (5 * NNUE_SQUARE_NB) -#define NNUE_PS_W_ROOK (6 * NNUE_SQUARE_NB) -#define NNUE_PS_B_ROOK (7 * NNUE_SQUARE_NB) -#define NNUE_PS_W_QUEEN (8 * NNUE_SQUARE_NB) -#define NNUE_PS_B_QUEEN (9 * NNUE_SQUARE_NB) -#define NNUE_PS_KING (10 * NNUE_SQUARE_NB) -#define NNUE_PS_NB (11 * NNUE_SQUARE_NB) // 704 - -#define NNUE_DIMENSIONS ((NNUE_SQUARE_NB * NNUE_PS_NB) / 2) // 22528 - -// -// Convert a typhoon 0x88 COOR to Stockfish's flat 0-63 square index -// (file + 8*(rank-1), rank/file both derived from the engine's own -// RANK()/FILE() macros -- see docs/NNUE.md's spec for why this -// one-line conversion is the single highest-risk spot in the whole -// scheme). -// -static int -CoorToSq64(COOR c) -{ - return (int)FILE(c) + 8 * ((int)RANK(c) - 1); -} - -// -// King bucket (0..31) for a king on Stockfish-square sq64, from -// color uPerspective's (WHITE/BLACK) point of view. Verified against -// Stockfish's literal KingBuckets[][] table -- see docs/NNUE.md. -// -static int -KingBucket(ULONG uPerspective, int sq64) -{ - int file = sq64 & 7; - int rank0 = sq64 >> 3; // 0..7, rank-1 - int fileQuartet = (file < 4) ? file : (7 - file); // 0..3, mirrored - int rankGroup = (uPerspective == WHITE) ? (7 - rank0) : rank0; - return rankGroup * 4 + fileQuartet; -} - -// -// Orientation XOR mask for perspective uPerspective whose own king -// sits on Stockfish-square ksq64. Only ksq64's file selects the -// branch (queenside half a-d vs. kingside half e-h); any rank. -// -static int -OrientMask(ULONG uPerspective, int ksq64) -{ - int file = ksq64 & 7; - if (uPerspective == WHITE) - { - return (file < 4) ? 7 : 0; // SQ_H1 : SQ_A1 - } - return (file < 4) ? 63 : 56; // SQ_H8 : SQ_A8 -} - -// -// Piece-square plane offset for a piece of color uPieceColor and -// type uPieceType (chess.h's PAWN..KING values), as seen from -// perspective uPerspective. Both kings (own and enemy) share -// NNUE_PS_KING. -// -static int -PiecePlane(ULONG uPerspective, ULONG uPieceColor, ULONG uPieceType) -{ - FLAG fOwn; - - if (uPieceType == KING) - { - return NNUE_PS_KING; - } - fOwn = (uPieceColor == uPerspective); - switch (uPieceType) - { - case PAWN: return fOwn ? NNUE_PS_W_PAWN : NNUE_PS_B_PAWN; - case KNIGHT: return fOwn ? NNUE_PS_W_KNIGHT : NNUE_PS_B_KNIGHT; - case BISHOP: return fOwn ? NNUE_PS_W_BISHOP : NNUE_PS_B_BISHOP; - case ROOK: return fOwn ? NNUE_PS_W_ROOK : NNUE_PS_B_ROOK; - case QUEEN: return fOwn ? NNUE_PS_W_QUEEN : NNUE_PS_B_QUEEN; - default: - ASSERT(FALSE); - return 0; - } -} - static int CompareInts(const void *a, const void *b) { @@ -149,9 +52,6 @@ ActiveFeatureIndices(POSITION *pos, ULONG uPerspective, int *pIndices) { int f, r; COOR cKing = ILLEGAL_COOR; - int ksq64; - int orientMask; - int kingBucketOffset; int n = 0; // @@ -172,9 +72,6 @@ ActiveFeatureIndices(POSITION *pos, ULONG uPerspective, int *pIndices) } } ASSERT(cKing != ILLEGAL_COOR); - ksq64 = CoorToSq64(cKing); - orientMask = OrientMask(uPerspective, ksq64); - kingBucketOffset = KingBucket(uPerspective, ksq64) * NNUE_PS_NB; for (f = 0; f < 8; f++) { @@ -182,20 +79,13 @@ ActiveFeatureIndices(POSITION *pos, ULONG uPerspective, int *pIndices) { COOR c = FILE_RANK_TO_COOR(f, r); PIECE p = pos->rgSquare[c].pPiece; - int sq64; - int idx; if (IS_EMPTY(p)) { continue; } - sq64 = CoorToSq64(c); - idx = (sq64 ^ orientMask) + - PiecePlane(uPerspective, PIECE_COLOR(p), PIECE_TYPE(p)) + - kingBucketOffset; - ASSERT((idx >= 0) && (idx < NNUE_DIMENSIONS)); ASSERT(n < 32); - pIndices[n++] = idx; + pIndices[n++] = NnueFeatureIndex(uPerspective, cKing, c, p); } } qsort(pIndices, n, sizeof(int), CompareInts); diff --git a/src/testmove.c b/src/testmove.c index 9dbee96..3a86fec 100644 --- a/src/testmove.c +++ b/src/testmove.c @@ -105,6 +105,13 @@ Return value: COOR cFrom, cTo; UINT64 u64Start = SystemReadTimeStampCounter(); UINT64 u64End; + // Not read back by this test -- it only exercises board-mutation + // correctness, not NNUE accumulator values -- but Lift/Place/ + // Slide now always keep one up to date, so a real (if unused) + // one has to be passed. + NNUE_ACCUMULATOR sNnueAccum[2]; + + memset(sNnueAccum, 0, sizeof(sNnueAccum)); Trace("Testing Lift/Place/Slide Piece... "); if (FALSE == FenToPosition(&pos, @@ -148,13 +155,13 @@ Return value: // SlidePiece and SlidePawn and caused the necessity // of this test. // - if (IS_PAWN(pos.rgSquare[cFrom].pPiece)) + if (IS_PAWN(pos.rgSquare[cFrom].pPiece)) { - SlidePawn(&pos, cFrom, cTo); + SlidePawn(&pos, cFrom, cTo, sNnueAccum); } else { - SlidePiece(&pos, cFrom, cTo); + SlidePiece(&pos, cFrom, cTo, sNnueAccum); } break; @@ -166,7 +173,7 @@ Return value: cFrom = _TestPickRandomPieceLocation(&pos); } while(IS_KING(pos.rgSquare[cFrom].pPiece)); - p[u] = LiftPiece(&pos, cFrom); + p[u] = LiftPiece(&pos, cFrom, sNnueAccum); u++; } break; @@ -180,7 +187,7 @@ Return value: cTo = RANDOM_COOR; } while(!IS_EMPTY(pos.rgSquare[cTo].pPiece)); - PlacePiece(&pos, cTo, p[u]); + PlacePiece(&pos, cTo, p[u], sNnueAccum); } break; } |
