Skip to content

[Perf] [NVIDIA] qwen3: Reuse the table QK-norm/RoPE kernel for MRoPE decode - #47993

Merged
zcnrex merged 5 commits into
sgl-project:mainfrom
zcnrex:qwen3-vl-mrope-table-decode
Nov 3, 2026
Merged

zcnrex merged 5 commits into
sgl-project:mainfrom
zcnrex:qwen3-vl-mrope-table-decode

Conversation

@zcnrex

@zcnrex zcnrex commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

#46908 fuses QK RMSNorm and RoPE into one Triton kernel (table_qk_norm_rope_) for dense text Qwen3 decode, behind --enable-fused-qk-norm-rope. Qwen3-VL uses the same Qwen3Attention, but its MRotaryEmbedding keeps it on the split path: JIT QK-norm, then triton_mrope_fused. This PR extends the table kernel to MRoPE. It supersedes #43425, which added a separate CUDA kernel for the same case.

Modifications

  • table_qk_norm_rope_
    • Takes [3, T] MRoPE positions (row-strided, as the decode graph runner passes them) plus MRotaryEmbedding.axis_map, and gathers each rotary lane's position from its own axis. Any positions are handled correctly, including precomputed spatial decode positions.
    • Accepts a BF16 rotary table. With one, it rotates in BF16 using triton_mrope_fused's expression, so its output is bit-identical to the split MRoPE path.
    • Above 1024 (token, head) rows, each program handles eight heads over four warps, instead of one (token, head) per one-warp program. A [heads, 32] partial-sum tile keeps each head's reduction order, so the output stays bit-exact. With one head per program, the kernel's cost grew with tokens x heads and it lost to the split path at decode batches of 256-512; it now wins at every measured size (table below).
    • The rotation arithmetic is flattened into one shared multiply/add branch. The non-match_cuda path keeps one head per program and four warps, and its output is bit-identical to before (checked at head_dim 64/128/256).
  • Qwen3Attention
    • The gate now also admits MRotaryEmbedding with an mrope_section, at head_dim 64 or 128. At head_dim 256, the table kernel's BF16 rotation isn't bit-exact with triton_mrope_fused (about 14% of elements differ by up to 1 ULP), so MRoPE at 256 keeps the split path. Plain RoPE still covers 64, 128 and 256.
    • With 2D positions, the layer first casts the rotary table to the activation dtype (_match_cos_sin_cache_dtype, the same cast the split path makes). The cast happens during graph warmup, not after capture.
    • MRoPE with 1D positions, extend, and speculative verify keep the split path.
  • Tests
    • Kernel: BF16 table with distinct per-axis positions vs JIT QK-norm + triton_mrope_fused, bit for bit, at head_dim 64 and 128, for the section, interleaved, and GLM-interleaved layouts.
    • Kernel: new cases with head counts that aren't a multiple of 8, so the eight-heads-per-program path also runs partial head blocks.
    • Model: MRoPE layers in the dispatch and changing-input graph-replay tests, with distinct, row-strided axes. Graph replay includes batch 128 (1280 rows), which runs the eight-heads-per-program path inside a captured graph.
    • Model: MRoPE at head_dim 256 stays on the split path.
    • Model: 1D positions on an MRoPE layer stay on the split path.

The flag stays opt-in on CUDA.

Accuracy

The fused path is bit-identical to the split path, so no accuracy eval was run.

  • Unit tests: 78/78 pass on one B300 (SM 10.3): test_table_qk_norm_rope.py and test_qwen3_qk_norm_rope.py.
  • End-to-end: Qwen/Qwen3-VL-2B-Instruct, TP1, default backends, decode CUDA graphs on, one B300. 6 concurrent greedy chat requests (2 with an image, 4 text-only), 256 tokens each. All 6 outputs are identical with the flag off and on. A call-recording hook on the flag-on server confirmed fused calls during capture of the bs=512 decode graph, with row-strided [3, 512] positions and a BF16 table.

Benchmark

Kernel only, on one idle B300. Each value is the median per-call time in µs over 30 replays of a CUDA graph holding 100 calls. Setup: bf16, head_dim 128.

  • MRoPE: BF16 table; split is JIT QK-norm + triton_mrope_fused.
  • Plain RoPE: FP32 table; split is JIT QK-norm + apply_rope_with_cos_sin_cache_inplace.

Every row is bit-exact with the split path.

RoPE Q/KV heads Tokens Split This PR Speedup
MRoPE 8/1 1 2.66 1.54 1.72x
MRoPE 8/1 512 4.23 2.44 1.73x
MRoPE 32/8 1 4.17 1.58 2.64x
MRoPE 32/8 32 4.75 1.74 2.72x
MRoPE 32/8 128 5.00 2.12 2.37x
MRoPE 32/8 256 5.78 2.73 2.12x
MRoPE 32/8 512 7.39 4.15 1.78x
MRoPE 64/8 1 4.77 1.62 2.94x
MRoPE 64/8 512 8.79 6.12 1.44x
Plain 16/8 128 2.96 2.00 1.48x
Plain 32/8 1 2.30 1.40 1.65x
Plain 32/8 256 4.98 2.60 1.91x
Plain 64/8 256 6.69 3.56 1.88x
Plain 64/8 512 8.00 5.91 1.35x

The full sweep (T = 1, 8, 32, 64, 128, 256, 512 with heads 8/1, 16/8, 32/8, 64/8) ranges from 1.35x to 3.09x. Below 1024 rows the kernel keeps the one-head layout; interleaved old-vs-new runs at T <= 24 match within noise.

No end-to-end latency was measured.

Checklist

  • Repository pre-commit checks pass.
  • Unit tests for the kernel and the model dispatch.
  • GPU correctness on the PR commit (B300).
  • End-to-end decode latency for Qwen3-VL.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ❌ Run #41586246656
Latest PR Test (Extra): ❌ Run #41586246680
Latest PR Test (AMD ROCm 10): ❌ Run #41586246809

Tip

Base CI only covers the core cases, and its workload may not exercise your change. Use /rerun-test to run the tests your change touches, or copy this prompt for an agent:

Find the registered tests this PR affects and run them with /rerun-test, following https://github.com/sgl-project/sglang/blob/main/.agents/skills/rerun-test/SKILL.md

…decode

Qwen3-VL decode now takes the same opt-in fused QK-norm + RoPE path as dense
text Qwen3. Decode MRoPE positions repeat one position on all three axes, so
the layer passes positions[0] and the BF16 rotary table. With a BF16 table the
kernel rotates in BF16 with triton_mrope_fused's expression, so the output is
bit-identical to the split path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@yuan-luo

yuan-luo commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Decode CUDA graphs commonly capture batch sizes of 256–512, and the kernel launches a (T, heads) grid with num_warps=1. That could be slower than the split path at those sizes. Could you add rows at 256 and 512 to check whether this PR outperforms in those scenarios or not?

and self.k_norm.weight.dtype == torch.bfloat16
and positions.ndim == self.table_rope_positions_ndim
):
if positions.ndim == 2:

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.

The fused path relies on _compute_mrope_positions_decode producing repeated positions. Its fallback path (has_precomputed_mrope) produces real spatial positions where the axes differ. If another model built on Qwen3Attention ever supplies precomputed decode positions, and the flag is on, it will produce silently wrong output, with no crash.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. A silently wrong fallback isn't acceptable. Fixed in b39e69b and eee8d67 by removing the assumption instead of documenting it.

The kernel now takes the full [3, T] MRoPE positions plus MRotaryEmbedding.axis_map, and gathers each rotary lane's position from its own axis. triton_mrope_fused does the same with its section masks, and axis_map encodes those masks exactly, including the interleaved and GLM-interleaved layouts. The result is correct for any decode positions, including precomputed spatial ones.

Tests now use distinct positions on each axis:

  • Kernel: bit-exact against JIT QK-norm + triton_mrope_fused for the section, interleaved, and GLM-interleaved layouts.
  • Model: the dispatch and CUDA-graph replay tests feed 2D positions with different rows.

zcnrex and others added 4 commits November 2, 2026 20:20
…E tokens

The table kernel now takes [3, T] MRoPE positions with MRotaryEmbedding.axis_map
and gathers each rotary lane's position from its axis, so it no longer assumes
decode repeats one position on all axes. Precomputed spatial decode positions
stay bit-identical to triton_mrope_fused.

On B300 the table kernel loses to the split path above ~3k (token, head) rows
(e.g. 0.42x at 512 tokens x 72 heads), for plain RoPE and MRoPE alike, so the
fused path is now capped at 3072 // (num_heads + num_kv_heads) tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runner

The decode graph runner passes mrope_positions as a [:, :bs] slice of a
[3, max_bs] buffer, so require unit token stride only; the kernel already
reads rows through pos_stride. Model tests now use row-strided positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…drop the token cap

The match_cuda table kernel ran one (token, head) per one-warp program, so its
cost grew with tokens x heads and it lost to the split path at decode batches
of 256-512. Above 1024 (token, head) rows it now handles eight heads per
program over four warps, with a [heads, 32] partial-sum tile that keeps each
head's reduction order. On B300 it beats the split path at every measured
decode size (1.35-3.09x, T=1..512, 9-72 heads, plain RoPE and MRoPE), bit-exact,
so Qwen3 no longer caps fused tokens.

The rotation arithmetic is flattened to one shared multiply/add branch. The
non-match path keeps one head per program and four warps, bit-identical to
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…MRoPE dims and the eight-head path

- Graph replay's third MRoPE axis reached position 6153 against a 4096-row
  table, so both kernels read out of bounds. Positions now wrap at the table
  length, keeping the row-strided view.
- Graph replay adds batch 128 (1280 rows), which runs the eight-heads-per-program
  path inside a captured graph.
- MRoPE kernel tests cover head_dim 64 as well as 128. At head_dim 256 the BF16
  rotation is not bit-exact with triton_mrope_fused (about 14% of elements
  differ by up to 1 ULP), so Qwen3 keeps MRoPE at head_dim 256 on the split path.
- col0 keeps its q/k form: the equivalent heads * D changes Triton's codegen and
  breaks bit-exactness at head_dim 64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zcnrex

zcnrex commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

/rerun-test registered/kernels/ops/attention/test_table_qk_norm_rope.py registered/unit/models/test_qwen3_qk_norm_rope.py

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🚀 1-gpu-h100 (2 tests): ✅ View workflow run

cd test/ && python3 registered/kernels/ops/attention/test_table_qk_norm_rope.py
cd test/ && python3 registered/unit/models/test_qwen3_qk_norm_rope.py

🚀 4-gpu-b200 (1 test): ✅ View workflow run

cd test/ && python3 registered/kernels/ops/attention/test_table_qk_norm_rope.py

@zcnrex
zcnrex merged commit 80121f2 into sgl-project:main Nov 3, 2026
110 of 128 checks passed
@zcnrex
zcnrex deleted the qwen3-vl-mrope-table-decode branch November 3, 2026 06:30
Yuxingwang-intel pushed a commit to Yuxingwang-intel/sglang that referenced this pull request Nov 6, 2026
…decode (sgl-project#47993)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants