diff options
| author | Scott Gasch <[email protected]> | 2026-09-18 22:35:42 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-18 22:35:42 -0700 |
| commit | 91dc4a9393204104c06fa64a05be24d08feca0ef (patch) | |
| tree | 26933246f26a5acfe0590fe678bb805de4d9af1a /src | |
| parent | 73b82a01c6f1b15489608c7e2cf757180746f7b8 (diff) | |
Add SeeFastSign, a provably-exact sign-only shortcut for SEE(), and wire it into GetMoveSee's cache-miss path
GetMoveSee's only consumer, ComputeMoveExtension, reads iMoveSee in
exactly two places -- both plain sign checks (`< 0` / `>= 0`), never
the magnitude. On a cache miss GetMoveSee always paid for a full SEE()
exchange walk (list-build, heap, x-ray discovery) just to throw away
everything but the sign.
SeeFastSign answers the sign question directly, without building
either SEE_LIST: if the defending side has zero attackers on the
target square, SEE()'s own exchange loop terminates on its first
iteration anyway and returns exactly captured+promoted, which is never
negative -- so "no defender" provably implies SEE >= 0, not an
approximation. Falls back to real SEE() whenever a defender exists.
Must check with mv.cFrom removed from occupancy, not the raw pre-move
board -- the mover's own departure can unmask a defender that's
currently blocked by the mover itself sitting on the same ray (exactly
what SEE()'s own _AddXRaysMailbox discovers explicitly). A first
version missed this and returned a wrong TRUE for a quiet bishop move
that unmasked a queen's diagonal attack through the vacated square,
caught by the correctness test below before it ever reached production
code.
testsee.c's TestSeeFastSign asserts SeeFastSign's sign matches real
SEE() across 3000 random positions x every legal move, then benchmarks
both: SeeFastSign runs at 0.40x-0.65x real SEE()'s cycles/call.
Measured end-to-end (sd10 + sn5M, all three curated suites, before vs.
after): node counts are bit-for-bit identical on every suite/depth
pair, as expected from a provably sign-equivalent function -- zero
search-behavior change, no self-play gate needed. Wall-clock improved
on all 6 measurements, ~2.7% overall.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src')
| -rwxr-xr-x | src/chess.h | 10 | ||||
| -rw-r--r-- | src/main.c | 1 | ||||
| -rw-r--r-- | src/searchsup.c | 14 | ||||
| -rw-r--r-- | src/see.c | 75 | ||||
| -rw-r--r-- | src/testsee.c | 144 |
5 files changed, 243 insertions, 1 deletions
diff --git a/src/chess.h b/src/chess.h index e585506..802c89d 100755 --- a/src/chess.h +++ b/src/chess.h @@ -3399,6 +3399,13 @@ SCORE SEE(POSITION *pos, MOVE mv); +// Sign-only fast path for SEE(pos, mv) >= 0 -- see its own header +// comment in see.c. Exposed non-static so GetMoveSee (searchsup.c) can +// call it and testsee.c can diff it against real SEE(). +FLAG +SeeFastSign(POSITION *pos, + MOVE mv); + typedef struct _SEE_THREESOME { PIECE pPiece; @@ -3432,6 +3439,9 @@ TestGetAttacks(void); void TestIsAttackedBB(void); +void +TestSeeFastSign(void); + // // hash.c // @@ -615,6 +615,7 @@ Return value: TestIcs(); TestGetAttacks(); TestIsAttackedBB(); + TestSeeFastSign(); TestMoveGenerator(); TestLegalMoveGenerator(); TestGenerateKnightSpeed(); diff --git a/src/searchsup.c b/src/searchsup.c index 26856cd..49027db 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -465,6 +465,18 @@ Routine description: Only the sign matters to any caller of ComputeMoveExtension, so the cached value is coarsened to 0/-1. + On a cache miss, use SeeFastSign (see.c) instead of a real SEE() + call -- same reasoning as the cache-hit path above (only the sign + ever reaches ComputeMoveExtension, both of its own iMoveSee checks + are plain `< 0`/`>= 0`), but for the miss case there was no cached + value to fall back to at all, so this always used to pay for a + full SEE() exchange walk (list-build, heap, x-ray discovery) just + to throw away everything but the sign. SeeFastSign is + correctness-tested against real SEE()'s sign across thousands of + random positions (testsee.c's TestSeeFastSign) and measured at + 0.40x-0.65x SEE()'s own cycles/call, coarsened to the same 0/-1 + convention as the cache-hit path for consistency. + Parameters: SEARCHER_THREAD_CONTEXT *ctx : searcher thread context @@ -491,7 +503,7 @@ Return value: return((bvFlags & MVF_SEE_NONNEGATIVE) ? 0 : -1); } } - return(SEE(&ctx->sPosition, mv)); + return(SeeFastSign(&ctx->sPosition, mv) ? 0 : -1); } @@ -933,6 +933,81 @@ Return value: } +FLAG +SeeFastSign(IN POSITION *pos, + IN MOVE mv) +/** + +Routine description: + + Cheap answer to "is SEE(pos, mv) >= 0", for callers that only need + the sign -- GetMoveSee's only consumer, ComputeMoveExtension, reads + iMoveSee in exactly two places (searchsup.c), both pure sign checks + (`iMoveSee < 0` / `iMoveSee >= 0`), never the magnitude. Real SEE() + unconditionally builds both sides' full sorted/heaped attacker + lists and discovers x-rays before playing out the exchange, even + though the exchange loop's own first iteration already terminates + immediately whenever the defending side has zero attackers on + cTo -- _MinLegalPiece finds nothing, breaks, and SEE() returns + exactly rgiList[0] = PIECE_VALUE(mv.pCaptured) + + PIECE_VALUE(mv.pPromoted), which is always >= 0 (a piece value is + never negative). So "defender list is empty" provably implies + SEE(pos, mv) >= 0 -- not an approximation of the sign, the actual + sign, derived without ever building either SEE_LIST, running the + heap, or discovering a single x-ray. + + Checks defender existence via two raw bitboard tests (the same + ones _GetAttacksBB would populate a full list from: pawns via + g_PawnAttackOriginBB, everything else via _WhoAttacksSquareBB) -- + falls back to the real SEE() whenever a defender is actually + present, since the sign genuinely depends on the full exchange at + that point and there's no cheap shortcut left to take. + + Must check with mv.cFrom removed from occupancy, not the raw + pre-move board: the mover's own departure can unmask a defender + that's currently blocked by the mover itself sitting on the same + ray between cTo and the defender (exactly what real SEE()'s + _AddXRaysMailbox discovers explicitly, right before its exchange + loop starts). A first version of this function used the raw + pos->bbOccupied and returned a wrong TRUE for a quiet bishop move + that unmasked a queen's own diagonal attack through the vacated + square -- caught by TestSeeFastSign's correctness check (real + SEE() said -300, this said nonnegative). Pawn attacks don't need + this adjustment -- a pawn's attack squares are a fixed 2-square + delta, never ray-blocked by anything to begin with. + +Parameters: + + POSITION *pos, + MOVE mv + +Return value: + + FLAG : TRUE if SEE(pos, mv) >= 0 + +**/ +{ + ULONG uDefender = FLIP(GET_COLOR(mv.pMoved)); + BITBOARD bbOccupiedWithoutMover = pos->bbOccupied & ~COOR_TO_BB(mv.cFrom); + + if (g_PawnAttackOriginBB[uDefender][mv.cTo] & pos->bbPawns[uDefender]) + { + return (SEE(pos, mv) >= 0); + } + if (_WhoAttacksSquareBB(pos, mv.cTo, uDefender, bbOccupiedWithoutMover)) + { + return (SEE(pos, mv) >= 0); + } + + // + // No defender at all -- the exchange can't continue past our own + // move, so the result is exactly the material gained (captured + + // promoted), which is never negative. + // + return TRUE; +} + + SCORE SEE(IN POSITION *pos, IN MOVE mv) diff --git a/src/testsee.c b/src/testsee.c index ea8318e..6346697 100644 --- a/src/testsee.c +++ b/src/testsee.c @@ -375,4 +375,148 @@ TestIsAttackedBB(void) } } } + +// SeeFastSign (see.c) is a sign-only shortcut for SEE(pos, mv) >= 0 -- +// GetMoveSee's only consumer (ComputeMoveExtension, searchsup.c) never +// reads the magnitude, only the sign, so this skips SEE()'s list- +// build/heap/xray work whenever the defending side has zero attackers +// on the target square (a case where SEE()'s own exchange loop would +// terminate on its first iteration anyway). Correctness requirement, +// per Scott: assert the sign always matches what real SEE() returns -- +// this isn't meant to be an approximation, so any mismatch is a real +// bug in the fast path, not tolerable noise. +void +TestSeeFastSign(void) +{ + SEARCHER_THREAD_CONTEXT *ctx; + POSITION posBench; + MOVE rgmv[MAX_MOVE_STACK]; + ULONG uMoveCount; + ULONG u, x; + FLAG fFast, fReal; + const ULONG uPositions = 3000; + + Trace("Testing SeeFastSign (sign vs real SEE)...\n"); + ctx = (SEARCHER_THREAD_CONTEXT *)malloc(sizeof(SEARCHER_THREAD_CONTEXT)); + if (NULL == ctx) + { + return; + } + + for (u = 0; u < uPositions; u++) + { + GenerateRandomLegalPosition(&posBench); + InitializeSearcherContext(&posBench, ctx); + GenerateMoves(ctx, (MOVE){0}, GENERATE_DONT_SCORE); + + uMoveCount = 0; + for (x = ctx->sMoveStack.uBegin[ctx->uPly]; + x < ctx->sMoveStack.uEnd[ctx->uPly]; + x++) + { + rgmv[uMoveCount++] = ctx->sMoveStack.mvf[x].mv; + } + + for (x = 0; x < uMoveCount; x++) + { + fFast = SeeFastSign(&(ctx->sPosition), rgmv[x]); + fReal = (SEE(&(ctx->sPosition), rgmv[x]) >= 0); + if (fFast != fReal) + { + Trace("move=0x%x from=%d to=%d moved=%d captured=%d " + "promoted=%d fFast=%d fReal=%d real_see=%d\n", + rgmv[x].uMove, rgmv[x].cFrom, rgmv[x].cTo, + rgmv[x].pMoved, rgmv[x].pCaptured, rgmv[x].pPromoted, + fFast, fReal, SEE(&(ctx->sPosition), rgmv[x])); + DumpPosition(&(ctx->sPosition)); + UtilPanic(TESTCASE_FAILURE, + &(ctx->sPosition), + "SeeFastSign/SEE sign mismatch", + NULL, NULL, + __FILE__, __LINE__); + } + } + } + free(ctx); + + // + // Speed: same isolated, interleaved cycles/call methodology as the + // other benchmarks above, real generated moves at each benchmark + // position (same setup as TestSEESpeed). + // + { + static const char *rgszFen[3] = + { + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "r1bq1rk1/pp2bppp/2n1pn2/2pp4/3P4/2NBPN2/PP3PPP/R1BQ1RK1 w - - 0 1", + "8/5k2/8/3K4/8/8/8/4R3 w - - 0 1", + }; + static const char *rgszLabel[3] = + { + "opening ", "middlegame", "endgame ", + }; + SEARCHER_THREAD_CONTEXT *ctx2; + POSITION posBench2; + MOVE rgmv2[MAX_MOVE_STACK]; + ULONG uMoveCount2; + UINT64 u64RealTotal, u64FastTotal, u64Start; + ULONG uIter; + FLAG fSink; + const ULONG uCallsPerPosition = 200000; + + Trace("Benchmarking SEE sign: real SEE()>=0 vs SeeFastSign " + "(interleaved, %lu calls/position)...\n", + uCallsPerPosition); + ctx2 = (SEARCHER_THREAD_CONTEXT *)malloc(sizeof(SEARCHER_THREAD_CONTEXT)); + if (NULL == ctx2) + { + return; + } + + for (u = 0; u < 3; u++) + { + FenToPosition(&posBench2, (char *)rgszFen[u]); + InitializeSearcherContext(&posBench2, ctx2); + GenerateMoves(ctx2, (MOVE){0}, GENERATE_DONT_SCORE); + + uMoveCount2 = 0; + for (x = ctx2->sMoveStack.uBegin[ctx2->uPly]; + x < ctx2->sMoveStack.uEnd[ctx2->uPly]; + x++) + { + rgmv2[uMoveCount2++] = ctx2->sMoveStack.mvf[x].mv; + } + if (uMoveCount2 == 0) + { + continue; + } + + u64RealTotal = 0; + u64FastTotal = 0; + for (uIter = 0; uIter < uCallsPerPosition; uIter++) + { + ULONG uPair = uIter % uMoveCount2; + + u64Start = SystemReadTimeStampCounter(); + fSink = (SEE(&(ctx2->sPosition), rgmv2[uPair]) >= 0); + u64RealTotal += (SystemReadTimeStampCounter() - u64Start); + + u64Start = SystemReadTimeStampCounter(); + fSink = SeeFastSign(&(ctx2->sPosition), rgmv2[uPair]); + u64FastTotal += (SystemReadTimeStampCounter() - u64Start); + (void)fSink; + } + printf(" %s: real-SEE %" COMPILER_LONGLONG_UNSIGNED_FORMAT + " cycles/call, SeeFastSign %" + COMPILER_LONGLONG_UNSIGNED_FORMAT " cycles/call " + "(fast is %.2fx real, %u distinct moves)\n", + rgszLabel[u], + u64RealTotal / uCallsPerPosition, + u64FastTotal / uCallsPerPosition, + (double)u64FastTotal / (double)u64RealTotal, + uMoveCount2); + } + free(ctx2); + } +} #endif // TEST |
