@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 9e19f8338b
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 6, 20:30 UTC

[Perf] add packed recurrent fast path for decode (#36596)

Signed-off-by: hdj <1293066020@qq.com> Co-authored-by: Roger Wang <hey@rogerw.io>

caozuoba committed Mar 12, 2026 at 19:01 UTC 9e19f8338b4098047175ca3119d5ae0368bcf24a
5 files changed +402 -4
tests/kernels/test_fused_recurrent_packed_decode.py new
+98
@@ -0,0 +1,98 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +import torch
6 +
7 +from vllm.model_executor.layers.fla.ops import (
8 + fused_recurrent_gated_delta_rule,
9 + fused_recurrent_gated_delta_rule_packed_decode,
10 +)
11 +
12 +
13 +@pytest.mark.skipif(torch.cuda.device_count() < 1, reason="Need CUDA device")
14 +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
15 +@pytest.mark.parametrize("strided_mixed_qkv", [False, True])
16 +def test_fused_recurrent_packed_decode_matches_reference(
17 + dtype: torch.dtype, strided_mixed_qkv: bool
18 +):
19 + torch.manual_seed(0)
20 +
21 + # Small but representative GDN config (Qwen3Next defaults are K=128, V=128).
22 + B = 32
23 + H = 4
24 + HV = 8 # grouped value attention: HV must be divisible by H
25 + K = 128
26 + V = 128
27 + qkv_dim = 2 * (H * K) + (HV * V)
28 +
29 + device = torch.device("cuda")
30 +
31 + if strided_mixed_qkv:
32 + # Simulate a packed view into a larger projection buffer:
33 + # mixed_qkv.stride(0) > mixed_qkv.shape[1]
34 + proj = torch.randn((B, qkv_dim + 64), device=device, dtype=dtype)
35 + mixed_qkv = proj[:, :qkv_dim]
36 + else:
37 + mixed_qkv = torch.randn((B, qkv_dim), device=device, dtype=dtype)
38 +
39 + a = torch.randn((B, HV), device=device, dtype=dtype)
40 + b = torch.randn((B, HV), device=device, dtype=dtype)
41 + A_log = torch.randn((HV,), device=device, dtype=dtype)
42 + dt_bias = torch.randn((HV,), device=device, dtype=dtype)
43 +
44 + # Continuous batching indices (include PAD_SLOT_ID=-1 cases).
45 + ssm_state_indices = torch.arange(B, device=device, dtype=torch.int32)
46 + ssm_state_indices[-3:] = -1
47 +
48 + state0 = torch.randn((B, HV, V, K), device=device, dtype=dtype)
49 + state_ref = state0.clone()
50 + state_packed = state0.clone()
51 +
52 + out_packed = torch.empty((B, 1, HV, V), device=device, dtype=dtype)
53 +
54 + # Reference path: materialize contiguous Q/K/V + explicit gating.
55 + q, k, v = torch.split(mixed_qkv, [H * K, H * K, HV * V], dim=-1)
56 + q = q.view(B, H, K).unsqueeze(1).contiguous()
57 + k = k.view(B, H, K).unsqueeze(1).contiguous()
58 + v = v.view(B, HV, V).unsqueeze(1).contiguous()
59 +
60 + x = a.float() + dt_bias.float()
61 + softplus_x = torch.where(
62 + x <= 20.0, torch.log1p(torch.exp(torch.clamp(x, max=20.0))), x
63 + )
64 + g = (-torch.exp(A_log.float()) * softplus_x).unsqueeze(1)
65 + beta = torch.sigmoid(b.float()).to(dtype).unsqueeze(1)
66 +
67 + out_ref, state_ref = fused_recurrent_gated_delta_rule(
68 + q=q,
69 + k=k,
70 + v=v,
71 + g=g,
72 + beta=beta,
73 + scale=K**-0.5,
74 + initial_state=state_ref,
75 + inplace_final_state=True,
76 + cu_seqlens=None,
77 + ssm_state_indices=ssm_state_indices,
78 + use_qk_l2norm_in_kernel=True,
79 + )
80 +
81 + # Packed path: fused gating + recurrent directly from packed mixed_qkv.
82 + fused_recurrent_gated_delta_rule_packed_decode(
83 + mixed_qkv=mixed_qkv,
84 + a=a,
85 + b=b,
86 + A_log=A_log,
87 + dt_bias=dt_bias,
88 + scale=K**-0.5,
89 + initial_state=state_packed,
90 + out=out_packed,
91 + ssm_state_indices=ssm_state_indices,
92 + use_qk_l2norm_in_kernel=True,
93 + )
94 +
95 + atol = 2e-2 if dtype != torch.float32 else 1e-4
96 + rtol = 1e-2 if dtype != torch.float32 else 1e-4
97 + torch.testing.assert_close(out_packed, out_ref, rtol=rtol, atol=atol)
98 + torch.testing.assert_close(state_packed, state_ref, rtol=rtol, atol=atol)
vllm/envs.py
+4
@@ -96,6 +96,7 @@ if TYPE_CHECKING:
96 VLLM_ALLOW_RUNTIME_LORA_UPDATING: bool = False
97 VLLM_SKIP_P2P_CHECK: bool = False
98 VLLM_DISABLED_KERNELS: list[str] = []
99 + VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE: bool = True
100 VLLM_DISABLE_PYNCCL: bool = False
101 VLLM_USE_OINK_OPS: bool = False
102 VLLM_ROCM_USE_AITER: bool = False
@@ -899,6 +900,9 @@ environment_variables: dict[str, Callable[[], Any]] = {
900 "VLLM_DISABLED_KERNELS": lambda: []
901 if "VLLM_DISABLED_KERNELS" not in os.environ
902 else os.environ["VLLM_DISABLED_KERNELS"].split(","),
903 + "VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE": lambda: bool(
904 + int(os.getenv("VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE", "1"))
905 + ),
906 # Disable pynccl (using torch.distributed instead)
907 "VLLM_DISABLE_PYNCCL": lambda: (
908 os.getenv("VLLM_DISABLE_PYNCCL", "False").lower() in ("true", "1")
vllm/model_executor/layers/fla/ops/__init__.py
+5 -1
@@ -7,7 +7,10 @@
7 # the following copyright notice:
8 # Copyright (c) 2023-2025, Songlin Yang, Yu Zhang
9 from .chunk import chunk_gated_delta_rule
10 -from .fused_recurrent import fused_recurrent_gated_delta_rule
10 +from .fused_recurrent import (
11 + fused_recurrent_gated_delta_rule,
12 + fused_recurrent_gated_delta_rule_packed_decode,
13 +)
14 from .fused_sigmoid_gating import fused_sigmoid_gating_delta_rule_update
15 from .layernorm_guard import RMSNormGated
16
@@ -15,5 +18,6 @@ __all__ = [
18 "RMSNormGated",
19 "chunk_gated_delta_rule",
20 "fused_recurrent_gated_delta_rule",
21 + "fused_recurrent_gated_delta_rule_packed_decode",
22 "fused_sigmoid_gating_delta_rule_update",
23 ]
vllm/model_executor/layers/fla/ops/fused_recurrent.py
+225
@@ -252,6 +252,231 @@ def fused_recurrent_gated_delta_rule_fwd(
252 return o, final_state
253
254
255 +@triton.jit
256 +def fused_recurrent_gated_delta_rule_packed_decode_kernel(
257 + mixed_qkv,
258 + a,
259 + b,
260 + A_log,
261 + dt_bias,
262 + o,
263 + h0,
264 + ht,
265 + ssm_state_indices,
266 + scale,
267 + stride_mixed_qkv_tok: tl.constexpr,
268 + stride_a_tok: tl.constexpr,
269 + stride_b_tok: tl.constexpr,
270 + stride_init_state_token: tl.constexpr,
271 + stride_final_state_token: tl.constexpr,
272 + stride_indices_seq: tl.constexpr,
273 + H: tl.constexpr,
274 + HV: tl.constexpr,
275 + K: tl.constexpr,
276 + V: tl.constexpr,
277 + BK: tl.constexpr,
278 + BV: tl.constexpr,
279 + SOFTPLUS_THRESHOLD: tl.constexpr,
280 + USE_QK_L2NORM_IN_KERNEL: tl.constexpr,
281 +):
282 + i_v, i_nh = tl.program_id(0), tl.program_id(1)
283 + i_n, i_hv = i_nh // HV, i_nh % HV
284 + i_h = i_hv // (HV // H)
285 +
286 + o_k = tl.arange(0, BK)
287 + o_v = i_v * BV + tl.arange(0, BV)
288 + mask_k = o_k < K
289 + mask_v = o_v < V
290 + mask_h = mask_v[:, None] & mask_k[None, :]
291 +
292 + state_idx = tl.load(ssm_state_indices + i_n * stride_indices_seq).to(tl.int64)
293 + p_o = o + (i_n * HV + i_hv) * V + o_v
294 +
295 + if state_idx < 0:
296 + zero = tl.zeros([BV], dtype=tl.float32).to(p_o.dtype.element_ty)
297 + tl.store(p_o, zero, mask=mask_v)
298 + return
299 +
300 + p_h0 = h0 + state_idx * stride_init_state_token
301 + p_h0 = p_h0 + i_hv * V * K + o_v[:, None] * K + o_k[None, :]
302 + b_h = tl.load(p_h0, mask=mask_h, other=0).to(tl.float32)
303 +
304 + p_mixed = mixed_qkv + i_n * stride_mixed_qkv_tok
305 + q_off = i_h * K + o_k
306 + k_off = (H * K) + i_h * K + o_k
307 + v_off = (2 * H * K) + i_hv * V + o_v
308 + b_q = tl.load(p_mixed + q_off, mask=mask_k, other=0).to(tl.float32)
309 + b_k = tl.load(p_mixed + k_off, mask=mask_k, other=0).to(tl.float32)
310 + b_v = tl.load(p_mixed + v_off, mask=mask_v, other=0).to(tl.float32)
311 +
312 + if USE_QK_L2NORM_IN_KERNEL:
313 + b_q = b_q / tl.sqrt(tl.sum(b_q * b_q) + 1e-6)
314 + b_k = b_k / tl.sqrt(tl.sum(b_k * b_k) + 1e-6)
315 + b_q = b_q * scale
316 +
317 + a_val = tl.load(a + i_n * stride_a_tok + i_hv).to(tl.float32)
318 + b_val = tl.load(b + i_n * stride_b_tok + i_hv).to(tl.float32)
319 + A_log_val = tl.load(A_log + i_hv).to(tl.float32)
320 + dt_bias_val = tl.load(dt_bias + i_hv).to(tl.float32)
321 + x = a_val + dt_bias_val
322 + softplus_x = tl.where(x <= SOFTPLUS_THRESHOLD, tl.log(1.0 + tl.exp(x)), x)
323 + g_val = -tl.exp(A_log_val) * softplus_x
324 + beta_val = tl.sigmoid(b_val).to(b.dtype.element_ty).to(tl.float32)
325 +
326 + b_h *= exp(g_val)
327 + b_v -= tl.sum(b_h * b_k[None, :], 1)
328 + b_v *= beta_val
329 + b_h += b_v[:, None] * b_k[None, :]
330 + b_o = tl.sum(b_h * b_q[None, :], 1)
331 + tl.store(p_o, b_o.to(p_o.dtype.element_ty), mask=mask_v)
332 +
333 + p_ht = ht + state_idx * stride_final_state_token
334 + p_ht = p_ht + i_hv * V * K + o_v[:, None] * K + o_k[None, :]
335 + tl.store(p_ht, b_h.to(p_ht.dtype.element_ty), mask=mask_h)
336 +
337 +
338 +def fused_recurrent_gated_delta_rule_packed_decode(
339 + mixed_qkv: torch.Tensor,
340 + a: torch.Tensor,
341 + b: torch.Tensor,
342 + A_log: torch.Tensor,
343 + dt_bias: torch.Tensor,
344 + scale: float,
345 + initial_state: torch.Tensor,
346 + out: torch.Tensor,
347 + ssm_state_indices: torch.Tensor,
348 + use_qk_l2norm_in_kernel: bool = False,
349 +) -> tuple[torch.Tensor, torch.Tensor]:
350 + if mixed_qkv.ndim != 2:
351 + raise ValueError(
352 + f"`mixed_qkv` must be a 2D tensor (got ndim={mixed_qkv.ndim})."
353 + )
354 + if mixed_qkv.stride(-1) != 1:
355 + raise ValueError("`mixed_qkv` must be contiguous in the last dim.")
356 + if a.ndim != 2 or b.ndim != 2:
357 + raise ValueError(
358 + f"`a` and `b` must be 2D tensors (got a.ndim={a.ndim}, b.ndim={b.ndim})."
359 + )
360 + if a.stride(-1) != 1 or b.stride(-1) != 1:
361 + raise ValueError("`a`/`b` must be contiguous in the last dim.")
362 + if A_log.ndim != 1 or dt_bias.ndim != 1:
363 + raise ValueError("`A_log`/`dt_bias` must be 1D tensors.")
364 + if A_log.stride(0) != 1 or dt_bias.stride(0) != 1:
365 + raise ValueError("`A_log`/`dt_bias` must be contiguous.")
366 + if ssm_state_indices.ndim != 1:
367 + raise ValueError(
368 + f"`ssm_state_indices` must be 1D for packed decode (got ndim={ssm_state_indices.ndim})."
369 + )
370 + if not out.is_contiguous():
371 + raise ValueError("`out` must be contiguous.")
372 +
373 + dev = mixed_qkv.device
374 + if (
375 + a.device != dev
376 + or b.device != dev
377 + or A_log.device != dev
378 + or dt_bias.device != dev
379 + or initial_state.device != dev
380 + or out.device != dev
381 + or ssm_state_indices.device != dev
382 + ):
383 + raise ValueError("All inputs must be on the same device.")
384 +
385 + B = mixed_qkv.shape[0]
386 + if a.shape[0] != B or b.shape[0] != B:
387 + raise ValueError(
388 + "Mismatched batch sizes: "
389 + f"mixed_qkv.shape[0]={B}, a.shape[0]={a.shape[0]}, b.shape[0]={b.shape[0]}."
390 + )
391 + if ssm_state_indices.shape[0] != B:
392 + raise ValueError(
393 + f"`ssm_state_indices` must have shape [B] (got {tuple(ssm_state_indices.shape)}; expected ({B},))."
394 + )
395 +
396 + if initial_state.ndim != 4:
397 + raise ValueError(
398 + f"`initial_state` must be a 4D tensor (got ndim={initial_state.ndim})."
399 + )
400 + if initial_state.stride(-1) != 1:
401 + raise ValueError("`initial_state` must be contiguous in the last dim.")
402 + HV, V, K = initial_state.shape[-3:]
403 + if a.shape[1] != HV or b.shape[1] != HV:
404 + raise ValueError(
405 + f"`a`/`b` must have shape [B, HV] with HV={HV} (got a.shape={tuple(a.shape)}, b.shape={tuple(b.shape)})."
406 + )
407 + if A_log.numel() != HV or dt_bias.numel() != HV:
408 + raise ValueError(
409 + f"`A_log` and `dt_bias` must have {HV} elements (got A_log.numel()={A_log.numel()}, dt_bias.numel()={dt_bias.numel()})."
410 + )
411 + if out.shape != (B, 1, HV, V):
412 + raise ValueError(
413 + f"`out` must have shape {(B, 1, HV, V)} (got out.shape={tuple(out.shape)})."
414 + )
415 +
416 + qkv_dim = mixed_qkv.shape[1]
417 + qk_dim = qkv_dim - HV * V
418 + if qk_dim <= 0 or qk_dim % 2 != 0:
419 + raise ValueError(
420 + f"Invalid packed `mixed_qkv` last dim={qkv_dim} for HV={HV}, V={V}."
421 + )
422 + q_dim = qk_dim // 2
423 + if q_dim % K != 0:
424 + raise ValueError(f"Invalid packed Q size {q_dim}: must be divisible by K={K}.")
425 + H = q_dim // K
426 + if H <= 0 or HV % H != 0:
427 + raise ValueError(
428 + f"Invalid head config inferred from mixed_qkv: H={H}, HV={HV}."
429 + )
430 +
431 + BK = triton.next_power_of_2(K)
432 + if triton.cdiv(K, BK) != 1:
433 + raise ValueError(
434 + f"Packed decode kernel only supports NK=1 (got K={K}, BK={BK})."
435 + )
436 + BV = min(triton.next_power_of_2(V), 32)
437 + num_stages = 3
438 + num_warps = 1
439 +
440 + stride_mixed_qkv_tok = mixed_qkv.stride(0)
441 + stride_a_tok = a.stride(0)
442 + stride_b_tok = b.stride(0)
443 + stride_init_state_token = initial_state.stride(0)
444 + stride_final_state_token = initial_state.stride(0)
445 + stride_indices_seq = ssm_state_indices.stride(0)
446 +
447 + NV = triton.cdiv(V, BV)
448 + grid = (NV, B * HV)
449 + fused_recurrent_gated_delta_rule_packed_decode_kernel[grid](
450 + mixed_qkv=mixed_qkv,
451 + a=a,
452 + b=b,
453 + A_log=A_log,
454 + dt_bias=dt_bias,
455 + o=out,
456 + h0=initial_state,
457 + ht=initial_state,
458 + ssm_state_indices=ssm_state_indices,
459 + scale=scale,
460 + stride_mixed_qkv_tok=stride_mixed_qkv_tok,
461 + stride_a_tok=stride_a_tok,
462 + stride_b_tok=stride_b_tok,
463 + stride_init_state_token=stride_init_state_token,
464 + stride_final_state_token=stride_final_state_token,
465 + stride_indices_seq=stride_indices_seq,
466 + H=H,
467 + HV=HV,
468 + K=K,
469 + V=V,
470 + BK=BK,
471 + BV=BV,
472 + SOFTPLUS_THRESHOLD=20.0,
473 + USE_QK_L2NORM_IN_KERNEL=use_qk_l2norm_in_kernel,
474 + num_warps=num_warps,
475 + num_stages=num_stages,
476 + )
477 + return out, initial_state
478 +
479 +
480 class FusedRecurrentFunction(torch.autograd.Function):
481 @staticmethod
482 def forward(
vllm/model_executor/models/qwen3_next.py
+70 -3
@@ -10,6 +10,7 @@ from einops import rearrange
10 from torch import nn
11 from transformers.activations import ACT2FN
12
13 +from vllm import envs
14 from vllm.compilation.decorators import support_torch_compile
15 from vllm.config import (
16 CacheConfig,
@@ -34,6 +35,7 @@ from vllm.model_executor.layers.fla.ops import (
35 chunk_gated_delta_rule as fla_chunk_gated_delta_rule,
36 )
37 from vllm.model_executor.layers.fla.ops import (
38 + fused_recurrent_gated_delta_rule_packed_decode,
39 fused_sigmoid_gating_delta_rule_update,
40 )
41 from vllm.model_executor.layers.fla.ops.chunk import l2norm_fwd
@@ -474,6 +476,9 @@ class Qwen3NextGatedDeltaNet(nn.Module, MambaBase):
476 )
477
478 self.chunk_gated_delta_rule = ChunkGatedDeltaRule()
479 + self.enable_packed_recurrent_decode = (
480 + envs.VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE
481 + )
482
483 compilation_config = get_current_vllm_config().compilation_config
484 if prefix in compilation_config.static_forward_context:
@@ -747,9 +752,6 @@ class Qwen3NextGatedDeltaNet(nn.Module, MambaBase):
752 a: torch.Tensor,
753 core_attn_out: torch.Tensor,
754 ):
750 - """
751 - Core attention computation (called by custom op).
752 - """
755 forward_context = get_forward_context()
756 attn_metadata: AttentionMetadata = forward_context.attn_metadata
757
@@ -762,6 +764,22 @@ class Qwen3NextGatedDeltaNet(nn.Module, MambaBase):
764 assert isinstance(attn_metadata, dict)
765 attn_metadata = attn_metadata[self.prefix]
766 assert isinstance(attn_metadata, GDNAttentionMetadata)
767 +
768 + if (
769 + self.enable_packed_recurrent_decode
770 + and attn_metadata.spec_sequence_masks is None
771 + and attn_metadata.num_prefills == 0
772 + and attn_metadata.num_decodes > 0
773 + ):
774 + return self._forward_core_decode_non_spec(
775 + mixed_qkv=mixed_qkv,
776 + b=b,
777 + a=a,
778 + core_attn_out=core_attn_out,
779 + attn_metadata=attn_metadata,
780 + virtual_engine=forward_context.virtual_engine,
781 + )
782 +
783 has_initial_state = attn_metadata.has_initial_state
784 spec_query_start_loc = attn_metadata.spec_query_start_loc
785 non_spec_query_start_loc = attn_metadata.non_spec_query_start_loc
@@ -946,6 +964,55 @@ class Qwen3NextGatedDeltaNet(nn.Module, MambaBase):
964 else:
965 core_attn_out[:num_actual_tokens] = core_attn_out_non_spec.squeeze(0)
966
967 + def _forward_core_decode_non_spec(
968 + self,
969 + mixed_qkv: torch.Tensor,
970 + b: torch.Tensor,
971 + a: torch.Tensor,
972 + core_attn_out: torch.Tensor,
973 + attn_metadata: GDNAttentionMetadata,
974 + virtual_engine: int,
975 + ):
976 + """
977 + Core attention computation with a packed non-spec decode fast path.
978 + """
979 + non_spec_state_indices_tensor = attn_metadata.non_spec_state_indices_tensor # noqa: E501
980 + self_kv_cache = self.kv_cache[virtual_engine]
981 + conv_state = self_kv_cache[0].transpose(-1, -2)
982 + ssm_state = self_kv_cache[1]
983 + num_actual_tokens = attn_metadata.num_actual_tokens
984 +
985 + mixed_qkv = mixed_qkv[:num_actual_tokens]
986 + b = b[:num_actual_tokens]
987 + a = a[:num_actual_tokens]
988 +
989 + conv_weights = self.conv1d.weight.view(
990 + self.conv1d.weight.size(0), self.conv1d.weight.size(2)
991 + )
992 + mixed_qkv_non_spec = causal_conv1d_update(
993 + mixed_qkv,
994 + conv_state,
995 + conv_weights,
996 + self.conv1d.bias,
997 + self.activation,
998 + conv_state_indices=non_spec_state_indices_tensor[:num_actual_tokens],
999 + validate_data=False,
1000 + )
1001 + out_buf = core_attn_out[:num_actual_tokens].unsqueeze(1)
1002 + fused_recurrent_gated_delta_rule_packed_decode(
1003 + mixed_qkv=mixed_qkv_non_spec,
1004 + a=a,
1005 + b=b,
1006 + A_log=self.A_log,
1007 + dt_bias=self.dt_bias,
1008 + scale=self.head_k_dim**-0.5,
1009 + initial_state=ssm_state,
1010 + out=out_buf,
1011 + ssm_state_indices=non_spec_state_indices_tensor[:num_actual_tokens],
1012 + use_qk_l2norm_in_kernel=True,
1013 + )
1014 + return
1015 +
1016
1017 class Qwen3NextAttention(nn.Module):
1018 def __init__(