Skip to content

Two phase move loop, the probe's entry handed back, and the first search asked from the loop - #403

Merged
aywrite merged 3 commits into
masterfrom
claude/search-node-two-phase-loop
Oct 13, 2026
Merged

aywrite merged 3 commits into
masterfrom
claude/search-node-two-phase-loop

Conversation

@aywrite

@aywrite aywrite commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Three changes to the search node's own work. Each is exact: every node
is searched with the same window, every probe answers and every store
lands as before, and node counts and every bench line are unchanged
(11,088,536).

Measured on d82638a, the base's parent, which has the same search
code, an interleaved clock over shuffled layouts read the games suite
at 16 MB +4.96% (+3.56% to +6.37%, 200 rounds) and at 256 MB +3.67%
(+2.17% to +5.18%, 60 rounds). Each commit was faster than the one
before it on the 16 MB clock. The same clock read the bench +4.76%
(+3.40% to +6.21%, 100 rounds). scripts/speed.sh reads the head
against b48777a at +2.6% (+1.3% to +3.9%, 120 rounds), and the commits
at +2.4%, +0.3% and +1.1% over 60 rounds each.

  1. The probe returns the entry it found beside its answer, rather than
    writing it to a cell on the table for the node to read back. The
    store's contest reads the age penalty from a 32 slot array kept
    beside the generation.
  2. The move loop runs in two phases, so the node halves of the late
    move reduction and of the shallow rules are built only once a node
    has searched a move and has a place left. The loop reads the list
    through a slice, the quiet ordering keeps one place threshold, and
    the rules hold the mate test on alpha.
  3. search_child asks principal variation search's first search
    itself, with the rest of the sequence in a cold windowed_rest, and
    a child at depth zero out of check goes to a small leaf entry in
    front of the one quiescence body.

Callgrind against b48777a, the games suite's tree search: 11,050,460,280
instructions to 10,231,570,885 (-7.410%); the bench's 16,254,520,835 to
15,285,054,673 (-5.964%). By commit, in points of the base: -1.840,
-2.911 and -2.659 on the games suite, -1.641, -2.383 and -1.939 on the
bench.

Constraints this leaves:

  • Rules holds the mate test on alpha. Alpha moves only at the loop's
    one rise, which calls Rules::raise; an edit that moves alpha
    anywhere else in the loop has to call it too. The debug build checks
    the held answer at every call.
  • The first phase of the loop asks no rule. A rule that should act on a
    node's first searched move would have to move into the first phase.
  • leaf writes out answered_by_rule and alpha_beta's depth zero
    entry, and must keep in step with them. Comments on all three say so,
    and a test asks alpha_beta and the leaf the same nodes.
  • windowed now runs whole only for a move whose scout the ledger or
    the forced decision instrument staged. A test holds search_child
    and windowed to the same values and taints.
  • The penalty array is rewritten wherever the generation or the age
    weight changes (with_capacity, clear, new_search). A new place
    that writes either has to rewrite it; the debug build checks it
    against the formula at every contest.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae


Generated by Claude Code

…e penalty from an array

A probe wrote the entry it found into a cell on the table, and a full
width node read its static evaluation and its floor back from the cell
straight after. Every probe paid for the write, quiescence's included,
which reads nothing back. A hit also built the entry on the stack to
have something to write, and since the write went through a `Cell` the
compiler reloaded what it had read through `&self` around it.

The probe now returns the entry beside its answer, as `Found`, and the
node reads the evaluation and the floor from that. Quiescence drops it.
The cell goes, and with it the debug check that no other probe came
between the probe and the read: the entry now travels with the node that
asked for it.

The store's contest took each entry's age modulo the 31 generations and
multiplied it by the age weight. The table now keeps the weight times
each generation's age in a 32 slot array beside the generation,
rewritten wherever the generation or the weight changes, and the contest
reads it. The debug build checks it against the formula at every call.

Every probe answers and every store lands as before. Node counts and
every bench line are unchanged. Callgrind against the parent: the games
suite's tree search goes from 11,050,460,280 instructions to
10,847,099,545 (-1.840%), the bench's from 16,254,520,835 to
15,987,709,636 (-1.641%). Measured apart on the parent's parent, which
has the same search code, the entry handed back is 1.628 points of the
games suite and the penalty array 0.212.

Bench: 11088536
Speed: +2.4% (bench nps, 95% interval +0.4% to +4.6%, 60 interleaved rounds over shuffled layouts vs b48777a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae
…rched move, in a two phase move loop

A full width node built the node halves of the late move reduction and
of quiet futility and move count pruning (`Rules::new`) before its move
loop, and the compiler worked out the loop's invariants for the gate on
entry. No rule acts on the first move a node searches, and most loops
never get past it: on the games suite the rules are now built at 808,818
of 1,823,170 loops (counted by an assertion build).

The loop now runs in two phases when the table's move was not searched.
The first takes the places up to the first move searched, asks no rule,
and stops after that move; a cutoff returns as before. Only a node with
a place left builds the rules and enters the second phase, which is the
old loop less its test for the first move. Three smaller changes go with
it:

- The loop reads the move list through a slice, so an index no longer
  asks whether the list spilled its inline buffer.
- The quiet ordering keeps the first place it has work at
  (`QuietOrder::at`), so a place outside the quiet run asks one compare
  in place of three tests.
- The two rule halves hold the mate test on alpha, asked again when the
  loop raises alpha (`Rules::raise`), rather than asking it at every
  move. This puts back a value kept in step with alpha, which ba83869
  removed. The debug build checks the held answer against the test at
  every call.

Before the first searched move the searched count is zero, so no rule
could have acted there. The rules read the node's facts, the position
and the evaluation, none of which the first search changes, and alpha,
which the held test reads when the rules are built and at every rise.
Node counts and every bench line are unchanged. Callgrind against the
parent: the games suite's tree search goes from 10,847,099,545
instructions to 10,525,445,589 (2.911 points of the tree search before
the series), the bench's from 15,987,709,636 to 15,600,283,382 (2.383
points). `alpha_beta` shrinks from 15,812 bytes to 13,713, with the
quiet keying (1,571 bytes) and `Rules::new` (364) now out of line.

Bench: 11088536
Speed: +0.3% (bench nps, 95% interval -1.1% to +1.8%, 60 interleaved rounds over shuffled layouts vs 3a8a067)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae
…h zero child to a leaf entry

Every searched move went through `windowed`, an out of line function
between the node and its child that asked the child's searches: on the
games suite 5,925,401 calls at 56.7 instructions of its own each, for
what is one search unless a later move rises above alpha. A child at
depth zero out of check then entered `alpha_beta` only to run its entry
tests and hand the position to quiescence, which repeated part of them.

`search_child` now asks principal variation search's first search
itself. The rest of the sequence (the probe after a scout that rose
above alpha, and the proof at an open window) is the cold
`windowed_rest`. `windowed` stays whole for a move whose scout an
instrument staged, so the ledger and the forced decision instrument see
the calls they saw; a debug assertion holds that a reduced move reaching
the new path has no instrument armed. A child at depth zero out of check
goes to `leaf`, a small out of line entry that runs `alpha_beta`'s entry
for such a node in its order (the poll, the node count, the fifty move
rule and repetition, the rail, mate distance) and then the quiescence
body. Quiescence is split into an inlined head and that one body, so
there is still one copy of it. The null move's pass goes through the
same dispatch.

The leaf writes out `answered_by_rule` with the check known false, and
comments on both and on `alpha_beta`'s depth zero exit say a rule added
to one goes to the other. A new test asks `alpha_beta` at depth zero and
the leaf the same nodes on two engines (every bench and games suite
position out of check, a fifty move draw and a repetition, at four line
plies and seven windows, two of which mate distance narrows or closes)
and holds the values, taints, node counts and selective depth equal.
Another asks `search_child` and `windowed` the same children under the
loop's three decisions, at positions two or three plies short of the
fifty move rule, and holds the values, the taints `windowed_rest`
carries and the node counts equal.

Every node is searched with the window, depth and bounds it had. Node
counts and every bench line are unchanged. Callgrind against the parent:
the games suite's tree search goes from 10,525,445,589 instructions to
10,231,570,885 (2.659 points of the tree search before the series), the
bench's from 15,600,283,382 to 15,285,054,673 (1.939 points). The code
on the per child path grows by about 425 bytes, with `windowed` (705)
and `windowed_rest` (214) now off it, and a leaf touches 624 bytes of
stack where the parent's call into `windowed`, `alpha_beta` and
quiescence touched 2,184.

Bench: 11088536
Speed: +1.1% (bench nps, 95% interval -1.3% to +3.5%, 60 interleaved rounds over shuffled layouts vs 4d4741e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae
@github-actions

Copy link
Copy Markdown
Contributor

Speed against 5293b25e

Measured on AMD EPYC 7763 64-Core Processor.
Both sides built with rustc 1.99.0 (b940084d7 2026-09-28).

Both sides built and run on this runner in this job, the way
scripts/speed.sh measures a perf commit. Each round runs
both sides on a layout of its own, the same compiled code with
its code and data shuffled and moved, so the 95% interval carries
where the code landed as well as the run. The verdict holds the
interval against a 1% threshold. The default layout, the one a
release ships, is measured after as a diagnostic. The node
counts are the search's: they move when the search does, and a
speed change leaves them alone.

round     base nps  candidate nps  change
    1      4602954        4886589   +6.2%
    2      4700270        4781319   +1.7%
    3      4594909        4773761   +3.9%
    4      4589998        4851708   +5.7%
    5      4671579        4806540   +2.9%
    6      4615721        4896141   +6.1%
    7      4745299        4882520   +2.9%
    8      4727070        4849690   +2.6%
    9      4709497        4835160   +2.7%
   10      4651249        4951994   +6.5%
   11      4705534        4855840   +3.2%
   12      4596090        4878029   +6.1%
   13      4743685        4736526   -0.2%
   14      4740540        4926134   +3.9%
   15      4750524        4914580   +3.5%
   16      4684862        4865134   +3.8%
   17      4539609        4613226   +1.6%
   18      4603063        4693121   +2.0%
   19      4570095        4704318   +2.9%
   20      4745027        4792209   +1.0%
   21      4659573        4819086   +3.4%
   22      4788666        4796255   +0.2%
   23      4794924        4972769   +3.7%
   24      4714431        4841453   +2.7%
   25      4775751        4821184   +1.0%
   26      4634544        4868981   +5.1%
   27      4719871        4942139   +4.7%
   28      4698167        4826712   +2.7%
   29      4622039        4795283   +3.7%
   30      4458459        4752423   +6.6%
   31      4613021        4685688   +1.6%
   32      4625098        4784486   +3.4%
   33      4727330        4829350   +2.2%
   34      4650152        4711502   +1.3%
   35      4648306        4715319   +1.4%
   36      4588562        4662506   +1.6%
   37      4579133        4789998   +4.6%
   38      4630897        4666301   +0.8%
   39      4577781        4873458   +6.5%
   40      4628718        4543012   -1.9%

              nodes    time  median nps  faster half
base       11088536  2.38 s     4650700      4722692
candidate  11088536  2.30 s     4820135      4878478
change                            +3.6%        +3.3%

paired change +3.1%, 95% interval +2.4% to +3.7%
diagnostic, on the default layout alone +3.6%, 95% interval +2.9% to +5.0%, 13 rounds

faster: the whole interval is above +1.0%

Speed: +3.1% (bench nps, 95% interval +2.4% to +3.7%, 40 interleaved rounds over shuffled layouts vs 5293b25e)

The instructions each side's bench executed, counted under
cachegrind. The count repeats to within a few hundred
instructions, so a small change here is a real one, but it
prices instructions only: cache misses, mispredicted branches
and where the code lands are the speed's to show.

instructions against 5293b25e, one cachegrind run a side, bench

             instructions     nodes  per node
base       16,519,614,904  11088536    1489.8
candidate  15,683,747,223  11088536    1414.4
change             -5.06%              -5.06%

fewer instructions, beyond the ±0.7% that edits not made for speed have moved the count

@aywrite
aywrite merged commit f2d249b into master Oct 13, 2026
21 checks passed
@aywrite
aywrite deleted the claude/search-node-two-phase-loop branch October 13, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants