<?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>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 plaintext"&gt;&lt;code&gt;$ PYTORCH_MPS_HIGH_WATERMARK_RATIO=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 plaintext"&gt;&lt;code&gt;PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.8 PYTORCH_MPS_LOW_WATERMARK_RATIO=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"&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="err"&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="err"&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="err"&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="err"&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>
    <item>
      <title>Where a code graph pays off: big repositories, small models</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:53:18 +0000</pubDate>
      <link>https://dev.to/nefayran/where-a-code-graph-pays-off-big-repositories-small-models-30h3</link>
      <guid>https://dev.to/nefayran/where-a-code-graph-pays-off-big-repositories-small-models-30h3</guid>
      <description>&lt;p&gt;&lt;a href="https://allkeep.org/en/lab/code-graph-vs-grep" rel="noopener noreferrer"&gt;My last benchmark&lt;/a&gt; said a code graph did not make my coding agent cheaper. It was too small to say more than that: 24 questions, nothing bigger than 25k graph nodes, no Opus. So I ran it again at a size where the answer could change, and wrote the plan down first. The hypotheses, repositories, budget and analysis are in &lt;a href="https://github.com/nefayran/code-graph-vs-grep/blob/main/bench/PLAN-v2.md" rel="noopener noreferrer"&gt;PLAN-v2.md&lt;/a&gt;, committed before any run.&lt;/p&gt;

&lt;p&gt;The short version: a graph pays when the model would otherwise keep searching. Haiku on kubernetes and vscode is that case. Sonnet saves a little there and opus nothing, and on a small repository the graph costs sonnet more than plain grep.&lt;/p&gt;

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

&lt;p&gt;The agent is GitHub Copilot CLI 1.0.90, run headless, one question per session, with two arms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;grep: Copilot's view, grep and glob tools and nothing else;&lt;/li&gt;
&lt;li&gt;graph: the same three tools, plus cbm-lean (my thin wrapper around codebase-memory-mcp 0.9.0) and a routing rule at the top of the prompt: the graph for callers, callees and call chains, grep for exact strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four public repositories, each pinned to a commit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;repository&lt;/th&gt;
&lt;th&gt;language&lt;/th&gt;
&lt;th&gt;graph nodes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full-stack-fastapi-template&lt;/td&gt;
&lt;td&gt;Python, TypeScript&lt;/td&gt;
&lt;td&gt;1,601&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dub&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;25,023&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubernetes&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;146,421&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vscode&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;218,883&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each repository has 16 questions. Eight are structural: who calls a function, what it calls, the call path from a CLI command or an HTTP handler down to a named function, and which functions break if a signature changes. Eight are exact: a config value, where an environment variable is read, the line an error is raised on, the line a type is defined on. I checked every answer key by hand at the pinned commit, and an entry counts only as a whole word, so &lt;code&gt;init_db&lt;/code&gt; does not pass for &lt;code&gt;init&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Haiku 4.5 answered all 64 questions three times in each arm, and sonnet 5 twice. Opus 5.5 costs 15 premium requests a prompt in Copilot, so it answered twelve structural questions from kubernetes and vscode once. That is 664 runs and about 744 premium requests.&lt;/p&gt;

&lt;p&gt;Cost is tokenEquiv, as in the first note: uncached input, plus 1.25 × cache writes, plus 0.1 × cache reads, summed over the model calls of a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;The graph's cost as a share of grep's: for each question the graph arm's median over repetitions divided by the grep arm's, then the median over questions, with a 95% bootstrap interval. Below 1 the graph is cheaper.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;repository size&lt;/th&gt;
&lt;th&gt;structural&lt;/th&gt;
&lt;th&gt;exact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;haiku 4.5&lt;/td&gt;
&lt;td&gt;small (1.6k nodes)&lt;/td&gt;
&lt;td&gt;0.88 (0.65–1.29)&lt;/td&gt;
&lt;td&gt;0.92 (0.72–1.15)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haiku 4.5&lt;/td&gt;
&lt;td&gt;medium (25k)&lt;/td&gt;
&lt;td&gt;0.61 (0.11–1.50)&lt;/td&gt;
&lt;td&gt;0.96 (0.75–1.37)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haiku 4.5&lt;/td&gt;
&lt;td&gt;large (146k, 219k)&lt;/td&gt;
&lt;td&gt;0.29 (0.11–0.70)&lt;/td&gt;
&lt;td&gt;1.01 (0.94–1.28)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sonnet 5&lt;/td&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;1.51 (1.45–1.70)&lt;/td&gt;
&lt;td&gt;1.42 (1.20–1.71)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sonnet 5&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;0.94 (0.57–2.00)&lt;/td&gt;
&lt;td&gt;1.14 (1.05–1.30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sonnet 5&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;td&gt;0.74 (0.62–0.92)&lt;/td&gt;
&lt;td&gt;1.13 (1.01–1.29)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;opus 5.5&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;td&gt;0.97 (0.72–1.10)&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Correct answers on the structural questions, graph against grep:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;small&lt;/th&gt;
&lt;th&gt;medium&lt;/th&gt;
&lt;th&gt;large&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;haiku 4.5&lt;/td&gt;
&lt;td&gt;13/24 against 19/24&lt;/td&gt;
&lt;td&gt;20/24 against 22/24&lt;/td&gt;
&lt;td&gt;38/47 against 33/48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sonnet 5&lt;/td&gt;
&lt;td&gt;13/16 against 15/16&lt;/td&gt;
&lt;td&gt;16/16 against 16/16&lt;/td&gt;
&lt;td&gt;32/32 against 28/32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;opus 5.5&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;12/12 against 12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost and accuracy together, as tokenEquiv per correct answer on the structural questions:&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%2Frn7ovoyvmtt3tplvctg9.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%2Frn7ovoyvmtt3tplvctg9.png" alt="Tokens per correct answer on structural questions, with the code graph and with grep only, on the FastAPI template and on kubernetes and vscode" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What holds up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On kubernetes and vscode, haiku with the graph spent 0.29 of what it spent with grep on structural questions and was cheaper on 15 of 16 of them. It was also right more often, so per correct answer the gap is 47k against 209k tokenEquiv. This is the only result that survives the Holm correction across the 13 tests (p = 0.006).&lt;/li&gt;
&lt;li&gt;Sonnet on the same questions: 0.74, cheaper on 12 of 16, and 32 of 32 correct against 28 of 32. Opus: 0.97, cheaper on 7 of 12, and every answer right in both arms.&lt;/li&gt;
&lt;li&gt;On the small repository the graph never paid. Haiku spent about the same and got structural questions wrong more often. Sonnet spent 42 to 51% more.&lt;/li&gt;
&lt;li&gt;On exact questions the graph made no measurable difference for haiku, and sonnet paid 13 to 42% more for it.&lt;/li&gt;
&lt;li&gt;The graph's tool schemas and the routing rule add a fixed 1.56k prompt tokens to every model call for haiku and 2.07k for sonnet and opus, at every repository size.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the small model gains most
&lt;/h2&gt;

&lt;p&gt;Count the model calls. With grep, haiku needed a median of 22 calls per structural answer on kubernetes and 20 on vscode, and one impact question took 114. With the graph it needed 5 and 4. Every call sends the conversation again, so a long search costs far more than its call count suggests: haiku's most expensive grep answer came to 471k tokenEquiv.&lt;/p&gt;

&lt;p&gt;Sonnet's searches with grep were shorter, 8 calls against 4 with the graph. Opus took 4 or 5 calls either way, so the graph had nothing to cut, and its fixed overhead ate what little it saved.&lt;/p&gt;

&lt;p&gt;My guess, which I did not test directly: a graph replaces search turns, so it pays when the model would otherwise take many of them, which means a weaker model on a larger repository. A strong model already searches efficiently with grep, and on a small repository nobody needs many turns.&lt;/p&gt;

&lt;p&gt;Copilot bills premium requests per prompt, whatever the number of calls, so inside Copilot the graph saves no money. It saves time: haiku's structural answers on the large repositories took a median of 71 s with the graph and 157 s with grep. On the small repository the graph arm was slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each arm went wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A field called &lt;code&gt;lines&lt;/code&gt;. Asked on which line &lt;code&gt;HorizontalController&lt;/code&gt; is defined, the graph arm answered horizontal.go:43 in all five runs, haiku and sonnet alike. The struct starts on line 93 and is 43 lines long. The snippet that codebase-memory-mcp returns carries &lt;code&gt;"start_line": 93&lt;/code&gt;, &lt;code&gt;"end_line": 135&lt;/code&gt; and &lt;code&gt;"lines": 43&lt;/code&gt;, and the models read the last one as the line number. It happened again with &lt;code&gt;LemonSqueezyClient&lt;/code&gt; (client.ts:129 for a class on line 59 that is 129 lines long), and haiku did the same with &lt;code&gt;CursorsController&lt;/code&gt;. My first note blamed "line numbers from the graph" for that client.ts:129; this is where it came from. Sonnet with grep got all three right. cbm-lean now passes the field on as &lt;code&gt;line_count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Holes in the graph, trusted. codebase-memory-mcp 0.9.0 records no call edge for a bare call to a function imported with &lt;code&gt;from x import y&lt;/code&gt;. Asked what &lt;code&gt;recover_password&lt;/code&gt; calls, the graph arm answered "only get_user_by_email" in all five runs, haiku and sonnet alike, straight from the graph and without a single grep. The function also calls &lt;code&gt;generate_password_reset_token&lt;/code&gt;, &lt;code&gt;generate_reset_password_email&lt;/code&gt; and &lt;code&gt;send_email&lt;/code&gt;, and the grep arm found all four every time.&lt;/li&gt;
&lt;li&gt;A missing dash. Copilot's grep tool shows line numbers only when it gets &lt;code&gt;"-n": true&lt;/code&gt;, and its view tool returns a file without them. Sonnet passed &lt;code&gt;-n&lt;/code&gt; in 508 of its 571 grep calls and opus in all 53. Haiku passed &lt;code&gt;n&lt;/code&gt;, without the dash, 920 times and &lt;code&gt;-n&lt;/code&gt; 22 times out of 1,949. The tool ignores the unknown key, so haiku got no numbers, counted lines in the viewed file and missed by a few: for an error raised on line 112 it answered 105, 107, 108 and 113. All but one of haiku's 79 wrong answers to exact questions are a wrong line number.&lt;/li&gt;
&lt;li&gt;Long searches that still fail. On the call chain from &lt;code&gt;kubeadm token create&lt;/code&gt; down to &lt;code&gt;encodeTokenSecretData&lt;/code&gt;, haiku with grep took 18 to 22 calls and got the path wrong all three times. It was wrong with the graph too. Sonnet was right in both arms.&lt;/li&gt;
&lt;li&gt;Haiku tried to call bash three times in the grep arm. Copilot refused, since the tool does not exist there, so no run used a tool outside its arm.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changed since the first note
&lt;/h2&gt;

&lt;p&gt;The first note said not to expect token savings from a graph with Claude models on repositories up to 25k nodes. That still holds at those sizes. It missed what happens above them: on kubernetes and vscode the smaller models gain, and the smaller the model, the more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell a team wiring a graph into an agent
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measure on your largest repository with your cheapest model first. That is where a graph earns its place.&lt;/li&gt;
&lt;li&gt;With a strong model on a small or medium repository, leave it out. You pay for its schemas on every call and get nothing back.&lt;/li&gt;
&lt;li&gt;Keep routing exact questions to grep. The graph never won them.&lt;/li&gt;
&lt;li&gt;Do not let the model take line numbers or "this is the complete list" from graph output on trust. Drop or rename fields like &lt;code&gt;lines&lt;/code&gt; before they reach the model.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;One agent harness (Copilot CLI) and one graph (codebase-memory-mcp 0.9.0 behind my wrapper). Claude Code, another graph or a newer version could give other numbers.&lt;/li&gt;
&lt;li&gt;Eight questions per cell on the small and medium repositories. With 13 tests under the Holm correction, a cell of eight cannot reach significance even if the graph wins every question, so only the large-repository haiku result is significant.&lt;/li&gt;
&lt;li&gt;Opus answered twelve questions, once each.&lt;/li&gt;
&lt;li&gt;tokenEquiv leaves output tokens out. Counting them would widen haiku's gap on the large repositories: its grep answers there produced a median of 7.0k output tokens against 0.9k with the graph.&lt;/li&gt;
&lt;li&gt;One haiku run failed before Copilot opened a session and is left out. Three sonnet runs failed to fetch Copilot's model list before any model call and were run again. Both are recorded in the plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce
&lt;/h2&gt;

&lt;p&gt;Everything is in &lt;a href="https://github.com/nefayran/code-graph-vs-grep" rel="noopener noreferrer"&gt;code-graph-vs-grep&lt;/a&gt;: the plan written before the runs, the questions with the evidence for every answer key, the driver, all 664 runs with their answers, and the analysis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node bench/run-copilot.mjs &lt;span class="nt"&gt;--model&lt;/span&gt; claude-haiku-4.5 &lt;span class="nt"&gt;--reps&lt;/span&gt; 3 &lt;span class="nt"&gt;--out&lt;/span&gt; results/v2/claude-haiku-4.5.json
python3 bench/stats-v2.py results/v2/&lt;span class="k"&gt;*&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ai</category>
      <category>mcp</category>
      <category>llm</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>Japan Life Hub: Net Salary Calculator &amp; Housing Guide</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Thu, 04 Dec 2025 00:00:00 +0000</pubDate>
      <link>https://dev.to/nefayran/japan-life-hub-net-salary-calculator-housing-guide-2f95</link>
      <guid>https://dev.to/nefayran/japan-life-hub-net-salary-calculator-housing-guide-2f95</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fdyrcnuhxepgos9zk2c8g.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.amazonaws.com%2Fuploads%2Farticles%2Fdyrcnuhxepgos9zk2c8g.png" alt="Japan Life Hub: Net Salary Calculator &amp;amp; Housing Guide" width="800" height="754"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Just shipped two new features for &lt;a href="https://japan.allkeep.app" rel="noopener noreferrer"&gt;Japan Life Hub&lt;/a&gt; — a Net Salary Calculator with 2025 tax rates and a comprehensive Housing Guide. Both available in English, Japanese, Russian, and Hindi.&lt;/p&gt;

&lt;p&gt;This article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why Year 1 vs Year 2 salary matters&lt;/li&gt;
&lt;li&gt;2025 Japanese tax rates and deductions&lt;/li&gt;
&lt;li&gt;Apartment rental costs breakdown&lt;/li&gt;
&lt;li&gt;Key terminology for foreigners&lt;/li&gt;
&lt;li&gt;Lessons learned building multilingual tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Year 1 Salary Trap
&lt;/h2&gt;

&lt;p&gt;When I moved to Japan, my first paycheck was surprisingly high. Then Year 2 hit.&lt;/p&gt;

&lt;p&gt;The catch: Japan's &lt;strong&gt;resident tax&lt;/strong&gt; (住民税 &lt;em&gt;juuminzei&lt;/em&gt;) is calculated on previous year's income and collected the following year. Your first year in Japan? Zero resident tax. Year 2? Roughly 10% of your gross salary disappears.&lt;/p&gt;

&lt;p&gt;Nobody told me this. I built the calculator I wish I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Net Salary Calculator
&lt;/h2&gt;

&lt;p&gt;The calculator shows both Year 1 and Year 2 take-home pay side by side.&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.amazonaws.com%2Fuploads%2Farticles%2Fdyrcnuhxepgos9zk2c8g.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.amazonaws.com%2Fuploads%2Farticles%2Fdyrcnuhxepgos9zk2c8g.png" alt="Net Salary Calculator Year 1 vs Year 2" width="800" height="754"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2025 Tax Rates
&lt;/h3&gt;

&lt;p&gt;Spent a day researching current rates from official sources. Here's what's deducted from your salary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Social Insurance (Employee Share):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health Insurance&lt;/td&gt;
&lt;td&gt;4.955%&lt;/td&gt;
&lt;td&gt;Tokyo rate, March 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term Care&lt;/td&gt;
&lt;td&gt;0.795%&lt;/td&gt;
&lt;td&gt;Ages 40-64 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pension&lt;/td&gt;
&lt;td&gt;9.15%&lt;/td&gt;
&lt;td&gt;Capped at ¥650k/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Employment Insurance&lt;/td&gt;
&lt;td&gt;0.55%&lt;/td&gt;
&lt;td&gt;April 2025 update&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Income Tax:&lt;/strong&gt; Progressive 5-45% across 7 brackets, plus 2.1% reconstruction surcharge until 2037.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resident Tax:&lt;/strong&gt; ~10% (6% prefectural + 4% municipal), delayed one year.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calculator Features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Employment type toggle:&lt;/strong&gt; Employee vs Self-employed (different deductions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age selector:&lt;/strong&gt; Long-term care insurance kicks in at 40&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependents &amp;amp; spouse:&lt;/strong&gt; Affects deductions for employees&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual breakdown bar:&lt;/strong&gt; See where your money goes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Salary presets:&lt;/strong&gt; Junior ¥3M, Mid ¥5M, Senior ¥7M, Manager ¥10M&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share button:&lt;/strong&gt; Copy summary to clipboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "What You Get Back" section explains what you're paying for — 70% healthcare coverage, pension from 65, unemployment benefits. Japanese social insurance is actually good value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Housing Guide
&lt;/h2&gt;

&lt;p&gt;Finding an apartment in Japan as a foreigner is confusing. Unique fees, guarantor requirements, Japanese-only listings.&lt;/p&gt;

&lt;p&gt;The guide covers the entire process in 8 sections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Terminology
&lt;/h3&gt;

&lt;p&gt;Japanese rental vocabulary that confused me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Japanese&lt;/th&gt;
&lt;th&gt;Romaji&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;敷金&lt;/td&gt;
&lt;td&gt;&lt;em&gt;shikikin&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Deposit, 1-2 months, refundable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;礼金&lt;/td&gt;
&lt;td&gt;&lt;em&gt;reikin&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;"Key money" gift to landlord, 0-2 months, gone forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;管理費&lt;/td&gt;
&lt;td&gt;&lt;em&gt;kanrihi&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Monthly maintenance fee, ¥3,000-15,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;更新料&lt;/td&gt;
&lt;td&gt;&lt;em&gt;koushinryo&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Renewal fee, ~1 month every 2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;保証会社&lt;/td&gt;
&lt;td&gt;&lt;em&gt;hoshougaisha&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Guarantor company, 30-100% of 1 month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Good news: less than 50% of renters now pay key money. Look for &lt;strong&gt;礼金なし&lt;/strong&gt; (&lt;em&gt;reikin nashi&lt;/em&gt;) listings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initial Costs Example
&lt;/h3&gt;

&lt;p&gt;For ¥80,000/month rent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;Refundable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First month rent&lt;/td&gt;
&lt;td&gt;¥80,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deposit&lt;/td&gt;
&lt;td&gt;¥80,000&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key money&lt;/td&gt;
&lt;td&gt;¥80,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agency fee&lt;/td&gt;
&lt;td&gt;¥88,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guarantor&lt;/td&gt;
&lt;td&gt;¥40,000-80,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insurance&lt;/td&gt;
&lt;td&gt;¥15,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock change&lt;/td&gt;
&lt;td&gt;¥15,000&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;¥398,000-438,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 5-6x monthly rent upfront. Budget accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where to Search
&lt;/h3&gt;

&lt;p&gt;The Housing Guide covers all major platforms — from Japanese-only sites like SUUMO with the largest listings, to English-friendly options like Real Estate Japan and GaijinPot, plus government UR Housing with no key money or guarantor requirements.&lt;/p&gt;

&lt;p&gt;Pro tip: UR Housing has no reikin, no guarantor requirement, no renewal fees. Limited availability but worth checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timing Matters
&lt;/h3&gt;

&lt;p&gt;Avoid March-April. Everyone moves — new graduates, company transfers. Prices spike, competition fierce.&lt;/p&gt;

&lt;p&gt;Best time: May-February. More negotiation room, landlords eager to fill vacant units.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Japan Life Hub now has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initial Costs Calculator&lt;/li&gt;
&lt;li&gt;Net Salary Calculator&lt;/li&gt;
&lt;li&gt;Moving to Japan Guide&lt;/li&gt;
&lt;li&gt;Housing Guide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coming soon: Monthly Budget Planner, City Comparison Tool, First Year Timeline.&lt;/p&gt;

&lt;p&gt;The goal is one place for everything you need to know about living in Japan — practical tools, not generic advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://japan.allkeep.app/en/tools/net-salary" rel="noopener noreferrer"&gt;Net Salary Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://japan.allkeep.app/en/guides/housing" rel="noopener noreferrer"&gt;Housing Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://japan.allkeep.app/en/tools" rel="noopener noreferrer"&gt;All Tools&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built with Next.js, TypeScript, Tailwind CSS. All calculations run client-side, no data collected.&lt;/p&gt;

&lt;p&gt;If you're moving to Japan or know someone who is — share the link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://allkeep.org/en/blog/japan-life-hub-net-salary-housing-guide" rel="noopener noreferrer"&gt;Read on AllKeep →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>japan</category>
      <category>salarycalculator</category>
      <category>housing</category>
      <category>relocation</category>
    </item>
    <item>
      <title>Build a Chrome Extension with React, TypeScript and Vite</title>
      <dc:creator>Rodion Kazennov</dc:creator>
      <pubDate>Wed, 03 Dec 2025 12:13:19 +0000</pubDate>
      <link>https://dev.to/nefayran/build-a-chrome-extension-with-react-typescript-and-vite-31ho</link>
      <guid>https://dev.to/nefayran/build-a-chrome-extension-with-react-typescript-and-vite-31ho</guid>
      <description>&lt;p&gt;This article walks through building a Chrome extension from scratch using React, TypeScript and Vite. We'll build a Freelance Rate Calculator that helps developers check their market rate.&lt;/p&gt;

&lt;p&gt;This article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup React + Vite + TypeScript project for Chrome Extension&lt;/li&gt;
&lt;li&gt;Configure manifest.json for Manifest V3&lt;/li&gt;
&lt;li&gt;Build calculator UI with Tailwind CSS&lt;/li&gt;
&lt;li&gt;Load and test extension locally&lt;/li&gt;
&lt;li&gt;Submit to Chrome Web Store&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setup Project
&lt;/h2&gt;

&lt;p&gt;Create new Vite project with React and TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm create vite@latest rate-calculator -- --template react-ts
cd rate-calculator
npm install

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Tailwind CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure &lt;code&gt;tailwind.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Tailwind directives to &lt;code&gt;src/index.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@tailwind base;
@tailwind components;
@tailwind utilities;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Lucide icons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install lucide-react

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Add Manifest V3
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;public/manifest.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "manifest_version": 3,
  "name": "Freelance Rate Calculator",
  "version": "1.0.0",
  "description": "Calculate your freelance rate based on tech stack, experience, and location",
  "action": {
    "default_popup": "index.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;public/icons/&lt;/code&gt; folder and add extension icons in 16x16, 48x48, and 128x128 sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Rate Data
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;src/data/rates.ts&lt;/code&gt; with tech stacks, experience levels, and locations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export interface TechStack {
  id: string;
  name: string;
  baseRate: number;
  demand: "high" | "medium" | "low";
}

export interface ExperienceLevel {
  id: string;
  name: string;
  years: string;
  multiplier: number;
}

export interface Location {
  id: string;
  name: string;
  multiplier: number;
}

export const techStacks: TechStack[] = [
  { id: "genai", name: "GenAI / LLM", baseRate: 175, demand: "high" },
  { id: "rust", name: "Rust", baseRate: 135, demand: "high" },
  { id: "react", name: "React", baseRate: 90, demand: "high" },
  { id: "typescript", name: "TypeScript", baseRate: 92, demand: "high" },
  { id: "nodejs", name: "Node.js", baseRate: 85, demand: "high" },
  { id: "python", name: "Python", baseRate: 90, demand: "high" },
  // add more stacks...
];

export const experienceLevels: ExperienceLevel[] = [
  { id: "junior", name: "Junior", years: "0-2 years", multiplier: 0.55 },
  { id: "mid", name: "Mid-level", years: "3-5 years", multiplier: 1.0 },
  { id: "senior", name: "Senior", years: "5-8 years", multiplier: 1.45 },
  { id: "lead", name: "Lead / Architect", years: "8+ years", multiplier: 1.85 },
];

export const locations: Location[] = [
  { id: "usa", name: "United States", multiplier: 1.0 },
  { id: "uk", name: "United Kingdom", multiplier: 0.85 },
  { id: "germany", name: "Germany", multiplier: 0.82 },
  { id: "japan", name: "Japan", multiplier: 0.62 },
  { id: "india", name: "India", multiplier: 0.32 },
  // add more countries...
];

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rate calculation formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const getMarketRate = (
  stack: TechStack,
  exp: ExperienceLevel,
  loc: Location
) =&amp;gt; {
  const base = stack.baseRate * exp.multiplier * loc.multiplier;
  return {
    low: Math.round(base * 0.85),
    mid: Math.round(base),
    high: Math.round(base * 1.15),
  };
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example: Senior React developer in Germany = &lt;code&gt;90 * 1.45 * 0.82 = $107/hr&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Calculator UI
&lt;/h2&gt;

&lt;p&gt;Update &lt;code&gt;src/App.tsx&lt;/code&gt; with calculator form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { useState } from "react";
import { techStacks, experienceLevels, locations } from "./data/rates";
import type { TechStack, ExperienceLevel, Location } from "./data/rates";

function App() {
  const [stack, setStack] = useState&amp;lt;TechStack | null&amp;gt;(null);
  const [experience, setExperience] = useState&amp;lt;ExperienceLevel | null&amp;gt;(null);
  const [location, setLocation] = useState&amp;lt;Location | null&amp;gt;(null);
  const [myRate, setMyRate] = useState("");
  const [showResult, setShowResult] = useState(false);

  const getMarketRate = () =&amp;gt; {
    if (!stack || !experience || !location) return null;
    const base = stack.baseRate * experience.multiplier * location.multiplier;
    return {
      low: Math.round(base * 0.85),
      mid: Math.round(base),
      high: Math.round(base * 1.15),
    };
  };

  const handleCheck = () =&amp;gt; {
    if (stack &amp;amp;&amp;amp; experience &amp;amp;&amp;amp; location &amp;amp;&amp;amp; myRate) {
      setShowResult(true);
    }
  };

  // render form or results based on showResult state
  return (
    &amp;lt;div className="w-[400px] h-[600px] p-4 bg-gray-900 text-white"&amp;gt;
      {/* form inputs and results */}
    &amp;lt;/div&amp;gt;
  );
}

export default App;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set fixed popup dimensions in &lt;code&gt;src/index.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;body {
  width: 400px;
  height: 600px;
  margin: 0;
  overflow: hidden;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Input form with dropdowns for stack, experience, location:&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.amazonaws.com%2Fuploads%2Farticles%2Fny6qas5a1kgvk0yy0o2i.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.amazonaws.com%2Fuploads%2Farticles%2Fny6qas5a1kgvk0yy0o2i.png" alt="Rate Calculator Input Form" width="398" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Results screen showing market rate comparison:&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.amazonaws.com%2Fuploads%2Farticles%2Fj4acaul3thd0c263rm67.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.amazonaws.com%2Fuploads%2Farticles%2Fj4acaul3thd0c263rm67.png" alt="Rate Calculator Results" width="393" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Extension
&lt;/h2&gt;

&lt;p&gt;Update &lt;code&gt;vite.config.ts&lt;/code&gt; for extension build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: "dist",
    rollupOptions: {
      input: "index.html",
    },
  },
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run build

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;dist/&lt;/code&gt; folder with your extension files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Load Extension Locally
&lt;/h2&gt;

&lt;p&gt;Open Chrome and navigate to &lt;code&gt;chrome://extensions&lt;/code&gt;. Enable "Developer mode" in top right corner.&lt;/p&gt;

&lt;p&gt;Click "Load unpacked" and select the &lt;code&gt;dist/&lt;/code&gt; folder.&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.amazonaws.com%2Fuploads%2Farticles%2Fat60l8nx29x5kktwdb4x.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.amazonaws.com%2Fuploads%2Farticles%2Fat60l8nx29x5kktwdb4x.png" alt="Extension loaded in Chrome" width="454" height="239"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the extension icon in toolbar to test the popup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submit to Chrome Web Store
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://chrome.google.com/webstore/devconsole" rel="noopener noreferrer"&gt;Chrome Web Store Developer Dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;First time requires $5 one-time developer fee.&lt;/p&gt;

&lt;p&gt;Prepare assets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extension icon: 128x128 PNG&lt;/li&gt;
&lt;li&gt;Screenshot: 1280x800 or 640x400&lt;/li&gt;
&lt;li&gt;Promotional tile: 440x280&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create ZIP of your &lt;code&gt;dist/&lt;/code&gt; folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd dist &amp;amp;&amp;amp; zip -r ../extension.zip . &amp;amp;&amp;amp; cd ..

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload ZIP, fill in description, add screenshots, submit for review.&lt;/p&gt;

&lt;p&gt;Review takes 1-3 days. Common rejection reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keyword stuffing in description&lt;/li&gt;
&lt;li&gt;Missing privacy policy (if using permissions)&lt;/li&gt;
&lt;li&gt;Incorrect screenshots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After approval:&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.amazonaws.com%2Fuploads%2Farticles%2Fq6a83shveo5wk2zcqud3.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.amazonaws.com%2Fuploads%2Farticles%2Fq6a83shveo5wk2zcqud3.png" alt="Extension published in Chrome Web Store" width="800" height="762"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;That's all. We built a Chrome extension with React, TypeScript and Vite, tested it locally, and published to Chrome Web Store.&lt;/p&gt;

&lt;p&gt;Final stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React 18&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Vite&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;Manifest V3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live extension: &lt;a href="https://chromewebstore.google.com/detail/freelance-rate-calculator/ofmocfkagkdpbkapmjmobbnomjkiphlf" rel="noopener noreferrer"&gt;Freelance Rate Calculator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you liked the article, follow me on &lt;a href="https://twitter.com/Nefayran" rel="noopener noreferrer"&gt;Twitter @Nefayran&lt;/a&gt; for more #buildinpublic content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://allkeep.org/en/blog/build-chrome-extension-react-typescript-vite" rel="noopener noreferrer"&gt;Read on AllKeep →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>freelance</category>
    </item>
  </channel>
</rss>
