benchscope 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. benchscope/__init__.py +3 -0
  2. benchscope/__main__.py +4 -0
  3. benchscope/benches/__init__.py +1 -0
  4. benchscope/benches/base.py +54 -0
  5. benchscope/benches/runner.py +185 -0
  6. benchscope/benches/sglang_bench.py +77 -0
  7. benchscope/benches/vllm_bench.py +94 -0
  8. benchscope/cli.py +46 -0
  9. benchscope/config.py +91 -0
  10. benchscope/constants.py +49 -0
  11. benchscope/datasets.py +200 -0
  12. benchscope/gpu.py +39 -0
  13. benchscope/parser.py +93 -0
  14. benchscope/server/__init__.py +3 -0
  15. benchscope/server/api_config.py +97 -0
  16. benchscope/server/api_logs.py +356 -0
  17. benchscope/server/api_test.py +64 -0
  18. benchscope/server/app.py +99 -0
  19. benchscope/server/state.py +18 -0
  20. benchscope/server/status.py +97 -0
  21. benchscope/server/test_manager.py +367 -0
  22. benchscope/server/ws.py +56 -0
  23. benchscope/summary.py +148 -0
  24. benchscope/webui/assets/LogView-BDFIduo7.css +1 -0
  25. benchscope/webui/assets/LogView-BMsVPLVq.js +1 -0
  26. benchscope/webui/assets/MetricsCharts-D1wU0LbK.css +1 -0
  27. benchscope/webui/assets/MetricsCharts-DHO93JrC.js +1 -0
  28. benchscope/webui/assets/SettingsView-7uuTFqXU.css +1 -0
  29. benchscope/webui/assets/SettingsView-DJmFSQbU.js +1 -0
  30. benchscope/webui/assets/TestView-81VlxBZU.js +4 -0
  31. benchscope/webui/assets/TestView-BAwcOtR2.css +1 -0
  32. benchscope/webui/assets/antd-DWALckI0.js +478 -0
  33. benchscope/webui/assets/echarts-Bb6yjXMn.js +60 -0
  34. benchscope/webui/assets/index-Dfr0hp72.js +2 -0
  35. benchscope/webui/assets/index-EhoNt9Gv.css +1 -0
  36. benchscope/webui/assets/vue-Ch4zjUb1.js +37 -0
  37. benchscope/webui/index.html +19 -0
  38. benchscope-1.0.0.dist-info/METADATA +136 -0
  39. benchscope-1.0.0.dist-info/RECORD +43 -0
  40. benchscope-1.0.0.dist-info/WHEEL +5 -0
  41. benchscope-1.0.0.dist-info/entry_points.txt +2 -0
  42. benchscope-1.0.0.dist-info/licenses/LICENSE +176 -0
  43. benchscope-1.0.0.dist-info/top_level.txt +1 -0
benchscope/datasets.py ADDED
@@ -0,0 +1,200 @@
1
+ """数据集管理:ShareGPT 自动下载(modelscope)、自定义数据集上传与本地路径。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import shutil
7
+ import urllib.parse
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import requests
12
+
13
+ from benchscope.constants import SHAREGPT_DATASET_ID
14
+
15
+ log = logging.getLogger("benchscope.datasets")
16
+
17
+ # 期望的 ShareGPT 文件(下载源)与转换后的 jsonl 文件名
18
+ SHAREGPT_SOURCE_CANDIDATES = [
19
+ "ShareGPT_V3_unfiltered_cleaned_split.json",
20
+ "ShareGPT_V3_unfiltered_cleaned_split.jsonl",
21
+ "sharegpt_v3_unfiltered_cleaned_split.jsonl",
22
+ "sharegpt.jsonl",
23
+ ]
24
+ SHAREGPT_JSONL_NAME = "ShareGPT_V3_unfiltered_cleaned_split.jsonl"
25
+
26
+ MODELSCOPE_API = "https://modelscope.cn/api/v1/datasets/{namespace}/{name}/repo"
27
+
28
+
29
+ def _split_dataset_id(dataset_id: str) -> tuple[str, str]:
30
+ parts = dataset_id.split("/", 1)
31
+ if len(parts) != 2:
32
+ raise ValueError(f"无效的 modelscope 数据集 id: {dataset_id}")
33
+ return parts[0], parts[1]
34
+
35
+
36
+ def list_dataset_files(dataset_id: str, revision: str = "master", timeout: int = 30) -> list[dict]:
37
+ """通过 modelscope HTTP API 列出数据集文件(/repo/tree)。"""
38
+ namespace, name = _split_dataset_id(dataset_id)
39
+ url = MODELSCOPE_API.format(namespace=namespace, name=name) + "/tree"
40
+ params = {"Revision": revision, "Root": "", "Recursive": "true"}
41
+ resp = requests.get(url, params=params, timeout=timeout)
42
+ resp.raise_for_status()
43
+ data = resp.json()
44
+ files = (data.get("Data") or {}).get("Files") or []
45
+ out = []
46
+ for f in files:
47
+ if isinstance(f, dict) and f.get("Type") == "blob":
48
+ out.append(f)
49
+ return out
50
+
51
+
52
+ def convert_json_array_to_jsonl(src: Path, dst: Path) -> Path:
53
+ """流式把 JSON 数组文件转换为 jsonl(每行一个对象),内存占用低。
54
+
55
+ 逐字符状态机跟踪对象边界与字符串(正确处理字符串内的 {} 与转义);
56
+ 每个对象归一化为单行 json 输出(vllm/sglang 按行 json.loads 解析)。
57
+ """
58
+ dst.parent.mkdir(parents=True, exist_ok=True)
59
+ tmp = dst.with_suffix(dst.suffix + ".part")
60
+ with open(src, "r", encoding="utf-8", errors="replace") as fin, open(tmp, "w", encoding="utf-8") as fout:
61
+ obj_chars: list[str] = []
62
+ depth = 0
63
+ in_string = False
64
+ escaped = False
65
+ for chunk in iter(lambda: fin.read(65536), ""):
66
+ for ch in chunk:
67
+ if in_string:
68
+ if escaped:
69
+ escaped = False
70
+ elif ch == "\\":
71
+ escaped = True
72
+ elif ch == '"':
73
+ in_string = False
74
+ else:
75
+ if ch == '"':
76
+ in_string = True
77
+ elif ch == "{":
78
+ depth += 1
79
+ if depth == 1:
80
+ obj_chars = ["{"]
81
+ continue
82
+ elif ch == "}":
83
+ depth -= 1
84
+ if depth == 0:
85
+ try:
86
+ obj = json.loads("".join(obj_chars) + "}")
87
+ fout.write(json.dumps(obj, ensure_ascii=False) + "\n")
88
+ except Exception:
89
+ pass # 跳过损坏对象
90
+ obj_chars = []
91
+ continue
92
+ if depth > 0:
93
+ obj_chars.append(ch)
94
+ tmp.replace(dst)
95
+ return dst
96
+
97
+
98
+ def download_file(
99
+ dataset_id: str, file_path: str, dest: Path, revision: str = "master", timeout: int = 60
100
+ ) -> Path:
101
+ """下载 modelscope 数据集中的单个文件。"""
102
+ namespace, name = _split_dataset_id(dataset_id)
103
+ url = MODELSCOPE_API.format(namespace=namespace, name=name)
104
+ params = {"Revision": revision, "FilePath": file_path}
105
+ dest.parent.mkdir(parents=True, exist_ok=True)
106
+ tmp = dest.with_suffix(dest.suffix + ".part")
107
+ with requests.get(url, params=params, stream=True, timeout=timeout) as resp:
108
+ resp.raise_for_status()
109
+ with open(tmp, "wb") as f:
110
+ for chunk in resp.iter_content(chunk_size=1 << 20):
111
+ f.write(chunk)
112
+ tmp.replace(dest)
113
+ return dest
114
+
115
+
116
+ def ensure_sharegpt(datasets_dir: Path, force: bool = False) -> Path:
117
+ """确保 ShareGPT jsonl 已下载并转换缓存,返回 jsonl 路径。"""
118
+ cache_dir = datasets_dir / "sharegpt"
119
+ cache_dir.mkdir(parents=True, exist_ok=True)
120
+ jsonl_path = cache_dir / SHAREGPT_JSONL_NAME
121
+
122
+ # 已缓存的 jsonl
123
+ if not force and jsonl_path.exists() and jsonl_path.stat().st_size > 0:
124
+ return jsonl_path
125
+
126
+ # 已下载但未转换的源文件
127
+ source = None
128
+ if not force:
129
+ for cand in SHAREGPT_SOURCE_CANDIDATES:
130
+ p = cache_dir / cand
131
+ if p.exists() and p.stat().st_size > 0:
132
+ source = p
133
+ break
134
+ if source and jsonl_path.exists() and jsonl_path.stat().st_size > 0:
135
+ return jsonl_path
136
+
137
+ # 尝试 modelscope SDK(若安装)
138
+ try:
139
+ from modelscope import snapshot_download # type: ignore
140
+
141
+ root = Path(snapshot_download(SHAREGPT_DATASET_ID, cache_dir=str(cache_dir)))
142
+ if source is None:
143
+ for cand in SHAREGPT_SOURCE_CANDIDATES:
144
+ p = root / cand
145
+ if p.exists():
146
+ source = p
147
+ break
148
+ if source is None:
149
+ jsonls = sorted(root.rglob("*.json*"))
150
+ if jsonls:
151
+ source = jsonls[0]
152
+ except ImportError:
153
+ pass
154
+ except Exception as e:
155
+ log.warning("modelscope SDK 下载失败,回退 HTTP 下载: %s", e)
156
+
157
+ # HTTP API 方式
158
+ if source is None:
159
+ files = list_dataset_files(SHAREGPT_DATASET_ID)
160
+ cands = []
161
+ for f in files:
162
+ path = f.get("Path") or ""
163
+ if path.endswith((".json", ".jsonl")):
164
+ cands.append((path, f.get("Size") or 0))
165
+ if not cands:
166
+ raise FileNotFoundError(f"数据集 {SHAREGPT_DATASET_ID} 中未找到数据文件: {files[:5]}")
167
+ # 优先文件名含 sharegpt,其次最大文件
168
+ cands.sort(key=lambda x: (0 if "sharegpt" in x[0].lower() else 1, -x[1]))
169
+ candidate, cand_size = cands[0]
170
+ source = cache_dir / Path(candidate).name
171
+ if not source.exists() or source.stat().st_size == 0 or force:
172
+ log.info("正在从 modelscope 下载 %s(约 %.0f MB)...", candidate, cand_size / 1e6)
173
+ download_file(SHAREGPT_DATASET_ID, candidate, source, timeout=300)
174
+
175
+ # 转换为 jsonl
176
+ if jsonl_path.exists() and jsonl_path.stat().st_size > 0 and not force:
177
+ return jsonl_path
178
+ if source.suffix.lower() == ".jsonl":
179
+ if source != jsonl_path:
180
+ import shutil as _shutil
181
+
182
+ _shutil.copyfile(source, jsonl_path)
183
+ else:
184
+ log.info("正在转换 %s 为 jsonl ...", source.name)
185
+ convert_json_array_to_jsonl(source, jsonl_path)
186
+ return jsonl_path
187
+
188
+
189
+ def is_valid_jsonl(path: Path, sample: int = 2000) -> bool:
190
+ """粗略校验 jsonl 文件格式。"""
191
+ try:
192
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
193
+ for _ in range(sample):
194
+ line = f.readline()
195
+ if not line:
196
+ break
197
+ json.loads(line)
198
+ return True
199
+ except Exception:
200
+ return False
benchscope/gpu.py ADDED
@@ -0,0 +1,39 @@
1
+ """GPU 信息自动获取(nvidia-smi),失败时回退手动配置。"""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ from typing import Optional
7
+
8
+
9
+ def detect_gpu() -> Optional[dict]:
10
+ """尝试自动获取 GPU 型号与数量。成功返回 {"name":..., "count":N},失败返回 None。"""
11
+ nvidia_smi = shutil.which("nvidia-smi")
12
+ if not nvidia_smi:
13
+ return None
14
+ try:
15
+ result = subprocess.run(
16
+ [nvidia_smi, "--query-gpu=name,count", "--format=csv,noheader,nounits"],
17
+ capture_output=True,
18
+ text=True,
19
+ timeout=10,
20
+ )
21
+ if result.returncode != 0:
22
+ return None
23
+ lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
24
+ if not lines:
25
+ return None
26
+ names = [ln.split(",")[0].strip() for ln in lines]
27
+ counts = []
28
+ for ln in lines:
29
+ parts = ln.split(",")
30
+ if len(parts) > 1:
31
+ try:
32
+ counts.append(int(parts[1].strip()))
33
+ except ValueError:
34
+ pass
35
+ name = names[0] if names else ""
36
+ count = sum(counts) if counts else len(lines)
37
+ return {"name": name, "count": count}
38
+ except Exception:
39
+ return None
benchscope/parser.py ADDED
@@ -0,0 +1,93 @@
1
+ """bench 输出解析:同时解析 mean 与 P99 两套指标。
2
+
3
+ 兼容 vLLM `vllm bench serve` 与 SGLang `sglang.bench_serving` 两种输出格式。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Optional
9
+
10
+ # 指标键(统一命名)
11
+ METRIC_KEYS = [
12
+ "output",
13
+ "peakoutput",
14
+ "total",
15
+ "ttft",
16
+ "itl",
17
+ "tpot",
18
+ ]
19
+
20
+ # 每个指标在输出中的可能标签(vllm / sglang 写法不同)
21
+ _PATTERNS: list[tuple[str, str, re.Pattern]] = []
22
+
23
+
24
+ def _add(kind: str, metric: str, regex: str):
25
+ _PATTERNS.append((kind, metric, re.compile(regex, re.IGNORECASE)))
26
+
27
+
28
+ # ---- vLLM bench serve ----
29
+ _add("mean", "output", r"Output token throughput \(tok/s\):\s+([\d.]+)")
30
+ _add("mean", "peakoutput", r"Peak output token throughput \(tok/s\):\s+([\d.]+)")
31
+ _add("mean", "total", r"Total token throughput \(tok/s\):\s+([\d.]+)")
32
+ _add("mean", "ttft", r"Mean TTFT \(ms\):\s+([\d.]+)")
33
+ _add("p99", "ttft", r"P99 TTFT \(ms\):\s+([\d.]+)")
34
+ _add("mean", "tpot", r"Mean TPOT \(ms\):\s+([\d.]+)")
35
+ _add("p99", "tpot", r"P99 TPOT \(ms\):\s+([\d.]+)")
36
+ _add("mean", "itl", r"Mean ITL \(ms\):\s+([\d.]+)")
37
+ _add("p99", "itl", r"P99 ITL \(ms\):\s+([\d.]+)")
38
+ _add("concurrency", "concurrency", r"Maximum request concurrency:\s+(\d+)")
39
+
40
+ # ---- SGLang bench_serving ----
41
+ _add("mean", "output", r"Output token throughput \(tok/s\):\s+([\d.]+)")
42
+ _add("mean", "total", r"Total token throughput \(tok/s\):\s+([\d.]+)")
43
+ _add("mean", "ttft", r"Time to first token \(TTFT\) mean \(ms\):\s+([\d.]+)")
44
+ _add("p99", "ttft", r"Time to first token \(TTFT\) p99 \(ms\):\s+([\d.]+)")
45
+ _add("mean", "tpot", r"Time per output token \(TPOT\) mean \(ms\):\s+([\d.]+)")
46
+ _add("p99", "tpot", r"Time per output token \(TPOT\) p99 \(ms\):\s+([\d.]+)")
47
+ _add("mean", "itl", r"Inter-token latency \(ITL\) mean \(ms\):\s+([\d.]+)")
48
+ _add("p99", "itl", r"Inter-token latency \(ITL\) p99 \(ms\):\s+([\d.]+)")
49
+
50
+ # 请求吞吐(req/s),sglang/vllm 均输出
51
+ _add("mean", "req_per_s", r"Request throughput \(req/s\):\s+([\d.]+)")
52
+
53
+
54
+ def parse_metrics(output: str) -> dict:
55
+ """解析 bench 输出,返回统一指标字典。
56
+
57
+ 返回结构:
58
+ {
59
+ "concurrency": int,
60
+ "output": float, "peakoutput": float, "total": float,
61
+ "ttft_mean": float, "tpot_mean": float, "itl_mean": float,
62
+ "ttft_p99": float, "tpot_p99": float, "itl_p99": float,
63
+ "req_per_s": float, "raw": str(完整原始输出)
64
+ }
65
+ """
66
+ metrics: dict = {}
67
+ for kind, metric, pattern in _PATTERNS:
68
+ m = pattern.search(output)
69
+ if m:
70
+ key = metric if kind == "concurrency" else f"{metric}_{kind}"
71
+ try:
72
+ value = float(m.group(1)) if "." in m.group(1) else int(m.group(1))
73
+ except ValueError:
74
+ value = float(m.group(1))
75
+ metrics[key] = value
76
+
77
+ # 兼容只有 mean 没有 p99 的情况:p99 缺省回退 mean
78
+ for metric in ("ttft", "tpot", "itl"):
79
+ mean_key = f"{metric}_mean"
80
+ p99_key = f"{metric}_p99"
81
+ if mean_key in metrics and p99_key not in metrics:
82
+ metrics[p99_key] = metrics[mean_key]
83
+
84
+ # 单用户 QPS = 1000 / tpot_mean(README 定义)
85
+ if "tpot_mean" in metrics and metrics["tpot_mean"] > 0:
86
+ metrics["single_user"] = round(1000.0 / metrics["tpot_mean"], 2)
87
+ metrics["raw"] = output
88
+ return metrics
89
+
90
+
91
+ def has_result_block(output: str) -> bool:
92
+ """判断输出中是否包含结果块(可能成功或失败)。"""
93
+ return "Serving Benchmark Result" in output or "Benchmark Result" in output
@@ -0,0 +1,3 @@
1
+ from benchscope.server.app import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,97 @@
1
+ """配置 / 模型 / GPU / 状态 相关 API。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ import requests
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+ from benchscope.constants import STATUS_READY
11
+ from benchscope.gpu import detect_gpu
12
+ from benchscope.server.state import state
13
+
14
+ log = logging.getLogger("benchscope.api_config")
15
+
16
+ router = APIRouter(prefix="/api/config", tags=["config"])
17
+
18
+
19
+ @router.get("")
20
+ def get_config():
21
+ return state.config.snapshot()
22
+
23
+
24
+ class ConfigPatch(BaseModel):
25
+ api: dict | None = None
26
+ gpu: dict | None = None
27
+ logs_dir: str | None = None
28
+ datasets_dir: str | None = None
29
+ tpot_threshold_ms: float | None = None
30
+ request_rate: str | None = None
31
+ bench_commands: dict | None = None
32
+ framework: str | None = None
33
+
34
+
35
+ @router.post("")
36
+ def update_config(patch: ConfigPatch):
37
+ data = patch.model_dump(exclude_none=True)
38
+ state.config.update(data)
39
+ return state.config.snapshot()
40
+
41
+
42
+ @router.get("/status")
43
+ def get_status():
44
+ snap = state.monitor.check_once(broadcast=False)
45
+ snap["web"] = STATUS_READY
46
+ return snap
47
+
48
+
49
+ @router.get("/models")
50
+ def get_models():
51
+ """返回推理服务当前模型列表(优先用状态缓存,必要时实时探测)。"""
52
+ snap = state.monitor.check_once(broadcast=False)
53
+ if not snap["models"] and snap["error"]:
54
+ raise HTTPException(status_code=502, detail=f"推理服务不可达: {snap['error']}")
55
+ return {"models": snap["models"], "inference": snap["inference"], "error": snap["error"]}
56
+
57
+
58
+ class ConnTest(BaseModel):
59
+ base_url: str
60
+ endpoint: str = "/v1/chat/completions"
61
+ api_key: str = ""
62
+ extra_headers: dict = {}
63
+
64
+
65
+ @router.post("/test-connection")
66
+ def test_connection(req: ConnTest):
67
+ base = req.base_url.rstrip("/")
68
+ headers = {}
69
+ if req.api_key:
70
+ headers["Authorization"] = f"Bearer {req.api_key}"
71
+ headers.update(req.extra_headers or {})
72
+ try:
73
+ resp = requests.get(f"{base}/v1/models", headers=headers, timeout=6)
74
+ resp.raise_for_status()
75
+ models = [m.get("id") for m in resp.json().get("data", []) if m.get("id")]
76
+ return {"ok": True, "models": models}
77
+ except Exception as e:
78
+ return {"ok": False, "error": str(e)[:300]}
79
+
80
+
81
+ @router.get("/gpu")
82
+ def get_gpu():
83
+ auto = detect_gpu()
84
+ cfg = state.config.get("gpu", {"auto": True, "name": "", "count": 8})
85
+ return {"auto_detected": auto, "config": cfg}
86
+
87
+
88
+ @router.get("/params/{framework}")
89
+ def get_params(framework: str):
90
+ """返回指定框架的可配置参数定义(前端表单渲染用)。"""
91
+ if framework == "sglang":
92
+ from benchscope.benches.sglang_bench import CURATED_PARAMS
93
+ elif framework == "vllm":
94
+ from benchscope.benches.vllm_bench import CURATED_PARAMS
95
+ else:
96
+ raise HTTPException(status_code=404, detail="未知框架")
97
+ return {"framework": framework, "params": [p.__dict__ for p in CURATED_PARAMS]}