Skip to content

Commit 55c27ec

Browse files
yongfuFangChenxiQ
andauthored
[Feature] Migrate KDA Triton Op to Ascend NPU (vllm-project#9035)
### What this PR does / why we need it? This PR migrates the KDA (Kimi Delta Attention) Triton operators from vLLM's upstream FLA (flash-linear-attention) GPU implementation to Ascend NPU. Previously, KDA had no NPU-compatible Triton implementation. ### Does this PR introduce _any_ user-facing change? No new user-facing configuration or public API is introduced. This PR only adds the underlying Ascend NPU Triton kernels for KDA, without changing existing behavior. ### Implementation overview #### File responsibilities | File | Responsibility | Main caller | | --- | --- | --- | | `kda.py` | Public operator entry points and orchestration for `chunk_kda`, `fused_recurrent_kda`, `fused_kda_gate`, and gated RMSNorm; also contains the chunk KKT, `W/U`, and output kernels. | Model integration / tests | | `fused_recurrent_kda.py` | Single-kernel token-by-token KDA recurrence used by decode, including optional Q/K L2 normalization and paged in-place state updates. | `fused_recurrent_kda_fwd` in `kda.py` | | `cumsum.py` | Computes the per-chunk cumulative vector gate `G`. | `chunk_kda_fwd` in `kda.py` | | `solve_tril.py` | Computes the blockwise inverse `M = (I + L)^{-1}` for 16/32/64-token lower-triangular blocks. The current KDA path uses a chunk size of 64. | `chunk_kda_fwd` in `kda.py` | | `chunk_delta_h.py` | Propagates the recurrent state across chunks and computes the corrected value block `V'`. | `chunk_kda_fwd` in `kda.py` | | `l2norm.py` | Optional standalone Q/K L2 normalization for the chunk path. | `chunk_kda` in `kda.py` | | `utils.py` | Cached variable-length metadata (`chunk_indices` and `chunk_offsets`) plus contiguous-input/device guards. | `kda.py`, `cumsum.py`, `solve_tril.py`, `chunk_delta_h.py` | #### Kernel call graph ##### Prefill / chunked path <img width="959" height="955" alt="img" src="https://github.com/user-attachments/assets/69718f6e-20d9-4363-ba56-3183ffd1c7e2" /> ##### Decode / recurrent path <img width="955" height="955" alt="img_1" src="https://github.com/user-attachments/assets/360ac9b6-963c-4eec-b996-693040ff089a" /> `fused_kda_gate` and `rms_norm_gated` are independent helper entry points in `kda.py`; they are not called from the two attention paths above. `fused_kda_gate` launches `kda_gate_fwd_kernel`, while `rms_norm_gated` selects one of the two `layer_norm_gated_fwd_kernel` variants according to the feature dimension. #### Recurrent KDA definition For readability, let the mathematical state be $S_t \in \mathbb{R}^{K \times V}$. The Triton kernels physically store its transpose, $S_t^\top \in \mathbb{R}^{V \times K}$, to make the value dimension contiguous. With $s = K^{-1/2}$ and optional $$ q_t \leftarrow \frac{q_t}{\sqrt{\lVert q_t \rVert_2^2 + \epsilon}}, \qquad k_t \leftarrow \frac{k_t}{\sqrt{\lVert k_t \rVert_2^2 + \epsilon}}, $$ the decode kernel implements the following recurrence in one launch: $$ \widetilde{S}_t = \mathrm{Diag}(\exp(g_t)) S_{t-1}, $$ $$ \delta_t = \beta_t \odot \left(v_t - \widetilde{S}_t^\top k_t\right), $$ $$ S_t = \widetilde{S}_t + k_t \delta_t^\top, \qquad o_t = s S_t^\top q_t. $$ Here, $g_t \in \mathbb{R}^{K}$ is a key-dimension decay gate. The recurrent kernel accepts either a scalar $\beta_t$ or a value-dimension vector $\beta_t \in \mathbb{R}^{V}$. The `ssm_state_indices` path maps each sequence/token to its paged state slot and writes the updated state in place. The standalone gate helper produces the negative decay gate used above: $$ g = -\exp(A)\,\mathrm{softplus}_{\beta}(g_{\mathrm{raw}} + b), $$ where the bias is optional and the softplus kernel switches to its linear approximation above `threshold`. #### Chunked formulation The prefill path computes the same recurrence in 64-token chunks. For local token indices $0 \le j \le i < C$, define the cumulative vector gate $$ G_i = \sum_{r=0}^{i} g_r, \qquad E_{ij} = \exp(G_i - G_j). $$ The implementation converts $G$ to base-2 exponent space, $$ \widehat{G} = G\log_2(e), \qquad \mathrm{exp2}(\widehat{G}_i-\widehat{G}_j)=\exp(G_i-G_j), $$ so the Triton kernels can use `exp2` without changing the mathematical decay. The two KKT kernels construct a strictly lower-triangular correction matrix $L$ and a causal query-key matrix $P$ (`Aqk` in code): $$ L_{ij} = \begin{cases} \beta_i\left\langle k_i,\;k_j\odot E_{ij}\right\rangle, & j < i,\\ 0, & j \ge i, \end{cases} $$ $$ P_{ij} = \begin{cases} s\left\langle q_i,\;k_j\odot E_{ij}\right\rangle, & j \le i,\\ 0, & j > i. \end{cases} $$ `solve_tril_kda` then computes $$ M=(I+L)^{-1}. $$ For a chunk matrix $K_c \in \mathbb{R}^{C\times K}$, values $V_c \in \mathbb{R}^{C\times V}$, scalar-per-token $\beta$, and the state $S_c$ at the start of the chunk, `recompute_w_u_fwd_kernel` and the chunk-state kernel compute $$ U = M\,\mathrm{Diag}(\beta)V_c, $$ $$ W = M\left[\mathrm{Diag}(\beta)\left(K_c\odot\exp(G)\right)\right], $$ $$ K_c^{\star}=K_c\odot\exp(G_{C-1}-G), \qquad V' = U-WS_c, $$ $$ S_{c+1}=\mathrm{Diag}(\exp(G_{C-1}))S_c+\left(K_c^{\star}\right)^\top V'. $$ Finally, `chunk_gla_fwd_kernel_o` combines the contribution from the state before the chunk with the causal contribution produced inside the chunk: $$ O_c=s\left(Q_c\odot\exp(G)\right)S_c+PV'. $$ This decomposition keeps the sequential recurrence only at chunk boundaries; all token interactions within a chunk are expressed as tiled matrix operations suitable for Ascend NPU execution. `cu_seqlens`, `chunk_indices`, and `chunk_offsets` preserve the same flow for flattened variable-length batches without allowing a chunk to cross a sequence boundary. #### Precision and layout notes - The default chunk size is 64 tokens. Pairwise matrices are built from 16-token sub-blocks, and the triangular inverse is assembled from 16x16 blocks into the final 64x64 inverse. - Cumulative gates, $L$, and `Aqk` are produced in fp32. The solved inverse is converted to the input dtype for the following tiled matrix multiplications. - Chunk-boundary state is accumulated and returned in fp32. In particular, the precision-sensitive $WS_c$ residual uses fp32 operands and `input_precision="ieee"` before subtracting it from $U$. - The physical state layout is `[V, K]`, while the equations use `[K, V]`. This avoids an extra transpose in the value-tiled state update and output kernels. ### How was this patch tested? Added e2e nightly precision tests under `tests/e2e/nightly/single_node/ops/singlecard_ops/triton/`. Each test compares the Triton kernel against a naive float32 recurrent KDA reference ported from FLA's `naive.py`, with an RMSE-ratio tolerance of `0.005` for both output `o` and final state `ht`. - **`test_chunk_kda_npu.py`** – Tests `chunk_kda` prefill/chunked execution across variable-length layouts using `cu_seqlens`, including: - Single- and multi-sequence inputs - Unaligned sequence lengths - Long sequences up to 8192 tokens - `H ∈ {32, 64}` and `D = 128` - fp16 and bf16 - **`test_fused_recurrent_kda_npu.py`** – Tests `fused_recurrent_kda` decode execution, including: - Variable-length recurrent mode - Single-token decode and short multi-token sequences - fp16 and bf16 - In-place state updates with `ssm_state_indices` - Paged-state usage with `N ∈ {1, 4, 16}` Run with: ```bash pytest -sv \ tests/e2e/nightly/single_node/ops/singlecard_ops/triton/test_chunk_kda_npu.py pytest -sv \ tests/e2e/nightly/single_node/ops/singlecard_ops/triton/test_fused_recurrent_kda_npu.py ``` - vLLM version: v0.23.0 - vLLM main: vllm-project/vllm@967c5c3 --------- Signed-off-by: fangyongfu <fangyongfu321@163.com> Signed-off-by: Chenxi Qian <chenxi.qian.cq@outlook.com> Signed-off-by: yongfuFang <fangyongfu321@163.com> Co-authored-by: Chenxi Qian <chenxi.qian.cq@outlook.com>
1 parent 0233ddf commit 55c27ec

10 files changed

Lines changed: 3221 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
# mypy: ignore-errors
4+
"""Precision tests for vllm's chunk_kda Triton operator on NPU.
5+
6+
Compares chunk_kda against a naive recurrent reference (float32).
7+
"""
8+
9+
import pytest
10+
import torch
11+
import torch.nn.functional as F
12+
import torch_npu # noqa: F401
13+
14+
from vllm_ascend.ops.triton.kda.kda import chunk_kda
15+
16+
DEVICE = "npu"
17+
18+
NPU_RMSE_RATIO_O = 0.005
19+
NPU_RMSE_RATIO_HT = 0.005
20+
21+
22+
def reference_l2norm(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
23+
dtype = x.dtype
24+
x = x.to(torch.float32)
25+
return (x * torch.rsqrt(torch.sum(x * x, dim=-1, keepdim=True) + eps)).to(dtype)
26+
27+
28+
def naive_recurrent_kda(
29+
q: torch.Tensor,
30+
k: torch.Tensor,
31+
v: torch.Tensor,
32+
g: torch.Tensor,
33+
beta: torch.Tensor,
34+
scale: float | None = None,
35+
initial_state: torch.Tensor | None = None,
36+
output_final_state: bool = False,
37+
) -> tuple[torch.Tensor, torch.Tensor | None]:
38+
"""Naive recurrent KDA reference, ported from FLA's naive.py."""
39+
dtype = v.dtype
40+
B, T, H, K, V = *q.shape, v.shape[-1]
41+
if scale is None:
42+
scale = K**-0.5
43+
44+
q, k, v, g, beta = map(lambda x: x.to(torch.float), [q, k, v, g, beta])
45+
q = q * scale
46+
47+
S = k.new_zeros(B, H, K, V).to(q)
48+
if initial_state is not None:
49+
S += initial_state
50+
o = torch.zeros_like(v)
51+
for i in range(T):
52+
q_i, k_i, v_i, g_i, b_i = q[:, i], k[:, i], v[:, i], g[:, i], beta[:, i]
53+
S = S * g_i[..., None].exp()
54+
S = S + torch.einsum(
55+
"bhk,bhv->bhkv",
56+
b_i[..., None] * k_i,
57+
v_i - (k_i[..., None] * S).sum(-2),
58+
)
59+
o[:, i] = torch.einsum("bhk,bhkv->bhv", q_i, S)
60+
if not output_final_state:
61+
S = None
62+
return o.to(dtype), S
63+
64+
65+
def assert_close(
66+
name: str,
67+
ref: torch.Tensor,
68+
tri: torch.Tensor,
69+
ratio: float,
70+
err_atol: float = 1e-6,
71+
):
72+
"""RMSE-based relative error comparison."""
73+
abs_err = (ref.detach() - tri.detach()).flatten().abs().max().item()
74+
rmse_diff = (ref.detach() - tri.detach()).flatten().square().mean().sqrt().item()
75+
rmse_base = ref.detach().flatten().square().mean().sqrt().item()
76+
rel_err = rmse_diff / (rmse_base + 1e-8)
77+
print(f"{name:>4} | abs={abs_err:.6f} | rmse={rel_err:.6f} | thr={ratio}")
78+
if abs_err <= err_atol:
79+
return
80+
assert not torch.isnan(ref).any(), f"{name}: NaN detected in ref"
81+
assert not torch.isnan(tri).any(), f"{name}: NaN detected in tri"
82+
assert rel_err < ratio, f"{name}: max abs err {abs_err:.6f}, rmse ratio {rel_err:.6f} >= {ratio}"
83+
84+
85+
@pytest.mark.parametrize(
86+
("H", "D", "cu_seqlens", "dtype"),
87+
[
88+
pytest.param(
89+
*test,
90+
id="H{}-D{}-cu{}-{}".format(*test),
91+
)
92+
for test in [
93+
(32, 128, [0, 64], torch.float16),
94+
(32, 128, [0, 1024], torch.float16),
95+
(32, 128, [0, 15], torch.float16),
96+
(32, 128, [0, 256, 512, 768, 1024], torch.float16),
97+
(32, 128, [0, 15, 100, 300, 1200], torch.float16),
98+
(64, 128, [0, 256, 500, 1000], torch.float16),
99+
(32, 128, [0, 8192], torch.float16),
100+
(32, 128, [0, 256, 500, 1000], torch.bfloat16),
101+
(32, 128, [0, 4096], torch.float16),
102+
]
103+
],
104+
)
105+
@pytest.mark.skip_global_cleanup
106+
@torch.inference_mode()
107+
def test_chunk_kda(
108+
H: int,
109+
D: int,
110+
cu_seqlens: list[int],
111+
dtype: torch.dtype,
112+
):
113+
T = cu_seqlens[-1]
114+
torch.manual_seed(42)
115+
B = 1
116+
cu_seqlens_t = torch.LongTensor(cu_seqlens).to(DEVICE)
117+
N = len(cu_seqlens) - 1
118+
119+
q = torch.randn(B, T, H, D, dtype=dtype, device=DEVICE)
120+
k = torch.randn(B, T, H, D, dtype=dtype, device=DEVICE)
121+
v = torch.randn(B, T, H, D, dtype=dtype, device=DEVICE)
122+
g = F.logsigmoid(torch.randn(B, T, H, D, dtype=torch.float32, device=DEVICE)).to(dtype)
123+
beta = torch.rand(B, T, H, dtype=dtype, device=DEVICE).sigmoid()
124+
h0 = torch.randn(N, H, D, D, dtype=torch.float32, device=DEVICE)
125+
126+
ref_outputs = []
127+
ref_states = []
128+
for i in range(N):
129+
s, e = cu_seqlens[i], cu_seqlens[i + 1]
130+
q_i = reference_l2norm(q[:, s:e].contiguous())
131+
k_i = reference_l2norm(k[:, s:e].contiguous())
132+
o_i, ht_i = naive_recurrent_kda(
133+
q_i,
134+
k_i,
135+
v[:, s:e],
136+
g[:, s:e],
137+
beta[:, s:e],
138+
initial_state=h0[i],
139+
output_final_state=True,
140+
)
141+
ref_outputs.append(o_i)
142+
ref_states.append(ht_i)
143+
ref_o = torch.cat(ref_outputs, dim=1)
144+
ref_ht = torch.cat(ref_states, dim=0)
145+
146+
# h0 transposed to (V, K) layout for the kernel; naive uses (K, V)
147+
tri_o, tri_ht = chunk_kda(
148+
q=q.clone(),
149+
k=k.clone(),
150+
v=v.clone(),
151+
g=g.clone(),
152+
beta=beta.clone(),
153+
initial_state=h0.transpose(-1, -2).contiguous().clone(),
154+
output_final_state=True,
155+
cu_seqlens=cu_seqlens_t,
156+
use_qk_l2norm_in_kernel=True,
157+
)
158+
159+
assert not torch.isnan(tri_o).any(), "Triton output o contains NaN"
160+
assert not torch.isnan(tri_ht).any(), "Triton output ht contains NaN"
161+
assert_close("o", ref_o, tri_o, NPU_RMSE_RATIO_O)
162+
assert_close("ht", ref_ht, tri_ht.transpose(-1, -2).contiguous(), NPU_RMSE_RATIO_HT)

0 commit comments

Comments
 (0)