servebench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
servebench/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """servebench: load-test any OpenAI-compatible LLM endpoint."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .api import benchmark, check_regression, compare
6
+ from .check import CheckReport, CheckThresholds
7
+ from .results import LevelResult, RequestResult, RunResult, Stats, Summary
8
+
9
+ __all__ = [
10
+ "benchmark",
11
+ "check_regression",
12
+ "compare",
13
+ "CheckThresholds",
14
+ "CheckReport",
15
+ "RunResult",
16
+ "LevelResult",
17
+ "RequestResult",
18
+ "Summary",
19
+ "Stats",
20
+ "__version__",
21
+ ]
servebench/api.py ADDED
@@ -0,0 +1,189 @@
1
+ """Stable Python API for programmatic LLM serving benchmarks.
2
+
3
+ Allows executing benchmarks, running quick capacity discovery, evaluating regressions,
4
+ and comparing runs directly in Python scripts, test harnesses, or Jupyter notebooks.
5
+
6
+ Example:
7
+ >>> import servebench
8
+ >>> res = servebench.benchmark(
9
+ ... url="http://127.0.0.1:8000/v1",
10
+ ... model="llama-3-8b",
11
+ ... mode="concurrency",
12
+ ... levels=[1, 4, 8],
13
+ ... duration=10.0,
14
+ ... )
15
+ >>> peak = max(lv.summary.output_throughput for lv in res.levels)
16
+ >>> print(f"Peak throughput: {peak:.1f} tok/s")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import platform
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Literal
26
+ from urllib.parse import urlparse
27
+
28
+ from . import __version__, detect, system
29
+ from .check import CheckReport, CheckThresholds, evaluate_regression
30
+ from .client import RequestSpec
31
+ from .load import run_sweep
32
+ from .prompts import Prompt, load_prompts
33
+ from .report import load_run, save_run
34
+ from .results import Environment, RunConfig, RunResult
35
+ from .tokens import make_counter
36
+ from .workloads import PRESETS, generate_workload_requests, load_trace_file
37
+
38
+
39
+ def _build_env(url: str, notes: str | None = None) -> Environment:
40
+ server = detect.identify(url)
41
+ host = urlparse(url).hostname or ""
42
+ return Environment(
43
+ servebench_version=__version__,
44
+ python=platform.python_version(),
45
+ platform=f"{platform.system()} {platform.release()}",
46
+ timestamp=datetime.now(UTC).isoformat(timespec="seconds"),
47
+ local_server=host in ("127.0.0.1", "localhost", "::1", "0.0.0.0"),
48
+ engine=server.engine if server else None,
49
+ engine_version=server.version if server else None,
50
+ notes=notes,
51
+ **system.describe_for_env(),
52
+ )
53
+
54
+
55
+ def benchmark(
56
+ url: str,
57
+ model: str,
58
+ *,
59
+ mode: Literal["concurrency", "rate"] = "concurrency",
60
+ levels: list[float] | None = None,
61
+ num_requests: int | None = 20,
62
+ duration: float | None = None,
63
+ input_len: int = 256,
64
+ output_len: int = 128,
65
+ preset: str | None = None,
66
+ burstiness: float = 1.0,
67
+ trace: str | Path | None = None,
68
+ shared_prefix_tokens: int | None = None,
69
+ gpu_telemetry: bool = True,
70
+ scrape_metrics: str | None = None,
71
+ endpoint: Literal["chat", "completions"] = "chat",
72
+ slo_ttft_ms: float | None = 500.0,
73
+ slo_tpot_ms: float | None = 50.0,
74
+ warmup: int = 2,
75
+ seed: int = 0,
76
+ timeout: float = 300.0,
77
+ api_key: str | None = None,
78
+ label: str | None = None,
79
+ save_path: str | Path | None = None,
80
+ ) -> RunResult:
81
+ """Run a serving benchmark synchronously and return a RunResult."""
82
+ if preset and preset in PRESETS:
83
+ input_len = PRESETS[preset]["input_len"]
84
+ output_len = PRESETS[preset]["output_len"]
85
+
86
+ if levels is None:
87
+ levels = [1.0, 2.0, 4.0] if mode == "concurrency" else [5.0, 10.0]
88
+
89
+ env = _build_env(url)
90
+ label_val = label or f"{model} ({mode})"
91
+
92
+ spec = RequestSpec(
93
+ url=url,
94
+ model=model,
95
+ endpoint=endpoint,
96
+ output_len=output_len,
97
+ ignore_eos=True,
98
+ temperature=0.0,
99
+ api_key=api_key,
100
+ )
101
+
102
+ if trace:
103
+ def prompts_for(n: int, _s: int) -> list[Prompt]:
104
+ return load_trace_file(trace, n)
105
+ elif shared_prefix_tokens:
106
+ def prompts_for(n: int, s: int) -> list[Prompt]:
107
+ return generate_workload_requests(
108
+ n,
109
+ input_len,
110
+ output_len,
111
+ shared_prefix_tokens=shared_prefix_tokens,
112
+ seed=s,
113
+ )
114
+ else:
115
+ def prompts_for(n: int, s: int) -> list[Prompt]:
116
+ return load_prompts("synthetic", n, input_len, s)
117
+
118
+ counter = make_counter(None)
119
+ config = RunConfig(
120
+ url=url,
121
+ model=model,
122
+ endpoint=endpoint,
123
+ input_len=input_len,
124
+ output_len=output_len,
125
+ mode=mode,
126
+ levels=levels,
127
+ num_requests=num_requests,
128
+ duration=duration,
129
+ warmup=warmup,
130
+ burstiness=burstiness,
131
+ gpu_telemetry=gpu_telemetry,
132
+ scrape_metrics=scrape_metrics,
133
+ )
134
+
135
+ levels_out = asyncio.run(
136
+ run_sweep(
137
+ spec,
138
+ prompts_for,
139
+ mode,
140
+ levels,
141
+ num_requests,
142
+ duration=duration,
143
+ warmup_requests=warmup,
144
+ timeout=timeout,
145
+ seed=seed,
146
+ slo_ttft_ms=slo_ttft_ms,
147
+ slo_tpot_ms=slo_tpot_ms,
148
+ counter=counter,
149
+ burstiness=burstiness,
150
+ gpu_telemetry=gpu_telemetry,
151
+ metrics_url=scrape_metrics,
152
+ )
153
+ )
154
+
155
+ run_res = RunResult(
156
+ label=label_val,
157
+ config=config,
158
+ environment=env,
159
+ levels=levels_out,
160
+ )
161
+
162
+ if save_path:
163
+ save_run(run_res, Path(save_path))
164
+
165
+ return run_res
166
+
167
+
168
+ def check_regression(
169
+ candidate: RunResult | Path | str,
170
+ baseline: RunResult | Path | str,
171
+ thresholds: CheckThresholds | None = None,
172
+ ) -> CheckReport:
173
+ """Evaluate performance regressions between candidate and baseline runs."""
174
+ cand = load_run(Path(candidate)) if isinstance(candidate, (str, Path)) else candidate
175
+ base = load_run(Path(baseline)) if isinstance(baseline, (str, Path)) else baseline
176
+ return evaluate_regression(cand, base, thresholds)
177
+
178
+
179
+ def compare(runs: list[RunResult | Path | str]) -> list[RunResult]:
180
+ """Load and normalize multiple benchmark runs for comparison."""
181
+ loaded: list[RunResult] = []
182
+ for r in runs:
183
+ if isinstance(r, (str, Path)):
184
+ loaded.append(load_run(Path(r)))
185
+ elif isinstance(r, RunResult):
186
+ loaded.append(r)
187
+ else:
188
+ raise TypeError(f"Expected RunResult, Path, or str, got {type(r)}")
189
+ return loaded
servebench/capacity.py ADDED
@@ -0,0 +1,104 @@
1
+ """Find the highest load that still meets the SLOs.
2
+
3
+ Search: double the load from `start` until a level fails (or `max_value` passes), then
4
+ binary-search between the last pass and the first fail until the gap is within `tolerance`
5
+ of the passing value (and at least 1 for whole-number concurrency).
6
+
7
+ A level passes when no request failed and at least `target` of the requests met the SLOs.
8
+ Each level is measured once, so a noisy server near its limit can pass a level above one
9
+ that failed; the search assumes latency grows with load.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass, field
16
+
17
+ from .results import CapacityResult, LevelResult
18
+
19
+
20
+ @dataclass
21
+ class SearchResult:
22
+ capacity: float | None
23
+ first_fail: float | None
24
+ reason: str # "converged", "max_reached" or "start_failed"
25
+ tried: list[tuple[float, bool]] = field(default_factory=list)
26
+
27
+
28
+ async def search_capacity(
29
+ probe: Callable[[float], Awaitable[bool]],
30
+ *,
31
+ start: float,
32
+ max_value: float,
33
+ integer: bool,
34
+ tolerance: float = 0.1,
35
+ ) -> SearchResult:
36
+ """Generic search over `probe(value) -> passed`; knows nothing about HTTP."""
37
+ tried: list[tuple[float, bool]] = []
38
+
39
+ async def check(v: float) -> bool:
40
+ ok = await probe(v)
41
+ tried.append((v, ok))
42
+ return ok
43
+
44
+ lo: float | None = None
45
+ v = min(start, max_value)
46
+ while True:
47
+ if not await check(v):
48
+ hi = v
49
+ break
50
+ lo = v
51
+ if v >= max_value:
52
+ return SearchResult(lo, None, "max_reached", tried)
53
+ v = min(v * 2, max_value)
54
+ if lo is None:
55
+ return SearchResult(None, hi, "start_failed", tried)
56
+
57
+ min_gap = 1 if integer else 0
58
+ while hi - lo > max(min_gap, tolerance * lo):
59
+ mid = (lo + hi) // 2 if integer else (lo + hi) / 2
60
+ if await check(mid):
61
+ lo = mid
62
+ else:
63
+ hi = mid
64
+ return SearchResult(lo, hi, "converged", tried)
65
+
66
+
67
+ def passes(level: LevelResult, target: float) -> bool:
68
+ s = level.summary
69
+ return s.failed == 0 and s.slo_attainment is not None and s.slo_attainment >= target
70
+
71
+
72
+ async def find_capacity(
73
+ measure: Callable[[float], Awaitable[LevelResult]],
74
+ mode: str,
75
+ *,
76
+ start: float = 1,
77
+ max_value: float = 256,
78
+ target: float = 0.9,
79
+ tolerance: float = 0.1,
80
+ slo_ttft_ms: float | None = None,
81
+ slo_tpot_ms: float | None = None,
82
+ ) -> tuple[list[LevelResult], CapacityResult]:
83
+ """Run the search with real levels. Returns every measured level, sorted by load."""
84
+ if slo_ttft_ms is None and slo_tpot_ms is None:
85
+ raise ValueError("find-capacity needs at least one SLO (TTFT or TPOT)")
86
+ if not 0 < target <= 1:
87
+ raise ValueError("target must be in (0, 1]")
88
+ integer = mode == "concurrency"
89
+ if integer:
90
+ start, max_value = max(1, int(start)), max(1, int(max_value))
91
+ levels: list[LevelResult] = []
92
+
93
+ async def probe(v: float) -> bool:
94
+ level = await measure(v)
95
+ levels.append(level)
96
+ return passes(level, target)
97
+
98
+ res = await search_capacity(probe, start=start, max_value=max_value, integer=integer,
99
+ tolerance=tolerance)
100
+ result = CapacityResult(
101
+ mode=mode, target=target, slo_ttft_ms=slo_ttft_ms, slo_tpot_ms=slo_tpot_ms,
102
+ capacity=res.capacity, first_fail=res.first_fail, reason=res.reason,
103
+ )
104
+ return sorted(levels, key=lambda lv: lv.value), result
servebench/check.py ADDED
@@ -0,0 +1,246 @@
1
+ """CI Performance Regression Gate (`servebench check`).
2
+
3
+ Compares candidate benchmark run against a baseline run. Computes relative degradation
4
+ percentages for TTFT p50, TPOT p50, output throughput, goodput, and error rates across
5
+ matched load levels. Exits with code 1 if regressions exceed configured tolerance thresholds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, Field
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from .results import RunResult
15
+
16
+
17
+ class CheckThresholds(BaseModel):
18
+ max_ttft_pct: float = Field(
19
+ 15.0, description="Max allowed TTFT p50 latency increase percentage"
20
+ )
21
+ max_tpot_pct: float = Field(
22
+ 10.0, description="Max allowed TPOT p50 latency increase percentage"
23
+ )
24
+ max_throughput_pct: float = Field(
25
+ 10.0, description="Max allowed output throughput (tok/s) drop percentage"
26
+ )
27
+ max_goodput_pct: float = Field(10.0, description="Max allowed goodput drop percentage")
28
+ max_error_rate_pct: float = Field(1.0, description="Max allowed request error rate percentage")
29
+
30
+
31
+ class MetricComparison(BaseModel):
32
+ metric: str
33
+ baseline: float
34
+ candidate: float
35
+ unit: str
36
+ change_pct: float
37
+ passed: bool
38
+ threshold_pct: float
39
+
40
+
41
+ class LevelComparison(BaseModel):
42
+ label: str
43
+ passed: bool
44
+ metrics: list[MetricComparison] = Field(default_factory=list)
45
+ failures: list[str] = Field(default_factory=list)
46
+
47
+
48
+ class CheckReport(BaseModel):
49
+ passed: bool
50
+ candidate_label: str
51
+ baseline_label: str
52
+ levels: list[LevelComparison] = Field(default_factory=list)
53
+ failures: list[str] = Field(default_factory=list)
54
+
55
+
56
+ def evaluate_regression(
57
+ candidate: RunResult,
58
+ baseline: RunResult,
59
+ thresholds: CheckThresholds | None = None,
60
+ ) -> CheckReport:
61
+ """Evaluate performance regressions of candidate run against baseline run."""
62
+ thresh = thresholds or CheckThresholds()
63
+
64
+ # Index baseline levels by label (e.g. c=1, c=4, rate=10)
65
+ baseline_by_label = {lv.label: lv for lv in baseline.levels}
66
+ level_comparisons: list[LevelComparison] = []
67
+ overall_failures: list[str] = []
68
+
69
+ for c_lv in candidate.levels:
70
+ b_lv = baseline_by_label.get(c_lv.label)
71
+ if not b_lv:
72
+ # If no direct matching level, skip
73
+ continue
74
+
75
+ c_sum = c_lv.summary
76
+ b_sum = b_lv.summary
77
+
78
+ metrics: list[MetricComparison] = []
79
+ level_failures: list[str] = []
80
+
81
+ # 1. TTFT p50 (lower is better; positive diff = latency degradation)
82
+ b_ttft = (b_sum.ttft.p50 or 0.0) * 1000.0
83
+ c_ttft = (c_sum.ttft.p50 or 0.0) * 1000.0
84
+ if b_ttft > 0:
85
+ ttft_diff_pct = ((c_ttft - b_ttft) / b_ttft) * 100.0
86
+ ttft_pass = ttft_diff_pct <= thresh.max_ttft_pct
87
+ metrics.append(
88
+ MetricComparison(
89
+ metric="TTFT p50",
90
+ baseline=round(b_ttft, 1),
91
+ candidate=round(c_ttft, 1),
92
+ unit="ms",
93
+ change_pct=round(ttft_diff_pct, 1),
94
+ passed=ttft_pass,
95
+ threshold_pct=thresh.max_ttft_pct,
96
+ )
97
+ )
98
+ if not ttft_pass:
99
+ fail_msg = (
100
+ f"[{c_lv.label}] TTFT p50 degraded by +{ttft_diff_pct:.1f}% "
101
+ f"({b_ttft:.1f}ms -> {c_ttft:.1f}ms, limit +{thresh.max_ttft_pct:.1f}%)"
102
+ )
103
+ level_failures.append(fail_msg)
104
+ overall_failures.append(fail_msg)
105
+
106
+ # 2. TPOT p50 (lower is better; positive diff = latency degradation)
107
+ b_tpot = (b_sum.tpot.p50 or 0.0) * 1000.0
108
+ c_tpot = (c_sum.tpot.p50 or 0.0) * 1000.0
109
+ if b_tpot > 0:
110
+ tpot_diff_pct = ((c_tpot - b_tpot) / b_tpot) * 100.0
111
+ tpot_pass = tpot_diff_pct <= thresh.max_tpot_pct
112
+ metrics.append(
113
+ MetricComparison(
114
+ metric="TPOT p50",
115
+ baseline=round(b_tpot, 2),
116
+ candidate=round(c_tpot, 2),
117
+ unit="ms",
118
+ change_pct=round(tpot_diff_pct, 1),
119
+ passed=tpot_pass,
120
+ threshold_pct=thresh.max_tpot_pct,
121
+ )
122
+ )
123
+ if not tpot_pass:
124
+ fail_msg = (
125
+ f"[{c_lv.label}] TPOT p50 degraded by +{tpot_diff_pct:.1f}% "
126
+ f"({b_tpot:.2f}ms -> {c_tpot:.2f}ms, limit +{thresh.max_tpot_pct:.1f}%)"
127
+ )
128
+ level_failures.append(fail_msg)
129
+ overall_failures.append(fail_msg)
130
+
131
+ # 3. Output Throughput (higher is better; positive diff = throughput drop)
132
+ b_tok_s = b_sum.output_throughput
133
+ c_tok_s = c_sum.output_throughput
134
+ if b_tok_s > 0:
135
+ tok_drop_pct = ((b_tok_s - c_tok_s) / b_tok_s) * 100.0
136
+ tok_pass = tok_drop_pct <= thresh.max_throughput_pct
137
+ metrics.append(
138
+ MetricComparison(
139
+ metric="Throughput",
140
+ baseline=round(b_tok_s, 1),
141
+ candidate=round(c_tok_s, 1),
142
+ unit="tok/s",
143
+ change_pct=round(-tok_drop_pct, 1), # negative indicates drop
144
+ passed=tok_pass,
145
+ threshold_pct=thresh.max_throughput_pct,
146
+ )
147
+ )
148
+ if not tok_pass:
149
+ limit_s = f"-{thresh.max_throughput_pct:.1f}%"
150
+ fail_msg = (
151
+ f"[{c_lv.label}] Output throughput dropped by {tok_drop_pct:.1f}% "
152
+ f"({b_tok_s:.1f} -> {c_tok_s:.1f} tok/s, limit {limit_s})"
153
+ )
154
+ level_failures.append(fail_msg)
155
+ overall_failures.append(fail_msg)
156
+
157
+ # 4. Error Rate
158
+ total_reqs = len(c_lv.requests)
159
+ failed_reqs = sum(1 for r in c_lv.requests if not r.ok)
160
+ error_rate_pct = (failed_reqs / total_reqs * 100.0) if total_reqs > 0 else 0.0
161
+ err_pass = error_rate_pct <= thresh.max_error_rate_pct
162
+ metrics.append(
163
+ MetricComparison(
164
+ metric="Error Rate",
165
+ baseline=0.0,
166
+ candidate=round(error_rate_pct, 2),
167
+ unit="%",
168
+ change_pct=round(error_rate_pct, 2),
169
+ passed=err_pass,
170
+ threshold_pct=thresh.max_error_rate_pct,
171
+ )
172
+ )
173
+ if not err_pass:
174
+ fail_msg = (
175
+ f"[{c_lv.label}] Request error rate {error_rate_pct:.1f}% exceeded "
176
+ f"maximum threshold of {thresh.max_error_rate_pct:.1f}%"
177
+ )
178
+ level_failures.append(fail_msg)
179
+ overall_failures.append(fail_msg)
180
+
181
+ level_comparisons.append(
182
+ LevelComparison(
183
+ label=c_lv.label,
184
+ passed=len(level_failures) == 0,
185
+ metrics=metrics,
186
+ failures=level_failures,
187
+ )
188
+ )
189
+
190
+ all_passed = len(overall_failures) == 0 and len(level_comparisons) > 0
191
+ return CheckReport(
192
+ passed=all_passed,
193
+ candidate_label=candidate.label,
194
+ baseline_label=baseline.label,
195
+ levels=level_comparisons,
196
+ failures=overall_failures,
197
+ )
198
+
199
+
200
+ def print_check_report(report: CheckReport, console: Console | None = None) -> None:
201
+ """Render check regression table to terminal."""
202
+ con = console or Console()
203
+
204
+ status_str = (
205
+ "[bold green]PASSED[/bold green]" if report.passed else "[bold red]FAILED[/bold red]"
206
+ )
207
+ tbl = Table(
208
+ title=f"CI Performance Regression Gate: {status_str}",
209
+ box=None,
210
+ pad_edge=False,
211
+ header_style="bold underline",
212
+ )
213
+ tbl.add_column("Level", justify="left")
214
+ tbl.add_column("Metric", justify="left")
215
+ tbl.add_column("Baseline", justify="right")
216
+ tbl.add_column("Candidate", justify="right")
217
+ tbl.add_column("Change", justify="right")
218
+ tbl.add_column("Status", justify="center")
219
+
220
+ for lv in report.levels:
221
+ for m in lv.metrics:
222
+ if m.metric in ("TTFT p50", "TPOT p50"):
223
+ sign = "+" if m.change_pct > 0 else ""
224
+ diff_str = f"{sign}{m.change_pct:.1f}%"
225
+ elif m.metric == "Throughput":
226
+ diff_str = f"{m.change_pct:+.1f}%"
227
+ else:
228
+ diff_str = f"{m.candidate:.1f}%"
229
+
230
+ stat = "[green]PASS[/green]" if m.passed else "[red]FAIL[/red]"
231
+ base_str = f"{m.baseline} {m.unit}"
232
+ cand_str = f"{m.candidate} {m.unit}"
233
+ tbl.add_row(lv.label, m.metric, base_str, cand_str, diff_str, stat)
234
+
235
+ con.print(tbl)
236
+ con.print()
237
+
238
+ if report.failures:
239
+ con.print("[bold red]Performance Regressions Detected:[/bold red]")
240
+ for f in report.failures:
241
+ con.print(f" [red]✗ {f}[/red]")
242
+ else:
243
+ con.print(
244
+ "[bold green]✓ All performance metrics are within specified "
245
+ "regression tolerances.[/bold green]"
246
+ )