Skip to content

ROCm on Windows: keep VRAM resident and bound attention and VAE memory - #304

Open
Pfannkuchensack wants to merge 18 commits into
mainfrom
feat/rocm-windows-memory
Open

Pfannkuchensack wants to merge 18 commits into
mainfrom
feat/rocm-windows-memory

Conversation

@Pfannkuchensack

@Pfannkuchensack Pfannkuchensack commented Sep 21, 2026

Copy link
Copy Markdown
Member

Summary

Generations on a ROCm build of PyTorch under Windows ran 4-5x slower than the hardware allows, and Krea-2 fp8 did not run at all. Windows never fails an allocation that does not fit: it moves it into shared system memory and keeps it there, so nothing in the log says why a run crawls. Measured on an RX 9060 XT (gfx1200, torch 2.12+rocm7.14.1): Z-Image Turbo nvfp4 at 1024px denoised in 144/139/186 s over three runs; it now takes 34 s, with pixel-identical images. Krea-2 fp8 produced no step in two minutes; it now takes 60 s per image. Z-Image at 1536px is possible for the first time.

Four causes, addressed in order:

  1. The allocator fragments. An allocation that finds no contiguous VRAM is placed in system memory as a whole, even with the budget and plenty of room free, and it stays there. A ROCm build on Windows now defaults to expandable_segments:True, set before torch is imported and only when no allocator variable is configured. Any explicit pytorch_cuda_alloc_conf still wins.
  2. The model cache plans against memory Windows will not keep resident. torch.cuda.mem_get_info is the device total minus this process's own usage there: it ignores other processes, and Windows starts paging once the process passes its WDDM budget (15.09 of 15.92 GiB alone; lowered within a second when another GPU process starts). The cache's free figure is now capped by that budget through D3DKMTQueryVideoMemoryInfo, and a worker says so when memory stays in system RAM across two sessions (PDH GPU Process Memory\Shared Usage). Both are best-effort and silent everywhere else.
  3. No fused SDPA kernel exists on gfx1200, so attention runs on the math kernel and materializes the whole score matrix: 8 GiB per call for Z-Image at 1024px, 12.9 GiB for Krea-2. The existing ROCm SDPA guard now computes such a call in chunks of at most 1 GiB of scores - head groups first, query rows otherwise - and the working-memory estimates are capped to match. Chunking is exact in exact arithmetic and lands within one bf16 ulp of the unchunked call; head groups are bitwise identical where the kernel's arithmetic does not depend on the batch it runs over, which holds on CPU but not for a rocBLAS batched GEMM that re-tiles with the group count. Krea-2's denoise reservation prices the score matrix, which it did not before.
  4. VAE decodes only tiled after an out-of-memory error, which Windows never raises. Large decodes are now tiled up front when the untiled estimate would claim more than 90% of the VAE's device (Anima keeps its measured 70%), switchable with the new auto_tiled_decode setting. The FLUX.1 autoencoder's working-memory constants follow the convolution backend, as the FLUX.2 ones already did: MIOpen needs 3600 B/pixel-byte to decode where cuDNN needs 2200.

Items 3 and 4 change behaviour on every platform: math-kernel attention runs in chunks on Linux ROCm too, and decodes that would take most of the card are tiled everywhere. On CUDA the estimates and the fused path are untouched.

Documentation says only what is true: AMD remains supported on Linux only; the Windows ROCm notes are hints.

Related Issues / Discussions

None.

QA Instructions

Gates

  • uv run --no-sync pytest -n 6: 8905 passed, 174 skipped, 9 xfailed.
  • uv tool run ruff@0.11.2 check / format --check on the changed paths: clean.
  • pnpm -C docs build: 326 pages, link validation clean. Generated openapi.json, schema.ts and docs/src/generated/settings.json were regenerated with their generators; generate_docs_json.py now emits Path defaults with .as_posix(), so regenerating on Windows no longer flips the separators and fails check-docs-data.
  • ROCm hardware tests (-m slow, RX 9060 XT): 5 passed, 1 skipped. The skip is test_a_fused_kernel_is_still_wrong_for_the_wide_head: no fused backend runs that shape on gfx1200 at all, so there is nothing to compare.
  • In the ROCm venv, 4-5 tests in tests/app/invocations/test_flux2_working_memory.py fail depending on the build (5 on gfx1200 / torch 2.12+rocm7.14 here); they fail identically on the base commit, as they assume a CUDA/Linux backend.

End-to-end, from this branch's code with no runtime patches

RX 9060 XT, 16 GB (port 9091):

Run Result
Z-Image nvfp4 1024px x3 50.7 / 36.3 / 36.3 s (denoise 34.3 s), pixel-identical to the pre-change baseline
Z-Image nvfp4 1536px x3 180.4-180.9 s, decode 2.5-2.7 s, no overflow
Krea-2 fp8 1024px x3 78.3 / 59.6 / 60.2 s; runs 0 and 2 bit-identical. The same image takes 400.7 s at the merge-base
Shared system memory, both 0.28 GiB peak
Paging warning exactly one, after a second process held 8 GiB; silent in clean runs

RTX 4090, 24 GB (port 9090), for the CUDA regression:

Run Result
Z-Image nvfp4 1024px x3 identical to the stored reference images; denoise 4.84 s, decode 0.25 s, unchanged
Z-Image nvfp4 2560px tiles up front (26.9 GiB estimated > 90% of 24 GiB), decode 3.1 s, no OOM

Measurements behind two design decisions

  • The cap is the budget minus this process's live allocations, not minus CurrentUsage. HIP under Windows keeps 1-2 GiB after a free plus empty_cache() and hands it to the next allocation, while CurrentUsage still counts it: 4 GiB allocated, then freed, leaves CurrentUsage at 1.14 GiB with memory_reserved at 0 and torch's free figure back at its starting value; the next 2 GiB cost only 1 GiB of new usage. Capping against CurrentUsage would hide what an offload just freed.
  • Under expandable segments the offload loop empties the allocator after each model it moves out. Freed pages are invisible until then (del freed 0.00 GiB, empty_cache() freed 2.99 GiB), so the loop saw no progress and unloaded every unlocked model.

Not verified

Linux ROCm (the query-row chunks in the VAE and the FLUX.1 MIOpen constant), multi-GPU under Windows ROCm, other Windows ROCm builds, MPS/XPU pre-tiling, and small CUDA cards on real hardware. The Qwen-Image VAE's ROCm constants are unchanged (measurable only on Windows here), and auto_detect_slice_size still uses torch.cuda.mem_get_info directly.

Review

Three independent read-only reviews ran over the full candidate with the same base and acceptance criteria: correctness/spec conformance, architecture/operational safety/performance, and test value/product quality. Material findings resolved:

  • A settings.json regenerated on Windows flipped two path defaults and would have failed the docs job; the generator now emits POSIX paths.
  • The allocator test fixture leaked PYTORCH_CUDA_ALLOC_CONF into later tests, which made a model-cache test fail depending on file order.
  • The offload loop over-unloaded under expandable segments (see the measurement above), now covered by a regression test that fails without the fix.
  • The allocator default silently did nothing when torch was already imported; it now warns instead.
  • The paging warning could fire on an overflow that returns to VRAM by itself, and advised a restart; it now requires two consecutive readings and names settings to change.
  • Chunking sliced 3-D and wider-batch K/V wrongly; those calls stay whole.
  • The FLUX.1 decode node reserved nothing for a diffusers-layout AutoencoderKL, which is the exact failure this PR targets; it is now priced and pre-tiled like the Z-Image node.
  • The up-front tiling log was at INFO on every Anima decode and suggested turning the setting off, which is bad advice for Wan (no OOM retry) and Anima (tiling measured faster).
  • Test gaps closed: masked query-row chunking, Krea-2 with CFG and a regional mask, auto_tiled_decode=false for Wan and Anima, a Wan cpu_only test that passed regardless of the code, PDH struct offsets plus a hardware test, and a test that the worker loop calls the warning at all.

The final blocker-only review over the resulting candidate reported no blockers.

Compatibility / Rollout

  • New setting auto_tiled_decode (default true); generated OpenAPI, frontend types and docs settings regenerated.
  • No persisted-data migrations. The allocator default only applies to a ROCm build on Windows and only when nothing else is configured.
  • wddm.py is new and best-effort: every failure answers "unknown" and leaves the previous behaviour in place.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

With pytorch_cuda_alloc_conf unset, a ROCm build on Windows now runs with expandable_segments:True,
so fragmented VRAM no longer pushes allocations into shared system memory.
An explicit allocator setting or allocator env var still wins; docs and generated config artifacts updated.
On ROCm under Windows, free VRAM is now capped by the WDDM budget (gdi32 D3DKMTQueryVideoMemoryInfo),
past which Windows pages allocations into system memory instead of failing them.
torch's figure there ignores other processes and runs up to the physical total; other platforms are unchanged.
After each session, a worker on a Windows ROCm device reads the process's shared GPU memory (PDH) and warns
once per episode when more than 512 MiB of it sits in system memory, where every generation that touches it slows down.
Adds a low-VRAM docs section on AMD GPUs under Windows.
The ROCm SDPA guard now splits any math-kernel call over 1 GiB of scores into head groups (bitwise identical)
or query rows (one oversized head), and working-memory estimates price one chunk instead of the whole matrix.
Z-Image 1024px peaks at 0.5 GiB instead of 8 GiB per attention on an RX 9060 XT; renamed to install_rocm_sdpa_guard.
The Krea-2 working-memory estimate now adds the score matrix where the build materializes it (no fused kernel),
priced from the loaded model's heads and the attended sequence; one 1 GiB chunk on ROCm, nothing on CUDA.
Before, Krea-2 fp8 on an RX 9060 XT loaded the whole transformer and paged each 12.9 GiB attention into system RAM.
On ROCm the FLUX.1 VAE (also Z-Image's) now budgets 3600 decode / 2750 encode bytes per pixel·byte instead of 2200/1100,
measured 3451/2688 on an RX 9060 XT: the same convolution stack and numbers as the FLUX.2 VAE, so both share one table.
The estimate follows the VAE's compute device, so a cpu_only VAE keeps the cuDNN column.
…code

The FLUX.1, Z-Image, Qwen-Image (Krea-2) and Wan decodes now tile when the untiled estimate exceeds 90% of the VAE's
GPU memory (Anima keeps its measured 70%), instead of relying on an OOM that Windows paging never raises.
New auto_tiled_decode setting (default on) turns it off; force_tiled_decode and the tiled field still win.
- Cap free VRAM at the WDDM budget minus live allocations: HIP keeps freed memory that CurrentUsage still counts
- Empty the allocator cache after each offload under expandable segments so the cache stops once enough is free
- Hardware tests for the headroom after a free and for the PDH lookup
- Skip the allocator default once torch is imported; stop the test fixture leaking it
- Warn only about memory paged across two sessions, with actionable advice
- Leave K/V broadcasts the chunks cannot slice whole; cover masked row chunks and Krea-2 CFG/regional pricing
- Price and pre-tile FLUX.1 decodes with a diffusers-layout VAE
- Log up-front tiling at debug; document what auto_tiled_decode off means
- Emit POSIX path defaults in the generated docs settings
@lstein

lstein commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

I can verify this on Linux ROCm, but I don't have a Windows boot on my AMD rig.

@lstein

lstein commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Passes all the ROCm tests, including test_a_fused_kernel_is_still_wrong_for_the_wide_head on my W7900 rig.

These tests fail on purpose. They pin behaviour the current up-front
tiling rule does not have, and should go green with the fix.

- A Qwen-Image/Krea-2 decode whose measured peak fits the card is tiled
  anyway, because the gate is fed the padded reservation figure (a flat
  5500 B/pixel-byte) instead of the expected peak (3273 measured at
  1536px). Tiling is not pixel-identical, so this silently changes output
  for images that would have decoded in a single pass.
- should_pretile_vae_decode compares against the card's nameplate total,
  not the Windows video-memory budget this PR added
  TorchDevice.cuda_mem_get_info for, so a decode Windows will page into
  system memory is left untiled.
@lstein

lstein commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Adversarial review

Reviewed against c2e4cf6ee6 with three independent read-only passes (attention chunking; WDDM/allocator/cache; VAE tiling and working memory), each verified against the code afterwards. A lot of this holds up well — the ctypes work is correct down to the struct offsets, the chunking survived ~450 differential shape cases, and both generated artifacts regenerate byte-identical. The findings below are what survived verification.

I've pushed 33e16af with three intentionally-failing tests for the two findings I think are blockers. CI will be red by design — they pin behaviour the current rule does not have and should go green with the fix. Happy to drop that commit if you'd rather not carry red tests.


1. Reservation headroom is reused as a tiling trigger, so decodes that fit are silently tiled

invokeai/backend/util/vae_working_memory.py:22-38

The estimator constants are deliberately over-provisioned ("max observed + ~8% headroom"). Over-reserving used to cost only cache eviction. Comparing that padded figure against a 90% line converts the conservatism into a non-pixel-identical output change, with no error and nothing above DEBUG in the log.

Qwen-Image is the sharp case, because its ROCm decode constant is a flat 5500 while the curve in your own table is not flat — it falls to 3273 at 1536². Computed by calling the real estimator:

px estimate (5500) measured peak outcome
1024 10.74 GiB 8.93 GiB untiled everywhere
1536 24.17 GiB 14.38 GiB tiles on 16 and 24 GiB — fits both
1792 32.90 GiB 22.34 GiB tiles ≤24 GiB (genuinely too big)
2048 42.97 GiB 37.60 GiB tiles (correct)

On a 24 GiB ROCm card a 1536² Qwen-Image decode that previously ran untiled and succeeded now tiles. Krea-2 decodes through this node too. On CUDA FLUX.1 the crossovers are benign (shipped 2200 vs measured 2185), so this is specifically Qwen/Krea-2 on MIOpen.

The reservation is right as it is — it's the tiling decision that shouldn't be carrying reservation headroom. Test: TestPretilingDoesNotFireOnReservationHeadroom in tests/app/invocations/test_qwen_image_working_memory.py, written at the node level so any fix shape satisfies it.

One knock-on: test_a_decode_too_large_for_its_gpu_is_tiled_up_front_unless_switched_off asserts pretile.assert_called_once_with(compute_device, 20 * 2**30) — the raw padded estimate — so it pins the current behaviour and will need updating alongside.

2. The gate measures total VRAM on the platform this PR taught the cache not to trust

invokeai/backend/util/vae_working_memory.py:33-38

should_pretile_vae_decode uses torch.cuda.get_device_properties(device).total_memory. The docstring justifies the whole feature with "on Windows, drivers page an allocation that does not fit into system memory instead of failing it (always for ROCm)" — and this same PR adds TorchDevice.cuda_mem_get_info capping free VRAM by wddm.video_memory_budget for exactly that reason. The gate consults neither that budget, nor free memory, nor max_cache_vram_gb.

Trigger: 16 GiB RX 9060 XT, a browser holding ~3 GiB so the budget is ~12.5 GiB. FLUX.1/Z-Image at 1408² estimates 13.29 GiB — under the 14.4 GiB threshold, over the budget. No pre-tile, no OOM (Windows pages instead), so the retry at flux_vae_decode.py:114 never fires and the generation just crawls. That is the failure the helper exists to prevent. Your own measurement says Windows lowers the budget within about a second of another GPU process starting.

Also reachable on Linux/CUDA: max_cache_vram_gb: 6 on a 24 GiB card means nothing below 21.6 GiB ever pre-tiles while the cache budget is 6 GiB. Qwen has no OOM retry, so it hard-fails.

Test: test_the_pretile_gate_uses_the_memory_the_device_will_keep_resident in tests/backend/util/test_vae_pretile.py. It patches both wddm.video_memory_budget and the devices binding, so a fix routing through either is covered.


Other material findings (no tests pushed)

3. The expandable-segments offload fix is inert on multi-GPU. model_cache.py:2558, 2576-2577, 2584. TorchDevice.empty_cache() is peer-aware — when another registered generation device holds its lock it sets _empty_cache_deferred and returns without freeing. So the new per-offload call is a no-op in that window, _get_reclaimable_allocator_bytes() returns 0 under expandable segments, the loop sees no progress, and every unlocked model is offloaded. Line 2584 now reads if vram_bytes_freed > 0 and not empty_cache_per_offload, so the end-of-loop release is skipped too. Net vs base is "no better", not "worse". test_offloading_under_expandable_segments_stops_once_enough_is_free can't see it because it replaces TorchDevice.empty_cache wholesale with a fake, so the deferral never runs. Single-GPU is unaffected (both callers run on the session thread); the trigger is multi-GPU + expandable segments, which is the default on Windows ROCm.

4. The "bitwise identical" claim for head-group chunking is false. attention.py:135, :232, and the PR description. Reproduced on gfx1100 / torch 2.13+rocm7.2 with Z-Image's own shape (1,30,4608,128) bf16 under sdpa_kernel([MATH]): the math kernel is run-to-run deterministic, yet chunked == whole is False, max abs diff 0.00048828125. Splitting the batched GEMM's 30 head-batches into groups of 2 changes rocBLAS's tiling. Under one bf16 ulp, so it's a documentation fix rather than a code one — but test_heads_are_split_first_and_the_result_is_bitwise_unchunked asserts torch.equal on CPU fp32, where the claim does hold, so it validates the guarantee on the one platform it isn't about. The head-groups-vs-query-rows distinction is actually inverted for real shapes here: the FLUX VAE mid-block (row chunking) was bitwise identical in the same run.

5. paged_bytes can raise, breaking the module's stated contract. wddm.py:337. _pdh_query = _open_shared_usage_query(pdh) sits outside the try/except guarding the collect, and that helper checks return codes but catches nothing — on Windows ctypes turns an SEH fault into OSError. The module header promises "any failure yields None … callers must keep their existing behaviour." Contained today because the only caller wraps it.

6. auto_tiled_decode: false doesn't restore previous behaviour. Anima's 0.7 rule was unconditional before this PR, and its documented purpose isn't OOM avoidance — it's keeping the ~4 GB transformer resident ("7s+ observed on 8GB, vs ~1s tiled"). With the switch off on an 8 GiB card at 1024², you get the 7x slow path and the setting's own promise ("still retry tiled after running out of memory") doesn't cover it, because no OOM occurs. The switch also doesn't undo the reservation changes in the FLUX.1 and Z-Image nodes.

7. SD1/SDXL, SD3 and CogView4 left on the cuDNN constant. vae_working_memory.py:89, :135, :635. The module header groups "the diffusers AutoencoderKL (SD1/SDXL, SD3, CogView4) and the FLUX.1 AutoEncoder" as the same network, and this PR's evidence is that MIOpen needs 3600 where cuDNN needs 2200 for exactly that stack. Those three still hardcode 2200 if decode else 1100 and don't take a device, so on ROCm they under-reserve ~1.64x, and the pre-tiling gate inherits the under-estimate. The backend-branch pattern already exists two functions away in estimate_vae_working_memory_qwen_image. Pre-existing, but this PR quantifies it. Relatedly flux2_vae_decode.py — the node these constants were fitted on — has no tiling code and no OOM retry, so "tiled everywhere" is 5 of 12 decode nodes.

Smaller

  • sdpa_score_matrix_bytes caps at SDPA_MATH_CHUNK_BYTES whenever rocm_sdpa_chunks_math() (attention.py:530-532), which only tests ROCm + sentinel. The guard declines to chunk for is_causal, dropout_p > 0, non-4-D query, and 3-D/wider-batch K/V. No current caller prices such a shape, but a future one would be reserved 1 GiB against a matrix up to ~20x larger. The predicate name reads stronger than it is.
  • cuda_mem_get_info calls video_memory_budget unguarded (devices.py:471-473) on the hot path of every generation. I traced the escape surface — index-less devices, _resolve_adapter and the D3DKMT call are all caught — so the only hole is _load_gdi32 raising outside (AttributeError, OSError). Defensive gap, not a live bug.
  • Adapter handle leak if an exception escapes the enumeration loop (wddm.py:222-244); bounded to ~1 per device per process.
  • Contradictory docstrings on empty_cache() under expandable segments: model_cache.py:2344-2347 says it "reclaims nothing", :2555-2557 says an offload "only shows up once empty_cache() unmaps its pages". The newer one is right; the older is the stated justification for returning 0 there.
  • Two ROCm detectors disagree: torch_cuda_allocator.py:36-38 tests "+rocm" in the packaging metadata, wddm._supported() tests torch.version.hip. A locally-built Windows ROCm wheel gets the budget cap but not expandable segments, silently — and the docs point these users at self-installed torch.
  • rocm_causal_conv3d.py:143 still names install_rocm_sdpa_head_dim_guard.
  • flux_vae_encode.py:47 / z_image_image_to_latents.py:56 now price against compute_device, but lines 53/67 still place the tensor on choose_torch_device(). Pre-existing; the partial threading makes it visible.
  • auto_detect_slice_size (attention.py:34) and diffusers_pipeline.py:223 still use raw torch.cuda.mem_get_info.
  • In qwen_image_latents_to_image.py the tiled local goes stale after the pre-tile branch (only effective_tile_size is updated, which is what actually drives tiling — I confirmed tiling does engage). A later if tiled: added below would silently be wrong.

Verified as claimed

  • ruff check / format --check clean on all 37 changed Python files.
  • The test_flux2_working_memory.py failures are genuinely pre-existing — I ran the base tree from git archive and got the identical set. It's 4, not 5.
  • docs/src/generated/settings.json and openapi.json both regenerate byte-identical; schema.ts matches config_default.py.
  • "CUDA estimates untouched" holds: the new cudnn column is exactly the old hardcoded FLUX.1 literals, and install_rocm_sdpa_guard returns early off ROCm.
  • The docs' "~1850×1850 on a 16GB Nvidia GPU" matches my computed 1874px.
  • Deleted TestUseTiledDecode coverage migrated cleanly into test_vae_pretile.py.
  • Wan and Anima already pre-tiled at 0.9/0.7; switching them to vae_info.compute_device is a correctness improvement on multi-GPU.
  • Chunking held up: row chunking slices the query axis (softmax is over keys); mask broadcasting is right for 2/3/4-D, bool and additive, including the B == H == L == S trap; GQA grouping forces a multiple of heads // kv_heads; is_causal and dropout_p > 0 are excluded before any slicing, so the classic causal-chunk bug is unreachable; zero-extent inputs never enter the chunked path. Krea-2's score-matrix pricing matches the chunk budget, CFG sequencing and the GQA expansion.

I could not complete a full-suite run — this box was saturated and -n logical is not safe here. Changed-file tests are green apart from the three I added and the four pre-existing FLUX.2 failures.

…droom

- Compare against the Windows video-memory budget where there is one, not the card's total
- Qwen-Image ROCm constants now follow the bounded math attention this branch ships: measured 2650-2772 decode and
  1541-1552 encode across 512-2048px, against 5500/6300 fitted while a call built its whole score matrix
- Calibrate with the attention guard installed, so the script measures the path the app runs
…ld up

- Credit freed bytes while a peer device defers empty_cache, so multi-GPU stops over-unloading
- Never let the budget or paged-bytes lookups raise, and close adapter handles if enumeration throws
- Detect a ROCm build from torch's version.py too, so a locally built wheel gets the allocator default
- Correct the bitwise-identical claim for head-group chunking
…kend

- auto_tiled_decode no longer disables Anima's own rule, which is a speed optimization, not an OOM fallback
- SD1/SDXL, SD3 and CogView4 take the MIOpen constant on ROCm, like the FLUX.1 autoencoder they share a stack with
- Encode nodes place their tensors on the VAE's device, matching the decode nodes
…eiling

- Keep the padded Qwen-Image reservations; the tiling decision reads the measured curve instead, so a decode whose
  real peak fits the card is no longer tiled by a reservation's headroom
- Compare against the video-memory budget plus what this process can release: Windows halves the budget once the
  process passes ~12 of 16 GiB, and the bare figure tiled a 7.0 GiB decode that fits
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

Thanks — this was a genuinely useful review, and the two tests made the first finding much easier to act on. All three are green now, and everything else you raised is either fixed or answered below. One of my first attempts at finding 1 was wrong in a way worth recording, so I've left that in rather than quietly dropping it.

1. Reservation headroom as a tiling trigger — fixed the way you framed it

Implemented as you described it: the reservation keeps its headroom, and the tiling decision no longer carries it. qwen_image_untiled_decode_peak_bytes prices that decision from the measured curve in the comment above the constants (the per-point W7900 column), so 1536² is compared as 14.4 GiB rather than 24.2 GiB and stays untiled on a 24 GiB card, while 1792² still tiles there and not on 32 GiB. Both your tests run exactly as you wrote them.

I went the wrong way first and want to record why, since it nearly shipped: I re-measured on an RX 9060 XT (gfx1200, torch 2.12+rocm7.14, fp16) and got a flat 2650-2772 decode / 1541-1552 encode across 512²..2048², attributed that to the score-matrix chunking this branch adds, and made the constants conditional on the guard. That attribution does not survive your own table. At 512² the gap between the two cards is 1.24 GB while the entire score matrix there is ~0.3 GB, and at 1536² an unbounded matrix would be ~23 GB against a measured 15.4 GB total — so the W7900 run was never dominated by an unbounded score matrix in the first place. The spread is the card, not the guard, and shipping the lower pair would have under-reserved by 1.9x (decode) to 3.8x (encode) on the hardware the shipped figures were measured on, with Qwen Image Edit's encode having no tiled retry to fall back on. Reverted; the constants are unchanged.

The gfx1200 numbers stay in the comment, because that spread is the actual argument for your finding: a reservation that has to cover a 1.9x card difference is not a figure to decide non-pixel-identical output with.

One thing I did keep: scripts/calibrate_qwen_vae_working_memory.py never installed the ROCm attention guard, so it measured a path the app does not run. It does now.

2. The gate measured the nameplate total — fixed

The gate consults the budget now. My first version compared against the bare budget, and an end-to-end run on the card caught what that does mid-session: Windows holds the budget at 15.09 GiB until the process passes about 12 of 16 GiB and then halves it to 7.62 — below what we already hold. With models resident, the gate read 7.6 GiB, drew a 6.9 GiB line, and tiled a 7.0 GiB Z-Image decode that fits. Output stopped being pixel-identical (PSNR 42 dB) and the decode went from 0.89 s to 1.5 s: your finding 1, reintroduced at the other end.

So the ceiling is the budget plus what this process itself holds, capped at the card: the cache evicts models to honour the reservation, so that memory is available to the decode. Measured:

own allocations 0.14 2.15 6.15 10.15 12.15 GiB
budget reported 15.09 15.09 15.09 15.09 7.62 GiB

Your test passes unchanged (it patches mem_get_info to a process holding nothing, so the ceiling is the budget). A second test pins the mid-session case with these numbers, and the E2E is pixel-identical again.

The max_cache_vram_gb half of that finding I did not implement: that cap bounds model residency, not the working-memory reservation, so a 6 GiB cap on a 24 GiB card still leaves the decode the rest of the card. If you meant something else by it, say so and I'll look again.

3. Offload fix inert on multi-GPU — fixed

TorchDevice.empty_cache() now reports whether it ran. When a peer device defers it, _offload_unlocked_models credits the bytes it just freed to the measurement instead — they are in this process's allocator, which reuses them for the load being made room for. The regression test is parametrized over alone / peer-device-busy; the second case fails without the credit, exactly as you described.

4. "Bitwise identical" — corrected, thank you

You are right, and the CPU test was validating the claim on the one platform it isn't about. The comment and the docstring now say what actually holds: exact in exact arithmetic, bitwise where the kernel's arithmetic does not depend on the batch it runs over (CPU), and within a bf16 ulp on a card whose batched GEMM re-tiles with the group count — with your 0.0005 measurement named. The test keeps its exact assertion and says why it is exact there. The PR description is updated too.

5. paged_bytes could raise — fixed

The query-open moved inside the try, and video_memory_budget is wrapped as a whole, so the module keeps its "any failure yields None" contract at both entry points rather than relying on its callers. The adapter enumeration now closes what it has opened if anything escapes the loop.

6. auto_tiled_decode: false didn't restore previous behaviour — fixed

Anima's 0.7 rule is no longer gated on the setting. It predates it and is a speed optimization, not an OOM fallback; switching it off only bought the 7s path. The setting description and the docs now say so.

7. SD1/SDXL, SD3, CogView4 on the cuDNN constant — fixed

They take a device and use _flux_vae_scaling_constant, so MIOpen gets 3600/2750 like the FLUX.1 autoencoder they share a stack with; the six call sites pass vae_info.compute_device. Also in that area: the FLUX.1 and Z-Image encode nodes now place their tensors and generator on the VAE's device, which is what the decode nodes already did.

flux2_vae_decode.py having no tiling and no OOM retry is real and out of scope here; worth its own issue.

Smaller ones

Done: the rocm_causal_conv3d reference to the old guard name, the contradictory empty_cache docstrings, the adapter-handle leak, the unguarded video_memory_budget call on the generation path, the stale tiled local in the Qwen node, and the estimator docstring now states which call shapes the 1 GiB cap assumes (4-D, non-causal, dropout-free, K/V of the query's batch — what every caller prices).

Two ROCm detectors disagreeing was the interesting one: torch_cuda_allocator now also reads hip = ... out of the installed torch's version.py without importing torch, so a locally built wheel gets the allocator default as well as the budget cap. Verified against both venvs here (ROCm: true, CUDA: false).

Not changed: auto_detect_slice_size and diffusers_pipeline.py still use raw mem_get_info — pre-existing, and out of this PR's scope.

Verification after the fixes

  • Full suite -n 6: 8978 passed. One timing test (test_loop_scheduler_overhead_is_linear[iterate]) failed under six workers and passes alone; it is load-sensitive on this box and unrelated to the change. ruff check and format --check clean, pnpm -C docs build clean.
  • End-to-end on the RX 9060 XT from this branch's code: Z-Image nvfp4 1024px three times, pixel-identical to the pre-change baseline, decode 0.89 s; 1536px still pre-tiled at 177 s; Krea-2 fp8 1024px 59 s, run-to-run bit-identical; shared system memory 0.28 GiB peak.
  • Krea-2 output moved against my older reference by 10.5/255, which is upstream, not this branch: one image at the merge-base (c2e4cf6ee6) differs from the branch by 0.124 and from the old reference by 10.54. The Qwen3-VL encoder path changed on main in between. The same image takes 400.7 s at the merge-base and 59 s on the branch.
  • ROCm hardware slow tests on the RX 9060 XT: 5 passed, 1 skipped (test_a_fused_kernel_is_still_wrong_for_the_wide_head now skips where no fused backend runs the shape at all, as on gfx1200 — it asserted on wrong > 0 and had no kernel to find).
  • On the count of pre-existing test_flux2_working_memory.py failures we measure differently: this box reports 5 in the ROCm venv, identically on the base commit and on the branch (gfx1200, torch 2.12+rocm7.14). Yours reports 4. Either way they are pre-existing and backend-dependent; the PR description now says "4-5 depending on the ROCm build" rather than a single number.

# Conflicts:
#	tests/app/invocations/test_z_image_tiled_decode.py
main added a case reading _FLUX2_VAE_SCALING_CONSTANTS, which this branch merged into _FLUX_VAE_SCALING_CONSTANTS.
The new device argument made a device-less estimate resolve the session device, so the same call answered MIOpen on
a GPU box and cuDNN on CI. A named device still answers for that device.

Pin the convolution backend separately from the attention guard in the ROCm estimator tests, which only diverged on a
ROCm build.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants