容器给了 90 GB,ComfyUI 以为自己有 754 GB
目录 · 13 节
我在 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 最实际的动机。