Skip to content

perf(wan): 2.3x faster streaming Wan VAE decode - #425

Merged
NSagan271 merged 17 commits into
mstar-project:mainfrom
arun-elr:perf/wan-vae-decode
Oct 14, 2026
Merged

NSagan271 merged 17 commits into
mstar-project:mainfrom
arun-elr:perf/wan-vae-decode

Conversation

@arun-elr

@arun-elr arun-elr commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

M*'s streaming Wan VAE decoder (Cosmos3VAEDecoderARSubmodule) re-decodes 8 latents of context from an empty causal-conv cache before every window. This PR keeps each live stream's conv cache, so a window decodes only its new latents, and makes the decode cheaper (channels-last, padding inside the conv). Served, a window's decode is 2.3× faster (534 → 236 ms) and a rollout 16 to 23% faster.

Before and after: per-window decode

3-latent benchmark chunk; served kv windows have 8 new latents.

Cosmos3-Edge, shipped config, one B200 with the DiT co-located:

  • Speed: decoder 534 → 236 ms per served window (2.3×); rollouts 16 to 23% faster (kv 14.27 → 11.91 s, chained 11.85 → 9.11 s); DiT unchanged.
  • Quality: 53.4 dB vs an exact fp32 decode (worst frame 49.5 dB), as close as stock diffusers bf16; main: 46.5 dB (worst 34.5 dB).
  • Memory: +1.33 GiB per live stream (the decoder cache, at 832×480 in bf16; it grows with frame area and doubles in fp32) plus a fixed 1.03 GiB decoder-only VAE copy; 8 streams add 14.6 GiB above idle (main: 5.5 GiB).

Decoder alone, ms per 3-latent chunk (480×832, bf16), same latents for every engine:

decoder only, one B200 Wan 2.2 ms Wan 2.1 ms
M* this PR, opt-in (windowed_decode_latents_per_call=0 + cudnn.benchmark) 72.78 55.88
vLLM-Omni f69b1f2b, best (--vae-fast-path channels_last) 77.85 64.08
M* this PR, defaults (=1, lowest memory) 79.5 64.5
SGLang v0.5.21, best (--quality extra-high) 94.36 92.50
M* before (stateless re-decode) 372.08 338.37

Most of the 4.7× against M* before is work not done: main decodes 11 latents per 3-latent chunk and this PR 3, which is 3.4×; the cheaper decode does the rest. The opt-in row takes 6.5% less time per chunk than vLLM-Omni's best on Wan 2.2 (12.8% on Wan 2.1); the defaults favour memory (5.5 GiB peak) and are within 2.1% (0.7%). All land at bf16-rounding quality; neither other engine streams Cosmos3.

Changes

switch (default) what it does
windowed_decode_warm_cache (true) keep each stream's conv cache; decode only new latents
windowed_decode_max_warm_streams (0 = no cap) streams that start past the cap stay stateless
windowed_decode_latents_per_call (1) latents per decoder call (1 = vae.decode's calls, 0 = a whole window)
vae_decode_channels_last (true) channels-last decode on the node's own decoder-only VAE
vae_decode_cudnn_benchmark (false) opt-in cudnn.benchmark

The whole-clip decoder and the wan22 node decode exactly as on main.

Where to look

  • wan_vae.decode_causal: from an empty cache with clip_start, equal to vae.decode call for call.
  • wan_vae.DecoderStep.__call__: first two calls eager ("Rep" marker); new compiled signatures warm up on a cache copy.
  • wan_vae.FeatCache / compact_feat_cache: compiled cache writes become compact copies (no pinned buffers).
  • Cosmos3VAEDecoderARSubmodule.forward: warm vs stateless chosen once per stream (_warm_slot_free); cache freed after the last window or with the request.
  • Cosmos3Model._build_decoder_only_vae: the channels-last copy; the whole-clip decoder keeps main's layout.
Code notes
  • Shared code: new mstar/model/components/wan_vae.py (decode_causal, DecoderStep, to_channels_last, cudnn_benchmark); ChannelsLastCausalConv3d does the H/W zero padding inside the conv, with no padded copy.
  • DecoderStep runs a stream's first two decoder calls eagerly (they still see diffusers' "Rep" cache markers; compiled, the second returns wrong frames on torch 2.12.1) and warms each new compiled signature on a copy of the cache, like the whole-clip decoder's existing _warmed_decode_shapes.
  • FeatCache writes cache slots as compact copies under compile, and compact_feat_cache catches any slot still returned as a view (see Memory).
  • submodules.py imports the helpers lazily, so mstar.model.cosmos3 still imports without diffusers.
  • configs/cosmos3_edge.yaml and configs/cosmos3_nano_ar.yaml carry the cap and windowed_decode_latents_per_call as commented keys.
  • vae_decode_cudnn_benchmark gains nothing on Wan 2.2 (77.2 vs 77.8 ms) and frames can differ between launches; on an H200 it takes ~18% off a Wan 2.1 chunk.

Proposed defaults (happy to change any of these)

  1. Warm cache on. Risk: the per-stream cache (Summary), outside any engine resource pool. Fallback: windowed_decode_warm_cache: false restores the stateless decode; with vae_decode_channels_last: false too, today's decode and memory exactly.
  2. No warm-stream cap. Risk: memory bounded only by admission (8 streams fit easily on a co-located B200). Fallback: set the cap; later streams stay stateless instead of failing. A free-memory check could follow.
  3. Sticky per-request routing as an invariant. Risk: a window on a copy without its state decodes as window 0, silently (true before this PR too). Fallback: document it; a follow-up carries the window index per chunk so this fails loudly.

Correctness

Per-frame PSNR vs fp32, Wan 2.2 VAE

Per-frame PSNR vs an exact fp32 decode, Wan 2.2 VAE, all 1,021 frames: M* before dips at chunk boundaries (worst 34.8 dB); this PR (in-tree node) tracks stock with no drift (all VAEs and references).

Exactness tiers

Whole 1,021-frame stream per configuration, fresh process, uint8 frames vs stock diffusers bf16 and an fp32 reference (TF32 off). These rows ran at =0 with cudnn.benchmark on; the default =1 issues the bit-exact tier's decoder calls and scores the same PSNR vs fp32 (55.40 / 54.42 dB, first 10 chunks). Full tables: RESULTS.md §8.

tier configuration evidence
bit-exact the warm cache alone (eager, default layout, one latent per call) byte-identical uint8 frames to one stock decode of the same input, both VAEs, full stream, and for windows of 1, 1+3+3+3+3+2, 1+2+5+4+3 and 8+6+1 latents
within bf16 rounding compile, channels-last, conv-internal padding, =0, cudnn.benchmark 9–16% of uint8 values differ from stock, ≤ 0.12% by two or more levels; PSNR vs fp32 within 0.45 dB of stock; worst-frame SSIM ≥ 0.9978; no drift, no seam
approximation the stateless context re-decode 30% of values differ from the whole-stream decode, up to 207 levels; worst frame 34.8 dB (Wan 2.2), at a window boundary

The stateless re-decode's difference is the expected price of 8 latents of context (windowed_decode_context_latents exists to raise it); with context covering the whole stream it is exact too (test_warm_windows_are_the_whole_stream_decode).

Videos

clip what to look for video
Wan 2.2 face close-up, 5 s, with a 2× face zoom M* before below 40 dB from 1.5 s (chunk 3) on, worst 32.8 dB at 1.5 s; this PR's worst 53.5 dB ▶ mp4, 22 s, 71 MB
Cosmos3-Edge robot arm, 10 s, image-to-video from the model card's example (the path this PR changes) M* before's error from 2.5 s (window 3), worst 34.5 dB at 5.3 s; this PR's worst 49.4 dB ▶ mp4, 37 s, 69 MB
Wan 2.2 waves, 5 s M* before below 40 dB from 1.5 s (chunk 3) on, worst 27.0 dB at 2.0 s; this PR's worst 50.1 dB ▶ mp4, 22 s, 78 MB
Wan 2.2 fox, 5 s M* before's error from 1.5 s (chunk 3), worst 35.8 dB at 2.0 s; this PR's worst 56.7 dB ▶ mp4, 22 s, 75 MB
Frame-arrival timeline time to first frame and per-window cadence 📈 png

Top row should look identical; the ×10 heatmaps and the PSNR plot show where M* before differed (PSNR = image quality vs the exact decode, higher is closer).

Reference: fp32 (Cosmos3) or stock bf16 (Wan) whole-stream decode. Cosmos3 windows count from 1, Wan chunks from 0; a red tag marks M* before below 40 dB. Each video opens with a 4 s what-to-look-for card, plays at 0.5×, holds 2.5 s on M* before's worst frame, then replays at 1×.
Silent (neither model makes sound). Prompts, seeds, lossless frames: README.

Testing

ruff and 75 targeted + 277 related CPU tests pass (1 failure, also on main). CI's CPU job now installs diffusers, so the two new test files run there.

Test list
  • ruff check . passes.
  • pytest test/modular/test_wan_vae_decode.py mstar/model/cosmos3/tests/test_windowed_decode.py mstar/model/cosmos3/tests/test_windowed.py (torch 2.12.1, diffusers 0.39): 75 passed. They cover the warm stream bit-exact against one vae.decode (test_warm_cache_stream_is_the_whole_stream_decode, test_warm_windows_are_the_whole_stream_decode); cache release after the last window (test_cache_is_freed_after_the_last_window) and on abort through cleanup_request (test_cache_goes_with_an_aborted_request); resumed sessions; the cap (overflow streams stay stateless, 0 is no cap, the slot frees when a stream ends or aborts, and the cap check survives concurrent request removal); the compact cache under a real Inductor compile on CPU (test_compiled_channels_last_stream_cache_owns_its_storage); the whole-clip decoder left as on main; and cudnn.benchmark opt-in and scoped to the streaming decode, with the context manager restoring the flag when its body raises (test_cudnn_benchmark_is_scoped); a decode that raises mid-window dropping the cache (test_a_failed_window_drops_the_cache); a stream's first window running one latent per call at =0 (test_resumed_first_window_is_not_grouped); and the config checks on the five new keys.
  • pytest mstar/model/cosmos3/tests mstar/model/wan22 test/modular/test_cosmos3_edge_model.py test/modular/test_wan22_*.py test/modular/test_wan_vae_decode.py: 277 passed, 37 skipped, 1 failed. The failure, test_video_postprocess_falls_back_to_pyav, fails the same way on main.
  • CI's CPU core job now installs diffusers==0.39.0, so the two new test files on test/cpu-core.txt run there (54 tests, about 40 s on CPU, tiny random models, no weights).
  • GPU: Cosmos3-Edge served end to end on one B200 (1, 4 and 8 streams), every decoder table on one B200, and a cross-check on one H200 (results/h200).

Reproduce and data

bash run_all.sh (README); served rollouts, raw data, every table: results/INDEX.md on the results branch (outside this diff).

Commands

Decoder alone, from this branch. The input is a 6.5 s 4K natural video clip (hands drawing on a tablet), cropped to 832×480, ping-ponged to 1,021 frames and encoded with each VAE's own encoder; prepare_latents.py --video PATH encodes any clip the same way (--random without one). Timing doesn't depend on the latent values; PSNR does.

cd benchmark/wan_vae_decoder   # uses $MSTAR/.venv
GPU=0 VIDEO=/path/to/clip.mp4 WAN22=1 bash run_all.sh
# fp32 reference, stock diffusers bf16, mstar_step0..2, mstar_warm (defaults), mstar_warm_lpc0

Served rollouts, from the results branch (e2e-cosmos3-edge):

E2E_DIR=~/e2e-cosmos3 bash scripts/run_all.sh before default   # layout in that README
python scripts/analyze.py ~/e2e-cosmos3/runs out/

The 3-run tables come from run_ablation.sh ladder on the results branch; the comparison from external/. results/INDEX.md lists each dataset (B200 main, H200 cross-check) with its GPU, software stack, code commit, run count and the table it feeds. More: samples, where the time goes, things that did not help.

Details

End-to-end setup and tables

configs/cosmos3_edge.yaml as shipped (all nodes in one worker), nvidia/Cosmos3-Edge @ 344d602b, image-to-video /generate streaming at 832×480, 241 frames, 20 steps, guidance 6.0, seed 0. kv: 8 windows of 8 latents; chained: 10 windows with 2 latents of overlap. before is main 99f5108b; this PR is the PR at 913295e4 (compact cache, cudnn.benchmark off) at its defaults (=1) and with =0. Medians of 3 kv / 2 chained rollouts after a warm-up, one B200. The DiT takes M*'s fallback from dense to flashinfer attention (the fa3-fwd wheel is sm90-only) in every variant. Setup and per-phase data: this PR, pre-fix. A second session on another B200 (e2e-cosmos3-edge-rerun) agrees: kv 14.26 → 11.93 s, chained 11.89 → 9.04 s.

timing before this PR, =1 (default) this PR, =0 pre-fix =1 pre-fix =0
kv: time to first frame 1.43 s 1.39 s 1.53 s 1.38 s 1.61 s
kv: total rollout 14.27 s 11.91 s (−16.5%) 11.94 s 11.88 s 12.01 s
kv: delivered fps 16.9 20.2 — — —
kv: DiT / decoder per window 1,287 / 534 ms 1,294 / 236 ms 1,292 / 237 ms 1,287 / 232 ms 1,294 / 244 ms
kv: decoder share of wall time 29.9% 15.8% — — —
chained: time to first frame 1.35 s 1.36 s 1.47 s 1.32 s 1.53 s
chained: total rollout 11.85 s 9.11 s (−23.1%) 9.09 s 9.01 s 9.13 s
kv, 4 streams: round wall / aggregate fps 65.0 s / 14.8 55.0 s / 17.5 55.1 s / 17.5 55.1 s / 17.5 55.6 s / 17.3
kv, peak allocated above idle before this PR, =1 (default) this PR, =0 pre-fix =1 pre-fix =0
1 stream 5.3 GiB 4.4 GiB 18.9 GiB 6.3 GiB 36.1 GiB
4 streams 5.4 GiB 8.4 GiB 22.9 GiB 16.8 GiB 92.3 GiB
8 streams 5.5 GiB 14.6 GiB 28.3 GiB 31.7 GiB out of memory, 7 of 8 streams, at 175.6 GiB allocated
growth per extra stream (1 → 4) 0.03 GiB 1.33 GiB 1.33 GiB 3.50 GiB 18.73 GiB

pre-fix is the PR at 60fbc5cd, before the compact-cache fix, with cudnn.benchmark on.

  • Each variant's own idle is subtracted (the PR's is higher by the fixed decoder copy in the Summary). Growth per stream is exactly the Wan 2.2 cache; the one-call transient (about 3 GiB at =1) is paid once.
  • With the compact cache, =0 fits 8 streams, but it is still no faster end to end (11.94 vs 11.91 s; 17.5 fps either way) and was 0.14 s later to the first frame (0.22 to 0.23 s before the fix): DecoderStep runs a stream's first two decoder calls eagerly, and at =0 the second was the first window's whole 7-latent decode. A stream's first window now runs one latent per call at =0 too (032362aa); in a later B200 check that cut =0's first-frame lag behind the default from 0.15 to 0.01 s. So =1 stays the default.
  • The latents the decoder receives are bit-identical across before, pre-fix and this PR at =1, so every frame difference comes from the decoder. This PR at =1 scores 53.46 dB (kv) and 52.33 dB (chained) against the fp32 whole-stream decode, the same as pre-fix; main scores 46.52 and 41.34 dB, with its error starting at window boundaries from window 3 on.
Design

The stateless re-decode costs about 2× the useful work on the served 8-latent kv windows and 3.4× on the 3-latent benchmark chunks.

Warm path. A stream's first window runs the same context decode, but against a cache the node owns (new_feat_cache, decode_causal): the last frames each causal conv saw, kept in the request's state as ar_feat_cache next to the ar_tail and ar_chunks the node already keeps. Each later window decodes only its new latents and updates the cache in place.

Cache lifecycle

Lifecycle. Created on the stream's first window if the copy is below the cap (decided once per stream). Freed when the last window is decoded, or with the rest of the request state when the engine removes the request (cleanup_request from Engine.remove_request: abort, failure, teardown). Tests cover the last-window and abort releases; a failed request goes through the conductor's same removal path as an abort. A decode that raises mid-window drops the half-updated cache at once, so a retried window re-decodes behind its context. Sessions are unchanged: the SessionStore keeps only the 8-latent tail, and a resumed session's first window is the context re-decode behind it.

Routing. Before and after this PR, the conductor binds each request to one copy of every component at admission (_assign_worker_graphs_to_workers) and keeps it for the request's life. The decoder already relies on that for ar_tail, ar_chunks and ar_pixels; the cache adds no new routing requirement, and a stream's windows never move between copies.

Per-change gains

ladder

One more switch per step, compiled, n = 3 fresh-process runs per row; green is the default configuration, orange the opt-in switches. Conv-internal padding comes with step 2. Eager rows, kernel counts and TFLOP/s: RESULTS.md §1.

step change Wan 2.2 ms (×) Wan 2.1 ms (×) peak GiB (2.2 / 2.1) PSNR vs fp32, first 10 chunks (2.2 / 2.1)
0 M* before: stateless context re-decode 372.08 (1.0×) 338.37 (1.0×) 6.4 / 6.3 51.08 / 52.09
1 + warm conv cache 106.87 (3.5×) 96.98 (3.5×) 6.3 / 6.2 55.39 / 54.42
2 + channels-last, conv-internal padding 77.51 (4.8×) 61.98 (5.5×) 7.4 / 8.2 55.40 / 54.42
3 + one decoder call per window (=0, opt-in) 72.16 (5.2×) 59.26 (5.7×) 15.9 / 19.0 55.40 / 54.42
4 + cudnn.benchmark (opt-in) 72.78 (5.1×) 55.88 (6.1×) 15.9 / 19.0 55.40 / 54.42

Step 2 is the default configuration. The ladder ran before the compact-cache fix (+1.7 ms per chunk); the defaults row in the Summary table was re-timed after it (RESULTS.md §0).

Full comparison table

Same latents, reference, protocol and timing, one B200; each engine's own streaming decoder, loader and documented flags. M* before and the opt-in row are the 3-run ladder above; the defaults row was re-timed after the compact-cache fix (2 runs, RESULTS.md §0). Full table (defaults, compile, fp32 serving paths), kernel profiles and harnesses: RESULTS.md §7, external/.

engine setting Wan 2.2 ms Wan 2.1 ms PSNR vs fp32, whole-stream mean (2.2 · 2.1)
M* before stateless context re-decode, compiled 372.08 338.37 50.11 · 51.76
diffusers 0.39 stock, eager bf16 184.57 191.98 55.12 · 54.84
vLLM-Omni f69b1f2b best (--vae-fast-path channels_last) 77.85 64.08 55.02 · 54.82
SGLang v0.5.21 best (--quality extra-high, bf16) 94.36 92.50 56.65 · 55.16 †
M* this PR defaults (=1, lowest memory, the same decoder calls as vae.decode) 79.5 64.5 55.40 · 54.42 (first 10 chunks)
M* this PR one decoder call per window (windowed_decode_latents_per_call=0 + cudnn.benchmark, opt-in) 72.78 55.88 55.27 · 54.40

† SGLang converts to uint8 from fp32; stock diffusers converted the same way scores the same, so its higher PSNR comes from the conversion, not the decode. Both other engines ran torch 2.13 (M*: 2.12.1).

Memory
  • Fixed cost: with channels-last on, the streaming node keeps its own decoder-only copy of the VAE (the Summary's fixed cost; measured as the higher idle) in a process that hosts both decoder nodes, as in both shipped configs. Two weight layouts can't share one copy while the whole-clip decoder keeps main's layout.
  • Per-stream cache: one per live warm stream on its decoder copy (size in the Summary, Wan 2.2 at 832×480 in bf16; fixed, independent of stream length), outside any engine resource pool. The shipped configs set no max_concurrent_requests, so with no cap the total is bounded only by admission. At 8 streams (8 caches plus one ~4 GiB transient) the shipped Cosmos3-Edge config peaks at about 44 GiB allocated of the B200's 178 GiB.
  • Compact under torch.compile. Inductor removes diffusers' clone() of each cached slice on channels-last activations, which left every slot a view of its conv's whole input buffer. Each slot is now written as a compact copy, so a stream holds exactly its logical cache, as vLLM-Omni and SGLang do. Output is byte-identical; the copy costs +1.7 / +2.2 ms per chunk (Wan 2.2 / Wan 2.1).
  • windowed_decode_latents_per_call sets one decode's transient peak: on a served window =0 is 74.3 vs 79.4 ms per 12 frames for about 10 GiB more peak, and end to end no faster (End-to-end above). Served windows before the fix: RESULTS.md §2b; the pre-fix sweep over 1, 2, 3 and 0 latents per call: lpc.csv.
Wan 2.2, 832×480, one B200 before the fix with it
served, growth per extra stream, =1 (default) / =0 (end to end) 3.50 / 18.73 GiB 1.33 / 1.33 GiB
served 8-latent window, peak, =1 / =0 (§0) 7.39 / 28.70 GiB 5.48 / 15.86 GiB
3-latent chunk, peak, =1 / =0 (§0) 7.39 / 15.93 GiB 5.48 / 9.61 GiB
Related, not in this PR
Samples: still frames

waves: stock, M* before, this PR, ×10 difference heatmaps

Rows: stock diffusers, M* before, this PR, then ×10 difference against stock (black = identical). M* before matches stock only while the stream still fits its 8-latent context (chunks 0–2, frames 0–36, up to 1.5 s), then its error appears from chunk 3 (1.5 s) on (worst frame 27.0 dB at frame 49, 2.0 s, in chunk 4); this PR stays near black throughout (within bf16 rounding of stock), with no drift.

Side-by-side clips and per-step metrics: samples. "this PR" in the clips is the opt-in =0 + cudnn.benchmark step; the README lists the defaults too.

Checklist

  • ruff check . passes
  • Added or updated tests / docs where relevant

The streaming decoder (Cosmos3VAEDecoderARSubmodule) re-decoded the last 8
latents of the stream ahead of every window from an empty causal-conv cache,
so each window paid for 11 latents to keep 3, and the result only
approximated one decode of the whole stream. On one B200 (Wan 2.1 VAE,
3-latent chunk, bf16, compiled) that is 335 ms per chunk.

- A live stream keeps the decoder's causal-conv cache in its request state
  (windowed_decode_warm_cache, on by default). A window decodes only its new
  latents, and the frames are those of one decode of the whole stream. The
  cache is freed after the last window or when the request goes away;
  sessions still store only latents. A stream without a cache (first
  window, resumed session, switch off) takes the old re-decode once.
- The decode runs on channels-last weights and activations
  (vae_decode_channels_last, both decoder nodes), with the H/W zero padding
  done inside the conv, so no conv pays a layout conversion.
- A window's new latents go through the decoder in one call
  (windowed_decode_latents_per_call=0; 1 restores per-latent calls). Peak
  activation memory grows with the latents per call.
- cudnn.benchmark is set around the VAE decode only
  (vae_decode_cudnn_benchmark) and restored afterwards.
- The wan22 node gets the channels-last and cuDNN switches. Compiling its
  decoder step is available (compile_vae_decode) but stays off.

The shared pieces live in mstar/model/components/wan_vae.py. CPU tests cover
the warm-cache stream against vae.decode for both decoder families (bit for
bit in fp32, including a lone clip-start latent and a ragged tail), the
first-window re-decode and cleanup paths, concurrent streams, and the layout
switch.
Each warm stream keeps its decoder cache (1.76 GiB for the Wan 2.1 VAE,
1.33 GiB for Wan 2.2 at 832x480 in bf16) outside any engine resource pool.
windowed_decode_max_warm_streams (default 0, no cap) bounds them per decoder
copy: a stream that starts while that many streams hold a cache takes the
stateless context re-decode for its whole life. The choice is made at the
stream's first window, so a stream never switches paths. A debug log
reports the live warm streams and their cache bytes when one starts or
ends.

The windowed configs carry the key commented out, and the
windowed_decode_latents_per_call comment now states its memory trade-off:
one decoder call per window is about 6% faster at 832x480 for a 21-27 GiB
larger transient peak. The docstrings say what the first-window re-decode
covers (a stream's first window, fresh or resumed) and that a stream stays
on one decoder copy.

CPU tests: with the cap reached a second stream stays stateless and matches
the stateless path while the first stays warm, 0 means no cap, and a slot
frees when a stream ends or is aborted.
@arun-elr
arun-elr force-pushed the perf/wan-vae-decode branch from 7a1b4cf to 60fbc5c Compare October 13, 2026 19:12
Measures the Wan VAE decode the way a streaming rollout uses it: a latent
stream encoded from a natural clip, 3-latent chunks (12 frames at 480x832),
10 warm-up and 75 timed chunks replayed twice, median ms per chunk, one
variant per fresh process with fresh compile caches, PSNR against an fp32
TF32-off reference.

bench_wan_decoder.py runs the fp32 reference, stock diffusers, and M*'s
streaming decoder node with this change's switches turned on one at a time
(mstar_step0 is the stateless context re-decode, mstar_warm all switches
on); run_all.sh runs that ladder for both VAEs and prints ms per chunk, peak
memory and PSNR.

The prototype harness, ablations, output-equivalence checks, profiles,
charts, the vLLM-Omni / SGLang harnesses and all results are on the
perf/wan-vae-decode-results branch.
@arun-elr
arun-elr force-pushed the perf/wan-vae-decode branch from 60fbc5c to daa4414 Compare October 13, 2026 19:57
One decoder call per latent is now the default for the streaming decode:
the same decoder calls as vae.decode (byte-identical eagerly in the default
layout), and the smallest transient peak. Decoding a window's new latents
in one call (0) stays available.

Served end to end on one B200 (cosmos3_edge.yaml, Wan 2.2 VAE, the DiT on
the same GPU, 241 frames in 8 windows), 1 was as fast as 0 or faster
everywhere (rollout 11.88 vs 12.01 s, first frame 1.38 vs 1.61 s), and at 8
concurrent streams 0 ran out of memory in 7 of them while 1 completed all 8
at 60.2 GiB. On the decoder alone (served 8-latent windows at 832x480,
Wan 2.1 VAE) 1 takes 60.0 ms per 12 frames at an 8.2 GiB peak, 0 56.4 ms at
35.1 GiB.

The config comment lists 1 / 2 / 3 / 0, the windowed configs carry the key
commented out, the tests treat 0 as the opt-in, and the benchmark's
mstar_warm follows the config defaults (mstar_warm_lpc0 is the opt-in).
@arun-elr arun-elr changed the title perf(wan): 6x faster streaming Wan VAE decode perf(wan): 2.3-6x faster streaming Wan VAE decode Oct 13, 2026
…aming decoder

vae_decode_cudnn_benchmark now defaults to False: the timed algorithm pick
can differ between launches, and it gains nothing on the Wan2.2 VAE.
The wan22 node and the whole-clip Cosmos3 decoder behave as on main: the
streaming node converts its own decoder-only VAE copy to channels-last
instead of the shared instance, and only its decodes take cudnn.benchmark.
List the new tests in test/cpu-core.txt (they skip without diffusers).
The cap check runs on the worker's GPU thread while its main thread can
remove request states; iterating the live dict could raise.
Inductor drops diffusers' clone of each cached conv-input slice on
channels-last activations, so a compiled call left every cache slot as a
view of its conv's whole input buffer and a stream held 2-5x its logical
cache. Cache slots are now written as compact copies (FeatCache), and
compact_feat_cache catches any slot that still comes back as a view.
mstar/model/components/wan_vae.py imports diffusers at module level, and a
top-level import of it made mstar.model.cosmos3 unimportable without
diffusers (CI's CPU job collects test_model_kwargs_tensor_transport.py,
which imports Cosmos3Model). Only the streaming decode paths load it now.
… warm path is bit-exact

latents_per_call=0 costs ~10 GiB more peak on the served window after the compact cache (was ~21).
The warm path is bit-identical to a whole-stream decode without compile at =1 (as the CPU tests
assert), and matches it to bf16 rounding under compile.
@arun-elr
arun-elr marked this pull request as ready for review October 14, 2026 01:43
… results branch

The directory alone reproduces the headline; the results-branch link is for the full data.

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read the change and the benchmark harness, ran the new tests (63 pass with diffusers 0.41), and measured it on H200s against the merge base. The numbers hold up and the output is right, so approving. Details:

  • Exactness, real Wan 2.2 weights. The warm windowed decode (eager, default layout, one latent per call) is byte-identical to one stock vae.decode of the whole stream for windows of [1,3,3,3,2], [4,4,4] and [1,11] latents (45 frames at 480x832), and the per-stream cache is 1,425,216,000 bytes, the 1.33 GiB the description states. Channels-last eager differs by at most 4 of 255 levels (56.1 dB vs the whole-stream decode), compiled by at most 6 (54.6 dB), and main's stateless re-decode by up to 8 levels on the same random latents.
  • Decoder alone, in-tree harness, Wan 2.2, 3-latent chunks. Main's method 620 ms per chunk (620.5 ms with main's own code run through the harness), this PR's defaults 144.8 ms, so 4.3x on an H200 against 4.7x on your B200. Stock diffusers bf16 sits at 279 ms. PSNR vs fp32 is 54.5 dB here against 51.1 dB for the stateless path. Peak memory 5.48 GiB at the defaults, 9.61 with latents_per_call=0.
  • Served Cosmos3-Edge, shipped config, 241 frames at 20 steps, 832x480, both trees back to back on one GPU. kv rollout 14.4 s vs 18.2 s (medians of 3, -21%), chained 12.9 s vs 17.5 s (-27%), time to first window 1.9 s on both, four concurrent streams 67 s vs 82 s per round (-19%), eight streams 134 s vs 165 s. Aborted streams (closed after 3 windows) leave nothing behind on either tree, and repeat runs are byte-identical within each tree. Reserved GPU memory on the branch grows with the peak number of concurrent streams (39.1 GB idle, 42.8 after 4, 49.7 after 8, which is 8 caches) and then stays flat through aborts and further rollouts, so the caches go back to the allocator. Main stays at 38.9 to 41.7 GB.
  • 8 streams. One of three branch rounds lost 3 of 8 streams to the UniPC assert self.this_order > 0 in the DiT scheduler. The other two branch rounds and all three main rounds ran 8 of 8. That matches the pre-existing, load-dependent assertion the description lists; nothing here points at the decoder.
  • wan22 node. Same picture on both trees for a seeded 9-frame 256x256 clip at 4 steps, and within one server launch every request after the first is byte-identical on both trees (about 0.26 s a request on both once warm). Pixel parity between the trees cannot be measured end to end on this stack: the first request of a launch, and each new launch, render the same seeded sample a little differently (around 20 dB between renderings, the same on main), so the whole-clip decoder rests on the harness exactness check above.
  • One observation about the served output, not about this PR. With the shipped config the kv windowed rollout drifts away from the conditioning image within the first two windows on this stack and is noise by frame 60, on main and on the branch alike (same latents feed both decoders, and the two trees' frames show the same content). The chained rollout keeps the scene but smears it more with every window. A non-windowed 121-frame clip stays on the scene, and a prompt that matches the image (a dashcam drive along a coastal road) changes nothing, so it is not the prompt. I would look at the windowed DiT path separately.

Notes:

  • The 4.7x decoder headline is mostly work reduction: the 3-latent benchmark chunk decodes 11 latents on main and 3 here, so 3.4x of it is fewer frames decoded. The served 2.3x per window is the number that matters, and the description does say so lower down. Worth leading with it.
  • The per-stream cache is 1.33 GiB only in bf16. An fp32 decode path doubles it, and the comments state 1.33 unconditionally.
  • With windowed_decode_latents_per_call=0 the context re-decode is grouped too, so a resumed stream's first window (up to 13 latents) is one eager call, well above the documented peak for that opt-in. Grouping only steady windows would keep the documented peak honest.
  • An exception inside a window leaves a half-updated cache in the request state. Nothing retries today, so it is harmless, but a try/except that drops the cache would make it safe if that changes.
  • None of the five new config keys is validated (a negative cap, latents_per_call above the window).
  • The two new test files are on the CPU core list but CI's core job installs no diffusers, so they never run there (42 tests). The description notes this as a follow-up; it is worth doing with this PR so the bit-exact tests guard the code they cover.
  • The in-tree bench reproduces the 3-latent rows. The served-window row, the whole-stream PSNR and the byte-identical check are on the results branch only.

else:
if st["ar_warm"]:
feat_cache = _wan_vae().new_feat_cache(self.vae)
pixels = self._decode_stream(stream_tail, feat_cache, clip_start=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

At windowed_decode_latents_per_call=0 this first call groups the whole stream_tail, so a resumed stream's first window (8 latents of context plus the new ones, up to 13) goes through the decoder in one eager call, well above the peak the config comment quotes for =0. Grouping only the steady windows, or documenting the resumed-first-window peak, would keep that opt-in honest. Also worth a try/except that drops ar_feat_cache if a decode raises mid-window, so a half-updated cache cannot be continued if a retry ever lands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both done:

  • =0 grouping: only steady windows are grouped now. A stream's first window, fresh or resumed (context plus new latents, up to 13), runs one latent per call like the default, so the documented =0 peak holds. A test checks the call sizes. The cost is one more compiled decoder graph on a =0 server's first stream.
  • Failed window: if a warm window's decode raises, ar_feat_cache is dropped before the exception propagates. ar_tail and ar_chunks haven't advanced, so a retry re-decodes behind its context like a first window, then carries on warm. A test injects the failure.

# re-decode for its whole life. The caches sit outside any engine resource
# pool, and the shipped configs set no max_concurrent_requests, so with no
# cap their total is bounded only by how many streams are admitted.
windowed_decode_max_warm_streams: int = 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two small things on the new keys: nothing validates them (a negative cap behaves like no cap, latents_per_call above the window size silently groups everything), and the cap is a count that is blind to resolution, so the 1.33 GiB per stream it implies holds only at 832x480 in bf16 (an fp32 decode path doubles it; the comments state 1.33 unconditionally).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fixed:

  • Validation: the five keys are checked at load, after the yaml overrides. A ValueError names the key when a switch isn't a boolean (a yaml "false" string used to read as true), when the cap or latents_per_call isn't an integer >= 0, or when latents_per_call is larger than the default window (8 latents at 29 frames). Tests cover each case and a model load with a bad override.
  • Cache size: the comments now say 1.33 GiB holds at 832×480 in bf16, grows with frame area and doubles in fp32. The cap counts streams, not bytes, so size it from the per-stream cache at the served resolution and dtype. A byte budget would track resolution automatically; I've left that for later.

@NSagan271

Copy link
Copy Markdown
Collaborator

@arun-elr Overall looks good, quick question: could the Wan 2.2 model in the system also reuse the same decoder to get performance gains?

1.33 GiB holds at 832x480 in bf16; an fp32 decode (cuDNN before 9.16, or
COSMOS3_VAE_DECODE_FP32) doubles it, and the warm-stream cap counts streams,
not bytes.
At windowed_decode_latents_per_call=0 a stream's first window grouped its whole
decode input into one eager call: 7 latents for a fresh stream, up to 12 behind
a resumed session's context, above the peak documented for =0. The first window
now runs one latent per call, as the default does.
The cache is updated in place, so a failed decode left it half-advanced in the
request state. Nothing retries a window today; if something does, it now
re-decodes behind the window's context instead of continuing a broken cache.
The three switches must be booleans (a yaml "false" string was truthy), the
warm-stream cap and latents per call integers >= 0 (a negative cap read as no
cap), and latents per call no more than the default window's latent frames
(more read as one call per window).
The Wan VAE decode tests on the core list import diffusers and were skipped in
CI. They build tiny random models (no weights) and take about 40 s on CPU.
@arun-elr arun-elr changed the title perf(wan): 2.3-6x faster streaming Wan VAE decode perf(wan): 2.3x faster streaming Wan VAE decode Oct 14, 2026
@arun-elr

arun-elr commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Overall looks good, quick question: could the Wan 2.2 model in the system also reuse the same decoder to get performance gains?

@NSagan271 Thanks! Yes, the wan22 model can reuse the channels-last layout and compiled decoder from this PR. Its whole-clip path doesn't need the cross-window cache, since vae.decode already keeps a causal cache within the call. In a decoder-only benchmark on an H200 (121 frames at 1280x704), decode time went from 5.92 s to 3.41 s, about 1.7x, with similar output quality and peak memory. I'm measuring the same on a B200, plus the end-to-end effect, now. Happy to do this as a follow-up PR to keep #425 scoped.

Measured on B200 serving cosmos3_edge.yaml at 832x480 in bf16: 48.50 GiB peak
allocated at windowed_decode_latents_per_call=0 against 33.95 GiB at the
default, not the ~10 GiB the comment gave. Comment-only.
@arun-elr

Copy link
Copy Markdown
Contributor Author

Thanks @merceod, especially for re-measuring on H200s and the exactness check on real Wan 2.2 weights. Changes in this round:

  • Lead with the served number. The title and summary now say 2.3× per served window (534 → 236 ms). The decoder-alone table notes that most of its 4.7× is work not done (3.4× from decoding 3 latents instead of 11).
  • Cache size wording. The comments say 1.33 GiB per stream holds at 832×480 in bf16, grows with frame area and doubles in fp32, and that the cap counts streams, not bytes.
  • =0 peak. Only steady windows are grouped. A stream's first window, fresh or resumed, runs one latent per call, so the documented peak holds.
  • =0 memory note. The config comment now says about 15 GiB more peak end to end, as measured.
  • Failed window. A decode that raises mid-window drops ar_feat_cache, so a retry re-decodes behind its context. Covered by a test.
  • Config validation. The five new keys are checked at load, with a ValueError naming the bad key. Covered by tests.
  • Tests in CI. The CPU core job installs diffusers==0.39.0, so the 54 Wan VAE decode tests now run there (about 40 s, tiny random models, no weights).

Deferred:

  • Served-window bench, whole-stream PSNR and byte-identical check stay on the results branch, since they need the Cosmos3 checkpoint and the full fp32 reference. Happy to move bench_decode_nodes.py in-tree if you'd like the served-window row reproducible from this PR.
  • kv windowed drift you spotted is in the DiT path on main too, so I filed it as a separate issue, Cosmos3-Edge: kv windowed rollout drifts off the conditioning image within two windows #434, with what you found and a first step. Happy to take it.
  • 8-stream UniPC assertion is the pre-existing one listed under Related.

@arun-elr

Copy link
Copy Markdown
Contributor Author

@NSagan271 Follow-up with the B200 numbers. Decoder only (121 frames at 1280x704), the wan22 node goes from 3.97 s to 1.74 s, about 2.3x, with similar output quality and peak memory. End to end, one real wan22 request (50 steps) goes from 48.0 s to 45.8 s with a prototype of the node using this decoder, as decode drops from 8.9% to 4.4% of the request. I'll send a follow-up PR shortly.

@NSagan271
NSagan271 merged commit 641162a into mstar-project:main Oct 14, 2026
4 checks passed
@arun-elr

Copy link
Copy Markdown
Contributor Author

@NSagan271 the follow-up for the wan22 node is up in #437.

merceod pushed a commit that referenced this pull request Oct 18, 2026
… the #425 decoder (#437)

The wan22 VAE decoder node reuses the shared Wan VAE decoder from #425 for untiled decodes at the listed sizes (vae_decode_fast, default on; vae_decode_fast_sizes, default 1280x704): channels-last, compiled DecoderStep, 1.74x faster decode on an H200. Other sizes and tiled decodes switch the VAE back to its loaded layout and run stock vae.decode, byte-identical to the off switch with no compile. The compiled decode runs in Inductor's deterministic mode so every server process decodes the same latents to the same frames.
dghosef added a commit to dghosef/mstar that referenced this pull request Oct 21, 2026
The fp32 Wan2.1 VAE decode was a third of a chunk. Channels-last weights and
activations (the mstar-project#425 path, already wired as vae_decode_channels_last) plus
cudnn.benchmark around the decode only: 547 -> 340 ms per 16-frame chunk at
480x832 on an H200, still fp32. Frames differ from the contiguous decode by
conv accumulation order only (69.4 dB, max 2 uint8 levels).

Served A/B, H200, 93 frames, 2 rounds x 2 reps ABBA with the DiT change:
9.60 -> 11.34 fps, first chunk 1.51 -> 1.30 s; both arms deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit to dghosef/mstar that referenced this pull request Oct 21, 2026
The fp32 Wan2.1 VAE decode was a third of a chunk. Channels-last weights and
activations (the mstar-project#425 path, already wired as vae_decode_channels_last) plus
cudnn.benchmark around the decode only: 547 -> 340 ms per 16-frame chunk at
480x832 on an H200, still fp32. Frames differ from the contiguous decode by
conv accumulation order only (69.4 dB, max 2 uint8 levels).

Served A/B, H200, 93 frames, 2 rounds x 2 reps ABBA with the DiT change:
9.60 -> 11.34 fps, first chunk 1.51 -> 1.30 s; both arms deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit to dghosef/mstar that referenced this pull request Oct 21, 2026
The fp32 Wan2.1 VAE decode was a third of a chunk. Channels-last weights and
activations (the mstar-project#425 path, already wired as vae_decode_channels_last) plus
cudnn.benchmark around the decode only: 547 -> 340 ms per 16-frame chunk at
480x832 on an H200, still fp32. Frames differ from the contiguous decode by
conv accumulation order only (69.4 dB, max 2 uint8 levels).

Served A/B, H200, 93 frames, 2 rounds x 2 reps ABBA with the DiT change:
9.60 -> 11.34 fps, first chunk 1.51 -> 1.30 s; both arms deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit to dghosef/mstar that referenced this pull request Oct 21, 2026
The fp32 Wan2.1 VAE decode was a third of a chunk. Channels-last weights and
activations (the mstar-project#425 path, already wired as vae_decode_channels_last) plus
cudnn.benchmark around the decode only: 547 -> 340 ms per 16-frame chunk at
480x832 on an H200, still fp32. Frames differ from the contiguous decode by
conv accumulation order only (69.4 dB, max 2 uint8 levels).

Served A/B, H200, 93 frames, 2 rounds x 2 reps ABBA with the DiT change:
9.60 -> 11.34 fps, first chunk 1.51 -> 1.30 s; both arms deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

3 participants