Linux なら、ジョブのメモリは ulimit -v、systemd-run -p MemoryMax=、あるいは cgroup で上限を決められ、大きくなりすぎたジョブはマシンを道連れにせず自分だけ失敗する。macOS には systemd も cgroup もなく、ulimit は拒否する。macOS 26.5.2 ではこうなる:
$ /bin/bash -c 'ulimit -v 1048576'
/bin/bash: line 0: ulimit: virtual memory: cannot modify limit: Invalid argument
$ /bin/bash -c 'ulimit -m 1048576'
/bin/bash: line 0: ulimit: max memory size: cannot modify limit: Invalid argumentPython の resource.setrlimit(resource.RLIMIT_DATA, ...) は ValueError を出し、ulimit -m が失敗したシェルでは、スクリプトがそのまま 3 GiB を確保して書き込んだ。macOS が代わりにするのは、メモリを圧縮してスワップに書き出すことだ。収まらないモデルは失敗しない。遅くなり、Mac の上の他のものもすべて遅くなる。
だから問うべきことが変わる。ジョブを始める前に空きはあるか、そして実行中にスワップが増えていないか。
ジョブの実行中にスワップを見る
sysctl vm.swapusage # compare "used" before and after a run
sysctl kern.memorystatus_level # system-wide free memory, in percentプロセスのフットプリント、つまりアクティビティモニタが表示する数値は、すでにスワップに出たページを数えない。48 GB の M5 Pro で diffusers の model CPU offload を使った Qwen-Image-2.1 の一回の実行 では、フットプリントは一度も 18.5 GB を超えなかったが、スワップは 9.6 GB 増えた。フットプリントだけのグラフなら、この実行は収まったと判断していただろう。
スワップする前にジョブを止める
ステージごとにモデルを読み込むために私が書いた小さなライブラリ stageload には、そのためのガードが入っている(pip install stageload):
stageload guard --wait-free 40 --swap-budget 8G -- python generate.pyガードは kern.memorystatus_level が 40 % 以上になるまで待ってから、コマンドを始める。開始時に使われていた量よりスワップが 8 GB 以上増えるか、空きメモリが二回続けて 10 % を下回ると、コマンドのプロセスグループに SIGTERM を送り、五秒後に残ったものへ SIGKILL を送る。root は要らず、止めるのはコマンドとそのプロセスグループだけだ。終了コード 3 はガードがコマンドを止めたこと、4 は始める空きが見つからなかったことを表し、それ以外はコマンド自身の終了コードが返る。
eager の Qwen-Image の実行はこうして終わった。20 ステップをすべて終え、VAE のデコードでフットプリントが 43.6 GB になり、スワップが 8 GB 増えたところで、Mac が固まる前にガードが止めた。
重いジョブを二つ同時に
ガードはそれぞれ単独で判断する。同じ瞬間に空きを見つけた二つのガードはどちらもジョブを始めてしまうが、どのジョブも、他に何も動いていない Mac を前提にした大きさだ。10月5日には、私の実行の一つが別のスクリプトの二つの動画ジョブの合間に始まり、次の動画ジョブが 20 秒以内にスワップの上限を超えた。
stageload run はジョブをマシン全体で一つのキューに並べる。フラグはガードと同じだ。ここでは二つのターミナルから、3 GiB と 2 GiB を確保する二つのジョブを始めている:
stageload run --who images -- python3 alloc.py 3 10 # holds 3 GiB for 10 s
stageload run --who upscale -- python3 alloc.py 2 4 # holds 2 GiB for 4 s$ 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二つ目のジョブは一つ目が終わると始まり、その後はいつもどおりガードのチェックが走る:
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$ 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
共有の Mac で効いた細かい点:
- ジョブはレーンに入る。既定は
ramだ。レーン同士は互いを待たないので、リモート API の上限待ちになるジョブは専用のレーン(--lane api --wait-free 0)に入れれば、メモリを必要とするジョブを止めずに済む。 - 順番はオーナーの間で公平に回る。最後に始めてから最も時間が経ったオーナーが次になるので、ジョブを二十個並べたスクリプトもレーンを占有せず、他と交互に進む。オーナーは
--owner、なければ--whoの最初のダッシュより前の部分だ。 - 各実行は、コマンドとその配下のすべてのプロセスのメモリトレースを書き、
~/.stageload/history.jsonlに待ち時間、実行時間、ピークのフットプリントとスワップを一行で追記する。 - キューの外で始めたエンジンは
~/.stageload/busy.txtに一行に一パターンずつ書く。ramレーンの実行はすべて、そのどれもが 20 秒間見えなくなるまで待つ。
制限
- ガードとメーターは macOS のカウンター(
libproc、sysctl)を読むので、Mac でしか動かない。 - ガードはジョブを止めるだけで、ジョブを小さくはしない。モデルの側では、ステージごとにモデルを一つずつ読み込むことで、同じ Mac で Qwen-Image-2.1 が 43.6 GB から 19.0 GB になった:48 GB の Mac で Qwen-Image-2.1 をスワップなしで動かす方法。
- マシン全体の安全網としては、macos-oom-guard が root で動き、カーネルパニックの前に最大のプロセスを止める。二つは一緒に動かせる。