Skip to Content
Ⅱ · 执行引擎08 · 缓存与增量重算

08 · 缓存与增量重算

ComfyUI 的交互速度很大程度来自增量执行:改一个提示词,不必重新读 checkpoint;改变预览节点,也不应重做采样。正确缓存的难点不是保存输出,而是回答:两个位于不同 Prompt、甚至不同节点 ID 的计算,何时语义等价?

CacheSet 有两类缓存

class CacheSet: outputs # CacheEntry(ui, outputs),按输入语义签名 objects # 节点类实例,按节点身份
execution.pyL60-149IsChangedCache、CacheEntry、CacheType 与 CacheSet。

outputs cache

保存节点返回值和 UI 数据。它以输入签名作为 key,使“相同计算”跨 Prompt 复用。

objects cache

保存实例化的节点对象。V1 节点可能在实例字段中保留复用状态;它按 ID key 管理,不能与输出语义缓存混为一谈。

关闭 cache 时两者都使用 NullCache。节点不应假设同一个 Python 实例永远存在。

缓存键为什么不能只用节点 ID

用户修改节点 6 的 text 后,ID 不变但结果必须失效。反过来,把完全相同的节点复制成 106,如果它是幂等计算,仍有机会复用语义相同的结果。

默认 CacheKeySetInputSignature.include_node_id_in_input() 返回 false。只有以下情况把 ID 纳入签名:

  • 节点声明 NOT_IDEMPOTENT / V3 not_idempotent;
  • 协议要求 unique ID 参与输入;
  • 特定 key strategy 显式要求。

完整输入签名

CacheKeySetInputSignature 为当前节点与所有有序祖先生成 immediate signature:

[ class_type, IS_CHANGED / fingerprint_inputs result, maybe node_id, (constant_input_name, constant_value), (link_input_name, ("ANCESTOR", ancestor_index, output_socket)), ... ]

再把本节点签名与祖先签名列表转为可 hash 结构。

comfy_execution/caching.pyL82-149递归有序祖先与 immediate node signature。

为什么链接存 ancestor index,而不是原始 ID

签名先按输入名排序,并以确定性 DFS 给祖先编号。链接记录 (ancestor_index, socket),祖先自身的类型、常量和上游关系另行进入签名。

因此两个节点 ID 不同、但拓扑与常量完全相同的纯计算图可以得到同样签名;同时,共享上游与复制上游的连接结构仍被确定性表达。

输入名为何排序

JSON 对象的插入顺序不应影响计算语义。排序后,客户端以不同字段顺序提交同一 Prompt 也会命中缓存。

output socket 为何必须进入

Checkpoint Loader 同时输出 MODEL、CLIP 和 VAE。链接到 slot 0 与 slot 1 即使祖先相同,语义完全不同。

IS_CHANGED / fingerprint_inputs

某些节点的输出依赖 Prompt 中看不见的外部状态:

  • 文件内容变了,但文件名没变;
  • 随机源或时钟;
  • 远端资源版本;
  • 节点自己的可变配置。

V1 IS_CHANGED 或 V3 fingerprint_inputs 返回一个 fingerprint,进入 immediate signature。IsChangedCache 对每个 Prompt 每节点只计算一次。

execution.pyL60-101V1/V3 fingerprint 的发现、列表映射、异常与 memo。

为什么 fingerprint 只读取常量输入

代码刻意不从 outputs cache 解析链接值。完整上游依赖已经由 ancestry signature 表达;fingerprint 的职责是补充节点外部状态。若在这里读大张量,不仅昂贵,还会造成签名递归与对象身份耦合。

NaN 的用途

fingerprint 计算异常时写入 NaN。NaN 不稳定相等的特性可保守地阻止错误缓存复用。对节点作者而言,正确做法仍是让 fingerprint 轻量且可靠,而不是依赖这个降级。

一次签名传播

图:LoadCheckpoint → Encode → Sample → Save

只改 Save 前缀

LoadCheckpoint signature unchanged Encode signature unchanged Sample signature unchanged Save immediate constant changed

前三个输出复用,只执行 Save。

改提示词

LoadCheckpoint unchanged Encode text changed Sample ancestor signature changed Save ancestor signature changed

模型文件原地替换

若 Loader 的 IS_CHANGED fingerprint 能观察文件状态,则 Loader 及全部下游失效;若节点没有表达该外部变化,缓存系统无法凭空知道。这说明缓存正确性最终依赖节点协议完整性。

BasicCache 生命周期

每次 Prompt 开始:

  1. set_prompt(dynprompt, node_ids, is_changed_cache) 建当前 key set;
  2. clean_unused() 依据策略删除不再需要的 key / subcache;
  3. get(node_id) 把 ID 映射到当前 data key 后查询;
  4. set(node_id, CacheEntry) 以签名存储;
  5. 可选 provider 收到 lookup/store/lifecycle 通知。
comfy_execution/caching.pyL151-286BasicCache 的 prompt 绑定、清理、读写与 provider 扩展。

Cache object 跨 Prompt 存活,但 key set 每次重建。这样能复用旧条目,也能按新图判断哪些仍活跃。

三种实际策略与关闭模式

模式outputs适合主要代价
ClassicHierarchicalCache默认交互式工作流切换图后较快清除不用结果
LRULRUCache(max_size)在多个近期计算间往返数量不等于内存大小
RAM pressureRAMPressureCache大中间张量、系统 RAM 紧张需扫描估算与更复杂淘汰
NoneNullCache调试、强制每次重算失去增量性能与对象复用

模式由 worker 创建 PromptExecutor 时选择,不是每个节点自行选择。

HierarchicalCache 与运行时子图

父节点 expand 的临时节点存在于其 subcache:

parent cache key └── subcache ├── ephemeral A signature → result └── ephemeral B signature → result

父节点输入签名改变,旧父 key 不再 used,对应 subcache 可一起清理。层级表达了临时计算的所有权,避免全局命名和生命周期泄漏。

LRU 的“代”

LRU cache 在每次 set_prompt 增加 generation,节点被访问时记录使用 generation。超出 max size 时,按较老 generation 删除,直到容量满足。

它更接近“最近几次 workflow 使用”,而非逐访问精确链表 LRU。subcache 仍用于跟踪活节点和 children。

RAMPressureCache 的淘汰评分

RAM 模式不按条目数,而是结合:

  • psutil.virtual_memory().available;
  • 当前/旧 generation;
  • 最近 touch timestamp;
  • 递归扫描 CPU tensor 的 numel × element_size;
  • 旧 workflow 的指数惩罚 1.3 ** age;
  • 旧 ModelPatcher 输出优先淘汰;
  • 无法测量的条目给极小 baseline weight。
comfy_execution/caching.pyL522-602RAM 估算、旧 workflow 偏置与逐条淘汰。

淘汰顺序

候选按 oom_score × estimated_ram 排序,优先移除得分大的条目;完全未知大小时 timestamp 作为 tie breaker。

当前 generation 默认不参与普通回收。压力继续升高时,执行外循环可请求 free_active=True,先清理超过 512 MiB 的大中间值,再释放 pins,最后更激进回收。

动态输出保护

若当前工作流条目的所有输出都声明动态驻留能力,RAM cache 跳过它,避免与动态内存管理器重复争夺同一对象的生命周期。

CacheEntry 为什么包含 UI

节点输出不仅是下游 Python 值,还可能有图片、文本等 UI 元数据。缓存命中时执行器要重新发送 executed / cached UI,让新一轮请求的前端仍能显示结果。

如果只缓存张量,后端“没计算”是正确的,用户界面却像没发生过。因此 UI 与 outputs 一起成为计算结果。

Assets enrichment 在写入 UI/cache 前完成,即使当前没有连接客户端,资产 ID 也会进入 history 与 cache,而不依赖发送路径。

外部 cache providers

outputs cache 可启用 provider hooks,支持 lookup、store、prompt start/end 等扩展。provider 异常被警告并隔离,不能轻易摧毁核心执行。

这为未来跨进程或持久缓存留下接口,但当前内建缓存的语义仍以进程内对象为主;Python model/tensor 并不天然适合直接序列化到共享缓存。

缓存不变量

  1. 同一签名必须代表相同可观察输出;否则是错误命中。
  2. 任一未进入签名的外部依赖都可能造成陈旧结果。
  3. NOT_IDEMPOTENT 节点必须隔离身份,副作用节点不能跨 ID 合并。
  4. 当前消费者拿到的局部值不能因全局淘汰突然消失。
  5. 子图结果的生命周期必须从属于父签名。

常见反模式

用时间戳无条件作为 fingerprint

每次都变会彻底禁用该节点及下游缓存。只有输出确实随时间变化才合理。

fingerprint 返回大对象

签名需要 hashable、轻量、稳定。更合适的是文件 size + mtime + content hash 策略,而不是整个 tensor。

有副作用却不声明 not idempotent

两个不同节点若输入等价,可能共享 output signature;保存、计费或远端创建资源等动作不应被错误消去。

把节点实例当输出缓存

实例复用不表示计算结果仍有效。状态容器与语义输出必须分开。

调试“为什么重算 / 为什么没重算”

1. 比较 class_type 是否变化 2. 比较所有常量输入(注意归一化后的类型) 3. 查看每条 link 的上游 slot 与祖先图是否变化 4. 检查 IS_CHANGED / fingerprint_inputs 的返回 5. 检查 NOT_IDEMPOTENT / unique-id rule 6. 确认缓存模式、容量和 RAM eviction 7. 若是 expand,检查父节点签名和 subcache 生命周期

可迁移的设计方法

  • 用规范化的完整依赖图做内容寻址,不依赖编辑器对象 ID。
  • 外部状态通过显式 fingerprint 进入签名。
  • 把 cache correctness 与 eviction policy 分开:前者定义 key,后者定义保留多久。
  • 动态子任务用层级 cache 表达所有权。
  • 交互系统缓存“展示结果 + 计算结果”,保证 cache hit 也可重放 UI。
Last updated on