← ラボに戻る

macOS では ulimit でメモリを制限できない:スワップする前にジョブを止める

apple-siliconmemoryguide

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 argument

Python の 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

メモリトレースから見た二つのジョブのフットプリントの推移:images が 3 GB を 10 s 確保している間、upscale はキューで待ち、その後 upscale が 2 GB を 4 s 確保する

共有の Mac で効いた細かい点:

制限