FlashVSR: From 17.2 FPS to 57.3 FPS at 768x1408#108
Open
hamuzhan wants to merge 35 commits into
Open
Conversation
The LQ-projector CausalConv3d kernels (4x3x3, stride (2,1,1), large channels)
run at ~27 TFLOP/s (~2.8% peak) on GH200 because cuDNN can't pick a tensor-core
implicit-GEMM for these shapes. They account for ~50% of denoise time.
Reformulate the core convolution as explicit im2col (unfold) + bf16 GEMM, which
saturates Hopper WGMMA tensor cores (~400+ TFLOP/s, ~15-17x faster) with
bit-identical math (parity cosine 0.999996).
- Phase 1 (D1): _conv3d_gemm + FLASHVSR_CONV3D_BACKEND={auto|gemm} knob +
sm_90 guard + silent fallback. Causal replicate-pad + streaming cache
preserved; only the padding-free core conv is rerouted. Single-point change
benefits conv1/conv2 in both Buffer_/Causal_ projectors.
- Phase 2 (D1.5): chunked im2col over the output-H axis bounded by
FLASHVSR_CONV3D_IM2COL_BUDGET_GB (default 2 GB) -> conv transient mem -51%
at 1920x2560 with no speed/parity loss.
E2E (v1.1 Tiny): 1.92x @1536, 1.91x @2560x1920; norm-FPS ~17 -> ~33-34;
PSNR(auto,gemm) 47.6-49.5 dB. Default 'auto' = no regression.
Also fix transformers PretrainedConfig import (renamed -> PreTrainedConfig)
so the pipeline import chain works on current transformers.
Compares GH200 against the README's A100 reference (~17 FPS @ 768x1408) at the exact same resolution, with both conv3d backends. Results @ 768x1408: - GH200 auto (cuDNN): 16.54 FPS (0.97x A100 - parity, no Hopper gain) - GH200 gemm (tensor-core): 31.56 FPS (1.86x A100) - gemm vs auto: 1.91x Confirms the thesis: before the im2col+GEMM backend, GH200 was stuck at A100 parity despite 3x the hardware; the tensor-core conv path unlocks the real Hopper advantage.
After the conv3d im2col+GEMM work (1.86x A100), profiled the full denoise pipeline @768x1408 to find the next bottlenecks. GPU is ~91% busy (compute- bound). Breakdown: attention ~27%, GEMM ~21%, TCDecoder conv2d ~19%, norm/elementwise ~17%, copy/layout ~15%. 3-A) TCDecoder channels_last (NHWC): The TCDecoder is a pure Conv2d (TAEHV) graph running contiguous (NCHW), which made cuDNN insert nchwToNhwc/nhwcToNchw around every bf16 conv (~226ms /9%) and run the convs ~1.5x slower. Run the decoder in channels_last: - isolated decode: 231 -> 189 ms (1.22x), bit-identical (max|diff|=0) - E2E: 31.6 -> 32.9 FPS, 1.86 -> 1.93x A100, -0.5 GB peak Knob FLASHVSR_TCDECODER_CHANNELS_LAST (default on). 3-B) Adaptive attention backend (FLASHVSR_ATTN_BACKEND, default 'sparse'): Measured the real self-attn: seq=25344, 12 heads, dim=128, block-mask density ~0.606 (only 39% sparse). At that density cuDNN fused dense SDPA (6.5ms, 605 TFLOP/s) beats block_sparse (7.3ms) AND uses full context; FA2 dense is 10.6ms (cuDNN wins by 1.64x). Crossover is density ~0.5. Added density-adaptive routing (opt-in). E2E gain at default topk is negligible (~+0.5%) and dense changes the output (full vs trained sparse pattern, PSNR 41 dB), so default stays 'sparse' (no quality change). Tooling added (isolated + E2E): profile_e2e_bottlenecks.py, probe_attention_shapes.py, test_tcdecoder_channels_last.py, test_attention_backend.py, test_topk_sweep.py Note (not committed as default): topk/sparsity sweep shows sparse_ratio=1.5 gives 2.07x A100 @ PSNR 42.8 dB vs baseline (a documented 'faster' setting).
The DiT block spends ~17% of denoise GPU time in memory-bound elementwise kernels: RMSNorm (q/k, with an fp32 up/down cast), modulate (x*(1+scale)+shift) and the gate (x + gate*residual). Fuse each via torch.compile(dynamic=True). Isolated (dim=1536, seq=25344): RMSNorm 0.556 -> 0.263 ms (2.12x, cos 1.000000) modulate+gate 0.438 -> 0.313 ms (1.40x, cos 0.999993) E2E @768x1408 (conv3d=gemm, TCDecoder NHWC): 32.9 -> 35.5 FPS, 1.94 -> 2.09x A100, PSNR(off,on) 49.2 dB (bf16-level). These fused fns contain no attention / custom kernels, so they don't interact with the block_sparse path or streaming cache. Knob FLASHVSR_FUSE_NORM (default off, opt-in due to compile warmup + tiny bf16 reorder diff). Adds test_fuse_norm.py (E2E parity + speed).
The bundled block_sparse_attn CUDA kernel is FlashAttention-2 style and emits only Ampere HMMA tensor-core ops even when compiled for sm_90 (verified in SASS: 380928 HMMA, 0 WGMMA), reaching only ~33% of bf16 peak (~327 TFLOP/s) at the real self-attn shape (seq=25344, 12 heads, dim=128, block-mask density ~0.606). cuDNN dense reaches ~62% peak via WGMMA but can't express FlashVSR's 2D-spatial block mask (cuDNN block_mask unsupported on sm_90; 1D band masks don't cover a 2D-local pattern efficiently). Add a Triton block-sparse FlashAttention kernel that honors the exact per-(q_block, kv_block) boolean mask and compiles to Hopper WGMMA (verified in PTX/SASS). It uses a CSR-style per-q-block kept-kv-index list so masked tiles are skipped entirely. Isolated (vs the real block_sparse_attn kernel, same mask): cos 0.99999, max|diff| 0.0005; 7.36 -> 6.06 ms (1.21x), ~41% peak. E2E @768x1408 (conv3d=gemm, TCDecoder NHWC, fuse_norm on): 35.5 -> 38.0 FPS, 2.09 -> 2.23x A100; PSNR(sparse,triton) 49.97 dB (bf16-level). Opt-in via FLASHVSR_ATTN_BACKEND=triton, guarded to sm_90, silent fallback to the original block_sparse kernel on non-Hopper / triton-missing / any error. Default remains 'sparse' (zero regression; Ampere keeps the original path).
Add a Tensor Memory Accelerator (TMA) fast path to the Triton block-sparse attention kernel. Q/K/V tiles are loaded via device TMA descriptors (triton TensorDescriptor), overlapping bulk global->shared loads with WGMMA. Isolated (real shape 25344x12x128, density 0.606): 6.11 -> 5.6 ms, ~40% -> ~44% of bf16 peak, parity cos 0.99999 (math unchanged; TMA only changes how memory is fetched). E2E @768x1408 (conv3d=gemm, TCDecoder NHWC, fuse_norm on): 2.23 -> 2.29x A100; PSNR(sparse, triton+TMA) 49.97 dB. TMA is the default when available (Triton TensorDescriptor present); guarded by FLASHVSR_ATTN_TMA (set 0 to disable) and falls back silently to the non-TMA kernel on older Triton / SMEM limits / any error. Still gated to sm_90 via the attention backend; Ampere keeps the original block_sparse path.
Two opt-in, bit-identical caches for per-block elementwise results that are recomputed every denoise step but depend only on fixed inputs (not q/k/x): - FLASHVSR_CACHE_MOD (default 0): cache (modulation + t_mod).chunk(...) per DiTBlock and Head. t_mod is computed once in init_cross_kv and is constant. - FLASHVSR_CACHE_MASK_BIAS (default 0): cache the geometry-only 0/-inf additive bias in generate_draft_block_mask (repeat + masked_fill), keyed on shape only. Both default OFF, pure elementwise, silent fallback; verified max|diff|==0 on all test clips (test_cache_lossless.py). These remove ~2169 small kernels per denoise. Also: profile_e2e_bottlenecks.py categorize() now classifies the Triton _bsfa kernel as attention and xmma_fprop as TCDecoder conv.
Add a "Hopper Acceleration" section to the README so users can discover and enable the opt-in fast paths: the env var table (all default OFF), the recommended full-speed one-liner, and notes on sm_90 guarding and parity (bit-identical vs ~49-50 dB PSNR paths).
|
Dude you’ve really done an amazing job |
|
amazing would any of these work on other gpus too? like rtx 3000 4000 series? 5000 series? |
- FLASHVSR_NVTX-gated nvtx ranges (default OFF, zero-effect) across the DiT block, pipeline chunk loop and LQ projector for nsys/ncu attribution - FLASHVSR_PROFILER_START/STOP_CHUNK cudaProfiler window in flashvsr_tiny - pipeline now honours progress_bar_cmd (needed for per-chunk timing) - profiling/ harness: run_pipe_target.py, nsys/ncu drivers (with the triton ldconfig deadlock workaround), gap analyzer, ncu extractor, ceiling benches - ANALYSIS.md (Phase-1 findings), PHASE_ROADMAP.md, PHASE_BENCH_LOG.md - gitignore model weights / results / large profiling artifacts
Lossless (max|diff|==0) on-device assembly of the per-chunk RoPE freqs tensor: one-time H2D of the per-axis tables, persistent per-(f,h,w,device) buffer, h/w columns written once, f columns rewritten only when the chunk temporal offset changes. Removes per-chunk CPU-side complex128 assembly and the ~8.6MB/chunk H2D. FPS-neutral @768 untraced (CPU cost rode under GPU work); kept behind flag (default OFF) — prerequisite for graph capture and useful under CPU load. Adds the generic Phase-2A parity harness.
Same fp64 complex multiply expressed in real arithmetic and compiled with torch.compile: reads bf16 x + strided fp64 freqs views, writes bf16, no 104MB fp64/complex128 intermediates in DRAM. Lazy compile, eager fallback. Kernel 0.181->0.128 ms/call; E2E 38.585->39.023 FPS (+1.14%), steady chunk 156.28->153.62 ms, output bit-identical (max|diff|==0). Keep-enabled.
Preallocated (kv_len+1+SPARE)-slot arena per block/tensor: new KV windows are partition-written directly into the tail (replacing the partition's own contiguous() materialization), the live window is a contiguous slice view, trim advances a pointer, and the only remaining copy is an overlap-safe compaction amortized once per SPARE chunks. Values/order bit-identical to the cat path (max|diff|==0 over 9 chunks incl. rotation+compaction). E2E 39.023->39.429 FPS, steady 153.62->150.76 ms; peak mem 12.6->15.5 GiB (2 spare slots, tunable via FLASHVSR_KV_RINGBUF_SPARE). Keep-enabled.
Strided-IO variant of the WGMMA block-sparse kernel: TMA descriptors cover the glue's natural 2D (S, n*d) view and load per-(head, block) tiles at [row, head*HEAD_DIM]; output is stored with explicit strides straight into (S, n, d). Kills the three (S,n,d)->(n,S,d) .contiguous() copies plus the output transpose (~11.6 ms/chunk attribution). Non-TMA fallback uses the existing stride-general kernel with transposed views; failures fall back to the contiguous triton path, then sparse. Kernel and E2E outputs bit-identical (max|diff|==0). E2E 39.429->41.099 FPS (+4.24%), steady 150.76->141.14 ms. Keep-enabled.
Exact-semantics lean pass on the draft-mask chain: (a) threshold via a single kthvalue radix-select (same order statistic as topk(k+1)[:,-1], verified equal incl. heavy ties; 0.187->0.076 ms/call, no values/indices materialization), (b) drop the no-op repeat copy of the boolean mask for B==1, (c) persistent cu_seqlens/head_mask_type for the sparse backend (removes a per-call H2D sync on that path). Mask equality + E2E max|diff|==0. E2E 41.099->41.477 FPS, steady 141.14->139.00 ms. Keep-enabled.
Copies-only cleanup around the (mandatory) im2col+GEMM conv path: the causal pad + streaming-cache cat is assembled with slice writes into a persistent per-conv buffer (one materialization instead of two, replicate corner semantics preserved), and the stream cache slices keep views instead of clones (sources never mutated in place). A third sub-item — skipping the GEMM output .contiguous() — was measured at 49.9 dB (F.normalize reduction accumulation order changes with layout) and rejected against this item's lossless gate; documented in code. E2E max|diff|==0 across a full clip. 41.477->41.580 FPS, steady 139.00->138.46 ms. Keep-enabled.
- 2A-6 go/no-go: corrected steady idle 0.6-2.5% with the full stack (below the >=2% gate) and the steady body is not capture-stable (arena slice offsets, per-chunk LQ slices) -> postponed with rationale in the log - @1536x2560 closure spot-check: 11.01 -> 11.489 FPS, 531.5 -> 501.9 ms - test_phase2a_lossless.py ALL: combined 2A stack bit-identical vs all-OFF - README: document the six new knobs + updated recommended config/numbers - PHASE_BENCH_LOG: 2A-6 entry, closure summary, closure spot-check table
Phase 2B-1: TCDecoder decode now runs per-chunk on a dedicated side CUDA stream (FLASHVSR_DECODER_OVERLAP, default OFF), overlapping with the denoise loop's later chunks instead of running as a fully serialized tail after it. Event-gated handoff (ready/done events per chunk), preallocated chunk-index-addressed result slots (exact output ordering preserved), explicit tensor-lifetime comments. No decoder math/weights/precision touched; serialized path is untouched and remains the default. Correctness: bit-identical (max|diff|==0) vs the serialized path at 768x1408 and 1536x2560, for both a 1-chunk and 8-chunk clip, stable across 3 repeated ON runs (no race condition). test_phase2a_lossless.py ALL still passes (flag-OFF path unaffected). Benchmark (768x1408, F=81, full Phase 2A stack, 3-run medians): 41.708 -> 42.375 FPS (+1.60%), decode tail 559.4 -> 125.7 ms (-77.5%), peak memory 15.62 -> 15.16 GiB (decrease). @1536x2560: +1.31% FPS, tail -76.9%, peak memory decrease. Nsight confirms genuine concurrent GPU execution on two streams with no new hidden synchronization, but only ~30-36% of decode's own GPU time is truly hidden in steady state (rest is CPU-dispatch-bound relocation) - gain is real but more modest than the roadmap ceiling. Decision: keep-behind-flag. See PHASE_BENCH_LOG.md Phase 2B-1 section for full methodology, Nsight evidence, and written interpretation. Files: diffsynth/pipelines/flashvsr_tiny.py (side-stream decode path), examples/WanVSR/profiling/run_pipe_target.py ([tail] timing metric), examples/WanVSR/profiling/test_decoder_overlap_lossless.py (new), examples/WanVSR/profiling/PHASE_BENCH_LOG.md (Phase 2B-1 log entry).
Phase 2B-1: with FLASHVSR_DECODER_OVERLAP=1 (default OFF), each chunk's latents are decoded by TCDecoder on a persistent side CUDA stream as soon as they are finalized, event-gated (per-chunk ready/done events), with the single main-stream wait_event right before output assembly. Decoded chunks land in chunk-id-indexed slots, so output ordering is structural. Decoder math/state semantics untouched (per-chunk cond slicing identical to flashvsr_tiny_long.py); serialized path preserved verbatim behind the flag. Correctness (test_decoder_overlap_lossless.py): max|diff|==0 vs serialized on full clip (9 chunks) and short clip (1 chunk, trim edge), 3x repeated ON runs bit-identical (no races); test_phase2a_lossless.py ALL still PASS. Benchmark @768x1408 F=81, full 2A stack, 3-run medians, same commit: 41.662 -> 42.373 FPS (+1.71%), decode tail 561.2 -> 125.1 ms, steady chunk 138.30 -> 190.58 ms (absorbs decode), peak mem 15.62 -> 15.16 GiB (lower). @1536x2560: 11.485 -> 11.712 FPS (+2.0%), tail 2046 -> 470 ms, peak 48.39 -> 46.72 GiB. Nsight (reports/phase2b_decoder_overlap): decode on side stream across the whole loop, 34% of decode busy time co-executes with denoise, GPU idle 1.9%, decode_wait 0.1 ms - gain is bounded by SM time-sharing (attn kernel owns full SMs), not by hidden syncs. Decision: keep-behind-flag; re-evaluate promotion after the Phase-3 attention kernel frees SM/smem headroom. Supersedes the concurrent duplicate variant committed as 7cecb65 (same design; this tree is the one the logged numbers and parity runs were produced against). Files: diffsynth/pipelines/flashvsr_tiny.py (flag + overlap path), examples/WanVSR/profiling/run_pipe_target.py ([tail] metric), examples/WanVSR/profiling/test_decoder_overlap_lossless.py (harness), examples/WanVSR/profiling/PHASE_BENCH_LOG.md (2B-1 entry + tables).
Phase 2B-2: e4m3 torch._scaled_mm path for the DiT hot GEMMs (self-attn q/k/v with one shared input quantization, o, ffn1/ffn2, LQ-projector 30x linears with one shared quantization). Weights pre-cast once with per-out-channel scales (+1.03 GiB); activations quantized per call with per-row scales by a single Triton kernel (eager quantize chains measured ~5x off bandwidth and erased the GEMM win); ffn2's input quantization is fused with GELU-tanh (fp32 gelu -> e4m3, replacing the eager bf16 GELU pass). Bias fused in the scaled_mm epilogue; sticky eager fallback on any error; scope bisection via FLASHVSR_FP8_GEMM_SCOPE. Benchmark @768x1408 F=81, full 2A stack, 3-run medians: 41.704 -> 42.986 FPS (+3.07%), steady chunk 137.98 -> 132.96 ms (-5.0 ms). Kernel evidence (torch.profiler + ncu): nvjet_sm90_qqtst_*_ovscale e4m3 kernels dispatch in the live steady chunk. Quality (Phase-4 protocol measurement): full scope PSNR 40.70 dB; per-site qkv 45.58 / o 45.05 / ffn 42.37 / ffn1 44.15 / lq 49.08 dB — distilled one-step model is fp8-sensitive, no configuration currently clears the 45 dB enable floor. Ships permanently default-OFF pending the Phase-4 audit (blockwise scales, smoothquant rebalancing, block exclusion). Flag-OFF paths bit-identical (test_phase2a_lossless.py ALL and test_decoder_overlap_lossless.py both PASS). Decision: keep-behind-flag. See PHASE_BENCH_LOG.md Phase 2B-2 entry.
Phase 2B-3: a Triton radix-select+compare kernel replacing the kthvalue/broadcast/compare tail of generate_draft_block_mask proved bit-exact (mask + E2E max|diff|==0 across adversarial tie/shape/k cases) but performance-negative: 69.2 us vs 59.7 us eager at the real shape (rows=12 -> one-CTA-per-row select is latency-bound by 4 dependent histogram rounds; torch kthvalue is already a single efficient radix-select), E2E -0.4..-0.9%. Below the pre-registered +0.5% keep gate: code reverted, no flag left behind. The exact-mask gate makes everything upstream of the select (pool/einsum/softmax) bitwise-untouchable, so remaining mask_gen headroom moves to Phase-3 (fold into attention kernel) or Phase-4 (E2E-neutral mask changes). See PHASE_BENCH_LOG.md 2B-3 entry.
58653a5 to
1f37a68
Compare
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.
Hopper Tiny Inference Acceleration
Before, Then, Now
That is 17.2 -> 38.585 -> 57.256 FPS on the same GH200 and 768x1408 target. The current result is a 3.33x increase over the original stock number and a further +48.4% over the first complete Hopper stack.
The 17.2 FPS number is the original GH200 denoise benchmark recorded when this work started. The 38.585 and 57.256 values use the current F=81 output-FPS harness. They describe the same model, GPU, and resolution, but the current harness is more complete and reproducible. The strict same-harness comparison for the latest work is 55.910 -> 57.256 FPS.
What Was Actually Slow
A big Hopper GPU does not automatically make FlashVSR fast. I profiled the real streaming workload instead of trusting isolated FLOP counts and found several very specific problems.
I added NVTX coverage, strict route counters, Nsight Systems and Nsight Compute harnesses, input provenance, per-chunk timing, tail latency, memory reporting, and fallback reporting before treating any optimization as real.
How I Fixed It
Put the LQ projector on Hopper tensor cores
The LQ projector Conv3D shapes were a poor cuDNN match. I reformulated the core operation as im2col plus BF16 GEMM, added H-axis tiling to bound transient memory, used persistent causal-pad buffers, and added an exact Triton im2col packer. This was the first major unlock.
Rebuild sparse attention for Hopper
The model depends on locality-constrained sparse attention, so dense attention was not an acceptable default replacement. I added Hopper WGMMA sparse attention, then a warp-specialized producer and consumer path with TMA loads, fused CSR construction, FFMA-form softmax math, strided IO, incremental pooled-K caching, a coalesced RoPE kernel, and zero-copy raster V handling.
Each attention route keeps the sparse-mask contract or is explicitly quality-gated. FP8 attention was investigated and rejected because it fell far below the 49 dB quality bar.
Stop paying decoder overhead every frame
The decoder now runs channels-last, overlaps chunk decode with denoise on a side stream, rotates recurrent state pointers instead of copying tensors, writes decoded chunks directly to final output, uses Triton pointwise and layout kernels, moves TGrow work to low resolution, and uses cuDNN fused Conv, Bias, Add, and ReLU paths when quality permits it.
The split-K MemBlock path removes the recurrent cat materialization. It splits the first convolution into current and past weight halves, then recombines both contributions through cuDNN.
Remove the remaining control-plane tax
The final GH200 set adds a Triton row-wise DiT LayerNorm to AdaLN fusion, a gated-residual kernel, and a steady mask-threshold cache. These are smaller wins than the original attention and decoder work, but they are measured, routed, and quality-gated like the rest.
Measured Results
768x1408, F=81, 77 output frames
xychart-beta title "GH200 throughput at 768x1408, F=81" x-axis ["Stock GH200", "First Hopper stack", "Current Hopper stack", "Goal"] y-axis "FPS" 0 --> 60 bar [17.2, 38.585, 57.256, 60]57.256 / 57.213 / 57.358FPS1536x2560
xychart-beta title "GH200 throughput at 1536x2560" x-axis ["Initial stack", "Previous recommended", "Updated recommended"] y-axis "FPS" 0 --> 16 bar [11.01, 15.07, 15.415]Quality And Correctness
The new GH200 preset is intentionally explicit about this. The flags are opt-in, unsupported Hopper paths retain their existing fallback behavior, and the quality-gated preset is not described as lossless.
Recommended GH200 Controls
FLASHVSR_CONV3D_BACKEND=gemmFLASHVSR_ATTN_BACKEND=triton2FLASHVSR_DECODER_OVERLAP=1FLASHVSR_TCDECODER_CUDNN_FUSED=1FLASHVSR_DIT_ROW_FUSION=1FLASHVSR_MASKGEN_THRESHOLD_CACHE=1FLASHVSR_TCDECODER_SPLITK_CONV=1TCDECODER_CUDNN_FUSED=1, quality-gatedThe full preset lives in
examples/WanVSR/run_flashvsr_v1.1_tiny_gh200.sh.Things I Tried And Rejected
Reproducibility And Profiling
run_pipe_target.pyrecords input provenance, per-chunk timing, tail latency, peak memory, active backend routes, and fallback errors.FLASHVSR_REQUIRE_FASTPATHS=1makes a benchmark fail if a requested route silently falls back.bench_phase7.shruns the final GH200 stack and automatically selects a valid steady window for short clips.PHASE_BENCH_LOG.mdcontains the complete measurement ledger, quality probes, rejected experiments, and resolution spot checks.Verification
Commit Organization
Commit subjects are grouped by responsibility using
perf,feat,docs, andtestprefixes with scopes such asattention,decoder,rope,kv,lq,mask, andprofiling.