[Model] Support Ling-3.0-flash (BailingMoeV3) (#33561)
Signed-off-by: JustinTong <justintong0323@gmail.com> Signed-off-by: Xinyuan Tong <xinyuantong.cs@gmail.com> Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com> Co-authored-by: 得泽 <zhangkaihong.zkh@antgroup.com> Co-authored-by: 翎悦 <vito.yy@antgroup.com> Co-authored-by: 羽癫 <yudian.zy@antgroup.com> Co-authored-by: tiwei.btw <tiwei.btw@antgroup.com> Co-authored-by: Liangsheng Yin <hnyls2002@gmail.com> Co-authored-by: 文赋 <zibin.zb@antgroup.com> Co-authored-by: JustinTong <justintong0323@gmail.com>
This commit is contained in:
co-authored by
luoyuan.luo
得泽
翎悦
羽癫
tiwei.btw
Liangsheng Yin
文赋
JustinTong
parent
8739d56a31
commit
20621aa14b
@@ -409,7 +409,18 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _run_baseline(
|
||||
mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices, H, HV, K, V
|
||||
mixed_qkv,
|
||||
a,
|
||||
b,
|
||||
A_log,
|
||||
dt_bias,
|
||||
ssm_states,
|
||||
cache_indices,
|
||||
H,
|
||||
HV,
|
||||
K,
|
||||
V,
|
||||
lower_bound=None,
|
||||
):
|
||||
B = mixed_qkv.shape[0]
|
||||
q_flat, k_flat, v_flat = torch.split(mixed_qkv, [H * K, H * K, HV * V], dim=-1)
|
||||
@@ -436,11 +447,22 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
scale=K**-0.5,
|
||||
use_qk_l2norm_in_kernel=True,
|
||||
is_kda=True,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _run_packed(
|
||||
mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices, HV, K, V
|
||||
mixed_qkv,
|
||||
a,
|
||||
b,
|
||||
A_log,
|
||||
dt_bias,
|
||||
ssm_states,
|
||||
cache_indices,
|
||||
HV,
|
||||
K,
|
||||
V,
|
||||
lower_bound=None,
|
||||
):
|
||||
B = mixed_qkv.shape[0]
|
||||
out = mixed_qkv.new_empty(B, 1, HV, V)
|
||||
@@ -455,10 +477,11 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
out=out,
|
||||
ssm_state_indices=cache_indices,
|
||||
use_qk_l2norm_in_kernel=True,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
return out.transpose(0, 1)
|
||||
|
||||
def _check(self, B, H, HV, K, V):
|
||||
def _check(self, B, H, HV, K, V, lower_bound=None):
|
||||
device = get_device()
|
||||
dtype = torch.bfloat16
|
||||
pool_size = B + 4
|
||||
@@ -469,10 +492,31 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
s_baseline = ssm_states.clone()
|
||||
|
||||
o_packed = self._run_packed(
|
||||
mixed_qkv, a, b, A_log, dt_bias, s_packed, cache_indices, HV, K, V
|
||||
mixed_qkv,
|
||||
a,
|
||||
b,
|
||||
A_log,
|
||||
dt_bias,
|
||||
s_packed,
|
||||
cache_indices,
|
||||
HV,
|
||||
K,
|
||||
V,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
o_baseline = self._run_baseline(
|
||||
mixed_qkv, a, b, A_log, dt_bias, s_baseline, cache_indices, H, HV, K, V
|
||||
mixed_qkv,
|
||||
a,
|
||||
b,
|
||||
A_log,
|
||||
dt_bias,
|
||||
s_baseline,
|
||||
cache_indices,
|
||||
H,
|
||||
HV,
|
||||
K,
|
||||
V,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(
|
||||
@@ -501,6 +545,9 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
# Common KDA config with HV > H (grouped query).
|
||||
self._check(B=8, H=8, HV=16, K=128, V=128)
|
||||
|
||||
def test_safe_gate_lower_bound(self):
|
||||
self._check(B=8, H=16, HV=16, K=128, V=128, lower_bound=-5.0)
|
||||
|
||||
def test_pad_slot(self):
|
||||
"""Entries with state_idx == -1 must produce zero output and skip state writeback."""
|
||||
device = get_device()
|
||||
@@ -544,6 +591,7 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
device = get_device()
|
||||
dtype = torch.bfloat16
|
||||
B, H, HV, K, V = 4, 16, 16, 128, 128
|
||||
lower_bound = -5.0
|
||||
pool_size = B + 4
|
||||
mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices = self._make_inputs(
|
||||
B, H, HV, K, V, pool_size, dtype, device
|
||||
@@ -568,11 +616,23 @@ class TestKDAPackedDecode(unittest.TestCase):
|
||||
cache_indices=cache_indices,
|
||||
num_v_heads=HV,
|
||||
head_v_dim=V,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
|
||||
s_baseline = ssm_states.clone()
|
||||
o_baseline = self._run_baseline(
|
||||
mixed_qkv, a, b, A_log, dt_bias, s_baseline, cache_indices, H, HV, K, V
|
||||
mixed_qkv,
|
||||
a,
|
||||
b,
|
||||
A_log,
|
||||
dt_bias,
|
||||
s_baseline,
|
||||
cache_indices,
|
||||
H,
|
||||
HV,
|
||||
K,
|
||||
V,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
|
||||
# Dispatcher returns [1, B, HV, V], same layout as the baseline.
|
||||
|
||||
@@ -80,7 +80,7 @@ def _chunk_kda_ref(d, lower_bound):
|
||||
"""Triton chunk_kda reference. chunk_kda mutates g/v and the state in place,
|
||||
so feed clones; returns (output, updated_state_slots)."""
|
||||
st = d["pool"].clone()
|
||||
out = chunk_kda(
|
||||
out, _ = chunk_kda(
|
||||
q=d["q"].clone(),
|
||||
k=d["k"].clone(),
|
||||
v=d["v"].clone(),
|
||||
@@ -105,7 +105,7 @@ def test_flashkda_matches_triton_safe_gate(seq_lens):
|
||||
ref_out, ref_state = _chunk_kda_ref(d, LOWER_BOUND)
|
||||
|
||||
st_fk = d["pool"].clone()
|
||||
out = FlashKDAKernel().extend(
|
||||
out, h = FlashKDAKernel().extend(
|
||||
d["q"].clone(),
|
||||
d["k"].clone(),
|
||||
d["v"].clone(),
|
||||
@@ -121,6 +121,7 @@ def test_flashkda_matches_triton_safe_gate(seq_lens):
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert h is None
|
||||
assert torch.isfinite(out).all(), "FlashKDA output has non-finite values"
|
||||
assert torch.isfinite(st_fk).all(), "FlashKDA final state has non-finite values"
|
||||
# bf16 cross-implementation noise (chunk=16 CUTLASS vs chunk=64 Triton);
|
||||
@@ -140,7 +141,7 @@ def test_flashkda_falls_back_without_lower_bound():
|
||||
ref_out, _ = _chunk_kda_ref(d, None)
|
||||
|
||||
st_fk = d["pool"].clone()
|
||||
out = FlashKDAKernel().extend(
|
||||
out, _ = FlashKDAKernel().extend(
|
||||
d["q"].clone(),
|
||||
d["k"].clone(),
|
||||
d["v"].clone(),
|
||||
@@ -170,7 +171,7 @@ def test_flashkda_spec_verify_falls_back():
|
||||
ref_out, _ = _chunk_kda_ref(d, LOWER_BOUND)
|
||||
|
||||
st_fk = d["pool"].clone()
|
||||
out = FlashKDAKernel().extend(
|
||||
out, _ = FlashKDAKernel().extend(
|
||||
d["q"].clone(),
|
||||
d["k"].clone(),
|
||||
d["v"].clone(),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.kernels.ops.attention.fla.fused_kda_conv_recurrent_verify import (
|
||||
fused_kda_conv_gating_verify,
|
||||
)
|
||||
from sglang.kernels.ops.attention.fla.fused_sigmoid_gating_recurrent import (
|
||||
fused_sigmoid_gating_delta_rule_update,
|
||||
)
|
||||
from sglang.kernels.ops.mamba.causal_conv1d_triton import (
|
||||
causal_conv1d_update,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=60, stage="base-b", runner_config="1-gpu-large")
|
||||
|
||||
_DEVICE = "cuda"
|
||||
|
||||
_CASES = [
|
||||
(1, 4, 4, 4, 128, 128, 4, False, None, False, 1),
|
||||
(1, 4, 4, 4, 128, 128, 4, True, None, False, 2),
|
||||
(1, 4, 4, 4, 128, 128, 4, True, 2.0, False, 3),
|
||||
(3, 4, 4, 4, 128, 128, 4, True, None, False, 4),
|
||||
(3, 4, 4, 4, 128, 128, 4, True, None, True, 5),
|
||||
(2, 3, 4, 4, 128, 128, 4, True, None, False, 6),
|
||||
(2, 8, 2, 2, 128, 128, 4, True, 1.5, False, 7),
|
||||
(1, 4, 8, 8, 64, 64, 4, True, None, False, 8),
|
||||
]
|
||||
|
||||
|
||||
def _make_inputs(B, T, H, HV, K, V, W, has_bias, neg_slot, seed):
|
||||
torch.manual_seed(seed)
|
||||
dim = 2 * H * K + HV * V
|
||||
seq_len = B * T
|
||||
lines = slots = 8
|
||||
|
||||
inputs = {
|
||||
"mixed": torch.randn(seq_len, dim, device=_DEVICE, dtype=torch.bfloat16) * 0.5,
|
||||
"w": torch.randn(dim, W, device=_DEVICE, dtype=torch.bfloat16) * 0.3,
|
||||
"bias": (
|
||||
torch.randn(dim, device=_DEVICE, dtype=torch.bfloat16) * 0.1
|
||||
if has_bias
|
||||
else None
|
||||
),
|
||||
"a": torch.randn(seq_len, HV * K, device=_DEVICE, dtype=torch.bfloat16) * 0.5,
|
||||
"b": torch.randn(seq_len, HV, device=_DEVICE, dtype=torch.bfloat16),
|
||||
"A_log": torch.randn(HV, device=_DEVICE, dtype=torch.float32) * 0.5,
|
||||
"dt_bias": torch.randn(HV * K, device=_DEVICE, dtype=torch.float32) * 0.5,
|
||||
# Pool layouts mirroring MambaPool: conv [lines, state_len, dim] (then
|
||||
# transposed), ssm [slots, HV, V, K] fp32, window [lines, T, W-1, dim],
|
||||
# intermediate ssm cache [lines, T, HV, V, K] fp32.
|
||||
"conv_pool": torch.randn(
|
||||
lines, W - 1, dim, device=_DEVICE, dtype=torch.bfloat16
|
||||
),
|
||||
"ssm": torch.randn(slots, HV, V, K, device=_DEVICE, dtype=torch.float32) * 0.2,
|
||||
"win_pool": torch.zeros(
|
||||
lines, T, W - 1, dim, device=_DEVICE, dtype=torch.bfloat16
|
||||
),
|
||||
"inter_ssm": torch.zeros(
|
||||
lines, T, HV, V, K, device=_DEVICE, dtype=torch.float32
|
||||
),
|
||||
}
|
||||
idx_vals = list(range(2, 2 + B))
|
||||
if neg_slot and B >= 2:
|
||||
idx_vals[1] = -1
|
||||
inputs["idx_vals"] = idx_vals
|
||||
inputs["cache_indices"] = torch.tensor(idx_vals, device=_DEVICE, dtype=torch.int32)
|
||||
inputs["inter_indices"] = torch.arange(B, device=_DEVICE, dtype=torch.int32)
|
||||
return inputs
|
||||
|
||||
|
||||
def _run_reference(inp, B, T, H, HV, K, V, lower_bound):
|
||||
dim = 2 * H * K + HV * V
|
||||
seq_len = B * T
|
||||
conv = inp["conv_pool"].clone()
|
||||
ssm = inp["ssm"].clone()
|
||||
win = inp["win_pool"].clone()
|
||||
ic = inp["inter_ssm"].clone()
|
||||
|
||||
x3 = inp["mixed"].reshape(B, T, dim).transpose(1, 2)
|
||||
out3 = causal_conv1d_update(
|
||||
x3,
|
||||
conv.transpose(-1, -2),
|
||||
inp["w"],
|
||||
inp["bias"],
|
||||
activation="silu",
|
||||
conv_state_indices=inp["cache_indices"],
|
||||
intermediate_conv_window=win.transpose(-1, -2),
|
||||
intermediate_state_indices=inp["inter_indices"],
|
||||
)
|
||||
mixed_out = out3.transpose(1, 2).reshape(seq_len, dim)
|
||||
q, k, v = mixed_out.split([H * K, H * K, HV * V], dim=-1)
|
||||
q = q.unflatten(-1, (H, K)).unsqueeze(0)
|
||||
k = k.unflatten(-1, (H, K)).unsqueeze(0)
|
||||
v = v.unflatten(-1, (HV, V)).unsqueeze(0)
|
||||
cu = torch.arange(0, B + 1, device=_DEVICE, dtype=torch.int32) * T
|
||||
o = fused_sigmoid_gating_delta_rule_update(
|
||||
A_log=inp["A_log"],
|
||||
a=inp["a"],
|
||||
dt_bias=inp["dt_bias"],
|
||||
softplus_beta=1.0,
|
||||
softplus_threshold=20.0,
|
||||
q=q,
|
||||
k=k,
|
||||
v=v,
|
||||
b=inp["b"],
|
||||
initial_state_source=ssm,
|
||||
initial_state_indices=inp["cache_indices"],
|
||||
use_qk_l2norm_in_kernel=True,
|
||||
cu_seqlens=cu,
|
||||
is_kda=True,
|
||||
disable_state_update=True,
|
||||
intermediate_states_buffer=ic,
|
||||
intermediate_state_indices=inp["inter_indices"],
|
||||
cache_steps=T,
|
||||
retrieve_parent_token=None,
|
||||
lower_bound=lower_bound,
|
||||
)
|
||||
return o, conv, win, ic
|
||||
|
||||
|
||||
def _run_fused(inp, B, T, H, HV, K, V, lower_bound, num_warps):
|
||||
conv = inp["conv_pool"].clone()
|
||||
ssm = inp["ssm"].clone()
|
||||
win = inp["win_pool"].clone()
|
||||
ic = inp["inter_ssm"].clone()
|
||||
|
||||
o = fused_kda_conv_gating_verify(
|
||||
mixed_qkv=inp["mixed"],
|
||||
conv_weight=inp["w"],
|
||||
conv_bias=inp["bias"],
|
||||
conv_state=conv.transpose(-1, -2),
|
||||
conv_state_indices=inp["cache_indices"],
|
||||
intermediate_conv_window=win.transpose(-1, -2),
|
||||
intermediate_state_indices=inp["inter_indices"],
|
||||
a=inp["a"],
|
||||
b=inp["b"],
|
||||
A_log=inp["A_log"],
|
||||
dt_bias=inp["dt_bias"],
|
||||
ssm_states=ssm,
|
||||
cache_indices=inp["cache_indices"],
|
||||
intermediate_states_buffer=ic,
|
||||
scale=K**-0.5,
|
||||
T=T,
|
||||
num_q_heads=H,
|
||||
num_v_heads=HV,
|
||||
head_k_dim=K,
|
||||
head_v_dim=V,
|
||||
lower_bound=lower_bound,
|
||||
num_warps=num_warps,
|
||||
)
|
||||
return o, conv, win, ic
|
||||
|
||||
|
||||
def _compare_case(case, num_warps):
|
||||
B, T, H, HV, K, V, W, has_bias, lower_bound, neg_slot, seed = case
|
||||
inp = _make_inputs(B, T, H, HV, K, V, W, has_bias, neg_slot, seed)
|
||||
o_ref, conv_ref, win_ref, ic_ref = _run_reference(
|
||||
inp, B, T, H, HV, K, V, lower_bound
|
||||
)
|
||||
o_fus, conv_fus, win_fus, ic_fus = _run_fused(
|
||||
inp, B, T, H, HV, K, V, lower_bound, num_warps
|
||||
)
|
||||
|
||||
idx_vals = inp["idx_vals"]
|
||||
valid_rows = [i for i, slot in enumerate(idx_vals) if slot >= 0]
|
||||
touched_slots = [slot for slot in idx_vals if slot >= 0]
|
||||
|
||||
o_ref_v = o_ref.reshape(B, T, HV, V)[valid_rows]
|
||||
o_fus_v = o_fus.reshape(B, T, HV, V)[valid_rows]
|
||||
assert torch.equal(o_ref_v, o_fus_v)
|
||||
assert torch.equal(conv_ref[touched_slots], conv_fus[touched_slots])
|
||||
assert torch.equal(win_ref[valid_rows], win_fus[valid_rows])
|
||||
torch.testing.assert_close(
|
||||
ic_ref[valid_rows], ic_fus[valid_rows], atol=4e-3, rtol=0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _CASES)
|
||||
def test_matches_unfused_reference(case):
|
||||
_compare_case(case, num_warps=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__]))
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Unit tests for ModelConfig shape normalization."""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.configs.model_config import ModelConfig
|
||||
from sglang.srt.configs.model_config import (
|
||||
AttentionArch,
|
||||
ModelConfig,
|
||||
_quant_config_to_dict,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
@@ -66,6 +69,62 @@ class TestModelConfigShapes(CustomTestCase):
|
||||
self.assertEqual(model_config.swa_head_dim, 64)
|
||||
self.assertEqual(model_config.swa_v_head_dim, 48)
|
||||
|
||||
def test_ling_mla_nope_shapes(self):
|
||||
text_config = _make_text_config(
|
||||
architectures=["BailingMoeV3ForCausalLM"],
|
||||
kv_lora_rank=512,
|
||||
qk_nope_head_dim=128,
|
||||
qk_rope_head_dim=64,
|
||||
use_mla_nope=True,
|
||||
v_head_dim=128,
|
||||
)
|
||||
|
||||
model_config = self._derive_shapes(text_config)
|
||||
|
||||
self.assertEqual(model_config.attention_arch, AttentionArch.MLA)
|
||||
self.assertEqual(model_config.head_dim, 128)
|
||||
self.assertEqual(model_config.qk_rope_head_dim, 0)
|
||||
self.assertEqual(model_config.scaling, 1 / math.sqrt(128))
|
||||
|
||||
def test_ling_mla_rope_shapes(self):
|
||||
text_config = _make_text_config(
|
||||
architectures=["BailingMoeV3ForCausalLM"],
|
||||
kv_lora_rank=512,
|
||||
qk_nope_head_dim=128,
|
||||
qk_rope_head_dim=64,
|
||||
use_mla_nope=False,
|
||||
v_head_dim=128,
|
||||
)
|
||||
|
||||
model_config = self._derive_shapes(text_config)
|
||||
|
||||
self.assertEqual(model_config.attention_arch, AttentionArch.MLA)
|
||||
self.assertEqual(model_config.head_dim, 128)
|
||||
self.assertEqual(model_config.qk_rope_head_dim, 64)
|
||||
self.assertEqual(model_config.scaling, 1 / math.sqrt(192))
|
||||
|
||||
def test_sarvam_mla_shapes(self):
|
||||
text_config = _make_text_config(
|
||||
architectures=["SarvamMLAForCausalLM"],
|
||||
kv_lora_rank=512,
|
||||
qk_nope_head_dim=128,
|
||||
qk_rope_head_dim=64,
|
||||
rope_scaling=None,
|
||||
v_head_dim=128,
|
||||
)
|
||||
|
||||
model_config = self._derive_shapes(text_config)
|
||||
|
||||
self.assertEqual(model_config.attention_arch, AttentionArch.MLA)
|
||||
self.assertEqual(model_config.head_dim, 192)
|
||||
self.assertEqual(model_config.qk_rope_head_dim, 64)
|
||||
self.assertEqual(model_config.scaling, 1 / math.sqrt(192))
|
||||
|
||||
def test_quant_config_objects_are_normalized(self):
|
||||
quant_config = SimpleNamespace(to_dict=lambda: {"quant_method": "test"})
|
||||
|
||||
self.assertEqual(_quant_config_to_dict(quant_config), {"quant_method": "test"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3236,6 +3236,29 @@ class ServingChatTestCase(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(self.chat._get_reasoning_from_request(req_enabled))
|
||||
|
||||
def test_fallback_ling3_default_on(self):
|
||||
"""Ling3 public checkpoints default `thinking_option='on'` in the chat
|
||||
template when `enable_thinking` is omitted, and the template detector
|
||||
cannot infer that indirect assignment. The parser fallback must mirror
|
||||
the template default: omitted kwargs enable reasoning, only an explicit
|
||||
`enable_thinking=False` disables it. Regression: the detector shipped
|
||||
with `explicit_enable_thinking`, which left `reasoning_content` null on
|
||||
default requests while the model was in fact thinking."""
|
||||
self._setup_fallback("ling3")
|
||||
req = ChatCompletionRequest(
|
||||
model="x", messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
cases = [
|
||||
(None, True), # no chat_template_kwargs → thinking (template default)
|
||||
({}, True), # empty kwargs → thinking
|
||||
({"enable_thinking": True}, True), # explicit on
|
||||
({"enable_thinking": False}, False), # explicit off
|
||||
]
|
||||
for kwargs, expected in cases:
|
||||
with self.subTest(kwargs=kwargs):
|
||||
req.chat_template_kwargs = kwargs
|
||||
self.assertEqual(self.chat._get_reasoning_from_request(req), expected)
|
||||
|
||||
def test_fallback_no_detector_returns_false(self):
|
||||
self.chat.reasoning_parser = "qwen3"
|
||||
self.chat._reasoning_detector = None
|
||||
|
||||
@@ -28,6 +28,7 @@ from sglang.srt.function_call.inkling_detector import InklingDetector
|
||||
from sglang.srt.function_call.json_array_parser import JsonArrayParser
|
||||
from sglang.srt.function_call.kimik2_detector import KimiK2Detector
|
||||
from sglang.srt.function_call.lfm2_detector import Lfm2Detector
|
||||
from sglang.srt.function_call.ling3_detector import Ling3Detector
|
||||
from sglang.srt.function_call.llama32_detector import Llama32Detector
|
||||
from sglang.srt.function_call.mistral_detector import MistralDetector
|
||||
from sglang.srt.function_call.pythonic_detector import PythonicDetector
|
||||
@@ -2944,6 +2945,84 @@ class TestGlm4MoeDetector(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.normal_text, "")
|
||||
|
||||
def test_streaming_tool_call(self):
|
||||
chunks = [
|
||||
"<tool_call>get_weather\n",
|
||||
"<arg_key>city</arg_key>\n<arg_value>Beijing</arg_value>\n",
|
||||
"<arg_key>date</arg_key>\n<arg_value>2024-06-27</arg_value>\n",
|
||||
"</tool_call>",
|
||||
]
|
||||
tool_calls = []
|
||||
for chunk in chunks:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
for tool_call_chunk in result.calls:
|
||||
if (
|
||||
hasattr(tool_call_chunk, "tool_index")
|
||||
and tool_call_chunk.tool_index is not None
|
||||
):
|
||||
while len(tool_calls) <= tool_call_chunk.tool_index:
|
||||
tool_calls.append({"name": "", "parameters": ""})
|
||||
tc = tool_calls[tool_call_chunk.tool_index]
|
||||
if tool_call_chunk.name:
|
||||
tc["name"] = tool_call_chunk.name
|
||||
if tool_call_chunk.parameters:
|
||||
tc["parameters"] += tool_call_chunk.parameters
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
||||
self.assertEqual(
|
||||
tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}'
|
||||
)
|
||||
|
||||
def test_streaming_tool_call_without_arguments(self):
|
||||
chunks = [
|
||||
"<tool_call>get_weather\n",
|
||||
"</tool_call>",
|
||||
]
|
||||
tool_calls = []
|
||||
for chunk in chunks:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
for tool_call_chunk in result.calls:
|
||||
if (
|
||||
hasattr(tool_call_chunk, "tool_index")
|
||||
and tool_call_chunk.tool_index is not None
|
||||
):
|
||||
while len(tool_calls) <= tool_call_chunk.tool_index:
|
||||
tool_calls.append({"name": "", "parameters": ""})
|
||||
tc = tool_calls[tool_call_chunk.tool_index]
|
||||
if tool_call_chunk.name:
|
||||
tc["name"] = tool_call_chunk.name
|
||||
if tool_call_chunk.parameters:
|
||||
tc["parameters"] += tool_call_chunk.parameters
|
||||
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
||||
self.assertEqual(tool_calls[0]["parameters"], "{}")
|
||||
self.assertEqual(self.detector.streamed_args_for_tool[0], "{}")
|
||||
|
||||
def test_streaming_tool_call_without_arguments_single_chunk(self):
|
||||
"""Test no-argument tool call when name and end token arrive together."""
|
||||
chunks = ["<tool_call>get_weather\n</tool_call>"]
|
||||
tool_calls = []
|
||||
for chunk in chunks:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
for tool_call_chunk in result.calls:
|
||||
if (
|
||||
hasattr(tool_call_chunk, "tool_index")
|
||||
and tool_call_chunk.tool_index is not None
|
||||
):
|
||||
while len(tool_calls) <= tool_call_chunk.tool_index:
|
||||
tool_calls.append({"name": "", "parameters": ""})
|
||||
tc = tool_calls[tool_call_chunk.tool_index]
|
||||
if tool_call_chunk.name:
|
||||
tc["name"] = tool_call_chunk.name
|
||||
if tool_call_chunk.parameters:
|
||||
tc["parameters"] += tool_call_chunk.parameters
|
||||
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
||||
self.assertEqual(tool_calls[0]["parameters"], "{}")
|
||||
self.assertEqual(self.detector.streamed_args_for_tool[0], "{}")
|
||||
|
||||
def test_streaming_multiple_tool_calls(self):
|
||||
"""Test streaming incremental parsing of multiple tool calls."""
|
||||
chunks = [
|
||||
@@ -3602,6 +3681,119 @@ class TestGlm47MoeDetector(unittest.TestCase):
|
||||
_glm47_native_structural_tag_available.cache_clear()
|
||||
|
||||
|
||||
class TestLing3Detector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tools = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather",
|
||||
description="Get weather information",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_date",
|
||||
description="Get current date",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
),
|
||||
),
|
||||
]
|
||||
self.detector = Ling3Detector()
|
||||
|
||||
def _collect_streaming_tool_calls(self, chunks):
|
||||
tool_calls = []
|
||||
for chunk in chunks:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
for tool_call_chunk in result.calls:
|
||||
while len(tool_calls) <= tool_call_chunk.tool_index:
|
||||
tool_calls.append({"name": "", "parameters": ""})
|
||||
tc = tool_calls[tool_call_chunk.tool_index]
|
||||
if tool_call_chunk.name:
|
||||
tc["name"] = tool_call_chunk.name
|
||||
if tool_call_chunk.parameters:
|
||||
tc["parameters"] += tool_call_chunk.parameters
|
||||
return tool_calls
|
||||
|
||||
def test_detect_and_parse_newline_and_compact_tool_call(self):
|
||||
cases = {
|
||||
"newline": (
|
||||
"<tool_call>get_weather\n"
|
||||
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
||||
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>"
|
||||
"</tool_call>",
|
||||
'{"city": "Beijing", "date": "2024-06-27"}',
|
||||
),
|
||||
"compact": (
|
||||
"<tool_call>get_weather"
|
||||
"<arg_key>city</arg_key><arg_value>Shanghai</arg_value>"
|
||||
"<arg_key>date</arg_key><arg_value>2024-06-28</arg_value>"
|
||||
"</tool_call>",
|
||||
'{"city": "Shanghai", "date": "2024-06-28"}',
|
||||
),
|
||||
}
|
||||
for layout, (text, expected) in cases.items():
|
||||
with self.subTest(layout=layout):
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "get_weather")
|
||||
self.assertEqual(result.calls[0].parameters, expected)
|
||||
|
||||
def test_detect_and_parse_empty_args(self):
|
||||
result = self.detector.detect_and_parse(
|
||||
"<tool_call>get_date</tool_call>", self.tools
|
||||
)
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "get_date")
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {})
|
||||
|
||||
def test_streaming_empty_args_emits_single_empty_object(self):
|
||||
tool_calls = self._collect_streaming_tool_calls(
|
||||
["<tool_call>get_date", "</tool_call>"]
|
||||
)
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0]["name"], "get_date")
|
||||
self.assertEqual(tool_calls[0]["parameters"], "{}")
|
||||
self.assertEqual(self.detector.streamed_args_for_tool[0], "{}")
|
||||
|
||||
def test_streaming_newline_and_compact_tool_call(self):
|
||||
cases = {
|
||||
"newline": (
|
||||
[
|
||||
"<tool_call>get_weather\n",
|
||||
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>",
|
||||
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>",
|
||||
"</tool_call>",
|
||||
],
|
||||
'{"city": "Beijing", "date": "2024-06-27"}',
|
||||
),
|
||||
"compact": (
|
||||
[
|
||||
"<tool_call>get_weather",
|
||||
"<arg_key>city</arg_key><arg_value>Shanghai</arg_value>",
|
||||
"<arg_key>date</arg_key><arg_value>2024-06-28</arg_value>",
|
||||
"</tool_call>",
|
||||
],
|
||||
'{"city": "Shanghai", "date": "2024-06-28"}',
|
||||
),
|
||||
}
|
||||
for layout, (chunks, expected) in cases.items():
|
||||
with self.subTest(layout=layout):
|
||||
self.setUp()
|
||||
tool_calls = self._collect_streaming_tool_calls(chunks)
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
||||
self.assertEqual(tool_calls[0]["parameters"], expected)
|
||||
|
||||
|
||||
class TestJsonArrayParser(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create sample tools for testing
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Unit tests for fused shared-expert weight scaling on per-rank shared slots.
|
||||
"""Unit tests for fused shared-expert weight scaling.
|
||||
|
||||
These tests pin the contract of ``remap_topk_for_per_rank_shared_slots`` for
|
||||
the fused shared expert's topk weight on the two paths this fix covers:
|
||||
These tests pin the fused shared expert's topk weight contract on three paths:
|
||||
|
||||
* aiter (HIP) path: routed_scaling_factor is folded into the routed weights and
|
||||
the post-MoE multiply is skipped, so the shared weight must be 1.0
|
||||
for a net 1.0x contribution.
|
||||
* post-MoE scaling path (default): the whole MoE output is multiplied by
|
||||
routed_scaling_factor afterward, so the shared weight must be 1/rsf.
|
||||
* aiter (HIP) per-rank-slot path: routed_scaling_factor is folded into the
|
||||
routed weights and the post-MoE multiply is skipped, so the shared weight
|
||||
must be 1.0 for a net 1.0x contribution.
|
||||
* post-MoE scaling per-rank-slot path (default): the whole MoE output is
|
||||
multiplied by routed_scaling_factor afterward, so the shared weight must
|
||||
be 1/rsf.
|
||||
* standard EP path (no per-rank slots): every rank computes the fused shared
|
||||
expert and the outputs are all-reduced, so the model-supplied 1/ep_size
|
||||
factor must be applied to the shared weight.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
@@ -80,6 +83,49 @@ class TestFusedSharedExpertScaling(CustomTestCase):
|
||||
shared_weight = self._run_remap(use_aiter=False)
|
||||
self.assertAlmostEqual(shared_weight, 1.0 / self.ROUTED_SCALING_FACTOR)
|
||||
|
||||
def _run_post_process_standard_path(self, *, scaling_factor):
|
||||
topk_ids = torch.tensor([[5, 40, 100, 256]], dtype=torch.int32)
|
||||
topk_weights = torch.tensor([[1.0, 0.5, 0.25, 1.0]], dtype=torch.float32)
|
||||
topk_config = TopKConfig(
|
||||
top_k=4,
|
||||
num_fused_shared_experts=1,
|
||||
fused_shared_experts_scaling_factor=scaling_factor,
|
||||
allow_routed_experts_capture=False,
|
||||
)
|
||||
router_logits = torch.zeros((1, 256), dtype=torch.float32)
|
||||
with (
|
||||
patch.object(topk_module, "_is_cuda", False),
|
||||
patch.object(topk_module, "_is_hip", False),
|
||||
patch.object(topk_module, "_use_aiter", False),
|
||||
patch.object(
|
||||
topk_module, "has_per_rank_fused_shared_slots", return_value=False
|
||||
),
|
||||
):
|
||||
_out_ids, out_weights, _recorder_ids = topk_module._post_process_topk_ids(
|
||||
topk_ids.clone(),
|
||||
topk_weights.clone(),
|
||||
topk_config,
|
||||
router_logits,
|
||||
layer_id=0,
|
||||
)
|
||||
self.assertTrue(torch.equal(out_weights[0, :-1], topk_weights[0, :-1]))
|
||||
return out_weights[0, -1].item()
|
||||
|
||||
def test_standard_ep_path_applies_shared_scaling_factor(self):
|
||||
# Regression: models pass 1/ep_size under standard EP (every rank
|
||||
# computes the fused shared expert and outputs are all-reduced), but
|
||||
# the standard CUDA post-process dropped the factor, so the shared
|
||||
# contribution was summed ep_size times (corrupt EP4 output on
|
||||
# BailingMoeV3, BF16 and FP8 alike).
|
||||
shared_weight = self._run_post_process_standard_path(scaling_factor=0.25)
|
||||
self.assertAlmostEqual(shared_weight, 0.25)
|
||||
|
||||
def test_standard_path_without_factor_keeps_shared_weight(self):
|
||||
# TP mode passes no factor; the shared weight must pass through
|
||||
# unscaled (guards the predicate against degrading to always-scale).
|
||||
shared_weight = self._run_post_process_standard_path(scaling_factor=None)
|
||||
self.assertAlmostEqual(shared_weight, 1.0)
|
||||
|
||||
def test_shared_expert_ids_route_to_home_rank(self):
|
||||
# Sanity: the shared slot id is placed at this rank's interleaved
|
||||
# position (ep_rank * num_local_experts + num_local_routed).
|
||||
|
||||
+132
-56
@@ -1,32 +1,10 @@
|
||||
"""CPU regression test for WNA16 compressed-tensors MoE with no "Linear" group.
|
||||
|
||||
CompressedTensorsWNA16MoE used to read ``target_scheme_map["Linear"]`` in its
|
||||
constructor. That raised ``KeyError: 'Linear'`` for compressed-tensors MoE
|
||||
checkpoints whose ``config_groups`` only target the expert projections through a
|
||||
regex or per-layer FQN target and therefore have no group literally named
|
||||
"Linear" (e.g. mixed-precision INT4/INT8 MoE quant configs). ``get_moe_scheme``
|
||||
already resolves the per-layer weight scheme by matching the layer against the
|
||||
config_groups targets, so it now threads that ``weight_quant`` into the scheme
|
||||
constructor instead of assuming a "Linear" group.
|
||||
|
||||
These tests pin that contract: building a MoE compressed-tensors config with no
|
||||
"Linear" group and calling ``get_moe_scheme`` must return the correct WNA16 MoE
|
||||
scheme rather than raising ``KeyError``. This is pure config-parsing logic (no
|
||||
weights are created and no kernels run), so it runs on CPU.
|
||||
|
||||
The configs mirror real Laguna-style MoE quant configs: WNA16 int4/int8, group
|
||||
strategy, group_size 128, symmetric, expert projections targeted by regex or by
|
||||
per-layer FQN, with attention / router layers ignored.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.moe import MoeRunnerBackend
|
||||
from sglang.srt.layers.quantization.compressed_tensors import compressed_tensors
|
||||
from sglang.srt.layers.quantization.compressed_tensors.compressed_tensors import (
|
||||
CompressedTensorsConfig,
|
||||
)
|
||||
@@ -34,18 +12,13 @@ from sglang.srt.layers.quantization.compressed_tensors.schemes import (
|
||||
CompressedTensorsWNA16MoE,
|
||||
CompressedTensorsWNA16TritonMoE,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
# WNA16 MoE Marlin (default) and Triton backends are both valid resolutions for
|
||||
# this config; only the "no KeyError, correct WNA16 int-N scheme" contract matters.
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
_WNA16_MOE_SCHEMES = (CompressedTensorsWNA16MoE, CompressedTensorsWNA16TritonMoE)
|
||||
|
||||
# Layer whose experts we resolve a scheme for. get_moe_scheme() expands this into
|
||||
# ".0.gate_proj" / ".0.up_proj" / ".0.down_proj" and matches each against targets.
|
||||
EXPERTS_LAYER = "model.layers.0.mlp.experts"
|
||||
|
||||
# Per-layer FQN targets: the three expert projections of layer 0, named
|
||||
# explicitly rather than via regex. Still no "Linear" group.
|
||||
PER_LAYER_EXPERT_TARGETS = [
|
||||
f"{EXPERTS_LAYER}.0.gate_proj",
|
||||
f"{EXPERTS_LAYER}.0.up_proj",
|
||||
@@ -53,28 +26,22 @@ PER_LAYER_EXPERT_TARGETS = [
|
||||
]
|
||||
|
||||
|
||||
def _make_wna16_moe_config(targets, num_bits):
|
||||
"""A WNA16 compressed-tensors MoE quant config with NO "Linear" group.
|
||||
|
||||
Only the expert projections are quantized, targeted via ``targets`` (regex or
|
||||
per-layer FQN). Attention / router / lm_head are ignored, exactly as a real
|
||||
mixed-precision MoE checkpoint would express it.
|
||||
"""
|
||||
def _make_wna16_moe_config(targets, num_bits, **weight_overrides):
|
||||
weights = {
|
||||
"num_bits": num_bits,
|
||||
"type": "int",
|
||||
"symmetric": True,
|
||||
"strategy": "group",
|
||||
"group_size": 128,
|
||||
}
|
||||
weights.update(weight_overrides)
|
||||
return {
|
||||
"quant_method": "compressed-tensors",
|
||||
# pack-quantized => WNA16 (weight-only, int, no input activations).
|
||||
"format": "pack-quantized",
|
||||
"config_groups": {
|
||||
"group_0": {
|
||||
"targets": targets,
|
||||
"weights": {
|
||||
"num_bits": num_bits,
|
||||
"type": "int",
|
||||
"symmetric": True,
|
||||
"strategy": "group",
|
||||
"group_size": 128,
|
||||
},
|
||||
# Weight-only: no activation quantization.
|
||||
"weights": weights,
|
||||
"input_activations": None,
|
||||
}
|
||||
},
|
||||
@@ -83,18 +50,11 @@ def _make_wna16_moe_config(targets, num_bits):
|
||||
|
||||
|
||||
class TestWNA16MoENoLinearGroup(CustomTestCase):
|
||||
"""Regression: get_moe_scheme() must not assume a "Linear" config group."""
|
||||
|
||||
def _assert_wna16_moe(self, config_dict, expected_bits):
|
||||
quant_config = CompressedTensorsConfig.from_config(config_dict)
|
||||
|
||||
# Precondition that reproduces the original bug: the parsed scheme map
|
||||
# has no "Linear" group, so the old target_scheme_map["Linear"] lookup
|
||||
# would KeyError.
|
||||
self.assertNotIn("Linear", quant_config.target_scheme_map)
|
||||
|
||||
layer = torch.nn.Module()
|
||||
# Would raise KeyError: 'Linear' before the fix.
|
||||
scheme = quant_config.get_moe_scheme(layer, layer_name=EXPERTS_LAYER)
|
||||
|
||||
self.assertIsInstance(scheme, _WNA16_MOE_SCHEMES)
|
||||
@@ -113,6 +73,122 @@ class TestWNA16MoENoLinearGroup(CustomTestCase):
|
||||
config = _make_wna16_moe_config(PER_LAYER_EXPERT_TARGETS, num_bits=4)
|
||||
self._assert_wna16_moe(config, expected_bits=4)
|
||||
|
||||
def test_blackwell_int4_auto_uses_triton(self):
|
||||
for group_size in (32, 128):
|
||||
with self.subTest(group_size=group_size):
|
||||
quant_config = CompressedTensorsConfig.from_config(
|
||||
_make_wna16_moe_config(
|
||||
["re:.*mlp.experts.*"],
|
||||
num_bits=4,
|
||||
group_size=group_size,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
compressed_tensors,
|
||||
"get_moe_runner_backend",
|
||||
return_value=MoeRunnerBackend.AUTO,
|
||||
),
|
||||
mock.patch.object(
|
||||
compressed_tensors, "is_sm100_supported", return_value=True
|
||||
),
|
||||
):
|
||||
scheme = quant_config.get_moe_scheme(
|
||||
torch.nn.Module(), layer_name=EXPERTS_LAYER
|
||||
)
|
||||
|
||||
self.assertIsInstance(scheme, CompressedTensorsWNA16TritonMoE)
|
||||
|
||||
def test_blackwell_auto_rejects_unvalidated_triton_layouts(self):
|
||||
cases = {
|
||||
"asymmetric": {"symmetric": False},
|
||||
"channel": {"strategy": "channel", "group_size": None},
|
||||
"group64": {"group_size": 64},
|
||||
"actorder": {"actorder": "group"},
|
||||
}
|
||||
for name, overrides in cases.items():
|
||||
with self.subTest(name=name):
|
||||
quant_config = CompressedTensorsConfig.from_config(
|
||||
_make_wna16_moe_config(
|
||||
["re:.*mlp.experts.*"], num_bits=4, **overrides
|
||||
)
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
compressed_tensors,
|
||||
"get_moe_runner_backend",
|
||||
return_value=MoeRunnerBackend.AUTO,
|
||||
),
|
||||
mock.patch.object(
|
||||
compressed_tensors, "is_sm100_supported", return_value=True
|
||||
),
|
||||
):
|
||||
scheme = quant_config.get_moe_scheme(
|
||||
torch.nn.Module(), layer_name=EXPERTS_LAYER
|
||||
)
|
||||
|
||||
self.assertIsInstance(scheme, CompressedTensorsWNA16MoE)
|
||||
self.assertNotIsInstance(scheme, CompressedTensorsWNA16TritonMoE)
|
||||
|
||||
def test_explicit_triton_rejects_unvalidated_layout(self):
|
||||
quant_config = CompressedTensorsConfig.from_config(
|
||||
_make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=4, symmetric=False)
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
compressed_tensors,
|
||||
"get_moe_runner_backend",
|
||||
return_value=MoeRunnerBackend.TRITON,
|
||||
),
|
||||
self.assertRaisesRegex(ValueError, "only supports symmetric INT4"),
|
||||
):
|
||||
quant_config.get_moe_scheme(torch.nn.Module(), layer_name=EXPERTS_LAYER)
|
||||
|
||||
def test_blackwell_explicit_marlin_is_preserved(self):
|
||||
quant_config = CompressedTensorsConfig.from_config(
|
||||
_make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=4)
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
compressed_tensors,
|
||||
"get_moe_runner_backend",
|
||||
return_value=MoeRunnerBackend.MARLIN,
|
||||
),
|
||||
mock.patch.object(
|
||||
compressed_tensors, "is_sm100_supported", return_value=True
|
||||
),
|
||||
):
|
||||
scheme = quant_config.get_moe_scheme(
|
||||
torch.nn.Module(), layer_name=EXPERTS_LAYER
|
||||
)
|
||||
|
||||
self.assertIsInstance(scheme, CompressedTensorsWNA16MoE)
|
||||
|
||||
def test_blackwell_int8_auto_keeps_marlin(self):
|
||||
quant_config = CompressedTensorsConfig.from_config(
|
||||
_make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=8)
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
compressed_tensors,
|
||||
"get_moe_runner_backend",
|
||||
return_value=MoeRunnerBackend.AUTO,
|
||||
),
|
||||
mock.patch.object(
|
||||
compressed_tensors, "is_sm100_supported", return_value=True
|
||||
),
|
||||
):
|
||||
scheme = quant_config.get_moe_scheme(
|
||||
torch.nn.Module(), layer_name=EXPERTS_LAYER
|
||||
)
|
||||
|
||||
self.assertIsInstance(scheme, CompressedTensorsWNA16MoE)
|
||||
self.assertNotIsInstance(scheme, CompressedTensorsWNA16TritonMoE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -50,6 +50,9 @@ def _track_seqlen(*, tree_page: int, prefix_len: int, extend_len: int) -> int:
|
||||
req.mamba_branching_seqlen = None
|
||||
|
||||
batch = ScheduleBatch(reqs=[req])
|
||||
batch.model_config = SimpleNamespace(
|
||||
hf_text_config=SimpleNamespace(mamba_chunk_size=CHUNK)
|
||||
)
|
||||
batch.tree_cache = SimpleNamespace(page_size=tree_page)
|
||||
batch.req_to_token_pool = MagicMock()
|
||||
batch.req_to_token_pool.get_mamba_ping_pong_other_idx.return_value = 1
|
||||
|
||||
@@ -178,11 +178,12 @@ class TestFlashKDAStridedStateAccess(unittest.TestCase):
|
||||
conv_before = [cv.clone() for cv in conv_views]
|
||||
|
||||
cache_indices = torch.tensor([5, 2], dtype=torch.int32)
|
||||
out = self._run_extend(ssm_states, cache_indices)
|
||||
out, intermediate_states = self._run_extend(ssm_states, cache_indices)
|
||||
|
||||
# Routing: the fused path ran exactly once (a silent re-route to the
|
||||
# triton fallback would make every assertion below vacuous).
|
||||
self.assertEqual(self.fake.calls, 1)
|
||||
self.assertIsNone(intermediate_states)
|
||||
self.assertEqual(tuple(out.shape), (1, 2 * _SEQ_LEN, _H, _V))
|
||||
|
||||
# Gather: the external kernel must receive a CONTIGUOUS copy whose rows
|
||||
|
||||
@@ -14,9 +14,13 @@ through `get_parallel().override(...)`; the ones that are pure config /
|
||||
quantization are exercised directly.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from types import SimpleNamespace
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from sglang.srt.runtime_context import get_context, get_parallel
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
@@ -29,6 +33,25 @@ def _quant(name: str):
|
||||
return SimpleNamespace(get_name=lambda: name)
|
||||
|
||||
|
||||
def _import_bailing_modules():
|
||||
if importlib.util.find_spec("vllm") is not None:
|
||||
from sglang.srt.models import bailing_moe_nextn, bailing_moe_v3
|
||||
|
||||
return bailing_moe_v3, bailing_moe_nextn
|
||||
|
||||
# CPU CI omits vLLM; these fusion gates never execute the imported AWQ kernel.
|
||||
vllm = ModuleType("vllm")
|
||||
vllm.__path__ = []
|
||||
custom_ops = ModuleType("vllm._custom_ops")
|
||||
custom_ops.awq_dequantize = unittest.mock.Mock()
|
||||
with unittest.mock.patch.dict(
|
||||
sys.modules, {"vllm": vllm, "vllm._custom_ops": custom_ops}
|
||||
):
|
||||
from sglang.srt.models import bailing_moe_nextn, bailing_moe_v3
|
||||
|
||||
return bailing_moe_v3, bailing_moe_nextn
|
||||
|
||||
|
||||
class _FusionGateCase(CustomTestCase):
|
||||
def _seed(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
@@ -228,6 +251,113 @@ class TestMiniMaxGates(_FusionGateCase):
|
||||
)
|
||||
|
||||
|
||||
class TestBailingMoeV3Gate(_FusionGateCase):
|
||||
def _config(self):
|
||||
return SimpleNamespace(
|
||||
architectures=["BailingMoeV3ForCausalLM"],
|
||||
num_shared_experts=1,
|
||||
moe_intermediate_size=1024,
|
||||
)
|
||||
|
||||
def _compressed_tensors(self, ignore):
|
||||
return SimpleNamespace(
|
||||
get_name=lambda: "compressed_tensors",
|
||||
ignore=ignore,
|
||||
packed_modules_mapping={},
|
||||
)
|
||||
|
||||
def _reason_on_cuda(self, quant_config):
|
||||
bailing_moe_v3, _ = _import_bailing_modules()
|
||||
|
||||
self._seed()
|
||||
with (
|
||||
unittest.mock.patch.object(bailing_moe_v3, "_is_cuda", True),
|
||||
unittest.mock.patch.object(
|
||||
bailing_moe_v3.torch.cuda,
|
||||
"get_device_capability",
|
||||
return_value=(9, 0),
|
||||
),
|
||||
):
|
||||
return self._reason(
|
||||
bailing_moe_v3.BailingMoeV3ForCausalLM,
|
||||
self._config(),
|
||||
quant_config,
|
||||
)
|
||||
|
||||
def test_compressed_tensors_mixed_expert_layout_cannot_fuse(self):
|
||||
reason = self._reason_on_cuda(
|
||||
self._compressed_tensors(
|
||||
["re:.*(mlp|shared_experts)\\.(gate|up|gate_up|down|eh)_proj.*"]
|
||||
)
|
||||
)
|
||||
self.assertIn("different quant methods", reason)
|
||||
|
||||
def test_compressed_tensors_uniform_expert_layout_can_fuse(self):
|
||||
self.assertIsNone(self._reason_on_cuda(self._compressed_tensors([])))
|
||||
|
||||
def test_nextn_uses_its_rewritten_architecture(self):
|
||||
bailing_moe_v3, bailing_moe_nextn = _import_bailing_modules()
|
||||
|
||||
config = self._config()
|
||||
config.architectures = ["BailingMoeForCausalLMNextN"]
|
||||
config.model_type = "bailing_hybrid"
|
||||
config.use_kda = True
|
||||
self._seed()
|
||||
with (
|
||||
unittest.mock.patch.object(bailing_moe_v3, "_is_cuda", True),
|
||||
unittest.mock.patch.object(
|
||||
bailing_moe_v3.torch.cuda,
|
||||
"get_device_capability",
|
||||
return_value=(9, 0),
|
||||
),
|
||||
):
|
||||
reason = self._reason(
|
||||
bailing_moe_nextn.BailingMoeForCausalLMNextN,
|
||||
config,
|
||||
self._compressed_tensors(
|
||||
["re:.*(mlp|shared_experts)\\.(gate|up|gate_up|down|eh)_proj.*"]
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIn("different quant methods", reason)
|
||||
|
||||
def test_nextn_constructor_calls_v3_fusion_setup(self):
|
||||
bailing_moe_v3, bailing_moe_nextn = _import_bailing_modules()
|
||||
|
||||
config = SimpleNamespace(
|
||||
architectures=["BailingMoeForCausalLMNextN"],
|
||||
model_type="bailing_hybrid",
|
||||
use_kda=True,
|
||||
num_shared_experts=1,
|
||||
vocab_size=32000,
|
||||
hidden_size=4096,
|
||||
)
|
||||
parallel = SimpleNamespace(
|
||||
tp_size=1,
|
||||
moe_ep_size=1,
|
||||
enable_dp_lm_head=False,
|
||||
)
|
||||
with (
|
||||
unittest.mock.patch.object(
|
||||
bailing_moe_nextn, "get_parallel", return_value=parallel
|
||||
),
|
||||
unittest.mock.patch.object(
|
||||
bailing_moe_v3, "get_parallel", return_value=parallel
|
||||
),
|
||||
unittest.mock.patch.object(
|
||||
bailing_moe_v3,
|
||||
"is_shared_experts_fusion_disabled",
|
||||
return_value=False,
|
||||
),
|
||||
unittest.mock.patch.object(bailing_moe_nextn, "BailingMoEModelNextN"),
|
||||
unittest.mock.patch.object(bailing_moe_nextn, "ParallelLMHead"),
|
||||
unittest.mock.patch.object(bailing_moe_nextn, "LogitsProcessor"),
|
||||
):
|
||||
model = bailing_moe_nextn.BailingMoeForCausalLMNextN(config)
|
||||
|
||||
self.assertEqual(model.num_fused_shared_experts, 1)
|
||||
|
||||
|
||||
class TestQwen3_5Gate(_FusionGateCase):
|
||||
def test_every_entry_class_answers(self):
|
||||
import sglang.srt.models.qwen3_5 as qwen3_5
|
||||
@@ -569,4 +699,4 @@ class TestFamiliesWithoutAGate(_FusionGateCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
sys.exit(pytest.main([__file__]))
|
||||
|
||||
@@ -14,6 +14,7 @@ from sglang.srt.parser.reasoning_parser import (
|
||||
InklingDetector,
|
||||
KimiDetector,
|
||||
KimiK2Detector,
|
||||
Ling3Detector,
|
||||
Nemotron3Detector,
|
||||
Qwen3Detector,
|
||||
ReasoningParser,
|
||||
@@ -466,6 +467,69 @@ class TestGlm45Detector(CustomTestCase):
|
||||
self.assertEqual(result.normal_text, "<tool_call>tool call")
|
||||
|
||||
|
||||
class TestLing3Detector(CustomTestCase):
|
||||
def setUp(self):
|
||||
self.detector = Ling3Detector()
|
||||
|
||||
def test_init(self):
|
||||
self.assertEqual(self.detector.tool_start_token, "<tool_call>")
|
||||
self.assertEqual(self.detector.reasoning_default, "enable_thinking")
|
||||
self.assertTrue(self.detector.thinks_internally)
|
||||
self.assertTrue(self.detector._force_nonempty_content)
|
||||
self.assertFalse(self.detector._in_reasoning)
|
||||
|
||||
def test_tool_interrupt(self):
|
||||
text = "<think>I need a tool<tool_call>get_weather</tool_call>"
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "I need a tool")
|
||||
self.assertEqual(result.normal_text, "<tool_call>get_weather</tool_call>")
|
||||
|
||||
def test_reasoning_only_swaps_to_normal_text(self):
|
||||
text = "<think>Final answer without a closing think tag"
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "")
|
||||
self.assertEqual(result.normal_text, "Final answer without a closing think tag")
|
||||
|
||||
def test_reasoning_only_with_end_token_swaps_to_normal_text(self):
|
||||
text = "<think>Final answer accidentally wrapped as reasoning</think>"
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "")
|
||||
self.assertEqual(
|
||||
result.normal_text, "Final answer accidentally wrapped as reasoning"
|
||||
)
|
||||
|
||||
def test_force_nonempty_content_false_disables_swap(self):
|
||||
detector = Ling3Detector(force_nonempty_content=False)
|
||||
text = "<think>Reasoning only</think>"
|
||||
result = detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "Reasoning only")
|
||||
self.assertEqual(result.normal_text, "")
|
||||
|
||||
def test_does_not_swap_when_normal_text_exists(self):
|
||||
text = "<think>Reasoning here</think>The answer is 42."
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "Reasoning here")
|
||||
self.assertEqual(result.normal_text, "The answer is 42.")
|
||||
|
||||
def test_empty_reasoning_with_normal_text(self):
|
||||
text = "<think></think>The answer is 42."
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "")
|
||||
self.assertEqual(result.normal_text, "The answer is 42.")
|
||||
|
||||
def test_plain_text_without_thinking(self):
|
||||
text = "The answer is 42."
|
||||
result = self.detector.detect_and_parse(text)
|
||||
self.assertEqual(result.reasoning_text, "")
|
||||
self.assertEqual(result.normal_text, text)
|
||||
|
||||
def test_streaming_reasoning_only_currently_streams_reasoning(self):
|
||||
self.detector.parse_streaming_increment("<think>")
|
||||
result = self.detector.parse_streaming_increment("The answer is 42.")
|
||||
self.assertEqual(result.reasoning_text, "The answer is 42.")
|
||||
self.assertEqual(result.normal_text, "")
|
||||
|
||||
|
||||
class TestHunyuanDetector(CustomTestCase):
|
||||
"""Test cases for Hunyuan detector with tool interruption support."""
|
||||
|
||||
@@ -678,6 +742,9 @@ class TestReasoningParser(CustomTestCase):
|
||||
parser = ReasoningParser("glm45")
|
||||
self.assertIsInstance(parser.detector, Glm45Detector)
|
||||
|
||||
parser = ReasoningParser("ling3")
|
||||
self.assertIsInstance(parser.detector, Ling3Detector)
|
||||
|
||||
parser = ReasoningParser("hunyuan")
|
||||
self.assertIsInstance(parser.detector, HunyuanDetector)
|
||||
|
||||
|
||||
@@ -2117,6 +2117,7 @@ class TestGoldenModelOverrides(_IsolatedPublish):
|
||||
disable_overlap_schedule=False,
|
||||
page_size=None,
|
||||
linear_attn_backend="triton",
|
||||
linear_attn_prefill_backend=None,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return ResolvedView(
|
||||
@@ -2142,6 +2143,13 @@ class TestGoldenModelOverrides(_IsolatedPublish):
|
||||
"mamba_radix_cache_strategy": "extra_buffer",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
_mamba_radix_cache_resolution(_view("BailingMoeV3ForCausalLM")),
|
||||
{
|
||||
"uses_mamba_radix_cache": True,
|
||||
"mamba_radix_cache_strategy": "extra_buffer",
|
||||
},
|
||||
)
|
||||
# auto + no extra-buffer support (Lfm2) -> no_buffer + overlap disable
|
||||
self.assertEqual(
|
||||
_mamba_radix_cache_resolution(_view("Lfm2ForCausalLM")),
|
||||
@@ -2204,6 +2212,15 @@ class TestGoldenModelOverrides(_IsolatedPublish):
|
||||
SimpleNamespace(linear_attn_backend="fla"), "Qwen3NextForCausalLM"
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
supports_mamba_cache_extra_buffer(
|
||||
SimpleNamespace(
|
||||
linear_attn_backend="triton",
|
||||
linear_attn_prefill_backend="flashinfer",
|
||||
),
|
||||
"Qwen3_5MoeForConditionalGeneration",
|
||||
)
|
||||
)
|
||||
|
||||
def test_qwen3_5_hybrid_coupled_declaration(self):
|
||||
from sglang.srt.arg_groups.overrides import _qwen3_5_hybrid_overrides
|
||||
|
||||
Reference in New Issue
Block a user