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
+114
View File
@@ -0,0 +1,114 @@
# Test and Continuous Integration (CI) System in SGLang
This page covers principles and essentials: folder layout, how to run tests, registration, and suite selection. For complete references, see the skill guides:
- **Writing tests** — templates, fixtures, model selection, complete suite tables, checklist: [`.claude/skills/write-sglang-test/SKILL.md`](../.claude/skills/write-sglang-test/SKILL.md)
- **CI pipeline internals** — stage flow diagrams, fast-fail layers, gating, partitioning, execution modes, debugging failures: [`.claude/skills/ci-workflow-guide/SKILL.md`](../.claude/skills/ci-workflow-guide/SKILL.md)
## CI Pipeline Overview
The CI pipeline runs in three sequential stages: **A** (pre-flight, ~3 min) → **B** (basic, ~30 min) → **C** (advanced, ~30 min). Kernel and multimodal-gen tests run in parallel with stage B. For details on stage gating, fast-fail mechanisms, execution modes (PR vs scheduled vs `/rerun-stage`), and debugging CI failures, see the [CI workflow guide](../.claude/skills/ci-workflow-guide/SKILL.md).
## Folder Organization
- `registered/`: CI test files, auto-discovered by `run_suite.py`. Most tests live here. JIT kernel tests are an exception (see below).
- `manual/`: Non-CI tests for local debugging or special setups.
- `run_suite.py`: CI runner — scans `registered/` and JIT kernel directories.
The system supports both [unittest](https://docs.python.org/3/library/unittest.html) and [pytest](https://docs.pytest.org/en/stable/). The launcher runs `python filename.py -f` with **failfast enabled by default**.
Make sure your file ends with **exactly** one of:
```python
# for unittest
if __name__ == "__main__":
unittest.main()
```
```python
# for pytest
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__]))
```
Do not add custom `argparse` or modify `sys.argv` before these calls — the CI runner appends `-f` for failfast.
## Run Tests Locally
```bash
# Single file
python3 test/registered/core/test_srt_endpoint.py
# Single test method
python3 test/registered/core/test_srt_endpoint.py TestSRTEndpoint.test_simple_decode
# Single JIT kernel test
python3 test/registered/jit/test_add_constant.py
# Run a suite
python3 test/run_suite.py --hw cpu --suite base-a-test-cpu
python3 test/run_suite.py --hw cuda --suite base-a-test-1-gpu-small
# Nightly tests
python3 test/run_suite.py --hw cuda --suite nightly-1-gpu --nightly
# With auto-partitioning (for parallel CI jobs)
python3 test/run_suite.py --hw cuda --suite base-b-test-1-gpu-small \
--auto-partition-id 0 --auto-partition-size 4
```
## CI Registration
Every CI-discovered test file must call a registration function at module level:
```python
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=80, stage="base-b", runner_config="1-gpu-small")
```
Parameters: `est_time` (seconds), `stage` + `runner_config` (target stage and runner pool from `scripts/ci/runner_configs.yml`), `nightly=True` (nightly-only), `disabled="reason"` (temporarily disable).
Keep `est_time`, `stage`, `runner_config` as **literal values**`run_suite.py` collects them by AST parsing.
JIT kernel correctness tests and benchmarks live under `test/registered/jit/`, same as other registered tests (their helpers stay alongside the kernel source under `python/sglang/jit_kernel/` and are imported by absolute path):
- Correctness tests: `test/registered/jit/test_*.py``base-b-kernel-unit-test-1-gpu-large`
- Benchmarks: `test/registered/jit/benchmark/bench_*.py``base-b-kernel-benchmark-test-1-gpu-large`
## Choosing a Suite
Use the lightest suite that meets your test's needs. Full suite tables are in the [write-sglang-test skill](../.claude/skills/write-sglang-test/SKILL.md#all-ci-suites).
| Need | Suite |
|------|-------|
| No GPU required | `base-a-test-cpu` |
| Small GPU (fits 5090, 32GB) | `base-b-test-1-gpu-small` (most tests go here) |
| Large GPU memory or Hopper features | `base-b-test-1-gpu-large` |
| JIT kernel correctness | `base-b-kernel-unit-test-1-gpu-large` |
| JIT kernel benchmarks | `base-b-kernel-benchmark-test-1-gpu-large` |
| Multi-GPU (2/4/8) | `base-b-test-2-gpu-large`, `base-c-test-*` |
| Long-running or experimental | `nightly-*` suites |
## Steps for Adding a Test
See the [write-sglang-test skill](../.claude/skills/write-sglang-test/SKILL.md) for templates, fixtures, model selection, and a complete checklist.
## Multi-Hardware Backends
This README mostly describes the NVIDIA GPU CI pipeline. Other hardware backends (AMD, NPU) follow the same practices and use the multi-backend registry system. A scheduled job summarizes test coverage across all backends; [here is an example run](https://github.com/sgl-project/sglang/actions/runs/23424304300).
## Tips
- Learn from existing examples in [test/registered](https://github.com/sgl-project/sglang/tree/main/test/registered).
- Reuse servers — launching is expensive. Share one server across many test methods via `setUpClass`.
- Use as few GPUs as possible. Prefer 1-GPU runners.
- Each test file should take < 500 seconds; split if longer.
- Each GitHub Actions job should take < 30 minutes; split if longer.
- If tests are too slow for per-commit, consider nightly suites.
## Other Notes
### Adding New Models to Nightly CI
- **Text models**: Extend the [global model list variables](https://github.com/sgl-project/sglang/blob/85c1f7937781199203b38bb46325a2840f353a04/python/sglang/test/test_utils.py#L104) in `test_utils.py`.
- **VLMs**: Extend the `MODEL_THRESHOLDS` dictionary in `test/registered/eval/test_vlms_mmmu_eval.py`.
@@ -0,0 +1,13 @@
model_name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
tasks:
- name: "gsm8k"
metrics:
- name: "exact_match,strict-match"
value: 0.847
- name: "exact_match,flexible-extract"
value: 0.556
limit: 1319
num_concurrent: 128
num_fewshot: 5
apply_chat_template: false
fewshot_as_multiturn: true
@@ -0,0 +1,13 @@
model_name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8"
tasks:
- name: "gsm8k"
metrics:
- name: "exact_match,strict-match"
value: 0.847
- name: "exact_match,flexible-extract"
value: 0.556
limit: 1319
num_concurrent: 128
num_fewshot: 5
apply_chat_template: false
fewshot_as_multiturn: true
@@ -0,0 +1,13 @@
model_name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4"
tasks:
- name: "gsm8k"
metrics:
- name: "exact_match,strict-match"
value: 0.847
- name: "exact_match,flexible-extract"
value: 0.444
limit: 1319
num_concurrent: 128
num_fewshot: 5
apply_chat_template: false
fewshot_as_multiturn: true
@@ -0,0 +1,13 @@
model_name: "Qwen/Qwen3.5-397B-A17B"
tasks:
- name: "gsm8k"
metrics:
- name: "exact_match,strict-match"
value: 0.9704
- name: "exact_match,flexible-extract"
value: 0.9697
limit: 1319
num_concurrent: 256
num_fewshot: 5
gen_kwargs: "max_gen_toks=2048"
rtol: 0.05
@@ -0,0 +1,68 @@
import unittest
from sglang.test.accuracy_test_runner import AccuracyTestParams
# This eval harness applies the chat_template, which is critical for qwen3.5
# to get good accuracy on gsm8k
from sglang.test.run_combined_tests import run_combined_tests
from sglang.test.test_utils import (
CustomTestCase,
ModelLaunchSettings,
)
QWEN35_FP4_MODEL = "nvidia/Qwen3.5-397B-A17B-NVFP4"
ACC_THRESHOLDS = {QWEN35_FP4_MODEL: {"gsm8k": 0.95}}
class TestQwen35FP4(CustomTestCase):
def test_gsm8k(self):
base_args = [
"--tp-size",
"4",
"--chunked-prefill-size",
"2048",
"--mamba-scheduler-strategy",
"extra_buffer",
"--mamba-track-interval",
"128",
"--mamba-ssm-dtype",
"bfloat16",
"--max-running-requests",
"128",
"--reasoning-parser",
"qwen3",
"--attention-backend",
"trtllm_mha",
"--quantization",
"modelopt_fp4",
"--model-loader-extra-config",
'{"enable_multithread_load": true,"num_threads": 64}',
]
variants = [
ModelLaunchSettings(
QWEN35_FP4_MODEL,
extra_args=base_args,
variant="Triton",
),
]
run_combined_tests(
models=variants,
test_name="Qwen3.5-397B-A17B-NVFP4",
accuracy_params=AccuracyTestParams(
dataset="gsm8k",
baseline_accuracy=ACC_THRESHOLDS[QWEN35_FP4_MODEL]["gsm8k"],
num_examples=200,
num_threads=128,
max_tokens=16000,
thinking_mode="qwen3",
temperature=0.6,
top_p=0.95,
top_k=20,
),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,168 @@
"""Archived test classes split out of test/registered/4-gpu-models/test_qwen35_models.py.
Originally registered with `register_cuda_ci(...)`. Moved here as part of
the per-commit pruning effort to keep the code reachable manually.
Run with `python3 test/manual/4-gpu-models/test_qwen35_models_archived.py`.
"""
import unittest
from types import SimpleNamespace
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.accuracy_test_runner import AccuracyTestParams
from sglang.test.kits.reasoning_kit import ReasoningTokenUsageMixin
# This eval harness applies the chat_template, which is critical for qwen3.5
# to get good accuracy on gsm8k
from sglang.test.run_combined_tests import run_combined_tests
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
ModelLaunchSettings,
popen_launch_server,
)
QWEN35_FP4_MODEL = "nvidia/Qwen3.5-397B-A17B-NVFP4"
ACC_THRESHOLDS = {QWEN35_FP4_MODEL: {"gsm8k": 0.95}}
class TestQwen35FP4(CustomTestCase):
def test_gsm8k(self):
base_args = [
"--tp-size",
"4",
"--chunked-prefill-size",
"2048",
"--mamba-scheduler-strategy",
"extra_buffer",
"--mamba-track-interval",
"128",
"--mamba-ssm-dtype",
"bfloat16",
"--max-running-requests",
"128",
"--reasoning-parser",
"qwen3",
"--attention-backend",
"trtllm_mha",
"--quantization",
"modelopt_fp4",
"--model-loader-extra-config",
'{"enable_multithread_load": true,"num_threads": 64}',
]
variants = [
ModelLaunchSettings(
QWEN35_FP4_MODEL,
extra_args=base_args,
variant="Triton",
),
# TODO: Fix this and re-enable it
# ModelLaunchSettings(
# QWEN35_FP4_MODEL,
# extra_args=base_args + ["--linear-attn-decode-backend", "flashinfer"],
# variant="FlashInfer",
# ),
]
run_combined_tests(
models=variants,
test_name="Qwen3.5-397B-A17B-NVFP4",
accuracy_params=AccuracyTestParams(
dataset="gsm8k",
baseline_accuracy=ACC_THRESHOLDS[QWEN35_FP4_MODEL]["gsm8k"],
num_examples=200,
num_threads=128,
max_tokens=16000,
thinking_mode="qwen3",
temperature=0.6,
top_p=0.95,
top_k=20,
),
)
class TestQwen35FP4MTP(ReasoningTokenUsageMixin, CustomTestCase):
reasoning_parser_name = "qwen3"
@classmethod
def setUpClass(cls):
cls.model = QWEN35_FP4_MODEL
cls.base_url = DEFAULT_URL_FOR_TEST
cls.init_reasoning_token_verifier()
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--tp-size",
"4",
"--chunked-prefill-size",
"2048",
"--mamba-scheduler-strategy",
"extra_buffer",
"--mamba-track-interval",
"128",
"--mamba-ssm-dtype",
"bfloat16",
"--max-running-requests",
"128",
"--reasoning-parser",
"qwen3",
"--attention-backend",
"trtllm_mha",
"--quantization",
"modelopt_fp4",
"--speculative-algorithm",
"NEXTN",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--mem-fraction-static",
"0.8",
"--model-loader-extra-config",
'{"enable_multithread_load": true,"num_threads": 64}',
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
args = SimpleNamespace(
model=self.model,
eval_name="gsm8k",
num_shots=5,
num_examples=200,
max_tokens=16000,
num_threads=128,
repeat=1,
temperature=0.6,
top_p=0.95,
top_k=20,
base_url=self.base_url,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval(args)
print(f"{metrics=}")
self.assertGreaterEqual(metrics["score"], ACC_THRESHOLDS[self.model]["gsm8k"])
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, 3.3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
import unittest
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
from sglang.test.kits.kl_divergence_kit import KLDivergenceMixin
from sglang.test.kits.prefix_cache_branching_kit import PrefixCacheBranchingMixin
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
QWEN3_NEXT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct"
class TestQwen3Next(
GSM8KMixin, KLDivergenceMixin, PrefixCacheBranchingMixin, DefaultServerBase
):
model = QWEN3_NEXT_MODEL
cache_chunk_size = 64
gsm8k_accuracy_thres = 0.93
kl_div_thres = 0.0025
other_args = [
"--tp-size",
"4",
"--chunked-prefill-size",
"1024",
"--mamba-scheduler-strategy",
"extra_buffer",
"--mamba-track-interval",
"2",
"--page-size",
"1",
"--attention-backend",
"triton",
"--moe-runner-backend",
"triton",
]
class TestQwen3NextLazyExtraBuffer(
GSM8KMixin, KLDivergenceMixin, PrefixCacheBranchingMixin, DefaultServerBase
):
model = QWEN3_NEXT_MODEL
cache_chunk_size = 64
gsm8k_accuracy_thres = 0.93
kl_div_thres = 0.0025
other_args = [
"--tp-size",
"4",
"--chunked-prefill-size",
"1024",
"--mamba-scheduler-strategy",
"extra_buffer_lazy",
"--mamba-track-interval",
"2",
"--page-size",
"1",
"--attention-backend",
"triton",
"--moe-runner-backend",
"triton",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,44 @@
"""Archived test classes split out of test/registered/4-gpu-models/test_qwen3_next_models_mtp.py.
Originally registered with `register_cuda_ci(...)`. Moved here as part of
the per-commit pruning effort to keep the code reachable manually.
Run with `python3 test/manual/4-gpu-models/test_qwen3_next_models_mtp_archived.py`.
"""
import unittest
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
from sglang.test.kits.kl_divergence_kit import KLDivergenceMixin
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
QWEN3_NEXT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct"
class TestQwen3NextMTP(GSM8KMixin, KLDivergenceMixin, DefaultServerBase):
model = QWEN3_NEXT_MODEL
gsm8k_accuracy_thres = 0.93
kl_div_thres = 0.0025
other_args = [
"--trust-remote-code",
"--speculative-algorithm",
"NEXTN",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--mem-fraction-static",
"0.8",
"--tp",
"4",
"--chunked-prefill-size",
"2048",
"--mamba-scheduler-strategy",
"no_buffer",
"--disable-radix-cache",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
import unittest
from types import SimpleNamespace
from sglang.srt.utils import kill_process_tree
from sglang.test.run_eval import run_eval
from sglang.test.send_one import BenchArgs, send_one_prompt
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_amd_ci,
is_in_ci,
popen_launch_server,
write_github_step_summary,
)
FULL_DEEPSEEK_V3_MODEL_PATH = "deepseek-ai/DeepSeek-V3-0324"
class TestDeepseekV3Basic(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = FULL_DEEPSEEK_V3_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"8",
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 5,
other_args=other_args,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_a_gsm8k(
self,
): # Append an "a" to make this test run first (alphabetically) to warm up the server
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=1400,
num_threads=1400,
num_shots=8,
)
metrics = run_eval(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k (deepseek-v3)\n" f'{metrics["score"]=:.3f}\n'
)
self.assertGreater(metrics["score"], 0.935)
def test_bs_1_speed(self):
args = BenchArgs(port=int(self.base_url.split(":")[-1]), max_new_tokens=2048)
acc_length, speed = send_one_prompt(args)
print(f"{speed=:.2f}")
if is_in_ci():
write_github_step_summary(
f"### test_bs_1_speed (deepseek-v3)\n" f"{speed=:.2f} token/s\n"
)
if is_in_amd_ci():
self.assertGreater(speed, 12)
else:
self.assertGreater(speed, 75)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,259 @@
import unittest
from types import SimpleNamespace
from sglang.srt.utils import kill_process_tree
from sglang.test.run_eval import run_eval
from sglang.test.send_one import BenchArgs, send_one_prompt
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_ci,
popen_launch_server,
write_github_step_summary,
)
DEEPSEEK_V32_MODEL_PATH = "deepseek-ai/DeepSeek-V3.2"
GLM5_MODEL_PATH = "zai-org/GLM-5-FP8"
class TestDeepseekV32DP(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEEPSEEK_V32_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
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_a_gsm8k(
self,
): # Append an "a" to make this test run first (alphabetically) to warm up the server
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=1400,
num_threads=1400,
num_shots=20,
)
metrics = run_eval(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k (deepseek-v32)\n" f'{metrics["score"]=:.3f}\n'
)
self.assertGreater(metrics["score"], 0.935)
def test_bs_1_speed(self):
args = BenchArgs(port=int(self.base_url.split(":")[-1]), max_new_tokens=2048)
acc_length, speed = send_one_prompt(args)
print(f"{speed=:.2f}")
if is_in_ci():
write_github_step_summary(
f"### test_bs_1_speed (deepseek-v32)\n" f"{speed=:.2f} token/s\n"
)
self.assertGreater(speed, 50)
class TestDeepseekV32TP(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEEPSEEK_V32_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"8",
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
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_a_gsm8k(
self,
): # Append an "a" to make this test run first (alphabetically) to warm up the server
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=1400,
num_threads=1400,
num_shots=20,
)
metrics = run_eval(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k (deepseek-v32)\n" f'{metrics["score"]=:.3f}\n'
)
self.assertGreater(metrics["score"], 0.935)
def test_bs_1_speed(self):
args = BenchArgs(port=int(self.base_url.split(":")[-1]), max_new_tokens=2048)
acc_length, speed = send_one_prompt(args)
print(f"{speed=:.2f}")
if is_in_ci():
write_github_step_summary(
f"### test_bs_1_speed (deepseek-v32)\n" f"{speed=:.2f} token/s\n"
)
self.assertGreater(speed, 80)
class TestGLM5DP(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = GLM5_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
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_a_gsm8k(
self,
): # Append an "a" to make this test run first (alphabetically) to warm up the server
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=1400,
num_threads=1400,
num_shots=20,
)
metrics = run_eval(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k (glm-5)\n" f'{metrics["score"]=:.3f}\n'
)
self.assertGreater(metrics["score"], 0.935)
def test_bs_1_speed(self):
args = BenchArgs(port=int(self.base_url.split(":")[-1]), max_new_tokens=2048)
acc_length, speed = send_one_prompt(args)
print(f"{speed=:.2f}")
if is_in_ci():
write_github_step_summary(
f"### test_bs_1_speed (glm-5)\n" f"{speed=:.2f} token/s\n"
)
self.assertGreater(speed, 40)
class TestGLM5TP(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = GLM5_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"8",
"--model-loader-extra-config",
'{"enable_multithread_load": true, "num_threads": 64}',
]
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_a_gsm8k(
self,
): # Append an "a" to make this test run first (alphabetically) to warm up the server
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=1400,
num_threads=1400,
num_shots=20,
)
metrics = run_eval(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k (glm-5)\n" f'{metrics["score"]=:.3f}\n'
)
self.assertGreater(metrics["score"], 0.935)
def test_bs_1_speed(self):
args = BenchArgs(port=int(self.base_url.split(":")[-1]), max_new_tokens=2048)
acc_length, speed = send_one_prompt(args)
print(f"{speed=:.2f}")
if is_in_ci():
write_github_step_summary(
f"### test_bs_1_speed (glm-5)\n" f"{speed=:.2f} token/s\n"
)
self.assertGreater(speed, 60)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
import logging
import os
import time
import warnings
from urllib.parse import urlparse
import requests
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,
popen_with_error_check,
)
logger = logging.getLogger(__name__)
class TestDisaggregationBase(CustomTestCase):
@classmethod
def setUpClass(cls):
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.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}"
print(f"{cls.base_host=} {cls.lb_port=} {cls.prefill_port=} {cls.decode_port=}")
cls.process_lb, cls.process_decode, cls.process_prefill = None, None, None
# config transfer backend and rdma devices
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)
@classmethod
def launch_lb(cls):
lb_command = [
"python3",
"-m",
"sglang_router.launch_router",
"--pd-disaggregation",
"--mini-lb",
"--prefill",
cls.prefill_url,
"--decode",
cls.decode_url,
"--host",
cls.base_host,
"--port",
cls.lb_port,
]
print("Starting load balancer:", " ".join(lb_command))
cls.process_lb = popen_with_error_check(lb_command)
cls.wait_server_ready(cls.lb_url + "/health")
@classmethod
def wait_server_ready(cls, url, timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH):
start_time = time.perf_counter()
while True:
try:
response = requests.get(url)
if response.status_code == 200:
print(f"Server {url} is ready")
return
except Exception:
pass
if time.perf_counter() - start_time > timeout:
raise RuntimeError(f"Server {url} failed to start in {timeout}s")
time.sleep(1)
@classmethod
def tearDownClass(cls):
for process in [cls.process_lb, cls.process_decode, cls.process_prefill]:
if process:
try:
kill_process_tree(process.pid)
except Exception as e:
print(f"Error killing process {process.pid}: {e}")
# wait for 5 seconds
time.sleep(5)
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]]
rdma_all_devices = _parse_list_env("SGLANG_CI_RDMA_ALL_DEVICES") or [
f"mlx5_roce{i}" for i in range(8)
]
logger.info("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
rdma_devices = []
for gpu_idx in gpu_indices:
nic_index = gpu_idx // (8 // n_rdma)
rdma_devices.append(rdma_all_devices[nic_index])
if not rdma_devices:
return ",".join(_pick_default_pair(rdma_all_devices))
return ",".join(rdma_devices)
@@ -0,0 +1,30 @@
import unittest
from sglang.test.ascend.gsm8k_ascend_mixin import GSM8KAscendMixin
from sglang.test.ascend.test_ascend_utils import GEMMA_4_26B_A4B_IT_WEIGHTS_PATH
from sglang.test.test_utils import CustomTestCase
class TestGemma426BA4BIt(GSM8KAscendMixin, CustomTestCase):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-26B-A4B-it model on the GSM8K dataset is no less than 0.35.
[Test Category] Model
[Test Target] google/gemma-4-26B-A4B-it
"""
model = GEMMA_4_26B_A4B_IT_WEIGHTS_PATH
accuracy = 0.35
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.7",
"--attention-backend",
"ascend",
"--disable-cuda-graph",
"--tp-size",
"2",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,30 @@
import unittest
from sglang.test.ascend.gsm8k_ascend_mixin import GSM8KAscendMixin
from sglang.test.ascend.test_ascend_utils import GEMMA_4_31B_WEIGHTS_PATH
from sglang.test.test_utils import CustomTestCase
class TestGemma431B(GSM8KAscendMixin, CustomTestCase):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-31B-it model on the GSM8K dataset is no less than 0.70.
[Test Category] Model
[Test Target] google/gemma-4-31B-it
"""
model = GEMMA_4_31B_WEIGHTS_PATH
accuracy = 0.70
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.7",
"--attention-backend",
"ascend",
"--disable-cuda-graph",
"--tp-size",
"2",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,30 @@
import unittest
from sglang.test.ascend.gsm8k_ascend_mixin import GSM8KAscendMixin
from sglang.test.ascend.test_ascend_utils import GEMMA_4_E2B_WEIGHTS_PATH
from sglang.test.test_utils import CustomTestCase
class TestGemma4E2B(GSM8KAscendMixin, CustomTestCase):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-E2B-it model on the GSM8K dataset is no less than 0.05.
[Test Category] Model
[Test Target] google/gemma-4-E2B-it
"""
model = GEMMA_4_E2B_WEIGHTS_PATH
accuracy = 0.05
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.7",
"--attention-backend",
"ascend",
"--disable-cuda-graph",
"--tp-size",
"1",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,30 @@
import unittest
from sglang.test.ascend.gsm8k_ascend_mixin import GSM8KAscendMixin
from sglang.test.ascend.test_ascend_utils import GEMMA_4_E4B_WEIGHTS_PATH
from sglang.test.test_utils import CustomTestCase
class TestGemma4E4B(GSM8KAscendMixin, CustomTestCase):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-E4B-it model on the GSM8K dataset is no less than 0.60.
[Test Category] Model
[Test Target] google/gemma-4-E4B-it
"""
model = GEMMA_4_E4B_WEIGHTS_PATH
accuracy = 0.60
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.7",
"--attention-backend",
"ascend",
"--disable-cuda-graph",
"--tp-size",
"1",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
import unittest
from sglang.test.ascend.gsm8k_ascend_mixin import GSM8KAscendMixin
from sglang.test.ascend.test_ascend_utils import MIMO_V2_FLASH_WEIGHTS_PATH
from sglang.test.test_utils import CustomTestCase
class TestMiMoV2FlashGraphWithMTP(GSM8KAscendMixin, CustomTestCase):
"""Testcase: Verify the inference accuracy of MiMo-V2-Flash on GSM8K with cuda graph and MTP (speculative decoding).
[Test Category] Model
[Test Target] XiaomiMiMo/MiMo-V2-Flash
[Test Config] Prefill+Decode, cuda graph enabled, EAGLE speculative decoding
"""
model = MIMO_V2_FLASH_WEIGHTS_PATH
accuracy = 0.9
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.8",
"--attention-backend",
"ascend",
"--tp-size",
"16",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--enable-multi-layer-eagle",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,93 @@
import unittest
from types import SimpleNamespace
from urllib.parse import urlparse
from sglang.srt.environ import envs
from sglang.srt.utils import kill_process_tree
from sglang.test.few_shot_gsm8k import run_eval as run_eval_few_shot_gsm8k
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
TEST_MODEL_MATRIX = {
"/root/.cache/modelscope/hub/models/vllm-ascend/DeepSeek-R1-0528-W8A8": {
"accuracy": 0.95,
"latency": 1000,
"output_throughput": 6,
},
}
class TestAscendDeepSeekMTP(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.models = TEST_MODEL_MATRIX.keys()
cls.base_url = DEFAULT_URL_FOR_TEST
cls.url = urlparse(DEFAULT_URL_FOR_TEST)
cls.common_args = [
"--trust-remote-code",
"--attention-backend",
"ascend",
"--mem-fraction-static",
0.8,
"--disable-radix-cache",
"--chunked-prefill-size",
32768,
"--tp-size",
16,
"--dp-size",
2,
"--enable-dp-attention",
"--speculative-algorithm",
"NEXTN",
"--speculative-num-steps",
1,
"--speculative-eagle-topk",
1,
"--speculative-num-draft-tokens",
2,
]
envs.SGLANG_NPU_USE_MLAPO.set(True)
envs.SGLANG_ENABLE_OVERLAP_PLAN_STREAM.set(True)
def test_a_gsm8k(self):
for model in self.models:
with self.subTest(model=model):
print(f"##=== Testing accuracy: {model} ===##")
process = popen_launch_server(
model,
self.base_url,
timeout=2400,
other_args=[
*self.common_args,
],
)
try:
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=1319,
max_new_tokens=512,
parallel=128,
host=f"http://{self.url.hostname}",
port=int(self.url.port),
)
metrics = run_eval_few_shot_gsm8k(args)
self.assertGreaterEqual(
metrics["accuracy"],
TEST_MODEL_MATRIX[model]["accuracy"],
)
finally:
kill_process_tree(process.pid)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,81 @@
import math
import pytest
import torch
from sglang.srt.constrained import xgrammar_backend as xb
def _pack_mask(allowed_ids, vocab_size, batch_size=1):
nwords = math.ceil(vocab_size / 32)
m = torch.zeros((batch_size, nwords), dtype=torch.int32)
for b in range(batch_size):
for tid in allowed_ids[b]:
m[b, tid // 32] |= 1 << (tid % 32)
return m
def _apply_ref_cpu(logits, vocab_mask):
vocab_size = logits.shape[-1]
token_ids = torch.arange(vocab_size, device="cpu", dtype=torch.int64)
word_idx = token_ids // 32
bit_idx = (token_ids % 32).to(torch.int32)
words = vocab_mask.cpu()[:, word_idx].to(torch.int32)
allowed = ((words >> bit_idx) & 1).bool().to(logits.device)
out = logits.clone()
out.masked_fill_(~allowed, float("-inf"))
return out
@pytest.mark.skipif(
not hasattr(torch, "npu") or not torch.npu.is_available(), reason="NPU required"
)
def test_mask_blocks_disallowed_token_on_npu():
device = "npu:0"
vocab_size = 64
logits = torch.zeros((1, vocab_size), device=device, dtype=torch.float32)
logits[0, 16] = 22.125
logits[0, 5] = 10.0
allowed = [[5, 6, 7, 8]]
vocab_mask = _pack_mask(allowed, vocab_size).to(device=device, dtype=torch.int32)
g = xb.XGrammarGrammar.__new__(xb.XGrammarGrammar)
out = logits.clone()
g.apply_vocab_mask(out, vocab_mask)
assert not torch.isfinite(out[0, 16])
assert int(torch.argmax(out[0]).item()) != 16
@pytest.mark.skipif(
not hasattr(torch, "npu") or not torch.npu.is_available(), reason="NPU required"
)
def test_npu_path_matches_reference_random():
device = "npu:0"
B, V = 4, 257
torch.manual_seed(0)
logits = torch.randn(B, V, device=device, dtype=torch.float32)
allowed = []
for _ in range(B):
ids = torch.randperm(V)[: V // 4].tolist()
allowed.append(ids)
vocab_mask = _pack_mask(allowed, V, B).to(device=device, dtype=torch.int32)
g = xb.XGrammarGrammar.__new__(xb.XGrammarGrammar)
out_npu = logits.clone()
g.apply_vocab_mask(out_npu, vocab_mask)
out_ref = _apply_ref_cpu(logits, vocab_mask)
assert torch.equal(torch.isfinite(out_npu), torch.isfinite(out_ref))
diff = (
torch.nan_to_num(out_npu - out_ref, nan=0.0, posinf=0.0, neginf=0.0)
.abs()
.max()
.item()
)
assert diff < 1e-5
@@ -0,0 +1,102 @@
"""
Usage:
python3 -m unittest test_ascend_w8a8_quantization.TestAscendW8A8.test_gsm8k
"""
import os
import time
import unittest
from types import SimpleNamespace
from urllib.parse import urlparse
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.few_shot_gsm8k import run_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_ci,
popen_launch_server,
)
if "ASCEND_RT_VISIBLE_DEVICES" not in os.environ:
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = "0,1"
DEFAULT_PORT_FOR_SRT_TEST_RUNNER = (
7000 + int(os.environ.get("ASCEND_RT_VISIBLE_DEVICES", "0")[0]) * 100
)
DEFAULT_URL_FOR_TEST = f"http://127.0.0.1:{DEFAULT_PORT_FOR_SRT_TEST_RUNNER + 1000}"
class TestAscendW8A8(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = "vllm-ascend/Qwen2.5-0.5B-Instruct-w8a8"
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--disable-cuda-graph",
"--device",
"npu",
"--attention-backend",
"ascend",
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
base_url = DEFAULT_URL_FOR_TEST
url = urlparse(base_url)
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=200,
max_new_tokens=512,
parallel=128,
host=f"http://{url.hostname}",
port=int(url.port),
)
metrics = run_eval(args)
print(metrics)
self.assertGreaterEqual(metrics["accuracy"], 0.25)
self.assertGreaterEqual(metrics["output_throughput"], 1000)
def run_decode(self, max_new_tokens):
response = requests.post(
self.base_url + "/generate",
json={
"text": "The capital of France is",
"sampling_params": {
"temperature": 0,
"max_new_tokens": max_new_tokens,
},
"ignore_eos": True,
},
)
return response.json()
def test_throughput(self):
max_tokens = 256
tic = time.perf_counter()
res = self.run_decode(max_tokens)
tok = time.perf_counter()
print(res["text"])
throughput = max_tokens / (tok - tic)
print(f"Throughput: {throughput} tokens/s")
if is_in_ci():
self.assertGreaterEqual(throughput, 25)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,64 @@
"""
Usage:
python3 -m unittest test_mindspore_models.TestMindSporeQwen3.test_gsm8k
"""
import unittest
from types import SimpleNamespace
from sglang.srt.utils import kill_process_tree
from sglang.test.few_shot_gsm8k import run_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
class TestMindSporeQwen3(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = "Qwen/Qwen3-8B"
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--device",
"npu",
"--model-impl",
"mindspore",
"--attention-backend",
"ascend",
"--tp-size",
"1",
"--dp-size",
"1",
"--mem-fraction-static",
0.8,
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=200,
max_new_tokens=512,
parallel=128,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval(args)
print(f"{metrics=}")
self.assertGreater(metrics["accuracy"], 0.78)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from sglang.test.ascend.test_ascend_utils import GEMMA_4_26B_A4B_IT_WEIGHTS_PATH
from sglang.test.ascend.vlm_utils import TestVLMModels
class TestGemma426BA4BIt(TestVLMModels):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-26B-A4B-it model on the MMMU dataset is no less than 0.40.
[Test Category] Model
[Test Target] google/gemma-4-26B-A4B-it
"""
model = GEMMA_4_26B_A4B_IT_WEIGHTS_PATH
mmmu_accuracy = 0.40
def test_vlm_mmmu_benchmark(self):
self._run_vlm_mmmu_test()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from sglang.test.ascend.test_ascend_utils import GEMMA_4_31B_WEIGHTS_PATH
from sglang.test.ascend.vlm_utils import TestVLMModels
class TestGemma431B(TestVLMModels):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-31B-it model on the MMMU dataset is no less than 0.50.
[Test Category] Model
[Test Target] google/gemma-4-31B-it
"""
model = GEMMA_4_31B_WEIGHTS_PATH
mmmu_accuracy = 0.50
def test_vlm_mmmu_benchmark(self):
self._run_vlm_mmmu_test()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from sglang.test.ascend.test_ascend_utils import GEMMA_4_E2B_WEIGHTS_PATH
from sglang.test.ascend.vlm_utils import TestVLMModels
class TestGemma4E2B(TestVLMModels):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-E2B-it model on the MMMU dataset is no less than 0.15.
[Test Category] Model
[Test Target] google/gemma-4-E2B-it
"""
model = GEMMA_4_E2B_WEIGHTS_PATH
mmmu_accuracy = 0.15
def test_vlm_mmmu_benchmark(self):
self._run_vlm_mmmu_test()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from sglang.test.ascend.test_ascend_utils import GEMMA_4_E4B_WEIGHTS_PATH
from sglang.test.ascend.vlm_utils import TestVLMModels
class TestGemma4E4B(TestVLMModels):
"""Testcase: Verify that the inference accuracy of the google/gemma-4-E4B-it model on the MMMU dataset is no less than 0.30.
[Test Category] Model
[Test Target] google/gemma-4-E4B-it
"""
model = GEMMA_4_E4B_WEIGHTS_PATH
mmmu_accuracy = 0.30
def test_vlm_mmmu_benchmark(self):
self._run_vlm_mmmu_test()
if __name__ == "__main__":
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
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_DRAFT_MODEL_EAGLE3,
DEFAULT_MODEL_NAME_FOR_TEST,
DEFAULT_MODEL_NAME_FOR_TEST_MLA,
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
# FlashAttention3 integration tests (requires SM 90+ / H100)
# Multiple test classes: FA3, FA3+MLA, FA3+SpecDecode variants
GSM_DATASET_PATH = None
# In case of some machine lack internet connection, we can set OFFLINE_MODE to True.
OFFLINE_MODE = False
# Change the path below when OFFLINE_MODE is True.
OFFLINE_PATH_DICT = {
DEFAULT_MODEL_NAME_FOR_TEST: "/shared/public/elr-models/meta-llama/Meta-Llama-3.1-8B-Instruct",
DEFAULT_DRAFT_MODEL_EAGLE3: "/shared/public/elr-models/jamesliu1/sglang-EAGLE3-Llama-3.1-Instruct-8B",
DEFAULT_MODEL_NAME_FOR_TEST_MLA: "/shared/public/sharing/deepseek/dsv3-test/snapshots/",
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN: "/shared/public/sharing/deepseek/dsv3-test-NextN/snapshots/",
GSM_DATASET_PATH: "/shared/public/data/gsm8k/test.jsonl",
}
if OFFLINE_MODE:
DEFAULT_MODEL_NAME_FOR_TEST = OFFLINE_PATH_DICT[DEFAULT_MODEL_NAME_FOR_TEST]
DEFAULT_DRAFT_MODEL_EAGLE3 = OFFLINE_PATH_DICT[DEFAULT_DRAFT_MODEL_EAGLE3]
DEFAULT_MODEL_NAME_FOR_TEST_MLA = OFFLINE_PATH_DICT[DEFAULT_MODEL_NAME_FOR_TEST_MLA]
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN = OFFLINE_PATH_DICT[
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN
]
GSM_DATASET_PATH = OFFLINE_PATH_DICT[GSM_DATASET_PATH]
# Default server arguments shared across all tests
DEFAULT_SERVER_ARGS = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"8",
"--attention-backend",
"fa3",
]
"""
Integration test for python/sglang/srt/layers/attention/flashattention_backend.py
"""
@unittest.skipIf(get_device_sm() < 90, "Test requires CUDA SM 90 or higher")
class BaseFlashAttentionTest(CustomTestCase):
"""Base class for testing FlashAttention3."""
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 = 1.0 # derived spec decoding tests need to override this
@classmethod
def get_server_args(cls):
"""Return the arguments for the server launch. Override in subclasses."""
return DEFAULT_SERVER_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),
):
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)
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=}")
# Use the appropriate metric key based on the test class
metric_key = "score"
self.assertGreater(metrics[metric_key], self.accuracy_threshold)
if self.speculative_decode:
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.spec_decode_threshold)
class TestFlashAttention3MLA(BaseFlashAttentionTest):
"""Test FlashAttention3 with MLA, e.g. deepseek v3 test model"""
accuracy_threshold = 0.60
model = DEFAULT_MODEL_NAME_FOR_TEST_MLA
@classmethod
def get_server_args(cls):
return DEFAULT_SERVER_ARGS
class TestFlashAttention3SpeculativeDecode(BaseFlashAttentionTest):
"""Test FlashAttention3 with speculative decode enabled with Llama 3.1 8B and its eagle3 model"""
model = DEFAULT_MODEL_NAME_FOR_TEST
accuracy_threshold = 0.65
speculative_decode = True
spec_decode_threshold = 1.5
@classmethod
def get_server_args(cls):
args = DEFAULT_SERVER_ARGS
args.extend(
[
"--cuda-graph-max-bs-decode",
"4",
"--speculative-algorithm",
"EAGLE3",
"--speculative-draft-model-path",
DEFAULT_DRAFT_MODEL_EAGLE3,
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--dtype",
"float16",
]
)
return args
class TestFlashAttention3SpeculativeDecodeTopk(BaseFlashAttentionTest):
"""Tests FlashAttention3 with enhanced speculative decoding using Llama 3.1 8B and EAGLE3.
This test will be using top-k value > 1 which would verify the other branches of the FA3 code
"""
model = DEFAULT_MODEL_NAME_FOR_TEST
accuracy_threshold = 0.65
speculative_decode = True
spec_decode_threshold = 1.6
@classmethod
def get_server_args(cls):
args = DEFAULT_SERVER_ARGS
args.extend(
[
"--cuda-graph-max-bs-decode",
"4",
"--speculative-algorithm",
"EAGLE3",
"--speculative-draft-model-path",
DEFAULT_DRAFT_MODEL_EAGLE3,
"--speculative-num-steps",
"5",
"--speculative-eagle-topk",
"4",
"--speculative-num-draft-tokens",
"8",
"--dtype",
"float16",
]
)
return args
class TestFlashAttention3MLASpeculativeDecode(BaseFlashAttentionTest):
"""Test FlashAttention3 with speculative decode enabled with deepseek v3 test model and its nextN model"""
model = DEFAULT_MODEL_NAME_FOR_TEST_MLA
accuracy_threshold = 0.60
speculative_decode = True
spec_decode_threshold = 2.5
@classmethod
def get_server_args(cls):
args = DEFAULT_SERVER_ARGS
args.extend(
[
"--cuda-graph-max-bs-decode",
"4",
"--speculative-algorithm",
"EAGLE",
"--speculative-draft-model-path",
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
]
)
return args
class TestFlashAttention3MLASpeculativeDecodeTopk(BaseFlashAttentionTest):
"""Test FlashAttention3 with speculative decode enabled with deepseek v3 test model and its nextN model
This test will be using top-k value > 1 which would verify the other branches of the FA3 code
"""
model = DEFAULT_MODEL_NAME_FOR_TEST_MLA
accuracy_threshold = 0.60
speculative_decode = True
spec_decode_threshold = 2.95
@classmethod
def get_server_args(cls):
args = DEFAULT_SERVER_ARGS
args.extend(
[
"--cuda-graph-max-bs-decode",
"4",
"--speculative-algorithm",
"EAGLE",
"--speculative-draft-model-path",
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
"--speculative-num-steps",
"5",
"--speculative-eagle-topk",
"4",
"--speculative-num-draft-tokens",
"8",
]
)
return args
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,588 @@
import unittest
import torch
from sglang.srt.configs.model_config import AttentionArch
from sglang.srt.layers.attention.flashattention_backend import (
FlashAttentionBackend,
draft_decode_set_expand_metadata,
)
from sglang.srt.layers.attention.torch_native_backend import TorchNativeAttnBackend
from sglang.srt.layers.radix_attention import RadixAttention
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
from sglang.srt.model_executor.forward_context import (
ForwardContext,
set_forward_context,
)
from sglang.test.test_utils import CustomTestCase
class MockModelRunner:
def __init__(
self,
page_size=1,
num_heads=2,
head_dim=8,
):
self.device = "cuda"
self.dtype = torch.float16
self.kv_cache_dtype = torch.float16
self.is_hybrid_swa = False
self.attention_chunk_size = None
attention_arch = AttentionArch.MHA
# Max batch size for the test.
max_batch_size = 160
# Total tokens(prefix + extend + decode) in the test should not exceed this length.
max_context_len = 2048
self.model_config = type(
"ModelConfig",
(),
{
"context_len": max_context_len,
"is_multimodal": False,
"attention_arch": attention_arch,
"is_encoder_decoder": False,
"is_local_attention_model": False,
},
)()
self.sliding_window_size = None
self.kv_cache_dtype = (
self.dtype
) # torch dtype, required by FlashAttentionBackend
# server_args is still needed for string-based config (kv_cache_dtype_str)
self.server_args = type(
"ServerArgs",
(),
{
"kv_cache_dtype": "auto", # string version for kv_cache_dtype_str
"speculative_eagle_topk": None,
"speculative_num_draft_tokens": 0,
"enable_deterministic_inference": False,
},
)
self.attn_cp_size = 1
# Create a large enough req_to_token_pool to fit the test usage.
self.req_to_token_pool = type(
"TokenPool",
(),
{
# A typical max_bs * max_context_len for cuda graph decode
"size": max_batch_size,
# Add req_to_token attribute
"req_to_token": torch.zeros(
max_batch_size,
max_context_len,
dtype=torch.int32,
device=self.device,
),
},
)()
self.page_size = page_size
max_total_num_tokens = max_batch_size * max_context_len
self.token_to_kv_pool = MHATokenToKVPool(
size=max_total_num_tokens,
page_size=page_size,
dtype=self.dtype,
head_num=num_heads,
head_dim=head_dim,
layer_num=1, # only consider layer=1 for unit test
device=self.device,
enable_memory_saver=False,
)
self.hisparse_coordinator = None
@unittest.skipIf(not torch.cuda.is_available(), "Test requires CUDA")
class TestFlashAttentionBackend(CustomTestCase):
def setUp(self):
# Test parameters
self.batch_size = 2
self.seq_len = 256
self.num_heads = 2
self.head_dim = 8
self.device = "cuda"
self.dtype = torch.float16
def _init_model_runner(self, page_size=1):
self.model_runner = MockModelRunner(
page_size=page_size,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
self.backend = FlashAttentionBackend(self.model_runner)
self.ref_backend = TorchNativeAttnBackend(self.model_runner)
self.model_runner.model_config.num_attention_heads = self.num_heads
# Publish the backend for any RadixAttention.forward path the tests
# exercise; tearDown is unnecessary here since each test re-inits.
set_forward_context(ForwardContext(attn_backend=self.backend))
def _mock_write_to_req_to_token_pool(self, batch_size, seq_len, page_size):
# if page_size > 1, the token pool stores the index to the page.
# so we need to multiply the index by page_size.
self.req_to_token = (
torch.arange(0, batch_size, dtype=torch.int32, device=self.device)[:, None]
* seq_len
+ torch.arange(0, seq_len, dtype=torch.int32, device=self.device)[None, :]
+ page_size
)
self.model_runner.req_to_token_pool.req_to_token[:batch_size, :seq_len] = (
self.req_to_token
)
def _create_attention_layer(self):
"""Create attention layer for testing."""
return RadixAttention(
num_heads=self.num_heads,
head_dim=self.head_dim,
scaling=1.0,
num_kv_heads=self.num_heads,
layer_id=0,
)
def _create_qkv_tensors(self, tokens_len):
"""Create q, k, v tensors for testing."""
shape = (tokens_len, self.num_heads, self.head_dim)
return (
torch.randn(shape, dtype=self.dtype, device=self.device),
torch.randn(shape, dtype=self.dtype, device=self.device),
torch.randn(shape, dtype=self.dtype, device=self.device),
)
def _run_reference_forward(
self, mode, q, k, v, layer, forward_batch, expected_shape
):
"""Run reference forward pass using native backend."""
if mode == ForwardMode.EXTEND:
output = self.ref_backend.forward_extend(q, k, v, layer, forward_batch)
else: # ForwardMode.DECODE
output = self.ref_backend.forward_decode(q, k, v, layer, forward_batch)
return output.view(expected_shape)
def _verify_output(self, output, expected_shape, output_ref=None):
"""Verify output tensor shape, dtype, and values."""
self.assertEqual(
output.shape,
expected_shape,
f"Expected shape {expected_shape}, got {output.shape}",
)
self.assertEqual(output.dtype, self.dtype)
self.assertEqual(output.device.type, "cuda")
self.assertEqual(
torch.isnan(output).sum().item(), 0, "Output contains NaN values"
)
if output_ref is not None:
if not torch.allclose(output, output_ref, atol=1e-1, rtol=0.0):
# Check where the values differ beyond the given tolerances
diff_mask = ~torch.isclose(output, output_ref, atol=1e-1, rtol=0.0)
# Find the first index where the difference occurs
if diff_mask.any():
first_mismatch_idx = diff_mask.nonzero()[0]
print(
"First mismatch at index:", tuple(first_mismatch_idx.tolist())
)
print("output:", output[tuple(first_mismatch_idx.tolist())])
print("output_ref:", output_ref[tuple(first_mismatch_idx.tolist())])
raise AssertionError(
"Attention output is not close to the torch native backend output"
)
def _create_forward_batch(
self, mode, q_len=None, prefix_len=0, page_size=1, attn_cp_size=1
):
"""Create a forward batch for testing based on mode and lengths."""
self._init_model_runner(page_size=page_size)
# Default to self.seq_len if not specified
q_len = q_len or self.seq_len
if mode == ForwardMode.EXTEND:
total_len = prefix_len + q_len
out_cache_start = prefix_len * self.batch_size
out_cache_end = total_len * self.batch_size
forward_batch = ForwardBatch(
batch_size=self.batch_size,
input_ids=torch.randint(
0, 100, (self.batch_size, q_len), device=self.device
),
out_cache_loc=torch.arange(
out_cache_start, out_cache_end, device=self.device
),
seq_lens_sum=self.batch_size * total_len,
forward_mode=mode,
req_pool_indices=torch.arange(self.batch_size, device=self.device),
seq_lens=torch.tensor(
[total_len] * self.batch_size, device=self.device
),
seq_lens_cpu=torch.tensor([total_len] * self.batch_size, device="cpu"),
extend_prefix_lens=torch.tensor(
[prefix_len] * self.batch_size, device=self.device
),
extend_prefix_lens_cpu=[prefix_len] * self.batch_size,
extend_seq_lens=torch.tensor(
[q_len] * self.batch_size, device=self.device
),
extend_seq_lens_cpu=[q_len] * self.batch_size,
)
if attn_cp_size > 1:
forward_batch.attn_cp_metadata = type(
"AttnCPMetadata",
(),
{
"kv_len_prev_tensor": torch.tensor(
[q_len // 2] * self.batch_size,
dtype=torch.int32,
device=self.device,
),
"kv_len_next_tensor": torch.tensor(
[q_len] * self.batch_size,
dtype=torch.int32,
device=self.device,
),
"actual_seq_q_prev": q_len // 2,
"actual_seq_q_next": q_len // 2,
},
)
else: # ForwardMode.DECODE
decode_len = q_len # Assuming 1 for decode testing
total_len = self.seq_len + decode_len
if mode == ForwardMode.DECODE and page_size > 1:
# Get next page_size multiple of self.seq_len
out_cache_start = (
self.batch_size * self.seq_len // page_size + 1
) * page_size
# out_cache_end is the start of the next block
out_cache_end = out_cache_start + decode_len * page_size
else:
out_cache_start = self.batch_size * self.seq_len
out_cache_end = self.batch_size * total_len
forward_batch = ForwardBatch(
batch_size=self.batch_size,
input_ids=torch.randint(
0, 100, (self.batch_size, decode_len), device=self.device
),
out_cache_loc=torch.tensor(
[out_cache_start, out_cache_end], device=self.device
),
seq_lens_sum=self.batch_size * total_len,
forward_mode=mode,
req_pool_indices=torch.arange(self.batch_size, device=self.device),
seq_lens=torch.tensor(
[total_len] * self.batch_size, device=self.device
),
seq_lens_cpu=torch.tensor([total_len] * self.batch_size, device="cpu"),
)
# Pool refs are resolved via the active ForwardContext (published in
# setUp). Write the test fixture's req_to_token mapping.
self._mock_write_to_req_to_token_pool(self.batch_size, total_len, page_size)
return forward_batch
def _setup_kv_cache(self, forward_batch, layer, cache_len):
# Create constant values for the prefix cache for easy debugging
cache_k = torch.ones(
self.batch_size * cache_len,
self.num_heads,
self.head_dim,
dtype=self.dtype,
device=self.device,
)
cache_v = (
torch.ones(
self.batch_size * cache_len,
self.num_heads,
self.head_dim,
dtype=self.dtype,
device=self.device,
)
* 2
)
# Set the prefix KV cache
self.model_runner.token_to_kv_pool.set_kv_buffer(
layer,
torch.arange(self.batch_size * cache_len, device=self.device),
cache_k,
cache_v,
layer.k_scale,
layer.v_scale,
)
def _run_attention_test(self, mode, q_len, prefix_len=0, page_size=1):
"""
Run an attention test with the specified parameters.
Args:
mode: ForwardMode.EXTEND or ForwardMode.DECODE
q_len: Length of the query sequence. For decode mode, q_len is 1.
prefix_len: Length of the prefix sequence for extend mode
page_size: Page size for the KV cache
"""
layer = self._create_attention_layer()
# Create forward batch and set up
forward_batch = self._create_forward_batch(mode, q_len, prefix_len, page_size)
# Create QKV tensors for the input
q, k, v = self._create_qkv_tensors(self.batch_size * q_len)
# KV cache for prefixed extend is prefix_len
# KV cache for decode is same as seq_len
# No KV cache for extend without prefix
if mode == ForwardMode.EXTEND:
if prefix_len > 0:
self._setup_kv_cache(forward_batch, layer, prefix_len)
else:
self._setup_kv_cache(forward_batch, layer, self.seq_len)
self.backend.init_forward_metadata(forward_batch)
if mode == ForwardMode.EXTEND:
expected_shape = (
self.batch_size * q_len,
self.num_heads * self.head_dim,
)
output = self.backend.forward_extend(q, k, v, layer, forward_batch)
else:
expected_shape = (self.batch_size, self.num_heads * self.head_dim)
output = self.backend.forward_decode(q, k, v, layer, forward_batch)
output_ref = self._run_reference_forward(
mode, q, k, v, layer, forward_batch, expected_shape
)
self._verify_output(output, expected_shape, output_ref)
return output
def _run_attention_cp_test(self, mode, q_len, prefix_len=0, page_size=1):
layer = self._create_attention_layer()
# Create forward batch and set up
forward_batch = self._create_forward_batch(
mode, q_len, prefix_len, page_size, attn_cp_size=2
)
self.backend.attn_cp_size = 2
# Create QKV tensors for the input
q, k, v = self._create_qkv_tensors(self.batch_size * q_len)
# KV cache for prefixed extend is prefix_len
# KV cache for decode is same as seq_len
# No KV cache for extend without prefix
# Setup KV cache for CP testing - need KV cache to have actual values
# For extend with CP, we need KV cache populated so attention has something to attend to
self._setup_kv_cache(forward_batch, layer, q_len)
self.backend.init_forward_metadata(forward_batch)
# if mode == ForwardMode.EXTEND:
expected_shape = (
self.batch_size * q_len,
self.num_heads * self.head_dim,
)
output = self.backend.forward_extend(q, k, v, layer, forward_batch)
# else:
# expected_shape = (self.batch_size, self.num_heads * self.head_dim)
# output = self.backend.forward_decode(q, k, v, layer, forward_batch)
output_ref = self._run_reference_forward(
mode, q, k, v, layer, forward_batch, expected_shape
)
self._verify_output(output, expected_shape, output_ref)
return output
def test_forward_extend_cp(self):
"""Test the standard extend operation with context parallel."""
self._run_attention_cp_test(ForwardMode.EXTEND, q_len=self.seq_len)
# def test_forward_extend_cp_with_prefix(self):
# """Test the standard extend operation with context parallel and prefix."""
# prefix_len = self.seq_len // 2
# extend_len = self.seq_len - prefix_len
# self._run_attention_cp_test(ForwardMode.EXTEND, q_len=extend_len, prefix_len=prefix_len)
# def test_forward_extend(self):
# """Test the standard extend operation."""
# self._run_attention_test(ForwardMode.EXTEND, q_len=self.seq_len)
# def test_forward_decode(self):
# """Test the decode operation with cached tokens."""
# self._run_attention_test(ForwardMode.DECODE, q_len=1)
# def test_forward_extend_with_prefix(self):
# """Test extending from cached prefix tokens."""
# prefix_len = self.seq_len // 2
# extend_len = self.seq_len - prefix_len
# self._run_attention_test(
# ForwardMode.EXTEND, q_len=extend_len, prefix_len=prefix_len
# )
# def test_forward_extend_with_page_size_greater_than_1(self):
# """Test extending from cached prefix tokens with page size greater than 1."""
# self._run_attention_test(ForwardMode.EXTEND, q_len=self.seq_len, page_size=64)
# def test_forward_decode_with_page_size_greater_than_1(self):
# """Test decode operation with page size greater than 1."""
# self._run_attention_test(ForwardMode.DECODE, q_len=1, page_size=64)
class TestUpdateDraftDecodeSetExpandMetadata(CustomTestCase):
"""
All the test cases examples have 1 additional cache location than the decode length.
This is to align with the current allocation logic. It does not affect the correctness.
"""
def test_draft_decode_set_expand_metadata(self):
bs, topk, page_size = 1, 2, 4
cases = [
(
torch.tensor(
[
[23, 24],
[31, 32],
],
dtype=torch.int32,
),
torch.tensor(
[
[5, 6],
[7, 8],
],
dtype=torch.int32,
),
1,
),
# Decode span multiple pages:
# duplicated kv cache: 24, 25, 26
# decode locations: 27, 28, 29, 30, 31, 32
# We need 3 pages in total.
(
torch.tensor(
[
[27, 28, 29, 30, 31, 32],
[35, 36, 37, 38, 39, 40],
],
dtype=torch.int32,
),
torch.tensor(
[
[6, 7, 8, 0, 0, 0],
[8, 9, 10, 0, 0, 0],
],
dtype=torch.int32,
),
5,
),
]
last_page_lens = torch.tensor([3], dtype=torch.int32)
for cache_loc, expected_page_table, decode_length in cases:
cache_seqlens_int32 = torch.zeros(bs * topk, dtype=torch.int32)
page_table = torch.zeros_like(cache_loc, dtype=torch.int32)
draft_decode_set_expand_metadata(
cache_seqlens_int32=cache_seqlens_int32,
page_table=page_table,
last_page_lens=last_page_lens,
decode_length=decode_length,
cache_loc=cache_loc,
topk=topk,
page_size=page_size,
)
expected_cache_seqlens = torch.tensor(
[decode_length + 3, decode_length + 3], dtype=torch.int32
)
self.assertTrue(torch.equal(cache_seqlens_int32, expected_cache_seqlens))
self.assertTrue(torch.equal(page_table, expected_page_table))
def test_draft_decode_set_expand_metadata_page_crossing(self):
"""
Regression for fa3 EAGLE draft decode with topk > 1 and page_size > 1.
cache_loc arrives num_steps-wide; callers pre-slice it to `decode_length`
(the live draft tokens) before this helper runs, so the dedup'd scatter
never writes past the (decode_length + 1)-wide expand page_table row even
when consecutive draft tokens land on distinct pages.
"""
bs, topk, page_size = 1, 2, 4
decode_length = 2
last_page_lens = torch.tensor([3], dtype=torch.int32)
# 2 live draft tokens per (batch, topk) crossing into distinct pages.
cache_loc = torch.tensor([[23, 28], [31, 36]], dtype=torch.int32)
cache_seqlens_int32 = torch.zeros(bs * topk, dtype=torch.int32)
# page_table is (decode_length + 1) wide (extra slot for the last partial
# page); the trailing column must stay zero.
page_table = torch.zeros(bs * topk, decode_length + 1, dtype=torch.int32)
draft_decode_set_expand_metadata(
cache_seqlens_int32=cache_seqlens_int32,
page_table=page_table,
last_page_lens=last_page_lens,
decode_length=decode_length,
cache_loc=cache_loc,
topk=topk,
page_size=page_size,
)
expected_page_table = torch.tensor([[5, 7, 0], [7, 9, 0]], dtype=torch.int32)
expected_cache_seqlens = torch.tensor([5, 5], dtype=torch.int32)
self.assertTrue(torch.equal(page_table, expected_page_table))
self.assertTrue(torch.equal(cache_seqlens_int32, expected_cache_seqlens))
def test_update_draft_decode_set_expand_metadata_multi_batch(self):
"""
Ensure expand metadata works when batch size > 1 and last pages differ.
"""
bs, topk, decode_length, page_size = 3, 2, 3, 4
cache_loc = torch.tensor(
[
# First batch: last page duplicate is 1, consecutive pages
[1, 2, 3, 4],
[6, 7, 8, 9],
# Second batch: last page duplicate is 3, non-consecutive pages
[3, 8, 9, 10],
[14, 15, 16, 17],
# Third batch: last page duplicate is 0, consecutive pages
[0, 1, 2, 3],
[4, 5, 6, 7],
],
dtype=torch.int32,
)
cache_seqlens_int32 = torch.zeros(bs * topk, dtype=torch.int32)
last_page_lens = torch.tensor([1, 3, 0], dtype=torch.int32)
page_table = torch.zeros_like(cache_loc, dtype=torch.int32)
draft_decode_set_expand_metadata(
cache_seqlens_int32=cache_seqlens_int32,
page_table=page_table,
last_page_lens=last_page_lens,
decode_length=decode_length,
cache_loc=cache_loc,
topk=topk,
page_size=page_size,
)
expected_cache_seqlens = torch.tensor([4, 4, 6, 6, 3, 3], dtype=torch.int32)
expected_page_table = torch.tensor(
[
[0, 1, 0, 0],
[1, 2, 0, 0],
[0, 2, 0, 0],
[3, 4, 0, 0],
[0, 0, 0, 0],
[1, 0, 0, 0],
],
dtype=torch.int32,
)
self.assertTrue(torch.equal(cache_seqlens_int32, expected_cache_seqlens))
self.assertTrue(torch.equal(page_table, expected_page_table))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,328 @@
import unittest
import torch
from sglang.srt.configs.model_config import AttentionArch
from sglang.srt.layers.attention.flashattention_backend import FlashAttentionBackend
from sglang.srt.layers.attention.torch_native_backend import TorchNativeAttnBackend
from sglang.srt.layers.radix_attention import RadixAttention
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
from sglang.srt.model_executor.forward_context import (
ForwardContext,
set_forward_context,
)
from sglang.test.test_utils import CustomTestCase
class MockModelRunner:
def __init__(
self,
kv_lora_rank,
qk_rope_head_dim,
):
attention_arch = AttentionArch.MLA
self.device = "cuda"
self.dtype = torch.float16
self.is_hybrid_swa = False
context_len = 2048
self.model_config = type(
"ModelConfig",
(),
{
"context_len": context_len,
"attention_arch": attention_arch,
"is_encoder_decoder": False,
"is_local_attention_model": False,
},
)
self.sliding_window_size = None
# Add server_args attribute
self.server_args = type(
"ServerArgs",
(),
{
"kv_cache_dtype": torch.float16,
"speculative_eagle_topk": None,
"speculative_num_draft_tokens": 0,
"enable_deterministic_inference": False,
},
)
self.kv_cache_dtype = self.server_args.kv_cache_dtype
batch_size = 160
# Create a proper req_to_token_pool with the req_to_token attribute
self.req_to_token_pool = type(
"TokenPool",
(),
{
# A typical max_bs * max_context_len for cuda graph decode
"size": batch_size,
# Add req_to_token attribute
"req_to_token": torch.zeros(
batch_size, context_len, dtype=torch.int32, device=self.device
),
},
)
self.page_size = 1
max_total_num_tokens = batch_size * context_len
self.token_to_kv_pool = MLATokenToKVPool(
size=max_total_num_tokens,
page_size=self.page_size,
dtype=self.kv_cache_dtype,
kv_lora_rank=kv_lora_rank,
qk_rope_head_dim=qk_rope_head_dim,
layer_num=1, # only consider layer=1 for unit test
device=self.device,
enable_memory_saver=False,
)
self.hisparse_coordinator = None
class MockReqToTokenPool:
def __init__(self, batch_size, seq_len, device):
self.req_to_token = (
torch.arange(batch_size * seq_len, device=device)
.reshape(batch_size, seq_len)
.to(torch.int32)
)
@unittest.skipIf(not torch.cuda.is_available(), "Test requires CUDA")
class TestFlashAttentionMLABackend(CustomTestCase):
def setUp(self):
# MLA with different V headdim requires Hopper architecture (compute capability >= 9.0)
if torch.cuda.is_available():
compute_capability = torch.cuda.get_device_capability()
if compute_capability[0] < 9:
self.skipTest(
f"MLA requires Hopper GPU (compute capability >= 9.0), "
f"but found compute capability {compute_capability[0]}.{compute_capability[1]}"
)
# Test parameters
self.batch_size = 2
self.seq_len = 360
self.num_heads = 2
self.device = "cuda"
self.dtype = torch.float16
self.kv_lora_rank = 512
self.q_lora_rank = 128
self.qk_rope_head_dim = 64
self.qk_head_dim = self.qk_rope_head_dim + self.kv_lora_rank
# Assume no rope scaling
self.scaling = self.qk_head_dim**-0.5
# Initialize model runner and backend
self._init_model_runner()
self.backend = FlashAttentionBackend(self.model_runner)
self.ref_backend = TorchNativeAttnBackend(self.model_runner)
self.num_local_heads = 2
# Publish the backend so RadixAttention.forward resolves correctly.
set_forward_context(ForwardContext(attn_backend=self.backend))
def _init_model_runner(self):
self.model_runner = MockModelRunner(
kv_lora_rank=self.kv_lora_rank,
qk_rope_head_dim=self.qk_rope_head_dim,
)
def _create_attention_layer(self):
"""Create attention layer for testing."""
self.attn_mqa = RadixAttention(
num_heads=self.num_local_heads,
head_dim=self.kv_lora_rank + self.qk_rope_head_dim,
scaling=self.scaling,
num_kv_heads=1,
layer_id=0,
v_head_dim=self.kv_lora_rank,
prefix="attn_mqa",
)
return self.attn_mqa
def _run_reference_forward(
self, mode, q, k, v, layer, forward_batch, expected_shape
):
"""Run reference forward pass using native backend."""
if mode == ForwardMode.EXTEND:
output = self.ref_backend.forward_extend(q, k, v, layer, forward_batch)
else: # ForwardMode.DECODE
output = self.ref_backend.forward_decode(q, k, v, layer, forward_batch)
return output.view(expected_shape)
def _verify_output(self, output, expected_shape):
"""Verify output tensor shape, dtype, and values."""
self.assertEqual(
output.shape,
expected_shape,
f"Expected shape {expected_shape}, got {output.shape}",
)
self.assertEqual(output.dtype, self.dtype)
self.assertEqual(output.device.type, "cuda")
self.assertEqual(
torch.isnan(output).sum().item(), 0, "Output contains NaN values"
)
def _create_forward_batch(self, mode, q_len=None, prefix_len=0):
"""Create a forward batch for testing based on mode and lengths."""
# Default to self.seq_len if not specified
q_len = q_len or self.seq_len
if mode == ForwardMode.EXTEND:
total_len = prefix_len + q_len
out_cache_start = prefix_len * self.batch_size
out_cache_end = total_len * self.batch_size
forward_batch = ForwardBatch(
batch_size=self.batch_size,
input_ids=torch.randint(
0, 100, (self.batch_size, q_len), device=self.device
),
out_cache_loc=torch.arange(
out_cache_start, out_cache_end, device=self.device
),
seq_lens_sum=self.batch_size * total_len,
forward_mode=mode,
req_pool_indices=torch.arange(self.batch_size, device=self.device),
seq_lens=torch.tensor(
[total_len] * self.batch_size, device=self.device
),
seq_lens_cpu=torch.tensor([total_len] * self.batch_size, device="cpu"),
extend_prefix_lens=torch.tensor(
[prefix_len] * self.batch_size, device=self.device
),
extend_prefix_lens_cpu=[prefix_len] * self.batch_size,
extend_seq_lens=torch.tensor(
[q_len] * self.batch_size, device=self.device
),
extend_seq_lens_cpu=[q_len] * self.batch_size,
)
else: # ForwardMode.DECODE
decode_len = q_len # typically 1 for decode mode
total_len = self.seq_len + decode_len
out_cache_start = self.batch_size * self.seq_len
out_cache_end = self.batch_size * total_len
forward_batch = ForwardBatch(
batch_size=self.batch_size,
input_ids=torch.randint(
0, 100, (self.batch_size, decode_len), device=self.device
),
out_cache_loc=torch.arange(
out_cache_start, out_cache_end, device=self.device
),
seq_lens_sum=self.batch_size * total_len,
forward_mode=mode,
req_pool_indices=torch.arange(self.batch_size, device=self.device),
seq_lens=torch.tensor(
[total_len] * self.batch_size, device=self.device
),
seq_lens_cpu=torch.tensor([total_len] * self.batch_size, device="cpu"),
)
# Pool refs are resolved via the active ForwardContext (published in
# setUp); the fixture no longer needs to attach them to forward_batch.
return forward_batch
def _setup_kv_cache(self, forward_batch, layer, cache_len):
"""Set up KV cache with prefix tokens."""
if cache_len <= 0:
return
# For MLA, create separate nope and rope caches
cache_k_nope = torch.ones(
self.batch_size * cache_len,
1, # latent cache has only one head in MQA
self.kv_lora_rank,
dtype=self.dtype,
device=self.device,
)
cache_k_rope = torch.ones(
self.batch_size * cache_len,
1, # latent cache has only one head in MQA
self.qk_rope_head_dim,
dtype=self.dtype,
device=self.device,
)
# Set the prefix KV cache using MLA-specific method
self.model_runner.token_to_kv_pool.set_mla_kv_buffer(
layer,
torch.arange(self.batch_size * cache_len, device=self.device),
cache_k_nope,
cache_k_rope,
)
def _run_attention_test(self, mode, q_len, prefix_len=0):
"""
Run an attention test with the specified parameters.
Args:
mode: ForwardMode.EXTEND or ForwardMode.DECODE
q_len: Length of the query sequence. For decode mode, q_len is 1.
prefix_len: Length of the prefix sequence for extend mode
"""
layer = self._create_attention_layer()
# Create forward batch and set up
forward_batch = self._create_forward_batch(mode, q_len, prefix_len)
# Create q, kv_compressed for testing
q_shape = (self.batch_size * q_len, self.num_heads, self.qk_head_dim)
kv_shape = (self.batch_size * q_len, self.qk_head_dim)
q = torch.randn(q_shape, dtype=self.dtype, device=self.device)
kv_compressed = torch.randn(kv_shape, dtype=self.dtype, device=self.device)
# For MLA, split kv_compressed into k_nope and k_rope
# k_nope has dimension kv_lora_rank, k_rope has dimension qk_rope_head_dim
k_nope = kv_compressed[:, : self.kv_lora_rank]
k_rope = kv_compressed[:, self.kv_lora_rank :]
# k_nope needs to be unsqueezed for the num_heads dimension
k = k_nope.unsqueeze(1)
# k_rope also needs to be unsqueezed
k_rope = k_rope.unsqueeze(1)
# v is not used for mqa
v = torch.randn((1), dtype=self.dtype, device=self.device)
self._setup_kv_cache(forward_batch, layer, prefix_len)
self.backend.init_forward_metadata(forward_batch)
expected_shape = (
self.batch_size * q_len,
self.num_heads * self.kv_lora_rank,
)
if mode == ForwardMode.EXTEND:
output = self.backend.forward_extend(
q, k, v, layer, forward_batch, k_rope=k_rope
)
else:
output = self.backend.forward_decode(
q, k, v, layer, forward_batch, k_rope=k_rope
)
self._verify_output(output, expected_shape)
return output
def test_forward_extend(self):
"""Test the standard extend operation."""
self._run_attention_test(ForwardMode.EXTEND, q_len=self.seq_len)
def test_forward_decode(self):
"""Test the decode operation with cached tokens."""
self._run_attention_test(ForwardMode.DECODE, q_len=1)
def test_forward_extend_with_prefix(self):
"""Test extending from cached prefix tokens."""
prefix_len = self.seq_len // 2
extend_len = self.seq_len - prefix_len
self._run_attention_test(
ForwardMode.EXTEND, q_len=extend_len, prefix_len=prefix_len
)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
import os
import unittest
from types import SimpleNamespace
import requests
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_LOCAL_ATTENTION,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
# Local attention with FA3 (requires SM 90+ / H100, tp=4)
@unittest.skipIf(get_device_sm() < 90, "Test requires CUDA SM 90 or higher")
class TestFlashAttention3LocalAttn(CustomTestCase):
model = DEFAULT_MODEL_NAME_FOR_TEST_LOCAL_ATTENTION
base_url = DEFAULT_URL_FOR_TEST
accuracy_threshold = 0.90
@classmethod
def get_server_args(cls):
return [
"--cuda-graph-max-bs-decode",
"2",
"--attention-backend",
"fa3",
"--tp",
"4",
"--context-length",
"1000000",
]
@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(),
env=os.environ,
)
@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,
)
metrics = run_eval(args)
print(f"{metrics=}")
# Use the appropriate metric key based on the test class
metric_key = "score"
self.assertGreater(metrics[metric_key], self.accuracy_threshold)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,238 @@
import unittest
import torch
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
from sglang.srt.utils.common import get_device
from sglang.test.test_utils import CustomTestCase
TEST_CASES = [
# Sequence with same prefix lens
{
"batch_size": 3,
"prefix_lens": [64, 64, 64],
"max_chunk_capacity": 48,
"prefix_chunk_len": 16,
"num_prefix_chunks": 4,
"prefix_chunk_starts": torch.tensor(
[
[0, 0, 0],
[16, 16, 16],
[32, 32, 32],
[48, 48, 48],
],
dtype=torch.int32,
),
"prefix_chunk_seq_lens": torch.tensor(
[
[16, 16, 16],
[16, 16, 16],
[16, 16, 16],
[16, 16, 16],
],
dtype=torch.int32,
),
},
# Sequence with different prefix lens
{
"batch_size": 4,
"prefix_lens": [16, 32, 48, 64],
"max_chunk_capacity": 64,
"prefix_chunk_len": 16,
"num_prefix_chunks": 4,
"prefix_chunk_starts": torch.tensor(
[
[0, 0, 0, 0],
[16, 16, 16, 16],
[32, 32, 32, 32],
[48, 48, 48, 48],
],
dtype=torch.int32,
),
"prefix_chunk_seq_lens": torch.tensor(
[
[16, 16, 16, 16],
[0, 16, 16, 16],
[0, 0, 16, 16],
[0, 0, 0, 16],
],
dtype=torch.int32,
),
},
# Sequence with irregular shapes
{
"batch_size": 2,
"prefix_lens": [1, 64],
"max_chunk_capacity": 31,
"prefix_chunk_len": 15,
"num_prefix_chunks": 5,
"prefix_chunk_starts": torch.tensor(
[
[0, 0],
[15, 15],
[30, 30],
[45, 45],
[60, 60],
],
dtype=torch.int32,
),
"prefix_chunk_seq_lens": torch.tensor(
[
[1, 15],
[0, 15],
[0, 15],
[0, 15],
[0, 4],
],
dtype=torch.int32,
),
},
]
class MockForwardBatch(ForwardBatch):
def __init__(self, max_chunk_capacity: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_chunk_capacity = max_chunk_capacity
def get_max_chunk_capacity(self):
return self.max_chunk_capacity
class MockReqToTokenPool:
def __init__(self, batch_size, seq_len, device):
self.req_to_token = (
torch.arange(batch_size * seq_len, device=device)
.reshape(batch_size, seq_len)
.to(torch.int32)
)
# Test correctness of triton kernel for computing kv indices
def check_kv_indices(forward_batch, req_to_token_pool):
for i in range(forward_batch.num_prefix_chunks):
computed_kv_indices = forward_batch.prefix_chunk_kv_indices[i]
req_to_token = req_to_token_pool.req_to_token[: forward_batch.batch_size, :]
ref_kv_indices = torch.empty(
forward_batch.prefix_chunk_num_tokens[i],
dtype=torch.int32,
device=computed_kv_indices.device,
)
running_ptr = 0
for j in range(forward_batch.batch_size):
seq_start = forward_batch.prefix_chunk_starts[i, j].item()
seq_len = forward_batch.prefix_chunk_seq_lens[i, j].item()
ref_kv_indices[running_ptr : running_ptr + seq_len].copy_(
req_to_token[j, seq_start : seq_start + seq_len]
)
running_ptr += seq_len
assert torch.allclose(computed_kv_indices, ref_kv_indices)
@unittest.skipIf(
not (torch.cuda.is_available() or torch.xpu.is_available()),
"Test requires CUDA or XPU",
)
class TestPrefixChunkInfo(CustomTestCase):
def setUp(self):
# Common test parameters
self.num_local_heads = 128
self.kv_lora_rank = 512
self.qk_rope_head_dim = 64
self.device = get_device()
self.dtype = torch.bfloat16
self.extend_len = 64
self.max_bs = 4
self.max_seq_len = 128
# req_to_token_pool
self.req_to_token_pool = MockReqToTokenPool(
self.max_bs,
self.max_seq_len,
self.device,
)
# token_to_kv_pool
self.token_to_kv_pool = MLATokenToKVPool(
size=self.max_bs * self.max_seq_len,
page_size=1, # only consider page=1 for unit test
dtype=self.dtype,
kv_lora_rank=self.kv_lora_rank,
qk_rope_head_dim=self.qk_rope_head_dim,
layer_num=1, # only consider layer=1 for unit test
device=self.device,
enable_memory_saver=False,
)
def test_prefix_chunk_info(self):
"""Test the standard extend operation."""
for test_case in TEST_CASES:
print(
f"Test case with batch_size={test_case['batch_size']}, prefix_lens={test_case['prefix_lens']}, max_chunk_capacity={test_case['max_chunk_capacity']}"
)
batch_size = test_case["batch_size"]
prefix_lens_cpu = test_case["prefix_lens"]
assert len(prefix_lens_cpu) == batch_size
prefix_lens = torch.tensor(prefix_lens_cpu, device=self.device)
max_chunk_capacity = test_case["max_chunk_capacity"]
seq_lens_cpu = [
self.extend_len + prefix_lens_cpu[i] for i in range(batch_size)
]
seq_lens = torch.tensor(seq_lens_cpu, device=self.device)
# Create forward batch
# input_ids and out_cache_loc are dummy tensors in this test
forward_batch = MockForwardBatch(
max_chunk_capacity=max_chunk_capacity,
batch_size=batch_size,
input_ids=torch.randint(
0, 100, (batch_size, self.extend_len), device=self.device
),
out_cache_loc=torch.arange(
self.max_bs * self.max_seq_len - batch_size * self.extend_len,
self.max_bs * self.max_seq_len,
device=self.device,
),
seq_lens_sum=sum(seq_lens_cpu),
forward_mode=ForwardMode.EXTEND,
req_pool_indices=torch.arange(batch_size, device=self.device),
seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu,
extend_prefix_lens=prefix_lens,
extend_prefix_lens_cpu=prefix_lens_cpu,
)
# Pool refs are resolved via the active ForwardContext; mock an
# attn_backend that carries the pools (Pattern A invariant).
from types import SimpleNamespace
from sglang.srt.model_executor.forward_context import (
ForwardContext,
set_forward_context,
)
mock_backend = SimpleNamespace(
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool=self.token_to_kv_pool,
)
set_forward_context(ForwardContext(attn_backend=mock_backend))
forward_batch.prepare_chunked_prefix_cache_info(self.device)
assert forward_batch.get_max_chunk_capacity() == max_chunk_capacity
assert forward_batch.prefix_chunk_len == test_case["prefix_chunk_len"]
assert forward_batch.num_prefix_chunks == test_case["num_prefix_chunks"]
assert torch.allclose(
forward_batch.prefix_chunk_starts,
test_case["prefix_chunk_starts"].to(self.device),
)
assert torch.allclose(
forward_batch.prefix_chunk_seq_lens,
test_case["prefix_chunk_seq_lens"].to(self.device),
)
check_kv_indices(forward_batch, self.req_to_token_pool)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestPDBase
class TestChunkedFeatureDisagg(ChunkedTestPDBase):
__test__ = True
use_kv_canary = False
gsm8k_threshold = 0.50
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,23 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
from sglang.test.test_utils import DEFAULT_MLA_MODEL_NAME_FOR_TEST
class TestChunkedFeatureDPAttention(ChunkedTestBase):
__test__ = True
use_kv_canary = False
model = DEFAULT_MLA_MODEL_NAME_FOR_TEST
gsm8k_threshold = 0.50
feature_args = [
"--trust-remote-code",
"--tp",
"2",
"--enable-dp-attention",
"--dp",
"2",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from sglang.test.chunked_prefill_test_utils import (
LONG_PROMPT_NUM_SHOTS,
ChunkedTestBase,
)
class TestChunkedFeatureHybridSWA(ChunkedTestBase):
__test__ = True
model = "openai/gpt-oss-20b"
num_shots = LONG_PROMPT_NUM_SHOTS
gsm8k_threshold = 0.50
feature_args = [
"--mem-fraction-static",
"0.70",
"--disable-piecewise-cuda-graph",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
class TestChunkedFeatureLoRA(ChunkedTestBase):
__test__ = True
model = "meta-llama/Llama-3.2-1B-Instruct"
gsm8k_threshold = 0.20
feature_args = [
"--enable-lora",
"--lora-paths",
"nicoboss/Llama-3.2-1B-Instruct-Uncensored-Lora",
"codelion/Llama-3.2-1B-Instruct-tool-calling-lora",
"--max-loras-per-batch",
"2",
"--max-loaded-loras",
"4",
"--mem-fraction-static",
"0.75",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,26 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
class TestChunkedFeatureLoRAOverlap(ChunkedTestBase):
__test__ = True
model = "meta-llama/Llama-3.2-1B-Instruct"
gsm8k_threshold = 0.20
feature_args = [
"--enable-lora",
"--enable-lora-overlap-loading",
"--lora-paths",
"nicoboss/Llama-3.2-1B-Instruct-Uncensored-Lora",
"codelion/Llama-3.2-1B-Instruct-tool-calling-lora",
"--max-loras-per-batch",
"2",
"--max-loaded-loras",
"4",
"--mem-fraction-static",
"0.75",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,18 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
class TestChunkedFeaturePageSize(ChunkedTestBase):
__test__ = True
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
gsm8k_threshold = 0.30
feature_args = [
"--page-size",
"16",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestPDBase
class TestChunkedFeaturePDPP(ChunkedTestPDBase):
__test__ = True
gsm8k_threshold = 0.50
feature_args = [
"--tp-size",
"2",
"--pp-size",
"2",
"--disable-overlap-schedule",
]
decode_feature_args = [
"--tp-size",
"2",
"--base-gpu-id",
"4",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,16 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
class TestChunkedFeaturePiecewiseCudaGraph(ChunkedTestBase):
__test__ = True
use_kv_canary = False
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
gsm8k_threshold = 0.30
feature_args = []
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,19 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
class TestChunkedFeaturePP(ChunkedTestBase):
__test__ = True
gsm8k_threshold = 0.50
feature_args = [
"--tp-size",
"2",
"--pp-size",
"2",
"--enable-dynamic-chunking",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,21 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
class TestChunkedFeaturePriority(ChunkedTestBase):
__test__ = True
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
gsm8k_threshold = 0.30
feature_args = [
"--enable-priority-scheduling",
"--max-running-requests",
"8",
"--max-queued-requests",
"128",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,20 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
class TestChunkedFeatureRadix(ChunkedTestBase):
__test__ = True
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
gsm8k_threshold = 0.30
feature_args = [
"--max-total-tokens",
"20000",
"--schedule-policy",
"fcfs",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,28 @@
import unittest
from sglang.test.chunked_prefill_test_utils import ChunkedTestBase
class TestChunkedFeatureSpec(ChunkedTestBase):
__test__ = True
use_kv_canary = False
model = "Qwen/Qwen3-8B"
gsm8k_threshold = 0.50
feature_args = [
"--speculative-algorithm",
"EAGLE3",
"--speculative-draft-model-path",
"Tengyunw/qwen3_8b_eagle3",
"--speculative-num-steps",
"5",
"--speculative-eagle-topk",
"8",
"--speculative-num-draft-tokens",
"64",
"--mem-fraction-static",
"0.7",
]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,643 @@
import unittest
from sglang.srt.environ import envs
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.req_handle import ScriptedReqHandle
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
SMALL_KV_POOL_BALLAST_PROMPT_LEN,
SMALL_KV_POOL_MAX_TOTAL_TOKENS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
def _drain_until_released(t: ScriptedContext, *handles: ScriptedReqHandle):
for _ in range(12):
if all(
h.kv_pages == 0
and h.lock_refs == 0
and (h.req is None or h.req.req_pool_idx is None)
for h in handles
):
return
yield
class TestAbortBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_abort_waiting_chunked_resume(self):
self.server.execute_script(self._script_abort_waiting_chunked_resume)
@staticmethod
def _script_abort_waiting_chunked_resume(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=100
)
yield from run_until(r, lambda h: h.is_chunking)
pages_before = r.kv_pages
assert pages_before > 0, "chunked req should own KV pages mid-chunk"
t.abort(r)
yield from _drain_until_released(t, r)
assert r.status in (
"finished",
"unknown",
), f"after abort r should be finished/unknown, got {r.status}"
assert (
r.kv_pages == 0
), f"abort must release KV; r.kv_pages={r.kv_pages} after abort"
assert (
r.req is None or r.req.req_pool_idx is None
), f"abort must release row; r.req={r.req} after abort"
assert (
r.lock_refs == 0
), f"abort must release lock_refs; r.lock_refs={r.lock_refs}"
def test_abort_at_chunk_0(self):
self.server.execute_script(self._script_abort_at_chunk_0)
@staticmethod
def _script_abort_at_chunk_0(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=110
)
yield
yield from run_until(r, lambda h: h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
def test_abort_at_chunk_mid(self):
self.server.execute_script(self._script_abort_at_chunk_mid)
@staticmethod
def _script_abort_at_chunk_mid(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=120
)
yield from run_until(r, lambda h: h.chunks_done >= 2 and h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
def test_abort_one_does_not_disturb_other(self):
self.server.execute_script(self._script_abort_one_does_not_disturb_other)
@staticmethod
def _script_abort_one_does_not_disturb_other(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=130
)
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=131
)
yield from run_until(r1, lambda h: h.is_chunking)
t.abort(r1)
yield from _drain_until_released(t, r1)
assert r1.kv_pages == 0
yield from run_until_finished(r2)
assert r2.finished, "r2 should still complete after r1 is aborted"
def test_abort_with_zero_yield(self):
self.server.execute_script(self._script_abort_with_zero_yield)
@staticmethod
def _script_abort_with_zero_yield(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
assert r.lock_refs == 0
def test_abort_at_admission_step(self):
self.server.execute_script(self._script_abort_at_admission_step)
@staticmethod
def _script_abort_at_admission_step(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
def test_abort_then_start_same_step_new_rid(self):
self.server.execute_script(self._script_abort_then_start_same_step_new_rid)
@staticmethod
def _script_abort_then_start_same_step_new_rid(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=140
)
yield from run_until(r1, lambda h: h.is_chunking)
t.abort(r1)
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=141
)
yield from run_until_finished(r2)
assert r2.finished
assert r1.kv_pages == 0
def test_abort_then_start_same_step_same_rid(self):
self.server.execute_script(self._script_abort_then_start_same_step_same_rid)
@staticmethod
def _script_abort_then_start_same_step_same_rid(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
rid="abort-reuse",
prompt_token=150,
)
yield from run_until(r1, lambda h: h.is_chunking)
t.abort(r1)
yield
r2 = t.start_req(prompt_len=16, max_new_tokens=2, rid="abort-reuse")
yield from run_until_finished(r2)
assert r2.finished
def test_abort_five_chunked_in_a_row(self):
self.server.execute_script(self._script_abort_five_chunked_in_a_row)
@staticmethod
def _script_abort_five_chunked_in_a_row(t: ScriptedContext):
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=160 + i
)
for i in range(5)
]
yield from run_until(reqs[0], lambda h: h.is_chunking)
for r in reqs:
t.abort(r)
yield from _drain_until_released(t, *reqs)
for r in reqs:
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
def test_abort_unknown_rid_noop(self):
self.server.execute_script(self._script_abort_unknown_rid_noop)
@staticmethod
def _script_abort_unknown_rid_noop(t: ScriptedContext):
bogus = ScriptedReqHandle(rid="never-submitted-rid", context=t)
t.abort(bogus, await_arrival=False)
yield
r = t.start_req(prompt_len=16, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
for _ in range(12):
if r.kv_pages == 0 and r.lock_refs == 0:
break
yield
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_abort_after_finish_noop(self):
self.server.execute_script(self._script_abort_after_finish_noop)
@staticmethod
def _script_abort_after_finish_noop(t: ScriptedContext):
r = t.start_req(prompt_len=16, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
for _ in range(12):
if t.is_fully_idle:
break
yield
assert r.kv_pages == 0
assert r.lock_refs == 0
kv_pool_free_before = t.engine_stats()["kv_pool_free"]
t.abort(r, await_arrival=False)
yield
assert r.kv_pages == 0
assert r.lock_refs == 0
kv_pool_free_after = t.engine_stats()["kv_pool_free"]
assert kv_pool_free_after == kv_pool_free_before, (
f"abort-after-finish must not move KV pool; "
f"before={kv_pool_free_before} after={kv_pool_free_after}"
)
def test_abort_chunk_last(self):
self.server.execute_script(self._script_abort_chunk_last)
@staticmethod
def _script_abort_chunk_last(t: ScriptedContext):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=4, prompt_token=170
)
yield from run_until(r, lambda h: h.chunks_done >= 1 and h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.inflight_middle_chunks == 0
def test_abort_penultimate_chunk(self):
self.server.execute_script(self._script_abort_penultimate_chunk)
@staticmethod
def _script_abort_penultimate_chunk(t: ScriptedContext):
r = t.start_req(
prompt_len=4 * DEFAULT_CHUNK_SIZE, max_new_tokens=2, prompt_token=180
)
yield from run_until(r, lambda h: h.chunks_done >= 2 and h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
assert r.lock_refs == 0
def test_double_abort_idempotent(self):
self.server.execute_script(self._script_double_abort_idempotent)
@staticmethod
def _script_double_abort_idempotent(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=190
)
yield from run_until(r, lambda h: h.is_chunking)
t.abort(r)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_abort_during_decode(self):
self.server.execute_script(self._script_abort_during_decode)
@staticmethod
def _script_abort_during_decode(t: ScriptedContext):
r = t.start_req(prompt_len=16, max_new_tokens=64)
yield from run_until(r, lambda h: h.status == "running")
assert r.kv_pages > 0, "decode req must own KV before abort"
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_abort_one_of_three_others_finish(self):
self.server.execute_script(self._script_abort_one_of_three_others_finish)
@staticmethod
def _script_abort_one_of_three_others_finish(t: ScriptedContext):
r1 = t.start_req(prompt_len=16, max_new_tokens=4)
r2 = t.start_req(prompt_len=16, max_new_tokens=4)
r3 = t.start_req(prompt_len=16, max_new_tokens=4)
yield from run_until(r2, lambda h: h.status == "running")
t.abort(r2)
yield from run_until_all_finished([r1, r3])
assert r2.kv_pages == 0
def test_abort_in_separate_yields(self):
self.server.execute_script(self._script_abort_in_separate_yields)
@staticmethod
def _script_abort_in_separate_yields(t: ScriptedContext):
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=200 + i
)
for i in range(3)
]
yield from run_until(reqs[0], lambda h: h.is_chunking)
for r in reqs:
t.abort(r)
yield
yield from _drain_until_released(t, *reqs)
for r in reqs:
assert r.kv_pages == 0
def test_abort_at_chunk_boundary_race(self):
self.server.execute_script(self._script_abort_at_chunk_boundary_race)
@staticmethod
def _script_abort_at_chunk_boundary_race(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=210
)
yield from run_until(r, lambda h: h.is_chunking)
yield from run_until(r, lambda h: h.chunks_done >= 1 and h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
chunks_after_abort = r.chunks_done
yield
yield
assert not r.is_chunking, "aborted req must not resume chunking"
assert r.chunks_done == chunks_after_abort, (
f"aborted req revived and ran another chunk; "
f"chunks_done went {chunks_after_abort} -> {r.chunks_done}"
)
assert r.req is None or r.req.req_pool_idx is None
def test_abort_mid_chunk_no_extra_radix_node(self):
self.server.execute_script(self._script_abort_mid_chunk_no_extra_radix_node)
@staticmethod
def _script_abort_mid_chunk_no_extra_radix_node(
t: ScriptedContext,
):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=220
)
yield from run_until(r, lambda h: h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
chunks_after_release = r.chunks_done
for _ in range(4):
yield
assert r.chunks_done == chunks_after_release, (
f"aborted mid-chunk req revived after release; chunks_done went "
f"{chunks_after_release} -> {r.chunks_done}"
)
lock_refs_after = t.get_all_node_lock_refs()
assert all(ref == 0 for ref in lock_refs_after.values()), (
f"abort mid-chunk left a locked radix node behind; "
f"node lock_refs={lock_refs_after!r}"
)
def test_abort_then_resubmit_same_rid_same_step(self):
self.server.execute_script(self._script_abort_then_resubmit_same_rid_same_step)
@staticmethod
def _script_abort_then_resubmit_same_rid_same_step(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
rid="abort-resubmit-same-step",
prompt_token=230,
)
yield from run_until(r1, lambda h: h.is_chunking)
t.abort(r1)
r2 = t.start_req(
prompt_len=16,
max_new_tokens=2,
rid="abort-resubmit-same-step",
)
yield
yield from run_until_finished(r2)
assert r2.finished, "resubmit under same rid must complete independently"
assert r1.kv_pages == 0, "aborted r1 must release KV before resubmit"
assert r1.req is None or r1.req.req_pool_idx is None
assert r1.lock_refs == 0
def test_abort_during_gap_inflight_middle_chunks_positive(self):
self.server.execute_script(
self._script_abort_during_gap_inflight_middle_chunks_positive
)
@staticmethod
def _script_abort_during_gap_inflight_middle_chunks_positive(t: ScriptedContext):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2, prompt_token=240
)
yield from run_until(
r,
lambda h: h.is_chunking and h.chunks_done >= 1,
)
assert r.req.inflight_middle_chunks > 0
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
assert not r.is_chunking, "aborted gap req must not re-enter chunking"
yield
assert not r.is_chunking, "aborted gap req must stay out of chunking"
if r.req is not None:
assert (
r.req.inflight_middle_chunks == 0
), f"inflight_middle_chunks not cleared; got {r.req.inflight_middle_chunks}"
def test_abort_when_chunked_only_then_idle(self):
self.server.execute_script(self._script_abort_when_chunked_only_then_idle)
@staticmethod
def _script_abort_when_chunked_only_then_idle(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=250
)
yield from run_until(r, lambda h: h.is_chunking)
assert (1 if t.scheduler.chunked_req is not None else 0) == 1
t.abort(r)
yield from _drain_until_released(t, r)
for _ in range(12):
if t.scheduler.chunked_req is None and t.is_idle:
break
yield
assert r.kv_pages == 0
assert (1 if t.scheduler.chunked_req is not None else 0) == 0
assert t.is_idle, "engine must be idle after the only chunked req is aborted"
def test_chunked_req_then_abort_then_new_short_in_one_yield(self):
self.server.execute_script(
self._script_chunked_req_then_abort_then_new_short_in_one_yield
)
@staticmethod
def _script_chunked_req_then_abort_then_new_short_in_one_yield(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=260
)
yield from run_until(r1, lambda h: h.is_chunking)
assert (
t.scheduler.chunked_req.rid if t.scheduler.chunked_req is not None else None
) == r1.rid, (
f"r1 should hold the chunked slot before abort; got "
f"{(t.scheduler.chunked_req.rid if t.scheduler.chunked_req is not None else None)!r}"
)
t.abort(r1)
r2 = t.start_req(prompt_len=16, max_new_tokens=2)
yield from _drain_until_released(t, r1)
cur = (
t.scheduler.chunked_req.rid if t.scheduler.chunked_req is not None else None
)
assert cur != r1.rid, f"chunked slot still points to aborted r1; got {cur!r}"
assert r1.kv_pages == 0
yield from run_until_finished(r2)
assert r2.finished, "fresh r2 must admit and complete after combo step"
def test_force_retract_then_abort_same_yield(self):
self.server.execute_script(self._script_force_retract_then_abort_same_yield)
@staticmethod
def _script_force_retract_then_abort_same_yield(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=270
)
yield from run_until(r1, lambda h: h.is_chunking)
assert r1.kv_pages > 0
t.pause_generation(mode="retract")
t.abort(r1)
yield from _drain_until_released(t, r1)
assert r1.kv_pages == 0, (
f"force_retract + abort same yield must release KV; got " f"{r1.kv_pages}"
)
assert r1.req is None or r1.req.req_pool_idx is None, (
f"force_retract + abort same yield must release row; got " f"{r1.req}"
)
assert r1.lock_refs == 0, (
f"force_retract + abort same yield must release lock_refs; "
f"got {r1.lock_refs}"
)
yield
t.continue_generation()
def test_abort_chunked_with_baton_handoff(self):
self.server.execute_script(self._script_abort_chunked_with_baton_handoff)
@staticmethod
def _script_abort_chunked_with_baton_handoff(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=280
)
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=281
)
yield from run_until(r1, lambda h: h.is_chunking)
assert (1 if t.scheduler.chunked_req is not None else 0) == 1
t.abort(r1)
yield from _drain_until_released(t, r1)
yield from run_until(r2, lambda h: h.is_chunking)
assert r1.kv_pages == 0
assert r1.req is None or r1.req.req_pool_idx is None
assert r1.lock_refs == 0
yield from run_until_finished(r2)
assert r2.finished, "baton handoff must let r2 complete"
assert r2.lock_refs == 0
class TestAbortPP(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
pp_size=2,
)
def test_abort_at_last_chunk_in_flight_pp(self):
self.server.execute_script(self._script_abort_at_last_chunk_in_flight_pp)
@staticmethod
def _script_abort_at_last_chunk_in_flight_pp(t: ScriptedContext):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=4, prompt_token=290
)
yield from run_until(
r,
lambda h: h.chunks_done >= 1 and h.is_chunking,
)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
assert r.lock_refs == 0
assert r.finished
class TestAbortSmallPool(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
max_total_tokens=SMALL_KV_POOL_MAX_TOTAL_TOKENS,
)
def test_waiting_timeout_sweep_aborts_pressured_waiting_req(self):
self.server.execute_script(
self._script_waiting_timeout_sweep_aborts_pressured_waiting_req
)
@staticmethod
def _script_waiting_timeout_sweep_aborts_pressured_waiting_req(t: ScriptedContext):
b1 = t.start_req(
prompt_len=SMALL_KV_POOL_BALLAST_PROMPT_LEN,
max_new_tokens=SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
ignore_eos=True,
prompt_token=300,
)
b2 = t.start_req(
prompt_len=SMALL_KV_POOL_BALLAST_PROMPT_LEN,
max_new_tokens=SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
ignore_eos=True,
prompt_token=301,
)
yield from run_until(b1, lambda h: h.status == "running")
yield from run_until(b2, lambda h: h.status == "running")
r = t.start_req(prompt_len=16, max_new_tokens=2)
def waiting_rids():
return {req.rid for req in t.scheduler.waiting_queue}
yield from run_until(r, lambda h: r.rid in waiting_rids())
assert r.kv_pages == 0, "pressured waiting req must not own KV before admission"
with envs.SGLANG_REQ_WAITING_TIMEOUT.override(1e-6):
for _ in range(DEFAULT_MAX_STEPS):
if r.rid not in waiting_rids():
break
yield
else:
raise AssertionError(
f"waiting-timeout sweep never removed the req from "
f"waiting_queue after {DEFAULT_MAX_STEPS} steps; "
f"waiting_rids={waiting_rids()!r}"
)
assert r.rid not in waiting_rids(), (
f"the loop's waiting-timeout sweep must drop the timed-out waiting "
f"req from waiting_queue; got {waiting_rids()!r}"
)
assert r.status in ("finished", "unknown"), (
f"swept-out req must be aborted (gone from every live scheduler "
f"structure); got status={r.status!r}"
)
assert r.kv_pages == 0, "timeout-abort of an unadmitted req owns no KV"
t.abort(b1)
t.abort(b2)
yield from _drain_until_released(t, b1, b2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,361 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
base_engine_kwargs,
run_until,
run_until_finished,
)
def _expected_chunks(prompt_len: int, chunk_size: int) -> int:
if prompt_len <= chunk_size:
return 0
return (prompt_len + chunk_size - 1) // chunk_size
class TestChunkSizeDefault(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_exact_chunk_size(self):
self.server.execute_script(self._script_exact_chunk_size)
@staticmethod
def _script_exact_chunk_size(t: ScriptedContext):
r = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0, (
f"prompt_len == chunk_size completes in one non-chunked shot, "
f"got chunks_done={r.chunks_done}"
)
def test_one_token_over(self):
self.server.execute_script(self._script_one_token_over)
@staticmethod
def _script_one_token_over(t: ScriptedContext):
r = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE + 1, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2, (
f"prompt_len == chunk_size + 1 expected exactly 2 chunks, "
f"got chunks_done={r.chunks_done}"
)
def test_two_chunks_exact(self):
self.server.execute_script(self._script_two_chunks_exact)
@staticmethod
def _script_two_chunks_exact(t: ScriptedContext):
r = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2
def test_three_chunks_one_decode(self):
self.server.execute_script(self._script_three_chunks_one_decode)
@staticmethod
def _script_three_chunks_one_decode(t: ScriptedContext):
r = t.start_req(prompt_len=3 * DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 3
def test_tiny_prompt(self):
self.server.execute_script(self._script_tiny_prompt)
@staticmethod
def _script_tiny_prompt(t: ScriptedContext):
r = t.start_req(prompt_len=1, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert (
r.chunks_done == 0
), f"single-token prompt should not chunk, got chunks_done={r.chunks_done}"
def test_chunk_size_256_prompt_100x(self):
self.server.execute_script(self._script_chunk_size_256_prompt_100x)
@staticmethod
def _script_chunk_size_256_prompt_100x(t: ScriptedContext):
r = t.start_req(prompt_len=100 * 256, max_new_tokens=2)
yield from run_until(r, lambda h: h.finished, max_steps=4000)
assert r.finished
assert r.chunks_done == 100
def test_prompt_n_chunks_plus_minus_1(self):
self.server.execute_script(self._script_prompt_n_chunks_plus_minus_1)
@staticmethod
def _script_prompt_n_chunks_plus_minus_1(t: ScriptedContext):
for n in range(1, 6):
prompt_minus = n * DEFAULT_CHUNK_SIZE - 1
r_minus = t.start_req(
prompt_len=prompt_minus, max_new_tokens=1, prompt_token=2 * n
)
yield from run_until_finished(r_minus, max_steps=800)
assert r_minus.finished
expected_minus = _expected_chunks(prompt_minus, DEFAULT_CHUNK_SIZE)
assert r_minus.chunks_done == expected_minus, (
f"N={n} prompt_len={prompt_minus}: "
f"expected chunks_done={expected_minus}, got {r_minus.chunks_done}"
)
prompt_plus = n * DEFAULT_CHUNK_SIZE + 1
r_plus = t.start_req(
prompt_len=prompt_plus, max_new_tokens=1, prompt_token=2 * n + 1
)
yield from run_until_finished(r_plus, max_steps=800)
assert r_plus.finished
expected_plus = _expected_chunks(prompt_plus, DEFAULT_CHUNK_SIZE)
assert r_plus.chunks_done == expected_plus, (
f"N={n} prompt_len={prompt_plus}: "
f"expected chunks_done={expected_plus}, got {r_plus.chunks_done}"
)
class TestChunkSizeDefaultRadixExplicit(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
disable_radix_cache=False,
)
def test_radix_hit_minus_one(self):
self.server.execute_script(self._script_radix_hit_minus_one)
@staticmethod
def _script_radix_hit_minus_one(t: ScriptedContext):
r_warm = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE - 1, max_new_tokens=1)
yield from run_until_finished(r_warm)
assert r_warm.finished
yield
r = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0, (
f"radix hit should leave only 1 fresh token; chunked path should "
f"not engage. Got chunks_done={r.chunks_done}"
)
class TestChunkSize1(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=1)
def test_chunk_size_one_token(self):
self.server.execute_script(self._script_chunk_size_one_token)
@staticmethod
def _script_chunk_size_one_token(t: ScriptedContext):
r = t.start_req(prompt_len=8, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 8, f"expected 8 chunks, got {r.chunks_done}"
def test_chunks_done_monotone_under_chunk_size_1(self):
self.server.execute_script(self._script_chunks_done_monotone_under_chunk_size_1)
@staticmethod
def _script_chunks_done_monotone_under_chunk_size_1(t: ScriptedContext):
r = t.start_req(prompt_len=16, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 16, (
f"chunk_size=1 with prompt_len=16 chunks one token at a time "
f"(ceil(16/1)=16), got chunks_done={r.chunks_done}"
)
def test_chunk_size_one_long_prompt(self):
self.server.execute_script(self._script_chunk_size_one_long_prompt)
@staticmethod
def _script_chunk_size_one_long_prompt(t: ScriptedContext):
r = t.start_req(prompt_len=64, max_new_tokens=2)
yield from run_until(r, lambda h: h.finished, max_steps=500)
assert r.finished
assert r.chunks_done == 64
class TestChunkSize2(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=2)
def test_chunk_size_two_prompt_two(self):
self.server.execute_script(self._script_chunk_size_two_prompt_two)
@staticmethod
def _script_chunk_size_two_prompt_two(t: ScriptedContext):
r = t.start_req(prompt_len=2, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
def test_chunk_size_two_prompt_five(self):
self.server.execute_script(self._script_chunk_size_two_prompt_five)
@staticmethod
def _script_chunk_size_two_prompt_five(t: ScriptedContext):
r = t.start_req(prompt_len=5, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 3, f"expected 3 chunks, got {r.chunks_done}"
class TestChunkSize4(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=4)
def test_chunk_size_4_prompt_4(self):
self.server.execute_script(self._script_chunk_size_4_prompt_4)
@staticmethod
def _script_chunk_size_4_prompt_4(t: ScriptedContext):
r = t.start_req(prompt_len=4, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
def test_chunk_size_4_prompt_5(self):
self.server.execute_script(self._script_chunk_size_4_prompt_5)
@staticmethod
def _script_chunk_size_4_prompt_5(t: ScriptedContext):
r = t.start_req(prompt_len=5, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2
class TestChunkSize16(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=16)
def test_chunk_size_16_prompt_1024(self):
self.server.execute_script(self._script_chunk_size_16_prompt_1024)
@staticmethod
def _script_chunk_size_16_prompt_1024(t: ScriptedContext):
r = t.start_req(prompt_len=1024, max_new_tokens=2)
yield from run_until(r, lambda h: h.finished, max_steps=1000)
assert r.finished
assert r.chunks_done == 64
class TestChunkSize32(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=32)
def test_chunk_size_32_prompt_33(self):
self.server.execute_script(self._script_chunk_size_32_prompt_33)
@staticmethod
def _script_chunk_size_32_prompt_33(t: ScriptedContext):
r = t.start_req(prompt_len=33, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2
class TestChunkSize64(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=64)
def test_chunk_size_64_prompt_129(self):
self.server.execute_script(self._script_chunk_size_64_prompt_129)
@staticmethod
def _script_chunk_size_64_prompt_129(t: ScriptedContext):
r = t.start_req(prompt_len=129, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 3
class TestChunkSize128(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=128)
def test_chunk_size_128_prompt_512(self):
self.server.execute_script(self._script_chunk_size_128_prompt_512)
@staticmethod
def _script_chunk_size_128_prompt_512(t: ScriptedContext):
r = t.start_req(prompt_len=512, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 4
class TestChunkSize1024(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=1024)
def test_chunk_size_1024_prompt_4096(self):
self.server.execute_script(self._script_chunk_size_1024_prompt_4096)
@staticmethod
def _script_chunk_size_1024_prompt_4096(t: ScriptedContext):
r = t.start_req(prompt_len=4096, max_new_tokens=2)
yield from run_until(r, lambda h: h.finished, max_steps=1000)
assert r.finished
assert r.chunks_done == 4
class TestChunkSize4096(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=4096)
def test_chunk_size_4096_prompt_4097(self):
self.server.execute_script(self._script_chunk_size_4096_prompt_4097)
@staticmethod
def _script_chunk_size_4096_prompt_4097(t: ScriptedContext):
r = t.start_req(prompt_len=4097, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2
class TestChunkSize1024MaxPrefill1024(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=1024, max_prefill_tokens=1024
)
def test_chunk_size_equals_max_prefill_tokens(self):
self.server.execute_script(self._script_chunk_size_equals_max_prefill_tokens)
@staticmethod
def _script_chunk_size_equals_max_prefill_tokens(t: ScriptedContext):
r = t.start_req(prompt_len=1024, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
class TestChunkSize2048MaxPrefill1024(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=2048, max_prefill_tokens=1024
)
def test_chunk_size_exceeds_max_prefill_tokens(self):
self.server.execute_script(self._script_chunk_size_exceeds_max_prefill_tokens)
@staticmethod
def _script_chunk_size_exceeds_max_prefill_tokens(t: ScriptedContext):
r = t.start_req(prompt_len=512, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
def test_prompt_between_max_prefill_and_chunk_size(self):
self.server.execute_script(
self._script_prompt_between_max_prefill_and_chunk_size
)
@staticmethod
def _script_prompt_between_max_prefill_and_chunk_size(t: ScriptedContext):
r = t.start_req(prompt_len=1536, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,49 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
base_engine_kwargs,
run_until_finished,
)
class TestScriptedHttpSmoke(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_chunked_req_is_chunking_then_finishes(self):
self.server.execute_script(self._script_chunked_req_is_chunking_then_finishes)
@staticmethod
def _script_chunked_req_is_chunking_then_finishes(t: ScriptedContext):
r = t.start_req(prompt_len=4 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
saw_chunking = False
for _ in range(800):
if r.is_chunking:
saw_chunking = True
if r.finished:
break
yield
assert r.finished
assert (
saw_chunking
), "expected the req to hold the chunked_req slot at least once"
def test_two_reqs_finish(self):
self.server.execute_script(self._script_two_reqs_finish)
@staticmethod
def _script_two_reqs_finish(t: ScriptedContext):
r1 = t.start_req(prompt_len=8, max_new_tokens=4)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until_finished(r2)
assert r1.finished
assert r2.finished
assert r1.chunks_done == 0
assert r2.chunks_done == 2
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,180 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_finished,
)
_SWA_MODEL = "openai/gpt-oss-20b"
_SWA_WINDOW = 4096
class TestSWABasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_SWA_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
mem_fraction_static=0.70,
disable_piecewise_cuda_graph=True,
)
def test_naive_swa_chunked(self):
self.server.execute_script(self._script_naive_swa_chunked)
@staticmethod
def _script_naive_swa_chunked(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN + 4096, max_new_tokens=4)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 24
assert len(r.req.output_ids) == 4
def test_swa_prompt_equals_window(self):
self.server.execute_script(self._script_swa_prompt_equals_window)
@staticmethod
def _script_swa_prompt_equals_window(t: ScriptedContext):
r = t.start_req(prompt_len=_SWA_WINDOW, max_new_tokens=4)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_swa_budget_for_chunked_req_math(self):
self.server.execute_script(self._script_swa_budget_for_chunked_req_math)
@staticmethod
def _script_swa_budget_for_chunked_req_math(t: ScriptedContext):
baseline_free = t.engine_stats()["kv_pool_free"]
r = t.start_req(prompt_len=_SWA_WINDOW + 13, max_new_tokens=2)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2
assert r.kv_pages == 0
assert r.lock_refs == 0
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
assert t.engine_stats()["kv_pool_free"] >= baseline_free, (
"SWA pool failed to recover after a window-straddling chunked req: "
f"baseline={baseline_free}, "
f"final={t.engine_stats()['kv_pool_free']}"
)
def test_swa_chunked_resume_under_swa_pressure(self):
self.server.execute_script(self._script_swa_chunked_resume_under_swa_pressure)
@staticmethod
def _script_swa_chunked_resume_under_swa_pressure(t: ScriptedContext):
r = t.start_req(prompt_len=_SWA_WINDOW + VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
chunks_at_pressure = r.chunks_done
t.exhaust_kv(leave_pages=1000)
yield from run_until_finished(r, max_steps=2000)
assert r.finished
assert r.chunks_done > chunks_at_pressure, (
f"chunked prefill stalled under SWA pressure: chunks_done="
f"{r.chunks_done} did not advance past chunks_at_pressure="
f"{chunks_at_pressure}"
)
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_swa_chunked_resume_kv_committed_bound(self):
self.server.execute_script(self._script_swa_chunked_resume_kv_committed_bound)
@staticmethod
def _script_swa_chunked_resume_kv_committed_bound(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(400):
if r.is_chunking:
assert len(r.req.prefix_indices) <= r.req.kv_committed_len, (
f"prefix_indices must be bounded by kv_committed_len, "
f"got prefix_indices_len={len(r.req.prefix_indices)}, "
f"kv_committed_len={r.req.kv_committed_len}"
)
if r.finished:
break
yield
assert r.finished
class TestSWAHalfWindowChunk(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_SWA_MODEL,
chunked_prefill_size=_SWA_WINDOW // 2,
mem_fraction_static=0.70,
disable_piecewise_cuda_graph=True,
)
def test_swa_prompt_2x_window_half_chunks(self):
self.server.execute_script(self._script_swa_prompt_2x_window_half_chunks)
@staticmethod
def _script_swa_prompt_2x_window_half_chunks(t: ScriptedContext):
r = t.start_req(prompt_len=2 * _SWA_WINDOW, max_new_tokens=4)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert (
r.chunks_done >= 4
), f"expected >=4 chunks for 2*window / (window/2), got {r.chunks_done}"
assert len(r.req.output_ids) == 4
class TestSWAChunkSizeExceedsWindow(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_SWA_MODEL,
chunked_prefill_size=_SWA_WINDOW * 2,
mem_fraction_static=0.70,
disable_piecewise_cuda_graph=True,
)
def test_swa_chunk_size_exceeds_window(self):
self.server.execute_script(self._script_swa_chunk_size_exceeds_window)
@staticmethod
def _script_swa_chunk_size_exceeds_window(t: ScriptedContext):
r = t.start_req(prompt_len=3 * _SWA_WINDOW, max_new_tokens=2)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 2
class TestSWARadix(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_SWA_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
mem_fraction_static=0.70,
disable_radix_cache=False,
disable_piecewise_cuda_graph=True,
)
def test_swa_radix_partial_hit_straddles_window(self):
self.server.execute_script(self._script_swa_radix_partial_hit_straddles_window)
@staticmethod
def _script_swa_radix_partial_hit_straddles_window(t: ScriptedContext):
r1 = t.start_req(prompt_len=_SWA_WINDOW + DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until_finished(r1, max_steps=800)
assert r1.finished
r2 = t.start_req(
prompt_len=_SWA_WINDOW + DEFAULT_CHUNK_SIZE * 2, max_new_tokens=2
)
yield from run_until_finished(r2, max_steps=800)
assert r2.finished
assert (
r2.req.cached_tokens > 0
), f"r2 must hit the radix prefix, got cached_tokens={r2.req.cached_tokens}"
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,512 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
class TestInvariantsBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_kv_pages_zero_after_finish(self):
self.server.execute_script(self._script_kv_pages_zero_after_finish)
@staticmethod
def _script_kv_pages_zero_after_finish(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r)
assert r.kv_pages == 0
def test_kv_pages_positive_continuously_mid_chunk(self):
self.server.execute_script(
self._script_kv_pages_positive_continuously_mid_chunk
)
@staticmethod
def _script_kv_pages_positive_continuously_mid_chunk(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
observed_chunking = False
for _ in range(DEFAULT_MAX_STEPS):
if r.is_chunking:
observed_chunking = True
assert (
r.kv_pages > 0
), f"kv_pages must be > 0 while is_chunking; got {r.kv_pages}"
if r.finished:
break
yield
assert observed_chunking, "test must observe at least one mid-chunk iter"
assert r.finished
def test_batch_composition_consistent_with_status(self):
self.server.execute_script(
self._script_batch_composition_consistent_with_status
)
@staticmethod
def _script_batch_composition_consistent_with_status(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(DEFAULT_MAX_STEPS):
if r.status == "running":
comp = t.batch_composition()
all_rids = (
comp.get("prefill", [])
+ comp.get("decode", [])
+ comp.get("chunked", [])
)
assert (
r.rid in all_rids
), f"running but not in batch_composition: {comp}"
if r.finished:
return
yield
raise AssertionError("req never finished")
def test_active_reqs_listing(self):
self.server.execute_script(self._script_active_reqs_listing)
@staticmethod
def _script_active_reqs_listing(t: ScriptedContext):
r1 = t.start_req(prompt_len=16, max_new_tokens=4)
r2 = t.start_req(prompt_len=16, max_new_tokens=4)
yield
actives = t.list_active_reqs()
rids = {h.rid for h in actives}
assert r1.rid in rids or r2.rid in rids
yield from run_until_all_finished([r1, r2])
for _ in range(12):
actives_after = t.list_active_reqs()
if all(h.rid not in (r1.rid, r2.rid) for h in actives_after):
break
yield
actives_after = t.list_active_reqs()
assert all(h.rid not in (r1.rid, r2.rid) for h in actives_after)
def test_finished_means_chunks_done_stable(self):
self.server.execute_script(self._script_finished_means_chunks_done_stable)
@staticmethod
def _script_finished_means_chunks_done_stable(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r)
snap = r.chunks_done
for _ in range(10):
yield
assert r.chunks_done == snap
def test_finished_means_kv_pages_stays_zero(self):
self.server.execute_script(self._script_finished_means_kv_pages_stays_zero)
@staticmethod
def _script_finished_means_kv_pages_stays_zero(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r)
for _ in range(10):
yield
assert r.kv_pages == 0
def test_engine_stats_keys_present(self):
self.server.execute_script(self._script_engine_stats_keys_present)
@staticmethod
def _script_engine_stats_keys_present(t: ScriptedContext):
stats = t.engine_stats()
assert isinstance(stats, dict)
assert "kv_pool_free" in stats
assert "req_pool_free" in stats
yield
def test_kv_pool_recovers_to_baseline(self):
self.server.execute_script(self._script_kv_pool_recovers_to_baseline)
@staticmethod
def _script_kv_pool_recovers_to_baseline(t: ScriptedContext):
before = t.engine_stats()["kv_pool_free"]
reqs = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(8)]
yield from run_until_all_finished(reqs)
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
after = t.engine_stats()["kv_pool_free"]
assert after >= before
def test_hundred_reqs_no_leak(self):
self.server.execute_script(self._script_hundred_reqs_no_leak)
@staticmethod
def _script_hundred_reqs_no_leak(t: ScriptedContext):
baseline = t.engine_stats()
reqs = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(100)]
yield from run_until_all_finished(reqs, max_steps=4000)
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()
assert (
final["kv_pool_free"] >= baseline["kv_pool_free"]
), f"KV leak: {baseline['kv_pool_free']} -> {final['kv_pool_free']}"
assert final["req_pool_free"] >= baseline["req_pool_free"]
def test_long_lived_engine_reps_chunked(self):
self.server.execute_script(self._script_long_lived_engine_reps_chunked)
@staticmethod
def _script_long_lived_engine_reps_chunked(t: ScriptedContext):
baseline = t.engine_stats()
for _ in range(20):
reqs = [
t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(5)
]
yield from run_until_all_finished(reqs, max_steps=2000)
for r in reqs:
assert r.finished
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()
assert final["kv_pool_free"] >= baseline["kv_pool_free"]
def test_sustained_long_chunked_load(self):
self.server.execute_script(self._script_sustained_long_chunked_load)
@staticmethod
def _script_sustained_long_chunked_load(t: ScriptedContext):
expected_chunks_done = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
baseline_kv = t.engine_stats()["kv_pool_free"]
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=10 + i
)
for i in range(30)
]
yield from run_until_all_finished(reqs, max_steps=DEFAULT_MAX_STEPS * 20)
for r in reqs:
assert r.finished
assert r.chunks_done == expected_chunks_done, (
f"VERY_LONG_PROMPT_LEN must take exactly {expected_chunks_done} "
f"chunks; got chunks_done={r.chunks_done}"
)
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final_kv = t.engine_stats()["kv_pool_free"]
assert (
final_kv >= baseline_kv
), f"KV leak after sustained chunked load: {baseline_kv} -> {final_kv}"
def test_round_robin_short_and_chunked(self):
self.server.execute_script(self._script_round_robin_short_and_chunked)
@staticmethod
def _script_round_robin_short_and_chunked(t: ScriptedContext):
baseline = t.engine_stats()
for _ in range(5):
shorts = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(10)]
chunked = [
t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(1)
]
yield from run_until_all_finished(shorts + chunked, max_steps=2000)
for r in shorts + chunked:
assert r.finished
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()
assert final["kv_pool_free"] >= baseline["kv_pool_free"]
def test_long_decode_then_many_short(self):
self.server.execute_script(self._script_long_decode_then_many_short)
@staticmethod
def _script_long_decode_then_many_short(t: ScriptedContext):
expected_chunks_done = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
long_max_new_tokens = 256
long_decode = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=long_max_new_tokens,
ignore_eos=True,
)
shorts = [t.start_req(prompt_len=8, max_new_tokens=2) for _ in range(50)]
all_reqs = [long_decode] + shorts
yield from run_until_all_finished(all_reqs, max_steps=DEFAULT_MAX_STEPS * 20)
for r in all_reqs:
assert r.finished
assert long_decode.chunks_done == expected_chunks_done, (
f"long req must chunk across exactly {expected_chunks_done} chunks; "
f"got chunks_done={long_decode.chunks_done}"
)
assert len(long_decode.req.output_ids) == long_max_new_tokens, (
f"ignore_eos long req must decode exactly {long_max_new_tokens} "
f"tokens; got len(output_ids)={len(long_decode.req.output_ids)}"
)
def test_engine_stats_monotone_after_each_batch(self):
self.server.execute_script(self._script_engine_stats_monotone_after_each_batch)
@staticmethod
def _script_engine_stats_monotone_after_each_batch(t: ScriptedContext):
last = None
for _ in range(10):
reqs = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(8)]
yield from run_until_all_finished(reqs)
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
cur = t.engine_stats()["kv_pool_free"]
if last is not None:
assert cur >= last - 1, f"KV pool drifted: {last} -> {cur}"
last = cur
def test_inflight_middle_chunks_caps_at_one(self):
self.server.execute_script(self._script_inflight_middle_chunks_caps_at_one)
@staticmethod
def _script_inflight_middle_chunks_caps_at_one(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
running_max = 0
running_max_post_finish = 0
post_finish_samples = 0
for _ in range(DEFAULT_MAX_STEPS):
yield
req = r.req
cur = req.inflight_middle_chunks if req is not None else 0
running_max = max(running_max, cur)
if r.finished:
running_max_post_finish = max(running_max_post_finish, cur)
post_finish_samples += 1
if post_finish_samples >= 5:
break
assert r.finished, "req never finished"
assert running_max == 1, (
f"inflight_middle_chunks must reach exactly 1 across the chunked "
f"lifecycle (the cap from revert e875cd36e4); observed max={running_max}"
)
assert running_max_post_finish == 0, (
f"inflight_middle_chunks must be reset to 0 after finish; "
f"observed max post-finish={running_max_post_finish}"
)
def test_chunks_done_strictly_increases_no_plateaus(self):
self.server.execute_script(
self._script_chunks_done_strictly_increases_no_plateaus
)
@staticmethod
def _script_chunks_done_strictly_increases_no_plateaus(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
prev_chunks_done = r.chunks_done
prev_was_chunking = r.is_chunking
for _ in range(DEFAULT_MAX_STEPS):
yield
if r.finished:
return
cur_chunks_done = r.chunks_done
cur_is_chunking = r.is_chunking
if prev_was_chunking and cur_is_chunking:
assert cur_chunks_done > prev_chunks_done, (
f"chunks_done plateau between consecutive mid-chunk "
f"yields: {prev_chunks_done} -> {cur_chunks_done}"
)
prev_chunks_done = cur_chunks_done
prev_was_chunking = cur_is_chunking
raise AssertionError("req never finished")
def test_output_tokens_len_equals_max_new_tokens_chunked(self):
self.server.execute_script(
self._script_output_tokens_len_equals_max_new_tokens_chunked
)
@staticmethod
def _script_output_tokens_len_equals_max_new_tokens_chunked(t: ScriptedContext):
n: int = 8
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=n,
ignore_eos=True,
)
yield from run_until_finished(r)
assert r.finished
assert (
r.chunks_done >= 2
), f"VERY_LONG_PROMPT_LEN should chunk; got chunks_done={r.chunks_done}"
assert len(r.req.output_ids) == n, (
f"ignore_eos=True + max_new_tokens={n} must produce exactly "
f"{n} output tokens; got len(output_tokens)={len(r.req.output_ids)}"
)
def test_num_input_tokens_equals_prompt_len_for_chunked(self):
self.server.execute_script(
self._script_num_input_tokens_equals_prompt_len_for_chunked
)
@staticmethod
def _script_num_input_tokens_equals_prompt_len_for_chunked(t: ScriptedContext):
prompt_len: int = VERY_LONG_PROMPT_LEN
r = t.start_req(prompt_len=prompt_len, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2, (
f"VERY_LONG_PROMPT_LEN should chunk so this invariant is exercised "
f"on a real multi-chunk prefill; got chunks_done={r.chunks_done}"
)
assert r.remaining_prompt_tokens == 0, (
f"the whole prompt must be committed after a chunked finish; "
f"remaining_prompt_tokens={r.remaining_prompt_tokens}"
)
def test_chunked_in_flight_count_exactly_zero_after_finish(self):
self.server.execute_script(
self._script_chunked_in_flight_count_exactly_zero_after_finish
)
@staticmethod
def _script_chunked_in_flight_count_exactly_zero_after_finish(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
assert (1 if t.scheduler.chunked_req is not None else 0) == 1, (
f"chunked_in_flight_count should be 1 mid-chunk; got "
f"{(1 if t.scheduler.chunked_req is not None else 0)}"
)
yield from run_until_finished(r)
for _ in range(3):
yield
assert (1 if t.scheduler.chunked_req is not None else 0) == 0, (
f"chunked_in_flight_count must be 0 after finish; got "
f"{(1 if t.scheduler.chunked_req is not None else 0)}"
)
def test_extend_batch_idx_monotonic_invariant(self):
self.server.execute_script(self._script_extend_batch_idx_monotonic_invariant)
@staticmethod
def _script_extend_batch_idx_monotonic_invariant(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=64)
observed_regression: bool = False
yield from run_until(
r,
lambda h: (
h.req is not None
and not h.req.is_retracted
and h.req.extend_batch_idx > 0
and h.remaining_prompt_tokens == 0
and not h.finished
),
)
pre_retract_idx: int = r.req.extend_batch_idx
assert not r.req.is_retracted
t.pause_generation(mode="retract")
retracted = t.find_req_by_rid(r.rid)
assert retracted is not None, "retracted req must stay live in the queue"
if retracted.extend_batch_idx < pre_retract_idx:
observed_regression = True
assert retracted.is_retracted, (
f"extend_batch_idx regressed without retract flag: "
f"{pre_retract_idx} -> {retracted.extend_batch_idx}"
)
t.continue_generation()
prev_extend_batch_idx: int = -1
regressions: int = 0
for _ in range(DEFAULT_MAX_STEPS):
req = t.find_req_by_rid(r.rid)
if req is not None:
cur_extend_batch_idx = req.extend_batch_idx
if (
prev_extend_batch_idx >= 0
and cur_extend_batch_idx < prev_extend_batch_idx
):
regressions += 1
observed_regression = True
assert regressions == 1, (
f"extend_batch_idx regressed more than once for a single "
f"retract episode: "
f"{prev_extend_batch_idx} -> {cur_extend_batch_idx}"
)
prev_extend_batch_idx = cur_extend_batch_idx
if r.finished:
break
yield
assert r.finished, "req never finished"
assert observed_regression, (
"retract must reset extend_batch_idx, producing the regression this "
"test guards"
)
def test_inflight_decrement_only_on_final_invariant(self):
self.server.execute_script(
self._script_inflight_decrement_only_on_final_invariant
)
@staticmethod
def _script_inflight_decrement_only_on_final_invariant(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
prev_inflight: int = 0
prev_was_chunked_slot: bool = False
prev_finished: bool = False
observed_decrement: bool = False
for _ in range(DEFAULT_MAX_STEPS):
s = t.scheduler
req = t.find_req_by_rid(r.rid)
cur_inflight = req.inflight_middle_chunks if req is not None else 0
cur_is_chunked_slot = (
s.chunked_req is not None and s.chunked_req.rid == r.rid
)
cur_finished = req.finished() if req is not None else True
if cur_inflight < prev_inflight:
observed_decrement = True
slot_just_released = prev_was_chunked_slot and not cur_is_chunked_slot
finish_just_happened = (not prev_finished) and cur_finished
assert slot_just_released or finish_just_happened, (
f"inflight_middle_chunks decreased ({prev_inflight} -> "
f"{cur_inflight}) without chunked slot release or req "
f"finish; prev_was_chunked_slot={prev_was_chunked_slot}, "
f"cur_is_chunked_slot={cur_is_chunked_slot}, "
f"prev_finished={prev_finished}, cur_finished={cur_finished}"
)
prev_inflight = cur_inflight
prev_was_chunked_slot = cur_is_chunked_slot
prev_finished = cur_finished
if r.finished:
break
yield
assert r.finished
assert observed_decrement, (
"test must observe at least one inflight_middle_chunks decrement "
"across the chunked lifecycle"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,550 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
BALLAST_MAX_NEW_TOKENS,
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
SMALL_KV_POOL_BALLAST_PROMPT_LEN,
SMALL_KV_POOL_MAX_TOTAL_TOKENS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
class TestKVPressureBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_lock_refs_tight_concurrent_prefix(self):
self.server.execute_script(self._script_lock_refs_tight_concurrent_prefix)
@staticmethod
def _script_lock_refs_tight_concurrent_prefix(t: ScriptedContext):
warm_token = 7
warm_len = DEFAULT_CHUNK_SIZE
r_warm = t.start_req(
prompt_len=warm_len, max_new_tokens=1, prompt_token=warm_token
)
yield from run_until_finished(r_warm)
r_warm2 = t.start_req(prompt_len=warm_len, max_new_tokens=1, prompt_token=8)
yield from run_until_finished(r_warm2)
assert r_warm.finished
for _ in range(12):
if r_warm.lock_refs == 0:
break
yield
assert r_warm.lock_refs == 0
baseline_lock_refs = t.get_all_node_lock_refs()
t.exhaust_lock_refs(leave_refs=1)
yield
pinned_lock_refs = t.get_all_node_lock_refs()
assert any(
pinned_lock_refs.get(node_id, 0) > baseline_lock_refs.get(node_id, 0)
for node_id in pinned_lock_refs
), "exhaust_lock_refs(leave_refs=1) must pin at least one warm-prefix node"
r_long = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
prompt_token=warm_token,
)
yield from run_until(r_long, lambda h: h.is_chunking)
yield from run_until_finished(r_long, max_steps=2000)
assert r_long.finished
assert r_long.chunks_done >= 2, (
f"long req must really chunk under pinned cache; got chunks_done="
f"{r_long.chunks_done}"
)
assert (
r_long.lock_refs == 0
), f"req {r_long.rid} leaked {r_long.lock_refs} lock_refs after finish"
t._release_exhausted_pools()
final_lock_refs = t.get_all_node_lock_refs()
for node_id, baseline in baseline_lock_refs.items():
assert final_lock_refs.get(node_id, 0) == baseline, (
f"node {node_id} lock_ref leaked: baseline={baseline}, "
f"final={final_lock_refs.get(node_id, 0)}"
)
def test_kv_pressure_with_retract_resume(self):
self.server.execute_script(self._script_kv_pressure_with_retract_resume)
@staticmethod
def _script_kv_pressure_with_retract_resume(t: ScriptedContext):
baseline = t.engine_stats()["kv_pool_free"]
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=60
)
yield from run_until(r, lambda h: h.is_chunking)
chunks_before_retract = r.chunks_done
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until(
r,
lambda h: h.chunks_done > chunks_before_retract,
max_steps=2000,
)
yield from run_until_finished(r, max_steps=2000)
assert r.finished
assert r.kv_pages == 0
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert final >= baseline, (
f"KV pool failed to recover after retract+resume: "
f"baseline={baseline}, final={final}"
)
def test_chunked_batch_recovers_pools_to_steady_state(self):
self.server.execute_script(
self._script_chunked_batch_recovers_pools_to_steady_state
)
@staticmethod
def _script_chunked_batch_recovers_pools_to_steady_state(t: ScriptedContext):
before = t.engine_stats()
reqs = [
t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE + 1,
max_new_tokens=2,
prompt_token=500 + i,
)
for i in range(50)
]
yield from run_until_all_finished(reqs, max_steps=2000)
for r in reqs:
assert r.finished
assert r.kv_pages == 0, f"req {r.rid} kept {r.kv_pages} pages after finish"
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
after = t.engine_stats()
assert after["kv_pool_free"] >= before["kv_pool_free"], (
f"50 chunked reqs leaked KV: baseline={before['kv_pool_free']}, "
f"final={after['kv_pool_free']}"
)
assert after["req_pool_free"] >= before["req_pool_free"], (
f"50 chunked reqs leaked req-pool rows: "
f"baseline={before['req_pool_free']}, final={after['req_pool_free']}"
)
def test_chunked_retract_at_chunk_first_mid_last(self):
self.server.execute_script(self._script_chunked_retract_at_chunk_first_mid_last)
@staticmethod
def _script_chunked_retract_at_chunk_first_mid_last(t: ScriptedContext):
expected_chunks = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
mid_chunk = expected_chunks // 2
last_minus_one = max(1, expected_chunks - 1)
r_first = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=31
)
yield from run_until(r_first, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r_first, max_steps=2000)
assert r_first.finished
assert r_first.kv_pages == 0
r_mid = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=32
)
yield from run_until(r_mid, lambda h: h.chunks_done >= mid_chunk)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r_mid, max_steps=2000)
assert r_mid.finished
assert r_mid.kv_pages == 0
r_last = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=33
)
yield from run_until(r_last, lambda h: h.chunks_done >= last_minus_one)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r_last, max_steps=2000)
assert r_last.finished
assert r_last.kv_pages == 0
def test_flush_cache_during_chunked_in_flight(self):
self.server.execute_script(self._script_flush_cache_during_chunked_in_flight)
@staticmethod
def _script_flush_cache_during_chunked_in_flight(t: ScriptedContext):
r_warm = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=1, prompt_token=41
)
yield from run_until_finished(r_warm)
assert r_warm.finished
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=42
)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.flush_cache()
yield from run_until_finished(r, max_steps=2000)
assert r.finished
assert r.kv_pages == 0
def test_chunked_oscillation_three_force_retracts(self):
self.server.execute_script(
self._script_chunked_oscillation_three_force_retracts
)
@staticmethod
def _script_chunked_oscillation_three_force_retracts(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=70
)
yield from run_until(r, lambda h: h.is_chunking)
chunks_at_first = r.chunks_done
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until(
r,
lambda h: h.is_chunking and h.chunks_done >= chunks_at_first,
max_steps=800,
)
chunks_after_first_resume = r.chunks_done
assert chunks_after_first_resume >= chunks_at_first, (
f"chunks_done regressed across retract: "
f"before={chunks_at_first}, after={chunks_after_first_resume}"
)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until(
r,
lambda h: h.is_chunking and h.chunks_done >= chunks_after_first_resume,
max_steps=800,
)
chunks_after_second_resume = r.chunks_done
assert chunks_after_second_resume >= chunks_after_first_resume
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r, max_steps=2000)
assert r.finished
assert r.chunks_done >= chunks_after_second_resume
class TestKVPressureSmallRowPool(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
max_running_requests=8,
)
def test_row_pool_tight_admits_after_release(self):
self.server.execute_script(self._script_row_pool_tight_admits_after_release)
@staticmethod
def _script_row_pool_tight_admits_after_release(t: ScriptedContext):
baseline_rows_used = (
t.scheduler.req_to_token_pool.size
- t.scheduler.req_to_token_pool.available_size()
)
row_pool_size = t.scheduler.req_to_token_pool.size
ballast = [
t.start_req(
prompt_len=1, max_new_tokens=BALLAST_MAX_NEW_TOKENS, ignore_eos=True
)
for _ in range(row_pool_size)
]
for _ in range(DEFAULT_MAX_STEPS):
if t.scheduler.req_to_token_pool.available_size() == 0:
break
yield
assert t.scheduler.req_to_token_pool.available_size() == 0, (
f"ballast must hold every row; "
f"available={t.scheduler.req_to_token_pool.available_size()}"
)
reqs = [
t.start_req(prompt_len=8, max_new_tokens=1, prompt_token=50 + i)
for i in range(5)
]
for _ in range(6):
yield
for r in reqs:
assert r.status == "waiting", (
f"fresh req must be unschedulable under a full row pool; "
f"rid={r.rid}, status={r.status}"
)
for b in ballast:
t.abort(b)
yield from run_until_all_finished(reqs, max_steps=2000)
for r in reqs:
assert r.finished, f"req {r.rid} did not finish after release"
assert r.kv_pages == 0, (
f"row-pool pressure must not leave KV held: rid={r.rid}, "
f"kv_pages={r.kv_pages}"
)
for _ in range(40):
if t.is_fully_idle:
break
yield
final_rows_used = (
t.scheduler.req_to_token_pool.size
- t.scheduler.req_to_token_pool.available_size()
)
assert final_rows_used <= baseline_rows_used, (
f"row pool leak after admit-after-release: baseline used="
f"{baseline_rows_used}, final used={final_rows_used}"
)
class TestKVPressureSmallPool(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
max_total_tokens=SMALL_KV_POOL_MAX_TOTAL_TOKENS,
)
@staticmethod
def _start_ballast(t: ScriptedContext, *, prompt_token: int):
return t.start_req(
prompt_len=SMALL_KV_POOL_BALLAST_PROMPT_LEN,
max_new_tokens=SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
ignore_eos=True,
prompt_token=prompt_token,
)
def test_chunked_completes_when_ballast_retracted(self):
self.server.execute_script(
self._script_chunked_completes_when_ballast_retracted
)
@staticmethod
def _script_chunked_completes_when_ballast_retracted(t: ScriptedContext):
baseline = t.engine_stats()["kv_pool_free"]
r_chunk = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=600
)
yield from run_until(r_chunk, lambda h: h.is_chunking and h.chunks_done >= 1)
ballast = TestKVPressureSmallPool._start_ballast(t, prompt_token=601)
ballast_retracted = False
for _ in range(2000):
if ballast.status == "waiting":
ballast_retracted = True
if r_chunk.finished:
break
yield
assert r_chunk.finished, (
f"chunked req must complete once the engine retracts the ballast under "
f"real KV pressure; status={r_chunk.status}, kv_pages={r_chunk.kv_pages}"
)
assert r_chunk.kv_pages == 0, f"kv_pages={r_chunk.kv_pages}"
assert r_chunk.lock_refs == 0, f"lock_refs={r_chunk.lock_refs}"
ballast_resolved = (
ballast_retracted
or ballast.finished
or ballast.status in ("waiting", "finished", "unknown")
)
assert ballast_resolved, (
f"ballast must be retracted/aborted under pressure; "
f"status={ballast.status}, retracted={ballast_retracted}"
)
t.abort(ballast)
for _ in range(200):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert final >= baseline, (
f"KV pool not recovered after ballast-retract pressure: "
f"baseline={baseline}, final={final}"
)
def test_chunked_completes_under_ballast_then_aborts_chunked(self):
self.server.execute_script(
self._script_chunked_completes_under_ballast_then_aborts_chunked
)
@staticmethod
def _script_chunked_completes_under_ballast_then_aborts_chunked(
t: ScriptedContext,
):
baseline = t.engine_stats()["kv_pool_free"]
r_chunk = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=610
)
yield from run_until(r_chunk, lambda h: h.is_chunking and h.chunks_done >= 1)
ballast = TestKVPressureSmallPool._start_ballast(t, prompt_token=611)
for _ in range(6):
yield
t.abort(r_chunk)
for _ in range(12):
if (
r_chunk.kv_pages == 0
and r_chunk.lock_refs == 0
and (r_chunk.req is None or r_chunk.req.req_pool_idx is None)
):
break
yield
assert r_chunk.kv_pages == 0, f"kv_pages={r_chunk.kv_pages}"
assert r_chunk.lock_refs == 0, f"lock_refs={r_chunk.lock_refs}"
assert r_chunk.req is None or r_chunk.req.req_pool_idx is None
t.abort(ballast)
for _ in range(200):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert final >= baseline, (
f"KV pool not recovered after abort-under-pressure: "
f"baseline={baseline}, final={final}"
)
def test_kv_recovery_after_full(self):
self.server.execute_script(self._script_kv_recovery_after_full)
@staticmethod
def _script_kv_recovery_after_full(t: ScriptedContext):
baseline = t.engine_stats()["kv_pool_free"]
b1 = t.start_req(
prompt_len=SMALL_KV_POOL_BALLAST_PROMPT_LEN,
max_new_tokens=SMALL_KV_POOL_BALLAST_MAX_NEW_TOKENS,
ignore_eos=True,
prompt_token=620,
)
yield from run_until(b1, lambda h: h.status == "running")
big = t.start_req(prompt_len=2048, max_new_tokens=2, prompt_token=621)
yield from run_until(big, lambda h: h.is_chunking and h.chunks_done >= 2)
r = t.start_req(prompt_len=16, max_new_tokens=2)
yield
assert r.status == "waiting", (
f"16-token req must be unschedulable while the ballast and the "
f"mid-chunk req own the pool; status={r.status}"
)
t.abort(b1)
yield from run_until(big, lambda h: h.finished, max_steps=3000)
yield from run_until(r, lambda h: h.finished, max_steps=3000)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert final >= baseline, (
f"pool must recover to baseline after release: "
f"baseline={baseline}, final={final}"
)
class TestKVPressurePriority(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
)
def test_priority_preempt_multiple_chunked(self):
self.server.execute_script(self._script_priority_preempt_multiple_chunked)
@staticmethod
def _script_priority_preempt_multiple_chunked(t: ScriptedContext):
baseline = t.engine_stats()["kv_pool_free"]
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=11
)
yield from run_until(r1, lambda h: h.is_chunking)
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
priority=10,
prompt_token=12,
)
done = {r1.rid: False, r2.rid: False}
for _ in range(DEFAULT_MAX_STEPS * 4):
assert not (r1.is_chunking and r2.is_chunking), (
f"two reqs cannot share the chunked slot; "
f"r1.is_chunking={r1.is_chunking}, r2.is_chunking={r2.is_chunking}"
)
done[r1.rid] = done[r1.rid] or r1.finished
done[r2.rid] = done[r2.rid] or r2.finished
if all(done.values()):
break
yield
assert done[r1.rid] and done[r2.rid]
assert r1.kv_pages == 0
assert r2.kv_pages == 0
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert final >= baseline, (
f"KV pool not fully released after preemption: "
f"baseline={baseline}, final={final}"
)
class TestKVPressurePageSize(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
page_size=16,
)
def test_strict_mem_check_handles_chunked_tail(self):
self.server.execute_script(self._script_strict_mem_check_handles_chunked_tail)
@staticmethod
def _script_strict_mem_check_handles_chunked_tail(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN + 17, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r, max_steps=2000)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,440 @@
import unittest
from sglang.srt.managers.schedule_batch import FINISH_ABORT
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_finished,
)
def _drain_until_released(t, *handles):
for _ in range(12):
if all(
h.kv_pages == 0
and h.lock_refs == 0
and (h.req is None or h.req.req_pool_idx is None)
for h in handles
):
return
yield
class TestLifecycleBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_small_prompt_short_decode(self):
self.server.execute_script(self._script_small_prompt_short_decode)
@staticmethod
def _script_small_prompt_short_decode(t: ScriptedContext):
r = t.start_req(prompt_len=8, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
assert len(r.req.output_ids) == 2
def test_medium_prompt_medium_decode(self):
self.server.execute_script(self._script_medium_prompt_medium_decode)
@staticmethod
def _script_medium_prompt_medium_decode(t: ScriptedContext):
r = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=16, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
assert len(r.req.output_ids) == 16
def test_long_prompt_short_decode(self):
self.server.execute_script(self._script_long_prompt_short_decode)
@staticmethod
def _script_long_prompt_short_decode(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 8
assert len(r.req.output_ids) == 2
def test_long_prompt_long_decode(self):
self.server.execute_script(self._script_long_prompt_long_decode)
@staticmethod
def _script_long_prompt_long_decode(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=64, ignore_eos=True
)
yield from run_until(r, lambda h: h.finished, max_steps=1000)
assert r.finished
assert r.chunks_done == 8
assert len(r.req.output_ids) == 64
def test_tiny_prompt_long_decode(self):
self.server.execute_script(self._script_tiny_prompt_long_decode)
@staticmethod
def _script_tiny_prompt_long_decode(t: ScriptedContext):
r = t.start_req(prompt_len=1, max_new_tokens=64, ignore_eos=True)
yield from run_until(r, lambda h: h.finished, max_steps=500)
assert r.finished
assert r.chunks_done == 0
assert len(r.req.output_ids) == 64
def test_chunk_size_minus_one_prompt(self):
self.server.execute_script(self._script_chunk_size_minus_one_prompt)
@staticmethod
def _script_chunk_size_minus_one_prompt(t: ScriptedContext):
r = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE - 1, max_new_tokens=4, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
assert len(r.req.output_ids) == 4
def test_chunk_size_plus_two_prompt(self):
self.server.execute_script(self._script_chunk_size_plus_two_prompt)
@staticmethod
def _script_chunk_size_plus_two_prompt(t: ScriptedContext):
r = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE + 2, max_new_tokens=4, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 2
assert len(r.req.output_ids) == 4
def test_just_over_2x_chunk_size(self):
self.server.execute_script(self._script_just_over_2x_chunk_size)
@staticmethod
def _script_just_over_2x_chunk_size(t: ScriptedContext):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE + 1, max_new_tokens=4, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 3
assert len(r.req.output_ids) == 4
def test_five_x_chunk_size(self):
self.server.execute_script(self._script_five_x_chunk_size)
@staticmethod
def _script_five_x_chunk_size(t: ScriptedContext):
r = t.start_req(
prompt_len=5 * DEFAULT_CHUNK_SIZE, max_new_tokens=4, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 5
assert len(r.req.output_ids) == 4
def test_ten_x_chunk_size(self):
self.server.execute_script(self._script_ten_x_chunk_size)
@staticmethod
def _script_ten_x_chunk_size(t: ScriptedContext):
r = t.start_req(
prompt_len=10 * DEFAULT_CHUNK_SIZE, max_new_tokens=2, ignore_eos=True
)
yield from run_until(r, lambda h: h.finished, max_steps=1000)
assert r.finished
assert r.chunks_done == 10
assert len(r.req.output_ids) == 2
def test_status_progression_happy_path(self):
self.server.execute_script(self._script_status_progression_happy_path)
@staticmethod
def _script_status_progression_happy_path(t: ScriptedContext):
r = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
seen = []
for _ in range(DEFAULT_MAX_STEPS):
seen.append(r.status)
if r.finished:
break
yield
else:
raise AssertionError("req did not finish within DEFAULT_MAX_STEPS")
assert "running" in seen, f"never observed running status; seen={seen}"
assert seen[-1] == "finished", f"final status must be finished; seen={seen}"
finished_idx = seen.index("finished")
assert all(
s in ("finished",) for s in seen[finished_idx:]
), f"status regressed after finish; seen={seen}"
def test_long_prompt_only_one_decode(self):
self.server.execute_script(self._script_long_prompt_only_one_decode)
@staticmethod
def _script_long_prompt_only_one_decode(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=1, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 8
assert len(r.req.output_ids) == 1
def test_kv_pages_consistent_during_run(self):
self.server.execute_script(self._script_kv_pages_consistent_during_run)
@staticmethod
def _script_kv_pages_consistent_during_run(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4, ignore_eos=True
)
saw_positive = False
for _ in range(DEFAULT_MAX_STEPS):
pages = r.kv_pages
if pages > 0:
saw_positive = True
elif saw_positive and not r.finished:
raise AssertionError(
f"kv_pages collapsed to 0 mid-run before finish; "
f"saw_positive={saw_positive}, status={r.status!r}"
)
if r.finished:
break
yield
else:
raise AssertionError("req did not finish within DEFAULT_MAX_STEPS")
assert saw_positive
assert r.kv_pages == 0
assert len(r.req.output_ids) == 4
def test_row_idx_recycled_after_finish(self):
self.server.execute_script(self._script_row_idx_recycled_after_finish)
@staticmethod
def _script_row_idx_recycled_after_finish(t: ScriptedContext):
r = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert r.req.req_pool_idx is None
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_two_seq_clean_handoff(self):
self.server.execute_script(self._script_two_seq_clean_handoff)
@staticmethod
def _script_two_seq_clean_handoff(t: ScriptedContext):
r1 = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r1)
yield from _drain_until_released(t, r1)
assert r1.req.req_pool_idx is None and r1.kv_pages == 0 and r1.lock_refs == 0
r1_output_len = len(r1.req.output_ids)
r2 = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r2)
yield from _drain_until_released(t, r2)
assert r1.finished and r2.finished
assert r1_output_len == 2 and len(r2.req.output_ids) == 2
assert r2.req.req_pool_idx is None and r2.kv_pages == 0 and r2.lock_refs == 0
def test_five_seq_clean(self):
self.server.execute_script(self._script_five_seq_clean)
@staticmethod
def _script_five_seq_clean(t: ScriptedContext):
reqs = []
for _ in range(5):
r = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
assert r.req.req_pool_idx is None
assert r.kv_pages == 0
assert r.lock_refs == 0
reqs.append(r)
for r in reqs:
assert r.finished
def test_radix_partial_seq(self):
self.server.execute_script(self._script_radix_partial_seq)
@staticmethod
def _script_radix_partial_seq(t: ScriptedContext):
r1 = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=1, ignore_eos=True
)
yield from run_until_finished(r1)
r2 = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE + 32, max_new_tokens=2, ignore_eos=True
)
yield from run_until_finished(r2)
assert r1.finished and r2.finished
assert r2.chunks_done == 0
assert r2.req.cached_tokens > 0, (
f"r2 must hit r1's radix prefix; got cached_tokens="
f"{r2.req.cached_tokens}"
)
assert len(r2.req.output_ids) == 2
def test_alternating_short_long_seq(self):
self.server.execute_script(self._script_alternating_short_long_seq)
@staticmethod
def _script_alternating_short_long_seq(t: ScriptedContext):
for i in range(6):
prompt = 8 if i % 2 == 0 else VERY_LONG_PROMPT_LEN
r = t.start_req(
prompt_len=prompt,
max_new_tokens=2,
ignore_eos=True,
prompt_token=10 + i,
)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
assert r.req.req_pool_idx is None and r.kv_pages == 0 and r.lock_refs == 0
if prompt == VERY_LONG_PROMPT_LEN:
assert r.chunks_done == 8
else:
assert r.chunks_done == 0
def test_seq_with_growing_prompt(self):
self.server.execute_script(self._script_seq_with_growing_prompt)
@staticmethod
def _script_seq_with_growing_prompt(t: ScriptedContext):
for idx, L in enumerate([8, 32, 128, 512, 1024]):
r = t.start_req(
prompt_len=L, max_new_tokens=1, ignore_eos=True, prompt_token=10 + idx
)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 1
yield from _drain_until_released(t, r)
assert r.req is None or r.req.req_pool_idx is None
assert r.kv_pages == 0 and r.lock_refs == 0
if L > DEFAULT_CHUNK_SIZE:
assert (
r.chunks_done == (L + DEFAULT_CHUNK_SIZE - 1) // DEFAULT_CHUNK_SIZE
)
else:
assert r.chunks_done == 0
def test_seq_with_shrinking_prompt(self):
self.server.execute_script(self._script_seq_with_shrinking_prompt)
@staticmethod
def _script_seq_with_shrinking_prompt(t: ScriptedContext):
for idx, L in enumerate([1024, 512, 128, 32, 8]):
r = t.start_req(
prompt_len=L, max_new_tokens=1, ignore_eos=True, prompt_token=10 + idx
)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 1
yield from _drain_until_released(t, r)
assert r.req is None or r.req.req_pool_idx is None
assert r.kv_pages == 0 and r.lock_refs == 0
if L > DEFAULT_CHUNK_SIZE:
assert (
r.chunks_done == (L + DEFAULT_CHUNK_SIZE - 1) // DEFAULT_CHUNK_SIZE
)
else:
assert r.chunks_done == 0
def test_seq_with_idle_yields_between(self):
self.server.execute_script(self._script_seq_with_idle_yields_between)
@staticmethod
def _script_seq_with_idle_yields_between(t: ScriptedContext):
for _ in range(4):
r = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
assert r.req.req_pool_idx is None and r.kv_pages == 0 and r.lock_refs == 0
for _ in range(20):
yield
def test_chunked_then_short_seq(self):
self.server.execute_script(self._script_chunked_then_short_seq)
@staticmethod
def _script_chunked_then_short_seq(t: ScriptedContext):
seq = [VERY_LONG_PROMPT_LEN, 8, VERY_LONG_PROMPT_LEN, 8]
for idx, L in enumerate(seq):
r = t.start_req(
prompt_len=L, max_new_tokens=2, ignore_eos=True, prompt_token=10 + idx
)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
assert r.req.req_pool_idx is None and r.kv_pages == 0 and r.lock_refs == 0
if L == VERY_LONG_PROMPT_LEN:
assert r.chunks_done == 8
else:
assert r.chunks_done == 0
def test_seq_engine_stats_stable(self):
self.server.execute_script(self._script_seq_engine_stats_stable)
@staticmethod
def _script_seq_engine_stats_stable(t: ScriptedContext):
baseline = t.engine_stats()["kv_pool_free"]
for _ in range(5):
r = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
assert r.req.req_pool_idx is None and r.kv_pages == 0 and r.lock_refs == 0
for _ in range(5):
yield
t.flush_cache()
yield
final = t.engine_stats()["kv_pool_free"]
assert (
final >= baseline - 1
), f"KV pool drift: baseline={baseline}, final={final}"
def test_abort_all_during_chunked(self):
self.server.execute_script(self._script_abort_all_during_chunked)
@staticmethod
def _script_abort_all_during_chunked(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.abort_all()
def _error_message(h):
if h.req is None:
return None
return (
h.req.finished_reason.message
if isinstance(h.req.finished_reason, FINISH_ABORT)
else None
)
for _ in range(DEFAULT_MAX_STEPS):
if r.finished or _error_message(r) is not None:
break
yield
else:
raise AssertionError(
"chunked req did not terminate after abort_all within DEFAULT_MAX_STEPS"
)
assert r.finished or _error_message(r) is not None
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
assert r.lock_refs == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,240 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
_LORA_BASE_MODEL = "meta-llama/Llama-3.2-1B-Instruct"
_LORA_ADAPTER = "codelion/Llama-3.2-1B-Instruct-tool-calling-lora"
_LORA_ADAPTER_B = "nicoboss/Llama-3.2-1B-Instruct-Uncensored-Lora"
class TestLoRASingleAdapter(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER],
)
def test_naive_lora_chunked(self):
self.server.execute_script(self._script_naive_lora_chunked)
@staticmethod
def _script_naive_lora_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
lora_path=_LORA_ADAPTER,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert r.kv_pages == 0
assert r.lock_refs == 0
assert len(r.req.output_ids) == 4
def test_lora_logprob_chunked_pass_idx(self):
self.server.execute_script(self._script_lora_logprob_chunked_pass_idx)
@staticmethod
def _script_lora_logprob_chunked_pass_idx(t: ScriptedContext):
prompt_len: int = VERY_LONG_PROMPT_LEN
r = t.start_req(
prompt_len=prompt_len,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
return_logprob=True,
logprob_start_len=0,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == prompt_len // DEFAULT_CHUNK_SIZE
assert len(r.req.logprob.input_token_logprobs_val) == prompt_len
class TestLoRADrainerBypass(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
max_loras_per_batch=1,
)
def test_lora_drainer_does_not_block_chunked_resume(self):
self.server.execute_script(
self._script_lora_drainer_does_not_block_chunked_resume
)
@staticmethod
def _script_lora_drainer_does_not_block_chunked_resume(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
yield from run_until(r_a, lambda h: h.is_chunking and h.chunks_done >= 1)
chunks_before = r_a.chunks_done
r_b = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE // 2,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
for _ in range(200):
if r_a.chunks_done > chunks_before:
break
yield
else:
raise AssertionError(
f"chunked-resume r_a starved by LoRA drainer; "
f"chunks_done stuck at {chunks_before}"
)
yield from run_until_all_finished(handles=[r_a, r_b])
assert r_a.finished and r_b.finished
class TestLoRAAdapterSwitch(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
max_loras_per_batch=2,
)
def test_lora_adapter_switch_mid_chunk(self):
self.server.execute_script(self._script_lora_adapter_switch_mid_chunk)
@staticmethod
def _script_lora_adapter_switch_mid_chunk(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
r_b = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
yield from run_until_all_finished(handles=[r_a, r_b])
assert r_a.finished and r_b.finished
expected_chunks = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
assert r_a.chunks_done == expected_chunks
assert r_b.chunks_done == expected_chunks
class TestLoRAAllDistinctAdapters(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
max_loras_per_batch=2,
max_loaded_loras=4,
)
def test_lora_all_distinct_adapters_chunked(self):
self.server.execute_script(self._script_lora_all_distinct_adapters_chunked)
@staticmethod
def _script_lora_all_distinct_adapters_chunked(t: ScriptedContext):
adapters = [_LORA_ADAPTER, _LORA_ADAPTER_B, _LORA_ADAPTER, _LORA_ADAPTER_B]
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=adapter,
)
for adapter in adapters
]
yield from run_until_all_finished(handles=reqs, max_steps=2000)
assert all(r.finished for r in reqs)
for r in reqs:
assert r.kv_pages == 0
assert r.lock_refs == 0
expected_first_chunks = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
assert reqs[0].chunks_done == expected_first_chunks
assert reqs[1].chunks_done == expected_first_chunks
assert reqs[2].chunks_done < expected_first_chunks
assert reqs[3].chunks_done < expected_first_chunks
class TestLoRAAdapterEviction(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
max_loras_per_batch=1,
max_loaded_loras=2,
)
def test_lora_adapter_eviction_between_chunks(self):
self.server.execute_script(self._script_lora_adapter_eviction_between_chunks)
@staticmethod
def _script_lora_adapter_eviction_between_chunks(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
yield from run_until(r_a, lambda h: h.is_chunking and h.chunks_done >= 1)
r_b = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE // 2,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
yield from run_until_all_finished(handles=[r_a, r_b], max_steps=800)
assert r_a.finished and r_b.finished
def test_lora_chunked_abort_during_eviction(self):
self.server.execute_script(self._script_lora_chunked_abort_during_eviction)
@staticmethod
def _script_lora_chunked_abort_during_eviction(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
yield from run_until(r_a, lambda h: h.is_chunking and h.chunks_done >= 1)
r_b = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE // 2,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
yield
t.abort(r_a)
for _ in range(12):
if r_a.kv_pages == 0 and (r_a.req is None or r_a.req.req_pool_idx is None):
break
yield
assert r_a.status in ("finished", "unknown")
if r_a.req is not None:
assert r_a.kv_pages == 0
assert r_a.lock_refs == 0
yield from run_until_finished(r_b)
assert r_b.finished
assert r_b.kv_pages == 0
assert r_b.lock_refs == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,191 @@
import unittest
from sglang.srt.lora.lora_registry import LoRARef
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
_LORA_BASE_MODEL = "meta-llama/Llama-3.2-1B-Instruct"
_LORA_ADAPTER = "codelion/Llama-3.2-1B-Instruct-tool-calling-lora"
_LORA_ADAPTER_B = "nicoboss/Llama-3.2-1B-Instruct-Uncensored-Lora"
def _expected_lora_id(adapter_path: str) -> str:
return LoRARef.deterministic_id(adapter_path, adapter_path)
class TestLoRAOverlapSingleAdapter(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER],
enable_lora_overlap_loading=True,
max_loras_per_batch=1,
max_loaded_loras=1,
)
def test_naive_lora_overlap_chunked(self):
self.server.execute_script(self._script_naive_lora_overlap_chunked)
@staticmethod
def _script_naive_lora_overlap_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
lora_path=_LORA_ADAPTER,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
class TestLoRAOverlapH2dDuringChunk(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
enable_lora_overlap_loading=True,
max_loras_per_batch=2,
max_loaded_loras=2,
)
def test_lora_overlap_h2d_during_chunk_admit(self):
self.server.execute_script(self._script_lora_overlap_h2d_during_chunk_admit)
@staticmethod
def _script_lora_overlap_h2d_during_chunk_admit(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
yield from run_until(r_a, lambda h: h.is_chunking and h.chunks_done >= 1)
r_b = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
yield from run_until_all_finished(handles=[r_a, r_b], max_steps=1200)
assert r_a.finished and r_b.finished
expected_chunks = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
assert r_a.chunks_done == expected_chunks
assert r_b.chunks_done == expected_chunks
assert r_a.req.lora_id == _expected_lora_id(_LORA_ADAPTER)
assert r_b.req.lora_id == _expected_lora_id(_LORA_ADAPTER_B)
class TestLoRAOverlapAbortDuringH2d(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
enable_lora_overlap_loading=True,
max_loras_per_batch=2,
max_loaded_loras=2,
)
def test_lora_overlap_abort_during_h2d(self):
self.server.execute_script(self._script_lora_overlap_abort_during_h2d)
@staticmethod
def _script_lora_overlap_abort_during_h2d(t: ScriptedContext):
r_a = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER,
)
yield from run_until(r_a, lambda h: h.is_chunking and h.chunks_done >= 1)
r_b = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=_LORA_ADAPTER_B,
)
yield
b_loader = t.scheduler.lora_overlap_loader
b_lora_id = _expected_lora_id(_LORA_ADAPTER_B)
assert (
b_lora_id in b_loader.lora_to_overlap_load_event
or b_lora_id in b_loader.lora_manager.memory_pool.uid_to_buffer_id
), "adapter B never entered H2D; the abort would not exercise the H2D path"
t.abort(r_b)
yield
assert r_b.chunks_done == 0
assert r_b.req is None
assert r_b.status in ("finished", "unknown")
yield from run_until_finished(r_a, max_steps=800)
assert r_a.finished
assert r_a.req.lora_id == _expected_lora_id(_LORA_ADAPTER)
class TestLoRAOverlapAdapterRotation(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
model_path=_LORA_BASE_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_lora=True,
lora_paths=[_LORA_ADAPTER, _LORA_ADAPTER_B],
enable_lora_overlap_loading=True,
max_loras_per_batch=1,
max_loaded_loras=2,
)
def test_lora_overlap_back_to_back_adapters_chunked(self):
self.server.execute_script(
self._script_lora_overlap_back_to_back_adapters_chunked
)
@staticmethod
def _script_lora_overlap_back_to_back_adapters_chunked(t: ScriptedContext):
adapters = [_LORA_ADAPTER, _LORA_ADAPTER_B, _LORA_ADAPTER, _LORA_ADAPTER_B]
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
lora_path=adapter,
)
for adapter in adapters
]
expected_ids = [_expected_lora_id(adapter) for adapter in adapters]
lora_id_by_rid: dict = {}
done = [False] * len(reqs)
for _ in range(2400):
for i, r in enumerate(reqs):
req = r.req
if req is not None:
lora_id_by_rid[r.rid] = req.lora_id
done[i] = done[i] or r.finished
if all(done):
break
yield
assert all(done)
expected_first_chunks = VERY_LONG_PROMPT_LEN // DEFAULT_CHUNK_SIZE
chunk_counts = [r.chunks_done for r in reqs]
a_counts = sorted([reqs[0].chunks_done, reqs[2].chunks_done])
b_counts = sorted([reqs[1].chunks_done, reqs[3].chunks_done])
assert a_counts == [0, expected_first_chunks], (
f"adapter A pair must be one cold full prefill + one full prefix "
f"hit; per-req chunks_done={chunk_counts}"
)
assert b_counts == [0, expected_first_chunks], (
f"adapter B pair must be one cold full prefill + one full prefix "
f"hit; per-req chunks_done={chunk_counts}"
)
for r, expected_id in zip(reqs, expected_ids):
assert lora_id_by_rid.get(r.rid) == expected_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,167 @@
import unittest
from typing import List
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.scheduler_hook import ScriptedBatchRecord
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
base_engine_kwargs,
run_until,
run_until_finished,
)
def _records_for_rid(
batch_log: List[ScriptedBatchRecord], rid: str
) -> List[ScriptedBatchRecord]:
return [rec for rec in batch_log if rid in rec.rids]
def _decode_records(
batch_log: List[ScriptedBatchRecord], rid: str
) -> List[ScriptedBatchRecord]:
return [rec for rec in _records_for_rid(batch_log, rid) if rec.mode == "decode"]
def _extend_records(
batch_log: List[ScriptedBatchRecord], rid: str
) -> List[ScriptedBatchRecord]:
return [rec for rec in _records_for_rid(batch_log, rid) if rec.mode == "extend"]
class TestMaxNewTokensDecodeForwardLaw(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_decode_forward_count_equals_mnt(self):
self.server.execute_script(self._script_decode_forward_count_law)
@staticmethod
def _script_decode_forward_count_law(t: ScriptedContext):
for max_new_tokens in (1, 2, 3, 4):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE,
max_new_tokens=max_new_tokens,
ignore_eos=True,
prompt_token=10 + max_new_tokens,
)
yield from run_until_finished(r)
assert r.finished
output_ids = r.req.output_ids
assert len(output_ids) == max_new_tokens, (
f"max_new_tokens={max_new_tokens} must produce exactly "
f"{max_new_tokens} tokens; got {len(output_ids)}"
)
batch_log = t._scheduler_hook._batch_log
decode_records = _decode_records(batch_log, r.rid)
extend_records = _extend_records(batch_log, r.rid)
assert len(decode_records) == max_new_tokens, (
f"max_new_tokens={max_new_tokens} expected "
f"{max_new_tokens} decode forward batches, got "
f"{len(decode_records)}"
)
assert len(extend_records) >= 2, (
f"max_new_tokens={max_new_tokens} expected >= 2 extend (chunk) "
f"records, got {len(extend_records)}"
)
yield
class TestMaxNewTokensOneSkipsDecode(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_mnt_one_emits_token_on_prefill_then_one_dead_decode(self):
self.server.execute_script(self._script_mnt_one_skips_decode)
@staticmethod
def _script_mnt_one_skips_decode(t: ScriptedContext):
r = t.start_req(
prompt_len=3 * DEFAULT_CHUNK_SIZE,
max_new_tokens=1,
ignore_eos=True,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2, (
f"prompt spanning >=3 chunks should chunk at least twice, got "
f"chunks_done={r.chunks_done}"
)
assert len(r.req.output_ids) == 1, (
f"max_new_tokens=1 must produce exactly 1 token, got "
f"{len(r.req.output_ids)}"
)
batch_log = t._scheduler_hook._batch_log
decode_records = _decode_records(batch_log, r.rid)
assert len(decode_records) == 1, (
f"max_new_tokens=1 under overlap launches exactly ONE trailing decode "
f"forward whose token is discarded; got {len(decode_records)}"
)
rid_records = _records_for_rid(batch_log, r.rid)
assert rid_records, "expected at least one batch record for the req"
rid_modes = [rec.mode for rec in rid_records]
first_decode_pos = rid_modes.index("decode")
assert first_decode_pos >= 1, (
f"the lone decode must be preceded by an extend chunk; rid_modes="
f"{rid_modes}"
)
assert rid_modes[first_decode_pos - 1] in ("extend", "mixed"), (
f"the record immediately before the lone decode must be the final "
f"prefill chunk that emits the only token; got "
f"{rid_modes[first_decode_pos - 1]!r}, rid_modes={rid_modes}"
)
class TestMaxNewTokensFirstDecodeAdjacent(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_first_decode_immediately_follows_last_chunk(self):
self.server.execute_script(self._script_first_decode_adjacent)
@staticmethod
def _script_first_decode_adjacent(t: ScriptedContext):
max_new_tokens = 16
r = t.start_req(
prompt_len=3 * DEFAULT_CHUNK_SIZE,
max_new_tokens=max_new_tokens,
ignore_eos=True,
)
yield from run_until(r, lambda h: h.finished, max_steps=400)
assert r.finished
assert len(r.req.output_ids) == max_new_tokens, (
f"max_new_tokens={max_new_tokens} must produce exactly "
f"{max_new_tokens} tokens; got {len(r.req.output_ids)}"
)
batch_log = t._scheduler_hook._batch_log
rid_records = _records_for_rid(batch_log, r.rid)
decode_records = _decode_records(batch_log, r.rid)
assert len(decode_records) == max_new_tokens, (
f"expected {max_new_tokens} decode forwards, got " f"{len(decode_records)}"
)
rid_modes = [rec.mode for rec in rid_records]
first_decode_pos = rid_modes.index("decode")
assert first_decode_pos >= 1, (
f"first decode must be preceded by an extend chunk; rid_modes="
f"{rid_modes}"
)
assert rid_modes[first_decode_pos - 1] == "extend", (
f"record immediately before the first decode (in this rid's "
f"subsequence) must be the last extend chunk; got "
f"{rid_modes[first_decode_pos - 1]!r}, rid_modes={rid_modes}"
)
assert all(mode == "extend" for mode in rid_modes[:first_decode_pos]), (
f"all records before the first decode must be extend chunks; "
f"rid_modes={rid_modes}"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,537 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
BALLAST_MAX_NEW_TOKENS,
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
def _drain_flush_then_assert_no_kv_leak(t: ScriptedContext, baseline: dict):
for _ in range(5):
yield
t.flush_cache()
yield
final = t.engine_stats()
assert (
final["kv_pool_free"] >= baseline["kv_pool_free"]
), f"KV leak: {baseline['kv_pool_free']} -> {final['kv_pool_free']}"
class TestMultiReqBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_second_chunked_waits(self):
self.server.execute_script(self._script_second_chunked_waits)
@staticmethod
def _script_second_chunked_waits(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield
assert r1.is_chunking, "r1 should still be chunking"
assert (
not r2.is_chunking
), "r2 must wait for r1's chunk loop to clear before chunking"
yield from run_until_all_finished([r1, r2])
assert r1.finished and r2.finished
def test_hundred_short_reqs(self):
self.server.execute_script(self._script_hundred_short_reqs)
@staticmethod
def _script_hundred_short_reqs(t: ScriptedContext):
baseline = t.engine_stats()
reqs = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(100)]
yield from run_until_all_finished(reqs, max_steps=2000)
for r in reqs:
assert r.finished
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_five_hundred_short_reqs(self):
self.server.execute_script(self._script_five_hundred_short_reqs)
@staticmethod
def _script_five_hundred_short_reqs(t: ScriptedContext):
baseline = t.engine_stats()
reqs = [t.start_req(prompt_len=8, max_new_tokens=1) for _ in range(500)]
yield from run_until_all_finished(reqs, max_steps=20000)
for r in reqs:
assert r.finished
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_mixed_ten_chunked_ten_short(self):
self.server.execute_script(self._script_mixed_ten_chunked_ten_short)
@staticmethod
def _script_mixed_ten_chunked_ten_short(t: ScriptedContext):
baseline = t.engine_stats()
chunked = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=100 + i
)
for i in range(10)
]
shorts = [t.start_req(prompt_len=8, max_new_tokens=2) for _ in range(10)]
all_reqs = chunked + shorts
finished = False
for _ in range(DEFAULT_MAX_STEPS * 20):
assert sum(1 for r in all_reqs if r.is_chunking) <= 1
if all(r.finished for r in all_reqs):
finished = True
break
yield
if not finished:
raise AssertionError("not all reqs finished")
for r in chunked:
assert r.chunks_done >= 2
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_submit_during_chunk_mid(self):
self.server.execute_script(self._script_submit_during_chunk_mid)
@staticmethod
def _script_submit_during_chunk_mid(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield
r3 = t.start_req(prompt_len=16, max_new_tokens=2)
yield
assert r1.is_chunking, "r1 must still hold the single chunked slot"
assert not r2.is_chunking, "r2 must wait until r1's chunk loop clears"
assert not r3.is_chunking, "r3 must wait until r1's chunk loop clears"
yield from run_until_all_finished([r1, r2, r3])
assert r1.finished and r2.finished and r3.finished
def test_five_identical_prompts(self):
self.server.execute_script(self._script_five_identical_prompts)
@staticmethod
def _script_five_identical_prompts(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
others = [
t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(4)
]
yield from run_until_all_finished(others)
for r in others:
assert r.finished
assert r.chunks_done == 0
assert r1.finished
def test_sibling_shared_prefix(self):
self.server.execute_script(self._script_sibling_shared_prefix)
@staticmethod
def _script_sibling_shared_prefix(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN + 8, max_new_tokens=2)
yield from run_until_finished(r2)
assert r1.finished and r2.finished
assert (
r2.chunks_done < r1.chunks_done
), "r2 reuses r1's cached prefix, so it should chunk fewer times"
def test_trickle_per_yield_50(self):
self.server.execute_script(self._script_trickle_per_yield_50)
@staticmethod
def _script_trickle_per_yield_50(t: ScriptedContext):
baseline = t.engine_stats()
reqs = []
for _ in range(50):
reqs.append(t.start_req(prompt_len=8, max_new_tokens=2))
yield
yield from run_until_all_finished(reqs, max_steps=2000)
for r in reqs:
assert r.finished
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_submit_then_immediate_abort(self):
self.server.execute_script(self._script_submit_then_immediate_abort)
@staticmethod
def _script_submit_then_immediate_abort(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
t.abort(r)
for _ in range(5):
yield
assert r.kv_pages == 0
assert r.req is None or r.req.req_pool_idx is None
def test_rid_reuse_after_finish(self):
self.server.execute_script(self._script_rid_reuse_after_finish)
@staticmethod
def _script_rid_reuse_after_finish(t: ScriptedContext):
baseline = t.engine_stats()
r1 = t.start_req(prompt_len=16, max_new_tokens=2, rid="reuse-rid")
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=16, max_new_tokens=2, rid="reuse-rid")
yield from run_until_finished(r2)
assert r1.finished and r2.finished
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_submit_pause_n_resubmit_same_rid(self):
self.server.execute_script(self._script_submit_pause_n_resubmit_same_rid)
@staticmethod
def _script_submit_pause_n_resubmit_same_rid(t: ScriptedContext):
baseline = t.engine_stats()
r1 = t.start_req(prompt_len=16, max_new_tokens=2, rid="reuse-200")
yield from run_until_finished(r1)
for _ in range(200):
assert (1 if t.scheduler.chunked_req is not None else 0) == 0
yield
r2 = t.start_req(prompt_len=16, max_new_tokens=2, rid="reuse-200")
yield from run_until_finished(r2)
assert r2.finished
yield from _drain_flush_then_assert_no_kv_leak(t, baseline)
def test_submit_during_decode_of_other(self):
self.server.execute_script(self._script_submit_during_decode_of_other)
@staticmethod
def _script_submit_during_decode_of_other(t: ScriptedContext):
r1 = t.start_req(prompt_len=16, max_new_tokens=16)
yield from run_until(r1, lambda h: h.status == "running")
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r2, lambda h: h.is_chunking)
comp = t.batch_composition()
chunked_set = set(comp.get("chunked", []))
prefill_set = set(comp.get("prefill", []))
decode_set = set(comp.get("decode", []))
assert r2.rid in chunked_set, f"r2 should be the chunked req; got {comp}"
assert chunked_set.isdisjoint(prefill_set)
assert chunked_set.isdisjoint(decode_set)
yield from run_until_all_finished([r1, r2], max_steps=DEFAULT_MAX_STEPS * 5)
assert r1.finished and r2.finished
def test_two_small_parallel(self):
self.server.execute_script(self._script_two_small_parallel)
@staticmethod
def _script_two_small_parallel(t: ScriptedContext):
r1 = t.start_req(prompt_len=16, max_new_tokens=4)
r2 = t.start_req(prompt_len=16, max_new_tokens=4)
for _ in range(DEFAULT_MAX_STEPS):
assert (
t.scheduler.chunked_req.rid
if t.scheduler.chunked_req is not None
else None
) is None
if r1.finished and r2.finished:
break
yield
assert r1.finished and r2.finished
def test_one_chunked_plus_many_short(self):
self.server.execute_script(self._script_one_chunked_plus_many_short)
@staticmethod
def _script_one_chunked_plus_many_short(t: ScriptedContext):
chunked = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
shorts = [t.start_req(prompt_len=16, max_new_tokens=2) for _ in range(5)]
for _ in range(DEFAULT_MAX_STEPS * 5):
assert (1 if t.scheduler.chunked_req is not None else 0) <= 1
if chunked.finished and all(s.finished for s in shorts):
break
yield
assert chunked.chunks_done >= 2
assert chunked.finished
for s in shorts:
assert s.finished
def test_multiple_chunked_staggered(self):
self.server.execute_script(self._script_multiple_chunked_staggered)
@staticmethod
def _script_multiple_chunked_staggered(t: ScriptedContext):
reqs = []
for i in range(4):
reqs.append(
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
prompt_token=100 + i,
)
)
yield
assert sum(1 for r in reqs if r.is_chunking) <= 1
yield
for _ in range(DEFAULT_MAX_STEPS * 10):
assert sum(1 for r in reqs if r.is_chunking) <= 1
if all(r.finished for r in reqs):
break
yield
for r in reqs:
assert r.finished
assert r.chunks_done >= 2
def test_eight_concurrent_chunked(self):
self.server.execute_script(self._script_eight_concurrent_chunked)
@staticmethod
def _script_eight_concurrent_chunked(t: ScriptedContext):
reqs = [
t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=100 + i
)
for i in range(8)
]
finished = False
for _ in range(DEFAULT_MAX_STEPS * 5):
assert sum(1 for r in reqs if r.is_chunking) <= 1
if all(r.finished for r in reqs):
finished = True
break
yield
if not finished:
raise AssertionError("not all reqs finished")
for r in reqs:
assert r.chunks_done >= 2
def test_decode_only_batch(self):
self.server.execute_script(self._script_decode_only_batch)
@staticmethod
def _script_decode_only_batch(t: ScriptedContext):
reqs = [t.start_req(prompt_len=4, max_new_tokens=8) for _ in range(10)]
for _ in range(DEFAULT_MAX_STEPS * 3):
assert (
t.scheduler.chunked_req.rid
if t.scheduler.chunked_req is not None
else None
) is None, "pure decode workload must never populate chunked_req"
assert (1 if t.scheduler.chunked_req is not None else 0) == 0
if all(r.finished for r in reqs):
return
yield
raise AssertionError("not all reqs finished")
def test_mixed_prefill_lengths(self):
self.server.execute_script(self._script_mixed_prefill_lengths)
@staticmethod
def _script_mixed_prefill_lengths(t: ScriptedContext):
lens = [8, 16, 32, 64, 128, 256, 512, 1024]
reqs = [t.start_req(prompt_len=L, max_new_tokens=2) for L in lens]
by_len = dict(zip(lens, reqs))
for _ in range(DEFAULT_MAX_STEPS * 10):
assert sum(1 for r in reqs if r.is_chunking) <= 1
if all(r.finished for r in reqs):
break
yield
for r in reqs:
assert r.finished
for L in (8, 16, 32, 64, 128):
assert by_len[L].chunks_done <= 1, f"prompt_len={L} should not chunk"
for L in (512, 1024):
assert by_len[L].chunks_done >= 2, f"prompt_len={L} should chunk >=2"
def test_chunked_req_exclusive_of_batch_invariant(self):
self.server.execute_script(
self._script_chunked_req_exclusive_of_batch_invariant
)
@staticmethod
def _script_chunked_req_exclusive_of_batch_invariant(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(DEFAULT_MAX_STEPS * 5):
s = t.scheduler
chunked = s.chunked_req
running = s.running_batch
if chunked is not None and running is not None:
assert chunked not in running.reqs, (
f"chunked_req must be exclusive of running_batch.reqs; "
f"chunked.rid={chunked.rid!r} appears in running_batch"
)
if r1.finished and r2.finished:
return
yield
raise AssertionError("r1 and r2 did not both finish within step budget")
def test_two_chunked_one_decode(self):
self.server.execute_script(self._script_two_chunked_one_decode)
@staticmethod
def _script_two_chunked_one_decode(t: ScriptedContext):
chunked1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
chunked2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
short = t.start_req(prompt_len=8, max_new_tokens=4)
all_reqs = [chunked1, chunked2, short]
for _ in range(DEFAULT_MAX_STEPS * 10):
comp = t.batch_composition()
prefill_set = set(comp.get("prefill", []))
decode_set = set(comp.get("decode", []))
chunked_set = set(comp.get("chunked", []))
assert prefill_set.isdisjoint(decode_set)
assert prefill_set.isdisjoint(chunked_set)
assert decode_set.isdisjoint(chunked_set)
if all(r.finished for r in all_reqs):
break
yield
assert chunked1.finished and chunked2.finished and short.finished
def test_batch_state_query_during_run(self):
self.server.execute_script(self._script_batch_state_query_during_run)
@staticmethod
def _script_batch_state_query_during_run(t: ScriptedContext):
reqs = [t.start_req(prompt_len=16, max_new_tokens=4) for _ in range(4)]
for _ in range(DEFAULT_MAX_STEPS):
comp = t.batch_composition()
prefill = set(comp.get("prefill", []))
decode = set(comp.get("decode", []))
assert prefill & decode == set()
if all(r.finished for r in reqs):
return
yield
raise AssertionError("not all reqs finished")
class TestMultiReqPriority(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
)
def test_parallel_with_priority(self):
self.server.execute_script(self._script_parallel_with_priority)
@staticmethod
def _script_parallel_with_priority(t: ScriptedContext):
normal = [
t.start_req(
prompt_len=16,
max_new_tokens=BALLAST_MAX_NEW_TOKENS,
ignore_eos=True,
priority=0,
)
for _ in range(4)
]
yield from run_until(normal[-1], lambda h: h.status == "running")
t.exhaust_kv(leave_pages=0)
high = t.start_req(prompt_len=16, max_new_tokens=2, priority=100)
preempted = False
for _ in range(DEFAULT_MAX_STEPS * 5):
if any(r.status == "waiting" for r in normal):
preempted = True
if high.finished:
break
yield
assert high.finished, "high-priority req must run to completion"
assert preempted, "a normal req must be preempted back to the waiting queue"
t.abort_all()
for _ in range(DEFAULT_MAX_STEPS):
if all(r.finished for r in normal):
break
yield
class TestMultiReqMixedChunk(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_mixed_chunk=True,
)
def test_long_prefill_chunks_more_with_concurrent_decode(self):
self.server.execute_script(
self._script_long_prefill_chunks_more_with_concurrent_decode
)
@staticmethod
def _script_long_prefill_chunks_more_with_concurrent_decode(t: ScriptedContext):
decoder = t.start_req(prompt_len=16, max_new_tokens=64)
yield from run_until(decoder, lambda h: h.status == "running")
long_req = t.start_req(prompt_len=4 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until(long_req, lambda h: h.is_chunking)
saw_mixed_batch: bool = False
for _ in range(DEFAULT_MAX_STEPS * 5):
if t.last_batch_forward_mode == "MIXED":
saw_mixed_batch = True
if long_req.finished and decoder.finished:
break
yield
assert long_req.finished and decoder.finished
assert saw_mixed_batch, "expected at least one MIXED (prefill+decode) batch"
assert long_req.chunks_done >= 4, (
f"a 4-chunk prompt must chunk >= 4 times under concurrent decode; "
f"got {long_req.chunks_done}"
)
def test_chunked_plus_decode_in_batch(self):
self.server.execute_script(self._script_chunked_plus_decode_in_batch)
@staticmethod
def _script_chunked_plus_decode_in_batch(t: ScriptedContext):
r2 = t.start_req(prompt_len=8, max_new_tokens=64, ignore_eos=True)
yield from run_until(
r2, lambda h: h.status == "running" and len(h.req.output_ids) >= 1
)
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking)
r2_out_before = len(r2.req.output_ids)
saw_chunked_r1 = False
saw_r2_decode_during_chunking = False
for _ in range(DEFAULT_MAX_STEPS):
comp = t.batch_composition()
if r1.rid in comp.get("chunked", []):
saw_chunked_r1 = True
if r2.rid in comp.get("decode", []) + comp.get("prefill", []):
saw_r2_decode_during_chunking = True
chunked_set = set(comp.get("chunked", []))
assert chunked_set.isdisjoint(set(comp.get("prefill", [])))
assert chunked_set.isdisjoint(set(comp.get("decode", [])))
if not r1.is_chunking:
break
yield
assert saw_chunked_r1, "r1 was never observed in the chunked subset"
r2_out_after = len(r2.req.output_ids) if r2.req is not None else 64
assert r2_out_after > r2_out_before, (
f"r2's decode must progress while r1 chunks; output_ids stayed at "
f"{r2_out_before}"
)
assert saw_r2_decode_during_chunking, (
"r2 was never observed co-batched (decode/mixed) while r1 held "
"the chunked slot"
)
t.abort(r2)
yield from run_until_finished(r1)
for _ in range(40):
if t.is_fully_idle:
break
yield
assert r1.finished
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,106 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
base_engine_kwargs,
run_until_finished,
)
class TestPageSize16ChunkSize16(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=16, page_size=16)
def test_chunk_size_equals_page_size(self):
self.server.execute_script(self._script_chunk_size_equals_page_size)
@staticmethod
def _script_chunk_size_equals_page_size(t: ScriptedContext):
r = t.start_req(prompt_len=8 * 16, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 8
assert r.kv_pages == 0
def test_chunk_size_equals_page_size_plus_one(self):
self.server.execute_script(self._script_chunk_size_equals_page_size_plus_one)
@staticmethod
def _script_chunk_size_equals_page_size_plus_one(t: ScriptedContext):
r = t.start_req(prompt_len=129, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 9
assert r.kv_pages == 0
class TestPageSize16ChunkSize16NonMultiplePrompt(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=16, page_size=16)
def test_non_page_multiple_prompt_advances_in_page_steps(self):
self.server.execute_script(
self._script_non_page_multiple_prompt_advances_in_page_steps
)
@staticmethod
def _script_non_page_multiple_prompt_advances_in_page_steps(t: ScriptedContext):
r = t.start_req(prompt_len=60, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 4
assert r.kv_pages == 0
class TestPageSize16ChunkSize64(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=64, page_size=16)
def test_chunk_spans_multiple_pages_exact(self):
self.server.execute_script(self._script_chunk_spans_multiple_pages_exact)
@staticmethod
def _script_chunk_spans_multiple_pages_exact(t: ScriptedContext):
r = t.start_req(prompt_len=4 * 64, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 4
assert r.kv_pages == 0
def test_chunk_spans_multiple_pages_plus_one_page(self):
self.server.execute_script(
self._script_chunk_spans_multiple_pages_plus_one_page
)
@staticmethod
def _script_chunk_spans_multiple_pages_plus_one_page(t: ScriptedContext):
r = t.start_req(prompt_len=4 * 64 + 16, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 5
assert r.kv_pages == 0
class TestPageSize16RadixHit(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=16, page_size=16, disable_radix_cache=False
)
def test_radix_hit_on_page_boundary(self):
self.server.execute_script(self._script_radix_hit_on_page_boundary)
@staticmethod
def _script_radix_hit_on_page_boundary(t: ScriptedContext):
r_warm = t.start_req(prompt_len=4 * 16, max_new_tokens=1)
yield from run_until_finished(r_warm, max_steps=400)
assert r_warm.finished
yield
r = t.start_req(prompt_len=8 * 16, max_new_tokens=1)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert r.chunks_done == 4
assert r.kv_pages == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_finished,
)
class TestPiecewiseBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
disable_cuda_graph=False,
)
def test_naive_piecewise_cg_chunked(self):
self.server.execute_script(self._script_naive_piecewise_cg_chunked)
@staticmethod
def _script_naive_piecewise_cg_chunked(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=8)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
def test_piecewise_cg_tail_chunk_tiny(self):
self.server.execute_script(self._script_piecewise_cg_tail_chunk_tiny)
@staticmethod
def _script_piecewise_cg_tail_chunk_tiny(t: ScriptedContext):
r = t.start_req(
prompt_len=4 * DEFAULT_CHUNK_SIZE + 1,
max_new_tokens=2,
)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done == 5
class TestPiecewiseRetractResume(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
disable_cuda_graph=False,
)
def test_piecewise_cg_retract_resume(self):
self.server.execute_script(self._script_piecewise_cg_retract_resume)
@staticmethod
def _script_piecewise_cg_retract_resume(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,246 @@
import unittest
from typing import Any, Dict
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
from sglang.test.test_utils import DEFAULT_MODEL_NAME_FOR_TEST
def _pp_engine_kwargs(*, pp_size: int = 2, **overrides: Any) -> Dict[str, Any]:
return base_engine_kwargs(
model_path=DEFAULT_MODEL_NAME_FOR_TEST,
pp_size=pp_size,
**overrides,
)
def _expected_chunks(prompt_len: int, chunk_size: int) -> int:
if prompt_len <= chunk_size:
return 0
return (prompt_len + chunk_size - 1) // chunk_size
def _drain_until_released(t, *handles):
for _ in range(16):
if all(
h.kv_pages == 0 and (h.req is None or h.req.req_pool_idx is None)
for h in handles
):
return
yield
class TestPPBasic(ScriptedTestCase):
ENGINE_KWARGS = _pp_engine_kwargs()
def test_pp_abort_during_inflight_chunk(self):
self.server.execute_script(self._script_pp_abort_during_inflight_chunk)
@staticmethod
def _script_pp_abort_during_inflight_chunk(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_pp_last_chunk_cross_mb_kv_correctness(self):
self.server.execute_script(self._script_pp_last_chunk_cross_mb_kv_correctness)
@staticmethod
def _script_pp_last_chunk_cross_mb_kv_correctness(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=8,
prompt_token=7,
ignore_eos=True,
)
yield from run_until_finished(r, max_steps=800)
assert r.finished
expected_chunks = _expected_chunks(VERY_LONG_PROMPT_LEN, DEFAULT_CHUNK_SIZE)
assert r.chunks_done == expected_chunks, (
f"cross-mb chunked prefill must complete all {expected_chunks} chunks, "
f"got chunks_done={r.chunks_done}"
)
def test_pp_static_chunk_size_predictor_returns_none(self):
self.server.execute_script(
self._script_pp_static_chunk_size_predictor_returns_none
)
@staticmethod
def _script_pp_static_chunk_size_predictor_returns_none(t: ScriptedContext):
sched = t.scheduler
assert sched.enable_dynamic_chunking is False
assert sched.predict_next_chunk_size(0) is None
assert sched.predict_next_chunk_size(VERY_LONG_PROMPT_LEN // 2) is None
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r, max_steps=800)
assert r.finished
expected = _expected_chunks(VERY_LONG_PROMPT_LEN, DEFAULT_CHUNK_SIZE)
assert r.chunks_done == expected, (
f"static chunked_prefill_size must produce exactly {expected} chunks, "
f"got {r.chunks_done}"
)
def test_pp_multi_microbatch_chunks_done_aggregation(self):
self.server.execute_script(
self._script_pp_multi_microbatch_chunks_done_aggregation
)
@staticmethod
def _script_pp_multi_microbatch_chunks_done_aggregation(t: ScriptedContext):
r = t.start_req(prompt_len=4 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 4, (
f"PP=2 multi-chunk req should aggregate >=4 chunks_done across "
f"microbatches, got {r.chunks_done}"
)
def test_pp_two_chunked_one_per_mb_simultaneous(self):
self.server.execute_script(self._script_pp_two_chunked_one_per_mb_simultaneous)
@staticmethod
def _script_pp_two_chunked_one_per_mb_simultaneous(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=1
)
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=2
)
yield from run_until_all_finished(handles=[r1, r2], max_steps=800)
assert r1.finished and r2.finished
assert r1.chunks_done >= 2 and r2.chunks_done >= 2
assert r1.kv_pages == 0 and r2.kv_pages == 0
assert r1.lock_refs == 0 and r2.lock_refs == 0
def test_pp_retract_chunked_in_middle_mb(self):
self.server.execute_script(self._script_pp_retract_chunked_in_middle_mb)
@staticmethod
def _script_pp_retract_chunked_in_middle_mb(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_pp_chunked_req_to_exclude_pp_context(self):
self.server.execute_script(self._script_pp_chunked_req_to_exclude_pp_context)
@staticmethod
def _script_pp_chunked_req_to_exclude_pp_context(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.abort(r)
r2 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=2)
yield from run_until_finished(r2, max_steps=400)
assert r2.finished
assert r2.kv_pages == 0
assert r2.lock_refs == 0
@unittest.skip(
"PD-Multiplexing is mutually exclusive with pipeline parallelism: ServerArgs "
"asserts pp_size == 1 (server_args.py:7284-7287, 'PD-Multiplexing is only "
"supported with pipeline parallelism disabled'). It further requires "
"chunked_prefill_size == -1 and disable_overlap_schedule, both of which the PP "
"chunked scripted-runtime harness mandates the opposite of. enable_pdmux=True "
"with pp_size=2 therefore cannot launch -- the spawned server dies on that "
"assert and setUpClass times out on the handshake. This is a genuine engine "
"config constraint, not a test bug, so the pdmux-under-PP scenario is not a "
"valid combination to exercise here."
)
class TestPPPdmux(ScriptedTestCase):
ENGINE_KWARGS = _pp_engine_kwargs(enable_pdmux=True)
def test_pp_split_prefill_chunked_no_merge_assert(self):
self.server.execute_script(
self._script_pp_split_prefill_chunked_no_merge_assert
)
@staticmethod
def _script_pp_split_prefill_chunked_no_merge_assert(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r, max_steps=800)
assert (
r.finished
), "engine died before req finished — merge_batch assert may have tripped"
assert r.chunks_done >= 2, (
f"pdmux + chunked path must produce >=2 chunks to exercise "
f"split_prefill_batch filter; got chunks_done={r.chunks_done}"
)
class TestPPDynamic(ScriptedTestCase):
ENGINE_KWARGS = _pp_engine_kwargs(enable_dynamic_chunking=True)
def test_naive_pp_chunked(self):
self.server.execute_script(self._script_naive_pp_chunked)
@staticmethod
def _script_naive_pp_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
prompt_token=3,
ignore_eos=True,
)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2, f"expected >=2 chunks, got {r.chunks_done}"
def test_pp_dynamic_chunk_size_recompute_branch_taken(self):
self.server.execute_script(
self._script_pp_dynamic_chunk_size_recompute_branch_taken
)
@staticmethod
def _script_pp_dynamic_chunk_size_recompute_branch_taken(t: ScriptedContext):
sched = t.scheduler
assert sched.enable_dynamic_chunking is True
assert sched.length_predictor is not None
assert sched.length_predictor.is_ready is True
dynamic_size = sched.predict_next_chunk_size(0)
assert dynamic_size is not None
assert isinstance(dynamic_size, int) and dynamic_size > 0
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, prompt_token=4
)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2, f"expected >=2 chunks, got {r.chunks_done}"
class TestPPSize4(ScriptedTestCase):
ENGINE_KWARGS = _pp_engine_kwargs(pp_size=4)
def test_pp_size_4_chunked_completes(self):
self.server.execute_script(self._script_pp_size_4_chunked_completes)
@staticmethod
def _script_pp_size_4_chunked_completes(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 4
assert r.kv_pages == 0
assert r.lock_refs == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,309 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
BALLAST_MAX_NEW_TOKENS,
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
class TestPriorityBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_retract_mid_chunk_releases_kv(self):
self.server.execute_script(self._script_retract_mid_chunk_releases_kv)
@staticmethod
def _script_retract_mid_chunk_releases_kv(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
pages_before = r.kv_pages
assert pages_before > 0
t.pause_generation(mode="retract")
yield
assert (
r.status == "waiting"
), f"force-retracted chunked req must be back in waiting; got {r.status}"
assert r.kv_pages == 0, f"retract must release KV; got {r.kv_pages}"
t.continue_generation()
yield from run_until_finished(r)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_retract_and_resume(self):
self.server.execute_script(self._script_retract_and_resume)
@staticmethod
def _script_retract_and_resume(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
assert r.status == "waiting"
assert r.kv_pages == 0
t.continue_generation()
yield from run_until_finished(r)
assert r.finished
def test_force_retract_at_chunk_0(self):
self.server.execute_script(self._script_force_retract_at_chunk_0)
@staticmethod
def _script_force_retract_at_chunk_0(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done <= 1)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
def test_force_retract_at_chunk_mid(self):
self.server.execute_script(self._script_force_retract_at_chunk_mid)
@staticmethod
def _script_force_retract_at_chunk_mid(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.chunks_done >= 2 and h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.lock_refs == 0
def test_force_retract_at_last_chunk(self):
self.server.execute_script(self._script_force_retract_at_last_chunk)
@staticmethod
def _script_force_retract_at_last_chunk(t: ScriptedContext):
r = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=4)
yield from run_until(r, lambda h: h.chunks_done >= 1 and h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_force_retract_then_readmit(self):
self.server.execute_script(self._script_force_retract_then_readmit)
@staticmethod
def _script_force_retract_then_readmit(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0, "retract must release KV before re-admission"
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_retract_one_admit_one(self):
self.server.execute_script(self._script_retract_one_admit_one)
@staticmethod
def _script_retract_one_admit_one(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking)
r2 = t.start_req(prompt_len=8, max_new_tokens=2)
t.pause_generation(mode="retract")
yield
t.continue_generation()
yield from run_until_finished(r2)
assert r2.finished
assert r2.kv_pages == 0
yield from run_until_finished(r1)
assert r1.finished
assert r1.kv_pages == 0
assert r1.lock_refs == 0
def test_retract_during_decode(self):
self.server.execute_script(self._script_retract_during_decode)
@staticmethod
def _script_retract_during_decode(t: ScriptedContext):
r = t.start_req(prompt_len=8, max_new_tokens=32)
yield from run_until(r, lambda h: h.status == "running")
assert r.kv_pages > 0, "decode-state req must own KV before retract"
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0, f"retract must release KV; got {r.kv_pages}"
t.continue_generation()
yield from run_until_finished(r)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_retract_then_abort_idempotent(self):
self.server.execute_script(self._script_retract_then_abort_idempotent)
@staticmethod
def _script_retract_then_abort_idempotent(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
t.abort(r)
for _ in range(12):
if (
r.kv_pages == 0
and r.lock_refs == 0
and (r.req is None or r.req.req_pool_idx is None)
):
break
yield
assert r.kv_pages == 0
assert r.lock_refs == 0
assert r.req is None or r.req.req_pool_idx is None
t.continue_generation()
yield
assert r.kv_pages == 0 and r.lock_refs == 0
def test_retract_chunked_resume_in_waiting(self):
self.server.execute_script(self._script_retract_chunked_resume_in_waiting)
@staticmethod
def _script_retract_chunked_resume_in_waiting(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0
assert r.status == "waiting"
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
def test_two_retracts_same_yield(self):
self.server.execute_script(self._script_two_retracts_same_yield)
@staticmethod
def _script_two_retracts_same_yield(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r1.kv_pages == 0
assert r2.kv_pages == 0
t.continue_generation()
yield from run_until_all_finished([r1, r2])
assert r1.finished and r2.finished
assert r1.lock_refs == 0
assert r2.lock_refs == 0
def test_retract_then_re_chunk(self):
self.server.execute_script(self._script_retract_then_re_chunk)
@staticmethod
def _script_retract_then_re_chunk(t: ScriptedContext):
r = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until(r, lambda h: h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0, "retract must release KV"
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.lock_refs == 0
assert r.kv_pages == 0
class TestPriorityPriority(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
)
def test_naive_priority_chunked(self):
self.server.execute_script(self._script_naive_priority_chunked)
@staticmethod
def _script_naive_priority_chunked(t: ScriptedContext):
low = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=4, priority=0)
yield from run_until(low, lambda h: h.is_chunking)
high = t.start_req(prompt_len=8, max_new_tokens=2, priority=10)
yield from run_until_finished(high)
assert high.finished
assert not low.finished
yield from run_until_all_finished([low, high])
assert low.finished and high.finished
class TestPriorityPreempt(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
max_running_requests=1,
priority_scheduling_preemption_threshold=0,
)
def test_priority_preempt_decode_victim_to_waiting(self):
self.server.execute_script(
self._script_priority_preempt_decode_victim_to_waiting
)
@staticmethod
def _script_priority_preempt_decode_victim_to_waiting(t: ScriptedContext):
low = t.start_req(
prompt_len=8,
max_new_tokens=BALLAST_MAX_NEW_TOKENS,
priority=0,
ignore_eos=True,
)
yield from run_until(low, lambda h: h.status == "running")
assert low.kv_pages > 0
high = t.start_req(prompt_len=8, max_new_tokens=2, priority=10)
yield from run_until(low, lambda h: h.status == "waiting")
assert low.status == "waiting"
assert low.kv_pages == 0
yield from run_until_finished(high)
assert high.finished
def test_priority_preempt_release_invariant(self):
self.server.execute_script(self._script_priority_preempt_release_invariant)
@staticmethod
def _script_priority_preempt_release_invariant(t: ScriptedContext):
r_low = t.start_req(
prompt_len=8,
max_new_tokens=BALLAST_MAX_NEW_TOKENS,
priority=0,
ignore_eos=True,
)
yield from run_until(r_low, lambda h: h.status == "running")
pages_before = r_low.kv_pages
assert pages_before > 0
r_high = t.start_req(prompt_len=8, max_new_tokens=2, priority=10)
yield from run_until(r_low, lambda h: h.status == "waiting")
assert r_low.kv_pages == 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,741 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
)
class TestRadixBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_radix_full_prefix_hit_nine_reqs(self):
self.server.execute_script(self._script_radix_full_prefix_hit_nine_reqs)
@staticmethod
def _script_radix_full_prefix_hit_nine_reqs(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
others = [
t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for _ in range(9)
]
yield from run_until_all_finished(others)
for r in others:
assert r.chunks_done == 0
def test_radix_hit_full_prefix(self):
self.server.execute_script(self._script_radix_hit_full_prefix)
@staticmethod
def _script_radix_hit_full_prefix(t: ScriptedContext):
r1 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE + 1, max_new_tokens=1)
yield from run_until_finished(r2)
assert r2.chunks_done == 0
def test_radix_hit_partial_then_chunk_tail(self):
self.server.execute_script(self._script_radix_hit_partial_then_chunk_tail)
@staticmethod
def _script_radix_hit_partial_then_chunk_tail(t: ScriptedContext):
r1 = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=3 * DEFAULT_CHUNK_SIZE + 1, max_new_tokens=1)
yield from run_until_finished(r2)
assert r2.chunks_done == 2
def test_radix_evict_then_resubmit_rechunks(self):
self.server.execute_script(self._script_radix_evict_then_resubmit_rechunks)
@staticmethod
def _script_radix_evict_then_resubmit_rechunks(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
assert r1.finished
for _ in range(5):
yield
t.evict_radix(prefix_tokens=None)
yield
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r2)
assert r2.finished
assert r2.chunks_done >= 2, (
f"after eviction r2 must re-chunk from scratch; "
f"chunks_done={r2.chunks_done} cached_tokens={r2.req.cached_tokens}"
)
assert (
r2.req.cached_tokens == 0
), f"eviction must clear r1's prefix; cached_tokens={r2.req.cached_tokens}"
assert r2.kv_pages == 0
assert r2.lock_refs == 0
def test_radix_resume_init_next_round_path(self):
self.server.execute_script(self._script_radix_resume_init_next_round_path)
@staticmethod
def _script_radix_resume_init_next_round_path(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=1)
yield from run_until_finished(r1)
assert r1.finished
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN + 2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2
)
yield from run_until_finished(r2)
assert r2.finished
assert r2.req.cached_tokens > 0, (
f"r2 must hit r1's prefix to exercise the partial-hit chunked-"
f"resume branch; got cached_tokens={r2.req.cached_tokens}"
)
assert r2.chunks_done >= 1, (
f"residual tail beyond cached prefix should still chunk; got "
f"chunks_done={r2.chunks_done}"
)
def test_radix_lock_ref_concurrent_chunked(self):
self.server.execute_script(self._script_radix_lock_ref_concurrent_chunked)
@staticmethod
def _script_radix_lock_ref_concurrent_chunked(t: ScriptedContext):
r_warm = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1)
yield from run_until_finished(r_warm)
reqs = [
t.start_req(prompt_len=DEFAULT_CHUNK_SIZE * 4 + 8, max_new_tokens=2)
for _ in range(5)
]
yield from run_until_all_finished(reqs)
for r in reqs:
assert r.finished
assert r.req.cached_tokens > 0
assert r.lock_refs == 0
def test_radix_partial_hit_exact_chunk_boundary(self):
self.server.execute_script(self._script_radix_partial_hit_exact_chunk_boundary)
@staticmethod
def _script_radix_partial_hit_exact_chunk_boundary(t: ScriptedContext):
r1 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r2)
assert r2.chunks_done == 0, (
f"residual of exactly chunk_size must not chunk; "
f"chunks_done={r2.chunks_done} cached_tokens={r2.req.cached_tokens}"
)
def test_radix_two_distinct_prefixes(self):
self.server.execute_script(self._script_radix_two_distinct_prefixes)
@staticmethod
def _script_radix_two_distinct_prefixes(t: ScriptedContext):
r_a = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=11
)
yield from run_until_finished(r_a)
r_b = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=22
)
yield from run_until_finished(r_b)
r_a2 = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=11
)
yield from run_until_finished(r_a2)
assert r_a2.chunks_done == 0
assert r_a2.req.cached_tokens > 0
r_b2 = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=22
)
yield from run_until_finished(r_b2)
assert r_b2.chunks_done == 0
assert r_b2.req.cached_tokens > 0
def test_radix_full_prefix_minus_one(self):
self.server.execute_script(self._script_radix_full_prefix_minus_one)
@staticmethod
def _script_radix_full_prefix_minus_one(t: ScriptedContext):
r1 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE - 1, max_new_tokens=1)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=1)
yield from run_until_finished(r2)
assert r2.chunks_done == 0
def test_radix_hit_changes_between_chunks(self):
self.server.execute_script(self._script_radix_hit_changes_between_chunks)
@staticmethod
def _script_radix_hit_changes_between_chunks(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking and h.chunks_done >= 1)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1, max_steps=800)
yield from run_until_finished(r2, max_steps=800)
assert r1.finished and r2.finished
assert r2.chunks_done < r1.chunks_done, (
f"r2 should hit r1's committed prefix; r2.chunks_done="
f"{r2.chunks_done} not < r1.chunks_done={r1.chunks_done}"
)
assert r2.req.cached_tokens > 0
def test_radix_evict_during_inflight_chunk(self):
self.server.execute_script(self._script_radix_evict_during_inflight_chunk)
@staticmethod
def _script_radix_evict_during_inflight_chunk(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.evict_radix(prefix_tokens=None)
yield
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_radix_full_hit_no_chunked_path(self):
self.server.execute_script(self._script_radix_full_hit_no_chunked_path)
@staticmethod
def _script_radix_full_hit_no_chunked_path(t: ScriptedContext):
prompt_len: int = 16 * DEFAULT_CHUNK_SIZE
r_warm = t.start_req(prompt_len=prompt_len, max_new_tokens=1)
yield from run_until_finished(r_warm, max_steps=1200)
assert r_warm.finished
r = t.start_req(prompt_len=prompt_len, max_new_tokens=2)
yield from run_until_finished(r, max_steps=400)
assert r.finished
assert (
r.chunks_done == 0
), f"full prefix hit must skip chunked path; got chunks_done={r.chunks_done}"
def test_radix_evict_race_concurrent_chunked_admit(self):
self.server.execute_script(
self._script_radix_evict_race_concurrent_chunked_admit
)
@staticmethod
def _script_radix_evict_race_concurrent_chunked_admit(t: ScriptedContext):
warm_len: int = 4 * DEFAULT_CHUNK_SIZE
r_warm = t.start_req(prompt_len=warm_len, max_new_tokens=1)
yield from run_until_finished(r_warm, max_steps=400)
assert r_warm.finished
for _ in range(5):
yield
t.evict_radix(prefix_tokens=None)
r = t.start_req(
prompt_len=warm_len + DEFAULT_CHUNK_SIZE * 2,
max_new_tokens=2,
)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.req.cached_tokens == 0, (
f"eviction must clear the warm prefix; "
f"cached_tokens={r.req.cached_tokens} chunks_done={r.chunks_done}"
)
assert r.chunks_done >= 2
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_chunked_req_re_chunked_after_resume_same_prefix(self):
self.server.execute_script(
self._script_chunked_req_re_chunked_after_resume_same_prefix
)
@staticmethod
def _script_chunked_req_re_chunked_after_resume_same_prefix(t: ScriptedContext):
prompt_len: int = 4 * DEFAULT_CHUNK_SIZE
r = t.start_req(prompt_len=prompt_len, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0, f"retract must release KV; got {r.kv_pages}"
t.continue_generation()
yield from run_until_finished(r, max_steps=800)
assert r.finished
expected_total: int = prompt_len // DEFAULT_CHUNK_SIZE
assert r.chunks_done >= expected_total, (
f"lifetime chunks_done after retract+resume must cover the "
f"whole prompt; expected >= {expected_total}, got "
f"{r.chunks_done}"
)
assert r.chunks_done < 2 * expected_total, (
f"lifetime chunks_done after retract+resume should not double "
f"the prompt's chunk count; expected < {2 * expected_total}, "
f"got {r.chunks_done}"
)
class TestRadixNoTailChunked(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_page_size_one_chunked_has_no_partial_page_tail(self):
self.server.execute_script(
self._script_page_size_one_chunked_has_no_partial_page_tail
)
@staticmethod
def _script_page_size_one_chunked_has_no_partial_page_tail(t: ScriptedContext):
s = t.scheduler
prompt_len: int = 4 * DEFAULT_CHUNK_SIZE
r = t.start_req(prompt_len=prompt_len, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
observed_mid_chunk: bool = False
for _ in range(800):
req = s.chunked_req
if req is not None and req.rid == r.rid:
observed_mid_chunk = True
prefix_len: int = len(req.prefix_indices)
protected_len: int = req.cache_protected_len
assert prefix_len == protected_len, (
f"page_size=1 must take the no-tail else branch: "
f"len(prefix_indices)={prefix_len} != "
f"cache_protected_len={protected_len} (a partial-page tail "
f"was appended, which only happens for page_size > 1)"
)
if r.finished:
break
yield
assert r.finished
assert observed_mid_chunk, (
"test must observe r as the in-flight chunked_req at least once; the "
"no-tail else branch was never exercised"
)
assert (
r.kv_pages == 0
), f"finished chunked req must release KV; got {r.kv_pages}"
class TestRadixHitCountInvariant(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_chunked_stash_no_hit_count_inflation_invariant(self):
self.server.execute_script(
self._script_chunked_stash_no_hit_count_inflation_invariant
)
@staticmethod
def _script_chunked_stash_no_hit_count_inflation_invariant(t: ScriptedContext):
def _snapshot_hit_counts(root) -> dict:
snapshot: dict = {}
stack = [root]
while stack:
node = stack.pop()
snapshot[node.id] = node.hit_count
stack.extend(node.children.values())
return snapshot
s = t.scheduler
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
baseline = _snapshot_hit_counts(s.tree_cache.root_node)
prev_chunks: int = r.chunks_done
observed_chunk_admissions: int = 0
for _ in range(800):
cur_chunks: int = r.chunks_done
if cur_chunks > prev_chunks:
observed_chunk_admissions += 1
cur = _snapshot_hit_counts(s.tree_cache.root_node)
for node_id, base_count in baseline.items():
if node_id in cur:
assert cur[node_id] == base_count, (
f"_inc_hit_count(chunked=True) inflated existing "
f"node id={node_id} hit_count: baseline={base_count}, "
f"now={cur[node_id]}"
)
prev_chunks = cur_chunks
if r.finished:
break
yield
assert r.finished
assert observed_chunk_admissions >= 2, (
f"test must exercise at least 2 chunk admissions after baseline; "
f"observed {observed_chunk_admissions}"
)
class TestRadixHitCountNonChunked(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_non_chunked_prefix_hit_increments_hit_count_by_one(self):
self.server.execute_script(
self._script_non_chunked_prefix_hit_increments_hit_count_by_one
)
@staticmethod
def _script_non_chunked_prefix_hit_increments_hit_count_by_one(t: ScriptedContext):
def _snapshot_hit_counts(root) -> dict:
snapshot: dict = {}
stack = [root]
while stack:
node = stack.pop()
snapshot[node.id] = node.hit_count
stack.extend(node.children.values())
return snapshot
s = t.scheduler
r_warm = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=1, prompt_token=11
)
yield from run_until_finished(r_warm)
assert r_warm.finished
baseline = _snapshot_hit_counts(s.tree_cache.root_node)
r2 = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE + 1, max_new_tokens=1, prompt_token=11
)
yield from run_until_finished(r2)
assert r2.finished
assert r2.chunks_done == 0, (
f"residual past the warm prefix is one token and must not chunk; "
f"got chunks_done={r2.chunks_done}"
)
assert r2.req.cached_tokens > 0, (
f"r2 must hit the warm 2-chunk prefix to drive the non-chunked "
f"hit_count increment; got cached_tokens={r2.req.cached_tokens}"
)
cur = _snapshot_hit_counts(s.tree_cache.root_node)
incremented_by_one = 0
for node_id, base_count in baseline.items():
if node_id not in cur:
continue
delta: int = cur[node_id] - base_count
assert delta in (0, 1), (
f"non-chunked insert moved existing node id={node_id} hit_count "
f"by {delta} (expected 0 or 1): baseline={base_count}, "
f"now={cur[node_id]}"
)
if delta == 1:
incremented_by_one += 1
assert incremented_by_one >= 1, (
f"_inc_hit_count(chunked=False) at radix_cache.py:672 must bump at "
f"least one matched warm-prefix node by exactly 1; saw "
f"{incremented_by_one} such nodes"
)
class TestRadixPartialPage(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
page_size=4,
)
def test_partial_page_tail_no_double_free_invariant(self):
self.server.execute_script(
self._script_partial_page_tail_no_double_free_invariant
)
@staticmethod
def _script_partial_page_tail_no_double_free_invariant(t: ScriptedContext):
s = t.scheduler
allocator = s.token_to_kv_pool_allocator
free_before: int = allocator.available_size()
prompt_len: int = 4 * DEFAULT_CHUNK_SIZE + 7
r = t.start_req(prompt_len=prompt_len, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
for _ in range(800):
req = s.chunked_req
if req is not None and req.rid == r.rid:
prefix_len: int = len(req.prefix_indices)
protected_len: int = req.cache_protected_len
assert prefix_len >= protected_len, (
f"len(prefix_indices)={prefix_len} dropped below "
f"cache_protected_len={protected_len}: tail was freed "
f"prematurely"
)
if r.finished:
break
yield
assert r.finished
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
free_after: int = allocator.available_size()
assert free_after == free_before, (
f"KV pool free count delta on chunked req lifecycle must be 0; "
f"got free_before={free_before}, free_after={free_after} "
f"(double-free or leak of partial-page tail)"
)
class TestRadixFcfs(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
schedule_policy="fcfs",
)
def test_naive_radix_chunked(self):
self.server.execute_script(self._script_naive_radix_chunked)
@staticmethod
def _script_naive_radix_chunked(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
assert r1.finished
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN + DEFAULT_CHUNK_SIZE * 2,
max_new_tokens=2,
)
yield from run_until_finished(r2)
assert r2.finished
assert r2.req.cached_tokens > 0
assert r2.chunks_done >= 1
class TestRadixDisabled(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
disable_radix_cache=True,
kv_canary="none",
kv_canary_real_data="none",
kv_canary_sweep_interval=0,
)
def test_radix_disabled_chunks_every_time(self):
self.server.execute_script(self._script_radix_disabled_chunks_every_time)
@staticmethod
def _script_radix_disabled_chunks_every_time(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r1)
assert r1.chunks_done >= 2
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r2)
assert r2.chunks_done >= 2
class TestRadixLpm(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
schedule_policy="lpm",
)
def test_radix_lpm_policy_chunked_priority(self):
self.server.execute_script(self._script_radix_lpm_policy_chunked_priority)
@staticmethod
def _script_radix_lpm_policy_chunked_priority(t: ScriptedContext):
r_warm = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=1
)
yield from run_until_finished(r_warm)
assert r_warm.finished
for _ in range(5):
yield
r_long = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, prompt_token=1
)
r_short = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, prompt_token=7
)
first_admitted = None
cached_tokens_by_rid: dict = {}
for _ in range(DEFAULT_MAX_STEPS):
comp = t.batch_composition()
active = (
comp.get("chunked", [])
+ comp.get("prefill", [])
+ comp.get("running", [])
)
if first_admitted is None and active:
first_admitted = active[0]
for r in (r_long, r_short):
if r.req is not None:
cached_tokens_by_rid[r.rid] = r.req.cached_tokens
if r_long.finished and r_short.finished:
break
yield
assert r_long.finished and r_short.finished
assert first_admitted == r_long.rid, (
f"LPM must admit the longest-prefix-match req first; first admitted "
f"rid was {first_admitted!r}, expected r_long={r_long.rid!r}"
)
assert cached_tokens_by_rid.get(r_long.rid, 0) > 0
assert cached_tokens_by_rid.get(r_short.rid, 0) == 0
for r in (r_long, r_short):
assert r.kv_pages == 0
assert r.lock_refs == 0
class TestRadixDfsWeight(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
schedule_policy="dfs-weight",
)
def test_radix_dfs_weight_policy_chunked(self):
self.server.execute_script(self._script_radix_dfs_weight_policy_chunked)
@staticmethod
def _script_radix_dfs_weight_policy_chunked(t: ScriptedContext):
warm_a = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=3
)
yield from run_until_finished(warm_a)
warm_b = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1, prompt_token=4
)
yield from run_until_finished(warm_b)
heavy = [
t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, prompt_token=3
)
for _ in range(3)
]
light = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, prompt_token=4
)
all_reqs = heavy + [light]
finish_order: list = []
cached_tokens_by_rid: dict = {}
for _ in range(DEFAULT_MAX_STEPS * 4):
for r in all_reqs:
if r.req is not None:
cached_tokens_by_rid[r.rid] = r.req.cached_tokens
if r.finished and r.rid not in finish_order:
finish_order.append(r.rid)
if all(r.finished for r in all_reqs):
break
yield
assert all(r.finished for r in all_reqs)
assert finish_order[-1] == light.rid, (
f"dfs-weight must drain the heavier branch-A subtree before the "
f"lighter branch-B req; finish order was {finish_order!r}, "
f"light={light.rid!r}"
)
for r in heavy:
assert cached_tokens_by_rid.get(r.rid, 0) > 0
assert cached_tokens_by_rid.get(light.rid, 0) > 0
for r in all_reqs:
assert r.kv_pages == 0
assert r.lock_refs == 0
class TestRadixPriority(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
)
def test_radix_prefix_match_with_priority(self):
self.server.execute_script(self._script_radix_prefix_match_with_priority)
@staticmethod
def _script_radix_prefix_match_with_priority(t: ScriptedContext):
r_warm = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE * 2, max_new_tokens=1)
yield from run_until_finished(r_warm)
assert r_warm.finished
for _ in range(5):
yield
r_low = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, priority=0
)
r_high = t.start_req(
prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=1, priority=10
)
first_admitted = None
low_done = False
high_done = False
cached_tokens_by_rid: dict = {}
for _ in range(DEFAULT_MAX_STEPS):
comp = t.batch_composition()
active = (
comp.get("chunked", [])
+ comp.get("prefill", [])
+ comp.get("running", [])
)
if first_admitted is None and active:
first_admitted = active[0]
for r in (r_low, r_high):
if r.req is not None:
cached_tokens_by_rid[r.rid] = r.req.cached_tokens
low_done = low_done or r_low.finished
high_done = high_done or r_high.finished
if low_done and high_done:
break
yield
assert low_done and high_done
assert first_admitted == r_high.rid, (
f"higher-priority req must be admitted first; first admitted rid "
f"was {first_admitted!r}, expected r_high={r_high.rid!r}"
)
for r in (r_low, r_high):
assert cached_tokens_by_rid.get(r.rid, 0) > 0
assert r.kv_pages == 0
assert r.lock_refs == 0
def test_radix_calc_priority_skip_chunked_resume(self):
self.server.execute_script(self._script_radix_calc_priority_skip_chunked_resume)
@staticmethod
def _script_radix_calc_priority_skip_chunked_resume(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, priority=10)
yield from run_until(r1, lambda h: h.is_chunking)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, priority=0)
prev_chunks_done = r1.chunks_done
r1_fin_step = None
r2_fin_step = None
step = 0
while not (r1.finished and r2.finished):
assert r1.chunks_done >= prev_chunks_done, (
f"r1 chunked prefill was preempted by lower-priority r2: "
f"chunks_done regressed {prev_chunks_done} -> {r1.chunks_done}"
)
prev_chunks_done = r1.chunks_done
if r1.finished and r1_fin_step is None:
r1_fin_step = step
if r2.finished and r2_fin_step is None:
r2_fin_step = step
step += 1
yield
if r1.finished and r1_fin_step is None:
r1_fin_step = step
if r2.finished and r2_fin_step is None:
r2_fin_step = step
assert r1.finished and r2.finished
assert r1_fin_step is not None and r2_fin_step is not None
assert r1_fin_step <= r2_fin_step, (
f"high-priority r1 must finish its chunked prefill no later than "
f"low-priority r2; r1 finished at step {r1_fin_step}, r2 at step "
f"{r2_fin_step} (lower-priority r2 jumped ahead of r1's resume)"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,524 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_STEPS,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_all_finished,
run_until_finished,
warmup_radix,
)
def _drain_until_released(t, *handles):
for _ in range(12):
if all(
h.kv_pages == 0
and h.lock_refs == 0
and (h.req is None or h.req.req_pool_idx is None)
for h in handles
):
return
yield
class TestRegressionBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_abort_waiting_releases_all(self):
self.server.execute_script(self._script_abort_waiting_releases_all)
@staticmethod
def _script_abort_waiting_releases_all(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.abort(r)
yield from _drain_until_released(t, r)
assert r.kv_pages == 0
assert r.req.req_pool_idx is None
assert r.lock_refs == 0
assert not r.is_chunking
assert r.req.inflight_middle_chunks == 0
def test_pause_covers_waiting_chunked(self):
self.server.execute_script(self._script_pause_covers_waiting_chunked)
@staticmethod
def _script_pause_covers_waiting_chunked(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking)
t.pause_generation(mode="retract")
yield
assert r.kv_pages == 0
assert r.req.req_pool_idx is None
assert r.lock_refs == 0
assert not r.is_chunking
t.continue_generation()
def test_inflight_middle_chunks_invariant(self):
self.server.execute_script(self._script_inflight_middle_chunks_invariant)
@staticmethod
def _script_inflight_middle_chunks_invariant(t: ScriptedContext):
r = t.start_req(
prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=4, ignore_eos=True
)
observed_max = 0
saw_chunking_bump = False
cleared_inflight = False
for _ in range(DEFAULT_MAX_STEPS):
req = r.req
if req is not None:
observed_max = max(observed_max, req.inflight_middle_chunks)
if r.chunks_done >= 1 and r.is_chunking:
saw_chunking_bump = saw_chunking_bump or (
req.inflight_middle_chunks > 0
)
if not r.is_chunking and r.chunks_done >= 2:
cleared_inflight = req.inflight_middle_chunks == 0
break
if r.finished:
cleared_inflight = True
break
yield
else:
raise AssertionError("chunk loop did not clear within DEFAULT_MAX_STEPS")
assert saw_chunking_bump, "last-chunk admit must bump inflight_middle_chunks"
assert observed_max == 1, (
f"e875cd36e4: inflight_middle_chunks must be a 0/1 latch; "
f"observed max={observed_max} (pre-fix bug would bump to 2 "
f"at the last-chunk admit boundary)"
)
assert (
cleared_inflight
), "inflight_middle_chunks should be 0 once the chunk loop clears"
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
@unittest.skip(
"dbdcdde245 mamba_pool_idx cleanup-skip is mamba-architecture-specific. "
"The shared test fixture does not configure a mamba model; running this "
"regression against the default transformer model would not exercise the "
"mamba NO_TOKEN cleanup path, so the body would be a check "
"with no real protection. Re-enable when a mamba fixture is wired in."
)
def test_mamba_chunked_resume_no_token(self):
self.server.execute_script(self._script_mamba_chunked_resume_no_token)
@staticmethod
def _script_mamba_chunked_resume_no_token(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until_finished(r)
assert r.finished
def test_merge_batch_assert_widened(self):
self.server.execute_script(self._script_merge_batch_assert_widened)
@staticmethod
def _script_merge_batch_assert_widened(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2, ignore_eos=True
)
r2 = t.start_req(prompt_len=16, max_new_tokens=2, ignore_eos=True)
yield from run_until_all_finished([r1, r2])
assert r1.finished and r2.finished
assert r1.chunks_done >= 2 and r2.chunks_done == 0
assert len(r1.req.output_ids) == 2 and len(r2.req.output_ids) == 2
def test_chunked_pending_tokens_subtract_prefix(self):
self.server.execute_script(self._script_chunked_pending_tokens_subtract_prefix)
@staticmethod
def _script_chunked_pending_tokens_subtract_prefix(t: ScriptedContext):
r1 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE * 4, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking and h.chunks_done >= 1)
r2 = t.start_req(prompt_len=DEFAULT_CHUNK_SIZE, max_new_tokens=2)
yield from run_until(r2, lambda h: h.status == "waiting")
r1_prefix = len(r1.req.prefix_indices)
assert r1.is_chunking, "r1 must still be the in-flight chunked req"
assert r1_prefix > 0, "r1 must hold a committed prefix as the chunked req"
observed_pending = t.scheduler.load_inquirer._get_num_pending_tokens()
r1_seqlen = r1.req.seqlen
r2_seqlen = r2.req.seqlen
expected_post_fix = (r1_seqlen - r1_prefix) + r2_seqlen
pre_fix_bad = r1_seqlen + r2_seqlen
assert observed_pending == expected_post_fix, (
f"c79a73bec4: load_inquirer must subtract the chunked req's "
f"prefix_indices_len from its pending-token contribution; "
f"observed={observed_pending}, expected_post_fix="
f"{expected_post_fix}, pre_fix_bad={pre_fix_bad}"
)
assert observed_pending < pre_fix_bad, (
f"c79a73bec4: observed pending tokens did not subtract the chunked "
f"prefix (matches the no-subtraction sum {pre_fix_bad}) — fix is regressed"
)
yield from run_until_all_finished([r1, r2])
assert r1.finished and r2.finished
def test_chunked_admission_reuse_branch_balanced(self):
self.server.execute_script(self._script_chunked_admission_reuse_branch_balanced)
@staticmethod
def _script_chunked_admission_reuse_branch_balanced(t: ScriptedContext):
baseline_refs = sum(t.get_all_node_lock_refs().values())
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
for target_chunk in (3, 4, 5):
yield from run_until(
r, lambda h: h.chunks_done >= target_chunk and h.is_chunking
)
assert r.lock_refs == 1, (
f"reuse branch re-acquired lock_ref on the chunked req's node at "
f"chunk {target_chunk}: r.lock_refs={r.lock_refs} (expected 1)"
)
per_node = t.get_all_node_lock_refs()
assert max(per_node.values(), default=0) <= 1, (
f"reuse branch double-locked a single node at chunk "
f"{target_chunk}: per-node lock_refs={per_node}"
)
yield from run_until_finished(r)
final_refs = sum(t.get_all_node_lock_refs().values())
assert final_refs == baseline_refs, (
f"chunked lifecycle must net to zero lock_ref delta; "
f"baseline={baseline_refs}, final={final_refs}"
)
assert r.lock_refs == 0
def test_multiturn_full_hit_no_reuse_branch(self):
self.server.execute_script(self._script_multiturn_full_hit_no_reuse_branch)
@staticmethod
def _script_multiturn_full_hit_no_reuse_branch(t: ScriptedContext):
baseline_refs = sum(t.get_all_node_lock_refs().values())
r1 = t.start_req(prompt_len=64, max_new_tokens=2)
yield from run_until_finished(r1)
r2 = t.start_req(prompt_len=64, max_new_tokens=2)
yield from run_until_finished(r2)
assert not r2.is_chunking
assert r2.req.cached_tokens > 0, (
f"follow-up req must fully hit the warm prefix to exercise the "
f"no-reuse path; got cached_tokens={r2.req.cached_tokens}"
)
for _ in range(5):
yield
assert sum(t.get_all_node_lock_refs().values()) == baseline_refs, (
f"full-prefix-hit follow-up must not take the chunked reuse "
f"branch; lock_refs drifted from {baseline_refs} to "
f"{sum(t.get_all_node_lock_refs().values())}"
)
def test_abort_chunked_resume_releases_all_resources(self):
self.server.execute_script(
self._script_abort_chunked_resume_releases_all_resources
)
@staticmethod
def _script_abort_chunked_resume_releases_all_resources(t: ScriptedContext):
baseline_refs = sum(t.get_all_node_lock_refs().values())
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
assert r.req.req_pool_idx is not None, "row must be held mid-chunk"
assert r.kv_pages > 0, "committed KV must be held mid-chunk"
assert r.lock_refs >= 1, "radix lock_ref must be held mid-chunk"
t.abort(r)
yield from _drain_until_released(t, r)
assert (
r.req.req_pool_idx is None
), f"96d4749094: abort must release row; got row_idx={r.req.req_pool_idx!r}"
assert (
r.kv_pages == 0
), f"96d4749094: abort must release KV; got kv_pages={r.kv_pages}"
assert (
r.lock_refs == 0
), f"96d4749094: abort must release lock_ref; got lock_refs={r.lock_refs}"
assert not r.is_chunking
assert r.req.inflight_middle_chunks == 0
assert sum(t.get_all_node_lock_refs().values()) == baseline_refs
def test_pause_retract_releases_waiting_chunked_resume(self):
self.server.execute_script(
self._script_pause_retract_releases_waiting_chunked_resume
)
@staticmethod
def _script_pause_retract_releases_waiting_chunked_resume(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
assert r.req.req_pool_idx is not None and r.kv_pages > 0 and r.lock_refs >= 1
t.pause_generation(mode="retract")
yield
assert r.req.req_pool_idx is None, (
f"f38e69f87d: pause(retract) must release waiting "
f"chunked-resume row; got row_idx={r.req.req_pool_idx!r}"
)
assert r.kv_pages == 0
assert r.lock_refs == 0
assert not r.is_chunking
assert r.status == "waiting", (
f"f38e69f87d: pause(retract) must re-queue the retracted "
f"chunked-resume req; got status={r.status!r}"
)
assert t.scheduler.chunked_req is None
assert t.scheduler.running_batch.is_empty()
t.continue_generation()
yield from run_until_finished(r)
assert r.finished
assert len(r.req.output_ids) == 2
def test_retract_all_clears_batch_with_chunked(self):
self.server.execute_script(self._script_retract_all_clears_batch_with_chunked)
@staticmethod
def _script_retract_all_clears_batch_with_chunked(t: ScriptedContext):
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
r2 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking and h.chunks_done >= 1)
yield from run_until(r2, lambda h: h.status == "waiting")
t.pause_generation(mode="retract")
yield
assert len(t.scheduler.running_batch.reqs) == 0, (
f"f0388931bf: retract_all must clear batch; got batch_size="
f"{len(t.scheduler.running_batch.reqs)}"
)
assert (
t.scheduler.chunked_req.rid if t.scheduler.chunked_req is not None else None
) is None
for r in (r1, r2):
assert r.status == "waiting"
assert r.kv_pages == 0
assert not r.is_chunking
t.continue_generation()
yield from run_until_all_finished([r1, r2])
class TestRegressionPp(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
tp_size=2,
pp_size=2,
)
def test_pp_abort_dedup(self):
self.server.execute_script(self._script_pp_abort_dedup)
@staticmethod
def _script_pp_abort_dedup(t: ScriptedContext):
r = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=4)
yield from run_until(r, lambda h: h.chunks_done >= 1 and h.is_chunking)
t.abort(r)
yield
rids_after_abort = [req.rid for req in t.scheduler.running_batch.reqs]
occurrences = sum(1 for rid in rids_after_abort if rid == r.rid)
assert occurrences <= 1, (
f"b823c16e60: batch_rids must dedup across mbs + "
f"waiting_queue; got {occurrences} occurrences of rid="
f"{r.rid} (pre-fix bug would yield 3)"
)
yield from _drain_until_released(t, r)
assert r.finished
class TestRegressionPriority(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
enable_priority_scheduling=True,
)
def test_priority_skips_chunked_in_prefix_match(self):
self.server.execute_script(self._script_priority_skips_chunked_in_prefix_match)
@staticmethod
def _script_priority_skips_chunked_in_prefix_match(t: ScriptedContext):
r1 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
priority=0,
ignore_eos=True,
)
yield from run_until(r1, lambda h: h.is_chunking and h.chunks_done >= 1)
r1_host_hit_before = r1.req.host_hit_length
r2 = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
priority=10,
ignore_eos=True,
)
prev_chunks = r1.chunks_done
while not (r1.finished and r2.finished):
assert r1.req is None or r1.req.host_hit_length == r1_host_hit_before, (
f"aaf3752d2b: priority calc re-matched the chunked-resume req; "
f"host_hit_length changed {r1_host_hit_before} -> "
f"{r1.req.host_hit_length}"
)
assert r1.chunks_done >= prev_chunks, (
f"r1 chunked prefill was preempted by higher-priority r2: "
f"chunks_done regressed {prev_chunks} -> {r1.chunks_done}"
)
prev_chunks = r1.chunks_done
yield
assert r1.finished and r2.finished
assert r1.chunks_done >= 2
assert len(r1.req.output_ids) == 2 and len(r2.req.output_ids) == 2
class TestRegressionLpm(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
schedule_policy="lpm",
)
def test_chunked_resume_priority_in_sort(self):
self.server.execute_script(self._script_chunked_resume_priority_in_sort)
@staticmethod
def _script_chunked_resume_priority_in_sort(t: ScriptedContext):
r_long = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r_long, lambda h: h.is_chunking)
shorts = [t.start_req(prompt_len=4, max_new_tokens=1) for _ in range(8)]
initial_chunks = r_long.chunks_done
for _ in range(200):
if r_long.chunks_done > initial_chunks:
break
yield
else:
raise AssertionError(
f"chunked req starved by short req flood; "
f"chunks_done stuck at {initial_chunks}"
)
all_reqs = [r_long, *shorts]
done = {r.rid: False for r in all_reqs}
for _ in range(DEFAULT_MAX_STEPS):
for r in all_reqs:
done[r.rid] = done[r.rid] or r.finished
if all(done.values()):
break
yield
else:
raise AssertionError(
f"reqs did not all finish; done={done}, "
f"long_chunks={r_long.chunks_done}"
)
assert r_long.finished
assert r_long.chunks_done >= 2
def test_lpm_skips_chunked_resume_prefix_match(self):
self.server.execute_script(self._script_lpm_skips_chunked_resume_prefix_match)
@staticmethod
def _script_lpm_skips_chunked_resume_prefix_match(t: ScriptedContext):
yield from warmup_radix(t, [1] * (2 * DEFAULT_CHUNK_SIZE))
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.chunks_done >= 1 and h.is_chunking)
host_hit_before = r1.req.host_hit_length
r2 = t.start_req(prompt_len=2 * DEFAULT_CHUNK_SIZE, max_new_tokens=2)
while not (r1.finished and r2.finished):
assert r1.req is None or r1.req.host_hit_length == host_hit_before, (
f"calc_priority re-matched the chunked-resume req; host_hit_length "
f"changed {host_hit_before} -> {r1.req.host_hit_length}"
)
yield
def test_chunked_resume_priority_under_lpm(self):
self.server.execute_script(self._script_chunked_resume_priority_under_lpm)
@staticmethod
def _script_chunked_resume_priority_under_lpm(t: ScriptedContext):
long_prefix_tokens = [1] * (3 * DEFAULT_CHUNK_SIZE)
yield from warmup_radix(t, long_prefix_tokens)
r1 = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r1, lambda h: h.is_chunking and h.chunks_done >= 1)
competitors = [
t.start_req(prompt_len=3 * DEFAULT_CHUNK_SIZE + 32, max_new_tokens=2)
for _ in range(6)
]
baseline_chunks = r1.chunks_done
for _ in range(50):
if r1.chunks_done > baseline_chunks:
break
yield
else:
raise AssertionError(
f"bf5b4e9a10: chunked-resume starved under LPM by "
f"long-prefix competitors; chunks_done stuck at "
f"{baseline_chunks}"
)
yield from run_until_all_finished([r1, *competitors])
class TestRegressionGptOss(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
model_path="openai/gpt-oss-20b",
mem_fraction_static=0.70,
disable_piecewise_cuda_graph=True,
)
def test_chunked_stash_bounded_by_kv_committed_len(self):
self.server.execute_script(
self._script_chunked_stash_bounded_by_kv_committed_len
)
@staticmethod
def _script_chunked_stash_bounded_by_kv_committed_len(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=2)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
committed = r.req.kv_committed_len
assert committed > 0
assert len(r.req.prefix_indices) <= committed, (
f"cache_unfinished_req over-read past kv_committed_len: "
f"prefix_indices_len={len(r.req.prefix_indices)}, "
f"kv_committed_len={committed}"
)
yield from run_until_finished(r)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,274 @@
import unittest
from sglang.srt.managers.schedule_batch import FINISH_LENGTH, FINISH_MATCHED_TOKEN
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_finished,
)
class TestSamplingBasic(ScriptedTestCase):
ENGINE_KWARGS = base_engine_kwargs(chunked_prefill_size=DEFAULT_CHUNK_SIZE)
def test_max_new_tokens_zero_prefill_only(self):
self.server.execute_script(self._script_max_new_tokens_zero_prefill_only)
@staticmethod
def _script_max_new_tokens_zero_prefill_only(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=0)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 1, (
f"max_new_tokens=0 finishes on the prefill chunk with one sampled "
f"token; got {len(r.req.output_ids)}"
)
decode_records = [
rec
for rec in t._scheduler_hook._batch_log
if r.rid in rec.rids and rec.mode == "decode"
]
assert len(decode_records) == 0, (
f"max_new_tokens=0 must run zero decode forwards; got "
f"{len(decode_records)}"
)
def test_max_new_tokens_one_long_chunked(self):
self.server.execute_script(self._script_max_new_tokens_one_long_chunked)
@staticmethod
def _script_max_new_tokens_one_long_chunked(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=1)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 1, (
f"max_new_tokens=1 must produce exactly 1 token, got "
f"{len(r.req.output_ids)}"
)
def test_max_new_tokens_1000_long_chunked(self):
self.server.execute_script(self._script_max_new_tokens_1000_long_chunked)
@staticmethod
def _script_max_new_tokens_1000_long_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=1000,
ignore_eos=True,
)
yield from run_until(r, lambda h: h.finished, max_steps=2000)
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 1000, (
f"ignore_eos=True + max_new_tokens=1000 must produce 1000 "
f"output tokens; got {len(r.req.output_ids)}"
)
def test_return_logprob_chunked(self):
self.server.execute_script(self._script_return_logprob_chunked)
@staticmethod
def _script_return_logprob_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
return_logprob=True,
ignore_eos=True,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert r.req.logprob is not None
assert len(r.req.logprob.output_token_logprobs_val) == 4
def test_ignore_eos_chunked(self):
self.server.execute_script(self._script_ignore_eos_chunked)
@staticmethod
def _script_ignore_eos_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=16, ignore_eos=True
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 16
assert isinstance(r.req.finished_reason, FINISH_LENGTH), (
f"ignore_eos=True must finish via length cap; got "
f"{r.req.finished_reason!r}"
)
def test_return_logprob_top_logprobs_chunked(self):
self.server.execute_script(self._script_return_logprob_top_logprobs_chunked)
@staticmethod
def _script_return_logprob_top_logprobs_chunked(t: ScriptedContext):
top_k = 5
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
return_logprob=True,
top_logprobs_num=top_k,
ignore_eos=True,
)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done >= 2
assert r.req.logprob is not None
top = r.req.logprob.output_top_logprobs_val
assert len(top) == 4, (
f"top logprobs must be reported once per output token; "
f"got {len(top)} entries for 4 tokens"
)
for step_entries in top:
assert len(step_entries) == top_k, (
f"each step must carry exactly top_logprobs_num={top_k} "
f"entries; got {len(step_entries)}"
)
def test_explicit_rid_chunked(self):
self.server.execute_script(self._script_explicit_rid_chunked)
@staticmethod
def _script_explicit_rid_chunked(t: ScriptedContext):
r = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=2,
rid="custom-rid-1",
ignore_eos=True,
)
yield from run_until_finished(r)
assert r.rid == "custom-rid-1"
assert r.finished
assert r.chunks_done >= 2
assert len(r.req.output_ids) == 2
def test_default_sampling_short(self):
self.server.execute_script(self._script_default_sampling_short)
@staticmethod
def _script_default_sampling_short(t: ScriptedContext):
r = t.start_req(prompt_len=8, max_new_tokens=2, ignore_eos=True)
yield from run_until_finished(r)
assert r.finished
assert r.chunks_done == 0
assert len(r.req.output_ids) == 2
def test_chunked_logprob_input_accumulates_across_chunks(self):
self.server.execute_script(
self._script_chunked_logprob_input_accumulates_across_chunks
)
@staticmethod
def _script_chunked_logprob_input_accumulates_across_chunks(t: ScriptedContext):
prompt_len = VERY_LONG_PROMPT_LEN
r = t.start_req(
prompt_len=prompt_len,
max_new_tokens=4,
return_logprob=True,
logprob_start_len=0,
)
yield from run_until_finished(r)
assert r.finished
assert (
r.chunks_done >= 2
), f"prompt should span multiple chunks, got chunks_done={r.chunks_done}"
assert r.req.logprob is not None
input_lp = r.req.logprob.input_token_logprobs_val
assert len(input_lp) == prompt_len, (
f"expected {prompt_len} input logprobs (one per prompt token), "
f"got {len(input_lp)}"
)
def test_logprob_start_len_inside_chunk_2(self):
self.server.execute_script(self._script_logprob_start_len_inside_chunk_2)
@staticmethod
def _script_logprob_start_len_inside_chunk_2(t: ScriptedContext):
prompt_len = 4 * DEFAULT_CHUNK_SIZE
start_len = DEFAULT_CHUNK_SIZE + 50
r = t.start_req(
prompt_len=prompt_len,
max_new_tokens=4,
return_logprob=True,
logprob_start_len=start_len,
)
yield from run_until_finished(r)
assert r.finished
assert (
r.chunks_done >= 3
), f"prompt should span 3+ chunks, got chunks_done={r.chunks_done}"
assert r.req.logprob is not None
input_lp = r.req.logprob.input_token_logprobs_val
assert len(input_lp) == prompt_len - start_len, (
f"expected {prompt_len - start_len} input logprobs for tokens "
f">= logprob_start_len={start_len}, got {len(input_lp)}"
)
def test_finish_reason_value_eos_vs_length_chunked(self):
self.server.execute_script(
self._script_finish_reason_value_eos_vs_length_chunked
)
@staticmethod
def _script_finish_reason_value_eos_vs_length_chunked(t: ScriptedContext):
probe = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=1,
ignore_eos=True,
prompt_token=7,
temperature=0.0,
)
yield from run_until_finished(probe)
assert probe.finished
first_token = probe.req.output_ids[0]
for _ in range(40):
if t.is_fully_idle:
break
yield
t.flush_cache()
yield
r_eos = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=999,
ignore_eos=False,
prompt_token=7,
stop_token_ids=[first_token],
temperature=0.0,
)
yield from run_until_finished(r_eos, max_steps=2000)
assert r_eos.finished
assert (
r_eos.chunks_done >= 2
), f"scenario 1 should chunk; got chunks_done={r_eos.chunks_done}"
assert isinstance(r_eos.req.finished_reason, FINISH_MATCHED_TOKEN), (
f"a stop token the model deterministically produces under greedy must "
f"finish via the matched-token path; got {r_eos.req.finished_reason!r}"
)
r_length = t.start_req(
prompt_len=VERY_LONG_PROMPT_LEN,
max_new_tokens=4,
ignore_eos=True,
)
yield from run_until_finished(r_length)
assert r_length.finished
assert (
r_length.chunks_done >= 2
), f"scenario 2 should chunk; got chunks_done={r_length.chunks_done}"
assert isinstance(r_length.req.finished_reason, FINISH_LENGTH), (
f"ignore_eos=True + max_new_tokens=4 chunked must finish via "
f"length cap; got {r_length.req.finished_reason!r}"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
import unittest
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.test_case import ScriptedTestCase
from sglang.test.scripted_runtime_chunked_helpers import (
DEFAULT_CHUNK_SIZE,
VERY_LONG_PROMPT_LEN,
base_engine_kwargs,
run_until,
run_until_finished,
)
_SPEC_MODEL = "Qwen/Qwen3-8B"
_SPEC_DRAFT = "Tengyunw/qwen3_8b_eagle3"
def _spec_engine_kwargs(**overrides):
return base_engine_kwargs(
model_path=_SPEC_MODEL,
chunked_prefill_size=DEFAULT_CHUNK_SIZE,
speculative_algorithm="EAGLE3",
speculative_draft_model_path=_SPEC_DRAFT,
speculative_num_steps=6,
speculative_eagle_topk=10,
speculative_num_draft_tokens=32,
kv_canary="none",
kv_canary_real_data="none",
kv_canary_sweep_interval=0,
**overrides,
)
class TestSpecBasic(ScriptedTestCase):
ENGINE_KWARGS = _spec_engine_kwargs()
def test_spec_chunked_handoff_first_verify(self):
self.server.execute_script(self._script_spec_chunked_handoff_first_verify)
@staticmethod
def _script_spec_chunked_handoff_first_verify(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=16)
yield from run_until_finished(r, max_steps=800)
assert r.finished
assert r.chunks_done >= 2
assert r.req.spec_verify_ct >= 1, (
f"expected >=1 spec verify after chunked handoff, got "
f"{r.req.spec_verify_ct}"
)
def test_spec_abort_during_chunked_prepare(self):
self.server.execute_script(self._script_spec_abort_during_chunked_prepare)
@staticmethod
def _script_spec_abort_during_chunked_prepare(t: ScriptedContext):
r = t.start_req(prompt_len=VERY_LONG_PROMPT_LEN, max_new_tokens=16)
yield from run_until(r, lambda h: h.is_chunking and h.chunks_done >= 1)
t.abort(r)
for _ in range(40):
if t.is_fully_idle:
break
yield
assert r.kv_pages == 0
assert r.lock_refs == 0
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
import unittest
import torch
from sglang.srt.utils import DynamicGradMode
from sglang.test.test_utils import CustomTestCase
class TestDynamicGradMode(CustomTestCase):
def test_inference(self):
# Test inference_mode
DynamicGradMode.set_inference_mode(True)
@DynamicGradMode()
def create_tensor_x():
return torch.empty(0)
X = create_tensor_x()
self.assertTrue(not X.requires_grad and X.is_inference())
def test_no_grad(self):
# Test no_grad
DynamicGradMode.set_inference_mode(False)
@DynamicGradMode()
def create_tensor_y():
return torch.empty(0)
Y = create_tensor_y()
self.assertTrue(not Y.requires_grad and not Y.is_inference())
def test_nested_inference(self):
# Test no_grad nested inference_mode, inference_mode should has higher priority
DynamicGradMode.set_inference_mode(False)
@DynamicGradMode()
def create_tensor_z():
with torch.inference_mode():
return torch.empty(0)
Z = create_tensor_z()
self.assertTrue(not Z.requires_grad and Z.is_inference())
def test_nested_no_grad(self):
# Test inference_mode nested no_grad, inference_mode should has higher priority
DynamicGradMode.set_inference_mode(True)
@DynamicGradMode()
def create_tensor_w():
with torch.no_grad():
return torch.empty(0)
W = create_tensor_w()
self.assertTrue(not W.requires_grad and W.is_inference())
if __name__ == "__main__":
unittest.main(verbosity=2)
+31
View File
@@ -0,0 +1,31 @@
import unittest
from sglang.test.gpt_oss_common import BaseTestGptOss
class TestGptOss1Gpu(BaseTestGptOss):
def test_mxfp4_20b(self):
self.run_test(
model_variant="20b",
quantization="mxfp4",
expected_score_of_reasoning_effort={
"low": 0.34,
"medium": 0.34,
"high": 0.27, # TODO investigate
},
)
def test_bf16_20b(self):
self.run_test(
model_variant="20b",
quantization="bf16",
expected_score_of_reasoning_effort={
"low": 0.34,
"medium": 0.34,
"high": 0.27, # TODO investigate
},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,174 @@
"""Manual test for the session radix cache (--enable-session-radix-cache).
Run directly: python test/manual/core/test_session_radix_cache.py
"""
import unittest
from array import array
from types import SimpleNamespace
import torch
from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import (
EvictParams,
InsertParams,
MatchPrefixParams,
)
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey
class TestSessionRadixCache(unittest.TestCase):
def setUp(self):
dtype = torch.float16
kv = MHATokenToKVPool(
size=64,
page_size=1,
dtype=dtype,
head_num=2,
head_dim=8,
layer_num=1,
device="cpu",
enable_memory_saver=False,
)
allocator = TokenToKVPoolAllocator(
size=64, dtype=dtype, device="cpu", kvcache=kv, need_sort=False
)
req_to_token_pool = ReqToTokenPool(
size=8, max_context_len=1024, device="cpu", enable_memory_saver=False
)
self.cache = RadixCache(
CacheInitParams(
disable=False,
req_to_token_pool=req_to_token_pool,
token_to_kv_pool_allocator=allocator,
page_size=1,
eviction_policy="lru",
enable_kv_cache_events=False,
enable_session_radix_cache=True,
)
)
def _insert(self, toks):
idx = self.cache.token_to_kv_pool_allocator.alloc(len(toks))
self.cache.insert(
InsertParams(key=RadixKey(array("q", toks)), value=idx.to(torch.int64))
)
def _tag(self, toks, sid):
self.cache._tag_session_leaf(
SimpleNamespace(session_id=sid),
RadixKey(array("q", toks)),
node=self._leaf(toks),
)
def _cached(self, toks):
return int(
self.cache.match_prefix(
MatchPrefixParams(key=RadixKey(array("q", toks)))
).device_indices.numel()
)
def _leaf(self, toks):
return self.cache.match_prefix(
MatchPrefixParams(key=RadixKey(array("q", toks)))
).last_device_node
def test_tag_with_known_node_skips_match_prefix(self):
self._insert([1, 2, 3, 4])
leaf = self._leaf([1, 2, 3, 4])
orig_match_prefix = self.cache.match_prefix
def fail_match_prefix(_params):
raise AssertionError("match_prefix should not run when node is supplied")
self.cache.match_prefix = fail_match_prefix
try:
self.cache._tag_session_leaf(
SimpleNamespace(session_id="S"),
RadixKey(array("q", [1, 2, 3, 4])),
node=leaf,
)
finally:
self.cache.match_prefix = orig_match_prefix
self.assertEqual(getattr(leaf, "session_ids", None), {"S"})
self.assertIn(leaf, self.cache._session_leaves["S"])
def test_disabled_cache_does_not_tag_session_kv(self):
self.cache.enable_session_radix_cache = False
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "S")
self.assertIsNone(getattr(self._leaf([1, 2, 3, 4]), "session_ids", None))
def test_shared_prefix_frees_only_unique_tail(self):
# A/B share prefix [1,2]; close(A) frees only A's tail, B + shared stay.
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "A")
self._insert([1, 2, 5, 6])
self._tag([1, 2, 5, 6], "B")
self.assertGreater(self.cache.release_radix_session("A"), 0)
self.assertEqual(self._cached([1, 2, 3, 4]), 2) # only shared [1,2] left
self.assertEqual(self._cached([1, 2, 5, 6]), 4) # B intact
def test_same_leaf_freed_only_on_last_holder(self):
# Identical content -> one leaf held by {A,B}; freed only on last close.
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "A")
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "B")
self.assertEqual(
getattr(self._leaf([1, 2, 3, 4]), "session_ids", None), {"A", "B"}
)
self.assertEqual(self.cache.release_radix_session("A"), 0) # B still holds
self.assertEqual(self._cached([1, 2, 3, 4]), 4)
self.assertEqual(self.cache.release_radix_session("B"), 1) # last holder frees
self.assertEqual(self._cached([1, 2, 3, 4]), 0)
def test_legacy_release_does_not_release_radix_session(self):
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "S")
self.cache.release_session("S")
self.assertEqual(self._cached([1, 2, 3, 4]), 4)
self.assertEqual(self.cache.release_radix_session("S"), 1)
def test_tag_is_lru_neutral_not_pinned(self):
# The tag must add no lock/pin: a tagged, never-closed node is evictable.
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "S")
leaf = self._leaf([1, 2, 3, 4])
self.assertEqual(leaf.lock_ref, 0)
self.assertEqual(self.cache.protected_size(), 0)
self.assertIn(leaf, self.cache.evictable_leaves)
self.cache.evict(EvictParams(num_tokens=4)) # LRU reclaims it while open
self.assertEqual(self._cached([1, 2, 3, 4]), 0)
self.assertNotIn("S", self.cache._session_leaves)
self.assertEqual(
self.cache.release_radix_session("S"), 0
) # late close is a no-op
def test_close_tombstone_blocks_late_finish(self):
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "S")
self.assertEqual(self.cache.release_radix_session("S"), 1)
self._insert([5, 6, 7, 8])
self._tag([5, 6, 7, 8], "S") # simulates a finish racing after close
self.assertIsNone(getattr(self._leaf([5, 6, 7, 8]), "session_ids", None))
def test_tombstoned_shared_holder_cannot_retag_after_last_holder_close(self):
self._insert([1, 2, 3, 4])
self._tag([1, 2, 3, 4], "A")
self._tag([1, 2, 3, 4], "B")
self.assertEqual(self.cache.release_radix_session("B"), 0)
self.assertEqual(getattr(self._leaf([1, 2, 3, 4]), "session_ids", None), {"A"})
self.assertEqual(self.cache.release_radix_session("A"), 1)
self._insert([5, 6, 7, 8])
self._tag([5, 6, 7, 8], "B")
self.assertIsNone(getattr(self._leaf([5, 6, 7, 8]), "session_ids", None))
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
import copy
import multiprocessing
import os
import traceback
import unittest
from multiprocessing import Process
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from sglang.test.test_utils import CustomTestCase, find_available_port
def run_distributed_test(rank, world_size, master_port, output_writer, fn):
try:
os.environ["RANK"] = str(rank)
os.environ["WORLD_SIZE"] = str(world_size)
os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = str(master_port)
os.environ["LOCAL_SIZE"] = str(world_size)
dist.init_process_group("gloo", rank=rank, world_size=world_size)
torch.ops.sgl_kernel.initialize(world_size, rank)
fn(rank, world_size)
execution_ok = True
except Exception as e:
print(f"subprocess[{rank=}] has error: {e}", flush=True)
traceback.print_exc()
execution_ok = False
output_writer.send(execution_ok)
output_writer.close()
if dist.is_initialized():
dist.destroy_process_group()
def all_reduce_fn(rank, world_size):
op = dist.ReduceOp.SUM
for dtype in [torch.float32, torch.bfloat16, torch.float16]:
tensor = torch.randn(2, 10, dtype=dtype)
tensor_shm = copy.deepcopy(tensor)
dist.all_reduce(tensor, op=op)
torch.ops.sgl_kernel.shm_allreduce(tensor_shm, op)
torch.testing.assert_close(tensor, tensor_shm)
def all_gather_fn(rank, world_size):
dim = -1
for dtype in [torch.float32, torch.bfloat16, torch.float16]:
tensor = torch.randn(2, 10, dtype=dtype)
if dim < 0:
# Convert negative dim to positive.
dim += tensor.dim()
input_size = tensor.size()
output_size = (input_size[0] * world_size,) + input_size[1:]
output_tensor = torch.empty(
output_size, dtype=tensor.dtype, device=tensor.device
)
dist.all_gather_into_tensor(output_tensor, tensor)
output_tensor = output_tensor.reshape((world_size,) + input_size)
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim] + (world_size * input_size[dim],) + input_size[dim + 1 :]
)
output_shm = torch.ops.sgl_kernel.shm_allgather(tensor, dim)
torch.testing.assert_close(output_tensor, output_shm)
class TestComm(CustomTestCase):
def _spawn_and_check(self, fn, world_size=2):
mp.set_start_method("spawn", force=True)
master_port = find_available_port(23456)
processes = []
output_reader, output_writer = multiprocessing.Pipe(duplex=False)
for rank in range(world_size):
p = Process(
target=run_distributed_test,
kwargs=dict(
rank=rank,
world_size=world_size,
master_port=master_port,
output_writer=output_writer,
fn=fn,
),
)
p.start()
processes.append(p)
for _ in range(world_size):
self.assertTrue(output_reader.recv(), "Subprocess fail. Check logs above.")
for p in processes:
p.join()
def test_all_reduce(self):
self._spawn_and_check(all_reduce_fn)
def test_all_gather(self):
self._spawn_and_check(all_gather_fn)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
import torch
import torch.nn as nn
class DummyModel(nn.Module):
def __init__(self, d_in=2048, n_heads=128, softmax_scale=0.5):
super().__init__()
self.weights_proj = nn.Linear(d_in, 1024)
self.n_heads = n_heads
self.softmax_scale = softmax_scale
def _get_logits_head_gate_orig(self, x: torch.Tensor, q_scale: torch.Tensor):
weights = self.weights_proj(x)
weights = weights * self.n_heads**-0.5
q_scale = q_scale.unsqueeze(1) # (B,1,1)
weights = weights.unsqueeze(-1) * q_scale * self.softmax_scale
return weights
def _get_logits_head_gate_opt(self, x: torch.Tensor, q_scale: torch.Tensor):
weights = self.weights_proj(x)
q_scale = q_scale.unsqueeze(1) # (B,1,1)
scale_const = self.n_heads**-0.5 * q_scale * self.softmax_scale # (B,1,1)
weights = weights.unsqueeze(-1) * scale_const # (B,1024,1)
return weights
def main():
torch.manual_seed(0)
model = DummyModel(d_in=2048, n_heads=128, softmax_scale=0.5)
x = torch.randn(128, 2048) # batch=128, d_in=2048
q_scale = torch.randn(128, 1)
import time
start = time.time()
for _ in range(1000):
out_orig = model._get_logits_head_gate_orig(x, q_scale)
print("Original version time:", time.time() - start)
start = time.time()
for _ in range(1000):
out_opt = model._get_logits_head_gate_opt(x, q_scale)
print("Optimized version time:", time.time() - start)
print("Difference:", (out_orig - out_opt).abs().max().item())
assert torch.allclose(out_orig, out_opt), "Mismatch between original and optimized"
if __name__ == "__main__":
main()
"""
Original version time: 0.49235057830810547
Optimized version time: 0.4087331295013428
Difference: 1.4901161193847656e-08
"""
+53
View File
@@ -0,0 +1,53 @@
"""
Simple wrapper to run a test file with retry logic.
Usage:
python3 -m sglang.test.ci.run_with_retry test_file.py [--max-attempts 2] [--retry-wait 60]
"""
import argparse
import sys
from sglang.test.ci.ci_utils import TestFile, run_unittest_files
def main():
parser = argparse.ArgumentParser(description="Run a test file with retry logic")
parser.add_argument("test_file", help="The test file to run")
parser.add_argument(
"--max-attempts",
type=int,
default=2,
help="Maximum number of attempts (default: 2)",
)
parser.add_argument(
"--retry-wait",
type=int,
default=60,
help="Seconds to wait between retries (default: 60)",
)
parser.add_argument(
"--timeout",
type=int,
default=1200,
help="Timeout per attempt in seconds (default: 1200)",
)
args = parser.parse_args()
# Create a TestFile with a reasonable estimated time
test_file = TestFile(name=args.test_file, estimated_time=args.timeout)
exit_code = run_unittest_files(
files=[test_file],
timeout_per_file=args.timeout,
continue_on_error=False,
enable_retry=True,
max_attempts=args.max_attempts,
retry_wait_seconds=args.retry_wait,
)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""Unit tests for dump_metric() function."""
import json
import os
import tempfile
import unittest
from pathlib import Path
from sglang.test.test_utils import dump_metric
class TestDumpMetric(unittest.TestCase):
"""Test suite for dump_metric() function."""
_ENV_KEYS_TO_CLEAN = ["SGLANG_TEST_METRICS_OUTPUT", "PYTEST_CURRENT_TEST"]
def setUp(self):
"""Clean up env vars before each test."""
for key in self._ENV_KEYS_TO_CLEAN:
os.environ.pop(key, None)
def tearDown(self):
"""Clean up env vars after each test."""
for key in self._ENV_KEYS_TO_CLEAN:
os.environ.pop(key, None)
def test_writes_valid_jsonl(self):
"""Test that dump_metric writes one valid JSON line when env is set."""
with tempfile.TemporaryDirectory() as tmpdir:
base_path = os.path.join(tmpdir, "metrics")
os.environ["SGLANG_TEST_METRICS_OUTPUT"] = base_path
dump_metric("test_accuracy", 0.95, labels={"model": "llama"})
# Check file exists with PID suffix
pid = os.getpid()
jsonl_path = f"{base_path}.{pid}.jsonl"
self.assertTrue(os.path.exists(jsonl_path))
# Read and validate
with open(jsonl_path, encoding="utf-8") as f:
lines = f.readlines()
self.assertEqual(len(lines), 1)
record = json.loads(lines[0])
# Validate required fields
self.assertIn("filename", record)
self.assertIn("test_case", record)
self.assertEqual(record["metric_name"], "test_accuracy")
self.assertEqual(record["value"], 0.95)
# Validate optional fields
self.assertIn("ts", record)
self.assertIsInstance(record["ts"], (int, float))
self.assertEqual(record["labels"], {"model": "llama"})
def test_no_env_no_file(self):
"""Test that dump_metric doesn't create file when env var not set."""
with tempfile.TemporaryDirectory() as tmpdir:
# Don't set env var
dump_metric("test_metric", 42)
# Verify no files created
files = list(Path(tmpdir).glob("*.jsonl"))
self.assertEqual(len(files), 0)
def test_labels_not_serializable_stringified(self):
"""Test that non-serializable labels are stringified."""
with tempfile.TemporaryDirectory() as tmpdir:
base_path = os.path.join(tmpdir, "metrics")
os.environ["SGLANG_TEST_METRICS_OUTPUT"] = base_path
# Non-serializable label
class NonSerializable:
pass
dump_metric("test_metric", 100, labels={"obj": NonSerializable()})
pid = os.getpid()
jsonl_path = f"{base_path}.{pid}.jsonl"
with open(jsonl_path, encoding="utf-8") as f:
lines = f.readlines()
record = json.loads(lines[0])
self.assertIn("labels", record)
self.assertIsInstance(record["labels"], str)
def test_bool_to_int(self):
"""Test that bool values are converted to int."""
with tempfile.TemporaryDirectory() as tmpdir:
base_path = os.path.join(tmpdir, "metrics")
os.environ["SGLANG_TEST_METRICS_OUTPUT"] = base_path
dump_metric("bool_true", True)
dump_metric("bool_false", False)
pid = os.getpid()
jsonl_path = f"{base_path}.{pid}.jsonl"
with open(jsonl_path, encoding="utf-8") as f:
lines = f.readlines()
self.assertEqual(len(lines), 2)
record1 = json.loads(lines[0])
record2 = json.loads(lines[1])
self.assertEqual(record1["value"], 1) # True -> 1
self.assertEqual(record2["value"], 0) # False -> 0
def test_pytest_current_test_parsing(self):
"""Test PYTEST_CURRENT_TEST parsing for test_case."""
with tempfile.TemporaryDirectory() as tmpdir:
base_path = os.path.join(tmpdir, "metrics")
os.environ["SGLANG_TEST_METRICS_OUTPUT"] = base_path
os.environ["PYTEST_CURRENT_TEST"] = (
"test/srt/test_example.py::TestClass::test_method (call)"
)
dump_metric("pytest_metric", 123)
pid = os.getpid()
jsonl_path = f"{base_path}.{pid}.jsonl"
with open(jsonl_path, encoding="utf-8") as f:
lines = f.readlines()
record = json.loads(lines[0])
# Only assert test_case parsing, not filename
self.assertEqual(record["test_case"], "TestClass.test_method")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,28 @@
import json
import unittest
from sglang.srt.debug_utils import log_parser
from sglang.test.test_utils import CustomTestCase
class TestLogParser(CustomTestCase):
def test_log_parser(self):
lines = """
(SGLangEngine pid=35555) [2025-10-31 03:45:20 TP0] Decode batch [51341], #running-req: 317, #token: 1094261, token usage: 0.67, cuda graph: True, gen throughput (token/s): 14806.57, #queue-req: 0,
(SGLangEngine pid=111711, ip=10.15.36.1) [2025-10-31 03:45:20 TP0] Decode batch [39913], #running-req: 78, #token: 432100, token usage: 0.27, cuda graph: True, gen throughput (token/s): 7269.16, #queue-req: 0,
[2025-11-03 14:31:10 DP6 TP6 EP6] Decode batch, #running-req: 251, #token: 2811200, token usage: 1.00, cuda graph: True, gen throughput (token/s): 2055.94, #queue-req: 655,
"""
expect_rows = json.loads(
"""[{"line":"(SGLangEngine pid=35555) [2025-10-31 03:45:20 TP0] Decode batch [51341], #running-req: 317, #token: 1094261, token usage: 0.67, cuda graph: True, gen throughput (token/s): 14806.57, #queue-req: 0,","1":"(SGLangEngine pid=35555)","pid":35555,"ip":null,"time":"2025-10-31 03:45:20","dp_rank":null,"tp_rank":0,"ep_rank":null,"pp_rank":null,"9":" [51341]","num_running_req":317,"num_token":1094261,"token_usage":0.67,"gen_throughput":14806.57,"queue_req":0},{"line":"(SGLangEngine pid=111711, ip=10.15.36.1) [2025-10-31 03:45:20 TP0] Decode batch [39913], #running-req: 78, #token: 432100, token usage: 0.27, cuda graph: True, gen throughput (token/s): 7269.16, #queue-req: 0,","1":"(SGLangEngine pid=111711, ip=10.15.36.1)","pid":111711,"ip":"10.15.36.1","time":"2025-10-31 03:45:20","dp_rank":null,"tp_rank":0,"ep_rank":null,"pp_rank":null,"9":" [39913]","num_running_req":78,"num_token":432100,"token_usage":0.27,"gen_throughput":7269.16,"queue_req":0},{"line":"[2025-11-03 14:31:10 DP6 TP6 EP6] Decode batch, #running-req: 251, #token: 2811200, token usage: 1.00, cuda graph: True, gen throughput (token/s): 2055.94, #queue-req: 655,","1":null,"pid":null,"ip":null,"time":"2025-11-03 14:31:10","dp_rank":6,"tp_rank":6,"ep_rank":6,"pp_rank":null,"9":null,"num_running_req":251,"num_token":2811200,"token_usage":1.0,"gen_throughput":2055.94,"queue_req":655}]""",
)
df = log_parser.parse(lines)
print(df)
print(df.write_json())
assert len(df) == len(lines.strip().splitlines()), f"{len(df)=}"
self.assertEqual(json.loads(df.write_json()), expect_rows)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,101 @@
"""Archived test classes split out of test/registered/distributed/test_dp_attention.py.
Originally registered with `register_cuda_ci(...)`. Moved here as part of
the per-commit pruning effort to keep the code reachable manually.
Run with `python3 test/manual/distributed/test_dp_attention_archived.py`.
"""
import unittest
from types import SimpleNamespace
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.kits.ebnf_constrained_kit import EBNFConstrainedMixin
from sglang.test.kits.json_constrained_kit import JSONConstrainedMixin
from sglang.test.kits.regex_constrained_kit import RegexConstrainedMixin
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_MODEL_NAME_FOR_TEST_MLA,
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_amd_ci,
popen_launch_server,
)
class TestDPAttentionDP2TP2DeepseekV3MTP(
CustomTestCase,
JSONConstrainedMixin,
EBNFConstrainedMixin,
RegexConstrainedMixin,
):
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL_NAME_FOR_TEST_MLA
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--disable-radix",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"2",
"--speculative-eagle-topk",
"4",
"--speculative-num-draft-tokens",
"4",
"--speculative-draft-model-path",
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
"--tp-size",
"2",
"--enable-dp-attention",
"--dp-size",
"2",
]
if not is_in_amd_ci():
other_args += ["--mem-frac", "0.7"]
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=200,
num_threads=128,
)
metrics = run_eval(args)
print(metrics)
self.assertGreater(metrics["score"], 0.60)
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"###test_gsm8k (deepseek-v3 mtp + dp):\n"
f"accuracy={metrics['score']=:.3f}\n"
f"{avg_spec_accept_length=:.3f}\n"
)
self.assertGreater(avg_spec_accept_length, 2.5)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,188 @@
import unittest
from types import SimpleNamespace
import requests
from sglang.lang.chat_template import get_chat_template_by_model_path
from sglang.srt.utils import kill_process_tree
from sglang.test.kits.ebnf_constrained_kit import EBNFConstrainedMixin
from sglang.test.kits.json_constrained_kit import JSONConstrainedMixin
from sglang.test.kits.regex_constrained_kit import RegexConstrainedMixin
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_IMAGE_URL,
DEFAULT_MLA_MODEL_NAME_FOR_TEST,
DEFAULT_MODEL_NAME_FOR_TEST_MLA,
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_amd_ci,
popen_launch_server,
)
@unittest.skipIf(
is_in_amd_ci(),
"DeepSeek MLA forward_mla NameError on AMD (batched_gemm not defined)",
)
class TestDPAttentionDP2TP4(
CustomTestCase,
JSONConstrainedMixin,
EBNFConstrainedMixin,
RegexConstrainedMixin,
):
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MLA_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--tp=4",
"--enable-dp-attention",
"--dp=2",
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k(self):
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="gsm8k",
num_examples=None,
num_threads=1024,
)
metrics = run_eval(args)
print(f"{metrics=}")
self.assertGreater(metrics["score"], 0.8)
@unittest.skipIf(
is_in_amd_ci(),
"DeepSeek MTP forward_mla NameError on AMD + needs 8 GPUs",
)
class TestDPAttentionDP2TP2DeepseekV3MTP(
CustomTestCase,
JSONConstrainedMixin,
EBNFConstrainedMixin,
RegexConstrainedMixin,
):
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL_NAME_FOR_TEST_MLA
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--disable-radix",
"--speculative-algorithm=EAGLE",
"--speculative-num-steps=2",
"--speculative-eagle-topk=4",
"--speculative-num-draft-tokens=4",
"--speculative-draft-model-path",
DEFAULT_MODEL_NAME_FOR_TEST_MLA_NEXTN,
"--tp-size=4",
"--enable-dp-attention",
"--dp-size=2",
]
if not is_in_amd_ci():
other_args += ["--mem-frac", "0.7"]
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=200,
num_threads=128,
)
metrics = run_eval(args)
print(metrics)
self.assertGreater(metrics["score"], 0.60)
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"###test_gsm8k (deepseek-v3 mtp + dp):\n"
f"accuracy={metrics['score']=:.3f}\n"
f"{avg_spec_accept_length=:.3f}\n"
)
self.assertGreater(avg_spec_accept_length, 2.5)
@unittest.skipIf(
is_in_amd_ci(),
"Qwen3-VL-30B-A3B-Instruct OOMs at TP=4 DP=2 on MI325 4-GPU runners",
)
class TestDPAttentionDP2TP4VLM(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = "Qwen/Qwen3-VL-30B-A3B-Instruct"
cls.base_url = DEFAULT_URL_FOR_TEST
cls.image_url = DEFAULT_IMAGE_URL
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--tp",
"4",
"--enable-dp-attention",
"--dp",
"2",
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_vlm_generate(self):
chat_template = get_chat_template_by_model_path(self.model)
prompt = f"{chat_template.image_token}What is in this image?"
response = requests.post(
self.base_url + "/generate",
json={
"text": prompt,
"image_data": [self.image_url],
"sampling_params": {
"temperature": 0,
"max_new_tokens": 16,
},
},
)
response.raise_for_status()
response_json = response.json()
print(response_json)
self.assertIn("output_ids", response_json)
self.assertGreater(len(response_json["output_ids"]), 0)
if __name__ == "__main__":
unittest.main()
View File
+317
View File
@@ -0,0 +1,317 @@
"""Shared fixture for DeepSeek-V4 cookbook launch-command tests.
Each sibling ``test_<hardware>_<model_size>.py`` declares ONE
``hardware x model_size`` cell from the cookbook (e.g. B200 x Flash)
and contains one ``CustomTestCase`` subclass per recipe
(Low-Latency / Balanced / Max-Throughput / CP, where supported).
Each subclass launches the server with the cookbook's exact flags and
runs two sgl-eval evaluations (https://github.com/sgl-project/sgl-eval):
- ``test_smoke_gsm8k`` — short, cheap GSM8K pass to verify the server
can produce coherent math answers at all (sanity gate).
- ``test_aime25`` — full AIME25 accuracy run (heavy; 16 repeats default).
Cookbook reference:
https://docs.sglang.io/cookbook/autoregressive/DeepSeek/DeepSeek-V4
These are MANUAL tests (not CI). ``sgl-eval`` must be on PATH.
Per-variant defaults (set on the Flash/Pro intermediate base classes):
Flash recipes -> AIME25 score threshold 0.93
Pro recipes -> AIME25 score threshold 0.95
GSM8K sanity threshold (0.93) is shared across Flash and Pro.
AIME25 knobs (env vars):
DSV4_AIME25_NUM_REPEATS (default 16 -> --n-repeats)
DSV4_AIME25_TEMPERATURE (default 1.0 -> --temperature)
DSV4_AIME25_TOP_P (default 1.0 -> --top-p)
DSV4_AIME25_MAX_TOKENS (default 65536 -> --max-tokens)
DSV4_AIME25_NUM_THREADS (default 512 -> --num-threads)
DSV4_AIME25_SCORE_METRIC (default "score"; sgl-eval JSON key under "aggregate")
DSV4_AIME25_SCORE_THRESHOLD (default 0; >0 overrides per-variant default)
GSM8K sanity knobs (env vars):
DSV4_GSM8K_NUM_EXAMPLES (default 50 -> --num-examples)
DSV4_GSM8K_N_REPEATS (default 1 -> --n-repeats)
DSV4_GSM8K_TEMPERATURE (default 0.6 -> --temperature)
DSV4_GSM8K_TOP_P (default 0.95 -> --top-p)
DSV4_GSM8K_MAX_TOKENS (default 8192 -> --max-tokens)
DSV4_GSM8K_NUM_THREADS (default 64 -> --num-threads)
DSV4_GSM8K_SCORE_METRIC (default "score"; sgl-eval JSON key under "aggregate")
DSV4_GSM8K_SCORE_THRESHOLD (default 0.93; set to 0 to skip the assertion)
Shared knobs:
DSV4_SGL_EVAL_OUT_DIR (default /tmp/sgl-eval-out -> --out-dir)
DSV4_SGL_EVAL_BIN (default "sgl-eval"; override path to the CLI)
DSV4_SERVER_LAUNCH_TIMEOUT (default 3600s; the sglang 600s default is
too short for DSV4 model load + DeepGEMM
warmup. 1800s is also tight for the heavier
recipes (DP-attn + DeepEP); 3600s is the
safe default. Bump again for first-run
model downloads if needed.)
Multi-node knobs (only consumed by multi-node test classes; if either
is unset, those classes ``SkipTest``):
DSV4_NODE_RANK (per-node rank for --node-rank)
DSV4_DIST_INIT_ADDR (e.g. 10.0.0.1:20000 for --dist-init-addr)
Always-on env (set by the base class for every recipe; per-recipe EXTRA_ENV
wins on key conflict):
SGLANG_JIT_DEEPGEMM_FAST_WARMUP=1 skip the slow DeepGEMM warmup grid
"""
import json
import os
import shutil
import subprocess
import unittest
from pathlib import Path
from typing import ClassVar, Dict, List, Optional
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
SGL_EVAL_BIN = os.environ.get("DSV4_SGL_EVAL_BIN", "sgl-eval")
SGL_EVAL_OUT_DIR = os.environ.get("DSV4_SGL_EVAL_OUT_DIR", "/tmp/sgl-eval-out")
# DSV4 server launch needs more than the 600s sglang default: model load alone
# can take 5+ min and DeepGEMM warmup another ~5 min. First-run model download
# adds ~10-30 min on top. 1800s covers steady-state; bump via env for downloads.
SERVER_LAUNCH_TIMEOUT = int(os.environ.get("DSV4_SERVER_LAUNCH_TIMEOUT", "3600"))
# Defaults applied to every recipe's EXTRA_ENV. Per-recipe EXTRA_ENV wins on key
# conflict.
BASE_ENV: Dict[str, str] = {
# Skip the slow exhaustive DeepGEMM warmup grid; covers the shapes DSV4
# actually hits and shaves several minutes off server startup.
"SGLANG_JIT_DEEPGEMM_FAST_WARMUP": "1",
}
AIME25_NUM_REPEATS = int(os.environ.get("DSV4_AIME25_NUM_REPEATS", "16"))
AIME25_TEMPERATURE = float(os.environ.get("DSV4_AIME25_TEMPERATURE", "1.0"))
AIME25_TOP_P = float(os.environ.get("DSV4_AIME25_TOP_P", "1.0"))
AIME25_MAX_TOKENS = int(os.environ.get("DSV4_AIME25_MAX_TOKENS", "65536"))
AIME25_NUM_THREADS = int(os.environ.get("DSV4_AIME25_NUM_THREADS", "512"))
AIME25_SCORE_METRIC = os.environ.get("DSV4_AIME25_SCORE_METRIC", "score")
AIME25_SCORE_THRESHOLD = float(os.environ.get("DSV4_AIME25_SCORE_THRESHOLD", "0.0"))
GSM8K_NUM_EXAMPLES = int(os.environ.get("DSV4_GSM8K_NUM_EXAMPLES", "50"))
GSM8K_N_REPEATS = int(os.environ.get("DSV4_GSM8K_N_REPEATS", "1"))
GSM8K_TEMPERATURE = float(os.environ.get("DSV4_GSM8K_TEMPERATURE", "0.6"))
GSM8K_TOP_P = float(os.environ.get("DSV4_GSM8K_TOP_P", "0.95"))
GSM8K_MAX_TOKENS = int(os.environ.get("DSV4_GSM8K_MAX_TOKENS", "8192"))
GSM8K_NUM_THREADS = int(os.environ.get("DSV4_GSM8K_NUM_THREADS", "64"))
GSM8K_SCORE_METRIC = os.environ.get("DSV4_GSM8K_SCORE_METRIC", "score")
GSM8K_SCORE_THRESHOLD = float(os.environ.get("DSV4_GSM8K_SCORE_THRESHOLD", "0.93"))
# DeepEP "large SMS" config — appears as `--deepep-config '{...}'` in every
# DeepEP recipe except multi-node ones (where it is gated off in the JSX).
DEEPEP_LARGE_SMS_CONFIG = (
'{"normal_dispatch":{"num_sms":96},"normal_combine":{"num_sms":96}}'
)
def multinode_args(nnodes: int) -> List[str]:
"""Return CLI args for a multi-node launch, or skip the test.
Reads DSV4_NODE_RANK and DSV4_DIST_INIT_ADDR from the env. Raises
``unittest.SkipTest`` when either is missing — call from inside
``setUpClass`` so the whole class skips cleanly.
"""
rank = os.environ.get("DSV4_NODE_RANK")
addr = os.environ.get("DSV4_DIST_INIT_ADDR")
if rank is None or addr is None:
raise unittest.SkipTest(
"multi-node test requires DSV4_NODE_RANK and DSV4_DIST_INIT_ADDR"
)
return [
"--nnodes",
str(nnodes),
"--node-rank",
rank,
"--dist-init-addr",
addr,
]
class DSV4Aime25TestBase(CustomTestCase):
"""Subclass via ``DSV4FlashAime25TestBase`` or ``DSV4ProAime25TestBase``,
not directly. Per-recipe subclasses set MODEL / OTHER_ARGS / EXTRA_ENV.
SCORE_THRESHOLD is set by the Flash/Pro intermediate base classes:
Flash 0.93, Pro 0.95.
"""
MODEL: ClassVar[str] = ""
OTHER_ARGS: ClassVar[List[str]] = []
EXTRA_ENV: ClassVar[Dict[str, str]] = {}
SCORE_THRESHOLD: ClassVar[float] = 0.0
_BASE_CLASSES: ClassVar[set] = set()
@classmethod
def setUpClass(cls):
if cls in cls._BASE_CLASSES:
raise unittest.SkipTest("base class; subclass to run")
if not cls.MODEL or not cls.OTHER_ARGS:
raise unittest.SkipTest(f"{cls.__name__}: MODEL and OTHER_ARGS must be set")
cls.base_url = DEFAULT_URL_FOR_TEST
env: Optional[Dict[str, str]] = {**BASE_ENV, **(cls.EXTRA_ENV or {})}
cls.process = popen_launch_server(
cls.MODEL,
cls.base_url,
timeout=SERVER_LAUNCH_TIMEOUT,
other_args=list(cls.OTHER_ARGS),
env=env,
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
def test_smoke_gsm8k(self):
"""Quick GSM8K pass to verify the server is producing math answers."""
self._run_sgl_eval(
eval_name="gsm8k",
n_repeats=GSM8K_N_REPEATS,
temperature=GSM8K_TEMPERATURE,
top_p=GSM8K_TOP_P,
max_tokens=GSM8K_MAX_TOKENS,
num_threads=GSM8K_NUM_THREADS,
num_examples=GSM8K_NUM_EXAMPLES,
metric=GSM8K_SCORE_METRIC,
threshold=GSM8K_SCORE_THRESHOLD,
)
def test_aime25(self):
"""Full AIME25 accuracy run; threshold gated by Flash vs Pro base."""
threshold = (
AIME25_SCORE_THRESHOLD
if AIME25_SCORE_THRESHOLD > 0
else self.SCORE_THRESHOLD
)
self._run_sgl_eval(
eval_name="aime25",
n_repeats=AIME25_NUM_REPEATS,
temperature=AIME25_TEMPERATURE,
top_p=AIME25_TOP_P,
max_tokens=AIME25_MAX_TOKENS,
num_threads=AIME25_NUM_THREADS,
num_examples=None,
metric=AIME25_SCORE_METRIC,
threshold=threshold,
)
def _run_sgl_eval(
self,
eval_name,
n_repeats,
temperature,
top_p,
max_tokens,
num_threads,
num_examples,
metric,
threshold,
):
if shutil.which(SGL_EVAL_BIN) is None:
self.skipTest(f"{SGL_EVAL_BIN!r} not found on PATH")
out_dir = Path(SGL_EVAL_OUT_DIR)
out_dir.mkdir(parents=True, exist_ok=True)
glob_pattern = f"sgl_eval_{eval_name}_*.json"
before = set(out_dir.glob(glob_pattern))
cmd = [
SGL_EVAL_BIN,
"run",
eval_name,
"--base-url",
f"{self.base_url}/v1",
"--n-repeats",
str(n_repeats),
"--temperature",
str(temperature),
"--top-p",
str(top_p),
"--max-tokens",
str(max_tokens),
"--num-threads",
str(num_threads),
"--out-dir",
str(out_dir),
]
if num_examples is not None:
cmd += ["--num-examples", str(num_examples)]
print(f"[{type(self).__name__}] + {' '.join(cmd)}", flush=True)
subprocess.run(cmd, check=True)
new = sorted(set(out_dir.glob(glob_pattern)) - before)
if not new:
self.fail(f"sgl-eval produced no new {eval_name} JSON in {out_dir}")
result_path = new[-1]
with open(result_path) as f:
result = json.load(f)
print(
f"[{type(self).__name__}] sgl-eval {eval_name} result "
f"({result_path.name}): {json.dumps(result, indent=2)}",
flush=True,
)
score = self._extract_score(result, metric)
if threshold > 0:
self.assertGreaterEqual(
score,
threshold,
f"{eval_name} {metric}={score} below threshold {threshold}",
)
@staticmethod
def _extract_score(result, metric):
"""Find ``metric`` (e.g. "pass@1") anywhere in the sgl-eval JSON tree."""
def walk(o):
if isinstance(o, dict):
if metric in o and isinstance(o[metric], (int, float)):
return float(o[metric])
for v in o.values():
s = walk(v)
if s is not None:
return s
elif isinstance(o, list):
for v in o:
s = walk(v)
if s is not None:
return s
return None
score = walk(result)
if score is None:
raise AssertionError(f"metric {metric!r} not found in sgl-eval result JSON")
return score
class DSV4FlashAime25TestBase(DSV4Aime25TestBase):
"""Base for DeepSeek-V4-Flash recipes: AIME25 threshold 0.93."""
SCORE_THRESHOLD = 0.93
class DSV4ProAime25TestBase(DSV4Aime25TestBase):
"""Base for DeepSeek-V4-Pro recipes: AIME25 threshold 0.95."""
SCORE_THRESHOLD = 0.95
DSV4Aime25TestBase._BASE_CLASSES = {
DSV4Aime25TestBase,
DSV4FlashAime25TestBase,
DSV4ProAime25TestBase,
}
+108
View File
@@ -0,0 +1,108 @@
"""B200 (FP4) x DeepSeek-V4-Flash.
Covers the four cookbook recipes for this hardware x model_size cell:
Low-Latency, Balanced, Max-Throughput, Context-Parallel (CP).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4FlashAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Flash"
class TestB200FlashLowLatency(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
]
EXTRA_ENV = {}
class TestB200FlashBalanced(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestB200FlashMaxThroughput(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestB200FlashCP(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--max-running-requests",
"1024",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
"""B200 (FP4) x DeepSeek-V4-Pro.
Covers the four cookbook recipes for this hardware x model_size cell:
Low-Latency, Balanced, Max-Throughput, Context-Parallel (CP).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4ProAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Pro"
class TestB200ProLowLatency(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
class TestB200ProBalanced(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--mem-fraction-static",
"0.82",
"--cuda-graph-max-bs-decode",
"64",
"--max-running-requests",
"128",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestB200ProMaxThroughput(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--mem-fraction-static",
"0.82",
"--cuda-graph-max-bs-decode",
"64",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestB200ProCP(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--cuda-graph-max-bs-decode",
"256",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256",
}
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""B300 x DeepSeek-V4-Flash.
The cookbook generator aliases B300 to B200, so the launch flags
are identical to the B200(FP4) Flash cell. Kept as a separate file
because the hardware target (and therefore the runtime environment)
is different. Covers Low-Latency, Balanced, Max-Throughput, CP.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4FlashAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Flash"
class TestB300FlashLowLatency(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
]
EXTRA_ENV = {}
class TestB300FlashBalanced(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestB300FlashMaxThroughput(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestB300FlashCP(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--max-running-requests",
"1024",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""B300 x DeepSeek-V4-Pro.
The cookbook generator aliases B300 to B200, so the launch flags
are identical to the B200(FP4) Pro cell. Kept as a separate file
because the hardware target (and therefore the runtime environment)
is different. Covers Low-Latency, Balanced, Max-Throughput, CP.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4ProAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Pro"
class TestB300ProLowLatency(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
class TestB300ProBalanced(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--mem-fraction-static",
"0.82",
"--cuda-graph-max-bs-decode",
"64",
"--max-running-requests",
"128",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestB300ProMaxThroughput(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--dp",
"8",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--mem-fraction-static",
"0.82",
"--cuda-graph-max-bs-decode",
"64",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestB300ProCP(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--cuda-graph-max-bs-decode",
"256",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256",
}
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
"""DSV4 Flash MTP test using EAGLE speculative algorithm.
DSV4 Flash MTP shares the EAGLE wire path: EAGLE algo + NextN head built
into the target model weights. No separate draft model is needed (sglang
auto-falls back `--speculative-draft-model-path` to the target model).
Test matrix mirrors test_eagle_infer_b.TestEAGLEServerBasic to maximize
cuda-graph + buffer-pool coverage on the DSV4 path:
- test_gsm8k (accuracy + spec path full forward)
- test_max_token_one (degenerate spec step, still cuda-graph captured)
- test_request_abort (cuda-graph buffer pool survives abort+restart)
Server launch matches `run_flash_dp4.sh`: tp=4, dp=4, deepep MoE backend,
DSV4 FP8 (FP4 experts disabled).
"""
import random
import threading
import time
import unittest
from types import SimpleNamespace
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.few_shot_gsm8k import run_eval as run_gsm8k_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
DSV4_FLASH_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
# MTP runs ~num_draft_tokens forward passes per step, so the deepep
# dispatch input size scales by that factor. Default 256 (used by the
# plain server) overflows once cuda-graph-max-bs-decode * num_draft_tokens
# > 256. 1024 covers bs=128 * 4 draft tokens with headroom.
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
DEEPEP_CONFIG = '{"normal_dispatch":{"num_sms":96},"normal_combine":{"num_sms":96}}'
PROMPTS = [
"[INST] You are a helpful assistant.\\nWhere are you from? [/INST]",
"[INST] You are a helpful assistant.\\nSummarize gradient descent in 2 sentences. [/INST]",
"[INST] You are a helpful assistant.\\nWhat is 17*23? [/INST]",
"[INST] You are a helpful assistant.\\nList three primary colors. [/INST]",
]
class DSV4FlashMTPServerBase(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DSV4_FLASH_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_CONFIG,
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--mem-fraction-static",
"0.7",
]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=other_args,
env=DSV4_FLASH_ENV,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def send_request(self):
time.sleep(random.uniform(0, 2))
for prompt in PROMPTS:
resp = requests.post(
self.base_url + "/generate",
json={
"text": prompt,
"sampling_params": {"temperature": 0, "max_new_tokens": 256},
},
)
assert resp.status_code == 200
def send_requests_abort(self):
for prompt in PROMPTS:
try:
time.sleep(random.uniform(0, 2))
requests.post(
self.base_url + "/generate",
json={
"text": prompt,
"sampling_params": {"temperature": 0, "max_new_tokens": 256},
},
timeout=0.5,
)
except requests.exceptions.Timeout:
pass
class TestDSV4FlashMTPBasic(DSV4FlashMTPServerBase):
def test_gsm8k(self):
"""Accuracy + spec path full forward."""
requests.get(self.base_url + "/flush_cache")
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=200,
max_new_tokens=512,
parallel=128,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_gsm8k_eval(args)
print(f"{metrics=}")
self.assertGreater(metrics["accuracy"], 0.95)
def test_max_token_one(self):
"""Degenerate spec step (still cuda-graph captured)."""
requests.get(self.base_url + "/flush_cache")
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=100,
max_new_tokens=1,
parallel=128,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_gsm8k_eval(args)
self.assertGreater(metrics["output_throughput"], 50)
def test_request_abort(self):
"""Cuda-graph buffer pool must survive abort+restart cycles."""
concurrency = 4
threads = [
threading.Thread(target=self.send_request) for _ in range(concurrency)
] + [
threading.Thread(target=self.send_requests_abort)
for _ in range(concurrency)
]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertIsNone(self.process.poll())
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""DSV4-Flash 285B MTP performance tests on H200 TP=8.
Manual test (8× H200, 285B FP8 weights). Not registered in CI.
"""
import os
import tempfile
import unittest
import requests
from sglang.bench_one_batch_server import BenchArgs as OneBatchBenchArgs
from sglang.bench_one_batch_server import run_benchmark as run_one_batch_benchmark
from sglang.srt.server_args import ServerArgs
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,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
DSV4_FLASH_BASE_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
}
DSV4_FLASH_SERVER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--max-running-requests",
"8",
]
def _launch_dsv4_flash_server(extra_env=None):
env = dict(DSV4_FLASH_BASE_ENV)
if extra_env:
env.update(extra_env)
return popen_launch_server(
DSV4_FLASH_MODEL_PATH,
DEFAULT_URL_FOR_TEST,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 4,
other_args=DSV4_FLASH_SERVER_ARGS,
env=env,
)
class TestDSV4FlashMTPSimulatedAcc(CustomTestCase):
"""bs=1 latency at isl=4096 / 900000 with `SGLANG_SIMULATE_ACC_LEN=3`.
Reference (H200 Flash TP8):
- isl=4096 → output 258.1 tok/s, accept 2.94
- isl=900000 → output 222.9 tok/s, accept 2.90
"""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch_dsv4_flash_server(
extra_env={"SGLANG_SIMULATE_ACC_LEN": "3"}
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
def _run_one_batch(self, input_len):
requests.get(self.base_url + "/flush_cache")
server_args = ServerArgs(model_path=DSV4_FLASH_MODEL_PATH)
bench_args = OneBatchBenchArgs(
run_name=f"dsv4_flash_simacc_isl{input_len}",
batch_size=(1,),
input_len=(input_len,),
output_len=(1024,),
base_url=self.base_url,
skip_warmup=True,
result_filename=os.path.join(
tempfile.gettempdir(), f"dsv4_flash_simacc_isl{input_len}.jsonl"
),
append_to_github_summary=False,
)
results, _ = run_one_batch_benchmark(server_args, bench_args)
self.assertTrue(results, "bench_one_batch_server returned no results")
return results[0]
def test_isl_4096(self):
r = self._run_one_batch(4096)
print(
f"[flash simacc isl=4096] output_throughput={r.output_throughput:.2f} tok/s "
f"latency={r.latency:.2f}s last_ttft={r.last_ttft:.2f}s "
f"acc_length={r.acc_length:.2f}"
)
# Reference 258.1 tok/s / acc=2.94.
self.assertGreater(r.output_throughput, 232.0)
self.assertGreater(r.acc_length, 2.85)
def test_isl_900k(self):
r = self._run_one_batch(900_000)
print(
f"[flash simacc isl=900k] output_throughput={r.output_throughput:.2f} tok/s "
f"latency={r.latency:.2f}s last_ttft={r.last_ttft:.2f}s "
f"acc_length={r.acc_length:.2f}"
)
# Reference 222.9 tok/s / acc=2.90.
self.assertGreater(r.output_throughput, 200.0)
self.assertGreater(r.acc_length, 2.85)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,160 @@
"""DSV4-Flash 4-GPU server sanity matrix (TP4 variants)."""
import unittest
from sglang.srt.utils import kill_process_tree
from sglang.test.kits.basic_decode_correctness_kit import BasicDecodeCorrectnessMixin
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
DSV4_FLASH_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
DEEPEP_CONFIG = '{"normal_dispatch":{"num_sms":96},"normal_combine":{"num_sms":96}}'
def _launch(other_args, env_extra=None, timeout_mult=1):
env = dict(DSV4_FLASH_ENV)
if env_extra:
env.update(env_extra)
return popen_launch_server(
DSV4_FLASH_MODEL_PATH,
DEFAULT_URL_FOR_TEST,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * timeout_mult,
other_args=other_args,
env=env,
)
_EAGLE_SPEC_ARGS = [
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
]
class TestDSV4FlashTP4DP4(
BasicDecodeCorrectnessMixin,
CustomTestCase,
):
"""TP4 + DP4 + deepep + EAGLE MTP."""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch(
[
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_CONFIG,
"--mem-fraction-static",
"0.7",
*_EAGLE_SPEC_ARGS,
]
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
class TestDSV4FlashTP4EP(
BasicDecodeCorrectnessMixin,
CustomTestCase,
):
"""TP attn + EP MoE (no DP attn) — exercises the DeepEP + TP-attn path."""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch(
[
"--trust-remote-code",
"--tp",
"4",
"--ep",
"4",
# No --enable-dp-attention by design: covers TP-attn path.
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"64",
"--deepep-config",
DEEPEP_CONFIG,
"--mem-fraction-static",
"0.7",
*_EAGLE_SPEC_ARGS,
]
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
class TestDSV4FlashTP4DP4ChunkedPrefillLarge(
BasicDecodeCorrectnessMixin,
CustomTestCase,
):
"""TP4 + DP4 with --chunked-prefill-size 16384 — large chunked prefill."""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch(
[
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--chunked-prefill-size",
"16384",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_CONFIG,
"--mem-fraction-static",
"0.7",
*_EAGLE_SPEC_ARGS,
]
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,53 @@
"""DSV4-Flash 8-GPU server sanity (TP8, no spec decoding)."""
import unittest
from sglang.srt.utils import kill_process_tree
from sglang.test.kits.basic_decode_correctness_kit import BasicDecodeCorrectnessMixin
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
DSV4_FLASH_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
class TestDSV4FlashTP8NoSpec(
BasicDecodeCorrectnessMixin,
CustomTestCase,
):
"""TP8, no spec decoding."""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
DSV4_FLASH_MODEL_PATH,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--tp",
"8",
"--max-running-requests",
"8",
"--mem-fraction-static",
"0.85",
],
env=DSV4_FLASH_ENV,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,148 @@
"""DSV4 Flash PD-disagg with NIXL backend. Both sides run dp-attention
+ deepep + EAGLE MTP so attn_tp_size and the V4 state pool layout are
fully symmetric: same SWA item_len under matching attn_tp, and same
DSA c4/c128 indexer ring buffer size under matching spec status. nixl
`send_state` is page-by-index and has no V4 TP-slice / spec-asymmetric
path, so any layout mismatch would trip the item_len assert in
`nixl/conn.py`."""
import unittest
from types import SimpleNamespace
from sglang.test.few_shot_gsm8k import run_eval as run_gsm8k_eval
from sglang.test.server_fixtures.disaggregation_fixture import (
PDDisaggregationServerBase,
)
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
popen_launch_pd_server,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
DSV4_FLASH_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
# MTP num_draft_tokens=4 scales dispatch by ~4x; 256 overflows at bs=128.
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
DEEPEP_CONFIG = '{"normal_dispatch":{"num_sms":96},"normal_combine":{"num_sms":96}}'
class TestDSV4FlashPDDisaggNIXL(PDDisaggregationServerBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.transfer_backend = ["--disaggregation-transfer-backend", "nixl"]
cls.rdma_devices = []
cls.model = DSV4_FLASH_MODEL_PATH
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()
@classmethod
def start_prefill(cls):
prefill_args = [
"--trust-remote-code",
"--disaggregation-mode",
"prefill",
"--base-gpu-id",
"0",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--deepep-config",
DEEPEP_CONFIG,
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--mem-fraction-static",
"0.7",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
*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=DSV4_FLASH_ENV,
)
@classmethod
def start_decode(cls):
decode_args = [
"--trust-remote-code",
"--disaggregation-mode",
"decode",
"--base-gpu-id",
"4",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--deepep-config",
DEEPEP_CONFIG,
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--mem-fraction-static",
"0.7",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
*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=DSV4_FLASH_ENV,
)
def test_gsm8k(self):
"""End-to-end PD-disagg accuracy through the LB."""
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=200,
max_new_tokens=512,
parallel=64,
host=f"http://{self.base_host}",
port=int(self.lb_port),
)
metrics = run_gsm8k_eval(args)
print(f"{metrics=}")
self.assertGreater(metrics["accuracy"], 0.95)
if __name__ == "__main__":
unittest.main()
+278
View File
@@ -0,0 +1,278 @@
"""DSV4-Pro 1.6T MTP performance tests on B200 TP=8.
1. TestDSV4ProMTPSimulatedAcc — `SGLANG_SIMULATE_ACC_LEN=3` pins EAGLE accept
length so latency comparisons are apples-to-apples. Runs `bench_one_batch_server`
at bs=1 for isl=4096 and isl=900000 (osl=1024).
2. TestDSV4ProMTPHongloumeng — real EAGLE accept (no SIMULATE) on Chinese
long-context input (`hongloumeng.txt`, ~627k DSV4 tokens). Builds a one-line
custom JSONL dataset on the fly and drives `bench_serving --dataset-name custom`
with one short slice (30k tokens) and the full long prompt.
Manual test (8× B200, 1.6T weights). Not registered in CI.
"""
import json
import os
import tempfile
import unittest
from types import SimpleNamespace
import requests
from sglang.bench_one_batch_server import BenchArgs as OneBatchBenchArgs
from sglang.bench_one_batch_server import run_benchmark as run_one_batch_benchmark
from sglang.benchmark.serving import run_benchmark as run_serving_benchmark
from sglang.srt.server_args import ServerArgs
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,
)
DSV4_PRO_MODEL_PATH = "deepseek-ai/DeepSeek-V4-Pro"
HONGLOUMENG_PATH = os.environ.get(
"SGLANG_HONGLOUMENG_PATH",
os.path.join(os.path.dirname(__file__), "hongloumeng.txt"),
)
DSV4_PRO_BASE_ENV = {
"SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2": "1",
}
DSV4_PRO_SERVER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
"--mem-fraction-static",
"0.82",
"--max-running-requests",
"8",
]
def _launch_dsv4_pro_server(extra_env=None):
env = dict(DSV4_PRO_BASE_ENV)
if extra_env:
env.update(extra_env)
return popen_launch_server(
DSV4_PRO_MODEL_PATH,
DEFAULT_URL_FOR_TEST,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 4,
other_args=DSV4_PRO_SERVER_ARGS,
env=env,
)
class TestDSV4ProMTPSimulatedAcc(CustomTestCase):
"""bs=1 latency at isl=4096 / 900000 with `SGLANG_SIMULATE_ACC_LEN=3`.
Reference (B200 Pro TP8):
- isl=4096 → output 194.6 tok/s, accept 2.96
- isl=900000 → output 174.6 tok/s, accept 2.93
"""
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch_dsv4_pro_server(
extra_env={"SGLANG_SIMULATE_ACC_LEN": "3"}
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
def _run_one_batch(self, input_len):
requests.get(self.base_url + "/flush_cache")
server_args = ServerArgs(model_path=DSV4_PRO_MODEL_PATH)
bench_args = OneBatchBenchArgs(
run_name=f"dsv4_pro_simacc_isl{input_len}",
batch_size=(1,),
input_len=(input_len,),
output_len=(1024,),
base_url=self.base_url,
skip_warmup=True,
result_filename=os.path.join(
tempfile.gettempdir(), f"dsv4_pro_simacc_isl{input_len}.jsonl"
),
append_to_github_summary=False,
)
results, _ = run_one_batch_benchmark(server_args, bench_args)
self.assertTrue(results, "bench_one_batch_server returned no results")
return results[0]
def test_isl_4096(self):
r = self._run_one_batch(4096)
print(
f"[pro simacc isl=4096] output_throughput={r.output_throughput:.2f} tok/s "
f"latency={r.latency:.2f}s last_ttft={r.last_ttft:.2f}s "
f"acc_length={r.acc_length:.2f}"
)
# Reference 194.6 tok/s / acc=2.96 — give 10% throughput margin and a
# generous accept-length floor to absorb run-to-run jitter.
self.assertGreater(r.output_throughput, 175.0)
self.assertGreater(r.acc_length, 2.85)
def test_isl_900k(self):
r = self._run_one_batch(900_000)
print(
f"[pro simacc isl=900k] output_throughput={r.output_throughput:.2f} tok/s "
f"latency={r.latency:.2f}s last_ttft={r.last_ttft:.2f}s "
f"acc_length={r.acc_length:.2f}"
)
# Reference 174.6 tok/s / acc=2.93.
self.assertGreater(r.output_throughput, 155.0)
self.assertGreater(r.acc_length, 2.85)
def _build_hongloumeng_jsonl(num_tokens, tokenizer, out_path):
"""Slice the first `num_tokens` DSV4 tokens of hongloumeng.txt into a
one-line CustomDataset JSONL. Pass num_tokens=None to keep the full text.
"""
with open(HONGLOUMENG_PATH, "r", encoding="utf-8") as f:
text = f.read()
if num_tokens is not None:
ids = tokenizer.encode(text)
text = tokenizer.decode(ids[:num_tokens])
with open(out_path, "w", encoding="utf-8") as f:
f.write(
json.dumps(
{"conversations": [{"value": text}, {"value": "x"}]},
ensure_ascii=False,
)
+ "\n"
)
return out_path
class TestDSV4ProMTPHongloumeng(CustomTestCase):
"""Real EAGLE accept on Chinese long-context (hongloumeng.txt).
Reference (B200 Pro TP8, no SIMULATE):
- isl=30000 → output 124.4 tok/s, decode peak 184 tok/s, accept 2.47
- isl=627059 → output 125.7 tok/s, decode peak 179 tok/s, accept 2.52
"""
SHORT_TOKENS = 30_000
LONG_TOKENS = None # full file (~627k DSV4 tokens)
OUTPUT_TOKENS = 4096
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = _launch_dsv4_pro_server()
# Resolve tokenizer once; the server reports its own tokenizer path so
# on-the-fly token-level slicing matches what the server will see.
info = requests.get(cls.base_url + "/server_info", timeout=60).json()
tokenizer_path = info.get("tokenizer_path") or DSV4_PRO_MODEL_PATH
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
cls.tokenizer = get_tokenizer(tokenizer_path)
cls.tmpdir = tempfile.mkdtemp(prefix="dsv4_hongloumeng_")
cls.short_jsonl = _build_hongloumeng_jsonl(
cls.SHORT_TOKENS,
cls.tokenizer,
os.path.join(cls.tmpdir, "hongloumeng_30k.jsonl"),
)
cls.long_jsonl = _build_hongloumeng_jsonl(
cls.LONG_TOKENS,
cls.tokenizer,
os.path.join(cls.tmpdir, "hongloumeng_full.jsonl"),
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
def _run_custom_bench(self, dataset_path):
requests.get(self.base_url + "/flush_cache")
args = SimpleNamespace(
backend="sglang",
base_url=self.base_url,
host=None,
port=None,
dataset_name="custom",
dataset_path=dataset_path,
model=None,
tokenizer=None,
num_prompts=1,
sharegpt_output_len=self.OUTPUT_TOKENS,
sharegpt_context_len=None,
random_input_len=4096,
random_output_len=2048,
random_range_ratio=0.0,
request_rate=float("inf"),
max_concurrency=1,
warmup_requests=0,
flush_cache=True,
multi=None,
output_file=None,
disable_tqdm=False,
disable_stream=False,
return_logprob=False,
return_routed_experts=False,
seed=0,
disable_ignore_eos=False,
extra_request_body=None,
apply_chat_template=False,
profile=None,
lora_name=None,
lora_request_distribution="uniform",
lora_zipf_alpha=1.5,
prompt_suffix="",
device="cuda",
pd_separated=False,
ready_check_timeout_sec=0,
)
return run_serving_benchmark(args)
def test_short_30k(self):
res = self._run_custom_bench(self.short_jsonl)
print(
f"[hongloumeng 30k] output_throughput={res['output_throughput']:.2f} tok/s "
f"accept_length={res['accept_length']:.2f} "
f"mean_ttft_ms={res['mean_ttft_ms']:.0f} "
f"mean_tpot_ms={res['mean_tpot_ms']:.2f}"
)
# Reference 124 tok/s / accept 2.47.
self.assertGreater(res["output_throughput"], 105.0)
self.assertGreater(res["accept_length"], 2.30)
def test_long_full(self):
res = self._run_custom_bench(self.long_jsonl)
print(
f"[hongloumeng full] output_throughput={res['output_throughput']:.2f} tok/s "
f"accept_length={res['accept_length']:.2f} "
f"mean_ttft_ms={res['mean_ttft_ms']:.0f} "
f"mean_tpot_ms={res['mean_tpot_ms']:.2f}"
)
# Reference 125 tok/s / accept 2.52. Cold prefill takes ~85s on 627k
# tokens so the run is dominated by prefill, but decode steady-state
# accept_length is the metric we care about.
self.assertGreater(res["output_throughput"], 105.0)
self.assertGreater(res["accept_length"], 2.30)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,165 @@
"""DSV4 stress test for SWA radix cache + tombstone + retract interaction.
Reproduces the assert in `swa_radix_cache.cache_unfinished_req`:
assert old_prefix_len <= len(new_indices)
Trip conditions (all required):
1. Fork-only SWA leaf early-release on (`SGLANG_OPT_SWA_RELEASE_LEAF_LOCK_AFTER_WINDOW=1`)
2. Multiple requests share a long prefix (so one req's tombstoned leaf
poisons match_prefix for others walking the same radix path).
3. Memory pressure forces retract while at least one req has tombstoned
its leaf (decode_batch_idx >= sliding_window_size at retract time).
After main #19427 changed `old_prefix_len = req.cache_protected_len`
(stable), tombstone-induced shrinks in match's `best_value_len` across
chunked-prefill rounds can make stale `cache_protected_len` exceed
current matchable length -> assert trips.
Test passes iff the scheduler does not crash under this stress workload.
"""
import random
import threading
import time
import unittest
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,
)
DSV4_FLASH_MODEL_PATH = "sgl-project/DeepSeek-V4-Flash-FP8"
# Long shared prefix forces multi-chunk prefill and ensures cross-request
# prefix-cache hits so one req's tombstone affects later reqs.
SHARED_PREFIX_BLOCK = (
"You are a careful, expert assistant. Answer concisely.\n"
"Context: " + ("the quick brown fox jumps over the lazy dog. " * 600)
)
QUESTION_TAILS = [
" Q: What is 17*23?\n",
" Q: List three primary colors.\n",
" Q: Where is Mount Everest?\n",
" Q: Summarize gradient descent in two sentences.\n",
" Q: Name two bodies of water in Africa.\n",
" Q: What language is spoken in Brazil?\n",
]
class TestDSV4FlashSWARadixRetract(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DSV4_FLASH_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
'{"normal_dispatch":{"num_sms":96},"normal_combine":{"num_sms":96}}',
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
# Tight static memory so SWA pool fills up under load and
# retract is forced.
"--mem-fraction-static",
"0.7",
]
env = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
"SGLANG_OPT_SWA_RADIX_CACHE_COMPACT": "0",
"SGLANG_TEST_RETRACT": "1",
"SGLANG_TEST_RETRACT_INTERVAL": "3",
}
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=other_args,
env=env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def _send_req(self, prompt: str, max_new_tokens: int):
try:
resp = requests.post(
self.base_url + "/generate",
json={
"text": prompt,
"sampling_params": {
# Vary outputs slightly so reqs don't share decode
# paths perfectly; we want some to finish, some to
# be retracted under pressure.
"temperature": 0.7,
"max_new_tokens": max_new_tokens,
},
},
timeout=600,
)
# Per-request success is not the gate; some requests are
# expected to be retracted/aborted under heavy pressure.
return resp.status_code == 200
except Exception:
return False
def test_swa_tombstone_retract_does_not_crash(self):
"""Stress: 64 concurrent long-prompt reqs with long generation force
retract under SWA pool pressure. Reqs share a 30k+ token prefix so
tombstoned leaves from retracted reqs are on the radix path of new
reqs. Scheduler must not crash on the swa_radix_cache assert."""
random.seed(0)
concurrency = 64
# Long enough generation to push past sliding_window_size -> fires
# `dec_swa_lock_only` -> tombstones leaves. Combined with SWA pool
# pressure this guarantees retract while tombstones are live.
max_new_tokens = 1024
threads = []
for i in range(concurrency):
tail = QUESTION_TAILS[i % len(QUESTION_TAILS)]
# Add a small per-req suffix so reqs don't dedup at radix root
# but still share the bulk of the prefix.
prompt = SHARED_PREFIX_BLOCK + tail + f"(seed={i})"
t = threading.Thread(target=self._send_req, args=(prompt, max_new_tokens))
threads.append(t)
t.start()
# Stagger so requests enter prefill in waves; some are still in
# decode (and have tombstoned leaves) when later waves of
# chunked-prefill reqs walk the same radix path.
time.sleep(0.05)
for t in threads:
t.join(timeout=600)
# The only invariant: scheduler survived. Per-request completion is
# best-effort under retract pressure.
self.assertIsNone(self.process.poll())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,465 @@
"""Unit tests for the fused compressor attention Triton kernel on HIP.
Validates numerical parity between the fused single-kernel path (plan-driven
Triton) and the reference per-seq Python implementation.
Usage:
python -m pytest test/manual/dsv4/test_fused_compress_attn_hip.py -v
# or directly:
python test/manual/dsv4/test_fused_compress_attn_hip.py
"""
import unittest
from dataclasses import dataclass
import numpy as np
import torch
@dataclass
class FusedCompressPlan:
compress_plan_gpu: torch.Tensor
write_plan_gpu: torch.Tensor
num_compress: int
num_write: int
def write_current_token_to_state(
kv_score_input: torch.Tensor,
write_plan: torch.Tensor,
state_pool_buffer: torch.Tensor,
head_dim: int,
overlap: bool,
ratio: int,
) -> None:
"""Reference write path used by this manual test.
Plan row layout: [ragged_id, batch_id, position, window_len, state_base].
"""
del head_dim # layout is already encoded in kv_score_input/state_pool_buffer shape.
state_size = (2 if overlap else 1) * ratio
plan_cpu = write_plan.cpu()
for row in plan_cpu:
ragged_id = int(row[0].item())
position = int(row[2].item())
state_base = int(row[4].item())
if ragged_id < 0 or position < 0:
continue
dst = state_base + (position % state_size)
if (
0 <= dst < state_pool_buffer.shape[0]
and 0 <= ragged_id < kv_score_input.shape[0]
):
state_pool_buffer[dst] = kv_score_input[ragged_id]
def fused_compress_attn(
state_pool_buffer: torch.Tensor,
plan: torch.Tensor,
ape: torch.Tensor,
rms_weight: torch.Tensor,
rms_eps: float,
freqs_cis_real: torch.Tensor,
head_dim: int,
rope_head_dim: int,
overlap: bool,
ratio: int,
out: torch.Tensor,
) -> torch.Tensor:
"""Reference compress path for manual parity tests.
This keeps the test runnable after removing `fused_compress_kernel.py`.
"""
freqs_cis = torch.view_as_complex(
freqs_cis_real.view(freqs_cis_real.shape[0], -1, 2).contiguous()
)
result = _ref_compress(
kv_score_input=torch.empty(
0, device=state_pool_buffer.device, dtype=torch.float32
),
state_pool=state_pool_buffer,
plan=plan,
ape=ape,
rms_weight=rms_weight,
rms_eps=rms_eps,
freqs_cis=freqs_cis,
head_dim=head_dim,
rope_head_dim=rope_head_dim,
overlap=overlap,
ratio=ratio,
num_compress=plan.shape[0],
)
out.copy_(result)
return out
def _make_plan_from_params(
extend_lens: list[int],
seq_lens: list[int],
ratio: int,
overlap: bool,
state_bases: list[int],
device: torch.device,
) -> FusedCompressPlan:
"""Build a test plan without requiring real SWA / req_to_token tables."""
bs = len(extend_lens)
ext = np.array(extend_lens, dtype=np.int32)
seq = np.array(seq_lens, dtype=np.int32)
total = int(ext.sum())
state_size = (2 if overlap else 1) * ratio
K = state_size
batch_ids = np.repeat(np.arange(bs, dtype=np.int32), ext)
ragged_ids = np.arange(total, dtype=np.int32)
cu_extend = np.empty(bs + 1, dtype=np.int32)
cu_extend[0] = 0
np.cumsum(ext, out=cu_extend[1:])
j_in_seq = ragged_ids - cu_extend[batch_ids]
prefix_lens = seq - ext
positions = prefix_lens[batch_ids] + j_in_seq
window_lens = np.maximum(0, K - np.minimum(j_in_seq + 1, K)).astype(np.int32)
state_base_arr = np.array(state_bases, dtype=np.int32)
state_base_per_token = state_base_arr[batch_ids]
plan_rows = np.stack(
[ragged_ids, batch_ids, positions, window_lens, state_base_per_token],
axis=1,
).astype(np.int32)
compress_mask = (positions + 1) % ratio == 0
compress_plan = plan_rows[compress_mask]
write_starts = np.maximum(0, seq - K).astype(np.int32)
write_mask = positions >= write_starts[batch_ids]
write_plan = plan_rows[write_mask]
n_compress = int(compress_plan.shape[0]) if compress_plan.size > 0 else 0
n_write = int(write_plan.shape[0]) if write_plan.size > 0 else 0
compress_gpu = (
torch.from_numpy(np.ascontiguousarray(compress_plan)).to(device)
if n_compress > 0
else torch.empty((0, 5), dtype=torch.int32, device=device)
)
write_gpu = (
torch.from_numpy(np.ascontiguousarray(write_plan)).to(device)
if n_write > 0
else torch.empty((0, 5), dtype=torch.int32, device=device)
)
return FusedCompressPlan(
compress_plan_gpu=compress_gpu,
write_plan_gpu=write_gpu,
num_compress=n_compress,
num_write=n_write,
)
def _make_freqs_cis(max_seq: int, rope_dim: int, device: torch.device) -> torch.Tensor:
"""Create test freqs_cis as complex64 [max_seq, rope_dim/2], matching production."""
half = rope_dim // 2
angles = torch.randn(max_seq, half, device=device, dtype=torch.float32) * 0.1
return torch.polar(torch.ones_like(angles), angles)
def _freqs_to_real(freqs_cis: torch.Tensor) -> torch.Tensor:
"""Convert complex64 freqs to float32 [max_seq, rope_dim] interleaved."""
return torch.view_as_real(freqs_cis).flatten(-2).contiguous()
def _ref_compress(
kv_score_input: torch.Tensor,
state_pool: torch.Tensor,
plan: torch.Tensor,
ape: torch.Tensor,
rms_weight: torch.Tensor,
rms_eps: float,
freqs_cis: torch.Tensor,
head_dim: int,
rope_head_dim: int,
overlap: bool,
ratio: int,
num_compress: int,
) -> torch.Tensor:
"""Pure-PyTorch reference matching SGLang compress_decode_paged semantics.
State already has current tokens written (no APE).
APE is added to ALL K scores at compress time.
"""
if num_compress == 0:
return torch.empty(0, head_dim, dtype=torch.float32, device=state_pool.device)
coff = 2 if overlap else 1
half_dim = coff * head_dim
state_size = coff * ratio
K = state_size
plan_cpu = plan[:num_compress].cpu()
out = torch.empty(
num_compress, head_dim, dtype=torch.float32, device=state_pool.device
)
for pid in range(num_compress):
position = int(plan_cpu[pid, 2].item())
state_base = int(plan_cpu[pid, 4].item())
if position < 0:
continue
kv_rows = []
score_rows = []
for k in range(K):
s = position - K + 1 + k
col_off = (head_dim if k >= ratio else 0) if overlap else 0
ape_row = k % ratio
d_slice = slice(col_off, col_off + head_dim)
if s < 0:
kv_rows.append(
torch.zeros(head_dim, dtype=torch.float32, device=state_pool.device)
)
score_rows.append(
torch.full(
(head_dim,),
float("-inf"),
dtype=torch.float32,
device=state_pool.device,
)
)
else:
ring = s % state_size
row = state_pool[state_base + ring]
kv_rows.append(row[d_slice].float())
# APE added to ALL scores
score_rows.append(
row[half_dim + col_off : half_dim + col_off + head_dim].float()
+ ape[ape_row, d_slice].float()
)
kv_stack = torch.stack(kv_rows, dim=0)
sc_stack = torch.stack(score_rows, dim=0)
weights = torch.softmax(sc_stack, dim=0)
compressed = (weights * kv_stack).sum(dim=0)
var = (compressed * compressed).mean()
normed = compressed * torch.rsqrt(var + rms_eps) * rms_weight.float()
comp_pos = (position // ratio) * ratio
rope_seg = normed[-rope_head_dim:].clone()
freqs_row = torch.view_as_real(freqs_cis[comp_pos]).flatten()
cos_v = freqs_row[0::2].float()
sin_v = freqs_row[1::2].float()
even = rope_seg[0::2]
odd = rope_seg[1::2]
normed[-rope_head_dim:] = torch.stack(
[even * cos_v - odd * sin_v, odd * cos_v + even * sin_v], dim=-1
).flatten()
out[pid] = normed
return out
class TestFusedCompressAttn(unittest.TestCase):
def _run_test(
self,
ratio: int,
overlap: bool,
bs: int,
extend_lens: list[int],
prefix_lens: list[int],
head_dim: int = 512,
rope_head_dim: int = 64,
):
device = torch.device("cuda")
torch.manual_seed(42)
coff = 2 if overlap else 1
half_dim = coff * head_dim
last_dim = 2 * half_dim
state_size = coff * ratio
seq_lens = [p + e for p, e in zip(prefix_lens, extend_lens)]
total_tokens = sum(extend_lens)
max_seq = max(seq_lens) + 128
kv_score_input = torch.randn(
total_tokens, last_dim, device=device, dtype=torch.float32
)
pool_size = bs * state_size + 2
state_pool = torch.randn(
pool_size, last_dim, device=device, dtype=torch.float32
)
state_pool[:, half_dim:] *= 0.5 # reasonable score magnitudes
state_bases = [i * state_size for i in range(bs)]
ape = torch.randn(ratio, half_dim, device=device, dtype=torch.float32) * 0.1
rms_weight = torch.ones(head_dim, device=device, dtype=torch.float32)
rms_eps = 1e-6
freqs_cis = _make_freqs_cis(max_seq, rope_head_dim, device)
freqs_real = _freqs_to_real(freqs_cis)
plan = _make_plan_from_params(
extend_lens, seq_lens, ratio, overlap, state_bases, device
)
if plan.num_compress == 0:
return
# Step 1: write current tokens to state (same for both paths)
state_triton = state_pool.clone()
state_ref = state_pool.clone()
write_current_token_to_state(
kv_score_input=kv_score_input,
write_plan=plan.write_plan_gpu,
state_pool_buffer=state_triton,
head_dim=head_dim,
overlap=overlap,
ratio=ratio,
)
# Reference: same write
write_current_token_to_state(
kv_score_input=kv_score_input,
write_plan=plan.write_plan_gpu,
state_pool_buffer=state_ref,
head_dim=head_dim,
overlap=overlap,
ratio=ratio,
)
# Step 2a: Triton fused compress
out_triton = torch.empty(
plan.num_compress, head_dim, device=device, dtype=torch.float32
)
fused_compress_attn(
state_pool_buffer=state_triton,
plan=plan.compress_plan_gpu,
ape=ape,
rms_weight=rms_weight,
rms_eps=rms_eps,
freqs_cis_real=freqs_real,
head_dim=head_dim,
rope_head_dim=rope_head_dim,
overlap=overlap,
ratio=ratio,
out=out_triton,
)
# Step 2b: reference compress
out_ref = _ref_compress(
kv_score_input=kv_score_input,
state_pool=state_ref,
plan=plan.compress_plan_gpu,
ape=ape,
rms_weight=rms_weight,
rms_eps=rms_eps,
freqs_cis=freqs_cis,
head_dim=head_dim,
rope_head_dim=rope_head_dim,
overlap=overlap,
ratio=ratio,
num_compress=plan.num_compress,
)
torch.testing.assert_close(out_triton, out_ref, atol=1e-3, rtol=1e-3)
def test_hca_single(self):
self._run_test(
ratio=128, overlap=False, bs=1, extend_lens=[128], prefix_lens=[0]
)
def test_hca_multi(self):
self._run_test(
ratio=128, overlap=False, bs=2, extend_lens=[128, 256], prefix_lens=[0, 128]
)
def test_csa_single(self):
self._run_test(ratio=4, overlap=True, bs=1, extend_lens=[16], prefix_lens=[8])
def test_csa_multi(self):
self._run_test(
ratio=4, overlap=True, bs=3, extend_lens=[8, 12, 16], prefix_lens=[4, 8, 0]
)
def test_csa_small_dim(self):
self._run_test(
ratio=4,
overlap=True,
bs=2,
extend_lens=[8, 8],
prefix_lens=[4, 0],
head_dim=256,
)
class TestStateOrdering(unittest.TestCase):
def test_write_then_compress(self):
"""Verify write-first, compress-second matches reference."""
device = torch.device("cuda")
torch.manual_seed(123)
ratio, overlap = 4, True
coff = 2
head_dim, rope_head_dim = 128, 64
half_dim = coff * head_dim
last_dim = 2 * half_dim
state_size = coff * ratio
pool_size = state_size + 2
state_pool = torch.randn(
pool_size, last_dim, device=device, dtype=torch.float32
)
state_pool[:, half_dim:] *= 0.5
kv_score_input = torch.randn(8, last_dim, device=device, dtype=torch.float32)
ape = torch.randn(ratio, half_dim, device=device, dtype=torch.float32) * 0.1
rms_weight = torch.ones(head_dim, device=device, dtype=torch.float32)
freqs_cis = _make_freqs_cis(64, rope_head_dim, device)
plan = _make_plan_from_params([8], [8], ratio, overlap, [0], device)
if plan.num_compress == 0:
return
state_before = state_pool.clone()
# Write first
write_current_token_to_state(
kv_score_input=kv_score_input,
write_plan=plan.write_plan_gpu,
state_pool_buffer=state_pool,
head_dim=head_dim,
overlap=overlap,
ratio=ratio,
)
# State should now be different (tokens written)
self.assertFalse(torch.allclose(state_pool, state_before))
# Compress
out = torch.empty(
plan.num_compress, head_dim, device=device, dtype=torch.float32
)
fused_compress_attn(
state_pool_buffer=state_pool,
plan=plan.compress_plan_gpu,
ape=ape,
rms_weight=rms_weight,
rms_eps=1e-6,
freqs_cis_real=_freqs_to_real(freqs_cis),
head_dim=head_dim,
rope_head_dim=rope_head_dim,
overlap=overlap,
ratio=ratio,
out=out,
)
self.assertFalse(torch.any(torch.isnan(out)).item())
self.assertFalse(torch.any(torch.isinf(out)).item())
if __name__ == "__main__":
unittest.main()
+108
View File
@@ -0,0 +1,108 @@
"""GB300 x DeepSeek-V4-Flash.
Single-node TP=4 path on the deepseek-ai MXFP4 repo. Covers
Low-Latency, Balanced, Max-Throughput, Context-Parallel (CP).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4FlashAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Flash"
class TestGB300FlashLowLatency(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
]
EXTRA_ENV = {}
class TestGB300FlashBalanced(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestGB300FlashMaxThroughput(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024"}
class TestGB300FlashCP(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--max-running-requests",
"1024",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""GB300 x DeepSeek-V4-Pro.
Single-node TP=4 path. Note that GB300 Pro CP bumps
mem-fraction-static to 0.88 (1.6T weights at TP=4 on 273 GB don't
fit at the default 0.78). Covers Low-Latency, Balanced,
Max-Throughput, Context-Parallel (CP).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4ProAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Pro"
class TestGB300ProLowLatency(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"flashinfer_mxfp4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--chunked-prefill-size",
"4096",
"--disable-flashinfer-autotune",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
class TestGB300ProBalanced(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--mem-fraction-static",
"0.9",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestGB300ProMaxThroughput(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--mem-fraction-static",
"0.9",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256"}
class TestGB300ProCP(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.88",
"--cuda-graph-max-bs-decode",
"256",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256",
}
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""H200 (FP4 / Marlin) x DeepSeek-V4-Flash.
The cookbook disables Context-Parallel for the H200 FP4 (Marlin)
hardware, so this file only covers Low-Latency, Balanced, and
Max-Throughput.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DSV4FlashAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Flash"
class TestH200Fp4FlashLowLatency(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"marlin",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
]
EXTRA_ENV = {}
class TestH200Fp4FlashBalanced(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"marlin",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
]
EXTRA_ENV = {}
class TestH200Fp4FlashMaxThroughput(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-runner-backend",
"marlin",
]
EXTRA_ENV = {}
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
"""H200 (FP4 / Marlin) x DeepSeek-V4-Pro.
The cookbook disables Context-Parallel for the H200 FP4 (Marlin)
hardware, so this file only covers Low-Latency, Balanced, and
Max-Throughput.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DSV4ProAime25TestBase
MODEL = "deepseek-ai/DeepSeek-V4-Pro"
class TestH200Fp4ProLowLatency(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"marlin",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
class TestH200Fp4ProBalanced(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"marlin",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
class TestH200Fp4ProMaxThroughput(DSV4ProAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"8",
"--moe-runner-backend",
"marlin",
"--mem-fraction-static",
"0.88",
]
EXTRA_ENV = {}
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""H200 (FP8) x DeepSeek-V4-Flash.
Uses the FP8-repackaged repo (sgl-project/DeepSeek-V4-Flash-FP8) and
the SGLANG_DSV4_FP4_EXPERTS=0 env that the cookbook generator emits
for H200 FP8 cells. Covers Low-Latency, Balanced, Max-Throughput, CP.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DEEPEP_LARGE_SMS_CONFIG, DSV4FlashAime25TestBase
MODEL = "sgl-project/DeepSeek-V4-Flash-FP8"
H200_FP8_ENV = {"SGLANG_DSV4_FP4_EXPERTS": "0"}
class TestH200Fp8FlashLowLatency(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
]
EXTRA_ENV = dict(H200_FP8_ENV)
class TestH200Fp8FlashBalanced(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"128",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
**H200_FP8_ENV,
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256",
}
class TestH200Fp8FlashMaxThroughput(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--dp",
"4",
"--enable-dp-attention",
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
**H200_FP8_ENV,
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "256",
}
class TestH200Fp8FlashCP(DSV4FlashAime25TestBase):
MODEL = MODEL
OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"4",
"--moe-a2a-backend",
"deepep",
"--enable-dsa-prefill-context-parallel",
"--dsa-prefill-cp-mode",
"round-robin-split",
"--chunked-prefill-size",
"16384",
"--mem-fraction-static",
"0.78",
"--max-running-requests",
"1024",
"--deepep-config",
DEEPEP_LARGE_SMS_CONFIG,
]
EXTRA_ENV = {
**H200_FP8_ENV,
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "1024",
}
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""H200 (FP8) x DeepSeek-V4-Pro.
The cookbook ships this cell as a multi-node (2 nodes, TP=16) launch
using the FP8-repackaged repo (sgl-project/DeepSeek-V4-Pro-FP8).
Each test class skips itself unless DSV4_NODE_RANK and
DSV4_DIST_INIT_ADDR are exported. Runtime expectation:
On every node:
DSV4_NODE_RANK=<0 or 1> \\
DSV4_DIST_INIT_ADDR=<head-node-ip>:20000 \\
python test/manual/models/dsv4/test_h200_fp8_pro.py
Context-Parallel is marked TBD in the cookbook for this cell, so it
is intentionally omitted.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import DSV4ProAime25TestBase, multinode_args
MODEL = "sgl-project/DeepSeek-V4-Pro-FP8"
H200_FP8_PRO_ENV = {
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "128",
}
class TestH200Fp8ProLowLatency(DSV4ProAime25TestBase):
MODEL = MODEL
EXTRA_ENV = dict(H200_FP8_PRO_ENV)
@classmethod
def setUpClass(cls):
cls.OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"16",
"--dp",
"16",
"--enable-dp-attention",
*multinode_args(2),
"--moe-a2a-backend",
"deepep",
"--cuda-graph-max-bs-decode",
"8",
"--max-running-requests",
"32",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
"--mem-fraction-static",
"0.88",
]
super().setUpClass()
class TestH200Fp8ProBalanced(DSV4ProAime25TestBase):
MODEL = MODEL
EXTRA_ENV = dict(H200_FP8_PRO_ENV)
@classmethod
def setUpClass(cls):
cls.OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"16",
"--dp",
"16",
"--enable-dp-attention",
*multinode_args(2),
"--moe-a2a-backend",
"deepep",
"--speculative-algorithm",
"EAGLE",
"--speculative-num-steps",
"1",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"2",
"--mem-fraction-static",
"0.88",
"--cuda-graph-max-bs-decode",
"8",
"--max-running-requests",
"32",
]
super().setUpClass()
class TestH200Fp8ProMaxThroughput(DSV4ProAime25TestBase):
MODEL = MODEL
EXTRA_ENV = dict(H200_FP8_PRO_ENV)
@classmethod
def setUpClass(cls):
cls.OTHER_ARGS = [
"--trust-remote-code",
"--tp",
"16",
"--dp",
"16",
"--enable-dp-attention",
*multinode_args(2),
"--moe-a2a-backend",
"deepep",
"--mem-fraction-static",
"0.88",
"--cuda-graph-max-bs-decode",
"128",
"--max-running-requests",
"256",
]
super().setUpClass()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,205 @@
"""
Integration test for abort_request functionality with a SGLang server.
Run with:
python -m unittest sglang.test.srt.entrypoints.http_server.test_abort_request -v
"""
import threading
import time
import unittest
import requests
from sglang.srt.utils import kill_process_tree
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,
)
class TestAbortRequest(CustomTestCase):
"""Integration test class for abort request functionality."""
model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
base_url = DEFAULT_URL_FOR_TEST
@classmethod
def setUpClass(cls):
"""Launch the server."""
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--disable-cuda-graph"],
)
cls.completion_url = f"{cls.base_url}/generate"
cls.abort_url = f"{cls.base_url}/abort_request"
cls.health_url = f"{cls.base_url}/health"
print(f"Server started at {cls.base_url}")
@classmethod
def tearDownClass(cls):
"""Clean up the server."""
kill_process_tree(cls.process.pid)
def _send_completion_request(
self,
text: str,
request_id: str,
max_tokens: int = 50,
temperature: float = 0.8,
stream: bool = True,
) -> requests.Response:
"""Send a completion request to the server."""
payload = {
"text": text,
"sampling_params": {
"max_new_tokens": max_tokens,
"temperature": temperature,
},
"stream": stream,
"rid": request_id,
}
response = requests.post(
self.completion_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=30,
stream=stream,
)
return response
def _send_abort_request(self, request_id: str) -> requests.Response:
"""Send an abort request."""
payload = {"rid": request_id}
return requests.post(self.abort_url, json=payload, timeout=10)
def _check_server_health(self) -> bool:
"""Check if server is healthy."""
try:
response = requests.get(self.health_url, timeout=5)
return response.status_code == 200
except:
return False
def test_abort_during_non_streaming_generation(self):
"""Test aborting a non-streaming request during generation."""
self.assertTrue(self._check_server_health(), "Server should be healthy")
request_id = "test_abort_non_streaming"
completion_result = {}
def run_completion():
response = self._send_completion_request(
"Write a detailed essay about artificial intelligence",
max_tokens=500,
temperature=1,
request_id=request_id,
stream=False,
)
if response.status_code == 200:
result = response.json()
completion_result["text"] = result.get("text", "")
completion_result["finish_reason"] = result.get("meta_info", {}).get(
"finish_reason"
)
completion_thread = threading.Thread(target=run_completion)
completion_thread.start()
time.sleep(0.1)
abort_response = self._send_abort_request(request_id)
completion_thread.join()
self.assertEqual(abort_response.status_code, 200)
self.assertIsNotNone(completion_result, "Should have completion result")
if completion_result:
finish_reason_obj = completion_result.get("finish_reason")
self.assertIsNotNone(finish_reason_obj, "Should have finish_reason")
if finish_reason_obj:
self.assertEqual(
finish_reason_obj.get("type"), "abort", "Should be aborted"
)
def test_batch_requests_with_selective_abort(self):
"""Test multiple concurrent requests with selective abort of one request."""
self.assertTrue(self._check_server_health(), "Server should be healthy")
request_ids = ["batch_test_0", "batch_test_1", "batch_test_2"]
abort_target_id = "batch_test_1"
completion_results = {}
threads = []
def run_completion(req_id, prompt):
response = self._send_completion_request(
f"Write a story about {prompt}",
max_tokens=100,
temperature=0.8,
request_id=req_id,
stream=False,
)
if response.status_code == 200:
result = response.json()
completion_results[req_id] = {
"text": result.get("text", ""),
"finish_reason": result.get("meta_info", {}).get("finish_reason"),
}
# Start all requests
prompts = ["a knight's adventure", "a space discovery", "a chef's restaurant"]
for i, req_id in enumerate(request_ids):
thread = threading.Thread(target=run_completion, args=(req_id, prompts[i]))
threads.append(thread)
thread.start()
# Abort one request
time.sleep(0.1)
abort_response = self._send_abort_request(abort_target_id)
# Wait for completion
for thread in threads:
thread.join(timeout=30)
# Verify results
self.assertEqual(abort_response.status_code, 200)
# Check aborted request
aborted_result = completion_results.get(abort_target_id)
self.assertIsNotNone(
aborted_result, f"Aborted request {abort_target_id} should have result"
)
if aborted_result:
aborted_finish_reason = aborted_result.get("finish_reason")
self.assertIsNotNone(
aborted_finish_reason, "Aborted request should have finish_reason"
)
if aborted_finish_reason:
self.assertEqual(aborted_finish_reason.get("type"), "abort")
# Check other requests completed normally
normal_completions = 0
for req_id in request_ids:
if req_id != abort_target_id and req_id in completion_results:
result = completion_results[req_id]
if result:
finish_reason = result.get("finish_reason")
if finish_reason and finish_reason.get("type") == "length":
normal_completions += 1
self.assertEqual(
normal_completions, 2, "Other 2 requests should complete normally"
)
if __name__ == "__main__":
unittest.main(verbosity=2, warnings="ignore")
+445
View File
@@ -0,0 +1,445 @@
# Copy from deepseek-ai/DeepEP/tests/test_internode.py
import os
import time
# noinspection PyUnresolvedReferences
import deep_ep
# Test compatibility with low latency functions
import test_deepep_low_latency
import torch
import torch.distributed as dist
from sglang.test.test_deepep_utils import (
bench,
calc_diff,
create_grouped_scores,
init_dist,
inplace_unique,
per_token_cast_back,
per_token_cast_to_fp8,
)
def test_main(
num_sms: int,
local_rank: int,
num_local_ranks: int,
num_ranks: int,
num_nodes: int,
rank: int,
buffer: deep_ep.Buffer,
group: dist.ProcessGroup,
):
# Settings
num_tokens, hidden, num_topk_groups, num_topk, num_experts = (
4096,
7168,
min(num_nodes, 4),
8,
(256 // num_ranks) * num_ranks,
)
assert num_experts % num_ranks == 0 and num_local_ranks == 8
if local_rank == 0:
print(
f"[config] num_tokens={num_tokens}, hidden={hidden}, num_topk_groups={num_topk_groups}, num_topk={num_topk}",
flush=True,
)
# Random data
x = torch.ones((num_tokens, hidden), dtype=torch.bfloat16, device="cuda") * rank
x_pure_rand = torch.randn((num_tokens, hidden), dtype=torch.bfloat16, device="cuda")
x_e4m3 = per_token_cast_to_fp8(x)
scores = (
torch.randn((num_tokens, num_experts), dtype=torch.float32, device="cuda").abs()
+ 1
)
group_scores = scores.view(num_tokens, num_nodes, -1).amax(dim=-1)
group_idx = torch.topk(
group_scores, k=num_topk_groups, dim=-1, sorted=False
).indices
masked_scores = create_grouped_scores(scores, group_idx, num_nodes)
topk_idx = torch.topk(masked_scores, num_topk, dim=-1, largest=True, sorted=False)[
1
]
topk_weights = (
torch.ones((num_tokens, num_topk), dtype=torch.float32, device="cuda") * rank
)
topk_weights_pure_rand = torch.randn(
(num_tokens, num_topk), dtype=torch.float32, device="cuda"
)
rank_idx = topk_idx // (num_experts // num_ranks)
rank_idx.masked_fill_(topk_idx == -1, -1)
inplace_unique(rank_idx, num_ranks)
rdma_rank_idx = rank_idx // num_local_ranks
rdma_rank_idx.masked_fill_(rank_idx == -1, -1)
inplace_unique(rdma_rank_idx, num_nodes)
# RDMA dispatch counts
rdma_idx = topk_idx // (num_experts // num_nodes)
rdma_idx.masked_fill_(topk_idx == -1, -1)
inplace_unique(rdma_idx, num_nodes)
num_rdma_token_sent = rdma_idx.ne(-1).sum().item()
# Expert meta
num_tokens_per_expert = torch.zeros((num_experts,), dtype=torch.int, device="cuda")
for i in range(num_experts):
num_tokens_per_expert[i] = (topk_idx == i).sum()
gbl_num_tokens_per_expert = num_tokens_per_expert.clone()
dist.all_reduce(gbl_num_tokens_per_expert, group=group)
# Rank layout meta
num_tokens_per_rank = torch.empty((num_ranks,), dtype=torch.int, device="cuda")
num_tokens_per_rdma_rank = torch.empty((num_nodes,), dtype=torch.int, device="cuda")
token_idx_in_rank = torch.full(
(num_ranks, num_tokens), -1, dtype=torch.long, device="cuda"
)
for i in range(num_ranks):
num_tokens_per_rank[i] = (rank_idx == i).sum()
token_sel = (rank_idx == i).max(dim=-1)[0]
count = token_sel.sum().item()
tokens = torch.sort(token_sel.to(torch.int), descending=True)[1]
tokens[:count] = torch.sort(tokens[:count])[0]
token_idx_in_rank[i][tokens[:count]] = torch.arange(
count, dtype=torch.long, device="cuda"
)
for i in range(num_nodes):
num_tokens_per_rdma_rank[i] = (rdma_rank_idx == i).sum()
token_idx_in_rank = token_idx_in_rank.T.contiguous().to(torch.int)
is_token_in_rank = token_idx_in_rank >= 0
gbl_num_tokens_per_rank = num_tokens_per_rank.clone()
dist.all_reduce(gbl_num_tokens_per_rank, group=group)
(
ref_num_tokens_per_rank,
ref_num_tokens_per_rdma_rank,
ref_num_tokens_per_expert,
ref_is_token_in_rank,
_,
) = buffer.get_dispatch_layout(topk_idx, num_experts)
assert torch.allclose(ref_num_tokens_per_rank, num_tokens_per_rank)
assert torch.allclose(ref_num_tokens_per_rdma_rank, num_tokens_per_rdma_rank)
assert torch.allclose(ref_num_tokens_per_expert, num_tokens_per_expert)
assert torch.allclose(ref_is_token_in_rank, is_token_in_rank)
t = bench(lambda: buffer.get_dispatch_layout(topk_idx, num_experts))[0]
if local_rank == 0:
print(f"[layout] Kernel performance: {t * 1000:.3f} ms", flush=True)
print("", flush=True)
group.barrier()
time.sleep(1)
# Config
rdma_buffer_size, nvl_buffer_size = 128, (720 if num_ranks in (144, 160) else 512)
config = deep_ep.Config(num_sms, 8, nvl_buffer_size, 16, rdma_buffer_size)
# Test dispatch
# noinspection PyShadowingNames
def check_data(check_x, recv_gbl_rank_prefix_sum):
assert torch.allclose(check_x.amin(dim=1), check_x.amax(dim=1))
check_start = 0
for i in range(num_ranks):
check_end = recv_gbl_rank_prefix_sum[i].item()
assert (check_x[check_start:check_end, :].int() - i).sum().item() == 0
check_start = check_end
for previous_mode in (False, True):
for async_mode in (False, True):
for current_x in (x_pure_rand, x, x_e4m3):
for with_topk in (False, True):
if local_rank == 0:
print(
f'[testing] Running with {"FP8" if isinstance(current_x, tuple) else "BF16"}, {"with" if with_topk else "without"} top-k (async={async_mode}, previous={previous_mode}) ...',
flush=True,
end="",
)
dispatch_args = {
"x": current_x,
"num_tokens_per_rank": num_tokens_per_rank,
"num_tokens_per_rdma_rank": num_tokens_per_rdma_rank,
"is_token_in_rank": is_token_in_rank,
"num_tokens_per_expert": num_tokens_per_expert,
"config": config,
"async_finish": async_mode,
}
if with_topk:
dispatch_args.update(
{
"topk_idx": topk_idx,
"topk_weights": (
topk_weights_pure_rand
if current_x is x_pure_rand
else topk_weights
),
}
)
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
(
recv_x,
recv_topk_idx,
recv_topk_weights,
recv_num_tokens_per_expert_list,
handle,
event,
) = buffer.dispatch(**dispatch_args)
event.current_stream_wait() if async_mode else ()
recv_x = (
per_token_cast_back(*recv_x)
if isinstance(recv_x, tuple)
else recv_x
)
# Checks
recv_gbl_rank_prefix_sum = handle[-4]
assert gbl_num_tokens_per_rank[rank].item() == recv_x.size(
0
), f"{gbl_num_tokens_per_rank[rank].item()} != {recv_x.size(0)}"
assert (
gbl_num_tokens_per_expert.view(num_ranks, -1)[rank].tolist()
== recv_num_tokens_per_expert_list
)
if current_x is not x_pure_rand:
check_data(recv_x, recv_gbl_rank_prefix_sum)
if with_topk:
# Check `topk_idx`
assert (
recv_topk_idx.eq(-1)
| (
(recv_topk_idx >= 0)
& (recv_topk_idx < (num_experts // num_ranks))
)
).sum().item() == recv_topk_idx.numel()
for i, count in enumerate(recv_num_tokens_per_expert_list):
assert recv_topk_idx.eq(i).sum().item() == count
# Check `topk_weights`
if current_x is not x_pure_rand:
recv_topk_weights[recv_topk_idx.eq(-1)] = (
recv_topk_weights.amax(dim=1, keepdim=True).expand_as(
recv_topk_weights
)[recv_topk_idx.eq(-1)]
)
check_data(recv_topk_weights, recv_gbl_rank_prefix_sum)
# Test cached dispatch (must without top-k staffs)
if not with_topk:
dispatch_args = {
"x": current_x,
"handle": handle,
"config": config,
"async_finish": async_mode,
}
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
recv_x, _, _, _, _, event = buffer.dispatch(**dispatch_args)
event.current_stream_wait() if async_mode else ()
recv_x = (
per_token_cast_back(*recv_x)
if isinstance(recv_x, tuple)
else recv_x
)
if current_x is not x_pure_rand:
check_data(recv_x, recv_gbl_rank_prefix_sum)
# Test combine
combine_args = {
"x": recv_x,
"handle": handle,
"config": config,
"async_finish": async_mode,
}
if with_topk:
combine_args.update({"topk_weights": recv_topk_weights})
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
combined_x, combined_topk_weights, event = buffer.combine(
**combine_args
)
event.current_stream_wait() if async_mode else ()
check_x = combined_x.float() / is_token_in_rank.sum(
dim=1
).unsqueeze(1)
ref_x = x_pure_rand if current_x is x_pure_rand else x
assert calc_diff(check_x, ref_x) < 5e-6
if with_topk:
check_topk_weights = (
combined_topk_weights
if (current_x is x_pure_rand)
else (
combined_topk_weights
/ is_token_in_rank.sum(dim=1).unsqueeze(1)
)
)
ref_topk_weights = (
topk_weights_pure_rand
if current_x is x_pure_rand
else topk_weights
)
assert calc_diff(check_topk_weights, ref_topk_weights) < 1e-9
# For later tuning
dispatch_bf16_rdma_send_bytes = num_rdma_token_sent * hidden * 2
dispatch_bf16_nvl_recv_bytes = recv_x.numel() * 2
combine_bf16_nvl_send_bytes = dispatch_bf16_nvl_recv_bytes
combine_bf16_rdma_recv_bytes = dispatch_bf16_rdma_send_bytes
if local_rank == 0:
print(" passed", flush=True)
if local_rank == 0:
print("", flush=True)
# Tune dispatch performance
best_dispatch_results = None
fp8_factor = (1 + 4 / 128) / 2
for current_x in (x_e4m3, x):
best_time, best_results = 1e10, None
rdma_send_bytes = (
(dispatch_bf16_rdma_send_bytes * fp8_factor)
if isinstance(current_x, tuple)
else dispatch_bf16_rdma_send_bytes
)
nvl_recv_bytes = (
(dispatch_bf16_nvl_recv_bytes * fp8_factor)
if isinstance(current_x, tuple)
else dispatch_bf16_nvl_recv_bytes
)
for nvl_chunk_size in range(4, 33, 4):
for rdma_chunk_size in range(4, 33, 4):
config = deep_ep.Config(
num_sms,
nvl_chunk_size,
nvl_buffer_size,
rdma_chunk_size,
rdma_buffer_size,
)
tune_args = {"x": current_x, "handle": handle, "config": config}
t = bench(lambda: buffer.dispatch(**tune_args))[0]
if t < best_time:
best_time, best_results = t, (
num_sms,
nvl_chunk_size,
rdma_chunk_size,
)
if local_rank == 0:
print(
f"[tuning] SMs {num_sms}, NVL chunk {nvl_chunk_size}, RDMA chunk {rdma_chunk_size}: {rdma_send_bytes / 1e9 / t:.2f} GB/s (RDMA), {nvl_recv_bytes / 1e9 / t:.2f} GB/s (NVL) ",
flush=True,
)
if local_rank == 0:
print(
f'[tuning] Best dispatch ({"FP8" if isinstance(current_x, tuple) else "BF16"}): SMs {best_results[0]}, NVL chunk {best_results[1]}, RDMA chunk {best_results[2]}: {rdma_send_bytes / 1e9 / best_time:.2f} GB/s (RDMA), {nvl_recv_bytes / 1e9 / best_time:.2f} GB/s (NVL)',
flush=True,
)
print("", flush=True)
if isinstance(current_x, tuple):
# Gather FP8 the best config from rank 0
best_dispatch_results = torch.tensor(
[best_results[0], best_results[1], best_results[2]],
dtype=torch.int32,
device="cuda",
)
all_best_fp8_results_list = [
torch.zeros_like(best_dispatch_results)
for _ in range(torch.distributed.get_world_size())
]
dist.all_gather(
all_best_fp8_results_list, best_dispatch_results, group=group
)
best_dispatch_results = all_best_fp8_results_list[0].tolist()
dispatch_config = deep_ep.Config(
best_dispatch_results[0],
best_dispatch_results[1],
nvl_buffer_size,
best_dispatch_results[2],
rdma_buffer_size,
)
dispatch_args = {
"x": x,
"num_tokens_per_rank": num_tokens_per_rank,
"num_tokens_per_rdma_rank": num_tokens_per_rdma_rank,
"is_token_in_rank": is_token_in_rank,
"num_tokens_per_expert": num_tokens_per_expert,
"config": dispatch_config if dispatch_config is not None else config,
}
recv_x, _, _, _, handle, _ = buffer.dispatch(**dispatch_args)
# Tune combine performance
best_time, best_results = 1e10, None
for nvl_chunk_size in range(1, 5, 1):
for rdma_chunk_size in range(8, 33, 4):
config = deep_ep.Config(
num_sms,
nvl_chunk_size,
nvl_buffer_size,
rdma_chunk_size,
rdma_buffer_size,
)
tune_args = {"x": recv_x, "handle": handle, "config": config}
t = bench(lambda: buffer.combine(**tune_args))[0]
if local_rank == 0:
print(
f"[tuning] SMs {num_sms}, NVL chunk {nvl_chunk_size}, RDMA chunk {rdma_chunk_size}: {combine_bf16_rdma_recv_bytes / 1e9 / t:.2f} GB/s (RDMA), {combine_bf16_nvl_send_bytes / 1e9 / t:.2f} GB/s (NVL) ",
flush=True,
)
if t < best_time:
best_time, best_results = t, (
num_sms,
nvl_chunk_size,
rdma_chunk_size,
)
if local_rank == 0:
print(
f"[tuning] Best combine: SMs {best_results[0]}, NVL chunk {best_results[1]}, RDMA chunk {best_results[2]}: {combine_bf16_rdma_recv_bytes / 1e9 / best_time:.2f} GB/s (RDMA), {combine_bf16_nvl_send_bytes / 1e9 / best_time:.2f} GB/s (NVL)",
flush=True,
)
print("", flush=True)
# noinspection PyUnboundLocalVariable
def test_loop(local_rank: int, num_local_ranks: int):
num_nodes = int(os.getenv("WORLD_SIZE", 1))
rank, num_ranks, group = init_dist(local_rank, num_local_ranks)
test_ll_compatibility = False
if test_ll_compatibility:
ll_num_tokens, ll_hidden, ll_num_experts, ll_num_topk = 16, 5120, 256, 9
buffer = deep_ep.Buffer(
group,
int(1e9),
int(1e9),
low_latency_mode=test_ll_compatibility,
num_qps_per_rank=(ll_num_experts // num_ranks if test_ll_compatibility else 1),
)
assert num_local_ranks == 8 and num_ranks > 8
torch.manual_seed(rank)
for i in (24,):
test_main(
i, local_rank, num_local_ranks, num_ranks, num_nodes, rank, buffer, group
)
if local_rank == 0:
print("", flush=True)
# Test compatibility with low latency functions
if test_ll_compatibility:
buffer.clean_low_latency_buffer(ll_num_tokens, ll_hidden, ll_num_experts)
test_deepep_low_latency.test_main(
ll_num_tokens,
ll_hidden,
ll_num_experts,
ll_num_topk,
rank,
num_ranks,
group,
buffer,
seed=1,
)
if __name__ == "__main__":
num_processes = 8
torch.multiprocessing.spawn(test_loop, args=(num_processes,), nprocs=num_processes)
+378
View File
@@ -0,0 +1,378 @@
# Copy from deepseek-ai/DeepEP/tests/test_intranode.py
import time
# noinspection PyUnresolvedReferences
import deep_ep
# Test compatibility with low latency functions
import test_deepep_low_latency
import torch
import torch.distributed as dist
from sglang.test.test_deepep_utils import (
bench,
calc_diff,
init_dist,
inplace_unique,
per_token_cast_back,
per_token_cast_to_fp8,
)
def test_main(
num_sms: int,
local_rank: int,
num_ranks: int,
rank: int,
buffer: deep_ep.Buffer,
group: dist.ProcessGroup,
):
# Settings
num_tokens, hidden, num_topk, num_experts = (
4096,
7168,
8,
(256 // num_ranks) * num_ranks,
)
assert num_experts % num_ranks == 0
if local_rank == 0:
print(
f"[config] num_tokens={num_tokens}, hidden={hidden}, num_topk={num_topk}",
flush=True,
)
# Random data
x = torch.ones((num_tokens, hidden), dtype=torch.bfloat16, device="cuda") * rank
x_pure_rand = torch.randn((num_tokens, hidden), dtype=torch.bfloat16, device="cuda")
x_e4m3 = per_token_cast_to_fp8(x)
scores = (
torch.randn((num_tokens, num_experts), dtype=torch.float32, device="cuda").abs()
+ 1
)
topk_idx = torch.topk(scores, num_topk, dim=-1, largest=True, sorted=False)[1]
topk_weights = (
torch.ones((num_tokens, num_topk), dtype=torch.float32, device="cuda") * rank
)
topk_weights_pure_rand = torch.randn(
(num_tokens, num_topk), dtype=torch.float32, device="cuda"
)
rank_idx = topk_idx // (num_experts // num_ranks)
rank_idx.masked_fill_(topk_idx == -1, -1)
inplace_unique(rank_idx, num_ranks)
# Expert meta
num_tokens_per_expert = torch.zeros((num_experts,), dtype=torch.int, device="cuda")
for i in range(num_experts):
num_tokens_per_expert[i] = (topk_idx == i).sum()
gbl_num_tokens_per_expert = num_tokens_per_expert.clone()
dist.all_reduce(gbl_num_tokens_per_expert, group=group)
# Rank layout meta
num_tokens_per_rank = torch.empty((num_ranks,), dtype=torch.int, device="cuda")
token_idx_in_rank = torch.full(
(num_ranks, num_tokens), -1, dtype=torch.long, device="cuda"
)
for i in range(num_ranks):
num_tokens_per_rank[i] = (rank_idx == i).sum()
token_sel = (rank_idx == i).max(dim=-1)[0]
count = token_sel.sum().item()
tokens = torch.sort(token_sel.to(torch.int), descending=True)[1]
tokens[:count] = torch.sort(tokens[:count])[0]
token_idx_in_rank[i][tokens[:count]] = torch.arange(
count, dtype=torch.long, device="cuda"
)
token_idx_in_rank = token_idx_in_rank.T.contiguous().to(torch.int)
is_token_in_rank = token_idx_in_rank >= 0
gbl_num_tokens_per_rank = num_tokens_per_rank.clone()
dist.all_reduce(gbl_num_tokens_per_rank, group=group)
ref_num_tokens_per_rank, _, ref_num_tokens_per_expert, ref_is_token_in_rank, _ = (
buffer.get_dispatch_layout(topk_idx, num_experts)
)
assert torch.allclose(ref_num_tokens_per_rank, num_tokens_per_rank)
assert torch.allclose(ref_num_tokens_per_expert, num_tokens_per_expert)
assert torch.allclose(ref_is_token_in_rank, is_token_in_rank)
t = bench(lambda: buffer.get_dispatch_layout(topk_idx, num_experts))[0]
if local_rank == 0:
print(f"[layout] Kernel performance: {t * 1000:.3f} ms", flush=True)
print("", flush=True)
group.barrier()
time.sleep(1)
# Config
nvl_buffer_size = 256
config = deep_ep.Config(num_sms, 8, nvl_buffer_size)
# Test dispatch
# noinspection PyShadowingNames
def check_data(check_x, rank_prefix_matrix):
assert torch.allclose(check_x.amin(dim=1), check_x.amax(dim=1))
check_start = 0
for i in range(num_ranks):
check_end = rank_prefix_matrix[i][rank].item()
assert (check_x[check_start:check_end, :].int() - i).sum().item() == 0
check_start = check_end
for previous_mode in (False, True):
for async_mode in (False, True):
for current_x in (x_pure_rand, x, x_e4m3):
for with_topk in (False, True):
if local_rank == 0:
print(
f'[testing] Running with {"FP8" if isinstance(current_x, tuple) else "BF16"}, {"with" if with_topk else "without"} top-k (async={async_mode}, previous={previous_mode}) ...',
flush=True,
end="",
)
dispatch_args = {
"x": current_x,
"num_tokens_per_rank": num_tokens_per_rank,
"is_token_in_rank": is_token_in_rank,
"num_tokens_per_expert": num_tokens_per_expert,
"config": config,
"async_finish": async_mode,
}
if with_topk:
dispatch_args.update(
{
"topk_idx": topk_idx,
"topk_weights": (
topk_weights_pure_rand
if current_x is x_pure_rand
else topk_weights
),
}
)
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
(
recv_x,
recv_topk_idx,
recv_topk_weights,
recv_num_tokens_per_expert_list,
handle,
event,
) = buffer.dispatch(**dispatch_args)
event.current_stream_wait() if async_mode else ()
recv_x = (
per_token_cast_back(*recv_x)
if isinstance(recv_x, tuple)
else recv_x
)
# Checks
rank_prefix_matrix = handle[0]
assert gbl_num_tokens_per_rank[rank].item() == recv_x.size(
0
), f"{gbl_num_tokens_per_rank[rank].item()} != {recv_x.size(0)}"
assert (
gbl_num_tokens_per_expert.view(num_ranks, -1)[rank].tolist()
== recv_num_tokens_per_expert_list
)
if current_x is not x_pure_rand:
check_data(recv_x, rank_prefix_matrix)
if with_topk:
# Check `topk_idx`
assert (
recv_topk_idx.eq(-1)
| (
(recv_topk_idx >= 0)
& (recv_topk_idx < (num_experts // num_ranks))
)
).sum().item() == recv_topk_idx.numel()
for i, count in enumerate(recv_num_tokens_per_expert_list):
assert recv_topk_idx.eq(i).sum().item() == count
# Check `topk_weights`
if current_x is not x_pure_rand:
recv_topk_weights[recv_topk_idx.eq(-1)] = (
recv_topk_weights.amax(dim=1, keepdim=True).expand_as(
recv_topk_weights
)[recv_topk_idx.eq(-1)]
)
check_data(recv_topk_weights, rank_prefix_matrix)
# Test cached dispatch (must without top-k staffs)
if not with_topk:
dispatch_args = {
"x": current_x,
"handle": handle,
"config": config,
"async_finish": async_mode,
}
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
recv_x, _, _, _, _, event = buffer.dispatch(**dispatch_args)
event.current_stream_wait() if async_mode else ()
recv_x = (
per_token_cast_back(*recv_x)
if isinstance(recv_x, tuple)
else recv_x
)
if current_x is not x_pure_rand:
check_data(recv_x, rank_prefix_matrix)
# Test combine
combine_args = {
"x": recv_x,
"handle": handle,
"config": config,
"async_finish": async_mode,
}
if with_topk:
combine_args.update({"topk_weights": recv_topk_weights})
if previous_mode:
dispatch_args.update({"previous_event": buffer.capture()})
combined_x, combined_topk_weights, event = buffer.combine(
**combine_args
)
event.current_stream_wait() if async_mode else ()
check_x = combined_x.float() / is_token_in_rank.sum(
dim=1
).unsqueeze(1)
ref_x = x_pure_rand if current_x is x_pure_rand else x
assert calc_diff(check_x, ref_x) < 5e-6
if with_topk:
check_topk_weights = (
combined_topk_weights
if (current_x is x_pure_rand)
else (
combined_topk_weights
/ is_token_in_rank.sum(dim=1).unsqueeze(1)
)
)
ref_topk_weights = (
topk_weights_pure_rand
if current_x is x_pure_rand
else topk_weights
)
assert calc_diff(check_topk_weights, ref_topk_weights) < 1e-9
# For later tuning
dispatch_bf16_nvl_recv_bytes = recv_x.numel() * 2
combine_bf16_nvl_send_bytes = dispatch_bf16_nvl_recv_bytes
if local_rank == 0:
print(" passed", flush=True)
if local_rank == 0:
print("", flush=True)
# Tune dispatch performance
best_dispatch_results = None
fp8_factor = (1 + 4 / 128) / 2
for current_x in (x_e4m3, x):
best_time, best_results = 1e10, None
nvl_recv_bytes = (
(dispatch_bf16_nvl_recv_bytes * fp8_factor)
if isinstance(current_x, tuple)
else dispatch_bf16_nvl_recv_bytes
)
for nvl_chunk_size in range(4, 33, 4):
config = deep_ep.Config(num_sms, nvl_chunk_size, nvl_buffer_size)
tune_args = {"x": current_x, "handle": handle, "config": config}
t = bench(lambda: buffer.dispatch(**tune_args))[0]
if t < best_time:
best_time, best_results = t, (num_sms, nvl_chunk_size)
if local_rank == 0:
print(
f"[tuning] SMs {num_sms}, NVL chunk {nvl_chunk_size}: {nvl_recv_bytes / 1e9 / t:.2f} GB/s (NVL) ",
flush=True,
)
if local_rank == 0:
print(
f'[tuning] Best dispatch ({"FP8" if isinstance(current_x, tuple) else "BF16"}): SMs {best_results[0]}, NVL chunk {best_results[1]}, {nvl_recv_bytes / 1e9 / best_time:.2f} GB/s (NVL)',
flush=True,
)
print("", flush=True)
if isinstance(current_x, tuple):
# Gather FP8 the best config from rank 0
best_dispatch_results = torch.tensor(
[best_results[0], best_results[1]], dtype=torch.int32, device="cuda"
)
all_best_fp8_results_list = [
torch.zeros_like(best_dispatch_results)
for _ in range(torch.distributed.get_world_size())
]
dist.all_gather(
all_best_fp8_results_list, best_dispatch_results, group=group
)
best_dispatch_results = all_best_fp8_results_list[0].tolist()
dispatch_config = deep_ep.Config(
best_dispatch_results[0], best_dispatch_results[1], nvl_buffer_size
)
dispatch_args = {
"x": x,
"num_tokens_per_rank": num_tokens_per_rank,
"is_token_in_rank": is_token_in_rank,
"num_tokens_per_expert": num_tokens_per_expert,
"config": dispatch_config if dispatch_config is not None else config,
}
recv_x, _, _, _, handle, _ = buffer.dispatch(**dispatch_args)
# Tune combine performance
best_time, best_results = 1e10, None
for nvl_chunk_size in range(1, 7, 1):
config = deep_ep.Config(num_sms, nvl_chunk_size, nvl_buffer_size)
tune_args = {"x": recv_x, "handle": handle, "config": config}
t = bench(lambda: buffer.combine(**tune_args))[0]
if local_rank == 0:
print(
f"[tuning] SMs {num_sms}, NVL chunk {nvl_chunk_size}: {combine_bf16_nvl_send_bytes / 1e9 / t:.2f} GB/s (NVL) ",
flush=True,
)
if t < best_time:
best_time, best_results = t, (num_sms, nvl_chunk_size)
if local_rank == 0:
print(
f"[tuning] Best combine: SMs {best_results[0]}, NVL chunk {best_results[1]}: {combine_bf16_nvl_send_bytes / 1e9 / best_time:.2f} GB/s (NVL)",
flush=True,
)
print("", flush=True)
# noinspection PyUnboundLocalVariable
def test_loop(local_rank: int, num_local_ranks: int):
rank, num_ranks, group = init_dist(local_rank, num_local_ranks)
test_ll_compatibility, num_rdma_bytes = False, 0
if test_ll_compatibility:
ll_num_tokens, ll_hidden, ll_num_experts, ll_num_topk = 16, 5120, 256, 9
num_rdma_bytes = deep_ep.Buffer.get_low_latency_rdma_size_hint(
ll_num_tokens, ll_hidden, num_ranks, ll_num_experts
)
buffer = deep_ep.Buffer(
group,
int(1e9),
num_rdma_bytes,
low_latency_mode=test_ll_compatibility,
num_qps_per_rank=(ll_num_experts // num_ranks if test_ll_compatibility else 1),
)
torch.manual_seed(rank)
for i in (24,):
test_main(i, local_rank, num_ranks, rank, buffer, group)
if local_rank == 0:
print("", flush=True)
# Test compatibility with low latency functions
if test_ll_compatibility:
buffer.clean_low_latency_buffer(ll_num_tokens, ll_hidden, ll_num_experts)
test_deepep_low_latency.test_main(
ll_num_tokens,
ll_hidden,
ll_num_experts,
ll_num_topk,
rank,
num_ranks,
group,
buffer,
seed=1,
)
if __name__ == "__main__":
num_processes = 8
torch.multiprocessing.spawn(test_loop, args=(num_processes,), nprocs=num_processes)
+325
View File
@@ -0,0 +1,325 @@
# Copy from deepseek-ai/DeepEP/tests/test_low_latency.py
import random
from functools import partial
import deep_ep
import torch
import torch.distributed as dist
from sglang.test.test_deepep_utils import (
bench,
bench_kineto,
calc_diff,
hash_tensor,
init_dist,
per_token_cast_back,
)
def test_main(
num_tokens: int,
hidden: int,
num_experts: int,
num_topk: int,
rank: int,
num_ranks: int,
group: dist.ProcessGroup,
buffer: deep_ep.Buffer,
seed: int = 0,
):
torch.manual_seed(seed + rank)
random.seed(seed + rank)
assert num_experts % num_ranks == 0
num_local_experts = num_experts // num_ranks
# NOTES: the integers greater than 256 exceeds the BF16 precision limit
rank_offset = 128
assert (
num_ranks - rank_offset < 257
), "Too many ranks (exceeding test precision limit)"
x = torch.ones((num_tokens, hidden), dtype=torch.bfloat16, device="cuda") * (
rank - rank_offset
)
x[:, -128:] = torch.arange(num_tokens, device="cuda").to(torch.bfloat16).view(-1, 1)
scores = (
torch.randn((num_tokens, num_experts), dtype=torch.float32, device="cuda").abs()
+ 1
)
topk_idx = torch.topk(scores, num_topk, dim=-1, largest=True, sorted=True)[1]
topk_weights = torch.randn(
(num_tokens, num_topk), dtype=torch.float32, device="cuda"
).abs()
# Randomly mask some positions
for i in range(10):
topk_idx[random.randint(0, num_tokens - 1), random.randint(0, num_topk - 1)] = (
-1
)
# Check dispatch correctness
do_check = True
hash_value, num_times = 0, 0
for return_recv_hook in (False, True):
for dispatch_use_fp8 in (False, True):
num_times += 1
for i in range((num_times % 2) + 1):
packed_recv_x, packed_recv_count, handle, event, hook = (
buffer.low_latency_dispatch(
x,
topk_idx,
num_tokens,
num_experts,
use_fp8=dispatch_use_fp8,
async_finish=not return_recv_hook,
return_recv_hook=return_recv_hook,
)
)
hook() if return_recv_hook else event.current_stream_wait()
packed_recv_x = (
(packed_recv_x[0], packed_recv_x[1].contiguous())
if dispatch_use_fp8
else packed_recv_x
)
simulated_gemm_x = (
per_token_cast_back(
packed_recv_x[0].view(-1, hidden),
packed_recv_x[1].view(-1, hidden // 128),
).view(packed_recv_x[0].shape)
if dispatch_use_fp8
else packed_recv_x.clone()
)
all_topk_idx = torch.empty(
(num_ranks, num_tokens, num_topk), dtype=topk_idx.dtype, device="cuda"
)
dist.all_gather_into_tensor(all_topk_idx, topk_idx, group=group)
for i in range(num_local_experts if do_check else 0):
expert_id = rank * num_local_experts + i
recv_x = (
per_token_cast_back(packed_recv_x[0][i], packed_recv_x[1][i])
if dispatch_use_fp8
else packed_recv_x[i]
)
recv_count, recv_src_info, recv_layout_range = (
packed_recv_count[i],
handle[0][i],
handle[1][i],
)
# Check expert indices
int_mask = (2**32) - 1
num_valid_tokens = recv_count.item()
assert (
num_valid_tokens == (recv_layout_range & int_mask).sum().item()
), f"{num_valid_tokens} != {recv_layout_range & int_mask}.sum().item()"
assert (
num_valid_tokens == (all_topk_idx == expert_id).sum().item()
), f"{num_valid_tokens} != {(all_topk_idx == expert_id).sum().item()}"
# Check received data
recv_x = recv_x[:num_valid_tokens]
recv_x_amin = recv_x[:, :-128].amin(dim=-1)
recv_src_info = recv_src_info[:num_valid_tokens]
assert torch.equal(recv_x_amin, recv_x[:, :-128].amax(dim=-1))
assert (
recv_x[:, -128:] - recv_src_info.view(-1, 1) % num_tokens
).sum().item() == 0
for j in range(num_ranks):
begin_idx, count = (recv_layout_range[j] >> 32).item(), (
recv_layout_range[j] & int_mask
).item()
assert (recv_x_amin == j - rank_offset).sum().item() == (
all_topk_idx[j] == expert_id
).sum().item()
assert (
recv_x[begin_idx : begin_idx + count][:-128] - j
).sum().item() == 0
if dispatch_use_fp8:
hash_value ^= hash_tensor(packed_recv_x[0][i, :num_valid_tokens])
hash_value ^= hash_tensor(packed_recv_x[1][i, :num_valid_tokens])
else:
hash_value ^= hash_tensor(packed_recv_x[i, :num_valid_tokens])
# Check combine correctness
for zero_copy in (False, True):
if zero_copy:
buffer.get_next_low_latency_combine_buffer(handle)[
:, :, :
] = simulated_gemm_x
out = torch.empty(
(num_tokens, hidden), dtype=torch.bfloat16, device="cuda"
)
combined_x, event, hook = buffer.low_latency_combine(
simulated_gemm_x,
topk_idx,
topk_weights,
handle,
async_finish=not return_recv_hook,
zero_copy=zero_copy,
return_recv_hook=return_recv_hook,
out=out,
)
hook() if return_recv_hook else event.current_stream_wait()
if do_check:
diff = calc_diff(
x
* topk_weights.masked_fill(topk_idx == -1, 0)
.sum(dim=1)
.view(-1, 1),
combined_x,
)
assert torch.isnan(combined_x).sum().item() == 0
assert diff < 1e-5, f"Error: {diff=}, {zero_copy=}"
hash_value ^= hash_tensor(combined_x)
def create_test_cast_with_outliers(num_outliers):
tmp = torch.randn((num_tokens, hidden), dtype=torch.bfloat16, device="cuda")
tmp /= tmp.abs().amax(dim=1).view(-1, 1)
assert tmp.abs().amax().item() <= 1
# Create some amax outliers
for i in range(num_outliers):
tmp[random.randint(0, num_tokens - 1)] *= 1e3
return tmp
# noinspection PyShadowingNames
def large_gemm_with_hook(hook):
mat_0 = torch.randn((8192, 8192), dtype=torch.float)
mat_1 = torch.randn((8192, 8192), dtype=torch.float)
mat_0 @ mat_1
hook()
# noinspection PyShadowingNames
def test_func(zero_copy: bool, return_recv_hook: bool):
recv_x, recv_count, handle, event, hook = buffer.low_latency_dispatch(
x,
topk_idx,
num_tokens,
num_experts,
async_finish=False,
return_recv_hook=return_recv_hook,
)
large_gemm_with_hook(hook) if return_recv_hook else None
if zero_copy:
buffer.get_next_low_latency_combine_buffer(handle)[
:, :, :
] = simulated_gemm_x
combined_x, event, hook = buffer.low_latency_combine(
simulated_gemm_x,
topk_idx,
topk_weights,
handle,
zero_copy=zero_copy,
return_recv_hook=return_recv_hook,
)
large_gemm_with_hook(hook) if return_recv_hook else None
# Calculate bandwidth
num_fp8_bytes, num_bf16_bytes = (hidden + hidden / 128 * 4 + 16), hidden * 2
num_dispatch_comm_bytes, num_combine_comm_bytes = 0, 0
for i in range(num_tokens):
num_selections = (topk_idx[i] != -1).sum().item()
num_dispatch_comm_bytes += num_fp8_bytes * num_selections
num_combine_comm_bytes += num_bf16_bytes * num_selections
# Dispatch + combine testing
avg_t, min_t, max_t = bench(
partial(test_func, zero_copy=False, return_recv_hook=False)
)
print(
f"[rank {rank}] Dispatch + combine bandwidth: {(num_dispatch_comm_bytes + num_combine_comm_bytes) / 1e9 / avg_t:.2f} GB/s, "
f"avg_t={avg_t * 1e6:.2f} us, min_t={min_t * 1e6:.2f} us, max_t={max_t * 1e6:.2f} us",
flush=True,
)
# Separate profiling
for return_recv_hook in (False, True):
group.barrier()
dispatch_t, combine_t = bench_kineto(
partial(test_func, zero_copy=True, return_recv_hook=return_recv_hook),
kernel_names=("dispatch", "combine"),
barrier_comm_profiling=True,
suppress_kineto_output=True,
)
if not return_recv_hook:
print(
f"[rank {rank}] Dispatch bandwidth: {num_dispatch_comm_bytes / 1e9 / dispatch_t:.2f} GB/s, avg_t={dispatch_t * 1e6:.2f} us | "
f"Combine bandwidth: {num_combine_comm_bytes / 1e9 / combine_t:.2f} GB/s, avg_t={combine_t * 1e6:.2f} us",
flush=True,
)
else:
print(
f"[rank {rank}] Dispatch send/recv time: {dispatch_t * 2 * 1e6:.2f} us | "
f"Combine send/recv time: {combine_t * 2 * 1e6:.2f} us",
flush=True,
)
return hash_value
# noinspection PyUnboundLocalVariable
def test_loop(local_rank: int, num_local_ranks: int):
rank, num_ranks, group = init_dist(local_rank, num_local_ranks)
num_tokens, hidden, num_topk, num_experts = 128, 7168, 8, 288
num_rdma_bytes = deep_ep.Buffer.get_low_latency_rdma_size_hint(
num_tokens, hidden, num_ranks, num_experts
)
if local_rank == 0:
print(f"Allocating buffer size: {num_rdma_bytes / 1e6} MB ...", flush=True)
buffer = deep_ep.Buffer(
group,
num_rdma_bytes=num_rdma_bytes,
low_latency_mode=True,
num_qps_per_rank=num_experts // num_ranks,
)
test_main(
num_tokens,
hidden,
num_experts,
num_topk,
rank,
num_ranks,
group,
buffer,
seed=1,
)
do_pressure_test = False
for seed in range(int(1e9) if do_pressure_test else 0):
if local_rank == 0:
print(f"Testing with seed {seed} ...", flush=True)
ref_hash = test_main(
num_tokens,
hidden,
num_experts,
num_topk,
rank,
num_ranks,
group,
buffer,
seed=seed,
)
for i in range(20):
assert (
test_main(
num_tokens,
hidden,
num_experts,
num_topk,
rank,
num_ranks,
group,
buffer,
seed=seed,
)
== ref_hash
), f"Error: seed={seed}"
if __name__ == "__main__":
# TODO: you may modify NUMA binding for less CPU overhead
num_processes = 8
torch.multiprocessing.spawn(test_loop, args=(num_processes,), nprocs=num_processes)
+182
View File
@@ -0,0 +1,182 @@
import os
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
import sglang as sgl
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_MLA_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
def get_a2a_backend_config():
# On Blackwell or machines where DeepEP is hard to compile, set
# SGLANG_EPLB_TEST_MOE_A2A_BACKEND=flashinfer to use FlashInfer A2A.
moe_a2a_backend = os.environ.get("SGLANG_EPLB_TEST_MOE_A2A_BACKEND", "deepep")
args = ["--moe-a2a-backend", moe_a2a_backend]
kwargs = {"moe_a2a_backend": moe_a2a_backend}
if moe_a2a_backend == "deepep":
args.extend(["--deepep-mode", "normal"])
kwargs["deepep_mode"] = "normal"
elif moe_a2a_backend == "flashinfer":
args.extend(["--moe-runner-backend", "flashinfer_cutlass"])
kwargs["moe_runner_backend"] = "flashinfer_cutlass"
return args, kwargs
def get_a2a_backend_args():
return get_a2a_backend_config()[0]
def get_a2a_backend_kwargs():
return get_a2a_backend_config()[1]
class _BaseTestDynamicEPLB(CustomTestCase):
extra_args = []
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MLA_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
with (
envs.SGLANG_ENABLE_JIT_DEEPGEMM.override(False),
envs.SGLANG_EXPERT_LOCATION_UPDATER_CANARY.override(True),
):
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--tp",
"2",
"--dp",
"2",
"--enable-dp-attention",
*get_a2a_backend_args(),
"--disable-cuda-graph",
"--enable-eplb",
"--ep-num-redundant-experts",
"4",
"--eplb-rebalance-num-iterations",
"50",
"--expert-distribution-recorder-buffer-size",
"50",
# TODO pr-chain: enable later
# "--enable-expert-distribution-metrics",
# TODO auto determine these flags
"--expert-distribution-recorder-mode",
"stat",
"--ep-dispatch-algorithm",
"static",
*cls.extra_args,
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
time.sleep(5)
def test_mmlu(self):
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="mmlu",
num_examples=64,
num_threads=32,
)
metrics = run_eval(args)
self.assertGreater(metrics["score"], 0.5)
class TestDynamicEPLBSimple(_BaseTestDynamicEPLB):
pass
class TestDynamicEPLBMultiChunk(_BaseTestDynamicEPLB):
extra_args = ["--eplb-rebalance-layers-per-chunk", "1"]
class TestStaticEPLB(CustomTestCase):
def test_save_expert_distribution_and_init_expert_location(self):
envs.SGLANG_ENABLE_JIT_DEEPGEMM.set(False)
with tempfile.TemporaryDirectory() as tmp_dir:
engine_kwargs = dict(
model_path=DEFAULT_MLA_MODEL_NAME_FOR_TEST,
trust_remote_code=True,
ep_num_redundant_experts=4,
enable_dp_attention=True,
disable_cuda_graph=True,
expert_distribution_recorder_mode="stat",
tp_size=2,
dp_size=2,
log_level="info",
# TODO pr-chain: enable later
# enable_expert_distribution_metrics=True,
)
engine_kwargs.update(get_a2a_backend_kwargs())
print(f"Action: start engine")
envs.SGLANG_EXPERT_DISTRIBUTION_RECORDER_DIR.set(tmp_dir)
engine = sgl.Engine(
**engine_kwargs,
disable_overlap_schedule=True,
port=11000,
)
engine.start_expert_distribution_record()
self._assert_engine_generate_correct(engine)
print(f"Action: dump_expert_distribution_record")
engine.dump_expert_distribution_record()
snapshot_path = list(Path(tmp_dir).glob("*.pt"))[0]
assert snapshot_path is not None
print(f"{snapshot_path=}")
print(f"Action: shutdown engine")
engine.shutdown()
del engine
engine = None
time.sleep(5)
print(f"Action: start engine with init_expert_location")
engine = sgl.Engine(
**engine_kwargs,
init_expert_location=str(snapshot_path),
# TODO auto determine these flags
ep_dispatch_algorithm="static",
port=12000,
)
self._assert_engine_generate_correct(engine)
print(f"Action: shutdown engine")
engine.shutdown()
del engine
engine = None
time.sleep(5)
def _assert_engine_generate_correct(self, engine: sgl.Engine):
output = engine.generate(
prompt=["1+1=2, 2+2=4", "One plus one is two, two plus two is four"],
sampling_params=dict(max_new_tokens=8, temperature=0.0),
)
print(f"engine.generate {output=}")
self.assertEqual(
[x["text"] for x in output],
[", 4+4=8,", ", four plus four is eight, eight"],
)
if __name__ == "__main__":
unittest.main()
+242
View File
@@ -0,0 +1,242 @@
import os
import unittest
from types import SimpleNamespace
from sglang.srt.server_args import ZMQ_TCP_PORT_DELTA
from sglang.srt.utils import kill_process_tree
from sglang.srt.utils.network import is_port_available
from sglang.test.few_shot_gsm8k import run_eval as run_eval_few_shot_gsm8k
from sglang.test.test_utils import (
DEFAULT_DEEPEP_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
def wait_all_ports_release(base_url, timeout_s=60):
import time
port = int(base_url.split(":")[-1])
offsets = [
0,
ZMQ_TCP_PORT_DELTA,
ZMQ_TCP_PORT_DELTA + 1,
ZMQ_TCP_PORT_DELTA + 2,
ZMQ_TCP_PORT_DELTA + 3,
ZMQ_TCP_PORT_DELTA + 4,
]
for _ in range(timeout_s):
if all(is_port_available(port + off) for off in offsets):
return
time.sleep(1)
print(f"Warning: some ports still occupied after {timeout_s}s")
mori_env = {
**os.environ,
"SGLANG_USE_AITER": "1",
"SGLANG_MORI_DISPATCH_DTYPE": "bf16",
"SGLANG_MORI_NUM_MAX_DISPATCH_TOKENS_PER_RANK": "4096",
"SGLANG_EPLB_ROCM_P2P_BATCH_CHUNK_SIZE": "32",
"MORI_SHMEM_MODE": "ISOLATION",
}
common_args = [
"--tp-size",
"8",
"--ep-size",
"8",
"--dp-size",
"8",
"--enable-dp-attention",
"--moe-a2a-backend",
"mori",
"--trust-remote-code",
"--load-balance-method",
"round_robin",
"--moe-dense-tp-size",
"1",
"--enable-dp-lm-head",
"--mem-fraction-static",
"0.6",
"--chunked-prefill-size",
"32768",
"--max-running-requests",
"128",
"--context-length",
"12288",
"--attention-backend",
"aiter",
"--cuda-graph-max-bs-decode",
"32",
]
eplb_args = [
"--enable-eplb",
"--ep-num-redundant-experts",
"32",
"--eplb-rebalance-num-iterations",
"50",
"--expert-distribution-recorder-buffer-size",
"50",
"--ep-dispatch-algorithm",
"static",
]
mtp_args = [
"--speculative-algo",
"EAGLE",
"--speculative-num-steps",
"3",
"--speculative-eagle-topk",
"1",
"--speculative-num-draft-tokens",
"4",
]
class TestEPLBMoriStat(CustomTestCase):
"""EPLB with mori backend, stat mode (on_select_experts path)."""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_DEEPEP_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = (
common_args
+ eplb_args
+ [
"--deepep-mode",
"normal",
"--expert-distribution-recorder-mode",
"stat",
]
)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 5,
other_args=other_args,
env=mori_env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
wait_all_ports_release(cls.base_url)
def test_gsm8k(self):
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=1209,
max_new_tokens=512,
parallel=1209,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval_few_shot_gsm8k(args)
print(f"{metrics=}")
self.assertGreaterEqual(metrics["accuracy"], 0.9)
class TestEPLBMoriStatApprox(CustomTestCase):
"""EPLB with mori backend, stat_approx mode (local_expert_count kernel)."""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_DEEPEP_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = (
common_args
+ eplb_args
+ [
"--deepep-mode",
"normal",
"--expert-distribution-recorder-mode",
"stat_approx",
]
)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 5,
other_args=other_args,
env=mori_env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
wait_all_ports_release(cls.base_url)
def test_gsm8k(self):
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=1209,
max_new_tokens=512,
parallel=1209,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval_few_shot_gsm8k(args)
print(f"{metrics=}")
self.assertGreaterEqual(metrics["accuracy"], 0.9)
class TestEPLBMoriMultiChunk(CustomTestCase):
"""EPLB with mori backend, chunked layer updates."""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_DEEPEP_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = (
common_args
+ eplb_args
+ [
"--deepep-mode",
"normal",
"--expert-distribution-recorder-mode",
"stat",
"--eplb-rebalance-layers-per-chunk",
"1",
]
)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH * 5,
other_args=other_args,
env=mori_env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
wait_all_ports_release(cls.base_url)
def test_gsm8k(self):
args = SimpleNamespace(
num_shots=5,
data_path=None,
num_questions=1209,
max_new_tokens=512,
parallel=1209,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval_few_shot_gsm8k(args)
print(f"{metrics=}")
self.assertGreaterEqual(metrics["accuracy"], 0.9)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More