围绕 ComfyUI,大家讨论最多的是节点、工作流、算力这些,真正去看缓存细节的人其实不多。但只要你开始在一台机器上堆多个模型、多个 LoRA、多个 workflow,缓存策略就会直接决定这几件事:
-
你是算力浪费,还是把显存 / 内存用在刀刃上; -
容器会不会莫名其妙 OOM; -
工作流切换时,是“秒级热身”还是“从头再来”。
这篇文章只做一件事:把 ComfyUI 当前的缓存架构和实现讲清楚,重点是三类策略(CLASSIC / LRU / RAM_PRESSURE)在「键怎么算、什么时候命中、什么时候过期」上的差异,以及在多模型、多 LoRA、多 workflow 场景下应该怎么选择。
1. 整体架构:两路缓存 + 四种策略
先把整体结构捋清楚。
1.1 两类缓存:outputs 和 objects
在执行一个工作流的时候,ComfyUI 维护了两类缓存:
-
outputs:
存中间结果和 UI 输出。命中时可以直接跳过节点执行,这部分是真正省计算的地方。 -
objects:
存节点对象实例(类实例),避免每次重新构造节点对象。
这两类缓存由一个统一的集合类 CacheSet 来管理。核心结构在 execution.py 中:
class CacheType(Enum):
CLASSIC = 0
LRU = 1
NONE = 2
RAM_PRESSURE = 3
class CacheSet:
def __init__(self, cache_type=None, cache_args={}):
if cache_type == CacheType.NONE:
self.init_null_cache()
elif cache_type == CacheType.RAM_PRESSURE:
cache_ram = cache_args.get("ram", 16.0)
self.init_ram_cache(cache_ram)
elif cache_type == CacheType.LRU:
cache_size = cache_args.get("lru", 0)
self.init_lru_cache(cache_size)
else:
self.init_classic_cache()
self.all = [self.outputs, self.objects]
不管是哪种策略,结构都是:
-
outputs:按输入签名做 key; -
objects:按 (node_id, class_type) 做 key。
1.2 四种策略:CLASSIC / LRU / RAM_PRESSURE / NONE
从启动参数到缓存策略的选择,大致是这样的优先级(在 main.py 中):
-
指定 --cache-lru→ 用 LRU; -
否则指定 --cache-ram→ 用 RAM_PRESSURE; -
否则指定 --cache-none→ 完全关闭缓存; -
都没指定 → 默认 CLASSIC。
CacheType 的定义前面已经贴了。不同策略只决定 outputs 的实现方式,而 objects 基本始终使用层级缓存 HierarchicalCache(CacheKeySetID),后面细讲。
2. 缓存键:输入签名 + 变化指纹
缓存是否命中,首先取决于“key 算得是否合理”。ComfyUI 的设计核心是:
-
输出缓存(outputs):用“输入签名 + is_changed 指纹(+ 某些情况下的 node_id)”作为 key; -
对象缓存(objects):用 (node_id, class_type)作为 key。
2.1 输出缓存:输入签名是怎么来的
输出键的生成由 CacheKeySetInputSignature 负责,核心逻辑在 comfy_execution/caching.py:100-126:
async def get_node_signature(self, dynprompt, node_id):
signature = []
ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id)
signature.append(await self.get_immediate_node_signature(dynprompt, node_id,
order_mapping))
for ancestor_id in ancestors:
signature.append(await self.get_immediate_node_signature(dynprompt,
ancestor_id, order_mapping))
return to_hashable(signature)
这里有几个点:
-
不只看当前节点:
它会按拓扑顺序把“当前节点 + 所有祖先”的签名拼在一起。这保证了只要整个子图的拓扑和输入一致,输出就能命中缓存。 -
immediate 签名包含什么:
get_immediate_node_signature会把这些信息打包进去(位置见同文件 108–126):-
class_type:节点类型; -
is_changed指纹(通过fingerprint_inputs/IS_CHANGED); -
必要时的 node_id; -
有序的输入值/链接。
-
-
什么时候把 node_id 也算进 key:
当节点被声明为非幂等,或者内部有UNIQUE_ID这种隐含输入时,会把node_id加进签名(见comfy_execution/caching.py:18-23,116),避免“看起来一样”的节点被错误复用。
最后通过 to_hashable 转成可哈希结构(tuple/frozenset 等),作为最终键值。
结果是:
-
输入完全相同 → key 相同 → 直接命中; -
任意一个上游节点输入或参数变化 → 指纹变了 → key 不同 → 不会复用旧结果。
2.2 对象缓存:用 (node_id, class_type)
节点对象的键由 CacheKeySetID 构造(comfy_execution/caching.py:66-80),逻辑简单:
-
key = (node_id, class_type)。
读取时:
obj = caches.objects.get(unique_id)
if obj is None:
obj = class_def()
caches.objects.set(unique_id, obj)
对象缓存存在的目的只有一个:同一个 workflow 执行过程中,不要重复 new 节点实例。
3. 缓存容器:Basic / Hierarchical / LRU / RAM / Null
有了 key,还需要一个合理的「容器」和「驱逐策略」。
主要类在 comfy_execution/caching.py:
-
BasicCache:基础容器,提供set_prompt / clean_unused / get / set等; -
HierarchicalCache:按“父节点 → 子图”构建层级缓存; -
LRUCache:在 Basic + Hierarchical 的基础上增加代际 LRU; -
RAMPressureCache:在 LRU 的基础上增加 RAM 压力驱逐; -
NullCache:空实现(禁用缓存)。
核心接口统一在 BasicCache:
def clean_unused(self):
self._clean_cache()
self._clean_subcaches()
层级相关逻辑在 HierarchicalCache,用来支持“子图单独分区缓存”。
3.1 层级缓存:怎么定位到某个节点的分区
HierarchicalCache 通过 parent 链定位子缓存,代码在 comfy_execution/caching.py:242-269:
def _get_cache_for(self, node_id):
parent_id = self.dynprompt.get_parent_node_id(node_id)
...
for parent_id in reversed(hierarchy):
cache = cache._get_subcache(parent_id)
if cache is None: return None
return cache
def get(self, node_id):
cache = self._get_cache_for(node_id)
if cache is None: return None
return cache._get_immediate(node_id)
def set(self, node_id, value):
cache = self._get_cache_for(node_id)
assert cache is not None
cache._set_immediate(node_id, value)
含义很直接:
-
根节点存在当前 cache; -
某些节点生成子图,会在该节点下挂一个子 cache; -
读写时,先通过 parent 链找到对应的子 cache 再读写。
clean_unused() 里除了清除不用的 key,还会删除没用到的子 cache 分区(_clean_subcaches())。
4. 执行循环中的使用路径
缓存不是“挂在那就完事了”,它在执行循环中有比较明确的调用点。
4.1 设置 prompt + 清理
启动一次执行时(execution.py:681-685):
is_changed_cache = IsChangedCache(prompt_id, dynamic_prompt, self.caches.outputs)
for cache in self.caches.all:
await cache.set_prompt(dynamic_prompt, prompt.keys(), is_changed_cache)
cache.clean_unused()
这里做了三件事:
-
给当前 prompt 生成一个 IsChangedCache,为 key 计算提供 is_changed 结果; -
对 outputs / objects 各自执行一次 set_prompt(不同策略实现不同); -
紧接着执行 clean_unused(),做一次基于“当前 prompt 键集合”的清理。
4.2 节点执行前后:cache 命中与写入
在节点执行路径中:
-
执行前:优先尝试从 outputs命中(execution.py:686-701); -
执行后:将 (ui, outputs)作为CacheEntry写入outputs(execution.py:568-571)。
为了简化,这里只看抽象行为:
-
命中 → 跳过计算,直接拿值; -
未命中 → 正常跑一遍,将结果塞回缓存。
4.3 每个节点之后的 RAM 轮询
如果是 RAM_PRESSURE 模式,执行完每个节点都会触发一次内存检查(execution.py:720):
self.caches.outputs.poll(ram_headroom=self.cache_args["ram"])
只有 RAMPressureCache 实现了 poll,其他模式下这个调用等同空操作。
5. CLASSIC:默认层级缓存
先看默认策略:CLASSIC。
5.1 初始化与结构
CacheSet.init_classic_cache(execution.py:97-126):
class CacheType(Enum):
CLASSIC = 0
LRU = 1
NONE = 2
RAM_PRESSURE = 3
class CacheSet:
def init_classic_cache(self):
self.outputs = HierarchicalCache(CacheKeySetInputSignature)
self.objects = HierarchicalCache(CacheKeySetID)
可以看到:
-
outputs用层级缓存 + 输入签名做 key; -
objects也用层级缓存 + (node_id, class_type) 做 key; -
不涉及 LRU 或 RAM 驱逐。
5.2 CLASSIC 的过期机制:完全由「当前 prompt」驱动
CLASSIC 模式不做容量和时间管理,它只有两种“失效”方式:
-
提示切换 / 执行前清理
clean_unused() 的核心逻辑(comfy_execution/caching.py:172-195):
def clean_unused(self):
self._clean_cache()
self._clean_subcaches()
-
_clean_cache():把不在“当前 prompt 键集合”的项删掉; -
_clean_subcaches():把不再需要的子缓存分区删掉。
在 execution.py:681-685 每次绑定新 prompt 时,都会执行这一步。结果是:
-
换了一个新的 workflow / prompt,旧 workflow 的 outputs / objects 都会被视为“未使用”,被清理掉; -
CLASSIC 不会跨不同 prompt 保留旧 workflow 的缓存。
-
键不命中(指纹失效)
is_changed 的计算在 execution.py:48-89,当节点输入更新时,指纹会变化;CacheKeySetInputSignature 在构造键时会把这个指纹带进去(comfy_execution/caching.py:115-127)。因此只要:
-
参数 / 输入 / 上游节点的任一变化 → key 改变 → 旧值自然不命中。
5.3 CLASSIC 明确不会做的事
在 CLASSIC 下:
-
不做 LRU 容量控制:没有 max_size,也没有代际淘汰逻辑; -
不做 RAM 压力驱逐: poll()是空的,执行循环里即使调用了也什么都不干; -
不做 TTL:不看时间,只看 prompt 键集合。
对应的注释已经在参考内容中点得很清楚,这里就不重复堆代码了。
5.4 在频繁切换 workflow / 模型时的表现
结合上面的机制,总结一下 CLASSIC 在多 workflow 场景下的行为:
-
执行新工作流时:
set_prompt + clean_unused会直接把“不在新 prompt 键集合里”的缓存项(包括对象子缓存)全部清掉; -
模型 / LoRA 变化:
即便节点 ID 不变,输入签名和 is_changed 指纹不同,也会生成新键;旧条目先不命中,随后在下一次 prompt 绑定时被清空; -
回切旧 workflow:
因为在上一次切换时已经把旧 workflow 相关缓存清干净了,所以基本等于重新计算。
适用场景:
-
workflow 比较固定; -
主要想在“一次执行当中”复用中间结果,不在意跨 prompt 的持久化。
6. LRU:代际 LRU 控制 outputs 尺寸
第二种策略是 LRU,主要解决的问题是:在允许跨 prompt 复用输出的前提下,限制缓存总量,避免无限膨胀。
6.1 初始化:只作用于 outputs
CacheSet.init_lru_cache(execution.py:127-135):
def init_lru_cache(self, cache_size):
self.outputs = LRUCache(CacheKeySetInputSignature, max_size=cache_size)
self.objects = HierarchicalCache(CacheKeySetID)
注意几点:
-
LRU 只作用于 outputs; -
objects 仍然用 HierarchicalCache,不受 LRU 驱逐; -
启动方式: --cache-lru N,且N > 0。
6.2 LRU 的代际设计
LRUCache 的骨架逻辑在 comfy_execution/caching.py:299-337:
class LRUCache(BasicCache):
def __init__(self, key_class, max_size=100):
self.max_size = max_size
self.min_generation = 0
self.generation = 0
self.used_generation = {}
self.children = {}
async def set_prompt(...):
self.generation += 1
for node_id in node_ids:
self._mark_used(node_id)
def get(self, node_id):
self._mark_used(node_id)
return self._get_immediate(node_id)
def _mark_used(self, node_id):
cache_key = self.cache_key_set.get_data_key(node_id)
if cache_key is not None:
self.used_generation[cache_key] = self.generation
含义:
-
有一个全局代数 generation,每次绑定新 promptgeneration += 1; -
每次: -
绑定 prompt 时,会把该 prompt 内所有节点标记为“在当前代被使用”; -
get/set时更新条目的used_generation[key]为当前代。
-
6.3 容量驱逐:按「最老代」逐步清理
clean_unused() 的一部分逻辑在 comfy_execution/caching.py:314-323:
def clean_unused(self):
while len(self.cache) > self.max_size and self.min_generation < self.
generation:
self.min_generation += 1
to_remove = [key for key in self.cache if self.used_generation[key] < self.
min_generation]
for key in to_remove:
del self.cache[key]
del self.used_generation[key]
if key in self.children:
del self.children[key]
self._clean_subcaches()
简单归纳一下:
-
只要 len(cache) > max_size,就逐步提升min_generation; -
每提升一代,就删除“最近使用代 < min_generation”的条目; -
同步清除掉和这些 key 绑定的子缓存引用; -
清理完再执行 _clean_subcaches()做层级清扫。
6.4 子图分区与代际配合
子缓存创建时会显式标记父节点和子节点“被使用”,避免刚刚生成的子图被误删。代码在 comfy_execution/caching.py:338-349:
async def ensure_subcache_for(self, node_id, children_ids):
await super()._ensure_subcache(node_id, children_ids)
await self.cache_key_set.add_keys(children_ids)
self._mark_used(node_id)
cache_key = self.cache_key_set.get_data_key(node_id)
self.children[cache_key] = []
for child_id in children_ids:
self._mark_used(child_id)
self.children[cache_key].append(self.cache_key_set.get_data_key(child_id))
return self
配合前面提到的层级结构,就形成了“按 workflow 子图分区 + LRU 按代际清理”的整体行为。
6.5 触发时机和行为总结
在 LRU 模式下:
-
每次绑定 prompt:
generation += 1,标记当前 prompt 的节点使用代为当前代; -
每次 get/set:
更新条目的used_generation为当前代; -
每次 clean_unused():-
当 len(cache) > max_size时,通过提升min_generation清除旧代条目; -
额外清理无用子缓存。
-
特点:
-
可以跨 prompt 保留一部分中间结果; -
由 max_size控制缓存上限; -
没有 RAM 压力感知: poll()依然不做事。
适用场景:
-
希望在多 workflow 之间部分复用缓存; -
但机器内存有限,需要给 outputs 一个明确的容量上限; -
对 RAM 细粒度控制没有强需求,或使用的是物理机 / 内存足够的环境。
7. RAM_PRESSURE:按可用内存压力驱逐
第三种策略是 RAM_PRESSURE,对应类是 RAMPressureCache。它继承自 LRUCache,但不按 max_size 做驱逐,而是:
-
通过 poll(ram_headroom),在可用内存不足时按“OOM 评分”驱逐条目。
7.1 初始化:objects 仍然是层级缓存
CacheSet.init_ram_cache(execution.py:131-133):
def init_ram_cache(self, min_headroom):
self.outputs = RAMPressureCache(CacheKeySetInputSignature)
self.objects = HierarchicalCache(CacheKeySetID)
注意两个点:
-
RAM 模式下,只有 outputs 会按 RAM 压力驱逐; -
objects 不参与 RAM 驱逐,逻辑完全和 CLASSIC/LRU 下相同。
7.2 poll:可用 RAM 检测 + OOM 评分驱逐
poll 的主逻辑在 comfy_execution/caching.py:384-454:
def poll(self, ram_headroom):
def _ram_gb():
# 优先 cgroup v2/v1,失败回退 psutil
...
if _ram_gb() > ram_headroom: return
gc.collect()
if _ram_gb() > ram_headroom: return
clean_list = []
for key, (outputs, _), in self.cache.items():
oom_score = RAM_CACHE_OLD_WORKFLOW_OOM_MULTIPLIER ** (self.generation -
self.used_generation[key])
ram_usage = RAM_CACHE_DEFAULT_RAM_USAGE
def scan_list_for_ram_usage(outputs):
nonlocal ram_usage
...
scan_list_for_ram_usage(outputs)
oom_score *= ram_usage
bisect.insort(clean_list, (oom_score, self.timestamps[key], key))
while _ram_gb() < ram_headroom * RAM_CACHE_HYSTERESIS and clean_list:
_, _, key = clean_list.pop()
del self.cache[key]
gc.collect()
流程拆一下:
-
获取可用 RAM
_ram_gb()的实现优先读取 cgroup 的限制:-
cgroup v2: memory.max/memory.current; -
cgroup v1: memory.limit_in_bytes/memory.usage_in_bytes; -
都失败才回退 psutil.virtual_memory().available。
这解决了容器环境下“宿主机内存大,容器实际被限制”的常见问题。
-
-
阈值和 GC
-
如果可用 RAM > ram_headroom,直接返回; -
否则先跑一次 gc.collect(); -
再测一次 RAM,如果还是不足,进入驱逐流程。
-
-
为每个条目计算 OOM 评分
-
初始 oom_score = RAM_CACHE_OLD_WORKFLOW_OOM_MULTIPLIER ** (generation - used_generation[key]):-
大概意思是:越久没被用,分数指数级放大(默认倍数为 1.3,见 comfy_execution/caching.py:365);
-
-
初始 ram_usage = RAM_CACHE_DEFAULT_RAM_USAGE(0.1,见360); -
递归遍历 outputs列表:-
CPU tensor: numel * element_size * 0.5(认为 CPU 上的 tensor 价值更高,折半); -
自定义对象:如果实现了 get_ram_usage()就加上它;
-
-
最后 oom_score *= ram_usage,得到综合评分。
所有条目按
(oom_score, timestamp, key)排序,放入clean_list。 -
-
按迟滞阈值逐个删除
-
只要 _ram_gb() < ram_headroom * RAM_CACHE_HYSTERESIS,就从clean_list末尾 pop 一个 key 并删除; -
每删一个都跑一次 gc.collect(); -
迟滞倍数 RAM_CACHE_HYSTERESIS默认 1.1,避免“刚删完又马上触发清理”的抖动。
-
-
访问时间戳
访问时会更新 timestamps(
comfy_execution/caching.py:376-382):
def set(self, node_id, value):
self.timestamps[self.cache_key_set.get_data_key(node_id)] = time.time()
super().set(node_id, value)
def get(self, node_id):
self.timestamps[self.cache_key_set.get_data_key(node_id)] = time.time()
return super().get(node_id)
在 oom_score 一样时,timestamp 起到“最近访问优先保留”的作用。
7.3 提示绑定下的行为:不清理 outputs
RAM 模式下,clean_unused() 的行为与 CLASSIC 不同(见参考说明):
-
RAM 模式:
clean_unused()只做子缓存分区清理,不会删掉“当前 prompt 未使用”的 outputs 条目; -
CLASSIC 模式:
clean_unused()会同时删掉当前 prompt 未用到的 outputs 条目。
结果是:
-
RAM 模式可以跨多个 workflow 长时间保留中间结果; -
只有在 RAM 不够时才做清退。
7.4 容器环境下需要注意的点
的 AutoDL 中,用 psutil.virtual_memory().available,在容器里看到的是宿主机内存,而不是容器的限额,导致永远“不触发回收”,最后 OOM。
适用场景:
-
多 workflow / 多模型 / 多 LoRA 同时存在,且希望尽可能长时间复用输出结果; -
机器内存有限,但更关心“不 OOM”,而不是一个固定的 max_size; -
特别适合容器环境(K8s / AutoDL 等),配合 --cache-ram <GB>。
8. 一些落地建议
最后,用一段比较直接的建议收尾。
8.1 单模型 / workflow 稳定:用 CLASSIC 即可
特点:
-
工作流基本不换; -
主要希望避免一次执行中的重复计算(比如多次 preview)。
用默认 CLASSIC:
-
结构简单; -
不参与复杂的跨 prompt 保留; -
不需要担心 LRU 尺寸和 RAM 阈值调参。
8.2 多 workflow + 控制缓存尺寸:用 LRU
场景:
-
有多套 workflow,在它们之间来回切; -
机器内存不是特别大,希望 outputs 不要无限膨胀; -
又希望某些常用子图能被复用。
做法:
-
启动时加 --cache-lru N(N 先给一个相对保守的值,比如几百到几千条,看内存曲线再调); -
让 LRUCache用代际 + max_size 帮你自动做“近期常用保留、早期冷门清理”。
8.3 多模型 / 多 LoRA / 容器环境:优先 RAM_PRESSURE
场景:
-
容器 / 云平台(K8s、AutoDL 等); -
有较多模型、LoRA 和 workflow 混用; -
内存被容器限制,容易因爆 RAM 掉进 OOM。
做法:
-
启动时用 --cache-ram <GB>配一个“希望保留的 RAM 余量”;-
比如容器给了 32GB,就设在 16–24GB 看情况;
-
-
让 RAMPressureCache:-
最大程度保留各个 workflow 的中间结果(包括应用 LoRA 后的模型对象等); -
在内存不足时,根据 OOM 评分优先清旧代、大内存条目。
-
注意一点:即使有容器优化,如果平台本身的 cgroup 挂得不标准,_ram_gb() 的结果还是有可能偏离实际,这一点要结合平台文档确认。
8.4 完全不想折腾:直接关缓存
如果你:
-
环境受限; -
或者调试阶段不想被缓存影响行为理解;
可以直接用:
-
--cache-none
对应CacheType.NONE,CacheSet.init_null_cache()走NullCache,所有 get/set 都是 no-op。
代价就是:每次执行都完全重新算一遍。
9. 小结一下
把上面的内容压缩成几句话:
-
ComfyUI 有两路缓存:
-
outputs存中间结果,真正用来省算力; -
objects存节点实例,只减少 Python 对象构造开销。
-
-
键体系:
-
输出:输入签名 + is_changed 指纹(+ 条件下的 node_id); -
对象:**(node_id, class_type)**。
-
-
四种策略:
-
CLASSIC:默认层级缓存,按当前 prompt 键集合清理,不做 LRU / RAM 驱逐; -
LRU:只对 outputs 做代际 LRU,配合 max_size控制容量; -
RAM_PRESSURE:在 LRU 基础上加 RAM 压力驱逐,在内存不足时按 OOM 评分清理; -
NONE:彻底关掉缓存。
-
-
在多模型 / 多 workflow / 多 LoRA 场景下:
-
对象缓存 objects始终是层级缓存,不参与 LRU / RAM 驱逐,在每次绑定 prompt 时按键集合清理; -
模型权重 / LoRA 的真实驻留由模型管理层控制; -
真正要关心的是:如何选择 outputs 的策略,让中间结果既能有效复用,又不会把内存打爆。
-
如果我们在一个复杂的工作流环境里跑 ComfyUI,建议先搞清楚自己处于哪种场景,再结合上面的策略选项,把缓存调成我们能控制的状态,而不是让它在后台「自动长草」。
以上。