ComfyUI memory management background¶
What a running ComfyUI holds on to, where it holds it, and what has to happen before it lets go.
Last verified against ComfyUI b133e483 (2026-08-26); ComfyUI now pins
comfy-aimdo 0.5.2.
Start here. You need this page before comfy-env's memory management.
If page faults, swap, pinning or memory mapping are unfamiliar, take the detour through How operating systems manage memory, which defines every term this page borrows. Otherwise carry on.
Why does ComfyUI manage memory?¶
ComfyUI runs a workflow as a graph.
Each node does its work, hands the result to the next one, and this becomes worth thinking about when the graph is heavy.
A model checkpoint can be several GBs and a user's GPU/RAM might hold a fixed number of them: a UNet, a VAE, a text encoder and two ControlNets might not all fit on a GPU at once.
ComfyUI does things about it:
- It shuffles model weights on and off the GPU VRAM while the graph runs.
- It keeps room back for work whose size it cannot predict.
- It remembers what each node produced, in case you run the graph again.
- It retries in smaller pieces when something fails.
These memory optimizations are crucial to the good functioning of ComfyUI, especially on consumer hardware, and comfy-env aims to maintain them all.
Where memory lives¶
People say "RAM and VRAM" and that is close enough, but there are a few things to keep in mind
Two kinds of bytes on RAM¶
File backed bytes that came off a disk and have not been modified since (safetensors...). The OS has it easy: if it needs memory it can just delete them and the file still holds a perfect copy.
Anonymous bytes exist only in RAM. A tensor you built, a buffer you allocated. Nothing on disk backs them, so if the OS wants that memory back it cannot simply discard them. It has to put them somewhere first.
Same RAM, same speed, different fate under pressure.
The places¶
| # | Place | What it is | Getting a weight back from here costs |
|---|---|---|---|
| 1 | VRAM | GPU memory | nothing. It is already there |
| 2 | Pinned RAM | host RAM the OS has promised not to move | the RAM to VRAM transfer, and nothing else: 25 GB/s on a PCIe 4.0 x16 RTX 3090, and the call returns before the copy finishes |
| 3a | Pageable RAM, anonymous | ordinary host RAM, holding bytes that exist nowhere else | a copy into the GPU driver's own pinned buffer, then the transfer: 12 GB/s on the same card, a flat 2x at every size from 64 MiB to 4 GiB, and the call blocks for the whole copy (measured 2026-09-15, research/memory-floor/pinned_vs_pageable.md in comfy-env) |
| 3b | Pageable RAM, file backed | ordinary host RAM, holding a clean copy of bytes the disk also has | exactly the same as 3a |
| 4a | Compressed | 3a, squashed rather than written out. Still RAM, unreadable until decompressed | decompressing, then everything 3a/3b costs |
| 4b | Swap | 3a, written out to a disk | reading it back off disk, then everything 3a costs |
| 4c | The file | what is left after 3b is dropped. The safetensors, where they always were | reading it, through a small pinned window rather than into a full copy in RAM |
The fork¶
VRAM, pinned RAM and pageable RAM are where something is put. Compressed, swap and the file are where it can end up.
ComfyUI doesn't handle compressed RAM! It is named once, in order to exclude it
get_disk_swap_total() sums /proc/swaps to size the pinned memory budget
and skips any device whose name begins with zram
(model_management.py). Compressed RAM is not backing store, so
counting it would let the budget promise pins against memory that cannot
absorb a spill. It returns zero when /proc/swaps is absent, so this is
Linux only by construction rather than by an OS check.
Pinned RAM is the one place in this table with a budget, because pages the kernel cannot move are pages the rest of the machine cannot have.
ComfyUI lets the OS manage pageable RAM and instead only manages VRAM. That is a stated position, not an omission: Comfy-Org/ComfyUI#8298.
The six kinds¶
Everything ComfyUI holds in memory falls into one of these six categories, and each kind is here because it is released by something the other five are not.
This table is the index. You arrive knowing what the memory is, and what you want is the What frees it column, which names a mechanism from the table after this one.
Exists on your install when is there because two of the six kinds are
absent on ordinary setups: Carry needs an offload stream, and Results and
State are switched off by --cache-none (--high-ram silently switches
them back on). Memory that was never there is not worth looking for.
| # | Kind | What it is | Where it lives | What frees it | Exists on your install when |
|---|---|---|---|---|---|
| 1 | Model weights | the models themselves | VRAM, or anywhere in the places table above | VRAM pressure (M1) | always |
| 2 | Work | activations, attention buffers, tiling accumulators | VRAM, and RAM when tiling | Refcount (M2) | always |
| 3 | Carry | cast staging buffers, CUDA graph pools, the static tensors a sampler reuses between steps | VRAM | Node end call (M3) | NVIDIA or AMD with async offload on; the 16 GiB reservation only under the pager |
| 4 | Results | what each node returned, kept in case you run again | RAM, and VRAM if a node returned a GPU tensor | Host RAM pressure (M4) | not --cache-none |
| 5 | State | what a node kept on itself between runs | RAM | Prompt start key sweep (M5) | not --cache-none; V1 nodes only |
| 6 | Everything else | imports, native libraries, fragmentation | both | Nothing (M6) | always |
What frees it¶
The same facts, sorted the other way. Rows here are what takes memory away, each one a function you can grep for.
| # | Mechanism | Where it lives | What it frees |
|---|---|---|---|
| M1 | VRAM pressure | aimdo page eviction, or free_memory (model_management.py) |
1 Model weights |
| M2 | Refcount | no call site. torch's allocator takes the blocks back into its own pool | 2 Work |
| M3 | Node end call | reset_cast_buffers (model_management.py) and cleanup_prefetch_queues, both from execution.py |
When a node ends, the executor calls reset_cast_buffers, which frees, in order:
cleanup_prefetch_queues, which drops the prefetch queues and CUDA graph pools (3 Carry again). |
| M4 | Host RAM pressure | RAMPressureCache.ram_release (caching.py), free_pins (model_management.py) |
4 Results, and Model weights' pinned copies |
| M5 | Prompt start key sweep | clean_unused (execution.py) into _clean_cache (caching.py) |
5 State. Results too, but only on the classic and LRU caches |
| M6 | Nothing | sys.modules interning in nodes.py |
6 Everything else, and PromptQueue.history |
| M7 | Local LRU | a feature's own free memory check, such as model_animate2.py |
nothing in the six kinds |
| M8 | Kernel reclaim | no ComfyUI call site. the mmap in utils.py |
the file backed half of Model weights |
| M9 | On request | POST /free into main.py |
Model weights, Results, State, torch's pool |
M1 to M6 line up with kinds 1 to 6. Read either table and you can jump straight to the matching row in the other. The numbering was chosen to make that true, so M4 frees kind 4 and nothing else has to be remembered.
M7, M8 and M9 fit no kind at all, and that is where this page used to be wrong:
- M9 cuts across three kinds at once. One request frees weights, results and node state, which is why it can only ever be a note beside the six and never a row among them.
- M8 owns half of kind 1 and no ComfyUI code decides anything about it. A
weight loaded under aimdo is a view into the mapped checkpoint, so the kernel
reclaims those pages on its own schedule, and
MemAvailablecounts them as available. That half of a loaded model is invisible to every sensor on this page, which is why kind 1's manager is only half the story. - M7 frees nothing in the six. It is a feature's private cache that behaves exactly like Carry and is released by its own check instead of the node boundary. No rule in the kinds table excludes it.
M6 carries one thing the six kinds never mention. PromptQueue.history
holds up to 10000 deep copied prompts (MAXIMUM_HISTORY_SIZE,
execution.py), bounded by count rather than by bytes. POST /free does
not touch it, and POST /history {"clear": true} does.
Neither table is a partition, and that is why there are two
One kind is freed by several mechanisms: Model weights answers to M1, M4, M3, M8 and M9. One mechanism frees several kinds: M3 alone reaches Carry, Model weights' patch pins and part of Everything else, in forty lines.
Sorting by kind tears M3 across four rows. Sorting by mechanism tears the pinned buffers across four rows. Whichever you pick, something is cut in half, so the page keeps both and lets you enter from whichever you have.
Managed memory¶
Three of the six kinds of memory objects (Model weights, Work and Results) are concerned by memory conditions at all. The other three are released without anything reading a number: Carry by M3, State by M5, and Everything else by nothing at all, which is why M6 is a row rather than a function. On a legacy install M3 never fires, so Carry has no releaser there either.
Among the three, the split that matters is central policy versus local reaction.
Model weights and Results each have a single thing that keeps a total, measures it and decides.
Work has neither: it has sixteen call sites that each read free memory and shrink their own work, and nine more that catch an out of memory error and retry smaller, with nobody anywhere keeping a running total. That is management, but it is management without a defined manager.
M3 and M2 look alike from outside and are opposites
Carry is released deliberately: reset_cast_buffers() at
execution.py, inside a finally, so it runs even when the node raises.
Work is released by nobody. Nothing frees an activation when a pass
ends. The tensors lose their last reference and torch's allocator reclaims
the blocks into its own pool, so the memory stops being active, stays
reserved, and never leaves the process. get_free_memory adds
mem_reserved - mem_active back onto driver free to account for exactly
this.
Carry is only released on the aimdo path
reset_cast_buffers() has one caller in the tree and it sits inside
if comfy.memory_management.aimdo_enabled (execution.py). On the
legacy ledger it never runs.
That costs less than it sounds, and it costs something different for each thing the call would have cleared:
- Cast buffers are held at their high water mark, not leaked.
get_cast_bufferkeeps one per offload stream and replaces it when a larger weight arrives, handing anything over 50 MB back to the driver at that moment. So the largest buffer ever needed stays resident for the life of the process instead of being freed at each node. - Dirty mmaps never accumulate at all.
_comfy_tensor_mmap_refsis only set byload_safetensors, andload_torch_fileonly calls that when aimdo is enabled (utils.py). The legacy path loads throughsafetensors.safe_open, soDIRTY_MMAPSstays empty and there is nothing to bounce. - Cross step state is empty on either path now.
_register_cross_stepand itsCROSS_STEP_STATEset are still inmodel_management.py, but the three model side callers they had (llama.py,gemma4.py,ar.py) were removed on 2026-09-05 by the Comfy Compiler commit (804eb551), which moved that reuse intomodel_prefetch.py's CUDA graph machinery, released bycleanup_prefetch_queuesfrom the samefinally. So the registry exists, nothing writes to it, and theWeakSetwalk inreset_cast_buffersiterates nothing.
The two managers share a channel for RAM and have none for VRAM
They do talk. Before it pins a tensor the Model weights manager calls the
Results cache's release hook (model_management.py,
pinned_memory.py), and the cache's own eviction loop turns around and
asks the weight manager to drop pins (execution.py). That channel
carries host RAM in both directions.
There is no equivalent for VRAM. So a cached output holding VRAM is the one shortfall nothing in the system can act on: the manager that can see VRAM cannot evict it, and the manager that owns it has no way to report it.
Model weights has several sensors and one trigger
Free VRAM, free host RAM, a pinned quota and the Windows pagefile, through a four tier eviction order. Those are four measurements of one question, "is something short". Multiplying sensors does not multiply kinds.
The Results poll has two rules, not one
Cached outputs from an earlier prompt are dropped after every node
regardless of pressure, because the target gating that sweep is set to the
machine's total RAM capped at 128 GB (main.py), so on any machine with
less than that it can never be satisfied. Outputs from the current
prompt are dropped only when free RAM falls below roughly two to ten
gigabytes, and on the first pass only if the entry is at least half a
gigabyte. So it reads like a pressure system and behaves, for stale entries,
like a collector that runs at every node boundary.
Anyone can short circuit Model weights, Results and State at any time
POST /free unloads every model and rebuilds both caches. The stock
interface has a button for it. So the What frees it column names the
mechanism that acts on its own, not the only one that can act: M9 releases
weights, results and node state on demand, with no pressure and no waiting
for a node or a prompt to end.
Five configurations move a kind onto a different mechanism
--disable-smart-memory retires weights at the end of every prompt rather
than under pressure, moving Model weights off M1. And a V3
node gets a fresh class clone per call, so State does not apply to it at
all: its state cannot outlive a single node.
The rest of this page takes the six in order. One thing cuts across all of them, the number they all read, and that comes last.
Two eras of weight management¶
ComfyUI has had two approaches to Model weights, and the second arrived in a single commit. Kind 3 and mechanisms M3 and M8 exist only under aimdo. Everything else on this page is the same under both.
The ledger came first. current_loaded_models is a list of what is
resident, ModelPatcher wraps each model, and the unit of eviction is a whole
model. The manager estimates how much room a load needs, evicts entire other
models until that much is free, then loads.
aimdo replaced the estimate with demand paging. It landed on 2026-01-31 in
f8acd9c4, "Reduce RAM usage, fix VRAM OOMs, and fix Windows shared memory
spilling with adaptive model loading". VRAM is reserved as address space, pages
are mapped when a layer faults, and the unit of eviction is a page. A loaded
model is mostly a promise. How it works.
ComfyUI tells you which one you got, in the log:
| Log line | Manager |
|---|---|
DynamicVRAM support detected and enabled |
aimdo. Weights are paged in per layer |
No working comfy-aimdo install detected... Falling back to legacy ModelPatcher. |
the ledger. Whole models, evicted whole |
aimdo is the default. main.py picks it unless one of four things stops it:
a flag (--disable-dynamic-vram, --highvram, --gpu-only, --novram,
--cpu), an unsupported GPU (aimdo needs NVIDIA, or AMD on ROCm 7.14 and later), torch below
2.8, or a failed init. One flag runs the other way: --enable-dynamic-vram
forces the pager on past the first three gates, flags, GPU and torch version
alike (enables_dynamic_vram in cli_args.py returns True before reading
anything else, and main.py skips the torch floor for it), so with it only a
failed init_devices still lands on the ledger.
The escape hatch is being removed. The ledger is not
--disable-dynamic-vram prints "this argument will be removed soon"
(main.py). That notice is about the flag, not the code.
The ledger stays, because ordinary workflows still reach it.
samplers.py calls get_non_dynamic_delegate() whenever a cond carries
hooks, and model_patcher.py builds that delegate with
disable_dynamic=True. A CPU load device
and multi GPU deepclones take the same path.
So "legacy" describes which era it belongs to, not whether it runs.
1. Model weights¶
The models. This is the kind everyone means when they say ComfyUI manages memory. It and Results are the two kinds with a manager that keeps a total, and it is the only one whose manager can move bytes rather than only drop them.
On the aimdo path, a loaded model is mostly a promise¶
Weights get virtual address space, which is free, and no physical pages. When a
layer runs, fault() asks for VRAM to be committed for that weight, and
unpin() afterwards says it is no longer in use so the memory may be taken back.
Faulting a high priority weight evicts lower priority ones, and priority is
address order, so the application lays weights out in the order it will need
them.
Neither word means what it means elsewhere on these pages
An aimdo fault is a call the application makes on purpose, not the processor trapping because you touched unmapped memory. An aimdo pin is about whether VRAM is currently in use, not about locking host pages against swapping. The two operations also cost nothing alike: committing VRAM takes microseconds, while locking host pages runs at a couple of gigabytes per second. The full comparison is on the aimdo page.
An eviction sets a watermark. Faults above it fail fast, so a model does not re-fault everything on every iteration. A failed fault is not an error: the layer allocates a temporary, copies the weight in, and runs slower.
The point is that the application stops doing admission control. It asks every time and checks the answer. Full mechanism.
On the ledger path, whole models, by budget¶
load_models_gpu computes a per model byte budget called
lowvram_model_memory, then hands it to the model. A model need not be all in or
all out: partially_load loads layers until the budget is spent and streams the
rest during the forward pass. That is all "lowvram mode" ever was, a small
budget rather than a separate mode.
Two magic numbers
| Value | Means |
|---|---|
0 |
Load everything. Rewritten to 1e32. |
0.1 |
Load essentially nothing. Set when the computed budget came out as zero, and under NO_VRAM. |
The smallest possible request and the largest possible request are 0.1 and
0, adjacent numbers with opposite meanings. Any integer cast of the budget
would collapse 0.1 to 0 and invert the instruction. Nothing in the tree
does that cast today, and nothing prevents it either.
Making room¶
When the budget does not fit, free_memory walks the ledger and unloads until it
does. The order it picks victims:
can_unload.append((-shift_model.model_offloaded_memory(),
sys.getrefcount(shift_model.model),
shift_model.model_memory(), i))
Ascending, so the first victim is the one already most offloaded, since finishing that eviction is the cheapest way to free the next byte. Ties break on refcount, then size, then list position. It recomputes free memory every iteration, so it is a feedback loop rather than a plan, and it stops as soon as there is room.
Two consequences of that sort key
Refcount as a tiebreaker means Results is a term in the Model weights eviction policy. How many references a model has depends on what the results cache is holding. These are not independent systems.
The last tiebreaker is list index, where index zero is the most recently loaded model. At the bottom of the sort, the policy is anti LRU.
On the default path this loop declines to act
if current_loaded_models[i].model.is_dynamic() and for_dynamic:
memory_required -= current_loaded_models[i].model.loaded_size()
memory_to_free = 0
for_dynamic is true when every model in the request is dynamic, which is
the normal case. The loop evicts nothing, because aimdo is already doing it
per page. This is where the ledger stops applying.
Where evicted weights go¶
On the ledger path, down the table in Where memory lives,
one place at a time. Under aimdo eviction has no destination: vbar_free_memory
unmaps the page and the bytes are gone. What it costs to get them back is set by
which host copy already exists, a pin, the mmap, or nothing and a reread.
--fast-disk makes the file (4c) preferred over a resident host copy, so a
weight is re-read from the file rather than kept in host memory between uses.
This is not GPUDirect Storage
The bytes still pass through host RAM. What changes is that they pass through
a small ring of pinned buffers rather than landing in a full size copy in
ordinary memory: the reader preads a window into a pinned slot, sends that
slot to the card, and rotates slots so reading and sending overlap. There is
no cuFile and no O_DIRECT anywhere in the implementation.
So it trades a persistent host copy for repeated reads. Worth it on fast storage, which is what the flag's help text says.
On a full unload the ledger entry is popped, so nothing tracks the copy that lands in RAM.
Pinned RAM is the only place with a real budget: MAX_PINNED_MEMORY, with
ensure_pin_budget gating each new pin.
Three ways that budget is softer than it looks
--high-ramreturnsTruebefore reading it. It is off. The same flag also switches Results to the classic cache, so one flag has two effects.- In the default configuration the branch taken never consults
MAX_PINNED_MEMORYat all. It probes system available RAM instead. ensure_pin_registerable()returns a value that four of its five callers discard and pin anyway. Onlypinned_memory.pychecks it.
Nothing budgets pageable RAM
free_memory takes a ram_required parameter. It appears in one log string
and no caller in the tree passes it. VRAM pressure converts into RAM pressure
with no check that the RAM exists.
2. Work¶
Everything a forward pass allocates and then drops: activations, attention workspaces, the buffers a cast needs, the accumulator a tiled VAE decode writes into. It is freed by the pass ending, which means refcounting, which means nothing decides anything.
Nothing measures it either. ComfyUI holds a constant back and estimates the rest.
The constant is EXTRA_RESERVED_VRAM, 400 MB, or 600 MB on Windows, plus 100 MB
more on Windows cards over 15 GB. minimum_inference_memory() adds 0.8 GB. On a
16 GB Windows card roughly 1.5 GB is spoken for before a single weight loads.
The knob differs by manager, which catches people out:
| Path | Knob | What it does |
|---|---|---|
| aimdo | --vram-headroom |
keeps this much free, "even counting VRAM from other apps" |
| both | --reserve-vram |
replaces EXTRA_RESERVED_VRAM on either path, and is handed to aimdo as its simple VRAM headroom (main.py) |
On top of the constant sits an estimate, area × dtype × memory_usage_factor,
where the factor is one of roughly 47 hand tuned per architecture constants from
0.03 to 11.6, a dozen of them carrying a #TODO.
The estimator disagrees with itself
One of its two branches has no dtype term while the other scales by it. The
branch test names xformers and pytorch flash attention and nothing else, so
which formula you get depends on whether ENABLE_PYTORCH_ATTENTION happens
to be set rather than on which kernel will run. On NVIDIA it is set by
default, so --use-sage-attention still takes the dtype scaled branch. Where
pytorch attention is off, for example AMD without aotriton, every kernel gets
the flat constant instead.
Text encoders are budgeted at zero unless they define
memory_estimation_function. Two files implement one, ace15.py and lt.py. Both
estimate for fp32 and halve the constant when bf16 is available.
That constant is a guess, so the guess is sometimes wrong. Sixteen places outside the memory manager read free memory at runtime and change what they compute: VAE batch size, CFG batching, attention chunk sizes, even the choice between GPU and CPU. Nine more catch an out of memory error and retry smaller, and two fall back to a cheaper algorithm or to the CPU without shrinking the work. None of them tell the manager anything, and most of them discard the setting that worked, so a fifty step workflow can rediscover the same fallback fifty times.
The fallback allocates more RAM than the thing it replaced
Tiled VAE decode writes into a float32 accumulator sized for the whole
output. The untiled path honours --fp16-intermediates. The path you take
when you are already out of memory does not.
3. Carry¶
The buffers a model reuses between forward passes, which is why they are not Work. A sampler running fifty steps allocates these once and keeps them for all fifty.
- Weight cast staging buffers, one per offload stream, each sized to the largest weight in the model. Two streams by default on NVIDIA and AMD.
- A 16 GiB VRAM address reservation taken by aimdo for the same purpose.
- Static tensors a model parks on itself to survive steps, such as the reused input buffers and rotary tables in the text encoders, or the device tensors an autoregressive audio decoder keeps for the whole loop.
- Pinned host memory holding patch weights, and prefetched weights whose pages are currently held resident.
All of it is released together, in a finally block wrapped around one node's
execution, so it survives every pass inside that node. The same finally reaches further
than Carry: see M3.
Carry needs aimdo and an offload stream
M3 is guarded on aimdo being active, which is the default. On the fallback path most of these structures are not created at all, and the ones that are outlive the node: cast buffers for the life of the process, at their high water mark, and cross step tensors for as long as the model that owns them.
Separately, the cast buffers need an offload stream. With
--disable-async-offload or on anything that is not NVIDIA or AMD,
get_offload_stream returns None (model_management.py) and the
staging buffer becomes a per call torch.empty in ops.py, which is
Work, not Carry.
One caller, and no other way back
The function that releases these has exactly one call site in the whole
tree. There is no pressure path to it and no periodic sweep. If a node never
returns, none of this is ever freed. The 16 GiB is a virtual reservation
(cuMemAddressReserve), committed on demand in 16 MiB chunks by
vrambuf_grow, so what is actually resident is the largest weight the node
cast, rounded up, not 16 GiB.
4. Results¶
What each node returned, kept in case you run the graph again. In an ordinary install this is the largest thing in the process, larger than any model.
On the default cache nothing frees a Results entry at the prompt boundary.
RAMPressureCache.clean_unused (caching.py) drops the parent's key sweep
and keeps only subcache cleanup, so an entry survives until host RAM pressure
picks it (M4) or you ask for it to go (M9). On --cache-classic and
--cache-lru the parent sweep runs and M5 applies instead. That is a
real byte budget, which is more than State and Everything else get.
Three sharp edges
- A cache entry whose outputs are all CUDA tensors scores 0.05 bytes in total. The size scan adds real bytes for CPU tensors and nothing for GPU tensors, so the entry keeps that baseline however much VRAM it holds, and that also puts it under the half gibibyte floor on the first pass. There is a release callback for RAM and no equivalent for VRAM, so if cached results are your VRAM shortfall, nothing can act on it.
--cache-lru Nbounds item count, not bytes. A hundred entries of unbounded size.- The poll now honours a cgroup limit, since 2026-08-27.
comfy/system_memory.pyreadsmemory.maxandmemory.limit_in_bytesand clamps to the container's budget, so underdocker --memorythe eviction threshold is the container's, not the machine's.
5. State¶
What a node kept on itself. A core LoRA loader parks the whole state dict on
self, several gigabytes of it, and a hook loader does the same with a
checkpoint's patch weights.
Nothing polls this and it has no size accounting. It is freed by the prompt start
key sweep (M5), and the key is the node id plus its class type, nothing else.
Changing a seed, a prompt string or any widget does not free it. Loading a
different workflow frees it only if no node in the new graph carries that same id
and class. What reliably clears it is POST /free with {"free_memory": true},
which rebuilds the cache set (execution.py), or --cache-none, which makes
the objects cache a NullCache so no instance is retained at all.
The practical shape of that: fill your RAM, keep running the same workflow, and Results drops entries while State does not move. The results cache reacts to pressure. State does not react to anything except a change of graph.
V1 and V3 nodes differ here
V3 nodes get a fresh class clone per call, so they cannot accumulate state across executions the way a V1 node can.
6. Everything else¶
Two of these are M6, freed by nothing: custom node imports and allocations made
outside torch. The other two are partly reached by the node end call (M3), whose
soft_empty_cache returns any segment with no live block on it. Fragmentation
that survives that is fragmentation live tensors are pinning, and nothing short
of a restart moves it.
- Custom node imports. Arbitrary code at import time, interned in
sys.modulesforever. There is no unload path anywhere in the tree, and exactly one function is protected against monkeypatching. See Import time side effects. - Allocations outside torch. OpenGL framebuffers on the same card, FFmpeg
decoder buffers, scipy trees, cuBLAS workspaces created by
--deterministic. Invisible to every accounting scheme in the codebase. - Class level caches holding GPU tensors, which no mechanism on this page reaches.
- Fragmentation. Free and unusable, measured nowhere.
The number M1 and Work read¶
Every decision above is computed from one number:
mem_free_cuda, _ = torch.cuda.mem_get_info(dev)
mem_free_torch = mem_reserved - mem_active # torch's own cache
mem_free_total = mem_free_cuda + mem_free_torch
That second term is VRAM torch has reserved and is not using.
It is counted as free. It is not reliably returnable.
reserved minus active is the sum of free blocks, which may all be too
small and too scattered to serve the next allocation. Measured on an RTX
3090: after freeing every other block the number reported 124 MB,
empty_cache() released nothing, and a contiguous 128 MB allocation still
went to the driver.
When every block is dead it releases everything. When live tensors pin every segment it releases nothing. Fragmentation is what decides, not the allocator backend. And it can never release Model weights on the aimdo path, because those weights were never in the caching allocator to begin with.
There is also more than one of this function. ModelPatcher.get_free_memory
returns the above plus what aimdo could reclaim on demand. Both are used, in the
same file, in the same batching decision.
Why comfy-env has to care¶
comfy-env runs node code in separate processes, so a worker's models are Model weights memory that ComfyUI's ledger cannot see. What it does about that is the subject of what survives isolation: the stand-in model it registers so upstream can evict a worker's model, the admission arithmetic it corrects, and what it runs inside a worker, including how a worker gets the pager at all.
How this page goes stale¶
--disable-dynamic-vramis removed. Upstream says it will be, and issue #15285 is open asking them not to. Note what goes: the flag, not the ledger. A CPU load device has no aimdo path at all, so the ledger cannot be deleted without deleting CPU support.RAMPressureCachestops overridingclean_unused. One deleted method puts Results back on M5 and makes half of section 4 wrong.- The cache flag chain in
main.pyis reordered.--high-ramoverriding--cache-noneis precedence, not intent, and the Present when column depends on it. current_loaded_modelsstops being a list. Everything comfy-env does registers into it.