chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.layers.amx_utils import (
CPUQuantMethod,
_amx_process_weight_after_loading,
)
from sglang.srt.layers.moe import MoeRunnerConfig
if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import StandardDispatchOutput
from sglang.srt.layers.quantization.awq.awq import AWQConfig
__all__ = ["AWQIntelAMXLinearKernel", "AWQIntelAMXMoEKernel"]
class AWQIntelAMXLinearKernel:
def __init__(self, quant_config: AWQConfig):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
_amx_process_weight_after_loading(
layer, ["qweight", "qzeros", "scales"], None, "awq"
)
layer.qweight = torch.nn.Parameter(layer.qweight.data, requires_grad=False)
layer.qzeros = torch.nn.Parameter(layer.qzeros.data, requires_grad=False)
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
return torch.ops.sgl_kernel.int4_scaled_mm_cpu(
x,
layer.qweight,
layer.qzeros,
layer.scales,
bias,
)
class AWQIntelAMXMoEKernel:
def __init__(self, quant_config: AWQConfig):
self.quant_config = quant_config
self.moe_runner_config: Optional[MoeRunnerConfig] = None
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
_amx_process_weight_after_loading(
layer, ["w13_qweight", "w13_qzeros", "w13_scales"], None, "awq"
)
_amx_process_weight_after_loading(
layer, ["w2_qweight", "w2_qzeros", "w2_scales"], None, "awq"
)
def create_moe_runner(
self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig
):
self.moe_runner_config = moe_runner_config
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> torch.Tensor:
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput
assert (
self.moe_runner_config.activation == "silu"
), "Only SiLU activation is supported."
x = dispatch_output.hidden_states
topk_output = dispatch_output.topk_output
topk_weights, topk_ids, _ = topk_output
output = torch.ops.sgl_kernel.fused_experts_cpu(
x,
layer.w13_qweight,
layer.w2_qweight,
topk_weights,
topk_ids,
False, # inplace See [Note] inplace should be False in fused_experts.
CPUQuantMethod.INT4_W4A8,
layer.w13_scales, # w1_scale
layer.w2_scales, # w2_scale
layer.w13_qzeros,
layer.w2_qzeros,
None, # block_size
None, # w1 bias
None, # w3 bias
None, # alpha
None, # limit
True, # is_vnni
)
return StandardCombineInput(hidden_states=output)
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.layers.amx_utils import (
CPUQuantMethod,
_amx_process_weight_after_loading,
)
from sglang.srt.layers.moe import MoeRunnerConfig
if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import StandardDispatchOutput
from sglang.srt.layers.quantization.gptq.gptq import GPTQConfig
__all__ = ["GPTQIntelAMXLinearKernel", "GPTQIntelAMXMoEKernel"]
class GPTQIntelAMXLinearKernel:
def __init__(self, quant_config: GPTQConfig):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
_amx_process_weight_after_loading(
layer, ["qweight", "qzeros", "scales"], None, "gptq"
)
layer.qweight = torch.nn.Parameter(layer.qweight.data, requires_grad=False)
layer.qzeros = torch.nn.Parameter(layer.qzeros.data, requires_grad=False)
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
return torch.ops.sgl_kernel.int4_scaled_mm_cpu(
x,
layer.qweight,
layer.qzeros,
layer.scales,
bias,
)
class GPTQIntelAMXMoEKernel:
def __init__(self, quant_config: GPTQConfig):
self.quant_config = quant_config
self.moe_runner_config: Optional[MoeRunnerConfig] = None
def create_moe_runner(
self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig
):
self.moe_runner_config = moe_runner_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
_amx_process_weight_after_loading(
layer, ["w13_qweight", "w13_qzeros", "w13_scales"], None, "gptq"
)
_amx_process_weight_after_loading(
layer, ["w2_qweight", "w2_qzeros", "w2_scales"], None, "gptq"
)
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> torch.Tensor:
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput
assert (
self.moe_runner_config.activation == "silu"
), "Only SiLU activation is supported."
x = dispatch_output.hidden_states
topk_output = dispatch_output.topk_output
topk_weights, topk_ids, _ = topk_output
output = torch.ops.sgl_kernel.fused_experts_cpu(
x,
layer.w13_qweight,
layer.w2_qweight,
topk_weights,
topk_ids,
False, # inplace See [Note] inplace should be False in fused_experts.
CPUQuantMethod.INT4_W4A8,
layer.w13_scales, # w1_scale
layer.w2_scales, # w2_scale
layer.w13_qzeros,
layer.w2_qzeros,
None, # block_size
None, # w1 bias
None, # w3 bias
None, # alpha
None, # limit
True, # is_vnni
)
return StandardCombineInput(hidden_states=output)
@@ -0,0 +1,255 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.layers.moe import MoeRunner
from sglang.srt.layers.moe.moe_runner.marlin import MarlinMoeQuantInfo
from sglang.srt.layers.quantization.marlin_utils import (
apply_awq_marlin_linear,
awq_to_marlin_zero_points,
marlin_make_empty_g_idx,
marlin_make_workspace,
marlin_moe_permute_scales,
marlin_permute_scales,
moe_awq_to_marlin_zero_points,
)
from sglang.srt.layers.quantization.utils import get_scalar_types, replace_parameter
from sglang.srt.utils import is_hip, is_xpu
if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import (
CombineInput,
StandardDispatchOutput,
)
from sglang.srt.layers.quantization.base_config import QuantizationConfig
awq_marlin_moe_repack = None
awq_marlin_repack = None
def _unsupported_awq_dequantize(*args, **kwargs):
raise RuntimeError("AWQ GPU kernels are unavailable on the current platform.")
awq_dequantize = _unsupported_awq_dequantize
if is_xpu():
try:
from sgl_kernel import awq_dequantize
except ImportError:
pass
elif is_hip():
try:
from sglang.srt.layers.quantization.awq.awq_triton import (
awq_dequantize_triton as awq_dequantize,
)
except ImportError:
pass
else:
try:
from sglang.jit_kernel.awq_dequantize import awq_dequantize
from sglang.jit_kernel.awq_marlin_repack import (
awq_marlin_moe_repack,
awq_marlin_repack,
)
from sglang.srt.utils.custom_op import register_custom_op_from_extern
awq_dequantize = register_custom_op_from_extern(
awq_dequantize,
fake_impl=lambda qweight, scales, qzeros: qweight.new_empty(
qweight.shape[:-1] + (qweight.shape[-1] * 8,), dtype=scales.dtype
),
)
except ImportError:
try:
from sglang.srt.layers.quantization.awq.awq_triton import (
awq_dequantize_triton as awq_dequantize,
)
except ImportError:
try:
from sgl_kernel import awq_dequantize
except ImportError:
pass
_, scalar_types = get_scalar_types()
class AWQLinearKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
layer.qweight = torch.nn.Parameter(layer.qweight.data, requires_grad=False)
layer.qzeros = torch.nn.Parameter(layer.qzeros.data, requires_grad=False)
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
qweight = layer.qweight
scales = layer.scales
qzeros = layer.qzeros
pack_factor = self.quant_config.pack_factor
out_shape = x.shape[:-1] + (qweight.shape[-1] * pack_factor,)
reshaped_x = x.reshape(-1, x.shape[-1])
out = awq_dequantize(qweight, scales, qzeros)
out = torch.matmul(reshaped_x, out)
if bias is not None:
out.add_(bias)
return out.reshape(out_shape)
class AWQMarlinLinearKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
device = layer.qweight.device
layer.qweight = torch.nn.Parameter(layer.qweight.data, requires_grad=False)
layer.qzeros = torch.nn.Parameter(layer.qzeros.data, requires_grad=False)
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
layer.workspace = marlin_make_workspace(device)
marlin_qweight = awq_marlin_repack(
layer.qweight,
size_k=layer.input_size_per_partition,
size_n=layer.output_size_per_partition,
num_bits=self.quant_config.quant_type.size_bits,
)
replace_parameter(layer, "qweight", marlin_qweight)
marlin_scales = marlin_permute_scales(
layer.scales,
size_k=layer.input_size_per_partition,
size_n=layer.output_size_per_partition,
group_size=self.quant_config.group_size,
)
replace_parameter(layer, "scales", marlin_scales)
marlin_zp = awq_to_marlin_zero_points(
layer.qzeros,
size_k=layer.num_groups,
size_n=layer.output_size_per_partition,
num_bits=self.quant_config.quant_type.size_bits,
)
replace_parameter(layer, "qzeros", marlin_zp)
layer.g_idx = marlin_make_empty_g_idx(device)
layer.g_idx_sort_indices = marlin_make_empty_g_idx(device)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
return apply_awq_marlin_linear(
input=x,
weight=layer.qweight,
weight_scale=layer.scales,
weight_zp=layer.qzeros,
g_idx=layer.g_idx,
g_idx_sort_indices=layer.g_idx_sort_indices,
workspace=layer.workspace,
quant_type=self.quant_config.quant_type,
output_size_per_partition=layer.output_size_per_partition,
input_size_per_partition=layer.input_size_per_partition,
bias=bias,
)
class AWQMoEKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
self.runner: Optional[MoeRunner] = None
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
num_experts = layer.w13_qweight.shape[0]
device = layer.w13_qweight.device
layer.w13_g_idx_sort_indices = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
layer.w2_g_idx_sort_indices = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
marlin_w13_qweight = awq_marlin_moe_repack(
layer.w13_qweight,
layer.w13_g_idx_sort_indices,
size_k=layer.w13_qweight.shape[1],
size_n=layer.w13_qweight.shape[2] * self.quant_config.pack_factor,
num_bits=self.quant_config.weight_bits,
)
replace_parameter(layer, "w13_qweight", marlin_w13_qweight)
marlin_w2_qweight = awq_marlin_moe_repack(
layer.w2_qweight,
layer.w2_g_idx_sort_indices,
size_k=layer.w2_qweight.shape[1],
size_n=layer.w2_qweight.shape[2] * self.quant_config.pack_factor,
num_bits=self.quant_config.weight_bits,
)
replace_parameter(layer, "w2_qweight", marlin_w2_qweight)
marlin_w13_scales = marlin_moe_permute_scales(
s=layer.w13_scales,
size_k=layer.intermediate_size_per_partition,
size_n=layer.w13_scales.shape[2],
group_size=self.quant_config.group_size,
)
replace_parameter(layer, "w13_scales", marlin_w13_scales)
marlin_w2_scales = marlin_moe_permute_scales(
s=layer.w2_scales,
size_k=layer.intermediate_size_per_partition,
size_n=layer.w2_scales.shape[2],
group_size=self.quant_config.group_size,
)
replace_parameter(layer, "w2_scales", marlin_w2_scales)
marlin_w13_zp = moe_awq_to_marlin_zero_points(
layer.w13_qzeros,
size_k=layer.w13_qzeros.shape[1],
size_n=layer.w13_qzeros.shape[2] * self.quant_config.pack_factor,
num_bits=self.quant_config.weight_bits,
)
replace_parameter(layer, "w13_qzeros", marlin_w13_zp)
marlin_w2_zp = moe_awq_to_marlin_zero_points(
layer.w2_qzeros,
size_k=layer.w2_qzeros.shape[1],
size_n=layer.w2_qzeros.shape[2] * self.quant_config.pack_factor,
num_bits=self.quant_config.weight_bits,
)
replace_parameter(layer, "w2_qzeros", marlin_w2_zp)
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> CombineInput:
if self.runner is None:
raise RuntimeError("moe runner is not initialized")
quant_info = MarlinMoeQuantInfo(
w13_qweight=layer.w13_qweight,
w2_qweight=layer.w2_qweight,
w13_scales=layer.w13_scales,
w2_scales=layer.w2_scales,
w13_g_idx_sort_indices=layer.w13_g_idx_sort_indices,
w2_g_idx_sort_indices=layer.w2_g_idx_sort_indices,
w13_qzeros=layer.w13_qzeros,
w2_qzeros=layer.w2_qzeros,
weight_bits=self.quant_config.weight_bits,
)
return self.runner.run(dispatch_output, quant_info)
@@ -0,0 +1,382 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Optional
import torch
from sglang.srt.layers.moe import MoeRunner, MoeRunnerBackend, get_moe_runner_backend
from sglang.srt.layers.moe.moe_runner.marlin import MarlinMoeQuantInfo
from sglang.srt.layers.parameter import BasevLLMParameter, permute_param_layout_
from sglang.srt.layers.quantization.marlin_utils import (
apply_gptq_marlin_linear,
check_marlin_supports_shape,
marlin_is_k_full,
marlin_make_empty_g_idx,
marlin_make_workspace,
marlin_moe_permute_scales,
marlin_permute_scales,
marlin_sort_g_idx,
marlin_zero_points,
)
from sglang.srt.layers.quantization.utils import (
get_scalar_types,
replace_parameter,
unpack_cols,
)
if TYPE_CHECKING:
from sglang.srt.layers.moe import MoeRunnerConfig
from sglang.srt.layers.moe.token_dispatcher import (
CombineInput,
StandardDispatchOutput,
)
from sglang.srt.layers.quantization.base_config import QuantizationConfig
ScalarType, _ = get_scalar_types()
def _unsupported_kernel(*args, **kwargs):
raise RuntimeError("GPTQ CUDA kernels are unavailable on the current platform.")
gptq_gemm = _unsupported_kernel
gptq_marlin_repack = _unsupported_kernel
gptq_shuffle = _unsupported_kernel
try:
from sgl_kernel import gptq_gemm, gptq_shuffle
from sglang.jit_kernel.gptq_marlin_repack import gptq_marlin_repack
except Exception:
pass
@dataclass
class MarlinLinearLayerConfig:
full_weight_shape: tuple[int, int] # [in, out]
partition_weight_shape: tuple[int, int]
weight_type: ScalarType
act_type: torch.dtype
group_size: int
zero_points: bool
has_g_idx: bool
def gptq_marlin_moe_repack(
b_q_weight: torch.Tensor,
perm: torch.Tensor,
size_k: int,
size_n: int,
num_bits: int,
) -> torch.Tensor:
num_experts = b_q_weight.shape[0]
assert size_k % 16 == 0
output = torch.empty(
(num_experts, size_k // 16, size_n * (num_bits // 2)),
device=b_q_weight.device,
dtype=b_q_weight.dtype,
)
for e in range(num_experts):
output[e] = gptq_marlin_repack(b_q_weight[e], perm[e], size_k, size_n, num_bits)
return output
class GPTQLinearKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
self.use_shuffle = True
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# for torch.compile
layer.qzeros = torch.nn.Parameter(layer.qzeros.data, requires_grad=False)
layer.qweight = torch.nn.Parameter(layer.qweight.data, requires_grad=False)
layer.g_idx = torch.nn.Parameter(layer.g_idx.data, requires_grad=False)
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
# exllama needs to shuffle the weight after the weight is loaded
# here we do the shuffle on first forward pass
if self.use_shuffle:
if self.quant_config.desc_act:
layer.g_idx.data = torch.argsort(layer.g_idx).to(torch.int)
else:
layer.g_idx.data = torch.empty(
(0,), dtype=torch.int, device=layer.g_idx.device
)
gptq_shuffle(layer.qweight, layer.g_idx, self.quant_config.weight_bits)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
out_shape = x.shape[:-1] + (layer.qweight.shape[-1],)
reshaped_x = x.reshape(-1, x.shape[-1])
output = gptq_gemm(
reshaped_x,
layer.qweight,
layer.qzeros,
layer.scales,
layer.g_idx,
self.use_shuffle,
self.quant_config.weight_bits,
)
if bias is not None:
output.add_(bias)
return output.reshape(out_shape)
class GPTQMarlinLinearKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
device = getattr(layer, "qweight").device
c = self.kernel_config
check_marlin_supports_shape(
c.partition_weight_shape[1], # out_features
c.partition_weight_shape[0], # in_features
c.full_weight_shape[0], # in_features
c.group_size,
)
row_parallel = c.partition_weight_shape[0] != c.full_weight_shape[0]
self.is_k_full = marlin_is_k_full(c.has_g_idx, row_parallel)
# Allocate marlin workspace.
self.workspace = marlin_make_workspace(device)
# Default names since marlin requires empty parameters for these,
# TODO: remove this requirement from marlin (allow optional tensors)
self.w_q_name = "qweight"
self.w_s_name = "scales"
self.w_zp_name = "qzeros"
self.w_gidx_name = "g_idx"
def _transform_param(
layer: torch.nn.Module, name: Optional[str], fn: Callable
) -> None:
if name is not None and getattr(layer, name, None) is not None:
old_param = getattr(layer, name)
new_param = fn(old_param)
# replace the parameter with torch.nn.Parameter for TorchDynamo
# compatibility
replace_parameter(
layer, name, torch.nn.Parameter(new_param.data, requires_grad=False)
)
def transform_w_q(x):
assert isinstance(x, BasevLLMParameter)
permute_param_layout_(x, input_dim=0, output_dim=1, packed_dim=0)
x.data = gptq_marlin_repack(
x.data.contiguous(),
perm=layer.g_idx_sort_indices,
size_k=c.partition_weight_shape[0],
size_n=c.partition_weight_shape[1],
num_bits=c.weight_type.size_bits,
)
return x
def transform_w_s(x):
assert isinstance(x, BasevLLMParameter)
permute_param_layout_(x, input_dim=0, output_dim=1)
x.data = marlin_permute_scales(
x.data.contiguous(),
size_k=c.partition_weight_shape[0],
size_n=c.partition_weight_shape[1],
group_size=c.group_size,
)
return x
if c.has_g_idx:
g_idx, g_idx_sort_indices = marlin_sort_g_idx(
getattr(layer, self.w_gidx_name)
)
_transform_param(layer, self.w_gidx_name, lambda _: g_idx)
layer.g_idx_sort_indices = g_idx_sort_indices
else:
setattr(layer, self.w_gidx_name, marlin_make_empty_g_idx(device))
layer.g_idx_sort_indices = marlin_make_empty_g_idx(device)
if c.zero_points:
grouped_k = (
c.partition_weight_shape[0] // c.group_size if c.group_size != -1 else 1
)
_transform_param(
layer,
self.w_zp_name,
lambda x: marlin_zero_points(
unpack_cols(
x.t(),
c.weight_type.size_bits,
grouped_k,
c.partition_weight_shape[1],
),
size_k=grouped_k,
size_n=c.partition_weight_shape[1],
num_bits=c.weight_type.size_bits,
),
)
else:
setattr(layer, self.w_zp_name, marlin_make_empty_g_idx(device))
_transform_param(layer, self.w_q_name, transform_w_q)
_transform_param(layer, self.w_s_name, transform_w_s)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
c = self.kernel_config
def _get_weight_params(
layer: torch.nn.Module,
) -> tuple[
torch.Tensor, # w_q
torch.Tensor, # w_s
Optional[torch.Tensor], # w_zp,
Optional[torch.Tensor], # w_gidx
]:
return (
getattr(layer, self.w_q_name),
getattr(layer, self.w_s_name),
getattr(layer, self.w_zp_name or "", None),
getattr(layer, self.w_gidx_name or "", None),
)
w_q, w_s, w_zp, w_gidx = _get_weight_params(layer)
# `process_weights_after_loading` will ensure w_zp and w_gidx are not
# None for marlin
return apply_gptq_marlin_linear(
input=x,
weight=w_q,
weight_scale=w_s,
weight_zp=w_zp, # type: ignore
g_idx=w_gidx, # type: ignore
g_idx_sort_indices=layer.g_idx_sort_indices,
workspace=self.workspace,
wtype=c.weight_type,
input_size_per_partition=c.partition_weight_shape[0],
output_size_per_partition=c.partition_weight_shape[1],
is_k_full=self.is_k_full,
bias=bias,
)
class GPTQMarlinMoEKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# Process act_order
if self.quant_config.desc_act:
# Get sorting based on g_idx
num_experts = layer.w13_g_idx.shape[0]
w13_g_idx_sort_indices = torch.empty_like(layer.w13_g_idx)
w2_g_idx_sort_indices = torch.empty_like(layer.w2_g_idx)
w13_sorted_g_idx = torch.empty_like(layer.w13_g_idx)
w2_sorted_g_idx = torch.empty_like(layer.w2_g_idx)
for e in range(num_experts):
w13_g_idx_sort_indices[e] = torch.argsort(layer.w13_g_idx[e]).to(
torch.int32
)
w2_g_idx_sort_indices[e] = torch.argsort(layer.w2_g_idx[e]).to(
torch.int32
)
w13_sorted_g_idx[e] = layer.w13_g_idx[e][w13_g_idx_sort_indices[e]]
w2_sorted_g_idx[e] = layer.w2_g_idx[e][w2_g_idx_sort_indices[e]]
replace_parameter(layer, "w13_g_idx", w13_sorted_g_idx)
replace_parameter(layer, "w2_g_idx", w2_sorted_g_idx)
replace_parameter(layer, "w13_g_idx_sort_indices", w13_g_idx_sort_indices)
replace_parameter(layer, "w2_g_idx_sort_indices", w2_g_idx_sort_indices)
else:
# Reset g_idx related tensors
num_experts = layer.w13_g_idx.shape[0]
device = layer.w13_g_idx.device
layer.w13_g_idx = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
layer.w2_g_idx = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
layer.w13_g_idx_sort_indices = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
layer.w2_g_idx_sort_indices = torch.nn.Parameter(
torch.empty((num_experts, 0), dtype=torch.int32, device=device),
requires_grad=False,
)
# Repack weights
marlin_w13_qweight = gptq_marlin_moe_repack(
layer.w13_qweight,
layer.w13_g_idx_sort_indices,
layer.w13_qweight.shape[1] * self.quant_config.pack_factor,
layer.w13_qweight.shape[2],
self.quant_config.weight_bits,
)
replace_parameter(layer, "w13_qweight", marlin_w13_qweight)
marlin_w2_qweight = gptq_marlin_moe_repack(
layer.w2_qweight,
layer.w2_g_idx_sort_indices,
layer.w2_qweight.shape[1] * self.quant_config.pack_factor,
layer.w2_qweight.shape[2],
self.quant_config.weight_bits,
)
replace_parameter(layer, "w2_qweight", marlin_w2_qweight)
# Repack scales
marlin_w13_scales = marlin_moe_permute_scales(
s=layer.w13_scales,
size_k=layer.intermediate_size_per_partition,
size_n=layer.w13_scales.shape[2],
group_size=self.quant_config.group_size,
)
replace_parameter(layer, "w13_scales", marlin_w13_scales)
marlin_w2_scales = marlin_moe_permute_scales(
s=layer.w2_scales,
size_k=layer.w2_scales.shape[1]
* (
self.quant_config.group_size
if self.quant_config.group_size != -1
else self.quant_config.pack_factor
),
size_n=layer.w2_scales.shape[2],
group_size=self.quant_config.group_size,
)
replace_parameter(layer, "w2_scales", marlin_w2_scales)
def create_moe_runner(
self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig
):
assert get_moe_runner_backend().is_auto()
self.moe_runner_config = moe_runner_config
self.runner = MoeRunner(MoeRunnerBackend.MARLIN, moe_runner_config)
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> CombineInput:
quant_info = MarlinMoeQuantInfo(
w13_qweight=layer.w13_qweight,
w2_qweight=layer.w2_qweight,
w13_scales=layer.w13_scales,
w2_scales=layer.w2_scales,
w13_g_idx=layer.w13_g_idx,
w2_g_idx=layer.w2_g_idx,
w13_g_idx_sort_indices=layer.w13_g_idx_sort_indices,
w2_g_idx_sort_indices=layer.w2_g_idx_sort_indices,
weight_bits=self.quant_config.weight_bits,
is_k_full=self.is_k_full,
)
return self.runner.run(dispatch_output, quant_info)
@@ -0,0 +1,240 @@
"""AOT kernel selection and decode-context helpers for the MLX backend."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Optional
import mlx.core as mx
from sglang.srt.environ import envs
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
)
def _load_metal_rope_pool_fused():
try:
from sgl_kernel import metal
except ImportError as exc:
raise ImportError(
"sgl_kernel.metal is not importable. Install sgl-kernel in the "
"active environment before enabling SGLANG_MLX_USE_CUSTOM_ROPE."
) from exc
import_error = getattr(metal, "_IMPORT_ERROR", None)
if getattr(metal, "_metal", None) is None or import_error is not None:
reason = f" Reason: {import_error}." if import_error is not None else ""
raise ImportError(
"sgl_kernel.metal is importable, but the native Metal extension "
f"or metallib is not available.{reason} Install the Metal kernels "
"with `uv run sgl-kernel/setup_metal.py install` from the SGLang "
"repo root in the active environment."
) from import_error
return metal.rope_pool_fused
@dataclass
class MlxAOTRoPEKernel:
base: float = 0.0
config: dict[str, Any] = field(default_factory=dict)
rope_pool_fused: Optional[Any] = None
@property
def enabled(self) -> bool:
return (
self.base > 0.0 and bool(self.config) and self.rope_pool_fused is not None
)
@dataclass
class MlxAOTKernelBuildInputs:
sample_attn: Any
n_kv_heads: int
head_dim: int
@dataclass(frozen=True)
class MlxAOTKernelSpec:
name: str
kernel_attr: str
is_enabled: Callable[[], bool]
build: Callable[[MlxAOTKernelBuildInputs], Any]
@dataclass
class MlxAOTKernelSet:
rope: MlxAOTRoPEKernel = field(default_factory=MlxAOTRoPEKernel)
selected_kernel_names: tuple[str, ...] = ()
class MlxAOTKernelRegistry:
"""Registry for optional MLX AOT kernels.
Each spec owns one kernel field on ``MlxAOTKernelSet``. The registry is the
only place that checks kernel enablement policy and model support.
"""
def __init__(self, specs: tuple[MlxAOTKernelSpec, ...]):
self._specs = specs
@property
def registered_kernel_names(self) -> tuple[str, ...]:
return tuple(spec.name for spec in self._specs)
def build_kernel_set(
self,
*,
sample_attn: Any,
n_kv_heads: int,
head_dim: int,
) -> MlxAOTKernelSet:
inputs = MlxAOTKernelBuildInputs(
sample_attn=sample_attn,
n_kv_heads=n_kv_heads,
head_dim=head_dim,
)
kernel_set = MlxAOTKernelSet()
selected_kernel_names = []
for spec in self._specs:
if not spec.is_enabled():
continue
kernel = spec.build(inputs)
if getattr(kernel, "enabled", False):
if not hasattr(kernel_set, spec.kernel_attr):
raise ValueError(
f"AOT kernel {spec.name} targets unknown kernel-set "
f"attribute {spec.kernel_attr}"
)
setattr(kernel_set, spec.kernel_attr, kernel)
selected_kernel_names.append(spec.name)
kernel_set.selected_kernel_names = tuple(selected_kernel_names)
if kernel_set.selected_kernel_names:
logger.info(
"MLX AOT kernels selected: %s",
", ".join(kernel_set.selected_kernel_names),
)
return kernel_set
def _build_rope_kernel(inputs: MlxAOTKernelBuildInputs) -> MlxAOTRoPEKernel:
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_num_heads,
)
sample_attn = getattr(inputs.sample_attn, "_inner", inputs.sample_attn)
rope = getattr(sample_attn, "rope", None)
if rope is None or getattr(rope, "traditional", False):
return MlxAOTRoPEKernel()
rope_dim = int(getattr(rope, "dims", 0))
if rope_dim == 0:
return MlxAOTRoPEKernel()
if rope_dim != inputs.head_dim:
# AOT kernel currently requires rope_dim == head_dim.
return MlxAOTRoPEKernel()
base = float(getattr(rope, "base", 10000.0))
num_qo_heads = get_num_heads(sample_attn)
if num_qo_heads is None:
return MlxAOTRoPEKernel()
config = {
"head_dim": int(inputs.head_dim),
"rope_dim": rope_dim,
"num_qo_heads": int(num_qo_heads),
"num_kv_heads": int(inputs.n_kv_heads),
}
try:
rope_pool_fused = _load_metal_rope_pool_fused()
except Exception as exc: # noqa: BLE001
logger.info(
"AOT Metal RoPE kernel not available (%s) - falling back to "
"mx.fast.rope.",
exc,
)
return MlxAOTRoPEKernel()
logger.info(
f"AOT Metal RoPE kernel ENABLED: head_dim={inputs.head_dim}, "
f"n_heads={config['num_qo_heads']}, n_kv={config['num_kv_heads']}, "
f"base={base}"
)
return MlxAOTRoPEKernel(
base=base,
config=config,
rope_pool_fused=rope_pool_fused,
)
MLX_AOT_KERNEL_REGISTRY = MlxAOTKernelRegistry(
specs=(
MlxAOTKernelSpec(
name="metal_rope_pool_fused",
kernel_attr="rope",
is_enabled=lambda: envs.SGLANG_MLX_USE_CUSTOM_ROPE.get(),
build=_build_rope_kernel,
),
)
)
@dataclass
class MlxAOTRoPEContext:
kernel: MlxAOTRoPEKernel
kv_pool: Any
new_token_slots: Optional[mx.array] = None
@dataclass
class MlxAOTKernelContext:
rope: Optional[MlxAOTRoPEContext] = None
@classmethod
def from_decode(
cls,
*,
aot_kernels: MlxAOTKernelSet,
kv_pool: Any | None,
req_ids: list[str],
req_pool_idx: dict[str, int],
req_to_token_pool: Any | None,
layer_caches: list[list[ContiguousAttentionKVCache]],
) -> MlxAOTKernelContext:
"""Build optional AOT context for one batched decode step."""
if not aot_kernels.rope.enabled or kv_pool is None:
return cls()
new_token_slots = None
if req_to_token_pool is not None:
try:
slot_ids = []
for req_idx, req_id in enumerate(req_ids):
pool_idx = req_pool_idx.get(req_id)
if pool_idx is None:
raise KeyError(req_id)
slot = int(
req_to_token_pool.req_to_token[
pool_idx, layer_caches[0][req_idx].offset
].item()
)
slot_ids.append(slot)
new_token_slots = mx.array(slot_ids, dtype=mx.int32)
except Exception as exc: # noqa: BLE001
logger.warning(
"AOT RoPE: failed to resolve new-token slots (%s); "
"falling back to RoPE-only for this decode step",
exc,
)
return cls(
rope=MlxAOTRoPEContext(
kernel=aot_kernels.rope,
kv_pool=kv_pool,
new_token_slots=new_token_slots,
)
)
@@ -0,0 +1,59 @@
"""Cache components for the MLX backend."""
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_head_dim,
get_num_heads,
get_num_kv_heads,
is_attention_module,
uses_sliding_window_attention,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
AttentionOffsetCache,
ContiguousAttentionKVCache,
PoolBackedAttentionKVCache,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
MlxAttentionKVPool,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_wrapper import (
BatchedDecodeContext,
MLXAttentionWrapper,
clear_context,
get_context,
set_context,
)
from sglang.srt.hardware_backend.mlx.kv_cache.auxiliary_state import (
MlxAuxiliaryStateComponent,
MlxAuxiliaryStatePool,
MlxAuxiliaryStateReqToTokenPool,
)
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
from sglang.srt.hardware_backend.mlx.kv_cache.model_patching import (
find_attention_layers,
get_num_layers,
patch_model_attention,
)
__all__ = [
"BatchedDecodeContext",
"clear_context",
"AttentionOffsetCache",
"ContiguousAttentionKVCache",
"find_attention_layers",
"get_head_dim",
"get_context",
"get_num_layers",
"get_num_heads",
"get_num_kv_heads",
"is_attention_module",
"MLXAttentionWrapper",
"MlxAttentionKVPool",
"MlxAuxiliaryStateComponent",
"MlxAuxiliaryStatePool",
"MlxAuxiliaryStateReqToTokenPool",
"MlxModelCacheLayout",
"patch_model_attention",
"PoolBackedAttentionKVCache",
"set_context",
"uses_sliding_window_attention",
]
@@ -0,0 +1,66 @@
"""Attention helpers based on duck typing for the MLX backend."""
from __future__ import annotations
from typing import Any, Iterable
# ``rope`` and ``scale`` are required by MLXAttentionWrapper. Keeping them in
# the contract also prevents recurrent mixers such as DeltaNet from being
# mistaken for softmax attention just because they expose projection layers.
ATTENTION_API_ATTRS = ("q_proj", "k_proj", "v_proj", "o_proj", "rope", "scale")
NUM_HEAD_ATTRS = ("n_heads", "num_heads", "num_attention_heads")
NUM_KV_HEAD_ATTRS = ("n_kv_heads", "num_k_heads", "num_kv_heads", "num_key_value_heads")
SLIDING_ATTENTION_ATTRS = (
"is_sliding",
"use_sliding",
"is_sliding_window",
"use_sliding_window",
"is_swa",
)
def first_present_attr(module: Any, names: Iterable[str]) -> Any | None:
"""Return the first present attribute value without treating 0 as absent."""
for name in names:
if hasattr(module, name):
return getattr(module, name)
return None
def get_num_heads(module: Any) -> int | None:
return first_present_attr(module, NUM_HEAD_ATTRS)
def get_num_kv_heads(module: Any) -> int | None:
return first_present_attr(module, NUM_KV_HEAD_ATTRS)
def get_head_dim(module: Any) -> int | None:
head_dim = first_present_attr(module, ("head_dim",))
if head_dim is not None:
return head_dim
n_kv_heads = get_num_kv_heads(module)
if n_kv_heads is None:
return None
if hasattr(module, "hidden_size") and hasattr(module, "num_k_heads"):
return module.hidden_size // module.num_k_heads
if hasattr(module, "k_proj") and hasattr(module.k_proj, "weight"):
return module.k_proj.weight.shape[0] // n_kv_heads
return None
def is_attention_module(module: Any) -> bool:
return (
all(hasattr(module, attr) for attr in ATTENTION_API_ATTRS)
and get_num_heads(module) is not None
and get_num_kv_heads(module) is not None
)
def uses_sliding_window_attention(*modules: Any) -> bool:
return any(
bool(getattr(module, attr, False))
for module in modules
for attr in SLIDING_ATTENTION_ATTRS
)
@@ -0,0 +1,210 @@
"""Attention KV cache adapters for the MLX backend."""
from __future__ import annotations
from typing import TYPE_CHECKING
import mlx.core as mx
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
MlxAttentionKVPool,
)
class AttentionOffsetCache:
"""Data-free shim satisfying mlx-lm's cache protocol.
Provides ``make_mask`` and ``state`` without storing actual K/V.
"""
def __init__(self, offset: int = 0):
self.offset = offset
@property
def state(self):
return () # Empty — safe for mx.eval unpacking
def make_mask(self, N, **kwargs):
return None if N == 1 else "causal"
def update_and_fetch(self, keys, values):
raise RuntimeError("AttentionOffsetCache should not store data")
_DEFAULT_MAX_SEQ_LEN = 4096
class ContiguousAttentionKVCache:
"""Pre-allocated attention KV buffer for one request and one layer.
Shape ``(1, n_kv_heads, max_seq_len, head_dim)``. Slice assignment
instead of ``mx.concatenate``. Lazy-allocated on first write.
"""
__slots__ = ("keys", "values", "offset", "max_seq_len")
def __init__(
self,
n_kv_heads: int | None = None,
head_dim: int | None = None,
max_seq_len: int = _DEFAULT_MAX_SEQ_LEN,
dtype: mx.Dtype | None = None,
):
if n_kv_heads is not None and head_dim is not None and dtype is not None:
self.keys = mx.zeros((1, n_kv_heads, max_seq_len, head_dim), dtype=dtype)
self.values = mx.zeros((1, n_kv_heads, max_seq_len, head_dim), dtype=dtype)
else:
self.keys = None
self.values = None
self.offset = 0
self.max_seq_len = max_seq_len
def _allocate(self, keys: mx.array) -> None:
"""Allocate buffers matching the first key tensor's shape."""
B, n_kv_heads, _, head_dim = keys.shape
self.keys = mx.zeros(
(B, n_kv_heads, self.max_seq_len, head_dim), dtype=keys.dtype
)
self.values = mx.zeros(
(B, n_kv_heads, self.max_seq_len, head_dim), dtype=keys.dtype
)
@property
def state(self):
"""Arrays for ``mx.eval`` unpacking."""
if self.keys is None:
return ()
return (self.keys, self.values)
def make_mask(self, N, **kwargs):
return None if N == 1 else "causal"
def _grow(self, required: int) -> None:
"""Double the buffer until it can hold *required* tokens."""
new_max = self.max_seq_len
while new_max < required:
new_max *= 2
B, n_kv_heads, _, head_dim = self.keys.shape
new_k = mx.zeros((B, n_kv_heads, new_max, head_dim), dtype=self.keys.dtype)
new_v = mx.zeros((B, n_kv_heads, new_max, head_dim), dtype=self.values.dtype)
if self.offset > 0:
new_k[:, :, : self.offset, :] = self.keys[:, :, : self.offset, :]
new_v[:, :, : self.offset, :] = self.values[:, :, : self.offset, :]
self.keys = new_k
self.values = new_v
self.max_seq_len = new_max
def update_and_fetch(
self, keys: mx.array, values: mx.array
) -> tuple[mx.array, mx.array]:
"""Append K/V and return all valid K/V up to current offset."""
if self.keys is None:
self._allocate(keys)
S = keys.shape[2]
end = self.offset + S
if end > self.max_seq_len:
self._grow(end)
self.keys[:, :, self.offset : end, :] = keys
self.values[:, :, self.offset : end, :] = values
self.offset = end
return self.keys[:, :, :end, :], self.values[:, :, :end, :]
def write_token(self, k: mx.array, v: mx.array) -> None:
"""Write one token. k, v shape: (1, n_kv_heads, 1, head_dim)."""
end = self.offset + 1
if end > self.max_seq_len:
self._grow(end)
self.keys[:, :, self.offset : end, :] = k
self.values[:, :, self.offset : end, :] = v
self.offset = end
def get_kv(self) -> tuple[mx.array, mx.array]:
"""Return valid K/V: (1, n_kv_heads, offset, head_dim)."""
return self.keys[:, :, : self.offset, :], self.values[:, :, : self.offset, :]
class PoolBackedAttentionKVCache:
"""Lazily gathers cached attention KV from the shared pool during forward.
Each ``update_and_fetch`` gathers this layer's prefix from the pool
on demand, keeping operations in the lazy compute graph. Convert to
``ContiguousAttentionKVCache`` via ``to_contiguous`` after the forward pass.
"""
__slots__ = (
"_pool",
"_layer_idx",
"_slots",
"offset",
"_full_keys",
"_full_values",
"_new_keys",
"_new_values",
)
def __init__(
self,
pool: MlxAttentionKVPool,
layer_idx: int,
slots: mx.array,
prefix_len: int,
):
self._pool = pool
self._layer_idx = layer_idx
self._slots = slots
self.offset = prefix_len
self._full_keys: mx.array | None = None
self._full_values: mx.array | None = None
self._new_keys: mx.array | None = None
self._new_values: mx.array | None = None
@property
def keys(self) -> mx.array | None:
return self._full_keys
@property
def values(self) -> mx.array | None:
return self._full_values
@property
def state(self):
if self._full_keys is not None:
return (self._full_keys, self._full_values)
return ()
def make_mask(self, N, **kwargs):
return None if N == 1 else "causal"
def update_and_fetch(
self, keys: mx.array, values: mx.array
) -> tuple[mx.array, mx.array]:
"""Gather cached prefix from pool, concatenate with new K/V."""
S = keys.shape[2]
if self.offset > 0:
k_cached, v_cached = self._pool.get_kv(
self._layer_idx, self._slots[: self.offset]
)
# Pool layout (S, n_kv_heads, head_dim) → cache (1, n_kv_heads, S, head_dim)
k_cached = k_cached.transpose(1, 0, 2)[None]
v_cached = v_cached.transpose(1, 0, 2)[None]
k_all = mx.concatenate([k_cached, keys], axis=2)
v_all = mx.concatenate([v_cached, values], axis=2)
else:
k_all = keys
v_all = values
self.offset += S
self._full_keys = k_all
self._full_values = v_all
self._new_keys = keys
self._new_values = values
return k_all, v_all
def to_contiguous(self, max_seq_len: int = 4096) -> ContiguousAttentionKVCache:
"""Convert to contiguous attention KV reusing forward-pass arrays."""
cache = ContiguousAttentionKVCache(max_seq_len=max_seq_len)
if self._full_keys is not None:
cache.update_and_fetch(self._full_keys, self._full_values)
return cache
@@ -0,0 +1,86 @@
"""Flat attention KV pool for the MLX backend.
Each layer buffer has shape ``(pool_size, n_kv_heads, head_dim)``.
This v1 pool is intentionally uniform: every wrapped softmax-attention
layer must share the same KV shape and full-context KV semantics.
Heterogeneous KV shapes and sliding-window KV need per-layer/window-aware
pools before they can use MLX radix reuse.
Slot 0 is reserved as padding (1-based indexing).
"""
import logging
import mlx.core as mx
logger = logging.getLogger(__name__)
class MlxAttentionKVPool:
"""Pre-allocated attention KV pool indexed by integer slot IDs."""
def __init__(
self,
pool_size: int,
num_layers: int,
n_kv_heads: int,
head_dim: int,
dtype: mx.Dtype = mx.float16,
):
self.pool_size = pool_size
self.num_layers = num_layers
self.n_kv_heads = n_kv_heads
self.head_dim = head_dim
self.dtype = dtype
# Per-attention-layer buffers: (pool_size, n_kv_heads, head_dim)
self.k_buffer: list[mx.array] = [
mx.zeros((pool_size, n_kv_heads, head_dim), dtype=dtype)
for _ in range(num_layers)
]
self.v_buffer: list[mx.array] = [
mx.zeros((pool_size, n_kv_heads, head_dim), dtype=dtype)
for _ in range(num_layers)
]
mem_mb = (pool_size * n_kv_heads * head_dim * 2 * num_layers * dtype.size) / (
1024 * 1024
)
logger.info(
f"MlxAttentionKVPool: {pool_size} slots x {num_layers} layers "
f"x {n_kv_heads} heads x {head_dim} dim, "
f"dtype={dtype}, ~{mem_mb:.1f} MB"
)
def set_kv(self, layer_id: int, slots: mx.array, k: mx.array, v: mx.array) -> None:
"""Scatter K/V into *slots* for one layer."""
self.k_buffer[layer_id][slots] = k
self.v_buffer[layer_id][slots] = v
def get_kv(self, layer_id: int, slots: mx.array) -> tuple[mx.array, mx.array]:
"""Gather K/V from *slots* for one layer."""
return self.k_buffer[layer_id][slots], self.v_buffer[layer_id][slots]
def get_kv_all_layers(self, slots: mx.array) -> tuple[mx.array, mx.array]:
"""Gather K/V from *slots* across all layers."""
k_all = mx.stack([self.k_buffer[i][slots] for i in range(self.num_layers)])
v_all = mx.stack([self.v_buffer[i][slots] for i in range(self.num_layers)])
return k_all, v_all
def set_kv_all_layers(
self, slots: mx.array, k_all: mx.array, v_all: mx.array
) -> None:
"""Scatter K/V into *slots* across all layers."""
for i in range(self.num_layers):
self.set_kv(i, slots, k_all[i], v_all[i])
def all_buffers(self) -> list[mx.array]:
"""Return all buffer arrays (for ``mx.eval``)."""
return self.k_buffer + self.v_buffer
def clear(self) -> None:
"""Zero all buffers."""
shape = (self.pool_size, self.n_kv_heads, self.head_dim)
for i in range(self.num_layers):
self.k_buffer[i] = mx.zeros(shape, dtype=self.dtype)
self.v_buffer[i] = mx.zeros(shape, dtype=self.dtype)
@@ -0,0 +1,298 @@
"""Batched decode attention wrapper for MLX backend."""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from sglang.srt.hardware_backend.mlx.aot import (
MlxAOTKernelContext,
MlxAOTKernelSet,
MlxAOTRoPEContext,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_head_dim,
get_num_heads,
get_num_kv_heads,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
)
_thread_local = threading.local()
# TODO: Move from threading to multiprocessing or asyncio
@dataclass
class BatchedDecodeContext:
"""Context set before batched decode, read by attention wrappers."""
batch_size: int
seq_lens: list[int] # per-request token count before the new token
# attention_layer_caches[attention_pool_idx][req_idx] = ContiguousAttentionKVCache
attention_layer_caches: list[list[ContiguousAttentionKVCache]]
attention_pool_index_by_layer: dict[int, int] = field(default_factory=dict)
# Optional AOT kernel state. Keep kernel-specific fields out of the regular
# MLX decode path so future AOT kernels can be added without growing this
# context one field at a time.
aot: MlxAOTKernelContext = field(default_factory=MlxAOTKernelContext)
# Derived tensors/metadata, shared across all layers in one forward pass.
offsets: mx.array = field(init=False)
max_len: int = field(init=False)
valid_lens: mx.array = field(init=False)
needs_padding: bool = field(init=False)
pad_sizes: list[int] = field(init=False)
positions: Optional[mx.array] = field(init=False)
def __post_init__(self) -> None:
seq_lens = self.seq_lens
max_seq_len = max(seq_lens)
self.offsets = mx.array(seq_lens, dtype=mx.int32)
self.max_len = max_seq_len + 1
self.valid_lens = self.offsets + 1
self.needs_padding = min(seq_lens) < max_seq_len
self.pad_sizes = [max_seq_len - s for s in seq_lens]
self.positions = mx.arange(self.max_len) if self.needs_padding else None
if not self.attention_pool_index_by_layer:
self.attention_pool_index_by_layer = {
idx: idx for idx in range(len(self.attention_layer_caches))
}
@classmethod
def from_decode(
cls,
*,
caches: list[list[Any]],
req_ids: list[str],
aot_kernels: MlxAOTKernelSet,
kv_pool: Any | None,
req_pool_idx: dict[str, int],
req_to_token_pool: Any | None,
attention_layer_indices: list[int] | None = None,
attention_pool_index_by_layer: dict[int, int] | None = None,
) -> BatchedDecodeContext:
batch_size = len(req_ids)
if attention_layer_indices is None:
attention_layer_indices = list(range(len(caches[0])))
seq_lens = [
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
]
attention_layer_caches = [
[caches[i][layer_idx] for i in range(batch_size)]
for layer_idx in attention_layer_indices
]
return cls(
batch_size=batch_size,
seq_lens=seq_lens,
attention_layer_caches=attention_layer_caches,
attention_pool_index_by_layer=attention_pool_index_by_layer or {},
aot=MlxAOTKernelContext.from_decode(
aot_kernels=aot_kernels,
kv_pool=kv_pool,
req_ids=req_ids,
req_pool_idx=req_pool_idx,
req_to_token_pool=req_to_token_pool,
layer_caches=attention_layer_caches,
),
)
def set_context(ctx: Optional[BatchedDecodeContext]) -> None:
_thread_local.batched_ctx = ctx
def get_context() -> Optional[BatchedDecodeContext]:
return getattr(_thread_local, "batched_ctx", None)
def clear_context() -> None:
_thread_local.batched_ctx = None
class MLXAttentionWrapper(nn.Module):
"""Wraps an mlx-lm Attention for batched decode (BS>1).
When ``BatchedDecodeContext`` is set, performs per-request RoPE,
cache writes, and batched SDPA. Otherwise delegates to inner module.
"""
def __init__(self, inner: nn.Module, layer_idx: int):
super().__init__()
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "_layer_idx", layer_idx)
def __call__(self, x: mx.array, mask: Any = None, cache: Any = None) -> mx.array:
ctx = get_context()
if ctx is None:
return self._inner(x, mask=mask, cache=cache)
return self._batched_decode(x, ctx)
def _batched_decode(self, x: mx.array, ctx: BatchedDecodeContext) -> mx.array:
inner = self._inner
layer_idx = self._layer_idx
B = ctx.batch_size
n_heads = get_num_heads(inner)
n_kv_heads = get_num_kv_heads(inner)
if n_heads is None or n_kv_heads is None:
raise RuntimeError(
f"Cannot determine attention head counts for {type(inner).__name__}"
)
q_proj_output = inner.q_proj(x)
keys = inner.k_proj(x)
values = inner.v_proj(x)
head_dim = get_head_dim(inner)
if head_dim is None:
head_dim = keys.shape[-1] // n_kv_heads
q_width = n_heads * head_dim
gate = None
if q_proj_output.shape[-1] == q_width:
queries = q_proj_output.reshape(B, 1, n_heads, head_dim)
elif q_proj_output.shape[-1] == 2 * q_width:
queries, gate = mx.split(
q_proj_output.reshape(B, 1, n_heads, 2 * head_dim), 2, axis=-1
)
gate = gate.reshape(B, 1, q_width)
else:
raise RuntimeError(
f"Unexpected q_proj output shape {q_proj_output.shape} for "
f"{type(inner).__name__}"
)
keys = keys.reshape(B, 1, n_kv_heads, head_dim)
values = values.reshape(B, 1, n_kv_heads, head_dim)
if hasattr(inner, "q_norm"):
queries = inner.q_norm(queries)
if hasattr(inner, "k_norm"):
keys = inner.k_norm(keys)
queries = queries.transpose(0, 2, 1, 3)
keys = keys.transpose(0, 2, 1, 3)
values = values.transpose(0, 2, 1, 3)
# Vectorized RoPE with per-batch offsets (cached on the context).
offsets = ctx.offsets
attention_pool_idx = ctx.attention_pool_index_by_layer[layer_idx]
if ctx.aot.rope is not None:
# AOT path: real .metallib RoPE + fused KV pool scatter.
queries, keys = self._rope_custom_aot(
queries,
keys,
values,
offsets,
attention_pool_idx,
ctx.aot.rope,
)
else:
# Fallback: MLX's built-in mx.fast.rope (used when the AOT kernel
# isn't built or the model uses an unsupported RoPE variant).
queries = inner.rope(queries, offset=offsets)
keys = inner.rope(keys, offset=offsets)
layer_caches = ctx.attention_layer_caches[attention_pool_idx]
pad_sizes = ctx.pad_sizes
# TODO: replace per-request loop with native batched/ragged
# attention once mx.fast.scaled_dot_product_attention supports
# variable-length sequences.
all_k = []
all_v = []
for i in range(B):
layer_caches[i].write_token(keys[i : i + 1], values[i : i + 1])
k_all, v_all = layer_caches[i].get_kv()
pad = pad_sizes[i]
if pad > 0:
k_pad = mx.zeros((1, n_kv_heads, pad, head_dim), dtype=k_all.dtype)
v_pad = mx.zeros((1, n_kv_heads, pad, head_dim), dtype=v_all.dtype)
k_all = mx.concatenate([k_all, k_pad], axis=2)
v_all = mx.concatenate([v_all, v_pad], axis=2)
all_k.append(k_all)
all_v.append(v_all)
keys_b = mx.concatenate(all_k, axis=0)
values_b = mx.concatenate(all_v, axis=0)
attn_mask = None
if ctx.needs_padding:
mask_bool = ctx.positions[None, :] >= ctx.valid_lens[:, None]
attn_mask = mx.where(
mask_bool[:, None, None, :],
mx.array(mx.finfo(queries.dtype).min, dtype=queries.dtype),
mx.array(0.0, dtype=queries.dtype),
)
output = mx.fast.scaled_dot_product_attention(
queries, keys_b, values_b, scale=inner.scale, mask=attn_mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, 1, -1)
if gate is not None:
output = output * mx.sigmoid(gate)
return inner.o_proj(output)
@staticmethod
def _rope_custom_aot(
queries: mx.array,
keys: mx.array,
values: mx.array,
positions: mx.array,
attention_pool_idx: int,
rope_ctx: MlxAOTRoPEContext,
) -> tuple[mx.array, mx.array]:
"""AOT path: rotate Q/K and scatter K/V into the shared pool.
The kernel call does RoPE on Q/K and scatters
rotated K + (untouched) V into ``kv_pool`` at ``new_token_slots``
for ``layer_idx``.
If ``new_token_slots`` is None, slot=-1 sentinel is used (no pool
write, RoPE-only mode). Returns rotated (queries, keys) in the
original 4-D attention layout. ``values`` is unchanged by RoPE.
"""
# (B, n_heads, 1, head_dim) -> (B, n_heads, head_dim) for kernel
q_flat = queries[:, :, 0, :]
k_flat = keys[:, :, 0, :]
v_flat = values[:, :, 0, :]
B = q_flat.shape[0]
if rope_ctx.new_token_slots is None:
slots = mx.full((B,), -1, dtype=mx.int32)
else:
slots = rope_ctx.new_token_slots.astype(mx.int32)
k_pool = rope_ctx.kv_pool.k_buffer[attention_pool_idx]
v_pool = rope_ctx.kv_pool.v_buffer[attention_pool_idx]
q_rot, k_rot, k_pool_new, v_pool_new = rope_ctx.kernel.rope_pool_fused(
q_flat,
k_flat,
v_flat,
positions,
slots,
k_pool,
v_pool,
head_dim=rope_ctx.kernel.config["head_dim"],
num_qo_heads=rope_ctx.kernel.config["num_qo_heads"],
num_kv_heads=rope_ctx.kernel.config["num_kv_heads"],
rope_base=rope_ctx.kernel.base,
)
# Rebind pool buffers (zero-copy donation result).
rope_ctx.kv_pool.k_buffer[attention_pool_idx] = k_pool_new
rope_ctx.kv_pool.v_buffer[attention_pool_idx] = v_pool_new
# (B, n_heads, head_dim) -> (B, n_heads, 1, head_dim) for SDPA path
return q_rot[:, :, None, :], k_rot[:, :, None, :]
@@ -0,0 +1,390 @@
"""MLX auxiliary-state snapshots for unified radix cache.
Hybrid MLX models may include non-softmax-attention layers whose native
``mlx-lm`` cache state cannot be reconstructed from the attention KV pool.
The global scheduler exposes that state through its existing MAMBA component
contract, so this MLX adapter keeps those scheduler-facing field names while
storing model-agnostic native cache snapshots.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import mlx.core as mx
import torch
from sglang.srt.mem_cache.base_prefix_cache import EvictParams, InsertResult
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.mem_cache.unified_cache_components.mamba_component import (
MambaComponent,
)
from sglang.srt.mem_cache.unified_cache_components.tree_component import TreeComponent
_CACHE_ATTRS = ("offset", "lengths", "left_padding")
_MISSING = object()
def _clone_tree(value: Any) -> Any:
if isinstance(value, mx.array):
return mx.array(value)
if isinstance(value, list):
return [_clone_tree(item) for item in value]
if isinstance(value, tuple):
return tuple(_clone_tree(item) for item in value)
if isinstance(value, dict):
return {key: _clone_tree(item) for key, item in value.items()}
return value
def _arrays_in_tree(value: Any) -> list[mx.array]:
arrays: list[mx.array] = []
def collect(item: Any) -> None:
if isinstance(item, mx.array):
arrays.append(item)
elif isinstance(item, (list, tuple)):
for child in item:
collect(child)
elif isinstance(item, dict):
for child in item.values():
collect(child)
collect(value)
return arrays
@dataclass
class _CacheSnapshot:
state: Any
meta_state: Any = _MISSING
attrs: dict[str, Any] | None = None
def _snapshot_cache(cache: Any) -> _CacheSnapshot:
state = _clone_tree(getattr(cache, "state", ()))
meta_state = (
_clone_tree(cache.meta_state) if hasattr(cache, "meta_state") else _MISSING
)
attrs = {
name: _clone_tree(getattr(cache, name))
for name in _CACHE_ATTRS
if hasattr(cache, name)
}
arrays = _arrays_in_tree((state, meta_state, attrs))
if arrays:
mx.eval(*arrays)
return _CacheSnapshot(state=state, meta_state=meta_state, attrs=attrs)
def _restore_cache(cache: Any, snapshot: _CacheSnapshot) -> None:
cache.state = _clone_tree(snapshot.state)
if snapshot.meta_state is not _MISSING and hasattr(cache, "meta_state"):
cache.meta_state = _clone_tree(snapshot.meta_state)
for name, value in (snapshot.attrs or {}).items():
setattr(cache, name, _clone_tree(value))
class MlxAuxiliaryStatePool:
"""Index-addressable snapshots of native MLX auxiliary cache state."""
def __init__(self, size: int, device: str):
self.size = size
self.device = device
self.mamba_cache = None
self.mem_usage = 0
self._snapshots: dict[int, dict[int, _CacheSnapshot]] = {}
self.clear()
def _tensor(self, indices: Any) -> torch.Tensor:
return torch.as_tensor(indices, dtype=torch.int64, device=self.device).view(-1)
def _index(self, index: Any) -> int:
flat = self._tensor(index)
assert flat.numel() == 1
return int(flat.item())
def available_size(self) -> int:
return int(self.free_slots.numel())
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
if need_size > self.available_size():
return None
slots = self.free_slots[:need_size].clone()
self.free_slots = self.free_slots[need_size:]
for slot in slots.tolist():
self._snapshots.pop(int(slot), None)
return slots
def free(self, indices: Any) -> None:
if indices is None:
return
indices = self._tensor(indices)
if indices.numel() == 0:
return
for slot in indices.tolist():
self._snapshots.pop(int(slot), None)
self.free_slots = torch.cat([self.free_slots, indices])
def clear(self) -> None:
self.free_slots = torch.arange(
1, self.size + 1, dtype=torch.int64, device=self.device
)
self._snapshots.clear()
def copy_from(self, src: Any, dst: Any) -> None:
src_indices = self._tensor(src)
dst_indices = self._tensor(dst)
assert src_indices.numel() == dst_indices.numel()
for src_idx, dst_idx in zip(src_indices.tolist(), dst_indices.tolist()):
snapshot = self._snapshots.get(int(src_idx))
if snapshot is None:
self._snapshots.pop(int(dst_idx), None)
else:
self._snapshots[int(dst_idx)] = {
layer_idx: _CacheSnapshot(
state=_clone_tree(cache_snapshot.state),
meta_state=_clone_tree(cache_snapshot.meta_state),
attrs=_clone_tree(cache_snapshot.attrs),
)
for layer_idx, cache_snapshot in snapshot.items()
}
def fork_from(self, src: Any) -> Optional[torch.Tensor]:
src_indices = self._tensor(src)
dst = self.alloc(src_indices.numel())
if dst is None:
return None
self.copy_from(src_indices, dst)
return dst
def store_cache(
self,
index: Any,
cache: list[Any],
layer_indices: Iterable[int],
) -> None:
self._snapshots[self._index(index)] = {
layer_idx: _snapshot_cache(cache[layer_idx]) for layer_idx in layer_indices
}
def restore_cache(
self,
index: Any,
cache: list[Any],
layer_indices: Iterable[int] | None = None,
) -> bool:
snapshot = self._snapshots.get(self._index(index))
if snapshot is None:
return False
selected_layers = set(layer_indices) if layer_indices is not None else None
for layer_idx, cache_snapshot in snapshot.items():
if selected_layers is not None and layer_idx not in selected_layers:
continue
_restore_cache(cache[layer_idx], cache_snapshot)
return True
def has_snapshot(self, index: Any) -> bool:
return self._index(index) in self._snapshots
class MlxAuxiliaryStateReqToTokenPool(ReqToTokenPool):
"""Req-to-token pool with MLX auxiliary-state slot bookkeeping."""
def __init__(
self,
*,
size: int,
max_context_len: int,
device: str,
enable_memory_saver: bool,
auxiliary_state_size: int,
):
super().__init__(
size=size,
max_context_len=max_context_len,
device=device,
enable_memory_saver=enable_memory_saver,
)
self.mamba_pool = MlxAuxiliaryStatePool(
size=auxiliary_state_size,
device=device,
)
# The unified radix base MAMBA component still reads ``mamba_pool``.
# Keep the MLX-owned name beside it so local code can avoid model-
# specific terminology.
self.auxiliary_state_pool = self.mamba_pool
self.enable_mamba_extra_buffer = False
self.req_index_to_auxiliary_state_index_mapping = torch.zeros(
self._alloc_size, dtype=torch.int32, device=device
)
def alloc(self, reqs):
select_index = super().alloc(reqs)
if select_index is None:
return None
auxiliary_state_indices = []
for req in reqs:
if getattr(req, "mamba_pool_idx", None) is not None:
mid = req.mamba_pool_idx
else:
allocated = self.auxiliary_state_pool.alloc(1)
assert allocated is not None, "Not enough MLX auxiliary state slots"
mid = allocated[0]
req.mamba_pool_idx = mid
auxiliary_state_indices.append(mid.to(dtype=torch.int32))
self.req_index_to_auxiliary_state_index_mapping[select_index] = torch.stack(
auxiliary_state_indices
)
return select_index
def get_auxiliary_state_indices(self, req_indices) -> torch.Tensor:
return self.req_index_to_auxiliary_state_index_mapping[req_indices]
def get_mamba_indices(self, req_indices) -> torch.Tensor:
return self.get_auxiliary_state_indices(req_indices)
def get_mamba_ping_pong_other_idx(self, mamba_next_track_idx: int) -> int:
return 0
def free_mamba_cache(self, req, mamba_ping_pong_track_buffer_to_keep=None):
if getattr(req, "mamba_pool_idx", None) is not None:
self.auxiliary_state_pool.free(req.mamba_pool_idx.unsqueeze(0))
req.mamba_pool_idx = None
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
if mamba_ping_pong_track_buffer_to_keep is None:
self.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
def free_auxiliary_state_cache(self, req, track_buffer_to_keep=None):
self.free_mamba_cache(
req,
mamba_ping_pong_track_buffer_to_keep=track_buffer_to_keep,
)
def free(self, req):
super().free(req)
def clear(self):
super().clear()
self.auxiliary_state_pool.clear()
self.req_index_to_auxiliary_state_index_mapping.zero_()
class MlxAuxiliaryStateComponent(MambaComponent):
"""Unified radix component for MLX native auxiliary-state snapshots."""
def __init__(self, cache, params):
if params.enable_mamba_extra_buffer:
raise NotImplementedError(
"MLX auxiliary-state radix cache does not support "
"enable_mamba_extra_buffer yet."
)
pool = getattr(cache.req_to_token_pool, "auxiliary_state_pool", None)
if not isinstance(pool, MlxAuxiliaryStatePool):
raise TypeError(
"MlxAuxiliaryStateComponent requires MlxAuxiliaryStatePool, "
f"got {type(pool)}"
)
TreeComponent.__init__(self, cache, params)
self.enable_mamba_extra_buffer = False
self._mamba_pool_host = None
@staticmethod
def _tracked_value(req) -> tuple[object | None, bool]:
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
track_len = getattr(req, "mamba_last_track_seqlen", None)
if track_buffer is not None and track_len is not None:
return track_buffer[0].unsqueeze(-1).clone(), True
if getattr(req, "mamba_pool_idx", None) is None:
return None, False
return req.mamba_pool_idx.unsqueeze(-1).clone(), False
def prepare_for_caching_req(
self,
req,
insert_params,
token_ids_len: int,
is_finished: bool,
) -> int | None:
cache_len = getattr(req, "mamba_last_track_seqlen", None)
auxiliary_value, uses_track_slot = self._tracked_value(req)
setattr(insert_params, "mlx_auxiliary_state_uses_track_slot", uses_track_slot)
if auxiliary_value is None:
return 0 if is_finished else None
if cache_len is None:
cache_len = token_ids_len
if is_finished:
insert_params.mamba_value = auxiliary_value
else:
source_value = auxiliary_value
forked_value = self.cache.req_to_token_pool.auxiliary_state_pool.fork_from(
source_value
)
if forked_value is None:
self.cache.evict(EvictParams(num_tokens=0, mamba_num=1))
forked_value = (
self.cache.req_to_token_pool.auxiliary_state_pool.fork_from(
source_value
)
)
assert forked_value is not None, "Can not alloc MLX auxiliary cache"
insert_params.mamba_value = forked_value
return cache_len
def cleanup_after_caching_req(
self,
req,
is_finished: bool,
insert_result: InsertResult | None = None,
insert_params=None,
) -> None:
if not is_finished:
if (
insert_params is not None
and insert_params.mamba_value is not None
and (insert_result is None or insert_result.mamba_exist)
):
self.cache.req_to_token_pool.auxiliary_state_pool.free(
insert_params.mamba_value
)
if bool(
getattr(insert_params, "mlx_auxiliary_state_uses_track_slot", False)
):
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
self.cache.req_to_token_pool.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
req.mamba_last_track_seqlen = None
return
auxiliary_value_exists = (
insert_result.mamba_exist if insert_result is not None else True
)
uses_track_slot = bool(
getattr(insert_params, "mlx_auxiliary_state_uses_track_slot", False)
)
if uses_track_slot:
keep_track_slot = not auxiliary_value_exists
self.cache.req_to_token_pool.free_auxiliary_state_cache(
req,
track_buffer_to_keep=0 if keep_track_slot else None,
)
elif auxiliary_value_exists:
self.cache.req_to_token_pool.free_auxiliary_state_cache(req)
else:
# The radix tree now owns the live auxiliary-state slot.
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
self.cache.req_to_token_pool.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
req.mamba_pool_idx = None
req.mamba_last_track_seqlen = None
@@ -0,0 +1,93 @@
"""Model cache layout helpers for the MLX backend."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Sequence
@dataclass(frozen=True)
class MlxModelCacheLayout:
"""Map model layers to MLX cache storage components.
Attention layers store softmax-attention KV in the MLX attention KV pool.
Auxiliary layers keep native ``mlx-lm`` cache state and are snapshotted by
the MLX auxiliary-state component.
"""
layers: tuple[Any, ...]
attention_attrs: tuple[str | None, ...]
attention_layer_indices: tuple[int, ...]
auxiliary_layer_indices: tuple[int, ...]
attention_pool_index_by_layer: dict[int, int]
@classmethod
def from_attention_discovery(
cls,
layers: Sequence[Any],
attention_attrs: Sequence[str | None],
) -> MlxModelCacheLayout:
if len(layers) != len(attention_attrs):
raise ValueError(
"Layer count and attention attribute count differ: "
f"{len(layers)} != {len(attention_attrs)}"
)
attention_layer_indices = tuple(
idx for idx, attr in enumerate(attention_attrs) if attr is not None
)
auxiliary_layer_indices = tuple(
idx for idx, attr in enumerate(attention_attrs) if attr is None
)
attention_pool_index_by_layer = {
layer_idx: pool_idx
for pool_idx, layer_idx in enumerate(attention_layer_indices)
}
return cls(
layers=tuple(layers),
attention_attrs=tuple(attention_attrs),
attention_layer_indices=attention_layer_indices,
auxiliary_layer_indices=auxiliary_layer_indices,
attention_pool_index_by_layer=attention_pool_index_by_layer,
)
@property
def num_layers(self) -> int:
return len(self.layers)
@property
def num_attention_layers(self) -> int:
return len(self.attention_layer_indices)
@property
def has_auxiliary_state(self) -> bool:
return bool(self.auxiliary_layer_indices)
@property
def first_attention_layer_index(self) -> int:
if not self.attention_layer_indices:
raise RuntimeError("MLX model has no supported attention layers")
return self.attention_layer_indices[0]
def attention_pool_index(self, layer_idx: int) -> int:
try:
return self.attention_pool_index_by_layer[layer_idx]
except KeyError as exc:
raise KeyError(f"Layer {layer_idx} is not an attention layer") from exc
def attention_attr(self, layer_idx: int) -> str:
attr = self.attention_attrs[layer_idx]
if attr is None:
raise KeyError(f"Layer {layer_idx} is not an attention layer")
return attr
def attention_layer_caches(
self,
caches_by_request: list[list[Any]],
) -> list[list[Any]]:
"""Return layer-major attention caches for batched decode."""
return [
[request_cache[layer_idx] for request_cache in caches_by_request]
for layer_idx in self.attention_layer_indices
]
@@ -0,0 +1,61 @@
"""Model introspection and attention patching."""
from typing import Any
import mlx.nn as nn
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
is_attention_module,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_wrapper import (
MLXAttentionWrapper,
)
def _find_attention_attr(layer: Any) -> str | None:
"""Return the direct child name that satisfies the attention contract."""
if not isinstance(layer, nn.Module):
raise TypeError(f"Expected mlx.nn.Module layer, got {type(layer)}")
for name, module in layer.children().items():
if isinstance(module, MLXAttentionWrapper) or is_attention_module(module):
return name
return None
def find_attention_layers(model: Any) -> tuple[list[Any], list[str | None]]:
"""Find transformer layers and per-layer attention attribute names."""
root = getattr(model, "language_model", model)
container = getattr(root, "model", root)
layer_list = getattr(container, "layers", None) or getattr(root, "layers", [])
if layer_list:
attn_attrs = [_find_attention_attr(layer) for layer in layer_list]
if any(attr is not None for attr in attn_attrs):
return layer_list, attn_attrs
raise ValueError(f"No attention attribute in layer type {type(layer_list[0])}")
return layer_list, []
def patch_model_attention(model: Any) -> int:
"""Install MLXAttentionWrapper on all attention layers (idempotent).
The wrapper delegates to the inner module when no BatchedDecodeContext
is set, so it is always installed and never removed.
"""
layer_list, attn_attrs = find_attention_layers(model)
patched = 0
for idx, (layer, attn_attr) in enumerate(zip(layer_list, attn_attrs)):
if attn_attr is None:
continue
attn = getattr(layer, attn_attr)
if isinstance(attn, MLXAttentionWrapper):
continue
setattr(layer, attn_attr, MLXAttentionWrapper(attn, idx))
patched += 1
return patched
def get_num_layers(model: Any) -> int:
"""Return the number of transformer layers."""
layer_list, _ = find_attention_layers(model)
return len(layer_list)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
"""Lightweight ModelRunner stub for MLX on Apple Silicon.
Skips PyTorch weight loading. Creates only the CPU-side bookkeeping
(req_to_token_pool, token_to_kv_pool_allocator) the scheduler needs.
"""
import logging
from typing import Tuple
import torch
from sglang.srt.hardware_backend.mlx.kv_cache.auxiliary_state import (
MlxAuxiliaryStateReqToTokenPool,
)
from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator
from sglang.srt.mem_cache.memory_pool import KVCache, ReqToTokenPool
from sglang.srt.model_executor.model_runner import ModelRunner
logger = logging.getLogger(__name__)
class _DummyKVCache(KVCache):
"""Scheduler-facing KV cache that allocates no GPU memory.
Satisfies the KVCache interface so that TokenToKVPoolAllocator can be
constructed, but every buffer access raises. The MLX backend manages
attention KV and auxiliary state internally.
"""
def __init__(self, size: int, dtype: torch.dtype, device: str):
# Bypass KVCache.__init__ to avoid custom_mem_pool / memory_saver
# initialization that may touch CUDA APIs.
self.size = size
self.page_size = 1
self.dtype = dtype
self.store_dtype = dtype
self.device = device
self.layer_num = 0
self.start_layer = 0
self.end_layer = 0
self.mem_usage = 0
self.cpu_offloading_chunk_size = 8192
self.layer_transfer_counter = None
self.enable_custom_mem_pool = False
self.custom_mem_pool = None
def get_key_buffer(self, layer_id: int) -> torch.Tensor:
raise RuntimeError("_DummyKVCache has no key buffer (MLX manages cache)")
def get_value_buffer(self, layer_id: int) -> torch.Tensor:
raise RuntimeError("_DummyKVCache has no value buffer (MLX manages cache)")
def get_kv_buffer(self, layer_id: int) -> Tuple[torch.Tensor, torch.Tensor]:
raise RuntimeError("_DummyKVCache has no kv buffer (MLX manages cache)")
def set_kv_buffer(self, layer, loc, cache_k, cache_v) -> None:
raise RuntimeError("_DummyKVCache cannot set kv buffer (MLX manages cache)")
def get_kv_size_bytes(self):
return 0, 0
class _DummyModel:
"""Minimal stand-in so that `inspect.signature(model.forward)` and
`getattr(model, ...)` calls in ModelRunner.__init__ don't crash."""
@staticmethod
def forward():
pass
class MlxModelRunnerStub(ModelRunner):
"""ModelRunner that skips PyTorch weight loading and KV cache allocation.
Overrides both load_model() and initialize() so that no PyTorch model
weights are loaded and no large KV cache tensors are allocated. Only
the minimal bookkeeping pools needed by the scheduler are created.
"""
# No KV canary on the MLX path. The base ModelRunner installs it via
# install_canary() in its full initialize(), which this lightweight override
# skips. Downstream consumers (scheduler, cuda graph runner, speculative
# workers) all guard with `canary_manager is not None`, so default to None
# as a class attribute to keep those checks working instead of raising
# AttributeError.
canary_manager = None
# No prefill-aware SWA on the MLX path. The base ModelRunner derives this in
# its full initialize() from `model.is_prefill_aware_swa()`, which this
# lightweight override skips (and `_DummyModel` does not implement). The
# scheduler reads `model_runner.prefill_aware_swa` unconditionally when
# admitting a prefill batch, so default to False as a class attribute to keep
# that path working instead of raising AttributeError.
prefill_aware_swa = False
def __init__(self, *args, mlx_pool_size: int | None = None, **kwargs):
self._mlx_pool_size = mlx_pool_size
super().__init__(*args, **kwargs)
def load_model(self):
"""Set only the metadata that downstream code needs, without
loading any PyTorch model weights."""
logger.info(
"MLX stub: skipping PyTorch model weight loading "
"(inference runs through MLX)"
)
self.model = _DummyModel()
self.sliding_window_size = None
if (
self.model_config.is_hybrid_swa
and self.model_config.sliding_window_size is not None
):
self.sliding_window_size = self.model_config.sliding_window_size
elif self.model_config.attention_chunk_size is not None:
self.sliding_window_size = self.model_config.attention_chunk_size
self.dtype = self.model_config.dtype
self.weight_load_mem_usage = 0
def initialize(self):
"""Lightweight initialize that skips heavy PyTorch setup.
Creates minimal req_to_token_pool and token_to_kv_pool_allocator
with a dummy KV cache (zero GPU memory) so the scheduler works.
"""
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
self.memory_saver_adapter = TorchMemorySaverAdapter.create(
enable=self.server_args.enable_memory_saver
)
# Load model (sets metadata only)
self.sampler = None
self.load_model()
# Layer metadata
model_num_layers = max(
self.model_config.num_hidden_layers,
self.model_config.num_attention_layers,
)
self.start_layer = 0
self.end_layer = model_num_layers
self.num_effective_layers = model_num_layers
# KV cache dtype
self.kv_cache_dtype = self.dtype
# Pool sizing — use the MLX runner's auto-sized pool if available,
# otherwise fall back to context_len.
if self._mlx_pool_size is not None:
self.max_total_num_tokens = self._mlx_pool_size
else:
self.max_total_num_tokens = self.model_config.context_len
self.max_running_requests = min(
self.max_total_num_tokens // 2,
4096,
)
self.is_hybrid_swa = False
# Create minimal pools
if self.mambaish_config is not None:
auxiliary_state_size = self.server_args.max_mamba_cache_size
if auxiliary_state_size is None:
auxiliary_state_size = self.max_running_requests * 4
self.req_to_token_pool = MlxAuxiliaryStateReqToTokenPool(
size=self.max_running_requests,
max_context_len=self.model_config.context_len,
device="cpu",
enable_memory_saver=False,
auxiliary_state_size=auxiliary_state_size,
)
else:
self.req_to_token_pool = ReqToTokenPool(
size=self.max_running_requests,
max_context_len=self.model_config.context_len,
device="cpu",
enable_memory_saver=False,
)
dummy_kv = _DummyKVCache(
size=self.max_total_num_tokens,
dtype=self.kv_cache_dtype,
device="cpu",
)
self.token_to_kv_pool = dummy_kv
self.token_to_kv_pool_allocator = TokenToKVPoolAllocator(
size=self.max_total_num_tokens,
dtype=self.kv_cache_dtype,
device="cpu",
kvcache=dummy_kv,
need_sort=False,
)
# No CUDA graphs, no attention backend
self.decode_cuda_graph_runner = None
self.graph_mem_usage = 0
self.attn_backend = None
logger.info(
f"MLX stub: initialized minimal pools "
f"(max_total_num_tokens={self.max_total_num_tokens}, "
f"max_running_requests={self.max_running_requests}, "
f"zero GPU KV cache allocation)"
)
def alloc_memory_pool(self, memory_pool_config=None):
"""No-op: MLX manages its own KV cache."""
pass
@@ -0,0 +1,573 @@
"""Path B fusion for SwitchGLU: gate gather_qmv with silu(gate) * x_up epilogue.
Why this exists
---------------
The existing `FusedSwitchUpGate` (fused_switch_glu.py) concatenates up_proj
and gate_proj weights along the output dim and runs one gather_qmm. That saves
one kernel launch per layer but doubles the matmul's output dim, which pushes
MLX's quantized GEMV into a worse tile/occupancy config. At bs >= 4 on
Qwen3-30B-A3B-4bit this is a net regression (~2% slower at bs=32).
Path B keeps up_proj and gate_proj separate (matmul kernels see their natural
N — no tile regression) and instead fuses the *activation* into the gate
matmul. Concretely:
Baseline (3 kernels per MoE layer in the swiglu front-half):
x_up = gather_qmm(x, W_up)
x_gate = gather_qmm(x, W_gate)
out = silu(x_gate) * x_up # 1 compiled kernel via mlx_lm.swiglu
Path B (2 kernels):
x_up = gather_qmm(x, W_up)
out = fused_gate_qmv_silu_mul(x, W_gate, ..., x_up)
# one custom Metal kernel
This removes one kernel launch per MoE layer with no change in matmul kernel
shapes. The measured end to end decode impact is within run to run noise (bs=1,
K=12 interleaved trials on Qwen3-30B-A3B-4bit: on minus off 0.4%, a quarter of
the noise band), so v1 lands off by default as a correct fusion substrate, not
a measured speedup.
Scope of v1
-----------
Targets the configuration shared by Qwen3-30B-A3B-4bit and Qwen1.5-MoE-A2.7B-4bit:
- bits=4, mode='affine', group_size=64
- K (input_dim) divisible by 512 (Qwen3: 2048, Qwen1.5: 2048)
- N (output_dim) divisible by 8 (Qwen3: 768, Qwen1.5: 1408)
- Scales/biases dtype matches the input dtype (bf16 or fp16)
Anything outside that falls back to the unfused mlx_lm path.
When the fast `gather_qmv` from #22283 lands, the qmv inner loop here can be
replaced by a call into it; the epilogue (silu * x_up) doesn't change.
"""
from __future__ import annotations
import logging
import weakref
import mlx.core as mx
import mlx.nn as nn
logger = logging.getLogger(__name__)
# Constants matching MLX's affine_qmv_fast for bits=4, group_size=64.
# Lifted directly from mlx/include/mlx/backend/metal/kernels/quantized.h
# (qmv_fast_impl), so the inner-loop layout matches MLX's own gather_qmm in
# that regime.
_BITS = 4
_GROUP_SIZE = 64
_SIMD_SIZE = 32
_PACK_FACTOR = 8 # 32 / bits
_BYTES_PER_PACK = 4 # sizeof(uint32_t)
_PACKS_PER_THREAD = 2
_NUM_SIMDGROUPS = 2
_RESULTS_PER_SIMDGROUP = 4
_VALUES_PER_THREAD = _PACK_FACTOR * _PACKS_PER_THREAD # 16
_BLOCK_SIZE = _VALUES_PER_THREAD * _SIMD_SIZE # 512
_ROWS_PER_TG = _NUM_SIMDGROUPS * _RESULTS_PER_SIMDGROUP # 8
# Metal source for the fused kernel.
# Body only — mx.fast.metal_kernel auto-generates the kernel signature
# based on input_names / output_names and the template params below.
_KERNEL_SOURCE = r"""
// Mirrors qmv_fast_impl<T, group_size=64, bits=4> from MLX's quantized.h
// with a silu(result) * x_up write epilogue.
//
// Inputs:
// x [M_tok, K] — pre-gather activations (T)
// w [E, N, K * 4 / 32] — packed 4-bit weights (uint32)
// s [E, N, K / GROUP_SIZE] — affine scales (T)
// b [E, N, K / GROUP_SIZE] — affine biases (T)
// idx [M_tok * TOPK] — expert per (token, topk) pair (uint32)
// x_up [M_tok * TOPK, N] — precomputed up output (T)
// Output:
// y [M_tok * TOPK, N] — silu(gate_qmv(x)) * x_up
constexpr int BITS = 4;
constexpr int GROUP_SIZE = 64;
constexpr int SIMD_SIZE = 32;
constexpr int PACK_FACTOR = 8;
constexpr int BYTES_PER_PACK = 4;
constexpr int PACKS_PER_THREAD = 2;
constexpr int NUM_SIMDGROUPS = 2;
constexpr int RESULTS_PER_SIMDGROUP = 4;
constexpr int VALUES_PER_THREAD = PACK_FACTOR * PACKS_PER_THREAD; // 16
constexpr int BLOCK_SIZE = VALUES_PER_THREAD * SIMD_SIZE; // 512
constexpr int SCALE_STEP_PER_THREAD = GROUP_SIZE / VALUES_PER_THREAD; // 4
// Compile-time problem dims (template params)
constexpr int K = IN_VEC_SIZE;
constexpr int N = OUT_VEC_SIZE;
constexpr int TOPK = TOP_K;
constexpr int in_vec_size_w = K * BYTES_PER_PACK / PACK_FACTOR; // K/2 bytes per row
constexpr int in_vec_size_g = K / GROUP_SIZE; // groups per row
// tid.x = (token, topk) pair index in M_tok*TOPK
// tid.y = output row block index (each block writes 8 rows)
uint mt = threadgroup_position_in_grid.x;
uint out_row_block = threadgroup_position_in_grid.y;
uint simd_gid = simdgroup_index_in_threadgroup;
uint simd_lid = thread_index_in_simdgroup;
uint m = mt / TOPK; // index into pre-gather x
uint e = idx[mt]; // expert id
// Base pointers for this (expert) row block.
// Weights / scales / biases live in [E, N, ...] tensors.
const device uint8_t* ws = (const device uint8_t*)(w
+ uint64_t(e) * uint64_t(N) * uint64_t(in_vec_size_w / BYTES_PER_PACK));
const device T* scales_p = s
+ uint64_t(e) * uint64_t(N) * uint64_t(in_vec_size_g);
const device T* biases_p = b
+ uint64_t(e) * uint64_t(N) * uint64_t(in_vec_size_g);
// out_row indexes the first of RESULTS_PER_SIMDGROUP rows this simdgroup handles.
int out_row = out_row_block * NUM_SIMDGROUPS * RESULTS_PER_SIMDGROUP
+ simd_gid * RESULTS_PER_SIMDGROUP;
ws += out_row * in_vec_size_w + simd_lid * PACKS_PER_THREAD * BYTES_PER_PACK;
scales_p += out_row * in_vec_size_g + simd_lid / SCALE_STEP_PER_THREAD;
biases_p += out_row * in_vec_size_g + simd_lid / SCALE_STEP_PER_THREAD;
// Pre-gather x: shape [M_tok, K]
const device T* x_p = x + uint64_t(m) * uint64_t(K)
+ uint64_t(simd_lid) * uint64_t(VALUES_PER_THREAD);
float result[RESULTS_PER_SIMDGROUP] = {0, 0, 0, 0};
thread float x_thread[VALUES_PER_THREAD];
// Outer loop over K in BLOCK_SIZE-wide chunks.
// K % BLOCK_SIZE == 0 is required (checked in Python wrapper).
for (int k = 0; k < K; k += BLOCK_SIZE) {
// --- load_vector for bits=4 ---
float sum = 0;
for (int i = 0; i < VALUES_PER_THREAD; i += 4) {
float a0 = float(x_p[i]);
float a1 = float(x_p[i + 1]);
float a2 = float(x_p[i + 2]);
float a3 = float(x_p[i + 3]);
sum += a0 + a1 + a2 + a3;
x_thread[i] = a0;
x_thread[i + 1] = a1 / 16.0f;
x_thread[i + 2] = a2 / 256.0f;
x_thread[i + 3] = a3 / 4096.0f;
}
// For each of the 4 output rows this simdgroup is responsible for...
for (int row = 0; row < RESULTS_PER_SIMDGROUP; row++) {
const device uint16_t* ws_u16 =
(const device uint16_t*)(ws + row * in_vec_size_w);
float scale_v = float(scales_p[row * in_vec_size_g]);
float bias_v = float(biases_p[row * in_vec_size_g]);
// --- qdot for bits=4, values_per_thread=16 ---
float accum = 0;
for (int i = 0; i < VALUES_PER_THREAD / 4; i++) {
uint16_t packed = ws_u16[i];
accum += (x_thread[4 * i] * float(packed & 0x000f) +
x_thread[4 * i + 1] * float(packed & 0x00f0) +
x_thread[4 * i + 2] * float(packed & 0x0f00) +
x_thread[4 * i + 3] * float(packed & 0xf000));
}
result[row] += scale_v * accum + sum * bias_v;
}
ws += BLOCK_SIZE * BYTES_PER_PACK / PACK_FACTOR; // += 256 bytes
scales_p += BLOCK_SIZE / GROUP_SIZE; // += 8 groups
biases_p += BLOCK_SIZE / GROUP_SIZE;
x_p += BLOCK_SIZE;
}
// Write epilogue: simd-sum across lanes, then silu(gate) * x_up.
device T* y_p = y + uint64_t(mt) * uint64_t(N) + uint64_t(out_row);
const device T* x_up_p = x_up + uint64_t(mt) * uint64_t(N) + uint64_t(out_row);
for (int row = 0; row < RESULTS_PER_SIMDGROUP; row++) {
float gate_v = simd_sum(result[row]);
if (simd_lid == 0) {
// silu(x) = x * sigmoid(x) = x / (1 + exp(-x))
float silu_v = gate_v / (1.0f + metal::precise::exp(-gate_v));
y_p[row] = T(silu_v * float(x_up_p[row]));
}
}
"""
# Build kernel lazily so import-time on non-MLX systems doesn't fail.
_kernel_cache: dict = {}
# AOT pre-compile cache: tracks which (dtype, K, N, T) tuples have been warmed.
# MLX specializes the Metal kernel on template args at first dispatch, so the
# first call per unique tuple pays a ~3ms compile cost. Pre-warming at patch
# time moves that cost out of the first forward pass.
_aot_warmed: set = set()
def _get_kernel(dtype: mx.Dtype):
"""Return a compiled mx.fast.metal_kernel for the given input dtype.
Kept per-dtype because mx.fast.metal_kernel specializes on template args
at first call, and we want clean separation between fp16 / bf16 variants.
"""
if dtype not in _kernel_cache:
# Strip the `mlx.core.` prefix and any dots from the dtype repr so the
# kernel name is a valid C identifier (Metal's host_name attribute and
# function-name slot don't accept '.').
dtype_tag = str(dtype).replace("mlx.core.", "").replace(".", "_")
_kernel_cache[dtype] = mx.fast.metal_kernel(
name=f"affine_gather_qmv_silu_mul_4bit_gs64_{dtype_tag}",
input_names=["x", "w", "s", "b", "idx", "x_up"],
output_names=["y"],
source=_KERNEL_SOURCE,
)
return _kernel_cache[dtype]
def fused_gate_qmv_silu_mul(
x: mx.array,
gate_w: mx.array,
gate_s: mx.array,
gate_b: mx.array,
indices: mx.array,
x_up: mx.array,
) -> mx.array:
"""Compute ``silu(gather_qmm(x, W_gate)) * x_up`` in one kernel.
Shapes:
x : (..., 1, 1, K) input activations (pre-gather)
gate_w : (E, N, K // PACK_FACTOR) packed 4-bit weights
gate_s : (E, N, K // GROUP_SIZE) affine scales
gate_b : (E, N, K // GROUP_SIZE) affine biases
indices : (..., T) expert per (token, topk) pair
x_up : (..., T, 1, N) pre-computed up output
y : (..., T, 1, N) returned
Numerical contract: equivalent within floating point tolerance to::
x_gate = mx.gather_qmm(x, gate_w, gate_s, gate_b, rhs_indices=indices,
transpose=True, group_size=GROUP_SIZE, bits=BITS,
mode='affine')
y = nn.silu(x_gate) * x_up
up to floating-point ordering of accumulations (which matches MLX's own
qmv_fast_impl exactly).
"""
# Validate the regime this kernel supports. Outside it, the caller should
# fall back to the unfused MLX path.
K = gate_s.shape[-1] * _GROUP_SIZE
N = gate_w.shape[-2]
if K % _BLOCK_SIZE != 0:
raise ValueError(
f"fused_gate_qmv_silu_mul: K={K} not divisible by {_BLOCK_SIZE}. "
f"Use the unfused path."
)
if N % _ROWS_PER_TG != 0:
raise ValueError(
f"fused_gate_qmv_silu_mul: N={N} not divisible by {_ROWS_PER_TG}."
)
# Sanity: scales/biases dtype must match x dtype for in-kernel float() conversion.
if gate_s.dtype != x.dtype or gate_b.dtype != x.dtype:
raise ValueError(
f"fused_gate_qmv_silu_mul: dtype mismatch x={x.dtype} "
f"s={gate_s.dtype} b={gate_b.dtype}"
)
# Shape handling: x always has K as its last axis. M_tok is the number of
# distinct pre-gather tokens (= x.size // K). T is the top_k axis carried
# by `indices`. In the unsorted SwitchGLU path x has shape (B, 1, 1, K) and
# indices is (B, T); in the sorted path x has shape (B*T, 1, K) and
# indices is (B*T, 1) after our reshape. Either way: M_tok * T == idx.size
# and M_tok * K == x.size.
assert x.shape[-1] == K, f"x last dim {x.shape[-1]} != K={K}"
M_tok = x.size // K
T = indices.shape[-1]
assert (
M_tok * T == indices.size
), f"M_tok({M_tok}) * T({T}) != indices.size({indices.size})"
x_flat = x.reshape(M_tok, K)
idx_flat = indices.reshape(M_tok * T)
if idx_flat.dtype != mx.uint32:
idx_flat = idx_flat.astype(mx.uint32)
# x_up has N as its last axis and total size M_tok * T * N. The singleton
# rank dims (1 or 2 of them) get folded away by reshape.
assert (
x_up.shape[-1] == N and x_up.size == M_tok * T * N
), f"x_up shape {x_up.shape} does not match M_tok({M_tok})*T({T})*N({N})"
x_up_flat = x_up.reshape(M_tok * T, N)
kernel = _get_kernel(x.dtype)
(y_flat,) = kernel(
inputs=[x_flat, gate_w, gate_s, gate_b, idx_flat, x_up_flat],
template=[
("T", x.dtype),
("IN_VEC_SIZE", K),
("OUT_VEC_SIZE", N),
("TOP_K", T),
],
# grid is in *threads*, not threadgroups: total threads = product of
# (grid_x, grid_y, grid_z). One threadgroup processes one (mt, row_block).
# Threadgroup is 64 = 2 simdgroups × 32 lanes.
grid=(M_tok * T * 64, N // _ROWS_PER_TG, 1),
threadgroup=(64, 1, 1),
output_shapes=[(M_tok * T, N)],
output_dtypes=[x.dtype],
)
# Reshape to x_up's shape, which is exactly what self.activation(x_up,
# x_gate) would have returned (silu*mul is shape-preserving).
return y_flat.reshape(x_up.shape)
def _aot_warm_kernel(switch_mlp, top_k: int) -> None:
"""Pre-compile the fused kernel for the (dtype, K, N, T) tuples this
layer will dispatch at runtime.
MLX's mx.fast.metal_kernel specializes Metal source on template args
at first dispatch (one Metal compile per unique tuple, ~3ms each).
Issuing one dummy dispatch per shape moves that compile out of the
first forward pass and into model init. The module-level _aot_warmed
set means only the first layer per shape actually compiles; the
remaining 47 hit the cache and no-op.
Warms both the unsorted decode shape (T=top_k) and the sorted
large-batch shape (T=1) used by the gather-sort path.
"""
gate = switch_mlp.gate_proj
K = gate.scales.shape[-1] * _GROUP_SIZE
N = gate.weight.shape[-2]
dtype = gate.scales.dtype
for T in (top_k, 1):
key = (dtype, K, N, T)
if key in _aot_warmed:
continue
M_tok = 1
x_dummy = mx.zeros((M_tok, 1, 1, K), dtype=dtype)
idx_dummy = mx.zeros((M_tok, T), dtype=mx.uint32)
x_up_dummy = mx.zeros((M_tok, T, 1, N), dtype=dtype)
out = fused_gate_qmv_silu_mul(
x_dummy,
gate["weight"],
gate["scales"],
gate.get("biases"),
idx_dummy,
x_up_dummy,
)
mx.eval(out)
_aot_warmed.add(key)
logger.info(
"Path B AOT: warmed fused kernel dtype=%s K=%d N=%d T=%d",
dtype,
K,
N,
T,
)
def can_fuse(switch_mlp) -> bool:
"""Cheap structural check: does this SwitchGLU match the Path B v1 regime?"""
try:
from mlx_lm.models.switch_layers import (
QuantizedSwitchLinear,
SwiGLU,
SwitchGLU,
)
except ImportError:
return False
up = switch_mlp.up_proj
gate = switch_mlp.gate_proj
if not isinstance(up, QuantizedSwitchLinear) or not isinstance(
gate, QuantizedSwitchLinear
):
return False
# A model that overrides SwitchGLU.__call__ runs a custom forward, but the
# patch installs a subclass __call__ that imposes the stock semantics
# fused_forward reimplements, silently bypassing the override. Decline when
# the forward is not the stock SwitchGLU.__call__ (evaluated at patch time,
# before the class swap, so this sees the model's real class).
if type(switch_mlp).__call__ is not SwitchGLU.__call__:
return False
# fused_forward bakes silu into both the kernel and its fallback, and a
# swapped activation= leaves __call__ stock, so the check above cannot see
# it. Exact type, fail closed: a SwiGLU subclass may change the math.
if type(getattr(switch_mlp, "activation", None)) is not SwiGLU:
return False
# Learned per-expert bias, added after the matmul in
# QuantizedSwitchLinear.__call__ as ``x + bias[indices]`` whenever
# ``"bias" in self``. This is the affine learned bias, distinct from the
# quant ``biases`` (zero-points) the kernel already consumes. The fused
# kernel recomputes the gate matmul in-register and has no slot for the
# learned bias, so a gate carrying one would silently drop it. up_proj runs
# its normal path (its bias, if any, is already in x_up), so only the gate
# is at risk: fall back to the unfused path when the gate has a learned bias.
if "bias" in gate:
return False
if up.bits != 4 or up.group_size != 64 or up.mode != "affine":
return False
if gate.bits != 4 or gate.group_size != 64 or gate.mode != "affine":
return False
if up.biases is None or gate.biases is None:
return False
# The kernel reads gate scales/biases as the activation dtype (in-kernel
# float() then writes back as the activation dtype), so the runtime
# precondition in fused_gate_qmv_silu_mul requires a single shared float
# dtype across the quant params. Check it here so a mismatch falls through
# to the unfused path instead of raising at forward. The packed weight is
# uint32 and intentionally excluded from the dtype contract.
param_dtype = gate.scales.dtype
if (
gate.biases.dtype != param_dtype
or up.scales.dtype != param_dtype
or up.biases.dtype != param_dtype
):
return False
# Validate from the gate projection: fused_gate_qmv_silu_mul recomputes the
# gate matmul and keys K/N off gate dims, so the gate is the operand that
# must satisfy the tiling constraints (equivalent to up only while
# up.shape == gate.shape).
K = gate.scales.shape[-1] * _GROUP_SIZE
N = gate.weight.shape[-2]
if K % _BLOCK_SIZE != 0 or N % _ROWS_PER_TG != 0:
return False
return True
_fallback_warned = False
def _fused_gate_or_fallback(gate_proj, x, idx, x_up, sorted_indices=False):
"""silu(gate_qmv(x)) * x_up via the fused kernel; on ValueError fall back to
the unfused gate projection. gather_qmm tolerates the activation dtype the
fused kernel rejects, which can_fuse cannot pre-check at patch time. Warns
once.
"""
# Kernel layout only: the Metal kernel reads T from indices.shape[-1], and
# _gather_sort folded top_k into M_tok, so the sorted path needs an explicit
# T=1 axis. The fallback must not see it: gather_qmm broadcasts an (M_tok, 1)
# index against sorted x's (M_tok,) batch dim into an M_tok x M_tok cross
# product.
gate_idx = idx.reshape(-1, 1) if sorted_indices else idx
gw = gate_proj["weight"]
gs = gate_proj["scales"]
gb = gate_proj.get("biases")
try:
return fused_gate_qmv_silu_mul(x, gw, gs, gb, gate_idx, x_up)
except ValueError as e:
global _fallback_warned
if not _fallback_warned:
logger.warning(
"Path B: fused gate kernel declined inputs (%s); using the "
"unfused gate path for this and matching calls.",
e,
)
_fallback_warned = True
# Reference expression by construction: the same projection call with
# the same flat idx up_proj/down_proj receive.
return nn.silu(gate_proj(x, idx, sorted_indices=sorted_indices)) * x_up
class FusedSwitchSwiGLU(nn.Module):
"""SwitchGLU forward with Path B fusion installed.
Wraps an existing SwitchGLU instance. Reads up_proj / gate_proj weights
directly (no concatenation), runs up_proj as usual, then calls the fused
kernel for ``silu(gate_qmv(x)) * x_up`` in one shot.
Replaces ``switch_mlp.__call__`` via patch_switch_glu_with_fused_swiglu;
SwitchGLU.up_proj / gate_proj / activation are *not* replaced and remain
available for fallback paths (e.g. sorted-indices large-batch case).
"""
def __init__(self, switch_mlp):
super().__init__()
# Weak ref: sw stores the bound fused_forward, so a strong ref here would
# cycle (sw -> method -> self -> sw). sw outlives every call into it.
self._switch_mlp = weakref.proxy(switch_mlp)
def fused_forward(self, x, indices):
"""Same contract as SwitchGLU.__call__ but with fused activation."""
from mlx_lm.models.switch_layers import _gather_sort, _scatter_unsort
sw = self._switch_mlp
x = mx.expand_dims(x, (-2, -3))
do_sort = indices.size >= 64
idx = indices
inv_order = None
if do_sort:
x, idx, inv_order = _gather_sort(x, indices)
x_up = sw.up_proj(x, idx, sorted_indices=do_sort)
swiglu = _fused_gate_or_fallback(
sw.gate_proj, x, idx, x_up, sorted_indices=do_sort
)
out = sw.down_proj(swiglu, idx, sorted_indices=do_sort)
if do_sort:
out = _scatter_unsort(out, inv_order, indices.shape)
return out.squeeze(-2)
def patch_switch_glu_with_fused_swiglu(model) -> int:
"""Install Path B on every eligible SwitchGLU in the model.
Replaces ``switch_mlp.__call__`` with FusedSwitchSwiGLU.fused_forward.
Leaves ``up_proj``, ``gate_proj``, ``activation`` in place so the original
code path is still reachable for sorted-large-batch (handled internally) and
for any callers that bypass the patched __call__.
Returns number of layers patched.
"""
from mlx_lm.models.switch_layers import SwitchGLU
patched = 0
for layer in model.model.layers:
mlp = getattr(layer, "mlp", None)
if mlp is None:
continue
sw = getattr(mlp, "switch_mlp", None)
if not isinstance(sw, SwitchGLU):
continue
if not can_fuse(sw):
continue
# Idempotent: skip if already patched.
if getattr(sw, "_path_b_installed", False):
continue
# AOT: pre-compile the fused kernel for this layer's shapes. top_k
# lives on the parent MoE block (e.g. Qwen3MoeSparseMoeBlock.top_k).
# If absent, skip warming and fall back to lazy JIT on first dispatch.
top_k = getattr(mlp, "top_k", None)
if top_k is not None:
_aot_warm_kernel(sw, int(top_k))
# One-off SwitchGLU subclass rather than rewriting up_proj/gate_proj: the
# activation fusion folds silu(gate)*x_up into the gate matmul, which has
# to intercept the forward (the projection level can't express it).
# can_fuse declines a non-stock __call__, so a customized forward falls
# back unpatched. Python resolves __call__ on the type, so swap
# sw.__class__ to a subclass; cache it on the exact class (cls.__dict__,
# not hasattr which walks the MRO) so a SwitchGLU subclass gets its own
# entry instead of being downcast to the base.
sw._path_b_call = FusedSwitchSwiGLU(sw).fused_forward
cls = type(sw)
if "_PathBSubclass" not in cls.__dict__:
cls._PathBSubclass = type(
f"{cls.__name__}_PathB",
(cls,),
{"__call__": lambda self, *a, **kw: self._path_b_call(*a, **kw)},
)
sw.__class__ = cls._PathBSubclass
sw._path_b_installed = True
patched += 1
if patched == 0:
logger.warning(
"patch_switch_glu_with_fused_swiglu: no eligible SwitchGLU found"
)
else:
logger.info(f"patch_switch_glu_with_fused_swiglu: patched {patched} layers")
return patched
@@ -0,0 +1,464 @@
"""Numerical equivalence and eligibility tests for the Path B fused swiglu kernel.
Two groups:
* Model-based equivalence (``@requires_model``): loads a small MoE model, runs
the fused gate_qmv + silu + ×x_up kernel against the unfused reference
(``mx.gather_qmm`` + ``nn.silu(gate) * x_up``) on both the unsorted and
sorted paths. Gated by SGLANG_MLX_TEST_MODEL so CI hosts without a model
cache skip them.
* Synthetic eligibility (no model, MLX only): the learned-bias fallback. The
fused kernel recomputes the gate matmul and has no slot for the per-expert
learned bias QuantizedSwitchLinear adds after the matmul, so ``can_fuse``
must exclude a gate carrying one, and the patch must leave such a layer
unfused. These run whenever MLX is importable.
"""
import os
import pytest
mx = pytest.importorskip("mlx.core")
# Model-based tests need a real checkpoint; synthetic tests below do not.
requires_model = pytest.mark.skipif(
not os.environ.get("SGLANG_MLX_TEST_MODEL"),
reason="Set SGLANG_MLX_TEST_MODEL to a HuggingFace model id to enable",
)
def _max_rel_diff(a, b):
diff = mx.abs(a.astype(mx.float32) - b.astype(mx.float32))
max_abs = diff.max().item()
ref_max = mx.abs(a.astype(mx.float32)).max().item()
return max_abs, max_abs / max(ref_max, 1e-9)
@requires_model
def test_fused_gate_qmv_silu_mul_matches_unfused():
"""Kernel output matches ``nn.silu(gate_qmv) * x_up`` within bf16 ULP."""
import mlx.nn as nn
from mlx_lm import load
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
can_fuse,
fused_gate_qmv_silu_mul,
)
model, _ = load(os.environ["SGLANG_MLX_TEST_MODEL"])
sw = model.model.layers[0].mlp.switch_mlp
assert can_fuse(sw), "layer 0 not eligible for fused swiglu"
up = sw.up_proj
gate = sw.gate_proj
in_dim = up.scales.shape[-1] * up.group_size
out_dim = up.weight.shape[-2]
num_experts = up.weight.shape[0]
dtype = up.scales.dtype
# Two batch sizes both take the unsorted path (indices.size < 64).
for B, TOPK in [(1, 8), (4, 8)]:
x = mx.random.normal(shape=(B, 1, 1, in_dim)).astype(dtype)
indices = mx.random.randint(0, num_experts, shape=(B, TOPK)).astype(mx.uint32)
x_up = up(x, indices, sorted_indices=False)
x_gate = gate(x, indices, sorted_indices=False)
y_ref = nn.silu(x_gate) * x_up
y_fused = fused_gate_qmv_silu_mul(
x, gate["weight"], gate["scales"], gate.get("biases"), indices, x_up
)
mx.eval(y_ref, y_fused)
assert y_ref.shape == y_fused.shape
max_abs, rel = _max_rel_diff(y_ref, y_fused)
# 2 % relative covers ~2 bf16 ULPs at typical activation magnitudes;
# the kernel's fp32 accumulation order matches MLX's qmv_fast_impl so
# most elements should land within 1 ULP.
assert rel < 2e-2, f"B={B} TOPK={TOPK}: max_abs={max_abs:.3e} rel={rel:.2%}"
@requires_model
def test_patched_switchglu_matches_unpatched():
"""Full SwitchGLU forward equivalence on both sorted and unsorted paths."""
from mlx_lm import load
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
patch_switch_glu_with_fused_swiglu,
)
model, _ = load(os.environ["SGLANG_MLX_TEST_MODEL"])
sw = model.model.layers[0].mlp.switch_mlp
in_dim = sw.up_proj.scales.shape[-1] * sw.up_proj.group_size
num_experts = sw.up_proj.weight.shape[0]
dtype = sw.up_proj.scales.dtype
cases = []
# B=2 TOPK=8 -> indices.size=16 < 64 -> unsorted
# B=8 TOPK=8 -> indices.size=64 -> sorted
for B, TOPK, label in [(2, 8, "unsorted"), (8, 8, "sorted")]:
x = mx.random.normal(shape=(B, in_dim)).astype(dtype)
indices = mx.random.randint(0, num_experts, shape=(B, TOPK)).astype(mx.uint32)
out_ref = sw(x, indices)
mx.eval(out_ref)
cases.append((label, x, indices, out_ref))
n_patched = patch_switch_glu_with_fused_swiglu(model)
assert n_patched > 0, "no SwitchGLU layers were patched"
for label, x, indices, out_ref in cases:
out_fused = sw(x, indices)
mx.eval(out_fused)
max_abs, rel = _max_rel_diff(out_ref, out_fused)
# 5 % is generous; in practice we see <0.6 % on 48-layer Qwen3-MoE.
# The looser bound here absorbs cross-layer ULP propagation through
# down_proj's quantized matmul.
assert rel < 5e-2, f"full forward {label}: max_abs={max_abs:.3e} rel={rel:.2%}"
# Learned-bias fallback (synthetic, no model): a gate with a learned bias must
# not fuse, since the kernel has no slot for the bias added after the matmul.
def _quantized_switch_glu(in_dim, hidden, n_experts, gate_bias):
"""Small quantized SwitchGLU; gate carries a learned bias iff gate_bias.
in_dim=512 keeps K%512==0 and hidden%8==0, inside the Path B v1 regime, so
the bias-free build is genuinely fusion-eligible (the True control).
"""
from mlx_lm.models.switch_layers import SwitchGLU
sw = SwitchGLU(in_dim, hidden, n_experts, bias=False)
sw.up_proj = sw.up_proj.to_quantized(group_size=64, bits=4, mode="affine")
sw.down_proj = sw.down_proj.to_quantized(group_size=64, bits=4, mode="affine")
gate = sw.gate_proj
if gate_bias:
# Learned per-expert bias (E, N), nonzero so dropping it would change
# the result. to_quantized copies it into the QuantizedSwitchLinear.
gate.bias = mx.random.normal((n_experts, hidden)) * 0.1
sw.gate_proj = gate.to_quantized(group_size=64, bits=4, mode="affine")
return sw
def test_can_fuse_excludes_learned_gate_bias():
"""can_fuse: False for a gate with a learned bias, True when bias-free."""
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import can_fuse
sw_free = _quantized_switch_glu(512, 64, 8, gate_bias=False)
sw_bias = _quantized_switch_glu(512, 64, 8, gate_bias=True)
assert "bias" not in sw_free.gate_proj
assert "bias" in sw_bias.gate_proj
assert can_fuse(sw_free) is True, "bias-free gate in regime should fuse"
assert can_fuse(sw_bias) is False, "gate with learned bias must fall back"
def test_patch_falls_back_on_gate_bias():
"""Patching a biased-gate SwitchGLU is a no-op; the forward stays bias-correct."""
import types
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
patch_switch_glu_with_fused_swiglu,
)
in_dim, hidden, n_experts, top_k, B = 512, 64, 8, 4, 2 # 2*4=8 < 64 -> unsorted
sw = _quantized_switch_glu(in_dim, hidden, n_experts, gate_bias=True)
x = mx.random.normal((B, in_dim))
indices = mx.random.randint(0, n_experts, shape=(B, top_k)).astype(mx.uint32)
out_before = sw(x, indices)
mx.eval(out_before)
# Minimal model stand-in: the patch walks model.model.layers[*].mlp.switch_mlp.
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=top_k)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
n_patched = patch_switch_glu_with_fused_swiglu(model)
assert n_patched == 0, "biased gate must not be patched"
out_after = sw(x, indices)
mx.eval(out_after)
d = mx.abs(out_before.astype(mx.float32) - out_after.astype(mx.float32))
diff = d.max().item()
assert diff == 0.0, f"forward changed after (no-op) patch: max|delta|={diff:.3e}"
# Model-free numerical equivalence + non-stock-forward guard: the central
# correctness check, runs without a model download (skips where Metal is absent).
def test_fused_matches_unfused_synthetic():
"""Synthetic quantized gate weights: fused kernel vs the unfused
gather_qmm + silu*x_up path, within the kernel's bf16 bound, plus finiteness."""
mx.random.seed(0)
import mlx.nn as nn
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
fused_gate_qmv_silu_mul,
)
# Gate regime: K%512==0, N%8==0, bits=4, group_size=64, affine.
E, N, K, TOPK = 4, 16, 512, 2
dtype = mx.bfloat16
gate_w = (mx.random.normal((E, N, K)) * 0.02).astype(dtype)
gwq, gs, gb = mx.quantize(gate_w, group_size=64, bits=4)
mx.eval(gwq, gs, gb)
# Two routing patterns: spread (hi=E) and collisions (many tokens, few experts).
for B, hi in [(2, E), (4, max(1, E // 2))]:
x = mx.random.normal((B, 1, 1, K)).astype(dtype)
idx = mx.random.randint(0, hi, shape=(B, TOPK)).astype(mx.uint32)
x_up = mx.random.normal((B, TOPK, 1, N)).astype(dtype)
x_gate = mx.gather_qmm(
x,
gwq,
gs,
gb,
rhs_indices=idx,
transpose=True,
group_size=64,
bits=4,
mode="affine",
)
y_ref = nn.silu(x_gate) * x_up
y_fused = fused_gate_qmv_silu_mul(x, gwq, gs, gb, idx, x_up)
mx.eval(y_ref, y_fused)
assert y_ref.shape == y_fused.shape
# A broken kernel must not leak NaN/Inf into the downstream down_proj matmul.
assert bool(
mx.all(mx.isfinite(y_fused.astype(mx.float32))).item()
), f"B={B} hi={hi}: non-finite fused output"
# Same bf16 bound as the @requires_model kernel test.
max_abs, rel = _max_rel_diff(y_ref, y_fused)
assert rel < 2e-2, f"B={B} hi={hi}: max_abs={max_abs:.3e} rel={rel:.2%}"
def test_can_fuse_declines_nonstock_call():
"""can_fuse: False when SwitchGLU.__call__ is overridden (the fused subclass
would impose stock semantics and silently bypass the override), True for stock."""
from mlx_lm.models.switch_layers import SwitchGLU
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import can_fuse
# hidden=64 keeps down_proj's input dim divisible by the quant group size.
sw_stock = _quantized_switch_glu(512, 64, 4, gate_bias=False)
assert can_fuse(sw_stock) is True, "stock in-regime SwitchGLU should fuse"
class _CustomSwitchGLU(SwitchGLU):
def __call__(self, x, indices): # overridden forward
return super().__call__(x, indices)
sw_custom = _quantized_switch_glu(512, 64, 4, gate_bias=False)
sw_custom.__class__ = _CustomSwitchGLU # same swap mechanism the patch uses
assert can_fuse(sw_custom) is False, "non-stock __call__ must fall back"
def test_can_fuse_declines_non_silu_activation():
"""can_fuse: False for a non SiLU activation (the kernel and the fallback
both bake in silu, which would silently replace the module's formula),
True for the stock SwiGLU control."""
import types
import mlx.nn as nn
from mlx_lm.models.switch_layers import SwitchGLU
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
can_fuse,
patch_switch_glu_with_fused_swiglu,
)
# Same build as _quantized_switch_glu, but the activation kwarg is the
# subject under test, so construct directly.
sw = SwitchGLU(512, 64, 4, activation=nn.gelu, bias=False)
for name in ("up_proj", "gate_proj", "down_proj"):
proj = getattr(sw, name)
setattr(sw, name, proj.to_quantized(group_size=64, bits=4, mode="affine"))
assert can_fuse(sw) is False, "non SiLU activation must fall back"
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=4)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
assert patch_switch_glu_with_fused_swiglu(model) == 0, "gelu module must not patch"
sw_stock = _quantized_switch_glu(512, 64, 4, gate_bias=False)
assert can_fuse(sw_stock) is True, "stock SwiGLU activation should fuse"
def test_fused_forward_falls_back_on_dtype_mismatch():
"""A runtime activation dtype the fused kernel rejects but gather_qmm
tolerates (bf16 gate params, fp16 activations) must fall back, not crash,
and match the unfused forward."""
import types
from mlx_lm.models.switch_layers import SwitchGLU
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
patch_switch_glu_with_fused_swiglu,
)
mx.random.seed(0)
in_dim, hidden, n_experts, top_k, B = 512, 64, 4, 4, 2 # 2*4=8 < 64 -> unsorted
sw = SwitchGLU(in_dim, hidden, n_experts, bias=False)
for name in ("up_proj", "gate_proj", "down_proj"):
lin = getattr(sw, name)
lin.weight = lin.weight.astype(mx.bfloat16) # bf16 weight -> bf16 scales
setattr(sw, name, lin.to_quantized(group_size=64, bits=4, mode="affine"))
assert sw.gate_proj.scales.dtype == mx.bfloat16
# fp16 activations mismatch the bf16 gate params: the fused kernel raises,
# the unfused gather_qmm tolerates it.
x = mx.random.normal((B, in_dim)).astype(mx.float16)
indices = mx.random.randint(0, n_experts, shape=(B, top_k)).astype(mx.uint32)
out_ref = sw(x, indices) # stock forward, unpatched
mx.eval(out_ref)
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=top_k)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
assert patch_switch_glu_with_fused_swiglu(model) == 1, "layer should patch"
out_fb = sw(x, indices) # patched -> kernel raises -> fallback, no crash
mx.eval(out_fb)
max_abs, rel = _max_rel_diff(out_ref, out_fb)
assert rel < 1e-3, f"fallback != unfused: max_abs={max_abs:.3e} rel={rel:.2%}"
# Fallback index contract (synthetic, no model): the fallback must see the
# untouched flat indices. The sorted path's (M_tok, 1) kernel reshape once
# leaked into the fallback and broadcast an M_tok x M_tok cross product
# (PR #26188 review repro).
def _bf16_quantized_switch_glu(in_dim, hidden, n_experts):
"""Quantize from bf16 weights so fp16 activations trip the kernel's runtime
dtype check while the unfused path tolerates them."""
from mlx_lm.models.switch_layers import SwitchGLU
sw = SwitchGLU(in_dim, hidden, n_experts, bias=False)
for name in ("up_proj", "gate_proj", "down_proj"):
lin = getattr(sw, name)
lin.weight = lin.weight.astype(mx.bfloat16)
setattr(sw, name, lin.to_quantized(group_size=64, bits=4, mode="affine"))
return sw
def test_sorted_dtype_mismatch_fallback_matches_reference(monkeypatch):
"""Reviewer repro: B*T == 64 takes the sorted path, the kernel rejects fp16
activations on bf16 params, and the fallback must match the reference in
shape and value."""
import types
import sglang.srt.hardware_backend.mlx.moe.fused_swiglu as fused_swiglu
monkeypatch.setattr(fused_swiglu, "_fallback_warned", False)
mx.random.seed(0)
in_dim, hidden, n_experts, top_k, B = 512, 64, 4, 4, 16 # 16*4 = 64 -> sorted
sw = _bf16_quantized_switch_glu(in_dim, hidden, n_experts)
x = mx.random.normal((B, in_dim)).astype(mx.float16)
indices = mx.random.randint(0, n_experts, shape=(B, top_k)).astype(mx.uint32)
out_ref = sw(x, indices)
mx.eval(out_ref)
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=top_k)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
assert fused_swiglu.patch_switch_glu_with_fused_swiglu(model) == 1
out_fb = sw(x, indices)
mx.eval(out_fb)
assert out_fb.shape == out_ref.shape
max_abs, rel = _max_rel_diff(out_ref, out_fb)
# Post fix the fallback runs the same MLX ops as the stock forward, so the
# bound only absorbs compiled vs eager elementwise ordering (~1 fp16 ULP).
assert bool(
mx.allclose(
out_fb.astype(mx.float32),
out_ref.astype(mx.float32),
rtol=2e-3,
atol=2e-4,
).item()
), f"sorted fallback != reference: max_abs={max_abs:.3e} rel={rel:.2%}"
def test_unsorted_dtype_mismatch_fallback_matches_reference(monkeypatch):
"""Sibling guard: same dtype mismatch on the unsorted path (B*T < 64)."""
import types
import sglang.srt.hardware_backend.mlx.moe.fused_swiglu as fused_swiglu
monkeypatch.setattr(fused_swiglu, "_fallback_warned", False)
mx.random.seed(0)
in_dim, hidden, n_experts, top_k, B = 512, 64, 4, 4, 2 # 2*4 = 8 < 64 -> unsorted
sw = _bf16_quantized_switch_glu(in_dim, hidden, n_experts)
x = mx.random.normal((B, in_dim)).astype(mx.float16)
indices = mx.random.randint(0, n_experts, shape=(B, top_k)).astype(mx.uint32)
out_ref = sw(x, indices)
mx.eval(out_ref)
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=top_k)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
assert fused_swiglu.patch_switch_glu_with_fused_swiglu(model) == 1
out_fb = sw(x, indices)
mx.eval(out_fb)
assert out_fb.shape == out_ref.shape
max_abs, rel = _max_rel_diff(out_ref, out_fb)
assert bool(
mx.allclose(
out_fb.astype(mx.float32),
out_ref.astype(mx.float32),
rtol=2e-3,
atol=2e-4,
).item()
), f"unsorted fallback != reference: max_abs={max_abs:.3e} rel={rel:.2%}"
def test_forced_kernel_rejection_falls_back_correctly(monkeypatch):
"""Any ValueError from the fused kernel, not just a dtype mismatch, must
take the identical fallback: force one via monkeypatch and check both
routing paths against the unpatched module."""
import types
import sglang.srt.hardware_backend.mlx.moe.fused_swiglu as fused_swiglu
monkeypatch.setattr(fused_swiglu, "_fallback_warned", False)
mx.random.seed(0)
in_dim, hidden, n_experts, top_k = 512, 64, 4, 4
sw = _quantized_switch_glu(in_dim, hidden, n_experts, gate_bias=False)
cases = []
# B=2 -> 8 < 64 -> unsorted; B=16 -> 64 -> sorted.
for B, label in [(2, "unsorted"), (16, "sorted")]:
x = mx.random.normal((B, in_dim))
indices = mx.random.randint(0, n_experts, shape=(B, top_k)).astype(mx.uint32)
out_ref = sw(x, indices)
mx.eval(out_ref)
cases.append((label, x, indices, out_ref))
mlp = types.SimpleNamespace(switch_mlp=sw, top_k=top_k)
layer = types.SimpleNamespace(mlp=mlp)
model = types.SimpleNamespace(model=types.SimpleNamespace(layers=[layer]))
# Patch before installing the raiser: _aot_warm_kernel dispatches the real
# kernel at patch time and does not catch ValueError.
assert fused_swiglu.patch_switch_glu_with_fused_swiglu(model) == 1
def raiser(*args, **kwargs):
raise ValueError("forced rejection")
monkeypatch.setattr(fused_swiglu, "fused_gate_qmv_silu_mul", raiser)
for label, x, indices, out_ref in cases:
out_fb = sw(x, indices)
mx.eval(out_fb)
assert out_fb.shape == out_ref.shape, label
max_abs, rel = _max_rel_diff(out_ref, out_fb)
assert bool(
mx.allclose(
out_fb.astype(mx.float32),
out_ref.astype(mx.float32),
rtol=1e-5,
atol=1e-6,
).item()
), f"forced rejection {label}: max_abs={max_abs:.3e} rel={rel:.2%}"
@@ -0,0 +1,60 @@
"""Parent-death watchdog for MLX workers on Apple Silicon.
macOS has no ``PR_SET_PDEATHSIG`` equivalent, so the kernel will not signal a
worker process when its parent dies; the worker would be reparented to PID 1
and leak (holding GPU/host memory and ports). This module emulates PDEATHSIG
with a daemon thread that watches the parent PID via kqueue and SIGKILLs the
current process once it gets orphaned.
"""
import os
import select
import signal
import threading
def start_parent_death_watcher() -> None:
"""SIGKILL this process once its current parent exits (macOS only).
kqueue with an ``EVFILT_PROC`` / ``NOTE_EXIT`` filter is the native,
event-driven mechanism on macOS (exposed via ``select.kqueue`` /
``select.kevent``), so the watcher thread blocks until the parent actually
exits instead of waking up to poll.
``SIGKILL`` is sent from this watcher thread and is uncatchable /
unblockable, so it works even when the main thread is stuck inside a
blocking native call (e.g. an MLX/Metal ``mx.eval`` / ``.tolist()``).
"""
original_ppid = os.getppid()
def _watch_parent():
kq = select.kqueue()
kev = select.kevent(
original_ppid,
filter=select.KQ_FILTER_PROC,
flags=select.KQ_EV_ADD,
fflags=select.KQ_NOTE_EXIT,
)
try:
# Register the EVFILT_PROC / NOTE_EXIT watch on the parent PID.
kq.control([kev], 0, None)
except (ProcessLookupError, OSError):
# The parent already exited before we could register the watch
# (ESRCH); we are already orphaned.
os.kill(os.getpid(), signal.SIGKILL)
return
# Guard against the race where the parent exits between reading
# original_ppid and registering the watch above.
if os.getppid() != original_ppid:
os.kill(os.getpid(), signal.SIGKILL)
return
# Block until the parent exits, then terminate ourselves.
kq.control(None, 1, None)
os.kill(os.getpid(), signal.SIGKILL)
watcher = threading.Thread(
target=_watch_parent,
name="parent-death-watcher",
daemon=True,
)
watcher.start()
@@ -0,0 +1,261 @@
from __future__ import annotations
import gzip
import json
import logging
import os
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
import torch
from sglang.srt.managers.io_struct import ProfileReqOutput
from sglang.srt.utils.tensor_bridge import use_mlx
logger = logging.getLogger(__name__)
@dataclass
class MetalCaptureProfiler:
label: str
trace_path: Path
stop_capture: Callable[[], None]
standalone: bool
@classmethod
def start_mlx(cls, trace_path: Path):
trace_path.parent.mkdir(parents=True, exist_ok=True)
try:
import mlx.core as mx
mx.metal.start_capture(str(trace_path))
except RuntimeError as e:
return None, _capture_error("MLX", e)
return cls._started(
label="MLX",
trace_path=trace_path,
stop_capture=mx.metal.stop_capture,
standalone=True,
)
@classmethod
def start_mps(cls, trace_path: Path):
trace_path.parent.mkdir(parents=True, exist_ok=True)
try:
if not hasattr(torch, "mps") or not hasattr(torch.mps, "profiler"):
raise RuntimeError("torch.mps.profiler is not available")
context = torch.mps.profiler.metal_capture(str(trace_path))
context.__enter__()
except RuntimeError as e:
return None, _capture_error("MPS", e)
return cls._started(
label="MPS",
trace_path=trace_path,
stop_capture=lambda: context.__exit__(None, None, None),
standalone=False,
)
@classmethod
def _started(
cls,
*,
label: str,
trace_path: Path,
stop_capture: Callable[[], None],
standalone: bool,
):
profiler = cls(
label=label,
trace_path=trace_path,
stop_capture=stop_capture,
standalone=standalone,
)
logger.info("%s Metal capture started, saving to %s", label, trace_path)
return profiler, ProfileReqOutput(success=True, message="Succeeded")
def stop(self) -> str:
self.stop_capture()
logger.info(
"%s Metal capture stopped. Trace saved to: %s",
self.label,
self.trace_path,
)
return f" Metal trace: {self.trace_path}"
def _capture_error(label: str, error: RuntimeError) -> ProfileReqOutput:
return ProfileReqOutput(
success=False,
message=(
f"Failed to start {label} Metal capture: {error}. "
"Set MTL_CAPTURE_ENABLED=1 in the server's environment "
"before launching to enable GPU trace capture."
),
)
class MetalTorchProfiler:
def __init__(
self,
*,
start_metal_capture: Callable[[Path], tuple[Any, ProfileReqOutput]],
torch_profiler: Optional[Any] = None,
):
self.start_metal_capture = start_metal_capture
self.torch_profiler = torch_profiler
self.metal_profiler = None
def start(self):
trace_path = _new_temp_gputrace_path()
self.metal_profiler, result = self.start_metal_capture(trace_path)
if not result.success:
raise RuntimeError(result.message)
if self.torch_profiler is not None:
try:
self.torch_profiler.start()
except Exception:
self.metal_profiler.stop()
raise
def stop(self):
try:
if self.torch_profiler is not None:
self.torch_profiler.stop()
finally:
if self.metal_profiler is not None:
self.metal_profiler.stop()
def export_chrome_trace(self, path: str):
if self.torch_profiler is not None:
self.torch_profiler.export_chrome_trace(path)
else:
_write_empty_chrome_trace(path)
if self.metal_profiler is None:
return
final_path = _unique_gputrace_path_for_chrome_trace(path)
final_path.parent.mkdir(parents=True, exist_ok=True)
if self.metal_profiler.trace_path.exists():
shutil.move(str(self.metal_profiler.trace_path), str(final_path))
logger.info("Metal trace saved to: %s", final_path)
def apply_metal_profiler_patches() -> None:
if getattr(torch.profiler.profile, "_sglang_metal_patched", False):
return
original_profile = torch.profiler.profile
def profile(*args, **kwargs):
activities = _get_activities(args, kwargs)
if not _has_cuda_activity(activities):
return original_profile(*args, **kwargs)
if use_mlx():
return MetalTorchProfiler(
start_metal_capture=MetalCaptureProfiler.start_mlx
)
torch_activities = [
activity for activity in activities if not _is_cuda_activity(activity)
]
torch_profiler = None
if torch_activities:
patched_args, patched_kwargs = _replace_activities(
args, kwargs, torch_activities
)
torch_profiler = original_profile(*patched_args, **patched_kwargs)
return MetalTorchProfiler(
start_metal_capture=MetalCaptureProfiler.start_mps,
torch_profiler=torch_profiler,
)
profile._sglang_metal_patched = True
profile._sglang_original_profile = original_profile
torch.profiler.profile = profile
def _get_activities(args, kwargs):
if "activities" in kwargs:
return kwargs["activities"]
if args:
return args[0]
return None
def _replace_activities(args, kwargs, activities):
kwargs = dict(kwargs)
if "activities" in kwargs:
kwargs["activities"] = activities
return args, kwargs
if args:
args = list(args)
args[0] = activities
return tuple(args), kwargs
kwargs["activities"] = activities
return args, kwargs
def _has_cuda_activity(activities) -> bool:
if activities is None:
return False
return any(_is_cuda_activity(activity) for activity in activities)
def _is_cuda_activity(activity) -> bool:
return activity == torch.profiler.ProfilerActivity.CUDA
def _new_temp_gputrace_path() -> Path:
output_dir = Path(os.getenv("SGLANG_TORCH_PROFILER_DIR", "/tmp")).expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
for i in range(100):
candidate = (
output_dir / f"sglang-metal-{os.getpid()}-{time.time_ns()}-{i}.gputrace"
)
if not candidate.exists():
return candidate
raise RuntimeError(f"Cannot find an unused Metal trace path in {output_dir}")
def _unique_gputrace_path_for_chrome_trace(path: str) -> Path:
chrome_path = Path(path).expanduser()
name = chrome_path.name
if name.endswith(".trace.json.gz"):
name = name[: -len(".trace.json.gz")] + ".gputrace"
else:
name = chrome_path.stem + ".gputrace"
base = chrome_path.with_name(name)
if not base.exists():
return base
stem = base.name[: -len(".gputrace")]
for i in range(100):
candidate = base.with_name(f"{stem}-{time.time_ns()}-{i}.gputrace")
if not candidate.exists():
return candidate
raise RuntimeError(f"Cannot find an unused Metal trace path for {base}")
def _write_empty_chrome_trace(path: str):
trace = {"traceEvents": []}
Path(path).expanduser().parent.mkdir(parents=True, exist_ok=True)
if str(path).endswith(".gz"):
with gzip.open(path, "wt") as f:
json.dump(trace, f)
else:
with open(path, "w") as f:
json.dump(trace, f)
@@ -0,0 +1,263 @@
"""MLX overlap scheduling mixin for the SGLang scheduler.
Provides ``event_loop_overlap_mlx``, which pipelines MLX forward
passes by keeping two in-flight lazy graphs queued on the GPU while
the scheduler runs its CPU-side bookkeeping on the tokens of the
older one. The lazy-graph primitives live in
``hardware_backend/mlx/tp_worker.py`` and ``model_runner.py``.
Each request's attention KV lives in per-request, per-layer
``ContiguousAttentionKVCache`` objects that ``MLXAttentionWrapper`` mutates
in place during the forward pass. Chained decodes reuse the same cache objects:
step N+1's graph reads step N's lazy writes via MLX's dependency tracking, so
the GPU runs both steps back-to-back with no idle gap.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
import mlx.core as mx
from sglang.srt.environ import envs
from sglang.srt.managers.overlap_utils import resolve_forward_inputs
from sglang.srt.utils import DynamicGradMode
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.model_runner import (
MlxPendingDecode,
MlxPendingExtend,
MlxPendingPrefill,
)
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
from sglang.srt.managers.scheduler import Scheduler
@dataclass
class MlxPendingJob:
"""Unfinished MLX work and graphs queued on the GPU.
Attributes:
lazy_tokens: Lazily evaluated token IDs produced by the forward
pass. Unevaluated; calling ``.tolist()`` / ``.item()`` /
``mx.eval`` on it will block until the Metal kernel finishes.
``None`` for idle batches.
prefills: MLX prefill state returned by the model worker — one
entry per new request in an extend batch. Used by
``finalize_mlx_result`` to commit per-request caches. Empty
list for pure-decode steps.
extends: Chunked-prefill-continuation state, one entry per
already-active request whose extend seq_len > 1. Also empty
for pure-decode steps.
decode: Decode state covering full-decode mode AND mixed
single-token decodes inside an extend batch. Used as the
chaining root by :meth:`async_chained_decode_mlx`.
mode: One of ``"decode"``, ``"extend"``, ``"idle"`` describing
which forward pass produced this job. Drives finalise
dispatch and whether chaining is safe.
batch_copy: Snapshot of the :class:`ScheduleBatch` at launch
time. Decoupled from the live batch so
``process_batch_result`` can update request state without
racing against the next scheduling decision.
schedule_batch: The full scheduler batch. Unlike ``batch_copy``,
this keeps allocator/cache fields needed when a prefill batch
becomes the next running decode batch.
reqs: Snapshot of ``batch.reqs`` at launch time. The overlap
loop uses this to check ``req.finished()`` on the previous
step's request list without holding a reference to the
mutable batch object.
"""
lazy_tokens: Optional[mx.array]
prefills: list[MlxPendingPrefill]
extends: list[MlxPendingExtend]
decode: Optional[MlxPendingDecode]
mode: str
batch_copy: ScheduleBatch
schedule_batch: ScheduleBatch
reqs: List[Req]
class SchedulerMlxOverlapMixin:
"""Mixin that adds MLX overlap scheduling to :class:`Scheduler`."""
def _finalize_mlx_pending_job(self: Scheduler, pending: MlxPendingJob):
# Account for this completed forward step. The standard scheduler does
# this inside run_batch(), but the MLX overlap loop bypasses run_batch,
# so without this forward_ct never advances on MLX. That stalls the
# watchdog liveness counter and, more importantly, breaks step-bounded
# profiling: _profile_batch_predicate auto-starts/stops based on
# forward_ct, so `--profile-steps` (and the server /start_profile
# num_steps path) only takes effect once the counter moves here.
self.forward_ct += 1
self.profiler_manager._profile_batch_predicate(pending.schedule_batch)
result = self.tp_worker.finalize_mlx_result(
pending.prefills,
pending.extends,
pending.decode,
pending.mode,
pending.reqs,
)
if result.next_token_ids is not None:
pending.batch_copy.input_ids = result.next_token_ids
pending.schedule_batch.input_ids = result.next_token_ids
self.last_batch = pending.schedule_batch
self.process_batch_result(pending.batch_copy, result)
@DynamicGradMode()
def event_loop_overlap_mlx(self: Scheduler):
"""MLX-specific overlap loop modelled on ``mlx_lm.generate.generate_step``.
At steady state we keep TWO in-flight MLX graphs queued on the
GPU:
* ``pending_curr`` — the step whose tokens we are about to block
on and feed into the scheduler's bookkeeping.
* ``pending_next`` — the step that was built on top of
``pending_curr``'s still-lazy output tokens via
``async_chained_decode_mlx`` and has already been handed to
``mx.async_eval``. Because MLX tracks the full dependency
graph, the GPU will execute ``pending_next`` back-to-back
with ``pending_curr`` — there is no scheduling gap on the
device.
Bookkeeping timeline for a steady-state decode loop:
iter k:
build pending_next (CPU graph build + mx.async_eval; cheap)
block on pending_curr via .tolist() (wait only on curr's tokens)
process_batch_result(pending_curr) <-- GPU is running pending_next
pending_curr = pending_next
The chain is broken (we fall back to a "schedule + launch" step)
whenever any of the following holds:
* ``pending_curr`` is not a pure decode (e.g. prefill/extend).
* The waiting queue has new requests that need prefill.
* Any req in ``pending_curr`` just finished this iteration, so
the composition for ``pending_next`` would need to shrink.
When the chain breaks mid-flight we still finalise the
already-launched ``pending_next`` normally (its tokens are
valid for all surviving reqs). With RadixCache-backed caches
(#21509) there is no ``extract_cache`` step: per-request caches
are the source of truth and are never merged into a shared
batched buffer.
"""
pending_curr: Optional[MlxPendingJob] = None
pending_next: Optional[MlxPendingJob] = None
def _launch_fresh(batch: ScheduleBatch) -> MlxPendingJob:
# Materialize batch.input_ids from CPU staging (prefill) or the
# FutureMap relay (decode) before the forward. With deferred input
# materialization, get_next_batch_to_run leaves input_ids unset; the
# CUDA paths call resolve_forward_inputs for this, but the MLX overlap
# loop must do it too, otherwise async_forward_batch_generation_mlx
# dereferences a None input_ids.
resolve_forward_inputs(batch, self.future_map)
lazy_tokens, prefills, extends, decode, mode = (
self.tp_worker.async_forward_batch_generation_mlx(batch)
)
return MlxPendingJob(
lazy_tokens=lazy_tokens,
prefills=prefills,
extends=extends,
decode=decode,
mode=mode,
batch_copy=batch.copy(),
schedule_batch=batch,
reqs=list(batch.reqs),
)
def _launch_chained(prev: MlxPendingJob) -> MlxPendingJob:
assert prev.decode is not None
lazy_tokens, prefills, extends, decode, mode = (
self.tp_worker.async_chained_decode_mlx(prev.decode)
)
# Composition is identical to prev: reuse a fresh batch copy
# of the same underlying ScheduleBatch so process_batch_result
# updates the same req objects with the new token.
return MlxPendingJob(
lazy_tokens=lazy_tokens,
prefills=prefills,
extends=extends,
decode=decode,
mode=mode,
batch_copy=prev.batch_copy.copy(),
schedule_batch=prev.schedule_batch,
reqs=prev.reqs,
)
while True:
recv_reqs = self.request_receiver.recv_requests()
self.process_input_requests(recv_reqs)
if self._engine_paused:
continue
# 1. If pending_curr is a pure decode AND no new prefill is waiting,
# build pending_next on top of it NOW — before we block on curr.
can_chain = (
pending_curr is not None
and pending_curr.mode == "decode"
and pending_curr.decode is not None
and not self.waiting_queue
)
if can_chain and pending_next is None:
# Build + launch the chained step BEFORE we block on
# pending_curr — this is the "no idle gap" trick.
# GPU now has 2 steps queued.
pending_next = _launch_chained(pending_curr)
self.result_queue.append(pending_next)
# 2. Finalize/process on pending_curr's tokens. (GPU is already
# executing pending_next at this point.)
if pending_curr is not None:
self._finalize_mlx_pending_job(pending_curr)
self.result_queue.popleft()
pending_curr = None
# 3. Decide whether pending_next is still valid (if no reqs finished)
# and promote it.
finished_any = any(
req.finished() for req in (pending_next.reqs if pending_next else [])
)
new_prefill_waiting = bool(self.waiting_queue)
if (
pending_next is not None
and not finished_any
and not new_prefill_waiting
):
pending_curr = pending_next
pending_next = None
self.cur_batch_for_debug = pending_curr.schedule_batch
self.last_batch = pending_curr.schedule_batch
if envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get():
self.invariant_checker.self_check_during_busy()
continue
# 4. Chain is broken. Finalise pending_next (if any), then
# schedule fresh.
if pending_next is not None:
self._finalize_mlx_pending_job(pending_next)
self.result_queue.popleft()
pending_next = None
plan = self.get_next_batch_to_run(
running_batch=self.running_batch, last_batch=self.last_batch
)
self.running_batch = plan.running_batch
next_batch = plan.batch_to_run
self.cur_batch_for_debug = next_batch
if next_batch:
pending_curr = _launch_fresh(next_batch)
self.result_queue.append(pending_curr)
else:
self.on_idle()
self.last_batch = next_batch
if envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get():
self.invariant_checker.self_check_during_busy()
@@ -0,0 +1,528 @@
"""MLX-specific TpModelWorker subclass for Apple Silicon.
Routes forward passes through the MLX model runner, bypassing PyTorch
MPS. A lightweight stub provides scheduler bookkeeping; the actual
attention KV data lives in MlxAttentionKVPool.
The worker also exposes an async (lazy-eval) surface used by the MLX
overlap scheduler: ``async_forward_batch_generation_mlx`` launches a
batch without blocking on the GPU, ``async_chained_decode_mlx`` builds
the next decode step on top of a still-lazy previous decode, and
``finalize_mlx_result`` blocks on the lazy outputs and produces a
normal ``GenerationBatchResult``.
"""
import logging
from typing import Optional, Union
import mlx.core as mx
import torch
from sglang.srt.hardware_backend.mlx.model_runner import (
MlxPendingDecode,
MlxPendingExtend,
MlxPendingPrefill,
)
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.managers.tp_worker import TpModelWorker
from sglang.srt.managers.utils import GenerationBatchResult
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors
logger = logging.getLogger(__name__)
class MlxTpModelWorker(TpModelWorker):
"""A tensor parallel model worker that routes inference through MLX.
Inherits from TpModelWorker for scheduler integration, but replaces
the standard ModelRunner with MlxModelRunnerStub (no PyTorch weights,
zero-memory KV cache) and delegates all forward passes to a native
MlxModelRunner.
"""
def _init_model_runner(self):
"""Create MLX runner first (auto-sizes pool), then stub with matching size."""
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
from sglang.srt.hardware_backend.mlx.model_runner_stub import (
MlxModelRunnerStub,
)
logger.info("Initializing MlxModelRunner for end-to-end MLX inference")
init_kwargs = dict(
model_path=self.server_args.model_path,
trust_remote_code=self.server_args.trust_remote_code,
disable_radix_cache=self.server_args.disable_radix_cache,
mem_fraction_static=self.server_args.mem_fraction_static,
quantization=self.server_args.quantization,
)
if self.server_args.max_total_tokens is not None:
init_kwargs["pool_size"] = self.server_args.max_total_tokens
self._mlx_runner = MlxModelRunner(**init_kwargs)
self._model_runner = MlxModelRunnerStub(
model_config=self.model_config,
mem_fraction_static=self.server_args.mem_fraction_static,
gpu_id=self.gpu_id,
tp_rank=self.tp_rank,
tp_size=self.tp_size,
moe_ep_rank=self.moe_ep_rank,
moe_ep_size=self.ep_size,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
nccl_port=self.nccl_port,
dp_rank=self.dp_rank,
server_args=self.server_args,
is_draft_worker=self.is_draft_worker,
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
memory_pool_config=self.memory_pool_config,
mlx_pool_size=self._mlx_runner.pool_size,
)
self._mlx_active_rids: set[str] = set()
self._mlx_pool_initialized = False
def get_pad_input_ids_func(self):
"""Override since the stub ModelRunner has no real model."""
return None
def _ensure_mlx_pool_initialized(self):
"""Lazily initialize MLX cache pools after the stub pools are ready."""
if not self._mlx_pool_initialized:
self._mlx_runner.init_cache_pools(self._model_runner.req_to_token_pool)
self._mlx_pool_initialized = True
def forward_batch_generation(
self,
batch: Optional[ScheduleBatch],
forward_batch: Optional[ForwardBatch] = None,
pp_proxy_tensors: Optional[PPProxyTensors] = None,
is_verify: bool = False,
skip_attn_backend_init: Optional[bool] = None, # deprecated
) -> GenerationBatchResult:
"""Override to route through MLX model runner."""
if batch is not None:
self._ensure_mlx_pool_initialized()
return self._forward_batch_generation_mlx(batch)
# Fallback to standard path for None batches
return super().forward_batch_generation(
batch,
forward_batch,
pp_proxy_tensors,
is_verify,
skip_attn_backend_init,
)
def _cleanup_stale_rids(self, forward_mode, current_rids: set[str]) -> None:
"""Remove MLX state for decode-mode requests that dropped out of the batch."""
if forward_mode.is_decode():
stale_rids = self._mlx_active_rids - current_rids
for rid in stale_rids:
self._mlx_runner.remove_request(rid)
self._mlx_active_rids = current_rids
else:
self._mlx_active_rids |= current_rids
def prepare_for_kv_cache_release(self, req) -> None:
"""Snapshot MLX auxiliary state at the scheduler's radix insert point."""
if self._mlx_runner.has_request(req.rid):
self._mlx_runner.store_auxiliary_state_for_request(req.rid)
# Prefer the just-snapshotted live auxiliary state for the final
# insert. Any older tracked slot is released during component cleanup.
req.mamba_last_track_seqlen = None
def _route_extend_request(self, rid: str, decoding_rids: set[str]) -> str:
"""Classify a request within an extend / mixed batch.
Shared by the sync (:meth:`_forward_batch_generation_mlx`) and async
(:meth:`_async_extend_batch`) paths so both route identically.
Returns one of:
* ``"prefill"`` -- not seen before; start a fresh prefill.
* ``"decode"`` -- a genuine single-token decode step mixed into
this batch (present in ``batch.decoding_reqs``).
* ``"continuation"`` -- a chunked-prefill continuation. Routing keys on
request state, **not** ``seq_len``: a final continuation chunk can be
exactly one token, which must still extend. Routing it as a decode
would drop the real token and feed the model its own previous-chunk
prediction, silently corrupting the output.
"""
if not self._mlx_runner.has_request(rid):
return "prefill"
if rid in decoding_rids:
return "decode"
return "continuation"
def _forward_batch_generation_mlx(
self, batch: ScheduleBatch
) -> GenerationBatchResult:
"""Run forward pass through the MLX model runner (greedy only)."""
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
forward_mode = batch.forward_mode
reqs = batch.reqs
if forward_mode.is_idle():
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
can_run_cuda_graph=False,
)
self._cleanup_stale_rids(forward_mode, {req.rid for req in reqs})
next_token_ids_list: list[int] = []
if forward_mode.is_extend():
# Ensure pool is up-to-date before pool-backed attention reads it
# for prefix-cached prefills. Only runs on extend batches.
self._mlx_runner.flush_all_decode_kv()
input_ids_cpu = batch.input_ids.cpu().tolist()
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
extend_seq_lens = batch.extend_lens
offset = 0 # into input_ids_cpu
slot_offset = 0 # into out_cache_loc_cpu
prefill_rids: list[tuple[str, int]] = []
extend_rids: list[tuple[str, int]] = []
decode_rids: list[str] = []
# Genuine decode steps mixed into this extend batch; see
# _route_extend_request.
decoding_rids = {r.rid for r in (batch.decoding_reqs or [])}
for i, req in enumerate(reqs):
seq_len = extend_seq_lens[i]
req_token_ids = input_ids_cpu[offset : offset + seq_len]
req_new_slots = out_cache_loc_cpu[slot_offset : slot_offset + seq_len]
offset += seq_len
slot_offset += seq_len
route = self._route_extend_request(req.rid, decoding_rids)
if route == "continuation":
next_token = self._mlx_runner.extend(
req.rid, req_token_ids, req_new_slots
)
extend_rids.append((req.rid, next_token))
elif route == "decode":
decode_rids.append(req.rid)
else: # "prefill"
prefix_slot_ids = req.prefix_indices.tolist()
full_token_ids = list(req.get_fill_ids())
next_token = self._mlx_runner.prefill(
req_id=req.rid,
new_token_ids=req_token_ids,
full_token_ids=full_token_ids,
prefix_slot_ids=prefix_slot_ids,
new_slot_ids=req_new_slots,
req_pool_idx=req.req_pool_idx,
req=req,
)
prefill_rids.append((req.rid, next_token))
# Batch decode all existing requests at once
if decode_rids:
decode_results = self._mlx_runner.decode_batch(decode_rids)
decode_map = dict(zip(decode_rids, decode_results))
else:
decode_map = {}
prefill_map = dict(prefill_rids)
extend_map = dict(extend_rids)
for req in reqs:
if req.rid in decode_map:
next_token_ids_list.append(decode_map[req.rid])
elif req.rid in extend_map:
next_token_ids_list.append(extend_map[req.rid])
else:
next_token_ids_list.append(prefill_map[req.rid])
elif forward_mode.is_decode():
req_ids = [req.rid for req in reqs]
next_token_ids_list = self._mlx_runner.decode_batch(req_ids)
else:
raise ValueError(
f"MLX runner does not support forward mode: {forward_mode}"
)
next_token_ids = torch.tensor(
next_token_ids_list, dtype=torch.long, device="cpu"
)
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
next_token_ids=next_token_ids,
can_run_cuda_graph=False,
)
def async_forward_batch_generation_mlx(self, batch: ScheduleBatch) -> tuple[
Union[mx.array, None],
list[MlxPendingPrefill],
list[MlxPendingExtend],
Optional[MlxPendingDecode],
str,
]:
"""Start an async (lazy) forward pass through the MLX model runner.
Returns ``(lazy_result, prefills, extends, decode, mode)``:
* ``lazy_result`` — an ``mx.array`` that, when evaluated, forces
materialisation of the whole batch's outputs. ``None`` for
idle batches.
* ``prefills`` — list of :class:`MlxPendingPrefill` for new
requests in an extend batch.
* ``extends`` — list of :class:`MlxPendingExtend` for chunked
prefill continuations in an extend batch.
* ``decode`` — :class:`MlxPendingDecode` for the decode
sub-batch (covers full decode mode AND mixed decodes inside
an extend batch).
* ``mode`` — one of ``"idle"``, ``"decode"``, ``"extend"``.
The caller must make sure the returned pendings are fed into a
subsequent ``mx.async_eval`` or ``.item()`` / ``.tolist()`` call
— :meth:`finalize_mlx_result` does that.
"""
self._ensure_mlx_pool_initialized()
forward_mode = batch.forward_mode
reqs = batch.reqs
if forward_mode.is_idle():
return None, [], [], None, "idle"
self._cleanup_stale_rids(forward_mode, {req.rid for req in reqs})
if forward_mode.is_decode():
req_ids = [req.rid for req in reqs]
pending_decode = self._mlx_runner.decode_batch_start(req_ids)
mx.async_eval(pending_decode.lazy_tokens)
return pending_decode.lazy_tokens, [], [], pending_decode, "decode"
if forward_mode.is_extend():
# TODO (changminbark): Implement per-batch flushing using prefix_slot_ids
# Ensure the pool is up-to-date before pool-backed attention
# reads it for prefix-cached prefills. Mirror the sync path.
self._mlx_runner.flush_all_decode_kv()
return self._async_extend_batch(batch)
raise ValueError(
f"MLX async runner does not support forward mode: {forward_mode}"
)
def _async_extend_batch(self, batch: ScheduleBatch) -> tuple[
Union[mx.array, None],
list[MlxPendingPrefill],
list[MlxPendingExtend],
Optional[MlxPendingDecode],
str,
]:
"""Launch each request in an EXTEND batch lazily and kick GPU work."""
reqs = batch.reqs
input_ids_cpu = batch.input_ids.cpu().tolist()
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
extend_seq_lens = batch.extend_lens
offset = 0
slot_offset = 0
pending_prefills: list[MlxPendingPrefill] = []
pending_extends: list[MlxPendingExtend] = []
mixed_decode_rids: list[str] = []
# Genuine decode steps mixed into this extend batch; see
# _route_extend_request.
decoding_rids = {r.rid for r in (batch.decoding_reqs or [])}
for i, req in enumerate(reqs):
seq_len = extend_seq_lens[i]
req_token_ids = input_ids_cpu[offset : offset + seq_len]
req_new_slots = out_cache_loc_cpu[slot_offset : slot_offset + seq_len]
offset += seq_len
slot_offset += seq_len
route = self._route_extend_request(req.rid, decoding_rids)
if route == "continuation":
pending_extends.append(
self._mlx_runner.extend_start(
req_id=req.rid,
new_token_ids=req_token_ids,
new_slot_ids=req_new_slots,
)
)
elif route == "decode":
mixed_decode_rids.append(req.rid)
else: # "prefill"
prefix_slot_ids = req.prefix_indices.tolist()
full_token_ids = list(req.get_fill_ids())
pending_prefills.append(
self._mlx_runner.prefill_start(
req_id=req.rid,
new_token_ids=req_token_ids,
full_token_ids=full_token_ids,
prefix_slot_ids=prefix_slot_ids,
new_slot_ids=req_new_slots,
req_pool_idx=req.req_pool_idx,
req=req,
)
)
pending_mixed_decode: Optional[MlxPendingDecode] = None
if mixed_decode_rids:
pending_mixed_decode = self._mlx_runner.decode_batch_start(
mixed_decode_rids
)
# Stack lazy tokens so the caller has a single handle to evaluate
# after CPU scheduling work. We also hand every cache buffer
# (and the decode cache arrays) to mx.async_eval so the GPU
# kernel-launch stream sees everything the next step depends on
# before we actually block on anything.
prefill_ext_tokens: list[mx.array] = [p.lazy_token for p in pending_prefills]
prefill_ext_tokens.extend(e.lazy_token for e in pending_extends)
async_args: list[mx.array] = []
if prefill_ext_tokens:
lazy_stacked = mx.stack(prefill_ext_tokens, axis=0)
async_args.append(lazy_stacked)
else:
lazy_stacked = None
for p in pending_prefills:
async_args.extend(self._cache_state(p.cache))
for e in pending_extends:
async_args.extend(self._cache_state(self._mlx_runner._req_caches[e.req_id]))
if pending_mixed_decode is not None:
async_args.append(pending_mixed_decode.lazy_tokens)
for c_list in pending_mixed_decode.caches:
async_args.extend(self._cache_state(c_list))
if async_args:
mx.async_eval(*async_args)
return (
lazy_stacked,
pending_prefills,
pending_extends,
pending_mixed_decode,
"extend",
)
@staticmethod
def _cache_state(cache_list) -> list[mx.array]:
"""Flatten a per-layer cache list to its ``state`` arrays."""
arrays: list[mx.array] = []
def collect(value):
if isinstance(value, mx.array):
arrays.append(value)
elif value is None:
return
elif isinstance(value, (list, tuple)):
for item in value:
collect(item)
elif isinstance(value, dict):
for item in value.values():
collect(item)
for cache in cache_list:
collect(getattr(cache, "state", ()))
return arrays
def async_chained_decode_mlx(
self,
prev_pending: MlxPendingDecode,
) -> tuple[mx.array, list, list, MlxPendingDecode, str]:
"""Launch a decode step that chains off a still-lazy previous decode.
This is the "no idle gap" pipelining primitive: build the next
decode's compute graph using ``prev_pending.lazy_tokens`` (still
unevaluated) as its input ids, hand the combined graph to
``mx.async_eval``, and return. The GPU runs the new step
immediately after ``prev_pending`` with no scheduling gap, while
the caller is free to block on ``prev_pending`` and run CPU-side
bookkeeping.
Preconditions (caller must ensure):
* ``prev_pending`` was produced by a previous decode start
(either :meth:`async_forward_batch_generation_mlx` in decode
mode or a previous :meth:`async_chained_decode_mlx`).
* The batch composition for this step is identical to
``prev_pending`` — same requests, same order. Composition
changes (finished reqs, new prefills) must break the chain.
* ``prev_pending`` should be finalised BEFORE the returned
pending, so per-request token lists are appended in order.
Returns a 5-tuple matching
:meth:`async_forward_batch_generation_mlx` for the decode case:
``(lazy_tokens, [], [], pending_decode, "decode")``. The empty
prefill/extend lists are always absent for chained decodes.
"""
pending = self._mlx_runner.decode_batch_start_chained(prev_pending)
mx.async_eval(pending.lazy_tokens)
return pending.lazy_tokens, [], [], pending, "decode"
def finalize_mlx_result(
self,
prefills: list[MlxPendingPrefill],
extends: list[MlxPendingExtend],
decode: Optional[MlxPendingDecode],
mode: str,
reqs: list,
) -> GenerationBatchResult:
"""Materialise a lazy MLX result into a :class:`GenerationBatchResult`.
The blocking wait happens inside ``decode_batch_finalize`` /
``prefill_finalize`` / ``extend_finalize`` via ``.tolist()`` /
``.item()`` on the specific lazy outputs.
"""
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
if mode == "idle":
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
can_run_cuda_graph=False,
)
if mode == "decode":
assert decode is not None
next_tokens_list = self._mlx_runner.decode_batch_finalize(decode)
elif mode == "extend":
prefill_map: dict[str, int] = {}
for pending_p in prefills:
prefill_map[pending_p.req_id] = self._mlx_runner.prefill_finalize(
pending_p
)
extend_map: dict[str, int] = {}
for pending_e in extends:
extend_map[pending_e.req_id] = self._mlx_runner.extend_finalize(
pending_e
)
decode_map: dict[str, int] = {}
if decode is not None:
mixed_tokens = self._mlx_runner.decode_batch_finalize(decode)
decode_map = {
rid: tok for rid, tok in zip(decode.req_ids, mixed_tokens)
}
next_tokens_list = []
for req in reqs:
if req.rid in decode_map:
next_tokens_list.append(decode_map[req.rid])
elif req.rid in extend_map:
next_tokens_list.append(extend_map[req.rid])
else:
next_tokens_list.append(prefill_map[req.rid])
else:
raise ValueError(f"Unknown MLX async mode: {mode}")
next_token_ids = torch.tensor(next_tokens_list, dtype=torch.long, device="cpu")
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
next_token_ids=next_token_ids,
can_run_cuda_graph=False,
)
@@ -0,0 +1 @@
# MUSA (Moore Threads GPU) hardware backend
@@ -0,0 +1,3 @@
from .flashattention_backend import MusaFlashAttentionBackend
__all__ = ["MusaFlashAttentionBackend"]
@@ -0,0 +1,951 @@
from __future__ import annotations
import threading
from typing import TYPE_CHECKING, Optional, Tuple, Union
import torch
from flash_attn_interface import flash_attn_varlen_func
from flash_attn_interface import flash_attn_with_kvcache as mate_flash_attn_with_kvcache
from flash_attn_interface import get_scheduler_metadata
from sglang.srt.distributed import get_pp_group, get_pp_indices
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.musa.layers.utils.cp_utils import (
musa_cp_attn_forward_extend as cp_attn_forward_extend,
)
from sglang.srt.layers.attention.flashattention_backend import (
FlashAttentionBackend,
FlashAttentionMultiStepBackend,
merge_state_v2_wrapper,
)
from sglang.srt.layers.radix_attention import AttentionType
from sglang.srt.layers.utils.cp_utils import (
cp_allgather_and_save_kv_cache,
)
from sglang.srt.mem_cache.memory_pool import KVWriteLoc
from sglang.srt.runtime_context import get_server_args
if TYPE_CHECKING:
from sglang.srt.layers.radix_attention import RadixAttention
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.model_executor.model_runner import ModelRunner
# Global workspace buffer for MLA
_MATE_MLA_WORKSPACE_SIZE_BYTES = 128 * 1024 * 1024
# Cache for non-MLA scheduler metadata by prefix
_MATE_NO_MLA_SCHEDULER_METADATA_DICT: dict = {}
_MATE_NO_MLA_SCHEDULER_METADATA_LOCK = threading.Lock()
# Global reference to the current backend instance (set during __init__)
_CURRENT_BACKEND: Optional[MusaFlashAttentionBackend] = None
def _compute_scheduler_metadata(
backend: MusaFlashAttentionBackend,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k_new: Optional[torch.Tensor],
cache_seqlens: torch.Tensor,
max_seqlen_q: int,
page_size: int,
causal: bool,
window_size: Tuple[int, int],
num_splits: int,
) -> Tuple[torch.Tensor, bool] | torch.Tensor:
"""Compute scheduler metadata based on backend's current state."""
global _MATE_NO_MLA_SCHEDULER_METADATA_DICT
layer = backend._current_layer
current_layer_id = layer.layer_id
batch_size = cu_seqlens_q.shape[-1] - 1
# Determine if scheduler metadata should be updated
should_update = True
pp_group = get_pp_group()
pp_rank = pp_group.rank_in_group
start_layer_id, _ = get_pp_indices(
backend.num_hidden_layers, pp_group.rank_in_group, pp_group.world_size
)
if backend._current_can_run_tbo and pp_rank == 0:
start_layer_id += (
backend.first_k_dense_replace
if backend.first_k_dense_replace is not None
else 0
)
if backend.full_attention_interval is not None:
start_layer_id += backend.full_attention_interval - 1
if current_layer_id > start_layer_id:
should_update = False
if envs.SGLANG_MUSA_FA3_FORCE_UPDATE_METADATA.get():
should_update = True
if backend.use_mla:
from sglang.srt.runtime_context import get_buffer
workspace = get_buffer(
"musa_mate_mla_workspace",
lambda: torch.empty(
_MATE_MLA_WORKSPACE_SIZE_BYTES, device=backend.device, dtype=torch.uint8
),
)
return (workspace, not should_update)
else:
with _MATE_NO_MLA_SCHEDULER_METADATA_LOCK:
if (
should_update
or backend._current_prefix not in _MATE_NO_MLA_SCHEDULER_METADATA_DICT
):
_MATE_NO_MLA_SCHEDULER_METADATA_DICT[backend._current_prefix] = (
get_scheduler_metadata(
batch_size=batch_size,
num_heads_q=layer.tp_q_head_num,
num_heads_kv=layer.tp_k_head_num,
headdim=layer.qk_head_dim,
headdim_v=layer.v_head_dim,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k_new=cu_seqlens_k_new,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=backend._current_max_seqlen_k,
page_size=page_size,
causal=causal,
window_size=window_size,
num_splits=num_splits,
)
)
return _MATE_NO_MLA_SCHEDULER_METADATA_DICT[backend._current_prefix]
def flash_attn_with_kvcache(
q: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
k: Optional[torch.Tensor] = None,
v: Optional[torch.Tensor] = None,
qv: Optional[torch.Tensor] = None,
rotary_cos: Optional[torch.Tensor] = None,
rotary_sin: Optional[torch.Tensor] = None,
cache_seqlens: Optional[Union[int, torch.Tensor]] = None,
cache_batch_idx: Optional[torch.Tensor] = None,
cache_leftpad: Optional[torch.Tensor] = None,
page_table: Optional[torch.Tensor] = None,
cu_seqlens_q: Optional[torch.Tensor] = None,
cu_seqlens_k_new: Optional[torch.Tensor] = None,
max_seqlen_q: Optional[int] = None,
rotary_seqlens: Optional[torch.Tensor] = None,
q_descale: Optional[torch.Tensor] = None,
k_descale: Optional[torch.Tensor] = None,
v_descale: Optional[torch.Tensor] = None,
softmax_scale: Optional[float] = None,
causal: bool = False,
window_size: Tuple[int, int] = (-1, -1),
attention_chunk: int = 0,
softcap: float = 0.0,
rotary_interleaved: bool = True,
scheduler_metadata: Optional[torch.Tensor] = None,
num_splits: int = 0,
pack_gqa=None,
sm_margin: int = 0,
return_softmax_lse: bool = False,
sinks=None,
score_mod=None,
aux_tensors=None,
ver=3,
):
"""MUSA flash_attn_with_kvcache wrapper that auto-injects scheduler_metadata."""
if ver != 3:
raise ValueError("Only ver=3 is supported for MUSA FA3.")
if scheduler_metadata is None and _CURRENT_BACKEND is not None:
backend = _CURRENT_BACKEND
# Ensure backend has been properly set up for this call
if backend._current_layer is not None:
page_size = k_cache.shape[1] if k_cache is not None else 1
scheduler_metadata = _compute_scheduler_metadata(
backend=backend,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k_new=cu_seqlens_k_new,
cache_seqlens=cache_seqlens,
max_seqlen_q=max_seqlen_q,
page_size=page_size,
causal=causal,
window_size=window_size,
num_splits=num_splits,
)
return mate_flash_attn_with_kvcache(
q=q,
k_cache=k_cache,
v_cache=v_cache,
k=k,
v=v,
qv=qv,
rotary_cos=rotary_cos,
rotary_sin=rotary_sin,
cache_seqlens=cache_seqlens,
cache_batch_idx=cache_batch_idx,
cache_leftpad=cache_leftpad,
page_table=page_table,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k_new=cu_seqlens_k_new,
max_seqlen_q=max_seqlen_q,
rotary_seqlens=rotary_seqlens,
q_descale=q_descale,
k_descale=k_descale,
v_descale=v_descale,
softmax_scale=softmax_scale,
causal=causal,
window_size=window_size,
attention_chunk=attention_chunk,
softcap=softcap,
rotary_interleaved=rotary_interleaved,
scheduler_metadata=scheduler_metadata,
num_splits=num_splits,
pack_gqa=pack_gqa,
sm_margin=sm_margin,
return_softmax_lse=return_softmax_lse,
sinks=sinks,
)
class MusaFlashAttentionBackend(FlashAttentionBackend):
def __init__(self, model_runner: ModelRunner, **kwargs):
super().__init__(model_runner, **kwargs)
self.num_hidden_layers = model_runner.model_config.num_hidden_layers
self.first_k_dense_replace = model_runner.model_config.first_k_dense_replace
self.full_attention_interval = model_runner.model_config.full_attention_interval
# State for current attention call (simplified from threadlocal context)
self._current_layer: Optional[RadixAttention] = None
self._current_prefix: str = ""
self._current_max_seqlen_k: int = 0
self._current_can_run_tbo: bool = False
# Disable default scheduler metadata for fa3
self._get_scheduler_metadata = None
# Register this backend as the global current instance for the wrapper
global _CURRENT_BACKEND
_CURRENT_BACKEND = self
def _set_current_state(
self, layer: RadixAttention, prefix: str, max_seqlen_k: int, can_run_tbo: bool
):
"""Set the dynamic state for the upcoming flash attention call."""
self._current_layer = layer
self._current_prefix = prefix
self._current_max_seqlen_k = max_seqlen_k
self._current_can_run_tbo = can_run_tbo
def forward_extend(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
layer: RadixAttention,
forward_batch: ForwardBatch,
save_kv_cache=True,
q_rope: Optional[torch.Tensor] = None,
k_rope: Optional[torch.Tensor] = None,
sinks: Optional[torch.Tensor] = None,
):
if k is not None:
assert v is not None
is_cp_mode = (
forward_batch.forward_mode.is_context_parallel_extend()
and forward_batch.attn_cp_metadata is not None
and self.attn_cp_size > 1
)
if save_kv_cache and not is_cp_mode:
cache_loc = (
forward_batch.out_cache_loc
if not layer.is_cross_attention
else forward_batch.encoder_out_cache_loc
)
if not self.use_mla:
self.token_to_kv_pool.set_kv_buffer(
layer,
KVWriteLoc(cache_loc, self.forward_metadata.swa_out_cache_loc),
k,
v,
layer.k_scale,
layer.v_scale,
)
else:
self.token_to_kv_pool.set_mla_kv_buffer(
layer,
cache_loc,
k,
k_rope,
)
if is_cp_mode:
cp_allgather_and_save_kv_cache(
forward_batch,
layer,
k,
v,
self.attn_cp_size,
swa_loc=(
self.forward_metadata.swa_out_cache_loc
if self.use_sliding_window_kv_pool
else None
),
)
metadata = self.forward_metadata
is_swa_layer = (
layer.sliding_window_size is not None and layer.sliding_window_size > -1
)
window_size = (layer.sliding_window_size, 0) if is_swa_layer else (-1, -1)
k_descale, v_descale = None, None
if (
self.kv_cache_dtype_str != "auto"
and layer.head_dim <= 256
and self.fa_impl_ver != 4
):
if layer.k_scale is not None:
descale_shape = (forward_batch.batch_size, layer.tp_k_head_num)
k_descale = layer.k_scale.expand(descale_shape)
v_descale = layer.v_scale.expand(descale_shape)
q = q.to(self.kv_cache_dtype)
q_rope = q_rope.to(self.kv_cache_dtype) if q_rope is not None else None
k_rope = k_rope.to(self.kv_cache_dtype) if k_rope is not None else None
causal = True
if layer.is_cross_attention or layer.attn_type == AttentionType.ENCODER_ONLY:
causal = False
use_local_attn = (
self.has_local_attention
and self.attention_chunk_size is not None
and metadata.local_attn_metadata is not None
and (hasattr(layer, "use_irope") and layer.use_irope)
)
use_cascade_attn = (
forward_batch.forward_mode.is_target_verify()
and self.topk > 1
and not is_swa_layer
)
kwargs = {}
if sinks is not None:
kwargs["sinks"] = sinks
if use_local_attn:
local_metadata = metadata.local_attn_metadata
page_table = local_metadata.local_block_table
cu_seqlens_q = local_metadata.local_query_start_loc
cache_seqlens = local_metadata.local_seqused_k
max_seqlen_q = local_metadata.local_max_query_len
max_seqlen_k = local_metadata.local_max_seq_len
elif is_swa_layer and metadata.swa_spec_metadata is not None:
swa_spec_metadata = metadata.swa_spec_metadata
page_table = swa_spec_metadata.page_table
cu_seqlens_q = swa_spec_metadata.cu_seqlens_q
cache_seqlens = swa_spec_metadata.cache_seqlens_int32
max_seqlen_q = swa_spec_metadata.max_seq_len_q
cu_seqlens_k = swa_spec_metadata.cu_seqlens_k
max_seqlen_k = swa_spec_metadata.max_seq_len_k
else:
page_table = metadata.page_table
if is_swa_layer and self.use_sliding_window_kv_pool:
if metadata.swa_page_table is not None:
page_table = metadata.swa_page_table
else:
page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table
)
cu_seqlens_q = metadata.cu_seqlens_q
cache_seqlens = metadata.cache_seqlens_int32
max_seqlen_q = metadata.max_seq_len_q
cu_seqlens_k = metadata.cu_seqlens_k
max_seqlen_k = metadata.max_seq_len_k
# Set current state for the flash attention call
self._set_current_state(
layer=layer,
prefix="forward_extend",
max_seqlen_k=max_seqlen_k,
can_run_tbo=forward_batch.can_run_tbo,
)
if not self.use_mla:
key_cache, value_cache = self.token_to_kv_pool.get_kv_buffer(layer.layer_id)
key_cache = key_cache.view(
-1, self.page_size, layer.tp_k_head_num, layer.head_dim
)
value_cache = value_cache.view(
-1, self.page_size, layer.tp_v_head_num, layer.v_head_dim
)
if layer.is_cross_attention:
page_table = metadata.encoder_page_table
cache_seqlens = metadata.encoder_lens_int32
cu_seqlens_k = metadata.encoder_cu_seqlens_k
window_size = (-1, -1)
if (
forward_batch.forward_mode.is_context_parallel_extend()
and forward_batch.attn_cp_metadata is not None
and self.attn_cp_size > 1
):
def _fa_cp_attn(
q_chunk, cu_seqlens_q_cp, cache_seqlens_cp, max_seqlen_q_cp
):
return flash_attn_with_kvcache(
q=q_chunk,
k_cache=key_cache,
v_cache=value_cache,
page_table=page_table,
cache_seqlens=cache_seqlens_cp,
cu_seqlens_q=cu_seqlens_q_cp,
cu_seqlens_k_new=(cu_seqlens_k if not use_local_attn else None),
max_seqlen_q=max_seqlen_q_cp,
softmax_scale=layer.scaling,
causal=False if use_cascade_attn else causal,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=use_cascade_attn,
num_splits=self.num_splits,
**kwargs,
)
result = cp_attn_forward_extend(
self,
forward_batch,
q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
self.device,
_fa_cp_attn,
)
elif (
forward_batch.extend_prefix_lens_cpu is not None
and any(forward_batch.extend_prefix_lens_cpu)
) or forward_batch.forward_mode.is_target_verify():
result = flash_attn_with_kvcache(
q=q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
k_cache=key_cache,
v_cache=value_cache,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k_new=cu_seqlens_k if not use_local_attn else None,
max_seqlen_q=max_seqlen_q,
softmax_scale=layer.scaling,
causal=False if use_cascade_attn else causal,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=use_cascade_attn,
num_splits=self.num_splits,
**kwargs,
)
else:
output = flash_attn_varlen_func(
q=q.view(-1, layer.tp_q_head_num, layer.head_dim),
k=k.view(-1, layer.tp_k_head_num, layer.head_dim).to(q.dtype),
v=v.view(-1, layer.tp_k_head_num, layer.v_head_dim).to(q.dtype),
cu_seqlens_q=metadata.cu_seqlens_q,
cu_seqlens_k=metadata.cu_seqlens_q,
max_seqlen_q=metadata.max_seq_len_q,
max_seqlen_k=metadata.max_seq_len_q,
softmax_scale=layer.scaling,
causal=True,
return_softmax_lse=forward_batch.mha_return_lse,
**kwargs,
)
if forward_batch.mha_return_lse:
output, lse, *rest = output
lse = torch.transpose(lse, 0, 1).contiguous()
return (
output.view(-1, layer.tp_q_head_num * layer.v_head_dim),
lse,
)
return output.view(-1, layer.tp_q_head_num * layer.v_head_dim)
if use_cascade_attn:
# Update state for the second call
self._current_prefix = "forward_extend_use_cascade_attn"
self._current_max_seqlen_k = (
self.forward_metadata_spec_decode_expand.max_seq_len_k
)
o, softmax_lse, *rest = result
o_expand, softmax_lse_expand, *rest_expand = flash_attn_with_kvcache(
q=q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
k_cache=key_cache.view(-1, 1, layer.tp_k_head_num, layer.head_dim),
v_cache=value_cache.view(
-1, 1, layer.tp_v_head_num, layer.head_dim
),
page_table=self.forward_metadata_spec_decode_expand.page_table,
cache_seqlens=self.forward_metadata_spec_decode_expand.cache_seqlens_int32,
cu_seqlens_q=self.forward_metadata_spec_decode_expand.cu_seqlens_q,
cu_seqlens_k_new=self.forward_metadata_spec_decode_expand.cu_seqlens_k,
max_seqlen_q=self.forward_metadata_spec_decode_expand.max_seq_len_q,
softmax_scale=layer.scaling,
causal=False,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=True,
num_splits=self.num_splits,
**kwargs,
)
o, _ = merge_state_v2_wrapper(
o,
softmax_lse.T.contiguous(),
o_expand,
softmax_lse_expand.T.contiguous(),
)
else:
o = result
else:
if (
forward_batch.attn_attend_prefix_cache is not None
and not forward_batch.forward_mode.is_target_verify()
and not forward_batch.forward_mode.is_draft_extend_v2()
):
if forward_batch.attn_attend_prefix_cache:
assert not get_server_args().disable_chunked_prefix_cache
assert forward_batch.prefix_chunk_idx is not None
assert forward_batch.prefix_chunk_cu_seq_lens is not None
assert forward_batch.prefix_chunk_max_seq_lens is not None
chunk_idx = forward_batch.prefix_chunk_idx
assert chunk_idx >= 0
assert forward_batch.mha_return_lse
output = flash_attn_varlen_func(
q=q.view(-1, layer.tp_q_head_num, layer.head_dim),
k=k.view(-1, layer.tp_k_head_num, layer.head_dim).to(q.dtype),
v=v.view(-1, layer.tp_k_head_num, layer.v_head_dim).to(q.dtype),
cu_seqlens_q=metadata.cu_seqlens_q,
cu_seqlens_k=forward_batch.prefix_chunk_cu_seq_lens[chunk_idx],
max_seqlen_q=metadata.max_seq_len_q,
max_seqlen_k=forward_batch.prefix_chunk_max_seq_lens[chunk_idx],
softmax_scale=layer.scaling,
causal=False,
return_softmax_lse=True,
**kwargs,
)
else:
cu_seqlens_k = (
metadata.cu_seqlens_q
if not forward_batch.mha_one_shot
else metadata.cu_seqlens_k
)
max_seqlen_k = (
metadata.max_seq_len_q
if not forward_batch.mha_one_shot
else metadata.max_seq_len_k
)
output = flash_attn_varlen_func(
q=q.view(-1, layer.tp_q_head_num, layer.head_dim),
k=k.view(-1, layer.tp_k_head_num, layer.head_dim).to(q.dtype),
v=v.view(-1, layer.tp_k_head_num, layer.v_head_dim).to(q.dtype),
cu_seqlens_q=metadata.cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=metadata.max_seq_len_q,
max_seqlen_k=max_seqlen_k,
softmax_scale=layer.scaling,
causal=True,
return_softmax_lse=forward_batch.mha_return_lse,
**kwargs,
)
if forward_batch.mha_return_lse:
output, lse, *rest = output
lse = torch.transpose(lse, 0, 1).contiguous()
return output, lse
return output
else:
kv_cache = self.token_to_kv_pool.get_key_buffer(layer.layer_id).to(
q.dtype
)
k_rope = kv_cache[:, :, layer.v_head_dim :]
c_kv = kv_cache[:, :, : layer.v_head_dim]
k_rope_cache = k_rope.view(
-1,
self.page_size,
layer.tp_k_head_num,
layer.head_dim - layer.v_head_dim,
)
c_kv_cache = c_kv.view(
-1, self.page_size, layer.tp_v_head_num, layer.v_head_dim
)
if q_rope is not None:
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
else:
q_all = q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim)
q_nope = q_all[:, :, : layer.v_head_dim]
q_rope = q_all[:, :, layer.v_head_dim :]
result = flash_attn_with_kvcache(
q=q_rope,
k_cache=k_rope_cache,
v_cache=c_kv_cache,
qv=q_nope,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k_new=cu_seqlens_k if not use_local_attn else None,
max_seqlen_q=max_seqlen_q,
softmax_scale=layer.scaling,
causal=False if use_cascade_attn else causal,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=use_cascade_attn,
num_splits=self.num_splits,
)
if use_cascade_attn:
self._current_prefix = "forward_extend_use_cascade_attn"
self._current_max_seqlen_k = (
self.forward_metadata_spec_decode_expand.max_seq_len_k
)
o, softmax_lse, *rest = result
o_expand, softmax_lse_expand, *rest_expand = (
flash_attn_with_kvcache(
q=q_rope,
k_cache=k_rope_cache,
v_cache=c_kv_cache,
qv=q_nope,
page_table=self.forward_metadata_spec_decode_expand.page_table,
cache_seqlens=self.forward_metadata_spec_decode_expand.cache_seqlens_int32,
cu_seqlens_q=self.forward_metadata_spec_decode_expand.cu_seqlens_q,
cu_seqlens_k_new=self.forward_metadata_spec_decode_expand.cu_seqlens_k,
max_seqlen_q=self.forward_metadata_spec_decode_expand.max_seq_len_q,
softmax_scale=layer.scaling,
causal=False,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=True,
num_splits=self.num_splits,
)
)
o, _ = merge_state_v2_wrapper(
o,
softmax_lse.T.contiguous(),
o_expand,
softmax_lse_expand.T.contiguous(),
)
else:
o = result
return o.view(-1, layer.tp_q_head_num * layer.v_head_dim)
def forward_decode(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
layer: RadixAttention,
forward_batch: ForwardBatch,
save_kv_cache=True,
q_rope: Optional[torch.Tensor] = None,
k_rope: Optional[torch.Tensor] = None,
sinks: Optional[torch.Tensor] = None,
) -> torch.Tensor:
if k is not None:
assert v is not None
if save_kv_cache:
cache_loc = (
forward_batch.out_cache_loc
if not layer.is_cross_attention
else forward_batch.encoder_out_cache_loc
)
if not self.use_mla:
self.token_to_kv_pool.set_kv_buffer(
layer,
KVWriteLoc(cache_loc, self.forward_metadata.swa_out_cache_loc),
k,
v,
layer.k_scale,
layer.v_scale,
)
else:
self.token_to_kv_pool.set_mla_kv_buffer(
layer,
cache_loc,
k,
k_rope,
)
metadata = self.forward_metadata
local_attn_metadata = getattr(metadata, "local_attn_metadata", None)
use_local_attn = (
self.has_local_attention
and self.attention_chunk_size is not None
and local_attn_metadata is not None
and (hasattr(layer, "use_irope") and layer.use_irope)
)
use_cascade_attn = forward_batch.spec_info is not None and self.topk > 1
is_swa_layer = (
layer.sliding_window_size is not None and layer.sliding_window_size > -1
)
window_size = (layer.sliding_window_size, 0) if is_swa_layer else (-1, -1)
causal = True
if layer.is_cross_attention or layer.attn_type == AttentionType.ENCODER_ONLY:
causal = False
kwargs = {}
if sinks is not None:
kwargs["sinks"] = sinks
k_descale, v_descale = None, None
if self.kv_cache_dtype_str != "auto" and layer.head_dim <= 256:
if layer.k_scale is not None:
descale_shape = (forward_batch.batch_size, layer.tp_k_head_num)
k_descale = layer.k_scale.expand(descale_shape)
v_descale = layer.v_scale.expand(descale_shape)
q = q.to(self.kv_cache_dtype)
q_rope = q_rope.to(self.kv_cache_dtype) if q_rope is not None else None
k_rope = k_rope.to(self.kv_cache_dtype) if k_rope is not None else None
# Set current state for the flash attention call
self._set_current_state(
layer=layer,
prefix="forward_decode",
max_seqlen_k=metadata.max_seq_len_k,
can_run_tbo=forward_batch.can_run_tbo,
)
if not self.use_mla:
key_cache, value_cache = self.token_to_kv_pool.get_kv_buffer(layer.layer_id)
key_cache = key_cache.view(
-1, self.page_size, layer.tp_k_head_num, layer.head_dim
)
value_cache = value_cache.view(
-1, self.page_size, layer.tp_v_head_num, layer.v_head_dim
)
if layer.is_cross_attention:
o = flash_attn_with_kvcache(
q=q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
k_cache=key_cache,
v_cache=value_cache,
page_table=metadata.encoder_page_table,
cache_seqlens=metadata.encoder_lens_int32,
cu_seqlens_q=metadata.cu_seqlens_q,
cu_seqlens_k_new=metadata.encoder_cu_seqlens_k,
max_seqlen_q=1,
softmax_scale=layer.scaling,
causal=False,
window_size=(-1, -1),
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
num_splits=self.num_splits,
**kwargs,
)
elif use_local_attn:
o = flash_attn_with_kvcache(
q=q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
k_cache=key_cache,
v_cache=value_cache,
page_table=local_attn_metadata.local_block_table,
cache_seqlens=local_attn_metadata.local_seqused_k,
cu_seqlens_q=local_attn_metadata.local_query_start_loc,
cu_seqlens_k_new=None,
max_seqlen_q=local_attn_metadata.local_max_query_len,
softmax_scale=layer.scaling,
causal=True,
window_size=(-1, -1),
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
num_splits=self.num_splits,
**kwargs,
)
else:
page_table = metadata.page_table
if is_swa_layer and self.use_sliding_window_kv_pool:
if metadata.swa_page_table is not None:
page_table = metadata.swa_page_table
else:
page_table = (
self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table
)
)
cache_seqlens = metadata.cache_seqlens_int32
cu_seqlens_k = metadata.cu_seqlens_k
max_seqlen_q = metadata.max_seq_len_q
q_reshaped = q.contiguous().view(
-1, layer.tp_q_head_num, layer.head_dim
)
result = flash_attn_with_kvcache(
q=q_reshaped,
k_cache=key_cache,
v_cache=value_cache,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=metadata.cu_seqlens_q,
max_seqlen_q=max_seqlen_q,
softmax_scale=layer.scaling,
causal=False if use_cascade_attn else causal,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=use_cascade_attn,
num_splits=self.num_splits,
**kwargs,
)
if use_cascade_attn:
self._current_prefix = "forward_decode_use_cascade_attn"
self._current_max_seqlen_k = (
self.forward_metadata_spec_decode_expand.max_seq_len_k
)
o, softmax_lse, *rest = result
o_expand, softmax_lse_expand, *rest_expand = (
flash_attn_with_kvcache(
q=q_reshaped,
k_cache=key_cache,
v_cache=value_cache,
page_table=self.forward_metadata_spec_decode_expand.page_table,
cache_seqlens=self.forward_metadata_spec_decode_expand.cache_seqlens_int32,
cu_seqlens_q=self.forward_metadata_spec_decode_expand.cu_seqlens_q,
cu_seqlens_k_new=self.forward_metadata_spec_decode_expand.cu_seqlens_k,
max_seqlen_q=self.forward_metadata_spec_decode_expand.max_seq_len_q,
softmax_scale=layer.scaling,
causal=False,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=True,
num_splits=self.num_splits,
**kwargs,
)
)
o, _ = merge_state_v2_wrapper(
o,
softmax_lse.T.contiguous(),
o_expand,
softmax_lse_expand.T.contiguous(),
)
else:
o = result
else:
kv_cache = self.token_to_kv_pool.get_key_buffer(layer.layer_id).to(q.dtype)
k_rope = kv_cache[:, :, layer.v_head_dim :]
c_kv = kv_cache[:, :, : layer.v_head_dim]
k_rope_cache = k_rope.view(
-1,
self.page_size,
layer.tp_k_head_num,
layer.head_dim - layer.v_head_dim,
)
c_kv_cache = c_kv.view(
-1, self.page_size, layer.tp_v_head_num, layer.v_head_dim
)
if q_rope is not None:
q_nope = q.view(-1, layer.tp_q_head_num, layer.v_head_dim)
q_rope = q_rope.view(
-1, layer.tp_q_head_num, layer.head_dim - layer.v_head_dim
)
else:
q_all = q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim)
q_nope = q_all[:, :, : layer.v_head_dim]
q_rope = q_all[:, :, layer.v_head_dim :]
max_seqlen_q = metadata.max_seq_len_q
result = flash_attn_with_kvcache(
q=q_rope,
k_cache=k_rope_cache,
v_cache=c_kv_cache,
qv=q_nope,
page_table=metadata.page_table,
cache_seqlens=metadata.cache_seqlens_int32,
cu_seqlens_q=metadata.cu_seqlens_q,
cu_seqlens_k_new=metadata.cu_seqlens_k,
max_seqlen_q=max_seqlen_q,
softmax_scale=layer.scaling,
causal=False if use_cascade_attn else causal,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=use_cascade_attn,
num_splits=self.num_splits,
)
if use_cascade_attn:
self._current_prefix = "forward_decode_use_cascade_attn"
self._current_max_seqlen_k = (
self.forward_metadata_spec_decode_expand.max_seq_len_k
)
o, softmax_lse, *rest = result
o_expand, softmax_lse_expand, *rest_expand = flash_attn_with_kvcache(
q=q_rope,
k_cache=k_rope_cache,
v_cache=c_kv_cache,
qv=q_nope,
page_table=self.forward_metadata_spec_decode_expand.page_table,
cache_seqlens=self.forward_metadata_spec_decode_expand.cache_seqlens_int32,
cu_seqlens_q=self.forward_metadata_spec_decode_expand.cu_seqlens_q,
cu_seqlens_k_new=self.forward_metadata_spec_decode_expand.cu_seqlens_k,
max_seqlen_q=self.forward_metadata_spec_decode_expand.max_seq_len_q,
softmax_scale=layer.scaling,
causal=False,
window_size=window_size,
softcap=layer.logit_cap,
k_descale=k_descale,
v_descale=v_descale,
return_softmax_lse=True,
num_splits=self.num_splits,
)
o, _ = merge_state_v2_wrapper(
o,
softmax_lse.T.contiguous(),
o_expand,
softmax_lse_expand.T.contiguous(),
)
else:
o = result
return o.view(-1, layer.tp_q_head_num * layer.v_head_dim)
class MusaFlashAttentionMultiStepBackend(FlashAttentionMultiStepBackend):
def __init__(
self,
model_runner: ModelRunner,
topk: int,
speculative_num_steps: int,
fa_impl_ver: int = 3,
):
self.model_runner = model_runner
self.topk = topk
self.speculative_num_steps = speculative_num_steps
self.attn_backends = []
for i in range(self.speculative_num_steps - 1):
self.attn_backends.append(
MusaFlashAttentionBackend(
model_runner,
speculative_step_id=i,
topk=self.topk,
speculative_num_steps=self.speculative_num_steps,
fa_impl_ver=fa_impl_ver,
)
)
@@ -0,0 +1,300 @@
from typing import (
Optional,
)
import torch
import triton
import triton.language as tl
@triton.jit
def tanh(x):
# Tanh is just a scaled sigmoid
return 2 * tl.sigmoid(2 * x) - 1
@triton.autotune(
configs=[
triton.Config({}, num_warps=1, num_stages=1),
triton.Config({}, num_warps=1, num_stages=2),
triton.Config({}, num_warps=2, num_stages=1),
triton.Config({}, num_warps=2, num_stages=2),
triton.Config({}, num_warps=4, num_stages=1),
triton.Config({}, num_warps=4, num_stages=2),
triton.Config({}, num_warps=4, num_stages=3),
triton.Config({}, num_warps=8, num_stages=1),
triton.Config({}, num_warps=8, num_stages=2),
triton.Config({}, num_warps=8, num_stages=3),
triton.Config({}, num_warps=16, num_stages=1),
triton.Config({}, num_warps=16, num_stages=2),
triton.Config({}, num_warps=16, num_stages=3),
triton.Config({}, num_warps=32, num_stages=1),
triton.Config({}, num_warps=32, num_stages=2),
],
key=["num_tokens", "num_experts", "has_correction_bias"],
)
@triton.jit
def topk_softmax_triton_kernel(
gating_output_ptr,
selected_expert_ptr,
moe_weights_ptr,
renormalize_flag,
num_experts,
num_tokens, # for autotune key
moe_softcapping,
correction_bias_ptr,
has_correction_bias: tl.constexpr,
K: tl.constexpr,
BLOCK_K: tl.constexpr,
BLOCK_WIDTH_SIZE_UP: tl.constexpr,
):
curr_row_idx = tl.program_id(0)
FLOAT_MINIMUM = -10000.0
LOG2E = 1.4426950408889634
weights_local_final = tl.zeros((BLOCK_K,), dtype=tl.float32)
selected_local_final = tl.zeros((BLOCK_K,), dtype=tl.int32)
offset = tl.arange(0, BLOCK_WIDTH_SIZE_UP)
k_offset = tl.arange(0, BLOCK_K)
mask_expert = offset < num_experts
mask_topk = k_offset < K
row_offset = curr_row_idx * num_experts
logits = tl.load(
gating_output_ptr + row_offset + offset, mask=mask_expert, other=FLOAT_MINIMUM
)
logits = tl.cast(logits, tl.float32)
if has_correction_bias:
bias = tl.load(correction_bias_ptr + offset, mask=mask_expert, other=0.0)
logits = logits + bias
if moe_softcapping > 0.0:
logits = moe_softcapping * tanh(logits / moe_softcapping)
row_max = tl.max(logits, axis=0)
probs = tl.exp2((logits - row_max) * LOG2E)
row_sum = tl.sum(probs, axis=0)
inv_row_sum = 1.0 / row_sum
probs = probs * inv_row_sum
probs = tl.where(mask_expert, probs, FLOAT_MINIMUM)
weights_selected_sum = 0.0
for k_idx in range(K):
top_k_index = tl.argmax(probs, axis=0)
mask = offset == top_k_index
top_k_value = tl.sum(tl.where(mask, probs, 0.0))
weights_local_final = tl.where(
k_offset == k_idx, top_k_value, weights_local_final
)
selected_local_final = tl.where(
k_offset == k_idx, top_k_index, selected_local_final
)
weights_selected_sum += top_k_value
probs = tl.where(offset == top_k_index, FLOAT_MINIMUM, probs)
if renormalize_flag:
weights_local_final = weights_local_final / weights_selected_sum
tl.store(
moe_weights_ptr + curr_row_idx * K + k_offset,
weights_local_final,
mask=mask_topk,
)
tl.store(
selected_expert_ptr + curr_row_idx * K + k_offset,
selected_local_final,
mask=mask_topk,
)
def topk_softmax(
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
gating_output: torch.Tensor,
renormalize: bool = False,
moe_softcapping: float = 0.0,
correction_bias: Optional[torch.Tensor] = None,
) -> None:
"""
Compute top-k softmax for MoE routing.
Args:
topk_weights: Output tensor for top-k weights [num_tokens, topk]
topk_ids: Output tensor for top-k expert indices [num_tokens, topk]
gating_output: Gating logits [num_tokens, num_experts]
renormalize: Whether to renormalize the top-k weights
moe_softcapping: Tanh softcapping value (0.0 to disable)
correction_bias: Per-expert bias correction [num_experts], must be float32 if provided
"""
num_tokens, num_experts = gating_output.shape
topk = topk_weights.shape[-1]
has_correction_bias = correction_bias is not None
block_width_up = triton.next_power_of_2(num_experts)
grid = (num_tokens,)
topk_softmax_triton_kernel[grid](
gating_output,
topk_ids,
topk_weights,
renormalize,
num_experts,
num_tokens,
moe_softcapping,
correction_bias,
has_correction_bias,
K=topk,
BLOCK_K=triton.next_power_of_2(topk),
BLOCK_WIDTH_SIZE_UP=block_width_up,
)
@triton.autotune(
configs=[
triton.Config({}, num_warps=1, num_stages=1),
triton.Config({}, num_warps=1, num_stages=2),
triton.Config({}, num_warps=2, num_stages=1),
triton.Config({}, num_warps=2, num_stages=2),
triton.Config({}, num_warps=4, num_stages=1),
triton.Config({}, num_warps=4, num_stages=2),
triton.Config({}, num_warps=4, num_stages=3),
triton.Config({}, num_warps=8, num_stages=1),
triton.Config({}, num_warps=8, num_stages=2),
triton.Config({}, num_warps=8, num_stages=3),
triton.Config({}, num_warps=16, num_stages=1),
triton.Config({}, num_warps=16, num_stages=2),
triton.Config({}, num_warps=16, num_stages=3),
triton.Config({}, num_warps=32, num_stages=1),
triton.Config({}, num_warps=32, num_stages=2),
],
key=["num_tokens", "num_experts"],
)
@triton.jit
def topk_sigmoid_triton_kernel(
gating_output_ptr,
selected_expert_ptr,
moe_weights_ptr,
renormalize_flag,
correction_bias_ptr,
has_correction_bias: tl.constexpr,
num_experts,
num_tokens, # for autotune key
K: tl.constexpr,
BLOCK_K: tl.constexpr,
BLOCK_WIDTH_SIZE_UP: tl.constexpr,
):
curr_row_idx = tl.program_id(0)
FLOAT_MINIMUM = -10000.0
LOG2E = 1.4426950408889634
weights_local_final = tl.zeros((BLOCK_K,), dtype=tl.float32)
selected_local_final = tl.zeros((BLOCK_K,), dtype=tl.int32)
offset = tl.arange(0, BLOCK_WIDTH_SIZE_UP)
k_offset = tl.arange(0, BLOCK_K)
mask_expert = offset < num_experts
mask_topk = k_offset < K
row_offset = curr_row_idx * num_experts
x = tl.load(
gating_output_ptr + row_offset + offset, mask=mask_expert, other=FLOAT_MINIMUM
)
x = tl.cast(x, tl.float32)
# Compute sigmoid(x)
is_positive = x >= 0
neg_x = tl.where(is_positive, -x, x)
exp_neg_x = tl.exp2(neg_x * LOG2E)
probs = tl.where(
is_positive,
1.0 / (1.0 + exp_neg_x),
exp_neg_x / (1.0 + exp_neg_x),
)
if has_correction_bias:
bias = tl.load(correction_bias_ptr + offset, mask=mask_expert, other=0.0)
probs_for_choice = probs + bias
else:
probs_for_choice = probs
probs_for_choice = tl.where(mask_expert, probs_for_choice, FLOAT_MINIMUM)
weights_selected_sum = 0.0
for k_idx in range(K):
top_k_index = tl.argmax(probs_for_choice, axis=0)
mask = offset == top_k_index
top_k_value = tl.sum(tl.where(mask, probs, 0.0))
weights_local_final = tl.where(
k_offset == k_idx, top_k_value, weights_local_final
)
selected_local_final = tl.where(
k_offset == k_idx, top_k_index, selected_local_final
)
weights_selected_sum += top_k_value
probs_for_choice = tl.where(
offset == top_k_index, FLOAT_MINIMUM, probs_for_choice
)
if renormalize_flag:
weights_local_final = weights_local_final / weights_selected_sum
tl.store(
moe_weights_ptr + curr_row_idx * K + k_offset,
weights_local_final,
mask=mask_topk,
)
tl.store(
selected_expert_ptr + curr_row_idx * K + k_offset,
selected_local_final,
mask=mask_topk,
)
def topk_sigmoid(
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
gating_output: torch.Tensor,
renormalize: bool = False,
correction_bias: Optional[torch.Tensor] = None,
) -> None:
"""
Compute top-k sigmoid for MoE routing.
Args:
topk_weights: Output tensor for top-k weights [num_tokens, topk]
topk_ids: Output tensor for top-k expert indices [num_tokens, topk]
gating_output: Gating logits [num_tokens, num_experts]
renormalize: Whether to renormalize the top-k weights
correction_bias: Per-expert bias correction [num_experts], must be float32 if provided
"""
num_tokens, num_experts = gating_output.shape
topk = topk_weights.shape[-1]
has_correction_bias = correction_bias is not None
block_width_up = triton.next_power_of_2(num_experts)
grid = (num_tokens,)
topk_sigmoid_triton_kernel[grid](
gating_output,
topk_ids,
topk_weights,
renormalize,
correction_bias,
has_correction_bias,
num_experts,
num_tokens,
K=topk,
BLOCK_K=triton.next_power_of_2(topk),
BLOCK_WIDTH_SIZE_UP=block_width_up,
)
@@ -0,0 +1,57 @@
from typing import TYPE_CHECKING, Callable
import torch
if TYPE_CHECKING:
from sglang.srt.hardware_backend.musa.attention.flashattention_backend import (
MusaFlashAttentionBackend,
)
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
def musa_cp_attn_forward_extend(
musa_fa_backend: "MusaFlashAttentionBackend",
forward_batch: "ForwardBatch",
q: torch.Tensor,
device: torch.device,
attn_fn: Callable[[torch.Tensor, torch.Tensor, torch.Tensor, int], torch.Tensor],
) -> torch.Tensor:
"""
Split q into prev/next zigzag halves based on CP metadata, call the
backend-specific attention function twice with appropriate per-half
metadata, and concatenate the results.
attn_fn signature:
attn_fn(q, cu_seqlens_q, cache_seqlens, max_seqlen_q) -> result
where only these four CP-varying parameters differ between halves.
All other backend-specific args should be captured in the closure.
"""
cp_meta = forward_batch.attn_cp_metadata
q_prev, q_next = torch.chunk(q, 2, dim=0)
cu_seqlens_q_prev = torch.tensor(
[0, cp_meta.actual_seq_q_prev], device=device, dtype=torch.int32
)
if hasattr(musa_fa_backend, "_current_prefix"):
musa_fa_backend._current_prefix = "forward_extend_cp_prev"
result_prev = attn_fn(
q_prev,
cu_seqlens_q_prev,
cp_meta.kv_len_prev_tensor,
cp_meta.actual_seq_q_prev,
)
cu_seqlens_q_next = torch.tensor(
[0, cp_meta.actual_seq_q_next], device=device, dtype=torch.int32
)
if hasattr(musa_fa_backend, "_current_prefix"):
musa_fa_backend._current_prefix = "forward_extend_cp_next"
result_next = attn_fn(
q_next,
cu_seqlens_q_next,
cp_meta.kv_len_next_tensor,
cp_meta.actual_seq_q_next,
)
return torch.concat([result_prev, result_next], dim=0)
@@ -0,0 +1,63 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import re
from dataclasses import replace as _dataclass_replace
import torch
import torch.fx.graph as fx_graph
_DEVICE_REPR_RE = re.compile(r"\bdevice\(type='([^']+)'(?:,\s*index=(\d+))?\)")
def _replace_device_repr(m: re.Match) -> str:
dev_type = m.group(1)
dev_index = m.group(2)
if dev_index is not None:
return f"torch.device('{dev_type}:{dev_index}')"
return f"torch.device('{dev_type}')"
def patch_fx_custom_device() -> None:
"""
Fix FX codegen serialization for non-standard devices (e.g. torch_musa).
Root cause:
torch.device is registered as a custom builtin named 'device', imported
via 'from torch import device'. repr(torch.device('musa', 0)) produces
"device(type='musa', index=0)", which is syntactically valid but fails
at runtime because torch.device does not recognize 'musa' as a type when
invoked through the standard import path.
Fix:
Post-process the generated src string, replacing all occurrences of
device(type='x', index=N) with torch.device('x:N'), and ensure 'torch'
is present in the graph globals.
Note:
_get_repr is a closure inside _gen_python_code and cannot be patched
directly, so we wrap _gen_python_code and rewrite its output instead.
"""
original = fx_graph.CodeGen._gen_python_code
def patched(self, nodes, root_module, namespace, **kwargs):
result = original(self, nodes, root_module, namespace, **kwargs)
new_src = _DEVICE_REPR_RE.sub(_replace_device_repr, result.src)
if new_src is result.src:
return result
result.globals.setdefault("torch", torch)
if hasattr(result, "_replace"):
return result._replace(src=new_src)
return _dataclass_replace(result, src=new_src)
fx_graph.CodeGen._gen_python_code = patched
@@ -0,0 +1,155 @@
from typing import TYPE_CHECKING
import torch
from sglang.srt.mem_cache.allocator import (
PagedTokenToKVPoolAllocator,
alloc_extend_naive,
)
from sglang.srt.utils import get_num_new_pages, next_power_of_2
if TYPE_CHECKING:
from sglang.srt.mem_cache.memory_pool import KVCache
class NPUPagedTokenToKVPoolAllocator(PagedTokenToKVPoolAllocator):
def __init__(
self,
size: int,
page_size: int,
dtype: torch.dtype,
device: str,
kvcache: "KVCache",
need_sort: bool,
):
super().__init__(size, page_size, dtype, device, kvcache, need_sort)
self.roundup = page_size - 1
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
num_new_pages: int = None,
):
if self.debug_mode:
assert torch.all(
(last_loc + 1) % self.page_size == prefix_lens % self.page_size
)
if num_new_pages is None:
num_new_pages_tensor = (
(seq_lens + self.roundup) // self.page_size
- (prefix_lens + self.roundup) // self.page_size
).sum()
num_new_pages_item = num_new_pages_tensor.item()
else:
num_new_pages_item = num_new_pages
if self.need_sort and num_new_pages_item > len(self.free_pages):
self.merge_and_sort_free()
if num_new_pages_item > len(self.free_pages):
return None
if num_new_pages_item < 200:
from sgl_kernel_npu.mem_cache.allocator import alloc_extend_kernel
out_indices = torch.empty(
(extend_num_tokens,),
dtype=torch.int64,
device=self.device,
)
max_num_extend_tokens = next_power_of_2(extend_num_tokens)
bs = prefix_lens.shape[0]
alloc_extend_kernel[(bs,)](
prefix_lens,
seq_lens,
last_loc,
self.free_pages,
out_indices,
next_power_of_2(bs),
self.page_size,
max_num_extend_tokens,
)
else:
out_indices = torch.empty(
(extend_num_tokens,),
dtype=torch.int32,
device=self.device,
)
alloc_extend_naive(
prefix_lens,
seq_lens,
last_loc,
self.free_pages,
out_indices,
self.page_size,
self.device,
)
if self.debug_mode:
assert len(torch.unique(out_indices)) == len(out_indices)
self.free_pages = self.free_pages[num_new_pages_item:]
return out_indices.int()
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
):
if self.debug_mode:
assert torch.all(
(last_loc + 2) % self.page_size == seq_lens % self.page_size
)
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu,
page_size=self.page_size,
decode=True,
)
if num_new_pages > len(self.free_pages):
self.merge_and_sort_free()
if num_new_pages > len(self.free_pages):
return None
need_new_pages = (seq_lens % self.page_size == 1).int()
end_new_pages = torch.cumsum(need_new_pages, 0)
start_new_pages = end_new_pages - need_new_pages
if num_new_pages == 0:
out_indices = last_loc + 1
else:
out_indices = (last_loc + 1) * (1 - need_new_pages) + self.free_pages[
start_new_pages
] * self.page_size * need_new_pages
if self.debug_mode:
assert len(torch.unique(out_indices)) == len(out_indices)
self.free_pages = self.free_pages[num_new_pages:]
return out_indices.int()
def free(self, free_index: torch.Tensor):
if free_index.numel() == 0:
return
if self.is_not_in_free_group:
device = free_index.device
free_page_indices = torch.unique(free_index.cpu() // self.page_size)
free_page_indices = free_page_indices.to(device)
if self.need_sort:
self.release_pages = torch.cat((free_page_indices, self.release_pages))
else:
self.free_pages = torch.cat((free_page_indices, self.free_pages))
else:
self.free_group.append(free_index)
if self.debug_mode:
assert len(torch.unique(self.free_pages)) == len(self.free_pages)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
from typing import Optional, Tuple, Union
import torch
from sgl_kernel_npu.fla.fused_gdn_gating import (
fused_gdn_gating_kernel_without_sigmoid,
fused_gdn_gating_npu,
)
from sgl_kernel_npu.mamba.causal_conv1d import (
causal_conv1d_fn_npu,
causal_conv1d_update_npu,
causal_conv1d_update_v2,
)
from sglang.srt.hardware_backend.npu.attention.ascend_hybrid_linear_attn_backend import (
AscendMambaAttnBackendBase,
)
from sglang.srt.layers.attention.linear.gdn_backend import GDNKernelDispatcher
from sglang.srt.layers.attention.linear.utils import (
get_linear_attn_decode_backend,
get_linear_attn_prefill_backend,
)
from sglang.srt.layers.radix_linear_attention import RadixLinearAttention
from sglang.srt.mem_cache.memory_pool import MambaPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.speculative.eagle_info import EagleDraftInput, EagleVerifyInput
fused_gdn_gating = fused_gdn_gating_npu
causal_conv1d_fn = causal_conv1d_fn_npu
causal_conv1d_update = causal_conv1d_update_npu
class AscendGDNAttnBackend(AscendMambaAttnBackendBase):
def __init__(self, model_runner: ModelRunner):
super().__init__(model_runner)
self.conv_states_shape = torch.Size(
(
*model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape[
:-2
],
model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape[-1],
model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape[-2],
)
)
decode_backend = get_linear_attn_decode_backend()
prefill_backend = get_linear_attn_prefill_backend()
self.kernel_dispatcher = GDNKernelDispatcher(decode_backend, prefill_backend)
def _prepare_mamba_track_metadata(self, forward_batch: ForwardBatch):
if self.forward_metadata.has_mamba_track_mask:
mamba_track_mask_indices = forward_batch.mamba_track_mask.nonzero(
as_tuple=True
)[0]
self.forward_metadata.conv_states_mask_indices = (
forward_batch.mamba_track_indices[mamba_track_mask_indices]
)
def prepare_gdn_inputs(
self,
bs: int,
forward_mode: ForwardMode,
spec_info: Optional[Union[EagleDraftInput, EagleVerifyInput]],
):
cache_indices = self.forward_metadata.mamba_cache_indices
self.num_accept_tokens = torch.ones(
[bs], dtype=torch.int32, device=cache_indices.device
)
self.actual_seq_lengths = torch.ones(
[bs], dtype=torch.int32, device=cache_indices.device
)
if forward_mode.is_target_verify():
seq_len = spec_info.draft_token_num
self.actual_seq_lengths = self.actual_seq_lengths * seq_len
# indices
self.ssm_state_indices = torch.arange(
cache_indices.shape[0] * seq_len,
dtype=torch.int32,
device=cache_indices.device,
)
else:
self.ssm_state_indices = cache_indices
def init_forward_metadata_out_graph(
self,
forward_batch: ForwardBatch,
in_capture: bool = False,
):
if forward_batch.forward_mode.is_draft_extend_v2():
return
super().init_forward_metadata_out_graph(forward_batch, in_capture=in_capture)
self.prepare_gdn_inputs(
forward_batch.batch_size,
forward_batch.forward_mode,
forward_batch.spec_info,
)
self._prepare_mamba_track_metadata(forward_batch)
self.graph_mode = True
def init_forward_metadata(self, forward_batch: ForwardBatch):
if forward_batch.forward_mode.is_draft_extend_v2():
return
super().init_forward_metadata(forward_batch)
self.prepare_gdn_inputs(
forward_batch.batch_size,
forward_batch.forward_mode,
forward_batch.spec_info,
)
self._prepare_mamba_track_metadata(forward_batch)
self.graph_mode = False
def forward_decode(
self,
layer: RadixLinearAttention,
forward_batch: ForwardBatch,
mixed_qkv: Union[torch.Tensor, Tuple[torch.Tensor, ...]],
a: torch.Tensor,
b: torch.Tensor,
**kwargs,
):
layer_cache = self.req_to_token_pool.mamba2_layer_cache(layer.layer_id)
conv_states = layer_cache.conv[0]
ssm_states = layer_cache.temporal
query_start_loc = self.forward_metadata.query_start_loc
cache_indices = self.forward_metadata.mamba_cache_indices
assert isinstance(mixed_qkv, torch.Tensor)
conv_states_tmp = conv_states.transpose(1, 2).clone()
mixed_qkv = causal_conv1d_update(
mixed_qkv,
conv_states_tmp,
layer.conv_weights,
layer.bias,
layer.activation,
conv_state_indices=cache_indices,
)
conv_states[:] = conv_states_tmp.transpose(1, 2)
query, key, value = torch.split(
mixed_qkv,
[layer.q_dim, layer.k_dim, layer.v_dim],
dim=-1,
)
bs = forward_batch.batch_size
query = query.view(1, bs, layer.num_q_heads, layer.head_q_dim)
key = key.view(1, bs, layer.num_k_heads, layer.head_k_dim)
value = value.view(1, bs, layer.num_v_heads, layer.head_v_dim)
core_attn_out = self.kernel_dispatcher.decode(
q=query,
k=key,
v=value,
a=a,
b=b,
A_log=layer.A_log,
dt_bias=layer.dt_bias,
ssm_states=ssm_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
)
self._track_mamba_state_decode(
forward_batch, conv_states, ssm_states, cache_indices
)
return core_attn_out
def forward_extend(
self,
layer: RadixLinearAttention,
forward_batch: ForwardBatch,
mixed_qkv: Union[torch.Tensor, Tuple[torch.Tensor, ...]],
a: torch.Tensor,
b: torch.Tensor,
**kwargs,
):
assert isinstance(mixed_qkv, torch.Tensor)
seq_len = mixed_qkv.shape[0]
is_target_verify = forward_batch.forward_mode.is_target_verify()
forward_metadata = self.forward_metadata
query_start_loc = forward_metadata.query_start_loc
cache_indices = forward_metadata.mamba_cache_indices
retrieve_next_token = forward_metadata.retrieve_next_token
retrieve_next_sibling = forward_metadata.retrieve_next_sibling
retrieve_parent_token = forward_metadata.retrieve_parent_token
mamba_cache_params = self.req_to_token_pool.mamba2_layer_cache(layer.layer_id)
conv_states = mamba_cache_params.conv[0]
ssm_states = mamba_cache_params.temporal
if is_target_verify:
assert isinstance(mamba_cache_params, MambaPool.SpeculativeState)
intermediate_state_cache = mamba_cache_params.intermediate_ssm
intermediate_conv_window_cache = (
mamba_cache_params.intermediate_conv_window[0]
)
has_initial_states = torch.ones(
seq_len // forward_batch.spec_info.draft_token_num,
dtype=torch.bool,
device=forward_batch.input_ids.device,
)
else:
has_initial_states = forward_batch.extend_prefix_lens > 0
if is_target_verify:
num_token_padding = mixed_qkv.shape[0]
if (
not self.graph_mode
and forward_batch.num_token_non_padded_cpu != num_token_padding
):
mixed_qkv = mixed_qkv[: forward_batch.num_token_non_padded_cpu]
a = a[: forward_batch.num_token_non_padded_cpu]
b = b[: forward_batch.num_token_non_padded_cpu]
seq_len = forward_batch.num_token_non_padded_cpu
batch_size = cache_indices.shape[0]
draft_token_num = forward_batch.spec_info.draft_token_num
num_accepted_tokens = torch.full(
(batch_size,),
draft_token_num,
dtype=torch.int32,
device=mixed_qkv.device,
)
mixed_qkv = causal_conv1d_update_v2(
x=mixed_qkv.view(batch_size, draft_token_num, -1).contiguous(),
conv_state=conv_states.contiguous(),
weight=layer.conv_weights.transpose(0, 1).contiguous(),
bias=layer.bias,
activation=layer.activation,
conv_state_indices=cache_indices,
num_accepted_tokens=num_accepted_tokens,
pad_slot_id=-1,
validate_data=False,
).view(seq_len, -1)
else:
mixed_qkv = mixed_qkv.transpose(0, 1)
if forward_metadata.has_mamba_track_mask:
mixed_qkv_to_track = mixed_qkv[
:, forward_metadata.track_conv_indices
].transpose(0, 1)
conv_states.transpose(1, 2)[
forward_metadata.conv_states_mask_indices
] = mixed_qkv_to_track
kernel_size = layer.conv_weights.shape[-1]
conv_states_for_prefill = conv_states[:, -(kernel_size - 1) :, :]
conv_states_tmp = conv_states_for_prefill.transpose(1, 2).contiguous()
mixed_qkv = causal_conv1d_fn(
mixed_qkv,
layer.conv_weights,
layer.bias,
activation=layer.activation,
conv_states=conv_states_tmp,
has_initial_state=has_initial_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
seq_lens_cpu=forward_batch.extend_seq_lens_cpu,
).transpose(0, 1)[:seq_len]
conv_states[:, -(kernel_size - 1) :, :] = conv_states_tmp.transpose(
1, 2
).contiguous()
if is_target_verify:
g, beta = fused_gdn_gating_kernel_without_sigmoid(
layer.A_log, a, b, layer.dt_bias
)
beta = beta.unsqueeze(0)
num_heads, head_k_dim = layer.num_q_heads, layer.head_q_dim
num_value_heads, head_v_dim = layer.num_v_heads, layer.head_v_dim
mixed_qkv_last_dim = mixed_qkv.shape[-1]
mixed_qkv = mixed_qkv.view(batch_size, -1, mixed_qkv_last_dim)
beta = beta.view(batch_size, -1, num_value_heads)
g = g.view(batch_size, -1, num_value_heads)
core_attn_out = self.fused_recurrent_gated_delta_rule_update(
mixed_qkv,
num_heads,
num_value_heads,
head_k_dim,
head_v_dim,
recurrent_state=ssm_states,
beta=beta,
g=g,
cache_indices=cache_indices,
intermediate_state=intermediate_state_cache,
)
core_attn_out = core_attn_out.view(-1, num_value_heads, head_v_dim)
if (not self.graph_mode) and core_attn_out.shape[0] < num_token_padding:
core_attn_out = torch.cat(
[
core_attn_out,
core_attn_out.new_zeros(
num_token_padding - core_attn_out.shape[0],
*core_attn_out.shape[1:],
),
],
dim=0,
)
else:
query, key, value = torch.split(
mixed_qkv,
[layer.q_dim, layer.k_dim, layer.v_dim],
dim=-1,
)
actual_seq_len = query.shape[0]
query = query.view(1, actual_seq_len, layer.num_q_heads, layer.head_q_dim)
key = key.view(1, actual_seq_len, layer.num_k_heads, layer.head_k_dim)
value = value.view(1, actual_seq_len, layer.num_v_heads, layer.head_v_dim)
g, beta = fused_gdn_gating(layer.A_log, a, b, layer.dt_bias)
core_attn_out, last_recurrent_state, h = self.kernel_dispatcher.extend(
q=query,
k=key,
v=value,
g=g,
beta=beta,
ssm_states=ssm_states,
cache_indices=cache_indices,
query_start_loc=query_start_loc,
)
if last_recurrent_state is not None:
last_recurrent_state = last_recurrent_state.to(
ssm_states.dtype, copy=False
)
ssm_states[cache_indices] = last_recurrent_state
last_recurrent_state = last_recurrent_state.to(ssm_states.dtype, copy=False)
ssm_states[cache_indices] = last_recurrent_state
if h is not None:
self._track_mamba_state_extend(
forward_batch, h, ssm_states, forward_metadata
)
return core_attn_out
def fused_recurrent_gated_delta_rule_update(
self,
mix_qkv: torch.Tensor,
num_heads,
num_value_heads,
head_k_dim,
head_v_dim,
recurrent_state: torch.Tensor,
beta: torch.Tensor,
g: torch.Tensor,
cache_indices: torch.Tensor,
intermediate_state: Optional[torch.Tensor] = None,
):
beta = beta.to(torch.bfloat16)
g = g.to(torch.float32)
batch_size = mix_qkv.shape[0]
seq_len = mix_qkv.shape[1]
scale = 1 / (head_k_dim**0.5)
if intermediate_state is not None:
intermediate_state = intermediate_state.view(
-1, num_value_heads, head_k_dim, head_v_dim
)
if self.graph_mode:
num_accept_tokens = torch.full(
[batch_size], 1, dtype=torch.int32, device=cache_indices.device
)
actual_seq_lengths = torch.full(
[batch_size], seq_len, dtype=torch.int32, device=cache_indices.device
)
ssm_state_indices = self.forward_metadata.mamba_cache_indices_gdn
else:
num_accept_tokens = self.num_accept_tokens
actual_seq_lengths = self.actual_seq_lengths
ssm_state_indices = self.ssm_state_indices
attn_core_out = torch.ops.npu.recurrent_gated_delta_rule(
mix_qkv,
recurrent_state,
beta=beta,
scale=scale,
actual_seq_lengths=actual_seq_lengths,
ssm_state_indices=ssm_state_indices.view(batch_size, seq_len),
nk=num_heads,
nv=num_value_heads,
intermediate_state=intermediate_state,
cache_indices=cache_indices,
num_accepted_tokens=num_accept_tokens,
g=g,
)
if intermediate_state is not None:
intermediate_state = intermediate_state.view(
-1, seq_len, num_value_heads, head_k_dim, head_v_dim
)
return attn_core_out
@@ -0,0 +1,320 @@
import logging
from typing import Optional, Union
import torch
from sgl_kernel_npu.mamba.mamba_state_update_triton import (
conv_state_rollback,
move_intermediate_cache,
)
from sglang.srt.layers.attention.base_attn_backend import AttentionBackend
from sglang.srt.layers.attention.hybrid_linear_attn_backend import (
HybridLinearAttnBackend,
MambaAttnBackendBase,
)
from sglang.srt.layers.attention.mamba.mamba2_metadata import (
ForwardMetadata,
)
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.speculative.eagle_info import EagleDraftInput, EagleVerifyInput
from sglang.srt.speculative.spec_info import SpecInput
logger = logging.getLogger(__name__)
class AscendMambaAttnBackendBase(MambaAttnBackendBase):
def __init__(self, model_runner: ModelRunner):
super().__init__(model_runner)
self.state_indices_list_gdn = []
def init_cuda_graph_state(self, max_bs: int, max_num_tokens: int):
assert (
max_num_tokens % max_bs == 0
), f"max_num_tokens={max_num_tokens} must be divisible by max_bs={max_bs}"
draft_token_num = max_num_tokens // max_bs
for i in range(max_bs):
self.state_indices_list.append(
torch.full(
(i + 1,), self.pad_slot_id, dtype=torch.int32, device=self.device
)
)
self.state_indices_list_gdn.append(
torch.full(
((i + 1) * draft_token_num,),
self.pad_slot_id,
dtype=torch.int32,
device=self.device,
)
)
self.query_start_loc_list.append(
torch.zeros((i + 2,), dtype=torch.int32, device=self.device)
)
self.retrieve_next_token_list.append(
torch.zeros(
(i + 1, draft_token_num), dtype=torch.int32, device=self.device
)
)
self.retrieve_next_sibling_list.append(
torch.zeros(
(i + 1, draft_token_num), dtype=torch.int32, device=self.device
)
)
self.retrieve_parent_token_list.append(
torch.zeros(
(i + 1, draft_token_num), dtype=torch.int32, device=self.device
)
)
self.cached_cuda_graph_decode_query_start_loc = torch.arange(
0, max_bs + 1, dtype=torch.int32, device=self.device
)
self.cached_cuda_graph_verify_query_start_loc = torch.arange(
0,
max_bs * draft_token_num + 1,
step=draft_token_num,
dtype=torch.int32,
device=self.device,
)
def _capture_metadata(
self,
bs: int,
req_pool_indices: torch.Tensor,
forward_mode: ForwardMode,
spec_info: Optional[Union[EagleDraftInput, EagleVerifyInput]],
):
mamba_indices = self.req_to_token_pool.get_mamba_indices(req_pool_indices)
self.state_indices_list[bs - 1][: len(mamba_indices)].copy_(mamba_indices)
if forward_mode.is_decode_or_idle():
self.query_start_loc_list[bs - 1].copy_(
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]
)
ssm_state_indices = torch.arange(
mamba_indices.shape[0] * spec_info.draft_token_num,
dtype=torch.int32,
device=mamba_indices.device,
)
self.state_indices_list_gdn[bs - 1][
: len(mamba_indices) * spec_info.draft_token_num
].copy_(ssm_state_indices)
else:
raise ValueError(f"Invalid forward mode: {forward_mode=}")
# If topk > 1, we need to use retrieve_next_token and retrieve_next_sibling to handle the eagle tree custom attention mask
if forward_mode.is_target_verify() and spec_info.topk > 1:
# They are None during cuda graph capture so skip the copy_...
# self.retrieve_next_token_list[bs - 1].copy_(spec_info.retrive_next_token)
# self.retrieve_next_sibling_list[bs - 1].copy_(spec_info.retrive_next_sibling)
return ForwardMetadata(
query_start_loc=self.query_start_loc_list[bs - 1],
mamba_cache_indices=self.state_indices_list[bs - 1],
retrieve_next_token=self.retrieve_next_token_list[bs - 1],
retrieve_next_sibling=self.retrieve_next_sibling_list[bs - 1],
retrieve_parent_token=self.retrieve_parent_token_list[bs - 1],
)
else:
return ForwardMetadata(
query_start_loc=self.query_start_loc_list[bs - 1],
mamba_cache_indices=self.state_indices_list[bs - 1],
mamba_cache_indices_gdn=self.state_indices_list_gdn[bs - 1],
)
def _replay_metadata(
self,
bs: int,
req_pool_indices: torch.Tensor,
forward_mode: ForwardMode,
spec_info: Optional[SpecInput],
seq_lens_cpu: Optional[torch.Tensor],
num_padding: Optional[int] = None,
in_capture: bool = False,
mamba_track_indices: Optional[torch.Tensor] = None,
*args,
**kwargs,
):
# out_graph passes seq_lens_cpu=None at capture; mirror the base guard.
if seq_lens_cpu is None:
num_padding = 0
else:
num_padding = torch.count_nonzero(
seq_lens_cpu == self.get_cuda_graph_seq_len_fill_value()
)
# Make sure forward metadata is correctly handled for padding reqs
req_pool_indices[bs - num_padding :] = 0
mamba_indices = self.req_to_token_pool.get_mamba_indices(req_pool_indices)
mamba_indices[bs - num_padding :] = 0
self.state_indices_list[bs - 1][: len(mamba_indices)].copy_(mamba_indices)
track_buf = None
if mamba_track_indices is not None:
track_buf = mamba_track_indices
if forward_mode.is_decode_or_idle():
if num_padding == 0:
self.query_start_loc_list[bs - 1].copy_(
self.cached_cuda_graph_decode_query_start_loc[: bs + 1]
)
else:
self.query_start_loc_list[bs - 1][: bs - num_padding].copy_(
self.cached_cuda_graph_decode_query_start_loc[: bs - num_padding]
)
self.query_start_loc_list[bs - 1][bs - num_padding :].fill_(
bs - num_padding
)
elif forward_mode.is_target_verify():
ssm_state_indices = torch.arange(
bs * spec_info.draft_token_num,
dtype=torch.int32,
device=mamba_indices.device,
)
self.state_indices_list_gdn[bs - 1].copy_(ssm_state_indices)
if num_padding == 0:
self.query_start_loc_list[bs - 1].copy_(
self.cached_cuda_graph_verify_query_start_loc[: bs + 1]
)
else:
self.query_start_loc_list[bs - 1][: bs - num_padding].copy_(
self.cached_cuda_graph_verify_query_start_loc[: bs - num_padding]
)
self.query_start_loc_list[bs - 1][bs - num_padding :].fill_(
(bs - num_padding) * spec_info.draft_token_num
)
else:
raise ValueError(f"Invalid forward mode: {forward_mode=}")
# If topk > 1, we need to use retrieve_next_token and retrieve_next_sibling to handle the eagle tree custom attention mask
if forward_mode.is_target_verify() and spec_info.topk > 1:
bs_without_pad = spec_info.retrive_next_token.shape[0]
self.retrieve_next_token_list[bs - 1][:bs_without_pad].copy_(
spec_info.retrive_next_token
)
self.retrieve_next_sibling_list[bs - 1][:bs_without_pad].copy_(
spec_info.retrive_next_sibling
)
return ForwardMetadata(
query_start_loc=self.query_start_loc_list[bs - 1],
mamba_cache_indices=self.state_indices_list[bs - 1],
mamba_track_indices=track_buf,
retrieve_next_token=self.retrieve_next_token_list[bs - 1],
retrieve_next_sibling=self.retrieve_next_sibling_list[bs - 1],
retrieve_parent_token=self.retrieve_parent_token_list[bs - 1],
)
else:
return ForwardMetadata(
query_start_loc=self.query_start_loc_list[bs - 1],
mamba_cache_indices=self.state_indices_list[bs - 1],
mamba_cache_indices_gdn=self.state_indices_list_gdn[bs - 1],
mamba_track_indices=track_buf,
)
def get_cuda_graph_seq_len_fill_value(self):
return 0 # Mamba attn does not use seq lens to index kv cache
class AscendMamba2AttnBackend(AscendMambaAttnBackendBase):
pass
class AscendHybridLinearAttnBackend(HybridLinearAttnBackend):
def __init__(
self,
full_attn_backend: AttentionBackend,
linear_attn_backend: AscendMambaAttnBackendBase,
full_attn_layers: list[int],
):
super().__init__(full_attn_backend, linear_attn_backend, full_attn_layers)
def update_mamba_state_after_mtp_verify(
self,
last_correct_step_indices: torch.Tensor,
mamba_track_indices: Optional[torch.Tensor],
mamba_steps_to_track: Optional[torch.Tensor],
model,
):
"""
Update mamba states after MTP verify using fully fused Triton kernel.
This replaces the original advanced indexing operations with a single fused
gather-scatter kernel that also handles masking internally, avoiding:
- index_elementwise_kernel from tensor[bool_mask]
- index_select kernel launches
- nonzero kernel launches
"""
request_number = last_correct_step_indices.shape[0]
state_indices_tensor = (
self.linear_attn_backend.forward_metadata.mamba_cache_indices[
:request_number
]
)
mamba_caches = (
self.linear_attn_backend.req_to_token_pool.get_speculative_mamba2_params_all_layers()
)
conv_states = mamba_caches.conv[0]
ssm_states = mamba_caches.temporal
intermediate_state_cache = mamba_caches.intermediate_ssm
dst_indices_tensor = state_indices_tensor.to(torch.int64) # [N]
src_indices_tensor = torch.arange(
dst_indices_tensor.shape[0],
device=dst_indices_tensor.device,
dtype=torch.int64,
)
last_steps = last_correct_step_indices.to(torch.int64) # [N]
move_intermediate_cache(
ssm_states,
intermediate_state_cache,
dst_indices_tensor,
src_indices_tensor,
last_steps,
)
draft_token_num = intermediate_state_cache.shape[2]
if mamba_track_indices is not None:
assert mamba_steps_to_track is not None
mamba_track_indices = mamba_track_indices.to(torch.int64)
mamba_steps_to_track = mamba_steps_to_track.to(torch.int64)
move_intermediate_cache(
ssm_states,
intermediate_state_cache,
mamba_track_indices,
src_indices_tensor,
mamba_steps_to_track,
)
track_mask = mamba_steps_to_track >= 0
# Track conv state from the verify-time window before rolling back
# the working slot; NPU does not keep per-step conv intermediates.
track_indices = mamba_track_indices[track_mask]
if track_indices.numel() > 0:
conv_states[:, track_indices] = conv_states[
:, dst_indices_tensor[track_mask]
]
if dst_indices_tensor.numel() > 0:
conv_state_rollback(
conv_states,
dst_indices_tensor,
last_steps,
draft_token_num,
)
if mamba_track_indices is not None and mamba_track_indices.numel() > 0:
conv_state_rollback(
conv_states,
mamba_track_indices,
mamba_steps_to_track,
draft_token_num,
)
return
def update_verify_buffers_to_fill_after_draft(
self, spec_info: SpecInput, cuda_graph_bs: Optional[int]
):
pass
@@ -0,0 +1,336 @@
from __future__ import annotations
import math
from typing import Optional
import torch
from torch.nn.functional import scaled_dot_product_attention
class AscendTorchNativeAttnBackend:
def __init__(self):
pass
def scaled_dot_product_attention_with_softcapping(
self,
query,
key,
value,
attn_mask=None,
is_causal=False,
scale=None,
enable_gqa=False,
logit_cap=0.0,
logit_capping_method="tanh",
) -> torch.Tensor:
L, S = query.size(-2), key.size(-2)
scale_factor = 1 / math.sqrt(query.size(-1)) if scale is None else scale
attn_bias = torch.zeros(L, S, dtype=query.dtype, device=query.device)
if is_causal:
assert attn_mask is None
temp_mask = torch.ones(L, S, dtype=torch.bool, device=query.device).tril(
diagonal=0
)
attn_bias.masked_fill_(temp_mask.logical_not(), float("-inf"))
attn_bias.to(query.dtype)
if attn_mask is not None:
if attn_mask.dtype == torch.bool:
attn_bias.masked_fill_(attn_mask.logical_not(), float("-inf"))
else:
attn_bias = attn_mask + attn_bias
if enable_gqa:
key = key.repeat_interleave(query.size(-3) // key.size(-3), -3)
value = value.repeat_interleave(query.size(-3) // value.size(-3), -3)
attn_weight = query @ key.transpose(-2, -1) * scale_factor
if logit_cap > 0:
if logit_capping_method == "tanh":
attn_weight = logit_cap * torch.tanh(attn_weight / logit_cap)
attn_weight += attn_bias
attn_weight = torch.softmax(attn_weight, dim=-1)
return attn_weight @ value
def run_sdpa_forward_extend(
self,
query: torch.Tensor,
output: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
req_to_token: torch.Tensor,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
extend_prefix_lens: torch.Tensor,
extend_seq_lens: torch.Tensor,
encoder_lens: torch.Tensor = None,
is_cross_attention: bool = False,
scaling=None,
enable_gqa=False,
causal=False,
sliding_window_size: int = -1,
full_to_swa_mapping: Optional[torch.Tensor] = None,
logit_cap: float = 0.0,
logit_capping_method: str = "tanh",
):
"""Run the extend forward by using torch native sdpa op.
Args:
query: [num_tokens, num_heads, head_size]
output: [num_tokens, num_heads, head_size]
k_cache: [max_total_num_tokens, num_heads, head_size]
v_cache: [max_total_num_tokens, num_heads, head_size]
req_to_token: [max_num_reqs, max_context_len]
req_pool_indices: [num_seqs]
seq_lens: [num_seqs]
extend_prefix_lens: [num_seqs]
extend_seq_lens: [num_seqs]
encoder_lens: [num_seqs]
is_cross_attention: [bool]
scaling: float or None
enable_gqa: bool
causal: bool
sliding_window_size: int, -1 means no sliding window
full_to_swa_mapping: mapping from full pool index to SWA pool index,
required for SWA layers to translate req_to_token indices
Returns:
output: [num_tokens, num_heads, head_size]
"""
assert seq_lens.shape[0] == extend_prefix_lens.shape[0]
assert seq_lens.shape[0] == extend_seq_lens.shape[0]
# [num_tokens, num_heads, head_size] -> [num_heads, num_tokens, head_size]
query = query.movedim(0, query.dim() - 2)
start_q, start_kv = 0, 0
for seq_idx in range(seq_lens.shape[0]):
# Need optimize the performance later.
extend_seq_len_q = int(extend_seq_lens[seq_idx].item())
prefill_seq_len_q = int(extend_prefix_lens[seq_idx].item())
seq_len_kv = int(seq_lens[seq_idx].item())
end_q = start_q + extend_seq_len_q
end_kv = start_kv + seq_len_kv
atten_start_kv = 0
atten_end_kv = seq_len_kv
# support cross attention
if encoder_lens is not None:
if is_cross_attention:
atten_end_kv = int(encoder_lens[seq_idx].item())
else:
atten_start_kv = int(encoder_lens[seq_idx].item())
atten_end_kv = atten_start_kv + extend_seq_len_q
is_swa_self_attn = (
sliding_window_size is not None
and sliding_window_size > -1
and encoder_lens is None
)
if is_swa_self_attn:
# For extend, the sliding window must be anchored at the first
# query token in this chunk rather than the final sequence
# length. Otherwise a large extend chunk can no longer fit in
# the cropped query suffix, which breaks the native fallback.
atten_start_kv = max(
prefill_seq_len_q - sliding_window_size, atten_start_kv
)
per_req_query = query[:, start_q:end_q, :]
# SWA crops the front of the KV window, so the redundant query
# tensor must match the cropped window to keep Q.len == K.len for
# the causal mask. In cross-attention (and non-SWA self-attention)
# the original sizing — text seq len — must be preserved, since Q
# (text) and KV (encoder) lengths legitimately differ there.
if is_swa_self_attn:
redundant_len = atten_end_kv - atten_start_kv
query_start_idx = max(prefill_seq_len_q - atten_start_kv, 0)
else:
redundant_len = int(seq_lens[seq_idx].item())
query_start_idx = prefill_seq_len_q
per_req_query_redundant = torch.zeros(
(per_req_query.shape[0], redundant_len, per_req_query.shape[2]),
dtype=per_req_query.dtype,
device=per_req_query.device,
)
per_req_query_redundant[:, query_start_idx:, :] = per_req_query
# get key and value from cache. per_req_tokens contains the kv cache
# index for each token in the sequence.
req_pool_idx = req_pool_indices[seq_idx]
per_req_tokens = req_to_token[req_pool_idx, atten_start_kv:atten_end_kv]
# For SWA layers, k_cache/v_cache are from the SWA pool but
# req_to_token stores full pool indices. Translate before indexing.
if full_to_swa_mapping is not None:
per_req_tokens = full_to_swa_mapping[per_req_tokens]
per_req_key = k_cache[per_req_tokens].movedim(0, query.dim() - 2)
per_req_value = v_cache[per_req_tokens].movedim(0, query.dim() - 2)
if not (per_req_query.dtype == per_req_key.dtype == per_req_value.dtype):
# scaled_dot_product_attention() expects query, key, and value to have the same dtype
per_req_key = per_req_key.to(per_req_query.dtype)
per_req_value = per_req_value.to(per_req_query.dtype)
if logit_cap > 0:
per_req_out_redundant = (
self.scaled_dot_product_attention_with_softcapping(
per_req_query_redundant.unsqueeze(0),
per_req_key.unsqueeze(0),
per_req_value.unsqueeze(0),
enable_gqa=enable_gqa,
scale=scaling,
is_causal=causal,
logit_cap=logit_cap,
logit_capping_method=logit_capping_method,
)
.squeeze(0)
.movedim(query.dim() - 2, 0)
)
else:
per_req_out_redundant = (
scaled_dot_product_attention(
per_req_query_redundant.unsqueeze(0),
per_req_key.unsqueeze(0),
per_req_value.unsqueeze(0),
enable_gqa=enable_gqa,
scale=scaling,
is_causal=causal,
)
.squeeze(0)
.movedim(query.dim() - 2, 0)
)
output[start_q:end_q, :, :] = per_req_out_redundant[
query_start_idx : query_start_idx + extend_seq_len_q, :, :
]
start_q, start_kv = end_q, end_kv
return output
def run_sdpa_forward_decode(
self,
query: torch.Tensor,
output: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
req_to_token: torch.Tensor,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
encoder_lens: torch.Tensor = None,
is_cross_attention: bool = False,
scaling=None,
enable_gqa=False,
causal=False,
sliding_window_size: int = -1,
full_to_swa_mapping: Optional[torch.Tensor] = None,
logit_cap: float = 0.0,
logit_capping_method: str = "tanh",
):
"""Run the decode forward by using torch native sdpa op.
Args:
query: [num_tokens, num_heads, head_size]
output: [num_tokens, num_heads, head_size]
k_cache: [max_total_num_tokens, num_heads, head_size]
v_cache: [max_total_num_tokens, num_heads, head_size]
req_to_token: [max_num_reqs, max_context_len]
req_pool_indices: [num_seqs]
seq_lens: [num_seqs]
encoder_lens: [num_seqs]
is_cross_attention: [bool]
scaling: float or None
enable_gqa: bool
causal: bool
Returns:
output: [num_tokens, num_heads, head_size]
"""
# [num_tokens, num_heads, head_size] -> [num_heads, num_tokens, head_size]
query = query.movedim(0, query.dim() - 2)
start_q, start_kv = 0, 0
for seq_idx in range(seq_lens.shape[0]):
# Need optimize the performance later.
seq_len_q = 1
seq_len_kv = int(seq_lens[seq_idx].item())
end_q = start_q + seq_len_q
end_kv = start_kv + seq_len_kv
atten_start_kv = 0
atten_end_kv = seq_len_kv
# support cross attention
if encoder_lens is not None:
if is_cross_attention:
atten_end_kv = int(encoder_lens[seq_idx].item())
else:
atten_start_kv = int(encoder_lens[seq_idx].item())
atten_end_kv = atten_start_kv + seq_len_kv
if (
sliding_window_size is not None
and sliding_window_size > -1
and encoder_lens is None
):
atten_start_kv = max(
atten_end_kv - (sliding_window_size + 1), atten_start_kv
)
per_req_query = query[:, start_q:end_q, :]
# get key and value from cache. per_req_tokens contains the kv cache
# index for each token in the sequence.
req_pool_idx = req_pool_indices[seq_idx]
per_req_tokens = req_to_token[req_pool_idx, atten_start_kv:atten_end_kv]
# For SWA layers, k_cache/v_cache are from the SWA pool but
# req_to_token stores full pool indices. Translate before indexing.
if full_to_swa_mapping is not None:
per_req_tokens = full_to_swa_mapping[per_req_tokens]
per_req_key = k_cache[per_req_tokens].movedim(0, query.dim() - 2)
per_req_value = v_cache[per_req_tokens].movedim(0, query.dim() - 2)
if not (per_req_query.dtype == per_req_key.dtype == per_req_value.dtype):
# scaled_dot_product_attention() expects query, key, and value to have the same dtype
per_req_key = per_req_key.to(per_req_query.dtype)
per_req_value = per_req_value.to(per_req_query.dtype)
if logit_cap > 0:
per_req_out = (
self.scaled_dot_product_attention_with_softcapping(
per_req_query.unsqueeze(0),
per_req_key.unsqueeze(0),
per_req_value.unsqueeze(0),
enable_gqa=enable_gqa,
scale=scaling,
is_causal=causal,
logit_cap=logit_cap,
logit_capping_method=logit_capping_method,
)
.squeeze(0)
.movedim(query.dim() - 2, 0)
)
else:
per_req_out = (
scaled_dot_product_attention(
per_req_query.unsqueeze(0),
per_req_key.unsqueeze(0),
per_req_value.unsqueeze(0),
enable_gqa=enable_gqa,
scale=scaling,
is_causal=causal,
)
.squeeze(0)
.movedim(query.dim() - 2, 0)
)
output[start_q:end_q, :, :] = per_req_out
start_q, start_kv = end_q, end_kv
return output
def support_triton(self):
return False
@@ -0,0 +1,493 @@
import re
from functools import lru_cache
from typing import TYPE_CHECKING, Optional
import torch
import torch.nn.functional as F
from sglang.srt.hardware_backend.npu.utils import npu_format_cast
from sglang.srt.model_executor.forward_context import (
get_attn_backend,
get_token_to_kv_pool,
)
from sglang.srt.utils import get_bool_env_var
if TYPE_CHECKING:
from sglang.srt.layers.quantization.base_config import QuantizationConfig
@lru_cache(maxsize=1)
def is_mla_preprocess_enabled() -> bool:
return get_bool_env_var("SGLANG_NPU_USE_MLAPO")
@lru_cache(maxsize=1)
def is_fia_nz() -> bool:
is_fia_nz_ = get_bool_env_var("SGLANG_USE_FIA_NZ")
if is_fia_nz_:
assert (
is_mla_preprocess_enabled()
), "SGLANG_USE_FIA_NZ must be enable with SGLANG_NPU_USE_MLAPO"
return is_fia_nz_
def round_up(val: int, align: int) -> int:
if align == 0:
return 0
return -(val // -align) * align
def transdata(nd_mat, block_size: tuple = (16, 16)):
r = round_up(nd_mat.shape[0], block_size[0])
c = round_up(nd_mat.shape[1], block_size[1])
r_pad = r - nd_mat.shape[0]
c_pad = c - nd_mat.shape[1]
nd_mat = F.pad(nd_mat, ((0, r_pad, 0, c_pad)))
nz_mat = torch.permute(
torch.reshape(
nd_mat,
(r // block_size[0], block_size[0], c // block_size[1], block_size[1]),
),
[2, 0, 1, 3],
)
nz_mat = torch.reshape(
nz_mat, (nz_mat.shape[0], nz_mat.shape[1] * nz_mat.shape[2], nz_mat.shape[3])
)
return nz_mat
def trans_rope_weight(weight, rope_dim):
weight_1 = weight[..., -rope_dim::2, :].contiguous()
weight_2 = weight[..., -rope_dim + 1 :: 2, :].contiguous()
weight[..., -rope_dim:, :] = torch.cat([weight_1, weight_2], dim=-2)
return weight.contiguous()
class NPUFusedMLAPreprocess(torch.nn.Module):
def __init__(
self,
fused_qkv_a_proj_with_mqa,
q_a_layernorm,
kv_a_layernorm,
q_b_proj,
w_kc,
rotary_emb,
layer_id,
num_local_heads,
qk_nope_head_dim,
qk_rope_head_dim,
v_head_dim,
quant_config: Optional["QuantizationConfig"] = None,
):
super().__init__()
self.qkv_a_proj = fused_qkv_a_proj_with_mqa
self.q_a_layernorm = q_a_layernorm
self.kv_a_layernorm = kv_a_layernorm
self.q_b_proj = q_b_proj
self.w_kc = w_kc.contiguous()
self.rotary_emb = rotary_emb
self.layer_id = layer_id
self.quant_config = quant_config
self.has_preprocess_weights = False
self.dtype = None
self.q_lora_rank = self.q_b_proj.input_size # 1536
self.kv_lora_rank = self.kv_a_layernorm.hidden_size # 512
self.num_local_heads = num_local_heads # tp
self.qk_nope_head_dim = qk_nope_head_dim # 128
self.qk_rope_head_dim = qk_rope_head_dim # 64
self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
self.v_head_dim = v_head_dim
self.q_b_proj_weight_scale = self.q_b_proj.weight_scale.view(1, -1).to(
torch.float
)
def preprocess_weights(self, hidden_states):
self.dummy = torch.zeros(
(hidden_states.shape[-1]),
dtype=hidden_states.dtype,
device=hidden_states.device,
)
self.qkv_a_proj_input_offset = self.qkv_a_proj.input_offset.to(dtype=torch.int8)
self.q_b_proj_input_offset = self.q_b_proj.input_offset.to(dtype=torch.int8)
# matmul_0 weight [7168, 2112]
fused_qkv_a_proj_with_mqa_weight_q = self.qkv_a_proj.weight.data[
:, : self.q_lora_rank
].clone() # [7168, 1536]
fused_qkv_a_proj_with_mqa_weight_kv = self.qkv_a_proj.weight.data[
:, self.q_lora_rank :
].clone() # [7168, 576]
# rope fit
fused_qkv_a_proj_with_mqa_weight_kv_t = (
fused_qkv_a_proj_with_mqa_weight_kv.t().contiguous()
)
fused_qkv_a_proj_with_mqa_weight_kv_t = trans_rope_weight(
fused_qkv_a_proj_with_mqa_weight_kv_t, self.qk_rope_head_dim
)
fused_qkv_a_proj_with_mqa_weight_kv = (
fused_qkv_a_proj_with_mqa_weight_kv_t.t().contiguous()
)
# cat nz
fused_qkv_a_proj_with_mqa_weight_new = torch.cat(
(fused_qkv_a_proj_with_mqa_weight_kv, fused_qkv_a_proj_with_mqa_weight_q),
dim=-1,
)
fused_qkv_a_proj_with_mqa_weight = (
fused_qkv_a_proj_with_mqa_weight_new.t().contiguous()
)
fused_qkv_a_proj_with_mqa_weight_nz = (
transdata(fused_qkv_a_proj_with_mqa_weight, block_size=(16, 32))
.unsqueeze(0)
.contiguous()
)
self.qkv_a_proj_weight_nz = npu_format_cast(fused_qkv_a_proj_with_mqa_weight_nz)
# matmul_0 deq_scale [2112]
fused_qkv_a_proj_with_mqa_deq_scale_q = self.qkv_a_proj.deq_scale.data[
: self.q_lora_rank
].clone() # [7168, 1536]
fused_qkv_a_proj_with_mqa_deq_scale_kv = self.qkv_a_proj.deq_scale.data[
self.q_lora_rank :
].clone() # [7168, 576]
# rope fit
fused_qkv_a_proj_with_mqa_deq_scale_kv = (
fused_qkv_a_proj_with_mqa_deq_scale_kv.reshape(
self.kv_lora_rank + self.qk_rope_head_dim, -1
).contiguous()
)
fused_qkv_a_proj_with_mqa_deq_scale_kv = trans_rope_weight(
fused_qkv_a_proj_with_mqa_deq_scale_kv, self.qk_rope_head_dim
)
fused_qkv_a_proj_with_mqa_deq_scale_kv = (
fused_qkv_a_proj_with_mqa_deq_scale_kv.view(
self.kv_lora_rank + self.qk_rope_head_dim
).contiguous()
)
self.qkv_a_proj_deq_scale_kvq = torch.cat(
(
fused_qkv_a_proj_with_mqa_deq_scale_kv,
fused_qkv_a_proj_with_mqa_deq_scale_q,
),
dim=-1,
)
# matmul_0 quant_bias [2112]
fused_qkv_a_proj_with_mqa_quant_bias_q = self.qkv_a_proj.quant_bias.data[
: self.q_lora_rank
].clone() # [7168, 1536]
fused_qkv_a_proj_with_mqa_quant_bias_kv = self.qkv_a_proj.quant_bias.data[
self.q_lora_rank :
].clone() # [7168, 576]
# rope fit
fused_qkv_a_proj_with_mqa_quant_bias_kv = (
fused_qkv_a_proj_with_mqa_quant_bias_kv.reshape(
self.kv_lora_rank + self.qk_rope_head_dim, -1
).contiguous()
)
fused_qkv_a_proj_with_mqa_quant_bias_kv = trans_rope_weight(
fused_qkv_a_proj_with_mqa_quant_bias_kv, self.qk_rope_head_dim
)
fused_qkv_a_proj_with_mqa_quant_bias_kv = (
fused_qkv_a_proj_with_mqa_quant_bias_kv.view(
self.kv_lora_rank + self.qk_rope_head_dim
).contiguous()
)
self.qkv_a_proj_quant_bias_kvq = torch.cat(
(
fused_qkv_a_proj_with_mqa_quant_bias_kv,
fused_qkv_a_proj_with_mqa_quant_bias_q,
),
dim=-1,
)
# matmul_1 weight [1536, num_head * 192]
q_b_proj_weight = self.q_b_proj.weight.data.clone()
q_b_proj_weight = q_b_proj_weight.t().reshape(
self.num_local_heads, self.qk_nope_head_dim + self.qk_rope_head_dim, -1
)
q_b_proj_weight = trans_rope_weight(q_b_proj_weight, self.qk_rope_head_dim)
q_b_proj_weight = q_b_proj_weight.reshape(
self.num_local_heads * (self.qk_nope_head_dim + self.qk_rope_head_dim), -1
)
q_b_proj_weight_nz = (
transdata(q_b_proj_weight, block_size=(16, 32)).unsqueeze(0).contiguous()
)
self.q_b_proj_weight_nz = npu_format_cast(q_b_proj_weight_nz)
# matmul_1 deq_scale [num_head * 192]
q_b_proj_deq_scale = self.q_b_proj.deq_scale.data.clone()
q_b_proj_deq_scale = q_b_proj_deq_scale.reshape(
self.num_local_heads, self.qk_nope_head_dim + self.qk_rope_head_dim, -1
)
q_b_proj_deq_scale = trans_rope_weight(
q_b_proj_deq_scale, self.qk_rope_head_dim
)
self.q_b_proj_deq_scale = q_b_proj_deq_scale.reshape(
self.num_local_heads * (self.qk_nope_head_dim + self.qk_rope_head_dim)
)
# matmul_1 quant_bias [num_head * 192]
q_b_proj_quant_bias = self.q_b_proj.quant_bias.data.clone()
q_b_proj_quant_bias = q_b_proj_quant_bias.reshape(
self.num_local_heads, self.qk_nope_head_dim + self.qk_rope_head_dim, -1
)
q_b_proj_quant_bias = trans_rope_weight(
q_b_proj_quant_bias, self.qk_rope_head_dim
)
self.q_b_proj_quant_bias = q_b_proj_quant_bias.reshape(
self.num_local_heads * (self.qk_nope_head_dim + self.qk_rope_head_dim)
)
def mlaprolog_preprocess_weight(self):
self.qkv_a_proj.weight.data = self.qkv_a_proj.weight.data.transpose(0, 1)
qkv_a_proj_weight_q = self.qkv_a_proj.weight.data[:, : self.q_lora_rank].clone()
qkv_a_proj_weight_kv = self.qkv_a_proj.weight.data[
:, self.q_lora_rank :
].clone()
self.q_a_proj_weight = npu_format_cast(qkv_a_proj_weight_q)
self.kv_a_proj_weight = npu_format_cast(qkv_a_proj_weight_kv)
def get_sin_cos(self, positions):
cos_sin = self.rotary_emb.cos_sin_cache[positions]
cos, sin = cos_sin.chunk(2, dim=-1)
cos = cos.repeat(1, 2)
sin = sin.repeat(1, 2)
return cos, sin
def get_kv_cache_and_cache_idx(self, forward_batch):
k_cache, v_cache = get_token_to_kv_pool().get_kv_buffer(self.layer_id)
slot_mapping = forward_batch.out_cache_loc.to(dtype=torch.int32)
return k_cache, v_cache, slot_mapping
def forward_absorb_prepare_npu_rms_norm_cache(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch,
zero_allocator,
):
bsz, _ = hidden_states.view(-1, hidden_states.shape[-1]).shape
self.dtype = hidden_states.dtype
if self.layer_id == 0:
self.cos, self.sin = self.get_sin_cos(positions)
self.rotary_emb.cos_cached, self.rotary_emb.sin_cache = self.cos, self.sin
else:
self.cos, self.sin = self.rotary_emb.cos_cached, self.rotary_emb.sin_cache
self.kvCache, self.kvCacheRope, self.slotmapping = (
self.get_kv_cache_and_cache_idx(forward_batch)
)
if not self.has_preprocess_weights:
self.has_preprocess_weights = True
cos, sin = self.cos, self.sin
if self.q_lora_rank is not None:
fused_qkv_a_proj_out = self.qkv_a_proj(hidden_states)[0]
q_lowrank, latent_cache = fused_qkv_a_proj_out.split(
[self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim], dim=-1
)
q = self.q_a_layernorm(q_lowrank)
q = self.q_b_proj(q)[0].view(-1, self.num_local_heads, self.qk_head_dim)
else:
q = self.q_proj(hidden_states)[0].view(
-1, self.num_local_heads, self.qk_head_dim
)
latent_cache = self.kv_a_proj_with_mqa(hidden_states)[0]
q_nope, q_pe = torch.split(
q, [self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1
) # b*s,n,d
q_nope = q_nope.view(-1, self.num_local_heads, self.qk_nope_head_dim)
q_nope = torch.matmul(q_nope.transpose(0, 1), self.w_kc).transpose(0, 1)
q_pe = q_pe.view(-1, self.num_local_heads, 1, self.qk_rope_head_dim)
cos = cos.view(-1, 1, 1, self.qk_rope_head_dim)
sin = sin.view(-1, 1, 1, self.qk_rope_head_dim)
q_pe = torch.ops.npu.npu_interleave_rope(q_pe, cos, sin) # (B,N,S,D)
q_pe = q_pe.view(cos.shape[0], self.num_local_heads, self.qk_rope_head_dim)
latent_cache = latent_cache.view(
-1, 1, 1, self.kv_lora_rank + self.qk_rope_head_dim
) # (B*S,N,1,D)
cache_mode = "PA_NZ" if is_fia_nz() else "PA_BNSD"
self.kvCache = self.kvCache.view(
-1,
get_attn_backend().page_size,
1,
get_attn_backend().kv_lora_rank,
)
self.kvCacheRope = self.kvCacheRope.view(
-1,
get_attn_backend().page_size,
1,
get_attn_backend().qk_rope_head_dim,
)
k_rope, k_nope, _, _ = torch.ops.npu.npu_kv_rmsnorm_rope_cache(
latent_cache,
self.kv_a_layernorm.weight,
cos,
sin,
self.slotmapping.to(torch.int64),
self.kvCacheRope,
self.kvCache,
epsilon=self.kv_a_layernorm.variance_epsilon,
cache_mode=cache_mode,
)
return (q_pe, k_rope, q_nope, k_nope, forward_batch, zero_allocator, positions)
def forward_mlapo(self, positions, hidden_states, forward_batch, zero_allocator):
input_dtype = hidden_states.dtype
if not self.has_preprocess_weights:
self.preprocess_weights(hidden_states)
self.has_preprocess_weights = True
self.dtype = hidden_states.dtype
if self.layer_id == 0:
cos, sin = self.get_sin_cos(positions)
self.rotary_emb.cos_cached, self.rotary_emb.sin_cache = cos, sin
else:
cos, sin = self.rotary_emb.cos_cached, self.rotary_emb.sin_cache
k_cache, v_cache, slot_mapping = self.get_kv_cache_and_cache_idx(forward_batch)
q_nope_out = torch.empty(
(hidden_states.shape[0], self.w_kc.shape[0], k_cache.shape[-1]),
dtype=input_dtype,
device=hidden_states.device,
)
q_rope_out = torch.empty(
(hidden_states.shape[0], self.w_kc.shape[0], v_cache.shape[-1]),
dtype=input_dtype,
device=hidden_states.device,
)
if is_fia_nz():
kv_shape, kv_rope_shape = k_cache.shape, v_cache.shape
num_blocks, block_size, num_heads, _ = kv_shape
k_cache = k_cache.view(
num_blocks, num_heads * self.kv_lora_rank // 16, block_size, 16
)
v_cache = v_cache.view(
num_blocks, num_heads * self.qk_rope_head_dim // 16, block_size, 16
)
# TODO: dummy inputs to be removed
# https://github.com/sgl-project/sgl-kernel-npu/issues/78
if hasattr(self.q_a_layernorm, "bias"):
q_a_layernorm_bias = self.q_a_layernorm.bias
else:
q_a_layernorm_bias = self.dummy
torch.ops.npu.mla_preprocess(
hidden_states,
self.dummy,
self.dummy,
self.qkv_a_proj_weight_nz,
self.qkv_a_proj_deq_scale_kvq,
self.q_a_layernorm.weight,
q_a_layernorm_bias,
self.q_b_proj_weight_nz,
self.q_b_proj_deq_scale,
self.kv_a_layernorm.weight,
cos,
sin,
self.w_kc,
k_cache,
v_cache,
slot_mapping,
quant_scale0=self.qkv_a_proj.input_scale,
quant_offset0=self.qkv_a_proj_input_offset,
bias0=self.qkv_a_proj_quant_bias_kvq,
quant_scale1=self.q_b_proj.input_scale,
quant_offset1=self.q_b_proj_input_offset,
bias1=self.q_b_proj_quant_bias,
cache_mode="nzcache" if is_fia_nz() else "krope_ctkv",
quant_mode="per_tensor_quant_asymm",
q_out0=q_nope_out,
kv_cache_out0=k_cache,
q_out1=q_rope_out,
kv_cache_out1=v_cache,
)
if is_fia_nz():
k_cache = k_cache.view(kv_shape)
v_cache = v_cache.view(kv_rope_shape)
return (
q_rope_out,
v_cache,
q_nope_out,
k_cache,
forward_batch,
zero_allocator,
positions,
)
def forward_mlaprolog(self, positions, hidden_states, forward_batch):
if not self.has_preprocess_weights:
self.mlaprolog_preprocess_weight()
self.has_preprocess_weights = True
self.cos, self.sin = self.get_sin_cos(positions)
k_cache, v_cache, slot_mapping = self.get_kv_cache_and_cache_idx(forward_batch)
mla_prolog_input_args = {
"token_x": hidden_states,
"weight_dq": self.q_a_proj_weight,
"weight_uq_qr": self.q_b_proj.weight,
"weight_uk": self.w_kc,
"weight_dkv_kr": self.kv_a_proj_weight,
"rmsnorm_gamma_cq": self.q_a_layernorm.weight,
"rmsnorm_gamma_ckv": self.kv_a_layernorm.weight,
"rope_sin": self.sin,
"rope_cos": self.cos,
"kv_cache": k_cache,
"kr_cache": v_cache,
"cache_index": slot_mapping.to(dtype=torch.int64),
"dequant_scale_w_uq_qr": self.q_b_proj_weight_scale,
"rmsnorm_epsilon_cq": self.q_a_layernorm.variance_epsilon,
"rmsnorm_epsilon_ckv": self.kv_a_layernorm.variance_epsilon,
"cache_mode": "PA_BSND",
"query_norm_flag": True,
"weight_quant_mode": 1, # 0:no quant; 1:uq_qr: quant; 2: weight_dq,weight_uq_qr,weight_dkv_kr: quant
}
q_nope, q_pe, dequant_scale_q_nope, qr, dequant_q_norm = (
torch.ops.custom.npu_mla_prolog_v3(**mla_prolog_input_args)
)
dequant_q_norm = dequant_q_norm.view(hidden_states.shape[0])
return (
q_pe,
v_cache,
q_nope,
k_cache,
qr,
forward_batch,
positions,
dequant_q_norm,
)
def forward(self, positions, hidden_states, forward_batch, zero_allocator):
# assert self.quant_config and self.quant_config.get_name() == "modelslim"
# route by `qkv_a_proj` quant type as MTP layers can be unquantized
_is_w8a8 = (
hasattr(self.qkv_a_proj.quant_method, "quantization_config")
and self.qkv_a_proj.quant_method.quantization_config.get_name()
== "modelslim"
)
# with the mlaprolog enabled, the kv_b_proj layers are unquantized
_is_mlaprolog = hasattr(self.quant_config, "ignore") and any(
re.fullmatch(r".*kv_b_proj", l) for l in self.quant_config.ignore
)
if _is_w8a8:
return self.forward_mlapo(
positions, hidden_states, forward_batch, zero_allocator
)
elif _is_mlaprolog:
return self.forward_mlaprolog(positions, hidden_states, forward_batch)
else:
return self.forward_absorb_prepare_npu_rms_norm_cache(
positions, hidden_states, forward_batch, zero_allocator
)
@@ -0,0 +1,62 @@
# Adapted from https://github.com/thinking-machines-lab/batch_invariant_ops/blob/main/batch_invariant_ops/batch_invariant_ops.py
import batch_invariant_ops # noqa: F401
import torch
import torch_npu
def npu_mm_batch_invariant(a, b):
return torch.ops.batch_invariant_ops.npu_mm_batch_invariant(a, b)
def npu_matmul_batch_invariant(a, b):
return torch.ops.batch_invariant_ops.npu_matmul_batch_invariant(a, b)
def npu_mean_batch_invariant(
input, dim, keepdim=False, dtype: torch.dtype | None = None
):
assert dtype is None or dtype == torch.float32, f"unsupported dtype: {dtype}"
if len(dim) == 1:
return torch.ops.batch_invariant_ops.npu_reduce_mean_batch_invariant(
input, dim[0], keepdim=keepdim
)
else:
assert input.dtype in {
torch.float16,
torch.bfloat16,
torch.float32,
}, "only float types supported for now"
n_elems = 1
for d in dim:
n_elems *= input.shape[d]
return torch.sum(input, dim=dim, keepdim=keepdim, dtype=torch.float32) / n_elems
def npu_log_softmax_batch_invariant(input, dim, _half_to_float):
assert not _half_to_float, "not implemented"
return torch.ops.batch_invariant_ops.npu_log_softmax_batch_invariant(input, dim=dim)
def npu_fused_infer_attention_score_batch_invariant(*args, **kwargs):
return (
torch.ops.batch_invariant_ops.npu_fused_infer_attention_score_batch_invariant(
*args, **kwargs
)
)
def npu_add_rms_norm_batch_invariant(
x: torch.Tensor,
residual: torch.Tensor,
weight: torch.Tensor,
eps: float,
):
"""
AclnnAddRmsNorm can't ensure batch invariant,
so we need to split it into add and rms_norm.
"""
x_ = x + residual
residual_ = x_
x_, _ = torch_npu.npu_rms_norm(x_, weight, eps)
return x_, None, residual_
@@ -0,0 +1,83 @@
import torch
cmo_stream = None
share_stream = None
def get_cmo_stream():
"""
Cache Management Operation(CMO).
Launch a new stream to prefetch the weight of matmul when running other
AIV or communication kernels, aiming to overlap the memory access time.
"""
global cmo_stream
return cmo_stream
def set_cmo_stream(stream):
global cmo_stream
cmo_stream = stream
def prepare_weight_cache(handle, cache, PREFETCH_MAX_SIZE=1000000000):
"""
PREFETCH_MAX_SIZE: maximum size (bytes) for each prefetch operation.
This affects the time spent in prefetch:
time ≈ PREFETCH_MAX_SIZE / system_bandwidth
"""
import torch_npu
stream = get_cmo_stream()
if stream is None:
stream = torch.npu.Stream()
set_cmo_stream(stream)
stream.wait_stream(torch.npu.current_stream())
with torch.npu.stream(stream):
if isinstance(cache, list):
for weight in cache:
torch_npu.npu_prefetch(
weight,
handle,
PREFETCH_MAX_SIZE,
)
else:
torch_npu.npu_prefetch(
cache,
handle,
PREFETCH_MAX_SIZE,
)
def wait_cmo_stream():
stream = get_cmo_stream()
if stream is not None:
cur_stream = torch.npu.current_stream()
cur_stream.wait_stream(stream)
def get_share_stream():
global share_stream
return share_stream
def set_share_stream(stream):
global share_stream
share_stream = stream
def wait_share_stream():
stream = get_share_stream()
if stream is not None:
cur_stream = torch.npu.current_stream()
cur_stream.wait_stream(stream)
def shared_expert_on_independent_stream(hidden_states, forward_func):
stream = get_share_stream()
if stream is None:
stream = torch.npu.Stream()
set_share_stream(stream)
stream.wait_stream(torch.npu.current_stream())
with torch.npu.stream(stream):
shared_output = forward_func(hidden_states)
return shared_output
@@ -0,0 +1,749 @@
"""DSV4-NPU SWA + c4/c128 paged allocator.
Subclasses :class:`SWATokenToKVPoolAllocator` and adds paged allocation for the
c4/c128 compressed-KV pools and their tail-only compress-state pools, alongside
the parent's full + SWA pools.
Per ``alloc_extend`` / ``alloc_decode``:
1. super() allocates the full + SWA slots (``out_full_loc``).
2. Allocate c4/c128 KV slots — one compressed token per ``ratio`` raw tokens
(``seq_len // ratio - prefix_len // ratio``) — via the standard
:class:`NPUPagedTokenToKVPoolAllocator` over the pool's c4/c128 KV buffers.
3. Allocate the c4/c128 compress-state slots the same way, tail-only per req,
using the per-req lens the scheduler packed into ``DSV4StateLens``.
4. Return a :class:`DSV4OutCacheLoc` bundling all five slot families.
State slots are paged because the NPU fused compressor runs ``cache_mode=1``; the
base class' ``translate_kv_loc_to_compress_state_loc`` ring-hash is the CUDA-only
path and is unused on NPU. The bundle is the explicit return value:
mem_cache/common.py unpacks ``out_full_loc`` and stashes the bundle on
``batch.out_cache_loc_dsv4``; ``DSV4NPUReqToTokenPool`` writes the per-req
``req_to_token_c{4,128}[_state]`` tables that :meth:`free` and the last_loc
lookups read back.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional
import torch
from sglang.srt.configs.model_config import is_deepseek_v4
from sglang.srt.hardware_backend.npu.allocator_npu import NPUPagedTokenToKVPoolAllocator
from sglang.srt.hardware_backend.npu.dsv4.dsv4_common_hooks import (
maybe_write_dsv4_extend,
)
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.common import alloc_paged_token_slots_extend
from sglang.srt.model_executor.forward_batch_info import DSV4OutCacheLoc, DSV4StateLens
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
def get_last_loc(
req_to_token: torch.Tensor,
req_pool_indices: torch.Tensor,
prefix_lens: torch.Tensor,
) -> torch.Tensor:
"""Slot id of each req's last already-allocated token, or -1 when
``prefix_lens[i] == 0`` (fresh req).
Looks up ``req_to_token[req, prefix_lens - 1]`` to anchor the paged
allocator's ``alloc_extend`` on the real previous tail slot, preserving the
intra-page slot continuity the kernel's ``cmp_block_table`` relies on (the
allocator debug-asserts ``(last_loc + 1) % page_size == prefix_lens %
page_size``). Result dtype matches ``prefix_lens``.
"""
req_pool_indices = req_pool_indices.to(torch.int64)
safe_idx = (prefix_lens.to(torch.int64) - 1).clamp(min=0)
looked_up = req_to_token[req_pool_indices, safe_idx].to(prefix_lens.dtype)
return torch.where(
prefix_lens > 0,
looked_up,
torch.full_like(prefix_lens, -1),
)
def alloc_paged_token_slots_extend_npu(*args, batch=None, **kwargs):
if batch is not None and is_deepseek_v4(batch.model_config.hf_config):
return alloc_paged_token_slots_reserve_extend(*args, batch=batch, **kwargs)
return alloc_paged_token_slots_extend(*args, batch=batch, **kwargs)
def alloc_paged_token_slots_reserve_extend(
tree_cache,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
*,
req_pool_indices: Optional[torch.Tensor] = None,
dsv4_state_lens: Optional[DSV4StateLens] = None,
batch=None,
):
"""Allocate reserved draft slots and update DSV4 per-request tables."""
if dsv4_state_lens is None and batch is not None:
allocator = batch.token_to_kv_pool_allocator
dsv4_state_lens = (
allocator.compute_dsv4_state_lens_reserve(
batch.reqs, prefix_lens_cpu, seq_lens_cpu
)
if hasattr(allocator, "compute_dsv4_state_lens_reserve")
else None
)
out_cache_loc = alloc_paged_token_slots_extend(
tree_cache,
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
req_pool_indices=req_pool_indices,
dsv4_state_lens=dsv4_state_lens,
batch=batch,
)
if batch is not None:
maybe_write_dsv4_extend(
batch,
batch.req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
c4_state_alloc_offsets=prefix_lens_cpu,
c128_state_alloc_offsets=prefix_lens_cpu,
)
return out_cache_loc
class DSV4NPUTokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""SWA allocator + c4/c128 KV and compress-state paged allocators for DSV4 on NPU."""
def __init__(
self,
size: int,
size_swa: int,
page_size: int,
dtype: torch.dtype,
device: str,
kvcache,
need_sort: bool,
):
super().__init__(
size=size,
size_swa=size_swa,
page_size=page_size,
dtype=dtype,
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
def mk(pool_size, pool):
# c4/c128 KV and state sub-pools implement KVCache, so they drop into
# the standard paged allocator. pool_size is in compressed-token units.
return NPUPagedTokenToKVPoolAllocator(
pool_size,
page_size=page_size,
dtype=dtype,
device=device,
kvcache=pool,
need_sort=need_sort,
)
self.c4_attn_allocator = mk(kvcache.c4_size, kvcache.c4_kv_pool)
self.c128_attn_allocator = mk(kvcache.c128_size, kvcache.c128_kv_pool)
# State allocators (paged, NPU-only). Any layer's pool works as KVCache
# pointer (slot alloc is layer-agnostic); None when no c{ratio} layers or
# zero budget.
self.c4_state_attn_allocator: Optional[NPUPagedTokenToKVPoolAllocator] = None
self.c128_state_attn_allocator: Optional[NPUPagedTokenToKVPoolAllocator] = None
state_pools = getattr(kvcache, "compress_state_pools", None)
if state_pools:
def first_state_pool(want_ratio):
return next(
(
p
for r, p in zip(kvcache.compression_ratios, state_pools)
if r == want_ratio and p is not None
),
None,
)
c4_state_pool = first_state_pool(4)
c128_state_pool = first_state_pool(128)
if c4_state_pool is not None and kvcache.c4_state_pool_size > 0:
self.c4_state_attn_allocator = mk(
kvcache.c4_state_pool_size, c4_state_pool
)
if c128_state_pool is not None and kvcache.c128_state_pool_size > 0:
self.c128_state_attn_allocator = mk(
kvcache.c128_state_pool_size, c128_state_pool
)
# Returned by the c-pool helpers when a step adds no compressed tokens.
self._empty_loc = torch.empty((0,), dtype=torch.int64, device=device)
# Per-call handle to the DSV4NPUReqToTokenPool, stashed by alloc_extend/
# alloc_decode for last_loc lookups; avoids a permanent allocator->pool ref.
self._cur_req_to_token_pool = None
@staticmethod
def _compute_c_extend_counts(
prefix_lens_cpu: torch.Tensor,
seq_lens_cpu: torch.Tensor,
ratio: int,
) -> int:
"""New compressed-K tokens this extend produces across the batch:
``sum_i (seq_lens[i] // ratio - prefix_lens[i] // ratio)``."""
if prefix_lens_cpu is None or seq_lens_cpu is None:
return 0
diff = ((seq_lens_cpu // ratio) - (prefix_lens_cpu // ratio)).clamp(min=0)
return int(diff.sum().item())
@staticmethod
def _pool_exhausted(
ratio: int, kind: str, need: int, available: int
) -> RuntimeError:
return RuntimeError(
f"DSV4 c{ratio} {kind} pool exhausted: need {need} new slots, "
f"available={available}. Raise --mem-fraction-static, lower "
f"--max-running-requests, or check that "
f"DSV4NPUTokenToKVPoolAllocator.free(req=...) releases {kind} slots "
f"on req finish."
)
def _alloc_state_extend(
self,
allocator: Optional[NPUPagedTokenToKVPoolAllocator],
raw_prefix_lens: torch.Tensor,
state_prefix_lens: torch.Tensor,
state_prefix_lens_cpu: torch.Tensor,
state_seq_lens: torch.Tensor,
state_seq_lens_cpu: torch.Tensor,
req_pool_indices: torch.Tensor,
last_loc_dtype: torch.dtype,
state_extend_num_tokens: int,
ratio: int,
) -> torch.Tensor:
"""Allocate tail-only state-pool slots for an extend at ``ratio``.
The state pool is a separate paged slot space; each req allocates only
its trailing window (cumulative lens precomputed by
``ScheduleBatch._compute_dsv4_state_lens_*`` and passed via
``DSV4StateLens``). ``state_last_loc`` is looked up from
``req_to_token_c{ratio}_state`` at the RAW position
``raw_prefix_lens - 1`` (the last position the previous extend/decode
populated). Returns ``_empty_loc`` when the allocator is absent (no
c{ratio} layers) or there is nothing to add.
"""
if allocator is None or state_extend_num_tokens == 0:
return self._empty_loc
assert self._cur_req_to_token_pool is not None, (
"alloc_extend/alloc_decode must be called with req_to_token_pool= "
"for the state-pool last_loc lookup."
)
state_table = (
self._cur_req_to_token_pool.req_to_token_c4_state
if ratio == 4
else self._cur_req_to_token_pool.req_to_token_c128_state
)
state_last_loc = get_last_loc(
state_table, req_pool_indices, raw_prefix_lens
).to(last_loc_dtype)
result = allocator.alloc_extend(
state_prefix_lens,
state_prefix_lens_cpu,
state_seq_lens,
state_seq_lens_cpu,
state_last_loc,
state_extend_num_tokens,
)
if result is None:
raise self._pool_exhausted(
ratio, "state", state_extend_num_tokens, allocator.available_size()
)
return result
def _alloc_c_extend(
self,
allocator: NPUPagedTokenToKVPoolAllocator,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
req_pool_indices: torch.Tensor,
last_loc_dtype: torch.dtype,
ratio: int,
) -> torch.Tensor:
"""Allocate compressed-KV slots for an extend at ``ratio``.
Prefix/seq lens are translated to compressed units (``// ratio``); the
c-pool last_loc comes from ``req_to_token_c{ratio}`` via
:func:`get_last_loc` so the paged allocator continues in-page (or opens
a fresh page at a ratio boundary), keeping the intra-page continuity the
``cmp_block_table`` reader relies on. Returns ``_empty_loc`` when this
step closes no compressed token.
"""
c_extend = self._compute_c_extend_counts(prefix_lens_cpu, seq_lens_cpu, ratio)
if c_extend == 0:
return self._empty_loc
assert self._cur_req_to_token_pool is not None, (
"alloc_extend/alloc_decode must be called with req_to_token_pool= "
"for the c-pool last_loc lookup."
)
c_table = (
self._cur_req_to_token_pool.req_to_token_c4
if ratio == 4
else self._cur_req_to_token_pool.req_to_token_c128
)
c_prefix = (prefix_lens // ratio).to(prefix_lens.dtype)
c_seq = (seq_lens // ratio).to(seq_lens.dtype)
c_last_loc = get_last_loc(c_table, req_pool_indices, c_prefix).to(
last_loc_dtype
)
result = allocator.alloc_extend(
c_prefix,
prefix_lens_cpu // ratio,
c_seq,
seq_lens_cpu // ratio,
c_last_loc,
c_extend,
)
if result is None:
raise self._pool_exhausted(
ratio, "KV", c_extend, allocator.available_size()
)
return result
def _alloc_c_and_state(
self,
out_full_loc: torch.Tensor,
out_swa_loc: torch.Tensor,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc_dtype: torch.dtype,
req_pool_indices: Optional[torch.Tensor],
dsv4_state_lens: Optional[DSV4StateLens],
) -> DSV4OutCacheLoc:
"""Allocate c4/c128 KV + state slots and bundle them with full/swa loc.
Shared by alloc_extend / alloc_decode (which differ only in how
prefix_lens is derived). State lens are tail-only, precomputed by
ScheduleBatch._compute_dsv4_state_lens_*; raw prefix_lens drives the
state last_loc lookup.
"""
assert req_pool_indices is not None, (
"DSV4NPUTokenToKVPoolAllocator requires req_pool_indices "
"(forwarded from batch.req_pool_indices)."
)
if dsv4_state_lens is not None:
out_c4_state_loc = self._alloc_state_extend(
self.c4_state_attn_allocator,
prefix_lens,
dsv4_state_lens.c4_prefix_lens,
dsv4_state_lens.c4_prefix_lens_cpu,
dsv4_state_lens.c4_seq_lens,
dsv4_state_lens.c4_seq_lens_cpu,
req_pool_indices,
last_loc_dtype,
dsv4_state_lens.c4_extend_num_tokens,
ratio=4,
)
out_c128_state_loc = self._alloc_state_extend(
self.c128_state_attn_allocator,
prefix_lens,
dsv4_state_lens.c128_prefix_lens,
dsv4_state_lens.c128_prefix_lens_cpu,
dsv4_state_lens.c128_seq_lens,
dsv4_state_lens.c128_seq_lens_cpu,
req_pool_indices,
last_loc_dtype,
dsv4_state_lens.c128_extend_num_tokens,
ratio=128,
)
else:
out_c4_state_loc = self._empty_loc
out_c128_state_loc = self._empty_loc
out_c4_loc = self._alloc_c_extend(
self.c4_attn_allocator,
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
req_pool_indices,
last_loc_dtype,
ratio=4,
)
out_c128_loc = self._alloc_c_extend(
self.c128_attn_allocator,
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
req_pool_indices,
last_loc_dtype,
ratio=128,
)
return DSV4OutCacheLoc(
out_full_loc=out_full_loc,
out_swa_loc=out_swa_loc,
out_c4_loc=out_c4_loc,
out_c128_loc=out_c128_loc,
out_c4_state_loc=out_c4_state_loc,
out_c128_state_loc=out_c128_state_loc,
)
def compute_dsv4_state_lens_extend(
self, reqs: List[Req], seq_lens: List[int]
) -> Optional[DSV4StateLens]:
"""Per-req c{4,128}_state pool alloc lens for extend (tail-only).
State pool stores only the trailing portion of each sequence (the c{N}
compressor's read/write window); the tail length depends on raw
seq_len's alignment to the SWA page boundary (128)::
c4_alloc_len = tail + 128 if (tail <= 3 and seq_len >= 128) else tail
c128_alloc_len = tail where tail = seq_len % 128
Long prefills allocate only the trailing partial window, not slots for
already-compressed positions, so the small paged state pool (~256
slots/req) stays sufficient even for 28k-token prompts.
Mutates per-req cumulative state via getattr/setattr so the community
``Req`` needs no DSV4 field declarations:
* ``req.c{4,128}_state_kv_len`` — cumulative slot count (prefix for
the paged allocator; never decreases on eviction).
* ``req.c{4,128}_state_alloc_offset`` — low-water raw-position mark
for eviction (see ``dsv4_common_hooks.maybe_evict_dsv4_state``).
Returns None when this model has no paged state pools (CUDA / non-V4 /
zero budget) — callers pass that straight through as ``dsv4_state_lens``.
"""
if self.c4_state_attn_allocator is None:
return None
c4_prefix: List[int] = []
c4_seq: List[int] = []
c128_prefix: List[int] = []
c128_seq: List[int] = []
for req, seq_len in zip(reqs, seq_lens):
tail = seq_len % 128
c4_alloc_len = tail + 128 if (tail <= 3 and seq_len >= 128) else tail
c128_alloc_len = tail
prev_c4 = getattr(req, "c4_state_kv_len", 0)
prev_c128 = getattr(req, "c128_state_kv_len", 0)
new_c4 = prev_c4 + c4_alloc_len
new_c128 = prev_c128 + c128_alloc_len
c4_prefix.append(prev_c4)
c4_seq.append(new_c4)
c128_prefix.append(prev_c128)
c128_seq.append(new_c128)
req.c4_state_kv_len = new_c4
req.c128_state_kv_len = new_c128
req.c4_state_alloc_offset = seq_len - c4_alloc_len
req.c128_state_alloc_offset = seq_len - c128_alloc_len
return self._pack_state_lens(
c4_prefix,
c4_seq,
c128_prefix,
c128_seq,
c4_extend_num_tokens=int(sum(s - p for s, p in zip(c4_seq, c4_prefix))),
c128_extend_num_tokens=int(
sum(s - p for s, p in zip(c128_seq, c128_prefix))
),
)
def compute_dsv4_state_lens_decode(
self, reqs: List[Req]
) -> Optional[DSV4StateLens]:
"""Per-req c{4,128}_state pool alloc lens for decode: exactly 1 new
state slot per req per pool. ``c{N}_state_alloc_offset`` does NOT
advance here (only eviction advances it). Returns None when there are
no paged state pools."""
if self.c4_state_attn_allocator is None:
return None
c4_prefix: List[int] = []
c4_seq: List[int] = []
c128_prefix: List[int] = []
c128_seq: List[int] = []
for req in reqs:
prev_c4 = getattr(req, "c4_state_kv_len", 0)
prev_c128 = getattr(req, "c128_state_kv_len", 0)
c4_prefix.append(prev_c4)
c4_seq.append(prev_c4 + 1)
c128_prefix.append(prev_c128)
c128_seq.append(prev_c128 + 1)
req.c4_state_kv_len = prev_c4 + 1
req.c128_state_kv_len = prev_c128 + 1
bs = len(reqs)
return self._pack_state_lens(
c4_prefix,
c4_seq,
c128_prefix,
c128_seq,
c4_extend_num_tokens=bs,
c128_extend_num_tokens=bs,
)
def compute_dsv4_state_lens_reserve(
self, reqs: List[Req], prefix_lens: List[int], seq_lens: List[int]
) -> Optional[DSV4StateLens]:
"""Allocate state slots for a speculative pre-reserved raw interval."""
if self.c4_state_attn_allocator is None:
return None
c4_prefix: List[int] = []
c4_seq: List[int] = []
c128_prefix: List[int] = []
c128_seq: List[int] = []
for req, prefix_len, seq_len in zip(reqs, prefix_lens, seq_lens):
reserve = max(0, int(seq_len) - int(prefix_len))
prev_c4 = getattr(req, "c4_state_kv_len", 0)
prev_c128 = getattr(req, "c128_state_kv_len", 0)
c4_prefix.append(prev_c4)
c4_seq.append(prev_c4 + reserve)
c128_prefix.append(prev_c128)
c128_seq.append(prev_c128 + reserve)
req.c4_state_kv_len = prev_c4 + reserve
req.c128_state_kv_len = prev_c128 + reserve
total = sum(max(0, int(s) - int(p)) for p, s in zip(prefix_lens, seq_lens))
return self._pack_state_lens(
c4_prefix,
c4_seq,
c128_prefix,
c128_seq,
c4_extend_num_tokens=total,
c128_extend_num_tokens=total,
)
def _pack_state_lens(
self,
c4_prefix: List[int],
c4_seq: List[int],
c128_prefix: List[int],
c128_seq: List[int],
*,
c4_extend_num_tokens: int,
c128_extend_num_tokens: int,
) -> DSV4StateLens:
c4_prefix_cpu = torch.tensor(c4_prefix, dtype=torch.int64)
c4_seq_cpu = torch.tensor(c4_seq, dtype=torch.int64)
c128_prefix_cpu = torch.tensor(c128_prefix, dtype=torch.int64)
c128_seq_cpu = torch.tensor(c128_seq, dtype=torch.int64)
return DSV4StateLens(
c4_prefix_lens=c4_prefix_cpu.to(self.device, non_blocking=True),
c4_prefix_lens_cpu=c4_prefix_cpu,
c4_seq_lens=c4_seq_cpu.to(self.device, non_blocking=True),
c4_seq_lens_cpu=c4_seq_cpu,
c4_extend_num_tokens=c4_extend_num_tokens,
c128_prefix_lens=c128_prefix_cpu.to(self.device, non_blocking=True),
c128_prefix_lens_cpu=c128_prefix_cpu,
c128_seq_lens=c128_seq_cpu.to(self.device, non_blocking=True),
c128_seq_lens_cpu=c128_seq_cpu,
c128_extend_num_tokens=c128_extend_num_tokens,
)
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
*,
req_pool_indices: Optional[torch.Tensor] = None,
dsv4_state_lens: Optional[DSV4StateLens] = None,
req_to_token_pool=None,
) -> Optional[DSV4OutCacheLoc]:
# Stash per-req tables for this call's last_loc lookups (read by
# _alloc_c_extend / _alloc_state_extend); no permanent allocator->pool ref.
self._cur_req_to_token_pool = req_to_token_pool
out_full_loc = super().alloc_extend(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
)
if out_full_loc is None:
return None
out_swa_loc = self.translate_loc_from_full_to_swa(out_full_loc)
assert out_swa_loc is not None, (
"translate_loc_from_full_to_swa returned None — "
"full_to_swa_index_mapping not initialized?"
)
return self._alloc_c_and_state(
out_full_loc,
out_swa_loc,
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc.dtype,
req_pool_indices,
dsv4_state_lens,
)
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
*,
req_pool_indices: Optional[torch.Tensor] = None,
dsv4_state_lens: Optional[DSV4StateLens] = None,
req_to_token_pool=None,
) -> Optional[DSV4OutCacheLoc]:
self._cur_req_to_token_pool = req_to_token_pool
out_full_loc = super().alloc_decode(seq_lens, seq_lens_cpu, last_loc)
if out_full_loc is None:
return None
out_swa_loc = self.translate_loc_from_full_to_swa(out_full_loc)
# One new token per req. Model as an extend from (seq_len-1)//ratio to
# seq_len//ratio so _alloc_c_extend anchors on the real c-pool last_loc.
prefix_lens = (seq_lens - 1).clamp(min=0)
prefix_lens_cpu = (seq_lens_cpu - 1).clamp(min=0)
return self._alloc_c_and_state(
out_full_loc,
out_swa_loc,
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc.dtype,
req_pool_indices,
dsv4_state_lens,
)
def free(
self,
free_index: Optional[torch.Tensor] = None,
*,
req=None,
req_to_token_pool=None,
):
"""Unified free for full/swa/c4/c128 pools. Two forms (may co-fire):
* ``free(free_index)`` — full + SWA only (tail/radix eviction; no req
identity, so c-pool free can't run).
* ``free(req=, req_to_token_pool=)`` — from DSV4NPUReqToTokenPool.free
on req finish: reads the per-req slot lists from
``req_to_token_c{4,128}[_state]`` and returns them to the c-pools
(the paged allocator dedupes by page).
KV pools free ``[0, kv_len // ratio)``. State pools are 1-per-raw-token
and free only the tail ``[c{N}_state_alloc_offset, kv_len)`` — the prefix
was already returned by ScheduleBatch._evict_swa (state rides SWA
eviction); freeing it again would double-free (caught by the paged
allocator's debug_mode assert, corrupts the free list otherwise).
"""
if free_index is not None:
super().free(free_index)
if req is None or req_to_token_pool is None:
return
kv_len = max(req.kv_committed_len, req.kv_allocated_len)
req_pool_idx = req.req_pool_idx
if kv_len <= 0 or req_pool_idx is None:
return
# KV pools: free the leading [0, kv_len // ratio) compressed slots.
for ratio, allocator, table_attr in (
(4, self.c4_attn_allocator, "req_to_token_c4"),
(128, self.c128_attn_allocator, "req_to_token_c128"),
):
n = kv_len // ratio
if n > 0 and hasattr(req_to_token_pool, table_attr):
slots = getattr(req_to_token_pool, table_attr)[req_pool_idx, :n]
# to int64 — paged allocator's free does cpu()//page_size on it.
allocator.free(slots.to(torch.int64))
# State pools: free only the tail [c{N}_state_alloc_offset, kv_len).
for ratio, allocator, table_attr, off_attr in (
(
4,
self.c4_state_attn_allocator,
"req_to_token_c4_state",
"c4_state_alloc_offset",
),
(
128,
self.c128_state_attn_allocator,
"req_to_token_c128_state",
"c128_state_alloc_offset",
),
):
if allocator is None or not hasattr(req_to_token_pool, table_attr):
continue
off = getattr(req, off_attr, 0)
if kv_len > off:
slots = getattr(req_to_token_pool, table_attr)[req_pool_idx, off:kv_len]
allocator.free(slots.to(torch.int64))
def backup_state(self):
# EAGLE/NEXTN draft preprocess allocates speculative c{4,128} KV via
# alloc_extend(backup_state=True) and rolls it back with restore_state.
# The base SWATokenToKVPoolAllocator only snapshots the full + SWA pools,
# so without this override the draft's c{4,128} (+ state) slots are never
# rolled back -> they leak every draft step until the c4 pool exhausts.
# Snapshot the sub-allocators alongside the base pools.
return (
super().backup_state(),
self.c4_attn_allocator.backup_state(),
self.c128_attn_allocator.backup_state(),
(
self.c4_state_attn_allocator.backup_state()
if self.c4_state_attn_allocator is not None
else None
),
(
self.c128_state_attn_allocator.backup_state()
if self.c128_state_attn_allocator is not None
else None
),
)
def restore_state(self, state):
base, c4, c128, c4_state, c128_state = state
super().restore_state(base)
self.c4_attn_allocator.restore_state(c4)
self.c128_attn_allocator.restore_state(c128)
if self.c4_state_attn_allocator is not None and c4_state is not None:
self.c4_state_attn_allocator.restore_state(c4_state)
if self.c128_state_attn_allocator is not None and c128_state is not None:
self.c128_state_attn_allocator.restore_state(c128_state)
def clear(self):
super().clear()
# super().__init__ calls clear() before our sub-allocators exist;
# getattr(..., None) tolerates that and the always-None state allocators.
for attr in (
"c4_attn_allocator",
"c128_attn_allocator",
"c4_state_attn_allocator",
"c128_state_attn_allocator",
):
allocator = getattr(self, attr, None)
if allocator is not None:
allocator.clear()
@@ -0,0 +1,404 @@
"""Helpers used by mem_cache/common.py to wire DSV4-NPU per-req tables.
mem_cache/common.py runs platform-agnostic alloc flow. When the model is
DSV4 on NPU, ``alloc_paged_token_slots_{extend,decode}`` already stashed the
:class:`DSV4OutCacheLoc` the allocator returned onto
``batch.out_cache_loc_dsv4``. After each ``alloc_extend`` / ``alloc_decode``
these hooks then:
1. Read the bundle from ``batch.out_cache_loc_dsv4``.
2. Write the per-pool slot ids into the per-req tables on the
:class:`DSV4NPUReqToTokenPool`.
Non-DSV4 paths leave ``batch.out_cache_loc_dsv4`` None, so this module is a
no-op for them.
TODO: the disagg DSV4 path bypasses these hooks — it calls
``allocator.alloc_extend`` directly then ``req_to_token_pool.write`` without
going through ``mem_cache/common.py`` (see ``disaggregation/decode.py``). The
DSV4OutCacheLoc bundle is still produced but never written into the per-req
tables, so disagg + DSV4 is unsupported here (c-pages leak). Fixing requires
calling these hooks from disagg's per-req alloc loop, or moving the write
into the allocator itself.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Sequence
import torch
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
def maybe_write_dsv4_extend(
batch: ScheduleBatch,
req_pool_indices_cpu: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens_cpu: torch.Tensor,
*,
c4_state_alloc_offsets: Sequence[int] | torch.Tensor | None = None,
c128_state_alloc_offsets: Sequence[int] | torch.Tensor | None = None,
) -> None:
"""Post-alloc_extend hook for DSV4. No-op when allocator/pool is not DSV4.
For each compressed pool (c4 / c128), spreads the flat
``out_c{4,128}_loc`` tensor across requests using per-req extend
counts (``seq_lens[i] // ratio - prefix_lens[i] // ratio``) and writes
the resulting slot ids into ``req_to_token_c{4,128}[req, prefix:seq]``.
Also writes ``req_to_token_swa[req, prefix:seq]`` with the swa slots
derived from out_full_loc via the SWA index mapping.
"""
# Bundle stashed on batch.out_cache_loc_dsv4 by mem_cache/common.py;
# None on CUDA / non-V4 paths → no-op.
bundle = batch.out_cache_loc_dsv4
if bundle is None:
return
req_to_token_pool = batch.req_to_token_pool
if not hasattr(req_to_token_pool, "write_c4"):
return # non-DSV4 pool; skip defensively (shouldn't happen)
# SWA writes: prefix..seq token positions, one slot per raw token.
_write_per_req_slice(
req_to_token_pool.write_swa,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_swa_loc,
ratio=1,
)
# c4 / c128 writes: prefix//ratio .. seq//ratio compressed positions.
_write_per_req_slice(
req_to_token_pool.write_c4,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c4_loc,
ratio=4,
)
_write_per_req_slice(
req_to_token_pool.write_c128,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c128_loc,
ratio=128,
)
# c4_state / c128_state writes: tail-only. Bundle length is
# sum(c{N}_state_alloc_len_i), NOT total raw extend tokens. Normal extend
# uses the per-Req low-water marks; reserve callers can pass explicit raw
# offsets for the pre-reserved interval.
if c4_state_alloc_offsets is None:
c4_state_alloc_offsets = [
getattr(r, "c4_state_alloc_offset", 0) for r in batch.reqs
]
if c128_state_alloc_offsets is None:
c128_state_alloc_offsets = [
getattr(r, "c128_state_alloc_offset", 0) for r in batch.reqs
]
if bundle.out_c4_state_loc is not None and hasattr(
req_to_token_pool, "write_c4_state"
):
_write_state_tail_per_req(
req_to_token_pool.write_c4_state,
req_pool_indices_cpu,
c4_state_alloc_offsets,
seq_lens_cpu,
bundle.out_c4_state_loc,
)
if bundle.out_c128_state_loc is not None and hasattr(
req_to_token_pool, "write_c128_state"
):
_write_state_tail_per_req(
req_to_token_pool.write_c128_state,
req_pool_indices_cpu,
c128_state_alloc_offsets,
seq_lens_cpu,
bundle.out_c128_state_loc,
)
def maybe_write_dsv4_decode(
batch: ScheduleBatch,
seq_lens_cpu: torch.Tensor,
token_per_req: int,
) -> None:
"""Post-alloc_decode hook for DSV4. Spreads the new token slot ids
(one per req for swa, gated by ratio boundary for c4/c128) into the
per-req tables on DSV4NPUReqToTokenPool.
``seq_lens_cpu`` is the POST-decode seq len (already incremented by
``token_per_req``); the new compressed tokens go at positions
``[(old_seq) // ratio, (new_seq) // ratio)``.
"""
# Bundle stashed on batch.out_cache_loc_dsv4 by mem_cache/common.py;
# None on CUDA / non-V4 paths → no-op.
bundle = batch.out_cache_loc_dsv4
if bundle is None:
return
req_to_token_pool = batch.req_to_token_pool
if not hasattr(req_to_token_pool, "write_c4"):
return
prefix_lens_cpu = (seq_lens_cpu - token_per_req).clamp(min=0)
req_pool_indices_cpu = batch.req_pool_indices.cpu()
_write_per_req_slice(
req_to_token_pool.write_swa,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_swa_loc,
ratio=1,
)
_write_per_req_slice(
req_to_token_pool.write_c4,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c4_loc,
ratio=4,
)
_write_per_req_slice(
req_to_token_pool.write_c128,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c128_loc,
ratio=128,
)
# State table decode writes: one slot per raw decode token (ratio=1).
if bundle.out_c4_state_loc is not None and hasattr(
req_to_token_pool, "write_c4_state"
):
_write_per_req_slice(
req_to_token_pool.write_c4_state,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c4_state_loc,
ratio=1,
)
if bundle.out_c128_state_loc is not None and hasattr(
req_to_token_pool, "write_c128_state"
):
_write_per_req_slice(
req_to_token_pool.write_c128_state,
req_pool_indices_cpu,
prefix_lens_cpu,
seq_lens_cpu,
bundle.out_c128_state_loc,
ratio=1,
)
def maybe_build_dsv4_verify_bundle(batch: ScheduleBatch, draft_token_num: int):
"""Build the DSV4 cache-location view for one target-verify pass.
Spec-v2 reserves cache ahead of time, so target verify must select only the
current draft interval from the per-request DSV4 tables instead of reusing
the larger allocation bundle produced during decode preparation.
"""
pool = batch.req_to_token_pool
if not hasattr(pool, "req_to_token_c4"):
return None
reserve_bundle = batch.out_cache_loc_dsv4
if reserve_bundle is None:
return None
req_indices = batch.req_pool_indices_cpu.tolist()
seq_lens = batch.seq_lens_cpu.tolist()
def flatten_interval(table: torch.Tensor, ratio: int) -> torch.Tensor:
chunks = []
for req_idx, seq_len in zip(req_indices, seq_lens):
start = int(seq_len) // ratio
end = (int(seq_len) + draft_token_num) // ratio
if end > start:
chunks.append(table[int(req_idx), start:end])
return torch.cat(chunks) if chunks else table.new_empty((0,))
return type(reserve_bundle)(
out_full_loc=batch.out_cache_loc,
out_swa_loc=flatten_interval(pool.req_to_token_swa, 1),
out_c4_loc=flatten_interval(pool.req_to_token_c4, 4),
out_c128_loc=flatten_interval(pool.req_to_token_c128, 128),
out_c4_state_loc=flatten_interval(pool.req_to_token_c4_state, 1),
out_c128_state_loc=flatten_interval(pool.req_to_token_c128_state, 1),
)
def _write_per_req(
write_fn,
req_pool_indices_cpu: torch.Tensor,
flat_loc: torch.Tensor,
bounds_fn,
) -> None:
"""Distribute a flat ``[total_alloc]`` slot tensor across reqs.
``bounds_fn(i) -> (lo, hi)`` gives req i's write window; the matching
``hi - lo`` slots are sliced off ``flat_loc`` in order and written via
``write_fn((req_idx, slice(lo, hi)), values)``. flat_loc may be None /
empty when the alloc path bypassed DSV4NPUTokenToKVPoolAllocator (e.g.
page_size=1 or HiSparse wrapper); skip then.
"""
if flat_loc is None or flat_loc.numel() == 0:
return
pt = 0
for i in range(req_pool_indices_cpu.shape[0]):
lo, hi = bounds_fn(i)
alloc_len = max(0, hi - lo)
if alloc_len == 0:
continue
req_idx = int(req_pool_indices_cpu[i].item())
chunk = flat_loc[pt : pt + alloc_len].to(torch.int32)
write_fn((req_idx, slice(lo, hi)), chunk)
pt += alloc_len
def _write_state_tail_per_req(
write_fn,
req_pool_indices_cpu: torch.Tensor,
state_alloc_offsets: list,
seq_lens_cpu: torch.Tensor,
flat_loc: torch.Tensor,
) -> None:
"""Tail-only state write: req i's slots go at ``[state_alloc_offsets[i],
seq_lens[i])`` in ``req_to_token_c{N}_state``."""
_write_per_req(
write_fn,
req_pool_indices_cpu,
flat_loc,
lambda i: (int(state_alloc_offsets[i]), int(seq_lens_cpu[i].item())),
)
def _write_per_req_slice(
write_fn,
req_pool_indices_cpu: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens_cpu: torch.Tensor,
flat_loc: torch.Tensor,
ratio: int,
) -> None:
"""Compressed-position write: req i's slots go at
``[prefix_lens[i] // ratio, seq_lens[i] // ratio)``."""
_write_per_req(
write_fn,
req_pool_indices_cpu,
flat_loc,
lambda i: (
int(prefix_lens_cpu[i].item()) // ratio,
int(seq_lens_cpu[i].item()) // ratio,
),
)
def maybe_evict_dsv4_state(batch: ScheduleBatch, req: Req, pre_len: int) -> None:
"""Per-decode evict for the DSV4-NPU compress-state pools, independent of
SWA evict cadence. Called every decode step from ``ScheduleBatch``.
The state pool is small (~2 pages c4 / ~3 pages c128 of raw positions per
req) — with a large sliding_window (SWA evict fires every
``eviction_interval`` and needs ``pre_len > sliding_window + page_size`` to
free anything) the pool exhausts before the first SWA frontier advance, so
we drain it here on its own cadence.
Retention windows (kernel read window + decode lookahead margin):
c4 = 8 + 16, c128 = 128 + 64 raw positions — intentionally smaller than one
SWA page so the first eviction fires before the small pool fills. Watermarks
are page-aligned so freed slots are whole pages reclaimable by the paged
allocator. ``req.c{4,128}_state_alloc_offset`` (read/written via getattr/
setattr) is the low-water mark. No-op on non-DSV4-NPU paths.
"""
allocator = batch.token_to_kv_pool_allocator
pool = batch.req_to_token_pool
if not hasattr(allocator, "c4_state_attn_allocator") or (
allocator.c4_state_attn_allocator is None
and allocator.c128_state_attn_allocator is None
):
return
page_size = batch.tree_cache.page_size
c4_watermark = ((max(0, pre_len - (8 + 16))) // page_size) * page_size
c128_watermark = ((max(0, pre_len - (128 + 64))) // page_size) * page_size
_free_state_range(
allocator.c4_state_attn_allocator,
pool,
"req_to_token_c4_state",
req,
"c4_state_alloc_offset",
c4_watermark,
)
_free_state_range(
allocator.c128_state_attn_allocator,
pool,
"req_to_token_c128_state",
req,
"c128_state_alloc_offset",
c128_watermark,
)
def maybe_evict_dsv4_state_on_swa(
allocator, pool, req: Req, new_swa_evicted_seqlen: int
) -> None:
"""Free compress-state slots that ride along with SWA eviction.
State at raw positions < ``swa_evicted_seqlen`` is no longer readable (the
compressor only reads the trailing ``2*ratio`` window) and is returned to
its paged allocator to keep the small state pool from exhausting on long
generations. No-op when the DSV4-NPU state allocators are absent.
This path is needed for small-sliding-window models where
``sliding_window < retention`` (e.g. c128 retention 192 > window 128):
in that case the watermark-based eviction alone may not free slots
fast enough, and the SWA-ride eviction is the primary reclaim mechanism.
For typical large-window models (DS-V4 with window >> 192), the
watermark eviction always runs first, making this path a no-op.
"""
if not hasattr(allocator, "c4_state_attn_allocator"):
return
_free_state_range(
allocator.c4_state_attn_allocator,
pool,
"req_to_token_c4_state",
req,
"c4_state_alloc_offset",
new_swa_evicted_seqlen,
)
_free_state_range(
allocator.c128_state_attn_allocator,
pool,
"req_to_token_c128_state",
req,
"c128_state_alloc_offset",
new_swa_evicted_seqlen,
)
def _free_state_range(
state_allocator,
pool,
table_attr: str,
req: Req,
offset_attr: str,
watermark: int,
) -> None:
"""Free ``[alloc_offset, watermark)`` raw-position state slots for ``req``
and advance its low-water mark. No-op when the allocator/table is absent or
the watermark hasn't advanced past the current offset."""
offset = getattr(req, offset_attr, 0)
if state_allocator is None or not hasattr(pool, table_attr) or watermark <= offset:
return
free_slots = getattr(pool, table_attr)[req.req_pool_idx, offset:watermark]
state_allocator.free(free_slots.to(torch.int64))
setattr(req, offset_attr, watermark)
@@ -0,0 +1,593 @@
"""NPU-only KV pool variant for DeepSeek-V4.
Subclasses :class:`DeepSeekV4TokenToKVPool` to swap the ring-buffered
:class:`CompressStatePool` for the paged :class:`NPUCompressStatePool` that
the on-NPU fused compressor kernel (``torch.ops.custom.compressor`` with
``cache_mode=1``) requires. Atlas A3 rejects ``cache_mode=2`` (ring) entirely,
so this is the only valid layout on that hardware.
Selected at pool construction time by
:meth:`ModelRunnerKVCacheMixin._init_pools` when the model is DSV4 AND the
device is NPU. CUDA continues to use the unchanged base class.
The subclass overrides only:
* ``_make_attn_state_pool`` / ``_make_indexer_state_pool`` — the per-ratio
state-pool factories the base ``_init_paged_compress_states`` loop calls.
Both return :class:`NPUCompressStatePool` (paged, ``cache_mode=1``)
instead of the base's ring-buffered :class:`CompressStatePool`.
* ``translate_kv_loc_to_compress_state_loc`` — raise loudly. The ring
hash this method implements is meaningless on the paged kernel; callers
must consume ``out_cache_loc_dsv4.out_c{4,128}_state_loc`` from the
allocator bundle instead. Currently the only NPU caller that still
invokes translate is the unfused Python compressor decode path
(``layers/attention/dsv4/compressor.py``); with USE_FUSED_COMPRESSOR=1
that path is dead. If someone disables the fused compressor, they hit
the raise with a clear message.
"""
from __future__ import annotations
import math
from typing import Optional, Tuple
import torch
import torch_npu
from sglang.srt.constants import GPU_MEMORY_TYPE_KV_CACHE
from sglang.srt.mem_cache.deepseek_v4_compress_state import CompressStatePool
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
ONLINE_C128,
DeepSeekV4IndexerPool,
DeepSeekV4SingleKVPool,
DeepSeekV4TokenToKVPool,
)
class NPUDeepSeekV4SingleKVPool(DeepSeekV4SingleKVPool):
"""NPU bf16 variant of the full / SWA / c4 / c128 single-KV pool.
``npu_sparse_attn_sharedkv`` reads KV in PA_ND layout
``(num_pages, kernel_page_size, num_kv_heads=1, dim)`` with ``dim`` packing
K_nope + K_rope as bf16, and requires ``cmp_kv.shape[1] == ori_kv.shape[1]``.
So the c4/c128 pools (whose token-level page_size is ``page_size // ratio``)
are allocated at the GLOBAL ``kernel_page_size`` rather than their own
per-ratio page_size; the SWA pool uses ``kernel_page_size == page_size``.
The CUDA fp8-packed-bytes layout (the base ``create_buffer``) is untouched.
"""
def __init__(self, *args, kernel_page_size: int, **kwargs):
# Set before super().__init__ — it calls _create_buffers() ->
# create_buffer(), which reads self.kernel_page_size.
self.kernel_page_size = kernel_page_size
super().__init__(*args, **kwargs)
def create_buffer(self, *, num_pages: int):
# Non-bf16 store dtype (shouldn't happen here) falls back to base layout.
if self.store_dtype != torch.bfloat16:
return super().create_buffer(num_pages=num_pages)
kv_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
self.kv_cache_total_dim = kv_dim
# GLOBAL kernel_page_size keeps cmp_kv.shape[1] == ori_kv.shape[1]; writes
# are flat-indexed by loc, so page granularity affects shape not location.
npu_num_pages = (self.size + self.kernel_page_size + 1) // self.kernel_page_size
return torch.zeros(
npu_num_pages,
self.kernel_page_size,
1,
kv_dim,
dtype=torch.bfloat16,
device=self.device,
)
def npu_state_pool_size(
*,
ratio: int,
page_size: int,
max_num_reqs: int,
) -> int:
"""Per-pool state slot count for the NPU paged state pool's
:class:`NPUPagedTokenToKVPoolAllocator`.
Sizing formula::
max(2, ceil(1.8 * ratio / page_size) + 1) * max_num_reqs * page_size
Sized for steady-state during decode: each req keeps roughly the trailing
``sliding_window_size`` worth of state slots live at any one time (SWA
eviction in :meth:`ScheduleBatch._evict_swa` frees state slots as it
advances), and the 1.8x factor adds headroom for the tail-only allocation
pattern across page boundaries.
Prefill no longer drives sizing because allocation is tail-only — long
prompts only allocate ``c{ratio}_alloc_len`` slots (``≤ tail + 128`` for
c4, ``≤ tail`` for c128, where ``tail = seq_len % 128``), not the full raw
seqlen. See :meth:`ScheduleBatch._compute_dsv4_state_lens_extend` for the
per-req formula.
Result is in TOKEN units (matches the SGLang allocator
``PagedTokenToKVPoolAllocator(size, ...)`` convention where
``num_pages = size // page_size`` is the count of USABLE pages handed out
by ``free_pages = arange(1, num_pages+1)``). The BUFFER allocates one extra
page (see :class:`NPUCompressStatePool`, sized ``(num_pages + 1) *
page_size`` — page 0 is the kernel's skip-sentinel).
"""
blocks_per_req = max(2, math.ceil(1.8 * ratio / page_size) + 1)
num_usable_pages = blocks_per_req * max_num_reqs
return num_usable_pages * page_size
class NPUCompressStatePool(CompressStatePool):
"""Paged compress-state pool for the NPU fused compressor kernel.
``torch.ops.custom.compressor`` (cache_mode=1) reads/writes the compress
state via ``state_cache`` shape ``(block_num, page_size, 2*coff*head_dim)``
indexed by a paged ``state_block_table`` (block ids from 1; value 0 means
"skip this slot"). The CUDA :class:`CompressStatePool` sizes itself
ring-style, which misaddresses slots under cache_mode=1 (ring is also
unsupported on Atlas A3). This subclass keeps the parent's buffer layout
(``(self._size, 2*coff*head_dim)`` flat; ``state_cache_3d`` reshapes to
``(num_blocks, page_size, 2*coff*head_dim)``) but replaces the size formula
with a paged one derived from ``max_num_reqs``. Block 0 is reserved as the
kernel's skip-sentinel (zero kv / -inf score) so any ``state_block_table``
entry defaulting to 0 lands in a deterministic, attention-neutral place.
NPU-only; CUDA keeps using the unchanged :class:`CompressStatePool`.
"""
def __init__(
self,
*,
size: int,
overlap: bool,
head_dim: int,
dtype: torch.dtype,
device: str,
enable_memory_saver: bool,
ratio: int,
page_size: int,
):
# Bypass parent __init__ — its ring-based sizing is incompatible with the
# kernel's paged block-id contract. We redo buffer alloc and set the same
# fields so the parent API (state_cache_3d, kv_score_buffer) stays intact.
assert ratio in (
4,
128,
), f"NPUCompressStatePool only supports ratio in (4, 128); got {ratio}"
assert page_size > 1, (
"NPUCompressStatePool requires page_size>1 (kernel's "
"state_cache_3d view is (block_num, page_size, slot_dim)). "
"Got page_size=%d." % page_size
)
# ``size`` is the ALLOCATOR's size (npu_state_pool_size output). Buffer
# needs one EXTRA page so the free list arange(1, num_pages+1) indexes it
# without OOB (page 0 = skip sentinel; pages 1..num_pages handed out).
num_usable_pages = (size + page_size - 1) // page_size
num_buffer_pages = num_usable_pages + 1
self._size = num_buffer_pages * page_size
self.page_size = page_size
# ring_size=0 marks "not ring-buffered" (paged allocator replaces the
# parent's ring hashing); kept so downstream hasattr probes don't break.
self.ring_size = 0
# online compress is a CUDA-only opt with no NPU fused-compressor support;
# force off so layout matches kernel expectations.
self.online = False
# Slot dim = 2 * coff * head_dim = [kv | score]; coff = 1 (no overlap) or
# 2 (overlap). Matches CompressStatePool non-online layout.
self.last_dim = 2 * (1 + int(overlap)) * head_dim
# Reuse parent's buffer-alloc helper; only self._size differs from the
# ring-based parent path.
self._alloc_kv_score_buffer(
dtype=dtype, device=device, enable_memory_saver=enable_memory_saver
)
# Block 0 = kernel skip-sentinel: kv zeroed, score -inf (softmax → 0).
# The free list excludes it; only stale state_block_table entries land here.
self.kv_score_buffer.kv[:page_size].zero_()
self.kv_score_buffer.score[:page_size].fill_(float("-inf"))
class NPUDeepSeekV4IndexerPool(DeepSeekV4IndexerPool):
"""NPU c4-indexer pool. Keeps the base packed CUDA buffer (read by
get_contiguous_buf_infos / NSA) and ADDS dedicated int8 K + float16 scale
buffers in PA_ND layout at the global ``kernel_page_size``, written by
``torch_npu.npu_scatter_nd_update_`` and read by
``torch.ops.custom.npu_quant_lightning_indexer``.
"""
def __init__(self, *args, kernel_page_size: int, **kwargs):
# Set before super().__init__ — it calls _create_buffer().
self._kernel_page_size = kernel_page_size
super().__init__(*args, **kwargs)
def _create_buffer(self):
# Base allocates the packed CUDA index_k_with_scale_buffer (kept for
# get_contiguous_buf_infos / NSA compat); then add the NPU buffers.
super()._create_buffer()
kp = self._kernel_page_size
npu_num_pages = (self.size + kp + 1) // kp
with self.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
self.index_k_buffer = [
torch.zeros(
npu_num_pages,
kp,
1,
self.index_head_dim,
dtype=torch.int8,
device=self.device,
)
for _ in range(self.layer_num)
]
self.index_scale_buffer = [
torch.zeros(
npu_num_pages,
kp,
1,
1,
dtype=torch.float16,
device=self.device,
)
for _ in range(self.layer_num)
]
@property
def has_npu_storage(self) -> bool:
return True
def get_index_k(self, layer_id: int) -> torch.Tensor:
return self.index_k_buffer[layer_id]
def get_index_scale(self, layer_id: int) -> torch.Tensor:
return self.index_scale_buffer[layer_id]
def set_index_k_scale(
self,
layer_id: int,
loc: torch.Tensor,
index_k: torch.Tensor,
index_k_scale: Optional[torch.Tensor],
) -> None:
# int8 K + fp16 scale come from _compressor_epilog_npu's npu_dynamic_quant
# output (index_k: int8 [T, D], index_k_scale: fp16 [T, 1]).
d = self.index_head_dim
loc_long = loc.view(-1, 1).long()
torch_npu.npu_scatter_nd_update_(
self.index_k_buffer[layer_id].view(-1, 1, d),
loc_long,
index_k.to(torch.int8).view(-1, 1, d),
)
if index_k_scale is not None:
torch_npu.npu_scatter_nd_update_(
self.index_scale_buffer[layer_id].view(-1, 1, 1),
loc_long,
index_k_scale.to(torch.float16).view(-1, 1, 1),
)
class DSV4NPUTokenToKVPool(DeepSeekV4TokenToKVPool):
"""NPU-only DSV4 KV pool with paged compress-state buffers.
The full / SWA / c4 / c128 KV pools use the NPU bf16 PA_ND layout
(:class:`NPUDeepSeekV4SingleKVPool`); the compress-state pool is paged
(:class:`NPUCompressStatePool`) rather than ring-buffered; and the indexer
pool adds dedicated int8 K + fp16 scale buffers
(:class:`NPUDeepSeekV4IndexerPool`). The generic-accessor / port-hook
methods at the bottom of this class are the NPU equivalents of the CUDA
DSV4 store-cache chain — kept here, not in the community base, which raises
``NotImplementedError`` for them (CUDA goes through the radix / store_cache
accessors instead).
"""
def _make_kv_pool(
self,
*,
size: int,
page_size: int,
dtype: torch.dtype,
layer_num: int,
device: str,
enable_memory_saver: bool,
global_page_size: int,
cls: type = DeepSeekV4SingleKVPool,
) -> NPUDeepSeekV4SingleKVPool:
# NPU does not use the HiSparse c4 device pool; fail loud if someone
# enables it so the silent layout mismatch surfaces at init.
assert cls is DeepSeekV4SingleKVPool, (
"enable_hisparse is not supported on the NPU DSV4 KV pool "
f"(got c4 pool class {cls.__name__})."
)
return NPUDeepSeekV4SingleKVPool(
size,
page_size,
dtype,
self.qk_nope_head_dim,
self.qk_rope_head_dim,
layer_num,
device,
enable_memory_saver,
kernel_page_size=global_page_size,
)
def _get_state_pool(self, layer_id: int, from_indexer: bool) -> CompressStatePool:
"""Select this layer's attention vs c4-indexer compress-state pool.
Wraps the community getters so the NPU port hooks below don't index the
pool lists directly."""
if from_indexer:
return self.get_indexer_compress_states(layer_id)
return self.get_attention_compress_states(layer_id)
def _make_attn_state_pool(
self, ratio: int, enable_memory_saver: bool
) -> NPUCompressStatePool:
# ONLINE_C128 (CUDA-only) collapses the c128 ring to size 1; the NPU fused
# compressor has no online mode, so assert the config mismatch early.
assert not (ratio == 128 and ONLINE_C128), (
"SGLANG_OPT_USE_ONLINE_COMPRESS is incompatible with the "
"NPU fused compressor (no online mode in the kernel)."
)
return NPUCompressStatePool(
size=self._state_pool_size(ratio),
overlap=ratio == 4,
head_dim=self.qk_nope_head_dim + self.qk_rope_head_dim,
dtype=self.c4_state_dtype if ratio == 4 else self.c128_state_dtype,
device=self.device,
enable_memory_saver=enable_memory_saver,
ratio=ratio,
page_size=self.swa_page_size,
)
def _make_indexer_state_pool(
self, ratio: int, enable_memory_saver: bool
) -> NPUCompressStatePool:
# c4 indexer shares the c4 state pool size budget but has its own
# slot_dim (indexer_head_dim vs attention head_dim).
return NPUCompressStatePool(
size=self.c4_state_pool_size,
overlap=ratio == 4,
head_dim=self.indexer_head_dim,
device=self.device,
dtype=self.c4_state_dtype,
enable_memory_saver=enable_memory_saver,
ratio=ratio,
page_size=self.swa_page_size,
)
def clear_unaccepted_c128_draft_states(
self,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
accept_lens: torch.Tensor,
num_draft_tokens: int,
) -> None:
pass
def _make_indexer_pool(
self,
size: int,
page_size: int,
dtype: torch.dtype,
index_head_dim: int,
layer_num: int,
device: str,
enable_memory_saver: bool,
) -> NPUDeepSeekV4IndexerPool:
# NPU dedicated int8 K + fp16 scale buffers use the GLOBAL page_size
# (= self.page_size) as kernel_page_size, matching ori_kv for the kernel.
return NPUDeepSeekV4IndexerPool(
size,
page_size,
dtype,
index_head_dim,
layer_num,
device,
enable_memory_saver,
kernel_page_size=self.page_size,
)
def get_state_cache(self, layer_id: int, from_indexer: bool) -> torch.Tensor:
"""fp32 ``[block_num, page_size, 2*coff*D]`` view of this layer's
kv+score buffer — the fused compressor op
(``torch.ops.custom.compressor``)'s ``state_cache`` argument."""
return self._get_state_pool(layer_id, from_indexer).state_cache_3d
# ------------------------------------------------------------------
# Generic KV accessors (community base raises NotImplementedError; CUDA uses
# store_cache). AscendAttnBackend reads KV through these, routed to the right
# sub-pool by compression ratio.
# ------------------------------------------------------------------
def get_key_buffer(self, layer_id: int) -> torch.Tensor:
item = self.layer_mapping[layer_id]
ratio = item.compress_ratio
if ratio == 0:
return self.swa_kv_pool.kv_buffer[item.compress_layer_id]
if ratio == 4:
return self.c4_kv_pool.kv_buffer[item.compress_layer_id]
if ratio == 128:
return self.c128_kv_pool.kv_buffer[item.compress_layer_id]
raise ValueError(f"unsupported compress_ratio={ratio} for get_key_buffer")
def get_value_buffer(self, layer_id: int) -> torch.Tensor:
# V4 uses MQA / latent attention — the K buffer doubles as V.
return self.get_key_buffer(layer_id)
def get_kv_buffer(self, layer_id: int) -> Tuple[torch.Tensor, torch.Tensor]:
buf = self.get_key_buffer(layer_id)
return buf, buf
def get_swa_buffer(
self, layer_id: int, loc: Optional[torch.Tensor] = None
) -> torch.Tensor:
"""Return the SWA layer's KV cache in PA_ND layout
(num_pages, page_size, num_kv_heads=1, dim). When ``loc`` is given,
flatten across (num_pages, page_size) and gather the matching tokens —
shape becomes (num_tokens, 1, dim).
"""
# Index by RAW layer_id, not compress_layer_id (a per-bucket counter that
# would collide across ratios). swa_kv_pool is sized layer_num=total_layers.
kv = self.swa_kv_pool.kv_buffer[layer_id]
if loc is not None:
kv = kv.flatten(0, 1)[loc]
return kv
def get_compress_buffer(
self,
layer_id: int,
from_indexer: bool = False,
loc: Optional[torch.Tensor] = None,
) -> Optional[torch.Tensor]:
"""Return the compressed KV buffer for a c4 / c128 layer.
Routes to c4 / c128 kv_pool by layer compression ratio. Returns
``None`` for ratio == 0 (no compress KV exists). The
from_indexer=True branch returns the dedicated int8 K buffer that
``torch.ops.custom.npu_quant_lightning_indexer`` consumes.
"""
item = self.layer_mapping[layer_id]
if item.compress_ratio == 4:
if from_indexer:
kv = self.c4_indexer_kv_pool.get_index_k(item.compress_layer_id)
else:
kv = self.c4_kv_pool.kv_buffer[item.compress_layer_id]
elif item.compress_ratio == 128:
assert not from_indexer, "c128 has no indexer pool"
kv = self.c128_kv_pool.kv_buffer[item.compress_layer_id]
else:
return None
if loc is not None:
kv = kv.flatten(0, 1)[loc]
return kv
def set_swa_buffer(
self,
layer_id: int,
loc: torch.Tensor,
cache: torch.Tensor,
) -> None:
"""Write ``cache`` into the SWA pool at flat token positions ``loc``.
``cache`` shape: (num_tokens, num_kv_heads=1, dim). The buffer view is
(num_pages, page_size, 1, dim) so we flatten the first two dims and
index_put.
"""
# Index by raw layer_id (see get_swa_buffer) to avoid bucket collision.
buf = self.swa_kv_pool.kv_buffer[layer_id]
buf_flat = buf.flatten(0, 1) # (num_pages * page_size, 1, dim)
# Caller (V4 MQALayer) may hand us cache shaped (T, dim); the buffer has
# an explicit num_kv_heads=1 axis, so insert it.
if cache.ndim == buf_flat.ndim - 1:
cache = cache.unsqueeze(1)
buf_flat[loc] = cache.to(buf_flat.dtype)
# ------------------------------------------------------------------
# NPU port hooks — used by dsv4/{compressor,indexer}.py forward_npu.
# CompressStatePool stores a fused [kv | score] tensor; split is a last-dim slice.
# ------------------------------------------------------------------
def set_state_buffer(
self,
layer_id: int,
loc: torch.Tensor,
kv: torch.Tensor,
score: torch.Tensor,
from_indexer: bool,
) -> None:
# KVAndScore.kv_score is [..., 2*coff*head_dim] = [kv | score].
kv_score = self._get_state_pool(layer_id, from_indexer).kv_score_buffer.kv_score
last_dim = kv_score.shape[-1]
half = last_dim // 2
kv_view = kv.reshape(-1, half).to(kv_score.dtype)
score_view = score.reshape(-1, half).to(kv_score.dtype)
kv_score[loc, :half] = kv_view
kv_score[loc, half:] = score_view
def get_state_buffer(
self,
layer_id: int,
from_indexer: bool,
kv_indices: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
kv_score = self._get_state_pool(layer_id, from_indexer).kv_score_buffer.kv_score
if kv_indices is not None:
kv_score = kv_score[kv_indices]
last_dim = kv_score.shape[-1]
half = last_dim // 2
kv = kv_score[..., :half].unsqueeze(-2) # add num_kv_heads=1 axis
score = kv_score[..., half:].unsqueeze(-2)
return kv, score
def set_compress_buffer(
self,
layer_id: int,
loc: torch.Tensor,
kv: torch.Tensor,
kv_scale: Optional[torch.Tensor],
from_indexer: bool,
) -> None:
# Routes to c4_indexer (from_indexer) / c4_kv (ratio 4) / c128_kv (ratio
# 128). NPU bypasses CUDA fused_store_cache with direct bf16 writes.
ratio, compress_layer_id, _ = self.layer_mapping[layer_id]
device_type = kv.device.type
if from_indexer:
assert ratio == 4, f"indexer only on c4 layers, got ratio={ratio}"
if device_type == "npu":
assert (
self.c4_indexer_kv_pool.has_npu_storage
), "NPU index buffers not allocated — pool was init'd on CUDA?"
self.c4_indexer_kv_pool.set_index_k_scale(
compress_layer_id, loc, kv, kv_scale
)
return
if kv_scale is None:
self.c4_indexer_kv_pool.set_index_fused(compress_layer_id, loc, kv)
return
self.c4_indexer_kv_pool.set_index_k_scale_buffer(
compress_layer_id, loc, kv, kv_scale
)
return
compress_pool = self.c4_kv_pool if ratio == 4 else self.c128_kv_pool
if device_type == "npu":
# PA_ND layout: kv_buffer[layer_id] shape = (num_pages, page_size,
# 1, kv_dim). Flatten (num_pages, page_size) and index by `loc`.
buf = compress_pool.kv_buffer[compress_layer_id]
buf_flat = buf.flatten(0, 1)
kv_view = kv.to(buf_flat.dtype)
if kv_view.ndim == buf_flat.ndim - 1:
kv_view = kv_view.unsqueeze(1)
buf_flat[loc] = kv_view
return
compress_pool.set_key_buffer_fused(compress_layer_id, loc, kv)
def get_compress_dequant_scale_buffer(
self,
layer_id: int,
from_indexer: bool,
) -> torch.Tensor:
# Returns the float16 dequant scale buffer (NPU indexer pool's dedicated
# scale buffer alongside the int8 K buffer).
assert from_indexer, "only indexer compress pool has dequant scale"
compress_layer_id = self.layer_mapping[layer_id].compress_layer_id
return self.c4_indexer_kv_pool.get_index_scale(compress_layer_id)
def translate_kv_loc_to_compress_state_loc(
self,
kv_loc: torch.Tensor,
compress_ratio: int,
) -> torch.Tensor:
# Parent's ring-buffer hash is meaningless under the paged cache_mode=1
# contract; returning a stale value would silently corrupt state. Fail loud.
raise RuntimeError(
"DSV4NPUTokenToKVPool.translate_kv_loc_to_compress_state_loc was "
"called, but the NPU fused compressor kernel uses a paged state "
"pool (cache_mode=1) and does not support ring-buffer state "
"addressing (cache_mode=2 is explicitly unsupported on Atlas A3). "
"Callers must consume out_cache_loc_dsv4.out_c{4,128}_state_loc "
"from the allocator bundle (set during alloc_extend/alloc_decode) "
"and read state_page_table from req_to_token_c{4,128}_state on "
"the DSV4NPUReqToTokenPool instead. See "
"hardware_backend/npu/dsv4_memory_pool.py for the rationale."
)
@@ -0,0 +1,128 @@
"""DSV4-NPU per-request mapping pool.
Subclass of ``ReqToTokenPool`` that adds five auxiliary per-request tables
needed by the DSV4 attention backend:
* ``req_to_token_swa`` — slot ids in the SWA full-pool view
* ``req_to_token_c4`` — slot ids in the c4 compressed-KV pool
* ``req_to_token_c128`` — slot ids in the c128 compressed-KV pool
* ``req_to_token_c4_state`` — c4 state-pool slot ids, 1 per raw token
* ``req_to_token_c128_state`` — c128 state-pool slot ids, 1 per raw token
Compressed KV pools store 1 slot per ``ratio`` raw tokens, so their per-req
table column count is ``max_context_len // ratio``. swa mirrors the raw
token count. Elements are token-level slot ids; the attention backend
converts to page ids via ``// page_size`` when constructing PA_ND block
tables.
The c4/c128 STATE pools also have per-req tables here: the NPU fused
compressor uses a paged state pool (``cache_mode=1``), so each raw token's
state slot id is recorded (1 column per raw token) and the backend builds
``state_block_table = req_to_token_c{N}_state[req, ::page_size] // page_size``
to feed the kernel. (The base class' ``translate_kv_loc_to_compress_state_loc``
ring-hash is the CUDA-only path; it is disabled on NPU.)
Memory cost example (size=64, max_context_len=32K): swa 8MB + c4 2MB +
c128 64KB ≈ 10MB extra on top of the base req_to_token (8MB).
The tables are populated by the ``dsv4_common_hooks`` writers (driven from
``mem_cache/common.py``) immediately after a successful alloc_extend /
alloc_decode, using the per-pool slot indices returned in ``DSV4OutCacheLoc``.
"""
from __future__ import annotations
import torch
from sglang.srt.constants import GPU_MEMORY_TYPE_KV_CACHE
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
class DSV4NPUReqToTokenPool(ReqToTokenPool):
"""ReqToTokenPool extended with DSV4 SWA + c4/c128 per-req tables.
Drop-in replacement for ReqToTokenPool when the model is DeepSeek-V4 on
NPU. Selected by ``model_runner_kv_cache_mixin`` based on model arch +
device. Non-DSV4 and non-NPU paths continue to use the base class.
The auxiliary tables are intentionally NOT zeroed on ``clear()``: they are
indexed only by active rows (via req_pool_idx) and only each row's
``[:seq_len]`` prefix is read, so stale entries past kv_committed_len are
unreachable by the attention metadata builder.
"""
def __init__(
self,
size: int,
max_context_len: int,
device: str,
enable_memory_saver: bool,
):
super().__init__(size, max_context_len, device, enable_memory_saver)
memory_saver_adapter = TorchMemorySaverAdapter.create(
enable=enable_memory_saver
)
# Back-ref to DSV4NPUTokenToKVPoolAllocator, wired via
# register_dsv4_allocator after both exist, so free(req) can release
# c4/c128 pages. None at construction so base clear() runs safely.
self._dsv4_allocator = None
# (name, columns). swa + state tables: 1 slot per raw token; c4/c128:
# 1 slot per `ratio` raw tokens. Init zero so unallocated columns map to
# block 0 (kernel skip sentinel cleared by NPUCompressStatePool).
with memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
for name, cols in (
("req_to_token_swa", max_context_len),
("req_to_token_c4", max(1, max_context_len // 4)),
("req_to_token_c128", max(1, max_context_len // 128)),
("req_to_token_c4_state", max_context_len),
("req_to_token_c128_state", max_context_len),
):
setattr(
self,
name,
torch.zeros(
(self._alloc_size, cols),
dtype=torch.int32,
device=device,
),
)
# ------------------------------------------------------------------
# Per-pool write helpers, called by mem_cache/common.py after alloc, using
# slot indices from DSV4OutCacheLoc. Args: (req_pool_idx, token_offset), slot.
# ------------------------------------------------------------------
def write_swa(self, indices, values: torch.Tensor) -> None:
self.req_to_token_swa[indices] = values
def write_c4(self, indices, values: torch.Tensor) -> None:
self.req_to_token_c4[indices] = values
def write_c128(self, indices, values: torch.Tensor) -> None:
self.req_to_token_c128[indices] = values
def write_c4_state(self, indices, values: torch.Tensor) -> None:
self.req_to_token_c4_state[indices] = values
def write_c128_state(self, indices, values: torch.Tensor) -> None:
self.req_to_token_c128_state[indices] = values
def register_dsv4_allocator(self, allocator) -> None:
"""Wire the DSV4NPUTokenToKVPoolAllocator ref so ``free(req)`` can
release c4/c128 pool pages alongside the req_pool_idx slot. This is a
one-way ref (pool -> allocator). The reverse direction (the allocator
reading these per-req tables for its c-pool / state last_loc lookup) is
no longer a stored back-ref: mem_cache/common.py passes this pool into
``alloc_extend`` / ``alloc_decode`` per call instead."""
self._dsv4_allocator = allocator
def free(self, req):
# Trigger c4/c128 free via the allocator's unified free path. May be None
# between __init__ and register_dsv4_allocator — defensive None check.
if self._dsv4_allocator is not None:
self._dsv4_allocator.free(req=req, req_to_token_pool=self)
super().free(req)
@@ -0,0 +1,49 @@
# Copyright 2024-2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from sglang.srt.configs.model_config import is_deepseek_dsa
from sglang.srt.speculative.eagle_draft_extend_cuda_graph_runner import (
EAGLEDraftExtendCudaGraphRunner,
)
if TYPE_CHECKING:
from sglang.srt.speculative.eagle_worker_v2 import EagleDraftWorker
class EAGLEDraftExtendNpuGraphRunner(EAGLEDraftExtendCudaGraphRunner):
def __init__(self, eagle_worker: EagleDraftWorker):
super().__init__(eagle_worker)
def _cache_loc_dtype(self):
return torch.int32
def _replay_graph(self, shape_key, forward_batch):
if not is_deepseek_dsa(self.model_runner.model_config.hf_config):
seq_lens = forward_batch.seq_lens_cpu.tolist() + [0] * (
self.bs - self.raw_bs
)
return self.backend.replay_with_input_update(
shape_key,
seq_lens=seq_lens,
attr_name="actual_seq_lengths_kv",
attr_type=[],
)
else:
return self.backend.replay(shape_key, forward_batch)
@@ -0,0 +1,69 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Union
import torch
from sglang.srt.configs.model_config import AttentionArch, is_deepseek_dsa
from sglang.srt.speculative.eagle_draft_cuda_graph_runner import (
EAGLEDraftCudaGraphRunner,
)
if TYPE_CHECKING:
from sglang.srt.speculative.eagle_worker_v2 import EagleDraftWorker
class EAGLEDraftNpuGraphRunner(EAGLEDraftCudaGraphRunner):
def __init__(self, eagle_worker: EagleDraftWorker):
self._init_arch_map()
super().__init__(eagle_worker)
def _init_arch_map(self):
self.attr_name: Dict[str, str] = {
AttentionArch.MLA: "actual_seq_lengths_kv",
AttentionArch.MHA: "context_lens",
}
self.attr_type: Dict[str, Union[list, torch.Tensor]] = {
AttentionArch.MLA: [],
AttentionArch.MHA: torch.Tensor(),
}
def _cache_loc_dtype(self):
return torch.int32
def _get_update_attr_name(self):
return self.attr_name[AttentionArch.MLA]
def _get_update_attr_type(self):
return self.attr_type[AttentionArch.MLA]
def _replay_graph(self, shape_key, forward_batch):
if not is_deepseek_dsa(self.model_runner.model_config.hf_config):
seq_lens_for_each_draft_step = []
for speculative_step_id in range(self.speculative_num_steps - 1):
seq_lens_cpu = (
forward_batch.seq_lens_cpu[: self.raw_bs] + speculative_step_id + 1
)
seq_lens = seq_lens_cpu.tolist() + [0] * (self.bs - self.raw_bs)
seq_lens_for_each_draft_step.append(seq_lens)
attr_name = self._get_update_attr_name()
cpu_update_input = [{attr_name: sl} for sl in seq_lens_for_each_draft_step]
return self.backend.replay_with_input_update(
shape_key, seq_lens=None, cpu_update_input=cpu_update_input
)
else:
return self.backend.replay(shape_key, forward_batch)
@@ -0,0 +1,54 @@
# Copyright 2024-2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from sglang.srt.model_executor.cuda_graph_config import cuda_graph_fully_disabled
from sglang.srt.speculative.multi_layer_eagle_draft_extend_cuda_graph_runner import (
MultiLayerEagleDraftExtendCudaGraphRunner,
MultiLayerEagleMultiStepDraftExtendCudaGraphRunner,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
pass
class MultiLayerEagleDraftExtendNpuGraphRunner(
MultiLayerEagleDraftExtendCudaGraphRunner
):
def _replay_graph(self, shape_key, forward_batch):
seq_lens = self.buffers.seq_lens_cpu[: self.raw_bs].tolist() + [0] * (
self.bs - self.raw_bs
)
return self.backend.replay_with_input_update(
shape_key,
seq_lens=seq_lens,
attr_name="actual_seq_kvlen",
attr_type=[],
)
class MultiLayerEagleMultiStepDraftExtendNpuGraphRunner(
MultiLayerEagleMultiStepDraftExtendCudaGraphRunner
):
def _create_runner(self, step: int) -> MultiLayerEagleDraftExtendNpuGraphRunner:
return MultiLayerEagleDraftExtendNpuGraphRunner(self.eagle_worker, step)
def _cuda_graph_disabled(self) -> bool:
return cuda_graph_fully_disabled()
@@ -0,0 +1,177 @@
"""NPUCudaGraphBackend — Ascend NPU full-graph capture (torch.npu.NPUGraph).
Mirrors FullCudaGraphBackend with two differences:
- Captures via torch.npu.graph(...) into torch.npu.NPUGraph.
- replay_with_input_update(shape_key, seq_lens, attr_name) rebinds
the recorded graph's input bindings for variable seq_lens at replay
time (NPU's NPUGraph.update(...) API).
torch.npu is imported lazily inside methods so the module loads on
non-NPU hosts.
"""
from __future__ import annotations
import threading
from contextlib import AbstractContextManager, contextmanager
from functools import partial
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
import numpy as np
import torch
from sglang.srt.constants import GPU_MEMORY_TYPE_CUDA_GRAPH
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
set_graph_pool_id,
)
from sglang.srt.model_executor.runner.shape_key import ShapeKey
from sglang.srt.model_executor.runner_backend.base_cuda_graph_backend import (
BaseCudaGraphBackend,
)
from sglang.srt.utils import empty_context, get_bool_env_var
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
if TYPE_CHECKING:
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.model_executor.runner.base_cuda_graph_runner import (
BaseCudaGraphRunner,
)
class NPUCudaGraphBackend(BaseCudaGraphBackend):
"""One torch.npu.NPUGraph per shape; attention metadata captured
inside the graph. replay_with_input_update substitutes fresh
seq_lens without re-recording."""
def __init__(
self,
cuda_graph_runner: BaseCudaGraphRunner,
*,
enable_memory_saver: bool = False,
) -> None:
self._graphs: Dict[Any, Any] = {}
self._outputs: Dict[Any, Any] = {}
self._pool = None
self._device_module = cuda_graph_runner.device_module
self._tp_group = cuda_graph_runner.model_runner.tp_group
self._capture_stream = None
self._memory_saver_adapter: Optional[Any] = TorchMemorySaverAdapter.create(
enable=enable_memory_saver
and get_bool_env_var("SGLANG_MEMORY_SAVER_CUDA_GRAPH")
)
self._enable_torch_compile = getattr(
cuda_graph_runner, "enable_torch_compile", False
)
@contextmanager
def capture_session(self, stream):
if self._pool is None:
self._pool = self._device_module.graph_pool_handle()
set_graph_pool_id(self._pool)
self._capture_stream = stream
try:
yield
finally:
self._capture_stream = None
def capture_one(
self,
shape_key: ShapeKey,
forward_fn: Callable[[], Any],
dummies: Optional[Any] = None,
post_warmup_hook: Optional[Callable[[], None]] = None,
) -> None:
import torch_npu # noqa: F401 (verifies NPU availability)
# Two warmups so kernels are loaded and one-time setup is paid before capture.
# post_warmup_hook lets the attention backend reset state that warmup mutated.
for _ in range(2):
self._device_module.synchronize()
self._tp_group.barrier()
forward_fn()
if post_warmup_hook is not None:
post_warmup_hook()
graph = torch.npu.NPUGraph()
if self._enable_torch_compile:
skip_guard_context = torch.compiler.set_stance(skip_guard_eval_unsafe=True)
else:
skip_guard_context = empty_context()
graph_ctx: Callable[..., AbstractContextManager]
if (
self._memory_saver_adapter is not None
and self._memory_saver_adapter.enabled
):
graph_ctx = partial(
self._memory_saver_adapter.cuda_graph,
tag=GPU_MEMORY_TYPE_CUDA_GRAPH,
)
else:
graph_ctx = torch.npu.graph
with skip_guard_context, graph_ctx(
graph,
pool=self._pool,
stream=self._capture_stream,
auto_dispatch_capture=True,
):
out = forward_fn()
self._graphs[shape_key] = graph
self._outputs[shape_key] = out
def can_run(self, forward_batch: ForwardBatch, shape_key: ShapeKey) -> bool:
return shape_key in self._graphs
@contextmanager
def replay_session(self):
yield
def replay(
self,
shape_key: ShapeKey,
static_forward_batch: ForwardBatch,
**kwargs,
) -> Any:
self._graphs[shape_key].replay()
return self._outputs[shape_key]
def replay_with_input_update(
self,
shape_key: ShapeKey,
seq_lens: Any,
attr_name: str = None,
attr_type: Any = None,
cpu_update_input: list = None,
) -> Any:
"""Rebind seq_lens on the recorded NPU graph in a background
thread, then replay. Used when the model is not deepseek-nsa.
Two calling conventions:
1. (legacy) seq_lens + attr_name + attr_type:
Constructs cpu_update_input=[{attr_name: seq_lens}] internally.
2. cpu_update_input: A list of {attr_name: seq_lens} dicts,
one per speculative step. Used by EAGLE draft runners.
"""
if cpu_update_input is None:
if isinstance(attr_type, torch.Tensor):
seq_lens = torch.from_numpy(np.array(seq_lens).astype(np.int32))
cpu_update_input = [{attr_name: seq_lens}]
graph = self._graphs[shape_key]
def _update():
graph.update(cpu_update_input=cpu_update_input)
thread = threading.Thread(target=_update)
thread.start()
graph.replay()
thread.join()
return self._outputs[shape_key]
def cleanup(self) -> None:
self._graphs.clear()
self._outputs.clear()
self._pool = None
@@ -0,0 +1,284 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Run the model with NPU graph and torch.compile.
NPUGraphRunner is a thin subclass of DecodeCudaGraphRunner: the
factory returns NPUCudaGraphBackend for NPU devices, so all
capture/replay mechanics live in the backend. This class adds:
- NPU-specific patch_model monkey-patch for the decode-Full +
torch.compile path.
- Profile context override (NPU profiler emits to disk, not in-mem).
- Replay override that issues an async NPUGraph.update for
seq_lens before replay (skipped for deepseek-nsa).
- Smaller cache_loc dtype (int32 instead of int64).
"""
from __future__ import annotations
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Dict, Optional, Union
import numpy as np
import torch
from sglang.srt.configs.model_config import (
AttentionArch,
is_deepseek_dsa,
is_deepseek_v4,
)
from sglang.srt.distributed.parallel_state import GroupCoordinator
from sglang.srt.environ import envs
from sglang.srt.model_executor.runner import DecodeCudaGraphRunner
from sglang.srt.utils import (
empty_context,
get_bool_env_var,
get_compiler_backend,
is_npu,
)
is_npu = is_npu()
if is_npu:
import torch_npu
from torch_npu.profiler import ProfilerActivity, profile
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors
@contextmanager
def patch_model_npu(
model: torch.nn.Module,
enable_compile: bool,
num_tokens: int,
tp_group: GroupCoordinator,
):
if enable_compile:
backend = get_compiler_backend("npugraph_ex")
yield torch.compile(
torch.no_grad()(model.forward),
fullgraph=True,
dynamic=False,
backend=backend,
)
else:
yield model.forward
class NPUGraphRunner(DecodeCudaGraphRunner):
"""A NPUGraphRunner runs the forward pass of a model with NPU graph and torch.compile."""
def __init__(
self,
model_runner: ModelRunner,
*,
attn_backend=None,
speculative_num_steps: Optional[int] = None,
speculative_num_draft_tokens: Optional[int] = None,
):
# NPU patch_model override: monkey-patch torch_compile_decoration's
# patch_model with the NPU-specific version.
from sglang.srt.compilation import torch_compile_decoration
torch_compile_decoration.patch_model = patch_model_npu
super().__init__(
model_runner,
attn_backend=attn_backend,
speculative_num_steps=speculative_num_steps,
speculative_num_draft_tokens=speculative_num_draft_tokens,
)
self.update_attr_name = None
self.update_attr_type = None
self.model_runner = model_runner
self._init_arch_map()
self.use_fia = get_bool_env_var("ASCEND_USE_FIA", "False")
self.if_use_v2 = any(
arch
in ("MiMoV2ForCausalLM", "MiMoV2FlashForCausalLM", "Step3p5ForCausalLM")
for arch in (model_runner.model_config.hf_config.architectures or [])
)
def _init_arch_map(self):
if self.is_dllm:
self.attr_name: Dict[str, str] = {
AttentionArch.MLA: "actual_seq_lengths_kv",
AttentionArch.MHA: "actual_seq_lengths_kv",
"TARGET_VERIFY": "actual_seq_kvlen",
}
else:
self.attr_name: Dict[str, str] = {
AttentionArch.MLA: "actual_seq_lengths_kv",
AttentionArch.MHA: "context_lens",
"TARGET_VERIFY": "actual_seq_kvlen",
}
self.attr_type: Dict[str, Union[list, torch.Tensor]] = {
AttentionArch.MLA: [],
AttentionArch.MHA: torch.Tensor(),
"TARGET_VERIFY": [],
}
def _create_device_graph(self):
return torch.npu.NPUGraph()
def _capture_graph(self, graph, pool, stream, run_once_fn):
if self.enable_torch_compile:
skip_guard_context = torch.compiler.set_stance(skip_guard_eval_unsafe=True)
else:
skip_guard_context = empty_context()
with (
skip_guard_context,
torch.npu.graph(
graph,
pool=pool,
stream=stream,
auto_dispatch_capture=True,
),
):
out = run_once_fn()
return out
def _get_update_attr_name(self):
if self.if_use_v2:
return self.attr_name["TARGET_VERIFY"]
return self.attr_name[AttentionArch.MLA]
def _get_update_attr_type(self):
if self.if_use_v2:
return self.attr_type["TARGET_VERIFY"]
return self.attr_type[AttentionArch.MLA]
def _update_inputs(self, seq_lens):
if isinstance(self.update_attr_type, torch.Tensor):
seq_lens = torch.from_numpy(np.array(seq_lens).astype(np.int32))
self.graphs[self.bs].update(
cpu_update_input=[{self.update_attr_name: seq_lens}]
)
def _cache_loc_dtype(self):
return torch.int32
def _init_profile_context_and_memory_record(self):
output_dir = os.path.join(
os.getenv("SGLANG_TORCH_PROFILER_DIR", "/tmp"), "graph_capture_profile"
)
if not Path(output_dir).exists():
Path(output_dir).mkdir(parents=True, exist_ok=True)
logger.info(
f"Profiling starts for graph capture for NPU. Traces will be saved to: {output_dir}"
)
experimental_config = torch_npu.profiler._ExperimentalConfig(
export_type=[torch_npu.profiler.ExportType.Text],
profiler_level=torch_npu.profiler.ProfilerLevel.Level1,
)
profile_context = profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.NPU],
record_shapes=True,
profile_memory=True,
on_trace_ready=torch_npu.profiler.tensorboard_trace_handler(
output_dir, async_mode=True
),
experimental_config=experimental_config,
)
return profile_context
def _post_process_after_profile(self, prof_context):
# for NPU, profile data will be saved to disk for further analysis.
pass
def execute(
self,
forward_batch: ForwardBatch,
pp_proxy_tensors: Optional[PPProxyTensors] = None,
) -> Union[LogitsProcessorOutput, PPProxyTensors]:
if forward_batch.needs_forward_metadata_init():
self.load_batch(forward_batch, pp_proxy_tensors)
else:
# In speculative decoding, these two fields are still needed.
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 (
self.model_runner.spec_algorithm.is_dflash()
and self.model_runner.is_draft_worker
and forward_batch.input_embeds is not None
):
self.buffers.input_embeds[: self.raw_num_token].copy_(
forward_batch.input_embeds
)
if (
envs.SGLANG_ENABLE_OVERLAP_PLAN_STREAM.get()
and forward_batch.mrope_positions is not None
):
self.buffers.mrope_positions[:, : self.raw_num_token].copy_(
forward_batch.mrope_positions
)
graph_key = self._make_graph_key(self.bs)
if not (
is_deepseek_dsa(self.model_runner.model_config.hf_config)
or is_deepseek_v4(self.model_runner.model_config.hf_config)
):
if forward_batch.forward_mode.is_target_verify():
seq_lens_cpu = forward_batch.seq_lens.cpu() + self.num_tokens_per_bs
seq_lens = seq_lens_cpu.tolist() + [0] * (self.bs - self.raw_bs)
else:
seq_lens = forward_batch.seq_lens.cpu().tolist() + [0] * (
self.bs - self.raw_bs
)
output = self.backend.replay_with_input_update(
graph_key,
seq_lens=seq_lens,
attr_name=self._get_update_attr_name(),
attr_type=self._get_update_attr_type(),
)
else:
output = self.backend.replay(graph_key, forward_batch)
if isinstance(output, LogitsProcessorOutput):
if self.is_dllm:
next_token_logits = None
full_logits = (
output.full_logits[: self.raw_num_token]
if output.full_logits is not None
else None
)
else:
full_logits = None
next_token_logits = (
output.next_token_logits[: self.raw_num_token]
if output.next_token_logits is not None
else None
)
return LogitsProcessorOutput(
next_token_logits=next_token_logits,
full_logits=full_logits,
hidden_states=(
output.hidden_states[: self.raw_num_token]
if output.hidden_states is not None
else None
),
)
else:
assert isinstance(output, PPProxyTensors)
return PPProxyTensors({k: v[: self.bs] for k, v in output.tensors.items()})
@@ -0,0 +1,229 @@
# Copyright 2023-2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""ViT NPU Graph Runner class."""
from __future__ import annotations
from typing import Dict, Hashable, List, Optional, Tuple
import torch
import torch.nn as nn
import torch_npu
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
set_graph_pool_id,
)
from sglang.srt.layers.attention.vision import VisionAttention
from sglang.srt.multimodal.vit_cuda_graph_runner import ViTCudaGraphRunner
from sglang.srt.runtime_context import get_server_args
class ViTNpuGraphRunner(ViTCudaGraphRunner):
"""Generic ViT NPU Graph Runner.
This runner captures the "blocks + merger + deepstack merger (optional)" part
of a vision transformer into a NPU graph and replays it for identical shapes.
Optional for Qwen3 deepstack:
- vit.deepstack_vision_indexes: Sequence[int]
- vit.deepstack_merger_list: nn.ModuleList (same length as deepstack_vision_indexes)
"""
_graph_memory_pool = None
def __init__(
self,
vit: nn.Module,
) -> None:
super().__init__(vit)
self.device_module = torch.get_device_module(self.device)
self.cu_seq_lens: Dict[Hashable, torch.Tensor] = {}
# rotary position buffers shared across graphs
self.sin_cos_ws: Dict[Hashable, Tuple[torch.Tensor, torch.Tensor]] = {}
@property
def device(self) -> torch.device:
return self.vit.device
@property
def dtype(self) -> torch.dtype:
return self.vit.dtype
def _create_graph(
self,
graph_key: int,
):
graph = torch_npu.npu.NPUGraph()
vit = self.vit
override_backend = get_server_args().mm_attention_backend
with torch_npu.npu.graph(graph, pool=ViTNpuGraphRunner._graph_memory_pool):
y = None
deepstack_outs: List[torch.Tensor] = []
deepstack_capture_idx = 0
for layer_num, blk in enumerate(vit.blocks):
if override_backend == "ascend_attn":
cu_seq_lens = self.cu_seq_lens[graph_key]
else:
raise RuntimeError("Not supported ViT attention backend")
if layer_num == 0:
y = blk(
self.block_input[graph_key],
cu_seqlens=cu_seq_lens,
rotary_pos_emb_cos=self.sin_cos_ws[graph_key][0],
rotary_pos_emb_sin=self.sin_cos_ws[graph_key][1],
output_ws=self.block_ws[graph_key],
)
else:
y = blk(
y,
cu_seqlens=cu_seq_lens,
rotary_pos_emb_cos=self.sin_cos_ws[graph_key][0],
rotary_pos_emb_sin=self.sin_cos_ws[graph_key][1],
output_ws=self.block_ws[graph_key],
)
# Optional deepstack support (Qwen3-VL)
if (
self._deepstack_visual_indexes
and layer_num in self._deepstack_visual_indexes
):
if self._deepstack_merger_list is None:
raise RuntimeError(
"deepstack_visual_indexes exists but deepstack_merger_list is missing."
)
deepstack_out = self._deepstack_merger_list[deepstack_capture_idx](
y
)
deepstack_outs.append(deepstack_out)
deepstack_capture_idx += 1
main_out = vit.merger(y)
if deepstack_outs:
self.block_output[graph_key] = torch.cat(
[main_out] + deepstack_outs, dim=1
)
else:
self.block_output[graph_key] = main_out
self.block_graphs[graph_key] = graph
def create_graph(
self,
x_3d: torch.Tensor, # [S, 1, H]
cu_seqlens: torch.Tensor,
rotary_pos_emb_cos: Optional[torch.Tensor] = None,
rotary_pos_emb_sin: Optional[torch.Tensor] = None,
) -> int:
vit = self.vit
graph_key = self._get_graph_key(x_3d)
if graph_key in self.block_graphs:
return graph_key
if ViTNpuGraphRunner._graph_memory_pool is None:
ViTNpuGraphRunner._graph_memory_pool = (
self.device_module.graph_pool_handle()
)
# Set graph pool id globally to be able to use symmetric memory
set_graph_pool_id(ViTNpuGraphRunner._graph_memory_pool)
# pre-allocate workspace
attn_module: VisionAttention = vit.blocks[0].attn
num_heads = attn_module.num_attention_heads_per_partition
attn_head_dim = attn_module.head_size
if graph_key not in self.block_output:
self.block_output[graph_key] = x_3d
self.block_input[graph_key] = x_3d
self.block_ws[graph_key] = torch.empty(
graph_key,
num_heads,
attn_head_dim,
device=self.device,
dtype=self.dtype,
)
if rotary_pos_emb_cos is not None and rotary_pos_emb_sin is not None:
self.sin_cos_ws[graph_key] = (rotary_pos_emb_cos, rotary_pos_emb_sin)
if graph_key not in self.cu_seq_lens:
seq_lens = cu_seqlens[1:] - cu_seqlens[:-1]
self.cu_seq_lens[graph_key] = seq_lens.to("cpu").to(torch.int32)
if rotary_pos_emb_cos is not None and rotary_pos_emb_sin is not None:
self._create_graph(
graph_key=graph_key,
)
return graph_key
def replay(
self,
graph_key: int,
x_3d: torch.Tensor,
rotary_pos_emb_cos: Optional[torch.Tensor] = None,
rotary_pos_emb_sin: Optional[torch.Tensor] = None,
output_indices: Optional[torch.Tensor] = None,
) -> torch.Tensor:
if rotary_pos_emb_cos is not None and rotary_pos_emb_sin is not None:
# update rotary workspace content
self.sin_cos_ws[graph_key][0].copy_(rotary_pos_emb_cos)
self.sin_cos_ws[graph_key][1].copy_(rotary_pos_emb_sin)
# copy input
self.block_input[graph_key].copy_(x_3d)
# replay
self.block_graphs[graph_key].replay()
out = self.block_output[graph_key]
# Optional output reordering (Qwen2.5-VL window permutation inverse)
if output_indices is not None:
out = out.index_select(0, output_indices)
return out
def run(
self,
x: torch.Tensor,
cu_seqlens: torch.Tensor,
rotary_pos_emb_cos: Optional[torch.Tensor] = None,
rotary_pos_emb_sin: Optional[torch.Tensor] = None,
output_indices: Optional[torch.Tensor] = None,
) -> torch.Tensor:
# x: [seq_len, hidden] -> [S, B=1, H]
x_3d = x.unsqueeze(1)
graph_key = self._get_graph_key(x_3d)
if graph_key not in self.block_graphs:
self.create_graph(
x_3d=x_3d,
cu_seqlens=cu_seqlens,
rotary_pos_emb_cos=rotary_pos_emb_cos,
rotary_pos_emb_sin=rotary_pos_emb_sin,
)
return self.replay(
graph_key=graph_key,
x_3d=x_3d,
rotary_pos_emb_cos=rotary_pos_emb_cos,
rotary_pos_emb_sin=rotary_pos_emb_sin,
output_indices=output_indices,
)
@@ -0,0 +1,553 @@
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.constants import GPU_MEMORY_TYPE_KV_CACHE
from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPool,
MLATokenToKVPool,
get_tensor_size_bytes,
unwrap_write_loc,
)
from sglang.srt.utils import get_bool_env_var
from sglang.srt.utils.common import is_npu
if TYPE_CHECKING:
from sglang.srt.layers.radix_attention import RadixAttention
if is_npu():
import torch_npu
def _init_npu_conv_state(
conv_state_in, conv_state_shape, speculative_num_draft_tokens: Optional[int] = None
):
extra_conv_len = 0
if speculative_num_draft_tokens is not None:
extra_conv_len = speculative_num_draft_tokens - 1
# conv_state shape (layers, pool_size, conv_wind + draft_step, dim) for conv1d ascendc ops require dim as last dim
conv_state = [
torch.zeros(
size=(
conv_state_in.shape[0],
conv_state_in.shape[1],
conv_shape[1] + extra_conv_len,
conv_shape[0],
),
dtype=conv_state_in.dtype,
device=conv_state_in.device,
)
for conv_shape in conv_state_shape
]
return conv_state
class NPUMHATokenToKVPool(MHATokenToKVPool):
def __init__(
self,
size: int,
page_size: int,
dtype: torch.dtype,
head_num: int,
head_dim: int,
layer_num: int,
device: str,
enable_memory_saver: bool,
v_head_dim: Optional[int] = None,
swa_head_num: Optional[int] = None,
swa_head_dim: Optional[int] = None,
swa_v_head_dim: Optional[int] = None,
start_layer: Optional[int] = None,
end_layer: Optional[int] = None,
enable_alt_stream: bool = True,
enable_kv_cache_copy: bool = False,
**kwargs,
):
self.use_fia = get_bool_env_var("ASCEND_USE_FIA", "False")
super().__init__(
size=size,
page_size=page_size,
dtype=dtype,
head_num=head_num,
head_dim=head_dim,
layer_num=layer_num,
device=device,
enable_memory_saver=enable_memory_saver,
v_head_dim=v_head_dim,
swa_head_num=swa_head_num,
swa_head_dim=swa_head_dim,
swa_v_head_dim=swa_v_head_dim,
start_layer=start_layer,
end_layer=end_layer,
enable_alt_stream=enable_alt_stream,
enable_kv_cache_copy=enable_kv_cache_copy,
**kwargs,
)
def _create_buffers(self):
with self.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
# [size, head_num, head_dim] for each layer
# The padded slot 0 is used for writing dummy outputs from padded tokens.
# Continuous memory improves the efficiency of Ascend`s transmission backend,
# while other backends remain unchanged.
self.k_buffer = torch.zeros(
(
self.layer_num,
self.size // self.page_size + 1,
self.page_size,
self.head_num,
self.head_dim,
),
dtype=self.store_dtype,
device=self.device,
)
self.v_buffer = torch.zeros(
(
self.layer_num,
self.size // self.page_size + 1,
self.page_size,
self.head_num,
self.v_head_dim,
),
dtype=self.store_dtype,
device=self.device,
)
if self.use_fia:
# Use per-layer Python lists to avoid torch.compile capturing
# the entire multi-layer tensor (OOM during graph capture).
# Each layer view: [P*ps, 1, H, D], sharing the contiguous
# storage allocated above.
self.k_buffer = [
self.k_buffer[i].view(-1, 1, self.head_num, self.head_dim)
for i in range(self.layer_num)
]
self.v_buffer = [
self.v_buffer[i].view(-1, 1, self.head_num, self.v_head_dim)
for i in range(self.layer_num)
]
def _init_kv_copy_and_warmup(self):
# implementation relies on self.data_strides / self.data_ptrs, which the
# NPU paged buffer layout never builds.
self._kv_copy_config = None
# for disagg
def get_contiguous_buf_infos(self):
# layer_num x [seq_len, head_num, head_dim]
# layer_num x [page_num, page_size, head_num, head_dim]
kv_data_ptrs = [
self.get_key_buffer(i).data_ptr()
for i in range(self.start_layer, self.start_layer + self.layer_num)
] + [
self.get_value_buffer(i).data_ptr()
for i in range(self.start_layer, self.start_layer + self.layer_num)
]
kv_data_lens = [
self.get_key_buffer(i).nbytes
for i in range(self.start_layer, self.start_layer + self.layer_num)
] + [
self.get_value_buffer(i).nbytes
for i in range(self.start_layer, self.start_layer + self.layer_num)
]
if self.use_fia:
kv_item_lens = [
self.get_key_buffer(i)[0].nbytes * self.page_size
for i in range(self.start_layer, self.start_layer + self.layer_num)
] + [
self.get_value_buffer(i)[0].nbytes * self.page_size
for i in range(self.start_layer, self.start_layer + self.layer_num)
]
else:
kv_item_lens = [
self.get_key_buffer(i)[0].nbytes
for i in range(self.start_layer, self.start_layer + self.layer_num)
] + [
self.get_value_buffer(i)[0].nbytes
for i in range(self.start_layer, self.start_layer + self.layer_num)
]
return kv_data_ptrs, kv_data_lens, kv_item_lens
def set_kv_buffer(
self,
layer: "RadixAttention",
loc_info,
cache_k: torch.Tensor,
cache_v: torch.Tensor,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
layer_id_override: Optional[int] = None,
dcp_kv_mask: Optional[torch.Tensor] = None,
):
loc, _, _ = unwrap_write_loc(loc_info)
if layer_id_override is not None:
layer_id = layer_id_override
else:
layer_id = layer.layer_id
if cache_k.dtype != self.dtype:
if k_scale is not None:
cache_k.div_(k_scale)
if v_scale is not None:
cache_v.div_(v_scale)
cache_k = cache_k.to(self.dtype)
cache_v = cache_v.to(self.dtype)
if self.store_dtype != self.dtype:
cache_k = cache_k.view(self.store_dtype)
cache_v = cache_v.view(self.store_dtype)
if self.use_fia:
k_buffer_layer = self.k_buffer[layer_id - self.start_layer]
v_buffer_layer = self.v_buffer[layer_id - self.start_layer]
torch_npu.npu_scatter_nd_update_(
k_buffer_layer,
loc.view(-1, 1),
cache_k.view(-1, 1, self.head_num, self.head_dim),
)
torch_npu.npu_scatter_nd_update_(
v_buffer_layer,
loc.view(-1, 1),
cache_v.view(-1, 1, self.head_num, self.v_head_dim),
)
else:
loc = loc.to(torch.int32)
torch_npu._npu_reshape_and_cache(
key=cache_k,
value=cache_v,
key_cache=self.k_buffer[layer_id - self.start_layer].view(
-1, self.page_size, self.head_num, self.head_dim
),
value_cache=self.v_buffer[layer_id - self.start_layer].view(
-1, self.page_size, self.head_num, self.v_head_dim
),
slot_indices=loc,
)
def _chunk_copy_npu_to_cpu(self, buf_of_layers, indices):
chunk_size = self.cpu_offloading_chunk_size
out = []
for tensors_per_layer in buf_of_layers: # [k_buf, v_buf]
layer_chunks = []
for i in range(0, len(indices), chunk_size):
ci = indices[i : i + chunk_size]
layer_chunks.append(
[
t[ci].to("cpu", non_blocking=True)
for t in tensors_per_layer
if t is not None
]
)
out.append(layer_chunks)
return out
# Parent MHATokenToKVPool.get_cpu_copy / load_cpu_copy use
# `self.k_buffer[layer_id][chunk_indices]` which indexes the first dim.
# NPUMHATokenToKVPool stores buffers as
# (num_pages, page_size, head_num, head_dim) # use_fia=False
# (num_pages*page_size, 1, head_num, head_dim) # use_fia=True
def get_cpu_copy(self, indices, mamba_indices=None):
torch.npu.synchronize()
buf_of_layers = []
for local_layer_id in range(self.layer_num):
k_layer = self.k_buffer[local_layer_id].view(
-1, self.head_num, self.head_dim
)
v_layer = self.v_buffer[local_layer_id].view(
-1, self.head_num, self.head_dim
)
buf_of_layers.append([k_layer, v_layer])
kv_cache_cpu = self._chunk_copy_npu_to_cpu(buf_of_layers, indices)
torch.npu.synchronize()
return kv_cache_cpu
def load_cpu_copy(self, kv_cache_cpu, indices, mamba_indices=None):
torch.npu.synchronize()
chunk_size = self.cpu_offloading_chunk_size
for local_layer_id in range(self.layer_num):
k_layer = self.k_buffer[local_layer_id].view(
-1, self.head_num, self.head_dim
)
v_layer = self.v_buffer[local_layer_id].view(
-1, self.head_num, self.head_dim
)
for i in range(0, len(indices), chunk_size):
chunk_indices = indices[i : i + chunk_size]
k_cpu, v_cpu = (
kv_cache_cpu[local_layer_id][i // chunk_size][0],
kv_cache_cpu[local_layer_id][i // chunk_size][1],
)
assert k_cpu.shape[0] == v_cpu.shape[0] == len(chunk_indices)
k_layer[chunk_indices] = k_cpu.to(k_layer.device, non_blocking=True)
v_layer[chunk_indices] = v_cpu.to(v_layer.device, non_blocking=True)
torch.npu.synchronize()
class NPUMLATokenToKVPool(MLATokenToKVPool):
def __init__(
self,
size: int,
page_size: int,
dtype: torch.dtype,
kv_lora_rank: int,
qk_rope_head_dim: int,
index_head_dim: Optional[int],
layer_num: int,
device: str,
enable_memory_saver: bool,
start_layer: Optional[int] = None,
end_layer: Optional[int] = None,
):
super(MLATokenToKVPool, self).__init__(
size=size,
page_size=page_size,
dtype=dtype,
layer_num=layer_num,
device=device,
enable_memory_saver=enable_memory_saver,
start_layer=start_layer,
end_layer=end_layer,
)
self.kv_lora_rank = kv_lora_rank
self.qk_rope_head_dim = qk_rope_head_dim
self.index_head_dim = index_head_dim
self.custom_mem_pool = None
with self.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
# The padded slot 0 is used for writing dummy outputs from padded tokens.
self.k_buffer = torch.zeros(
(
layer_num,
self.size // self.page_size + 1,
self.page_size,
1,
self.kv_lora_rank,
),
dtype=self.store_dtype,
device=self.device,
)
self.v_buffer = torch.zeros(
(
layer_num,
self.size // self.page_size + 1,
self.page_size,
1,
self.qk_rope_head_dim,
),
dtype=self.store_dtype,
device=self.device,
)
self.index_k_buffer = None
if self.index_head_dim is not None:
self.index_k_buffer = torch.zeros(
(
layer_num,
self.size // self.page_size + 1,
self.page_size,
1,
self.index_head_dim,
),
dtype=self.store_dtype,
device=self.device,
)
self._finalize_allocation_log(size)
def get_kv_size_bytes(self):
assert hasattr(self, "k_buffer")
assert hasattr(self, "v_buffer")
kv_size_bytes = 0
for k_cache in self.k_buffer:
kv_size_bytes += get_tensor_size_bytes(k_cache)
for v_cache in self.v_buffer:
kv_size_bytes += get_tensor_size_bytes(v_cache)
if self.index_head_dim is not None:
assert hasattr(self, "index_k_buffer")
for index_k_cache in self.index_k_buffer:
kv_size_bytes += get_tensor_size_bytes(index_k_cache)
return kv_size_bytes
def get_kv_buffer(self, layer_id: int):
if self.layer_transfer_counter is not None:
self.layer_transfer_counter.wait_until(layer_id - self.start_layer)
return (
self.k_buffer[layer_id - self.start_layer],
self.v_buffer[layer_id - self.start_layer],
)
def get_state_buf_infos(self):
if self.index_head_dim is None:
return [], [], []
data_ptrs = [self.index_k_buffer[i].data_ptr() for i in range(self.layer_num)]
data_lens = [self.index_k_buffer[i].nbytes for i in range(self.layer_num)]
item_lens = [self.index_k_buffer[i][0].nbytes for i in range(self.layer_num)]
return data_ptrs, data_lens, item_lens
def get_key_buffer(self, layer_id: int):
if self.layer_transfer_counter is not None:
self.layer_transfer_counter.wait_until(layer_id - self.start_layer)
if self.store_dtype != self.dtype:
return self.k_buffer[layer_id - self.start_layer].view(self.dtype)
return self.k_buffer[layer_id - self.start_layer]
def get_value_buffer(self, layer_id: int):
if self.layer_transfer_counter is not None:
self.layer_transfer_counter.wait_until(layer_id - self.start_layer)
if self.store_dtype != self.dtype:
return self.v_buffer[layer_id - self.start_layer].view(self.dtype)
return self.v_buffer[layer_id - self.start_layer]
def get_index_k_buffer(self, layer_id: int):
if self.layer_transfer_counter is not None:
self.layer_transfer_counter.wait_until(layer_id - self.start_layer)
if self.store_dtype != self.dtype:
return self.index_k_buffer[layer_id - self.start_layer].view(self.dtype)
return self.index_k_buffer[layer_id - self.start_layer]
# for disagg
def get_contiguous_buf_infos(self):
# MLA has only one kv_buffer, so only the information of this buffer needs to be returned.
kv_data_ptrs = [self.k_buffer[i].data_ptr() for i in range(self.layer_num)] + [
self.v_buffer[i].data_ptr() for i in range(self.layer_num)
]
kv_data_lens = [self.k_buffer[i].nbytes for i in range(self.layer_num)] + [
self.v_buffer[i].nbytes for i in range(self.layer_num)
]
kv_item_lens = [self.k_buffer[i][0].nbytes for i in range(self.layer_num)] + [
self.v_buffer[i][0].nbytes for i in range(self.layer_num)
]
if self.index_head_dim is not None:
kv_data_ptrs += [
self.index_k_buffer[i].data_ptr() for i in range(self.layer_num)
]
kv_data_lens += [
self.index_k_buffer[i].nbytes for i in range(self.layer_num)
]
kv_item_lens += [
self.index_k_buffer[i][0].nbytes for i in range(self.layer_num)
]
return kv_data_ptrs, kv_data_lens, kv_item_lens
def set_kv_buffer(
self,
layer: "RadixAttention",
loc_info,
cache_k: torch.Tensor,
cache_v: torch.Tensor,
):
loc, _, _ = unwrap_write_loc(loc_info)
layer_id = layer.layer_id
if cache_k.dtype != self.dtype:
cache_k = cache_k.to(self.dtype)
cache_v = cache_v.to(self.dtype)
if self.store_dtype != self.dtype:
cache_k = cache_k.view(self.store_dtype)
cache_v = cache_v.view(self.store_dtype)
if cache_v is None:
cache_k, cache_v = cache_k.split(
[self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
)
torch_npu.npu_scatter_nd_update_(
self.k_buffer[layer_id - self.start_layer].view(-1, 1, self.kv_lora_rank),
loc.view(-1, 1),
cache_k.view(-1, 1, self.kv_lora_rank),
)
torch_npu.npu_scatter_nd_update_(
self.v_buffer[layer_id - self.start_layer].view(
-1, 1, self.qk_rope_head_dim
),
loc.view(-1, 1),
cache_v.view(-1, 1, self.qk_rope_head_dim),
)
def set_index_k_buffer(
self,
layer_id: int,
loc: torch.Tensor,
index_k: torch.Tensor,
):
if index_k.dtype != self.dtype:
index_k = index_k.to(self.dtype)
if self.store_dtype != self.dtype:
index_k = index_k.view(self.store_dtype)
torch_npu.npu_scatter_nd_update_(
self.index_k_buffer[layer_id - self.start_layer].view(
-1, 1, self.index_head_dim
),
loc.view(-1, 1),
index_k.view(-1, 1, self.index_head_dim),
)
def _chunk_copy_npu_to_cpu(self, buf_of_layers, indices):
chunk_size = self.cpu_offloading_chunk_size
out = []
for tensors_per_layer in buf_of_layers: # [k_buf, v_buf, ik_buf/None]
layer_chunks = []
for i in range(0, len(indices), chunk_size):
ci = indices[i : i + chunk_size]
layer_chunks.append(
[
t[ci].to("cpu", non_blocking=True)
for t in tensors_per_layer
if t is not None
]
)
out.append(layer_chunks)
return out
def get_cpu_copy(self, indices, mamba_indices=None):
torch.npu.synchronize()
buf_of_layers = []
has_ik = self.index_head_dim is not None
for local_layer_id in range(self.layer_num):
k_layer = self.k_buffer[local_layer_id].view(-1, 1, self.kv_lora_rank)
v_layer = self.v_buffer[local_layer_id].view(-1, 1, self.qk_rope_head_dim)
ik_layer = (
self.index_k_buffer[local_layer_id].view(-1, 1, self.index_head_dim)
if has_ik
else None
)
buf_of_layers.append([k_layer, v_layer, ik_layer])
kv_cache_cpu = self._chunk_copy_npu_to_cpu(buf_of_layers, indices)
torch.npu.synchronize()
return kv_cache_cpu
def load_cpu_copy(self, kv_cache_cpu, indices, mamba_indices=None):
torch.npu.synchronize()
chunk_size = self.cpu_offloading_chunk_size
has_ik = self.index_head_dim is not None
for local_layer_id in range(self.layer_num):
k_layer = self.k_buffer[local_layer_id].view(-1, 1, self.kv_lora_rank)
v_layer = self.v_buffer[local_layer_id].view(-1, 1, self.qk_rope_head_dim)
ik_layer = (
self.index_k_buffer[local_layer_id].view(-1, 1, self.index_head_dim)
if has_ik
else None
)
for i in range(0, len(indices), chunk_size):
chunk_indices = indices[i : i + chunk_size]
chunk = kv_cache_cpu[local_layer_id][i // chunk_size]
k_cpu, v_cpu = chunk[0], chunk[1]
assert k_cpu.shape[0] == len(chunk_indices)
k_layer[chunk_indices] = k_cpu.to(k_layer.device, non_blocking=True)
v_layer[chunk_indices] = v_cpu.to(v_layer.device, non_blocking=True)
if has_ik:
ik_cpu = chunk[2]
ik_layer[chunk_indices] = ik_cpu.to(
ik_layer.device, non_blocking=True
)
torch.npu.synchronize()
@@ -0,0 +1,604 @@
import re
from typing import TYPE_CHECKING
import torch
import torch_npu
from sgl_kernel_npu.norm.fused_split_qk_norm import fused_split_qk_norm
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.npu.attention.mla_preprocess import (
NPUFusedMLAPreprocess,
is_fia_nz,
is_mla_preprocess_enabled,
)
from sglang.srt.layers.attention.dsa.dsa_indexer import scattered_to_tp_attn_full
from sglang.srt.layers.attention.dsa.utils import (
dsa_use_prefill_cp,
)
from sglang.srt.layers.communicator import ScatterMode, get_attn_tp_context
from sglang.srt.model_executor.forward_context import get_token_to_kv_pool
if TYPE_CHECKING:
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.models.deepseek_v2 import DeepseekV2AttentionMLA
from sglang.srt.utils import BumpAllocator
_use_ag_after_qlora = envs.SGLANG_USE_AG_AFTER_QLORA.get()
# region MHA
def forward_mha_prepare_npu(
m: "DeepseekV2AttentionMLA",
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
layer_scatter_modes,
):
if m.q_lora_rank is not None:
q, latent_cache = (
get_attn_tp_context()
.fetch_qkv_latent()
.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim],
dim=-1,
)
)
# DSA Indexer: cache quantized keys, auto-skip topk for sequences <= dsa_index_topk
if m.use_dsa:
q_lora = m.q_a_layernorm(q)
q = m.q_b_proj(q_lora)[0].view(-1, m.num_local_heads, m.qk_head_dim)
_ = m.indexer(
x=hidden_states,
q_lora=q_lora,
positions=positions,
forward_batch=forward_batch,
layer_id=m.layer_id,
return_indices=False,
)
else:
q = m.q_a_layernorm(q)
if (
_use_ag_after_qlora
and layer_scatter_modes.layer_input_mode == ScatterMode.SCATTERED
and layer_scatter_modes.attn_mode == ScatterMode.TP_ATTN_FULL
):
q = scattered_to_tp_attn_full(q, forward_batch)
latent_cache = scattered_to_tp_attn_full(latent_cache, forward_batch)
q = m.q_b_proj(q)[0].view(-1, m.num_local_heads, m.qk_head_dim)
else:
q = m.q_proj(hidden_states)[0].view(-1, m.num_local_heads, m.qk_head_dim)
latent_cache = m.kv_a_proj_with_mqa(hidden_states)[0]
_, q_pe = q.split([m.qk_nope_head_dim, m.qk_rope_head_dim], dim=-1)
kv_a, _ = latent_cache.split([m.kv_lora_rank, m.qk_rope_head_dim], dim=-1)
latent_cache = latent_cache.unsqueeze(1)
if m.use_deepseek_yarn_rope:
B, S = q.shape[0], 1
cos, sin = m.rotary_emb.get_cos_sin_cache(
positions, hidden_states.dtype, offsets=None
)
q_pe = torch_npu.npu_interleave_rope(
q_pe.reshape(B, -1, S, m.qk_rope_head_dim),
cos,
sin,
)
q_pe = q_pe.reshape(B, -1, m.qk_rope_head_dim)
ckv_cache, k_rope_cache = get_token_to_kv_pool().get_kv_buffer(m.layer_id)
_, _, k_pe, kv_a = torch_npu.npu_kv_rmsnorm_rope_cache(
latent_cache.view(-1, 1, 1, m.kv_lora_rank + m.qk_rope_head_dim), # bnsd
m.kv_a_layernorm.weight,
cos,
sin,
forward_batch.out_cache_loc.to(torch.int64),
k_rope_cache,
ckv_cache,
k_rope_scale=None,
c_kv_scale=None,
k_rope_offset=None,
c_kv_offset=None,
epsilon=m.kv_a_layernorm.variance_epsilon,
cache_mode="PA_NZ" if is_fia_nz() else "PA_BNSD",
is_output_kv=True,
) # adapter NZ
k_pe = k_pe.reshape(B, -1, m.qk_rope_head_dim)
else:
kv_a = m.kv_a_layernorm(kv_a)
k_pe = latent_cache[:, :, m.kv_lora_rank :]
if m.rotary_emb is not None:
q_pe, k_pe = m.rotary_emb(positions, q_pe, k_pe)
# this is for model kimi-vl-a3B-instruct
get_token_to_kv_pool().set_kv_buffer(
m, forward_batch.out_cache_loc, kv_a.unsqueeze(1), k_pe
)
q[..., m.qk_nope_head_dim :] = q_pe
kv = m.kv_b_proj(kv_a)[0]
kv = kv.view(-1, m.num_local_heads, m.qk_nope_head_dim + m.v_head_dim)
k_nope = kv[..., : m.qk_nope_head_dim]
v = kv[..., m.qk_nope_head_dim :]
k = m._concat_and_cast_mha_k(k_nope, k_pe, forward_batch)
return q, k, v, forward_batch
def forward_mha_core_npu(
m: "DeepseekV2AttentionMLA",
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
forward_batch: "ForwardBatch",
) -> torch.Tensor:
attn_output = m.attn_mha(q, k, v, forward_batch, save_kv_cache=False)
attn_output = attn_output.reshape(-1, m.num_local_heads * m.v_head_dim)
output, _ = m.o_proj(attn_output)
return output
# endregion
# region MLA
def forward_mla_prepare_npu(
m: "DeepseekV2AttentionMLA",
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
layer_scatter_modes,
):
if is_mla_preprocess_enabled():
if not hasattr(m, "mla_preprocess"):
m.mla_preprocess = NPUFusedMLAPreprocess(
m.fused_qkv_a_proj_with_mqa,
m.q_a_layernorm,
m.kv_a_layernorm,
m.q_b_proj,
m.w_kc,
m.rotary_emb,
m.layer_id,
m.num_local_heads,
m.qk_nope_head_dim,
m.qk_rope_head_dim,
m.quant_config,
)
(
q_pe,
k_pe,
q_nope_out,
k_nope,
forward_batch,
zero_allocator,
positions,
) = m.mla_preprocess.forward(
positions, hidden_states, forward_batch, zero_allocator
)
topk_indices = None
else:
q_lora = None
if m.q_lora_rank is not None:
qkv_latent = get_attn_tp_context().fetch_qkv_latent()
if (
_use_ag_after_qlora
and layer_scatter_modes.layer_input_mode == ScatterMode.SCATTERED
and layer_scatter_modes.attn_mode == ScatterMode.TP_ATTN_FULL
):
q, latent_cache = qkv_latent.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim],
dim=-1,
)
k_nope = latent_cache[..., : m.kv_lora_rank]
q = m.q_a_layernorm(q)
q = scattered_to_tp_attn_full(q, forward_batch)
latent_cache = scattered_to_tp_attn_full(latent_cache, forward_batch)
k_nope = m.kv_a_layernorm(k_nope).unsqueeze(1)
k_pe = latent_cache[..., m.kv_lora_rank :].unsqueeze(1)
else:
if qkv_latent.shape[0] < 65536 and not dsa_use_prefill_cp(
forward_batch
):
q, k_nope, k_pe = fused_split_qk_norm(
qkv_latent,
m.q_a_layernorm,
m.kv_a_layernorm,
m.q_lora_rank,
m.kv_lora_rank,
m.qk_rope_head_dim,
eps=m.q_a_layernorm.variance_epsilon,
)
else:
q, latent_cache = qkv_latent.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim],
dim=-1,
)
k_nope = latent_cache[..., : m.kv_lora_rank]
q = m.q_a_layernorm(q)
k_nope = m.kv_a_layernorm(k_nope).unsqueeze(1)
k_pe = latent_cache[..., m.kv_lora_rank :].unsqueeze(1)
# q_lora needed by indexer
if m.use_dsa:
q_lora = q
q = m.q_b_proj(q)[0].view(-1, m.num_local_heads, m.qk_head_dim)
else:
q = m.q_proj(hidden_states)[0].view(-1, m.num_local_heads, m.qk_head_dim)
latent_cache = m.kv_a_proj_with_mqa(hidden_states)[0]
k_nope = latent_cache[..., : m.kv_lora_rank]
k_nope = m.kv_a_layernorm(k_nope).unsqueeze(1)
k_pe = latent_cache[..., m.kv_lora_rank :].unsqueeze(1)
q_nope, q_pe = q.split([m.qk_nope_head_dim, m.qk_rope_head_dim], dim=-1)
q_nope_out = torch.bmm(q_nope.transpose(0, 1), m.w_kc)
q_nope_out = q_nope_out.transpose(0, 1)
q_pe, k_pe = m.rotary_emb(positions, q_pe, k_pe)
if dsa_use_prefill_cp(forward_batch):
# support allgather+rerrange
k_nope, k_pe = m.rebuild_cp_kv_cache(
latent_cache, forward_batch, k_nope, k_pe
)
topk_indices = None
if q_lora is not None:
topk_indices = m.indexer(
x=hidden_states,
q_lora=q_lora,
positions=positions,
forward_batch=forward_batch,
layer_id=m.layer_id,
)
return (
q_pe,
k_pe,
q_nope_out,
k_nope,
forward_batch,
zero_allocator,
positions,
topk_indices,
)
def forward_mla_core_npu(
m: "DeepseekV2AttentionMLA",
q_pe: torch.Tensor,
k_pe: torch.Tensor,
q_nope_out: torch.Tensor,
k_nope: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
positions: torch.Tensor,
topk_indices: torch.Tensor,
) -> torch.Tensor:
attn_output = m.attn_mqa(
q_nope_out,
k_nope,
k_nope,
forward_batch,
q_rope=q_pe,
k_rope=k_pe,
**(dict(topk_indices=topk_indices) if topk_indices is not None else {}),
)
attn_output = attn_output.view(-1, m.num_local_heads, m.kv_lora_rank)
attn_bmm_output = torch.empty(
(attn_output.shape[0], m.num_local_heads, m.v_head_dim),
dtype=attn_output.dtype,
device=attn_output.device,
)
attn_output = attn_output.contiguous()
torch.ops.npu.batch_matmul_transpose(attn_output, m.w_vc, attn_bmm_output)
attn_bmm_output = attn_bmm_output.reshape(-1, m.num_local_heads * m.v_head_dim)
output, _ = m.o_proj(attn_bmm_output)
return output
# endregion
# region DSA
def forward_dsa_prepare_npu(
m: "DeepseekV2AttentionMLA",
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
layer_scatter_modes,
prev_topk_indices: torch.Tensor = None,
):
dynamic_scale = None
if is_mla_preprocess_enabled() and forward_batch.forward_mode.is_decode():
(
q_pe,
k_pe,
q_nope_out,
k_nope,
q_lora,
forward_batch,
zero_allocator,
positions,
dynamic_scale,
) = npu_mla_preprocess(
m,
hidden_states,
positions,
forward_batch,
zero_allocator,
)
else:
fused_qkv_a_proj_out = m.fused_qkv_a_proj_with_mqa(hidden_states)[0]
if m.rotary_emb.is_neox_style:
q, latent_cache = fused_qkv_a_proj_out.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim], dim=-1
)
# overlap qk norm
q = m.q_a_layernorm(q)
if (
_use_ag_after_qlora
and layer_scatter_modes.layer_input_mode == ScatterMode.SCATTERED
and layer_scatter_modes.attn_mode == ScatterMode.TP_ATTN_FULL
):
q = scattered_to_tp_attn_full(q, forward_batch)
latent_cache = scattered_to_tp_attn_full(latent_cache, forward_batch)
q_lora = q.clone() # required for topk_indices
q_event = None
if m.alt_stream is not None:
m.alt_stream.wait_stream(torch.npu.current_stream())
with torch.npu.stream(m.alt_stream):
q = m.q_b_proj(q_lora)[0].view(-1, m.num_local_heads, m.qk_head_dim)
# record q to ensure memory space will not be released
q.record_stream(m.alt_stream)
q_event = m.alt_stream.record_event()
else:
q = m.q_b_proj(q_lora)[0].view(-1, m.num_local_heads, m.qk_head_dim)
k_nope, k_pe = latent_cache.unsqueeze(1).split(
[m.kv_lora_rank, m.qk_rope_head_dim], dim=-1
)
k_nope = m.kv_a_layernorm(k_nope)
# main stream waits for the completion of the event on the alt stream to ensure data dependency is complete
if q_event is not None:
torch.npu.current_stream().wait_event(q_event)
else:
if fused_qkv_a_proj_out.shape[0] < 65535 and not dsa_use_prefill_cp(
forward_batch
):
q_lora, k_nope, k_pe = fused_split_qk_norm(
fused_qkv_a_proj_out,
m.q_a_layernorm,
m.kv_a_layernorm,
m.q_lora_rank,
m.kv_lora_rank,
m.qk_rope_head_dim,
eps=m.q_a_layernorm.variance_epsilon,
)
else:
q, latent_cache = fused_qkv_a_proj_out.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim], dim=-1
)
# overlap qk norm
q = m.q_a_layernorm(q)
q_lora = q.clone() # required for topk_indices
k_nope, k_pe = latent_cache.unsqueeze(1).split(
[m.kv_lora_rank, m.qk_rope_head_dim], dim=-1
)
k_nope = m.kv_a_layernorm(k_nope)
q = m.q_b_proj(q_lora)[0].view(-1, m.num_local_heads, m.qk_head_dim)
q_nope, q_pe = q.split([m.qk_nope_head_dim, m.qk_rope_head_dim], dim=-1)
q_nope_out = torch.bmm(q_nope.transpose(0, 1), m.w_kc)
q_nope_out = q_nope_out.transpose(0, 1)
if m.layer_id == 0:
m.rotary_emb.sin_cos_cache = m.rotary_emb.cos_sin_cache.index_select(
0, positions
)
q_pe, k_pe = m.rotary_emb(positions, q_pe, k_pe)
if dsa_use_prefill_cp(forward_batch):
# support allgather+rerrange
k_nope, k_pe = m.rebuild_cp_kv_cache(
latent_cache, forward_batch, k_nope, k_pe
)
if not m.skip_topk or (m.is_nextn and prev_topk_indices is None):
topk_indices = m.indexer(
hidden_states,
q_lora,
positions,
forward_batch,
m.layer_id,
layer_scatter_modes,
dynamic_scale,
)
else:
topk_indices = prev_topk_indices
return (
q_pe,
k_pe,
q_nope_out,
k_nope,
topk_indices,
forward_batch,
zero_allocator,
positions,
)
def forward_dsa_core_npu(
m: "DeepseekV2AttentionMLA",
q_pe: torch.Tensor,
k_pe: torch.Tensor,
q_nope_out: torch.Tensor,
k_nope: torch.Tensor,
topk_indices: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
positions: torch.Tensor,
) -> torch.Tensor:
attn_output = m.attn_mqa(
q_nope_out.contiguous(),
k_nope.contiguous(),
k_nope.contiguous(),
forward_batch,
save_kv_cache=True, # False if forward_batch.forward_mode.is_extend() else True,
q_rope=q_pe.contiguous(),
k_rope=k_pe.contiguous(),
topk_indices=topk_indices,
)
attn_output = attn_output.view(-1, m.num_local_heads, m.kv_lora_rank)
attn_bmm_output = torch.empty(
(attn_output.shape[0], m.num_local_heads, m.v_head_dim),
dtype=attn_output.dtype,
device=attn_output.device,
)
if (
forward_batch.forward_mode.is_extend()
and not forward_batch.forward_mode.is_draft_extend_v2()
and not forward_batch.forward_mode.is_target_verify()
):
attn_output = attn_output.transpose(0, 1)
torch.bmm(
attn_output,
m.w_vc,
out=attn_bmm_output.view(-1, m.num_local_heads, m.v_head_dim).transpose(
0, 1
),
)
else:
attn_output = attn_output.contiguous()
torch.ops.npu.batch_matmul_transpose(attn_output, m.w_vc, attn_bmm_output)
attn_bmm_output = attn_bmm_output.reshape(-1, m.num_local_heads * m.v_head_dim)
output, _ = m.o_proj(attn_bmm_output)
if not m.next_skip_topk:
return output, None
else:
return output, topk_indices
def npu_mla_preprocess(
m: "DeepseekV2AttentionMLA",
hidden_states: torch.Tensor,
positions: torch.Tensor,
forward_batch: "ForwardBatch",
zero_allocator: "BumpAllocator",
):
dynamic_scale = None
if not hasattr(m, "mla_preprocess"):
m.mla_preprocess = NPUFusedMLAPreprocess(
m.fused_qkv_a_proj_with_mqa,
m.q_a_layernorm,
m.kv_a_layernorm,
m.q_b_proj,
m.w_kc,
m.rotary_emb,
m.layer_id,
m.num_local_heads,
m.qk_nope_head_dim,
m.qk_rope_head_dim,
m.v_head_dim,
m.quant_config,
)
# mlaprolog does not require additional calculation of q_lora
_is_mlaprolog = hasattr(m.quant_config, "ignore") and any(
re.fullmatch(r".*kv_b_proj", l) for l in m.quant_config.ignore
)
if _is_mlaprolog:
(
q_pe,
k_pe,
q_nope_out,
k_nope,
q_lora,
forward_batch,
positions,
dynamic_scale,
) = m.mla_preprocess.forward(
positions, hidden_states, forward_batch, zero_allocator
)
else:
if m.alt_stream is not None:
mla_event = torch.npu.Event()
mla_event.record()
with torch.npu.stream(m.alt_stream):
# alt stream waits for the completion of the event on the main stream to ensure data dependency is complete
torch.npu.current_stream().wait_event(mla_event)
(
q_pe,
k_pe,
q_nope_out,
k_nope,
forward_batch,
zero_allocator,
positions,
) = m.mla_preprocess.forward(
positions, hidden_states, forward_batch, zero_allocator
)
fused_qkv_a_proj_out = m.fused_qkv_a_proj_with_mqa(hidden_states)[0]
q, _ = fused_qkv_a_proj_out.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim], dim=-1
)
q_lora = m.q_a_layernorm(q)
torch.npu.current_stream().wait_event(m.alt_stream)
else:
(
q_pe,
k_pe,
q_nope_out,
k_nope,
forward_batch,
zero_allocator,
positions,
) = m.mla_preprocess.forward(
positions, hidden_states, forward_batch, zero_allocator
)
fused_qkv_a_proj_out = m.fused_qkv_a_proj_with_mqa(hidden_states)[0]
q, _ = fused_qkv_a_proj_out.split(
[m.q_lora_rank, m.kv_lora_rank + m.qk_rope_head_dim], dim=-1
)
q_lora = m.q_a_layernorm(q)
return (
q_pe,
k_pe,
q_nope_out,
k_nope,
q_lora,
forward_batch,
zero_allocator,
positions,
dynamic_scale,
)
# endregion
@@ -0,0 +1,285 @@
"""NPU patch for GLM-4.6V image and video preprocessing.
The GLM-4.6V image processor (Glm46VImageProcessorFast) and video processor
(Glm46VVideoProcessor) create 10-dimensional tensors during patch extraction,
which exceeds Ascend NPU's 8-dimension limit.
This patch restructures the computation to stay within 8 dimensions, following
the same pattern as the Qwen VL NPU patch.
"""
import torch
import torchvision.transforms.v2.functional as tvF
from transformers.image_processing_utils import BatchFeature
from transformers.image_processing_utils_fast import (
group_images_by_shape,
reorder_images,
)
from transformers.image_utils import (
ChannelDimension,
PILImageResampling,
SizeDict,
get_image_size,
)
from transformers.models.glm46v.image_processing_glm46v import smart_resize
from transformers.utils import TensorType
from transformers.video_utils import group_videos_by_shape, reorder_videos
from sglang.srt.hardware_backend.npu.modules.qwen_vl_processor import (
transform_patches_to_flatten,
)
from sglang.srt.utils import apply_module_patch
# Func refers to transformers.models.glm46v.image_processing_glm46v_fast.py
# Glm46VImageProcessorFast._preprocess
def npu_wrapper_glm46v_preprocess(func):
def _preprocess(
self,
images: list["torch.Tensor"],
do_resize: bool,
size: SizeDict,
resample: "PILImageResampling | tvF.InterpolationMode | int | None",
do_rescale: bool,
rescale_factor: float,
do_normalize: bool,
image_mean: float | list[float] | None,
image_std: float | list[float] | None,
patch_size: int,
temporal_patch_size: int,
merge_size: int,
disable_grouping: bool | None,
return_tensors: str | TensorType | None,
**kwargs,
):
grouped_images, grouped_images_index = group_images_by_shape(
images, disable_grouping=disable_grouping
)
resized_images_grouped = {}
for shape, stacked_images in grouped_images.items():
height, width = stacked_images.shape[-2:]
if do_resize:
resized_height, resized_width = smart_resize(
num_frames=temporal_patch_size,
height=height,
width=width,
temporal_factor=temporal_patch_size,
factor=patch_size * merge_size,
min_pixels=size.shortest_edge,
max_pixels=size.longest_edge,
)
stacked_images = self.resize(
stacked_images,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
resized_images_grouped[shape] = stacked_images
resized_images = reorder_images(resized_images_grouped, grouped_images_index)
grouped_images, grouped_images_index = group_images_by_shape(
resized_images, disable_grouping=disable_grouping
)
processed_images_grouped = {}
processed_grids = {}
for shape, stacked_images in grouped_images.items():
resized_height, resized_width = stacked_images.shape[-2:]
patches = self.rescale_and_normalize(
stacked_images,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
if patches.ndim == 4:
patches = patches.unsqueeze(1)
if patches.shape[1] % temporal_patch_size != 0:
repeats = patches[:, -1:].repeat(
1,
temporal_patch_size - (patches.shape[1] % temporal_patch_size),
1,
1,
1,
)
patches = torch.cat([patches, repeats], dim=1)
batch_size, t_len, channel = patches.shape[:3]
grid_t = t_len // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
######################################
# Start of modifications for sglang #
######################################
flatten_patches = transform_patches_to_flatten(
patches,
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h,
grid_w,
patch_size,
merge_size,
)
######################################
# End of modifications for sglang #
######################################
processed_images_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_images = reorder_images(
processed_images_grouped, grouped_images_index
)
processed_grids = reorder_images(processed_grids, grouped_images_index)
pixel_values = torch.cat(processed_images, dim=0)
image_grid_thw = torch.tensor(processed_grids)
return BatchFeature(
data={"pixel_values": pixel_values, "image_grid_thw": image_grid_thw},
tensor_type=return_tensors,
)
return _preprocess
# Func refers to transformers.models.glm46v.video_processing_glm46v.py
# Glm46VVideoProcessor._preprocess
def npu_wrapper_glm46v_video_preprocess(func):
def _preprocess(
self,
videos: list[torch.Tensor],
do_convert_rgb: bool = True,
do_resize: bool = True,
size: SizeDict | None = None,
resample: "PILImageResampling | tvF.InterpolationMode | int | None" = PILImageResampling.BICUBIC,
do_rescale: bool = True,
rescale_factor: float = 1 / 255.0,
do_normalize: bool = True,
image_mean: float | list[float] | None = None,
image_std: float | list[float] | None = None,
patch_size: int | None = None,
temporal_patch_size: int | None = None,
merge_size: int | None = None,
return_tensors: str | TensorType | None = None,
**kwargs,
):
grouped_videos, grouped_videos_index = group_videos_by_shape(videos)
resized_videos_grouped = {}
for shape, stacked_videos in grouped_videos.items():
B, T, C, H, W = stacked_videos.shape
num_frames, height, width = T, H, W
if do_resize:
resized_height, resized_width = smart_resize(
num_frames=num_frames,
height=height,
width=width,
temporal_factor=temporal_patch_size,
factor=patch_size * merge_size,
min_pixels=size.shortest_edge,
max_pixels=size.longest_edge,
)
stacked_videos = stacked_videos.view(B * T, C, H, W)
stacked_videos = self.resize(
stacked_videos,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
stacked_videos = stacked_videos.view(
B, T, C, resized_height, resized_width
)
resized_videos_grouped[shape] = stacked_videos
resized_videos = reorder_videos(resized_videos_grouped, grouped_videos_index)
# Group videos by size for further processing
# Needed in case do_resize is False, or resize returns videos with different sizes
grouped_videos, grouped_videos_index = group_videos_by_shape(resized_videos)
processed_videos_grouped = {}
processed_grids = {}
for shape, stacked_videos in grouped_videos.items():
resized_height, resized_width = get_image_size(
stacked_videos[0], channel_dim=ChannelDimension.FIRST
)
# Fused rescale and normalize
stacked_videos = self.rescale_and_normalize(
stacked_videos,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
patches = stacked_videos
# Check that videos have `num_frames` divisible by `temporal_patch_size`
if patches.shape[1] % temporal_patch_size != 0:
repeats = patches[:, -1:].repeat(1, temporal_patch_size - 1, 1, 1, 1)
patches = torch.cat([patches, repeats], dim=1)
batch_size, grid_t, channel = patches.shape[:3]
grid_t = grid_t // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
######################################
# Start of modifications for sglang #
######################################
flatten_patches = transform_patches_to_flatten(
patches,
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h,
grid_w,
patch_size,
merge_size,
)
######################################
# End of modifications for sglang #
######################################
processed_videos_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_videos = reorder_videos(
processed_videos_grouped, grouped_videos_index
)
processed_grids = reorder_videos(processed_grids, grouped_videos_index)
pixel_values_videos = torch.cat(processed_videos, dim=0)
video_grid_thw = torch.tensor(processed_grids)
data = {
"pixel_values_videos": pixel_values_videos,
"video_grid_thw": video_grid_thw,
}
return BatchFeature(data=data, tensor_type=return_tensors)
return _preprocess
_npu_glm46v_preprocess_patched = False
def npu_apply_glm46v_image_preprocess_patch():
global _npu_glm46v_preprocess_patched
if _npu_glm46v_preprocess_patched:
return
apply_module_patch(
"transformers.models.glm46v.image_processing_glm46v_fast.Glm46VImageProcessorFast",
"_preprocess",
[npu_wrapper_glm46v_preprocess],
)
apply_module_patch(
"transformers.models.glm46v.video_processing_glm46v.Glm46VVideoProcessor",
"_preprocess",
[npu_wrapper_glm46v_video_preprocess],
)
_npu_glm46v_preprocess_patched = True
@@ -0,0 +1,304 @@
import torch
import torchvision.transforms.v2.functional as tvF
from transformers.image_processing_utils import BatchFeature
from transformers.image_transforms import group_images_by_shape, reorder_images
from transformers.image_utils import (
ChannelDimension,
PILImageResampling,
SizeDict,
get_image_size,
)
from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize
from transformers.models.qwen3_vl.video_processing_qwen3_vl import (
smart_resize as smart_resize_video,
)
from transformers.utils import TensorType
from transformers.video_utils import group_videos_by_shape, reorder_videos
from sglang.srt.utils import apply_module_patch
def transform_patches_to_flatten(
patches: torch.Tensor,
batch_size: int,
grid_t: int,
temporal_patch_size: int,
channel: int,
grid_h: int,
grid_w: int,
patch_size: int,
merge_size: int,
) -> torch.Tensor:
patches = patches.view(
batch_size * grid_t,
temporal_patch_size * channel,
grid_h // merge_size,
merge_size,
patch_size,
grid_w // merge_size,
merge_size,
patch_size,
)
patches = patches.permute(0, 1, 2, 5, 3, 6, 4, 7)
patches = patches.reshape(
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h * grid_w,
patch_size,
patch_size,
)
patches = patches.permute(0, 1, 4, 3, 2, 5, 6)
flatten_patches = patches.reshape(
batch_size,
grid_t * grid_h * grid_w,
-1,
)
return flatten_patches
# Func refers to transformers.models.qwen2_vl.image_processing_qwen2_vl.py
# Qwen2VLImageProcessor._preprocess
def npu_wrapper_preprocess(func):
def _preprocess(
self,
images: list["torch.Tensor"],
do_resize: bool,
size: SizeDict,
resample: "PILImageResampling | tvF.InterpolationMode | int | None",
do_rescale: bool,
rescale_factor: float,
do_normalize: bool,
image_mean: float | list[float] | None,
image_std: float | list[float] | None,
patch_size: int,
temporal_patch_size: int,
merge_size: int,
disable_grouping: bool | None,
return_tensors: str | TensorType | None,
**kwargs,
):
# Group images by size for batched resizing
grouped_images, grouped_images_index = group_images_by_shape(
images, disable_grouping=disable_grouping
)
resized_images_grouped = {}
for shape, stacked_images in grouped_images.items():
height, width = stacked_images.shape[-2:]
if do_resize:
resized_height, resized_width = smart_resize(
height,
width,
factor=patch_size * merge_size,
min_pixels=size.shortest_edge,
max_pixels=size.longest_edge,
)
stacked_images = self.resize(
image=stacked_images,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
resized_images_grouped[shape] = stacked_images
resized_images = reorder_images(resized_images_grouped, grouped_images_index)
# Group images by size for further processing
# Needed in case do_resize is False, or resize returns images with different sizes
grouped_images, grouped_images_index = group_images_by_shape(
resized_images, disable_grouping=disable_grouping
)
processed_images_grouped = {}
processed_grids = {}
for shape, stacked_images in grouped_images.items():
resized_height, resized_width = stacked_images.shape[-2:]
# Fused rescale and normalize
patches = self.rescale_and_normalize(
stacked_images,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
if patches.ndim == 4:
# add a temporal dimension if we have images
patches = patches.unsqueeze(1)
if patches.shape[1] % temporal_patch_size != 0:
repeats = patches[:, -1:].repeat(1, temporal_patch_size - 1, 1, 1, 1)
patches = torch.cat([patches, repeats], dim=1)
batch_size, grid_t, channel = patches.shape[:3]
grid_t = grid_t // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
######################################
# Start of modifications for sglang #
######################################
flatten_patches = transform_patches_to_flatten(
patches,
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h,
grid_w,
patch_size,
merge_size,
)
######################################
# End of modifications for sglang #
######################################
processed_images_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_images = reorder_images(
processed_images_grouped, grouped_images_index
)
processed_grids = reorder_images(processed_grids, grouped_images_index)
pixel_values = torch.cat(processed_images, dim=0)
image_grid_thw = torch.tensor(processed_grids)
return BatchFeature(
data={"pixel_values": pixel_values, "image_grid_thw": image_grid_thw},
tensor_type=return_tensors,
)
return _preprocess
# Func refers to transformers.models.qwen3_vl.video_processing_qwen3_vl.py
# Qwen3VLVideoProcessor._preprocess
def npu_wrapper_video_preprocess(func):
def _preprocess(
self,
videos: list[torch.Tensor],
do_convert_rgb: bool = True,
do_resize: bool = True,
size: SizeDict | None = None,
resample: "PILImageResampling | tvF.InterpolationMode | int | None" = PILImageResampling.BICUBIC,
do_rescale: bool = True,
rescale_factor: float = 1 / 255.0,
do_normalize: bool = True,
image_mean: float | list[float] | None = None,
image_std: float | list[float] | None = None,
patch_size: int | None = None,
temporal_patch_size: int | None = None,
merge_size: int | None = None,
return_tensors: str | TensorType | None = None,
**kwargs,
):
grouped_videos, grouped_videos_index = group_videos_by_shape(videos)
resized_videos_grouped = {}
for shape, stacked_videos in grouped_videos.items():
B, T, C, H, W = stacked_videos.shape
num_frames, height, width = T, H, W
if do_resize:
resized_height, resized_width = smart_resize_video(
num_frames=num_frames,
height=height,
width=width,
temporal_factor=temporal_patch_size,
factor=patch_size * merge_size,
min_pixels=size.shortest_edge,
max_pixels=size.longest_edge,
)
stacked_videos = stacked_videos.view(B * T, C, H, W)
stacked_videos = self.resize(
stacked_videos,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
stacked_videos = stacked_videos.view(
B, T, C, resized_height, resized_width
)
resized_videos_grouped[shape] = stacked_videos
resized_videos = reorder_videos(resized_videos_grouped, grouped_videos_index)
# Group videos by size for further processing
# Needed in case do_resize is False, or resize returns videos with different sizes
grouped_videos, grouped_videos_index = group_videos_by_shape(resized_videos)
processed_videos_grouped = {}
processed_grids = {}
for shape, stacked_videos in grouped_videos.items():
resized_height, resized_width = get_image_size(
stacked_videos[0], channel_dim=ChannelDimension.FIRST
)
# Fused rescale and normalize
stacked_videos = self.rescale_and_normalize(
stacked_videos,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
patches = stacked_videos
# Check that videos have `num_frames` divisible by `temporal_patch_size`
T = patches.shape[1]
if pad := -T % temporal_patch_size:
repeats = patches[:, -1:].expand(-1, pad, -1, -1, -1)
patches = torch.cat((patches, repeats), dim=1)
batch_size, grid_t, channel = patches.shape[:3]
grid_t = grid_t // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
######################################
# Start of modifications for sglang #
######################################
flatten_patches = transform_patches_to_flatten(
patches,
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h,
grid_w,
patch_size,
merge_size,
)
######################################
# End of modifications for sglang #
######################################
processed_videos_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_videos = reorder_videos(
processed_videos_grouped, grouped_videos_index
)
processed_grids = reorder_videos(processed_grids, grouped_videos_index)
pixel_values_videos = torch.cat(processed_videos, dim=0)
video_grid_thw = torch.tensor(processed_grids)
data = {
"pixel_values_videos": pixel_values_videos,
"video_grid_thw": video_grid_thw,
}
return BatchFeature(data=data, tensor_type=return_tensors)
return _preprocess
_npu_preprocess_patched = False
def npu_apply_qwen_image_preprocess_patch():
global _npu_preprocess_patched
if _npu_preprocess_patched:
return
apply_module_patch(
"transformers.models.qwen2_vl.image_processing_qwen2_vl.Qwen2VLImageProcessor",
"_preprocess",
[npu_wrapper_preprocess],
)
apply_module_patch(
"transformers.models.qwen3_vl.video_processing_qwen3_vl.Qwen3VLVideoProcessor",
"_preprocess",
[npu_wrapper_video_preprocess],
)
_npu_preprocess_patched = True
@@ -0,0 +1,171 @@
"""Ascend FuseEP fused dispatch+GEMM+combine forward path.
Follows the mega_moe shape: a free-function bypass invoked from
``FusedMoE.forward`` when ``--moe-a2a-backend ascend_fuseep`` is set, plus a
weight-postprocess helper that NPU quant_methods call from their
``process_weights_after_loading`` when the same backend is selected.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from sglang.srt.distributed import get_tp_group
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.npu.utils import FusedMoEMode, npu_format_cast
from sglang.srt.layers.moe.token_dispatcher.deepep import DeepEPBuffer
from sglang.srt.layers.moe.utils import DeepEPMode
if TYPE_CHECKING:
from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE
from sglang.srt.layers.moe.topk import TopKOutput
_PARAMS_BYTES = 2 # bf16 — Ascend's Dispatch & Combine does not support fp16
def _get_fuseep_buffer(layer: FusedMoE):
DeepEPBuffer.set_dispatch_mode_as_low_latency()
return DeepEPBuffer.get_deepep_buffer(
get_tp_group().device_group,
layer.hidden_size,
_PARAMS_BYTES,
DeepEPMode.LOW_LATENCY,
envs.SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK.get(),
layer.num_experts,
)
def forward_fuseep(
layer: FusedMoE,
hidden_states: torch.Tensor,
topk_output: TopKOutput,
) -> torch.Tensor:
buf = _get_fuseep_buffer(layer)
hidden_states, _ = buf.fused_deep_moe(
hidden_states,
topk_idx=topk_output.topk_ids,
topk_weights=topk_output.topk_weights,
gmm1_permuted_weight=layer.w13_weight,
gmm1_permuted_weight_scale=layer.w13_weight_scale,
gmm2_weight=layer.w2_weight,
gmm2_weight_scale=layer.w2_weight_scale,
num_max_dispatch_tokens_per_rank=(
envs.SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK.get()
),
num_experts=layer.num_experts,
fuse_mode=envs.SGLANG_NPU_FUSED_MOE_MODE.get(),
)
return hidden_states
def _permute_w13_weight_scale(w: torch.Tensor, tile_n: int) -> torch.Tensor:
if tile_n % 2 != 0:
raise ValueError(f"tile_n must be even, got {tile_n}")
*dims, n = w.shape
if n % tile_n != 0:
raise ValueError(f"Last dimension {n} must be divisible by tile_n {tile_n}")
w_reshaped = w.reshape(*dims, 2, n // tile_n, tile_n // 2)
perm_order = list(range(len(dims))) + [-2, -3, -1]
return w_reshaped.permute(perm_order).reshape(*dims, n)
def _reshape_w13_weight(
weight: torch.Tensor, dim: int, chunk_size: int = 64
) -> torch.Tensor:
# Achieving greater computing power through reshape on Ascend.
original_shape = weight.shape
if dim < 0:
dim += len(original_shape)
if original_shape[dim] % (2 * chunk_size) != 0:
raise ValueError(
f"Dimension {dim} size {original_shape[dim]} must be divisible by "
f"{2 * chunk_size}"
)
new_shape = (
*original_shape[:dim],
2,
original_shape[dim] // (2 * chunk_size),
chunk_size,
*original_shape[dim + 1 :],
)
weight = weight.view(new_shape)
weight = weight.transpose(dim, dim + 1).contiguous()
return weight.view(*original_shape[:dim], -1, *original_shape[dim + 1 :])
def _release_weight_cache(weight: torch.Tensor) -> torch.Tensor:
# .contiguous() introduces additional memory overhead; release with resize_(0)
origin_weight = weight.data.transpose(1, 2)
new_weight = origin_weight.contiguous()
origin_weight.untyped_storage().resize_(0)
return new_weight
def _scale_from_float_to_int64(scale: torch.Tensor) -> torch.nn.Parameter:
import numpy as np
converted = torch.from_numpy(
np.frombuffer(
scale.cpu().to(torch.float32).numpy().tobytes(), dtype=np.int32
).astype(np.int64)
).to(scale.device)
return torch.nn.Parameter(converted, requires_grad=False)
def process_fuseep_weights(layer: torch.nn.Module) -> None:
"""Apply the Ascend FuseEP-specific weight layout.
Replaces NPU quant_method weight layouts with the form required by the
fused_deep_moe op. Invoked from NPU ``process_weights_after_loading``
when ``--moe-a2a-backend ascend_fuseep`` is set.
"""
if envs.SGLANG_NPU_FUSED_MOE_MODE.get() == FusedMoEMode.DISPATCH_FFN_COMBINE.value:
w13_weight = _release_weight_cache(layer.w13_weight)
layer.w13_weight.data = npu_format_cast(w13_weight)
w2_weight = _release_weight_cache(layer.w2_weight)
layer.w2_weight.data = npu_format_cast(w2_weight)
layer.w13_weight_scale.data = layer.w13_weight_scale.data.view(
layer.w13_weight_scale.data.shape[0], -1
)
w2_scale = layer.w2_weight_scale.data.squeeze(-1).contiguous()
layer.w2_weight_scale = torch.nn.Parameter(
w2_scale.to(torch.float32), requires_grad=False
)
layer.w13_weight_scale = _scale_from_float_to_int64(layer.w13_weight_scale.data)
layer.w2_weight_scale = _scale_from_float_to_int64(layer.w2_weight_scale.data)
else:
cpu_w13 = layer.w13_weight.data.transpose(1, 2).cpu()
layer.w13_weight.data = _reshape_w13_weight(cpu_w13, -1).npu()
w13_scale = layer.w13_weight_scale.data.squeeze(-1).contiguous()
w13_scale = _permute_w13_weight_scale(w13_scale, 128)
layer.w13_weight_scale = torch.nn.Parameter(
w13_scale.to(torch.float32), requires_grad=False
)
layer.w13_weight.data = npu_format_cast(layer.w13_weight.data)
layer.w2_weight.data = npu_format_cast(layer.w2_weight.data)
w2_scale = layer.w2_weight_scale.data.squeeze(-1).contiguous()
layer.w2_weight_scale = torch.nn.Parameter(
w2_scale.to(torch.float32), requires_grad=False
)
if hasattr(layer, "w13_weight_offset"):
layer.w13_weight_offset = torch.nn.Parameter(
layer.w13_weight_offset.data.squeeze(-1).contiguous(),
requires_grad=False,
)
if hasattr(layer, "w2_weight_offset"):
layer.w2_weight_offset = torch.nn.Parameter(
layer.w2_weight_offset.data.squeeze(-1).contiguous(),
requires_grad=False,
)
@@ -0,0 +1,113 @@
from typing import TYPE_CHECKING, Optional
import torch
from sgl_kernel_npu.norm.l1_norm import l1_norm
from sglang.srt.eplb.expert_distribution import get_global_expert_distribution_recorder
from sglang.srt.eplb.expert_location_dispatch import topk_ids_logical_to_physical
from sglang.srt.layers.moe.topk import (
StandardTopKOutput,
capture_routed_experts_if_allowed,
select_experts,
)
if TYPE_CHECKING:
from sglang.srt.eplb.expert_location_dispatch import ExpertLocationDispatchInfo
from sglang.srt.layers.moe.topk import TopKConfig, TopKOutput
def fused_topk_npu(
hidden_states: torch.Tensor,
router_logits: torch.Tensor,
topk_config: "TopKConfig",
num_token_non_padded: Optional[torch.Tensor] = None,
expert_location_dispatch_info: Optional["ExpertLocationDispatchInfo"] = None,
layer_id: Optional[int] = None,
) -> "TopKOutput":
use_grouped_topk = topk_config.use_grouped_topk
renormalize = topk_config.renormalize
correction_bias = topk_config.correction_bias
# Fast path: simple top-k without grouped routing and bias
if not use_grouped_topk and correction_bias is None:
topk_weights, topk_ids, _ = torch.ops.npu.npu_moe_gating_top_k_softmax(
router_logits,
k=topk_config.top_k,
)
if renormalize:
topk_weights = l1_norm(
topk_weights
if topk_config.num_fused_shared_experts == 0
else topk_weights[:, :-1]
)
topk_weights = topk_weights.to(torch.float32)
# sqrtsoftplus (DSV4 noaux_tc): the NPU op only scores sigmoid/softmax, so use
# a torch path. top-k over (scores + bias); weights from un-biased scores.
elif topk_config.scoring_func == "sqrtsoftplus":
scores = torch.nn.functional.softplus(router_logits.float()).sqrt()
scores_for_choice = (
scores + correction_bias.unsqueeze(0).float()
if correction_bias is not None
else scores
)
_, topk_ids = torch.topk(
scores_for_choice, k=topk_config.top_k, dim=-1, sorted=False
)
topk_ids = topk_ids.to(torch.int32)
topk_weights = scores.gather(1, topk_ids)
if renormalize:
topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
else:
topk_weights = topk_weights * topk_config.routed_scaling_factor
topk_weights = topk_weights.to(torch.float32)
# Support grouped top-k or correction bias or sigmoid or routed_scaling_factor
elif (
correction_bias is not None
or topk_config.scoring_func == "sigmoid"
or num_token_non_padded is not None
):
topk_weights, topk_ids, _ = torch.ops.npu.npu_moe_gating_top_k(
router_logits.to(torch.float32),
k=topk_config.top_k,
bias=(
correction_bias.to(torch.float32)
if correction_bias is not None
else None
),
# num_expert_group and topk_group in some topk_config without group is None, (not supported by this ops)
k_group=topk_config.topk_group if use_grouped_topk else 1,
group_count=topk_config.num_expert_group if use_grouped_topk else 1,
group_select_mode=(1 if use_grouped_topk else 0),
renorm=0,
# 1 for sigmoid, 0 for softmax
norm_type=1,
routed_scaling_factor=(
1 if renormalize else topk_config.routed_scaling_factor
),
eps=float(1e-20),
)
topk_weights = topk_weights.to(torch.float32)
# torch native is not yet supported num_token_non_padded
# Fallback to torch native implementation
else:
topk_config.torch_native = True
return select_experts(
hidden_states=hidden_states,
layer_id=layer_id,
router_logits=router_logits,
topk_config=topk_config,
num_token_non_padded=num_token_non_padded,
expert_location_dispatch_info=expert_location_dispatch_info,
)
if expert_location_dispatch_info is not None:
topk_ids = topk_ids_logical_to_physical(topk_ids, expert_location_dispatch_info)
get_global_expert_distribution_recorder().on_select_experts(topk_ids=topk_ids)
capture_routed_experts_if_allowed(topk_config, layer_id, topk_ids)
return StandardTopKOutput(topk_weights, topk_ids, router_logits)
@@ -0,0 +1,174 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.hardware_backend.npu.quantization.fused_moe_method_npu import (
NPUW4A16Int4DynamicMoEMethod,
)
from sglang.srt.layers.quantization.utils import replace_parameter
if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import StandardDispatchOutput
from sglang.srt.layers.quantization.base_config import QuantizationConfig
import torch_npu
class AWQAscendLinearKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
layer.scales = torch.nn.Parameter(layer.scales.data, requires_grad=False)
qweight_tmp = torch.zeros_like(layer.qweight.data)
qzeros_tmp = layer.qzeros.data
qzeros_list = []
shifts = [0, 4, 1, 5, 2, 6, 3, 7]
for i in range(0, self.quant_config.pack_factor):
shift_num = shifts[i] * 4
qzeros_list.append((qzeros_tmp.reshape(-1, 1) >> shift_num) & 0xF)
qweight_tmp.bitwise_or_(
((layer.qweight.data >> shift_num) & 0xF) << (4 * i)
)
qweight_tmp.bitwise_xor_(0x88888888)
qzeros_tmp = torch.cat(qzeros_list, dim=-1).reshape(qzeros_tmp.shape[0], -1)
qzeros_tmp = -(qzeros_tmp - 8)
qzeros_tmp = qzeros_tmp.to(layer.scales.data.dtype)
layer.zeros = torch.nn.Parameter(qzeros_tmp, requires_grad=False)
layer.weight = torch.nn.Parameter(qweight_tmp, requires_grad=False)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
qweight = layer.weight
scales = layer.scales
qzeros = layer.zeros
pack_factor = self.quant_config.pack_factor
out_shape = x.shape[:-1] + (qweight.shape[-1] * pack_factor,)
reshaped_x = x.reshape(-1, x.shape[-1])
if bias is not None and bias.dtype == torch.bfloat16:
bias = bias.float()
out = torch_npu.npu_weight_quant_batchmatmul(
reshaped_x,
qweight,
antiquant_scale=scales,
antiquant_offset=qzeros,
antiquant_group_size=self.quant_config.group_size,
bias=bias,
)
return out.reshape(out_shape)
class AWQAscendMoEKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
self.kernel = NPUW4A16Int4DynamicMoEMethod()
@staticmethod
def _register_or_replace_parameter(
layer: torch.nn.Module, name: str, tensor: torch.Tensor
) -> None:
if hasattr(layer, name):
replace_parameter(layer, name, tensor)
else:
layer.register_parameter(
name, torch.nn.Parameter(tensor, requires_grad=False)
)
def _convert_awq_weight_to_npu_layout(self, qweight: torch.Tensor) -> torch.Tensor:
num_experts, input_size, _ = qweight.shape
unpacked_weight = (
self.kernel._unpack_from_int32(qweight.flatten(0, 1), 4)
.view(num_experts, input_size, -1)
.transpose(1, 2)
.contiguous()
.int()
)
return self.kernel._pack_to_int32(unpacked_weight)
def _convert_awq_qzeros_to_npu_offset(
self, qzeros: torch.Tensor, dtype: torch.dtype
) -> torch.Tensor:
num_experts, num_groups, _ = qzeros.shape
offset = (
-self.kernel._unpack_from_int32(qzeros.flatten(0, 1), 4)
.view(num_experts, num_groups, -1)
.transpose(1, 2)
.contiguous()
)
return offset.to(dtype)
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
self._register_or_replace_parameter(
layer,
"w13_weight",
self._convert_awq_weight_to_npu_layout(layer.w13_qweight.data),
)
self._register_or_replace_parameter(
layer,
"w2_weight",
self._convert_awq_weight_to_npu_layout(layer.w2_qweight.data),
)
self._register_or_replace_parameter(
layer,
"w13_weight_scale",
layer.w13_scales.data.transpose(1, 2).contiguous(),
)
self._register_or_replace_parameter(
layer,
"w2_weight_scale",
layer.w2_scales.data.transpose(1, 2).contiguous(),
)
self._register_or_replace_parameter(
layer,
"w13_weight_offset",
self._convert_awq_qzeros_to_npu_offset(
layer.w13_qzeros.data, layer.w13_scales.data.dtype
),
)
self._register_or_replace_parameter(
layer,
"w2_weight_offset",
self._convert_awq_qzeros_to_npu_offset(
layer.w2_qzeros.data, layer.w2_scales.data.dtype
),
)
self.kernel.process_weights_after_loading(layer)
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> torch.Tensor:
return self.kernel.apply(layer, dispatch_output)
def apply_without_routing_weights(
self,
layer,
hidden_states,
hidden_states_scale,
group_list_type,
group_list,
output_dtype,
):
return self.kernel.apply_without_routing_weights(
layer,
hidden_states,
hidden_states_scale,
group_list_type,
group_list,
output_dtype,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import torch
import torch_npu
from sglang.srt.hardware_backend.npu.quantization.fused_moe_method_npu import (
npu_fused_experts,
)
if TYPE_CHECKING:
from sglang.srt.layers.moe import MoeRunnerConfig
from sglang.srt.layers.moe.token_dispatcher import StandardDispatchOutput
from sglang.srt.layers.quantization.base_config import QuantizationConfig
def unpack_from_int32(
weight: torch.Tensor,
num_bits: int,
packed_dim: int = 1,
) -> torch.Tensor:
"""
Unpacks quantized weights from int32 format back to original bits.
:param weight: The packed int32 tensor containing quantized weights
:param num_bits: The number of bits used for quantization (<= 8)
:param packed_dim: Dimension along which weights are packed (0 or 1), defaults to 1
:return: Unpacked tensor with int8 dtype after applying offset correction
"""
assert (
weight.dtype == torch.int32
), f"Expecting `weight.dtype` is torch.int32 but got {weight.dtype}."
assert (
num_bits <= 8
), f"Expecting `num_bits` should not be larger than 8 but got {num_bits}."
pack_factor = 32 // num_bits
mask = (1 << num_bits) - 1
if packed_dim == 1:
unpacked_weight = torch.zeros(
(weight.shape[0], weight.shape[1] * pack_factor),
device=weight.device,
dtype=torch.int32,
)
for i in range(pack_factor):
unpacked_weight[:, i::pack_factor] = (weight >> (num_bits * i)) & mask
else:
unpacked_weight = torch.zeros(
(weight.shape[0] * pack_factor, weight.shape[1]),
device=weight.device,
dtype=torch.int32,
)
for i in range(pack_factor):
unpacked_weight[i::pack_factor, :] = (weight >> (num_bits * i)) & mask
offset = pow(2, num_bits) // 2
unpacked_weight = (unpacked_weight - offset).to(torch.int8)
return unpacked_weight
class GPTQLinearAscendKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
self.use_v2_format = quant_config.checkpoint_format == "gptq_v2"
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
layer.qzeros = torch.nn.Parameter(
unpack_from_int32(
layer.qzeros.data.contiguous(),
self.quant_config.weight_bits,
packed_dim=1,
).to(layer.scales.dtype),
requires_grad=False,
)
if not self.use_v2_format:
layer.qzeros += 1
qweight_tmp = unpack_from_int32(
layer.qweight.data.contiguous(), self.quant_config.weight_bits, packed_dim=0
)
# use int8 to store weight by default
if self.quant_config.weight_bits != 4:
layer.qweight = torch.nn.Parameter(
qweight_tmp,
requires_grad=False,
)
return
# for 4bit case we need to pack 4bit weight to int32 to save memory
layer.qweight = torch.nn.Parameter(
torch_npu.npu_convert_weight_to_int4pack(qweight_tmp.to(torch.int32)),
requires_grad=False,
)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
qweight = layer.qweight
scales = layer.scales
qzeros = layer.qzeros
reshaped_x = x.reshape(-1, x.shape[-1])
if bias is not None and bias.dtype == torch.bfloat16:
bias = bias.float()
# 4bit weight is packed to int32(8 x int4)
if self.quant_config.weight_bits == 4:
out_shape = x.shape[:-1] + (qweight.shape[-1] * 8,)
else:
out_shape = x.shape[:-1] + (qweight.shape[-1],)
out = torch_npu.npu_weight_quant_batchmatmul(
reshaped_x,
qweight,
antiquant_scale=scales,
antiquant_offset=qzeros,
antiquant_group_size=self.quant_config.group_size,
bias=bias,
)
return out.reshape(out_shape)
class GPTQMoEAscendKernel:
def __init__(self, quant_config: Optional[QuantizationConfig] = None):
self.quant_config = quant_config
self.use_v2_format = quant_config.checkpoint_format == "gptq_v2"
self.moe_runner_config: Optional[MoeRunnerConfig] = None
def create_moe_runner(
self,
layer: torch.nn.Module,
moe_runner_config: MoeRunnerConfig,
**extra_weight_attrs,
):
self.moe_runner_config = moe_runner_config
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
w13_qzeros_2d = layer.w13_qzeros.data.contiguous().reshape(
-1, layer.w13_qzeros.shape[-1]
)
layer.w13_qzeros = torch.nn.Parameter(
unpack_from_int32(
w13_qzeros_2d,
self.quant_config.weight_bits,
packed_dim=1,
)
.reshape(layer.w13_qzeros.shape[0], layer.w13_qzeros.shape[1], -1)
.to(layer.w13_scales.dtype),
requires_grad=False,
)
if not self.use_v2_format:
layer.w13_qzeros += 1
w2_qzeros_2d = layer.w2_qzeros.data.contiguous().reshape(
-1, layer.w2_qzeros.shape[-1]
)
layer.w2_qzeros = torch.nn.Parameter(
unpack_from_int32(
w2_qzeros_2d,
self.quant_config.weight_bits,
packed_dim=1,
)
.reshape(layer.w2_qzeros.shape[0], layer.w2_qzeros.shape[1], -1)
.to(layer.w2_scales.dtype),
requires_grad=False,
)
if not self.use_v2_format:
layer.w2_qzeros += 1
w13_qweight_2d = (
layer.w13_qweight.data.transpose(-1, -2)
.contiguous()
.reshape(-1, layer.w13_qweight.shape[-2])
)
w13_qweight_tmp = unpack_from_int32(
w13_qweight_2d, self.quant_config.weight_bits, packed_dim=1
)
if self.quant_config.weight_bits == 4:
group_size = self.quant_config.group_size
scale_expanded = layer.w13_scales.data.repeat_interleave(group_size, dim=1)
neg_mask = scale_expanded < 0
if neg_mask.any():
neg_mask = neg_mask.transpose(-1, -2)
neg_mask = neg_mask.contiguous().reshape(w13_qweight_tmp.shape)
w13_qweight_tmp[neg_mask] = -w13_qweight_tmp[neg_mask]
if w13_qweight_tmp.max() > 7:
w13_qweight_tmp.clamp_(max=7)
layer.w13_scales.data.abs_()
layer.w13_qweight = torch.nn.Parameter(
torch_npu.npu_convert_weight_to_int4pack(
w13_qweight_tmp.reshape(
layer.w13_qweight.shape[0], layer.w13_qweight.shape[2], -1
)
.transpose(-1, -2)
.contiguous()
.reshape(-1, layer.w13_qweight.shape[2])
.to(torch.int32)
)
.reshape(layer.w13_qweight.shape[0], layer.w13_qweight.shape[1] * 8, -1)
.contiguous(),
requires_grad=False,
)
# use int8 to store weight by default
else:
layer.w13_qweight = torch.nn.Parameter(
w13_qweight_tmp.reshape(
layer.w13_qweight.shape[0], layer.w13_qweight.shape[2], -1
)
.transpose(-1, -2)
.contiguous(),
requires_grad=False,
)
w2_qweight_2d = (
layer.w2_qweight.data.transpose(-1, -2)
.contiguous()
.reshape(-1, layer.w2_qweight.shape[-2])
)
w2_qweight_tmp = unpack_from_int32(
w2_qweight_2d, self.quant_config.weight_bits, packed_dim=1
)
if self.quant_config.weight_bits == 4:
group_size = self.quant_config.group_size
scale_expanded = layer.w2_scales.data.repeat_interleave(group_size, dim=1)
neg_mask = scale_expanded < 0
if neg_mask.any():
neg_mask = neg_mask.transpose(-1, -2)
neg_mask = neg_mask.contiguous().reshape(w2_qweight_tmp.shape)
w2_qweight_tmp[neg_mask] = -w2_qweight_tmp[neg_mask]
if w2_qweight_tmp.max() > 7:
w2_qweight_tmp.clamp_(max=7)
layer.w2_scales.data.abs_()
layer.w2_qweight = torch.nn.Parameter(
torch_npu.npu_convert_weight_to_int4pack(
w2_qweight_tmp.reshape(
layer.w2_qweight.shape[0], layer.w2_qweight.shape[2], -1
)
.transpose(-1, -2)
.contiguous()
.reshape(-1, layer.w2_qweight.shape[2])
.to(torch.int32)
)
.reshape(layer.w2_qweight.shape[0], layer.w2_qweight.shape[1] * 8, -1)
.contiguous(),
requires_grad=False,
)
# use int8 to store weight by default
else:
layer.w2_qweight = torch.nn.Parameter(
w2_qweight_tmp.reshape(
layer.w2_qweight.shape[0], layer.w2_qweight.shape[2], -1
)
.transpose(-1, -2)
.contiguous(),
requires_grad=False,
)
def apply(
self,
layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput,
) -> torch.Tensor:
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput
assert self.moe_runner_config is not None, (
"moe_runner_config is not set. "
"Did you forget to call create_weights/create_moe_runner?"
)
assert self.moe_runner_config.activation in ("silu", "swiglu"), (
f"Only SiLU/Swiglu activation is supported, "
f"got {self.moe_runner_config.activation!r}."
)
x = dispatch_output.hidden_states
topk_output = dispatch_output.topk_output
topk_weights, topk_ids, _ = topk_output
topk_ids = topk_ids.to(torch.int32)
topk_weights = topk_weights.to(x.dtype)
output = npu_fused_experts(
hidden_states=x,
w13=layer.w13_qweight,
w13_scale=layer.w13_scales,
w13_offset=layer.w13_qzeros,
w2=layer.w2_qweight,
w2_scale=layer.w2_scales,
w2_offset=layer.w2_qzeros,
topk_weights=topk_weights,
topk_ids=topk_ids,
top_k=topk_ids.shape[1],
use_wna16=True,
)
return StandardCombineInput(hidden_states=output)
@@ -0,0 +1,622 @@
import logging
from typing import TYPE_CHECKING, Optional
import torch
from torch.nn.parameter import Parameter
from sglang.srt.hardware_backend.npu.utils import NPUACLFormat, npu_format_cast
from sglang.srt.layers.quantization.base_config import LinearMethodBase
if TYPE_CHECKING:
from sglang.srt.layers.quantization.base_config import QuantizationConfig
logger = logging.getLogger(__name__)
MXFP8_BLOCK_SIZE = 32
# W4A8_MXFP block (group) size — fixed at 32 by the msmodelslim export format.
MXFP4_BLOCK_SIZE = 32
# NPU ops are reached via torch.ops.npu.* (registered when torch_npu is imported
# by the runtime), so this module needs no top-level `import torch_npu` and stays
# importable on CUDA/CPU/AMD/XPU CI.
def _get_float8_e8m0fnu_dtype():
# Resolve lazily rather than as a module-level constant: this module is
# imported early (during quant-scheme registration), so reading the dtype at
# call time keeps it correct regardless of import order / platform.
return getattr(torch, "float8_e8m0fnu", None)
def _get_float4_e2m1fn_x2_dtype():
# The packed-FP4 dtype MUST come from torch_npu (an int enum, e.g. 296), not
# from torch. The NPU ops that consume it -- npu_dynamic_mx_quant(dst_type=),
# npu_quant_matmul(x2_dtype=), npu_format_cast(input_dtype=) -- REJECT the
# torch dtype object torch.float4_e2m1fn_x2 in op-plugin on recent torch_npu
# builds (it raises, or with None gives "output y must be same shape as input
# x"), even though torch.float4_e2m1fn_x2 exists. This is fp4-specific: fp8 /
# float8_e8m0fnu is accepted from torch either way. Verified on A5 /
# torch_npu 2.10.0.post2.dev20260704 (see llm/probe_fp4_w4a8_chain.py: dst=296
# passes the full quant->format_cast->matmul chain, dst=torch dtype fails).
#
# Lazy import so this NPU-only path keeps the module importable on
# CUDA/CPU/AMD/XPU CI (no top-level torch_npu; see AGENTS.md known pitfalls).
from sglang.srt.utils import is_npu
if is_npu():
import torch_npu
npu_dtype = getattr(torch_npu, "float4_e2m1fn_x2", None)
if npu_dtype is not None:
return npu_dtype
return getattr(torch, "float4_e2m1fn_x2", None)
class _NPULinearMethodBase(LinearMethodBase):
def __init__(
self,
quant_config: Optional["QuantizationConfig"] = None,
):
self.quant_config = quant_config
class NPUW8A8Int8LinearMethod(_NPULinearMethodBase):
def process_weights_after_loading(self, layer: torch.nn.Module):
layer.weight.data = layer.weight.data.transpose(0, 1).contiguous()
layer.weight.data = npu_format_cast(layer.weight.data)
layer.weight_scale.data = layer.weight_scale.data.flatten()
# Compressed-tensors format doesn't have this field
if hasattr(layer, "weight_offset"):
layer.weight_offset.data = layer.weight_offset.data.flatten()
expanding_factor = layer.weight.data.shape[0]
layer.aclnn_input_scale = torch.nn.Parameter(
layer.input_scale.data.repeat(expanding_factor).to(device="npu"),
requires_grad=False,
)
layer.aclnn_input_scale_reciprocal = 1 / torch.nn.Parameter(
layer.input_scale.data.repeat(expanding_factor).to(device="npu"),
requires_grad=False,
)
layer.aclnn_input_offset = torch.nn.Parameter(
layer.input_offset.data.repeat(expanding_factor).to(device="npu"),
requires_grad=False,
)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
from sglang.srt.layers.linear import RowParallelLinear
original_dtype = x.dtype
if original_dtype != torch.int8:
x = torch.ops.npu.npu_quantize(
x,
layer.aclnn_input_scale_reciprocal,
layer.aclnn_input_offset,
torch.qint8,
-1,
False,
)
# Only fuse bias add into GEMM for rank 0 (this ensures that
# bias will not get added more than once in Attention TP>1 case)
if isinstance(layer, RowParallelLinear) and layer.tp_rank > 0:
quant_bias = None
else:
quant_bias = layer.quant_bias
return torch.ops.npu.npu_quant_matmul(
x,
layer.weight,
layer.deq_scale,
bias=quant_bias,
output_dtype=original_dtype,
)
class NPUW8A8Int8DynamicLinearMethod(_NPULinearMethodBase):
def process_weights_after_loading(self, layer: torch.nn.Module):
layer.weight.data = layer.weight.data.transpose(0, 1).contiguous()
layer.weight.data = npu_format_cast(layer.weight.data)
layer.weight_scale.data = layer.weight_scale.data.flatten()
# Compressed-tensors format doesn't have this field
if hasattr(layer, "weight_offset"):
layer.weight_offset.data = layer.weight_offset.data.flatten()
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
if isinstance(x, tuple):
"""dynamic_scale is calculated in malprolog kernel"""
original_dtype = torch.bfloat16
quant_out, dynamic_scale = x
else:
original_dtype = x.dtype
quant_out, dynamic_scale = torch.ops.npu.npu_dynamic_quant(x)
return torch.ops.npu.npu_quant_matmul(
quant_out,
layer.weight,
layer.weight_scale,
pertoken_scale=dynamic_scale.flatten(),
bias=bias,
output_dtype=original_dtype,
)
class NPUMXFP8LinearMethod(_NPULinearMethodBase):
"""Ascend NPU MXFP8 linear method for LLM (SRT) models.
Shared kernel for both the online config path (``--quantization mxfp8``) and
the offline ModelSlimMXFP8Scheme (which delegates to this as ``self.kernel``).
process_weights_after_loading branches on weight dtype: FP16/BF16 weights are
quantised to MXFP8 at load time (online); pre-quantised float8_e4m3fn weights
are only re-laid-out (offline). Inference: dynamic MXFP8 activation quant +
MXFP8 matmul (block_size=32).
"""
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes,
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
):
from sglang.srt.layers.parameter import ModelWeightParameter
output_size_per_partition = sum(output_partition_sizes)
weight_loader = extra_weight_attrs.get("weight_loader")
layer.logical_widths = output_partition_sizes
layer.input_size_per_partition = input_size_per_partition
layer.output_size_per_partition = output_size_per_partition
layer.orig_dtype = params_dtype
# Load weights in original dtype; quantise later in process_weights_after_loading
weight = ModelWeightParameter(
data=torch.empty(
output_size_per_partition,
input_size_per_partition,
dtype=params_dtype,
),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
)
layer.register_parameter("weight", weight)
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
weight = layer.weight.data
if weight.dtype == torch.float8_e4m3fn:
# Offline (ModelSlim) path: weight is already MXFP8-quantised and
# layer.weight_scale holds the uint8 block scales [out, in/32]. Only
# re-layout to [in, out] / [in//64, out, 2] strided views below.
n_dim, k_dim = layer.weight_scale.data.shape
scale = layer.weight_scale.data.reshape(n_dim, k_dim // 2, 2)
layer.weight = Parameter(weight.transpose(0, 1), requires_grad=False)
layer.weight_scale_inv = Parameter(
scale.transpose(0, 1), requires_grad=False
)
# weight_scale is now folded into weight_scale_inv (which keeps the
# underlying storage alive via its view); drop the stale parameter so
# it doesn't linger in named_parameters() / state_dict().
del layer.weight_scale
else:
# Online path: quantise FP16/BF16 weights to MXFP8 at load time.
if weight.dtype not in (torch.float16, torch.bfloat16):
logger.warning(
"NPUMXFP8LinearMethod: weight dtype %s is not float16/bfloat16; "
"casting to bfloat16 before MXFP8 quantisation.",
weight.dtype,
)
weight = weight.to(torch.bfloat16)
# Move weight to NPU if needed (cpu offload may move it back to CPU).
if not weight.is_npu:
weight = weight.to(f"npu:{torch.npu.current_device()}")
# Online MXFP8 quantisation of weights (block_size=32).
# qw: [out, in] float8_e4m3fn, w_scale: [out, in//64, 2] uint8.
qw, w_scale = torch.ops.npu.npu_dynamic_mx_quant(
weight, dst_type=torch.float8_e4m3fn
)
layer.weight = Parameter(qw.transpose(0, 1), requires_grad=False)
layer.weight_scale_inv = Parameter(
w_scale.transpose(0, 1), requires_grad=False
)
# Both paths produce weight [in, out] and weight_scale_inv [in//64, out,
# 2] as strided transpose views — DO NOT call .contiguous(). The matmul
# reduction loop scans the in-dim per output column; the [out, in]
# row-major source gives stride-1 access for that scan via the transpose
# view (matches msmodelslim's offline layout and vllm-ascend's
# AscendW8A8MXFP8DynamicLinearMethod). Calling .contiguous() physically
# reorders to [in, out] row-major, making the inner-loop stride = out and
# tanking HBM bandwidth.
# Cache FP32 bias once to avoid a per-forward dtype conversion + alloc.
if (
getattr(layer, "bias", None) is not None
and layer.bias.dtype != torch.float32
):
layer.bias_fp32 = Parameter(
layer.bias.data.to(torch.float32), requires_grad=False
)
else:
layer.bias_fp32 = None
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
original_dtype = x.dtype
if original_dtype not in (torch.float16, torch.bfloat16):
x = x.to(torch.bfloat16)
original_dtype = torch.bfloat16
# Flatten to 2D [tokens, hidden] for npu_dynamic_mx_quant
input_shape = x.shape
x_2d = x.reshape(-1, x.shape[-1])
# Dynamic MXFP8 activation quantisation
qx, input_scale = torch.ops.npu.npu_dynamic_mx_quant(
x_2d, dst_type=torch.float8_e4m3fn
)
# MXFP8 matmul (weight & scale already transposed at load time)
# Use the cached FP32 bias from process_weights_after_loading; fall back
# to per-call conversion if the cache was bypassed (e.g. dynamic bias).
if bias is None:
quant_bias = None
elif (
bias is getattr(layer, "bias", None)
and getattr(layer, "bias_fp32", None) is not None
):
quant_bias = layer.bias_fp32
else:
quant_bias = bias.to(torch.float32)
e8m0_dtype = _get_float8_e8m0fnu_dtype()
output = torch.ops.npu.npu_quant_matmul(
qx,
layer.weight,
layer.weight_scale_inv,
scale_dtype=e8m0_dtype,
pertoken_scale=input_scale,
pertoken_scale_dtype=e8m0_dtype,
bias=quant_bias,
output_dtype=original_dtype,
group_sizes=[1, 1, MXFP8_BLOCK_SIZE],
)
# Restore original shape (replace last dim with output features)
output_shape = list(input_shape[:-1]) + [output.shape[-1]]
return output.reshape(output_shape)
class NPU_W4A4DynamicLinearMethod(_NPULinearMethodBase):
def process_weights_after_loading(self, layer):
layer.weight.data = layer.weight.data.transpose(0, 1).contiguous()
layer.weight_scale.data = layer.weight_scale.data.flatten()
layer.weight_scale_fp32 = layer.weight_scale.data.to(torch.float32)
layer.weight_offset.data = layer.weight_offset.data.flatten()
layer.weight.data = torch.ops.npu.npu_convert_weight_to_int4pack(
layer.weight.data.to(torch.int32)
)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
tp_rank: Optional[int] = 0,
) -> torch.Tensor:
original_dtype = x.dtype
quant_out, dynamic_scale = torch.ops.npu.npu_dynamic_quant(
x, dst_type=torch.quint4x2
)
return torch.ops.npu.npu_quant_matmul(
quant_out,
layer.weight,
layer.weight_scale,
pertoken_scale=dynamic_scale.flatten(),
bias=bias,
output_dtype=original_dtype,
)
class NPUMXFP4W4A8LinearMethod(_NPULinearMethodBase):
"""Ascend NPU W4A8 online quantization: MXFP4 weights + MXFP8 activations.
This is a *true* W4(weight) A8(activation) path: it mirrors the offline
``W4A8_MXFP`` kernel (``NPUMXFP4W4A8OfflineLinearMethod``) exactly — the only
difference is that the FP4 weights are produced online from BF16/FP16
(round-to-nearest, no calibration) instead of being loaded from a msmodelslim
checkpoint. An earlier version of this method ran a *dual-level* scheme that
also compressed the activation to FP4 (W4A4 compute via
``npu_dual_level_quant_matmul``); that was a large accuracy regression — 4-bit
activations — so it was replaced with the single-level FP8-activation path
below, aligned with the offline W4A8 implementation.
Weight quantization (process_weights_after_loading):
BF16/FP16 weight → npu_dynamic_mx_quant(dst=float4_e2m1fn_x2) → packed FP4
+ UE8M0 block scale → npu_format_cast to FRACTAL_NZ → transpose [in//2, out]
Inference (apply):
BF16/FP16 activation → npu_dynamic_mx_quant(dst=float8_e4m3fn) (A8, FP8)
→ npu_quant_matmul(x2_dtype=float4_e2m1fn_x2, group_sizes=[0, 0, block])
Hardware: Ascend 950 (A5) + a recent torch_npu with the FP4 npu_quant_matmul
(same requirement as the offline W4A8 path — see that class's docstring).
"""
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes,
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
):
"""Register an unquantized (``params_dtype``) weight placeholder.
Online quantization needs its own ``create_weights`` because the
checkpoint still holds full-precision BF16/FP16 weights: the loader
fills this buffer, then ``process_weights_after_loading`` quantizes it to
MXFP4 in place. This differs from the offline/int8 methods, whose weights
are created by the scheme's own ``create_weights`` to match the
already-quantized (FP8 / uint8-packed) layout the checkpoint provides.
"""
from sglang.srt.layers.parameter import ModelWeightParameter
output_size_per_partition = sum(output_partition_sizes)
weight_loader = extra_weight_attrs.get("weight_loader")
layer.logical_widths = output_partition_sizes
layer.input_size_per_partition = input_size_per_partition
layer.output_size_per_partition = output_size_per_partition
layer.orig_dtype = params_dtype
# Load weights in original dtype; quantise to MXFP4 in
# process_weights_after_loading.
weight = ModelWeightParameter(
data=torch.empty(
output_size_per_partition,
input_size_per_partition,
dtype=params_dtype,
),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
)
layer.register_parameter("weight", weight)
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# Online single-level MXFP4 weight quant, then lay the weight out exactly
# like the offline W4A8 path so the same npu_quant_matmul(x2_dtype=fp4)
# kernel accepts it. All NPU ops go through torch.ops.npu.* (no torch_npu).
fp4_dtype = _get_float4_e2m1fn_x2_dtype()
weight_fp = layer.weight.data
if weight_fp.dtype not in (torch.float16, torch.bfloat16):
weight_fp = weight_fp.to(torch.bfloat16)
# Move to NPU if needed (cpu offload may have put it on CPU).
if not weight_fp.is_npu:
weight_fp = weight_fp.to(f"npu:{torch.npu.current_device()}")
# BF16 -> packed FP4 (float4_e2m1fn_x2, [out, in//2]) + UE8M0 block scale.
# npu_dynamic_mx_quant returns the scale as [out, in//64, 2] (3D); older
# builds may return [out, in//32] (2D) — handle both before the transpose.
qw, w_scale = torch.ops.npu.npu_dynamic_mx_quant(
weight_fp, dst_type=fp4_dtype, round_mode="round"
)
# weight: packed FP4 -> FRACTAL_NZ (float8_e4m3fn view) -> transpose
# [in//2, out]. Mirror the offline path (no .contiguous() on the NZ view);
# view as uint8 first because npu_format_cast only accepts int-dtype tensors.
qw_nz = npu_format_cast(
qw.view(torch.uint8),
NPUACLFormat.ACL_FORMAT_FRACTAL_NZ,
customize_dtype=torch.float8_e4m3fn,
input_dtype=fp4_dtype,
)
layer.weight = Parameter(qw_nz.transpose(-1, -2), requires_grad=False)
# weight_scale -> [in//64, out, 2] to match npu_quant_matmul.
if w_scale.dim() == 2:
n, k = w_scale.shape
w_scale = w_scale.reshape(n, k // 2, 2)
layer.weight_scale = Parameter(w_scale.transpose(-3, -2), requires_grad=False)
# Cache FP32 bias once to avoid a per-forward dtype conversion + alloc.
if (
getattr(layer, "bias", None) is not None
and layer.bias.dtype != torch.float32
):
layer.bias_fp32 = Parameter(
layer.bias.data.to(torch.float32), requires_grad=False
)
else:
layer.bias_fp32 = None
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
e8m0_dtype = _get_float8_e8m0fnu_dtype()
fp4_dtype = _get_float4_e2m1fn_x2_dtype()
original_dtype = x.dtype
if original_dtype not in (torch.float16, torch.bfloat16):
x = x.to(torch.bfloat16)
original_dtype = torch.bfloat16
# Flatten to 2D [tokens, hidden] for npu_dynamic_mx_quant.
input_shape = x.shape
x_2d = x.reshape(-1, x.shape[-1])
# Dynamic MXFP8 activation quantisation (A8 — FP8, not FP4).
quantized_x, dynamic_scale = torch.ops.npu.npu_dynamic_mx_quant(
x_2d, dst_type=torch.float8_e4m3fn
)
# Use the cached FP32 bias from process_weights_after_loading; fall back
# to per-call conversion if the cache was bypassed (e.g. dynamic bias).
if bias is None:
quant_bias = None
elif (
bias is getattr(layer, "bias", None)
and getattr(layer, "bias_fp32", None) is not None
):
quant_bias = layer.bias_fp32
else:
quant_bias = bias.to(torch.float32)
# True W4(weight)A8(activation) matmul, identical to the offline path.
output = torch.ops.npu.npu_quant_matmul(
quantized_x,
layer.weight,
layer.weight_scale,
scale_dtype=e8m0_dtype,
pertoken_scale=dynamic_scale,
pertoken_scale_dtype=e8m0_dtype,
bias=quant_bias,
output_dtype=original_dtype,
x2_dtype=fp4_dtype,
group_sizes=[0, 0, MXFP4_BLOCK_SIZE],
)
# Restore original shape (replace last dim with output features).
output_shape = list(input_shape[:-1]) + [output.shape[-1]]
return output.reshape(output_shape)
class NPUMXFP4W4A8OfflineLinearMethod(_NPULinearMethodBase):
"""Ascend NPU offline W4A8 (ModelSlim ``W4A8_MXFP``): packed-FP4 weights + MXFP8 activations.
Kernel for the offline ModelSlimMXFP4W4A8Scheme (delegated as ``self.kernel``).
The msmodelslim ``W4A8_MXFP`` checkpoint stores weights as *packed FP4*
(``pack_fp4_to_uint8`` → ``uint8`` shape ``[out, in//2]``) plus UE8M0 block
scales (``uint8`` shape ``[out, in//group_size]``):
process_weights_after_loading:
weight (uint8 packed FP4 [out, in//2]) → npu_format_cast(29,
customize_dtype=float8_e4m3fn, input_dtype=float4_e2m1fn_x2) → FRACTAL_NZ
→ transpose [in//2, out]
weight_scale [out, in/32] → reshape [out, in/64, 2] → transpose → [in/64, out, 2]
apply:
BF16/FP16 activation → npu_dynamic_mx_quant(dst=float8_e4m3fn) (A8, MXFP8)
→ npu_quant_matmul(x2_dtype=float4_e2m1fn_x2, group_sizes=[0, 0, block])
Mirrors vllm-ascend ``AscendW4A8MXFPDynamicLinearMethod`` exactly (Ascend 950/A5).
The weight is cast to FRACTAL_NZ then transposed; ``npu_dynamic_mx_quant`` already
returns a 3D ``[tokens, in//64, 2]`` block scale so the matmul needs no extra
scale-layout normalization.
⚠️ REQUIRES a recent torch_npu build for the FP4 ``npu_quant_matmul``. On the
A5 this device forces ``allow_internal_format=False`` (the NZ cast still produces
a ``FRACTAL_NZ_C0_16`` tensor, which is fine). Older torch_npu (e.g.
``2.10.0.dev20260320``) had a broken FP4 matmul that rejected the NZ weight in
*prefill* with ``x2 should be in ... nz format, but it is 2``;
``2.10.0.post1.dev20260624`` (and later) runs the vllm-aligned NZ path
correctly. If you hit ``it is 2``, update torch_npu — do NOT "fix" it by
switching the weight to ND.
⚠️ A ``atb::OperationSetup`` *segfault during decode* (not prefill) is a
DIFFERENT, unrelated issue: it is the eager-decode ``ascend`` attention
backend, NOT this matmul (verified by stage-sync bisection — qkv's matmul
syncs clean, the fault surfaces at the entry-sync of the next layer, i.e. the
decode attention between qkv and o_proj). Run with the NPU decode graph (do
NOT pass ``--disable-cuda-graph``); graph mode is the NPU default and what
vllm uses. This attention issue is model-agnostic and out of scope for W4A8.
This is a true W4(weight) A8(activation) single-level matmul. The *online*
``NPUMXFP4W4A8LinearMethod`` now uses this exact apply path — the only
difference is that it quantizes BF16/FP16 weights to FP4 at load time instead
of loading them from a msmodelslim checkpoint. ``group_size`` is fixed at 32
by the ``W4A8_MXFP`` export format.
"""
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# Mirror vllm-ascend AscendW4A8MXFPDynamicLinearMethod: cast the packed-FP4
# weight to FRACTAL_NZ then transpose. All NPU ops go through
# torch.ops.npu.* (no torch_npu). Requires a recent torch_npu build (see
# class docstring): older builds reject the NZ weight ("x2 ... it is 2").
fp4_dtype = _get_float4_e2m1fn_x2_dtype()
# weight: packed-FP4 uint8 [out, in//2] -> FRACTAL_NZ (float8_e4m3fn view)
# -> transpose to [in//2, out].
layer.weight.data = npu_format_cast(
layer.weight.data,
NPUACLFormat.ACL_FORMAT_FRACTAL_NZ,
customize_dtype=torch.float8_e4m3fn,
input_dtype=fp4_dtype,
)
layer.weight.data = layer.weight.data.transpose(-1, -2)
# weight_scale: [out, in/32] uint8 -> [in/64, out, 2].
n, k = layer.weight_scale.data.shape
layer.weight_scale.data = layer.weight_scale.data.reshape(
n, k // 2, 2
).transpose(-3, -2)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: Optional[torch.Tensor] = None,
) -> torch.Tensor:
e8m0_dtype = _get_float8_e8m0fnu_dtype()
fp4_dtype = _get_float4_e2m1fn_x2_dtype()
original_dtype = x.dtype
if original_dtype not in (torch.float16, torch.bfloat16):
x = x.to(torch.bfloat16)
original_dtype = torch.bfloat16
# Flatten to 2D [tokens, hidden] for npu_dynamic_mx_quant.
input_shape = x.shape
x_2d = x.reshape(-1, x.shape[-1])
# Dynamic MXFP8 activation quantisation (A8).
quantized_x, dynamic_scale = torch.ops.npu.npu_dynamic_mx_quant(
x_2d, dst_type=torch.float8_e4m3fn
)
if bias is not None and bias.dtype != torch.float32:
bias = bias.to(torch.float32)
# W4(weight)A8(activation) matmul, mirroring vllm-ascend exactly.
output = torch.ops.npu.npu_quant_matmul(
quantized_x,
layer.weight,
layer.weight_scale,
scale_dtype=e8m0_dtype,
pertoken_scale=dynamic_scale,
pertoken_scale_dtype=e8m0_dtype,
bias=bias,
output_dtype=original_dtype,
x2_dtype=fp4_dtype,
group_sizes=[0, 0, MXFP4_BLOCK_SIZE],
)
# Restore original shape (replace last dim with output features).
output_shape = list(input_shape[:-1]) + [output.shape[-1]]
return output.reshape(output_shape)
@@ -0,0 +1,366 @@
import functools
import logging
import sys
from enum import IntEnum
from typing import TYPE_CHECKING, Callable
import torch
from sglang.srt.environ import envs
from sglang.srt.utils import get_npu_memory_capacity, is_npu
if TYPE_CHECKING:
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
_is_npu = is_npu()
indexer_weight_stream = None
gva_is_inited = False
class NPUACLFormat(IntEnum):
ACL_FORMAT_UNDEFINED = -1
ACL_FORMAT_ND = 2
ACL_FORMAT_FRACTAL_NZ = 29
class FusedMoEMode(IntEnum):
FUSED_DEEP_MOE = 1
DISPATCH_FFN_COMBINE = 2
def _call_once(fn: Callable):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if getattr(fn, "_has_been_called", False):
logger.debug("Function {} has already been called.", fn.__name__)
return
fn._has_been_called = True
return fn(*args, **kwargs)
return wrapper
def set_default_server_args(args: "ServerArgs"):
"""
Set default server arguments for NPU backend.
"""
# NPU only works with "ascend" attention backend for now
args.attention_backend = "ascend"
args.prefill_attention_backend = "ascend"
args.decode_attention_backend = "ascend"
if args.page_size is None:
args.page_size = 128
# NPU memory settings
decode = args.cuda_graph_config.decode
npu_mem = get_npu_memory_capacity()
if npu_mem <= 32 * 1024:
# Ascend 910B4,910B4_1
# (chunked_prefill_size 4k, max_bs 16 if tp < 4 else 64)
if args.chunked_prefill_size is None:
args.chunked_prefill_size = 4 * 1024
if decode.max_bs is None:
if args.tp_size < 4:
decode.max_bs = 16
else:
decode.max_bs = 64
elif npu_mem <= 64 * 1024:
# Ascend 910B1,910B2,910B2C,910B3,910_9391,910_9392,910_9381,910_9382,910_9372,910_9362
# (chunked_prefill_size 8k, max_bs 64 if tp < 4 else 256)
if args.chunked_prefill_size is None:
args.chunked_prefill_size = 8 * 1024
if decode.max_bs is None:
if args.tp_size < 4:
decode.max_bs = 64
else:
decode.max_bs = 256
# NPU does not support CustomAllReduce
args.disable_custom_all_reduce = True
# handles hierarchical cache configs
if args.enable_hierarchical_cache:
args.hicache_io_backend = "kernel_ascend"
if args.use_mla_backend():
args.hicache_mem_layout = "page_first_kv_split"
else:
args.hicache_mem_layout = "page_first_direct"
@_call_once
def init_npu_backend():
"""
Initialize NPU backend. This function should be called only once.
"""
assert _is_npu, "NPU backend initialization called on non-NPU device."
try:
import custom_ops # noqa: F401
import sgl_kernel_npu # noqa: F401
except ImportError as e:
logger.warning("NPU custom kernel packages unavailable: %s", e)
import torch_npu
from torch_npu.contrib import transfer_to_npu # noqa: F401
# Re-mock torch.cuda.is_available cuz transfer_to_npu mocks it True
torch.cuda.is_available = lambda: False
torch_npu.npu.config.allow_internal_format = True
torch_npu.npu.set_compile_mode(jit_compile=False)
def _is_nz_aligned(tensor: torch.Tensor) -> bool:
"""Check whether the last two dims satisfy FRACTAL_NZ alignment rules.
Ascend FRACTAL_NZ requires:
BF16 / FP16 : both dims divisible by 16
INT8 : k % 16 == 0 and n % 32 == 0
INT4 : k % 16 == 0 and n % 64 == 0
FP4 : both dims divisible by 64
"""
if tensor.dim() < 2:
return False
k, n = tensor.shape[-2], tensor.shape[-1]
if tensor.dtype in (torch.bfloat16, torch.float16):
return k % 16 == 0 and n % 16 == 0
if tensor.dtype == torch.int8:
return k % 16 == 0 and n % 32 == 0
if tensor.dtype in (torch.uint8, torch.int32):
# INT4 is typically packed into uint8/int32; be conservative
return k % 16 == 0 and n % 64 == 0
return True
def npu_format_cast(
tensor: torch.Tensor,
acl_format: NPUACLFormat = NPUACLFormat.ACL_FORMAT_FRACTAL_NZ,
*,
customize_dtype=None,
input_dtype=None,
) -> torch.Tensor:
"""
Cast a tensor to a specific NPU ACL format.
Args:
tensor (torch.Tensor): The input tensor.
acl_format (NPUACLFormat): The target NPU ACL format.
customize_dtype / input_dtype: packed-FP4 unpack kwargs (e.g.
``customize_dtype=torch.float8_e4m3fn``,
``input_dtype=torch.float4_e2m1fn_x2``). When either is set the unpack
kwargs are forwarded to the op and the ``_is_nz_aligned`` ND fallback
is skipped: the FP4 matmul strictly requires FRACTAL_NZ, so a silent
ND fallback would corrupt results.
Returns:
torch.Tensor: The tensor cast to the specified NPU ACL format.
"""
if not _is_npu:
return tensor
if envs.SGLANG_NPU_DISABLE_ACL_FORMAT_WEIGHT.get():
return tensor
if tensor.device == torch.device("cpu"):
logger.warning_once(
"Warning: The conversion from 'ND' to 'NZ' does not work on the CPU. "
"Please disable offloading, otherwise the performance will be "
"significantly reduced. --dit-cpu-offload false"
)
return tensor
# Skip format cast for meta tensors (used in offloader)
if tensor.device.type == "meta":
return tensor
# Packed-FP4 → FRACTAL_NZ: forward the unpack kwargs to the op, and skip the
# _is_nz_aligned ND fallback — the FP4 matmul strictly requires NZ, so a
# silent ND fallback would corrupt results.
if customize_dtype is not None or input_dtype is not None:
return torch.ops.npu.npu_format_cast(
tensor,
int(acl_format),
customize_dtype=customize_dtype,
input_dtype=input_dtype,
)
if acl_format == NPUACLFormat.ACL_FORMAT_FRACTAL_NZ and not _is_nz_aligned(tensor):
k, n = tensor.shape[-2], tensor.shape[-1]
logger.warning_once(
"Skipping FRACTAL_NZ format cast: tensor shape (%d, %d) dtype %s "
"is not aligned to NZ requirements. Falling back to 'ND' format, "
"which may reduce NPU performance.",
k,
n,
tensor.dtype,
)
return tensor
return torch.ops.npu.npu_format_cast(tensor, acl_format.value)
def get_indexer_weight_stream():
global indexer_weight_stream
if indexer_weight_stream is None:
indexer_weight_stream = torch.npu.Stream()
return indexer_weight_stream
def init_zbal(world_size, gpu_id, world_rank, do_check=True):
"""
init zbal, if is mix alloc mode, only register for sma & comm
"""
zbal_mem_size = envs.SGLANG_ZBAL_LOCAL_MEM_SIZE.get()
if not zbal_mem_size > 0:
return 1
global gva_is_inited
from zbal import is_mix_alloc, switch_to_allocator, zbal_init
if is_mix_alloc():
switch_to_allocator()
# use lazy init for mix alloc
return 1
else:
if envs.SGLANG_ZBAL_BOOTSTRAP_URL.get():
ret = zbal_init(
world_size,
gpu_id,
world_rank,
zbal_mem_size * (1024**2),
ip_port=envs.SGLANG_ZBAL_BOOTSTRAP_URL.get(),
)
else:
ret = zbal_init(world_size, gpu_id, world_rank, zbal_mem_size * (1024**2))
gva_is_inited = True
if do_check and not ret:
logger.error("[ZBAL] zbal init failed!")
sys.exit(-1)
return ret
def lazy_init_zbal_gva_mem(
device, gpu_id, world_rank, world_size, cpu_group=None, do_check=True
):
"""
lazy init zbal gva mem, keep weights and kv remains alloc by dma vmm to avoid memory fragment
"""
from zbal import is_mix_alloc, zbal_init
if not is_mix_alloc():
logger.info(
"lazy init is supported only in mix alloc mode, this action will be passed"
)
return 1
global gva_is_inited
from sglang.srt.utils.common import get_available_gpu_memory
# TODO need to use allgather if you want use total_memory stats from mem_get_info as unbalance os
total_memory = 61.2 # 2.5GB for other (workspace & os) outside torch
free_gpu_memory = get_available_gpu_memory(
device,
gpu_id,
distributed=world_size > 1,
cpu_group=cpu_group,
empty_cache=True,
)
used_memory = total_memory - free_gpu_memory
used_memory_in_mb = int(used_memory * 1024)
gva_in_mb = envs.SGLANG_ZBAL_LOCAL_MEM_SIZE.get() - used_memory_in_mb
gva_in_mb = gva_in_mb - gva_in_mb % 128 # align to 128MB
print(f"[ZBAL] rank {world_rank} allocated {gva_in_mb} MB gva space.")
assert not gva_is_inited, "zbal gva should be inited only once"
# zbal_set_logger_level(0)
if envs.SGLANG_ZBAL_BOOTSTRAP_URL.get():
res = zbal_init(
world_size,
gpu_id,
world_rank,
gva_in_mb * (1024**2),
ip_port=envs.SGLANG_ZBAL_BOOTSTRAP_URL.get(),
)
else:
res = zbal_init(world_size, gpu_id, world_rank, gva_in_mb * (1024**2))
gva_is_inited = True
if do_check and not res:
logger.error("[ZBAL] zbal lazy init failed!")
sys.exit(-1)
return res
share_stream = None
routed_stream = None
def get_share_stream():
global share_stream
return share_stream
def set_share_stream(stream):
global share_stream
share_stream = stream
# TODO LKL: set stream limit has impact on precision
# torch.npu.set_stream_limit(share_stream, 8, 16)
def get_routed_stream():
global routed_stream
return routed_stream
def set_routed_stream(stream):
global routed_stream
routed_stream = stream
# TODO LKL: set stream limit has impact on precision
# torch.npu.set_stream_limit(routed_stream, 16, 32)
def wait_share_stream():
stream = get_share_stream()
if stream is not None:
cur_stream = torch.get_device_module().current_stream()
cur_stream.wait_stream(stream)
def wait_routed_stream():
stream = get_routed_stream()
if stream is not None:
cur_stream = torch.get_device_module().current_stream()
cur_stream.wait_stream(stream)
def process_shared_expert(hidden_states, forward_func):
stream = get_share_stream()
if stream is None:
stream = torch.get_device_module().Stream()
set_share_stream(stream)
stream.wait_stream(torch.get_device_module().current_stream())
with torch.get_device_module().stream(stream):
shared_output = forward_func(hidden_states)
return shared_output
def process_routed_expert(hidden_states, topk_output, forward_func):
stream = get_routed_stream()
if stream is None:
stream = torch.get_device_module().Stream()
set_routed_stream(stream)
stream.wait_stream(torch.get_device_module().current_stream())
with torch.get_device_module().stream(stream):
shared_output = forward_func(hidden_states, topk_output)
return shared_output
@@ -0,0 +1 @@
# XPU (Intel GPU) hardware backend
@@ -0,0 +1,100 @@
"""FullXPUGraphBackend — Intel XPU full-graph capture (torch.xpu.XPUGraph).
Mirrors FullCudaGraphBackend with XPU-specific differences:
- Captures via torch.xpu.graph(xpu_graph=...) into torch.xpu.XPUGraph.
- Shares the global graph memory pool with the prefill backend so that
decode + prefill graphs occupy max(decode, prefill) rather than their sum.
- No set_graph_pool_id: SymmetricMemoryContext is never triggered on XPU
(oneCCL has no ncclMemAlloc equivalent; enable_symm_mem defaults False).
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
import torch
from sglang.srt.model_executor.runner.shape_key import ShapeKey
from sglang.srt.model_executor.runner_backend.base_cuda_graph_backend import (
BaseCudaGraphBackend,
)
from sglang.srt.model_executor.runner_utils.pool import (
get_or_create_global_graph_memory_pool,
)
if TYPE_CHECKING:
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.model_executor.runner.base_cuda_graph_runner import (
BaseCudaGraphRunner,
)
class FullXPUGraphBackend(BaseCudaGraphBackend):
"""One torch.xpu.XPUGraph per shape for Intel XPU devices."""
def __init__(
self,
cuda_graph_runner: BaseCudaGraphRunner,
) -> None:
self._graphs: Dict[Any, torch.xpu.XPUGraph] = {}
self._outputs: Dict[Any, Any] = {}
self._pool = None
self._device_module = cuda_graph_runner.device_module
self._tp_group = cuda_graph_runner.model_runner.tp_group
self._capture_stream: Optional[torch.xpu.Stream] = None
@contextmanager
def capture_session(self, stream: torch.xpu.Stream):
if self._pool is None:
self._pool = get_or_create_global_graph_memory_pool(self._device_module)
self._capture_stream = stream
try:
yield
finally:
self._capture_stream = None
def capture_one(
self,
shape_key: ShapeKey,
forward_fn: Callable[[], Any],
dummies: Optional[Any] = None,
post_warmup_hook: Optional[Callable[[], None]] = None,
) -> None:
for _ in range(2):
self._device_module.synchronize()
self._tp_group.barrier()
forward_fn()
if post_warmup_hook is not None:
post_warmup_hook()
graph = torch.xpu.XPUGraph()
with self._device_module.graph(
xpu_graph=graph, pool=self._pool, stream=self._capture_stream
):
out = forward_fn()
self._graphs[shape_key] = graph
self._outputs[shape_key] = out
def can_run(self, forward_batch: ForwardBatch, shape_key: ShapeKey) -> bool:
return shape_key in self._graphs
@contextmanager
def replay_session(self):
yield
def replay(
self,
shape_key: ShapeKey,
static_forward_batch: ForwardBatch,
**kwargs,
) -> Any:
self._graphs[shape_key].replay()
return self._outputs[shape_key]
def cleanup(self) -> None:
self._graphs.clear()
self._outputs.clear()
self._pool = None
@@ -0,0 +1,169 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Run the model with xpu graph and torch.compile."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import torch
from torch.profiler import ProfilerActivity, profile
from sglang.srt.model_executor.runner import DecodeCudaGraphRunner
from sglang.srt.utils import register_xpu_device_properties_for_dynamo
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.model_executor.model_runner import ModelRunner
_fake_ops_registered = False
def register_fake_ops():
"""Register fake/abstract implementations for XPU sgl_kernel ops so that
torch.compile (Dynamo) can trace through them using FakeTensors for shape
and dtype propagation, without executing the real GPU kernels.
"""
global _fake_ops_registered
if _fake_ops_registered:
return
_fake_ops_registered = True
@torch.library.register_fake("sgl_kernel::fwd")
def _(
q,
k,
v,
q_v,
cu_seqlens_q,
cu_seqlens_k,
max_seqlen_q,
max_seqlen_k,
page_table,
kv_batch_idx,
leftpad_k,
rotary_cos,
rotary_sin,
seqlens_rotary,
q_descale,
k_descale,
v_descale,
softmax_scale,
sinks,
is_causal,
window_size_left,
window_size_right,
softcap,
is_rotary_interleaved,
scheduler_metadata,
num_kv_splits,
pack_gqa,
sm_margin,
out=None,
):
total_q = q.shape[0]
num_heads_q = q.shape[1]
head_size_v = v.shape[-1]
if out is None:
out = q.new_empty(total_q, num_heads_q, head_size_v)
softmax_lse = q.new_empty(num_heads_q, total_q, dtype=torch.float32)
# out_accum and softmax_lse_accum are intermediate split-kv buffers;
# they are only read when num_kv_splits > 1, which is determined at
# runtime. Return empty tensors with correct rank so downstream ops
# that index into the list do not fail shape propagation.
out_accum = q.new_empty(0)
softmax_lse_accum = q.new_empty(0, dtype=torch.float32)
return (out, softmax_lse, out_accum, softmax_lse_accum)
@torch.library.register_fake("sgl_kernel::flash_mla_decode")
def _(
out,
q_nope,
q_pe,
kv_c_and_k_pe_cache,
seq_lens,
page_table,
workspace,
sm_scale,
num_kv_splits,
):
return
class XPUGraphRunner(DecodeCudaGraphRunner):
"""A XPUGraphRunner runs the forward pass of a model with xpu graph and torch.compile."""
@staticmethod
def _apply_xpu_compile_config() -> None:
"""Apply XPU-specific torch.compile / dynamo settings.
Called unconditionally before super().__init__() so that the settings
are in place regardless of whether --enable-torch-compile is passed.
The critical flag is suppress_errors: when the Intel IGC compiler
crashes with SIGFPE on certain reduction kernels (ocloc -device bmg
returns exit code 245), dynamo falls back to eager for that subgraph
instead of propagating the crash.
"""
import torch._dynamo.config
torch._dynamo.config.suppress_errors = True
def __init__(self, model_runner: ModelRunner):
assert (
not model_runner.server_args.enable_memory_saver
), "XPUGraphRunner does not support Torch Memory Saver yet."
register_fake_ops()
self._apply_xpu_compile_config()
register_xpu_device_properties_for_dynamo()
super().__init__(model_runner)
assert (
not self.enable_two_batch_overlap
), "XPUGraphRunner does not support two batch overlap yet."
assert (
not self.require_mlp_tp_gather
), "XPUGraphRunner does not support MLP TP gather yet."
assert (
not self.require_mlp_sync
), "XPUGraphRunner does not support MLP sync yet."
assert (
not self.require_gathered_buffer
), "XPUGraphRunner does not support gathered buffer yet."
def _init_profile_context_and_memory_record(self):
profile_context = profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.XPU],
record_shapes=True,
)
torch.xpu.memory._record_memory_history()
return profile_context
def _post_process_after_profile(self, prof_context):
torch.xpu.memory._dump_snapshot("xpu_graph_runner_memory_usage.pickle")
torch.xpu.memory._record_memory_history(enabled=None)
log_message = (
"Sorted by XPU Time:\n"
+ prof_context.key_averages(group_by_input_shape=True).table(
sort_by="self_xpu_time_total"
)
+ "\n\nSorted by CPU Time:\n"
+ prof_context.key_averages(group_by_input_shape=True).table(
sort_by="self_cpu_time_total"
)
+ "\n\nMemory Usage is saved to xpu_graph_runner_memory_usage.pickle\n"
)
logger.info(log_message)
@@ -0,0 +1,280 @@
from typing import Optional, Tuple
import torch
import triton
import triton.language as tl
from sglang.srt.layers.attention.fla.index import (
prepare_chunk_indices,
prepare_chunk_offsets,
)
from sglang.srt.layers.attention.fla.op import exp, make_tensor_descriptor, safe_exp
from sglang.srt.layers.attention.fla.utils import (
autotune_cache_kwargs,
)
CHUNK_SIZE = 64
# This kernel handles K blocks in a for loop to minimize register spills.
# Time is the OUTER loop; K blocks are processed in two inner phases per step:
# Phase 1: store h to output, accumulate v_correction = sum_k(w_k @ h_k^T)
# Phase 2: update h = gate * h + k^T @ v_gated, save to scratch (initial_state)
@triton.autotune(
configs=[triton.Config({"BV": 64}, num_warps=8, num_stages=2)],
key=["H", "K", "V", "BT", "USE_GK", "USE_INITIAL_STATE", "NT_BUCKET"],
**autotune_cache_kwargs,
)
@triton.jit(do_not_specialize=["T"])
def chunk_gated_delta_rule_fwd_kernel_h_blockdim64_k_loop(
k,
v,
w,
v_new,
g,
gk,
h,
initial_state,
initial_state_indices,
cu_seqlens,
chunk_offsets,
T,
H: tl.constexpr,
Hg: tl.constexpr,
K: tl.constexpr,
V: tl.constexpr,
BT: tl.constexpr,
BV: tl.constexpr,
USE_G: tl.constexpr,
USE_GK: tl.constexpr,
USE_INITIAL_STATE: tl.constexpr,
INPLACE_UPDATE: tl.constexpr,
SAVE_NEW_VALUE: tl.constexpr,
IS_VARLEN: tl.constexpr,
NT_BUCKET: tl.constexpr, # this arg is kept to align with the triton kernel for CUDA
):
i_v, i_nh = tl.program_id(0), tl.program_id(1)
i_n, i_h = i_nh // H, i_nh % H
if IS_VARLEN:
bos, eos = tl.load(cu_seqlens + i_n).to(tl.int32), tl.load(
cu_seqlens + i_n + 1
).to(tl.int32)
T = eos - bos
NT = tl.cdiv(T, BT)
boh = tl.load(chunk_offsets + i_n).to(tl.int32)
else:
bos, eos = i_n * T, i_n * T + T
NT = tl.cdiv(T, BT)
boh = i_n * NT
# calculate offset
h += ((boh * H + i_h) * V * K).to(tl.int64)
v += ((bos * H + i_h) * V).to(tl.int64)
k += ((bos * Hg + i_h // (H // Hg)) * K).to(tl.int64)
w += ((bos * H + i_h) * K).to(tl.int64)
if SAVE_NEW_VALUE:
v_new += ((bos * H + i_h) * V).to(tl.int64)
stride_v = H * V
stride_h = H * V * K
stride_k = Hg * K
stride_w = H * K
w_desc = make_tensor_descriptor(
base=w,
shape=(T, K),
strides=(stride_w, 1),
block_shape=(BT, 64),
)
v_desc = make_tensor_descriptor(
base=v,
shape=(T, V),
strides=(stride_v, 1),
block_shape=(BT, BV),
)
k_desc = make_tensor_descriptor(
base=k,
shape=(T, K),
strides=(stride_k, 1),
block_shape=(BT, 64),
)
if SAVE_NEW_VALUE:
v_new_desc = make_tensor_descriptor(
base=v_new,
shape=(T, V),
strides=(stride_v, 1),
block_shape=(BT, BV),
)
index = tl.load(initial_state_indices + i_n).to(tl.int32)
h0 = initial_state + index * stride_h
ht = initial_state + index * stride_h
if USE_INITIAL_STATE:
h0 = h0 + i_h * V * K
if INPLACE_UPDATE:
ht = ht + i_h * V * K
# main recurrence — time is the outer loop
for i_t in range(NT):
########################################################################
# Phase 1: store h to output, compute v_new = u - sum_k(w_k @ h_k^T)
########################################################################
b_v_corr = tl.zeros([BT, BV], dtype=tl.float32)
for k_blk in range(0, K, 64):
# Load h: from initial_state (i_t==0) or scratch (i_t>0)
if i_t == 0:
if USE_INITIAL_STATE:
p_hs = tl.make_block_ptr(
h0, (V, K), (K, 1), (i_v * BV, k_blk), (BV, 64), (1, 0)
)
b_h = tl.load(p_hs, boundary_check=(0, 1)).to(tl.float32)
else:
b_h = tl.zeros([BV, 64], dtype=tl.float32)
else:
p_hs = tl.make_block_ptr(
ht, (V, K), (K, 1), (i_v * BV, k_blk), (BV, 64), (1, 0)
)
b_h = tl.load(p_hs, boundary_check=(0, 1)).to(tl.float32)
# Store pre-update h to output
p_ho = tl.make_block_ptr(
h + i_t * stride_h,
(V, K),
(K, 1),
(i_v * BV, k_blk),
(BV, 64),
(1, 0),
)
tl.store(p_ho, b_h.to(p_ho.dtype.element_ty), boundary_check=(0, 1))
# Accumulate correction: w_k @ h_k^T
b_w = w_desc.load([i_t * BT, k_blk])
b_v_corr += tl.dot(b_w, tl.trans(b_h).to(b_w.dtype))
# v_new = u - correction
b_v = v_desc.load([i_t * BT, i_v * BV]) - b_v_corr
if SAVE_NEW_VALUE:
v_new_desc.store([i_t * BT, i_v * BV], b_v.to(v_new.dtype.element_ty))
# Apply gate to v
last_idx = min((i_t + 1) * BT, T) - 1
if USE_G:
b_g_last = tl.load(g + bos * H + last_idx * H + i_h)
p_g = tl.make_block_ptr(
g + bos * H + i_h, (T,), (H,), (i_t * BT,), (BT,), (0,)
)
b_g = tl.load(p_g, boundary_check=(0,))
b_v = b_v * tl.expand_dims(safe_exp(b_g_last - b_g), 1)
b_g_last = exp(b_g_last)
b_v = b_v.to(k.dtype.element_ty)
########################################################################
# Phase 2: reload h, apply gate, update h += k^T @ v, save to scratch
########################################################################
for k_blk in range(0, K, 64):
# Reload h (same source as Phase 1)
if i_t == 0:
if USE_INITIAL_STATE:
p_hs = tl.make_block_ptr(
h0, (V, K), (K, 1), (i_v * BV, k_blk), (BV, 64), (1, 0)
)
b_h = tl.load(p_hs, boundary_check=(0, 1)).to(tl.float32)
else:
b_h = tl.zeros([BV, 64], dtype=tl.float32)
else:
p_hs = tl.make_block_ptr(
ht, (V, K), (K, 1), (i_v * BV, k_blk), (BV, 64), (1, 0)
)
b_h = tl.load(p_hs, boundary_check=(0, 1)).to(tl.float32)
# Gate decay on h
if USE_G:
b_h = b_h * b_g_last
if USE_GK:
o_k1 = tl.arange(0, 64) + k_blk
b_gk_last1 = tl.load(
gk + (bos + last_idx) * H * K + i_h * K + o_k1,
mask=(o_k1 < K),
other=0.0,
)
b_h *= tl.expand_dims(exp(b_gk_last1), 0)
# Delta update: h += k^T @ v
b_k = tl.trans(k_desc.load([i_t * BT, k_blk]))
b_h += tl.trans(tl.dot(b_k, b_v))
# Save updated h to scratch (initial_state) for next time step
if INPLACE_UPDATE:
p_hs = tl.make_block_ptr(
ht, (V, K), (K, 1), (i_v * BV, k_blk), (BV, 64), (1, 0)
)
tl.store(p_hs, b_h.to(p_hs.dtype.element_ty), boundary_check=(0, 1))
def chunk_gated_delta_rule_fwd_h(
k: torch.Tensor,
w: torch.Tensor,
u: torch.Tensor,
g: Optional[torch.Tensor] = None,
gk: Optional[torch.Tensor] = None,
initial_state: Optional[torch.Tensor] = None,
initial_state_indices: Optional[torch.Tensor] = None,
save_new_value: bool = True,
cu_seqlens: Optional[torch.LongTensor] = None,
chunk_indices: Optional[torch.LongTensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
B, T, Hg, K, V = *k.shape, u.shape[-1]
H = u.shape[-2]
BT = CHUNK_SIZE
if chunk_indices is None and cu_seqlens is not None:
chunk_indices = prepare_chunk_indices(cu_seqlens, CHUNK_SIZE)
# N: the actual number of sequences in the batch with either equal or variable lengths
if cu_seqlens is None:
N, NT, chunk_offsets = B, triton.cdiv(T, BT), None
else:
N, NT, chunk_offsets = (
len(cu_seqlens) - 1,
len(chunk_indices),
prepare_chunk_offsets(cu_seqlens, BT),
)
assert K <= 256, "current kernel does not support head dimension larger than 256."
h = k.new_empty(B, NT, H, V, K)
v_new = torch.empty_like(u) if save_new_value else None
def grid(meta):
return (triton.cdiv(V, meta["BV"]), N * H)
kernel = chunk_gated_delta_rule_fwd_kernel_h_blockdim64_k_loop
kernel[grid](
k=k,
v=u,
w=w,
v_new=v_new,
g=g,
gk=gk,
h=h,
initial_state=initial_state,
initial_state_indices=initial_state_indices,
cu_seqlens=cu_seqlens,
chunk_offsets=chunk_offsets,
T=T,
H=H,
Hg=Hg,
K=K,
V=V,
BT=BT,
USE_G=g is not None,
USE_GK=gk is not None,
USE_INITIAL_STATE=initial_state is not None,
INPLACE_UPDATE=True,
SAVE_NEW_VALUE=v_new is not None,
IS_VARLEN=cu_seqlens is not None,
NT_BUCKET=(0 if NT <= 32 else (1 if NT <= 128 else 2)),
)
return h, v_new
@@ -0,0 +1,325 @@
import torch
import triton
import triton.language as tl
from sglang.srt.layers.attention.fla.index import prepare_chunk_indices
from sglang.srt.layers.attention.fla.op import safe_exp
from sglang.srt.layers.attention.fla.utils import (
autotune_cache_kwargs,
)
from sglang.srt.layers.attention.fla.wy_fast import recompute_w_u_fwd
_MERGE_DOT_PRECISION = tl.constexpr("ieee")
@triton.heuristics(
{
"USE_G": lambda args: args["g"] is not None,
"IS_VARLEN": lambda args: args["cu_seqlens"] is not None,
}
)
@triton.autotune(
configs=[
triton.Config({"BK": BK}, num_warps=num_warps)
for BK in [16, 32, 64]
for num_warps in [2, 4, 8, 16, 32]
],
key=["H", "Hg", "K", "BC", "BK", "USE_G", "IS_VARLEN"],
**autotune_cache_kwargs,
)
@triton.jit(do_not_specialize=["T"])
def chunk_gated_delta_rule_fwd_kkt_solve_kernel_low_reg(
k,
g,
beta,
A,
cu_seqlens,
chunk_indices,
T,
H: tl.constexpr,
Hg: tl.constexpr,
K: tl.constexpr,
BT: tl.constexpr,
BC: tl.constexpr,
BK: tl.constexpr,
USE_G: tl.constexpr,
IS_VARLEN: tl.constexpr,
):
"""
Low-reg version: one [BC,BC] accumulator at a time to minimise register pressure.
Pass 1: loop over 4 diagonal blocks (tl.static_range unrolls to 4 K-loops).
Pass 2: nested loop over off-diagonal distance d=1,2,3 and column j.
d=1 (nearest): Ai_{ij} = -Ai_ii @ A_ij_raw @ Ai_jj
d>1 (farther): Ai_{ij} = -(Ai_ii @ A_ij_raw
+ sum_{m=j+1}^{i-1} Ai_im @ A_mj_raw) @ Ai_jj
Each K-loop holds exactly one [BC,BC] accumulator. Raw blocks needed by
later correction terms are spilled to upper-triangular scratch slots in A
(see _KKT_SCRATCH_COL for the layout; boundary_check makes out-of-bounds
stores/loads safe so no runtime `if i_tcX < T` guards are needed).
"""
i_t, i_bh = tl.program_id(0), tl.program_id(1)
i_b, i_h = i_bh // H, i_bh % H
if IS_VARLEN:
i_n, i_t = tl.load(chunk_indices + i_t * 2).to(tl.int32), tl.load(
chunk_indices + i_t * 2 + 1
).to(tl.int32)
bos, eos = tl.load(cu_seqlens + i_n).to(tl.int32), tl.load(
cu_seqlens + i_n + 1
).to(tl.int32)
T = eos - bos
else:
bos, eos = i_b * T, i_b * T + T
if i_t * BT >= T:
return
i_tc0 = i_t * BT
k += (bos * Hg + i_h // (H // Hg)) * K
A += (bos * H + i_h) * BT
o_i = tl.arange(0, BC)
m_d = o_i[:, None] > o_i[None, :]
m_I = o_i[:, None] == o_i[None, :]
############################################################################
# Pass 1: diagonal blocks — one K-loop per sub-chunk (tl.static_range → 4)
############################################################################
for i_b in tl.static_range(4):
i_tci = i_tc0 + i_b * BC
m_tci = (i_tci + o_i) < T
p_bi = tl.make_block_ptr(
beta + bos * H + i_h, (T,), (H,), (i_tci,), (BC,), (0,)
)
b_bi = tl.load(p_bi, boundary_check=(0,)).to(tl.float32)
if USE_G:
p_gi = tl.make_block_ptr(
g + bos * H + i_h, (T,), (H,), (i_tci,), (BC,), (0,)
)
b_gi = tl.load(p_gi, boundary_check=(0,)).to(tl.float32)
b_A = tl.zeros([BC, BC], dtype=tl.float32)
for i_k in range(tl.cdiv(K, BK)):
p_k = tl.make_block_ptr(
k, (T, K), (Hg * K, 1), (i_tci, i_k * BK), (BC, BK), (1, 0)
)
b_k = tl.load(p_k, boundary_check=(0, 1))
b_A += tl.dot(b_k, tl.trans(b_k))
if USE_G:
b_A *= safe_exp(b_gi[:, None] - b_gi[None, :])
b_A = (
tl.where(m_d & (m_tci[:, None] & m_tci[None, :]), b_A, 0.0) * b_bi[:, None]
)
# Forward substitution: solve (I + A_diag) x = I column by column.
# Extra iterations for out-of-bounds rows are no-ops (b_A rows are zero).
b_Ai = -b_A
for i in range(2, BC):
b_a = tl.sum(tl.where((o_i == i)[:, None], -b_A, 0.0), 0)
b_a = tl.where(o_i < i, b_a, 0.0)
b_a = b_a + tl.sum(b_a[:, None] * b_Ai, 0)
b_Ai = tl.where((o_i == i)[:, None], b_a, b_Ai)
b_Ai += m_I
p_Aii = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tci, i_b * BC), (BC, BC), (1, 0)
)
tl.store(p_Aii, b_Ai.to(A.dtype.element_ty), boundary_check=(0, 1))
############################################################################
# Pass 2: off-diagonal blocks — one K-loop per (i,j) pair.
# Outer loop: d = i-j (diagonal distance) from 1 to 3.
# Inner loop: j from 0 to 3-d (so i = j+d).
# Processing order ensures Ai_im (m in j+1..i-1) is already in A when needed.
# Formula: Ai_ij = -(Ai_ii @ A_ij_raw
# + sum_{m=j+1}^{i-1} Ai_im @ A_mj_raw) @ Ai_jj
############################################################################
for d in tl.static_range(1, 4):
for j in tl.static_range(0, 4 - d):
i = j + d # compile-time ints from static_range
i_tci = i_tc0 + i * BC
i_tcj = i_tc0 + j * BC
p_bi = tl.make_block_ptr(
beta + bos * H + i_h, (T,), (H,), (i_tci,), (BC,), (0,)
)
b_bi = tl.load(p_bi, boundary_check=(0,)).to(tl.float32)
if USE_G:
p_gi = tl.make_block_ptr(
g + bos * H + i_h, (T,), (H,), (i_tci,), (BC,), (0,)
)
p_gj = tl.make_block_ptr(
g + bos * H + i_h, (T,), (H,), (i_tcj,), (BC,), (0,)
)
b_gi = tl.load(p_gi, boundary_check=(0,)).to(tl.float32)
b_gj = tl.load(p_gj, boundary_check=(0,)).to(tl.float32)
# K-loop: accumulate k_i @ k_j^T
b_A = tl.zeros([BC, BC], dtype=tl.float32)
for i_k in range(tl.cdiv(K, BK)):
p_ki = tl.make_block_ptr(
k, (T, K), (Hg * K, 1), (i_tci, i_k * BK), (BC, BK), (1, 0)
)
p_kj = tl.make_block_ptr(
k, (T, K), (Hg * K, 1), (i_tcj, i_k * BK), (BC, BK), (1, 0)
)
b_A += tl.dot(
tl.load(p_ki, boundary_check=(0, 1)),
tl.trans(tl.load(p_kj, boundary_check=(0, 1))),
)
if USE_G:
b_A *= safe_exp(b_gi[:, None] - b_gj[None, :])
b_A *= b_bi[:, None]
# Scratch this raw block if a later row in this column needs it as
# a correction term. i < 3 means rows i+1..3 exist and will use it.
# Scratch column layout (compile-time ternary, evaluated at trace time):
# (i=1,j=0)->col1 (i=2,j=1)->col2 (i=2,j=0)->col3
if i < 3:
sc = 1 if (i == 1 and j == 0) else (2 if (i == 2 and j == 1) else 3)
p_s = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tc0, sc * BC), (BC, BC), (1, 0)
)
tl.store(p_s, b_A.to(A.dtype.element_ty), boundary_check=(0, 1))
# Correction sum: sum_{m=j+1}^{i-1} Ai_im @ A_mj_raw
# Unrolled manually (d is a compile-time Python int from tl.static_range):
# d=1: no corrections; d=2: m=j+1; d=3: m=j+1 then m=j+2
b_corr = tl.zeros([BC, BC], dtype=tl.float32)
if d >= 2:
m1 = j + 1
sc_m1j = (
1 if (m1 == 1 and j == 0) else (2 if (m1 == 2 and j == 1) else 3)
)
p_s_m1j = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tc0, sc_m1j * BC), (BC, BC), (1, 0)
)
b_A_m1j = tl.load(p_s_m1j, boundary_check=(0, 1)).to(tl.float32)
p_Ai_im1 = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tci, m1 * BC), (BC, BC), (1, 0)
)
b_corr += tl.dot(
tl.load(p_Ai_im1, boundary_check=(0, 1)).to(tl.float32),
b_A_m1j,
input_precision=_MERGE_DOT_PRECISION,
)
if d >= 3:
m2 = j + 2
sc_m2j = (
1 if (m2 == 1 and j == 0) else (2 if (m2 == 2 and j == 1) else 3)
)
p_s_m2j = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tc0, sc_m2j * BC), (BC, BC), (1, 0)
)
b_A_m2j = tl.load(p_s_m2j, boundary_check=(0, 1)).to(tl.float32)
p_Ai_im2 = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tci, m2 * BC), (BC, BC), (1, 0)
)
b_corr += tl.dot(
tl.load(p_Ai_im2, boundary_check=(0, 1)).to(tl.float32),
b_A_m2j,
input_precision=_MERGE_DOT_PRECISION,
)
# Compute and store Ai_ij
p_Ai_ii = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tci, i * BC), (BC, BC), (1, 0)
)
p_Ai_jj = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tcj, j * BC), (BC, BC), (1, 0)
)
b_Ai_ii = tl.load(p_Ai_ii, boundary_check=(0, 1)).to(tl.float32)
b_Ai_jj = tl.load(p_Ai_jj, boundary_check=(0, 1)).to(tl.float32)
b_Ai_ij = -tl.dot(
tl.dot(b_Ai_ii, b_A, input_precision=_MERGE_DOT_PRECISION) + b_corr,
b_Ai_jj,
input_precision=_MERGE_DOT_PRECISION,
)
p_Ai_ij = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tci, j * BC), (BC, BC), (1, 0)
)
tl.store(p_Ai_ij, b_Ai_ij.to(A.dtype.element_ty), boundary_check=(0, 1))
# Clean up scratch slots: Pass 2 stored raw A_ij blocks in the upper-triangular
# part of row i_tc0 (cols BC..3*BC). These must be zeroed because
# recompute_w_u_fwd reads the full BT×BT block.
b_zero = tl.zeros([BC, BC], dtype=tl.float32)
for sc in tl.static_range(1, BT // BC):
p_scratch = tl.make_block_ptr(
A, (T, BT), (H * BT, 1), (i_tc0, sc * BC), (BC, BC), (1, 0)
)
tl.store(p_scratch, b_zero.to(A.dtype.element_ty), boundary_check=(0, 1))
def chunk_gated_delta_rule_fwd_intra(
k: torch.Tensor,
v: torch.Tensor,
g: torch.Tensor | None = None,
beta: torch.Tensor | None = None,
cu_seqlens: torch.LongTensor | None = None,
chunk_size: int = 64,
chunk_indices: torch.LongTensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
r"""
GDN intra-chunk forward: fused kkt + solve_tril + recompute_w_u.
Equivalent to:
A = chunk_scaled_dot_kkt_fwd(k, g, beta, ...) # kernel 1
A = solve_tril(A, ...) # kernel 2
w, u = recompute_w_u_fwd(k, v, beta, A, g, ...) # kernel 3
Fuses kernels 1+2 into a single kernel, reducing from 3 to 2 kernel launches
and eliminating the HBM round-trip for the intermediate A matrix.
Args:
k (torch.Tensor):
The key tensor of shape `[B, T, H, K]`.
v (torch.Tensor):
The value tensor of shape `[B, T, H, V]`.
g (torch.Tensor):
The cumulative sum of the gate tensor of shape `[B, T, H]`. Default: `None`.
beta (torch.Tensor):
The beta tensor of shape `[B, T, H]`.
cu_seqlens (torch.LongTensor):
The cumulative sequence lengths. Default: `None`.
chunk_size (int):
The chunk size. Default: 64.
chunk_indices (torch.LongTensor):
Precomputed chunk indices. Default: `None`.
Returns:
w (torch.Tensor): shape `[B, T, H, K]`
u (torch.Tensor): shape `[B, T, H, V]`
A (torch.Tensor): shape `[B, T, H, BT]`, the solved (I+A)^{-1} matrix
"""
B, T, Hg, K = k.shape
H = beta.shape[-1]
BT = chunk_size
BC = 16
if chunk_indices is None and cu_seqlens is not None:
chunk_indices = prepare_chunk_indices(cu_seqlens, BT)
NT = triton.cdiv(T, BT) if cu_seqlens is None else len(chunk_indices)
# Step 1: fused kkt + solve_tril
A = torch.zeros(B, T, H, BT, device=k.device, dtype=k.dtype)
kernel = chunk_gated_delta_rule_fwd_kkt_solve_kernel_low_reg
kernel[(NT, B * H)](
k=k,
g=g,
beta=beta,
A=A,
cu_seqlens=cu_seqlens,
chunk_indices=chunk_indices,
T=T,
H=H,
Hg=Hg,
K=K,
BT=BT,
BC=BC,
)
# Step 2: recompute_w_u
w, u = recompute_w_u_fwd(
k=k,
v=v,
beta=beta,
A=A,
g_cumsum=g,
cu_seqlens=cu_seqlens,
chunk_indices=chunk_indices,
)
return w, u, A
@@ -0,0 +1,128 @@
from typing import Optional
import torch
import triton
from sglang.srt.layers.attention.fla.fused_sigmoid_gating_recurrent import (
fused_sigmoid_gating_delta_rule_update_kernel,
)
def fused_sigmoid_gating_delta_rule_update(
A_log: torch.Tensor,
a: torch.Tensor,
dt_bias: torch.Tensor,
softplus_beta: float,
softplus_threshold: float,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
b: torch.Tensor,
initial_state_source: torch.Tensor,
initial_state_indices: torch.Tensor,
scale: Optional[float] = None,
use_qk_l2norm_in_kernel: bool = False,
cu_seqlens: Optional[torch.Tensor] = None,
is_kda: bool = False,
# Optional parameters for target_verify support
disable_state_update: bool = False,
intermediate_states_buffer: Optional[torch.Tensor] = None,
intermediate_state_indices: Optional[torch.Tensor] = None,
cache_steps: Optional[int] = None,
retrieve_parent_token: Optional[torch.Tensor] = None,
):
"""
Fused triton implementation of sigmoid gating delta rule update.
This function uses a single fused kernel that combines both sigmoid gating computation
and the recurrent delta rule update for better performance.
Supports both decode and target_verify modes:
- decode: standard single-step update with state write-back
- target_verify: multi-step with intermediate state caching, optional tree attention,
and optional state update disable
"""
B, T, H, K, V = *k.shape, v.shape[-1]
stride_q = q.stride()[1]
stride_k = k.stride()[1]
stride_v = v.stride()[1]
stride_b = b.stride()[-2]
# Both paths (KDA/GDN) advance p_a once per token, so use the token-axis stride.
# For 2D a ([T, ...]) this is stride(0); for 3D a ([B, T, ...]) this is stride(1).
# Using stride()[-2] covers GDN [T, HV] and KDA layouts ([T, HV*K] / [B, T, HV*K]).
stride_a = a.stride()[-2]
HV = v.shape[2]
N = B if cu_seqlens is None else len(cu_seqlens) - 1
BK, BV = triton.next_power_of_2(K), min(
triton.next_power_of_2(V), 16
) # use 16 here to reduce register pressure
NK, NV = triton.cdiv(K, BK), triton.cdiv(V, BV)
assert NK == 1, "NK > 1 is not supported yet"
num_stages = 3
num_warps = 1
if scale is None:
scale = k.shape[-1] ** -0.5
else:
assert scale > 0, "scale must be positive"
o = q.new_empty(NK, *v.shape)
# Prepare retrieve_parent_token strides
if retrieve_parent_token is not None:
stride_retrieve_parent_token_seq = retrieve_parent_token.stride(0)
stride_retrieve_parent_token_token = retrieve_parent_token.stride(1)
else:
stride_retrieve_parent_token_seq = 0
stride_retrieve_parent_token_token = 0
NP2_T = triton.next_power_of_2(T)
grid = (NK, NV, N * HV)
fused_sigmoid_gating_delta_rule_update_kernel[grid](
A_log=A_log,
a=a,
dt_bias=dt_bias,
softplus_beta=softplus_beta,
softplus_threshold=softplus_threshold,
q=q,
k=k,
v=v,
b=b,
o=o,
h0_source=initial_state_source,
h0_indices=initial_state_indices,
cu_seqlens=cu_seqlens,
intermediate_states_buffer=intermediate_states_buffer,
intermediate_state_indices=intermediate_state_indices,
cache_steps=0 if cache_steps is None else cache_steps,
retrieve_parent_token_ptr=retrieve_parent_token,
stride_retrieve_parent_token_seq=stride_retrieve_parent_token_seq,
stride_retrieve_parent_token_token=stride_retrieve_parent_token_token,
scale=scale,
T=T,
stride_a=stride_a,
stride_q=stride_q,
stride_k=stride_k,
stride_v=stride_v,
stride_b=stride_b,
NP2_T=NP2_T,
B=B,
H=H,
HV=HV,
K=K,
V=V,
BK=BK,
BV=BV,
USE_INITIAL_STATE=initial_state_source is not None,
USE_QK_L2NORM_IN_KERNEL=use_qk_l2norm_in_kernel,
IS_VARLEN=cu_seqlens is not None,
IS_KDA=is_kda,
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,
num_warps=num_warps,
num_stages=num_stages,
)
o = o.squeeze(0)
return o