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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,73 @@
import logging
import time
from contextlib import contextmanager
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
logger = logging.getLogger(__name__)
@contextmanager
def openai_api_env(api_key: str):
"""Context manager to set OpenAI API environment variables."""
import os
original_api_key = os.environ.get("OPENAI_API_KEY")
original_base_url = os.environ.get("OPENAI_API_BASE")
os.environ["OPENAI_API_KEY"] = api_key
os.environ["OPENAI_API_BASE"] = f"{DEFAULT_URL_FOR_TEST}/v1"
try:
yield
finally:
if original_api_key is not None:
os.environ["OPENAI_API_KEY"] = original_api_key
else:
del os.environ["OPENAI_API_KEY"]
if original_base_url is not None:
os.environ["OPENAI_API_BASE"] = original_base_url
else:
del os.environ["OPENAI_API_BASE"]
class DefaultServerBase(CustomTestCase):
model = None
base_url = DEFAULT_URL_FOR_TEST
timeout = DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
other_args: list[str] = []
# For OpenAI API settings
api_key = "sk-123456"
@classmethod
def setUpClass(cls):
assert cls.model is not None, "Please set cls.model in subclass"
# Set OpenAI API key and base URL environment variables.
# Needed for lmm-evals to work.
with openai_api_env(cls.api_key):
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=cls.timeout,
other_args=cls.other_args,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid, wait_timeout=60)
time.sleep(2)
@classmethod
def flush_cache(cls):
requests.post(cls.base_url + "/flush_cache")
@@ -0,0 +1,458 @@
import io
import logging
import os
import shlex
import time
import warnings
from typing import ClassVar, Optional
from urllib.parse import urlparse
from sglang.srt.environ import envs
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_ci,
popen_launch_pd_server,
popen_with_error_check,
start_subprocess_fail_fast_watcher,
)
from sglang.utils import wait_for_http_ready
logger = logging.getLogger(__name__)
class PDDisaggregationServerBase(CustomTestCase):
capture_per_side_logs: ClassVar[bool] = False
extra_prefill_env: ClassVar[dict[str, str]] = {}
extra_decode_env: ClassVar[dict[str, str]] = {}
_prefill_stdout_buf: ClassVar[Optional[io.StringIO]] = None
_prefill_stderr_buf: ClassVar[Optional[io.StringIO]] = None
_decode_stdout_buf: ClassVar[Optional[io.StringIO]] = None
_decode_stderr_buf: ClassVar[Optional[io.StringIO]] = None
@classmethod
def setUpClass(cls):
os.environ["MC_TCP_ENABLE_CONNECTION_POOL"] = "true"
parsed_url = urlparse(DEFAULT_URL_FOR_TEST)
cls.base_host = parsed_url.hostname
base_port = str(parsed_url.port)
cls.lb_port = base_port
cls.prefill_port = f"{int(base_port) + 100}"
cls.decode_port = f"{int(base_port) + 200}"
cls.bootstrap_port = f"{int(base_port) + 500}"
cls.prefill_url = f"http://{cls.base_host}:{cls.prefill_port}"
cls.decode_url = f"http://{cls.base_host}:{cls.decode_port}"
cls.lb_url = f"http://{cls.base_host}:{cls.lb_port}"
cls.base_url = cls.lb_url
print(
f"{cls.base_host=} {cls.lb_port=} {cls.prefill_port=} {cls.decode_port=} {cls.bootstrap_port=}"
)
cls.process_lb, cls.process_decode, cls.process_prefill = None, None, None
if cls.capture_per_side_logs:
cls._prefill_stdout_buf = io.StringIO()
cls._prefill_stderr_buf = io.StringIO()
cls._decode_stdout_buf = io.StringIO()
cls._decode_stderr_buf = io.StringIO()
cls._fail_fast_stop = None
# config transfer backend and rdma devices
cls._mc_gid_index_set = False
if is_in_ci():
cls.transfer_backend = ["--disaggregation-transfer-backend", "mooncake"]
ib_devices = get_rdma_devices_args()
cls.rdma_devices = ["--disaggregation-ib-device", ib_devices]
cls._mc_gid_index_set = _maybe_set_roce_gid_index(ib_devices)
else:
cls.transfer_backend = [
"--disaggregation-transfer-backend",
envs.SGLANG_TEST_PD_DISAGG_BACKEND.get(),
]
cls.rdma_devices = [
"--disaggregation-ib-device",
envs.SGLANG_TEST_PD_DISAGG_DEVICES.get(),
]
if cls.rdma_devices[1] is None:
cls.rdma_devices = []
msg = "No RDMA devices specified for disaggregation test, using default settings."
warnings.warn(msg)
# Subclasses can set these to customize server args
extra_prefill_args = []
extra_decode_args = []
@classmethod
def start_prefill(cls):
prefill_args = [
"--trust-remote-code",
"--disaggregation-mode",
"prefill",
"--disaggregation-bootstrap-port",
cls.bootstrap_port,
"--tp",
"1",
] + list(cls.extra_prefill_args)
prefill_args += cls.transfer_backend + cls.rdma_devices
cls.process_prefill = popen_launch_pd_server(
cls.model,
cls.prefill_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=prefill_args,
env=dict(cls.extra_prefill_env),
return_stdout_stderr=(
(cls._prefill_stdout_buf, cls._prefill_stderr_buf)
if cls.capture_per_side_logs
else None
),
)
@classmethod
def start_decode(cls):
decode_args = [
"--trust-remote-code",
"--disaggregation-mode",
"decode",
"--disaggregation-bootstrap-port",
cls.bootstrap_port,
"--tp",
"1",
"--base-gpu-id",
"1",
] + list(cls.extra_decode_args)
decode_args += cls.transfer_backend + cls.rdma_devices
cls.process_decode = popen_launch_pd_server(
cls.model,
cls.decode_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=decode_args,
env=dict(cls.extra_decode_env),
return_stdout_stderr=(
(cls._decode_stdout_buf, cls._decode_stderr_buf)
if cls.capture_per_side_logs
else None
),
)
@classmethod
def launch_all(cls):
"""Start prefill, decode, wait for health, and launch LB."""
cls.start_prefill()
cls.start_decode()
cls.wait_server_ready(cls.prefill_url + "/health", process=cls.process_prefill)
cls.wait_server_ready(cls.decode_url + "/health", process=cls.process_decode)
cls.launch_lb()
cls._fail_fast_stop = start_subprocess_fail_fast_watcher(
[
("prefill", cls.process_prefill),
("decode", cls.process_decode),
("lb", cls.process_lb),
]
)
@classmethod
def launch_lb(cls):
lb_command = [
"python3",
"-m",
"sglang_router.launch_router",
"--pd-disaggregation",
"--mini-lb", # FIXME: remove this
"--prefill",
cls.prefill_url,
"--decode",
cls.decode_url,
"--host",
cls.base_host,
"--port",
cls.lb_port,
]
print("Starting load balancer:", shlex.join(lb_command))
cls.process_lb = popen_with_error_check(lb_command)
cls.wait_server_ready(cls.lb_url + "/health", process=cls.process_lb)
@classmethod
def wait_server_ready(
cls, url, timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, process=None
):
wait_for_http_ready(url=url, timeout=timeout, process=process)
print(f"Server {url} is ready")
@classmethod
def tearDownClass(cls):
# Stop the watcher BEFORE killing processes: kill_process_tree
# below makes them exit with a negative signal rc, which would
# otherwise trip the watcher and os._exit out of pytest mid-teardown.
if cls._fail_fast_stop is not None:
cls._fail_fast_stop.set()
os.environ.pop("MC_TCP_ENABLE_CONNECTION_POOL")
if getattr(cls, "_mc_gid_index_set", False):
os.environ.pop("MC_GID_INDEX", None)
for process in [cls.process_lb, cls.process_decode, cls.process_prefill]:
if process:
try:
kill_process_tree(process.pid, wait_timeout=60)
except Exception as e:
print(f"Error killing process {process.pid}: {e}")
if cls.capture_per_side_logs:
for buf in (
cls._prefill_stdout_buf,
cls._prefill_stderr_buf,
cls._decode_stdout_buf,
cls._decode_stderr_buf,
):
if buf is not None:
buf.close()
cls._prefill_stdout_buf = None
cls._prefill_stderr_buf = None
cls._decode_stdout_buf = None
cls._decode_stderr_buf = None
# wait for 5 seconds
time.sleep(5)
def _get_available_ib_devices():
"""Auto-detect available high-speed RDMA devices from sysfs.
Filters for devices that are:
1. Not Ethernet NICs (excludes devices with 'eth' in the name like mlx5_eth0)
2. Active (port state)
3. High-speed (rate >= 100 Gbps to exclude regular Ethernet NICs)
"""
ib_sysfs_path = "/sys/class/infiniband"
if not os.path.isdir(ib_sysfs_path):
logger.warning("IB sysfs path %s does not exist", ib_sysfs_path)
return None
all_devices = sorted(os.listdir(ib_sysfs_path))
logger.warning("All IB devices in sysfs: %s", all_devices)
devices = []
for dev in all_devices:
# Check port 1 state and rate (most devices have single port)
port_path = os.path.join(ib_sysfs_path, dev, "ports", "1")
if not os.path.isdir(port_path):
logger.warning("Device %s: SKIPPED (no port 1)", dev)
continue
# Read state and rate for logging
state = "unknown"
rate = -1
state_file = os.path.join(port_path, "state")
rate_file = os.path.join(port_path, "rate")
try:
with open(state_file) as f:
state = f.read().strip()
except (OSError, IOError):
pass
try:
with open(rate_file) as f:
rate_str = f.read().strip()
rate = int(rate_str.split()[0])
except (OSError, IOError, ValueError, IndexError):
pass
# Log device properties for debugging
logger.warning(
"Device %s: state=%s, rate=%d Gbps, has_eth_in_name=%s",
dev,
state,
rate,
"eth" in dev.lower(),
)
# Skip devices with "eth" in the name - these are typically Ethernet NICs
# that don't work properly with RDMA (e.g., mlx5_eth0)
if "eth" in dev.lower():
logger.warning("Device %s: SKIPPED (contains 'eth' in name)", dev)
continue
# Check if port is active
# State format is like "4: ACTIVE" or just "ACTIVE"
if "ACTIVE" not in state.upper():
logger.warning("Device %s: SKIPPED (state=%s)", dev, state)
continue
# Check rate (filter out low-speed NICs like 10/25 Gbps Ethernet)
if rate >= 0 and rate < 100: # Skip devices slower than 100 Gbps
logger.warning("Device %s: SKIPPED (rate=%d Gbps)", dev, rate)
continue
devices.append(dev)
logger.warning("Device %s: INCLUDED", dev)
logger.warning("Filtered IB devices: %s (count=%d)", devices, len(devices))
return devices if devices else None
def get_rdma_devices_args():
def _parse_list_env(var_name: str):
val = os.getenv(var_name)
if not val:
return None
items = [x.strip() for x in val.split(",") if x.strip()]
return items or None
def _pick_default_pair(rdma_all_devices):
return [rdma_all_devices[0], rdma_all_devices[len(rdma_all_devices) // 2]]
# Priority: env var > auto-detect > hardcoded fallback
rdma_all_devices = (
_parse_list_env("SGLANG_CI_RDMA_ALL_DEVICES")
or _get_available_ib_devices()
or [f"mlx5_roce{i}" for i in range(8)]
)
logger.warning("Resolved rdma_all_devices=%s", rdma_all_devices)
n_rdma = len(rdma_all_devices)
# 1. Get visible GPU indices
cuda_visible_devices = os.getenv("CUDA_VISIBLE_DEVICES")
if not cuda_visible_devices:
warnings.warn("CUDA_VISIBLE_DEVICES is not set. Using default RDMA devices.")
return ",".join(_pick_default_pair(rdma_all_devices))
try:
# Convert to list of integers (handling possible spaces and empty strings)
gpu_indices = [
int(idx.strip()) for idx in cuda_visible_devices.split(",") if idx.strip()
]
if not gpu_indices or len(gpu_indices) > 4:
return ",".join(_pick_default_pair(rdma_all_devices))
except ValueError:
warnings.warn(f"Invalid CUDA_VISIBLE_DEVICES format: {cuda_visible_devices}")
return ",".join(_pick_default_pair(rdma_all_devices))
# 2. Calculate base RDMA index group (each group of 4 GPUs uses consecutive devices)
base_rdma_group = (min(gpu_indices) // 4) * 4
for gpu_idx in gpu_indices:
if not (base_rdma_group <= gpu_idx < base_rdma_group + 4):
warnings.warn(
f"GPU index {gpu_idx} is outside expected group "
f"{base_rdma_group}-{base_rdma_group+3}"
)
# 3. Generate RDMA device names
# Detect total GPUs on the node (not just visible ones)
try:
import torch
total_gpus = torch.cuda.device_count()
except Exception:
total_gpus = 8 # Fallback to common 8-GPU setup
# Handle edge cases
if total_gpus == 0:
total_gpus = 8
if n_rdma > total_gpus:
logger.warning(
"More RDMA devices (%d) than GPUs (%d), using first and middle device",
n_rdma,
total_gpus,
)
return ",".join(_pick_default_pair(rdma_all_devices))
# Calculate how many GPUs share each RDMA device
gpus_per_rdma = max(1, total_gpus // n_rdma)
logger.warning(
"GPU-to-RDMA mapping: total_gpus=%d, n_rdma=%d, gpus_per_rdma=%d",
total_gpus,
n_rdma,
gpus_per_rdma,
)
rdma_devices = []
base_gpu = min(gpu_indices)
for gpu_idx in gpu_indices:
nic_index = min((gpu_idx - base_gpu) // gpus_per_rdma, n_rdma - 1)
rdma_devices.append(rdma_all_devices[nic_index])
if not rdma_devices:
return ",".join(_pick_default_pair(rdma_all_devices))
# Deduplicate while preserving order
return ",".join(dict.fromkeys(rdma_devices))
_IB_SYSFS = "/sys/class/infiniband"
def _roce_v2_gid_index(device: str):
"""Return a RoCEv2 GID index for a device, preferring a global (routable)
GID over a link-local (fe80::) one, or None if the device has no RoCEv2 GID.
"""
port = os.path.join(_IB_SYSFS, device, "ports", "1")
types_dir = os.path.join(port, "gid_attrs", "types")
try:
indices = sorted(int(x) for x in os.listdir(types_dir) if x.isdigit())
except OSError:
return None
fallback = None
for i in indices:
try:
with open(os.path.join(types_dir, str(i))) as f:
if f.read().strip() != "RoCE v2":
continue
except OSError:
continue
if fallback is None:
fallback = i
try:
with open(os.path.join(port, "gids", str(i))) as f:
gid = f.read().strip()
except OSError:
gid = ""
# Prefer a global GID; link-local (fe80::) entries don't route between
# NICs on some fabrics.
if gid and not gid.lower().startswith("fe80"):
return i
return fallback
def _detect_roce_gid_index(devices):
"""Return a single RoCEv2 GID index shared by all `devices`, or None.
None when any device is InfiniBand (mooncake selects the GID automatically
there), when a device has no RoCEv2 GID, or when devices disagree on the
index — MC_GID_INDEX is a single global value, so a divergent set can't be
satisfied and is left to mooncake's own selection.
"""
picked = None
for device in [d.strip() for d in devices if d.strip()]:
try:
with open(os.path.join(_IB_SYSFS, device, "ports", "1", "link_layer")) as f:
if f.read().strip() != "Ethernet":
return None
except OSError:
return None
idx = _roce_v2_gid_index(device)
if idx is None:
return None
if picked is None:
picked = idx
elif picked != idx:
return None
return picked
def _maybe_set_roce_gid_index(ib_devices) -> bool:
"""Export MC_GID_INDEX for a RoCE fabric; return True if this call set it.
On RoCE-only hosts mooncake's automatic GID selection can come up empty
("GID is NULL, please check your GID index by specifying MC_GID_INDEX"),
leaving the KV-transfer RDMA endpoint with no GID so every prefill->decode
transfer fails and PD accuracy collapses to 0. InfiniBand hosts don't need
this (auto GID works), and a user-provided MC_GID_INDEX is left untouched.
"""
if not ib_devices or os.environ.get("MC_GID_INDEX"):
return False
gid_index = _detect_roce_gid_index(ib_devices.split(","))
if gid_index is None:
return False
os.environ["MC_GID_INDEX"] = str(gid_index)
logger.warning("RoCE fabric detected; set MC_GID_INDEX=%d for mooncake", gid_index)
return True
@@ -0,0 +1,98 @@
"""DSA model + MTP (EAGLE) speculative-decoding server fixture.
Variants combine `DsaMtpServerBase` (server lifecycle) with
`DsaMtpEvalConfigDefaults` (shared eval thresholds/params),
`GSM8KMixin` and `SpecDecodingMixin`, then set `model` and per-variant
overrides (`enable_dp_attention`, `mem_fraction_static`, `bs_1_speed_thres`).
Example:
class TestDsv32DP(
DsaMtpServerBase,
DsaMtpEvalConfigDefaults,
GSM8KMixin,
SpecDecodingMixin,
):
model = "deepseek-ai/DeepSeek-V3.2"
enable_dp_attention = True
bs_1_speed_thres = 90
The base itself is NOT a runnable test (no `test_*` methods until a subclass
mixes in the kits), so unittest discovery picks it up as empty.
"""
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
class DsaMtpEvalConfigDefaults:
"""Eval thresholds & params shared across DSA-MTP regression variants."""
# GSM8KMixin defaults.
gsm8k_accuracy_thres = 0.935
gsm8k_accept_length_thres = 3.7
gsm8k_num_questions = 500
gsm8k_num_threads = 500
gsm8k_num_shots = 20
# SpecDecodingMixin default; per-variant subclasses set `bs_1_speed_thres`.
accept_length_thres = 4.0
class DsaMtpServerBase(CustomTestCase):
base_url = DEFAULT_URL_FOR_TEST
# Subclasses must set `model`; the others have sensible defaults.
model: str = ""
tp_size: int = 8
dp_size: int = 8
mem_fraction_static: float = 0.7
enable_dp_attention: bool = False
extra_server_args = ()
# EAGLE MTP config (fixed across DSA-MTP variants).
speculative_algorithm: str = "EAGLE"
speculative_num_steps: int = 5
speculative_eagle_topk: int = 1
speculative_num_draft_tokens: int = 6
@classmethod
def get_server_args(cls):
assert cls.model, f"{cls.__name__} must set `model`"
args = ["--trust-remote-code", "--tp", str(cls.tp_size)]
if cls.enable_dp_attention:
args += ["--dp", str(cls.dp_size), "--enable-dp-attention"]
args += [
"--speculative-algorithm",
cls.speculative_algorithm,
"--speculative-num-steps",
str(cls.speculative_num_steps),
"--speculative-eagle-topk",
str(cls.speculative_eagle_topk),
"--speculative-num-draft-tokens",
str(cls.speculative_num_draft_tokens),
"--mem-frac",
str(cls.mem_fraction_static),
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
args += list(cls.extra_server_args)
return args
@classmethod
def setUpClass(cls):
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=cls.get_server_args(),
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
@@ -0,0 +1,115 @@
import json
import random
import time
import requests
from sglang.srt.utils.common import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_DRAFT_MODEL_EAGLE,
DEFAULT_TARGET_MODEL_EAGLE,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
PROMPTS = [
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nToday is a sunny day and I like[/INST]"
'[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nWhat are the mental triggers in Jeff Walker\'s Product Launch Formula and "Launch" book?[/INST]',
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nSummarize Russell Brunson's Perfect Webinar Script...[/INST]",
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nwho are you?[/INST]",
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nwhere are you from?[/INST]",
]
class EagleServerBase(CustomTestCase):
target_model = DEFAULT_TARGET_MODEL_EAGLE
draft_model = DEFAULT_DRAFT_MODEL_EAGLE
spec_algo = "EAGLE"
spec_steps = 5
spec_topk = 8
spec_tokens = 64
mem_fraction_static = 0.7
extra_args = []
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.target_model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
f"--speculative-algorithm={cls.spec_algo}",
f"--speculative-draft-model-path={cls.draft_model}",
f"--speculative-num-steps={cls.spec_steps}",
f"--speculative-eagle-topk={cls.spec_topk}",
f"--speculative-num-draft-tokens={cls.spec_tokens}",
f"--mem-fraction-static={cls.mem_fraction_static}",
]
+ cls.extra_args,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid, wait_timeout=60)
def send_request(self):
time.sleep(random.uniform(0, 2))
for prompt in PROMPTS:
url = self.base_url + "/generate"
data = {
"text": prompt,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 1024,
},
}
response = requests.post(url, json=data)
assert response.status_code == 200
def send_requests_abort(self):
for prompt in PROMPTS:
try:
time.sleep(random.uniform(0, 2))
url = self.base_url + "/generate"
data = {
"model": "base",
"text": prompt,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 1024,
},
}
# set timeout = 1s, mock disconnected
requests.post(url, json=data, timeout=1)
except Exception as e:
print(e)
pass
def run_decode(self, sampling_params):
return_logprob = True
top_logprobs_num = 5
return_text = True
n = 1
response = requests.post(
self.base_url + "/generate",
json={
"text": "Human: Write a travel blog post to Hawaii.\n\nAssistant:",
"sampling_params": {
"max_new_tokens": 48,
"n": n,
"temperature": 0.7,
**sampling_params,
},
"return_logprob": return_logprob,
"top_logprobs_num": top_logprobs_num,
"return_text_in_logprobs": return_text,
"logprob_start_len": 0,
},
)
self.assertEqual(response.status_code, 200)
print(json.dumps(response.json()))
print("=" * 100)
@@ -0,0 +1,102 @@
"""Hybrid attention-backend (FA3 prefill + FlashInfer decode) test fixture.
Variants combine `TestHybridAttnBackendBase` with their own
`get_server_args()` / `accuracy_threshold` / `speculative_decode` knobs.
Requires SM 90+ (H100); the base class wraps that in a `skipIf`.
"""
import unittest
from types import SimpleNamespace
import requests
from sglang.srt.environ import envs
from sglang.srt.utils import get_device_sm, kill_process_tree
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_MODEL_NAME_FOR_TEST,
DEFAULT_TARGET_MODEL_EAGLE,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
GSM_DATASET_PATH = None
# Default server arguments shared across all hybrid-attn-backend tests
DEFAULT_HYBRID_ATTN_SERVER_ARGS = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"8",
"--prefill-attention-backend",
"fa3",
"--decode-attention-backend",
"flashinfer",
]
@unittest.skipIf(get_device_sm() < 90, "Test requires CUDA SM 90 or higher")
class TestHybridAttnBackendBase(CustomTestCase):
model = DEFAULT_MODEL_NAME_FOR_TEST
base_url = DEFAULT_URL_FOR_TEST
accuracy_threshold = 0.65 # derived tests need to override this
speculative_decode = False
spec_decode_threshold = 2.2 # derived spec decoding tests need to override this
# Appended after DEFAULT_HYBRID_ATTN_SERVER_ARGS in get_server_args.
extra_args: list = []
@classmethod
def get_server_args(cls):
return DEFAULT_HYBRID_ATTN_SERVER_ARGS + list(cls.extra_args)
@classmethod
def setUpClass(cls):
# disable deep gemm precompile to make launch server faster
# please don't do this if you want to make your inference workload faster
with (
envs.SGLANG_JIT_DEEPGEMM_PRECOMPILE.override(False),
envs.SGLANG_ENABLE_JIT_DEEPGEMM.override(False),
):
if cls.speculative_decode:
model = DEFAULT_TARGET_MODEL_EAGLE
else:
model = cls.model
cls.process = popen_launch_server(
model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=cls.get_server_args(),
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
requests.get(self.base_url + "/flush_cache")
model = DEFAULT_TARGET_MODEL_EAGLE if self.speculative_decode else self.model
args = SimpleNamespace(
base_url=self.base_url,
model=model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=100,
num_threads=128,
)
metrics = run_eval(args)
print(f"{metrics=}")
self.assertGreater(metrics["score"], self.accuracy_threshold)
if self.speculative_decode:
server_info = requests.get(self.base_url + "/server_info")
avg_spec_accept_length = server_info.json()["internal_states"][0][
"avg_spec_accept_length"
]
print(f"{avg_spec_accept_length=}")
self.assertGreater(avg_spec_accept_length, self.spec_decode_threshold)
@@ -0,0 +1,71 @@
import logging
import os
import time
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
logger = logging.getLogger(__name__)
# Set default mem_fraction_static to 0.8
DEFAULT_MEM_FRACTION_STATIC = 0.8
class MMMUServerBase(CustomTestCase):
"""Server fixture for MMMU VLM tests.
This fixture handles server lifecycle for single-model MMMU tests.
For multi-model tests that need to start/stop servers within test methods,
use MMMUMultiModelTestBase instead.
Set server_api_key = None to launch without auth when sharing the server
with mixins whose clients do not send API keys.
"""
model = None
base_url = DEFAULT_URL_FOR_TEST
timeout = DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
other_args: list[str] = []
mem_fraction_static: float = DEFAULT_MEM_FRACTION_STATIC
server_api_key = "sk-123456"
@classmethod
def setUpClass(cls):
assert cls.model is not None, "Please set cls.model in subclass"
# Prepare environment variables
process_env = os.environ.copy()
process_env["SGLANG_USE_CUDA_IPC_TRANSPORT"] = "1"
# Build server args with MMMU-specific settings
server_args = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"64",
"--enable-multimodal",
"--mem-fraction-static",
str(cls.mem_fraction_static),
*cls.other_args,
]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=cls.timeout,
api_key=cls.server_api_key,
other_args=server_args,
env=process_env,
)
@classmethod
def tearDownClass(cls):
if cls.process is not None and cls.process.poll() is None:
try:
kill_process_tree(cls.process.pid, wait_timeout=60)
except Exception as e:
logger.error(f"Error killing process: {e}")
time.sleep(2)
@@ -0,0 +1,74 @@
"""NGRAM speculative-decoding server fixture.
Variants combine this base with `GSM8KMixin` and override `attention_backend`
(required) plus optional `extra_args` to select a backend / pass extra flags.
Example:
from sglang.test.server_fixtures.ngram_fixture import NgramServerBase
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
class TestNgramSpeculativeDecodingTriton(NgramServerBase, GSM8KMixin):
attention_backend = "triton"
The base itself is NOT a runnable test (no `test_*` methods until a subclass
mixes in GSM8KMixin), so unittest discovery picks it up as empty.
"""
from sglang.srt.environ import envs
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TARGET_MODEL_NGRAM,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
DEFAULT_NGRAM_SERVER_ARGS = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"8",
"--speculative-algorithm",
"NGRAM",
"--speculative-num-draft-tokens",
"16",
"--mem-fraction-static",
0.8,
]
class NgramServerBase(CustomTestCase):
model = DEFAULT_TARGET_MODEL_NGRAM
base_url = DEFAULT_URL_FOR_TEST
gsm8k_accuracy_thres = 0.79
gsm8k_accept_length_thres = 1.8
# Subclasses must set `attention_backend`; `extra_args` is optional.
attention_backend: str = ""
extra_args: list = []
@classmethod
def get_server_args(cls):
assert cls.attention_backend, f"{cls.__name__} must set `attention_backend`"
return (
DEFAULT_NGRAM_SERVER_ARGS
+ ["--attention-backend", cls.attention_backend]
+ list(cls.extra_args)
)
@classmethod
def setUpClass(cls):
# disable deep gemm precompile to make launch server faster
# please don't do this if you want to make your inference workload faster
envs.SGLANG_JIT_DEEPGEMM_PRECOMPILE.set(False)
envs.SGLANG_ENABLE_JIT_DEEPGEMM.set(False)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=cls.get_server_args(),
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
@@ -0,0 +1,80 @@
"""Piecewise CUDA Graph + speculative decoding test fixture.
Each variant tests PCG coexisting with one speculative-decoding algorithm
(EAGLE3 / NEXTN / STANDALONE / NGRAM). Variants differ widely on model /
server args / thresholds, so the base only abstracts the common shape:
- launch a server with `server_args` (variant-supplied list)
- run gsm8k, assert `score > accuracy_threshold`
- read `avg_spec_accept_length` from /server_info, assert
`> speedup_threshold`
Pure mixin (does NOT inherit `TestCase`), so unittest does not collect
the base itself.
"""
from types import SimpleNamespace
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
popen_launch_server,
)
class PCGSpecBase:
# Subclasses must set:
model: str = ""
server_args: list = []
# Optional knobs (variant defaults override):
timeout_mult: int = 2
server_env: dict = None # passed to popen_launch_server `env=...`
accuracy_threshold: float = 0.70
speedup_threshold: float = 1.5
max_tokens: int = 512
thinking_mode: str = "" # set to e.g. "qwen3" if needed
@classmethod
def setUpClass(cls):
assert (
cls.model and cls.server_args
), f"{cls.__name__} must set `model` and `server_args`"
cls.base_url = DEFAULT_URL_FOR_TEST
kwargs = dict(
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * cls.timeout_mult,
other_args=cls.server_args,
)
if cls.server_env:
kwargs["env"] = cls.server_env
cls.process = popen_launch_server(cls.model, cls.base_url, **kwargs)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
eval_kwargs = dict(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
max_tokens=self.max_tokens,
num_examples=200,
num_threads=200,
)
if self.thinking_mode:
eval_kwargs["thinking_mode"] = self.thinking_mode
args = SimpleNamespace(**eval_kwargs)
metrics = run_eval(args)
print(metrics)
self.assertGreater(metrics["score"], self.accuracy_threshold)
server_info = requests.get(self.base_url + "/server_info").json()
avg_spec_accept_length = server_info["internal_states"][0][
"avg_spec_accept_length"
]
print(f"{avg_spec_accept_length=}")
self.assertGreater(avg_spec_accept_length, self.speedup_threshold)
@@ -0,0 +1,243 @@
"""Unified EAGLE/EAGLE3 speculative-decoding server fixture.
A single popen-server base whose launch is fully described by class attributes,
so concrete test classes only flip knobs (overlap on/off, model, topk, page size,
backend, env overrides). Pair it with the kits in
``sglang.test.kits.spec_server_kits`` to assemble test classes.
The primary axis is ``disable_overlap``:
- ``False`` -> overlap scheduler
- ``True`` -> synchronous (non-overlap) scheduling
"""
import contextlib
import random
import time
import requests
from sglang.srt.environ import envs
from sglang.srt.utils.common import kill_process_tree
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.test.test_utils import (
DEFAULT_DRAFT_MODEL_EAGLE,
DEFAULT_DRAFT_MODEL_EAGLE3,
DEFAULT_TARGET_MODEL_EAGLE,
DEFAULT_TARGET_MODEL_EAGLE3,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
# Chat-style prompts shared by send_request / send_requests_abort.
PROMPTS = [
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nToday is a sunny day and I like[/INST]"
'[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nWhat are the mental triggers in Jeff Walker\'s Product Launch Formula and "Launch" book?[/INST]',
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nSummarize Russell Brunson's Perfect Webinar Script...[/INST]",
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nwho are you?[/INST]",
"[INST] <<SYS>>\\nYou are a helpful assistant.\\n<</SYS>>\\nwhere are you from?[/INST]",
]
class SpecEagleServerBase(CustomTestCase):
"""Launch a single EAGLE/EAGLE3 spec server from class-attribute knobs."""
# -- model --
model = DEFAULT_TARGET_MODEL_EAGLE3
draft_model = DEFAULT_DRAFT_MODEL_EAGLE3
spec_algo = "EAGLE3"
# -- speculative config --
spec_steps = 5
spec_topk = 1
spec_tokens = 6
# -- runtime config --
page_size = 1
attention_backend = "flashinfer"
# Primary axis: False -> overlap scheduler; True -> synchronous (non-overlap).
disable_overlap = False
mem_fraction_static = 0.85
max_running_requests = 8
chunked_prefill_size = 128
# bf16 rather than fp16: fp16 activations can overflow (-> Inf -> NaN) on
# degenerate draft branches in verify and trip the CI NaN asserts.
dtype = "bfloat16"
cuda_graph_max_bs_decode = None
trust_remote_code = True
# Launch with --enable-return-hidden-states so SpecHiddenStatesKit can probe
# per-request hidden states; per-request gated, so other requests don't pay.
enable_return_hidden_states = False
# -- extras --
# env_overrides: (env_var_obj, value) pairs applied only around launch.
# Declare ONLY this class's own; _merged_env_overrides() unions them down the
# MRO (base first), so never restate a base's. Derived wins on a repeated env.
env_overrides = ()
extra_args = ()
@classmethod
def _launch_args(cls):
args = [
"--speculative-algorithm",
cls.spec_algo,
"--speculative-draft-model-path",
cls.draft_model,
"--speculative-num-steps",
str(cls.spec_steps),
"--speculative-eagle-topk",
str(cls.spec_topk),
"--speculative-num-draft-tokens",
str(cls.spec_tokens),
"--page-size",
str(cls.page_size),
"--attention-backend",
cls.attention_backend,
"--mem-fraction-static",
str(cls.mem_fraction_static),
"--max-running-requests",
str(cls.max_running_requests),
"--chunked-prefill-size",
str(cls.chunked_prefill_size),
"--dtype",
cls.dtype,
]
if cls.disable_overlap:
args.append("--disable-overlap-schedule")
if cls.trust_remote_code:
args.append("--trust-remote-code")
if cls.enable_return_hidden_states:
args.append("--enable-return-hidden-states")
if cls.cuda_graph_max_bs_decode is not None:
args += ["--cuda-graph-max-bs-decode", str(cls.cuda_graph_max_bs_decode)]
args += [str(a) for a in cls.extra_args]
return args
@classmethod
def _merged_env_overrides(cls):
# Base first so a derived class wins for a repeated env var.
merged = []
for klass in reversed(cls.__mro__):
merged.extend(klass.__dict__.get("env_overrides", ()))
return merged
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
# Alias so kit methods can use either name.
cls.target_model = cls.model
cls._tokenizer = None
with contextlib.ExitStack() as stack:
stack.enter_context(
envs.SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN.override(True)
)
for env_var, value in cls._merged_env_overrides():
stack.enter_context(env_var.override(value))
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=cls._launch_args(),
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid, wait_timeout=60)
@property
def tokenizer(self):
if type(self)._tokenizer is None:
type(self)._tokenizer = get_tokenizer(self.model)
return type(self)._tokenizer
# -- helpers used by kits --
def send_request(self):
time.sleep(random.uniform(0, 2))
for prompt in PROMPTS:
url = self.base_url + "/generate"
data = {
"text": prompt,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 1024,
},
}
response = requests.post(url, json=data)
assert response.status_code == 200
def send_requests_abort(self):
for prompt in PROMPTS:
try:
time.sleep(random.uniform(0, 2))
url = self.base_url + "/generate"
data = {
"model": "base",
"text": prompt,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 1024,
},
}
# set timeout = 1s, mock disconnected
requests.post(url, json=data, timeout=1)
except Exception as e:
print(e)
pass
def run_decode(self, sampling_params):
response = requests.post(
self.base_url + "/generate",
json={
"text": "Human: Write a travel blog post to Hawaii.\n\nAssistant:",
"sampling_params": {
"max_new_tokens": 48,
"n": 1,
"temperature": 0.7,
**sampling_params,
},
"return_logprob": True,
"top_logprobs_num": 5,
"return_text_in_logprobs": True,
"logprob_start_len": 0,
},
)
self.assertEqual(response.status_code, 200)
class Eagle3Base(SpecEagleServerBase):
"""EAGLE3 (Llama-3.1) config preset, topk=1 / page_size=1 by default."""
model = DEFAULT_TARGET_MODEL_EAGLE3
draft_model = DEFAULT_DRAFT_MODEL_EAGLE3
spec_algo = "EAGLE3"
spec_steps = 5
spec_topk = 1
spec_tokens = 6
attention_backend = "flashinfer"
chunked_prefill_size = 1024
# EAGLE3 topk=1 accepts modestly; tune against CI if needed.
acc_length_thres = 1.6
batch_accept_len_thres = 1.3
gsm8k_score_thres = 0.7
gsm8k_accept_len_thres = 1.3
class EagleLlama2Base(SpecEagleServerBase):
"""EAGLE (Llama-2) config preset. topk=8 tree -> synchronous path; gsm8k is low."""
model = DEFAULT_TARGET_MODEL_EAGLE
draft_model = DEFAULT_DRAFT_MODEL_EAGLE
spec_algo = "EAGLE"
spec_steps = 5
spec_topk = 8
spec_tokens = 64
attention_backend = "flashinfer"
chunked_prefill_size = 128
mem_fraction_static = 0.7
gsm8k_score_thres = 0.20
acc_length_thres = 3.0
batch_accept_len_thres = 1.8
# topk>1 tree verify runs on the synchronous (non-overlap) path.
disable_overlap = True
@@ -0,0 +1,118 @@
"""STANDALONE speculative-decoding server fixture.
Variants combine this base with `CustomTestCase` and override class
attributes (`attention_backend`, plus optional `speculative_eagle_topk` /
`speculative_num_draft_tokens` / `disable_overlap` /
`enable_deterministic_inference`) to select a backend, deterministic mode,
and the V1 / V2 spec engine.
Pure mixin (does NOT inherit `TestCase`), so unittest does not collect
the base itself.
"""
from types import SimpleNamespace
import requests
from sglang.srt.environ import envs
from sglang.srt.utils import kill_process_tree
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_DRAFT_MODEL_STANDALONE,
DEFAULT_TARGET_MODEL_STANDALONE,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
popen_launch_server,
)
GSM_DATASET_PATH = None
class StandaloneServerBase:
model = DEFAULT_TARGET_MODEL_STANDALONE
draft_model = DEFAULT_DRAFT_MODEL_STANDALONE
base_url = DEFAULT_URL_FOR_TEST
accuracy_threshold = 0.69
spec_decode_threshold = 3.6
# Subclasses set these:
attention_backend: str = ""
# Overlap defaults; synchronous subclasses override to (2, 7, True).
speculative_num_steps: int = 4
speculative_eagle_topk: int = 1
speculative_num_draft_tokens: int = 5
disable_overlap: bool = False
enable_deterministic_inference: bool = False
@classmethod
def get_server_args(cls):
assert cls.attention_backend, f"{cls.__name__} must set `attention_backend`"
args = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"8",
"--speculative-algorithm",
"STANDALONE",
"--speculative-draft-model-path",
DEFAULT_DRAFT_MODEL_STANDALONE,
"--speculative-num-steps",
str(cls.speculative_num_steps),
"--speculative-eagle-topk",
str(cls.speculative_eagle_topk),
"--speculative-num-draft-tokens",
str(cls.speculative_num_draft_tokens),
"--mem-fraction-static",
0.7,
"--attention-backend",
cls.attention_backend,
]
if cls.enable_deterministic_inference:
args.append("--enable-deterministic-inference")
return args
@classmethod
def setUpClass(cls):
# disable deep gemm precompile to make launch server faster
# please don't do this if you want to make your inference workload faster
envs.SGLANG_JIT_DEEPGEMM_PRECOMPILE.set(False)
envs.SGLANG_ENABLE_JIT_DEEPGEMM.set(False)
other_args = cls.get_server_args()
if cls.disable_overlap:
other_args = other_args + ["--disable-overlap-schedule"]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=other_args,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
requests.get(self.base_url + "/flush_cache")
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=100,
num_threads=128,
num_shots=4,
gsm8k_data_path=GSM_DATASET_PATH,
)
metrics = run_eval(args)
print(f"{metrics=}")
metric_key = "score"
self.assertGreaterEqual(metrics[metric_key], self.accuracy_threshold)
server_info = requests.get(self.base_url + "/server_info")
avg_spec_accept_length = server_info.json()["internal_states"][0][
"avg_spec_accept_length"
]
print(f"{avg_spec_accept_length=}")
self.assertGreater(avg_spec_accept_length, self.spec_decode_threshold)
@@ -0,0 +1,435 @@
"""Streaming-session test fixture.
`TestStreamingSession` is the base class for all streaming-session tests
(default config — Llama-3.1-8B, no spec). Variants in
test_streaming_session.py and test_streaming_session_extra.py inherit
it and only override `setUpClass`.
Also exports:
- ABORT_REPRO_* constants used by the basic file's abort-leak repro.
- _abort_repro_run_all coroutine reused by the basic file.
Lives under sglang.test.server_fixtures so siblings under test/registered
can `import` it without sys.path hacks.
"""
import asyncio
import json
from typing import Any, Optional
import aiohttp
import requests
from sglang.srt.environ import envs
from sglang.srt.utils import kill_process_tree
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.test.test_utils import (
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
LOGPROB_PROMPTS = [
"The quick brown fox jumps over the lazy dog.",
"Pack my box with five dozen liquor jugs.",
"How vexingly quick daft zebras jump.",
"Sphinx of black quartz judge my vow.",
"The five boxing wizards jump quickly.",
]
# Long enough to trigger chunked prefill at 200+ tokens per slice.
LEAK_FILLER = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"How vexingly quick daft zebras jump. "
"Sphinx of black quartz, judge my vow. "
"The five boxing wizards jump quickly. "
"Jackdaws love my big sphinx of quartz. "
"A wizard's job is to vex chumps quickly in fog. "
"We promptly judged antique ivory buckles for the next prize. "
) * 20
ABORT_REPRO_CONTEXT_LEN = 512
ABORT_REPRO_PAGE_SIZE = 256
ABORT_REPRO_GEN_LEN = 4
ABORT_REPRO_SESSIONS = 4
ABORT_REPRO_WARMUP_TURNS = 1
ABORT_REPRO_ROUNDS = 8
ABORT_REPRO_STREAM_TOKENS = 16
ABORT_REPRO_ABORT_TOKENS = 600
ABORT_REPRO_NON_STREAMING_TOKENS = 16
ABORT_REPRO_CHUNKED_PREFILL_SIZE = 4096
CONCURRENT_LOGPROB_SESSIONS = 6
CONCURRENT_LOGPROB_TURNS = 5
CONCURRENT_LOGPROB_ROUNDS = 10
STRESS_NUM_SESSIONS = 8
STRESS_NUM_NON_STREAMING = 4
STRESS_NUM_TURNS = 6
STRESS_GEN_LEN = 16
def _make_token_sized_ids(
tokenizer: Any, prefix: str, min_tokens: int, max_tokens: Optional[int] = None
) -> list[int]:
text = prefix
chunk = " pack quartz wizard sphinx zebra fox " * 16
token_ids = tokenizer.encode(text)
while len(token_ids) < min_tokens:
text += chunk
token_ids = tokenizer.encode(text)
if max_tokens is not None:
token_ids = token_ids[:max_tokens]
return token_ids
async def _abort_repro_generate(
base_url: str,
session: aiohttp.ClientSession,
input_ids: list[int],
max_new_tokens: int,
session_params: Optional[dict[str, Any]] = None,
expect_abort: bool = False,
) -> Optional[dict[str, Any]]:
payload: dict[str, Any] = {
"input_ids": input_ids,
"sampling_params": {
"temperature": 0,
"max_new_tokens": max_new_tokens,
"no_stop_trim": True,
"skip_special_tokens": False,
},
}
if session_params:
payload["session_params"] = session_params
async with session.post(base_url + "/generate", json=payload) as resp:
text = await resp.text()
if expect_abort:
if resp.status == 200:
data = json.loads(text)
finish_reason = data.get("meta_info", {}).get("finish_reason", {})
assert finish_reason.get("type") == "abort", text
assert "maximum allowed length" in finish_reason.get(
"message", ""
) or "context length" in finish_reason.get("message", ""), text
return data
assert resp.status == 400, text
assert "maximum allowed length" in text or "context length" in text, text
return None
assert resp.status == 200, text
data = json.loads(text)
finish_reason = data.get("meta_info", {}).get("finish_reason", {})
assert finish_reason.get("type") != "abort", text
return data
async def _abort_repro_run_all(base_url: str, tokenizer: Any) -> None:
timeout = aiohttp.ClientTimeout(total=300)
async with aiohttp.ClientSession(timeout=timeout) as http:
session_ids = []
for _ in range(ABORT_REPRO_SESSIONS):
async with http.post(
base_url + "/open_session",
json={"capacity_of_str_len": 50000, "streaming": True},
) as resp:
assert resp.status == 200, await resp.text()
session_ids.append(await resp.json())
try:
for warmup_turn in range(ABORT_REPRO_WARMUP_TURNS):
warmup_tasks = []
for session_idx, session_id in enumerate(session_ids):
input_ids = _make_token_sized_ids(
tokenizer,
prefix=f"[warmup={warmup_turn} session={session_idx}]",
min_tokens=ABORT_REPRO_STREAM_TOKENS,
max_tokens=ABORT_REPRO_STREAM_TOKENS + 8,
)
warmup_tasks.append(
_abort_repro_generate(
base_url,
http,
input_ids,
ABORT_REPRO_GEN_LEN,
session_params={"id": session_id, "rid": None},
)
)
await asyncio.gather(*warmup_tasks)
for round_idx in range(ABORT_REPRO_ROUNDS):
mixed_tasks = []
for session_idx, session_id in enumerate(session_ids):
input_ids = _make_token_sized_ids(
tokenizer,
prefix=f"[round={round_idx} ok session={session_idx}]",
min_tokens=ABORT_REPRO_STREAM_TOKENS,
max_tokens=ABORT_REPRO_STREAM_TOKENS + 8,
)
mixed_tasks.append(
_abort_repro_generate(
base_url,
http,
input_ids,
ABORT_REPRO_GEN_LEN,
session_params={"id": session_id, "rid": None},
)
)
for ns_idx in range(2):
input_ids = _make_token_sized_ids(
tokenizer,
prefix=f"[round={round_idx} ns={ns_idx}]",
min_tokens=ABORT_REPRO_NON_STREAMING_TOKENS,
max_tokens=ABORT_REPRO_NON_STREAMING_TOKENS + 8,
)
mixed_tasks.append(
_abort_repro_generate(
base_url,
http,
input_ids,
ABORT_REPRO_GEN_LEN,
)
)
await asyncio.gather(*mixed_tasks)
abort_tasks = []
for session_idx, session_id in enumerate(session_ids):
input_ids = _make_token_sized_ids(
tokenizer,
prefix=f"[round={round_idx} abort session={session_idx}]",
min_tokens=ABORT_REPRO_ABORT_TOKENS,
)
abort_tasks.append(
_abort_repro_generate(
base_url,
http,
input_ids,
ABORT_REPRO_GEN_LEN,
session_params={"id": session_id, "rid": None},
expect_abort=True,
)
)
await asyncio.gather(*abort_tasks)
recovery_tasks = []
for session_idx, session_id in enumerate(session_ids):
input_ids = _make_token_sized_ids(
tokenizer,
prefix=f"[round={round_idx} recover session={session_idx}]",
min_tokens=ABORT_REPRO_NON_STREAMING_TOKENS,
max_tokens=ABORT_REPRO_NON_STREAMING_TOKENS + 8,
)
recovery_tasks.append(
_abort_repro_generate(
base_url,
http,
input_ids,
ABORT_REPRO_GEN_LEN,
session_params={"id": session_id, "rid": None},
)
)
recovery_results = await asyncio.gather(*recovery_tasks)
for result in recovery_results:
assert result is not None
assert result["meta_info"]["cached_tokens"] > 0, result
health = requests.get(base_url + "/health", timeout=10)
if health.status_code != 200:
raise RuntimeError(
f"server unhealthy after round={round_idx}: "
f"{health.status_code} {health.text}"
)
finally:
for session_id in session_ids:
async with http.post(
base_url + "/close_session", json={"session_id": session_id}
) as resp:
assert resp.status == 200, await resp.text()
async def _async_generate(
base_url: str,
session: aiohttp.ClientSession,
input_ids: list[int],
max_new_tokens: int = 8,
session_params: Optional[dict[str, Any]] = None,
return_logprob: bool = False,
logprob_start_len: Optional[int] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"input_ids": input_ids,
"sampling_params": {
"temperature": 0,
"max_new_tokens": max_new_tokens,
"no_stop_trim": True,
"skip_special_tokens": False,
},
}
if session_params:
payload["session_params"] = session_params
if return_logprob:
payload["return_logprob"] = True
if logprob_start_len is not None:
payload["logprob_start_len"] = logprob_start_len
timeout = aiohttp.ClientTimeout(total=300)
async with session.post(
base_url + "/generate", json=payload, timeout=timeout
) as resp:
assert resp.status == 200, f"Generate failed: {await resp.text()}"
return await resp.json()
async def _concurrent_logprob_run(base_url: str, tokenizer: Any, **gen_kwargs) -> None:
"""N sessions per round, all requests fired simultaneously per turn so
the running batch has real concurrency (retract can actually kick one).
"""
timeout = aiohttp.ClientTimeout(total=300)
async with aiohttp.ClientSession(timeout=timeout) as http:
for _ in range(CONCURRENT_LOGPROB_ROUNDS):
sids: list[str] = []
for _ in range(CONCURRENT_LOGPROB_SESSIONS):
async with http.post(
base_url + "/open_session",
json={"capacity_of_str_len": 50000, "streaming": True},
) as resp:
assert resp.status == 200
sids.append(await resp.json())
rids: list[Optional[str]] = [None] * CONCURRENT_LOGPROB_SESSIONS
for turn in range(CONCURRENT_LOGPROB_TURNS):
tasks = []
for s in range(CONCURRENT_LOGPROB_SESSIONS):
text = (
f"S{s} T{turn}: "
f"{LOGPROB_PROMPTS[turn % len(LOGPROB_PROMPTS)]}"
)
ids = tokenizer.encode(text)
tasks.append(
_async_generate(
base_url,
http,
ids,
session_params={"id": sids[s], "rid": rids[s]},
**gen_kwargs,
)
)
results = await asyncio.gather(*tasks)
for s in range(CONCURRENT_LOGPROB_SESSIONS):
rids[s] = results[s]["meta_info"]["id"]
for sid in sids:
async with http.post(
base_url + "/close_session", json={"session_id": sid}
) as resp:
assert resp.status == 200
async def _stress_run_all(base_url: str, tokenizer: Any) -> None:
"""Streaming + non-streaming mixed batches under retract pressure.
Long prompts (~200+ tokens) trigger chunked prefill so retract can
interrupt mid-extend.
"""
timeout = aiohttp.ClientTimeout(total=300)
async with aiohttp.ClientSession(timeout=timeout) as http:
sids: list[str] = []
for _ in range(STRESS_NUM_SESSIONS):
async with http.post(
base_url + "/open_session",
json={"capacity_of_str_len": 50000, "streaming": True},
) as resp:
assert resp.status == 200
sids.append(await resp.json())
rids: list[Optional[str]] = [None] * STRESS_NUM_SESSIONS
for turn in range(STRESS_NUM_TURNS):
tasks = []
# Streaming requests — long prompts to trigger chunked prefill.
for s in range(STRESS_NUM_SESSIONS):
offset = (s * STRESS_NUM_TURNS + turn) * 200
text = (
f"Session {s} turn {turn}: " f"{LEAK_FILLER[offset : offset + 800]}"
)
ids = tokenizer.encode(text)
tasks.append(
_async_generate(
base_url,
http,
ids,
max_new_tokens=STRESS_GEN_LEN,
session_params={"id": sids[s], "rid": rids[s]},
)
)
# Non-streaming requests interleaved.
for ns in range(STRESS_NUM_NON_STREAMING):
text = (
f"Non-streaming {ns} turn {turn}: "
f"{LEAK_FILLER[ns * 100 : ns * 100 + 400]}"
)
ids = tokenizer.encode(text)
tasks.append(
_async_generate(
base_url,
http,
ids,
max_new_tokens=STRESS_GEN_LEN,
)
)
results = await asyncio.gather(*tasks)
for s in range(STRESS_NUM_SESSIONS):
rids[s] = results[s]["meta_info"]["id"]
for sid in sids:
async with http.post(
base_url + "/close_session", json={"session_id": sid}
) as resp:
assert resp.status == 200
class StreamingSessionServerBase(CustomTestCase):
"""Minimal streaming-session server fixture.
Subclasses override class attrs to customize launch:
- `model`: defaults to the small model.
- `extra_args`: appended after `--enable-streaming-session` (set
`--chunked-prefill-size`, `--page-size`, spec args, etc. here).
- `env_overrides`: list of `(env_attr_name, value)` tuples; each is
pushed onto the `setUpClass` context stack so the env override is
live during `popen_launch_server` and torn down on
`tearDownClass`-time. `SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY=1`
is always applied on top of these.
"""
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
base_url = DEFAULT_URL_FOR_TEST
extra_args: list = []
env_overrides: list = []
@classmethod
def setUpClass(cls):
import contextlib
with contextlib.ExitStack() as stack:
stack.enter_context(
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.override(1)
)
stack.enter_context(envs.SGLANG_CHECK_KV_PAGE_INVARIANTS.override(True))
for name, val in cls.env_overrides:
stack.enter_context(getattr(envs, name).override(val))
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--enable-streaming-session"] + list(cls.extra_args),
)
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)