Skip to content

[Perf] diffusion: Project Wan's cross-attention text K/V once per denoising loop - #46246

Closed
mickqian wants to merge 17 commits into
mainfrom
mick/wan-text-kv-once-per-prompt
Closed

mickqian wants to merge 17 commits into
mainfrom
mick/wan-text-kv-once-per-prompt

Conversation

@mickqian

@mickqian mickqian commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

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, a Req.extra key 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_loop drops 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 that forward projects; forward(..., context_kv=None) uses them when given.
  • WanTransformerBlock.forward(..., text_kv=None) passes them to attn2.
  • WanTransformer3DModel._text_kvs(source, context): on the first step with a prompt tensor source, it projects every block's K/V from the embedded context. They are stored in the forward context's request, under req.extra[DENOISING_LOOP_STATE], keyed by (id(model), id(source)). The entry holds source so 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 given project_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:
    • Every step of a loop reads the K/V projected on its first step, for both CFG branches, with the bits of a direct projection.
    • Another request projects its own, and K/V left on another device are projected again.
    • The second expert's first step frees the first expert's K/V.
    • The real DenoisingStage._post_denoising_loop frees the rest.
    • A request dropped mid-loop takes its K/V with it.
    • Negative checks: a model that keeps a reference fails the expert-switch check, and a stage that keeps the state fails the loop-end check.
  • Wan2.2 T2V A14B 720p, 4xB300, 3-step lossless (crf 0) mp4: md5 identical to the build without the Wan stack for 3 prompts, before and after the fix.

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.

mickqian and others added 13 commits October 24, 2026 03:27
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

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 24, 2026
@mickqian mickqian closed this Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

diffusion SGLang Diffusion jit-kernel run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant