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.
- benchscope/__init__.py +3 -0
- benchscope/__main__.py +4 -0
- benchscope/benches/__init__.py +1 -0
- benchscope/benches/base.py +54 -0
- benchscope/benches/runner.py +185 -0
- benchscope/benches/sglang_bench.py +77 -0
- benchscope/benches/vllm_bench.py +94 -0
- benchscope/cli.py +46 -0
- benchscope/config.py +91 -0
- benchscope/constants.py +49 -0
- benchscope/datasets.py +200 -0
- benchscope/gpu.py +39 -0
- benchscope/parser.py +93 -0
- benchscope/server/__init__.py +3 -0
- benchscope/server/api_config.py +97 -0
- benchscope/server/api_logs.py +356 -0
- benchscope/server/api_test.py +64 -0
- benchscope/server/app.py +99 -0
- benchscope/server/state.py +18 -0
- benchscope/server/status.py +97 -0
- benchscope/server/test_manager.py +367 -0
- benchscope/server/ws.py +56 -0
- benchscope/summary.py +148 -0
- benchscope/webui/assets/LogView-BDFIduo7.css +1 -0
- benchscope/webui/assets/LogView-BMsVPLVq.js +1 -0
- benchscope/webui/assets/MetricsCharts-D1wU0LbK.css +1 -0
- benchscope/webui/assets/MetricsCharts-DHO93JrC.js +1 -0
- benchscope/webui/assets/SettingsView-7uuTFqXU.css +1 -0
- benchscope/webui/assets/SettingsView-DJmFSQbU.js +1 -0
- benchscope/webui/assets/TestView-81VlxBZU.js +4 -0
- benchscope/webui/assets/TestView-BAwcOtR2.css +1 -0
- benchscope/webui/assets/antd-DWALckI0.js +478 -0
- benchscope/webui/assets/echarts-Bb6yjXMn.js +60 -0
- benchscope/webui/assets/index-Dfr0hp72.js +2 -0
- benchscope/webui/assets/index-EhoNt9Gv.css +1 -0
- benchscope/webui/assets/vue-Ch4zjUb1.js +37 -0
- benchscope/webui/index.html +19 -0
- benchscope-1.0.0.dist-info/METADATA +136 -0
- benchscope-1.0.0.dist-info/RECORD +43 -0
- benchscope-1.0.0.dist-info/WHEEL +5 -0
- benchscope-1.0.0.dist-info/entry_points.txt +2 -0
- benchscope-1.0.0.dist-info/licenses/LICENSE +176 -0
- benchscope-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""日志管理 API:运行目录列表、预览、下载、数据集上传、汇总解析、分析数据。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
11
|
+
from fastapi.responses import FileResponse, PlainTextResponse
|
|
12
|
+
|
|
13
|
+
from benchscope.server.state import state
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("benchscope.api_logs")
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
|
18
|
+
|
|
19
|
+
TEXT_SUFFIXES = {".log", ".csv", ".txt", ".json", ".md", ".sh", ".py"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ----------------------------------------------------------------------
|
|
23
|
+
# 运行列表
|
|
24
|
+
@router.get("/runs")
|
|
25
|
+
def list_runs():
|
|
26
|
+
logs_dir = state.config.logs_dir
|
|
27
|
+
runs = []
|
|
28
|
+
if logs_dir.exists():
|
|
29
|
+
for d in sorted(logs_dir.iterdir(), key=lambda p: p.name, reverse=True):
|
|
30
|
+
if not d.is_dir():
|
|
31
|
+
continue
|
|
32
|
+
files = sorted(
|
|
33
|
+
(p.name, p.stat().st_size) for p in d.iterdir() if p.is_file()
|
|
34
|
+
)
|
|
35
|
+
run_info = _load_run_json(d)
|
|
36
|
+
runs.append({
|
|
37
|
+
"run_id": d.name,
|
|
38
|
+
"dir": str(d),
|
|
39
|
+
"files": [{"name": n, "size": s} for n, s in files],
|
|
40
|
+
"meta": {
|
|
41
|
+
"framework": (run_info or {}).get("framework_name"),
|
|
42
|
+
"model": (run_info or {}).get("model"),
|
|
43
|
+
"status": (run_info or {}).get("status"),
|
|
44
|
+
"started_at": (run_info or {}).get("started_at"),
|
|
45
|
+
"finished_at": (run_info or {}).get("finished_at"),
|
|
46
|
+
} if run_info else {},
|
|
47
|
+
})
|
|
48
|
+
return {"runs": runs}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_run_json(d: Path) -> dict | None:
|
|
52
|
+
p = d / "run.json"
|
|
53
|
+
if p.exists():
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/runs/{run_id}")
|
|
62
|
+
def get_run(run_id: str):
|
|
63
|
+
d = _resolve_run_dir(run_id)
|
|
64
|
+
run_info = _load_run_json(d) or {}
|
|
65
|
+
files = sorted((p.name, p.stat().st_size) for p in d.iterdir() if p.is_file())
|
|
66
|
+
return {"run_id": run_id, "dir": str(d), "files": files, "run": run_info}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ----------------------------------------------------------------------
|
|
70
|
+
# 预览 / 下载
|
|
71
|
+
@router.get("/runs/{run_id}/preview")
|
|
72
|
+
def preview_file(run_id: str, name: str, tail: int = 500):
|
|
73
|
+
d = _resolve_run_dir(run_id)
|
|
74
|
+
p = _resolve_file(d, name)
|
|
75
|
+
suffix = p.suffix.lower()
|
|
76
|
+
if suffix not in TEXT_SUFFIXES and name not in ("run.json",):
|
|
77
|
+
raise HTTPException(status_code=400, detail="该文件不是文本文件,请下载查看")
|
|
78
|
+
try:
|
|
79
|
+
content = p.read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
except Exception as e:
|
|
81
|
+
raise HTTPException(status_code=500, detail=f"读取失败: {e}")
|
|
82
|
+
lines = content.splitlines()
|
|
83
|
+
if len(lines) > tail:
|
|
84
|
+
preview = "\n".join(lines[-tail:])
|
|
85
|
+
truncated = len(lines) - tail
|
|
86
|
+
else:
|
|
87
|
+
preview = content
|
|
88
|
+
truncated = 0
|
|
89
|
+
return {"name": name, "truncated": truncated, "total_lines": len(lines), "content": preview}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/runs/{run_id}/download")
|
|
93
|
+
def download_file(run_id: str, name: str):
|
|
94
|
+
d = _resolve_run_dir(run_id)
|
|
95
|
+
p = _resolve_file(d, name)
|
|
96
|
+
return FileResponse(p, filename=name)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ----------------------------------------------------------------------
|
|
100
|
+
# 自定义数据集上传
|
|
101
|
+
@router.get("/datasets")
|
|
102
|
+
def list_datasets():
|
|
103
|
+
ds_dir = state.config.datasets_dir / "uploads"
|
|
104
|
+
files = []
|
|
105
|
+
if ds_dir.exists():
|
|
106
|
+
for p in sorted(ds_dir.iterdir(), key=lambda x: x.name):
|
|
107
|
+
if p.is_file():
|
|
108
|
+
files.append({"name": p.name, "size": p.stat().st_size, "path": str(p)})
|
|
109
|
+
return {"datasets": files, "dir": str(ds_dir)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/datasets/upload")
|
|
113
|
+
async def upload_dataset(file: UploadFile = File(...)):
|
|
114
|
+
ds_dir = state.config.datasets_dir / "uploads"
|
|
115
|
+
ds_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
dest = ds_dir / (file.filename or "dataset.jsonl")
|
|
117
|
+
dest = _unique_path(dest)
|
|
118
|
+
content = await file.read()
|
|
119
|
+
dest.write_bytes(content)
|
|
120
|
+
return {"ok": True, "name": dest.name, "path": str(dest), "size": len(content)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.delete("/datasets/{name}")
|
|
124
|
+
def delete_dataset(name: str):
|
|
125
|
+
p = state.config.datasets_dir / "uploads" / name
|
|
126
|
+
if p.exists() and p.is_file():
|
|
127
|
+
p.unlink()
|
|
128
|
+
return {"ok": True}
|
|
129
|
+
raise HTTPException(status_code=404, detail="文件不存在")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ----------------------------------------------------------------------
|
|
133
|
+
# 汇总 / 分析数据
|
|
134
|
+
@router.get("/runs/{run_id}/summary")
|
|
135
|
+
def run_summary(run_id: str, threshold: float | None = None):
|
|
136
|
+
"""返回该次运行的 mean / P99 两套记录与分析数据。
|
|
137
|
+
|
|
138
|
+
优先读 run.json(含完整指标),否则回退解析汇总 CSV。
|
|
139
|
+
"""
|
|
140
|
+
d = _resolve_run_dir(run_id)
|
|
141
|
+
run_info = _load_run_json(d) or {}
|
|
142
|
+
threshold = threshold if threshold is not None else run_info.get("tpot_threshold_ms")
|
|
143
|
+
|
|
144
|
+
records = _records_from_run(run_info, d)
|
|
145
|
+
if not records:
|
|
146
|
+
return {
|
|
147
|
+
"run_id": run_id, "records_mean": [], "records_p99": [],
|
|
148
|
+
"best_mean": {}, "best_p99": {}, "threshold": threshold, "meta": run_info,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
mean_rows = _to_display_rows(records, "mean")
|
|
152
|
+
p99_rows = _to_display_rows(records, "p99")
|
|
153
|
+
return {
|
|
154
|
+
"run_id": run_id,
|
|
155
|
+
"records_mean": mean_rows,
|
|
156
|
+
"records_p99": p99_rows,
|
|
157
|
+
"records": _to_merged_rows(records),
|
|
158
|
+
"best_mean": _find_best(mean_rows, threshold),
|
|
159
|
+
"best_p99": _find_best(p99_rows, threshold),
|
|
160
|
+
"threshold": threshold,
|
|
161
|
+
"meta": run_info,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _to_merged_rows(records: list[dict]) -> list[dict]:
|
|
166
|
+
"""合并 mean / P99 的展示行(日志项面板用)。"""
|
|
167
|
+
out = []
|
|
168
|
+
for r in records:
|
|
169
|
+
m = r.get("metrics", {})
|
|
170
|
+
out.append({
|
|
171
|
+
"label": r.get("label") or r.get("case"),
|
|
172
|
+
"input_len": r.get("input_len"),
|
|
173
|
+
"output_len": r.get("output_len"),
|
|
174
|
+
"concurrency": r.get("concurrency"),
|
|
175
|
+
"output_mean": m.get("output_mean", m.get("output")),
|
|
176
|
+
"peakoutput_mean": m.get("peakoutput_mean", m.get("peakoutput")),
|
|
177
|
+
"total_mean": m.get("total_mean", m.get("total")),
|
|
178
|
+
"ttft_mean": m.get("ttft_mean", m.get("ttft")),
|
|
179
|
+
"tpot_mean": m.get("tpot_mean", m.get("tpot")),
|
|
180
|
+
"itl_mean": m.get("itl_mean", m.get("itl")),
|
|
181
|
+
"ttft_p99": m.get("ttft_p99", m.get("ttft")),
|
|
182
|
+
"tpot_p99": m.get("tpot_p99", m.get("tpot")),
|
|
183
|
+
"itl_p99": m.get("itl_p99", m.get("itl")),
|
|
184
|
+
"single_user": m.get("single_user"),
|
|
185
|
+
})
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _records_from_run(run_info: dict, d: Path) -> list[dict]:
|
|
190
|
+
rows = run_info.get("rows") or []
|
|
191
|
+
if rows:
|
|
192
|
+
return [r for r in rows if isinstance(r, dict) and "metrics" in r]
|
|
193
|
+
# 回退:解析 CSV
|
|
194
|
+
csvs = sorted(d.glob("*_p99.log")) or sorted(d.glob("*.log"))
|
|
195
|
+
for csv in csvs:
|
|
196
|
+
parsed = parse_summary_csv(csv)
|
|
197
|
+
if parsed:
|
|
198
|
+
return parsed
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _to_display_rows(records: list[dict], key: str) -> list[dict]:
|
|
203
|
+
out = []
|
|
204
|
+
for r in records:
|
|
205
|
+
m = r.get("metrics", {})
|
|
206
|
+
out.append({
|
|
207
|
+
"label": r.get("label") or r.get("case"),
|
|
208
|
+
"input_len": r.get("input_len"),
|
|
209
|
+
"output_len": r.get("output_len"),
|
|
210
|
+
"concurrency": r.get("concurrency"),
|
|
211
|
+
# 吞吐指标无 p99 变体,两种块都取 mean
|
|
212
|
+
"output": m.get(f"output_{key}", m.get("output_mean", m.get("output"))),
|
|
213
|
+
"peakoutput": m.get(f"peakoutput_{key}", m.get("peakoutput_mean", m.get("peakoutput"))),
|
|
214
|
+
"total": m.get(f"total_{key}", m.get("total_mean", m.get("total"))),
|
|
215
|
+
"ttft": m.get(f"ttft_{key}", m.get("ttft")),
|
|
216
|
+
"itl": m.get(f"itl_{key}", m.get("itl")),
|
|
217
|
+
"tpot": m.get(f"tpot_{key}", m.get("tpot")),
|
|
218
|
+
"single_user": m.get("single_user"),
|
|
219
|
+
})
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _find_best(rows: list[dict], threshold) -> dict:
|
|
224
|
+
if not rows:
|
|
225
|
+
return {}
|
|
226
|
+
try:
|
|
227
|
+
threshold = float(threshold)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
threshold = None
|
|
230
|
+
if threshold is None:
|
|
231
|
+
return {}
|
|
232
|
+
by_case: dict = {}
|
|
233
|
+
for r in rows:
|
|
234
|
+
by_case.setdefault(r.get("label"), []).append(r)
|
|
235
|
+
best = {}
|
|
236
|
+
for label, items in by_case.items():
|
|
237
|
+
valid = [(float(r["tpot"]), r) for r in items if r.get("tpot") is not None]
|
|
238
|
+
if not valid:
|
|
239
|
+
continue
|
|
240
|
+
below = [(t, r) for t, r in valid if t < threshold]
|
|
241
|
+
if below:
|
|
242
|
+
t, r = max(below, key=lambda x: x[0])
|
|
243
|
+
else:
|
|
244
|
+
t, r = min(valid, key=lambda x: x[0])
|
|
245
|
+
best[label] = {"concurrency": r["concurrency"], "tpot": t, "row": r}
|
|
246
|
+
return best
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ----------------------------------------------------------------------
|
|
250
|
+
def parse_summary_csv(path: Path) -> list[dict]:
|
|
251
|
+
"""解析汇总 CSV(含用例块头)为记录列表。"""
|
|
252
|
+
records = []
|
|
253
|
+
try:
|
|
254
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
255
|
+
except Exception:
|
|
256
|
+
return records
|
|
257
|
+
cur = None
|
|
258
|
+
for ln in lines:
|
|
259
|
+
m = re.match(r"测试条件:(\S+)\s*\|\s*输入=(\d+)?\s*\|\s*输出=(\d+)", ln)
|
|
260
|
+
if m:
|
|
261
|
+
cur = {"label": m.group(1), "input_len": int(m.group(2)) if m.group(2) else None,
|
|
262
|
+
"output_len": int(m.group(3)) if m.group(3) else None}
|
|
263
|
+
continue
|
|
264
|
+
if ln.startswith("并发数,") or ln.startswith("=") or not ln.strip():
|
|
265
|
+
continue
|
|
266
|
+
parts = [x.strip() for x in ln.split(",")]
|
|
267
|
+
if len(parts) >= 7 and cur:
|
|
268
|
+
try:
|
|
269
|
+
conc = int(parts[0])
|
|
270
|
+
except ValueError:
|
|
271
|
+
continue
|
|
272
|
+
values = []
|
|
273
|
+
for v in parts[1:7]:
|
|
274
|
+
try:
|
|
275
|
+
values.append(float(v))
|
|
276
|
+
except ValueError:
|
|
277
|
+
values.append(None)
|
|
278
|
+
records.append({
|
|
279
|
+
"label": cur["label"], "input_len": cur["input_len"], "output_len": cur["output_len"],
|
|
280
|
+
"concurrency": conc,
|
|
281
|
+
"metrics": {
|
|
282
|
+
"output": values[0], "peakoutput": values[1], "total": values[2],
|
|
283
|
+
"ttft": values[3], "tpot": values[4], "itl": values[5],
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
return records
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ----------------------------------------------------------------------
|
|
290
|
+
# ShareGPT 数据集下载(modelscope)
|
|
291
|
+
_SHAREGPT_STATE = {"state": "idle", "path": None, "error": None, "thread": None}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _sharegpt_cached_path() -> str | None:
|
|
295
|
+
from benchscope.datasets import SHAREGPT_JSONL_NAME
|
|
296
|
+
|
|
297
|
+
p = state.config.datasets_dir / "sharegpt" / SHAREGPT_JSONL_NAME
|
|
298
|
+
if p.exists() and p.stat().st_size > 0:
|
|
299
|
+
return str(p)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@router.get("/datasets/sharegpt")
|
|
304
|
+
def sharegpt_status():
|
|
305
|
+
cached = _sharegpt_cached_path()
|
|
306
|
+
if cached and _SHAREGPT_STATE["state"] == "idle":
|
|
307
|
+
_SHAREGPT_STATE.update(state="done", path=cached, error=None)
|
|
308
|
+
return dict(_SHAREGPT_STATE)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@router.post("/datasets/sharegpt/download")
|
|
312
|
+
def sharegpt_download():
|
|
313
|
+
if _SHAREGPT_STATE["thread"] and _SHAREGPT_STATE["thread"].is_alive():
|
|
314
|
+
return dict(_SHAREGPT_STATE)
|
|
315
|
+
from benchscope.datasets import ensure_sharegpt
|
|
316
|
+
|
|
317
|
+
def _work():
|
|
318
|
+
try:
|
|
319
|
+
_SHAREGPT_STATE.update(state="downloading", error=None)
|
|
320
|
+
path = ensure_sharegpt(state.config.datasets_dir, force=True)
|
|
321
|
+
_SHAREGPT_STATE.update(state="done", path=str(path), error=None)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
log.exception("sharegpt 下载失败")
|
|
324
|
+
_SHAREGPT_STATE.update(state="error", error=str(e)[:300])
|
|
325
|
+
|
|
326
|
+
_SHAREGPT_STATE["thread"] = threading.Thread(target=_work, daemon=True)
|
|
327
|
+
_SHAREGPT_STATE["thread"].start()
|
|
328
|
+
return dict(_SHAREGPT_STATE)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ----------------------------------------------------------------------
|
|
332
|
+
def _resolve_run_dir(run_id: str) -> Path:
|
|
333
|
+
logs_dir = state.config.logs_dir
|
|
334
|
+
d = logs_dir / run_id
|
|
335
|
+
if not d.is_dir():
|
|
336
|
+
raise HTTPException(status_code=404, detail=f"运行记录不存在: {run_id}")
|
|
337
|
+
return d
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resolve_file(d: Path, name: str) -> Path:
|
|
341
|
+
p = (d / name).resolve()
|
|
342
|
+
if not p.is_file() or not str(p).startswith(str(d.resolve())):
|
|
343
|
+
raise HTTPException(status_code=404, detail="文件不存在")
|
|
344
|
+
return p
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _unique_path(p: Path) -> Path:
|
|
348
|
+
if not p.exists():
|
|
349
|
+
return p
|
|
350
|
+
stem, suffix = p.stem, p.suffix
|
|
351
|
+
i = 1
|
|
352
|
+
while True:
|
|
353
|
+
cand = p.with_name(f"{stem}_{i}{suffix}")
|
|
354
|
+
if not cand.exists():
|
|
355
|
+
return cand
|
|
356
|
+
i += 1
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""测试启停与进度 API。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from benchscope.server.state import state
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("benchscope.api_test")
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/test", tags=["test"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StartRequest(BaseModel):
|
|
17
|
+
framework: str = "vllm"
|
|
18
|
+
model: str = ""
|
|
19
|
+
tokenizer: str = ""
|
|
20
|
+
dataset: dict = {}
|
|
21
|
+
concurrency_list: list = []
|
|
22
|
+
gpu: dict = {}
|
|
23
|
+
request_rate: str | float = "inf"
|
|
24
|
+
tpot_threshold_ms: float | None = None
|
|
25
|
+
precision: str = ""
|
|
26
|
+
curated: dict = {}
|
|
27
|
+
extra_args: list = []
|
|
28
|
+
force: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/start")
|
|
32
|
+
def start_test(req: StartRequest):
|
|
33
|
+
try:
|
|
34
|
+
run = state.tests.start(req.model_dump())
|
|
35
|
+
return {"ok": True, "run_id": run.run_id, "run": run.snapshot()}
|
|
36
|
+
except RuntimeError as e:
|
|
37
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.post("/preview")
|
|
41
|
+
def preview_test(req: StartRequest):
|
|
42
|
+
"""预览将要执行的命令(不实际运行)。"""
|
|
43
|
+
from benchscope.server.test_manager import build_command_lines
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
lines = build_command_lines(req.model_dump(), state.config)
|
|
47
|
+
return {"ok": True, "commands": lines, "count": len(lines)}
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/stop")
|
|
53
|
+
def stop_test():
|
|
54
|
+
state.tests.stop()
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/status")
|
|
59
|
+
def test_status():
|
|
60
|
+
run = state.tests.current
|
|
61
|
+
return {
|
|
62
|
+
"running": state.tests.running,
|
|
63
|
+
"run": run.snapshot() if run else None,
|
|
64
|
+
}
|
benchscope/server/app.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""FastAPI 应用装配:API 路由 + WebSocket + 前端静态托管。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from benchscope.server import api_config, api_logs, api_test
|
|
14
|
+
from benchscope.server.state import state
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("benchscope.app")
|
|
17
|
+
|
|
18
|
+
WEBUI_DIR = Path(__file__).resolve().parent.parent / "webui"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def lifespan(app: FastAPI):
|
|
23
|
+
state.monitor.start()
|
|
24
|
+
yield
|
|
25
|
+
state.monitor.stop()
|
|
26
|
+
state.tests.stop()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app() -> FastAPI:
|
|
30
|
+
app = FastAPI(
|
|
31
|
+
title="benchscope",
|
|
32
|
+
description="vLLM / SGLang 推理服务性能测试工具",
|
|
33
|
+
version="0.1.0",
|
|
34
|
+
lifespan=lifespan,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
app.add_middleware(
|
|
38
|
+
CORSMiddleware,
|
|
39
|
+
allow_origins=["*"],
|
|
40
|
+
allow_credentials=True,
|
|
41
|
+
allow_methods=["*"],
|
|
42
|
+
allow_headers=["*"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
app.include_router(api_config.router)
|
|
46
|
+
app.include_router(api_test.router)
|
|
47
|
+
app.include_router(api_logs.router)
|
|
48
|
+
|
|
49
|
+
# ---------------- WebSocket ----------------
|
|
50
|
+
@app.websocket("/ws")
|
|
51
|
+
async def websocket_endpoint(ws: WebSocket):
|
|
52
|
+
await ws.accept()
|
|
53
|
+
import asyncio
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
loop = asyncio.get_running_loop()
|
|
57
|
+
state.hub.register(ws, loop)
|
|
58
|
+
try:
|
|
59
|
+
# 连接后立即推送一次状态
|
|
60
|
+
snap = state.monitor.check_once(broadcast=False)
|
|
61
|
+
snap["web"] = "ready"
|
|
62
|
+
await ws.send_text(json.dumps({"type": "status", "status": snap}, ensure_ascii=False))
|
|
63
|
+
run = state.tests.current
|
|
64
|
+
if run:
|
|
65
|
+
await ws.send_text(
|
|
66
|
+
json.dumps({"type": "run_snapshot", "run": run.snapshot()}, ensure_ascii=False)
|
|
67
|
+
)
|
|
68
|
+
while True:
|
|
69
|
+
await ws.receive_text() # 保持连接,忽略客户端消息
|
|
70
|
+
except WebSocketDisconnect:
|
|
71
|
+
pass
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
state.hub.unregister(ws)
|
|
76
|
+
|
|
77
|
+
# ---------------- 前端静态托管 ----------------
|
|
78
|
+
assets_dir = WEBUI_DIR / "assets"
|
|
79
|
+
if assets_dir.is_dir():
|
|
80
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
|
81
|
+
|
|
82
|
+
@app.get("/", include_in_schema=False)
|
|
83
|
+
def index():
|
|
84
|
+
return _spa_response()
|
|
85
|
+
|
|
86
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
87
|
+
def spa_fallback(full_path: str):
|
|
88
|
+
if full_path.startswith("api/") or full_path == "ws":
|
|
89
|
+
raise HTTPException(status_code=404, detail="Not Found")
|
|
90
|
+
return _spa_response()
|
|
91
|
+
|
|
92
|
+
return app
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _spa_response():
|
|
96
|
+
index = WEBUI_DIR / "index.html"
|
|
97
|
+
if index.exists():
|
|
98
|
+
return FileResponse(index)
|
|
99
|
+
raise HTTPException(status_code=404, detail="前端未构建,请先运行 `npm run build` 或使用 Vite dev server")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""服务端全局状态单例。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from benchscope.config import ConfigManager
|
|
5
|
+
from benchscope.server.status import StatusMonitor
|
|
6
|
+
from benchscope.server.test_manager import TestManager
|
|
7
|
+
from benchscope.server.ws import WebSocketHub
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AppState:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.config = ConfigManager()
|
|
13
|
+
self.hub = WebSocketHub()
|
|
14
|
+
self.monitor = StatusMonitor(self.config, self.hub)
|
|
15
|
+
self.tests = TestManager(self.config, self.hub)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
state = AppState()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""推理服务状态监控:心跳探测 /v1/models,广播状态变化。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from benchscope.constants import STATUS_OFFLINE, STATUS_READY
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("benchscope.status")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StatusMonitor:
|
|
17
|
+
INTERVAL = 5.0
|
|
18
|
+
|
|
19
|
+
def __init__(self, config, hub):
|
|
20
|
+
self.config = config
|
|
21
|
+
self.hub = hub
|
|
22
|
+
self._stop = threading.Event()
|
|
23
|
+
self._thread: Optional[threading.Thread] = None
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self.status = {
|
|
26
|
+
"web": STATUS_READY,
|
|
27
|
+
"inference": STATUS_OFFLINE,
|
|
28
|
+
"last_check": None,
|
|
29
|
+
"models": [],
|
|
30
|
+
"error": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
def start(self) -> None:
|
|
35
|
+
if self._thread and self._thread.is_alive():
|
|
36
|
+
return
|
|
37
|
+
self._stop.clear()
|
|
38
|
+
self._thread = threading.Thread(target=self._loop, name="status-monitor", daemon=True)
|
|
39
|
+
self._thread.start()
|
|
40
|
+
|
|
41
|
+
def stop(self) -> None:
|
|
42
|
+
self._stop.set()
|
|
43
|
+
if self._thread:
|
|
44
|
+
self._thread.join(timeout=3)
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
def _loop(self) -> None:
|
|
48
|
+
while not self._stop.is_set():
|
|
49
|
+
try:
|
|
50
|
+
self.check_once(broadcast=True)
|
|
51
|
+
except Exception:
|
|
52
|
+
log.exception("状态探测异常")
|
|
53
|
+
self._stop.wait(self.INTERVAL)
|
|
54
|
+
|
|
55
|
+
def check_once(self, broadcast: bool = False) -> dict:
|
|
56
|
+
api = self.config.api
|
|
57
|
+
base = (api.get("base_url") or "").rstrip("/")
|
|
58
|
+
headers = {}
|
|
59
|
+
if api.get("api_key"):
|
|
60
|
+
headers["Authorization"] = f"Bearer {api['api_key']}"
|
|
61
|
+
headers.update(api.get("extra_headers") or {})
|
|
62
|
+
|
|
63
|
+
models: list[str] = []
|
|
64
|
+
error = None
|
|
65
|
+
inference = STATUS_READY
|
|
66
|
+
if not base:
|
|
67
|
+
inference, error = STATUS_OFFLINE, "未配置 API 地址"
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
resp = requests.get(f"{base}/v1/models", headers=headers, timeout=4)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
data = resp.json()
|
|
73
|
+
for item in data.get("data", []):
|
|
74
|
+
mid = item.get("id")
|
|
75
|
+
if mid and mid not in models:
|
|
76
|
+
models.append(mid)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
inference = STATUS_OFFLINE
|
|
79
|
+
error = str(e)[:300]
|
|
80
|
+
|
|
81
|
+
changed = False
|
|
82
|
+
with self._lock:
|
|
83
|
+
old = self.status["inference"]
|
|
84
|
+
self.status.update(
|
|
85
|
+
inference=inference, last_check=time.strftime("%H:%M:%S"),
|
|
86
|
+
models=models, error=error,
|
|
87
|
+
)
|
|
88
|
+
changed = old != inference or bool(self.status["models"]) != bool(models)
|
|
89
|
+
snapshot = dict(self.status)
|
|
90
|
+
|
|
91
|
+
if broadcast and changed:
|
|
92
|
+
self.hub.broadcast({"type": "status", "status": snapshot})
|
|
93
|
+
return snapshot
|
|
94
|
+
|
|
95
|
+
def snapshot(self) -> dict:
|
|
96
|
+
with self._lock:
|
|
97
|
+
return dict(self.status)
|