Skip to content

[perf] avoid full-sequence materialization in VSA coarse/sparse combine - #1813

Open
boxwrench wants to merge 4 commits into
hao-ai-lab:mainfrom
boxwrench:perf/vsa-block-resolution-combine
Open

[perf] avoid full-sequence materialization in VSA coarse/sparse combine#1813
boxwrench wants to merge 4 commits into
hao-ai-lab:mainfrom
boxwrench:perf/vsa-block-resolution-combine

Conversation

@boxwrench

Copy link
Copy Markdown

Problem

The VSA compression branch computes a per-block coarse output, then expands it across the full sequence with repeat() before combining it with the sparse output:

out_c = out_c.view(batch, heads, q_num_blocks, 1, dim)
out_c = out_c.repeat(1, 1, 1, block_elements, 1).view(batch, heads, q_seq_len, dim)
...
return out_c * compress_attn_weight + out_s

The coarse value is constant within a block, so the expansion is avoidable. Gated, the combine then allocates two further [B, H, S, D] tensors for the product and the sum — three full-size temporaries where none is needed.

Change

Keep the coarse result at block resolution, [B, H, n_blocks, 1, D], and let it broadcast over the intra-block axis during the combine.

This mirrors the BSHD 128/256 combine, which already broadcasts out_c_blk.unsqueeze(2) instead of repeating. Under no_grad the combine accumulates into the sparse output with addcmul_, removing the remaining two full-sequence temporaries. With grad enabled it stays out-of-place, for the same reason the BSHD path documents in its comment — the sparse output is saved by FA4's autograd node for backward, so mutating it there would invalidate the graph.

The combine is shared by the 64/128/256 dispatch (the repeat() happened before the block-size branch), so all three paths benefit.

Correctness

New tests/test_vsa_combine.py, 17 cases, all CI-sized synthetic tensors with no model, checkpoint or pipeline dependency:

  • Ungated: bit-exact against the previous implementation, both for the combine in isolation and end-to-end through video_sparse_attn.
  • Gated: addcmul_ fuses the multiply-add instead of rounding the intermediate product to bf16, so the rounding differs from the old path and the two disagree on a fraction of elements. In these tests the fused result is the more accurate of the two when both are compared against an fp32 reference (mean error 1.52e-3 → 1.29e-3, max 2.29e-2 → 1.56e-2). The test asserts accuracy against fp32 rather than agreement with the old rounding.
  • Autograd: the grad-enabled path does not mutate the sparse output, and backward still produces gradients.
  • Routing and dispatch: top-k selection is unchanged, shapes and dtypes are unchanged, the 64-block path still calls block_sparse_attn, and the 128/256 kernels are still dispatched.
  • Also covers a non-contiguous sparse output and asserts the combine no longer allocates a full-sequence temporary.

Performance

benchmarks/bench_vsa_combine.py. These numbers are an isolated microbenchmark of the combine alone — not an end-to-end workload. At an H3-like shape (bf16, 56 heads, head dim 128, 15488 tokens, block size 64):

latency peak allocated
gated, before 4.06 ms 848 MiB
gated, after 2.12 ms 212 MiB
ungated, before 2.72 ms 636 MiB
ungated, after 1.56 ms 212 MiB

The 636 MiB saved in the gated case is exactly the three full [B, H, S, D] bf16 tensors that are no longer materialized (3 × 211.8 MiB). The residual 212 MiB is the benchmark's own input clone.

For a full-workload data point, MiniMax H3 at 864×480/124f with topk 0.20 measured end-to-end:

s per transformer forward peak allocated
before 7.55 s 25.885 GiB
after 7.23 s 25.473 GiB

That end-to-end measurement is from an AMD Radeon AI PRO R9700 (gfx1201, ROCm 7.2.1, PyTorch 2.9.1, Triton 3.5.1) and is hardware-specific — it should not be extrapolated to other GPUs. The change itself is not platform-specific; the mechanism is allocation count and memory traffic.

Notes

  • video_sparse_attn_bshd still uses .float().sum(dim=2) for its block means, which materializes an fp32 copy of q/k/v. That is a separate concern on a 128/256-only path and is left alone here to keep this diff minimal.
  • Existing test suite: no new failures. On the machine used here the suite fails 99 tests both before and after this change, with identical failure sets (96 test_vmoba_correctness, 2 test_attn_qat_train, and one intermittent test_vsa_varlen.py::TestVSAVarlenBackward::test_backward_different_lengths that also fails on unmodified main). Those are unrelated to this change and appear to be ROCm-environment failures.

The compression branch computed a per-block coarse output and then expanded it
across the full sequence with repeat() before combining it with the sparse
output. The gated combine then allocated two more [B, H, S, D] tensors for the
product and the sum.

Keep the coarse result at block resolution, [B, H, n_blocks, 1, D], and let it
broadcast over the intra-block axis during the combine. This mirrors the BSHD
128/256 path, which already broadcasts out_c_blk.unsqueeze(2) rather than
repeating. Under no_grad the combine accumulates into the sparse output with
addcmul_, removing the remaining two full-sequence temporaries; with grad
enabled it stays out-of-place, for the same reason the BSHD path documents (the
sparse output is saved by FA4's autograd node for backward).

Ungated the result is bit-exact. Gated, addcmul_ fuses the multiply-add instead
of rounding the intermediate product to bf16, so the rounding differs from the
old path; in the added tests the fused result is the more accurate of the two
when both are compared against fp32. Top-k routing, shapes and dtypes are
unchanged, and 64/128/256 dispatch is unaffected.

Isolated combine microbenchmark at an H3-like shape (bf16, 56 heads, dim 128,
15488 tokens, block 64), gated: 4.06 -> 2.12 ms and 848 -> 212 MiB peak
allocated. This measures the combine alone, not an end-to-end workload.

Adds tests/test_vsa_combine.py (17 cases, CI-sized, no model or pipeline
dependency) and benchmarks/bench_vsa_combine.py.
@mergify mergify Bot added type: perf Performance improvement scope: kernel CUDA kernels, fastvideo-kernel labels Sep 3, 2026
@mergify

mergify Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI

Protection Waiting on
🔴 PR merge requirements 👀 reviews and 🤖 CI

🔴 PR merge requirements

Waiting for

  • #approved-reviews-by>=1
  • check-success=full-suite-passed
This rule is failing.
  • #approved-reviews-by>=1
  • check-success=full-suite-passed
  • check-success=fastcheck-passed
  • check-success~=pre-commit
  • title~=(?i)^\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model|skill|skills|infra)\]

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Welcome to FastVideo! Thanks for your first pull request.

How our CI works:

PRs run a three-tier CI system:

  1. Pre-commit — formatting (yapf), linting (ruff), type checking (mypy). Runs immediately on every PR.
  2. Fastcheck — six core GPU lanes run automatically via Buildkite (~10-15 min).
  3. Merge gate — a reviewer adds ready; changed paths select only the relevant integration, training, golden, or SSIM coverage.

Before your PR is reviewed:

  • pre-commit run --all-files passes locally
  • You've added or updated tests for your changes
  • The PR description explains what and why

If pre-commit fails, a bot comment will explain how to fix it. Fastcheck and merge-gate results appear in the Checks section below.

Useful links:

SolitaryThinker and others added 3 commits September 5, 2026 00:39
Follow-up to the block-resolution combine in video_sparse_attn:

- one helper for both layouts (seq_dim=2 for the BHSD entry, seq_dim=1 for
  video_sparse_attn_bshd), so the 128/256 BSHD path also stops allocating
  combine temporaries at inference
- the grad branch uses out-of-place torch.addcmul: one temporary instead of
  two, and bit-identical to the in-place addcmul_ path, so results no longer
  depend on the caller's grad mode
- the in-place path is keyed on out_s.requires_grad (the actual aliasing
  invariant) rather than the global grad mode, and skipped when dtype
  promotion would otherwise downcast into out_s
- strict shape validation of out_s and the gate (same-numel wrong-layout
  inputs used to be accepted silently); the dead .contiguous() fallback is
  gone because splitting the sequence axis is always expressible as a view
- traceable by torch.compile(fullgraph=True)

Tests (GB200): 77 cases on the default Triton route and 44 on the FA4 CuTe
route. Ungated results are bit-exact with the old path; every gated element
is within half a bf16 ulp of the fp32 truth in all four grad/no-grad modes;
gradients match the old path; exact peak-allocation pins (0 bytes in place,
one full tensor out of place); end-to-end value, routing and gradient checks
for 64/128/256 tiles in both layouts; a fullgraph compile check.

The benchmark now uses triton.testing.do_bench, measures peak memory outside
the timed window, adds end-to-end video_sparse_attn / video_sparse_attn_bshd
cases, and drops the MiniMax H3 label (that backend does not call this
combine).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFimeedTQWxgqSzP5xo4j
The 64-tile BHSD path copied gate_compress into a contiguous BHSD tensor on
every call. The coarse/sparse combine views the gate at block resolution,
which never needs contiguity, so the transposed BSHD view is passed as is.
This saves one full-sequence copy per attention layer with bit-identical
output. On a GB200 at 1x39936x12x128 (Wan 1.3B, 480p) the per-call peak
drops from 639.8 to 521.8 MiB and latency from 11.64 to 11.29 ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFimeedTQWxgqSzP5xo4j
…inference

torch.addcmul keeps one full-sequence temporary under grad instead of two,
and when the sparse output is not tracked by autograd the gate branch
accumulates into it directly, allocating nothing. The product is no longer
rounded to bf16 before the add, so gated values move by at most half a bf16
ulp (towards the fp32 value). test_vsa_h3_backward.py passes on the Triton
and FA4 CuTe backends on a GB200.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFimeedTQWxgqSzP5xo4j
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

Pushed three follow-up commits after review (all verified on a GB200):

  1. Kernel (f9ff387f): one shared _combine_coarse_sparse for both layouts, so the 128/256 BSHD entry also stops allocating combine temporaries at inference. The grad branch uses out-of-place torch.addcmul (one temporary instead of two, bit-identical to the in-place path, so results no longer depend on the caller's grad mode). The in-place path is keyed on out_s.requires_grad rather than global grad mode and skipped on dtype promotion. Strict shape checks on out_s and the gate (same-numel wrong-layout inputs used to be accepted silently); the dead .contiguous() fallback is gone since splitting the sequence axis is always a view. torch.compile(fullgraph=True)-safe. Tests rewritten: fp32-oracle bounds in all four grad/no-grad modes, gradient parity with the old path, exact allocation pins (0 bytes in place, one full tensor out of place), end-to-end value/routing/gradient checks at 64/128/256 tiles in both layouts (77 cases on the Triton route, 44 on FA4 CuTe).
  2. video_sparse_attn.py (28c68b9d): the gate is passed to the kernel as a transposed view instead of a contiguous copy, saving one full-sequence copy per layer with bit-identical output (1x39936x12x128: 639.8 -> 521.8 MiB peak, 11.64 -> 11.29 ms per call).
  3. H3 backend (38412b28): same fused combine; gated values move by at most half a bf16 ulp, towards the fp32 value.

The benchmark now uses triton.testing.do_bench and measures peak memory outside the timed window (the previous numbers included an out_s.clone() inside both). At 56x15488x128 bf16, gated:

latency us peak alloc MiB
old combine 366 636
new, inference (in place) 304 0
new, training (addcmul) 304 212

End-to-end video_sparse_attn latency is unchanged within noise (the sparse kernel dominates); per-call peak drops by 2-3 full tensors on the 64-tile path and by about one on the CuTe 256-tile BSHD path. One note on the description: the MiniMax H3 backend does not call this combine (it has its own, now fused in commit 3), so the H3 end-to-end table cannot come from this diff; the beneficiaries are the Wan-family 64-tile path and the 128/256 paths.

@mergify mergify Bot added the scope: attention Attention backends (VSA, STA, Flash, etc.) label Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: attention Attention backends (VSA, STA, Flash, etc.) scope: kernel CUDA kernels, fastvideo-kernel type: perf Performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants