Custom Node Testing
Expected Behavior
ModelPatcher.backup entries should reuse one tuple type. For N backed-up weights:
len(self.backup) == N
len({type(entry) for entry in self.backup.values()}) == 1
Creating and clearing backup entries should not generate a new class and its associated cyclic objects for every weight.
Actual Behavior
comfy/model_patcher.py calls collections.namedtuple() inside two per-key hot paths. Because collections.namedtuple() creates a new class on every call, ComfyUI creates one distinct Dimension type per backed-up weight:
self.backup[key] = collections.namedtuple(
"Dimension", ["weight", "inplace_update"]
)(weight.to(device=self.offload_device, copy=inplace_update), inplace_update)
and:
self.backup[key] = collections.namedtuple(
"Dimension", ["weight", "inplace_update"]
)(weight, False)
Therefore:
len({type(entry) for entry in self.backup.values()}) == len(self.backup)
Each generated class brings its own methods, closure cells, descriptors, staticmethod, classmethod, and two _tuplegetter objects. Repeated model patch/load/unload cycles generate large amounts of class-shaped cyclic garbage.
In one 178.1-second production observation window, the GC-tracked heap grew with the exact signature of approximately 925 newly generated two-field namedtuple classes:
total tracked +65,105
type +931
staticmethod +925
classmethod +925
_tuplegetter +1,850
function +5,568
cell +8,365
tuple +23,049
frozenset +17,550
Gen 2 collections: +13
Objects collected by Gen 2: +310,277
Average per Gen 2: ~23,867
In an isolated reproduction using the actual upstream assignment, 2,000 backup entries created 2,000 distinct classes. Clearing the backup made Gen 2 collect approximately 58,000–62,000 class-related cyclic objects, depending on the Python version. The shared-type implementation created one class and no per-entry class-related cyclic garbage.
Steps to Reproduce
This reproduces the core allocation behavior on a clean ComfyUI checkout without loading custom nodes or requiring a workflow/model file:
-
Check out current ComfyUI master and activate its Python environment.
-
Save the following as /tmp/reproduce_model_patcher_namedtuple_gc.py:
import gc
import torch
from comfy.model_patcher import ModelPatcher
model = torch.nn.Module()
keys = []
for index in range(1000):
key = f"weight_{index}"
keys.append(key)
model.register_parameter(
key,
torch.nn.Parameter(torch.ones(1)),
)
patcher = ModelPatcher(
model,
load_device=torch.device("cpu"),
offload_device=torch.device("cpu"),
)
# Exclude the interpreter/import baseline from the cleanup measurement.
gc.collect()
gc.freeze()
gc.disable()
for key in keys:
patcher.patch_weight_to_device(key, force_cast=True)
print("backup entries:", len(patcher.backup))
print(
"unique backup types:",
len({type(entry) for entry in patcher.backup.values()}),
)
patcher.backup.clear()
print("gen2 collected after clear:", gc.collect(2))
-
Run it from the ComfyUI repository root:
python /tmp/reproduce_model_patcher_namedtuple_gc.py
-
Current upstream behavior is approximately:
backup entries: 1000
unique backup types: 1000
gen2 collected after clear: ~29,000-31,000
-
With the proposed shared module-level type:
backup entries: 1000
unique backup types: 1
gen2 collected after clear: 0 class-related objects
The issue is in ComfyUI core and the clean reproduction does not import custom nodes. The production workload did contain custom nodes, but they are not needed to reproduce the class-per-backup behavior.
Debug Logs
These production logs motivated the investigation. They demonstrate the symptom but, as explained under Other, subsequent testing found an independent execution-cache factor; the absolute pause duration should not be attributed solely to the inline namedtuple calls.
Pre-fix examples:
2026-08-11 09:40:58.853 | WARNING | req_id:- | hooks.gc_trace:_gc_callback:73 - slow gc_trace | gen:2 cost:15379.6ms collected:26200 uncollectable:0 counts:(1, 0, 0)
2026-08-11 15:03:54.158 | WARNING | req_id:- | hooks.gc_trace:_gc_callback:73 - slow gc_trace | gen:2 cost:17517.8ms collected:31913 uncollectable:0 counts:(2, 0, 0)
Summary of 3,495 consecutive pre-fix Gen 2 collections over 24 hours:
time range: 2026-08-10 11:03:08.072 -> 2026-08-11 11:02:54.467
samples: 3495
min: 6498.3 ms
mean: 7351.1 ms
median: 7344.7 ms
p90: 7954.3 ms
p95: 8033.6 ms
p99: 8183.6 ms
max: 8455.0 ms
max collected: 47808
uncollectable total: 0
The hourly median increased from 6,636.4 ms to 8,042.6 ms during that 24-hour log.
Post-fix examples from a newly started worker:
2026-08-12 10:34:42.815 | WARNING | req_id:- | hooks.gc_trace:_gc_callback:73 - slow gc_trace | gen:2 cost:485.3ms collected:948 uncollectable:0 counts:(1, 0, 0)
2026-08-12 10:40:14.053 | WARNING | req_id:- | hooks.gc_trace:_gc_callback:73 - slow gc_trace | gen:2 cost:493.5ms collected:996 uncollectable:0 counts:(1, 0, 0)
Summary of 100 consecutive post-fix collections:
samples: 100
min: 437.3 ms
median: 480.1 ms
p95: 513.0 ms
max: 520.1 ms
last: 517.9 ms
max collected: 1313
This production before/after comparison is observational rather than a controlled benchmark because deploying the fix also restarted the worker and reset its execution cache. The controlled evidence for this issue is the class count and isolated GC-object count in Steps to Reproduce and Other.
The GC timer uses gc.callbacks and measures wall time between the start and stop phases with time.perf_counter(). The collected and uncollectable values come directly from the callback's info argument.
Other
Upstream revision used for reproduction
The two inline allocations were verified on upstream commit 27bca654eb9a70237d93f56a6ea336ab55f8925d on 2026-08-12:
|
self.backup[key] = collections.namedtuple('Dimension', ['weight', 'inplace_update'])(weight.to(device=self.offload_device, copy=inplace_update), inplace_update) |
|
self.backup[key] = collections.namedtuple('Dimension', ['weight', 'inplace_update'])(weight, False) |
The issue was originally validated against commit 62b3c94bd45154f6486c7abf1b9efcacee96ea69 and reproduced on Python 3.9.6, 3.11.15, and 3.12.13.
Investigation update: independent execution-cache growth
Further investigation found a separate source of full-GC growth in the production service. The default RAM-pressure execution cache retained input-signature keys composed primarily of tuples and frozensets until memory pressure caused eviction.
On the same worker running the shared-type fix, the execution cache was allowed to grow and was then cleared through ComfyUI's /free endpoint:
| Metric |
Before clearing execution cache |
After clearing execution cache |
| GC-tracked objects |
1,839,066 |
152,673 |
tuple |
876,414 |
35,960 |
frozenset |
873,963 |
28,355 |
| Gen 2 GC duration |
approximately 624 ms |
44.9 ms |
The removed tuples and frozensets accounted for 1,686,062 of the 1,686,393-object decrease. Meanwhile, the tracked type count remained stable at 887 across repeated post-fix workflows, and _tuplegetter showed no growth. This confirms that the per-backup class creation stopped, while also showing that execution-cache state independently affects absolute full-GC duration.
Consequently, the earlier production before/after timing cannot be used to claim that the inline namedtuple construction alone caused the 15–17 second pauses. This report is intentionally limited to the independently reproducible class-per-backup allocation and its associated cyclic-GC churn.
Proposed fix
Create the type once at module scope while preserving the existing tuple behavior, typename, and .weight / .inplace_update attributes:
ModelPatcherBackup = collections.namedtuple(
"Dimension",
["weight", "inplace_update"],
)
Then use it at both call sites:
self.backup[key] = ModelPatcherBackup(
weight.to(
device=self.offload_device,
copy=inplace_update,
),
inplace_update,
)
and:
self.backup[key] = ModelPatcherBackup(weight, False)
A regression test creates two backup entries through patch_weight_to_device() and asserts that both are instances of ModelPatcherBackup and share exactly one type.
Additional diagnostics
An isolated benchmark on the affected production Python 3.11.15 build produced the following results:
| Backup entries |
Implementation |
Unique types |
Live Gen 2 GC |
Cleanup Gen 2 GC |
Cleanup collected |
| 925 |
Current source |
925 |
3.03 ms |
5.60 ms |
26,825 |
| 925 |
Shared type |
1 |
0.04 ms |
0.00 ms |
0 |
| 2,000 |
Current source |
2,000 |
7.53 ms |
17.01 ms |
58,000 |
| 2,000 |
Shared type |
1 |
0.08 ms |
0.00 ms |
0 |
| 10,000 |
Current source |
10,000 |
54.80 ms |
103.69 ms |
290,000 |
| 10,000 |
Shared type |
1 |
0.36 ms |
0.00 ms |
0 |
The production log's common collected-object counts closely match the benchmark's approximately 29 cyclic objects per generated class. For example, 925 generated types produced 26,825 collectible objects.
The production deployment was followed by a large reduction in Gen 2 duration, but that comparison also included a process restart and execution-cache reset. It therefore cannot isolate the timing improvement caused by this change. The controlled benchmark establishes the narrower claim: the current implementation creates one class and approximately 29 cyclic objects per backup entry, whereas the shared-type implementation eliminates that per-entry class allocation and associated cyclic garbage.
A /proc sampler and a py-spy --gil --native profile found the observed slow collections CPU-bound in CPython's gc_collect_main, without cgroup throttling, memory/IO pressure, or Python-level model finalizers.
Environment:
Python: 3.11.15
PyTorch: 2.8.0+cu128
OS: Linux container
Pre-fix worker uptime at initial observation: approximately 6 days
cpu.cfs_quota_us: -1
nr_throttled: 0
throttled_time: 0
swap: 0
oom_kill: 0
Custom Node Testing
Expected Behavior
ModelPatcher.backupentries should reuse one tuple type. ForNbacked-up weights:Creating and clearing backup entries should not generate a new class and its associated cyclic objects for every weight.
Actual Behavior
comfy/model_patcher.pycallscollections.namedtuple()inside two per-key hot paths. Becausecollections.namedtuple()creates a new class on every call, ComfyUI creates one distinctDimensiontype per backed-up weight:and:
Therefore:
Each generated class brings its own methods, closure cells, descriptors,
staticmethod,classmethod, and two_tuplegetterobjects. Repeated model patch/load/unload cycles generate large amounts of class-shaped cyclic garbage.In one 178.1-second production observation window, the GC-tracked heap grew with the exact signature of approximately 925 newly generated two-field namedtuple classes:
In an isolated reproduction using the actual upstream assignment, 2,000 backup entries created 2,000 distinct classes. Clearing the backup made Gen 2 collect approximately 58,000–62,000 class-related cyclic objects, depending on the Python version. The shared-type implementation created one class and no per-entry class-related cyclic garbage.
Steps to Reproduce
This reproduces the core allocation behavior on a clean ComfyUI checkout without loading custom nodes or requiring a workflow/model file:
Check out current ComfyUI
masterand activate its Python environment.Save the following as
/tmp/reproduce_model_patcher_namedtuple_gc.py:Run it from the ComfyUI repository root:
Current upstream behavior is approximately:
With the proposed shared module-level type:
The issue is in ComfyUI core and the clean reproduction does not import custom nodes. The production workload did contain custom nodes, but they are not needed to reproduce the class-per-backup behavior.
Debug Logs
These production logs motivated the investigation. They demonstrate the symptom but, as explained under Other, subsequent testing found an independent execution-cache factor; the absolute pause duration should not be attributed solely to the inline
namedtuplecalls.Pre-fix examples:
Summary of 3,495 consecutive pre-fix Gen 2 collections over 24 hours:
The hourly median increased from 6,636.4 ms to 8,042.6 ms during that 24-hour log.
Post-fix examples from a newly started worker:
Summary of 100 consecutive post-fix collections:
This production before/after comparison is observational rather than a controlled benchmark because deploying the fix also restarted the worker and reset its execution cache. The controlled evidence for this issue is the class count and isolated GC-object count in Steps to Reproduce and Other.
The GC timer uses
gc.callbacksand measures wall time between thestartandstopphases withtime.perf_counter(). Thecollectedanduncollectablevalues come directly from the callback'sinfoargument.Other
Upstream revision used for reproduction
The two inline allocations were verified on upstream commit
27bca654eb9a70237d93f56a6ea336ab55f8925don 2026-08-12:ComfyUI/comfy/model_patcher.py
Line 907 in 27bca65
ComfyUI/comfy/model_patcher.py
Line 1976 in 27bca65
The issue was originally validated against commit
62b3c94bd45154f6486c7abf1b9efcacee96ea69and reproduced on Python 3.9.6, 3.11.15, and 3.12.13.Investigation update: independent execution-cache growth
Further investigation found a separate source of full-GC growth in the production service. The default RAM-pressure execution cache retained input-signature keys composed primarily of tuples and frozensets until memory pressure caused eviction.
On the same worker running the shared-type fix, the execution cache was allowed to grow and was then cleared through ComfyUI's
/freeendpoint:tuplefrozensetThe removed tuples and frozensets accounted for 1,686,062 of the 1,686,393-object decrease. Meanwhile, the tracked
typecount remained stable at 887 across repeated post-fix workflows, and_tuplegettershowed no growth. This confirms that the per-backup class creation stopped, while also showing that execution-cache state independently affects absolute full-GC duration.Consequently, the earlier production before/after timing cannot be used to claim that the inline
namedtupleconstruction alone caused the 15–17 second pauses. This report is intentionally limited to the independently reproducible class-per-backup allocation and its associated cyclic-GC churn.Proposed fix
Create the type once at module scope while preserving the existing tuple behavior, typename, and
.weight/.inplace_updateattributes:Then use it at both call sites:
and:
A regression test creates two backup entries through
patch_weight_to_device()and asserts that both are instances ofModelPatcherBackupand share exactly one type.Additional diagnostics
An isolated benchmark on the affected production Python 3.11.15 build produced the following results:
The production log's common collected-object counts closely match the benchmark's approximately 29 cyclic objects per generated class. For example, 925 generated types produced 26,825 collectible objects.
The production deployment was followed by a large reduction in Gen 2 duration, but that comparison also included a process restart and execution-cache reset. It therefore cannot isolate the timing improvement caused by this change. The controlled benchmark establishes the narrower claim: the current implementation creates one class and approximately 29 cyclic objects per backup entry, whereas the shared-type implementation eliminates that per-entry class allocation and associated cyclic garbage.
A
/procsampler and apy-spy --gil --nativeprofile found the observed slow collections CPU-bound in CPython'sgc_collect_main, without cgroup throttling, memory/IO pressure, or Python-level model finalizers.Environment: