<?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: Rodion Kazennov</title>
    <description>The latest articles on DEV Community by Rodion Kazennov (@nefayran).</description>
    <link>https://dev.to/nefayran</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%2F3643130%2Fc65fbbf1-6ab9-44e2-a28e-96e69769e9ce.jpg</url>
      <title>DEV Community: Rodion Kazennov</title>
      <link>https://dev.to/nefayran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nefayran"/>
    <language>en</language>
    <item>
      <title>My Godot level load fell from 34.5 s to 2.7 s, and the best fix removed work nobody needed</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:31:14 +0000</pubDate>
      <link>https://dev.to/nefayran/my-godot-level-load-fell-from-345-s-to-27-s-and-the-best-fix-removed-work-nobody-needed-2e4p</link>
      <guid>https://dev.to/nefayran/my-godot-level-load-fell-from-345-s-to-27-s-and-the-best-fix-removed-work-nobody-needed-2e4p</guid>
      <description>&lt;p&gt;On the morning of 2 November the landing in my Godot 4 game, Planet Wanderer, took 34.5 seconds to load. A week later it takes 2.7. The biggest single step was not threads or caching. A footprint calculation went from 8.3 s to 0.3 s because it was walking models with 50,000 to 400,000 triangles when a copy with a few hundred would do. Below are the steps in the order I took them, with the log line that justified each, and one fix that measured as nothing.&lt;/p&gt;

&lt;p&gt;All times are from one Windows PC with 8 worker threads, read from a timing line the game prints for each part of the load. Each number comes from a separate run, so treat anything under about half a second as noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "landing" loads
&lt;/h2&gt;

&lt;p&gt;When the player picks a landing spot and presses "Sit here", the game builds a camp around the rover. It lays out two small camps and a town, places a hundred or so props, places the people who live there, plants grass and flowers, and works out where each thing stands so the grass bends away from it. Props and people are 3D models made from images, so some of them carry 50,000 to 400,000 triangles.&lt;/p&gt;

&lt;p&gt;I had been measuring the load as one number. That number only told me it was bad. Splitting it into named parts, each printed with its time, is what made everything below possible. If you do one thing from this post, do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a frame that holds for 36 seconds
&lt;/h2&gt;

&lt;p&gt;After "Sit here", one function placed the camps in a single frame: 9.3 s. Then one function planted the ground around them in a single frame: 36.7 s. The loading ring on screen stood still for most of that, because nothing could draw while the frame was busy.&lt;/p&gt;

&lt;p&gt;The fix was the boring one: make both steps advance a little each frame and let the ring move. Same total work, the screen alive. This did not make the load shorter, but it made it measurable, because the next steps could now be timed one at a time while the game kept rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: plants as one buffer (36.7 s in the part, 92 ms for the plants)
&lt;/h2&gt;

&lt;p&gt;Most of that 36.7 s was the ground plants. I moved them into a single &lt;code&gt;MultiMesh&lt;/code&gt; buffer and wrote the transforms into it directly. On the Windows PC the plants alone then measured 92 ms. After this and the next step, the whole landing load went from 34.5 s to 21.6 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the footprint walk, moved to workers (13.1 s + 4.0 s to 8.3 s)
&lt;/h2&gt;

&lt;p&gt;To make the grass lie flat around a tent, the game needs the tent's footprint: which ground cells its triangles cover. The code walked every triangle in GDScript, twice, once for the footprint and once for the feet of the people. The cost was not reading the mesh surfaces (that took 0.3 s in total). It was the walking: 13.1 s and 4.0 s.&lt;/p&gt;

&lt;p&gt;I changed it to one walk that fills both masks, and ran it on &lt;code&gt;WorkerThreadPool&lt;/code&gt;, one task per ground cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WorkerThreadPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_group_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;func&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;jb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Dictionary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jobs&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="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Dictionary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"surfs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pm"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mask"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mask"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;jb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"feet"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"feet"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&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;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"camp footprints"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nc"&gt;WorkerThreadPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_group_task_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;planet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;process_frame&lt;/span&gt;
&lt;span class="nc"&gt;WorkerThreadPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_group_task_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading scene nodes and meshes must stay on the main thread, so a first pass on the main thread collects each model's vertices and indices into plain arrays, and the workers only do arithmetic on those. The &lt;code&gt;await&lt;/code&gt; on the frame loop keeps the ring moving while they run. Result: 8.3 s on 8 threads, and the frames kept coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the fix that measured as nothing
&lt;/h2&gt;

&lt;p&gt;The camp build was almost entirely props: the "heart" of the camp took 4.2 s of 4.4. A hundred things were each being repainted (materials adjusted, one by one). My idea: paint the first thing of each model path, and let the rest be copies of it.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;first_of(path)&lt;/code&gt;, kept the first painted node, and duplicated it for the rest. The heart went from 4.2 s to 4.6 s. Slower, within noise.&lt;/p&gt;

&lt;p&gt;The reason was in the data, not the code. At the first camp every prop is the first of its path, so there were no copies to share. I only saw that because I added a per-model timing line next time instead of re-running the stopwatch around the whole function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: it was the disk read, 180 to 300 ms per model
&lt;/h2&gt;

&lt;p&gt;The per-model line said the time was almost all the first read of each model from disk: 180 to 300 ms for each of about 30 models. Painting was 0 ms. Copies and far copies cost 2 to 35 ms.&lt;/p&gt;

&lt;p&gt;So the fix was to start reading them earlier. The screen where you choose a landing spot comes before the build, and &lt;code&gt;ResourceLoader&lt;/code&gt; can load in the background while it is open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;static&lt;/span&gt; &lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;warm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_warm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;_warm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;
        &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_threaded_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The place-selection screen requests all 59 files (the models and their far copies, and the people). When the camps are built the requests are already finished and the "read" is a lookup. Measured: camp near the landing 6,957 ms to 479, town 3,378 to 617, heart props 4,924 to 29. Whole landing load 22.8 s to 13.6 s.&lt;/p&gt;

&lt;p&gt;One trap here: &lt;code&gt;warm()&lt;/code&gt; keeps the requests, and something has to collect them afterwards or they pile up in memory. A &lt;code&gt;forget_firsts()&lt;/code&gt; call frees them once the camps are up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: stop walking the big model (8.3 s to 0.3 s)
&lt;/h2&gt;

&lt;p&gt;The footprint walk was still the largest part, 8.3 s on 8 workers. I looked at which triangles it walked and found it went through every mesh under every prop, including the full 50,000 to 400,000 triangle model, and also the model's far copy: the low-triangle stand-in the game shows at a distance.&lt;/p&gt;

&lt;p&gt;For a footprint on the ground, the far copy is enough. Now when a thing has a node called &lt;code&gt;far&lt;/code&gt;, the walk takes that and stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_node_or_null&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"far"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The footprint walk went from 8.3 s to 0.3 s. I checked that the result stayed the same: plants removed 6,026 against 6,041 before, plants trampled 850 against 836. The footprints are nearly the same, because a few hundred triangles at the right silhouette give the same ground cells as 300,000 at the same silhouette. Whole load: 13.6 s to 5.7 s.&lt;/p&gt;

&lt;p&gt;This is the step I would have missed if I had stopped at "use more threads". Eight threads took 17 s of work down to 8.3 s. Doing less work took it to 0.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: a release that came too early (1.7 s to 0.3 s)
&lt;/h2&gt;

&lt;p&gt;The locals' camps took 1.7 s in one frame, and the per-model line showed they were reading their people's models from disk again. The cause: &lt;code&gt;forget_firsts()&lt;/code&gt; freed the pre-read models right after the camps were built, before the locals, which use the same files. I moved the call to after both locals' camps. Locals' camps 1,762 ms to 321. Whole load 6.3 s to 4.7 s (a different run from the 5.7 above, so the baselines differ).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: the town's free-ground calculation, and the last models
&lt;/h2&gt;

&lt;p&gt;Two smaller things took the load from 4.7 s to 2.7 s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The town's locals spent 816 ms in a free-ground calculation per cell, almost all of it on the main thread. I collected the data on the main thread and ran the triangle walk on workers, one cell per worker. 816 to 329 ms, camps 321 to 209. I compared the logged places of the locals before and after: identical to the hundredth of a metre.&lt;/li&gt;
&lt;li&gt;The camp and town cell models and the wounded person with the blanket were still read at the moment of use. Reading them early too: camp 617 to 244 ms, town 736 to 97, the wounded figure 348 to 28. Whole load 4.2 s to 2.7 s.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Whole landing load&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;start (2 Nov)&lt;/td&gt;
&lt;td&gt;one 36.7 s frame&lt;/td&gt;
&lt;td&gt;34.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 to 3&lt;/td&gt;
&lt;td&gt;frames split, plants in one buffer, footprint walk on workers&lt;/td&gt;
&lt;td&gt;21.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;models read ahead during place selection&lt;/td&gt;
&lt;td&gt;13.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;walk only the far copy&lt;/td&gt;
&lt;td&gt;5.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;locals' models kept until used&lt;/td&gt;
&lt;td&gt;4.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;town on workers, last models read ahead&lt;/td&gt;
&lt;td&gt;2.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I would do again, and the part I still do not understand
&lt;/h2&gt;

&lt;p&gt;Three habits made this go quickly. Print a named time for every part and keep the line in the log permanently. When a fix does not help (step 4), go to a finer-grained measurement before inventing the next fix. And check that a speed-up did not change the result: I compared plants removed and the locals' positions each time.&lt;/p&gt;

&lt;p&gt;What I cannot explain yet is the other machine. In the game's log from my Linux PC, slicing the ground after the load costs 39 s of CPU, against 5.8 s on the Windows PC. The numbers above are all from the Windows PC. Is that gap fewer fast cores free at that moment, a different Godot build, or something in how the workers are scheduled there? I have not measured it, and I would like to know where else a seven-fold difference like that could come from.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>performance</category>
      <category>godot</category>
      <category>gdscript</category>
    </item>
    <item>
      <title>Godot 4 animation retargeting with free mocap: five traps that look like bad animation</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Mon, 09 Nov 2026 12:31:06 +0000</pubDate>
      <link>https://dev.to/nefayran/godot-4-animation-retargeting-with-free-mocap-five-traps-that-look-like-bad-animation-5bob</link>
      <guid>https://dev.to/nefayran/godot-4-animation-retargeting-with-free-mocap-five-traps-that-look-like-bad-animation-5bob</guid>
      <description>&lt;p&gt;I retargeted free CMU motion capture onto a humanoid character in Godot 4 with &lt;code&gt;SkeletonProfileHumanoid&lt;/code&gt;. Most of the time I lost was not on the animation itself. It went on results that looked like a bad clip or a bad rig and turned out to be a setting, a facing, or a measurement that read the wrong pose. These are the five that cost the most, each with what I saw, what caused it and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Wide legs: the source's rest pose is not its stance
&lt;/h2&gt;

&lt;p&gt;The CMU skeleton rests in a wide straddle, with its ankles 0.818 m apart. In the clip itself the actor stands with the ankles 0.268 m apart. I first read the rest and called it "the source's stance", which made a correct transfer look broken.&lt;/p&gt;

&lt;p&gt;The rest also poisons the transfer. &lt;code&gt;SkeletonProfileHumanoid&lt;/code&gt; measures every motion against the rest pose. The source's legs moved inward relative to their straddled rest, so the target's legs moved by the same amount the other way and came out wide.&lt;/p&gt;

&lt;p&gt;The fix belongs on the source import, not the target: &lt;code&gt;retarget/rest_fixer/fix_silhouette/enable = true&lt;/code&gt; on the mocap file. It straightened the source rest while leaving the clip's world pose alone (the ankle gap in the take stayed 0.268 m). On the target the same option did harm: it moved the target's rest 32 cm away from its bind pose.&lt;/p&gt;

&lt;p&gt;If you also straighten the legs in your own export script, turn that off. With both on, the stance came out wrong again.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Inverted arms: &lt;code&gt;overwrite_axis&lt;/code&gt; and a rig that faces −Z
&lt;/h2&gt;

&lt;p&gt;My rig was built facing −Z, so its left side is on −X. The humanoid profile and the mocap face +Z with the left on +X. Both rigs were named correctly; the problem is that &lt;code&gt;overwrite_axis&lt;/code&gt; forces the profile's bone axes onto the target. On the legs and spine that is harmless, because those bones point along Y. On the arms, the only bones that point along X, the forced axis ran against the geometry, and every arm rotation came out inverted: the hands ended far above the hips in poses where the source has them below.&lt;/p&gt;

&lt;p&gt;Turning the character to face +Z removed the conflict. To check facing and side, I put markers in the frame (a sphere at +X, one at +Z and a box on the bone named LeftHand) instead of deducing the side from a render. Two of my three guesses from renders alone were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Measurements that read the rest pose in a headless run
&lt;/h2&gt;

&lt;p&gt;I measured legs and angles from a script run with &lt;code&gt;godot --headless -s script.gd&lt;/code&gt;. &lt;code&gt;Skeleton3D.get_bone_global_pose()&lt;/code&gt; returned the rest pose on every frame, even after &lt;code&gt;force_update_all_bone_transforms()&lt;/code&gt; and with the AnimationPlayer playing. The animation was applied: &lt;code&gt;get_bone_pose_rotation()&lt;/code&gt; on the same bone changed frame to frame. Only the global composition was stale. The numbers looked plausible, so I drew several wrong conclusions from them.&lt;/p&gt;

&lt;p&gt;Composing the chain by hand gives the right answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Transform3D&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Transform3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IDENTITY&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_bone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_bone_pose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_bone_parent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a run that renders, the API and this function agree to three decimals.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. 170 degrees of twist on a clip that looks fine
&lt;/h2&gt;

&lt;p&gt;A twist audit built on &lt;code&gt;get_bone_pose_rotation()&lt;/code&gt; reported 160 to 180 degrees of twist on almost every bone of clips that looked perfectly normal. That call returns the pose including the bone's rest orientation. Take the delta from the rest instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rest_q&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_bone_rest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;basis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_rotation_quaternion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest_q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inverse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_bone_pose_rotation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that change the same audit read about 55 degrees on the upper arms of a lively take and 8 to 14 in a calm one, which is what the eye sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A perfect T-pose at rest and one arm straight up in every pose
&lt;/h2&gt;

&lt;p&gt;This one starts in Blender, before the GLB reaches Godot. I rotated the mesh and the armature with &lt;code&gt;bpy.ops.object.transform_apply(rotation=True)&lt;/code&gt;, one after the other. Only the armature rotated. The skin's bind matrices absorbed the difference, so the exported GLB looked perfect at rest and threw one arm straight up in every animated pose.&lt;/p&gt;

&lt;p&gt;Rotating the data instead keeps the two bound together:&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rest renders cannot show this, so check it with the same markers as in trap 2, in an animated pose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checks that would have saved me the time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fix Silhouette on the mocap source only, and the source's stance measured inside the take, never from its rest.&lt;/li&gt;
&lt;li&gt;Facing and left/right verified with markers in Godot before any retarget setting is tuned.&lt;/li&gt;
&lt;li&gt;Bone measurements in headless scripts composed by hand from &lt;code&gt;get_bone_pose()&lt;/code&gt;, and rotations compared against the rest.&lt;/li&gt;
&lt;li&gt;A measurement that stays exactly the same across a parameter change means the change never reached the export. In my case a flag had switched the bones to quaternion rotation, and a later smoothing step kept editing the now inert euler channels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Judge clips in the engine, from frames captured in Godot, rather than from Blender's clay renders. Six hand-authored versions were rejected in a row while Blender's workbench renders of them looked passable; the first honest look came from frames captured in Godot. Bone world positions in the two agreed to the millimetre when I checked, so a disagreement between the two views is a misreading, not an import bug.&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>animation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>WebGPU fails silently: buffer limits, a missing COPY_SRC and default requiredLimits</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Wed, 04 Nov 2026 12:32:59 +0000</pubDate>
      <link>https://dev.to/nefayran/webgpu-fails-silently-buffer-limits-a-missing-copysrc-and-default-requiredlimits-3lmj</link>
      <guid>https://dev.to/nefayran/webgpu-fails-silently-buffer-limits-a-missing-copysrc-and-default-requiredlimits-3lmj</guid>
      <description>&lt;p&gt;The run finished at 0.04 ms per step, roughly 500 times faster than the simulation should have run at that size. The health report was clean: no NaN, no infinity, the largest force was zero, and every particle had zero neighbours. It was clean because nothing had been computed.&lt;/p&gt;

&lt;p&gt;This happened in &lt;a href="https://github.com/nefayran/protocell-genesis" rel="noopener noreferrer"&gt;protocell-genesis&lt;/a&gt;, a WebGPU simulation I built to test whether a coarse-grained "primordial soup" can assemble itself into a closed vesicle, a bilayer membrane with water trapped inside. In this model it can't, and the repository has the measured reasons. The largest run had 483,268 particles. Over the project, six bugs behaved like the one above: no exception, no error on the page, and numbers that looked plausible or even good. Three of them are specific to WebGPU. Here they are, with what each one looked like and what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A buffer larger than the device allows
&lt;/h2&gt;

&lt;p&gt;At a box size of 85 σ the Verlet neighbour list needed 5,484,474,528 bytes. The device's &lt;code&gt;maxStorageBufferBindingSize&lt;/code&gt; was 4,294,967,292.&lt;/p&gt;

&lt;p&gt;WebGPU does not throw when &lt;code&gt;createBuffer&lt;/code&gt; fails validation. It prints a warning to the console and returns an invalid buffer, and every later dispatch on a bind group that uses it is dropped. The simulation loop kept stepping and kept reading back zeros: &lt;code&gt;max|F| = 0&lt;/code&gt;, 0 non-finite values, 0 neighbours, 0.0405 ms per step.&lt;/p&gt;

&lt;p&gt;The fix is a check before allocation. &lt;code&gt;createSoup&lt;/code&gt; computes every buffer whose size scales with the particle count and compares it with the device's own limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bindingLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxStorageBufferBindingSize&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bufferLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxBufferSize&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;bindingLimit&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;bufferLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`buffer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; needs &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes at N=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;capacityN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`maxStorageBufferBindingSize=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bindingLimit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`maxBufferSize=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bufferLimit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&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;h2&gt;
  
  
  2. Reading back a buffer without COPY_SRC
&lt;/h2&gt;

&lt;p&gt;Reading data off the GPU means copying it into a mappable staging buffer. If the source buffer was created without &lt;code&gt;GPUBufferUsage.COPY_SRC&lt;/code&gt;, the copy is a validation error. That invalidates the command buffer, so the submit does nothing. The staging buffer was just created, WebGPU zero-initialises new buffers, and &lt;code&gt;mapAsync&lt;/code&gt; resolves normally. You get zeros that look like data.&lt;/p&gt;

&lt;p&gt;Two random-number buffers were missing the flag. All 72 checkpoints on disk had saved a random-number state of pure zeros, so every resumed run seeded the thermostat with 0 for every particle. With identical Langevin noise on every particle, the whole system drifts as one body: the mean squared displacement was 62.32 σ² for all seven species alike, and relative diffusion, which is what lets aggregates find each other, was gone. After the fix, the largest aggregate at step 90,000 grew from 268 to 588.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;usage&lt;/code&gt; is a readable property of a &lt;code&gt;GPUBuffer&lt;/code&gt;, so the whole class of bug costs one bitwise test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;GPUBufferUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COPY_SRC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readBack: the buffer was created without &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`GPUBufferUsage.COPY_SRC (usage=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&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;h2&gt;
  
  
  3. requestDevice() without requiredLimits
&lt;/h2&gt;

&lt;p&gt;A device requested without &lt;code&gt;requiredLimits&lt;/code&gt; gets the default limits, not the ones the adapter supports. The default allows 8 storage buffers per shader stage. The bond kernels grew past that, pipeline creation with an &lt;code&gt;'auto'&lt;/code&gt; layout failed with a console warning, and every affected pass did nothing. No bonds formed at all.&lt;/p&gt;

&lt;p&gt;The size limits fail the same way, at a particle count instead of a code change. The default &lt;code&gt;maxStorageBufferBindingSize&lt;/code&gt; is 128 MiB. With 1,000 neighbour slots per particle, the list fit at 28,400 particles (113.6 MB) and silently did nothing at 36,400 (145.6 MB).&lt;/p&gt;

&lt;p&gt;The device now asks for the adapter's own maxima:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;adapter&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestDevice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;requiredLimits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;maxStorageBuffersPerShaderStage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxStorageBuffersPerShaderStage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxStorageBufferBindingSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxStorageBufferBindingSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxBufferSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxBufferSize&lt;/span&gt;&lt;span class="p"&gt;,&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;h2&gt;
  
  
  4. A NaN guard that could never fire
&lt;/h2&gt;

&lt;p&gt;A safety check threw when particles drifted further than half the Verlet skin, using &lt;code&gt;sqrt(maxDriftSq) &amp;gt; skin / 2&lt;/code&gt;. Every comparison with NaN is false. Once positions became NaN, the drift was NaN and the check passed on every tick. The guard ran the whole time and could not see the failure it was written for.&lt;/p&gt;

&lt;p&gt;The fix scans the IEEE-754 exponent bits of every position on the same tick, before the old comparison. It costs 0.212 ms per chunk of 1,000 steps that takes 331.68 ms, which is 0.064%. On the composition that had been diverging silently, the run now throws at step 1,000 and names 6,957 non-finite components out of 60,750.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Forces from before the box changed size
&lt;/h2&gt;

&lt;p&gt;Drying and rehydration change the box size. The code rescaled the coordinates, rebuilt the neighbour grid and the Verlet list, and then took the next step with forces computed before the rescale. The result is a wrong number, which is harder to spot than zeros.&lt;/p&gt;

&lt;p&gt;A test now pins it from both sides. With the fix, the force buffer on the GPU matches a fresh calculation to within 1.5e-4 at a force scale of 513. Without it, the buffer equals the old forces bit for bit and is off by 313.7.&lt;/p&gt;

&lt;p&gt;It had moved a result I had already written up. At box 30 the largest aggregate came out 2.28 times smaller once the forces were right (three runs on each side, ranges not overlapping), and the effect I had reported for drying cycles shrank from 12.5–14.5× to 8.1–8.4×. A guard that had fired 9 times out of 216 fired 0 times out of 36 after the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Gates that recognised their input by name
&lt;/h2&gt;

&lt;p&gt;This one has nothing to do with the GPU. The percolation gates, which turn measurements into pass or fail verdicts, found their input by the campaign label, &lt;code&gt;zfB54&lt;/code&gt;, written into the code. The first campaign with a different label flipped the gate to "unproven" without an error. Rows now carry a &lt;code&gt;role&lt;/code&gt; field, and the label is only a fallback.&lt;/p&gt;

&lt;p&gt;A pipeline that identifies its inputs by name goes quiet when someone runs a new experiment, which is exactly when its verdict matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  A seventh: the instrument read zeros
&lt;/h2&gt;

&lt;p&gt;The test that checked whether the page was drawing read the WebGPU canvas through &lt;code&gt;drawImage&lt;/code&gt; into a 2D canvas. It reported 0 changed pixels out of 30,000, on a page that had just drawn 119 frames with 661 instances. The test now uses the scene's own instance counters and a real &lt;code&gt;page.screenshot()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I treat as a failure now
&lt;/h2&gt;

&lt;p&gt;Zero forces, zero neighbours and zero bonds are failures until something shows the physics can produce them. So is a speed-up nobody made. The same goes for a guard that has never fired, until I have seen it fire on purpose.&lt;/p&gt;

&lt;p&gt;WebGPU reports validation errors to the console and to the device's &lt;code&gt;uncapturederror&lt;/code&gt; event, and error scopes (&lt;code&gt;device.pushErrorScope('validation')&lt;/code&gt;) let you await them around a block of work. This project uses explicit checks that throw and name the buffer and the byte count. Whichever you pick, the default is a console warning and a result that looks fine.&lt;/p&gt;

&lt;p&gt;The code, the gate report and the full verdict are in &lt;a href="https://github.com/nefayran/protocell-genesis" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, and the &lt;a href="https://nefayran.github.io/protocell-genesis/viewer/gallery.html" rel="noopener noreferrer"&gt;snapshot gallery&lt;/a&gt; renders the real checkpoints in any current browser.&lt;/p&gt;

</description>
      <category>webgpu</category>
      <category>typescript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I capped my coding agent's tool output to save tokens. It used 2.65 times as many.</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:01:04 +0000</pubDate>
      <link>https://dev.to/nefayran/i-capped-my-coding-agents-tool-output-to-save-tokens-it-used-265-times-as-many-4ocn</link>
      <guid>https://dev.to/nefayran/i-capped-my-coding-agents-tool-output-to-save-tokens-it-used-265-times-as-many-4ocn</guid>
      <description>&lt;p&gt;To save tokens, I capped every response from my coding agent's code-graph tool at 12,000 characters. One answer then took 18 turns instead of 7, and the input tokens it was billed for went from 267k to 708k, 2.65 times as many.&lt;/p&gt;

&lt;p&gt;This post explains why a smaller response used more tokens, and what else I found when I rebuilt the measurement on public repositories: 252 headless Claude Code runs (non-interactive, one question each), every token counted. You'll also get the benchmark's exact command line and a script that shows how much of your own sessions' input comes from the prompt cache. The runner and every raw run, answers included, are in &lt;a href="https://github.com/nefayran/claude-code-token-bench" rel="noopener noreferrer"&gt;claude-code-token-bench&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I was cutting tool output
&lt;/h2&gt;

&lt;p&gt;I wanted to know whether a code graph makes Claude Code, Anthropic's coding agent, cheaper than plain search. The graph came from &lt;a href="https://github.com/DeusData/codebase-memory-mcp" rel="noopener noreferrer"&gt;codebase-memory-mcp&lt;/a&gt;, an open-source MCP server that builds a graph of a repository's symbols and the calls between them. MCP, the Model Context Protocol, is how Claude Code plugs in outside tools.&lt;/p&gt;

&lt;p&gt;The first measurement went badly for the graph. On five questions, the agent with the graph was billed 2.35 times the input of the agent with grep, 4.26M tokens against 1.81M, and it came out cheaper on only one question.&lt;/p&gt;

&lt;p&gt;So I looked at what the server sent back. One search hit was 1,342 bytes of JSON, most of it hash vectors, complexity metrics and a list of every identifier in the function's body. The same hit as a grep line was 115 bytes. On top of that, the server's 14 tool definitions rode along in every prompt.&lt;/p&gt;

&lt;p&gt;I put a thin MCP server of my own in front of it, graph-ask. This wrapper dropped the fields no code question needs, which brought a hit down to 251 bytes. It also capped every response at 12,000 characters: it halved the longest list until the response fit, and noted how much it had cut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrl97w4n88tbl4gds61l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrl97w4n88tbl4gds61l.png" alt="One search hit: 1,342 bytes from the graph server, 251 through my wrapper, 115 as a grep line" width="799" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cap did
&lt;/h2&gt;

&lt;p&gt;The tool that returns a repository's structure hit the cap. The model got a shortened list with a note saying how much was missing, and asked again. That answer took 18 turns where it had taken 7, and its billed input rose from 267k tokens to 708k.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4wwttxf6sclhfeifi0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4wwttxf6sclhfeifi0m.png" alt="Billed input for one answer: 267k tokens in 7 turns without the cap, 708k in 18 turns with a 12,000-character cap" width="799" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A turn is one round trip to the model. Each one is a new API request, and it carries the whole conversation: the system prompt, the tool definitions, the question and every tool result so far. Divide the totals by the turns and the average turn comes to about 38,000 tokens before the cap and 39,000 after. The smaller responses didn't make a turn any cheaper. The turn count went up 2.57 times and the tokens 2.65 times, which came to 441k more tokens in all.&lt;/p&gt;

&lt;p&gt;That is also why a turn costs more the later it comes. Each tool result joins the history that every later turn sends again, so billed input grows faster than the number of turns.&lt;/p&gt;

&lt;p&gt;One limit on the 2.65: in that round I counted every input token the same, including the ones served from the prompt cache, the unchanged start of the conversation that Anthropic keeps between requests. Anthropic bills those cache reads at a tenth of the input price, so 2.65 is a ratio of tokens, not of dollars. The public benchmark below prices each kind of token at its own rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in the wrapper
&lt;/h2&gt;

&lt;p&gt;I kept the trimming and rebuilt the rest around the number of turns. One tool call now answers a whole structural question, with a function's definition, source, callers and callees together, so the model doesn't assemble the answer over several turns. A script summarizes each repository's layout once into a file, and the agent reads that file instead of querying the graph. Asked for a project's HTTP routes, the tool returns each handler along with the config files that hold the URL prefixes. And the cap went up to 60,000 characters.&lt;/p&gt;

&lt;p&gt;Together those changes took that round's questions from 83 turns and 3.46M billed input tokens to 36 turns and 1.62M. Listing a project's HTTP routes alone went from 23 turns to 8.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tr0t30ko8nj4oldhge3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tr0t30ko8nj4oldhge3.png" alt="Billed input for one round of questions: 3.46M tokens in 83 turns for the first wrapper, 1.62M in 36 turns after the rebuild" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current version also checks a name before it queries. The graph matches names exactly, so a near miss returns nothing and costs a turn to recover. When the name doesn't exist, the tool takes the top search hit and says it did.&lt;/p&gt;

&lt;p&gt;The wrapper still labels a cut when a response hits the cap. From &lt;code&gt;server.mjs&lt;/code&gt;, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bring a response under MAX_CHARS by shortening its longest arrays, halving the&lt;/span&gt;
&lt;span class="c1"&gt;// worst offender at a time, and recording what was dropped next to it. Slicing the&lt;/span&gt;
&lt;span class="c1"&gt;// serialized JSON instead would hand the model invalid syntax and no idea what is&lt;/span&gt;
&lt;span class="c1"&gt;// missing — the truncation has to stay legible, or it reads as a complete answer.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_truncated`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`showing &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 12,000-character version had the same label, and the model went back for the rest anyway. The label stays so that a cut list never passes for the complete answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bash and subagents hid more turns
&lt;/h2&gt;

&lt;p&gt;The cap wasn't the only thing adding turns in that round. The grep-only setup was allowed Bash and subagents, the helper agents Claude Code can start for a subtask, and the model ran &lt;code&gt;grep | head&lt;/code&gt; loop after loop, much of it inside subagents. It was billed 1.8M input tokens. With Bash and subagents denied, the same setup needed 252k.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomvsszojw9blvguxarpt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomvsszojw9blvguxarpt.png" alt="Grep-only setup: 1.8M billed input tokens with Bash and subagents allowed, 252k with both denied" width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things kept this out of sight. Claude Code's turn count covers only the main loop, so a subagent's turns are billed but missing from the count of the run you're watching. And scoped Bash permissions don't match piped commands: a grant for &lt;code&gt;grep&lt;/code&gt; alone doesn't cover the pipelines the model writes, so different setups hit different numbers of permission denials.&lt;/p&gt;

&lt;p&gt;A denial can even look like a saving. In headless mode nobody is there to approve a tool, so an MCP server's tools have to be listed in &lt;code&gt;--allowedTools&lt;/code&gt;, or every call is refused while their definitions are still paid for. My very first graph run looked six times cheaper than grep. In that run the graph tools had been refused.&lt;/p&gt;

&lt;p&gt;For read-only questions the benchmark gave the agent only Read, Grep and Glob, Claude Code's built-in tools for opening a file, searching file contents and finding files by name. Bash was denied, and so was Agent, the tool that starts subagents. For an agent that only has to read code, that's the set I'd start from. The benchmark ran each question like this, from the repository's checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Which functions or route files call createPaymentIntent? Ignore tests. Give the file:line of each call site, nothing else."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="nt"&gt;--setting-sources&lt;/span&gt; project &lt;span class="nt"&gt;--no-session-persistence&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; Read Grep Glob &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disallowedTools&lt;/span&gt; Bash Agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--strict-mcp-config&lt;/code&gt; loads only the MCP servers named on the command line, none here. &lt;code&gt;--setting-sources project&lt;/code&gt; skips user-level settings, hooks and plugins, so nothing installed on the machine steers the model. &lt;code&gt;stream-json&lt;/code&gt; reports each tool call and the run's token usage, which is what the benchmark recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The earlier result didn't survive public repositories
&lt;/h2&gt;

&lt;p&gt;That earlier round had also found my wrapper 26% cheaper than grep with Claude Haiku and 18% with Claude Sonnet. But it had given a routing rule, a line in the prompt that says when to use the graph and when to use grep, to every setup, including the one with no graph at all. And its repositories were private, so no one else could check it. I rebuilt the benchmark on public repositories, and that result did not reproduce.&lt;/p&gt;

&lt;p&gt;The setup: Claude Code 2.1.283 in headless mode with Claude Haiku 4.5 and Claude Sonnet 5. Three small repositories have 1.6k to 5k graph nodes each, where a node is one function, class, file or similar item: FastAPI's full-stack template, ltx-2-mlx (an MLX port of the LTX-2 video model) and avoid-ai-writing (a tool that flags AI writing patterns). One large repository, dub, a link attribution platform, has 25k. The three small repositories share twelve questions, and dub has twelve of its own. In each set, six are about structure, such as who calls a function or the path from an endpoint down to the database write, and six are about exact strings, such as a config value or an error message. Every answer is checked for the facts it must contain. The 252 runs cost $14.69 at API prices.&lt;/p&gt;

&lt;p&gt;Tokens are priced by kind, with Anthropic's multipliers: fresh input at 1, a cache write at 1.25 and a cache read at 0.1. The setups run in a different order on each repetition, only the setups with a graph get the routing rule, and none of them has Bash or Agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F094rtvkpyt7ylnilovlc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F094rtvkpyt7ylnilovlc.png" alt="Median priced tokens per run for the three setups in three settings, with correct answers as labels" width="800" height="868"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Median priced tokens per run, and correct answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;repositories, model&lt;/th&gt;
&lt;th&gt;graph through my wrapper&lt;/th&gt;
&lt;th&gt;graph server directly&lt;/th&gt;
&lt;th&gt;Read and Grep only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3 small, Haiku 4.5&lt;/td&gt;
&lt;td&gt;30,321 · 28/36&lt;/td&gt;
&lt;td&gt;43,846 · 32/36&lt;/td&gt;
&lt;td&gt;31,159 · 33/36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dub, Haiku 4.5&lt;/td&gt;
&lt;td&gt;18,791 · 22/24&lt;/td&gt;
&lt;td&gt;20,687 · 22/24&lt;/td&gt;
&lt;td&gt;14,955 · 20/24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dub, Sonnet 5&lt;/td&gt;
&lt;td&gt;17,227 · 22/24&lt;/td&gt;
&lt;td&gt;23,085 · 22/24&lt;/td&gt;
&lt;td&gt;13,348 · 22/24&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The graph never beat Read and Grep on tokens. With Sonnet on dub, Read and Grep were cheaper on 11 of the 12 questions at the same accuracy (p = 0.034, a Wilcoxon signed-rank test over per-question medians). My wrapper did beat the bare graph server in all three settings, on 8, 11 and 9 of the 12 questions.&lt;/p&gt;

&lt;p&gt;The graph's one clear win was accuracy. On dub, Haiku answered the structural questions correctly 12 times out of 12 with it and 8 out of 12 without, at a median of 23.5k tokens against 16.0k on those questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most expensive setup was a graph the agent mostly ignored
&lt;/h2&gt;

&lt;p&gt;By default, Claude Code 2.1.283 lists an MCP server's tools by name and loads a tool's full definition only when the model asks for it through its ToolSearch tool. In headless mode Haiku mostly didn't ask. On the twelve small-repository questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;setup&lt;/th&gt;
&lt;th&gt;runs that called the graph&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;th&gt;median priced tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;definitions deferred (the default), routing rule in the prompt&lt;/td&gt;
&lt;td&gt;2/12&lt;/td&gt;
&lt;td&gt;11/12&lt;/td&gt;
&lt;td&gt;41,008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all definitions loaded, no rule&lt;/td&gt;
&lt;td&gt;4/12&lt;/td&gt;
&lt;td&gt;9/12&lt;/td&gt;
&lt;td&gt;62,008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all definitions loaded, with the rule&lt;/td&gt;
&lt;td&gt;22/36&lt;/td&gt;
&lt;td&gt;28/36&lt;/td&gt;
&lt;td&gt;30,321&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flv932ztvzc1eav6c5f7l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flv932ztvzc1eav6c5f7l.png" alt="Median priced tokens: 41,008 with deferred definitions, 62,008 with all loaded and no rule, 30,321 with all loaded and the rule" width="800" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first two rows are one run per question; the last is the main run, three per question. Setting &lt;code&gt;ENABLE_TOOL_SEARCH=false&lt;/code&gt; in the environment makes Claude Code load every definition up front. In six pilot runs with the default, the graph was never called. With that variable set, the same question went through the graph in three turns.&lt;/p&gt;

&lt;p&gt;Loaded definitions are paid for on every turn, whether anything calls them or not. That is why the mounted graph with no rule cost the most of any setup here. Even with the rule, Sonnet picked the graph in 10 of its 24 runs on dub and Grep for the rest.&lt;/p&gt;

&lt;p&gt;If you keep an MCP server mounted, load its definitions and tell the agent when to use it. The benchmark passed the rule with &lt;code&gt;--append-system-prompt&lt;/code&gt;. Day to day it belongs in CLAUDE.md, the instruction file Claude Code reads at the start of a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Structure (who calls what, call chains, routes, module layout, a symbol end to end): one graph_ask call.
Exact strings, config values, flags, error text: Grep and Read.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;graph_ask&lt;/code&gt; is my wrapper's one-call tool; name your own server's tool there. If you can't say when the agent should use a server, unmount it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A warm cache made later setups look cheaper
&lt;/h2&gt;

&lt;p&gt;Back-to-back runs on the same repository share a prompt cache. When the setups ran in a fixed order, the later ones started warm and got a discount the first one never had, which is why the order now rotates.&lt;/p&gt;

&lt;p&gt;You can see how much the cache does in your own sessions. &lt;code&gt;tools/cache-audit.mjs&lt;/code&gt; in the repository reads Claude Code's local session logs, reports what share of billed input came from the cache, overall and per project, and lists the worst sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node tools/cache-audit.mjs &lt;span class="nt"&gt;--days&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my earlier benchmark runs, 83 to 88 percent of billed input came from the cache. A much lower share points at something that changes the start of the prompt between turns, like a timestamp in a system prompt or a tool set that shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a graph does pay
&lt;/h2&gt;

&lt;p&gt;A second benchmark ran in GitHub Copilot CLI on two much bigger repositories, kubernetes (146k nodes) and vscode (219k). There the graph cut Haiku's priced tokens on structural questions to 0.29 of grep's, and it was cheaper on 15 of those 16 questions. Sonnet's went to 0.74 and Opus 5.5's to 0.97. The details are in &lt;a href="https://allkeep.org/en/lab/when-a-code-graph-pays-off" rel="noopener noreferrer"&gt;Where a code graph pays off&lt;/a&gt;. In Claude Code I haven't tried repositories that large, or Opus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5h7tn9sydpk834b1dgf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5h7tn9sydpk834b1dgf.png" alt="Graph priced tokens as a share of grep's on structural questions in kubernetes and vscode: 0.29 for Haiku, 0.74 for Sonnet, 0.97 for Opus" width="800" height="717"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What these runs can't tell me
&lt;/h2&gt;

&lt;p&gt;Every run in this post was a single question in a fresh session. In a long interactive session two things change, and they pull in opposite directions: each extra turn sends a much longer history again, and most of that history is read from the cache at a tenth of the price. So in a long session, does a tight cap on tool output cost more than the 2.65 times I measured here, or less?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>Chaining image-to-video clips from the last frame: what 10 links did to a face (MiniMax-H3, measured)</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:07:26 +0000</pubDate>
      <link>https://dev.to/nefayran/chaining-image-to-video-clips-from-the-last-frame-what-10-links-did-to-a-face-minimax-h3-46e0</link>
      <guid>https://dev.to/nefayran/chaining-image-to-video-clips-from-the-last-frame-what-10-links-did-to-a-face-minimax-h3-46e0</guid>
      <description>&lt;p&gt;For a dialogue scene I generated clips with &lt;a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" rel="noopener noreferrer"&gt;MiniMax-H3&lt;/a&gt;, MiniMax's open-weights video model that can start from a given first frame, end on a given last frame, or both. To make one shot run longer than one clip, I started every clip from the last frame of the clip before. After ten clips the woman on screen no longer looked like the woman I had drawn:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszmn2h9re1uvs5bbjti0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszmn2h9re1uvs5bbjti0.jpg" alt="First frames of clips 1, 3, 6, 9 and 10 in one chain" width="798" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I measured what each link did. People on forums describe this drift a lot (&lt;a href="https://huggingface.co/RuneXX/LTX-2.3-Workflows/discussions/132" rel="noopener noreferrer"&gt;LTX-2.3&lt;/a&gt;, Wan, Seedance), but I didn't find numbers per link, so here are mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Model: MiniMax-H3, the first/last-frame checkpoint (FL2VA), run locally on an Apple Silicon Mac through a fork of &lt;a href="https://github.com/antirez/h3.c" rel="noopener noreferrer"&gt;h3.c&lt;/a&gt;, with an INT8 copy of the transformer and lightx2v's &lt;a href="https://huggingface.co/lightx2v/Minimax-h3-Turbo" rel="noopener noreferrer"&gt;8-step Turbo LoRA&lt;/a&gt; (&lt;code&gt;fl2v_turbo_8step_v1.0_768p&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Every clip: 768x1344, 124 frames (5.17 s at 24 fps), 8 steps, seed 42.&lt;/li&gt;
&lt;li&gt;Conditioning: a first frame only, no end frame. Clip 1 starts from a keyframe I drew. Every later clip starts from the previous clip's last frame:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-sseof&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt;.05 &lt;span class="nt"&gt;-i&lt;/span&gt; clip_n.mp4 &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 1 &lt;span class="nt"&gt;-update&lt;/span&gt; 1 clip_n_last.png
&lt;span class="c"&gt;# clip_n_last.png becomes the first frame of clip n+1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three chains: A, 10 clips of a woman in an armchair; B, 7 clips of a man by a door; C, 9 clips of a woman with a phone. Each prompt gave the person on screen one line of dialogue (the model voices it too), and every prompt said "The camera holds a static shot" and "natural skin texture".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;p&gt;On the first frame of each clip, which is the last frame of the clip before, I computed four numbers on the whole frame and divided each by its value on the drawn keyframe:&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;lap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;g&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="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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&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="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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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="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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g&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="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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g&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="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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;span class="n"&gt;edge_sharpness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                      &lt;span class="c1"&gt;# Laplacian variance
&lt;/span&gt;
&lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfft2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfftfreq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fftfreq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;finest_detail&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="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&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="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# energy above 0.25 cycles/px
&lt;/span&gt;
&lt;span class="n"&gt;contrast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;near_white&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;# share of pixels
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcfwzwzddcdkhabl61e4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcfwzwzddcdkhabl61e4.png" alt="Four metrics per link, chains A and B, relative to the drawn keyframe" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detail goes first
&lt;/h2&gt;

&lt;p&gt;Finest detail dropped on the first link: 77 % of the keyframe in chain A and 80 % in chain B at the start of clip 2. It bottomed out at clip 4 (61 % and 65 %) and never returned to the keyframe's level: chain A ended at 65 %, chain B at 88 %.&lt;/p&gt;

&lt;p&gt;Edge sharpness fell too at first, to 73 % and 82 % at clip 3, and then it overshot. From clip 5 on it was above the keyframe, and it reached 174 % in chain A (clip 9) and 159 % in chain B (clip 7). Detail wasn't coming back: the finest band stayed low. What rose were harder, coarser edges. On the shirts you can see it as a crunchy pattern that the keyframe didn't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the faces turn oily
&lt;/h2&gt;

&lt;p&gt;Contrast rose with almost every link: by 14 % in chain A (clip 9) and 16 % in chain B (clip 7). Near-white pixels grew faster: from 0.20 % to 0.55 % of the frame in chain A, 2.8 times, and from 0.054 % to 0.124 % in chain B, 2.3 times. On a face this shows up as shine on the forehead, nose and cheeks. Up close:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9x5vq2298xokm9p5okeo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9x5vq2298xokm9p5okeo.jpg" alt="The same face at clips 1, 4 and 9" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The freckles from the drawing are gone by clip 4, the skin is glossy although every prompt asked for natural skin texture, and by clip 9 the bone structure has changed. I didn't score identity with a face-embedding model, so that last part is by eye.&lt;/p&gt;

&lt;p&gt;One oddity: the first frame of chain A's clip 10 is brighter overall (mean luma up 10 %, saturation down 15 %), which pulls its contrast and near-white numbers back. I haven't looked into why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes ride along
&lt;/h2&gt;

&lt;p&gt;Chain C shows a different failure. At the end of clip 2 the model drew a chat bubble with made-up English over the frame, and since clip 3 starts from that frame, the bubble was in the scene from then on:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpuatiqcnwai4ofumgy8a.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpuatiqcnwai4ofumgy8a.jpg" alt="First frames of clips 1, 3, 5, 7 and 9 in chain C, with the chat bubble" width="798" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It stayed through the next seven clips, with the text inside it changing from clip to clip. The model treats whatever is in the conditioning frame as part of the scene. A last-frame chain has no way to drop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;When I watched the long chain, I saw smeared text on the phone and faces that looked oiled. Since then I work like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At most 3 clips in a row start from a previous clip's last frame. Then a fresh drawn keyframe.&lt;/li&gt;
&lt;li&gt;If a new person or object enters, the clip still starts from the last frame, but it ends on a freshly drawn end keyframe: an edit of the start frame with the new detail painted in.&lt;/li&gt;
&lt;li&gt;A face close-up or a new location always starts from a freshly drawn keyframe.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cap of 3 is a judgment call, not a threshold the numbers found. In these two chains, the first three clips lose fine detail (to 66 % and 69 %), while contrast has barely moved (100 % and 106 %) and near-white pixels are up 16 % and 11 %. From clip 5 on, all four metrics move away from the keyframe together. You could argue for 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own chain
&lt;/h2&gt;

&lt;p&gt;The measurement needs only the PNGs you already feed the model. Compute the four numbers above for every first frame and divide by the keyframe. A falling finest-detail share with rising contrast is the pattern to watch for. Before you trust a long chain, look at the first, middle and last frame of every clip side by side. A contact sheet per chain would have caught the chat bubble at clip 3. I found it much later, in a rough cut.&lt;/p&gt;

&lt;p&gt;These are two clean chains with one model, one seed and one resolution, and the metrics cover the whole frame while people move inside it. If you run the same check on another model, I'd like to compare curves.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>video</category>
    </item>
    <item>
      <title>Real-CUGAN on Linux vs Windows: same flags, two NVIDIA GPUs, SSIM 0.9977</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:05:53 +0000</pubDate>
      <link>https://dev.to/nefayran/real-cugan-on-linux-vs-windows-same-flags-two-nvidia-gpus-ssim-09977-4ea8</link>
      <guid>https://dev.to/nefayran/real-cugan-on-linux-vs-windows-same-flags-two-nvidia-gpus-ssim-09977-4ea8</guid>
      <description>&lt;p&gt;I upscale short AI-generated clips to 4K with &lt;a href="https://github.com/nihui/realcugan-ncnn-vulkan" rel="noopener noreferrer"&gt;Real-CUGAN&lt;/a&gt;, the ncnn-vulkan build. Two laptops with NVIDIA GPUs can do the work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Windows laptop&lt;/th&gt;
&lt;th&gt;Linux laptop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU, RAM&lt;/td&gt;
&lt;td&gt;i7-13700H, 32 GB&lt;/td&gt;
&lt;td&gt;i5-9300HF, 23 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;RTX 4060 Laptop, 8 GB&lt;/td&gt;
&lt;td&gt;RTX 2060, 6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Windows 11 (build 22631)&lt;/td&gt;
&lt;td&gt;Arch Linux, kernel 7.2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVIDIA driver&lt;/td&gt;
&lt;td&gt;610.88&lt;/td&gt;
&lt;td&gt;615.78.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg&lt;/td&gt;
&lt;td&gt;8.1.3&lt;/td&gt;
&lt;td&gt;9.0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;realcugan-ncnn-vulkan&lt;/td&gt;
&lt;td&gt;20220728 release&lt;/td&gt;
&lt;td&gt;20220728 release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before I split one video's clips between them, I wanted to know whether they give the same picture. If they didn't, a cut between two clips upscaled on different machines could show it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;One clip, 768x1344 at 24 fps, 104 frames, went through the same steps on both machines: crop to an exact 9:16, write PNG frames, Real-CUGAN at 3x with the &lt;code&gt;models-se&lt;/code&gt; weights and denoise level 0, Lanczos down to 2160x3840, then lossless x264. The Linux side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=&lt;/span&gt;&lt;span class="nv"&gt;$cw&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-fps_mode&lt;/span&gt; passthrough &lt;span class="k"&gt;in&lt;/span&gt;/f%06d.png
realcugan-ncnn-vulkan &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; up &lt;span class="nt"&gt;-s&lt;/span&gt; 3 &lt;span class="nt"&gt;-n&lt;/span&gt; 0 &lt;span class="nt"&gt;-g&lt;/span&gt; 0 &lt;span class="nt"&gt;-m&lt;/span&gt; models-se &lt;span class="nt"&gt;-f&lt;/span&gt; png
ffmpeg &lt;span class="nt"&gt;-framerate&lt;/span&gt; 24 &lt;span class="nt"&gt;-i&lt;/span&gt; up/f%06d.png &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-map&lt;/span&gt; 0:v &lt;span class="nt"&gt;-map&lt;/span&gt; &lt;span class="s1"&gt;'1:a?'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2160:3840:flags&lt;span class="o"&gt;=&lt;/span&gt;lanczos &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-qp&lt;/span&gt; 0 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-c&lt;/span&gt;:a copy &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 104 out_4k.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows runs the same three commands from Python, with one difference: &lt;code&gt;-g 1&lt;/code&gt;. On that laptop GPU 0 is the integrated Intel chip, and &lt;code&gt;-g 0&lt;/code&gt; would run the upscale there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Both files have 104 frames. I compared them with ffmpeg's own filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; windows_4k.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; linux_4k.mp4 &lt;span class="nt"&gt;-lavfi&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"[0:v]split[a0][a1];[1:v]split[b0][b1];[a0][b0]ssim=stats_file=ssim.log;[a1][b1]psnr=stats_file=psnr.log"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; null -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSIM came out at 0.9977 (Y 0.9976, U 0.9978, V 0.9980) and PSNR at 54.2 dB. Frame by frame the range was narrow: SSIM 0.99758 to 0.99779, PSNR 54.00 to 54.45 dB. Nothing drifts over the clip.&lt;/p&gt;

&lt;p&gt;A single score hides how the differences are spread, so I also counted the absolute difference of every Y, U and V sample, 1.29 billion of them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqo8gavvx3xlppn5upvi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqo8gavvx3xlppn5upvi.png" alt="Histogram of per-sample differences between the Windows and Linux outputs" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;75.5 % of the samples are identical, and 99.97 % are within one level out of 255. The largest difference anywhere is 5 levels, and only 5 samples reach it. The mean absolute difference is 0.25 of a level. Side by side at 100 % zoom I couldn't tell the two apart.&lt;/p&gt;

&lt;p&gt;The core of the counting script, in case you want to run it on your own pair:&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&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;-v&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;error&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;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&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;rawvideo&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;-pix_fmt&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;yuv420p&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;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;win&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2160&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3840&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;          &lt;span class="c1"&gt;# one yuv420p frame
&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;while &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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;and&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frombuffer&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frombuffer&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="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bincount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minlength&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where the difference comes from: not separated
&lt;/h2&gt;

&lt;p&gt;The two sides differ in GPU, driver, operating system and ffmpeg version, all at once. The candidates are the floating-point math in the Vulkan shaders, on two GPU generations and two drivers, and ffmpeg's crop, Lanczos scaling and RGB to YUV conversion in two versions. The x264 step at &lt;code&gt;-qp 0&lt;/code&gt; is lossless; the only loss in that step is the conversion to yuv420p, which belongs to the ffmpeg candidate.&lt;/p&gt;

&lt;p&gt;To split it, compare the PNGs that Real-CUGAN writes before the encode step. My pipeline deletes them after each clip, so I don't have that answer yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed, from the job log
&lt;/h2&gt;

&lt;p&gt;I run these jobs with &lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt;, a small open-source tool that runs heavy jobs one at a time and logs each one's run time. Its log gives whole-job numbers: copying the clip over the LAN, extracting frames, upscaling, encoding and copying the result back. Divided by frame count:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxa51gznb5f4zz0336vni.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxa51gznb5f4zz0336vni.png" alt="Seconds per frame for Real-CUGAN and Real-ESRGAN on both laptops" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Frames&lt;/th&gt;
&lt;th&gt;Run time&lt;/th&gt;
&lt;th&gt;Per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real-CUGAN 3x, 4K lossless out&lt;/td&gt;
&lt;td&gt;RTX 4060, Windows&lt;/td&gt;
&lt;td&gt;506 (one job, 5 clips)&lt;/td&gt;
&lt;td&gt;515.5 s&lt;/td&gt;
&lt;td&gt;1.02 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-CUGAN 3x, 4K lossless out&lt;/td&gt;
&lt;td&gt;RTX 2060, Linux&lt;/td&gt;
&lt;td&gt;104 (one clip)&lt;/td&gt;
&lt;td&gt;232.7 s&lt;/td&gt;
&lt;td&gt;2.24 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-ESRGAN x4plus, 4K lossless out&lt;/td&gt;
&lt;td&gt;RTX 4060, Windows&lt;/td&gt;
&lt;td&gt;73 each, 11 jobs&lt;/td&gt;
&lt;td&gt;383 to 422 s, mean 401.5 s&lt;/td&gt;
&lt;td&gt;5.50 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-ESRGAN x4plus, 1080p out&lt;/td&gt;
&lt;td&gt;RTX 2060, Linux&lt;/td&gt;
&lt;td&gt;73 each, 3 jobs&lt;/td&gt;
&lt;td&gt;542 to 558 s, mean 548.8 s&lt;/td&gt;
&lt;td&gt;7.52 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read these with care. The Real-CUGAN rows are one job each, and the Linux job also ran a 4K lossless x264 encode on a CPU with 8 threads, limited to 4 threads at low priority. The Real-ESRGAN rows come closer to a GPU comparison, though not all the way, because the Linux jobs encoded to 1080p, the lighter encode. There the RTX 2060 laptop took 1.37 times as long. One two-clip Windows job took 3,374 s, far above the rest, and I left it out because I don't know why.&lt;/p&gt;

&lt;p&gt;On the same Windows laptop, Real-ESRGAN x4plus (4x) cost about 5.4 times as much per frame as Real-CUGAN at 3x. I still use it for photoreal footage: Real-CUGAN is trained on anime, and on realistic faces it turned skin into painted strokes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Before I mix machines in one video, I upscale one clip on both and run the two checks above. If the frame counts match and the differences look like this histogram, the clips go to whichever laptop is free. The SSIM and PSNR pass took 11 seconds on my Mac for 104 frames at 4K. It catches setting mistakes, such as the wrong &lt;code&gt;-g&lt;/code&gt; or a different denoise level, before they reach an edit.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>linux</category>
      <category>ffmpeg</category>
      <category>gpu</category>
    </item>
    <item>
      <title>"Resource provisioning failed" on Vercel previews: the Neon 10-branch limit, measured</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:05:46 +0000</pubDate>
      <link>https://dev.to/nefayran/resource-provisioning-failed-on-vercel-previews-the-neon-10-branch-limit-measured-23n8</link>
      <guid>https://dev.to/nefayran/resource-provisioning-failed-on-vercel-previews-the-neon-10-branch-limit-measured-23n8</guid>
      <description>&lt;p&gt;My Next.js app runs on Vercel with a Neon Postgres database connected through the Vercel-managed Neon integration. In late October every preview deployment started to fail about a second after it was created. There was no build log at all, and production deployments kept working. Vercel showed one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BUILD_FAILED
Resource provisioning failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause was the Neon free plan's limit of 10 branches per project. Below is how I confirmed it, how fast the project filled up again after I emptied it, and what to do before you delete anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Neon's reason shows up
&lt;/h2&gt;

&lt;p&gt;With this integration, each preview deployment asks Neon for a database branch named &lt;code&gt;preview/&amp;lt;git-branch&amp;gt;&lt;/code&gt;, a copy-on-write copy of the production branch. That request runs as an integration step before the build starts (&lt;a href="https://neon.com/docs/guides/vercel-native-integration-previews" rel="noopener noreferrer"&gt;Neon docs&lt;/a&gt;). The production branch counts toward the 10, so nine previews fill the project. When the project is full, Neon refuses the new branch and Vercel reports the whole step as "Resource provisioning failed".&lt;/p&gt;

&lt;p&gt;The first time I hit this, in September, I found Neon's own reason in the deployment's integration actions, read through the Vercel REST API (&lt;code&gt;GET /v1/deployments/&amp;lt;deployment-id&amp;gt;/integrations&lt;/code&gt;). The status text there said &lt;code&gt;Branch limit reached&lt;/code&gt;. Counting the branches is faster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx neonctl branches list &lt;span class="nt"&gt;--project-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEON_PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; json | jq &lt;span class="s1"&gt;'length'&lt;/span&gt;

&lt;span class="c"&gt;# who made them and when&lt;/span&gt;
npx neonctl branches list &lt;span class="nt"&gt;--project-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEON_PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [.created_at, .creation_source, .name] | @tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first command prints 10 and most rows say &lt;code&gt;vercel&lt;/code&gt; and &lt;code&gt;preview/...&lt;/code&gt;, that's your answer. Other people on the &lt;a href="https://community.vercel.com/t/neon-postgres-integration-provisioning-failure-blocking-vercel-preview-deployments/34640" rel="noopener noreferrer"&gt;Vercel community forum&lt;/a&gt; traced the same error to the same limit. Some threads report the error with free slots left, so check the count first. Don't assume it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast ten branches fill
&lt;/h2&gt;

&lt;p&gt;My repository gets a lot of short pull requests. In October, up to the 27th, 484 of the project's 851 Vercel builds were previews. Every new git branch that got a preview deployment also got a Neon branch.&lt;/p&gt;

&lt;p&gt;On 28 October I emptied the project at about 05:02 UTC, leaving only production. This is what happened next:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgisrss3n09dtmknpgpj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgisrss3n09dtmknpgpj.png" alt="Neon branch count over 21 hours, against the 10-branch limit" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nine new preview branches appeared between 05:03 and 14:11 UTC, all with &lt;code&gt;creation_source: vercel&lt;/code&gt;. The project was full again 9 hours 8 minutes after I emptied it. I deleted nine more at 18:29 to 18:31 UTC, and by 01:45 UTC the next morning five new ones had arrived.&lt;/p&gt;

&lt;p&gt;The same cleanup came up every day that week. These are the preview branches I deleted by hand:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day (UTC)&lt;/th&gt;
&lt;th&gt;Preview branches deleted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24 October&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 October&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 October&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27 October&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28 October&lt;/td&gt;
&lt;td&gt;9 in the morning, 9 in the evening&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 41 branches in five days. The ones I looked at in the listing had each kept a compute endpoint active for about 300 seconds (&lt;code&gt;active_time_seconds&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging the pull request doesn't free the branch
&lt;/h2&gt;

&lt;p&gt;Every branch I deleted belonged to a pull request that was already merged, and its git branch was usually gone. With the Vercel-managed integration, Neon deletes a preview branch when the branch's Vercel deployments are deleted, and Vercel keeps preview deployments for 180 days by default (&lt;a href="https://neon.com/docs/guides/vercel-native-integration-previews" rel="noopener noreferrer"&gt;Neon docs&lt;/a&gt;). A merge doesn't delete deployments, so with that default retention the database branch stays for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skipping the build doesn't skip the branch
&lt;/h2&gt;

&lt;p&gt;My first fix was to stop building previews with Vercel's ignored build step in &lt;code&gt;apps/web/vercel.json&lt;/code&gt;. The first version also cancelled a push to &lt;code&gt;main&lt;/code&gt;, because &lt;code&gt;VERCEL_ENV&lt;/code&gt; didn't read as &lt;code&gt;production&lt;/code&gt; in that step. The second version tests the branch name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// first try: also skipped production&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"ignoreCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[ &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$VERCEL_ENV&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; != production ]"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// second try: build main, and any deploy whose branch is unknown&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"ignoreCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[ -n &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$VERCEL_GIT_COMMIT_REF&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; ] &amp;amp;&amp;amp; [ &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$VERCEL_GIT_COMMIT_REF&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; != main ]"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The builds were skipped, but the database branches kept coming. All 14 branches in the chart were created after this step was in place. The integration step runs before the ignored build step, so a skipped preview still provisions its branch, and when the project is full it fails the same way. The ignored build step saves build minutes. It does nothing for the branch count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back up before you delete
&lt;/h2&gt;

&lt;p&gt;A preview branch may hold data that a preview wrote, so I dumped each one before deleting it. One catch: &lt;code&gt;pg_dump&lt;/code&gt; refuses to dump a server with a newer major version than its own (&lt;a href="https://www.bytebase.com/reference/postgres/error/pgdump-aborting-because-of-server-version-mismatch/" rel="noopener noreferrer"&gt;the error and the rule&lt;/a&gt;). My Neon project runs Postgres 17 and my Mac had &lt;code&gt;pg_dump&lt;/code&gt; 16.15, so I installed the 17 client first. A loop like this one does the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;postgresql@17
&lt;span class="nv"&gt;PG_DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;brew &lt;span class="nt"&gt;--prefix&lt;/span&gt; postgresql@17&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/bin/pg_dump"&lt;/span&gt;   &lt;span class="c"&gt;# 17.11 here&lt;/span&gt;

npx neonctl branches list &lt;span class="nt"&gt;--project-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEON_PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.name | startswith("preview/")) | "\(.id) \(.name)"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;name&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;&lt;span class="nv"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx neonctl connection-string &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--project-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEON_PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PG_DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"backup/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;//\//__&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The preview branches I listed showed about 52 MB of logical size each, the same as production, since it starts as a copy. The nine dumps came to 27.7 MB in all, between 3.03 and 3.08 MB each, and finished 41 to 48 seconds apart. Only after that did I delete the branches, by id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx neonctl branches delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--project-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEON_PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The same limit breaks your CI
&lt;/h2&gt;

&lt;p&gt;Before a schema migration merges, I rehearse it on a throwaway branch copied from production, then delete the branch. Reduced to its shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;neon&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;branches&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--parent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// prisma migrate deploy against the copy, then compare the schema&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;neon&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;branches&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// also when a step fails&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;finally&lt;/code&gt; keeps the rehearsal from leaking branches. It can't help when previews have taken every slot: on several days in the table the rehearsal failed at &lt;code&gt;branches create&lt;/code&gt; until I cleared space by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stops the refill
&lt;/h2&gt;

&lt;p&gt;Deleting branches only buys hours. The setting that stops the refill is in the integration: Neon's docs describe a "Create database branch for deployment" choice with Preview and Production boxes when you connect the project. With Preview off, a preview deployment gets no branch of its own, so check which &lt;code&gt;DATABASE_URL&lt;/code&gt; your Preview environment uses then. It must not be production. Two other levers: shorten Vercel's retention for preview deployments, so the branches go with them, or delete the branch from CI when the pull request closes.&lt;/p&gt;

&lt;p&gt;I haven't measured the fix yet. At 01:45 UTC on 29 October preview branching was still on and the project had 6 of 10 branches. My next step is to switch it off and check that the count stays at 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. neonctl branches list | jq length     -&amp;gt; 10 means the limit, not your code
2. pg_dump at the server's major version  -&amp;gt; back up the preview branches
3. delete by id, previews of merged PRs only
4. turn off "Create database branch for deployment" for Preview,
   or shorten preview retention, or delete branches on PR close
5. leave slots for CI throwaway branches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>vercel</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>MPS backend out of memory: the "max allowed" number on every Mac size</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 20:02:36 +0000</pubDate>
      <link>https://dev.to/nefayran/mps-backend-out-of-memory-the-max-allowed-number-on-every-mac-size-249e</link>
      <guid>https://dev.to/nefayran/mps-backend-out-of-memory-the-max-allowed-number-on-every-mac-size-249e</guid>
      <description>&lt;p&gt;The PyTorch error on a Mac carries one number that people search for. This one is from a 16 GB Mac (&lt;a href="https://github.com/haoheliu/AudioLDM2/issues/20" rel="noopener noreferrer"&gt;AudioLDM2 #20&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RuntimeError: MPS backend out of memory (MPS allocated: 18.02 GB, other allocations: 107.10 MB, max allowed: 18.13 GB). Tried to allocate 4.50 MB on private pool.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;a href="https://allkeep.org/en/lab/mps-backend-out-of-memory" rel="noopener noreferrer"&gt;my earlier note&lt;/a&gt; I measured where it comes from on my 48 GB Mac. "max allowed" is &lt;code&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/code&gt;, 1.7 by default, times the GPU working set macOS recommends, which &lt;code&gt;torch.mps.recommended_max_memory()&lt;/code&gt; returns. One machine can't tell you what that number is on the others, so I read it off other people's errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers come from
&lt;/h2&gt;

&lt;p&gt;On 24 October I searched GitHub for issues that quote the error with its "max allowed" value. That gave 250 issues and pull requests, 167 of them with a number in the text. Most values fall into a few groups, and where a reporter named their Mac's memory, the groups line up with the size. Divided by 1.7, each value gives the working set macOS gave the GPU on that machine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mac memory&lt;/th&gt;
&lt;th&gt;max allowed, GiB&lt;/th&gt;
&lt;th&gt;GPU working set, GiB&lt;/th&gt;
&lt;th&gt;share of memory&lt;/th&gt;
&lt;th&gt;reports&lt;/th&gt;
&lt;th&gt;seen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;9.07&lt;/td&gt;
&lt;td&gt;5.33&lt;/td&gt;
&lt;td&gt;67 %&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;2023 – 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;18.13&lt;/td&gt;
&lt;td&gt;10.67&lt;/td&gt;
&lt;td&gt;67 %&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2023 – 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 GB&lt;/td&gt;
&lt;td&gt;27.20&lt;/td&gt;
&lt;td&gt;16.00&lt;/td&gt;
&lt;td&gt;67 %&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Jan 2025 – Jan 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 GB&lt;/td&gt;
&lt;td&gt;30.19&lt;/td&gt;
&lt;td&gt;17.76&lt;/td&gt;
&lt;td&gt;74 %&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Dec 2025 – Sep 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 GB&lt;/td&gt;
&lt;td&gt;36.27&lt;/td&gt;
&lt;td&gt;21.33&lt;/td&gt;
&lt;td&gt;67 %&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2024 – 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;36 GB&lt;/td&gt;
&lt;td&gt;45.90&lt;/td&gt;
&lt;td&gt;27.00&lt;/td&gt;
&lt;td&gt;75 %&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2024 – 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;48 GB&lt;/td&gt;
&lt;td&gt;63.65&lt;/td&gt;
&lt;td&gt;37.44&lt;/td&gt;
&lt;td&gt;78 %&lt;/td&gt;
&lt;td&gt;4, plus mine&lt;/td&gt;
&lt;td&gt;Sep – Oct 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 GB&lt;/td&gt;
&lt;td&gt;81.60&lt;/td&gt;
&lt;td&gt;48.00&lt;/td&gt;
&lt;td&gt;75 %&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Dec 2023 – Apr 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 GB&lt;/td&gt;
&lt;td&gt;88.13&lt;/td&gt;
&lt;td&gt;51.84&lt;/td&gt;
&lt;td&gt;81 %&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Mar – Oct 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128 GB&lt;/td&gt;
&lt;td&gt;163.20&lt;/td&gt;
&lt;td&gt;96.00&lt;/td&gt;
&lt;td&gt;75 %&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2024 – 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128 GB&lt;/td&gt;
&lt;td&gt;182.78&lt;/td&gt;
&lt;td&gt;107.52&lt;/td&gt;
&lt;td&gt;84 %&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Dec 2025 – Jan 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3e0ej038m4usqdamfhz6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3e0ej038m4usqdamfhz6.png" alt="The share of memory macOS gives the GPU, by Mac size: two thirds up to 32 GB and three quarters above in reports until late 2025; 74 %, 78 %, 81 % and 84 % on 24 GB, 48 GB, 64 GB and 128 GB Macs in reports since late 2025" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Older PyTorch versions label these numbers "GB" and newer ones "GiB". Both are the binary unit: 1.7 × two thirds × 16 GiB is exactly 18.13.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Until late 2025 the rule looks simple. macOS gave the GPU two thirds of memory on Macs up to 32 GB and three quarters above that. Reports from late 2025 on show larger shares on the 24 GB, 64 GB and 128 GB Macs. The three 64 GB reports that name their system all say macOS 26. The 8 and 16 GB values haven't moved. My own 48 GB Mac runs macOS 26.5.2 and returns 37.44 GiB, 78 %.&lt;/p&gt;

&lt;p&gt;Two limits on what this shows: the newer rows rest on two to six reports each, and I can't tell from them whether macOS or the chip made the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means when you see the error
&lt;/h2&gt;

&lt;p&gt;At the default ratio the limit is above physical memory on every row of the table: 1.7 × two thirds is 113 % of memory, and 1.7 × 84 % is 143 %. By the time a job reaches "max allowed", it has been swapping for a while. That matches my own measurement: on my Mac, swap began between 33.5 and 36 GB of footprint, far below the 63.65 GiB limit. The advice at the end of the error, a ratio of 0.0, removes the limit, so the job can swap further.&lt;/p&gt;

&lt;p&gt;If you'd rather get a clean error than a swapping Mac, set the limit below your memory, for example a ratio of 1.0, which is the working set itself. Set the low ratio with it: on PyTorch 2.14, a high ratio below 1.4 set on its own fails with "invalid low watermark ratio 1.4".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.0 &lt;span class="nv"&gt;PYTORCH_MPS_LOW_WATERMARK_RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.8 python your_script.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see your own Mac's working set:&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;torch&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recommended_max_memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GiB&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;h2&gt;
  
  
  The numbers that don't fit
&lt;/h2&gt;

&lt;p&gt;About a third of the reports show other limits, such as 1.70 GiB, 3.40 GiB, 6.77 GiB or 3.00 GiB. Those aren't the Mac's default. An app or a user set its own ratio or cap: one video tool caps MPS at 3 GiB even on a 128 GB Mac. Another 20 reports sit at 7.93, and I couldn't tie that value to one Mac size. If your number is far below the table, look in the tool's settings before you blame the Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Two reports for each row. The full list of 167 is in the data file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8 GB, 9.07: &lt;a href="https://github.com/kaixxx/noScribe/issues/84" rel="noopener noreferrer"&gt;noScribe #84&lt;/a&gt;, &lt;a href="https://github.com/pytorch/pytorch/issues/105839" rel="noopener noreferrer"&gt;pytorch #105839&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;16 GB, 18.13: &lt;a href="https://github.com/pinokiocomputer/pinokio/issues/941" rel="noopener noreferrer"&gt;pinokio #941&lt;/a&gt;, &lt;a href="https://github.com/haoheliu/AudioLDM2/issues/20" rel="noopener noreferrer"&gt;AudioLDM2 #20&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;24 GB, 27.20: &lt;a href="https://github.com/pytorch/pytorch/issues/145374" rel="noopener noreferrer"&gt;pytorch #145374&lt;/a&gt;, &lt;a href="https://github.com/ivrejchik/qwen3-optimizer-study/pull/13" rel="noopener noreferrer"&gt;qwen3-optimizer-study #13&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;24 GB, 30.19: &lt;a href="https://github.com/nandometzger/ml-bokeh/pull/3" rel="noopener noreferrer"&gt;ml-bokeh #3&lt;/a&gt;, &lt;a href="https://github.com/Comfy-Org/ComfyUI/issues/17264" rel="noopener noreferrer"&gt;ComfyUI #17264&lt;/a&gt; (ratio 1.0, so 17.76)&lt;/li&gt;
&lt;li&gt;32 GB, 36.27: &lt;a href="https://github.com/kijai/ComfyUI-LivePortraitKJ/issues/82" rel="noopener noreferrer"&gt;ComfyUI-LivePortraitKJ #82&lt;/a&gt;, &lt;a href="https://github.com/instructlab/instructlab/issues/2568" rel="noopener noreferrer"&gt;instructlab #2568&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;36 GB, 45.90: &lt;a href="https://github.com/ace-step/ACE-Step-1.5/issues/282" rel="noopener noreferrer"&gt;ACE-Step-1.5 #282&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;48 GB, 63.65: &lt;a href="https://github.com/abosnjakovic/trellis2-mlx/pull/1" rel="noopener noreferrer"&gt;trellis2-mlx #1&lt;/a&gt;, &lt;a href="https://github.com/shankar-sachin/ask-physics/pull/24" rel="noopener noreferrer"&gt;ask-physics #24&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;64 GB, 81.60: &lt;a href="https://github.com/BoBo0037/bobo-fun/issues/8" rel="noopener noreferrer"&gt;bobo-fun #8&lt;/a&gt;, &lt;a href="https://github.com/kijai/ComfyUI-CogVideoXWrapper/issues/312" rel="noopener noreferrer"&gt;ComfyUI-CogVideoXWrapper #312&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;64 GB, 88.13: &lt;a href="https://github.com/ThinkingInTeraflops/EtherReel-releases/issues/7" rel="noopener noreferrer"&gt;EtherReel-releases #7&lt;/a&gt;, &lt;a href="https://github.com/Comfy-Org/ComfyUI/issues/17309" rel="noopener noreferrer"&gt;ComfyUI #17309&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;128 GB, 163.20: &lt;a href="https://github.com/Comfy-Org/ComfyUI/issues/9308" rel="noopener noreferrer"&gt;ComfyUI #9308&lt;/a&gt;, &lt;a href="https://github.com/EleutherAI/lm-evaluation-harness/issues/1835" rel="noopener noreferrer"&gt;lm-evaluation-harness #1835&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;128 GB, 182.78: &lt;a href="https://github.com/Comfy-Org/ComfyUI/issues/11584" rel="noopener noreferrer"&gt;ComfyUI #11584&lt;/a&gt;, &lt;a href="https://github.com/kijai/ComfyUI-WanVideoWrapper/issues/1856" rel="noopener noreferrer"&gt;ComfyUI-WanVideoWrapper #1856&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>macos</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>MPS backend out of memory on a Mac: what the numbers mean and how to set the watermark</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:01:59 +0000</pubDate>
      <link>https://dev.to/nefayran/mps-backend-out-of-memory-on-a-mac-what-the-numbers-mean-and-how-to-set-the-watermark-43dp</link>
      <guid>https://dev.to/nefayran/mps-backend-out-of-memory-on-a-mac-what-the-numbers-mean-and-how-to-set-the-watermark-43dp</guid>
      <description>&lt;p&gt;When a PyTorch job runs out of memory on a Mac, the error ends with a piece of advice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RuntimeError: MPS backend out of memory (MPS allocated: 18.50 GiB, other allocations: 448.00 KiB, max allowed: 18.72 GiB). Tried to allocate 512.00 MiB on shared pool. Use PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 to disable upper limit for memory allocations (may cause system failure).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wanted to know what following that advice does, so I filled MPS memory in 512 MiB steps on my 48 GB M5 Pro (macOS 26.5.2, PyTorch 2.14.0), once per setting, under a guard that stops the process when swap grows by 2 GB. Two things came out of it. On this Mac the default limit is higher than the RAM, so a job that outgrows memory slides into swap and never gets to this error. And lowering the limit takes two variables, not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where "max allowed" comes from
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;max allowed&lt;/code&gt; is the limit: &lt;code&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/code&gt; times what &lt;code&gt;torch.mps.recommended_max_memory()&lt;/code&gt; returns. On my Mac that is 37.44 GiB, and 0.5 × 37.44 = 18.72 GiB, the number in the error above. &lt;code&gt;MPS allocated&lt;/code&gt; matched &lt;code&gt;torch.mps.current_allocated_memory()&lt;/code&gt; in my log, the memory held by tensors. To see your own base number:&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;torch&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recommended_max_memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GiB&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;h2&gt;
  
  
  Why a 48 GB Mac swaps first
&lt;/h2&gt;

&lt;p&gt;PyTorch's documentation gives the default high ratio as 1.7. On my Mac that puts the limit at about 63.6 GiB, more memory than the machine has. This is what each setting did:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpqvawycwc57andf1ro2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpqvawycwc57andf1ro2.png" alt="Tensors held when each run stopped: with the default ratio, 0.0 and 1.0 the Mac went into swap at 31 to 34 GiB and the guard stopped the run; with 0.8 and 0.5 PyTorch raised out of memory at its limit" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;setting&lt;/th&gt;
&lt;th&gt;what stopped the fill&lt;/th&gt;
&lt;th&gt;tensors held&lt;/th&gt;
&lt;th&gt;swap during the run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default (1.7)&lt;/td&gt;
&lt;td&gt;the guard&lt;/td&gt;
&lt;td&gt;38.5 GiB&lt;/td&gt;
&lt;td&gt;6.1 → 12.0 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;the guard&lt;/td&gt;
&lt;td&gt;36.0 GiB&lt;/td&gt;
&lt;td&gt;5.1 → 9.8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.0, low 1.0&lt;/td&gt;
&lt;td&gt;the guard&lt;/td&gt;
&lt;td&gt;34.5 GiB&lt;/td&gt;
&lt;td&gt;4.6 → 8.7 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.8, low 0.8&lt;/td&gt;
&lt;td&gt;PyTorch, max allowed 29.95 GiB&lt;/td&gt;
&lt;td&gt;29.5 GiB&lt;/td&gt;
&lt;td&gt;no change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5, low 0.5&lt;/td&gt;
&lt;td&gt;PyTorch, max allowed 18.72 GiB&lt;/td&gt;
&lt;td&gt;18.5 GiB&lt;/td&gt;
&lt;td&gt;no change&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the default, with 0.0 and with 1.0, swap started to grow once the process passed 33.5 to 36.2 GB, and PyTorch never raised: its limit was further away than the end of the RAM. So &lt;code&gt;0.0&lt;/code&gt;, the advice inside the error, changed nothing I could see here. The limit was already out of reach, and nothing in PyTorch stopped the run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it: two variables
&lt;/h2&gt;

&lt;p&gt;Setting only the high ratio fails at the first MPS call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.8 python fill.py
RuntimeError: invalid low watermark ratio 1.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The low ratio defaults to 1.4 on Apple Silicon and may not be above the high one (unless the high one is 0.0, which turns the limit off). Set both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.8 &lt;span class="nv"&gt;PYTORCH_MPS_LOW_WATERMARK_RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.8 python run.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From Python, set them before the first tensor goes to &lt;code&gt;mps&lt;/code&gt;. PyTorch reads them when MPS starts, not at &lt;code&gt;import torch&lt;/code&gt;, and ignores changes made after that:&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;os&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTORCH_MPS_LOW_WATERMARK_RATIO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documentation calls the low ratio a soft limit and says that "setting 0.0 disables adaptive commit and garbage collection". I set it equal to the high one and did not measure it on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a value
&lt;/h2&gt;

&lt;p&gt;The limit should stay below the memory your Mac really has free, with your usual apps open. On mine 1.0 (37.44 GiB) was already too much, and 0.8 (29.95 GiB) gave a clean error with no swap. After a clean error you can lower the batch size or the resolution and try again; a run sliding into swap slows down the whole Mac first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A trap when you test this
&lt;/h2&gt;

&lt;p&gt;My first run filled the tensors with &lt;code&gt;torch.ones&lt;/code&gt;. With the default setting it reached the 46 GiB cap I had set, with no error and no swap at all: macOS compresses pages of identical values almost to nothing. Model weights don't compress like that, so a memory test on a Mac needs random data.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the model really doesn't fit
&lt;/h2&gt;

&lt;p&gt;A clean error only tells you the model doesn't fit. For a pipeline of several models, loading only the models of the current stage does more than any ratio, which is why I wrote &lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt;. The fill script and the traces of every run above are in its repository, in &lt;a href="https://github.com/nefayran/stageload/tree/main/bench/results/2026-10-13-mps-watermark" rel="noopener noreferrer"&gt;&lt;code&gt;bench/results/2026-10-13-mps-watermark&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>macos</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>PyTorch on a Mac: why memory looks freed after .to("cpu")</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:43:56 +0000</pubDate>
      <link>https://dev.to/nefayran/pytorch-on-a-mac-why-memory-looks-freed-after-tocpu-1apl</link>
      <guid>https://dev.to/nefayran/pytorch-on-a-mac-why-memory-looks-freed-after-tocpu-1apl</guid>
      <description>&lt;p&gt;On a Mac, a tensor moved from &lt;code&gt;mps&lt;/code&gt; to the CPU seems to disappear from memory. The process footprint, the number Activity Monitor shows in its Memory column, drops to almost nothing, and so does the MPS driver count. The copy is still there and still takes the same memory. This is the minimal example I put together for a &lt;a href="https://github.com/huggingface/diffusers/issues/14833" rel="noopener noreferrer"&gt;diffusers discussion about offloading&lt;/a&gt;, with a 4 GiB tensor standing in for a model's weights (torch 2.14.0, macOS 26.5.2, M5 Pro with 48 GB):&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;ctypes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="c1"&gt;# rusage_info_v2 from &amp;lt;libproc.h&amp;gt;; phys_footprint is the Memory column of Activity Monitor
&lt;/span&gt;&lt;span class="n"&gt;FIELDS&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;user_time&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;system_time&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;pkg_idle_wkups&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;interrupt_wkups&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;pageins&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;wired_size&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;resident_size&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;phys_footprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RUsageInfoV2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Structure&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_fields_&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;uuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_uint8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_uint64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; \
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rest&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_uint64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="n"&gt;libproc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CDLL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/lib/libproc.dylib&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&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="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synchronize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RUsageInfoV2&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;proc_pid_rusage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; footprint &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phys_footprint&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;4.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GiB, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mps driver &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;driver_allocated_memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;4.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GiB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 4 GiB of float32, standing in for a model's weights
&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on mps&lt;/span&gt;&lt;span class="sh"&gt;"&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="c1"&gt;# what model CPU offload does when a model's turn ends
&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty_cache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moved to cpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;float&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;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                            &lt;span class="c1"&gt;# read every page of the cpu copy
&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu copy read&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start          footprint  0.1 GiB, mps driver  0.0 GiB
on mps         footprint  4.2 GiB, mps driver  4.0 GiB
moved to cpu   footprint  0.1 GiB, mps driver  0.0 GiB
cpu copy read  footprint  4.2 GiB, mps driver  0.0 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it three times and got the same output each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;Right after the move, both counters are back near zero, but &lt;code&gt;w&lt;/code&gt; holds all 4 GiB of its values. Once the CPU reads every page of the copy, the footprint is 4.2 GiB again. So a footprint taken between &lt;code&gt;.to("cpu")&lt;/code&gt; and the next time the CPU touches those weights does not show them.&lt;/p&gt;

&lt;p&gt;The reason the move frees nothing in the first place is the hardware. On Apple Silicon the CPU and the GPU use one pool of physical memory. On a PC, moving weights from the GPU to the CPU frees VRAM for the next model. On a Mac, the weights "on the CPU" sit in the same RAM the GPU needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters for model CPU offload
&lt;/h2&gt;

&lt;p&gt;diffusers' &lt;code&gt;enable_model_cpu_offload()&lt;/code&gt; keeps each model on the CPU and moves it to the GPU only while it runs. I ran Qwen-Image-2.1 at 1024 × 1024 that way on the same Mac (&lt;a href="https://allkeep.org/en/lab/model-cpu-offload-on-a-mac" rel="noopener noreferrer"&gt;trace and write-up&lt;/a&gt;). The process footprint never went above 18.5 GB, which would fit on a 48 GB Mac with room to spare. Swap grew by 3.5 GB during denoising and had grown by 9.6 GB by the VAE decode, and my memory guard stopped the run before it wrote an image.&lt;/p&gt;

&lt;p&gt;A chart of the footprint alone would have called that run a fit. The example above shows one way the footprint can stay low while the weights are still in memory. I didn't measure which pages went to swap in the offload run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own run
&lt;/h2&gt;

&lt;p&gt;Look at swap, not only at the footprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl vm.swapusage             &lt;span class="c"&gt;# compare "used" before and after the run&lt;/span&gt;
sysctl kern.memorystatus_level  &lt;span class="c"&gt;# system-wide free memory, in percent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If swap grows while the job runs, it doesn't fit, whatever the footprint says. &lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt; (&lt;code&gt;pip install stageload&lt;/code&gt;), a small library I wrote for this, records both in a trace every 0.5 s with &lt;code&gt;MemoryMeter&lt;/code&gt;, and &lt;code&gt;stageload guard&lt;/code&gt; stops a command once swap grows past a budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stageload guard &lt;span class="nt"&gt;--wait-free&lt;/span&gt; 40 &lt;span class="nt"&gt;--swap-budget&lt;/span&gt; 8G &lt;span class="nt"&gt;--&lt;/span&gt; python generate.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What does free the memory
&lt;/h2&gt;

&lt;p&gt;For a pipeline that runs its models one after another, the fix is to load a model when its stage starts and release it when the stage ends, instead of parking it on the CPU. stageload's &lt;code&gt;release(module)&lt;/code&gt; moves every parameter and buffer to PyTorch's &lt;code&gt;meta&lt;/code&gt; device, collects garbage and empties the MPS cache; a tensor on &lt;code&gt;meta&lt;/code&gt; has no storage, so the memory goes back even while other code still holds a reference to the module. With the text encoder (16.3 GB) and the transformer (13.3 GB) loaded one at a time this way, Qwen-Image-2.1 peaked at 19.0 GB on the same Mac, and swap did not grow in either of two runs. The full setup is in &lt;a href="https://allkeep.org/en/lab/qwen-image-on-a-48-gb-mac" rel="noopener noreferrer"&gt;How to run Qwen-Image-2.1 on a 48 GB Mac without swap&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not measure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The free memory of the whole system during the example: other jobs on this Mac move it by several GiB between runs, so I couldn't get a clean number.&lt;/li&gt;
&lt;li&gt;Whether &lt;code&gt;del&lt;/code&gt; plus &lt;code&gt;torch.mps.empty_cache()&lt;/code&gt; gives the memory back as fully as moving to &lt;code&gt;meta&lt;/code&gt;, and how quickly.&lt;/li&gt;
&lt;li&gt;Other macOS and torch versions, and Macs with more memory.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>macos</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Godot Movie Maker and screenshots on macOS without --headless</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 21:22:55 +0000</pubDate>
      <link>https://dev.to/nefayran/godot-movie-maker-and-screenshots-on-macos-without-headless-26pi</link>
      <guid>https://dev.to/nefayran/godot-movie-maker-and-screenshots-on-macos-without-headless-26pi</guid>
      <description>&lt;p&gt;I wanted Godot to write screenshots and short videos from a script on my Mac, the way it does in CI on Linux. Godot 4.7.1 on macOS 26.5.2 can't do it in &lt;code&gt;--headless&lt;/code&gt;, but it can with the real renderer and the window off the screen. One thing I haven't solved: the app still comes to the front while it runs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8muiymq9k0lpl0dlhik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8muiymq9k0lpl0dlhik.png" alt="The headless error and four frames of one Movie Maker run (frames 0, 16, 32 and 47), written with the window off the screen" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything below ran on a 640 × 360 test project with one script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Node2D&lt;/span&gt;

&lt;span class="c1"&gt;# Saves the 30th frame to the path given after "--" and quits.&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;80.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;
    &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_cmdline_user_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;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;await&lt;/span&gt; &lt;span class="n"&gt;RenderingServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frame_post_draw&lt;/span&gt;
        &lt;span class="n"&gt;get_viewport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_texture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_image&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save_png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&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;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What --headless does with a capture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Godot.app/Contents/MacOS/Godot &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/out.png"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script reached frame 30 and then waited at &lt;code&gt;await RenderingServer.frame_post_draw&lt;/code&gt; for good: the process was still running 15 seconds later, and nothing was saved. Reading the texture without the &lt;code&gt;await&lt;/code&gt; fails at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Parameter "t" is null.
   at: texture_2d_get (./servers/rendering/dummy/storage/texture_storage.h:110)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;--headless&lt;/code&gt; Godot runs a dummy renderer, so the viewport has no texture to read; the path in the error is the dummy renderer's texture storage. On Linux the usual way round is a virtual X display under a real renderer. Godot's macOS build draws through the macOS window server and Metal, so that trick doesn't carry over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render for real, off the screen
&lt;/h2&gt;

&lt;p&gt;Keep the real renderer and keep the window out of the way. Window settings take effect when the window is created, before any script runs, so they go in an &lt;code&gt;override.cfg&lt;/code&gt; next to &lt;code&gt;project.godot&lt;/code&gt; for the length of the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[display]&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="na"&gt;window/size/&lt;/span&gt;&lt;span class="py"&gt;no_focus&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;window/size/&lt;/span&gt;&lt;span class="py"&gt;initial_position_type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;window/size/&lt;/span&gt;&lt;span class="py"&gt;initial_position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Vector2i(-6000, -6000)&lt;/span&gt;
&lt;span class="na"&gt;window/size/&lt;/span&gt;&lt;span class="py"&gt;always_on_top&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the app through &lt;code&gt;open&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;open &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; Godot.app &lt;span class="nt"&gt;--stdout&lt;/span&gt; run.log &lt;span class="nt"&gt;--stderr&lt;/span&gt; run.log &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--args&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/out.png"&lt;/span&gt;
&lt;span class="nb"&gt;rm &lt;/span&gt;override.cfg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-n&lt;/code&gt; starts a new instance even if Godot is already open, &lt;code&gt;-W&lt;/code&gt; waits until it quits, &lt;code&gt;-g&lt;/code&gt; keeps it in the background, &lt;code&gt;-j&lt;/code&gt; launches it hidden, and &lt;code&gt;--stdout&lt;/code&gt; and &lt;code&gt;--stderr&lt;/code&gt; bring its output back to a file. Without them &lt;code&gt;open&lt;/code&gt; drops the output, because Godot isn't a child process of your shell. The frame came back at 640 × 360 in the scene's own colours: a mean RGB of 188, 117, 82, the orange background with the blue box on it.&lt;/p&gt;

&lt;p&gt;Delete &lt;code&gt;override.cfg&lt;/code&gt; when the run ends. Godot reads it on every launch, so a run interrupted before the &lt;code&gt;rm&lt;/code&gt; leaves the next normal launch with a window that never gets keyboard focus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Movie Maker: a PNG sequence, then an MP4
&lt;/h2&gt;

&lt;p&gt;Movie Maker renders every frame at a fixed rate, so the video's timing doesn't depend on how fast the machine renders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;open &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; Godot.app &lt;span class="nt"&gt;--stdout&lt;/span&gt; run.log &lt;span class="nt"&gt;--stderr&lt;/span&gt; run.log &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--args&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--write-movie&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/movie/f.png"&lt;/span&gt; &lt;span class="nt"&gt;--fixed-fps&lt;/span&gt; 24 &lt;span class="nt"&gt;--quit-after&lt;/span&gt; 48
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;48 frames at 24 FPS (movie length: 00:00:02:00), recorded in 00:00:01 (200% of real-time speed).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It wrote &lt;code&gt;f00000000.png&lt;/code&gt; to &lt;code&gt;f00000047.png&lt;/code&gt;, with the audio next to them as &lt;code&gt;f.wav&lt;/code&gt;. ffmpeg turns the frames into a video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-framerate&lt;/span&gt; 24 &lt;span class="nt"&gt;-i&lt;/span&gt; movie/f%08d.png &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-crf&lt;/span&gt; 18 film.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was 48 frames at 24 fps, 2.0 seconds long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not solved: focus
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no_focus&lt;/code&gt; keeps the window from taking keyboard focus, but Godot still makes itself the active app when it opens its window. I polled the frontmost app during two runs. Godot was in front in 1 of 21 samples during the single-frame run and in 4 samples in a row during the Movie Maker run, which took about a second, and the app I was working in came back to the front each time Godot quit. In September a copy of the app bundle with &lt;code&gt;LSUIElement&lt;/code&gt; set to true didn't help either. How long Godot stays in front during a longer recording, I haven't measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Godot 4.7.1 on macOS 26.5.2, one Mac, the Forward+ renderer. I didn't try the Compatibility renderer.&lt;/li&gt;
&lt;li&gt;The off-screen position was tested with one display.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>macos</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>ulimit doesn't cap memory on macOS: stop a job before it swaps</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:31:35 +0000</pubDate>
      <link>https://dev.to/nefayran/ulimit-doesnt-cap-memory-on-macos-stop-a-job-before-it-swaps-4dd3</link>
      <guid>https://dev.to/nefayran/ulimit-doesnt-cap-memory-on-macos-stop-a-job-before-it-swaps-4dd3</guid>
      <description>&lt;p&gt;On Linux a job's memory can be capped with &lt;code&gt;ulimit -v&lt;/code&gt;, &lt;code&gt;systemd-run -p MemoryMax=&lt;/code&gt; or a cgroup, and a job that grows too big fails on its own instead of taking the machine down. macOS has no systemd and no cgroups, and &lt;code&gt;ulimit&lt;/code&gt; refuses. This is macOS 26.5.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;/bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ulimit -v 1048576'&lt;/span&gt;
/bin/bash: line 0: &lt;span class="nb"&gt;ulimit&lt;/span&gt;: virtual memory: cannot modify limit: Invalid argument
&lt;span class="nv"&gt;$ &lt;/span&gt;/bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ulimit -m 1048576'&lt;/span&gt;
/bin/bash: line 0: &lt;span class="nb"&gt;ulimit&lt;/span&gt;: max memory size: cannot modify limit: Invalid argument
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resource.setrlimit(resource.RLIMIT_DATA, ...)&lt;/code&gt; in Python raises &lt;code&gt;ValueError&lt;/code&gt;, and in the shell where &lt;code&gt;ulimit -m&lt;/code&gt; had failed, a script went on to allocate and touch 3 GiB. What macOS does instead is compress memory and write it to swap. A model that doesn't fit doesn't fail: it slows down, and so does everything else on the Mac.&lt;/p&gt;

&lt;p&gt;So the questions worth asking change: is there room before a job starts, and is swap growing while it runs?&lt;/p&gt;

&lt;h2&gt;
  
  
  Check swap while a job runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl vm.swapusage             &lt;span class="c"&gt;# compare "used" before and after a run&lt;/span&gt;
sysctl kern.memorystatus_level  &lt;span class="c"&gt;# system-wide free memory, in percent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The process footprint, the number Activity Monitor shows, doesn't count pages that are already in swap. In &lt;a href="https://allkeep.org/en/lab/model-cpu-offload-on-a-mac" rel="noopener noreferrer"&gt;one run of Qwen-Image-2.1 with diffusers' model CPU offload&lt;/a&gt; on my 48 GB M5 Pro, the footprint never went above 18.5 GB while swap grew by 9.6 GB. A chart of the footprint alone would have called that run a fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop a job before it swaps
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt;, a small library I wrote for loading models one stage at a time, ships a guard for this (&lt;code&gt;pip install stageload&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stageload guard &lt;span class="nt"&gt;--wait-free&lt;/span&gt; 40 &lt;span class="nt"&gt;--swap-budget&lt;/span&gt; 8G &lt;span class="nt"&gt;--&lt;/span&gt; python generate.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It waits until &lt;code&gt;kern.memorystatus_level&lt;/code&gt; is at least 40 %, then starts the command. If swap grows more than 8 GB over what was in use at the start, or free memory stays under 10 % for two samples, it sends SIGTERM to the command's process group and, five seconds later, SIGKILL to whatever is left. It needs no root, and the command and its process group are the only processes it ever stops. Exit code 3 means the guard stopped the command and 4 that it never found room to start it; otherwise you get the command's own exit code.&lt;/p&gt;

&lt;p&gt;That is how the eager Qwen-Image run ended: it got through all 20 steps, the VAE decode took the footprint to 43.6 GB, swap grew by 8 GB, and the guard stopped it before the Mac stalled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two heavy jobs at once
&lt;/h2&gt;

&lt;p&gt;A guard decides alone. Two guards that find room at the same moment both start their jobs, and each job was sized for a Mac with nothing else running. On 5 October one of my runs started in the gap between two video jobs of another script, and the next video job took swap past its budget within 20 seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stageload run&lt;/code&gt; puts the jobs in one queue for the machine. It takes the guard's flags. Here two jobs hold 3 and 2 GiB, started from two terminals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stageload run &lt;span class="nt"&gt;--who&lt;/span&gt; images &lt;span class="nt"&gt;--&lt;/span&gt; python3 alloc.py 3 10    &lt;span class="c"&gt;# holds 3 GiB for 10 s&lt;/span&gt;
stageload run &lt;span class="nt"&gt;--who&lt;/span&gt; upscale &lt;span class="nt"&gt;--&lt;/span&gt; python3 alloc.py 2 4    &lt;span class="c"&gt;# holds 2 GiB for 4 s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stageload queue
ram     0 running  images               pid 32488   since 2026-10-12T14:08:56+00:00  python3 alloc.py 3 10
ram     1 waiting  upscale              pid 32499   since 2026-10-12T14:08:58+00:00  python3 alloc.py 2 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second job starts when the first one ends, and then the guard's checks run as usual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:08:58Z queue: waiting in ram (1 ahead: images)
14:09:08Z queue: start in ram after 10 s, swap 6.0 GB, stop above 14.0 GB or under 10% available twice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stageload queue --history 2
done   ram    exit 0    images               waited 0 s, ran 10 s, peak 3.0 GB  python3 alloc.py 3 10
done   ram    exit 0    upscale              waited 10 s, ran 4 s, peak 2.0 GB  python3 alloc.py 2 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwurgb96ji42r1yb0kiq7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwurgb96ji42r1yb0kiq7.png" alt="The two jobs' footprint over time from their memory traces: images holds 3 GB for 10 s while upscale waits in the queue, then upscale holds 2 GB for 4 s"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Details that mattered on a shared Mac:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jobs join a lane, &lt;code&gt;ram&lt;/code&gt; by default. Lanes don't wait for each other, so a job that waits on a remote API quota can go in a lane of its own (&lt;code&gt;--lane api --wait-free 0&lt;/code&gt;) and not hold up the ones that need memory.&lt;/li&gt;
&lt;li&gt;Turns are fair between owners. The owner who started least recently goes next, so a script that queues twenty jobs takes turns with the others instead of holding the lane. The owner is &lt;code&gt;--owner&lt;/code&gt;, or the part of &lt;code&gt;--who&lt;/code&gt; before its first dash.&lt;/li&gt;
&lt;li&gt;Each run writes a memory trace of the command and every process under it, and one line to &lt;code&gt;~/.stageload/history.jsonl&lt;/code&gt; with the wait, the run time, the peak footprint and swap.&lt;/li&gt;
&lt;li&gt;Engines started outside the queue go in &lt;code&gt;~/.stageload/busy.txt&lt;/code&gt;, one pattern per line. Every run in the &lt;code&gt;ram&lt;/code&gt; lane waits until none of them has been seen for 20 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The guard and the meter read macOS counters (&lt;code&gt;libproc&lt;/code&gt;, &lt;code&gt;sysctl&lt;/code&gt;), so they only run on a Mac.&lt;/li&gt;
&lt;li&gt;The guard stops a job; it doesn't make the job smaller. On the model side, loading one model per stage is what took Qwen-Image-2.1 from 43.6 GB to 19.0 GB on the same Mac: &lt;a href="https://allkeep.org/en/lab/qwen-image-on-a-48-gb-mac" rel="noopener noreferrer"&gt;How to run Qwen-Image-2.1 on a 48 GB Mac without swap&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;For a safety net across the whole machine, &lt;a href="https://github.com/fl4p/macos-oom-guard" rel="noopener noreferrer"&gt;macos-oom-guard&lt;/a&gt; runs as root and kills the largest process before the kernel panics. The two can run together.&lt;/li&gt;
&lt;/ul&gt;

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