编写于 2026-10-16
这是一次受控对比,被测对象是调试版二进制在一个合成负载下的 CPU 与内存。它能回答「并发本身有没有额外惩罚」和「成本随什么上升」,不能回答「真实使用下能省多少」——真实模型、release 构建与 TUI 渲染都不在本次测量范围内。
结论
- 4 倍工作量对应 3.56 倍 CPU(中位数之比,跨度 2.31 到 4.41)。跨度覆盖 4.0,所以不能断言低于线性,只能说没有观察到超线性放大。
- 4 个子会话确实并行:以每个子会话的首次请求到末次请求作为窗口,四段窗口的并集长度只占四段长度之和的 0.26 到 0.27,完全重合时该比值是 0.25。
- 可测的成本驱动是单个会话的历史长度:每会话 20 轮时每轮 0.0166 CPU 秒,每会话 80 轮时升到 0.0222 到 0.0250 CPU 秒(1.34 倍与 1.51 倍);同期最大请求体从 147 KB 涨到 415 到 439 KB。
- 会话持久化不是这份负载里可测的 CPU 来源:持久化开关的差值都小于组内离散;栈采样里也没有 SQLite 符号进入栈顶热点。
- 峰值内存跟随请求体规模,不跟随子会话个数:4 个子会话、每会话 20 轮的峰值内存(105.5 MiB)低于单会话 80 轮(157.1 MiB)。
- 「把 80 轮拆成 4 个 20 轮子会话更省 CPU」(比值 0.67)不能读成「并发降低 CPU」:这个对比同时把每会话历史从 80 轮缩到 20 轮,两个变量没有分离。
数据底稿与口径
- 测量日期:2026-10-16。
- 被测二进制:
target/debug/peri,debug 构建、保留符号。测量时仓库 HEAD 为fdd90b7cc,工作区仍有未提交改动,因此二进制与 HEAD 不保证完全一致。所有对比用的是同一个二进制,所以组间可比;绝对值不代表 release 构建。 - 模型:本地假模型服务(Anthropic SSE 格式),每个响应固定 4096 字节文本、固定 10 毫秒延迟。零网络、零推理等待。
- 启动方式:
peri -p非交互单轮问答模式,参数为--permission-mode bypass、--max-turns 400、--bare。--bare跳过了 hooks、插件与 MCP 服务初始化;-p模式没有 TUI 渲染。 - CPU 口径:
ps -o time=每 100 毫秒采样,取进程累计 CPU TIME 的差分,不使用瞬时%cpu。 - 环境:Apple M5 Pro、18 核、macOS 26.5.1。机器并非空闲,运行期间 load average 在 11 到 14 之间,这是单 Agent 组离散偏大的可能原因。
- 样本量:主表每个配置 3 次运行;关闭持久化的探针各 2 次。
- 一份批次被单独统计:与
sample采样同时进行的批次不进主表,因为sample本身会抢占 CPU 且对短运行影响明显(单 Agent 组带采样时 CPU 落在 2.13 到 3.19 秒,不带采样是 1.98 到 2.93 秒)。
负载定义
一个「脚本轮」包含三件事:
- 一次假模型响应,固定 4096 字节文本。
- 一次
Bash(true)工具调用。 - 一次会话持久化写入。
三种组织方式:
| 组 | 结构 | 脚本轮总数 |
|---|---|---|
| 单 Agent | 1 个会话连续跑 80 轮 | 80 |
| 4 子 Agent × 80 轮 | 一条助手消息发出 4 个 Agent 调用,每个子会话各 80 轮 |
320 |
| 4 子 Agent × 20 轮 | 同上,每个子会话各 20 轮 | 80 |
- 前两组回答「每会话工作量相同、并发数从 1 涨到 4」的代价。
- 第三组与「单 Agent」的总轮数相同,用于等总工作量对比;但它同时缩短了每会话历史长度。
并发有代码依据:同一条助手消息内的多个工具调用由 join_all 并发派发(peri-agent/src/agent/stages/tool_dispatch/execution.rs:571)。
结果
| 配置 | 运行次数 | CPU 秒 中位[范围] | CPU 秒/轮 中位[范围] | 墙钟 中位[范围] | 峰值内存 中位 | 最大请求体 |
|---|---|---|---|---|---|---|
| 单 Agent × 80 轮 | 3 | 2.00 [1.98, 2.93] | 0.0250 [0.0248, 0.0366] | 3.43s [3.40, 7.28] | 157.1 MiB | 439 KB |
| 4 子 Agent × 每会话 80 轮 | 3 | 7.12 [6.76, 8.74] | 0.0222 [0.0211, 0.0273] | 5.11s [4.92, 6.08] | 160.2 MiB | 415 KB |
| 4 子 Agent × 每会话 20 轮 | 3 | 1.33 [1.31, 1.47] | 0.0166 [0.0164, 0.0184] | 1.20s [1.19, 1.29] | 105.5 MiB | 147 KB |
| 单 Agent × 80 轮,关闭持久化 | 2 | 2.09 [2.06, 2.12] | 0.0262 [0.0258, 0.0265] | 3.53s [3.51, 3.55] | 159.1 MiB | 439 KB |
| 4 子 Agent × 每会话 80 轮,关闭持久化 | 2 | 7.05 [7.02, 7.08] | 0.0220 [0.0219, 0.0221] | 5.12s [5.09, 5.16] | 158.2 MiB | 415 KB |
- CPU 秒/轮 = 进程累计 CPU TIME 增量 ÷ 该组脚本轮总数。注意 4 子 Agent × 80 轮这一组的总数是 320,不是 80。
- 峰值内存取该组各次运行峰值的中位数,按 1 MiB = 1024 KiB 换算,原始采样单位是 KB。
- 最大请求体按十进制 KB 给出:439 KB 对应 439,057 字节,415 KB 对应 415,211 字节,147 KB 对应 147,191 字节。
比值与解读
- 并发数 1 到 4,每会话工作量相同:7.12 ÷ 2.00 = 3.56。改用范围端点计算,跨度是 6.76 ÷ 2.93 = 2.31 到 8.74 ÷ 1.98 = 4.41。
- 3.56 低于 4.0,但跨度包含 4.0,所以只能说「没有观察到超线性放大」,不能说「并发摊销了成本」。
- 等总轮数(80 轮)的两种组织方式:1.33 ÷ 2.00 = 0.67,跨度 0.45 到 0.74。
- 这个比值看起来是「拆成子会话更便宜」,但它同时把每会话历史从 80 轮压到 20 轮,最大请求体从 439 KB 降到 147 KB。并发数与历史长度两个变量没有分离,因此不能读成并发的收益。
- 持久化开与关:单 Agent 2.00 到 2.09 秒,4 子 Agent 7.12 到 7.05 秒。两个差值都小于各自组内离散,本负载下没有可测差异。
- 每轮成本随每会话历史长度上升:0.0166 → 0.0222(1.34 倍)与 0.0166 → 0.0250(1.51 倍)。同期最大请求体 147 KB → 415 到 439 KB。
栈采样的定性归属
- 用 macOS
sample每 1 毫秒采样,只作定性归属,不作占比结论。 - 口径警告:
sample会采样进程内每个线程,且「Sort by top of stack」段只列出样本数不少于 5 的符号。这些计数是墙钟采样份额,不是 CPU 时间占比,也不能相加当作互斥占比。 - 单 Agent 组那个文件里,栈顶符号共 38 项、合计 26,312 个样本,其中内核等待与空闲类 25,724 个,占 97.8%;非等待样本只有 588 个。
- 4 子会话组那个文件里,栈顶符号共 67 项、合计 25,051 个样本,其中内核等待与空闲类 23,936 个,占 95.6%。
- 非等待热点按样本数排序依次是:
_platform_memmove内存搬运、切片边界与指针对齐检查、serde_json::format_escaped_str_contents(模型请求的字符串序列化)、内存分配器_xzm_*。 __fork出现在栈顶 95 次,对应拉起Bash工具子进程。- SQLite 没有进入栈顶热点:两个文件里样本数不少于 5 的符号列表都没有 SQLite 或 sqlx 符号。SQLite 帧确实出现在更深的调用栈里,但那些节点的样本计数都是个位数量级,所以这条只能算弱证据,它与持久化探针「没有可测差异」的方向一致。
- 线程数:单 Agent 组进程内有 14 到 15 个线程,4 子会话组是 19 到 20 个。子会话确实带来了额外的执行线程。
与 2026-10-07 现场调查的关系
- 更早的现场调查是
spec/issues/2026-10-07-multi-subagent-cpu.md,它自述为非受控采样,观察到一个target/debug/peri进程在多 Subagent 工具执行期间累计 CPU TIME 从7:05.97涨到7:11.34。 - 那次观察里的「CPU 168%」是进程级 CPU,多核时可以超过 100%,不是整机占用。
- 那份调查的机制段落已经过时,不能读作当前版本的机制。 它把成本指向
write_work与encode<WorkState>、decode<WorkState>;这条路径已被完整剥离:- 仓库根
CLAUDE.md写明「不持久化 Agent 执行恢复状态」; - 剥离计划记录删除四张
session_work_*与两张session_control_*表,schema 向前升级到 18; - 本次核对,
peri-resources/src/sessions/sqlite_store/session_data/work.rs、peri-resources/src/sessions/work/effects.rs、peri-acp-types/src/session_resources/work/processing.rs三个路径在当前树里都已不存在。
- 仓库根
- 当前的逐条写入入口是
INSERT INTO messages(peri-resources/src/sessions/sqlite_store/session_data/history.rs:34)。 - 那份调查的待办里,「同模型、上下文与工具负载下对比单 Agent 与多 Agent」在本次合成负载下完成了一部分;真实模型、release 与 TUI 三个维度仍未验收。
不能声称
- 不能宣布「多 Subagent 高 CPU 已根治」,也不能给出任何真实环境降幅——本次负载是零延迟假模型,真实模型下的等待占比与请求体规模完全不同。
- 不能把 3.56 说成「低于线性」或「并发有摊销」,理由见比值第 1 条。
- 不能把 0.67 说成「并发降低 CPU」,理由见比值第 2 条。
- 不能把
sample的栈顶计数当作占比或 CPU 时间。 - 不能把 debug 二进制、
--bare、无 TUI 的数字当作 release 或 TUI 上的表现。 - 不能引用瞬时
ps %cpu作为占比证据——本次没有使用这个口径。
局限
- 每组只有 2 到 3 次运行,样本量小,中位数与范围都很粗。
- 机器不空闲(load 11 到 14),单 Agent 组有一次运行墙钟到 7.28 秒,是组内离散的主要来源。
- 负载是合成的:假模型零延迟,工具只有
Bash(true),缺少真实的工具多样性与真实推理等待。 - 只测了并发数 1 和 4,没有扫 2 与 8,所以无法判断比值是否随并发数单调变化。
- 二进制与仓库 HEAD 不保证一致,绝对值只在这一次测量内部有效。
- 没有覆盖 TUI 渲染成本,而现场调查里 render loop 是出现过的栈顶符号之一。
还没测的维度
按优先级列出,全部沿用本次的夹具:
- release 构建复测:
./scripts/cargo-rmcp-patched.sh build --locked --release -p peri-tui --bin peri。要注意[profile.release]设了strip = "symbols",release 只能做 CPU TIME 与内存计量,栈分类仍得用 debug。 - TUI 渲染维度:在 tmux 内用同一剧本启动 TUI,追加采集帧延迟与绘制线程的样本。
- 并发数扫描:1、2、4、8 个子会话,每会话轮数相同,看比值是否随并发数线性变化。
- 真实模型与真实网络:需要凭据与外部成本,建议只做两组各 3 次,采集「CPU TIME 增量/轮」而不是墙钟。
- 大载荷:把每轮文本提到 64 到 256 KB,测「每轮成本对会话历史长度」的斜率,直接检验比值第 4 条的机制。
复核方式
- 本文所有数字都来自
/tmp下的原始输出:汇总 JSON、CPU 采样、请求时间线与sample栈文件,未写入仓库,也未做估算补齐。 - 关键比值可以用主表的秒数直接复算:7.12 ÷ 2.00、1.33 ÷ 2.00、0.0166 对 0.0222 与 0.0250。
- 内存取各次运行的峰值中位数,单位是 MiB,主表脚注给出了换算方式与原始字节数。
- 栈采样只报了文件内可直接数出的样本数与符号名,没有把它们折算成百分比结论。
