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.
Files changed (266) hide show
  1. gbench/__init__.py +64 -0
  2. gbench/__main__.py +23 -0
  3. gbench/analysis/__init__.py +30 -0
  4. gbench/analysis/perf_load.py +213 -0
  5. gbench/analysis/perf_method.py +306 -0
  6. gbench/analysis/perf_sweep.py +146 -0
  7. gbench/analysis/statistics.py +367 -0
  8. gbench/canonical.py +101 -0
  9. gbench/changelog_gate.py +517 -0
  10. gbench/cli.py +4111 -0
  11. gbench/core/__init__.py +70 -0
  12. gbench/core/config.py +1380 -0
  13. gbench/core/models.py +561 -0
  14. gbench/core/scaffold.py +1831 -0
  15. gbench/docker/api_bank_translate_worker.py +83 -0
  16. gbench/docker/cyberseceval_icd_worker.py +315 -0
  17. gbench/docker/gaia2_cascade_judge.py +259 -0
  18. gbench/docker/gaia2_run.py +1815 -0
  19. gbench/docker/mcp_bench_bridge.py +372 -0
  20. gbench/docker/mcp_bench_build_check.py +133 -0
  21. gbench/docker/mcp_bench_cascade_judge.py +335 -0
  22. gbench/docker/mcp_bench_credentialed.py +415 -0
  23. gbench/docker/mcp_bench_request_policy.py +323 -0
  24. gbench/docker/mcp_bench_run.py +959 -0
  25. gbench/docker/mini_swe_agent_v2_run.py +223 -0
  26. gbench/docker/mobilegym_run.py +1489 -0
  27. gbench/docker/mswe_agent_run.py +525 -0
  28. gbench/docker/skillsbench_run.py +378 -0
  29. gbench/docker/swe_lancer_eval.py +98 -0
  30. gbench/docker/terminal_bench_verify_env.py +95 -0
  31. gbench/docker/ui_control_osworld_run.py +447 -0
  32. gbench/docker/wildclawbench_cascade_judge.py +1481 -0
  33. gbench/docker/wildclawbench_run.py +2201 -0
  34. gbench/golden_dataset/assets/starry_night.png +0 -0
  35. gbench/golden_dataset/assets/tellMeAboutTheSun.wav +0 -0
  36. gbench/golden_dataset/assets/what_color_is_cheese.wav +0 -0
  37. gbench/golden_dataset/audio_transcription_smoketest.json +49 -0
  38. gbench/golden_dataset/code_canonical.json +32 -0
  39. gbench/golden_dataset/function_call_single.json +55 -0
  40. gbench/golden_dataset/identify_person.json +35 -0
  41. gbench/golden_dataset/location_identification.json +41 -0
  42. gbench/golden_dataset/math_canonical.json +28 -0
  43. gbench/golden_dataset/multimodal_vision_landmark.json +50 -0
  44. gbench/golden_dataset/safety_boundary.json +47 -0
  45. gbench/golden_dataset/structured_json.json +27 -0
  46. gbench/golden_dataset/template_multi_turn.json +49 -0
  47. gbench/golden_dataset/template_multi_turn_twin.json +33 -0
  48. gbench/golden_dataset/template_tool_response.json +78 -0
  49. gbench/golden_dataset/template_tool_response_twin.json +34 -0
  50. gbench/golden_dataset/tool_call_minimal.json +48 -0
  51. gbench/golden_dataset/translation_romance.json +39 -0
  52. gbench/golden_dataset/two_rivers_zurich.json +38 -0
  53. gbench/merge.py +811 -0
  54. gbench/pool.py +1135 -0
  55. gbench/pool_durations.json +487 -0
  56. gbench/runners/__init__.py +32 -0
  57. gbench/runners/eval_suites/__init__.py +367 -0
  58. gbench/runners/eval_suites/base.py +5485 -0
  59. gbench/runners/eval_suites/canonical_sync.py +5919 -0
  60. gbench/runners/eval_suites/container_guard.py +566 -0
  61. gbench/runners/eval_suites/custom_jsonl.py +205 -0
  62. gbench/runners/eval_suites/dataset_utils.py +146 -0
  63. gbench/runners/eval_suites/extraction_common.py +159 -0
  64. gbench/runners/eval_suites/fc_spec_validator.py +136 -0
  65. gbench/runners/eval_suites/gemini_budget.py +102 -0
  66. gbench/runners/eval_suites/gemini_relay.py +668 -0
  67. gbench/runners/eval_suites/limit_plan.py +197 -0
  68. gbench/runners/eval_suites/loader.py +115 -0
  69. gbench/runners/eval_suites/math_equiv.py +196 -0
  70. gbench/runners/eval_suites/metrics.py +261 -0
  71. gbench/runners/eval_suites/mswe_harness_build.py +339 -0
  72. gbench/runners/eval_suites/sampling.py +273 -0
  73. gbench/runners/eval_suites/sandbox.py +408 -0
  74. gbench/runners/eval_suites/search_tool.py +1005 -0
  75. gbench/runners/eval_suites/swe_agentic.py +1746 -0
  76. gbench/runners/eval_suites/swe_thread_cap.py +246 -0
  77. gbench/runners/eval_suites/swebench_common.py +4766 -0
  78. gbench/runners/eval_suites/tau_common.py +4640 -0
  79. gbench/runners/eval_suites/terminus_salvage_patch.py +145 -0
  80. gbench/runners/evals.py +1281 -0
  81. gbench/runners/golden.py +1435 -0
  82. gbench/runners/perf_payload.py +294 -0
  83. gbench/runners/quality.py +2289 -0
  84. gbench/runners/serving.py +2929 -0
  85. gbench/runners/stress.py +2748 -0
  86. gbench/runners/throughput.py +1211 -0
  87. gbench/smoke.py +1441 -0
  88. gbench/third_party/__init__.py +15 -0
  89. gbench/third_party/_acebench_vendor/LICENSE +21 -0
  90. gbench/third_party/_acebench_vendor/__init__.py +104 -0
  91. gbench/third_party/_acebench_vendor/multi_step_prompts.py +60 -0
  92. gbench/third_party/_acebench_vendor/multi_turn_prompts.py +104 -0
  93. gbench/third_party/_acebench_vendor/prompt_en.py +143 -0
  94. gbench/third_party/_acebench_vendor/prompt_zh.py +146 -0
  95. gbench/third_party/_acebench_vendor/scenariosen/__init__.py +1 -0
  96. gbench/third_party/_acebench_vendor/scenariosen/phone_platform/__init__.py +1 -0
  97. gbench/third_party/_acebench_vendor/scenariosen/phone_platform/base_api.py +27 -0
  98. gbench/third_party/_acebench_vendor/scenariosen/phone_platform/food_services.py +253 -0
  99. gbench/third_party/_acebench_vendor/scenariosen/phone_platform/message.py +286 -0
  100. gbench/third_party/_acebench_vendor/scenariosen/phone_platform/reminder.py +143 -0
  101. gbench/third_party/_acebench_vendor/scenariosen/travel.py +530 -0
  102. gbench/third_party/_acebench_vendor/scenarioszh/__init__.py +1 -0
  103. gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/__init__.py +1 -0
  104. gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/base_api.py +26 -0
  105. gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/food_services.py +247 -0
  106. gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/message.py +287 -0
  107. gbench/third_party/_acebench_vendor/scenarioszh/phone_platform/reminder.py +177 -0
  108. gbench/third_party/_acebench_vendor/scenarioszh/travel.py +548 -0
  109. gbench/third_party/_json_repair_vendor/LICENSE +21 -0
  110. gbench/third_party/_json_repair_vendor/__init__.py +4 -0
  111. gbench/third_party/_json_repair_vendor/json_context.py +43 -0
  112. gbench/third_party/_json_repair_vendor/json_parser.py +891 -0
  113. gbench/third_party/_json_repair_vendor/json_repair.py +277 -0
  114. gbench/third_party/_json_repair_vendor/object_comparer.py +55 -0
  115. gbench/third_party/_json_repair_vendor/string_file_wrapper.py +119 -0
  116. gbench/third_party/_scicode_vendor/LICENSE +201 -0
  117. gbench/third_party/_scicode_vendor/__init__.py +26 -0
  118. gbench/third_party/_scicode_vendor/scicode/__init__.py +16 -0
  119. gbench/third_party/_scicode_vendor/scicode/compare/__init__.py +3 -0
  120. gbench/third_party/_scicode_vendor/scicode/compare/cmp.py +78 -0
  121. gbench/third_party/_scicode_vendor/scicode/parse/__init__.py +2 -0
  122. gbench/third_party/_scicode_vendor/scicode/parse/parse.py +100 -0
  123. gbench/third_party/_teval_vendor/LICENSE +201 -0
  124. gbench/third_party/_teval_vendor/__init__.py +14 -0
  125. gbench/third_party/_teval_vendor/format_load.py +50 -0
  126. gbench/third_party/_teval_vendor/instruct_evaluator.py +162 -0
  127. gbench/third_party/_teval_vendor/planning_evaluator.py +409 -0
  128. gbench/third_party/_teval_vendor/reason_retrieve_understand_evaluator.py +463 -0
  129. gbench/third_party/_teval_vendor/review_evaluator.py +139 -0
  130. gbench/third_party/_teval_vendor/schema.py +25 -0
  131. gbench/third_party/_teval_vendor/template.py +82 -0
  132. gbench/third_party/eval_suites/LICENSE +293 -0
  133. gbench/third_party/eval_suites/_complexfuncbench_data/LICENSE +201 -0
  134. gbench/third_party/eval_suites/_complexfuncbench_data/exact_match_values.json +232 -0
  135. gbench/third_party/eval_suites/_complexfuncbench_data/tool_info.json +56 -0
  136. gbench/third_party/eval_suites/_ifeval_vendor/LICENSE +201 -0
  137. gbench/third_party/eval_suites/_ifeval_vendor/__init__.py +23 -0
  138. gbench/third_party/eval_suites/_ifeval_vendor/evaluation_lib.py +220 -0
  139. gbench/third_party/eval_suites/_ifeval_vendor/instructions.py +1570 -0
  140. gbench/third_party/eval_suites/_ifeval_vendor/instructions_registry.py +176 -0
  141. gbench/third_party/eval_suites/_ifeval_vendor/instructions_util.py +147 -0
  142. gbench/third_party/eval_suites/_swelancer_adapter/predictions_solver.py +158 -0
  143. gbench/third_party/eval_suites/_swelancer_adapter/run_swelancer_eval.py +92 -0
  144. gbench/third_party/eval_suites/aa_lcr.py +564 -0
  145. gbench/third_party/eval_suites/acebench.py +2987 -0
  146. gbench/third_party/eval_suites/agent_dojo.py +966 -0
  147. gbench/third_party/eval_suites/aider_polyglot.py +1536 -0
  148. gbench/third_party/eval_suites/aime.py +208 -0
  149. gbench/third_party/eval_suites/amc_aime.py +232 -0
  150. gbench/third_party/eval_suites/api_bank.py +1131 -0
  151. gbench/third_party/eval_suites/arc_agi.py +708 -0
  152. gbench/third_party/eval_suites/beam_128k.py +857 -0
  153. gbench/third_party/eval_suites/bfcl.py +2005 -0
  154. gbench/third_party/eval_suites/bfcl_live.py +158 -0
  155. gbench/third_party/eval_suites/bfcl_v4_agentic.py +3585 -0
  156. gbench/third_party/eval_suites/bigcodebench.py +952 -0
  157. gbench/third_party/eval_suites/browsecomp.py +408 -0
  158. gbench/third_party/eval_suites/bundled_detection.py +503 -0
  159. gbench/third_party/eval_suites/causalbench.py +371 -0
  160. gbench/third_party/eval_suites/chartqa.py +171 -0
  161. gbench/third_party/eval_suites/charxiv.py +744 -0
  162. gbench/third_party/eval_suites/charxiv_constants.py +531 -0
  163. gbench/third_party/eval_suites/cimemories.py +1470 -0
  164. gbench/third_party/eval_suites/coco_caption.py +397 -0
  165. gbench/third_party/eval_suites/codeforces.py +699 -0
  166. gbench/third_party/eval_suites/complexfuncbench.py +2157 -0
  167. gbench/third_party/eval_suites/copilot_bench_swe.py +119 -0
  168. gbench/third_party/eval_suites/cruxeval.py +1135 -0
  169. gbench/third_party/eval_suites/cyberseceval.py +766 -0
  170. gbench/third_party/eval_suites/deepsearch_qa.py +619 -0
  171. gbench/third_party/eval_suites/docvqa.py +193 -0
  172. gbench/third_party/eval_suites/fc_common.py +1836 -0
  173. gbench/third_party/eval_suites/frames.py +276 -0
  174. gbench/third_party/eval_suites/gaia.py +1007 -0
  175. gbench/third_party/eval_suites/gaia2.py +1449 -0
  176. gbench/third_party/eval_suites/gdpval.py +1447 -0
  177. gbench/third_party/eval_suites/gorilla_apibench.py +533 -0
  178. gbench/third_party/eval_suites/gpqa.py +451 -0
  179. gbench/third_party/eval_suites/gpqa_diamond.py +69 -0
  180. gbench/third_party/eval_suites/gsm8k.py +349 -0
  181. gbench/third_party/eval_suites/healthbench.py +505 -0
  182. gbench/third_party/eval_suites/hmmt.py +800 -0
  183. gbench/third_party/eval_suites/humanitys_last_exam.py +524 -0
  184. gbench/third_party/eval_suites/i18n_translate.py +487 -0
  185. gbench/third_party/eval_suites/ifeval.py +306 -0
  186. gbench/third_party/eval_suites/imo_answer_bench.py +385 -0
  187. gbench/third_party/eval_suites/infographicvqa.py +191 -0
  188. gbench/third_party/eval_suites/lab_bench.py +550 -0
  189. gbench/third_party/eval_suites/lcb.py +3269 -0
  190. gbench/third_party/eval_suites/livebench.py +1830 -0
  191. gbench/third_party/eval_suites/loft_scifact.py +368 -0
  192. gbench/third_party/eval_suites/longbench_v2_code.py +1096 -0
  193. gbench/third_party/eval_suites/matharena_common.py +724 -0
  194. gbench/third_party/eval_suites/mcp_atlas.py +3259 -0
  195. gbench/third_party/eval_suites/mcp_bench.py +2141 -0
  196. gbench/third_party/eval_suites/medxpertqa.py +501 -0
  197. gbench/third_party/eval_suites/mmlu.py +634 -0
  198. gbench/third_party/eval_suites/mmlu_pro.py +803 -0
  199. gbench/third_party/eval_suites/mmlu_redux.py +714 -0
  200. gbench/third_party/eval_suites/mmmu_pro.py +483 -0
  201. gbench/third_party/eval_suites/mobilegym.py +856 -0
  202. gbench/third_party/eval_suites/mobilegym_pp.py +71 -0
  203. gbench/third_party/eval_suites/mrcr.py +440 -0
  204. gbench/third_party/eval_suites/multi_swe_bench.py +1472 -0
  205. gbench/third_party/eval_suites/multilingual_mmlu.py +283 -0
  206. gbench/third_party/eval_suites/multipl_e.py +793 -0
  207. gbench/third_party/eval_suites/nestful.py +1509 -0
  208. gbench/third_party/eval_suites/nexusraven_api_eval.py +593 -0
  209. gbench/third_party/eval_suites/ojbench.py +644 -0
  210. gbench/third_party/eval_suites/omnidoc_metrics.py +245 -0
  211. gbench/third_party/eval_suites/omnidocbench.py +746 -0
  212. gbench/third_party/eval_suites/putnam.py +515 -0
  213. gbench/third_party/eval_suites/putnam_formal.py +1791 -0
  214. gbench/third_party/eval_suites/ruler.py +561 -0
  215. gbench/third_party/eval_suites/scicode.py +1353 -0
  216. gbench/third_party/eval_suites/scicode_data/13.6.txt +50 -0
  217. gbench/third_party/eval_suites/scicode_data/62.1.txt +29 -0
  218. gbench/third_party/eval_suites/scicode_data/76.3.txt +27 -0
  219. gbench/third_party/eval_suites/scicode_data/ATTRIBUTION.md +13 -0
  220. gbench/third_party/eval_suites/scicode_data/LICENSE +201 -0
  221. gbench/third_party/eval_suites/screenspot.py +287 -0
  222. gbench/third_party/eval_suites/screenspot_pointing.py +349 -0
  223. gbench/third_party/eval_suites/seal_tools.py +276 -0
  224. gbench/third_party/eval_suites/simpleqa.py +553 -0
  225. gbench/third_party/eval_suites/skillsbench.py +1192 -0
  226. gbench/third_party/eval_suites/spider2.py +674 -0
  227. gbench/third_party/eval_suites/swe_bench_live.py +2878 -0
  228. gbench/third_party/eval_suites/swe_bench_multilingual.py +122 -0
  229. gbench/third_party/eval_suites/swe_bench_pro.py +1710 -0
  230. gbench/third_party/eval_suites/swe_lancer.py +607 -0
  231. gbench/third_party/eval_suites/t_eval.py +697 -0
  232. gbench/third_party/eval_suites/tau2.py +140 -0
  233. gbench/third_party/eval_suites/tau3.py +150 -0
  234. gbench/third_party/eval_suites/terminal_bench.py +2364 -0
  235. gbench/third_party/eval_suites/textvqa.py +207 -0
  236. gbench/third_party/eval_suites/toolbench.py +2321 -0
  237. gbench/third_party/eval_suites/ui_control_osworld.py +858 -0
  238. gbench/third_party/eval_suites/vqa_common.py +673 -0
  239. gbench/third_party/eval_suites/wildbench.py +1885 -0
  240. gbench/third_party/eval_suites/wildclawbench.py +1725 -0
  241. gbench/third_party/eval_suites/wmdp.py +541 -0
  242. gbench/third_party/matharena_src/LICENSE +21 -0
  243. gbench/third_party/matharena_src/configs/competitions/aime/aime_2026.yaml +6 -0
  244. gbench/third_party/matharena_src/configs/competitions/hmmt/hmmt_feb_2026.yaml +6 -0
  245. gbench/third_party/matharena_src/matharena/__init__.py +10 -0
  246. gbench/third_party/matharena_src/matharena/grader.py +245 -0
  247. gbench/third_party/matharena_src/matharena/parse_manual.py +73 -0
  248. gbench/third_party/matharena_src/matharena/parser.py +1133 -0
  249. gbench/third_party/matharena_src/matharena/tools/lean_execution.py +513 -0
  250. gbench/third_party/matharena_src/matharena/tools/submit_answer.py +126 -0
  251. gbench/third_party/matharena_src/matharena/utils.py +383 -0
  252. gbench/utils/__init__.py +45 -0
  253. gbench/utils/calibrate.py +480 -0
  254. gbench/utils/dependency_checks.py +114 -0
  255. gbench/utils/endpoint.py +278 -0
  256. gbench/utils/endpoint_lock.py +232 -0
  257. gbench/utils/log_manager.py +605 -0
  258. gbench/utils/process_groups.py +148 -0
  259. gbench/utils/redaction.py +32 -0
  260. gbench-0.5.0.dist-info/METADATA +267 -0
  261. gbench-0.5.0.dist-info/RECORD +266 -0
  262. gbench-0.5.0.dist-info/WHEEL +5 -0
  263. gbench-0.5.0.dist-info/entry_points.txt +2 -0
  264. gbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  265. gbench-0.5.0.dist-info/licenses/NOTICE +491 -0
  266. 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