fix(qwen): make room in VRAM for the quantized Qwen2.5-VL encoder and release it afterwards (#9147) - #9572
fix(qwen): make room in VRAM for the quantized Qwen2.5-VL encoder and release it afterwards (#9147)#9572lstein wants to merge 5 commits into
Conversation
…encoder The int8/nf4 encoder path bypasses the model cache (BitsAndBytes models are pinned to the device they were quantized on), so it also bypassed the make-room-for-the-model step that `lock()` performs for cached loads. With the transformer and VAE still resident, `device_map="auto"` planned the encoder onto the CPU and BnB int8 refused with "Some modules are dispatched on the CPU or the disk" as soon as the prompt changed after a generation (invoke-ai#9147). - `ModelCache.make_room_in_vram(bytes, working_mem)` exposes the same smallest-first offload policy `lock()` uses, for loads that live outside the cache. Surfaced on the invocation context as `context.models.make_room_in_vram`. - The quantized encoder estimates its post-quantization footprint from the bf16 checkpoint size, asks the cache for that much VRAM, and loads with an explicit `device_map={"": <execution device>}` so a genuine shortfall is a plain OOM rather than a misleading offload error. In multi-GPU mode this also pins the load to the worker's own device instead of letting "auto" pick one. - Offload and load run under the MODEL_LOAD_LOCK read lock, like every other VRAM move, so a concurrent cache construction cannot hijack the encoder's parameter assignment onto the meta device. Fixes invoke-ai#9147. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98
`_encode` still held the BitsAndBytes encoder when its cleanup callback ran, so the callback's `del` freed nothing and `empty_cache()` ran while ~9 GB of encoder weights were alive. They were released only when the frame exited, after which they stayed *reserved* by torch. The model cache budgets from allocated + driver-free VRAM, so that reserved-but-unused memory looked like it was in use and the next model (the transformer) was needlessly partial-loaded - the "transformer 57% in VRAM" swings reported in invoke-ai#9147. Measured on a W7900 with the bf16 Qwen2.5-VL-7B encoder quantized to int8: cleanup with the frame's references live left 40.7 GB allocated; dropping them first brought allocated to 31.9 GB, and empty_cache() then returned 8.9 GB to the driver. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98
…ghten tests Findings from the adversarial review of the first commit: - On a CPU-only install `_get_vram_available` raises for the cpu device. `lock()` never reaches it there, but `make_room_in_vram` did, so the second quantized-encoder run (once anything was cached) failed with "Unsupported execution device: cpu" where it used to load. There is no VRAM to make room in, so short-circuit like `lock()` does. - The invocation test only sampled the model-load lock inside `from_pretrained`; moving `make_room_in_vram` outside the read lock survived it. The offload is a VRAM move like any other, so it now has to be under the lock as well. - Nothing proved `make_room_in_vram` owns the cache lock, and nothing covered the invocation-context delegation (dropping `working_mem_bytes` there survived). Both are pinned now. - The docstring claimed a selective smallest-first offload. That is the loop's contract, but on hardware the driver only sees freed memory after the trailing `empty_cache()`, so `lock()`'s policy (which this reuses) tends to offload every unlocked model. Reworded; the policy itself is out of scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98
… before empty_cache Two residuals of the release mechanism, from the adversarial review: - When the forward raises (an OOM is the likeliest failure here), the in-flight traceback references the forward's frames, and through their locals the model, so `del text_encoder` freed nothing and the ~9 GB stayed reserved into the next generation - exactly when VRAM is scarcest. Clear the finished traceback frames before re-raising; the raising line survives for the error report. - The full-vocabulary logits and per-layer hidden states (~0.5 GB in edit mode with three reference images) were still held by `_encode`'s locals during empty_cache(). Moved the forward and post-processing into a helper so they die with its frame on both the normal and the error path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98
Findings1. High - Chain:
Before this PR To expose this issue, add a test that holds a fake long-running 2. Medium -
Secondary consequence on the same path: To expose this issue, add a test that makes 3. Medium -
To expose this issue, add a test that asserts the invocation surfaces an actionable error (rather than a bare OOM) when 4. Low -
5. Low -
To expose this issue, add a test that points 6. Low - The |
Summary
Fixes #9147 (the part that was still failing after #9305): with Encoder quantization set to
int8ornf4, the Qwen Image prompt node failed on every prompt change after the first generation withRoot cause. The BitsAndBytes path loads the Qwen2.5-VL encoder outside the model cache (BnB models are pinned to the device they were quantized on), so it also skipped the make-room step that
lock()performs for every cached load. With the transformer and VAE still resident,from_pretrained(device_map="auto")planned the encoder onto the CPU and BnB int8 refused. Nothing in the path ever asked the cache for VRAM.A second defect in the same path explains the "transformer swings between 57% and 100% in VRAM" symptom from the issue:
_encodestill held the encoder when its cleanup callback ran, so the callback'sdelfreed nothing andempty_cache()ran while the weights were alive. The weights were released only when the frame exited, after which ~9 GB stayed reserved by torch. The cache budgets frommemory_allocated+ driver-free VRAM, so that reserved-but-unused memory looked like it was in use and the next model (the transformer) was needlessly partial-loaded.Changes
ModelCache.make_room_in_vram(bytes, working_mem_bytes=None)– exposes the offload policylock()already uses (_offload_unlocked_models) for loads that live outside the cache. Locked models are never touched; offloaded models stay cached and re-stream later. No-op on a CPU execution device (where_get_vram_availablewould raise).context.models.make_room_in_vram(...)– invocation-context wrapper, resolving to the calling worker's per-device cache in multi-GPU mode.qwen_image_text_encoder.py):0.6×int8,0.4×nf4; measured 0.56× on the 7B encoder) and asks the cache for it;device_map={"": <execution device>}instead of"auto", so a real shortfall is a plain OOM rather than a misleading offload error, and in multi-GPU mode the load is pinned to the worker's own device;MODEL_LOAD_LOCKread lock, like every other VRAM move, so a concurrent cache construction cannot hijack the encoder's parameter assignment onto the meta device;_encodedrops its reference to the encoder before the cleanup callback runs, soempty_cache()actually returns the weights to the driver. The forward and post-processing live in a helper so the logits/hidden states die with its frame, and on an exception inside the forward the finished traceback frames are cleared first (the in-flight traceback otherwise keeps the model alive exactly when VRAM is scarcest).Verification on real hardware (W7900, ROCm, bf16 Qwen2.5-VL-7B → int8)
A 37.5 GB filler model in the cache, then the prompt node's quantized path wired to the real cache:
_get_vram_availablemake_room_in_vramwas called with 9.27 GB, partially offloaded the filler by 5.6 GB, the encoder landed oncuda:0, and a forward pass produced finite bf16 embeddings. (The original CPU-dispatch error itself cannot be reproduced on AMD: the HIP driver spills oversubscribed allocations into system memory instead of refusing, sodevice_map="auto""succeeds" there. It reproduces on NVIDIA.)Tests
tests/backend/model_manager/load/model_cache/test_model_cache_make_room_in_vram.py– offload loop contract with fake VRAM accounting (stops when satisfied, skips locked, forwards working memory, holds the cache lock, CPU no-op) plus a real-accelerator test (skipped without CUDA/ROCm) that checks weights actually move for both cached-model flavours.tests/app/invocations/test_qwen_image_text_encoder.py–make_room_in_vramis called with the estimated size beforefrom_pretrained, both under the model-load lock;device_mapis explicit; single-file checkpoints still fall back to the cache; the encoder is released before cleanup runs, on the normal path and when the forward raises.tests/app/services/shared/test_invocation_context_make_room_in_vram.py– context wrapper delegation.Each test was checked against a mutant of the production line it pins (revert the
del, skip the traceback clearing, usedevice_map="auto", drop the make-room call, move it outside the lock, drop@synchronized, drop the CPU guard, drop the working-memory argument); every mutant fails its test.Manual test plan
Set
log_level: debugto see the[MODEL CACHE]lines. The encoder must be a folder (diffusers-layout) install with atext_encoder/directory – BnB cannot quantize a single-file checkpoint, and those fall back to the cached path as before.NVIDIA 16 GB (this rig reproduces the original failure)
Offloading unlocked models with goal of making room for ~9500 MBfollowed byUnloaded <transformer> from VRAM ...right before the encoder loads.~6300 MBrequested.Loaded model ... VRAM: N%line to be the same on every iteration (before, it drifted down on later iterations because of the reserved-memory leak).nvidia-smibetween nodes: after the prompt node finishes, used memory should drop back to transformer-only.Dual W7900 (48 GB each, ROCm)
generation_devices: auto, enqueue two Qwen sessions with int8 quantization at once. Expect one session per card, each encoder loaded on its own device (rocm-smi --showmemuseshows both cards climbing by ~9 GB during the prompt nodes). Before,device_map="auto"could place both encoders on GPU 0.nonevsint8– images should be near-identical.pytest tests/backend/model_manager/load/model_cache/test_model_cache_make_room_in_vram.py -k gpu.Follow-ups (out of scope)
_offload_unlocked_modelscannot see freed memory until its trailingempty_cache(), becausememory_allocatedand driver-free move in opposite directions during the loop. In practice any shortfall offloads every unlocked model. Pre-existing inlock(); this PR only reuses and documents it.BnbQuantizedLlmInt8bCheckpointModel) would make it cacheable and partially loadable, but needs a model format and an install-time or first-load quantization step.🤖 Generated with Claude Code
https://claude.ai/code/session_01C7hM9XHDWGNa57jY5pat98