Add Inkling model support (#31681)
Co-authored-by: Chunan Zeng <zcnrex@gmail.com> Co-authored-by: Ke Bao <ispobaoke@gmail.com> Co-authored-by: Yanbin Jiang <jybsuper@gmail.com> Co-authored-by: Yuhao Yang <47235274+yhyang201@users.noreply.github.com> Co-authored-by: Qiaolin Yu <qiaolin.yu@radixark.ai> Co-authored-by: Zhichen Zeng <zczeng@uw.edu> Co-authored-by: Aurick Qiao <aurick@thinkingmachines.ai> Co-authored-by: Joseph <jk@thinkingmachines.ai>
This commit is contained in:
co-authored by
Chunan Zeng
Ke Bao
Yanbin Jiang
Yuhao Yang
Qiaolin Yu
Zhichen Zeng
Aurick Qiao
Joseph
parent
829e9ce9d5
commit
02236fa38c
@@ -0,0 +1,203 @@
|
||||
"""The fused attn prologue's conditional log-scaling-tau fold on the q path:
|
||||
q_out must equal {per-head RMSNorm -> bf16 -> * tau -> bf16} (the unfused
|
||||
{prologue -> apply_log_scaling_tau} rounding, exactly), and the k/v outputs
|
||||
must be untouched by tau.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.jit_kernel.inkling_attn_prologue import inkling_attn_prologue_decode
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=40, stage="base-b-kernel-unit", runner_config="1-gpu-large")
|
||||
register_cuda_ci(est_time=40, stage="base-b-kernel-unit", runner_config="4-gpu-b200")
|
||||
|
||||
HEAD = 128
|
||||
W = 4
|
||||
EPS = 1e-6
|
||||
|
||||
|
||||
def _run(t, dq, dkv, tau):
|
||||
torch.manual_seed(1234)
|
||||
dev = "cuda"
|
||||
row = dq + 2 * dkv + 64 # packed qkvr row with an r tail
|
||||
qkvr = torch.randn(t, row, device=dev, dtype=torch.bfloat16)
|
||||
pool = 64
|
||||
k_cache = torch.randn(pool, W - 1, dkv, device=dev, dtype=torch.bfloat16)
|
||||
v_cache = torch.randn(pool, W - 1, dkv, device=dev, dtype=torch.bfloat16)
|
||||
ci = torch.arange(t, device=dev, dtype=torch.int32) + 3
|
||||
cm = torch.ones(t, device=dev, dtype=torch.bool)
|
||||
kw = torch.randn(dkv, W, device=dev, dtype=torch.bfloat16) * 0.3
|
||||
vw = torch.randn(dkv, W, device=dev, dtype=torch.bfloat16) * 0.3
|
||||
qg = torch.randn(HEAD, device=dev, dtype=torch.bfloat16)
|
||||
kg = torch.randn(HEAD, device=dev, dtype=torch.bfloat16)
|
||||
slots = 256
|
||||
loc = (torch.arange(t, device=dev, dtype=torch.int64) * 7 + 5) % slots
|
||||
k_buf = torch.zeros(slots, dkv // HEAD, HEAD, device=dev, dtype=torch.bfloat16)
|
||||
v_buf = torch.zeros_like(k_buf)
|
||||
return inkling_attn_prologue_decode(
|
||||
qkvr,
|
||||
k_cache,
|
||||
v_cache,
|
||||
ci,
|
||||
cm,
|
||||
kw,
|
||||
vw,
|
||||
qg,
|
||||
kg,
|
||||
EPS,
|
||||
loc,
|
||||
k_buf,
|
||||
v_buf,
|
||||
0,
|
||||
dq,
|
||||
dq + dkv,
|
||||
dq,
|
||||
dkv,
|
||||
activation=None,
|
||||
use_residual=True,
|
||||
do_store=True,
|
||||
log_scaling_tau=tau,
|
||||
)
|
||||
|
||||
|
||||
def _q_ref(t, dq, dkv, tau):
|
||||
torch.manual_seed(1234)
|
||||
row = dq + 2 * dkv + 64
|
||||
qkvr = torch.randn(t, row, device="cuda", dtype=torch.bfloat16)
|
||||
# (regenerate the SAME rng stream as _run for the remaining tensors)
|
||||
_ = torch.randn(64, W - 1, dkv, device="cuda", dtype=torch.bfloat16)
|
||||
_ = torch.randn(64, W - 1, dkv, device="cuda", dtype=torch.bfloat16)
|
||||
kw = torch.randn(dkv, W, device="cuda", dtype=torch.bfloat16) * 0.3
|
||||
vw = torch.randn(dkv, W, device="cuda", dtype=torch.bfloat16) * 0.3
|
||||
del kw, vw
|
||||
qg = torch.randn(HEAD, device="cuda", dtype=torch.bfloat16)
|
||||
q = qkvr[:, :dq].float().view(t, dq // HEAD, HEAD)
|
||||
inv = torch.rsqrt(q.pow(2).mean(-1, keepdim=True) + EPS)
|
||||
out = (q * inv * qg.float()).bfloat16()
|
||||
if tau is not None:
|
||||
out = (out.float() * tau.view(-1, 1, 1)).bfloat16()
|
||||
return out.view(t, dq)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("t", [1, 3, 8, 32])
|
||||
def test_prologue_decode_tau_fold(t):
|
||||
dq, dkv = 2048, 256
|
||||
tau = 1.0 + 0.1 * torch.rand(t, device="cuda", dtype=torch.float32)
|
||||
|
||||
q_tau, k_tau, v_tau, _ = _run(t, dq, dkv, tau)
|
||||
q_ref = _q_ref(t, dq, dkv, tau)
|
||||
torch.testing.assert_close(q_tau.float(), q_ref.float(), rtol=2e-2, atol=2e-2)
|
||||
|
||||
# tau must not touch the k/v legs.
|
||||
q_off, k_off, v_off, _ = _run(t, dq, dkv, None)
|
||||
assert torch.equal(k_tau, k_off)
|
||||
assert torch.equal(v_tau, v_off)
|
||||
# And with tau=None the q path matches the tau-free reference bit-wise
|
||||
# modulo the norm's fp32 reduction (tolerance).
|
||||
torch.testing.assert_close(
|
||||
q_off.float(), _q_ref(t, dq, dkv, None).float(), rtol=2e-2, atol=2e-2
|
||||
)
|
||||
# The fold itself must be exactly {round -> fp32 mul -> round}: applying
|
||||
# tau to the tau-free kernel output reproduces the fused output bit-wise.
|
||||
refold = (q_off.float() * tau.view(-1, 1)).bfloat16()
|
||||
assert torch.equal(q_tau, refold)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("t", [1, 7, 64, 2048])
|
||||
def test_rel_logits_proj_prescale_tau(t):
|
||||
"""RelLogitsProj's operand-side tau fold (r*tau before the einsum) must
|
||||
match the legacy output-side scale within bf16 rounding."""
|
||||
from sglang.kernels.ops.attention.log_scaling_tau import apply_log_scaling_tau
|
||||
from sglang.srt.models.inkling_common.attn import RelLogitsProj
|
||||
|
||||
torch.manual_seed(t)
|
||||
h, d_rel, e = 16, 16, 1024
|
||||
m = RelLogitsProj(d_rel, e).cuda()
|
||||
m.proj.data = torch.randn(d_rel, e, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||
r = torch.randn(t, h, d_rel, device="cuda", dtype=torch.bfloat16)
|
||||
tau = 1.0 + 0.1 * torch.rand(t, device="cuda", dtype=torch.float32)
|
||||
|
||||
assert m._prescale_tau # default-on flag
|
||||
out = m(r, tau)
|
||||
ref = apply_log_scaling_tau(
|
||||
torch.einsum("thd,de->the", r, m.proj), tau.view(-1, 1, 1)
|
||||
)
|
||||
torch.testing.assert_close(out.float(), ref.float(), rtol=2e-2, atol=2e-2)
|
||||
# And without tau it is the plain einsum, bit-exact.
|
||||
assert torch.equal(m(r), torch.einsum("thd,de->the", r, m.proj))
|
||||
|
||||
|
||||
_QKVR_ROW = 2816 # dq 2048 + 2*dkv 512 + h*d_rel 256 (the TP4 packed row)
|
||||
|
||||
|
||||
def _strided_r(t, h=16, d_rel=16, elem_offset=0):
|
||||
"""r exactly as production builds it: the trailing slice of the packed
|
||||
qkvr projection output, viewed [t, h, d_rel] (row stride = full row)."""
|
||||
torch.manual_seed(t + elem_offset)
|
||||
qkvr = torch.randn(t, _QKVR_ROW + elem_offset, device="cuda", dtype=torch.bfloat16)
|
||||
off = _QKVR_ROW + elem_offset - h * d_rel
|
||||
return qkvr[:, off:].view(t, h, d_rel)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("t", [1, 2, 48, 49, 64, 200, 1024])
|
||||
def test_rel_logits_proj_strided_dispatch(t):
|
||||
"""_project on the production strided layout must be BIT-identical to the
|
||||
plain einsum in both dispatch bands -- the zero-copy batched matmul
|
||||
(t <= _REL_PROJ_MATMUL_MAX_T) and {JIT row-compact -> einsum} above it.
|
||||
Guards the band boundary, the as_strided compaction math, and the
|
||||
batched-GEMM == flat-GEMM reduction-order claim the dispatch relies on."""
|
||||
from sglang.srt.models.inkling_common.attn import RelLogitsProj
|
||||
|
||||
h, d_rel, e = 16, 16, 1024
|
||||
m = RelLogitsProj(d_rel, e).cuda()
|
||||
m.proj.data = torch.randn(d_rel, e, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||
assert m._proj_dispatch # default-on flag
|
||||
|
||||
r = _strided_r(t, h, d_rel)
|
||||
ref = torch.einsum("thd,de->the", r.contiguous(), m.proj)
|
||||
out = m(r)
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, ref)
|
||||
|
||||
# The tau path on the same strided layout (prescale compacts first).
|
||||
from sglang.kernels.ops.attention.log_scaling_tau import apply_log_scaling_tau
|
||||
|
||||
tau = 1.0 + 0.1 * torch.rand(t, device="cuda", dtype=torch.float32)
|
||||
ref_tau = apply_log_scaling_tau(ref, tau.view(-1, 1, 1))
|
||||
torch.testing.assert_close(m(r, tau).float(), ref_tau.float(), rtol=2e-2, atol=2e-2)
|
||||
|
||||
|
||||
def test_rel_logits_proj_dispatch_fallbacks():
|
||||
"""The compact band must fall back to the plain einsum (and stay exact)
|
||||
when the JIT copy is ineligible -- e.g. a 2-byte-aligned r slice -- and
|
||||
flag-off must restore the undispatched einsum on every input."""
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.models.inkling_common.attn import (
|
||||
_REL_PROJ_MATMUL_MAX_T,
|
||||
RelLogitsProj,
|
||||
)
|
||||
|
||||
h, d_rel, e = 16, 16, 1024
|
||||
m = RelLogitsProj(d_rel, e).cuda()
|
||||
m.proj.data = torch.randn(d_rel, e, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||
|
||||
t = _REL_PROJ_MATMUL_MAX_T + 16 # inside the compact band
|
||||
r_misaligned = _strided_r(t, h, d_rel, elem_offset=1)
|
||||
assert r_misaligned.data_ptr() % 16 != 0
|
||||
ref = torch.einsum("thd,de->the", r_misaligned.contiguous(), m.proj)
|
||||
assert torch.equal(m(r_misaligned), ref)
|
||||
|
||||
with envs.SGLANG_OPT_USE_INKLING_REL_PROJ_DISPATCH.override(False):
|
||||
m_off = RelLogitsProj(d_rel, e).cuda()
|
||||
m_off.proj.data = m.proj.data
|
||||
assert not m_off._proj_dispatch
|
||||
r = _strided_r(t, h, d_rel)
|
||||
assert torch.equal(m_off(r), torch.einsum("thd,de->the", r, m_off.proj))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v", "-s"]))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""rel_proj_small_t: the single-launch small-t rel projection (optional tau
|
||||
prescale folded in registers) must match the reference chains it replaces --
|
||||
{r*tau -> bf16 round -> projection} -- within bf16 GEMM rounding, on both the
|
||||
production strided-r layout and contiguous inputs."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.jit_kernel.inkling_rel_proj import rel_proj_small_t
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=30, stage="base-b-kernel-unit", runner_config="1-gpu-large")
|
||||
register_cuda_ci(est_time=30, stage="base-b-kernel-unit", runner_config="4-gpu-b200")
|
||||
|
||||
H, K, E, ROW = 16, 16, 1024, 2816
|
||||
|
||||
|
||||
def _make_r(t, strided):
|
||||
torch.manual_seed(t + int(strided))
|
||||
if strided:
|
||||
qkvr = torch.randn(t, ROW, device="cuda", dtype=torch.bfloat16)
|
||||
return qkvr[:, ROW - H * K :].view(t, H, K)
|
||||
return torch.randn(t, H, K, device="cuda", dtype=torch.bfloat16)
|
||||
|
||||
|
||||
def _ref(r, proj, tau):
|
||||
rf = r.float()
|
||||
if tau is not None:
|
||||
# The prescale contract: r*tau rounds to bf16 BEFORE the dot.
|
||||
rf = (rf * tau.view(-1, 1, 1)).bfloat16().float()
|
||||
return torch.einsum("thd,de->the", rf, proj.float())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("t", [1, 2, 5, 16, 32])
|
||||
@pytest.mark.parametrize("strided", [False, True])
|
||||
@pytest.mark.parametrize("with_tau", [False, True])
|
||||
def test_rel_proj_small_t(t, strided, with_tau):
|
||||
r = _make_r(t, strided)
|
||||
proj = torch.randn(K, E, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||
tau = (
|
||||
1.0 + 0.1 * torch.rand(t, device="cuda", dtype=torch.float32)
|
||||
if with_tau
|
||||
else None
|
||||
)
|
||||
out = rel_proj_small_t(r, proj, tau)
|
||||
assert out.is_contiguous() and out.shape == (t, H, E)
|
||||
ref = _ref(r, proj, tau)
|
||||
# fp32 accumulation, one bf16 round -- match the fp32 reference to
|
||||
# 2 bf16 ulp (the GEMM reduction-order slack vs cuBLAS is within this).
|
||||
torch.testing.assert_close(out.float(), ref, rtol=2e-2, atol=2e-2)
|
||||
|
||||
|
||||
def test_rel_proj_tau_isolation():
|
||||
"""tau must only scale: kernel(tau) == kernel(no tau) computed on the
|
||||
pre-rounded r*tau operand -- guards the round-before-dot placement (a
|
||||
fold AFTER the dot would diverge at large |logits|)."""
|
||||
t = 8
|
||||
r = _make_r(t, True)
|
||||
proj = torch.randn(K, E, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||
tau = 1.0 + 0.5 * torch.rand(t, device="cuda", dtype=torch.float32)
|
||||
out = rel_proj_small_t(r, proj, tau)
|
||||
r_pre = (r.float() * tau.view(-1, 1, 1)).bfloat16()
|
||||
assert torch.equal(out, rel_proj_small_t(r_pre.contiguous(), proj))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v", "-s"]))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""The vectorized row-scale kernel must be BIT-identical to the triton
|
||||
apply_log_scaling_tau kernel it replaces (same fp32 multiply + bf16 round),
|
||||
including on the row-strided qkvr-slice layouts."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.jit_kernel.inkling_row_scale import row_scale_bf16
|
||||
from sglang.kernels.ops.attention.log_scaling_tau import (
|
||||
_apply_log_scaling_tau_kernel,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=30, stage="base-b-kernel-unit", runner_config="1-gpu-large")
|
||||
register_cuda_ci(est_time=30, stage="base-b-kernel-unit", runner_config="4-gpu-b200")
|
||||
|
||||
|
||||
def _triton_ref(x2d, tau):
|
||||
import triton
|
||||
|
||||
rows, inner = x2d.shape
|
||||
out = torch.empty(rows, inner, dtype=x2d.dtype, device=x2d.device)
|
||||
total = rows * inner
|
||||
_apply_log_scaling_tau_kernel[(triton.cdiv(total, 1024),)](
|
||||
x2d, tau, out, x2d.stride(0), inner, total, BLOCK=1024
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [1, 3, 16, 200, 4096])
|
||||
@pytest.mark.parametrize("inner", [8, 256, 2048, 16384])
|
||||
@pytest.mark.parametrize("strided", [False, True])
|
||||
def test_row_scale_bitexact(rows, inner, strided):
|
||||
torch.manual_seed(rows + inner)
|
||||
if strided:
|
||||
packed = torch.randn(rows, inner + 40, device="cuda", dtype=torch.bfloat16)
|
||||
x = packed[:, 8 : 8 + inner]
|
||||
else:
|
||||
x = torch.randn(rows, inner, device="cuda", dtype=torch.bfloat16)
|
||||
tau = 1.0 + 0.1 * torch.rand(rows, device="cuda", dtype=torch.float32)
|
||||
|
||||
out = row_scale_bf16(x, tau)
|
||||
ref = _triton_ref(x, tau)
|
||||
assert torch.equal(out, ref)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [1, 3, 200, 4096])
|
||||
@pytest.mark.parametrize("inner", [8, 256, 16384])
|
||||
@pytest.mark.parametrize("strided", [False, True])
|
||||
def test_row_compact_bitexact(rows, inner, strided):
|
||||
"""The tau-less compaction flavor (kHasTau=false) must reproduce
|
||||
.contiguous() exactly on the same strided layouts row_scale handles --
|
||||
no other test exercises run_compact."""
|
||||
from sglang.jit_kernel.inkling_row_scale import row_compact_bf16
|
||||
|
||||
torch.manual_seed(rows + inner)
|
||||
if strided:
|
||||
packed = torch.randn(rows, inner + 40, device="cuda", dtype=torch.bfloat16)
|
||||
x = packed[:, 8 : 8 + inner]
|
||||
else:
|
||||
x = torch.randn(rows, inner, device="cuda", dtype=torch.bfloat16)
|
||||
out = row_compact_bf16(x)
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, x.contiguous())
|
||||
|
||||
|
||||
def test_dispatch_through_apply_log_scaling_tau():
|
||||
from sglang.kernels.ops.attention.log_scaling_tau import apply_log_scaling_tau
|
||||
|
||||
torch.manual_seed(0)
|
||||
for shape, view in (((7, 16, 16), (-1, 1, 1)), ((7, 2048), (-1, 1))):
|
||||
x = torch.randn(*shape, device="cuda", dtype=torch.bfloat16)
|
||||
tau = 1.0 + 0.1 * torch.rand(7, device="cuda", dtype=torch.float32)
|
||||
out = apply_log_scaling_tau(x, tau.view(*view))
|
||||
ref = _triton_ref(x.view(7, -1), tau).view(x.shape)
|
||||
assert torch.equal(out, ref)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v", "-s"]))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Precision test for the fused packed-topk unpack triton kernel used by the
|
||||
Marlin MoE runner (fused-gate-topk support).
|
||||
|
||||
The FlashInfer / Inkling fused gate emits PackedTopKOutput -- int32
|
||||
``(expert_id << 16) | bf16-weight-bits``. The Marlin runner reads topk_ids /
|
||||
topk_weights separately, so it unpacks with a single Triton launch. This test
|
||||
checks the kernel is bit-identical to the torch elementwise reference and that
|
||||
pack -> unpack round-trips, across shapes / top_k / num_experts / weight
|
||||
distributions.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.moe.moe_runner.marlin import _fused_unpack_packed_topk
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=30, stage="base-b-kernel-unit", runner_config="1-gpu-large")
|
||||
|
||||
|
||||
def _torch_unpack(packed: torch.Tensor):
|
||||
ids = (packed >> 16).to(torch.int32)
|
||||
w = (packed & 0xFFFF).to(torch.int16).view(torch.bfloat16).to(torch.float32)
|
||||
return ids, w
|
||||
|
||||
|
||||
def _torch_pack(ids: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
|
||||
# inverse of the unpack, matching trtllm_lora_temp/topk_pack._pack_topk_kernel
|
||||
wbits = weights.to(torch.bfloat16).view(torch.int16).to(torch.int32) & 0xFFFF
|
||||
return (ids.to(torch.int32) << 16) | wbits
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_tokens,top_k",
|
||||
[
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(3, 6),
|
||||
(8, 2),
|
||||
(127, 6),
|
||||
(512, 6),
|
||||
(631, 2),
|
||||
(1024, 8),
|
||||
(2048, 8),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("num_experts", [2, 8, 64, 256])
|
||||
@pytest.mark.parametrize("wdist", ["uniform", "edge", "tiny"])
|
||||
def test_unpack_matches_reference_and_roundtrips(num_tokens, top_k, num_experts, wdist):
|
||||
torch.manual_seed(0)
|
||||
ids = torch.randint(
|
||||
0, num_experts, (num_tokens, top_k), dtype=torch.int32, device="cuda"
|
||||
)
|
||||
if wdist == "uniform":
|
||||
w = torch.rand(num_tokens, top_k, device="cuda")
|
||||
elif wdist == "edge":
|
||||
choices = torch.tensor([0.0, 1.0, 0.5, 0.999, 1e-3], device="cuda")
|
||||
w = choices[
|
||||
torch.randint(0, choices.numel(), (num_tokens, top_k), device="cuda")
|
||||
]
|
||||
else:
|
||||
w = torch.rand(num_tokens, top_k, device="cuda") * 1e-3
|
||||
|
||||
packed = _torch_pack(ids, w)
|
||||
t_ids, t_w = _fused_unpack_packed_topk(packed)
|
||||
r_ids, r_w = _torch_unpack(packed)
|
||||
|
||||
# bit-identical to the torch elementwise reference
|
||||
assert torch.equal(t_ids, r_ids)
|
||||
assert torch.equal(t_w, r_w)
|
||||
# round-trip: ids exact, weights recover the bf16-rounded originals
|
||||
assert torch.equal(t_ids, ids)
|
||||
torch.testing.assert_close(
|
||||
t_w, w.to(torch.bfloat16).to(torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert t_ids.dtype == torch.int32 and t_w.dtype == torch.float32
|
||||
assert t_ids.shape == (num_tokens, top_k) and t_w.shape == (num_tokens, top_k)
|
||||
|
||||
|
||||
def test_unpack_empty():
|
||||
packed = torch.empty((0, 2), dtype=torch.int32, device="cuda")
|
||||
ids, w = _fused_unpack_packed_topk(packed)
|
||||
assert ids.shape == (0, 2) and w.shape == (0, 2)
|
||||
assert ids.dtype == torch.int32 and w.dtype == torch.float32
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v", "-s"]))
|
||||
@@ -0,0 +1,229 @@
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=15, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
"""Boundary-KV fix kernels (SGLANG_ENABLE_MTP_BOUNDARY_KV_FIX) vs a pure-torch reference.
|
||||
|
||||
Covers the three pieces behind the pool-free chain-MTP boundary-KV exactness
|
||||
fix (widened draft-extend windows that rewrite draft KV rows keyed on rejected
|
||||
chain proposals with their committed keys):
|
||||
- compute_widened_draft_extend_locs_positions: batched out_cache_loc /
|
||||
positions for the widened window (runs BEFORE the forward batch is built;
|
||||
data-invalid rows zeroed, conv warm-up rows routed to the sacrificial
|
||||
cache slot 0, sacrificial zone capped at the front);
|
||||
- fill_widened_draft_extend_inputs: widened depth-0 window token/hidden
|
||||
materialization (stash fronts | predict rows);
|
||||
- stash_append_boundary_state: rolling per-request (token, base-hidden)
|
||||
stash append, in both decode (accumulate valid_len) and prefill-seed
|
||||
(SET valid_len, varlen sources) modes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.speculative.multi_layer_eagle_utils import (
|
||||
compute_widened_draft_extend_locs_positions,
|
||||
fill_widened_draft_extend_inputs_triton,
|
||||
stash_append_boundary_state_triton,
|
||||
)
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
DEV = "cuda"
|
||||
|
||||
# (bs, W, front, warmup, hidden, seq_lens, valid, accept)
|
||||
CASES = [
|
||||
# steady state: fully seeded stash, long sequences
|
||||
(4, 4, 5, 3, 64, [100, 33, 200, 17], [5, 5, 5, 5], [1, 4, 2, 3]),
|
||||
# fresh/short: partially seeded stash, seq_lens < front (degenerate rows)
|
||||
(4, 4, 5, 3, 64, [3, 1, 6, 2], [2, 0, 5, 1], [1, 1, 4, 2]),
|
||||
# d66-like 8-step chain shape without conv warm-up
|
||||
(2, 9, 7, 0, 128, [50, 9], [7, 3], [9, 1]),
|
||||
# minimal chain: 2 steps, W=3, front=1
|
||||
(3, 3, 1, 0, 32, [10, 11, 12], [1, 1, 0], [1, 2, 3]),
|
||||
]
|
||||
|
||||
|
||||
def _ref_stash_append(
|
||||
stash_t, stash_h, valid, src_t, src_h, ends, avail, rpis, set_valid
|
||||
):
|
||||
front = stash_t.shape[1]
|
||||
for i, rpi in enumerate(rpis.tolist()):
|
||||
m = min(int(avail[i]), front)
|
||||
end = int(ends[i])
|
||||
keep = front - m
|
||||
stash_t[rpi, :keep] = stash_t[rpi, m:].clone()
|
||||
stash_h[rpi, :keep] = stash_h[rpi, m:].clone()
|
||||
stash_t[rpi, keep:] = src_t[end - m : end].to(stash_t.dtype)
|
||||
stash_h[rpi, keep:] = src_h[end - m : end].to(stash_h.dtype)
|
||||
if set_valid:
|
||||
valid[rpi] = m
|
||||
else:
|
||||
valid[rpi] = min(int(valid[rpi]) + m, front)
|
||||
|
||||
|
||||
def _ref_fill_and_locs(
|
||||
predict, vhid, stash_t, stash_h, valid, seq_lens, rpis, req_to_token, W, warmup
|
||||
):
|
||||
bs = rpis.shape[0]
|
||||
front = stash_t.shape[1]
|
||||
width = W + front
|
||||
h = stash_h.shape[2]
|
||||
ids = torch.zeros(bs * width, dtype=torch.int64, device=DEV)
|
||||
hid = torch.zeros(bs * width, h, dtype=stash_h.dtype, device=DEV)
|
||||
pos = torch.zeros(bs * width, dtype=torch.int64, device=DEV)
|
||||
loc = torch.zeros(bs * width, dtype=torch.int64, device=DEV)
|
||||
for i, rpi in enumerate(rpis.tolist()):
|
||||
seq_len = int(seq_lens[i])
|
||||
first_valid = max(front - int(valid[rpi]), front - seq_len, 0)
|
||||
# Sacrificial zone is capped at the front: original rows always write.
|
||||
first_real = min(first_valid + warmup, front)
|
||||
for j in range(width):
|
||||
row = i * width + j
|
||||
p = seq_len - front + j
|
||||
if j >= front:
|
||||
src = i * W + j - front
|
||||
ids[row] = predict[src]
|
||||
hid[row] = vhid[src]
|
||||
pos[row] = p
|
||||
loc[row] = req_to_token[rpi, p]
|
||||
else:
|
||||
if j >= first_valid:
|
||||
ids[row] = stash_t[rpi, j]
|
||||
hid[row] = stash_h[rpi, j]
|
||||
pos[row] = p
|
||||
if j >= first_real:
|
||||
loc[row] = req_to_token[rpi, p]
|
||||
return ids, hid, pos, loc
|
||||
|
||||
|
||||
class TestBoundaryKvFixKernels(CustomTestCase):
|
||||
def test_kernels_vs_reference(self):
|
||||
for case_i, (
|
||||
bs,
|
||||
W,
|
||||
front,
|
||||
warmup,
|
||||
hidden,
|
||||
seq_lens,
|
||||
valid,
|
||||
accept,
|
||||
) in enumerate(CASES):
|
||||
with self.subTest(case=case_i):
|
||||
self._run_case(bs, W, front, warmup, hidden, seq_lens, valid, accept)
|
||||
|
||||
def _run_case(self, bs, W, front, warmup, hidden, seq_lens, valid, accept):
|
||||
torch.manual_seed(0)
|
||||
pool, max_ctx = 32, 512
|
||||
rpis = torch.randperm(pool, device=DEV)[:bs].to(torch.int64)
|
||||
req_to_token = (
|
||||
torch.arange(pool * max_ctx, device=DEV, dtype=torch.int32).reshape(
|
||||
pool, max_ctx
|
||||
)
|
||||
+ 1000
|
||||
)
|
||||
seq_lens_t = torch.tensor(seq_lens, device=DEV, dtype=torch.int64)
|
||||
|
||||
stash_t = torch.randint(5, 900, (pool, front), device=DEV, dtype=torch.int64)
|
||||
stash_h = torch.randn(pool, front, hidden, device=DEV, dtype=torch.bfloat16)
|
||||
valid_t = torch.zeros(pool, dtype=torch.int32, device=DEV)
|
||||
for i, rpi in enumerate(rpis.tolist()):
|
||||
valid_t[rpi] = valid[i]
|
||||
|
||||
predict = torch.randint(5, 900, (bs * W,), device=DEV, dtype=torch.int64)
|
||||
vhid = torch.randn(bs * W, hidden, device=DEV, dtype=torch.bfloat16)
|
||||
|
||||
r_ids, r_hid, r_pos, r_loc = _ref_fill_and_locs(
|
||||
predict,
|
||||
vhid,
|
||||
stash_t,
|
||||
stash_h,
|
||||
valid_t,
|
||||
seq_lens_t,
|
||||
rpis,
|
||||
req_to_token,
|
||||
W,
|
||||
warmup,
|
||||
)
|
||||
|
||||
# --- locs / positions (pre-forward-batch torch path) ---
|
||||
loc, pos = compute_widened_draft_extend_locs_positions(
|
||||
seq_lens_t,
|
||||
rpis,
|
||||
req_to_token,
|
||||
valid_t,
|
||||
draft_token_num=W,
|
||||
num_front_tokens=front,
|
||||
num_warmup_tokens=warmup,
|
||||
)
|
||||
self.assertTrue(torch.equal(loc, r_loc), "locs mismatch")
|
||||
self.assertTrue(torch.equal(pos, r_pos), "positions mismatch")
|
||||
|
||||
# --- widened window token/hidden fill ---
|
||||
width = W + front
|
||||
ids = torch.zeros(bs * width, dtype=torch.int64, device=DEV)
|
||||
hid = torch.zeros(bs * width, hidden, dtype=torch.bfloat16, device=DEV)
|
||||
fill_widened_draft_extend_inputs_triton(
|
||||
ids,
|
||||
hid,
|
||||
predict,
|
||||
vhid,
|
||||
stash_t,
|
||||
stash_h,
|
||||
valid_t,
|
||||
seq_lens_t,
|
||||
rpis,
|
||||
draft_token_num=W,
|
||||
)
|
||||
self.assertTrue(torch.equal(ids, r_ids), "fill mismatch on ids")
|
||||
self.assertTrue(torch.equal(hid, r_hid), "fill mismatch on hid")
|
||||
|
||||
# --- decode-style stash roll-forward (accumulating valid_len) ---
|
||||
accept_t = torch.tensor(accept, device=DEV, dtype=torch.int32)
|
||||
ends = torch.arange(bs, device=DEV, dtype=torch.int64) * W + accept_t.to(
|
||||
torch.int64
|
||||
)
|
||||
ref_t, ref_h, ref_v = stash_t.clone(), stash_h.clone(), valid_t.clone()
|
||||
_ref_stash_append(
|
||||
ref_t, ref_h, ref_v, predict, vhid, ends, accept_t, rpis, False
|
||||
)
|
||||
stash_append_boundary_state_triton(
|
||||
predict,
|
||||
vhid,
|
||||
ends,
|
||||
accept_t,
|
||||
rpis,
|
||||
stash_t,
|
||||
stash_h,
|
||||
valid_t,
|
||||
set_valid=False,
|
||||
)
|
||||
self.assertTrue(torch.equal(stash_t, ref_t), "decode stash tokens mismatch")
|
||||
self.assertTrue(torch.equal(stash_h, ref_h), "decode stash hiddens mismatch")
|
||||
self.assertTrue(torch.equal(valid_t, ref_v), "decode stash valid_len mismatch")
|
||||
|
||||
# --- prefill-style seed (varlen segments, SET valid_len) ---
|
||||
lens = torch.tensor(
|
||||
[max(1, (i * 7) % (W + front)) for i in range(bs)],
|
||||
device=DEV,
|
||||
dtype=torch.int32,
|
||||
)
|
||||
starts = torch.cumsum(
|
||||
torch.cat([torch.zeros(1, device=DEV, dtype=torch.int32), lens[:-1]]), 0
|
||||
)
|
||||
total = int(lens.sum())
|
||||
src_t = torch.randint(5, 900, (total,), device=DEV, dtype=torch.int64)
|
||||
src_h = torch.randn(total, hidden, device=DEV, dtype=torch.bfloat16)
|
||||
ends2 = (starts + lens).to(torch.int64)
|
||||
ref_t, ref_h, ref_v = stash_t.clone(), stash_h.clone(), valid_t.clone()
|
||||
_ref_stash_append(ref_t, ref_h, ref_v, src_t, src_h, ends2, lens, rpis, True)
|
||||
stash_append_boundary_state_triton(
|
||||
src_t, src_h, ends2, lens, rpis, stash_t, stash_h, valid_t, set_valid=True
|
||||
)
|
||||
self.assertTrue(torch.equal(stash_t, ref_t), "seed stash tokens mismatch")
|
||||
self.assertTrue(torch.equal(stash_h, ref_h), "seed stash hiddens mismatch")
|
||||
self.assertTrue(torch.equal(valid_t, ref_v), "seed stash valid_len mismatch")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Unit tests for the fused conv-slot clear/copy kernels
|
||||
(srt/mem_cache/mamba_slot_fused.py), checked bit-exact against the per-tensor
|
||||
reference loop that MambaPool.clear_slots / copy_from fall back to.
|
||||
|
||||
Covers heterogeneous conv shapes, single- and multi-layer pools, single /
|
||||
partial / full index sets, int32 indices, and the strided per-slot-envelope
|
||||
layout used by page-major / unified pools.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.mamba_slot_fused import (
|
||||
build_conv_slot_descriptor,
|
||||
fused_clear_conv_slots,
|
||||
fused_copy_conv_slots,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cuda_ci(est_time=30, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
CONV_LEN = 3
|
||||
# Representative hybrid conv-state trailing dims (a couple of KV-projection
|
||||
# streams + wider residual streams); only the trailing dim differs.
|
||||
HETERO_DIMS = [128, 128, 256, 256, 6144, 6144]
|
||||
|
||||
# (dims, num_layers, pool_size)
|
||||
CONFIGS = [
|
||||
(HETERO_DIMS, 1, 460), # single-layer draft pool, realistic size
|
||||
(HETERO_DIMS, 3, 128), # multi-layer
|
||||
([128], 1, 64), # single conv tensor
|
||||
([256, 6144], 2, 32), # mixed shapes, 2 layers
|
||||
]
|
||||
|
||||
|
||||
def _make_convs(dims, num_layers, pool, device, seed):
|
||||
g = torch.Generator(device=device).manual_seed(seed)
|
||||
return [
|
||||
torch.randn(
|
||||
num_layers,
|
||||
pool,
|
||||
CONV_LEN,
|
||||
d,
|
||||
dtype=torch.bfloat16,
|
||||
device=device,
|
||||
generator=g,
|
||||
)
|
||||
for d in dims
|
||||
]
|
||||
|
||||
|
||||
def _envelope_views(buf, dims, num_layers, pool):
|
||||
"""Strided per-slot-envelope views (page-major / unified layout): each conv
|
||||
tensor is a slice inside a shared per-slot entry, so slot_stride is the whole
|
||||
envelope, not the feature length."""
|
||||
envelope = buf.shape[2]
|
||||
views, off = [], 0
|
||||
for d in dims:
|
||||
views.append(
|
||||
torch.as_strided(
|
||||
buf,
|
||||
size=(num_layers, pool, CONV_LEN, d),
|
||||
stride=(pool * envelope, envelope, d, 1),
|
||||
storage_offset=off,
|
||||
)
|
||||
)
|
||||
off += CONV_LEN * d
|
||||
return views
|
||||
|
||||
|
||||
def _ref_clear(convs, idx):
|
||||
for t in convs:
|
||||
t[:, idx] = 0
|
||||
|
||||
|
||||
def _ref_copy(convs, src, dst):
|
||||
for t in convs:
|
||||
t[:, dst] = t[:, src]
|
||||
|
||||
|
||||
@unittest.skipUnless(torch.cuda.is_available(), "fused conv-slot kernels need CUDA")
|
||||
class TestMambaSlotFused(CustomTestCase):
|
||||
def test_clear_matches_reference(self):
|
||||
dev = "cuda"
|
||||
for dims, num_layers, pool in CONFIGS:
|
||||
for n in sorted({1, pool // 3, pool}): # single / partial / all slots
|
||||
with self.subTest(dims=dims, num_layers=num_layers, pool=pool, n=n):
|
||||
base = _make_convs(dims, num_layers, pool, dev, seed=0)
|
||||
idx = torch.randperm(pool, device=dev)[:n].to(torch.int64)
|
||||
ref = [t.clone() for t in base]
|
||||
got = [t.clone() for t in base]
|
||||
_ref_clear(ref, idx)
|
||||
fused_clear_conv_slots(build_conv_slot_descriptor(got), idx)
|
||||
torch.cuda.synchronize()
|
||||
for r, g in zip(ref, got):
|
||||
self.assertTrue(torch.equal(r, g))
|
||||
# Cleared slots are exactly zero; the rest is untouched.
|
||||
keep = torch.ones(pool, dtype=torch.bool, device=dev)
|
||||
keep[idx] = False
|
||||
for g, b in zip(got, base):
|
||||
self.assertTrue((g[:, idx] == 0).all().item())
|
||||
self.assertTrue(torch.equal(g[:, keep], b[:, keep]))
|
||||
|
||||
def test_copy_matches_reference(self):
|
||||
dev = "cuda"
|
||||
for dims, num_layers, pool in CONFIGS:
|
||||
with self.subTest(dims=dims, num_layers=num_layers, pool=pool):
|
||||
base = _make_convs(dims, num_layers, pool, dev, seed=1)
|
||||
perm = torch.randperm(pool, device=dev)
|
||||
n = max(1, pool // 4)
|
||||
src = perm[:n].to(torch.int64) # disjoint from dst (COW invariant)
|
||||
dst = perm[n : 2 * n].to(torch.int64)
|
||||
ref = [t.clone() for t in base]
|
||||
got = [t.clone() for t in base]
|
||||
_ref_copy(ref, src, dst)
|
||||
fused_copy_conv_slots(build_conv_slot_descriptor(got), src, dst)
|
||||
torch.cuda.synchronize()
|
||||
for r, g in zip(ref, got):
|
||||
self.assertTrue(torch.equal(r, g))
|
||||
|
||||
def test_strided_envelope_layout(self):
|
||||
# Page-major / unified pools store conv tensors as strided views into a
|
||||
# shared per-slot envelope (slot_stride = whole entry >> feat). The
|
||||
# kernel reads real strides, so it must handle this; the whole envelope
|
||||
# buffer (including the other streams' bytes in each slot) must be
|
||||
# bit-exact vs the reference, proving no cross-stream clobber.
|
||||
dev = "cuda"
|
||||
num_layers, pool = 2, 48
|
||||
dims = [128, 256, 6144]
|
||||
envelope = sum(CONV_LEN * d for d in dims)
|
||||
g = torch.Generator(device=dev).manual_seed(4)
|
||||
buf = torch.randn(
|
||||
num_layers, pool, envelope, dtype=torch.bfloat16, device=dev, generator=g
|
||||
)
|
||||
v0 = _envelope_views(buf, dims, num_layers, pool)[0]
|
||||
self.assertFalse(v0.is_contiguous()) # strided view...
|
||||
self.assertTrue(v0[0, 0].is_contiguous()) # ...but per-slot block is not
|
||||
|
||||
idx = torch.tensor([2, 7, 40], dtype=torch.int64, device=dev)
|
||||
ref_buf = buf.clone()
|
||||
got_buf = buf.clone()
|
||||
_ref_clear(_envelope_views(ref_buf, dims, num_layers, pool), idx)
|
||||
fused_clear_conv_slots(
|
||||
build_conv_slot_descriptor(
|
||||
_envelope_views(got_buf, dims, num_layers, pool)
|
||||
),
|
||||
idx,
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
self.assertTrue(torch.equal(ref_buf, got_buf))
|
||||
|
||||
# copy on the same strided layout
|
||||
src = torch.tensor([1, 20], dtype=torch.int64, device=dev)
|
||||
dst = torch.tensor([30, 45], dtype=torch.int64, device=dev)
|
||||
ref_buf = buf.clone()
|
||||
got_buf = buf.clone()
|
||||
_ref_copy(_envelope_views(ref_buf, dims, num_layers, pool), src, dst)
|
||||
fused_copy_conv_slots(
|
||||
build_conv_slot_descriptor(
|
||||
_envelope_views(got_buf, dims, num_layers, pool)
|
||||
),
|
||||
src,
|
||||
dst,
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
self.assertTrue(torch.equal(ref_buf, got_buf))
|
||||
|
||||
def test_empty_indices_is_noop(self):
|
||||
dev = "cuda"
|
||||
base = _make_convs(HETERO_DIMS, 1, 16, dev, seed=2)
|
||||
got = [t.clone() for t in base]
|
||||
empty = torch.empty(0, dtype=torch.int64, device=dev)
|
||||
desc = build_conv_slot_descriptor(got)
|
||||
fused_clear_conv_slots(desc, empty)
|
||||
fused_copy_conv_slots(desc, empty, empty)
|
||||
torch.cuda.synchronize()
|
||||
for b, g in zip(base, got):
|
||||
self.assertTrue(torch.equal(b, g))
|
||||
|
||||
def test_int32_indices_accepted(self):
|
||||
# deferred-clear/COW indices are staged as int32; the wrappers must upcast.
|
||||
dev = "cuda"
|
||||
base = _make_convs(HETERO_DIMS, 1, 32, dev, seed=3)
|
||||
idx = torch.tensor([1, 5, 9], dtype=torch.int32, device=dev)
|
||||
ref = [t.clone() for t in base]
|
||||
got = [t.clone() for t in base]
|
||||
_ref_clear(ref, idx.long())
|
||||
fused_clear_conv_slots(build_conv_slot_descriptor(got), idx)
|
||||
torch.cuda.synchronize()
|
||||
for r, g in zip(ref, got):
|
||||
self.assertTrue(torch.equal(r, g))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,6 +17,7 @@ import unittest
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import torch
|
||||
from torch.cuda import Event as CudaEvent
|
||||
from torch.cuda import Stream as CudaStream
|
||||
|
||||
@@ -69,6 +70,7 @@ class TestLoRAOverlapLoaderUnitTests(CustomTestCase):
|
||||
self.mock_lora_manager.memory_pool.uid_to_buffer_id = {}
|
||||
self.mock_lora_manager.validate_lora_batch.return_value = True
|
||||
self.mock_lora_manager.fetch_new_loras.side_effect = self._mark_loras_loaded
|
||||
self.mock_lora_manager.pending_lora_load_events = {}
|
||||
|
||||
def tearDown(self):
|
||||
self.torch_patcher.stop()
|
||||
@@ -155,6 +157,46 @@ class TestLoRAOverlapLoaderUnitTests(CustomTestCase):
|
||||
self.mock_lora_manager.fetch_new_loras.assert_not_called()
|
||||
self.assertIn("lora_A", loader.lora_to_overlap_load_event)
|
||||
|
||||
def test_loader_uses_manager_pending_event_store(self):
|
||||
loader = self._create_loader()
|
||||
|
||||
self.assertIs(
|
||||
loader.lora_to_overlap_load_event,
|
||||
self.mock_lora_manager.pending_lora_load_events,
|
||||
)
|
||||
|
||||
def test_pending_load_is_synchronized_before_unload(self):
|
||||
manager = LoRAManager.__new__(LoRAManager)
|
||||
manager.device = torch.device("cuda:0")
|
||||
manager.pending_lora_load_events = {}
|
||||
manager.memory_pool = MagicMock()
|
||||
manager.configs = {"lora_A": object()}
|
||||
manager.loras = {"lora_A": object()}
|
||||
lora_ref = MagicMock()
|
||||
lora_ref.lora_id = "lora_A"
|
||||
lora_ref.lora_name = "lora_A"
|
||||
lora_ref.lora_path = "/tmp/lora_A"
|
||||
lora_ref.pinned = False
|
||||
manager.lora_refs = {"lora_A": lora_ref}
|
||||
manager.num_pinned_loras = 0
|
||||
manager.lora_modules = []
|
||||
|
||||
order = []
|
||||
event = self._create_mock_event(False)
|
||||
event.synchronize.side_effect = lambda: order.append("synchronize")
|
||||
manager.memory_pool.remove_lora.side_effect = lambda _uid: (
|
||||
order.append("remove") or 0
|
||||
)
|
||||
loader = LoRAOverlapLoader(manager)
|
||||
loader.lora_to_overlap_load_event["lora_A"] = event
|
||||
|
||||
result = manager.unload_lora_adapter(lora_ref)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(order, ["synchronize", "remove"])
|
||||
event.synchronize.assert_called_once_with()
|
||||
self.assertNotIn("lora_A", manager.pending_lora_load_events)
|
||||
|
||||
def test_full_lifecycle_single_lora_load(self):
|
||||
loader = self._create_loader()
|
||||
|
||||
|
||||
@@ -396,5 +396,45 @@ class TestCreateGrammarBackend(unittest.TestCase):
|
||||
self.assertIsNone(kwargs["model_eos_token_ids"])
|
||||
|
||||
|
||||
class TestLlguidanceStructuralTagTriggerPairing(unittest.TestCase):
|
||||
"""Bug regression: dispatch_structural_tag paired EVERY structure with
|
||||
triggers[0]. Detectors with per-tool triggers (Inkling emits
|
||||
<|message_model|>{name}<|content_invoke_tool_json|> per tool) produce
|
||||
multiple distinct triggers, and llguidance's StructTag asserts
|
||||
begin.startswith(trigger) — so any multi-tool constrained request
|
||||
compiled to InvalidGrammarObject."""
|
||||
|
||||
def test_each_structure_pairs_with_its_own_trigger(self):
|
||||
import json
|
||||
|
||||
from sglang.srt.constrained.llguidance_backend import GuidanceBackend
|
||||
|
||||
backend = object.__new__(GuidanceBackend)
|
||||
backend._from_serialized = lambda serialized: serialized
|
||||
begins = [
|
||||
'<|message_model|>alpha<|content_invoke_tool_json|>{"name":"alpha","args":',
|
||||
'<|message_model|>beta<|content_invoke_tool_json|>{"name":"beta","args":',
|
||||
]
|
||||
key = json.dumps(
|
||||
{
|
||||
"type": "structural_tag",
|
||||
"structures": [
|
||||
{
|
||||
"begin": begin,
|
||||
"schema": {"type": "object"},
|
||||
"end": "<|end_message|>",
|
||||
}
|
||||
for begin in begins
|
||||
],
|
||||
"triggers": [
|
||||
"<|message_model|>alpha<|content_invoke_tool_json|>",
|
||||
"<|message_model|>beta<|content_invoke_tool_json|>",
|
||||
],
|
||||
}
|
||||
)
|
||||
result = backend.dispatch_structural_tag(key)
|
||||
self.assertNotIsInstance(result, InvalidGrammarObject)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -259,19 +259,27 @@ class TestChatCompletionRequest(unittest.TestCase):
|
||||
self.assertFalse(request.chat_template_kwargs.get("thinking"))
|
||||
self.assertFalse(request.chat_template_kwargs.get("enable_thinking"))
|
||||
|
||||
def test_chat_completion_reasoning_effort_max(self):
|
||||
"""`max` is an sglang extension on chat completion's top-level
|
||||
`reasoning_effort` only; the Responses-API-style nested
|
||||
`reasoning.effort` path stays aligned with OpenAI's three levels."""
|
||||
def test_chat_completion_extended_reasoning_effort_levels(self):
|
||||
"""Extended effort levels work in both supported request forms."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=messages,
|
||||
reasoning_effort="max",
|
||||
)
|
||||
self.assertEqual(request.reasoning_effort, "max")
|
||||
for effort in ("xhigh", "max"):
|
||||
with self.subTest(effort=effort, request_form="top-level"):
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=messages,
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
self.assertEqual(request.reasoning_effort, effort)
|
||||
|
||||
with self.subTest(effort=effort, request_form="nested"):
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=messages,
|
||||
reasoning={"effort": effort},
|
||||
)
|
||||
self.assertEqual(request.reasoning_effort, effort)
|
||||
|
||||
# Unknown values still rejected.
|
||||
with self.assertRaises(ValidationError):
|
||||
@@ -281,14 +289,79 @@ class TestChatCompletionRequest(unittest.TestCase):
|
||||
reasoning_effort="ultra",
|
||||
)
|
||||
|
||||
# Nested reasoning.effort=max is NOT promoted by normalize_reasoning_inputs:
|
||||
# the Responses API path keeps the OpenAI low/medium/high contract.
|
||||
def test_chat_completion_reasoning_effort_is_strictly_validated(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
for request_kwargs, expected in (
|
||||
({"reasoning_effort": 0.99}, 0.99),
|
||||
({"reasoning": {"effort": 0.0}}, 0.0),
|
||||
# numeric strings coerce identically on BOTH request surfaces
|
||||
# (the top-level field's lax union already coerced them).
|
||||
({"reasoning": {"effort": "0.5"}}, 0.5),
|
||||
({"reasoning": {"effort": None, "reasoning_effort": 0.4}}, 0.4),
|
||||
):
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model", messages=messages, **request_kwargs
|
||||
)
|
||||
self.assertEqual(request.reasoning_effort, expected)
|
||||
|
||||
for request_kwargs in (
|
||||
{"reasoning_effort": -0.1},
|
||||
# 0.99 is the maximum valid effort; 1.0 is out of range.
|
||||
{"reasoning_effort": 1.0},
|
||||
{"reasoning_effort": 1.1},
|
||||
{"reasoning_effort": float("nan")},
|
||||
{"reasoning_effort": True},
|
||||
{"reasoning": {"effort": "invalid"}},
|
||||
{"reasoning": {"effort": 1.0}},
|
||||
{"reasoning": {"effort": 1.1}},
|
||||
{"reasoning": {"effort": "1.5"}},
|
||||
):
|
||||
with self.subTest(request_kwargs=request_kwargs), self.assertRaises(
|
||||
ValidationError
|
||||
):
|
||||
ChatCompletionRequest(
|
||||
model="test-model", messages=messages, **request_kwargs
|
||||
)
|
||||
|
||||
def test_chat_completion_accepts_ordered_thinking_parts(self):
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=messages,
|
||||
reasoning={"effort": "max"},
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "first"},
|
||||
{"type": "text", "text": "visible"},
|
||||
{"type": "reasoning", "text": "second"},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertNotEqual(request.reasoning_effort, "max")
|
||||
parts = request.messages[0].content
|
||||
self.assertEqual(
|
||||
[part.type for part in parts], ["thinking", "text", "reasoning"]
|
||||
)
|
||||
|
||||
def test_chat_completion_rejects_thinking_parts_outside_assistant(self):
|
||||
"""Bug regression: adding the thinking part to the SHARED content-part
|
||||
union silently widened acceptance to every role (user/system/tool) and
|
||||
every model family, where downstream templates cannot render it —
|
||||
replacing the previous clean 422 with template-dependent behavior."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
for role in ("user", "system", "tool"):
|
||||
with self.subTest(role=role), self.assertRaises(ValidationError):
|
||||
ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[
|
||||
{
|
||||
"role": role,
|
||||
"content": [{"type": "thinking", "thinking": "x"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_chat_completion_json_format(self):
|
||||
"""Test chat completion json format"""
|
||||
|
||||
@@ -2325,6 +2325,29 @@ class TestProcessToolCallsWithRequiredToolChoice(unittest.TestCase):
|
||||
self.assertEqual(tool_calls[0].function.name, "get_weather")
|
||||
self.assertEqual(fr["type"], "tool_calls")
|
||||
|
||||
def test_empty_parser_result_is_not_reported_as_tool_call(self):
|
||||
with patch(
|
||||
"sglang.srt.entrypoints.openai.serving_chat.FunctionCallParser"
|
||||
) as ParserMock:
|
||||
parser_instance = ParserMock.return_value
|
||||
parser_instance.has_tool_call.return_value = True
|
||||
parser_instance.detector.supports_structural_tag.return_value = True
|
||||
parser_instance.parse_non_stream.return_value = ("Visible prefix.", [])
|
||||
|
||||
finish_reason = {"type": "stop", "matched": None}
|
||||
tools = [{"type": "function", "function": {"name": "get_weather"}}]
|
||||
|
||||
tool_calls, text, fr = self.chat._process_tool_calls(
|
||||
text="<|malformed_tool_call|>",
|
||||
tools=tools,
|
||||
finish_reason=finish_reason,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
self.assertIsNone(tool_calls)
|
||||
self.assertEqual(text, "Visible prefix.")
|
||||
self.assertEqual(fr, {"type": "stop", "matched": None})
|
||||
|
||||
def test_required_without_parser_falls_back_to_json(self):
|
||||
"""tool_choice='required' without parser should parse as JSON array."""
|
||||
self.chat.tool_call_parser = None
|
||||
@@ -2393,5 +2416,156 @@ class TestNormalizeToolContent(unittest.TestCase):
|
||||
self.assertEqual(result, "plain rich")
|
||||
|
||||
|
||||
class InklingReasoningEffortTest(unittest.TestCase):
|
||||
"""Inkling reasoning-effort mapping and validation."""
|
||||
|
||||
def test_named_levels(self):
|
||||
parse = OpenAIServingChat._parse_inkling_reasoning_effort
|
||||
self.assertEqual(parse("none"), 0.0)
|
||||
self.assertEqual(parse("minimal"), 0.1)
|
||||
self.assertEqual(parse("low"), 0.2)
|
||||
self.assertEqual(parse("medium"), 0.7)
|
||||
self.assertEqual(parse("high"), 0.9)
|
||||
# "xhigh" and "max" are aliases for the same 0.99 ceiling
|
||||
self.assertEqual(parse("xhigh"), 0.99)
|
||||
self.assertEqual(parse("max"), 0.99)
|
||||
self.assertEqual(parse("max"), parse("xhigh"))
|
||||
|
||||
def test_scalar_range_is_validated(self):
|
||||
parse = OpenAIServingChat._parse_inkling_reasoning_effort
|
||||
self.assertEqual(parse(0.5), 0.5)
|
||||
self.assertEqual(parse(0.99), 0.99)
|
||||
for value in (1.0, "1.0", 2.0, "1.5", -1.0, float("nan"), True):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
parse(value)
|
||||
|
||||
def test_invalid_and_none(self):
|
||||
parse = OpenAIServingChat._parse_inkling_reasoning_effort
|
||||
self.assertIsNone(parse(None))
|
||||
with self.assertRaises(ValueError):
|
||||
parse("garbage")
|
||||
|
||||
def test_env_default(self):
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
get = OpenAIServingChat._get_inkling_default_reasoning_effort
|
||||
env = envs.SGLANG_INKLING_DEFAULT_REASONING_EFFORT
|
||||
try:
|
||||
env.clear() # unset -> EnvStr default "0.9"
|
||||
self.assertEqual(get(), 0.9)
|
||||
env.set("") # explicit empty still uses the protocol default
|
||||
self.assertEqual(get(), 0.9)
|
||||
env.set("0.7")
|
||||
self.assertEqual(get(), 0.7)
|
||||
for value in ("1.0", "1.1", "garbage"):
|
||||
env.set(value)
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
get()
|
||||
finally:
|
||||
env.clear()
|
||||
|
||||
def test_serving_does_not_prefill_model_message(self):
|
||||
from sglang.srt.parser.inkling_tokenizer import INKLING_SPECIAL_TOKEN_IDS
|
||||
|
||||
class Tokenizer:
|
||||
def encode(self, text, add_special_tokens=False):
|
||||
return list(text.encode())
|
||||
|
||||
serving = object.__new__(OpenAIServingChat)
|
||||
serving.chat_encoding_spec = "inkling"
|
||||
serving.tokenizer_manager = Mock(tokenizer=Tokenizer())
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
reasoning_effort=0.5,
|
||||
)
|
||||
prompt_ids = serving._encode_messages(
|
||||
[message.model_dump() for message in request.messages],
|
||||
request,
|
||||
thinking_mode=None,
|
||||
)
|
||||
self.assertEqual(prompt_ids[-1], INKLING_SPECIAL_TOKEN_IDS["<|end_message|>"])
|
||||
|
||||
def test_continue_final_message_resumes_open_model_text_block(self):
|
||||
"""Bug regression: continue_final_message was silently ignored on the
|
||||
inkling path — the trailing assistant message rendered as a CLOSED
|
||||
historical turn (<|end_message|> + <|content_model_end_sampling|>), so
|
||||
the model started a fresh turn instead of continuing. The prefix must
|
||||
render as an OPEN model text block."""
|
||||
from sglang.srt.parser.inkling_tokenizer import INKLING_SPECIAL_TOKEN_IDS
|
||||
|
||||
class Tokenizer:
|
||||
def encode(self, text, add_special_tokens=False):
|
||||
return list(text.encode())
|
||||
|
||||
serving = object.__new__(OpenAIServingChat)
|
||||
serving.chat_encoding_spec = "inkling"
|
||||
serving.tokenizer_manager = Mock(tokenizer=Tokenizer())
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "The answer"},
|
||||
],
|
||||
reasoning_effort=0.5,
|
||||
continue_final_message=True,
|
||||
)
|
||||
prompt_ids = serving._encode_messages(
|
||||
[message.model_dump() for message in request.messages],
|
||||
request,
|
||||
thinking_mode=None,
|
||||
)
|
||||
open_block = [
|
||||
INKLING_SPECIAL_TOKEN_IDS["<|message_model|>"],
|
||||
INKLING_SPECIAL_TOKEN_IDS["<|content_text|>"],
|
||||
*list(b"The answer"),
|
||||
]
|
||||
self.assertEqual(prompt_ids[-len(open_block) :], open_block)
|
||||
self.assertNotIn(
|
||||
INKLING_SPECIAL_TOKEN_IDS["<|content_model_end_sampling|>"], prompt_ids
|
||||
)
|
||||
|
||||
def test_continue_final_message_leaves_tool_call_turns_closed(self):
|
||||
"""A trailing assistant message with tool_calls cannot be continued —
|
||||
it must keep rendering as a closed historical turn."""
|
||||
from sglang.srt.parser.inkling_tokenizer import INKLING_SPECIAL_TOKEN_IDS
|
||||
|
||||
class Tokenizer:
|
||||
def encode(self, text, add_special_tokens=False):
|
||||
return list(text.encode())
|
||||
|
||||
serving = object.__new__(OpenAIServingChat)
|
||||
serving.chat_encoding_spec = "inkling"
|
||||
serving.tokenizer_manager = Mock(tokenizer=Tokenizer())
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[
|
||||
{"role": "user", "content": "hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "calling",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
reasoning_effort=0.5,
|
||||
continue_final_message=True,
|
||||
)
|
||||
prompt_ids = serving._encode_messages(
|
||||
[message.model_dump() for message in request.messages],
|
||||
request,
|
||||
thinking_mode=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt_ids[-1],
|
||||
INKLING_SPECIAL_TOKEN_IDS["<|content_model_end_sampling|>"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -22,6 +22,7 @@ from sglang.srt.function_call.gigachat3_detector import GigaChat3Detector
|
||||
from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector
|
||||
from sglang.srt.function_call.glm47_moe_detector import Glm47MoeDetector
|
||||
from sglang.srt.function_call.gpt_oss_detector import GptOssDetector
|
||||
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
|
||||
@@ -35,6 +36,249 @@ register_cpu_ci(est_time=15, suite="base-a-test-cpu")
|
||||
register_cpu_ci(est_time=61, suite="base-c-test-cpu")
|
||||
|
||||
|
||||
class TestInklingDetector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tools = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="weather",
|
||||
description="Lookup weather",
|
||||
parameters={"type": "object"},
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def test_canonical_header_is_not_visible_content(self):
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{"city":"SF"}}<|end_message|>'
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertEqual(result.normal_text, "")
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "weather")
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"city": "SF"})
|
||||
|
||||
def test_streaming_header_is_buffered_until_the_tool_kind(self):
|
||||
detector = InklingDetector()
|
||||
chunks = [
|
||||
"<|message_model|>",
|
||||
"weat",
|
||||
"her",
|
||||
"<|content_invoke_tool_json|>",
|
||||
'{"name":"weather",',
|
||||
'"args":{"city":"SF"}}',
|
||||
"<|end_message|>",
|
||||
]
|
||||
normal_text = ""
|
||||
name = None
|
||||
parameters = ""
|
||||
for chunk in chunks:
|
||||
result = detector.parse_streaming_increment(chunk, self.tools)
|
||||
normal_text += result.normal_text
|
||||
for call in result.calls:
|
||||
name = call.name or name
|
||||
parameters += call.parameters
|
||||
self.assertEqual(normal_text, "")
|
||||
self.assertEqual(name, "weather")
|
||||
self.assertEqual(json.loads(parameters), {"city": "SF"})
|
||||
|
||||
def test_mismatched_header_is_rejected(self):
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>other<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{}}<|end_message|>'
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertEqual(result.calls, [])
|
||||
|
||||
def test_rejected_call_does_not_leak_protocol_tokens(self):
|
||||
"""Bug regression: the no-surviving-calls path returned the RAW text,
|
||||
so a rejected call (e.g. header/payload mismatch) leaked <|...|>
|
||||
protocol tokens into user-visible content."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>other<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{}}<|end_message|>'
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertNotIn("<|", result.normal_text)
|
||||
# Framework parity: the rejected tool-call REGION is dropped entirely
|
||||
# (normal_text = content before the marker), like every other detector
|
||||
# — the JSON payload must not surface as visible content either.
|
||||
self.assertEqual(result.normal_text, "")
|
||||
|
||||
def test_headerless_legacy_tool_call_still_parses(self):
|
||||
"""Spec tolerance: a bare <|content_invoke_tool_json|> block with no
|
||||
<|message_model|>name header (the pre-canonical form) must keep
|
||||
parsing in both modes."""
|
||||
source = '<|content_invoke_tool_json|>{"name":"weather","args":{"city":"SF"}}<|end_message|>'
|
||||
result = InklingDetector().detect_and_parse(source, self.tools)
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "weather")
|
||||
|
||||
streaming = InklingDetector()
|
||||
name = None
|
||||
parameters = ""
|
||||
for char in source:
|
||||
for call in streaming.parse_streaming_increment(char, self.tools).calls:
|
||||
name = call.name or name
|
||||
parameters += call.parameters
|
||||
self.assertEqual(name, "weather")
|
||||
self.assertEqual(json.loads(parameters), {"city": "SF"})
|
||||
|
||||
def test_streaming_two_sequential_tool_calls_get_distinct_indices(self):
|
||||
"""Coverage for multi-call responses: two back-to-back canonical tool
|
||||
calls must stream as tool_index 0 and 1 with per-call args."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{"city":"SF"}}<|end_message|>'
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{"city":"NY"}}<|end_message|>'
|
||||
)
|
||||
args_by_index: dict = {}
|
||||
for char in source:
|
||||
for call in detector.parse_streaming_increment(char, self.tools).calls:
|
||||
args_by_index[call.tool_index] = (
|
||||
args_by_index.get(call.tool_index, "") + call.parameters
|
||||
)
|
||||
self.assertEqual(sorted(args_by_index), [0, 1])
|
||||
self.assertEqual(json.loads(args_by_index[0]), {"city": "SF"})
|
||||
self.assertEqual(json.loads(args_by_index[1]), {"city": "NY"})
|
||||
|
||||
def test_streaming_rejection_does_not_collide_tool_indices(self):
|
||||
"""Bug regression: a rejected mid-stream call reset current_tool_id to
|
||||
-1, so the NEXT valid call re-announced as tool_index 0 — colliding
|
||||
with the first call's index and slicing its arguments against index
|
||||
0's already-streamed args."""
|
||||
detector = InklingDetector()
|
||||
chunks = [
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>",
|
||||
'{"name":"weather","args":{"city":"SF"}}<|end_message|>',
|
||||
# header/payload mismatch -> rejected
|
||||
"<|message_model|>other<|content_invoke_tool_json|>",
|
||||
'{"name":"weather","args":{"city":"NY"}}<|end_message|>',
|
||||
# valid again
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>",
|
||||
'{"name":"weather","args":{"city":"LA"}}<|end_message|>',
|
||||
]
|
||||
args_by_index: dict = {}
|
||||
for chunk in chunks:
|
||||
for call in detector.parse_streaming_increment(chunk, self.tools).calls:
|
||||
args_by_index[call.tool_index] = (
|
||||
args_by_index.get(call.tool_index, "") + call.parameters
|
||||
)
|
||||
self.assertEqual(json.loads(args_by_index[0]), {"city": "SF"})
|
||||
self.assertEqual(len(args_by_index), 2)
|
||||
second_index = max(args_by_index)
|
||||
self.assertGreater(second_index, 0)
|
||||
self.assertEqual(json.loads(args_by_index[second_index]), {"city": "LA"})
|
||||
|
||||
def test_undeclared_tool_name_is_surfaced(self):
|
||||
"""A call to a tool absent from the request's tool list surfaces as a
|
||||
structured tool_call (OpenAI behavior for hallucinated tools) so agent
|
||||
harnesses can return a tool error and let the model self-correct,
|
||||
instead of the serialized invocation becoming terminal answer text."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>document_search<|content_invoke_tool_json|>"
|
||||
'{"name":"document_search","args":{"query":"q"}}<|end_message|>'
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "document_search")
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"query": "q"})
|
||||
self.assertNotIn("<|", result.normal_text)
|
||||
|
||||
def test_undeclared_tool_name_surfaces_in_streaming(self):
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>document_search<|content_invoke_tool_json|>"
|
||||
'{"name":"document_search","args":{"query":"q"}}<|end_message|>'
|
||||
)
|
||||
normal_text = ""
|
||||
name = None
|
||||
parameters = ""
|
||||
for char in source:
|
||||
result = detector.parse_streaming_increment(char, self.tools)
|
||||
normal_text += result.normal_text
|
||||
for call in result.calls:
|
||||
name = call.name or name
|
||||
parameters += call.parameters
|
||||
self.assertEqual(name, "document_search")
|
||||
self.assertEqual(json.loads(parameters), {"query": "q"})
|
||||
self.assertNotIn("<|", normal_text)
|
||||
|
||||
def test_malformed_json_does_not_leak_protocol_tokens(self):
|
||||
"""Malformed JSON must drop the protocol region and its tool header."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
"{not json at all<|end_message|>"
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertEqual(result.calls, [])
|
||||
self.assertEqual(result.normal_text, "")
|
||||
|
||||
def test_parser_does_not_restore_malformed_tool_call_as_text(self):
|
||||
"""The parser wrapper must preserve the detector's sanitized fallback."""
|
||||
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
||||
|
||||
source = (
|
||||
"Visible prefix."
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
"{not json at all<|end_message|>"
|
||||
)
|
||||
normal_text, calls = FunctionCallParser(self.tools, "inkling").parse_non_stream(
|
||||
source
|
||||
)
|
||||
self.assertEqual(normal_text, "Visible prefix.")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_parser_preserves_text_without_tool_call_marker(self):
|
||||
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
||||
|
||||
source = " Ordinary assistant text. "
|
||||
normal_text, calls = FunctionCallParser(self.tools, "inkling").parse_non_stream(
|
||||
source
|
||||
)
|
||||
self.assertEqual(normal_text, source)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_malformed_call_does_not_discard_an_earlier_valid_call(self):
|
||||
source = (
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{"city":"SF"}}<|end_message|>'
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
"{not json at all<|end_message|>"
|
||||
)
|
||||
result = InklingDetector().detect_and_parse(source, self.tools)
|
||||
self.assertEqual(result.normal_text, "")
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "weather")
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"city": "SF"})
|
||||
|
||||
def test_clean_normal_text_strips_the_full_control_alphabet(self):
|
||||
"""Fall-through text is cleaned against the whole shared control-token
|
||||
alphabet, not a hand-picked subset."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|><|content_thinking|>leak<|end_message|>"
|
||||
"<|message_user|>x<|content_audio_input|><|audio_end|>"
|
||||
)
|
||||
result = detector.detect_and_parse(source, self.tools)
|
||||
self.assertNotIn("<|", result.normal_text)
|
||||
|
||||
def test_structural_tag_uses_the_canonical_header(self):
|
||||
info = InklingDetector().structure_info()("weather")
|
||||
header = "<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
self.assertEqual(info.trigger, header)
|
||||
self.assertTrue(info.begin.startswith(header + '{"name":"weather"'))
|
||||
|
||||
|
||||
class TestPythonicDetector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create sample tools for testing
|
||||
@@ -3968,6 +4212,31 @@ class TestGetStructureConstraint(unittest.TestCase):
|
||||
result = parser.get_structure_constraint("auto")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_inkling_auto_constrains_json_after_tool_trigger(self):
|
||||
import xgrammar as xgr
|
||||
|
||||
from sglang.srt.parser.inkling_tokenizer import INKLING_SPECIAL_TOKEN_IDS
|
||||
|
||||
parser = self._make_parser("inkling", strict=False)
|
||||
result = parser.get_structure_constraint("auto")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
self.assertIsInstance(result[1], xgr.StructuralTag)
|
||||
format_ = result[1].model_dump()["format"]
|
||||
self.assertEqual(format_["type"], "token_triggered_tags")
|
||||
self.assertEqual(
|
||||
format_["trigger_tokens"],
|
||||
[INKLING_SPECIAL_TOKEN_IDS["<|content_invoke_tool_json|>"]],
|
||||
)
|
||||
tag = format_["tags"][0]
|
||||
self.assertEqual(
|
||||
tag["end"]["token"], INKLING_SPECIAL_TOKEN_IDS["<|end_message|>"]
|
||||
)
|
||||
schema = tag["content"]["json_schema"]
|
||||
self.assertEqual(schema["required"], ["name", "args"])
|
||||
self.assertFalse(schema["additionalProperties"])
|
||||
|
||||
def test_kimi_named_tool_choice_returns_structural_tag(self):
|
||||
from sglang.srt.entrypoints.openai.protocol import (
|
||||
ToolChoice,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""CUDA graph tests for multi-LoRA merged alignment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=15, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
ALIGN_PATH = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "python/sglang/kernels/ops/moe/trtllm_lora_temp/virtual_experts.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_align_function():
|
||||
tree = ast.parse(ALIGN_PATH.read_text())
|
||||
function = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_align_block_size_jit"
|
||||
)
|
||||
module = ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[]))
|
||||
namespace = {
|
||||
"torch": torch,
|
||||
"jit_moe_align_block_size": sys.modules[
|
||||
"sglang.jit_kernel.moe_align"
|
||||
].moe_align_block_size,
|
||||
}
|
||||
exec(compile(module, str(ALIGN_PATH), "exec"), namespace)
|
||||
return namespace["_align_block_size_jit"]
|
||||
|
||||
|
||||
def test_experimental_alignment_geometry_and_empty_input(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_jit_align(*args):
|
||||
(
|
||||
topk_ids,
|
||||
num_buckets,
|
||||
block_size,
|
||||
sorted_ids,
|
||||
expert_ids,
|
||||
total,
|
||||
cumsum,
|
||||
flag,
|
||||
) = args
|
||||
calls.append((num_buckets, cumsum.numel(), flag))
|
||||
sorted_ids.fill_(topk_ids.numel())
|
||||
expert_ids[:2] = torch.tensor([-1, num_buckets - 2])
|
||||
total.fill_(2 * block_size)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"sglang.jit_kernel.moe_align",
|
||||
types.SimpleNamespace(moe_align_block_size=fake_jit_align),
|
||||
)
|
||||
align = _load_align_function()
|
||||
|
||||
topk_ids = torch.tensor([[-1, 383]], dtype=torch.int32)
|
||||
sorted_ids, expert_ids, num_tokens_post_pad = align(topk_ids, 5, 384)
|
||||
|
||||
assert sorted_ids.numel() == 12 # int4-safe capacity above logical 10.
|
||||
assert expert_ids.numel() == 3
|
||||
assert calls == [(385, 386, True)]
|
||||
assert expert_ids[:2].tolist() == [-1, 383]
|
||||
assert num_tokens_post_pad.item() == 10
|
||||
|
||||
outputs = align(torch.empty((0, 6), dtype=torch.int32), 16, 384)
|
||||
assert [tensor.numel() for tensor in outputs] == [0, 0, 1]
|
||||
assert outputs[2].item() == 0
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
def test_experimental_alignment_cuda_sentinel_and_max_expert():
|
||||
from sglang.kernels.ops.moe.trtllm_lora_temp.virtual_experts import (
|
||||
_align_block_size_jit,
|
||||
)
|
||||
|
||||
topk_ids = torch.tensor([[-1, 383], [0, 0]], device="cuda", dtype=torch.int32)
|
||||
_, expert_ids, num_tokens_post_pad = _align_block_size_jit(topk_ids, 16, 384)
|
||||
active_experts = expert_ids[: num_tokens_post_pad.item() // 16].cpu().tolist()
|
||||
assert sorted(active_experts) == [-1, 0, 383]
|
||||
|
||||
|
||||
def _assert_shared_outer_merged_align_semantics(
|
||||
outputs: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, int],
|
||||
token_lora_mapping: torch.Tensor,
|
||||
*,
|
||||
topk: int,
|
||||
block_size: int,
|
||||
num_slots: int,
|
||||
) -> None:
|
||||
"""Validate routing without depending on atomic scatter order."""
|
||||
|
||||
(
|
||||
sorted_token_ids,
|
||||
expert_ids,
|
||||
num_tokens_post_padded,
|
||||
token_lora_mask,
|
||||
virtual_num_experts,
|
||||
) = outputs
|
||||
mapping = token_lora_mapping.cpu()
|
||||
num_routes = mapping.numel() * topk
|
||||
total_padded = int(num_tokens_post_padded.item())
|
||||
sorted_cpu = sorted_token_ids[:total_padded].cpu()
|
||||
experts_cpu = expert_ids[: total_padded // block_size].cpu()
|
||||
|
||||
assert virtual_num_experts == num_slots
|
||||
assert torch.equal(token_lora_mask.cpu(), mapping >= 0)
|
||||
assert (mapping == -1).any() # -1 is the runtime base/no-adapter sentinel.
|
||||
assert (mapping == 0).any() # Slot 0 remains a valid adapter slot.
|
||||
|
||||
expected_routes = []
|
||||
expected_total_padded = 0
|
||||
for slot in range(num_slots):
|
||||
slot_tokens = torch.nonzero(mapping == slot, as_tuple=False).flatten()
|
||||
slot_routes = (
|
||||
slot_tokens[:, None] * topk + torch.arange(topk)[None, :]
|
||||
).flatten()
|
||||
expected_routes.extend(slot_routes.tolist())
|
||||
route_count = slot_routes.numel()
|
||||
expected_total_padded += (
|
||||
(route_count + block_size - 1) // block_size
|
||||
) * block_size
|
||||
|
||||
assert total_padded == expected_total_padded
|
||||
observed_routes = []
|
||||
for block, slot in enumerate(experts_cpu.tolist()):
|
||||
assert 0 <= slot < num_slots
|
||||
block_routes = sorted_cpu[block * block_size : (block + 1) * block_size]
|
||||
real_routes = block_routes[block_routes < num_routes].to(torch.long)
|
||||
if real_routes.numel():
|
||||
routed_tokens = torch.div(real_routes, topk, rounding_mode="floor")
|
||||
assert torch.all(mapping[routed_tokens] == slot)
|
||||
observed_routes.extend(real_routes.tolist())
|
||||
assert torch.all((block_routes >= 0) & (block_routes <= num_routes))
|
||||
|
||||
assert sorted(observed_routes) == sorted(expected_routes)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
@pytest.mark.parametrize("num_slots", [2, 3, 4])
|
||||
def test_multi_slot_shared_outer_merged_align_cuda_graph_parity(num_slots):
|
||||
"""The fused hot path must replay with current multi-LoRA routing data."""
|
||||
|
||||
from sglang.jit_kernel.trtllm_lora_temp.moe_lora_merged_align import (
|
||||
moe_lora_merged_align,
|
||||
)
|
||||
|
||||
device = torch.device("cuda")
|
||||
num_tokens = 37 # Exercise the multi-LoRA prefill-size routing contract.
|
||||
topk = 6
|
||||
block_size = 16
|
||||
num_experts = 384
|
||||
generator = torch.Generator(device=device).manual_seed(9000 + num_slots)
|
||||
topk_ids = torch.randint(
|
||||
0,
|
||||
num_experts,
|
||||
(num_tokens, topk),
|
||||
device=device,
|
||||
dtype=torch.int32,
|
||||
generator=generator,
|
||||
)
|
||||
token_lora_mapping = torch.arange(
|
||||
num_tokens, device=device, dtype=torch.int32
|
||||
).remainder(num_slots)
|
||||
token_lora_mapping[0] = -1
|
||||
token_lora_mapping[-1] = -1
|
||||
|
||||
def invoke(fuse_scatter: bool):
|
||||
return moe_lora_merged_align(
|
||||
topk_ids,
|
||||
token_lora_mapping,
|
||||
num_experts,
|
||||
shared_outer=True,
|
||||
max_loras=num_slots,
|
||||
block_size=block_size,
|
||||
do_skip=True,
|
||||
fuse_scatter=fuse_scatter,
|
||||
)
|
||||
|
||||
# Compile both real kernel variants and initialize CUDA state off the
|
||||
# capture stream. Production selects the fused variant for this geometry.
|
||||
warmup_stream = torch.cuda.Stream()
|
||||
warmup_stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup_stream):
|
||||
for _ in range(2):
|
||||
invoke(fuse_scatter=True)
|
||||
invoke(fuse_scatter=False)
|
||||
torch.cuda.current_stream().wait_stream(warmup_stream)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
fused_outputs = invoke(fuse_scatter=True)
|
||||
split_outputs = invoke(fuse_scatter=False)
|
||||
|
||||
stable_outputs = (*fused_outputs[:4], *split_outputs[:4])
|
||||
stable_addresses = tuple(tensor.data_ptr() for tensor in stable_outputs)
|
||||
|
||||
for replay in range(3):
|
||||
if replay:
|
||||
next_mapping = (
|
||||
torch.arange(num_tokens, device=device, dtype=torch.int32)
|
||||
.add_(replay)
|
||||
.remainder_(num_slots)
|
||||
)
|
||||
# Move the base/no-adapter rows on every replay while retaining a
|
||||
# valid adapter in slot 0.
|
||||
next_mapping[replay] = -1
|
||||
next_mapping[-replay - 1] = -1
|
||||
token_lora_mapping.copy_(next_mapping)
|
||||
topk_ids.copy_(torch.roll(topk_ids, shifts=1, dims=1))
|
||||
|
||||
for outputs in (fused_outputs, split_outputs):
|
||||
outputs[0].fill_(-12345)
|
||||
outputs[1].fill_(-12345)
|
||||
outputs[2].fill_(-1)
|
||||
outputs[3].fill_(False)
|
||||
graph.replay()
|
||||
|
||||
torch.cuda.synchronize()
|
||||
assert tuple(tensor.data_ptr() for tensor in stable_outputs) == stable_addresses
|
||||
for outputs in (fused_outputs, split_outputs):
|
||||
_assert_shared_outer_merged_align_semantics(
|
||||
outputs,
|
||||
token_lora_mapping,
|
||||
topk=topk,
|
||||
block_size=block_size,
|
||||
num_slots=num_slots,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,270 @@
|
||||
"""CUDA graph and numerical tests for direct Inkling decode LoRA kernels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=20, stage="base-c", runner_config="4-gpu-b200")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
_B200_AVAILABLE = bool(
|
||||
torch.cuda.is_available()
|
||||
and torch.version.hip is None
|
||||
and torch.cuda.get_device_capability()[0] == 10
|
||||
)
|
||||
|
||||
E = 256
|
||||
TOPK = 6
|
||||
RANK = 32
|
||||
DTYPE = torch.bfloat16
|
||||
|
||||
|
||||
def _make_topk_ids(
|
||||
num_tokens: int, *, device: torch.device, offset: int
|
||||
) -> torch.Tensor:
|
||||
tokens = torch.arange(num_tokens, device=device, dtype=torch.int32)[:, None]
|
||||
routes = torch.arange(TOPK, device=device, dtype=torch.int32)[None, :]
|
||||
topk_ids = (tokens * 11 + routes * 17 + offset).remainder(E - 1)
|
||||
topk_ids[:, 0] = E - 1
|
||||
return topk_ids.contiguous()
|
||||
|
||||
|
||||
def _gate_reference(
|
||||
shared: torch.Tensor,
|
||||
gate_b: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
token_lora_mapping: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
intermediate = gate_b.shape[2] // 2
|
||||
gate_width = gate_b.shape[2]
|
||||
flat_ids = topk_ids.reshape(-1).to(torch.long)
|
||||
flat_slots = token_lora_mapping[:, None].expand(-1, TOPK).reshape(-1).to(torch.long)
|
||||
active = flat_slots >= 0
|
||||
routed_b = gate_b[flat_slots.clamp_min(0), flat_ids].to(torch.float32)
|
||||
if shared.ndim == 3:
|
||||
token = torch.arange(shared.shape[1], device=shared.device)
|
||||
selected_shared = shared[token_lora_mapping.clamp_min(0).long(), token]
|
||||
else:
|
||||
selected_shared = shared
|
||||
routed_shared = (
|
||||
selected_shared[:, None, :].expand(-1, TOPK, -1).reshape(-1, 2 * RANK)
|
||||
).float()
|
||||
gate = torch.bmm(routed_b[:, :intermediate], routed_shared[:, :RANK, None]).squeeze(
|
||||
-1
|
||||
)
|
||||
up = torch.bmm(routed_b[:, intermediate:], routed_shared[:, RANK:, None]).squeeze(
|
||||
-1
|
||||
)
|
||||
result = torch.cat((gate, up), dim=1)
|
||||
result[~active] = 0
|
||||
return result.view(topk_ids.shape[0], TOPK, gate_width)
|
||||
|
||||
|
||||
def _down_reference(
|
||||
activation: torch.Tensor,
|
||||
down_a: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
token_lora_mapping: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
flat_ids = topk_ids.reshape(-1).to(torch.long)
|
||||
flat_slots = token_lora_mapping[:, None].expand(-1, TOPK).reshape(-1).to(torch.long)
|
||||
active = flat_slots >= 0
|
||||
routed_a = down_a[flat_slots.clamp_min(0), flat_ids].to(torch.float32)
|
||||
result = torch.bmm(routed_a, activation.to(torch.float32).unsqueeze(-1)).squeeze(-1)
|
||||
result[~active] = 0
|
||||
return result.view(topk_ids.shape[0], TOPK, RANK)
|
||||
|
||||
|
||||
def _make_operands(
|
||||
num_tokens: int, intermediate: int, num_slots: int, device: torch.device
|
||||
):
|
||||
gate_width = 2 * intermediate
|
||||
generator = torch.Generator(device=device).manual_seed(9000 + num_tokens)
|
||||
shared_shape = (
|
||||
(num_slots, num_tokens, RANK) if num_slots > 1 else (num_tokens, RANK)
|
||||
)
|
||||
gate_half = torch.randn(
|
||||
shared_shape, device=device, dtype=DTYPE, generator=generator
|
||||
)
|
||||
# Deliberately unrelated halves regression-protect the gated split.
|
||||
up_half = (
|
||||
torch.randn(shared_shape, device=device, dtype=DTYPE, generator=generator)
|
||||
* -0.75
|
||||
+ 0.25
|
||||
)
|
||||
shared = torch.cat((gate_half, up_half), dim=-1).contiguous()
|
||||
gate_b = (
|
||||
torch.randn(
|
||||
(num_slots, E, gate_width, RANK),
|
||||
device=device,
|
||||
dtype=DTYPE,
|
||||
generator=generator,
|
||||
)
|
||||
/ RANK**0.5
|
||||
).contiguous()
|
||||
activation = torch.randn(
|
||||
(num_tokens * TOPK, intermediate),
|
||||
device=device,
|
||||
dtype=DTYPE,
|
||||
generator=generator,
|
||||
)
|
||||
down_a = (
|
||||
torch.randn(
|
||||
(num_slots, E, RANK, intermediate),
|
||||
device=device,
|
||||
dtype=DTYPE,
|
||||
generator=generator,
|
||||
)
|
||||
/ intermediate**0.5
|
||||
).contiguous()
|
||||
topk_ids = _make_topk_ids(num_tokens, device=device, offset=0)
|
||||
token_lora_mapping = torch.arange(
|
||||
num_tokens, device=device, dtype=torch.int32
|
||||
).remainder(num_slots)
|
||||
if num_tokens > 1:
|
||||
token_lora_mapping[-1] = -1
|
||||
gate_output = torch.empty(
|
||||
(num_tokens, TOPK, gate_width), device=device, dtype=DTYPE
|
||||
)
|
||||
down_output = torch.empty((num_tokens, TOPK, RANK), device=device, dtype=DTYPE)
|
||||
return (
|
||||
shared,
|
||||
gate_b,
|
||||
activation,
|
||||
down_a,
|
||||
topk_ids,
|
||||
token_lora_mapping,
|
||||
gate_output,
|
||||
down_output,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _B200_AVAILABLE,
|
||||
reason="direct Inkling decode kernels are currently gated to B200",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("num_slots", "num_tokens", "intermediate"),
|
||||
[(1, 1, 384), (2, 4, 768), (3, 4, 384), (4, 32, 768)],
|
||||
)
|
||||
def test_direct_decode_cuda_graph_replay_and_base_weights(
|
||||
num_tokens: int, intermediate: int, num_slots: int
|
||||
):
|
||||
from sglang.srt.lora.marlin_lora_temp.direct_decode import (
|
||||
direct_decode_down_shrink,
|
||||
direct_decode_gate_expand,
|
||||
)
|
||||
|
||||
device = torch.device("cuda")
|
||||
(
|
||||
shared,
|
||||
gate_b,
|
||||
activation,
|
||||
down_a,
|
||||
topk_ids,
|
||||
token_lora_mapping,
|
||||
gate_output,
|
||||
down_output,
|
||||
) = _make_operands(num_tokens, intermediate, num_slots, device)
|
||||
|
||||
def invoke() -> None:
|
||||
direct_decode_gate_expand(
|
||||
shared, gate_b, topk_ids, token_lora_mapping, gate_output
|
||||
)
|
||||
direct_decode_down_shrink(
|
||||
activation, down_a, topk_ids, token_lora_mapping, down_output
|
||||
)
|
||||
|
||||
# Compile and initialize CUDA state outside capture.
|
||||
warmup_stream = torch.cuda.Stream()
|
||||
warmup_stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup_stream):
|
||||
for _ in range(3):
|
||||
invoke()
|
||||
torch.cuda.current_stream().wait_stream(warmup_stream)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert int(topk_ids[0, 0]) == 255
|
||||
torch.testing.assert_close(
|
||||
gate_output.float(),
|
||||
_gate_reference(shared, gate_b, topk_ids, token_lora_mapping),
|
||||
rtol=0.03,
|
||||
atol=0.01,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
down_output.float(),
|
||||
_down_reference(activation, down_a, topk_ids, token_lora_mapping),
|
||||
rtol=0.03,
|
||||
atol=0.01,
|
||||
)
|
||||
|
||||
stable_tensors = (
|
||||
shared,
|
||||
gate_b,
|
||||
activation,
|
||||
down_a,
|
||||
topk_ids,
|
||||
token_lora_mapping,
|
||||
gate_output,
|
||||
down_output,
|
||||
)
|
||||
stable_addresses = tuple(tensor.data_ptr() for tensor in stable_tensors)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
invoke()
|
||||
|
||||
# Mutate every captured input in place. Replay must follow stable addresses
|
||||
# and read the new expert ids and values, including expert 255.
|
||||
shared.mul_(-0.5).add_(0.125)
|
||||
gate_b.mul_(0.75).add_(0.001)
|
||||
activation.mul_(0.625).sub_(0.03125)
|
||||
down_a.mul_(-0.875).add_(0.0005)
|
||||
topk_ids.copy_(_make_topk_ids(num_tokens, device=device, offset=29))
|
||||
token_lora_mapping.copy_((token_lora_mapping + 1).remainder(num_slots))
|
||||
if num_tokens > 1:
|
||||
token_lora_mapping[0] = -1
|
||||
gate_output.fill_(float("nan"))
|
||||
down_output.fill_(float("nan"))
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert tuple(tensor.data_ptr() for tensor in stable_tensors) == stable_addresses
|
||||
assert int(topk_ids[0, 0]) == 255
|
||||
torch.testing.assert_close(
|
||||
gate_output.float(),
|
||||
_gate_reference(shared, gate_b, topk_ids, token_lora_mapping),
|
||||
rtol=0.03,
|
||||
atol=0.01,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
down_output.float(),
|
||||
_down_reference(activation, down_a, topk_ids, token_lora_mapping),
|
||||
rtol=0.03,
|
||||
atol=0.01,
|
||||
)
|
||||
|
||||
# Base/None replay retains the same captured pointers and zeroes the loaded
|
||||
# adapter weights in place. Both kernels must fully overwrite their output.
|
||||
gate_b.zero_()
|
||||
down_a.zero_()
|
||||
gate_output.fill_(float("nan"))
|
||||
down_output.fill_(float("nan"))
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert tuple(tensor.data_ptr() for tensor in stable_tensors) == stable_addresses
|
||||
assert torch.count_nonzero(gate_output).item() == 0
|
||||
assert torch.count_nonzero(down_output).item() == 0
|
||||
assert torch.isfinite(gate_output).all().item()
|
||||
assert torch.isfinite(down_output).all().item()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,654 @@
|
||||
"""CUDA parity for the multi-slot shared-outer Marlin prefill factorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=45, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# The fused MoE LoRA-add kernel's shared-memory footprint exceeds the opt-in
|
||||
# ceiling of the small-GPU CI runner (~99 KiB on L4) at rank=128, so the
|
||||
# generic-fallback parity case OOMs there. Skip this file on CI rather than
|
||||
# shrink the production kernel to a small-GPU block config.
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason="fused MoE LoRA-add kernel needs more opt-in shared memory than the "
|
||||
"small-GPU CI runner provides"
|
||||
)
|
||||
|
||||
|
||||
_CUDA_BF16_AVAILABLE = bool(
|
||||
torch.cuda.is_available()
|
||||
and torch.version.hip is None
|
||||
and torch.cuda.get_device_capability()[0] >= 8
|
||||
)
|
||||
|
||||
|
||||
def _set_mapping(mapping: torch.Tensor, num_slots: int, offset: int) -> None:
|
||||
"""Select active slots while exercising both no-adapter encodings."""
|
||||
|
||||
rows = torch.arange(mapping.numel(), dtype=torch.int32)
|
||||
values = (rows + offset).remainder(num_slots - 1).add_(1)
|
||||
values[(rows + 2 * offset).remainder(7) == 0] = 0
|
||||
values[(rows + 3 * offset + 1).remainder(11) == 0] = -1
|
||||
mapping.copy_(values.to(mapping.device))
|
||||
|
||||
|
||||
def _reference_gate(
|
||||
hidden_states: torch.Tensor,
|
||||
gate_a: torch.Tensor,
|
||||
gate_b: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
mapping: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Materialize shrink/expand with the same BF16 stage boundary."""
|
||||
|
||||
active = mapping >= 0
|
||||
slots = mapping.clamp_min(0).long()
|
||||
experts = topk_ids.long()
|
||||
rank = gate_b.shape[-1]
|
||||
intermediate_size = gate_b.shape[2] // 2
|
||||
|
||||
selected_a = gate_a[slots, 0]
|
||||
shared_rank = torch.einsum(
|
||||
"mh,mrh->mr", hidden_states.float(), selected_a.float()
|
||||
).to(hidden_states.dtype)
|
||||
selected_b = gate_b[slots[:, None], experts]
|
||||
|
||||
gate = torch.einsum(
|
||||
"mkr,mkir->mki",
|
||||
shared_rank[:, None, :rank].expand(-1, experts.shape[1], -1).float(),
|
||||
selected_b[:, :, :intermediate_size].float(),
|
||||
)
|
||||
up = torch.einsum(
|
||||
"mkr,mkir->mki",
|
||||
shared_rank[:, None, rank:].expand(-1, experts.shape[1], -1).float(),
|
||||
selected_b[:, :, intermediate_size:].float(),
|
||||
)
|
||||
output = torch.cat((gate, up), dim=-1).to(hidden_states.dtype)
|
||||
output[~active] = 0
|
||||
return output
|
||||
|
||||
|
||||
def _reference_down(
|
||||
activation: torch.Tensor,
|
||||
down_a: torch.Tensor,
|
||||
down_b: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
mapping: torch.Tensor,
|
||||
base_output: torch.Tensor,
|
||||
routed_scaling_factor: float,
|
||||
) -> torch.Tensor:
|
||||
"""Materialize routed shrink, weighted rank sum, and selected shared B."""
|
||||
|
||||
slots = mapping.clamp_min(0).long()
|
||||
experts = topk_ids.long()
|
||||
selected_a = down_a[slots[:, None], experts]
|
||||
routed_rank = torch.einsum(
|
||||
"mki,mkri->mkr", activation.float(), selected_a.float()
|
||||
).to(activation.dtype)
|
||||
rank_sum = (
|
||||
(routed_rank.float() * topk_weights.float().unsqueeze(-1))
|
||||
.sum(dim=1)
|
||||
.mul(routed_scaling_factor)
|
||||
.to(activation.dtype)
|
||||
)
|
||||
|
||||
output = base_output.clone()
|
||||
for slot in range(down_b.shape[0]):
|
||||
rows = mapping == slot
|
||||
if rows.any():
|
||||
output[rows] = torch.addmm(
|
||||
base_output[rows], rank_sum[rows], down_b[slot, 0].T
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _reference_generic_delta(
|
||||
hidden_states: torch.Tensor,
|
||||
lora_a: torch.Tensor,
|
||||
lora_b: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
mapping: torch.Tensor,
|
||||
*,
|
||||
shared_a: bool,
|
||||
shared_b: bool,
|
||||
mul_routed_weight: bool,
|
||||
) -> torch.Tensor:
|
||||
num_tokens, topk = topk_ids.shape
|
||||
output = hidden_states.new_zeros(num_tokens, topk, lora_b.shape[2])
|
||||
routed_inputs = hidden_states.shape[0] == topk_ids.numel()
|
||||
for token in range(num_tokens):
|
||||
slot = int(mapping[token])
|
||||
if slot < 0:
|
||||
continue
|
||||
for route in range(topk):
|
||||
expert = int(topk_ids[token, route])
|
||||
if expert < 0:
|
||||
continue
|
||||
row = token * topk + route if routed_inputs else token
|
||||
a = lora_a[slot, 0 if shared_a else expert]
|
||||
b = lora_b[slot, 0 if shared_b else expert]
|
||||
shrink = torch.mv(a.float(), hidden_states[row].float()).to(
|
||||
hidden_states.dtype
|
||||
)
|
||||
rank = b.shape[1]
|
||||
if shrink.numel() == 2 * rank:
|
||||
output_half = b.shape[0] // 2
|
||||
delta = torch.cat(
|
||||
(
|
||||
torch.mv(b[:output_half].float(), shrink[:rank].float()),
|
||||
torch.mv(b[output_half:].float(), shrink[rank:].float()),
|
||||
)
|
||||
).to(hidden_states.dtype)
|
||||
else:
|
||||
delta = torch.mv(b.float(), shrink.float()).to(hidden_states.dtype)
|
||||
if mul_routed_weight:
|
||||
delta.mul_(topk_weights[token, route])
|
||||
output[token, route] = delta
|
||||
return output
|
||||
|
||||
|
||||
def _run_factored_pipeline(
|
||||
*,
|
||||
hidden_states: torch.Tensor,
|
||||
activation: torch.Tensor,
|
||||
gate_a: torch.Tensor,
|
||||
gate_b: torch.Tensor,
|
||||
down_a: torch.Tensor,
|
||||
down_b: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
mapping: torch.Tensor,
|
||||
gate_rank: torch.Tensor,
|
||||
gate_output: torch.Tensor,
|
||||
down_routed_rank: torch.Tensor,
|
||||
down_rank_sum: torch.Tensor,
|
||||
down_output: torch.Tensor,
|
||||
full_routing_cache: dict,
|
||||
collapsed_routing_cache: dict,
|
||||
routed_scaling_factor: float,
|
||||
) -> None:
|
||||
from sglang.kernels.ops.moe.trtllm_lora_temp.virtual_experts import (
|
||||
merged_experts_fused_moe_lora_add,
|
||||
)
|
||||
from sglang.srt.lora.marlin_lora_temp.shared_outer import weighted_topk_rank_sum
|
||||
|
||||
num_tokens = topk_ids.shape[0]
|
||||
num_experts = gate_b.shape[1]
|
||||
collapsed_ids = mapping.view(num_tokens, 1)
|
||||
collapsed_weights = topk_weights[:, :1]
|
||||
|
||||
# Capture the same four routing domains as the production schedule. The
|
||||
# dictionaries must remain distinct because full and collapsed top-k have
|
||||
# different flattened token domains.
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=gate_output,
|
||||
hidden_states=hidden_states,
|
||||
lora_a=gate_a,
|
||||
lora_b=gate_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=True,
|
||||
experts_shared_outer_loras_b=False,
|
||||
routing_cache=full_routing_cache,
|
||||
stage="routing",
|
||||
prewarm_a_routing=False,
|
||||
prewarm_b_routing=True,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=gate_output,
|
||||
hidden_states=hidden_states,
|
||||
lora_a=gate_a,
|
||||
lora_b=gate_b,
|
||||
topk_ids=collapsed_ids,
|
||||
topk_weights=collapsed_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=True,
|
||||
experts_shared_outer_loras_b=False,
|
||||
routing_cache=collapsed_routing_cache,
|
||||
stage="routing",
|
||||
prewarm_a_routing=True,
|
||||
prewarm_b_routing=False,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=activation,
|
||||
hidden_states=activation,
|
||||
lora_a=down_a,
|
||||
lora_b=down_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=True,
|
||||
experts_shared_outer_loras_a=False,
|
||||
experts_shared_outer_loras_b=True,
|
||||
routing_cache=full_routing_cache,
|
||||
stage="routing",
|
||||
prewarm_a_routing=True,
|
||||
prewarm_b_routing=False,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=down_output,
|
||||
hidden_states=down_rank_sum,
|
||||
lora_a=down_a,
|
||||
lora_b=down_b,
|
||||
topk_ids=collapsed_ids,
|
||||
topk_weights=collapsed_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=False,
|
||||
experts_shared_outer_loras_b=True,
|
||||
routing_cache=collapsed_routing_cache,
|
||||
stage="routing",
|
||||
prewarm_a_routing=False,
|
||||
prewarm_b_routing=True,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=gate_output,
|
||||
hidden_states=hidden_states,
|
||||
lora_a=gate_a,
|
||||
lora_b=gate_b,
|
||||
topk_ids=collapsed_ids,
|
||||
topk_weights=collapsed_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=True,
|
||||
experts_shared_outer_loras_b=False,
|
||||
routing_cache=collapsed_routing_cache,
|
||||
stage="shrink",
|
||||
prewarm_b_routing=False,
|
||||
intermediate_buffer=gate_rank,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=gate_output,
|
||||
hidden_states=hidden_states,
|
||||
lora_a=gate_a,
|
||||
lora_b=gate_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=True,
|
||||
experts_shared_outer_loras_b=False,
|
||||
routing_cache=full_routing_cache,
|
||||
fuse_add_to_output=False,
|
||||
use_direct_expand_add=True,
|
||||
stage="expand",
|
||||
intermediate_buffer=gate_rank,
|
||||
broadcast_intermediate=True,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=activation,
|
||||
hidden_states=activation,
|
||||
lora_a=down_a,
|
||||
lora_b=down_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=True,
|
||||
experts_shared_outer_loras_a=False,
|
||||
experts_shared_outer_loras_b=True,
|
||||
routing_cache=full_routing_cache,
|
||||
stage="shrink",
|
||||
prewarm_b_routing=False,
|
||||
intermediate_buffer=down_routed_rank,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
weighted_topk_rank_sum(
|
||||
down_routed_rank,
|
||||
topk_weights,
|
||||
down_rank_sum,
|
||||
routed_scaling_factor,
|
||||
block_m=1,
|
||||
)
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=down_output,
|
||||
hidden_states=down_rank_sum,
|
||||
lora_a=down_a,
|
||||
lora_b=down_b,
|
||||
topk_ids=collapsed_ids,
|
||||
topk_weights=collapsed_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=False,
|
||||
experts_shared_outer_loras_b=True,
|
||||
routing_cache=collapsed_routing_cache,
|
||||
fuse_add_to_output=True,
|
||||
use_direct_expand_add=False,
|
||||
stage="expand",
|
||||
intermediate_buffer=down_rank_sum,
|
||||
local_expert_offset=0,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _CUDA_BF16_AVAILABLE,
|
||||
reason="multi-prefill parity requires a CUDA GPU with BF16 tensor cores",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("num_slots", "num_tokens"), [(2, 33), (3, 64), (4, 65), (5, 33), (8, 64), (16, 65)]
|
||||
)
|
||||
def test_multi_shared_outer_prefill_cuda_graph_parity(
|
||||
num_slots: int, num_tokens: int
|
||||
) -> None:
|
||||
"""Replay full+collapsed routing while adapter selections change in place."""
|
||||
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.bfloat16
|
||||
num_experts, router_topk = 4, 2
|
||||
hidden_size = intermediate_size = 128
|
||||
rank = 16
|
||||
scale = 1.25
|
||||
generator = torch.Generator(device=device).manual_seed(2027 + num_slots)
|
||||
|
||||
def randn(*shape: int) -> torch.Tensor:
|
||||
return (
|
||||
torch.randn(*shape, device=device, dtype=dtype, generator=generator) * 0.05
|
||||
)
|
||||
|
||||
hidden_states = randn(num_tokens, hidden_size)
|
||||
activation = randn(num_tokens, router_topk, intermediate_size)
|
||||
gate_a = randn(num_slots, 1, 2 * rank, hidden_size)
|
||||
gate_b = randn(num_slots, num_experts, 2 * intermediate_size, rank)
|
||||
down_a = randn(num_slots, num_experts, rank, intermediate_size)
|
||||
down_b = randn(num_slots, 1, hidden_size, rank)
|
||||
# Slot 0 is the production base/None representation: it remains a valid
|
||||
# mapping value, while every attached operand is zero in address-stable pool
|
||||
# storage. This catches accidental stale reads that a -1-only test misses.
|
||||
gate_a[0].zero_()
|
||||
gate_b[0].zero_()
|
||||
down_a[0].zero_()
|
||||
down_b[0].zero_()
|
||||
|
||||
token = torch.arange(num_tokens, device=device, dtype=torch.int32)[:, None]
|
||||
route = torch.arange(router_topk, device=device, dtype=torch.int32)[None, :]
|
||||
topk_ids = (token + 2 * route).remainder(num_experts).contiguous()
|
||||
topk_weights = (
|
||||
torch.tensor([0.25, 0.75], device=device, dtype=torch.float32)
|
||||
.expand(num_tokens, -1)
|
||||
.contiguous()
|
||||
)
|
||||
mapping = torch.empty(num_tokens, device=device, dtype=torch.int32)
|
||||
_set_mapping(mapping, num_slots, offset=0)
|
||||
|
||||
gate_rank = torch.empty(num_tokens, 2 * rank, device=device, dtype=dtype)
|
||||
gate_output = torch.empty(
|
||||
num_tokens,
|
||||
router_topk,
|
||||
2 * intermediate_size,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
down_routed_rank = torch.empty(
|
||||
num_tokens, router_topk, rank, device=device, dtype=dtype
|
||||
)
|
||||
down_rank_sum = torch.empty(num_tokens, rank, device=device, dtype=dtype)
|
||||
base_output = randn(num_tokens, hidden_size)
|
||||
down_output = base_output.clone()
|
||||
|
||||
common = dict(
|
||||
hidden_states=hidden_states,
|
||||
activation=activation.view(num_tokens * router_topk, intermediate_size),
|
||||
gate_a=gate_a,
|
||||
gate_b=gate_b,
|
||||
down_a=down_a,
|
||||
down_b=down_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
mapping=mapping,
|
||||
gate_rank=gate_rank,
|
||||
gate_output=gate_output,
|
||||
down_routed_rank=down_routed_rank,
|
||||
down_rank_sum=down_rank_sum,
|
||||
down_output=down_output,
|
||||
routed_scaling_factor=scale,
|
||||
)
|
||||
|
||||
# Compile JIT and initialize CUDA-library state outside capture. These
|
||||
# throwaway caches deliberately do not enter the captured graph.
|
||||
warmup_stream = torch.cuda.Stream()
|
||||
warmup_stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup_stream):
|
||||
_run_factored_pipeline(
|
||||
**common, full_routing_cache={}, collapsed_routing_cache={}
|
||||
)
|
||||
torch.cuda.current_stream().wait_stream(warmup_stream)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
full_routing_cache: dict = {}
|
||||
collapsed_routing_cache: dict = {}
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
_run_factored_pipeline(
|
||||
**common,
|
||||
full_routing_cache=full_routing_cache,
|
||||
collapsed_routing_cache=collapsed_routing_cache,
|
||||
)
|
||||
|
||||
assert full_routing_cache and collapsed_routing_cache
|
||||
routing_tensors = tuple(
|
||||
tensor
|
||||
for cache in (full_routing_cache, collapsed_routing_cache)
|
||||
for value in cache.values()
|
||||
for tensor in value
|
||||
)
|
||||
stable_tensors = (
|
||||
mapping,
|
||||
gate_rank,
|
||||
gate_output,
|
||||
down_routed_rank,
|
||||
down_rank_sum,
|
||||
down_output,
|
||||
*routing_tensors,
|
||||
)
|
||||
stable_addresses = tuple(tensor.data_ptr() for tensor in stable_tensors)
|
||||
|
||||
for offset in (1, 3):
|
||||
_set_mapping(mapping, num_slots, offset)
|
||||
assert torch.any(mapping == -1).item()
|
||||
assert torch.any(mapping == 0).item()
|
||||
gate_rank.fill_(float("nan"))
|
||||
gate_output.fill_(float("nan"))
|
||||
down_routed_rank.fill_(float("nan"))
|
||||
down_rank_sum.fill_(float("nan"))
|
||||
down_output.copy_(base_output)
|
||||
|
||||
expected_gate = _reference_gate(
|
||||
hidden_states, gate_a, gate_b, topk_ids, mapping
|
||||
)
|
||||
expected_down = _reference_down(
|
||||
activation,
|
||||
down_a,
|
||||
down_b,
|
||||
topk_ids,
|
||||
topk_weights,
|
||||
mapping,
|
||||
base_output,
|
||||
scale,
|
||||
)
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert tuple(tensor.data_ptr() for tensor in stable_tensors) == stable_addresses
|
||||
torch.testing.assert_close(gate_output, expected_gate, rtol=0.025, atol=0.025)
|
||||
torch.testing.assert_close(down_output, expected_down, rtol=0.025, atol=0.025)
|
||||
base_rows = mapping <= 0
|
||||
assert torch.count_nonzero(gate_output[base_rows]).item() == 0
|
||||
torch.testing.assert_close(
|
||||
down_output[base_rows], base_output[base_rows], rtol=0, atol=0
|
||||
)
|
||||
assert torch.isfinite(gate_output).all().item()
|
||||
assert torch.isfinite(down_output).all().item()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _CUDA_BF16_AVAILABLE,
|
||||
reason="generic fallback parity requires a CUDA GPU with BF16 tensor cores",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("num_slots", "rank", "shared_outer", "ep"),
|
||||
[(5, 32, True, False), (8, 128, True, True), (16, 128, False, True)],
|
||||
)
|
||||
def test_generic_fallback_cuda_graph_parity(
|
||||
num_slots: int, rank: int, shared_outer: bool, ep: bool
|
||||
) -> None:
|
||||
from sglang.kernels.ops.moe.trtllm_lora_temp.virtual_experts import (
|
||||
merged_experts_fused_moe_lora_add,
|
||||
)
|
||||
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.bfloat16
|
||||
num_tokens, topk = 8, 2
|
||||
num_experts = 2 if ep else 4
|
||||
hidden_size = intermediate_size = 64
|
||||
generator = torch.Generator(device=device).manual_seed(3100 + num_slots + rank)
|
||||
|
||||
def randn(*shape: int) -> torch.Tensor:
|
||||
return (
|
||||
torch.randn(*shape, device=device, dtype=dtype, generator=generator) * 0.03
|
||||
)
|
||||
|
||||
gate_a = randn(num_slots, 1 if shared_outer else num_experts, 2 * rank, hidden_size)
|
||||
gate_b = randn(num_slots, num_experts, 2 * intermediate_size, rank)
|
||||
down_a = randn(num_slots, num_experts, rank, intermediate_size)
|
||||
down_b = randn(num_slots, 1 if shared_outer else num_experts, hidden_size, rank)
|
||||
hidden_states = randn(num_tokens, hidden_size)
|
||||
activation = randn(num_tokens * topk, intermediate_size)
|
||||
topk_ids = (
|
||||
torch.arange(num_tokens * topk, device=device, dtype=torch.int32)
|
||||
.remainder(num_experts)
|
||||
.view(num_tokens, topk)
|
||||
)
|
||||
if ep:
|
||||
topk_ids[::2, 1] = -1
|
||||
topk_weights = torch.tensor([0.4, 0.6], device=device, dtype=torch.float32).expand(
|
||||
num_tokens, -1
|
||||
)
|
||||
mapping = torch.arange(num_tokens, device=device, dtype=torch.int32).remainder(
|
||||
num_slots
|
||||
)
|
||||
mapping[::5] = -1
|
||||
gate_output = torch.empty(
|
||||
num_tokens, topk, 2 * intermediate_size, device=device, dtype=dtype
|
||||
)
|
||||
down_output = torch.empty(num_tokens, topk, hidden_size, device=device, dtype=dtype)
|
||||
|
||||
def run(gate_cache: dict, down_cache: dict) -> None:
|
||||
gate_output.zero_()
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=gate_output,
|
||||
hidden_states=hidden_states,
|
||||
lora_a=gate_a,
|
||||
lora_b=gate_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=False,
|
||||
experts_shared_outer_loras_a=shared_outer,
|
||||
experts_shared_outer_loras_b=False,
|
||||
routing_cache=gate_cache,
|
||||
fuse_add_to_output=False,
|
||||
use_direct_expand_add=True,
|
||||
local_num_experts=num_experts,
|
||||
)
|
||||
down_output.zero_()
|
||||
merged_experts_fused_moe_lora_add(
|
||||
output=down_output,
|
||||
hidden_states=activation,
|
||||
lora_a=down_a,
|
||||
lora_b=down_b,
|
||||
topk_ids=topk_ids,
|
||||
topk_weights=topk_weights,
|
||||
token_lora_mapping=mapping,
|
||||
mul_routed_weight=True,
|
||||
experts_shared_outer_loras_a=False,
|
||||
experts_shared_outer_loras_b=shared_outer,
|
||||
routing_cache=down_cache,
|
||||
use_direct_expand_add=False,
|
||||
local_num_experts=num_experts,
|
||||
zero_intermediate=ep and shared_outer,
|
||||
)
|
||||
|
||||
def assert_expected() -> None:
|
||||
torch.testing.assert_close(
|
||||
gate_output,
|
||||
_reference_generic_delta(
|
||||
hidden_states,
|
||||
gate_a,
|
||||
gate_b,
|
||||
topk_ids,
|
||||
topk_weights,
|
||||
mapping,
|
||||
shared_a=shared_outer,
|
||||
shared_b=False,
|
||||
mul_routed_weight=False,
|
||||
),
|
||||
rtol=2e-2,
|
||||
atol=2e-2,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
down_output,
|
||||
_reference_generic_delta(
|
||||
activation,
|
||||
down_a,
|
||||
down_b,
|
||||
topk_ids,
|
||||
topk_weights,
|
||||
mapping,
|
||||
shared_a=False,
|
||||
shared_b=shared_outer,
|
||||
mul_routed_weight=True,
|
||||
),
|
||||
rtol=2e-2,
|
||||
atol=2e-2,
|
||||
)
|
||||
|
||||
run({}, {})
|
||||
torch.cuda.synchronize()
|
||||
assert_expected()
|
||||
|
||||
gate_cache: dict = {}
|
||||
down_cache: dict = {}
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
run(gate_cache, down_cache)
|
||||
|
||||
for offset in (0, 1):
|
||||
mapping.copy_(
|
||||
torch.arange(num_tokens, device=device, dtype=torch.int32)
|
||||
.add(offset)
|
||||
.remainder(num_slots)
|
||||
)
|
||||
mapping[(torch.arange(num_tokens, device=device) + offset) % 5 == 0] = -1
|
||||
graph.replay()
|
||||
assert_expected()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,228 @@
|
||||
"""CPU-only tests for experimental_sgl_marlin's correctness contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from sglang.srt.lora.marlin_lora_temp.policy import (
|
||||
use_post_reduce_down_delta,
|
||||
validate_experimental_sgl_marlin_contract,
|
||||
validate_experimental_sgl_marlin_server_args,
|
||||
)
|
||||
from sglang.srt.lora.trtllm_lora_temp.specialized_expand import _get_gated_a_half
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=5, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
|
||||
def _config(**overrides):
|
||||
values = dict(
|
||||
activation="silu",
|
||||
is_gated=True,
|
||||
gemm1_alpha=None,
|
||||
gemm1_clamp_limit=None,
|
||||
swiglu_limit=None,
|
||||
apply_router_weight_on_input=False,
|
||||
no_combine=False,
|
||||
num_experts=256,
|
||||
num_local_experts=256,
|
||||
)
|
||||
values.update(overrides)
|
||||
return types.SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _validate(config=None, **overrides):
|
||||
values = dict(
|
||||
runner_config=config or _config(),
|
||||
moe_ep_size=1,
|
||||
device_capability=(9, 0),
|
||||
)
|
||||
values.update(overrides)
|
||||
return validate_experimental_sgl_marlin_contract(**values)
|
||||
|
||||
|
||||
def _validate_server(**overrides):
|
||||
ep_size = overrides.pop("ep_size", 4)
|
||||
moe_a2a_backend = overrides.pop("moe_a2a_backend", "none")
|
||||
values = dict(
|
||||
enable_lora=True,
|
||||
lora_paths=[],
|
||||
lora_use_virtual_experts=True,
|
||||
init_expert_location="trivial",
|
||||
ep_num_redundant_experts=0,
|
||||
enable_eplb=False,
|
||||
elastic_ep_backend=None,
|
||||
enable_elastic_expert_backup=False,
|
||||
elastic_ep_rejoin=False,
|
||||
experts_shared_outer_loras=False,
|
||||
max_lora_rank=64,
|
||||
lora_backend="triton",
|
||||
)
|
||||
values.update(overrides)
|
||||
return validate_experimental_sgl_marlin_server_args(
|
||||
types.SimpleNamespace(**values),
|
||||
types.SimpleNamespace(
|
||||
ep_size=ep_size,
|
||||
moe_a2a_backend=moe_a2a_backend,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_supported_contract_passes():
|
||||
_validate()
|
||||
|
||||
|
||||
def test_supported_ep_contract_passes():
|
||||
_validate(config=_config(num_local_experts=64), moe_ep_size=4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enable_lora", "lora_paths"),
|
||||
[(None, []), (False, []), (False, ["ignored=/tmp/adapter"])],
|
||||
)
|
||||
def test_base_only_ep_preserves_stock_marlin_placement_support(enable_lora, lora_paths):
|
||||
_validate_server(
|
||||
enable_lora=enable_lora,
|
||||
lora_paths=lora_paths,
|
||||
init_expert_location="random",
|
||||
ep_num_redundant_experts=1,
|
||||
enable_eplb=True,
|
||||
elastic_ep_backend="mooncake",
|
||||
enable_elastic_expert_backup=True,
|
||||
elastic_ep_rejoin=True,
|
||||
)
|
||||
|
||||
|
||||
def test_base_only_ep_rejects_unsupported_a2a():
|
||||
with pytest.raises(ValueError, match="moe-a2a-backend none"):
|
||||
_validate_server(enable_lora=False, moe_a2a_backend="deepep")
|
||||
|
||||
|
||||
def test_lora_rejects_non_triton_backend():
|
||||
with pytest.raises(ValueError, match="requires --lora-backend triton"):
|
||||
_validate_server(lora_backend="csgmv")
|
||||
# base-only servers are free to pick any dense backend
|
||||
_validate_server(enable_lora=False, lora_backend="csgmv")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ep_size", [1, 4])
|
||||
def test_lora_requires_virtual_experts(ep_size):
|
||||
with pytest.raises(ValueError, match="lora-use-virtual-experts"):
|
||||
_validate_server(ep_size=ep_size, lora_use_virtual_experts=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setting",
|
||||
[
|
||||
{"init_expert_location": "random"},
|
||||
{"ep_num_redundant_experts": 1},
|
||||
{"enable_eplb": True},
|
||||
{"elastic_ep_backend": "mooncake"},
|
||||
{"enable_elastic_expert_backup": True},
|
||||
{"elastic_ep_rejoin": True},
|
||||
],
|
||||
)
|
||||
def test_lora_ep_rejects_nontrivial_placement_features(setting):
|
||||
with pytest.raises(ValueError, match="trivial expert placement"):
|
||||
_validate_server(**setting)
|
||||
|
||||
|
||||
def test_adapter_paths_implicitly_enable_lora_ep_validation():
|
||||
with pytest.raises(ValueError, match="trivial expert placement"):
|
||||
_validate_server(
|
||||
enable_lora=None,
|
||||
lora_paths=["adapter=/tmp/adapter"],
|
||||
enable_eplb=True,
|
||||
)
|
||||
|
||||
|
||||
def test_supported_lora_ep_passes():
|
||||
_validate_server(experts_shared_outer_loras=True, max_lora_rank=64)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [65, 128, 256])
|
||||
def test_shared_outer_lora_ep_allows_generic_rank_fallback(rank):
|
||||
_validate_server(experts_shared_outer_loras=True, max_lora_rank=rank)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "message"),
|
||||
[
|
||||
(_config(activation="relu2"), "activation must be 'silu'"),
|
||||
(_config(is_gated=False), "only gated SwiGLU"),
|
||||
(_config(gemm1_alpha=1.0), "gemm1_alpha"),
|
||||
(_config(gemm1_clamp_limit=7.0), "gemm1_clamp_limit"),
|
||||
(_config(swiglu_limit=7.0), "swiglu_limit"),
|
||||
(
|
||||
_config(apply_router_weight_on_input=True),
|
||||
"apply_router_weight_on_input",
|
||||
),
|
||||
(_config(no_combine=True), "no_combine"),
|
||||
],
|
||||
)
|
||||
def test_rejects_unimplemented_activation_semantics(config, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_validate(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"config": _config(num_local_experts=128)},
|
||||
{"config": _config(num_local_experts=63), "moe_ep_size": 4},
|
||||
{"config": _config(num_experts=255, num_local_experts=64), "moe_ep_size": 4},
|
||||
{"moe_ep_size": 0},
|
||||
],
|
||||
)
|
||||
def test_rejects_incoherent_expert_parallelism(overrides):
|
||||
config = overrides.get("config")
|
||||
validation_overrides = {k: v for k, v in overrides.items() if k != "config"}
|
||||
with pytest.raises(ValueError, match="moe_ep_size|num_local_experts"):
|
||||
_validate(config, **validation_overrides)
|
||||
|
||||
|
||||
def test_rejects_pre_hopper_gpu():
|
||||
with pytest.raises(ValueError, match="compute capability 9.0 or newer"):
|
||||
_validate(device_capability=(8, 0))
|
||||
|
||||
|
||||
def test_direct_expand_always_splits_gated_gate_up_a():
|
||||
assert _get_gated_a_half(intermediate_width=64, rank=32, output_width=768) == 384
|
||||
assert _get_gated_a_half(intermediate_width=32, rank=32, output_width=6144) == 0
|
||||
|
||||
|
||||
def test_direct_expand_rejects_invalid_intermediate_width():
|
||||
with pytest.raises(ValueError, match="intermediate width"):
|
||||
_get_gated_a_half(intermediate_width=48, rank=32, output_width=768)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("run_lora", "scale", "num_tokens", "expected"),
|
||||
[
|
||||
(True, 1.0, 1, True),
|
||||
(True, 1.0, 2048, True),
|
||||
(True, 1.0, 2049, False),
|
||||
(True, 0.5, 32, False),
|
||||
(False, 1.0, 32, False),
|
||||
],
|
||||
)
|
||||
def test_post_reduce_down_policy(run_lora, scale, num_tokens, expected):
|
||||
assert (
|
||||
use_post_reduce_down_delta(
|
||||
run_lora=run_lora,
|
||||
routed_scaling_factor=scale,
|
||||
num_tokens=num_tokens,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,945 @@
|
||||
"""CPU-only runtime-flow tests for experimental_sgl_marlin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=5, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
LORA_TEMP_ROOT = REPO_ROOT / "python/sglang/srt/lora"
|
||||
MARLIN_RUNNER_PATH = LORA_TEMP_ROOT / "marlin_lora_temp/moe_runner.py"
|
||||
MARLIN_POLICY_PATH = LORA_TEMP_ROOT / "marlin_lora_temp/policy.py"
|
||||
TWO_STREAM_PATH = LORA_TEMP_ROOT / "trtllm_lora_temp/__init__.py"
|
||||
|
||||
|
||||
def _stub_module(monkeypatch, name: str, **attributes):
|
||||
parts = name.split(".")
|
||||
for end in range(1, len(parts)):
|
||||
package_name = ".".join(parts[:end])
|
||||
if package_name not in sys.modules:
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = []
|
||||
monkeypatch.setitem(sys.modules, package_name, package)
|
||||
module = types.ModuleType(name)
|
||||
for key, value in attributes.items():
|
||||
setattr(module, key, value)
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
if len(parts) > 1:
|
||||
parent = sys.modules[".".join(parts[:-1])]
|
||||
monkeypatch.setattr(parent, parts[-1], module, raising=False)
|
||||
return module
|
||||
|
||||
|
||||
def _load_file(monkeypatch, name: str, path: Path):
|
||||
parts = name.split(".")
|
||||
for end in range(1, len(parts)):
|
||||
package_name = ".".join(parts[:end])
|
||||
if package_name not in sys.modules:
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = []
|
||||
monkeypatch.setitem(sys.modules, package_name, package)
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
spec.loader.exec_module(module)
|
||||
if len(parts) > 1:
|
||||
parent = sys.modules[".".join(parts[:-1])]
|
||||
monkeypatch.setattr(parent, parts[-1], module, raising=False)
|
||||
return module
|
||||
|
||||
|
||||
def _load_marlin_runner(monkeypatch, name: str):
|
||||
_stub_module(monkeypatch, "sglang.srt.utils", is_cuda=lambda: False)
|
||||
_load_file(
|
||||
monkeypatch,
|
||||
"sglang.srt.lora.marlin_lora_temp.policy",
|
||||
MARLIN_POLICY_PATH,
|
||||
)
|
||||
return _load_file(monkeypatch, name, MARLIN_RUNNER_PATH)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tokens", "expected"), [(256, True), (257, False)])
|
||||
def test_two_stream_token_threshold_is_inclusive(monkeypatch, tokens, expected):
|
||||
lora_envs = SimpleNamespace(
|
||||
SGLANG_TWO_STREAM_MAX_TOKENS=SimpleNamespace(get=lambda: 256)
|
||||
)
|
||||
_stub_module(monkeypatch, "sglang.srt.environ", envs=SimpleNamespace())
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.lora.trtllm_lora_temp.environ",
|
||||
lora_envs=lora_envs,
|
||||
)
|
||||
module = _load_file(monkeypatch, "_two_stream_under_test", TWO_STREAM_PATH)
|
||||
assert module.is_two_stream_active(torch.empty(tokens, 1)) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("combined_rank", "rank", "expected"),
|
||||
[(128, 64, True), (128, 128, False), (256, 64, False)],
|
||||
)
|
||||
def test_two_stream_dense_lora_rank_falls_back(
|
||||
monkeypatch, combined_rank, rank, expected
|
||||
):
|
||||
lora_envs = SimpleNamespace(
|
||||
SGLANG_TWO_STREAM_MAX_TOKENS=SimpleNamespace(get=lambda: 256)
|
||||
)
|
||||
_stub_module(monkeypatch, "sglang.srt.environ", envs=SimpleNamespace())
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.lora.trtllm_lora_temp.environ",
|
||||
lora_envs=lora_envs,
|
||||
)
|
||||
module = _load_file(monkeypatch, "_two_stream_rank_under_test", TWO_STREAM_PATH)
|
||||
assert (
|
||||
module.supports_two_stream_dense_lora(
|
||||
torch.empty(1, combined_rank, 1), torch.empty(1, 1, rank)
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
class _CombineInput:
|
||||
def __init__(self, hidden_states):
|
||||
self.hidden_states = hidden_states
|
||||
|
||||
|
||||
class _DispatchOutput:
|
||||
def __init__(self, hidden_states, topk_output):
|
||||
self.hidden_states = hidden_states
|
||||
self.topk_output = topk_output
|
||||
|
||||
|
||||
def _run_marlin_policy(
|
||||
monkeypatch,
|
||||
*,
|
||||
tokens: int,
|
||||
master: bool = True,
|
||||
two_stream: bool = False,
|
||||
capture: bool = False,
|
||||
active_lora: bool = True,
|
||||
base_mapping: bool = False,
|
||||
direct_decode: bool = False,
|
||||
ep: bool = False,
|
||||
slots: int = 1,
|
||||
rank: int = 1,
|
||||
shared_outer: bool = True,
|
||||
base_value: float = 0.0,
|
||||
):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_under_test")
|
||||
# The hermetic runner uses tiny CPU tensors. Explicitly emulate the exact
|
||||
# B200/Inkling eligibility gate so these tests exercise the fused schedule.
|
||||
module._use_fused_shared_outer_tail = (
|
||||
lambda _info, _hidden, num_tokens, _hidden_size, _topk: num_tokens <= 512
|
||||
)
|
||||
module._use_direct_decode_kernels = lambda *_args, **_kwargs: direct_decode
|
||||
|
||||
calls = SimpleNamespace(
|
||||
merged=[],
|
||||
split_gate_checks=0,
|
||||
weighted_rank_sums=0,
|
||||
fused_tails=0,
|
||||
direct_gate=0,
|
||||
direct_down=0,
|
||||
marlin_is_ep=[],
|
||||
align_num_experts=[],
|
||||
cache3_was_zero=None,
|
||||
schedule=[],
|
||||
zeroed=[],
|
||||
event_records=[],
|
||||
event_waits=[],
|
||||
)
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def wait_stream(self, other):
|
||||
calls.schedule.append(("wait_stream", self.name, other.name))
|
||||
|
||||
def wait_event(self, event):
|
||||
item = ("wait_event", self.name, event.name)
|
||||
calls.schedule.append(item)
|
||||
calls.event_waits.append(item)
|
||||
|
||||
main_stream = _FakeStream("main")
|
||||
side_stream = _FakeStream("side")
|
||||
stream_state = {"current": main_stream}
|
||||
event_count = 0
|
||||
|
||||
class _FakeEvent:
|
||||
def __init__(self):
|
||||
nonlocal event_count
|
||||
self.name = f"event{event_count}"
|
||||
event_count += 1
|
||||
|
||||
def record(self):
|
||||
item = ("record", self.name, stream_state["current"].name)
|
||||
calls.schedule.append(item)
|
||||
calls.event_records.append(item)
|
||||
|
||||
class _StreamContext:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.previous = None
|
||||
|
||||
def __enter__(self):
|
||||
self.previous = stream_state["current"]
|
||||
stream_state["current"] = self.stream
|
||||
|
||||
def __exit__(self, *_args):
|
||||
stream_state["current"] = self.previous
|
||||
|
||||
monkeypatch.setattr(torch.cuda, "Event", _FakeEvent)
|
||||
monkeypatch.setattr(torch.cuda, "current_stream", lambda: stream_state["current"])
|
||||
monkeypatch.setattr(torch.cuda, "stream", _StreamContext)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: capture)
|
||||
|
||||
original_zero = torch.Tensor.zero_
|
||||
|
||||
def tracked_zero(intermediate, *args, **kwargs):
|
||||
item = ("zero", stream_state["current"].name, id(intermediate))
|
||||
calls.schedule.append(item)
|
||||
calls.zeroed.append(item)
|
||||
return original_zero(intermediate, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch.Tensor, "zero_", tracked_zero)
|
||||
|
||||
def merged_experts_fused_moe_lora_add(**kwargs):
|
||||
intermediate = kwargs.get("intermediate_buffer")
|
||||
stage = kwargs.get("stage")
|
||||
calls.schedule.append(
|
||||
(
|
||||
"merged",
|
||||
stage,
|
||||
stream_state["current"].name,
|
||||
id(intermediate) if intermediate is not None else None,
|
||||
)
|
||||
)
|
||||
calls.merged.append(
|
||||
{
|
||||
"stage": stage,
|
||||
"stream": stream_state["current"].name,
|
||||
"intermediate_shape": (
|
||||
tuple(kwargs["intermediate_buffer"].shape)
|
||||
if kwargs.get("intermediate_buffer") is not None
|
||||
else None
|
||||
),
|
||||
"broadcast": kwargs.get("broadcast_intermediate", False),
|
||||
"prewarm_a": kwargs.get("prewarm_a_routing", True),
|
||||
"prewarm_b": kwargs.get("prewarm_b_routing", True),
|
||||
"topk_shape": tuple(kwargs["topk_ids"].shape),
|
||||
"cache_id": id(kwargs.get("routing_cache")),
|
||||
"shared_a": kwargs["experts_shared_outer_loras_a"],
|
||||
"shared_b": kwargs["experts_shared_outer_loras_b"],
|
||||
"fuse_add": kwargs.get("fuse_add_to_output", True),
|
||||
"direct_expand": kwargs.get("use_direct_expand_add", False),
|
||||
"mul_routed_weight": kwargs["mul_routed_weight"],
|
||||
"zero_intermediate": kwargs.get("zero_intermediate", False),
|
||||
"mapping": kwargs["token_lora_mapping"].clone(),
|
||||
"intermediate_id": (
|
||||
id(intermediate) if intermediate is not None else None
|
||||
),
|
||||
}
|
||||
)
|
||||
if stage == "expand":
|
||||
if kwargs.get("fuse_add_to_output", True):
|
||||
active = kwargs["token_lora_mapping"] >= 0
|
||||
kwargs["output"][active].add_(1)
|
||||
else:
|
||||
kwargs["output"].fill_(0)
|
||||
if stage == "shrink":
|
||||
return intermediate
|
||||
|
||||
def is_two_stream_active(_hidden_states):
|
||||
calls.split_gate_checks += 1
|
||||
return two_stream
|
||||
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.layers.moe.token_dispatcher.standard",
|
||||
StandardCombineInput=_CombineInput,
|
||||
StandardDispatchOutput=_DispatchOutput,
|
||||
)
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.lora.trtllm_lora_temp",
|
||||
get_lora_side_stream=lambda: side_stream,
|
||||
is_two_stream_active=is_two_stream_active,
|
||||
)
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.lora.trtllm_lora_temp.environ",
|
||||
experimental_lora_enabled=lambda: master,
|
||||
)
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.kernels.ops.moe.trtllm_lora_temp.virtual_experts",
|
||||
merged_experts_fused_moe_lora_add=merged_experts_fused_moe_lora_add,
|
||||
)
|
||||
_stub_module(
|
||||
monkeypatch,
|
||||
"sglang.srt.model_executor.runner",
|
||||
get_is_capture_mode=lambda: capture,
|
||||
)
|
||||
|
||||
def fake_align(_topk_ids, _block_size, num_experts, **_kwargs):
|
||||
calls.align_num_experts.append(num_experts)
|
||||
return (
|
||||
torch.zeros(1, dtype=torch.int32),
|
||||
torch.zeros(1, dtype=torch.int32),
|
||||
torch.ones(1, dtype=torch.int32),
|
||||
)
|
||||
|
||||
module.moe_align_block_size = fake_align
|
||||
module.marlin_make_workspace = lambda *_args, **_kwargs: None
|
||||
module.get_scalar_type = lambda *_args, **_kwargs: None
|
||||
|
||||
def fake_marlin_gemm(_x, output, *_args, **_kwargs):
|
||||
calls.marlin_is_ep.append(_kwargs["is_ep"])
|
||||
if len(calls.marlin_is_ep) == 2:
|
||||
calls.cache3_was_zero = bool(torch.count_nonzero(output) == 0)
|
||||
calls.schedule.append(
|
||||
("marlin", stream_state["current"].name, len(calls.schedule))
|
||||
)
|
||||
output.fill_(base_value if len(calls.marlin_is_ep) == 2 else 0)
|
||||
return output
|
||||
|
||||
module.moe_wna16_marlin_gemm = fake_marlin_gemm
|
||||
|
||||
def fake_silu_and_mul_add_delta(_x, _delta, output):
|
||||
calls.schedule.append(("activation", stream_state["current"].name))
|
||||
output.fill_(0)
|
||||
|
||||
module.silu_and_mul_add_delta = fake_silu_and_mul_add_delta
|
||||
module.silu_and_mul = lambda _x, output: output.fill_(0)
|
||||
|
||||
def fake_triton_reduce(_input, output, _scale):
|
||||
calls.schedule.append(("reduce", stream_state["current"].name))
|
||||
output.copy_(_input.sum(dim=1) * _scale)
|
||||
|
||||
module.moe_sum_reduce_triton = fake_triton_reduce
|
||||
|
||||
def fake_weighted_rank_sum(routed_rank, weights, output, scale, *, block_m):
|
||||
calls.weighted_rank_sums += 1
|
||||
calls.schedule.append(
|
||||
("weighted", stream_state["current"].name, id(routed_rank))
|
||||
)
|
||||
output.copy_(
|
||||
(routed_rank * weights.to(routed_rank.dtype).unsqueeze(-1)).sum(dim=1)
|
||||
* scale
|
||||
)
|
||||
|
||||
module.weighted_topk_rank_sum = fake_weighted_rank_sum
|
||||
|
||||
def fake_fused_tail(
|
||||
routed_base,
|
||||
routed_rank,
|
||||
weights,
|
||||
_shared_b,
|
||||
output,
|
||||
scale,
|
||||
*,
|
||||
block_m,
|
||||
block_k,
|
||||
):
|
||||
calls.fused_tails += 1
|
||||
calls.schedule.append(
|
||||
("fused_tail", stream_state["current"].name, block_m, block_k)
|
||||
)
|
||||
output.copy_(routed_base.sum(dim=1) * scale)
|
||||
|
||||
module.fused_base_shared_lora_reduce = fake_fused_tail
|
||||
module.fused_base_shared_lora_reduce_config = lambda _tokens: (1, 32)
|
||||
|
||||
def fake_direct_gate(_shared, _weight, _topk_ids, _mapping, output):
|
||||
calls.direct_gate += 1
|
||||
calls.schedule.append(("direct_gate", stream_state["current"].name))
|
||||
output.fill_(0)
|
||||
|
||||
def fake_direct_down(_activation, _weight, _topk_ids, _mapping, output):
|
||||
calls.direct_down += 1
|
||||
calls.schedule.append(("direct_down", stream_state["current"].name))
|
||||
output.fill_(0)
|
||||
|
||||
module.direct_decode_gate_expand = fake_direct_gate
|
||||
module.direct_decode_down_shrink = fake_direct_down
|
||||
|
||||
hidden_size, num_experts, expert_size, topk, max_rank = 2, 1, 16, 2, rank
|
||||
hidden_states = torch.zeros(tokens, hidden_size)
|
||||
topk_ids = torch.zeros(tokens, topk, dtype=torch.int32)
|
||||
if ep:
|
||||
topk_ids[:, 1] = -1
|
||||
topk_weights = torch.ones(tokens, topk)
|
||||
dispatch_output = _DispatchOutput(
|
||||
hidden_states,
|
||||
SimpleNamespace(topk_ids=topk_ids, topk_weights=topk_weights),
|
||||
)
|
||||
quant_info = SimpleNamespace(
|
||||
w13_qweight=torch.empty(num_experts, 2),
|
||||
w13_bias=None,
|
||||
w13_scales=torch.ones(1),
|
||||
w13_global_scale=None,
|
||||
w13_qzeros=None,
|
||||
w13_g_idx=None,
|
||||
w13_g_idx_sort_indices=None,
|
||||
w2_qweight=torch.empty(num_experts, 1),
|
||||
w2_bias=None,
|
||||
w2_scales=torch.ones(1),
|
||||
w2_global_scale=None,
|
||||
w2_qzeros=None,
|
||||
w2_g_idx=None,
|
||||
w2_g_idx_sort_indices=None,
|
||||
expert_map=None,
|
||||
global_num_experts=num_experts,
|
||||
weight_bits=4,
|
||||
is_k_full=True,
|
||||
)
|
||||
lora_info = SimpleNamespace(
|
||||
lora_use_virtual_experts=True,
|
||||
max_lora_rank=max_rank,
|
||||
has_active_lora=active_lora,
|
||||
gate_up_lora_a_weights=torch.zeros(
|
||||
slots,
|
||||
1 if shared_outer else num_experts,
|
||||
2 * max_rank,
|
||||
hidden_size,
|
||||
),
|
||||
gate_up_lora_b_weights=torch.zeros(
|
||||
slots, num_experts, 2 * expert_size, max_rank
|
||||
),
|
||||
down_lora_a_weights=torch.zeros(slots, num_experts, max_rank, expert_size),
|
||||
down_lora_b_weights=torch.zeros(
|
||||
slots,
|
||||
1 if shared_outer else num_experts,
|
||||
hidden_size,
|
||||
max_rank,
|
||||
),
|
||||
token_lora_mapping=(
|
||||
torch.full((tokens,), -1, dtype=torch.int32)
|
||||
if base_mapping
|
||||
else torch.arange(tokens, dtype=torch.int32).remainder(slots)
|
||||
),
|
||||
experts_shared_outer_loras=shared_outer,
|
||||
)
|
||||
runner_config = SimpleNamespace(
|
||||
activation="silu",
|
||||
routed_scaling_factor=1.0,
|
||||
num_experts=num_experts,
|
||||
num_local_experts=num_experts,
|
||||
)
|
||||
if ep:
|
||||
runner_config.num_experts = 2 * num_experts
|
||||
|
||||
result = module.fused_experts_experimental_sgl_marlin_lora(
|
||||
dispatch_output, quant_info, runner_config, lora_info
|
||||
)
|
||||
assert result.hidden_states.shape == hidden_states.shape
|
||||
calls.input_ptr = hidden_states.data_ptr()
|
||||
calls.result_ptr = result.hidden_states.data_ptr()
|
||||
calls.result = result.hidden_states
|
||||
calls.capture_event_count = len(module._MARLIN_LORA_OVERLAP_EVENTS)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("master", "split_gate_checks"), [(False, 0), (True, 1)])
|
||||
def test_two_stream_batch_gate_is_master_gated(monkeypatch, master, split_gate_checks):
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=1, master=master, two_stream=True)
|
||||
assert calls.split_gate_checks == split_gate_checks
|
||||
shrinks = [call for call in calls.merged if call["stage"] == "shrink"]
|
||||
assert shrinks[0]["stream"] == ("side" if master else "main")
|
||||
|
||||
|
||||
def test_shared_outer_factorization_runtime_flow(monkeypatch):
|
||||
tokens = 16
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=tokens)
|
||||
gate_expand = [
|
||||
call for call in calls.merged if call["stage"] == "expand" and call["broadcast"]
|
||||
]
|
||||
down_shrink = [call for call in calls.merged if call["stage"] == "shrink"]
|
||||
routing = [call for call in calls.merged if call["stage"] == "routing"]
|
||||
|
||||
assert len(gate_expand) == 1
|
||||
assert len(down_shrink) == 1
|
||||
assert calls.weighted_rank_sums == 0
|
||||
assert calls.fused_tails == 1
|
||||
assert gate_expand[0]["intermediate_shape"] == (tokens, 2)
|
||||
assert down_shrink[0]["intermediate_shape"] == (tokens, 2, 1)
|
||||
assert [(call["prewarm_a"], call["prewarm_b"]) for call in routing] == [
|
||||
(False, True),
|
||||
(True, False),
|
||||
]
|
||||
assert down_shrink[0]["prewarm_b"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slots", "rank"),
|
||||
[(8, 128), (16, 128)],
|
||||
)
|
||||
def test_ep_shared_outer_uses_safe_generic_fallback(monkeypatch, slots, rank):
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch,
|
||||
tokens=1,
|
||||
ep=True,
|
||||
slots=slots,
|
||||
rank=rank,
|
||||
)
|
||||
generic_down = [
|
||||
call for call in calls.merged if call["stage"] == "all" and call["shared_b"]
|
||||
]
|
||||
assert len(generic_down) == 1
|
||||
assert generic_down[0]["zero_intermediate"] is True
|
||||
assert generic_down[0]["direct_expand"] is (rank <= 64)
|
||||
|
||||
|
||||
def test_ep_shared_outer_low_rank_multi_slot_takes_factored_path(monkeypatch):
|
||||
# Slot-count gates are lifted: EP shared-outer rank<=64 pools of any size
|
||||
# collapse through the factored prefill path instead of the zeroed generic
|
||||
# fallback (routing is by adapter slot, so no unowned regions are read).
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch,
|
||||
tokens=1,
|
||||
ep=True,
|
||||
slots=5,
|
||||
rank=32,
|
||||
)
|
||||
generic_down = [
|
||||
call for call in calls.merged if call["stage"] == "all" and call["shared_b"]
|
||||
]
|
||||
assert not generic_down
|
||||
|
||||
|
||||
def test_ep_per_expert_layout_uses_generic_fallback(monkeypatch):
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch,
|
||||
tokens=1,
|
||||
ep=True,
|
||||
slots=8,
|
||||
rank=128,
|
||||
shared_outer=False,
|
||||
)
|
||||
generic_down = [
|
||||
call
|
||||
for call in calls.merged
|
||||
if call["stage"] == "all" and call["mul_routed_weight"]
|
||||
]
|
||||
assert len(generic_down) == 1
|
||||
assert generic_down[0]["shared_b"] is False
|
||||
assert generic_down[0]["zero_intermediate"] is False
|
||||
assert generic_down[0]["direct_expand"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected"),
|
||||
[
|
||||
("supported", True),
|
||||
("multi_slot", False),
|
||||
("non_shared", False),
|
||||
("rank_too_large", False),
|
||||
("single_route", False),
|
||||
("empty_batch", False),
|
||||
("mismatched_experts", False),
|
||||
],
|
||||
)
|
||||
def test_shared_outer_factorization_eligibility_is_narrow(monkeypatch, case, expected):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_eligibility")
|
||||
|
||||
rank = 65 if case == "rank_too_large" else 32
|
||||
slots = 2 if case == "multi_slot" else 1
|
||||
experts = 4
|
||||
hidden = 8
|
||||
intermediate = 3
|
||||
info = SimpleNamespace(
|
||||
max_lora_rank=rank,
|
||||
experts_shared_outer_loras=case != "non_shared",
|
||||
gate_up_lora_a_weights=torch.empty(slots, 1, 2 * rank, hidden),
|
||||
gate_up_lora_b_weights=torch.empty(slots, experts, 2 * intermediate, rank),
|
||||
down_lora_a_weights=torch.empty(
|
||||
slots,
|
||||
experts + (1 if case == "mismatched_experts" else 0),
|
||||
rank,
|
||||
intermediate,
|
||||
),
|
||||
down_lora_b_weights=torch.empty(slots, 1, hidden, rank),
|
||||
)
|
||||
tokens = 0 if case == "empty_batch" else 1
|
||||
topk = 1 if case == "single_route" else 2
|
||||
assert module._use_shared_outer_factorization(info, tokens, topk) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected"),
|
||||
[
|
||||
("supported", True),
|
||||
("three_slots", True),
|
||||
("one_slot", False),
|
||||
("five_slots", True),
|
||||
("sixteen_slots", True),
|
||||
("large_batch", False),
|
||||
("ep", False),
|
||||
("hopper", False),
|
||||
],
|
||||
)
|
||||
def test_multi_shared_outer_decode_factorization_is_narrow(monkeypatch, case, expected):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_multi_policy")
|
||||
monkeypatch.setattr(
|
||||
torch.cuda,
|
||||
"get_device_capability",
|
||||
lambda _device: (9, 0) if case == "hopper" else (10, 0),
|
||||
)
|
||||
slots = (
|
||||
1
|
||||
if case == "one_slot"
|
||||
else (
|
||||
3
|
||||
if case == "three_slots"
|
||||
else 5 if case == "five_slots" else 16 if case == "sixteen_slots" else 4
|
||||
)
|
||||
)
|
||||
info = SimpleNamespace(
|
||||
max_lora_rank=32,
|
||||
experts_shared_outer_loras=True,
|
||||
gate_up_lora_a_weights=SimpleNamespace(shape=(slots, 1, 64, 6144)),
|
||||
gate_up_lora_b_weights=SimpleNamespace(shape=(slots, 256, 768, 32)),
|
||||
down_lora_a_weights=SimpleNamespace(shape=(slots, 256, 32, 384)),
|
||||
down_lora_b_weights=SimpleNamespace(shape=(slots, 1, 6144, 32)),
|
||||
)
|
||||
hidden_states = SimpleNamespace(
|
||||
is_cuda=True, dtype=torch.bfloat16, device=torch.device("cuda")
|
||||
)
|
||||
assert (
|
||||
module._use_multi_shared_outer_decode_factorization(
|
||||
info,
|
||||
hidden_states,
|
||||
num_tokens=33 if case == "large_batch" else 32,
|
||||
hidden_size=6144,
|
||||
router_topk=6,
|
||||
num_experts=256,
|
||||
intermediate_size=384,
|
||||
ep_active=case == "ep",
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected"),
|
||||
[
|
||||
("supported", True),
|
||||
("four_slots", True),
|
||||
("decode_boundary", False),
|
||||
("one_slot", False),
|
||||
("five_slots", True),
|
||||
("ep", True),
|
||||
("ep_decode", True),
|
||||
("non_shared", False),
|
||||
("rank_too_large", False),
|
||||
("single_route", False),
|
||||
("mismatched_shape", False),
|
||||
],
|
||||
)
|
||||
def test_multi_shared_outer_prefill_factorization_is_narrow(
|
||||
monkeypatch, case, expected
|
||||
):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_prefill_policy")
|
||||
|
||||
slots = (
|
||||
1
|
||||
if case == "one_slot"
|
||||
else 5 if case == "five_slots" else 4 if case == "four_slots" else 2
|
||||
)
|
||||
rank = 65 if case == "rank_too_large" else 32
|
||||
experts = 256
|
||||
intermediate = 384
|
||||
hidden = 6144
|
||||
info = SimpleNamespace(
|
||||
max_lora_rank=rank,
|
||||
experts_shared_outer_loras=case != "non_shared",
|
||||
gate_up_lora_a_weights=SimpleNamespace(shape=(slots, 1, 2 * rank, hidden)),
|
||||
gate_up_lora_b_weights=SimpleNamespace(
|
||||
shape=(slots, experts, 2 * intermediate, rank)
|
||||
),
|
||||
down_lora_a_weights=SimpleNamespace(
|
||||
shape=(
|
||||
slots,
|
||||
experts + (1 if case == "mismatched_shape" else 0),
|
||||
rank,
|
||||
intermediate,
|
||||
)
|
||||
),
|
||||
down_lora_b_weights=SimpleNamespace(shape=(slots, 1, hidden, rank)),
|
||||
)
|
||||
assert (
|
||||
module._use_multi_shared_outer_prefill_factorization(
|
||||
info,
|
||||
num_tokens=32 if case in ("decode_boundary", "ep_decode") else 33,
|
||||
hidden_size=hidden,
|
||||
router_topk=1 if case == "single_route" else 6,
|
||||
num_experts=experts,
|
||||
intermediate_size=intermediate,
|
||||
ep_active=case in ("ep", "ep_decode"),
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected"),
|
||||
[
|
||||
("supported", True),
|
||||
("unfactored", False),
|
||||
("unfused", False),
|
||||
("ep", False),
|
||||
("large_batch", False),
|
||||
],
|
||||
)
|
||||
def test_direct_decode_selection_is_narrow(monkeypatch, case, expected):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_direct_policy")
|
||||
info = SimpleNamespace(
|
||||
gate_up_lora_b_weights=SimpleNamespace(shape=(3, 256, 768, 32)),
|
||||
down_lora_a_weights=SimpleNamespace(shape=(3, 256, 32, 384)),
|
||||
)
|
||||
assert (
|
||||
module._use_direct_decode_kernels(
|
||||
info,
|
||||
factored_shared_outer=case != "unfactored",
|
||||
fused_shared_outer_tail=case != "unfused",
|
||||
ep_active=case == "ep",
|
||||
num_tokens=33 if case == "large_batch" else 32,
|
||||
num_experts=256,
|
||||
intermediate_size=384,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected"),
|
||||
[
|
||||
("supported", True),
|
||||
("boundary_m", True),
|
||||
("hopper", False),
|
||||
("fp16", False),
|
||||
("rank64", False),
|
||||
("hidden", False),
|
||||
("topk", False),
|
||||
("large_m", False),
|
||||
],
|
||||
)
|
||||
def test_fused_shared_outer_tail_is_b200_inkling_specific(monkeypatch, case, expected):
|
||||
module = _load_marlin_runner(monkeypatch, "_marlin_runner_tail_policy")
|
||||
monkeypatch.setattr(
|
||||
torch.cuda,
|
||||
"get_device_capability",
|
||||
lambda _device: (9, 0) if case == "hopper" else (10, 0),
|
||||
)
|
||||
info = SimpleNamespace(max_lora_rank=64 if case == "rank64" else 32)
|
||||
hidden_states = SimpleNamespace(
|
||||
is_cuda=True,
|
||||
dtype=torch.float16 if case == "fp16" else torch.bfloat16,
|
||||
device=torch.device("cuda"),
|
||||
)
|
||||
assert (
|
||||
module._use_fused_shared_outer_tail(
|
||||
info,
|
||||
hidden_states,
|
||||
513 if case == "large_m" else 512 if case == "boundary_m" else 32,
|
||||
4096 if case == "hidden" else 6144,
|
||||
8 if case == "topk" else 6,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
def test_multi_prefill_collapses_only_shared_factors_and_separates_caches(
|
||||
monkeypatch,
|
||||
):
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=64, slots=3)
|
||||
|
||||
routing = [call for call in calls.merged if call["stage"] == "routing"]
|
||||
full_routing = [call for call in routing if call["topk_shape"] == (64, 2)]
|
||||
collapsed_routing = [call for call in routing if call["topk_shape"] == (64, 1)]
|
||||
assert [(call["prewarm_a"], call["prewarm_b"]) for call in full_routing] == [
|
||||
(False, True), # real-route per-expert gate B
|
||||
(True, False), # real-route per-expert down A
|
||||
]
|
||||
assert [(call["prewarm_a"], call["prewarm_b"]) for call in collapsed_routing] == [
|
||||
(True, False), # collapsed selected shared gate A
|
||||
(False, True), # collapsed selected shared down B
|
||||
]
|
||||
assert len({call["cache_id"] for call in full_routing}) == 1
|
||||
assert len({call["cache_id"] for call in collapsed_routing}) == 1
|
||||
assert full_routing[0]["cache_id"] != collapsed_routing[0]["cache_id"]
|
||||
|
||||
gate_shrink = next(
|
||||
call for call in calls.merged if call["stage"] == "shrink" and call["shared_a"]
|
||||
)
|
||||
gate_expand = next(
|
||||
call for call in calls.merged if call["stage"] == "expand" and call["broadcast"]
|
||||
)
|
||||
down_shrink = next(
|
||||
call
|
||||
for call in calls.merged
|
||||
if call["stage"] == "shrink" and not call["shared_a"]
|
||||
)
|
||||
down_expand = next(
|
||||
call for call in calls.merged if call["stage"] == "expand" and call["shared_b"]
|
||||
)
|
||||
|
||||
assert gate_shrink["topk_shape"] == (64, 1)
|
||||
assert gate_shrink["intermediate_shape"] == (64, 2)
|
||||
assert gate_expand["topk_shape"] == (64, 2)
|
||||
assert gate_expand["intermediate_shape"] == (64, 2)
|
||||
assert down_shrink["topk_shape"] == (64, 2)
|
||||
assert down_shrink["intermediate_shape"] == (64, 2, 1)
|
||||
assert down_expand["topk_shape"] == (64, 1)
|
||||
assert down_expand["intermediate_shape"] == (64, 1)
|
||||
assert down_expand["fuse_add"] is True
|
||||
assert down_expand["direct_expand"] is False
|
||||
assert down_expand["mul_routed_weight"] is False
|
||||
assert calls.weighted_rank_sums == 1
|
||||
# The mapped one-token-per-CTA tail remains decode-only.
|
||||
assert calls.fused_tails == 0
|
||||
|
||||
|
||||
def test_multi_prefill_none_rows_preserve_base_reduction(monkeypatch):
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch,
|
||||
tokens=64,
|
||||
slots=2,
|
||||
capture=True,
|
||||
active_lora=False,
|
||||
base_mapping=True,
|
||||
base_value=3.0,
|
||||
)
|
||||
|
||||
down_expand = next(
|
||||
call for call in calls.merged if call["stage"] == "expand" and call["shared_b"]
|
||||
)
|
||||
assert down_expand["topk_shape"] == (64, 1)
|
||||
assert torch.equal(down_expand["mapping"], torch.full((64,), -1, dtype=torch.int32))
|
||||
# The fake Marlin down output is 3 for each of two routes. The collapsed
|
||||
# shared-B expand masks every None row, so it must leave the base sum at 6.
|
||||
torch.testing.assert_close(calls.result, torch.full_like(calls.result, 6.0))
|
||||
assert calls.fused_tails == 0
|
||||
|
||||
|
||||
def test_direct_decode_skips_virtual_routing_and_zero_fill(monkeypatch):
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch, tokens=16, two_stream=True, direct_decode=True
|
||||
)
|
||||
|
||||
assert [call for call in calls.merged if call["stage"] == "routing"] == []
|
||||
assert [call for call in calls.merged if call["stage"] == "shrink"] == []
|
||||
assert calls.direct_gate == 1
|
||||
assert calls.direct_down == 1
|
||||
assert calls.zeroed == []
|
||||
assert calls.fused_tails == 1
|
||||
|
||||
|
||||
def test_ep_uses_local_alignment_and_skips_nonlocal_marlin_blocks(monkeypatch):
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=16, ep=True)
|
||||
|
||||
assert calls.align_num_experts == [1]
|
||||
assert calls.marlin_is_ep == [True, True]
|
||||
assert calls.cache3_was_zero is True
|
||||
|
||||
|
||||
def test_factored_decode_two_stream_schedule_and_ownership(monkeypatch):
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=16, two_stream=True)
|
||||
shrinks = [call for call in calls.merged if call["stage"] == "shrink"]
|
||||
|
||||
assert len(shrinks) == 1
|
||||
assert shrinks[0]["stream"] == "side"
|
||||
assert shrinks[0]["prewarm_b"] is False
|
||||
assert len(calls.zeroed) == 1
|
||||
assert calls.zeroed[0][1] == "side"
|
||||
|
||||
buffer_id = shrinks[0]["intermediate_id"]
|
||||
assert calls.zeroed[0][2] == buffer_id
|
||||
zero_index = calls.schedule.index(calls.zeroed[0])
|
||||
shrink_index = next(
|
||||
index
|
||||
for index, item in enumerate(calls.schedule)
|
||||
if item[:3] == ("merged", "shrink", "side")
|
||||
)
|
||||
down_record = calls.event_records[-1]
|
||||
down_wait = calls.event_waits[-1]
|
||||
record_index = calls.schedule.index(down_record)
|
||||
wait_index = calls.schedule.index(down_wait)
|
||||
fused_index = next(
|
||||
index for index, item in enumerate(calls.schedule) if item[0] == "fused_tail"
|
||||
)
|
||||
assert down_record[2] == "side"
|
||||
assert down_wait == ("wait_event", "main", down_record[1])
|
||||
assert zero_index < shrink_index < record_index < wait_index < fused_index
|
||||
assert fused_index == len(calls.schedule) - 1
|
||||
|
||||
|
||||
def test_factored_decode_single_stream_fallback_has_one_main_shrink(monkeypatch):
|
||||
calls = _run_marlin_policy(monkeypatch, tokens=16, two_stream=False)
|
||||
shrinks = [call for call in calls.merged if call["stage"] == "shrink"]
|
||||
|
||||
assert len(shrinks) == 1
|
||||
assert shrinks[0]["stream"] == "main"
|
||||
assert shrinks[0]["prewarm_b"] is False
|
||||
assert len(calls.zeroed) == 1
|
||||
assert calls.zeroed[0][1] == "main"
|
||||
assert calls.event_records == []
|
||||
assert calls.event_waits == []
|
||||
|
||||
buffer_id = shrinks[0]["intermediate_id"]
|
||||
assert calls.zeroed[0][2] == buffer_id
|
||||
second_marlin_index = max(
|
||||
index for index, item in enumerate(calls.schedule) if item[0] == "marlin"
|
||||
)
|
||||
zero_index = calls.schedule.index(calls.zeroed[0])
|
||||
shrink_index = next(
|
||||
index
|
||||
for index, item in enumerate(calls.schedule)
|
||||
if item[:3] == ("merged", "shrink", "main")
|
||||
)
|
||||
fused_index = next(
|
||||
index for index, item in enumerate(calls.schedule) if item[0] == "fused_tail"
|
||||
)
|
||||
assert second_marlin_index < zero_index < shrink_index < fused_index
|
||||
|
||||
|
||||
def test_factored_decode_capture_base_rows_keep_main_owned_buffers(monkeypatch):
|
||||
calls = _run_marlin_policy(
|
||||
monkeypatch,
|
||||
tokens=16,
|
||||
two_stream=True,
|
||||
capture=True,
|
||||
active_lora=False,
|
||||
base_mapping=True,
|
||||
)
|
||||
shrinks = [call for call in calls.merged if call["stage"] == "shrink"]
|
||||
|
||||
assert len(shrinks) == 1
|
||||
assert len(calls.zeroed) == 1
|
||||
assert calls.zeroed[0][1] == "side"
|
||||
assert calls.zeroed[0][2] == shrinks[0]["intermediate_id"]
|
||||
assert calls.weighted_rank_sums == 0
|
||||
assert calls.fused_tails == 1
|
||||
assert calls.capture_event_count == 3
|
||||
assert calls.result_ptr != calls.input_ptr
|
||||
torch.testing.assert_close(calls.result, torch.zeros_like(calls.result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,306 @@
|
||||
"""CUDA parity tests for the fused shared-outer Marlin decode reduction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=20, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
|
||||
_CUDA_BF16_AVAILABLE = bool(
|
||||
torch.cuda.is_available()
|
||||
and torch.version.hip is None
|
||||
and torch.cuda.get_device_capability()[0] >= 8
|
||||
)
|
||||
|
||||
|
||||
def _reference_reduce(
|
||||
routed_base: torch.Tensor,
|
||||
routed_rank: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
shared_b: torch.Tensor,
|
||||
routed_scaling_factor: float,
|
||||
) -> torch.Tensor:
|
||||
"""Materialize the three production operations with their BF16 boundaries."""
|
||||
|
||||
operand_dtype = routed_base.dtype
|
||||
base_sum = (
|
||||
routed_base.to(torch.float32)
|
||||
.sum(dim=1)
|
||||
.mul(routed_scaling_factor)
|
||||
.to(operand_dtype)
|
||||
)
|
||||
rank_sum = (
|
||||
(routed_rank.to(torch.float32) * topk_weights.to(torch.float32).unsqueeze(-1))
|
||||
.sum(dim=1)
|
||||
.mul(routed_scaling_factor)
|
||||
.to(operand_dtype)
|
||||
)
|
||||
base_sum.addmm_(rank_sum, shared_b.T)
|
||||
return base_sum
|
||||
|
||||
|
||||
def _mapped_reference_reduce(
|
||||
routed_base: torch.Tensor,
|
||||
routed_rank: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
shared_b: torch.Tensor,
|
||||
token_lora_mapping: torch.Tensor,
|
||||
routed_scaling_factor: float,
|
||||
) -> torch.Tensor:
|
||||
dtype = routed_base.dtype
|
||||
base_sum = routed_base.float().sum(dim=1).mul(routed_scaling_factor).to(dtype)
|
||||
rank_sum = (
|
||||
(routed_rank.float() * topk_weights.unsqueeze(-1))
|
||||
.sum(dim=1)
|
||||
.mul(routed_scaling_factor)
|
||||
.to(dtype)
|
||||
)
|
||||
output = base_sum.clone()
|
||||
for slot in range(shared_b.shape[0]):
|
||||
rows = token_lora_mapping == slot
|
||||
if rows.any():
|
||||
output[rows] = torch.addmm(
|
||||
base_sum[rows], rank_sum[rows], shared_b[slot, 0].T
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _CUDA_BF16_AVAILABLE,
|
||||
reason="fused shared-outer reduction requires a CUDA GPU with BF16 tensor cores",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("num_tokens", "rank", "routed_scaling_factor", "hidden_width"),
|
||||
[
|
||||
(1, 16, 1.0, 128),
|
||||
(2, 32, 1.75, 137),
|
||||
(4, 64, 1.0, 128),
|
||||
(32, 16, 1.75, 137),
|
||||
(512, 64, 1.0, 137),
|
||||
],
|
||||
)
|
||||
def test_fused_base_shared_lora_reduce_cuda_graph_parity(
|
||||
num_tokens: int,
|
||||
rank: int,
|
||||
routed_scaling_factor: float,
|
||||
hidden_width: int,
|
||||
):
|
||||
"""Graph replay matches sum + rounded rank reduction + shared-B addmm."""
|
||||
|
||||
from sglang.srt.lora.marlin_lora_temp.shared_outer import (
|
||||
fused_base_shared_lora_reduce,
|
||||
fused_base_shared_lora_reduce_config,
|
||||
)
|
||||
|
||||
device = torch.device("cuda")
|
||||
topk = 6
|
||||
dtype = torch.bfloat16
|
||||
generator = torch.Generator(device=device).manual_seed(
|
||||
1000 + num_tokens * 100 + rank + hidden_width
|
||||
)
|
||||
|
||||
routed_base = (
|
||||
torch.randn(
|
||||
(num_tokens, topk, hidden_width),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
generator=generator,
|
||||
)
|
||||
* 0.05
|
||||
)
|
||||
routed_rank = (
|
||||
torch.randn(
|
||||
(num_tokens, topk, rank),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
generator=generator,
|
||||
)
|
||||
* 0.05
|
||||
)
|
||||
topk_weights = torch.softmax(
|
||||
torch.randn(
|
||||
(num_tokens, topk),
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
generator=generator,
|
||||
),
|
||||
dim=1,
|
||||
).contiguous()
|
||||
shared_b = (
|
||||
torch.randn(
|
||||
(hidden_width, rank),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
generator=generator,
|
||||
)
|
||||
* 0.05
|
||||
)
|
||||
output = torch.empty((num_tokens, hidden_width), device=device, dtype=dtype)
|
||||
|
||||
block_m, block_k = fused_base_shared_lora_reduce_config(num_tokens)
|
||||
|
||||
def invoke() -> None:
|
||||
fused_base_shared_lora_reduce(
|
||||
routed_base,
|
||||
routed_rank,
|
||||
topk_weights,
|
||||
shared_b,
|
||||
output,
|
||||
routed_scaling_factor,
|
||||
block_m=block_m,
|
||||
block_k=block_k,
|
||||
)
|
||||
|
||||
# Compile the rank/block specialization and initialize CUDA state away from
|
||||
# the capture stream.
|
||||
warmup_stream = torch.cuda.Stream()
|
||||
warmup_stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup_stream):
|
||||
for _ in range(3):
|
||||
invoke()
|
||||
torch.cuda.current_stream().wait_stream(warmup_stream)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
stable_tensors = (routed_base, routed_rank, topk_weights, shared_b, output)
|
||||
stable_addresses = tuple(tensor.data_ptr() for tensor in stable_tensors)
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
invoke()
|
||||
|
||||
for replay in range(2):
|
||||
# Mutate every captured operand in place so replay proves that the graph
|
||||
# follows stable addresses rather than values observed during capture.
|
||||
routed_base.mul_(0.75).add_(0.002 * (replay + 1))
|
||||
routed_rank.mul_(-0.5).add_(0.001 * (replay + 1))
|
||||
topk_weights.copy_(torch.roll(topk_weights, shifts=1, dims=1))
|
||||
shared_b.mul_(0.875).add_(0.0005 * (replay + 1))
|
||||
output.fill_(float("nan"))
|
||||
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
assert tuple(tensor.data_ptr() for tensor in stable_tensors) == stable_addresses
|
||||
expected = _reference_reduce(
|
||||
routed_base,
|
||||
routed_rank,
|
||||
topk_weights,
|
||||
shared_b,
|
||||
routed_scaling_factor,
|
||||
)
|
||||
torch.testing.assert_close(output, expected, rtol=0.03, atol=0.004)
|
||||
assert torch.isfinite(output).all().item()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _CUDA_BF16_AVAILABLE,
|
||||
reason="mapped shared-outer reduction requires CUDA BF16 tensor cores",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("num_slots", "num_tokens", "hidden_width"),
|
||||
[(2, 1, 128), (3, 32, 137)],
|
||||
)
|
||||
def test_fused_base_mapped_shared_lora_reduce_cuda_graph_parity(
|
||||
num_tokens: int, num_slots: int, hidden_width: int
|
||||
):
|
||||
from sglang.srt.lora.marlin_lora_temp.shared_outer import (
|
||||
fused_base_mapped_shared_lora_reduce,
|
||||
)
|
||||
|
||||
device = torch.device("cuda")
|
||||
generator = torch.Generator(device=device).manual_seed(
|
||||
7000 + num_tokens * 100 + num_slots * 10 + hidden_width
|
||||
)
|
||||
routed_base = 0.05 * torch.randn(
|
||||
(num_tokens, 6, hidden_width),
|
||||
device=device,
|
||||
dtype=torch.bfloat16,
|
||||
generator=generator,
|
||||
)
|
||||
routed_rank = 0.05 * torch.randn(
|
||||
(num_tokens, 6, 32),
|
||||
device=device,
|
||||
dtype=torch.bfloat16,
|
||||
generator=generator,
|
||||
)
|
||||
topk_weights = torch.softmax(
|
||||
torch.randn(
|
||||
(num_tokens, 6),
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
generator=generator,
|
||||
),
|
||||
dim=1,
|
||||
).contiguous()
|
||||
shared_b = 0.05 * torch.randn(
|
||||
(num_slots, 1, hidden_width, 32),
|
||||
device=device,
|
||||
dtype=torch.bfloat16,
|
||||
generator=generator,
|
||||
)
|
||||
token_lora_mapping = torch.arange(
|
||||
num_tokens, device=device, dtype=torch.int32
|
||||
).remainder(num_slots)
|
||||
if num_tokens > 1:
|
||||
token_lora_mapping[-1] = -1
|
||||
output = torch.empty(
|
||||
(num_tokens, hidden_width), device=device, dtype=torch.bfloat16
|
||||
)
|
||||
|
||||
def invoke() -> None:
|
||||
fused_base_mapped_shared_lora_reduce(
|
||||
routed_base,
|
||||
routed_rank,
|
||||
topk_weights,
|
||||
shared_b,
|
||||
token_lora_mapping,
|
||||
output,
|
||||
1.75,
|
||||
block_k=64,
|
||||
)
|
||||
|
||||
warmup_stream = torch.cuda.Stream()
|
||||
warmup_stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup_stream):
|
||||
for _ in range(3):
|
||||
invoke()
|
||||
torch.cuda.current_stream().wait_stream(warmup_stream)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
invoke()
|
||||
|
||||
token_lora_mapping.copy_((token_lora_mapping + 1).remainder(num_slots))
|
||||
if num_tokens > 1:
|
||||
token_lora_mapping[0] = -1
|
||||
routed_base.mul_(0.75)
|
||||
routed_rank.mul_(-0.5)
|
||||
shared_b.mul_(0.875)
|
||||
output.fill_(float("nan"))
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
expected = _mapped_reference_reduce(
|
||||
routed_base,
|
||||
routed_rank,
|
||||
topk_weights,
|
||||
shared_b,
|
||||
token_lora_mapping,
|
||||
1.75,
|
||||
)
|
||||
torch.testing.assert_close(output, expected, rtol=0.03, atol=0.004)
|
||||
assert torch.isfinite(output).all().item()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
"""Hermetic regression tests for Inkling shared-sink LoRA normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=1, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: newly-added inkling LoRA test, disabled pending stabilization.
|
||||
pytestmark = pytest.mark.skip(reason="new inkling LoRA test; disabled on CI")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
LORA_PATH = REPO_ROOT / "python/sglang/srt/lora/lora.py"
|
||||
|
||||
|
||||
def _load_normalizer_class():
|
||||
tree = ast.parse(LORA_PATH.read_text())
|
||||
source_class = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "LoRAAdapter"
|
||||
)
|
||||
method_names = {"_normalize_shared_expert_moe", "normalize_gate_up_proj"}
|
||||
methods = [
|
||||
node
|
||||
for node in source_class.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name in method_names
|
||||
]
|
||||
assert {method.name for method in methods} == method_names
|
||||
test_class = ast.ClassDef(
|
||||
name="_NormalizerUnderTest",
|
||||
bases=[],
|
||||
keywords=[],
|
||||
body=methods,
|
||||
decorator_list=[],
|
||||
)
|
||||
namespace = {"Dict": dict, "re": __import__("re"), "torch": torch}
|
||||
exec(
|
||||
compile(
|
||||
ast.fix_missing_locations(ast.Module(body=[test_class], type_ignores=[])),
|
||||
str(LORA_PATH),
|
||||
"exec",
|
||||
),
|
||||
namespace,
|
||||
)
|
||||
return namespace[test_class.name]
|
||||
|
||||
|
||||
def _normalizer(num_shared: int = 2, *, text_config: bool = False):
|
||||
normalizer = _load_normalizer_class()()
|
||||
config = SimpleNamespace(
|
||||
architectures=None if text_config else ["InklingForConditionalGeneration"],
|
||||
model_type="inkling_text" if text_config else "inkling",
|
||||
n_shared_experts=num_shared,
|
||||
)
|
||||
normalizer.base_hf_config = config
|
||||
return normalizer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text_config", [False, True])
|
||||
def test_proj_named_shared_sink_factors_gain_the_expert_axis(text_config):
|
||||
n, rank, hidden, intermediate = 2, 3, 5, 7
|
||||
prefix = "model.layers.0.mlp.shared_experts"
|
||||
gate_a = torch.arange(rank * hidden).reshape(rank, hidden)
|
||||
gate_b = torch.arange(n * 2 * intermediate * rank).reshape(
|
||||
n * 2 * intermediate, rank
|
||||
)
|
||||
down_a = torch.arange(rank * n * intermediate).reshape(rank, n * intermediate)
|
||||
down_b = torch.arange(hidden * rank).reshape(hidden, rank)
|
||||
weights = {
|
||||
f"{prefix}.gate_up_proj.lora_A.weight": gate_a,
|
||||
f"{prefix}.gate_up_proj.lora_B.weight": gate_b,
|
||||
f"{prefix}.down_proj.lora_A.weight": down_a,
|
||||
f"{prefix}.down_proj.lora_B.weight": down_b,
|
||||
}
|
||||
|
||||
normalizer = _normalizer(n, text_config=text_config)
|
||||
normalizer._normalize_shared_expert_moe(weights)
|
||||
normalizer.normalize_gate_up_proj(list(weights), weights)
|
||||
|
||||
torch.testing.assert_close(
|
||||
weights[f"{prefix}.gate_up_proj.lora_A.weight"],
|
||||
gate_a.unsqueeze(0).repeat(1, 2, 1),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
weights[f"{prefix}.gate_up_proj.lora_B.weight"],
|
||||
gate_b.reshape(n, 2 * intermediate, rank),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
weights[f"{prefix}.down_proj.lora_A.weight"],
|
||||
down_a.reshape(rank, n, intermediate).transpose(0, 1).contiguous(),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
weights[f"{prefix}.down_proj.lora_B.weight"], down_b.unsqueeze(0)
|
||||
)
|
||||
|
||||
|
||||
def test_named_per_expert_outer_factor_is_not_collapsed_to_shared_outer():
|
||||
name = "model.layers.0.mlp.shared_experts.1.gate_up_proj.lora_A.weight"
|
||||
weight = torch.arange(15).reshape(3, 5)
|
||||
weights = {name: weight}
|
||||
|
||||
_normalizer()._normalize_shared_expert_moe(weights)
|
||||
|
||||
torch.testing.assert_close(weights[name], weight)
|
||||
assert weights[name].dim() == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Hermetic stream-order checks for Inkling shared/routed overlap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
|
||||
register_cuda_ci(est_time=5, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
# Skipped on CI: this hermetic check re-parses the InklingMoE forward source and
|
||||
# pins its exact stream-order, so it breaks on unrelated refactors of that
|
||||
# method. Skip until it is rebuilt against a stable seam.
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason="refactor-fragile source-parsing unit test; skipped on CI"
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
MOE_PATH = REPO_ROOT / "python/sglang/srt/models/inkling_common/moe.py"
|
||||
INKLING_DENSE_PATH = (
|
||||
REPO_ROOT / "python/sglang/srt/lora/trtllm_lora_temp/inkling_dense.py"
|
||||
)
|
||||
|
||||
|
||||
class _Flag:
|
||||
def __init__(self, value: bool):
|
||||
self.value = value
|
||||
|
||||
def get(self) -> bool:
|
||||
return self.value
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, name: str, events: list[str]):
|
||||
self.name = name
|
||||
self.events = events
|
||||
|
||||
def wait_stream(self, other: _Stream) -> None:
|
||||
self.events.append(f"{self.name}.wait({other.name})")
|
||||
|
||||
|
||||
class _StreamContext:
|
||||
def __init__(self, cuda, stream: _Stream):
|
||||
self.cuda = cuda
|
||||
self.stream = stream
|
||||
self.previous = None
|
||||
|
||||
def __enter__(self):
|
||||
self.previous = self.cuda.current
|
||||
self.cuda.current = self.stream
|
||||
self.cuda.events.append(f"enter({self.stream.name})")
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.cuda.events.append(f"exit({self.stream.name})")
|
||||
self.cuda.current = self.previous
|
||||
|
||||
|
||||
class _Cuda:
|
||||
def __init__(self, events: list[str]):
|
||||
self.events = events
|
||||
self.current = _Stream("main", events)
|
||||
|
||||
def current_stream(self) -> _Stream:
|
||||
return self.current
|
||||
|
||||
def stream(self, stream: _Stream) -> _StreamContext:
|
||||
return _StreamContext(self, stream)
|
||||
|
||||
|
||||
class _Tensor:
|
||||
def __init__(self, name: str, events: list[str], *, tokens: int = 1):
|
||||
self.name = name
|
||||
self.events = events
|
||||
self.shape = (tokens, 8)
|
||||
self.dtype = "bf16"
|
||||
self.is_cuda = True
|
||||
|
||||
def record_stream(self, stream: _Stream) -> None:
|
||||
self.events.append(f"{self.name}.record({stream.name})")
|
||||
|
||||
def __add__(self, other: _Tensor) -> _Tensor:
|
||||
self.events.append(f"add({self.name},{other.name})")
|
||||
return _Tensor("sum", self.events)
|
||||
|
||||
|
||||
def _load_forward(fake_torch, capture: bool = False):
|
||||
tree = ast.parse(MOE_PATH.read_text())
|
||||
source_class = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "InklingMoE"
|
||||
)
|
||||
forward = next(
|
||||
node
|
||||
for node in source_class.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "forward"
|
||||
)
|
||||
test_class = ast.ClassDef(
|
||||
name="_InklingMoEForwardUnderTest",
|
||||
bases=[],
|
||||
keywords=[],
|
||||
body=[forward],
|
||||
decorator_list=[],
|
||||
)
|
||||
namespace = {
|
||||
"ForwardBatch": object,
|
||||
"envs": SimpleNamespace(
|
||||
SGLANG_OPT_USE_INKLING_MULTI_STREAM_OVERLAP=_Flag(True)
|
||||
),
|
||||
# capture gating: overlap only inside cuda-graph capture
|
||||
"get_is_capture_mode": lambda: capture,
|
||||
"get_ar_buffer": lambda *_: None,
|
||||
"get_tensor_model_parallel_group": lambda: SimpleNamespace(world_size=1),
|
||||
"lora_compatible_layout_enabled": lambda: True,
|
||||
"torch": fake_torch,
|
||||
}
|
||||
exec(
|
||||
compile(
|
||||
ast.fix_missing_locations(ast.Module(body=[test_class], type_ignores=[])),
|
||||
str(MOE_PATH),
|
||||
"exec",
|
||||
),
|
||||
namespace,
|
||||
)
|
||||
return namespace[test_class.name]
|
||||
|
||||
|
||||
def _load_lora_overlap_policy():
|
||||
tree = ast.parse(INKLING_DENSE_PATH.read_text())
|
||||
policy = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "allow_inkling_moe_two_stream"
|
||||
)
|
||||
namespace = {}
|
||||
exec(
|
||||
compile(
|
||||
ast.fix_missing_locations(ast.Module(body=[policy], type_ignores=[])),
|
||||
str(INKLING_DENSE_PATH),
|
||||
"exec",
|
||||
),
|
||||
namespace,
|
||||
)
|
||||
return namespace[policy.name]
|
||||
|
||||
|
||||
def _make_moe(events: list[str], cuda: _Cuda, capture: bool = False):
|
||||
fake_torch = SimpleNamespace(Tensor=_Tensor, cuda=cuda)
|
||||
moe = _load_forward(fake_torch, capture)()
|
||||
moe.alt_stream = _Stream("alt", events)
|
||||
moe.shared_experts = SimpleNamespace(
|
||||
lora_backend=SimpleNamespace(batch_info=SimpleNamespace(has_active_lora=True))
|
||||
)
|
||||
moe.experts = SimpleNamespace()
|
||||
moe._clone_fused_sink_input = False
|
||||
moe._fused_ar_shared = False
|
||||
moe.gate = lambda x: (
|
||||
_Tensor("topk_weights", events),
|
||||
_Tensor("topk_ids", events),
|
||||
_Tensor("gammas", events),
|
||||
None,
|
||||
)
|
||||
|
||||
def forward_shared(x, gammas):
|
||||
events.append(f"shared({cuda.current.name})")
|
||||
return _Tensor("shared_out", events)
|
||||
|
||||
def forward_routed(*_):
|
||||
assert cuda.current.name == "main"
|
||||
events.append("routed(main)")
|
||||
return _Tensor("routed_out", events)
|
||||
|
||||
moe._forward_shared = forward_shared
|
||||
moe._forward_routed = forward_routed
|
||||
return moe
|
||||
|
||||
|
||||
def _install_lora_policy(monkeypatch, *, main_alloc: bool, capture: bool = False):
|
||||
lora_envs = SimpleNamespace(SGLANG_OPT_LORA_OVERLAP_MAIN_ALLOC=_Flag(main_alloc))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"sglang.srt.lora.trtllm_lora_temp.environ",
|
||||
types.SimpleNamespace(lora_envs=lora_envs),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"sglang.srt.model_executor.runner_utils.capture_mode",
|
||||
types.SimpleNamespace(get_is_capture_mode=lambda: capture),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"sglang.srt.lora.trtllm_lora_temp.inkling_dense",
|
||||
types.SimpleNamespace(allow_inkling_moe_two_stream=_load_lora_overlap_policy()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tokens", [1, 32])
|
||||
def test_direct_sink_keeps_decode_overlap(monkeypatch, tokens):
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=True, capture=True)
|
||||
moe = _make_moe(events, cuda, capture=True)
|
||||
|
||||
moe.forward(_Tensor("x", events, tokens=tokens), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"x.record(alt)",
|
||||
"gammas.record(alt)",
|
||||
"alt.wait(main)",
|
||||
"enter(alt)",
|
||||
"shared(alt)",
|
||||
"exit(alt)",
|
||||
"routed(main)",
|
||||
"main.wait(alt)",
|
||||
"shared_out.record(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
def test_lora_prefill_stays_serial(monkeypatch):
|
||||
# Even when capture would allow overlap, the M>32 LoRA policy forces serial.
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=True, capture=True)
|
||||
moe = _make_moe(events, cuda, capture=True)
|
||||
|
||||
moe.forward(_Tensor("x", events, tokens=33), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"routed(main)",
|
||||
"shared(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
def test_captured_prefill_stays_serial_even_base_only(monkeypatch):
|
||||
# Capture forces has_lora_work (one schedule for every replay), so
|
||||
# prefill-sized batches (>32 tokens) are serial even with no live adapter.
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=True, capture=True)
|
||||
moe = _make_moe(events, cuda, capture=True)
|
||||
moe.shared_experts.lora_backend.batch_info.has_active_lora = False
|
||||
|
||||
moe.forward(_Tensor("x", events, tokens=33), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"routed(main)",
|
||||
"shared(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
def test_eager_forward_stays_serial_even_base_only(monkeypatch):
|
||||
# overlap is gated on cuda-graph capture; eager forwards are serial.
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=False, capture=False)
|
||||
moe = _make_moe(events, cuda, capture=False)
|
||||
moe.shared_experts.lora_backend.batch_info.has_active_lora = False
|
||||
|
||||
moe.forward(_Tensor("x", events, tokens=33), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"routed(main)",
|
||||
"shared(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
def test_lora_overlap_stays_serial_without_main_alloc(monkeypatch):
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=False, capture=True)
|
||||
moe = _make_moe(events, cuda, capture=True)
|
||||
|
||||
moe.forward(_Tensor("x", events), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"routed(main)",
|
||||
"shared(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
def test_capture_keeps_lora_schedule_without_active_adapter(monkeypatch):
|
||||
events: list[str] = []
|
||||
cuda = _Cuda(events)
|
||||
_install_lora_policy(monkeypatch, main_alloc=False, capture=True)
|
||||
moe = _make_moe(events, cuda, capture=True)
|
||||
moe.shared_experts.lora_backend.batch_info.has_active_lora = False
|
||||
|
||||
moe.forward(_Tensor("x", events), reduce=False)
|
||||
|
||||
assert events == [
|
||||
"routed(main)",
|
||||
"shared(main)",
|
||||
"add(routed_out,shared_out)",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -19,19 +19,164 @@ from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
|
||||
register_cuda_ci(est_time=9, stage="base-b", runner_config="1-gpu-small")
|
||||
register_amd_ci(est_time=9, suite="stage-b-test-1-gpu-small-amd")
|
||||
|
||||
import ast
|
||||
import types
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.lora.mem_pool import (
|
||||
LoRAMemoryPool,
|
||||
_get_moe_ep_context,
|
||||
_get_moe_tp_context,
|
||||
from sglang.srt.lora.eviction_policy import get_eviction_policy
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
MOE_UTILS_PATH = REPO_ROOT / "python/sglang/srt/layers/moe/utils.py"
|
||||
STANDARD_DISPATCHER_PATH = (
|
||||
REPO_ROOT / "python/sglang/srt/layers/moe/token_dispatcher/standard.py"
|
||||
)
|
||||
|
||||
|
||||
class _FakeBaseLayerWithLoRA:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeFusedMoEWithLoRA(_FakeBaseLayerWithLoRA):
|
||||
pass
|
||||
|
||||
|
||||
_LORA_LAYERS_STUB = types.ModuleType("sglang.srt.lora.layers")
|
||||
_LORA_LAYERS_STUB.BaseLayerWithLoRA = _FakeBaseLayerWithLoRA
|
||||
_LORA_LAYERS_STUB.FusedMoEWithLoRA = _FakeFusedMoEWithLoRA
|
||||
_LORA_ADAPTER_STUB = types.ModuleType("sglang.srt.lora.lora")
|
||||
_LORA_ADAPTER_STUB.LoRAAdapter = object
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"sglang.srt.lora.layers": _LORA_LAYERS_STUB,
|
||||
"sglang.srt.lora.lora": _LORA_ADAPTER_STUB,
|
||||
},
|
||||
):
|
||||
import sglang.srt.lora.mem_pool as mem_pool_module
|
||||
from sglang.srt.lora.mem_pool import (
|
||||
EMPTY_SLOT,
|
||||
LoRAMemoryPool,
|
||||
_get_moe_ep_context,
|
||||
_get_moe_tp_context,
|
||||
_moe_runner_keeps_global_expert_ids,
|
||||
)
|
||||
|
||||
|
||||
class _IdentityMoeSlices:
|
||||
def slice_moe_lora_a_weights(self, weights, _rank, _target):
|
||||
return weights
|
||||
|
||||
def slice_moe_lora_b_weights(self, weights, _rank, _target):
|
||||
return weights
|
||||
|
||||
|
||||
class _FakeSharedMoeLayer(_IdentityMoeSlices):
|
||||
is_shared_fused_moe = True
|
||||
|
||||
def __init__(self, tp_rank: int = 0):
|
||||
self.moe_tp_rank = tp_rank
|
||||
|
||||
|
||||
class _FakeRoutedMoeLayer(_FakeFusedMoEWithLoRA, _IdentityMoeSlices):
|
||||
def __init__(self, tp_rank: int = 0):
|
||||
object.__setattr__(
|
||||
self,
|
||||
"base_layer",
|
||||
types.SimpleNamespace(
|
||||
moe_tp_rank=tp_rank,
|
||||
is_shared_fused_moe=False,
|
||||
),
|
||||
)
|
||||
|
||||
def slice_moe_lora_a_weights(self, weights, _rank, _target):
|
||||
return weights
|
||||
|
||||
def slice_moe_lora_b_weights(self, weights, _rank, _target):
|
||||
return weights
|
||||
|
||||
|
||||
class _FakeDenseLayer:
|
||||
def slice_lora_a_weights(self, weights, _rank):
|
||||
return weights
|
||||
|
||||
def slice_lora_b_weights(self, weights, _rank):
|
||||
return weights
|
||||
|
||||
|
||||
def _load_lora_weight_to_buffer(pool, **kwargs):
|
||||
with mock.patch.dict("sys.modules", {"sglang.srt.lora.layers": _LORA_LAYERS_STUB}):
|
||||
return pool.load_lora_weight_to_buffer(**kwargs)
|
||||
|
||||
|
||||
def _load_moe_backend_enum():
|
||||
tree = ast.parse(MOE_UTILS_PATH.read_text())
|
||||
backend = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "MoeRunnerBackend"
|
||||
)
|
||||
namespace = {"Enum": Enum}
|
||||
exec(
|
||||
compile(
|
||||
ast.fix_missing_locations(ast.Module(body=[backend], type_ignores=[])),
|
||||
str(MOE_UTILS_PATH),
|
||||
"exec",
|
||||
),
|
||||
namespace,
|
||||
)
|
||||
return namespace[backend.name]
|
||||
|
||||
|
||||
def _load_standard_dispatcher(get_parallel, get_backend):
|
||||
tree = ast.parse(STANDARD_DISPATCHER_PATH.read_text())
|
||||
source = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "StandardDispatcher"
|
||||
)
|
||||
init = next(
|
||||
node
|
||||
for node in source.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "__init__"
|
||||
)
|
||||
|
||||
class _DispatcherBase:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
test_class = ast.ClassDef(
|
||||
name="_StandardDispatcherUnderTest",
|
||||
bases=[ast.Name(id="_DispatcherBase", ctx=ast.Load())],
|
||||
keywords=[],
|
||||
body=[init],
|
||||
decorator_list=[],
|
||||
)
|
||||
namespace = {
|
||||
"_DispatcherBase": _DispatcherBase,
|
||||
"MoeRunnerConfig": object,
|
||||
"get_parallel": get_parallel,
|
||||
"get_moe_runner_backend": get_backend,
|
||||
"_use_aiter": False,
|
||||
"get_moe_a2a_backend": lambda: types.SimpleNamespace(
|
||||
supports_aiter=lambda: False
|
||||
),
|
||||
}
|
||||
exec(
|
||||
compile(
|
||||
ast.fix_missing_locations(ast.Module(body=[test_class], type_ignores=[])),
|
||||
str(STANDARD_DISPATCHER_PATH),
|
||||
"exec",
|
||||
),
|
||||
namespace,
|
||||
)
|
||||
return namespace[test_class.name]
|
||||
|
||||
|
||||
def _make_pool(
|
||||
*,
|
||||
num_experts_global: int,
|
||||
@@ -60,6 +205,159 @@ def _make_pool(
|
||||
return pool
|
||||
|
||||
|
||||
class _IterationOrderedSet(set):
|
||||
def __init__(self, values, iteration_order):
|
||||
super().__init__(values)
|
||||
self.iteration_order = iteration_order
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.iteration_order)
|
||||
|
||||
|
||||
class TestDeterministicPoolSlots(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _prepare(iteration_order):
|
||||
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
|
||||
pool.max_loras_per_batch = 4
|
||||
pool.uid_to_buffer_id = {}
|
||||
pool.buffer_id_to_uid = [EMPTY_SLOT] * 4
|
||||
pool.eviction_policy = get_eviction_policy("lru")
|
||||
loaded = []
|
||||
pool.load_lora_weight_to_buffer = lambda uid, slot, *_args: loaded.append(
|
||||
(uid, slot)
|
||||
)
|
||||
uids = _IterationOrderedSet(
|
||||
{None, "adapter-a", "adapter-b", "adapter-c"}, iteration_order
|
||||
)
|
||||
|
||||
pool.prepare_lora_batch(
|
||||
cur_uids=uids,
|
||||
lora_adapters={},
|
||||
lora_modules=[],
|
||||
lora_refs={},
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
return loaded, list(pool.eviction_policy.access_order)
|
||||
|
||||
def test_uid_iteration_order_does_not_change_slots_or_lru(self):
|
||||
forward = [None, "adapter-a", "adapter-b", "adapter-c"]
|
||||
reverse = list(reversed(forward))
|
||||
expected = (
|
||||
[
|
||||
(None, 0),
|
||||
("adapter-a", 1),
|
||||
("adapter-b", 2),
|
||||
("adapter-c", 3),
|
||||
],
|
||||
["adapter-a", "adapter-b", "adapter-c"],
|
||||
)
|
||||
|
||||
self.assertEqual(self._prepare(forward), expected)
|
||||
self.assertEqual(self._prepare(reverse), expected)
|
||||
|
||||
@staticmethod
|
||||
def _evict_after_touch(iteration_order):
|
||||
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
|
||||
pool.max_loras_per_batch = 4
|
||||
pool.uid_to_buffer_id = {
|
||||
None: 0,
|
||||
"adapter-a": 1,
|
||||
"adapter-b": 2,
|
||||
"adapter-c": 3,
|
||||
}
|
||||
pool.buffer_id_to_uid = [None, "adapter-a", "adapter-b", "adapter-c"]
|
||||
pool.eviction_policy = get_eviction_policy("lru")
|
||||
for uid in ("adapter-a", "adapter-b", "adapter-c"):
|
||||
pool.eviction_policy.mark_used(uid)
|
||||
pool.load_lora_weight_to_buffer = lambda *_args: None
|
||||
common = dict(
|
||||
lora_adapters={},
|
||||
lora_modules=[],
|
||||
lora_refs={},
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
pool.prepare_lora_batch(
|
||||
cur_uids=_IterationOrderedSet(
|
||||
{"adapter-a", "adapter-b", "adapter-c"}, iteration_order
|
||||
),
|
||||
**common,
|
||||
)
|
||||
pool.prepare_lora_batch(cur_uids={"adapter-d"}, **common)
|
||||
return pool.uid_to_buffer_id, list(pool.eviction_policy.access_order)
|
||||
|
||||
def test_uid_iteration_order_does_not_change_next_lru_victim(self):
|
||||
forward = ["adapter-a", "adapter-b", "adapter-c"]
|
||||
reverse = list(reversed(forward))
|
||||
expected = (
|
||||
{None: 0, "adapter-d": 1, "adapter-b": 2, "adapter-c": 3},
|
||||
["adapter-b", "adapter-c", "adapter-d"],
|
||||
)
|
||||
|
||||
self.assertEqual(self._evict_after_touch(forward), expected)
|
||||
self.assertEqual(self._evict_after_touch(reverse), expected)
|
||||
|
||||
|
||||
class TestBufferSlotClearing(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _buffers(pool):
|
||||
layered = [
|
||||
tensor
|
||||
for buffers in (*pool.A_buffer.values(), *pool.B_buffer.values())
|
||||
for tensor in buffers
|
||||
]
|
||||
direct = [
|
||||
tensor
|
||||
for buffers in (
|
||||
pool.embedding_A_buffer,
|
||||
pool.embedding_B_buffer,
|
||||
pool.lm_head_A_buffer,
|
||||
pool.lm_head_B_buffer,
|
||||
pool.new_embeddings_buffer,
|
||||
)
|
||||
for tensor in buffers.values()
|
||||
]
|
||||
return layered + direct
|
||||
|
||||
@classmethod
|
||||
def _make_pool(cls):
|
||||
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
|
||||
pool.num_layer = 2
|
||||
pool.A_buffer = {"a": [torch.ones(2, 1, 2) for _ in range(2)]}
|
||||
pool.B_buffer = {"b": [torch.full((2, 1, 2), float("nan")) for _ in range(2)]}
|
||||
pool.embedding_A_buffer = {"a": torch.ones(2, 1, 2)}
|
||||
pool.embedding_B_buffer = {"b": torch.full((2, 1, 2), float("nan"))}
|
||||
pool.lm_head_A_buffer = {"a": torch.ones(2, 1, 2)}
|
||||
pool.lm_head_B_buffer = {"b": torch.full((2, 1, 2), float("inf"))}
|
||||
pool.new_embeddings_buffer = {"embeddings": torch.full((2, 1, 2), float("nan"))}
|
||||
for tensor in cls._buffers(pool):
|
||||
tensor[1].fill_(7)
|
||||
|
||||
pool.uid_to_buffer_id = {"adapter": 0}
|
||||
pool.buffer_id_to_uid = ["adapter", "other"]
|
||||
pool.eviction_policy = get_eviction_policy("lru")
|
||||
pool.eviction_policy.mark_used("adapter")
|
||||
return pool
|
||||
|
||||
def test_remove_lora_clears_slot_in_place_and_residency(self):
|
||||
pool = self._make_pool()
|
||||
tensors = self._buffers(pool)
|
||||
pointers = [tensor.data_ptr() for tensor in tensors]
|
||||
|
||||
self.assertEqual(pool.remove_lora("adapter"), 0)
|
||||
|
||||
for tensor, pointer in zip(tensors, pointers):
|
||||
torch.testing.assert_close(tensor[0], torch.zeros_like(tensor[0]))
|
||||
torch.testing.assert_close(tensor[1], torch.full_like(tensor[1], 7))
|
||||
self.assertTrue(torch.isfinite(tensor).all())
|
||||
self.assertEqual(tensor.data_ptr(), pointer)
|
||||
self.assertNotIn("adapter", pool.uid_to_buffer_id)
|
||||
self.assertIs(pool.buffer_id_to_uid[0], EMPTY_SLOT)
|
||||
self.assertNotIn("adapter", pool.eviction_policy.access_order)
|
||||
|
||||
|
||||
def _make_fake_base_model(num_experts: int) -> torch.nn.Module:
|
||||
"""Return a `torch.nn.Module` whose `.config` exposes `num_experts`.
|
||||
|
||||
@@ -338,6 +636,251 @@ class TestIterLocalExpertWeightsTensor(unittest.TestCase):
|
||||
list(pool._iter_local_expert_weights(weights, "weights"))
|
||||
|
||||
|
||||
class TestSharedMoeProductionLoad(unittest.TestCase):
|
||||
def test_unmarked_2d_shared_expert_uses_dense_buffers(self):
|
||||
pool = _make_pool(
|
||||
num_experts_global=8,
|
||||
moe_ep_size=1,
|
||||
moe_ep_rank=0,
|
||||
moe_use_local_expert_ids=False,
|
||||
)
|
||||
pool.num_layer = 1
|
||||
pool.tp_rank = 0
|
||||
pool.max_lora_rank = 2
|
||||
pool.target_modules = {"down_proj"}
|
||||
pool.experts_shared_outer_loras = False
|
||||
pool.strict_loading = True
|
||||
pool.lora_added_tokens_size = 0
|
||||
pool.pin_memory_available = False
|
||||
pool.enable_lora_overlap_loading = False
|
||||
pool.base_model = object()
|
||||
pool.A_buffer = {
|
||||
"down_proj": [torch.full((1, 2, 3), -1.0)],
|
||||
"down_proj_shared_moe": [torch.full((1, 2, 2, 3), -7.0)],
|
||||
}
|
||||
pool.B_buffer = {
|
||||
"down_proj": [torch.full((1, 5, 2), -1.0)],
|
||||
"down_proj_shared_moe": [torch.full((1, 1, 5, 2), -7.0)],
|
||||
}
|
||||
pool.embedding_A_buffer = {}
|
||||
pool.embedding_B_buffer = {}
|
||||
pool.lm_head_A_buffer = {}
|
||||
pool.lm_head_B_buffer = {}
|
||||
pool.new_embeddings_buffer = {}
|
||||
|
||||
down_a = torch.arange(6, dtype=torch.float32).reshape(2, 3)
|
||||
down_b = torch.arange(10, dtype=torch.float32).reshape(5, 2)
|
||||
adapter = types.SimpleNamespace(
|
||||
config=types.SimpleNamespace(r=2),
|
||||
scaling=2.5,
|
||||
layers=[
|
||||
types.SimpleNamespace(
|
||||
weights={
|
||||
"model.layers.0.mlp.shared_experts.down_proj.lora_A.weight": down_a,
|
||||
"model.layers.0.mlp.shared_experts.down_proj.lora_B.weight": down_b,
|
||||
},
|
||||
pinned_weights={},
|
||||
)
|
||||
],
|
||||
embedding_layers={},
|
||||
added_tokens_embeddings={},
|
||||
)
|
||||
|
||||
_load_lora_weight_to_buffer(
|
||||
pool,
|
||||
uid="dense-shared",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[
|
||||
{"model.layers.0.mlp.shared_experts.down_proj": _FakeDenseLayer()}
|
||||
],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(pool.A_buffer["down_proj"][0][0], down_a)
|
||||
torch.testing.assert_close(pool.B_buffer["down_proj"][0][0], down_b)
|
||||
self.assertTrue(torch.all(pool.A_buffer["down_proj_shared_moe"][0] == -7))
|
||||
self.assertTrue(torch.all(pool.B_buffer["down_proj_shared_moe"][0] == -7))
|
||||
|
||||
def test_rank3_loads_shared_gate_b_and_down_a(self):
|
||||
"""Shared-sink weights stay replicated even on a nonzero EP rank."""
|
||||
pool = _make_pool(
|
||||
num_experts_global=256,
|
||||
moe_ep_size=4,
|
||||
moe_ep_rank=3,
|
||||
moe_use_local_expert_ids=True,
|
||||
)
|
||||
pool.num_layer = 1
|
||||
pool.max_lora_rank = 2
|
||||
pool.target_modules = {"gate_up_proj", "down_proj"}
|
||||
pool.experts_shared_outer_loras = True
|
||||
pool.strict_loading = True
|
||||
pool.lora_added_tokens_size = 0
|
||||
pool.pin_memory_available = False
|
||||
pool.enable_lora_overlap_loading = False
|
||||
pool.base_model = object()
|
||||
pool.A_buffer = {
|
||||
"gate_up_proj": [torch.full((1, 4, 5), -7.0)],
|
||||
"down_proj": [torch.full((1, 2, 3), -7.0)],
|
||||
"gate_up_proj_shared_moe": [torch.full((1, 1, 4, 5), -1.0)],
|
||||
"down_proj_shared_moe": [torch.full((1, 2, 2, 3), -1.0)],
|
||||
}
|
||||
pool.B_buffer = {
|
||||
"gate_up_proj": [torch.full((1, 6, 2), -7.0)],
|
||||
"down_proj": [torch.full((1, 5, 2), -7.0)],
|
||||
"gate_up_proj_shared_moe": [torch.full((1, 2, 6, 2), -1.0)],
|
||||
"down_proj_shared_moe": [torch.full((1, 1, 5, 2), -1.0)],
|
||||
}
|
||||
pool.embedding_A_buffer = {}
|
||||
pool.embedding_B_buffer = {}
|
||||
pool.lm_head_A_buffer = {}
|
||||
pool.lm_head_B_buffer = {}
|
||||
pool.new_embeddings_buffer = {}
|
||||
|
||||
gate_b = torch.arange(2 * 6 * 2, dtype=torch.float32).reshape(2, 6, 2)
|
||||
down_a = torch.arange(2 * 2 * 3, dtype=torch.float32).reshape(2, 2, 3)
|
||||
adapter = types.SimpleNamespace(
|
||||
config=types.SimpleNamespace(r=2),
|
||||
scaling=2.5,
|
||||
layers=[
|
||||
types.SimpleNamespace(
|
||||
weights={
|
||||
"model.layers.0.mlp.shared_experts.gate_up_proj.lora_B.weight": gate_b,
|
||||
"model.layers.0.mlp.shared_experts.down_proj.lora_A.weight": down_a,
|
||||
},
|
||||
pinned_weights={},
|
||||
)
|
||||
],
|
||||
embedding_layers={},
|
||||
added_tokens_embeddings={},
|
||||
)
|
||||
|
||||
shared_sink = _FakeSharedMoeLayer(tp_rank=1)
|
||||
|
||||
_load_lora_weight_to_buffer(
|
||||
pool,
|
||||
uid="shared",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[{"shared_experts": shared_sink}],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(pool.A_buffer["down_proj_shared_moe"][0][0], down_a)
|
||||
torch.testing.assert_close(
|
||||
pool.B_buffer["gate_up_proj_shared_moe"][0][0], gate_b * adapter.scaling
|
||||
)
|
||||
self.assertTrue(torch.all(pool.A_buffer["down_proj"][0] == -7))
|
||||
self.assertTrue(torch.all(pool.B_buffer["gate_up_proj"][0] == -7))
|
||||
|
||||
pool.A_buffer["down_proj_shared_moe"][0].fill_(-1)
|
||||
pool.B_buffer["gate_up_proj_shared_moe"][0].fill_(-1)
|
||||
adapter.layers[0].weights = {
|
||||
**{
|
||||
f"model.layers.0.mlp.shared_experts.{i}.gate_up_proj.lora_B.weight": gate_b[
|
||||
i
|
||||
]
|
||||
for i in range(2)
|
||||
},
|
||||
**{
|
||||
f"model.layers.0.mlp.shared_experts.{i}.down_proj.lora_A.weight": down_a[
|
||||
i
|
||||
]
|
||||
for i in range(2)
|
||||
},
|
||||
}
|
||||
_load_lora_weight_to_buffer(
|
||||
pool,
|
||||
uid="shared-named",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[{"shared_experts": shared_sink}],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
torch.testing.assert_close(pool.A_buffer["down_proj_shared_moe"][0][0], down_a)
|
||||
torch.testing.assert_close(
|
||||
pool.B_buffer["gate_up_proj_shared_moe"][0][0], gate_b * adapter.scaling
|
||||
)
|
||||
|
||||
def test_missing_inner_factors_clear_reused_shared_and_routed_slots(self):
|
||||
for suffix, expert_path in (
|
||||
("_shared_moe", "shared_experts"),
|
||||
("_moe", "experts"),
|
||||
):
|
||||
with self.subTest(suffix=suffix):
|
||||
pool = _make_pool(
|
||||
num_experts_global=2,
|
||||
moe_ep_size=1,
|
||||
moe_ep_rank=0,
|
||||
moe_use_local_expert_ids=False,
|
||||
)
|
||||
pool.num_layer = 1
|
||||
pool.max_lora_rank = 2
|
||||
pool.target_modules = {"gate_up_proj", "down_proj"}
|
||||
pool.experts_shared_outer_loras = True
|
||||
pool.strict_loading = True
|
||||
pool.lora_added_tokens_size = 0
|
||||
pool.pin_memory_available = False
|
||||
pool.enable_lora_overlap_loading = False
|
||||
pool.base_model = object()
|
||||
pool.A_buffer = {
|
||||
f"gate_up_proj{suffix}": [torch.full((1, 1, 4, 5), -1.0)],
|
||||
f"down_proj{suffix}": [torch.full((1, 2, 2, 3), -1.0)],
|
||||
}
|
||||
pool.B_buffer = {
|
||||
f"gate_up_proj{suffix}": [torch.full((1, 2, 6, 2), -1.0)],
|
||||
f"down_proj{suffix}": [torch.full((1, 1, 5, 2), -1.0)],
|
||||
}
|
||||
pool.embedding_A_buffer = {}
|
||||
pool.embedding_B_buffer = {}
|
||||
pool.lm_head_A_buffer = {}
|
||||
pool.lm_head_B_buffer = {}
|
||||
pool.new_embeddings_buffer = {}
|
||||
|
||||
adapter = types.SimpleNamespace(
|
||||
config=types.SimpleNamespace(r=2),
|
||||
scaling=2.5,
|
||||
layers=[
|
||||
types.SimpleNamespace(
|
||||
weights={
|
||||
f"model.layers.0.mlp.{expert_path}.gate_up_proj."
|
||||
"lora_A.weight": torch.ones(1, 4, 5),
|
||||
f"model.layers.0.mlp.{expert_path}.down_proj."
|
||||
"lora_B.weight": torch.ones(1, 5, 2),
|
||||
},
|
||||
pinned_weights={},
|
||||
)
|
||||
],
|
||||
embedding_layers={},
|
||||
added_tokens_embeddings={},
|
||||
)
|
||||
module = (
|
||||
_FakeSharedMoeLayer()
|
||||
if suffix == "_shared_moe"
|
||||
else _FakeRoutedMoeLayer()
|
||||
)
|
||||
|
||||
_load_lora_weight_to_buffer(
|
||||
pool,
|
||||
uid="adapter",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[{"experts": module}],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
torch.count_nonzero(pool.A_buffer[f"down_proj{suffix}"][0]), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
torch.count_nonzero(pool.B_buffer[f"gate_up_proj{suffix}"][0]), 0
|
||||
)
|
||||
|
||||
|
||||
class TestModuleLevelHelpers(unittest.TestCase):
|
||||
"""`_get_moe_ep_context` / `_moe_runner_keeps_global_expert_ids`
|
||||
must degrade gracefully when the MoE EP group or runner backend is
|
||||
@@ -357,6 +900,48 @@ class TestModuleLevelHelpers(unittest.TestCase):
|
||||
self.assertEqual(tp_size, 1)
|
||||
self.assertEqual(tp_rank, 0)
|
||||
|
||||
def test_keeps_global_expert_ids_defaults_to_false(self):
|
||||
# Without a specific flashinfer backend selected, default is False.
|
||||
self.assertFalse(_moe_runner_keeps_global_expert_ids())
|
||||
|
||||
def test_real_backend_predicate_matches_dispatcher_and_pool(self):
|
||||
backends = _load_moe_backend_enum()
|
||||
expected_global = {
|
||||
backends.FLASHINFER_TRTLLM,
|
||||
backends.EXPERIMENTAL_SGL_TRTLLM,
|
||||
backends.FLASHINFER_TRTLLM_ROUTED,
|
||||
backends.FLASHINFER_CUTLASS,
|
||||
backends.FLASHINFER_MXFP4,
|
||||
backends.FLASHINFER_CUTEDSL,
|
||||
}
|
||||
config = types.SimpleNamespace(
|
||||
num_experts=8,
|
||||
num_local_experts=2,
|
||||
num_fused_shared_experts=0,
|
||||
)
|
||||
parallel = types.SimpleNamespace(moe_ep_size=4, moe_ep_rank=1)
|
||||
state = types.SimpleNamespace(backend=None)
|
||||
standard_dispatcher = _load_standard_dispatcher(
|
||||
get_parallel=lambda: parallel,
|
||||
get_backend=lambda: state.backend,
|
||||
)
|
||||
moe_utils = types.ModuleType("sglang.srt.layers.moe.utils")
|
||||
moe_utils.get_moe_runner_backend = lambda: state.backend
|
||||
for backend in backends:
|
||||
state.backend = backend
|
||||
with mock.patch.dict(
|
||||
"sys.modules", {"sglang.srt.layers.moe.utils": moe_utils}
|
||||
):
|
||||
dispatcher = standard_dispatcher(config)
|
||||
self.assertEqual(
|
||||
dispatcher.skip_local_expert_mapping,
|
||||
backend in expected_global,
|
||||
)
|
||||
self.assertEqual(
|
||||
_moe_runner_keeps_global_expert_ids(),
|
||||
backend in expected_global,
|
||||
)
|
||||
|
||||
|
||||
class TestPoolInitPicksUpEpContext(unittest.TestCase):
|
||||
"""`LoRAMemoryPool.__init__` should read EP context from the module-
|
||||
@@ -377,16 +962,19 @@ class TestPoolInitPicksUpEpContext(unittest.TestCase):
|
||||
`init_buffers` — we only care about the EP-context state.
|
||||
"""
|
||||
with (
|
||||
mock.patch(
|
||||
"sglang.srt.lora.mem_pool._get_moe_ep_context",
|
||||
mock.patch.object(
|
||||
mem_pool_module,
|
||||
"_get_moe_ep_context",
|
||||
return_value=(ep_size, ep_rank),
|
||||
),
|
||||
mock.patch(
|
||||
"sglang.srt.lora.mem_pool._get_moe_tp_context",
|
||||
mock.patch.object(
|
||||
mem_pool_module,
|
||||
"_get_moe_tp_context",
|
||||
return_value=(moe_tp_size, moe_tp_rank),
|
||||
),
|
||||
mock.patch(
|
||||
"sglang.srt.lora.mem_pool._moe_runner_keeps_global_expert_ids",
|
||||
mock.patch.object(
|
||||
mem_pool_module,
|
||||
"_moe_runner_keeps_global_expert_ids",
|
||||
return_value=keeps_global,
|
||||
),
|
||||
mock.patch.object(LoRAMemoryPool, "init_buffers", lambda self, _m: None),
|
||||
@@ -655,8 +1243,6 @@ class TestLoadBufferPassesMoeTpRankToSlice(unittest.TestCase):
|
||||
shapes the test does not provide)."""
|
||||
|
||||
def test_moe_tp_rank_used_for_slicing_when_ep_lt_tp(self):
|
||||
from sglang.srt.lora.layers import FusedMoEWithLoRA
|
||||
|
||||
# tp=4 ep=2 → moe_tp_size=2. Pick OUTER rank 3 so moe_tp_rank=1.
|
||||
# The two values differ; the bug would surface on this exact rank.
|
||||
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
|
||||
@@ -689,64 +1275,65 @@ class TestLoadBufferPassesMoeTpRankToSlice(unittest.TestCase):
|
||||
pool.lm_head_B_buffer = {}
|
||||
pool.new_embeddings_buffer = {}
|
||||
|
||||
captured_ranks = []
|
||||
moe_mod = _FakeRoutedMoeLayer(tp_rank=1)
|
||||
|
||||
moe_mod = mock.MagicMock(spec=FusedMoEWithLoRA)
|
||||
for ab in ("A", "B"):
|
||||
with self.subTest(ab=ab):
|
||||
captured_ranks = []
|
||||
|
||||
def capture_a(weights, tp_rank, target_module):
|
||||
captured_ranks.append(("A", target_module, tp_rank))
|
||||
raise TestLoadBufferPassesMoeTpRankToSlice._StopAfterCapture()
|
||||
def capture(weights, tp_rank, target_module):
|
||||
captured_ranks.append((ab, target_module, tp_rank))
|
||||
raise TestLoadBufferPassesMoeTpRankToSlice._StopAfterCapture()
|
||||
|
||||
def capture_b(weights, tp_rank, target_module):
|
||||
captured_ranks.append(("B", target_module, tp_rank))
|
||||
raise TestLoadBufferPassesMoeTpRankToSlice._StopAfterCapture()
|
||||
moe_mod.slice_moe_lora_a_weights = (
|
||||
capture if ab == "A" else lambda weights, *_args: weights
|
||||
)
|
||||
moe_mod.slice_moe_lora_b_weights = (
|
||||
capture if ab == "B" else lambda weights, *_args: weights
|
||||
)
|
||||
|
||||
moe_mod.slice_moe_lora_a_weights.side_effect = capture_a
|
||||
moe_mod.slice_moe_lora_b_weights.side_effect = capture_b
|
||||
# The per-expert key creates the A or B dictionary whose
|
||||
# production call site invokes the matching MoE slicer.
|
||||
adapter = mock.MagicMock()
|
||||
adapter.config.r = 4
|
||||
adapter.scaling = 1.0
|
||||
adapter.embedding_layers = {}
|
||||
adapter.added_tokens_embeddings = {}
|
||||
adapter.layers = [
|
||||
types.SimpleNamespace(
|
||||
weights={
|
||||
f"model.layers.0.mlp.experts.0.gate_up_proj.lora_{ab}.weight": torch.zeros(
|
||||
8, 4
|
||||
),
|
||||
},
|
||||
pinned_weights={},
|
||||
)
|
||||
]
|
||||
|
||||
# Adapter with one per-expert MoE LoRA-A weight. The expert regex
|
||||
# `experts\.(\d+)\.` must match the key, which routes the weight
|
||||
# into `temp_A_buffer["gate_up_proj_moe"]` — the dict shape that
|
||||
# makes `temp_A_buffer.get("gate_up_proj_moe") is not None` true,
|
||||
# which in turn triggers `slice_moe_lora_a_weights` (and the
|
||||
# capture).
|
||||
adapter = mock.MagicMock()
|
||||
adapter.config.r = 4
|
||||
adapter.scaling = 1.0
|
||||
adapter.embedding_layers = {}
|
||||
adapter.added_tokens_embeddings = {}
|
||||
adapter.layers = [
|
||||
types.SimpleNamespace(
|
||||
weights={
|
||||
"model.layers.0.mlp.experts.0.gate_up_proj.lora_A.weight": (
|
||||
torch.zeros(8, 4)
|
||||
),
|
||||
},
|
||||
pinned_weights={},
|
||||
)
|
||||
]
|
||||
with self.assertRaises(
|
||||
TestLoadBufferPassesMoeTpRankToSlice._StopAfterCapture
|
||||
):
|
||||
_load_lora_weight_to_buffer(
|
||||
pool,
|
||||
uid="test",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[{"mlp.experts": moe_mod}],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
with self.assertRaises(TestLoadBufferPassesMoeTpRankToSlice._StopAfterCapture):
|
||||
pool.load_lora_weight_to_buffer(
|
||||
uid="test",
|
||||
buffer_id=0,
|
||||
lora_adapter=adapter,
|
||||
lora_modules=[{"mlp.experts": moe_mod}],
|
||||
lora_embed_tokens_module=None,
|
||||
lora_lm_head_module=None,
|
||||
)
|
||||
|
||||
self.assertGreater(len(captured_ranks), 0, "slicing was never invoked")
|
||||
for ab, target_module, rank in captured_ranks:
|
||||
self.assertEqual(
|
||||
rank,
|
||||
pool.moe_tp_rank,
|
||||
f"slice_moe_lora_{ab.lower()}_weights for {target_module} "
|
||||
f"received rank={rank}; expected moe_tp_rank="
|
||||
f"{pool.moe_tp_rank} (outer tp_rank is {pool.tp_rank}). "
|
||||
"Passing the outer tp_rank slices past "
|
||||
"intermediate_size_per_partition when ep_size < tp_size.",
|
||||
)
|
||||
self.assertEqual(len(captured_ranks), 1, "slicing was never invoked")
|
||||
_, target_module, rank = captured_ranks[0]
|
||||
self.assertEqual(
|
||||
rank,
|
||||
pool.moe_tp_rank,
|
||||
f"slice_moe_lora_{ab.lower()}_weights for {target_module} "
|
||||
f"received rank={rank}; expected moe_tp_rank="
|
||||
f"{pool.moe_tp_rank} (outer tp_rank is {pool.tp_rank}). "
|
||||
"Passing the outer tp_rank slices past "
|
||||
"intermediate_size_per_partition when ep_size < tp_size.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -118,6 +118,7 @@ def _make_model_runner(
|
||||
sa.max_running_requests = max_running_requests
|
||||
sa.disaggregation_decode_extra_slots = disaggregation_decode_extra_slots
|
||||
sa.enable_dsa_cache_layer_split = False
|
||||
sa.kv_cache_dtype = "auto"
|
||||
mr.server_args = sa
|
||||
|
||||
spec = MagicMock()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import unittest
|
||||
|
||||
from sglang.srt.entrypoints.openai.chat_encoding import encode_simple_chat
|
||||
from sglang.srt.parser.inkling_renderer import render_inkling_messages
|
||||
from sglang.srt.parser.inkling_tokenizer import (
|
||||
CONTENT_INVOKE_TOOL_JSON,
|
||||
CONTENT_MODEL_END_SAMPLING,
|
||||
CONTENT_TEXT,
|
||||
CONTENT_THINKING,
|
||||
CONTENT_XML,
|
||||
END_MESSAGE,
|
||||
INKLING_SPECIAL_TOKEN_IDS,
|
||||
MESSAGE_MODEL,
|
||||
MESSAGE_SYSTEM,
|
||||
MESSAGE_USER,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _text(value: str) -> list[int]:
|
||||
return list(value.encode())
|
||||
|
||||
|
||||
class _InklingTokenizer:
|
||||
def encode_special(self, token: str) -> int:
|
||||
return INKLING_SPECIAL_TOKEN_IDS[token]
|
||||
|
||||
def encode_text(self, text: str) -> list[int]:
|
||||
return _text(text)
|
||||
|
||||
|
||||
class _BaseTokenizer:
|
||||
chat_template = None
|
||||
|
||||
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
|
||||
return _text(text)
|
||||
|
||||
|
||||
def _block(role: str, kind: str, payload: str, author: str = "") -> list[int]:
|
||||
return (
|
||||
[INKLING_SPECIAL_TOKEN_IDS[role]]
|
||||
+ _text(author)
|
||||
+ [
|
||||
INKLING_SPECIAL_TOKEN_IDS[kind],
|
||||
*_text(payload),
|
||||
INKLING_SPECIAL_TOKEN_IDS[END_MESSAGE],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestInklingRenderer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tokenizer = _InklingTokenizer()
|
||||
|
||||
def test_generation_prompt_is_not_prefilled(self):
|
||||
actual = render_inkling_messages(
|
||||
[{"role": "user", "content": "hello"}], self.tokenizer
|
||||
)
|
||||
self.assertEqual(
|
||||
actual,
|
||||
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
||||
+ _block(MESSAGE_USER, CONTENT_TEXT, "hello"),
|
||||
)
|
||||
self.assertNotEqual(actual[-1], INKLING_SPECIAL_TOKEN_IDS[MESSAGE_MODEL])
|
||||
|
||||
def test_tool_system_and_effort_have_canonical_prefix_order(self):
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather",
|
||||
"description": "Lookup weather",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
]
|
||||
actual = render_inkling_messages(
|
||||
[
|
||||
{"role": "system", "content": "original"},
|
||||
{"role": "user", "content": "question"},
|
||||
],
|
||||
self.tokenizer,
|
||||
tools=tools,
|
||||
reasoning_effort=0.8764,
|
||||
)
|
||||
tool_json = (
|
||||
'[{"description":"Lookup weather","name":"weather",'
|
||||
'"parameters":{"type":"object"},"type":"function"}]'
|
||||
)
|
||||
expected = (
|
||||
_block(
|
||||
MESSAGE_SYSTEM,
|
||||
CONTENT_XML,
|
||||
tool_json,
|
||||
author="tool_declare",
|
||||
)
|
||||
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "original")
|
||||
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.88")
|
||||
+ _block(MESSAGE_USER, CONTENT_TEXT, "question")
|
||||
)
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_multiturn_conversation_has_one_fixed_effort_directive(self):
|
||||
system = {"role": "system", "content": "system"}
|
||||
user1 = {"role": "user", "content": "user1"}
|
||||
assistant1 = {"role": "assistant", "content": "assistant1"}
|
||||
user2 = {"role": "user", "content": "user2"}
|
||||
prefix = (
|
||||
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "system")
|
||||
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.2")
|
||||
+ _block(MESSAGE_USER, CONTENT_TEXT, "user1")
|
||||
)
|
||||
|
||||
turn1 = render_inkling_messages(
|
||||
[system, user1], self.tokenizer, reasoning_effort=0.2
|
||||
)
|
||||
turn2 = render_inkling_messages(
|
||||
[system, user1, assistant1, user2],
|
||||
self.tokenizer,
|
||||
reasoning_effort=0.2,
|
||||
)
|
||||
|
||||
self.assertEqual(turn1, prefix)
|
||||
self.assertEqual(
|
||||
turn2,
|
||||
prefix
|
||||
+ _block(MESSAGE_MODEL, CONTENT_TEXT, "assistant1")
|
||||
+ [INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING]]
|
||||
+ _block(MESSAGE_USER, CONTENT_TEXT, "user2"),
|
||||
)
|
||||
|
||||
def test_historical_assistant_preserves_parts_and_ends_sampling(self):
|
||||
actual = render_inkling_messages(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "first"},
|
||||
{"type": "text", "text": "visible"},
|
||||
{"type": "reasoning", "text": "second"},
|
||||
],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"function": {
|
||||
"name": "weather",
|
||||
"arguments": '{"city":"SF"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
self.tokenizer,
|
||||
)
|
||||
expected = (
|
||||
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
||||
+ _block(MESSAGE_MODEL, CONTENT_THINKING, "first")
|
||||
+ _block(MESSAGE_MODEL, CONTENT_TEXT, "visible")
|
||||
+ _block(MESSAGE_MODEL, CONTENT_THINKING, "second")
|
||||
+ _block(
|
||||
MESSAGE_MODEL,
|
||||
CONTENT_INVOKE_TOOL_JSON,
|
||||
'{"name":"weather","args":{"city":"SF"}}',
|
||||
author="weather",
|
||||
)
|
||||
+ [INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING]]
|
||||
)
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_empty_assistant_message_does_not_emit_bare_terminator(self):
|
||||
"""Bug regression: an assistant message that renders zero blocks
|
||||
(content None, no reasoning, no tool calls) appended a bare
|
||||
<|content_model_end_sampling|> with no preceding model block —
|
||||
injecting a malformed turn terminator into the prompt."""
|
||||
actual = render_inkling_messages(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": None},
|
||||
{"role": "user", "content": "again"},
|
||||
],
|
||||
self.tokenizer,
|
||||
)
|
||||
self.assertNotIn(INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING], actual)
|
||||
|
||||
def test_reasoning_content_cannot_reorder_thinking_parts(self):
|
||||
with self.assertRaisesRegex(ValueError, "cannot mix"):
|
||||
render_inkling_messages(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"reasoning_content": "legacy",
|
||||
"content": [{"type": "thinking", "thinking": "ordered"}],
|
||||
}
|
||||
],
|
||||
self.tokenizer,
|
||||
)
|
||||
|
||||
def test_reasoning_effort_is_two_decimal_quantized_and_validated(self):
|
||||
for value, expected in (
|
||||
(0.8766, "0.88"),
|
||||
(0.0, "0"),
|
||||
(0.99, "0.99"),
|
||||
(0.125, "0.12"),
|
||||
(0.875, "0.88"),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
actual = render_inkling_messages(
|
||||
[{"role": "user", "content": "q"}],
|
||||
self.tokenizer,
|
||||
reasoning_effort=value,
|
||||
)
|
||||
directive = _block(
|
||||
MESSAGE_SYSTEM,
|
||||
CONTENT_TEXT,
|
||||
f"Thinking effort level: {expected}",
|
||||
)
|
||||
self.assertEqual(actual[: len(directive)], directive)
|
||||
for value in (-0.1, 1.0, 1.1, float("nan")):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
render_inkling_messages(
|
||||
[{"role": "user", "content": "q"}],
|
||||
self.tokenizer,
|
||||
reasoning_effort=value,
|
||||
)
|
||||
|
||||
def test_offline_encoder_uses_the_same_inkling_format(self):
|
||||
actual = encode_simple_chat(
|
||||
tokenizer=_BaseTokenizer(),
|
||||
spec="inkling",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
self.assertEqual(
|
||||
actual,
|
||||
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
||||
+ _block(MESSAGE_USER, CONTENT_TEXT, "hello"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ from sglang.srt.parser.reasoning_parser import (
|
||||
Gemma4Detector,
|
||||
Glm45Detector,
|
||||
HunyuanDetector,
|
||||
InklingDetector,
|
||||
KimiDetector,
|
||||
KimiK2Detector,
|
||||
Nemotron3Detector,
|
||||
@@ -166,6 +167,89 @@ class TestQwen3Detector(CustomTestCase):
|
||||
self.assertEqual(result.reasoning_text, "")
|
||||
|
||||
|
||||
class TestInklingDetector(CustomTestCase):
|
||||
def test_streaming_routes_blocks_across_all_string_boundaries(self):
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|><|content_thinking|>think<|end_message|>"
|
||||
"<|message_model|><|content_text|>answer<|end_message|>"
|
||||
"<|content_model_end_sampling|>"
|
||||
)
|
||||
reasoning = ""
|
||||
content = ""
|
||||
for char in source:
|
||||
result = detector.parse_streaming_increment(char)
|
||||
reasoning += result.reasoning_text
|
||||
content += result.normal_text
|
||||
self.assertEqual(reasoning, "think")
|
||||
self.assertEqual(content, "answer")
|
||||
|
||||
def test_tool_header_is_preserved_for_the_tool_parser(self):
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|>weather<|content_invoke_tool_json|>"
|
||||
'{"name":"weather","args":{"city":"SF"}}<|end_message|>'
|
||||
)
|
||||
content = ""
|
||||
for char in source:
|
||||
content += detector.parse_streaming_increment(char).normal_text
|
||||
self.assertEqual(content, source)
|
||||
|
||||
def test_quoted_message_model_token_inside_content_is_preserved(self):
|
||||
"""Bug regression: the header branch flipped to header state on ANY
|
||||
<|message_model|> occurrence, so a literal token the model wrote
|
||||
inside a content block (e.g. quoting the protocol) silently swallowed
|
||||
all payload text up to the next control token."""
|
||||
detector = InklingDetector()
|
||||
source = (
|
||||
"<|message_model|><|content_text|>Header token: <|message_model|>"
|
||||
" then more text<|end_message|>"
|
||||
)
|
||||
result = detector.detect_and_parse(source)
|
||||
self.assertEqual(
|
||||
result.normal_text, "Header token: <|message_model|> then more text"
|
||||
)
|
||||
|
||||
def test_control_token_inside_tool_header_shares_the_full_alphabet(self):
|
||||
"""Bug regression: the tool-call detector validated headers against
|
||||
INKLING_SPECIAL_TOKENS while the reasoning parser keyed on the larger
|
||||
control alphabet (+ <|model_trigger_generation|>), so a control token
|
||||
smuggled inside a header passed one machine and not the other."""
|
||||
from sglang.srt.function_call.inkling_detector import (
|
||||
InklingDetector as ToolDetector,
|
||||
)
|
||||
|
||||
detector = ToolDetector()
|
||||
prefix, name = detector._split_trailing_tool_header(
|
||||
"<|message_model|>weather<|model_trigger_generation|>"
|
||||
)
|
||||
self.assertIsNone(name)
|
||||
|
||||
def test_continuation_stream_text_survives_chunk_boundaries(self):
|
||||
"""Bug regression: text arriving with no open block (a
|
||||
continue_final_message stream resumes MID text block) was routed to
|
||||
content only when a chunk held no control token; a chunk like
|
||||
'ld<|end_message|>' silently dropped the 'ld'. All out-of-block text
|
||||
must reach content regardless of chunking."""
|
||||
source = (
|
||||
" world<|end_message|><|message_model|><|content_text|>next<|end_message|>"
|
||||
)
|
||||
for chunks in (
|
||||
[source],
|
||||
[
|
||||
" wor",
|
||||
"ld<|end_message|>",
|
||||
"<|message_model|><|content_text|>next<|end_message|>",
|
||||
],
|
||||
list(source),
|
||||
):
|
||||
detector = InklingDetector()
|
||||
content = ""
|
||||
for chunk in chunks:
|
||||
content += detector.parse_streaming_increment(chunk).normal_text
|
||||
self.assertEqual(content, " worldnext", msg=f"chunks={chunks!r}")
|
||||
|
||||
|
||||
class TestKimiDetector(CustomTestCase):
|
||||
def setUp(self):
|
||||
self.detector = KimiDetector()
|
||||
|
||||
@@ -74,6 +74,7 @@ class TestModelOverridableWhitelist(CustomTestCase):
|
||||
"disable_overlap_schedule",
|
||||
"uses_mamba_radix_cache",
|
||||
"mamba_radix_cache_strategy",
|
||||
"mamba_full_memory_ratio",
|
||||
"speculative_moe_runner_backend",
|
||||
"speculative_moe_a2a_backend",
|
||||
"disable_shared_experts_fusion",
|
||||
|
||||
@@ -20,6 +20,7 @@ from sglang.srt.utils.hf_transformers.common import (
|
||||
_is_deepseek_ocr_model,
|
||||
_override_v_head_dim_if_zero,
|
||||
_patch_text_config,
|
||||
attach_additional_stop_token_ids,
|
||||
check_gguf_file,
|
||||
get_context_length,
|
||||
get_hf_text_config,
|
||||
@@ -451,6 +452,37 @@ class TestGetHfTextConfig(unittest.TestCase):
|
||||
self.assertEqual(cfg.rope_scaling["type"], "llama3")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attach_additional_stop_token_ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAttachAdditionalStopTokenIds(unittest.TestCase):
|
||||
"""Bug regression: the Inkling bundle ships eos metadata unset while its
|
||||
turn-final marker <|content_model_end_sampling|> sits in added_tokens; the
|
||||
old detector only recognized <|eom_id|>, so generation ran to max length
|
||||
(documented by the Inkling GSM8K test)."""
|
||||
|
||||
@staticmethod
|
||||
def _tokenizer(added):
|
||||
return SimpleNamespace(get_added_vocab=lambda: added)
|
||||
|
||||
def test_inkling_end_sampling_registers_as_stop(self):
|
||||
tok = self._tokenizer({"<|content_model_end_sampling|>": 200006})
|
||||
attach_additional_stop_token_ids(tok)
|
||||
self.assertEqual(tok.additional_stop_token_ids, {200006})
|
||||
|
||||
def test_eom_id_still_registers_as_stop(self):
|
||||
tok = self._tokenizer({"<|eom_id|>": 128008})
|
||||
attach_additional_stop_token_ids(tok)
|
||||
self.assertEqual(tok.additional_stop_token_ids, {128008})
|
||||
|
||||
def test_no_known_marker_yields_none(self):
|
||||
tok = self._tokenizer({"<|other|>": 7})
|
||||
attach_additional_stop_token_ids(tok)
|
||||
self.assertIsNone(tok.additional_stop_token_ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fix_special_tokens_pattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""CPU unit test for Inkling per-expert RL weight-sync loading.
|
||||
|
||||
Exercises ``_load_per_expert_param`` on a simulated EP x MoE-TP grid (parallel
|
||||
helpers monkeypatched, no process groups) and checks every (ep_rank, tp_rank)
|
||||
against a reference fused stack built directly from the full per-expert weights:
|
||||
|
||||
- EP: global expert id remapped to the rank's contiguous local block,
|
||||
non-owned experts consumed without touching the stack
|
||||
- MoE-TP: w13 slices the intermediate dim (dim 0 of gate/up), w2 dim 1
|
||||
- w13 row layout: Inkling-interleaved vs contiguous [gate || up]
|
||||
(lora_compatible_layout_enabled() or inference_moe_w13_interleaved=False)
|
||||
- trtllm MoE layouts rejected loudly
|
||||
|
||||
Run: python3 test/srt/models/test_inkling_per_expert_sync.py
|
||||
"""
|
||||
|
||||
import types
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
import sglang.srt.models.inkling as inkling_mod
|
||||
|
||||
N_EXPERTS, I_FULL, H = 8, 6, 4
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
"""Just enough of InklingForConditionalGeneration for _load_per_expert_param."""
|
||||
|
||||
def __init__(self, interleaved: bool, moe=None):
|
||||
self.text_config = types.SimpleNamespace(
|
||||
n_routed_experts=N_EXPERTS,
|
||||
inference_moe_w13_interleaved=interleaved,
|
||||
)
|
||||
self._moe = moe if moe is not None else types.SimpleNamespace()
|
||||
|
||||
def get_submodule(self, path):
|
||||
return self._moe
|
||||
|
||||
_load_per_expert_param = (
|
||||
inkling_mod.InklingForConditionalGeneration._load_per_expert_param
|
||||
)
|
||||
|
||||
|
||||
def _full_weights(seed=0):
|
||||
g = torch.Generator().manual_seed(seed)
|
||||
return {
|
||||
(e, proj): torch.randn(
|
||||
(H, I_FULL) if proj == "down_proj" else (I_FULL, H), generator=g
|
||||
)
|
||||
for e in range(N_EXPERTS)
|
||||
for proj in ("gate_proj", "up_proj", "down_proj")
|
||||
}
|
||||
|
||||
|
||||
def _expected_stacks(full, ep_size, ep_rank, tp_size, tp_rank, contiguous):
|
||||
"""Reference: what the fused w13/w2 stacks must contain on this rank."""
|
||||
local = N_EXPERTS // ep_size
|
||||
i_tp = I_FULL // tp_size
|
||||
w13 = torch.empty(local, 2 * i_tp, H)
|
||||
w2 = torch.empty(local, H, i_tp)
|
||||
for e_local in range(local):
|
||||
e = ep_rank * local + e_local
|
||||
gate = full[(e, "gate_proj")][tp_rank * i_tp : (tp_rank + 1) * i_tp]
|
||||
up = full[(e, "up_proj")][tp_rank * i_tp : (tp_rank + 1) * i_tp]
|
||||
if contiguous:
|
||||
w13[e_local] = torch.cat([gate, up], dim=0)
|
||||
else: # Inkling-interleaved rows [g0, u0, g1, u1, ...]
|
||||
w13[e_local, 0::2] = gate
|
||||
w13[e_local, 1::2] = up
|
||||
w2[e_local] = full[(e, "down_proj")][:, tp_rank * i_tp : (tp_rank + 1) * i_tp]
|
||||
return w13, w2
|
||||
|
||||
|
||||
class TestPerExpertSync(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._saved = {
|
||||
n: getattr(inkling_mod, n)
|
||||
for n in (
|
||||
"get_moe_expert_parallel_world_size",
|
||||
"get_moe_expert_parallel_rank",
|
||||
"get_moe_tensor_parallel_rank",
|
||||
"lora_compatible_layout_enabled",
|
||||
)
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
for n, f in self._saved.items():
|
||||
setattr(inkling_mod, n, f)
|
||||
|
||||
def _patch(self, ep_size, ep_rank, tp_rank, lora_layout=False):
|
||||
inkling_mod.get_moe_expert_parallel_world_size = lambda: ep_size
|
||||
inkling_mod.get_moe_expert_parallel_rank = lambda: ep_rank
|
||||
inkling_mod.get_moe_tensor_parallel_rank = lambda: tp_rank
|
||||
inkling_mod.lora_compatible_layout_enabled = lambda: lora_layout
|
||||
|
||||
def _run_rank(
|
||||
self, full, ep_size, ep_rank, tp_size, tp_rank, *, interleaved, lora_layout
|
||||
):
|
||||
self._patch(ep_size, ep_rank, tp_rank, lora_layout)
|
||||
model = _FakeModel(interleaved)
|
||||
local, i_tp = N_EXPERTS // ep_size, I_FULL // tp_size
|
||||
params_dict = {
|
||||
"model.layers.0.mlp.experts.w13_weight": torch.nn.Parameter(
|
||||
torch.full((local, 2 * i_tp, H), float("nan")), requires_grad=False
|
||||
),
|
||||
"model.layers.0.mlp.experts.w2_weight": torch.nn.Parameter(
|
||||
torch.full((local, H, i_tp), float("nan")), requires_grad=False
|
||||
),
|
||||
}
|
||||
loaded = set()
|
||||
for (e, proj), w in full.items():
|
||||
name = f"model.layers.0.mlp.experts.{e}.{proj}.weight"
|
||||
self.assertTrue(model._load_per_expert_param(params_dict, loaded, name, w))
|
||||
contiguous = lora_layout or not interleaved
|
||||
exp_w13, exp_w2 = _expected_stacks(
|
||||
full, ep_size, ep_rank, tp_size, tp_rank, contiguous
|
||||
)
|
||||
got_w13 = params_dict["model.layers.0.mlp.experts.w13_weight"].data
|
||||
got_w2 = params_dict["model.layers.0.mlp.experts.w2_weight"].data
|
||||
self.assertFalse(torch.isnan(got_w13).any(), "unwritten w13 slots")
|
||||
self.assertFalse(torch.isnan(got_w2).any(), "unwritten w2 slots")
|
||||
torch.testing.assert_close(got_w13, exp_w13, rtol=0, atol=0)
|
||||
torch.testing.assert_close(got_w2, exp_w2, rtol=0, atol=0)
|
||||
self.assertEqual(loaded, set(params_dict))
|
||||
|
||||
def test_ep1_tp1_interleaved(self):
|
||||
# the validated RL rollout config (weight-checker <=1e-6 on 4layer + 951B)
|
||||
self._run_rank(_full_weights(), 1, 0, 1, 0, interleaved=True, lora_layout=False)
|
||||
|
||||
def test_ep_tp_grid_interleaved(self):
|
||||
full = _full_weights(1)
|
||||
for ep_rank in range(4):
|
||||
for tp_rank in range(2):
|
||||
self._run_rank(
|
||||
full, 4, ep_rank, 2, tp_rank, interleaved=True, lora_layout=False
|
||||
)
|
||||
|
||||
def test_ep_tp_grid_contiguous_layouts(self):
|
||||
full = _full_weights(2)
|
||||
# contiguous via the LoRA-serving layout and via a non-interleaved config
|
||||
for interleaved, lora_layout in ((True, True), (False, False)):
|
||||
for ep_rank in range(2):
|
||||
self._run_rank(
|
||||
full,
|
||||
2,
|
||||
ep_rank,
|
||||
2,
|
||||
1,
|
||||
interleaved=interleaved,
|
||||
lora_layout=lora_layout,
|
||||
)
|
||||
|
||||
def test_trtllm_layout_rejected(self):
|
||||
self._patch(1, 0, 0)
|
||||
moe = types.SimpleNamespace(use_flashinfer_trtllm_moe=True)
|
||||
model = _FakeModel(True, moe=moe)
|
||||
params_dict = {
|
||||
"model.layers.0.mlp.experts.w13_weight": torch.nn.Parameter(
|
||||
torch.zeros(N_EXPERTS, 2 * I_FULL, H), requires_grad=False
|
||||
)
|
||||
}
|
||||
with self.assertRaises(NotImplementedError):
|
||||
model._load_per_expert_param(
|
||||
params_dict,
|
||||
set(),
|
||||
"model.layers.0.mlp.experts.0.gate_proj.weight",
|
||||
torch.zeros(I_FULL, H),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user