Files
Karti Tripathi 006feee0f7
CI / verify (push) Successful in 24s
CI / deploy (push) Failing after 1m14s
Lumbridge Bench
2026-08-04 00:44:07 -07:00

320 lines
12 KiB
Python

"""Serving performance measurement.
A self-contained async load generator rather than a wrapper around
`vllm bench serve`. Reasons:
* It works against ANY OpenAI-compatible endpoint -- vLLM, SGLang,
llama.cpp, ollama, or a submitted model served however we choose to serve
it. Submissions are the point of the site; we cannot assume vLLM.
* It needs nothing installed on the target box. The runner talks HTTP.
* We control the metric definitions, so numbers stay comparable across
engines rather than inheriting each engine's benchmarking conventions.
TWO MEASUREMENT TRAPS THIS CODE AVOIDS -- both silently produce numbers that
look great and mean nothing:
1. PREFIX CACHING. vLLM caches shared prompt prefixes. If every concurrent
request sends the same prompt, prefill is nearly free after the first and
throughput is wildly overstated. Every request here gets a unique nonce
prefix so each one actually does its own prefill.
2. VARIABLE OUTPUT LENGTH. If the model decides when to stop, concurrency
levels finish at different token counts and tok/s is not comparable
between runs. We send `ignore_eos` so every request emits exactly
`output_tokens` tokens.
"""
from __future__ import annotations
import asyncio
import json
import statistics
import time
import uuid
from dataclasses import dataclass
import httpx
from .results import PerfPoint, PerfResult
from .specdec import read_counters
# Filler vocabulary for synthetic prompts. Ordinary words rather than repeated
# junk so tokenization stays close to what real traffic looks like.
_FILLER = (
"the quick brown fox jumps over a lazy dog while distant thunder rolls "
"across open water and the harbour lights flicker against low cloud cover "
"as fishing boats return with the morning tide carrying nets and crates "
).split()
def _synthetic_prompt(approx_tokens: int, nonce: str) -> str:
"""Build a prompt of roughly `approx_tokens` tokens, unique per request.
The nonce leads so it lands in the first block and defeats prefix-cache
reuse across concurrent requests.
"""
# ~0.75 tokens per word for ordinary English text.
n_words = max(1, int(approx_tokens / 0.75))
body = " ".join(_FILLER[i % len(_FILLER)] for i in range(n_words))
return f"[{nonce}] {body}"
@dataclass
class RequestResult:
ok: bool
ttft_ms: float | None = None
total_s: float | None = None
output_tokens: int = 0
input_tokens: int = 0
error: str | None = None
@property
def tpot_ms(self) -> float | None:
"""Inter-token latency: decode time divided by tokens after the first."""
if self.ttft_ms is None or self.total_s is None or self.output_tokens < 2:
return None
decode_ms = self.total_s * 1000 - self.ttft_ms
return decode_ms / (self.output_tokens - 1)
async def _one_request(
client: httpx.AsyncClient,
base_url: str,
model: str,
input_tokens: int,
output_tokens: int,
prompt: str | None = None,
pin_output: bool = True,
) -> RequestResult:
nonce = uuid.uuid4().hex[:12]
# A supplied prompt still gets the nonce, because prefix-cache defeat is orthogonal to
# whether the text is representative — reusing one real prompt across a concurrency level
# would let the engine serve most of it from cache and report an inflated number.
content = f"[{nonce}] {prompt}" if prompt else _synthetic_prompt(input_tokens, nonce)
payload = {
"model": model,
"messages": [{"role": "user", "content": content}],
"max_tokens": output_tokens,
"temperature": 0.0,
"stream": True,
"stream_options": {"include_usage": True},
}
if pin_output:
# vLLM/SGLang extension: force exactly max_tokens of output so runs are
# comparable. Harmless (ignored) on engines that do not support it.
#
# Turned OFF for speculative-decoding measurement. Forcing generation past a natural
# stop produces degenerate continuation, and a draft model's acceptance rate on
# degenerate text is not its acceptance rate on real work — so pinning the length here
# buys comparability at the cost of measuring the wrong thing.
payload["ignore_eos"] = True
start = time.perf_counter()
ttft: float | None = None
counted = 0
usage_in = 0
usage_out = 0
try:
async with client.stream(
"POST", f"{base_url}/chat/completions", json=payload
) as resp:
if resp.status_code != 200:
body = (await resp.aread()).decode()[:200]
return RequestResult(ok=False, error=f"HTTP {resp.status_code}: {body}")
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if usage := chunk.get("usage"):
usage_in = usage.get("prompt_tokens", 0)
usage_out = usage.get("completion_tokens", 0)
for choice in chunk.get("choices") or []:
delta = choice.get("delta") or {}
# Engines disagree on where reasoning text lands: vLLM with
# --reasoning-parser emits `reasoning`, others use
# `reasoning_content`. Count all of them -- reasoning tokens
# cost the same decode time and the user waits for them
# either way. Checking only `content` would report TTFT as
# the time to the *answer*, which on a reasoning model is
# thousands of tokens late.
if any(
isinstance(delta.get(k), str) and delta[k]
for k in ("content", "reasoning", "reasoning_content")
):
# The opening chunk carries role + empty content; it is
# the stream opening, not a token, and is excluded by
# the emptiness check above.
if ttft is None:
ttft = (time.perf_counter() - start) * 1000
counted += 1
except Exception as exc: # noqa: BLE001 - report, do not abort the sweep
return RequestResult(ok=False, error=f"{type(exc).__name__}: {exc}")
total = time.perf_counter() - start
return RequestResult(
ok=True,
ttft_ms=ttft,
total_s=total,
# Prefer server-reported usage; fall back to counted chunks.
output_tokens=usage_out or counted,
input_tokens=usage_in,
)
async def _sweep_point(
base_url: str,
model: str,
concurrency: int,
input_tokens: int,
output_tokens: int,
timeout_s: float,
prompts: list[str] | None = None,
pin_output: bool = True,
) -> PerfPoint:
limits = httpx.Limits(max_connections=concurrency + 8)
async with httpx.AsyncClient(timeout=timeout_s, limits=limits) as client:
# Warm up so the first measured request does not absorb graph capture,
# weight paging, or connection setup.
await _one_request(client, base_url, model, 32, 8)
# Bracket the measured window. These are lifetime counters, so the rate for THIS point
# is the delta — reading the total would fold in warm-up and every earlier point.
before = await read_counters(client, base_url)
start = time.perf_counter()
results = await asyncio.gather(
*(
_one_request(
client,
base_url,
model,
input_tokens,
output_tokens,
# Cycle rather than repeat: every concurrent request in a point gets a
# different prompt, so one unusually easy or hard example cannot set the
# whole level's number.
prompt=prompts[i % len(prompts)] if prompts else None,
pin_output=pin_output,
)
for i in range(concurrency)
)
)
wall = time.perf_counter() - start
after = await read_counters(client, base_url)
ok = [r for r in results if r.ok]
failed = [r for r in results if not r.ok]
total_out = sum(r.output_tokens for r in ok)
ttfts = [r.ttft_ms for r in ok if r.ttft_ms is not None]
tpots = [r.tpot_ms for r in ok if r.tpot_ms is not None]
per_stream = [
r.output_tokens / r.total_s for r in ok if r.total_s and r.output_tokens
]
def pct(values: list[float], p: float) -> float | None:
if not values:
return None
s = sorted(values)
idx = min(len(s) - 1, round(p * (len(s) - 1)))
return round(s[idx], 2)
# Prefill rate: input tokens processed per second, inferred from TTFT.
# At concurrency > 1 this is per-stream and includes queueing, so it reads
# low; treat the concurrency-1 value as the true prefill capability.
prefill = None
if ttfts and ok:
reported = [r.input_tokens for r in ok if r.input_tokens]
mean_in = statistics.mean(reported) if reported else input_tokens
mean_ttft_s = statistics.mean(ttfts) / 1000
if mean_ttft_s > 0:
prefill = round(mean_in / mean_ttft_s, 1)
# Delta across the measured window only. `None` when the engine exposes no counters, which
# is an ordinary outcome (no speculative decoding, or metrics disabled) and not an error.
spec = (after - before) if (before and after) else None
acceptance = spec.acceptance_rate if spec else None
drafted = int(spec.draft) if spec and spec.draft > 0 else None
# With a natural stop, requests no longer emit identical token counts, so the spread is
# itself a caveat on comparability and has to travel with the number.
out_counts = [r.output_tokens for r in ok if r.output_tokens]
stdev = (
round(statistics.stdev(out_counts), 2)
if not pin_output and len(out_counts) > 1
else None
)
return PerfPoint(
concurrency=concurrency,
input_tokens=input_tokens,
output_tokens=output_tokens,
n_requests=concurrency,
completed=len(ok),
failed=len(failed),
duration_s=round(wall, 3),
output_tps_total=round(total_out / wall, 2) if wall else 0.0,
output_tps_per_stream=round(statistics.mean(per_stream), 2) if per_stream else 0.0,
ttft_p50_ms=pct(ttfts, 0.50),
ttft_p95_ms=pct(ttfts, 0.95),
tpot_p50_ms=pct(tpots, 0.50),
prefill_tps=prefill,
spec_acceptance_rate=acceptance,
spec_draft_tokens=drafted,
natural_stop=not pin_output,
output_token_stdev=stdev,
error=failed[0].error if failed else None,
)
async def run_sweep(
base_url: str,
model: str,
concurrencies: tuple[int, ...] = (1, 8, 32),
input_tokens: int = 1024,
output_tokens: int = 256,
timeout_s: float = 600.0,
engine: str = "unknown",
progress: bool = True,
prompts: list[str] | None = None,
pin_output: bool = True,
) -> PerfResult:
"""Run a concurrency sweep and return a PerfResult.
`prompts` and `pin_output=False` together give the mode a speculative-decoding target needs:
representative text, stopping naturally. Both defaults stay as they were, so an ordinary
throughput sweep is unchanged and remains directly comparable to every result already
committed.
"""
points: list[PerfPoint] = []
for c in concurrencies:
if progress:
print(f" concurrency {c:>3} ...", end="", flush=True)
point = await _sweep_point(
base_url, model, c, input_tokens, output_tokens, timeout_s,
prompts=prompts, pin_output=pin_output,
)
points.append(point)
if progress:
status = f"{point.output_tps_total:>8.1f} tok/s total"
if point.spec_acceptance_rate is not None:
status += f" accept {point.spec_acceptance_rate:.0%}"
status += f" | {point.output_tps_per_stream:>6.1f} /stream"
if point.ttft_p50_ms is not None:
status += f" | TTFT p50 {point.ttft_p50_ms:>7.0f}ms"
if point.failed:
status += f" | {point.failed} FAILED"
print(status, flush=True)
return PerfResult(engine=engine, points=points)