diff --git a/API.md b/API.md index 15c7aa5..04f66be 100644 --- a/API.md +++ b/API.md @@ -130,9 +130,10 @@ | `name` | string | `""` | 测试名称/主题(会存入测试记录并展示在历史与详情) | | `context_lengths` | number[] | `[512,2048,4096,8192,16384,32768,65536,131072]` | 要测试的上下文长度列表,每个长度独立校准+预热+采样 | | `max_tokens` | number | `128` | 解码输出 token 长度 | -| `samples` | number | `2` | 每个上下文长度的采样次数 | -| `warmup` | bool | `true` | 测试前空转预热(不计速度) | -| `avoid_cache` | bool | `true` | 随机前缀避免缓存命中 | +| `samples` | number | `2` | 每个(长度×并发)组合的采样次数 | +| `concurrency_levels` | number[] | `[1]` | 并发数列表(默认单流)。>1 时每采样同时发起 N 个并行流,聚合为整批吞吐指标;多档自动并排对比 | +| `warmup` | bool | `true` | 测试前空转预热(不计速度,按并发数预热) | +| `avoid_cache` | bool | `true` | 随机前缀避免缓存命中(每个并发流独立前缀) | **响应:** `{ "ok": true, "id": 9 }` @@ -169,9 +170,10 @@ "id": 9, "created_at": "...", "status": "done", "provider": "openai", "model": "...", "name": "...", "error": "", "config": { "base_url": "...", "api_key": "sk-x****", ... }, - "gen": { "name": "...", "context_lengths": [512, 2048], "max_tokens": 128, "samples": 1, "warmup": true, "avoid_cache": true }, + "gen": { "name": "...", "context_lengths": [512, 2048], "max_tokens": 128, "samples": 1, "concurrency_levels": [1, 2, 4], "warmup": true, "avoid_cache": true }, "summary": { - "samples_total": 2, "samples_ok": 2, + "samples_total": 6, "samples_ok": 6, + "concurrency_levels": [1, 2, 4], "calibration_chars_per_token": 1.82, "avg_ttft_ms": 1808.7, "min_ttft_ms": 1122.8, "max_ttft_ms": 2494.6, "avg_prefill_speed": 694.2, "min_prefill_speed": 515.7, "max_prefill_speed": 872.7, @@ -179,9 +181,14 @@ "avg_prompt_tokens": 1378.0, "avg_output_tokens": 128.0, "avg_total_ms": 4148.4, "min_total_ms": 3449.9, "max_total_ms": 4846.9, "by_length": { - "512": { "samples_total": 1, "samples_ok": 1, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 }, - "2048": { "samples_total": 1, "samples_ok": 1, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 54.4, "avg_prompt_tokens": 2177, "avg_output_tokens": 128, "avg_total_ms": 4846.9 } - } + "512": { "samples_total": 3, "samples_ok": 3, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 }, + "2048": { "samples_total": 3, "samples_ok": 3, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 54.4, "avg_prompt_tokens": 2177, "avg_output_tokens": 128, "avg_total_ms": 4846.9 } + }, + "by_concurrency": { + "1": { "samples_total": 2, "samples_ok": 2, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_stream_decode": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 }, + "2": { "samples_total": 2, "samples_ok": 2, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 108.0, "avg_stream_decode": 54.0, "avg_prompt_tokens": 1158, "avg_output_tokens": 256, "avg_total_ms": 4846.9 } + }, + "by_length_concurrency": { "512": { "1": {...}, "2": {...} }, "2048": {...} } }, "runs": [ { "run_index": 1, "context_length": 512, @@ -245,6 +252,52 @@ ``` > `csv` 即画图数据(第一列=上下文长度,第二列=预填充速度,第三列=解码速度),前端「复制画图数据」按钮复制的就是它。 +### `GET /api/tests//concurrency-chart` +并发对比折线图 PNG(X 轴=**并发数**,左轴=预填充速度虚线、右轴=解码速度实线),用于直观展示吞吐随并发的变化。仅当本次测试包含**多个并发档**时才有数据。 + +**失败响应:** `{ "ok": false, "error": "无并发分组采样数据(本次测试可能只测了单流),无法画图" }`(400) + +### `GET /api/tests//concurrency-chart-data` +并发对比画图数据(CSV + 图表请求配置)。 + +```json +{ + "ok": true, + "csv": "并发数, 预填充速度(tok/s), 解码速度(tok/s)\n1, 767.30, 43.40\n2, 1388.20, 70.90\n4, 2085.00, 78.70", + "rows": [[1, 767.3, 43.4], [2, 1388.2, 70.9], [4, 2085.0, 78.7]], + "payload": { "data": "...", "chartType": "line", "title": "...", "dualYAxis": true, ... } +} +``` + +### `POST /api/chart` +通用图表代理:把任意 data-chart-tool `/api/chart` 请求体转发过去并返回 PNG(多测试对比面板用)。请求体即 data-chart-tool 的参数(`data`/`chartType`/`seriesTypes`/`seriesStyles`/`dualYAxis`…)。 + +**成功响应:** `Content-Type: image/png` + +### `POST /api/compare` +把多个测试结果放在一起对比。 + +**请求:** `{ "ids": [9, 10, 11] }`(最多 20 个) + +**响应:** +```json +{ + "ok": true, + "rows": [ { "id": 9, "label": "#9 xxx", "created_at": "...", "name": "...", "model": "...", + "concurrency_levels": [1, 2, 4], "by_concurrency": {...}, + "samples_ok": 6, "samples_total": 6, + "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, + "avg_stream_decode": 55.0, "avg_output_tokens": 128, "avg_total_ms": 3449.9 } ], + "bar_csv": "测试, 预填充速度(tok/s), 解码速度(tok/s)\n#9 xxx, 515.70, 55.00", + "bar_payload": { "data": "...", "chartType": "bar", "seriesTypes": ["bar", "bar"], "seriesStyles": ["hollow", "solid"], ... }, + "line": { // 仅当所选测试存在 >=2 个共同并发档时返回,否则为 null + "csv": "并发数, #9 xxx, #10 yyy\n1, 55.00, 52.10\n2, 108.00, 99.30\n4, 190.20, 175.60", + "payload": { "data": "...", "chartType": "line", ... }, + "levels": [1, 2, 4] + } +} +``` + --- ## 7. 导出(Excel / JSON) @@ -252,8 +305,8 @@ ### `GET /api/tests//export.xlsx` 导出 Excel 报告(**3 个 Sheet**:汇总 / 采样明细 / 日志),`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`。 -- **汇总**:测试信息 + 整体统计指标(平均/最大/最小)+ 按上下文长度分组 -- **采样明细**:每次采样的上下文长度与全部指标 +- **汇总**:测试信息(含并发数列表)+ 整体统计指标(平均/最大/最小)+ 按上下文长度分组 + **按并发数分组**(整批吞吐,含单流均解码) +- **采样明细**:每次采样的上下文长度、**并发**、流成功/总数与全部指标 - **日志**:完整测试日志 ### `GET /api/tests//export.json` @@ -267,12 +320,14 @@ |----|------|----------| | `configs` | 保存的接口配置 | id, name, provider, base_url, api_key, model, temperature | | `tests` | 测试记录 | id, status(running/done/error/canceled), provider, model, **name**, config_json, gen_cfg_json, summary_json, error | -| `test_runs` | 每次采样 | id, test_id, run_index, **context_length**, metrics_json, error | +| `test_runs` | 每次采样 | id, test_id, run_index, **context_length**, metrics_json, error(metrics 含 `concurrency`/`streams_total`/`streams_ok`/`avg_stream_decode`/`streams`(每流明细)) | | `logs` | 测试日志 | id, test_id, level, msg, rel | **summary 整体指标字段:** `avg_/min_/max_` 前缀 × `ttft_ms` / `prefill_speed` / `decode_speed` / `total_ms`,以及 `avg_prompt_tokens` / `avg_output_tokens` / `avg_cached_tokens` / `best_ttft_ms`(= min_ttft_ms)。 +**分组字段:** `by_length`(按上下文长度)、`by_concurrency`(按并发数,含 `avg_stream_decode` 单流均解码)、`by_length_concurrency`(长度×并发全网格)、`concurrency_levels`(本次测试的并发档列表)。 + --- ## 9. curl 使用示例 @@ -291,10 +346,10 @@ curl -X POST $BASE/api/configs -H 'Content-Type: application/json' \ curl -X POST $BASE/api/configs/test -H 'Content-Type: application/json' \ -d '{"provider":"openai","base_url":"http://121.40.164.32:18003/v1","api_key":"sk-xxx","model":"unsloth/Qwen3.8-27B-Q4_K_M"}' -# 启动速度测试(异步) +# 启动速度测试(异步,含多并发档) curl -X POST $BASE/api/tests -H 'Content-Type: application/json' -d '{ "config": {"provider":"openai","base_url":"http://121.40.164.32:18003/v1","api_key":"sk-xxx","model":"unsloth/Qwen3.8-27B-Q4_K_M"}, - "gen": {"name":"各长度对比","context_lengths":[512,2048,8192],"max_tokens":128,"samples":2,"warmup":true,"avoid_cache":true} + "gen": {"name":"并发对比","context_lengths":[2048],"max_tokens":128,"samples":2,"concurrency_levels":[1,2,4],"warmup":true,"avoid_cache":true} }' # 查询测试列表 / 详情 @@ -303,8 +358,12 @@ curl $BASE/api/tests/9 # 画图数据(CSV) curl $BASE/api/tests/9/chart-data -# 折线图 PNG(预填充左轴虚线 / 解码右轴实线) +# 折线图 PNG(预填充左轴虚线 / 解码右轴实线,X=上下文长度) curl -o chart.png $BASE/api/tests/9/chart +# 并发对比折线图(X=并发数) +curl -o cc.png $BASE/api/tests/9/concurrency-chart +# 多测试对比 +curl -X POST $BASE/api/compare -H 'Content-Type: application/json' -d '{"ids":[9,10,11]}' # 导出 curl -OJ $BASE/api/tests/9/export.xlsx diff --git a/README.md b/README.md index da22f80..a47a4d3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **访问地址:** `http://:16097/` - **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖) -- **版本:** v2.2.0 +- **版本:** v2.3.0 --- @@ -19,9 +19,12 @@ - **测试名称(主题)**:可为每次测试命名,用于标注测试内容/主题,展示在历史列表、详情弹窗与导出报表中 - **多上下文长度测试**:默认为 `512 / 2048 / 4096 / 8192 / 16384 / 32768 / 65536 / 131072` tokens,可通过标签点击启用/禁用,并支持手动添加任意自定义长度(≥16) - **解码输出长度(max tokens)**:默认为 `128`,可手动自定义 -- **每个长度采样次数**:默认为 `2`,可手动自定义 -- **测试前预热(空转)**:默认开启,先发一次不计速度的空转请求,避免冷启动/首请求偏慢污染真实采样数据 -- **避免缓存**:默认开启,为每次采样追加随机前缀,测量真实预填充性能 +- **每个长度×并发采样次数**:默认为 `2`,可手动自定义 +- **多并发测试**:默认为**单流**(并发 1);支持预设 **2 / 4** 并发档,并可“丝滑”添加任意自定义并发数(如 8、16、3…,≥1)。勾选多个并发档时,同一测试会分别跑各并发档,**把多个并发下的测试结果放在一起对比**: + - 并发采样时同时发起 N 个并行流,**整批吞吐**(聚合 prompt/output token / 批首字 / 批耗时)作为该采样指标,并记录每流明细 + - 汇总按并发数分组 + 长度×并发全网格;详情页「按并发数汇总」表 + **并发对比折线图**(X=并发数,预填充左轴虚线 / 解码右轴实线)直观展示吞吐随并发的变化 +- **测试前预热(空转)**:默认开启,先发一次不计速度的空转请求(按并发数预热),避免冷启动/首请求偏慢污染真实采样数据 +- **避免缓存**:默认开启,为每次采样追加随机前缀(每个并发流独立前缀),测量真实预填充性能 ### 📊 指标与结果 - 实时指标卡:首字延迟、预填充速度、解码速度、上文/输出 tokens、总耗时 @@ -35,6 +38,10 @@ - **文件下载 Excel(xlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet - **文件下载 JSON**:详情弹窗「导出 JSON」 - 测试历史留存(含测试名称),可随时刷新、查看、导出、删除 +- **⚖️ 多测试结果对比**:测试历史表格勾选多个测试(可全选),点「⚖️ 对比所选」弹出对比面板: + - **指标对比表**:时间 / 名称 / 模型 / 并发档 / 采样 / 首字 / 预填充 / 解码 / 单流均解码 / 输出 / 总耗时 并排展示(点击测试名可跳详情) + - **柱状图**:各测试预填充(空心柱)vs 解码(实心柱)速度对比 + - **折线图**:解码速度随并发数的变化(各测试一条线,取共同并发档);画图 CSV 可一键复制、PNG 可下载 ### 🔌 开放 API - 页面所有功能均通过 REST API 提供,前端只是可视化客户端 @@ -69,17 +76,18 @@ pip install -r requirements.txt ## 使用说明 1. **配置接口**:选择提供商 → 填写配置名称 / Base URL(可留空)/ API Key / 模型名称 → 点「保存」可留存,或直接点「🔍 测试连接」验证连通性 -2. **配置测试参数**:勾选要测试的上下文长度(默认 5 档),设置解码输出长度与采样次数,按需开关预热/避免缓存 +2. **配置测试参数**:勾选要测试的上下文长度(默认 5 档),设置解码输出长度、每个组合采样次数与**并发数**(默认单流,可加 2/4/自定义并发档,多档自动并排对比),按需开关预热/避免缓存 3. **开始测试**:点「▶ 开始测试」,右侧实时展示指标与日志;可随时「■ 停止」 4. **查看与导出**:测试完成后,在「测试历史」中点「查看」看完整详情,点「Excel」或详情内「导出 Excel」下载 xlsx 报告 ### 指标含义 | 指标 | 含义 | |------|------| -| 首字延迟 TTFT (ms) | 从请求发出到收到第一个 token 的时间(含预填充) | -| 预填充速度 (tok/s) | prompt tokens / 首字延迟,衡量上文处理吞吐 | -| 解码速度 (tok/s) | 输出 tokens / 解码阶段耗时,衡量逐 token 生成吞吐 | -| 上下文/输出 tokens | 实际发送的提示词 token 数与模型返回的 token 数 | +| 首字延迟 TTFT (ms) | 从请求发出到收到第一个 token 的时间(含预填充);并发时=整批任一流最早首字 | +| 预填充速度 (tok/s) | prompt tokens / 首字延迟,衡量上文处理吞吐;并发时=多流 prompt 之和 / 批首字,即**整批吞吐** | +| 解码速度 (tok/s) | 输出 tokens / 解码阶段耗时,衡量逐 token 生成吞吐;并发时=多流输出之和 / 批解码耗时(**整批吞吐**) | +| 单流均解码 (tok/s) | 并发整批解码吞吐 ÷ 并发数,衡量单流平均生成速率 | +| 上下文/输出 tokens | 实际发送的提示词 token 数与模型返回的 token 数(并发时为多流之和) | --- @@ -98,8 +106,12 @@ pip install -r requirements.txt | DELETE | `/api/tests/` | 删除测试 | | GET | `/api/tests//export.xlsx` | 导出 Excel 报告(汇总/采样明细/日志三 Sheet) | | GET | `/api/tests//export.json` | 导出完整测试 JSON | -| GET | `/api/tests//chart` | 用 data-chart-tool 生成折线图 PNG(预填充左轴虚线 / 解码右轴实线) | +| GET | `/api/tests//chart` | 用 data-chart-tool 生成折线图 PNG(预填充左轴虚线 / 解码右轴实线,X=上下文长度) | | GET | `/api/tests//chart-data` | 画图数据(CSV + 图表请求配置,供一键复制) | +| GET | `/api/tests//concurrency-chart` | 并发对比折线图 PNG(X=并发数,预填充左轴虚线 / 解码右轴实线) | +| GET | `/api/tests//concurrency-chart-data` | 并发对比画图数据(CSV + 图表请求配置) | +| POST | `/api/chart` | 通用图表代理:转发任意 data-chart-tool 请求体,返回 PNG(多测试对比用) | +| POST | `/api/compare` | 多测试对比:`{ids:[...]}` → 对比表 + 柱状图CSV + 并发折线图CSV | > 完整字段说明、响应示例与 curl 示例见 **API.md**。 @@ -119,6 +131,7 @@ POST /api/tests "context_lengths": [512, 2048, 4096, 8192, 16384, 32768, 65536, 131072], "max_tokens": 128, "samples": 2, + "concurrency_levels": [1, 2, 4], "warmup": true, "avoid_cache": true } @@ -164,4 +177,4 @@ llm-speed-tester/ ## Git - **仓库:** `hz4th_coder/llm-speed-tester` -- **版本:** v2.2.0(多上下文长度测试 + 预热 + Excel/JSON 导出 + 测试名称 + 整体统计平均/最小/最大 + 开放 API + 推理型模型兼容 + 📈 data-chart-tool 双Y轴折线图/画图数据复制 + 默认上下文长度增加 4096/16384/65536) +- **版本:** v2.3.0(多并发测试(默认单流,预设2/4可自定义并发档,整批吞吐聚合+按并发分组)+ 多测试结果对比(历史勾选→对比表/柱状图/并发折线图)) diff --git a/app.py b/app.py index 2d86ec5..6c2d94b 100644 --- a/app.py +++ b/app.py @@ -184,8 +184,28 @@ def _build_chart_csv(t): return {"csv": "\n".join(csv_lines), "rows": rows} +def _build_concurrency_chart_csv(t): + """由测试汇总 by_concurrency 构建画图 CSV:并发数, 预填充速度(tok/s), 解码速度(tok/s)""" + s = t.get("summary") or {} + by = s.get("by_concurrency") or {} + rows = [] + for C in sorted(int(k) for k in by): + bl = by.get(str(C)) if str(C) in by else by.get(C) or {} + pre = bl.get("avg_prefill_speed") + dec = bl.get("avg_decode_speed") + if pre is None or dec is None: + continue + rows.append([C, round(pre, 2), round(dec, 2)]) + if not rows: + return None + csv_lines = ["并发数, 预填充速度(tok/s), 解码速度(tok/s)"] + for C, pre, dec in rows: + csv_lines.append("%d, %.2f, %.2f" % (C, pre, dec)) + return {"csv": "\n".join(csv_lines), "rows": rows} + + def _chart_payload(t, csv_text): - """组装 data-chart-tool /api/chart 请求体(双Y轴折线图)""" + """组装 data-chart-tool /api/chart 请求体(双Y轴折线图:长度对比)""" title = ("%s %s" % (t.get("model") or "", t.get("name") or "速度对比")).strip() return { "data": csv_text, @@ -208,6 +228,155 @@ def _chart_payload(t, csv_text): } +def _concurrency_chart_payload(t, csv_text): + """并发对比折线图请求体(X=并发数,双Y轴:预填充左虚线 / 解码右实线)""" + title = ("%s %s · 并发对比" % (t.get("model") or "", t.get("name") or "速度对比")).strip() + p = _chart_payload(t, csv_text) + p["title"] = title + return p + + +def _fmt_num(v): + if v is None or v == "": + return "" + try: + return "%.2f" % float(v) + except Exception: + return "" + + +@app.route("/api/tests//concurrency-chart-data") +def test_concurrency_chart_data(tid): + t = db.get_test(tid) + if not t: + return jsonify({"ok": False, "error": "测试不存在"}), 404 + built = _build_concurrency_chart_csv(t) + if not built: + return jsonify({"ok": False, "error": "无并发分组采样数据(本次测试可能只测了单流),无法画图"}), 400 + return jsonify({ + "ok": True, + "csv": built["csv"], + "rows": built["rows"], + "payload": _concurrency_chart_payload(t, built["csv"]), + }) + + +@app.route("/api/tests//concurrency-chart") +def test_concurrency_chart(tid): + """并发对比折线图 PNG(X=并发数,预填充左轴虚线 / 解码右轴实线)""" + t = db.get_test(tid) + if not t: + return jsonify({"ok": False, "error": "测试不存在"}), 404 + built = _build_concurrency_chart_csv(t) + if not built: + return jsonify({"ok": False, "error": "无并发分组采样数据,无法画图"}), 400 + try: + resp = requests.post(config.CHART_API_BASE + "/api/chart", + json=_concurrency_chart_payload(t, built["csv"]), timeout=60) + except requests.RequestException as e: + return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502 + if resp.status_code != 200: + return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502 + return send_file(io.BytesIO(resp.content), mimetype="image/png") + + +@app.route("/api/chart", methods=["POST"]) +def chart_proxy(): + """通用图表代理:把任意 data-chart-tool /api/chart 请求体转发,返回 PNG(多测试对比用)""" + payload = request.get_json(force=True) or {} + try: + resp = requests.post(config.CHART_API_BASE + "/api/chart", json=payload, timeout=60) + except requests.RequestException as e: + return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502 + if resp.status_code != 200: + return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502 + return send_file(io.BytesIO(resp.content), mimetype="image/png") + + +# ───────────────────────── 多测试对比 ───────────────────────── + +@app.route("/api/compare", methods=["POST"]) +def compare_tests(): + """把多个测试结果放在一起对比:返回对比表 + 柱状图CSV + 并发折线图CSV""" + body = request.get_json(force=True) or {} + ids = [int(x) for x in (body.get("ids") or []) if str(x).isdigit()][:20] + rows = [] + for tid in ids: + t = db.get_test(tid) + if not t: + continue + s = t.get("summary") or {} + g = t.get("gen") or {} + cls = sorted(set(int(x) for x in (s.get("concurrency_levels") or g.get("concurrency_levels") or [1]))) + label = "#%d %s" % (t["id"], (t.get("name") or t.get("model") or "未命名")) + rows.append({ + "id": t["id"], + "label": label, + "created_at": t.get("created_at", ""), + "name": t.get("name", ""), + "provider": t.get("provider", ""), + "model": t.get("model", ""), + "status": t.get("status", ""), + "concurrency_levels": cls, + "by_concurrency": s.get("by_concurrency") or {}, + "samples_ok": s.get("samples_ok"), + "samples_total": s.get("samples_total"), + "avg_ttft_ms": s.get("avg_ttft_ms"), + "avg_prefill_speed": s.get("avg_prefill_speed"), + "avg_decode_speed": s.get("avg_decode_speed"), + "avg_stream_decode": s.get("avg_stream_decode"), + "avg_output_tokens": s.get("avg_output_tokens"), + "avg_total_ms": s.get("avg_total_ms"), + }) + if not rows: + return jsonify({"ok": False, "error": "未找到可对比的测试"}), 400 + + # 柱状图:预填充 vs 解码(X=测试) + bar_csv_lines = ["测试, 预填充速度(tok/s), 解码速度(tok/s)"] + for r in rows: + bar_csv_lines.append("%s, %s, %s" % (r["label"], _fmt_num(r["avg_prefill_speed"]), _fmt_num(r["avg_decode_speed"]))) + bar_csv = "\n".join(bar_csv_lines) + bar_payload = { + "data": bar_csv, "chartType": "bar", + "title": "多测试速度对比(预填充空心 / 解码实心)", + "theme": "default", "showLegend": True, "showGrid": True, "showLabel": True, + "smoothLine": True, + "seriesTypes": ["bar", "bar"], + "seriesStyles": ["hollow", "solid"], + "width": 1000, "height": 520, "pixelRatio": 2, + } + + # 折线图:解码速度随并发变化(取各测试共同并发档,>=2 档才有意义) + line_part = None + if rows: + common = sorted(set.intersection(*[set(r["concurrency_levels"]) for r in rows])) + if len(common) >= 2: + headers = ["并发数"] + [r["label"] for r in rows] + lines = [] + for C in common: + cells = [str(C)] + for r in rows: + bc = r.get("by_concurrency") or {} + bl = bc.get(str(C)) if str(C) in bc else bc.get(C) or {} + cells.append(_fmt_num(bl.get("avg_decode_speed"))) + lines.append(", ".join(cells)) + line_csv = "\n".join([", ".join(headers)] + lines) + nser = len(rows) + line_payload = { + "data": line_csv, "chartType": "line", + "title": "解码速度随并发变化(各测试对比)", + "theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, + "smoothLine": True, + "seriesTypes": ["line"] * nser, + "seriesStyles": (["solid", "dashed", "dotted"] * nser)[:nser], + "width": 1000, "height": 520, "pixelRatio": 2, + } + line_part = {"csv": line_csv, "payload": line_payload, "levels": common} + + return jsonify({"ok": True, "rows": rows, "bar_csv": bar_csv, + "bar_payload": bar_payload, "line": line_part}) + + @app.route("/api/tests//chart-data") def test_chart_data(tid): t = db.get_test(tid) @@ -317,6 +486,7 @@ def _build_xlsx(t): ["模型", t.get("model", "")], ["Base URL", cfg.get("base_url") or "(默认)"], ["上下文长度列表", " / ".join(str(x) for x in (g.get("context_lengths") or []))], + ["并发数列表", " / ".join(str(x) for x in (s.get("concurrency_levels") or g.get("concurrency_levels") or [1]))], ["生成长度(max tokens)", g.get("max_tokens", 128)], ["每个长度采样次数", g.get("samples", 2)], ["预热(空转)", "开" if g.get("warmup", True) else "关"], @@ -368,13 +538,38 @@ def _build_xlsx(t): rr += 1 else: ws.cell(row=r1 + 2, column=1, value="(无成功采样数据)") - for col, w in zip("ABCDEFGH", [22, 20, 12, 14, 14, 12, 12, 14]): - ws.column_dimensions[col].width = w + + # 按并发数分组(多测试/多并发对比核心数据) + by_conc = s.get("by_concurrency") or {} + r2 = r1 + (len(by_length) if by_length else 1) + 3 + ws.cell(r2, 1, "按并发数分组(整批吞吐,tok/s)").font = title_font + ccols = ["并发数", "采样(成功/总数)", "首字ms", "预填充tok/s", "解码tok/s", "单流均解码tok/s", "输出tok", "总耗时ms"] + ws.append([]) + for j, c in enumerate(ccols, start=1): + ws.cell(row=r2 + 1, column=j, value=c) + style_header(ws, r2 + 1, len(ccols)) + if by_conc: + rr = r2 + 2 + for C in sorted(int(k) for k in by_conc): + bl = by_conc[str(C)] if str(C) in by_conc else by_conc[C] + ws.cell(row=rr, column=1, value=C) + ws.cell(row=rr, column=2, value="%s / %s" % (bl.get("samples_ok"), bl.get("samples_total"))) + ws.cell(row=rr, column=3, value=bl.get("avg_ttft_ms")) + ws.cell(row=rr, column=4, value=bl.get("avg_prefill_speed")) + ws.cell(row=rr, column=5, value=bl.get("avg_decode_speed")) + ws.cell(row=rr, column=6, value=bl.get("avg_stream_decode")) + ws.cell(row=rr, column=7, value=bl.get("avg_output_tokens")) + ws.cell(row=rr, column=8, value=bl.get("avg_total_ms")) + rr += 1 + else: + ws.cell(row=r2 + 2, column=1, value="(仅单流,无并发分组)") + for col, w in zip("ABCDEFGH", [12, 20, 12, 14, 14, 16, 12, 14]): + ws.column_dimensions[col].width = max(w, ws.column_dimensions[col].width or 0) # ── Sheet2 采样明细 ── ws2 = wb.create_sheet("采样明细") - h2 = ["序号", "上下文长度tok", "提示词tok", "缓存tok", "首字ms", "预填充tok/s", - "输出tok", "解码tok/s", "总耗时ms", "备注"] + h2 = ["序号", "上下文长度tok", "并发", "流(成功/总数)", "提示词tok", "缓存tok", "首字ms", "预填充tok/s", + "输出tok", "解码tok/s", "单流均解码tok/s", "总耗时ms", "备注"] ws2.append(h2) style_header(ws2, 1, len(h2)) for i, r in enumerate(runs, start=1): @@ -382,16 +577,19 @@ def _build_xlsx(t): ws2.append([ i, r.get("context_length") or m.get("context_length") or "", + m.get("concurrency") or 1, + "%s/%s" % (m.get("streams_ok"), m.get("streams_total")) if m.get("streams_total") else 1, m.get("prompt_tokens") or "", m.get("cached_tokens") if m.get("cached_tokens") else "", m.get("ttft_ms"), m.get("prefill_speed"), m.get("output_tokens"), m.get("decode_speed"), + m.get("avg_stream_decode"), m.get("total_ms"), r.get("error") or "OK", ]) - for col, w in zip("ABCDEFGHIJ", [8, 14, 12, 10, 12, 14, 12, 14, 12, 30]): + for col, w in zip("ABCDEFGHIJKLM", [8, 14, 8, 14, 12, 10, 12, 14, 12, 14, 16, 12, 30]): ws2.column_dimensions[col].width = w # ── Sheet3 日志 ── diff --git a/database.py b/database.py index 8f02e21..ee10d02 100644 --- a/database.py +++ b/database.py @@ -209,12 +209,13 @@ def list_tests(limit=100): conn = _connect() try: rows = conn.execute( - "SELECT id,created_at,status,provider,model,name,summary_json,error " + "SELECT id,created_at,status,provider,model,name,summary_json,gen_cfg_json,error " "FROM tests ORDER BY id DESC LIMIT ?", (limit,)).fetchall() out = [] for r in rows: d = dict(r) d["summary"] = json.loads(d.pop("summary_json") or "{}") + d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}") out.append(d) return out finally: diff --git a/static/css/style.css b/static/css/style.css index a8240f9..1cd9371 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -119,6 +119,8 @@ body { .btn.danger:disabled { opacity: .4; cursor: not-allowed; } .btn.small { padding: 5px 10px; font-size: 12px; } .btn.block { width: 100%; } +.btn.link { background: none; border: none; padding: 0; color: var(--accent); font-size: 12.5px; text-decoration: underline; cursor: pointer; } +.btn.link:hover { color: #fff; } .btn-group { display: flex; gap: 8px; margin-top: 6px; } .btn-group .btn { flex: 1; } @@ -160,6 +162,8 @@ table.history { width: 100%; border-collapse: collapse; font-size: 13px; } .history th { color: var(--muted); font-weight: 600; font-size: 12px; } .history tbody tr:hover { background: var(--panel2); } .history td.num { font-family: var(--mono); } +.history .chk-col { width: 34px; text-align: center; } +.history .chk-col input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); } .status-pill { padding: 2px 10px; border-radius: 12px; font-size: 11px; } .status-pill.running { background: rgba(79,140,255,.15); color: var(--accent); } .status-pill.done { background: rgba(34,197,139,.15); color: var(--accent2); } @@ -181,7 +185,7 @@ table.history { width: 100%; border-collapse: collapse; font-size: 13px; } padding: 14px 18px; border-bottom: 1px solid var(--border); } .modal-head h2 { font-size: 16px; } -.modal-body { padding: 16px 18px; overflow-y: auto; } +.modal-body { padding: 16px 18px; overflow-y: auto; overflow-x: auto; } .modal-body h3 { font-size: 14px; margin: 16px 0 8px; color: var(--muted); } .modal-body h3:first-child { margin-top: 0; } .detail-name { @@ -199,6 +203,8 @@ table.mini { width: 100%; border-collapse: collapse; font-size: 12.5px; } .mini th { color: var(--muted); font-size: 11px; } .mini td.num { font-family: var(--mono); } .mini tr.err td { color: var(--danger); } +.mini.compare { min-width: 860px; } +.mini.compare th, .mini.compare td { white-space: nowrap; } .detail-log { background: #0a0e17; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font: 12px/1.7 var(--mono); max-height: 240px; overflow-y: auto; } .detail-log .ln { white-space: pre-wrap; } diff --git a/static/index.html b/static/index.html index d96ebf4..b9b9eec 100644 --- a/static/index.html +++ b/static/index.html @@ -85,9 +85,18 @@
- +
+
+ +
+
+ + +
+
选中多个并发数时,同一测试会分别跑各并发档并放在一起对比
+