gbench 0.5.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.
- gbench/__init__.py +64 -0
- gbench/__main__.py +23 -0
- gbench/analysis/__init__.py +30 -0
- gbench/analysis/perf_load.py +213 -0
- gbench/analysis/perf_method.py +306 -0
- gbench/analysis/perf_sweep.py +146 -0
- gbench/analysis/statistics.py +367 -0
- gbench/canonical.py +101 -0
- gbench/changelog_gate.py +517 -0
- gbench/cli.py +4111 -0
- gbench/core/__init__.py +70 -0
- gbench/core/config.py +1380 -0
- gbench/core/models.py +561 -0
- gbench/core/scaffold.py +1831 -0
- gbench/docker/api_bank_translate_worker.py +83 -0
- gbench/docker/cyberseceval_icd_worker.py +315 -0
- gbench/docker/gaia2_cascade_judge.py +259 -0
- gbench/docker/gaia2_run.py +1815 -0
- gbench/docker/mcp_bench_bridge.py +372 -0
- gbench/docker/mcp_bench_build_check.py +133 -0
- gbench/docker/mcp_bench_cascade_judge.py +335 -0
- gbench/docker/mcp_bench_credentialed.py +415 -0
- gbench/docker/mcp_bench_request_policy.py +323 -0
- gbench/docker/mcp_bench_run.py +959 -0
- gbench/docker/mini_swe_agent_v2_run.py +223 -0
- gbench/docker/mobilegym_run.py +1489 -0
- gbench/docker/mswe_agent_run.py +525 -0
- gbench/docker/skillsbench_run.py +378 -0
- gbench/docker/swe_lancer_eval.py +98 -0
- gbench/docker/terminal_bench_verify_env.py +95 -0
- gbench/docker/ui_control_osworld_run.py +447 -0
- gbench/docker/wildclawbench_cascade_judge.py +1481 -0
- gbench/docker/wildclawbench_run.py +2201 -0
- gbench/golden_dataset/assets/starry_night.png +0 -0
- gbench/golden_dataset/assets/tellMeAboutTheSun.wav +0 -0
- gbench/golden_dataset/assets/what_color_is_cheese.wav +0 -0
- gbench/golden_dataset/audio_transcription_smoketest.json +49 -0
- gbench/golden_dataset/code_canonical.json +32 -0
- gbench/golden_dataset/function_call_single.json +55 -0
- gbench/golden_dataset/identify_person.json +35 -0
- gbench/golden_dataset/location_identification.json +41 -0
- gbench/golden_dataset/math_canonical.json +28 -0
- gbench/golden_dataset/multimodal_vision_landmark.json +50 -0
- gbench/golden_dataset/safety_boundary.json +47 -0
- gbench/golden_dataset/structured_json.json +27 -0
- gbench/golden_dataset/template_multi_turn.json +49 -0
- gbench/golden_dataset/template_multi_turn_twin.json +33 -0
- gbench/golden_dataset/template_tool_response.json +78 -0
- gbench/golden_dataset/template_tool_response_twin.json +34 -0
- gbench/golden_dataset/tool_call_minimal.json +48 -0
- gbench/golden_dataset/translation_romance.json +39 -0
- gbench/golden_dataset/two_rivers_zurich.json +38 -0
- gbench/merge.py +811 -0
- gbench/pool.py +1135 -0
- gbench/pool_durations.json +487 -0
- gbench/runners/__init__.py +32 -0
- gbench/runners/eval_suites/__init__.py +367 -0
- gbench/runners/eval_suites/base.py +5485 -0
- gbench/runners/eval_suites/canonical_sync.py +5919 -0
- gbench/runners/eval_suites/container_guard.py +566 -0
- gbench/runners/eval_suites/custom_jsonl.py +205 -0
- gbench/runners/eval_suites/dataset_utils.py +146 -0
- gbench/runners/eval_suites/extraction_common.py +159 -0
- gbench/runners/eval_suites/fc_spec_validator.py +136 -0
- gbench/runners/eval_suites/gemini_budget.py +102 -0
- gbench/runners/eval_suites/gemini_relay.py +668 -0
- gbench/runners/eval_suites/limit_plan.py +197 -0
- gbench/runners/eval_suites/loader.py +115 -0
- gbench/runners/eval_suites/math_equiv.py +196 -0
- gbench/runners/eval_suites/metrics.py +261 -0
- gbench/runners/eval_suites/mswe_harness_build.py +339 -0
- gbench/runners/eval_suites/sampling.py +273 -0
- gbench/runners/eval_suites/sandbox.py +408 -0
- gbench/runners/eval_suites/search_tool.py +1005 -0
- gbench/runners/eval_suites/swe_agentic.py +1746 -0
- gbench/runners/eval_suites/swe_thread_cap.py +246 -0
- gbench/runners/eval_suites/swebench_common.py +4766 -0
- gbench/runners/eval_suites/tau_common.py +4640 -0
- gbench/runners/eval_suites/terminus_salvage_patch.py +145 -0
- gbench/runners/evals.py +1281 -0
- gbench/runners/golden.py +1435 -0
- gbench/runners/perf_payload.py +294 -0
- gbench/runners/quality.py +2289 -0
- gbench/runners/serving.py +2929 -0
- gbench/runners/stress.py +2748 -0
- gbench/runners/throughput.py +1211 -0
- gbench/smoke.py +1441 -0
- gbench/third_party/__init__.py +15 -0
- gbench/third_party/_acebench_vendor/LICENSE +21 -0
- gbench/third_party/_acebench_vendor/__init__.py +104 -0
- gbench/third_party/_acebench_vendor/multi_step_prompts.py +60 -0
- gbench/third_party/_acebench_vendor/multi_turn_prompts.py +104 -0
- gbench/third_party/_acebench_vendor/prompt_en.py +143 -0
- gbench/third_party/_acebench_vendor/prompt_zh.py +146 -0
- gbench/third_party/_acebench_vendor/scenariosen/__init__.py +1 -0
- gbench/third_party/_acebench_vendor/scenariosen/phone_platform/__init__.py +1 -0
- gbench/third_party/_acebench_vendor/scenariosen/phone_platform/base_api.py +27 -0
- gbench/third_party/_acebench_vendor/scenariosen/phone_platform/food_services.py +253 -0
- gbench/third_party/_acebench_vendor/scenariosen/phone_platform/message.py +286 -0
- gbench/third_party/_acebench_vendor/scenariosen/phone_platform/reminder.py +143 -0
- gbench/third_party/_acebench_vendor/scenariosen/travel.py +530 -0
- gbench/third_party/_acebench_vendor/scenarioszh/__init__.py +1 -0
- gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/__init__.py +1 -0
- gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/base_api.py +26 -0
- gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/food_services.py +247 -0
- gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/message.py +287 -0
- gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/reminder.py +177 -0
- gbench/third_party/_acebench_vendor/scenarioszh/travel.py +548 -0
- gbench/third_party/_json_repair_vendor/LICENSE +21 -0
- gbench/third_party/_json_repair_vendor/__init__.py +4 -0
- gbench/third_party/_json_repair_vendor/json_context.py +43 -0
- gbench/third_party/_json_repair_vendor/json_parser.py +891 -0
- gbench/third_party/_json_repair_vendor/json_repair.py +277 -0
- gbench/third_party/_json_repair_vendor/object_comparer.py +55 -0
- gbench/third_party/_json_repair_vendor/string_file_wrapper.py +119 -0
- gbench/third_party/_scicode_vendor/LICENSE +201 -0
- gbench/third_party/_scicode_vendor/__init__.py +26 -0
- gbench/third_party/_scicode_vendor/scicode/__init__.py +16 -0
- gbench/third_party/_scicode_vendor/scicode/compare/__init__.py +3 -0
- gbench/third_party/_scicode_vendor/scicode/compare/cmp.py +78 -0
- gbench/third_party/_scicode_vendor/scicode/parse/__init__.py +2 -0
- gbench/third_party/_scicode_vendor/scicode/parse/parse.py +100 -0
- gbench/third_party/_teval_vendor/LICENSE +201 -0
- gbench/third_party/_teval_vendor/__init__.py +14 -0
- gbench/third_party/_teval_vendor/format_load.py +50 -0
- gbench/third_party/_teval_vendor/instruct_evaluator.py +162 -0
- gbench/third_party/_teval_vendor/planning_evaluator.py +409 -0
- gbench/third_party/_teval_vendor/reason_retrieve_understand_evaluator.py +463 -0
- gbench/third_party/_teval_vendor/review_evaluator.py +139 -0
- gbench/third_party/_teval_vendor/schema.py +25 -0
- gbench/third_party/_teval_vendor/template.py +82 -0
- gbench/third_party/eval_suites/LICENSE +293 -0
- gbench/third_party/eval_suites/_complexfuncbench_data/LICENSE +201 -0
- gbench/third_party/eval_suites/_complexfuncbench_data/exact_match_values.json +232 -0
- gbench/third_party/eval_suites/_complexfuncbench_data/tool_info.json +56 -0
- gbench/third_party/eval_suites/_ifeval_vendor/LICENSE +201 -0
- gbench/third_party/eval_suites/_ifeval_vendor/__init__.py +23 -0
- gbench/third_party/eval_suites/_ifeval_vendor/evaluation_lib.py +220 -0
- gbench/third_party/eval_suites/_ifeval_vendor/instructions.py +1570 -0
- gbench/third_party/eval_suites/_ifeval_vendor/instructions_registry.py +176 -0
- gbench/third_party/eval_suites/_ifeval_vendor/instructions_util.py +147 -0
- gbench/third_party/eval_suites/_swelancer_adapter/predictions_solver.py +158 -0
- gbench/third_party/eval_suites/_swelancer_adapter/run_swelancer_eval.py +92 -0
- gbench/third_party/eval_suites/aa_lcr.py +564 -0
- gbench/third_party/eval_suites/acebench.py +2987 -0
- gbench/third_party/eval_suites/agent_dojo.py +966 -0
- gbench/third_party/eval_suites/aider_polyglot.py +1536 -0
- gbench/third_party/eval_suites/aime.py +208 -0
- gbench/third_party/eval_suites/amc_aime.py +232 -0
- gbench/third_party/eval_suites/api_bank.py +1131 -0
- gbench/third_party/eval_suites/arc_agi.py +708 -0
- gbench/third_party/eval_suites/beam_128k.py +857 -0
- gbench/third_party/eval_suites/bfcl.py +2005 -0
- gbench/third_party/eval_suites/bfcl_live.py +158 -0
- gbench/third_party/eval_suites/bfcl_v4_agentic.py +3585 -0
- gbench/third_party/eval_suites/bigcodebench.py +952 -0
- gbench/third_party/eval_suites/browsecomp.py +408 -0
- gbench/third_party/eval_suites/bundled_detection.py +503 -0
- gbench/third_party/eval_suites/causalbench.py +371 -0
- gbench/third_party/eval_suites/chartqa.py +171 -0
- gbench/third_party/eval_suites/charxiv.py +744 -0
- gbench/third_party/eval_suites/charxiv_constants.py +531 -0
- gbench/third_party/eval_suites/cimemories.py +1470 -0
- gbench/third_party/eval_suites/coco_caption.py +397 -0
- gbench/third_party/eval_suites/codeforces.py +699 -0
- gbench/third_party/eval_suites/complexfuncbench.py +2157 -0
- gbench/third_party/eval_suites/copilot_bench_swe.py +119 -0
- gbench/third_party/eval_suites/cruxeval.py +1135 -0
- gbench/third_party/eval_suites/cyberseceval.py +766 -0
- gbench/third_party/eval_suites/deepsearch_qa.py +619 -0
- gbench/third_party/eval_suites/docvqa.py +193 -0
- gbench/third_party/eval_suites/fc_common.py +1836 -0
- gbench/third_party/eval_suites/frames.py +276 -0
- gbench/third_party/eval_suites/gaia.py +1007 -0
- gbench/third_party/eval_suites/gaia2.py +1449 -0
- gbench/third_party/eval_suites/gdpval.py +1447 -0
- gbench/third_party/eval_suites/gorilla_apibench.py +533 -0
- gbench/third_party/eval_suites/gpqa.py +451 -0
- gbench/third_party/eval_suites/gpqa_diamond.py +69 -0
- gbench/third_party/eval_suites/gsm8k.py +349 -0
- gbench/third_party/eval_suites/healthbench.py +505 -0
- gbench/third_party/eval_suites/hmmt.py +800 -0
- gbench/third_party/eval_suites/humanitys_last_exam.py +524 -0
- gbench/third_party/eval_suites/i18n_translate.py +487 -0
- gbench/third_party/eval_suites/ifeval.py +306 -0
- gbench/third_party/eval_suites/imo_answer_bench.py +385 -0
- gbench/third_party/eval_suites/infographicvqa.py +191 -0
- gbench/third_party/eval_suites/lab_bench.py +550 -0
- gbench/third_party/eval_suites/lcb.py +3269 -0
- gbench/third_party/eval_suites/livebench.py +1830 -0
- gbench/third_party/eval_suites/loft_scifact.py +368 -0
- gbench/third_party/eval_suites/longbench_v2_code.py +1096 -0
- gbench/third_party/eval_suites/matharena_common.py +724 -0
- gbench/third_party/eval_suites/mcp_atlas.py +3259 -0
- gbench/third_party/eval_suites/mcp_bench.py +2141 -0
- gbench/third_party/eval_suites/medxpertqa.py +501 -0
- gbench/third_party/eval_suites/mmlu.py +634 -0
- gbench/third_party/eval_suites/mmlu_pro.py +803 -0
- gbench/third_party/eval_suites/mmlu_redux.py +714 -0
- gbench/third_party/eval_suites/mmmu_pro.py +483 -0
- gbench/third_party/eval_suites/mobilegym.py +856 -0
- gbench/third_party/eval_suites/mobilegym_pp.py +71 -0
- gbench/third_party/eval_suites/mrcr.py +440 -0
- gbench/third_party/eval_suites/multi_swe_bench.py +1472 -0
- gbench/third_party/eval_suites/multilingual_mmlu.py +283 -0
- gbench/third_party/eval_suites/multipl_e.py +793 -0
- gbench/third_party/eval_suites/nestful.py +1509 -0
- gbench/third_party/eval_suites/nexusraven_api_eval.py +593 -0
- gbench/third_party/eval_suites/ojbench.py +644 -0
- gbench/third_party/eval_suites/omnidoc_metrics.py +245 -0
- gbench/third_party/eval_suites/omnidocbench.py +746 -0
- gbench/third_party/eval_suites/putnam.py +515 -0
- gbench/third_party/eval_suites/putnam_formal.py +1791 -0
- gbench/third_party/eval_suites/ruler.py +561 -0
- gbench/third_party/eval_suites/scicode.py +1353 -0
- gbench/third_party/eval_suites/scicode_data/13.6.txt +50 -0
- gbench/third_party/eval_suites/scicode_data/62.1.txt +29 -0
- gbench/third_party/eval_suites/scicode_data/76.3.txt +27 -0
- gbench/third_party/eval_suites/scicode_data/ATTRIBUTION.md +13 -0
- gbench/third_party/eval_suites/scicode_data/LICENSE +201 -0
- gbench/third_party/eval_suites/screenspot.py +287 -0
- gbench/third_party/eval_suites/screenspot_pointing.py +349 -0
- gbench/third_party/eval_suites/seal_tools.py +276 -0
- gbench/third_party/eval_suites/simpleqa.py +553 -0
- gbench/third_party/eval_suites/skillsbench.py +1192 -0
- gbench/third_party/eval_suites/spider2.py +674 -0
- gbench/third_party/eval_suites/swe_bench_live.py +2878 -0
- gbench/third_party/eval_suites/swe_bench_multilingual.py +122 -0
- gbench/third_party/eval_suites/swe_bench_pro.py +1710 -0
- gbench/third_party/eval_suites/swe_lancer.py +607 -0
- gbench/third_party/eval_suites/t_eval.py +697 -0
- gbench/third_party/eval_suites/tau2.py +140 -0
- gbench/third_party/eval_suites/tau3.py +150 -0
- gbench/third_party/eval_suites/terminal_bench.py +2364 -0
- gbench/third_party/eval_suites/textvqa.py +207 -0
- gbench/third_party/eval_suites/toolbench.py +2321 -0
- gbench/third_party/eval_suites/ui_control_osworld.py +858 -0
- gbench/third_party/eval_suites/vqa_common.py +673 -0
- gbench/third_party/eval_suites/wildbench.py +1885 -0
- gbench/third_party/eval_suites/wildclawbench.py +1725 -0
- gbench/third_party/eval_suites/wmdp.py +541 -0
- gbench/third_party/matharena_src/LICENSE +21 -0
- gbench/third_party/matharena_src/configs/competitions/aime/aime_2026.yaml +6 -0
- gbench/third_party/matharena_src/configs/competitions/hmmt/hmmt_feb_2026.yaml +6 -0
- gbench/third_party/matharena_src/matharena/__init__.py +10 -0
- gbench/third_party/matharena_src/matharena/grader.py +245 -0
- gbench/third_party/matharena_src/matharena/parse_manual.py +73 -0
- gbench/third_party/matharena_src/matharena/parser.py +1133 -0
- gbench/third_party/matharena_src/matharena/tools/lean_execution.py +513 -0
- gbench/third_party/matharena_src/matharena/tools/submit_answer.py +126 -0
- gbench/third_party/matharena_src/matharena/utils.py +383 -0
- gbench/utils/__init__.py +45 -0
- gbench/utils/calibrate.py +480 -0
- gbench/utils/dependency_checks.py +114 -0
- gbench/utils/endpoint.py +278 -0
- gbench/utils/endpoint_lock.py +232 -0
- gbench/utils/log_manager.py +605 -0
- gbench/utils/process_groups.py +148 -0
- gbench/utils/redaction.py +32 -0
- gbench-0.5.0.dist-info/METADATA +267 -0
- gbench-0.5.0.dist-info/RECORD +266 -0
- gbench-0.5.0.dist-info/WHEEL +5 -0
- gbench-0.5.0.dist-info/entry_points.txt +2 -0
- gbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- gbench-0.5.0.dist-info/licenses/NOTICE +491 -0
- gbench-0.5.0.dist-info/top_level.txt +1 -0
gbench/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2026 Google LLC
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""gbench - Open Model Performance Benchmark Suite.
|
|
17
|
+
|
|
18
|
+
A production-grade, modular benchmarking tool for comprehensive evaluation
|
|
19
|
+
of open models served by vLLM or any OpenAI-compatible endpoint (text and multimodal).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.5.0"
|
|
23
|
+
__author__ = "Luciano Martins"
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
# Process-wide Gemini API key multi-key support & auto-sanitization.
|
|
30
|
+
# GEMINI_API_KEYS (a comma/semicolon list) wins when both it and GEMINI_API_KEY are set, as in
|
|
31
|
+
# base.gemini_api_keys(); otherwise GEMINI_API_KEY (one key or a list) is used. The chosen list is
|
|
32
|
+
# written back to GEMINI_API_KEYS, and GEMINI_API_KEY is set to its first key for third-party
|
|
33
|
+
# libraries (LiteLLM, OpenAI SDK) that read a single key.
|
|
34
|
+
def _split_gemini_keys(raw: str) -> list:
|
|
35
|
+
return [k.strip() for k in re.split(r"[;,]", raw or "") if k.strip()]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_GEMINI_KEYS = (_split_gemini_keys(os.environ.get("GEMINI_API_KEYS", ""))
|
|
39
|
+
or _split_gemini_keys(os.environ.get("GEMINI_API_KEY", "")))
|
|
40
|
+
if _GEMINI_KEYS:
|
|
41
|
+
os.environ["GEMINI_API_KEYS"] = ",".join(_GEMINI_KEYS)
|
|
42
|
+
os.environ["GEMINI_API_KEY"] = _GEMINI_KEYS[0]
|
|
43
|
+
|
|
44
|
+
# Auto-patch google.genai.Client globally so any eval or plugin can pass raw or multi-keys safely
|
|
45
|
+
try:
|
|
46
|
+
from google import genai
|
|
47
|
+
_orig_genai_client_init = genai.Client.__init__
|
|
48
|
+
|
|
49
|
+
def _patched_genai_client_init(self, *args, **kwargs):
|
|
50
|
+
if "api_key" in kwargs and kwargs["api_key"]:
|
|
51
|
+
keys = _split_gemini_keys(str(kwargs["api_key"]))
|
|
52
|
+
if len(keys) > 1:
|
|
53
|
+
kwargs["api_key"] = keys[0]
|
|
54
|
+
elif not kwargs.get("api_key"):
|
|
55
|
+
keys = (_split_gemini_keys(os.environ.get("GEMINI_API_KEYS", ""))
|
|
56
|
+
or _split_gemini_keys(os.environ.get("GEMINI_API_KEY", "")))
|
|
57
|
+
if keys:
|
|
58
|
+
kwargs["api_key"] = keys[0]
|
|
59
|
+
_orig_genai_client_init(self, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
genai.Client.__init__ = _patched_genai_client_init
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
gbench/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2026 Google LLC
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Main entry point for gbench package."""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .cli import main
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(main())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2026 Google LLC
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Analysis package for statistical processing."""
|
|
17
|
+
|
|
18
|
+
from .statistics import (
|
|
19
|
+
aggregate_benchmark_results,
|
|
20
|
+
compute_statistics,
|
|
21
|
+
format_statistics_summary,
|
|
22
|
+
validate_repeatability,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"aggregate_benchmark_results",
|
|
27
|
+
"compute_statistics",
|
|
28
|
+
"format_statistics_summary",
|
|
29
|
+
"validate_repeatability",
|
|
30
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2026 Google LLC
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Pure functions behind the stress pillar's open-loop load and its saturation gate.
|
|
17
|
+
|
|
18
|
+
No network, no clock: the runner owns the I/O and calls these with planned send times
|
|
19
|
+
and the records the client workers return. Every threshold here is relative (to the
|
|
20
|
+
point's own latency, rate or SLO), never an absolute number tuned to one GPU, so the
|
|
21
|
+
same gate is meaningful at 0.1 req/s on a laptop GPU and at 100 req/s on a large host.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from typing import Dict, List, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
# ── Schedule ───────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def merged_schedule(rate: float, n: int, seed: int) -> List[float]:
|
|
32
|
+
"""n Poisson arrival offsets (seconds after T0) at ``rate`` req/s from ONE seeded
|
|
33
|
+
generator: the whole point's arrival process, independent of how many client
|
|
34
|
+
processes later send it. The first arrival is an exponential gap after T0, never
|
|
35
|
+
at T0 itself."""
|
|
36
|
+
rng = np.random.default_rng(int(seed) & 0xFFFFFFFF)
|
|
37
|
+
return [float(x) for x in np.cumsum(rng.exponential(1.0 / float(rate), int(n)))]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assign_workers(n: int, k: int) -> List[List[int]]:
|
|
41
|
+
"""Request indices per worker: arrival i goes to worker i mod k."""
|
|
42
|
+
k = max(1, int(k))
|
|
43
|
+
return [list(range(w, int(n), k)) for w in range(k)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def request_count(qps: float, min_prompts: int, min_steady: int,
|
|
47
|
+
latency_est_s: Optional[float], c: float = 3.0,
|
|
48
|
+
window_s: float = 60.0, cap: Optional[int] = None) -> int:
|
|
49
|
+
"""Requests for one point; never fewer than the old floor max(min_prompts, qps·60).
|
|
50
|
+
|
|
51
|
+
Also enough to (a) cover c x the loaded latency plus 10 s, so the queue-growth test
|
|
52
|
+
sees a window several latencies long, and (b) leave min_steady requests in the
|
|
53
|
+
cohort after the first L of sends is excluded (planned send in [T0 + L, T_last]).
|
|
54
|
+
``cap`` (an explicit --stress-max-prompts) is honoured as a recorded cap."""
|
|
55
|
+
n = max(int(min_prompts), int(round(qps * window_s)))
|
|
56
|
+
if latency_est_s and latency_est_s > 0:
|
|
57
|
+
n = max(n, int(math.ceil(qps * (c * latency_est_s + 10.0))))
|
|
58
|
+
n = max(n, int(math.ceil(qps * latency_est_s)) + int(min_steady) + 1)
|
|
59
|
+
else:
|
|
60
|
+
n = max(n, int(min_steady) + 1)
|
|
61
|
+
if cap:
|
|
62
|
+
n = min(n, int(cap))
|
|
63
|
+
return n
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def offered_rate_ci(n: int, span_s: float, alpha: float = 0.05) -> Dict[str, float]:
|
|
67
|
+
"""Realized offered rate of a Poisson schedule whose n-th arrival is at span_s:
|
|
68
|
+
the unbiased estimate (n - 1) / span and the exact chi-square interval
|
|
69
|
+
[chi2(a/2, 2n), chi2(1 - a/2, 2n)] / (2 span)."""
|
|
70
|
+
if n <= 1 or span_s <= 0:
|
|
71
|
+
return {"rate": 0.0, "low": 0.0, "high": 0.0}
|
|
72
|
+
from scipy.stats import chi2
|
|
73
|
+
return {"rate": (n - 1) / span_s,
|
|
74
|
+
"low": float(chi2.ppf(alpha / 2, 2 * n)) / (2 * span_s),
|
|
75
|
+
"high": float(chi2.ppf(1 - alpha / 2, 2 * n)) / (2 * span_s)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def percentile_inf(values: Sequence[float], q: float = 99.0) -> float:
|
|
79
|
+
"""Linear-interpolated percentile that treats +inf (a request that never finished)
|
|
80
|
+
as larger than everything: inf when the rank lands on or next to one. numpy's
|
|
81
|
+
percentile returns nan there (inf - inf)."""
|
|
82
|
+
v = np.sort(np.asarray(values, dtype=float))
|
|
83
|
+
if len(v) == 0:
|
|
84
|
+
return float("inf")
|
|
85
|
+
rank = q / 100.0 * (len(v) - 1)
|
|
86
|
+
lo, hi = int(math.floor(rank)), int(math.ceil(rank))
|
|
87
|
+
if math.isinf(v[hi]):
|
|
88
|
+
return float("inf")
|
|
89
|
+
return float(v[lo] + (v[hi] - v[lo]) * (rank - lo))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Cohort ─────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def cohort(records: Sequence[dict], t_lo: float, t_hi: float) -> List[dict]:
|
|
95
|
+
"""Requests whose PLANNED send time is in [t_lo, t_hi], in planned order. Selection
|
|
96
|
+
never looks at completion order, so slow requests can't be dropped from it."""
|
|
97
|
+
sel = [r for r in records if r.get("planned") is not None and t_lo <= r["planned"] <= t_hi]
|
|
98
|
+
return sorted(sel, key=lambda r: r["planned"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Saturation: queue growth ───────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
SATURATED = "SATURATED"
|
|
104
|
+
NOT_DETECTED = "NOT_DETECTED"
|
|
105
|
+
INDETERMINATE = "INDETERMINATE"
|
|
106
|
+
|
|
107
|
+
# Calibration (docs/perf_evidence/step5b/final_calibration_2026-10-15.txt; 2000 seeds per
|
|
108
|
+
# healthy point, Clopper-Pearson 95% CIs; 1/4-slot capacity and batching servers, sigma
|
|
109
|
+
# 1.0, a fast batching server, 256 slots, unlimited servers at 0.13-100 req/s):
|
|
110
|
+
# certification size (~1300-request cohorts, which decide the headline): false
|
|
111
|
+
# SATURATED 0-0.1% at 0.8x/0.9x (upper CI <= 0.92%), detection 98.75-100% at 1.25x;
|
|
112
|
+
# sweep size (the bracket): false SATURATED up to 1.2% at 0.8x and 1.5-3.6% at 0.9x on
|
|
113
|
+
# 1-4-slot servers (0 on large/fast/unlimited ones), detection 10-77% at 1.25x on small
|
|
114
|
+
# servers; a false alarm there only moves the bracket conservatively.
|
|
115
|
+
# Live gemma-4-E2B (256 / 4 / 1 slots and a KV-limited server): 0/60 false at <= 0.9x; 32/40
|
|
116
|
+
# overloaded points flagged (all 40 also failed the P99 TTFT SLO).
|
|
117
|
+
# Undetected overload is NOT_DETECTED, never "keeping up"; the SLO gates then decide.
|
|
118
|
+
QG_BATCHES = 10
|
|
119
|
+
QG_ALPHA = 1e-4
|
|
120
|
+
# Material growth: TTFT rising faster than this many seconds per second of wall time.
|
|
121
|
+
# A queue fed at rho x capacity grows its wait by (rho - 1) s/s, so 0.05 means rho >~
|
|
122
|
+
# 1.05; a continuous-batching server settling into a larger batch raises TTFT by
|
|
123
|
+
# milliseconds over tens of seconds (~0.001 s/s), far below it. Dimensionless, so the
|
|
124
|
+
# same on any hardware.
|
|
125
|
+
QG_MATERIAL_SLOPE = 0.05
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _batch_means(x: np.ndarray, y: np.ndarray, batches: int, stat: str = "median"):
|
|
129
|
+
idx = np.array_split(np.arange(len(x)), batches)
|
|
130
|
+
f = np.median if stat == "median" else np.mean
|
|
131
|
+
return (np.array([x[i].mean() for i in idx]), np.array([f(y[i]) for i in idx]))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _t_crit(df: int, one_sided_alpha: float) -> float:
|
|
135
|
+
"""Student t critical value (scipy is a core gbench dependency)."""
|
|
136
|
+
from scipy.stats import t as _t
|
|
137
|
+
return float(_t.ppf(1.0 - one_sided_alpha, df))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def queue_growth(times: Sequence[float], values: Sequence[float],
|
|
141
|
+
batches: int = QG_BATCHES, alpha: float = QG_ALPHA,
|
|
142
|
+
material_slope: float = QG_MATERIAL_SLOPE, stat: str = "median") -> dict:
|
|
143
|
+
"""Is the queue growing over the cohort?
|
|
144
|
+
|
|
145
|
+
``values`` is a queueing-sensitive latency (TTFT, ms) at each request's planned send
|
|
146
|
+
``times`` (s). The cohort is cut into ``batches`` equal consecutive groups and the
|
|
147
|
+
slope of the batch medians over time is estimated by least squares on those medians
|
|
148
|
+
(batching makes the variance robust to the strong autocorrelation of queueing
|
|
149
|
+
delays, which an i.i.d. test on single requests ignores; medians stop one very slow
|
|
150
|
+
request from faking a trend):
|
|
151
|
+
|
|
152
|
+
SATURATED slope > 0 with one-sided significance ``alpha`` AND TTFT growing
|
|
153
|
+
faster than ``material_slope`` seconds per second of wall time (a
|
|
154
|
+
queue fed at rho x capacity grows its wait by rho - 1 s/s)
|
|
155
|
+
NOT_DETECTED otherwise (not proof of keeping up: the SLO gates decide)
|
|
156
|
+
INDETERMINATE too few requests for the test
|
|
157
|
+
"""
|
|
158
|
+
x = np.asarray(times, dtype=float)
|
|
159
|
+
y = np.asarray(values, dtype=float)
|
|
160
|
+
ok = np.isfinite(x) & np.isfinite(y)
|
|
161
|
+
x, y = x[ok], y[ok]
|
|
162
|
+
out = {"outcome": INDETERMINATE, "n": int(len(x)), "batches": int(batches),
|
|
163
|
+
"alpha": alpha, "material_slope_s_per_s": material_slope}
|
|
164
|
+
if len(x) < 2 * batches or batches < 4:
|
|
165
|
+
out["reason"] = "too few requests for the batch test"
|
|
166
|
+
return out
|
|
167
|
+
order = np.argsort(x)
|
|
168
|
+
x, y = x[order], y[order]
|
|
169
|
+
bx, by = _batch_means(x, y, batches, stat)
|
|
170
|
+
if float(x[-1] - x[0]) <= 0:
|
|
171
|
+
out["reason"] = "degenerate cohort"
|
|
172
|
+
return out
|
|
173
|
+
xc = bx - bx.mean()
|
|
174
|
+
sxx = float((xc ** 2).sum())
|
|
175
|
+
slope = float((xc * (by - by.mean())).sum() / sxx)
|
|
176
|
+
resid = by - (by.mean() + slope * xc)
|
|
177
|
+
df = batches - 2
|
|
178
|
+
se = float(math.sqrt(max(1e-18, (resid ** 2).sum() / df / sxx)))
|
|
179
|
+
tc = _t_crit(df, alpha)
|
|
180
|
+
out.update({"slope_s_per_s": slope / 1000.0, "slope_se_s_per_s": se / 1000.0,
|
|
181
|
+
"t_stat": slope / se if se > 0 else float("inf")})
|
|
182
|
+
out["outcome"] = (SATURATED if (slope / se > tc and slope / 1000.0 > material_slope)
|
|
183
|
+
else NOT_DETECTED)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def in_flight_at(records: Sequence[dict], times: Sequence[float]) -> List[int]:
|
|
188
|
+
"""Client-side in-flight count (sent and not yet finished, successfully or not) at
|
|
189
|
+
each instant; a request with no end at all counts as in flight forever."""
|
|
190
|
+
sends = np.sort([r["send"] for r in records if r.get("send") is not None])
|
|
191
|
+
dones = np.sort([r["done"] if r.get("done") is not None else math.inf
|
|
192
|
+
for r in records if r.get("send") is not None])
|
|
193
|
+
return [int(np.searchsorted(sends, t, "right") - np.searchsorted(dones, t, "right"))
|
|
194
|
+
for t in times]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── Secondary: completion fraction ─────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def completion_bound_s(ttft_slo_ms: float, itl_slo_ms: float, max_tokens: int) -> float:
|
|
200
|
+
"""The end-to-end time the SLOs imply for one request: TTFT SLO + (max_tokens - 1)
|
|
201
|
+
ITL SLOs. Independent of the measured latency, so the gate is not circular."""
|
|
202
|
+
return (float(ttft_slo_ms) + max(0, int(max_tokens) - 1) * float(itl_slo_ms)) / 1000.0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def completion_fraction(cohort_recs: Sequence[dict], t_last_arrival: float,
|
|
206
|
+
bound_s: float) -> Optional[float]:
|
|
207
|
+
"""Share of the cohort that finished successfully by t_last_arrival + bound_s."""
|
|
208
|
+
if not cohort_recs:
|
|
209
|
+
return None
|
|
210
|
+
deadline = t_last_arrival + bound_s
|
|
211
|
+
done = sum(1 for r in cohort_recs
|
|
212
|
+
if r.get("ok") and r.get("done") is not None and r["done"] <= deadline)
|
|
213
|
+
return done / len(cohort_recs)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2026 Google LLC
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Pure functions behind the performance pillars' measurement method.
|
|
17
|
+
|
|
18
|
+
Everything here is deterministic and side-effect free (no network, no clock), so
|
|
19
|
+
the statistics the stress and serving pillars gate on can be unit-tested over many
|
|
20
|
+
seeds without a server. The runners own the I/O; this module owns the maths.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from typing import Dict, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
# Bumped whenever a change alters a reported number or a pass/fail decision, so old
|
|
27
|
+
# and new results are never compared silently. Each component versions one part of
|
|
28
|
+
# the method. The serving and stress scaffolds pin methodology_version (as
|
|
29
|
+
# dataset_snapshot.workload_version), so a bump changes their scaffold_id and breaks
|
|
30
|
+
# the series instead of silently extending it. The offline throughput pillar uses
|
|
31
|
+
# none of this and is not pinned.
|
|
32
|
+
METHODOLOGY_COMPONENTS: Dict[str, int] = {
|
|
33
|
+
# 2: every request unique by construction (salt + nonce, coded images), one chat
|
|
34
|
+
# layout, the served context window for multimodal text (perf_payload).
|
|
35
|
+
# 3: vLLM requests send skip_special_tokens False, so a reply of special tokens is
|
|
36
|
+
# timed like any other (it streamed as empty chunks and read as never finished).
|
|
37
|
+
"payload": 3,
|
|
38
|
+
# 2: one merged Poisson schedule from the parent, sent at absolute times by any
|
|
39
|
+
# number of workers; request count never below max(min_prompts, qps x 60), sized up
|
|
40
|
+
# for the loaded latency; no prompt cap by default.
|
|
41
|
+
"schedule": 2,
|
|
42
|
+
# 2: the server ITL gate is PASS / FAIL / INDETERMINATE on the bucket counts.
|
|
43
|
+
# 3: every check on a planned-send cohort; saturation = queue growth of TTFT (batch
|
|
44
|
+
# medians, alpha 1e-4, material growth 0.05 s/s) + completion fraction >= 0.90 within
|
|
45
|
+
# the SLO-implied e2e; schedule-based client-bound checks; INCONCLUSIVE points.
|
|
46
|
+
# 4: the headline is the median repeat's bracket pass, certified with
|
|
47
|
+
# percentile-sized probes (bisection to +-9%, budgeted) or labelled UNCERTIFIED; no
|
|
48
|
+
# interpolated limit; drain before every point; contaminated points re-measured.
|
|
49
|
+
"gate": 4,
|
|
50
|
+
# 2: the server ITL P99 reads the CUMULATIVE histogram delta (v1 summed the
|
|
51
|
+
# running totals again and reached 99% one or more buckets too early).
|
|
52
|
+
# 3: client ITL split into inter-chunk (what users see) and per-token (chunk gaps
|
|
53
|
+
# divided by the tokens each chunk carried); the per-token value is the fallback gate.
|
|
54
|
+
"itl_metric": 3,
|
|
55
|
+
# 2: per-token TPOT = (e2e - ttft) / (completion_tokens - 1) from the server's usage,
|
|
56
|
+
# not the mean gap between streamed chunks (a chunk can carry several tokens).
|
|
57
|
+
"tpot_metric": 2,
|
|
58
|
+
# 2: per point / iteration prefix- and multimodal-cache deltas, prompt-length check.
|
|
59
|
+
"cache_telemetry": 2,
|
|
60
|
+
# 2: remote text serving on aiohttp (was a 32-thread pool that capped batch sizes
|
|
61
|
+
# above 32 and opened a connection per request); peak in-flight recorded.
|
|
62
|
+
"serving_transport": 2,
|
|
63
|
+
# 2: serving input_length_actual is the median over iterations (+ template tokens),
|
|
64
|
+
# ignore_eos_honored is observed (n/N requests reached max_tokens), per-request
|
|
65
|
+
# samples saved, honest multimodal banner.
|
|
66
|
+
"reporting": 2,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# The components each pillar's numbers depend on. Serving never reads the server ITL
|
|
71
|
+
# histogram or runs the sweep, so a schedule/gate/itl_metric bump must not break the
|
|
72
|
+
# serving series.
|
|
73
|
+
PILLAR_COMPONENTS: Dict[str, Tuple[str, ...]] = {
|
|
74
|
+
"serving": ("payload", "tpot_metric", "cache_telemetry", "serving_transport", "reporting"),
|
|
75
|
+
"stress": tuple(METHODOLOGY_COMPONENTS),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def methodology_record(pillar: str) -> dict:
|
|
80
|
+
"""The methodology stamp a serving or stress result carries: the components that
|
|
81
|
+
pillar's numbers depend on, and a version string derived from them."""
|
|
82
|
+
comps = {k: METHODOLOGY_COMPONENTS[k] for k in PILLAR_COMPONENTS[pillar]}
|
|
83
|
+
return {
|
|
84
|
+
"methodology_version": ".".join(f"{k}{v}" for k, v in sorted(comps.items())),
|
|
85
|
+
"methodology_components": comps,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Prometheus histogram (vllm:inter_token_latency_seconds) ────────────────────
|
|
90
|
+
|
|
91
|
+
def hist_delta(before: Optional[tuple], after: Optional[tuple]) -> Optional[Tuple[Dict[float, float], float]]:
|
|
92
|
+
"""Per-bucket delta of two cumulative histogram snapshots ({le: count}, total).
|
|
93
|
+
|
|
94
|
+
The buckets stay CUMULATIVE: delta[le] = number of observations <= le made
|
|
95
|
+
between the snapshots. None when a snapshot is missing or nothing was observed.
|
|
96
|
+
"""
|
|
97
|
+
if not before or not after:
|
|
98
|
+
return None
|
|
99
|
+
b_buckets, b_cnt = before
|
|
100
|
+
a_buckets, a_cnt = after
|
|
101
|
+
d_cnt = float(a_cnt) - float(b_cnt)
|
|
102
|
+
if d_cnt <= 0:
|
|
103
|
+
return None
|
|
104
|
+
# A counter reset, a server restart or a series first exposed mid-point gives an
|
|
105
|
+
# impossible delta; refuse it (the caller falls back to the client value) rather
|
|
106
|
+
# than report a 'provable' verdict from it.
|
|
107
|
+
if set(a_buckets) != set(b_buckets):
|
|
108
|
+
return None
|
|
109
|
+
delta = {}
|
|
110
|
+
prev = 0.0
|
|
111
|
+
for le in sorted(a_buckets):
|
|
112
|
+
v = float(a_buckets[le]) - float(b_buckets[le])
|
|
113
|
+
if v < -1e-9 or v + 1e-9 < prev or v > d_cnt + 1e-9:
|
|
114
|
+
return None
|
|
115
|
+
delta[le] = v
|
|
116
|
+
prev = v
|
|
117
|
+
if math.inf in delta and abs(delta[math.inf] - d_cnt) > 1e-6:
|
|
118
|
+
return None
|
|
119
|
+
return delta, d_cnt
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hist_quantile_bucket(delta: Dict[float, float], total: float,
|
|
123
|
+
pct: float = 99.0) -> Tuple[float, float]:
|
|
124
|
+
"""(lower_edge, upper_edge) in SECONDS of the bucket holding the pct quantile.
|
|
125
|
+
|
|
126
|
+
Reads the cumulative counts directly: the first edge whose cumulative count
|
|
127
|
+
reaches pct% of the total. No in-bucket interpolation (a multi-modal ITL
|
|
128
|
+
distribution makes that false precision). lower_edge is 0.0 for the first
|
|
129
|
+
bucket; upper_edge is inf for the overflow bucket.
|
|
130
|
+
"""
|
|
131
|
+
les = sorted(delta)
|
|
132
|
+
target = pct / 100.0 * total
|
|
133
|
+
lower = 0.0
|
|
134
|
+
for le in les:
|
|
135
|
+
if delta[le] >= target:
|
|
136
|
+
return lower, le
|
|
137
|
+
lower = le
|
|
138
|
+
return lower, math.inf
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fmt_edge_ms(sec: float) -> str:
|
|
142
|
+
if math.isinf(sec):
|
|
143
|
+
return "inf"
|
|
144
|
+
ms = sec * 1000.0
|
|
145
|
+
return f"{ms:.0f}" if abs(ms - round(ms)) < 1e-6 else f"{ms:g}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bucket_label(lo_s: float, hi_s: float) -> str:
|
|
149
|
+
"""'(50,75]ms', or '(1,2.5]s' when the upper edge is 1 s or more (slow hardware)."""
|
|
150
|
+
if not math.isinf(hi_s) and hi_s < 1.0:
|
|
151
|
+
return f"({_fmt_edge_ms(lo_s)},{_fmt_edge_ms(hi_s)}]ms"
|
|
152
|
+
if math.isinf(hi_s) and lo_s < 1.0:
|
|
153
|
+
return f"({_fmt_edge_ms(lo_s)}ms,inf]"
|
|
154
|
+
hi = "inf" if math.isinf(hi_s) else f"{hi_s:g}"
|
|
155
|
+
return f"({lo_s:g},{hi}]s"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def smallest_finite_edge_ms(buckets: Dict[float, float]) -> Optional[float]:
|
|
159
|
+
finite = [le for le in buckets if not math.isinf(le)]
|
|
160
|
+
return min(finite) * 1000.0 if finite else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def hist_itl_gate(delta: Dict[float, float], total: float, limit_ms: float,
|
|
164
|
+
pct: float = 99.0) -> dict:
|
|
165
|
+
"""Gate a P99 inter-token-latency SLO on a histogram delta, without interpolation.
|
|
166
|
+
|
|
167
|
+
Let lo = the largest finite edge <= limit and hi = the smallest edge >= limit.
|
|
168
|
+
The share of gaps <= limit is bounded by [cum(lo), cum(hi)] / total, so:
|
|
169
|
+
PASS cum(lo) >= pct% (provably at least pct% within the limit)
|
|
170
|
+
FAIL cum(hi) < pct% (provably fewer than pct% within the limit)
|
|
171
|
+
INDETERMINATE otherwise (the limit sits inside a bucket that straddles pct%),
|
|
172
|
+
or the limit is below the smallest finite edge (no lo exists).
|
|
173
|
+
When the limit is itself an edge, lo == hi and the outcome is never
|
|
174
|
+
INDETERMINATE. The caller falls back to the client per-token value on
|
|
175
|
+
INDETERMINATE and records that it did.
|
|
176
|
+
|
|
177
|
+
Returns outcome, p99_itl_server_ms (the upper edge of the P99 bucket, numeric),
|
|
178
|
+
p99_itl_server_bucket ("(50,75]ms"), itl_gate_edge_ms (lo, None if none),
|
|
179
|
+
itl_frac_over_limit (1 - cum(lo)/total, an upper bound on the true share over
|
|
180
|
+
the limit; None if no lo), and the bucket deltas in ms.
|
|
181
|
+
"""
|
|
182
|
+
limit_s = float(limit_ms) / 1000.0
|
|
183
|
+
les = sorted(delta)
|
|
184
|
+
finite = [le for le in les if not math.isinf(le)]
|
|
185
|
+
lo = max((le for le in finite if le <= limit_s + 1e-12), default=None)
|
|
186
|
+
hi = min((le for le in les if le >= limit_s - 1e-12), default=math.inf)
|
|
187
|
+
target = pct / 100.0 * total
|
|
188
|
+
cum_lo = delta.get(lo, 0.0) if lo is not None else None
|
|
189
|
+
cum_hi = delta.get(hi, total) if not math.isinf(hi) else total
|
|
190
|
+
|
|
191
|
+
if lo is not None and cum_lo >= target:
|
|
192
|
+
outcome = "PASS"
|
|
193
|
+
elif cum_hi < target:
|
|
194
|
+
outcome = "FAIL"
|
|
195
|
+
else:
|
|
196
|
+
outcome = "INDETERMINATE"
|
|
197
|
+
|
|
198
|
+
b_lo, b_hi = hist_quantile_bucket(delta, total, pct)
|
|
199
|
+
return {
|
|
200
|
+
"outcome": outcome,
|
|
201
|
+
"p99_itl_server_ms": (math.inf if math.isinf(b_hi) else b_hi * 1000.0),
|
|
202
|
+
"p99_itl_server_bucket": bucket_label(b_lo, b_hi),
|
|
203
|
+
"itl_gate_edge_ms": (lo * 1000.0 if lo is not None else None),
|
|
204
|
+
"itl_frac_over_limit": ((1.0 - cum_lo / total) if lo is not None else None),
|
|
205
|
+
"itl_hist_delta_ms": {_fmt_edge_ms(le): delta[le] for le in les},
|
|
206
|
+
"itl_hist_count": total,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── /metrics snapshot (vLLM Prometheus text) ────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
# Counters summed over every engine / API-server label. The "external_" prefix-cache
|
|
213
|
+
# series (KV connector) is a different cache and is not summed into these.
|
|
214
|
+
_COUNTERS = {
|
|
215
|
+
"vllm:prefix_cache_hits_total": "prefix_cache_hits",
|
|
216
|
+
"vllm:prefix_cache_queries_total": "prefix_cache_queries",
|
|
217
|
+
"vllm:mm_cache_hits_total": "mm_cache_hits",
|
|
218
|
+
"vllm:mm_cache_queries_total": "mm_cache_queries",
|
|
219
|
+
"vllm:prompt_tokens_total": "prompt_tokens",
|
|
220
|
+
"vllm:generation_tokens_total": "generation_tokens",
|
|
221
|
+
"vllm:request_success_total": "request_success", # all finished_reason labels
|
|
222
|
+
"vllm:spec_decode_num_draft_tokens_total": "spec_draft_tokens",
|
|
223
|
+
}
|
|
224
|
+
_GAUGES = {
|
|
225
|
+
"vllm:num_requests_running": "running",
|
|
226
|
+
"vllm:num_requests_waiting": "waiting",
|
|
227
|
+
}
|
|
228
|
+
_ITL_BUCKET = "vllm:inter_token_latency_seconds_bucket"
|
|
229
|
+
_ITL_COUNT = "vllm:inter_token_latency_seconds_count"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_metrics(text: str) -> dict:
|
|
233
|
+
"""One /metrics scrape as {counter: total, gauge: total, "itl": (buckets, count),
|
|
234
|
+
"cache_config": {label: value}}. Missing series are absent (never 0), so a caller
|
|
235
|
+
can tell 'not exposed' from 'zero'."""
|
|
236
|
+
import re
|
|
237
|
+
|
|
238
|
+
out: dict = {}
|
|
239
|
+
buckets: dict = {}
|
|
240
|
+
itl_count = None
|
|
241
|
+
for ln in text.splitlines():
|
|
242
|
+
if not ln or ln.startswith("#"):
|
|
243
|
+
continue
|
|
244
|
+
name, _, rest = ln.partition("{") if "{" in ln.split(" ", 1)[0] else (ln.split(" ", 1)[0], "", "")
|
|
245
|
+
try:
|
|
246
|
+
value = float(ln.rsplit(" ", 1)[-1])
|
|
247
|
+
except ValueError:
|
|
248
|
+
continue
|
|
249
|
+
if name in _COUNTERS:
|
|
250
|
+
k = _COUNTERS[name]
|
|
251
|
+
out[k] = out.get(k, 0.0) + value
|
|
252
|
+
elif name in _GAUGES:
|
|
253
|
+
k = _GAUGES[name]
|
|
254
|
+
out[k] = out.get(k, 0.0) + value
|
|
255
|
+
elif name == _ITL_BUCKET:
|
|
256
|
+
m = re.search(r'le="([^"]+)"', rest)
|
|
257
|
+
if m:
|
|
258
|
+
le = math.inf if m.group(1) == "+Inf" else float(m.group(1))
|
|
259
|
+
buckets[le] = buckets.get(le, 0.0) + value
|
|
260
|
+
elif name == _ITL_COUNT:
|
|
261
|
+
itl_count = (itl_count or 0.0) + value
|
|
262
|
+
elif name == "vllm:cache_config_info" and "cache_config" not in out:
|
|
263
|
+
out["cache_config"] = dict(re.findall(r'(\w+)="([^"]*)"', rest))
|
|
264
|
+
if buckets and itl_count is not None: # an empty histogram is 0, not absent
|
|
265
|
+
out["itl"] = (buckets, itl_count)
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def counter_delta(before: Optional[dict], after: Optional[dict], key: str) -> Optional[float]:
|
|
270
|
+
"""after[key] - before[key], or None when either scrape lacks the series."""
|
|
271
|
+
if not before or not after or key not in before or key not in after:
|
|
272
|
+
return None
|
|
273
|
+
return float(after[key]) - float(before[key])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── Client-side per-token timing ───────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def per_token_tpot_ms(e2e_ms: Optional[float], ttft_ms: Optional[float],
|
|
279
|
+
completion_tokens: Optional[int]) -> Optional[float]:
|
|
280
|
+
"""Per-token TPOT of one request: (e2e - ttft) / (completion_tokens - 1).
|
|
281
|
+
|
|
282
|
+
Uses the server's token count (final usage chunk), so a streamed chunk carrying
|
|
283
|
+
several tokens can't inflate it. None without a count or with fewer than 2 tokens."""
|
|
284
|
+
if e2e_ms is None or ttft_ms is None or not completion_tokens or completion_tokens < 2:
|
|
285
|
+
return None
|
|
286
|
+
return max(0.0, (float(e2e_ms) - float(ttft_ms)) / (int(completion_tokens) - 1))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def per_token_gaps(gaps_ms: list, tokens_per_chunk: list) -> list:
|
|
290
|
+
"""Per-token gaps: each inter-chunk gap divided by the tokens the later chunk carried
|
|
291
|
+
(one value per token, so a 3-token chunk after 30 ms gives three 10 ms gaps)."""
|
|
292
|
+
out = []
|
|
293
|
+
for g, n in zip(gaps_ms, tokens_per_chunk):
|
|
294
|
+
n = max(1, int(n or 1))
|
|
295
|
+
out.extend([float(g) / n] * n)
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def bucket_index(value_ms: float, edges_s: list) -> int:
|
|
300
|
+
"""Index of the histogram bucket (sorted upper edges in seconds, +Inf last) that
|
|
301
|
+
holds value_ms."""
|
|
302
|
+
v = float(value_ms) / 1000.0
|
|
303
|
+
for k, le in enumerate(sorted(edges_s)):
|
|
304
|
+
if v <= le + 1e-12:
|
|
305
|
+
return k
|
|
306
|
+
return len(edges_s) - 1
|