ADR-0038: The memory floor — read, publish, ask, never patch¶
Status: accepted (2026-09-04), amended 2026-09-05. Supersedes the admission mechanism of ADR-0034 and the co-management design of ADR-0036.
Read this before the Decision below
This ADR was written to deprecate ADR-0035's model proxy and drop host-driven reclaim. Both of those were wrong and were reversed on 2026-09-05. The proxy is registered unconditionally for every worker model, there is no switch, and it is the only mechanism by which upstream's own eviction reaches another process. The reasoning that follows is preserved because the argument is still worth reading and the reversal is worth knowing; it is not the current decision. See "Corrected, 2026-09-05" below.
Also reversed: this ADR states that aimdo's headroom is fixed once its devices initialise. It is not. The setter is live at the next page fault, measured, and comfy-env forwards into it on every publish.
The goal of ADR-0025 is unchanged: a pack's VRAM should be invisible to the user. What changed is the price we are willing to pay for it.
Decision¶
comfy-env does not patch, wrap or class-patch anything in the ComfyUI host process. It reads values, publishes one number into a knob ComfyUI already exposes, and calls ComfyUI's own public functions.
~~and does not register a model it does not own... Host-driven reclaim of worker VRAM is deliberately dropped; workers release on their own instead.~~ Reversed 2026-09-05. comfy-env registers a stand-in for every worker model, unconditionally, and host-driven reclaim is the mechanism that makes the whole floor work.
Two wraps survived this ADR as a named, switched exception. Both were deleted on 2026-09-05: the rule is that comfy-env replaces no host function, switched or not. An AST test fails the build if any module that runs in the host process assigns to a comfy module apart from
EXTRA_RESERVED_VRAM, the knob--reserve-vramwrites.
The floor is always on and has four moving parts:
| What it does | |
|---|---|
| Publish | Adds what workers hold to EXTRA_RESERVED_VRAM, which ComfyUI reads live on every load, so its own arithmetic backs off |
| Ask | When a worker needs room, calls mm.free_memory, which evicts the host's own models unconditionally |
| Measure | Workers report one measured scalar of what they hold, not a ledger sum |
| Release | A worker idle for 60 s gives its VRAM back, and only then may the reserve shrink |
Context¶
The reserve works on one path and is inert on the other¶
Measured on an RTX 3090, ComfyUI 0.33.0, comfy-aimdo 0.4.13
(research/memory-floor/p2_reserve_levers.py, 6/6):
| Path | Preventive (publish a reserve) | Reactive (ask the host to free) |
|---|---|---|
| legacy | works: a 20.13 GiB reserve took a 6 GiB model from 6.00 GiB resident to 1.38 GiB | works |
| paged (aimdo) | inert: 6.03 GiB resident with and without | works: 6.03 GiB → 0.03 GiB |
ModelPatcherDynamic ignores lowvram_model_memory and decides residency
at page-fault time, so ComfyUI's reserve never enters the decision. ~~aimdo's own headroom is fixed when its devices initialise; the setter is
inert once running and comfy-env cannot move it.~~ Wrong, and reversed
2026-09-05. That experiment used plain nn.Linear modules, which never
page, so the setter had nothing to steer. It is live at the next fault:
measured on Windows, one fault after the write took residency from
14,784 MiB to 6,112 MiB in 52.3 ms, and comfy-aimdo #107 makes it a
documented contract with an upstream test. comfy-env forwards into it on
every publish. What IS true from that experiment: a second init_devices
returns False and a second control.init segfaults the process.
So the floor is preventive on the legacy path and reactive on the paged one. This is the single largest correction to the design as originally drafted.
The host already sees half of it¶
get_free_memory is cudaMemGetInfo plus torch's cached-but-unused bytes,
and cudaMemGetInfo is device-wide on Linux. The host therefore already
sees resident worker VRAM there. Publishing residency again double-books it,
which cost 8.9 GiB of idle card in measurement. The charge is the
headroom a worker will take beyond what it already holds; on Windows WDDM,
where the reading is per process, the whole entitlement is charged.
The ledger is not the truth¶
A paged model reads zero in ComfyUI's own
model_loaded_weight_memory, and torch reports about 20 MB against 6.4 GB
resident, because aimdo allocates outside torch's allocator. The worker
therefore reports one measured scalar: the maximum of aimdo's own
accounting and torch's reserved, never their sum. They overlap when torch
allocates (a 4 GiB model measured 4.02 GiB in torch and 4.03 GiB in aimdo at
the same instant, so summing reserved 8.05 GiB for a 4 GiB worker) and
partition only when aimdo pages.
Reclaim was priced wrong¶
ADR-0035's proxy exists so ComfyUI can
evict a worker's model. Every defect ever found in comfy-env's stand-in
came through it, though not in the shape this ADR assumed. It assumed a new
unguarded .model.<x> read landing on a fake object. What the audit of
2026-08-15 actually found was four wrong numbers and one raise of our own
making, and no user has yet seen an AttributeError from this object.
comfy-env has been public since 2026-04-25, so the record is 134 days long
and contains zero instances of the failure the design is built around.
Against nothing that is a fair trade. Against a worker that releases on
its own it buys only latency, in one narrow window:
| Situation | With proxy | Reserve plus idle release |
|---|---|---|
| Worker executing, host needs VRAM | cannot evict a running model | same |
| Worker idle a while | host evicts it | already released |
| Worker idle 5 s | host evicts it | host runs conservatively for seconds |
| Host would over-commit | evicts to survive | never over-commits |
Only the third row differs, and it costs latency rather than capability. The proxy is not worth its breakage history for that.
Upstream reached the same shape and it did not ship¶
ComfyUI's own pyisolate-support branch built exactly this proxy at ten
times the scale: a 1311-line registry, an 891-line patcher proxy, and
113 lines of in-tree changes to model_management.py to make a foreign
entry survive its lifecycle paths. It is four months stale, 747 commits
behind, and none of it is in master. The lesson taken here is that a full
proxy is viable in the tree and a treadmill outside it.
Consequences¶
Good. The always-on floor is satisfied by ComfyUI back to roughly
September 2024, bounded only by EXTRA_RESERVED_VRAM, against the 22-day
window the previous design required. That range is computed by sweeping
comfy_env.contract over upstream history
(research/memory-floor/sweep_contract.py), not asserted. comfy-env stops
re-deriving upstream arithmetic, which removes a whole class of silent
drift: the shipped admission slack was 1.02 where upstream books 1.1, under-
freeing by 680 MiB on a 12 GiB model and growing linearly.
Bad, and accepted. The host cannot take VRAM back from a busy worker. It
avoids over-committing and waits. During a host out-of-memory event, idle
release cannot help, because the workers are not idle then. What covers that
case is the pressure hook on the stand-in's partially_unload: being asked at
all is the notice that the host is short, and it posts an ask to idle siblings
without blocking the eviction loop it fires from.
Amended, September 2026. The optional observer this ADR introduced for the
two paragraphs above was deleted. Its stated first purpose, making the
Free-memory button reach workers, was already false: unload_all_models asks
for 1e30, LoadedModel.model_unload compares that against loaded_size,
loses, and calls detach(True) on the stand-in, so the button works with the
observer off. Its second, hearing host pressure, moved onto the object
upstream already calls. Keeping two objects in current_loaded_models with
different defences was the direct cause of three wrong rows in the memory
table, and one of them credited the stand-in with a clone sentinel that only
the observer had.
Corrected, 2026-09-05. This ADR treated the proxy as a leftover that
nothing depended on. That was wrong, and the error mattered: reclaim depends
on it entirely. _insert_loaded_model registers a stand-in for every worker
model on every node boundary, and that is what lets ComfyUI's own eviction
loop, the Free button and the out-of-memory handler reach memory in another
process. Deleting it would not be a tidy-up, it would remove the only
mechanism by which the host can take VRAM back from a pack.
What this ADR got right stands: the proxy is the fragile part, both loud breaks came through it, and the surface it presents should be as small as comfy-env can make it. What replaces it is an upstream holder interface, not a deletion.
What this replaces, precisely¶
- ADR-0034's offset-compensated target
computed from numbers comfy-env owns →
reserve.ask_target, which reproduces upstream's own expression. The offset survives, but only wheremem_get_infois genuinely process-local. - ADR-0036's mirroring of the manager → publishing one number and letting upstream's manager run unmodified.
- The
PromptModelTracker.startclass patch → readingcomfy_execution.progress.get_progress_state().prompt_id, which carries the real prompt id, cannot be reverted by ComfyUI's own custom-node unhooking pass, and predates the tracker by more than a year. - The pin-split allocation half → deleted entirely (481 lines). It never
shipped,
ensure_pin_budgetalready stops pinning from the global available-RAM figure, and the same ceiling sizes each model's host buffer throughpinned_hostbuf_size, so a grant would have silently capped large models.
See comfy-env's memory management for what an operator can switch, and the measured range that each level requires.