Skip to content

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:
  • the cast staging buffers, one per offload stream, torch's and aimdo's (3 Carry)
  • dirty mmap pages, bounced back so a written-through checkpoint page stops being charged to the process
  • cross-step state tensors parked on modules (a registry nothing writes to any more)
  • for every paged model: its pinned host memory for LoRA patches (Model weights' patch pins), and dead entries in its weight pin buckets
  • malloc graph records on modules
  • then soft_empty_cache: torch's idle allocator cache back to the driver (the reachable part of 6 Everything else)
and 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 MemAvailable counts 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_buffer keeps 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_refs is only set by load_safetensors, and load_torch_file only calls that when aimdo is enabled (utils.py). The legacy path loads through safetensors.safe_open, so DIRTY_MMAPS stays empty and there is nothing to bounce.
  • Cross step state is empty on either path now. _register_cross_step and its CROSS_STEP_STATE set are still in model_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 into model_prefetch.py's CUDA graph machinery, released by cleanup_prefetch_queues from the same finally. So the registry exists, nothing writes to it, and the WeakSet walk in reset_cast_buffers iterates 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-ram returns True before 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_MEMORY at all. It probes system available RAM instead.
  • ensure_pin_registerable() returns a value that four of its five callers discard and pin anyway. Only pinned_memory.py checks 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 N bounds item count, not bytes. A hundred entries of unbounded size.
  • The poll now honours a cgroup limit, since 2026-08-27. comfy/system_memory.py reads memory.max and memory.limit_in_bytes and clamps to the container's budget, so under docker --memory the 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.modules forever. 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-vram is 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.
  • RAMPressureCache stops overriding clean_unused. One deleted method puts Results back on M5 and makes half of section 4 wrong.
  • The cache flag chain in main.py is reordered. --high-ram overriding --cache-none is precedence, not intent, and the Present when column depends on it.
  • current_loaded_models stops being a list. Everything comfy-env does registers into it.