Where the stand-in is inaccurate¶
comfy-env registers a stand-in object in ComfyUI's loaded-model list for every model a worker holds. It has to answer for memory it does not hold, and three of its answers are not true. Here is each one, why it is that way, and what it costs.
These are not bugs waiting for a fix. They are what standing in for an object in another process costs, against an interface nobody wrote down. They go away when the upstream hook exists, and not before.
1. It says a paged model is not paged¶
ComfyUI asks every entry in its list is_dynamic(): is this a model managed
by comfy-aimdo, which pages weights per layer, or a legacy one that lives on
the card whole? The stand-in always answers False, even when the worker's
model really is paged.
Why. Answering True opens a door. ComfyUI then reads dynamic_pins off
the object, a dictionary keyed by device, each entry holding four six element
positional tuples and four flags (reset_cast_buffers rewrites two of the
tuples' buckets), and
walks it after every node to manage pinned host RAM. That layout is internal,
it changed twice in one year, and getting it wrong does not raise an error;
it corrupts an accounting the host uses to decide what to page. Answering
False keeps the stand-in out of the whole pinned-memory machinery, which is
where most upstream churn lives.
What it costs. ComfyUI reasons about a paged worker model as though it were a legacy one. In practice this matters least where you would expect: the worker runs its own copy of ComfyUI's manager and pages its own models correctly, so the behaviour is right and only the host's description of it is wrong. What is genuinely lost is that worker pinned RAM never enters the host's pin eviction ladder. The worker backs off on its own, against a system wide figure that already sees every process, so nothing runs away; but it backs off at a different threshold than the host, and the host cannot ask it to do anything.
2. Its residency is a receipt, not a reading¶
ComfyUI asks a loaded model three different things: how big is it
(model_size), how much of it is on the card right now (loaded_size), and
therefore how much is already offloaded (model_offloaded_memory). It uses
the third to decide which model to evict first, on the reasoning that a model
already half on the CPU is the cheapest one to finish evicting.
The stand-in answers the first two separately and lets upstream derive the
third. model_size() returns the parameter plus buffer total the worker
computed when it registered the model. loaded_size() returns
model_loaded_weight_memory on its inner stand-in module, and that field is
written only by the host applying what the worker last said: a command echo
(state_sync.apply_echo, after a partial load, partial unload or detach) or
the node boundary census (state_sync.apply_residency), both carrying the
worker's own real loaded_size(), measured with whichever patcher the worker
actually runs. The max(aimdo, torch) figure the worker also reports is a
different number for a different consumer: it is the per worker held
scalar, read by pool._worker_charges to size the reserve, and it never
reaches the stand-in.
Why. The worker is the only process that can measure its own residency, and it can only say so when it is talking: at a command reply, or at the boundary of a call. Between those moments the host has no channel, and under aimdo the pager faults pages in and out on the worker's own schedule with no message to anyone.
What it costs. Freshness. The honest gloss on loaded_size() is "how
much was on the card when we last talked". An idle worker cannot re-fault
(faults are synchronous worker Python), so for an idle worker the receipt is
exact; a worker mid call can have moved gigabytes since its last echo. The
eviction sort key is built from that stale number, so ComfyUI believes it is
choosing the cheapest victim and is really choosing from a snapshot. On a card
with one host model and one worker model the choice is between two things and
the ordering barely matters; with several of each it can evict something more
expensive than it needed to.
3. On Linux, its size is already counted¶
get_free_memory on Linux reports device-wide free memory, so every byte a
worker holds is already missing from it. The stand-in also reports those bytes
as its own size. Anything that adds the two together counts the same memory
twice.
Why it is usually harmless. ComfyUI's eviction arithmetic is
what I need minus what is free, and the sizes in the list are used only to
order the candidates, never to compute the target. So the double count has
nowhere to land on the normal path.
Where it does land, and why it no longer lands there. Node code outside
model_management.py reads the loaded-model list and hands entries straight
back to load_models_gpu: controlnet does it, three of the bundled extras
nodes do it, and the multi-GPU node reads it unfiltered.
Six of those seven callers filter on currently_used, and since 2026-09-06
comfy-env registers every stand-in with currently_used False,
unconditionally. So the stand-in is not in the list those six read. The
remaining reader is multigpu.py, which reads unfiltered.
That change closed a cost that was real while it was open. Because the
stand-in answers is_dynamic() False, load_models_gpu was adding its full
size to total_pins_required, and free_memory was spending that on
ensure_pin_budget: the host evicting its own pinned RAM to make room for
weights that live in another process and are never pinned locally. With the
pager running the entire ask was phantom, because host models are dynamic and
book nothing, so they were the only models that could pay.
What remains, on the one unfiltered reader: multigpu.py ignores
currently_used entirely and calls clone() on entries that pass its
filters. The stand-in raises on clone(), because a worker model has no clone
semantics the host could use. It never gets there: the checks above compare
load_device, then clone_base_uuid, then an internal flag, and the stand-in
fails one of those first. Its clone_base_uuid is a private sentinel that
cannot equal a real uuid, which is a deliberate guard rather than luck. But
the ordering itself is line ordering in somebody else's file, and that is the
thinnest margin in this design.
What would remove all three¶
Not a better stand-in. All three exist because the object must answer for memory it does not hold, and no amount of care makes an approximation exact.
They are removed by ComfyUI gaining a way for an outside process to say how much it needs kept free and to be asked to give memory back: two methods and a registry, no object pretending to be a model, nothing in core that knows what a subprocess is. That is written out at the end of comfy-env's memory management.