<?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>Half of my FP8 convolutions were silently running in f32 – a one-line XLA fix</title>
      <dc:creator>Yehor Cherednichenko</dc:creator>
      <pubDate>Thu, 17 Sep 2026 17:40:01 +0000</pubDate>
      <link>https://dev.to/retretor/half-of-my-fp8-convolutions-were-silently-running-in-f32-a-one-line-xla-fix-2lod</link>
      <guid>https://dev.to/retretor/half-of-my-fp8-convolutions-were-silently-running-in-f32-a-one-line-xla-fix-2lod</guid>
      <description>&lt;h2&gt;
  
  
  A stray &lt;code&gt;return&lt;/code&gt; made XLA:GPU skip FP8 rewriting for most of a module
&lt;/h2&gt;

&lt;p&gt;I'm training a search-based RL agent (Gumbel MuZero via DeepMind's &lt;a href="https://github.com/google-deepmind/mctx" rel="noopener noreferrer"&gt;mctx&lt;/a&gt;) with a small convolutional network, in JAX on a single RTX 5070 (Blackwell, sm_120). The self-play actor is the hot loop: one policy move is a root network evaluation plus 16 MCTS simulations, each of which evaluates the same network again inside a &lt;code&gt;lax.while_loop&lt;/code&gt;, for a batch of 256 environments.&lt;/p&gt;

&lt;p&gt;Blackwell has FP8 tensor cores and cuDNN 9 has FP8 convolution "graph" kernels, so the obvious experiment was to run the actor's network in &lt;code&gt;float8_e4m3fn&lt;/code&gt;. The forward pass got faster right away:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;batch&lt;/th&gt;
&lt;th&gt;bf16 forward&lt;/th&gt;
&lt;th&gt;fp8 forward&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;2.74 ms&lt;/td&gt;
&lt;td&gt;1.98 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;9.65 ms&lt;/td&gt;
&lt;td&gt;7.15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But the compiled self-play step was not using FP8 for all of its convolutions, and finding out why turned into a one-line fix in XLA itself (&lt;a href="https://github.com/openxla/xla/pull/49007" rel="noopener noreferrer"&gt;PR&lt;/a&gt;). This post is the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting custom calls
&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_move&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;obs&lt;/span&gt;&lt;span class="p"&gt;,&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;On the GPU backend an FP8 convolution that cuDNN will run natively shows up as a custom call with target &lt;code&gt;__cudnn$convForwardGraph&lt;/code&gt; (the "graph" API, with scaling and clamping fused in). A plain convolution is &lt;code&gt;__cudnn$convForward&lt;/code&gt;. So the check is a &lt;code&gt;grep&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what is compiled&lt;/th&gt;
&lt;th&gt;&lt;code&gt;convForwardGraph&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;convForward&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one network forward&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two forwards on the same params, same computation&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;root forward + the same forward inside a &lt;code&gt;while_loop&lt;/code&gt; (the real policy move)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The network has 20 FP8 convolutions (the single legacy call is the first layer, which stays in bf16 by design). Two copies in one computation: 40 graph convolutions, as expected. Root forward plus loop body: only &lt;strong&gt;one&lt;/strong&gt; copy got the FP8 rewrite. The other 20 convolutions were compiled as legacy convolutions on operands converted back to f32.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong hypothesis first
&lt;/h2&gt;

&lt;p&gt;My first guess was operand sharing: the FP8 weights and their scales are parameters used both by the root forward and, through the loop carry, by the body — maybe the rewriter refuses to touch a convolution whose operand has more than one user. Row 2 of the table already argued against it (two forwards on the same parameters both get rewritten), and wrapping the parameters in &lt;code&gt;optimization_barrier&lt;/code&gt; for one or both copies changed nothing. The difference between rows 2 and 3 is not the operands. It is that the two copies live in &lt;strong&gt;different HLO computations&lt;/strong&gt;: the entry computation and the while body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the pass
&lt;/h2&gt;

&lt;p&gt;The rewrite is done by &lt;code&gt;CudnnFusedConvRewriter&lt;/code&gt; in &lt;code&gt;xla/backends/gpu/transforms/cudnn_fused_conv_rewriter.cc&lt;/code&gt;. Its &lt;code&gt;RunImpl&lt;/code&gt; loops over the non-fusion computations of the module and, for each of them, first runs &lt;code&gt;F8GraphConv&lt;/code&gt; (the FP8 → graph-conv rewrite), then a dozen bias/activation fusions for non-FP8 convolutions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HloComputation&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt;
     &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;MakeNonfusionComputations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;execution_threads&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;compute_capability_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsRocm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;compute_capability_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOneAPI&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;compute_capability_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuda_compute_capability&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;ABSL_ASSIGN_OR_RETURN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;F8GraphConv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dnn_version_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toolkit_version_&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;)&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;changed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// &amp;lt;-- exits the pass, not the loop&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;ABSL_ASSIGN_OR_RETURN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FuseRemoveConvertInConv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="n"&gt;any_changed&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... more fusions, all `any_changed |= changed`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other step in that loop accumulates into &lt;code&gt;any_changed&lt;/code&gt;. The FP8 step alone &lt;code&gt;return&lt;/code&gt;s. So the first computation in which an FP8 convolution gets rewritten ends the whole pass, and every FP8 convolution in every later computation is never visited. Computations are visited callees first, which is why in my case the loop body won and the entry computation lost.&lt;/p&gt;

&lt;p&gt;Downstream nothing complains: a later fallback pass sees FP8 convolutions that were not turned into graph convolutions, converts their operands to f32 and lowers them as ordinary cuDNN convolutions. The program is correct. It is just quietly slower, and the only symptom is the custom-call count.&lt;/p&gt;

&lt;p&gt;The fix is the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;any_changed&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;continue&lt;/code&gt; rather than falling through keeps the previous behaviour for the rewritten computation (the remaining fusions only match the legacy conv custom calls, so they have nothing to do there) and lets the loop reach the other computations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it end to end
&lt;/h2&gt;

&lt;p&gt;XLA lives inside the &lt;code&gt;jax-cuda12-pjrt&lt;/code&gt; plugin, so verifying the fix meant rebuilding that wheel from the JAX 0.10.2 sources against a local XLA tree with the patch (about an hour on 28 cores with Bazel). With the rebuilt plugin the policy move compiles to 40 graph convolutions out of 40, and a three-copy variant to 60 out of 60.&lt;/p&gt;

&lt;p&gt;The self-play numbers for one policy move at batch 256 and 16 simulations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;actor&lt;/th&gt;
&lt;th&gt;move time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16&lt;/td&gt;
&lt;td&gt;54 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp8, entry copy demoted to f32 (stock plugin)&lt;/td&gt;
&lt;td&gt;39 ms, −25 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp8 everywhere (patched plugin)&lt;/td&gt;
&lt;td&gt;−27 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix itself is worth only two percentage points &lt;em&gt;in my workload&lt;/em&gt;, and the reason is instructive: the demoted copy was the root evaluation, which runs once per move, while the 16 evaluations inside the loop were the copy that got the rewrite. Had the order been the other way round, the loop would have run at bf16-or-worse speed and FP8 would have looked like a loss. Which copy loses is an accident of computation order, not of your code.&lt;/p&gt;

&lt;p&gt;A 20-line repro with no network at all, the same FP8 convolution once in the entry computation and once in a while body:&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;jax&lt;/span&gt;&lt;span class="p"&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;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;jnp&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jax&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lax&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&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;lax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;conv_general_dilated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="n"&gt;dimension_numbers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NHWC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HWIO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NHWC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                                    &lt;span class="n"&gt;preferred_element_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@jax.jit&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                            &lt;span class="c1"&gt;# entry computation
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;i&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;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float8_e4m3fn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# while body
&lt;/span&gt;    &lt;span class="k"&gt;return&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="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ones&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float8_e4m3fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ones&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;jnp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float8_e4m3fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hlo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&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;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&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;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hlo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;custom_call_target=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__cudnn$convForwardGraph&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;hlo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;custom_call_target=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__cudnn$convForward&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stock &lt;code&gt;jax-cuda12-pjrt&lt;/code&gt; 0.10.2 prints &lt;code&gt;1 1&lt;/code&gt;. The patched plugin prints &lt;code&gt;2 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The PR adds a regression test to &lt;code&gt;cudnn_fused_conv_rewriter_test.cc&lt;/code&gt;: a module whose two &lt;code&gt;conditional&lt;/code&gt; branches each contain an FP8 convolution; both branches must come out as graph convolutions. Without the fix the test fails on the second branch, which still carries the legacy &lt;code&gt;__cudnn$convForward&lt;/code&gt; target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is affected
&lt;/h2&gt;

&lt;p&gt;Any XLA:GPU program that has FP8 convolutions in more than one HLO computation: a while/scan loop next to code outside it, both branches of a &lt;code&gt;cond&lt;/code&gt;, a &lt;code&gt;jax.checkpoint&lt;/code&gt;-ed block next to an un-checkpointed one. FP8 matmuls are not affected (the GEMM rewriter loops over computations correctly), and neither are TPU or ROCm (the FP8 graph-conv path is CUDA-only). The early return is in the XLA shipped with jaxlib 0.10.2 and is still on &lt;code&gt;main&lt;/code&gt; as of this writing; the pass's FP8 tests only ever had single-computation modules, so nothing caught it.&lt;/p&gt;

&lt;p&gt;To check your own program: count &lt;code&gt;custom_call_target="__cudnn$convForward"&lt;/code&gt; (with the closing quote, so that it does not match &lt;code&gt;...Graph"&lt;/code&gt;) in the compiled HLO. If it is not zero for a network you quantized to FP8, look at which computation those calls live in.&lt;/p&gt;

&lt;p&gt;Until the fix lands, the workarounds are unattractive but simple: keep all FP8 convolutions in a single computation (for example, run the root evaluation as iteration zero of the loop), or accept that one copy runs in f32 and choose which one by keeping the other in bf16 explicitly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. I blamed the data flow, not the pass.&lt;/strong&gt; Operand sharing, aliasing, &lt;code&gt;optimization_barrier&lt;/code&gt; — all plausible, all wrong, and each one cost an hour of compile-and-count. The pass source was 1,700 lines and the answer was on line 1,700. Reading the pass that owns the custom call you are missing is a better first step than theorizing about the graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. FP8 self-play was a win for the actor and a loss for the agent.&lt;/strong&gt; The 25–27 % faster policy move is real, but in my training runs the FP8 actor learned worse: from a cold start it failed to take off, and after switching to FP8 once the policy had ignited, the final score on my evaluation set dropped from 0.86 to 0.79 for the same budget. That is a separate story about what MCTS does with slightly noisier value estimates, but it is why I tell it here: the compiler fix is worth having, and it does not make FP8 a free lunch for search-based RL.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The compiled HLO is the ground truth for "is my model running in FP8". &lt;code&gt;.compile().as_text()&lt;/code&gt; plus counting custom-call targets takes a minute.&lt;/li&gt;
&lt;li&gt;When a compiler pass produces the right result for one part of a program and silently not for another, look at how the pass iterates before looking at what the parts contain.&lt;/li&gt;
&lt;li&gt;A fallback path that makes a program correct but slow is the hardest kind of bug to notice. Count kernels, not just check outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The fix is open as &lt;a href="https://github.com/openxla/xla/pull/49007" rel="noopener noreferrer"&gt;openxla/xla#49007&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>performance</category>
      <category>compilers</category>
      <category>gpu</category>
    </item>
    <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>
