How the engine comparison is measured¶
This page says exactly what the cross-engine comparison measures, where the engines are and are not comparable, and how to rerun it on your own machine. It is for anyone who wants to check the numbers, rerun them after a change, or add another engine. Terms such as decision and seat are defined in the glossary.
What one run is¶
A run is one operating-system process that plays whole two-player games between two fixed decks, single-threaded, pinned to one CPU core, for a fixed amount of CPU time. A row is one engine, one player policy and one deck pair; every row is run three times (three reps) and the tables report the median with the min-max spread of the three.
flowchart LR
S[xengine-bench.sh] -->|"taskset -c CORE"| P1[rep 0: core 24]
S --> P2[rep 1: core 26]
S --> P3[rep 2: core 28]
P1 --> W{JVM?}
W -->|yes| WU[warm-up: whole games<br/>for WARM s, discarded]
W -->|no: Go| M
WU --> M[measure: whole games<br/>for SECS CPU-seconds]
M --> R[runs.jsonl: games, turns,<br/>decisions, CPU s, peak RSS,<br/>load, core contention]
R --> T[summary table:<br/>median and min-max]
Metrics¶
| Metric | Definition | Why it is reported |
|---|---|---|
| games per core-second | measured games ÷ CPU seconds of the measured window | the headline: how many self-play games one core produces |
| ms per game | the inverse, in milliseconds | easier to read for the slow engines |
| µs per turn | CPU µs ÷ turns played | normalises game length; turn is each engine's own turn counter, which counts both players' turns in all four engines |
| µs per decision | CPU µs ÷ decisions answered | normalises game length per choice, but each engine splits a game into decisions differently (below), so compare it within one engine, not across |
| peak RSS | the process's maximum resident set size (/usr/bin/time %M) |
memory cost of one game-playing process, card database included |
CPU time is the process's user + system time over the measured window, so it includes every thread of the process: for the Go engines the garbage collector, for the JVM engines the garbage collector, the JIT compiler and XMage's AI search threads. With the process pinned to one core, CPU time and wall time agree to within a few per cent. Forge's driver reports wall time per game; on a pinned core that is the core-time the game used.
Decisions differ by engine:
| Engine | A decision is | Counted from |
|---|---|---|
| gorge | every decision.Decision the engine poses: priority, each mana tap (manual policies), each target, attack and block declarations, mulligans, choices |
enginebench detail.Decisions |
| dirty-dirac | every intent the kernel asks a policy for | kplay intents per game |
| XMage | each priority, selectAttackers and selectBlockers call on the game thread (targets and other asks are not counted) |
the driver's counter |
| mtg-kernel | every advance_until_decision call on the raw engine API (every priority window, each mana ability) |
the driver's counter |
| Forge | not counted: the driver has no per-decision hook | - |
The workload¶
Two FDN (Foundations) limited deck pairs from
cmd/enginebench/testdata/decks,
the same files every engine reads:
| Pair | Seat A | Seat B |
|---|---|---|
| A | FDN_top_04956_UG |
FDN_top_20626_WG |
| B | FDN_top_07961_WR |
FDN_top_02581_UR |
They are 40-card decks of 17lands-sourced (CC BY 4.0) Premier Draft lists. FDN is used because it is the one card pool every engine here plays in full: FDN is one of dirty-dirac's generated, lockstep-verified card pools, while gorge, XMage and Forge have every card.
mtg-kernel implements 245 of FDN's 286 card names at the measured commit
(and 452 card definitions in all, most of them for its Pauper pool). Pair A
is exactly its two FDN fixture decks, so it plays pair A; pair B needs four
cards it lacks (Goblin Negotiation, Leyline Axe, Drakuseth, Maw of Flames and
Imprisoned in the Moon), so it has no pair-B row. A second workload is played
for it: the Burn mirror (burn.json, the 60-card mono-red Pauper list
from mtg-kernel's own pool, MIT), which is its home deck. gorge, XMage and
Forge play Burn too; dirty-dirac does not (it has no generated pool for it). Burn is
played as Pauper constructed: 60 cards, 20 life.
Seats alternate every game, 20 life, 7-card hands, no mulligans except
where an engine's own AI decides one (XMage and Forge AIs may mulligan; the
gorge and dirty-dirac runs use their default of no mulligan round). Every game
has a turn cap (300 turns in enginebench, 100 in the other drivers); in the
published rows no game reached it, stalled or failed. The one exception was
left out of the tables: XMage's base ComputerPlayer on the Burn mirror casts
nothing, and every one of its games hit the cap without a winner.
The player policies¶
The policy is the biggest difference between rows, so every row says which one it uses. There are two classes.
Random legal action measures the engine almost alone: the policy is a few lines, so the cost is listing the legal actions and applying the chosen one.
| Engine | Row | What the policy does |
|---|---|---|
| gorge | random-autopay |
uniform over the priority options with mana paid automatically (payment plans, no individual taps), uniform over every other decision. The closest match to the other engines' random players. |
| gorge | random |
uniform over every option including each individual mana tap, so it wastes mana and plays about twice as many turns per game |
| dirty-dirac | random |
uniform over the kernel's options at every intent |
| XMage | random |
at each priority, a uniformly random playable ability or pass (the rule of XMage's own MCTS rollout player SimulatedPlayerMCTS), random attackers, random blockers. Targets, modes and the other asks are answered by XMage's base ComputerPlayer heuristics, so this row carries some AI cost |
| mtg-kernel | random |
the random policy of mtg-kernel's own benchmark (random_action_for_decision in examples/bench_kernel.rs): uniform over every legal action through the raw engine API, every priority window and every mana ability asked separately (like gorge's manual-tap random), each eligible attacker with probability 1/2, a block with probability 0.35. The driver adds one thing: a dual land's colour, which that policy (written for mono-coloured decks) does not choose, is picked at random from the colours the engine offers |
| Forge | - | Forge ships no random player; this comparison has no Forge random row |
Each engine's own AI measures what a game costs with the engine's built-in bot on both seats. These AIs are not the same strength or the same algorithm, so read these rows as "the price of a game with the bot this engine ships", not as engine speed.
| Engine | Row | The AI |
|---|---|---|
| gorge | bot |
the default heuristic bot (heuristic.NewBot), manual mana taps |
| gorge | bot-autopay |
the same bot with auto-pay (EnableAutoPayMana) |
| dirty-dirac | hbot |
a native port of gorge's auto-pay heuristic bot, which agrees with it on over 99.9% of priority decisions; compare it with gorge's bot-autopay |
| XMage | simple |
the base ComputerPlayer: no look-ahead. Its games run long (it rarely races) |
| XMage | mad |
ComputerPlayer7, "Computer - mad", the XMage client's default AI, at skill 1 (search depth 4, at most 3 s per decision) |
| Forge | ai |
Forge's PlayerControllerAi with the default AI profile on both seats, as Forge plays AI-vs-AI matches |
| mtg-kernel | - | no row: its built-in searchers (the kernel-native search opponent, the model-guided search) are wired to its training and evaluation contracts and have no standalone game runner, so they were not driven here |
Separating engine cost from AI cost: within one engine, the random row is the
engine floor and the AI rows add the AI on top. In gorge the default bot plays
games faster than the random player, because it ends games sooner (about 18
turns against 23-42) and its decisions are cheap; in XMage, mad costs about
an order of magnitude more per turn than random, because its search copies
and simulates the game.
Pinning, concurrency and noise¶
- Every run is one process pinned with
taskset -c CORE; Go runs withGOMAXPROCS=1, and a JVM pinned to one core picks the serial garbage collector by itself (-XX:+PrintFlagsFinalshowsUseSerialGCergonomic). JVM heap is capped at-Xmx2g. - The three reps of a row run at the same time, on cores 24, 26 and 28 (three different physical cores of a 16-core / 32-thread Ryzen 9 9950X; their SMT siblings are not reserved). Rows and engines run one after another, never together. The concurrency cuts the wall time by three; the cost is that the reps share the chip's L3 cache and memory bandwidth with each other (and with whatever else runs on the machine).
- The 1-minute load average before and after each run is recorded, and so is
core contention: the run's core's busy time (from
/proc/stat) beyond the run's own CPU time. A rep with more than a few per cent of other work on its core is rerun; the summary prints the worst rep's contention. - The box is shared with other work; the load average during the published run is printed beside every table. Variation between reps was small (see the min-max columns), which is the evidence that the numbers are stable at this load, not a guarantee that they hold on an idle machine.
JVM warm-up¶
A JVM engine starts interpreted and gets faster as the JIT compiles its hot
code: XMage's random games went from 11 to about 60-75 turns per second over
the first 15 games (about 15 s) on this machine. Each JVM run therefore plays
whole games for WARM = 40 CPU-seconds first and discards them, then
measures for SECS = 60 CPU-seconds. The reported JVM numbers are steady
state after warm-up. Start-up (loading the card database: 8-10 s for XMage,
6 s for Forge) and warm-up are excluded from the per-game numbers but included
in peak RSS. The Go engines need no warm-up; enginebench plays a few games
before its timer starts, and its corpus load (and kplay's, about 1-3 s) is
excluded the same way. One asymmetry works against dirty-dirac: kplay
writes a JSON line per game (about 1 KB) inside its measured window, while
enginebench writes only its summary.
What is not comparable¶
Read the comparison with these in mind
- Rules coverage differs. XMage and Forge implement every card and every format; gorge implements most of Forge's card corpus (the coverage ratchet is on the support pages); dirty-dirac is a two-player kernel that plays only its generated card pools. A faster engine that plays fewer cards is not doing the same job.
- Decisions are split differently. gorge asks for each mana tap and each target; XMage's counted decisions are priority, attack and block only; Forge's are not counted. Compare µs per decision within an engine.
- The AIs differ in kind. gorge's bot and dirty-dirac's port are
cheap one-ply heuristics; XMage's
madis a depth-limited game-tree search; Forge's AI is a large rule-based player. Their game lengths differ too. - XMage's random row is not fully random: non-priority asks go to its base AI.
- mtg-kernel plays a few hundred cards. It is a Rust kernel for a
fixed pool (452 card definitions: its Pauper decks and 245 FDN names),
against Forge's ~34,000-card corpus, about 94% of which gorge plays.
Its FDN registry is behind a cargo feature (
limited-fdn-fixtures), and that build is several times slower on the Burn mirror than the default build (32 against 157 games per core-second in a smoke run), so each workload uses the build that plays it: pair A the FDN build, Burn the default build. - One machine, one load. All numbers come from one shared machine at a moderate load. Relative positions are robust to that; absolute numbers are not.
Rerunning it¶
The harness is scripts/xengine-bench.sh
(bash, with the stdlib-only
scripts/xengine-bench-summarize.py
for the deck conversion and the table). It plays every engine that is set up
and prints the summary table; the raw per-run lines stay in
$OUT/runs.jsonl.
1. gorge alone (about 4 minutes)¶
git clone https://github.com/adams-shaun/gorge && cd gorge
make fetch-cards # the Forge card corpus, pinned, into .cards/
OUT=/tmp/xengine CORES="2 4 6" SECS=30 scripts/xengine-bench.sh gorge
Choose CORES on your machine: three idle cores on different physical cores
(lscpu -e shows which logical CPUs share a core).
2. XMage (one-time build about 2 minutes plus the download)¶
The XMage build comes from
gorge-compliance, which
builds XMage out of tree for its compliance oracle. The benchmark driver,
XEngineBench.java, lives there beside the oracle driver.
git clone https://github.com/adams-shaun/gorge-compliance && cd gorge-compliance
make xmage-oracle-setup XMAGE_ORACLE_DIR=$HOME/xmage # JDK 17 + Maven + XMage at XMAGE_REF, built
D=$HOME/xmage
(cd $D/mage && $D/maven/bin/mvn -q -B -pl Mage.Tests dependency:build-classpath \
-Dmdep.outputFile=$D/tests.cp -Dmdep.includeScope=test)
cd ../gorge
JAVA=$D/jdk/bin/java XMAGE_TESTS=$D/mage/Mage.Tests XMAGE_CP=$D/tests.cp \
XMAGE_BENCH=../gorge-compliance/oracle/xmageoracle/bench/org/mage/test/bench/XEngineBench.java \
OUT=/tmp/xengine CORES="2 4 6" SECS=60 WARM=40 scripts/xengine-bench.sh xmage
3. Forge¶
Forge is built by gorge-compliance's make forge-oracle-setup (forge-core,
forge-game and forge-ai at FORGE_ORACLE_REF from gorge's Forge fork, with
their classpath in cp.txt). The whole-game driver is forge.oracle.GameReplay
in the fork's forge-oracle module, on branch
oracle-harness
(since abbeafb93a); clone it with
git clone -b oracle-harness https://github.com/adams-shaun/forge.git.
cd gorge-compliance && make forge-oracle-setup FORGE_ORACLE_DIR=$HOME/forgeoracle
F=$HOME/forgeoracle
cd ../gorge
JAVA=$HOME/xmage/jdk/bin/java FORGE_CP=$F/cp.txt FORGE_RES=$F/forge/forge-gui/res \
FORGE_SRC=<forge checkout>/forge-oracle/src/main/java \
OUT=/tmp/xengine CORES="2 4 6" SECS=60 WARM=40 scripts/xengine-bench.sh forge
The Forge driver stops the JVM once WARM + SECS seconds of games are
written, and the warm-up games are dropped when the run is recorded.
4. dirty-dirac¶
dirty-dirac is the maintainers' private kernel, so its row cannot be rerun
from the public repositories. Its published rows were measured on
2026-10-20, before the kernel redesign of 2026-10-21
(Performance), with the same script, which
calls the kernel's game runner kplay when KPLAY names a binary built from
the private module:
KPLAY=<kplay binary> OUT=/tmp/xengine CORES="24 26 28" SECS=30 scripts/xengine-bench.sh dirty-dirac
# each run: kplay -cards .cards -deckfile <the pair, as JSON> -pairs-file <200000 games, seats alternating>
# -pairs 0 -a random|hbot -b random|hbot -wall 30s -out <games.jsonl>
5. mtg-kernel¶
mtg-kernel (MIT) was measured
at 058d075e30532b15a6417cafd7086e5b98fd88da (2026-10-20) with the Rust
toolchain its rust-toolchain.toml pins (1.94.1), release profile (thin LTO,
one codegen unit). Its own benchmark, examples/bench_kernel.rs, plays
random Burn mirrors; gorge adds a small mode to it, --xengine-json, that
plays any two deck lists for a fixed CPU time with that benchmark's own random
policy and prints one JSON line. The mode is gorge's code (Apache-2.0 or MIT),
in cmd/enginebench/scripts/xengine/mtgkernel/:
xengine.rs and apply.py, which copies it into a checkout and adds the
three-line dispatch to bench_kernel's main. Nothing from mtg-kernel is
committed to gorge.
git clone https://github.com/jackmaiorino/mtg-kernel && cd mtg-kernel
git checkout 058d075e30532b15a6417cafd7086e5b98fd88da
(cd mtg-kernel && python3 ../../gorge/cmd/enginebench/scripts/xengine/mtgkernel/apply.py \
../../gorge/cmd/enginebench/scripts/xengine/mtgkernel/xengine.rs)
CARGO_TARGET_DIR=$PWD/target cargo build --release -p mtg-kernel --example bench_kernel
CARGO_TARGET_DIR=$PWD/target-fdn cargo build --release -p mtg-kernel --example bench_kernel \
--features limited-fdn-fixtures # about 10 minutes each on 3-4 cores
cd ../gorge
MTGKERNEL_BENCH=../mtg-kernel/target-fdn/release/examples/bench_kernel MK_PAIRS=A \
OUT=/tmp/xengine CORES="2 4 6" SECS=30 scripts/xengine-bench.sh mtg-kernel
MTGKERNEL_BENCH=../mtg-kernel/target/release/examples/bench_kernel MK_PAIRS=burn \
OUT=/tmp/xengine CORES="2 4 6" SECS=30 scripts/xengine-bench.sh mtg-kernel
The driver refuses a deck with a card mtg-kernel does not fully support
(preflight_fully_supported_deck) instead of measuring something else, warms
up for MK_WARM (default 5) CPU-seconds, reads its CPU time from
/proc/self/stat, and counts halted games, engine errors and capped games
(none in the published run). The other engines play the Burn rows with
PAIRS=burn.
Reading the output¶
runs.jsonl holds one line per run: engine, row, pair, rep, core,
load1_before/load1_after, maxrss_mb, games, turns, decisions,
cpu_s, games_per_cpu_s, ms_per_game, us_per_turn, us_per_decision,
warmup_games, errors (stalls, unfinished or harness-failed games) and
other_busy (the core contention above). Rerun the table with
python3 scripts/xengine-bench-summarize.py $OUT/runs.jsonl.
Search bots¶
The search rows measure what one MCTS simulation costs, the number that decides how much look-ahead a search bot can afford. Each run is one process on one pinned core, three reps at once, 30 seconds, FDN pair A.
| Row | What runs | How cost is counted |
|---|---|---|
| gorge MCTS, heuristic leaf | enginebench -row az -sims N (build tag enginebench_az): the azmcts seat behind az-redeal, every simulation over a freshly re-dealt world of what the seat cannot see, a heuristic leaf value, gorge's heuristic bot playing every unsearched decision inside the search. It searches priority roots from turns 5-8 of seeded pair-A games |
CPU time inside the search only, divided by simulations (sims_per_cpu_s) and by searched roots |
| dirty-dirac MCTS, heuristic leaf | the kernel's game runner with an MCTS seat on both sides (-a az:sims=N -b az:sims=N): the same algorithm shape (re-dealt worlds, the native port of gorge's heuristic bot as the in-search player), heuristic leaf |
process CPU over whole games (searched and unsearched decisions, the engine between searches), divided by completed simulations; so it slightly overstates the search's own cost against the gorge row |
| dirty-dirac MCTS, network leaf | the same with :gonet=<weights>: a small multilayer perceptron (one 64-wide layer, about 2.4 MB of weights) evaluated in-process on the CPU as the leaf value |
as above |
No network leaf is measured for gorge, because enginebench's az row has no
network option. No mtg-kernel search row is published, for the reason given
under the player policies.
The cost split comes from profiles: for dirty-dirac the kernel's own
per-simulation timers (a kprof build of its runner), for gorge
go tool pprof on enginebench -cpuprofile.
go build -tags enginebench_az -o /tmp/eb-az ./cmd/enginebench
for n in 25 100 1000; do taskset -c 24 env GOMAXPROCS=1 /tmp/eb-az -row az -sims $n -secs 30; done
The dirty-dirac rows come from the private module's game runner, run as
kplay -cards .cards -deckfile <pair A> -pairs-file <games> -pairs 0 -a az:sims=N[:gonet=W] -b az:sims=N[:gonet=W] -wall 30s
with GOMAXPROCS=1 on one pinned core.
Adding an engine¶
An engine needs a driver that plays whole games between two .dck deck lists
on one thread, pinned, and reports games, turns, decisions and CPU time for a
measured window after any warm-up. Add a <engine>_run and <engine>_all
function to the script and a KIND to --record in the summarizer. Other
engines were not added because none is installed on the measuring machine.