[Kimi] Support kimi-k3 (#32541)

Co-authored-by: DarkSharpness <76582120+DarkSharpness@users.noreply.github.com>
Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
Co-authored-by: Mick <mickjagger19@icloud.com>
Co-authored-by: Yuhao Yang <47235274+yhyang201@users.noreply.github.com>
Co-authored-by: Cheng Wan <54331508+ch-wan@users.noreply.github.com>
Co-authored-by: Ke Bao <ispobaoke@gmail.com>
Co-authored-by: Baizhou Zhang <sobereddiezhang@gmail.com>
Co-authored-by: Chunan Zeng <zcnrex@gmail.com>
Co-authored-by: Khoa Pham <khoa.pham@radixark.ai>
Co-authored-by: Ziyi Xu <ziyi.xu@radixark.ai>
Co-authored-by: Zijie Xia <37504505+zijiexia@users.noreply.github.com>
Co-authored-by: Yuwei An <ayw.sirius19@gmail.com>
Co-authored-by: zhangxiaohao <1024393531@qq.com>
Co-authored-by: Yangmin Li <yangminl@nvidia.com>
Co-authored-by: Julien Lin <jullin@nvidia.com>
Co-authored-by: Hao Phan <htphan@nvidia.com>
Co-authored-by: Thomas Wang <1am9trash@gmail.com>
Co-authored-by: RolaoDenthu <xinyisong0111@gmail.com>
Co-authored-by: pigeonsoup <32922982+pigeonsoup@users.noreply.github.com>
Co-authored-by: HaiShaw <hixiao@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
Co-authored-by: Pranjal Shankhdhar <pranjal.ssh@gmail.com>
Co-authored-by: Lee Nau <lee.nau@gmail.com>
Co-authored-by: HMING <126185151+Hearum@users.noreply.github.com>
Co-authored-by: elvischenv <219235043+elvischenv@users.noreply.github.com>
Co-authored-by: Byron Hsu <byronhsu1230@gmail.com>
Co-authored-by: Byron Hsu <byron+per@periodiclabs.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Thomas Wang <thomawan@amd.com>
Co-authored-by: Xinyi Song <86638975+RolaoDenthu@users.noreply.github.com>
Co-authored-by: Mohammad Miadh Angkad <176301910+mmangkad@users.noreply.github.com>
Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
Co-authored-by: BBuf <xiaoyu.zhang@radixark.ai>
Co-authored-by: Hanming Lu <hanminglu@meta.com>
Co-authored-by: Xinyi Song <xinyis10@illinois.edu>
This commit is contained in:
Liangsheng Yin
2026-08-04 13:22:49 -07:00
committed by GitHub
co-authored by DarkSharpness Xiaoyu Zhang Mick Yuhao Yang Cheng Wan Ke Bao Baizhou Zhang Chunan Zeng Khoa Pham Ziyi Xu Zijie Xia Yuwei An zhangxiaohao Yangmin Li Julien Lin Hao Phan Thomas Wang RolaoDenthu pigeonsoup HaiShaw Xinyuan Tong Pranjal Shankhdhar Lee Nau HMING elvischenv Byron Hsu Byron Hsu Claude Opus 5 Thomas Wang Xinyi Song Mohammad Miadh Angkad Cheng Wan BBuf Hanming Lu Xinyi Song
parent 0753663b8e
commit abddb1c7e9
139 changed files with 15414 additions and 911 deletions
+16
View File
@@ -514,6 +514,21 @@ jobs:
rust_ext_artifact: ''
secrets: inherit
base-c-test-8-gpu-b300:
needs: [check-changes, call-gate, wait-for-base-b, sgl-kernel-build-wheels, rust-ext-build]
if: ${{ !failure() && !cancelled() }}
uses: ./.github/workflows/_pr-test-stage.yml
with:
self_name: base-c-test-8-gpu-b300
runner_config: 8-gpu-b300
check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '60'
timeout_per_file: '3600'
rust_ext_artifact: ${{ needs.rust-ext-build.outputs.artifact_name }}
secrets: inherit
# List every build and test job: `skipped` passes here, so an omission turns that
# job's failure into a green run with no tests.
pr-test-finish:
@@ -549,6 +564,7 @@ jobs:
base-c-test-deepep-8-gpu-h200,
base-c-test-4-gpu-b200,
base-c-test-4-gpu-gb300,
base-c-test-8-gpu-b300,
]
if: always()
runs-on: ubuntu-latest
+1 -1
View File
@@ -12,7 +12,7 @@ ARG DEEPEP_COMMIT=9af0e0d0e74f3577af1979c9b9e1ac2cad0104ee
ARG BUILD_AND_DOWNLOAD_PARALLEL=8
ARG SGL_KERNEL_VERSION=0.4.5
ARG SGL_VERSION
ARG SGL_DEEP_GEMM_VERSION=0.1.5
ARG SGL_DEEP_GEMM_VERSION=0.1.5.post1
ARG USE_LATEST_SGLANG=0
ARG GDRCOPY_VERSION=2.5.1
ARG PIP_DEFAULT_INDEX
@@ -1966,8 +1966,8 @@ SGLang supports various environment variables that can be used to configure its
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}><code>SGLANG_USE_IPC_POOL_HANDLE_CACHE</code></td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Cache CUDA IPC pool handles.</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.05)"}}>When CUDA IPC multimodal feature transport is selected, reuse mappings to its existing bounded pool. This does not enable CUDA IPC transport or reserve another pool.</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_MM_FEATURE_CACHE_MB</code></td>
+1 -1
View File
@@ -67,7 +67,7 @@ dependencies = [
"scipy",
"sentencepiece",
"setproctitle",
"sgl-deep-gemm==0.1.5",
"sgl-deep-gemm==0.1.5.post1",
"sglang-kernel==0.4.5",
"smg-grpc-servicer>=0.5.0",
"soundfile==0.13.1",
@@ -18,9 +18,11 @@
#include <tvm/ffi/container/tensor.h>
#include <cstdint>
#include <cuda_fp8.h>
#include <limits>
#include <type_traits>
#ifndef USE_ROCM
#include <cuda_fp8.h>
#endif
namespace sglang {
@@ -0,0 +1,107 @@
// Tiny moe_align_block_size for M == 1 decode: one warp replaces the
// moe_align_block_size + count_and_sort_expert_tokens kernel pair (~4.4us)
// with a single ~1.5us launch.
//
// For a single token the top-k expert ids are distinct, so the aligned
// layout is exactly: experts sorted ascending, one block per expert,
// slot i of block b = flat topk index for b's expert, remaining block
// slots padded with numel (= topk). num_tokens_post_padded = topk * block.
#include <sgl_kernel/tensor.h> // For TensorMatcher, SymbolicSize, SymbolicDevice
#include <sgl_kernel/utils.h> // For RuntimeCheck
#include <sgl_kernel/type.cuh> // For device::cast
#include <sgl_kernel/utils.cuh> // For LaunchKernel
#include <tvm/ffi/container/tensor.h>
#include <cstdint>
namespace {
struct AlignSingleTokenParams {
const int32_t* __restrict__ topk_ids; // [1, topk]
int32_t* __restrict__ sorted_ids; // [topk * block_size]
int32_t* __restrict__ expert_ids; // [topk]
int32_t* __restrict__ num_post; // [1]
uint32_t topk;
uint32_t block_size;
};
template <bool kUsePDL>
__global__ void align_single_token_kernel(const AlignSingleTokenParams __grid_constant__ params) {
using namespace device;
const uint32_t lane = threadIdx.x; // one warp
const uint32_t topk = params.topk;
const uint32_t bs = params.block_size;
PDLWaitPrimary<kUsePDL>();
int32_t my_id = (lane < topk) ? params.topk_ids[lane] : INT32_MAX;
// Rank of my expert id among the topk (ids are distinct for one token;
// tie-break on lane keeps this robust anyway).
uint32_t rank = 0;
for (uint32_t j = 0; j < topk; ++j) {
int32_t other = __shfl_sync(0xffffffff, my_id, j);
if (other < my_id || (other == my_id && j < lane)) {
rank++;
}
}
if (lane < topk) {
params.expert_ids[rank] = my_id;
// block `rank`: first slot is my flat index (token 0, slot `lane`),
// rest padded with numel (= topk).
params.sorted_ids[rank * bs] = static_cast<int32_t>(lane);
}
// Fill padding cooperatively: positions not equal to a block start.
for (uint32_t p = lane; p < topk * bs; p += 32) {
if (p % bs != 0) {
params.sorted_ids[p] = static_cast<int32_t>(topk);
}
}
if (lane == 0) {
params.num_post[0] = static_cast<int32_t>(topk * bs);
}
PDLTriggerSecondary<kUsePDL>();
}
template <bool kUsePDL>
struct AlignSingleTokenKernel {
static constexpr auto kernel = align_single_token_kernel<kUsePDL>;
static void
run(const tvm::ffi::TensorView topk_ids,
const tvm::ffi::TensorView sorted_ids,
const tvm::ffi::TensorView expert_ids,
const tvm::ffi::TensorView num_post,
int64_t block_size) {
using namespace host;
auto One_ = SymbolicSize{"one"};
auto K_ = SymbolicSize{"topk"};
auto device = SymbolicDevice{};
device.set_options<kDLCUDA>();
TensorMatcher({One_, K_}).with_dtype<int32_t>().with_device(device).verify(topk_ids);
const auto topk = static_cast<uint32_t>(K_.unwrap());
RuntimeCheck(One_.unwrap() == 1, "moe_align_single_token requires M == 1");
RuntimeCheck(topk <= 32, "moe_align_single_token requires topk <= 32");
const auto params = AlignSingleTokenParams{
.topk_ids = static_cast<const int32_t*>(topk_ids.data_ptr()),
.sorted_ids = static_cast<int32_t*>(sorted_ids.data_ptr()),
.expert_ids = static_cast<int32_t*>(expert_ids.data_ptr()),
.num_post = static_cast<int32_t*>(num_post.data_ptr()),
.topk = topk,
.block_size = static_cast<uint32_t>(block_size),
};
LaunchKernel(dim3(1), 32, device.unwrap()).enable_pdl(kUsePDL)(kernel, params);
}
};
} // namespace
@@ -0,0 +1,49 @@
"""CUDA JIT single-warp moe_align_block_size for M == 1 decode batches."""
from __future__ import annotations
from typing import TYPE_CHECKING, Tuple
import torch
from sglang.kernels.jit.utils import (
cache_once,
is_arch_support_pdl,
load_jit,
make_cpp_args,
)
if TYPE_CHECKING:
from tvm_ffi.module import Module
@cache_once
def _jit_align_single_token_module() -> Module:
args = make_cpp_args(is_arch_support_pdl())
return load_jit(
"moe_align_single_token",
*args,
cuda_files=["moe/align_single_token.cuh"],
cuda_wrappers=[("run", f"AlignSingleTokenKernel<{args}>::run")],
extra_cuda_cflags=["-O3"],
)
def moe_align_single_token(
topk_ids: torch.Tensor, block_size: int
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""moe_align_block_size for a single token (distinct expert ids).
Returns (sorted_token_ids, expert_ids, num_tokens_post_padded) with the
same layout as moe_align_block_size: experts ascending, one block per
expert, padding value = numel.
"""
topk = topk_ids.shape[1]
device = topk_ids.device
sorted_ids = torch.empty((topk * block_size,), dtype=torch.int32, device=device)
expert_ids = torch.empty((topk,), dtype=torch.int32, device=device)
num_post = torch.empty((1,), dtype=torch.int32, device=device)
_jit_align_single_token_module().run(
topk_ids, sorted_ids, expert_ids, num_post, block_size
)
return sorted_ids, expert_ids, num_post
@@ -0,0 +1,172 @@
"""ROCm-compatible top-k / top-p probability renormalization fallbacks."""
from __future__ import annotations
from typing import Union
import torch
import triton
import triton.language as tl
_BLOCK_SIZE = 1024
@triton.jit
def _mask_and_partial_sum_kernel(
probs_ptr,
pivots_ptr,
out_ptr,
partial_sums_ptr,
vocab_size: tl.constexpr,
num_chunks: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
row = tl.program_id(0)
chunk = tl.program_id(1)
offsets = chunk * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offsets < vocab_size
row_offsets = row * vocab_size + offsets
probs = tl.load(probs_ptr + row_offsets, mask=mask, other=0.0).to(tl.float32)
pivot = tl.load(pivots_ptr + row)
kept = tl.where(mask & (probs >= pivot), probs, 0.0)
tl.store(out_ptr + row_offsets, kept, mask=mask)
tl.store(partial_sums_ptr + row * num_chunks + chunk, tl.sum(kept, axis=0))
@triton.jit
def _normalize_kernel(
out_ptr,
row_sums_ptr,
numel,
vocab_size: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offsets < numel
row = offsets // vocab_size
values = tl.load(out_ptr + offsets, mask=mask, other=0.0).to(tl.float32)
denominator = tl.load(row_sums_ptr + row, mask=mask, other=1.0)
tl.store(out_ptr + offsets, values / denominator, mask=mask)
def _prepare_probs(probs: torch.Tensor) -> torch.Tensor:
if probs.ndim != 2:
raise ValueError(f"probs must be 2D, got shape={tuple(probs.shape)}")
if not probs.is_cuda:
raise ValueError("renorm kernels require a CUDA/HIP tensor")
return probs.float().contiguous()
def _renorm_from_pivots(probs_fp32: torch.Tensor, pivots: torch.Tensor) -> torch.Tensor:
batch_size, vocab_size = probs_fp32.shape
num_chunks = triton.cdiv(vocab_size, _BLOCK_SIZE)
out = torch.empty_like(probs_fp32)
partial_sums = torch.empty(
(batch_size, num_chunks), device=probs_fp32.device, dtype=torch.float32
)
_mask_and_partial_sum_kernel[(batch_size, num_chunks)](
probs_fp32,
pivots,
out,
partial_sums,
vocab_size=vocab_size,
num_chunks=num_chunks,
BLOCK_SIZE=_BLOCK_SIZE,
num_warps=8,
)
row_sums = partial_sums.sum(dim=1)
_normalize_kernel[(triton.cdiv(out.numel(), _BLOCK_SIZE),)](
out,
row_sums,
out.numel(),
vocab_size=vocab_size,
BLOCK_SIZE=_BLOCK_SIZE,
num_warps=8,
)
return out
def top_p_renorm_probs_triton(
probs: torch.Tensor, top_p: Union[torch.Tensor, float]
) -> torch.Tensor:
"""Apply exact top-p thresholding and renormalize each probability row.
Sorting and prefix sums use PyTorch's device kernels because a vocabulary-sized
in-register Triton sort does not scale to 100K+ vocabularies. Triton performs
the bandwidth-heavy masking, partial reduction, and normalization.
"""
probs_fp32 = _prepare_probs(probs)
batch_size, vocab_size = probs_fp32.shape
if batch_size == 0 or vocab_size == 0:
return probs_fp32
if isinstance(top_p, torch.Tensor):
top_ps = top_p.to(device=probs.device, dtype=torch.float32).reshape(-1)
if top_ps.numel() == 1:
top_ps = top_ps.expand(batch_size)
elif top_ps.numel() != batch_size:
raise ValueError(
f"top_p must be scalar or have one value per row, got "
f"{top_ps.numel()} values for {batch_size} rows"
)
else:
if not 0.0 < float(top_p) <= 1.0:
raise ValueError("top_p values must be in (0, 1]")
top_ps = torch.full(
(batch_size,), float(top_p), device=probs.device, dtype=torch.float32
)
# Match FlashInfer's threshold semantics: sort ascending, discard the prefix
# whose cumulative mass is below 1 - p, and retain all ties at the pivot.
sorted_probs = torch.sort(probs_fp32, dim=-1).values
cdf = torch.cumsum(sorted_probs, dim=-1)
cutoff = torch.searchsorted(cdf, (1.0 - top_ps).unsqueeze(1), right=False).squeeze(
1
)
cutoff.clamp_(max=vocab_size - 1)
pivots = sorted_probs.gather(1, cutoff.unsqueeze(1)).squeeze(1).contiguous()
return _renorm_from_pivots(probs_fp32, pivots)
def top_k_renorm_probs_triton(
probs: torch.Tensor, top_k: Union[torch.Tensor, int]
) -> torch.Tensor:
"""Apply exact top-k thresholding and renormalize each probability row.
Sorting uses PyTorch's device kernels because a vocabulary-sized in-register
Triton sort does not scale to 100K+ vocabularies. Triton performs the
bandwidth-heavy masking, partial reduction, and normalization.
"""
probs_fp32 = _prepare_probs(probs)
batch_size, vocab_size = probs_fp32.shape
if batch_size == 0 or vocab_size == 0:
return probs_fp32
if isinstance(top_k, torch.Tensor):
top_ks = top_k.to(device=probs.device, dtype=torch.int64).reshape(-1)
if top_ks.numel() == 1:
top_ks = top_ks.expand(batch_size)
elif top_ks.numel() != batch_size:
raise ValueError(
f"top_k must be scalar or have one value per row, got "
f"{top_ks.numel()} values for {batch_size} rows"
)
else:
top_ks = torch.full(
(batch_size,), int(top_k), device=probs.device, dtype=torch.int64
)
# Match FlashInfer's threshold semantics: sort descending, keep the k highest
# probabilities, and retain all ties at the pivot.
sorted_probs = torch.sort(probs_fp32, dim=-1, descending=True).values
cutoff = (top_ks - 1).clamp_(min=0, max=vocab_size - 1)
pivots = sorted_probs.gather(1, cutoff.unsqueeze(1)).squeeze(1).contiguous()
return _renorm_from_pivots(probs_fp32, pivots)
__all__ = ["top_k_renorm_probs_triton", "top_p_renorm_probs_triton"]
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
def apply_kimi_k3_spec_backend_defaults(server_args: ServerArgs) -> None:
"""Apply speculative backend defaults for Kimi hybrid models."""
from sglang.srt.utils import is_sm100_supported
if server_args.speculative_algorithm is None:
return
# Use the fused Kimi-K3/DSPARK CuTeDSL kernel for KDA target verification.
# Decode is left free (its bf16-ssm SM100+ flashinfer default is fine -- the
# target only verifies under spec); the verify backend is pinned directly.
if server_args.linear_attn_verify_backend is None:
server_args.linear_attn_verify_backend = "nv_cutedsl"
logger.info(
"Kimi hybrid model with speculative decoding: pinning "
"--linear-attn-verify-backend to nv_cutedsl (uses the fused "
"Kimi-K3/DSPARK CuTeDSL kernel)."
)
# dspark's draft is dense MQA; trtllm_mha avoids flashinfer's blocking
# per-step host plan. DSPARK-only: other spec algos use MLA-family drafts.
if (
server_args.speculative_algorithm == "DSPARK"
and server_args.speculative_draft_attention_backend is None
and is_sm100_supported()
):
server_args.speculative_draft_attention_backend = "trtllm_mha"
logger.info(
"Kimi hybrid DSPARK: defaulting "
"--speculative-draft-attention-backend to trtllm_mha."
)
def disable_kimi_k3_symm_mem(server_args: ServerArgs) -> None:
"""Turn `--enable-symm-mem` back off unless every phase runs eager.
Symm-mem allocations are per-forward, so an address captured into a graph is
neither reserved for its lifetime nor at the same offset on every rank. Under
capture that corrupts spec decode: accept collapses to 1.000, or the server
silently emits garbage with accept pinned at the ceiling. Prefill counts too --
the same allocation sits in any captured RowParallelLinear.
Gates on the arch itself: this runs from cuda-graph resolution, which is earlier
than the model-specific hook block.
"""
from sglang.srt.connector import ConnectorType
from sglang.srt.model_executor.cuda_graph_config import Backend
from sglang.srt.utils import parse_connector_type
if not server_args.enable_symm_mem:
return
if parse_connector_type(server_args.model_path) == ConnectorType.INSTANCE:
return
if server_args.get_model_config().hf_config.architectures[0] not in (
"KimiLinearForCausalLM",
"KimiK3ForConditionalGeneration",
):
return
graph = server_args.cuda_graph_config
if (
graph.decode.backend == Backend.DISABLED
and graph.prefill.backend == Backend.DISABLED
):
return
server_args.enable_symm_mem = False
logger.warning(
"Kimi hybrid model: ignoring --enable-symm-mem because CUDA graphs are on. "
"The symmetric-memory pool's per-forward allocations are not valid for the "
"lifetime of a captured graph, which corrupts speculative decoding and can "
"silently produce wrong output. The auto-probed K3 fused all-reduce is faster "
"anyway. Disable capture on every phase "
"(--cuda-graph-backend-decode=disabled --cuda-graph-backend-prefill=disabled) "
"if you genuinely need symmetric memory."
)
def apply_kimi_k3_linear_attn_defaults(server_args: ServerArgs) -> None:
"""KDA decode-fallback default for Kimi hybrid models (spec-independent)."""
from sglang.srt.utils import is_sm100_supported
# Preempts the generic SM100+bf16 flashinfer switch (a GDN default): on
# KDA shapes the triton packed decode measures ~35% faster than
# recurrent_kda across bs 1-256, and ReplaySSM requires triton.
if (
server_args.linear_attn_decode_backend is None
and server_args.mamba_ssm_dtype == "bfloat16"
and is_sm100_supported()
):
server_args.linear_attn_decode_backend = "triton"
logger.info(
"Kimi hybrid model with bf16 SSM state: defaulting "
"--linear-attn-decode-backend to triton."
)
+236
View File
@@ -30,6 +30,7 @@ Two declaration forms, keyed on ``hf_config.architectures[0]``:
from __future__ import annotations
import dataclasses
import inspect
import logging
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
@@ -39,6 +40,7 @@ from sglang.srt.model_executor.cuda_graph_config import Backend
from sglang.srt.utils.common import (
cpu_has_amx_support,
get_device_capability,
get_device_name,
get_device_sm,
get_nvidia_driver_version,
get_quantization_config,
@@ -48,6 +50,7 @@ from sglang.srt.utils.common import (
is_flashinfer_available,
is_gfx95_supported,
is_hip,
is_mnnvl_fabric_device,
is_musa,
is_npu,
is_sm90_supported,
@@ -315,6 +318,234 @@ def _register_for(*architectures: str):
return decorator
def _dspark_verify_on_decode_backend(
backend: Optional[str], q_len: int, kv_cache_dtype: Optional[str]
) -> bool:
"""Whether the MLA decode backend can serve a q_len-wide target verify."""
if backend == "trtllm_mla":
return True
if backend == "tokenspeed_mla":
return kv_cache_dtype == "fp8_e4m3" and q_len <= 8
if backend == "cutedsl_mla":
# The cute-dsl kernel rejects q_len >= 5 with no fallback.
return q_len <= 4
return False
_KIMI_K3_DCP_PATCH_URL = (
"https://github.com/sgl-project/sglang/blob/"
"b701464720ca22aa1851d5dda7144e84a410f2c7/"
"docker/kimi_k3/kimi_k3_cu13.Dockerfile#L116-L123"
)
def _require_kimi_k3_cutedsl_dcp_support() -> None:
try:
from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
parameters = inspect.signature(trtllm_batch_decode_with_kv_cache_mla).parameters
except (ImportError, TypeError, ValueError) as exc:
raise RuntimeError(
"Kimi-K3 DCP with decode_attention_backend='cutedsl_mla' requires "
"a DCP-patched FlashInfer "
"trtllm_batch_decode_with_kv_cache_mla exposing enable_dcp in its "
f"signature. Apply the patch as shown in {_KIMI_K3_DCP_PATCH_URL}."
) from exc
if "enable_dcp" not in parameters:
raise RuntimeError(
"Kimi-K3 DCP with decode_attention_backend='cutedsl_mla' requires "
"enable_dcp in the signature of "
"flashinfer.decode.trtllm_batch_decode_with_kv_cache_mla. Apply "
f"the FlashInfer DCP patch as shown in {_KIMI_K3_DCP_PATCH_URL}."
)
@_register_for("KimiK3ForConditionalGeneration")
def _kimi_k3_overrides(server_args: Any, hf_config: Any) -> dict:
if server_args.dcp_size > 1:
overrides = {}
if server_args.enable_symm_mem:
logger.warning(
"Kimi-K3 DCP disables --enable-symm-mem due to decode CUDA "
"graph correctness issues."
)
overrides["enable_symm_mem"] = False
if server_args.speculative_algorithm == "DSPARK":
from sglang.srt.speculative.ragged_verify import (
RaggedVerifyMode,
read_ragged_verify_mode,
)
ragged_mode = read_ragged_verify_mode()
if ragged_mode is not RaggedVerifyMode.STATIC:
raise ValueError(
"Kimi-K3 DCP + DSPARK currently requires "
"SGLANG_RAGGED_VERIFY_MODE=static; compact/cap-accept are "
f"not validated under DCP (got {ragged_mode.value!r})."
)
# DSPARK target-verify + draft-extend must run on the decode
# (cutedsl_mla) backend, whose _run_decode_kernel implements the DCP
# signature (causal_seqs / cp_world / cp_rank). The default
# "prefill" routes verify to trtllm_mla, whose base _run_decode_kernel
# lacks that DCP path (TypeError: unexpected kwarg 'causal_seqs').
overrides["speculative_attention_mode"] = "decode"
prefill_backend, decode_backend = attention_backends_of(server_args)
if decode_backend == "cutedsl_mla" or decode_backend is None:
_require_kimi_k3_cutedsl_dcp_support()
logger.info(
"Kimi-K3 DCP keeps decode attention backend 'cutedsl_mla' "
f"(prefill={prefill_backend!r} -> 'trtllm_mla')."
)
overrides.update(
prefill_attention_backend="trtllm_mla",
decode_attention_backend="cutedsl_mla",
)
elif decode_backend == "tokenspeed_mla":
logger.info(
"Kimi-K3 DCP overrides attention backends: "
f"prefill={prefill_backend!r}, decode={decode_backend!r} -> "
"'tokenspeed_mla'."
)
logger.info(
"Kimi-K3 DCP with tokenspeed mla backend overrides KV cache dtype: "
f"{server_args.kv_cache_dtype!r} -> 'fp8_e4m3'."
)
overrides.update(
prefill_attention_backend="tokenspeed_mla",
decode_attention_backend="tokenspeed_mla",
kv_cache_dtype="fp8_e4m3",
)
else:
raise AssertionError(
f"Decode attention backend for Kimi-K3 DCP must be 'cutedsl_mla' or 'tokenspeed_mla', got {decode_backend!r}."
)
if server_args.dcp_replicate_q_proj is None:
logger.info("Kimi-K3 DCP enables replicated Q projection by default.")
overrides["dcp_replicate_q_proj"] = True
device_name = get_device_name()
dcp_comm_backend = "fi_a2a" if is_mnnvl_fabric_device() else "a2a"
logger.info(
"Kimi-K3 DCP selects communication backend on "
f"{device_name!r}: {server_args.dcp_comm_backend!r} -> "
f"{dcp_comm_backend!r}."
)
overrides["dcp_comm_backend"] = dcp_comm_backend
return overrides
if not (is_sm100_supported() and get_device_sm() in (100, 103)):
return {}
backends_unset = server_args.is_attention_backend_not_set()
if server_args.speculative_algorithm != "DSPARK":
if not backends_unset:
return {}
logger.info(
"Use trtllm_mla as the default prefill and decode attention "
"backend for Kimi-K3 on SM100/SM103."
)
return {
"decode_attention_backend": "trtllm_mla",
"prefill_attention_backend": "trtllm_mla",
}
# DSPARK: verify runs on the decode backend (mode=decode below), so this
# picks the verify kernel -- mode=prefill routes it to flashinfer, which is
# slow and syncs, while plain decode is cold under dspark.
q_len = server_args.speculative_num_draft_tokens or (
server_args.speculative_dspark_block_size + 1
if server_args.speculative_dspark_block_size is not None
# Checkpoint auto-infer happens after overrides; K3 draft uses block 7.
else 8
)
overrides = {}
if backends_unset:
backend = "trtllm_mla"
overrides["decode_attention_backend"] = backend
overrides["prefill_attention_backend"] = "trtllm_mla"
else:
# Explicit backend knobs keep priority, but the mode is a separate knob
# that still needs declaring -- else verify stays on the prefill backend,
# whose host-side plan (flashinfer by default) forces a per-step D2H.
_, backend = attention_backends_of(server_args)
if _dspark_verify_on_decode_backend(backend, q_len, server_args.kv_cache_dtype):
overrides["speculative_attention_mode"] = "decode"
logger.info(
"Kimi-K3 DSPARK on SM100/SM103: decode/verify attention backend "
f"{backend} (speculative_attention_mode=decode)."
)
else:
logger.warning(
f"Kimi-K3 DSPARK: decode attention backend {backend!r} cannot serve "
f"target verify at q_len={q_len}, so verify runs on the prefill "
"backend (speculative_attention_mode=prefill). A host-plan prefill "
"backend costs a per-step seq_lens D2H sync; leave the attention "
"backend knobs unset for the sync-free default."
)
return overrides
def _is_mxfp4_pack_quantized(hf_config: Any) -> bool:
qc = getattr(
getattr(hf_config, "text_config", hf_config), "quantization_config", None
)
if not isinstance(qc, dict):
return False
groups = qc.get("config_groups") or {}
return any(
"mxfp4" in str(g.get("format", ""))
for g in groups.values()
if isinstance(g, dict)
)
@_register_for("KimiK3ForConditionalGeneration")
def _kimi_k3_moe_runner_overrides(server_args: Any, hf_config: Any) -> dict:
# MoE runner default, independent of the attention-backend gate above.
# trtllm-gen fused MoE (flashinfer_mxfp4) beats marlin on both the decode
# (M=bs) and the target-verify (M=bs*(gamma+1)) regimes on SM100/SM103;
# it hard-requires the SiTU cubin pool on the box (K3's SiTU activation has
# no public cubins). Do not silently trade W4A8 for Marlin W4A16 when the
# default cannot start; explicit non-FlashInfer runner choices still win.
if server_args.moe_runner_backend not in ("auto", "flashinfer_mxfp4"):
return {}
if not (is_sm100_supported() and get_device_sm() in (100, 103)):
return {}
if not _is_mxfp4_pack_quantized(hf_config):
return {}
from sglang.kernels.ops.moe.trtllm_gen_moe import available as _trtllm_gen_moe_ok
if not _trtllm_gen_moe_ok():
raise RuntimeError(
"Kimi-K3 on Blackwell with moe_runner_backend='auto' or "
"'flashinfer_mxfp4' requires a valid "
"SGLANG_TRTLLM_GEN_MOE_CUBIN_POOL. Install it with:\n"
"wget https://github.com/sgl-project/whl/releases/download/"
"trtllm_gen_moe_cubin_20260617/"
"trtllm_gen_moe_cubin_pool_20260617_v0613rc1.zip\n"
"sudo mkdir -p /opt/trtllm_gen_moe_cubin_pool\n"
"sudo unzip -q "
"trtllm_gen_moe_cubin_pool_20260617_v0613rc1.zip -d "
"/opt/trtllm_gen_moe_cubin_pool\n"
"export "
"SGLANG_TRTLLM_GEN_MOE_CUBIN_POOL=/opt/trtllm_gen_moe_cubin_pool/"
"trtllm_gen_moe_cubin_pool_20260617_v0613rc1\n"
"To use Marlin "
"instead, set --moe-runner-backend marlin explicitly."
)
if server_args.moe_runner_backend == "auto":
logger.info(
"Kimi-K3 on SM100/SM103: moe_runner_backend=flashinfer_mxfp4 "
"(trtllm-gen SiTU cubin pool found)."
)
return {"moe_runner_backend": "flashinfer_mxfp4"}
return {}
@_register_for(
"DeepseekV3ForCausalLM",
"DeepseekV32ForCausalLM",
@@ -1175,6 +1406,7 @@ def _step3p_overrides(server_args: Any, hf_config: Any) -> dict:
_MAMBA_RADIX_CACHE_ARCHS = frozenset(
{
"KimiLinearForCausalLM",
"KimiK3ForConditionalGeneration",
"BailingMoeV2_5ForCausalLM",
"Qwen3NextForCausalLM",
"Qwen3_5MoeForConditionalGeneration",
@@ -1208,6 +1440,10 @@ _MAMBA_EXTRA_BUFFER_ARCHS = frozenset(
"GraniteMoeHybridForCausalLM",
"NemotronHForCausalLM",
"NemotronHPuzzleForCausalLM",
# KDA-based: same MambaPool ping-pong machinery as GDN; requires the
# KDA backend's track-snapshot writes (decode + extend) so donated
# slots hold real states for prefix-cache restores.
"KimiK3ForConditionalGeneration",
}
)
+2
View File
@@ -19,6 +19,7 @@ from sglang.srt.configs.interns2preview import InternS2PreviewConfig
from sglang.srt.configs.janus_pro import MultiModalityConfig
from sglang.srt.configs.jet_nemotron import JetNemotronConfig
from sglang.srt.configs.jet_vlm import JetVLMConfig
from sglang.srt.configs.kimi_k3 import KimiK3Config
from sglang.srt.configs.kimi_k25 import KimiK25Config
from sglang.srt.configs.kimi_linear import KimiLinearConfig
from sglang.srt.configs.kimi_vl import KimiVLConfig
@@ -71,6 +72,7 @@ __all__ = [
"Step3VisionEncoderConfig",
"Olmo3Config",
"KimiLinearConfig",
"KimiK3Config",
"KimiK25Config",
"LagunaConfig",
"Qwen3NextConfig",
+3
View File
@@ -102,6 +102,9 @@ def kimi_linear_config(model_config: ModelConfig):
config = model_config.hf_config
if isinstance(config, KimiLinearConfig):
return config
text_config = getattr(config, "text_config", None)
if isinstance(text_config, KimiLinearConfig):
return text_config
return None
+124
View File
@@ -0,0 +1,124 @@
from transformers.configuration_utils import PretrainedConfig
from sglang.srt.configs.kimi_linear import KimiLinearConfig
class KimiK3VisionConfig(PretrainedConfig):
model_type = "kimi_k3_vision"
def __init__(
self,
patch_size: int = 14,
init_pos_emb_height: int = 64,
init_pos_emb_width: int = 64,
init_pos_emb_time: int = 4,
pos_emb_type: str = "divided_fixed",
vt_num_attention_heads: int = 12,
vt_num_hidden_layers: int = 27,
vt_hidden_size: int = 1024,
vt_intermediate_size: int = 4096,
merge_kernel_size: tuple[int, int] = (2, 2),
video_attn_type: str = "spatial_temporal",
merge_type: str = "sd2_tpool",
_attn_implementation: str = "flash_attention_2",
mm_projector_type: str = "patchmergerv2",
mm_hidden_size: int | None = None,
projector_hidden_act: str = "gelu",
projector_ln_eps: float = 1e-5,
qkv_hidden_size: int = 1536,
norm_type: str = "rmsnorm",
attn_bias: bool = False,
patch_embed_proj_bias: bool = False,
mlp_type: str = "mlp2",
linear_bias: bool = False,
activation_func: str = "gelu_pytorch_tanh",
pos_emb_interpolation_mode: str = "bilinear",
text_hidden_size: int = 2304,
**kwargs,
):
super().__init__(**kwargs)
self.patch_size = patch_size
self.init_pos_emb_height = init_pos_emb_height
self.init_pos_emb_width = init_pos_emb_width
self.init_pos_emb_time = init_pos_emb_time
self.pos_emb_type = pos_emb_type
self.vt_num_attention_heads = vt_num_attention_heads
self.vt_num_hidden_layers = vt_num_hidden_layers
self.vt_hidden_size = vt_hidden_size
self.vt_intermediate_size = vt_intermediate_size
self.merge_kernel_size = tuple(merge_kernel_size)
self.video_attn_type = video_attn_type
self.merge_type = merge_type
self._attn_implementation = _attn_implementation
self.mm_projector_type = mm_projector_type
self.mm_hidden_size = (
mm_hidden_size if mm_hidden_size is not None else vt_hidden_size
)
self.projector_hidden_act = projector_hidden_act
self.projector_ln_eps = projector_ln_eps
self.text_hidden_size = text_hidden_size
self.qkv_hidden_size = qkv_hidden_size
self.norm_type = norm_type
self.attn_bias = attn_bias
self.patch_embed_proj_bias = patch_embed_proj_bias
self.mlp_type = mlp_type
self.linear_bias = linear_bias
self.activation_func = activation_func
self.pos_emb_interpolation_mode = pos_emb_interpolation_mode
# Aliases consumed by the K2.5 vision implementation.
self.num_attention_heads = vt_num_attention_heads
self.num_hidden_layers = vt_num_hidden_layers
self.hidden_size = vt_hidden_size
self.intermediate_size = vt_intermediate_size
class KimiK3Config(PretrainedConfig):
model_type = "kimi_k3"
def __init__(
self,
text_config: dict | KimiLinearConfig | None = None,
vision_config: dict | KimiK3VisionConfig | None = None,
ignore_index: int = -100,
media_placeholder_token_id: int = 163605,
pad_token_id: int = 0,
image_placeholder: str = "<|kimi_image_placeholder|>",
**kwargs,
):
if text_config is None:
self.text_config = KimiLinearConfig()
elif isinstance(text_config, dict):
self.text_config = KimiLinearConfig(**text_config)
else:
self.text_config = text_config
if vision_config is None:
self.vision_config = KimiK3VisionConfig()
elif isinstance(vision_config, dict):
self.vision_config = KimiK3VisionConfig(**vision_config)
else:
self.vision_config = vision_config
if self.vision_config.text_hidden_size != self.text_config.hidden_size:
self.vision_config.text_hidden_size = self.text_config.hidden_size
self.ignore_index = ignore_index
self.media_placeholder_token_id = media_placeholder_token_id
self.image_placeholder = image_placeholder
if getattr(self.text_config, "quantization_config", None) is not None:
self.quantization_config = self.text_config.quantization_config
super().__init__(pad_token_id=pad_token_id, **kwargs)
@property
def hidden_size(self) -> int:
return self.text_config.hidden_size
@property
def vocab_size(self) -> int:
return self.text_config.vocab_size
+17
View File
@@ -43,6 +43,7 @@ class KimiLinearConfig(PretrainedConfig):
use_grouped_topk: bool = True,
num_expert_group: int = 1,
topk_group: int = 1,
topk_method: str = "noaux_tc",
q_lora_rank: int | None = None,
kv_lora_rank: int | None = None,
qk_nope_head_dim: int | None = None,
@@ -51,6 +52,13 @@ class KimiLinearConfig(PretrainedConfig):
mla_use_nope: bool | None = False,
num_nextn_predict_layers: int = 0,
linear_attn_config: dict | None = None,
attn_res_block_size: int | None = None,
routed_expert_hidden_size: int | None = None,
latent_moe_use_norm: bool = False,
activation_situ_beta: float | None = None,
activation_situ_linear_beta: float | None = None,
mla_use_output_gate: bool = False,
max_position_embeddings: int = 4096,
**kwargs,
):
self.model_type = model_type
@@ -83,6 +91,7 @@ class KimiLinearConfig(PretrainedConfig):
self.mla_use_nope = mla_use_nope
# moe config
self.n_routed_experts = self.num_experts = num_experts
self.topk_method = topk_method
self.num_experts_per_token = num_experts_per_token
self.moe_renormalize = moe_renormalize
self.num_shared_experts = num_shared_experts
@@ -97,6 +106,14 @@ class KimiLinearConfig(PretrainedConfig):
self.topk_group = topk_group
self.num_nextn_predict_layers = num_nextn_predict_layers
self.attn_res_block_size = attn_res_block_size
self.routed_expert_hidden_size = routed_expert_hidden_size
self.latent_moe_use_norm = latent_moe_use_norm
self.activation_situ_beta = activation_situ_beta
self.activation_situ_linear_beta = activation_situ_linear_beta
self.mla_use_output_gate = mla_use_output_gate
self.max_position_embeddings = max_position_embeddings
if linear_attn_config is not None:
assert linear_attn_config["kda_layers"] is not None
assert linear_attn_config["full_attn_layers"] is not None
+14 -6
View File
@@ -128,18 +128,26 @@ class BaseLinearStateParams(ABC):
"""Per-slot bytes of the ReplaySSM spec-verify fold window (all
layers). Not part of ``mamba_cache_per_req``, so the memory solver
must charge it separately. MUST mirror the ``MambaPool`` allocation:
raw v/k in the conv dtype + fp32 g and beta. GDN scalar-g layout
only."""
assert not self.is_kda, "replayssm ring accounting supports GDN only"
raw v/k in the conv dtype + fp32 beta, plus the fp32 gate ring
(per-head scalar for GDN, per-K vector for KDA). KDA additionally
keeps the chunked d/k rings under spec (its forward_decode routes on
their presence), also in the conv dtype."""
hv, v_dim, k_dim = self.shape.temporal
h_k = self.shape.num_k_heads_per_tp
conv_b = self.dtype.conv.itemsize
fp32_b = 4
per_layer = (
hv * record_len * v_dim * conv_b
+ h_k * record_len * k_dim * conv_b
+ 2 * hv * record_len * fp32_b
hv * record_len * v_dim * conv_b # rawv
+ h_k * record_len * k_dim * conv_b # rawk
+ hv * record_len * fp32_b # beta (fp32)
# g (fp32): GDN per-head scalar, KDA per-K vector
+ hv * record_len * (k_dim if self.is_kda else 1) * fp32_b
)
if self.is_kda:
per_layer += (
hv * record_len * v_dim * conv_b # d
+ h_k * record_len * k_dim * conv_b # k
)
return per_layer * len(self.layers)
@property
+41 -10
View File
@@ -121,6 +121,10 @@ def is_deepseek_dsa(config) -> bool:
)
def is_kimi_k3(config) -> bool:
return _hf_arch(config) == "KimiK3ForConditionalGeneration"
def is_deepseek_v4(config) -> bool:
return _hf_arch(config) in (
"DeepseekV4ForCausalLM",
@@ -484,6 +488,9 @@ class ModelConfig:
self.is_multimodal_breakable_cuda_graph_supported = enable_multimodal and (
is_multimodal_breakable_cuda_graph_supported(self.hf_config.architectures)
)
self.is_mla_breakable_cuda_graph_supported = (
is_mla_breakable_cuda_graph_supported(self.hf_config.architectures)
)
self.dtype = _get_and_verify_dtype(self.hf_text_config, dtype)
# Derive context length and model shapes
@@ -912,18 +919,20 @@ class ModelConfig:
self.qk_rope_head_dim = self.hf_text_config.qk_rope_head_dim
self.v_head_dim = self.hf_text_config.v_head_dim
self.qk_nope_head_dim = self.hf_text_config.qk_nope_head_dim
elif "KimiLinearForCausalLM" in self.hf_config.architectures:
elif (
"KimiLinearForCausalLM" in self.hf_config.architectures
or "KimiK3ForConditionalGeneration" in self.hf_config.architectures
):
tc = self.hf_text_config
self.head_dim = 72
self.attention_arch = AttentionArch.MLA
self.kv_lora_rank = self.hf_config.kv_lora_rank
self.qk_rope_head_dim = self.hf_config.qk_rope_head_dim
self.v_head_dim = self.hf_config.v_head_dim
self.qk_nope_head_dim = self.hf_config.qk_nope_head_dim
self.kv_lora_rank = tc.kv_lora_rank
self.qk_rope_head_dim = tc.qk_rope_head_dim
self.v_head_dim = tc.v_head_dim
self.qk_nope_head_dim = tc.qk_nope_head_dim
self.scaling = 1 / math.sqrt(self.qk_nope_head_dim + self.qk_rope_head_dim)
if self.hf_config.rope_scaling:
self.scaling = compute_mla_mscale_scaling(
self.hf_config.rope_scaling, self.scaling
)
if getattr(tc, "rope_scaling", None):
self.scaling = compute_mla_mscale_scaling(tc.rope_scaling, self.scaling)
elif (
"BailingMoeV2_5ForCausalLM" in self.hf_config.architectures
or "BailingMoeForCausalLMNextN" in self.hf_config.architectures
@@ -1841,6 +1850,15 @@ multimodal_breakable_cuda_graph_supported_model_archs = [
"Qwen3_5MoeForConditionalGeneration",
]
# MLA archs validated to run breakable CUDA graph when it is explicitly
# requested (--cuda-graph-backend-prefill=breakable bypasses the ServerArgs
# disable rules). Dispatch pins the absorbed MLA path inside capture/replay
# for these archs, so the prefill runner's MHA-companion prefix restrictions
# do not apply (see PrefillCudaGraphRunner.mla_pinned_under_bcg).
mla_breakable_cuda_graph_supported_model_archs = [
"KimiK3ForConditionalGeneration",
]
if external_mm_model_arch := envs.SGLANG_EXTERNAL_MM_MODEL_ARCH.get():
multimodal_model_archs.append(external_mm_model_arch)
@@ -1915,6 +1933,14 @@ def is_multimodal_breakable_cuda_graph_supported(model_architectures: List[str])
)
def is_mla_breakable_cuda_graph_supported(model_architectures: List[str]):
"""Whether an MLA arch may keep prefill breakable CUDA graph enabled."""
return any(
arch in mla_breakable_cuda_graph_supported_model_archs
for arch in model_architectures
)
# SequenceClassification models that use CrossEncodingPooler
_cross_encoding_pooler_archs = [
"BertForSequenceClassification",
@@ -1936,8 +1962,13 @@ def compute_mla_mscale_scaling(rope_scaling: dict, base_scaling: float) -> float
"""Compute MLA attention scaling factor from rope_scaling with mscale.
Used by DeepSeek, BailingMoe, SarvamMLA and similar MLA models.
Warns if 'factor' is missing from rope_scaling (common in v5 configs).
Transformers v5 also exposes the default RoPE parameters through
``rope_scaling``. Those parameters do not request any scaling.
"""
rope_type = rope_scaling.get("rope_type") or rope_scaling.get("type")
if rope_type == "default":
return base_scaling
if not rope_scaling.get("apply_yarn_scaling", True) or not rope_scaling.get(
"apply_scale", True
):
@@ -206,6 +206,7 @@ class CommonKVManager(BaseKVManager):
self.request_status: Dict[int, KVPoll] = {}
self._socket_cache: Dict[str, zmq.Socket] = {}
self._monitor_cache: Dict[str, zmq.Socket] = {}
self._socket_send_locks: Dict[str, threading.Lock] = {}
self._socket_lock = threading.Lock()
self.failure_records: Dict[int, str] = {}
self.failure_lock = threading.Lock()
@@ -776,8 +777,18 @@ class CommonKVManager(BaseKVManager):
self._monitor_cache[endpoint] = sock.get_monitor_socket(
zmq.EVENT_DISCONNECTED
)
self._socket_send_locks.setdefault(endpoint, threading.Lock())
return sock
def _send_multipart_locked(
self, endpoint: str, parts: List[bytes], is_ipv6: bool = False
):
# Cached sockets are shared across sender threads and zmq sockets are
# not thread-safe; serialize sends per endpoint.
sock = self._connect(endpoint, is_ipv6=is_ipv6)
with self._socket_send_locks[endpoint]:
sock.send_multipart(parts)
def get_mha_kv_ptrs_with_pp(
self, src_kv_ptrs: List[int], dst_kv_ptrs: List[int]
) -> Tuple[List[int], List[int], List[int], List[int], int]:
+2 -2
View File
@@ -211,7 +211,7 @@ class HybridMambaDecodeReqToTokenPool(HybridReqToTokenPool):
speculative_eagle_topk: Optional[int] = None,
linear_replayssm_cache_len: int = 16,
mamba_envelope_layout: bool = False,
enable_gdn_replayssm_spec: bool = False,
enable_linear_replayssm_spec: bool = False,
):
DecodeReqToTokenPool.__init__(
self,
@@ -258,7 +258,7 @@ class HybridMambaDecodeReqToTokenPool(HybridReqToTokenPool):
speculative_eagle_topk=speculative_eagle_topk,
linear_replayssm_cache_len=linear_replayssm_cache_len,
mamba_envelope_layout=mamba_envelope_layout,
enable_gdn_replayssm_spec=enable_gdn_replayssm_spec,
enable_linear_replayssm_spec=enable_linear_replayssm_spec,
)
def clear(self):
@@ -481,6 +481,7 @@ _GENERAL_VIDEO_META_ATTRS = (
"video_timestamps",
"second_per_grid_ts",
)
_GENERAL_IMAGE_META_ATTRS = ("original_image_sizes",)
# MiMo-VL audio-in-video fields; appended only when model_type is MiMo.
_MIMO_VIDEO_AUDIO_META_ATTRS = (
"video_audio_feature_lens",
@@ -551,6 +552,8 @@ class MultiModalEmbeddingData(EmbeddingData):
self.img_grid_thw = [None] * num_parts
self.video_grid_thw = [None] * num_parts
self.audio_feature_lens = [None] * num_parts
for attr in _GENERAL_IMAGE_META_ATTRS:
setattr(self, attr, [None] * num_parts)
self.modality_list = [
modality if part_idx == i else None for i in range(num_parts)
]
@@ -567,6 +570,18 @@ class MultiModalEmbeddingData(EmbeddingData):
self._set_part_grid(part_idx, modality, self.get_grid())
if modality == Modality.VIDEO:
self._set_video_meta_for_part(part_idx, kwargs)
if modality == Modality.IMAGE:
self._set_image_meta_for_part(part_idx, kwargs)
def _set_image_meta_for_part(self, part_idx, source):
for attr_name in _GENERAL_IMAGE_META_ATTRS:
val = (
source.get(attr_name)
if isinstance(source, dict)
else getattr(source, attr_name, None)
)
if val is not None:
getattr(self, attr_name)[part_idx] = val
def _set_part_grid(self, part_idx, modality, grid):
"""Set the grid for one part according to modality (IMAGE/VIDEO/AUDIO)."""
@@ -601,6 +616,10 @@ class MultiModalEmbeddingData(EmbeddingData):
val = getattr(embedding_data, attr, None)
if val is not None:
extra[attr] = val
for attr in _GENERAL_IMAGE_META_ATTRS:
val = getattr(embedding_data, attr, None)
if val is not None:
extra[attr] = val
mm_data = cls(
part_idx=embedding_data.part_idx,
num_parts=embedding_data.num_parts,
@@ -651,6 +670,10 @@ class MultiModalEmbeddingData(EmbeddingData):
kwargs[attr] = torch.cat(valid, dim=0)
else:
kwargs[attr] = list(itertools.chain(*valid))
for attr in _GENERAL_IMAGE_META_ATTRS:
valid = [value for value in getattr(self, attr) if value is not None]
if valid:
kwargs[attr] = list(itertools.chain(*valid))
return kwargs
def add(self, embedding_data: EmbeddingData):
@@ -669,6 +692,8 @@ class MultiModalEmbeddingData(EmbeddingData):
self._set_part_grid(pid, embedding_data.modality, embedding_data.get_grid())
if embedding_data.modality == Modality.VIDEO:
self._set_video_meta_for_part(pid, embedding_data)
if embedding_data.modality == Modality.IMAGE:
self._set_image_meta_for_part(pid, embedding_data)
class WaitingImageRequestStatus(IntEnum):
@@ -678,6 +703,13 @@ class WaitingImageRequestStatus(IntEnum):
TIMEOUT = -2
def _select_mm_processor_prompt(recv_req, mm_processor):
"""Mirror tokenizer-side prompt selection for scheduler-side EPD rebuilds."""
if mm_processor.prefer_tokenized_input and recv_req.input_ids is not None:
return list(recv_req.input_ids)
return recv_req.input_text or recv_req.input_ids
def create_part_req_id(original_req_id: str, part_idx: int) -> str:
"""Create a unique part request ID by appending part index suffix."""
return f"{original_req_id}_local_part_{part_idx}"
@@ -724,6 +756,8 @@ class WaitingImageRequest:
model_type,
host_name,
receive_count,
zmq_context=None,
embedding_port=None,
):
self.rid = rid
self.recv_req = recv_req
@@ -736,9 +770,16 @@ class WaitingImageRequest:
self.host_name = host_name
self.receive_count = receive_count
self.num_items_assigned = recv_req.num_items_assigned
self.embedding_port, self.recv_socket = get_zmq_socket_on_host(
zmq.Context(), zmq.PULL, host=host_name
)
self.zmq_context = zmq_context
if embedding_port is None:
if self.zmq_context is None:
raise ValueError("zmq_context is required for a per-request socket")
self.embedding_port, self.recv_socket = get_zmq_socket_on_host(
self.zmq_context, zmq.PULL, host=host_name
)
else:
self.embedding_port = embedding_port
self.recv_socket = None
logger.info(f"Waiting for input {self.embedding_port = }")
self.recv_embedding_data = None
# ok=1 pending=0 fail=-1
@@ -835,71 +876,78 @@ class WaitingImageRequest:
def _try_recv_mm_data(self):
if self.status != WaitingImageRequestStatus.PENDING:
return
while self.recv_embedding_data is None or not self.recv_embedding_data.ready:
if self.recv_socket is None:
return
while self.status == WaitingImageRequestStatus.PENDING:
try:
parts = self.recv_socket.recv_multipart(flags=zmq.NOBLOCK, copy=False)
except zmq.Again:
# No data available yet, wait a bit and retry
return
try:
recv_obj: EmbeddingData = safe_pickle_loads(parts[0])
if getattr(recv_obj, "error_msg", None) is not None:
logger.warning(
f"Received error signal from encoder for {self.rid}: {recv_obj.error_msg} {recv_obj.error_code = }"
)
self.error_msg = recv_obj.error_msg
self.error_code = recv_obj.error_code
self.status = WaitingImageRequestStatus.FAIL
self.recv_socket.close()
return
self.consume_parts(parts)
# Extract original req_id from part_req_id and drop stale payloads
# that may arrive on a reused ZMQ port after a prior request aborted.
original_req_id = extract_original_req_id(recv_obj.req_id)
if original_req_id != self.recv_req.rid:
logger.warning(
f"Dropping stale embedding data: expected rid={self.recv_req.rid}, "
f"got rid={recv_obj.req_id} (likely from ZMQ port reuse)"
)
continue
recv_obj.req_id = original_req_id
def consume_parts(self, parts):
if self.status != WaitingImageRequestStatus.PENDING:
return
buffer = parts[1].buffer if hasattr(parts[1], "buffer") else parts[1]
recv_obj.embedding = (
torch.frombuffer(buffer, dtype=recv_obj.dtype)
.reshape(recv_obj.shape)
.clone()
try:
recv_obj: EmbeddingData = safe_pickle_loads(parts[0])
if getattr(recv_obj, "error_msg", None) is not None:
logger.warning(
f"Received error signal from encoder for {self.rid}: {recv_obj.error_msg} {recv_obj.error_code = }"
)
if self.recv_embedding_data is None:
self.recv_embedding_data = (
MultiModalEmbeddingData.from_embedding_data(
recv_obj, model_type=self.model_type
)
)
else:
self.recv_embedding_data.add(recv_obj)
except Exception as e:
# A message the scheduler cannot decode (blocked unpickle,
# bad shape/dtype, ...) must fail this request, not crash the
# scheduler event loop; FAIL still reaches the TP-wide status
# all-reduce in _process_waiting_requests.
logger.exception(
"Failed to decode embedding message for rid=%s", self.rid
)
self.error_msg = f"Failed to decode embedding message: {e}"
self.error_msg = recv_obj.error_msg
self.error_code = recv_obj.error_code
self.status = WaitingImageRequestStatus.FAIL
self._cleanup_gpu_buffer()
self.recv_socket.close()
self.close_recv_socket()
return
# Extract original req_id from part_req_id and drop stale payloads
# that may arrive on a reused ZMQ port after a prior request aborted.
original_req_id = extract_original_req_id(recv_obj.req_id)
if original_req_id != self.recv_req.rid:
logger.warning(
f"Dropping stale embedding data: expected rid={self.recv_req.rid}, "
f"got rid={recv_obj.req_id} (likely from ZMQ port reuse)"
)
return
recv_obj.req_id = original_req_id
buffer = parts[1].buffer if hasattr(parts[1], "buffer") else parts[1]
recv_obj.embedding = (
torch.frombuffer(buffer, dtype=recv_obj.dtype)
.reshape(recv_obj.shape)
.clone()
)
if self.recv_embedding_data is None:
self.recv_embedding_data = MultiModalEmbeddingData.from_embedding_data(
recv_obj, model_type=self.model_type
)
else:
self.recv_embedding_data.add(recv_obj)
except Exception as e:
# A message the scheduler cannot decode (blocked unpickle,
# bad shape/dtype, ...) must fail this request, not crash the
# scheduler event loop; FAIL still reaches the TP-wide status
# all-reduce in _process_waiting_requests.
logger.exception("Failed to decode embedding message for rid=%s", self.rid)
self.error_msg = f"Failed to decode embedding message: {e}"
self.status = WaitingImageRequestStatus.FAIL
self._cleanup_gpu_buffer()
self.close_recv_socket()
return
if not self.recv_embedding_data.ready:
return
# Assemble mm_inputs. Wrapped so an assembly failure still reaches the
# TP-wide status all-reduce in _process_waiting_requests instead of
# raising past it.
try:
recv_embedding = self.recv_embedding_data.get_embedding(is_concat=True)
mm_inputs = self.mm_processor.get_mm_data(
self.recv_req.input_text,
_select_mm_processor_prompt(self.recv_req, self.mm_processor),
recv_embedding,
**self.recv_embedding_data.get_mm_extra_meta(),
)
@@ -913,7 +961,12 @@ class WaitingImageRequest:
self.status = WaitingImageRequestStatus.FAIL
self.error_msg = f"Failed to assemble multimodal inputs: {e}"
self._cleanup_gpu_buffer()
self.recv_socket.close()
self.close_recv_socket()
def close_recv_socket(self):
if self.recv_socket is not None:
self.recv_socket.close()
self.recv_socket = None
def _cleanup_gpu_buffer(self):
pass
@@ -975,11 +1028,13 @@ class WaitingImageRDMARequest(WaitingImageRequest):
encoder_urls,
host_name,
receive_count,
zmq_context,
embeddings_engine,
dtype,
gpu_id=0,
model_type: Optional[str] = None,
embedding_pool=None,
embedding_port=None,
):
super().__init__(
rid=rid,
@@ -989,6 +1044,8 @@ class WaitingImageRDMARequest(WaitingImageRequest):
model_type=model_type,
host_name=host_name,
receive_count=receive_count,
zmq_context=zmq_context,
embedding_port=embedding_port,
)
self.embeddings_engine = embeddings_engine
self.dtype = dtype
@@ -1256,7 +1313,7 @@ class WaitingImageRDMARequest(WaitingImageRequest):
else:
recv_embedding = self.recv_embedding_data.get_embedding(is_concat=True)
mm_inputs = self.mm_processor.get_mm_data(
self.recv_req.input_text,
_select_mm_processor_prompt(self.recv_req, self.mm_processor),
recv_embedding,
**self.recv_embedding_data.get_mm_extra_meta(),
)
@@ -1511,6 +1568,10 @@ class MMReceiverBase(ABC):
encode_urls: Optional[List[str]] = None,
):
self.context = zmq.asyncio.Context(20)
# Scheduler-side receive is polled synchronously. Keep one regular ZMQ
# context alive for the process instead of creating a temporary context
# whose destruction also closes its per-request socket.
self.scheduler_context = zmq.Context()
self.encoder_transfer_backend = server_args.encoder_transfer_backend
# When ``encode_urls`` is shared with an :class:`EncoderBootstrapServer`
# (tokenizer manager process), it grows / shrinks in place as encoders
@@ -1529,6 +1590,24 @@ class MMReceiverBase(ABC):
self.nnodes = server_args.nnodes
self.hostname = get_local_ip_auto()
self.waiting_list: List[WaitingImageRequest] = []
self.waiting_by_rid: Dict[str, WaitingImageRequest] = {}
self.scheduler_embedding_port = None
self.scheduler_recv_socket = None
if (
self.encoder_transfer_backend == "zmq_to_scheduler"
and scheduler is not None
):
(
self.scheduler_embedding_port,
self.scheduler_recv_socket,
) = get_zmq_socket_on_host(
self.scheduler_context, zmq.PULL, host=self.hostname
)
logger.info(
"Scheduler TP rank %s reuses ZMQ embedding port %s",
self.tp_rank,
self.scheduler_embedding_port,
)
self.scheduler = scheduler
self.gpu_id = scheduler.ps.gpu_id if scheduler is not None else 0
self.wait_timeout = envs.SGLANG_ENCODER_RECV_TIMEOUT.get()
@@ -1864,6 +1943,28 @@ class MMReceiverBase(ABC):
waiting_req.error_code = best_code
# For zmq_to_scheduler
def _drain_scheduler_embeddings(self):
if self.scheduler_recv_socket is None:
return
while True:
try:
parts = self.scheduler_recv_socket.recv_multipart(
flags=zmq.NOBLOCK, copy=False
)
except zmq.Again:
return
recv_obj: EmbeddingData = safe_pickle_loads(parts[0])
rid = extract_original_req_id(recv_obj.req_id)
waiting_req = self.waiting_by_rid.get(rid)
if waiting_req is None:
logger.warning(
"Dropping embedding data for inactive request %s", recv_obj.req_id
)
continue
waiting_req.consume_parts(parts)
def _process_waiting_requests(self, recv_reqs, waiting_cls, **extra_kwargs):
new_recv_reqs = []
for recv_req in recv_reqs:
@@ -1886,8 +1987,16 @@ class MMReceiverBase(ABC):
model_type=self.model_type,
host_name=self.hostname,
receive_count=self.tp_size,
zmq_context=(
None
if self.scheduler_recv_socket is not None
else self.scheduler_context
),
embedding_port=self.scheduler_embedding_port,
**extra_kwargs,
)
if self.scheduler_recv_socket is not None:
self.waiting_by_rid[waiting_req.rid] = waiting_req
waiting_req.send_encode_request()
self.waiting_list.append(waiting_req)
else:
@@ -1896,14 +2005,16 @@ class MMReceiverBase(ABC):
if len(self.waiting_list) == 0:
return new_recv_reqs, []
self._drain_scheduler_embeddings()
current_time = time.time()
local_status = []
for waiting_req in self.waiting_list:
waiting_req._try_recv_mm_data()
if self.scheduler_recv_socket is None:
waiting_req._try_recv_mm_data()
if current_time - waiting_req.start_time > self.wait_timeout:
waiting_req.status = WaitingImageRequestStatus.TIMEOUT
waiting_req._cleanup_gpu_buffer()
waiting_req.recv_socket.close()
waiting_req.close_recv_socket()
local_status.append(waiting_req.status)
local_status = torch.tensor(local_status, device="cpu", dtype=torch.int32)
@@ -1945,6 +2056,8 @@ class MMReceiverBase(ABC):
)
else: # status_value == WaitingImageRequestStatus.PENDING
new_waiting.append(waiting_req)
continue
self.waiting_by_rid.pop(waiting_req.rid, None)
self.waiting_list = new_waiting
return new_recv_reqs, abort_reqs
+256 -77
View File
@@ -55,6 +55,9 @@ from sglang.srt.managers.io_struct import (
)
from sglang.srt.managers.schedule_batch import Modality, MultimodalDataItem
from sglang.srt.mem_cache.multimodal_cache import EmbeddingResult, MultiModalStaticCache
from sglang.srt.model_executor.model_runner_components.load_model_utils import (
maybe_precompile_model_kernels_after_loading,
)
from sglang.srt.model_loader import get_model
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
@@ -114,6 +117,7 @@ mooncake_send_done_count: Dict[str, int] = dict()
use_image_processor_gpu = envs.SGLANG_ENCODER_IMAGE_PROCESSOR_USE_GPU.get()
ENCODER_MAX_BATCH_SIZE = envs.SGLANG_ENCODER_MAX_BATCH_SIZE.get()
ENCODER_MAX_BATCH_SIZE_EXPLICIT = envs.SGLANG_ENCODER_MAX_BATCH_SIZE.is_set()
# Watchdog: max time to wait for a batched /encode result. Bounds HTTP latency
# if the batch worker stalls (NCCL hang, dead worker proc, etc.).
ENCODER_REQ_TIMEOUT = envs.SGLANG_ENCODER_REQ_TIMEOUT.get()
@@ -186,7 +190,7 @@ def _convert(data):
_mm_grid_attrs = {
# Kimi K2.5 HF processor uses grid_thws (see base_processor.ATTR_NAME_TO_MODALITY).
# Kimi K2.5/K3 HF processors use grid_thws (see base_processor.ATTR_NAME_TO_MODALITY).
Modality.IMAGE: ["image_grid_thw", "image_grid_hws", "grid_thws"],
Modality.VIDEO: ["video_grid_thw"],
Modality.AUDIO: ["audio_feature_lens_raw"],
@@ -200,14 +204,18 @@ _mm_feature_attrs = {
def _get_mm_grid_dim(mm_inputs, modality, model_type: Optional[str] = None):
# Kimi K2.5/K3 vision processors only emit `grid_thws`; prefer it over generic keys
# so we never pick a mis-typed or stale `image_grid_hws` field from kwargs.
attrs = _mm_grid_attrs[modality]
model_type = (model_type or "").lower()
if modality == Modality.IMAGE:
# Kimi K2.5 emits grid_thws, while Kimi-VL emits image_grid_hws.
if model_type == "kimi_k25":
# Kimi K2.5/K3 emit grid_thws, while Kimi-VL emits image_grid_hws.
# Other model types keep the generic attr order above.
if model_type in ("kimi_k25", "kimi_k3"):
attrs = ("grid_thws", "image_grid_thw", "image_grid_hws")
elif model_type == "kimi_vl":
attrs = ("image_grid_hws", "image_grid_thw", "grid_thws")
for attr in attrs:
if attr in mm_inputs and mm_inputs[attr] is not None:
return _convert(mm_inputs[attr])
@@ -249,10 +257,29 @@ def _normalize_aux_value(val):
def _build_mm_aux_data(mm_inputs, model_type=None):
# Video aux metadata, scoped to model_type's video-meta attrs.
return {
aux = {
attr: _normalize_aux_value(mm_inputs.get(attr))
for attr in video_meta_attrs_for(model_type)
}
if model_type == "kimi_k3":
aux["original_image_sizes"] = _normalize_aux_value(
mm_inputs.get("original_image_sizes")
)
return aux
def _get_original_image_size(image):
"""Return an image's original (width, height) before encoder preprocessing."""
if isinstance(image, dict):
image = image.get("image")
if isinstance(image, torch.Tensor):
if image.ndim < 2:
raise ValueError(f"Invalid image tensor shape: {tuple(image.shape)}")
return [int(image.shape[-1]), int(image.shape[-2])]
if hasattr(image, "size"):
width, height = image.size
return [int(width), int(height)]
raise TypeError(f"Cannot determine original image size from {type(image)}")
class MMEncoder:
@@ -320,9 +347,12 @@ class MMEncoder:
load_config=self.load_config,
device_config=self.device_config,
)
maybe_precompile_model_kernels_after_loading(self.model, self.device)
self.context = zmq.asyncio.Context(2)
self.sync_context = zmq.Context() # Reuse sync context for thread pool
self.scheduler_send_sockets = {}
self.scheduler_send_locks = {}
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
# Dedicated executor for image preprocessing (resize/normalize).
# Separate from self.executor (ZMQ sends) to avoid contention under high concurrency.
@@ -799,7 +829,7 @@ class MMEncoder:
return self._get_feat_extract_output_lengths(input_length)
else:
if (
self.model_type in ["kimi_k25", "kimi_vl"]
self.model_type in ["kimi_k25", "kimi_k3", "kimi_vl"]
and modality == Modality.IMAGE
):
return self._kimi_tokens_from_patch_grid(grid)
@@ -855,6 +885,9 @@ class MMEncoder:
"""
if grid_thw is None:
grid_thw = _get_mm_grid_dim(mm_inputs, modality, self.model_type)
split_kimi_k3_images = (
self.model_type == "kimi_k3" and modality == Modality.IMAGE
)
# Audio features are per-item (list of mels for mimo_v2, or batched
# N x n_mels x T_max for qwen2_audio); slice by item index and keep
@@ -874,31 +907,50 @@ class MMEncoder:
offsets.append(curr)
for idx in indices:
sub_feature_list.append(mm_feature[offsets[idx] : offsets[idx + 1]])
sub_feature = torch.cat(sub_feature_list, dim=0)
if not split_kimi_k3_images:
sub_feature = torch.cat(sub_feature_list, dim=0)
mm_item = MultimodalDataItem.from_dict(
{
"modality": modality,
"feature": (
sub_feature
if isinstance(sub_feature, list)
else _convert(sub_feature)
),
}
)
if split_kimi_k3_images:
mm_items = [
MultimodalDataItem.from_dict(
{
"modality": modality,
"feature": _convert(feature),
}
)
for feature in sub_feature_list
]
else:
mm_items = [
MultimodalDataItem.from_dict(
{
"modality": modality,
"feature": (
sub_feature
if isinstance(sub_feature, list)
else _convert(sub_feature)
),
}
)
]
for k, v in mm_inputs.items():
if k in _mm_feature_attrs.get(modality, []):
continue
val = _convert(v)
if k in _mm_grid_attrs.get(modality, []):
mm_item.set(k, val[indices])
if split_kimi_k3_images:
for mm_item, idx in zip(mm_items, indices):
mm_item.set(k, val[idx : idx + 1])
else:
mm_items[0].set(k, val[indices])
else:
mm_item.set(k, val)
for mm_item in mm_items:
mm_item.set(k, val)
forward_start = time.perf_counter()
with torch.inference_mode():
new_embeddings = get_feature_fn([mm_item])
new_embeddings = get_feature_fn(mm_items)
if not keep_on_gpu:
new_embeddings = new_embeddings.cpu()
if new_embeddings.ndim != 2:
@@ -1473,12 +1525,20 @@ class MMEncoder:
def _grid_count_per_leaf(self, leaves: List, modality: Modality) -> List[int]:
"""Number of grid entries each leaf produces under the model's processor.
Most processors map 1 leaf 1 grid. Kimi-VL/K25 image processors expand
Most processors map 1 leaf 1 grid. Kimi-VL/K2.5/K3 image processors expand
a leaf shaped {"type": "image", "image": [pil1, pil2, ...]} into N grids
(see _normalize_kimi_encoder_images). Cross-request batching needs these
counts to keep per-request boundaries aligned with grid_dim.
"""
if self.model_type not in ("kimi_k25", "kimi_vl") or modality != Modality.IMAGE:
if (
self.model_type
not in (
"kimi_k25",
"kimi_k3",
"kimi_vl",
)
or modality != Modality.IMAGE
):
return [1] * len(leaves)
def count(leaf):
@@ -1527,7 +1587,7 @@ class MMEncoder:
normalized.append(img)
return normalized
# Kimi-K2.5 vision processor expects media dicts.
# Kimi-K2.5/K3 vision processors expect media dicts.
normalized = []
for img in images:
wrapped = wrap_one(img)
@@ -1580,12 +1640,16 @@ class MMEncoder:
if model_preprocessor:
return model_preprocessor(images, Modality.IMAGE, self.vision_config)
image_config = self.vision_config.get("image", {})
if self.model_type in ["kimi_k25", "kimi_vl"]:
original_image_sizes = [_get_original_image_size(item) for item in images]
if self.model_type in ["kimi_k25", "kimi_k3", "kimi_vl"]:
images = self._normalize_kimi_encoder_images(images)
return await asyncio.get_running_loop().run_in_executor(
processor_input = await asyncio.get_running_loop().run_in_executor(
self.preproc_executor,
functools.partial(self.image_processor, images=images, **image_config),
)
if self.model_type == "kimi_k3":
processor_input["original_image_sizes"] = original_image_sizes
return processor_input
async def _process_video_items(self, mm_items, model_preprocessor):
if model_preprocessor:
@@ -1893,20 +1957,79 @@ class MMEncoder:
serialized_data = pickle.dumps(new_mm_data)
buffer = embedding_tensor.__buffer__()
# Use thread pool executor for parallel ZMQ send operations
_zmq_xfer_start = time.perf_counter()
if (
self.server_args.encoder_transfer_backend == "zmq_to_scheduler"
and url is not None
):
lock = self.scheduler_send_locks.get(endpoint)
if lock is None:
lock = asyncio.Lock()
self.scheduler_send_locks[endpoint] = lock
async with lock:
sock = self.scheduler_send_sockets.get(endpoint)
if sock is None:
sock = self.context.socket(zmq.PUSH)
config_socket(sock, zmq.PUSH)
sock.setsockopt(zmq.IMMEDIATE, 1)
sock.setsockopt(zmq.SNDTIMEO, int(self.send_timeout * 1000))
sock.connect(endpoint)
self.scheduler_send_sockets[endpoint] = sock
try:
frames = (
[serialized_data, buffer]
if buffer is not None
else [serialized_data]
)
tracker = await sock.send_multipart(frames, copy=False, track=True)
except Exception:
if self.scheduler_send_sockets.get(endpoint) is sock:
self.scheduler_send_sockets.pop(endpoint, None)
sock.close(linger=0)
raise
# MessageTracker.wait() protects the zero-copy source buffer; it
# is not a receiver acknowledgement. Waiting under the per-peer
# lock serialized every large embedding on that TCP connection.
# Queue sends in order under the lock, then wait for buffer
# ownership independently so libzmq can pipeline the connection.
try:
await asyncio.to_thread(tracker.wait, self.send_timeout)
except Exception:
if self.scheduler_send_sockets.get(endpoint) is sock:
self.scheduler_send_sockets.pop(endpoint, None)
sock.close(linger=0)
raise
if encoder_metrics_collector is not None:
encoder_metrics_collector.observe_transfer(
time.perf_counter() - _zmq_xfer_start,
backend=self.server_args.encoder_transfer_backend,
)
return
# Per-request sockets remain for zmq_to_tokenizer and legacy direct
# scheduler sends. Scheduler URL sends use persistent sockets above.
def send_with_socket():
sock = self.sync_context.socket(zmq.PUSH)
config_socket(sock, zmq.PUSH)
sock.setsockopt(zmq.IMMEDIATE, 1)
sock.setsockopt(zmq.SNDTIMEO, int(self.send_timeout * 1000))
try:
sock.connect(endpoint)
if buffer is not None:
sock.send_multipart([serialized_data, buffer], copy=False)
tracker = sock.send_multipart(
[serialized_data, buffer], copy=False, track=True
)
else:
sock.send_multipart([serialized_data], copy=False)
tracker = sock.send_multipart(
[serialized_data], copy=False, track=True
)
tracker.wait(timeout=self.send_timeout)
finally:
sock.close(linger=5000)
_zmq_xfer_start = time.perf_counter()
await asyncio.get_event_loop().run_in_executor(self.executor, send_with_socket)
if (
encoder_metrics_collector is not None
@@ -2077,7 +2200,7 @@ class MMEncoder:
try:
mm_inputs, get_feature_fn = await self._process_mm_items(mm_items, modality)
grid_thw = _get_mm_grid_dim(mm_inputs, modality, self.model_type)
aux_data = _build_mm_aux_data(mm_inputs)
aux_data = _build_mm_aux_data(mm_inputs, self.model_type)
# Setup metadata and event management
nbytes, total_tokens, embedding_dim, event = (
@@ -2178,7 +2301,7 @@ class MMEncoder:
"""Cross-request encoder fusion (image/audio). No cache path."""
# items_per_req counts grid entries (post-expansion) so per-request
# slicing of grid_dim/final_slices stays aligned for processors that
# expand one leaf into multiple grids (e.g. Kimi-VL/K25 dict-of-images).
# expand one leaf into multiple grids (e.g. Kimi-VL/K2.5/K3 dict-of-images).
flat_items, items_per_req = [], []
for req in requests:
leaves = MMEncoder._flatten_nested_items(req["mm_items"])
@@ -2235,14 +2358,17 @@ class MMEncoder:
if self.profiler is not None:
for _ in requests:
self.profiler.step()
# No aux_data here: batch_encode only handles IMAGE/AUDIO
# (_BATCHABLE_MODALITIES), and _build_mm_aux_data only extracts
# video-meta fields — which never appear in image/audio mm_inputs.
aux_data = _build_mm_aux_data(mm_inputs, self.model_type)
results = []
offset = 0
for req, n in zip(requests, items_per_req):
slices = final_slices[offset : offset + n]
emb = slices[0] if n == 1 else torch.cat(slices, dim=0)
req_aux_data = {}
if aux_data.get("original_image_sizes") is not None:
req_aux_data["original_image_sizes"] = aux_data[
"original_image_sizes"
][offset : offset + n]
if self.rank == 0:
self.embedding_to_send[req["req_id"]] = EmbeddingData(
req["req_id"],
@@ -2251,6 +2377,7 @@ class MMEncoder:
grid_dim[offset : offset + n],
modality,
emb,
**req_aux_data,
)
results.append((emb.nbytes, emb.shape[0], emb.shape[1], None, None))
offset += n
@@ -2462,6 +2589,20 @@ class PendingRequest:
# VIDEO excluded: per-video preprocess kwargs (do_sample_frames, video_metadata)
# vary per request and can't merge into one HF processor call.
_BATCHABLE_MODALITIES = {Modality.IMAGE, Modality.AUDIO}
_KIMI_K3_DEFAULT_ENCODER_MAX_BATCH_SIZE = 2
def _resolve_encoder_batch_policy(
model_type: str,
configured_max_batch_size: int,
max_batch_size_is_explicit: bool,
) -> Tuple[int, bool]:
"""Return effective batch size and same-turn coalescing policy."""
max_batch_size = max(1, int(configured_max_batch_size))
coalesce_same_turn = model_type == "kimi_k3"
if coalesce_same_turn and not max_batch_size_is_explicit:
max_batch_size = min(max_batch_size, _KIMI_K3_DEFAULT_ENCODER_MAX_BATCH_SIZE)
return max_batch_size, coalesce_same_turn
class EncoderScheduler:
@@ -2472,11 +2613,13 @@ class EncoderScheduler:
encoder: "MMEncoder",
send_sockets: List[zmq.Socket],
max_batch_size: int,
coalesce_same_turn: bool = False,
request_timeout: float = ENCODER_REQ_TIMEOUT,
):
self.encoder = encoder
self.send_sockets = send_sockets
self.max_batch_size = max(1, int(max_batch_size))
self.coalesce_same_turn = bool(coalesce_same_turn)
self.request_timeout = max(1.0, float(request_timeout))
self.pending_queue: asyncio.Queue[PendingRequest] = asyncio.Queue()
self._worker_task: Optional[asyncio.Task] = None
@@ -2485,7 +2628,9 @@ class EncoderScheduler:
if self._worker_task is None:
self._worker_task = asyncio.create_task(self._batch_worker())
logger.info(
f"EncoderScheduler started with max_batch_size={self.max_batch_size}"
"EncoderScheduler started with "
f"max_batch_size={self.max_batch_size}, "
f"coalesce_same_turn={self.coalesce_same_turn}"
)
async def stop(self) -> None:
@@ -2520,6 +2665,17 @@ class EncoderScheduler:
async def _collect_batch(self) -> List[PendingRequest]:
batch = [await self.pending_queue.get()]
first_modality = Modality.from_str(batch[0].request.get("modality", "image"))
should_yield = (
self.coalesce_same_turn
and self.max_batch_size > 1
and first_modality in _BATCHABLE_MODALITIES
)
if should_yield:
# Let HTTP handlers that arrived in the same event-loop turn enqueue
# before dispatch. Unlike a fixed sleep, this adds no millisecond-scale
# tax to an isolated request.
await asyncio.sleep(0)
while len(batch) < self.max_batch_size:
try:
batch.append(self.pending_queue.get_nowait())
@@ -2603,23 +2759,29 @@ class EncoderScheduler:
encoder_metrics_collector.observe_queue_wait(
max(0.0, start - p.submit_time), modality=modality_str
)
for sock in self.send_sockets:
sock_send(
sock,
wrap_as_pickle(
{
"type": "batch_encode",
"modality": modality.name,
"requests": requests,
"enter_time": start,
}
),
)
logger.info(f"Dispatching batch of {len(group)} {modality.name} requests")
try:
results = await self.encoder.batch_encode(requests, modality)
# The scheduler is the sole owner of batched dispatch order. Keep
# the collective broadcast and rank-0 execution under the same
# lock, while allowing concurrent HTTP handlers to enqueue before
# waiting on their individual futures.
async with self.encoder.encode_dispatch_lock:
for sock in self.send_sockets:
sock_send(
sock,
wrap_as_pickle(
{
"type": "batch_encode",
"modality": modality.name,
"requests": requests,
"enter_time": start,
}
),
)
logger.info(
f"Dispatching batch of {len(group)} {modality.name} requests"
)
results = await self.encoder.batch_encode(requests, modality)
if len(group) > 1:
logger.info(
f"Batch of {len(group)} {modality.name} requests completed in "
@@ -3376,8 +3538,16 @@ async def run_dp_worker(
encoder_metrics_collector = EncoderMetricsCollector(labels)
enc.dp_rank = dp_rank
max_batch_size, coalesce_same_turn = _resolve_encoder_batch_policy(
enc.model_type,
ENCODER_MAX_BATCH_SIZE,
ENCODER_MAX_BATCH_SIZE_EXPLICIT,
)
sched = EncoderScheduler(
encoder=enc, send_sockets=[], max_batch_size=ENCODER_MAX_BATCH_SIZE
encoder=enc,
send_sockets=[],
max_batch_size=max_batch_size,
coalesce_same_turn=coalesce_same_turn,
)
ctx = zmq.asyncio.Context(2)
@@ -3386,12 +3556,12 @@ async def run_dp_worker(
send_lock = asyncio.Lock()
inflight: Set[asyncio.Task] = set()
# Acquire-before-recv → back-pressure propagates to the dispatcher
# PUSH buffer. Must be ≥ ENCODER_MAX_BATCH_SIZE or batching degrades.
# PUSH buffer. Must be at least max_batch_size or batching degrades.
max_inflight = envs.SGLANG_ENCODER_DP_WORKER_MAX_INFLIGHT.get()
if max_inflight < ENCODER_MAX_BATCH_SIZE:
if max_inflight < max_batch_size:
logger.warning(
f"SGLANG_ENCODER_DP_WORKER_MAX_INFLIGHT={max_inflight} is below "
f"ENCODER_MAX_BATCH_SIZE={ENCODER_MAX_BATCH_SIZE}; the encoder "
f"the effective encoder max_batch_size={max_batch_size}; the encoder "
f"will never assemble a full batch."
)
inflight_sem = asyncio.Semaphore(max_inflight)
@@ -3472,8 +3642,16 @@ async def _lifespan(app: FastAPI):
yield
return
if encoder is not None:
max_batch_size, coalesce_same_turn = _resolve_encoder_batch_policy(
encoder.model_type,
ENCODER_MAX_BATCH_SIZE,
ENCODER_MAX_BATCH_SIZE_EXPLICIT,
)
encoder_scheduler = EncoderScheduler(
encoder, send_sockets, max_batch_size=ENCODER_MAX_BATCH_SIZE
encoder,
send_sockets,
max_batch_size=max_batch_size,
coalesce_same_turn=coalesce_same_turn,
)
encoder_scheduler.start()
try:
@@ -3937,8 +4115,10 @@ async def handle_encode_request(request: dict):
},
)
else:
# Lock direct dispatch together with rank0 await so its NCCL launch
# order matches the ZMQ dispatch order rank>0 sees.
# Non-batched requests still own their collective dispatch order
# directly; batched requests take this lock in _dispatch_group.
# Locking direct dispatch together with the rank0 await keeps its
# NCCL launch order matching the ZMQ dispatch order rank>0 sees.
async with encoder.encode_dispatch_lock:
for socket in send_sockets:
sock_send(socket, wrap_as_pickle(request))
@@ -4157,12 +4337,6 @@ async def health_generate():
if encoder is None:
return Response(status_code=503)
# Skip the dummy encode when real requests are already in flight — the
# ongoing traffic already proves liveness, matching the scheduler's
# `is_fully_idle`-based health-check skip pattern.
if encoder.embedding_to_send:
return Response(status_code=200)
# Pick the first available modality for the dummy encode
if encoder.image_processor is not None:
mm_items = [f"data:image/png;base64,{MINIMUM_PNG_PICTURE_BASE64}"]
@@ -4186,21 +4360,26 @@ async def health_generate():
"part_idx": 0,
}
# Broadcast to other TP ranks so distributed ops stay in sync
for socket in send_sockets:
sock_send(socket, wrap_as_pickle(dummy_request))
# A health encode participates in the same TP collectives as a real
# request. Serialize its broadcast and rank-0 forward with every other
# collective dispatch, then recheck whether traffic made the probe
# unnecessary while it waited for the lock.
async with encoder.encode_dispatch_lock:
if encoder.embedding_to_send:
return Response(status_code=200)
for socket in send_sockets:
sock_send(socket, wrap_as_pickle(dummy_request))
# Run encode on rank 0 with timeout
_, _, _, error_msg, _ = await asyncio.wait_for(
encoder.encode(
mm_items=mm_items,
modality=modality,
req_id=req_id,
num_parts=1,
part_idx=0,
),
timeout=HEALTH_CHECK_TIMEOUT,
)
_, _, _, error_msg, _ = await asyncio.wait_for(
encoder.encode(
mm_items=mm_items,
modality=modality,
req_id=req_id,
num_parts=1,
part_idx=0,
),
timeout=HEALTH_CHECK_TIMEOUT,
)
# Clean up stored embedding
encoder.embedding_to_send.pop(req_id, None)
@@ -404,10 +404,8 @@ class MooncakeKVManager(CommonKVManager):
def _send_chunk_ready(self, req, chunk_idx, kv_chunk, prefill_unique_rank):
"""Notify decode that a non-last staging chunk RDMA is complete."""
na = NetworkAddress(req.endpoint, req.dst_port)
self._connect(
self._send_multipart_locked(
na.to_tcp(),
is_ipv6=na.is_ipv6,
).send_multipart(
[
b"CHUNK_READY",
str(req.room).encode("ascii"),
@@ -416,7 +414,8 @@ class MooncakeKVManager(CommonKVManager):
str(len(kv_chunk.prefill_kv_indices)).encode("ascii"),
req.mooncake_session_id.encode("ascii"),
str(prefill_unique_rank).encode("ascii"),
]
],
is_ipv6=na.is_ipv6,
)
def _do_staging_transfer(
@@ -1091,9 +1090,8 @@ class MooncakeKVManager(CommonKVManager):
data: bytes,
):
na = NetworkAddress(remote, dst_port)
socket = self._connect(na.to_tcp(), is_ipv6=na.is_ipv6)
socket.send_multipart(
self._send_multipart_locked(
na.to_tcp(),
[
MooncakeKVManager.AUX_DATA_HEADER,
str(room).encode("ascii"),
@@ -1101,7 +1099,8 @@ class MooncakeKVManager(CommonKVManager):
str(aux_index).encode("ascii"),
struct.pack(">I", len(data)),
data,
]
],
is_ipv6=na.is_ipv6,
)
def _handle_aux_data(self, msg: List[bytes]):
@@ -1502,12 +1501,14 @@ class MooncakeKVManager(CommonKVManager):
self, remote: str, dst_port: int, room: int, status: int, prefill_rank: int
):
na = NetworkAddress(remote, dst_port)
self._connect(na.to_tcp(), is_ipv6=na.is_ipv6).send_multipart(
self._send_multipart_locked(
na.to_tcp(),
[
str(room).encode("ascii"),
str(status).encode("ascii"),
str(prefill_rank).encode("ascii"),
]
],
is_ipv6=na.is_ipv6,
)
def transfer_worker(
@@ -1736,12 +1737,34 @@ class MooncakeKVManager(CommonKVManager):
if kv_chunk.is_last_chunk:
if kv_chunk.state_indices and not skip_state:
self.maybe_send_extra(
state_rc = self.maybe_send_extra(
req,
kv_chunk.state_indices,
executor,
target_rank_registration_info,
)
if state_rc != 0:
with self.session_lock:
self.session_failures[
req.mooncake_session_id
] += 1
self.failed_sessions.add(
req.mooncake_session_id
)
self.record_failure(
kv_chunk.room,
f"Failed to send state components of {kv_chunk.room} to "
f"{NetworkAddress(req.endpoint, req.dst_port).to_host_port_str()}",
)
self.update_status(kv_chunk.room, KVPoll.Failed)
self.sync_status_to_decode_endpoint(
req.endpoint,
req.dst_port,
req.room,
KVPoll.Failed,
prefill_unique_rank,
)
break
# Only the last chunk we need to send the aux data
ret = self.send_aux(
@@ -1856,11 +1879,13 @@ class MooncakeKVManager(CommonKVManager):
# Send ACK back to decode endpoint
try:
na = NetworkAddress(decode_ip, decode_port)
self._connect(na.to_tcp(), is_ipv6=na.is_ipv6).send_multipart(
self._send_multipart_locked(
na.to_tcp(),
[
b"ABORT_ACK",
str(room_to_be_aborted).encode("ascii"),
]
],
is_ipv6=na.is_ipv6,
)
logger.debug(
f"Sent ABORT_ACK for room {room_to_be_aborted} to "
@@ -58,6 +58,10 @@ _SEMAPHORE_BYTES = 128
_MAX_GRAPH_INPUTS = 131072
# resolved once at import time; explicit constructor sizes take precedence
_DEFAULT_MAX_SIZE = envs.SGLANG_CUSTOM_ALL_REDUCE_V2_MAX_SIZE_KB.get() * 1024
# forced per-direction workspace sizes; highest priority, override both the
# tuned config and explicit constructor sizes (None = not forced)
_FORCE_PULL_SIZE_KB = envs.SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PULL_SIZE_KB.get()
_FORCE_PUSH_SIZE_KB = envs.SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PUSH_SIZE_KB.get()
class _PullMode(enum.Enum):
@@ -109,6 +113,10 @@ class CustomAllReduceV2:
the tuned size and ``max_size``.
:param max_push_size: explicit per-buffer push workspace size;
overrides both the tuned size and ``max_size``.
``SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PULL_SIZE_KB`` /
``SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PUSH_SIZE_KB`` take the highest
priority and override all of the size parameters above.
"""
self.disabled = True
if not can_use_custom_all_reduce_v2(group=group, device=device):
@@ -123,6 +131,25 @@ class CustomAllReduceV2:
max_pull_size = min(base_config.max_pull_bytes, max_size)
if max_push_size is None:
max_push_size = min(base_config.max_push_bytes, max_size)
if _FORCE_PULL_SIZE_KB is not None:
max_pull_size = int(_FORCE_PULL_SIZE_KB) * 1024
if _FORCE_PUSH_SIZE_KB is not None:
max_push_size = int(_FORCE_PUSH_SIZE_KB) * 1024
def force_thresholds(heuristic):
# forced sizes bypass the tuned NCCL-crossover heuristics: lift
# each direction's ceiling to the forced workspace capacity (the
# clip() below only ever lowers thresholds)
if _FORCE_PULL_SIZE_KB is not None:
heuristic = heuristic._replace(two_shot_pull_threshold=max_pull_size)
if _FORCE_PUSH_SIZE_KB is not None:
heuristic = heuristic._replace(one_shot_push_threshold=max_push_size)
return heuristic
base_config = base_config._replace(
graph=force_thresholds(base_config.graph),
eager=force_thresholds(base_config.eager),
)
# a minimal workspace keeps the Communicator valid even when a caller
# only uses one direction (e.g. push-only fused qk-norm instances)
self.max_pull_size = _ceil_align(max(max_pull_size, _ALIGN_BYTES), _ALIGN_BYTES)
@@ -138,8 +165,12 @@ class CustomAllReduceV2:
max_push_bytes=self.max_push_size, max_pull_bytes=self.max_pull_size
)._replace(num_pull_blocks=num_pull_blocks, num_push_blocks=num_push_blocks)
self.override_algo: Optional[AllReduceAlgo] = None
self.tms_cudagraph = envs.SGLANG_MEMORY_SAVER_CUDA_GRAPH.get()
# On a multi-node (MNNVL) group the symm-mem workspace plane works
# across nodes, but graph zero-copy input registration (cudaIpc /
# node-local VMM remap) does not — force eager pull inside graphs.
is_multinode = not all(in_the_same_node_as(group, source_rank=0))
tms_cudagraph = envs.SGLANG_MEMORY_SAVER_CUDA_GRAPH.get()
self._is_graph_mode_supported = not tms_cudagraph and not is_multinode
# device-side pointer table: one row of world_size pointers per
# graph-captured all-reduce input
self.graph_params = torch.zeros(
@@ -342,7 +373,7 @@ class CustomAllReduceV2:
yield
return
try:
self._graph_mode_allowed = not self.tms_cudagraph
self._graph_mode_allowed = self._is_graph_mode_supported
yield
finally:
self._graph_mode_allowed = False
@@ -426,6 +457,22 @@ def can_use_custom_all_reduce_v2(
group: ProcessGroup,
device: torch.device,
) -> bool:
# Multi-node (MNNVL): the node-local NVLink/P2P topology checks below
# are meaningless across nodes; the torch symm-mem rendezvous (fabric
# handles) is the real capability gate there.
if envs.SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE.get() and not all(
in_the_same_node_as(group, source_rank=0)
):
world_size = dist.get_world_size(group=group)
if world_size in range(2, 9):
logger.warning(
"CustomAllReduceV2 enabled on a multi-node group "
"(world_size=%d); graph zero-copy is disabled.",
world_size,
)
return True
return False
supported = list(range(2, 17))
if dist.get_world_size(group=group) not in supported:
return False
@@ -695,10 +695,22 @@ class GroupCoordinator:
self.pymscclpp_comm is not None
and self.pymscclpp_comm.should_mscclpp_allreduce(input_)
)
# With the MNNVL opt-in, let CustomAllReduceV2 take eligible (small)
# inputs ahead of the symm-mem pynccl fast path; otherwise pynccl
# would absorb every all-reduce whenever --enable-symm-mem is on and
# v2 never runs. Large inputs fail should_custom_ar and still go to
# the symm-mem path below.
_ca_takes_input = (
_CA_V2_MULTINODE
and self.ca_comm is not None
and not self.ca_comm.disabled
and self.ca_comm.should_custom_ar(input_)
)
if (
self.pynccl_comm is not None
and self.is_symmetric_memory_enabled()
and not should_use_pymscclpp_allreduce
and not _ca_takes_input
):
self.debug_check_symmetric_mempool(self, {"input": input_}, "all_reduce")
with self.pynccl_comm.change_state(enable=True):
@@ -1984,6 +1996,9 @@ logger = logging.getLogger(__name__)
_ENABLE_CUSTOM_ALL_REDUCE = True
_ENABLE_MSCCLPP_ALL_REDUCE = False
_ENABLE_TORCH_SYMM_MEM_ALL_REDUCE = False
# Read once at import: whether CustomAllReduceV2 is opted in on a multi-node
# (MNNVL) group. Used on the all_reduce hot path (see GroupCoordinator).
_CA_V2_MULTINODE = envs.SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE.get()
def set_custom_all_reduce(enable: bool):
+7
View File
@@ -107,6 +107,7 @@ from sglang.srt.utils import (
configure_logger,
get_bool_env_var,
is_cuda,
is_mnnvl_fabric_device,
kill_process_tree,
launch_dummy_health_check_server,
maybe_reindex_device_id,
@@ -1572,6 +1573,12 @@ class Engine(EngineScoreMixin, EngineBase):
def _set_envs_and_config(server_args: ServerArgs):
# Set global environments
# MNNVL fabric (GB200/GB300) multi-node: cross-node NVLink needs NCCL's
# cuMem-based buffers and MNNVL transport. Default them on (user-set
# values win; the symm-mem override below only fires when unset).
if server_args.nnodes > 1 and is_mnnvl_fabric_device():
os.environ.setdefault("NCCL_CUMEM_ENABLE", "1")
os.environ.setdefault("NCCL_MNNVL_ENABLE", "1")
if "NCCL_CUMEM_ENABLE" not in os.environ or server_args.enable_symm_mem:
os.environ["NCCL_CUMEM_ENABLE"] = str(int(server_args.enable_symm_mem))
if (
@@ -194,6 +194,7 @@ class OpenAIServingChat(OpenAIServingBase):
"""Handler for /v1/chat/completions requests"""
_default_sampling_params_logged = False
_KIMI_K3_GENERATION_STUB_TOKENS = 3
def __init__(
self,
@@ -650,6 +651,14 @@ class OpenAIServingChat(OpenAIServingBase):
content["meta_info"].get("cached_tokens", 0)
)
def _reported_prompt_tokens(self, meta_info: Dict[str, Any]) -> int:
prompt_tokens = meta_info.get("prompt_tokens", 0)
if self.chat_encoding_spec == "kimi_k3":
# K3's three-token assistant generation stub is model input, but the
# reference API excludes it from billed/reported prompt tokens.
prompt_tokens = max(0, prompt_tokens - self._KIMI_K3_GENERATION_STUB_TOKENS)
return prompt_tokens
async def _generate_stream_content(
self,
content: Dict[str, Any],
@@ -1493,7 +1502,9 @@ class OpenAIServingChat(OpenAIServingBase):
):
index = content.get("index", 0)
prompt_tokens[index] = content["meta_info"].get("prompt_tokens", 0)
prompt_tokens[index] = self._reported_prompt_tokens(
content["meta_info"]
)
completion_tokens[index] = content["meta_info"].get(
"completion_tokens", 0
)
@@ -1720,6 +1731,20 @@ class OpenAIServingChat(OpenAIServingBase):
created: int,
) -> Union[ChatCompletionResponse, ORJSONResponse]:
"""Build chat completion response from generation results"""
if self.chat_encoding_spec == "kimi_k3":
ret = [
{
**item,
"meta_info": {
**item["meta_info"],
"prompt_tokens": self._reported_prompt_tokens(
item["meta_info"]
),
},
}
for item in ret
]
choices = []
# Build sglext at response level (from first ret_item, as these are per-request)
@@ -2378,7 +2403,7 @@ class OpenAIServingChat(OpenAIServingBase):
# Add usage stats if continuous_usage_stats is enabled
if continuous_usage_stats:
prompt_tokens = content["meta_info"].get("prompt_tokens", 0)
prompt_tokens = self._reported_prompt_tokens(content["meta_info"])
completion_tokens = content["meta_info"].get("completion_tokens", 0)
reasoning_tokens = content["meta_info"].get("reasoning_tokens", 0)
chunk.usage = UsageProcessor.calculate_token_usage(
@@ -2431,7 +2456,7 @@ class OpenAIServingChat(OpenAIServingBase):
# Add usage stats if continuous_usage_stats is enabled
if continuous_usage_stats:
prompt_tokens = content["meta_info"].get("prompt_tokens", 0)
prompt_tokens = self._reported_prompt_tokens(content["meta_info"])
completion_tokens = content["meta_info"].get("completion_tokens", 0)
reasoning_tokens = content["meta_info"].get("reasoning_tokens", 0)
chunk.usage = UsageProcessor.calculate_token_usage(
+49 -2
View File
@@ -808,6 +808,39 @@ class Envs:
# Triton two_dot variant, 1.16-1.38x faster across GLM/DS shapes).
SGLANG_OPT_Q8KV8_QPREP_VARIANT = EnvStr("auto")
# TRT-LLM-gen fused MoE (SiTU) via sglang JIT: path to an unpacked SiTU
# cubin pool (cubins + flat ABI headers + overlay/; distributed as a
# single downloadable archive). Needs the public flashinfer package
# installed for the unmodified JIT sources. Unset = feature off.
SGLANG_TRTLLM_GEN_MOE_CUBIN_POOL = EnvStr(None)
# MNNVL fused all-reduce (bf16, TP8): zero-copy 1shot multicast-push for
# small messages and in-place NVLS 2shot on symmetric-memory tensors for
# large ones, with an optional fused residual add. Covers the KDA o_proj
# output and the latent|shared MoE reduce; everything else falls back to
# the regular all-reduce path. Auto-enabled on SM100/SM103 when
# CustomAllReduceV2 with multicast is available; set 0/1 to override in
# either direction. See srt/layers/k3_ar_fusion.py.
SGLANG_K3_AR_FUSION = EnvBool(False)
# K3 SP-MoE fused residual + reduce-scatter and matching all-gather over
# CustomAllReduceV2's MNNVL push workspace. Auto-probed for the validated
# TP8 GB300 configuration; set 0/1 to override. See
# srt/layers/k3_sp_collective.py.
SGLANG_K3_SP_COLLECTIVE = EnvBool(False)
# Keep K3's post-MoE residual stream token-sharded between consecutive
# SP-MoE layers. The next attention-residual aggregation and snapshot
# bank write run on the local shard, then only the normalized attention
# input is all-gathered. Requires SGLANG_K3_SP_COLLECTIVE.
SGLANG_K3_SP_ATTN_RES = EnvBool(False)
# Fused o_proj GEMM + all-reduce (bf16, TP 2..8, SM100+): one
# kernel computes the TP-local o_proj partial and the cross-rank sum over
# a P2P comm region, replacing the GEMM + NCCL AR pair at M <= 512.
SGLANG_K3_GEMM_AR = EnvBool(False)
# Merge the router gate and routed_expert_down_proj weights so the K3 MoE
# front reads hidden_states once, and run the top-k plus the bf16 cast in one
# epilogue kernel. See kernels/ops/moe/moe_front.py. Default on.
SGLANG_K3_FUSED_FRONT = EnvBool(True)
# sgl-kernel
SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK = EnvBool(False)
@@ -848,6 +881,16 @@ class Envs:
# Default per-direction workspace cap for CustomAllReduceV2; explicit
# constructor sizes take precedence over this.
SGLANG_CUSTOM_ALL_REDUCE_V2_MAX_SIZE_KB = EnvInt(16 * 1024)
SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PULL_SIZE_KB = EnvInt(None)
SGLANG_FORCE_CUSTOM_ALL_REDUCE_V2_PUSH_SIZE_KB = EnvInt(None)
# Allow CustomAllReduceV2 on a process group that spans nodes (MNNVL
# fabric). Requires torch symmetric memory to rendezvous across nodes
# (fabric handles + IMEX). Graph zero-copy input registration is not
# supported in this mode and is disabled; all-reduce inside CUDA graphs
# falls back to eager pull from the symm workspace. Auto-enabled on
# MNNVL-fabric devices (GB200/GB300) when nnodes > 1; set 0/1 to
# override in either direction.
SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE = EnvBool(False)
SGLANG_FLASHINFER_PREFILL_SPLIT_TILE_SIZE = EnvInt(4096)
SGLANG_FLASHINFER_DECODE_SPLIT_TILE_SIZE = EnvInt(2048)
SGLANG_TRITON_PREFILL_TRUNCATION_ALIGN_SIZE = EnvInt(4096)
@@ -903,6 +946,9 @@ class Envs:
SGLANG_MM_BUFFER_SIZE_MB = EnvInt(0)
SGLANG_MM_PRECOMPUTE_HASH = EnvBool(False)
SGLANG_VIT_ENABLE_CUDA_GRAPH = EnvBool(False)
SGLANG_KIMI_K3_VIT_CUDA_GRAPH_CACHE_CAPACITY = EnvInt(2)
SGLANG_KIMI_K3_VIT_CUDA_GRAPH_MIN_HITS = EnvInt(2)
SGLANG_KIMI_K3_VIT_CUDA_GRAPH_MAX_SEQLEN = EnvInt(6144)
# Use the fully-vectorized ViT position-embedding interpolation (no per-image
# Python loop / CPU<->GPU sync). Bit-exact with the legacy implementation;
# set False to fall back to the per-image loop.
@@ -914,7 +960,9 @@ class Envs:
# VLM Item CUDA IPC Transport
SGLANG_USE_CUDA_IPC_TRANSPORT = EnvBool(False)
SGLANG_USE_IPC_POOL_HANDLE_CACHE = EnvBool(False)
# Reuse the mapping for the already-allocated bounded CUDA IPC pool. This
# has no effect unless CUDA IPC feature transport is explicitly selected.
SGLANG_USE_IPC_POOL_HANDLE_CACHE = EnvBool(True)
SGLANG_MM_FEATURE_CACHE_MB = EnvInt(1 * 1024)
SGLANG_MM_ITEM_MEM_POOL_RECYCLE_INTERVAL_SEC = EnvFloat(0.05)
@@ -929,7 +977,6 @@ class Envs:
# mamba pool ratio accordingly. Frees one resident slot per running request,
# raising max_running_requests. Off = original locking + ratio (escape hatch).
SGLANG_OPT_MAMBA_SKIP_DECODE_LOCK = EnvBool(False)
# Unified Radix Tree
SGLANG_ENABLE_UNIFIED_RADIX_TREE = EnvBool(False)
# Registered TreeCore backend serving the unified radix cache.
@@ -85,6 +85,7 @@ def fused_sigmoid_gating_delta_rule_update(
dt_bias=dt_bias,
softplus_beta=softplus_beta,
softplus_threshold=softplus_threshold,
lower_bound=0.0,
q=q,
k=k,
v=v,
@@ -118,6 +119,7 @@ def fused_sigmoid_gating_delta_rule_update(
USE_QK_L2NORM_IN_KERNEL=use_qk_l2norm_in_kernel,
IS_VARLEN=cu_seqlens is not None,
IS_KDA=is_kda,
USE_LOWER_BOUND=False,
DISABLE_STATE_UPDATE=disable_state_update,
CACHE_INTERMEDIATE_STATES=intermediate_states_buffer is not None,
HAS_EAGLE_TREE_CUSTOM_ATTN_MASK=retrieve_parent_token is not None,
+31
View File
@@ -181,6 +181,37 @@ class SiluAndMul(MultiPlatformOp):
return self._musa_swish_glu(x)
class SituAndMul(MultiPlatformOp):
"""SituGLU activation used by Kimi K3.
Computes beta * tanh(gate / beta) * sigmoid(gate) * up.
When linear_beta is set, up is softly clipped:
up = linear_beta * tanh(up / linear_beta).
"""
def __init__(self, beta: float = 1.0, linear_beta: float | None = None):
super().__init__()
self.beta = float(beta)
self.linear_beta = None if linear_beta is None else float(linear_beta)
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
d = x.shape[-1] // 2
gate = x[..., :d].float()
up = x[..., d:].float()
gate = self.beta * torch.tanh(gate / self.beta) * torch.sigmoid(gate)
if self.linear_beta is not None:
up = self.linear_beta * torch.tanh(up / self.linear_beta)
return (gate * up).to(x.dtype)
def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
from sglang.kernels.ops.kimi_k3.activation import situ_and_mul
return situ_and_mul(x, None, self.beta, self.linear_beta)
def forward_cpu(self, x: torch.Tensor) -> torch.Tensor:
return self.forward_native(x)
class GeluAndMul(MultiPlatformOp):
def __init__(self, approximate="tanh"):
super().__init__()
@@ -70,6 +70,18 @@ def create_flashinfer_backend(runner):
def create_trtllm_mla_backend(runner):
if not runner.use_mla_backend:
raise ValueError("trtllm_mla backend can only be used with MLA models.")
if (
runner.server_args.dcp_size > 1
and runner.server_args.speculative_algorithm is not None
):
_, decode_backend = runner.server_args.get_attention_backends()
if decode_backend == "trtllm_mla":
raise ValueError(
"trtllm_mla cannot serve decode context parallelism with speculative "
"decoding: it does not forward the cyclic DCP metadata to its decode "
"kernel and returns no rank-local LSE for the cross-rank merge. "
"Select cutedsl_mla or tokenspeed_mla."
)
from sglang.srt.layers.attention.trtllm_mla_backend import TRTLLMMLABackend
return TRTLLMMLABackend(runner)
@@ -178,15 +178,18 @@ class CuteDslMLABackend(TRTLLMMLABackend):
bs, num_tokens, forward_mode, seq_lens, device
)
if get_parallel().dcp_enabled:
if forward_mode.is_target_verify():
self.forward_decode_metadata.global_seq_lens_k = torch.zeros_like(
self.forward_decode_metadata.seq_lens_k
)
self.forward_decode_metadata.max_seq_len_k = (
self._get_dcp_local_max_seq_len(
self.max_context_len
+ (self.num_draft_tokens if forward_mode.is_target_verify() else 0)
metadata = self.forward_decode_metadata
if metadata.global_seq_lens_k is None:
# Plain decode under DCP also keeps the int32 GLOBAL lens in a
# capture-stable buffer (super allocates it only for verify):
# the DCP kernel consumes both the rank-local and the global
# lens every MLA layer, so both are maintained once per step.
metadata.global_seq_lens_k = torch.zeros(
(bs,), dtype=torch.int32, device=device
)
metadata.max_seq_len_k = self._get_dcp_local_max_seq_len(
self.max_context_len
+ (self.num_draft_tokens if forward_mode.is_target_verify() else 0)
)
def _apply_cuda_graph_metadata(
@@ -224,7 +227,13 @@ class CuteDslMLABackend(TRTLLMMLABackend):
local_seq_lens = self._get_dcp_local_seq_lens(seq_lens)
else:
seq_lens = seq_lens[:bs]
local_seq_lens = self._get_dcp_local_seq_lens(seq_lens)
# Hoist: refresh the int32 global + rank-local lens once per step
# into the capture-stable buffers; forward_decode reads them
# instead of recomputing get_dcp_lens + two int32 casts per MLA
# layer.
metadata.global_seq_lens_k.copy_(seq_lens)
metadata.seq_lens_k.copy_(self._get_dcp_local_seq_lens(seq_lens))
local_seq_lens = metadata.seq_lens_k
self._fill_dcp_block_kv_indices(
metadata.block_kv_indices,
@@ -249,6 +258,19 @@ class CuteDslMLABackend(TRTLLMMLABackend):
metadata.seq_lens_k = self._get_dcp_local_seq_lens(
metadata.global_seq_lens_k
)
elif (
forward_batch.forward_mode.is_decode_or_idle()
and self.forward_decode_metadata.seq_lens_k is not None
):
# Same hoist as verify: the parent stored the int32 GLOBAL
# lens in seq_lens_k; keep it as global_seq_lens_k and derive
# the rank-local view once per step (forward_decode consumes
# both every MLA layer).
metadata = self.forward_decode_metadata
metadata.global_seq_lens_k = metadata.seq_lens_k
metadata.seq_lens_k = self._get_dcp_local_seq_lens(
metadata.global_seq_lens_k
)
self.forward_decode_metadata.max_seq_len_k = (
self._get_dcp_local_max_seq_len(
self.forward_decode_metadata.max_seq_len_k
@@ -349,12 +371,30 @@ class CuteDslMLABackend(TRTLLMMLABackend):
# Query / KV preparation mirrors the base cute-dsl decode (both FP16 and
# FP8 KV), then swaps to the DCP kernel call + rank-local return.
merge_query = q_rope is not None
query = None
if self.data_type == torch.float8_e4m3fn:
assert q_rope is not None and k_rope is not None
if cos_sin_cache is None:
q, k, k_rope = mla_quantize_without_rope_for_fp8(
q, q_rope, k.squeeze(1), k_rope.squeeze(1)
)
if (
save_kv_cache
and self._fused_set_kv_concat_q_fp8
and not self._unified_mla
):
# Static pool: out_cache_loc is already the physical loc.
# Fused: bf16->fp8 quantize + KV scatter + q concat in one
# launch; None when not covered.
query = self._set_kv_and_concat_q_fp8_fused(
layer=layer,
loc=forward_batch.out_cache_loc,
q=q,
q_rope=q_rope,
k=k,
k_rope=k_rope,
)
if query is None:
q, k, k_rope = mla_quantize_without_rope_for_fp8(
q, q_rope, k.squeeze(1), k_rope.squeeze(1)
)
else:
q, k, k_rope = mla_quantize_and_rope_for_fp8(
q,
@@ -369,13 +409,15 @@ class CuteDslMLABackend(TRTLLMMLABackend):
)
merge_query = False
if save_kv_cache:
if query is None and save_kv_cache:
assert k is not None and k_rope is not None
self.token_to_kv_pool.set_mla_kv_buffer(
layer, forward_batch.out_cache_loc, k, k_rope
)
if merge_query:
if query is not None:
pass # fused fp8 path already built the query and wrote KV
elif merge_query:
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope_reshaped = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
@@ -404,8 +446,14 @@ class CuteDslMLABackend(TRTLLMMLABackend):
self.init_forward_metadata(forward_batch)
metadata = forward_batch.decode_trtllm_mla_metadata
global_seq_lens = forward_batch.seq_lens[: forward_batch.batch_size]
local_seq_lens = self._get_dcp_local_seq_lens(global_seq_lens)
if metadata.seq_lens_k is not None and metadata.global_seq_lens_k is not None:
# Hoisted path: int32 rank-local + global lens maintained once per
# step by metadata init / graph replay-prep.
local_seq_lens = metadata.seq_lens_k[: forward_batch.batch_size]
global_seq_lens = metadata.global_seq_lens_k[: forward_batch.batch_size]
else:
global_seq_lens = forward_batch.seq_lens[: forward_batch.batch_size]
local_seq_lens = self._get_dcp_local_seq_lens(global_seq_lens)
raw_out, lse = self._run_decode_kernel(
query=query,
kv_cache=kv_cache,
@@ -30,6 +30,9 @@ from sglang.srt.layers.dcp import (
)
from sglang.srt.layers.dcp.planner import plan_dcp_decode_metadata
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
from sglang.srt.model_executor.runner_backend_utils.breakable_cuda_graph import (
is_in_breakable_cuda_graph,
)
from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph import (
is_in_tc_piecewise_cuda_graph,
)
@@ -205,6 +208,10 @@ class FlashInferMhaChunkKVRunner:
class FlashInferMLAAttnBackend(AttentionBackend):
"""Flashinfer attention kernels."""
# Verify metadata is ragged-layout aware via generate_attn_arg_prefill;
# graphs key their wrappers by token tier (_verify_graph_key).
supports_ragged_verify_graph: bool = True
def __init__(
self,
model_runner: ModelRunner,
@@ -352,7 +359,9 @@ class FlashInferMLAAttnBackend(AttentionBackend):
kv_len_arr=self.cuda_graph_kv_lens[:bs],
backend="auto",
)
self.prefill_cuda_graph_metadata[bs] = prefill_wrapper
self.prefill_cuda_graph_metadata[
self._verify_graph_key(bs, spec_info)
] = prefill_wrapper
self.forward_metadata = PrefillMetadata(prefill_wrapper, False)
else:
raise ValueError(f"Invalid mode: {forward_mode=}")
@@ -404,8 +413,11 @@ class FlashInferMLAAttnBackend(AttentionBackend):
use_ragged = (
not get_exec().kernel.flashinfer_mla_disable_ragged
and extend_no_prefix
# Piecewise cuda graph should use paged prefill to be compatible with prefix cache
# Captured prefill (tc_piecewise or breakable) must use paged
# prefill: it stays compatible with prefix cache, and the ragged
# wrapper rejects the absorbed-MLA head dims (qk=576, vo=512).
and not is_in_tc_piecewise_cuda_graph()
and not is_in_breakable_cuda_graph()
)
self.indices_updater_prefill.update(
@@ -452,6 +464,15 @@ class FlashInferMLAAttnBackend(AttentionBackend):
"kv_indices": self.cuda_graph_kv_indices,
}
@staticmethod
def _verify_graph_key(bs: int, spec_info: Optional[SpecInput]):
"""bs for uniform graphs; token tier for ragged (tiers share slot
counts but each graph must replay its own recorded plan buffers)."""
layout = spec_info.ragged_verify_layout if spec_info is not None else None
if layout is None:
return bs
return ("ragged", layout.graph_num_tokens)
def _apply_cuda_graph_metadata(
self,
bs: int,
@@ -496,7 +517,9 @@ class FlashInferMLAAttnBackend(AttentionBackend):
seq_lens[:bs],
seq_lens_sum,
prefix_lens=None,
prefill_wrapper_paged=self.prefill_cuda_graph_metadata[bs],
prefill_wrapper_paged=self.prefill_cuda_graph_metadata[
self._verify_graph_key(bs, spec_info)
],
use_ragged=False,
spec_info=spec_info,
)
@@ -36,14 +36,23 @@ class HybridAttnBackend(AttentionBackend):
self.spec_attn_is_prefill = (
model_runner.server_args.speculative_attention_mode == "prefill"
)
# decide_needs_cpu_seq_lens ORs this flag across backends; without the
# delegation the base-class default (True) forces a per-step seq_lens
# D2H + host sync even when both sub-backends opted out.
self.needs_cpu_seq_lens = (
prefill_backend.needs_cpu_seq_lens or decode_backend.needs_cpu_seq_lens
# Gates the FutureMap's per-step seq_lens D2H (decide_needs_cpu_seq_lens
# ORs it across backends). Count only what runs in the spec decode loop:
# decode always, prefill only when mode=prefill routes verify to it --
# else a cpu-lens prefill backend forces the D2H on steps it never serves.
self.needs_cpu_seq_lens = decode_backend.needs_cpu_seq_lens or (
self.spec_attn_is_prefill and prefill_backend.needs_cpu_seq_lens
)
self.max_context_len = model_runner.model_config.context_len
@property
def supports_ragged_verify_graph(self) -> bool:
# Ragged verify is TARGET_VERIFY-only; delegate to its executor.
backend = (
self.decode_backend if self.spec_attn_is_decode else self.prefill_backend
)
return backend.supports_ragged_verify_graph
def _select_backend(self, forward_mode: ForwardMode) -> AttentionBackend:
"""
Select the appropriate attention backend based on the forward mode.
@@ -113,6 +122,19 @@ class HybridAttnBackend(AttentionBackend):
def get_cuda_graph_seq_len_fill_value(self):
return self.decode_backend.get_cuda_graph_seq_len_fill_value()
def init_mha_chunk_metadata(
self, forward_batch: ForwardBatch, disable_flashinfer_ragged: bool = False
):
# Chunked-prefix / one-shot MHA metadata is a prefill concern. Without
# this delegation the MLA MHA path silently skips (re)planning its
# ragged wrappers when the full-attn backend is this prefill/decode
# split (e.g. --decode-attention-backend trtllm_mla), and any
# prefix-cache-hit extend batch then runs against a stale plan:
# ValueError: q.shape[0] (...) does not match qo_indptr[-1] (...)
init = getattr(self.prefill_backend, "init_mha_chunk_metadata", None)
if init is not None:
init(forward_batch, disable_flashinfer_ragged)
@property
def verify_mask(self) -> Optional[VerifyMask]:
return self._select_backend(ForwardMode.TARGET_VERIFY).verify_mask
@@ -11,6 +11,7 @@ from sglang.kernels.ops.mamba.mamba_state_indices_triton import (
)
from sglang.kernels.ops.mamba.mamba_state_scatter_triton import (
scatter_mamba_states_after_mtp_verify,
track_mamba_states_all_layers,
track_mamba_states_if_needed,
)
from sglang.srt.configs.hybrid_arch import mamba2_config
@@ -118,7 +119,7 @@ class MambaAttnBackendBase(AttentionBackend):
# The ring cursor is a per-slot decode counter shared by all GDN layers;
# manage it once here (snapshot, hand to layers, advance mod L), not per-layer.
# Gate on the linear_replayssm FLAG, not on cursor-tensor presence: the
# spec-verify ring (--enable-gdn-replayssm-spec) shares the write_pos
# spec-verify ring (--enable-linear-replayssm-spec) shares the write_pos
# allocation but owns it exclusively via commit_gdn_replayssm_spec
# (advance-by-accept-count once per verify step). Advancing it here as
# well inserts one phantom/stale ring entry per step and cumulatively
@@ -178,13 +179,18 @@ class MambaAttnBackendBase(AttentionBackend):
# skip mamba metadata.
query_start_loc = None
elif forward_batch.forward_mode.is_target_verify():
query_start_loc = torch.arange(
0,
forward_batch.input_ids.shape[0] + 1,
step=forward_batch.spec_info.draft_token_num,
dtype=torch.int32,
device=forward_batch.input_ids.device,
)
ragged_layout = forward_batch.spec_info.ragged_verify_layout
if ragged_layout is not None:
# Compact ragged verify: variable per-request verify lens.
query_start_loc = ragged_layout.qo_indptr_device
else:
query_start_loc = torch.arange(
0,
forward_batch.input_ids.shape[0] + 1,
step=forward_batch.spec_info.draft_token_num,
dtype=torch.int32,
device=forward_batch.input_ids.device,
)
if self.topk > 1:
retrieve_next_token = forward_batch.spec_info.retrieve_next_token
@@ -371,7 +377,7 @@ class MambaAttnBackendBase(AttentionBackend):
"""True iff --enable-linear-replayssm is on for this pool.
Gate on the FLAG, not on ``replayssm_write_pos is not None``: the
spec-verify ring (--enable-gdn-replayssm-spec) also allocates the
spec-verify ring (--enable-linear-replayssm-spec) also allocates the
cursor tensor but owns it exclusively via commit_gdn_replayssm_spec.
The decode-ring metadata machinery gated here (per-bs static cursor
buffers, the per-replay snapshot + advance-by-one in _replay_metadata,
@@ -485,9 +491,16 @@ class MambaAttnBackendBase(AttentionBackend):
self.cached_cuda_graph_decode_query_start_loc[: bs + 1]
)
elif forward_mode.is_target_verify():
self.query_start_loc_list[bs - 1].copy_(
self.cached_cuda_graph_verify_query_start_loc[: bs + 1]
ragged_layout = (
spec_info.ragged_verify_layout if spec_info is not None else None
)
if ragged_layout is not None:
# Ragged capture: qsl from the runner's synthetic layout.
self.query_start_loc_list[bs - 1].copy_(ragged_layout.qo_indptr_device)
else:
self.query_start_loc_list[bs - 1].copy_(
self.cached_cuda_graph_verify_query_start_loc[: bs + 1]
)
else:
raise ValueError(f"Invalid forward mode: {forward_mode=}")
mamba_indices = self.req_to_token_pool.get_mamba_indices(req_pool_indices)
@@ -654,7 +667,19 @@ class MambaAttnBackendBase(AttentionBackend):
bs - num_padding
)
elif forward_mode.is_target_verify():
if num_padding == 0:
ragged_layout = (
spec_info.ragged_verify_layout if spec_info is not None else None
)
if ragged_layout is not None:
# Mamba kernels index dense [bs, N] scratch, so they need the
# capped variant (see padded_to_bucket). Padding rows carry
# mamba slot -1 and are skipped.
if ragged_layout.bs != bs or ragged_layout.cap is None:
ragged_layout = ragged_layout.padded_to_bucket(
padded_bs=bs, cap=spec_info.draft_token_num
)
self.query_start_loc_list[bs - 1].copy_(ragged_layout.qo_indptr_device)
elif num_padding == 0:
self.query_start_loc_list[bs - 1].copy_(
self.cached_cuda_graph_verify_query_start_loc[: bs + 1]
)
@@ -705,26 +730,73 @@ class MambaAttnBackendBase(AttentionBackend):
def get_cpu_graph_seq_len_fill_value(self):
return 1
def _track_pools(self):
"""Full [num_layers, pool_size, ...] conv/ssm pools plus the pool index
of the last mamba layer, for the fused all-layers track launch. None if
the pool shape is not the expected layout."""
cached = getattr(self, "_track_pools_cache", False)
if cached is False:
pools = None
try:
mamba_cache = self.req_to_token_pool.mamba_pool.mamba_cache
conv_pool = mamba_cache.conv[0]
ssm_pool = mamba_cache.temporal
last_pool_idx = max(self.req_to_token_pool.mamba_map.values())
if (
conv_pool.dim() >= 3
and ssm_pool.dim() >= 3
and conv_pool.shape[0] == ssm_pool.shape[0] == last_pool_idx + 1
):
pools = (conv_pool, ssm_pool, last_pool_idx)
except (AttributeError, IndexError):
pools = None
self._track_pools_cache = pools
cached = pools
return cached
def _track_mamba_state_decode(
self,
forward_batch: ForwardBatch,
conv_states: torch.Tensor,
ssm_states: torch.Tensor,
cache_indices: torch.Tensor,
layer_id: Optional[int] = None,
):
"""Copy decode conv/SSM states to track slots for prefix caching. Track
dests come from the metadata (under cuda-graph: the static buffer), so the
InputBuffer registry slot is never mutated."""
if forward_batch.mamba_track_mask is not None:
track_mamba_states_if_needed(
conv_states,
ssm_states,
cache_indices,
forward_batch.mamba_track_mask,
self.forward_metadata.mamba_track_indices,
forward_batch.batch_size,
check_freed_slots=self.enable_unified_memory,
)
InputBuffer registry slot is never mutated.
With a known layer_id, the per-layer launches collapse into ONE
all-layers launch fired at the last mamba layer: the mask/src/dst
indices are shared across layers and every layer's state is final by
then, so the result is identical."""
if forward_batch.mamba_track_mask is None:
return
if layer_id is not None:
pools = self._track_pools()
if pools is not None:
conv_pool, ssm_pool, last_pool_idx = pools
if self.req_to_token_pool.mamba_map.get(layer_id) != last_pool_idx:
return
track_mamba_states_all_layers(
conv_pool,
ssm_pool,
cache_indices,
forward_batch.mamba_track_mask,
self.forward_metadata.mamba_track_indices,
forward_batch.batch_size,
check_freed_slots=self.enable_unified_memory,
)
return
track_mamba_states_if_needed(
conv_states,
ssm_states,
cache_indices,
forward_batch.mamba_track_mask,
self.forward_metadata.mamba_track_indices,
forward_batch.batch_size,
check_freed_slots=self.enable_unified_memory,
)
def _track_mamba_state_extend(
self,
@@ -893,8 +965,18 @@ class HybridLinearAttnBackend(AttentionBackend):
@property
def data_type(self):
# KV-cache dtype readers (e.g. the trtllm_mla fused-rope check) reach the
# wrapper since split backends are wrapped once (#31439); the full-attn
# side owns the KV cache, so its dtype is authoritative.
return self.full_attn_backend.data_type
@property
def supports_ragged_verify_graph(self) -> bool:
return (
self.full_attn_backend.supports_ragged_verify_graph
and self.linear_attn_backend.supports_ragged_verify_graph
)
def _is_full_attn(
self, layer: Optional[RadixAttention], layer_id: Optional[int] = None
) -> bool:
@@ -1121,9 +1203,32 @@ class HybridLinearAttnBackend(AttentionBackend):
]
)
mamba_caches = (
self.linear_attn_backend.req_to_token_pool.get_speculative_mamba2_params_all_layers()
)
req_pool = self.linear_attn_backend.req_to_token_pool
mamba_caches = req_pool.get_speculative_mamba2_params_all_layers()
# ReplaySSM-KDA: the accepted drafts live in the per-slot ring (written
# during verify); no intermediate_ssm is allocated. Replay the accepted
# prefix into `temporal` instead of scattering an intermediate state.
# dspark/dflash call this method directly; the generic spec_utils commit
# handles replayssm before reaching here (returns early), so this branch is
# only hit by the direct callers. Chain layout only (topk <= 1), so
# accept_lens == last_correct_step_indices + 1.
mamba_pool = req_pool.mamba_pool
if getattr(mamba_pool, "replayssm_is_kda", False):
from sglang.kernels.ops.attention.fla.kda_replayssm_spec_decode import (
commit_kda_replayssm_after_verify,
)
commit_kda_replayssm_after_verify(
spec_state=mamba_caches,
state_batch_indices=state_indices_tensor,
accept_lens=last_correct_step_indices + 1,
last_correct_step_indices=last_correct_step_indices,
mamba_track_indices=mamba_track_indices,
mamba_steps_to_track=mamba_steps_to_track,
null_block_id=-1,
)
return
scatter_mamba_states_after_mtp_verify(
mamba_caches,
@@ -12,6 +12,7 @@ from sglang.srt.layers.attention.hybrid_linear_attn_backend import MambaAttnBack
from sglang.srt.layers.attention.linear.kernels.gdn_triton import TritonGDNKernel
from sglang.srt.layers.attention.linear.utils import (
LinearAttnKernelBackend,
build_verify_intermediate_state_indices,
get_linear_attn_decode_backend,
get_linear_attn_prefill_backend,
)
@@ -346,8 +347,13 @@ class GDNAttnBackend(MambaAttnBackendBase):
decode_backend = get_linear_attn_decode_backend()
prefill_backend = get_linear_attn_prefill_backend()
self.kernel_dispatcher = GDNKernelDispatcher(decode_backend, prefill_backend)
self.verify_intermediate_state_indices = torch.arange(
self.req_to_token_pool.size, dtype=torch.int32, device=model_runner.device
# Sized past the pool for attn_tp-padded warmup/MLP-sync batches (see helper).
self.verify_intermediate_state_indices = (
build_verify_intermediate_state_indices(
self.req_to_token_pool.size,
model_runner.server_args,
model_runner.device,
)
)
def init_forward_metadata(self, forward_batch: ForwardBatch):
@@ -427,7 +433,7 @@ class GDNAttnBackend(MambaAttnBackendBase):
replayssm_force_flush=replayssm_force_flush,
)
self._track_mamba_state_decode(
forward_batch, conv_states, ssm_states, cache_indices
forward_batch, conv_states, ssm_states, cache_indices, layer.layer_id
)
return core_attn_out
@@ -456,7 +462,7 @@ class GDNAttnBackend(MambaAttnBackendBase):
)
self._track_mamba_state_decode(
forward_batch, conv_states, ssm_states, cache_indices
forward_batch, conv_states, ssm_states, cache_indices, layer.layer_id
)
return core_attn_out
@@ -633,13 +639,13 @@ class GDNAttnBackend(MambaAttnBackendBase):
)
else:
# The recurrent fallback needs the per-draft snapshots, which
# the pool gates OFF under --enable-gdn-replayssm-spec (the
# the pool gates OFF under --enable-linear-replayssm-spec (the
# same flag that makes `use_replayssm_spec` true above), so
# this branch is unreachable with a None buffer by
# construction -- keep it loud rather than silently frozen.
assert intermediate_state_cache is not None, (
"recurrent target_verify fallback requires intermediate_ssm, "
"which is not allocated under --enable-gdn-replayssm-spec"
"which is not allocated under --enable-linear-replayssm-spec"
)
core_attn_out = self.kernel_dispatcher.target_verify(
A_log=layer.A_log,
@@ -1,7 +1,9 @@
import importlib.util
from typing import Optional, Tuple, Union
import torch
from sglang.kernels.ops.attention import kda_fused_decode
from sglang.kernels.ops.mamba.causal_conv1d_triton import (
causal_conv1d_fn,
causal_conv1d_update,
@@ -10,8 +12,10 @@ from sglang.srt.layers.attention.hybrid_linear_attn_backend import MambaAttnBack
from sglang.srt.layers.attention.linear.kernels.kda_triton import TritonKDAKernel
from sglang.srt.layers.attention.linear.utils import (
LinearAttnKernelBackend,
build_verify_intermediate_state_indices,
get_linear_attn_decode_backend,
get_linear_attn_prefill_backend,
get_linear_attn_verify_backend,
)
from sglang.srt.layers.radix_linear_attention import RadixLinearAttention
from sglang.srt.utils import is_cpu, is_cuda, is_npu
@@ -39,7 +43,9 @@ class KDAKernelDispatcher:
self,
decode_backend: LinearAttnKernelBackend,
prefill_backend: LinearAttnKernelBackend,
verify_backend: LinearAttnKernelBackend,
):
self.verify_backend = verify_backend
triton_kernel = TritonKDAKernel()
if decode_backend.is_triton():
@@ -68,15 +74,39 @@ class KDAKernelDispatcher:
"KDA supports 'triton', 'cutedsl', or 'flashinfer'."
)
# target_verify (MTP / speculative decode) kernel: each decode backend
# verifies with its own kernel. FlashInfer decode uses recurrent_kda (SM100,
# chain only); Triton -- and CuTe DSL, which has no verify of its own -- use
# the Triton fused KDA verify, which handles chain + tree
# (retrieve_parent_token) and per-step checkpointing and is the reference the
# KDA backend correctness tests assert against.
self.verify_kernel = (
self.decode_kernel if decode_backend.is_flashinfer() else triton_kernel
)
# target_verify kernel, selected via --linear-attn-verify-backend (defaults
# to follow decode: flashinfer -> recurrent_kda, else triton).
# triton: fused chain + tree (retrieve_parent_token) verify; the reference
# the KDA correctness tests assert against.
# flashinfer: recurrent_kda (SM100, chain only); reuses the decode kernel
# when decode is also flashinfer.
# nv_cutedsl: fused Kimi-K3/DSpARK dense verify.
if verify_backend.is_triton():
self.verify_kernel = triton_kernel
elif verify_backend.is_flashinfer():
if decode_backend.is_flashinfer():
self.verify_kernel = self.decode_kernel
else:
if not is_cuda():
raise ValueError("KDA FlashInfer verify backend requires CUDA")
from sglang.srt.layers.attention.linear.kernels.kda_flashinfer import (
FlashInferKDAKernel,
)
self.verify_kernel = FlashInferKDAKernel()
elif verify_backend.is_nv_cutedsl():
self.verify_kernel = triton_kernel
elif verify_backend.is_custom():
# Future custom KDA verify kernel plugs in here.
raise NotImplementedError(
"--linear-attn-verify-backend custom: no custom KDA verify kernel "
"is registered yet."
)
else:
raise ValueError(
f"Unsupported KDA verify backend: {verify_backend}. "
"KDA verify supports 'triton', 'nv_cutedsl', or 'flashinfer'."
)
if prefill_backend.is_triton():
self.extend_kernel = triton_kernel
@@ -104,11 +134,40 @@ class KDAKernelDispatcher:
rank0_log(
"KDA cutedsl prefill needs SM100; falling back to Triton extend."
)
elif prefill_backend.is_ptx_kda():
if not is_cuda():
raise ValueError("PTX KDA prefill backend requires CUDA")
from sglang.srt.layers.attention.linear.kernels.kda_ptx import PtxKDAKernel
ptx_kda_kernel = PtxKDAKernel()
if ptx_kda_kernel.supports_prefill:
self.extend_kernel = ptx_kda_kernel
else:
self.extend_kernel = triton_kernel
rank0_log(
"PTX KDA prefill needs SM103 (GB300); falling back to Triton "
"extend."
)
elif prefill_backend.is_nvidia_kda():
if not is_cuda():
raise ValueError("NVIDIA KDA prefill backend requires CUDA")
from sglang.srt.layers.attention.linear.kernels.kda_nvidia import (
NvidiaKDAKernel,
)
nvidia_kda_kernel = NvidiaKDAKernel()
if nvidia_kda_kernel.supports_prefill:
self.extend_kernel = nvidia_kda_kernel
else:
self.extend_kernel = triton_kernel
rank0_log(
"NVIDIA KDA prefill needs SM100; falling back to Triton extend."
)
else:
raise ValueError(
f"Unsupported KDA prefill backend: {prefill_backend}. "
"KDA supports 'triton', 'flashkda', or 'cutedsl' "
"(cutedsl prefill needs SM100)."
"KDA supports 'triton', 'flashkda', 'cutedsl', 'nvidia_kda', or "
"'ptx_kda' (cutedsl/nvidia_kda prefill need SM100, ptx_kda SM103)."
)
self.supports_packed_decode = getattr(
@@ -201,6 +260,7 @@ class KDAKernelDispatcher:
intermediate_state_indices: torch.Tensor,
cache_steps: int,
retrieve_parent_token: torch.Tensor,
lower_bound: Optional[float] = None,
**kwargs,
) -> torch.Tensor:
"""MTP / speculative-decode verify, routed to ``self.verify_kernel``
@@ -221,6 +281,9 @@ class KDAKernelDispatcher:
intermediate_state_indices=intermediate_state_indices,
cache_steps=cache_steps,
retrieve_parent_token=retrieve_parent_token,
lower_bound=lower_bound,
# Forward extras (e.g. the fused ring-write cache_ring/replayssm_*).
**kwargs,
)
def extend(
@@ -249,19 +312,48 @@ class KDAKernelDispatcher:
)
def ragged_verify_dense_scatter_indices(
*,
query_start_loc: torch.Tensor,
seq_len: int,
draft_token_num: int,
) -> torch.Tensor:
"""Dense [bs, draft_token_num] slot index per packed ragged-verify token.
Rows never exceed draft_token_num under either layout variant (cap for
graph replay, planner construction for eager -- see
RaggedVerifyLayout.padded_to_bucket), so in-row offsets stay in-row;
tokens past the layout's coverage collapse into one ghost row at index
bs * draft_token_num.
"""
batch_size = query_start_loc.shape[0] - 1
token_pos = torch.arange(seq_len, device=query_start_loc.device, dtype=torch.int32)
token_slots = torch.searchsorted(query_start_loc[1:], token_pos, right=True)
return (
token_slots * draft_token_num
+ (token_pos - query_start_loc[token_slots]).to(torch.int64)
).clamp_(max=batch_size * draft_token_num)
class KDAAttnBackend(MambaAttnBackendBase):
"""Attention backend for KDA (Kimi Delta Attention) linear attention."""
# Same GPU-only contract as GDNAttnBackend / Mamba2AttnBackend: KDA metadata
# never reads the spec-v2 seq_lens_cpu mirror (replay padding comes from
# forward_batch.num_padding, and the replayssm track-flush mask paths are
# gated `not is_kda`), so don't force FutureMap's blocking per-step
# seq_lens D2H (~0.5 ms/step host stall in bs=1 MTP decode).
# The verify kernel is varlen and the conv path scatters ragged tokens
# to its dense layout, so ragged verify graphs are supported.
supports_ragged_verify_graph: bool = True
# Read by decide_needs_cpu_seq_lens. Decode/verify metadata is GPU-only
# (graph replay already passes seq_lens_cpu=None), extend reads
# extend_seq_lens_cpu from schedule, mamba track indices rebuild from req
# objects, and the replayssm seq_lens_cpu force-flush is GDN-only.
needs_cpu_seq_lens: bool = False
def __init__(self, model_runner: ModelRunner):
super().__init__(model_runner)
# mamba_cache.conv is [..., kernel-1, dim] while conv_states_shape expects the window length (kernel-1) at shape[-1], hence the transpose.
# Needed by the extra_buffer track path: _init_track_conv_indices reads
# conv_states_shape[-1] as the conv window length (kernel_size - 1).
# The KDA pool stores conv states as [kernel-1, dim] — transposed vs
# Mamba2/GDN's [dim, kernel-1] — so expose the transposed shape here.
self.conv_states_shape = (
model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0]
.transpose(-1, -2)
@@ -269,22 +361,35 @@ class KDAAttnBackend(MambaAttnBackendBase):
)
decode_backend = get_linear_attn_decode_backend()
prefill_backend = get_linear_attn_prefill_backend()
# KDA FlashInfer speculative decode (target_verify) is linear-chain only --
# recurrent_kda has no tree-ancestor traversal. Reject EAGLE tree verify
# (topk > 1) early at setup instead of deep in the per-step verify call.
# (The kernel keeps a per-call retrieve_parent_token guard as a backstop; it
# also covers ngram tree, which this topk field does not.)
verify_backend = get_linear_attn_verify_backend()
# KDA FlashInfer target_verify (recurrent_kda) is chain-only (no tree-ancestor
# traversal). Reject EAGLE tree verify (topk > 1) early at setup, keyed on the
# verify backend (not decode). The kernel keeps a per-call
# retrieve_parent_token backstop that also covers ngram tree.
speculative_topk = model_runner.server_args.speculative_eagle_topk or 1
if decode_backend.is_flashinfer() and speculative_topk > 1:
if verify_backend.is_flashinfer() and speculative_topk > 1:
raise ValueError(
"KDA FlashInfer speculative decoding only supports topk=1 "
"(EAGLE tree verify / retrieve_parent_token is unsupported)."
)
self.kernel_dispatcher = KDAKernelDispatcher(decode_backend, prefill_backend)
self.kernel_dispatcher = KDAKernelDispatcher(
decode_backend, prefill_backend, verify_backend
)
# One-shot; emitted at the first fused-decode interception below.
self._fused_override_notice = (
"K3 fused KDA decode engaged: --linear-attn-decode-backend "
f"{decode_backend} only picks the fallback kernel for shapes "
"the fused kernel does not cover."
)
# Per-request row index into the speculative `intermediate_ssm` scratch,
# used by the MTP / target_verify path (mirrors GDNAttnBackend).
self.verify_intermediate_state_indices = torch.arange(
self.req_to_token_pool.size, dtype=torch.int32, device=model_runner.device
# used by the MTP / target_verify path (mirrors GDNAttnBackend). Sized
# past the pool for attn_tp-padded warmup/MLP-sync batches (see helper).
self.verify_intermediate_state_indices = (
build_verify_intermediate_state_indices(
self.req_to_token_pool.size,
model_runner.server_args,
model_runner.device,
)
)
def init_forward_metadata(self, forward_batch: ForwardBatch):
@@ -326,6 +431,77 @@ class KDAAttnBackend(MambaAttnBackendBase):
replayssm_k = layer_cache.replayssm_k
replayssm_g = layer_cache.replayssm_g
# Fully fused decode step: conv1d update + delta-rule recurrence +
# gated RMSNorm in one kernel. Engages only when the model handed off
# the output-norm gate for this forward (attempt-and-verify stash,
# see kimi_k3.py) and the shapes are covered; the model applies the
# norm itself whenever the stash is left unconsumed.
if replayssm_d is None:
fused_static = getattr(layer, "_k3_fused_decode_args", None)
onorm_gate = getattr(layer, "_k3_onorm_gate", None)
if (
fused_static is not None
and onorm_gate is not None
and mixed_qkv.shape[0] == cache_indices.shape[0]
and b.ndim == 3
and kda_fused_decode.covered(
mixed_qkv,
a,
b[0],
conv_states,
ssm_states,
cache_indices,
onorm_gate,
)
):
if self._fused_override_notice is not None:
rank0_log(self._fused_override_notice)
self._fused_override_notice = None
w_q_t, w_k_t, w_v_t, conv_bias, a_log, onorm_w, onorm_eps = fused_static
core_attn_out = kda_fused_decode.kda_fused_decode(
mixed_qkv,
a,
b[0],
conv_states,
w_q_t,
w_k_t,
w_v_t,
conv_bias,
a_log,
layer.dt_bias,
onorm_gate,
onorm_w,
ssm_states,
cache_indices,
scale=layer.head_k_dim**-0.5,
onorm_eps=onorm_eps,
lower_bound=layer.lower_bound,
)
layer._k3_onorm_consumed = True
self._track_mamba_state_decode(
forward_batch,
conv_states,
ssm_states,
cache_indices,
layer.layer_id,
)
return core_attn_out
elif fused_static is not None and onorm_gate is not None:
# One-shot diagnostics: the model offered the handoff but the
# runtime shapes were rejected (decode stays on the unfused
# chain, which is correct but slower).
if not getattr(KDAAttnBackend, "_fused_reject_logged", False):
KDAAttnBackend._fused_reject_logged = True
rank0_log(
"KDA fused decode rejected by covered(): "
f"mixed_qkv {tuple(mixed_qkv.shape)}/{mixed_qkv.dtype} "
f"stride {mixed_qkv.stride()}, "
f"conv_states {tuple(conv_states.shape)} "
f"stride {conv_states.stride()}, "
f"ssm_states {tuple(ssm_states.shape)}/{ssm_states.dtype}, "
f"b {tuple(b.shape)}, indices {cache_indices.dtype}"
)
qkv = causal_conv1d_update(
mixed_qkv,
conv_states.transpose(-1, -2),
@@ -353,6 +529,7 @@ class KDAAttnBackend(MambaAttnBackendBase):
cache_indices=cache_indices,
num_v_heads=layer.num_v_heads,
head_v_dim=layer.head_v_dim,
lower_bound=layer.lower_bound,
replayssm_d=replayssm_d,
replayssm_k=replayssm_k,
replayssm_g=replayssm_g,
@@ -360,7 +537,7 @@ class KDAAttnBackend(MambaAttnBackendBase):
replayssm_force_flush=replayssm_force_flush,
)
self._track_mamba_state_decode(
forward_batch, conv_states, ssm_states, cache_indices
forward_batch, conv_states, ssm_states, cache_indices, layer.layer_id
)
return core_attn_out
@@ -380,10 +557,11 @@ class KDAAttnBackend(MambaAttnBackendBase):
ssm_states=ssm_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
lower_bound=layer.lower_bound,
)
self._track_mamba_state_decode(
forward_batch, conv_states, ssm_states, cache_indices
forward_batch, conv_states, ssm_states, cache_indices, layer.layer_id
)
return core_attn_out
@@ -418,6 +596,11 @@ class KDAAttnBackend(MambaAttnBackendBase):
has_initial_state = forward_batch.extend_prefix_lens > 0
if self.forward_metadata.has_mamba_track_mask:
# Snapshot the conv sliding window at the last track-aligned chunk
# boundary into the ping-pong track slots (the prefix-cache restore
# source). The KDA pool stores conv states as [kernel-1, dim], so
# rows of the raw [tokens, dim] pre-conv input index in directly
# (GDN needs a transpose here; KDA does not).
mamba_cache_params.conv[0][
self.forward_metadata.conv_states_mask_indices
] = mixed_qkv[self.forward_metadata.track_conv_indices]
@@ -483,14 +666,23 @@ class KDAAttnBackend(MambaAttnBackendBase):
query_start_loc=query_start_loc,
A_log=layer.A_log,
dt_bias=layer.dt_bias,
lower_bound=getattr(layer, "lower_bound", None),
lower_bound=layer.lower_bound,
extend_seq_lens_cpu=forward_batch.extend_seq_lens_cpu,
# draft_extend_v2 must stay rollback-able, so kernels that commit state
# in place (e.g. FlashKDA) must not run for it.
is_spec_decode=forward_batch.forward_mode.is_draft_extend_v2(),
return_intermediate_states=track_ssm,
# Which global chunk rows of h the track snapshot will read; lets
# kernels that cannot materialize per-chunk states (NVIDIA KDA) take the
# fast path when the snapshot only needs the final state.
track_ssm_h_src=(
self.forward_metadata.track_ssm_h_src if track_ssm else None
),
)
if track_ssm:
# Snapshot the SSM state at the last track-aligned chunk boundary
# from the kernel's per-chunk states (h) / final states into the
# ping-pong track slots (see _init_track_ssm_indices).
core_attn_out, h = core_attn_out
self._track_mamba_state_extend(
forward_batch, h, ssm_states, self.forward_metadata
@@ -525,7 +717,12 @@ class KDAAttnBackend(MambaAttnBackendBase):
conv_states = mamba_cache_params.conv[0]
ssm_states = mamba_cache_params.temporal
intermediate_state_cache = getattr(mamba_cache_params, "intermediate_ssm", None)
if intermediate_state_cache is None:
# ReplaySSM: intermediate_ssm is intentionally None (the ring + commit-time
# fold replace the per-step snapshots). Pass None to the verify kernel so it
# skips the write (CACHE_INTERMEDIATE_STATES=False); the output is unaffected.
replayssm_rawv = getattr(mamba_cache_params, "replayssm_rawv", None)
replayssm_on = replayssm_rawv is not None
if intermediate_state_cache is None and not replayssm_on:
raise RuntimeError(
"KDA target_verify requires a speculative mamba cache "
"(MambaPool.SpeculativeState); none found."
@@ -534,13 +731,66 @@ class KDAAttnBackend(MambaAttnBackendBase):
intermediate_state_indices = self.verify_intermediate_state_indices
draft_token_num = forward_batch.spec_info.draft_token_num
batch_size = seq_len // draft_token_num
ragged_layout = forward_batch.spec_info.ragged_verify_layout
if self._can_run_dspark_cutedsl_mtp(
layer=layer,
mixed_qkv=mixed_qkv,
a=a,
b=b,
draft_token_num=draft_token_num,
ragged_layout=ragged_layout,
conv_states=conv_states,
ssm_states=ssm_states,
intermediate_state_cache=intermediate_state_cache,
intermediate_conv_window_cache=intermediate_conv_window_cache,
retrieve_parent_token=retrieve_parent_token,
replayssm_rawv=replayssm_rawv,
):
return self._run_dspark_cutedsl_mtp(
layer=layer,
mixed_qkv=mixed_qkv,
a=a,
b=b,
conv_states=conv_states,
ssm_states=ssm_states,
intermediate_state_cache=intermediate_state_cache,
intermediate_conv_window_cache=intermediate_conv_window_cache,
intermediate_state_indices=intermediate_state_indices,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
replayssm_rawv=replayssm_rawv,
replayssm_rawk=mamba_cache_params.replayssm_rawk,
replayssm_g=mamba_cache_params.replayssm_g,
replayssm_beta=mamba_cache_params.replayssm_beta,
)
if ragged_layout is None:
batch_size = seq_len // draft_token_num
dense_token_indices = None
mixed_qkv_dense = mixed_qkv.view(batch_size, draft_token_num, -1)
else:
# Conv update and its per-step scratch want the dense
# [bs, draft_token_num] layout: scatter ragged tokens to their
# step slots, gather back after (pad steps are never committed).
# Uncovered tier-leftover tokens land in the ghost row (see
# ragged_verify_dense_scatter_indices); their values are pad
# garbage, discarded downstream (ghost collisions are
# value-irrelevant).
batch_size = query_start_loc.shape[0] - 1
num_dense_tokens = batch_size * draft_token_num
dense_token_indices = ragged_verify_dense_scatter_indices(
query_start_loc=query_start_loc,
seq_len=seq_len,
draft_token_num=draft_token_num,
)
dense = mixed_qkv.new_zeros(num_dense_tokens + 1, mixed_qkv.shape[-1])
dense.index_copy_(0, dense_token_indices, mixed_qkv)
mixed_qkv_dense = dense[:num_dense_tokens].view(
batch_size, draft_token_num, -1
)
# causal_conv1d_update expects [.., dim, width]. KDA keeps dense conv-window
# scratch because the deduplicated overlapping layout cannot be transposed.
mixed_qkv_reshaped = mixed_qkv.view(batch_size, draft_token_num, -1).transpose(
1, 2
)
mixed_qkv_reshaped = mixed_qkv_dense.transpose(1, 2)
mixed_qkv_processed = causal_conv1d_update(
mixed_qkv_reshaped,
conv_states.transpose(-1, -2),
@@ -554,14 +804,41 @@ class KDAAttnBackend(MambaAttnBackendBase):
retrieve_next_sibling=retrieve_next_sibling,
retrieve_parent_token=retrieve_parent_token,
)
mixed_qkv = mixed_qkv_processed.transpose(1, 2).reshape(seq_len, -1)
mixed_qkv_flat = mixed_qkv_processed.transpose(1, 2).reshape(
batch_size * draft_token_num, -1
)
if dense_token_indices is None:
mixed_qkv = mixed_qkv_flat
else:
# Ghost row (zeros) so uncovered tail tokens gather finite values.
padded_flat = mixed_qkv_flat.new_zeros(
batch_size * draft_token_num + 1, mixed_qkv_flat.shape[-1]
)
padded_flat[: batch_size * draft_token_num] = mixed_qkv_flat
mixed_qkv = padded_flat[dense_token_indices]
q, k, v = mixed_qkv.split([layer.q_dim, layer.k_dim, layer.v_dim], dim=-1)
q = q.unflatten(-1, (-1, layer.head_q_dim)).unsqueeze(0) # n (h d) -> 1 n h d
k = k.unflatten(-1, (-1, layer.head_k_dim)).unsqueeze(0)
v = v.unflatten(-1, (-1, layer.head_v_dim)).unsqueeze(0)
return self.kernel_dispatcher.target_verify(
# ReplaySSM: the ring-write is fused into the triton verify kernel
# (CACHE_RING). Ragged layouts work natively -- step_idx is the
# within-row step under varlen, so row i writes
# ring[slot][0..verify_lens[i]) and commit folds at most commit_lens
# of them (absorb overflow is bounded in-kernel). ring_kwargs stays
# empty for non-triton verify kernels, which never see replayssm.
ring_kwargs = {}
if replayssm_rawv is not None:
ring_kwargs = dict(
cache_ring=True,
replayssm_rawv=replayssm_rawv,
replayssm_rawk=mamba_cache_params.replayssm_rawk,
replayssm_g=mamba_cache_params.replayssm_g,
replayssm_beta=mamba_cache_params.replayssm_beta,
)
core_attn_out = self.kernel_dispatcher.target_verify(
A_log=layer.A_log,
dt_bias=layer.dt_bias,
q=q,
@@ -576,4 +853,193 @@ class KDAAttnBackend(MambaAttnBackendBase):
intermediate_state_indices=intermediate_state_indices,
cache_steps=draft_token_num,
retrieve_parent_token=retrieve_parent_token,
lower_bound=layer.lower_bound,
**ring_kwargs,
)
if dense_token_indices is not None:
# Kernel output is empty-allocated and the capped qsl skips the
# uncovered tail rows; zero them so discarded pad hidden states
# stay finite. Uncovered == clamped-to-ghost.
covered = dense_token_indices < (batch_size * draft_token_num)
core_attn_out = torch.where(covered.view(1, -1, 1, 1), core_attn_out, 0.0)
return core_attn_out
def _can_run_dspark_cutedsl_mtp(
self,
*,
layer: RadixLinearAttention,
mixed_qkv: torch.Tensor,
a: torch.Tensor,
b: torch.Tensor,
draft_token_num: int,
ragged_layout,
conv_states: torch.Tensor,
ssm_states: torch.Tensor,
intermediate_state_cache: torch.Tensor,
intermediate_conv_window_cache: torch.Tensor,
retrieve_parent_token: Optional[torch.Tensor],
replayssm_rawv: Optional[torch.Tensor],
) -> bool:
"""Return whether the fixed Kimi-K3/DSpARK CuTe contract is satisfied."""
if not self.kernel_dispatcher.verify_backend.is_nv_cutedsl() or not is_cuda():
return False
if importlib.util.find_spec("cutlass") is None:
return False
if torch.cuda.get_device_capability()[0] != 10:
return False
if ragged_layout is not None or retrieve_parent_token is not None:
return False
# draft_token_num = 1 bonus + dspark block size; the CuTe kernel is
# specialized per block size and capped at 8 by shared-memory growth.
if not 2 <= draft_token_num <= 8:
return False
if layer.bias is not None or layer.lower_bound is None:
return False
if (
layer.head_q_dim != 128
or layer.head_k_dim != 128
or layer.head_v_dim != 128
):
return False
if (
layer.num_q_heads != layer.num_k_heads
or layer.num_k_heads != layer.num_v_heads
):
return False
if (
mixed_qkv.dtype != torch.bfloat16
or a.dtype != torch.bfloat16
or b.dtype != torch.bfloat16
):
return False
if layer.conv_weights is None or tuple(layer.conv_weights.shape) != (
layer.q_dim + layer.k_dim + layer.v_dim,
4,
):
return False
if layer.conv_weights.dtype != torch.float32:
return False
if (
ssm_states.dtype != torch.float32
or tuple(ssm_states.shape[-3:])
!= (layer.num_v_heads, layer.head_v_dim, layer.head_k_dim)
or tuple(ssm_states.stride()[-3:])
!= (
layer.head_v_dim * layer.head_k_dim,
layer.head_k_dim,
1,
)
or ssm_states.stride(0) % 4 != 0
or ssm_states.storage_offset() % 4 != 0
):
return False
if conv_states.shape[-2] != 3 or intermediate_conv_window_cache.shape[-2] != 3:
return False
if intermediate_state_cache is None:
# ReplaySSM: the ring replaces the per-step snapshots. The kernel
# wrapper validates ring layout/dtypes and raises loudly (there is
# no recurrent fallback without intermediate_ssm).
return replayssm_rawv is not None
if intermediate_state_cache.shape[1] < draft_token_num:
return False
if tuple(intermediate_state_cache.shape[-3:]) != (
layer.num_v_heads,
layer.head_v_dim,
layer.head_k_dim,
):
return False
return True
@staticmethod
def _run_dspark_cutedsl_mtp(
*,
layer: RadixLinearAttention,
mixed_qkv: torch.Tensor,
a: torch.Tensor,
b: torch.Tensor,
conv_states: torch.Tensor,
ssm_states: torch.Tensor,
intermediate_state_cache: torch.Tensor,
intermediate_conv_window_cache: torch.Tensor,
intermediate_state_indices: torch.Tensor,
cache_indices: torch.Tensor,
query_start_loc: torch.Tensor,
replayssm_rawv: Optional[torch.Tensor] = None,
replayssm_rawk: Optional[torch.Tensor] = None,
replayssm_g: Optional[torch.Tensor] = None,
replayssm_beta: Optional[torch.Tensor] = None,
) -> torch.Tensor:
from sglang.kernels.ops.kimi_k3.kda_decode_mtp import (
fused_kda_decode_mtp_dspark,
)
seq_len = mixed_qkv.shape[0]
h = layer.num_v_heads
x_q, x_k, x_v = mixed_qkv.split([layer.q_dim, layer.k_dim, layer.v_dim], dim=-1)
x_q = x_q.reshape(1, seq_len, h, layer.head_q_dim)
x_k = x_k.reshape(1, seq_len, h, layer.head_k_dim)
x_v = x_v.reshape(1, seq_len, h, layer.head_v_dim)
w_q, w_k, w_v = layer.conv_weights.split(
[layer.q_dim, layer.k_dim, layer.v_dim], dim=0
)
cs_q, cs_k, cs_v = conv_states.split(
[layer.q_dim, layer.k_dim, layer.v_dim], dim=-1
)
cs_q = cs_q.transpose(-1, -2)
cs_k = cs_k.transpose(-1, -2)
cs_v = cs_v.transpose(-1, -2)
intermediate_conv_window_cache = intermediate_conv_window_cache.transpose(
-1, -2
)
ic_q, ic_k, ic_v = intermediate_conv_window_cache.split(
[layer.q_dim, layer.k_dim, layer.v_dim], dim=-2
)
# The kernel owns all 128 output channels and can fold gated RMSNorm
# into the recurrence.
onorm_gate = getattr(layer, "_k3_onorm_gate", None)
fused_static = getattr(layer, "_k3_fused_decode_args", None)
apply_onorm = onorm_gate is not None and fused_static is not None
if apply_onorm:
onorm_weight = fused_static[5]
onorm_eps = fused_static[6]
onorm_gate = onorm_gate.reshape(1, seq_len, h, layer.head_v_dim)
else:
onorm_weight = None
onorm_eps = None
onorm_gate = None
out = fused_kda_decode_mtp_dspark(
x_q=x_q,
x_k=x_k,
x_v=x_v,
w_q=w_q,
w_k=w_k,
w_v=w_v,
cs_q=cs_q,
cs_k=cs_k,
cs_v=cs_v,
g=a,
beta=b,
A_log=layer.A_log.reshape(-1),
dt_bias=layer.dt_bias.reshape(-1),
recurrent_state=ssm_states,
intermediate_ssm=intermediate_state_cache,
intermediate_state_indices=intermediate_state_indices,
intermediate_conv_q=ic_q,
intermediate_conv_k=ic_k,
intermediate_conv_v=ic_v,
ssm_state_indices=cache_indices.to(torch.int32),
cu_seqlens=query_start_loc.to(torch.int32),
lower_bound=float(layer.lower_bound),
scale=layer.head_q_dim**-0.5,
replayssm_rawv=replayssm_rawv,
replayssm_rawk=replayssm_rawk,
replayssm_g=replayssm_g,
replayssm_beta=replayssm_beta,
onorm_gate=onorm_gate,
onorm_weight=onorm_weight,
onorm_eps=onorm_eps,
)
if apply_onorm:
layer._k3_onorm_consumed = True
return out
@@ -75,6 +75,11 @@ class CuteDSLKDAKernel(LinearAttnKernelBase):
query_start_loc: torch.Tensor,
**kwargs,
) -> torch.Tensor:
if kwargs.get("lower_bound") is not None:
raise NotImplementedError(
"KDA safe gate (lower_bound) is not implemented in the CuTe DSL "
"decode kernel; use --linear-attn-decode-backend triton."
)
return cutedsl_fused_sigmoid_gating_kda_update(
A_log=A_log,
dt_bias=dt_bias,
@@ -108,6 +113,10 @@ class CuteDSLKDAKernel(LinearAttnKernelBase):
**kwargs,
) -> torch.Tensor:
if kwargs.get("return_intermediate_states"):
# The mamba radix extra_buffer track path needs per-chunk states
# (h), which chunk_kda_cutedsl does not expose. Refuse instead of
# silently skipping the snapshot (that corrupts prefix-cache
# restores). Use the Triton prefill backend with extra_buffer.
raise NotImplementedError(
"CuteDSLKDAKernel.extend cannot return intermediate chunk "
"states required by mamba_radix_cache_strategy=extra_buffer; "
@@ -78,8 +78,51 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
# Cache the constant per-(row-map, batch, T) verify scatter indices
# (ssm_state_indices), which never change across verify calls.
self._verify_idx_cache: dict = {}
# State pools whose stride layout has been validated against the
# recurrent_kda contract (per-layer views are pool-stable, so id() is
# a stable key — same lifetime argument as _gate_cache).
self._state_contract_ok: set = set()
logger.info("Using FlashInfer KDA kernel")
def _check_state_stride_contract(self, ssm_states: torch.Tensor) -> None:
"""One-time (per pool view) check that ``ssm_states`` matches the
layout ``recurrent_kda`` was compiled for.
The kernel's state argument is a CuTe fake tensor of shape
``[N, HV, V, K]`` with stride ``(sym_int64(divisibility=16), V*K, K, 1)``
and ``assumed_align=32`` (flashinfer ``kda_kernels/recurrent_kda.py``):
the slot stride is free which is what lets the envelope-strided pools
(unified memory / page-major layout, slot stride = per-slot envelope
pitch) be passed in and updated IN PLACE on the cu_seqlens path but
the inner strides are compiled-in constants and the divisibility /
alignment are hard assumptions. A pool violating them would mis-address
state in-kernel without any error; fail loudly here instead.
"""
key = id(ssm_states)
if key in self._state_contract_ok:
return
if ssm_states.dim() != 4:
raise ValueError(
f"recurrent_kda needs a [N, HV, V, K] state pool; got "
f"shape {tuple(ssm_states.shape)}"
)
_, hv, v, k = ssm_states.shape
if ssm_states.stride()[1:] != (v * k, k, 1):
raise ValueError(
"recurrent_kda state inner strides must be compact "
f"(V*K, K, 1)=({v * k}, {k}, 1); got {ssm_states.stride()[1:]} "
"(only the slot stride may be non-compact)"
)
base_bytes = ssm_states.storage_offset() * ssm_states.element_size()
if ssm_states.stride(0) % 16 != 0 or base_bytes % 32 != 0:
raise ValueError(
"recurrent_kda state pool breaks the compiled stride contract: "
f"slot stride {ssm_states.stride(0)} elements must be a multiple "
f"of 16 and the base byte offset {base_bytes} a multiple of 32 "
"(sym_int64(divisibility=16) / assumed_align=32)"
)
self._state_contract_ok.add(key)
# ---- gate / beta normalization (shared by decode + verify) ----
def _prep_gate_params(self, A_log: torch.Tensor, dt_bias: torch.Tensor):
@@ -118,6 +161,7 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
ssm_states: torch.Tensor,
cache_indices: torch.Tensor,
query_start_loc: torch.Tensor,
lower_bound: Optional[float] = None,
**kwargs,
) -> torch.Tensor:
batch_size = cache_indices.shape[0]
@@ -126,6 +170,11 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
num_v_heads = v.shape[2]
head_v_dim = v.shape[3]
# The committed pool goes into the kernel as-is (in-place update); under
# unified memory / page-major it is an envelope-strided view, which the
# cu_seqlens path supports — verify the compiled contract once per pool.
self._check_state_stride_contract(ssm_states)
# Pack each request as a length-1 sequence ([1, B, ...] + cu_seqlens) so
# recurrent_kda indexes the committed pool IN-KERNEL via ssm_state_indices.
# The plain [B, 1, ...] path (no cu_seqlens) instead python-gathers
@@ -144,9 +193,8 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
A_log_fi, dt_bias_fi = self._prep_gate_params(A_log, dt_bias)
# Softplus gate (lower_bound=None) to match the Triton KDA decode path;
# in-place state update into the committed pool (no rollback for decode).
# query_start_loc is the decode cu_seqlens (one token per request).
# Gate contract matches the Triton decode path (safe gate when
# lower_bound set); in-place state update, no rollback for decode.
output_fi, _ = self._recurrent_kda(
q=query_fi,
k=key_fi,
@@ -160,7 +208,7 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
output_final_state=False,
use_qk_l2norm_in_kernel=True,
use_gate_in_kernel=True,
lower_bound=None,
lower_bound=lower_bound,
cu_seqlens=query_start_loc.to(torch.int32),
ssm_state_indices=cache_indices.to(torch.int32),
)
@@ -186,6 +234,7 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
intermediate_state_indices: torch.Tensor,
cache_steps: int,
retrieve_parent_token: torch.Tensor,
lower_bound: Optional[float] = None,
**kwargs,
) -> torch.Tensor:
if retrieve_parent_token is not None:
@@ -272,7 +321,7 @@ class FlashInferKDAKernel(LinearAttnKernelBase):
output_final_state=False,
use_qk_l2norm_in_kernel=True,
use_gate_in_kernel=True,
lower_bound=None,
lower_bound=lower_bound,
cu_seqlens=query_start_loc.to(torch.int32),
ssm_state_indices=ssm_state_indices,
num_spec_tokens=num_spec_tokens,
@@ -117,6 +117,10 @@ class FlashKDAKernel(LinearAttnKernelBase):
return_intermediate_states: bool = False,
**kwargs,
) -> torch.Tensor:
# The fused kernel cannot expose per-chunk states (h), which the mamba
# radix extra_buffer track path needs; route tracked batches through
# the Triton chunk_kda fallback instead of silently skipping the
# snapshot (that would corrupt prefix-cache restores).
if return_intermediate_states or self._should_fall_back(
lower_bound, is_spec_decode, query_start_loc, extend_seq_lens_cpu
):
@@ -0,0 +1,495 @@
# SPDX-License-Identifier: Apache-2.0
"""NVIDIA split KDA prefill backend (K1-K4 CuTe/Triton/cuTile pipeline).
Wraps the vendored ``kda_nvidia_prefill`` package through its FLA-compatible
``chunk_kda_fwd`` interface. SGLang owns the boundary conversion from its
V-major ``[B,H,V,K]`` cache to the vendor's K-major ``[B,H,K,V]`` state and
back; the vendored split kernels keep their original internal layouts.
The serving wrapper repacks ordinary packed prefill batches into bounded
equal-length groups (B <= 8) and pads every sequence to one of
2k/4k/8k/16k. This makes short multi-sequence prefill use the same NVIDIA KDA
pipeline while bounding compile variants and transient workspaces. Everything
that is not an ordinary state-committing prefill stays on its dedicated path:
- single-sequence prefill, including chunks of one long request, where Triton
retains the neutral BS=1 performance;
- track batches needing an *interior* chunk snapshot (mamba extra_buffer
with a non-boundary track point): the pipeline never materializes
per-chunk states. Boundary-aligned track batches reuse the final state;
- spec-decode extends, which must stay rollback-able.
Inputs are staged into per-bucket persistent buffers (2k/4k/8k/16k):
stable pointers keep the vendored pipeline's pointer-keyed wrapper and
fast-path caches hot, and bound both the workspace set and the compile
variants. Pad rows are state-neutral (k/v/beta zero => no rank-1 update;
g sentinel -1000 => decay exactly 1).
Any unexpected kernel failure falls back to Triton for that batch with a
loud log (attempt-and-verify, same stance as the fused decode kernel).
"""
import logging
from typing import Optional
import torch
from sglang.srt.layers.attention.linear.kernels.kda_triton import TritonKDAKernel
from sglang.srt.layers.attention.linear.kernels.kernel_backend import (
LinearAttnKernelBase,
)
logger = logging.getLogger(__name__)
_BUCKETS = (2048, 4096, 8192, 16384)
_MAX_NVIDIA_KDA_BATCH = 8
def _to_nvidia_kda_state_layout(
state: torch.Tensor, *, head_k_dim: int, head_v_dim: int
) -> torch.Tensor:
"""Materialize SGLang [B,H,V,K] state as vendor [B,H,K,V]."""
expected = (head_v_dim, head_k_dim)
if state.ndim != 4 or tuple(state.shape[-2:]) != expected:
raise ValueError(
"SGLang KDA state must be [B,H,V,K] with "
f"(V,K)={expected}, got {tuple(state.shape)}"
)
return state.transpose(-1, -2).float().contiguous()
def _from_nvidia_kda_state_layout(
state: torch.Tensor,
*,
head_k_dim: int,
head_v_dim: int,
dtype: torch.dtype,
) -> torch.Tensor:
"""Materialize vendor [B,H,K,V] state as SGLang [B,H,V,K]."""
expected = (head_k_dim, head_v_dim)
if state.ndim != 4 or tuple(state.shape[-2:]) != expected:
raise ValueError(
"NVIDIA KDA state must be [B,H,K,V] with "
f"(K,V)={expected}, got {tuple(state.shape)}"
)
return state.transpose(-1, -2).to(dtype=dtype).contiguous()
class NvidiaKDAKernel(LinearAttnKernelBase):
def __init__(self):
# This kernel uses tcgen05 + TMEM, which are available on datacenter
# Blackwell (SM100/SM103, reported as capability major 10), but not on
# Blackwell desktop SM120 even though its capability number is larger.
self.supports_prefill = torch.cuda.is_available() and (
torch.cuda.get_device_capability()[0] == 10
)
self._fwd = None
self._l2norm = None
self._triton = TritonKDAKernel()
# Stable detached fp32 views of the (frozen) gate params: nn.Parameters
# trip cute's from_dlpack (grad-tracking) and per-call views defeat
# the pointer-keyed wrapper caches. Keyed per source tensor -- this
# kernel instance is shared by every KDA layer (one dispatcher per
# attention backend) and each layer has its own A_log/dt_bias.
self._param_flat = {}
# One-shot engagement log per (batch size, bucket) (observability).
self._engaged_logged = set()
self._unsupported_logged = set()
# (batch size, bucket, shape, device) -> staging dict.
self._staging = {}
def _ensure_loaded(self):
if self._fwd is None:
from sglang.kernels.ops.attention.fla.l2norm import l2norm_fwd
from sglang.kernels.ops.attention.linear.kda_nvidia_prefill import (
chunk_kda_fwd,
)
self._fwd = chunk_kda_fwd
self._l2norm = l2norm_fwd
logger.info("Using NVIDIA chunked KDA prefill (Blackwell)")
def decode(self, *args, **kwargs):
raise NotImplementedError("NvidiaKDAKernel is prefill-only")
def target_verify(self, *args, **kwargs):
raise NotImplementedError("NvidiaKDAKernel does not support target_verify")
def _triton_extend(
self,
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
):
return self._triton.extend(
q,
k,
v,
g,
beta,
ssm_states=ssm_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
A_log=A_log,
dt_bias=dt_bias,
lower_bound=lower_bound,
return_intermediate_states=return_intermediate_states,
**kwargs,
)
def _flat_param(self, t: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
if t is None:
return None
key = (t.data_ptr(), t.dtype, tuple(t.shape))
v = self._param_flat.get(key)
if v is None:
v = t.detach().reshape(-1).float().contiguous()
self._param_flat[key] = v
return v
def _get_staging(self, batch_size, bucket, num_heads, head_k_dim, head_v_dim, dev):
key = (
batch_size,
bucket,
num_heads,
head_k_dim,
head_v_dim,
dev.index,
)
st = self._staging.get(key)
if st is None:
st = {
"q": torch.zeros(
batch_size,
bucket,
num_heads,
head_k_dim,
dtype=torch.bfloat16,
device=dev,
),
"k": torch.zeros(
batch_size,
bucket,
num_heads,
head_k_dim,
dtype=torch.bfloat16,
device=dev,
),
"v": torch.zeros(
batch_size,
bucket,
num_heads,
head_v_dim,
dtype=torch.bfloat16,
device=dev,
),
"g": torch.full(
(batch_size, bucket, num_heads, head_k_dim),
-1000.0,
dtype=torch.bfloat16,
device=dev,
),
"beta": torch.zeros(
batch_size,
bucket,
num_heads,
dtype=torch.bfloat16,
device=dev,
),
"s0": torch.zeros(
batch_size,
num_heads,
head_k_dim,
head_v_dim,
dtype=torch.float32,
device=dev,
),
}
self._staging[key] = st
return st
def extend(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
g: torch.Tensor,
beta: torch.Tensor,
*,
ssm_states: torch.Tensor,
cache_indices: torch.Tensor,
query_start_loc: torch.Tensor,
A_log: Optional[torch.Tensor] = None,
dt_bias: Optional[torch.Tensor] = None,
lower_bound: Optional[float] = None,
return_intermediate_states: bool = False,
**kwargs,
) -> torch.Tensor:
num_tokens = q.shape[1]
seq_lens_cpu = kwargs.get("extend_seq_lens_cpu")
track_h_src = kwargs.get("track_ssm_h_src")
supported_shape = (
q.ndim == 4
and k.ndim == 4
and v.ndim == 4
and q.shape[-1] == 128
and k.shape[-1] == 128
and v.shape[-1] == 128
and q.dtype == torch.bfloat16
and k.dtype == torch.bfloat16
and v.dtype == torch.bfloat16
and g.dtype == torch.bfloat16
and beta.dtype == torch.float32
and A_log is not None
)
needs_interior_snapshot = (
return_intermediate_states
and track_h_src is not None
and track_h_src.numel() > 0
)
eligible = (
not needs_interior_snapshot
and not kwargs.get("is_spec_decode")
and seq_lens_cpu is not None
and len(seq_lens_cpu) > 1
and supported_shape
)
if not eligible:
if (
seq_lens_cpu is not None
and len(seq_lens_cpu) > 1
and not kwargs.get("is_spec_decode")
and not needs_interior_snapshot
and not supported_shape
):
unsupported_key = (
q.dtype,
k.dtype,
v.dtype,
g.dtype,
beta.dtype,
q.shape[-1],
k.shape[-1],
v.shape[-1],
A_log is not None,
)
if unsupported_key not in self._unsupported_logged:
self._unsupported_logged.add(unsupported_key)
logger.warning(
"NVIDIA KDA prefill only supports BF16 q/k/v/g, FP32 beta, "
"K=V=128 with A_log; "
"got q/k/v/g/beta=%s/%s/%s/%s/%s, Kq/Kk/V=%d/%d/%d, "
"A_log=%s. Falling back to Triton.",
q.dtype,
k.dtype,
v.dtype,
g.dtype,
beta.dtype,
q.shape[-1],
k.shape[-1],
v.shape[-1],
A_log is not None,
)
return self._triton_extend(
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
)
self._ensure_loaded()
seq_lens = [int(length) for length in seq_lens_cpu]
if (
any(length <= 0 or length > _BUCKETS[-1] for length in seq_lens)
or sum(seq_lens) != num_tokens
or len(seq_lens) != cache_indices.numel()
):
logger.warning(
"NVIDIA KDA cannot repack prefill shape T=%d seq_lens=%s; falling "
"back to Triton",
num_tokens,
seq_lens,
)
return self._triton_extend(
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
)
num_heads = q.shape[2]
head_k_dim = q.shape[-1]
head_v_dim = v.shape[-1]
alog_flat = self._flat_param(A_log)
dtb_flat = self._flat_param(dt_bias)
all_slot_indices = torch.where(
cache_indices >= 0, cache_indices, ssm_states.shape[0] - 1
).to(torch.int64)
state_backup = ssm_states.index_select(0, all_slot_indices).clone()
packed_output = torch.empty_like(v)
try:
seq_start = 0
token_start = 0
while seq_start < len(seq_lens):
group_lens = seq_lens[seq_start : seq_start + _MAX_NVIDIA_KDA_BATCH]
group_size = len(group_lens)
group_tokens = sum(group_lens)
bucket = next(b for b in _BUCKETS if b >= max(group_lens))
engage_key = (group_size, bucket)
if engage_key not in self._engaged_logged:
self._engaged_logged.add(engage_key)
logger.info(
"NVIDIA KDA prefill engaged: sequences=%d max_T=%d -> B=%d "
"bucket=%d",
len(seq_lens),
max(group_lens),
group_size,
bucket,
)
st = self._get_staging(
group_size,
bucket,
num_heads,
head_k_dim,
head_v_dim,
q.device,
)
st["q"].zero_()
st["k"].zero_()
st["v"].zero_()
st["g"].fill_(-1000.0)
st["beta"].zero_()
row_token_start = token_start
for row, length in enumerate(group_lens):
row_token_end = row_token_start + length
st["q"][row, :length].copy_(
self._l2norm(q[0, row_token_start:row_token_end].contiguous())
)
st["k"][row, :length].copy_(
self._l2norm(k[0, row_token_start:row_token_end].contiguous())
)
st["v"][row, :length].copy_(v[0, row_token_start:row_token_end])
g_in = g[0, row_token_start:row_token_end]
if g_in.dim() == 2:
g_in = g_in.view(length, num_heads, head_k_dim)
st["g"][row, :length].copy_(g_in)
st["beta"][row, :length].copy_(
beta[0, row_token_start:row_token_end]
)
row_token_start = row_token_end
group_slots = all_slot_indices[seq_start : seq_start + group_size]
st["s0"].copy_(
_to_nvidia_kda_state_layout(
ssm_states.index_select(0, group_slots),
head_k_dim=head_k_dim,
head_v_dim=head_v_dim,
)
)
res = self._fwd(
st["q"],
st["k"],
st["v"],
st["g"],
st["beta"],
scale=head_k_dim**-0.5,
initial_state=st["s0"],
output_final_state=True,
cu_seqlens=None,
safe_gate=lower_bound is not None,
lower_bound=lower_bound,
use_gate_in_kernel=True,
A_log=alog_flat,
dt_bias=dtb_flat,
)
group_output, final_state = res[0], res[1]
row_token_start = token_start
for row, length in enumerate(group_lens):
row_token_end = row_token_start + length
packed_output[0, row_token_start:row_token_end].copy_(
group_output[row, :length]
)
row_token_start = row_token_end
ssm_states.index_copy_(
0,
group_slots,
_from_nvidia_kda_state_layout(
final_state,
head_k_dim=head_k_dim,
head_v_dim=head_v_dim,
dtype=ssm_states.dtype,
),
)
seq_start += group_size
token_start += group_tokens
except Exception:
ssm_states.index_copy_(0, all_slot_indices, state_backup)
logger.warning(
"NVIDIA KDA prefill failed (T=%d sequences=%d); restored states "
"and fell back to Triton for this batch",
num_tokens,
len(seq_lens),
exc_info=True,
)
return self._triton_extend(
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
)
if return_intermediate_states:
# Boundary-aligned track: the snapshot comes from the final state
# already scattered into the pool (track_ssm_final_src); h is
# never indexed (track_ssm_h_src is empty), a zero-row stand-in
# keeps the (out, h) contract.
h_empty = q.new_empty(
(1, 0) + tuple(ssm_states.shape[1:]), dtype=torch.float32
)
return packed_output, h_empty
return packed_output
@@ -0,0 +1,349 @@
# SPDX-License-Identifier: Apache-2.0
"""PTX/tcgen05 KDA chunked-prefill backend (``--linear-attn-prefill-backend ptx_kda``).
Wraps the vendored hand-CUDA ``kda_ptx_prefill`` kernel (GB300 / sm_103a) through
its FLA-compatible ``chunk_kda_fwd`` interface. The kernel selects a fused
long-sequence route or a two-launch high-head/many-sequence route at the KDA
serving shape: K = V = 128, chunk 64.
Scope: ordinary extend batches satisfying the kernel's fixed tensor contract.
Correctness-sensitive cases stay on Triton:
- track batches receive dense intermediate SSM states directly from the kernel
when the cache checkpoint stride is also 64 tokens. Other interior snapshots
stay on Triton; boundary-only tracking can still use the final state;
- spec-decode extends, which must stay rollback-able.
Single-sequence token counts that are not a multiple of the kernel's 64-token
chunk are padded up to a bucket (1k/2k/4k/8k/16k/32k) in a persistent staging
buffer, which bounds the resident workspace set. Pad rows are state-neutral:
k/v/beta zero => no rank-1 update; raw gate -1000 => transformed decay of
exactly 1. Multi-sequence batches go through the kernel's own varlen grid
(real cu_seqlens, no padding), so their shapes are whatever the scheduler
produces and each distinct shape can retain another workspace.
The kernel caches one workspace per distinct shape for the process lifetime;
callers should account for that resident-memory behavior when sending highly
variable packed batches. Any unexpected kernel failure restores the state and
falls back to Triton for that batch with a loud log.
"""
import logging
from typing import Optional
import torch
from sglang.srt.layers.attention.linear.kernels.kda_triton import TritonKDAKernel
from sglang.srt.layers.attention.linear.kernels.kernel_backend import (
LinearAttnKernelBase,
)
logger = logging.getLogger(__name__)
_CHUNK = 64
_PAD_BUCKETS = (1024, 2048, 4096, 8192, 16384, 32768)
# Raw-gate sentinel for pad rows: softplus(-1000) ~ 0 and
# sigmoid(-1000) ~ 0, so either gate transform yields glog 0 => decay 1.
_PAD_GATE = -1000.0
class PtxKDAKernel(LinearAttnKernelBase):
def __init__(self):
# tcgen05 + TMEM with sm_103a-only encodings: GB300 (SM103) only.
self.supports_prefill = torch.cuda.is_available() and (
torch.cuda.get_device_capability() == (10, 3)
)
self._fwd = None
self._triton = TritonKDAKernel()
# Stable detached fp32 views of the (frozen) gate params, keyed per
# source tensor: this kernel instance is shared by every KDA layer.
self._param_flat = {}
self._unsupported_logged = False
# (bucket, H, K, V, device) -> staging dict for ragged token counts.
self._staging = {}
def _ensure_loaded(self):
if self._fwd is None:
from sglang.kernels.ops.attention.linear.kda_ptx_prefill import (
chunk_kda_fwd,
load_ext,
)
logger.info("Building the PTX KDA prefill extension (first use, ~1-2 min)")
load_ext()
self._fwd = chunk_kda_fwd
logger.info("Using PTX KDA chunked prefill (GB300 / sm_103a)")
def decode(self, *args, **kwargs):
raise NotImplementedError("PtxKDAKernel is prefill-only")
def target_verify(self, *args, **kwargs):
raise NotImplementedError("PtxKDAKernel does not support target_verify")
def _flat_param(self, t: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
if t is None:
return None
key = (t.data_ptr(), t.dtype, tuple(t.shape))
flat = self._param_flat.get(key)
if flat is None:
flat = t.detach().reshape(-1).float().contiguous()
self._param_flat[key] = flat
return flat
def _get_staging(self, bucket, num_heads, head_k_dim, head_v_dim, dev):
key = (bucket, num_heads, head_k_dim, head_v_dim, dev.index)
st = self._staging.get(key)
if st is None:
st = {
"q": torch.zeros(
1, bucket, num_heads, head_k_dim, dtype=torch.bfloat16, device=dev
),
"k": torch.zeros(
1, bucket, num_heads, head_k_dim, dtype=torch.bfloat16, device=dev
),
"v": torch.zeros(
1, bucket, num_heads, head_v_dim, dtype=torch.bfloat16, device=dev
),
"g": torch.full(
(1, bucket, num_heads, head_k_dim),
_PAD_GATE,
dtype=torch.bfloat16,
device=dev,
),
"beta": torch.zeros(
1, bucket, num_heads, dtype=torch.bfloat16, device=dev
),
}
self._staging[key] = st
return st
def _triton_extend(
self,
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
):
return self._triton.extend(
q,
k,
v,
g,
beta,
ssm_states=ssm_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
A_log=A_log,
dt_bias=dt_bias,
lower_bound=lower_bound,
return_intermediate_states=return_intermediate_states,
**kwargs,
)
def extend(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
g: torch.Tensor,
beta: torch.Tensor,
*,
ssm_states: torch.Tensor,
cache_indices: torch.Tensor,
query_start_loc: torch.Tensor,
A_log: Optional[torch.Tensor] = None,
dt_bias: Optional[torch.Tensor] = None,
lower_bound: Optional[float] = None,
return_intermediate_states: bool = False,
**kwargs,
) -> torch.Tensor:
num_tokens = q.shape[1]
seq_lens_cpu = kwargs.get("extend_seq_lens_cpu")
track_h_src = kwargs.get("track_ssm_h_src")
supported_shape = (
q.ndim == 4
and k.ndim == 4
and v.ndim == 4
and q.shape[-1] == 128
and k.shape[-1] == 128
and v.shape[-1] == 128
and q.dtype == torch.bfloat16
and k.dtype == torch.bfloat16
and v.dtype == torch.bfloat16
and g.dtype == torch.bfloat16
and A_log is not None
)
needs_interior_snapshot = (
return_intermediate_states
and track_h_src is not None
and track_h_src.numel() > 0
)
intermediate_stride_supported = True
if needs_interior_snapshot:
# The CUDA kernel hardcodes BT=64 and h[c] is the state before
# global chunk c. SGLang indexes h using mamba_cache_chunk_size,
# which may be larger (for example 256 for Nemotron-H). Returning
# the raw 64-token rows in that case would silently select the
# wrong boundary and compute wrong offsets for later sequences.
from sglang.srt.runtime_context import get_server_args
intermediate_stride_supported = (
get_server_args().mamba_cache_chunk_size == _CHUNK
)
seq_lens = (
[int(length) for length in seq_lens_cpu] if seq_lens_cpu is not None else []
)
shape_known = (
len(seq_lens) >= 1
and len(seq_lens) == cache_indices.numel()
and sum(seq_lens) == num_tokens
and min(seq_lens, default=0) > 0
)
# Only the single-sequence path pads (staging buffers keep the resident
# workspace set bounded); varlen batches go in as-is. A ragged single
# sequence beyond the largest bucket uses the kernel's ragged grid.
padded_tokens = num_tokens
if len(seq_lens) == 1 and num_tokens % _CHUNK != 0:
padded_tokens = next(
(bucket for bucket in _PAD_BUCKETS if bucket >= num_tokens),
num_tokens,
)
eligible = (
not kwargs.get("is_spec_decode")
and intermediate_stride_supported
and shape_known
and supported_shape
)
if not eligible:
if shape_known and not supported_shape and not self._unsupported_logged:
self._unsupported_logged = True
logger.warning(
"PTX KDA prefill only supports BF16 q/k/v/g with K=V=128 and "
"A_log; got q/k/v/g=%s/%s/%s/%s, Kq/Kk/V=%d/%d/%d, A_log=%s. "
"Falling back to Triton.",
q.dtype,
k.dtype,
v.dtype,
g.dtype,
q.shape[-1],
k.shape[-1],
v.shape[-1],
A_log is not None,
)
return self._triton_extend(
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
)
self._ensure_loaded()
num_heads = q.shape[2]
head_k_dim = q.shape[-1]
head_v_dim = v.shape[-1]
slot = torch.where(
cache_indices >= 0, cache_indices, ssm_states.shape[0] - 1
).to(torch.int64)
state_backup = ssm_states.index_select(0, slot).clone()
try:
qn = q
kn = k
g4 = g.reshape(1, num_tokens, num_heads, head_k_dim)
b3 = beta.reshape(1, num_tokens, num_heads)
if padded_tokens != num_tokens:
st = self._get_staging(
padded_tokens, num_heads, head_k_dim, head_v_dim, q.device
)
for name, src in (("q", qn), ("k", kn), ("v", v), ("g", g4)):
st[name][0, :num_tokens].copy_(src[0])
st["beta"][0, :num_tokens].copy_(b3[0])
st["q"][0, num_tokens:].zero_()
st["k"][0, num_tokens:].zero_()
st["v"][0, num_tokens:].zero_()
st["g"][0, num_tokens:].fill_(_PAD_GATE)
st["beta"][0, num_tokens:].zero_()
qn, kn, v_in, g4, b3 = st["q"], st["k"], st["v"], st["g"], st["beta"]
else:
v_in = v
# Multi-sequence packed batch, or a single sequence beyond the
# padding buckets -> the kernel's varlen grid. Host
# cu_seqlens avoids the D2H sync its piece table would otherwise do.
cu_kwargs = {}
if len(seq_lens) > 1 or padded_tokens % _CHUNK != 0:
cu_cpu = torch.zeros(len(seq_lens) + 1, dtype=torch.int32)
cu_cpu[1:] = torch.tensor(seq_lens, dtype=torch.int32).cumsum(0)
cu_kwargs = dict(cu_seqlens=query_start_loc, cu_seqlens_cpu=cu_cpu)
result = self._fwd(
qn,
kn,
v_in,
g4,
b3,
scale=head_k_dim**-0.5,
initial_state=ssm_states.index_select(0, slot),
output_final_state=True,
**cu_kwargs,
# SGLang keeps the recurrent state V-major ([slots, H, V, K]);
# the kernel is K-major, so let the wrapper transpose both ways.
state_v_first=True,
safe_gate=lower_bound is not None,
lower_bound=lower_bound,
use_gate_in_kernel=True,
A_log=self._flat_param(A_log),
dt_bias=self._flat_param(dt_bias),
return_intermediate_states=return_intermediate_states,
use_qk_l2norm_in_kernel=True,
)
out, final_state, h = result[0], result[1], result[10]
ssm_states.index_copy_(0, slot, final_state.to(ssm_states.dtype))
except Exception:
ssm_states.index_copy_(0, slot, state_backup)
logger.warning(
"PTX KDA prefill failed (T=%d); restored state and fell back to "
"Triton for this batch",
num_tokens,
exc_info=True,
)
return self._triton_extend(
q,
k,
v,
g,
beta,
ssm_states,
cache_indices,
query_start_loc,
A_log,
dt_bias,
lower_bound,
return_intermediate_states,
kwargs,
)
packed_output = out[:, :num_tokens].contiguous()
if return_intermediate_states:
return packed_output, h
return packed_output
@@ -38,6 +38,7 @@ class TritonKDAKernel(LinearAttnKernelBase):
cache_indices: torch.Tensor,
num_v_heads: int,
head_v_dim: int,
lower_bound: Optional[float] = None,
**kwargs,
) -> torch.Tensor:
"""Packed decode fast path: feed the conv-1d output ``mixed_qkv``
@@ -67,6 +68,11 @@ class TritonKDAKernel(LinearAttnKernelBase):
and replayssm_g is not None
and replayssm_write_pos is not None
):
if lower_bound is not None:
raise NotImplementedError(
"KDA safe gate (lower_bound) is not implemented in the "
"ReplaySSM decode kernel; disable --enable-linear-replayssm."
)
K = ssm_states.shape[-1] # ssm_states: [num_slots, HV, V, K]
fused_recurrent_linear_replayssm_decode(
mixed_qkv=mixed_qkv,
@@ -105,6 +111,7 @@ class TritonKDAKernel(LinearAttnKernelBase):
out=out,
ssm_state_indices=cache_indices,
use_qk_l2norm_in_kernel=True,
lower_bound=lower_bound,
)
# [B, 1, HV, V] -> [1, B, HV, V] view to match existing decode layout.
return out.transpose(0, 1)
@@ -122,6 +129,7 @@ class TritonKDAKernel(LinearAttnKernelBase):
ssm_states: torch.Tensor,
cache_indices: torch.Tensor,
query_start_loc: torch.Tensor,
lower_bound: Optional[float] = None,
**kwargs,
) -> torch.Tensor:
return fused_sigmoid_gating_delta_rule_update(
@@ -139,6 +147,7 @@ class TritonKDAKernel(LinearAttnKernelBase):
softplus_beta=1.0,
softplus_threshold=20.0,
is_kda=True,
lower_bound=lower_bound,
)
def target_verify(
@@ -158,6 +167,13 @@ class TritonKDAKernel(LinearAttnKernelBase):
intermediate_state_indices: torch.Tensor,
cache_steps: int,
retrieve_parent_token: torch.Tensor,
lower_bound: Optional[float] = None,
# fused ReplaySSM ring-write (dense verify only; off elsewhere).
cache_ring: bool = False,
replayssm_rawv: Optional[torch.Tensor] = None,
replayssm_rawk: Optional[torch.Tensor] = None,
replayssm_g: Optional[torch.Tensor] = None,
replayssm_beta: Optional[torch.Tensor] = None,
**kwargs,
) -> torch.Tensor:
# KDA MTP / speculative-decode verify via the fused KDA kernel (IS_KDA=True),
@@ -186,6 +202,12 @@ class TritonKDAKernel(LinearAttnKernelBase):
intermediate_state_indices=intermediate_state_indices,
cache_steps=cache_steps,
retrieve_parent_token=retrieve_parent_token,
lower_bound=lower_bound,
cache_ring=cache_ring,
replayssm_rawv=replayssm_rawv,
replayssm_rawk=replayssm_rawk,
replayssm_g=replayssm_g,
replayssm_beta=replayssm_beta,
)
def extend(
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Dict, Optional
from sglang.srt.utils.common import rank0_log
@@ -15,8 +15,11 @@ logger = logging.getLogger(__name__)
class LinearAttnKernelBackend(Enum):
TRITON = "triton"
CUTEDSL = "cutedsl"
NV_CUTEDSL = "nv_cutedsl"
FLASHINFER = "flashinfer"
FLASHKDA = "flashkda"
NVIDIA_KDA = "nvidia_kda"
PTX_KDA = "ptx_kda"
CUSTOM = "custom"
@classmethod
@@ -29,51 +32,109 @@ class LinearAttnKernelBackend(Enum):
def is_cutedsl(self):
return self == LinearAttnKernelBackend.CUTEDSL
def is_nv_cutedsl(self):
return self == LinearAttnKernelBackend.NV_CUTEDSL
def is_flashinfer(self):
return self == LinearAttnKernelBackend.FLASHINFER
def is_flashkda(self):
return self == LinearAttnKernelBackend.FLASHKDA
def is_nvidia_kda(self):
return self == LinearAttnKernelBackend.NVIDIA_KDA
def is_ptx_kda(self):
return self == LinearAttnKernelBackend.PTX_KDA
def is_custom(self):
return self == LinearAttnKernelBackend.CUSTOM
LINEAR_ATTN_DECODE_BACKEND: Optional[LinearAttnKernelBackend] = None
LINEAR_ATTN_PREFILL_BACKEND: Optional[LinearAttnKernelBackend] = None
_BACKENDS: Dict[str, Optional[LinearAttnKernelBackend]] = {
"decode": None,
"prefill": None,
"verify": None,
}
def initialize_linear_attn_config(
server_args: ServerArgs, prefill_default: Optional[str] = None
):
global LINEAR_ATTN_DECODE_BACKEND
global LINEAR_ATTN_PREFILL_BACKEND
base = server_args.linear_attn_backend
decode = server_args.linear_attn_decode_backend or base
prefill = server_args.linear_attn_prefill_backend or prefill_default or base
LINEAR_ATTN_DECODE_BACKEND = LinearAttnKernelBackend(decode)
LINEAR_ATTN_PREFILL_BACKEND = LinearAttnKernelBackend(prefill)
_BACKENDS["decode"] = LinearAttnKernelBackend(decode)
_BACKENDS["prefill"] = LinearAttnKernelBackend(prefill)
rank0_log(f"Linear attention kernel backend: decode={decode}, prefill={prefill}")
# Verify backend. Unset -> follow decode (flashinfer -> its recurrent kernel,
# else triton), preserving historical behavior.
verify = server_args.linear_attn_verify_backend
if verify is None:
verify = decode if _BACKENDS["decode"].is_flashinfer() else "triton"
_BACKENDS["verify"] = LinearAttnKernelBackend(verify)
rank0_log(
f"Linear attention kernel backend: decode={decode}, prefill={prefill}, "
f"verify={verify}"
)
def _get_backend(phase: str) -> LinearAttnKernelBackend:
backend = _BACKENDS[phase]
if backend is None:
logger.warning(
"linear-attn %s backend is not initialized, using triton backend", phase
)
backend = _BACKENDS[phase] = LinearAttnKernelBackend.TRITON
return backend
def get_linear_attn_decode_backend() -> LinearAttnKernelBackend:
global LINEAR_ATTN_DECODE_BACKEND
if LINEAR_ATTN_DECODE_BACKEND is None:
logger.warning(
"LINEAR_ATTN_DECODE_BACKEND is not initialized, using triton backend"
)
LINEAR_ATTN_DECODE_BACKEND = LinearAttnKernelBackend.TRITON
return LINEAR_ATTN_DECODE_BACKEND
return _get_backend("decode")
def get_linear_attn_prefill_backend() -> LinearAttnKernelBackend:
global LINEAR_ATTN_PREFILL_BACKEND
if LINEAR_ATTN_PREFILL_BACKEND is None:
logger.warning(
"LINEAR_ATTN_PREFILL_BACKEND is not initialized, using triton backend"
return _get_backend("prefill")
def get_linear_attn_verify_backend() -> LinearAttnKernelBackend:
return _get_backend("verify")
def build_verify_intermediate_state_indices(
pool_size: int, server_args: ServerArgs, device
):
"""Per-request row index into the speculative intermediate scratch
(`intermediate_ssm` / `intermediate_conv_window`) for the MTP /
target_verify path: request slot i owns scratch row i.
The scratch is allocated with one extra padding row (the `+1` in
MambaPool.SpeculativeState, index `pool_size`). Warmup and MLP-sync
batches can be padded past the pool capacity under DP attention
`get_eager_max_batch_size` ceil-aligns the eager warmup bs to attn_tp
and the verify kernels index this table positionally up to that padded
bs. Size the table to the padded maximum and clamp every out-of-pool row
onto the padding row: pad rows race onto one discard row, which is
value-irrelevant (same convention as the ragged-verify ghost row).
"""
import torch
from sglang.srt.utils.common import get_eager_max_batch_size
padded_bs = max(get_eager_max_batch_size(server_args, pool_size), pool_size)
indices = torch.arange(pool_size, dtype=torch.int32, device=device)
if padded_bs > pool_size:
indices = torch.cat(
[
indices,
torch.full(
(padded_bs - pool_size,),
pool_size,
dtype=torch.int32,
device=device,
),
]
)
LINEAR_ATTN_PREFILL_BACKEND = LinearAttnKernelBackend.TRITON
return LINEAR_ATTN_PREFILL_BACKEND
return indices
@@ -55,6 +55,7 @@ from sglang.srt.layers.attention.trtllm_mla_backend import (
TRTLLMMLAMultiStepDraftBackend,
)
from sglang.srt.layers.dcp.layout import get_dcp_lens
from sglang.srt.layers.logits_processor import get_in_autotune_dummy_run
from sglang.srt.runtime_context import get_parallel
from sglang.srt.utils import is_flashinfer_available, is_tokenspeed_mla_available
@@ -75,7 +76,8 @@ logger = logging.getLogger(__name__)
# Workspace upper bound for tokenspeed_mla_decode:
# num_sms * num_heads * max_q_len * (kv_lora_rank + 1) * sizeof(float32)
# MAX_Q_LEN=8 covers EAGLE3 num_draft_tokens=4 plus headroom.
# MAX_Q_LEN=8 covers EAGLE3 num_draft_tokens=4 plus headroom. Larger
# speculative widths grow the bound at backend initialization.
_TOKENSPEED_MAX_Q_LEN = 8
@@ -401,7 +403,10 @@ class TokenspeedMLABackend(TRTLLMMLABackend):
):
if not get_parallel().dcp_enabled:
return super()._apply_cuda_graph_metadata(
bs, req_pool_indices, seq_lens, forward_mode
bs,
req_pool_indices,
seq_lens,
forward_mode,
)
metadata = self.decode_cuda_graph_metadata[bs]
@@ -511,6 +516,25 @@ class TokenspeedMLABackend(TRTLLMMLABackend):
llama_4_scaling: Optional[torch.Tensor] = None,
):
parallel = get_parallel()
# FlashInfer autotunes MoE kernels with a synthetic full-model decode
# and discards the attention/logits result. On multi-node GB300, the
# synthetic full-head DCP metadata can make both the TokenSpeed and
# TRTLLM decode kernels surface cudaErrorNvlinkUncorrectable. Skip
# attention only inside that explicitly scoped dummy pass. Real
# requests and CUDA graph capture continue through TokenSpeed below.
if parallel.dcp_enabled and get_in_autotune_dummy_run():
output = torch.zeros(
(q.shape[0], layer.tp_q_head_num * layer.v_head_dim),
dtype=self.q_data_type,
device=q.device,
)
lse = torch.zeros(
(q.shape[0], layer.tp_q_head_num),
dtype=torch.float32,
device=q.device,
)
return output, lse
if not parallel.dcp_enabled:
return super().forward_decode(
q,
@@ -615,7 +639,7 @@ class TokenspeedMLABackend(TRTLLMMLABackend):
# (prepare_prefill_qkv / pack_prefix_chunk_kv); no quantize here.
# Hybrid MLA models resolve the model-side hook through the outer
# HybridLinearAttnBackend, so their fallback MHA path can pass V as a
# last-dimension slice of kv_b_proj (stride(-2) > size(-1)). The
# last-dimension slice of kv_b_proj (stride(-2) > size(-1)). The
# TokenSpeed prefill kernel requires dense Q/K/V layouts even though
# the public wrapper accepts arbitrary torch tensors.
q = q.contiguous()
@@ -465,11 +465,19 @@ class TritonAttnBackend(AttentionBackend):
spec_info,
):
"""Fill all cuda-graph buffers for target_verify mode."""
# Prefer the spec_info's per-request query length (DSpark draft propose
# uses gamma < verify window); fall back to the configured verify window.
num_draft_tokens = self.num_draft_tokens
if (
spec_info is not None
and getattr(spec_info, "draft_token_num", None) is not None
):
num_draft_tokens = int(spec_info.draft_token_num)
qo_indptr = self.qo_indptr[: bs + 1]
qo_indptr[: bs + 1] = torch.arange(
0,
(1 + bs) * self.num_draft_tokens,
step=self.num_draft_tokens,
(1 + bs) * num_draft_tokens,
step=num_draft_tokens,
dtype=torch.int32,
device=self.device,
)
@@ -506,7 +514,7 @@ class TritonAttnBackend(AttentionBackend):
custom_mask[: spec_info.custom_mask.shape[0]] = spec_info.custom_mask
else:
custom_mask = None
seq_mask_len = self.num_draft_tokens * (seq_lens + self.num_draft_tokens)
seq_mask_len = num_draft_tokens * (seq_lens + num_draft_tokens)
mask_indptr = self.mask_indptr[: bs + 1]
mask_indptr[1 : bs + 1] = torch.cumsum(seq_mask_len, dim=0)
return (
@@ -799,10 +807,18 @@ class TritonAttnBackend(AttentionBackend):
max_extend_len = None
elif forward_batch.forward_mode.is_target_verify():
bs = len(forward_batch.req_pool_indices)
# self.num_draft_tokens is the verify window (gamma + 1), while
# DSpark draft propose runs a gamma-token TARGET_VERIFY forward.
num_draft_tokens = self.num_draft_tokens
if (
spec_info is not None
and getattr(spec_info, "draft_token_num", None) is not None
):
num_draft_tokens = int(spec_info.draft_token_num)
qo_indptr = torch.arange(
0,
(1 + bs) * self.num_draft_tokens,
step=self.num_draft_tokens,
(1 + bs) * num_draft_tokens,
step=num_draft_tokens,
dtype=torch.int32,
device=self.device,
)
@@ -839,13 +855,13 @@ class TritonAttnBackend(AttentionBackend):
)
custom_mask = spec_info.custom_mask
seq_mask_len = self.num_draft_tokens * (
forward_batch.seq_lens + self.num_draft_tokens
seq_mask_len = num_draft_tokens * (
forward_batch.seq_lens + num_draft_tokens
)
mask_indptr = self.mask_indptr
mask_indptr[1 : bs + 1] = torch.cumsum(seq_mask_len[:bs], dim=0)
mask_indptr = mask_indptr[: bs + 1]
max_extend_len = self.num_draft_tokens
max_extend_len = num_draft_tokens
num_kv_splits = None
attn_logits = None
attn_lse = None
@@ -1093,10 +1109,16 @@ class TritonAttnBackend(AttentionBackend):
and getattr(spec_info, "custom_mask", None) is not None
else None
)
max_extend_len = self.num_draft_tokens
if (
spec_info is not None
and getattr(spec_info, "draft_token_num", None) is not None
):
max_extend_len = int(spec_info.draft_token_num)
return ForwardMetadata(
attn_logits=None,
attn_lse=None,
max_extend_len=self.num_draft_tokens,
max_extend_len=max_extend_len,
num_kv_splits=None,
kv_indptr=self.kv_indptr[: bs + 1],
kv_indices=self.cuda_graph_kv_indices,
@@ -19,6 +19,20 @@ from sglang.kernels.ops.attention.pad import (
from sglang.kernels.ops.attention.pad import (
unpad_draft_extend_output as unpad_draft_extend_output_triton,
)
from sglang.kernels.ops.attention.set_mla_kv_concat_q import (
can_use_set_mla_kv_concat_q,
can_use_set_mla_kv_concat_q_fp8,
)
from sglang.kernels.ops.attention.set_mla_kv_concat_q import (
covered as set_mla_kv_concat_q_covered,
)
from sglang.kernels.ops.attention.set_mla_kv_concat_q import (
covered_fp8 as set_mla_kv_concat_q_fp8_covered,
)
from sglang.kernels.ops.attention.set_mla_kv_concat_q import (
set_mla_kv_concat_q,
set_mla_kv_concat_q_fp8,
)
from sglang.kernels.ops.attention.utils import (
concat_mla_absorb_q_general,
mla_quantize_and_rope_for_fp8,
@@ -146,6 +160,13 @@ class TRTLLMMLAPrefillMetadata:
fallback_to_flashinfer_impl: bool = False
from sglang.kernels.jit.utils import is_arch_support_pdl
# Arm PDL on the trtllm-gen decode launch so its prolog overlaps the tail of
# the query-prep kernels (which already trigger their PDL secondary).
_ENABLE_PDL = is_arch_support_pdl()
@dataclass
class TRTLLMMLADecodeMetadata:
"""Metadata for TRTLLM MLA decode operations."""
@@ -167,6 +188,10 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
# read seq_lens_cpu / seq_lens_sum; opt out of the D2H sync.
needs_cpu_seq_lens: bool = False
# Ragged verify: the packed query is front-aligned into the dense
# [bs, draft_token_num] layout in forward_extend; metadata stays uniform.
supports_ragged_verify_graph: bool = True
def __init__(
self,
model_runner: ModelRunner,
@@ -266,6 +291,27 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
# write translates through the pool's _full_translate hook instead).
self._decode_dense_loc: Optional[torch.Tensor] = None
self.cuda_graph_out_cache_loc_dense: Optional[torch.Tensor] = None
# Fused KV-scatter + q-concat on the decode dense-loc path (one launch
# instead of set_mla_kv_buffer + concat_mla_absorb_q). Disabled under
# async asserts: the fused path writes the pool directly and would
# skip the pool's OOB probe.
self._fused_set_kv_concat_q = (
self.data_type == torch.bfloat16
and not envs.SGLANG_ENABLE_ASYNC_ASSERT.get()
and can_use_set_mla_kv_concat_q(
self.kv_lora_rank * 2, self.qk_rope_head_dim * 2
)
)
# fp8 sibling: quantize + KV scatter + q concat in one launch
# (replaces mla_quantize_without_rope_for_fp8's concat + three aten
# casts plus the KV-row write on the fp8 decode path).
self._fused_set_kv_concat_q_fp8 = (
self.data_type == torch.float8_e4m3fn
and not envs.SGLANG_ENABLE_ASYNC_ASSERT.get()
and self.kv_lora_rank == 512
and self.qk_rope_head_dim == 64
and can_use_set_mla_kv_concat_q_fp8()
)
def _calc_padded_blocks(self, max_seq_len: int) -> int:
"""
@@ -415,6 +461,11 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
device: torch.device,
):
"""Allocate persistent metadata buffers for CUDA graph capture."""
if forward_mode.is_target_verify() and bs in self.decode_cuda_graph_metadata:
# Token tiers at the same slot count must share one per-bs buffer
# set (each graph bakes in the tensors it captured).
self.forward_decode_metadata = self.decode_cuda_graph_metadata[bs]
return
metadata = TRTLLMMLADecodeMetadata()
if forward_mode.is_target_verify():
@@ -438,6 +489,11 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
)
metadata.seq_lens_k = torch.zeros((bs,), dtype=torch.int32, device=device)
if metadata.seq_lens_k is None:
# Plain decode: static int32 seq_lens buffer, refreshed by the
# capture+replay body below (same pattern as target-verify).
metadata.seq_lens_k = torch.zeros((bs,), dtype=torch.int32, device=device)
# Capture with full width so future longer sequences are safe during replay.
max_blocks_per_seq = self._calc_padded_blocks(self.max_context_len)
block_kv_indices = self.decode_cuda_graph_kv_indices[:bs, :max_blocks_per_seq]
@@ -476,6 +532,10 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
metadata.sum_seq_lens_q = num_tokens_per_req * bs
seq_lens = seq_lens[:bs]
metadata.seq_lens_k.copy_(seq_lens)
elif metadata.seq_lens_k is not None:
# Plain decode: int64 -> int32 downcast copy into the static
# buffer (once per step, replacing the per-layer conversion).
metadata.seq_lens_k.copy_(seq_lens[:bs])
# Update block indices for new sequences.
create_flashmla_kv_indices_triton[
@@ -643,6 +703,11 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
self.forward_decode_metadata.global_seq_lens_k = (
self.forward_decode_metadata.seq_lens_k
)
elif forward_batch.forward_mode.is_decode_or_idle():
# One int32 conversion per step; forward_decode reads it back
# so the per-layer .to(int32) in _run_decode_kernel stays a
# no-op (24 elementwise copies/step otherwise).
self.forward_decode_metadata.seq_lens_k = seq_lens.to(torch.int32)
elif forward_batch.forward_mode.is_draft_extend_v2():
sum_seq_lens_q = sum(forward_batch.extend_seq_lens_cpu)
max_seq_len_q = max(forward_batch.extend_seq_lens_cpu)
@@ -699,14 +764,32 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
cu_seqlens_q: torch.Tensor,
seq_lens_q: torch.Tensor,
sum_seq_lens_q: int,
zero_uncovered: bool = False,
) -> torch.Tensor:
"""Unpad draft extended output using Triton kernel."""
"""Unpad draft extended output using Triton kernel.
zero_uncovered: ragged verify's clamped rows leave output positions
unwritten; zero the destination so those discarded rows stay finite
(draft_extend writes every position and does not need this).
"""
output_buffer = self.unpad_output_buffer
if zero_uncovered:
if output_buffer is not None:
output_buffer[:sum_seq_lens_q].zero_()
else:
# No persistent buffer without cuda graph state; the triton
# wrapper's dynamic fallback is torch.empty.
output_buffer = torch.zeros(
(sum_seq_lens_q, raw_out.shape[2], raw_out.shape[3]),
dtype=raw_out.dtype,
device=raw_out.device,
)
return unpad_draft_extend_output_triton(
raw_out,
cu_seqlens_q,
seq_lens_q,
sum_seq_lens_q,
self.unpad_output_buffer,
output_buffer,
)
def _compute_decode_bmm1_scale(self, layer: RadixAttention) -> float:
@@ -738,8 +821,25 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
seq_lens: torch.Tensor,
max_seq_len: int,
layer: RadixAttention,
*,
causal_seqs: Optional[torch.Tensor] = None,
cp_world: int = 1,
cp_rank: int = 0,
return_lse: bool = False,
) -> torch.Tensor:
"""Hook for subclasses to swap the decode/spec-verify kernel."""
"""Hook for subclasses to swap the decode/spec-verify kernel.
The DCP arguments belong to the hook contract because forward_extend
passes them on the DCP target-verify path. This implementation does not
forward them to the kernel and returns no LSE, so only the DCP-capable
subclasses serve them."""
if cp_world > 1 or return_lse:
raise NotImplementedError(
"trtllm_mla does not forward the cyclic DCP metadata to its "
"decode kernel and returns no rank-local LSE for the cross-rank "
"merge; select cutedsl_mla or tokenspeed_mla for a DCP "
"target-verify run"
)
# Scale computation for TRTLLM MLA kernel BMM1 operation:
# The final BMM1 scale is computed as: q_scale * k_scale * softmax_scale
@@ -761,6 +861,7 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
return flashinfer.decode.trtllm_batch_decode_with_kv_cache_mla(
query=query,
kv_cache=kv_cache,
enable_pdl=_ENABLE_PDL,
workspace_buffer=self.workspace_buffer,
qk_nope_head_dim=self.qk_nope_head_dim,
kv_lora_rank=self.kv_lora_rank,
@@ -818,6 +919,101 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
skip_softmax_threshold_scale_factor=envs.SGLANG_SKIP_SOFTMAX_PREFILL_THRESHOLD_SCALE_FACTOR.get(),
)
def _set_kv_and_concat_q_fused(
self,
layer: RadixAttention,
loc: torch.Tensor,
k: torch.Tensor,
k_rope: torch.Tensor,
q: torch.Tensor,
q_rope: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Decode: scatter the KV row at ``loc`` (already physical — the
dense-loc buffer on the unified pool, or out_cache_loc on the static
pool where ``_full_translate`` is identity) and build the
[q_nope | q_rope] fmha query in one kernel launch (saves one launch
per MLA layer and keeps the PDL chain intact).
Returns the concatenated query, or None when the fused kernel does
not cover the inputs (caller falls back to the two-kernel path).
"""
k_nope_2d = k.view(k.shape[0], -1)
k_rope_2d = k_rope.view(k_rope.shape[0], -1)
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope_3d = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
# Same raw per-layer buffer the decode kernel reads below (bf16-only
# gate means store_dtype == dtype, so no view); get_key_buffer applies
# the hybrid pool's full-attention layer-id mapping.
kv_raw = self.token_to_kv_pool.get_key_buffer(layer.layer_id)
kv_2d = kv_raw.view(kv_raw.shape[0], -1) if kv_raw.dim() != 2 else kv_raw
if not set_mla_kv_concat_q_covered(
kv_buffer=kv_2d,
loc=loc,
k_nope=k_nope_2d,
k_rope=k_rope_2d,
q_nope=q_nope,
q_rope=q_rope_3d,
):
return None
return set_mla_kv_concat_q(
kv_buffer=kv_2d,
loc=loc,
cache_k_nope=k_nope_2d,
cache_k_rope=k_rope_2d,
q_nope=q_nope,
q_rope=q_rope_3d,
)
def _set_kv_and_concat_q_fp8_fused(
self,
layer: RadixAttention,
loc: torch.Tensor,
q: torch.Tensor,
q_rope: torch.Tensor,
k: torch.Tensor,
k_rope: torch.Tensor,
) -> Optional[torch.Tensor]:
"""fp8-KV decode: quantize + scatter the KV row at ``loc`` (already
physical) and build the fp8 [q_nope | q_rope] query in one launch.
Returns the fp8 query, or None when the fused kernel does not cover
the inputs (caller falls back to the aten quantize chain).
"""
k_nope_2d = k.view(k.shape[0], -1)
k_rope_2d = k_rope.view(k_rope.shape[0], -1)
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope_3d = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
# fp8 view of the pool's uint8 store; same buffer the decode kernel
# reads below.
kv_raw = self.token_to_kv_pool.get_key_buffer(layer.layer_id)
kv_2d = kv_raw.view(kv_raw.shape[0], -1) if kv_raw.dim() != 2 else kv_raw
if not set_mla_kv_concat_q_fp8_covered(
kv_buffer=kv_2d,
loc=loc,
k_nope=k_nope_2d,
k_rope=k_rope_2d,
q_nope=q_nope,
q_rope=q_rope_3d,
):
return None
parallel = get_parallel()
return set_mla_kv_concat_q_fp8(
kv_buffer=kv_2d,
loc=loc,
cache_k_nope=k_nope_2d,
cache_k_rope=k_rope_2d,
q_nope=q_nope,
q_rope=q_rope_3d,
# DCP cyclic KV sharding: virtual loc -> owner mask + loc//world
# (identity when attn_dcp_size == 1).
dcp_world_size=parallel.attn_dcp_size,
dcp_rank=parallel.attn_dcp_rank,
)
def forward_decode(
self,
q: torch.Tensor, # q_nope
@@ -834,12 +1030,33 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
) -> torch.Tensor:
"""Run forward for decode using TRTLLM MLA kernel."""
merge_query = q_rope is not None
fused_fp8_query = None
if self.data_type == torch.float8_e4m3fn:
assert q_rope is not None and k_rope is not None
if cos_sin_cache is None:
q, k, k_rope = mla_quantize_without_rope_for_fp8(
q, q_rope, k.squeeze(1), k_rope.squeeze(1)
)
if save_kv_cache and self._fused_set_kv_concat_q_fp8:
loc = (
self._decode_dense_loc
if self._decode_dense_loc is not None
else (
None if self._unified_mla else forward_batch.out_cache_loc
)
)
if loc is not None:
# Fused: bf16->fp8 quantize + KV scatter + q concat
# in one launch; None when not covered.
fused_fp8_query = self._set_kv_and_concat_q_fp8_fused(
layer=layer,
loc=loc,
q=q,
q_rope=q_rope,
k=k,
k_rope=k_rope,
)
if fused_fp8_query is None:
q, k, k_rope = mla_quantize_without_rope_for_fp8(
q, q_rope, k.squeeze(1), k_rope.squeeze(1)
)
else:
q, k, k_rope = mla_quantize_and_rope_for_fp8(
q,
@@ -854,34 +1071,65 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
)
merge_query = False
# Save KV cache if requested
if save_kv_cache:
# Save KV cache if requested (the fused fp8 path already wrote it)
query = fused_fp8_query
if query is None and save_kv_cache:
assert (
k is not None and k_rope is not None
), "For populating trtllm_mla kv cache, both k_nope and k_rope should be not None."
if self._decode_dense_loc is not None:
# cuda-graph path: dense write loc precomputed out-of-graph, so
# the in-graph write captures no translate allocation.
self.token_to_kv_pool.set_mla_kv_buffer(
layer, self._decode_dense_loc, k, k_rope, loc_is_dense=True
)
if merge_query and self._fused_set_kv_concat_q:
# Fused: KV scatter + [q_nope | q_rope] concat in one
# launch; None when the inputs are not covered.
query = self._set_kv_and_concat_q_fused(
layer=layer,
loc=self._decode_dense_loc,
k=k,
k_rope=k_rope,
q=q,
q_rope=q_rope,
)
if query is None:
self.token_to_kv_pool.set_mla_kv_buffer(
layer, self._decode_dense_loc, k, k_rope, loc_is_dense=True
)
else:
# eager (or static pool): the pool's _full_translate handles it.
self.token_to_kv_pool.set_mla_kv_buffer(
layer, forward_batch.out_cache_loc, k, k_rope
)
if (
merge_query
and self._fused_set_kv_concat_q
and not self._unified_mla
):
# Static pool: _full_translate is identity, so
# out_cache_loc is already the physical write loc.
query = self._set_kv_and_concat_q_fused(
layer=layer,
loc=forward_batch.out_cache_loc,
k=k,
k_rope=k_rope,
q=q,
q_rope=q_rope,
)
if query is None:
self.token_to_kv_pool.set_mla_kv_buffer(
layer, forward_batch.out_cache_loc, k, k_rope
)
# Prepare query tensor inline
if merge_query:
# For FP16 path, we merge the query and rope parts into a single tensor
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope_reshaped = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
query = concat_mla_absorb_q_general(q_nope, q_rope_reshaped)
else:
# For FP8 path, we already have the query and rope parts merged because of the quantize_and_rope_for_fp8 function
query = q.view(-1, layer.tp_q_head_num, layer.head_dim)
# Prepare query tensor inline (already built when the fused save-KV
# path ran)
if query is None:
if merge_query:
# For FP16 path, we merge the query and rope parts into a single tensor
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope_reshaped = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
query = concat_mla_absorb_q_general(q_nope, q_rope_reshaped)
else:
# For FP8 path, we already have the query and rope parts merged because of the quantize_and_rope_for_fp8 function
query = q.view(-1, layer.tp_q_head_num, layer.head_dim)
# Apply llama 4 scaling if provided
if llama_4_scaling is not None:
@@ -915,7 +1163,11 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
query=query,
kv_cache=kv_cache,
block_tables=metadata.block_kv_indices,
seq_lens=forward_batch.seq_lens,
seq_lens=(
metadata.seq_lens_k
if metadata.seq_lens_k is not None
else forward_batch.seq_lens
),
max_seq_len=metadata.max_seq_len_k,
layer=layer,
)
@@ -1038,9 +1290,38 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
max_seq_len = metadata.max_seq_len_k + (
0 if dcp_enabled else draft_token_num
)
# For target_verify, all sequences have the same number of draft tokens
q = q.view(bs, -1, layer.tp_q_head_num, layer.head_dim)
needs_unpad = False
ragged_layout = forward_batch.spec_info.ragged_verify_layout
if ragged_layout is None or dcp_enabled:
q = q.view(bs, -1, layer.tp_q_head_num, layer.head_dim)
needs_unpad = False
else:
if ragged_layout.bs != bs:
# Capped variant: the dense [bs, draft_token_num] q
# buffer below cannot take a row the full-coverage pad
# may inflate past the verify window, and it keeps
# qo_indptr consistent with the clamped lens (same
# contract as the KDA dense path).
ragged_layout = ragged_layout.padded_to_bucket(
padded_bs=bs, cap=draft_token_num
)
total_tokens = q.shape[0]
seq_lens_q = torch.clamp(
ragged_layout.verify_lens, max=draft_token_num
)
cu_seqlens_q = ragged_layout.qo_indptr_device
padded_q = torch.zeros(
(bs, draft_token_num, layer.tp_q_head_num, layer.head_dim),
dtype=q.dtype,
device=q.device,
)
q = self.pad_draft_extend_query(
q, padded_q, seq_lens_q, cu_seqlens_q
)
needs_unpad = True
unpad_zero_uncovered = True
unpad_seq_lens_q = seq_lens_q
unpad_cu_seqlens_q = cu_seqlens_q
unpad_sum_seq_lens_q = total_tokens
else:
# draft_extend: handle varying num_correct_drafts_per_req. If total_tokens % bs == 0,
# we can directly reshape q; otherwise, pad to max_seq_len_q.
@@ -1084,6 +1365,7 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
q, padded_q, actual_seq_lens_q, actual_cu_seqlens_q
)
needs_unpad = True
unpad_zero_uncovered = False
unpad_seq_lens_q = actual_seq_lens_q
unpad_cu_seqlens_q = actual_cu_seqlens_q
unpad_sum_seq_lens_q = total_tokens
@@ -1145,6 +1427,7 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
unpad_cu_seqlens_q,
unpad_seq_lens_q,
unpad_sum_seq_lens_q,
zero_uncovered=unpad_zero_uncovered,
)
output = output.view(-1, layer.tp_q_head_num * layer.v_head_dim)
else:
@@ -169,6 +169,43 @@ def prepare_vision_attention_metadata(
)
def prepare_flashinfer_cudnn_vision_attention_metadata(
cu_seqlens: torch.Tensor,
device: torch.device,
*,
elem_per_token: int,
) -> VisionAttentionMetadata:
cu_seqlens = cu_seqlens.to(device=device, dtype=torch.int32, non_blocking=True)
seq_lens = cu_seqlens[1:] - cu_seqlens[:-1]
batch_size = int(seq_lens.numel())
padded_batch_size = next(
(size for size in BATCH_BUCKETS if size >= batch_size),
math.ceil(batch_size / BATCH_BUCKETS[0]) * BATCH_BUCKETS[0],
)
if padded_batch_size != batch_size:
pad_size = padded_batch_size - batch_size
padded_indptrs = torch.cat([cu_seqlens, cu_seqlens[-1].expand(pad_size)])
padded_seq_lens = torch.cat([seq_lens, seq_lens.new_zeros(pad_size)])
else:
padded_indptrs = cu_seqlens
padded_seq_lens = seq_lens
elem_indptrs = padded_indptrs * elem_per_token
real_max_seqlen = int(seq_lens.max().item())
max_seqlen = next(
(size for size in FLASHINFER_MAX_SEQLEN_BUCKETS if size >= real_max_seqlen),
math.ceil(real_max_seqlen / FLASHINFER_MAX_SEQLEN_BUCKETS[-1])
* FLASHINFER_MAX_SEQLEN_BUCKETS[-1],
)
return prepare_vision_attention_metadata(
cu_seqlens,
device=device,
packed_indptrs=torch.cat([elem_indptrs] * 3),
sequence_lengths=padded_seq_lens.view(-1, 1, 1, 1),
flashinfer_max_seqlen=max_seqlen,
)
# TODO: requires real seqlens from images
@functools.lru_cache(maxsize=128)
def _get_cu_seqlens_for_shape(batch_size: int, seqlen: int, device) -> torch.Tensor:
+505
View File
@@ -0,0 +1,505 @@
# SPDX-License-Identifier: Apache-2.0
# Kimi-K3 Attention Residual: snapshot bank + aggregation.
#
# The public API is the AttnResidual class (constructed once per forward pass).
# It owns the frozen snapshot bank [T, NB, H] and the valid-row counter, and
# dispatches each aggregation point (score rows → softmax → weighted sum →
# RMSNorm) by hardware capability:
# fast — warp-specialized TMA kernel: cp.async.bulk producer +
# online-softmax consumers over a double-buffered chunk ring, out
# norm fused, per-nvb tuned launch config, one persistent CTA per
# SM. Taken on SM100+ with H=7168.
# fused — Triton 2-kernel pipeline with full H-parallelism; the fallback
# everywhere the fast kernel does not apply.
# aggregate_stream_torch is the eager reference (tests and the
# H % _BLOCK_H != 0 shape fallback of aggregate_stream).
from typing import Optional
import torch
import triton
import triton.language as tl
from sglang.srt.layers.layernorm import RMSNorm
from sglang.srt.layers.linear import ReplicatedLinear
_BLOCK_H: int = 1024 # H = 7168 = 7 x 1024
_MAX_ROWS: int = 16 # next_pow2(8 + 1), K3 has <= 8 snapshots
_FAST_SUPPORTED = None
def _use_fast(hidden_size: int) -> bool:
"""The TMA kernel needs SM100+ (tcgen05, cp.async.bulk) and its H=7168
template instantiation; everything else takes the triton pipeline."""
global _FAST_SUPPORTED
if _FAST_SUPPORTED is None:
major, _ = torch.cuda.get_device_capability()
_FAST_SUPPORTED = major >= 10
return _FAST_SUPPORTED and hidden_size == 7168
def get_cw(
proj: ReplicatedLinear,
norm: RMSNorm,
dtype: torch.dtype = torch.float32,
) -> torch.Tensor:
"""Cached product norm_weight ⊙ proj_weight (both [H]) in `dtype`.
Cached per dtype: the fast kernel consumes bf16 while the triton path
consumes fp32, and a shared slot would hand one path the other's dtype."""
cache = getattr(proj, "_attn_res_cw_cache", None)
if cache is None:
cache = {}
proj._attn_res_cw_cache = cache
cw = cache.get(dtype)
if cw is None:
cw = (norm.weight.float() * proj.weight.squeeze().float()).contiguous()
cw = cache[dtype] = cw.to(dtype)
return cw
def _aggregate_fast(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
write_bank_row: bool = False,
) -> torch.Tensor:
"""Warp-specialized TMA kernel: online softmax over row chunks with the
output RMSNorm fused, one persistent CTA per SM, per-nvb tuned launch
config (GB300 benchmark winner across nvb). With write_bank_row the kernel
also snapshots the prefix row into bank[:, nvb, :] (bit-exact, zero extra
reads the row streams through the score pass anyway)."""
from sglang.kernels.ops.kimi_k3.attn_res import attn_res_fused_tma
# The kernel applies one eps to both the score norm and the output norm.
assert score_norm.variance_epsilon == out_norm.variance_epsilon
cw = get_cw(score_proj, score_norm, dtype=torch.bfloat16)
out = torch.empty_like(prefix_sum)
attn_res_fused_tma(
prefix_sum,
bank,
cw,
out_norm.weight,
out,
nvb,
score_norm.variance_epsilon,
write_prefix=write_bank_row,
)
return out
@triton.jit
def _score_kernel(
prefix_ptr, # [T, H]
bank_ptr, # [T, NB_total, H]
cw_ptr, # [H] fp32
scores_ptr, # [T, MAX_ROWS] fp32
NVB,
eps,
stride_pm,
stride_bm,
stride_bb,
stride_sm,
H: tl.constexpr,
BLOCK_H: tl.constexpr,
):
"""One CTA per (token, row): scan H, output one scalar score."""
pid_t = tl.program_id(0)
j = tl.program_id(1)
if j > NVB:
return
sumsq = 0.0
dotv = 0.0
for h0 in tl.static_range(0, H, BLOCK_H):
offs_h = h0 + tl.arange(0, BLOCK_H)
if j < NVB:
v = tl.load(bank_ptr + pid_t * stride_bm + j * stride_bb + offs_h).to(
tl.float32
)
else:
v = tl.load(prefix_ptr + pid_t * stride_pm + offs_h).to(tl.float32)
cw = tl.load(cw_ptr + offs_h)
sumsq += tl.sum(v * v)
dotv += tl.sum(v * cw)
rrms = 1.0 / tl.sqrt(sumsq / H + eps)
tl.store(scores_ptr + pid_t * stride_sm + j, dotv * rrms)
@triton.jit
def _combine_kernel(
prefix_ptr,
bank_ptr,
scores_ptr, # [T, MAX_ROWS] fp32
out_ptr, # [T, H]
NVB,
stride_pm,
stride_bm,
stride_bb,
stride_sm,
stride_om,
BLOCK_H: tl.constexpr,
MAX_ROWS: tl.constexpr,
):
"""One CTA per (token, H-chunk): softmax(scores) → weighted sum → write chunk.
Softmax is redundantly computed by each H-chunk CTA (16 elements, trivial).
This gives full H-parallelism: 7 CTAs for H=7168/1024.
"""
pid_t = tl.program_id(0)
pid_h = tl.program_id(1)
h0 = pid_h * BLOCK_H
# Softmax (redundant per chunk, 16 fp32 ops)
offs_b = tl.arange(0, MAX_ROWS)
mask_b = offs_b <= NVB
raw = tl.load(
scores_ptr + pid_t * stride_sm + offs_b, mask=mask_b, other=float("-inf")
)
m = tl.max(raw, axis=0)
e = tl.where(mask_b, tl.exp(raw - m), 0.0)
p = e / tl.sum(e, axis=0)
# Weighted sum for this H chunk
offs_h = h0 + tl.arange(0, BLOCK_H)
acc = tl.zeros([BLOCK_H], tl.float32)
for j in range(0, NVB + 1):
if j < NVB:
v = tl.load(bank_ptr + pid_t * stride_bm + j * stride_bb + offs_h).to(
tl.float32
)
else:
v = tl.load(prefix_ptr + pid_t * stride_pm + offs_h).to(tl.float32)
p_j = tl.sum(tl.where(offs_b == j, p, 0.0), axis=0)
acc += p_j * v
tl.store(
out_ptr + pid_t * stride_om + offs_h,
acc.to(out_ptr.dtype.element_ty),
)
def _mix_fused(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
) -> torch.Tensor:
"""Triton score + combine pair: returns the pre-norm mixture."""
T, H = prefix_sum.shape
cw = get_cw(score_proj, score_norm)
n_h_blocks = H // _BLOCK_H
# Step 1: score each row (2D grid, full row-parallelism)
scores = torch.empty((T, _MAX_ROWS), dtype=torch.float32, device=prefix_sum.device)
_score_kernel[(T, nvb + 1)](
prefix_sum,
bank,
cw,
scores,
nvb,
score_norm.variance_epsilon,
prefix_sum.stride(0),
bank.stride(0),
bank.stride(1),
scores.stride(0),
H=H,
BLOCK_H=_BLOCK_H,
num_warps=8,
)
# Step 2: softmax + weighted sum (2D grid, full H-parallelism)
out = torch.empty_like(prefix_sum)
_combine_kernel[(T, n_h_blocks)](
prefix_sum,
bank,
scores,
out,
nvb,
prefix_sum.stride(0),
bank.stride(0),
bank.stride(1),
scores.stride(0),
out.stride(0),
BLOCK_H=_BLOCK_H,
MAX_ROWS=_MAX_ROWS,
num_warps=4,
)
return out
def _aggregate_fused(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
) -> torch.Tensor:
# Step 3: standard RMSNorm (sglang's optimized kernel)
return out_norm(_mix_fused(prefix_sum, bank, nvb, score_proj, score_norm))
def aggregate_stream_torch(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
) -> torch.Tensor:
"""Eager reference for aggregate_stream (materializes [T, R, H])."""
if nvb == 0:
return prefix_sum
T, H = prefix_sum.shape
# rows = [bank[0..nvb-1], prefix_sum] shape [T, nvb+1, H]
rows = torch.cat([bank[:, :nvb, :], prefix_sum.unsqueeze(1)], dim=1)
R = nvb + 1
normed = score_norm(rows.reshape(T * R, H))
scores = score_proj(normed)[0].reshape(T, R)
probs = torch.softmax(scores.float(), dim=-1)
mixed = (probs.unsqueeze(-1) * rows.float()).sum(dim=1)
return mixed.to(prefix_sum.dtype)
def aggregate_stream(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
) -> torch.Tensor:
"""Pre-norm aggregated stream value (softmax mixture, no output norm):
the K3 analogue of the residual stream, for dspark aux capture -- the
raw wire only carries the current block's running prefix."""
if nvb == 0:
return prefix_sum
if prefix_sum.shape[1] % _BLOCK_H != 0:
return aggregate_stream_torch(prefix_sum, bank, nvb, score_proj, score_norm)
return _mix_fused(prefix_sum, bank, nvb, score_proj, score_norm)
def _aggregate_fused_add(
prefix_a: torch.Tensor,
prefix_b: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
write_bank_row: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Aggregation point with a pending upstream residual add: materialize
prefix = prefix_a + prefix_b, then aggregate. Returns (normed, prefix).
write_bank_row rides _aggregate (fast path only)."""
prefix = prefix_a + prefix_b
return (
_aggregate(
prefix,
bank,
nvb,
score_proj,
score_norm,
out_norm,
write_bank_row=write_bank_row,
),
prefix,
)
def _aggregate(
prefix_sum: torch.Tensor,
bank: torch.Tensor,
nvb: int,
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
write_bank_row: bool = False,
) -> torch.Tensor:
"""Single aggregation point: score → softmax → mix → norm.
Caller handles nvb == 0 (layer 0 attn side: just out_norm(prefix_sum)).
write_bank_row is fast-path only (in-kernel snapshot of the prefix row
into bank[:, nvb, :]); the triton path keeps the standalone .write() copy
the caller (AttnResidual.forward) owns that fallback.
"""
if _use_fast(prefix_sum.shape[1]):
return _aggregate_fast(
prefix_sum,
bank,
nvb,
score_proj,
score_norm,
out_norm,
write_bank_row=write_bank_row,
)
assert not write_bank_row, "fused bank write is fast-path only"
return _aggregate_fused(prefix_sum, bank, nvb, score_proj, score_norm, out_norm)
class AttnResidual:
"""Snapshot bank + aggregation of one K3 attention-residual stream,
backed by the capability-dispatched kernels above.
One instance lives for one model forward pass.
"""
def __init__(
self,
hidden_states: torch.Tensor,
block_num: int,
block_residual: Optional[torch.Tensor] = None,
) -> None:
num_tokens, hidden_size = hidden_states.shape
# Frozen snapshot rows [T, NB, H]; raw tensor for PP transfer and the
# legacy kernel path.
self.block_residual = hidden_states.new_empty(
(num_tokens, block_num, hidden_size)
)
self.num_valid_blocks = 0
if block_residual is not None: # inherited from the previous PP rank
self.num_valid_blocks = block_residual.size(1)
self.block_residual[:, : self.num_valid_blocks, :].copy_(block_residual)
def write(self, prefix_sum: torch.Tensor, rows: Optional[slice] = None) -> None:
"""Snapshot the pre-attention prefix into the next bank row.
Under SP attention-residual carry each rank owns a disjoint token
slice, so only that slice is written and subsequently read locally.
"""
bank = self.block_residual if rows is None else self.block_residual[rows]
bank[:, self.num_valid_blocks, :].copy_(prefix_sum)
self.num_valid_blocks += 1
def forward(
self,
hidden_states: torch.Tensor,
prefix_sum: Optional[torch.Tensor],
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
rows: Optional[slice] = None,
write: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Aggregate; with write=True also snapshot the aggregated prefix
(the second return value) into the next bank row fused into the
fast kernel (the row streams through its score pass anyway), a
standalone .write() copy on every other path."""
nvb = self.num_valid_blocks
# Layer 0 attention side: nothing banked yet
if nvb == 0:
assert prefix_sum is None
if write:
self.write(hidden_states, rows)
return out_norm(hidden_states), hidden_states
# SP-MoE: the caller holds only its token shard; align the banked
# residual rows to it (dim-0 slice of a contiguous buffer stays
# contiguous for the jit kernels).
block_residual = (
self.block_residual if rows is None else self.block_residual[rows]
)
fused_write = write and _use_fast(hidden_states.shape[1])
if prefix_sum is None:
# hidden_states already is the whole head (PP entry or a
# block-boundary restart).
normed = _aggregate(
hidden_states,
block_residual,
nvb,
score_proj,
score_norm,
out_norm,
write_bank_row=fused_write,
)
prefix = hidden_states
else:
# Pending add: materialize the prefix, then aggregate.
normed, prefix = _aggregate_fused_add(
prefix_sum,
hidden_states,
block_residual,
nvb,
score_proj,
score_norm,
out_norm,
write_bank_row=fused_write,
)
if fused_write:
self.num_valid_blocks += 1 # row nvb written in-kernel
elif write:
self.write(prefix, rows)
return normed, prefix
def forward_sp_all_gather(
self,
hidden_states: torch.Tensor,
prefix_sum: Optional[torch.Tensor],
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
rows: slice,
write: bool = False,
) -> Optional[tuple[torch.Tensor, torch.Tensor]]:
"""Fuse a local aggregation point and the following row all-gather."""
nvb = self.num_valid_blocks
if nvb == 0:
return None
if prefix_sum is not None and prefix_sum.shape != hidden_states.shape:
prefix_sum = prefix_sum[rows]
prefix = hidden_states if prefix_sum is None else prefix_sum.add(hidden_states)
bank = self.block_residual[rows]
cw = get_cw(score_proj, score_norm, dtype=torch.bfloat16)
assert score_norm.variance_epsilon == out_norm.variance_epsilon
from sglang.srt.layers import k3_sp_collective
normed = k3_sp_collective.attn_res_all_gather(
prefix,
bank,
cw,
out_norm.weight,
nvb,
score_norm.variance_epsilon,
write_prefix=write,
)
if normed is None:
return None
if write:
self.num_valid_blocks += 1
return normed, prefix
def forward_sp_reduce_scatter(
self,
hidden_states: torch.Tensor,
prefix_sum: Optional[torch.Tensor],
score_proj: ReplicatedLinear,
score_norm: RMSNorm,
out_norm: RMSNorm,
rows: slice,
) -> Optional[tuple[torch.Tensor, torch.Tensor]]:
"""Fuse o_proj RS, the pending local prefix add, and aggregation."""
nvb = self.num_valid_blocks
if nvb == 0:
return None
local_tokens = rows.stop - rows.start
residual = prefix_sum
if residual is not None and residual.shape[0] != local_tokens:
residual = residual[rows]
bank = self.block_residual[rows]
cw = get_cw(score_proj, score_norm, dtype=torch.bfloat16)
assert score_norm.variance_epsilon == out_norm.variance_epsilon
from sglang.srt.layers import k3_sp_collective
return k3_sp_collective.reduce_scatter_attn_res(
hidden_states,
residual,
bank,
cw,
out_norm.weight,
nvb,
score_norm.variance_epsilon,
)
+386
View File
@@ -0,0 +1,386 @@
"""K3 MNNVL fused all-reduce dispatch.
Auto-enabled on SM100/SM103 when CustomAllReduceV2 with multicast is
available; ``SGLANG_K3_AR_FUSION`` overrides in either direction (0 = off,
1 = attempt anywhere and warn when unavailable).
Glue between the model and the ``kernels.ops.kimi_k3.all_reduce`` kernels. Small
messages take the 1shot multicast-push through the group's CustomAllReduceV2
workspace; large ones take the in-place NVLS 2shot, which needs its input to be a
:func:`symm_buffer` slice and otherwise falls back to the regular all-reduce.
Call-site contract when the fusion is active: ``enabled()`` was
checked once (which initializes the state), ``x`` is bf16 and contiguous,
and the residual is identical on every rank (a fully reduced tensor such
as the attn-res prefix sum) or ``None``.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, List, NamedTuple, Optional
import torch
import sglang.srt.runtime_context as ctx
from sglang.kernels.jit.utils import cache_once
from sglang.srt.environ import envs
if TYPE_CHECKING:
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
logger = logging.getLogger(__name__)
# push wins below ~0.5 MB on B200x8/GB300 (bs<=32 @ H=7168); NVLS 2shot wins
# above (measured: push 8.5us vs pull 11.0us at 448KB, 15.5 vs 11.0 at 896KB)
_PUSH_MAX_BYTES = 512 * 1024
# Latent width of the K3 latent|shared MoE buffer the fused-norm AR expects
# ([N, NORM_DIM] latent then [N, 2*NORM_DIM] shared). MUST match kNormDim in
# csrc/kimi_k3/comm/ar_fusion.cuh — the mod hardcodes this row width.
NORM_DIM = 3584
# :func:`symm_buffer` names: o_proj's [rows, hidden_size] output, and the MoE
# [latent | shared] pair flattened to rows x (moe_hidden_size + hidden_size).
ATTN_O_PROJ = "attn_o_proj"
MOE_LATENT_SHARED = "moe_latent_shared"
class _State(NamedTuple):
comm: CustomAllReduceV2
world_size: int
group_name: str
@cache_once
def _get_state() -> Optional[_State]:
explicit = envs.SGLANG_K3_AR_FUSION.is_set()
if explicit:
if not envs.SGLANG_K3_AR_FUSION.get():
return None
else:
# Auto-probe: only SM100/SM103, and not under --enable-symm-mem or EP
# a2a (their allocator contexts misroute the buffers the pull needs).
from sglang.srt.utils.common import get_device_sm
if get_device_sm() not in (100, 103):
return None
server_args = ctx.get_server_args()
if server_args.enable_symm_mem or server_args.moe_a2a_backend != "none":
logger.info(
"K3 all-reduce fusion auto-probe: skipping "
"(enable_symm_mem=%s, moe_a2a_backend=%s; under symm-mem the "
"allocator contexts conflict, and under EP a2a the model's "
"symm-pool allocation contract does not hold on every AR "
"call-site. Set SGLANG_K3_AR_FUSION=1 to force.)",
server_args.enable_symm_mem,
server_args.moe_a2a_backend,
)
return None
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.distributed.parallel_state import get_tp_group
from sglang.srt.runtime_context import get_parallel
if get_parallel().tp_size <= 1:
return None
group = get_tp_group()
comm = group.ca_comm
if (
not isinstance(comm, CustomAllReduceV2)
or comm.disabled
or comm.mc_base_ptr == 0
):
if explicit:
logger.warning(
"SGLANG_K3_AR_FUSION requested but CustomAllReduceV2 with multicast "
"is unavailable; falling back to the regular all-reduce path."
)
else:
logger.info(
"K3 all-reduce fusion auto-probe: CustomAllReduceV2 with "
"multicast is unavailable; using the regular all-reduce path."
)
return None
from sglang.kernels.ops.kimi_k3 import all_reduce as mod
mod.register_comm(comm.obj, pull_sem_mc_ptr=comm.pull_sem_mc_ptr)
logger.info("K3 all-reduce fusion enabled (world_size=%d)", comm.world_size)
return _State(comm, comm.world_size, group.cpu_group.group_name)
def enabled() -> bool:
return _get_state() is not None
class _Buffer(NamedTuple):
region: torch.Tensor # flat [max_rows * width]
base: int # local VA of region[0]
mc_base: int # multicast VA of region[0]
max_rows: int
width: int
# Reverse pointer -> multicast lookup for get_mc_ptr().
_BUFS: List[_Buffer] = []
@cache_once
def _max_buffer_rows() -> int:
"""Rows to reserve per buffer: the largest batch the server args allow."""
server_args = ctx.get_server_args()
chunked = server_args.chunked_prefill_size
if chunked is not None and chunked > 0:
return int(chunked)
return int(server_args.max_prefill_tokens or 0)
def _create_buffer(
name: str, width: int, dtype: torch.dtype, group_name: str
) -> _Buffer:
"""One symmetric buffer, rendezvoused once. ``empty_strided_p2p`` skips the
caching allocator, so this is right for a persistent buffer only."""
from torch._C._distributed_c10d import _SymmetricMemory
max_rows = _max_buffer_rows()
# outside inference mode on purpose: created inside it the buffer would be an
# inference tensor, and the in-place write from a no_grad path (CUDA graph
# capture) is then rejected
with torch.inference_mode(False):
region = _SymmetricMemory.empty_strided_p2p(
(max_rows * width,),
[1],
dtype,
torch.device("cuda", torch.cuda.current_device()),
group_name,
).view(max_rows, width)
handle = _SymmetricMemory.rendezvous(region)
assert handle is not None and handle.multicast_ptr != 0
buf = _Buffer(
region=region,
base=int(region.data_ptr()),
mc_base=int(handle.multicast_ptr),
max_rows=max_rows,
width=width,
)
_BUFS.append(buf)
logger.info(
"K3 symm buffer %r: %d rows x %d, %.1f MB (rendezvoused once on first "
"use; no per-forward symmetric allocation)",
name,
max_rows,
width,
region.numel() * region.element_size() / (1024 * 1024),
)
return buf
def symm_buffer(
name: str,
rows: int,
width: int,
dtype: torch.dtype,
group_name: Optional[str] = None,
):
"""``[rows, width]`` view of a named persistent symmetric buffer.
The pull reduces in place on the input's multicast alias, so every rank must
resolve the same ``(buffer, offset)``; a per-forward symm-pool allocation does
not, because torch's caching allocator breaks ties on the absolute address and
``rendezvous`` validates sizes only.
``group_name`` defaults to the TP group, the one the fused all-reduce reduces
over; a caller reducing over another group (SP-MoE's attention TP group) passes
its own. Each name belongs to one group, so the name alone identifies it.
"""
if group_name is None:
from sglang.srt.distributed.parallel_state import get_tp_group
group_name = get_tp_group().cpu_group.group_name
buf: _Buffer = ctx.get_buffer(
f"k3_symm:{name}", lambda: _create_buffer(name, width, dtype, group_name)
)
assert 0 < rows <= buf.max_rows and width == buf.width and dtype == buf.region.dtype
return buf.region[:rows]
def find_mc_ptr(x: torch.Tensor) -> Optional[int]:
"""Multicast VA of ``x`` if it lies inside a named buffer, else None."""
ptr = int(x.data_ptr())
end = ptr + x.numel() * x.element_size()
for buf in _BUFS:
if buf.base <= ptr and end <= buf.base + buf.region.nbytes:
return buf.mc_base + (ptr - buf.base)
return None
def get_mc_ptr(x: torch.Tensor) -> int:
"""Multicast VA of ``x``, which must be a :func:`symm_buffer` slice."""
mc = find_mc_ptr(x)
assert mc is not None, "K3 fused pull input is not a symm_buffer slice"
return mc
def all_reduce(
x: torch.Tensor,
residual: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""In-place ``x = allreduce(x) [+ residual]``; returns ``x``."""
from sglang.kernels.ops.kimi_k3 import all_reduce as mod
state = _get_state()
assert state is not None
if x.shape[0] == 0:
return x if residual is None else x + residual
nbytes = x.numel() * 2
if nbytes <= min(_PUSH_MAX_BYTES, state.comm.max_push_size):
return mod.all_reduce_push_res(
state.world_size, x, residual, ws_mc_base=state.comm.mc_base_ptr
)
return mod.all_reduce_pull_res(
state.world_size, x, residual, input_mc_ptr=get_mc_ptr(x)
)
def all_reduce_low_sm(
x: torch.Tensor,
residual: Optional[torch.Tensor] = None,
*,
num_blocks: Optional[int] = None,
unroll: Optional[int] = None,
) -> torch.Tensor:
"""In-place ``x = allreduce(x) [+ residual]``, pinned to the low-SM NVLS pull.
For side-stream use: push would fan out over many blocks and steal SMs from
the GEMMs it overlaps. ``num_blocks`` / ``unroll`` default to the tuned tables.
"""
from sglang.kernels.ops.kimi_k3 import all_reduce as mod
state = _get_state()
assert state is not None
if x.shape[0] == 0:
return x if residual is None else x + residual
return mod.all_reduce_pull_res(
state.world_size,
x,
residual,
input_mc_ptr=get_mc_ptr(x),
num_blocks=num_blocks,
unroll=unroll,
)
def all_reduce_norm(
x: torch.Tensor,
weight: torch.Tensor,
eps: float = 1e-6,
*,
num_tokens: int,
) -> torch.Tensor:
"""In-place ``x = allreduce(x)`` with a fused RMSNorm over the first
``num_tokens`` rows of the 2D ``[rows, NORM_DIM]`` input."""
from sglang.kernels.ops.kimi_k3 import all_reduce as mod
state = _get_state()
assert state is not None
if x.shape[0] == 0:
return x
nbytes = x.numel() * 2
if nbytes <= min(_PUSH_MAX_BYTES, state.comm.max_push_size):
return mod.all_reduce_push_norm(
state.world_size,
x,
weight,
eps,
num_norm_rows=num_tokens,
ws_mc_base=state.comm.mc_base_ptr,
)
return mod.all_reduce_pull_norm(
state.world_size,
x,
weight,
eps,
num_norm_rows=num_tokens,
input_mc_ptr=get_mc_ptr(x),
)
def finalize_push_fits(num_tokens: int) -> bool:
"""Whether a [num_tokens, NORM_DIM] latent fits the push window; the
finalize-fused AR is push-only."""
state = _get_state()
assert state is not None
nbytes = num_tokens * NORM_DIM * 2
return nbytes <= min(_PUSH_MAX_BYTES, state.comm.max_push_size)
def gemm_ag_up_fits(num_tokens: int) -> bool:
"""Whether :func:`gemm_ag_up_proj` covers this decode batch: TP8, within the
kernel's win range, and the staging slice fits a push slot."""
from sglang.kernels.ops.kimi_k3 import gemm_ag as mod
state = _get_state()
assert state is not None
comm = state.comm
return (
0 < num_tokens <= mod.MAX_TOKENS
and state.world_size == 8
and num_tokens * (2 * NORM_DIM // 8) * 2 <= comm.max_push_size
# The GEMV producer reads a single phase counter, so its grid is not
# bound to the counter array; the spin consumer still needs one block
# per counter slot plus the cleanup block.
and comm.config.num_push_blocks >= 2
)
def gemm_ag_up_proj(
x: torch.Tensor,
weight: torch.Tensor,
b: torch.Tensor,
c: Optional[torch.Tensor],
) -> torch.Tensor:
"""``up_proj(x) + b (+ c)`` via column-parallel GEMV + multicast all-gather +
fused add3. ``weight`` is the FULL replicated up_proj (the kernel slices this
rank's rows). Caller checked :func:`gemm_ag_up_fits`."""
from sglang.kernels.ops.kimi_k3 import gemm_ag as mod
state = _get_state()
assert state is not None
return mod.gemm_ag_up_proj(
state.world_size,
x,
weight,
b,
c,
torch.empty_like(b),
ws_mc_base=state.comm.mc_base_ptr,
)
def finalize_all_reduce_push_norm(
out: torch.Tensor,
gemm2_out: torch.Tensor,
expanded_idx_to_permuted_idx: torch.Tensor,
expert_weights: torch.Tensor,
weight: torch.Tensor,
eps: float = 1e-6,
) -> torch.Tensor:
"""Deferred MoE finalize fused into the 1shot push AR + RMSNorm; ``out`` is
output-only. Caller checked :func:`finalize_push_fits`."""
from sglang.kernels.ops.kimi_k3 import all_reduce as mod
state = _get_state()
assert state is not None
return mod.finalize_all_reduce_push_norm(
state.world_size,
out,
gemm2_out,
expanded_idx_to_permuted_idx,
expert_weights,
weight,
eps,
ws_mc_base=state.comm.mc_base_ptr,
)
+95
View File
@@ -0,0 +1,95 @@
"""K3 fused o_proj GEMM+AR dispatch (``SGLANG_K3_GEMM_AR``).
Glue between the model and ``kernels.ops.kimi_k3.gemm_ar``: lazily allocates
the P2P comm region on the TP group and swaps the o_proj RowParallelLinear
forward for the single fused GEMM+all-reduce kernel at decode shapes
(falling through to the regular GEMM + AR path whenever the input doesn't
fit prefill, capture, non-2D input). Eager-mode only; see
kernels/ops/kimi_k3/GEMM_AR_README.md.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import torch
from sglang.srt.environ import envs
if TYPE_CHECKING:
from sglang.srt.layers.linear import RowParallelLinear
logger = logging.getLogger(__name__)
_INITIALIZED = False
_ENABLED = False
def _init() -> bool:
global _INITIALIZED, _ENABLED
if _INITIALIZED:
return _ENABLED
_INITIALIZED = True
if not envs.SGLANG_K3_GEMM_AR.get():
return False
from sglang.srt.runtime_context import get_parallel
world_size = get_parallel().tp_size
if not (2 <= world_size <= 8):
logger.warning("SGLANG_K3_GEMM_AR requires 2 <= TP <= 8; disabled.")
return False
if torch.cuda.get_device_capability() < (10, 0):
logger.warning("SGLANG_K3_GEMM_AR requires SM100+; disabled.")
return False
_ENABLED = True
logger.info("K3 fused o_proj GEMM+AR enabled (world_size=%d)", world_size)
return True
def maybe_wrap_o_proj(o_proj: RowParallelLinear) -> None:
"""SGLANG_K3_GEMM_AR: route decode-shaped o_proj calls through the fused
GEMM+all-reduce kernel; everything else falls through to the original
forward. The comm region + JIT compile happen HERE (model build, weight
shape known, well before CUDA-graph capture) capture must only see the
ready-to-launch path."""
if not _init():
return
from sglang.kernels.ops.kimi_k3 import gemm_ar as mod
from sglang.srt.distributed.parallel_state import get_tp_group
from sglang.srt.runtime_context import get_parallel
parallel = get_parallel()
world_size = parallel.tp_size
if not o_proj.reduce_results or o_proj.tp_size != world_size:
return
weight = o_proj.weight
if not (
isinstance(weight, torch.Tensor)
and weight.shape[0] == mod.N
and weight.shape[1] % 128 == 0
):
return
mod.init(
world_size=world_size,
rank=parallel.tp_rank,
group=get_tp_group().cpu_group,
k=weight.shape[1],
)
# per-K compile + base-address stash, pre-capture
mod._module_with_bases(weight.shape[1], world_size)
inner = o_proj.forward
def _gemm_ar_forward(x, *args, **kwargs):
weight = o_proj.weight
if (
isinstance(weight, torch.Tensor)
and weight.dtype == torch.bfloat16
and mod.fits(x)
):
return mod.o_proj_gemm_ar(x, weight), None
return inner(x, *args, **kwargs)
o_proj.forward = _gemm_ar_forward
@@ -0,0 +1,470 @@
"""K3 SP-MoE MNNVL reduce-scatter/all-gather dispatch.
The reduce-scatter folds the pending attention residual into its reduction
epilogue. The matching all-gather reassembles the token shards after MoE.
Both reuse CustomAllReduceV2's push workspace and fall back as a pair outside
the checked-in GB300 tuning envelope.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.environ import envs
from sglang.srt.layers import k3_ar_fusion
if TYPE_CHECKING:
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.distributed.parallel_state import GroupCoordinator
logger = logging.getLogger(__name__)
_HIDDEN_SIZE = 7168
_SUPPORTED_WORLD_SIZES = (4, 8)
# Named persistent symmetric buffers, one per NVLS-aliased tensor. Every rank
# must resolve the same (buffer, offset) for these, which a per-forward
# allocation does not give -- see k3_ar_fusion.symm_buffer.
_O_PROJ = "sp_o_proj" # TP-partial o_proj output, read by the pull RS
_ALL_GATHER = "sp_all_gather" # full-batch output of the direct AG
_ATTN_RES_AG = "sp_attn_res_ag" # ... and of its attention-residual fusion
class _State:
def __init__(self, group: GroupCoordinator, comm: CustomAllReduceV2):
self.group = group
self.comm = comm
_STATE: Optional[_State] = None
_INITIALIZED = False
_O_PROJ_RESULT_BUFFERS: dict[int, torch.Tensor] = {}
_O_PROJ_OUTPUT_ROWS: ContextVar[Optional[int]] = ContextVar(
"k3_sp_o_proj_output_rows", default=None
)
def _init_state() -> Optional[_State]:
global _STATE, _INITIALIZED
if _INITIALIZED:
return _STATE
_INITIALIZED = True
explicit = envs.SGLANG_K3_SP_COLLECTIVE.is_set()
if explicit and not envs.SGLANG_K3_SP_COLLECTIVE.get():
return None
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel, get_server_args
from sglang.srt.utils.common import get_device_sm
server_args = get_server_args()
a2a = server_args.moe_a2a_backend
group = get_parallel().attn_tp_group
comm = group.ca_comm
if (
get_device_sm() != 103
or group.world_size not in _SUPPORTED_WORLD_SIZES
or a2a not in ("megamoe", "deepep")
or not isinstance(comm, CustomAllReduceV2)
or comm.disabled
or comm.mc_base_ptr == 0
):
message = (
"K3 SP collective requires SM103, TP4/TP8, MegaMoE/DeepEP, and "
"CustomAllReduceV2 with multicast; using NCCL."
)
(logger.warning if explicit else logger.info)(message)
return None
from sglang.kernels.ops.kimi_k3 import attn_res, sp_collective
# Refuse to enable without a checked-in table for this exact device.
if (
sp_collective.get_dispatch(
"reduce_scatter",
group.world_size,
_HIDDEN_SIZE,
1,
torch.device("cuda"),
)
is None
):
(logger.warning if explicit else logger.info)(
"K3 SP collective has no tuning table for %s; using NCCL.",
torch.cuda.get_device_name(),
)
return None
sp_collective.register_comm(comm.obj, pull_sem_mc_ptr=comm.pull_sem_mc_ptr)
attn_res.register_comm(comm.obj, pull_sem_mc_ptr=comm.pull_sem_mc_ptr)
_STATE = _State(group, comm)
logger.info(
"K3 SP collective enabled (TP%d, fused RS residual + AG)",
group.world_size,
)
if envs.SGLANG_K3_SP_ATTN_RES.get():
logger.info(
"K3 SP attention-residual carry enabled "
"(local agg/bank write + normalized AG)"
)
return _STATE
def enabled() -> bool:
return _init_state() is not None
def _symm_buffer(
state: _State, name: str, rows: int, width: int, dtype: torch.dtype
) -> torch.Tensor:
"""Named persistent symmetric buffer over the group this module reduces on."""
return k3_ar_fusion.symm_buffer(
name, rows, width, dtype, group_name=state.group.cpu_group.group_name
)
def requires_symmetric_rs(num_tokens: int, device: torch.device) -> bool:
"""Whether standalone or fused RS reads o_proj through its NVLS alias."""
state = _init_state()
if state is None:
return False
from sglang.kernels.ops.kimi_k3 import sp_collective
dispatch = sp_collective.get_dispatch(
"reduce_scatter",
state.group.world_size,
_HIDDEN_SIZE,
num_tokens,
device,
)
if dispatch is not None and dispatch.strategy == "pull":
return True
if not envs.SGLANG_K3_SP_ATTN_RES.get():
return False
fusion = sp_collective.get_fusion_dispatch(
"reduce_scatter_attn_res",
state.group.world_size,
_HIDDEN_SIZE,
num_tokens,
device,
)
return fusion is not None and fusion.strategy == "fused_pull"
def get_o_proj_output_buffer(
num_tokens: int, dtype: torch.dtype, hidden_size: int = _HIDDEN_SIZE
) -> torch.Tensor:
"""Persistent symmetric storage for a TP-partial o_proj output."""
state = _init_state()
assert state is not None, "K3 SP collective is not initialized"
return _symm_buffer(state, _O_PROJ, num_tokens, hidden_size, dtype)
@contextmanager
def o_proj_output_rows(num_rows: int):
"""Tell o_proj to target a padded full-batch symmetric output."""
token = _O_PROJ_OUTPUT_ROWS.set(num_rows)
try:
yield
finally:
_O_PROJ_OUTPUT_ROWS.reset(token)
def get_o_proj_output_rows(default: int) -> int:
num_rows = _O_PROJ_OUTPUT_ROWS.get()
return default if num_rows is None else num_rows
def register_o_proj_output(result: torch.Tensor, output: torch.Tensor) -> None:
"""Associate a model-visible o_proj result/view with its symmetric backing."""
if (
result.ndim != output.ndim
or result.shape[1:] != output.shape[1:]
or result.shape[0] > output.shape[0]
or result.dtype != output.dtype
):
raise RuntimeError(
"K3 o_proj result does not match its persistent symmetric output"
)
_O_PROJ_RESULT_BUFFERS[result.data_ptr()] = output
def finish_padded_o_proj_output(
result: torch.Tensor, num_padded: int
) -> Optional[torch.Tensor]:
"""Zero the padding tail and return the full persistent symmetric buffer."""
output = _O_PROJ_RESULT_BUFFERS.get(result.data_ptr())
if output is None or output.shape[0] != num_padded:
return None
output[result.shape[0] :].zero_()
_O_PROJ_RESULT_BUFFERS[output.data_ptr()] = output
return output
def _resolve_symmetric_o_proj_input(tensor: torch.Tensor) -> tuple[torch.Tensor, int]:
"""Recover the persistent o_proj buffer if a model wrapper rebound its view.
The second element is 0 when neither is symmetric, and the caller falls back.
"""
input_mc_ptr = k3_ar_fusion.find_mc_ptr(tensor)
if input_mc_ptr is not None:
return tensor, input_mc_ptr
output = _O_PROJ_RESULT_BUFFERS.get(tensor.data_ptr())
if output is None:
return tensor, 0
return output, k3_ar_fusion.find_mc_ptr(output) or 0
def _eligible(
state: _State,
tensor: torch.Tensor,
residual: Optional[torch.Tensor] = None,
) -> bool:
if (
tensor.dtype != torch.bfloat16
or not tensor.is_contiguous()
or tensor.ndim != 2
or tensor.shape[1] != _HIDDEN_SIZE
or tensor.shape[0] <= 0
or tensor.shape[0] % state.group.world_size != 0
):
return False
if residual is not None:
local_shape = (tensor.shape[0] // state.group.world_size, tensor.shape[1])
if (
residual.shape not in (tensor.shape, local_shape)
or residual.dtype != tensor.dtype
or not residual.is_contiguous()
):
return False
local_bytes = tensor.numel() * tensor.element_size() // state.group.world_size
return local_bytes <= state.comm.max_push_size
def reduce_scatter_res(
tensor: torch.Tensor, residual: Optional[torch.Tensor]
) -> Optional[torch.Tensor]:
"""Return a fused local shard, or None when the NCCL fallback should run."""
state = _init_state()
if state is None or not _eligible(state, tensor, residual):
return None
from sglang.kernels.ops.kimi_k3 import sp_collective
dispatch = sp_collective.get_dispatch(
"reduce_scatter",
state.group.world_size,
tensor.shape[1],
tensor.shape[0],
tensor.device,
)
if dispatch is None:
return None
output = torch.empty(
(tensor.shape[0] // state.group.world_size, tensor.shape[1]),
dtype=tensor.dtype,
device=tensor.device,
)
if dispatch.strategy == "push":
return sp_collective.reduce_scatter_res(
state.group.world_size,
tensor,
output,
residual,
tuning=dispatch.tuning,
)
if dispatch.strategy == "pull":
tensor, input_mc_ptr = _resolve_symmetric_o_proj_input(tensor)
if input_mc_ptr == 0:
logger.warning("K3 pull RS input is not symmetric; using NCCL.")
return None
return sp_collective.reduce_scatter_pull(
state.group.world_size,
tensor,
output,
residual,
input_mc_ptr=input_mc_ptr,
tuning=dispatch.tuning,
)
raise AssertionError(f"unknown K3 reduce-scatter strategy: {dispatch.strategy}")
def reduce_scatter_attn_res(
tensor: torch.Tensor,
residual: Optional[torch.Tensor],
bank: torch.Tensor,
cw: torch.Tensor,
ow: torch.Tensor,
nvb: int,
eps: float,
) -> Optional[tuple[torch.Tensor, torch.Tensor]]:
"""Fused NVLS pull RS + local residual + attention-residual aggregation."""
state = _init_state()
if (
state is None
or not envs.SGLANG_K3_SP_ATTN_RES.get()
or not _eligible(state, tensor, residual)
):
return None
local_tokens = tensor.shape[0] // state.group.world_size
if (
bank.shape[0] != local_tokens
or bank.ndim != 3
or bank.shape[2] != _HIDDEN_SIZE
or not bank.is_contiguous()
or cw.shape != (_HIDDEN_SIZE,)
or ow.shape != (_HIDDEN_SIZE,)
):
return None
from sglang.kernels.ops.kimi_k3 import attn_res, sp_collective
dispatch = sp_collective.get_fusion_dispatch(
"reduce_scatter_attn_res",
state.group.world_size,
tensor.shape[1],
tensor.shape[0],
tensor.device,
)
if dispatch is None or dispatch.strategy != "fused_pull":
return None
tensor, input_mc_ptr = _resolve_symmetric_o_proj_input(tensor)
if input_mc_ptr == 0:
logger.warning("K3 fused pull RS input is not symmetric; using separate path.")
return None
normed = torch.empty(
(local_tokens, tensor.shape[1]), dtype=tensor.dtype, device=tensor.device
)
prefix = torch.empty_like(normed)
attn_res.attn_res_fused_pull_rs(
state.group.world_size,
tensor,
residual,
bank,
cw,
ow,
normed,
prefix,
nvb,
eps,
input_mc_ptr=input_mc_ptr,
max_blocks=dispatch.max_blocks,
)
return normed, prefix
def all_gather(tensor: torch.Tensor) -> Optional[torch.Tensor]:
"""Return the reassembled full batch, or None for the NCCL fallback."""
state = _init_state()
if state is None:
return None
global_tokens = tensor.shape[0] * state.group.world_size
if (
tensor.dtype != torch.bfloat16
or not tensor.is_contiguous()
or tensor.ndim != 2
or tensor.shape[1] != _HIDDEN_SIZE
or tensor.shape[0] <= 0
or tensor.numel() * tensor.element_size() > state.comm.max_push_size
):
return None
from sglang.kernels.ops.kimi_k3 import sp_collective
dispatch = sp_collective.get_dispatch(
"all_gather",
state.group.world_size,
tensor.shape[1],
global_tokens,
tensor.device,
)
if dispatch is None:
return None
output_shape = (global_tokens, tensor.shape[1])
if dispatch.strategy == "push":
output = torch.empty(output_shape, dtype=tensor.dtype, device=tensor.device)
return sp_collective.all_gather(
state.group.world_size,
tensor,
output,
ws_mc_base=state.comm.mc_base_ptr,
tuning=dispatch.tuning,
)
if dispatch.strategy == "direct":
output = _symm_buffer(
state, _ALL_GATHER, global_tokens, tensor.shape[1], tensor.dtype
)
return sp_collective.all_gather_direct(
state.group.world_size,
tensor,
output,
output_mc_ptr=k3_ar_fusion.get_mc_ptr(output),
tuning=dispatch.tuning,
)
raise AssertionError(f"unknown K3 all-gather strategy: {dispatch.strategy}")
def attn_res_all_gather(
prefix: torch.Tensor,
bank: torch.Tensor,
cw: torch.Tensor,
ow: torch.Tensor,
nvb: int,
eps: float,
*,
write_prefix: bool = False,
) -> Optional[torch.Tensor]:
"""Fused local attention-residual aggregation + direct multicast AG."""
state = _init_state()
if (
state is None
or not envs.SGLANG_K3_SP_ATTN_RES.get()
or prefix.dtype != torch.bfloat16
or not prefix.is_contiguous()
or prefix.ndim != 2
or prefix.shape[1] != _HIDDEN_SIZE
or prefix.shape[0] <= 0
or bank.ndim != 3
or bank.shape[0] != prefix.shape[0]
or bank.shape[2] != _HIDDEN_SIZE
or not bank.is_contiguous()
or cw.shape != (_HIDDEN_SIZE,)
or ow.shape != (_HIDDEN_SIZE,)
):
return None
from sglang.kernels.ops.kimi_k3 import attn_res, sp_collective
global_tokens = prefix.shape[0] * state.group.world_size
dispatch = sp_collective.get_fusion_dispatch(
"attn_res_all_gather",
state.group.world_size,
prefix.shape[1],
global_tokens,
prefix.device,
)
if dispatch is None or dispatch.strategy != "fused_direct":
return None
output = _symm_buffer(
state, _ATTN_RES_AG, global_tokens, prefix.shape[1], prefix.dtype
)
attn_res.attn_res_fused_direct_ag(
state.group.world_size,
prefix,
bank,
cw,
ow,
output,
nvb,
eps,
output_mc_ptr=k3_ar_fusion.get_mc_ptr(output),
max_blocks=dispatch.max_blocks,
write_prefix=write_prefix,
)
return output
+21 -2
View File
@@ -1560,7 +1560,13 @@ class RowParallelLinear(LinearBase):
# Fallback for parameters that don't accept additional args
param.load_row_parallel_weight(loaded_weight)
def forward(self, input_, skip_all_reduce=False, forward_batch=None):
def forward(
self,
input_,
skip_all_reduce=False,
forward_batch=None,
output_tensor=None,
):
if self.input_is_parallel:
input_parallel = input_
else:
@@ -1581,7 +1587,20 @@ class RowParallelLinear(LinearBase):
get_tp_group(), disabled=not is_allocation_symmetric()
)
with symm_ctx:
output_parallel = self.quant_method.apply(self, input_parallel, bias=bias_)
if output_tensor is None:
output_parallel = self.quant_method.apply(
self, input_parallel, bias=bias_
)
else:
apply_into = getattr(self.quant_method, "apply_into", None)
if apply_into is None:
raise RuntimeError(
f"{type(self.quant_method).__name__} cannot write into "
"caller-owned linear output"
)
output_parallel = apply_into(
self, input_parallel, output_tensor, bias=bias_
)
# skip_all_reduce: explicit call-site override. Also honor
# ForwardFlags (fuse_mlp_allreduce / mlp_reduce_scatter) published by
+11 -2
View File
@@ -125,6 +125,15 @@ class DeepEPMoE(FusedMoE):
in ("modelopt_fp4", "modelopt_mixed", "nvfp4_online")
):
self.deprecate_flag = True
elif (
deep_gemm_wrapper.ENABLE_JIT_DEEPGEMM
and get_moe_runner_backend().is_deep_gemm()
and quant_config is not None
and quant_config.get_name() == "mxfp4"
):
# MXFP4 experts (e.g. Kimi K3) on the DeepGEMM fp8_fp4 W4A8 path:
# route through the modern FusedMoE runner (Mxfp4MoEMethod.apply).
self.deprecate_flag = True
elif (
quant_config is None
and self.w13_weight.dtype == torch.bfloat16
@@ -322,7 +331,7 @@ class DeepEPMoE(FusedMoE):
self,
dispatch_output: DeepEPNormalDispatchOutput,
):
assert self.moe_runner_config.activation == "silu"
assert self.moe_runner_config.activation in ("silu", "situ")
assert isinstance(self.quant_method, W4AFp8MoEMethod)
return self.quant_method.apply_deepep_normal(
layer=self,
@@ -333,7 +342,7 @@ class DeepEPMoE(FusedMoE):
self,
dispatch_output: DeepEPLLDispatchOutput,
):
assert self.moe_runner_config.activation == "silu"
assert self.moe_runner_config.activation in ("silu", "situ")
assert isinstance(self.quant_method, W4AFp8MoEMethod)
return self.quant_method.apply_deepep_ll(
layer=self,
@@ -6,7 +6,7 @@ It is based on https://github.com/pytorch-labs/gpt-fast/blob/32971d3129541c5bfb4
import torch
from torch.nn import functional as F
from sglang.srt.layers.activation import GeluAndMul, SiluAndMul
from sglang.srt.layers.activation import GeluAndMul, SiluAndMul, SituAndMul
from sglang.srt.layers.moe.moe_runner import MoeRunnerConfig
from sglang.srt.layers.moe.moe_runner.triton_utils.fused_moe import (
swiglu_gpt_oss_sigmoid_alpha,
@@ -40,6 +40,14 @@ def fused_moe_forward_native(
x1 = F.silu(x1)
elif moe_runner_config.activation == "gelu":
x1 = F.gelu(x1)
elif moe_runner_config.activation == "situ":
beta = (
moe_runner_config.gemm1_alpha
if moe_runner_config.gemm1_alpha is not None
else 4.0
)
x1 = beta * torch.tanh(x1.float() / beta) * torch.sigmoid(x1.float())
x1 = x1.to(x.dtype)
else:
raise ValueError(f"Unsupported activation: {moe_runner_config.activation=}")
x3 = torch.einsum("ti, taoi -> tao", x, w3_weights)
@@ -77,6 +85,14 @@ def moe_forward_native(
act = SiluAndMul()
elif moe_runner_config.activation == "gelu":
act = GeluAndMul()
elif moe_runner_config.activation == "situ":
situ_beta = (
moe_runner_config.gemm1_alpha
if moe_runner_config.gemm1_alpha is not None
else 4.0
)
situ_linear_beta = moe_runner_config.gemm1_clamp_limit
act = SituAndMul(beta=situ_beta, linear_beta=situ_linear_beta)
else:
raise ValueError(f"Unsupported activation: {moe_runner_config.activation=}")
@@ -2,7 +2,10 @@ from typing import Optional
import torch
import torch.nn.functional as F
import triton
import triton.language as tl
from sglang.srt.layers import zero_copy_context
from sglang.srt.utils import is_cuda
from sglang.srt.utils.custom_op import register_custom_op
@@ -15,6 +18,69 @@ if _is_cuda:
from sglang.kernels.ops.moe.moe_wna16_marlin import moe_wna16_marlin_gemm
@triton.jit
def _tl_tanh(x):
return 2.0 * tl.sigmoid(2.0 * x) - 1.0
@triton.jit
def _situ_and_mul_kernel(
x_ptr, # [M, 2N] gate;up halves (non-interleaved)
out_ptr, # [M, N]
N,
situ_beta,
linear_beta,
stride_xm,
stride_om,
BLOCK_N: tl.constexpr,
HAS_LINEAR_BETA: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
mask = offs < N
base = x_ptr + pid_m * stride_xm
gate = tl.load(base + offs, mask=mask, other=0.0).to(tl.float32)
up = tl.load(base + N + offs, mask=mask, other=0.0).to(tl.float32)
gate = situ_beta * _tl_tanh(gate / situ_beta) * tl.sigmoid(gate)
if HAS_LINEAR_BETA:
up = linear_beta * _tl_tanh(up / linear_beta)
out = gate * up
tl.store(
out_ptr + pid_m * stride_om + offs,
out.to(out_ptr.dtype.element_ty),
mask=mask,
)
def situ_and_mul(
output: torch.Tensor,
x: torch.Tensor,
situ_beta: float,
linear_beta: Optional[float],
) -> None:
"""SiTU gated activation (Kimi K3), fused into one elementwise kernel:
out = situ_beta*tanh(gate/situ_beta)*sigmoid(gate) * linear_beta*tanh(up/linear_beta)
where x = [gate; up] halves along the last dim.
"""
M, N2 = x.shape
N = N2 // 2
assert output.shape == (M, N)
BLOCK_N = 1024
grid = (M, triton.cdiv(N, BLOCK_N))
_situ_and_mul_kernel[grid](
x,
output,
N,
float(situ_beta),
float(linear_beta) if linear_beta is not None else 0.0,
x.stride(0),
output.stride(0),
BLOCK_N=BLOCK_N,
HAS_LINEAR_BETA=linear_beta is not None,
)
def get_scalar_type(
num_bits: int,
has_zp: bool,
@@ -177,9 +243,25 @@ def fused_marlin_moe(
if global_num_experts == -1:
global_num_experts = E
sorted_token_ids, expert_ids, num_tokens_post_padded = moe_align_block_size(
topk_ids, block_size_m, global_num_experts
)
if (
M == 1
and topk <= 32
and expert_map is None
# The JIT kernel is int32-only; torch-native topk emits int64 -- let
# that (test-only) shape take the generic path instead of casting.
and topk_ids.dtype == torch.int32
):
# Single-token decode: top-k ids are distinct, so alignment is a
# single-warp sort instead of the align + count_and_sort kernel pair.
from sglang.kernels.ops.moe.moe_align_single_token import moe_align_single_token
sorted_token_ids, expert_ids, num_tokens_post_padded = moe_align_single_token(
topk_ids, block_size_m
)
else:
sorted_token_ids, expert_ids, num_tokens_post_padded = moe_align_block_size(
topk_ids, block_size_m, global_num_experts
)
if workspace is None:
max_workspace_size = (max(2 * N, K) // 64) * (
@@ -266,6 +348,13 @@ def fused_marlin_moe(
)
elif activation == "silu" and is_gated:
silu_and_mul(intermediate_cache1.view(-1, gemm1_n), intermediate_cache2)
elif activation == "situ" and is_gated:
situ_and_mul(
intermediate_cache2,
intermediate_cache1.view(-1, gemm1_n),
situ_beta=gemm1_alpha if gemm1_alpha is not None else 4.0,
linear_beta=clamp_limit,
)
elif activation == "silu" and not is_gated:
intermediate_cache2 = F.silu(intermediate_cache1.view(-1, N))
elif activation == "relu2" and not is_gated:
@@ -305,10 +394,28 @@ def fused_marlin_moe(
is_zp_float=False,
).view(-1, topk, K)
output = hidden_states if inplace else torch.empty_like(hidden_states)
output = zero_copy_context.get_moe_output(hidden_states)
if output is None:
output = hidden_states if inplace else torch.empty_like(hidden_states)
if is_mxfp4_marlin:
return torch.sum(intermediate_cache3, dim=1, out=output)
# Top-k weights (incl. routed scaling) are already applied above via
# mul_topk_weights, so this is a plain sum over the topk dim. The JIT
# vectorized pass (~1.5us at decode shapes) beats sgl_kernel's
# moe_sum_reduce_kernel_general (~5.7us) and the generic at::native
# reduce_kernel torch.sum dispatches to (~6.7us).
if (
intermediate_cache3.dtype == torch.bfloat16
and intermediate_cache3.is_contiguous()
and output.is_contiguous()
and intermediate_cache3.shape[-1] % 8 == 0
):
from sglang.kernels.ops.moe.moe_topk_sum import moe_topk_sum
moe_topk_sum(intermediate_cache3, output)
else:
moe_sum_reduce(intermediate_cache3, output, 1.0)
return output
else:
if routed_scaling_factor is None:
routed_scaling_factor = 1.0
@@ -658,13 +658,32 @@ class FusedMoE(torch.nn.Module):
if not is_bias and self.use_triton_kernels:
# do not transpose for bias
loaded_weight = loaded_weight.transpose(-2, -1)
# When the buffer is padded (e.g., MXFP4 SM100 rounds
# intermediate up to 256), shard_size from the buffer may
# exceed the checkpoint's per-TP slice. Derive the actual
# shard size from the loaded weight so we index correctly.
loaded_shard_size = loaded_weight.shape[shard_dim] // self.moe_tp_size
loaded_weight = loaded_weight.narrow(
shard_dim, shard_size * tp_rank, shard_size
shard_dim, loaded_shard_size * tp_rank, loaded_shard_size
)
expert_data = expert_data.narrow(shard_dim, start, shard_size)
loaded_weight = _maybe_copy_weight_view_before_h2d(loaded_weight)
expert_data.copy_(loaded_weight)
# loaded_weight may be smaller than expert_data along shard_dim when
# the buffer is padded. Copy into the leading slice and leave the
# trailing padding as zeros. Rank-mismatched tensors (bias / scalar
# scales) don't carry shard_dim; they keep the plain broadcast copy.
if (
loaded_weight.dim() == expert_data.dim()
and shard_dim < expert_data.dim()
and loaded_weight.shape[shard_dim] < expert_data.shape[shard_dim]
):
expert_data.narrow(shard_dim, 0, loaded_weight.shape[shard_dim]).copy_(
loaded_weight
)
else:
expert_data.copy_(loaded_weight)
def _load_w2(
self,
@@ -729,13 +748,28 @@ class FusedMoE(torch.nn.Module):
if not is_bias and not self.use_presharded_weights:
if self.use_triton_kernels:
loaded_weight = loaded_weight.transpose(-2, -1)
# Derive shard size from the loaded weight so padded buffers
# do not cause out-of-bounds indexing into the checkpoint.
loaded_shard_size = loaded_weight.shape[shard_dim] // self.moe_tp_size
loaded_weight = loaded_weight.narrow(
shard_dim, shard_size * tp_rank, shard_size
shard_dim, loaded_shard_size * tp_rank, loaded_shard_size
)
# w2, down_proj: Load into only logical weight of w2.
loaded_weight = _maybe_copy_weight_view_before_h2d(loaded_weight)
expert_data.copy_(loaded_weight)
# loaded_weight may be smaller than expert_data along shard_dim when
# the buffer is padded. Copy into the leading slice only. See the
# rank-mismatch note in _load_w13.
if (
loaded_weight.dim() == expert_data.dim()
and shard_dim < expert_data.dim()
and loaded_weight.shape[shard_dim] < expert_data.shape[shard_dim]
):
expert_data.narrow(shard_dim, 0, loaded_weight.shape[shard_dim]).copy_(
loaded_weight
)
else:
expert_data.copy_(loaded_weight)
def _maybe_load_fp8_shared_expert_as_fp4(
self,
@@ -873,7 +907,7 @@ class FusedMoE(torch.nn.Module):
# if expert_id is None, then
# all the experts are loaded at the same time
if (
not expert_id
expert_id is None
and self.quant_config is not None
and self.quant_config.get_name() == "mxfp4"
and self.quant_config.is_static_cfg()
@@ -91,7 +91,11 @@ class AiterRunnerOutput(RunnerOutput):
return MoeRunnerBackend.AITER
_AITER_ACTIVATIONS = {"silu": "Silu", "swiglu": "Swiglu"}
_AITER_ACTIVATIONS = {
"silu": "Silu",
"swiglu": "Swiglu",
"situ": "Situv2",
}
def _aiter_activation(activation: str):
@@ -162,7 +166,15 @@ class AiterRunnerCore(MoeRunnerCore):
extra["num_local_tokens"] = runner_input.num_local_tokens
if runner_input.output_dtype is not None:
extra["dtype"] = runner_input.output_dtype
if quant_info.swiglu_limit > 0:
if self.config.activation == "situ":
from aiter.ops.flydsl.moe_common import GateMode
extra["gate_mode"] = GateMode.SEPARATED.value
if self.config.gemm1_alpha is not None:
extra["beta"] = float(self.config.gemm1_alpha)
if self.config.gemm1_clamp_limit is not None:
extra["linear_beta"] = float(self.config.gemm1_clamp_limit)
elif quant_info.swiglu_limit > 0:
# GateMode is only needed for the gpt-oss MXFP4 swiglu_limit path.
# Import lazily so models that don't use it (e.g. DeepSeek-V3 fp8,
# swiglu_limit==0) still run on aiter builds where this module
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Any, List, Optional, Tuple
import einops
import torch
import triton
import triton.language as tl
from sglang.kernels.ops.attention.dsv4 import silu_and_mul_masked_post_quant
from sglang.kernels.ops.quantization import per_token_group_quant
@@ -165,7 +167,9 @@ class DeepGemmMoeQuantInfo(MoeQuantInfo):
class DeepGemmRunnerCore(MoeRunnerCore):
def __init__(self, config: MoeRunnerConfig):
super().__init__(config)
assert self.config.activation == "silu"
# SiTU (Kimi K3) is applied outside the GEMMs in python, so it only
# needs the masked-gemm activation site to branch (see _run_masked_gemm).
assert self.config.activation in ("silu", "situ")
assert self.config.is_gated
self.swiglu_limit = self.config.swiglu_limit
self.use_swizzle = False
@@ -256,7 +260,61 @@ class DeepGemmRunnerCore(MoeRunnerCore):
dispose_tensor(hidden_states)
dispose_tensor(hidden_states_scale)
if envs.SGLANG_OPT_FIX_MEGA_MOE_MEMORY.get():
if self.config.activation == "situ":
situ_beta = self.config.gemm1_alpha
situ_linear_beta = self.config.gemm1_clamp_limit
assert situ_beta is not None and situ_linear_beta is not None
if deep_gemm_wrapper.DEEPGEMM_SCALE_UE8M0:
# Fused SiTU + per-group fp8 quant over the compacted rows,
# then the proven round-up e8m0 cast (mn-major packed layout).
rows = gateup_output.shape[0]
half_n = N // 2
kg = half_n // scale_block_size
down_input_fp8 = torch.empty(
(rows, half_n),
device=gateup_output.device,
dtype=torch.float8_e4m3fn,
)
s = torch.empty(
(rows, kg), device=gateup_output.device, dtype=torch.float32
)
_situ_mul_quant_contig_kernel[(rows,)](
gateup_output,
down_input_fp8,
s,
half_n,
kg,
situ_beta,
situ_linear_beta,
GROUP=scale_block_size,
KG_POW2=triton.next_power_of_2(kg),
num_warps=8,
)
del gateup_output
down_input_scale = _cast_to_e8m0_with_rounding_up(
s.unsqueeze(0)
).squeeze(0)
else:
from sglang.kernels.ops.quantization.fp8_kernel import (
sglang_per_token_group_quant_fp8,
)
gate = gateup_output[:, : N // 2].float()
up = gateup_output[:, N // 2 :].float()
gate = situ_beta * torch.tanh(gate / situ_beta) * torch.sigmoid(gate)
up = situ_linear_beta * torch.tanh(up / situ_linear_beta)
down_input = (gate * up).to(torch.bfloat16)
del gateup_output
down_input_fp8, down_input_scale = sglang_per_token_group_quant_fp8(
down_input,
scale_block_size,
column_major_scales=False,
scale_tma_aligned=False,
scale_ue8m0=False,
)
del down_input
elif envs.SGLANG_OPT_FIX_MEGA_MOE_MEMORY.get():
swiglu_limit_arg: Optional[float] = self.swiglu_limit
down_input_fp8 = torch.empty(
@@ -519,28 +577,38 @@ class DeepGemmRunnerCore(MoeRunnerCore):
)
# Act.
topk_ids_rs = running_state.get("topk_ids")
num_real_tokens = (
topk_ids_rs.shape[0]
if (
use_mxfp8
and deep_gemm_wrapper.DEEPGEMM_SCALE_UE8M0
and topk_ids_rs is not None
and "src2dst" in running_state
if self.config.activation == "situ":
down_input, down_input_scale = _varlen_deep_gemm_situ_mul_quant(
gateup_output,
masked_m,
group_size=128,
topk=self.config.top_k,
beta=self.config.gemm1_alpha,
linear_beta=self.config.gemm1_clamp_limit,
)
else:
topk_ids_rs = running_state.get("topk_ids")
num_real_tokens = (
topk_ids_rs.shape[0]
if (
use_mxfp8
and deep_gemm_wrapper.DEEPGEMM_SCALE_UE8M0
and topk_ids_rs is not None
and "src2dst" in running_state
)
else None
)
down_input, down_input_scale = _varlen_deep_gemm_silu_mul_quant(
gateup_output,
masked_m,
group_size=scale_block_size,
topk=self.config.top_k,
swiglu_limit=swiglu_limit_arg,
swizzle=self.use_swizzle,
gemm1_alpha=self.config.gemm1_alpha,
gemm1_clamp_limit=self.config.gemm1_clamp_limit,
num_real_tokens=num_real_tokens,
)
else None
)
down_input, down_input_scale = _varlen_deep_gemm_silu_mul_quant(
gateup_output,
masked_m,
group_size=scale_block_size,
topk=self.config.top_k,
swiglu_limit=swiglu_limit_arg,
swizzle=self.use_swizzle,
gemm1_alpha=self.config.gemm1_alpha,
gemm1_clamp_limit=self.config.gemm1_clamp_limit,
num_real_tokens=num_real_tokens,
)
del gateup_output
# Down activation is quantised locally at scale_block_size (never DeepEP-LL),
@@ -885,10 +953,11 @@ def post_permute_deep_gemm_to_standard(
hidden_states_shape = running_state["hidden_states_shape"]
hidden_states_dtype = running_state["hidden_states_dtype"]
hidden_states_device = running_state["hidden_states_device"]
src2dst = running_state["src2dst"]
topk_ids = running_state["topk_ids"]
topk_weights = running_state["topk_weights"]
src2dst = running_state["src2dst"]
with use_symmetric_memory(get_tp_group(), disabled=not is_allocation_symmetric()):
output = torch.empty(
hidden_states_shape, dtype=hidden_states_dtype, device=hidden_states_device
@@ -975,7 +1044,7 @@ def pre_permute_deepep_normal_to_deep_gemm(
topk_weights,
num_recv_tokens_per_expert,
) = dispatch_output
assert runner_config.activation == "silu"
assert runner_config.activation in ("silu", "situ")
all_tokens = sum(num_recv_tokens_per_expert)
running_state["all_tokens"] = all_tokens
@@ -1090,6 +1159,53 @@ def post_permute_deep_gemm_to_deepep_normal(
)
def _varlen_deep_gemm_situ_mul_quant(
gateup_output: torch.Tensor,
masked_m: torch.Tensor,
group_size: int,
topk: int,
beta: float,
linear_beta: float,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Fused SiTU activation + per-group fp8 quant via CUDA JIT kernel."""
from sglang.kernels.ops.kimi_k3 import situ_and_mul_masked_post_quant
E, N, D_2 = gateup_output.shape
D = D_2 // 2
G = D // group_size
packed_ue8m0 = deep_gemm_wrapper.DEEPGEMM_SCALE_UE8M0
down_input = torch.empty(
(E, N, D), device=gateup_output.device, dtype=torch.float8_e4m3fn
)
if packed_ue8m0:
down_input_scale = torch.empty(
(E, G // 4, N), device=gateup_output.device, dtype=torch.int32
)
else:
down_input_scale = torch.empty(
(E, N, G), device=gateup_output.device, dtype=torch.float32
)
situ_and_mul_masked_post_quant(
gateup_output,
down_input,
down_input_scale,
group_size,
masked_m,
beta=beta,
linear_beta=linear_beta,
scale_ue8m0=packed_ue8m0,
topk=topk,
transposed=packed_ue8m0,
)
if packed_ue8m0:
down_input_scale = down_input_scale.transpose(-1, -2)
return down_input, down_input_scale
def _varlen_deep_gemm_silu_mul_quant(
gateup_output: torch.Tensor,
masked_m: Optional[torch.Tensor],
@@ -1202,6 +1318,37 @@ def _varlen_deep_gemm_silu_mul_quant(
)
@triton.jit
def _situ_mul_quant_contig_kernel(
g_ptr, # [rows, 2N] bf16, non-interleaved [gate; up] halves
q_ptr, # [rows, N] fp8 out
s_ptr, # [rows, KG] fp32 scales out
N,
KG,
situ_beta,
situ_linear_beta,
GROUP: tl.constexpr,
KG_POW2: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
rows2d = tl.arange(0, KG_POW2)[:, None]
cols = tl.arange(0, GROUP)[None, :]
offs = rows2d * GROUP + cols
mask = rows2d < KG
gate = tl.load(g_ptr + row * 2 * N + offs, mask=mask, other=0.0).to(tl.float32)
up = tl.load(g_ptr + row * 2 * N + N + offs, mask=mask, other=0.0).to(tl.float32)
# tanh(x) == 2*sigmoid(2x) - 1 (avoids a libdevice dependency)
gate_t = 2.0 * tl.sigmoid(2.0 * gate / situ_beta) - 1.0
gate = situ_beta * gate_t * tl.sigmoid(gate)
up_t = 2.0 * tl.sigmoid(2.0 * up / situ_linear_beta) - 1.0
y = gate * situ_linear_beta * up_t
amax = tl.clamp(tl.max(tl.abs(y), axis=1), min=1e-10, max=float("inf"))
q = (y * (448.0 / amax)[:, None]).to(tl.float8e4nv)
tl.store(q_ptr + row * N + offs, q, mask=mask)
srow = tl.arange(0, KG_POW2)
tl.store(s_ptr + row * KG + srow, amax / 448.0, mask=srow < KG)
def _apply_swiglu_limit(
gateup_output: torch.Tensor, swiglu_limit: float
) -> torch.Tensor:
@@ -140,7 +140,10 @@ def fused_experts_none_to_marlin(
)
if runner_config.is_gated:
assert runner_config.activation == "silu", "Only gated SiLU is supported."
assert runner_config.activation in {
"silu",
"situ",
}, f"Only gated SiLU/SiTU is supported, got {runner_config.activation}."
elif runner_config.activation not in {"silu", "relu2"}:
raise ValueError(
f"Unsupported Marlin MoE activation: {runner_config.activation}"
@@ -685,6 +685,17 @@ def _fused_moe_kernel_sequence(
x = intermediate_cache1.view(-1, N)
d = x.shape[-1] // 2
intermediate_cache2.copy_(F.silu(x[..., :d]) * x[..., d:])
elif activation == "situ" and is_gated:
d = N // 2
x = intermediate_cache1.view(-1, N)
gate = x[..., :d].float()
up = x[..., d:].float()
situ_beta = gemm1_alpha if gemm1_alpha is not None else 4.0
gate = situ_beta * torch.tanh(gate / situ_beta) * torch.sigmoid(gate)
situ_linear_beta = gemm1_limit
if situ_linear_beta is not None:
up = situ_linear_beta * torch.tanh(up / situ_linear_beta)
intermediate_cache2.copy_((gate * up).to(intermediate_cache1.dtype))
elif activation == "gelu" and is_gated:
assert gemm1_alpha is None, "gemm1_alpha is not supported for gelu"
assert gemm1_limit is None, "gemm1_limit is not supported for gelu"
@@ -0,0 +1,130 @@
"""Attempt-and-verify handoff for the fused K3 MoE-front prep launch.
At decode batch sizes the chain between the K3 fused-front GEMM and the
trtllm-gen SiTU MoE op is three tiny back-to-back kernels on the critical
path route_radix (top-16 of 896), the triton ``(id << 16) | bf16(weight)``
pack, and per_token_group_quant (mxfp8, ``[T, 3584]``) about 7.5 us busy
plus two extra launches per MoE layer, each leaving the SMs near idle. The
fused kernel (kernels/ops/moe/moe_route_quant_fused.py) runs all three in one
launch: routing CTAs and one quant CTA per token, concurrently.
The inputs live in different modules (router logits reach the router through
TopK, activations through the MoE runner), so the fusion is wired as a
consume-once stash instead of new signatures:
KimiK3MoE._forward_routed* stage(x) before self.topk, clear() after
the experts call
biased_grouped_topk_gpu try_route_quant_fused() replaces the
moe_fused_gate call on a hit
Mxfp4MoEMethod.apply (situ) take(x) skips the quant and the pack
Every step is fallback-safe: if the routing dispatch never consumes the staged
request (different model, uncovered shape, triton fallback) or the runner's
``take`` misses (activations re-viewed or copied), the unfused chain runs as
before. The stage/clear bracket in the model layer guarantees a published
entry can never leak into another layer whose allocator reused the same
activation address.
"""
from __future__ import annotations
from typing import Optional, Tuple
import msgspec
import torch
class _Handoff(msgspec.Struct):
# staged by the model layer: the activation rows the runner will quantize
request_x: Optional[torch.Tensor] = None
# published by the routing dispatch, keyed by the staged activations
produced_x: Optional[torch.Tensor] = None
packed: Optional[torch.Tensor] = None
x_q: Optional[torch.Tensor] = None
x_s: Optional[torch.Tensor] = None
_handoff = _Handoff()
def stage(x: torch.Tensor) -> None:
"""Publish the routed activations for the upcoming topk call. Caller pairs
this with clear() after the experts call (try/finally)."""
_handoff.request_x = x
_handoff.produced_x = None
def clear() -> None:
_handoff.request_x = None
_handoff.produced_x = None
_handoff.packed = None
_handoff.x_q = None
_handoff.x_s = None
def try_route_quant_fused(
gating_output: torch.Tensor,
correction_bias: torch.Tensor,
topk: int,
*,
num_fused_shared_experts: int,
renormalize: bool,
routed_scaling_factor: Optional[float],
apply_routed_scaling_factor_on_output: bool,
) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
"""Fused replacement for the ungrouped-sigmoid moe_fused_gate call when a
staged request covers it. Returns (weights, ids) on a hit, None otherwise
(caller falls through to the unfused router)."""
x = _handoff.request_x
if x is None or num_fused_shared_experts != 0:
return None
from sglang.kernels.ops.moe import moe_route_quant_fused
if (
not moe_route_quant_fused.covered(gating_output, correction_bias, topk, x)
or not moe_route_quant_fused.available()
):
return None
weights, ids, packed, x_q, x_s = moe_route_quant_fused.route_quant_fused(
gating_output,
correction_bias,
x,
topk,
renormalize=renormalize,
routed_scaling_factor=(
routed_scaling_factor if routed_scaling_factor is not None else 1.0
),
apply_scale=apply_routed_scaling_factor_on_output,
)
_handoff.request_x = None
_handoff.produced_x = x
_handoff.packed = packed
_handoff.x_q = x_q
_handoff.x_s = x_s
return weights, ids
def take(
x: torch.Tensor,
) -> Optional[Tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
"""Consume the published (packed_topk, x_q, x_s int32) for these exact
activation rows, or None. Storage identity is verified so a re-viewed or
copied tensor simply misses."""
produced = _handoff.produced_x
if produced is None:
return None
if (
produced.data_ptr() != x.data_ptr()
or produced.shape != x.shape
or produced.dtype != x.dtype
or produced.stride() != x.stride()
):
return None
out = (_handoff.packed, _handoff.x_q, _handoff.x_s)
_handoff.produced_x = None
_handoff.packed = None
_handoff.x_q = None
_handoff.x_s = None
return out
+88 -3
View File
@@ -1574,7 +1574,22 @@ def biased_grouped_topk_gpu(
return topk_weights, topk_ids
else:
num_experts = gating_output.shape[1]
if _is_cuda and num_experts == 384 and num_expert_group == 1:
# The JIT triton router (single fused kernel: scoring + bias + topk +
# renorm) handles the ungrouped case with arbitrary num_experts/topk.
# Original user: Kimi K2 (384 experts). Also dispatch shapes the other
# fused kernels cannot cover, e.g. Kimi K3 (896 experts, top-16):
# fused_topk_deepseek needs pow2 experts + topk<=8, jit_grouped_topk
# needs experts<=512 + topk<=8.
_jit_gate_ok = (
_is_cuda
and num_expert_group == 1
and (topk_group is None or topk_group == 1)
and (
num_experts == 384
or (num_experts <= 1024 and (num_experts > 512 or topk > 8))
)
)
if _jit_gate_ok:
# ===== TO BE REFACTORED ====
_use_jit_bf16_gate = False
if _SGLANG_EXPERIMENTAL_LORA_OPTI:
@@ -1601,9 +1616,36 @@ def biased_grouped_topk_gpu(
# ===== END TO BE REFACTORED ====
from sglang.kernels.ops.moe.moe_fused_gate import moe_fused_gate as jit_gate
# Pass BF16 logits through untouched for the automatic radix-select
# fast path. BF16 -> FP32 is exact, and unsupported shapes fall back
# inside moe_fused_gate.
_gating = (
gating_output
if gating_output.dtype == torch.bfloat16
else gating_output.to(dtype=torch.float32)
)
# K3 staged fusion: when the model layer staged the routed
# activations (route_quant_handoff), the radix route, the trtllm id
# pack and the mxfp8 quant run as one launch. Bit-identical
# (weights, ids); a miss falls through to the unfused router.
from sglang.srt.layers.moe import route_quant_handoff
fused = route_quant_handoff.try_route_quant_fused(
_gating,
correction_bias.to(dtype=torch.float32),
topk,
num_fused_shared_experts=num_fused_shared_experts,
renormalize=renormalize,
routed_scaling_factor=routed_scaling_factor,
apply_routed_scaling_factor_on_output=bool(
apply_routed_scaling_factor_on_output
),
)
if fused is not None:
return fused
return jit_gate(
gating_output.to(dtype=torch.float32),
correction_bias,
_gating,
correction_bias.to(dtype=torch.float32),
topk=topk,
scoring_func="sigmoid",
num_fused_shared_experts=num_fused_shared_experts,
@@ -2255,6 +2297,49 @@ def select_experts(
return StandardTopKOutput(topk_weights, topk_ids, router_logits)
def precomputed_topk_postprocess_is_noop(
topk_config: TopKConfig,
num_token_non_padded: Optional[torch.Tensor] = None,
expert_location_dispatch_info: Optional[ExpertLocationDispatchInfo] = None,
) -> bool:
"""Whether :func:`build_precomputed_topk_output` can stand in for
:func:`select_experts`' post-processing.
A router that produces (weights, ids) itself -- e.g. K3's fused gate+top-k
kernel -- skips select_experts entirely, so it must not skip the work
select_experts does *after* the top-k: the EPLB logical->physical remap, the
padded-region mask, and the shared-expert append. This returns True only
when all of those reduce to no-ops, leaving just the capture hook and the
distribution recorder (which the builder below still runs).
"""
return (
_is_cuda
and topk_config.num_fused_shared_experts == 0
and num_token_non_padded is None
and expert_location_dispatch_info is None
and not envs.SGLANG_SIMULATE_UNIFORM_EXPERTS.get()
and not envs.SGLANG_SIMULATE_ROUND_ROBIN_EXPERTS.get()
)
def build_precomputed_topk_output(
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
topk_config: TopKConfig,
layer_id: int,
) -> StandardTopKOutput:
"""Wrap a router's own (weights, ids) as a STANDARD top-k output, running the
capture hook and the expert-distribution recorder that select_experts would.
Only valid when :func:`precomputed_topk_postprocess_is_noop` holds.
"""
capture_routed_experts_if_allowed(topk_config, layer_id, topk_ids)
get_global_expert_distribution_recorder().on_select_experts(topk_ids=topk_ids)
# router_logits is only read by the BYPASSED formats and by the
# shared-expert append (excluded above); STANDARD consumers take ids/weights.
return StandardTopKOutput(topk_weights, topk_ids, None)
# NOTE: the AOT sgl_kernel::moe_fused_gate and sgl_kernel::kimi_k2_moe_fused_gate
# ops (and their torch.compile fake impls) were retired here — both CUDA gate
# paths now route through the unified Triton router (kernels/ops/moe/moe_fused_gate.py),
@@ -178,6 +178,17 @@ class CompressedTensorsConfig(QuantizationConfig):
from sglang.srt.layers.moe.fused_moe_triton import FusedMoE
if isinstance(layer, FusedMoE):
# Detect MXFP4 before the scheme-based path: MXFP4 uses a
# dedicated FusedMoEMethodBase (Mxfp4MoEMethod) that already
# handles all MoE backends, bypassing the scheme abstraction.
if self._is_mxfp4_moe(layer_name=prefix):
from sglang.srt.layers.quantization.mxfp4 import Mxfp4MoEMethod
logger.info_once(
"Using Mxfp4MoEMethod for MXFP4 compressed-tensors MoE"
)
return Mxfp4MoEMethod(prefix=prefix)
layer.scheme = self.get_moe_scheme(layer=layer, layer_name=prefix)
if layer.scheme is None: # ignored layer
use_triton_kernels = get_moe_runner_backend().is_triton_kernels()
@@ -559,6 +570,28 @@ class CompressedTensorsConfig(QuantizationConfig):
return is_mxint4 and input_quant_none and is_symmetric and is_static
def _is_mxfp4_moe(self, layer_name: str) -> bool:
"""Detect MXFP4-quantized MoE from global format or target scheme."""
if "mxfp4" in (self.quant_format or ""):
return True
self._add_fused_moe_to_target_scheme_map()
for key in ["FusedMoE", "Linear"]:
scheme = self.target_scheme_map.get(key)
if scheme is None:
continue
wq = scheme.get("weights")
if wq is None:
continue
if (
wq.num_bits == 4
and wq.type == QuantizationType.FLOAT
and wq.strategy == QuantizationStrategy.GROUP.value
and wq.group_size == 32
and wq.symmetric
):
return True
return False
def _is_dynamic_token_w4(
self, weight_quant: BaseModel, input_quant: BaseModel
) -> bool:
@@ -250,7 +250,7 @@ def check_moe_marlin_supports_layer(
# apply_router_weight_on_input is not supported for moe marlin
supports_router_weight = not layer.moe_runner_config.apply_router_weight_on_input
if layer.moe_runner_config.is_gated:
supports_activation = layer.moe_runner_config.activation == "silu"
supports_activation = layer.moe_runner_config.activation in {"silu", "situ"}
else:
supports_activation = layer.moe_runner_config.activation in {
"silu",
+443 -122
View File
@@ -33,13 +33,13 @@ from sglang.srt.distributed.device_communicators.pynccl_allocator import (
use_symmetric_memory,
)
from sglang.srt.environ import envs
from sglang.srt.layers import zero_copy_context
from sglang.srt.layers.amx_utils import (
CPUQuantMethod,
_amx_process_weight_after_loading,
)
from sglang.srt.layers.dp_attention import is_allocation_symmetric
from sglang.srt.layers.moe import MoeRunner, MoeRunnerBackend, MoeRunnerConfig
from sglang.srt.layers.moe.moe_runner.marlin import MarlinMoeQuantInfo
from sglang.srt.layers.moe.moe_runner.triton import TritonMoeQuantInfo
from sglang.srt.layers.moe.utils import get_moe_a2a_backend, get_moe_runner_backend
from sglang.srt.layers.quantization.base_config import (
@@ -145,6 +145,7 @@ if TYPE_CHECKING:
_is_cpu = is_cpu()
_is_hip = is_hip()
_use_aiter = get_bool_env_var("SGLANG_USE_AITER") and _is_hip
_aiter_k3_opt = _use_aiter and get_bool_env_var("SGLANG_AITER_K3_OPT")
_is_shuffle_moe_mxfp4 = is_gfx95_supported()
_is_cpu_amx_available = cpu_has_amx_support()
@@ -332,6 +333,10 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
self.with_bias = False
self.use_flashinfer = get_moe_runner_backend().is_flashinfer_mxfp4()
self.use_marlin = get_moe_runner_backend().is_marlin()
# True W4A8: DeepGEMM fp8_fp4 grouped GEMM (SM100 MXF8F6F4 UMMA).
# Weights stay MXFP4 (e2m1 + ue8m0 g32, zero requantization);
# activations are quantized to fp8 per-token-group-128.
self.use_deep_gemm = get_moe_runner_backend().is_deep_gemm()
self.flashinfer_mxfp4_moe_precision = (
get_exec().moe.flashinfer_mxfp4_moe_precision
)
@@ -392,12 +397,21 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
intermediate_size_per_partition_after_pad
- layer.intermediate_size_per_partition
)
elif self.use_deep_gemm:
# DeepGEMM fp8_fp4 grouped GEMM consumes the checkpoint layout
# directly (packed e2m1 K-major + ue8m0 g32 scales); no padding.
pass
elif is_sm100_supported():
if self.use_flashinfer:
# FlashInfer trtllm-gen FP4 kernel actual alignment:
# intermediate: scale shuffle needs M%128==0 → intermediate%64==0
# hidden: finalize kernel needs K%32==0, block quant needs K%32==0
# Using 128 alignment (not 256) since 256 was overly conservative
# and causes 60GB/GPU waste for models like K3 (384 intermediate).
intermediate_size_per_partition_after_pad = round_up(
intermediate_size_per_partition, 256
intermediate_size_per_partition, 128
)
hidden_size = round_up(hidden_size, 256)
hidden_size = round_up(hidden_size, 128)
else:
intermediate_size_per_partition_after_pad = round_up(
intermediate_size_per_partition, triton_kernels_padding_alignment
@@ -422,9 +436,13 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
# naive-copy fast path is correct.
intermediate_size_per_partition_after_pad = intermediate_size_per_partition
elif _use_aiter:
# Expert intermediate is padded to 128 or 256. On K3 (TP8) the 256
# default costs +33% mem (3072/8=384 -> 512). K3 FlyDSL MoE works with
# 128 padding, so relaxing to 128 saves that memory and lets the TP8
# decode CUDA graph range grow from 20 to 120.
_inter_align = 128 if _aiter_k3_opt else 256
intermediate_size_per_partition_after_pad = round_up(
intermediate_size_per_partition, 256
intermediate_size_per_partition, _inter_align
)
hidden_size = round_up(hidden_size, 256)
@@ -465,17 +483,20 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
)
layer.register_parameter("w13_weight_scale", w13_weight_scale)
set_weight_attrs(w13_weight_scale, extra_weight_attrs)
w13_weight_scale.quant_method = "group"
w13_weight_bias = torch.nn.Parameter(
torch.zeros(
layer.num_local_experts,
2 * intermediate_size_per_partition_after_pad,
dtype=torch.bfloat16,
),
requires_grad=False,
)
layer.register_parameter("w13_weight_bias", w13_weight_bias)
set_weight_attrs(w13_weight_bias, extra_weight_attrs)
create_bias = with_bias or not _is_hip
if create_bias:
w13_weight_bias = torch.nn.Parameter(
torch.zeros(
layer.num_local_experts,
2 * intermediate_size_per_partition_after_pad,
dtype=torch.bfloat16,
),
requires_grad=False,
)
layer.register_parameter("w13_weight_bias", w13_weight_bias)
set_weight_attrs(w13_weight_bias, extra_weight_attrs)
# down_proj (row parallel)
w2_weight = torch.nn.Parameter(
@@ -501,13 +522,15 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
)
layer.register_parameter("w2_weight_scale", w2_weight_scale)
set_weight_attrs(w2_weight_scale, extra_weight_attrs)
w2_weight_scale.quant_method = "group"
w2_weight_bias = torch.nn.Parameter(
torch.zeros(layer.num_local_experts, hidden_size, dtype=torch.bfloat16),
requires_grad=False,
)
layer.register_parameter("w2_weight_bias", w2_weight_bias)
set_weight_attrs(w2_weight_bias, extra_weight_attrs)
if create_bias:
w2_weight_bias = torch.nn.Parameter(
torch.zeros(layer.num_local_experts, hidden_size, dtype=torch.bfloat16),
requires_grad=False,
)
layer.register_parameter("w2_weight_bias", w2_weight_bias)
set_weight_attrs(w2_weight_bias, extra_weight_attrs)
def process_weights_after_loading(self, layer):
if self.use_marlin:
@@ -519,19 +542,73 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
prepare_moe_mxfp4_layer_for_marlin,
)
if not is_sm90_supported() and not is_sm120_supported():
raise RuntimeError("MXFP4 Marlin requires SM90 or SM120.")
if (
not is_sm90_supported()
and not is_sm100_supported()
and not is_sm120_supported()
):
raise RuntimeError("MXFP4 Marlin requires SM90+.")
if not check_moe_marlin_supports_layer(layer, 32, allow_tile_padding=True):
raise RuntimeError(
"Current MXFP4 MoE layer is not supported by Marlin."
)
if self.moe_runner_config.gemm1_alpha is not None:
if self.moe_runner_config.gemm1_alpha is not None and getattr(
self.moe_runner_config, "gate_up_interleaved", True
):
deinterleave_moe_mxfp4_w13_for_marlin(layer)
prepare_moe_mxfp4_layer_for_marlin(layer)
layer._mxfp4_backend = "marlin"
return
if self.use_deep_gemm:
from deep_gemm import transform_sf_into_required_layout
# Packed fp4 (e2m1 x2 per byte) weights: DeepGEMM expects int8.
layer.w13_weight.data = layer.w13_weight.data.view(torch.int8)
layer.w2_weight.data = layer.w2_weight.data.view(torch.int8)
# Checkpoint scales are uint8 e8m0 (biased exponents). DeepGEMM
# SM100 needs them in packed-UE8M0 TMA-aligned MN-major layout.
# Round-trip through fp32 is exact (values are powers of two).
for scale_name, weight in (
("w13_weight_scale", layer.w13_weight),
("w2_weight_scale", layer.w2_weight),
):
scale = getattr(layer, scale_name)
num_experts, n, _ = scale.data.shape
k = weight.shape[2] * 2
scale_f32 = scale.data.view(torch.float8_e8m0fnu).to(torch.float32)
scale.data = transform_sf_into_required_layout(
scale_f32,
mn=n,
k=k,
recipe=(1, 32),
num_groups=num_experts,
disable_ue8m0_cast=False,
)
if get_moe_a2a_backend().is_megamoe():
# MegaMoE consumes the same transformed sf, plus its own
# interleaved/UTCCP weight layout. K3 routes EVERY batch
# through mega (the megamoe backend has no a2a fallback), so
# the contig-layout originals are dead weight — repoint the
# params at the mega tensors to reclaim ~1.9GB/layer/rank
# (keeping both layouts OOMs: 92 layers double the experts).
from deep_gemm import transform_weights_for_mega_moe
l1_pair, l2_pair = transform_weights_for_mega_moe(
(layer.w13_weight.data, layer.w13_weight_scale.data),
(layer.w2_weight.data, layer.w2_weight_scale.data),
)
layer.mega_l1_weights = l1_pair
layer.mega_l2_weights = l2_pair
layer.w13_weight.data = l1_pair[0]
layer.w13_weight_scale.data = l1_pair[1]
layer.w2_weight.data = l2_pair[0]
layer.w2_weight_scale.data = l2_pair[1]
layer._mega_moe_weights_built = True
layer._mxfp4_backend = "deep_gemm"
return
if self._fi_kernel == "cutlass_sm90":
self._process_weights_for_sm90_cutlass(layer)
return
@@ -539,38 +616,42 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
self._process_weights_for_sm120_cutlass(layer)
return
if self.use_flashinfer:
# TODO: these values are hardcoded for now, we need to get them from the model
# Per-expert buffers are local (create_weights uses num_local_experts);
# the global self.num_experts here breaks EP>1. Mirrors the SM90 path.
E = layer.num_local_experts
_alpha = getattr(layer.moe_runner_config, "gemm1_alpha", None) or 1.702
_limit = getattr(layer.moe_runner_config, "gemm1_clamp_limit", None) or 7.0
layer.gemm1_alpha = Parameter(
torch.tensor([1.702] * self.num_experts, dtype=torch.float32).cuda(),
torch.tensor([_alpha] * E, dtype=torch.float32).cuda(),
requires_grad=False,
)
layer.gemm1_beta = Parameter(
torch.tensor([1.0] * self.num_experts, dtype=torch.float32).cuda(),
torch.tensor([1.0] * E, dtype=torch.float32).cuda(),
requires_grad=False,
)
layer.gemm1_clamp_limit = Parameter(
torch.tensor([7.0] * self.num_experts, dtype=torch.float32).cuda(),
torch.tensor([_limit] * E, dtype=torch.float32).cuda(),
requires_grad=False,
)
sf_block_size = 32 # mxfp4 block size
assert (
layer.w13_weight.dim() == 3
and layer.w13_weight.shape[0] == self.num_experts
and layer.w13_weight.shape[0] == E
and layer.w13_weight.shape[1]
== self.intermediate_size_per_partition * 2
and layer.w13_weight.shape[2] == self.hidden_size // 2
)
assert (
layer.w13_weight_scale.dim() == 3
and layer.w13_weight_scale.shape[0] == self.num_experts
and layer.w13_weight_scale.shape[0] == E
and layer.w13_weight_scale.shape[1]
== self.intermediate_size_per_partition * 2
and layer.w13_weight_scale.shape[2] == self.hidden_size // sf_block_size
)
assert (
layer.w2_weight.dim() == 3
and layer.w2_weight.shape[0] == self.num_experts
and layer.w2_weight.shape[0] == E
and layer.w2_weight.shape[1] == self.hidden_size
and layer.w2_weight.shape[2]
== self.intermediate_size_per_partition // 2
@@ -583,13 +664,13 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
)
assert (
layer.w13_weight_bias.dim() == 2
and layer.w13_weight_bias.shape[0] == self.num_experts
and layer.w13_weight_bias.shape[0] == E
and layer.w13_weight_bias.shape[1]
== self.intermediate_size_per_partition * 2
)
assert (
layer.w2_weight_bias.dim() == 2
and layer.w2_weight_bias.shape[0] == self.num_experts
and layer.w2_weight_bias.shape[0] == E
and layer.w2_weight_bias.shape[1] == self.hidden_size
)
@@ -618,9 +699,26 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
new_shape = list(shape)
return x.reshape(*new_shape)
w13_weight_scale = swap_every_two_rows(w13_weight_scale, -2)
w13_weight = swap_every_two_rows(w13_weight, -2)
w13_bias = swap_every_two_rows(w13_bias, -1)
if getattr(layer.moe_runner_config, "gate_up_interleaved", True):
w13_weight_scale = swap_every_two_rows(w13_weight_scale, -2)
w13_weight = swap_every_two_rows(w13_weight, -2)
w13_bias = swap_every_two_rows(w13_bias, -1)
else:
# Non-interleaved layout (e.g. K3 Latent MoE): first half = gate
# (w1), second half = up (w3). The trtllm-gen fused gated-act
# epilogue wants rows interleaved as (up_i, gate_i) PAIRS -
# the layout get_reorder_rows_for_gated_act_gemm_row_indices
# produces from [linear; gate] halves, and what the
# interleaved branch above arrives at via swap_every_two_rows.
# A plain halves swap keeps rows blocked and pairs up_i with
# up_{i+1}, which scrambles the gated activation.
half = w13_weight.shape[-2] // 2
pair_idx = torch.empty(2 * half, dtype=torch.long)
pair_idx[0::2] = torch.arange(half) + half # up (w3)
pair_idx[1::2] = torch.arange(half) # gate (w1)
w13_weight = w13_weight[..., pair_idx, :].contiguous()
w13_weight_scale = w13_weight_scale[..., pair_idx, :].contiguous()
w13_bias = w13_bias[..., pair_idx].contiguous()
# Shuffle weights and scaling factors for transposed mma output
gemm1_weights_mxfp4_shuffled = []
@@ -658,7 +756,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
epilogue_tile_m,
)
for i in range(self.num_experts):
for i in range(E):
gemm1_weights_mxfp4_shuffled.append(
w13_weight[i]
.view(torch.uint8)[w13_weight_permute_indices]
@@ -699,7 +797,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
w13_weight_scale = (
torch.stack(gemm1_scales_mxfp4_shuffled)
.reshape(
self.num_experts,
E,
2 * self.intermediate_size_per_partition,
self.hidden_size // sf_block_size,
)
@@ -710,7 +808,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
w2_weight_scale = (
torch.stack(gemm2_scales_mxfp4_shuffled)
.reshape(
self.num_experts,
E,
self.hidden_size,
self.intermediate_size_per_partition // sf_block_size,
)
@@ -722,44 +820,57 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
layer.w2_weight = Parameter(w2_weight, requires_grad=False)
layer.w2_weight_scale = Parameter(w2_weight_scale, requires_grad=False)
layer.w13_weight_bias = Parameter(
torch.stack(gemm1_bias_shuffled).reshape(self.num_experts, -1),
torch.stack(gemm1_bias_shuffled).reshape(E, -1),
requires_grad=False,
)
layer.w2_weight_bias = Parameter(
torch.stack(gemm2_bias_shuffled).reshape(self.num_experts, -1),
torch.stack(gemm2_bias_shuffled).reshape(E, -1),
requires_grad=False,
)
return
if _use_aiter:
if layer.w13_weight_bias is not None:
if getattr(layer, "w13_weight_bias", None) is not None:
layer.w13_weight_bias.data = layer.w13_weight_bias.data.to(
torch.float32
)
if layer.w2_weight_bias is not None:
if getattr(layer, "w2_weight_bias", None) is not None:
layer.w2_weight_bias.data = layer.w2_weight_bias.data.to(torch.float32)
e, n, k = layer.w13_weight.shape
layer.w13_weight.view(torch.uint8).copy_(
layer.w13_weight.data.view(torch.uint8)
.view(e, n // 2, 2, k)
.permute(0, 2, 1, 3)
.contiguous()
.view(e, n, k)
)
layer.w13_weight_scale.data = (
layer.w13_weight_scale.data.view(e, n // 2, 2, -1)
.permute(0, 2, 1, 3)
.contiguous()
.view(e, n, -1)
)
layer.w13_weight_bias.data = (
layer.w13_weight_bias.data.view(-1, n // 2, 2)
.permute(0, 2, 1)
.contiguous()
.view(-1, n)
gate_up_interleaved = getattr(
layer.moe_runner_config, "gate_up_interleaved", True
)
if envs.SGLANG_USE_AITER_MOE_GU_ITLV.get():
if gate_up_interleaved:
layer.w13_weight.view(torch.uint8).copy_(
layer.w13_weight.data.view(torch.uint8)
.view(e, n // 2, 2, k)
.permute(0, 2, 1, 3)
.contiguous()
.view(e, n, k)
)
layer.w13_weight_scale.data = (
layer.w13_weight_scale.data.view(e, n // 2, 2, -1)
.permute(0, 2, 1, 3)
.contiguous()
.view(e, n, -1)
)
if getattr(layer, "w13_weight_bias", None) is not None:
layer.w13_weight_bias.data = (
layer.w13_weight_bias.data.view(-1, n // 2, 2)
.permute(0, 2, 1)
.contiguous()
.view(-1, n)
)
k3_situ_a8w4 = (
os.environ.get("AITER_SITUV2_A8W4", "0") == "1"
and getattr(layer.moe_runner_config, "activation", None) == "situ"
)
use_aiter_gu_interleave = k3_situ_a8w4 or (
envs.SGLANG_USE_AITER_MOE_GU_ITLV.get() and gate_up_interleaved
)
if use_aiter_gu_interleave:
layer.w13_weight.data = shuffle_weight_a16w4(layer.w13_weight, 16, True)
shuffled_w13_scale = shuffle_scale_a16w4(
layer.w13_weight_scale.view(-1, layer.w13_weight_scale.shape[-1]),
@@ -925,11 +1036,18 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
# half along its row dim (N) from N_un to N_pad with zeros, and along
# its last dim (K) from K_un (or K_un / sf_block_size) to K_pad.
_interleaved = getattr(layer.moe_runner_config, "gate_up_interleaved", True)
def _stack_up_gate_w13(unpadded_w13, last_pad, last_un):
# unpadded_w13: [E, 2*N_un, last_un]
# Returns: [E, 2*N_pad, last_pad] in [up_padded; gate_padded] order.
gate_rows = unpadded_w13[:, 0::2, :] # [E, N_un, last_un]
up_rows = unpadded_w13[:, 1::2, :] # [E, N_un, last_un]
if _interleaved:
gate_rows = unpadded_w13[:, 0::2, :] # [E, N_un, last_un]
up_rows = unpadded_w13[:, 1::2, :] # [E, N_un, last_un]
else:
# Non-interleaved: first half=gate, second half=up
gate_rows = unpadded_w13[:, :N_un, :]
up_rows = unpadded_w13[:, N_un:, :]
out = torch.zeros(
E, 2 * N_pad, last_pad, dtype=unpadded_w13.dtype, device=device
)
@@ -948,8 +1066,12 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
K_un // sf_block_size,
)
# Bias: same de-interleave on dim=-1.
w13_bias_gate = layer.w13_weight_bias.data[:, 0::2] # [E, N_un]
w13_bias_up = layer.w13_weight_bias.data[:, 1::2] # [E, N_un]
if _interleaved:
w13_bias_gate = layer.w13_weight_bias.data[:, 0::2] # [E, N_un]
w13_bias_up = layer.w13_weight_bias.data[:, 1::2] # [E, N_un]
else:
w13_bias_gate = layer.w13_weight_bias.data[:, :N_un]
w13_bias_up = layer.w13_weight_bias.data[:, N_un:]
w13_bias_padded = torch.zeros(E, 2 * N_pad, dtype=bias_dtype, device=device)
w13_bias_padded[:, :N_un] = w13_bias_up
w13_bias_padded[:, N_pad : N_pad + N_un] = w13_bias_gate
@@ -971,9 +1093,11 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
w2_bias_padded = torch.zeros(E, K_pad, dtype=bias_dtype, device=device)
w2_bias_padded[:, :K_un] = layer.w2_weight_bias.data
# ---- Per-expert SwiGLU scalars (GPT-OSS defaults) ------------------
# ---- Per-expert SwiGLU scalars (read from runner config, fallback to GPT-OSS defaults)
_sm90_alpha = getattr(layer.moe_runner_config, "gemm1_alpha", None) or 1.702
_sm90_limit = getattr(layer.moe_runner_config, "gemm1_clamp_limit", None) or 7.0
layer.swiglu_alpha = Parameter(
torch.full((E,), 1.702, dtype=torch.float32, device=device),
torch.full((E,), _sm90_alpha, dtype=torch.float32, device=device),
requires_grad=False,
)
layer.swiglu_beta = Parameter(
@@ -981,7 +1105,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
requires_grad=False,
)
layer.swiglu_limit = Parameter(
torch.full((E,), 7.0, dtype=torch.float32, device=device),
torch.full((E,), _sm90_limit, dtype=torch.float32, device=device),
requires_grad=False,
)
@@ -1121,14 +1245,18 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
moe_runner_backend = MoeRunnerBackend.TRITON
if moe_runner_backend.is_aiter():
# MXFP4 hard-codes Swiglu in the AITER kernel path.
self.runner = MoeRunner(
moe_runner_backend, replace(moe_runner_config, activation="swiglu")
)
# MXFP4 hard-codes Swiglu in the AITER kernel path, so the
# checkpoint's "silu" has to be translated. K3's SiTU is the one
# activation the kernel selects on its own -- leave it alone.
aiter_config = moe_runner_config
if aiter_config.activation != "situ":
aiter_config = replace(aiter_config, activation="swiglu")
self.runner = MoeRunner(moe_runner_backend, aiter_config)
elif (
moe_runner_backend.is_triton_kernels()
or moe_runner_backend.is_triton()
or moe_runner_backend.is_marlin()
or moe_runner_backend.is_deep_gemm()
):
self.runner = MoeRunner(moe_runner_backend, moe_runner_config)
elif moe_runner_backend.is_flashinfer_mxfp4() and self._fi_kernel in (
@@ -1172,6 +1300,28 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
)
return self.runner.run(dispatch_output, quant_info)
def _apply_marlin(self, layer, dispatch_output):
"""MXFP4 x BF16 MoE via the Marlin runner. The quant_info (incl. the
dispatcher's EP mapping) is shared with
:class:`~sglang.srt.layers.quantization.mxfp4_marlin_moe.Mxfp4MarlinMoEMethod`;
only the input padding is local (``hidden_size`` is pre-rounded to the
Marlin-padded width at create_weights)."""
from sglang.srt.layers.quantization.mxfp4_marlin_moe import (
build_marlin_moe_quant_info,
)
x = dispatch_output.hidden_states
if x.shape[-1] == self.hidden_size:
x_padded = x
else:
x_padded = torch.nn.functional.pad(
x, (0, self.hidden_pad), mode="constant", value=0.0
)
quant_info = build_marlin_moe_quant_info(layer)
return self.runner.run(
dispatch_output._replace(hidden_states=x_padded), quant_info
)
def _apply_sm120_cutlass(self, layer, dispatch_output):
"""SM120 GPT-OSS MXFP8 x MXFP4 MoE via FlashInfer CUTLASS."""
from sglang.srt.layers.moe.moe_runner.flashinfer_cutlass import (
@@ -1206,6 +1356,24 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput
from sglang.srt.layers.moe.topk import TopKOutputChecker
if self.use_deep_gemm:
# Handles standard AND deepep_ll/deepep_normal dispatch formats via
# the runner's registered pre/post-permute functions. Must run
# before the `.topk_output` unpack below: deepep dispatch outputs
# carry topk_ids/topk_weights directly and have no `.topk_output`.
from sglang.srt.layers.moe.moe_runner.deep_gemm import DeepGemmMoeQuantInfo
quant_info = DeepGemmMoeQuantInfo(
w13_weight=layer.w13_weight,
w2_weight=layer.w2_weight,
use_fp8=True,
w13_scale=layer.w13_weight_scale,
w2_scale=layer.w2_weight_scale,
block_shape=[128, 128],
is_fp4_experts=True,
)
return self.runner.run(dispatch_output, quant_info)
x = dispatch_output.hidden_states
topk_output = dispatch_output.topk_output
if _is_cpu:
@@ -1248,27 +1416,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
if self.use_marlin:
assert TopKOutputChecker.format_is_standard(topk_output)
if x.shape[-1] == self.hidden_size:
x_padded = x
else:
x_padded = torch.nn.functional.pad(
x, (0, self.hidden_pad), mode="constant", value=0.0
)
quant_info = MarlinMoeQuantInfo(
w13_qweight=layer.w13_weight,
w2_qweight=layer.w2_weight,
w13_scales=layer.w13_weight_scale,
w2_scales=layer.w2_weight_scale,
w13_g_idx_sort_indices=None,
w2_g_idx_sort_indices=None,
weight_bits=4,
is_k_full=True,
w13_bias=getattr(layer, "w13_weight_bias", None),
w2_bias=getattr(layer, "w2_weight_bias", None),
)
return self.runner.run(
dispatch_output._replace(hidden_states=x_padded), quant_info
)
return self._apply_marlin(layer, dispatch_output)
if self._fi_kernel == "cutlass_sm90":
return self._apply_sm90_cutlass(layer, dispatch_output)
@@ -1279,6 +1427,9 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
# TRT-LLM automatically handles quantization in the kernel implementation and pipelines it with GEMM operations,
# which can theoretically improve performance
origin_hidden_states_dim = x.shape[-1]
# Filled by the staged K3 route+pack+quant fusion below; the pack
# site further down falls back to PackTopkIds when it is None.
prepared_packed_topk = None
if self.flashinfer_mxfp4_moe_precision == "bf16":
assert x.dtype == torch.bfloat16
x_quant = x
@@ -1293,31 +1444,200 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
value=0.0,
)
elif self.flashinfer_mxfp4_moe_precision == "default":
from sglang.srt.layers.quantization.fp8_utils import (
flashinfer_mxfp8_quantize,
)
if x.shape[-1] == self.hidden_size:
if x.dim() > 2:
x = x.view(-1, x.shape[-1])
# K3 staged fusion (route_quant_handoff): the routing
# dispatch already quantized these rows and packed the
# topk ids in the fused route launch — consume both and
# skip the two standalone kernels. Identity-verified;
# a miss runs the unfused chain below.
from sglang.srt.layers.moe import route_quant_handoff
x_quant, x_scale = flashinfer_mxfp8_quantize(
x, False, alignment=self.hidden_size
)
x_scale = x_scale.view(torch.float8_e4m3fn).reshape(*x.shape[:-1], -1)
prepared = route_quant_handoff.take(x)
if prepared is not None:
prepared_packed_topk, x_quant, x_scale = prepared
x_scale = x_scale.view(torch.float8_e4m3fn)
else:
from sglang.kernels.ops.quantization.per_token_group_quant import (
per_token_group_quant,
)
x_quant, x_scale = per_token_group_quant(
x, group_size=32, scale_ue8m0=True
)
x_scale = x_scale.view(torch.float8_e4m3fn)
else:
from sglang.srt.layers.quantization.fp8_utils import (
flashinfer_mxfp8_quantize,
)
x_quant, x_scale = flashinfer_mxfp8_quantize(
x, False, alignment=self.hidden_size
)
x_scale = x_scale.view(torch.float8_e4m3fn).reshape(
*x.shape[:-1], -1
)
else:
raise NotImplementedError()
assert x_quant.shape[-1] == self.hidden_size
assert TopKOutputChecker.format_is_bypassed(topk_output)
is_standard = TopKOutputChecker.format_is_standard(topk_output)
# The situ path accepts precomputed (standard) routing; the
# public path below is logits-only.
assert is_standard or TopKOutputChecker.format_is_bypassed(
topk_output
), f"unsupported topk format: {topk_output.format}"
if is_standard:
assert (
self.moe_runner_config.activation == "situ"
), "standard topk output only wired for the situ path"
top_k = topk_output.topk_ids.shape[1]
router_logits = None
else:
top_k = topk_output.topk_config.top_k
router_logits = topk_output.router_logits
top_k = topk_output.topk_config.top_k
router_logits = topk_output.router_logits
num_tokens = x_quant.shape[0]
hidden_size = origin_hidden_states_dim
# The K3 fused-front path publishes its [latent | shared] buffer
# slice as the output destination (zero_copy_context); writing
# the finalize output there directly skips this allocation and
# the copy_ back in _forward_fused. The slice lives in the same
# symmetric buffer the caller all-reduces.
symm_output = zero_copy_context.get_moe_output_spec(
torch.Size((num_tokens, hidden_size)),
torch.bfloat16,
x_quant.device,
)
if symm_output is None:
with use_symmetric_memory(
get_tp_group(), disabled=not is_allocation_symmetric()
):
symm_output = torch.empty(
num_tokens,
hidden_size,
dtype=torch.bfloat16,
device=x_quant.device,
)
with use_symmetric_memory(
get_tp_group(), disabled=not is_allocation_symmetric()
):
num_tokens = x_quant.shape[0]
hidden_size = origin_hidden_states_dim
symm_output = torch.empty(
num_tokens, hidden_size, dtype=torch.bfloat16, device=x_quant.device
if self.moe_runner_config.activation == "situ":
# SiTU is only in the private trtllm-gen cubin pool (the
# public artifact bakes swiglu into the fused-act cubins and
# silently computes the wrong activation). Routing must also
# be noaux_tc (sigmoid + correction bias, DeepSeekV3 method),
# not the renormalize-softmax default below.
from sglang.kernels.ops.moe import trtllm_gen_moe as situ_moe
if not situ_moe.available():
raise RuntimeError(
"activation='situ' with the flashinfer_mxfp4 runner "
"needs the SiTU cubin pool: set "
"SGLANG_TRTLLM_GEN_MOE_CUBIN_POOL (see "
"sglang/kernels/ops/moe/trtllm_gen_moe.py)."
)
# EP is cubin-internal: each rank computes its local expert slice
# [offset, +num_local) and the caller all-reduces. ep=1 -> TP path.
local_expert_offset = layer.moe_ep_rank * layer.num_local_experts
if TopKOutputChecker.format_is_standard(topk_output):
# Precomputed routing (radix router upstream): skip the
# in-op routing kernels entirely. At small T the in-op
# single-CTA routing costs ~22 us/layer vs ~6 us for the
# external radix router.
if prepared_packed_topk is not None:
packed_topk = prepared_packed_topk
else:
from sglang.kernels.ops.moe.pack_topk_ids import PackTopkIds
packed_topk = PackTopkIds.execute(
topk_output.topk_ids, topk_output.topk_weights
)
# Deferred finalize (K3 forward_deferred_finalize): return
# the finalize inputs instead of the finalized output.
from sglang.srt.layers.moe.moe_runner.flashinfer_trtllm import (
_deferred_finalize_enabled,
)
defer_finalize = _deferred_finalize_enabled.get()
result = situ_moe.trtllm_fp4_block_scale_routed_moe(
packed_topk_ids=packed_topk,
hidden_states=x_quant,
hidden_states_scale=x_scale,
gemm1_weights=layer.w13_weight,
gemm1_weights_scale=layer.w13_weight_scale,
gemm1_alpha=layer.gemm1_alpha,
# SiTuGlu: gatedActBeta is the linear-half tanh
# clip; K3 stores it in gemm1_clamp_limit.
gemm1_beta=layer.gemm1_clamp_limit,
gemm2_weights=layer.w2_weight,
gemm2_weights_scale=layer.w2_weight_scale,
output1_scale_scalar=None,
output1_scale_gate_scalar=None,
output2_scale_scalar=None,
num_experts=layer.num_experts,
top_k=packed_topk.shape[1],
intermediate_size=self.intermediate_size_per_partition,
activation_type=situ_moe.ACTIVATION_SITU,
local_expert_offset=local_expert_offset,
local_num_experts=layer.num_local_experts,
output=symm_output,
do_finalize=not defer_finalize,
)
if defer_finalize:
from sglang.srt.layers.moe.moe_runner.flashinfer_trtllm import (
FlashInferTrtllmDeferredFinalizeOutput,
)
gemm2_out, topk_weights, expanded_idx = result
result = FlashInferTrtllmDeferredFinalizeOutput(
gemm2_out=gemm2_out,
expert_weights=topk_weights,
expanded_idx_to_permuted_idx=expanded_idx,
top_k=packed_topk.shape[1],
)
return StandardCombineInput(hidden_states=result)
# Bypassed topk: route from logits inside the op.
correction_bias = topk_output.topk_config.correction_bias
bias_bf16 = getattr(layer, "_situ_routing_bias_bf16", None)
if bias_bf16 is None and correction_bias is not None:
bias_bf16 = correction_bias.to(torch.bfloat16)
layer._situ_routing_bias_bf16 = bias_bf16
situ_moe.trtllm_fp4_block_scale_moe(
# router_logits is a row-strided slice of the K3 fused
# front GEMM output; the FFI reads it as dense.
routing_logits=router_logits.to(torch.bfloat16).contiguous(),
routing_bias=bias_bf16,
hidden_states=x_quant,
hidden_states_scale=x_scale,
gemm1_weights=layer.w13_weight,
gemm1_weights_scale=layer.w13_weight_scale,
gemm1_alpha=layer.gemm1_alpha,
# SiTuGlu: gatedActBeta is the linear-half tanh clip;
# K3 stores it in gemm1_clamp_limit (situ_linear_beta).
gemm1_beta=layer.gemm1_clamp_limit,
gemm2_weights=layer.w2_weight,
gemm2_weights_scale=layer.w2_weight_scale,
output1_scale_scalar=None,
output1_scale_gate_scalar=None,
output2_scale_scalar=None,
num_experts=layer.num_experts,
top_k=top_k,
n_group=topk_output.topk_config.num_expert_group,
topk_group=topk_output.topk_config.topk_group,
intermediate_size=self.intermediate_size_per_partition,
routed_scaling_factor=(
topk_output.topk_config.routed_scaling_factor or 1.0
),
routing_method_type=situ_moe.ROUTING_DEEPSEEK_V3,
activation_type=situ_moe.ACTIVATION_SITU,
norm_topk_prob=topk_output.topk_config.renormalize,
local_expert_offset=local_expert_offset,
local_num_experts=layer.num_local_experts,
output=symm_output,
)
return StandardCombineInput(hidden_states=symm_output)
trtllm_gen_output = trtllm_fp4_block_scale_moe(
router_logits.to(torch.bfloat16),
None, # routing_bias
@@ -1385,22 +1705,23 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
quant_type=AiterQuantType.PER_1X32,
w13_scale=layer.w13_weight_scale,
w2_scale=layer.w2_weight_scale,
b13=layer.w13_weight_bias,
b2=layer.w2_weight_bias,
b13=layer.w13_weight_bias if self.with_bias else None,
b2=layer.w2_weight_bias if self.with_bias else None,
expert_mask=layer.dispatcher.expert_mask_gpu,
doweight_stage1=self.moe_runner_config.apply_router_weight_on_input,
hidden_pad=self.hidden_pad,
intermediate_pad=self.intermediate_pad,
# Triggers aiter's INTERLEAVE gate_mode dispatch (required for our
# preshuffled gate/up-interleaved weight layout) and applies the
# model's swiglu clamp. Models populate the same scalar under
# different MoeRunnerConfig fields: gpt-oss uses `gemm1_clamp_limit`
# (renamed in `models/gpt_oss.py` from `config.swiglu_limit`); DSv4
# / FP8 uses `swiglu_limit` directly. Accept either.
# Applies swiglu clamp for GPT-OSS-style activations. K3 SiTU
# uses gemm1_clamp_limit as linear_beta, which is forwarded by
# the AITER runner and must not be treated as swiglu_limit.
swiglu_limit=(
self.moe_runner_config.gemm1_clamp_limit
or self.moe_runner_config.swiglu_limit
or 0.0
0.0
if self.moe_runner_config.activation == "situ"
else (
self.moe_runner_config.gemm1_clamp_limit
or self.moe_runner_config.swiglu_limit
or 0.0
)
),
)
return self.runner.run(
@@ -17,6 +17,31 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def build_marlin_moe_quant_info(layer: Module) -> MarlinMoeQuantInfo:
"""Build the Marlin quant_info for an MXFP4 MoE layer.
Single source for the runner inputs shared by the marlin path of
``Mxfp4MoEMethod.apply`` and :class:`Mxfp4MarlinMoEMethod`, including
the dispatcher's EP mapping (global -> local expert ids) when EP is on.
"""
expert_map = getattr(layer.dispatcher, "local_expert_mapping", None)
global_num_experts = layer.dispatcher.num_experts if expert_map is not None else -1
return MarlinMoeQuantInfo(
w13_qweight=layer.w13_weight,
w2_qweight=layer.w2_weight,
w13_scales=layer.w13_weight_scale,
w2_scales=layer.w2_weight_scale,
w13_g_idx_sort_indices=None,
w2_g_idx_sort_indices=None,
weight_bits=4,
is_k_full=True,
w13_bias=getattr(layer, "w13_weight_bias", None),
w2_bias=getattr(layer, "w2_weight_bias", None),
expert_map=expert_map,
global_num_experts=global_num_experts,
)
class Mxfp4MarlinMoEMethod:
"""MXFP4 (E8M0 scales) MoE quantization method using the Marlin backend."""
@@ -162,18 +187,7 @@ class Mxfp4MarlinMoEMethod:
value=0.0,
)
quant_info = MarlinMoeQuantInfo(
w13_qweight=layer.w13_weight,
w2_qweight=layer.w2_weight,
w13_scales=layer.w13_weight_scale,
w2_scales=layer.w2_weight_scale,
w13_g_idx_sort_indices=None,
w2_g_idx_sort_indices=None,
weight_bits=4,
is_k_full=True,
w13_bias=getattr(layer, "w13_weight_bias", None),
w2_bias=getattr(layer, "w2_weight_bias", None),
)
quant_info = build_marlin_moe_quant_info(layer)
runner_output = self.runner.run(
dispatch_output._replace(hidden_states=hidden_states_padded),
quant_info=quant_info,
@@ -254,6 +254,48 @@ class UnquantizedLinearMethod(LinearMethodBase):
return F.linear(x, layer.weight, bias)
def apply_into(
self,
layer: torch.nn.Module,
x: torch.Tensor,
output: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Run an inference-only BF16 linear into caller-owned storage."""
if (
get_bf16_gemm_backend().is_cutedsl()
and x.is_cuda
and x.ndim == 2
and x.dtype == torch.bfloat16
and layer.weight.dtype == torch.bfloat16
and output.dtype == torch.bfloat16
and output.is_contiguous()
and output.shape == (x.shape[0], layer.weight.shape[0])
and (bias is None or bias.dtype == torch.bfloat16)
and not layer.weight.requires_grad
and (bias is None or not bias.requires_grad)
and _use_cutedsl_bf16_gemm(
x.shape[0], layer.weight.shape[0], layer.weight.shape[1]
)
):
from sglang.kernels.ops.gemm.cutedsl_bf16_gemm import (
cutedsl_bf16_gemm_out,
)
return cutedsl_bf16_gemm_out(x, layer.weight, output, bias)
if x.ndim != 2:
raise ValueError("caller-owned linear output currently requires a 2D input")
if output.shape != (x.shape[0], layer.weight.shape[0]):
raise ValueError(
f"linear output has shape {output.shape}, expected "
f"{(x.shape[0], layer.weight.shape[0])}"
)
torch.mm(x, layer.weight.t(), out=output)
if bias is not None:
output.add_(bias)
return output
class UnquantizedFusedMoEMethod(FusedMoEMethodBase, MultiPlatformOp):
"""MoE method without quantization."""
@@ -74,6 +74,7 @@ class RadixLinearAttention(nn.Module):
self.A_log = A_log
self.dt_bias = dt_bias
self.lower_bound = None
def forward(
self,
@@ -0,0 +1,56 @@
import contextlib
from typing import Iterator, Optional
import msgspec
import torch
class ZeroCopyContext(msgspec.Struct, frozen=True):
moe_output: Optional[torch.Tensor] = None
ctx = ZeroCopyContext()
@contextlib.contextmanager
def set_moe_output(out: torch.Tensor) -> Iterator[None]:
"""Publish `out` as the MoE runner's output destination for the block."""
old_output = ctx.moe_output
msgspec.structs.force_setattr(ctx, "moe_output", out)
try:
yield
finally:
msgspec.structs.force_setattr(ctx, "moe_output", old_output)
def get_moe_output(ref: torch.Tensor) -> Optional[torch.Tensor]:
"""The published destination iff it can stand in for empty_like(ref)."""
return get_moe_output_spec(ref.shape, ref.dtype, ref.device)
def get_moe_output_spec(
shape: torch.Size, dtype: torch.dtype, device: torch.device
) -> Optional[torch.Tensor]:
"""Spec form of get_moe_output for callers that would otherwise have to
materialize a reference tensor just for the match (e.g. the trtllm-gen
runner, whose activation input is fp4-packed and shaped differently
from its output)."""
out = ctx.moe_output
if (
out is not None
and out.shape == shape
and out.dtype == dtype
and out.device == device
and out.is_contiguous()
):
return out
return None
__all__ = [
"ZeroCopyContext",
"ctx",
"set_moe_output",
"get_moe_output",
"get_moe_output_spec",
]
@@ -5,7 +5,7 @@ import logging
import os
import threading
import time
from queue import Queue
from queue import Empty, Queue
from typing import TYPE_CHECKING, Any, Callable, List, Optional
import torch
@@ -675,14 +675,62 @@ class HybridCacheController(BaseHiCacheController):
operation.pool_transfers_done = True
def _page_backup(self, operation):
# Backup extra pools
if operation.pool_transfers:
# MLA KV is replicated across TP ranks and should still be written only
# by TP0. On follower ranks, only the rank-sharded Mamba/KDA pool is
# owned by the rank and must be written here. Do not replicate other
# sidecar pools (for example SWA or indexer state) accidentally.
backup_transfers = operation.pool_transfers
if self.backup_skip:
backup_transfers = [
transfer
for transfer in operation.pool_transfers or []
if transfer.name == PoolName.MAMBA
]
if backup_transfers:
self._resolve_sidecar_derived_pool_transfers(operation)
results = self.storage_backend.batch_set_v2(operation.pool_transfers)
results = self.storage_backend.batch_set_v2(backup_transfers)
operation.pool_storage_result.update_extra_pool_hit_pages(results)
# Backup kv pools
super()._page_backup(operation)
if not self.backup_skip:
super()._page_backup(operation)
else:
sidecar_ok = bool(backup_transfers)
if sidecar_ok:
for transfer in backup_transfers:
result = results.get(transfer.name)
if result is None:
result = results.get(transfer.name.value)
expected = len(transfer.keys or [])
if expected == 0 and transfer.host_indices is not None:
expected = int(transfer.host_indices.numel())
if (
not isinstance(result, (list, tuple))
or len(result) != expected
or not all(bool(ok) for ok in result)
):
sidecar_ok = False
break
operation.completed_tokens = (
len(operation.hash_value) * self.page_size if sidecar_ok else 0
)
def backup_thread_func(self):
"""Back up rank-sharded sidecars on every TP rank.
The base implementation skips the entire operation on non-zero MLA TP
ranks. That optimization is valid for replicated MLA KV, but not for
hybrid rank-sharded pools such as Kimi-K3 Mamba state.
"""
while not self.storage_stop_event.is_set():
try:
operation = self.backup_queue.get(block=True, timeout=1)
if operation is None:
continue
self._page_backup(operation)
self.ack_backup_queue.put(operation)
except Empty:
continue
def _resolve_sidecar_derived_pool_transfers(self, operation):
for transfer in operation.pool_transfers:
@@ -27,7 +27,7 @@ from sglang.srt.mem_cache.pool_host.mha import (
get_mha_host_pool_cls,
)
from sglang.srt.mem_cache.pool_host.mla import MLATokenToKVPoolHost
from sglang.srt.mem_cache.unified_cache.components import ComponentType
from sglang.srt.mem_cache.unified_cache.component_type import ComponentType
from sglang.srt.runtime_context import get_parallel
if TYPE_CHECKING:
@@ -8,7 +8,11 @@ from typing import TYPE_CHECKING, Any, Optional
import msgspec
import torch
from sglang.srt.configs.hybrid_arch import hybrid_gdn_config, mambaish_config
from sglang.srt.configs.hybrid_arch import (
hybrid_gdn_config,
kimi_linear_config,
mambaish_config,
)
from sglang.srt.configs.model_config import (
ModelConfig,
get_dsa_index_head_dim,
@@ -702,9 +706,16 @@ class KVCacheConfigurator:
start_layer=self.layer_info.start_layer,
linear_replayssm_cache_len=get_exec().mamba.linear_replayssm_cache_len,
mamba_envelope_layout=get_memory().enable_page_major_kv_layout,
enable_gdn_replayssm_spec=(
get_exec().mamba.enable_gdn_replayssm_spec
and self.hybrid_gdn_config is not None
# ReplaySSM spec-verify is for linear-attn models (GDN fold or KDA
# fold); activate the pool machinery only for those, so any other
# mamba-ish model (Mamba2/Nemotron, lightning, ...) run with the
# flag set stays byte-identical to flag-off.
enable_linear_replayssm_spec=(
get_exec().mamba.enable_linear_replayssm_spec
and (
self.hybrid_gdn_config is not None
or kimi_linear_config(self.model_config) is not None
)
),
)
return req_to_token_pool
@@ -738,6 +749,18 @@ class KVCacheConfigurator:
max_num_reqs: int,
extra_max_context_len: int,
) -> ReqToTokenPool:
# DSPARK/DFLASH commit routes through the backend fold (KDA-only); a
# non-KDA model there would scatter a None intermediate_ssm and crash.
_algo = (self.server_args.speculative_algorithm or "").upper()
if (
get_exec().mamba.enable_linear_replayssm_spec
and _algo in ("DSPARK", "DFLASH")
and kimi_linear_config(self.model_config) is None
):
raise ValueError(
"--enable-linear-replayssm-spec with DSPARK/DFLASH requires a KDA "
"(kimi_linear) model; got a non-KDA model."
)
req_to_token_pool = HybridReqToTokenPool(
size=max_num_reqs,
mamba_size=get_schedule().max_mamba_cache_size,
@@ -762,9 +785,16 @@ class KVCacheConfigurator:
enable_linear_replayssm=get_exec().mamba.enable_linear_replayssm,
linear_replayssm_cache_len=get_exec().mamba.linear_replayssm_cache_len,
mamba_envelope_layout=get_memory().enable_page_major_kv_layout,
enable_gdn_replayssm_spec=(
get_exec().mamba.enable_gdn_replayssm_spec
and self.hybrid_gdn_config is not None
# ReplaySSM spec-verify is for linear-attn models (GDN fold or KDA
# fold); activate the pool machinery only for those, so any other
# mamba-ish model (Mamba2/Nemotron, lightning, ...) run with the
# flag set stays byte-identical to flag-off.
enable_linear_replayssm_spec=(
get_exec().mamba.enable_linear_replayssm_spec
and (
self.hybrid_gdn_config is not None
or kimi_linear_config(self.model_config) is not None
)
),
)
return req_to_token_pool
@@ -1688,11 +1718,23 @@ class KVCacheConfigurator:
requested_per_worker = None
max_num_reqs = min(estimated, token_capacity // 2)
capped_by_mamba = False
if self.mambaish_config is not None:
ratio = self._calculate_mamba_ratio()
max_num_reqs = min(
max_num_reqs, get_schedule().max_mamba_cache_size // ratio
)
mamba_cap = get_schedule().max_mamba_cache_size // ratio
if mamba_cap < max_num_reqs:
capped_by_mamba = True
logger.warning(
"max_running_requests is capped to %d by the mamba state "
"cache (max_mamba_cache_size=%d, %d state slots per "
"request). To raise it: increase --mamba-full-memory-ratio "
"or --max-mamba-cache-size, or halve the state size with "
"--mamba-ssm-dtype bfloat16.",
mamba_cap,
get_schedule().max_mamba_cache_size,
ratio,
)
max_num_reqs = min(max_num_reqs, mamba_cap)
if max_num_reqs <= 0:
raise RuntimeError(
@@ -1703,7 +1745,11 @@ class KVCacheConfigurator:
f"(2) increase --mem-fraction-static, or "
f"(3) use GPUs with more memory."
)
if requested_per_worker is not None and max_num_reqs < requested_per_worker:
if (
requested_per_worker is not None
and max_num_reqs < requested_per_worker
and not capped_by_mamba
):
logger.warning(
"max_running_requests was reduced from the requested %d to %d "
"(per dp worker) due to the available KV cache capacity.",
@@ -1760,18 +1806,25 @@ class KVCacheConfigurator:
assert config is not None
has_spec_dec = not self.spec_algorithm.is_none()
# ReplaySSM drops the per-step intermediate_ssm scratch, so its mamba budget
# no longer reserves the (1 + D/ratio) intermediate factor -- the whole
# budget goes to persistent slots (K sized like non-spec), which is how the
# freed ~9GB turns into higher max_running.
# The ring is allocated per slot but is not part of mamba_cache_per_req;
# the solve must charge it too or num_slots is over-provisioned.
replayssm_active = (
get_exec().mamba.enable_gdn_replayssm_spec
and self.hybrid_gdn_config is not None
replayssm_active = get_exec().mamba.enable_linear_replayssm_spec and (
self.hybrid_gdn_config is not None
or kimi_linear_config(self.model_config) is not None
)
if replayssm_active:
record_len = (
server_args.max_speculative_num_draft_tokens
if server_args.max_speculative_num_draft_tokens is not None
else get_exec().mamba.linear_replayssm_cache_len
)
# GDN sizes the fold window to the draft maximum; the KDA ring
# stays --linear-replayssm-cache-len long (mirrors MambaPool).
if kimi_linear_config(self.model_config) is not None:
record_len = get_exec().mamba.linear_replayssm_cache_len
elif server_args.max_speculative_num_draft_tokens is not None:
record_len = server_args.max_speculative_num_draft_tokens
else:
record_len = get_exec().mamba.linear_replayssm_cache_len
replayssm_ring_per_req = (
config.mamba2_cache_params.replayssm_ring_bytes_per_req(
record_len=record_len
@@ -1790,7 +1843,9 @@ class KVCacheConfigurator:
max_mamba_cache_size=get_schedule().max_mamba_cache_size
// self.ps.attn_dp_size,
)
# Reserve intermediate memory based on capped max_num_reqs (+1 padding slot)
# Reserve intermediate memory based on capped max_num_reqs (+1: the
# pool's padding slot, see memory_pool.py). Skipped under replayssm
# (no intermediate_ssm allocated).
if has_spec_dec and not replayssm_active:
ratio = self._calculate_mamba_ratio()
capped_reqs = min(
@@ -1813,7 +1868,8 @@ class KVCacheConfigurator:
max_mamba_cache_size=get_schedule().max_running_requests
// self.ps.attn_dp_size,
)
# Reserve intermediate memory based on capped max_num_reqs (+1 padding slot)
# Reserve intermediate memory based on capped max_num_reqs (+1: the
# pool's padding slot). Skipped under replayssm.
if has_spec_dec and not replayssm_active:
intermediate_size = (
config.mamba2_cache_params.mamba_cache_per_req
@@ -1880,7 +1936,9 @@ class KVCacheConfigurator:
f"(4) use GPUs with more memory."
)
# +1: the pool's padding slot
# +1: the pool's padding slot is allocated alongside the request slots.
# ReplaySSM ring rides on every slot too (replayssm_ring_per_req is 0 when
# the ring is not allocated).
mamba_state_memory = (
(get_schedule().max_mamba_cache_size + 1)
* (config.mamba2_cache_params.mamba_cache_per_req + replayssm_ring_per_req)
+51 -23
View File
@@ -344,7 +344,7 @@ class MambaPool:
# replayssm_rawv: [num_layers, num_slots, HV, L, V] (conv/activation dtype)
# replayssm_rawk: [num_layers, num_slots, H, L, K] (conv/activation dtype)
# replayssm_beta: [num_layers, num_slots, HV, L] (fp32)
# The raw rings + beta exist only under --enable-gdn-replayssm-spec: the
# The raw rings + beta exist only under --enable-linear-replayssm-spec: the
# closed-loop exact fold sequentially replays them through the recurrent
# update at flush -- bit-identical to the recurrent baseline -- instead
# of folding the chunked `d` records open-loop (which accumulates error
@@ -380,7 +380,7 @@ class MambaPool:
@dataclass(frozen=True, kw_only=True)
class SpeculativeState(State):
# None under --enable-gdn-replayssm-spec: the spec ring owns rollback
# None under --enable-linear-replayssm-spec: the spec ring owns rollback
# (verify writes ring records, commit moves cursors), so the per-draft
# full-state snapshots are never produced or consumed.
intermediate_ssm: Optional[torch.Tensor]
@@ -468,7 +468,7 @@ class MambaPool:
enable_linear_replayssm: bool = False,
linear_replayssm_cache_len: int = 16,
envelope_layout: bool = False,
enable_gdn_replayssm_spec: bool = False,
enable_linear_replayssm_spec: bool = False,
):
conv_state_shape = cache_params.shape.conv
temporal_state_shape = cache_params.shape.temporal
@@ -487,12 +487,13 @@ class MambaPool:
self.linear_replayssm_cache_len = linear_replayssm_cache_len
# ReplaySSM: the decode ring (--enable-linear-replayssm) allocates the
# chunked (d, k) records + write_pos; the spec-verify flag
# (--enable-gdn-replayssm-spec) always uses fold-every-commit and
# (--enable-linear-replayssm-spec) always uses fold-every-commit and
# allocates only the raw (v, k, g, beta) window -- no chunked records,
# no cursors. The shared g allocation gates on `_replayssm_on`.
self.enable_gdn_replayssm_spec = enable_gdn_replayssm_spec
self.replayssm_spec_fold = bool(enable_gdn_replayssm_spec)
_replayssm_on = enable_linear_replayssm or enable_gdn_replayssm_spec
# no cursors (KDA additionally keeps d/k, see the allocation below).
# The shared g allocation gates on `_replayssm_on`.
self.enable_linear_replayssm_spec = enable_linear_replayssm_spec
self.replayssm_spec_fold = bool(enable_linear_replayssm_spec)
_replayssm_on = enable_linear_replayssm or enable_linear_replayssm_spec
# for disagg with nvlink
self.enable_custom_mem_pool, self.custom_mem_pool, _ = (
@@ -570,7 +571,7 @@ class MambaPool:
# flag is on; otherwise left as None so the legacy State is
# byte-identical. temporal_state_shape == (HV, V, K). Either the decode
# ring (--enable-linear-replayssm) or the spec-verify ring
# (--enable-gdn-replayssm-spec) shares this allocation.
# (--enable-linear-replayssm-spec) shares this allocation.
replayssm_d = replayssm_k = replayssm_g = None
replayssm_rawv = replayssm_rawk = replayssm_beta = None
if _replayssm_on:
@@ -580,16 +581,23 @@ class MambaPool:
num_slots = size + 1
# Ring dtype. DECODE ring (--enable-linear-replayssm): records
# follow the SSM dtype -- its flush folds `d` directly into the
# state. SPEC-verify ring (--enable-gdn-replayssm-spec): d/k feed
# state. SPEC-verify ring (--enable-linear-replayssm-spec): d/k feed
# ONLY the one-shot output reconstruction (the closed-loop exact
# fold replays the raw rings for state instead), so their
# quantization noise stays below the bf16 output cast; keep them
# in the conv/activation dtype instead of the (fp32-enforced)
# SSM dtype to halve the ring traffic. g stays fp32 everywhere
# (exact-fold input). The two flags are mutually exclusive.
ring_dtype = conv_dtype if enable_gdn_replayssm_spec else ssm_dtype
# Fold-every-commit: one verify window, no chunked (d, k) records.
if self.replayssm_spec_fold:
ring_dtype = conv_dtype if enable_linear_replayssm_spec else ssm_dtype
# Fold-every-commit: one verify window, no chunked (d, k)
# records. KDA is the exception on both counts: its window
# stays L-sized (the fused verify ring-write drops
# absorb-inflated rows past L), and d/k stay allocated --
# forward_decode routes on `replayssm_d is None` (fused vs
# decode-ring), so skipping them would flip KDA decode to the
# fused path, a behavior change needing its own validation
# (memory follow-up).
if self.replayssm_spec_fold and not cache_params.is_kda:
record_len = (
speculative_num_draft_tokens
if speculative_num_draft_tokens is not None
@@ -597,6 +605,7 @@ class MambaPool:
)
else:
record_len = L
if not self.replayssm_spec_fold or cache_params.is_kda:
replayssm_d = torch.zeros(
size=(num_mamba_layers, num_slots, hv, L, v_dim),
dtype=ring_dtype,
@@ -626,7 +635,22 @@ class MambaPool:
# flush replays these through the recurrent update sequentially
# (bit-identical to the recurrent baseline) instead of folding
# the chunked `d` records open-loop.
if enable_gdn_replayssm_spec:
if enable_linear_replayssm_spec:
if cache_params.is_kda:
# Backstop for the KDA ring invariants; this pool is
# sized with the final adaptive-aware draft maximum.
if L & (L - 1) != 0:
raise ValueError(
f"spec-verify ring length must be a power of two, got {L}"
)
if (
speculative_num_draft_tokens is not None
and L < 2 * speculative_num_draft_tokens
):
raise ValueError(
f"spec-verify ring too small: {L} < "
f"2 * {speculative_num_draft_tokens} (early-flush margin)"
)
replayssm_rawv = torch.zeros(
size=(num_mamba_layers, num_slots, hv, record_len, v_dim),
dtype=conv_dtype,
@@ -662,7 +686,11 @@ class MambaPool:
# The recurrent-verify fallback cannot be reached under the flag
# (GDN + linear chain + triton enforced in server_args; the
# backend asserts loudly if it ever is).
if enable_gdn_replayssm_spec:
# ReplaySSM skips this dominant scratch (~9GB @ K3 dspark γ=7): the
# KDA verify kernel takes intermediate_states_buffer=None (skips the
# per-step write, CACHE_INTERMEDIATE_STATES=False) and the commit
# replays the ring into the checkpoint instead. This is the memory win.
if enable_linear_replayssm_spec:
intermediate_ssm_state_cache = None
else:
intermediate_ssm_state_cache = torch.zeros(
@@ -795,7 +823,7 @@ class MambaPool:
f"rawv={get_tensor_size_bytes(replayssm_rawv) / GB:.3f}GB, "
f"rawk={get_tensor_size_bytes(replayssm_rawk) / GB:.3f}GB, "
f"beta={get_tensor_size_bytes(replayssm_beta) / GB:.3f}GB "
if enable_gdn_replayssm_spec
if enable_linear_replayssm_spec
else ""
)
)
@@ -818,12 +846,12 @@ class MambaPool:
# all GDN layers of one verify step; advanced by commit_gdn_replayssm_spec.
self.replayssm_cache_base = (
torch.zeros((size + 1,), dtype=torch.int32, device=device)
if enable_gdn_replayssm_spec and not self.replayssm_spec_fold
if enable_linear_replayssm_spec and not self.replayssm_spec_fold
else None
)
self.replayssm_is_flush = (
torch.zeros((size + 1,), dtype=torch.int8, device=device)
if enable_gdn_replayssm_spec and not self.replayssm_spec_fold
if enable_linear_replayssm_spec and not self.replayssm_spec_fold
else None
)
mem_usage_bytes = self.mamba_cache.mem_usage_bytes()
@@ -1130,7 +1158,7 @@ class HybridReqToTokenPool(ReqToTokenPool):
enable_linear_replayssm: bool = False,
linear_replayssm_cache_len: int = 16,
mamba_envelope_layout: bool = False,
enable_gdn_replayssm_spec: bool = False,
enable_linear_replayssm_spec: bool = False,
):
super().__init__(
size=size,
@@ -1157,7 +1185,7 @@ class HybridReqToTokenPool(ReqToTokenPool):
enable_linear_replayssm=enable_linear_replayssm,
linear_replayssm_cache_len=linear_replayssm_cache_len,
mamba_envelope_layout=mamba_envelope_layout,
enable_gdn_replayssm_spec=enable_gdn_replayssm_spec,
enable_linear_replayssm_spec=enable_linear_replayssm_spec,
)
def _init_mamba_pool(
@@ -1173,7 +1201,7 @@ class HybridReqToTokenPool(ReqToTokenPool):
enable_linear_replayssm: bool = False,
linear_replayssm_cache_len: int = 16,
mamba_envelope_layout: bool = False,
enable_gdn_replayssm_spec: bool = False,
enable_linear_replayssm_spec: bool = False,
):
self.mamba_pool = self.mamba_pool_cls(
size=mamba_size,
@@ -1187,7 +1215,7 @@ class HybridReqToTokenPool(ReqToTokenPool):
enable_linear_replayssm=enable_linear_replayssm,
linear_replayssm_cache_len=linear_replayssm_cache_len,
envelope_layout=mamba_envelope_layout,
enable_gdn_replayssm_spec=enable_gdn_replayssm_spec,
enable_linear_replayssm_spec=enable_linear_replayssm_spec,
)
self.mamba_allocator = MambaSlotAllocator(
size=mamba_size,
@@ -3663,7 +3691,7 @@ class HybridLinearKVPool(KVCache):
def get_kv_buffer_shape(self) -> Tuple[torch.Size, torch.Size]:
# Hybrid layer ids are global model-layer ids, while the backing pool
# is dense over only full-attention layers. Shape discovery does not
# is dense over only full-attention layers. Shape discovery does not
# need a global layer lookup, so delegate it to that backing pool.
return self.full_kv_pool.get_kv_buffer_shape()
@@ -710,13 +710,14 @@ class UnifiedMambaPool(MambaPool):
self.enable_custom_mem_pool = False
self.custom_mem_pool = None
self.num_mamba_layers = spec.layer_num
# GDN/KDA ReplaySSM unsupported; replicate parent's disabled-state attrs so
# paths guarded by `replayssm_write_pos is not None` don't AttributeError.
# GDN/KDA ReplaySSM / spec unsupported; replicate parent's disabled-state
# attrs so unconditional reads (e.g. `replayssm_cache_base is not None` in
# the req-slot alloc path) and `... is not None` guards don't AttributeError.
self.enable_linear_replayssm = False
self.linear_replayssm_cache_len = 16
self.replayssm_write_pos = None
self.replayssm_is_kda = False
self.enable_gdn_replayssm_spec = False
self.enable_linear_replayssm_spec = False
self.replayssm_spec_fold = False
self.replayssm_cache_base = None
self.replayssm_is_flush = None
@@ -941,10 +942,10 @@ class UnifiedHybridReqToTokenPool(HybridReqToTokenPool):
mamba_envelope_layout: bool = False,
enable_linear_replayssm: bool = False,
linear_replayssm_cache_len: int = 16,
enable_gdn_replayssm_spec: bool = False,
enable_linear_replayssm_spec: bool = False,
):
# mamba_envelope_layout / speculative_eagle_topk / enable_linear_replayssm /
# linear_replayssm_cache_len / enable_gdn_replayssm_spec: accepted to match
# linear_replayssm_cache_len / enable_linear_replayssm_spec: accepted to match
# the parent signature but NOT forwarded — the shared pool's conv/temporal
# state are fixed-shape views (replayssm/spec are gated off under unified).
assert mamba_size == self._shared_mamba_size, (
@@ -66,12 +66,35 @@ def share_input_buffers_in(obj) -> None:
setattr(obj, name, share_input_buffer(name, buffer))
# Values that index the rope table, the KV pool, req_to_token, or the mamba
# state pool, so stale content is unsafe to execute.
_INDEX_SEMANTIC_BUFFERS = frozenset(
{
"positions",
"mrope_positions",
"out_cache_loc",
"req_pool_indices",
"mamba_track_indices",
"mamba_track_mask",
}
)
@dataclass
class ForwardInputBuffers:
def _share_one_buffer(self, name: str, new_buffer: torch.Tensor) -> torch.Tensor:
return share_input_buffer(name, new_buffer)
def reset_index_buffers(self) -> None:
"""Zero the index-semantic buffers this set declares."""
for f in fields(self):
if f.name not in _INDEX_SEMANTIC_BUFFERS:
continue
buffer = getattr(self, f.name)
if buffer is not None:
buffer.zero_()
def share_buffers(self):
# disable share input buffer on npu due to accuracy issue
if is_npu():
@@ -134,6 +134,7 @@ from sglang.srt.model_executor.model_runner_components.load_model_utils import (
load_model_with_memory_saver,
maybe_downgrade_dtype_for_legacy_gpu,
maybe_enable_ipc_weight_cache,
maybe_precompile_model_kernels_after_loading,
maybe_register_debug_tensor_dump_hook,
maybe_trigger_remote_instance_nccl_send_group,
report_online_quantization,
@@ -736,6 +737,14 @@ class ModelRunner:
start_layer=self.layer_info.start_layer,
)
def get_pp_proxy_residual_num_blocks(self) -> Optional[int]:
return misc_utils.resolve_pp_proxy_residual_num_blocks(
model_config=self.model_config,
pp_size=self.ps.pp_size,
pp_rank=self.ps.pp_rank,
start_layer=self.layer_info.start_layer,
)
def decode_num_tokens_per_req(
self, *, num_draft_tokens: Optional[int] = None
) -> int:
@@ -1060,6 +1069,8 @@ class ModelRunner:
if not self.is_draft_worker:
get_offloader().post_init()
self.maybe_precompile_model_kernels_after_loading()
# Register model for layerwise NVTX profiling if enabled
if get_exec().comm.enable_layerwise_nvtx_marker:
pyt_hooks = PytHooks()
@@ -1124,6 +1135,9 @@ class ModelRunner:
is_ep_joiner=self.server_args.is_ep_joiner,
)
def maybe_precompile_model_kernels_after_loading(self) -> None:
maybe_precompile_model_kernels_after_loading(self.model, self.device)
def maybe_init_dwdp(self):
if self.is_draft_worker:
return
@@ -1190,7 +1204,9 @@ class ModelRunner:
model_dtype=getattr(self, "dtype", torch.bfloat16),
is_draft_worker=getattr(self, "is_draft_worker", False),
is_dflash=(
spec_algorithm.is_dflash() if spec_algorithm is not None else False
spec_algorithm.is_dflash_family()
if spec_algorithm is not None
else False
),
speculative_draft_attention_backend=getattr(
self.server_args, "speculative_draft_attention_backend", None
@@ -24,6 +24,7 @@ from sglang.srt.model_loader.remote_instance_weight_loader_utils import (
RemoteInstanceWeightLoaderBackend,
trigger_init_weights_send_group_for_remote_instance_request,
)
from sglang.srt.platforms import current_platform
from sglang.srt.utils.common import is_npu
from sglang.srt.utils.network import NetworkAddress
@@ -40,6 +41,20 @@ _is_npu = is_npu()
UNBALANCED_MODEL_LOADING_TIMEOUT_S = 480 # leave more time for post data processing
def maybe_precompile_model_kernels_after_loading(model, device: str) -> None:
precompile = getattr(model, "precompile_kernels_after_loading", None)
if precompile is None:
return
if device == "cuda":
current_platform.synchronize()
current_platform.empty_cache()
precompile()
if device == "cuda":
current_platform.synchronize()
current_platform.empty_cache()
class LoadedModel(msgspec.Struct, frozen=True, kw_only=True):
loader: Any
model: Any
@@ -3,7 +3,11 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Optional
from sglang.srt.configs.model_config import dsa_layer_skips_topk, is_deepseek_dsa
from sglang.srt.configs.model_config import (
dsa_layer_skips_topk,
is_deepseek_dsa,
is_kimi_k3,
)
from sglang.srt.runtime_context import get_context, get_exec, get_schedule
from sglang.srt.server_args import CHUNKED_PREFIX_CACHE_SUPPORTED_ATTENTION_BACKENDS
@@ -68,3 +72,16 @@ def resolve_pp_proxy_topk_size(
):
return None
return getattr(hf_config, "index_topk", None)
def resolve_pp_proxy_residual_num_blocks(
*, model_config: ModelConfig, pp_size: int, pp_rank: int, start_layer: int
) -> Optional[int]:
"""Return the inherited Kimi K3 attention-residual bank width."""
if pp_size <= 1 or pp_rank == 0 or not is_kimi_k3(model_config.hf_config):
return None
block_size = getattr(model_config.hf_text_config, "attn_res_block_size", None)
if block_size is None:
return None
return (start_layer + block_size - 1) // block_size
@@ -81,6 +81,7 @@ def _allocate_decode_buffers(
ne_token_table: Optional[torch.Tensor] = None,
hc_hidden_size: Optional[int] = None,
pp_proxy_topk_size: Optional[int] = None,
pp_proxy_residual_num_blocks: Optional[int] = None,
) -> SimpleNamespace:
"""Allocate the FB-shared decode buffers."""
with torch.device(device):
@@ -115,9 +116,14 @@ def _allocate_decode_buffers(
"hidden_states": torch.zeros((max_bs, hs), dtype=dtype),
}
if not is_mhc:
pp_proxy_tensors["residual"] = torch.zeros(
(max_bs, hidden_size), dtype=dtype
# Only Kimi K3 supplies num_blocks: its PP bank is token-major
# [T, blocks, H]. Other models keep the legacy [max_bs, H].
residual_shape = (
(max_num_token, pp_proxy_residual_num_blocks, hidden_size)
if pp_proxy_residual_num_blocks is not None
else (max_bs, hidden_size)
)
pp_proxy_tensors["residual"] = torch.zeros(residual_shape, dtype=dtype)
if pp_proxy_topk_size is not None:
pp_proxy_tensors["topk_indices"] = torch.zeros(
(max_num_token, pp_proxy_topk_size), dtype=torch.int32
@@ -338,6 +344,7 @@ class BaseRunner(ABC):
),
hc_hidden_size=getattr(mr.model_config, "hc_hidden_size", None),
pp_proxy_topk_size=mr.get_pp_proxy_topk_size(),
pp_proxy_residual_num_blocks=mr.get_pp_proxy_residual_num_blocks(),
)
def _dummy_run(
@@ -297,6 +297,9 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
else None
)
self._ragged_graph_size = 0
# Per-tier capture layouts; their verify_lens / qo_indptr tensors are
# baked into the captured graphs and refreshed in place each replay.
self._captured_ragged_layouts: dict[int, object] = {}
if self.ragged_verify_mode and (
self.enable_two_batch_overlap
or model_runner.server_args.enable_lora
@@ -377,6 +380,9 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
self.model_runner.model_config, "hc_hidden_size", None
),
pp_proxy_topk_size=self.model_runner.get_pp_proxy_topk_size(),
pp_proxy_residual_num_blocks=(
self.model_runner.get_pp_proxy_residual_num_blocks()
),
)
self.buffers.share_buffers()
# FB-shared slot registry adopting DecodeInputBuffers storage (same
@@ -495,11 +501,27 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
num_slots=self._ragged_capture_slots(num_tokens),
num_draft_tokens=self.captured_req_width,
)
return RaggedVerifyLayout.from_verify_lens(
layout = RaggedVerifyLayout.from_verify_lens(
verify_lens_cpu=verify_lens_cpu,
device=self.device,
grid=self.capture_num_tokens,
)
self._captured_ragged_layouts[num_tokens] = layout
return layout
def _stage_ragged_verify_layout(self, ragged_layout, graph_size_key: int) -> None:
# Without this refresh every replay reuses the capture-time synthetic
# verify_lens / qo_indptr and mis-slices the packed q rows.
cap_layout = self._captured_ragged_layouts.get(graph_size_key)
if cap_layout is None:
return
live = ragged_layout
if live.bs != cap_layout.bs or live.cap is None:
live = live.padded_to_bucket(
padded_bs=cap_layout.bs, cap=self.captured_req_width
)
cap_layout.verify_lens.copy_(live.verify_lens)
cap_layout.qo_indptr_device.copy_(live.qo_indptr_device)
def can_run_graph(self, forward_batch: ForwardBatch):
# Disable for token embedding overrides (dynamic per-request)
@@ -823,6 +845,10 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
# captured graph needs before capturing.
self.buffers.seq_lens.fill_(self.seq_len_fill_value)
self.buffers.seq_lens_cpu.fill_(self.seq_len_fill_value)
# Capture runs real forwards, so a mid-serving recapture would index --
# and write KV -- through the previous batch's live values. Replay is
# already covered by the registry's padding policy.
self.buffers.reset_index_buffers()
# Trigger CUDA graph capture for specific shapes.
# Capture the large shapes first so that the smaller shapes
@@ -1036,6 +1062,7 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
f"stale ragged raw_num_token {self.raw_num_token} != "
f"{ragged_layout.graph_num_tokens}"
)
self._stage_ragged_verify_layout(ragged_layout, graph_size_key)
self.buffers.input_ids[: self.raw_num_token].copy_(forward_batch.input_ids)
self.buffers.positions[: self.raw_num_token].copy_(forward_batch.positions)
if (
@@ -1072,6 +1099,7 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
f"{raw_bs}; the planner must reject this batch before replay"
)
padded_num_tokens = graph_size_key
self._stage_ragged_verify_layout(ragged_layout, graph_size_key)
else:
raw_num_token = raw_bs * self.captured_req_width
if self.require_mlp_tp_gather:
@@ -337,6 +337,12 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
self.has_mha_companion_layers = any(
layer is not None for layer in self.mha_companion_layers
)
# Archs on the MLA-BCG allowlist pin the absorbed MLA path inside
# capture/replay (attention_backend_handler), so the MHA companion is
# never captured and the MHA-prefix restrictions below don't apply.
self.mla_pinned_under_bcg = (
self.model_runner.model_config.is_mla_breakable_cuda_graph_supported
)
self.moe_layers = self.model_runner.moe_layers
self.moe_fusions = self.model_runner.moe_fusions
self.dsa_indexers = getattr(self.model_runner, "dsa_indexers", None)
@@ -1052,11 +1058,14 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
return False
# A prefix forces the MHA companion path, whose captured state is
# frozen prefix-free; DSA models are exempt (capture/replay force
# the sparse path, which takes any prefix via device metadata).
# the sparse path, which takes any prefix via device metadata), as
# are archs on the MLA-BCG allowlist (they pin the absorbed MLA path
# inside capture/replay, so the MHA companion is never captured).
if (
self.prefill_backend_name == Backend.BREAKABLE
and self.has_mha_companion_layers
and not self.dsa_sparse_prefill_forced
and not self.mla_pinned_under_bcg
and prefix_lens is not None
and any(prefix_lens)
):
@@ -1536,6 +1545,7 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
if (
isinstance(self.backend, BreakableCudaGraphBackend)
and self.has_mha_companion_layers
and not self.mla_pinned_under_bcg
):
self._restore_mha_capture_state(static_forward_batch)
@@ -105,6 +105,7 @@ class DecodeInputBuffers(ForwardInputBuffers):
ne_token_table: Optional[torch.Tensor] = None,
hc_hidden_size: Optional[int] = None,
pp_proxy_topk_size: Optional[int] = None,
pp_proxy_residual_num_blocks: Optional[int] = None,
) -> DecodeInputBuffers:
with torch.device(device):
input_ids = torch.zeros((max_num_token,), dtype=torch.int64)
@@ -135,8 +136,15 @@ class DecodeInputBuffers(ForwardInputBuffers):
"hidden_states": torch.zeros((max_bs, hs), dtype=dtype),
}
if not is_mhc:
# Only Kimi K3 supplies num_blocks: its PP bank is token-major
# [T, blocks, H]. Other models keep the legacy [max_bs, H].
residual_shape = (
(max_num_token, pp_proxy_residual_num_blocks, hidden_size)
if pp_proxy_residual_num_blocks is not None
else (max_bs, hidden_size)
)
pp_proxy_tensors["residual"] = torch.zeros(
(max_bs, hidden_size), dtype=dtype
residual_shape, dtype=dtype
)
if pp_proxy_topk_size is not None:
pp_proxy_tensors["topk_indices"] = torch.zeros(
@@ -80,7 +80,10 @@ def _support_mha_one_shot(attn, forward_batch, backend_name):
def _handle_attention_backend(attn, forward_batch, backend_name):
if is_in_tc_piecewise_cuda_graph():
# Captured prefill (tc_piecewise or breakable) must keep a single attention
# path: pin the absorbed MLA method — MHA one-shot/chunked shapes vary with
# kv-len and cannot be captured.
if is_in_tc_piecewise_cuda_graph() or is_in_breakable_cuda_graph():
return AttnForwardMethod.MLA
# MLA prefill CP forces absorbed MLA regardless of prefix length: the
@@ -145,7 +148,7 @@ def handle_attention_fa4(attn, forward_batch):
def handle_attention_trtllm_mla(attn, forward_batch):
if is_in_tc_piecewise_cuda_graph():
if is_in_tc_piecewise_cuda_graph() or is_in_breakable_cuda_graph():
return AttnForwardMethod.MLA
sum_extend_prefix_lens = _get_sum_extend_prefix_lens(forward_batch)
@@ -190,7 +193,7 @@ def handle_attention_dsa(attn, forward_batch):
def handle_attention_triton(attn, forward_batch):
if is_in_tc_piecewise_cuda_graph():
if is_in_tc_piecewise_cuda_graph() or is_in_breakable_cuda_graph():
return AttnForwardMethod.MLA
# when deterministic inference is enabled, use MLA
@@ -27,6 +27,7 @@ from sglang.srt.layers.dcp import (
cp_lse_ag_out_rs_mla,
dcp_a2a_lse_reduce,
)
from sglang.srt.layers.logits_processor import get_in_autotune_dummy_run
from sglang.srt.layers.quantization.fp8_utils import (
materialize_bpreshuffle_fp8_scale_tuple,
)
@@ -68,7 +69,7 @@ from sglang.srt.models.deepseek_common.utils import (
_use_aiter_bpreshuffle_gfx95,
_use_aiter_gfx95,
)
from sglang.srt.runtime_context import get_exec, get_parallel, get_server_args, get_spec
from sglang.srt.runtime_context import get_exec, get_parallel, get_server_args
from sglang.srt.state_capturer.indexer_topk import (
maybe_capture_indexer_topk,
)
@@ -92,23 +93,12 @@ class MlaBmmFusionPlan:
attn_output_buf: torch.Tensor
def _is_dcp_mla_decode_phase(forward_batch: ForwardBatch) -> bool:
if not get_parallel().dcp_enabled:
return False
if forward_batch.forward_mode.is_decode():
return True
if not forward_batch.forward_mode.is_target_verify() or not _is_cuda:
return False
server_args = get_server_args()
decode_backend = (
server_args.decode_attention_backend or server_args.attention_backend
)
return (
get_spec().speculative_algorithm == "DSPARK"
and get_spec().speculative_attention_mode == "decode"
and decode_backend in ("tokenspeed_mla", "cutedsl_mla")
)
def _select_local_dcp_heads_for_autotune(
attn_output: torch.Tensor, num_local_heads: int
) -> torch.Tensor:
"""Select this rank's head shard without communicating dummy outputs."""
rank = get_parallel().attn_dcp_rank
return attn_output.narrow(1, rank * num_local_heads, num_local_heads)
def _is_mla_dcp_lse_base_on_e(attention_backend: Optional[str]) -> bool:
@@ -366,7 +356,11 @@ class DeepseekMLAForwardMixin:
# weights and skip the per-layer Q all-gather (bf16 decode absorb only).
q_replicate_active = (
get_parallel().dcp_replicate_q_proj
and _is_dcp_mla_decode_phase(forward_batch)
and get_parallel().dcp_enabled
and (
forward_batch.forward_mode.is_decode()
or forward_batch.forward_mode.is_target_verify()
)
and not self.use_deep_gemm_bmm
and self.w_kc_qrep is not None
and self.q_b_proj_qrep_weight is not None
@@ -802,7 +796,10 @@ class DeepseekMLAForwardMixin:
# all_gather q_pe, q_nope_out,take tp8 as an example q_pe [B, H, ROPE_DIM], q_nope_out [B, H, NOPE_DIM] gathered to [B, H * dcp_world_size, ROPE_DIM] [B, H * dcp_world_size, NOPE_DIM] for decode batch, and all gather k_pe, k_nope for extend batch.
if get_parallel().dcp_enabled:
if _is_dcp_mla_decode_phase(forward_batch):
if (
forward_batch.forward_mode.is_decode()
or forward_batch.forward_mode.is_target_verify()
):
if not q_replicate_active:
q_nope_out, q_pe = all_gather_q_for_mla_decode(
q_nope_out=q_nope_out,
@@ -955,7 +952,10 @@ class DeepseekMLAForwardMixin:
topk_indices=topk_indices,
)
attn_output = fusion_plan.attn_output_buf
elif _is_dcp_mla_decode_phase(forward_batch):
elif (
forward_batch.forward_mode.is_decode()
or forward_batch.forward_mode.is_target_verify()
) and get_parallel().dcp_enabled:
# set return_lse=True to correct attn_output
attn_output, lse = self.attn_mqa_for_dcp_decode(
q_nope_out,
@@ -1029,31 +1029,44 @@ class DeepseekMLAForwardMixin:
)
# correct attn_output with respect to lse from other ranks
if _is_dcp_mla_decode_phase(forward_batch):
if (
forward_batch.forward_mode.is_decode()
or forward_batch.forward_mode.is_target_verify()
) and get_parallel().dcp_enabled:
attn_output = attn_output.view(
-1,
self.num_local_heads * get_parallel().attn_dcp_size,
self.kv_lora_rank,
)
dcp_comm_backend = get_parallel().dcp_comm_backend
is_lse_base_on_e = _is_mla_dcp_lse_base_on_e(self.current_attention_backend)
if dcp_comm_backend in ("a2a", "fi_a2a"):
# A2A exchange of head partials + LSE, then local Triton combine.
attn_output = dcp_a2a_lse_reduce(
attn_output.contiguous(),
lse.contiguous(),
get_parallel().dcp_group,
is_lse_base_on_e=is_lse_base_on_e,
comm_backend=dcp_comm_backend,
if get_in_autotune_dummy_run():
# The synthetic FlashInfer MoE autotune pass discards model
# outputs. Avoid an unnecessary cross-node MNNVL exchange of
# zero attention partials.
attn_output = _select_local_dcp_heads_for_autotune(
attn_output, self.num_local_heads
)
else:
attn_output = cp_lse_ag_out_rs_mla(
attn_output,
lse,
get_parallel().dcp_group,
is_lse_base_on_e=is_lse_base_on_e,
dcp_comm_backend = get_parallel().dcp_comm_backend
is_lse_base_on_e = _is_mla_dcp_lse_base_on_e(
self.current_attention_backend
)
attn_output = attn_output.transpose(0, 1)
if dcp_comm_backend in ("a2a", "fi_a2a"):
# A2A exchange of head partials + LSE, then local Triton combine.
attn_output = dcp_a2a_lse_reduce(
attn_output.contiguous(),
lse.contiguous(),
get_parallel().dcp_group,
is_lse_base_on_e=is_lse_base_on_e,
comm_backend=dcp_comm_backend,
)
else:
attn_output = cp_lse_ag_out_rs_mla(
attn_output,
lse,
get_parallel().dcp_group,
is_lse_base_on_e=is_lse_base_on_e,
)
attn_output = attn_output.transpose(0, 1)
attn_output = attn_output.view(-1, self.num_local_heads, self.kv_lora_rank)
_kvb_v = None
+8 -2
View File
@@ -596,11 +596,17 @@ class DSparkDraftMixin:
class DSparkDraftModel(DSparkDraftMixin, DFlashDraftModel):
pass
def prune_to_ctx_kv_injection(self) -> None:
self.markov_head = None
self.confidence_head = None
for layer in self.layers:
layer.mlp = None
layer.self_attn.o_proj = None
torch.cuda.empty_cache()
class Qwen3DSparkModel(DSparkDraftModel):
pass
EntryClass = [Qwen3DSparkModel]
EntryClass = [Qwen3DSparkModel, DSparkDraftModel]
@@ -217,6 +217,7 @@ class InklingBatchDenseMLP(nn.Module, FusedMoELoadingMixin):
self.quant_method,
self.moe_runner_config,
self.moe_tp_rank,
self.moe_tp_size,
)
self.quant_method.create_weights(
layer=self,
@@ -88,6 +88,7 @@ class FusedMoELoadingMixin(abc.ABC):
quant_method: UnquantizedFusedMoEMethod,
moe_runner_config: MoeRunnerConfig,
moe_tp_rank: int,
moe_tp_size: int,
) -> None:
super().__init__()
helper = FusedMoE.__new__(FusedMoE)
@@ -97,6 +98,7 @@ class FusedMoELoadingMixin(abc.ABC):
helper.moe_runner_config = moe_runner_config
helper.use_triton_kernels = False
helper.moe_tp_rank = moe_tp_rank
helper.moe_tp_size = moe_tp_size
helper.use_presharded_weights = False
helper.use_flashinfer_trtllm_moe = False
# Keep this parameterless loading helper out of the module tree so
File diff suppressed because it is too large Load Diff
+937
View File
@@ -0,0 +1,937 @@
"""Kimi K3 vision tower (MoonViT3d) and projector.
Faithful port of the checkpoint reference implementation
(modeling_kimi_k3.py). Dedicated to K3 do not share with Kimi K2.5:
K3 uses qkv_hidden_size != hidden_size (head_dim = qkv_hidden_size //
num_heads), RMSNorm encoder norms, bias-free linears, and the
PatchMergerMLPV2 projector (no pre-norm, post RMSNorm), all of which
differ from the K2.5 vision code.
Weight names match the checkpoint exactly (wqkv/wo, mlp.fc0/fc1,
mm_projector.proj.0/proj.2, mm_projector.post_norm), so loading needs no
renames.
"""
from dataclasses import dataclass, replace
from typing import List, Optional, Sequence, Tuple, Union
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
from sglang.kernels.ops.attention.vision_rope import (
apply_fused_qk_complex_rope,
precompile_fused_qk_complex_rope,
)
from sglang.srt.environ import envs
from sglang.srt.layers.attention.vision import (
FLASHINFER_WORKSPACE_SIZE_BYTES,
QKV_BACKEND_IMPL,
VisionAttentionMetadata,
prepare_flashinfer_cudnn_vision_attention_metadata,
prepare_vision_attention_metadata,
)
from sglang.srt.models.kimi_vl_moonvit import tpool_patch_merger
from sglang.srt.multimodal.mm_utils import concat_or_single
from sglang.srt.runtime_context import get_server_args
from sglang.srt.utils import get_bool_env_var, is_hip, print_info_once
_is_hip = is_hip()
_use_aiter = get_bool_env_var("SGLANG_USE_AITER") and _is_hip
if _use_aiter:
from aiter.ops.triton.conv.conv2d import conv2d as aiter_conv2d
_SM103_TRITON_MAX_SEQLEN = 1536
_SM103_FA4_MIN_ATTENTION_WORK = 3_000_000
GridTHW = Tuple[int, int, int]
SegmentBounds = Tuple[Tuple[int, int], ...]
def _resolve_grid_thw_list(
grid_thws: torch.Tensor, grid_thw_list: Optional[Sequence[Sequence[int]]] = None
) -> Tuple[GridTHW, ...]:
values = grid_thws.tolist() if grid_thw_list is None else grid_thw_list
return tuple((int(t), int(h), int(w)) for t, h, w in values)
def _get_mm_attention_backend() -> str:
try:
server_args = get_server_args()
except ValueError:
return "auto"
return server_args.mm_attention_backend or "auto"
def _is_fa4_available() -> bool:
try:
from sglang.kernels.ops.attention.flash_attention_v4 import (
is_flash_attention_v4_available,
)
except ImportError:
return False
return is_flash_attention_v4_available()
def _resolve_mm_attention_backend(
configured_backend: str,
*,
max_seqlen: int,
total_tokens: int,
device: torch.device,
fa4_available: Optional[bool] = None,
) -> str:
if configured_backend != "auto":
return configured_backend
if device.type != "cuda":
return "sdpa"
if torch.cuda.get_device_capability(device) != (10, 3):
return "sdpa"
use_fa4 = (
max_seqlen > _SM103_TRITON_MAX_SEQLEN
or max_seqlen * total_tokens >= _SM103_FA4_MIN_ATTENTION_WORK
)
if use_fa4:
if fa4_available is None:
fa4_available = _is_fa4_available()
return "fa4" if fa4_available else "sdpa"
return "triton_attn"
def apply_rope(
xq: torch.Tensor, xk: torch.Tensor, freqs_cis: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
freqs_cis = freqs_cis.unsqueeze(-2)
xq_ = torch.view_as_complex(xq.float().view(*xq.shape[:-1], -1, 2))
xk_ = torch.view_as_complex(xk.float().view(*xk.shape[:-1], -1, 2))
xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(-2)
xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(-2)
return xq_out.type_as(xq), xk_out.type_as(xk)
def _can_use_fused_rope(hidden_states: torch.Tensor, freqs_cis: torch.Tensor) -> bool:
return _can_use_fused_rope_for_shape(
dtype=hidden_states.dtype,
device=hidden_states.device,
freqs_cis=freqs_cis,
)
def _can_use_fused_rope_for_shape(
*,
dtype: torch.dtype,
device: torch.device,
freqs_cis: torch.Tensor,
) -> bool:
if not (
device.type == "cuda"
and freqs_cis.is_cuda
and device == freqs_cis.device
and dtype in (torch.bfloat16, torch.float16)
and freqs_cis.dtype == torch.complex64
):
return False
major, _ = torch.cuda.get_device_capability(device)
return major >= 9
def sdpa_varlen_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens: torch.Tensor,
*,
segment_bounds: Optional[SegmentBounds] = None,
) -> torch.Tensor:
is_single_segment = (
len(segment_bounds) == 1
if segment_bounds is not None
else cu_seqlens.numel() == 2
)
if is_single_segment:
out = F.scaled_dot_product_attention(
q.transpose(0, 1).unsqueeze(0),
k.transpose(0, 1).unsqueeze(0),
v.transpose(0, 1).unsqueeze(0),
)
return out.squeeze(0).transpose(0, 1).flatten(start_dim=-2)
outputs = []
if segment_bounds is None:
bounds = cu_seqlens.tolist()
segment_bounds = tuple(zip(bounds[:-1], bounds[1:]))
for start, end in segment_bounds:
seg_q = q[start:end].transpose(0, 1).unsqueeze(0)
seg_k = k[start:end].transpose(0, 1).unsqueeze(0)
seg_v = v[start:end].transpose(0, 1).unsqueeze(0)
out = F.scaled_dot_product_attention(seg_q, seg_k, seg_v)
outputs.append(out.squeeze(0).transpose(0, 1))
return torch.cat(outputs).flatten(start_dim=-2)
def get_1d_sincos_pos_embed_from_grid(embed_dim: int, pos: np.ndarray) -> np.ndarray:
assert embed_dim % 2 == 0
omega = np.arange(embed_dim // 2, dtype=np.float32)
omega /= embed_dim / 2.0
omega = 1.0 / 10000**omega
pos = pos.reshape(-1)
out = np.einsum("m,d->md", pos, omega)
emb_sin = np.sin(out)
emb_cos = np.cos(out)
return np.concatenate([emb_sin, emb_cos], axis=1)
def get_1d_sincos_pos_embed(embed_dim: int, t_size: int) -> np.ndarray:
grid_t = np.arange(t_size, dtype=np.float32)
return get_1d_sincos_pos_embed_from_grid(embed_dim, grid_t)
def interpolate_pos_emb(
weight: torch.Tensor, interpolation_mode: str, shape: Tuple[int, int]
) -> torch.Tensor:
return (
F.interpolate(
weight.permute((2, 0, 1)).contiguous().unsqueeze(0),
size=shape,
mode=interpolation_mode,
)
.squeeze(0)
.permute((1, 2, 0))
.flatten(end_dim=1)
)
class Learnable2DInterpPosEmbDividedFixed(nn.Module):
def __init__(
self,
height: int,
width: int,
num_frames: int,
dim: int,
interpolation_mode: str = "bicubic",
) -> None:
super().__init__()
self.height = height
self.width = width
self.num_frames = num_frames
self.dim = dim
self.interpolation_mode = interpolation_mode
self.weight = nn.Parameter(torch.empty(height, width, dim))
self.register_buffer(
"time_weight",
torch.from_numpy(get_1d_sincos_pos_embed(dim, num_frames))
.float()
.unsqueeze(1),
persistent=False,
)
def position_embeddings(
self,
grid_thws: torch.Tensor,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
) -> torch.Tensor:
pos_embs = []
for t, h, w in _resolve_grid_thw_list(grid_thws, grid_thw_list):
assert t <= self.num_frames, f"t:{t} > num_frames:{self.num_frames}"
if (h, w) == self.weight.shape[:-1]:
pos_emb_2d = self.weight.flatten(end_dim=1)
else:
pos_emb_2d = interpolate_pos_emb(
self.weight, self.interpolation_mode, (h, w)
)
if t == 1:
pos_emb_3d = pos_emb_2d
else:
pos_emb_3d = pos_emb_2d.unsqueeze(0).repeat(t, 1, 1) + self.time_weight[
0:t
].to(pos_emb_2d.dtype)
pos_embs.append(pos_emb_3d.reshape(-1, pos_emb_3d.shape[-1]))
return torch.cat(pos_embs)
def forward(
self,
x: torch.Tensor,
grid_thws: torch.Tensor,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
position_embeddings: Optional[torch.Tensor] = None,
) -> torch.Tensor:
if position_embeddings is None:
position_embeddings = self.position_embeddings(
grid_thws, grid_thw_list=grid_thw_list
)
return x + position_embeddings
class MoonVision3dPatchEmbed(nn.Module):
def __init__(
self,
out_dim: int,
in_dim: int = 3,
patch_size: Union[int, Tuple[int, int]] = (14, 14),
pos_emb_height: int = 14,
pos_emb_width: int = 14,
pos_emb_time: int = 4,
pos_emb_type: str = "divided_fixed",
pos_emb_interpolation_mode: str = "bicubic",
patch_embed_proj_bias: bool = True,
):
super().__init__()
if isinstance(patch_size, int):
patch_size = (patch_size, patch_size)
self.patch_size = patch_size
self.proj = nn.Conv2d(
in_dim,
out_dim,
kernel_size=patch_size,
stride=patch_size,
bias=patch_embed_proj_bias,
)
if pos_emb_type != "divided_fixed":
raise NotImplementedError(f"Not support pos_emb_type: {pos_emb_type}")
self.pos_emb = Learnable2DInterpPosEmbDividedFixed(
height=pos_emb_height,
width=pos_emb_width,
num_frames=pos_emb_time,
dim=out_dim,
interpolation_mode=pos_emb_interpolation_mode,
)
def forward(
self,
x: torch.Tensor,
grid_thws: torch.Tensor,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
position_embeddings: Optional[torch.Tensor] = None,
) -> torch.Tensor:
# MIOpen can overflow grid_size for some patch shapes. Prefer AITER's
# Triton convolution on AMD, with an equivalent linear fallback.
if _use_aiter:
x = aiter_conv2d(
x,
self.proj.weight,
self.proj.bias,
stride=self.patch_size,
padding=(0, 0),
dilation=(1, 1),
).view(x.size(0), -1)
elif _is_hip:
x = F.linear(x.flatten(1), self.proj.weight.flatten(1), self.proj.bias)
else:
x = self.proj(x).view(x.size(0), -1)
return self.pos_emb(
x,
grid_thws,
grid_thw_list=grid_thw_list,
position_embeddings=position_embeddings,
)
class Rope2DPosEmbRepeated(nn.Module):
def __init__(
self, dim: int, max_height: int, max_width: int, theta_base: float = 10000
):
super().__init__()
assert dim % 4 == 0, "dim must be divisible by 4"
self.dim = dim
self.max_height = max_height
self.max_width = max_width
self.theta_base = theta_base
def _precompute_freqs_cis(self, device: torch.device) -> torch.Tensor:
N = self.max_height * self.max_width
flat_pos = torch.arange(0, N).float().to(device)
x_pos = flat_pos % self.max_width
y_pos = flat_pos // self.max_width
dim_range = torch.arange(0, self.dim, 4)[: (self.dim // 4)].float().to(device)
freqs = 1.0 / (self.theta_base ** (dim_range / self.dim))
x_freqs = torch.outer(x_pos, freqs).float()
y_freqs = torch.outer(y_pos, freqs).float()
x_cis = torch.polar(torch.ones_like(x_freqs), x_freqs)
y_cis = torch.polar(torch.ones_like(y_freqs), y_freqs)
freqs_cis = torch.cat(
[x_cis.unsqueeze(dim=-1), y_cis.unsqueeze(dim=-1)], dim=-1
)
return freqs_cis.reshape(self.max_height, self.max_width, -1)
def get_freqs_cis(
self,
grid_thws: torch.Tensor,
device: torch.device,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
) -> torch.Tensor:
if not hasattr(self, "freqs_cis"):
self.register_buffer(
"freqs_cis", self._precompute_freqs_cis(device), persistent=False
)
shapes = _resolve_grid_thw_list(grid_thws, grid_thw_list)
assert all(
1 <= h <= self.max_height and 1 <= w <= self.max_width for t, h, w in shapes
), (shapes, self.max_height, self.max_width)
return torch.cat(
[
self.freqs_cis[:h, :w].reshape(-1, self.dim // 2).repeat(t, 1)
for t, h, w in shapes
],
dim=0,
)
class MLP2(nn.Module):
def __init__(self, dims: List[int], activation, bias: bool = True):
super().__init__()
assert len(dims) == 3
self.fc0 = nn.Linear(dims[0], dims[1], bias=bias)
self.fc1 = nn.Linear(dims[1], dims[2], bias=bias)
self.activation = activation
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.fc1(self.activation(self.fc0(x)))
def _make_norm(norm_type: str, dim: int) -> nn.Module:
if norm_type == "layernorm":
return nn.LayerNorm(dim)
if norm_type == "rmsnorm":
return nn.RMSNorm(dim)
raise NotImplementedError(f"Not support norm_type: {norm_type}")
class MoonViTEncoderLayer(nn.Module):
def __init__(
self,
num_heads: int,
hidden_dim: int,
mlp_dim: int,
qkv_hidden_size: Optional[int] = None,
norm_type: str = "layernorm",
*,
activation=F.gelu,
attn_bias: bool = False,
linear_bias: bool = True,
attention_backend: str = "sdpa",
attention_workspace: Optional[torch.Tensor] = None,
):
super().__init__()
self.num_heads = num_heads
self.hidden_dim = hidden_dim
self.qkv_hidden_size = (
hidden_dim if qkv_hidden_size is None else qkv_hidden_size
)
self.hidden_size_per_attention_head = self.qkv_hidden_size // self.num_heads
self.norm0 = _make_norm(norm_type, hidden_dim)
self.norm1 = _make_norm(norm_type, hidden_dim)
self.mlp = MLP2([hidden_dim, mlp_dim, hidden_dim], activation, bias=linear_bias)
self.wqkv = nn.Linear(hidden_dim, self.qkv_hidden_size * 3, bias=attn_bias)
self.wo = nn.Linear(self.qkv_hidden_size, hidden_dim, bias=attn_bias)
self.attention_backend = attention_backend
if attention_backend == "auto":
if not torch.cuda.is_available():
implementation_backends = ()
elif _is_hip:
implementation_backends = ("triton_attn",)
else:
implementation_backends = ("triton_attn", "fa4")
elif attention_backend == "sdpa":
implementation_backends = ()
else:
implementation_backends = (attention_backend,)
self.attention_backend_impls = nn.ModuleDict(
{
backend: QKV_BACKEND_IMPL[backend](
use_data_parallel=True,
workspace_buffer=attention_workspace,
)
for backend in implementation_backends
}
)
def _attention(
self,
x: torch.Tensor,
cu_seqlens: torch.Tensor,
segment_bounds: SegmentBounds,
rope_freqs_cis: torch.Tensor,
forward_metadata: VisionAttentionMetadata,
use_fused_rope: bool,
selected_attention_backend: str,
) -> torch.Tensor:
xqkv = self.wqkv(x)
qkv_shape = xqkv.size()[:-1] + (
3,
self.num_heads,
self.hidden_size_per_attention_head,
)
xqkv = xqkv.view(*qkv_shape)
xq, xk, xv = torch.unbind(xqkv, dim=-3)
if use_fused_rope:
xq, xk = apply_fused_qk_complex_rope(xq, xk, rope_freqs_cis)
else:
xq, xk = apply_rope(xq, xk, rope_freqs_cis)
if selected_attention_backend == "sdpa":
attn_out = sdpa_varlen_attention(
xq,
xk,
xv,
cu_seqlens,
segment_bounds=segment_bounds,
)
else:
if selected_attention_backend == "flashinfer_cudnn":
xv = xv.contiguous()
attn_out = self.attention_backend_impls[selected_attention_backend](
xq,
xk,
xv,
cu_seqlens=cu_seqlens,
bsz=1,
seq_len=xq.shape[0],
forward_metadata=forward_metadata,
).flatten(start_dim=-2)
return self.wo(attn_out)
def forward(
self,
hidden_states: torch.Tensor,
cu_seqlens: torch.Tensor,
segment_bounds: SegmentBounds,
rope_freqs_cis: torch.Tensor,
forward_metadata: VisionAttentionMetadata,
use_fused_rope: bool,
selected_attention_backend: str,
) -> torch.Tensor:
residual = hidden_states
hidden_states = self.norm0(hidden_states)
hidden_states = self._attention(
hidden_states,
cu_seqlens,
segment_bounds,
rope_freqs_cis,
forward_metadata,
use_fused_rope,
selected_attention_backend,
)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.norm1(hidden_states)
hidden_states = self.mlp(hidden_states)
return residual + hidden_states
@dataclass(frozen=True)
class KimiK3VisionForwardMetadata:
grid_thw_list: Tuple[GridTHW, ...]
segment_bounds: SegmentBounds
rope_freqs_cis: torch.Tensor
attention: VisionAttentionMetadata
use_fused_rope: bool
selected_attention_backend: str
position_embeddings: Optional[torch.Tensor] = None
class MoonViT3dEncoder(nn.Module):
def __init__(self, hidden_dim: int, num_layers: int, block_cfg: dict) -> None:
super().__init__()
qkv_hidden_size = block_cfg.get("qkv_hidden_size") or block_cfg["hidden_dim"]
attention_backend = _get_mm_attention_backend()
if attention_backend != "auto" and attention_backend not in QKV_BACKEND_IMPL:
raise ValueError(
f"Unsupported Kimi-K3 vision attention backend: {attention_backend}"
)
attention_workspace = None
if attention_backend == "flashinfer_cudnn" and torch.cuda.is_available():
attention_workspace = torch.empty(
FLASHINFER_WORKSPACE_SIZE_BYTES,
dtype=torch.uint8,
device=torch.device("cuda", torch.cuda.current_device()),
)
self.attention_backend = attention_backend
if attention_backend == "auto":
print_info_once(
"Kimi-K3 vision attention uses shape-aware auto selection on "
"B300/GB300 (Triton for small workloads, FA4 otherwise)."
)
self.attention_width = qkv_hidden_size
self.rope_2d = Rope2DPosEmbRepeated(
qkv_hidden_size // block_cfg["num_heads"], 512, 512
)
self.blocks = nn.ModuleList(
[
MoonViTEncoderLayer(
**block_cfg,
attention_backend=attention_backend,
attention_workspace=attention_workspace,
)
for _ in range(num_layers)
]
)
self.final_layernorm = _make_norm(
block_cfg.get("norm_type", "layernorm"), hidden_dim
)
def precompile_fused_rope(self, dtype: torch.dtype, device: torch.device) -> bool:
if not self.blocks:
return False
return precompile_fused_qk_complex_rope(
num_heads=self.blocks[0].num_heads,
head_dim=self.blocks[0].hidden_size_per_attention_head,
dtype=dtype,
device=device,
)
def precompile_attention_backend(
self, dtype: torch.dtype, device: torch.device
) -> bool:
if (
self.attention_backend != "auto"
or not self.blocks
or device.type != "cuda"
or torch.cuda.get_device_capability(device) != (10, 3)
or not _is_fa4_available()
):
return False
block = self.blocks[0]
if "fa4" not in block.attention_backend_impls:
return False
num_tokens = 256
packed_qkv = torch.zeros(
(
num_tokens,
3,
block.num_heads,
block.hidden_size_per_attention_head,
),
dtype=dtype,
device=device,
)
q = packed_qkv[:, 0].contiguous()
k = packed_qkv[:, 1].contiguous()
v = packed_qkv[:, 2]
cu_seqlens = torch.tensor([0, num_tokens], dtype=torch.int32, device=device)
metadata = prepare_vision_attention_metadata(cu_seqlens, device=device)
with torch.inference_mode():
block.attention_backend_impls["fa4"](
q,
k,
v,
cu_seqlens=cu_seqlens,
bsz=1,
seq_len=num_tokens,
forward_metadata=metadata,
)
torch.cuda.synchronize(device)
return True
def prepare_forward_metadata(
self,
*,
grid_thws: torch.Tensor,
total_tokens: int,
dtype: torch.dtype,
device: torch.device,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
) -> KimiK3VisionForwardMetadata:
shapes = _resolve_grid_thw_list(grid_thws, grid_thw_list)
rope_freqs_cis = self.rope_2d.get_freqs_cis(
grid_thws=grid_thws,
device=device,
grid_thw_list=shapes,
)
cumulative_lengths = [0]
for t, h, w in shapes:
cumulative_lengths.append(cumulative_lengths[-1] + t * h * w)
cu_seqlens = torch.tensor(cumulative_lengths, dtype=torch.int32, device=device)
if self.attention_backend == "flashinfer_cudnn":
attention = prepare_flashinfer_cudnn_vision_attention_metadata(
cu_seqlens,
device=device,
elem_per_token=self.attention_width,
)
else:
attention = prepare_vision_attention_metadata(cu_seqlens, device=device)
selected_attention_backend = _resolve_mm_attention_backend(
self.attention_backend,
max_seqlen=attention.max_seqlen,
total_tokens=total_tokens,
device=device,
)
return KimiK3VisionForwardMetadata(
grid_thw_list=shapes,
segment_bounds=tuple(zip(cumulative_lengths[:-1], cumulative_lengths[1:])),
rope_freqs_cis=rope_freqs_cis,
attention=attention,
use_fused_rope=_can_use_fused_rope_for_shape(
dtype=dtype,
device=device,
freqs_cis=rope_freqs_cis,
),
selected_attention_backend=selected_attention_backend,
)
def forward(
self,
hidden_states: torch.Tensor,
grid_thws: torch.Tensor,
*,
forward_metadata: Optional[KimiK3VisionForwardMetadata] = None,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
) -> torch.Tensor:
if forward_metadata is None:
forward_metadata = self.prepare_forward_metadata(
grid_thws=grid_thws,
total_tokens=hidden_states.shape[0],
dtype=hidden_states.dtype,
device=hidden_states.device,
grid_thw_list=grid_thw_list,
)
forward_metadata = replace(
forward_metadata,
use_fused_rope=_can_use_fused_rope(
hidden_states, forward_metadata.rope_freqs_cis
),
)
attention = forward_metadata.attention
cu_seqlens = attention.cu_seqlens
rope_freqs_cis = forward_metadata.rope_freqs_cis
for block in self.blocks:
hidden_states = block(
hidden_states,
cu_seqlens,
forward_metadata.segment_bounds,
rope_freqs_cis,
attention,
forward_metadata.use_fused_rope,
forward_metadata.selected_attention_backend,
)
return self.final_layernorm(hidden_states)
class KimiK3VisionTower(nn.Module):
def __init__(self, vision_config, **kwargs):
super().__init__()
config = vision_config
self.config = config
self.merge_kernel_size = tuple(config.merge_kernel_size)
self.patch_size = config.patch_size
self.merge_type = config.merge_type
if self.merge_type != "sd2_tpool":
raise NotImplementedError(f"Not support merge_type: {self.merge_type}")
hidden_size = getattr(config, "vt_hidden_size", None) or config.hidden_size
num_heads = (
getattr(config, "vt_num_attention_heads", None)
or config.num_attention_heads
)
num_layers = (
getattr(config, "vt_num_hidden_layers", None) or config.num_hidden_layers
)
intermediate_size = (
getattr(config, "vt_intermediate_size", None) or config.intermediate_size
)
self.patch_embed = MoonVision3dPatchEmbed(
out_dim=hidden_size,
patch_size=config.patch_size,
pos_emb_height=config.init_pos_emb_height,
pos_emb_width=config.init_pos_emb_width,
pos_emb_time=config.init_pos_emb_time,
pos_emb_type=config.pos_emb_type,
pos_emb_interpolation_mode=config.pos_emb_interpolation_mode,
patch_embed_proj_bias=getattr(config, "patch_embed_proj_bias", True),
)
activation_func = getattr(config, "activation_func", "gelu_pytorch_tanh")
if activation_func == "gelu_pytorch_tanh":
activation = lambda x: F.gelu(x, approximate="tanh")
elif activation_func == "gelu":
activation = F.gelu
else:
raise NotImplementedError(f"Not support activation_func: {activation_func}")
self.encoder = MoonViT3dEncoder(
hidden_dim=hidden_size,
num_layers=num_layers,
block_cfg={
"num_heads": num_heads,
"hidden_dim": hidden_size,
"qkv_hidden_size": getattr(config, "qkv_hidden_size", None),
"mlp_dim": intermediate_size,
"norm_type": getattr(config, "norm_type", "layernorm"),
"activation": activation,
"attn_bias": getattr(config, "attn_bias", True),
"linear_bias": getattr(config, "linear_bias", True),
},
)
self.cuda_graph_runner = None
@property
def dtype(self) -> torch.dtype:
return self.patch_embed.proj.weight.dtype
@property
def device(self) -> torch.device:
return self.patch_embed.proj.weight.device
def precompile_fused_rope(self) -> bool:
return self.encoder.precompile_fused_rope(self.dtype, self.device)
def precompile_attention_backend(self) -> bool:
return self.encoder.precompile_attention_backend(self.dtype, self.device)
def prepare_forward_metadata(
self,
grid_thws: torch.Tensor,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
total_tokens: int,
dtype: Optional[torch.dtype] = None,
) -> KimiK3VisionForwardMetadata:
metadata = self.encoder.prepare_forward_metadata(
grid_thws=grid_thws,
total_tokens=total_tokens,
dtype=self.dtype if dtype is None else dtype,
device=self.device,
grid_thw_list=grid_thw_list,
)
return replace(
metadata,
position_embeddings=self.patch_embed.pos_emb.position_embeddings(
grid_thws, grid_thw_list=metadata.grid_thw_list
),
)
def forward(
self,
pixel_values: torch.Tensor,
grid_thws: Optional[torch.Tensor] = None,
max_seqlen: Optional[int] = None,
*,
grid_hw: Optional[torch.Tensor] = None,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
forward_metadata: Optional[KimiK3VisionForwardMetadata] = None,
) -> List[torch.Tensor]:
# run_dp_sharded_mrope_vision_model calls rope_2d towers with
# grid_hw=/max_seqlen= keywords (#30878); K3 grids are (t, h, w) and the
# encoder derives its own varlen metadata, so max_seqlen is unused.
if grid_thws is None:
grid_thws = grid_hw
assert grid_thws.ndim == 2 and grid_thws.size(1) == 3, grid_thws.shape
if (
forward_metadata is None
and pixel_values.is_cuda
and envs.SGLANG_VIT_ENABLE_CUDA_GRAPH.get()
):
if grid_thw_list is None:
grid_thw_list = _resolve_grid_thw_list(grid_thws)
else:
grid_thw_list = _resolve_grid_thw_list(grid_thws, grid_thw_list)
if self.cuda_graph_runner is None:
from sglang.srt.multimodal.kimi_k3_vit_cuda_graph_runner import (
KimiK3ViTCudaGraphRunner,
)
self.cuda_graph_runner = KimiK3ViTCudaGraphRunner(
self,
capacity=(envs.SGLANG_KIMI_K3_VIT_CUDA_GRAPH_CACHE_CAPACITY.get()),
min_hits=envs.SGLANG_KIMI_K3_VIT_CUDA_GRAPH_MIN_HITS.get(),
max_seqlen=(envs.SGLANG_KIMI_K3_VIT_CUDA_GRAPH_MAX_SEQLEN.get()),
)
return self.cuda_graph_runner.run(pixel_values, grid_thws, grid_thw_list)
return self._forward_eager(
pixel_values,
grid_thws,
grid_thw_list=grid_thw_list,
forward_metadata=forward_metadata,
)
def _forward_eager(
self,
pixel_values: torch.Tensor,
grid_thws: torch.Tensor,
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
forward_metadata: Optional[KimiK3VisionForwardMetadata] = None,
) -> List[torch.Tensor]:
if forward_metadata is not None:
grid_thw_list = forward_metadata.grid_thw_list
hidden_states = self.patch_embed(
pixel_values,
grid_thws,
grid_thw_list=grid_thw_list,
position_embeddings=(
None
if forward_metadata is None
else forward_metadata.position_embeddings
),
)
hidden_states = self.encoder(
hidden_states,
grid_thws,
forward_metadata=forward_metadata,
grid_thw_list=grid_thw_list,
)
return tpool_patch_merger(
hidden_states,
grid_thws,
merge_kernel_size=self.merge_kernel_size,
grid_thw_list=grid_thw_list,
)
class KimiK3MultiModalProjector(nn.Module):
"""PatchMergerMLPV2: bias-free two-layer MLP over merged patches with a
post RMSNorm; K3 has no pre-norm, unlike the K2.5 projector."""
def __init__(self, vision_config):
super().__init__()
config = vision_config
mm_hidden_size = (
getattr(config, "mm_hidden_size", None) or config.vt_hidden_size
)
merge_h, merge_w = config.merge_kernel_size
self.hidden_size = mm_hidden_size * merge_h * merge_w
text_hidden_size = getattr(config, "text_hidden_size", None) or getattr(
config, "hidden_size"
)
eps = config.projector_ln_eps
self.proj = nn.Sequential(
nn.Linear(self.hidden_size, self.hidden_size, bias=False),
nn.GELU(),
nn.Linear(self.hidden_size, text_hidden_size, bias=False),
)
self.post_norm = nn.RMSNorm(text_hidden_size, eps=eps)
def forward(
self, image_features: Union[torch.Tensor, List[torch.Tensor]]
) -> torch.Tensor:
if isinstance(image_features, (list, tuple)):
x = concat_or_single(
[item.reshape(item.shape[0], -1) for item in image_features]
)
else:
x = image_features.reshape(image_features.shape[0], -1)
return self.post_norm(self.proj(x))
+13 -1
View File
@@ -113,6 +113,9 @@ class KimiMoE(nn.Module):
layer_id=self.layer_idx,
quant_config=quant_config,
routed_scaling_factor=self.routed_scaling_factor,
activation=config.hidden_act,
gemm1_alpha=config.activation_situ_beta,
gemm1_clamp_limit=config.activation_situ_linear_beta,
prefix=add_prefix("experts", prefix),
)
@@ -405,7 +408,10 @@ class KimiDeltaAttention(nn.Module):
forget_gate = forget_gate.unflatten(
-1, (-1, self.head_dim)
) # [T, H*K] -> [T, H, K]
beta = beta.float().sigmoid()
if not forward_batch.forward_mode.is_target_verify():
# Only chunk_kda (extend) wants pre-activated beta; the verify
# kernel sigmoids it in-kernel like decode.
beta = beta.float().sigmoid()
forget_gate = forget_gate.unsqueeze(0)
beta = beta.unsqueeze(0)
@@ -866,6 +872,12 @@ class KimiLinearForCausalLM(nn.Module):
weight_loader(param, loaded_weight, **kwargs)
loaded_params.add(name)
self.post_load_weights()
def post_load_weights(self):
# Derive the absorbed MLA weights. Also called by the dummy loader
# (`_post_load_weights`), which never runs `load_weights` — keeping the
# derivation only there left `w_kc` None under --load-format dummy.
for layer_id in self.config.full_attention_layer_ids:
if not self.model.start_layer <= layer_id < self.model.end_layer:
continue
+10 -2
View File
@@ -570,13 +570,21 @@ def tpool_patch_merger(
x: torch.Tensor,
grid_thws: torch.Tensor,
merge_kernel_size: tuple[int, int] = (2, 2),
*,
grid_thw_list: Optional[Sequence[Sequence[int]]] = None,
) -> List[torch.Tensor]:
"""Group spatial patches and average only across real video frames."""
"""Group spatial patches and average only across real video frames.
``grid_thw_list`` lets a graph-aware tower pass the host grid it already
has instead of paying a device sync for ``grid_thws.tolist()``.
"""
d_model = x.size(-1)
outputs = []
pre_sum = 0
for t, h, w in grid_thws.tolist():
shapes = grid_thws.tolist() if grid_thw_list is None else grid_thw_list
for t, h, w in shapes:
t, h, w = int(t), int(h), int(w)
seq = x[pre_sum : pre_sum + t * h * w]
kernel_height, kernel_width = merge_kernel_size
new_height, new_width = h // kernel_height, w // kernel_width
@@ -0,0 +1,211 @@
"""Bounded full-tower CUDA graph runner for Kimi K3 vision."""
from __future__ import annotations
import logging
from collections import OrderedDict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Hashable, List, Tuple
import torch
from sglang.srt.model_executor.runner_utils.pool import (
get_or_create_global_graph_memory_pool,
)
if TYPE_CHECKING:
from sglang.srt.models.kimi_k3_vl import (
GridTHW,
KimiK3VisionForwardMetadata,
KimiK3VisionTower,
)
logger = logging.getLogger(__name__)
@dataclass
class _CapturedVisionGraph:
graph: torch.cuda.CUDAGraph
input_buffer: torch.Tensor
outputs: Tuple[torch.Tensor, ...]
metadata: KimiK3VisionForwardMetadata
class KimiK3ViTCudaGraphRunner:
def __init__(
self,
tower: KimiK3VisionTower,
*,
capacity: int,
min_hits: int,
max_seqlen: int | None = None,
) -> None:
if capacity <= 0:
raise ValueError(f"capacity must be positive, got {capacity}")
if min_hits <= 0:
raise ValueError(f"min_hits must be positive, got {min_hits}")
if max_seqlen is not None and max_seqlen <= 0:
raise ValueError(f"max_seqlen must be positive, got {max_seqlen}")
self.tower = tower
self.capacity = capacity
self.min_hits = min_hits
self.max_seqlen = max_seqlen
self.graphs: dict[Hashable, _CapturedVisionGraph] = {}
self.seen: OrderedDict[Hashable, int] = OrderedDict()
self.failed_keys: set[Hashable] = set()
self._graph_memory_pool: Any = None
self._capacity_logged = False
self._max_seqlen_logged = False
logger.info(
"Kimi-K3 ViT CUDA graph: capacity=%d min_hits=%d max_seqlen=%s",
capacity,
min_hits,
max_seqlen,
)
@staticmethod
def graph_key(grid_thw_list: Tuple[GridTHW, ...]) -> Hashable:
return grid_thw_list
def _record_hit(self, key: Hashable) -> int:
count = self.seen.pop(key, 0) + 1
self.seen[key] = count
seen_capacity = max(self.capacity * 8, self.capacity)
if len(self.seen) > seen_capacity:
self.seen.popitem(last=False)
return count
def _run_eager(
self,
pixel_values: torch.Tensor,
grid_thws: torch.Tensor,
grid_thw_list: Tuple[GridTHW, ...],
) -> tuple[List[torch.Tensor], KimiK3VisionForwardMetadata]:
metadata = self.tower.prepare_forward_metadata(
grid_thws,
grid_thw_list=grid_thw_list,
total_tokens=pixel_values.shape[0],
dtype=pixel_values.dtype,
)
outputs = self.tower._forward_eager(
pixel_values,
grid_thws,
grid_thw_list=grid_thw_list,
forward_metadata=metadata,
)
return outputs, metadata
def _capture(
self,
key: Hashable,
pixel_values: torch.Tensor,
grid_thws: torch.Tensor,
grid_thw_list: Tuple[GridTHW, ...],
metadata: KimiK3VisionForwardMetadata,
) -> _CapturedVisionGraph:
allocated_before = torch.cuda.memory_allocated(pixel_values.device)
reserved_before = torch.cuda.memory_reserved(pixel_values.device)
input_buffer = torch.empty_like(pixel_values)
input_buffer.copy_(pixel_values)
graph = torch.cuda.CUDAGraph()
if self._graph_memory_pool is None:
self._graph_memory_pool = get_or_create_global_graph_memory_pool(torch.cuda)
with torch.cuda.graph(graph, pool=self._graph_memory_pool):
outputs = self.tower._forward_eager(
input_buffer,
grid_thws,
grid_thw_list=grid_thw_list,
forward_metadata=metadata,
)
torch.cuda.synchronize(pixel_values.device)
entry = _CapturedVisionGraph(
graph=graph,
input_buffer=input_buffer,
outputs=tuple(outputs),
metadata=metadata,
)
position_embeddings = getattr(metadata, "position_embeddings", None)
position_cache_bytes = (
position_embeddings.numel() * position_embeddings.element_size()
if position_embeddings is not None
else 0
)
logger.info(
"Captured Kimi-K3 ViT CUDA graph: key=%s cache=%d/%d "
"allocated_delta_mib=%.1f reserved_delta_mib=%.1f "
"position_cache_mib=%.1f allocated_total_mib=%.1f "
"reserved_total_mib=%.1f",
key,
len(self.graphs) + 1,
self.capacity,
(torch.cuda.memory_allocated(pixel_values.device) - allocated_before)
/ 2**20,
(torch.cuda.memory_reserved(pixel_values.device) - reserved_before) / 2**20,
position_cache_bytes / 2**20,
torch.cuda.memory_allocated(pixel_values.device) / 2**20,
torch.cuda.memory_reserved(pixel_values.device) / 2**20,
)
return entry
def run(
self,
pixel_values: torch.Tensor,
grid_thws: torch.Tensor,
grid_thw_list: Tuple[GridTHW, ...],
) -> List[torch.Tensor]:
key = self.graph_key(grid_thw_list)
entry = self.graphs.get(key)
if entry is not None:
entry.input_buffer.copy_(pixel_values)
entry.graph.replay()
return list(entry.outputs)
max_seqlen = max(t * h * w for t, h, w in grid_thw_list)
if self.max_seqlen is not None and max_seqlen > self.max_seqlen:
if not self._max_seqlen_logged:
logger.info(
"Kimi-K3 ViT CUDA graph uses eager fallback above "
"max_seqlen=%d to avoid low-value captures and HBM use",
self.max_seqlen,
)
self._max_seqlen_logged = True
outputs, _ = self._run_eager(pixel_values, grid_thws, grid_thw_list)
return outputs
if key in self.failed_keys or self._record_hit(key) < self.min_hits:
outputs, _ = self._run_eager(pixel_values, grid_thws, grid_thw_list)
return outputs
if len(self.graphs) >= self.capacity:
if not self._capacity_logged:
logger.warning(
"Kimi-K3 ViT CUDA graph cache reached capacity=%d; "
"new shapes use eager fallback to bound HBM",
self.capacity,
)
self._capacity_logged = True
outputs, _ = self._run_eager(pixel_values, grid_thws, grid_thw_list)
return outputs
metadata = self.tower.prepare_forward_metadata(
grid_thws,
grid_thw_list=grid_thw_list,
total_tokens=pixel_values.shape[0],
dtype=pixel_values.dtype,
)
try:
entry = self._capture(key, pixel_values, grid_thws, grid_thw_list, metadata)
except Exception:
self.failed_keys.add(key)
logger.exception(
"Kimi-K3 ViT CUDA graph capture failed for key=%s; "
"using eager fallback",
key,
)
outputs, _ = self._run_eager(pixel_values, grid_thws, grid_thw_list)
return outputs
self.graphs[key] = entry
entry.input_buffer.copy_(pixel_values)
entry.graph.replay()
return list(entry.outputs)
+24 -11
View File
@@ -557,9 +557,11 @@ def run_dp_sharded_mrope_vision_model(
grid_thw_list: list,
*,
rope_type: Literal["rope_3d", "rope_2d", "rope_2d_packed"],
pool_temporal_dimension: bool = False,
load_local_pixel_values: Optional[Callable[[list[int]], torch.Tensor]] = None,
pixel_values_device: Optional[torch.device] = None,
pixel_values_dtype: Optional[torch.dtype] = None,
pass_grid_thw_list: bool = False,
):
"""Run a vision model with data parallelism (DP) sharding.
The function will shard the input image tensor on the
@@ -576,6 +578,11 @@ def run_dp_sharded_mrope_vision_model(
"rope_2d" for packed 2D rope outputs (e.g., Kimi-VL)
"rope_2d_packed" for packed 2D rope outputs that accept
``grid_thws`` positionally (e.g., Kimi-K2.5/K2.7)
pool_temporal_dimension: Whether the vision model pools away the temporal
grid dimension. Its output length is then h * w divided by
the spatial merge area instead of t * h * w divided by it.
pass_grid_thw_list: Forward the existing host grid list to the vision
model so graph-aware towers do not materialize it from a CUDA tensor.
Returns:
torch.Tensor: Output image embeddings
@@ -614,11 +621,13 @@ def run_dp_sharded_mrope_vision_model(
device=pixel_values.device if rope_type == "rope_2d" else None,
)
if rope_type == "rope_2d":
image_embeds = vision_model(
pixel_values,
grid_hw=grid_thw,
max_seqlen=max(math.prod(grid) for grid in grid_thw_list),
)
kwargs = {
"grid_hw": grid_thw,
"max_seqlen": max(math.prod(grid) for grid in grid_thw_list),
}
if pass_grid_thw_list:
kwargs["grid_thw_list"] = grid_thw_list
image_embeds = vision_model(pixel_values, **kwargs)
# MoonViT returns one tensor per image. The multi-GPU path below
# already concatenates these tensors before returning, so keep the
# TP=1 DP-encoder path on the same projector-facing contract.
@@ -687,7 +696,9 @@ def run_dp_sharded_mrope_vision_model(
)
output_tokens_per_image = [
math.prod(grid) // embed_dim_reduction_factor for grid in grid_thw_list
math.prod(grid[1:] if pool_temporal_dimension else grid)
// embed_dim_reduction_factor
for grid in grid_thw_list
]
grouped_output_lengths = []
assignment_offset = 0
@@ -717,11 +728,13 @@ def run_dp_sharded_mrope_vision_model(
device=(pixel_values_local.device if rope_type == "rope_2d" else None),
)
if rope_type == "rope_2d":
image_embeds_local = vision_model(
pixel_values_local,
grid_hw=local_grid_thw,
max_seqlen=max(math.prod(grid) for grid in local_grid_thw_list),
)
kwargs = {
"grid_hw": local_grid_thw,
"max_seqlen": max(math.prod(grid) for grid in local_grid_thw_list),
}
if pass_grid_thw_list:
kwargs["grid_thw_list"] = local_grid_thw_list
image_embeds_local = vision_model(pixel_values_local, **kwargs)
else:
image_embeds_local = vision_model(pixel_values_local, local_grid_thw)
if isinstance(image_embeds_local, list):
@@ -1,7 +1,7 @@
import math
import re
from collections import defaultdict
from typing import Dict, List, Union
from typing import Callable, Dict, List, Optional, Union
import numpy as np
import torch
@@ -194,15 +194,24 @@ def _process_single_image(
image_scale: torch.Tensor,
image_bias: torch.Tensor,
patch_size: int,
to_chw: Callable[[Union[torch.Tensor, Image.Image]], torch.Tensor] = _to_cuda_chw,
post_resize: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
) -> torch.Tensor:
"""Process a single image on GPU: resize -> pad -> normalize -> patchify."""
image = _to_cuda_chw(image)
"""Process a single image on GPU: resize -> pad -> normalize -> patchify.
``to_chw`` converts the input to a CUDA CHW tensor (a model may keep an
alpha channel here); ``post_resize`` runs on the resized ``(B, C, H, W)``
batch before patchify (K3 composites transparent backgrounds there).
"""
image = to_chw(image)
new_h, new_w = config["new_height"], config["new_width"]
padded_h = new_h + config["pad_height"]
padded_w = new_w + config["pad_width"]
x = _resize_bicubic_if_needed(image.unsqueeze(0), new_h, new_w)
if post_resize is not None:
x = post_resize(x)
return normalize_and_patchify(
x, image_scale, image_bias, patch_size, padded_h, padded_w
@@ -250,6 +259,8 @@ def _gpu_preprocess_images(
image_scale: torch.Tensor,
image_bias: torch.Tensor,
patch_size: int,
to_chw: Callable[[Union[torch.Tensor, Image.Image]], torch.Tensor] = _to_cuda_chw,
post_resize: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""GPU preprocessing pipeline for a batch of images.
@@ -278,21 +289,30 @@ def _gpu_preprocess_images(
if len(group) == 1:
idx, image, config = group[0]
patches = _process_single_image(
image, config, image_scale, image_bias, patch_size
image,
config,
image_scale,
image_bias,
patch_size,
to_chw=to_chw,
post_resize=post_resize,
)
all_patches[idx] = patches
all_grids[idx] = _grid_thw_from_resize_config(config, patch_size)
else:
indexed_images = [(idx, _to_cuda_chw(image)) for idx, image, _ in group]
indexed_images = [(idx, to_chw(image)) for idx, image, _ in group]
# One NaViT target group can include several original resolutions.
# Batch only source-compatible images, which removes redundant
# bicubic launches for common multi-image requests without padding
# random-size inputs to a larger source resolution.
batch = torch.cat(
_resize_images_by_source_shape(indexed_images, target_h, target_w),
dim=0,
)
resized = _resize_images_by_source_shape(indexed_images, target_h, target_w)
if post_resize is not None:
# Before the concat: a hook may change the channel count (K3
# composites RGBA onto a background and returns RGB), and mixed
# 3/4-channel sources cannot be concatenated first.
resized = [post_resize(part) for part in resized]
batch = torch.cat(resized, dim=0)
T = 1
gh, gw = padded_h // patch_size, padded_w // patch_size
@@ -0,0 +1,438 @@
"""Kimi K3 multimodal processor.
GPU image preprocessing dedicated to K3: unlike the K2.5 wrapper it keeps
the alpha channel through the bicubic resize and then composites RGBA
images onto the checkpoint-configured background
(``transparent_bg_config`` with ``transparent_bg_fill_stage ==
"after_resize"`` in preprocessor_config.json), instead of dropping alpha
at load time.
"""
import re
from typing import Dict, List, Union
import numpy as np
import torch
from PIL import Image
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.multimodal.processors.kimi_common import KimiGridMMDataMixin
from sglang.srt.multimodal.processors.kimi_k25 import (
KimiGPUProcessorWrapper,
_get_image_dimensions,
_gpu_preprocess_images,
navit_resize_config,
)
from sglang.srt.utils.cuda_ipc_transport_utils import (
DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY,
)
def _encode_k3_special_tokens(tokenizer, text: str) -> list[int]:
"""Encode K3 control tokens without allowing them to be BPE-split."""
try:
return list(tokenizer.encode(text, allowed_special="all"))
except TypeError:
# Keep the helper usable with lightweight tokenizer stubs in CPU tests.
return list(tokenizer.encode(text))
def _expand_k3_image_prompt_token_ids(
input_ids: Union[List[int], torch.Tensor],
image_token_id: int,
image_token_counts: List[int],
image_sizes: List[tuple[int, int]],
tokenizer,
) -> torch.Tensor:
"""Expand K3 image placeholders into the checkpoint's media contract.
K3 requires each image feature span to be enclosed by its original uploaded
dimensions. The chat template deliberately emits one ``media_pad`` per
image; after decode, insert the surrounding control tokens and expand that
one placeholder to the NaViT feature count.
"""
if len(image_token_counts) != len(image_sizes):
raise ValueError("Expected one original size for each K3 image.")
if isinstance(input_ids, torch.Tensor):
input_ids = input_ids.detach().flatten().cpu().numpy()
input_ids = np.asarray(input_ids, dtype=np.int64)
placeholder_count = np.count_nonzero(input_ids == image_token_id)
if placeholder_count != len(image_token_counts):
raise ValueError(
f"Expected {len(image_token_counts)} image placeholder token(s), "
f"found {placeholder_count}."
)
output = []
image_index = 0
for token_id in input_ids:
if token_id != image_token_id:
output.append(int(token_id))
continue
width, height = image_sizes[image_index]
output.extend(
_encode_k3_special_tokens(
tokenizer,
f"<|media_begin|>image {width}x{height}<|media_content|>",
)
)
output.extend([image_token_id] * image_token_counts[image_index])
output.extend(_encode_k3_special_tokens(tokenizer, "<|media_end|>"))
image_index += 1
return torch.tensor(output, dtype=torch.long).unsqueeze(0)
def _expand_k3_image_prompt_text(
input_text: str,
image_token: str,
image_token_counts: List[int],
image_sizes: List[tuple[int, int]],
) -> str:
"""Render the K3 media framing for the CPU HF-processor fallback."""
parts = input_text.split(image_token)
if len(parts) - 1 != len(image_token_counts):
raise ValueError(
f"Expected {len(image_token_counts)} image placeholder(s), "
f"found {len(parts) - 1}."
)
output = [parts[0]]
for image_token_count, (width, height), suffix in zip(
image_token_counts, image_sizes, parts[1:]
):
output.extend(
(
f"<|media_begin|>image {width}x{height}<|media_content|>",
image_token * image_token_count,
"<|media_end|>",
suffix,
)
)
return "".join(output)
def _k3_to_cuda_chw(image: Union[torch.Tensor, Image.Image]) -> torch.Tensor:
if isinstance(image, Image.Image):
# The checkpoint's fill_transparent_bg_with() returns RGB-mode images
# untouched before it ever inspects the alpha bands, so an RGB image
# carrying a stray "transparency" info key must NOT be promoted to
# RGBA here.
has_alpha = image.mode != "RGB" and (
"A" in image.getbands() or "transparency" in image.info
)
arr = np.asarray(image.convert("RGBA" if has_alpha else "RGB"))
return torch.from_numpy(arr).permute(2, 0, 1).cuda()
image = image.cuda()
if image.dim() == 2:
image = image.unsqueeze(0)
if image.shape[0] == 1:
image = image.repeat(3, 1, 1)
return image
def _chessboard_background(
height: int, width: int, cfg: dict, device: torch.device
) -> torch.Tensor:
square = cfg.get("chessboard_square_size", 16)
white = float(cfg.get("chessboard_white_value", 255))
gray = float(cfg.get("chessboard_gray_value", 200))
on_top_left = cfg.get("chessboard_square_on_top_left", True)
ys = torch.arange(height, device=device) // square
xs = torch.arange(width, device=device) // square
parity = (ys.unsqueeze(1) + xs.unsqueeze(0)) % 2
gray_parity = 1 if on_top_left else 0
bg = torch.where(parity == gray_parity, gray, white)
return bg.unsqueeze(0).expand(3, height, width)
def _fill_transparent_bg(x: torch.Tensor, bg_cfg: Union[dict, None]) -> torch.Tensor:
"""Composite a resized (1, 4, H, W) float image in [0, 255] onto the
configured background; 3-channel input passes through."""
if x.shape[1] == 3:
return x
rgb = x[:, :3]
if bg_cfg is None:
return rgb
_, _, height, width = x.shape
pattern = bg_cfg.get("pattern", "black")
if pattern == "chessboard":
bg = _chessboard_background(height, width, bg_cfg, x.device)
elif pattern == "white":
bg = torch.full((3, height, width), 255.0, device=x.device)
elif pattern == "black":
bg = torch.zeros(3, height, width, device=x.device)
elif pattern == "gray":
bg = torch.full((3, height, width), 128.0, device=x.device)
else:
raise ValueError(f"Invalid background pattern: {pattern}")
alpha = (x[:, 3:4] / 255.0).clamp(0.0, 1.0)
# The checkpoint processor casts the composited float result back with
# numpy's astype(np.uint8), which truncates; floor matches that exactly
# (a composite of [0, 255] inputs is always non-negative).
return (alpha * rgb + (1.0 - alpha) * bg).clamp(0.0, 255.0).floor_()
class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
def __init__(self, *args, transparent_bg_config=None, **kwargs):
super().__init__(*args, **kwargs)
self._transparent_bg_config = transparent_bg_config
def _prepare_input_ids(
self, input_text, resize_configs, original_input_ids, image_sizes
):
image_token_counts = [config["num_tokens"] for config in resize_configs]
if original_input_ids is None:
original_input_ids = _encode_k3_special_tokens(
self._hf_processor.tokenizer, input_text
)
return _expand_k3_image_prompt_token_ids(
original_input_ids,
self._image_token_id,
image_token_counts,
image_sizes,
self._hf_processor.tokenizer,
)
def __call__(self, text=None, images=None, **kwargs):
images = images or kwargs.pop("images", None)
original_input_ids = kwargs.pop("sglang_original_input_ids", None)
if images and torch.cuda.is_available():
return self._gpu_call(text, images, original_input_ids)
return self._cpu_call(text, images, original_input_ids, **kwargs)
def _gpu_call(self, text, images, original_input_ids=None):
input_text = text[0] if isinstance(text, list) else text
resize_configs = []
image_sizes = []
for image in images:
w, h = _get_image_dimensions(image)
image_sizes.append((w, h))
resize_configs.append(
navit_resize_config(
w,
h,
self._patch_size,
self._merge_kernel_size,
self._in_patch_limit,
self._patch_limit_on_one_side,
self._fixed_output_tokens,
)
)
input_ids = self._prepare_input_ids(
input_text, resize_configs, original_input_ids, image_sizes
)
image_scale, image_bias = self._get_gpu_norm_tensors()
# Shared source-compatible batched pipeline (same as K2.5): RGBA
# inputs land in their own source-shape groups, and the
# transparent-background compositing runs on each resized batch
# before patchify -- identical order to the previous per-image path.
pixel_values, grid_thws = _gpu_preprocess_images(
images,
resize_configs,
image_scale,
image_bias,
self._patch_size,
to_chw=_k3_to_cuda_chw,
post_resize=lambda x: _fill_transparent_bg(x, self._transparent_bg_config),
)
return {
"input_ids": input_ids,
"pixel_values": pixel_values,
"image_grid_thw": grid_thws,
}
def _cpu_call(self, text, images, original_input_ids=None, **kwargs):
"""HF fallback with the same K3 media framing as the GPU path."""
input_text = text[0] if isinstance(text, list) else text
if not images:
return self._hf_processor(text=[input_text], **kwargs)
image_sizes = [_get_image_dimensions(image) for image in images]
image_token_counts = [
self._hf_processor.media_processor.media_tokens_calculator(
{"type": "image", "image": image}
)
for image in images
]
expanded_text = _expand_k3_image_prompt_text(
input_text,
self._image_token,
image_token_counts,
image_sizes,
)
kwargs["medias"] = [{"type": "image", "image": image} for image in images]
out = self._hf_processor(text=[expanded_text], **kwargs)
out["input_ids"] = self._prepare_input_ids(
input_text,
[{"num_tokens": count} for count in image_token_counts],
original_input_ids,
image_sizes,
)
grid_thws = out.pop("grid_thws", None)
if grid_thws is not None:
out["image_grid_thw"] = grid_thws
return out
class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
models = [KimiK3ForConditionalGeneration]
gpu_image_decode = True
prefer_tokenized_input = True
precompute_hash_before_cpu_transfer = True
auto_mm_processor_worker_num = 2
auto_mm_io_worker_num = 16
supports_mm_processor_concurrency = True
preserve_processor_input_ids = True
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
mm_tokens = MultimodalSpecialTokens(
image_token="<|media_pad|>",
image_token_id=hf_config.media_placeholder_token_id,
image_token_regex=re.compile(r"(?:<\|media_pad\|>)+"),
).build(_processor)
media_proc_cfg = _processor.media_processor.media_proc_cfg
processor = KimiK3GPUProcessorWrapper(
_processor,
image_token=mm_tokens.image_token,
image_token_id=mm_tokens.image_token_id,
patch_size=media_proc_cfg["patch_size"],
merge_kernel_size=media_proc_cfg["merge_kernel_size"],
in_patch_limit=media_proc_cfg["in_patch_limit"],
patch_limit_on_one_side=media_proc_cfg["patch_limit_on_one_side"],
fixed_output_tokens=media_proc_cfg.get("fixed_output_tokens"),
image_mean=media_proc_cfg["image_mean"],
image_std=media_proc_cfg["image_std"],
transparent_bg_config=media_proc_cfg.get("transparent_bg_config"),
)
super().__init__(hf_config, server_args, processor, *args, **kwargs)
self.mm_tokens = mm_tokens
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
if getattr(request_obj, "video_data", None) or kwargs.get("audio_data"):
raise ValueError("Kimi-K3 supports image input only")
expected_image_count = len(image_data or [])
placeholder_count = self.count_image_placeholders(
input_text, self.mm_tokens.image_token_id
)
if placeholder_count is not None:
if placeholder_count != expected_image_count:
raise ValueError(
"Kimi image placeholders must map one-to-one to image data: "
f"expected {expected_image_count}, found {placeholder_count} token(s)"
)
# Keep structural media tokens distinct from user text that happens to
# spell ``<|media_pad|>``. Decoding the whole prompt and matching the
# resulting string would lose that distinction and could bind an image
# to user-provided text instead of the renderer-inserted token.
base_output = await self.fast_load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=False,
# Unlike load_mm_data, fast_load_mm_data does not derive
# input_ids from the prompt. Without this the wrapper falls
# back to re-encoding the decoded string, which is the loss of
# the structural/user distinction described above.
input_ids=input_text,
)
else:
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=False,
)
if len(base_output.images) != expected_image_count:
raise ValueError(
"Kimi image placeholders must map one-to-one to image data: "
f"expected {expected_image_count}, loaded {len(base_output.images)}"
)
mm_items, input_ids, _ = await self.process_and_combine_mm_data_async(
base_output,
self.mm_tokens,
sglang_original_input_ids=base_output.input_ids,
)
# K3's tower is unconditionally image-wise data-parallel (each image
# is consumed by exactly one TP rank), so keep IPC proxies lazy until
# that assignment is known: one tokenizer/scheduler crossing per
# image instead of one per rank. K2.5 gates this on
# --mm-enable-dp-encoder; K3 needs no flag.
if getattr(self, "use_cuda_ipc", False):
for item in mm_items:
item.model_specific_data[DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY] = (
True
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
def get_mm_data(self, prompt, embeddings, **kwargs):
img_grid_thw = kwargs.get("img_grid_thw", None)
output = self._build_kimi_mm_data_from_grids(
prompt=prompt,
embeddings=embeddings,
image_token_id=self.mm_tokens.image_token_id,
img_grid_thw=img_grid_thw,
)
image_sizes = kwargs.get("original_image_sizes")
if image_sizes is None:
return output
counts = [self._num_image_tokens_from_grid(grid) for grid in img_grid_thw]
if len(image_sizes) != len(counts):
raise ValueError(
"Expected one original image size for each K3 encoder grid."
)
output.input_ids = (
_expand_k3_image_prompt_token_ids(
prompt,
self.mm_tokens.image_token_id,
counts,
[tuple(size) for size in image_sizes],
self._tokenizer,
)
.flatten()
.tolist()
)
search_start = 0
for item, count in zip(output.mm_items, counts):
start = output.input_ids.index(self.mm_tokens.image_token_id, search_start)
item.offsets = [(start, start + count - 1)]
search_start = start + count
return output
+288 -111
View File
@@ -78,6 +78,7 @@ from sglang.srt.utils.common import (
is_hip,
is_hopper_with_cuda_12_3,
is_host_cpu_arm64,
is_mnnvl_fabric_device,
is_mps,
is_musa,
is_no_spec_infer_or_topk_one,
@@ -313,7 +314,23 @@ RL_ON_POLICY_TARGET_CHOICES = ["fsdp"]
LORA_BACKEND_CHOICES = ["triton", "csgmv", "ascend", "torch_native"]
ENCODER_TRANSFER_BACKEND_CHOICES = ["zmq_to_scheduler", "zmq_to_tokenizer", "mooncake"]
ENCODER_TRANSFER_BACKEND_CHOICES = [
"auto",
"zmq_to_scheduler",
"zmq_to_tokenizer",
"mooncake",
]
def resolve_encoder_transfer_backend(
backend: str, model_arch: str, tp_size: int
) -> str:
if backend != "auto":
return backend
if model_arch == "KimiK3ForConditionalGeneration" and tp_size > 1:
return "zmq_to_tokenizer"
return "zmq_to_scheduler"
DSA_PREFILL_CP_SPLIT_CHOICES = ["in-seq-split", "round-robin-split"]
NSA_PREFILL_CP_SPLIT_CHOICES = DSA_PREFILL_CP_SPLIT_CHOICES # deprecated alias
@@ -348,7 +365,14 @@ MAMBA_RADIX_CACHE_STRATEGY_CHOICES = [
MAMBA_BACKEND_CHOICES = ["triton", "flashinfer"]
LINEAR_ATTN_KERNEL_BACKEND_CHOICES = ["triton", "cutedsl", "flashinfer", "flashkda"]
LINEAR_ATTN_KERNEL_BACKEND_CHOICES = [
"triton",
"cutedsl",
"flashinfer",
"flashkda",
"nvidia_kda",
"ptx_kda",
]
# Allow external code to add more choices
@@ -1866,7 +1890,12 @@ class ServerArgs:
NS("exec.comm"),
] = False
enable_symm_mem: A[
bool, "Enable NCCL symmetric memory for fast collectives.", NS("exec.comm")
bool,
Arg(
help="Enable NCCL symmetric memory for fast collectives.",
resolvable=True,
),
NS("exec.comm"),
] = False
triton_attention_reduce_in_fp32: A[
bool,
@@ -2097,6 +2126,7 @@ class ServerArgs:
Arg(
help="Attention backend for speculative decoding operations (both target verify and draft extend). Can be one of 'prefill' (default) or 'decode'.",
choices=["prefill", "decode"],
resolvable=True,
),
NS("spec"),
] = "prefill"
@@ -2523,6 +2553,14 @@ class ServerArgs:
),
NS("exec.mamba"),
] = None
linear_attn_verify_backend: A[
Optional[str],
Arg(
help="Override the kernel backend for linear attention speculative target-verify. If not set, follows the decode backend (flashinfer decode -> flashinfer verify, otherwise triton). KDA supports triton, nv_cutedsl, and flashinfer verify backends.",
choices=LINEAR_ATTN_KERNEL_BACKEND_CHOICES + ["nv_cutedsl"],
),
NS("exec.mamba"),
] = None
# ReplaySSM buffered output-only linear-attn decode (GDN + KDA): per-slot
# ring + periodic flush to cut per-step HBM state traffic.
enable_linear_replayssm: A[
@@ -2542,14 +2580,15 @@ class ServerArgs:
"Ring-buffer length L for ReplaySSM linear-attn decode. The full recurrent state is flushed to HBM every L decode steps.",
NS("exec.mamba"),
] = 16
# ReplaySSM spec-verify (Part B of RFC #28511): GDN linear-chain target-verify
# via fold-every-commit instead of per-draft full-state snapshots -- the
# verify stores each draft step's raw inputs into a per-slot window and the
# commit replays the accepted prefix into the fp32 checkpoint. GDN only;
# linear-chain (topk <= 1) only.
enable_gdn_replayssm_spec: A[
# ReplaySSM spec-verify (Part B of RFC #28511): linear-attn target-verify via
# fold-every-commit instead of per-draft full-state snapshots -- the verify
# stores each draft step's raw inputs into a per-slot window and the commit
# replays the accepted prefix into the fp32 checkpoint. GDN sizes the window
# to the draft maximum; KDA folds a (raw v, pre-norm k, gate, beta) ring of
# length --linear-replayssm-cache-len. Linear-chain (topk <= 1) only.
enable_linear_replayssm_spec: A[
bool,
"Enable the ReplaySSM GDN spec-verify (Part B of RFC #28511): fold-every-commit -- a per-slot raw-input window sized to the draft maximum replaces the recurrent verify's per-draft full-state snapshots. GDN only, linear-chain (--speculative-eagle-topk in {None, 1}) only.",
"Enable the ReplaySSM spec-verify: fold-every-commit -- a per-slot raw-input window replaces the recurrent verify's per-draft full-state snapshots. GDN or KDA hybrid linear-attn models, linear-chain (--speculative-eagle-topk in {None, 1}) only.",
NS("exec.mamba"),
] = False
@@ -2704,7 +2743,10 @@ class ServerArgs:
mm_feature_transport: A[
Optional[Literal["cpu", "cuda_ipc"]],
"Transport multimodal features through CPU memory or a bounded CUDA IPC pool. "
"The default is CPU transport; CUDA IPC reserves GPU memory on the base GPU.",
"Unset resolves automatically: single-node CUDA deployments (without "
"disaggregation) use cuda_ipc, everything else uses cpu. CUDA IPC reserves "
"SGLANG_MM_FEATURE_CACHE_MB (default 1024 MiB) on the base GPU and falls "
"back to CPU transport per tensor when the pool is full.",
NS("mm"),
] = None
keep_mm_feature_on_device: A[
@@ -3005,7 +3047,7 @@ class ServerArgs:
encoder_transfer_backend: A[
str,
Arg(
help="The backend for encoder disaggregation transfer. Default is zmq_to_scheduler.",
help="The backend for encoder disaggregation transfer. Auto selects a model- and TP-aware backend.",
choices=ENCODER_TRANSFER_BACKEND_CHOICES,
),
NS("disagg"),
@@ -3550,8 +3592,6 @@ class ServerArgs:
handle_speculative_decoding(self)
# Needs the draft-token count derived just above.
# Validate the CuteDSL A2A token budget now that num_tokens_per_req is final.
self._validate_cutedsl_a2a_token_budget()
@@ -3753,16 +3793,20 @@ class ServerArgs:
)
def _handle_model_source_paths(self):
"""Resolve model/tokenizer paths backed by remote object stores."""
if is_runai_obj_uri(self.model_path):
ObjectStorageModel.download_and_get_path(self.model_path)
if (
self.tokenizer_path is not None
and is_runai_obj_uri(self.tokenizer_path)
and self.tokenizer_path != self.model_path
"""Prepare metadata for model paths backed by remote object stores."""
seen_paths = set()
for model_path in (
self.model_path,
self.tokenizer_path,
self.speculative_draft_model_path,
):
ObjectStorageModel.download_and_get_path(self.tokenizer_path)
if (
model_path is not None
and model_path not in seen_paths
and is_runai_obj_uri(model_path)
):
ObjectStorageModel.download_and_get_path(model_path)
seen_paths.add(model_path)
def _handle_pd_disaggregation(self):
from sglang.srt.arg_groups.pd_disaggregation_hook import (
@@ -3772,9 +3816,6 @@ class ServerArgs:
handle_pd_disaggregation(self)
def _handle_dcp_validation(self):
# Decode context parallel (DCP) is currently implemented and validated
# only on AMD HIP/ROCm. Reject invalid or unverified configurations
# early instead of letting them fail deeper in model initialization.
if self.dcp_size < 1:
raise ValueError(
"Decode context parallel size (--dcp-size / "
@@ -3805,48 +3846,6 @@ class ServerArgs:
"communication backend (it removes the head-dim Q all-gather); "
f"got --dcp-comm-backend={self.dcp_comm_backend}."
)
if not self.dcp_size > 1:
return
if is_hip():
return
elif is_cuda():
if self.speculative_algorithm is not None:
model_arches = self.get_model_config().hf_config.architectures
decode_backend = self.decode_attention_backend or self.attention_backend
kimi_linear_dspark = (
self.speculative_algorithm == "DSPARK"
and "KimiLinearForCausalLM" in model_arches
and self.speculative_attention_mode == "decode"
and decode_backend in ("tokenspeed_mla", "cutedsl_mla")
)
if kimi_linear_dspark:
ragged_verify_mode = envs.SGLANG_RAGGED_VERIFY_MODE.get()
if ragged_verify_mode != "static":
raise ValueError(
"Kimi Linear DCP + DSPARK currently requires "
"SGLANG_RAGGED_VERIFY_MODE=static, but got "
f"{ragged_verify_mode!r}."
)
else:
raise ValueError(
"Decode context parallel (--dcp-size / "
"--decode-context-parallel-size > 1) with speculative "
"decoding on CUDA is supported only for Kimi Linear + "
"DSPARK + --speculative-attention-mode decode + "
"tokenspeed_mla, or experimental cutedsl_mla, but got "
f"architectures={model_arches}, "
f"speculative_algorithm={self.speculative_algorithm!r}, "
"speculative_attention_mode="
f"{self.speculative_attention_mode!r}, "
f"decode_attention_backend={decode_backend!r}."
)
else:
raise ValueError(
"Decode context parallel (--dcp-size / "
"--decode-context-parallel-size > 1) is currently only "
f"supported on the AMD HIP platform, but got dcp_size="
f"{self.dcp_size} on a non-HIP platform."
)
def _handle_load_balance_method(self):
if self.disaggregation_mode not in ("null", "prefill", "decode"):
@@ -4231,7 +4230,12 @@ class ServerArgs:
self.cuda_graph_backend_prefill = Backend.FULL
def _handle_cuda_graph_config(self):
from sglang.srt.arg_groups.kimi_k3_hook import disable_kimi_k3_symm_mem
self._parse_cuda_graph_config()
# Reads the resolved per-phase backends; must precede the compat rules
# below and _handle_gpu_memory_settings, which key off enable_symm_mem.
disable_kimi_k3_symm_mem(self)
self._apply_cuda_graph_compatibility()
self._apply_deepep_adjustments()
self._apply_cuda_graph_disaggregation_roles()
@@ -5088,6 +5092,18 @@ class ServerArgs:
)
validate_declarations(self, self._resolved_overrides)
if model_arch in (
"KimiLinearForCausalLM",
"KimiK3ForConditionalGeneration",
):
from sglang.srt.arg_groups.kimi_k3_hook import (
apply_kimi_k3_linear_attn_defaults,
apply_kimi_k3_spec_backend_defaults,
)
apply_kimi_k3_linear_attn_defaults(self)
apply_kimi_k3_spec_backend_defaults(self)
if model_arch in [
"DeepseekV4ForCausalLM",
]:
@@ -5582,7 +5598,9 @@ class ServerArgs:
"--mamba-radix-cache-strategy extra_buffer."
)
algo = (view.speculative_algorithm or "").upper()
assert algo not in ("DFLASH", "DSPARK"), (
# dspark verifies through prepare_mamba_track_for_verify (lazy plan
# wired); dflash bypasses that hook, so it stays unsupported.
assert algo != "DFLASH", (
f"extra_buffer_lazy unsupported with {view.speculative_algorithm}; "
"use --mamba-radix-cache-strategy extra_buffer."
)
@@ -6018,6 +6036,10 @@ class ServerArgs:
self.linear_attn_decode_backend is None
and is_sm100_supported()
and self.mamba_ssm_dtype == "bfloat16"
# Stage 4: flashinfer's recurrent_kda compiles the state slot stride
# as a free int64, so it reads the page-major/unified envelope-strided
# state correctly — the unified-memory skip is no longer needed (the
# page-major gate now allows flashinfer for linear-attn decode).
):
self.linear_attn_decode_backend = "flashinfer"
logger.info(
@@ -6059,6 +6081,21 @@ class ServerArgs:
f"got {self.mamba_ssm_dtype!r}"
)
verify = self.linear_attn_verify_backend
if verify is None and decode == "flashinfer":
verify = "flashinfer"
if (
verify == "flashinfer"
and self.mamba_ssm_dtype != "bfloat16"
and is_cuda()
and torch.cuda.get_device_capability()[0] >= 10
):
raise ValueError(
"--linear-attn-verify-backend flashinfer on SM100+ requires "
"--mamba-ssm-dtype bfloat16, "
f"got {self.mamba_ssm_dtype!r}"
)
# SM100+ FlashInfer GDN prefill requires CUDA 13+ (CuTe DSL kernel)
# for correctness and best performance.
prefill = self.linear_attn_prefill_backend or self.linear_attn_backend
@@ -6123,20 +6160,20 @@ class ServerArgs:
f"{self.linear_replayssm_cache_len}."
)
# ReplaySSM spec-verify (Part B of #28511): GDN-only, linear-chain target
# verify. Reuses the `linear_replayssm` ring (replayssm_d/k/g + write_pos)
# plus two extra per-slot cursors (cache_base, is_flush) and the chunked
# (I+A)^-1 reconstruction verify kernel. The intra-window interaction uses a
# strictly-lower causal mask, so it is valid ONLY for a linear draft chain
# (speculative_eagle_topk in {None, 1}, i.e. NEXTN / MTP); EAGLE tree verify
# (topk > 1) must fall back to the recurrent verify. GDN-only is enforced at
# runtime (KDA routes through kda_backend, which never enters this path; the
# pool gate also checks `not cache_params.is_kda`). The ring length reuses
# --linear-replayssm-cache-len (no separate flag).
if self.enable_gdn_replayssm_spec:
# ReplaySSM spec-verify (Part B of #28511): linear-chain target verify via
# fold-every-commit -- the verify stores each draft step's raw inputs into
# the per-slot (rawv, rawk, g, beta) window and the commit replays the
# accepted prefix into the fp32 checkpoint. The intra-window interaction
# uses a strictly-lower causal mask, so it is valid ONLY for a linear
# draft chain (speculative_eagle_topk in {None, 1}, i.e. NEXTN / MTP);
# EAGLE tree verify (topk > 1) must fall back to the recurrent verify.
# GDN sizes the window to the draft maximum; KDA (kda_backend) keeps a
# --linear-replayssm-cache-len window and folds via its own fused
# verify ring-write + commit_kda_replayssm_after_verify.
if self.enable_linear_replayssm_spec:
if self.speculative_eagle_topk not in (None, 1):
raise ValueError(
"--enable-gdn-replayssm-spec requires a linear draft chain "
"--enable-linear-replayssm-spec requires a linear draft chain "
"(--speculative-eagle-topk in {None, 1}); the chunked verify "
"kernel uses a strictly-lower causal mask and is invalid for "
"EAGLE tree verify. Got "
@@ -6144,45 +6181,61 @@ class ServerArgs:
)
if decode not in ("triton", "flashinfer"):
raise ValueError(
"--enable-gdn-replayssm-spec requires the triton or "
"--enable-linear-replayssm-spec requires the triton or "
"flashinfer linear-attn decode backend, got "
f"--linear-attn-decode-backend={decode!r}."
)
# The auto->extra_buffer strategy resolution is still a declaration
# here, so read it through the resolved view.
view = self._resolved()
if (
view.disable_radix_cache is False
and view.mamba_radix_cache_strategy == "extra_buffer_lazy"
):
raise ValueError(
"--enable-gdn-replayssm-spec is not validated with "
"--mamba-radix-cache-strategy extra_buffer_lazy yet; "
"use extra_buffer."
)
from sglang.srt.speculative.ragged_verify import (
RaggedVerifyMode,
read_ragged_verify_mode,
)
ragged_mode = read_ragged_verify_mode()
if ragged_mode is not RaggedVerifyMode.STATIC:
# Ragged ring-writes need the KDA fold-every-commit family
# (DSPARK/DFLASH) + the triton verify kernel (nv_cutedsl falls
# back to it for ragged layouts). The GDN ring-write kernels do
# not take the ragged layout and the flashinfer verify kernel
# never writes the ring -> a stale ring would be folded; keep
# refusing those combinations.
_algo = (self.speculative_algorithm or "").upper()
verify = self.linear_attn_verify_backend
if _algo not in ("DSPARK", "DFLASH") or verify not in (
"triton",
"nv_cutedsl",
):
raise ValueError(
"--enable-linear-replayssm-spec with "
f"SGLANG_RAGGED_VERIFY_MODE={ragged_mode.value} requires the "
"KDA fold-every-commit family (DSPARK/DFLASH) and a "
"ring-writing verify kernel (--linear-attn-verify-backend "
"triton or nv_cutedsl); got "
f"algorithm={self.speculative_algorithm!r}, "
f"verify={verify!r}. Use SGLANG_RAGGED_VERIFY_MODE=static."
)
if self.disaggregation_mode == "prefill":
raise ValueError(
"--enable-gdn-replayssm-spec is not supported on a PD "
"--enable-linear-replayssm-spec is not supported on a PD "
"prefill server: the ring is spec-verify-only scratch and "
"the prefill server never runs spec verify."
)
if self.enable_linear_replayssm:
raise ValueError(
"--enable-gdn-replayssm-spec and --enable-linear-replayssm are "
"--enable-linear-replayssm-spec and --enable-linear-replayssm are "
"mutually exclusive: they share the ring storage but drive it "
"with incompatible cursor protocols (per-decode-forward vs "
"per-verify-commit advance)."
)
if self.mamba_ssm_dtype is None:
logger.info(
"--enable-gdn-replayssm-spec: setting --mamba-ssm-dtype "
"--enable-linear-replayssm-spec: setting --mamba-ssm-dtype "
"float32 (the closed-loop exact fold keeps the SSM checkpoint "
"bit-identical to the recurrent baseline)."
)
self.mamba_ssm_dtype = "float32"
elif self.mamba_ssm_dtype != "float32":
logger.warning(
"--enable-gdn-replayssm-spec with --mamba-ssm-dtype=%s: the "
"--enable-linear-replayssm-spec with --mamba-ssm-dtype=%s: the "
"closed-loop fold re-quantizes the committed state each "
"commit/flush (fp32 keeps it bit-exact to the fp32 recurrent "
"baseline), so it may drift over long sequences. Validate "
@@ -7212,6 +7265,13 @@ class ServerArgs:
elif is_remote_url(self.model_path):
self.load_format = "remote"
if (
self.speculative_draft_model_path is not None
and is_runai_obj_uri(self.speculative_draft_model_path)
and self.speculative_draft_load_format is None
):
self.speculative_draft_load_format = "runai_streamer"
if self.custom_weight_loader is None:
self.custom_weight_loader = []
@@ -7361,6 +7421,17 @@ class ServerArgs:
# Validate model type for encoder disaggregation
hf_config = self.get_model_config().hf_config
model_arch = hf_config.architectures[0]
if self.encoder_transfer_backend == "auto":
self.encoder_transfer_backend = resolve_encoder_transfer_backend(
self.encoder_transfer_backend, model_arch, self.tp_size
)
if self.encoder_only or self.language_only:
logger.info(
"Encoder transfer backend auto-resolved to %s for %s at TP%d.",
self.encoder_transfer_backend,
model_arch,
self.tp_size,
)
if (self.encoder_only or self.language_only) and model_arch not in [
"Qwen2VLForConditionalGeneration",
"Qwen3VLForConditionalGeneration",
@@ -7374,6 +7445,7 @@ class ServerArgs:
"Qwen2_5OmniForConditionalGeneration",
"KimiVLForConditionalGeneration",
"KimiK25ForConditionalGeneration",
"KimiK3ForConditionalGeneration",
"MiMoV2ForCausalLM",
]:
raise ValueError(
@@ -7523,6 +7595,26 @@ class ServerArgs:
"--mm-feature-transport=%s instead.",
requested_transport,
)
elif self.encoder_only:
requested_transport = "cpu"
logger.info(
"Multimodal feature transport auto-resolved to cpu for "
"encoder-only serving; encoder outputs use "
"--encoder-transfer-backend instead."
)
elif is_cuda() and self.nnodes == 1 and self.disaggregation_mode == "null":
# Auto policy: single-node CUDA serving defaults to the bounded
# CUDA-IPC pool. The pool is only allocated when a multimodal
# processor exists, so text-only deployments are unaffected; a
# full pool degrades to CPU transport per tensor. Multi-node
# (IPC handles are intra-node) and PD-disaggregated deployments
# keep CPU transport.
requested_transport = "cuda_ipc"
logger.info(
"Multimodal feature transport auto-resolved to cuda_ipc "
"(single-node CUDA). Pass --mm-feature-transport=cpu to "
"opt out."
)
else:
requested_transport = "cpu"
elif legacy_ipc_is_set and legacy_ipc_enabled != (
@@ -7535,6 +7627,14 @@ class ServerArgs:
int(legacy_ipc_enabled),
)
if self.encoder_only and requested_transport == "cuda_ipc":
logger.warning(
"--mm-feature-transport=cuda_ipc does not control encoder-only "
"output transfer; using cpu for this inactive transport. Select "
"--encoder-transfer-backend for encoder outputs."
)
requested_transport = "cpu"
if requested_transport == "cuda_ipc":
if not is_cuda():
raise ValueError(
@@ -7554,6 +7654,16 @@ class ServerArgs:
self.base_gpu_id,
self.tokenizer_worker_num,
)
logger.info(
"CUDA IPC pool-handle caching is %s. It reuses mappings to the "
"existing bounded pool without reserving another pool; set "
"SGLANG_USE_IPC_POOL_HANDLE_CACHE=0 to disable it.",
(
"enabled"
if envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.get()
else "disabled"
),
)
self.mm_feature_transport = requested_transport
# The bounded IPC pool owns device residency. Do not retain unpooled
@@ -7563,6 +7673,41 @@ class ServerArgs:
"1" if requested_transport == "cuda_ipc" else "0"
)
def _handle_custom_all_reduce_v2_multinode(self):
# Custom all-reduce v2's graph zero-copy path uses IPC handles and is
# intra-node only. On MNNVL-fabric devices (GB200/GB300) the eager pull
# path works across nodes via the symm-mem workspace, so opt into the
# multinode mode automatically (a failed fabric rendezvous falls back
# to the legacy path at init). Elsewhere force-disable v2 on
# multi-node so the dispatch falls back to the legacy CustomAllreduce
# path, unless the MNNVL opt-in is set explicitly.
if self.nnodes <= 1 or not envs.SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2.get():
return
if (
not envs.SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE.is_set()
and is_mnnvl_fabric_device()
# CustomAllReduceV2 supports world sizes 2..8 only
# (can_use_custom_all_reduce_v2 rejects larger groups); don't
# auto-opt-in a TP16+ launch just to fall back downstream.
and self.tp_size <= 8
):
logger.info(
"MNNVL fabric device detected with nnodes=%d: enabling "
"custom all-reduce v2 multinode mode "
"(SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE=1; set it "
"to 0 to opt out).",
self.nnodes,
)
envs.SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE.set("1")
if not envs.SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE.get():
if envs.SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2.is_set():
logger.warning(
"Disabling SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2 because nnodes=%d "
"(custom all-reduce v2 is intra-node only).",
self.nnodes,
)
envs.SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2.set("0")
def _handle_environment_variables(self):
self._handle_multimodal_feature_transport()
envs.SGLANG_ENABLE_TORCH_COMPILE.set("1" if self.enable_torch_compile else "0")
@@ -7574,6 +7719,7 @@ class ServerArgs:
envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.set(
"1" if self.enable_deterministic_inference else "0"
)
self._handle_custom_all_reduce_v2_multinode()
if self.debug_cuda_graph:
if not (is_cuda() or is_hip()):
logger.warning(
@@ -7815,19 +7961,43 @@ class ServerArgs:
f"paged MLA backends); got {sorted(backends)}, allowed "
f"{sorted(allowed_full)}. Pass a compatible --attention-backend."
)
# The Mamba state is stored in envelope-strided views; only the
# stride-aware Triton causal-conv / SSM kernels read them correctly.
linear_backends = {
self.linear_attn_backend,
self.linear_attn_decode_backend,
self.linear_attn_prefill_backend,
self.mamba_backend,
}
linear_backends.discard(None)
assert linear_backends <= {"triton"}, (
"--enable-page-major-kv-layout requires the Triton linear-attention / "
f"Mamba kernels for the strided conv/SSM state; got "
f"{sorted(linear_backends)}. Pass --linear-attn-backend triton and "
# The Mamba/KDA state is stored in envelope-strided views; only
# stride-audited kernels may read it (Stage 4 audit, per slot):
# - decode: triton; flashinfer (recurrent_kda compiles the state slot
# stride as a free int64 — natively strided); cutedsl (KDA fused
# sigmoid-gating update made stride-safe) on KDA-hybrid models only —
# cutedsl_gdn still compiles h0 against a contiguous dummy.
# - prefill: triton; flashkda (wrapper gathers/scatters a contiguous
# per-slot copy, external kernel never sees the pool); cutedsl
# (kernel_h compiles h0/ht with dynamic int64 strides), same
# KDA-only caveat.
# - mamba (mamba2/short-conv state): triton only.
# use_mla_backend() distinguishes the KDA-hybrid family (K3/KimiLinear
# are MLA-hybrid) from GDN models (GQA-hybrid) for the cutedsl caveat.
decode_allowed = {"triton", "flashinfer"}
prefill_allowed = {"triton", "flashkda"}
if self.use_mla_backend():
decode_allowed.add("cutedsl")
prefill_allowed.add("cutedsl")
resolved_linear_decode = (
self.linear_attn_decode_backend or self.linear_attn_backend
)
resolved_linear_prefill = (
self.linear_attn_prefill_backend or self.linear_attn_backend
)
assert resolved_linear_decode in decode_allowed | {None}, (
"--enable-page-major-kv-layout: linear-attention DECODE backend must "
f"be one of {sorted(decode_allowed)} for the strided conv/SSM state; "
f"got {resolved_linear_decode!r}."
)
assert resolved_linear_prefill in prefill_allowed | {None}, (
"--enable-page-major-kv-layout: linear-attention PREFILL backend must "
f"be one of {sorted(prefill_allowed)} for the strided conv/SSM state; "
f"got {resolved_linear_prefill!r}."
)
assert self.mamba_backend in (None, "triton"), (
"--enable-page-major-kv-layout requires the Triton Mamba kernels for "
f"the strided conv/SSM state; got {self.mamba_backend!r}. Pass "
"--mamba-backend triton."
)
@@ -8226,6 +8396,13 @@ class ServerArgs:
new_flag="--enable-prefill-cp",
help="[Deprecated] Use --enable-prefill-cp instead.",
)
parser.add_argument(
"--enable-gdn-replayssm-spec",
dest="enable_linear_replayssm_spec",
action=DeprecatedStoreTrueAction,
new_flag="--enable-linear-replayssm-spec",
help="[Deprecated] Use --enable-linear-replayssm-spec instead.",
)
parser.add_argument(
"--enable-prefill-context-parallel",
dest="enable_prefill_context_parallel",
@@ -105,6 +105,9 @@ class DFlashVerifyInput(SpecInput):
bs = len(req_pool_indices)
layout = self.ragged_verify_layout
if layout is not None and layout.bs != bs:
# Graph replay pads the batch to the captured slots; match it.
layout = layout.padded_to_bucket(padded_bs=bs)
if layout is None:
qo_indptr = torch.arange(
+10 -1
View File
@@ -13,7 +13,7 @@ from sglang.srt.layers.quantization.unquant import UnquantizedLinearMethod
from sglang.srt.layers.sampler import apply_custom_logit_processor
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.speculative.spec_utils import _sample_simulated_acc_len
from sglang.srt.utils import is_cuda, is_musa
from sglang.srt.utils import is_cuda, is_hip, is_musa
DEFAULT_DFLASH_MASK_TOKEN = "<|MASK|>"
@@ -46,6 +46,15 @@ if is_cuda() or is_musa():
top_k_renorm_prob = None
top_p_renorm_prob = None
tree_speculative_sampling_target_only = None
elif is_hip():
from sglang.kernels.ops.sampling.renorm_triton import (
top_k_renorm_probs_triton as top_k_renorm_prob,
)
from sglang.kernels.ops.sampling.renorm_triton import (
top_p_renorm_probs_triton as top_p_renorm_prob,
)
_DFLASH_SAMPLING_VERIFY_AVAILABLE = True
else:
top_k_renorm_prob = None
top_p_renorm_prob = None
@@ -22,13 +22,16 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# trtllm_mha: decode-only dense-MQA drafts (dspark). DFLASH excludes it
# earlier, at arg resolution (speculative_hook.py) -- its draft path needs
# per-layer DFlash attention -- so it never reaches this gate with it.
_SUPPORTED_DRAFT_BACKENDS = (
"flashinfer",
"fa3",
"fa4",
"triton",
"trtllm_mha",
"ascend",
"trtllm_mha",
)
@@ -92,8 +92,15 @@ class DSparkWorkerV2(BaseSpecWorker):
self._draft_dp_context_enabled = (
server_args.enable_dp_attention and not self._draft_is_moe
)
attn_tp_size = server_args.tp_size // max(server_args.dp_size, 1)
if server_args.enable_dp_attention and self._draft_is_moe and attn_tp_size > 1:
self._is_pd_prefill = server_args.disaggregation_mode == "prefill"
self._decode_graph_allowed = (
not server_args.disable_cuda_graph and not self._is_pd_prefill
)
if (
server_args.enable_dp_attention
and self._draft_is_moe
and ps.attn_tp_size > 1
):
raise ValueError(
"DSpark + dp attention with a DeepSeek-V4 (MoE) draft requires "
"attn_tp == 1 (set --dp-size == --tp). attn_tp > 1 corrupts the "
@@ -173,7 +180,7 @@ class DSparkWorkerV2(BaseSpecWorker):
server_args.enable_dp_attention
and not self._draft_is_moe
and self._verify_planner.is_compact_mode
and not server_args.disable_cuda_graph
and self._decode_graph_allowed
):
raise ValueError(
"DSpark dense-draft compact verify under --enable-dp-attention does not "
@@ -201,7 +208,7 @@ class DSparkWorkerV2(BaseSpecWorker):
self._verify_epilogue = None
if (
self._verify_planner.is_compact_mode
and not server_args.disable_cuda_graph
and self._decode_graph_allowed
and is_cuda()
):
self._verify_epilogue = DsparkVerifyEpilogue(
@@ -262,6 +269,9 @@ class DSparkWorkerV2(BaseSpecWorker):
simulate_acc_len=self._simulate_acc_len,
)
if self._is_pd_prefill and not self._draft_is_moe:
self.draft_model.prune_to_ctx_kv_injection()
def _resolve_target_embed_tokens(self, target_model):
if hasattr(target_model, "get_input_embeddings"):
return target_model.get_input_embeddings()
@@ -311,7 +321,7 @@ class DSparkWorkerV2(BaseSpecWorker):
)
def init_cuda_graphs(self):
capture_decode_cuda_graph = not get_exec().graph.disable_cuda_graph
capture_decode_cuda_graph = self._decode_graph_allowed
if is_cuda() and capture_decode_cuda_graph:
available_mem = get_available_gpu_memory(self.device, self.gpu_id)
if available_mem < 1.0:
@@ -420,6 +430,8 @@ class DSparkWorkerV2(BaseSpecWorker):
if batch.out_cache_loc is None:
raise RuntimeError("DSpark prefill expected out_cache_loc, but got None.")
# Must inject before prefill returns: the scheduler may update radix
# afterward, invalidating out_cache_loc.
device = next_token_ids.device
ctx_lens = torch.tensor(batch.extend_lens, dtype=torch.int32, device=device)
draft_seq_lens = torch.tensor(
@@ -436,6 +448,7 @@ class DSparkWorkerV2(BaseSpecWorker):
cache_loc=batch.out_cache_loc,
positions=positions,
)
# Avoid copying large hidden-state buffers to CPU in overlap scheduling.
logits_output.hidden_states = None
batch_output.next_draft_input = make_next_draft_input(
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from sglang.srt.managers.overlap_utils import RelayPayload
from sglang.srt.speculative.dspark_components.dspark_draft import make_next_draft_input
if TYPE_CHECKING:
from sglang.srt.managers.overlap_utils import FutureMap
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpecInput
def build_dspark_disagg_draft_input(
batch: ScheduleBatch,
server_args: ServerArgs,
last_tokens_tensor: torch.Tensor,
future_map: FutureMap,
) -> SpecInput:
spec_info = make_next_draft_input(
bonus_tokens=last_tokens_tensor,
new_seq_lens=batch.seq_lens,
)
if batch.enable_overlap:
spec_info.future_dsa_topk_indices_available = False
spec_info.future_indices = batch.req_pool_indices
future_map.publish(spec_info.future_indices, batch.seq_lens)
future_map.stash(
spec_info.future_indices, RelayPayload.from_draft_input(spec_info)
)
return spec_info

Some files were not shown because too many files have changed in this diff Show More