В Linux память задачи можно ограничить через ulimit -v, systemd-run -p MemoryMax= или cgroup, и разросшаяся задача падает сама, а не тянет за собой всю машину. В macOS нет ни systemd, ни cgroups, а 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 argumentresource.setrlimit(resource.RLIMIT_DATA, ...) в Python бросает ValueError, а в той оболочке, где ulimit -m не сработал, скрипт затем занял и записал 3 ГиБ. Вместо этого macOS сжимает память и пишет её в своп. Модель, которая не помещается, не падает: она замедляется, и вместе с ней замедляется всё остальное на Mac.
Поэтому и вопросы другие: есть ли место до старта задачи и растёт ли своп, пока она идёт?
Следить за свопом, пока идёт задача
sysctl vm.swapusage # compare "used" before and after a run
sysctl kern.memorystatus_level # system-wide free memory, in percentFootprint процесса, то есть число, которое показывает Мониторинг системы, не учитывает страницы, которые уже ушли в своп. В одном прогоне Qwen-Image-2.1 с model CPU offload из diffusers на моём M5 Pro с 48 ГБ footprint ни разу не поднялся выше 18,5 ГБ, а своп за это время вырос на 9,6 ГБ. График одного footprint посчитал бы, что прогон поместился.
Остановить задачу до свопа
В stageload, небольшую библиотеку, которую я написал, чтобы загружать модели по одной стадии за раз, входит для этого сторож (pip install stageload):
stageload guard --wait-free 40 --swap-budget 8G -- python generate.pyОн ждёт, пока kern.memorystatus_level не станет не меньше 40 %, и только потом запускает команду. Если своп вырастет больше чем на 8 ГБ относительно того, что было занято на старте, или свободной памяти два замера подряд будет меньше 10 %, он посылает SIGTERM группе процессов команды, а через пять секунд SIGKILL всему, что от неё осталось. Root ему не нужен, и останавливает он только саму команду и её группу процессов. Код выхода 3 значит, что команду остановил сторож, а 4, что он так и не нашёл места для запуска; в остальных случаях возвращается код выхода самой команды.
Так и закончился прогон Qwen-Image в режиме eager: он прошёл все 20 шагов, декодирование VAE подняло footprint до 43,6 ГБ, своп вырос на 8 ГБ, и сторож остановил прогон раньше, чем Mac встал.
Две тяжёлые задачи сразу
Сторож решает в одиночку. Два сторожа, которые нашли место в один и тот же момент, запускают обе задачи, а каждая задача рассчитана на Mac, где больше ничего не идёт. 5 октября один мой прогон стартовал в щели между двумя видеозадачами другого скрипта, и следующая видеозадача за 20 секунд вывела своп за его предел.
stageload run ставит задачи в одну очередь на всю машину. Флаги у него те же, что у сторожа. Здесь две задачи держат 3 и 2 ГиБ и запущены из двух терминалов:
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: ожидание, время работы, пиковый footprint и своп. - Движки, запущенные мимо очереди, вписываются в
~/.stageload/busy.txt, по одному шаблону на строку. Каждый прогон в полосеramждёт, пока ни одного из них не будет видно 20 секунд.
Ограничения
- Сторож и измеритель памяти читают счётчики macOS (
libproc,sysctl), поэтому работают только на Mac. - Сторож останавливает задачу, но не делает её меньше. Со стороны модели Qwen-Image-2.1 на том же Mac опустила с 43,6 ГБ до 19,0 ГБ загрузка по одной модели на стадию: Как запустить Qwen-Image-2.1 на Mac с 48 ГБ без свопа.
- Для страховки на всю машину есть macos-oom-guard: он работает от root и убивает самый большой процесс раньше, чем случится паника ядра. Их можно запускать вместе.