[AMD] [Kimi-K3] Fuse the KDA input projection into a single GEMM on ROCm (#35176)
Co-authored-by: HAI <hixiao@gmail.com>
This commit is contained in:
@@ -1837,6 +1837,16 @@ SGLang supports various environment variables that can be used to configure its
|
|||||||
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Disable linear-layer quantization on ROCm.</td>
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Disable linear-layer quantization on ROCm.</td>
|
||||||
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>false</code></td>
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>false</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}><code>SGLANG_ROCM_K3_FUSE_KDA_INPROJ</code></td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Kimi-K3 on ROCm: fold the KDA <code>[f_a|b]</code> tail into the wide <code>[q,k,v,g]</code> projection so the whole input projection is one GEMM. Applies to unquantized weights only; falls back to the split projection otherwise.</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>true</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}><code>SGLANG_ROCM_K3_FUSE_KDA_INPROJ_MAX_TOKENS</code></td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Token count above which <code>SGLANG_ROCM_K3_FUSE_KDA_INPROJ</code> stops applying and the split projection runs instead. The merged shape is only faster while the projection is bandwidth bound.</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>256</code></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -857,6 +857,11 @@ class Envs:
|
|||||||
# Enable dual-stream MoE (shared experts vs routed experts) on the
|
# Enable dual-stream MoE (shared experts vs routed experts) on the
|
||||||
# ROCm/AITER path. Requires GPU_MAX_HW_QUEUES>=5 to avoid HW-queue serialization.
|
# ROCm/AITER path. Requires GPU_MAX_HW_QUEUES>=5 to avoid HW-queue serialization.
|
||||||
SGLANG_ROCM_USE_MULTI_STREAM = EnvBool(False)
|
SGLANG_ROCM_USE_MULTI_STREAM = EnvBool(False)
|
||||||
|
# Fold the KDA [f_a|b] tail into the wide [q,k,v,g] projection so the whole
|
||||||
|
# in-proj is one GEMM. Decode is bandwidth bound there, so the 144 extra
|
||||||
|
# output columns ride along nearly free.
|
||||||
|
SGLANG_ROCM_K3_FUSE_KDA_INPROJ = EnvBool(True)
|
||||||
|
SGLANG_ROCM_K3_FUSE_KDA_INPROJ_MAX_TOKENS = EnvInt(256)
|
||||||
SGLANG_HACK_FLASHMLA_BACKEND = EnvStr("tilelang")
|
SGLANG_HACK_FLASHMLA_BACKEND = EnvStr("tilelang")
|
||||||
SGLANG_USE_AITER_FP8_PER_TOKEN = EnvBool(False)
|
SGLANG_USE_AITER_FP8_PER_TOKEN = EnvBool(False)
|
||||||
# Above 8192 tokens of context, aiter's non-static workspace is large enough
|
# Above 8192 tokens of context, aiter's non-static workspace is large enough
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
@@ -1451,6 +1452,8 @@ class KimiK3DeltaAttention(nn.Module):
|
|||||||
# (6144/rank at TP8). Folding b (12/rank) and f_a (128, replicated)
|
# (6144/rank at TP8). Folding b (12/rank) and f_a (128, replicated)
|
||||||
# in as well skews the output dim to 6284 and measurably degrades
|
# in as well skews the output dim to 6284 and measurably degrades
|
||||||
# the GEMM kernel selection; they stay as separate tiny GEMVs.
|
# the GEMM kernel selection; they stay as separate tiny GEMVs.
|
||||||
|
# (ROCm reverses this below the token threshold -- see
|
||||||
|
# _merge_kda_inproj_weights_hip.)
|
||||||
self.fused_qkvg_proj = MergedColumnParallelLinear(
|
self.fused_qkvg_proj = MergedColumnParallelLinear(
|
||||||
self.hidden_size,
|
self.hidden_size,
|
||||||
[
|
[
|
||||||
@@ -1500,6 +1503,17 @@ class KimiK3DeltaAttention(nn.Module):
|
|||||||
# _merge_bfa_weights().
|
# _merge_bfa_weights().
|
||||||
self._bfa_w: Optional[torch.Tensor] = None
|
self._bfa_w: Optional[torch.Tensor] = None
|
||||||
self._bfa_f_b_w: Optional[torch.Tensor] = None
|
self._bfa_f_b_w: Optional[torch.Tensor] = None
|
||||||
|
if _is_hip:
|
||||||
|
# ROCm only: _merge_kda_inproj_weights_hip() may merge the
|
||||||
|
# whole [q,k,v,g | f_a | b] in-proj instead, making _bfa_w a
|
||||||
|
# tail view of that buffer. _qkvgbfa_sizes is the split of the
|
||||||
|
# buffer, and stays None when the fusion does not apply. These
|
||||||
|
# attributes exist on ROCm only; every reader is _is_hip-gated.
|
||||||
|
self._qkvgbfa_layer: Optional[SimpleNamespace] = None
|
||||||
|
self._qkvgbfa_sizes: Optional[list[int]] = None
|
||||||
|
self._qkvgbfa_bs_limit = (
|
||||||
|
envs.SGLANG_ROCM_K3_FUSE_KDA_INPROJ_MAX_TOKENS.get()
|
||||||
|
)
|
||||||
elif self.do_fuse_qkvbfg:
|
elif self.do_fuse_qkvbfg:
|
||||||
self.qkvb_sizes = [
|
self.qkvb_sizes = [
|
||||||
projection_size,
|
projection_size,
|
||||||
@@ -1723,6 +1737,11 @@ class KimiK3DeltaAttention(nn.Module):
|
|||||||
return
|
return
|
||||||
if _is_npu:
|
if _is_npu:
|
||||||
return
|
return
|
||||||
|
if _is_hip and self._merge_kda_inproj_weights_hip():
|
||||||
|
# Split-path f_b GEMM still uses this when the fused in-proj
|
||||||
|
# is above the token threshold.
|
||||||
|
self._bfa_f_b_w = self.f_b_proj.weight
|
||||||
|
return
|
||||||
mods = [self.f_a_proj, self.b_proj]
|
mods = [self.f_a_proj, self.b_proj]
|
||||||
if self._bfa_uses_block_fp8:
|
if self._bfa_uses_block_fp8:
|
||||||
weights = [_get_k3_dense_weight(mod) for mod in mods]
|
weights = [_get_k3_dense_weight(mod) for mod in mods]
|
||||||
@@ -1739,6 +1758,59 @@ class KimiK3DeltaAttention(nn.Module):
|
|||||||
self._bfa_f_b_w = self.f_b_proj.weight
|
self._bfa_f_b_w = self.f_b_proj.weight
|
||||||
self._bfa_fa_size, self._bfa_b_size = sizes
|
self._bfa_fa_size, self._bfa_b_size = sizes
|
||||||
|
|
||||||
|
def _merge_kda_inproj_weights_hip(self) -> bool:
|
||||||
|
"""ROCm only: append the [f_a | b] tail to the wide [q,k,v,g] buffer so
|
||||||
|
one GEMM covers the whole in-proj, and take _bfa_w as a tail view of
|
||||||
|
that buffer. The merge is view-only, so the wide-only and whole-buffer
|
||||||
|
weights both stay live and forward_qkvbfg_fused picks per batch size.
|
||||||
|
|
||||||
|
Returns False when the fusion does not apply, leaving the caller to do
|
||||||
|
the plain [f_a | b] merge."""
|
||||||
|
if not self._may_fuse_kda_inproj():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# [q,k,v,g | f_a | b | pad]; f_a/b keep the same relative order and the
|
||||||
|
# same pad (both widths are 4 short of a multiple of 8), so the tail
|
||||||
|
# view is byte-identical to the wide-only merge.
|
||||||
|
merged, sizes = _merge_weights_as_views(
|
||||||
|
[self.fused_qkvg_proj, self.f_a_proj, self.b_proj], pad_rows_to=8
|
||||||
|
)
|
||||||
|
self._bfa_fa_size, self._bfa_b_size = sizes[-2:]
|
||||||
|
self._bfa_w = merged[sizes[0] :]
|
||||||
|
# Stand-in "layer" so the fused GEMM goes through the same
|
||||||
|
# quant_method.apply (and therefore the same backend choice) as the
|
||||||
|
# wide projection, whose own .weight stays the 6144-row view for the
|
||||||
|
# above-threshold split path. Not an nn.Module on purpose: this must
|
||||||
|
# not add a duplicate entry to state_dict.
|
||||||
|
self._qkvgbfa_layer = SimpleNamespace(weight=merged)
|
||||||
|
self._qkvgbfa_sizes = [
|
||||||
|
*self.split_sizes, # q,k,v then g
|
||||||
|
self._bfa_fa_size,
|
||||||
|
self._bfa_b_size,
|
||||||
|
merged.shape[0] - sum(sizes), # alignment pad
|
||||||
|
]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _may_fuse_kda_inproj(self) -> bool:
|
||||||
|
"""Whether the [f_a|b] tail can share the wide projection's buffer.
|
||||||
|
|
||||||
|
Needs the wide fused projection to exist and all three weights to be
|
||||||
|
plain unquantized 2-D tensors of one dtype and width -- the checkpoint
|
||||||
|
keeps attention in bf16, but a quantized variant would carry scales
|
||||||
|
that a raw row-cat would silently drop."""
|
||||||
|
if not (_is_hip and envs.SGLANG_ROCM_K3_FUSE_KDA_INPROJ.get()):
|
||||||
|
return False
|
||||||
|
if not (self.do_fuse_qkvbfg and self.use_full_rank_gate):
|
||||||
|
return False
|
||||||
|
# Block-FP8 in-proj needs dequantized BF16 buffers; a raw row-cat
|
||||||
|
# would drop the scales. Leave fusion to the split [f_a|b] path.
|
||||||
|
if self._bfa_uses_block_fp8:
|
||||||
|
return False
|
||||||
|
ws = [m.weight for m in (self.fused_qkvg_proj, self.f_a_proj, self.b_proj)]
|
||||||
|
if not all(type(w.data) is torch.Tensor and w.dim() == 2 for w in ws):
|
||||||
|
return False
|
||||||
|
return len({(w.dtype, w.shape[1]) for w in ws}) == 1
|
||||||
|
|
||||||
def _prepare_fused_decode(self) -> None:
|
def _prepare_fused_decode(self) -> None:
|
||||||
"""Static inputs for the fused KDA decode kernel
|
"""Static inputs for the fused KDA decode kernel
|
||||||
(kernels/ops/attention/kda_fused_decode): per-segment transposed fp32 conv
|
(kernels/ops/attention/kda_fused_decode): per-segment transposed fp32 conv
|
||||||
@@ -1847,6 +1919,25 @@ class KimiK3DeltaAttention(nn.Module):
|
|||||||
n_fa, n_b = self._bfa_fa_size, self._bfa_b_size
|
n_fa, n_b = self._bfa_fa_size, self._bfa_b_size
|
||||||
from sglang.kernels.ops.kimi_k3 import kimi_k3_tiny_gemm as gemm
|
from sglang.kernels.ops.kimi_k3 import kimi_k3_tiny_gemm as gemm
|
||||||
|
|
||||||
|
if (
|
||||||
|
_is_hip
|
||||||
|
and self._qkvgbfa_sizes is not None
|
||||||
|
and 0 < hidden_states.shape[0] <= self._qkvgbfa_bs_limit
|
||||||
|
):
|
||||||
|
# ROCm only. One GEMM for the whole in-proj: the [f_a|b]
|
||||||
|
# tail rides along in the wide projection's bandwidth
|
||||||
|
# instead of paying its own launch. Worth ~30% of the
|
||||||
|
# in-proj at decode on gfx950; see
|
||||||
|
# SGLANG_ROCM_K3_FUSE_KDA_INPROJ.
|
||||||
|
fused_states = self.fused_qkvg_proj.quant_method.apply(
|
||||||
|
self._qkvgbfa_layer, hidden_states, None
|
||||||
|
)
|
||||||
|
qkv, g_proj_states, f_a, beta, _pad = torch.split(
|
||||||
|
fused_states, self._qkvgbfa_sizes, dim=-1
|
||||||
|
)
|
||||||
|
forget_gate = gemm(f_a, self._bfa_f_b_w)
|
||||||
|
return qkv, beta, forget_gate, g_proj_states
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self._bfa_alt_stream is not None
|
self._bfa_alt_stream is not None
|
||||||
and get_is_capture_mode()
|
and get_is_capture_mode()
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Layout check for the ROCm fused Kimi-K3 KDA input projection.
|
||||||
|
|
||||||
|
Below SGLANG_ROCM_K3_FUSE_KDA_INPROJ_MAX_TOKENS the whole in-proj is one GEMM
|
||||||
|
over ``[q,k,v,g | f_a | b | pad]`` instead of a wide GEMM plus a tiny [f_a|b]
|
||||||
|
GEMV. Both layouts are views over the same buffer, so the two paths have to
|
||||||
|
agree; this pins the slice offsets, the tail view the split path still reads,
|
||||||
|
and the fact that the strided f_a slice is a legal input to the f_b GEMM and
|
||||||
|
to the fused decode kernel's shape gate.
|
||||||
|
|
||||||
|
The two paths run different GEMM kernels (N=6288 has no tuned aiter config,
|
||||||
|
N=6144 does), so they agree to bf16 rounding, not bitwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from sglang.kernels.ops.kimi_k3 import kimi_k3_tiny_gemm
|
||||||
|
from sglang.srt.models.kimi_k3 import _merge_weights_as_views
|
||||||
|
from sglang.test.ci.ci_register import register_amd_ci
|
||||||
|
from sglang.test.test_utils import CustomTestCase
|
||||||
|
|
||||||
|
register_amd_ci(est_time=30, suite="stage-b-test-1-gpu-small-amd-mi35x")
|
||||||
|
|
||||||
|
HIDDEN = 7168
|
||||||
|
HEADS_TP = 12 # num_heads / tp8
|
||||||
|
HEAD_DIM = 128
|
||||||
|
PROJ_TP = HEADS_TP * HEAD_DIM # 1536
|
||||||
|
WIDE = 4 * PROJ_TP # 6144, [q,k,v,g]
|
||||||
|
MERGED = 6288 # + f_a(128) + b(12) + pad(4)
|
||||||
|
# bf16 carries ~8 mantissa bits, so one ULP is ~4e-3 relative.
|
||||||
|
TOL = 8e-3
|
||||||
|
|
||||||
|
|
||||||
|
class _Fake(torch.nn.Module):
|
||||||
|
"""Minimal stand-in for a linear layer: _merge_weights_as_views only
|
||||||
|
touches .weight.data."""
|
||||||
|
|
||||||
|
def __init__(self, rows, device):
|
||||||
|
super().__init__()
|
||||||
|
self.weight = torch.nn.Parameter(
|
||||||
|
torch.randn(rows, HIDDEN, dtype=torch.bfloat16, device=device) * 0.02,
|
||||||
|
requires_grad=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(torch.cuda.is_available(), "no GPU")
|
||||||
|
class TestKimiK3KDAInProjFusion(CustomTestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
torch.manual_seed(0)
|
||||||
|
dev = torch.device("cuda", 0)
|
||||||
|
cls.qkvg = _Fake(WIDE, dev)
|
||||||
|
cls.f_a = _Fake(HEAD_DIM, dev)
|
||||||
|
cls.b = _Fake(HEADS_TP, dev)
|
||||||
|
cls.pre = [m.weight.data.clone() for m in (cls.qkvg, cls.f_a, cls.b)]
|
||||||
|
cls.f_b_w = (
|
||||||
|
torch.randn(PROJ_TP, HEAD_DIM, dtype=torch.bfloat16, device=dev) * 0.02
|
||||||
|
)
|
||||||
|
cls.merged, cls.sizes = _merge_weights_as_views(
|
||||||
|
[cls.qkvg, cls.f_a, cls.b], pad_rows_to=8
|
||||||
|
)
|
||||||
|
cls.split_sizes = [3 * PROJ_TP, PROJ_TP]
|
||||||
|
cls.all_sizes = cls.split_sizes + [
|
||||||
|
HEAD_DIM,
|
||||||
|
HEADS_TP,
|
||||||
|
MERGED - WIDE - HEAD_DIM - HEADS_TP,
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_merged_layout(self):
|
||||||
|
self.assertEqual(self.sizes, [WIDE, HEAD_DIM, HEADS_TP])
|
||||||
|
self.assertEqual(tuple(self.merged.shape), (MERGED, HIDDEN))
|
||||||
|
self.assertEqual(sum(self.all_sizes), MERGED)
|
||||||
|
|
||||||
|
def test_views_alias_and_preserve_values(self):
|
||||||
|
"""The merge must re-point, not reorder or copy-and-drop."""
|
||||||
|
tail = self.merged[WIDE:]
|
||||||
|
self.assertEqual(tail.data_ptr(), self.f_a.weight.data_ptr())
|
||||||
|
self.assertTrue(tail.is_contiguous())
|
||||||
|
self.assertTrue(self.qkvg.weight.is_contiguous())
|
||||||
|
self.assertEqual(self.qkvg.weight.data_ptr(), self.merged.data_ptr())
|
||||||
|
for got, want in zip((self.qkvg, self.f_a, self.b), self.pre):
|
||||||
|
self.assertTrue(torch.equal(got.weight.data, want))
|
||||||
|
|
||||||
|
def test_split_and_fused_paths_agree(self):
|
||||||
|
tail = self.merged[WIDE:]
|
||||||
|
for tokens in (1, 4, 8, 33, 128, 256):
|
||||||
|
with self.subTest(tokens=tokens):
|
||||||
|
x = torch.randn(
|
||||||
|
tokens, HIDDEN, dtype=torch.bfloat16, device=self.merged.device
|
||||||
|
)
|
||||||
|
|
||||||
|
wide = torch.nn.functional.linear(x, self.qkvg.weight)
|
||||||
|
s_qkv, s_g = torch.split(wide, self.split_sizes, dim=-1)
|
||||||
|
s_bfa = kimi_k3_tiny_gemm(x, tail)
|
||||||
|
s_beta = s_bfa[..., HEAD_DIM : HEAD_DIM + HEADS_TP]
|
||||||
|
s_fg = kimi_k3_tiny_gemm(s_bfa[..., :HEAD_DIM], self.f_b_w)
|
||||||
|
|
||||||
|
allp = torch.nn.functional.linear(x, self.merged)
|
||||||
|
f_qkv, f_g, f_fa, f_beta, _pad = torch.split(
|
||||||
|
allp, self.all_sizes, dim=-1
|
||||||
|
)
|
||||||
|
# The fused decode kernel's shape gate requires a unit last
|
||||||
|
# stride but tolerates the wider row stride.
|
||||||
|
self.assertEqual(f_fa.stride(-1), 1)
|
||||||
|
self.assertEqual(f_qkv.stride(-1), 1)
|
||||||
|
self.assertEqual(f_fa.stride(0), MERGED)
|
||||||
|
f_fg = kimi_k3_tiny_gemm(f_fa, self.f_b_w)
|
||||||
|
|
||||||
|
for name, got, want in (
|
||||||
|
("qkv", f_qkv, s_qkv),
|
||||||
|
("g", f_g, s_g),
|
||||||
|
("beta", f_beta, s_beta),
|
||||||
|
("forget_gate", f_fg, s_fg),
|
||||||
|
):
|
||||||
|
scale = want.float().abs().max().clamp_min(1e-6)
|
||||||
|
rel = ((got.float() - want.float()).abs().max() / scale).item()
|
||||||
|
self.assertLess(rel, TOL, f"{name} rel err {rel:.2e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user