Skip to content

[Perf] mamba: write the deduplicated conv verify window as one tile - #1

Open
jianc99 wants to merge 3 commits into
av/basefrom
av/conv-tile
Open

jianc99 wants to merge 3 commits into
av/basefrom
av/conv-tile

Conversation

@jianc99

@jianc99 jianc99 commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Motivation

On hybrid models, linear-chain speculative verify (EAGLE chain, MTP, DFLASH, DSPARK, standalone) calls causal_conv1d_update with an intermediate conv window. The window lets the conv state of the accepted prefix be restored after verify.

On CUDA and ROCm, MambaPool stores this window deduplicated. Each slot holds one [dim, T + K - 2] row, exposed as an overlapping [T, dim, K - 1] view whose step stride equals its window stride.

The kernel still writes the window per token, with three lane-strided element stores per draft token. Each column is therefore written up to three times by narrow scattered stores. On GB300 (dim 10240, bf16, T = 8, bs 128), the verify conv takes 106 us with the deduplicated window, against 39 us with a dense window.

Modifications

  • _causal_conv1d_update_kernel: adds a DEDUP_CONV_WINDOW path.

    • Step t's window is columns t..t+2 of the row [state[1], state[2], x_0, ..., x_{T-1}].
    • The kernel writes that row once, as a [BLOCK_N, next_pow2(T + 2)] tile before the token loop, and skips the per-token window stores.
    • The tile mask covers exactly the T + 2 columns the per-token path writes.
    • Padded rows still exit before any store.
    • Two tl.static_asserts keep the branch from being compiled for a conv width other than 4 or together with a tree mask, so the kernel is safe without relying on its caller.
  • causal_conv1d_update: takes the tile path only when all three hold:

    • the window view's step stride equals its window stride;
    • the verify is a chain (no tree mask);
    • the conv width is 4.

    With step stride == window stride == s, the per-token stores write column t + w of the row at (t + w) * s, and the tile writes column c at c * s: the same addresses and the same values, for any layout with those strides. MambaPool's deduplicated view is one such layout. There are no new arguments, and callers are unchanged. Kernels that do not take the tile get NP2_WINDOW_COLS=1, so no extra variants are compiled.

  • Test: test_causal_conv1d_update_dedup_window_matches_per_token.

    • It runs the kernel on a dense window (per-token path) and on the deduplicated view (tile path).
    • It checks bitwise that the shared rows, the windows read through the view and the conv state match a reference row built in torch.
    • It checks that slots of padded rows are untouched.
    • It compares the conv output with the file's update tolerances.
    • Coverage: T in {2, 3, 8, 16} (T + 2 pads to tiles of 4, 8, 16 and 32), a num_accept_tokens state offset, MambaPool's axis-0 window layout seen transposed, fp32 and bf16, a dim that leaves a partial channel block, with and without padded rows.
  • A comment in MambaPool is updated to describe the new write.

Scope

This path is used by every chain verify (topk <= 1) whose conv window uses MambaPool's deduplicated layout. Examples are GDN models (Qwen3.5/3.8) and Mamba2, on CUDA and ROCm.

These are unchanged and keep the per-token stores:

  • dense windows: UnifiedMambaPool, KDA / Kimi-Linear, tree verify;
  • deduplicated windows of a conv width other than 4 (for example LFM2, width 3).

Accuracy Tests

  • The new test passes, and so does the full test file (270 tests at 8eb01f2, which adds the tree-mask assert and the accept-offset, axis-0 and odd-T cases).
  • The test is sensitive: a tile that ignores num_accept_tokens fails exactly the accept-offset cases, and forcing the tile together with a tree mask fails at compile time on the new assert.
  • compute-sanitizer memcheck, racecheck and initcheck are clean on the tile test as of 8177311 (T in {2, 4, 8, 16}); besides comments and tests, 8eb01f2 adds only a compile-time assert and NP2_WINDOW_COLS=1 for launches without the tile. The extra 72-case check below, which covers accepted-token offsets, transposed views and odd T, is also memcheck-clean.
  • An extra 72-case check also passes, comparing the tile path bitwise with the per-token path. It covers T in {1, 3, 9}, mixed input and state dtypes, transposed views and accepted-token offsets.
  • End to end, greedy, c1: acceptance length and output tokens are identical to main.

Benchmarking and Profiling

Kernel time per call in microseconds (GB300, dim 10240, K 4, bf16, CUDA graph):

bs T before after dense window
1 8 5.35 2.49 3.71
64 8 57.39 9.96 21.08
128 8 106.45 17.55 38.58
128 16 322.63 39.10 74.12

End-to-end setup:

  • Qwen3.8-27B + DFlash2 (block 8), TP1, one GB300;
  • chain verify, bf16 mamba state, overlap scheduling on, --max-running-requests 128;
  • SPEED-Bench first turns, T = 1, top_p 0.95, top_k 20, up to 4096 output tokens;
  • mean of 3 interleaved runs.

Decode tok/s = per-request decode rate (output tokens after the first / time after the first token), times concurrency; c1 is the single-request decode rate.

concurrency main this PR change
64 6048 6361 +5.2%
128 6992 7329 +4.8%

Acceptance length is 3.59-3.63 in all six runs.

At c128 the running cap equals the concurrency, so about one request waits in the queue. #8 shows that this setup reads 7-15% higher at that level than a larger cap, on both sides, so compare c128 only within this table.

Single runs at lower concurrency: c8 2031 -> 2104 (+3.6%), c32 4723 -> 4973 (+5.3%). At c1 the change is within noise: 332 -> 321, with acceptance length 3.70 vs 3.60 from sampling; per accepted token the rate differs by under 0.5%. Greedy (single runs): c1 357.0 -> 357.1 with identical acceptance length and output tokens, c64 6746 -> 7145 (+5.9%).

The end-to-end runs used the first commit; the two later commits add compile-time asserts, comments and tests, and pass NP2_WINDOW_COLS=1 to kernels that skip the tile, with no runtime change on the tile path.

Related upstream work

Notes

  • The tile store is SubSir's work from the dynamic-verify branch. This PR ports it to the current kernel, gates it on the view's strides and adds the tests.
  • Only GB300 was tested; ROCm takes the same path but has not been run.
  • Commits: 7526326 (tile store and stride gate), 8177311 (width assert, test trim, MambaPool comment), 8eb01f2 (tree-mask assert, stride-based wrapper comment, NP2_WINDOW_COLS=1 when the tile is off, more test layouts). Squash them when merging upstream.

Jian Chen added 2 commits October 22, 2026 06:24
Problem: in linear-chain speculative verify, causal_conv1d_update saves the
intermediate conv window after every draft token with three lane-strided
element stores. MambaPool's deduplicated window view (step stride == window
stride) maps those stores onto one shared row per channel, so the same
addresses are written up to three times by scattered narrow stores. On GB300
(dim 10240, bf16, T=8, bs 128) the verify conv takes 106 us with the
deduplicated window versus 39 us with a dense one.

Fix: when the window view's step stride equals its window stride, step t's
window is columns t..t+2 of the row [state[1], state[2], x_0, ..., x_{T-1}].
For a chain (no tree mask) with kernel width 4, write that row once as a
[BLOCK_N, next_pow2(T+2)] tile before the token loop and skip the per-token
stores. Tree verify, other widths and dense windows keep the per-token
stores. The bytes written are the same; the new test checks them bitwise
against the per-token path.

Effect: kernel time on GB300, dim 10240, bf16, T=8: bs 1 5.4 -> 2.5 us,
bs 64 57.4 -> 10.0 us, bs 128 106.5 -> 17.6 us (T=16, bs 128: 322.6 ->
39.1 us). Every CUDA chain verify on a model with a width-4 conv (GDN,
Mamba2) takes this path. The tile store is SubSir's.
Problem: the deduplicated conv window tile writes [state[1], state[2], x...],
which is the right row only for kernel width 4, but only the Python wrapper
enforced that; a caller that set the flag for another width would get wrong
window bytes. The new test compiled 32 kernel variants (dim is a constexpr),
and it compared conv outputs of two separately compiled kernels bitwise, which
is not guaranteed on every backend the file runs on. The MambaPool comment
still described the kernel's window write as overlapping per-token stores.

Fix: static_assert KERNEL_WIDTH == 4 inside the tile branch. Test one dim
(1000, a partial channel block) instead of two; T in {2, 4, 8, 16} still covers
every tile width. Keep the bitwise checks on the window and conv state, which
are copies, and compare the conv output with the file's update tolerances.
Update the MambaPool comment.

Effect: no runtime change. The test compiles half as many kernel variants.
… layouts

Problem: only the Python wrapper kept tree verify off the deduplicated conv
window tile; a caller that set both flags would get the tile plus the tree
branch's per-token stores into the same window. The wrapper comment tied the
tile to MambaPool's overlapping view, while the real argument is about the
strides alone. Kernels that do not take the tile still got a seqlen-dependent
NP2_WINDOW_COLS. The test did not cover a num_accept_tokens state offset,
MambaPool's axis-0 window layout, or an odd T.

Fix: static_assert that the tile branch has no tree mask, next to the width
assert. Rewrite the comment: with step stride == window stride == s, the
per-token stores write row column t + w at (t + w) * s and the tile writes
column c at c * s, the same addresses and values for any layout with those
strides. Pass NP2_WINDOW_COLS=1 when the tile is off. Test T = 3, a
num_accept_tokens offset, and the axis-0 window (a [T + K - 2, dim] row seen
transposed); T = 3 replaces T = 4 in the plain cases, so T + 2 still pads to
tiles of 4, 8, 16 and 32.

Effect: no runtime change. The test compiles 24 kernel variants instead of 16.
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.

1 participant