Repository navigation
Conversation
Wan normalizes q and k across all heads (rms_norm_across_heads) with FlashInfer's RMSNorm, then rotates them with FlashInfer's interleaved cos/sin-cache RoPE: three full passes over q and k per block (at 720p on 4xB300 37800 x 5120 tokens per rank, ~0.54 ms of every block). fused_rmsnorm_interleaved_rope_ is FlashInfer's CuTe-DSL RMSNormKernel (same tiling and reduction) whose store rotates each (even, odd) pair of the rounded bf16 output in fp32 with the same expression as the RoPE kernel, in place. Its output is bit-identical to the two kernels it replaces. The Wan block uses it when the q/k norm runs FlashInfer's kernel (CUDA forward, no TP across heads) and the transformer supplies the cos/sin cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Wan block projects the same normalized hidden states three times. After loading, to_q / to_k / to_v now keep views of one [3 * dim, dim] weight and [3 * dim] bias, and the block runs a single GEMM over it while every layer still reads its view; a LoRA wrapper, offload or a weight swap that replaces a tensor sends the block back to the three projections. cuBLAS picks the same kernel for the fused shape and the output is bit-identical (B300, 37800 x 5120 x 15360: 3.976 ms against 3 x 1.354 ms at the power cap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s norm1 A Wan block ends with MulAdd (hidden + ff_output * gate) and the next block starts with norm1 over that sum: two passes over the residual stream. When every block is a plain WanTransformerBlock in a plain module list and both ops run their CUDA kernels, a block now hands (ff_output, gate) to the next one, whose norm1 runs the CuTe residual norm (residual + gate * x, then LayerNorm and scale/shift) in one kernel. The residual it writes equals MulAdd's output bit for bit (both fuse the multiply-add), and the norm reads that same bf16 sum, so the blocks' outputs keep their bits; the last block still adds its own residual. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Wan block projected the text to the cross-attention K and V (and normed K) in every denoising step, although the prompt embeddings the stage passes are the same tensor for the whole loop and the weights do not change inside it. The transformer now projects all blocks' K/V on the first step it sees a prompt tensor, keeps them keyed by that tensor (held, so its id is not reused) and its version, and hands each block its pair; the same GEMMs and norm run on the same inputs, so outputs are bit-identical. One entry per CFG branch is kept. LoRA-wrapped projections, layerwise offload, compile, graph capture and block hooks (cache-dit) keep the per-step projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block kept the fused [3 * dim, dim] weight and bias as plain attributes. Moving the DiT off the GPU (component residency swapping an A14B expert, --dit-cpu-offload) or layerwise offload gives to_q / to_k / to_v new data, but those attributes kept the fused buffers on the GPU: ~157 MB per block, ~6.3 GB per A14B expert, which defeated the offload. The forward already fell back to three GEMMs, but the memory stayed pinned. The block now holds no reference to them. _fused_qkv reads the fused weight and bias back from the three layers as views, while the layers are still consecutive rows of one buffer on the input's device, so the layers alone keep the buffers alive. The check costs ~10 us per block call on the host (4.4 us before) and is skipped under compile. The test checks that moving the block frees every byte it held on the GPU, and that re-fusing on CPU and GPU gives the same weights and the same output bits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-per-prompt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest The model kept up to two entries of every block's cross-attention K/V in a module dict (~420 MB per entry for an A14B expert; ~1.7 GB with serial CFG over both experts) until the next request evicted them: through VAE decode, across the expert switch, and while component residency had the DiT offloaded, since a plain dict does not move with the module. They now live in the request, under the new Req.extra key DENOISING_LOOP_STATE, which the shared DenoisingStage drops in _post_denoising_loop (standard, progressive, DMD and LTX-2 loops all end there), before decode. The module holds none of them, so a request that ends early takes its K/V with it. An expert's first step drops the other expert's entries (at most one other CFG branch is kept), and K/V cached on a device other than the current activation's are projected again. The key starts with "_" so disaggregated transfers skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paths.py The fused q/k/v test moves from its own file into a new "Wan" section of test_model_fast_paths.py, the per-model fast-path file. The section's _wan_block_env / _wan_block / _wan_block_inputs helpers hold the single-rank model-parallel setup, the attention patches and the block inputs, so the next Wan tests reuse them instead of repeating them. Like the file's other bit-exact fusion tests it is CUDA-only (requires_inline_ptx), which keeps it out of the AMD nightly run. est_time 110 -> 125. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l_fast_paths.py The chained-blocks test moves from its own file into the Wan section of test_model_fast_paths.py, on the section's shared block setup. est_time 125 -> 140. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-per-prompt Conflict (modify/delete) in test_wan_ffn_residual_chain.py: the chain branch moved its test into test_model_fast_paths.py and deleted the file, which this branch extended with the text K/V tests. The merge keeps this branch's file; the next commit moves the text K/V tests to the Wan section and deletes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ths.py The text K/V tests move into the Wan section of test_model_fast_paths.py, on its shared block setup, and test_wan_ffn_residual_chain.py goes away. The check that a block given projected K/V keeps its bits was the tail of the chain test and is now its own test (test_wan_projected_text_kv_keeps_bits), next to the lifetime test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mickqian
requested review from
AgainstEntropy,
BBuf,
HaiShaw,
kevin-mii,
niehen6174,
ping1jing2,
yichiche and
yingluosanqian
as code owners
October 24, 2026 04:37
Collaborator
Author
|
/tag-and-rerun-ci |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #46245 (FFN residual chain), #46244 and #46228; review the commits after #46245.
Motivation
Each Wan block projects the 512 text tokens to the cross-attention K and V (and RMS-normalizes K) in every denoising step: on Wan2.2 A14B 720p (4xB300) two small GEMMs and a norm, ~0.07 ms of every block, 40 blocks x 40 steps. The text and the DiT weights do not change inside a denoising loop, and the denoising stage hands the transformer the same prompt-embedding tensor at every step of the loop (the CFG branch kwargs are built once per loop). LTX-2 does the same per loop in #46105 through its own denoising stage. Wan runs the shared stage, so the transformer keys the projection by the prompt tensor, and the shared stage frees it when the loop ends.
Modifications
managers/forward_context.py:DENOISING_LOOP_STATE, aReq.extrakey for what models keep for one denoising loop. Its leading_keeps it out of disaggregated request transfers, like_skip_softmax_params.pipelines_core/stages/denoising.py:_post_denoising_loopdrops it. The standard, progressive, DMD and LTX-2 loops all end there, before decode. The stage knows nothing about what is inside, so there is no per-model hook and no getattr/hasattr fallback.models/dits/wanvideo.py:WanT2VCrossAttention.project_context_kv(context)returns the normed K and the V thatforwardprojects;forward(..., context_kv=None)uses them when given.WanTransformerBlock.forward(..., text_kv=None)passes them toattn2.WanTransformer3DModel._text_kvs(source, context): on the first step with a prompt tensorsource, it projects every block's K/V from the embeddedcontext. They are stored in the forward context's request, underreq.extra[DENOISING_LOOP_STATE], keyed by(id(model), id(source)). The entry holdssourceso its id cannot be reused while cached. The module holds none of them: they go when the loop ends and never outlive the request, through VAE decode, across requests, or while component residency has the DiT offloaded. Later steps of the loop read them. A new request (whose LoRA or weights may differ) projects again; inference tensors carry no version counter, so the request is the validity scope. An expert's first step drops the other expert's entries, and at most one other CFG branch is kept, so with serial CFG at most two entries (~840 MB for A14B) live during the loop. K/V cached on a device other than the activation's are projected again. It stays off, and every step projects as before, when a cross-attention projection is LoRA-wrapped, weights are layerwise-offloaded, under compile or CUDA-graph capture, for I2V cross-attention, or when a cache adapter or hook replaced the blocks (_plain_blocks).The same GEMMs and norm run on the same inputs, so outputs are bit-identical.
Accuracy Tests
test_model_fast_paths.py::test_wan_projected_text_kv_keeps_bits(Wan section): a Wan block givenproject_context_kv(context)gives the same bits as the block projecting it itself.test_model_fast_paths.py::test_wan_text_kv_lives_for_one_loop_of_one_request, run with two models standing in for the two experts:DenoisingStage._post_denoising_loopfrees the rest.Speed Tests and Profiling
Wan2.2 T2V A14B 720p, 4xB300, harness single_e2e, fresh server per run, interleaved: base (#46245 plus the spatial-parallel VAE changes #46171 and #46174) 89.800 / 89.791 / 89.691 / 89.771 s (mean 89.763) -> 89.604 / 89.652 / 89.665 / 89.616 / 89.691 s (mean 89.646), -0.13%. After the memory-lifetime fix, the full stack measured 89.517 / 89.540 s against 89.556 s for the pre-fix stack in the same session: no regression.
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ✅ Run #40439978673
Latest PR Test (Extra): ❌ Run #40439978258
Latest PR Test (AMD ROCm 10): ➖ No AMD PR run found for this commit.