ComfyUI's memory management API¶
What ComfyUI offers a caller, what it demands of a model in return, and how comfy-env satisfies both from another process.
Last verified against ComfyUI bab6ee5f (2026-08-24) and comfy-env f1f8260 (2026-09-04).
Read ComfyUI memory management background first. This page is the interface rather than the design. For the exhaustive list, function by function, with what comfy-env does about each, see ComfyUI memory API inventory.
There are two contracts, and they point in opposite directions¶
Almost every discussion of this conflates them, and they fail differently.
You call ComfyUI. A node asks where a tensor should live, or asks for a model to be made resident. This is an ordinary module API and it either works or raises.
ComfyUI calls you. Once a model is in the ledger, the memory manager reads
members off it during eviction, without asking permission. This is an implicit
interface with no declaration anywhere, and it fails by AttributeError in the
middle of someone else's loop.
comfy-env has to satisfy both, and the second is the hard one.
Contract one: what you call¶
All of it is comfy.model_management. Counted by how often node code in the
tree actually calls it:
Where should this tensor go¶
The most used part of the API by a wide margin, and the least discussed.
| Function | Answers |
|---|---|
get_torch_device() |
the device this ComfyUI is running on |
intermediate_device() |
where node outputs should live. CPU normally, the GPU under --gpu-only |
intermediate_dtype() |
the dtype for those outputs |
unet_offload_device() text_encoder_offload_device() vae_offload_device() |
where each model type goes when evicted |
text_encoder_device() vae_device() |
where each runs |
unet_manual_cast() |
whether weights need casting on the fly |
intermediate_device() is the one worth knowing. It decides whether Results live in host memory or VRAM, and
under --gpu-only a cached node output holds VRAM that nothing can evict.
Make room, and load¶
| Function | Does |
|---|---|
load_models_gpu(models, memory_required=, force_patch_weights=, ...) |
the main entry point. Computes a budget, evicts if needed, loads |
load_model_gpu(model) |
one model, thin wrapper |
free_memory(memory_required, device, keep_loaded=, for_dynamic=, pins_required=, ram_required=) |
"get me this many free bytes on this device" |
unload_all_models() |
evict everything, everywhere |
loaded_models(only_currently_used=False) |
the current ledger contents |
free_memory has a parameter that does nothing
ram_required appears in one log string and no caller in the tree passes it.
Host RAM is not budgeted. See
nothing budgets pageable RAM.
How much is there¶
| Function | Returns |
|---|---|
get_free_memory(device) |
driver free plus torch's own cache, which is not reliably returnable |
get_total_memory(device) |
device total |
module_size(module) |
bytes of a module's state dict, and nothing else it holds |
minimum_inference_memory() |
the floor that must stay free |
soft_empty_cache(force=False) |
return cached blocks to the driver |
There are two functions called get_free_memory
The module one, above, and ModelPatcher.get_free_memory, which adds what
the dynamic manager could reclaim on demand. They return different numbers
for the same device, and upstream uses both in the same batching decision.
Which one you want depends on whether you are asking "what is free" or
"what could I get".
When it goes wrong¶
| Function | Does |
|---|---|
raise_non_oom(e) |
re-raise unless this is an out of memory error. The correct guard for a retry loop |
OOM_EXCEPTION |
the exception type, which falls back to bare Exception on builds without it |
Eleven places in the tree catch an OOM and retry smaller. raise_non_oom is what
keeps those from swallowing real bugs, and six weight adapters do not call it.
Release on request¶
Not a Python API at all, and the one most people miss:
| Endpoint | Releases |
|---|---|
POST /free {"unload_models": true} |
every model, on every device |
POST /free {"free_memory": true} |
the node output cache and the node instance cache, by rebuilding both |
POST /history {"clear": true} |
stored prompts and their workflow JSON |
POST /queue {"clear": true} |
queued work |
This is a documented public endpoint with a button in the stock interface. It is the only release path that answers to a person rather than to a condition, and it short circuits three of the six kinds at once.
Contract two: what ComfyUI calls on you¶
Put an object in current_loaded_models and the memory manager will read
members off it, at times of its choosing, in the middle of eviction. Eighteen of
them, none declared anywhere in upstream:
load_device offload_device parent model
model_size loaded_size current_loaded_device
model_dtype model_patches_to model_patches_models
partially_load partially_unload detach
lowvram_patch_counter is_dynamic is_clone
clone_base_uuid get_nested_additional_models
Why eighteen and not fourteen
Fourteen of them appear as a literal .model.<name> in the memory manager
(model itself among them, as .model.model), which is what a grep can
detect. The other four never do: load_device, parent,
model_patches_models and get_nested_additional_models are reached
through an alias (model = loaded_model.model, then model.load_device)
or read off the INCOMING model rather than a list entry. load_device is
the one upstream reads most, and a literal grep sees it zero times. That
is why the compatibility test walks the AST and follows the alias rather
than grepping; its own docstring in tests/test_model_patcher_surface.py
records the same count.
Three groups, by what they are for:
- Identity and placement.
load_device,offload_device,parent,clone_base_uuid,is_clone. Eviction needs to know what a thing is and whether two entries are the same model. - Accounting.
model_size,loaded_size,current_loaded_device,lowvram_patch_counter. How big, how much of it is resident, and where. - Action.
partially_load,partially_unload,detach,model_patches_to. The verbs eviction actually calls.
is_dynamic deserves its own note. It decides whether an entry is managed by
the dynamic manager, and returning False excludes an object from every
pin path, every cast buffer reset, and the dynamic model bypass in the eviction
loop. It is the single highest leverage member on the list.
How comfy-env satisfies both¶
Contract one is not intercepted at all¶
The worker imports comfy.model_management from the host ComfyUI tree and
calls the real functions, with one exception.
load_models_gpu IS replaced: the worker assigns its own
_shimmed_load_models_gpu over it, which measures the incoming models, asks
the host to free room for them, writes back what the host says, and only then
calls the original it saved. Nothing is reimplemented, and the real function
still does the loading, but a reader who takes "no shim" literally will not
understand where a worker's reserve comes from.
The one thing it does do is correct the numbers it reads, because
get_free_memory in a worker reports that process's own view on WDDM. The
correction itself is not platform gated: pool._handle_vram_budget computes
its offset as ComfyUI's blind reading minus the NVML free figure wherever
NVML answers, on Linux included. What differs is the arithmetic: on WDDM the
difference is what siblings hold, while on Linux cudaMemGetInfo is already
device-wide, the sibling term cancels, and the offset collapses to the host's
own idle torch cache. The platform verdict only chooses the fallback when NVML
is absent (reconstruct from comfy-env's ledger on WDDM, trust the blind
reading elsewhere), so the double count it exists to avoid is the ledger's,
not NVML's. See
comfy-env's memory management.
Contract two: a duck type¶
Registering a stand-in is the only mechanism by which upstream's own eviction reaches another process (ADR-0038). Every defect ever found in this object has come through it, and every one was a wrong number found by audit rather than a missing attribute found by a user. The design reasoning below is why a duck type beat a subclass; it is the reason the object is safe to keep, not an argument for replacing it.
comfy-env registers a stand in object into current_loaded_models so upstream
can evict a worker's model the way it evicts its own. That object declares its
surface explicitly:
COMFY_SURFACE = frozenset({
"load_device", "offload_device", "parent", "model", "clone_base_uuid",
"model_size", "loaded_size", "current_loaded_device", "model_dtype",
"model_patches_to", "model_patches_models", "partially_load",
"partially_unload", "detach", "lowvram_patch_counter", "is_dynamic",
"is_clone", "get_nested_additional_models",
})
It does not inherit ModelPatcher, and a test enforces that. Inheriting
would silently import well over a hundred members that are wrong for an object
holding no weights, and every one of them would appear to work while returning
nonsense. A duck type fails loudly on the member it lacks; a wrong subclass
answers confidently.
The proxy's __getattr__ is the loud failure: anything upstream reaches for that
is not in the surface raises with a message naming the member and telling the
reader to extend COMFY_SURFACE rather than reach for inheritance.
How we know it still fits¶
A test greps upstream's model_management.py for every .model.<name> access,
subtracts the names that are not patcher members and the ones gated behind
is_dynamic(), and asserts the remainder is a subset of COMFY_SURFACE.
That is the right shape for the problem. Upstream has no declared interface, so the test derives one from the source rather than trusting a written record.
What the tripwire covers, and what it does not
It runs weekly against ComfyUI master, in a job the workflow itself marks allowed to fail, never gates anything. So it is a notification, not a gate: a red run opens an issue and publishing continues.
It reads one file, model_management.py. Node code outside it also reads
the loaded-model list, and the canary does not look there. It follows both
.model.<name> and the model = entry.model alias within a function, but
three surface members are read off the INCOMING model rather than off a
list entry and no ledger-shaped sweep finds those.
And it cannot see the failure that actually happens
The canary catches a missing member. Every defect found in this seam has
been a wrong value on a member the proxy implements. The one still live
is the eviction sort key that places the proxy first; the other, a size fed
into the wrong pin budget through a leaked loaded_models() list, closed on
2026-09-06 when every stand-in started registering with currently_used
False. A surface check is structurally blind to both kinds.
It also reads only one file. Within that file it does follow the
m = entry.model alias, as the paragraph above says; what it cannot follow
is a read that happens somewhere else, in node code that borrowed the list.
What this seam costs, honestly¶
The proxy works. It is registered, upstream evicts it, and the surface is
currently complete against b133e483.
On a default install every host model is managed dynamically, and the eviction loop has a bypass for those, while the proxy reports itself as non dynamic and does not get it. The worker's model can therefore be the only entry upstream evicts, and it also sorts first, because a fresh proxy reports nothing already offloaded and the lowest possible reference count.
Two things stop that being a complaint about upstream.
The bypass is if entry.model.is_dynamic() and for_dynamic:, and the comment
under it says why: "don't actually unload dynamic models for the sake of other
dynamic models as that works on-demand." Evicting a paged model to make room
for another paged model is churn, because the pager reclaims on demand. That is
a considered refusal, not drift.
And for_dynamic is a parameter. comfy-env's own free_memory call leaves
it False, so on the path comfy-env drives the bypass does not fire at all and
host dynamic models are fully evictable. Where the asymmetry does appear, it is
downstream of comfy-env's own choice to answer is_dynamic() False, which
ADR-0035 calls load-bearing and
where the stand-in is inaccurate prices honestly. It is the cost
of the safe answer, not a tilt in the field.