Skip to content

VAE tiling: decode 2x2 tiles again at 456-504 px while keeping the seam fix - #26

Merged
danielhanchen merged 9 commits into
masterfrom
fix/vae-tile-count
Oct 12, 2026
Merged

danielhanchen merged 9 commits into
masterfrom
fix/vae-tile-count

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Problem

#21 added sd_tiling_seam_safe_tile_size so a VAE axis is never split into two tiles that overlap by only 1 or 2 latents. When the requested tile fails that check it takes the nearest smaller size that passes. For an axis a little under twice the tile, every smaller size that passes needs a third tile, so the fix traded the seam for 50% more tiles per axis:

axis (latents) example tile asked upstream f89d9b1 master afac7a7
62 Z-Image / FLUX 496 px (8x VAE) 32 2 tiles of 32, overlap 2 3 tiles of 31
57 to 63 456 to 504 px, 8x VAE 32 2 tiles, overlap 1 to 7 3 tiles
30 Qwen-Image-2.1 480 px (16x VAE) 16 2 tiles of 16, overlap 2 3 tiles of 15
113 to 127 VAE encode at 904 to 1016 px (64-latent tiles) 64 2 tiles 3 tiles

At 496x496 that is 9 tiles instead of 4 on every --vae-tiling decode. Reported on an RTX 3090: Z-Image 496 tiled decode 0.38 s on upstream, 0.53 s on master; on an M1 (Metal) 54.3 s to 62.1 s end to end.

Change

sd_tiling_seam_safe_tile_size (src/runtime/tiling.cpp), one function:

The tile planner and the stitching are unchanged. The out-of-memory retry still halves the request, but the seam-safe search could widen a halved request back to the size that failed (at the 4-latent minimum it can even widen 4 to 8), so after an allocation failure the search is now bounded to sizes below the failed tile. Every retry therefore shrinks the tile or stops at the minimum; first attempts carry no bound and pick the same size as without the retry change (checked on 192k size / request / overlap combinations).

Results

Base is master afac7a7c, head is this branch, built the same way; upstream f89d9b1 as a third arm where it matters. Same prompt, seed 42, --rng cpu.

Decode time, --vae-tiling, decode_first_stage seconds, NVIDIA L4 (Colab, one job per VM), 4 interleaved rounds (master, fix, fix, master), 8 runs per arm:

case master this branch untiled
Z-Image-Turbo Q4_K_M 496x496 3x3 tiles, 0.81 (0.80 to 0.82) 2x2 tiles, 0.58 (0.57 to 0.59) 0.56
Qwen-Image-2.1-Turbo Q4_K_M 480x480 3x3 tiles, 1.80 (1.79 to 1.81) 2x2 tiles, 1.39 (1.37 to 1.40) 1.00

The spread within an arm is 0.02 to 0.03 s, the differences are 0.23 s (28%) and 0.41 s (23%). An earlier L4 session of the same Z-Image case gave 0.79 / 0.57.

Seams: each arm's tiled decode against the same arm's untiled decode of the same latent. FLUX.1-schnell stands in for Z-Image (same 16-channel 8x VAE, ae.safetensors) because Z-Image does not render reproducibly (see Not covered).

tiled vs untiled, PSNR dB / LPIPS upstream master this branch
FLUX 496x496, RTX PRO 6000 38.11 / 0.0055 (2x2, overlap 2) 39.00 / 0.0047 (3x3) 39.18 / 0.0042 (2x2, overlap 8)
FLUX 496x496, B200 38.02 / 0.0052 39.00 / 0.0045 39.06 / 0.0041
FLUX 496x496, B200, worst column mean abs error 4.90 at x=248, the tile edge 2.88 2.77
FLUX 496x496 --circulary, B200 36.94 / 0.0060 37.83 / 0.0055 38.48 / 0.0041
FLUX 1008x496, B200 35.89 / 0.0097 (6x3) 36.32 / 0.0091 (6x2)
Qwen-Image-2.1-Turbo 480x480, B200 33.28 / 0.0266, worst column 17.0 at x=239 43.59 / 0.0027 46.12 / 0.0014
Qwen-Image-2.1 480x480, B200 49.02 / 0.0011 51.40 / 0.0005
Qwen-Image-2.1-Turbo 960x480, B200 42.61 / 0.0031 (6x3) 44.10 / 0.0022 (6x2)

So this keeps #21's seam fix (no error peak on the tile edge, and closer to the untiled decode than master's 3x3 in every case) while decoding 4 tiles again. The gain over master comes from the extra overlap. The Qwen-Image-2.1 numbers repeated exactly in two separate B200 sessions.

Everywhere the requested tile already overlapped enough, this branch gives byte-identical output to master on a B200 (sm100) and an RTX PRO 6000 Blackwell (sm120): untiled renders of SD1.5, SDXL, Qwen-Image-2.1, Qwen-Image-2.1-Turbo, FLUX.1-schnell, FLUX.2-klein-4B and MiniMax-H3 (24 frames, with and without --circularx); --vae-tiling at 512, 992, 1008, 1184x1184, 1184x864 and 1344x768; --circularx at 512; FLUX img2img with a tiled encode at 1008; the out-of-memory retry at 1008x1008, 1184x864 and 1344x768 with a ballast leaving 14 and 11 GiB free (all three arms retry with 32-latent tiles and give identical images); SD1.5 and SDXL at 496 and 1008 (aligned to 64 px, so never in the changed range).

Retry bound (b1c2503) against the previous head on an RTX PRO 6000 Blackwell (Colab), FLUX.1-schnell Q4_K_M, 4 steps, seed 42, with a torch ballast so the decode really runs out of memory: 43 of 43 paired images are byte-identical, untiled and --vae-tiling at 1008, 1184x864, 1344x768, 992 and 496 with no ballast, and every real retry (1008 to 256x256 tiles, 496 to 248 then 120, 336 to 168 then 80) logs the reduction and finishes. The minimum-request case the bound exists for (a 4-latent request widened to 8) needs a third or fourth retry, which this model never reached before sampling itself ran out of memory, so that path is covered by the standalone check only.

Tests

  • Standalone check of sd_tiling_seam_safe_tile_size linked against the built library: 14 named cases (62, 63, 57 latents at 32-latent tiles; 30 and 31 at 16; the default 512 to 1344 px axes) require the minimum tile count, growth within 25% and the overlap rule; every axis from tile+1 to 400 latents for tiles 4 to 64 at overlaps 0.25 and 0.5 requires every replacement to overlap by the full target up to 8 latents. Master fails 14 checks, this branch passes all.
  • Builds: CUDA 13.1 sm_100 and CPU sd-cli (GGML_NATIVE=OFF) locally, CUDA 13.0 sm_89 (L4) and sm_120 (RTX PRO 6000) on Colab; no warnings in tiling.cpp.
  • CPU (x86): SD1.5 txt2img and img2img with --vae-tiling --vae-tile-size 128 give byte-identical images on upstream, master and this branch.

Not covered

  • RTX 3090 and Apple M1 were not rerun (no such machines here); the L4 shows the same 3x3 -> 2x2 change. GitHub's macOS runners cannot run the Metal backend (the current and the previous prebuilt both fail there with buffer is nil), so Metal was not exercised. Vulkan and ROCm were not run; the change is host-side integer tile planning and backend independent.
  • Z-Image-Turbo renders differ from run to run on both GPUs, on upstream f89d9b1 too (two upstream runs of the same command were 16.9 to 25.8 dB apart on the B200), so its tiled-vs-untiled numbers are not comparable and FLUX stands in for its VAE. On the B200, FLUX occasionally differs run to run as well (one of two upstream runs at 496 untiled, 37.5 dB apart); on the RTX PRO 6000 it was byte-identical in every run, and both GPUs give the same ranking above.
  • The replacement tiles are up to 25% larger per axis (up to about 56% more pixels per tile) at the affected sizes, so they need more VRAM per tile there. An explicit --vae-tile-size chosen to just fit could fail its first attempt; the retry then picks a smaller seam-safe tile and finishes.
  • Sizes whose requested tile passes Keep VAE tiles overlapping on the out-of-memory retry (no more seam lines), and retry the encode too #21's rule with only half the target overlap are left alone, as on upstream: for example Qwen-Image-2.1 at 448 px (28 latents) still decodes two 16-latent tiles overlapping by 4, which per the numbers above can leave a faint line. Tightening that would change default output at many 16x sizes and is not done here.
  • Wan and LTX video VAEs were not rendered. Their spatial tiles go through the same function, so they only change at the affected axis lengths.

…ler one

When the requested VAE tile leaves two tiles barely overlapping, #21 took the
nearest smaller size that overlaps by half the target, which needs a third
tile per axis: 496 px on an 8x VAE decoded 3x3 tiles instead of 2x2.

Pick the size that decodes the fewest latents instead, overlapping by the full
target up to 8 latents (half the target left a line on Qwen-Image-2.1's 16x
VAE), within 25% of the requested size. 496 px gets two 35-latent tiles,
Qwen-Image-2.1 480 px two 19-latent tiles. Sizes whose requested tile already
overlaps enough are unchanged.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T01:50:15.413552Z 4db928c Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48488df079

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/runtime/tiling.cpp Outdated
Comment on lines +101 to +105
const int tiles = static_cast<int>(sd_tiling_plan_axis(small_dim, size, tile_overlap_factor, false).size());
const int64_t cost = static_cast<int64_t>(tiles) * size;
const bool better = best_size == 0 || cost < best_cost ||
(cost == best_cost && (tiles < best_tiles ||
(tiles == best_tiles && std::abs(size - tile_size) < std::abs(best_size - tile_size))));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent low-overlap searches from multiplying tile calls

When the requested tile fails the initial overlap check, this ranks every size down to 4 solely by tile_count * size, allowing many tiny tiles to win. For an 8x VAE axis of 2960 pixels (small_dim=370) with --vae-tile-size 512 --vae-tile-overlap 0.1, the parent selects size 62 and 7 tiles, whereas this selects size 25 and 16 tiles because 400 is less than 434; on a square input, process_tiles_2d consequently performs 256 separate VAE evaluations instead of 49. Constrain the candidate size/tile count or account for per-tile invocation overhead so this performance optimization does not cause severe regressions for supported overlap settings.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d71e56: candidates may not need more tiles than the nearest passing size (#21's choice), so your case now gets 6 tiles of 67 instead of 16 of 25 (master: 7 of 62). A sweep over every axis up to 400 latents, tiles 4 to 64, overlaps 0.1/0.25/0.5 (66978 cases) finds no case using more tiles than master.

Comment thread src/runtime/tiling.cpp Outdated
Comment on lines +114 to +119
const int capped = std::min(small_dim - 1, tile_size + tile_size / 4);
const int widest = std::min(small_dim - 1, 2 * tile_size);
for (bool replacement : {true, false}) {
int size = pick(min_tile_size, capped, replacement);
if (size == 0) {
size = pick(capped + 1, widest, replacement);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the documented tile-selection policy

docs/performance.md lines 51-55 still state that an unsafe tile is adjusted to the nearest qualifying size, smaller first and then larger up to twice the request. This code instead searches all sizes, prioritizes stricter-overlap candidates by decoded extent, and may grow within 25% or shrink far past the nearest size, so users relying on the documented memory and performance behavior receive incorrect guidance. Update the VAE tiling documentation alongside this public behavior change.

AGENTS.md reference: AGENTS.md:L74-L76

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d71e56: docs/performance.md now describes the cheapest-qualifying choice, the 25% growth preference and the tile-count bound.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 11, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 0d71e5692a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…possible tie-break

A halved retry request can be widened back to the failed tile size by the seam-safe size search,
which made the retry loop give up. Lower the request of an axis that did not shrink one latent at a
time instead. First attempts are unchanged.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 312a8b3eb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pipeline/diffusion_engine.cpp Outdated
Comment on lines +2925 to +2928
const bool lower_w = tile_size_w >= last_w && tiling_params.tile_size_w > 4 * scale_factor;
const bool lower_h = tile_size_h >= last_h && tiling_params.tile_size_h > 4 * scale_factor;
if (!lower_w && !lower_h) {
return tile_size_w < last_w || tile_size_h < last_h;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow OOM retries below an expanded minimum tile

When the requested size is already the four-latent minimum, the new selector can still enlarge it beyond the nearest seam-safe size, but this condition prevents the retry from backing off. For example, on a 42-latent axis with 25% overlap and a four-latent request, the new policy selects 8 while the nearest passing size is 7; if an 8x8 tile allocation fails but 7x7 fits, prepare_vae_retry_tiling resets the request to four, this helper recomputes eight, and both lower_* flags remain false, so encode/decode aborts without trying the viable smaller candidate. Let an allocation retry bypass the stricter replacement choice or otherwise fall back to the nearest seam-safe size at the minimum request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b1c2503. After an allocation failure the VAE now passes the failed tile size minus one as an upper bound to the seam-safe search, so the retry picks the best seam-safe size below the failed one instead of re-widening the 4-latent request. Your example (42 latents, 25% overlap) now goes 8 -> 7. An exhaustive check over 391k bounded cases confirms the bound is always respected and every retry ladder from the minimum request shrinks to 4; without a bound the search returns the same size as before on 192k cases, so first attempts are unchanged.

At the 4-latent minimum request the seam-safe search can still widen the tile (42 latents at 25%
overlap: 4 -> 8), and lowering the request cannot go further, so a failed 8-latent tile aborted
even when 7 would fit. After an allocation failure the VAE now passes the failed size minus one as
an upper bound to the search, which picks the best seam-safe size under it. First attempts carry
no bound and are unchanged.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1c2503c47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pipeline/diffusion_engine.cpp Outdated
return last_w <= 0 || !tiling_params.enabled || tile_size_w < last_w || tile_size_h < last_h;
}

// Clears the retry bound when a VAE pass ends.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the comment that only restates the guard

This comment only paraphrases the destructor assignments immediately below it and adds no invariant or non-obvious rationale. Remove it to comply with the repository's explicit prohibition on comments that merely describe what the code does.

AGENTS.md reference: AGENTS.md:L128-L135

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in cde3933.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

2 similar comments
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: cde3933acb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79f183cb3d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/runtime/tiling.cpp Outdated
int sd_tiling_seam_safe_tile_size(int small_dim, int tile_size, float tile_overlap_factor, int max_tile_size) {
// Overlaps of 1-2 latents (32-latent tiles on a 62 axis) show as a seam; require >= half the target and >= 2.
const int min_tile_size = 4;
const int limit = max_tile_size >= min_tile_size ? max_tile_size : INT_MAX;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the minimum retry bound to avoid an OOM loop

Fresh evidence in the final tree after the earlier retry fix: when one non-circular axis has already failed at the 4-latent minimum, sd_vae_tile_sizes passes max_tile_size == 3, but this line treats that as no limit. The seam-safe chooser can therefore widen that axis back to 8 while the other shrinks, and the OR-based progress check accepts it. With a 42×42 latent, 25% overlap, a requested 4×64 tile, and persistent allocation failures, the actual retries cycle 4×7 → 8×4 → 7×8 → 4×7, so encode/decode never returns. Preserve a cap of 4, or reject retries that increase either axis, once the bound drops below the minimum.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 4db928c77b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit e896196 into master Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant