[Test] Drop cause-less disabled tests, fix XPU lane, demote quality gates off base-c (#40288)

This commit is contained in:
Liangsheng Yin
2026-09-20 14:36:02 -07:00
committed by GitHub
parent d229952e25
commit f6483e479f
14 changed files with 24 additions and 1286 deletions
+16
View File
@@ -248,6 +248,21 @@ jobs:
rust_ext_artifact: ${{ needs.rust-ext-build.outputs.artifact_name }}
secrets: inherit
extra-b-test-8-gpu-b300:
needs: [check-changes, call-gate, sgl-kernel-build-wheels, rust-ext-build]
if: ${{ !failure() && !cancelled() && needs.check-changes.result == 'success' && (needs.call-gate.result == 'success' || needs.call-gate.result == 'skipped') }}
uses: ./.github/workflows/_pr-test-stage.yml
with:
self_name: extra-b-test-8-gpu-b300
runner_config: 8-gpu-b300
check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '60'
timeout_per_file: '3600'
rust_ext_artifact: ${{ needs.rust-ext-build.outputs.artifact_name }}
secrets: inherit
# =============================================== aggregator ====================================================
# Mirrors pr-test.yml's `pr-test-finish` so notify-pr-states below only
# depends on one job rather than re-listing every stage. Fails if any
@@ -266,6 +281,7 @@ jobs:
extra-b-test-4-gpu-h100,
extra-b-test-4-gpu-b200,
extra-b-test-8-gpu-h200,
extra-b-test-8-gpu-b300,
]
if: always()
runs-on: ubuntu-latest
@@ -18,18 +18,13 @@ import unittest
import requests
import torch
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.ci.ci_register import register_xpu_ci
from sglang.test.server_fixtures.disaggregation_fixture import (
PDDisaggregationServerBase,
)
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST_QWEN
register_cuda_ci(
est_time=300,
stage="base-a",
runner_config="1-gpu-small",
disabled="Intel XPU only — not available in standard CUDA CI",
)
register_xpu_ci(est_time=300, suite="stage-b-test-1-gpu-xpu")
_XPU_AVAILABLE = torch.xpu.is_available()
@@ -10,7 +10,7 @@ from sglang.test.server_fixtures.dsa_mtp_fixture import (
register_cuda_ci(
est_time=400,
stage="base-c",
stage="nightly",
runner_config="8-gpu-h200",
)
@@ -10,7 +10,7 @@ from sglang.test.server_fixtures.dsa_mtp_fixture import (
register_cuda_ci(
est_time=400,
stage="base-c",
stage="nightly",
runner_config="8-gpu-h200",
)
@@ -3,7 +3,6 @@ import unittest
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.gpt_oss_common import BaseTestGptOss
register_cuda_ci(est_time=128, stage="base-c", runner_config="4-gpu-h100")
register_cuda_ci(est_time=119, stage="base-c", runner_config="4-gpu-b200")
@@ -17,7 +17,7 @@ from sglang.test.test_utils import (
popen_launch_server,
)
register_cuda_ci(est_time=1472, stage="base-c", runner_config="8-gpu-b300")
register_cuda_ci(est_time=1472, stage="extra-b", runner_config="8-gpu-b300")
MODEL_PATH = "moonshotai/Kimi-K3"
DSPARK_DRAFT_MODEL = "RadixArk/Kimi-K3-DSpark"
+1 -1
View File
@@ -5,7 +5,7 @@ from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
from sglang.test.server_fixtures.mmmu_fixture import MMMUServerBase
register_cuda_ci(est_time=317, stage="base-c", runner_config="8-gpu-h200")
register_cuda_ci(est_time=317, stage="extra-b", runner_config="8-gpu-h200")
MIMO_V2_MODEL = "XiaomiMiMo/MiMo-V2.5"
MIMO_V2_OTHER_ARGS = [
@@ -1,396 +0,0 @@
from __future__ import annotations
import socket
import sys
from dataclasses import dataclass
import pytest
import torch
import torch.nn.functional as F
from sglang.kernels.ops.attention.fla.layernorm_gated import (
_layer_norm_fwd as layer_norm_fwd,
)
from sglang.kernels.ops.attention.fla.layernorm_gated import (
layernorm_fn,
rms_norm_ref,
)
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(
est_time=60,
stage="base-b",
runner_config="2-gpu-large",
disabled="Temporarily disabled",
)
# Optional dependency in sglang repo; skip collection cleanly if absent.
custom_all_reduce_utils = pytest.importorskip(
"sglang.srt.distributed.device_communicators.custom_all_reduce_utils"
)
parallel_state = pytest.importorskip("sglang.srt.distributed.parallel_state")
update_environment_variables = custom_all_reduce_utils.update_environment_variables
init_distributed_environment = parallel_state.init_distributed_environment
initialize_model_parallel = parallel_state.initialize_model_parallel
NUM_GPUS = 2
def _find_free_port() -> int:
# Avoid hard-coded port collisions when pytest runs tests in parallel.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("localhost", 0))
s.listen(1)
return int(s.getsockname()[1])
def _skip_if_no_cuda_or_not_enough_gpus(required_gpus: int = NUM_GPUS) -> None:
if not torch.cuda.is_available():
pytest.skip("CUDA device not available")
if torch.cuda.device_count() < required_gpus:
pytest.skip(f"Need >= {required_gpus} GPUs, got {torch.cuda.device_count()}")
def _skip_if_dtype_unsupported(dtype: torch.dtype) -> None:
if dtype is torch.bfloat16 and not torch.cuda.is_bf16_supported():
pytest.skip("bfloat16 not supported on this CUDA device")
def _setup_sglang_distributed(
local_rank: int,
world_size: int,
master_port: int,
dtype: torch.dtype,
) -> torch.device:
# Match sglang test style: set per-rank CUDA device + default dtype/device.
torch.manual_seed(0)
torch.cuda.manual_seed_all(0)
device = torch.device(f"cuda:{local_rank}")
torch.cuda.set_device(device)
if hasattr(torch, "set_default_device"):
torch.set_default_device(device)
if hasattr(torch, "set_default_dtype"):
torch.set_default_dtype(dtype)
update_environment_variables(
{
"RANK": str(local_rank),
"LOCAL_RANK": str(local_rank),
"WORLD_SIZE": str(world_size),
"MASTER_ADDR": "localhost",
"MASTER_PORT": str(master_port),
}
)
init_distributed_environment(
world_size=world_size, rank=local_rank, local_rank=local_rank
)
initialize_model_parallel(tensor_model_parallel_size=world_size)
return device
def layer_norm_ref(
x: torch.Tensor,
weight: torch.Tensor,
bias: torch.Tensor | None,
z: torch.Tensor | None = None,
eps: float = 1e-6,
group_size: int | None = None,
norm_before_gate: bool = True,
is_rms_norm: bool = False,
) -> torch.Tensor:
"""Reference implementation for both LayerNorm and RMSNorm (supports optional gate + group norm)."""
if is_rms_norm:
return rms_norm_ref(
x,
weight,
bias,
z=z,
eps=eps,
group_size=group_size,
norm_before_gate=norm_before_gate,
upcast=True,
)
dtype = x.dtype
x_f = x.float()
w_f = weight.float()
b_f = bias.float() if bias is not None else None
z_f = z.float() if z is not None else None
if z_f is not None and not norm_before_gate:
x_f = x_f * F.silu(z_f)
if group_size is None:
mean = x_f.mean(dim=-1, keepdim=True)
var = (x_f - mean).square().mean(dim=-1, keepdim=True)
rstd = torch.rsqrt(var + eps)
out = (x_f - mean) * rstd * w_f
if b_f is not None:
out = out + b_f
else:
hidden = x_f.shape[-1]
assert hidden % group_size == 0
ng = hidden // group_size
xg = x_f.view(*x_f.shape[:-1], ng, group_size)
mean = xg.mean(dim=-1, keepdim=True)
var = (xg - mean).square().mean(dim=-1, keepdim=True)
rstd = torch.rsqrt(var + eps)
xg = (xg - mean) * rstd
out = xg.reshape(*x_f.shape[:-1], hidden) * w_f
if b_f is not None:
out = out + b_f
if z_f is not None and norm_before_gate:
out = out * F.silu(z_f)
return out.to(dtype)
@dataclass(frozen=True)
class FwdCase:
name: str
with_gate: bool
norm_before_gate: bool
group_size: int | None
is_rms_norm: bool
CASES: list[FwdCase] = [
FwdCase(
"layernorm",
with_gate=False,
norm_before_gate=True,
group_size=None,
is_rms_norm=False,
),
FwdCase(
"rmsnorm",
with_gate=False,
norm_before_gate=True,
group_size=None,
is_rms_norm=True,
),
FwdCase(
"layernorm_gate_pre",
with_gate=True,
norm_before_gate=True,
group_size=None,
is_rms_norm=False,
),
FwdCase(
"layernorm_gate_post",
with_gate=True,
norm_before_gate=False,
group_size=None,
is_rms_norm=False,
),
FwdCase(
"rmsnorm_gate_pre",
with_gate=True,
norm_before_gate=True,
group_size=None,
is_rms_norm=True,
),
FwdCase(
"group_layernorm",
with_gate=False,
norm_before_gate=True,
group_size=128,
is_rms_norm=False,
),
FwdCase(
"group_rmsnorm",
with_gate=False,
norm_before_gate=True,
group_size=128,
is_rms_norm=True,
),
]
@pytest.mark.parametrize("num_tokens", [128])
@pytest.mark.parametrize("hidden_size", [256])
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("case", CASES, ids=lambda c: c.name)
def test_layernorm_guard_fwd_spawn(
num_tokens: int,
hidden_size: int,
dtype: torch.dtype,
case: FwdCase,
device: str = "cuda",
):
_skip_if_no_cuda_or_not_enough_gpus(NUM_GPUS)
_skip_if_dtype_unsupported(dtype)
if case.group_size is not None and hidden_size % case.group_size != 0:
pytest.skip(
f"hidden_size {hidden_size} not divisible by group_size {case.group_size}"
)
master_port = _find_free_port()
world_size = NUM_GPUS
torch.multiprocessing.spawn(
_layernorm_guard_fwd_worker,
args=(
world_size,
master_port,
num_tokens,
hidden_size,
dtype,
case,
device,
),
nprocs=world_size,
join=True,
)
def _layernorm_guard_fwd_worker(
local_rank: int,
world_size: int,
master_port: int,
num_tokens: int,
hidden_size: int,
dtype: torch.dtype,
case: FwdCase,
device: str,
):
device = _setup_sglang_distributed(local_rank, world_size, master_port, dtype)
with torch.inference_mode():
torch.manual_seed(42 + local_rank)
torch.cuda.manual_seed_all(42 + local_rank)
x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
z = (
torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
if case.with_gate
else None
)
weight = torch.randn(hidden_size, dtype=dtype, device=device)
bias = (
None
if case.is_rms_norm
else torch.randn(hidden_size, dtype=dtype, device=device)
)
eps = 1e-6
out, mean, rstd = layer_norm_fwd(
x,
weight,
bias,
eps,
z=z,
group_size=case.group_size,
norm_before_gate=case.norm_before_gate,
is_rms_norm=case.is_rms_norm,
)
ref_out = layer_norm_ref(
x,
weight,
bias,
z=z,
eps=eps,
group_size=case.group_size,
norm_before_gate=case.norm_before_gate,
is_rms_norm=case.is_rms_norm,
)
assert out.shape == x.shape
assert out.dtype == x.dtype
torch.testing.assert_close(out, ref_out, atol=1e-2, rtol=1e-2)
# mean/rstd shape checks (same spirit as original vLLM tests)
if case.group_size is None:
if not case.is_rms_norm:
assert mean.shape == (num_tokens,)
assert rstd.shape == (num_tokens,)
else:
ngroups = hidden_size // case.group_size
if not case.is_rms_norm:
assert mean.shape == (ngroups * num_tokens,)
assert rstd.shape == (ngroups * num_tokens,)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
def test_layernorm_guard_misc_spawn(dtype: torch.dtype, device: str = "cuda"):
_skip_if_no_cuda_or_not_enough_gpus(NUM_GPUS)
_skip_if_dtype_unsupported(dtype)
master_port = _find_free_port()
world_size = NUM_GPUS
torch.multiprocessing.spawn(
_layernorm_guard_misc_worker,
args=(world_size, master_port, dtype, device),
nprocs=world_size,
join=True,
)
def _layernorm_guard_misc_worker(
local_rank: int,
world_size: int,
master_port: int,
dtype: torch.dtype,
device: str,
):
device = _setup_sglang_distributed(local_rank, world_size, master_port, dtype)
with torch.inference_mode():
torch.manual_seed(123 + local_rank)
torch.cuda.manual_seed_all(123 + local_rank)
# 1) rows_per_block-like sizes
hidden_size = 1024
weight = torch.randn(hidden_size, dtype=dtype, device=device)
bias = torch.randn(hidden_size, dtype=dtype, device=device)
eps = 1e-6
for num_tokens in [513]:
x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
out, _, _ = layer_norm_fwd(x, weight, bias, eps, z=None, is_rms_norm=False)
ref = layer_norm_ref(x, weight, bias, z=None, eps=eps, is_rms_norm=False)
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
# 2) strided input (slice then contiguous)
num_tokens = 128
x_large = torch.randn(num_tokens, hidden_size * 2, dtype=dtype, device=device)
x = x_large[:, :hidden_size]
x_contig = x.contiguous()
out, _, _ = layer_norm_fwd(
x_contig, weight, bias, eps, z=None, is_rms_norm=False
)
ref = layer_norm_ref(x_contig, weight, bias, z=None, eps=eps, is_rms_norm=False)
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
# 3) provided output buffer
num_tokens = 256
x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
out_buf = torch.empty_like(x)
out, _, _ = layer_norm_fwd(
x, weight, bias, eps, z=None, out=out_buf, is_rms_norm=False
)
assert out.data_ptr() == out_buf.data_ptr()
ref = layer_norm_ref(x, weight, bias, z=None, eps=eps, is_rms_norm=False)
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
# 4) multidimensional input via autograd fn
for shape in [(4, 16, 1024)]:
hs = shape[-1]
x = torch.randn(*shape, dtype=dtype, device=device)
w = torch.randn(hs, dtype=dtype, device=device)
b = torch.randn(hs, dtype=dtype, device=device)
out = layernorm_fn(x, w, b, z=None, eps=eps)
ref = layer_norm_ref(x, w, b, z=None, eps=eps, is_rms_norm=False)
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
if __name__ == "__main__":
sys.exit(pytest.main([__file__]))
@@ -65,39 +65,6 @@ class TestBenchServing1GPUPart1(CustomTestCase):
else:
self.assertGreater(res["output_throughput"], 1050)
def test_offline_throughput_without_radix_cache(self):
res = run_bench_serving(
model=DEFAULT_MODEL_NAME_FOR_TEST,
num_prompts=500,
request_rate=float("inf"),
other_server_args=["--disable-radix-cache"],
)
if is_in_ci():
write_github_step_summary(
f"### test_offline_throughput_without_radix_cache\n"
f"Output throughput: {res['output_throughput']:.2f} token/s\n"
)
if is_in_amd_ci():
self.assertGreater(res["output_throughput"], 3050)
else:
self.assertGreater(res["output_throughput"], 3800)
def test_offline_throughput_without_chunked_prefill(self):
res = run_bench_serving(
model=DEFAULT_MODEL_NAME_FOR_TEST,
num_prompts=500,
request_rate=float("inf"),
other_server_args=["--chunked-prefill-size", "-1"],
)
if is_in_ci():
write_github_step_summary(
f"### test_offline_throughput_without_chunked_prefill\n"
f"Output throughput: {res['output_throughput']:.2f} token/s\n"
)
self.assertGreater(res["output_throughput"], 2600)
def test_offline_throughput_with_triton_attention_backend(self):
res = run_bench_serving(
model=DEFAULT_MODEL_NAME_FOR_TEST,
@@ -24,32 +24,6 @@ register_amd_ci(est_time=900, suite="stage-b-test-1-gpu-large-amd")
class TestBenchServing1GPUPart2(CustomTestCase):
@unittest.skip(
"Qwen2.5-VL server crashes with SIGBUS (exit code -7) on main; disable until fixed"
)
def test_vlm_offline_throughput(self):
res = run_bench_serving(
model=DEFAULT_SMALL_VLM_MODEL_NAME_FOR_TEST,
num_prompts=200,
request_rate=float("inf"),
other_server_args=[
"--mem-fraction-static",
"0.7",
],
dataset_name="mmmu",
)
if is_in_ci():
write_github_step_summary(
f"### test_vlm_offline_throughput\n"
f"Output throughput: {res['output_throughput']:.2f} token/s\n"
)
# relax for mi300x
if is_in_amd_ci():
self.assertGreater(res["output_throughput"], 900)
else:
self.assertGreater(res["output_throughput"], 2500)
def test_vlm_online_latency(self):
res = run_bench_serving(
model=DEFAULT_SMALL_VLM_MODEL_NAME_FOR_TEST,
@@ -37,24 +37,6 @@ class TestBenchServing2GPU(CustomTestCase):
else:
self.assertGreater(res["output_throughput"], 2200)
def test_moe_offline_throughput_without_radix_cache(self):
res = run_bench_serving(
model=DEFAULT_MOE_MODEL_NAME_FOR_TEST,
num_prompts=300,
request_rate=float("inf"),
other_server_args=["--tp", "2", "--disable-radix-cache"],
)
if is_in_ci():
write_github_step_summary(
f"### test_moe_offline_throughput_without_radix_cache\n"
f"Output throughput: {res['output_throughput']:.2f} token/s\n"
)
if is_in_amd_ci():
self.assertGreater(res["output_throughput"], 2100)
else:
self.assertGreater(res["output_throughput"], 2200)
def test_pp_offline_throughput_default_decode(self):
res = run_bench_serving(
model=DEFAULT_MOE_MODEL_NAME_FOR_TEST,
@@ -19,7 +19,7 @@ from sglang.test.test_utils import (
terminate_and_kill_process_tree,
)
register_cuda_ci(est_time=738, stage="base-c", runner_config="4-gpu-h100")
register_cuda_ci(est_time=738, stage="nightly", runner_config="4-gpu-h100")
QWEN3_32B_MODEL = "Qwen/Qwen3-32B"
@@ -1,800 +0,0 @@
import asyncio
import os
import re
import time
import unittest
from dataclasses import dataclass
from types import SimpleNamespace
from typing import List, Optional
import openai
import requests
import torch
from sglang.benchmark.serving import run_benchmark
from sglang.srt.managers.prefill_delayer import PrefillDelayer
from sglang.srt.runtime_context import get_context
from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_MLA_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
get_benchmark_args,
popen_launch_server,
run_distributed_test,
)
register_cuda_ci(
est_time=300,
stage="base-c",
runner_config="8-gpu-h200",
disabled="Temporarily disabled",
)
WORLD_SIZE = os.environ.get("SGLANG_TEST_WORLD_SIZE", "8")
# ============================ Unit Tests ============================
@dataclass
class NegotiateCall:
prefillable: List[bool]
token_usage: List[float]
# Optional scheduler state; when None, _run_negotiate_test does not pass
# the kwarg and the delayer falls back to the historical behavior of
# reading kwargs.get(..., 0).
running_batch: Optional[List[int]] = None
max_prefill_bs: Optional[List[int]] = None
waiting_queue_len: Optional[List[int]] = None
max_running_requests: Optional[int] = None
# Inter-call sleep (seconds). Used to exercise the queue-trigger
# wall-clock timeout.
sleep_before_s: float = 0.0
@dataclass
class NegotiateTestCase:
name: str
max_delay_passes: int
token_usage_low_watermark: Optional[float]
calls: List[NegotiateCall]
expected_allow: bool
expected_reason: str
# Queue-trigger knobs (new in the queue-based delayer). Leave both None
# to exercise the legacy slot-only code paths.
queue_min_ratio: Optional[float] = None
max_delay_ms: Optional[float] = None
prefill_max_requests: Optional[int] = None
# Expected accumulated wait surfaced on the final (release) outcome. When
# set, asserts the wait histograms would observe this value instead of 0.
expected_wait_forward_passes: Optional[int] = None
def _run_negotiate_test(rank, test_cases):
world_size = torch.distributed.get_world_size()
cpu_group = torch.distributed.new_group(backend="gloo")
for case in test_cases:
# The DP-attention gate is a published config leaf.
override = get_context().override_server_args(
enable_dp_attention=True,
prefill_delayer_queue_min_ratio=case.queue_min_ratio,
prefill_delayer_max_delay_ms=case.max_delay_ms,
prefill_max_requests=case.prefill_max_requests,
)
override.install()
delayer = PrefillDelayer(
dp_size=world_size,
attn_tp_size=1,
cpu_group=cpu_group,
max_delay_passes=case.max_delay_passes,
token_usage_low_watermark=case.token_usage_low_watermark,
)
for call in case.calls:
if call.sleep_before_s > 0:
time.sleep(call.sleep_before_s)
extra_kwargs = {}
if call.running_batch is not None:
extra_kwargs["running_batch"] = call.running_batch[rank]
if call.max_prefill_bs is not None:
extra_kwargs["max_prefill_bs"] = call.max_prefill_bs[rank]
if call.waiting_queue_len is not None:
extra_kwargs["waiting_queue_len"] = call.waiting_queue_len[rank]
if call.max_running_requests is not None:
extra_kwargs["max_running_requests"] = call.max_running_requests
result = delayer._negotiate_should_allow_prefill(
local_prefillable=call.prefillable[rank],
token_usage=call.token_usage[rank],
**extra_kwargs,
)
assert (result.output_allow, result.output_reason) == (
case.expected_allow,
case.expected_reason,
), f"Case {case.name} rank {rank}"
if case.expected_wait_forward_passes is not None:
assert result.wait_forward_passes == case.expected_wait_forward_passes, (
f"Case {case.name} rank {rank}: wait_forward_passes "
f"{result.wait_forward_passes} != {case.expected_wait_forward_passes}"
)
# On a release after a real wait, seconds must be observed too.
if case.expected_wait_forward_passes > 0:
assert result.wait_seconds > 0.0, (
f"Case {case.name} rank {rank}: wait_seconds not surfaced"
)
override.restore()
_NEGOTIATE_TEST_CASES = [
NegotiateTestCase(
name="all_prefillable",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
],
expected_allow=True,
expected_reason="no_wait",
# No prior wait, so the histograms legitimately observe 0.
expected_wait_forward_passes=0,
),
NegotiateTestCase(
name="all_prefillable_with_previous_wait",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
],
expected_allow=True,
expected_reason="wait_success",
# One mixed delay preceded the release, so the wait histograms must
# observe 1 forward pass (regression guard for #25949).
expected_wait_forward_passes=1,
),
NegotiateTestCase(
name="none_prefillable",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[False, False, False, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
],
expected_allow=True,
expected_reason="",
),
NegotiateTestCase(
name="mixed_delay",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="mixed_watermark_force_allow",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
],
expected_allow=True,
expected_reason="token_watermark",
),
NegotiateTestCase(
name="mixed_watermark_disabled",
max_delay_passes=100,
token_usage_low_watermark=None,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="mixed_watermark_not_prefillable",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[False, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="mixed_timeout",
max_delay_passes=3,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
],
expected_allow=True,
expected_reason="wait_timeout",
# Two delays accumulated before timing out; the timeout release must
# still surface that wait to the histograms.
expected_wait_forward_passes=2,
),
# Queue-based trigger: waiting queue below queue_min = min(running * R,
# max_prefill_bs) should defer prefill. With R=0.5, running=100 and
# max_prefill_bs=80, queue_min = min(50, 80) = 50, and queue_len=10 < 50.
NegotiateTestCase(
name="queue_trigger_delay",
max_delay_passes=100,
token_usage_low_watermark=0.8,
queue_min_ratio=0.5,
max_delay_ms=5000,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=1024,
),
# skip_first_delayer consumes the first would-be delay; a second
# identical call must actually delay.
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=1024,
),
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="queue_trigger_uses_admission_capacity",
max_delay_passes=100,
token_usage_low_watermark=0.8,
queue_min_ratio=0.02,
max_delay_ms=5000,
prefill_max_requests=128,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[500, 500, 500, 500],
max_prefill_bs=[1, 1, 1, 1],
waiting_queue_len=[1, 1, 1, 1],
max_running_requests=1024,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[500, 500, 500, 500],
max_prefill_bs=[1, 1, 1, 1],
waiting_queue_len=[1, 1, 1, 1],
max_running_requests=1024,
),
],
expected_allow=False,
expected_reason="delay",
),
# Waiting queue at or above queue_min: queue trigger must not fire.
NegotiateTestCase(
name="queue_trigger_above_threshold",
max_delay_passes=100,
token_usage_low_watermark=0.8,
queue_min_ratio=0.5,
max_delay_ms=5000,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[64, 64, 64, 64],
max_running_requests=1024,
)
],
expected_allow=True,
expected_reason="no_wait",
),
# queue_min_ratio unset: queue trigger is opt-in and must stay disabled
# even when running_batch and queue_len would otherwise trigger it.
NegotiateTestCase(
name="queue_trigger_disabled_when_ratio_unset",
max_delay_passes=100,
token_usage_low_watermark=0.8,
queue_min_ratio=None,
max_delay_ms=None,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[1, 1, 1, 1],
max_running_requests=1024,
)
],
expected_allow=True,
expected_reason="no_wait",
),
# max_delay_ms wall-clock timeout: once a single queue-trigger delay
# exceeds the cap, prefill must be force-released.
# Call sequence:
# 1) queue_condition holds but skip_first_delayer consumes it
# (no state recorded, falls through to allow)
# 2) queue_condition holds -> delay, records start_time in state
# 3) after sleeping past max_delay_ms, elapsed >= cap -> force release
NegotiateTestCase(
name="queue_trigger_wall_clock_timeout",
max_delay_passes=100,
token_usage_low_watermark=0.8,
queue_min_ratio=0.5,
max_delay_ms=50,
calls=[
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=1024,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=1024,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=1024,
sleep_before_s=0.2, # > max_delay_ms (50ms)
),
],
expected_allow=True,
expected_reason="wait_success",
# One queue-trigger delay was recorded before the wall-clock release.
expected_wait_forward_passes=1,
),
# slot_condition (all-branch) must not delay forever: with 128-100=28
# free slots < max_prefill_bs=80 the delay holds, but it must release
# with wait_timeout after max_delay_passes, like the mixed branch.
NegotiateTestCase(
name="slot_condition_pass_cap_timeout",
max_delay_passes=3,
token_usage_low_watermark=0.8,
calls=[
# skip_first_delayer consumes the first would-be delay.
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=128,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=128,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=128,
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
running_batch=[100, 100, 100, 100],
max_prefill_bs=[80, 80, 80, 80],
waiting_queue_len=[10, 10, 10, 10],
max_running_requests=128,
),
],
expected_allow=True,
expected_reason="wait_timeout",
# Two slot-condition delays accumulated after the skip-first pass.
expected_wait_forward_passes=2,
),
]
class TestPrefillDelayerNegotiate(unittest.TestCase):
def test_negotiate(self):
run_distributed_test(
_run_negotiate_test,
world_size=4,
backend="gloo",
test_cases=_NEGOTIATE_TEST_CASES,
)
# ============================ E2E Tests ============================
class TestPrefillDelayerThroughputOnlineServing(CustomTestCase):
def test_throughput_comparison(self):
_run_throughput_comparison(
self,
test_name="online_serving",
other_launch_args=[
# Not really needed, only to test support non-FCFS algorithms
"--schedule-policy",
"lpm",
],
other_benchmark_args=dict(
num_prompts=500,
random_input_len=30000,
random_output_len=256,
request_rate=32,
),
# TODO: re-enable a throughput-improvement assertion once a
# workload that reliably exercises PrefillDelayer in online-
# serving mode is available. The current workload yields run-
# to-run noise on H200, while the offline test below shows the
# same code path is healthy (improvement ~+27%). We still
# validate functionality (server boot, benchmark completion,
# metrics emission).
min_improvement_pct=None,
)
class TestPrefillDelayerThroughputOfflineGen(CustomTestCase):
def test_throughput_comparison(self):
_run_throughput_comparison(
self,
test_name="offline_gen",
other_launch_args=["--max-total-tokens", "200000"],
other_benchmark_args=dict(
num_prompts=800,
random_input_len=30000,
random_output_len=500,
),
token_usage_low_watermark=0.8,
min_improvement_pct=20,
)
def _run_throughput_comparison(
test_case,
test_name: str,
other_launch_args,
other_benchmark_args,
min_improvement_pct: Optional[float],
token_usage_low_watermark: float = None,
):
common_kwargs = dict(
debug_name=test_name,
other_launch_args=other_launch_args,
other_benchmark_args=other_benchmark_args,
token_usage_low_watermark=token_usage_low_watermark,
)
res_enabled = _run_throughput_test(prefill_delayer=True, **common_kwargs)
res_disabled = _run_throughput_test(prefill_delayer=False, **common_kwargs)
_assert_throughput_improvement(
test_case,
test_name=test_name,
res_enabled=res_enabled,
res_disabled=res_disabled,
min_improvement_pct=min_improvement_pct,
)
def _run_throughput_test(
debug_name: str,
prefill_delayer: bool,
other_launch_args,
other_benchmark_args,
token_usage_low_watermark: float = None,
):
model = "Qwen/Qwen3-0.6B"
base_url = DEFAULT_URL_FOR_TEST
process = _launch_server(
prefill_delayer=prefill_delayer,
model=model,
base_url=base_url,
other_args=other_launch_args,
token_usage_low_watermark=token_usage_low_watermark,
)
try:
args = get_benchmark_args(
base_url=base_url,
dataset_name="random",
tokenizer=model,
**other_benchmark_args,
)
res = run_benchmark(args)
_print_prefill_delayer_metrics(base_url, expect_metrics=prefill_delayer)
finally:
kill_process_tree(process.pid)
print(f"=== {debug_name} ({prefill_delayer=}) ===")
res["total_throughput"] = res["input_throughput"] + res["output_throughput"]
print(f"Input throughput: {res['input_throughput']:.2f} token/s")
print(f"Output throughput: {res['output_throughput']:.2f} token/s")
print(f"Total throughput: {res['total_throughput']:.2f} token/s")
return res
def _assert_throughput_improvement(
test_case,
test_name: str,
res_enabled: dict,
res_disabled: dict,
min_improvement_pct: Optional[float],
):
test_case.assertEqual(
WORLD_SIZE,
"8",
f"This test requires 8 GPUs to properly measure throughput improvement, got {WORLD_SIZE}",
)
enabled = res_enabled["total_throughput"]
disabled = res_disabled["total_throughput"]
improvement_pct = (enabled - disabled) / disabled * 100
print(f"\n=== {test_name} Throughput Comparison ===")
print(
f"Total: enabled={enabled:.2f}, disabled={disabled:.2f}, improvement={improvement_pct:.2f}%"
)
if min_improvement_pct is None:
# Functionality-only mode: skip the perf assertion.
return
test_case.assertGreaterEqual(
improvement_pct,
min_improvement_pct,
f"{test_name}: Throughput improvement ({improvement_pct:.2f}%) < {min_improvement_pct}%",
)
class TestPrefillDelayerTokenUsageLowWatermark(CustomTestCase):
def test_1_with_low_watermark(self):
# The kv cache size here is deliberately small, thus we use smaller token usage
self._run(token_usage_low_watermark=0.5)
# TODO: re-enable once sglang/sglang#22511 (DP-attention detokenizer
# hang on H200 in CI) is fixed.
@unittest.skip("blocked by sgl-project/sglang#22511")
def test_2_without_low_watermark(self):
self._run(token_usage_low_watermark=None)
def _run(self, token_usage_low_watermark):
model = "Qwen/Qwen3-0.6B"
base_url = DEFAULT_URL_FOR_TEST
world_size = int(WORLD_SIZE)
process = _launch_server(
model=model,
base_url=base_url,
prefill_delayer=True,
other_args=["--max-total-tokens", "50000"],
# e.g. gen throughput is 370 tok/s on H200.
# Will need a different threshold on B200
max_delay_passes=3000,
token_usage_low_watermark=token_usage_low_watermark,
)
async def run_test():
client = openai.AsyncClient(base_url=f"{base_url}/v1", api_key="EMPTY")
long_prompt = "Hello " * 5000
async def send_blocking_request():
return await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": long_prompt}],
max_tokens=10000,
extra_body={"data_parallel_rank": 0},
)
async def send_normal_request(dp_rank, req_idx):
start = time.time()
await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Say hi"}],
max_tokens=10,
extra_body={"data_parallel_rank": dp_rank},
)
elapsed = time.time() - start
return dp_rank, req_idx, elapsed
asyncio.create_task(send_blocking_request())
await asyncio.sleep(3)
num_reqs_per_rank = 10
results = await asyncio.gather(
*[
send_normal_request(dp_rank, req_idx)
for dp_rank in range(1, world_size)
for req_idx in range(num_reqs_per_rank)
]
)
enabled = token_usage_low_watermark is not None
thresh = 5
for dp_rank, req_idx, elapsed in results:
print(f"DP rank {dp_rank} req {req_idx} completed in {elapsed:.2f}s")
self.assertTrue(
(elapsed < thresh) if enabled else (elapsed > thresh),
f"DP rank {dp_rank} req {req_idx}: elapsed={elapsed:.2f}s, thresh={thresh}, enabled={enabled}. "
f"Maybe you need a different `max_delay_passes` when using hardware other than H200.",
)
try:
asyncio.run(run_test())
metrics_text = _print_prefill_delayer_metrics(base_url, expect_metrics=True)
if token_usage_low_watermark is not None:
total = _sum_prometheus_metric_values(metrics_text, "token_watermark")
self.assertGreater(total, 0, "Expected token_watermark > 0")
print(f"total token_watermark: {total}")
finally:
kill_process_tree(process.pid)
class TestPrefillDelayerAccuracy(CustomTestCase):
def test_1_gsm8k_has_prefill_delayer(self):
self._run_accuracy_test(prefill_delayer=True)
def test_2_gsm8k_no_prefill_delayer(self):
self._run_accuracy_test(prefill_delayer=False)
def _run_accuracy_test(self, prefill_delayer: bool):
model = DEFAULT_MLA_MODEL_NAME_FOR_TEST
base_url = DEFAULT_URL_FOR_TEST
process = _launch_server(
prefill_delayer=prefill_delayer,
model=model,
base_url=base_url,
other_args=[
# Not really needed, only to test support non-FCFS algorithms
"--schedule-policy",
"lpm",
# Use this to ensure prefill delayer will be run
"--max-total-tokens",
"4096",
],
)
try:
args = SimpleNamespace(
base_url=base_url,
model=model,
eval_name="gsm8k",
num_examples=None,
num_threads=1024,
)
metrics = run_eval(args)
print(f"=== gsm8k ({prefill_delayer=}) ===")
print(f"{metrics=}")
self.assertGreater(metrics["score"], 0.57)
finally:
kill_process_tree(process.pid)
def _launch_server(
*,
model,
base_url,
prefill_delayer: bool,
other_args,
max_delay_passes: int = 100,
token_usage_low_watermark: float = None,
):
os.environ["SGLANG_PREFILL_DELAYER_DEBUG_LOG"] = "1"
return popen_launch_server(
model,
base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--tp",
WORLD_SIZE,
"--enable-dp-attention",
"--dp",
WORLD_SIZE,
"--chunked-prefill-size",
"131072",
"--mem-fraction-static",
"0.6",
"--enable-metrics",
*(["--enable-prefill-delayer"] if prefill_delayer else []),
"--prefill-delayer-max-delay-passes",
str(max_delay_passes),
*(
[
"--prefill-delayer-token-usage-low-watermark",
str(token_usage_low_watermark),
]
if token_usage_low_watermark is not None
else []
),
*(other_args or []),
],
)
def _print_prefill_delayer_metrics(base_url: str, expect_metrics: bool) -> str:
metrics_response = requests.get(f"{base_url}/metrics")
assert metrics_response.status_code == 200
metrics_text = metrics_response.text
prefill_delayer_metrics = [
line for line in metrics_text.split("\n") if "prefill_delayer" in line
]
print("=== PrefillDelayer Metrics ===")
for line in prefill_delayer_metrics:
print(line)
if expect_metrics:
assert "sglang:prefill_delayer_wait_forward_passes" in metrics_text
assert "sglang:prefill_delayer_wait_seconds" in metrics_text
assert "sglang:prefill_delayer_outcomes_total" in metrics_text
return metrics_text
def _sum_prometheus_metric_values(metrics_text: str, label_value: str) -> int:
matches = re.findall(rf'{label_value}".*?\}} (\d+)', metrics_text)
return sum(int(m) for m in matches)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -101,6 +101,7 @@ PER_COMMIT_SUITES = {
"extra-b-test-4-gpu-h100",
"extra-b-test-4-gpu-b200",
"extra-b-test-8-gpu-h200",
"extra-b-test-8-gpu-b300",
],
HWBackend.NPU: [
"base-a-test-1-npu-a2",