Henry Jia

容器给了 90 GB,ComfyUI 以为自己有 754 GB

约 8 分钟显影AI

目录 · 13 节
  1. 一、两个数字
  2. 二、为什么加一个 --cache-ram 参数解决不了
  3. 三、我第一次的验证是假的
  4. 四、修法:ComfyUI 源码一个字节没动
  5. 顺带一个反面案例:阈值设在正常水位以下
  6. 五、上游那个 issue,是 2024 年的
  7. 六、然后机器人 review 逼出了两个真洞
  8. 洞一:挂载根不是我自己那个 cgroup
  9. 洞二:限额是运行时可变的
  10. 第三条我拒了
  11. 七、这一趟不报错的坑
  12. 八、几条能带走的
  13. 最后:这个 PR 可能永远不会合

我在 AutoDL 租的一张 RTX 5090 上跑 ComfyUI,视频、出图、音频十几条线全挤在这一台。它挂过四到六次,每次都长一个样:整机没响应,控制台的内存曲线贴着 100%。

我一直以为是自己用得太凶——同时排了太多任务、模型没卸干净、并发开大了。这个归因错了很久。

真因只有一句话:

ComfyUI 用 psutil 读内存,在容器里拿到的是宿主机的数。

于是它所有基于内存压力的决策,缓存驱逐、模型卸载、pinned memory,全部失效。缓存跨条累积,直到撞上 cgroup 上限被内核杀掉。


一、两个数字

值
cgroup 上限 /sys/fs/cgroup/memory.max 96636764160 90.0 GiB,和 AutoPanel 上写的内存大小 90GB 同源
宿主机总量,也就是 psutil 看到的 810154872832 754.5 GiB
ComfyUI /system_stats 报的 ram_total 810154872832 报的是宿主机那个

差了 8.4 倍。ComfyUI 以为自己有七百多 GB 可用,实际上到 90 GB 就会被杀。

还有一层:宿主机是和别的租户共享的。同一时刻实测,宿主机已用 65 GiB,而我们这个容器只占 51.5 GiB,差出来的 13.5 GiB 是邻居的。所以拿宿主机的剩余量当阈值,既不可控也不可复现,何况它表达的根本就不是我要约束的那个量。


二、为什么加一个 --cache-ram 参数解决不了

--cache-ram 是 ComfyUI 的默认缓存模式,--help 里写着:active 部分保留 10% of system RAM(min 2GB, max 10GB)。把这里的 system RAM 代进去看:

system RAM 被读成 active 保留量 触发驱逐的条件 结果
修之前 754.5 GiB 撞上限 = 10 GB 宿主机空闲 < 10 GB 宿主机空着 683 GiB,永不触发
修之后 90 GiB 10% = 9 GB 容器用量 > 81 GiB 离 90 GiB 还有安全垫

这个 flag 的语义是保留 N GB 可用,而可用由 psutil 量、量的是宿主机。必须先让它看见对的数,阈值才有意义。

我中途真的以为不用改代码了,启动加个参数就行。跑了对照才知道不行:同一个 --cache-ram 60,带垫片的那一臂钉死不动,不带垫片的那一臂照样一路爬。


三、我第一次的验证是假的

这段得单写,因为它是这一趟最大的方法教训。

我打完垫片,跑了一轮,看到两个漂亮的数字:每条从 84 秒降到 40 秒,内存稳在 47 / 90 GiB 不涨。我当场就想宣布修好了。

问题有两个。一是我同时改了两个变量——打垫片的同时,我还把之前每条生成前手动调的 POST /free 去掉了,那 44 秒的提速几乎全是不调 /free 的功劳,跟垫片没关系。二是那一轮峰值只到 47 GiB,离 90 GiB 的触发点差得远,被我修好的那段驱逐逻辑,一次都没执行过。我把没有崩这件事,当成了修对了。

真正的验证长这样:同一个 --cache-ram 60,同一批四条视频生成,唯一的变量是垫片。

--cache-ram 60 的意思是空闲低于 60 GB 就开始淘汰。带垫片时触发点是 90 − 60 = 30 GiB;不带垫片时是 754.5 − 60 = 694 GiB,永远到不了。容器用量 /sys/fs/cgroup/memory.current:

第几条 带垫片 不带垫片
1 29.5 GiB 46.0 GiB
2 29.5 GiB 48.4 GiB
3 29.5 GiB 50.0 GiB
4 29.5 GiB 51.5 GiB

带垫片的那一臂钉在 30 GiB 阈值下沿,四条零增长,驱逐完全按设计工作。不带垫片的每条爬 1.5~2.4 GiB,单调上升,驱逐一次都没触发。

往下外推一下,这是外推不是实测:不带垫片跑 20 条大约是 46 + 30 = 76 GiB,再叠上别的模型就撞 90 GiB 了。和那次崩溃对得上。


四、修法:ComfyUI 源码一个字节没动

思路是业界标准解法,取 min(cgroup 上限, psutil),Ray 和 Dask 都是这么干的。memory.max 读不到或者写着 max 的时候自动退化成原函数,所以裸机上跑也安全。

三个落点都能达到目的,我选了第三个:

改什么 作用域 代价
A sitecustomize.py psutil 本体 整个 conda 环境所有 Python 进程 范围过大,会影响别人
B 改 comfy/model_management.py ComfyUI 源码 只有 ComfyUI 它是 git 仓,一改 git status 就脏、git pull 会冲突
C 启动垫片 comfy_boot.py 什么都不改,只改启动方式 只有这一个进程 绕过启动脚本直启就没有垫片

垫片在 runpy.run_path(main.py) 之前把 psutil.virtual_memory 换掉,main.py 里的一切,包括启动时就做的那些决策,都看见校正后的数。

修完的实测:

值
稳态内存 47 / 90 GiB,连跑 20 条不涨
每条耗时(LTX i2v 1344×768 · 5 秒) 首发 80 秒冷加载,之后稳定 40 秒
要不要 /free 不要,它自己会按真实的 90 GiB 判压力
要不要加 --cache-ram 不要,默认值现在算出来就是对的

顺带一个反面案例:阈值设在正常水位以下

真因还没查到之前,我的临时方案是内存快满了就调一次 POST /free,阈值拍了 45 GiB。

结果每一条生成都触发,因为 LTX 的稳态本来就是 47.8 GiB,45 这个数字压根在正常水位以下。等于每发都强制冷加载一次,84 秒一条,而热跑只要 40 秒。

拍阈值之前先量一下正常水位。 这条听起来很蠢,但当时我确实是凭感觉拍的。


五、上游那个 issue,是 2024 年的

修完想着回馈一下,先去查有没有人报过。有,而且不止一个:

issue 状态
total RAM reported wrong in Docker(2024 年) Open,维护者零回复。有人给过 patch,还发布了一个 ComfyUI-Manager 装得到的扩展
default cache_ram doesn't respect /sys/fs/cgroup/memory.max(2026-07) Open,9 条评论。作者的环境是 4090 容器、90GB 上限、1007GB 宿主机,和我这台几乎同型

这么常见的坑,为什么两年没修?我没有答案,只能猜:维护者多半在裸机上开发,裸机上这个数是对的。

所以我把垫片改写成补丁,提了上去。


六、然后机器人 review 逼出了两个真洞

PR 交上去,人还没来,先来了个 AI review 机器人。我第一反应是,bot 而已。它提的三条里,两条是对的,而且两条我自己那个垫片也中招。

洞一:挂载根不是我自己那个 cgroup

我读的是 /sys/fs/cgroup/memory.max。但那是挂载根,不一定是本进程所在的那个 cgroup。

这条我没有集群、没有 systemd 机器,一度以为验不了。后来发现本机 Docker 十几分钟就能造出全部四种形态:

造法 /proc/self/cgroup 旧版读到 新版读到 真正的有效限额
--cgroupns=private,默认,也是我这台 0::/ 4.00 GiB 4.00 GiB 4 GiB
--cgroupns=host 0::/docker/<id> 读不到 4.00 GiB 4 GiB
手工造嵌套,祖先 2G 小于自己 3G 0::/outer/inner 4.00 GiB 2.00 GiB 2 GiB
不设 -m,接近裸机 0::/docker/<id> 读不到 读不到,原样透传 无

第三行是真缺陷,读到一个既不是自己的限额、也不是有效限额的数,而且偏高一倍。

但注意第二列:三种失败方式全是报得太高,一次都没有报得太低。 这条很要紧,因为报低才会引起假驱逐,那才是会让性能白白变差的方向。

我原本以为第三行那个形态是我手工造的偏门情况。查官方文档才发现不是。内核 cgroup-v2 文档写着,越靠近根的限制越不能被下面推翻,也就是祖先限额天然约束后代;同一份文档还写着根 cgroup 不受资源控制、因此不该有那些接口文件,所以第二行读不到文件是规范规定的,不是 Docker 的偶然。Kubernetes 的 pod 级 limit 也是同一个形状,哪怕各容器 limit 加起来更高也超不过它,v1.34 起 beta 且默认开启。systemd 那边,systemd.slice(5) 写明 slice 上设的限制对该 slice 内所有单元的所有进程生效。

顺带发现一件我原先低估的事:把 ComfyUI 装成 systemd 服务这个再常见不过的部署,本身就是第二行那种形态(0::/system.slice/xxx.service,而根上没有 memory.max)。原来那版补丁在那种部署下完全不起作用。

洞二:限额是运行时可变的

我把限额在 import 时算了一次。但 memory.max 是可写的:docker update -m 是一条命令的事,K8s 的 in-place pod resize 更是 2026-04 刚升 beta 的正式功能。限额被调小之后还拿着旧值,等于原病复发。

改成每次调用重读。不在 cgroup 里的进程早退,Windows 和 macOS 上零文件 IO。

第三条我拒了

它还要求解析 /proc/self/mountinfo,去认自定义的 cgroup 挂载点。技术上不假,但有两条理由。一是失败方式是安全退化,挂载点不对就是文件读不到,退回 psutil,和今天的行为一模一样。二是业界标准解法根本没做到这一步——我去读了 Ray 的源码,它用的是写死路径,连 /proc/self/cgroup 都不读。

第二条是我提这个 PR 之后才知道的。我一直说照抄 Ray 的修法,真去读了才发现 Ray 停在我的第一版水平。


七、这一趟不报错的坑

按有多能骗人排序。

最能骗人的是补丁漏了一处,而跑出来的数看着像成功。第一版只改了两个文件,实跑 45.6 → 47.3,和垫片的 29.5 完全对不上。全树 grep 才发现第三个文件里还有一处。要不是把补丁真装上跑一遍,我会交出一个看起来对、其实不管用的东西。

然后是单元测试无限递归:测试里构造假内存的函数自己调了 psutil.virtual_memory(),而那个函数在测试里又被 patch 成了它自己。改成 patch 之前先把真值取好。

lint 也挂在没人想到的地方。补丁把最后一处 psutil 用法换掉之后,两个文件的 import psutil 成了死的,ruff 报 F401,而 Run Ruff 是那个仓的必过检查。

最后是 ComfyUI 的单测在纯 CPU 环境跑不起来:comfy/model_management.py import 时就调 torch.cuda.current_device(),没有卡直接崩在收集阶段。得在 import 之前先把 args.cpu 设成 True。

顺带发现:他们自己的 unit test workflow 装的就是 CPU 轮子,而且整个 job 挂着 continue-on-error: true。那个矩阵很可能长期是红的,没人看。

这四条全都只有真跑了才会发现。


八、几条能带走的

一次只改一个变量。我把 84 秒到 40 秒的功劳全算给了垫片,其实那几乎全是另一个变量的;而且那一轮被修好的代码路径一次都没执行过,我却已经准备宣布成功了。

阈值要锚在你真正想约束的那个量上。宿主机还剩多少,和我这个容器还能用多少,是两个量,前者还和邻居有关。拍阈值之前也先量一眼正常水位——45 GiB 那个数就是拍脑袋拍在水位以下的,代价是每条多 44 秒。

上游没修,不等于我不能修。我一度把 issue 状态是 Open 当成只能绕,于是去猜参数,而标准解法早就在那儿了。

机器人提的 review 也可能是对的,判据只能是它说的事实成不成立,不是谁说的。但要和另一件事分开:它不 gate 合并,那个仓最近好几个 PR 都是顶着它的 CHANGES_REQUESTED 合进去的。不 gate 合并,和说得不对,是两回事。

最后一条:说自己没有环境验,经常是错觉。 我以为要真集群才能验第六节那些形态,实际上本机 Docker 十几分钟造了四个。cgroup 的语义是内核给的,不是发行版给的。


最后:这个 PR 可能永远不会合

第一个 issue 是 2024 年的,维护者至今零回复。我对合并不抱期待。

但回头看,这一趟真正换回来的东西,没有一样取决于那个 merge 按钮。那个挂了四到六次、每次都被我当成自己用得太凶的故障,真因查清了。外部 review 白送了一双眼睛,指出了我自己那个垫片的两个洞,而它们在我这台机器上都不发作,会一直躺到换机的某一天再挂一次。还有一套以后能反复用的验证手法:本机 Docker 造 cgroup 形态、在容器里跑别人仓的单测。

如果上游合了,我还能把垫片扔掉。私补丁是要长期维护的,绕过启动脚本就失效、换机器要跟着搬。这才是我提这个 PR 最实际的动机。