<?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>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>
    <item>
      <title>How to run Qwen-Image-2.1 on a 48 GB Mac without swap</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:47:33 +0000</pubDate>
      <link>https://dev.to/nefayran/how-to-run-qwen-image-21-on-a-48-gb-mac-without-swap-3g4b</link>
      <guid>https://dev.to/nefayran/how-to-run-qwen-image-21-on-a-48-gb-mac-without-swap-3g4b</guid>
      <description>&lt;p&gt;&lt;a href="https://huggingface.co/Qwen/Qwen-Image-2.1" rel="noopener noreferrer"&gt;Qwen-Image-2.1&lt;/a&gt; runs in diffusers through &lt;code&gt;QwenImage21Pipeline&lt;/code&gt;. In bfloat16 its text encoder takes 16.3 GB, its transformer 13.3 GB and its VAE 1.3 GB, so the loaded pipeline came to 31.4 GB on a 48 GB Mac, and decoding a 1024 × 1024 image pushed it 11 GB higher. On that Mac, eager loading (the usual setup, with all three models on the GPU) and diffusers' model CPU offload both went into swap and never wrote an image. Staged loading, with one of the two large models in memory at a time, finished without swap.&lt;/p&gt;

&lt;p&gt;The runs used an Apple M5 Pro with 48 GB, torch 2.14.0 and diffusers 0.41.0.dev0 from main. &lt;code&gt;QwenImage21Pipeline&lt;/code&gt; also ships in the diffusers 0.41.0 release, which I did not run. Every run made one 1024 × 1024 image in 20 steps with seed 7, under a memory guard that stopped the run once swap had grown by 8 GB.&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%2Fuh7d88lr3qt1s2msbny7.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%2Fuh7d88lr3qt1s2msbny7.png" alt="Process footprint and swap growth over time for Qwen-Image-2.1 on a 48 GB Mac: eager, model CPU offload and staged" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Eager loading goes into swap in the decode
&lt;/h2&gt;

&lt;p&gt;The usual code loads all three models onto the GPU and keeps them there:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;diffusers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QwenImage21Pipeline&lt;/span&gt;

&lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QwenImage21Pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen-Image-2.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;torch_dtype&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="n"&gt;bfloat16&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;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;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_inference_steps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;images&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It got through encoding and all 20 denoising steps at 31 to 33 GB. The VAE decode then took the footprint from 32.5 to 43.6 GB and swap grew by 8 GB, so the guard stopped the run before it wrote an image. PyTorch raised no out-of-memory error before that; the Mac went into swap instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why model CPU offload does not help on a Mac
&lt;/h2&gt;

&lt;p&gt;The answer diffusers has built in for a pipeline that does not fit is model CPU offload: each model waits on the CPU and moves to the GPU only while it runs.&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;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QwenImage21Pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen-Image-2.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;torch_dtype&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="n"&gt;bfloat16&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="nf"&gt;enable_model_cpu_offload&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a Mac the CPU and the GPU share one pool of memory, so a model that waits "on the CPU" still sits in the same RAM. In the one offload run, the process footprint never went above 18.5 GB, which looks like a fit. Swap grew anyway: by 3.5 GB during denoising and to 9.6 GB in the VAE decode, and the guard stopped the run where it had stopped eager. The footprint, the number Activity Monitor shows for a process, does not count pages that are already in swap, so it cannot show this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Load one model per stage
&lt;/h2&gt;

&lt;p&gt;The pipeline uses its models in turn: the text encoder only to encode the prompt, the transformer only in the denoising loop, and neither in the VAE decode. So the two large models never have to be in memory together. &lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt; keeps track of that: &lt;code&gt;StagedModels&lt;/code&gt; loads a model the first time a stage asks for it and releases it when the pipeline enters a stage that does not list it.&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;from&lt;/span&gt; &lt;span class="n"&gt;stageload&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StagedModels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_call&lt;/span&gt;

&lt;span class="n"&gt;STAGES&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;encode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;text_encoder&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;denoise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;transformer&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;decode&lt;/span&gt;&lt;span class="sh"&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;models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StagedModels&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text_encoder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text_encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transformer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transformer&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;on_call&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encode_prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;on_call&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepare_latents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denoise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;on_call&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_unpack_latents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;text_encoder&lt;/code&gt; and &lt;code&gt;transformer&lt;/code&gt; are functions that load each model with &lt;code&gt;from_pretrained(..., subfolder=...)&lt;/code&gt; and move it to &lt;code&gt;mps&lt;/code&gt;. The pipeline itself is built with &lt;code&gt;text_encoder=None, transformer=None&lt;/code&gt; and the VAE, which stays loaded. &lt;code&gt;on_call&lt;/code&gt; runs &lt;code&gt;models.enter(stage)&lt;/code&gt; just before the pipeline methods where each stage begins, so the pipeline's code stays as it is. One more piece is needed: the pipeline reads the transformer's config before denoising starts, so &lt;code&gt;pipe.text_encoder&lt;/code&gt; and &lt;code&gt;pipe.transformer&lt;/code&gt; are stand-ins that answer &lt;code&gt;.config&lt;/code&gt; from the checkpoint and load the model on any other use. The whole script, with the stand-ins and a memory trace, is &lt;a href="https://github.com/nefayran/stageload/blob/main/bench/qwen_image.py" rel="noopener noreferrer"&gt;bench/qwen_image.py&lt;/a&gt;, and the library installs with &lt;code&gt;pip install stageload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both staged runs finished, in 94 and 124 s. The footprint peaked at 19.0 GB while encoding, at 16.2 and 18.6 GB in the two runs' denoising, and at 14.4 GB in the decode. Swap did not grow in either run, and free memory never fell below 51 %. Loading the two models took about 13 s per run, and releasing the transformer before the decode another 4 to 5 s. Counted from the moment the transformer was in memory to the start of the decode, the 20 steps took 70 s in one staged run, 99 s in the other and 77.5 s for eager; the traces do not show why the two staged runs differ. Both produced the same image, bit for bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check a run for swap
&lt;/h2&gt;

&lt;p&gt;A run fits when swap does not grow while it runs, whatever the footprint says. Two commands show it:&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;stageload's &lt;code&gt;MemoryMeter&lt;/code&gt; writes the footprint, swap and free memory to a JSONL trace every 0.5 s, and &lt;code&gt;stageload guard&lt;/code&gt; starts a command when there is room for it and stops it once swap has grown 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 I did not measure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;VAE tiling: &lt;code&gt;pipe.vae.enable_tiling()&lt;/code&gt; decodes the image in tiles, and the decode is where the guard stopped both eager and offload. I did not try it, so I cannot say whether it lets eager finish on this Mac.&lt;/li&gt;
&lt;li&gt;Sequential CPU offload, quantized weights, and Macs with less or more memory than 48 GB.&lt;/li&gt;
&lt;li&gt;Whether staged loading gives the same image as eager: eager never wrote an image on this Mac.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The measurements in more detail are in &lt;a href="https://allkeep.org/en/lab/qwen-image-one-stage-at-a-time" rel="noopener noreferrer"&gt;Qwen-Image-2.1 one stage at a time&lt;/a&gt; and &lt;a href="https://allkeep.org/en/lab/model-cpu-offload-on-a-mac" rel="noopener noreferrer"&gt;Model CPU offload on a 48 GB Mac&lt;/a&gt;, and the traces are in &lt;a href="https://github.com/nefayran/stageload/tree/main/bench/results" rel="noopener noreferrer"&gt;the stageload repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>macos</category>
    </item>
    <item>
      <title>Model CPU offload on a 48 GB Mac: 18.5 GB footprint and 9.6 GB of swap</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Sun, 11 Oct 2026 05:44:25 +0000</pubDate>
      <link>https://dev.to/nefayran/model-cpu-offload-on-a-48-gb-mac-185-gb-footprint-and-96-gb-of-swap-25pe</link>
      <guid>https://dev.to/nefayran/model-cpu-offload-on-a-48-gb-mac-185-gb-footprint-and-96-gb-of-swap-25pe</guid>
      <description>&lt;p&gt;&lt;a href="https://allkeep.org/en/lab/qwen-image-one-stage-at-a-time" rel="noopener noreferrer"&gt;The second stageload note&lt;/a&gt; ran Qwen-Image-2.1 on a 48 GB Mac two ways. The memory guard stopped eager loading in the VAE decode, and staged loading finished. diffusers has a third way built in, &lt;code&gt;enable_model_cpu_offload&lt;/code&gt;, which keeps each model on the CPU and moves it to the GPU only while it runs. On 8 October I ran it once, with the same prompt, size, steps, seed and guard limits as the runs of 6 October.&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%2Fuh7d88lr3qt1s2msbny7.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%2Fuh7d88lr3qt1s2msbny7.png" alt="Process footprint and swap growth over time for Qwen-Image-2.1 on a 48 GB Mac: eager, model CPU offload and staged" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By the process footprint, offload looked like staged: it peaked at 18.5 GB, staged at 19.0 GB. Swap tells them apart. Encoding took 8 s and added no swap. During the 87 s of denoising, swap grew by 3.5 GB and available memory fell to 27 %. The VAE decode took swap growth to 9.6 GB and available memory down to 22 %, and the guard stopped the run 105 s in, before it wrote an image. Staged added no swap in either of its two runs and finished in 94 and 124 s.&lt;/p&gt;

&lt;p&gt;The footprint cannot show this, because it does not count pages that are already in swap. On a Mac the CPU and the GPU share one pool of memory, so a model kept "on the CPU" sits in the same RAM as the model that is running. Judged by footprint alone, offload would sit next to staged. Judged by swap, it sits next to eager, which the guard stopped at the same point after 8 GB of new swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not show
&lt;/h2&gt;

&lt;p&gt;This is one run. A second would have pushed the machine into swap again for an outcome the guard had already decided. I did not measure which pages went to swap, and I did not try offload on a Mac with more memory.&lt;/p&gt;

&lt;p&gt;The script is &lt;a href="https://github.com/nefayran/stageload/blob/main/bench/qwen_image_offload.py" rel="noopener noreferrer"&gt;bench/qwen_image_offload.py&lt;/a&gt;, and the trace is in &lt;a href="https://github.com/nefayran/stageload/tree/main/bench/results/2026-10-08-qwen-image-offload" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;. stageload installs with &lt;code&gt;pip install stageload&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>macos</category>
    </item>
    <item>
      <title>Loading a PyTorch pipeline one stage at a time: Pixal3D on a 48 GB Mac</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:00:36 +0000</pubDate>
      <link>https://dev.to/nefayran/loading-a-pytorch-pipeline-one-stage-at-a-time-pixal3d-on-a-48-gb-mac-55kg</link>
      <guid>https://dev.to/nefayran/loading-a-pytorch-pipeline-one-stage-at-a-time-pixal3d-on-a-48-gb-mac-55kg</guid>
      <description>&lt;p&gt;On 4 October a Pixal3D run on my Mac went into its texture stage, and within ten seconds the process grew from 27 to 44 GB. Available memory fell to 6 % and swap grew by 12 GB, so the guard I run heavy jobs under stopped it. The Mac has 48 GB. Before I had that guard, two Pixal3D runs, one of them next to other heavy work, ended with the Mac rebooting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ldyang694.github.io/projects/pixal3d/" rel="noopener noreferrer"&gt;Pixal3D&lt;/a&gt; turns one image into a textured 3D mesh, and &lt;a href="https://github.com/pawel-mazurkiewicz/Pixal3D-mac" rel="noopener noreferrer"&gt;Pixal3D-mac&lt;/a&gt; is its port to Apple Silicon. The &lt;code&gt;1024_cascade&lt;/code&gt; pipeline runs seven models in turn: a flow model and a decoder for the coarse structure, two flow models for the shape at 512 and 1024, a shape decoder, and a flow model and a decoder for the texture. Around them work four image feature extractors, a background remover and a camera estimator. Each stage needs one or two of the seven models, and the port keeps all of them in memory from the first second to the last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the memory does not come back
&lt;/h2&gt;

&lt;p&gt;On Apple Silicon the CPU and the GPU share one pool of memory. Pixal3D's low-VRAM mode, which is on by default, keeps idle models on the CPU and copies the active one to the GPU. On a PC with a separate graphics card, that saves video memory. On a Mac the CPU copy and the GPU copy sit in the same RAM, so the mode keeps every model resident and adds a second copy of the active one. The four feature extractors also load the same DINOv3 backbone, four times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading one stage at a time
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;stageload&lt;/a&gt; is a small library I wrote for this. It swaps the pipeline's dictionary of models for one that loads a model the first time a stage asks for it, and that releases every model the next stage does not list. Releasing moves each tensor of the module to PyTorch's &lt;code&gt;meta&lt;/code&gt; device. That frees the storage even while other code still holds a reference to the module, and calling the module afterwards raises an error that names it and the stage that released it, instead of failing somewhere inside PyTorch.&lt;/p&gt;

&lt;p&gt;The port's own &lt;code&gt;run()&lt;/code&gt; executes unchanged. stageload wraps four of the pipeline's methods to learn where each stage begins: background removal, the first image conditioning, the conditioning of each shape and texture pass, and the final decoding. The four feature extractors are built so that they share one backbone.&lt;/p&gt;

&lt;p&gt;One part of this raises no error when it goes wrong. Pixal3D seeds once at the start of a run and then draws the noise of every stage from the CPU random generator. Building a model runs its random initialisation, which draws from that same generator. A model built lazily in the middle of the run would therefore change the noise of every stage after it, and with it the mesh. stageload saves the generators' state before each load and restores it afterwards: torch's CPU, MPS and CUDA generators, Python's &lt;code&gt;random&lt;/code&gt; and NumPy's global one. On a stand-in pipeline that seeds and draws noise the way the port does, a test checks that staged and eager runs produce the same latents bit for bit, and a control that turns the restore off sees them drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not only Pixal3D
&lt;/h2&gt;

&lt;p&gt;Nothing in the core of stageload knows about Pixal3D. A pipeline needs three things from it: a function that builds each model on its own, a list of the models each stage uses, and a call where each stage begins. In code you own, that call is one line, &lt;code&gt;models.enter("decode")&lt;/code&gt;. In someone else's code, like this port, &lt;code&gt;on_call&lt;/code&gt; attaches it to a method that already runs at that point, so the pipeline itself stays as it is. A text-to-image pipeline has the same shape as Pixal3D: a text encoder, a denoiser and an image decoder run one after another. The memory meter and the guard work for any program on a Mac. Pixal3D is the only pipeline I have measured so far; the README shows the core on a small two-model pipeline, and that example runs as a test.&lt;/p&gt;

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

&lt;p&gt;On this M5 Pro with 48 GB, I ran the port's example image through &lt;code&gt;1024_cascade&lt;/code&gt; with seed 7 and 2048-pixel textures, two runs per mode, each in its own process under the guard. "Eager" is the port's own loading and "staged" is stageload. Eager runs do not get through the texture stage on this machine within the guard's limits, so the two eager runs stop where it begins. The texture number for eager comes from the run on 4 October that went into it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stage&lt;/th&gt;
&lt;th&gt;eager, peak footprint&lt;/th&gt;
&lt;th&gt;staged, peak footprint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;setup&lt;/td&gt;
&lt;td&gt;20.4 GB&lt;/td&gt;
&lt;td&gt;2.9 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;camera&lt;/td&gt;
&lt;td&gt;23.2 GB&lt;/td&gt;
&lt;td&gt;6.7 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;structure&lt;/td&gt;
&lt;td&gt;22.2 GB&lt;/td&gt;
&lt;td&gt;10.9 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shape_512&lt;/td&gt;
&lt;td&gt;26.6 GB&lt;/td&gt;
&lt;td&gt;11.4 to 11.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shape_1024&lt;/td&gt;
&lt;td&gt;30.5 GB&lt;/td&gt;
&lt;td&gt;13.1 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;texture&lt;/td&gt;
&lt;td&gt;stopped by the guard at 44.2 GB&lt;/td&gt;
&lt;td&gt;30.1 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decode&lt;/td&gt;
&lt;td&gt;not reached&lt;/td&gt;
&lt;td&gt;17.9 to 18.4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;export&lt;/td&gt;
&lt;td&gt;not reached&lt;/td&gt;
&lt;td&gt;69.6 to 70.2 GB&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%2F7gfcwe4zro09xodg5cna.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%2F7gfcwe4zro09xodg5cna.png" alt="Peak footprint per stage of Pixal3D on a 48 GB Mac, with the port's own loading and with stageload" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In every stage both modes ran, staged held 11.3 to 17.5 GB less. The eight loads, the seven models plus the shape decoder a second time for decoding, read 13.2 GB in 24 s out of about ten minutes per run.&lt;/p&gt;

&lt;p&gt;Most of the texture stage's 30 GB is working memory: the texture model itself is 2.6 GB. When decoding began, stageload released it together with the feature extractors and emptied the MPS allocator's cache, and within two seconds the footprint was down to 4.5 GB in one run and 8.0 GB in the other.&lt;/p&gt;

&lt;p&gt;The highest staged number, about 70 GB, comes in the export, after every model weight has been released. It is the port's mesh processing (remeshing, UV unwrapping, baking), which stageload does not touch. The footprint is the number Activity Monitor shows, and it counts memory macOS has compressed, so it can be larger than the 48 GB of RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not show
&lt;/h2&gt;

&lt;p&gt;The first is whether staged loading changes the result. Every run saved a hash and a copy of each sampler's output. The first latent, the coarse structure, is the same bit for bit in all four runs. From the 512 shape stage on, the port does not repeat itself on MPS even with the same seed: the two eager runs differ by up to 1.77 in the shape latent, and a staged run differs from an eager one by 1.24 to 1.86. So the comparison says any change from staged loading is no larger than the port's own variation, measured on that single pair of eager runs. The texture latent and the final mesh were not compared at all, because eager runs never reach them here.&lt;/p&gt;

&lt;p&gt;Building on the CPU did not help. stageload builds each model directly on the GPU, and that changes one tensor the checkpoint does not cover: the rotary frequencies of the sparse attention, computed when a model is built, come out up to 6e-8 apart. To see whether that explained the differences, I ran a staged pass that built every model on the CPU and moved it to the GPU, the way the port does. Its latents differed from the eager runs by 1.84 and 1.12, the same order as every other pair. Loading took 82 s instead of 24 s, and three stages peaked 2.6 to 3.8 GB higher while two came out lower. I went back to building on the GPU.&lt;/p&gt;

&lt;p&gt;My own summary had a bug that inflated one number. It took each stage's peak from the moment the stage was recorded as started, but releasing the previous stage's models takes up to 1.7 s. One memory sample in that gap put the decode peak of the CPU-built run at 29.5 GB; counted from the end of the releases, it is 16.7. A code review caught it. A stage's window now begins when the releases its start set off have finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard
&lt;/h2&gt;

&lt;p&gt;The runs went through a second part of stageload, &lt;code&gt;stageload guard&lt;/code&gt;, because other heavy jobs run on this Mac too. It starts one command only when enough memory is available and no process matching a list of heavy jobs has been seen for a while. While the command runs, it stops the command's whole process group if swap grows past a budget or available memory stays under 10 %. It needs no root, and the only processes it ever stops are the ones it started.&lt;/p&gt;

&lt;p&gt;The wait for quiet came from a failure. A chain of video jobs leaves short gaps between them, a run started in one of those gaps, and the next video job took swap past the budget within 20 s. In the bench every run started only after three minutes with no heavy job in sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  On another image
&lt;/h2&gt;

&lt;p&gt;The same staged run on a character from a game I am making took 416 s and loaded the same 13.2 GB in 37 s. Its texture stage peaked at 30.1 GB, as on the example image. Here is the input and the mesh it produced, rendered in Blender:&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%2Fe93d8g09eoginig1n57m.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%2Fe93d8g09eoginig1n57m.png" alt="The input image of a moss-covered character and the textured mesh Pixal3D made from it with stageload, rendered in Blender" width="800" height="610"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code, the traces of every run and the scripts that drew these charts are in &lt;a href="https://github.com/nefayran/stageload" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;. The Pixal3D adapter is tested against one commit of Pixal3D-mac.&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>macos</category>
    </item>
    <item>
      <title>What 30 open-source projects ask of AI-assisted contributions</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:40:04 +0000</pubDate>
      <link>https://dev.to/nefayran/what-30-open-source-projects-ask-of-ai-assisted-contributions-4il0</link>
      <guid>https://dev.to/nefayran/what-30-open-source-projects-ask-of-ai-assisted-contributions-4il0</guid>
      <description>&lt;p&gt;Between 27 September and 2 October I sent fixes, bug reports and comments to twelve open-source projects. A coding agent did much of the work: Claude Code reproduced the bugs, wrote the patches and tests, and drafted most of the texts. I chose each issue and read every text, and nothing went out from my account until I had approved it.&lt;/p&gt;

&lt;p&gt;Along the way the projects kept telling me how they want that kind of work done. spec-kit wanted the agent, the model, its settings and the extent of its help named &lt;a href="https://github.com/github/spec-kit/blob/4a339209c877a1b68e7a790b2b2269a2b68e461f/CONTRIBUTING.md?plain=1#L366-L366" rel="noopener noreferrer"&gt;in every AI-generated comment&lt;/a&gt;. MLX's pull request template opens with a ticked line: &lt;a href="https://github.com/ml-explore/mlx/blob/25532871329fe4cafdada9fa0941a1cdbfb3a9f2/.github/pull_request_template.md?plain=1#L1-L2" rel="noopener noreferrer"&gt;"I understand it is strictly prohibited to use AI to write PR description"&lt;/a&gt;. So on 2 October I read the contribution rules of 30 repositories: the ones I had sent work to, and 21 more that I had not.&lt;/p&gt;

&lt;p&gt;The short version: 19 of the 30 have a rule about AI, and 14 of those 19 rules appeared in 2026. Most make the person who submits responsible for the change. Fifteen ask you to say that you used AI, and ten want a person, not a model, to write the description, the comments or the commit message. The commit trailer that coding agents add by default is required by some of these projects and banned by others.&lt;/p&gt;

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

&lt;p&gt;The sample is not random. Nine of the 30 are projects I sent work to that week: MLX, spec-kit, huggingface_hub, codebase-memory-mcp, Deskflow, agent-framework, awslabs/mcp, kaggle-cli and avoid-ai-writing. The other 21 are projects in AI tooling, developer tools and infrastructure, among them Docling, garak, TypeScript, Rust, Node.js and Kubernetes. The three remaining projects I sent work to, copilot-cli, PartCrafter and ltx-2-mlx, are not in the 30, and I found no AI rule in any of them.&lt;/p&gt;

&lt;p&gt;In each repository I read CONTRIBUTING, the pull request and issue templates, AGENTS.md and CLAUDE.md, the code of conduct, and the guides and wiki pages these files link to, at the default branch on 2 October 2026. Every quote below links to its line at the commit I read, so the link shows what the file said that day even after it changes. To date a rule, I searched the history of its file for the first commit that contains it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who has a rule
&lt;/h2&gt;

&lt;p&gt;19 of the 30 have a rule about AI in contributions: MLX, spec-kit, TypeScript, huggingface_hub, transformers, trl, peft, LangChain, Strands Agents, MLflow, garak, codebase-memory-mcp, Deskflow, workers-sdk, pydantic-ai, Rust, CPython, Node.js and Kubernetes.&lt;/p&gt;

&lt;p&gt;I found none in nine: accelerate, awslabs/mcp, kaggle-cli, Docling, BeeAI, ADK for Python, NeMo Agent Toolkit, VS Code and React. Two more I did not count. agent-framework only warns that AI has raised the number of contributions, so reviews take longer, and avoid-ai-writing checks the style of prose rather than how it was produced.&lt;/p&gt;

&lt;p&gt;A rule usually asks for several things at once:&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%2Fmtocq64itwy49jzt0sek.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%2Fmtocq64itwy49jzt0sek.png" alt="What the 19 rules ask for, counted in repositories: say that you used AI, 15; the person who submits is responsible and must understand the change, 12; a person writes the description, the comments or the commit messages, 10; a rule about commit trailers, 9; no bulk or automated pull requests, 7; an issue or a maintainer's approval before the pull request, 6" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second bar is my reading: 12 of the 19 say in some form that the person who submits is responsible for the change and must understand it. huggingface_hub does it in two sentences: &lt;a href="https://github.com/huggingface/huggingface_hub/blob/d711944ddf94abe9ef2a34f80d41007968ff9c30/CONTRIBUTING.md?plain=1#L43-L43" rel="noopener noreferrer"&gt;"Using AI to help write code is fine. Submitting AI-generated slop you cannot explain is not."&lt;/a&gt; CPython expects authors &lt;a href="https://github.com/python/devguide/blob/2439c5ff4529531db6773b6d604d0bff8a234385/getting-started/ai-tools.rst?plain=1#L8-L22" rel="noopener noreferrer"&gt;"to be able to explain their proposed changes in their own words"&lt;/a&gt;, and Strands Agents tells contributors &lt;a href="https://github.com/strands-agents/harness-sdk/blob/bc4cec4d0cd62fcd4d6ca075a22718d15ec3031a/CONTRIBUTING.md?plain=1#L235-L240" rel="noopener noreferrer"&gt;"you are the author of your pull request, not your agent."&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When they appeared
&lt;/h2&gt;

&lt;p&gt;CPython was first, with a short page in its developer guide in October 2024. Four followed in 2025: spec-kit, LangChain, Kubernetes and MLflow. The other 14 appeared in 2026, seven of them since June. codebase-memory-mcp added its rule eleven days before I read it.&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%2Fqwyechkdkk0u2kisr6r0.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%2Fqwyechkdkk0u2kisr6r0.png" alt="When each of the 19 repositories with a rule about AI contributions first added it, by half-year: CPython in July to December 2024; none in January to June 2025; spec-kit, LangChain, Kubernetes and MLflow in July to December 2025; Deskflow, transformers, trl, TypeScript, pydantic-ai, garak, huggingface_hub, peft and Strands Agents in January to June 2026; workers-sdk, Rust, Node.js, MLX and codebase-memory-mcp in July to September 2026" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The files are still changing. In 16 of the 19, a file that holds the rule was edited in September or on the first two days of October, though not every one of those edits touched the AI text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say so
&lt;/h2&gt;

&lt;p&gt;Disclosure is the most common rule, and the projects differ on how much of it they want. For Kubernetes one sentence is enough: its guide says that including "This PR was written in part with the assistance of generative AI" in the description &lt;a href="https://github.com/kubernetes/community/blob/e09b86d4a601cb612a36f770c3b291229ca2b44d/contributors/guide/pull-requests.md?plain=1#L629-L630" rel="noopener noreferrer"&gt;"is sufficient."&lt;/a&gt; TypeScript closes the PR without it: &lt;a href="https://github.com/microsoft/TypeScript/blob/09b1db0617311f3b39f165bb3480c0ce2f718b27/CONTRIBUTING.md?plain=1#L5-L5" rel="noopener noreferrer"&gt;"If your PR appears AI-authored and you do not include this disclosure, your PR will be closed without review."&lt;/a&gt; spec-kit wants the agent, the model, the settings and the extent named &lt;a href="https://github.com/github/spec-kit/blob/4a339209c877a1b68e7a790b2b2269a2b68e461f/CONTRIBUTING.md?plain=1#L358-L364" rel="noopener noreferrer"&gt;in the pull request&lt;/a&gt;, again in each AI-generated comment, and as an &lt;a href="https://github.com/github/spec-kit/blob/4a339209c877a1b68e7a790b2b2269a2b68e461f/CONTRIBUTING.md?plain=1#L403-L409" rel="noopener noreferrer"&gt;&lt;code&gt;Assisted-by:&lt;/code&gt; trailer&lt;/a&gt; on each commit an agent wrote.&lt;/p&gt;

&lt;p&gt;Two projects put the stakes in one line. codebase-memory-mcp: &lt;a href="https://github.com/DeusData/codebase-memory-mcp/blob/96c3f41cf334d87670cb085f1fcf16f637293222/CONTRIBUTING.md?plain=1#L156-L156" rel="noopener noreferrer"&gt;"Disclosure is never held against a contribution. Finding out later is."&lt;/a&gt; Deskflow: &lt;a href="https://github.com/deskflow/deskflow/wiki/Contributing/e33e4c85be89166ddeac4e1b1c6b7903d3636f38" rel="noopener noreferrer"&gt;"Being dishonest about AI use in contributions will result in a ban from the project."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CPython asks for less than the rest: &lt;a href="https://github.com/python/devguide/blob/2439c5ff4529531db6773b6d604d0bff8a234385/getting-started/ai-tools.rst?plain=1#L24-L26" rel="noopener noreferrer"&gt;"Disclosure of the use of AI tools in the PR description is appreciated, while not required."&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A person writes the words
&lt;/h2&gt;

&lt;p&gt;Ten projects let a model help with the code but want a person to write the text. Rust is the strictest: &lt;a href="https://github.com/rust-lang/rust/blob/c1658342b0ea7eab61db1960082668c77f553522/src/doc/rustc-dev-guide/src/llm-guidance/writing.md?plain=1#L41-L48" rel="noopener noreferrer"&gt;"LLM-created PR descriptions are banned. LLM-created GitHub comments are banned."&lt;/a&gt; The same goes for commits: &lt;a href="https://github.com/rust-lang/rust/blob/c1658342b0ea7eab61db1960082668c77f553522/src/doc/rustc-dev-guide/src/llm-guidance/writing.md?plain=1#L106-L106" rel="noopener noreferrer"&gt;"Commit messages must be authored by you, not your LLM."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes and Node.js draw the line at review. Kubernetes: &lt;a href="https://github.com/kubernetes/community/blob/e09b86d4a601cb612a36f770c3b291229ca2b44d/contributors/guide/pull-requests.md?plain=1#L644-L646" rel="noopener noreferrer"&gt;"When responding to review comments, you must do so without relying on AI tools."&lt;/a&gt; Node.js: responses to feedback &lt;a href="https://github.com/nodejs/node/blob/463711aa555f8bdaf72547721a4f4bc27190b67b/doc/contributing/ai-guidelines.md?plain=1#L74-L75" rel="noopener noreferrer"&gt;"must not be automated by AI tools."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Others build it into their templates. MLX has the line quoted above, and &lt;a href="https://github.com/ml-explore/mlx/blob/25532871329fe4cafdada9fa0941a1cdbfb3a9f2/.github/ISSUE_TEMPLATE/bug_report.md?plain=1#L10-L10" rel="noopener noreferrer"&gt;a matching one for issues&lt;/a&gt;. Strands Agents gives its pull request template a "Human Overview" section that &lt;a href="https://github.com/strands-agents/harness-sdk/blob/bc4cec4d0cd62fcd4d6ca075a22718d15ec3031a/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L1-L3" rel="noopener noreferrer"&gt;"An AI agent MUST NOT fill out"&lt;/a&gt;. LangChain's template warns: &lt;a href="https://github.com/langchain-ai/langchain/blob/ff46bb478bfbef98dd35ea705fa4e92a33881142/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L11-L11" rel="noopener noreferrer"&gt;"If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!"&lt;/a&gt;&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%2Fup0uvabkmvvmsx37bu5o.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%2Fup0uvabkmvvmsx37bu5o.png" alt="The exact lines in three pull request templates. ml-explore/mlx, .github/pull_request_template.md, lines 1 and 2: " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No bulk, and ask first
&lt;/h2&gt;

&lt;p&gt;Seven projects ban bulk or automated submissions. TypeScript describes the workflow it refuses: &lt;a href="https://github.com/microsoft/TypeScript/blob/09b1db0617311f3b39f165bb3480c0ce2f718b27/CONTRIBUTING.md?plain=1#L7-L7" rel="noopener noreferrer"&gt;"workflows in which an operator points an autonomous agent at GitHub, has it generate patches across many unrelated issues, and forwards the output to us as pull requests."&lt;/a&gt; LangChain's guide gives a test that fits any contribution: &lt;a href="https://github.com/langchain-ai/docs/blob/546af7d28320c76d57f2ad288740ce8d2035af21/src/oss/contributing/overview.mdx?plain=1#L129-L129" rel="noopener noreferrer"&gt;"If the effort required to create a pull request is less than the effort required for maintainers to review it, that contribution should not be submitted."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six want an approval before the pull request. In peft it is a maintainer's comment containing &lt;a href="https://github.com/huggingface/peft/blob/532a05dd505c28993119b7715ee286f4234bf51b/.ai/AGENTS.md?plain=1#L30-L36" rel="noopener noreferrer"&gt;&lt;code&gt;@peft-triage approved&lt;/code&gt;&lt;/a&gt;. In Rust a change created by an LLM must be pre-arranged, which means that &lt;a href="https://github.com/rust-lang/rust-forge/blob/70027ba878b5aa8534c8f70ad5aec0b73c6af236/src/policies/llm-usage.md?plain=1#L145-L147" rel="noopener noreferrer"&gt;"a reviewer has communicated &lt;em&gt;ahead of time&lt;/em&gt; that they are willing to review an LLM-created PR."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three projects turn away newcomers who use agents. transformers asks &lt;a href="https://github.com/huggingface/transformers/blob/000a29ffd0f91d936d5d1ea3720c1fa17b6be76f/CONTRIBUTING.md?plain=1#L19-L26" rel="noopener noreferrer"&gt;"that first-time contributors do not use code agents to create issues or PRs"&lt;/a&gt;, and trl &lt;a href="https://github.com/huggingface/trl/blob/f53b45f5edc9fbbf62b2814774d17f53fbd6ec77/CONTRIBUTING.md?plain=1#L15-L15" rel="noopener noreferrer"&gt;"will not review fully AI-generated PRs from first-time contributors."&lt;/a&gt; Node.js asks new contributors to &lt;a href="https://github.com/nodejs/node/blob/463711aa555f8bdaf72547721a4f4bc27190b67b/AGENTS.md?plain=1#L6-L9" rel="noopener noreferrer"&gt;"avoid using AI agents to interact with the project"&lt;/a&gt;, and it bans AI fixes for issues labelled "good first issue", which &lt;a href="https://github.com/nodejs/node/blob/463711aa555f8bdaf72547721a4f4bc27190b67b/doc/contributing/ai-guidelines.md?plain=1#L40-L42" rel="noopener noreferrer"&gt;"are meant to help new human contributors, not an AI, learn about the code base"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Rust also caps the share of this work: &lt;a href="https://github.com/rust-lang/rust-forge/blob/70027ba878b5aa8534c8f70ad5aec0b73c6af236/src/policies/llm-usage.md?plain=1#L199-L200" rel="noopener noreferrer"&gt;"If more than half of PRs merged in a 6-week window are LLM-created, we disallow merging new LLM-created PRs until we go back below 50%, with a minimum cooldown of 10 days."&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trailer they disagree on
&lt;/h2&gt;

&lt;p&gt;Coding agents often sign their work. Claude Code, for one, adds a &lt;code&gt;Co-Authored-By: Claude …&lt;/code&gt; trailer to each commit and a "Generated with Claude Code" line to pull requests unless told not to. Nine projects have a rule about such trailers, and the rules point in opposite directions:&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%2Fteue42fowyt0quue8egq.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%2Fteue42fowyt0quue8egq.png" alt="What 9 of the 30 repositories say about AI co-author trailers. Required: MLflow, Co-Authored-By: Claude for Claude Code's changes; garak, Co-authored-by naming the AI tool; spec-kit, Assisted-by with the agent and model, keeping the trailers a tool adds; Node.js, Assisted-by with the agent name. Banned: Kubernetes, AI co-authors, assisted-by and similar trailers; Rust, Co-Authored-By trailers; pydantic-ai, Claude as a co-author; Deskflow, co-author tags for LLMs; Node.js, an agent's own Co-authored-by or Signed-off-by. Allowed: codebase-memory-mcp" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The sources: &lt;a href="https://github.com/mlflow/mlflow/blob/f0f42b0d849b3f95aa1508d604b6d325426e72ed/CLAUDE.md?plain=1#L165-L172" rel="noopener noreferrer"&gt;MLflow&lt;/a&gt;, &lt;a href="https://github.com/NVIDIA/garak/blob/8d1259ef310e4803cf5a4cc77267fdfdc24434ec/AGENTS.md?plain=1#L78-L85" rel="noopener noreferrer"&gt;garak&lt;/a&gt;, &lt;a href="https://github.com/github/spec-kit/blob/4a339209c877a1b68e7a790b2b2269a2b68e461f/CONTRIBUTING.md?plain=1#L403-L409" rel="noopener noreferrer"&gt;spec-kit&lt;/a&gt;, &lt;a href="https://github.com/nodejs/node/blob/463711aa555f8bdaf72547721a4f4bc27190b67b/AGENTS.md?plain=1#L31-L50" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;, &lt;a href="https://github.com/kubernetes/community/blob/e09b86d4a601cb612a36f770c3b291229ca2b44d/contributors/guide/pull-requests.md?plain=1#L632-L633" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt;, &lt;a href="https://github.com/rust-lang/rust/blob/c1658342b0ea7eab61db1960082668c77f553522/AGENTS.md?plain=1#L192-L196" rel="noopener noreferrer"&gt;Rust&lt;/a&gt;, &lt;a href="https://github.com/pydantic/pydantic-ai/blob/6bc07cf18b0641ea92343d8c589cfb922108b802/AGENTS.md?plain=1#L75-L75" rel="noopener noreferrer"&gt;pydantic-ai&lt;/a&gt;, &lt;a href="https://github.com/deskflow/deskflow/wiki/Hacking-Guide/faa6d1d8adf79d13dda49297f27afbe2d0c3c518" rel="noopener noreferrer"&gt;Deskflow&lt;/a&gt; and &lt;a href="https://github.com/DeusData/codebase-memory-mcp/blob/96c3f41cf334d87670cb085f1fcf16f637293222/CONTRIBUTING.md?plain=1#L162-L162" rel="noopener noreferrer"&gt;codebase-memory-mcp&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Kubernetes: &lt;a href="https://github.com/kubernetes/community/blob/e09b86d4a601cb612a36f770c3b291229ca2b44d/contributors/guide/pull-requests.md?plain=1#L632-L633" rel="noopener noreferrer"&gt;"Listing AI tooling as a co-author, co-signing commits using an AI tool, or using the &lt;code&gt;assisted-by&lt;/code&gt;, &lt;code&gt;co-developed&lt;/code&gt; or similar commit trailer is not allowed."&lt;/a&gt; pydantic-ai says it in the file Claude Code loads when a session starts: &lt;a href="https://github.com/pydantic/pydantic-ai/blob/6bc07cf18b0641ea92343d8c589cfb922108b802/AGENTS.md?plain=1#L75-L75" rel="noopener noreferrer"&gt;"Never add yourself (Claude) as a co-author on commits."&lt;/a&gt; Rust's guide lists both default lines among its &lt;a href="https://github.com/rust-lang/rust/blob/c1658342b0ea7eab61db1960082668c77f553522/src/doc/rustc-dev-guide/src/llm-guidance/writing.md?plain=1#L174-L178" rel="noopener noreferrer"&gt;bad examples of disclosure&lt;/a&gt;: &lt;code&gt;🤖 Generated with Claude Code&lt;/code&gt; and &lt;code&gt;Co-Authored-By: Claude Opus 4.6 (1M context) &amp;lt;noreply@anthropic.com&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the default trailer is what MLflow asks for and a rule break in Kubernetes, Rust and pydantic-ai. No setting is right everywhere. An agent has to read a repository's rules before its first commit there, and so does the person who runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules written for the agent
&lt;/h2&gt;

&lt;p&gt;Some of these rules address the agent directly, in AGENTS.md or CLAUDE.md, the files coding agents read when they start work in a repository. What they tell it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pydantic-ai: &lt;a href="https://github.com/pydantic/pydantic-ai/blob/6bc07cf18b0641ea92343d8c589cfb922108b802/AGENTS.md?plain=1#L11-L11" rel="noopener noreferrer"&gt;"you are the first line of defense against low-quality contributions and maintainer headaches"&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;MLX: do not &lt;a href="https://github.com/ml-explore/mlx/blob/25532871329fe4cafdada9fa0941a1cdbfb3a9f2/AGENTS.md?plain=1#L5-L11" rel="noopener noreferrer"&gt;"write PR descriptions and commit messages for the user"&lt;/a&gt;, answer comments on the user's behalf, push or open a pull request.&lt;/li&gt;
&lt;li&gt;Rust: stop, &lt;a href="https://github.com/rust-lang/rust/blob/c1658342b0ea7eab61db1960082668c77f553522/AGENTS.md?plain=1#L77-L79" rel="noopener noreferrer"&gt;"name the prohibited category, and tell the user to author it."&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;transformers: warn people who are not yet contributors, &lt;a href="https://github.com/huggingface/transformers/blob/000a29ffd0f91d936d5d1ea3720c1fa17b6be76f/.ai/AGENTS.md?plain=1#L17-L28" rel="noopener noreferrer"&gt;"including the risk of being blocked."&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;spec-kit: ask the operator before a fourth open pull request in the repository, &lt;a href="https://github.com/github/spec-kit/blob/4a339209c877a1b68e7a790b2b2269a2b68e461f/CONTRIBUTING.md?plain=1#L397-L401" rel="noopener noreferrer"&gt;"including during autonomous operation"&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Strands Agents, to agents that run on a schedule: &lt;a href="https://github.com/strands-agents/harness-sdk/blob/bc4cec4d0cd62fcd4d6ca075a22718d15ec3031a/team/AGENT_GUIDELINES.md?plain=1#L33-L35" rel="noopener noreferrer"&gt;"work at a pace humans can follow and respond to."&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These rules work only if the agent reads the file and follows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened to mine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kaggle/kaggle-cli: &lt;a href="https://github.com/Kaggle/kaggle-cli/pull/1206" rel="noopener noreferrer"&gt;PR #1206&lt;/a&gt;, streaming &lt;code&gt;kernels output&lt;/code&gt; files through the shared downloader. Merged the same day.&lt;/li&gt;
&lt;li&gt;conorbronsdon/avoid-ai-writing: PRs &lt;a href="https://github.com/conorbronsdon/avoid-ai-writing/pull/352" rel="noopener noreferrer"&gt;#352&lt;/a&gt;, &lt;a href="https://github.com/conorbronsdon/avoid-ai-writing/pull/361" rel="noopener noreferrer"&gt;#361&lt;/a&gt; and &lt;a href="https://github.com/conorbronsdon/avoid-ai-writing/pull/362" rel="noopener noreferrer"&gt;#362&lt;/a&gt;, two detector fixes and a test. All three merged, each within a day.&lt;/li&gt;
&lt;li&gt;huggingface/huggingface_hub: &lt;a href="https://github.com/huggingface/huggingface_hub/issues/5065" rel="noopener noreferrer"&gt;issue #5065&lt;/a&gt;, &lt;code&gt;--env-file&lt;/code&gt; lost its first variable when the file started with a UTF-8 BOM. A maintainer opened the fix, &lt;a href="https://github.com/huggingface/huggingface_hub/pull/5066" rel="noopener noreferrer"&gt;#5066&lt;/a&gt;, 52 minutes later, and it was merged within two hours of the report.&lt;/li&gt;
&lt;li&gt;microsoft/agent-framework: &lt;a href="https://github.com/microsoft/agent-framework/pull/8963" rel="noopener noreferrer"&gt;PR #8963&lt;/a&gt;, data URIs with parameters. Closed after three hours as a duplicate: an older open PR, &lt;a href="https://github.com/microsoft/agent-framework/pull/8918" rel="noopener noreferrer"&gt;#8918&lt;/a&gt;, had the same fix.&lt;/li&gt;
&lt;li&gt;ml-explore/mlx: &lt;a href="https://github.com/ml-explore/mlx/pull/4610" rel="noopener noreferrer"&gt;PR #4610&lt;/a&gt;, Metal RMSNorm and LayerNorm with negative-stride weights. Closed after two and a half hours: "Thanks for the PR but this would not be the ideal fix."&lt;/li&gt;
&lt;li&gt;DeusData/codebase-memory-mcp: &lt;a href="https://github.com/DeusData/codebase-memory-mcp/issues/2454" rel="noopener noreferrer"&gt;issue #2454&lt;/a&gt; and &lt;a href="https://github.com/DeusData/codebase-memory-mcp/pull/2456" rel="noopener noreferrer"&gt;PR #2456&lt;/a&gt;, a misleading hint on empty search results. Labelled a high-priority bug, waiting for review.&lt;/li&gt;
&lt;li&gt;dgrauet/ltx-2-mlx: &lt;a href="https://github.com/dgrauet/ltx-2-mlx/pull/167" rel="noopener noreferrer"&gt;PR #167&lt;/a&gt;, &lt;code&gt;last&lt;/code&gt; and negative frame indices for image anchors. The maintainer asked for one fix before merging, and I sent it on 3 October.&lt;/li&gt;
&lt;li&gt;github/spec-kit, microsoft/agent-framework and awslabs/mcp: a proposed scope on &lt;a href="https://github.com/github/spec-kit/issues/4213#issuecomment-5946177460" rel="noopener noreferrer"&gt;#4213&lt;/a&gt; and plans for a fix on &lt;a href="https://github.com/microsoft/agent-framework/issues/8909#issuecomment-5949566164" rel="noopener noreferrer"&gt;#8909&lt;/a&gt; and &lt;a href="https://github.com/awslabs/mcp/issues/4705#issuecomment-5950659905" rel="noopener noreferrer"&gt;#4705&lt;/a&gt;. Waiting for maintainers.&lt;/li&gt;
&lt;li&gt;deskflow/deskflow: &lt;a href="https://github.com/deskflow/deskflow/issues/10068#issuecomment-5929237661" rel="noopener noreferrer"&gt;the root cause of #10068&lt;/a&gt;, a crash on macOS. Labelled and assigned, no reply yet.&lt;/li&gt;
&lt;li&gt;github/copilot-cli: &lt;a href="https://github.com/github/copilot-cli/issues/5038" rel="noopener noreferrer"&gt;issue #5038&lt;/a&gt;, the grep tool ignores &lt;code&gt;n&lt;/code&gt; without the dash. Labelled, no reply yet.&lt;/li&gt;
&lt;li&gt;wgsxm/PartCrafter: &lt;a href="https://github.com/wgsxm/PartCrafter/pull/46" rel="noopener noreferrer"&gt;PR #46&lt;/a&gt;, running on Apple Silicon. No reply yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five of the twelve have an AI rule: MLX, spec-kit, huggingface_hub, codebase-memory-mcp and Deskflow. In huggingface_hub I only opened an issue, and its rule is about pull requests. In MLX I wrote the pull request description and the commit message myself, as its rules require, and the description says what the agent did. My spec-kit comment carries the full disclosure that spec-kit asks for, and in codebase-memory-mcp the issue, the pull request and each comment carry its one line. Two of my codebase-memory-mcp replies went out on 28 September without that line, a week after the project added the rule; I added it on 1 October. Deskflow asks for a disclosure in pull requests. My comment there, which the agent drafted, went out without one; I added the line on 3 October.&lt;/p&gt;

&lt;p&gt;Neither closing comment mentions AI. agent-framework closed my PR because an older open one had the same fix, which I would have found if I had searched before writing mine. The MLX maintainer judged the approach.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Before writing a fix, I search the open pull requests and the issue's timeline for the issue number. The duplicate came from skipping that.&lt;/li&gt;
&lt;li&gt;In large projects I post a plan as a comment and open no pull request until a maintainer answers. agent-framework, awslabs/mcp and spec-kit are at that stage now.&lt;/li&gt;
&lt;li&gt;One open pull request per project at a time.&lt;/li&gt;
&lt;li&gt;The text follows each project's rule. If a project bans AI-written descriptions, as MLX does, I write them myself. spec-kit and codebase-memory-mcp get their disclosure in the form they ask for, and a project with no rule gets no disclosure line.&lt;/li&gt;
&lt;li&gt;No trailers unless a project asks for one. My commits carry my name only.&lt;/li&gt;
&lt;li&gt;I do not send this kind of work to projects whose rules it would not meet. Rust, Kubernetes and Node.js ban AI-written comments or replies to review, transformers, trl and Node.js ask newcomers not to use agents, and TypeScript refuses queue-driven work.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This is one account over six days. The 30 repositories are my choice and lean towards AI tooling, where rules are more likely. The kinds are my labels, and one rule often fits several. Everything was read on 2 October 2026, and in 16 of the 19 the rule files changed in the month before, so parts of this will be out of date soon. The pinned links show what each file said that day.&lt;/p&gt;

&lt;p&gt;All 106 quotes, the counts and a script that checks every quote against its file are in &lt;a href="https://github.com/nefayran/oss-ai-rules" rel="noopener noreferrer"&gt;nefayran/oss-ai-rules&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This note was drafted with Claude Code (Claude Opus 5.5), which also collected the rule files and checked every quote against them.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>github</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
