Repository navigation
LTX-2.5: tensor- and sequence-parallel DiT and the two-stage distilled recipe - #376
Conversation
aa976f6 to
c88093f
Compare
c88093f to
bdd0a86
Compare
dghosef
left a comment
There was a problem hiding this comment.
Code looks fine.
Shows a ~50%-~150% performance improvement over SGLang but loses to vllm-omni with Ulysses SP.
| config | GPUs | c=1 latency (s) | videos/min c=1 | videos/min c=2 | videos/min c=4 |
|---|---|---|---|---|---|
vllm-omni --usp 2 (Ulysses SP) |
2 | 3.54 | 16.87 ± 0.03 | 19.74 ± 0.01 | 19.87 ± 0.00 |
vllm-omni --tensor-parallel-size 2 (tune run) |
2 | — | 14.23 | 16.31 | — |
mstar TP2 (ltx2_5_tp2.yaml) |
2 | 4.65 | 12.91 ± 0.05 | 17.76 ± 0.39 | 18.82 ± 0.31 |
mstar default (ltx2_5.yaml) |
2 | 6.04 | 9.93 ± 0.02 | 13.95 ± 0.03 | 14.82 ± 0.00 |
SGLang --tp-size 2 --enable-torch-compile |
2 | 7.82 | 7.67 ± 0.10 | 7.61 ± 0.05 | 7.77 ± 0.05 |
| diffusers (compile, native attention) | 1 | 5.35 | 11.22 ± 0.05 | — | — |
Here is the breakdown of a single request:
| stage | where | time | share of 4.65 s |
|---|---|---|---|
| DiT, 8 denoising steps | both GPUs (TP2, lockstep) | ≈ 2.7 s (8 × ~336 ms) | ~58% |
| VAE + audio decode | GPU 0 only | ≈ 1.7–2.0 s (one ~2 s step per request) | ~37–40% |
| Gemma text encoder | GPU 1 | ≈ 0.3–0.6 s (partly overlapped) | ~5–10% |
| host overhead (scheduling, planning, prepare_inputs) | CPU | < 10 ms | negligible |
My hypothesis parallelizing the decode will help
bdd0a86 to
ac8d509
Compare
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
I ran ltx2_5.yaml, TP2 and SP2 on this branch, and your PSNR numbers against diffusers reproduce, two-stage included. Left couple of inline comments below.
|
|
||
|
|
||
| @torch.compiler.disable | ||
| def _all_to_all( |
There was a problem hiding this comment.
CommGroup.all_to_all, which ulysses_attention uses, already does this exchange, but it splits the tensor into pieces and concatenates what it receives, so it copies, while this sends one buffer with all_to_all_single. I'd move this version into CommGroup.all_to_all, so there is one all-to-all and ulysses_attention drops the copies too.
There was a problem hiding this comment.
Done in #897, which this PR is now stacked on: CommGroup.all_to_all sends one packed buffer with all_to_all_single (even and uneven splits), ulysses_attention takes seq_dim/head_dim, and LTX's video self-attention calls it, so this file no longer has its own exchange. On LTX SP2 the swap measured the same throughput with byte-identical outputs.
| if self.warming: | ||
| self._warmed.add(key) | ||
| if key in self._warmed: | ||
| return self._compiled(z.contiguous(memory_format=torch.channels_last_3d), temb, causal=causal) |
There was a problem hiding this comment.
At batch 1 this .contiguous returns z unchanged, so its batch stride differs from the warm-up tensor's, and torch.compile recompiles the decoder on the first batch-1 request, about 10 s in my TP2 runs. z.clone(memory_format=torch.channels_last_3d) gives it the warm-up's strides.
There was a problem hiding this comment.
Fixed in 5edba42 with z.clone(memory_format=torch.channels_last_3d). Confirmed with TORCH_LOGS=recompiles on TP2 (2×H200): before, the first batch-1 request recompiled the decoder on stride mismatch at index 0. expected 1044480, actual 128; after, no recompiles once the server is up, over single-stage and two-stage requests, with byte-identical video and audio.
| up = LTX2LatentUpsamplerModel.from_pretrained(str(snapshot / "latent_upsampler"), | ||
| torch_dtype=torch.bfloat16).to(device) | ||
| s1_video = torch.load(two / "s1_out_video.pt").to(device) | ||
| upsampled = up(s1_video.to(torch.bfloat16)) |
There was a problem hiding this comment.
Nit: This check runs diffusers' upsampler, and the re-normalize after it is a copy of LTXLatentUpsamplerSubmodule.run_batch, so it compares diffusers with itself and never runs the node's code. I'd have record_oracle_two_stage.py save stage 1's final latents, and call run_batch on them here.
There was a problem hiding this comment.
Done in c8d8a80. record_oracle_two_stage.py now also saves stage 1's final latents, packed and still normalized (the node's input), and stage 2's starting latents before the noise blend (its output). The harness builds the served upsampler node through LTX25Model.get_submodule, calls run_batch on the former, compares with the latter, and seeds stage 2 from the node's output. On a freshly recorded oracle (2×H200) all four are bit-exact: max_abs 0 for video and audio, before and after the noise blend.
| # False keeps every head on each sequence-parallel rank (TP still shards | ||
| # them), for an attention a rank runs whole over its own tokens: one over a | ||
| # replicated stream, or a cross-attention from the rank's query shard. | ||
| shard_over_sp: bool = True |
There was a problem hiding this comment.
shard_over_sp and workspace_mb aren't in the RaggedAttentionSpec row of docs/adding_models.rst. Can you add them, with a note that by default the resource splits heads across TP times SP ranks, and shard_over_sp=False splits them across TP only?
There was a problem hiding this comment.
Added in 38decd3, in the RaggedAttentionSpec row: by default heads split across TP × SP, shard_over_sp=False across TP only, and workspace_mb with its default. It isn't a YAML override key, so it's not in the deployment-overrides table.
PyAV converting per-frame strided views of the [3, F, H, W] frames took 190 ms of the 420 ms encode of 121 frames at 544x960; one contiguous [F, H, W, 3] copy first takes the encode to 230 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vae_compile (on in the configs, off by default and for the parity
harness) compiles the VAE's decoder, default mode, for the capture and
refine shapes at bs 1..2, warmed at load; any other shape decodes eager.
The decoder rather than vae.decode is compiled, so a tiled decode compiles
its four tile shapes instead of one unrolled graph, and the compiled path
runs a channels_last_3d copy of the weights (777 MB), which is faster
compiled but twice as slow eager.
On an H100 the 544x960x121 decode goes from 371 to 240 ms and the tiled
1088x1920 one from 1988 to 1315 ms, ~55 dB from the eager decode; served
PSNR against the reference is unchanged (32.33 dB SP2, 32.11 TP2). Load
takes ~1.5 min longer.
Served on 2x H100, with the two commits before this (contiguous mp4
frames, the compiled SP exchange):
SP2 c=1 3.55 -> 3.15 s, c=4 20.8 -> 22.3 videos/min
TP2 c=1 3.96 -> 3.70 s, c=4 18.7 -> 19.6 videos/min
TP2 two-stage 1088x1920 c=1 12.36 -> 11.03 s, c=2 5.52 -> 5.95 videos/min
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SeqParallel.attend calls ulysses_attention with the [B, L, H, D] dims, whose all-to-all is now the one this PR introduced, so sequence_to_heads / heads_to_sequence go. The exchange test moves to test_sequence_parallel.py with the other layouts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--backend sglang` drives SGLang diffusion's `/v1/videos` job API (create, poll, fetch the mp4) with the same prompts, seeds and closed loop as the M* and vllm-omni backends, for the SGLang rows of the comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At batch 1, `contiguous(memory_format=channels_last_3d)` returns a request's latents unchanged when they already count as channels-last, with batch stride 128 where the zeros the decoder was warmed on have 1044480. The compiled decoder guards on strides, so the first batch-1 request recompiled it (`stride mismatch at index 0. expected 1044480, actual 128`). `clone(memory_format=...)` always lays the copy out as the warm-up did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check ran diffusers' upsampler and a copy of the node's re-normalize, so it compared diffusers with itself. The oracle now also records stage 1's final latents (packed, still normalized: the node's input) and stage 2's starting latents before the noise blend (its output); the harness calls the served `LTXLatentUpsamplerSubmodule.run_batch` on the former, compares with the latter, and seeds stage 2 from the node's output. Bit-exact on 2x H200 (max_abs 0 for video and audio, before and after the noise blend). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d component `attention_over_sharded_keys` (a replicated query attending keys sharded along the sequence, each rank's partial attention merged by log-sum-exp) and its `attention_with_lse` kernel move from LTX's layers to `mstar/model/components/distributed/sequence_parallel.py`, beside `ulysses_attention`; LTX's `SeqParallel.attend_shards` calls it. It needs no planning, so it stays a function rather than a resource. Tested on 1-3 gloo ranks against one rank attending every key; LTX SP2's served video and audio are byte-identical before and after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`run(..., return_lse=True)` on the ragged wrappers, the managers and `RaggedAttentionCallable` also returns each query row's log-sum-exp, `[total_tokens, H]` fp32 in natural log (FlashInfer's `lse_base="ln"`), for merging attentions over disjoint key sets. The plan is the same; only the run differs, so nothing changes for callers that don't ask. Tests (H200): eager cross-attention, a captured graph re-planned before replay, and the callable, against a per-segment torch.logsumexp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ource Under sequence parallelism the audio queries over the video now attend this rank's video shard through the `video_to_audio` ragged cross-attention (the step plans that pair over the shard instead of dropping it), and `attention_over_sharded_keys` merges the ranks' results from the resource's log-sum-exps. The hand-picked torch flash call it used is gone; `sdpa_attention` / `joint_attention` take `return_lse` for the SDPA path. SP2 on 2x H200, alternated with the previous head over two servers each: single-stage 19.7 / 21.8 / 22.3 videos/min at c=1/2/4 against 19.9 / 21.9 / 22.3, two-stage 10.3 s / 6.35 per min against 10.2 s / 6.38; video PSNR against the oracle 31.46 dB against 31.39 (reference vs itself 32.07). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vae_compile_shapes lists the shapes the compiled VAE decoder is warmed for at load, independent of the DiT's capture shapes; it defaults to the capture and refine shapes as before. The SP2 config captures no refine shape but now warms the 1088x1920 two-stage decode, which ran eager: on 2x H200, two-stage 10.3 to 9.7 s for one request and 6.3 to 6.75 videos/min at 2 concurrent, two boots each, 544x960 and rank 0 memory unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re_refine_shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gh a private aten kernel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o warm, correct ltx2_5.yaml's comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ragged resource's return_lse passes lse_base, which needs FlashInfer 0.7.1 or later; every extra pins the version the suite runs on, as #852 does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
attention_over_sharded_keys gathers the partial outputs and their log-sum-exps as one fp32 tensor, the log-sum-exp in an extra last column, instead of two collectives a block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
latents_mean and latents_std are persistent buffers, so safe_open reads them from each VAE's safetensors instead of building both VAEs for four small tensors. They equal the loaded VAEs' buffers exactly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71fa555 to
8a26112
Compare
dghosef
left a comment
There was a problem hiding this comment.
Ran it on 2xH200 at 71fa555 - SP2/TP2 match your numbers within 1-4%, PSNR 32.8 dB vs the reference's own 32.1.
544x960, c=1 latency / c=4 videos/min:
- M* SP2: 3.10 s / 22.6
- vllm-omni Ulysses-2: 3.39 s / 20.7
- M* TP2: 3.42 s / 20.7
- SGLang Ulysses-2: 5.0 s / 12.2
- diffusers (1 GPU): 6.41 s / 9.4
Two-stage 1088x1920: SP2 9.81 s vs vllm-omni 10.09 s at c=1, tie at c=4.
Resolves the WorkspacePool conflict: keep the per-resource size_mb and fall back to main's workspace_bytes(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main brings #376: LTX-2.5 tensor- and sequence-parallel DiT (TP2 and Ulysses SP2 configs), the two-stage distilled recipe with its latent upsampler node, the compiled video VAE decoder, ragged attention's return_lse, shard_over_sp and workspace_mb, the log-sum-exp merge over sharded keys, and the flashinfer-python 0.7.2 pin; and #573's randomized CPU tests for KV page ownership. Resolutions: - Ragged attention's run keeps the mstar::ragged_attention op path for compile_op and takes return_lse; a return_lse call runs the kernel outside the op, which returns only the output. - _build_ragged shards by EngineResourceInfo.head_shards, or by the TP ranks alone for a resource with shard_over_sp=False. - RaggedAttentionCallable stays deleted: main's return_lse test calls the manager's run with the label bound, as the models now do. - docs/adding_models.rst describes compile_op, shard_over_sp and workspace_mb together, and how shard_over_sp and splits_heads combine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On main, which now has #897 (the shared Ulysses all-to-all) and #375.
Developed with the AGENTS.md / skills harness from #370.
TP and SP
The dit is TP-enabled; its linears and across-heads qk-norms already shard by head, and the ragged resources shard with the engine's group.
configs/ltx2_5_tp2.yaml: the DiT over both GPUs, text encoder on rank 1, decoders and upsampler on rank 0.It is also SP-enabled (Ulysses):
configs/ltx2_5_sp2.yamlgives each rank the whole DiT and half the video tokens, same placement otherwise. Only the video self-attention needs every video token, so it is the only exchange: it goes through the sharedulysses_attention(#897), oneall_to_all_singleeach way, captured in the CUDA graph. The cross-attentions from the video run on the rank's own tokens against the replicated text/audio, the audio stream's attentions run whole on each rank, and the audio queries over the video attend the rank's shard through the same cross-attention resource, asking it for the log-sum-exp (below), andattention_over_sharded_keys(shared, besideulysses_attention) merges the ranks' results. The ragged resources those use keep all their heads over SP (RaggedAttentionConfig.shard_over_sp=False) and get the rank's video span. Per denoise step this is 38 ms of collectives against 122 ms when every attention exchanges (2×H100). Moving from this PR's own exchange to the shared one changed nothing measurable: on 2×H200, 19.6 / 21.9 / 22.3 videos/min at c = 1 / 2 / 4 either way, and byte-identical 544×960 and two-stage 1088×1920 video and audio.How these were measured (this table and the two-stage one below): this PR at 2c40491, the distilled checkpoint in bf16 decoding with the conv VAE,
benchmark/ltx2_5/bench.py(--backend mstar|vllm-omni|sglang) closed loop with the same 8 prompts and seeds, 20 measured requests per level (10 for two-stage) after warmup. Each row is the mean of two server processes, which agree within 3%; no request failed.--usp 2or--tensor-parallel-size 2, withltx2_use_conv_vae(its default decoder is the diffusion decoder).--ulysses-degree 2or--tp-size 2,--enable-torch-compile, CPU offload off (by default it offloads the DiT and text encoder for video, even on an H200). sglang-omni 0.1.9 does not serve LTX-2.5.device_map="balanced", so its throughput is 1/latency.Served PSNR against the oracle, recorded on 2×H200 (
test/ltx2_5/compare_media.py): SP2 31.5 dB, TP2 31.0 dB; the reference against itself under the other SDPA kernel 32.1 dB. On 2×H100 with that box's recording it was SP2 32.3 dB, TP2 32.1 dB, reference 32.1 dB.Against vllm-omni's Ulysses-2 serving, M* SP2 has 11% lower single-request latency and 7% / 9% higher throughput at 2 / 4 concurrent; SGLang's throughput stays flat as concurrency rises. M* runs bf16 throughout; the DiT step is ~70% GEMM, and a sizing experiment with rowwise FP8 linears cut the SP2 step by about a quarter.
Five more changes in this PR:
vae_compile(on in the configs, off by default and in the parity harness) compiles the video VAE's decoder, warmed at load forvae_compile_shapes, which is separate from the DiT's capture shapes (default: the capture shapes; the SP2 config adds the two-stage 1088×1920, which its DiT doesn't capture); other shapes decode eager. The decoder rather thanvae.decodeis compiled, so a tiled decode compiles four tile shapes, and the compiled path runs a channels_last_3d copy of the weights (777 MB). On an H100: 544×960×121 371 → 240 ms, tiled 1088×1920 1988 → 1315 ms, ~55 dB from eager; served PSNR is unchanged. Load takes ~1.5 min longer.RaggedAttentionConfig.workspace_mblets a ragged resource size its FlashInfer workspaces. LTX's six labels × two slots held 6 GB at the 512 MB default; it declares 128 MB (1.5 GB), which serves 544×960 and the two-stage 1088×1920. This is what makes SP2 fit next to the text encoder.run(..., return_lse=True)on the wrappers, the managers andRaggedAttentionCallablealso gives[total_tokens, H]fp32 in natural log (FlashInfer'slse_base="ln") from the same plan (FlashInfer backend; Add an opt-in ragged FA3 varlen backend for cacheless attention. #863's DENSE backend raises if asked). SP's audio-over-video merge uses it, so no layer picks a kernel, and gathers each rank's output and log-sum-exp in one collective. SP2 on 2×H200, alternated with the version that called torch's flash kernel directly (two servers each): 22.3 videos/min at c=4 either way, single-request 3.05 s vs 3.02 s, two-stage 10.3 s vs 10.2 s.flashinfer-pythonis pinned to 0.7.2 in every extra (as Host overhead 5/N: cheaper resource planning, admit and commit #852 does):return_lsepasseslse_base, which needs 0.7.1 or later.Placement sweep around the TP2 DiT (text encoder / decoders on rank 0 or 1): within noise.
Two-stage recipe
recipe: "two_stage": stage 1 at half size (8 sigmas) → the x2 latent upsampler (newlatent_upsamplernode) → 3 sigmas at full size from the upsampled latents blended with noise at σ0. The reference threads one generator through both stages, so stage 2 replays stage 1's draws first and continues the stream. Uses #373'sinitial_loop_backand per-walk schedule. Frames above 720p decode in VAE tiles, as the card does for this decode. The refine walks replay the single-stage capture of their size, since the DiT step is the same forward; the TP2 config captures 544×960 and 1088×1920.The request's geometry lives under
kwargs["request"], not under the step-metadata keys: the conductor merges each pass'sstep_metadataintometadata.kwargs, so a request fact stored under a step-metadata key is overwritten by the previous walk's value.adding_models.rstnow says so.Correctness (
record_oracle_two_stage.py,parity_harness.py two_stage_seed): the upsampler and both of stage 2's starting tensors (generator continuation included) are bit-exact with the oracle. Served video against an H200 oracle, through H.264 (codec floor 40.6 dB): 29.8 dB on TP2 and SP2. On H100 earlier: 30.5 dB against 30.2 dB reference-vs-itself.Tests
On the rebased head (2×H200, torch 2.12.1+cu132, FlashInfer 0.7.2), on main at b47e35b; after a last rebase over #917 (tests and CI only), ruff and the GPU files below were rerun on the pushed 8a26112 with the same result:
ruff check .clean.python -m pytest test/modular test/distributed test/chatterbox test/kokoro test/omnivoice test/sampling_test mstar/model/*/tests benchmark/worker_phases,OMP_NUM_THREADS=2): 7782 passed, 905 skipped, 50 failed;main(b47e35b) run alongside fails the same 50 (7767 passed), all in seven files unrelated to this PR (test_loop_output_ownership.py, SHM shutdown, send registration, eval compare, serving metrics, client error text).test_sequence_parallel.py,test_ltx2_5_sequence_parallel.py,test_ltx2_5_model.py,test_ragged_attention.py,test_ragged_dense_attention.py,test_attn_backend_auto.py,test_ragged_attention_cpu.py,test_context_only_attention.py,test_openai_router.py,mstar/model/cosmos3/tests,test_cosmos3_edge_model.py): 467 passed, 31 skipped.New tests: stage-1 and refine walk structure, the two-stage schedule (replaying the conductor's step-metadata merge between walks), two-stage size validation; for SP, a toy DiT on two gloo ranks against one rank (
test_ltx2_5_sequence_parallel.py) and the SP step declaration; the compiled VAE decoder's shape gate andvae_compile_shapes; the ragged resource's log-sum-exp (eager, through a captured graph re-planned before replay, and the callable) againsttorch.logsumexp;attention_over_sharded_keyson 1–3 gloo ranks against one rank. The two-stage parity check (parity_harness.py two_stage_seed) runs the upsampler node and is bit-exact against the oracle.🤖 Generated with Claude Code