冻住的屏幕:一台 macOS 虚拟机的显示管线,为何会被内存压力”饿死”

很久没有发博客了,主要是工作太忙,而且在公司做的内容有太多问题都是解决我司独有的,一时半会也不知道咋分享。

最近有一个很有趣的项目叫做 VirtualMacOniPad,这个项目解放了我的 iPad Pro M2,使得它能够跑起来 macOS 虚拟机。我马上让它跑了起来,并且拉上我的 Claude Code 让它带着 DeepSeek-V4-Pro 一起给这个项目改进一手。

借着以前黑苹果的经验,对 macOS 底层多少还是有些了解的。所以还差不多可以识别出来 AI 哪里没搞对。(这种大任务还是得用梁圣的 DeepSeek,蹬了一晚上才花了不到 10 块钱)

在攻克了前几个小问题之后,今晚我决定解决一下跑着跑着 UI 就跑死了这个大问题。顺便看看 macOS 的内存系统有多屎山hhh

(以下内容主要为 DeepSeek-V4-Pro 整理编写)

一句话结论:VirtualMacOniPad 里那台 macOS 虚拟机跑约 26 分钟后,屏幕冻结,但系统本身还活着(SSH 还能连、进程照常跑)。追查到底,这不是 host ↔ 虚拟机之间”帧投递”的问题,而是 guest(虚拟机内部)内存耗尽:显示管线需要”钉死”在内存里的 WIRED 页,而 free 页趋近于零、压缩器又塞满时,内核给不出这些页,WindowServer 停止产帧。本该兜底的 swap(交换空间)从头到尾没有出现——因为内核创建 swap 文件这件事懒到了极致,而这台机器的负载形态又恰好推不动它的触发线。最终方案:一个开机自动”诱出 swap”的菜单栏小工具(SwapPrime)作为预防手段。

项目 VirtualMacOniPad——在 iPad 上用提取出的 Apple Virtualization 框架跑 macOS 虚拟机
Issue #24 framebuffer 停摆
环境 host = iPadOS;guest = macOS 15.6.1(24G90),机型 VirtualMac2,1,内存 8GB
结论 host 侧无法治愈、只能容忍;真正修复方向是降低 guest 内存压力 + 开机诱出 swap 作为逃生阀缓解

💡 给不熟悉这块的读者:这篇文章会反复用到几个词,先混个脸熟——
host / guest:运行虚拟机的那台真实设备叫 host(这里是 iPad),虚拟机里装的系统叫 guest(macOS)。两个系统各有一套自己的内存管理。
framebuffer(帧缓冲):屏幕上正在显示的那一帧画面在内存里的存放处。屏幕要动,就得不断往里面写新帧。
WIRED 内存:被”钉死”、不能被换出/压缩的页面。设备驱动(显卡、显示)要用的内存基本都是 WIRED,因为它必须物理存在。
内存压缩器(compressor):macOS 在内存紧张时,不急着往磁盘换,而是先把可压缩的页面压缩放在内存里腾地方。它是”先压缩”的第一道缓冲。
swap(交换空间):把压缩器也塞不下的页面写到磁盘上的最后手段。有了 swap,内存再紧也总能腾出物理页。

全文把”虚拟机的虚拟 GPU”叫 paravirt GPU——guest 里并没有一块真实的显卡,图形命令和帧内容通过一个”翻译层”转发给 host,由 host 的真实 GPU(iPad 的)来渲染显示。

这个项目做的事听起来很离谱:从 Apple 的系统里提取出(dlopen 出来)Virtualization.framework——这是 Apple 提供给 Mac 用户跑 Linux/macOS 虚拟机的官方框架——然后跑在一个 iPad 上,让 macOS 桌面操作系统在一个 iPadOS 系统里以虚拟机的形式运行。guest 有 8GB 内存,跑着 macOS 15.6.1。

💡 paravirtualization(半虚拟化):虚拟化的”翻译层”知道自己是虚拟机、配合 guest 一起干活,而不是把真实硬件原样模拟一遍。这种”我跟你(host)共享我的一部分能力”的合作方式,是后面一切故事的底色。

项目本身很不错,然后我就碰上了一个 bug:#24跑约 26 分钟后画面冻结

1. 现象:屏幕冻住了,但系统还活着

  • 虚拟机运行约 26 分钟后,桌面画面冻结,不再更新;
  • 但 guest 仍然活着:SSH 能连上、系统负载正常、进程照常运行;
  • host 侧的 health monitor 持续打点,METAL_HEALTH 每 10 秒一条,其中 Metal commit 数冻结在 448209,纹丝不动。

这是”画面卡死但系统没死”的经典形态——问题几乎必然出在显示路径本身,而不是整个 guest 崩溃。

💡 帧是”推”过来的:这台虚拟机的帧由 guest 里的 WindowServer(macOS 的桌面合成器)主动产生、主动推给 host,host 只负责最后用 Metal 画到屏幕上。所以”host 收不到新帧”,几乎只有一种可能:guest 那边不产帧了

2. 第一轮:以为 host 帧投递链路挂了(修了,但没用)

第一时间想到”host 收帧的链路 hang 住了”,打了 eb55f14

  1. 给 XPC 帧投递日志限频(只打前 8 次、之后每 300 次一条);
  2. 把帧投递移到专用串行队列,让投递线程立即返回;
  3. 保留帧率控制消息,App 每 2 分钟检测到停摆就重发一次。

结果:无效。 而且 pvg-trace.log 给出了决定性证据——host 的 Metal commit 数冻结

[4355.129317]  METAL_HEALTH  commits=448209  completions=448209  errors=0
[6175.540639]  METAL_HEALTH  commits=448209  completions=448209  errors=0   ← 持续冻结

commit 冻结说明 host 的 Metal 渲染管线根本没有新帧进来。帧是 guest 主动推给 host 的,guest 停产了,host 端无论怎么 kick、重发什么控制消息都收不到新帧——往一条已经停产的流水线上反复发调度信号,当然没用

结论:停摆不在 host↔VM 交付层,而在 guest 内部。 调头查 guest。

3. 真相在 guest 里:内存被吃干抹净

进到 guest 内部观察,一幅内存告急的画面:

  • free 页掉到 ~90MB(总共 8GB);
  • 压缩器持有 ~6.4GB 内容,压成 ~2.6GB 物理页;
  • 三条大户:Edge ≈ 1229MB + VS Code ≈ 934MB + QQ ≈ 745MB,合计 ~2.9GB 可压缩内存。

macOS 的显示路径是 WindowServer → IOSurface → IOAccelerator,这条路径需要的是 WIRED(不可换出)内存

💡 为什么显示路径特别怕内存紧:显示管线每画一帧都要问内核要 WIRED 页(IOSurface——一块给 GPU 用的共享内存,被”钉死”不能压缩/换出)。内核给不出新 WIRED 页时,只有两个办法:从别处压缩出物理页,或者换出到磁盘。当压缩器已经背了 6.4GB、free 页趋近于零,而 swap 又不干活时,WIRED 分配就只能干等——帧分配停滞,没新帧

这就是 COMPRESSOR_NEEDS_TO_SWAP(”压缩器需要开始换出”)这个内核标志原本该出场的场景:内存告急时,本应换出到 swap 来续命。但它从头到尾都没触发。

于是故事进入最精彩的一章:为什么 swap 在这台 VM 里就是不来?

4. 为什么 swap 从不出场(这趟排查的重头戏)

4.1 先说结论的”逆反”:swap 根本没被禁用

一开始的直觉是”swap 没启用”,于是想尽办法启用。第一步就是手动跑 /sbin/dynamic_pager——结果刚 exec 就被秒杀。翻 crash report,报错很有意思:

Termination Reason: CODE SIGNING, code: 4, indicator: Launch Constraint Violation
responsible process: sshd-session
codeSigningTrustLevel: -1 (0xFFFFFFFF)

💡 AMFI 和 Launch Constraint:AMFI(Apple Mobile File Integrity)是 macOS 的代码签名/完整性守护层。这台 macOS 15(Sequoia)上加了一道叫 Launch Constraint(启动约束) 的硬化:某些敏感的 Apple 系统守护进程在签名里刻了”我只能在什么情况下被合法启动”,AMFI 在 exec 的瞬间检查——负责启动我的那个进程(responsible process)是谁、够不够格。不满足就直接 SIGKILL,连解释的机会都不给。 dynamic_pager 就是被这样保护的一员:它是 Apple 自己的 swap 管理二进制,只允许 boot 时的系统机制合法拉起;我这种从 SSH 会话里跑的,在 AMFI 眼里就是”越权启动”,codeSigningTrustLevel = -1 意味着压根没建立任何受信任的启动关系。

不服,换了个姿势:写一个自定义 LaunchDaemon,让 launchd 在 system 域直接 spawn dynamic_pager——结果 22:02 又多了一份一模一样的 crash report(CODE SIGNING / Launch Constraint Violation),照样被秒。

这就很说明问题了:连 launchd 直拉都过不了约束。 所以这道约束看的不是”是不是 launchd 在拉”,而是这个二进制只能出现在 boot 上下文里——SSH 会话不行、自定义 daemon 也不行,怎么绕都绕不过去。macOS 对自己这套 swap 子系统,防得是真的死。

不过反过来想,这也正好说明 dynamic_pager 在开机时是正常跑过的——它本来就是系统启动流程的一部分(日志里甚至能看到一条非致命的 AMFI 加速提示,说明它 exec 过、干过活)。它不是”坏了”,是”被保护起来了”。

而这条 AMFI 死路,走到头其实也是死路——因为后面(§4.2)会发现 dynamic_pager 根本不创建 swap 文件,它只是个一次性清理脚本。也就是说”手动把它拉起来”这个思路,从一开始就找错了目标。但这场”想拉起来却被 AMFI 拦下”的拉锯,倒是让我先确认了一件事:swap 子系统本身是活的,是被系统精心守护着的。

不死心,继续翻 boot 日志,却看到了关键的一批记录:launchd: swap enabled[enable-swap] Doing boot taskMaximum number of VM swap files: 100"vm_compressor_mode" is 4 —— swap 本来就没被禁用

逐项核实后确认 swap 子系统完全就绪vm.swap_enabled=1、压缩器模式是”带 swap 的压缩器”、/System/Volumes/VM/ 是真实独立的 APFS 卷(402GB 空闲、可写)、挂载时没有”禁 swap”的标志、boot 日志明写 VM Swap Subsystem is ON

所以问题根本不是”swap 没启用”——而是 swapfile0(swap 文件)从来没被创建vm.swapusage 永远是 total = 0.00M

4.2 谁创建 swapfile0?只有一个”懒线程”,而它只在 swapout 时醒

/sbin/dynamic_pager 这个二进制,名字听着像是”动态换页器”,其实是个一次性清理脚本:删掉过期的 swap* 文件、改目录属主、exit 0。它从不创建 swap 文件。

真正创建 swapfile 的是内核里的 vm_swapfile_create_threadvm_compressor_backing_store.c:797)→ vm_swap_create_file()vm_swapfile_open()(对 swapfile0O_CREAT|O_TRUNC)。而唤醒这个线程的全部三个点都藏在 vm_swap_put()(swapout 落盘)路径里(1787 / 1818 / 1982):

if (vm_swapfile_should_create(now) && !vm_swapfile_create_thread_running) {
    thread_wakeup((event_t) &vm_swapfile_create_needed);   /* 只在 swapout 时 */
}

没有 swapout → 线程永不醒 → swapfile0 永不创建。 swap 文件的创建是懒到极致的——完全依赖第一次真正换出。

4.3 一个反直觉的观测:首轮连”尝试创建”都没有(15 秒门闩)

翻 boot 日志,5 次启动里既没有 Failed to open swap file,也没有 low swap: failed to create swapfile。直觉上,create 线程首次启动应该尝试建文件、然后失败——但连失败日志都没有

原因在 vm_swapfile_should_create 的一个 15 秒延迟门限vm_swapfile_creation_delay_ns,vm_compressor_backing_store.c:781):

uint64_t vm_swapfile_last_failed_to_create_ts = 0;   /* 初始为 0 */
TUNABLE(..., vm_swapfile_creation_delay_ns, ..., 15 * NSEC_PER_SEC);

vm_swapfile_should_create(uint64_t now) {
    ...
    absolutetime_to_nanoseconds(now - vm_swapfile_last_failed_to_create_ts, &delta_failed_creation_ns);
    return (vm_num_swap_files < vm_num_swap_files_config) &&
           ... && (delta_failed_creation_ns > vm_swapfile_creation_delay_ns);
}

last_failed_to_create_ts 初值是 0,所以首次运行时的 delta = now(开机以来的时间)。这台 VM 从开机(19:35:53.482)到 swap 初始化(19:35:53.682)只过了 0.2 秒——now ≈ 0.2s < 15s → 首轮 should_create 直接返回 FALSE,create 线程在第一个 whilebreak,然后睡死在 assert_wait(&vm_swapfile_create_needed)它根本没尝试创建,所以没有任何失败日志。

之前一度怀疑是”boot 顺序竞态”(swap init 早于 /System/Volumes/VM 挂载 0.8 秒导致首次 open 失败)——其实这个竞态无关紧要,因为 15 秒门闩让首轮连尝试都没有。

4.4 决定性推导:swapout 触发看的不是”free 太少”,而是”压缩器物理占用占比”

xnu-11417.140.69 源码。swapout 的判定不是”free 页太少”,而是 AVAILABLE_NON_COMPRESSED_MEMORY(ANC,非压缩可用内存)跌破一个阈值:

/* vm_page.h:1519-1520 */
#define AVAILABLE_NON_COMPRESSED_MEMORY (active + inactive + free + speculative)
#define AVAILABLE_MEMORY                (AVAILABLE_NON_COMPRESSED_MEMORY + CC_physical)
/* 注意:CC_physical 是压缩器的【物理常驻页数】,不是压缩内容大小 */

macOS >3GB 时阈值系数 divisor=25(vm_compressor.c:1070-1071),所以触发 swap ⟺:

ANC < 0.4 × (ANC + CC_physical)   ⟺   ANC < (2/3) × CC_physical   ⟺   CC_physical > 0.6 × (ANC + CC_physical)

这里 CC_physical 是压缩器的物理占用页数,不是压缩内容大小! 压缩内容(6.4GB)反映的是”曾经压缩过多少匿名页”,而决定是否 swapout 的是”压缩器在物理上挤占了多少内存”。

关键洞察——不能把它想成”CC 必须超过 1.5×ANC 这个固定值”:因为 ANC 和 CC 都是物理占用,加起来近似守恒——内核压缩工作集时,ANC 缩小、CC 同步增大。所以真正的触发线是:压缩器物理占用超过整个 ANC+CC 池的 60%(8GB 上 ≈ 3.84GB 物理页,≈48% 内存)。

实测(stall 前后一致):

ANC(active 117671 + inactive 115707 + free 5216 + spec 1298) 3.66GB
压缩器物理占用 CC_physical 2.9GB(内容 ≈6.9GB,压缩率 ~2.4×)
阈值 0.4×(ANC+CC) 2.5GB
ANC < 阈值 3.66GB > 2.5GB → 原始负载下不触发
CC / (ANC+CC) 45% < 60% 线

为什么压力这么大也不 swapout:swapout 是压缩都救不了的”最后手段”。内核判断”是否需要 swap”看的是 ANC——active+inactive 队列里始终有 ~3.6GB 应用页可继续压缩复用;free 少只是因为这些页被压进压缩器后释放出来的页又被 WIRED(显示路径)吃掉了。物理页分布远没到”压缩器占池 60%”的线。

顺带说明,swapout 其实有三个触发条件(compressor_swapout_conditions_met(),三选一,任一成立即可):

  1. ANC < 0.4×(ANC+CC) —— 压缩器占比失衡(上文的 ①);
  2. 外部队列节流 && anon < inactive/20 —— 需要异常 I/O 尖峰;
  3. free < 保留线(free_reserved 量级只有数百页)—— 需要 free 塌方到 <24MB 左右。

三者在本次事件里全部从未触发:①差 1.4×、②差两个数量级、③差 ~3.7×(free 一直悬在 ~90MB)。26 分钟里 Swapouts: 0,swapout 计数器全是 0。

核心修正:swapout 触发的不是”free 页紧张”,而是”压缩器物理占用 vs 非压缩可用内存”的比例失衡。这台 VM 卡在”free 紧、压缩器满、但比例未失衡”的区间——不是配置问题,是负载形态问题

4.5 “调小压缩池逼内核建 swap”行不行?—— 不行,反而更糟

顺着”阈值够不到 → 把池子调小让压力上来”的思路查,发现 arm64 主循环里,当 swapout 条件不满足时,段只做内存内压缩(无磁盘 I/O);而在 CONFIG_JETSAM 分支下,一旦”内存空间不足”(vm_compressor.c:2767):

memorystatus_thread_wake();   /* jetsam:杀进程 */
should_swap = false;          /* 同时抑制 swap */

也就是说:调小压缩池不会逼出 swap,只会逼系统杀进程、同时抑制 swap。 这条路彻底堵死。

所以参数这条路彻底堵死了。但换个角度想:触发线是负载性的——原始负载推不过去,不代表所有负载都推不过去。既然唯一缺口是”第一次 swapout”,那能不能在 guest 里人为强制压缩,把 CC 推过池的 60%?这就是下一节要验证的。(至于”为什么真实 Mac 不用这么费劲”,留到 §6 展开。)

顺带一提,这也从侧面回答了后面 §6 的核心问题:触发线能不能够到,是”够不够压力”的问题,不是”开没开 swap”的问题。

5. 实测:人为制造”第一次 swapout”

既然阈值降不下来,那就不降阈值,主动把压缩器物理占用推过 60% 线,人为制造第一次 swapout。写了个工具 swap_priming.c

  • 原理:分配不可压缩的匿名内存(随机数据)并逐页 touch。随机数据压缩器压不动,内核要腾物理页只能把现有活跃工作集压进压缩器——这个动作同时缩小 ANC、扩大 CC,直到 CC 越过池的 60%(条件①)。
  • 流程:逐块 128MB 推进,每块后检查 /System/Volumes/VM/swapfile0 是否出现,出现即停;总上限 2GB 防失控。

实测轨迹(每步快照,ANC = active+inactive+free+spec,CC = 压缩器物理占用):

step     ANC     阈值(0.4×(ANC+CC))  ANC<thr?   free   free<1572?
START  229024     166181             False     4184   False
+512M  222502     166346             False     2516   False
+640M  217008     166395             False      904    True   ← free 已破保留线
+896M  197575     166762             False      925    True
+1024M 171814     166180             False      914    True
+1152M 161062     165963             True       952    True   ← Swapouts 0→3388

关键观察:free 从 640MB 起就已跌破保留线(条件③),但 swapout 直到 1152MB 才发生——恰好是 ANC 第一次跌破阈值(条件①)的那一步。说明在这台机器上真正驱动 swapout 的是比值(①),不是 free 塌方(③):free 是快速振荡量,快照跌破保留线可能只是瞬态;比值才是稳态的压缩平衡度。

  • 条件①成立 → 第一次 swapout → 懒线程被唤醒 → swapfile0(1.0G)落盘vm.swapusage total = 1024M
  • 程序退出释放内存后:free 从 63MB 恢复到 759MB,swap 保持活跃(used 76M)。

完整链路被实证:人为压缩 → CC 越过池的 60%(条件①)→ swapout → create 线程唤醒 → swapfile0 创建 → 逃生阀生效。这也证明:60% 这条线不是结构性够不到——只是这台 VM 的原始负载推不过去,程序可以把它推过去。

💡 顺带一个有趣的发现:后来用 memory_load(把 swap_priming 的建压逻辑做成”hot 不可压缩 + cold 可压缩交错填充”的完整负载模拟器)单跑一次,构建过程中 ratio 自己就冲过了 60%,swap 自动激活——不需要再单独跑 swap_priming。这从侧面再次印证:触发线是负载形态问题。

两条实测得到的教训

  1. 已冻结的画面不会恢复。 swap 激活后显示依然冻结。停摆是单向陷阱:WindowServer 帧管线卡死后,即使 free 回到 759MB、swap 活跃,它也不重启。这和 host 侧 METAL_HEALTH 冻结、”kick 停产的流水线无效”完全一致。所以 swap 诱出是预防(在停摆前建立逃生阀),不是急救(救不回已冻结的帧管线)。
  2. 不跨重启持久。 内核启动时不会扫描已有 swapfile0(create 线程只创建、不枚举);重启后 create 线程仍因 15 秒门闩睡死,要等下一次塌方才会重建这个文件。要让 swap 每个 boot 都活着,得在启动后主动诱出一次

6. 追问:实体 Mac 为什么很少这样?—— 先从 swap 说起

到这里,一个自然的追问冒出来:内存压力这种东西应该跟”虚拟”还是”真实”无关啊,为什么实体 Mac 很少出现”图形管线被内存压力饿死”的场面? 答案分两层:第一层是 swap 这个”阀”是怎么在真实 Mac 上存在的(§6.1),第二层是这台 VM 的 paravirt GPU 在内存上到底”特殊”在哪(§6.2、§6.3)。

6.1 真实 Mac 的”阀”是怎么来的:负载形态 + 僵持时的”加压能力”

真实 Mac 和这台 VM 用的是完全同一套懒创建机制。区别只有一个:负载形态让”首次 swapout”这条触发链能不能走通。

  • 本 VM(原始负载):工作集(VS Code/Edge/QQ)太热、太活跃,内核压不动,压缩器物理占用停在 ANC+CC 池的 ~45%,到不了 60% 线;free 又从不塌方(③要 <24MB)、无 I/O 尖峰(②)→ 三条全 false → 26 分钟零 swapout → create 线程从未被唤醒 → swapfile0 从未创建
  • 真实 Mac:典型负载的活跃工作集小得多,压缩器更容易涨到池的 60% 以上 → 第一次 swapout 发生 → 懒线程被唤醒 → swapfile0 落盘并从此常驻 → swap 变成永久逃生阀:压缩器满了就往磁盘卸,free 一直有富余,显示路径要 WIRED 页永远给得出。

但这里还有一个更微妙的机制,值得单独拎出来:僵持状态下”还能不能继续加压”。

回想我们实测到的停摆——它是单向陷阱:WindowServer 帧管线卡死后,即使 free 回来了、swap 激活了,它也不会重启(§5 教训 1)。把这一点和触发线连起来看,会发现冻屏不只是”结果”,还是一个焊死压力源的元凶:

  • 在 VM 上,冻屏就是”压力源的上限”。 画面一旦冻死,用户没法再点、没法再开应用——交互产生的内存压力戛然而止。而现有负载(VS Code/Edge/QQ)的工作集又”太热”,内核不断重新服务这些活跃页,压缩器就稳在 ~45% 上不去。于是局面变成自锁的僵持:没有 swap → 冻屏 → 压力无法继续累积 → 永远到不了 60% 线 → swap 永远不建立。
  • 在真实 Mac 上,压力很少会被这样”焊死”。 内存压力下的真实 Mac 会变慢、转沙滩球,但显示路径通常不会硬性停摆——用户还能继续操作、后台进程还在动,压力可以一直往上爬,直到哪一刻把压缩器推过 60% 线 → 第一次 swapout → swapfile0 落盘 → 从此这台 Mac 就有阀了。而且绝大多数真实 Mac 的 swap 是在更早的某个瞬态尖峰(打开大应用、浏览器标签风暴)时就被创建出来的,根本不需要等到一次持续高压。

所以”真实的 Mac 有 swap、VM 没有”的根本原因,一句话:swap 是最后手段,触发它需要”压缩器物理占用超过池的 60%”。真实 Mac 的负载形态——工作集更轻、压力能持续累积——把它推过了线;这台 VM 的开发负载工作集太热,再加上冻屏把交互压力源焊死,永远推不过去。swap 不是”开不开”的问题,是”够不够压力”的问题。

推论:既然唯一缺口是”第一次 swapout”,理论上可以从 guest 里人为强制压缩,把 CC 推过池的 60%,触发条件① → swapout → swapfile0 落盘。这一推论已在 §5 实测证实swap_priming / memory_load 人为建压,swapfile0 成功落盘、free 63MB→759MB),但实测也打回了它的”疗效”:swap 激活救不了已冻结的画面(单向陷阱),且不跨重启持久

6.2 那 paravirt GPU 自己特殊吗?—— 先看证据(ioreg 现场)

回到这台 VM 本身。在 guest 里直接查 IOKit 注册表,看到了整条虚拟显示链路的真身:

设备:AppleParavirtGPU(驱动 com.apple.driver.AppleParavirtGPUIOGPUFamily)
  Metal 插件:AppleParavirtGPUMetalIOGPUFamily
  PerformanceStatistics:
    "Alloc system memory"     = 494419968   (≈ 471MB)
    "In use system memory"    = 540027904   (≈ 515MB  ← 虚拟 GPU 捏着的 guest RAM)
    "recoveryCount"           = 0
    "lastRecoveryTime"        = 0
  暴露的 IOReport 通道里有:"GPU Restart Count"、"Last GPU Restart"(与真实 GPU 同款)

显示器(IODeviceTree:/efi/platform 里的 "display" PCI 设备):
  MetalPluginName = AGXMetalA12          ← guest 里跑的其实是真实的那套 A12 Metal 渲染器
  VRAM,totalMB    = 16384                ← 号称 16GB "显存"
  vramFreeBytes   = 15728640             ← 实际可用只有 15MB(虚拟数字,见下)

IOSurfaceParavirtMapperDevice / IOSurfaceParavirtMapperService
  (驱动 com.apple.iokit.AppleParavirtIOSurface,匹配 "paravirtualizedgraphics,iosurface")

同期内存快照:wired ≈ 89459 页 ≈ 1.37GB;压缩器物理占用 ≈ 1.66GB

6.3 三个结构性”特殊”,加上一个真正的阀门

把这些证据放在一起,可以回答”paravirt GPU 对内存有没有特殊之处”——有,而且有三个,但每一个单独拎出来都不致命,致命的组合是”没有 swap + 没有 GPU 自救”

特殊一:”16GB 显存”是假的,GPU 内存就是 guest 的普通 RAM。
这台虚拟显示卡号称有 16GB VRAM(VRAM,totalMB=16384),而 vramFreeBytes 只剩 15MB——这两个数字都是虚拟的。真实情况是:虚拟 GPU 的”显存”就是它从 guest 内核拿的 WIRED 系统内存(当前 In use system memory ≈ 515MB)。也就是说,虚拟显卡把 guest 的普通物理内存”钉死”了一大块,占整台机器 wired 内存(~1.37GB)的三分之一以上。这些页既不参与压缩、也不能换出——它们只进不出。

特殊二:虚拟 GPU 没有自己的内存回收/自救,recoveryCount 全程为 0。
这个驱动的 IOReport 通道里“GPU Restart Count”(GPU 重启次数)——真实 GPU 驱动在分配失败、显存不足时会走 GPU restart/recovery 路径来自己清理。但本次事件全程 recoveryCount=0lastRecoveryTime=0没有任何一次 GPU 侧回收发生。要么机制缺失、要么从未被触发,无论哪种,当 guest 内存告急时,虚拟 GPU 只能一动不动地继续捏着它那 515MB,等待内核页面分配器——它不会自救。

特殊三:显示路径是”两跳”的,每次往 host 推帧都要额外的 WIRED 分配。
真实 Mac 上,WindowServer 渲染完直接进自家显示管线,一跳。这台虚拟机上,guest 的 WindowServer 渲染进 IOSurface → 经 IOSurfaceParavirtMapperDevice/Service(AppleParavirtIOSurface 驱动)把这块内存转发给 host → host 用 Metal 画出来。往 host 推每一帧,那帧所在的 IOSurface 就得被”钉死”在 guest 内存里(wired)并且存活到 host 用完为止。这等于在同一个 WIRED 池上多加了一路按消耗的买家,而 guest 里跑的又是真实的那套 Metal 渲染器(AGXMetalA12),它照常按渲染需求向内核要 WIRED IOSurface,根本不知道底层的物理内存其实是稀缺的 guest RAM

每个可见窗口都有一块 IOSurface backing store。 它不是开机一次性建好,而是窗口出现、缩放、滚动、内容重绘时才懒分配。这些 backing 在 macOS 上是可回收的。 内核在 free 低时会主动把它们 purge 掉腾内存。于是压力下会发生一个反直觉的循环:free 降 → 系统 purge
掉一批窗口 surface → 合成器还要用它们 → 得重新分配。也就是说,压力本身就在制造”新 wired 分配”——恰恰是在最缺页的时刻要页。

但真正的阀门不在 GPU 上,而在 swap。
这三条都只是”放大器”。真正让一台 8GB 真实 Mac 不至于走到这步的,是这台 VM 缺的东西:
1. swap 逃生阀(主因,已被 §4/§5 实测证明)——真实 Mac 的负载形态让”首次 swapout”发生过,swapfile0 落盘常驻,压缩器满了就往磁盘卸,WIRED 分配永远有货;这台 VM 的负载推不动触发线,swap 从未建立。
2. GPU 自己的回收路径(次因,与 evidence 一致、但为推断)——真实 GPU 驱动在分配失败时有 recovery 机制,而这里 recoveryCount=0

这也正好解释了 §6.1 里那个”真实 Mac 渐变卡顿、VM 直接冻死”的差异:有没有阀,决定了一次 WIRED 分配是”等一下拿到”还是”永远等不到”。 真实 Mac 上有 swap 兜底,内存紧时内核能把压缩器物理页卸到磁盘,帧缓冲的 WIRED 分配慢一点但最终能拿到——于是表现为卡顿、掉帧;这台 VM 没有阀,WIRED 分配直接等到天荒地老,WindowServer 一帧都交不出来——于是表现为二值冻结。冻结之后交互压力源又被焊死(§6.1),僵持就此定格。

6.4 说清楚哪些是实测、哪些是推断

把话说严谨:“真实 Mac 能推过 swap 触发线”以及”真实 Mac 的显示路径不会硬性停摆”这两点都是推断,不是实测——我们没有在一台真实 Mac 上做过同样的负载。前者的依据是触发线的”负载性本质”:同一套懒创建机制,真实 Mac 上 swapfile0 普遍存在,说明它的负载必然触发过至少一次 swapout。后者的依据则是”单向陷阱”的反面:一旦显示路径被永久焊死、压力源被掐断,系统就失去自救能力——而真实 Mac 的系统表现(慢归慢、仍能缓过来)说明它没有掉进这个陷阱。至于 “VM 无 swap + GPU 不自救 + 冻屏焊死压力源”这个组合,则是实测的。两者合起来,才能解释为什么实体 Mac 很少出现这种”显示管线被内存饿死”。

7. 缓解:SwapPrime——开机自动建立逃生阀

结论很清楚:swap 是预防,不是救援;降内存压力是治本,诱出 swap 是兜底。而 swapfile0 不跨重启,所以每次登录都要重新诱出一次

于是写了个菜单栏小工具 SwapPrime(macOS 11+,纯 AppKit):

  • 每次登录自动运行:分配 cold(可压缩,喂压缩器)+ hot(不可压缩,锁 free)交错填充,构建过程中 ratio 会越过 60% → 触发第一次 swapout → swap 一激活立即退出
  • 状态栏有个内存芯片图标 + 进度(已填 hot/cold MB、已耗秒数),让用户看到它正在干活;
  • 默认 60 秒超时:没拉起来就发通知退出(日志 /tmp/swapprime.log 含最终 free / CC / swapouts,便于诊断);
  • 启动时 swap 已激活(比如手动跑过)→ 立即成功退出,不重复建压;
  • 不落盘、不写系统状态,退出即无残留。

安装后在每次登录时自动执行。验证(本次会话实测):swap 已激活 total = 2048M、free 在低位仍能拿到 WIRED 页、display_load 动画窗口持续流畅——“屏幕冻住但系统活着”不再出现。

💡 为什么是预防而不是救援:已经冻住的屏幕,swap 也救不回来(§5 教训 1:单向陷阱)。SwapPrime 干的是在停摆之前把逃生阀建好:压力逼近时 swap 能把压缩器物理页卸到磁盘、腾出物理内存给 WIRED 分配。它在”发病前”起作用,不是在”发病后”。

治本仍然是降低 guest 内存压力:Swap 只是缓解,压力极端的情况下仍然可能冻屏。关掉/替换大内存应用(Edge/VS Code/QQ 占了 ~2.9GB 可压缩内存)、或给 host 侧 VM 配置更多内存。swap 只在压力真的逼近时兜底,平时不动它。

8. 这场排查教会我们的事

  1. “画面卡死但系统活着” = 显示路径饿死,不是系统崩溃。 帧是 push 模型,guest 停产,host 的一切 kick 都是空转。
  2. swap 在 macOS 上是”绝对最后手段”,而且懒到极致。 swapfile0 只能由”懒线程”在 swapout 时创建;没有 swapout → 永远没有 swap 文件。它甚至不会在开机时扫描已有的 swapfile——重启即失忆
  3. swapout 触发看的是”压缩器物理占用占比”(>60% 池),不是 free 页。 这解释了为什么”free 悬在 90MB、压缩器塞满”依然不 swapout——负载形态不够。
  4. 触发线全部硬编码,release 内核上没有任何参数能调。 唯一能改变方程式的”参数”是 guest 内存大小(host 侧配置)。
  5. 虚拟 GPU 在内存上很”朴实”:显存就是 guest 的 WIRED RAM,且没有自己的回收。 但真正决定生死的,是 swap 这个逃生阀存不存在。
  6. swap 是预防,不是救援。 单向陷阱,冻住了就回不来。所以要么提前建阀,要么别让压力到那份上。
  7. 冻屏会焊死压力源,让僵持自锁。 没有 swap → 冻屏 → 交互压力被掐断 → 永远到不了 60% 线 → 永远没有 swap。真实 Mac 不会被这样”焊死”(§6.1),这是”实体 Mac 很少出这事”的另一半答案。

附录:关键技术点速记(给想再挖一层的人)

  • Swap 创建懒到极致vm_swapfile_create_thread 的全部唤醒点都在 vm_swap_put()(swapout 落盘)路径里(vm_compressor_backing_store.c:1787 / 1818 / 1982);vm_swapfile_openO_CREAT|O_TRUNC/sbin/dynamic_pager 只是启动清理,不建 swap。
  • 首轮都建不成vm_swapfile_should_create 有 15s 延迟门限(vm_swapfile_creation_delay_ns);last_failed_to_create_ts 初值 0,首轮 delta = now 不足 15s → create 线程首轮直接 break、连失败日志都没有。
  • swap 触发阈值(macOS >3GB):COMPRESSOR_NEEDS_TO_SWAP()ANC < 0.4×(ANC+CC_physical)CC_physical > 0.6×(ANC+CC)。三个 swapout 条件(vm_compressor.c:2694-2705)是 OR:① 压缩器占比失衡;② 外部队列节流 && anon < inactive/20;③ free < 保留线。全部硬编码。
  • 阈值不可调:divisor(=25) 硬编码(vm_compressor.c:1070-1071)、_overridden 变量是死代码、ripe 路径在 macOS 上被 c_overage_swapped_limit=0 结构性锁死(backing_store.c:496 / 1640 两个赋值点都在 #if !XNU_TARGET_OS_OSX 里)、vm_ripe_target_age(48h) 连 TUNABLE 都不是。vm_swapfile_creation_delay_ns(15s) 是唯一可 boot-arg 覆盖的,但只影响建文件时机、不影响 swapout 触发。唯一能改方程式的”参数”是 guest 内存大小(host 侧)。
  • swap 可被人工诱出:分配不可压缩随机内存逐块 touch → 逼内核压缩工作集 → CC 越过池 60%(条件①)→ 第一次 swapout → swapfile0 落盘。2026-08-09 实测:free 63MB→759MB,swap total=1024M;且证实触发的是比值①而非 free 塌方③。激活救不了已冻结的画面(单向陷阱),也不跨重启持久。
  • paravirt GPU 内存事实(ioreg):AppleParavirtGPUIn use system memory ≈ 515MB(guest WIRED RAM),recoveryCount=0/lastRecoveryTime=0;显示器虚报 VRAM,totalMB=16384vramFreeBytes=15MB;Metal 插件为真实 AGXMetalA12 + AppleParavirtGPUMetalIOGPUFamilyIOSurfaceParavirtMapperDevice/Service 负责 guest↔host 帧转发。
  • 排查过程中翻阅xnu-11417.140.69 源码(vm_compressor.c / vm_compressor_backing_store.c / vm_compressor_xnu.h / vm_page.h)、guest 反汇编、boot 日志、host 侧 pvg-trace.log 与 health monitor 输出。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注