Accidentally quadratic: buffer copies made MCTS in DeepMind's mctx 3 slower
I'm training an AlphaZero-style agent (Gumbel MuZero via DeepMind's mctx) to lay out working factory modules for Factorio: the network places machines, belts and inserters on a grid, and the reward comes from an exact throughput verifier. Everything runs in JAX on a single RTX 5070: 128 environments in one batch, an action space of A = 1729 (3 entity types ร 144 cells ร 4 rotations + "done"), and a small 474k-parameter conv net in bf16. While benchmarking training configurations I hit this:
| MCTS simulations per move | training throughput | XLA compile time |
|---|---|---|
| 16 | 143 episodes/s | 5 s |
| 32 | 47 episodes/s | 13 s |
| 64 | 9 episodes/s | 60 s |
Doubling the simulation budget should roughly double the cost - each simulation is one network call plus some tree bookkeeping. Instead, 16โ32 costs ร3 and 32โ64 costs ร5. Something in the search was superlinear, and this post is the story of finding it in the compiled HLO and fixing it by rewriting one ~80-line function (PR #116), with bitwise-identical search results.
Ruling out the network
First, components in isolation (batch 128): one network evaluation takes ~0.8 ms, and the rest of recurrent_fn (environment step + observation + legal-action mask) adds almost nothing on top - the whole function is also ~0.8 ms. So at 64 simulations the network accounts for roughly 50 ms per move. But a full policy step at 64 simulations costs 362 ms. Hundreds of milliseconds were going somewhere else.
To localize them I benchmarked three variants of the same policy step:
- full - production setup;
- no-net - network replaced by constant logits, real environment;
- tree-only - no network and no environment:
recurrent_fnreturns the embedding unchanged. Nothing left but mctx's own tree machinery.
| sims | full | no-net | tree-only |
|---|---|---|---|
| 8 | 10.7 ms | 3.7 ms | 3.8 ms |
| 16 | 20.9 ms | 8.3 ms | 8.3 ms |
| 32 | 64.7 ms | 34.0 ms | 33.7 ms |
| 64 | 362.1 ms | 124.7 ms | 125.0 ms |
The pure tree machinery is superlinear all by itself. Per simulation it costs 0.47 โ 0.52 โ 1.05 โ 1.95 ms as the budget goes 8 โ 16 โ 32 โ 64: the per-simulation cost doubles every time the number of simulations doubles. That's O(Nยฒ) machinery.
What grows with N inside a simulation
mctx stores the search tree as struct-of-arrays: six [B, N+1, A] buffers (children_index, children_prior_logits, children_values, children_visits, children_rewards, children_discounts). With my shapes at 64 simulations that's six 57.5 MB buffers, ~345 MB of tree state.
Each simulation runs three phases: simulate (walk down the tree in a while_loop), expand (evaluate the leaf), backward (walk back up, updating value statistics). backward is the interesting one. It carries the entire tree as the lax.while_loop state, and each iteration both reads from children_values / children_visits (gathers for the parent update) and scatters single elements back into the same buffers.
Reading the compiled HLO
JAX makes it easy to look at what XLA actually compiled:
txt = jax.jit(policy_step).lower(params, key, states).compile().as_text()
A grep for copy ops on the tree-shaped buffers turned up this in the backward loop body (this dump is from a 16-simulation compile, so the middle dimension is N+1 = 17):
%copy.1 = f32[128,17,1729]{2,1,0} copy(%get-tuple-element.512)
%copy = s32[128,17,1729]{2,1,0} copy(%get-tuple-element.513)
Those are children_values (f32) and children_visits (s32) - copied in full, on every iteration of the backward loop, i.e. on every step of the leaf-to-root walk. XLA:GPU cannot prove the scatter can safely alias the input buffer (the same tensor is gathered from and scattered into within one loop iteration), so it materializes a defensive copy of each buffer, each iteration.
Now the quadratic makes sense, because two things grow with the simulation budget N:
- The buffers:
[B, N+1, A]grows linearly with N. - The walk length: with Gumbel sequential halving, the champion root action receives ~N/4 visits, and the deterministic interior selection extends a chain below it. Measured max tree depth (from
search_tree.parents): ~1 at 16 sims, ~4 at 32, up to 15 at 64 with a rained network. And sincebackwardis vmapped, every simulation pays for the deepest batch element out of 128.
Copies of size O(NยทA) times a depth that grows with N - there's the superlinear term. It also explains the compile-time blowup (5 โ 13 โ 60 s): XLA's buffer assignment and scheduling passes work over ever-larger buffers.
The fix
The backward pass doesn't need the whole tree in its loop carry. The patch (PR #116) walks the leaf-to-root path with only O(num_nodes) state - the path indices, actions, and the updated per-node statistics - and then applies each statistic with a single scatter per array after the loop:
def body_fun(state):
index = state.index
parent = tree.parents[index]
action = tree.action_from_parent[index]
# ... same value recursion as before, into small path buffers:
return _BackwardState(
step=step + 1,
index=parent,
leaf_value=leaf_value,
...,
path_parents=state.path_parents.at[step].set(parent),
path_node_values=state.path_node_values.at[step].set(parent_value),
...)
end = jax.lax.while_loop(cond_fun, body_fun, initial_state)
return tree.replace(
children_values=tree.children_values.at[end.path_parents, end.path_actions].set(
end.path_children_values, mode="drop"),
children_visits=..., # same single-scatter treatment
node_values=...,
node_visits=...)
# three updated arrays
Unused path slots point at an out-of-range index and are dropped by the scatters. The value recursion is arithmetically identical and runs in the same order, so this is not an approximation: same PRNG key โ bitwise-identical action and action_weights from gumbel_muzero_policy (checked at 16 and 32 simulations, batch 128); the full mctx test suite passes, including the golden-tree comparisons that directly validate backward's outputs; in my trainer, fixed-seed A/B runs produce identical metric traces - only the throughput changes.
Results
Policy step, interleaved A/B in a single process, trained checkpoint:
| sims | before | after | speedup |
|---|---|---|---|
| 16 | 29.3 ms | 27.0 ms | ร1.08 |
| 32 | 72.3 ms | 48.5 ms | ร1.49 |
| 64 | 394.7 ms | 120.8 ms | ร3.27 |
Per-simulation cost after the fix is flat - 1.5-1.9 ms across 16/32/64 simulations - instead of growing with the budget.
End-to-end training (full loop with SGD and reward verification, 4096 episodes per config):
| sims | before | after | speedup | compile |
|---|---|---|---|---|
| 16 | 138.6 eps/s | 152.0 eps/s | ร1.10 | 5.9 โ 5.4 s |
| 32 | 46.6 eps/s | 73.6 eps/s | ร1.58 | 12.7 โ 8.7 s |
| 64 | 9.1 eps/s | 29.8 eps/s | ร3.27 | 62.9 โ 19.4 s |
Until the PR lands you can monkeypatch - the function is module-level and looked up at trace time:
import mctx._src.search as msearch
msearch.backward = backward_fast # install before the first jit trace
Four things I got wrong along the way
I first blamed the action space. With A = 1729 the "obvious" fix is Sampled-MuZero-style top-K action candidates (search over K = 128 candidates instead of the full space). I prototyped it - and on top of the backward fix it gained nothing (117 vs 121 ms at 64 sims). The full-buffer copies were the A-scaled term; once they're gone, per-node work over 1729 actions is cheap. Know your actual bottleneck before reaching for the textbook fix.
max_depthlooked like the lever, and wasn't. Capping tree depth attacks the walk length, but only trims the tail: 394.7 โ 337.4 ms alone, 120.8 โ 111.2 ms on top of the fix.Cross-process GPU benchmarks kept lying to me. My RTX 5070 idles at 495 MHz and boosts to 2932 MHz under load. The same compiled graph measured 34 ms in one process and 57 ms in another, depending on clock ramp-up - a ร1.7 phantom difference that sent me chasing ghosts (an early version of this very fix looked slower than baseline for exactly this reason). All the A/B numbers above come from interleaved measurements inside a single process (variant A, B, C, A, B, Cโฆ, medians over rounds).
Benchmark with a trained network, not just a fresh one. A trained (peaked) policy builds much deeper trees than a randomly initialized one: at 64 sims the deepest tree in the batch reached 15 levels vs 10, and the per-example maximum averaged 11.6 vs 5.6. Deeper trees mean more backward iterations, so the superlinear term grows as the agent improves - the same-process checkpoint-vs-random gap at 64 sims was 394.7 vs 341.8 ms pre-fix. Fittingly, my own first estimate of this effect was a scary ร2.2, measured across two processes - i.e. contaminated by exactly the clock-drift artifact from the previous point.
Fresh-net benchmarks still measure the cheapest point of your training run.
Takeaways
- If a JAX
while_loopcarries big buffers and both reads and writes them in one iteration, check the HLO for defensive copies..compile().as_text()plusgrepis a ten-minute test that can be worth a ร3 speedup. - Decompose before optimizing: the net/no-net/tree-only split localized the problem in three benchmark runs.
- Caveat: measured on GPU (CUDA). On TPU the buffer assignment may already handle this; the patch should be neutral there.
The fix is open as google-deepmind/mctx#116.
Comments
No comments yet. Start the discussion.