fix(qwen): estimate Qwen Image VAE working memory so the cache frees room before decode/encode - #9305
Conversation
… decode/encode The Qwen Image l2i/i2l invocations called `model_on_device()` without a `working_mem_bytes` estimate, unlike the SD/SDXL path. The model cache therefore only reserved the default `device_working_mem_gb` and never evicted the resident transformer/text encoder before the VAE decode. On a near-full card (e.g. Qwen Image Edit Q8_0 with transformer + text encoder resident) the decode then OOMs trying to allocate its working set into the fragmented remainder. Add `estimate_vae_working_memory_qwen_image()` and pass it into both the decode and encode paths so the cache makes room (evicting other models when needed) before the operation runs. The constant is calibrated against a measured decode on an AMD W7900: at 1248x832 the decode grew CUDA reserved memory by ~10.06 GiB (implied constant ~5082), rounded up to 5500 for headroom. It tracks peak *reserved* (not just allocated) memory so that whenever the cache declines to free room (free >= estimate) the decode is still guaranteed to fit. Encode uses ~half, matching the other estimators (not independently measured). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FindingsMedium - Missing test coverage for the actual fixPaths: Both the decode and encode paths now compute and pass Without an equivalent, a future refactor that drops the
Low - Encode constant is unmeasured and the docstring is inconsistentPath: The encode constant is hardcoded as For Qwen Image Edit, which encodes a real input image, an under-modeled encode constant reproduces the exact failure mode this PR fixes (cache declines to evict because
Open Questions
|
Address review feedback from @Pfannkuchensack on #9305: - Add test_qwen_image_working_memory.py mirroring the z-image pattern, asserting both decode and encode paths call model_on_device with the estimated working_mem_bytes (regression guard for the OOM fix). - Clarify the qwen estimator comment: the encode constant is not independently measured (half of decode, matching siblings' ratio) and should be recalibrated against a measured encode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @Pfannkuchensack — addressed in dd285a1. Medium (missing test coverage): Added
So dropping the Low (encode constant unmeasured / inconsistent docstring): Reworded the comment to stop claiming the encode constant "matches the other estimators." It now states plainly that the encode constant is not independently measured, is set to half the decode constant (consistent with the siblings' ~45–50% ratio), and should be recalibrated against a measured encode — the path Qwen Image Edit actually exercises. I left the value at Open question (single-point linear calibration): Agreed this is a real limitation. The decode constant is extrapolated linearly in |
Add scripts/calibrate_qwen_vae_working_memory.py, a backend-portable
(CUDA/ROCm) harness that measures peak reserved-memory growth for VAE
decode/encode across a resolution grid, one fresh subprocess per point.
Calibrating on an AMD W7900 (fp16) showed the encode constant was wrong:
the previous 2750 ("half of decode") under-estimated by ~2x at every
measured resolution, the exact OOM mode Qwen Image Edit (which encodes a
real image) would hit. Raise encode 2750 -> 6300. Decode 5500 is confirmed
safe across the full 512^2..2048^2 range and left unchanged.
The grid also showed memory is super-linear in area above ~1792^2 (an
attention term) and non-monotonic (likely an SDPA-backend crossover on
ROCm); both documented in the estimator. Constants are the conservative
ROCm side and will be max-merged with a pending NVIDIA/CUDA run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-point calibration — follow-up on the single-point concernAdded Implied constant =
Three takeaways:
On CUDA vs ROCm: these are the conservative (ROCm) numbers. The curve shape is architectural and should reproduce, but the absolute constant and the mid-range crossover are backend-specific (MIOpen vs cuDNN conv workspaces, flash-attention availability). I'll run the same harness on an NVIDIA card and ship the per-backend max — the CUDA numbers can only push the constants up, never down, so the encode fix above is safe to land now either way. To reproduce on NVIDIA: (auto-discovers the VAE under |
Calibrating the same fp16 grid on an NVIDIA card showed CUDA reserves ~2x (decode) to ~4x (encode) less than ROCm: the Qwen VAE is attention- heavy, and CUDA's Flash/efficient attention is O(area) and flat while the ROCm math-attention fallback is O(area^2). The backends diverge far more than any headroom, so a single constant either under-estimates on ROCm (OOM) or massively over-budgets CUDA (needless eviction). Select constants via torch.version.hip: decode: ROCm 5500 / CUDA 2900 encode: ROCm 6300 / CUDA 1600 Each verified to cover its measured grid (19 points/backend) with ~8% headroom. The CUDA run also confirms the linear model holds with Flash attention (the ROCm super-linear/non-monotonic behavior is a math- attention artifact), and that "encode is half of decode" is CUDA-only. Add parametrized tests asserting the constant selected for each (operation, backend) so a refactor can't silently swap them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CUDA results in — constants now branched per backendRan the same fp16 grid on an NVIDIA card. Implied constant
¹ card-size OOM, not a super-linear blowup. This decisively answers the open question and explains the earlier weirdness:
Because the two backends diverge far beyond any headroom, a single constant would either under-estimate on ROCm (OOM) or over-budget CUDA ~2–4× (needless eviction on tight NVIDIA cards). So the estimator now branches on Each verified to cover its full measured grid (19 points/backend) with ~8% headroom, and there's a parametrized test asserting the constant selected for each |
The calibration script only loaded the Qwen VAE from a diffusers directory via from_pretrained, so passing a single .safetensors file failed. Add _load_vae, which loads a directory as before and handles a single-file checkpoint by loading the state dict directly: a strict load for the diffusers layout, falling back to convert_wan_vae_to_diffusers for the original Qwen-Image/Wan release layout (downsamples/residual/ time_conv keys) before retrying.
…ke-ai/InvokeAI into fix-qwen-vae-working-memory
Qwen-Image VAE — Working-Memory-KalibrierungSetup: torch 2.7.1+cu128 · NVIDIA GeForce RTX 4090 · hip=None · dtype=float16 Decode
Encode
Summary (max implied constant = candidate
|
| op | n | min_k | max_k | empfohlen |
|---|---|---|---|---|
| decode | 10 | 2662.2 | 2723.6 | >= 2724 (+ Headroom) |
| encode | 10 | 1450.7 | 1461.3 | >= 1461 (+ Headroom) |
Summary
The Qwen Image
qwen_image_l2i(decode) andqwen_image_i2l(encode) invocations calledmodel_on_device()without aworking_mem_bytesestimate — unlike the SD/SDXLl2ipath, which callsestimate_vae_working_memory_sd15_sdxl(...). As a result, the model cache only reserved the defaultdevice_working_mem_gband never evicted the resident transformer / text encoder before the VAE decode.On a near-full card this OOMs. Reproduced with Qwen Image Edit 2511 (Q8_0) + the standard Qwen Image VAE on a 48 GB AMD W7900: with the transformer (~20.7 GB) and text encoder (~15.8 GB) resident, the autoencoder decode tried to allocate ~5 GiB into the fragmented ~8 GiB remainder and failed:
Root cause
ModelCache._load_locked_model()computesvram_available = free_vram − working_memand only evicts other models when that drops below what the locked model needs. The VAE is tiny (~242 MB) and already resident, somodel_vram_needed ≈ 0and nothing is ever evicted — the big transformer/text encoder stay put and the decode is squeezed into whatever fragmented VRAM is left.Passing a realistic
working_mem_byteslets the cache make room (evicting other models) before the operation runs, which is exactly what the SD/SDXL path already does.Fix
estimate_vae_working_memory_qwen_image()invae_working_memory.py.model_on_device(working_mem_bytes=...)in both the decode and encode invocations.Calibration
The estimate is calibrated against a measured decode on a W7900. At 1248×832 the decode grew CUDA reserved memory by ~10.06 GiB (implied constant ~5082); rounded up to 5500 for headroom. The current SD constant (
2200) under-modeled this heavier video-style VAE by ~2.4×.The constant intentionally tracks peak reserved (not just allocated) memory. The cache's guarantee is "if it doesn't evict, then
free ≥ estimate," so the estimate must be ≥ the decode's true reserved footprint. This closes the danger zone where the cache would skip eviction yet the decode would still reserve more than the free VRAM:Testing
device_working_mem_gbback at its default — the text encoder is offloaded just before the VAE decode and the generation completes.ruff check/ruff format/ compile all clean.Notes / open question
2750) follows the SD-style "half of decode" convention and is not independently measured — a conservative default. Worth a follow-up measurement if encode-side OOMs surface (relevant for Qwen Image Edit, which encodes an input image).expandable_segments:Truedid not resolve the fragmentation on that stack — eviction is what reliably works.🤖 Generated with Claude Code