PyTorch Mac内存假释放:.to("cpu")后进程足迹骤降之谜

Mac 统一内存下 MPS→CPU 搬移只是计量假象,判内存要靠 swap 而非 footprint。
文章从一段最小复现实验出发,揭示 Apple Silicon 上的反直觉现象:把 4 GiB 张量从 MPS 搬到 CPU 后,进程 footprint 和 MPS 驱动计数几乎同时归零,但数据仍在内存中,只有 CPU 逐页读取后 footprint 才回升。根因在于统一内存架构——CPU 与 GPU 共享同一池物理 RAM,所谓「移到 CPU」并未释放任何资源,变的只是操作系统计量方式。作者据此指出 diffusers 的 enable_model_cpu_offload() 在 Mac 上存在陷阱:跑 Qwen-Image-2.1 时 footprint 从未超 18.5 GB,但 swap 已增长至 9.6 GB 并触发内存守卫。正确做法是观察 swap 与系统空闲内存,或用 stageload 把模型参数迁至 meta 设备并清空 MPS 缓存,实现真正的内存归还。
在 Mac 上跑 PyTorch 的人可能会碰到一个反直觉现象:把一个 MPS 设备上的张量搬到 CPU 后,活动监视器里的内存占用几乎瞬间归零,MPS 驱动计数也同步下跌——但数据其实还在,内存根本没被释放。
这一现象由一篇 diffusers 讨论中的最小复现实验引出,实验用 4 GiB 张量模拟模型权重(torch 2.14.0,macOS 26.5.2,M5 Pro 48 GB)。代码通过 libproc 读取进程的 phys_footprint(也就是活动监视器 Memory 列显示的物理足迹),并同步记录 torch.mps.driver_allocated_memory()。为了给不熟悉底层 API 的读者一个直观印象,这里先看原作者放出的来源截图。

复现结果:数字不会说谎
三次运行得到完全一致的输出:
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
- 刚搬到 CPU 时,两个计数器都回到了接近零的位置,但
w仍然完整持有全部 4 GiB 数值。 - 只有 CPU 逐页读完这份拷贝后,footprint 才重新回到 4.2 GiB。
换句话说,如果在这两步之间采样内存,你会得到一份"内存已释放"的假象。作者在原实验中反复强调:拷贝一直都在,占用的内存也没有变少——变的只是操作系统计量它的方式。
硬件层面的根因
为什么 .to("cpu") 一开始没有真正释放任何东西?答案在硬件架构上。在 Apple Silicon 上,CPU 和 GPU 共享同一池物理内存(统一内存架构)。
这意味着:在 PC 上,把权重从 GPU 移到 CPU 会释放 VRAM 给下一个模型;而在 Mac 上,所谓"在 CPU 上"的权重其实就停在 GPU 也需要的那块统一 RAM 里,内存压力并没有降低。
对 PyTorch 这种跨设备操作来说,从 MPS 到 CPU 的拷贝只是在统一内存内搬了个位置,底层物理页可能被标记为可压缩或已换出,但在下一次触达前,进程足迹确实会显示为接近零。
phys_footprint 是什么:phys_footprint 是 macOS 通过 libproc 暴露的一项进程级内存指标,活动监视器 Memory 列显示的正是它。它并不等于进程占用的全部虚拟内存,而是对「实际驻留且计入系统内存压力的物理页」的估算,包含匿名映射、部分脏页与压缩页,但会扣除被换出到 swap 的部分。因此在统一内存架构下,当页面被标记为可压缩或已进入 swap 时,phys_footprint 会迅速下跌,即便张量对象依然有效。这也是为什么它会在 MPS→CPU 拷贝后短暂归零、又在 CPU 逐页读取时回升——页被重新触达才重新计入足迹。
统一内存架构(UMA):Apple Silicon 将 CPU、GPU 及其他协处理器接入同一块物理 RAM,通过高带宽的片上互连共享访问,不存在独立的 VRAM 池。这与传统 PC 的 dGPU 架构根本不同:后者有独立的显存,cudaMemcpy 之类的操作在 VRAM 与系统内存之间搬运数据,卸载后显存确实会被释放。在 UMA 上,「把张量移到 CPU」只是改变了访问路径与页的状态,物理内存仍在同一池子里,因此对缓解整体内存压力几乎没有帮助。MPS 的 driver_allocated_memory 计数反映的是 MPS 驱动当前向系统申领的额度,它与进程 phys_footprint 是两套独立账本,这也是原文两个数字会同步下跌却又不代表释放的原因。
对模型 CPU offload 意味着什么
diffusers 的 enable_model_cpu_offload() 策略正是基于"把模型留在 CPU,只在运行时移到 GPU"的思路。作者在同一台 Mac 上用 1024×1024 分辨率跑 Qwen-Image-2.1 做了实测(参考 trace 与 write-up),结果值得警惕:
- 进程 footprint 最高从未超过 18.5 GB,看起来 48 GB Mac 绰绰有余。
- 但去噪过程中 swap 增长了 3.5 GB,到 VAE 解码阶段已经涨了 9.6 GB。
- 最终内存守卫在写出图像前主动终止了这次运行。
作者点破关键:只画 footprint 曲线会告诉你"这次运行放得下",而实际上 swap 已经在悄悄膨胀。这与最开始的 4 GiB 复现实验正好形成呼应——footprint 可以在权重仍在内存时保持低位。
如何自查你的运行
作者给出的建议是:看 swap,不要只看 footprint。
sysctl vm.swapusage # 对比运行前后 "used" 字段
sysctl kern.memorystatus_level # 系统级空闲内存百分比
两个判断原则:
- 只要 swap 在任务运行期间增长,无论 footprint 多好看,都说明它放不下。
- 系统级空闲内存百分比能反映全局压力,单看进程内存容易误判。
作者为此写了一个小工具 stageload(pip install stageload),其中 MemoryMeter 每 0.5 秒记录一次上述两项指标,stageload guard 可以在 swap 超过预算时终止命令:
stageload guard --wait-free 40 --swap-budget 8G -- python generate.py
真正释放内存的做法
对于按顺序运行多个模型的 pipeline,作者的方案不是把模型停在 CPU,而是"阶段开始加载、阶段结束释放":
stageload的release(module)会把所有参数和缓冲区移到 PyTorch 的meta设备,然后触发垃圾回收并清空 MPS 缓存。meta上的张量没有任何存储,因此即使其他代码仍持有对该模块的引用,内存也能真正归还。
作者用这个思路加载 Qwen-Image-2.1:文本编码器(16.3 GB)和 transformer(13.3 GB)一次只加载一个。同一台 Mac 上峰值降到 19.0 GB,两次运行 swap 都没有增长。完整配置见其 "How to run Qwen-Image-2.1 on a 48 GB Mac without swap" 一文。
尚未验证的边界
作者也诚实列出了没有测量的部分:
- 示例运行期间整机空闲内存:这台 Mac 上其他任务会在运行之间带来数 GiB 的波动,拿不到干净数值。
del+torch.mps.empty_cache()与迁移到meta相比,释放是否同等彻底、速度如何——未做对比。- 其他 macOS / torch 版本,以及更大内存的 Mac——未覆盖。
这些遗留问题也提醒 iOS/macOS 开发者:在统一内存架构上做性能剖析,进程级 footprint 是一个靠不住的单点指标,swap 与系统级空闲内存必须同时纳入观测。作者这套复现 + 工具 + 边界声明的做法,为后续在 Mac 上做模型 offload 的实践者提供了一个可复用的观测基线。
meta 设备原理:PyTorch 的 meta 是一种「无实体」设备——张量只保留形状、dtype 和 stride 等元数据,不分配任何底层存储。把模块的 nn.Parameter 与 buffer 全部 .to('meta') 后,这些对象仍然存在、引用仍然有效,但对内存的占用归零。随后调用 gc.collect() 清掉中间引用,再用 torch.mps.empty_cache() 回收 MPS 缓存,才能真正把内存归还给系统。相比 del module,meta 方案的关键优势是不必破坏外部引用即可完成释放,适合在 pipeline 中把已用完的阶段安全「卸载」而不引发悬空引用错误。需要注意的是,迁移后的模块不能再前向计算,除非重新 .to(device) 加载参数。
背景补充
swap 与 memorystatus_level 的含义:sysctl vm.swapusage 报告的 used 字段是系统累计写入交换区的字节数,它增长意味着物理内存已被压满、内核开始把页换出到磁盘。kern.memorystatus_level 则是系统判定「空闲内存百分比」的指标,macOS 的内存守卫(jetsam)正是基于它决定是否终止进程。两者结合才能反映真实压力:单看进程 footprint 时,页被换出会让曲线显得轻盈,而 swap 增长暴露了实际的紧张程度。这也是作者建议在任务前后对比这两项指标、并以 swap 是否增长作为「放得下/放不下」硬判据的原因。
相关推荐

JetBrains孵化ThinkRail:基于Pi的智能体IDE,规格驱动开发新体验
JetBrains Innovation Hub孵化的开源智能体IDE ThinkRail,基于Pi构建,支持规格驱动开发、Git worktree并行工作区与Monaco编辑器交互,完全免费。本文详解其安装、工作流与生态整合。

主流世界之外:被忽视地区的安全与隐私困境
人本安全研究长期集中于西方,导致安全方案在主流世界失效。研究者Collins Minando基于肯尼亚移动贷款应用、网吧认证困境及九国本地专家研究,揭示社会经济约束如何重塑隐私实践,并提出移动货币隐私重设计与PETs部署路径。

Claude被曝可在正常图片中隐藏秘密信息
Reddit帖子称Claude Fable 5.1能在正常图片中隐藏秘密信息,本文梳理事件背景、图片隐写技术原理及潜在安全风险,并给出从传闻走向验证的测试路径建议。