[GLM5] Implement op fusion for GLM5/DSV3.2 (#46876)
Woosuk Kwon committed
Jun 28, 2026 at 08:17 UTC
89876b0c548afdd932d41d5ef81b14347edb9ff7
9 files changed
+1530
-91
tests/kernels/test_fused_deepseek_v32_norm_rope.py
new
+423
@@ -0,0 +1,423 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for the horizontally-fused deepseek_v32 (NVIDIA SM100) Triton
4
+kernels used by the specialized DSA model:
5
+
6
+ fused_norm_rope
7
+ - q : q_lora RMSNorm
8
+ - kv : kv_lora RMSNorm + (interleaved) RoPE on k_pe + MLA cache insert
9
+ (bf16 or per-tensor fp8)
10
+ - idx: indexer-K LayerNorm + RoPE (interleaved or NeoX) + UE8M0 fp8 quant +
11
+ packed indexer cache insert; plus the top-k buffer (-1) fill
12
+ fused_q
13
+ - mqa: ql_nope + (interleaved) RoPE'd q_pe, concat-quantized to the fp8 MQA
14
+ query
15
+ - idx: indexer-Q RoPE (interleaved or NeoX) + UE8M0 fp8 quant + folded
16
+ index weights
17
+ fused_eh_norm (MTP): zero-at-pos-0 + enorm RMSNorm(embeds) + hnorm
18
+ RMSNorm(prev), concatenated side-by-side
19
+
20
+Each kernel is compared against a PyTorch reference. The kernel keeps the whole
21
+pipeline in fp32 and rounds once, so it can land on the opposite side of a
22
+round-to-nearest tie from the reference for a few elements: deterministic fp8
23
+outputs are checked within 1 representable-step (ULP); bf16 norm/RoPE outputs use
24
+rtol/atol=1e-2 (the tolerance the sibling deepseek_v4 fused-kernel test uses).
25
+"""
26
+
27
+import pytest
28
+import torch
29
+
30
+from vllm.models.deepseek_v32.nvidia import kernels as K
31
+from vllm.platforms import current_platform
32
+
33
+FP8 = torch.float8_e4m3fn
34
+FP8_MAX = 448.0
35
+
36
+# GLM-5.2 / DeepSeek-V3.2 shapes (TP8 local heads).
37
+Q_LORA = 2048
38
+KV_LORA = 512
39
+ROPE_DIM = 64
40
+NUM_HEADS = 8
41
+INDEX_HEADS = 32
42
+INDEX_HEAD_DIM = 128
43
+HIDDEN = 6144
44
+EPS = 1e-6
45
+
46
+pytestmark = pytest.mark.skipif(
47
+ not current_platform.is_cuda() or not current_platform.has_device_capability(89),
48
+ reason="deepseek_v32 fused kernels require CUDA with fp8 (SM89+)",
49
+)
50
+
51
+
52
+# ── reference helpers ────────────────────────────────────────────────────────
53
+
54
+
55
+def make_cos_sin(max_pos: int, rot_dim: int, device) -> torch.Tensor:
56
+ """cos||sin cache: row[pos] = [cos(theta)(rot/2), sin(theta)(rot/2)]."""
57
+ half = rot_dim // 2
58
+ inv_freq = 1.0 / (
59
+ 10000.0 ** (torch.arange(0, half, dtype=torch.float32, device=device) / half)
60
+ )
61
+ t = torch.arange(max_pos, dtype=torch.float32, device=device)
62
+ freqs = torch.einsum("i,j->ij", t, inv_freq)
63
+ return torch.cat([freqs.cos(), freqs.sin()], dim=-1)
64
+
65
+
66
+def rms_norm(x: torch.Tensor, w: torch.Tensor) -> torch.Tensor:
67
+ """RMSNorm matching kernels._rms_norm (fp32, eps inside rsqrt). Returns fp32."""
68
+ xf = x.float()
69
+ ms = xf.pow(2).mean(dim=-1, keepdim=True)
70
+ return xf * torch.rsqrt(ms + EPS) * w.float()
71
+
72
+
73
+def layer_norm(x: torch.Tensor, w: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
74
+ xf = x.float()
75
+ mean = xf.mean(dim=-1, keepdim=True)
76
+ var = (xf - mean).pow(2).mean(dim=-1, keepdim=True)
77
+ return (xf - mean) * torch.rsqrt(var + EPS) * w.float() + b.float()
78
+
79
+
80
+def rope(
81
+ x: torch.Tensor, pos: torch.Tensor, cos_sin: torch.Tensor, interleave: bool
82
+) -> torch.Tensor:
83
+ """Apply RoPE to the first ``rot_dim`` elements of x's last dim.
84
+
85
+ x: [..., head_dim] fp32. ``cos_sin`` is [max_pos, rot_dim]. ``interleave``
86
+ selects adjacent-pair (GLM) vs split-half NeoX (DeepSeek-V3.2) layout.
87
+ """
88
+ rot = cos_sin.shape[-1]
89
+ half = rot // 2
90
+ cs = cos_sin[pos.long()]
91
+ cos, sin = cs[..., :half], cs[..., half:]
92
+ out = x.float().clone()
93
+ r = out[..., :rot]
94
+ if interleave:
95
+ x1, x2 = r[..., 0::2].clone(), r[..., 1::2].clone()
96
+ r[..., 0::2] = x1 * cos - x2 * sin
97
+ r[..., 1::2] = x2 * cos + x1 * sin
98
+ else:
99
+ x1, x2 = r[..., :half].clone(), r[..., half:].clone()
100
+ r[..., :half] = x1 * cos - x2 * sin
101
+ r[..., half:] = x2 * cos + x1 * sin
102
+ return out
103
+
104
+
105
+def ue8m0_quant(vals: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
106
+ """Per-row (last dim) UE8M0 fp8 quant matching kernels._fp8_ue8m0_quantize."""
107
+ amax = vals.float().abs().amax(dim=-1, keepdim=True)
108
+ scale = torch.clamp(amax, min=1e-4) / FP8_MAX
109
+ scale = torch.exp2(torch.ceil(torch.log2(scale)))
110
+ q = (vals.float() / scale).to(FP8)
111
+ return q, scale.squeeze(-1)
112
+
113
+
114
+def _bf16_ulp(a: torch.Tensor, b: torch.Tensor) -> int:
115
+ def key(t):
116
+ u = t.contiguous().view(torch.int16).to(torch.int64) & 0xFFFF
117
+ return torch.where(u >= 0x8000, 0xFFFF - u, u + 0x8000)
118
+
119
+ return int((key(a) - key(b)).abs().max().item())
120
+
121
+
122
+def _fp8_ulp(a: torch.Tensor, b: torch.Tensor) -> int:
123
+ def key(t):
124
+ u = t.contiguous().view(torch.uint8).to(torch.int64)
125
+ return torch.where(u >= 0x80, 0xFF - u, u + 0x80)
126
+
127
+ return int((key(a) - key(b)).abs().max().item())
128
+
129
+
130
+def assert_bf16(got: torch.Tensor, ref_fp32: torch.Tensor, msg: str):
131
+ # Kernel keeps RMSNorm/RoPE in fp32 and rounds to bf16 once; the fp32
132
+ # reduction/FMA order differs from torch, so a few elements land on the
133
+ # opposite side of a round-to-nearest tie. Use the same tolerance the
134
+ # sibling deepseek_v4 fused-kernel test uses for this bf16 norm+rope class.
135
+ torch.testing.assert_close(
136
+ got.float(), ref_fp32.float(), rtol=1e-2, atol=1e-2, msg=lambda m: f"{msg}: {m}"
137
+ )
138
+
139
+
140
+def assert_fp8(got: torch.Tensor, ref: torch.Tensor, msg: str):
141
+ assert _fp8_ulp(got, ref) <= 1, f"{msg}: >1 fp8 ULP"
142
+
143
+
144
+# ── fused_norm_rope ──────────────────────────────────────────────────────────
145
+
146
+
147
+@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512, 4096])
148
+@pytest.mark.parametrize("index_interleave", [True, False])
149
+@pytest.mark.parametrize("mla_fp8", [False, True])
150
+def test_fused_norm_rope(num_tokens: int, index_interleave: bool, mla_fp8: bool):
151
+ torch.manual_seed(0)
152
+ dev = "cuda"
153
+ max_pos = 8192
154
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos
155
+
156
+ q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16)
157
+ kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16)
158
+ k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16)
159
+ qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16)
160
+ kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16)
161
+ ik = torch.randn(num_tokens, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16)
162
+ ikw = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
163
+ ikb = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
164
+
165
+ mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) # MLA k_pe: interleaved
166
+ idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
167
+
168
+ bs = max_pos # single block covering all tokens
169
+ mla_dim = KV_LORA + ROPE_DIM
170
+ if mla_fp8:
171
+ mla_cache = torch.zeros(1, bs, mla_dim, device=dev, dtype=torch.uint8)
172
+ mla_dtype = "fp8"
173
+ mla_k_scale = torch.tensor([0.3], device=dev, dtype=torch.float32)
174
+ else:
175
+ mla_cache = torch.zeros(1, bs, mla_dim, device=dev, dtype=torch.bfloat16)
176
+ mla_dtype = "auto"
177
+ mla_k_scale = None
178
+ idx_row = INDEX_HEAD_DIM + INDEX_HEAD_DIM // 128 * 4 # 132
179
+ idx_cache = torch.zeros(1, bs, idx_row, device=dev, dtype=torch.uint8)
180
+ slot = torch.arange(num_tokens, device=dev, dtype=torch.int64)
181
+ topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32)
182
+
183
+ q_out = K.fused_norm_rope(
184
+ pos,
185
+ q_c,
186
+ qw,
187
+ EPS,
188
+ kv_c,
189
+ kvw,
190
+ EPS,
191
+ k_pe,
192
+ mla_cos_sin,
193
+ ik,
194
+ ikw,
195
+ ikb,
196
+ EPS,
197
+ idx_cos_sin,
198
+ topk,
199
+ slot_mapping=slot,
200
+ indexer_k_cache=idx_cache,
201
+ mla_kv_cache=mla_cache,
202
+ mla_kv_cache_dtype=mla_dtype,
203
+ mla_k_scale=mla_k_scale,
204
+ has_indexer=True,
205
+ index_rope_interleave=index_interleave,
206
+ )
207
+
208
+ # q_lora RMSNorm
209
+ assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm")
210
+
211
+ # MLA cache: [kv_c_normed | k_pe_roped(interleaved)]
212
+ kv_ref = rms_norm(kv_c, kvw)
213
+ kpe_ref = rope(k_pe.float(), pos, mla_cos_sin, interleave=True)
214
+ if mla_fp8:
215
+ cache = mla_cache.view(FP8)[0, :num_tokens]
216
+ s = mla_k_scale.item()
217
+ assert_fp8(cache[:, :KV_LORA], (kv_ref / s).to(FP8), "MLA kv fp8")
218
+ assert_fp8(cache[:, KV_LORA:], (kpe_ref / s).to(FP8), "MLA k_pe fp8")
219
+ else:
220
+ cache = mla_cache[0, :num_tokens]
221
+ assert_bf16(cache[:, :KV_LORA], kv_ref, "MLA kv bf16")
222
+ assert_bf16(cache[:, KV_LORA:], kpe_ref, "MLA k_pe bf16")
223
+
224
+ # Indexer-K cache (packed [bs*head_dim fp8 | bs*4 fp32 scale]).
225
+ ik_ref = layer_norm(ik, ikw, ikb)
226
+ ik_ref = rope(ik_ref, pos, idx_cos_sin, interleave=index_interleave)
227
+ q_ref, s_ref = ue8m0_quant(ik_ref)
228
+ flat = idx_cache[0].reshape(-1)
229
+ vals = flat[: bs * INDEX_HEAD_DIM].view(FP8).reshape(bs, INDEX_HEAD_DIM)
230
+ scales = flat[bs * INDEX_HEAD_DIM :].view(torch.float32)
231
+ assert_fp8(vals[:num_tokens], q_ref, "indexer-K fp8")
232
+ torch.testing.assert_close(scales[:num_tokens], s_ref, rtol=0, atol=0)
233
+
234
+ # Top-k buffer cleared to -1 on indexer layers.
235
+ assert (topk == -1).all(), "topk buffer not cleared on indexer layer"
236
+
237
+
238
+@pytest.mark.parametrize("num_tokens", [1, 17, 512])
239
+def test_fused_norm_rope_no_indexer(num_tokens: int):
240
+ """Shared (no-indexer) layer: q + kv/MLA only; top-k buffer untouched."""
241
+ torch.manual_seed(1)
242
+ dev = "cuda"
243
+ max_pos = 8192
244
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
245
+
246
+ q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16)
247
+ kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16)
248
+ k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16)
249
+ qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16)
250
+ kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16)
251
+ mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
252
+
253
+ bs = max_pos
254
+ mla_cache = torch.zeros(1, bs, KV_LORA + ROPE_DIM, device=dev, dtype=torch.bfloat16)
255
+ slot = torch.arange(num_tokens, device=dev, dtype=torch.int64)
256
+ topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32)
257
+
258
+ q_out = K.fused_norm_rope(
259
+ pos,
260
+ q_c,
261
+ qw,
262
+ EPS,
263
+ kv_c,
264
+ kvw,
265
+ EPS,
266
+ k_pe,
267
+ mla_cos_sin,
268
+ None,
269
+ None,
270
+ None,
271
+ EPS,
272
+ None,
273
+ topk,
274
+ slot_mapping=slot,
275
+ indexer_k_cache=None,
276
+ mla_kv_cache=mla_cache,
277
+ mla_kv_cache_dtype="auto",
278
+ mla_k_scale=None,
279
+ has_indexer=False,
280
+ index_rope_interleave=False,
281
+ )
282
+
283
+ assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm (no-indexer)")
284
+ cache = mla_cache[0, :num_tokens]
285
+ assert_bf16(cache[:, :KV_LORA], rms_norm(kv_c, kvw), "MLA kv (no-indexer)")
286
+ assert_bf16(
287
+ cache[:, KV_LORA:],
288
+ rope(k_pe.float(), pos, mla_cos_sin, interleave=True),
289
+ "MLA k_pe (no-indexer)",
290
+ )
291
+ # Shared layers reuse the previous indexer's top-k: buffer must be untouched.
292
+ assert (topk == 7).all(), "topk buffer should be untouched on shared layer"
293
+
294
+
295
+# ── fused_q ──────────────────────────────────────────────────────────────────
296
+
297
+
298
+@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512, 4096])
299
+@pytest.mark.parametrize("index_interleave", [True, False])
300
+def test_fused_q(num_tokens: int, index_interleave: bool):
301
+ torch.manual_seed(2)
302
+ dev = "cuda"
303
+ max_pos = 8192
304
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos
305
+
306
+ q_pe = torch.randn(
307
+ num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16
308
+ )
309
+ ql_nope = torch.randn(
310
+ num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16
311
+ )
312
+ index_q = torch.randn(
313
+ num_tokens, INDEX_HEADS, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16
314
+ )
315
+ index_w = torch.randn(num_tokens, INDEX_HEADS, device=dev, dtype=torch.float32)
316
+ q_scale = torch.tensor([0.37], device=dev, dtype=torch.float32)
317
+ softmax_scale = INDEX_HEAD_DIM**-0.5
318
+ head_scale = INDEX_HEADS**-0.5
319
+ q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) # q_pe: interleaved
320
+ idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
321
+
322
+ iq_fp8, iw_out, mqa = K.fused_q(
323
+ pos,
324
+ q_pe,
325
+ q_cos_sin,
326
+ index_q,
327
+ idx_cos_sin,
328
+ ql_nope,
329
+ q_scale,
330
+ index_w,
331
+ softmax_scale,
332
+ head_scale,
333
+ has_indexer=True,
334
+ index_rope_interleave=index_interleave,
335
+ )
336
+
337
+ s = q_scale.item()
338
+ # MQA query: [ql_nope | q_pe RoPE'd (interleaved)], per-tensor fp8.
339
+ mqa_nope_ref = (ql_nope.float() / s).to(FP8)
340
+ qpe_ref = rope(
341
+ q_pe.float(),
342
+ pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS),
343
+ q_cos_sin,
344
+ interleave=True,
345
+ )
346
+ mqa_pe_ref = (qpe_ref / s).to(FP8)
347
+ assert_fp8(mqa[:, :, :KV_LORA], mqa_nope_ref, "mqa ql_nope")
348
+ assert_fp8(mqa[:, :, KV_LORA:], mqa_pe_ref, "mqa q_pe")
349
+
350
+ # Indexer-Q: RoPE + UE8M0 fp8 quant; index weights fold in q-scale.
351
+ iq_ref = rope(
352
+ index_q.float(),
353
+ pos.unsqueeze(-1).expand(num_tokens, INDEX_HEADS),
354
+ idx_cos_sin,
355
+ interleave=index_interleave,
356
+ )
357
+ q_ref, scale_ref = ue8m0_quant(iq_ref)
358
+ assert_fp8(iq_fp8, q_ref, "indexer-Q fp8")
359
+ iw_ref = index_w * scale_ref * softmax_scale * head_scale
360
+ torch.testing.assert_close(iw_out, iw_ref, rtol=1e-3, atol=1e-3)
361
+
362
+
363
+@pytest.mark.parametrize("num_tokens", [1, 17, 512])
364
+def test_fused_q_no_indexer(num_tokens: int):
365
+ torch.manual_seed(3)
366
+ dev = "cuda"
367
+ max_pos = 8192
368
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
369
+ q_pe = torch.randn(
370
+ num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16
371
+ )
372
+ ql_nope = torch.randn(
373
+ num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16
374
+ )
375
+ q_scale = torch.tensor([0.5], device=dev, dtype=torch.float32)
376
+ q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
377
+
378
+ _, _, mqa = K.fused_q(
379
+ pos,
380
+ q_pe,
381
+ q_cos_sin,
382
+ None,
383
+ None,
384
+ ql_nope,
385
+ q_scale,
386
+ None,
387
+ 0.0,
388
+ 0.0,
389
+ has_indexer=False,
390
+ index_rope_interleave=False,
391
+ )
392
+ s = q_scale.item()
393
+ assert_fp8(mqa[:, :, :KV_LORA], (ql_nope.float() / s).to(FP8), "mqa ql_nope")
394
+ qpe_ref = rope(
395
+ q_pe.float(),
396
+ pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS),
397
+ q_cos_sin,
398
+ interleave=True,
399
+ )
400
+ assert_fp8(mqa[:, :, KV_LORA:], (qpe_ref / s).to(FP8), "mqa q_pe")
401
+
402
+
403
+# ── fused_eh_norm (MTP) ──────────────────────────────────────────────────────
404
+
405
+
406
+@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512])
407
+def test_fused_eh_norm(num_tokens: int):
408
+ torch.manual_seed(4)
409
+ dev = "cuda"
410
+ # Mix in a position-0 token to exercise the embeds-zeroing branch.
411
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
412
+ pos[0] = 0
413
+ embeds = torch.randn(num_tokens, HIDDEN, device=dev, dtype=torch.bfloat16)
414
+ prev = torch.randn(num_tokens, HIDDEN, device=dev, dtype=torch.bfloat16)
415
+ ew = torch.randn(HIDDEN, device=dev, dtype=torch.bfloat16)
416
+ hw = torch.randn(HIDDEN, device=dev, dtype=torch.bfloat16)
417
+
418
+ out = K.fused_eh_norm(pos, embeds, prev, ew, hw, EPS)
419
+
420
+ masked = torch.where(pos.unsqueeze(-1) == 0, torch.zeros_like(embeds), embeds)
421
+ ref = torch.cat([rms_norm(masked, ew), rms_norm(prev, hw)], dim=-1)
422
+ assert out.shape == (num_tokens, 2 * HIDDEN)
423
+ assert_bf16(out, ref, "eh_norm")
vllm/model_executor/layers/fused_moe/config.py
+6
@@ -1287,6 +1287,12 @@ class FusedMoEConfig:
1287
has_bias: bool = False
1288
is_lora_enabled: bool = False
1289
1290
+ # When True, the MoE skips its final cross-rank all-reduce (and the separate
1291
+ # shared-expert reduce), returning the partial per-rank sum. The caller is
1292
+ # then responsible for the reduction (e.g. fusing it into the next RMSNorm).
1293
+ # Only honored on the non-reduced (late-AR) TP path. Default False.
1294
+ skip_final_all_reduce: bool = False
1295
+
1296
# SwiGLU clamp limit. When set, backends that do not implement the clamp
1297
# are filtered out by `FusedMoEExperts.is_supported_config` so the oracle
1298
# cannot silently select one and drop the clamp.
vllm/model_executor/layers/fused_moe/runner/moe_runner.py
+2
@@ -423,6 +423,7 @@ class MoERunner(MoERunnerInterface):
423
if (
424
shared_output is not None
425
and not self.moe_config.is_sequence_parallel
426
+ and not self.moe_config.skip_final_all_reduce
427
and self._fused_output_is_reduced
428
):
429
shared_output = tensor_model_parallel_all_reduce(shared_output)
@@ -445,6 +446,7 @@ class MoERunner(MoERunnerInterface):
446
# - The MK already reduced the fused output itself.
447
if (
448
not self.moe_config.is_sequence_parallel
449
+ and not self.moe_config.skip_final_all_reduce
450
and (self.moe_config.tp_size > 1 or self.moe_config.ep_size > 1)
451
and not self._fused_output_is_reduced
452
):
vllm/model_executor/layers/sparse_attn_indexer.py
+9
-1
@@ -227,6 +227,7 @@ def sparse_attn_indexer(
227
topk_indices_buffer: torch.Tensor,
228
skip_k_cache_insert: bool,
229
use_fp4_cache: bool = False,
230
+ skip_topk_buffer_clear: bool = False,
231
) -> torch.Tensor:
232
# careful! this will be None in dummy run
233
attn_metadata = get_forward_context().attn_metadata
@@ -303,7 +304,13 @@ def sparse_attn_indexer(
304
scale_fmt,
305
)
306
306
- topk_indices_buffer[: hidden_states.shape[0]] = -1
307
+ # The buffer must be pre-filled with -1 (the "no token" sentinel) before the
308
+ # top-k kernels scatter valid indices into it. On the fused deepseek_v32
309
+ # nvidia path, _fused_norm_rope_kernel already cleared the same
310
+ # [:num_tokens, :topk] region earlier in this forward, so skip the redundant
311
+ # fill.
312
+ if not skip_topk_buffer_clear:
313
+ topk_indices_buffer[: hidden_states.shape[0]] = -1
314
if has_prefill:
315
prefill_metadata = attn_metadata_narrowed.prefill
316
assert prefill_metadata is not None
@@ -546,6 +553,7 @@ def sparse_attn_indexer_fake(
553
topk_indices_buffer: torch.Tensor | None,
554
skip_k_cache_insert: bool,
555
use_fp4_cache: bool = False,
556
+ skip_topk_buffer_clear: bool = False,
557
) -> torch.Tensor:
558
return topk_indices_buffer
559
vllm/models/deepseek_v32/nvidia/attention.py
+168
-81
@@ -1,7 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from typing import TYPE_CHECKING
4
-
3
import torch
4
import torch.nn as nn
5
from transformers import DeepseekV2Config, DeepseekV3Config
@@ -23,7 +21,10 @@ from vllm.model_executor.layers.quantization.utils.fp8_utils import (
21
per_token_group_quant_fp8,
22
)
23
from vllm.model_executor.layers.rotary_embedding import get_rope
26
-from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
24
+from vllm.model_executor.layers.sparse_attn_indexer import (
25
+ SparseAttnIndexer,
26
+ sparse_attn_indexer,
27
+)
28
from vllm.model_executor.models.deepseek_v2 import (
29
DeepSeekV2FusedQkvAProjLinear,
30
DeepseekV32IndexerCache,
@@ -32,10 +33,7 @@ from vllm.model_executor.models.deepseek_v2 import (
33
from vllm.model_executor.models.utils import extract_layer_index
34
from vllm.utils.torch_utils import is_quantized_kv_cache
35
35
-if TYPE_CHECKING:
36
- from vllm.model_executor.layers.attention.mla_attention import (
37
- MLACommonMetadata,
38
- )
36
+from .kernels import fused_norm_rope, fused_q
37
38
39
class DeepseekV32Indexer(nn.Module):
@@ -160,6 +158,10 @@ class DeepseekV32Indexer(nn.Module):
158
159
160
class DeepseekV32Attention(MLAAttention):
161
+ # Narrow the base's broadly-typed `indexer` to the concrete type so the
162
+ # `if self.indexer is not None` guards below type-check its attributes.
163
+ indexer: "DeepseekV32Indexer | None"
164
+
165
def __init__(
166
self,
167
vllm_config: VllmConfig,
@@ -263,21 +265,27 @@ class DeepseekV32Attention(MLAAttention):
265
self.num_local_heads = num_local_heads
266
self.qk_head_dim = qk_head_dim
267
self.indexer = indexer
268
+ self.topk_indices_buffer = topk_indices_buffer
269
# Runtime toggle for index_share_for_mtp_iteration: MTP draft step 0
270
# computes the top-k, steps 1+ set this True to reuse it.
271
self.skip_topk = False
269
- # Whether the paged KV cache must be viewed as fp8 before the attention
270
- # (per-tensor fp8; the fp8_ds_mla layout is read as uint8).
271
- self._fp8_kv_needs_view = (
272
- is_quantized_kv_cache(self.kv_cache_dtype)
273
- and self.kv_cache_dtype != "fp8_ds_mla"
274
- )
275
- # Whether the backend takes an fp8-quantized query (FlashInfer sparse)
276
- # vs the (ql_nope, q_pe) tuple (FlashMLA sparse).
277
- self._use_concat_quant = (
272
+ # Single fused fp8 path: Triton fused norm/rope/cache + fused-q write a
273
+ # single fp8 MQA query and the contiguous [kv_c; k_pe] MLA cache layout.
274
+ # This requires an fp8 KV cache and a sparse MLA backend that accepts a
275
+ # quantized query (FlashInfer sparse on SM100).
276
+ assert (
277
is_quantized_kv_cache(self.kv_cache_dtype)
278
and self.impl.supports_quant_query_input
279
+ ), (
280
+ "deepseek_v32 (nvidia) requires an fp8 KV cache served by the "
281
+ "FlashInfer sparse MLA backend (which accepts a quantized query). "
282
+ "Launch with --kv-cache-dtype fp8."
283
)
284
+ # The paged KV cache is stored as uint8 and viewed as fp8 for the decode
285
+ # (per-tensor fp8; never the fp8_ds_mla layout on this path).
286
+ self._fp8_kv_needs_view = self.kv_cache_dtype != "fp8_ds_mla"
287
+ # GLM-5.2 uses interleaved indexer RoPE; DeepSeek-V3.2 uses NeoX.
288
+ self._index_rope_interleave = getattr(config, "indexer_rope_interleave", False)
289
290
# Remaining MLA projections (registered on this module).
291
self.fused_qkv_a_proj = DeepSeekV2FusedQkvAProjLinear(
@@ -295,10 +303,14 @@ class DeepseekV32Attention(MLAAttention):
303
prefix=f"{prefix}.q_b_proj",
304
)
305
self.kv_a_layernorm = RMSNorm(kv_lora_rank, eps=config.rms_norm_eps)
306
+ # reduce_results=False: the attention all-reduce is fused with the
307
+ # following post_attention_layernorm in the decoder layer via
308
+ # fused_allreduce_rms_norm.
309
self.o_proj = RowParallelLinear(
310
num_heads * v_head_dim,
311
hidden_size,
312
bias=False,
313
+ reduce_results=False,
314
quant_config=quant_config,
315
prefix=f"{prefix}.o_proj",
316
)
@@ -322,102 +334,177 @@ class DeepseekV32Attention(MLAAttention):
334
positions: torch.Tensor,
335
hidden_states: torch.Tensor,
336
) -> torch.Tensor:
337
+ # Captured: A-projections (+ indexer A-GEMM on indexer layers).
338
qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
326
- q_c, kv_lora = qkv_lora.split(
327
- [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim], dim=-1
328
- )
329
- q_c = self.q_a_layernorm(q_c)
330
- q = self.q_b_proj(q_c)[0]
331
-
332
- kv_c, k_pe = kv_lora.split([self.kv_lora_rank, self.qk_rope_head_dim], dim=-1)
333
- kv_c_normed = self.kv_a_layernorm(kv_c)
334
-
335
- q = q.view(-1, self.num_local_heads, self.qk_head_dim)
336
- k_pe = k_pe.unsqueeze(1)
337
- q[..., self.qk_nope_head_dim :], k_pe = self.rotary_emb(
338
- positions, q[..., self.qk_nope_head_dim :], k_pe
339
+ q_c, kv_c, k_pe = qkv_lora.split(
340
+ [self.q_lora_rank, self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
341
)
342
341
- num_tokens = hidden_states.shape[0]
342
- q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
343
- q_nope = q_nope.transpose(0, 1) # (N, B, P)
344
- ql_nope = torch.bmm(q_nope, self.W_UK_T).transpose(0, 1) # (B, N, L)
345
-
346
- # Lightning indexer writes the top-k indices into the shared buffer.
347
- # "Shared" layers (indexer is None) reuse the top-k from the previous
348
- # indexer layer already sitting in the buffer.
343
if self.indexer is not None and not self.skip_topk:
350
- self.indexer(hidden_states, q_c, positions, self.indexer_rope_emb) # type: ignore[operator]
351
-
352
- attn_latent = torch.empty(
353
- (num_tokens, self.num_local_heads, self.kv_lora_rank),
354
- dtype=q.dtype,
355
- device=q.device,
356
- )
357
- self._sparse_attention(kv_c_normed, k_pe, ql_nope, q_pe, attn_latent)
344
+ kw = self.indexer.wk_weights_proj(hidden_states)[0]
345
+ index_k = kw[:, : self.indexer.head_dim]
346
+ index_weights = kw[:, self.indexer.head_dim :]
347
+ else:
348
+ index_k = None
349
+ index_weights = None
350
359
- # V up-projection + output projection are metadata-independent GEMMs and
360
- # stay captured.
351
+ num_tokens = hidden_states.shape[0]
352
output = torch.empty(
353
(num_tokens, self.num_local_heads * self.v_head_dim),
363
- dtype=q.dtype,
364
- device=q.device,
354
+ dtype=hidden_states.dtype,
355
+ device=hidden_states.device,
356
+ )
357
+ self._fused_attention(
358
+ positions, q_c, kv_c, k_pe, index_k, index_weights, output
359
)
366
- self._v_up_proj(attn_latent, out=output)
360
return self.o_proj(output)[0]
361
362
@eager_break_during_capture
370
- def _sparse_attention(
363
+ def _fused_attention(
364
self,
372
- kv_c_normed: torch.Tensor,
365
+ positions: torch.Tensor,
366
+ q_c: torch.Tensor,
367
+ kv_c: torch.Tensor,
368
k_pe: torch.Tensor,
374
- ql_nope: torch.Tensor,
375
- q_pe: torch.Tensor,
376
- attn_latent: torch.Tensor,
369
+ index_k: torch.Tensor | None,
370
+ index_weights: torch.Tensor | None,
371
+ output: torch.Tensor,
372
) -> None:
373
+ # One eager break for the whole attention. In FULL cudagraph mode (pure
374
+ # decode) this decorator is a no-op, so everything here is captured; in
375
+ # PIECEWISE (prefill) it runs eagerly. The cache writes, sparse indexer,
376
+ # and forward_mqa all depend on per-step metadata and must not be split
377
+ # out (PIECEWISE capture would otherwise miss them).
378
forward_context = get_forward_context()
379
attn_metadata_raw = forward_context.attn_metadata
380
- attn_metadata: MLACommonMetadata | None
380
if isinstance(attn_metadata_raw, dict):
382
- attn_metadata = attn_metadata_raw[self.layer_name] # type: ignore[assignment]
381
+ attn_metadata = attn_metadata_raw.get(self.layer_name)
382
elif isinstance(attn_metadata_raw, list):
384
- # Speculative decoding: [0] is the base-model metadata dict.
385
- attn_metadata = attn_metadata_raw[0][self.layer_name] # type: ignore[assignment]
383
+ attn_metadata = attn_metadata_raw[0].get(self.layer_name)
384
else:
385
attn_metadata = attn_metadata_raw
386
387
slot_mapping = forward_context.slot_mapping
388
assert isinstance(slot_mapping, dict)
391
- self.impl.do_kv_cache_update( # type: ignore[attr-defined]
392
- kv_c_normed,
389
+ mla_slot = slot_mapping.get(self.layer_name)
390
+
391
+ if self.indexer is not None:
392
+ has_indexer = True
393
+ indexer_k_norm_w = self.indexer.k_norm.weight
394
+ indexer_k_norm_bias = self.indexer.k_norm.bias
395
+ indexer_k_norm_eps = self.indexer.k_norm.variance_epsilon
396
+ indexer_k_rope_cos_sin_cache = self.indexer_rope_emb.cos_sin_cache
397
+ indexer_k_cache = self.indexer.k_cache.kv_cache
398
+ indexer_softmax_scale = self.indexer.softmax_scale
399
+ indexer_n_head_scale = self.indexer.n_head**-0.5
400
+ else:
401
+ has_indexer = False
402
+ indexer_k_norm_w = None
403
+ indexer_k_norm_bias = None
404
+ indexer_k_norm_eps = 1e-6
405
+ indexer_k_rope_cos_sin_cache = None
406
+ indexer_k_cache = None
407
+ indexer_softmax_scale = 0.0
408
+ indexer_n_head_scale = 0.0
409
+
410
+ if attn_metadata is None:
411
+ mla_kv_cache = None
412
+ mla_k_scale = None
413
+ indexer_k_cache = None
414
+ mla_slot = None
415
+ else:
416
+ mla_kv_cache = self.kv_cache
417
+ mla_k_scale = self._k_scale
418
+
419
+ q_c = fused_norm_rope(
420
+ positions,
421
+ q_c,
422
+ self.q_a_layernorm.weight,
423
+ self.q_a_layernorm.variance_epsilon,
424
+ kv_c,
425
+ self.kv_a_layernorm.weight,
426
+ self.kv_a_layernorm.variance_epsilon,
427
k_pe,
394
- self.kv_cache,
395
- slot_mapping.get(self.layer_name),
396
- self.kv_cache_dtype,
397
- self._k_scale,
428
+ self.rotary_emb.cos_sin_cache,
429
+ index_k,
430
+ indexer_k_norm_w,
431
+ indexer_k_norm_bias,
432
+ indexer_k_norm_eps,
433
+ indexer_k_rope_cos_sin_cache,
434
+ self.topk_indices_buffer,
435
+ slot_mapping=mla_slot,
436
+ indexer_k_cache=indexer_k_cache,
437
+ mla_kv_cache=mla_kv_cache,
438
+ mla_kv_cache_dtype=self.kv_cache_dtype,
439
+ mla_k_scale=mla_k_scale,
440
+ has_indexer=has_indexer,
441
+ index_rope_interleave=self._index_rope_interleave,
442
+ )
443
+
444
+ q = self.q_b_proj(q_c)[0].view(-1, self.num_local_heads, self.qk_head_dim)
445
+ q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
446
+ q_nope = q_nope.transpose(0, 1)
447
+ ql_nope = torch.bmm(q_nope, self.W_UK_T).transpose(0, 1)
448
+
449
+ if self.indexer is not None:
450
+ index_q = self.indexer.wq_b(q_c)[0]
451
+ index_q = index_q.view(-1, self.indexer.n_head, self.indexer.head_dim)
452
+ else:
453
+ index_q = None
454
+
455
+ index_q_fp8, index_weights_out, mqa_q = fused_q(
456
+ positions,
457
+ q_pe,
458
+ self.rotary_emb.cos_sin_cache,
459
+ index_q,
460
+ self.indexer_rope_emb.cos_sin_cache if has_indexer else None,
461
+ ql_nope,
462
+ self._q_scale,
463
+ index_weights,
464
+ indexer_softmax_scale,
465
+ indexer_n_head_scale,
466
+ has_indexer=has_indexer,
467
+ index_rope_interleave=self._index_rope_interleave,
468
)
469
470
if attn_metadata is None:
401
- # Profile / warmup: zero-fill for DP+EP determinism.
402
- attn_latent.zero_()
471
+ output.zero_()
472
return
473
405
- num_actual = attn_metadata.num_actual_tokens
474
+ if self.indexer is not None:
475
+ sparse_attn_indexer(
476
+ q_c,
477
+ self.indexer.k_cache.prefix,
478
+ self.indexer.k_cache.kv_cache,
479
+ index_q_fp8,
480
+ None, # q_scale folded into weights on the fp8 path
481
+ None, # k unused when skip_k_cache_insert=True
482
+ index_weights_out,
483
+ self.indexer.quant_block_size,
484
+ self.indexer.scale_fmt,
485
+ self.indexer.topk_tokens,
486
+ self.indexer.head_dim,
487
+ self.indexer.max_model_len,
488
+ self.indexer.max_total_seq_len,
489
+ self.topk_indices_buffer,
490
+ True, # skip_k_cache_insert
491
+ False, # use_fp4_cache
492
+ True, # skip_topk_buffer_clear (fused_norm_rope already did it)
493
+ )
494
+
495
+ num_actual = attn_metadata.num_actual_tokens # type: ignore[attr-defined]
496
kv_cache = self.kv_cache
497
if self._fp8_kv_needs_view:
498
kv_cache = kv_cache.view(torch.float8_e4m3fn)
409
-
410
- ql_nope = ql_nope[:num_actual]
411
- q_pe = q_pe[:num_actual]
412
- # FlashInfer sparse takes a single fp8-quantized query; FlashMLA sparse
413
- # takes the (ql_nope, q_pe) tuple and concatenates internally.
414
- mqa_q: torch.Tensor | tuple[torch.Tensor, torch.Tensor]
415
- if self._use_concat_quant:
416
- mqa_q = self._decode_concat_quant_fp8_op(ql_nope, q_pe, self._q_scale)
417
- else:
418
- mqa_q = (ql_nope, q_pe)
419
-
420
- attn_out, _ = self.impl.forward_mqa(mqa_q, kv_cache, attn_metadata, self) # type: ignore[attr-defined]
421
- attn_latent[:num_actual] = attn_out.view(
499
+ attn_out, _ = self.impl.forward_mqa( # type: ignore[attr-defined]
500
+ mqa_q[:num_actual], kv_cache, attn_metadata, self
501
+ )
502
+ x = attn_out.view(
503
num_actual, self.num_local_heads, self.kv_lora_rank
504
+ ).transpose(0, 1)
505
+ out = (
506
+ output[:num_actual]
507
+ .view(num_actual, self.num_local_heads, self.v_head_dim)
508
+ .transpose(0, 1)
509
)
510
+ torch.bmm(x, self.W_UV, out=out)
vllm/models/deepseek_v32/nvidia/fused_ops.py
new
+63
@@ -0,0 +1,63 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Fused ops for deepseek_v32 (eager / breakable-cudagraph path).
4
+
5
+These recover fusions that vLLM's torch.compile passes would normally do but
6
+that don't fire when running eager under the breakable CUDA graph.
7
+"""
8
+
9
+import torch
10
+
11
+from vllm.distributed import (
12
+ get_tensor_model_parallel_world_size,
13
+ tensor_model_parallel_all_reduce,
14
+)
15
+from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
16
+ _AR_RESIDUAL_RMS_NORM,
17
+ _can_use_flashinfer,
18
+ flashinfer_trtllm_fused_allreduce_norm,
19
+)
20
+from vllm.model_executor.layers.layernorm import RMSNorm
21
+
22
+
23
+def fused_allreduce_rms_norm(
24
+ hidden_states: torch.Tensor,
25
+ residual: torch.Tensor,
26
+ norm: RMSNorm,
27
+) -> tuple[torch.Tensor, torch.Tensor]:
28
+ """All-reduce + add residual + (standard) RMSNorm, fused via flashinfer.
29
+
30
+ ``hidden_states`` is the per-rank *partial* output of a row-parallel linear
31
+ run with ``reduce_results=False``; ``norm`` is the RMSNorm applied right
32
+ after. Returns ``(normed_output, new_residual)``, equivalent to
33
+ ``norm(all_reduce(hidden_states), residual)``. Falls back to an explicit
34
+ all-reduce + RMSNorm when the flashinfer fast path is unavailable.
35
+ """
36
+ tp_size = get_tensor_model_parallel_world_size()
37
+ if tp_size == 1:
38
+ return norm(hidden_states, residual)
39
+
40
+ if flashinfer_trtllm_fused_allreduce_norm is not None:
41
+ ok, max_token_num = _can_use_flashinfer(hidden_states, tp_size)
42
+ if ok:
43
+ norm_out = torch.empty_like(hidden_states)
44
+ # With norm_out provided, the kernel writes the new residual
45
+ # (all_reduce(hidden_states) + residual) into the hidden_states
46
+ # buffer and the normalized result into norm_out.
47
+ flashinfer_trtllm_fused_allreduce_norm(
48
+ allreduce_in=hidden_states,
49
+ residual=residual,
50
+ rms_gamma=norm.weight,
51
+ rms_eps=norm.variance_epsilon,
52
+ world_size=tp_size,
53
+ weight_bias=0.0, # standard RMSNorm (Gemma would use 1.0)
54
+ launch_with_pdl=True,
55
+ fp32_acc=True,
56
+ max_token_num=max_token_num,
57
+ pattern_code=_AR_RESIDUAL_RMS_NORM,
58
+ norm_out=norm_out,
59
+ )
60
+ return norm_out, hidden_states
61
+
62
+ reduced = tensor_model_parallel_all_reduce(hidden_states)
63
+ return norm(reduced, residual)
vllm/models/deepseek_v32/nvidia/kernels.py
new
+823
@@ -0,0 +1,823 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import torch
4
+
5
+from vllm.triton_utils import tl, triton
6
+
7
+# Cache of tiny 1-element dummy tensors (per device, dtype) reused by the
8
+# has_indexer=False path so the indexer args don't allocate every call.
9
+_DUMMY_CACHE: dict[tuple, torch.Tensor] = {}
10
+
11
+
12
+def _dummy(shape: tuple, dtype: torch.dtype, device: torch.device) -> torch.Tensor:
13
+ key = (shape, dtype, device)
14
+ t = _DUMMY_CACHE.get(key)
15
+ if t is None:
16
+ t = torch.empty(shape, dtype=dtype, device=device)
17
+ _DUMMY_CACHE[key] = t
18
+ return t
19
+
20
+
21
+@triton.jit
22
+def _rms_norm(x, w, eps, HIDDEN_SIZE: tl.constexpr):
23
+ x = x.to(tl.float32)
24
+ mean_sq = tl.sum(x * x, axis=0) / HIDDEN_SIZE
25
+ rrms = tl.rsqrt(mean_sq + eps)
26
+ w = w.to(tl.float32)
27
+ return (x * rrms) * w
28
+
29
+
30
+@triton.jit
31
+def _get_cos_sin(
32
+ cos_sin_cache_ptr,
33
+ cos_sin_cache_stride,
34
+ pos,
35
+ HALF_ROT_DIM: tl.constexpr,
36
+):
37
+ block = tl.arange(0, HALF_ROT_DIM)
38
+ cos = tl.load(cos_sin_cache_ptr + pos * cos_sin_cache_stride + block)
39
+ cos = cos.to(tl.float32)
40
+ sin = tl.load(cos_sin_cache_ptr + pos * cos_sin_cache_stride + block + HALF_ROT_DIM)
41
+ sin = sin.to(tl.float32)
42
+ return cos, sin
43
+
44
+
45
+@triton.jit
46
+def _fp8_ue8m0_quantize(vals):
47
+ """Quantize float32 values to FP8 E4M3 with a ue8m0 (power-of-2) scale.
48
+
49
+ Returns (fp8_vals, scale) so the caller can store them or reuse the scale.
50
+ """
51
+ vals = vals.to(tl.float32)
52
+ amax = tl.max(tl.abs(vals))
53
+ scale = tl.div_rn(tl.maximum(amax, 1e-4), 448.0)
54
+ scale = tl.math.exp2(tl.math.ceil(tl.math.log2(scale)))
55
+ fp8_vals = tl.div_rn(vals, scale).to(tl.float8e4nv)
56
+ return fp8_vals, scale
57
+
58
+
59
+@triton.jit
60
+def _fp8_quant_and_cache_write(
61
+ vals,
62
+ mask,
63
+ slot_idx,
64
+ kv_cache_ptr,
65
+ kv_cache_scale_ptr,
66
+ cache_block_size,
67
+ cache_stride,
68
+ offsets,
69
+ HEAD_DIM: tl.constexpr,
70
+):
71
+ k_fp8, scale = _fp8_ue8m0_quantize(vals)
72
+
73
+ block_idx = slot_idx // cache_block_size
74
+ block_offset = slot_idx % cache_block_size
75
+ block_start = block_idx * cache_block_size * cache_stride
76
+
77
+ tl.store(
78
+ kv_cache_ptr + block_start + block_offset * HEAD_DIM + offsets,
79
+ k_fp8,
80
+ mask=mask,
81
+ )
82
+ scale_byte_off = block_start + cache_block_size * HEAD_DIM + block_offset * 4
83
+ tl.store(kv_cache_scale_ptr + scale_byte_off // 4, scale)
84
+
85
+
86
+@triton.jit
87
+def _fused_norm_rope_kernel(
88
+ pos_ptr,
89
+ # Q RMS norm
90
+ q_c_ptr,
91
+ q_c_stride,
92
+ q_rms_norm_w_ptr,
93
+ q_rms_eps,
94
+ q_c_out_ptr,
95
+ q_c_out_stride,
96
+ Q_DIM: tl.constexpr,
97
+ Q_BLOCK_SIZE: tl.constexpr,
98
+ # KV RMS norm
99
+ kv_ptr,
100
+ kv_stride,
101
+ kv_rms_norm_w_ptr,
102
+ kv_rms_eps,
103
+ KV_DIM: tl.constexpr,
104
+ # KV RoPE
105
+ kpe_ptr,
106
+ kpe_stride,
107
+ kpe_rope_cos_sin_cache_ptr,
108
+ kpe_rope_cos_sin_cache_stride,
109
+ KPE_HALF_ROT_DIM: tl.constexpr,
110
+ # Index K layer norm
111
+ index_k_ptr,
112
+ index_k_stride,
113
+ index_k_layer_norm_w_ptr,
114
+ index_k_layer_norm_bias_ptr,
115
+ index_k_layer_norm_eps,
116
+ INDEX_K_DIM: tl.constexpr,
117
+ INDEX_K_BLOCK_SIZE: tl.constexpr,
118
+ # Index K RoPE
119
+ index_k_rope_cos_sin_cache_ptr,
120
+ index_k_rope_cos_sin_cache_stride,
121
+ INDEX_K_HALF_ROT_DIM: tl.constexpr,
122
+ # Cache params (shared by indexer K and MLA)
123
+ slot_mapping_ptr,
124
+ # Index K FP8 cache
125
+ indexer_cache_ptr,
126
+ indexer_cache_scale_ptr,
127
+ indexer_cache_block_size,
128
+ indexer_cache_stride,
129
+ # MLA KV cache (concat kv_c_normed + k_pe_roped, uses slot_mapping_ptr)
130
+ mla_cache_ptr,
131
+ mla_cache_block_stride,
132
+ mla_cache_entry_stride,
133
+ MLA_CACHE_FP8: tl.constexpr,
134
+ mla_cache_scale_ptr,
135
+ # Top k indices
136
+ topk_indices_ptr,
137
+ topk_indices_stride,
138
+ TOPK: tl.constexpr,
139
+ TOPK_BLOCK_SIZE: tl.constexpr,
140
+ HAS_INDEXER: tl.constexpr,
141
+ INDEX_ROPE_INTERLEAVE: tl.constexpr,
142
+):
143
+ pid = tl.program_id(0)
144
+ tok_idx = tl.program_id(1)
145
+ if pid == 3:
146
+ if not HAS_INDEXER:
147
+ # Shared layer: reuse the previous indexer layer's top-k; do not
148
+ # clear the buffer.
149
+ return
150
+ # Fill top k indices buffer with -1
151
+ for i in range(0, TOPK, TOPK_BLOCK_SIZE):
152
+ offset = i + tl.arange(0, TOPK_BLOCK_SIZE)
153
+ mask = offset < TOPK
154
+ tl.store(
155
+ topk_indices_ptr + tok_idx * topk_indices_stride + offset,
156
+ -1,
157
+ mask=mask,
158
+ )
159
+ return
160
+
161
+ if slot_mapping_ptr is None:
162
+ # Memory profiling run.
163
+ return
164
+ slot_idx = tl.load(slot_mapping_ptr + tok_idx)
165
+ if slot_idx < 0:
166
+ # Padding
167
+ return
168
+
169
+ if pid == 2:
170
+ # Q RMS norm
171
+ q_block = tl.arange(0, Q_BLOCK_SIZE)
172
+ q_mask = q_block < Q_DIM
173
+ q_c = tl.load(q_c_ptr + tok_idx * q_c_stride + q_block, mask=q_mask, other=0.0)
174
+ q_c_rms_w = tl.load(q_rms_norm_w_ptr + q_block, mask=q_mask)
175
+ q_c = _rms_norm(q_c, q_c_rms_w, q_rms_eps, Q_DIM)
176
+ tl.store(q_c_out_ptr + tok_idx * q_c_out_stride + q_block, q_c, mask=q_mask)
177
+ elif pid == 1:
178
+ # KV RMS Norm + KV RoPE + MLA concat_and_cache.
179
+ # Merged so the normed kv_c and RoPE'd k_pe can be written
180
+ # to the MLA KV cache directly without a separate kernel.
181
+
182
+ # KV RMS Norm (result stays in registers for MLA cache write)
183
+ kv_block = tl.arange(0, KV_DIM)
184
+ kv_c = tl.load(kv_ptr + tok_idx * kv_stride + kv_block)
185
+ kv_c_rms_w = tl.load(kv_rms_norm_w_ptr + kv_block)
186
+ kv_c = _rms_norm(kv_c, kv_c_rms_w, kv_rms_eps, KV_DIM)
187
+
188
+ # KV RoPE (interleaved) on k_pe — in registers only.
189
+ # k_pe is not needed after the cache write (MLA decode reads
190
+ # from kv_cache), so we skip writing back to kpe_ptr.
191
+ pos = tl.load(pos_ptr + tok_idx)
192
+ cos, sin = _get_cos_sin(
193
+ kpe_rope_cos_sin_cache_ptr,
194
+ kpe_rope_cos_sin_cache_stride,
195
+ pos,
196
+ KPE_HALF_ROT_DIM,
197
+ )
198
+ dim_off = tl.arange(0, KPE_HALF_ROT_DIM)
199
+ kpe_base = kpe_ptr + tok_idx * kpe_stride
200
+ x1 = tl.load(kpe_base + dim_off * 2).to(tl.float32)
201
+ x2 = tl.load(kpe_base + dim_off * 2 + 1).to(tl.float32)
202
+ r1 = x1 * cos - x2 * sin
203
+ r2 = x2 * cos + x1 * sin
204
+
205
+ # MLA concat_and_cache: write [kv_c_normed, k_pe_roped] to cache.
206
+ if mla_cache_entry_stride == 0:
207
+ return
208
+
209
+ mla_block_size = mla_cache_block_stride // mla_cache_entry_stride
210
+ mla_block_idx = slot_idx // mla_block_size
211
+ mla_block_off = slot_idx % mla_block_size
212
+ dst = (
213
+ mla_cache_ptr
214
+ + mla_block_idx * mla_cache_block_stride
215
+ + mla_block_off * mla_cache_entry_stride
216
+ )
217
+ # kv_c_normed (KV_DIM elements)
218
+ if MLA_CACHE_FP8:
219
+ scale = tl.load(mla_cache_scale_ptr)
220
+ kv_c_fp8 = (kv_c.to(tl.float32) / scale).to(tl.float8e4nv)
221
+ tl.store(dst + kv_block, kv_c_fp8)
222
+ else:
223
+ tl.store(dst + kv_block, kv_c)
224
+ # k_pe_roped (from registers, interleaved layout)
225
+ if MLA_CACHE_FP8:
226
+ tl.store(dst + KV_DIM + dim_off * 2, (r1 / scale).to(tl.float8e4nv))
227
+ tl.store(dst + KV_DIM + dim_off * 2 + 1, (r2 / scale).to(tl.float8e4nv))
228
+ else:
229
+ tl.store(dst + KV_DIM + dim_off * 2, r1)
230
+ tl.store(dst + KV_DIM + dim_off * 2 + 1, r2)
231
+ elif pid == 0:
232
+ if not HAS_INDEXER:
233
+ # Shared layer: no indexer K to process.
234
+ return
235
+ # Fused: Index K LayerNorm + RoPE + FP8 quant + cache write.
236
+ # Eliminates the separate indexer_k_quant_and_cache kernel launch.
237
+
238
+ index_k_block = tl.arange(0, INDEX_K_BLOCK_SIZE)
239
+ index_k_mask = index_k_block < INDEX_K_DIM
240
+ index_k = tl.load(
241
+ index_k_ptr + tok_idx * index_k_stride + index_k_block,
242
+ mask=index_k_mask,
243
+ other=0.0,
244
+ ).to(tl.float32)
245
+ index_k_w = tl.load(
246
+ index_k_layer_norm_w_ptr + index_k_block, mask=index_k_mask
247
+ ).to(tl.float32)
248
+ index_k_b = tl.load(
249
+ index_k_layer_norm_bias_ptr + index_k_block, mask=index_k_mask
250
+ ).to(tl.float32)
251
+
252
+ # 1. LayerNorm. Keep (mean, rstd) so the RoPE rotation partner can be
253
+ # re-normalized in registers below, avoiding a global scratch buffer.
254
+ mean = tl.sum(index_k, axis=0) / INDEX_K_DIM
255
+ diff = tl.where(index_k_mask, index_k - mean, 0.0)
256
+ var = tl.sum(diff * diff, axis=0) / INDEX_K_DIM
257
+ rstd = tl.rsqrt(var + index_k_layer_norm_eps)
258
+ normed = (index_k - mean) * rstd * index_k_w + index_k_b
259
+
260
+ # 2. RoPE on the rotation region. Supports both interleaved (adjacent
261
+ # pairs, e.g. GLM-5.2) and NeoX (split-half, e.g. DeepSeek-V3.2). The
262
+ # rotation partner is gathered from the read-only inputs and
263
+ # re-normalized with the same (mean, rstd) — no scratch, no atomics.
264
+ pos = tl.load(pos_ptr + tok_idx)
265
+ in_rope = index_k_block < 2 * INDEX_K_HALF_ROT_DIM
266
+ if INDEX_ROPE_INTERLEAVE:
267
+ # pair i = block // 2; partner = block ^ 1; even -> -sin, odd -> +sin.
268
+ cos_idx = index_k_block // 2
269
+ partner_offs = tl.where(in_rope, index_k_block ^ 1, index_k_block)
270
+ sign = tl.where(index_k_block % 2 == 0, -1.0, 1.0)
271
+ else:
272
+ # NeoX: pair across halves; partner = block ^ HALF.
273
+ cos_idx = index_k_block % INDEX_K_HALF_ROT_DIM
274
+ partner_offs = tl.where(
275
+ in_rope, index_k_block ^ INDEX_K_HALF_ROT_DIM, index_k_block
276
+ )
277
+ sign = tl.where(index_k_block < INDEX_K_HALF_ROT_DIM, -1.0, 1.0)
278
+ cos_full = tl.load(
279
+ index_k_rope_cos_sin_cache_ptr
280
+ + pos * index_k_rope_cos_sin_cache_stride
281
+ + cos_idx,
282
+ mask=in_rope,
283
+ other=1.0,
284
+ ).to(tl.float32)
285
+ sin_full = tl.load(
286
+ index_k_rope_cos_sin_cache_ptr
287
+ + pos * index_k_rope_cos_sin_cache_stride
288
+ + INDEX_K_HALF_ROT_DIM
289
+ + cos_idx,
290
+ mask=in_rope,
291
+ other=0.0,
292
+ ).to(tl.float32)
293
+ # normed[partner_offs] == (raw_partner - mean) * rstd * w_partner +
294
+ # b_partner: gather the raw partner and its norm affine (read-only
295
+ # loads), then apply the same per-token mean/rstd.
296
+ raw_partner = tl.load(
297
+ index_k_ptr + tok_idx * index_k_stride + partner_offs,
298
+ mask=index_k_mask,
299
+ other=0.0,
300
+ ).to(tl.float32)
301
+ w_partner = tl.load(
302
+ index_k_layer_norm_w_ptr + partner_offs, mask=index_k_mask
303
+ ).to(tl.float32)
304
+ b_partner = tl.load(
305
+ index_k_layer_norm_bias_ptr + partner_offs, mask=index_k_mask
306
+ ).to(tl.float32)
307
+ normed_partner = (raw_partner - mean) * rstd * w_partner + b_partner
308
+ roped = normed * cos_full + sign * normed_partner * sin_full
309
+ result = tl.where(in_rope, roped, normed)
310
+
311
+ # 3. FP8 quantize + cache write from registers.
312
+ # No need to write back to index_k_ptr — the only consumer
313
+ # (sparse_attn_indexer) reads from the cache, not index_k.
314
+ _fp8_quant_and_cache_write(
315
+ result,
316
+ index_k_mask,
317
+ slot_idx,
318
+ indexer_cache_ptr,
319
+ indexer_cache_scale_ptr,
320
+ indexer_cache_block_size,
321
+ indexer_cache_stride,
322
+ index_k_block,
323
+ INDEX_K_DIM,
324
+ )
325
+
326
+
327
+def fused_norm_rope(
328
+ positions: torch.Tensor,
329
+ q_c: torch.Tensor,
330
+ q_rms_norm_w: torch.Tensor,
331
+ q_rms_eps: float,
332
+ kv_c: torch.Tensor,
333
+ kv_rms_norm_w: torch.Tensor,
334
+ kv_rms_eps: float,
335
+ k_pe: torch.Tensor,
336
+ k_rope_cos_sin_cache: torch.Tensor,
337
+ index_k: torch.Tensor | None,
338
+ index_k_layer_norm_w: torch.Tensor | None,
339
+ index_k_layer_norm_bias: torch.Tensor | None,
340
+ index_k_layer_norm_eps: float,
341
+ index_k_rope_cos_sin_cache: torch.Tensor | None,
342
+ topk_indices_buffer: torch.Tensor,
343
+ # Cache params for fused writes (single slot_mapping for both caches)
344
+ slot_mapping: torch.Tensor | None = None,
345
+ indexer_k_cache: torch.Tensor | None = None,
346
+ mla_kv_cache: torch.Tensor | None = None,
347
+ mla_kv_cache_dtype: str = "auto",
348
+ mla_k_scale: torch.Tensor | None = None,
349
+ has_indexer: bool = True,
350
+ index_rope_interleave: bool = False,
351
+ q_c_out: torch.Tensor | None = None,
352
+) -> torch.Tensor:
353
+ assert positions.ndim == 1
354
+ assert q_c.ndim == 2
355
+ assert kv_c.ndim == 2
356
+ assert k_pe.ndim == 2
357
+ assert topk_indices_buffer.ndim == 2
358
+
359
+ num_tokens = positions.shape[0]
360
+ q_dim = q_c.shape[-1]
361
+ kv_dim = kv_c.shape[-1]
362
+ device = positions.device
363
+
364
+ # Shared (no-indexer) layers: substitute cached 1-element dummies so the
365
+ # kernel launches cleanly; pid 0/3 (indexer + topk fill) skipped by
366
+ # HAS_INDEXER and never dereference them.
367
+ if not has_indexer:
368
+ indexer_k_cache = None
369
+ index_k = _dummy((1, 1), q_c.dtype, device)
370
+ index_k_layer_norm_w = _dummy((1,), torch.float32, device)
371
+ index_k_layer_norm_bias = _dummy((1,), torch.float32, device)
372
+ index_k_rope_cos_sin_cache = k_rope_cos_sin_cache
373
+ assert index_k is not None
374
+ assert index_k_rope_cos_sin_cache is not None
375
+ index_k_dim = index_k.shape[-1]
376
+ topk = topk_indices_buffer.shape[-1]
377
+
378
+ # --- Indexer K cache setup ---
379
+ if indexer_k_cache is not None:
380
+ assert slot_mapping is not None
381
+ idx_cache_scale_view = indexer_k_cache.view(torch.uint8).view(torch.float32)
382
+ idx_cache_block_size = indexer_k_cache.shape[1]
383
+ idx_cache_stride = indexer_k_cache.shape[2]
384
+ if indexer_k_cache.dtype == torch.uint8:
385
+ indexer_k_cache = indexer_k_cache.view(torch.float8_e4m3fn)
386
+ else:
387
+ # No indexer cache (shared layer / MLA-only fusion). Use dummies but
388
+ # KEEP the caller's slot_mapping so the MLA write (pid 1) still runs.
389
+ idx_cache_scale_view = torch.empty(0, dtype=torch.float32, device=device)
390
+ indexer_k_cache = torch.empty(0, dtype=torch.float8_e4m3fn, device=device)
391
+ idx_cache_block_size = 1
392
+ idx_cache_stride = 1
393
+ if mla_kv_cache is None:
394
+ # Pure profiling run (no caches at all): skip all per-token writes.
395
+ slot_mapping = torch.full(
396
+ (num_tokens,), -1, dtype=torch.int64, device=device
397
+ )
398
+
399
+ # --- MLA KV cache setup ---
400
+ mla_cache_fp8 = mla_kv_cache_dtype != "auto"
401
+ if mla_kv_cache is not None:
402
+ mla_block_stride = mla_kv_cache.stride(0)
403
+ mla_entry_stride = mla_kv_cache.stride(1)
404
+ if mla_cache_fp8 and mla_kv_cache.dtype == torch.uint8:
405
+ mla_kv_cache = mla_kv_cache.view(torch.float8_e4m3fn)
406
+ if mla_k_scale is None:
407
+ mla_k_scale = torch.ones(1, dtype=torch.float32, device=device)
408
+ else:
409
+ # Dummy values — pid 2 will skip the MLA cache write because
410
+ # slot_mapping is all -1.
411
+ mla_kv_cache = torch.empty(0, dtype=torch.bfloat16, device=device)
412
+ mla_block_stride = 0
413
+ mla_entry_stride = 0
414
+ mla_k_scale = torch.ones(1, dtype=torch.float32, device=device)
415
+
416
+ if q_c_out is None:
417
+ q_c_out = torch.empty_like(q_c)
418
+ _fused_norm_rope_kernel[(4, num_tokens)](
419
+ positions,
420
+ # Q RMS norm
421
+ q_c,
422
+ q_c.stride(0),
423
+ q_rms_norm_w,
424
+ q_rms_eps,
425
+ q_c_out,
426
+ q_c_out.stride(0),
427
+ q_dim,
428
+ triton.next_power_of_2(q_dim),
429
+ # KV RMS norm
430
+ kv_c,
431
+ kv_c.stride(0),
432
+ kv_rms_norm_w,
433
+ kv_rms_eps,
434
+ kv_dim,
435
+ # KV RoPE
436
+ k_pe,
437
+ k_pe.stride(0),
438
+ k_rope_cos_sin_cache,
439
+ k_rope_cos_sin_cache.stride(0),
440
+ k_rope_cos_sin_cache.shape[-1] // 2,
441
+ # Index K layer norm + RoPE + FP8 quant
442
+ index_k,
443
+ index_k.stride(0),
444
+ index_k_layer_norm_w,
445
+ index_k_layer_norm_bias,
446
+ index_k_layer_norm_eps,
447
+ index_k_dim,
448
+ triton.next_power_of_2(index_k_dim),
449
+ index_k_rope_cos_sin_cache,
450
+ index_k_rope_cos_sin_cache.stride(0),
451
+ index_k_rope_cos_sin_cache.shape[-1] // 2,
452
+ # Cache params
453
+ slot_mapping,
454
+ indexer_k_cache,
455
+ idx_cache_scale_view,
456
+ idx_cache_block_size,
457
+ idx_cache_stride,
458
+ # MLA KV cache (uses same slot_mapping)
459
+ mla_kv_cache,
460
+ mla_block_stride,
461
+ mla_entry_stride,
462
+ mla_cache_fp8,
463
+ mla_k_scale,
464
+ # Top k indices buffer
465
+ topk_indices_buffer,
466
+ topk_indices_buffer.stride(0),
467
+ topk,
468
+ TOPK_BLOCK_SIZE=1024,
469
+ HAS_INDEXER=has_indexer,
470
+ INDEX_ROPE_INTERLEAVE=index_rope_interleave,
471
+ )
472
+ return q_c_out
473
+
474
+
475
+@triton.jit
476
+def _fused_q_kernel(
477
+ pos_ptr,
478
+ # MQA query PE: RoPE + FP8 pack into output tail
479
+ q_pe_ptr,
480
+ q_pe_stride0,
481
+ q_pe_stride1,
482
+ NUM_Q_HEADS: tl.constexpr,
483
+ q_pe_cos_sin_ptr,
484
+ q_pe_cos_sin_stride,
485
+ Q_PE_HALF_ROT_DIM: tl.constexpr,
486
+ # Index Q RoPE
487
+ index_q_ptr,
488
+ index_q_stride0,
489
+ index_q_stride1,
490
+ NUM_INDEX_Q_HEADS: tl.constexpr,
491
+ index_q_cos_sin_ptr,
492
+ index_q_cos_sin_stride,
493
+ INDEX_Q_HALF_ROT_DIM: tl.constexpr,
494
+ # Index Q Quantize
495
+ index_q_fp8_ptr,
496
+ index_q_fp8_stride0,
497
+ index_q_fp8_stride1,
498
+ INDEX_Q_HEAD_DIM: tl.constexpr,
499
+ # MQA query pack: quantize ql_nope and RoPE+quantize q_pe into mqa_q_fp8
500
+ ql_nope_ptr,
501
+ ql_nope_stride0,
502
+ ql_nope_stride1,
503
+ mqa_q_fp8_ptr,
504
+ mqa_q_fp8_stride0,
505
+ mqa_q_fp8_stride1,
506
+ q_scale_ptr,
507
+ QL_NOPE_DIM: tl.constexpr,
508
+ QL_NOPE_BLOCK: tl.constexpr,
509
+ # Index weights
510
+ index_weights_ptr,
511
+ index_weights_stride,
512
+ index_weights_softmax_scale,
513
+ index_weights_head_scale,
514
+ index_weights_out_ptr,
515
+ index_weights_out_stride,
516
+ HAS_INDEXER: tl.constexpr,
517
+ INDEX_ROPE_INTERLEAVE: tl.constexpr,
518
+):
519
+ pid = tl.program_id(0)
520
+ tok_idx = tl.program_id(1)
521
+ head_idx = tl.program_id(2)
522
+
523
+ if pid == 2:
524
+ # ql_nope quantize + pack into the front of mqa_q_fp8.
525
+ if 2 * head_idx >= NUM_Q_HEADS:
526
+ return
527
+
528
+ scale = tl.load(q_scale_ptr)
529
+ for local_head in range(2):
530
+ q_head_idx = head_idx * 2 + local_head
531
+ if q_head_idx < NUM_Q_HEADS:
532
+ ql_nope_off = tl.arange(0, QL_NOPE_BLOCK)
533
+ ql_nope_mask = ql_nope_off < QL_NOPE_DIM
534
+ ql_nope = tl.load(
535
+ ql_nope_ptr
536
+ + tok_idx * ql_nope_stride0
537
+ + q_head_idx * ql_nope_stride1
538
+ + ql_nope_off,
539
+ mask=ql_nope_mask,
540
+ ).to(tl.float32)
541
+ ql_nope_fp8 = (ql_nope / scale).to(tl.float8e4nv)
542
+ tl.store(
543
+ mqa_q_fp8_ptr
544
+ + tok_idx * mqa_q_fp8_stride0
545
+ + q_head_idx * mqa_q_fp8_stride1
546
+ + ql_nope_off,
547
+ ql_nope_fp8,
548
+ mask=ql_nope_mask,
549
+ )
550
+ return
551
+ elif pid == 0:
552
+ # q_pe RoPE + quantize + pack into the tail of mqa_q_fp8.
553
+ if 2 * head_idx >= NUM_Q_HEADS:
554
+ return
555
+
556
+ pos = tl.load(pos_ptr + tok_idx)
557
+ cos, sin = _get_cos_sin(
558
+ q_pe_cos_sin_ptr,
559
+ q_pe_cos_sin_stride,
560
+ pos,
561
+ Q_PE_HALF_ROT_DIM,
562
+ )
563
+
564
+ scale = tl.load(q_scale_ptr)
565
+ for local_head in range(2):
566
+ q_head_idx = head_idx * 2 + local_head
567
+ if q_head_idx < NUM_Q_HEADS:
568
+ rot_off = tl.arange(0, Q_PE_HALF_ROT_DIM)
569
+ x1 = tl.load(
570
+ q_pe_ptr
571
+ + tok_idx * q_pe_stride0
572
+ + q_head_idx * q_pe_stride1
573
+ + rot_off * 2,
574
+ ).to(tl.float32)
575
+ x2 = tl.load(
576
+ q_pe_ptr
577
+ + tok_idx * q_pe_stride0
578
+ + q_head_idx * q_pe_stride1
579
+ + rot_off * 2
580
+ + 1
581
+ ).to(tl.float32)
582
+ r1 = x1 * cos - x2 * sin
583
+ r2 = x2 * cos + x1 * sin
584
+ tl.store(
585
+ mqa_q_fp8_ptr
586
+ + tok_idx * mqa_q_fp8_stride0
587
+ + q_head_idx * mqa_q_fp8_stride1
588
+ + QL_NOPE_DIM
589
+ + rot_off * 2,
590
+ (r1 / scale).to(tl.float8e4nv),
591
+ )
592
+ tl.store(
593
+ mqa_q_fp8_ptr
594
+ + tok_idx * mqa_q_fp8_stride0
595
+ + q_head_idx * mqa_q_fp8_stride1
596
+ + QL_NOPE_DIM
597
+ + rot_off * 2
598
+ + 1,
599
+ (r2 / scale).to(tl.float8e4nv),
600
+ )
601
+ return
602
+ elif pid == 1:
603
+ # Index Q RoPE + fp8 quant, all in registers. The roped bf16 index_q is
604
+ # never consumed (only the fp8 below is), so we avoid an in-place
605
+ # store-then-reload round-trip.
606
+ if not HAS_INDEXER:
607
+ return
608
+ if head_idx >= NUM_INDEX_Q_HEADS:
609
+ return
610
+
611
+ pos = tl.load(pos_ptr + tok_idx)
612
+ index_q_block = tl.arange(0, INDEX_Q_HEAD_DIM)
613
+ iq_base = index_q_ptr + tok_idx * index_q_stride0 + head_idx * index_q_stride1
614
+ index_q = tl.load(iq_base + index_q_block).to(tl.float32)
615
+
616
+ # RoPE in registers (interleaved for GLM-5.2, NeoX for DeepSeek-V3.2),
617
+ # gathering the rotation partner from the read-only input.
618
+ in_rope = index_q_block < 2 * INDEX_Q_HALF_ROT_DIM
619
+ if INDEX_ROPE_INTERLEAVE:
620
+ cos_idx = index_q_block // 2
621
+ partner_offs = tl.where(in_rope, index_q_block ^ 1, index_q_block)
622
+ sign = tl.where(index_q_block % 2 == 0, -1.0, 1.0)
623
+ else:
624
+ cos_idx = index_q_block % INDEX_Q_HALF_ROT_DIM
625
+ partner_offs = tl.where(
626
+ in_rope, index_q_block ^ INDEX_Q_HALF_ROT_DIM, index_q_block
627
+ )
628
+ sign = tl.where(index_q_block < INDEX_Q_HALF_ROT_DIM, -1.0, 1.0)
629
+ cos_full = tl.load(
630
+ index_q_cos_sin_ptr + pos * index_q_cos_sin_stride + cos_idx,
631
+ mask=in_rope,
632
+ other=1.0,
633
+ ).to(tl.float32)
634
+ sin_full = tl.load(
635
+ index_q_cos_sin_ptr
636
+ + pos * index_q_cos_sin_stride
637
+ + INDEX_Q_HALF_ROT_DIM
638
+ + cos_idx,
639
+ mask=in_rope,
640
+ other=0.0,
641
+ ).to(tl.float32)
642
+ partner = tl.load(iq_base + partner_offs).to(tl.float32)
643
+ roped = index_q * cos_full + sign * partner * sin_full
644
+ index_q = tl.where(in_rope, roped, index_q)
645
+
646
+ # Index Q Quantize (from registers)
647
+ index_q_fp8, index_q_scale = _fp8_ue8m0_quantize(index_q)
648
+ tl.store(
649
+ index_q_fp8_ptr
650
+ + tok_idx * index_q_fp8_stride0
651
+ + head_idx * index_q_fp8_stride1
652
+ + index_q_block,
653
+ index_q_fp8,
654
+ )
655
+
656
+ # Index weights update
657
+ index_weights = tl.load(
658
+ index_weights_ptr + tok_idx * index_weights_stride + head_idx
659
+ )
660
+ index_weights = index_weights.to(tl.float32)
661
+ index_weights *= index_q_scale
662
+ index_weights *= index_weights_softmax_scale
663
+ index_weights *= index_weights_head_scale
664
+ tl.store(
665
+ index_weights_out_ptr + tok_idx * index_weights_out_stride + head_idx,
666
+ index_weights,
667
+ )
668
+
669
+
670
+def fused_q(
671
+ positions: torch.Tensor,
672
+ q_pe: torch.Tensor,
673
+ q_pe_cos_sin_cache: torch.Tensor,
674
+ index_q: torch.Tensor | None,
675
+ index_q_cos_sin_cache: torch.Tensor | None,
676
+ ql_nope: torch.Tensor,
677
+ q_scale: torch.Tensor,
678
+ # Index weights
679
+ index_weights: torch.Tensor | None,
680
+ index_weights_softmax_scale: float,
681
+ index_weights_head_scale: float,
682
+ has_indexer: bool = True,
683
+ index_rope_interleave: bool = False,
684
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
685
+ assert positions.ndim == 1
686
+ assert q_pe.ndim == 3
687
+ assert q_pe_cos_sin_cache.ndim == 2
688
+ assert ql_nope.ndim == 3
689
+ assert ql_nope.shape[:2] == q_pe.shape[:2]
690
+
691
+ num_tokens = positions.shape[0]
692
+ num_q_heads = q_pe.shape[1]
693
+ # Grid's 3rd dim must cover the MQA-pack heads (pid 0/2 iterate 2 heads
694
+ # each) and, when present, the indexer heads (pid 1).
695
+ mqa_grid_heads = (num_q_heads + 1) // 2
696
+ if not has_indexer:
697
+ # Shared layer: cached 1-element dummies; pid 1 skipped by HAS_INDEXER
698
+ # and never dereferences them.
699
+ index_q = _dummy((1, 1, 1), q_pe.dtype, q_pe.device)
700
+ index_q_cos_sin_cache = q_pe_cos_sin_cache
701
+ index_weights = _dummy((1, 1), torch.float32, q_pe.device)
702
+ assert index_q is not None and index_q.ndim == 3
703
+ assert index_q_cos_sin_cache is not None
704
+ assert index_weights is not None
705
+ num_index_q_heads = index_q.shape[1]
706
+ index_q_head_dim = index_q.shape[2]
707
+ grid_heads = max(mqa_grid_heads, num_index_q_heads)
708
+ mqa_q_fp8 = torch.empty(
709
+ q_pe.shape[0],
710
+ q_pe.shape[1],
711
+ ql_nope.shape[2] + q_pe.shape[2],
712
+ dtype=torch.float8_e4m3fn,
713
+ device=q_pe.device,
714
+ )
715
+
716
+ index_q_fp8 = torch.empty_like(index_q, dtype=torch.float8_e4m3fn)
717
+ index_weights_out = torch.empty_like(index_weights, dtype=torch.float32)
718
+ _fused_q_kernel[(3, num_tokens, grid_heads)](
719
+ positions,
720
+ q_pe,
721
+ q_pe.stride(0),
722
+ q_pe.stride(1),
723
+ num_q_heads,
724
+ q_pe_cos_sin_cache,
725
+ q_pe_cos_sin_cache.stride(0),
726
+ q_pe_cos_sin_cache.shape[-1] // 2,
727
+ index_q,
728
+ index_q.stride(0),
729
+ index_q.stride(1),
730
+ num_index_q_heads,
731
+ index_q_cos_sin_cache,
732
+ index_q_cos_sin_cache.stride(0),
733
+ index_q_cos_sin_cache.shape[-1] // 2,
734
+ index_q_fp8,
735
+ index_q_fp8.stride(0),
736
+ index_q_fp8.stride(1),
737
+ index_q_head_dim,
738
+ ql_nope,
739
+ ql_nope.stride(0),
740
+ ql_nope.stride(1),
741
+ mqa_q_fp8,
742
+ mqa_q_fp8.stride(0),
743
+ mqa_q_fp8.stride(1),
744
+ q_scale,
745
+ ql_nope.shape[2],
746
+ triton.next_power_of_2(ql_nope.shape[2]),
747
+ index_weights,
748
+ index_weights.stride(0),
749
+ index_weights_softmax_scale,
750
+ index_weights_head_scale,
751
+ index_weights_out,
752
+ index_weights_out.stride(0),
753
+ HAS_INDEXER=has_indexer,
754
+ INDEX_ROPE_INTERLEAVE=index_rope_interleave,
755
+ # num_warps=1 is optimal here: each program is a single 128-element
756
+ # rope+quant, so the kernel is program-count/occupancy bound, not
757
+ # per-program compute bound (swept 1/2/4/8 — 1 wins or ties everywhere).
758
+ num_warps=1,
759
+ )
760
+ return index_q_fp8, index_weights_out, mqa_q_fp8
761
+
762
+
763
+@triton.jit
764
+def _fused_eh_norm_kernel(
765
+ pos_ptr,
766
+ embeds_ptr,
767
+ embeds_stride,
768
+ prev_ptr,
769
+ prev_stride,
770
+ enorm_w_ptr,
771
+ hnorm_w_ptr,
772
+ eps,
773
+ out_ptr,
774
+ out_stride,
775
+ H: tl.constexpr,
776
+ BLOCK: tl.constexpr,
777
+):
778
+ """MTP input fusion: zero embeds at position 0, RMSNorm(embeds) with enorm
779
+ and RMSNorm(prev_hidden) with hnorm, written side-by-side into ``out``
780
+ ([N, 2H]) ready for the eh_proj GEMM. Replaces where + 2x RMSNorm + cat."""
781
+ tok = tl.program_id(0)
782
+ off = tl.arange(0, BLOCK)
783
+ mask = off < H
784
+
785
+ pos = tl.load(pos_ptr + tok)
786
+ e = tl.load(embeds_ptr + tok * embeds_stride + off, mask=mask, other=0.0)
787
+ e = tl.where(pos == 0, 0.0, e.to(tl.float32))
788
+ ew = tl.load(enorm_w_ptr + off, mask=mask)
789
+ e_normed = _rms_norm(e, ew, eps, H)
790
+ tl.store(out_ptr + tok * out_stride + off, e_normed, mask=mask)
791
+
792
+ p = tl.load(prev_ptr + tok * prev_stride + off, mask=mask, other=0.0)
793
+ hw = tl.load(hnorm_w_ptr + off, mask=mask)
794
+ p_normed = _rms_norm(p, hw, eps, H)
795
+ tl.store(out_ptr + tok * out_stride + H + off, p_normed, mask=mask)
796
+
797
+
798
+def fused_eh_norm(
799
+ positions: torch.Tensor,
800
+ inputs_embeds: torch.Tensor,
801
+ previous_hidden: torch.Tensor,
802
+ enorm_w: torch.Tensor,
803
+ hnorm_w: torch.Tensor,
804
+ eps: float,
805
+) -> torch.Tensor:
806
+ """Returns cat([enorm(masked embeds), hnorm(prev_hidden)]) -> [N, 2H]."""
807
+ n, h = inputs_embeds.shape
808
+ out = torch.empty(n, 2 * h, dtype=inputs_embeds.dtype, device=inputs_embeds.device)
809
+ _fused_eh_norm_kernel[(n,)](
810
+ positions,
811
+ inputs_embeds,
812
+ inputs_embeds.stride(0),
813
+ previous_hidden,
814
+ previous_hidden.stride(0),
815
+ enorm_w,
816
+ hnorm_w,
817
+ eps,
818
+ out,
819
+ out.stride(0),
820
+ h,
821
+ triton.next_power_of_2(h),
822
+ )
823
+ return out
vllm/models/deepseek_v32/nvidia/model.py
+21
-3
@@ -36,6 +36,7 @@ from vllm.model_executor.models.utils import (
36
from vllm.sequence import IntermediateTensors
37
38
from .attention import DeepseekV32Attention
39
+from .fused_ops import fused_allreduce_rms_norm
40
41
42
class DeepseekV32DecoderLayer(torch.nn.Module):
@@ -77,6 +78,10 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
78
quant_config=quant_config,
79
prefix=f"{prefix}.mlp",
80
)
81
+ # Defer the MoE cross-rank all-reduce; it is fused into the next
82
+ # layer's input_layernorm (or the final norm) via
83
+ # fused_allreduce_rms_norm. self.mlp.experts is the MoERunner.
84
+ self.mlp.experts.moe_config.skip_final_all_reduce = True
85
else:
86
self.mlp = DeepseekV2MLP(
87
hidden_size=config.hidden_size,
@@ -84,6 +89,7 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
89
hidden_act=config.hidden_act,
90
quant_config=quant_config,
91
prefix=f"{prefix}.mlp",
92
+ reduce_results=False,
93
)
94
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
95
self.post_attention_layernorm = RMSNorm(
@@ -98,12 +104,23 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
104
residual: torch.Tensor | None,
105
) -> tuple[torch.Tensor, torch.Tensor]:
106
if residual is None:
107
+ # First layer: hidden_states is the (already reduced) embedding.
108
residual = hidden_states
109
hidden_states = self.input_layernorm(hidden_states)
110
else:
104
- hidden_states, residual = self.input_layernorm(hidden_states, residual)
111
+ # The previous layer's MLP/MoE output is left un-reduced; fuse its
112
+ # all-reduce into this input_layernorm.
113
+ hidden_states, residual = fused_allreduce_rms_norm(
114
+ hidden_states, residual, self.input_layernorm
115
+ )
116
+ # self_attn's o_proj runs reduce_results=False; fuse its all-reduce with
117
+ # the post-attention RMSNorm.
118
hidden_states = self.self_attn(positions=positions, hidden_states=hidden_states)
106
- hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
119
+ hidden_states, residual = fused_allreduce_rms_norm(
120
+ hidden_states, residual, self.post_attention_layernorm
121
+ )
122
+ # MLP/MoE runs un-reduced; its all-reduce is fused into the next layer's
123
+ # input_layernorm (or the model's final norm).
124
hidden_states = self.mlp(hidden_states)
125
return hidden_states, residual
126
@@ -201,7 +218,8 @@ class DeepseekV32Model(torch.nn.Module):
218
{"hidden_states": hidden_states, "residual": residual}
219
)
220
204
- hidden_states, _ = self.norm(hidden_states, residual)
221
+ # Last layer's MoE output is un-reduced; fuse its all-reduce into norm.
222
+ hidden_states, _ = fused_allreduce_rms_norm(hidden_states, residual, self.norm)
223
if len(aux_hidden_states) > 0:
224
return hidden_states, aux_hidden_states
225
return hidden_states
vllm/models/deepseek_v32/nvidia/mtp.py
+15
-6
@@ -1,6 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
3
import typing
4
from collections.abc import Callable, Iterable
5
@@ -9,6 +8,7 @@ import torch.nn as nn
8
9
from vllm._aiter_ops import rocm_aiter_ops
10
from vllm.config import VllmConfig
11
+from vllm.distributed import tensor_model_parallel_all_reduce
12
from vllm.model_executor.layers.fused_moe import (
13
fused_moe_make_expert_params_mapping,
14
)
@@ -35,6 +35,7 @@ from vllm.model_executor.models.utils import (
35
from vllm.platforms import current_platform
36
from vllm.sequence import IntermediateTensors
37
38
+from .kernels import fused_eh_norm
39
from .model import DeepseekV32DecoderLayer
40
41
@@ -75,15 +76,23 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
76
spec_step_index: int = 0,
77
) -> torch.Tensor:
78
assert inputs_embeds is not None
78
- inputs_embeds = torch.where(positions.unsqueeze(-1) == 0, 0, inputs_embeds)
79
- inputs_embeds = self.enorm(inputs_embeds)
80
- previous_hidden_states = self.hnorm(previous_hidden_states)
81
- hidden_states = self.eh_proj(
82
- torch.cat([inputs_embeds, previous_hidden_states], dim=-1)
79
+ # Fused: zero pos-0 embeds + enorm(embeds) + hnorm(prev) + cat -> [N, 2H].
80
+ eh_input = fused_eh_norm(
81
+ positions,
82
+ inputs_embeds,
83
+ previous_hidden_states,
84
+ self.enorm.weight,
85
+ self.hnorm.weight,
86
+ self.enorm.variance_epsilon,
87
)
88
+ hidden_states = self.eh_proj(eh_input)
89
hidden_states, residual = self.mtp_block(
90
positions=positions, hidden_states=hidden_states, residual=None
91
)
92
+ # mtp_block's MoE output is left un-reduced (skip_final_all_reduce); the
93
+ # main model fuses that all-reduce into the next norm, but here the
94
+ # recycle hidden is consumed directly, so reduce it now.
95
+ hidden_states = tensor_model_parallel_all_reduce(hidden_states)
96
# Return the pre-final-norm recycle hidden (re-fed as the next spec
97
# step's previous_hidden_states); shared_head norm is applied in
98
# compute_logits. Matches the V2-runner / deepseek_v4 MTP contract.