[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
@@ -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()