Drop7 Research
fair-expectimax

Rust bitboard engine

completedevidence: reproducedpublic information

A from-scratch Rust Drop7 engine that packs the board into seven 32-bit words — so gravity is a single bit-gather instruction — and runs the fair expectimax search bit-for-bit identically to the frozen reference, faster and with a fraction of the memory.

The one-paragraph version

A Drop7 board is 49 cells, each holding nothing, a number 1–7, or a gray disc in two states — ten possibilities, which fit in four bits. The reference engines store those cells as 49 bytes and walk them with loops. This engine packs them as seven 32-bit words, one per column, four bits per cell, with the bottom row in the least-significant nibble. That one choice cascades: gravity in a column becomes a single bit-gather instruction, a row rise becomes one shift-and-or per column, and the whole board state is 28 bytes that fit in a handful of registers. On top of that packed core sits the fair expectimax search with a pluggable leaf evaluator and a transposition table that the measurements below drove to a specific, cheap shape. Everything is proven bit-identical to the C++ and TypeScript references before any number was trusted.

engine throughput, single core
12.8M moves/s
2.0× the C++ fast engine, 19.8× TypeScript
board size
28 bytes
vs 49 bytes row-major; 7 × u32
searcher memory
≈2.5 KB + table
C++ fast search carries a 16.2 MB table
moves replayed, three engines
36,000+
0 mismatches on every observable

Strategy 1: the board is seven integers, and gravity is one instruction

Store a column as a 32-bit word of seven nibbles, with the bottom row in the least-significant nibble. Two invariants make everything cheap:

  • A column's height is the popcount of its non-zero nibbles, and a dropped disc lands in the first zero nibble — so a drop is word |= disc << 4*h and a legality check is one mask test against the top nibble.
  • Because columns stay bottom-packed, gravity is a stable compaction of non-zero nibbles toward the low end — which is exactly what the BMI2 PEXT instruction does: pext(word, mask) extracts the nibbles selected by mask and packs them, in order, at the bottom.
one u32 column word (4 bits per cell)r63r5r4r35r2r12r0↓ gravity = pext(word, expanded-nonzero-mask) — discs gather toward the bottom rowafter: bottom-packed, order preservedr6r5r4r33r25r12r0
One column word, read left-to-right from the top row's nibble to the bottom row's. The dashed slot is the unused eighth nibble (28 bits hold seven cells). Clearing the 3 and the 5 leaves holes; PEXT with the non-zero-nibble mask gathers the survivors toward the bottom row in their original order. On Zen 5 this is one 3-cycle instruction; a portable nibble loop produces the identical word elsewhere.

The row rise is the other place the packing pays: shifting every cell up one row and laying a fresh gray row underneath is (word << 4) | SOLID per column — seven shift-ors replace the reference's 49-cell copy.

Strategy 2: bitboards for the cascade, counted in parallel

Popper detection and cover hits want a different shape — row-major bitboards — and the engine derives them from the column words on each wave with SWAR nibble tests and PDEP scatters (about 70 bit operations, versus the reference's ~1,300 board reads per wave):

  • Poppers come from the same 128-entry run-length table the proven C++ fast engine uses: a disc pops when its value equals the contiguous occupied run through it in its row or its column, and the run length is a table lookup on the row/column occupancy mask.
  • Cover hits are counted for the whole board at once. The popper bitboard is shifted in the four neighbour directions, and the four resulting bitboards are summed with a bitwise parallel counter (ones/twos/fours planes). A solid gray reveals exactly when its two-or-more-hits plane is set; a cracked gray when any plane is set. No per-cell loop, and the reveal draws are still consumed in ascending row-major order — the order the reference consumes randomness, which is what keeps the streams identical.

Strategy 3: a pluggable leaf, statically dispatched

The search is generic over a Leaf trait, so the fair leaf (ported character-for-character from the proven C++ fastFairLeaf, verified bit-identical on 150,854 real states) is one monomorphised instantiation and a different evaluator costs no branching in the hot path. Because the leaf reads cells many times in row-major order, it works on a byte view of the board that costs seven PDEPs to produce — the engine keeps the packed words for the mechanics, the leaf pays array-load prices for the reads.

Strategy 4: the transposition table, designed by measurement

The fair search carries a transposition table so a state reached two different ways is evaluated once. The C++ fast search uses a packed-key, open-addressed, strict-LRU table. This engine's table was not designed by instinct; two measurements settled its shape, and they are the most interesting numbers on this page.

The node hit rate is a lie. On real decisions, the table's node hit rate is 1.3 % at depth 4 and 1.36 % at depth 5 — which reads as "the table is almost never useful." But a table hit does not save one node; it prunes the entire subtree under that node, about 49depth expansions. Measured work per decision (move + leaf evaluations):

Work per decision eliminated by memoization (no-table = 100%)
recomputed anyway (no-table does all of it): 53% — d4s7: 11.9M work units with no table53%pruned by the table at d4s7: 47% — 11.9M → 6.3M with the cheap table; C++ LRU reaches 6.0M47%
recomputed anyway (no-table does all of it) · 53%pruned by the table at d4s7 · 47%
table view
componentsharedetail
recomputed anyway (no-table does all of it)53%d4s7: 11.9M work units with no table
pruned by the table at d4s747%11.9M → 6.3M with the cheap table; C++ LRU reaches 6.0M
At depth 4 the table eliminates ~47 % of all work despite a 1.3 % node hit rate. At depth 5 it eliminates ~90 % (582.7M → 59.5M): the deeper the search, the more each rare hit prunes. A 1.3 % hit rate and a 47–90 % work reduction are the same measurement, seen from two directions.

The expensive part of the table was never the hits — it was the bookkeeping. The C++ table pays for a hash, a probe, and LRU linked-list maintenance on every interior node to earn its hit rate. This engine replaces it with a direct-mapped, depth-preferred table: one slot per hash index, no probe chain, no LRU list, replace-on-collision when the new node is at least as deep. It finds nearly the same hits (27.5k vs the LRU's 29.9k per decision at d4s7) at a lower per-operation cost and a fifth of the memory.

The depth gate exists and was measured: caching only depth ≥ 2 captures the rare deep hits but misses the numerous shallow ones and recovers only ~25 % of the work reduction, so the shipped configuration caches every depth (gate ≥ 1) with the cheap table. The table is a compile-time strategy parameter — the NoTable arm compiles to nothing — and the values gate proves the choice changes no per-column value, only speed and memory.

What happened

All speeds are fixed-work, complete-depth decisions on the same 105 harvested root positions, every arm computing bit-identical per-column values (proven by the gates below). Full data in benchmark.json.

Engine move throughput, single core (whole games, best of 3)
0×1×2×3×4×5×6×7×8×9×10×11×12×13×14×15×16×17×18×19×20×21×1× = frozen referenceTypeScript engine: 1× (0.65M moves/s · 1,540 ns/move)TypeScript engine1.00×C++ reference engine: 10.5× (6.8M moves/s · 147 ns/move)C++ reference engine10.50×C++ fast engine: 10× (6.5M moves/s · 154 ns/move)C++ fast engine10.00×Rust packed engine: 19.8× (12.8M moves/s · 78 ns/move)Rust packed engine19.80×
table view
configurationspeedupnote
TypeScript engine1.00×0.65M moves/s · 1,540 ns/move
C++ reference engine10.50×6.8M moves/s · 147 ns/move
C++ fast engine10.00×6.5M moves/s · 154 ns/move
Rust packed engine19.80×12.8M moves/s · 78 ns/move
Pure mechanics: place, cascade, rise, repeat, over 32,768 complete center-policy games with identical trajectories (same seeds, same mean score). The two C++ engines are within run-to-run noise of each other on this simple workload; the packed representation is where the Rust engine's ~2× comes from.
Fair search, depth 4 / 7 strata (21 decisions, best of 3)
0×1×2×3×4×1× = frozen referenceC++ reference storage: 1× (3,248 ms/decision · string-keyed LRU)C++ reference storage1.00×Rust, no table: 2× (1,633 ms · does all 11.9M work units, 0 bytes)Rust, no table2.00×C++ fast, packed LRU 200k: 3× (1,071 ms · 16.2 MB table)C++ fast, packed LRU 200k3.00×Rust, direct-mapped 64k: 3.6× (908 ms · 3.1 MB table)Rust, direct-mapped 64k3.60×
table view
configurationspeedupnote
C++ reference storage1.00×3,248 ms/decision · string-keyed LRU
Rust, no table2.00×1,633 ms · does all 11.9M work units, 0 bytes
C++ fast, packed LRU 200k3.00×1,071 ms · 16.2 MB table
Rust, direct-mapped 64k3.60×908 ms · 3.1 MB table
Speedup over the C++ reference storage. The Rust cheap-table arm is the fastest *and* uses a fifth of the C++ fast table's memory; the no-table arm is the fastest per unit of work (137 vs 177 ns) but does 1.9× the work, which is exactly the memoization payoff.

At depth 5 / 7 strata the same arms tell the depth story: the C++ fast search takes 7,817 ms per decision, the Rust cheap table at 256k entries ties it (7,788 ms), and at 1M entries pulls ahead to 7,047 ms (1.11×) — while the no-table arm, doing all 582.7M work units, takes 63,325 ms. Depth 5 is where memoization stops being optional, and where the Rust engine's per-work cost (113 vs 134 ns/work) still wins once the tables are comparably sized.

Thread scaling is shared-nothing: an atomic game cursor, one searcher per worker, no shared mutable state. Whole-game throughput scales 12.6M → 129.5M moves/s from 1 to 16 physical cores (10.3× on the shared machine; a clean run measured 14.1×), with identical move counts, wave counts and scores at every worker count — each game is computed by exactly one worker, deterministically, so worker count never changes a result. This is the mode that maps to a 192-core evaluation instance: 192 searchers with 64k-entry tables cost ~0.6 GB of table memory, against ~3.1 GB for the C++ fast search's tables.

How it was proven to be the same game

Every gate replays identical inputs through the Rust engine and a reference and fails on any difference. The columns in the trajectory gates are chosen once from the reference state and handed to both engines, isolating engine semantics from policy semantics.

gate 1 · trajectory × 3 drivers
board, next disc, score, score delta, level, moves remaining, terminal flag, board-clear and level-advance flags, and the complete wave list, entry by entry
512 games center policy + 256 games under a depth-3 search (C++ headless driver) + 256 games under the TypeScript driver — 36,427 moves, 40,286 waves
0 mismatches
gate 2 · bit-exact leaf
the fair leaf's return value as raw 64-bit patterns against the C++ fastFairLeaf
150,854 real states, harvested both as search leaves and as visited game positions
0 mismatches
gate 3 · search values
per-column f64 bit patterns and the chosen action at fixed depth/strata
105 real roots at depth 4 / 7 strata, 10 roots at depth 5 / 7 strata
0 mismatches
gate 4 · cache independence
the values gate re-run with the direct-mapped table enabled
105 roots, depth 4 / 7 strata
0 mismatches — the table changes speed and memory, never a value
gate 5 · search metrics
chosen action and completed depth against the C++ FastSearch
105 roots, depth 4 / 7 strata
0 mismatches
The TypeScript trajectory arm deserves a note: it drives the Rust engine with the TypeScript engine's exact draw pattern (one long-lived Mulberry32 stream) and reproduces engine.ts's board and score evolution move for move — three independent implementations of one game.

What this taught us, and what is still open

  • The representation is the win. Packing the board column-wise into seven words turned gravity, drops, rises and legality into single bit operations, and made the whole board small enough to live in registers. That — not any search trick — is where the ~2× engine throughput comes from.
  • Measure a cache by the work it eliminates, not the hits it records. A 1.3 % node hit rate sounded like the table was overhead; it was actually eliminating most of the work, because hits prune subtrees. The repository's own fast-engine finding had measured the table's storage cost (0.6 % of runtime) but never its work value; this is the complementary measurement.
  • The cheap table beats the careful table. Direct-mapped with depth-preferred replacement finds ~92 % of the strict-LRU table's hits at lower per-operation cost and a fifth of the memory.

Still open: a two- or four-way set-associative table should close the remaining hit-rate gap to the LRU at depth 5 (748k vs 911k hits per decision) without the LRU's bookkeeping; the leaf is within 1.2× of the C++ leaf but not yet faster than it on dense late-game states; and the engine has no GPU or latent-mode variant, so scripted-round and native-scenario duties stay with the existing engines.

Records, artifacts, and reproduction
  • Theory TH-20260824-rust-bitboard-engine-f68fcbfd, experiment EX-20260824-rust-engine-parity-throughput-4036a91f (engineering, CHECK tier).
  • Artifacts: runs/RUN-20260824T052018Z-b88c3e22/rust-engine/{gates.log,bench.log,benchmark.json,machine-profile.json}.
  • Source: approaches/fair-expectimax/rust-engine/ (std-only cargo crate, no dependencies). Build with ./build.sh (sets target-cpu=native for single-instruction PEXT/PDEP; a bit-identical portable loop compiles without BMI2). Gates: target/release/gate_{trajectory,leaf,search} against the C++ emitters in cpp/ and the TypeScript driver in ts/.
  • Reproduce the benchmark: ./bench_all.sh <run-dir> <roots-file> 3.
  • Seed discipline: sub-blocks 0xa52760000xa5277fff of the already-opened SEEDLEASE-A52-FAST development lease; zero new seeds opened; no strength claim.

Source files