[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:
co-authored by
cswuyg
cswuyg
Vedant Jhaveri
Vedant Jhaveri
parent
e5a1c5a423
commit
ec4bdbfa4a
@@ -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()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Golden and differential tests for the beam search core.
|
||||
|
||||
Covers the pure selection functions (joint_select / select_final_topk), the
|
||||
backpointer history DAG, and the BeamGroup lifecycle. The differential oracle is
|
||||
a naive walk-in-order loop written here, not the implementation under test.
|
||||
"""
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.beam_search import (
|
||||
BeamGroup,
|
||||
BeamNode,
|
||||
joint_select,
|
||||
materialize_tokens,
|
||||
select_final_topk,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def T(data, dtype):
|
||||
return torch.tensor(data, dtype=dtype)
|
||||
|
||||
|
||||
def run_select(cum, logprobs, tokens, stop_ids, k):
|
||||
return joint_select(
|
||||
T(cum, torch.float32),
|
||||
T(logprobs, torch.float32),
|
||||
T(tokens, torch.int64),
|
||||
T(sorted(stop_ids), torch.int64),
|
||||
k,
|
||||
)
|
||||
|
||||
|
||||
def reference_select(cum, logprobs, tokens, stop_ids, k):
|
||||
"""Naive port of the original walk-in-order expansion loop."""
|
||||
num_candidates = len(logprobs[0])
|
||||
cands = [
|
||||
(cum[r] + logprobs[r][c], r, tokens[r][c])
|
||||
for r in range(len(cum))
|
||||
for c in range(num_candidates)
|
||||
]
|
||||
cands.sort(key=lambda x: -x[0])
|
||||
cands = cands[:num_candidates]
|
||||
|
||||
survivors, finished = [], []
|
||||
for score, row, token in cands:
|
||||
if token in stop_ids:
|
||||
finished.append((score, row, token))
|
||||
else:
|
||||
survivors.append((score, row, token))
|
||||
if len(survivors) == k:
|
||||
break
|
||||
return survivors, finished
|
||||
|
||||
|
||||
def unpack(sel):
|
||||
ns, nf = int(sel.num_survivors), int(sel.num_finished)
|
||||
survivors = list(
|
||||
zip(
|
||||
sel.new_cum_logprobs[:ns].tolist(),
|
||||
sel.parent_idx[:ns].tolist(),
|
||||
sel.next_tokens[:ns].tolist(),
|
||||
)
|
||||
)
|
||||
finished = list(
|
||||
zip(
|
||||
sel.fin_cum_logprobs[:nf].tolist(),
|
||||
sel.fin_parent_idx[:nf].tolist(),
|
||||
sel.fin_tokens[:nf].tolist(),
|
||||
)
|
||||
)
|
||||
return survivors, finished
|
||||
|
||||
|
||||
class TestJointSelectGolden(CustomTestCase):
|
||||
CUM = [0.0, -1.0]
|
||||
LOGPROBS = [[-0.1, -0.2, -0.3, -0.4], [-0.05, -0.5, -0.6, -0.7]]
|
||||
TOKENS = [[10, 11, 12, 13], [20, 21, 22, 23]]
|
||||
|
||||
def assert_close(self, actual, expected):
|
||||
self.assertEqual(len(actual), len(expected))
|
||||
for (a_score, a_row, a_tok), (e_score, e_row, e_tok) in zip(actual, expected):
|
||||
self.assertAlmostEqual(a_score, e_score, places=5)
|
||||
self.assertEqual((a_row, a_tok), (e_row, e_tok))
|
||||
|
||||
def test_no_stop_fast_path(self):
|
||||
sel = run_select(self.CUM, self.LOGPROBS, self.TOKENS, set(), 2)
|
||||
survivors, finished = unpack(sel)
|
||||
self.assert_close(survivors, [(-0.1, 0, 10), (-0.2, 0, 11)])
|
||||
self.assertEqual(finished, [])
|
||||
|
||||
def test_stop_routing(self):
|
||||
# Sorted walk: 10 survives, 11 is a stop (examined), 12 survives -> stop.
|
||||
# 13 and every row-1 candidate fall outside the examined window.
|
||||
sel = run_select(self.CUM, self.LOGPROBS, self.TOKENS, {11, 20}, 2)
|
||||
survivors, finished = unpack(sel)
|
||||
self.assert_close(survivors, [(-0.1, 0, 10), (-0.3, 0, 12)])
|
||||
self.assert_close(finished, [(-0.2, 0, 11)])
|
||||
|
||||
def test_insufficient_survivors(self):
|
||||
sel = run_select(
|
||||
[0.0], [[-0.1, -0.2, -0.3, -0.4]], [[5, 6, 7, 8]], {5, 6, 7}, 2
|
||||
)
|
||||
survivors, finished = unpack(sel)
|
||||
self.assert_close(survivors, [(-0.4, 0, 8)])
|
||||
self.assert_close(finished, [(-0.1, 0, 5), (-0.2, 0, 6), (-0.3, 0, 7)])
|
||||
|
||||
def test_all_stop(self):
|
||||
sel = run_select(
|
||||
[0.0], [[-0.1, -0.2, -0.3, -0.4]], [[5, 6, 7, 8]], {5, 6, 7, 8}, 2
|
||||
)
|
||||
survivors, finished = unpack(sel)
|
||||
self.assertEqual(survivors, [])
|
||||
self.assertEqual(len(finished), 4)
|
||||
|
||||
def test_select_final_topk(self):
|
||||
sel = select_final_topk(
|
||||
T(self.CUM, torch.float32),
|
||||
T(self.LOGPROBS, torch.float32),
|
||||
T(self.TOKENS, torch.int64),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(sel.tokens.tolist(), [10, 11])
|
||||
self.assertEqual(sel.parent_idx.tolist(), [0, 0])
|
||||
for actual, expected in zip(sel.cum_logprobs.tolist(), [-0.1, -0.2]):
|
||||
self.assertAlmostEqual(actual, expected, places=5)
|
||||
|
||||
|
||||
class TestJointSelectDifferential(CustomTestCase):
|
||||
def test_random_vs_reference(self):
|
||||
rng = random.Random(42)
|
||||
torch.manual_seed(42)
|
||||
for trial in range(200):
|
||||
k = rng.choice([1, 2, 3, 5])
|
||||
num_rows = rng.choice([1, k])
|
||||
num_candidates = 2 * k
|
||||
vocab = list(range(100))
|
||||
|
||||
logprobs = torch.randn(num_rows, num_candidates)
|
||||
tokens = [rng.sample(vocab, num_candidates) for _ in range(num_rows)]
|
||||
cum = [rng.uniform(-5, 0) for _ in range(num_rows)]
|
||||
stop_density = rng.choice([0.0, 0.3, 0.9, 1.0])
|
||||
stop_ids = {t for t in vocab if rng.random() < stop_density}
|
||||
|
||||
sel = joint_select(
|
||||
T(cum, torch.float32),
|
||||
logprobs,
|
||||
T(tokens, torch.int64),
|
||||
T(sorted(stop_ids), torch.int64),
|
||||
k,
|
||||
)
|
||||
survivors, finished = unpack(sel)
|
||||
ref_survivors, ref_finished = reference_select(
|
||||
cum, logprobs.tolist(), tokens, stop_ids, k
|
||||
)
|
||||
|
||||
msg = f"trial={trial} k={k} rows={num_rows} density={stop_density}"
|
||||
self.assertEqual(len(survivors), len(ref_survivors), msg)
|
||||
self.assertEqual(len(finished), len(ref_finished), msg)
|
||||
for actual, expected in zip(
|
||||
survivors + finished, ref_survivors + ref_finished
|
||||
):
|
||||
self.assertAlmostEqual(actual[0], expected[0], places=4, msg=msg)
|
||||
self.assertEqual(actual[1:], expected[1:], msg)
|
||||
|
||||
|
||||
class TestHistory(CustomTestCase):
|
||||
def test_materialize(self):
|
||||
a = BeamNode(1)
|
||||
b = BeamNode(2, a)
|
||||
c = BeamNode(3, a) # reparent: sibling branch off the same prefix
|
||||
self.assertEqual(materialize_tokens(b), [1, 2])
|
||||
self.assertEqual(materialize_tokens(c), [1, 3])
|
||||
self.assertEqual(materialize_tokens(None), [])
|
||||
|
||||
|
||||
class TestBeamGroup(CustomTestCase):
|
||||
def _make_group(self, **kwargs):
|
||||
defaults = dict(beam_width=2, stop_token_ids=[99], max_new_tokens=3)
|
||||
defaults.update(kwargs)
|
||||
return BeamGroup(**defaults)
|
||||
|
||||
def test_lifecycle_eos_and_length_finish(self):
|
||||
group = self._make_group()
|
||||
|
||||
# Prefill selection: single pseudo-row frontier.
|
||||
sel = run_select([0.0], [[-0.1, -0.2, -0.3, -0.4]], [[1, 2, 3, 4]], {99}, 2)
|
||||
self.assertFalse(group.advance(sel))
|
||||
self.assertEqual(
|
||||
[materialize_tokens(leaf) for leaf in group.leaves], [[1], [2]]
|
||||
)
|
||||
|
||||
# The best candidate is a stop token; two survivors remain.
|
||||
sel = run_select(
|
||||
[-0.1, -0.2],
|
||||
[[-0.05, -0.2, -0.5, -0.9], [-0.11, -0.4, -0.8, -1.2]],
|
||||
[[99, 5, 6, 7], [8, 9, 10, 11]],
|
||||
{99},
|
||||
2,
|
||||
)
|
||||
self.assertFalse(group.advance(sel))
|
||||
self.assertEqual(len(group.completed), 1)
|
||||
self.assertEqual(
|
||||
[materialize_tokens(leaf) for leaf in group.leaves], [[1, 5], [2, 8]]
|
||||
)
|
||||
|
||||
# max_new_tokens reached: host decides, final top-k all finish.
|
||||
self.assertTrue(group.next_step_is_final())
|
||||
fsel = select_final_topk(
|
||||
group.frontier_cum_logprobs,
|
||||
T([[-0.1, -0.9], [-0.05, -0.9]], torch.float32),
|
||||
T([[12, 13], [14, 15]], torch.int64),
|
||||
2,
|
||||
)
|
||||
self.assertTrue(group.advance_final(fsel))
|
||||
|
||||
results = group.finalize()
|
||||
self.assertEqual(len(results), 2)
|
||||
# EOS beam: cum -0.15 over 2 tokens -> score -0.075, the best.
|
||||
self.assertEqual(results[0].tokens, [1, 99])
|
||||
self.assertEqual(results[0].matched_token, 99)
|
||||
self.assertAlmostEqual(results[0].beam_score, -0.075, places=5)
|
||||
# Runner-up: [2, 8, 14] with cum -0.36 over 3 tokens -> -0.12.
|
||||
self.assertEqual(results[1].tokens, [2, 8, 14])
|
||||
self.assertIsNone(results[1].matched_token)
|
||||
self.assertAlmostEqual(results[1].beam_score, -0.12, places=5)
|
||||
|
||||
def test_insufficient_survivors_finishes_group(self):
|
||||
group = self._make_group(stop_token_ids=[5, 6, 7])
|
||||
sel = run_select(
|
||||
[0.0], [[-0.1, -0.2, -0.3, -0.4]], [[5, 6, 7, 8]], {5, 6, 7}, 2
|
||||
)
|
||||
self.assertTrue(group.advance(sel))
|
||||
results = group.finalize()
|
||||
# Pool: three stop-finished beams + the folded-in partial survivor.
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(results[0].tokens, [5])
|
||||
self.assertAlmostEqual(results[0].beam_score, -0.1, places=5)
|
||||
|
||||
def test_length_penalty_ordering(self):
|
||||
group = self._make_group(length_penalty=0.0)
|
||||
# penalty 0: score == cum_logprob regardless of length.
|
||||
self.assertAlmostEqual(group.beam_score(-0.3, 2), -0.3, places=6)
|
||||
group2 = self._make_group(length_penalty=1.0)
|
||||
self.assertAlmostEqual(group2.beam_score(-0.3, 2), -0.15, places=6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Unit tests for the fork primitives: prompt alias, share-on-fork reparent,
|
||||
orphan reclaim, and group-owned member-row release."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.beam_search.fork import (
|
||||
StagedOrphans,
|
||||
alias_members_prompt_kv,
|
||||
collect_orphan_slots,
|
||||
free_member_rows,
|
||||
neutral_member_sampling_params,
|
||||
remap_kv_mapping,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestRemapKvMapping(CustomTestCase):
|
||||
def setUp(self):
|
||||
# 3 rows x 10 positions; every row maps to its own distinct slots.
|
||||
self.req_to_token = torch.arange(30, dtype=torch.int64).reshape(3, 10)
|
||||
self.rows = torch.tensor([0, 1, 2], dtype=torch.int64)
|
||||
|
||||
def test_rows_adopt_parent_slots(self):
|
||||
# Survivors 0 and 1 both descend from row 2; row 2 from row 0.
|
||||
parent_idx = torch.tensor([2, 2, 0], dtype=torch.int64)
|
||||
before = self.req_to_token.clone()
|
||||
|
||||
old_map, new_map = remap_kv_mapping(
|
||||
self.req_to_token, self.rows, parent_idx, prefix_len=4, seq_len=7
|
||||
)
|
||||
|
||||
# Each row's window now names its parent's slots; nothing outside
|
||||
# [4, 7) moved, and no KV data was touched (mapping-only reparent).
|
||||
for j, p in enumerate(parent_idx.tolist()):
|
||||
self.assertTrue(torch.equal(self.req_to_token[j, 4:7], before[p, 4:7]))
|
||||
self.assertTrue(torch.equal(self.req_to_token[j, :4], before[j, :4]))
|
||||
self.assertTrue(torch.equal(self.req_to_token[j, 7:], before[j, 7:]))
|
||||
self.assertTrue(torch.equal(old_map, before[self.rows, 4:7]))
|
||||
self.assertTrue(torch.equal(new_map, before[parent_idx, 4:7]))
|
||||
|
||||
def test_identity_parents_change_nothing(self):
|
||||
parent_idx = torch.arange(3, dtype=torch.int64)
|
||||
before = self.req_to_token.clone()
|
||||
remap_kv_mapping(
|
||||
self.req_to_token, self.rows, parent_idx, prefix_len=4, seq_len=7
|
||||
)
|
||||
self.assertTrue(torch.equal(self.req_to_token, before))
|
||||
|
||||
|
||||
class TestCollectOrphanSlots(CustomTestCase):
|
||||
def test_returns_slots_nobody_inherits(self):
|
||||
req_to_token = torch.arange(30, dtype=torch.int64).reshape(3, 10)
|
||||
rows = torch.tensor([0, 1, 2], dtype=torch.int64)
|
||||
# Row 1 is nobody's parent, so its window dies.
|
||||
parent_idx = torch.tensor([0, 2, 2], dtype=torch.int64)
|
||||
before = req_to_token.clone()
|
||||
|
||||
old_map, new_map = remap_kv_mapping(
|
||||
req_to_token, rows, parent_idx, prefix_len=4, seq_len=7
|
||||
)
|
||||
orphans = collect_orphan_slots(old_map, new_map)
|
||||
|
||||
self.assertEqual(sorted(orphans.tolist()), sorted(before[1, 4:7].tolist()))
|
||||
|
||||
def test_no_orphans_when_every_row_survives(self):
|
||||
req_to_token = torch.arange(30, dtype=torch.int64).reshape(3, 10)
|
||||
rows = torch.tensor([0, 1, 2], dtype=torch.int64)
|
||||
parent_idx = torch.tensor([2, 0, 1], dtype=torch.int64) # a permutation
|
||||
|
||||
old_map, new_map = remap_kv_mapping(
|
||||
req_to_token, rows, parent_idx, prefix_len=4, seq_len=7
|
||||
)
|
||||
|
||||
self.assertEqual(collect_orphan_slots(old_map, new_map).numel(), 0)
|
||||
|
||||
|
||||
class TestAliasMembersPromptKV(CustomTestCase):
|
||||
def test_alias_mapping(self):
|
||||
req_to_token = torch.arange(36, dtype=torch.int64).reshape(3, 12)
|
||||
leader_prompt = req_to_token[0, :5].clone()
|
||||
tails_before = req_to_token[1:, 5:].clone()
|
||||
|
||||
alias_members_prompt_kv(
|
||||
req_to_token,
|
||||
dst_rows=torch.tensor([1, 2]),
|
||||
leader_row=0,
|
||||
prompt_len=5,
|
||||
)
|
||||
|
||||
# Prompt indices aliased from the leader; the tails stay member-owned.
|
||||
self.assertTrue(torch.equal(req_to_token[1, :5], leader_prompt))
|
||||
self.assertTrue(torch.equal(req_to_token[2, :5], leader_prompt))
|
||||
self.assertTrue(torch.equal(req_to_token[1:, 5:], tails_before))
|
||||
|
||||
|
||||
class _FakeReqToTokenPool:
|
||||
def __init__(self, req_to_token):
|
||||
self.req_to_token = req_to_token
|
||||
self.freed = []
|
||||
|
||||
def free_rows(self, indices):
|
||||
self.freed.extend(indices)
|
||||
|
||||
|
||||
class _FakeAllocator:
|
||||
def __init__(self):
|
||||
self.freed = []
|
||||
|
||||
def free(self, slots):
|
||||
self.freed.extend(slots.tolist())
|
||||
|
||||
|
||||
class TestFreeMemberRows(CustomTestCase):
|
||||
def _make_group(self, req_to_token, allocated_len):
|
||||
leader = SimpleNamespace(
|
||||
kv=SimpleNamespace(kv_allocated_len=allocated_len),
|
||||
kv_committed_len=allocated_len,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
leader=leader,
|
||||
prompt_len=5,
|
||||
member_rows=torch.tensor([1, 2], dtype=torch.int64),
|
||||
member_rows_cpu=torch.tensor([1, 2], dtype=torch.int64),
|
||||
all_rows=torch.tensor([0, 1, 2], dtype=torch.int64),
|
||||
)
|
||||
|
||||
def test_frees_suffix_slots_and_rows(self):
|
||||
req_to_token = torch.arange(36, dtype=torch.int64).reshape(3, 12)
|
||||
pool = _FakeReqToTokenPool(req_to_token)
|
||||
allocator = _FakeAllocator()
|
||||
group = self._make_group(req_to_token, allocated_len=8)
|
||||
|
||||
leader = group.leader
|
||||
free_member_rows(group, pool, allocator)
|
||||
|
||||
# The group owns the whole decode region [5, 8) across all its rows
|
||||
# (leader included) and frees it once.
|
||||
expected = req_to_token[0:3, 5:8].flatten().tolist()
|
||||
self.assertEqual(sorted(allocator.freed), sorted(expected))
|
||||
# Leader rewound to the prompt: its own release must not free the
|
||||
# decode region a second time.
|
||||
self.assertEqual(leader.kv.kv_allocated_len, 5)
|
||||
self.assertEqual(leader.kv_committed_len, 5)
|
||||
self.assertEqual(sorted(pool.freed), [1, 2])
|
||||
self.assertIsNone(group.member_rows)
|
||||
self.assertIsNone(group.member_rows_cpu)
|
||||
self.assertIsNone(group.all_rows)
|
||||
|
||||
# Idempotent: a second free is a no-op.
|
||||
free_member_rows(group, pool, allocator)
|
||||
self.assertEqual(sorted(pool.freed), [1, 2])
|
||||
|
||||
def test_empty_suffix_frees_rows_only(self):
|
||||
# Dead leader right after spawn: allocated == prompt, no KV to free.
|
||||
req_to_token = torch.arange(36, dtype=torch.int64).reshape(3, 12)
|
||||
pool = _FakeReqToTokenPool(req_to_token)
|
||||
allocator = _FakeAllocator()
|
||||
group = self._make_group(req_to_token, allocated_len=5)
|
||||
|
||||
free_member_rows(group, pool, allocator)
|
||||
|
||||
self.assertEqual(allocator.freed, [])
|
||||
self.assertEqual(sorted(pool.freed), [1, 2])
|
||||
|
||||
|
||||
class TestRetireReclaimsStagedOrphans(CustomTestCase):
|
||||
"""Regression: aborting a group must not leak the orphan slots staged by the
|
||||
launch half, which no surviving row names."""
|
||||
|
||||
@staticmethod
|
||||
def _make_coordinator(allocator):
|
||||
from sglang.srt.beam_search.coordinator import BeamCoordinator
|
||||
|
||||
return BeamCoordinator(
|
||||
model_config=None,
|
||||
spec_algorithm=None,
|
||||
dllm_enabled=False,
|
||||
max_req_len=0,
|
||||
req_to_token_pool=None,
|
||||
token_to_kv_pool_allocator=allocator,
|
||||
tree_cache=None,
|
||||
future_map=None,
|
||||
)
|
||||
|
||||
def test_retract_abort_does_not_leak_staged_orphans(self):
|
||||
req_to_token = torch.arange(36, dtype=torch.int64).reshape(3, 12)
|
||||
rows = torch.tensor([0, 1, 2], dtype=torch.int64)
|
||||
# Row 1 is nobody's parent, so its window [5, 8) -- slots 17, 18, 19 --
|
||||
# is orphaned by the remap the launch half already applied.
|
||||
old_map, new_map = remap_kv_mapping(
|
||||
req_to_token,
|
||||
rows,
|
||||
torch.tensor([0, 2, 2], dtype=torch.int64),
|
||||
prefix_len=5,
|
||||
seq_len=8,
|
||||
)
|
||||
orphans = sorted(collect_orphan_slots(old_map, new_map).tolist())
|
||||
self.assertEqual(orphans, [17, 18, 19])
|
||||
|
||||
pool = _FakeReqToTokenPool(req_to_token)
|
||||
allocator = _FakeAllocator()
|
||||
group = SimpleNamespace(
|
||||
leader=SimpleNamespace(
|
||||
kv=SimpleNamespace(kv_allocated_len=8), kv_committed_len=8
|
||||
),
|
||||
prompt_len=5,
|
||||
member_rows=torch.tensor([1, 2], dtype=torch.int64),
|
||||
member_rows_cpu=torch.tensor([1, 2], dtype=torch.int64),
|
||||
all_rows=rows,
|
||||
pending_orphans=[StagedOrphans(7, old_map, new_map)],
|
||||
slots_freed=0,
|
||||
retired=False,
|
||||
_pending_steps=[],
|
||||
)
|
||||
|
||||
# retract_decode's sequence: member rows released without the
|
||||
# coordinator, then the scheduler retires the group.
|
||||
free_member_rows(group, pool, allocator)
|
||||
by_rows = sorted(allocator.freed)
|
||||
self.assertEqual(by_rows, [5, 6, 7, 29, 30, 31])
|
||||
# The orphans are disjoint from what the rows still name, which is
|
||||
# exactly why free_member_rows alone leaks them.
|
||||
self.assertFalse(set(orphans) & set(by_rows))
|
||||
|
||||
coordinator = self._make_coordinator(allocator)
|
||||
coordinator._num_live_groups = 1
|
||||
coordinator._retire_group(group)
|
||||
|
||||
self.assertEqual(sorted(allocator.freed[len(by_rows) :]), orphans)
|
||||
self.assertEqual(group.slots_freed, len(orphans))
|
||||
self.assertEqual(group.pending_orphans, [])
|
||||
self.assertEqual(coordinator._num_live_groups, 0)
|
||||
|
||||
# Retiring twice must not double-free or double-decrement.
|
||||
coordinator._retire_group(group)
|
||||
self.assertEqual(len(allocator.freed), len(by_rows) + len(orphans))
|
||||
self.assertEqual(coordinator._num_live_groups, 0)
|
||||
|
||||
|
||||
class TestNeutralParams(CustomTestCase):
|
||||
def test_neutral_params(self):
|
||||
from sglang.srt.sampling.sampling_params import SamplingParams
|
||||
|
||||
leader_params = SamplingParams(
|
||||
max_new_tokens=8,
|
||||
temperature=0.0,
|
||||
frequency_penalty=0.5,
|
||||
stop_token_ids={7},
|
||||
)
|
||||
params = neutral_member_sampling_params(leader_params)
|
||||
self.assertEqual(params.temperature, 1.0)
|
||||
self.assertEqual(params.top_p, 1.0)
|
||||
self.assertEqual(params.frequency_penalty, 0.0)
|
||||
self.assertTrue(params.ignore_eos)
|
||||
self.assertIsNone(params.stop_token_ids)
|
||||
self.assertGreater(params.max_new_tokens, 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Stop trimming must use each beam's own finish reason, not the leader's.
|
||||
|
||||
A group's returned beams mix stop-finished and length-finished ones, so a shared
|
||||
reason either drops a real token from a length-finished beam or leaks a stop
|
||||
token into a matched one.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.beam_search import BeamGroup, joint_select, select_final_topk
|
||||
from sglang.srt.beam_search.output import (
|
||||
decode_beam_search_output,
|
||||
pack_beam_search_output,
|
||||
)
|
||||
from sglang.srt.managers.detokenizer_manager import DetokenizerManager
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
STOP_ID = 99
|
||||
|
||||
|
||||
def _trim(output, finished_reason, no_stop_trim):
|
||||
stub = SimpleNamespace(is_tool_call_parser_gpt_oss=False)
|
||||
return DetokenizerManager.trim_matched_stop(
|
||||
stub, output, finished_reason, no_stop_trim
|
||||
)
|
||||
|
||||
|
||||
class _IdTokenizer:
|
||||
"""Renders the token list it was handed, so assertions read the trim result."""
|
||||
|
||||
def decode(self, tokens, **kwargs):
|
||||
return ",".join(str(t) for t in tokens)
|
||||
|
||||
def batch_decode(self, token_lists, **kwargs):
|
||||
return [self.decode(t) for t in token_lists]
|
||||
|
||||
|
||||
def _select(cum, logprobs, tokens, k):
|
||||
return joint_select(
|
||||
torch.tensor(cum, dtype=torch.float32),
|
||||
torch.tensor(logprobs, dtype=torch.float32),
|
||||
torch.tensor(tokens, dtype=torch.int64),
|
||||
torch.tensor([STOP_ID], dtype=torch.int64),
|
||||
k,
|
||||
)
|
||||
|
||||
|
||||
def _mixed_group(*, stop_wins: bool) -> BeamGroup:
|
||||
"""A finished group with one stop-matched and one length-finished beam;
|
||||
stop_wins picks which of the two scores higher, i.e. the leader's reason."""
|
||||
group = BeamGroup(beam_width=2, stop_token_ids=[STOP_ID], max_new_tokens=3)
|
||||
group.advance(_select([0.0], [[-0.1, -0.2, -0.3, -0.4]], [[1, 2, 3, 4]], 2))
|
||||
|
||||
# Bounded on both sides: low enough that the 3-token length beam outscores it
|
||||
# once normalized (-0.12), high enough to stay inside the examined window --
|
||||
# a stop candidate ranked past k survivors never finishes at all.
|
||||
stop_logprob = -0.05 if stop_wins else -0.15
|
||||
group.advance(
|
||||
_select(
|
||||
[-0.1, -0.2],
|
||||
[[stop_logprob, -0.2, -0.5, -0.9], [-0.11, -0.4, -0.8, -1.2]],
|
||||
[[STOP_ID, 5, 6, 7], [8, 9, 10, 11]],
|
||||
2,
|
||||
)
|
||||
)
|
||||
assert len(group.completed) == 1, "the stop candidate should have finished"
|
||||
|
||||
# max_new_tokens: the surviving frontier finishes by length.
|
||||
group.advance_final(
|
||||
select_final_topk(
|
||||
group.frontier_cum_logprobs,
|
||||
torch.tensor([[-0.1, -0.9], [-0.05, -0.9]], dtype=torch.float32),
|
||||
torch.tensor([[12, 13], [14, 15]], dtype=torch.int64),
|
||||
2,
|
||||
)
|
||||
)
|
||||
group.final_results = group.finalize()
|
||||
return group
|
||||
|
||||
|
||||
def _decode(group, *, disable_batch_decode):
|
||||
packed = pack_beam_search_output(SimpleNamespace(beam_group=group))
|
||||
recv_obj = SimpleNamespace(
|
||||
beam_search_output=[packed],
|
||||
# The leader's reason, which the trim must not consult.
|
||||
finished_reasons=[{"type": "stop", "matched": STOP_ID}],
|
||||
no_stop_trim=[False],
|
||||
skip_special_tokens=[True],
|
||||
spaces_between_special_tokens=[True],
|
||||
)
|
||||
decode_beam_search_output(
|
||||
recv_obj,
|
||||
tokenizer=_IdTokenizer(),
|
||||
disable_batch_decode=disable_batch_decode,
|
||||
trim_matched_stop=_trim,
|
||||
)
|
||||
return {tuple(s.tokens): s.text for s in packed.sequences}
|
||||
|
||||
|
||||
class TestDecodeBeamSearchOutput(CustomTestCase):
|
||||
def _assert_mixed_group_trims(self, *, stop_wins, disable_batch_decode):
|
||||
group = _mixed_group(stop_wins=stop_wins)
|
||||
texts = _decode(group, disable_batch_decode=disable_batch_decode)
|
||||
matched = [r for r in group.final_results if r.matched_token is not None]
|
||||
length = [r for r in group.final_results if r.matched_token is None]
|
||||
self.assertEqual(len(matched), 1, group.final_results)
|
||||
self.assertEqual(len(length), 1, group.final_results)
|
||||
|
||||
# The stop token is trimmed off the matched beam...
|
||||
stop_tokens = tuple(matched[0].tokens)
|
||||
self.assertEqual(stop_tokens[-1], STOP_ID)
|
||||
self.assertEqual(texts[stop_tokens], ",".join(map(str, stop_tokens[:-1])))
|
||||
# ...and the length-finished beam keeps every token.
|
||||
len_tokens = tuple(length[0].tokens)
|
||||
self.assertEqual(texts[len_tokens], ",".join(map(str, len_tokens)))
|
||||
|
||||
def test_leader_matched_does_not_trim_the_length_beam(self):
|
||||
for disable_batch_decode in (True, False):
|
||||
with self.subTest(disable_batch_decode=disable_batch_decode):
|
||||
self._assert_mixed_group_trims(
|
||||
stop_wins=True, disable_batch_decode=disable_batch_decode
|
||||
)
|
||||
|
||||
def test_leader_length_still_trims_the_matched_beam(self):
|
||||
for disable_batch_decode in (True, False):
|
||||
with self.subTest(disable_batch_decode=disable_batch_decode):
|
||||
self._assert_mixed_group_trims(
|
||||
stop_wins=False, disable_batch_decode=disable_batch_decode
|
||||
)
|
||||
|
||||
def test_no_stop_trim_keeps_the_stop_token(self):
|
||||
group = _mixed_group(stop_wins=True)
|
||||
packed = pack_beam_search_output(SimpleNamespace(beam_group=group))
|
||||
recv_obj = SimpleNamespace(
|
||||
beam_search_output=[packed],
|
||||
finished_reasons=[{"type": "stop", "matched": STOP_ID}],
|
||||
no_stop_trim=[True],
|
||||
skip_special_tokens=[True],
|
||||
spaces_between_special_tokens=[True],
|
||||
)
|
||||
decode_beam_search_output(
|
||||
recv_obj,
|
||||
tokenizer=_IdTokenizer(),
|
||||
disable_batch_decode=True,
|
||||
trim_matched_stop=_trim,
|
||||
)
|
||||
for seq in packed.sequences:
|
||||
self.assertEqual(seq.text, ",".join(map(str, seq.tokens)))
|
||||
|
||||
def test_non_beam_item_in_a_mixed_batch_is_skipped(self):
|
||||
group = _mixed_group(stop_wins=True)
|
||||
packed = pack_beam_search_output(SimpleNamespace(beam_group=group))
|
||||
recv_obj = SimpleNamespace(
|
||||
beam_search_output=[None, packed],
|
||||
finished_reasons=[None, {"type": "stop", "matched": STOP_ID}],
|
||||
no_stop_trim=[False, False],
|
||||
skip_special_tokens=[True, True],
|
||||
spaces_between_special_tokens=[True, True],
|
||||
)
|
||||
decode_beam_search_output(
|
||||
recv_obj,
|
||||
tokenizer=_IdTokenizer(),
|
||||
disable_batch_decode=False,
|
||||
trim_matched_stop=_trim,
|
||||
)
|
||||
self.assertTrue(all(s.text is not None for s in packed.sequences))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1216,6 +1216,7 @@ class TestMlxOverlapScheduler(unittest.TestCase):
|
||||
),
|
||||
logprob_result_processor=None,
|
||||
output_streamer=None,
|
||||
beam_coordinator=None,
|
||||
abort_request=lambda req: None,
|
||||
)
|
||||
# Stub out the methods _handle_finish_state_updated_req calls that
|
||||
|
||||
@@ -43,6 +43,7 @@ def _make_processor(case, server_mode: str = "full") -> SchedulerBatchResultProc
|
||||
model_worker=Mock(),
|
||||
logprob_result_processor=None,
|
||||
output_streamer=Mock(),
|
||||
beam_coordinator=Mock(),
|
||||
abort_request=lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
@@ -61,6 +62,7 @@ class _PrefillReq:
|
||||
self.grammar = None
|
||||
self.require_reasoning = False
|
||||
self.customized_info = None
|
||||
self.beam_group = None
|
||||
|
||||
def finished(self):
|
||||
return False
|
||||
@@ -79,6 +81,7 @@ class _DecodeReq:
|
||||
self.return_logprob = False
|
||||
self.return_sampling_mask = False
|
||||
self.grammar = None
|
||||
self.beam_group = None
|
||||
self.time_stats = Mock()
|
||||
|
||||
def finished(self):
|
||||
|
||||
@@ -64,6 +64,7 @@ def _make_processor() -> SchedulerBatchResultProcessor:
|
||||
token_to_kv_pool_allocator=MagicMock(),
|
||||
tree_cache=SimpleNamespace(page_size=TRACK_INTERVAL),
|
||||
hisparse_coordinator=None,
|
||||
beam_coordinator=MagicMock(),
|
||||
req_to_token_pool=None,
|
||||
decode_offload_manager=None,
|
||||
metrics_collector=None,
|
||||
|
||||
@@ -77,6 +77,7 @@ def _make_processor() -> SchedulerBatchResultProcessor:
|
||||
model_worker=SimpleNamespace(on_verify_complete_cpu=lambda *a, **k: None),
|
||||
logprob_result_processor=None,
|
||||
output_streamer=SimpleNamespace(),
|
||||
beam_coordinator=SimpleNamespace(),
|
||||
abort_request=lambda *a, **k: None,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class _FakeReq:
|
||||
)
|
||||
self.finished_output = False
|
||||
self.finished_len = None
|
||||
self.beam_group = None
|
||||
self.stream = False
|
||||
self.sampling_params = SimpleNamespace(
|
||||
stream_interval=None,
|
||||
|
||||
@@ -43,6 +43,7 @@ class _FakeReq:
|
||||
self.multimodal_inputs = None
|
||||
self.customized_info = None
|
||||
self.is_retracted = is_retracted
|
||||
self.beam_group = None
|
||||
|
||||
self.return_logprob = True
|
||||
self.input_logprob_sent = True
|
||||
|
||||
@@ -20,6 +20,7 @@ def _make_req():
|
||||
decode_batch_idx=0,
|
||||
kv_committed_len=3,
|
||||
kv_allocated_len=3,
|
||||
beam_group=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class _FakeReq:
|
||||
def __init__(self, rid, wait_entry=0.0, forward_entry=0.0, is_finished=False):
|
||||
self.rid = rid
|
||||
self.to_finish = None
|
||||
self.beam_group = None
|
||||
self._finished = is_finished
|
||||
self.output_ids = []
|
||||
self.weight_version_events = []
|
||||
@@ -52,6 +53,7 @@ def _scheduler(waiting_queue):
|
||||
s.waiting_queue = waiting_queue
|
||||
s.enable_hicache_storage = False
|
||||
s.ipc_channels = SimpleNamespace(send_to_tokenizer=MagicMock())
|
||||
s.beam_coordinator = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,19 @@ _OWNER_SITES = {
|
||||
"alloc_for_decode_prealloc_hisparse",
|
||||
"kv_allocated_len",
|
||||
): 1,
|
||||
# Beam member rows alias the leader's decode region, so releasing them
|
||||
# rewinds the leader's watermarks to keep its own per-Req release from
|
||||
# freeing that region twice.
|
||||
(
|
||||
"beam_search/fork.py",
|
||||
"free_member_rows",
|
||||
"kv_committed_len",
|
||||
): 1,
|
||||
(
|
||||
"beam_search/fork.py",
|
||||
"free_member_rows",
|
||||
"kv_allocated_len",
|
||||
): 1,
|
||||
# streaming session slot save/restore and tail trimming
|
||||
(_SS, "SessionSlot.save_from_req", "kv_committed_len"): 1,
|
||||
(_SS, "SessionSlot.restore_to_req", "kv_committed_len"): 1,
|
||||
|
||||
Reference in New Issue
Block a user