[AMD] Optimize Kimi-K3 Triton MLA prefill on gfx950 (#35770)

Co-authored-by: clintg6 <7388379+clintg6@users.noreply.github.com>
Co-authored-by: Thomas Wang <thomawan@amd.com>
Co-authored-by: HAI <hixiao@gmail.com>
Co-authored-by: kk <43161300+kkHuang-amd@users.noreply.github.com>
This commit is contained in:
Clint
2026-09-04 17:44:30 -07:00
committed by GitHub
co-authored by clintg6 Thomas Wang HAI kk
parent 3b64169f9d
commit 3c2724c48d
8 changed files with 1847 additions and 135 deletions
@@ -0,0 +1,188 @@
"""Dense (non-absorbed) Triton prefill over a materialized prefix + chunk.
Covers ``AttnForwardMethod.MHA_ONE_SHOT`` for Kimi-K3 on the triton backend,
where the cached prefix is up-projected to the 192/128 MHA shape and attended
in one pass instead of running the 576/512 absorbed kernel.
"""
import unittest
import torch
from sglang.kernels.ops.attention.extend_attention import (
can_use_dense_prefill_fp8,
dense_prefill_attention_fwd,
)
from sglang.srt.environ import envs
from sglang.srt.utils import get_device, is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
register_amd_ci(est_time=10, suite="stage-b-test-1-gpu-small-amd-mi35x")
H_Q, D_QK, D_V = 12, 192, 128
FP8 = torch.float8_e4m3fn
def _reference(q, k, v, qo_indptr, kv_indptr, scale, is_causal):
"""Bottom-right aligned causal attention, one sequence at a time, in fp32."""
out = torch.empty(q.shape[0], H_Q, D_V, dtype=torch.float32, device=q.device)
lse = torch.empty(q.shape[0], H_Q, dtype=torch.float32, device=q.device)
for i in range(len(qo_indptr) - 1):
q_lo, q_hi = int(qo_indptr[i]), int(qo_indptr[i + 1])
k_lo, k_hi = int(kv_indptr[i]), int(kv_indptr[i + 1])
q_len, kv_len = q_hi - q_lo, k_hi - k_lo
scores = (
torch.matmul(
q[q_lo:q_hi].float().transpose(0, 1),
k[k_lo:k_hi].float().transpose(0, 1).transpose(1, 2),
)
* scale
)
if is_causal:
# Query m sits at absolute position (kv_len - q_len) + m.
q_pos = torch.arange(q_len, device=q.device)[:, None] + (kv_len - q_len)
k_pos = torch.arange(kv_len, device=q.device)[None, :]
scores = scores.masked_fill(q_pos < k_pos, float("-inf"))
probs = torch.softmax(scores, dim=-1)
out[q_lo:q_hi] = torch.matmul(
probs, v[k_lo:k_hi].float().transpose(0, 1)
).transpose(0, 1)
lse[q_lo:q_hi] = torch.logsumexp(scores, dim=-1).transpose(0, 1)
return out, lse
@unittest.skipUnless(
is_hip() and is_gfx95_supported(), "Kimi-K3 dense Triton prefill requires gfx950"
)
class TestKimiK3TritonDensePrefill(unittest.TestCase):
def setUp(self):
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
self.device = get_device()
self.scale = D_QK**-0.5
def _run(self, q_lens, prefix_lens, *, mode="bf16", is_causal=True, want_lse=False):
device = self.device
kv_lens = [q + p for q, p in zip(q_lens, prefix_lens)]
qo_indptr = torch.zeros(len(q_lens) + 1, dtype=torch.int32, device=device)
kv_indptr = torch.zeros(len(q_lens) + 1, dtype=torch.int32, device=device)
qo_indptr[1:] = torch.tensor(q_lens, device=device).cumsum(0)
kv_indptr[1:] = torch.tensor(kv_lens, device=device).cumsum(0)
total_q, total_kv = int(qo_indptr[-1]), int(kv_indptr[-1])
q = torch.randn(total_q, H_Q, D_QK, dtype=torch.bfloat16, device=device) * 0.25
k = torch.randn(total_kv, H_Q, D_QK, dtype=torch.bfloat16, device=device) * 0.25
v = torch.randn(total_kv, H_Q, D_V, dtype=torch.bfloat16, device=device) * 0.25
# Quantize before taking the reference so the comparison isolates the
# kernel from the cast: an FP8 run should match FP8 inputs exactly.
if mode == "fp8":
q, k, v = q.to(FP8), k.to(FP8), v.to(FP8)
elif mode == "mixed":
# What forward_mha_rocm actually hands over on gfx95 with MXFP4
# kv_b_proj weights: k/v already e4m3 from the fused up-projection,
# q still bf16.
k, v = k.to(FP8), v.to(FP8)
ref_out, ref_lse = _reference(
q.float() if mode == "bf16" else q.to(FP8).float(),
k.float() if mode == "bf16" else k.float(),
v.float() if mode == "bf16" else v.float(),
qo_indptr,
kv_indptr,
self.scale,
is_causal,
)
out = torch.empty(total_q, H_Q, D_V, dtype=torch.bfloat16, device=device)
lse = (
torch.empty(total_q, H_Q, dtype=torch.float32, device=device)
if want_lse
else None
)
dense_prefill_attention_fwd(
q,
k,
v,
out,
qo_indptr,
kv_indptr,
max(q_lens),
sm_scale=self.scale,
is_causal=is_causal,
lse=lse,
)
return out, lse, ref_out, ref_lse
def test_causal_shapes(self):
# Prefix lengths deliberately straddle the BLOCK_N=64 boundary: the
# kernel splits its KV sweep into an unmasked interior and a masked
# tail at a BLOCK_N multiple, so an off-by-one there is only visible
# when the prefix is not a clean multiple.
cases = [
([128], [0]), # no prefix: every block is diagonal
([7], [0]), # q shorter than BLOCK_M
([1], [1000]), # single query, long prefix
([128], [63]),
([128], [64]),
([128], [65]),
([256], [1025]),
([100, 37, 256, 51], [0, 500, 300, 77]), # ragged, mixed prefixes
]
for q_lens, prefix_lens in cases:
with self.subTest(q=q_lens, prefix=prefix_lens):
out, _, ref, _ = self._run(q_lens, prefix_lens)
torch.testing.assert_close(out.float(), ref, rtol=2e-2, atol=2e-2)
def test_non_causal(self):
out, _, ref, _ = self._run([128, 64], [256, 130], is_causal=False)
torch.testing.assert_close(out.float(), ref, rtol=2e-2, atol=2e-2)
def test_lse_matches_reference(self):
out, lse, ref, ref_lse = self._run([192, 64], [300, 129], want_lse=True)
torch.testing.assert_close(out.float(), ref, rtol=2e-2, atol=2e-2)
# LSE is what the chunked-KV merge would consume, so it has to be a
# natural log in absolute terms, not just proportional.
torch.testing.assert_close(lse, ref_lse, rtol=1e-3, atol=1e-3)
def test_fp8_matches_quantized_reference(self):
out, _, ref, _ = self._run([128, 64], [512, 77], mode="fp8")
torch.testing.assert_close(out.float(), ref, rtol=5e-2, atol=5e-2)
def test_mixed_bf16_query_fp8_kv(self):
# Both operands of a tl.dot must share a dtype, so the wrapper has to
# promote q rather than compile a bf16 x fp8 pair. Regression guard:
# this shape reaches the kernel straight from forward_mha_rocm.
out, _, ref, _ = self._run([128, 64], [512, 77], mode="mixed")
torch.testing.assert_close(out.float(), ref, rtol=5e-2, atol=5e-2)
def test_fp8_gate(self):
device = self.device
q = torch.empty(1, H_Q, D_QK, dtype=torch.bfloat16, device=device)
k = torch.empty(1, H_Q, D_QK, dtype=torch.bfloat16, device=device)
v = torch.empty(1, H_Q, D_V, dtype=torch.bfloat16, device=device)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(True):
self.assertTrue(
can_use_dense_prefill_fp8(q, k, v, is_causal=True, logit_cap=0.0)
)
# Non-causal and logit-capped softmax are outside the gate, and an
# already-quantized input must not be cast a second time.
self.assertFalse(
can_use_dense_prefill_fp8(q, k, v, is_causal=False, logit_cap=0.0)
)
self.assertFalse(
can_use_dense_prefill_fp8(q, k, v, is_causal=True, logit_cap=1.0)
)
self.assertFalse(
can_use_dense_prefill_fp8(
q.to(FP8), k, v, is_causal=True, logit_cap=0.0
)
)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(False):
self.assertFalse(
can_use_dense_prefill_fp8(q, k, v, is_causal=True, logit_cap=0.0)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,310 @@
import unittest
import torch
from sglang.kernels.ops.attention.extend_attention import extend_attention_fwd
from sglang.kernels.ops.attention.extend_attention_split_dim import (
can_use_split_dim_absorbed_extend,
)
from sglang.srt.environ import envs
from sglang.srt.utils import get_device, is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
register_amd_ci(est_time=15, suite="stage-b-test-1-gpu-small-amd-mi35x")
@unittest.skipUnless(
is_hip() and is_gfx95_supported(), "Kimi-K3 Triton prefill requires gfx950"
)
class TestKimiK3TritonPrefill(unittest.TestCase):
def setUp(self):
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
def test_split_dim_ragged_bf16(self):
device = get_device()
h_q, h_kv, d_qk, d_v = 12, 1, 576, 512
extend_lens = (97, 128)
prefix_lens = (53, 91)
scale = 192**-0.5
total_extend, total_prefix = sum(extend_lens), sum(prefix_lens)
q = torch.randn(total_extend, h_q, d_qk, dtype=torch.bfloat16, device=device)
k = torch.randn(total_extend, h_kv, d_qk, dtype=torch.bfloat16, device=device)
v = torch.randn(total_extend, h_kv, d_v, dtype=torch.bfloat16, device=device)
k_buffer = torch.randn(
total_prefix, h_kv, d_qk, dtype=torch.bfloat16, device=device
)
v_buffer = torch.randn(
total_prefix, h_kv, d_v, dtype=torch.bfloat16, device=device
)
qo_indptr = torch.tensor(
[0, extend_lens[0], total_extend], dtype=torch.int32, device=device
)
kv_indptr = torch.tensor(
[0, prefix_lens[0], total_prefix], dtype=torch.int32, device=device
)
kv_indices = torch.arange(total_prefix, dtype=torch.int64, device=device)
output = torch.empty(
total_extend, h_q, d_v, dtype=torch.bfloat16, device=device
)
extend_attention_fwd(
q,
k,
v,
output,
k_buffer,
v_buffer,
qo_indptr,
kv_indptr,
kv_indices,
custom_mask=None,
is_causal=True,
mask_indptr=None,
max_len_extend=max(extend_lens),
k_scale=1.0,
v_scale=1.0,
sm_scale=scale,
)
reference = torch.empty_like(output, dtype=torch.float32)
for batch, (extend_len, prefix_len) in enumerate(zip(extend_lens, prefix_lens)):
q_start = int(qo_indptr[batch])
prefix_start = int(kv_indptr[batch])
q_batch = q[q_start : q_start + extend_len].float()
k_prefix = k_buffer[prefix_start : prefix_start + prefix_len, 0].float()
v_prefix = v_buffer[prefix_start : prefix_start + prefix_len, 0].float()
k_current = k[q_start : q_start + extend_len, 0].float()
v_current = v[q_start : q_start + extend_len, 0].float()
causal = torch.triu(
torch.ones(
extend_len,
extend_len,
dtype=torch.bool,
device=device,
),
diagonal=1,
)
for head in range(h_q):
prefix_scores = q_batch[:, head] @ k_prefix.T * scale
current_scores = q_batch[:, head] @ k_current.T * scale
current_scores.masked_fill_(causal, float("-inf"))
scores = torch.cat([prefix_scores, current_scores], dim=1)
values = torch.cat([v_prefix, v_current], dim=0)
reference[q_start : q_start + extend_len, head] = (
torch.softmax(scores, dim=-1) @ values
)
torch.testing.assert_close(output.float(), reference, rtol=1e-2, atol=1e-2)
def test_split_dim_dispatch_gates(self):
device = get_device()
q = torch.empty(1, 12, 576, dtype=torch.bfloat16, device=device)
k = torch.empty(1, 1, 576, dtype=torch.bfloat16, device=device)
v = torch.empty(1, 1, 512, dtype=torch.bfloat16, device=device)
o = torch.empty(1, 12, 512, dtype=torch.bfloat16, device=device)
k_buffer = torch.empty(1, 1, 576, dtype=torch.bfloat16, device=device)
v_buffer = torch.empty(1, 1, 512, dtype=torch.bfloat16, device=device)
kwargs = dict(
lse=None,
sinks=None,
k_scale=1.0,
v_scale=1.0,
custom_mask=None,
is_causal=True,
sliding_window_size=-1,
logit_cap=0.0,
xai_temperature_len=-1,
skip_prefix=False,
skip_extend=False,
page_size=1,
score_mod=None,
aux_tensors=None,
)
self.assertTrue(
can_use_split_dim_absorbed_extend(q, k, v, o, k_buffer, v_buffer, **kwargs)
)
fp8_k_buffer = k_buffer.to(torch.float8_e4m3fn)
fp8_v_buffer = v_buffer.to(torch.float8_e4m3fn)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(False):
self.assertFalse(
can_use_split_dim_absorbed_extend(
q, k, v, o, fp8_k_buffer, fp8_v_buffer, **kwargs
)
)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(True):
self.assertTrue(
can_use_split_dim_absorbed_extend(
q, k, v, o, fp8_k_buffer, fp8_v_buffer, **kwargs
)
)
self.assertTrue(
can_use_split_dim_absorbed_extend(
q,
k,
v,
o,
fp8_k_buffer,
fp8_v_buffer,
**{**kwargs, "k_scale": 0.5, "v_scale": 0.25},
)
)
for override in (
{"page_size": 2},
{"logit_cap": 1.0},
{"sliding_window_size": 128},
{"skip_prefix": True},
{"is_causal": False},
{"lse": torch.empty(1, 12, dtype=torch.float32, device=device)},
{"sinks": torch.empty(12, dtype=torch.float32, device=device)},
{"k_scale": 0.5},
):
self.assertFalse(
can_use_split_dim_absorbed_extend(
q,
k,
v,
o,
k_buffer,
v_buffer,
**{**kwargs, **override},
)
)
def test_zero_prefix_fp8_flag(self):
device = get_device()
tokens, heads, d_qk, d_v = 128, 12, 192, 128
q = torch.randn(tokens, heads, d_qk, dtype=torch.bfloat16, device=device) * 0.25
k = torch.randn(tokens, heads, d_qk, dtype=torch.bfloat16, device=device) * 0.25
v = torch.randn(tokens, heads, d_v, dtype=torch.bfloat16, device=device) * 0.25
k_buffer = torch.empty(1, heads, d_qk, dtype=torch.float8_e4m3fn, device=device)
v_buffer = torch.empty(1, heads, d_v, dtype=torch.float8_e4m3fn, device=device)
qo_indptr = torch.tensor([0, tokens], dtype=torch.int32, device=device)
kv_indptr = torch.tensor([0, 0], dtype=torch.int32, device=device)
kv_indices = torch.empty(0, dtype=torch.int64, device=device)
bf16_output = torch.empty(
tokens, heads, d_v, dtype=torch.bfloat16, device=device
)
fp8_output = torch.empty_like(bf16_output)
scale = d_qk**-0.5
def run(output):
extend_attention_fwd(
q,
k,
v,
output,
k_buffer,
v_buffer,
qo_indptr,
kv_indptr,
kv_indices,
custom_mask=None,
is_causal=True,
mask_indptr=None,
max_len_extend=tokens,
k_scale=1.0,
v_scale=1.0,
sm_scale=scale,
)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(False):
run(bf16_output)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(True):
run(fp8_output)
causal = (
torch.arange(tokens, device=device)[None, :]
<= torch.arange(tokens, device=device)[:, None]
)
def reference(q_ref, k_ref, v_ref):
scores = torch.einsum("qhd,khd->qhk", q_ref, k_ref) * scale
scores.masked_fill_(~causal[:, None, :], float("-inf"))
return torch.einsum("qhk,khd->qhd", torch.softmax(scores, dim=-1), v_ref)
bf16_reference = reference(q.float(), k.float(), v.float())
fp8_reference = reference(
q.to(torch.float8_e4m3fn).float(),
k.to(torch.float8_e4m3fn).float(),
v.to(torch.float8_e4m3fn).float(),
)
torch.testing.assert_close(
bf16_output.float(), bf16_reference, rtol=1e-2, atol=1e-2
)
torch.testing.assert_close(
fp8_output.float(), fp8_reference, rtol=2e-2, atol=2e-2
)
def test_absorbed_fp8_prefix(self):
device = get_device()
tokens, prefix, heads, d_qk, d_v = 64, 73, 12, 576, 512
q = torch.randn(tokens, heads, d_qk, dtype=torch.bfloat16, device=device) * 0.25
k = torch.randn(tokens, 1, d_qk, dtype=torch.bfloat16, device=device) * 0.25
v = torch.randn(tokens, 1, d_v, dtype=torch.bfloat16, device=device) * 0.25
k_buffer = (
torch.randn(prefix, 1, d_qk, dtype=torch.bfloat16, device=device) * 0.25
).to(torch.float8_e4m3fn)
v_buffer = (
torch.randn(prefix, 1, d_v, dtype=torch.bfloat16, device=device) * 0.25
).to(torch.float8_e4m3fn)
qo_indptr = torch.tensor([0, tokens], dtype=torch.int32, device=device)
kv_indptr = torch.tensor([0, prefix], dtype=torch.int32, device=device)
kv_indices = torch.arange(prefix, dtype=torch.int64, device=device)
output = torch.empty(tokens, heads, d_v, dtype=torch.bfloat16, device=device)
generic_output = torch.empty_like(output)
scale, k_scale, v_scale = 192**-0.5, 0.5, 0.25
def run(candidate):
extend_attention_fwd(
q,
k,
v,
candidate,
k_buffer,
v_buffer,
qo_indptr,
kv_indptr,
kv_indices,
custom_mask=None,
is_causal=True,
mask_indptr=None,
max_len_extend=tokens,
k_scale=k_scale,
v_scale=v_scale,
sm_scale=scale,
)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(False):
run(generic_output)
with envs.SGLANG_TRITON_FP8_PREFILL_ATTN.override(True):
run(output)
q_fp8 = q.to(torch.float8_e4m3fn).float()
prefix_scores = (
torch.einsum("qhd,kd->qhk", q_fp8, k_buffer[:, 0].float()) * scale * k_scale
)
current_scores = torch.einsum("qhd,kd->qhk", q.float(), k[:, 0].float())
current_scores *= scale
causal = (
torch.arange(tokens, device=device)[None, :]
<= torch.arange(tokens, device=device)[:, None]
)
current_scores.masked_fill_(~causal[:, None, :], float("-inf"))
scores = torch.cat([prefix_scores, current_scores], dim=-1)
values = torch.cat([v_buffer[:, 0].float() * v_scale, v[:, 0].float()], dim=0)
reference = torch.einsum("qhk,kv->qhv", torch.softmax(scores, dim=-1), values)
torch.testing.assert_close(output.float(), reference, rtol=3e-2, atol=3e-2)
torch.testing.assert_close(
generic_output.float(), reference, rtol=3e-2, atol=3e-2
)
torch.testing.assert_close(
output.float(), generic_output.float(), rtol=1e-2, atol=1e-2
)
if __name__ == "__main__":
unittest.main()