Skip to content

[LTX-2.5] Refactor LTX-2.5 Diffusion Decoder Forward Methods - #14694

Open
dg845 wants to merge 6 commits into
mainfrom
ltx-25-diff-decoder-refactor-forward
Open

[LTX-2.5] Refactor LTX-2.5 Diffusion Decoder Forward Methods#14694
dg845 wants to merge 6 commits into
mainfrom
ltx-25-diff-decoder-refactor-forward

Conversation

@dg845

@dg845 dg845 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

This PR refactors the LTX2VideoDiffusionDecoder3d forward method as follows:

  • forward represents a single Stage 5 diffusion denoising step, following other DiTs in /models/transformers
  • forward_stages_1_to_3 and forward_stage_4 have been renamed to encode_context_stages_1_to_3 and encode_context_stage_4, respectively, since they calculate the conditioning for the Stage 5 denoising loop using the input latents from the main LTX-2.5 DiT.
  • The denoising loop is now performed by _denoise in the ModelMixin subclass LTX2VideoDiffusionDecoderModel rather than LTX2VideoDiffusionDecoder3d.

The motivation for the refactor is to have a single forward method while retaining the VAE tiling interface for LTX2VideoDiffusionDecoderModel. See #14447 (comment) for more info.

Before submitting

  • Did you use an AI agent (Claude Code, Codex, Cursor, etc.) to help with this PR? If so:
    • Did you read the Coding with AI agents guide?
    • Did you run the self-review skill on the diff?
    • Did you share the final self-review notes in the PR description or a comment?
  • Did you read the contributor guideline?
  • Did you read our philosophy doc? (important for complex PRs)
  • Was this discussed/approved via a GitHub issue or the forum? Please add a link to it if that's the case.
  • Did you make sure to update the documentation with your changes? Here are the
    documentation guidelines, and
    here are tips on formatting docstrings.
  • Did you write any new necessary tests?
  • Are you the author (or part of the team) of the model/pipeline (only applicable for model/pipeline related PRs)?

Who can review?

Anyone in the community is free to review the PR once the tests have passed. Feel free to tag
members/contributors who may be interested in your PR.

@yiyixuxu
@sayakpaul

dg845 and others added 2 commits September 2, 2026 22:32
`LTX2VideoDiffusionDecoder3d` now exposes exactly the three stages that tiled
decoding needs, and its `forward` is one stage-5 denoising step:

  encode_context_stages_1_to_3   (was forward_stages_1_to_3)
  encode_context_stage_4         (was forward_stage_4)
  forward(hidden_states, latent_context, timestep)  (was forward_diffusion_step)

The timestep schedule, the noise and the reverse Euler updates move up to
`LTX2VideoDiffusionDecoderModel._denoise`, so the inner module is the network
alone and its `forward` reads like any other diffusion transformer: noised
input, conditioning, timestep. Stages 1-4 are the conditioning encoder for it.

Two duplicated paths collapse as a result:

- `decode` and the old untiled `LTX2VideoDiffusionDecoder3d.forward` each
  derived the pixel shape and drew the noise. Both now go through `_decode`,
  where an untiled decode is the single-tile schedule. `tiled_decode` still
  tiles unconditionally; `use_tiling` gates only `decode`'s routing.
- The `num_inference_steps == 1 and x0` special case generalizes to returning
  the x0 prediction at the final step for any step count, matching the
  reference decoder. The Euler update to t=0 reduces to that prediction, so it
  is the same value without a full-canvas float32 round trip.

Drops `self.spatial_compression_ratio` / `self.temporal_compression_ratio` on
the model, whose last reader this change removed. Both remain reachable through
`config`, and existing attribute access still resolves via
`ConfigMixin.__getattr__`. Unlike `AutoencoderKLLTX2Video`, these were pure
mirrors of required config ints rather than derived values.

Verified against a pre-refactor numeric baseline: bit-exact for single-step x0
and for the velocity path, ~2e-07 for multi-step x0 (the intended
identity-Euler skip). Checked end to end on the LTX-2.5 checkpoint at
768x512x121 on both the tiled and untiled paths.

Adds a test that `tiled_decode` tiles regardless of `use_tiling`. Output
comparison cannot catch that regression: a fallback to one full-grid tile
reproduces the untiled decode exactly and passes every other tiling test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added models tests size/L PR with diff > 200 LOC labels Sep 3, 2026
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@dg845

dg845 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author
Self-Review Report

Self-review

Ran the project's self-review rubric (.ai/references/review-rules.md plus code_style.md, models.md, testing.md,
pitfalls.md) over this diff.

Scope: src/diffusers/models/autoencoders/ltx2_diffusion_decoder.py (+99 −78),
tests/models/autoencoders/test_models_ltx2_diffusion_decoder.py (+51).

Verdict: READY — no blocking issues. Three items below are deliberate choices I'd rather surface than leave silent, and one is
a design question for the reviewer.


Blocking issues

None.

Non-blocking / for discussion

1. _denoise is a single-caller private helper.
ltx2_diffusion_decoder.py:829, called only from :986. code_style.md says "If a private helper has only one caller, inlining
it at the call site is usually the cleaner choice."
I kept it separate because inlining puts a 20-line Euler loop inside an
already three-deep tile loop. Flagging because the rule points the other way — happy to inline if you prefer.

2. _decode(z, tiled=...) has the shape of a config flag.
ltx2_diffusion_decoder.py:878. It isn't one: two real callers pass different values (tiled_decodeTrue, decode
self.use_tiling). Noting it because the shape is what code_style.md's "no configuration options" rule targets.

3. The module's full computation no longer lives in any single forward. (Design question.)
models.md says "All layer calls should be visible directly in forward." The new forward satisfies that for stage 5 — an
improvement on the old one, which hid two whole stages behind forward_stage_4(forward_stages_1_to_3(x)). But
LTX2VideoDiffusionDecoder3d.forward no longer decodes; it is one denoising step, and stages 1–4 are reached as named context methods. That is the point of the PR, and it is the thing most worth a second opinion.

4. One intentional numerical change.
Multi-step x0 now returns the final x0 prediction directly instead of running an Euler update to t=0. The update reduces to that prediction (x_t − t·(x_t − pred)/t = pred), so this is the same value without a full-canvas float32 round trip, and it matches the reference decoder's _decode_one_tile. Measured ~2e-07 in float32; every other path is bit-exact.

Addressed while self-reviewing

  • Test gap. tiled_decode is public and must tile regardless of use_tiling (which gates only decode's routing) — an
    intermediate version of this refactor broke that, and the whole suite stayed green, because a fallback to one full-grid tile
    reproduces the untiled decode exactly. Added test_tiled_decode_tiles_even_when_tiling_is_disabled, which counts stage-4
    invocations instead of comparing outputs. Verified it fails on the broken version while the other three tiling tests still pass.
  • Dead code. Removed self.spatial_compression_ratio / self.temporal_compression_ratio from the model — this diff removed their last reader. Non-breaking: ConfigMixin.__getattr__ still resolves the attribute to the config value with the standard deprecation warning. The inner decoder's own temporal_compression_ratio (:526, read at :620) stays; a plain nn.Module has no such fallback.

Verification

  • Numeric baseline vs. the pre-refactor code (tiny config, 6 configurations): bit-exact for single-step x0 and for the velocity
    path on both tiled and untiled; ~2e-07 for the two multi-step x0 cases, which is item 4 above.
  • Real LTX-2.5 checkpoint at 768×512×121: stages 1–3 → (1, 69, 64, 96, 512), stage 4 context → (1, 121, 128, 192, 256), output (1, 3, 121, 512, 768), finite, on both decode paths. Also confirmed use_tiling=False runs stage 4 exactly once on the
    full grid while enable_tiling() runs it twice — i.e. the routing genuinely honors the flag at production dimensions.
  • Accelerate hooks. The untiled path changed from self.decoder(z, ...) (an nn.Module.__call__) to a bound method, so a hook on self.decoder no longer fires for it. Checked because it would break silently: not a regression, since the tiled path never invoked decoder.__call__ either and the model already relies on submodule-level hooks. Both paths are now consistent. Offload/parallelism tests pass.
  • Suite: 45 passed, 5 skipped. ruff check, ruff format, make fix-copies clean; the # Copied from blend helpers are
    untouched and in sync.

Dead code analysis

symbol verdict reason
_denoise (:829) Used :986
_decode (:878) Used :876 (tiled_decode), :1032 (decode)
tiled_decode (:860) Used public API + test :172
encode_context_stages_1_to_3 (:580), encode_context_stage_4 (:603) Used :912, :961
inner forward (:623) Used :844
inner out_channels, default_num_inference_steps, model_output_type, trailing_pad_latent_frames, patch_size,
context_channels Used all still read from _decode / _denoise / __init__ — re-checked after deleting the old inner
forward, which had been their reader

Nothing newly orphaned.

Docs

No staleness found. docs/source/en/api/models/ltx2_diffusion_decoder.md documents enable_tiling() semantics and the
remnant-merge rule, both unchanged; the renamed methods were never in public docs.

Two rules this PR surfaced that aren't written down anywhere, offered for the agent guides:

  1. When a model exposes both decode and tiled_decode, tiled_decode must tile unconditionally — use_tiling gates only whether decode routes to it. Every in-tree autoencoder follows this implicitly; nothing states it, and it cost a real bug here.
  2. Removing an attribute that mirrors a register_to_config value is non-breaking (ConfigMixin.__getattr__ back-stops it) — but only when it really is a mirror. On AutoencoderKLLTX2Video the same-named attributes are derived (config defaults to None, value computed from the block config, read internally at :1274-1276 / :1364), so the same removal would break it. The two files sit side by side and the pattern does not transfer.

@dg845
dg845 requested review from sayakpaul and yiyixuxu September 3, 2026 06:33
Comment thread src/diffusers/models/autoencoders/ltx2_diffusion_decoder.py Outdated
w0 * scale_w : w0 * scale_w + tile_pixel_shape[4],
]
row.append(decoder.denoise(context, x_t, num_inference_steps))
row.append(self._denoise(context, x_t, num_inference_steps))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am okay to keep _denoise() as is but since it's not shared anywhere maybe we can fold that in here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

My motivation for having _denoise as a separate method is so that the code is easier to follow (since _denoise is called inside a nested loop over temporal_tiles/height_tiles/width_tiles, inlining the denoising loop would make _decode even more complicated).

Comment thread src/diffusers/models/autoencoders/ltx2_diffusion_decoder.py Outdated
)
return b

def _denoise(self, latent_context: torch.Tensor, x_t: torch.Tensor, num_inference_steps: int) -> torch.Tensor:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But I guess the denoise logic should go to the pipeline no? Cc: @yiyixuxu

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The current PR implementation (with denoising in decode) has the advantage that it doesn't require changes to the current LTX2VideoDiffusionDecodePipeline standard pipeline or the LTX2DiffusionVaeDecoderStep modular block, but I think it's reasonable if we want to refactor those as well to fit the diffusion pipeline design better.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that is more consistent with how we do it for other pipelines. But let's see what @yiyixuxu has to say about this.

dg845 and others added 4 commits September 3, 2026 18:30
…ng gate

Follows review feedback to use the pattern the other autoencoders use, e.g.
`AutoencoderKLLTX2Video._decode` and `AutoencoderKLFlux2._decode`: a private
`_decode` that hands off to `tiled_decode` when tiling is on and the video
needs it, then falls through to the untiled path, with `decode` as the
`apply_forward_hook` wrapper.

This also fixes a naming inversion. Those files use `_decode` for "dispatch
plus the untiled path"; the previous commit used the same name for the shared
*tiled* body, so a reader coming from any other autoencoder would read it
backwards. `tiled_decode` gets its body back and loses the `tiled` parameter,
which was the one part of that commit that read like a mode flag.

The cost is ~8 duplicated lines: the `num_inference_steps` default, the
pixel-canvas shape and the noise draw now appear in both `_decode` and
`tiled_decode`. The canvas derivation encodes the causal (T - 1) * ratio + 1
frame mapping, so the two copies have to move together.

The gate re-derives "does this need tiling" in latent units while the schedule
answers the same question on the stage-4 grid. Swept latent shapes against
tiling configs to check they cannot disagree in the direction that matters:
there is no configuration where the gate skips tiling that the schedule would
have split, and the two agree exactly whenever `tile_sample_min_num_frames` is
a multiple of 8. The only disagreements route to `tiled_decode` for a video
that then yields a single tile, which decodes identically.

Reads the ratios from `config` rather than restoring the mirror attributes the
previous commit removed.

`tiled_decode` still tiles regardless of `use_tiling` — the flag gates only the
routing — so the test added in the previous commit still covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ference

`tiled_decode`'s docstring pointed at `_decode`, which is private and so absent
from the rendered API docs even though `tiled_decode` itself is autodoc'd.
Points at `decode`, the entry point a user actually calls.

Adds a test for the size gate `_decode` uses to route. The gate's two outcomes
cannot be told apart from the output: a video below the tile size that reaches
`tiled_decode` anyway gets a single-tile schedule and decodes to the same
pixels. So the test asserts the routing directly, and separately pins the
contract callers depend on -- that turning tiling on cannot change the output
of a video that fits in one tile.

Both directions are covered, and both are load-bearing: a gate that always
routes wastes the tiling machinery on small videos, and a gate that never
routes disables tiling silently, which shows up as memory rather than as a
wrong result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

models size/L PR with diff > 200 LOC tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants