06 · 动态拓扑调度
普通 DAG 可以一次拓扑排序后逐个执行。ComfyUI 不行:某节点 stage 之后可能发现还需要一个懒输入,也可能返回运行时子图,或挂起一个异步任务。调度器必须允许选中、退回、增加依赖、再选中。
两层调度,不要混淆
PromptQueue 决定“哪个 Prompt 先跑” 进程级、heap priority
ExecutionList 决定“当前 Prompt 哪个节点跑” 请求级、动态拓扑本章只讲第二层。
PromptExecutor 的外循环
execution.pyL730-844一次 Prompt 的执行生命周期。简化后:
with torch.inference_mode():
dynamic_prompt = DynamicPrompt(prompt)
prepare_caches(dynamic_prompt)
execution_list = ExecutionList(dynamic_prompt, output_cache)
for output in good_outputs:
execution_list.add_node(output)
while not execution_list.is_empty():
node = await execution_list.stage_node_execution()
result = await execute(node)
if result is PENDING:
execution_list.unstage_node_execution()
elif result is SUCCESS:
execution_list.complete_node_execution()
else:
break这里最重要的不是 while,而是三态返回:SUCCESS / FAILURE / PENDING。PENDING 代表“没有失败,但目前还不能把节点从图中删除”。
TopologicalSort 保存什么
概念上有:
pendingNodes 仍需完成的节点
blockCount 每个节点还有多少强上游未完成
blocking 一个上游完成后会释放哪些下游
externalBlocks 尚未结束的外部异步阻塞数
unblockedEvent asyncio 等待唤醒当加入一个输出节点,调度器递归添加它的强依赖。缓存命中的上游可直接提供结果,不必成为 pending 计算。
comfy_execution/graph.pyL106-191动态可修改的拓扑基础结构。stage / unstage / complete
stage
stage_node_execution() 找到 blockCount == 0 的 ready nodes,从中选一个,写入 staged_node_id。同一时刻最多一个节点处于 staged。
unstage
如果执行函数返回 PENDING,清除 staged 标记,但不从 pending 图删除节点。执行器已在此前添加新的依赖或 external block,下一次循环会先处理它们。
complete
成功时 pop_node() 释放下游 block,清理为该消费者维护的 execution-local cache link,然后清空 staged。
这个小状态机是动态能力的支点。若 stage 后只允许成功或失败,懒求值和 expand 都必须被塞进节点内部,运行时就失去可见性。
ready nodes 不代表随便挑
任何 ready node 都满足依赖正确性,ux_friendly_pick_node() 再做体验启发式:
- ready 中有输出节点,优先它;
- ready 中有 async 节点,优先尽早启动;
- 某节点直接阻塞输出节点,优先它;
- 某节点距离输出两跳,优先它;
- 否则取列表第一个。
为什么输出优先不会缩短关键路径,却仍值得做
例如 VAEDecode → PreviewImage 已 ready,同时另一条远处分支也 ready。先执行 PreviewImage 让用户更早看到结果,总计算量不变,体感延迟却下降。
为什么 async 优先
异步节点往往提交远端任务或 I/O。越早启动,它越能和后续本地计算重叠。注意它不是并行执行任意 Python 节点,而是让 coroutine 进入 pending 后释放调度循环。
execution-local cache link
ExecutionList 不只存拓扑,还给每个消费者维护上游值引用:
execution_cache[to_node][from_node] = CacheEntry
execution_cache_listeners[from_node] = {(to_node, socket), ...}节点从输出 cache 被逐出时,已经建立的消费者仍可能需要它。局部引用保证本次依赖链稳定;读取时还会 write-back 到主 cache。PromptModelTracker.add 可作为 output link callback,发现链路中出现的 ModelPatcher。
这是缓存淘汰与正确执行之间的隔离层:全局策略可以回收,当前正在运行的边仍持有必要对象。
缓存节点如何进入调度
Prompt 开始时,执行器:
- 为所有原始节点计算/登记当前签名;
- 并发查询输出 cache;
- 发送
execution_cached节点列表; - 建立 ExecutionList 时把缓存可用性纳入边解析。
当某节点真正轮到 execute(),入口仍再次查询 cache。这是防守性设计:动态子图或执行期间的 cache 更新可能让状态与初始扫描不同。
一次局部重算的调度过程
正向提示词改变,负向不变:
调度器初始 pending 闭包仍从 Save 展开,但 cached nodes 可立即作为满足的边。第一个真正 ready 的可能就是 Positive encode,而非 Checkpoint。
外部阻塞
如果没有 ready node 但 externalBlocks > 0,stage_node_execution() 等待 unblockedEvent。异步任务完成后回调减少阻塞并 set event。
如果没有 ready node且没有 external block,则不是“再等等”,而是依赖环。代码溶解掉所有可消除节点,剩余的就是环中节点,并尽量把错误归因到非静态、可显示的父节点。
comfy_execution/graph.pyL229-247等待外部任务与运行时环错误。运行时新增依赖
懒输入
节点被 stage 后,check_lazy_status() 说还需要 fallback_image:
make_input_strong_link(current_node, 'fallback_image')
return PENDING
unstage current_node
execute fallback branch
stage current_node againexpand 子图
节点返回新图,执行器添加 ephemeral nodes、把子图输出与父节点连接,记录 pending results,父节点 PENDING。子图算完后,父节点再次 stage,解析结果链接并完成。
两者都证明“拓扑不是输入时一次决定的”。
错误路径
任一节点 FAILURE 时:
- 外循环停止,不继续下游;
handle_execution_error发送结构化消息;executed与current_outputs让客户端知道已完成/已有缓存的范围;- OOM 时执行入口会卸载已加载模型并附加提示;
- finally 仍结束 PromptModelTracker 和 cache provider 生命周期。
不会自动回滚已写文件或远端 API 副作用。节点作者应在不可逆操作前完成验证,并设计幂等键。
torch.inference_mode() 的边界
整个 Prompt 主循环位于 torch.inference_mode(),关闭 autograd 记录,符合推理场景。自定义节点若试图训练或依赖梯度,必须显式处理上下文;核心运行时不以训练工作负载为默认。
RAM pressure 在哪里插入
RAMPressure cache 模式下,每完成或挂起一个节点,外循环检查可用系统内存:
- 先回收不活跃 workflow 缓存;
- 仍不足时回收大的活跃中间结果;
- 再考虑释放 pinned memory;
- 必要时做完整 active eviction。
把检查放在节点边界,而不是任意张量操作中,既可预测又能利用刚完成节点的生命周期信息。
调度器的明确边界
- 它不是多 GPU 任务调度器;设备选择在模型管理层。
- 它默认一次 stage 一个节点;async 允许 I/O 重叠,不等于任意节点并行。
- 它保证依赖顺序,不保证节点副作用可回滚。
- 它理解缓存条目和链接,却不理解张量语义。
- UX heuristic 影响先后,不影响依赖正确性。
可迁移的设计方法
- 动态 DAG 用
stage → pending/complete,不要强迫一次性拓扑序。 - 正确性选择与体验选择分层:先 ready set,再 heuristic。
- 全局可淘汰缓存之外,给执行中消费者保留局部引用。
- 外部 async block 用显式计数与事件,而不是 busy polling。
- 静态验证之后仍在运行时检查环,因为图可能增长。