[控场AI]
· 8 分钟阅读· 4,279 字

macOS 内存限制无效:用 stageload 在交换前终止任务

macOS 内存限制无效:用 stageload 在交换前终止任务

macOS 无法用 ulimit 限制内存,需靠交换空间监控与守护器在卡死前终止任务。

macOS 既没有 systemd 也没有 cgroups,且 ulimit 直接拒绝设置内存上限,resource.setrlimit 也会抛错。系统面对内存压力时选择压缩内存并写入交换空间,于是放不下的任务不会失败,只会让整台 Mac 一起变慢。活动监视器显示的进程足迹不含已交换的页,可能严重低估内存使用,因此应改用 sysctl vm.swapusage 和 kern.memorystatus_level 监控。stageload guard 可在空闲内存不足或交换空间增长超预算时向进程组发送 SIGTERM/SIGKILL,在机器卡死前终止任务;stageload run 则把多个重任务纳入同一队列,按所有者公平调度、分通道运行,避免同时启动耗尽内存。根本的解法仍是逐阶段加载模型,把峰值内存从 43.6 GB 降到 19.0 GB。

在 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 argument

Python 中的 resource.setrlimit(resource.RLIMIT_DATA, ...) 会抛出 ValueError。更糟的是,在 ulimit -m 设置失败的那个 shell 里,一个脚本继续分配并触碰了 3 GiB 内存。

macOS 的做法是压缩内存并写入交换空间(swap)。一个放不下的模型不会失败:它变慢,同时整台 Mac 上的一切都变慢。

所以值得问的问题变了:任务启动前有空间吗?运行中交换空间在增长吗?

运行中检查交换空间

sysctl vm.swapusage             # 比较运行前后的 "used" 值
sysctl kern.memorystatus_level  # 系统级空闲内存百分比

活动监视器显示的进程足迹(footprint)不包含已经进入交换空间的页。在一次 Qwen-Image-2.1 使用 diffusers 模型 CPU offload 的运行中,48 GB M5 Pro 上的足迹从未超过 18.5 GB,而交换空间增长了 9.6 GB。只看足迹的图表会认为这次运行是放得下的。

rss source: ulimit doesn't cap memory on macOS: stop a job before it swaps

交换空间(swap)与内存压缩:macOS 没有将内存超限视为错误,而是通过两条机制消化压力。其一是压缩内存(compressed memory):内核把不常访问的页压缩后留在 RAM 中,解压时按需还原,代价是 CPU 开销;其二是交换空间(swap):当内存压力继续上升,页被写入磁盘上的交换文件,需要时再换回。两者都会让程序不失败、只变慢,且拖累同一台机器上的所有进程。sysctl vm.swapusage 报告的 used 是当前已占用交换空间(含加密压缩后的交换文件),启动前记录一次、运行中定期对比差值,就能判断任务是否正在把内存往磁盘上挤。kern.memorystatus_level 则是内核给出的系统级空闲内存百分比,数值越低表示内存越紧张,是比单看交换空间更即时的压力指标。

值得注意的是,交换空间本身不是失败信号,而是趋势信号:缓慢、稳定的小幅增长通常可接受,短时间内快速攀升则意味着任务正逼近机器容量极限,此时越早终止,机器被拖慢的时间越短。

在交换前终止任务

stageload 是一个为逐阶段加载模型而写的小型库,自带一个守护器(guard):

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 卡死前将它终止。

SIGTERM 与 SIGKILL 的分工:守护器先向命令的整个进程组发送 SIGTERM,这是一个可被捕获、可被处理的信号,程序有机会保存中间结果、释放资源并优雅退出。若五秒内进程仍未结束,再发送 SIGKILL——该信号无法被捕获或忽略,由内核强制终止,因此能保证收尾。向进程组而非单个 PID 发送很关键:一个训练或推理脚本常常会 fork 出数据加载、子进程或工作池,只杀主进程会留下占用内存的孤儿进程,导致限制形同虚设。

另一个细节是退出码的约定:普通 shell 命令被信号杀死时返回 128 加信号编号,而守护器有意使用自定义退出码——3 表示是守护器主动终止了命令(触发内存保护),4 表示启动前就没找到足够空闲空间;其余情况透传命令自身的退出码或信号结果。这样脚本和 CI 就能区分“任务自己失败”和“被内存守卫拦下”,从而决定是重试、缩小规模还是调整参数。守护器无需 root,也正是因为它只操作自己和子进程构成的进程组。

同时运行两个重任务

一个守护器独自做决定。两个守护器如果同时发现空间,就会同时启动各自的任务,而每个任务都是按空闲 Mac 来设定大小的。10 月 5 日,我的一次运行在另一个脚本两个视频任务的间隙启动,下一个视频任务在 20 秒内就把交换空间推过了预算。

stageload run 把任务放入同一台机器的队列。它接收守护器的参数。下面两个任务分别占用 3 GiB 和 2 GiB,从两个终端启动:

stageload run --who images -- python3 alloc.py 3 10    # 占用 3 GiB 持续 10 秒
stageload run --who upscale -- python3 alloc.py 2 4    # 占用 2 GiB 持续 4 秒
$ 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 上,有几个细节很重要:

  • 任务加入一个通道(lane),默认是 ram。通道之间不互相等待,所以等待远程 API 配额的任务可以放进自己的通道(--lane api --wait-free 0),不阻塞需要内存的任务。
  • 轮次在所有者(owner)之间公平。最久没启动的所有者下一个执行,因此一个排队二十个任务的脚本会与其他脚本轮流,而不会独占通道。所有者由 --owner 指定,或取 --who 中第一个连字符之前的部分。
  • 每次运行都会为命令及其下所有进程写入一条内存轨迹,并在 ~/.stageload/history.jsonl 中记录一行,包含等待时间、运行时间、峰值足迹和交换空间。
  • 在队列之外启动的引擎写入 ~/.stageload/busy.txt,每行一个模式。ram 通道中的每次运行都会等待,直到这些模式已有 20 秒未被观察到。

限制

  • 守护器和计量器读取的是 macOS 计数器(libproc、sysctl),因此只能在 Mac 上运行。
  • 守护器终止任务,但不会让任务变小。在模型侧,逐阶段加载单个模型正是让 Qwen-Image-2.1 在同一台 Mac 上从 43.6 GB 降到 19.0 GB 的原因:如何在 48 GB Mac 上无交换运行 Qwen-Image-2.1。
  • 如果需要覆盖整台机器的安全网,macos-oom-guard 以 root 运行,在内核 panic 前终止最大的进程。两者可以一起使用。
#macOS 内存限制#ulimit 无效#交换空间监控#stageload#内存守护器#macOS 内存管理#进程内存控制
分享:

相关推荐