summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-18 22:35:42 -0700
committerScott Gasch <[email protected]>2026-09-18 22:35:42 -0700
commit91dc4a9393204104c06fa64a05be24d08feca0ef (patch)
tree26933246f26a5acfe0590fe678bb805de4d9af1a /src
parent73b82a01c6f1b15489608c7e2cf757180746f7b8 (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-xsrc/chess.h10
-rw-r--r--src/main.c1
-rw-r--r--src/searchsup.c14
-rw-r--r--src/see.c75
-rw-r--r--src/testsee.c144
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
//
diff --git a/src/main.c b/src/main.c
index 5423805..736a5aa 100644
--- a/src/main.c
+++ b/src/main.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);
}
diff --git a/src/see.c b/src/see.c
index 47efd30..2edd6c5 100644
--- a/src/see.c
+++ b/src/see.c
@@ -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