<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yehor Cherednichenko</title>
    <description>The latest articles on DEV Community by Yehor Cherednichenko (@retretor).</description>
    <link>https://dev.to/retretor</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041960%2Fda218f12-e4e0-4a69-a6a3-b2048d566c8c.png</url>
      <title>DEV Community: Yehor Cherednichenko</title>
      <link>https://dev.to/retretor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/retretor"/>
    <language>en</language>
    <item>
      <title>Accidentally quadratic: buffer copies made MCTS in DeepMind's mctx 3 slower</title>
      <dc:creator>Yehor Cherednichenko</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:14:20 +0000</pubDate>
      <link>https://dev.to/retretor/accidentally-quadratic-buffer-copies-made-mcts-in-deepminds-mctx-3x-slower-59kl</link>
      <guid>https://dev.to/retretor/accidentally-quadratic-buffer-copies-made-mcts-in-deepminds-mctx-3x-slower-59kl</guid>
      <description>&lt;p&gt;I'm training an AlphaZero-style agent (Gumbel MuZero via DeepMind's &lt;a href="https://github.com/google-deepmind/mctx" rel="noopener noreferrer"&gt;mctx&lt;/a&gt;) 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.&lt;/p&gt;

&lt;p&gt;While benchmarking training configurations I hit this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCTS simulations per move&lt;/th&gt;
&lt;th&gt;training throughput&lt;/th&gt;
&lt;th&gt;XLA compile time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;143 episodes/s&lt;/td&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;47 episodes/s&lt;/td&gt;
&lt;td&gt;13 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;9 episodes/s&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Doubling the simulation budget should roughly double the cost — each simulation is one network call plus some tree bookkeeping. Instead, 16→32 costs &lt;strong&gt;×3&lt;/strong&gt; and 32→64 costs &lt;strong&gt;×5&lt;/strong&gt;. 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 (&lt;a href="https://github.com/google-deepmind/mctx/pull/116" rel="noopener noreferrer"&gt;PR #116&lt;/a&gt;), with bitwise-identical search results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling out the network
&lt;/h2&gt;

&lt;p&gt;First, components in isolation (batch 128): one network evaluation takes &lt;strong&gt;~0.8 ms&lt;/strong&gt;, and the rest of &lt;code&gt;recurrent_fn&lt;/code&gt; (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 &lt;strong&gt;362 ms&lt;/strong&gt;. Hundreds of milliseconds were going somewhere else.&lt;/p&gt;

&lt;p&gt;To localize them I benchmarked three variants of the same policy step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;full&lt;/strong&gt; — production setup;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;no-net&lt;/strong&gt; — network replaced by constant logits, real environment;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tree-only&lt;/strong&gt; — no network &lt;em&gt;and&lt;/em&gt; no environment: &lt;code&gt;recurrent_fn&lt;/code&gt; returns the embedding unchanged. Nothing left but mctx's own tree machinery.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sims&lt;/th&gt;
&lt;th&gt;full&lt;/th&gt;
&lt;th&gt;no-net&lt;/th&gt;
&lt;th&gt;tree-only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;10.7 ms&lt;/td&gt;
&lt;td&gt;3.7 ms&lt;/td&gt;
&lt;td&gt;3.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;20.9 ms&lt;/td&gt;
&lt;td&gt;8.3 ms&lt;/td&gt;
&lt;td&gt;8.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;64.7 ms&lt;/td&gt;
&lt;td&gt;34.0 ms&lt;/td&gt;
&lt;td&gt;33.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;362.1 ms&lt;/td&gt;
&lt;td&gt;124.7 ms&lt;/td&gt;
&lt;td&gt;125.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;em&gt;per-simulation&lt;/em&gt; cost doubles every time the &lt;em&gt;number&lt;/em&gt; of simulations doubles. That's O(N²) machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What grows with N inside a simulation
&lt;/h2&gt;

&lt;p&gt;mctx stores the search tree as struct-of-arrays: six &lt;code&gt;[B, N+1, A]&lt;/code&gt; buffers (&lt;code&gt;children_index&lt;/code&gt;, &lt;code&gt;children_prior_logits&lt;/code&gt;, &lt;code&gt;children_values&lt;/code&gt;, &lt;code&gt;children_visits&lt;/code&gt;, &lt;code&gt;children_rewards&lt;/code&gt;, &lt;code&gt;children_discounts&lt;/code&gt;). With my shapes at 64 simulations that's six 57.5 MB buffers, ~345 MB of tree state. Each simulation runs three phases: &lt;em&gt;simulate&lt;/em&gt; (walk down the tree in a &lt;code&gt;while_loop&lt;/code&gt;), &lt;em&gt;expand&lt;/em&gt; (evaluate the leaf), &lt;em&gt;backward&lt;/em&gt; (walk back up, updating value statistics).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;backward&lt;/code&gt; is the interesting one. It carries the &lt;strong&gt;entire tree&lt;/strong&gt; as the &lt;code&gt;lax.while_loop&lt;/code&gt; state, and each iteration both &lt;strong&gt;reads&lt;/strong&gt; from &lt;code&gt;children_values&lt;/code&gt;/&lt;code&gt;children_visits&lt;/code&gt; (gathers for the parent update) and &lt;strong&gt;scatters&lt;/strong&gt; single elements back into the same buffers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the compiled HLO
&lt;/h2&gt;

&lt;p&gt;JAX makes it easy to look at what XLA actually compiled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;txt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;policy_step&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;states&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;as_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A grep for &lt;code&gt;copy&lt;/code&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are &lt;code&gt;children_values&lt;/code&gt; (f32) and &lt;code&gt;children_visits&lt;/code&gt; (s32) — copied &lt;strong&gt;in full, on every iteration of the backward loop&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;Now the quadratic makes sense, because &lt;em&gt;two&lt;/em&gt; things grow with the simulation budget N:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The buffers&lt;/strong&gt;: &lt;code&gt;[B, N+1, A]&lt;/code&gt; grows linearly with N.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The walk length&lt;/strong&gt;: 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 &lt;code&gt;search_tree.parents&lt;/code&gt;): ~1 at 16 sims, ~4 at 32, up to &lt;strong&gt;15&lt;/strong&gt; at 64 with a rained network. And since &lt;code&gt;backward&lt;/code&gt; is vmapped, every simulation pays for the &lt;em&gt;deepest&lt;/em&gt; batch element out of 128.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The backward pass doesn't need the whole tree in its loop carry. The patch (&lt;a href="https://github.com/google-deepmind/mctx/pull/116" rel="noopener noreferrer"&gt;PR #116&lt;/a&gt;) 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 &lt;strong&gt;single scatter per array after the loop&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;body_fun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;
  &lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_from_parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# ... same value recursion as before, into small path buffers:
&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_BackwardState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leaf_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;leaf_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
      &lt;span class="n"&gt;path_parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_parents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;path_node_values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_node_values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent_value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;...)&lt;/span&gt;

&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;while_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cond_fun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_fun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initial_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;children_values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children_values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_parents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_actions&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_children_values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;children_visits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;   &lt;span class="c1"&gt;# same single-scatter treatment for the other
&lt;/span&gt;    &lt;span class="n"&gt;node_values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;node_visits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;  &lt;span class="c1"&gt;# three updated arrays
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unused path slots point at an out-of-range index and are dropped by the&lt;br&gt;
scatters. The value recursion is arithmetically identical and runs in the&lt;br&gt;
same order, so this is not an approximation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;same PRNG key → &lt;strong&gt;bitwise-identical&lt;/strong&gt; &lt;code&gt;action&lt;/code&gt; and &lt;code&gt;action_weights&lt;/code&gt; from &lt;code&gt;gumbel_muzero_policy&lt;/code&gt; (checked at 16 and 32 simulations, batch 128);&lt;/li&gt;
&lt;li&gt;the full mctx test suite passes, including the golden-tree comparisons that directly validate backward's outputs;&lt;/li&gt;
&lt;li&gt;in my trainer, fixed-seed A/B runs produce &lt;em&gt;identical&lt;/em&gt; metric traces — only the throughput changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Policy step, interleaved A/B in a single process, trained checkpoint:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sims&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;29.3 ms&lt;/td&gt;
&lt;td&gt;27.0 ms&lt;/td&gt;
&lt;td&gt;×1.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;72.3 ms&lt;/td&gt;
&lt;td&gt;48.5 ms&lt;/td&gt;
&lt;td&gt;×1.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;394.7 ms&lt;/td&gt;
&lt;td&gt;120.8 ms&lt;/td&gt;
&lt;td&gt;×3.27&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per-simulation cost after the fix is flat — 1.5–1.9 ms across 16/32/64&lt;br&gt;
simulations — instead of growing with the budget.&lt;/p&gt;

&lt;p&gt;End-to-end training (full loop with SGD and reward verification, 4096&lt;br&gt;
episodes per config):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sims&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;th&gt;compile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;138.6 eps/s&lt;/td&gt;
&lt;td&gt;152.0 eps/s&lt;/td&gt;
&lt;td&gt;×1.10&lt;/td&gt;
&lt;td&gt;5.9 → 5.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;46.6 eps/s&lt;/td&gt;
&lt;td&gt;73.6 eps/s&lt;/td&gt;
&lt;td&gt;×1.58&lt;/td&gt;
&lt;td&gt;12.7 → 8.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;9.1 eps/s&lt;/td&gt;
&lt;td&gt;29.8 eps/s&lt;/td&gt;
&lt;td&gt;×3.27&lt;/td&gt;
&lt;td&gt;62.9 → 19.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Until the PR lands you can monkeypatch — the function is module-level and&lt;br&gt;
looked up at trace time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mctx._src.search&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;msearch&lt;/span&gt;
&lt;span class="n"&gt;msearch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backward&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backward_fast&lt;/span&gt;  &lt;span class="c1"&gt;# install before the first jit trace
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four things I got wrong along the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. I first blamed the action space.&lt;/strong&gt; With A = 1729 the "obvious" fix is&lt;br&gt;
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 &lt;em&gt;nothing&lt;/em&gt; (117 vs 121 ms at 64 sims). The full-buffer copies &lt;em&gt;were&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;max_depth&lt;/code&gt; looked like the lever, and wasn't.&lt;/strong&gt; Capping tree depth&lt;br&gt;
attacks the walk length, but only trims the tail: 394.7 → 337.4 ms alone,&lt;br&gt;
120.8 → 111.2 ms on top of the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cross-process GPU benchmarks kept lying to me.&lt;/strong&gt; My RTX 5070 idles at&lt;br&gt;
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 &lt;em&gt;slower&lt;/em&gt; 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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Benchmark with a trained network, not just a fresh one.&lt;/strong&gt; A trained&lt;br&gt;
(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&lt;br&gt;
per-example maximum averaged 11.6 vs 5.6. Deeper trees mean more backward&lt;br&gt;
iterations, so the superlinear term grows as the agent improves — the&lt;br&gt;
same-process checkpoint-vs-random gap at 64 sims was 394.7 vs 341.8 ms&lt;br&gt;
pre-fix. Fittingly, my own first estimate of this effect was a scary ×2.2,&lt;br&gt;
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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If a JAX &lt;code&gt;while_loop&lt;/code&gt; carries big buffers and both reads and writes them in   one iteration, check the HLO for defensive copies. &lt;code&gt;.compile().as_text()&lt;/code&gt; plus grep is a ten-minute test that can be worth a ×3 speedup.&lt;/li&gt;
&lt;li&gt;Decompose before optimizing: the net/no-net/tree-only split localized the problem in three benchmark runs.&lt;/li&gt;
&lt;li&gt;Caveat: measured on GPU (CUDA). On TPU the buffer assignment may already
handle this; the patch should be neutral there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The fix is open as &lt;a href="https://github.com/google-deepmind/mctx/pull/116" rel="noopener noreferrer"&gt;google-deepmind/mctx#116&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>performance</category>
      <category>gpu</category>
    </item>
  </channel>
</rss>
