feat(qwen-image): add a tiling option to the Qwen-Image VAE nodes - #9427
feat(qwen-image): add a tiling option to the Qwen-Image VAE nodes#9427Pfannkuchensack wants to merge 11 commits into
Conversation
The Qwen-Image i2l node hardcoded vae.disable_tiling(), so a full-frame encode was the only option. At 2560x1440 that peaks at 9.26 GiB — on top of a resident multi-GB transformer, which is what makes an upscale round-trip run out of headroom exactly at this node while every other node fits. Adds `tiled` / `tile_size` input fields following the SD/SDXL i2l node, OR'd with the global force_tiled_decode setting. Off by default, so behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter. Without it the change would be inert: the cache would keep reserving the full-frame figure (10.99 GiB at 2560x1440) and evict models to honour it, no matter what the VAE actually does. Tiled, it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Measured through the node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending; real images blend far better), which is why this stays opt-in.
Both nodes reserve working memory for a full-frame operation, which at high resolutions exceeds a 24 GB card, so the model cache evicts everything else to honour it. On CUDA at 2560x1440: 19.91 GiB for the decode and 10.99 GiB for the encode. Tiling is the intended escape hatch, but it did not work on either node: - qwen_image_i2l hardcoded vae.disable_tiling(), so it could not be enabled. - qwen_image_l2i honoured the global force_tiled_decode, but computed its working-memory estimate before and independently of that flag. Tiling bounded the VAE while the cache still reserved the full-frame figure, so the memory was never freed for anything else — effectively inert. Adds `tiled` / `tile_size` input fields to both nodes following the SD/SDXL i2l/l2i nodes, OR'd with force_tiled_decode. Off by default; behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter, and both nodes resolve tile_size=0 to the VAE default (256px) before estimating. Tiled it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Without this the change would be cosmetic on i2l and remain inert on l2i. Measured through the i2l node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Verified across eight resolutions that tiled and untiled encodes produce the same latent dimensions. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending), which is why this stays opt-in. Also fixes a crash in qwen_image_i2l: `width`/`height` are `int | None`, but the workflow UI sends 0 for an unset number input, and `0 is not None` reached `image.resize((0, 0))` -> "height and width must be > 0". Non-positive values are now treated as unset, matching how tile_size uses 0.
lstein
left a comment
There was a problem hiding this comment.
Thanks for this — the diagnosis is right and well argued. The observation that l2i's tiling was inert because the estimate was computed independently of the flag is a genuine find, and threading tile_size into estimate_vae_working_memory_qwen_image is the correct fix for it. The untiled path is byte-identical (the estimator's else branch is untouched and both nodes pass tile_size=None when tiled is false), schema/openapi are regenerated correctly including both version bumps, and CI is fully green.
The problem is in how tiling is turned on. Both nodes call
vae.enable_tiling(tile_sample_min_height=tile_size, tile_sample_min_width=tile_size)and never touch tile_sample_stride_height / tile_sample_stride_width. That leads to two independent ways to get silently corrupted output.
Blocker 1 — tile_size below 192 silently truncates the image / latent
AutoencoderKLQwenImage.__init__ sets tile_sample_min_* = 256 and tile_sample_stride_* = 192. tiled_encode / tiled_decode step the tile loops by stride but slice each accumulated tile to min:
for i in range(0, height, tile_latent_stride_height): # advances by stride
...
result_row.append(tile[:, :, :, : self.tile_sample_stride_height, : self.tile_sample_stride_width])So whenever min < stride, whole bands of the image are never processed and the output is smaller than requested. tile_size is declared multiple_of=8 with no lower bound, so every value from 8 to 184 hits this.
Measured against a real AutoencoderKLQwenImage (tiny random weights, CPU, diffusers 0.39.0):
encode 512x512 image tile_size=192 -> (1,16,1,64,64) ok
tile_size=128 -> (1,16,1,48,48) *** WRONG ***
tile_size=64 -> (1,16,1,24,24) *** WRONG ***
decode 64x64 latent tile_size=192 -> (1,3,1,512,512) ok
tile_size=128 -> (1,3,1,384,384) *** WRONG ***
tile_size=64 -> (1,3,1,192,192) *** WRONG ***
No exception is raised. On i2l the undersized latent flows onward as a perfectly valid-looking tensor.
The same omission has a second, opposite-direction effect above 256: the number of tiles is fixed by the stride, so raising tile_size grows every tile without eliminating any. At 2560x1440 the loops emit ceil(1440/192) x ceil(2560/192) = 8 x 14 = 112 tiles regardless of tile_size:
| tile_size | tiles | pixels processed | vs full-frame |
|---|---|---|---|
| 256 | 112 | 7.3 Mpx | 2.0x |
| 384 | 112 | 16.5 Mpx | 4.5x |
| 512 | 112 | 29.4 Mpx | 8.0x |
| 768 | 112 | 66.1 Mpx | 17.9x |
FieldDescriptions.vae_tile_size says "larger tile sizes generally produce better results at the cost of higher memory usage", and QA step 5 recommends going larger — but on this VAE the real cost of a larger tile is quadratic compute, not just memory. With a proportional stride, tile_size=512 would be ~1.8x, not 8x.
Suggested fix: pass all four parameters, keeping the stock 256/192 ratio, e.g.
vae.enable_tiling(
tile_sample_min_height=tile_size,
tile_sample_min_width=tile_size,
tile_sample_stride_height=tile_size * 3 // 4,
tile_sample_stride_width=tile_size * 3 // 4,
)This is exactly what anima_latents_to_image.py:144-149 already does. A ge= floor on the field would be belt-and-braces.
Blocker 2 — enable_tiling permanently mutates the cached VAE
self.tile_sample_min_height = tile_sample_min_height or self.tile_sample_min_heightenable_tiling writes through to the module, and disable_tiling() only clears use_tiling — it does not restore the sizes. That module is the model cache's own instance: LoadedModel.model and model_on_device both return self._cache_record.cached_model.model (load_base.py:103,110), and as_qwen_image_vae deliberately returns the same object. So a tile_size set once sticks for the lifetime of the cache entry.
Replaying the node's exact call sequence twice against one instance:
run A tile_size=512 -> model min now 512
run B tile_size=0 -> estimate=512, tiles=512 (documented as "the VAE default, 256")
run A tile_size=128 -> (1,3,1,384,384)
run B tile_size=0 -> (1,3,1,384,384) *** still corrupt; the user set nothing ***
then i2l tiled=True tile_size=0 -> latent (1,16,1,48,48) *** the leak crosses nodes ***
It also crosses model families. anima_latents_to_image sets min=512, stride=384 (ANIMA_VAE_TILE_SIZE / ANIMA_VAE_TILE_STRIDE) on the same Wan-classified VAE instance these nodes reinterpret — a native-layout qwen_image_vae single file is classified with the Anima base, as the node comments note, so one VAE loader can feed both nodes in a single workflow. Afterwards the documented-safe value corrupts, because the leaked stride is now larger than it:
after Anima l2i: min=512 stride=384
qwen l2i with tile_size=256 -> (1,3,1,384,384) expected (1,3,1,512,512)
The SD l2i this PR follows avoids precisely this with patch_vae_tiling_params — a context manager that restores the original values in a finally. Note qwen_image_latents_to_image.py:92 still carries the now-vestigial tiling_context = nullcontext(); that is the hook the SD node uses for it.
Fixing blocker 1 by always passing all four parameters also closes most of this, since nothing is then inherited. The residual piece is getattr(vae_info.model, "tile_sample_min_height", 256) in the estimate, which reads whatever the previous run leaked rather than a known default.
Repro script
Both blockers above, self-contained (no weights needed):
import torch
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
vae = AutoencoderKLQwenImage(
base_dim=4, z_dim=16, dim_mult=[1, 1, 1, 1], num_res_blocks=1,
attn_scales=[], temperal_downsample=[False, True, True],
).eval()
z = torch.randn(1, 16, 1, 64, 64) # -> 512x512
# Blocker 1
vae.enable_tiling(tile_sample_min_height=128, tile_sample_min_width=128)
with torch.inference_mode():
print(tuple(vae.decode(z, return_dict=False)[0].shape)) # (1,3,1,384,384)
# Blocker 2 -- the node's tile_size=0 path, run afterwards
vae.enable_tiling() # no args == "model default"
with torch.inference_mode():
print(tuple(vae.decode(z, return_dict=False)[0].shape)) # (1,3,1,384,384), stillNon-blockers
The tiled estimate under-counts the accumulated tile buffers. working_memory += 3 * h * w * element_size budgets one RGB frame, but tiled_decode holds every decoded tile in rows (about (min/stride)^2 ~ 1.78x a full frame at the defaults) simultaneously with result_rows (~1x) and the final torch.cat plus its slice (~2x). At 2560x1440 fp16 that is roughly 116 MB actual against 22 MB budgeted — comfortably absorbed by the 25% tile slack within the 497 MB total. But the shortfall grows linearly with output area while the tile term stays constant, so it degrades in exactly the regime tiling exists for. estimate_vae_working_memory_wan, which the new docstring says this mirrors, uses clip_copies = 2 on decode; this one effectively uses 1.
force_tiled_decode now changes encode results. ORing it into i2l matches image_to_latents.py:158, so the precedent is fine — but users who already have it set in invokeai.yaml get a ~1.4%-different latent with no node or workflow change on their side. "Off by default, so behaviour is unchanged unless enabled" is not quite true for them; worth a line in the PR body / What's New rather than a code change.
The width/height zero-handling fix is an unrelated drive-by, untested and unmentioned in the summary. In self.width and self.height and self.width > 0 and self.height > 0 the first two clauses are redundant with the last two. Also note width=1024, height=0 now silently encodes at the original size rather than erroring — reasonable, but a behaviour change worth stating.
Test coverage. The new test patches the estimator, so nothing exercises the estimator's tiled arithmetic, the arguments actually passed to enable_tiling (where both blockers live), or the l2i node at all — and l2i is where the inert-tiling bug being fixed actually was. The try / except Exception: pass would also let the test pass if vae_encode blew up immediately after the estimate call.
Attacks that came back clean
- Untiled path is byte-identical to
main; the estimator's non-tiled branch is untouched and both nodes passtile_size=Nonewhentiledis false. - Negative
tile_sizevalues fall through to the> 0guard and are treated as "default" — no crash. - Images at or below the tile size over-reserve slightly rather than under-reserve (diffusers skips tiling entirely there).
QwenImageImageToLatentsInvocation.vae_encodehas no other callers, so the new keyword-only defaults break nothing.element_sizederivation and the ROCm/CUDA constant selection are unchanged.schema.tsandopenapi.jsonare correctly regenerated, including both1.0.0->1.1.0bumps and themultipleOf: 8constraint;typegen-checksandopenapi-checksboth pass.ruff checkclean; all 8 tests intest_qwen_image_working_memory.pypass.
enable_tiling() was called with tile_sample_min_* only, leaving the stride at the module's 192px default. The tile loops step by stride but slice each accumulated tile to min, so any tile_size below 192 silently dropped whole bands of the image -- a 128px tile turned a 512x512 decode into 384x384 with no error -- while sizes above 256 grew every tile without removing any, making compute scale with tile_size^2 (8x a full frame at 512px). Pass all four parameters with the stock 4:3 ratio, rounding the stride down to a multiple of the 8x spatial compression so the pixel and latent steps agree. Tile sizes below 64px are clamped; the field carries the 0 "use default" sentinel and so cannot take a pydantic lower bound. enable_tiling() also writes straight onto the module, and disable_tiling() only clears use_tiling. That module is the model cache's own instance, so a tile size set once persisted for the lifetime of the cache entry and leaked across invocations and into anima_latents_to_image, which shares the instance. Apply the geometry through a context manager that restores it, and resolve the 0 sentinel against a constant instead of the module's current value. Also budget the pixel-space buffers tiled_decode holds simultaneously (~5 frames, not 1) -- the term that grows with output area, so it degraded in exactly the regime tiling exists for.
Summary
Both Qwen-Image VAE nodes reserve working memory for a full-frame operation, which at high resolutions is more than a 24 GB card has — so the cache evicts everything else to honour it. On CUDA at 2560x1440:
qwen_image_l2i(decode)qwen_image_i2l(encode)Tiling is the intended escape hatch, but today it does not work on either node:
vae.disable_tiling()— there was no way to enable it at all.force_tiled_decode, but computes its working-memory estimate before and independently of that flag. So tiling bounds the VAE while the cache still reserves the full-frame figure — the memory is never actually freed for anything else. Effectively inert.This PR adds
tiled/tile_sizeinput fields to both nodes, following the SD/SDXL i2l/l2i nodes, OR'd with the globalforce_tiled_decode. Off by default, so behaviour is unchanged unless enabled.estimate_vae_working_memory_qwen_imagegains a matchingtile_sizeparameter, and both nodes resolvetile_size=0to the default (256px) before estimating. Tiled, it budgets one tile plus 25% overlap plus the pixel-space buffers, mirroringestimate_vae_working_memory_wan. Without this the change would be cosmetic on i2l and remain inert on l2i.Measured end-to-end through the i2l node at 2560x1440: 10.99 → 0.26 GiB reserved, 9.26 → 0.17 GiB actual peak, identical latent shape. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending; real images blend far better), which is why this stays opt-in.
Applying the tile geometry correctly
AutoencoderKLQwenImagecarries a tile size and a tile stride (stock: 256px / 192px).tiled_encode/tiled_decodestep the tile loops by stride but slice each accumulated tile to min, andenable_tilinginherits the module's current value for any argument left out. Passing onlytile_sample_min_*therefore breaks in both directions:tile_sizegrows every tile without eliminating any: at 2560x1440 the loops emit 112 tiles regardless, making compute scale withtile_size²(8.0x a full frame at 512px, 17.9x at 768px).So both nodes pass all four parameters, keeping the stock 3/4 stride ratio, as
anima_latents_to_imagealready does. The stride is additionally rounded down to a multiple of the VAE's 8x spatial compression: the loops step in one space and slice in the other, so the pixel stride must be exactly 8x the latent stride or the two disagree. With a proportional stride the processed area stays flat at ~2.0x a full frame at any tile size.tile_sizevalues between 1 and 64 are clamped to 64. The field has to accept the 0 "use the default" sentinel and so cannot carry a pydantic lower bound; below 64 the derived latent stride collapses toward zero.Scoping the tiling state
enable_tilingwrites the geometry straight onto the module anddisable_tilingonly clearsuse_tiling— it does not restore the sizes. That module is the model cache's own instance (LoadedModel.modelandmodel_on_deviceboth return it, andas_qwen_image_vaedeliberately returns the same object to keep partial-loading hooks intact), so atile_sizeset once would stick for the lifetime of the cache entry and leak into later invocations.It also crosses model families: a native-layout
qwen_image_vaesingle file is classified with the Anima base, soanima_latents_to_image— which sets 512/384 and never restores — can feed the same instance in a single workflow.Both nodes now apply the geometry through a context manager that restores all five attributes in a
finally, and resolve thetile_size=0sentinel against a constant rather than reading the module's currenttile_sample_min_height. Together that makes the nodes both non-leaking and immune to geometry left behind by anything else.Related Issues / Discussions
None filed — found while debugging VRAM exhaustion in a latents → image → upscale → image → latents workflow at 2560x1440 on a 24 GB card, where a ~12 GB transformer stays resident across the VAE round-trip.
QA Instructions
Reproducing the limit (no code needed): run a Qwen-Image / Krea-2 img2img round-trip at ~2560x1440 while a large transformer is resident. The VAE nodes request ~20 GB (decode) and ~11 GB (encode) of working memory, forcing the cache to evict the transformer; on a 24 GB card this surfaces as
Loading 0.0 MB into VRAM, but only -N MB were requestedand models loading at <100%.With this PR:
tiledon the Latents to Image and/or Image to Latents - Qwen Image nodes (or setforce_tiled_decode: trueininvokeai.yamlfor both).tiled: falserun. Images should be visually equivalent — tile seams are the failure mode to look for; none were observed on photographic content.tiledoff and confirm behaviour is identical tomain. This is the default path and the most important check.tile_size: 0uses the default (256px). Larger values mean fewer, larger tiles: reserved memory scales withtile_size²(256 → ~0.55 GiB decode, 512 → ~1.9 GiB, at any resolution) while the processed area stays ~2.0x a full frame either way. Values 1–64 are clamped to 64.Automated:
pytest tests/app/invocations/test_qwen_image_working_memory.py— covers the resolved tile size reaching the estimator, the arguments actually passed toenable_tilingon both nodes, the estimator's tiled arithmetic, and a class of tests against a real (tiny, randomly initialised)AutoencoderKLQwenImageasserting shape preservation across tile sizes, state restoration, and immunity to geometry left by another node.Not measured: the decode's runtime peak was not benchmarked separately — only its reservation, and the encode end-to-end. The decode constants themselves are unchanged from the existing calibration.
Behaviour changes worth calling out
force_tiled_decodenow affects encode. ORing the global flag into i2l matchesimage_to_latents.py, so the precedent is established — but users who already haveforce_tiled_decode: trueininvokeai.yamlwill get a ~1.4%-different latent with no node or workflow change on their side. "Off by default" holds for everyone else.width/heightzero-handling on i2l. The workflow UI cannot representNonein a number input and sends 0 for "unset", which reachedresize((0, 0))and raised "height and width must be > 0". Non-positive values are now treated as unset — which also means a half-filled pair (e.g.width=1024, height=0) encodes at the original size rather than erroring.Merge Plan
Nothing special. Both node versions bumped
1.0.0→1.1.0; all new fields have defaults, so saved workflows load unchanged and keep current (untiled) behaviour. The new fields change the generated OpenAPI schema, soschema.tsneeds regenerating as usual.Checklist
What's Newcopy (if doing a release after this PR) — worth a line forforce_tiled_decodeusers, see above