Benchmarks
How fast the Rust engine is on one core and on a whole 24-core machine, how it compares with the shipped game run headless, how long its searches and a PPO training iteration take, and how it compares with two other Slay the Spire 2 simulators. All measured on one server on 14 October 2026.
- engine actions per second on one core (59,400 random-play fights per second)
- 1.30 M
- actions per second on the whole machine, 48 processes
- 31.0 M
- the shipped game's best measured throughput on one core
- 1,500×
- the previous engine (12 Oct 2026), same fights
- 2.25×
- a typical PPO training iteration, down from 609 s
- 208 s
| What | Result | Compared with what |
|---|---|---|
| One core | 59,400 fights/s · 1.30 M actions/s · 766 ns per action | Random play: Ironclad's starter deck against the 22 Act-1 encounters; one process on one core, the core's second hardware thread idle |
| Whole machine | 1.41 M fights/s · 31.0 M actions/s | 48 processes on 24 cores / 48 threads: 23.5× the one-process run of the same series |
| Previous engine | 2.25× faster | The engine of 12 Oct 2026 on the same fights, identical results; 7 interleaved pairs, 2.17–2.37× |
| Shipped game, one core | 1,500× (1,630× with both hardware threads busy on each side) | The game's best measured on one core: 16 game processes sharing it (the most tried; still rising from 8 to 16, CPUs 89% busy), 871 actions/s |
| Shipped game, whole machine | 2,430× | The game's best on all 24 cores: 192 processes, 12,700 actions/s |
| Shipped game, CPU time | 2,970× (2,250× against its game thread alone) | One warm game process: 2.28 ms of CPU per action (1.73 ms on the game thread) against 766 ns |
| Browser hint, 1,000 simulations | 18.4 ms as WASM in Chromium · 13.7 ms native | 1.93× faster than the previous engine in Chromium |
| PPO training | 208 s per typical iteration instead of 609 s (2.93×); 150 iterations in about 9 h instead of 25 h | The deployed Ironclad recipe (960 games, 46 workers, one RTX 3090) on the engine training ran before |
| sansb/ | Ours is 3.13× faster per core on random play with no relic on either side, each engine stepping its own way (3.39× with our relic live; 1.63× on sansb's own fixture with a state copy per step, as sansb steps); 3.52× on the whole machine with our Burning Blood live (about 3.25× without it, scaled by the one-core ratio) | Combat only, game build v0.111.0, certified against real-game checksums; its review solver searches the true state by design (hindsight reviews) |
| lain-wood/ | sts2core is 1.56× faster per core (1.64–2.99× on sansb's fixture), 1.44× on the whole machine | Combat only, effectively Ironclad only, partial content, its own RNG by design, validated on recordings of real play; its live solver is given the true draw-pile order (partly clairvoyant) |
Rounding: most measured values have three significant figures; actions per fight have two decimals and percentages whole points; counts, dates and build ids are exact. Medians over repeated runs. The unrounded values, the commands and the identity checks are in the benchmark write-up.
The machine
| System | ASUS ESC4000A-E10 server, one socket, one NUMA node |
| CPU | AMD EPYC 7443P (Zen 3 "Milan"): 24 cores, 48 hardware threads (SMT on) |
| Clock | boost up to 4.04 GHz (top non-boost state 2.85 GHz), schedutil governor. Measured: 3.91 GHz with one busy process, 3.65 GHz with 48 |
| Caches | 32 KiB L1d, 32 KiB L1i and 512 KiB L2 per core; 128 MiB L3 in four 32 MiB domains of six cores (CPUs 0–5, 6–11, 12–17, 18–23, each with its SMT siblings 24 higher) |
| Memory | 256 GiB DDR4-3200 registered ECC, 4 × 64 GB; 4 of the CPU's 8 memory channels populated (102.4 GB/s peak) |
| GPUs | 4 × NVIDIA GeForce RTX 3090, 24 GiB each; training uses one |
| OS | Ubuntu 24.04.4, Linux 6.8, no isolated CPUs, transparent huge pages on request (madvise) |
| Rust | 1.96.0; release profile with fat LTO, one codegen unit, opt-level 3, no target-cpu, no profile-guided optimisation (except the training build marked PGO) |
| .NET | 9.0.15 runtime, default settings (tiered JIT, dynamic PGO) |
| Python, PyTorch | CPython 3.13.12; torch 2.12.1 with CUDA 13.0 |
| Browser | headless Chromium 149 (Playwright 1.61); Node 25.9 |
The host is shared with other users, so every timed run waited for it to go quiet first (Method). Three definitions hold on the whole page:
- One core: one single-threaded process pinned to one physical core (CPU 20) with that core's second hardware thread (CPU 44) idle. The shipped game runs several threads per process, so for it one core means one game process and its Python driver confined to both hardware threads of that core.
- Both threads of one core: two of our processes on CPUs 20 and 44. For the game: 2 to 16 game processes sharing CPUs 20 and 44.
- Whole machine: all 24 cores and 48 threads. For a Rust engine, the better of 24 processes (one per core) and 48 (one per hardware thread); for the game, the best of 24 to 384 processes. Each table says which one won.
An action is one state transition: a card play or an end of turn. Whole-machine rates are the sum of the processes' own rates, all released at the same instant.
Engine throughput
The workload is random play: Ironclad at Ascension 0, the 10-card starter deck at 80/80 HP and Burning Blood, against the 22 Act-1 Overgrowth encounters in turn (4 weak, 12 normal, 3 elites, 3 bosses), with uniformly random legal moves until the fight ends. Each fight starts from a copy of a start state built the way a real run builds it. A run is 110,000 fights: 2,200 seeds × 50. The random player wins 32.4% of them, with 22.0 actions per fight.
| Previous (12 Oct) | New | |
|---|---|---|
| Fights per second | 26,400 | 59,400 |
| Actions per second | 581,000 | 1.30 M |
| Time per action | 1,720 ns | 766 ns |
| Instructions per action | 13,500 | 6,520 |
| Instructions per cycle | 1.97 | 2.07 |
| Clock while running | 3.90 GHz | 3.88 GHz |
The new engine is 2.25× faster (median of 7 interleaved pairs; 2.17–2.37×). It executes half the instructions per action (the previous one needs 2.08×) and the fights are identical: the same 2,417,920 actions, the same 35,646 wins and the same digest over every fight's length and outcome in all 14 runs.
| Preset | CPUs | Processes | Fights/s | Actions/s | Per process | Efficiency | GHz |
|---|---|---|---|---|---|---|---|
| 1 core | 20 (44 idle) | 1 | 60,100 | 1.32 M | 1.32 M | 1.00 | 3.91 |
| 1 core, both threads | 20, 44 | 2 | 64,500 | 1.42 M | 709,000 | 0.537 | 3.90 |
| 1 L3 domain, 1 thread per core | 18–23 | 6 | 357,000 | 7.84 M | 1.31 M | 0.989 | 3.87 |
| 1 L3 domain, both threads | 18–23, 42–47 | 12 | 381,000 | 8.37 M | 697,000 | 0.528 | 3.86 |
| 12 cores, 3 per L3 domain | 0–2, 6–8, 12–14, 18–20 | 12 | 710,000 | 15.6 M | 1.30 M | 0.985 | 3.87 |
| 24 cores, 1 thread each | 0–23 | 24 | 1.37 M | 30.2 M | 1.26 M | 0.953 | 3.79 |
| 48 threads | 0–47 | 48 | 1.41 M | 31.0 M | 645,000 | 0.488 | 3.65 |
Each process plays 440,000 fights. Efficiency = the aggregate rate ÷ (processes × the same repeat's one-process rate), so a value near 0.5 on the two-thread presets means each core still does about the work of one process. Every process of every preset played identical fights (one digest).
Measured the same way, the previous engine reaches 13.8 M actions/s on the whole machine (48 processes), so the new one is 2.24× faster there as well (2.23× on one core of the same series). With every core busy the clock falls from 3.91 to 3.79 GHz (24 processes) and 3.65 GHz (48), which accounts for most of the missing 5% at 24 processes.
Against the shipped game
What "headless" means. The game's own assembly,
sts2.dll of build v0.107.1 from a copy of the game, IL-patched
and run by the project's harness on .NET 9 with Godot stubbed out: no
window, no rendering, no animations. A Python driver talks to it as JSON
over a pipe and plays the same fights as the Rust benchmark, choosing
uniformly at random from the game's own list of legal actions. It is the
same oracle the parity suite checks the engine against. Random play gives
the same statistics on both: over 8,448 fights the game wins 32.2% with
21.98 actions per fight, our engine 32.4% with 21.98 over 110,000, and 21
of the 22 encounters agree within two standard errors (largest |z| 2.05).
Why one game process is mostly asleep. After every
card play the harness waits for the game's action queue to drain by
polling it: Thread.Sleep(1) until five idle checks in a row
(WaitForActionExecutor in
sts2-pycli/). The game thread
goes to sleep 8.6 times per action, and a warm game process is on the CPU
for only 22% of its play time: 2.28 ms of CPU in 10.4 ms per action. The
wall time of one process therefore measures the harness's sleeping as
much as the game. The fair comparisons are CPU time and throughput with
many game processes sharing the CPUs, as many as raised the game's
throughput in the layouts tried.
| Comparison | The shipped game | Our engine | Ratio |
|---|---|---|---|
| One process, cold: a fresh process per fight (the old site figure) | 17.9 ms wall per action | 766 ns | 23,400× |
| One process, warm: many fights per process | 10.4 ms wall per action | 766 ns | 13,500× |
| CPU time per action, warm process | 2.28 ms (whole process) · 1.73 ms (game thread) | 766 ns | 2,970× · 2,250× |
| One core, best measured | 871 actions/s (16 processes on CPUs 20 and 44) | 1.30 M (one thread) · 1.42 M (both threads) | 1,500× · 1,630× |
| Whole machine, best measured | 12,700 actions/s (192 processes) | 31.0 M (48 processes) | 2,430× |
The fair headline: against the game's best measured process count (16 on one core, 192 on the machine), our engine does about 1,500 times the shipped game's work on one core and 2,430 times on the whole machine. Per unit of CPU time it is 2,250 to 2,970 times in one warm game process (game thread or whole process), and 954× at the lowest across every process layout measured. On one core the game had not levelled off at 16 processes, so the per-core ratio may be somewhat lower with more.
Our process is CPU-bound, so its 766 ns per action is also its CPU time. The game's figures lean in the game's disfavour in two small ways: its CPU time includes the harness's own work (building the state dictionary on the game thread, JSON on the main thread), and its throughput cells include a reset of about 30 ms per fight (reloading the save and entering the room), which our engine replaces with copying a start state. In one warm process the game does 96.5 actions/s without the reset and 85.8 with it. Across all the process layouts below, the game thread's CPU per action ranges from 0.73 to 1.73 ms, so the game-thread CPU-time ratio ranges from 954× to 2,250× (whole process: 1,200× to 2,970×).
Why "23,000×" is retired
Earlier versions of this site said the engine was about 23,000× faster than the shipped game. That compared the wall time of one game process per fight, started cold, with one Rust thread: today 17.9 ms per action against 766 ns, 23,400×. Of those 17.9 ms, about 2.3 ms is the game's warm CPU work, about 7.8 ms is extra CPU a fresh process spends on its first fight (JIT compilation and cold start), and about 7.8 ms is time off the CPU in the harness's 1 ms polling sleeps. It measured that setup correctly, but it is neither a throughput nor a language comparison.
| Layout | Processes | Actions/s | Per core | CPUs busy |
|---|---|---|---|---|
| One core, 1 warm process | 1 | 85.8 | 85.8 | – |
| One core, shared | 2 | 174 | 174 | 23% |
| One core, shared | 4 | 352 | 352 | 31% |
| One core, shared | 8 | 627 | 627 | 60% |
| One core, shared | 16 | 871 | 871 | 89% |
| Whole machine, 1 per core | 24 | 2,140 | 89.1 | 13% |
| Whole machine, 1 per thread | 48 | 4,240 | 177 | 15% |
| Whole machine, unpinned | 48 | 4,250 | 177 | 19% |
| Whole machine, 4 per core | 96 | 7,900 | 329 | 38% |
| Whole machine, 8 per core | 192 | 12,700 | 530 | 67% |
| Whole machine, 16 per core | 384 | 12,500 | 521 | 86% |
Every process carries its own Python driver on the same CPUs. Warm processes play 220 untimed fights first. The game never fills the CPUs: with 384 processes they are 86% busy and throughput is already lower than with 192. The whole-machine best is 61% of 24 × the one-core best. All layouts of a group played the same fights with the same digest, and 66 reference fights replayed step for step as in the earlier measurement.
Search speed
The browser hint's MCTS search on a fixed position (seed BENCH1, the first fight: Fuzzy Wurm Crawler at 57 HP against the starter hand), the same call as the workbench's bench button. Native is one thread on one core; WASM is the browser build, in Node and in headless Chromium on the same machine, timed without the Web Worker round trip.
| Simulations | Native | WASM, Node | WASM, Chromium | Chromium ÷ native | vs previous native · Chromium |
|---|---|---|---|---|---|
| 200 | 2.79 | 3.90 | 3.90 | 1.40 | 1.95× · 1.90× |
| 1,000 | 13.7 | 18.6 | 18.4 | 1.35 | 1.96× · 1.93× |
| 2,000 (the hint's default) | 27.9 | 39.6 | 39.9 | 1.43 | 1.97× · 1.90× |
| 4,000 | 58.8 | 79.4 | 79.4 | 1.35 | 1.95× · 1.92× |
| Threads | Previous | New | Simulations/s | Speed-up |
|---|---|---|---|---|
| 1 (CPU 20) | 491 ms | 275 ms | 29,100 | 1.79× |
| 8, two whole L3 domains (CPUs 6–17) | 76.4 ms | 39.6 ms | 202,000 | 1.90× |
| 8, one worker pinned per CPU (6–13) | 73.2 ms | 39.4 ms | 203,000 | 1.86× |
The advisor's position: Ironclad at Ascension 5 with a 16-card deck at 55/80 HP against the boss Vantom, where a simulation costs more than in a first fight. Eight pinned threads run 6.97× as fast as one (6.95× on the 6–17 mask). In the real game the advisor runs on the player's own PC, not on this server. Both searches keep the draw order hidden, as their deployed callers do. They were timed with the search library's old default end-of-turn widening (C 0.5, α 0.4). Both now use the run agent's wider one (C 5.79, α 0.44), which made the hint's 2,000-simulation search about 8% slower natively (28.6 → 30.9 ms, same position, interleaved runs); the advisor's search has not been re-timed. The advisor's root statistics were identical on both engines in every run, and the hint picked the same first move.
On the whole machine, independent hint searches (one per process) at 1,000 simulations reach 1,490 searches/s with 48 processes and 1,480 with 24, against 72.8 on one core; at 200 simulations, 24 processes do best, 8,180 searches/s. Longer searches scale a little worse: on 24 cores the efficiency is 0.951 at 200 simulations and 0.847 at 1,000.
Training speed
Training plays whole runs: the PPO run policy for every decision outside fights, MCTS for every fight (250 simulations per decision, 1,000 at bosses). Three builds: the previous engine as training ran it before (plain build), the new engine plain, and the new engine with the profile-guided build (PGO) that training uses now. Only this project's engine appears here: the shipped game cannot copy a state for search, and the other two simulators have no run-level training loop.
| Build | CPU s per run | Runs per hour | vs previous |
|---|---|---|---|
| Previous, plain | 12.3 | 292 | 1.00 |
| New, plain | 5.02 | 718 | 2.46× |
| New, PGO | 3.91 | 921 | 3.16× |
Seeds 700–705 at Ascensions 0/3/6/10, the Ironclad champion, CPU 20; 3 runs per build, all nine with byte-identical game records. Part of the speed-up is not the engine: the new code skips the search when a decision has exactly one legal move.
A full iteration is the deployed Ironclad recipe: 960 games across Ascensions 0–10, 46 worker processes on this 48-thread machine, then a PPO update on one RTX 3090, starting from the Ironclad champion. The new builds play identical games. Measured as the trainer ran it, iteration 0 is distorted by one game: game 703 (Ascension 4) has an Act-1 boss fight that takes far longer than any other game, so for more than half of iteration 0 it runs alone while the other 45 workers wait. On the previous engine it ran past the trainer's 600 s stall watchdog, which dropped it, so that iteration finished only 959 of 960 games and its time is a lower bound.
| Previous, plain | New, plain | New, PGO | |
|---|---|---|---|
| Collection | ≥ 1,180 s | 537 s | 381 s |
| PPO update on the GPU | 34.1 s | 34.1 s | 34.2 s |
| Iteration | ≥ 1,210 s | 571 s | 416 s |
| Games per hour | ≤ 2,930 | 6,440 | 9,070 |
| CPU seconds per game | 27.4 | 11.7 | 8.43 |
| Machine busy during collection | 50% | 47% | 47% |
| Previous, plain | New, plain | New, PGO | |
|---|---|---|---|
| Collection (the other 959 games) | 575 s | 242 s | 174 s |
| Iteration (with the update) | 609 s | 276 s | 208 s |
| Games per hour | 6,010 | 14,300 | 19,900 |
| 150 iterations | 25.4 h | 11.5 h | 8.66 h |
| Speed-up per iteration | 1.00 | 2.21× | 2.93× |
| Total CPU for all 960 games | 26,700 s | 11,000 s | 7,900 s |
The throughput measures agree: total CPU falls 2.42× (plain) and 3.37× (PGO), one core 2.46× and 3.16×, a typical iteration 2.21× and 2.93×. The GPU's share is small: the update takes 34 s of the 208 s, with the GPU 87% busy during it and idle during collection. With the 46 workers busy, the whole machine plays 21.6× the games per hour of one worker on one core (PGO build; different games, so indicative).
How "typical" was measured: the same 960-game pool replayed outside the trainer without the watchdog, once per build. The replays reproduce the trainer's new-engine collection times within 0.5% and predict the previous engine's watchdog cut at 1,175 s (trainer: 1,177–1,180 s). They ran right after the quiet-host window, one at a time with the machine about 2% busy before each, but not under its gate, so treat them as a validated estimate. A real campaign's utilisation is about 0.95, not the 0.5 of this iteration 0, but it also has rare outlier iterations: 21 of 762 in the four past Ironclad campaigns on this host hit the same watchdog. As a cross-check, the current Ironclad champion's last 150-iteration leg took 23.9 h on an older engine, close to the 25.4 h projected for the previous one.
Other simulators
This section measures two other open-source Rust simulators of Slay the Spire 2 combat, and each does something better than this project: sansb/sts2-sim certifies its fights against checksums the real game writes while it is played, which this project does not have yet, and it tracks a newer game build than the other two; lain-wood/sts2core is the fastest of the three per core; it steps a compact 3,880-byte state by value. Both were measured at the commits linked, on this machine, with our own harnesses built against their public APIs.
| spire-mentor (this project) | sansb/sts2-sim | lain-wood/sts2core | |
|---|---|---|---|
| Measured at | 6d1918237, 14 Oct 2026 (previous engine d3ea66a51) | c61336cf, 12 Oct 2026 | 3b2d969d, 25 Sep 2026 |
| Game build | v0.107.1 | v0.111.0 (the newest of the three; v0.109.0–v0.110.1 lie between) | v0.107.1 |
| Scope | whole single-player runs: map, events, shops, rest sites, rewards, three acts, Ascension 0–10 | combat only, from a fully specified start state; Python tools rebuild run context from captures | combat only: a rules kernel, an in-combat solver and a deck advisor |
| Characters | all 5 | all 5 (Ironclad is the largest share of its validation, 43–48% per set) | effectively Ironclad only (our reading of its 79 recordings and its tables; its docs do not say) |
| Content | an id for every current content class of the game (580 card ids for its 577 cards plus 3 legacy/internal; 297 relics, 63 potions, 121 monsters, 69 events, 23 enchantments; an id does not prove the behaviour); 3 of the 6 event-started fights are skipped: two give a stand-in outcome instead (one grants a placeholder relic), the third is not ported | 596 cards, 299 relics, 64 potions, 105 monster kinds, 88 encounters; content it has not ported is refused, never approximated | 117 cards incl. a placeholder (112 of the 128 in its reference catalog), 105 enemies incl. 3 synthetic and an unknown placeholder, 118 relic entries of which 103 modelled, 5 of the game's 22 enchantments; the Queen boss missing; values from Ascension 8 up taken from the game's code but not yet checked against its recordings (A0–A3) |
| Reproduces the game's RNG | yes: .NET System.Random ported, all 15 run and player streams | yes: all 9 combat streams | no, by design: its own streams for shuffles, enemy rolls, HP and rosters |
| Validated how | lockstep against the game's own assembly run headless, compared after every action (enemy intents and card afflictions are not in the compared state): full fuzz tier of random moves, Ironclad 6,104/6,104 clean (30 Jun 2026); Silent 5,732/5,762, Defect 5,740/5,752, Regent 5,740/5,762, Necrobinder 5,716/5,752 (7 Jul 2026, a deferred tail still open); run level 1,232/1,250 clean (7 Jul 2026, 5 characters × A0/2/5/8/10, random-policy runs that mostly end in Act 1; deeper Act-2 checks in June still had open divergences); no full sweep since; no checksummed real-game captures yet; five suspected bugs from 13–14 Oct open | lockstep against the checksums the real game writes after each action: 1,196 of the author's 1,206 captured fights replay exactly and none diverges (the other 10 are truncated, refused or unusable captures); 594 certified published fixtures; 4,205 certified A9/A10 fights from site uploads (a corpus of certified fights only; the sets overlap) (as of c61336cf) | one-step predictions on 79 recordings of real play: 1,565 match, 1 mismatch (a trace recorded before an enchantment fix), 9 content missing, 177 skipped; enemy next move 85% (353/415). Its README calls its deck advisor's absolute death rates untrustworthy, only paired differences (a caveat about the advisor, not the rules kernel) |
| Search sees hidden information? | no, for every deployed search (run AI, browser hint, real-game advisor: since July 2026 the draw order is hidden and every combat random stream reseeded); the search library's default setting is still clairvoyant | yes, by design: its exact solver and its site's reviews search the true state, draw order and RNG included, to show what was achievable in hindsight; a draw-blind lookahead (reshuffled draw pile, reseeded streams) exists for its coach's offer reviews | partly: a patched game-state exporter supplies the true draw-pile order, used as known until the next reshuffle |
| Licence | MIT | MIT | MIT |
| Rust code lines | 217,781 (engine: core + content; roughly 40% of raw lines in test modules; search lives in other crates); 365,739 (all 12 crates) | 419,659 (engine/src, incl. its solver; roughly 55–60% of raw lines in test modules) | 32,366 (src/, incl. its solver, advisor binaries and tests) |
| Bindings | Python (PyO3); WASM (the browser workbench) | WASM (its browser engine); no Python bindings | none; zero dependencies |
| Stepping | one state changed in place; a fight copies its start state once | a new state allocated for every step | a 3,880-byte plain-old-data state passed by value |
| Legal actions | one per card in hand per target (our search merges identical cards itself) | identical cards and identical targets merged | identical cards merged |
| Build | rustc 1.96.0, fat LTO, unwinding panics | rustc 1.97.1, thin LTO, aborting panics | rustc 1.96.0 (it pins none; its authors use 1.97.1), fat LTO, aborting panics |
Clairvoyance is a property of a search, not of an engine: every engine here steps a concrete state, ours included. No benchmark in this section runs a search, so it affects none of these numbers.
Lines of code: cloc, Rust code lines without
comments or blank lines, in-file test modules included, for each project's
engine source directories. Test shares are a heuristic count (lines from a
#[cfg(test)] module to the end of its file, plus test files).
sansb's and sts2core's directories include their solvers; ours keeps search
in separate crates, counted in the 365,739.
Random play, one core (W1)
The workload of the sections above. The other engines merge identical actions, so their own "uniformly random" player picks among fewer moves (it ends its turn more often) and plays a different, shorter game. Our harnesses therefore also give them the instance player: one choice per card in hand per target, the same random player as ours and the game's. Its weighting is our harness code, running inside their timed loop. With their own players, which play different games, their action rates are 0.7% (sansb) and 1.7% (sts2core) higher (medians in the table), and the ratios are 3.12× (sansb, ours with no relic) and 1.59× (sts2core).
| Engine, random player | Fights/s | Actions/s | ns per action | Wins | Actions per fight |
|---|---|---|---|---|---|
| Ours, Burning Blood live | 59,000 | 1.30 M | 770 | 32.4% | 21.98 |
| Ours, no relic | 54,700 | 1.20 M | 831 | 32.4% | 21.98 |
| sansb, instance player | 17,500 | 383,000 | 2,610 | 32.5% | 21.95 |
| sts2core, instance player | 90,200 | 2.03 M | 493 | 35.0% | 22.47 |
| sansb, its own merged-action player | 20,300 | 386,000 | 2,590 | 20.3% | 19.01 |
| sts2core, its own merged-action player | 106,000 | 2.06 M | 485 | 21.0% | 19.41 |
Paired by round: ours ÷ sansb is 3.13× with no live relic on either side (Burning Blood is held but inert in sansb, whose combat-only scope leaves out its after-fight heal) and 3.39× with ours live. Ours ÷ sts2core is 0.640: sts2core is 1.56× faster, with Burning Blood modelled on both. Our engine is 7.6% slower with no relic than with Burning Blood on identical fights (it runs 3.1% more instructions per action); the cause is not investigated, so both figures are given.
| Preset | Processes | Ours | sansb | sts2core |
|---|---|---|---|---|
| 1 core | 1 | 1.32 M | 388,000 | 2.01 M |
| 1 core, both threads | 2 | 1.42 M | 341,000 | 2.20 M |
| 1 L3 domain, 1 thread per core | 6 | 7.84 M | 2.29 M | 11.6 M |
| 24 cores, 1 thread each | 24 | 30.2 M | 8.79 M | 44.7 M |
| 48 threads | 48 | 31.0 M | 7.52 M | 44.7 M |
Best against best (in bold): ours ÷ sansb 3.52× (ours with Burning Blood live, as in the per-core 3.39×; about 3.25× like for like, scaled by the one-core ratio; there is no relic-free whole-machine run), ours ÷ sts2core 0.692 (sts2core 1.44×, Burning Blood live on both). The second hardware thread helps ours (+3% across the machine), makes no difference to sts2core and costs sansb 14.5% (48 threads ÷ 24 cores = 0.855).
sansb's own benchmark fixture (W2)
sansb's sts-sim bench plays 240,000 random playouts from
one Ironclad start state against two Toadpoles (68/80 HP, no relic),
allocating a new state for every step. The fixture leaves the ascension
field out, which sansb reads as Ascension 10, so all three engines play it
at A10. Our "copy per step" mode does what sansb does; "in place" is how
our own searches step.
| Engine, mode | Actions/s | Playouts/s | Wins | Actions per playout |
|---|---|---|---|---|
| Ours, a copy per step | 658,000 | 35,300 | 88.7% | 18.7 |
| Ours, in place | 1.19 M | 63,800 | 88.7% | 18.7 |
| sansb, instance player | 404,000 | 21,000 | 88.2% | 19.3 |
sansb, sts-sim bench as published | 414,000 | 24,300 | – | 17.0 |
| sts2core, instance player | 1.95 M | 106,000 | 89.3% | 18.5 |
| sts2core, its own merged-action player | 1.95 M | 114,000 | 61.1% | 17.2 |
Ours ÷ sansb with the same player: 1.63× copying per step, 2.95× in place. Ours ÷ sts2core: 0.335 copying, 0.610 in place. On this fixture our lead over sansb is smaller than on W1, and sts2core's lead over ours larger.
| Benchmark | As published by its authors (their hardware) | Re-run on this machine |
|---|---|---|
sansb sts-sim bench (W2 above) | 1,049,000 transitions/s, one thread on an Apple M5 Max, best of 5 | 414,000 actions/s, CPU 20, median of 5 |
sansb eval_throughput: random playouts from its 594 certified real-game fixtures | median 302,000 transitions/s | median 120,000 (10th–90th percentile 66,700–338,000) |
sts2core bench: a 28-card deck against a 300-HP synthetic target, a player that never ends its turn by choice | 3.56–3.61 M steps/s on 1 thread, 54.9–56.5 M on 32 threads; its authors call their machine noisy, "not a baseline" | 2.68 M on 1 thread, 44.4 M on 24 threads, 55.3 M on 48 |
Published figures come from the authors' own machines (sansb: an Apple M5 Max; sts2core: a Windows PC), so only the re-run column is comparable with the rest of this page.
What makes the comparison fair, and where it is not
- The same work. With the instance player, random play agrees per encounter: sansb with ours on 19 of the 22 encounters within two fight-level standard errors (which understate seed noise on randomized rosters), sts2core on 14. sts2core's largest gaps are Cubex Construct (+18 points; its monster differs from the game's code) and Flyconid, Mawler and Snapping Jaxfruit (+9 to +13; not traced). On Slithering Strangler ours is the outlier: 12.8% against 10.3% and 10.2% (12.0% over 1,600 seeds, whose 200-seed blocks vary by 0.7 points), unexplained. On Slimes (weak), sansb plays 0.86 more actions per fight than ours, unexplained.
- Merged action lists make each engine's own random player a different player; compare actions per second, or use the instance player, never fights per second across players. Merging also shrinks a search's branching; a per-action benchmark does not credit that.
- Burning Blood is inert in sansb; hence the no-relic comparison.
- Stepping. sansb allocates a state per step and sts2core copies 3,880 bytes per step; ours mutates one state. W2's copy mode puts ours on sansb's terms.
- Game build. sansb models v0.111.0, ours and sts2core v0.107.1. The W1 monsters' HP bands are identical between the two builds at A0, and none of the monsters on sansb's v0.110.1→v0.111.0 change list appears in W1; the v0.107.1→v0.110.1 changes are not documented there, and movesets were not audited.
- RNG. sts2core draws its own random numbers, so its fights match ours in distribution, not seed for seed; sansb and ours reproduce the game's streams but enumerate actions in different orders, so the same random player still picks different moves.
- Builds. Each engine uses its own release profile
(table above). None uses
target-cpuor PGO, and all use the system allocator. - W2 is an A10 fight. Read at A0 by mistake, and with our harness opening the back Toadpole with the wrong move (since fixed, see Method), it once looked like a 10-point rules disagreement between sansb and the other two; at A10 all three agree within about one point of win rate, and turns 1 and 2 match exactly. From turn 3 on, sansb's playouts run about 3–4% more actions (19.3 per playout against 18.7 and 18.5), unexplained.
Method
- When and where. 14 October 2026, 10:59–16:33 UTC, on the machine above. Before every timed stage the host had to be quiet: 1-minute load below 1.5, the whole machine below 3% busy over 5 s, and the stage's own CPUs and their SMT siblings below 5% busy. At the 58 gate passes the load was 1.38 and the machine 2% busy (medians). Whole-machine runs, ours and the game's, used the whole-machine part of the gate only, because background noise from other users on single CPUs never let the per-CPU check pass for the game's cells. The training stages checked only the whole-host busy share (≤ 3%); the 1-minute load at those 15 gate passes was 1.09–7.3.
- Pinning. Single-threaded runs on CPU 20 with CPU 44 idle; WASM on CPUs 18–23; the advisor as in its table; the game's one-core cells on CPUs 20 and 44. Whole-machine runs fork every process, pin it, and release all of them at the same instant.
- Interleaving. Previous and new engine alternate (previous, new, new, previous, …): 7 pairs for random play, 5 for each search runtime, 4 for the advisor; the three training builds run in reversed order every round (3 rounds on one core, 2 full iterations). Cross-engine runs rotate their order every round. Values are medians; speed-ups are medians of the paired ratios.
- Identity checks. A timing ratio is only a speed-up if both sides did the same work, so every harness prints a digest of it: every fight's length and outcome, a search's root statistics, a position fingerprint, or a hash of every training record. All 111 checks passed, and no published run was forced on a busy host.
- A harness fix. Before this benchmark, our
random-fight harness built monsters through a debug scenario builder
whose monster AI and HP rolls differed from a real run's. It now builds
them as a run does. The random-play statistics barely moved, but the
numbers on this site before 14 October 2026 used the old builder;
site_random_bench --legacy-spawnreproduces them. - Not covered. Other hardware: no desktop or laptop CPU, no ARM, no Windows, no phone or tablet browser. The real game with its renderer. The advisor on a player's PC with the game running.
The full write-up, with every value unrounded, the reproduce commands, the identity checks and what was left out and why: sts2-rs/docs/perf/2026-10-14-benchmarks.md. Harnesses: site_random_bench.rs and xsim_toadpoles.rs (random play, W2), hint_bench.rs (the hint's search), and scripts/site-bench/ (whole-machine runner, the shipped game, WASM, advisor, training, and the harnesses for the other two simulators).