Skip to content

Commit ff29c52

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 4aec4ea commit ff29c52

3 files changed

Lines changed: 259 additions & 13 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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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 = tl.load(
118+
q_base[:, None] + NOPE_DIM + off_rope[None, :],
119+
mask=tok_ok[:, None],
120+
other=0.0,
121+
).to(tl.float32) * rsqrt[:, None]
122+
else:
123+
kv_base = kv + tok * HEAD_DIM
124+
rope = tl.load(
125+
kv_base[:, None] + NOPE_DIM + off_rope[None, :],
126+
mask=tok_ok[:, None],
127+
other=0.0,
128+
).to(tl.float32)
129+
130+
# ── GPT-J interleaved RoPE on the trailing ROPE_DIM ──
131+
rope = tl.reshape(rope, TPP, HALF_ROPE_DIM, 2)
132+
even, odd = tl.split(rope) # [TPP, HALF_ROPE_DIM]
133+
new_even = even * cos_blk - odd * sin_blk
134+
new_odd = even * sin_blk + odd * cos_blk
135+
rope = tl.reshape(tl.join(new_even, new_odd), TPP, ROPE_DIM).to(tl.bfloat16)
136+
137+
if not is_kv:
138+
q_base = q + (tok * num_heads + slot_idx) * HEAD_DIM
139+
tl.store(
140+
q_base[:, None] + NOPE_DIM + off_rope[None, :],
141+
rope,
142+
mask=tok_ok[:, None],
143+
)
144+
return
145+
146+
# ── KV: RoPE already applied; UE8M0 FP8 quant + paged cache insert ──
147+
kv_base = kv + tok * HEAD_DIM
148+
ins_ok = tok_ok & (tok < num_tokens_insert)
149+
slot_id = tl.load(slot_mapping + tok, mask=ins_ok, other=-1) # [TPP]
150+
ins_ok = ins_ok & (slot_id >= 0)
151+
152+
block_idx = slot_id // cache_block_size
153+
pos_in_block = slot_id % cache_block_size
154+
block_base = block_idx * kv_block_stride
155+
token_fp8 = block_base + pos_in_block * TOKEN_DATA_BYTES # [TPP] byte offset
156+
token_scale = (
157+
block_base
158+
+ cache_block_size * TOKEN_DATA_BYTES
159+
+ pos_in_block * SCALE_BYTES_PER_TOKEN
160+
)
161+
162+
bf16_ptr = (k_cache + token_fp8 + NOPE_DIM).to(tl.pointer_type(tl.bfloat16))
163+
tl.store(bf16_ptr[:, None] + off_rope[None, :], rope, mask=ins_ok[:, None])
164+
165+
for b in tl.static_range(NUM_QUANT_BLOCKS):
166+
blk = tl.load(
167+
kv_base[:, None] + b * QUANT_BLOCK + off_quant[None, :],
168+
mask=ins_ok[:, None],
169+
other=0.0,
170+
).to(tl.float32)
171+
block_max = tl.maximum(tl.max(tl.abs(blk), axis=1), 1e-4) # [TPP]
172+
exponent = tl.ceil(tl.log2(block_max / FP8_MAX))
173+
scale = tl.exp2(exponent)
174+
x = tl.clamp(blk / scale[:, None], -FP8_MAX, FP8_MAX)
175+
tl.store(
176+
k_cache + token_fp8[:, None] + b * QUANT_BLOCK + off_quant[None, :],
177+
x.to(tl.float8e4nv).to(tl.uint8, bitcast=True),
178+
mask=ins_ok[:, None],
179+
)
180+
enc = tl.maximum(tl.minimum(exponent + 127.0, 255.0), 0.0)
181+
tl.store(k_cache + token_scale + b, enc.to(tl.uint8), mask=ins_ok)
182+
183+
tl.store(
184+
k_cache + token_scale + NUM_QUANT_BLOCKS,
185+
tl.zeros((TPP,), dtype=tl.uint8),
186+
mask=ins_ok,
187+
)
188+
189+
190+
def fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
191+
q: torch.Tensor,
192+
kv: torch.Tensor,
193+
k_cache: torch.Tensor,
194+
slot_mapping: torch.Tensor,
195+
position_ids: torch.Tensor,
196+
cos_sin_cache: torch.Tensor,
197+
eps: float,
198+
cache_block_size: int,
199+
):
200+
"""See the generic implementation for the layout contract."""
201+
assert q.is_contiguous() and kv.is_contiguous()
202+
num_tokens, num_heads, head_dims = q.shape
203+
204+
if num_tokens < _TILED_MIN_TOKENS:
205+
# Small shapes are launch-bound; the narrower generic kernel wins.
206+
from flag_gems.fused.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert import (
207+
fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert as _generic,
208+
)
209+
210+
return _generic(
211+
q,
212+
kv,
213+
k_cache,
214+
slot_mapping,
215+
position_ids,
216+
cos_sin_cache,
217+
eps,
218+
cache_block_size,
219+
)
220+
221+
assert head_dims == 512
222+
assert kv.shape == (num_tokens, 512)
223+
assert q.dtype == torch.bfloat16 and kv.dtype == torch.bfloat16
224+
assert k_cache.dtype == torch.uint8
225+
assert slot_mapping.dim() == 1
226+
num_tokens_insert = slot_mapping.shape[0]
227+
assert num_tokens_insert <= num_tokens
228+
assert slot_mapping.dtype == torch.int64
229+
assert position_ids.shape == (num_tokens,)
230+
assert position_ids.dtype == torch.int64
231+
assert cos_sin_cache.dim() == 2 and cos_sin_cache.shape[1] == 64
232+
assert cos_sin_cache.dtype == torch.float32
233+
234+
grid = triton.cdiv(num_tokens, _TPP) * (num_heads + 1)
235+
_tiled_kernel[(grid,)](
236+
q,
237+
kv,
238+
k_cache,
239+
slot_mapping,
240+
position_ids,
241+
cos_sin_cache,
242+
eps,
243+
cache_block_size,
244+
num_tokens,
245+
num_heads,
246+
k_cache.stride(0),
247+
num_tokens_insert,
248+
TPP=_TPP,
249+
num_warps=_NUM_WARPS,
250+
num_stages=2,
251+
)

tests/test_fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -560,20 +560,11 @@ def test_combined_q_and_kv(num_tokens: int, n_heads: int, block_size: int):
560560
k_cache_compare(k_cache, k_cache_ref, block_size, rtol=1e-2, atol=1e-2)
561561

562562

563-
# ── Test 4: a backend override must match the generic implementation ─────────
564-
#
565-
# A vendor backend may replace this operator (runtime/backend/<vendor>/fused/).
566-
# That is a performance change and must not be a numerical one, so the active
567-
# implementation is compared against the generic kernel directly rather than
568-
# against the torch reference. Skipped where no override is registered, so this
569-
# is a no-op on backends using the generic kernel.
570-
#
571-
# Shapes deliberately straddle any internal dispatch threshold an override may
572-
# use, and include token counts that are not multiples of a tile width.
573-
563+
# ── Test 5: a backend override must not change results ───────────────────────
564+
# A vendor override is a performance change. It runs only where one is
565+
# registered, so nothing below executes on the generic path.
574566
_OVERRIDE_ACTIVE = (
575-
flag_gems.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert.__module__
576-
!= _generic_impl.__module__
567+
flag_gems.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert is not _generic_impl
577568
)
578569

579570

0 commit comments

Comments
 (0)