[Feature] Beam search support (#31626)

Co-authored-by: cswuyg <cswuyg@gmail.com>
Co-authored-by: cswuyg <496090217@qq.com>
Co-authored-by: Vedant Jhaveri <vedantjh2@gmail.com>
Co-authored-by: Vedant Jhaveri <vjhaveri@linkedin.com>
This commit is contained in:
Liangsheng Yin
2026-08-26 16:56:15 -07:00
committed by GitHub
co-authored by cswuyg cswuyg Vedant Jhaveri Vedant Jhaveri
parent e5a1c5a423
commit ec4bdbfa4a
39 changed files with 3066 additions and 33 deletions
+122
View File
@@ -0,0 +1,122 @@
"""Beam search parity acceptance test (executable API spec).
- Trigger: sampling_params.beam_width = k (> 1); no server-level beam flag.
- Response: one response per rid; meta_info.beam_results holds the top-n
sequences (n <= beam_width, default 1 as in HF/OpenAI), best score first.
- Acceptance: sequence-set overlap vs HF transformers >= 0.8 for k in {2, 10}.
Manual test (GPU host): python3 test_beam_parity.py
"""
import os
import unittest
from typing import List
import requests
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
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,
)
PROMPT = "Hello SGLang"
MAX_NEW_TOKENS = 10
OVERLAP_THRESHOLD = 0.8
def get_transformers_beam_sequences(
model_path: str, prompt: str, beam_width: int, max_new_tokens: int
) -> List[str]:
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(model_path, dtype="auto")
model = model.to("cuda" if torch.cuda.is_available() else "cpu")
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
input_length = inputs["input_ids"].shape[1]
with torch.no_grad():
generated = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
num_beams=beam_width,
num_return_sequences=beam_width,
do_sample=False,
)
sequences = [
tokenizer.decode(seq[input_length:].cpu().tolist(), skip_special_tokens=True)
for seq in generated
]
del model
torch.cuda.empty_cache()
return sequences
class TestBeamParity(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = os.environ.get(
"SGLANG_TEST_BEAM_MODEL", DEFAULT_SMALL_MODEL_NAME_FOR_TEST
)
cls.base_url = DEFAULT_URL_FOR_TEST
# No beam-specific server flag. Overlap is pinned off so a parity
# mismatch can only come from the search, not from scheduling.
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--disable-overlap-schedule"],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def _generate_beams(self, beam_width, n=None):
sampling_params = {"beam_width": beam_width, "max_new_tokens": MAX_NEW_TOKENS}
if n is not None:
sampling_params["n"] = n
resp = requests.post(
f"{self.base_url}/generate",
json={"text": PROMPT, "sampling_params": sampling_params},
timeout=120,
)
self.assertEqual(resp.status_code, 200, resp.text)
beam_results = resp.json().get("meta_info", {}).get("beam_results")
self.assertIsNotNone(beam_results, "response carries no beam_results")
return beam_results
def test_parity_vs_transformers(self):
for beam_width in [2, 10]:
# n=beam_width to compare the whole beam set; the API default is 1.
beam_results = self._generate_beams(beam_width, n=beam_width)
self.assertEqual(len(beam_results), beam_width)
scores = [r["meta_info"]["sequence_score"] for r in beam_results]
self.assertEqual(scores, sorted(scores, reverse=True))
sglang_sequences = {r["text"] for r in beam_results}
hf_sequences = set(
get_transformers_beam_sequences(
self.model, PROMPT, beam_width, MAX_NEW_TOKENS
)
)
overlap = len(sglang_sequences & hf_sequences) / beam_width
print(f"beam_width={beam_width} overlap={overlap:.2%}")
self.assertGreaterEqual(overlap, OVERLAP_THRESHOLD)
def test_return_top_n(self):
beam_results = self._generate_beams(beam_width=10, n=3)
self.assertEqual(len(beam_results), 3)
def test_default_returns_one_sequence(self):
self.assertEqual(len(self._generate_beams(beam_width=10)), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,188 @@
"""Beam search load and admission-saturation tests.
- Mixed-width load: 100 requests at 10 QPS, widths in [2, 100]; expect
100/100 OK, report p50/p90/p99 latency.
- Extreme fanout: beam_width=3200, so each request owns 3200 req-to-token
slots and the admission gate serializes them. Phase 1 measures
single-inflight service time (arrival-rate percentiles sit on the queueing
knee and are not usable as an SLO); phase 2 drives 0.8x the measured
capacity and expects a stable queue.
Manual test (GPU host): python3 test_beam_search_load.py
"""
import asyncio
import os
import random
import time
import unittest
import aiohttp
import numpy as np
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,
)
MAX_NEW_TOKENS = 10
CLIENT_TIMEOUT_S = 600
PROMPT = "Write a short story about a robot learning to paint."
async def _generate(session, base_url, width):
start = time.perf_counter()
async with session.post(
f"{base_url}/generate",
json={
"text": PROMPT,
"sampling_params": {"beam_width": width, "max_new_tokens": MAX_NEW_TOKENS},
},
) as resp:
payload = await resp.json()
latency = time.perf_counter() - start
beam_results = payload.get("meta_info", {}).get("beam_results") or []
return resp.status, len(beam_results), latency
async def _run_at_qps(base_url, widths, qps):
"""Fire one request per width at a fixed rate; return per-request results."""
timeout = aiohttp.ClientTimeout(total=CLIENT_TIMEOUT_S)
async with aiohttp.ClientSession(timeout=timeout) as session:
async def delayed(i, width):
await asyncio.sleep(i / qps)
return await _generate(session, base_url, width)
return await asyncio.gather(
*[delayed(i, width) for i, width in enumerate(widths)]
)
def _report_latencies(name, results):
latencies_ms = [lat * 1000 for _, _, lat in results]
p50, p90, p99 = np.percentile(latencies_ms, [50, 90, 99])
print(f"{name}: n={len(results)} p50/p90/p99 = {p50:.0f}/{p90:.0f}/{p99:.0f} ms")
class _BeamLoadTestBase(CustomTestCase):
extra_server_args = []
@classmethod
def setUpClass(cls):
cls.model = os.environ.get("SGLANG_TEST_BEAM_LOAD_MODEL", "Qwen/Qwen2.5-0.5B")
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=["--disable-overlap-schedule", "--disable-radix-cache"]
+ cls.extra_server_args,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def _check_all_ok(self, results, widths):
num_ok = sum(1 for status, _, _ in results if status == 200)
self.assertEqual(num_ok, len(results), f"{len(results) - num_ok} failed")
for (_, num_beams, _), width in zip(results, widths):
self.assertGreaterEqual(num_beams, 1)
self.assertLessEqual(num_beams, width)
class TestBeamSearchMixedWidthLoad(_BeamLoadTestBase):
"""100 requests at 10 QPS with beam widths mixed in [2, 100]."""
def test_mixed_width_load(self):
rng = random.Random(42)
widths = [rng.randint(2, 100) for _ in range(100)]
results = asyncio.run(_run_at_qps(self.base_url, widths, qps=10))
self._check_all_ok(results, widths)
_report_latencies("mixed-width 100 reqs @ 10 QPS", results)
class TestBeamMixedWithNormalTraffic(_BeamLoadTestBase):
"""Beam and normal requests finishing in shared batches: the carrier must
stay index-aligned across IPC and normal outputs must keep their text."""
def test_mixed_traffic(self):
async def run():
timeout = aiohttp.ClientTimeout(total=CLIENT_TIMEOUT_S)
async with aiohttp.ClientSession(timeout=timeout) as session:
async def normal():
async with session.post(
f"{self.base_url}/generate",
json={
"text": PROMPT,
"sampling_params": {"max_new_tokens": MAX_NEW_TOKENS},
},
) as resp:
return resp.status, await resp.json()
return await asyncio.gather(
*[_generate(session, self.base_url, 4) for _ in range(10)],
*[normal() for _ in range(10)],
)
results = asyncio.run(run())
beam_results, normal_results = results[:10], results[10:]
self._check_all_ok(beam_results, [4] * 10)
for status, payload in normal_results:
self.assertEqual(status, 200)
self.assertTrue(
payload["text"], "normal request lost its text in a mixed batch"
)
self.assertNotIn("beam_results", payload["meta_info"])
class TestBeamSearchExtremeFanout(_BeamLoadTestBase):
"""beam_width=3200: measure single-group service time, then 0.8x-capacity load."""
# Each beam_width=3200 request owns 3200 req-to-token slots; make the pool
# size deterministic so exactly one group fits at a time.
extra_server_args = ["--max-running-requests", "4000"]
WIDTH = 3200
async def _run_sequential(self, num_requests):
timeout = aiohttp.ClientTimeout(total=CLIENT_TIMEOUT_S)
async with aiohttp.ClientSession(timeout=timeout) as session:
return [
await _generate(session, self.base_url, self.WIDTH)
for _ in range(num_requests)
]
def test_extreme_fanout(self):
# Phase 1: single-inflight service time (first request dropped as warmup).
results = asyncio.run(self._run_sequential(6))
self._check_all_ok(results, [self.WIDTH] * 6)
service_samples = [lat for _, _, lat in results[1:]]
service_s = sum(service_samples) / len(service_samples)
print(
f"extreme fanout n={self.WIDTH} single-inflight service: "
f"{service_s * 1000:.0f} ms/group"
)
# Phase 2: stable-queue load at 0.8x the measured serial capacity.
qps = 0.8 / service_s
num_requests = max(10, int(30 * qps))
widths = [self.WIDTH] * num_requests
results = asyncio.run(_run_at_qps(self.base_url, widths, qps=qps))
self._check_all_ok(results, widths)
_report_latencies(
f"extreme fanout n={self.WIDTH} @ 0.8x capacity ({qps:.2f} QPS)", results
)
# Server must still be alive and serving after the burst.
final = asyncio.run(_run_at_qps(self.base_url, [2], qps=1))
self._check_all_ok(final, [2])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,149 @@
"""Beam width sweep benchmark: 100 concurrent ShareGPT prompts
(prompt_len < 100), max_new_tokens=10, widths 10/50/100/200/400.
Primary metric is aggregate beam tok/s.
Manual test (GPU host): python3 test_beam_search_perf_sweep.py
"""
import asyncio
import os
import time
import unittest
import aiohttp
from sglang.benchmark.datasets.sharegpt import sample_sharegpt_requests
from sglang.srt.utils import kill_process_tree
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
BEAM_WIDTHS = [10, 50, 100, 200, 400]
NUM_PROMPTS = 100
MAX_PROMPT_LEN = 100
MAX_NEW_TOKENS = 10
CLIENT_TIMEOUT_S = 1200
async def _generate(session, base_url, prompt, width):
start = time.perf_counter()
async with session.post(
f"{base_url}/generate",
json={
"text": prompt,
"sampling_params": {"beam_width": width, "max_new_tokens": MAX_NEW_TOKENS},
},
) as resp:
payload = await resp.json()
latency = time.perf_counter() - start
beam_results = payload.get("meta_info", {}).get("beam_results") or []
return resp.status, len(beam_results), latency
class _BeamSweepBase(CustomTestCase):
# Primary metric is aggregate beam tok/s (reqs x width x new_tokens /
# elapsed); QPS is secondary since it conflates width.
extra_server_args = []
pool_label = "default pool"
@classmethod
def setUpClass(cls):
cls.model = os.environ.get("SGLANG_TEST_BEAM_MODEL", "Qwen/Qwen3-1.7B")
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
# 0.7 leaves headroom for the full-vocab [num_beam_rows, vocab]
# logprobs tensor, which OOMs at large width x concurrency.
other_args=["--disable-overlap-schedule", "--mem-fraction-static", "0.7"]
+ cls.extra_server_args,
)
tokenizer = get_tokenizer(cls.model)
rows = sample_sharegpt_requests(
dataset_path="", num_requests=4000, tokenizer=tokenizer
)
cls.prompts = [r.prompt for r in rows if r.prompt_len < MAX_PROMPT_LEN][
:NUM_PROMPTS
]
assert (
len(cls.prompts) == NUM_PROMPTS
), f"only {len(cls.prompts)} short prompts sampled"
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
async def _run_one_width(self, width):
timeout = aiohttp.ClientTimeout(total=CLIENT_TIMEOUT_S)
async with aiohttp.ClientSession(timeout=timeout) as session:
start = time.perf_counter()
results = await asyncio.gather(
*[
_generate(session, self.base_url, prompt, width)
for prompt in self.prompts
]
)
elapsed = time.perf_counter() - start
return results, elapsed
def _run_sweep(self):
report = []
for width in BEAM_WIDTHS:
results, elapsed = asyncio.run(self._run_one_width(width))
num_ok = sum(1 for status, _, _ in results if status == 200)
self.assertEqual(
num_ok, NUM_PROMPTS, f"width={width}: {NUM_PROMPTS - num_ok} failed"
)
for status, num_beams, _ in results:
self.assertGreaterEqual(num_beams, 1)
self.assertLessEqual(num_beams, width)
beam_tok_s = NUM_PROMPTS * width * MAX_NEW_TOKENS / elapsed
qps = NUM_PROMPTS / elapsed
report.append((width, beam_tok_s, qps, elapsed))
print(
f"width={width:4d} beam_tok/s={beam_tok_s:9.0f} "
f"qps={qps:6.2f} elapsed={elapsed:6.2f}s"
)
print(f"\nBeam width sweep ({self.pool_label}):")
print("| beam width | beam tok/s | qps | elapsed (s) |")
print("|---|---|---|---|")
for width, beam_tok_s, qps, elapsed in report:
print(f"| {width} | {beam_tok_s:.0f} | {qps:.2f} | {elapsed:.2f} |")
class TestBeamSweepDefaultPool(_BeamSweepBase):
"""Default req-slot pool (4096): beam rows pin at ~4000 for width >= 50, so
this curve saturates at the pool, not the engine."""
def test_beam_width_sweep(self):
self._run_sweep()
class TestBeamSweepLargePool(_BeamSweepBase):
"""Enlarged pool (16384) for the engine ceiling: ~40 width-400 groups run
concurrently, affordable because --context-length is short."""
extra_server_args = [
"--max-running-requests",
"16384",
"--context-length",
"2048",
]
pool_label = "large pool (16384 slots)"
def test_beam_width_sweep(self):
self._run_sweep()
if __name__ == "__main__":
unittest.main()