From 7d76a4447e382db4c5fac15618920f7a69fa9b35 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 02:36:40 +0100 Subject: [PATCH 01/39] Sanitize execution cache inputs --- comfy_execution/caching.py | 51 +++++++++++++++++++++++++++------- comfy_execution/graph_utils.py | 11 ++++++-- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 78212bde3..53d8e5bdb 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -51,17 +51,48 @@ class Unhashable: def __init__(self): self.value = float("NaN") -def to_hashable(obj): - # So that we don't infinitely recurse since frozenset and tuples - # are Sequences. - if isinstance(obj, (int, float, str, bool, bytes, type(None))): + +def _sanitize_signature_input(obj, depth=0, max_depth=32): + if depth >= max_depth: + return Unhashable() + + obj_type = type(obj) + if obj_type in (int, float, str, bool, bytes, type(None)): return obj - elif isinstance(obj, Mapping): - return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items())]) - elif isinstance(obj, Sequence): - return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) + elif obj_type is dict: + sanitized = [] + for key in sorted(obj.keys(), key=lambda x: (type(x).__module__, type(x).__qualname__, repr(x))): + sanitized.append((_sanitize_signature_input(key, depth + 1, max_depth), + _sanitize_signature_input(obj[key], depth + 1, max_depth))) + return tuple(sanitized) + elif obj_type in (list, tuple): + return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + elif obj_type in (set, frozenset): + sanitized_items = [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + sanitized_items.sort(key=repr) + return tuple(sanitized_items) + else: + # Execution-cache signatures should be built from prompt-safe values. + # If a custom node injects a runtime object here, mark it unhashable so + # the node won't reuse stale cache entries across runs, but do not walk + # the foreign object and risk crashing on custom container semantics. + return Unhashable() + +def to_hashable(obj): + # Restrict recursion to plain built-in containers. Some custom nodes insert + # runtime objects into prompt inputs for dynamic graph paths; walking those + # objects as generic Mappings / Sequences is unsafe and can destabilize the + # cache signature builder. + obj_type = type(obj) + if obj_type in (int, float, str, bool, bytes, type(None)): + return obj + elif obj_type is dict: + return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items(), key=lambda kv: repr(kv[0]))]) + elif obj_type in (list, tuple): + return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) + elif obj_type in (set, frozenset): + return frozenset([to_hashable(i) for i in obj]) else: - # TODO - Support other objects like tensors? return Unhashable() class CacheKeySetID(CacheKeySet): @@ -123,7 +154,7 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, inputs[key])) + signature.append((key, _sanitize_signature_input(inputs[key]))) return signature # This function returns a list of all ancestors of the given node. The order of the list is diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 496d2c634..75e4bc522 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,11 +1,16 @@ def is_link(obj): - if not isinstance(obj, list): + # Prompt links produced by the frontend / GraphBuilder are plain Python + # lists in the form [node_id, output_index]. Some custom-node paths can + # inject foreign runtime objects into prompt inputs during on-prompt graph + # rewriting or subgraph construction. Be strict here so cache signature + # building never tries to treat list-like proxy objects as links. + if type(obj) is not list: return False if len(obj) != 2: return False - if not isinstance(obj[0], str): + if type(obj[0]) is not str: return False - if not isinstance(obj[1], int) and not isinstance(obj[1], float): + if type(obj[1]) not in (int, float): return False return True From 2adde5a0e1f6abbb19c8aa4f3a71b93c7d47abf6 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 06:36:06 +0100 Subject: [PATCH 02/39] Keep container types in sanitizer --- comfy_execution/caching.py | 77 +++++++++++++++++++++++++++------- comfy_execution/graph_utils.py | 2 +- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 53d8e5bdb..d4bd2dee1 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -1,7 +1,6 @@ import asyncio import bisect import gc -import itertools import psutil import time import torch @@ -52,6 +51,37 @@ class Unhashable: self.value = float("NaN") +def _sanitized_sort_key(obj, depth=0, max_depth=32): + if depth >= max_depth: + return ("MAX_DEPTH",) + + obj_type = type(obj) + if obj_type is Unhashable: + return ("UNHASHABLE",) + elif obj_type in (int, float, str, bool, bytes, type(None)): + return (obj_type.__module__, obj_type.__qualname__, repr(obj)) + elif obj_type is dict: + items = [ + ( + _sanitized_sort_key(k, depth + 1, max_depth), + _sanitized_sort_key(v, depth + 1, max_depth), + ) + for k, v in obj.items() + ] + items.sort() + return ("dict", tuple(items)) + elif obj_type is list: + return ("list", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) + elif obj_type is tuple: + return ("tuple", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) + elif obj_type is set: + return ("set", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) + elif obj_type is frozenset: + return ("frozenset", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) + else: + return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") + + def _sanitize_signature_input(obj, depth=0, max_depth=32): if depth >= max_depth: return Unhashable() @@ -60,17 +90,28 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: - sanitized = [] - for key in sorted(obj.keys(), key=lambda x: (type(x).__module__, type(x).__qualname__, repr(x))): - sanitized.append((_sanitize_signature_input(key, depth + 1, max_depth), - _sanitize_signature_input(obj[key], depth + 1, max_depth))) - return tuple(sanitized) - elif obj_type in (list, tuple): + sanitized_items = [ + ( + _sanitize_signature_input(key, depth + 1, max_depth), + _sanitize_signature_input(value, depth + 1, max_depth), + ) + for key, value in obj.items() + ] + sanitized_items.sort( + key=lambda kv: ( + _sanitized_sort_key(kv[0], depth + 1, max_depth), + _sanitized_sort_key(kv[1], depth + 1, max_depth), + ) + ) + return {key: value for key, value in sanitized_items} + elif obj_type is list: + return [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + elif obj_type is tuple: return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) - elif obj_type in (set, frozenset): - sanitized_items = [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] - sanitized_items.sort(key=repr) - return tuple(sanitized_items) + elif obj_type is set: + return {_sanitize_signature_input(item, depth + 1, max_depth) for item in obj} + elif obj_type is frozenset: + return frozenset(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) else: # Execution-cache signatures should be built from prompt-safe values. # If a custom node injects a runtime object here, mark it unhashable so @@ -87,11 +128,15 @@ def to_hashable(obj): if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: - return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items(), key=lambda kv: repr(kv[0]))]) - elif obj_type in (list, tuple): - return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) - elif obj_type in (set, frozenset): - return frozenset([to_hashable(i) for i in obj]) + return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) + elif obj_type is list: + return ("list", tuple(to_hashable(i) for i in obj)) + elif obj_type is tuple: + return ("tuple", tuple(to_hashable(i) for i in obj)) + elif obj_type is set: + return ("set", frozenset(to_hashable(i) for i in obj)) + elif obj_type is frozenset: + return ("frozenset", frozenset(to_hashable(i) for i in obj)) else: return Unhashable() diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 75e4bc522..28f53ad95 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -10,7 +10,7 @@ def is_link(obj): return False if type(obj[0]) is not str: return False - if type(obj[1]) not in (int, float): + if type(obj[1]) is not int: return False return True From 39086890e23c1aa8fb6957a10e16a19bd5d51bc0 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 06:56:49 +0100 Subject: [PATCH 03/39] Fix sanitize_signature_input --- comfy_execution/caching.py | 3 +++ comfy_execution/graph_utils.py | 1 + 2 files changed, 4 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index d4bd2dee1..02c9028af 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -52,6 +52,7 @@ class Unhashable: def _sanitized_sort_key(obj, depth=0, max_depth=32): + """Build a deterministic sort key from sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) @@ -83,6 +84,7 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): def _sanitize_signature_input(obj, depth=0, max_depth=32): + """Sanitize prompt-signature inputs without recursing into opaque runtime objects.""" if depth >= max_depth: return Unhashable() @@ -120,6 +122,7 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): return Unhashable() def to_hashable(obj): + """Convert prompt-safe built-in values into a stable hashable representation.""" # Restrict recursion to plain built-in containers. Some custom nodes insert # runtime objects into prompt inputs for dynamic graph paths; walking those # objects as generic Mappings / Sequences is unsafe and can destabilize the diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 28f53ad95..573645c7c 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,4 +1,5 @@ def is_link(obj): + """Return True if obj is a plain prompt link of the form [node_id, output_index].""" # Prompt links produced by the frontend / GraphBuilder are plain Python # lists in the form [node_id, output_index]. Some custom-node paths can # inject foreign runtime objects into prompt inputs during on-prompt graph From 4d9516b909e6ad8cea68b306619594f117b77617 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 07:06:39 +0100 Subject: [PATCH 04/39] Fix caching sanitization logic --- comfy_execution/caching.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 02c9028af..b25673463 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -48,6 +48,7 @@ class CacheKeySet(ABC): class Unhashable: def __init__(self): + """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") @@ -186,6 +187,7 @@ class CacheKeySetInputSignature(CacheKeySet): return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): + """Build the cache-signature fragment for a node's immediate inputs.""" if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. return [float("NaN")] From 880b51ac4f4bdd66b2d697a3dea48a1c66bdd27f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 09:46:27 +0100 Subject: [PATCH 05/39] Harden to_hashable against cycles --- comfy_execution/caching.py | 36 +++++++++++++++---- .../execution_test/caching_hashable_test.py | 33 +++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests-unit/execution_test/caching_hashable_test.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b25673463..b690d03a8 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -122,8 +122,14 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): # the foreign object and risk crashing on custom container semantics. return Unhashable() -def to_hashable(obj): +def to_hashable(obj, depth=0, max_depth=32, seen=None): """Convert prompt-safe built-in values into a stable hashable representation.""" + if depth >= max_depth: + return Unhashable() + + if seen is None: + seen = set() + # Restrict recursion to plain built-in containers. Some custom nodes insert # runtime objects into prompt inputs for dynamic graph paths; walking those # objects as generic Mappings / Sequences is unsafe and can destabilize the @@ -131,16 +137,32 @@ def to_hashable(obj): obj_type = type(obj) if obj_type in (int, float, str, bool, bytes, type(None)): return obj - elif obj_type is dict: - return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) + + if obj_type in (dict, list, tuple, set, frozenset): + obj_id = id(obj) + if obj_id in seen: + return Unhashable() + seen = seen | {obj_id} + + if obj_type is dict: + return ( + "dict", + frozenset( + ( + to_hashable(k, depth + 1, max_depth, seen), + to_hashable(v, depth + 1, max_depth, seen), + ) + for k, v in obj.items() + ), + ) elif obj_type is list: - return ("list", tuple(to_hashable(i) for i in obj)) + return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i) for i in obj)) + return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is set: - return ("set", frozenset(to_hashable(i) for i in obj)) + return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i) for i in obj)) + return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) else: return Unhashable() diff --git a/tests-unit/execution_test/caching_hashable_test.py b/tests-unit/execution_test/caching_hashable_test.py new file mode 100644 index 000000000..31d20de10 --- /dev/null +++ b/tests-unit/execution_test/caching_hashable_test.py @@ -0,0 +1,33 @@ +from comfy_execution.caching import Unhashable, to_hashable + + +def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): + cyclic_list = [] + cyclic_list.append(cyclic_list) + + result = to_hashable(cyclic_list) + + assert result[0] == "list" + assert len(result[1]) == 1 + assert isinstance(result[1][0], Unhashable) + + +def test_to_hashable_returns_unhashable_when_max_depth_is_reached(): + nested = current = [] + for _ in range(32): + next_item = [] + current.append(next_item) + current = next_item + + result = to_hashable(nested) + + depth = 0 + current = result + while isinstance(current, tuple): + assert current[0] == "list" + assert len(current[1]) == 1 + current = current[1][0] + depth += 1 + + assert depth == 32 + assert isinstance(current, Unhashable) From 4b431ffc278d5be231d97ace5570b5a459bfb883 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 09:57:22 +0100 Subject: [PATCH 06/39] Add missing docstrings --- comfy_execution/caching.py | 2 ++ tests-unit/execution_test/caching_hashable_test.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b690d03a8..d6561ab94 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,6 +47,8 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: + """Hashable sentinel used when an input cannot be safely represented.""" + def __init__(self): """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") diff --git a/tests-unit/execution_test/caching_hashable_test.py b/tests-unit/execution_test/caching_hashable_test.py index 31d20de10..5613ac6c4 100644 --- a/tests-unit/execution_test/caching_hashable_test.py +++ b/tests-unit/execution_test/caching_hashable_test.py @@ -2,6 +2,7 @@ from comfy_execution.caching import Unhashable, to_hashable def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): + """Ensure self-referential built-in containers terminate as Unhashable.""" cyclic_list = [] cyclic_list.append(cyclic_list) @@ -13,6 +14,7 @@ def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): def test_to_hashable_returns_unhashable_when_max_depth_is_reached(): + """Ensure deeply nested built-in containers stop at the configured depth limit.""" nested = current = [] for _ in range(32): next_item = [] From 6728d4d4391ae42ab3a1e762a1acc501f2311ebd Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:11:04 +0100 Subject: [PATCH 07/39] Revert "Harden to_hashable against cycles" This reverts commit 880b51ac4f4bdd66b2d697a3dea48a1c66bdd27f. --- comfy_execution/caching.py | 36 ++++--------------- .../execution_test/caching_hashable_test.py | 35 ------------------ 2 files changed, 7 insertions(+), 64 deletions(-) delete mode 100644 tests-unit/execution_test/caching_hashable_test.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index d6561ab94..3e3bfcdab 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -124,14 +124,8 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): # the foreign object and risk crashing on custom container semantics. return Unhashable() -def to_hashable(obj, depth=0, max_depth=32, seen=None): +def to_hashable(obj): """Convert prompt-safe built-in values into a stable hashable representation.""" - if depth >= max_depth: - return Unhashable() - - if seen is None: - seen = set() - # Restrict recursion to plain built-in containers. Some custom nodes insert # runtime objects into prompt inputs for dynamic graph paths; walking those # objects as generic Mappings / Sequences is unsafe and can destabilize the @@ -139,32 +133,16 @@ def to_hashable(obj, depth=0, max_depth=32, seen=None): obj_type = type(obj) if obj_type in (int, float, str, bool, bytes, type(None)): return obj - - if obj_type in (dict, list, tuple, set, frozenset): - obj_id = id(obj) - if obj_id in seen: - return Unhashable() - seen = seen | {obj_id} - - if obj_type is dict: - return ( - "dict", - frozenset( - ( - to_hashable(k, depth + 1, max_depth, seen), - to_hashable(v, depth + 1, max_depth, seen), - ) - for k, v in obj.items() - ), - ) + elif obj_type is dict: + return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) elif obj_type is list: - return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("list", tuple(to_hashable(i) for i in obj)) elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("tuple", tuple(to_hashable(i) for i in obj)) elif obj_type is set: - return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("set", frozenset(to_hashable(i) for i in obj)) elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("frozenset", frozenset(to_hashable(i) for i in obj)) else: return Unhashable() diff --git a/tests-unit/execution_test/caching_hashable_test.py b/tests-unit/execution_test/caching_hashable_test.py deleted file mode 100644 index 5613ac6c4..000000000 --- a/tests-unit/execution_test/caching_hashable_test.py +++ /dev/null @@ -1,35 +0,0 @@ -from comfy_execution.caching import Unhashable, to_hashable - - -def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): - """Ensure self-referential built-in containers terminate as Unhashable.""" - cyclic_list = [] - cyclic_list.append(cyclic_list) - - result = to_hashable(cyclic_list) - - assert result[0] == "list" - assert len(result[1]) == 1 - assert isinstance(result[1][0], Unhashable) - - -def test_to_hashable_returns_unhashable_when_max_depth_is_reached(): - """Ensure deeply nested built-in containers stop at the configured depth limit.""" - nested = current = [] - for _ in range(32): - next_item = [] - current.append(next_item) - current = next_item - - result = to_hashable(nested) - - depth = 0 - current = result - while isinstance(current, tuple): - assert current[0] == "list" - assert len(current[1]) == 1 - current = current[1][0] - depth += 1 - - assert depth == 32 - assert isinstance(current, Unhashable) From 3568b82b76e94485ab1ee26c845b7c12c466abfa Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:11:35 +0100 Subject: [PATCH 08/39] Revert "Add missing docstrings" This reverts commit 4b431ffc278d5be231d97ace5570b5a459bfb883. --- comfy_execution/caching.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 3e3bfcdab..b25673463 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,8 +47,6 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: - """Hashable sentinel used when an input cannot be safely represented.""" - def __init__(self): """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") From 1af99b2e81c6ca3193f02535ed868159b5d13016 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:31:07 +0100 Subject: [PATCH 09/39] Update caching hash recursion --- comfy_execution/caching.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b25673463..b690d03a8 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -122,8 +122,14 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): # the foreign object and risk crashing on custom container semantics. return Unhashable() -def to_hashable(obj): +def to_hashable(obj, depth=0, max_depth=32, seen=None): """Convert prompt-safe built-in values into a stable hashable representation.""" + if depth >= max_depth: + return Unhashable() + + if seen is None: + seen = set() + # Restrict recursion to plain built-in containers. Some custom nodes insert # runtime objects into prompt inputs for dynamic graph paths; walking those # objects as generic Mappings / Sequences is unsafe and can destabilize the @@ -131,16 +137,32 @@ def to_hashable(obj): obj_type = type(obj) if obj_type in (int, float, str, bool, bytes, type(None)): return obj - elif obj_type is dict: - return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) + + if obj_type in (dict, list, tuple, set, frozenset): + obj_id = id(obj) + if obj_id in seen: + return Unhashable() + seen = seen | {obj_id} + + if obj_type is dict: + return ( + "dict", + frozenset( + ( + to_hashable(k, depth + 1, max_depth, seen), + to_hashable(v, depth + 1, max_depth, seen), + ) + for k, v in obj.items() + ), + ) elif obj_type is list: - return ("list", tuple(to_hashable(i) for i in obj)) + return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i) for i in obj)) + return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is set: - return ("set", frozenset(to_hashable(i) for i in obj)) + return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i) for i in obj)) + return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) else: return Unhashable() From 1451001f64818d2a47afea43695e30dfc41c7fcc Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:57:45 +0100 Subject: [PATCH 10/39] Add docstrings for cache signature hardening helpers --- comfy_execution/caching.py | 22 +++++++++++++++++----- comfy_execution/graph_utils.py | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b690d03a8..07d9a2902 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,13 +47,14 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: + """Hashable sentinel for values that cannot be represented safely in cache keys.""" def __init__(self): - """Create a hashable sentinel value for unhashable prompt inputs.""" + """Initialize a sentinel that stays hashable while never comparing equal.""" self.value = float("NaN") def _sanitized_sort_key(obj, depth=0, max_depth=32): - """Build a deterministic sort key from sanitized built-in container content.""" + """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) @@ -85,7 +86,10 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): def _sanitize_signature_input(obj, depth=0, max_depth=32): - """Sanitize prompt-signature inputs without recursing into opaque runtime objects.""" + """Normalize signature inputs to safe built-in containers. + + Preserves built-in container type and replaces opaque runtime values with Unhashable(). + """ if depth >= max_depth: return Unhashable() @@ -123,7 +127,10 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): return Unhashable() def to_hashable(obj, depth=0, max_depth=32, seen=None): - """Convert prompt-safe built-in values into a stable hashable representation.""" + """Convert sanitized prompt inputs into a stable hashable representation. + + Preserves built-in container type and stops safely on cycles or excessive depth. + """ if depth >= max_depth: return Unhashable() @@ -182,6 +189,7 @@ class CacheKeySetID(CacheKeySet): self.subcache_keys[node_id] = (node_id, node["class_type"]) class CacheKeySetInputSignature(CacheKeySet): + """Cache-key strategy that hashes a node's immediate inputs plus ancestor references.""" def __init__(self, dynprompt, node_ids, is_changed_cache): super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt @@ -201,6 +209,7 @@ class CacheKeySetInputSignature(CacheKeySet): self.subcache_keys[node_id] = (node_id, node["class_type"]) async def get_node_signature(self, dynprompt, node_id): + """Build the full cache signature for a node and its ordered ancestors.""" signature = [] ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id) signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) @@ -209,7 +218,10 @@ class CacheKeySetInputSignature(CacheKeySet): return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): - """Build the cache-signature fragment for a node's immediate inputs.""" + """Build the cache-signature fragment for a node's immediate inputs. + + Link inputs are reduced to ancestor references, while raw values are sanitized first. + """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. return [float("NaN")] diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 573645c7c..57b4ef36e 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,5 +1,5 @@ def is_link(obj): - """Return True if obj is a plain prompt link of the form [node_id, output_index].""" + """Return whether obj is a plain prompt link of the form [node_id, output_index].""" # Prompt links produced by the frontend / GraphBuilder are plain Python # lists in the form [node_id, output_index]. Some custom-node paths can # inject foreign runtime objects into prompt inputs during on-prompt graph From 31ba844624153c9b5601e47c56e2f4e9e41f176d Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:04:31 +0100 Subject: [PATCH 11/39] Add cycle detection to signature input sanitization --- comfy_execution/caching.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 07d9a2902..6b0468730 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -85,22 +85,32 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") -def _sanitize_signature_input(obj, depth=0, max_depth=32): +def _sanitize_signature_input(obj, depth=0, max_depth=32, seen=None): """Normalize signature inputs to safe built-in containers. - Preserves built-in container type and replaces opaque runtime values with Unhashable(). + Preserves built-in container type, replaces opaque runtime values with + Unhashable(), and stops safely on cycles or excessive depth. """ if depth >= max_depth: return Unhashable() + if seen is None: + seen = set() + obj_type = type(obj) + if obj_type in (dict, list, tuple, set, frozenset): + obj_id = id(obj) + if obj_id in seen: + return Unhashable() + next_seen = seen | {obj_id} + if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: sanitized_items = [ ( - _sanitize_signature_input(key, depth + 1, max_depth), - _sanitize_signature_input(value, depth + 1, max_depth), + _sanitize_signature_input(key, depth + 1, max_depth, next_seen), + _sanitize_signature_input(value, depth + 1, max_depth, next_seen), ) for key, value in obj.items() ] @@ -112,13 +122,13 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): ) return {key: value for key, value in sanitized_items} elif obj_type is list: - return [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + return [_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj] elif obj_type is tuple: - return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + return tuple(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) elif obj_type is set: - return {_sanitize_signature_input(item, depth + 1, max_depth) for item in obj} + return {_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj} elif obj_type is frozenset: - return frozenset(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + return frozenset(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) else: # Execution-cache signatures should be built from prompt-safe values. # If a custom node injects a runtime object here, mark it unhashable so From 17863f603a19c0290d7344543289b2f5e2a77eff Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:26:27 +0100 Subject: [PATCH 12/39] Add comprehensive docstrings for cache key helpers --- comfy_execution/caching.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 6b0468730..19480165e 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -16,6 +16,7 @@ NODE_CLASS_CONTAINS_UNIQUE_ID: Dict[str, bool] = {} def include_unique_id_in_input(class_type: str) -> bool: + """Return whether a node class includes UNIQUE_ID among its hidden inputs.""" if class_type in NODE_CLASS_CONTAINS_UNIQUE_ID: return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] @@ -23,27 +24,35 @@ def include_unique_id_in_input(class_type: str) -> bool: return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] class CacheKeySet(ABC): + """Base helper for building and storing cache keys for prompt nodes.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize cache-key storage for a dynamic prompt execution pass.""" self.keys = {} self.subcache_keys = {} @abstractmethod async def add_keys(self, node_ids): + """Populate cache keys for the provided node ids.""" raise NotImplementedError() def all_node_ids(self): + """Return the set of node ids currently tracked by this key set.""" return set(self.keys.keys()) def get_used_keys(self): + """Return the computed cache keys currently in use.""" return self.keys.values() def get_used_subcache_keys(self): + """Return the computed subcache keys currently in use.""" return self.subcache_keys.values() def get_data_key(self, node_id): + """Return the cache key for a node, if present.""" return self.keys.get(node_id, None) def get_subcache_key(self, node_id): + """Return the subcache key for a node, if present.""" return self.subcache_keys.get(node_id, None) class Unhashable: @@ -184,11 +193,14 @@ def to_hashable(obj, depth=0, max_depth=32, seen=None): return Unhashable() class CacheKeySetID(CacheKeySet): + """Cache-key strategy that keys nodes by node id and class type.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize identity-based cache keys for the supplied dynamic prompt.""" super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt async def add_keys(self, node_ids): + """Populate identity-based keys for nodes that exist in the dynamic prompt.""" for node_id in node_ids: if node_id in self.keys: continue @@ -201,14 +213,17 @@ class CacheKeySetID(CacheKeySet): class CacheKeySetInputSignature(CacheKeySet): """Cache-key strategy that hashes a node's immediate inputs plus ancestor references.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize input-signature-based cache keys for the supplied dynamic prompt.""" super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt self.is_changed_cache = is_changed_cache def include_node_id_in_input(self) -> bool: + """Return whether node ids should be included in computed input signatures.""" return False async def add_keys(self, node_ids): + """Populate input-signature-based keys for nodes in the dynamic prompt.""" for node_id in node_ids: if node_id in self.keys: continue @@ -254,12 +269,14 @@ class CacheKeySetInputSignature(CacheKeySet): # This function returns a list of all ancestors of the given node. The order of the list is # deterministic based on which specific inputs the ancestor is connected by. def get_ordered_ancestry(self, dynprompt, node_id): + """Return ancestors in deterministic traversal order and their index mapping.""" ancestors = [] order_mapping = {} self.get_ordered_ancestry_internal(dynprompt, node_id, ancestors, order_mapping) return ancestors, order_mapping def get_ordered_ancestry_internal(self, dynprompt, node_id, ancestors, order_mapping): + """Recursively collect ancestors in input order without revisiting prior nodes.""" if not dynprompt.has_node(node_id): return inputs = dynprompt.get_node(node_id)["inputs"] From 2bea0ee5d75961b5027e2edd20f785bd5a0e33dd Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:42:04 +0100 Subject: [PATCH 13/39] Simplify Unhashable sentinel implementation --- comfy_execution/caching.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 19480165e..cbf2e9de1 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -56,10 +56,8 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: - """Hashable sentinel for values that cannot be represented safely in cache keys.""" - def __init__(self): - """Initialize a sentinel that stays hashable while never comparing equal.""" - self.value = float("NaN") + """Hashable identity sentinel for values that cannot be represented safely in cache keys.""" + pass def _sanitized_sort_key(obj, depth=0, max_depth=32): From aceaa5e5798b53dcdc5a0c584d70b45de25083d7 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 02:32:25 +0100 Subject: [PATCH 14/39] fail closed on ambiguous container ordering in cache signatures --- comfy_execution/caching.py | 304 +++++++++++++++------- tests-unit/execution_test/caching_test.py | 176 +++++++++++++ 2 files changed, 380 insertions(+), 100 deletions(-) create mode 100644 tests-unit/execution_test/caching_test.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index cbf2e9de1..1ca1edcc0 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -60,136 +60,239 @@ class Unhashable: pass -def _sanitized_sort_key(obj, depth=0, max_depth=32): +_PRIMITIVE_SIGNATURE_TYPES = (int, float, str, bool, bytes, type(None)) +_CONTAINER_SIGNATURE_TYPES = (dict, list, tuple, set, frozenset) +_MAX_SIGNATURE_DEPTH = 32 +_MAX_SIGNATURE_CONTAINER_VISITS = 10_000 + + +def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) + if active is None: + active = set() + if memo is None: + memo = {} + obj_type = type(obj) if obj_type is Unhashable: return ("UNHASHABLE",) - elif obj_type in (int, float, str, bool, bytes, type(None)): + elif obj_type in _PRIMITIVE_SIGNATURE_TYPES: return (obj_type.__module__, obj_type.__qualname__, repr(obj)) - elif obj_type is dict: - items = [ - ( - _sanitized_sort_key(k, depth + 1, max_depth), - _sanitized_sort_key(v, depth + 1, max_depth), - ) - for k, v in obj.items() - ] - items.sort() - return ("dict", tuple(items)) - elif obj_type is list: - return ("list", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) - elif obj_type is tuple: - return ("tuple", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) - elif obj_type is set: - return ("set", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) - elif obj_type is frozenset: - return ("frozenset", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) - else: + elif obj_type not in _CONTAINER_SIGNATURE_TYPES: return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") + obj_id = id(obj) + if obj_id in memo: + return memo[obj_id] + if obj_id in active: + return ("CYCLE",) -def _sanitize_signature_input(obj, depth=0, max_depth=32, seen=None): + active.add(obj_id) + try: + if obj_type is dict: + items = [ + ( + _sanitized_sort_key(k, depth + 1, max_depth, active, memo), + _sanitized_sort_key(v, depth + 1, max_depth, active, memo), + ) + for k, v in obj.items() + ] + items.sort() + result = ("dict", tuple(items)) + elif obj_type is list: + result = ("list", tuple(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj)) + elif obj_type is tuple: + result = ("tuple", tuple(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj)) + elif obj_type is set: + result = ("set", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj))) + else: + result = ("frozenset", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj))) + finally: + active.discard(obj_id) + + memo[obj_id] = result + return result + + +def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): """Normalize signature inputs to safe built-in containers. Preserves built-in container type, replaces opaque runtime values with - Unhashable(), and stops safely on cycles or excessive depth. + Unhashable(), stops safely on cycles or excessive depth, and memoizes + repeated built-in substructures so shared DAG-like inputs do not explode + into repeated recursive work. """ if depth >= max_depth: return Unhashable() - if seen is None: - seen = set() + if active is None: + active = set() + if memo is None: + memo = {} + if budget is None: + budget = {"remaining": _MAX_SIGNATURE_CONTAINER_VISITS} obj_type = type(obj) - if obj_type in (dict, list, tuple, set, frozenset): - obj_id = id(obj) - if obj_id in seen: - return Unhashable() - next_seen = seen | {obj_id} - - if obj_type in (int, float, str, bool, bytes, type(None)): + if obj_type in _PRIMITIVE_SIGNATURE_TYPES: return obj - elif obj_type is dict: - sanitized_items = [ - ( - _sanitize_signature_input(key, depth + 1, max_depth, next_seen), - _sanitize_signature_input(value, depth + 1, max_depth, next_seen), - ) - for key, value in obj.items() - ] - sanitized_items.sort( - key=lambda kv: ( - _sanitized_sort_key(kv[0], depth + 1, max_depth), - _sanitized_sort_key(kv[1], depth + 1, max_depth), - ) - ) - return {key: value for key, value in sanitized_items} - elif obj_type is list: - return [_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj] - elif obj_type is tuple: - return tuple(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) - elif obj_type is set: - return {_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj} - elif obj_type is frozenset: - return frozenset(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) - else: - # Execution-cache signatures should be built from prompt-safe values. - # If a custom node injects a runtime object here, mark it unhashable so - # the node won't reuse stale cache entries across runs, but do not walk - # the foreign object and risk crashing on custom container semantics. + if obj_type not in _CONTAINER_SIGNATURE_TYPES: return Unhashable() -def to_hashable(obj, depth=0, max_depth=32, seen=None): + obj_id = id(obj) + if obj_id in memo: + return memo[obj_id] + if obj_id in active: + return Unhashable() + + budget["remaining"] -= 1 + if budget["remaining"] < 0: + return Unhashable() + + active.add(obj_id) + try: + if obj_type is dict: + sort_memo = {} + sanitized_items = [ + ( + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + ) + for key, value in obj.items() + ] + ordered_items = [ + ( + ( + _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), + _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), + ), + (key, value), + ) + for key, value in sanitized_items + ] + ordered_items.sort(key=lambda item: item[0]) + + result = Unhashable() + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + break + else: + result = {key: value for _, (key, value) in ordered_items} + elif obj_type is list: + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + elif obj_type is tuple: + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + elif obj_type is set: + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + else: + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + finally: + active.discard(obj_id) + + memo[obj_id] = result + return result + + +def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): """Convert sanitized prompt inputs into a stable hashable representation. - Preserves built-in container type and stops safely on cycles or excessive depth. + The input is expected to already be sanitized to plain built-in containers, + but this function still fails safe for anything unexpected. Traversal is + iterative and memoized so shared built-in substructures do not trigger + exponential re-walks during cache-key construction. """ - if depth >= max_depth: - return Unhashable() - - if seen is None: - seen = set() - - # Restrict recursion to plain built-in containers. Some custom nodes insert - # runtime objects into prompt inputs for dynamic graph paths; walking those - # objects as generic Mappings / Sequences is unsafe and can destabilize the - # cache signature builder. obj_type = type(obj) - if obj_type in (int, float, str, bool, bytes, type(None)): + if obj_type in _PRIMITIVE_SIGNATURE_TYPES or obj_type is Unhashable: return obj - - if obj_type in (dict, list, tuple, set, frozenset): - obj_id = id(obj) - if obj_id in seen: - return Unhashable() - seen = seen | {obj_id} - - if obj_type is dict: - return ( - "dict", - frozenset( - ( - to_hashable(k, depth + 1, max_depth, seen), - to_hashable(v, depth + 1, max_depth, seen), - ) - for k, v in obj.items() - ), - ) - elif obj_type is list: - return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) - elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) - elif obj_type is set: - return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) - elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) - else: + if obj_type not in _CONTAINER_SIGNATURE_TYPES: return Unhashable() + memo = {} + active = set() + sort_memo = {} + processed = 0 + stack = [(obj, False)] + + def resolve_value(value): + """Resolve a child value from the completed memo table when available.""" + value_type = type(value) + if value_type in _PRIMITIVE_SIGNATURE_TYPES or value_type is Unhashable: + return value + return memo.get(id(value), Unhashable()) + + def resolve_unordered_values(current, container_tag): + """Resolve a set-like container or fail closed if ordering is ambiguous.""" + ordered_items = [ + (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) + for item in current + ] + ordered_items.sort(key=lambda item: item[0]) + + for index in range(1, len(ordered_items)): + previous_key, previous_value = ordered_items[index - 1] + current_key, current_value = ordered_items[index] + if previous_key == current_key and previous_value != current_value: + return Unhashable() + + return (container_tag, tuple(value for _, value in ordered_items)) + + while stack: + current, expanded = stack.pop() + current_type = type(current) + + if current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable: + continue + if current_type not in _CONTAINER_SIGNATURE_TYPES: + memo[id(current)] = Unhashable() + continue + + current_id = id(current) + if current_id in memo: + continue + + if expanded: + active.discard(current_id) + if current_type is dict: + memo[current_id] = ( + "dict", + tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + ) + elif current_type is list: + memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + elif current_type is tuple: + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + elif current_type is set: + memo[current_id] = resolve_unordered_values(current, "set") + else: + memo[current_id] = resolve_unordered_values(current, "frozenset") + continue + + if current_id in active: + memo[current_id] = Unhashable() + continue + + processed += 1 + if processed > max_nodes: + return Unhashable() + + active.add(current_id) + stack.append((current, True)) + if current_type is dict: + items = list(current.items()) + for key, value in reversed(items): + stack.append((value, False)) + stack.append((key, False)) + else: + items = list(current) + for item in reversed(items): + stack.append((item, False)) + + return memo.get(id(obj), Unhashable()) + class CacheKeySetID(CacheKeySet): """Cache-key strategy that keys nodes by node id and class type.""" def __init__(self, dynprompt, node_ids, is_changed_cache): @@ -238,6 +341,7 @@ class CacheKeySetInputSignature(CacheKeySet): 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)) + signature = _sanitize_signature_input(signature) return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py new file mode 100644 index 000000000..2f088722e --- /dev/null +++ b/tests-unit/execution_test/caching_test.py @@ -0,0 +1,176 @@ +"""Unit tests for cache-signature sanitization and hash conversion hardening.""" + +import asyncio +import importlib +import sys +import types + +import pytest + + +class _DummyNode: + """Minimal node stub used to satisfy cache-signature class lookups.""" + + @staticmethod + def INPUT_TYPES(): + """Return a minimal empty input schema for unit tests.""" + return {"required": {}} + + +class _FakeDynPrompt: + """Small DynamicPrompt stand-in with only the methods these tests need.""" + + def __init__(self, nodes_by_id): + """Store test nodes by id.""" + self._nodes_by_id = nodes_by_id + + def has_node(self, node_id): + """Return whether the fake prompt contains the requested node.""" + return node_id in self._nodes_by_id + + def get_node(self, node_id): + """Return the stored node payload for the requested id.""" + return self._nodes_by_id[node_id] + + +class _FakeIsChangedCache: + """Async stub for `is_changed` lookups used by cache-key generation.""" + + def __init__(self, values): + """Store canned `is_changed` responses keyed by node id.""" + self._values = values + + async def get(self, node_id): + """Return the canned `is_changed` value for a node.""" + return self._values[node_id] + + +class _OpaqueValue: + """Hashable opaque object used to exercise fail-closed unordered hashing paths.""" + + +def _contains_unhashable(value, unhashable_type): + """Return whether a nested built-in structure contains an Unhashable sentinel.""" + if isinstance(value, unhashable_type): + return True + + value_type = type(value) + if value_type is dict: + return any( + _contains_unhashable(key, unhashable_type) or _contains_unhashable(item, unhashable_type) + for key, item in value.items() + ) + if value_type in (list, tuple, set, frozenset): + return any(_contains_unhashable(item, unhashable_type) for item in value) + return False + + +@pytest.fixture +def caching_module(monkeypatch): + """Import `comfy_execution.caching` with lightweight stub dependencies.""" + torch_module = types.ModuleType("torch") + psutil_module = types.ModuleType("psutil") + nodes_module = types.ModuleType("nodes") + nodes_module.NODE_CLASS_MAPPINGS = {} + graph_module = types.ModuleType("comfy_execution.graph") + + class DynamicPrompt: + """Placeholder graph type so the caching module can import cleanly.""" + + pass + + graph_module.DynamicPrompt = DynamicPrompt + + monkeypatch.setitem(sys.modules, "torch", torch_module) + monkeypatch.setitem(sys.modules, "psutil", psutil_module) + monkeypatch.setitem(sys.modules, "nodes", nodes_module) + monkeypatch.setitem(sys.modules, "comfy_execution.graph", graph_module) + monkeypatch.delitem(sys.modules, "comfy_execution.caching", raising=False) + + module = importlib.import_module("comfy_execution.caching") + module = importlib.reload(module) + return module, nodes_module + + +def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_module): + """Shared built-in substructures should sanitize without collapsing to Unhashable.""" + caching, _ = caching_module + shared = [{"value": 1}, {"value": 2}] + + sanitized = caching._sanitize_signature_input([shared, shared]) + + assert isinstance(sanitized, list) + assert sanitized[0] == sanitized[1] + assert sanitized[0][0]["value"] == 1 + assert sanitized[0][1]["value"] == 2 + + +def test_to_hashable_handles_shared_builtin_substructures(caching_module): + """Repeated sanitized content should hash stably for shared substructures.""" + caching, _ = caching_module + shared = [{"value": 1}, {"value": 2}] + + sanitized = caching._sanitize_signature_input([shared, shared]) + hashable = caching.to_hashable(sanitized) + + assert hashable[0] == "list" + assert hashable[1][0] == hashable[1][1] + assert hashable[1][0][0] == "list" + + +def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): + """Ambiguous dict sort ties should fail closed instead of depending on input order.""" + caching, _ = caching_module + ambiguous = { + _OpaqueValue(): _OpaqueValue(), + _OpaqueValue(): _OpaqueValue(), + } + + sanitized = caching._sanitize_signature_input(ambiguous) + + assert isinstance(sanitized, caching.Unhashable) + + +@pytest.mark.parametrize( + "container_factory", + [ + set, + frozenset, + ], +) +def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, container_factory): + """Ambiguous unordered values should fail closed instead of depending on iteration order.""" + caching, _ = caching_module + container = container_factory({_OpaqueValue(), _OpaqueValue()}) + + hashable = caching.to_hashable(container) + + assert isinstance(hashable, caching.Unhashable) + + +def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch): + """Recursive `is_changed` payloads should be sanitized inside the full node signature.""" + caching, nodes_module = caching_module + monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + + is_changed_value = [] + is_changed_value.append(is_changed_value) + + dynprompt = _FakeDynPrompt( + { + "node": { + "class_type": "UnitTestNode", + "inputs": {"value": 5}, + } + } + ) + key_set = caching.CacheKeySetInputSignature( + dynprompt, + ["node"], + _FakeIsChangedCache({"node": is_changed_value}), + ) + + signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) + + assert signature[0] == "list" + assert _contains_unhashable(signature, caching.Unhashable) From 117afbc1d7cfd9129af317e45b4cfdf39ed8ac6f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 02:55:39 +0100 Subject: [PATCH 15/39] Add docstrings and harden signature --- comfy_execution/caching.py | 131 +++++++++++++--------- tests-unit/execution_test/caching_test.py | 51 +++++++++ 2 files changed, 132 insertions(+), 50 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 1ca1edcc0..2169dda9a 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -154,42 +154,57 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti active.add(obj_id) try: if obj_type is dict: - sort_memo = {} - sanitized_items = [ - ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), - ) - for key, value in obj.items() - ] - ordered_items = [ - ( + try: + sort_memo = {} + sanitized_items = [ ( - _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), - _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), - ), - (key, value), - ) - for key, value in sanitized_items - ] - ordered_items.sort(key=lambda item: item[0]) + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + ) + for key, value in obj.items() + ] + ordered_items = [ + ( + ( + _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), + _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), + ), + (key, value), + ) + for key, value in sanitized_items + ] + ordered_items.sort(key=lambda item: item[0]) - result = Unhashable() - for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: - break - else: - result = {key: value for _, (key, value) in ordered_items} + result = Unhashable() + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + break + else: + result = {key: value for _, (key, value) in ordered_items} + except RuntimeError: + result = Unhashable() elif obj_type is list: - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + try: + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + except RuntimeError: + result = Unhashable() elif obj_type is tuple: - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + try: + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + except RuntimeError: + result = Unhashable() elif obj_type is set: - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + try: + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + except RuntimeError: + result = Unhashable() else: - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + try: + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + except RuntimeError: + result = Unhashable() finally: active.discard(obj_id) @@ -226,11 +241,14 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): def resolve_unordered_values(current, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" - ordered_items = [ - (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) - for item in current - ] - ordered_items.sort(key=lambda item: item[0]) + try: + ordered_items = [ + (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) + for item in current + ] + ordered_items.sort(key=lambda item: item[0]) + except RuntimeError: + return Unhashable() for index in range(1, len(ordered_items)): previous_key, previous_value = ordered_items[index - 1] @@ -256,19 +274,22 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): if expanded: active.discard(current_id) - if current_type is dict: - memo[current_id] = ( - "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), - ) - elif current_type is list: - memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) - elif current_type is tuple: - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) - elif current_type is set: - memo[current_id] = resolve_unordered_values(current, "set") - else: - memo[current_id] = resolve_unordered_values(current, "frozenset") + try: + if current_type is dict: + memo[current_id] = ( + "dict", + tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + ) + elif current_type is list: + memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + elif current_type is tuple: + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + elif current_type is set: + memo[current_id] = resolve_unordered_values(current, "set") + else: + memo[current_id] = resolve_unordered_values(current, "frozenset") + except RuntimeError: + memo[current_id] = Unhashable() continue if current_id in active: @@ -282,12 +303,22 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.add(current_id) stack.append((current, True)) if current_type is dict: - items = list(current.items()) + try: + items = list(current.items()) + except RuntimeError: + memo[current_id] = Unhashable() + active.discard(current_id) + continue for key, value in reversed(items): stack.append((value, False)) stack.append((key, False)) else: - items = list(current) + try: + items = list(current) + except RuntimeError: + memo[current_id] = Unhashable() + active.discard(current_id) + continue for item in reversed(items): stack.append((item, False)) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 2f088722e..c9892304a 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -105,6 +105,35 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +@pytest.mark.parametrize( + "container_factory", + [ + lambda marker: [marker], + lambda marker: (marker,), + lambda marker: {marker}, + lambda marker: frozenset({marker}), + lambda marker: {marker: "value"}, + ], +) +def test_sanitize_signature_input_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade sanitization to Unhashable.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + + def raising_sanitize(obj, *args, **kwargs): + """Raise a traversal RuntimeError for the marker value and delegate otherwise.""" + if obj is marker: + raise RuntimeError("container changed during iteration") + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", raising_sanitize) + + sanitized = original(container_factory(marker)) + + assert isinstance(sanitized, caching.Unhashable) + + def test_to_hashable_handles_shared_builtin_substructures(caching_module): """Repeated sanitized content should hash stably for shared substructures.""" caching, _ = caching_module @@ -118,6 +147,28 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +@pytest.mark.parametrize( + "container_factory", + [ + set, + frozenset, + ], +) +def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade unordered hash conversion to Unhashable.""" + caching, _ = caching_module + + def raising_sort_key(obj, *args, **kwargs): + """Raise a traversal RuntimeError while unordered values are canonicalized.""" + raise RuntimeError("container changed during iteration") + + monkeypatch.setattr(caching, "_sanitized_sort_key", raising_sort_key) + + hashable = caching.to_hashable(container_factory({"value"})) + + assert isinstance(hashable, caching.Unhashable) + + def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module From fadd79ad489f8d76d7c1d18d5511cbf70dff1080 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 03:29:59 +0100 Subject: [PATCH 16/39] Fix nondeterministic set signing --- comfy_execution/caching.py | 15 +++++--- tests-unit/execution_test/caching_test.py | 42 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 2169dda9a..08f3f436b 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -155,13 +155,14 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti try: if obj_type is dict: try: + items = list(obj.items()) sort_memo = {} sanitized_items = [ ( _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), ) - for key, value in obj.items() + for key, value in items ] ordered_items = [ ( @@ -187,22 +188,26 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti result = Unhashable() elif obj_type is list: try: - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + items = list(obj) + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items] except RuntimeError: result = Unhashable() elif obj_type is tuple: try: - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + items = list(obj) + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) except RuntimeError: result = Unhashable() elif obj_type is set: try: - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + items = list(obj) + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items} except RuntimeError: result = Unhashable() else: try: - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + items = list(obj) + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) except RuntimeError: result = Unhashable() finally: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c9892304a..6313faed1 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -105,6 +105,48 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): + """List sanitization should read a point-in-time snapshot before recursive descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + values = [marker, 2] + + def mutating_sanitize(obj, *args, **kwargs): + """Mutate the live list during recursion to verify snapshot-based traversal.""" + if obj is marker: + values[1] = 3 + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + + sanitized = original(values) + + assert isinstance(sanitized, list) + assert sanitized[1] == 2 + + +def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module, monkeypatch): + """Dict sanitization should read a point-in-time snapshot before recursive descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + values = {"first": marker, "second": 2} + + def mutating_sanitize(obj, *args, **kwargs): + """Mutate the live dict during recursion to verify snapshot-based traversal.""" + if obj is marker: + values["second"] = 3 + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + + sanitized = original(values) + + assert isinstance(sanitized, dict) + assert sanitized["second"] == 2 + + @pytest.mark.parametrize( "container_factory", [ From 9feb26928cd755d751a04e3f6e46c12485a57be5 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 04:31:32 +0100 Subject: [PATCH 17/39] Change signature cache to bail early --- comfy_execution/caching.py | 45 ++++++++++++---- tests-unit/execution_test/caching_test.py | 65 ++++++++++++++++------- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 08f3f436b..73b67f8ab 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -66,6 +66,12 @@ _MAX_SIGNATURE_DEPTH = 32 _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 +def _mark_signature_tainted(taint_state): + """Record that signature sanitization hit a fail-closed condition.""" + if taint_state is not None: + taint_state["tainted"] = True + + def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: @@ -117,15 +123,20 @@ def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=Non return result -def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): +def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None, taint_state=None): """Normalize signature inputs to safe built-in containers. Preserves built-in container type, replaces opaque runtime values with - Unhashable(), stops safely on cycles or excessive depth, and memoizes - repeated built-in substructures so shared DAG-like inputs do not explode - into repeated recursive work. + Unhashable(), stops safely on cycles or excessive depth, memoizes repeated + built-in substructures so shared DAG-like inputs do not explode into + repeated recursive work, and optionally records when sanitization had to + fail closed anywhere in the traversed structure. """ + if taint_state is not None and taint_state.get("tainted"): + return Unhashable() + if depth >= max_depth: + _mark_signature_tainted(taint_state) return Unhashable() if active is None: @@ -139,16 +150,19 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti if obj_type in _PRIMITIVE_SIGNATURE_TYPES: return obj if obj_type not in _CONTAINER_SIGNATURE_TYPES: + _mark_signature_tainted(taint_state) return Unhashable() obj_id = id(obj) if obj_id in memo: return memo[obj_id] if obj_id in active: + _mark_signature_tainted(taint_state) return Unhashable() budget["remaining"] -= 1 if budget["remaining"] < 0: + _mark_signature_tainted(taint_state) return Unhashable() active.add(obj_id) @@ -159,8 +173,8 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti sort_memo = {} sanitized_items = [ ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget, taint_state), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget, taint_state), ) for key, value in items ] @@ -181,34 +195,40 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti previous_sort_key, previous_item = ordered_items[index - 1] current_sort_key, current_item = ordered_items[index] if previous_sort_key == current_sort_key and previous_item != current_item: + _mark_signature_tainted(taint_state) break else: result = {key: value for _, (key, value) in ordered_items} except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is list: try: items = list(obj) - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items] + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items] except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is tuple: try: items = list(obj) - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is set: try: items = list(obj) - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items} + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items} except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() else: try: items = list(obj) - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() finally: active.discard(obj_id) @@ -377,7 +397,10 @@ class CacheKeySetInputSignature(CacheKeySet): 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)) - signature = _sanitize_signature_input(signature) + taint_state = {"tainted": False} + signature = _sanitize_signature_input(signature, taint_state=taint_state) + if taint_state["tainted"]: + return Unhashable() return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 6313faed1..390efe87b 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -49,22 +49,6 @@ class _OpaqueValue: """Hashable opaque object used to exercise fail-closed unordered hashing paths.""" -def _contains_unhashable(value, unhashable_type): - """Return whether a nested built-in structure contains an Unhashable sentinel.""" - if isinstance(value, unhashable_type): - return True - - value_type = type(value) - if value_type is dict: - return any( - _contains_unhashable(key, unhashable_type) or _contains_unhashable(item, unhashable_type) - for key, item in value.items() - ) - if value_type in (list, tuple, set, frozenset): - return any(_contains_unhashable(item, unhashable_type) for item in value) - return False - - @pytest.fixture def caching_module(monkeypatch): """Import `comfy_execution.caching` with lightweight stub dependencies.""" @@ -105,6 +89,43 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +def test_sanitize_signature_input_marks_tainted_on_opaque_values(caching_module): + """Opaque values should mark the containing signature as tainted.""" + caching, _ = caching_module + taint_state = {"tainted": False} + + sanitized = caching._sanitize_signature_input(["safe", object()], taint_state=taint_state) + + assert isinstance(sanitized, list) + assert taint_state["tainted"] is True + assert isinstance(sanitized[1], caching.Unhashable) + + +def test_sanitize_signature_input_stops_descending_after_taint(caching_module, monkeypatch): + """Once tainted, later recursive calls should return immediately without deeper descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + marker_seen = False + + def tracking_sanitize(obj, *args, **kwargs): + """Track whether recursion reaches the nested marker after tainting.""" + nonlocal marker_seen + if obj is marker: + marker_seen = True + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", tracking_sanitize) + + taint_state = {"tainted": False} + sanitized = original([object(), [marker]], taint_state=taint_state) + + assert isinstance(sanitized, list) + assert taint_state["tainted"] is True + assert marker_seen is False + assert isinstance(sanitized[1], caching.Unhashable) + + def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): """List sanitization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module @@ -241,10 +262,15 @@ def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, assert isinstance(hashable, caching.Unhashable) -def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch): - """Recursive `is_changed` payloads should be sanitized inside the full node signature.""" +def test_get_node_signature_returns_top_level_unhashable_for_tainted_signature(caching_module, monkeypatch): + """Tainted full signatures should fail closed before `to_hashable()` runs.""" caching, nodes_module = caching_module monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + monkeypatch.setattr( + caching, + "to_hashable", + lambda *_args, **_kwargs: pytest.fail("to_hashable should not run for tainted signatures"), + ) is_changed_value = [] is_changed_value.append(is_changed_value) @@ -265,5 +291,4 @@ def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) - assert signature[0] == "list" - assert _contains_unhashable(signature, caching.Unhashable) + assert isinstance(signature, caching.Unhashable) From 0b512198e896179019a85bfe3171f1cb076510bb Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 05:41:39 +0100 Subject: [PATCH 18/39] Adopt single-pass signature hashing --- comfy_execution/caching.py | 167 +++++++++++----------- tests-unit/execution_test/caching_test.py | 105 +++++++------- 2 files changed, 131 insertions(+), 141 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 73b67f8ab..f1b5227db 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -64,12 +64,13 @@ _PRIMITIVE_SIGNATURE_TYPES = (int, float, str, bool, bytes, type(None)) _CONTAINER_SIGNATURE_TYPES = (dict, list, tuple, set, frozenset) _MAX_SIGNATURE_DEPTH = 32 _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 +_FAILED_SIGNATURE = object() -def _mark_signature_tainted(taint_state): - """Record that signature sanitization hit a fail-closed condition.""" - if taint_state is not None: - taint_state["tainted"] = True +def _primitive_signature_sort_key(obj): + """Return a deterministic ordering key for primitive signature values.""" + obj_type = type(obj) + return ("primitive", obj_type.__module__, obj_type.__qualname__, repr(obj)) def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): @@ -123,21 +124,10 @@ def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=Non return result -def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None, taint_state=None): - """Normalize signature inputs to safe built-in containers. - - Preserves built-in container type, replaces opaque runtime values with - Unhashable(), stops safely on cycles or excessive depth, memoizes repeated - built-in substructures so shared DAG-like inputs do not explode into - repeated recursive work, and optionally records when sanitization had to - fail closed anywhere in the traversed structure. - """ - if taint_state is not None and taint_state.get("tainted"): - return Unhashable() - +def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): + """Canonicalize signature inputs directly into their final hashable form.""" if depth >= max_depth: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE if active is None: active = set() @@ -148,93 +138,102 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti obj_type = type(obj) if obj_type in _PRIMITIVE_SIGNATURE_TYPES: - return obj - if obj_type not in _CONTAINER_SIGNATURE_TYPES: - _mark_signature_tainted(taint_state) - return Unhashable() + return obj, _primitive_signature_sort_key(obj) + if obj_type is Unhashable or obj_type not in _CONTAINER_SIGNATURE_TYPES: + return _FAILED_SIGNATURE obj_id = id(obj) if obj_id in memo: return memo[obj_id] if obj_id in active: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE budget["remaining"] -= 1 if budget["remaining"] < 0: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE active.add(obj_id) try: if obj_type is dict: try: items = list(obj.items()) - sort_memo = {} - sanitized_items = [ - ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget, taint_state), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget, taint_state), - ) - for key, value in items - ] - ordered_items = [ - ( - ( - _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), - _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), - ), - (key, value), - ) - for key, value in sanitized_items - ] - ordered_items.sort(key=lambda item: item[0]) + except RuntimeError: + return _FAILED_SIGNATURE - result = Unhashable() - for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: - _mark_signature_tainted(taint_state) - break - else: - result = {key: value for _, (key, value) in ordered_items} - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is list: + ordered_items = [] + for key, value in items: + key_result = _signature_to_hashable_impl(key, depth + 1, max_depth, active, memo, budget) + if key_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + value_result = _signature_to_hashable_impl(value, depth + 1, max_depth, active, memo, budget) + if value_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + key_value, key_sort = key_result + value_value, value_sort = value_result + ordered_items.append((((key_sort, value_sort)), (key_value, value_value))) + + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + return _FAILED_SIGNATURE + + value = ("dict", tuple(item for _, item in ordered_items)) + sort_key = ("dict", tuple(sort_key for sort_key, _ in ordered_items)) + elif obj_type is list or obj_type is tuple: try: items = list(obj) - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items] except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is tuple: - try: - items = list(obj) - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is set: - try: - items = list(obj) - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items} - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() + return _FAILED_SIGNATURE + + child_results = [] + for item in items: + child_result = _signature_to_hashable_impl(item, depth + 1, max_depth, active, memo, budget) + if child_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + child_results.append(child_result) + + container_tag = "list" if obj_type is list else "tuple" + value = (container_tag, tuple(child for child, _ in child_results)) + sort_key = (container_tag, tuple(child_sort for _, child_sort in child_results)) else: try: items = list(obj) - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() + return _FAILED_SIGNATURE + + ordered_items = [] + for item in items: + child_result = _signature_to_hashable_impl(item, depth + 1, max_depth, active, memo, budget) + if child_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + child_value, child_sort = child_result + ordered_items.append((child_sort, child_value)) + + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + previous_sort_key, previous_value = ordered_items[index - 1] + current_sort_key, current_value = ordered_items[index] + if previous_sort_key == current_sort_key and previous_value != current_value: + return _FAILED_SIGNATURE + + container_tag = "set" if obj_type is set else "frozenset" + value = (container_tag, tuple(child_value for _, child_value in ordered_items)) + sort_key = (container_tag, tuple(child_sort for child_sort, _ in ordered_items)) finally: active.discard(obj_id) - memo[obj_id] = result - return result + memo[obj_id] = (value, sort_key) + return memo[obj_id] + + +def _signature_to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): + """Build the final cache-signature representation in one fail-closed pass.""" + result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + if result is _FAILED_SIGNATURE: + return Unhashable() + return result[0] def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): @@ -397,11 +396,7 @@ class CacheKeySetInputSignature(CacheKeySet): 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)) - taint_state = {"tainted": False} - signature = _sanitize_signature_input(signature, taint_state=taint_state) - if taint_state["tainted"]: - return Unhashable() - return to_hashable(signature) + return _signature_to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): """Build the cache-signature fragment for a node's immediate inputs. @@ -424,7 +419,7 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, _sanitize_signature_input(inputs[key]))) + signature.append((key, inputs[key])) return signature # This function returns a list of all ancestors of the given node. The order of the list is diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 390efe87b..a7dffcee0 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -1,4 +1,4 @@ -"""Unit tests for cache-signature sanitization and hash conversion hardening.""" +"""Unit tests for cache-signature canonicalization hardening.""" import asyncio import importlib @@ -76,96 +76,91 @@ def caching_module(monkeypatch): return module, nodes_module -def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_module): - """Shared built-in substructures should sanitize without collapsing to Unhashable.""" +def test_signature_to_hashable_handles_shared_builtin_substructures(caching_module): + """Shared built-in substructures should canonicalize without collapsing to Unhashable.""" caching, _ = caching_module shared = [{"value": 1}, {"value": 2}] - sanitized = caching._sanitize_signature_input([shared, shared]) + signature = caching._signature_to_hashable([shared, shared]) - assert isinstance(sanitized, list) - assert sanitized[0] == sanitized[1] - assert sanitized[0][0]["value"] == 1 - assert sanitized[0][1]["value"] == 2 + assert signature[0] == "list" + assert signature[1][0] == signature[1][1] + assert signature[1][0][0] == "list" + assert signature[1][0][1][0] == ("dict", (("value", 1),)) + assert signature[1][0][1][1] == ("dict", (("value", 2),)) -def test_sanitize_signature_input_marks_tainted_on_opaque_values(caching_module): - """Opaque values should mark the containing signature as tainted.""" +def test_signature_to_hashable_fails_closed_on_opaque_values(caching_module): + """Opaque values should collapse the full signature to Unhashable immediately.""" caching, _ = caching_module - taint_state = {"tainted": False} - sanitized = caching._sanitize_signature_input(["safe", object()], taint_state=taint_state) + signature = caching._signature_to_hashable(["safe", object()]) - assert isinstance(sanitized, list) - assert taint_state["tainted"] is True - assert isinstance(sanitized[1], caching.Unhashable) + assert isinstance(signature, caching.Unhashable) -def test_sanitize_signature_input_stops_descending_after_taint(caching_module, monkeypatch): - """Once tainted, later recursive calls should return immediately without deeper descent.""" +def test_signature_to_hashable_stops_descending_after_failure(caching_module, monkeypatch): + """Once canonicalization fails, later recursive descent should stop immediately.""" caching, _ = caching_module - original = caching._sanitize_signature_input + original = caching._signature_to_hashable_impl marker = object() marker_seen = False - def tracking_sanitize(obj, *args, **kwargs): - """Track whether recursion reaches the nested marker after tainting.""" + def tracking_canonicalize(obj, *args, **kwargs): + """Track whether recursion reaches the nested marker after failure.""" nonlocal marker_seen if obj is marker: marker_seen = True return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", tracking_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", tracking_canonicalize) - taint_state = {"tainted": False} - sanitized = original([object(), [marker]], taint_state=taint_state) + signature = caching._signature_to_hashable([object(), [marker]]) - assert isinstance(sanitized, list) - assert taint_state["tainted"] is True + assert isinstance(signature, caching.Unhashable) assert marker_seen is False - assert isinstance(sanitized[1], caching.Unhashable) -def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): - """List sanitization should read a point-in-time snapshot before recursive descent.""" +def test_signature_to_hashable_snapshots_list_before_recursing(caching_module, monkeypatch): + """List canonicalization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module - original = caching._sanitize_signature_input - marker = object() + original = caching._signature_to_hashable_impl + marker = ("marker",) values = [marker, 2] - def mutating_sanitize(obj, *args, **kwargs): + def mutating_canonicalize(obj, *args, **kwargs): """Mutate the live list during recursion to verify snapshot-based traversal.""" if obj is marker: values[1] = 3 return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", mutating_canonicalize) - sanitized = original(values) + signature = caching._signature_to_hashable(values) - assert isinstance(sanitized, list) - assert sanitized[1] == 2 + assert signature == ("list", (("tuple", ("marker",)), 2)) + assert values[1] == 3 -def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module, monkeypatch): - """Dict sanitization should read a point-in-time snapshot before recursive descent.""" +def test_signature_to_hashable_snapshots_dict_before_recursing(caching_module, monkeypatch): + """Dict canonicalization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module - original = caching._sanitize_signature_input - marker = object() + original = caching._signature_to_hashable_impl + marker = ("marker",) values = {"first": marker, "second": 2} - def mutating_sanitize(obj, *args, **kwargs): + def mutating_canonicalize(obj, *args, **kwargs): """Mutate the live dict during recursion to verify snapshot-based traversal.""" if obj is marker: values["second"] = 3 return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", mutating_canonicalize) - sanitized = original(values) + signature = caching._signature_to_hashable(values) - assert isinstance(sanitized, dict) - assert sanitized["second"] == 2 + assert signature == ("dict", (("first", ("tuple", ("marker",))), ("second", 2))) + assert values["second"] == 3 @pytest.mark.parametrize( @@ -178,31 +173,31 @@ def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module lambda marker: {marker: "value"}, ], ) -def test_sanitize_signature_input_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): - """Traversal RuntimeError should degrade sanitization to Unhashable.""" +def test_signature_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade canonicalization to Unhashable.""" caching, _ = caching_module - original = caching._sanitize_signature_input + original = caching._signature_to_hashable_impl marker = object() - def raising_sanitize(obj, *args, **kwargs): + def raising_canonicalize(obj, *args, **kwargs): """Raise a traversal RuntimeError for the marker value and delegate otherwise.""" if obj is marker: raise RuntimeError("container changed during iteration") return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", raising_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", raising_canonicalize) - sanitized = original(container_factory(marker)) + signature = caching._signature_to_hashable(container_factory(marker)) - assert isinstance(sanitized, caching.Unhashable) + assert isinstance(signature, caching.Unhashable) def test_to_hashable_handles_shared_builtin_substructures(caching_module): - """Repeated sanitized content should hash stably for shared substructures.""" + """The legacy helper should still hash sanitized built-ins stably when used directly.""" caching, _ = caching_module shared = [{"value": 1}, {"value": 2}] - sanitized = caching._sanitize_signature_input([shared, shared]) + sanitized = [shared, shared] hashable = caching.to_hashable(sanitized) assert hashable[0] == "list" @@ -232,7 +227,7 @@ def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, c assert isinstance(hashable, caching.Unhashable) -def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): +def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module ambiguous = { @@ -240,7 +235,7 @@ def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(cachi _OpaqueValue(): _OpaqueValue(), } - sanitized = caching._sanitize_signature_input(ambiguous) + sanitized = caching._signature_to_hashable(ambiguous) assert isinstance(sanitized, caching.Unhashable) From a6624a9afd2494bc3fdd05df5778c49699802c9a Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:09:24 +0100 Subject: [PATCH 19/39] Unify signature sanitize and hash --- comfy_execution/caching.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index f1b5227db..caa5d4a48 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -252,6 +252,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): memo = {} active = set() + snapshots = {} sort_memo = {} processed = 0 stack = [(obj, False)] @@ -263,12 +264,12 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return value return memo.get(id(value), Unhashable()) - def resolve_unordered_values(current, container_tag): + def resolve_unordered_values(current_items, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" try: ordered_items = [ (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) - for item in current + for item in current_items ] ordered_items.sort(key=lambda item: item[0]) except RuntimeError: @@ -300,18 +301,33 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.discard(current_id) try: if current_type is dict: + items = snapshots.pop(current_id, None) + if items is None: + items = list(current.items()) memo[current_id] = ( "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + tuple((resolve_value(k), resolve_value(v)) for k, v in items), ) elif current_type is list: - memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = ("list", tuple(resolve_value(item) for item in items)) elif current_type is tuple: - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in items)) elif current_type is set: - memo[current_id] = resolve_unordered_values(current, "set") + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = resolve_unordered_values(items, "set") else: - memo[current_id] = resolve_unordered_values(current, "frozenset") + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = resolve_unordered_values(items, "frozenset") except RuntimeError: memo[current_id] = Unhashable() continue @@ -329,6 +345,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): if current_type is dict: try: items = list(current.items()) + snapshots[current_id] = items except RuntimeError: memo[current_id] = Unhashable() active.discard(current_id) @@ -339,6 +356,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): else: try: items = list(current) + snapshots[current_id] = items except RuntimeError: memo[current_id] = Unhashable() active.discard(current_id) From 24fdbb9aca552f000486185136b8372a00be457f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:30:18 +0100 Subject: [PATCH 20/39] Replace sanitize hash two pass --- comfy_execution/caching.py | 14 +++++++------- tests-unit/execution_test/caching_test.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index caa5d4a48..130b0dd5e 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -170,17 +170,17 @@ def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, ac return _FAILED_SIGNATURE key_value, key_sort = key_result value_value, value_sort = value_result - ordered_items.append((((key_sort, value_sort)), (key_value, value_value))) + ordered_items.append((key_sort, value_sort, key_value, value_value)) - ordered_items.sort(key=lambda item: item[0]) + ordered_items.sort(key=lambda item: (item[0], item[1])) for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: + previous_key_sort = ordered_items[index - 1][0] + current_key_sort = ordered_items[index][0] + if previous_key_sort == current_key_sort: return _FAILED_SIGNATURE - value = ("dict", tuple(item for _, item in ordered_items)) - sort_key = ("dict", tuple(sort_key for sort_key, _ in ordered_items)) + value = ("dict", tuple((key_value, value_value) for _, _, key_value, value_value in ordered_items)) + sort_key = ("dict", tuple((key_sort, value_sort) for key_sort, value_sort, _, _ in ordered_items)) elif obj_type is list or obj_type is tuple: try: items = list(obj) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index a7dffcee0..9e813eec7 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -240,6 +240,28 @@ def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_ assert isinstance(sanitized, caching.Unhashable) +def test_signature_to_hashable_fails_closed_on_dict_key_sort_collisions_even_with_distinct_values(caching_module, monkeypatch): + """Different values must not mask dict key-sort collisions during canonicalization.""" + caching, _ = caching_module + original = caching._signature_to_hashable_impl + key_a = object() + key_b = object() + + def colliding_key_canonicalize(obj, *args, **kwargs): + """Force two distinct raw keys to share the same canonical sort key.""" + if obj is key_a: + return ("key-a", ("COLLIDE",)) + if obj is key_b: + return ("key-b", ("COLLIDE",)) + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_signature_to_hashable_impl", colliding_key_canonicalize) + + sanitized = caching._signature_to_hashable({key_a: 1, key_b: 2}) + + assert isinstance(sanitized, caching.Unhashable) + + @pytest.mark.parametrize( "container_factory", [ From dbed5a1b52ba5ce80dd938a446fb513971114885 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:39:10 +0100 Subject: [PATCH 21/39] Replace sanitize and hash passes --- comfy_execution/caching.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 130b0dd5e..02e36ec68 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -417,9 +417,11 @@ class CacheKeySetInputSignature(CacheKeySet): return _signature_to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): - """Build the cache-signature fragment for a node's immediate inputs. + """Build the immediate cache-signature fragment for a node. - Link inputs are reduced to ancestor references, while raw values are sanitized first. + Link inputs are reduced to ancestor references here, while non-link + values are appended as-is. Full canonicalization happens later in + `get_node_signature()` via `_signature_to_hashable()`. """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. From f1d91a4c8cdb1dad6b0363ac8d8951ff08fdcf13 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 16:14:23 +0100 Subject: [PATCH 22/39] Prevent canonicalizing is_changed --- comfy_execution/caching.py | 18 +++++++++- tests-unit/execution_test/caching_test.py | 44 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 02e36ec68..0342d7ec2 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -67,6 +67,22 @@ _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 _FAILED_SIGNATURE = object() +def _shallow_is_changed_signature(value): + """Sanitize execution-time `is_changed` values without deep recursion.""" + value_type = type(value) + if value_type in _PRIMITIVE_SIGNATURE_TYPES: + return value + if value_type is list or value_type is tuple: + try: + items = tuple(value) + except RuntimeError: + return Unhashable() + if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items): + container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" + return (container_tag, items) + return Unhashable() + + def _primitive_signature_sort_key(obj): """Return a deterministic ordering key for primitive signature values.""" obj_type = type(obj) @@ -429,7 +445,7 @@ class CacheKeySetInputSignature(CacheKeySet): node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] - signature = [class_type, await self.is_changed_cache.get(node_id)] + signature = [class_type, _shallow_is_changed_signature(await self.is_changed_cache.get(node_id))] if self.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or include_unique_id_in_input(class_type): signature.append(node_id) inputs = node["inputs"] diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 9e813eec7..c86359aa9 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -309,3 +309,47 @@ def test_get_node_signature_returns_top_level_unhashable_for_tainted_signature(c signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) assert isinstance(signature, caching.Unhashable) + + +def test_shallow_is_changed_signature_accepts_primitive_lists(caching_module): + """Primitive-only `is_changed` lists should stay hashable without deep descent.""" + caching, _ = caching_module + + sanitized = caching._shallow_is_changed_signature([1, "two", None, True]) + + assert sanitized == ("is_changed_list", (1, "two", None, True)) + + +def test_shallow_is_changed_signature_fails_closed_on_nested_containers(caching_module): + """Nested containers from `is_changed` should be rejected immediately.""" + caching, _ = caching_module + + sanitized = caching._shallow_is_changed_signature([1, ["nested"]]) + + assert isinstance(sanitized, caching.Unhashable) + + +def test_get_immediate_node_signature_marks_recursive_is_changed_unhashable(caching_module, monkeypatch): + """Recursive `is_changed` payloads should be cut off before signature canonicalization.""" + caching, nodes_module = caching_module + monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + + is_changed_value = [] + is_changed_value.append(is_changed_value) + dynprompt = _FakeDynPrompt( + { + "node": { + "class_type": "UnitTestNode", + "inputs": {"value": 5}, + } + } + ) + key_set = caching.CacheKeySetInputSignature( + dynprompt, + ["node"], + _FakeIsChangedCache({"node": is_changed_value}), + ) + + signature = asyncio.run(key_set.get_immediate_node_signature(dynprompt, "node", {})) + + assert isinstance(signature[1], caching.Unhashable) From 4c5f82971eb7f33dc7ebccde24534d3b7b07d98a Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 16:44:25 +0100 Subject: [PATCH 23/39] Restrict is_changed canonicalization --- comfy_execution/caching.py | 23 +++++++++++++++++----- tests-unit/execution_test/caching_test.py | 24 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 0342d7ec2..ca8b15918 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -246,7 +246,10 @@ def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, ac def _signature_to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): """Build the final cache-signature representation in one fail-closed pass.""" - result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + try: + result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + except RuntimeError: + return Unhashable() if result is _FAILED_SIGNATURE: return Unhashable() return result[0] @@ -320,10 +323,20 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): items = snapshots.pop(current_id, None) if items is None: items = list(current.items()) - memo[current_id] = ( - "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in items), - ) + ordered_items = [ + (_sanitized_sort_key(k, memo=sort_memo), resolve_value(k), resolve_value(v)) + for k, v in items + ] + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + if ordered_items[index - 1][0] == ordered_items[index][0]: + memo[current_id] = Unhashable() + break + else: + memo[current_id] = ( + "dict", + tuple((key, value) for _, key, value in ordered_items), + ) elif current_type is list: items = snapshots.pop(current_id, None) if items is None: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c86359aa9..c96f40722 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -205,6 +205,17 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +def test_to_hashable_canonicalizes_dict_insertion_order(caching_module): + """Dicts with the same content should hash identically regardless of insertion order.""" + caching, _ = caching_module + + first = {"b": 2, "a": 1} + second = {"a": 1, "b": 2} + + assert caching.to_hashable(first) == ("dict", (("a", 1), ("b", 2))) + assert caching.to_hashable(first) == caching.to_hashable(second) + + @pytest.mark.parametrize( "container_factory", [ @@ -227,6 +238,19 @@ def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, c assert isinstance(hashable, caching.Unhashable) +def test_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): + """Ambiguous dict key ordering should fail closed instead of using insertion order.""" + caching, _ = caching_module + ambiguous = { + _OpaqueValue(): 1, + _OpaqueValue(): 2, + } + + hashable = caching.to_hashable(ambiguous) + + assert isinstance(hashable, caching.Unhashable) + + def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module From 088778c35d4e53dd4c81bb2fc07f7a9999103e5c Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 17:06:20 +0100 Subject: [PATCH 24/39] Stop canonicalizing is_changed --- comfy_execution/caching.py | 21 +++++++++++++++++++-- tests-unit/execution_test/caching_test.py | 9 +++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index ca8b15918..d3a7ec52a 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -283,6 +283,10 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return value return memo.get(id(value), Unhashable()) + def is_failed(value): + """Return whether a resolved child value represents failed canonicalization.""" + return type(value) is Unhashable + def resolve_unordered_values(current_items, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" try: @@ -290,6 +294,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) for item in current_items ] + if any(is_failed(value) for _, value in ordered_items): + return Unhashable() ordered_items.sort(key=lambda item: item[0]) except RuntimeError: return Unhashable() @@ -327,6 +333,9 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): (_sanitized_sort_key(k, memo=sort_memo), resolve_value(k), resolve_value(v)) for k, v in items ] + if any(is_failed(key) or is_failed(value) for _, key, value in ordered_items): + memo[current_id] = Unhashable() + continue ordered_items.sort(key=lambda item: item[0]) for index in range(1, len(ordered_items)): if ordered_items[index - 1][0] == ordered_items[index][0]: @@ -341,12 +350,20 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): items = snapshots.pop(current_id, None) if items is None: items = list(current) - memo[current_id] = ("list", tuple(resolve_value(item) for item in items)) + resolved_items = tuple(resolve_value(item) for item in items) + if any(is_failed(item) for item in resolved_items): + memo[current_id] = Unhashable() + else: + memo[current_id] = ("list", resolved_items) elif current_type is tuple: items = snapshots.pop(current_id, None) if items is None: items = list(current) - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in items)) + resolved_items = tuple(resolve_value(item) for item in items) + if any(is_failed(item) for item in resolved_items): + memo[current_id] = Unhashable() + else: + memo[current_id] = ("tuple", resolved_items) elif current_type is set: items = snapshots.pop(current_id, None) if items is None: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c96f40722..943f72586 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -205,6 +205,15 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +def test_to_hashable_fails_closed_for_ordered_container_with_opaque_child(caching_module): + """Ordered containers should fail closed when a child cannot be canonicalized.""" + caching, _ = caching_module + + result = caching.to_hashable([object()]) + + assert isinstance(result, caching.Unhashable) + + def test_to_hashable_canonicalizes_dict_insertion_order(caching_module): """Dicts with the same content should hash identically regardless of insertion order.""" caching, _ = caching_module From fce22da313d8dc7f5c0d08d71cd86d65601aa7c1 Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 09:29:00 +0100 Subject: [PATCH 25/39] Prevent signature traversal of raw --- comfy_execution/caching.py | 8 +-- tests/execution/test_caching.py | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/execution/test_caching.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index d3a7ec52a..0a1bd188c 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -465,9 +465,9 @@ class CacheKeySetInputSignature(CacheKeySet): async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): """Build the immediate cache-signature fragment for a node. - Link inputs are reduced to ancestor references here, while non-link - values are appended as-is. Full canonicalization happens later in - `get_node_signature()` via `_signature_to_hashable()`. + Link inputs are reduced to ancestor references here. Non-link values + are canonicalized or failed closed before being appended so the outer + node-signature pass never recurses into live prompt input containers. """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. @@ -485,7 +485,7 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, inputs[key])) + signature.append((key, to_hashable(inputs[key]))) return signature # This function returns a list of all ancestors of the given node. The order of the list is diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py new file mode 100644 index 000000000..f5851c980 --- /dev/null +++ b/tests/execution/test_caching.py @@ -0,0 +1,103 @@ +import asyncio + +from comfy_execution import caching + + +class _StubDynPrompt: + def __init__(self, nodes): + self._nodes = nodes + + def has_node(self, node_id): + return node_id in self._nodes + + def get_node(self, node_id): + return self._nodes[node_id] + + +class _StubIsChangedCache: + async def get(self, node_id): + return None + + +class _StubNode: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + +def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch): + live_value = [1, {"nested": [2, 3]}] + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": live_value}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {})) + + assert signature == [ + "TestCacheNode", + None, + ("value", ("list", (1, ("dict", (("nested", ("list", (2, 3))),))))), + ] + + +def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(monkeypatch): + class OpaqueRuntimeValue: + pass + + live_value = OpaqueRuntimeValue() + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": live_value}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {})) + + assert signature[:2] == ["TestCacheNode", None] + assert signature[2][0] == "value" + assert type(signature[2][1]) is caching.Unhashable + + +def test_get_node_signature_never_visits_raw_non_link_input(monkeypatch): + live_value = [1, 2, 3] + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": live_value}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + + original_impl = caching._signature_to_hashable_impl + + def guarded_impl(obj, *args, **kwargs): + if obj is live_value: + raise AssertionError("raw non-link input reached outer signature canonicalizer") + return original_impl(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_signature_to_hashable_impl", guarded_impl) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_node_signature(dynprompt, "1")) + + assert isinstance(signature, tuple) From bff714dda0361874fefa64cd259103644cf59397 Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 10:13:04 +0100 Subject: [PATCH 26/39] Fix non-link input cache signature --- comfy_execution/caching.py | 19 ++++++++++--------- tests-unit/execution_test/caching_test.py | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 0a1bd188c..4cf2c3516 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -68,19 +68,20 @@ _FAILED_SIGNATURE = object() def _shallow_is_changed_signature(value): - """Sanitize execution-time `is_changed` values without deep recursion.""" + """Sanitize execution-time `is_changed` values with a small fail-closed budget.""" value_type = type(value) if value_type in _PRIMITIVE_SIGNATURE_TYPES: return value + + canonical = to_hashable(value, max_nodes=64) + if type(canonical) is Unhashable: + return canonical + if value_type is list or value_type is tuple: - try: - items = tuple(value) - except RuntimeError: - return Unhashable() - if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items): - container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" - return (container_tag, items) - return Unhashable() + container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" + return (container_tag, canonical[1]) + + return canonical def _primitive_signature_sort_key(obj): diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 943f72586..6effca064 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -353,11 +353,26 @@ def test_shallow_is_changed_signature_accepts_primitive_lists(caching_module): assert sanitized == ("is_changed_list", (1, "two", None, True)) -def test_shallow_is_changed_signature_fails_closed_on_nested_containers(caching_module): - """Nested containers from `is_changed` should be rejected immediately.""" +def test_shallow_is_changed_signature_accepts_structured_builtin_fingerprint_lists(caching_module): + """Structured built-in `is_changed` fingerprints should remain representable.""" caching, _ = caching_module - sanitized = caching._shallow_is_changed_signature([1, ["nested"]]) + sanitized = caching._shallow_is_changed_signature([("seed", 42), {"cfg": 8}]) + + assert sanitized == ( + "is_changed_list", + ( + ("tuple", ("seed", 42)), + ("dict", (("cfg", 8),)), + ), + ) + + +def test_shallow_is_changed_signature_fails_closed_for_opaque_payload(caching_module): + """Opaque `is_changed` payloads should still fail closed.""" + caching, _ = caching_module + + sanitized = caching._shallow_is_changed_signature([_OpaqueValue()]) assert isinstance(sanitized, caching.Unhashable) From 6158cd582073bd4c19ae4bfe2005f435e1b71de5 Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 13:31:02 +0100 Subject: [PATCH 27/39] Prevent redundant signature rewalk --- comfy_execution/caching.py | 10 ++-- tests/execution/test_caching.py | 84 ++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 4cf2c3516..3a04f8651 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -461,18 +461,18 @@ class CacheKeySetInputSignature(CacheKeySet): 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 _signature_to_hashable(signature) + return tuple(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): """Build the immediate cache-signature fragment for a node. Link inputs are reduced to ancestor references here. Non-link values - are canonicalized or failed closed before being appended so the outer - node-signature pass never recurses into live prompt input containers. + are canonicalized or failed closed before being appended so the final + node signature is assembled from already-hashable fragments. """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. - return [float("NaN")] + return (float("NaN"),) node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] @@ -487,7 +487,7 @@ class CacheKeySetInputSignature(CacheKeySet): signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: signature.append((key, to_hashable(inputs[key]))) - return signature + return tuple(signature) # This function returns a list of all ancestors of the given node. The order of the list is # deterministic based on which specific inputs the ancestor is connected by. diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py index f5851c980..192c3102e 100644 --- a/tests/execution/test_caching.py +++ b/tests/execution/test_caching.py @@ -42,11 +42,11 @@ def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch) keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {})) - assert signature == [ + assert signature == ( "TestCacheNode", None, ("value", ("list", (1, ("dict", (("nested", ("list", (2, 3))),))))), - ] + ) def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(monkeypatch): @@ -69,7 +69,7 @@ def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(mon keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {})) - assert signature[:2] == ["TestCacheNode", None] + assert signature[:2] == ("TestCacheNode", None) assert signature[2][0] == "value" assert type(signature[2][1]) is caching.Unhashable @@ -87,17 +87,77 @@ def test_get_node_signature_never_visits_raw_non_link_input(monkeypatch): monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) - - original_impl = caching._signature_to_hashable_impl - - def guarded_impl(obj, *args, **kwargs): - if obj is live_value: - raise AssertionError("raw non-link input reached outer signature canonicalizer") - return original_impl(obj, *args, **kwargs) - - monkeypatch.setattr(caching, "_signature_to_hashable_impl", guarded_impl) + monkeypatch.setattr( + caching, + "_signature_to_hashable", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("outer signature canonicalizer should not run") + ), + ) keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) signature = asyncio.run(keyset.get_node_signature(dynprompt, "1")) assert isinstance(signature, tuple) + + +def test_get_node_signature_keeps_deep_canonicalized_input_fragment(monkeypatch): + live_value = 1 + for _ in range(8): + live_value = [live_value] + expected = caching.to_hashable(live_value) + + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": live_value}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_node_signature(dynprompt, "1")) + + assert isinstance(signature, tuple) + assert signature[0][2][0] == "value" + assert signature[0][2][1] == expected + + +def test_get_node_signature_keeps_large_precanonicalized_fragment(monkeypatch): + live_value = object() + canonical_fragment = ("tuple", tuple(("list", (index, index + 1)) for index in range(256))) + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": live_value}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + monkeypatch.setattr( + caching, + "to_hashable", + lambda value, max_nodes=caching._MAX_SIGNATURE_CONTAINER_VISITS: ( + canonical_fragment if value is live_value else caching.Unhashable() + ), + ) + monkeypatch.setattr( + caching, + "_signature_to_hashable", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("outer signature canonicalizer should not run") + ), + ) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_node_signature(dynprompt, "1")) + + assert isinstance(signature, tuple) + assert signature[0][2] == ("value", canonical_fragment) From a6472b15147b6cc53ad018e7f5869e9baa72b9df Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 15:34:15 +0100 Subject: [PATCH 28/39] Fix to_hashable traversal stack handling --- comfy_execution/caching.py | 15 ++++++++------- tests/execution/test_caching.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 3a04f8651..6c99274a5 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -275,7 +275,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): snapshots = {} sort_memo = {} processed = 0 - stack = [(obj, False)] + # Keep traversal state separate from container snapshots/results. + work_stack = [(obj, False)] def resolve_value(value): """Resolve a child value from the completed memo table when available.""" @@ -309,8 +310,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return (container_tag, tuple(value for _, value in ordered_items)) - while stack: - current, expanded = stack.pop() + while work_stack: + current, expanded = work_stack.pop() current_type = type(current) if current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable: @@ -388,7 +389,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return Unhashable() active.add(current_id) - stack.append((current, True)) + work_stack.append((current, True)) if current_type is dict: try: items = list(current.items()) @@ -398,8 +399,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.discard(current_id) continue for key, value in reversed(items): - stack.append((value, False)) - stack.append((key, False)) + work_stack.append((value, False)) + work_stack.append((key, False)) else: try: items = list(current) @@ -409,7 +410,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.discard(current_id) continue for item in reversed(items): - stack.append((item, False)) + work_stack.append((item, False)) return memo.get(id(obj), Unhashable()) diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py index 192c3102e..a92a8a416 100644 --- a/tests/execution/test_caching.py +++ b/tests/execution/test_caching.py @@ -49,6 +49,21 @@ def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch) ) +def test_to_hashable_walks_dicts_without_rebinding_traversal_stack(): + live_value = { + "outer": {"nested": [2, 3]}, + "items": [{"leaf": 4}], + } + + assert caching.to_hashable(live_value) == ( + "dict", + ( + ("items", ("list", (("dict", (("leaf", 4),)),))), + ("outer", ("dict", (("nested", ("list", (2, 3))),))), + ), + ) + + def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(monkeypatch): class OpaqueRuntimeValue: pass From 1a00f7743f83e880911db9a67ea3a6764ba741e0 Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 16:10:01 +0100 Subject: [PATCH 29/39] Stop traversing dict keys --- comfy_execution/caching.py | 18 +++++++++++++----- tests-unit/execution_test/caching_test.py | 9 +++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 6c99274a5..dc785d21f 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -332,10 +332,10 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): if items is None: items = list(current.items()) ordered_items = [ - (_sanitized_sort_key(k, memo=sort_memo), resolve_value(k), resolve_value(v)) + (_sanitized_sort_key(k, memo=sort_memo), k, resolve_value(v)) for k, v in items ] - if any(is_failed(key) or is_failed(value) for _, key, value in ordered_items): + if any(type(key) not in _PRIMITIVE_SIGNATURE_TYPES or is_failed(value) for _, key, value in ordered_items): memo[current_id] = Unhashable() continue ordered_items.sort(key=lambda item: item[0]) @@ -398,9 +398,17 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): memo[current_id] = Unhashable() active.discard(current_id) continue - for key, value in reversed(items): - work_stack.append((value, False)) - work_stack.append((key, False)) + for key, value in items: + if type(key) not in _PRIMITIVE_SIGNATURE_TYPES: + snapshots.pop(current_id, None) + memo[current_id] = Unhashable() + active.discard(current_id) + break + else: + for _, value in reversed(items): + work_stack.append((value, False)) + continue + continue else: try: items = list(current) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 6effca064..2c63f68c8 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -225,6 +225,15 @@ def test_to_hashable_canonicalizes_dict_insertion_order(caching_module): assert caching.to_hashable(first) == caching.to_hashable(second) +def test_to_hashable_fails_closed_for_opaque_dict_key(caching_module): + """Opaque dict keys should fail closed instead of being traversed during hashing.""" + caching, _ = caching_module + + hashable = caching.to_hashable({_OpaqueValue(): 1}) + + assert isinstance(hashable, caching.Unhashable) + + @pytest.mark.parametrize( "container_factory", [ From ce05e377a805199e3513ffcaa1fa37816582304d Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 16:48:42 +0100 Subject: [PATCH 30/39] Stop canonicalizing dict keys --- comfy_execution/caching.py | 20 +++++++++++++---- tests-unit/execution_test/caching_test.py | 6 +++--- tests/execution/test_caching.py | 26 ++++++++++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index dc785d21f..6610150aa 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -467,9 +467,15 @@ class CacheKeySetInputSignature(CacheKeySet): """Build the full cache signature for a node and its ordered ancestors.""" signature = [] ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id) - signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) + immediate = await self.get_immediate_node_signature(dynprompt, node_id, order_mapping) + if type(immediate) is Unhashable: + return immediate + signature.append(immediate) for ancestor_id in ancestors: - signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping)) + immediate = await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping) + if type(immediate) is Unhashable: + return immediate + signature.append(immediate) return tuple(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): @@ -485,7 +491,10 @@ class CacheKeySetInputSignature(CacheKeySet): node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] - signature = [class_type, _shallow_is_changed_signature(await self.is_changed_cache.get(node_id))] + is_changed_signature = _shallow_is_changed_signature(await self.is_changed_cache.get(node_id)) + if type(is_changed_signature) is Unhashable: + return is_changed_signature + signature = [class_type, is_changed_signature] if self.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or include_unique_id_in_input(class_type): signature.append(node_id) inputs = node["inputs"] @@ -495,7 +504,10 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, to_hashable(inputs[key]))) + value_signature = to_hashable(inputs[key]) + if type(value_signature) is Unhashable: + return value_signature + signature.append((key, value_signature)) return tuple(signature) # This function returns a list of all ancestors of the given node. The order of the list is diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 2c63f68c8..6d9d38bce 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -386,8 +386,8 @@ def test_shallow_is_changed_signature_fails_closed_for_opaque_payload(caching_mo assert isinstance(sanitized, caching.Unhashable) -def test_get_immediate_node_signature_marks_recursive_is_changed_unhashable(caching_module, monkeypatch): - """Recursive `is_changed` payloads should be cut off before signature canonicalization.""" +def test_get_immediate_node_signature_fails_closed_for_unhashable_is_changed(caching_module, monkeypatch): + """Recursive `is_changed` payloads should fail the full fragment closed.""" caching, nodes_module = caching_module monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) @@ -409,4 +409,4 @@ def test_get_immediate_node_signature_marks_recursive_is_changed_unhashable(cach signature = asyncio.run(key_set.get_immediate_node_signature(dynprompt, "node", {})) - assert isinstance(signature[1], caching.Unhashable) + assert isinstance(signature, caching.Unhashable) diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py index a92a8a416..569bf5bd8 100644 --- a/tests/execution/test_caching.py +++ b/tests/execution/test_caching.py @@ -84,9 +84,29 @@ def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(mon keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {})) - assert signature[:2] == ("TestCacheNode", None) - assert signature[2][0] == "value" - assert type(signature[2][1]) is caching.Unhashable + assert isinstance(signature, caching.Unhashable) + + +def test_get_node_signature_propagates_unhashable_immediate_fragment(monkeypatch): + class OpaqueRuntimeValue: + pass + + dynprompt = _StubDynPrompt( + { + "1": { + "class_type": "TestCacheNode", + "inputs": {"value": OpaqueRuntimeValue()}, + } + } + ) + + monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode) + monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {}) + + keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache()) + signature = asyncio.run(keyset.get_node_signature(dynprompt, "1")) + + assert isinstance(signature, caching.Unhashable) def test_get_node_signature_never_visits_raw_non_link_input(monkeypatch): From 6e3bd33665ed1f48d58f1b07c62f05b976ad9240 Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 17:06:09 +0100 Subject: [PATCH 31/39] Prevent dict key canonicalization --- comfy_execution/caching.py | 4 ++-- tests-unit/execution_test/caching_test.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 6610150aa..00a3444ac 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -179,9 +179,9 @@ def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, ac ordered_items = [] for key, value in items: - key_result = _signature_to_hashable_impl(key, depth + 1, max_depth, active, memo, budget) - if key_result is _FAILED_SIGNATURE: + if type(key) not in _PRIMITIVE_SIGNATURE_TYPES: return _FAILED_SIGNATURE + key_result = (key, _primitive_signature_sort_key(key)) value_result = _signature_to_hashable_impl(value, depth + 1, max_depth, active, memo, budget) if value_result is _FAILED_SIGNATURE: return _FAILED_SIGNATURE diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 6d9d38bce..36d5b0688 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -282,6 +282,15 @@ def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_ assert isinstance(sanitized, caching.Unhashable) +def test_signature_to_hashable_fails_closed_for_opaque_dict_key(caching_module): + """Opaque dict keys should fail closed instead of being recursively canonicalized.""" + caching, _ = caching_module + + sanitized = caching._signature_to_hashable({_OpaqueValue(): 1}) + + assert isinstance(sanitized, caching.Unhashable) + + def test_signature_to_hashable_fails_closed_on_dict_key_sort_collisions_even_with_distinct_values(caching_module, monkeypatch): """Different values must not mask dict key-sort collisions during canonicalization.""" caching, _ = caching_module From c1ce00287cb4613e53b2c3a15362242c1e3058ac Mon Sep 17 00:00:00 2001 From: xmarre Date: Mon, 16 Mar 2026 19:21:24 +0100 Subject: [PATCH 32/39] Stop requeueing live containers --- comfy_execution/caching.py | 41 +++++++++++------------ tests-unit/execution_test/caching_test.py | 20 +++++++++++ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 00a3444ac..c971a29f2 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -311,26 +311,34 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return (container_tag, tuple(value for _, value in ordered_items)) while work_stack: - current, expanded = work_stack.pop() - current_type = type(current) + entry = work_stack.pop() + if len(entry) == 3: + _, current_id, current_type = entry + current = None + expanded = True + else: + current, expanded = entry + current_type = type(current) + current_id = id(current) - if current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable: + if not expanded and (current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable): continue - if current_type not in _CONTAINER_SIGNATURE_TYPES: - memo[id(current)] = Unhashable() + if not expanded and current_type not in _CONTAINER_SIGNATURE_TYPES: + memo[current_id] = Unhashable() continue - current_id = id(current) if current_id in memo: continue if expanded: active.discard(current_id) try: + items = snapshots.pop(current_id, None) + if items is None: + memo[current_id] = Unhashable() + continue + if current_type is dict: - items = snapshots.pop(current_id, None) - if items is None: - items = list(current.items()) ordered_items = [ (_sanitized_sort_key(k, memo=sort_memo), k, resolve_value(v)) for k, v in items @@ -349,32 +357,20 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): tuple((key, value) for _, key, value in ordered_items), ) elif current_type is list: - items = snapshots.pop(current_id, None) - if items is None: - items = list(current) resolved_items = tuple(resolve_value(item) for item in items) if any(is_failed(item) for item in resolved_items): memo[current_id] = Unhashable() else: memo[current_id] = ("list", resolved_items) elif current_type is tuple: - items = snapshots.pop(current_id, None) - if items is None: - items = list(current) resolved_items = tuple(resolve_value(item) for item in items) if any(is_failed(item) for item in resolved_items): memo[current_id] = Unhashable() else: memo[current_id] = ("tuple", resolved_items) elif current_type is set: - items = snapshots.pop(current_id, None) - if items is None: - items = list(current) memo[current_id] = resolve_unordered_values(items, "set") else: - items = snapshots.pop(current_id, None) - if items is None: - items = list(current) memo[current_id] = resolve_unordered_values(items, "frozenset") except RuntimeError: memo[current_id] = Unhashable() @@ -389,7 +385,6 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return Unhashable() active.add(current_id) - work_stack.append((current, True)) if current_type is dict: try: items = list(current.items()) @@ -405,6 +400,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.discard(current_id) break else: + work_stack.append(("EXPANDED", current_id, current_type)) for _, value in reversed(items): work_stack.append((value, False)) continue @@ -417,6 +413,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): memo[current_id] = Unhashable() active.discard(current_id) continue + work_stack.append(("EXPANDED", current_id, current_type)) for item in reversed(items): work_stack.append((item, False)) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 36d5b0688..e11e01285 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -205,6 +205,26 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +def test_to_hashable_uses_parent_snapshot_during_expanded_phase(caching_module, monkeypatch): + """Expanded-phase assembly should not reread a live parent container after snapshotting.""" + caching, _ = caching_module + original_sort_key = caching._sanitized_sort_key + outer = [{"marker"}, 2] + + def mutating_sort_key(obj, *args, **kwargs): + """Mutate the live parent while a child container is being canonicalized.""" + if obj == "marker": + outer[1] = 3 + return original_sort_key(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitized_sort_key", mutating_sort_key) + + hashable = caching.to_hashable(outer) + + assert hashable == ("list", (("set", ("marker",)), 2)) + assert outer[1] == 3 + + def test_to_hashable_fails_closed_for_ordered_container_with_opaque_child(caching_module): """Ordered containers should fail closed when a child cannot be canonicalized.""" caching, _ = caching_module From fdcc38b9eaee2aad779b3a769cb312fb40d1eafc Mon Sep 17 00:00:00 2001 From: xmarre Date: Tue, 17 Mar 2026 07:48:14 +0100 Subject: [PATCH 33/39] Return Unhashable on missing node --- comfy_execution/caching.py | 2 +- tests-unit/execution_test/caching_test.py | 82 ++++++++++++++++------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index c971a29f2..fecf54d1e 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -484,7 +484,7 @@ class CacheKeySetInputSignature(CacheKeySet): """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. - return (float("NaN"),) + return Unhashable() node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index e11e01285..a21dea628 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -170,7 +170,7 @@ def test_signature_to_hashable_snapshots_dict_before_recursing(caching_module, m lambda marker: (marker,), lambda marker: {marker}, lambda marker: frozenset({marker}), - lambda marker: {marker: "value"}, + lambda marker: {"key": marker}, ], ) def test_signature_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): @@ -276,26 +276,38 @@ def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, c assert isinstance(hashable, caching.Unhashable) -def test_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): +def test_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module, monkeypatch): """Ambiguous dict key ordering should fail closed instead of using insertion order.""" caching, _ = caching_module - ambiguous = { - _OpaqueValue(): 1, - _OpaqueValue(): 2, - } + original_sort_key = caching._sanitized_sort_key + ambiguous = {"a": 1, "b": 1} + + def colliding_sort_key(obj, *args, **kwargs): + """Force two distinct primitive keys to share the same ordering key.""" + if obj == "a" or obj == "b": + return ("COLLIDE",) + return original_sort_key(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitized_sort_key", colliding_sort_key) hashable = caching.to_hashable(ambiguous) assert isinstance(hashable, caching.Unhashable) -def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): +def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module, monkeypatch): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module - ambiguous = { - _OpaqueValue(): _OpaqueValue(), - _OpaqueValue(): _OpaqueValue(), - } + original_sort_key = caching._primitive_signature_sort_key + ambiguous = {"a": 1, "b": 1} + + def colliding_sort_key(obj): + """Force two distinct primitive keys to share the same ordering key.""" + if obj == "a" or obj == "b": + return ("COLLIDE",) + return original_sort_key(obj) + + monkeypatch.setattr(caching, "_primitive_signature_sort_key", colliding_sort_key) sanitized = caching._signature_to_hashable(ambiguous) @@ -314,21 +326,17 @@ def test_signature_to_hashable_fails_closed_for_opaque_dict_key(caching_module): def test_signature_to_hashable_fails_closed_on_dict_key_sort_collisions_even_with_distinct_values(caching_module, monkeypatch): """Different values must not mask dict key-sort collisions during canonicalization.""" caching, _ = caching_module - original = caching._signature_to_hashable_impl - key_a = object() - key_b = object() + original_sort_key = caching._primitive_signature_sort_key - def colliding_key_canonicalize(obj, *args, **kwargs): - """Force two distinct raw keys to share the same canonical sort key.""" - if obj is key_a: - return ("key-a", ("COLLIDE",)) - if obj is key_b: - return ("key-b", ("COLLIDE",)) - return original(obj, *args, **kwargs) + def colliding_sort_key(obj): + """Force two distinct primitive keys to share the same ordering key.""" + if obj == "a" or obj == "b": + return ("COLLIDE",) + return original_sort_key(obj) - monkeypatch.setattr(caching, "_signature_to_hashable_impl", colliding_key_canonicalize) + monkeypatch.setattr(caching, "_primitive_signature_sort_key", colliding_sort_key) - sanitized = caching._signature_to_hashable({key_a: 1, key_b: 2}) + sanitized = caching._signature_to_hashable({"a": 1, "b": 2}) assert isinstance(sanitized, caching.Unhashable) @@ -340,10 +348,19 @@ def test_signature_to_hashable_fails_closed_on_dict_key_sort_collisions_even_wit frozenset, ], ) -def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, container_factory): +def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, monkeypatch, container_factory): """Ambiguous unordered values should fail closed instead of depending on iteration order.""" caching, _ = caching_module - container = container_factory({_OpaqueValue(), _OpaqueValue()}) + original_sort_key = caching._sanitized_sort_key + container = container_factory({"a", "b"}) + + def colliding_sort_key(obj, *args, **kwargs): + """Force two distinct primitive values to share the same ordering key.""" + if obj == "a" or obj == "b": + return ("COLLIDE",) + return original_sort_key(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitized_sort_key", colliding_sort_key) hashable = caching.to_hashable(container) @@ -439,3 +456,18 @@ def test_get_immediate_node_signature_fails_closed_for_unhashable_is_changed(cac signature = asyncio.run(key_set.get_immediate_node_signature(dynprompt, "node", {})) assert isinstance(signature, caching.Unhashable) + + +def test_get_immediate_node_signature_fails_closed_for_missing_node(caching_module): + """Missing nodes should return the fail-closed sentinel instead of a NaN tuple.""" + caching, _ = caching_module + dynprompt = _FakeDynPrompt({}) + key_set = caching.CacheKeySetInputSignature( + dynprompt, + [], + _FakeIsChangedCache({}), + ) + + signature = asyncio.run(key_set.get_immediate_node_signature(dynprompt, "missing", {})) + + assert isinstance(signature, caching.Unhashable) From e13da8104c1314d467272ab1a1e46b84d1180002 Mon Sep 17 00:00:00 2001 From: xmarre Date: Wed, 18 Mar 2026 12:26:30 +0100 Subject: [PATCH 34/39] Fix shallow is_changed handling --- comfy_execution/caching.py | 18 ++++++++++-------- tests/execution/test_caching.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index fecf54d1e..398036eb8 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -68,20 +68,22 @@ _FAILED_SIGNATURE = object() def _shallow_is_changed_signature(value): - """Sanitize execution-time `is_changed` values with a small fail-closed budget.""" + """Reduce execution-time `is_changed` values without deep traversal.""" value_type = type(value) if value_type in _PRIMITIVE_SIGNATURE_TYPES: return value - canonical = to_hashable(value, max_nodes=64) - if type(canonical) is Unhashable: - return canonical - if value_type is list or value_type is tuple: - container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" - return (container_tag, canonical[1]) + try: + items = tuple(value) + except RuntimeError: + return Unhashable() + if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items): + container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" + return (container_tag, items) + return Unhashable() - return canonical + return Unhashable() def _primitive_signature_sort_key(obj): diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py index 569bf5bd8..db6d95063 100644 --- a/tests/execution/test_caching.py +++ b/tests/execution/test_caching.py @@ -25,6 +25,34 @@ class _StubNode: return {"required": {}} +def test_shallow_is_changed_signature_keeps_primitive_only_list_shallow(): + assert caching._shallow_is_changed_signature([1, "two", None, True]) == ( + "is_changed_list", + (1, "two", None, True), + ) + + +def test_shallow_is_changed_signature_keeps_primitive_only_tuple_shallow(): + assert caching._shallow_is_changed_signature((1, "two", None, True)) == ( + "is_changed_tuple", + (1, "two", None, True), + ) + + +def test_shallow_is_changed_signature_fails_closed_for_nested_container(monkeypatch): + monkeypatch.setattr( + caching, + "to_hashable", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("is_changed signature must not deep-canonicalize") + ), + ) + + signature = caching._shallow_is_changed_signature([1, [2, 3]]) + + assert isinstance(signature, caching.Unhashable) + + def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch): live_value = [1, {"nested": [2, 3]}] dynprompt = _StubDynPrompt( From c702cddf754a4fc0a61b046d7f80a730323b6811 Mon Sep 17 00:00:00 2001 From: xmarre Date: Wed, 18 Mar 2026 13:15:04 +0100 Subject: [PATCH 35/39] Fix shallow is_changed logic --- comfy_execution/caching.py | 20 ++++++++++---------- tests/execution/test_caching.py | 22 +++++++++++++++++++--- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 398036eb8..1a5615627 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -68,22 +68,22 @@ _FAILED_SIGNATURE = object() def _shallow_is_changed_signature(value): - """Reduce execution-time `is_changed` values without deep traversal.""" + """Reduce execution-time `is_changed` values through a fail-closed builtin canonicalizer.""" value_type = type(value) if value_type in _PRIMITIVE_SIGNATURE_TYPES: return value - if value_type is list or value_type is tuple: - try: - items = tuple(value) - except RuntimeError: - return Unhashable() - if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items): - container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" - return (container_tag, items) + if value_type not in _CONTAINER_SIGNATURE_TYPES: return Unhashable() - return Unhashable() + canonical = _signature_to_hashable(value, max_nodes=64) + if type(canonical) is Unhashable: + return canonical + if value_type is list or value_type is tuple: + container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" + return (container_tag, canonical[1]) + + return canonical def _primitive_signature_sort_key(obj): diff --git a/tests/execution/test_caching.py b/tests/execution/test_caching.py index db6d95063..765ab0b91 100644 --- a/tests/execution/test_caching.py +++ b/tests/execution/test_caching.py @@ -39,7 +39,17 @@ def test_shallow_is_changed_signature_keeps_primitive_only_tuple_shallow(): ) -def test_shallow_is_changed_signature_fails_closed_for_nested_container(monkeypatch): +def test_shallow_is_changed_signature_keeps_structured_builtin_fingerprint_list(): + assert caching._shallow_is_changed_signature([("seed", 42), {"cfg": 8}]) == ( + "is_changed_list", + ( + ("tuple", ("seed", 42)), + ("dict", (("cfg", 8),)), + ), + ) + + +def test_shallow_is_changed_signature_does_not_use_to_hashable(monkeypatch): monkeypatch.setattr( caching, "to_hashable", @@ -48,9 +58,15 @@ def test_shallow_is_changed_signature_fails_closed_for_nested_container(monkeypa ), ) - signature = caching._shallow_is_changed_signature([1, [2, 3]]) + signature = caching._shallow_is_changed_signature([("seed", 42), {"cfg": 8}]) - assert isinstance(signature, caching.Unhashable) + assert signature == ( + "is_changed_list", + ( + ("tuple", ("seed", 42)), + ("dict", (("cfg", 8),)), + ), + ) def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch): From 9c210473fcb623291d1e6b00156852ddf0892971 Mon Sep 17 00:00:00 2001 From: xmarre Date: Thu, 16 Apr 2026 12:49:49 +0200 Subject: [PATCH 36/39] Fix tiled VAE encode memory admission estimate --- comfy/sd.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/comfy/sd.py b/comfy/sd.py index e207bb0fd..e1a029cb6 100644 --- a/comfy/sd.py +++ b/comfy/sd.py @@ -1083,7 +1083,17 @@ class VAE: else: pixel_samples = pixel_samples.unsqueeze(2) - memory_used = self.memory_used_encode(pixel_samples.shape, self.vae_dtype) # TODO: calculate mem required for tile + if dims == 2: + default_tile_x = 512 if tile_x is None else tile_x + default_tile_y = 512 if tile_y is None else tile_y + tile_shapes = [ + (1, pixel_samples.shape[1], min(pixel_samples.shape[2], max(1, default_tile_y)), min(pixel_samples.shape[3], max(1, default_tile_x))), + (1, pixel_samples.shape[1], min(pixel_samples.shape[2], max(1, default_tile_y // 2)), min(pixel_samples.shape[3], max(1, default_tile_x * 2))), + (1, pixel_samples.shape[1], min(pixel_samples.shape[2], max(1, default_tile_y * 2)), min(pixel_samples.shape[3], max(1, default_tile_x // 2))), + ] + memory_used = max(self.memory_used_encode(shape, self.vae_dtype) for shape in tile_shapes) + else: + memory_used = self.memory_used_encode(pixel_samples.shape, self.vae_dtype) model_management.load_models_gpu([self.patcher], memory_required=memory_used, force_full_load=self.disable_offload) args = {} From 63e08a02fd22f87ac8a027059a629e3113801275 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 30 May 2026 17:43:20 +0200 Subject: [PATCH 37/39] Guard WSL CUDA sync during model load --- comfy/model_management.py | 21 +++++++++++++++++++++ comfy/model_patcher.py | 9 ++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/comfy/model_management.py b/comfy/model_management.py index b01c4d7fa..83cb5d277 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -190,6 +190,14 @@ def is_wsl(): return True return False +_WSL_SOFT_EMPTY_CACHE_SKIP_LOGGED = False + +def wsl_skip_nonforced_soft_empty_cache(): + return is_wsl() and os.getenv("COMFYUI_WSL_SOFT_EMPTY_CACHE", "0") != "1" + +def wsl_skip_model_load_synchronize(): + return is_wsl() and os.getenv("COMFYUI_WSL_MODEL_LOAD_SYNCHRONIZE", "0") != "1" + def get_torch_device(): global directml_enabled global cpu_state @@ -917,7 +925,14 @@ def load_models_gpu(models, memory_required=0, force_patch_weights=False, minimu if vram_set_state == VRAMState.NO_VRAM: lowvram_model_memory = 0.1 + model_name = model.model.__class__.__name__ if hasattr(model, "model") else model.__class__.__name__ + logging.info( + f"Loading model {model_name} start: device={torch_dev} " + f"vram_state={vram_set_state.name} lowvram_model_memory={lowvram_model_memory} " + f"force_full_load={force_full_load} force_patch_weights={force_patch_weights}" + ) loaded_model.model_load(lowvram_model_memory, force_patch_weights=force_patch_weights) + logging.info(f"Loading model {model_name} complete") current_loaded_models.insert(0, loaded_model) return @@ -1955,6 +1970,12 @@ def soft_empty_cache(force=False): elif is_mlu(): torch.mlu.empty_cache() elif torch.cuda.is_available(): + if wsl_skip_nonforced_soft_empty_cache() and not force: + global _WSL_SOFT_EMPTY_CACHE_SKIP_LOGGED + if not _WSL_SOFT_EMPTY_CACHE_SKIP_LOGGED: + logging.info("Skipping non-forced CUDA soft_empty_cache on WSL; set COMFYUI_WSL_SOFT_EMPTY_CACHE=1 to re-enable.") + _WSL_SOFT_EMPTY_CACHE_SKIP_LOGGED = True + return torch.cuda.synchronize() torch.cuda.empty_cache() torch.cuda.ipc_collect() diff --git a/comfy/model_patcher.py b/comfy/model_patcher.py index 00a15fa63..d7a2fb704 100644 --- a/comfy/model_patcher.py +++ b/comfy/model_patcher.py @@ -42,6 +42,8 @@ from comfy.patcher_extension import CallbacksMP, PatcherInjection, WrappersMP import comfy_aimdo.model_vbar +_WSL_MODEL_LOAD_SYNC_SKIP_LOGGED = False + def set_model_options_patch_replace(model_options, patch, name, block_name, number, transformer_index=None): to = model_options["transformer_options"].copy() @@ -1005,6 +1007,11 @@ class ModelPatcher: mem_counter += move_weight_functions(m, device_to) load_completely.sort(reverse=True) + skip_wsl_load_sync = comfy.model_management.is_device_cuda(device_to) and comfy.model_management.wsl_skip_model_load_synchronize() + global _WSL_MODEL_LOAD_SYNC_SKIP_LOGGED + if skip_wsl_load_sync and len(load_completely) > 0 and not _WSL_MODEL_LOAD_SYNC_SKIP_LOGGED: + logging.info("Skipping per-module CUDA synchronize during model load on WSL; set COMFYUI_WSL_MODEL_LOAD_SYNCHRONIZE=1 to re-enable.") + _WSL_MODEL_LOAD_SYNC_SKIP_LOGGED = True for x in load_completely: n = x[1] m = x[2] @@ -1019,7 +1026,7 @@ class ModelPatcher: key = key_param_name_to_key(n, param) self.unpin_weight(key) self.patch_weight_to_device(key, device_to=device_to) - if comfy.model_management.is_device_cuda(device_to): + if comfy.model_management.is_device_cuda(device_to) and not skip_wsl_load_sync: torch.cuda.synchronize() logging.debug("lowvram: loaded module regularly {} {}".format(n, m)) From 07c96b72382e0598abe1a61c42f54bfb0aa243fb Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:50:02 +0200 Subject: [PATCH 38/39] Update README with performance-oriented WSL setup --- README.md | 571 ++++++++++++++---------------------------------------- 1 file changed, 148 insertions(+), 423 deletions(-) diff --git a/README.md b/README.md index bcec86377..207a3f8bd 100644 --- a/README.md +++ b/README.md @@ -1,465 +1,190 @@ -
+# ComfyUI Global Memory Trim -# ComfyUI -**The most powerful and modular AI engine for content creation.** +Global native heap trimming for ComfyUI on Linux/WSL. +This custom node repo installs a small global execution patch when ComfyUI loads custom nodes. The patch can call Python `gc.collect()` and glibc `malloc_trim(0)` before and/or after node execution. It is meant for workflows that repeatedly create large CPU image/video buffers through PyTorch, NumPy, OpenCV, Pillow, or native custom nodes and then stall or wedge under WSL2 memory pressure. -[![Website][website-shield]][website-url] -[![Dynamic JSON Badge][discord-shield]][discord-url] -[![Twitter][twitter-shield]][twitter-url] -[![Matrix][matrix-shield]][matrix-url] -
-[![][github-release-shield]][github-release-link] -[![][github-release-date-shield]][github-release-link] -[![][github-downloads-shield]][github-downloads-link] -[![][github-downloads-latest-shield]][github-downloads-link] +It also provides two optional workflow nodes: -[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white -[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org -[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat -[website-url]: https://www.comfy.org/ - -[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total -[discord-url]: https://discord.com/invite/comfyorg -[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI -[twitter-url]: https://x.com/ComfyUI +- **Global Memory Trim Now**: manually run a trim and return RSS metrics. +- **Global Memory Trim Status**: return current config and last trim result. -[github-release-shield]: https://img.shields.io/github/v/release/comfyanonymous/ComfyUI?style=flat&sort=semver -[github-release-link]: https://github.com/comfyanonymous/ComfyUI/releases -[github-release-date-shield]: https://img.shields.io/github/release-date/comfyanonymous/ComfyUI?style=flat -[github-downloads-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/total?style=flat -[github-downloads-latest-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/latest/total?style=flat&label=downloads%40latest -[github-downloads-link]: https://github.com/comfyanonymous/ComfyUI/releases +The global patch does **not** require adding either node to your workflow. -ComfyUI Screenshot -
-
+## Why this exists -ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more... -- ComfyUI natively supports the latest open-source state of the art models. -- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc. -- It is available on Windows, Linux, and macOS, locally with our [desktop application](https://www.comfy.org/download), our [portable install](#installing) or on our [cloud](https://www.comfy.org/cloud). -- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode. -- It integrates seamlessly into production pipelines with our API endpoints. +Some WSL2 workloads can stall when native libraries repeatedly allocate and free large CPU buffers. Python objects may be gone, but glibc arenas can retain pages. Under a WSL memory cap, that can trigger heavy reclaim or a hard-looking VM stall. `malloc_trim(0)` asks glibc to return free heap pages to the OS. -## Get Started +This repo is intentionally CPU/native-heap focused. It does **not** directly free CUDA VRAM, unload ComfyUI models, delete ComfyUI caches, or change workflow outputs. -### Local +## Installation -#### [Desktop Application](https://www.comfy.org/download) -- The easiest way to get started. -- Available on Windows & macOS. +From your ComfyUI directory: -#### [Windows Portable Package](#installing) -- Get the latest commits and completely portable. -- Available on Windows. - -#### [Manual Install](#manual-install-windows-linux) -Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend). - -### Cloud - -#### [Comfy Cloud](https://www.comfy.org/cloud) -- Our official paid cloud version for those who can't afford local hardware. - -## Examples -See what ComfyUI can do with the [newer template workflows](https://comfy.org/workflows) or old [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/). - -## Features -- Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything. -- NOTE: There are many more models supported than the list below, if you want to see what is supported see our templates list inside ComfyUI. -- Image Models - - SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/)) - - [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/) - - [Stable Cascade](https://comfyanonymous.github.io/ComfyUI_examples/stable_cascade/) - - [SD3 and SD3.5](https://comfyanonymous.github.io/ComfyUI_examples/sd3/) - - Pixart Alpha and Sigma - - [AuraFlow](https://comfyanonymous.github.io/ComfyUI_examples/aura_flow/) - - [HunyuanDiT](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_dit/) - - [Flux](https://comfyanonymous.github.io/ComfyUI_examples/flux/) - - [Lumina Image 2.0](https://comfyanonymous.github.io/ComfyUI_examples/lumina2/) - - [HiDream](https://comfyanonymous.github.io/ComfyUI_examples/hidream/) - - [Qwen Image](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/) - - [Hunyuan Image 2.1](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_image/) - - [Flux 2](https://comfyanonymous.github.io/ComfyUI_examples/flux2/) - - [Z Image](https://comfyanonymous.github.io/ComfyUI_examples/z_image/) - - Ernie Image -- Image Editing Models - - [Omnigen 2](https://comfyanonymous.github.io/ComfyUI_examples/omnigen/) - - [Flux Kontext](https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-kontext-image-editing-model) - - [HiDream E1.1](https://comfyanonymous.github.io/ComfyUI_examples/hidream/#hidream-e11) - - [Qwen Image Edit](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/#edit-model) -- Video Models - - [Stable Video Diffusion](https://comfyanonymous.github.io/ComfyUI_examples/video/) - - [Mochi](https://comfyanonymous.github.io/ComfyUI_examples/mochi/) - - [LTX-Video](https://comfyanonymous.github.io/ComfyUI_examples/ltxv/) - - [Hunyuan Video](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/) - - [Wan 2.1](https://comfyanonymous.github.io/ComfyUI_examples/wan/) - - [Wan 2.2](https://comfyanonymous.github.io/ComfyUI_examples/wan22/) - - [Hunyuan Video 1.5](https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video-1-5) -- Audio Models - - [Stable Audio](https://comfyanonymous.github.io/ComfyUI_examples/audio/) - - [ACE Step](https://comfyanonymous.github.io/ComfyUI_examples/audio/) -- 3D Models - - [Hunyuan3D 2.0](https://docs.comfy.org/tutorials/3d/hunyuan3D-2) -- Asynchronous Queue system -- Many optimizations: Only re-executes the parts of the workflow that changes between executions. -- Smart memory management: can automatically run large models on GPUs with as low as 1GB vram with smart offloading. -- Works even if you don't have a GPU with: ```--cpu``` (slow) -- Can load ckpt and safetensors: All in one checkpoints or standalone diffusion models, VAEs and CLIP models. -- Safe loading of ckpt, pt, pth, etc.. files. -- Embeddings/Textual inversion -- [Loras (regular, locon and loha)](https://comfyanonymous.github.io/ComfyUI_examples/lora/) -- [Hypernetworks](https://comfyanonymous.github.io/ComfyUI_examples/hypernetworks/) -- Loading full workflows (with seeds) from generated PNG, WebP and FLAC files. -- Saving/Loading workflows as Json files. -- Nodes interface can be used to create complex workflows like one for [Hires fix](https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/) or much more advanced ones. -- [Area Composition](https://comfyanonymous.github.io/ComfyUI_examples/area_composition/) -- [Inpainting](https://comfyanonymous.github.io/ComfyUI_examples/inpaint/) with both regular and inpainting models. -- [ControlNet and T2I-Adapter](https://comfyanonymous.github.io/ComfyUI_examples/controlnet/) -- [Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/) -- [GLIGEN](https://comfyanonymous.github.io/ComfyUI_examples/gligen/) -- [Model Merging](https://comfyanonymous.github.io/ComfyUI_examples/model_merging/) -- [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/) -- Latent previews with [TAESD](#how-to-show-high-quality-previews) -- Works fully offline: core will never download anything unless you want to. -- Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes` -- [Config file](extra_model_paths.yaml.example) to set the search paths for models. - -Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/) - -## Release Process - -ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories: - -1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)** - - Releases a new major stable version (e.g., v0.7.0) roughly every 2 weeks. - - Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release. - - Minor versions will be used for releases off the master branch. - - Patch versions may still be used for releases on the master branch in cases where a backport would not make sense. - - Commits outside of the stable release tags may be very unstable and break many custom nodes. - - Serves as the foundation for the desktop release - -2. **[Comfy Desktop](https://github.com/Comfy-Org/Comfy-Desktop)** - - Builds a new release using the latest stable core version - -3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)** - - Every 2+ weeks frontend updates are merged into the core repository - - Features are frozen for the upcoming core release - - Development continues for the next release cycle - -## Shortcuts - -| Keybind | Explanation | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------| -| `Ctrl` + `Enter` | Queue up current graph for generation | -| `Ctrl` + `Shift` + `Enter` | Queue up current graph as first for generation | -| `Ctrl` + `Alt` + `Enter` | Cancel current generation | -| `Ctrl` + `Z`/`Ctrl` + `Y` | Undo/Redo | -| `Ctrl` + `S` | Save workflow | -| `Ctrl` + `O` | Load workflow | -| `Ctrl` + `A` | Select all nodes | -| `Alt `+ `C` | Collapse/uncollapse selected nodes | -| `Ctrl` + `M` | Mute/unmute selected nodes | -| `Ctrl` + `B` | Bypass selected nodes (acts like the node was removed from the graph and the wires reconnected through) | -| `Delete`/`Backspace` | Delete selected nodes | -| `Ctrl` + `Backspace` | Delete the current graph | -| `Space` | Move the canvas around when held and moving the cursor | -| `Ctrl`/`Shift` + `Click` | Add clicked node to selection | -| `Ctrl` + `C`/`Ctrl` + `V` | Copy and paste selected nodes (without maintaining connections to outputs of unselected nodes) | -| `Ctrl` + `C`/`Ctrl` + `Shift` + `V` | Copy and paste selected nodes (maintaining connections from outputs of unselected nodes to inputs of pasted nodes) | -| `Shift` + `Drag` | Move multiple selected nodes at the same time | -| `Ctrl` + `D` | Load default graph | -| `Alt` + `+` | Canvas Zoom in | -| `Alt` + `-` | Canvas Zoom out | -| `Ctrl` + `Shift` + LMB + Vertical drag | Canvas Zoom in/out | -| `P` | Pin/Unpin selected nodes | -| `Ctrl` + `G` | Group selected nodes | -| `Q` | Toggle visibility of the queue | -| `H` | Toggle visibility of history | -| `R` | Refresh graph | -| `F` | Show/Hide menu | -| `.` | Fit view to selection (Whole graph when nothing is selected) | -| Double-Click LMB | Open node quick search palette | -| `Shift` + Drag | Move multiple wires at once | -| `Ctrl` + `Alt` + LMB | Disconnect all wires from clicked slot | - -`Ctrl` can also be replaced with `Cmd` instead for macOS users - -# Installing - -## Windows Portable - -There is a portable standalone build for Windows that should work for running on Nvidia GPUs or for running on your CPU only on the [releases page](https://github.com/comfyanonymous/ComfyUI/releases). - -### [Direct link to download](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) - -Simply download, extract with [7-Zip](https://7-zip.org) or with the windows explorer on recent windows versions and run. For smaller models you normally only need to put the checkpoints (the huge ckpt/safetensors files) in: ComfyUI\models\checkpoints but many of the larger models have multiple files. Make sure to follow the instructions to know which subfolder to put them in ComfyUI\models\ - -If you have trouble extracting it, right click the file -> properties -> unblock - -The portable above currently comes with python 3.13 and pytorch cuda 13.0. Update your Nvidia drivers if it doesn't start. - -#### All Official Portable Downloads: - -[Portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z) - -[Portable for Intel GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_intel.7z) - -[Portable for Nvidia GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) (supports 20 series and above). - -[Portable for Nvidia GPUs with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs). - -#### How do I share models between another UI and ComfyUI? - -See the [Config file](extra_model_paths.yaml.example) to set the search paths for models. In the standalone windows build you can find this file in the ComfyUI directory. Rename this file to extra_model_paths.yaml and edit it with your favorite text editor. - - -## [comfy-cli](https://docs.comfy.org/comfy-cli/getting-started) - -You can install and start ComfyUI using comfy-cli: ```bash -pip install comfy-cli -comfy install +git clone https://github.com/xmarre/ComfyUI-Global-Memory-Trim custom_nodes/ComfyUI-Global-Memory-Trim ``` -## Manual Install (Windows, Linux) +Or copy this folder into: -Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported. +```text +ComfyUI/custom_nodes/ComfyUI-Global-Memory-Trim +``` -Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12 +Restart ComfyUI. On startup you should see a log line similar to: -torch 2.4 and above is supported but some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old. +```text +Installed global memory trim patch: enabled=True before=False after=True ... +``` -### Instructions: +## Performance-oriented WSL setup -Git clone this repo. +This is the current practical setup I use for a large WSL2 ComfyUI workflow with heavy model switching, Flux/SDXL/SeedVR2/detailer passes, and large CPU image buffers. -Put your SD checkpoints (the huge ckpt/safetensors files) in: models/checkpoints +The important parts are: -Put your VAE in: models/vae +- Keep ComfyUI on `--highvram` for performance. +- Disable async weight offload and pinned memory on WSL. +- Do **not** force `--disable-cuda-malloc` here; the normal CUDA allocator path avoids the VRAM over-reservation/overflow seen with the native allocator path in this workflow. +- Keep `PYTORCH_CUDA_ALLOC_CONF` unset. +- Use glibc trim thresholds and the global trim hook to reduce CPU/native heap retention. +- Keep SeedVR2 BF16 forced on if using the patched SeedVR2 import probe workaround and wanting the higher-quality 7B path. +```bash +#!/usr/bin/env bash +set -e -### AMD GPUs (Linux) +_hold_terminal_on_failure() { + local rc=$? + if [ "$rc" -ne 0 ]; then + printf '\nComfyUI launcher exited with status %d\n' "$rc" >&2 + printf 'Dropping into interactive shell so the terminal stays open.\n' >&2 + exec bash -i + fi +} +trap _hold_terminal_on_failure EXIT -AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version: +source ~/miniconda3/etc/profile.d/conda.sh +conda activate comfy312 -```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.2``` +# Native/CPU heap behavior. These do not free CUDA VRAM directly. +export MALLOC_MMAP_THRESHOLD_=65536 +export MALLOC_TRIM_THRESHOLD_=65536 -This is the command to install the nightly with ROCm 7.2 which might have some performance improvements: +# Global trim hook. +# BEFORE=1 is more aggressive and can help before large model/node transitions. +# LOG=1 is useful while validating. Set it to 0 once stable. +export COMFYUI_GLOBAL_TRIM=1 +export COMFYUI_GLOBAL_TRIM_AFTER=1 +export COMFYUI_GLOBAL_TRIM_BEFORE=1 +export COMFYUI_GLOBAL_TRIM_GC=1 +export COMFYUI_GLOBAL_TRIM_INTERVAL=1 +export COMFYUI_GLOBAL_TRIM_LOG=1 +export COMFYUI_GLOBAL_TRIM_MIN_RSS_MB=8192 + +# Optional, workflow-specific: keep SeedVR2 on BF16 without running an import-time CUDA probe. +export SEEDVR2_FORCE_BFLOAT16=1 +unset SEEDVR2_IMPORT_BFLOAT16_PROBE + +# Do not force PyTorch's allocator through the environment. +unset PYTORCH_CUDA_ALLOC_CONF -```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2``` +# Optional, workflow-specific memory reduction for SuperBeasts. +export SUPERBEASTS_SPCA_RETURN_RESIDUALS=false +export SUPERBEASTS_HDR_MALLOC_TRIM=true +export PYTHONFAULTHANDLER=1 -### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only. +cd ~/ComfyUI -These have less hardware support than the builds above but they work on windows. You also need to install the pytorch version specific to your hardware. +set +e +python main.py \ + --listen 0.0.0.0 \ + --port 8188 \ + --fast fp16_accumulation \ + --highvram \ + --use-pytorch-cross-attention \ + --disable-async-offload \ + --disable-pinned-memory \ + "$@" +status=$? +set -e -RDNA 3 (RX 7000 series): +exit "$status" +``` -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/``` +### After validating stability -RDNA 3.5 (Strix halo/Ryzen AI Max+ 365): +Once the workflow is stable, reduce log overhead first: -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx1151/``` +```bash +export COMFYUI_GLOBAL_TRIM_LOG=0 +``` -RDNA 4 (RX 9000 series): +Then, if performance still needs tuning, test one change at a time: -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx120X-all/``` +```bash +export COMFYUI_GLOBAL_TRIM_BEFORE=0 +``` -### Intel GPUs (Windows and Linux) +or: + +```bash +export COMFYUI_GLOBAL_TRIM_INTERVAL=2 +``` + +If wedges return, restore the previous value. + +## Conservative diagnostic WSL setup + +For reproducing or isolating CPU/native heap stalls, use the more conservative version below. It clamps native CPU thread pools and limits glibc arenas, which can improve WSL stability but may slow CPU-heavy nodes. + +```bash +export OMP_NUM_THREADS=1 +export OPENBLAS_NUM_THREADS=1 +export MKL_NUM_THREADS=1 +export NUMEXPR_NUM_THREADS=1 +export OPENCV_OPENCL_RUNTIME=disabled + +export MALLOC_ARENA_MAX=1 +export MALLOC_MMAP_THRESHOLD_=65536 +export MALLOC_TRIM_THRESHOLD_=65536 + +export COMFYUI_GLOBAL_TRIM=1 +export COMFYUI_GLOBAL_TRIM_AFTER=1 +export COMFYUI_GLOBAL_TRIM_BEFORE=0 +export COMFYUI_GLOBAL_TRIM_GC=1 +export COMFYUI_GLOBAL_TRIM_INTERVAL=1 +export COMFYUI_GLOBAL_TRIM_LOG=0 +export COMFYUI_GLOBAL_TRIM_MIN_RSS_MB=8192 +``` + +Use this when the problem is clearly CPU/native memory pressure rather than VRAM pressure. + +## Configuration -Intel Arc GPU users can install native PyTorch with torch.xpu support using pip. More information can be found [here](https://pytorch.org/docs/main/notes/get_start_xpu.html) +All configuration is via environment variables. + +| Variable | Default | Meaning | +|---|---:|---| +| `COMFYUI_GLOBAL_TRIM` | `1` | Enable/disable the global patch. | +| `COMFYUI_GLOBAL_TRIM_AFTER` | `1` | Trim after node execution. | +| `COMFYUI_GLOBAL_TRIM_BEFORE` | `0` | Also trim before node execution. More aggressive, useful for testing or fragile WSL setups. | +| `COMFYUI_GLOBAL_TRIM_GC` | `1` | Run `gc.collect()` before `malloc_trim(0)`. | +| `COMFYUI_GLOBAL_TRIM_INTERVAL` | `1` | Trim every N trim opportunities. Use `2`, `4`, etc. to reduce overhead. | +| `COMFYUI_GLOBAL_TRIM_MIN_RSS_MB` | `0` | Only trim when process RSS is at least this value. `0` means always. | +| `COMFYUI_GLOBAL_TRIM_LOG` | `0` | Log every trim with RSS before/after. Very noisy; enable only while diagnosing. | +| `COMFYUI_GLOBAL_TRIM_WARN_NO_LIBC` | `1` | Warn when glibc `malloc_trim` cannot be loaded. | -1. To install PyTorch xpu, use the following command: +## Notes -```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu``` +- Linux/WSL only. On non-Linux platforms the patch becomes a no-op. +- `malloc_trim(0)` only returns already-free native heap pages. It does not free live tensors, ComfyUI outputs, model weights, or Python objects that are still referenced. +- This is **not** a VRAM fixer. It targets CPU/native heap retention. +- `--disable-cuda-malloc` can change CUDA allocator behavior and may increase VRAM reservation/fragmentation in some workflows. Do not assume it is safer unless you specifically need it. +- `--disable-async-offload` and `--disable-pinned-memory` can be useful on WSL when async offload/pinned-memory paths cause wedges. +- `COMFYUI_GLOBAL_TRIM_LOG=1` is diagnostic only. Turn it off for normal use. -This is the command to install the Pytorch xpu nightly which might have some performance improvements: +## License -```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/xpu``` - -### NVIDIA - -Nvidia users should install stable pytorch using this command: - -```pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130``` - -This is the command to install pytorch nightly instead which might have performance improvements. - -```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu132``` - -#### Troubleshooting - -If you get the "Torch not compiled with CUDA enabled" error, uninstall torch with: - -```pip uninstall torch``` - -And install it again with the command above. - -### Dependencies - -Install the dependencies by opening your terminal inside the ComfyUI folder and: - -```pip install -r requirements.txt``` - -After this you should have everything installed and can proceed to running ComfyUI. - -### Others: - -#### Apple Mac silicon - -You can install ComfyUI in Apple Mac silicon (M1, M2, M3 or M4) with any recent macOS version. - -1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly). -1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux. -1. Install the ComfyUI [dependencies](#dependencies). If you have another Stable Diffusion UI [you might be able to reuse the dependencies](#i-already-have-another-ui-for-stable-diffusion-installed-do-i-really-have-to-install-all-of-these-dependencies). -1. Launch ComfyUI by running `python main.py` - -> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding Comfy folders, as discussed in [ComfyUI manual installation](#manual-install-windows-linux). - -#### Ascend NPUs - -For models compatible with Ascend Extension for PyTorch (torch_npu). To get started, ensure your environment meets the prerequisites outlined on the [installation](https://ascend.github.io/docs/sources/ascend/quick_install.html) page. Here's a step-by-step guide tailored to your platform and installation method: - -1. Begin by installing the recommended or newer kernel version for Linux as specified in the Installation page of torch-npu, if necessary. -2. Proceed with the installation of Ascend Basekit, which includes the driver, firmware, and CANN, following the instructions provided for your specific platform. -3. Next, install the necessary packages for torch-npu by adhering to the platform-specific instructions on the [Installation](https://ascend.github.io/docs/sources/pytorch/install.html#pytorch) page. -4. Finally, adhere to the [ComfyUI manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run ComfyUI as described earlier. - -#### Cambricon MLUs - -For models compatible with Cambricon Extension for PyTorch (torch_mlu). Here's a step-by-step guide tailored to your platform and installation method: - -1. Install the Cambricon CNToolkit by adhering to the platform-specific instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cntoolkit_3.7.2/cntoolkit_install_3.7.2/index.html) -2. Next, install the PyTorch(torch_mlu) following the instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cambricon_pytorch_1.17.0/user_guide_1.9/index.html) -3. Launch ComfyUI by running `python main.py` - -#### Iluvatar Corex - -For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step guide tailored to your platform and installation method: - -1. Install the Iluvatar Corex Toolkit by adhering to the platform-specific instructions on the [Installation](https://support.iluvatar.com/#/DocumentCentre?id=1&nameCenter=2&productId=520117912052801536) -2. Launch ComfyUI by running `python main.py` - - -## [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager/tree/manager-v4) - -**ComfyUI-Manager** is an extension that allows you to easily install, update, and manage custom nodes for ComfyUI. - -### Setup - -1. Install the manager dependencies: - ```bash - pip install -r manager_requirements.txt - ``` - -2. Enable the manager with the `--enable-manager` flag when running ComfyUI: - ```bash - python main.py --enable-manager - ``` - -### Command Line Options - -| Flag | Description | -|------|-------------| -| `--enable-manager` | Enable ComfyUI-Manager | -| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (implies `--enable-manager`) | -| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) | - - -# Running - -```python main.py``` - -### For AMD cards not officially supported by ROCm - -Try running it with this command if you have issues: - -For 6700, 6600 and maybe other RDNA2 or older: ```HSA_OVERRIDE_GFX_VERSION=10.3.0 python main.py``` - -For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py``` - -### AMD ROCm Tips - -You can try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run. - -# Notes - -Only parts of the graph that have an output with all the correct inputs will be executed. - -Only parts of the graph that change from each execution to the next will be executed, if you submit the same graph twice only the first will be executed. If you change the last part of the graph only the part you changed and the part that depends on it will be executed. - -Dragging a generated png on the webpage or loading one will give you the full workflow including seeds that were used to create it. - -You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \\( or \\). - -You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \\{ or \\}. - -Dynamic prompts also support C-style comments, like `// comment` or `/* comment */`. - -To use a textual inversion concepts/embeddings in a text prompt put them in the models/embeddings directory and use them in the CLIPTextEncode node like this (you can omit the .pt extension): - -```embedding:embedding_filename.pt``` - - -## How to show high-quality previews? - -Use ```--preview-method auto``` to enable previews. - -The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart ComfyUI and launch it with `--preview-method taesd` to enable high-quality previews. - -## How to use TLS/SSL? -Generate a self-signed certificate (not appropriate for shared/production use) and key by running the command: `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"` - -Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app will now be accessible with `https://...` instead of `http://...`. - -> Note: Windows users can use [alexisrolland/docker-openssl](https://github.com/alexisrolland/docker-openssl) or one of the [3rd party binary distributions](https://wiki.openssl.org/index.php/Binaries) to run the command example above. -

If you use a container, note that the volume mount `-v` can be a relative path so `... -v ".\:/openssl-certs" ...` would create the key & cert files in the current directory of your command prompt or powershell terminal. - -## Support and dev channel - -[Discord](https://comfy.org/discord): Try the #help or #feedback channels. - -[Matrix space: #comfyui_space:matrix.org](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) (it's like discord but open source). - -See also: [https://www.comfy.org/](https://www.comfy.org/) - -> _psst — we're hiring!_ Help build ComfyUI: [comfy.org/careers](https://www.comfy.org/careers) - -## Frontend Development - -As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). The compiled JS files (from TS/Vue) are published to [pypi](https://pypi.org/project/comfyui-frontend-package) and installed as a dependency in ComfyUI. - -### Reporting Issues and Requesting Features - -For any bugs, issues, or feature requests related to the frontend, please use the [ComfyUI Frontend repository](https://github.com/Comfy-Org/ComfyUI_frontend). This will help us manage and address frontend-specific concerns more efficiently. - -### Using the Latest Frontend - -The new frontend is now the default for ComfyUI. However, please note: - -1. The frontend in the main ComfyUI repository is updated fortnightly. -2. Daily releases are available in the separate frontend repository. - -To use the most up-to-date frontend version: - -1. For the latest daily release, launch ComfyUI with this command line argument: - - ``` - --front-end-version Comfy-Org/ComfyUI_frontend@latest - ``` - -2. For a specific version, replace `latest` with the desired version number: - - ``` - --front-end-version Comfy-Org/ComfyUI_frontend@1.2.2 - ``` - -This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes. - -# QA - -### Which GPU should I buy for this? - -[See this page for some recommendations](https://github.com/comfyanonymous/ComfyUI/wiki/Which-GPU-should-I-buy-for-ComfyUI) +MIT From 0bad1b06bdbcf6dc93a0e796878ad0699de2db1b Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:55:15 +0200 Subject: [PATCH 39/39] Restore ComfyUI README --- README.md | 547 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 411 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 207a3f8bd..bcec86377 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,465 @@ -# ComfyUI Global Memory Trim +
-Global native heap trimming for ComfyUI on Linux/WSL. +# ComfyUI +**The most powerful and modular AI engine for content creation.** -This custom node repo installs a small global execution patch when ComfyUI loads custom nodes. The patch can call Python `gc.collect()` and glibc `malloc_trim(0)` before and/or after node execution. It is meant for workflows that repeatedly create large CPU image/video buffers through PyTorch, NumPy, OpenCV, Pillow, or native custom nodes and then stall or wedge under WSL2 memory pressure. -It also provides two optional workflow nodes: +[![Website][website-shield]][website-url] +[![Dynamic JSON Badge][discord-shield]][discord-url] +[![Twitter][twitter-shield]][twitter-url] +[![Matrix][matrix-shield]][matrix-url] +
+[![][github-release-shield]][github-release-link] +[![][github-release-date-shield]][github-release-link] +[![][github-downloads-shield]][github-downloads-link] +[![][github-downloads-latest-shield]][github-downloads-link] -- **Global Memory Trim Now**: manually run a trim and return RSS metrics. -- **Global Memory Trim Status**: return current config and last trim result. +[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white +[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org +[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat +[website-url]: https://www.comfy.org/ + +[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total +[discord-url]: https://discord.com/invite/comfyorg +[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI +[twitter-url]: https://x.com/ComfyUI -The global patch does **not** require adding either node to your workflow. +[github-release-shield]: https://img.shields.io/github/v/release/comfyanonymous/ComfyUI?style=flat&sort=semver +[github-release-link]: https://github.com/comfyanonymous/ComfyUI/releases +[github-release-date-shield]: https://img.shields.io/github/release-date/comfyanonymous/ComfyUI?style=flat +[github-downloads-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/total?style=flat +[github-downloads-latest-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/latest/total?style=flat&label=downloads%40latest +[github-downloads-link]: https://github.com/comfyanonymous/ComfyUI/releases -## Why this exists +ComfyUI Screenshot +
+
-Some WSL2 workloads can stall when native libraries repeatedly allocate and free large CPU buffers. Python objects may be gone, but glibc arenas can retain pages. Under a WSL memory cap, that can trigger heavy reclaim or a hard-looking VM stall. `malloc_trim(0)` asks glibc to return free heap pages to the OS. +ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more... +- ComfyUI natively supports the latest open-source state of the art models. +- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc. +- It is available on Windows, Linux, and macOS, locally with our [desktop application](https://www.comfy.org/download), our [portable install](#installing) or on our [cloud](https://www.comfy.org/cloud). +- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode. +- It integrates seamlessly into production pipelines with our API endpoints. -This repo is intentionally CPU/native-heap focused. It does **not** directly free CUDA VRAM, unload ComfyUI models, delete ComfyUI caches, or change workflow outputs. +## Get Started -## Installation +### Local -From your ComfyUI directory: +#### [Desktop Application](https://www.comfy.org/download) +- The easiest way to get started. +- Available on Windows & macOS. +#### [Windows Portable Package](#installing) +- Get the latest commits and completely portable. +- Available on Windows. + +#### [Manual Install](#manual-install-windows-linux) +Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend). + +### Cloud + +#### [Comfy Cloud](https://www.comfy.org/cloud) +- Our official paid cloud version for those who can't afford local hardware. + +## Examples +See what ComfyUI can do with the [newer template workflows](https://comfy.org/workflows) or old [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/). + +## Features +- Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything. +- NOTE: There are many more models supported than the list below, if you want to see what is supported see our templates list inside ComfyUI. +- Image Models + - SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/)) + - [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/) + - [Stable Cascade](https://comfyanonymous.github.io/ComfyUI_examples/stable_cascade/) + - [SD3 and SD3.5](https://comfyanonymous.github.io/ComfyUI_examples/sd3/) + - Pixart Alpha and Sigma + - [AuraFlow](https://comfyanonymous.github.io/ComfyUI_examples/aura_flow/) + - [HunyuanDiT](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_dit/) + - [Flux](https://comfyanonymous.github.io/ComfyUI_examples/flux/) + - [Lumina Image 2.0](https://comfyanonymous.github.io/ComfyUI_examples/lumina2/) + - [HiDream](https://comfyanonymous.github.io/ComfyUI_examples/hidream/) + - [Qwen Image](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/) + - [Hunyuan Image 2.1](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_image/) + - [Flux 2](https://comfyanonymous.github.io/ComfyUI_examples/flux2/) + - [Z Image](https://comfyanonymous.github.io/ComfyUI_examples/z_image/) + - Ernie Image +- Image Editing Models + - [Omnigen 2](https://comfyanonymous.github.io/ComfyUI_examples/omnigen/) + - [Flux Kontext](https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-kontext-image-editing-model) + - [HiDream E1.1](https://comfyanonymous.github.io/ComfyUI_examples/hidream/#hidream-e11) + - [Qwen Image Edit](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/#edit-model) +- Video Models + - [Stable Video Diffusion](https://comfyanonymous.github.io/ComfyUI_examples/video/) + - [Mochi](https://comfyanonymous.github.io/ComfyUI_examples/mochi/) + - [LTX-Video](https://comfyanonymous.github.io/ComfyUI_examples/ltxv/) + - [Hunyuan Video](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/) + - [Wan 2.1](https://comfyanonymous.github.io/ComfyUI_examples/wan/) + - [Wan 2.2](https://comfyanonymous.github.io/ComfyUI_examples/wan22/) + - [Hunyuan Video 1.5](https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video-1-5) +- Audio Models + - [Stable Audio](https://comfyanonymous.github.io/ComfyUI_examples/audio/) + - [ACE Step](https://comfyanonymous.github.io/ComfyUI_examples/audio/) +- 3D Models + - [Hunyuan3D 2.0](https://docs.comfy.org/tutorials/3d/hunyuan3D-2) +- Asynchronous Queue system +- Many optimizations: Only re-executes the parts of the workflow that changes between executions. +- Smart memory management: can automatically run large models on GPUs with as low as 1GB vram with smart offloading. +- Works even if you don't have a GPU with: ```--cpu``` (slow) +- Can load ckpt and safetensors: All in one checkpoints or standalone diffusion models, VAEs and CLIP models. +- Safe loading of ckpt, pt, pth, etc.. files. +- Embeddings/Textual inversion +- [Loras (regular, locon and loha)](https://comfyanonymous.github.io/ComfyUI_examples/lora/) +- [Hypernetworks](https://comfyanonymous.github.io/ComfyUI_examples/hypernetworks/) +- Loading full workflows (with seeds) from generated PNG, WebP and FLAC files. +- Saving/Loading workflows as Json files. +- Nodes interface can be used to create complex workflows like one for [Hires fix](https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/) or much more advanced ones. +- [Area Composition](https://comfyanonymous.github.io/ComfyUI_examples/area_composition/) +- [Inpainting](https://comfyanonymous.github.io/ComfyUI_examples/inpaint/) with both regular and inpainting models. +- [ControlNet and T2I-Adapter](https://comfyanonymous.github.io/ComfyUI_examples/controlnet/) +- [Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/) +- [GLIGEN](https://comfyanonymous.github.io/ComfyUI_examples/gligen/) +- [Model Merging](https://comfyanonymous.github.io/ComfyUI_examples/model_merging/) +- [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/) +- Latent previews with [TAESD](#how-to-show-high-quality-previews) +- Works fully offline: core will never download anything unless you want to. +- Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes` +- [Config file](extra_model_paths.yaml.example) to set the search paths for models. + +Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/) + +## Release Process + +ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories: + +1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)** + - Releases a new major stable version (e.g., v0.7.0) roughly every 2 weeks. + - Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release. + - Minor versions will be used for releases off the master branch. + - Patch versions may still be used for releases on the master branch in cases where a backport would not make sense. + - Commits outside of the stable release tags may be very unstable and break many custom nodes. + - Serves as the foundation for the desktop release + +2. **[Comfy Desktop](https://github.com/Comfy-Org/Comfy-Desktop)** + - Builds a new release using the latest stable core version + +3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)** + - Every 2+ weeks frontend updates are merged into the core repository + - Features are frozen for the upcoming core release + - Development continues for the next release cycle + +## Shortcuts + +| Keybind | Explanation | +|------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| `Ctrl` + `Enter` | Queue up current graph for generation | +| `Ctrl` + `Shift` + `Enter` | Queue up current graph as first for generation | +| `Ctrl` + `Alt` + `Enter` | Cancel current generation | +| `Ctrl` + `Z`/`Ctrl` + `Y` | Undo/Redo | +| `Ctrl` + `S` | Save workflow | +| `Ctrl` + `O` | Load workflow | +| `Ctrl` + `A` | Select all nodes | +| `Alt `+ `C` | Collapse/uncollapse selected nodes | +| `Ctrl` + `M` | Mute/unmute selected nodes | +| `Ctrl` + `B` | Bypass selected nodes (acts like the node was removed from the graph and the wires reconnected through) | +| `Delete`/`Backspace` | Delete selected nodes | +| `Ctrl` + `Backspace` | Delete the current graph | +| `Space` | Move the canvas around when held and moving the cursor | +| `Ctrl`/`Shift` + `Click` | Add clicked node to selection | +| `Ctrl` + `C`/`Ctrl` + `V` | Copy and paste selected nodes (without maintaining connections to outputs of unselected nodes) | +| `Ctrl` + `C`/`Ctrl` + `Shift` + `V` | Copy and paste selected nodes (maintaining connections from outputs of unselected nodes to inputs of pasted nodes) | +| `Shift` + `Drag` | Move multiple selected nodes at the same time | +| `Ctrl` + `D` | Load default graph | +| `Alt` + `+` | Canvas Zoom in | +| `Alt` + `-` | Canvas Zoom out | +| `Ctrl` + `Shift` + LMB + Vertical drag | Canvas Zoom in/out | +| `P` | Pin/Unpin selected nodes | +| `Ctrl` + `G` | Group selected nodes | +| `Q` | Toggle visibility of the queue | +| `H` | Toggle visibility of history | +| `R` | Refresh graph | +| `F` | Show/Hide menu | +| `.` | Fit view to selection (Whole graph when nothing is selected) | +| Double-Click LMB | Open node quick search palette | +| `Shift` + Drag | Move multiple wires at once | +| `Ctrl` + `Alt` + LMB | Disconnect all wires from clicked slot | + +`Ctrl` can also be replaced with `Cmd` instead for macOS users + +# Installing + +## Windows Portable + +There is a portable standalone build for Windows that should work for running on Nvidia GPUs or for running on your CPU only on the [releases page](https://github.com/comfyanonymous/ComfyUI/releases). + +### [Direct link to download](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) + +Simply download, extract with [7-Zip](https://7-zip.org) or with the windows explorer on recent windows versions and run. For smaller models you normally only need to put the checkpoints (the huge ckpt/safetensors files) in: ComfyUI\models\checkpoints but many of the larger models have multiple files. Make sure to follow the instructions to know which subfolder to put them in ComfyUI\models\ + +If you have trouble extracting it, right click the file -> properties -> unblock + +The portable above currently comes with python 3.13 and pytorch cuda 13.0. Update your Nvidia drivers if it doesn't start. + +#### All Official Portable Downloads: + +[Portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z) + +[Portable for Intel GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_intel.7z) + +[Portable for Nvidia GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) (supports 20 series and above). + +[Portable for Nvidia GPUs with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs). + +#### How do I share models between another UI and ComfyUI? + +See the [Config file](extra_model_paths.yaml.example) to set the search paths for models. In the standalone windows build you can find this file in the ComfyUI directory. Rename this file to extra_model_paths.yaml and edit it with your favorite text editor. + + +## [comfy-cli](https://docs.comfy.org/comfy-cli/getting-started) + +You can install and start ComfyUI using comfy-cli: ```bash -git clone https://github.com/xmarre/ComfyUI-Global-Memory-Trim custom_nodes/ComfyUI-Global-Memory-Trim +pip install comfy-cli +comfy install ``` -Or copy this folder into: +## Manual Install (Windows, Linux) -```text -ComfyUI/custom_nodes/ComfyUI-Global-Memory-Trim -``` +Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported. -Restart ComfyUI. On startup you should see a log line similar to: +Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12 -```text -Installed global memory trim patch: enabled=True before=False after=True ... -``` +torch 2.4 and above is supported but some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old. -## Performance-oriented WSL setup +### Instructions: -This is the current practical setup I use for a large WSL2 ComfyUI workflow with heavy model switching, Flux/SDXL/SeedVR2/detailer passes, and large CPU image buffers. +Git clone this repo. -The important parts are: +Put your SD checkpoints (the huge ckpt/safetensors files) in: models/checkpoints -- Keep ComfyUI on `--highvram` for performance. -- Disable async weight offload and pinned memory on WSL. -- Do **not** force `--disable-cuda-malloc` here; the normal CUDA allocator path avoids the VRAM over-reservation/overflow seen with the native allocator path in this workflow. -- Keep `PYTORCH_CUDA_ALLOC_CONF` unset. -- Use glibc trim thresholds and the global trim hook to reduce CPU/native heap retention. -- Keep SeedVR2 BF16 forced on if using the patched SeedVR2 import probe workaround and wanting the higher-quality 7B path. +Put your VAE in: models/vae -```bash -#!/usr/bin/env bash -set -e -_hold_terminal_on_failure() { - local rc=$? - if [ "$rc" -ne 0 ]; then - printf '\nComfyUI launcher exited with status %d\n' "$rc" >&2 - printf 'Dropping into interactive shell so the terminal stays open.\n' >&2 - exec bash -i - fi -} -trap _hold_terminal_on_failure EXIT +### AMD GPUs (Linux) -source ~/miniconda3/etc/profile.d/conda.sh -conda activate comfy312 +AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version: -# Native/CPU heap behavior. These do not free CUDA VRAM directly. -export MALLOC_MMAP_THRESHOLD_=65536 -export MALLOC_TRIM_THRESHOLD_=65536 +```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.2``` -# Global trim hook. -# BEFORE=1 is more aggressive and can help before large model/node transitions. -# LOG=1 is useful while validating. Set it to 0 once stable. -export COMFYUI_GLOBAL_TRIM=1 -export COMFYUI_GLOBAL_TRIM_AFTER=1 -export COMFYUI_GLOBAL_TRIM_BEFORE=1 -export COMFYUI_GLOBAL_TRIM_GC=1 -export COMFYUI_GLOBAL_TRIM_INTERVAL=1 -export COMFYUI_GLOBAL_TRIM_LOG=1 -export COMFYUI_GLOBAL_TRIM_MIN_RSS_MB=8192 +This is the command to install the nightly with ROCm 7.2 which might have some performance improvements: -# Optional, workflow-specific: keep SeedVR2 on BF16 without running an import-time CUDA probe. -export SEEDVR2_FORCE_BFLOAT16=1 -unset SEEDVR2_IMPORT_BFLOAT16_PROBE +```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2``` -# Do not force PyTorch's allocator through the environment. -unset PYTORCH_CUDA_ALLOC_CONF -# Optional, workflow-specific memory reduction for SuperBeasts. -export SUPERBEASTS_SPCA_RETURN_RESIDUALS=false -export SUPERBEASTS_HDR_MALLOC_TRIM=true +### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only. -export PYTHONFAULTHANDLER=1 +These have less hardware support than the builds above but they work on windows. You also need to install the pytorch version specific to your hardware. -cd ~/ComfyUI +RDNA 3 (RX 7000 series): -set +e -python main.py \ - --listen 0.0.0.0 \ - --port 8188 \ - --fast fp16_accumulation \ - --highvram \ - --use-pytorch-cross-attention \ - --disable-async-offload \ - --disable-pinned-memory \ - "$@" -status=$? -set -e +```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/``` -exit "$status" -``` +RDNA 3.5 (Strix halo/Ryzen AI Max+ 365): -### After validating stability +```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx1151/``` -Once the workflow is stable, reduce log overhead first: +RDNA 4 (RX 9000 series): -```bash -export COMFYUI_GLOBAL_TRIM_LOG=0 -``` +```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx120X-all/``` -Then, if performance still needs tuning, test one change at a time: +### Intel GPUs (Windows and Linux) -```bash -export COMFYUI_GLOBAL_TRIM_BEFORE=0 -``` +Intel Arc GPU users can install native PyTorch with torch.xpu support using pip. More information can be found [here](https://pytorch.org/docs/main/notes/get_start_xpu.html) -or: +1. To install PyTorch xpu, use the following command: -```bash -export COMFYUI_GLOBAL_TRIM_INTERVAL=2 -``` +```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu``` -If wedges return, restore the previous value. +This is the command to install the Pytorch xpu nightly which might have some performance improvements: -## Conservative diagnostic WSL setup +```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/xpu``` -For reproducing or isolating CPU/native heap stalls, use the more conservative version below. It clamps native CPU thread pools and limits glibc arenas, which can improve WSL stability but may slow CPU-heavy nodes. +### NVIDIA -```bash -export OMP_NUM_THREADS=1 -export OPENBLAS_NUM_THREADS=1 -export MKL_NUM_THREADS=1 -export NUMEXPR_NUM_THREADS=1 -export OPENCV_OPENCL_RUNTIME=disabled +Nvidia users should install stable pytorch using this command: -export MALLOC_ARENA_MAX=1 -export MALLOC_MMAP_THRESHOLD_=65536 -export MALLOC_TRIM_THRESHOLD_=65536 +```pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130``` -export COMFYUI_GLOBAL_TRIM=1 -export COMFYUI_GLOBAL_TRIM_AFTER=1 -export COMFYUI_GLOBAL_TRIM_BEFORE=0 -export COMFYUI_GLOBAL_TRIM_GC=1 -export COMFYUI_GLOBAL_TRIM_INTERVAL=1 -export COMFYUI_GLOBAL_TRIM_LOG=0 -export COMFYUI_GLOBAL_TRIM_MIN_RSS_MB=8192 -``` +This is the command to install pytorch nightly instead which might have performance improvements. -Use this when the problem is clearly CPU/native memory pressure rather than VRAM pressure. +```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu132``` -## Configuration +#### Troubleshooting -All configuration is via environment variables. +If you get the "Torch not compiled with CUDA enabled" error, uninstall torch with: -| Variable | Default | Meaning | -|---|---:|---| -| `COMFYUI_GLOBAL_TRIM` | `1` | Enable/disable the global patch. | -| `COMFYUI_GLOBAL_TRIM_AFTER` | `1` | Trim after node execution. | -| `COMFYUI_GLOBAL_TRIM_BEFORE` | `0` | Also trim before node execution. More aggressive, useful for testing or fragile WSL setups. | -| `COMFYUI_GLOBAL_TRIM_GC` | `1` | Run `gc.collect()` before `malloc_trim(0)`. | -| `COMFYUI_GLOBAL_TRIM_INTERVAL` | `1` | Trim every N trim opportunities. Use `2`, `4`, etc. to reduce overhead. | -| `COMFYUI_GLOBAL_TRIM_MIN_RSS_MB` | `0` | Only trim when process RSS is at least this value. `0` means always. | -| `COMFYUI_GLOBAL_TRIM_LOG` | `0` | Log every trim with RSS before/after. Very noisy; enable only while diagnosing. | -| `COMFYUI_GLOBAL_TRIM_WARN_NO_LIBC` | `1` | Warn when glibc `malloc_trim` cannot be loaded. | +```pip uninstall torch``` -## Notes +And install it again with the command above. -- Linux/WSL only. On non-Linux platforms the patch becomes a no-op. -- `malloc_trim(0)` only returns already-free native heap pages. It does not free live tensors, ComfyUI outputs, model weights, or Python objects that are still referenced. -- This is **not** a VRAM fixer. It targets CPU/native heap retention. -- `--disable-cuda-malloc` can change CUDA allocator behavior and may increase VRAM reservation/fragmentation in some workflows. Do not assume it is safer unless you specifically need it. -- `--disable-async-offload` and `--disable-pinned-memory` can be useful on WSL when async offload/pinned-memory paths cause wedges. -- `COMFYUI_GLOBAL_TRIM_LOG=1` is diagnostic only. Turn it off for normal use. +### Dependencies -## License +Install the dependencies by opening your terminal inside the ComfyUI folder and: -MIT +```pip install -r requirements.txt``` + +After this you should have everything installed and can proceed to running ComfyUI. + +### Others: + +#### Apple Mac silicon + +You can install ComfyUI in Apple Mac silicon (M1, M2, M3 or M4) with any recent macOS version. + +1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly). +1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux. +1. Install the ComfyUI [dependencies](#dependencies). If you have another Stable Diffusion UI [you might be able to reuse the dependencies](#i-already-have-another-ui-for-stable-diffusion-installed-do-i-really-have-to-install-all-of-these-dependencies). +1. Launch ComfyUI by running `python main.py` + +> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding Comfy folders, as discussed in [ComfyUI manual installation](#manual-install-windows-linux). + +#### Ascend NPUs + +For models compatible with Ascend Extension for PyTorch (torch_npu). To get started, ensure your environment meets the prerequisites outlined on the [installation](https://ascend.github.io/docs/sources/ascend/quick_install.html) page. Here's a step-by-step guide tailored to your platform and installation method: + +1. Begin by installing the recommended or newer kernel version for Linux as specified in the Installation page of torch-npu, if necessary. +2. Proceed with the installation of Ascend Basekit, which includes the driver, firmware, and CANN, following the instructions provided for your specific platform. +3. Next, install the necessary packages for torch-npu by adhering to the platform-specific instructions on the [Installation](https://ascend.github.io/docs/sources/pytorch/install.html#pytorch) page. +4. Finally, adhere to the [ComfyUI manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run ComfyUI as described earlier. + +#### Cambricon MLUs + +For models compatible with Cambricon Extension for PyTorch (torch_mlu). Here's a step-by-step guide tailored to your platform and installation method: + +1. Install the Cambricon CNToolkit by adhering to the platform-specific instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cntoolkit_3.7.2/cntoolkit_install_3.7.2/index.html) +2. Next, install the PyTorch(torch_mlu) following the instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cambricon_pytorch_1.17.0/user_guide_1.9/index.html) +3. Launch ComfyUI by running `python main.py` + +#### Iluvatar Corex + +For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step guide tailored to your platform and installation method: + +1. Install the Iluvatar Corex Toolkit by adhering to the platform-specific instructions on the [Installation](https://support.iluvatar.com/#/DocumentCentre?id=1&nameCenter=2&productId=520117912052801536) +2. Launch ComfyUI by running `python main.py` + + +## [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager/tree/manager-v4) + +**ComfyUI-Manager** is an extension that allows you to easily install, update, and manage custom nodes for ComfyUI. + +### Setup + +1. Install the manager dependencies: + ```bash + pip install -r manager_requirements.txt + ``` + +2. Enable the manager with the `--enable-manager` flag when running ComfyUI: + ```bash + python main.py --enable-manager + ``` + +### Command Line Options + +| Flag | Description | +|------|-------------| +| `--enable-manager` | Enable ComfyUI-Manager | +| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (implies `--enable-manager`) | +| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) | + + +# Running + +```python main.py``` + +### For AMD cards not officially supported by ROCm + +Try running it with this command if you have issues: + +For 6700, 6600 and maybe other RDNA2 or older: ```HSA_OVERRIDE_GFX_VERSION=10.3.0 python main.py``` + +For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py``` + +### AMD ROCm Tips + +You can try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run. + +# Notes + +Only parts of the graph that have an output with all the correct inputs will be executed. + +Only parts of the graph that change from each execution to the next will be executed, if you submit the same graph twice only the first will be executed. If you change the last part of the graph only the part you changed and the part that depends on it will be executed. + +Dragging a generated png on the webpage or loading one will give you the full workflow including seeds that were used to create it. + +You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \\( or \\). + +You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \\{ or \\}. + +Dynamic prompts also support C-style comments, like `// comment` or `/* comment */`. + +To use a textual inversion concepts/embeddings in a text prompt put them in the models/embeddings directory and use them in the CLIPTextEncode node like this (you can omit the .pt extension): + +```embedding:embedding_filename.pt``` + + +## How to show high-quality previews? + +Use ```--preview-method auto``` to enable previews. + +The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart ComfyUI and launch it with `--preview-method taesd` to enable high-quality previews. + +## How to use TLS/SSL? +Generate a self-signed certificate (not appropriate for shared/production use) and key by running the command: `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"` + +Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app will now be accessible with `https://...` instead of `http://...`. + +> Note: Windows users can use [alexisrolland/docker-openssl](https://github.com/alexisrolland/docker-openssl) or one of the [3rd party binary distributions](https://wiki.openssl.org/index.php/Binaries) to run the command example above. +

If you use a container, note that the volume mount `-v` can be a relative path so `... -v ".\:/openssl-certs" ...` would create the key & cert files in the current directory of your command prompt or powershell terminal. + +## Support and dev channel + +[Discord](https://comfy.org/discord): Try the #help or #feedback channels. + +[Matrix space: #comfyui_space:matrix.org](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) (it's like discord but open source). + +See also: [https://www.comfy.org/](https://www.comfy.org/) + +> _psst — we're hiring!_ Help build ComfyUI: [comfy.org/careers](https://www.comfy.org/careers) + +## Frontend Development + +As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). The compiled JS files (from TS/Vue) are published to [pypi](https://pypi.org/project/comfyui-frontend-package) and installed as a dependency in ComfyUI. + +### Reporting Issues and Requesting Features + +For any bugs, issues, or feature requests related to the frontend, please use the [ComfyUI Frontend repository](https://github.com/Comfy-Org/ComfyUI_frontend). This will help us manage and address frontend-specific concerns more efficiently. + +### Using the Latest Frontend + +The new frontend is now the default for ComfyUI. However, please note: + +1. The frontend in the main ComfyUI repository is updated fortnightly. +2. Daily releases are available in the separate frontend repository. + +To use the most up-to-date frontend version: + +1. For the latest daily release, launch ComfyUI with this command line argument: + + ``` + --front-end-version Comfy-Org/ComfyUI_frontend@latest + ``` + +2. For a specific version, replace `latest` with the desired version number: + + ``` + --front-end-version Comfy-Org/ComfyUI_frontend@1.2.2 + ``` + +This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes. + +# QA + +### Which GPU should I buy for this? + +[See this page for some recommendations](https://github.com/comfyanonymous/ComfyUI/wiki/Which-GPU-should-I-buy-for-ComfyUI)