mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
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
This commit is contained in:
270
scripts/kernel-stress/cmp.py
Normal file
270
scripts/kernel-stress/cmp.py
Normal file
@ -0,0 +1,270 @@
|
||||
#!/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()
|
||||
@ -53,6 +53,12 @@ _RE_MIXED = re.compile(r"!mixed(\d+)")
|
||||
# cmd_run 已声明 ParallelSafe(执行体只依赖入参,共享的 p.history 由
|
||||
# recordCmd 加锁保护),且 sleep 是纯计算、不碰磁盘 —— 延迟可精确预期。
|
||||
_RE_SLOW = re.compile(r"!slowbatch(\d+)")
|
||||
# ★ 强制串行对照:混入一个未声明 ParallelSafe 的工具(knowledge_create)
|
||||
# ⇒ 内核 batchRunnable 必须整批退回串行("一个不安全就整批降级")。
|
||||
# 有了它才能在**同一套内核**上量出并发 vs 串行的差异 —— 跨版本做不到,
|
||||
# 因为旧版的适配器缺 stream_index,多个分片并到槽 0、参数混拼,
|
||||
# 工具一个都没真跑(耗时更短但没干活)。
|
||||
_RE_SERIALBATCH = re.compile(r"!serialbatch(\d+)")
|
||||
|
||||
# 批内并发的工具集。全部是**只读且已核实无共享写**的内置工具,
|
||||
# 且都不触发 ONNX(TF-IDF 关键词路)。
|
||||
@ -182,6 +188,16 @@ class H(BaseHTTPRequestHandler):
|
||||
|
||||
# ---- 批内并发:!batchN / !mixedN ----
|
||||
if not has_tool_msg:
|
||||
msb = _RE_SERIALBATCH.search(text)
|
||||
if msb:
|
||||
cnt = max(2, min(int(msb.group(1)), 32))
|
||||
sm = int(os.environ.get("MOCK_TOOL_SLEEP_MS", "200"))
|
||||
tcs = slow_tool_calls(cnt, sm)
|
||||
# 插在中间:确保降级判据不能靠"最后一个工具"侥幸通过
|
||||
tcs.insert(cnt // 2, mk_tool_call(900, SERIAL_TOOL,
|
||||
{"name": "对照/强制串行", "content": "x"}))
|
||||
return self._respond(body, content=None, tool_calls=tcs)
|
||||
|
||||
ms = _RE_SLOW.search(text)
|
||||
if ms:
|
||||
cnt = max(2, min(int(ms.group(1)), 32))
|
||||
|
||||
Reference in New Issue
Block a user