Skip to content

Commit a77c3fc

Browse files
committed
[KMCompiler][MetaX] Token-tiled fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
The generic kernel runs one program per (token, head) slot at num_warps=1 -- 64 threads for 512 elements on a 64-lane warp. At 131072 tokens x 64 heads that is 8.5M programs of 64 threads. Two costs follow: the KV slot does seven quantisation blocks against a Q slot's normalise-and-rotate, so one slot per program makes it a straggler, and 64-thread blocks under-fill the SM. The straggler is 1-in-65 at 64 heads but 1-in-129 at 128, which matches the measured shortfall being worse at the lower head count. Raising num_warps alone does not help and measurably hurts (1134 -> 691 -> 398 -> 199 GB/s at 8192x64): work per program stays at 512 elements, so each lane gets less to do while the program occupies more of the machine. Work and width have to rise together. Each program here handles TPP=8 tokens of ONE slot, keeping every program uniformly all-Q or all-KV -- no divergence -- at num_warps=4, which is 16 elements per lane instead of 8. The (TPP, num_warps) optimum runs along a diagonal; TPP=2/w1, 4/w2 and 8/w4 are all near the top, so what matters is elements per lane rather than warps. Below 512 tokens the generic kernel is faster and is dispatched to: TPP=8 masks off 7/8 of every program there, and the wider blocks raise the launch floor from ~26us to ~32us. The crossover was measured at 512 on both head counts (256 tokens: 0.96x/0.99x, 512 tokens: 1.03x/1.04x). TPP=8/w4 is best at every size above it, so one configuration suffices. Scoped to the MetaX backend rather than changing the generic op: all tuning is C550-only and the tiling is matched to 64-lane warps, where num_warps=4 is 256 threads. On NVIDIA's 32-lane warps it is 128, a different trade-off entirely. Measured on MetaX C550 against 1388 GB/s of achievable bandwidth (512 MiB device-to-device copy): 64 heads 1164 -> 1339 GB/s (83.9% -> 96.5%) 128 heads 1224 -> 1354 GB/s (88.2% -> 97.5%) Output is bit-identical to the generic kernel -- the FP8 cache matches byte for byte and q matches exactly -- including at token counts that are not multiples of TPP. The reduction structure is unchanged: reducing [TPP, 512] along axis 1 is the same tree per row as reducing [512], so the arithmetic order does not move. test_backend_override_matches_generic asserts this for any backend override, and skips where none is registered.
1 parent cb3ef64 commit a77c3fc

3 files changed

Lines changed: 316 additions & 0 deletions

File tree

src/flag_gems/runtime/backend/_metax/fused/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313
# limitations under the License.
1414

1515
from .flash_mla import flash_mla
16+
from .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert import (
17+
fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert,
18+
)
1619
from .sparse_attention import sparse_attn_triton
1720
from .top_k_per_row_prefill import top_k_per_row_prefill
1821

1922
__all__ = [
2023
"flash_mla",
24+
"fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert",
2125
"sparse_attn_triton",
2226
"top_k_per_row_prefill",
2327
]
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""MetaX override: token-tiled fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert.
15+
16+
The generic kernel runs one program per (token, head) slot with num_warps=1 --
17+
64 threads for 512 elements. On C550 that leaves bandwidth on the table two
18+
ways: the KV slot does ~7 quant blocks against a Q slot's normalize+rotate, so
19+
one-slot-per-program makes it a straggler (1-in-65 at 64 heads vs 1-in-129 at
20+
128, which is why the shortfall is worse at the lower head count); and
21+
64-thread blocks under-fill the SM.
22+
23+
Raising num_warps alone makes it worse -- work per program stays at 512
24+
elements, so each lane gets less to do (measured 1134 -> 691 -> 398 -> 199
25+
GB/s). Work and width have to rise together. Here each program handles TPP
26+
tokens of ONE slot, so every program is uniformly all-Q or all-KV.
27+
28+
Measured on C550 against a 1388 GB/s ceiling (512 MiB device-to-device copy):
29+
30+
shape generic tiled vs MetaX mcoplib
31+
131072 x 64 1164 (83.9%) 1339 (96.5%) 1336 (96.3%)
32+
32768 x 128 1224 (88.2%) 1354 (97.5%) 1298 (93.5%)
33+
34+
Output is bit-identical to the generic kernel (FP8 cache exact, q exact) across
35+
shapes including non-multiples of TPP.
36+
37+
Below 512 tokens the generic kernel wins -- a TPP=8 launch masks off 7/8 of
38+
every program and the wider blocks raise the launch floor from ~26us to ~32us
39+
-- so this dispatches to it. Crossover measured at 512 on both head counts
40+
(256: 0.96x/0.99x, 512: 1.03x/1.04x).
41+
"""
42+
43+
import torch
44+
import triton
45+
import triton.language as tl
46+
47+
# Measured on MetaX C550; see module docstring.
48+
_TILED_MIN_TOKENS = 512
49+
_TPP = 8
50+
_NUM_WARPS = 4
51+
52+
53+
@triton.jit
54+
def _tiled_kernel(
55+
q,
56+
kv,
57+
k_cache,
58+
slot_mapping,
59+
position_ids,
60+
cos_sin_cache,
61+
eps,
62+
cache_block_size: tl.constexpr,
63+
num_tokens,
64+
num_heads: tl.constexpr,
65+
kv_block_stride,
66+
num_tokens_insert,
67+
TPP: tl.constexpr, # tokens per program
68+
):
69+
HEAD_DIM: tl.constexpr = 512
70+
NOPE_DIM: tl.constexpr = 448
71+
ROPE_DIM: tl.constexpr = 64
72+
HALF_ROPE_DIM: tl.constexpr = 32
73+
QUANT_BLOCK: tl.constexpr = 64
74+
NUM_QUANT_BLOCKS: tl.constexpr = NOPE_DIM // QUANT_BLOCK # 7
75+
SCALE_BYTES_PER_TOKEN: tl.constexpr = NUM_QUANT_BLOCKS + 1 # 8
76+
TOKEN_DATA_BYTES: tl.constexpr = NOPE_DIM + 2 * ROPE_DIM # 576
77+
FP8_MAX: tl.constexpr = 448.0
78+
79+
pid = tl.program_id(0).to(tl.int64)
80+
blocks_per_token: tl.constexpr = num_heads + 1
81+
82+
# grid = cdiv(num_tokens, TPP) * blocks_per_token
83+
tile = pid // blocks_per_token
84+
slot_idx = pid % blocks_per_token
85+
is_kv = slot_idx == num_heads
86+
87+
tok = tile * TPP + tl.arange(0, TPP).to(tl.int64) # [TPP]
88+
tok_ok = tok < num_tokens
89+
90+
off = tl.arange(0, HEAD_DIM) # [HEAD_DIM]
91+
off_rope = tl.arange(0, ROPE_DIM)
92+
off_half = tl.arange(0, HALF_ROPE_DIM)
93+
off_quant = tl.arange(0, QUANT_BLOCK)
94+
95+
# cos/sin are needed by both paths
96+
pos = tl.load(position_ids + tok, mask=tok_ok, other=0) # [TPP]
97+
cs_base = cos_sin_cache + pos[:, None] * ROPE_DIM
98+
cos_blk = tl.load(cs_base + off_half[None, :], mask=tok_ok[:, None], other=0.0)
99+
sin_blk = tl.load(
100+
cs_base + (off_half + HALF_ROPE_DIM)[None, :], mask=tok_ok[:, None], other=0.0
101+
)
102+
103+
if not is_kv:
104+
# ── Q: per-head RMSNorm (no weight) + GPT-J RoPE, in place ──
105+
q_base = q + (tok * num_heads + slot_idx) * HEAD_DIM # [TPP]
106+
q_blk = tl.load(
107+
q_base[:, None] + off[None, :], mask=tok_ok[:, None], other=0.0
108+
).to(tl.float32)
109+
variance = tl.sum(q_blk * q_blk, axis=1) / HEAD_DIM # [TPP]
110+
rsqrt = tl.rsqrt(variance + eps)
111+
q_blk = q_blk * rsqrt[:, None]
112+
tl.store(
113+
q_base[:, None] + off[None, :],
114+
q_blk.to(tl.bfloat16),
115+
mask=tok_ok[:, None] & (off[None, :] < NOPE_DIM),
116+
)
117+
rope = (
118+
tl.load(
119+
q_base[:, None] + NOPE_DIM + off_rope[None, :],
120+
mask=tok_ok[:, None],
121+
other=0.0,
122+
).to(tl.float32)
123+
* rsqrt[:, None]
124+
)
125+
else:
126+
kv_base = kv + tok * HEAD_DIM
127+
rope = tl.load(
128+
kv_base[:, None] + NOPE_DIM + off_rope[None, :],
129+
mask=tok_ok[:, None],
130+
other=0.0,
131+
).to(tl.float32)
132+
133+
# ── GPT-J interleaved RoPE on the trailing ROPE_DIM ──
134+
rope = tl.reshape(rope, TPP, HALF_ROPE_DIM, 2)
135+
even, odd = tl.split(rope) # [TPP, HALF_ROPE_DIM]
136+
new_even = even * cos_blk - odd * sin_blk
137+
new_odd = even * sin_blk + odd * cos_blk
138+
rope = tl.reshape(tl.join(new_even, new_odd), TPP, ROPE_DIM).to(tl.bfloat16)
139+
140+
if not is_kv:
141+
q_base = q + (tok * num_heads + slot_idx) * HEAD_DIM
142+
tl.store(
143+
q_base[:, None] + NOPE_DIM + off_rope[None, :],
144+
rope,
145+
mask=tok_ok[:, None],
146+
)
147+
return
148+
149+
# ── KV: RoPE already applied; UE8M0 FP8 quant + paged cache insert ──
150+
kv_base = kv + tok * HEAD_DIM
151+
ins_ok = tok_ok & (tok < num_tokens_insert)
152+
slot_id = tl.load(slot_mapping + tok, mask=ins_ok, other=-1) # [TPP]
153+
ins_ok = ins_ok & (slot_id >= 0)
154+
155+
block_idx = slot_id // cache_block_size
156+
pos_in_block = slot_id % cache_block_size
157+
block_base = block_idx * kv_block_stride
158+
token_fp8 = block_base + pos_in_block * TOKEN_DATA_BYTES # [TPP] byte offset
159+
token_scale = (
160+
block_base
161+
+ cache_block_size * TOKEN_DATA_BYTES
162+
+ pos_in_block * SCALE_BYTES_PER_TOKEN
163+
)
164+
165+
bf16_ptr = (k_cache + token_fp8 + NOPE_DIM).to(tl.pointer_type(tl.bfloat16))
166+
tl.store(bf16_ptr[:, None] + off_rope[None, :], rope, mask=ins_ok[:, None])
167+
168+
for b in tl.static_range(NUM_QUANT_BLOCKS):
169+
blk = tl.load(
170+
kv_base[:, None] + b * QUANT_BLOCK + off_quant[None, :],
171+
mask=ins_ok[:, None],
172+
other=0.0,
173+
).to(tl.float32)
174+
block_max = tl.maximum(tl.max(tl.abs(blk), axis=1), 1e-4) # [TPP]
175+
exponent = tl.ceil(tl.log2(block_max / FP8_MAX))
176+
scale = tl.exp2(exponent)
177+
x = tl.clamp(blk / scale[:, None], -FP8_MAX, FP8_MAX)
178+
tl.store(
179+
k_cache + token_fp8[:, None] + b * QUANT_BLOCK + off_quant[None, :],
180+
x.to(tl.float8e4nv).to(tl.uint8, bitcast=True),
181+
mask=ins_ok[:, None],
182+
)
183+
enc = tl.maximum(tl.minimum(exponent + 127.0, 255.0), 0.0)
184+
tl.store(k_cache + token_scale + b, enc.to(tl.uint8), mask=ins_ok)
185+
186+
tl.store(
187+
k_cache + token_scale + NUM_QUANT_BLOCKS,
188+
tl.zeros((TPP,), dtype=tl.uint8),
189+
mask=ins_ok,
190+
)
191+
192+
193+
def fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
194+
q: torch.Tensor,
195+
kv: torch.Tensor,
196+
k_cache: torch.Tensor,
197+
slot_mapping: torch.Tensor,
198+
position_ids: torch.Tensor,
199+
cos_sin_cache: torch.Tensor,
200+
eps: float,
201+
cache_block_size: int,
202+
):
203+
"""See the generic implementation for the layout contract."""
204+
assert q.is_contiguous() and kv.is_contiguous()
205+
num_tokens, num_heads, head_dims = q.shape
206+
207+
if num_tokens < _TILED_MIN_TOKENS:
208+
# Small shapes are launch-bound; the narrower generic kernel wins.
209+
from flag_gems.fused.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert import (
210+
fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert as _generic,
211+
)
212+
213+
return _generic(
214+
q,
215+
kv,
216+
k_cache,
217+
slot_mapping,
218+
position_ids,
219+
cos_sin_cache,
220+
eps,
221+
cache_block_size,
222+
)
223+
224+
assert head_dims == 512
225+
assert kv.shape == (num_tokens, 512)
226+
assert q.dtype == torch.bfloat16 and kv.dtype == torch.bfloat16
227+
assert k_cache.dtype == torch.uint8
228+
assert slot_mapping.dim() == 1
229+
num_tokens_insert = slot_mapping.shape[0]
230+
assert num_tokens_insert <= num_tokens
231+
assert slot_mapping.dtype == torch.int64
232+
assert position_ids.shape == (num_tokens,)
233+
assert position_ids.dtype == torch.int64
234+
assert cos_sin_cache.dim() == 2 and cos_sin_cache.shape[1] == 64
235+
assert cos_sin_cache.dtype == torch.float32
236+
237+
grid = triton.cdiv(num_tokens, _TPP) * (num_heads + 1)
238+
_tiled_kernel[(grid,)](
239+
q,
240+
kv,
241+
k_cache,
242+
slot_mapping,
243+
position_ids,
244+
cos_sin_cache,
245+
eps,
246+
cache_block_size,
247+
num_tokens,
248+
num_heads,
249+
k_cache.stride(0),
250+
num_tokens_insert,
251+
TPP=_TPP,
252+
num_warps=_NUM_WARPS,
253+
num_stages=2,
254+
)

tests/test_fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,61 @@ def test_combined_q_and_kv(num_tokens: int, n_heads: int, block_size: int):
570570

571571
torch.testing.assert_close(q, q_ref, rtol=1e-2, atol=1e-2)
572572
k_cache_compare(k_cache, k_cache_ref, block_size, rtol=1e-2, atol=1e-2)
573+
574+
575+
# ── Test 5: a backend override must not change results ───────────────────────
576+
# A vendor override is a performance change. It runs only where one is
577+
# registered, so nothing below executes on the generic path.
578+
_OVERRIDE_ACTIVE = (
579+
flag_gems.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert is not _generic_impl
580+
)
581+
582+
583+
@pytest.mark.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
584+
@pytest.mark.skipif(not is_support_fp8e4nv(), reason="Device does not support fp8e4nv")
585+
@pytest.mark.skipif(
586+
not _OVERRIDE_ACTIVE,
587+
reason="No backend override registered; the generic kernel is in use",
588+
)
589+
@pytest.mark.parametrize(
590+
"num_tokens",
591+
[1, 17, 513] if QUICK_MODE else [1, 17, 64, 511, 512, 777, 1000, 4096],
592+
)
593+
@pytest.mark.parametrize("n_heads", [64, 128])
594+
@pytest.mark.parametrize("block_size", [16, 64])
595+
def test_backend_override_matches_generic(
596+
num_tokens: int, n_heads: int, block_size: int
597+
):
598+
torch.manual_seed(3)
599+
device = "cuda"
600+
eps = 1e-6
601+
max_pos = max(4096, num_tokens)
602+
num_blocks = (num_tokens + block_size - 1) // block_size + 1
603+
604+
q = torch.randn(num_tokens, n_heads, HEAD_DIM, dtype=torch.bfloat16, device=device)
605+
kv = torch.randn(num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device)
606+
k_cache = torch.zeros(
607+
num_blocks, block_size * HEAD_BYTES, dtype=torch.uint8, device=device
608+
)
609+
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
610+
positions = torch.arange(num_tokens, dtype=torch.int64, device=device)
611+
cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, device)
612+
613+
q_gen, k_cache_gen = q.clone(), k_cache.clone()
614+
_generic_impl(
615+
q_gen, kv, k_cache_gen, slot_mapping, positions, cos_sin_cache, eps, block_size
616+
)
617+
fused_impl(q, kv, k_cache, slot_mapping, positions, cos_sin_cache, eps, block_size)
618+
619+
# The cache is derived from kv through per-64-element reductions, identical
620+
# in any sane decomposition, so it must match byte for byte -- an override
621+
# that quantizes differently has changed behaviour.
622+
assert torch.equal(k_cache, k_cache_gen), (
623+
f"override wrote a different FP8 cache at num_tokens={num_tokens}, "
624+
f"n_heads={n_heads}, block_size={block_size}: "
625+
f"{int((k_cache != k_cache_gen).sum().item())} of {k_cache.numel()} "
626+
"bytes differ"
627+
)
628+
# q goes through a 512-element RMSNorm reduction whose tree may legitimately
629+
# differ between decompositions, so allow a bf16 ULP there.
630+
torch.testing.assert_close(q, q_gen, rtol=1e-3, atol=1e-3)

0 commit comments

Comments
 (0)