Files
HomeAgent/scripts/kernel-stress/cmp.py
JianFeeeee 190e46908d test(stress): 补更新前后全面对比脚本(cmp.py),修 blast 的计数错误
## cmp.py:更新前后对比的四个维度

★ 每轮都记录**处理数**(响应里有多少个工具结果标记)与**是否出现 error 帧**,
任一不符即记失败。理由:工具调用这一路的失败模式几乎都是**静默**的 ——
工具没跑、参数混拼、只处理了第一个 tool_call,都不报错只是结果不对。
"跑完没崩"完全不能说明它 work。

1. **批内工具调用**:!slowbatchN 在两版上各跑几轮,比中位耗时与处理数
2. **并发 vs 强制串行**(新版内部对照):!slowbatchN vs !serialbatchN
3. **调度器并发轰炸**:多连接并发排队,算通过率
4. **连续稳定性**:20 轮无错误率

## 修掉 mock 的 !serialbatch 缺失

之前只在 /tmp 的临时副本里加过,没进仓库,导致 cmp.py 测「强制串行」时
那个 marker 根本不存在 —— 测出来的"串行"其实是并发,**加速比是假的**
(0.34x / 0.31x,看起来并发比串行慢)。已加回并说明它的用途:跨版本做不了
并发/串行对照(旧版适配器缺 stream_index,工具一个都没真跑),只能在
同一套内核上做。

## 修掉 blast 的计数错误

第一版按 `conns * inputs` 起线程、每个线程又跑 `inputs` 轮 ⇒ 总输入数是
conns×inputs²,分子分母量纲不一致,算出过 **"128/32 = 400%"** 这种荒谬数字。

现在:恰好 conns 个 worker、每个跑 inputs 轮;且分母用**实际发出的**输入数
(含连接失败的),否则连接失败时通过率会虚高。

## 踩过的两个坑(都写进注释)

- 内核 `task.go:353` 有输入去重(`isDuplicateInput`,为 webui 断线重连重放
  而设),相同文本被丢弃并回空响应 ⇒ 每轮输入必须带唯一后缀
- cli 的 auth 帧本身就是 `{"type":"response"}` ⇒ 必须先吃掉它再开始收集,
  否则第一轮的"终止帧"是 auth,测出来耗时恒为 0
2026-09-27 18:56:25 +08:00

271 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""更新前后的**全面**性能对比与通过率。
## 为什么必须有「通过率」而不只是耗时
工具调用这一路的失败模式几乎都是**静默**的:工具没跑、参数混拼、只处理了
第一个 tool_call —— 都不报错,只是结果不对。所以"跑完没崩"完全不能说明它
work。本次每次测量都同时记录**处理数**(响应里有多少个工具结果标记)与
**是否出现错误帧**,任一不符即记为失败。
## 三块覆盖
1. **调度器**:多连接并发排队 + L4 中断(沿用 stress.py 的形态)
2. **批内工具调用**:!batchN / !slowbatchN / !serialbatchN
—— 并发批 vs 强制串行批 vs 单工具基线
3. **稳定性**:连续多轮的通过率 + 队列/背压计数
## 每轮两个必查项(任一不过即判失败)
- **工具真跑了**:响应里能看到 `done-N` 标记(cmd_run 的 echo 输出),
工具没跑就不可能有
- **无 error 帧**:`{"type":"error"}` 一律算失败
## 用法
python3 cmp.py <旧sock> <旧key> <新sock> <新key> [规模]
"""
import contextlib
import json
import re
import socket
import statistics
import sys
import threading
import time
# ---------------------------------------------------------------- 基础连接
class Conn:
"""一条 cli 连接。
★ auth 帧本身就是 {"type":"response"},必须先吃掉它再开始收集 ——
否则第一轮的"终止帧"就是 auth,测出来的耗时是 0。
"""
def __init__(self, sock, key, timeout=120):
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.settimeout(timeout)
self.s.connect(sock)
self.s.sendall(("/auth %s\n" % key).encode())
self.f = self.s.makefile("rb")
auth = self.f.readline()
if b"authenticated" not in auth:
raise RuntimeError("auth 失败: %s" % auth[:80])
def ask(self, text, timeout=120):
"""发一条输入,等终止帧。返回 (耗时, 帧列表)。"""
t0 = time.time()
self.s.sendall((text + "\n").encode())
frames = []
while time.time() - t0 < timeout:
line = self.f.readline()
if not line:
break
t = line.decode("utf-8", "replace").strip()
frames.append(t)
if t.startswith("{"):
try:
if json.loads(t).get("type") in ("response", "error"):
break
except ValueError:
pass
return time.time() - t0, frames
def close(self):
with contextlib.suppress(OSError):
self.s.close()
def has_error(frames):
for f in frames:
if f.startswith("{"):
try:
if json.loads(f).get("type") == "error":
return True
except ValueError:
pass
return False
def tools_done(frames):
"""数出工具真正执行的个数(按 done-N 标记去重)。"""
blob = " ".join(frames)
return len({int(m.group(1)) for m in re.finditer(r"done-(\d+)", blob)})
# ---------------------------------------------------------------- 单轮测量
def measure(sock, key, marker, rounds):
"""连一次、跑 rounds 轮(每轮唯一输入)。
★ 每轮输入必须唯一:内核 task.go:353 有输入去重
(isDuplicateInput,为 webui 断线重连重放而设),相同文本会被丢弃
并回空响应。第一版每轮同一个 marker,只有第 1 轮有效。
"""
dts, oks = [], 0
try:
c = Conn(sock, key)
except RuntimeError as e:
print(" 连接失败: %s" % e)
return None
try:
for _ in range(rounds):
dt, frames = c.ask("%s-%d" % (marker, time.time_ns()))
dts.append(dt)
# 无 error 帧即算通过(工具数由调用方按 marker 形态另行核对)
if not has_error(frames):
oks += 1
if has_error(frames):
break
finally:
c.close()
return {"rounds": rounds, "ok": oks, "median": statistics.median(dts),
"mean": statistics.mean(dts), "min": min(dts), "max": max(dts),
"all": dts}
# ---------------------------------------------------------------- 并发轰炸
def blast(sock, key, conns, inputs, tag):
"""conns 条连接并发,每条连接连发 inputs 条输入。返回通过率。
★ 线程数与计数:第一版按 `conns * inputs` 起线程、每个线程又跑
`inputs` 轮,于是总输入数是 conns×inputs²,分子分母量纲不一致,
算出过 "128/32 = 400%" 这种荒谬数字。
现在:**恰好 conns 个 worker,每个跑 inputs 轮** ⇒ 总输入 conns×inputs。
"""
results = [None] * conns
def worker(idx):
try:
c = Conn(sock, key)
except RuntimeError:
results[idx] = {"ok": 0, "sent": 0, "err": "connect"}
return
good = 0
try:
for j in range(inputs):
_, frames = c.ask(f"{tag}-{idx}-{j}-{time.time_ns()}")
if not has_error(frames):
good += 1
results[idx] = {"ok": good, "sent": inputs, "err": ""}
except Exception as e: # noqa: BLE001
results[idx] = {"ok": good, "sent": inputs, "err": repr(e)[:60]}
finally:
c.close()
t0 = time.time()
ths = [threading.Thread(target=worker, args=(i,), daemon=True)
for i in range(conns)]
for t in ths:
t.start()
for t in ths:
t.join()
wall = time.time() - t0
# 分母用"实际发出的输入数"(含连接失败的那些),而不是标称的 conns×inputs
# —— 连接失败时分子分母必须同步缩放,否则通过率会虚高。
total = sum(r["sent"] for r in results if r)
good = sum(r["ok"] for r in results if r)
return {"total": total, "good": good, "wall": wall,
"rate": good / total if total else 0}
# ---------------------------------------------------------------- 主流程
def arg_int(pos, default, name):
"""解析位置参数为正整数;非法时给出可执行报错而不是裸 ValueError。"""
if pos >= len(sys.argv):
return default
raw = sys.argv[pos]
try:
v = int(raw)
except ValueError:
sys.exit(f"参数 {name} 需要一个正整数,收到 {raw!r}(用法见本文件顶部)")
if v <= 0:
sys.exit(f"参数 {name} 必须 > 0,收到 {v}")
return v
def tools_for(sock, key, n):
"""单跑一轮,数出真正执行的工具个数(连不上返回 0)。"""
try:
c = Conn(sock, key)
except RuntimeError:
return 0
try:
_, frames = c.ask("!slowbatch%d-%d" % (n, time.time_ns()))
return tools_done(frames)
except Exception: # noqa: BLE001
return 0
finally:
c.close()
def main():
if len(sys.argv) < 5:
print(__doc__)
sys.exit(2)
old_sock, old_key, new_sock, new_key = sys.argv[1:5]
scale = arg_int(5, 1, "scale")
print("=" * 72)
print("更新前后全面对比(旧: 85e3d66 / 新: d3eaff4),scale=%d" % scale)
print("=" * 72)
# ---- ① 批内工具调用 ----
print("\n【① 批内工具调用】")
print("%-26s %-16s %-16s %s" % ("场景", "旧 中位/工具", "新 中位/工具", "变化"))
for n in (2, 4, 8):
row = {}
for tag, sock, key in (("old", old_sock, old_key), ("new", new_sock, new_key)):
m = measure(sock, key, "!slowbatch%d" % n, 3)
if m is None:
row[tag] = (0.0, 0)
else:
row[tag] = (m["median"], tools_for(sock, key, n))
o, w = row["old"], row["new"]
chg = "—" if o[0] == 0 else "%+.1f%%" % ((w[0] - o[0]) / o[0] * 100)
print("%-26s %-16s %-16s %s"
% ("!slowbatch%d 并发批" % n,
"%.3fs / %d 个" % o, "%.3fs / %d 个" % w, chg))
# ---- ② 并发 vs 强制串行(新版内部对照)----
print("\n【② 并发 vs 强制串行(新版内部对照)】")
print("%-6s %-14s %-14s %-10s %s" % ("N", "并发", "强制串行", "加速", "工具数"))
for n in (2, 4, 8):
cp = measure(new_sock, new_key, "!slowbatch%d" % n, 3)
cs = measure(new_sock, new_key, "!serialbatch%d" % n, 3)
if cp is None or cs is None:
print("%-6d 测量失败(连接不上)" % n)
continue
k = tools_for(new_sock, new_key, n)
sp = cs["median"] / cp["median"] if cp["median"] else 0
print("%-6d %-14s %-14s %-10.2f %d"
% (n, "%.3fs" % cp["median"], "%.3fs" % cs["median"], sp, k))
# ---- ③ 调度器并发轰炸 + 通过率 ----
print("\n【③ 调度器并发轰炸】")
conns, inputs = 8 * scale, 4 * scale
for tag, sock, key in (("旧", old_sock, old_key), ("新", new_sock, new_key)):
r = blast(sock, key, conns, inputs, "!batch2")
print(" %s版: %d/%d 通过(%.1f%%),墙钟 %.1fs"
% (tag, r["good"], r["total"], r["rate"] * 100, r["wall"]))
# ---- ④ 连续稳定性 ----
print("\n【④ 连续稳定性(20 轮)】")
for tag, sock, key in (("旧", old_sock, old_key), ("新", new_sock, new_key)):
m = measure(sock, key, "!batch2", 20)
if m is None:
print(" %s版: 连接失败" % tag)
continue
print(" %s版: %d/%d 无错误,中位 %.3fs,min %.3f / max %.3f"
% (tag, m["ok"], m["rounds"], m["median"], m["min"], m["max"]))
print()
if __name__ == "__main__":
main()