diff --git a/docs/docs/references/environment_variables.mdx b/docs/docs/references/environment_variables.mdx
index b10ac265e..99bf8c935 100644
--- a/docs/docs/references/environment_variables.mdx
+++ b/docs/docs/references/environment_variables.mdx
@@ -1837,6 +1837,16 @@ SGLang supports various environment variables that can be used to configure its
Disable linear-layer quantization on ROCm. |
false |
+
+ SGLANG_ROCM_K3_FUSE_KDA_INPROJ |
+ Kimi-K3 on ROCm: fold the KDA [f_a|b] tail into the wide [q,k,v,g] projection so the whole input projection is one GEMM. Applies to unquantized weights only; falls back to the split projection otherwise. |
+ true |
+
+
+ SGLANG_ROCM_K3_FUSE_KDA_INPROJ_MAX_TOKENS |
+ Token count above which SGLANG_ROCM_K3_FUSE_KDA_INPROJ stops applying and the split projection runs instead. The merged shape is only faster while the projection is bandwidth bound. |
+ 256 |
+
diff --git a/python/sglang/srt/environ.py b/python/sglang/srt/environ.py
index 7698b1200..ae8a96d61 100644
--- a/python/sglang/srt/environ.py
+++ b/python/sglang/srt/environ.py
@@ -857,6 +857,11 @@ class Envs:
# 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.
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_USE_AITER_FP8_PER_TOKEN = EnvBool(False)
# Above 8192 tokens of context, aiter's non-static workspace is large enough
diff --git a/python/sglang/srt/models/kimi_k3.py b/python/sglang/srt/models/kimi_k3.py
index a815afa7a..9770af47b 100644
--- a/python/sglang/srt/models/kimi_k3.py
+++ b/python/sglang/srt/models/kimi_k3.py
@@ -10,6 +10,7 @@ import logging
import os
from collections.abc import Iterable
from functools import cached_property
+from types import SimpleNamespace
from typing import TYPE_CHECKING, List, Optional, Tuple
import torch
@@ -1451,6 +1452,8 @@ class KimiK3DeltaAttention(nn.Module):
# (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
# 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.hidden_size,
[
@@ -1500,6 +1503,17 @@ class KimiK3DeltaAttention(nn.Module):
# _merge_bfa_weights().
self._bfa_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:
self.qkvb_sizes = [
projection_size,
@@ -1723,6 +1737,11 @@ class KimiK3DeltaAttention(nn.Module):
return
if _is_npu:
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]
if self._bfa_uses_block_fp8:
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_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:
"""Static inputs for the fused KDA decode kernel
(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
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 (
self._bfa_alt_stream is not None
and get_is_capture_mode()
diff --git a/test/registered/amd/test_kimi_k3_kda_inproj_fusion.py b/test/registered/amd/test_kimi_k3_kda_inproj_fusion.py
new file mode 100644
index 000000000..e6ea31072
--- /dev/null
+++ b/test/registered/amd/test_kimi_k3_kda_inproj_fusion.py
@@ -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()