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 +21 -0
- servebench/api.py +189 -0
- servebench/capacity.py +104 -0
- servebench/check.py +246 -0
- servebench/cli.py +901 -0
- servebench/client.py +224 -0
- servebench/detect.py +120 -0
- servebench/diagnosis.py +227 -0
- servebench/load.py +383 -0
- servebench/metrics.py +169 -0
- servebench/mock_server.py +187 -0
- servebench/prometheus.py +141 -0
- servebench/prompts.py +142 -0
- servebench/quick.py +92 -0
- servebench/report.py +913 -0
- servebench/results.py +271 -0
- servebench/share.py +129 -0
- servebench/system.py +116 -0
- servebench/telemetry.py +263 -0
- servebench/tokens.py +39 -0
- servebench/workloads.py +153 -0
- servebench-1.0.0.dist-info/METADATA +323 -0
- servebench-1.0.0.dist-info/RECORD +26 -0
- servebench-1.0.0.dist-info/WHEEL +4 -0
- servebench-1.0.0.dist-info/entry_points.txt +3 -0
- servebench-1.0.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|