← 全部工程笔记Cognitive OS 系列 / 07
可靠性与实测第 07 / 08 篇

我测了百万任务,但没有测百万次模型推理:Cognitive OS 的 Runtime 实测

逐层拆解 benchmark runner,用 Python 复算原始数据,解释 P99 聚合、吞吐退化和 RSS 分账。

文章目录

“百万任务”只有与工作负载一起解释才有意义。这次我测的是实际 Scheduler、AgentPool 和本地确定性任务的执行路径,目的是观察 Runtime 的规模行为,而不是宣称远程模型具有同样的吞吐。

这是 Cognitive OS 技术系列第 7 篇。数据来自仓库保存的扩展基准,以下按原报告口径解读。

这篇文章要解决什么问题?

本文围绕“从测量对象理解百万任务数据”展开。阅读顺序是:测试范围 → 计时与统计口径 → 原始数据复算 → 性能解释 → 可归因的下一轮实验。先建立概念与执行顺序,再进入具体函数、异常分支和测试,避免把模块名当成理解系统的起点。

阅读阶段 要回答的问题
先界定测量对象与结论范围 先确认任务 runner 实际做了什么,区分累计完成量、同时排队量与真实模型调用。
吞吐、延迟和内存分别怎样计量? 明确每项指标的起止点、样本集合和资源口径,数字才具备可比较的含义。
怎样复现并检查原始结果? 先通过正确性检查,再复算原始数据与数量守恒,最后解释统计汇总。
如何解释已有数据与性能变化? 把测量范围和结果一起读,用阶段耗时及内存组成排查变化,避免从相关性跳到因果。
下一轮实验怎样控制变量? 一次优先改变一个因素,同时保留运行条件与业务负载差异,才能评价优化收益。
如何发布可核对的性能结论? 报告保留数据、脚本和限制,确保读者能重新得到图表与相同口径的结论。

文中的源码行为以原文标注的 cca98b6 为阅读基线;涉及“应该”“可以”或后续演进的内容属于设计分析与验证建议。历史基准和故障记录不代表当前版本每次运行都会产生相同结果。

1. 先界定测量对象与结论范围

先确认任务 runner 实际做了什么,区分累计完成量、同时排队量与真实模型调用。

贯穿案例:累计百万任务,能推导出什么容量?

假设一项教学实验累计完成 100 万个轻量任务,总耗时为 50 秒,那么平均完成吞吐为 1,000,000 / 50 = 20,000 个任务/秒。这组数字仅用于解释计算,不是本文新增的实测结果。

仅凭它仍无法回答三个问题:同时有多少任务存活,每项任务排队多久,以及真实业务能承载多少用户。若任务分批提交,峰值在途数量可能远小于累计完成数;若 runner 没有调用模型,耗时也没有覆盖网络等待与模型生成。

还需要把统计集合说清楚。例如三轮运行各自得到 P99,取这三个数的中位数,只能称为“运行内 P99 的中位数”,不能称为“所有任务合并后的 P99”。百分位的合并通常需要原始样本,或可正确合并的直方图及其精度说明。

想回答的问题 还需要的数据
用户需要等多久? 接纳、开始、结束的计时点,以及端到端分布
能否承受突发? 到达模式、峰值在途数、队列长度和拒绝策略
内存会不会持续增长? 活动对象、历史保留、RSS 随时间的变化
换真实模型后能跑多快? 模型延迟、调用次数、限流、工具耗时及重试分布

因此后文先固定测量对象和统计口径,再展示复算与结果。读者既能检查报告中的数字,也能知道哪些结论需要另外的实验支持。

测试到底运行了什么

机器为 Windows 11 build 26200、Intel Family 6 Model 142,约 8 GiB 内存,使用 Zig cc 0.14.1、-O1 构建。规模负载使用 8 个工作线程,每任务执行 64 次 Context 探测,聚合上下文缓冲为 8 MiB;原始 JSON 还记录了 8 个空闲的辅助 Runtime Worker,不能遗漏这部分进程开销。

Agent 数量指注册的角色数。1000 个角色不等于 1000 个进程,也不等于同时向模型发起 1000 次请求。任务主体使用本地确定性工作负载,不包含生产模型推理与网络延迟。

每个规模组合执行 3 次独立运行;耗时、吞吐和各次运行的延迟分位数按报告取中位值,峰值 RSS 使用组内最大值。

先拆开 benchmark runner,避免测到空循环

bench_runtime.c 中的 runner 不请求远程模型,但也不是只递增一个计数器。它记录从提交到 runner 开始的时间,按角色名调用 agent_pool_task_prompt() 组织任务输入,对共享上下文做 64 次探测,再设置任务状态和输出。

核心路径可以简化为:

submitted_us → 进入 Scheduler → runner 开始
    → 记录 latency_us
    → 按 bench-N 找角色并构建 prompt
    → 64 次 context 字节访问与校验
    → 设置 DONE / FAILED、生成 output
    → Scheduler 完成与历史裁剪

这意味着数据包含实际队列、工作线程、角色提示组织和完成处理的成本。它仍然是很轻的本地工作,不包含生产模型的 token 生成、真实文档解析或慢速网络工具。理解这条路径后,读者才能判断自己的工作负载与它有多远。

还要看提交端是否主动控制压力。如果提交方节奏较慢,系统可能持续排空队列,此时累计百万项并未制造百万项积压。独立 backlog gate 才是刻意压住 worker、让队列真实增长的实验;两个场景回答的问题不同。

表格背后的数量守恒

每一行 benchmark 结果都应能从原始运行记录重建。首先检查总提交数等于 admitted 数,admitted 等于 completed 加上明确记录的未完成类别;若失败和拒绝均为零,则 submitted、admitted 与 completed 应严格相等。结束时 active 和 queue 都应归零,否则吞吐结果可能建立在仍有任务未完成的中间时刻。

已保存报告中每组运行至少记录 tasks、elapsed、吞吐、队列分位数、active/queue 峰值和 RSS。用公式检查 tasks_per_second ≈ tasks_completed / (elapsed_ms / 1000),再考虑报告的舍入精度。若差异超过舍入误差,说明字段含义或统计窗口可能不一致,应该先查 runner,而不是直接把数据复制到图表。

记录在三次独立运行内聚合时,需要明确每列采取的方式。耗时取中位数能降低单次系统抖动的影响;吞吐既可以对每次吞吐取中位数,也可以用任务总数除以中位耗时,两者不完全等价;P99 的运行间中位数不等于合并原始延迟样本重新计算的 P99;RSS 取组内最大值回答的是保守峰值,不应与中位耗时拼成同一次测量。

一个审计脚本可以像这样检查数据不变量:

from statistics import median

def summarize(runs, agents):
    selected = [row for row in runs if row["agents"] == agents]
    assert len(selected) == 3
    for row in selected:
        assert row["tasks_submitted"] == row["tasks_admitted"]
        assert row["tasks_admitted"] == row["tasks_completed"]
        assert row["tasks_failed"] == row["tasks_rejected"] == 0
        assert row["active_at_end"] == row["queue_at_end"] == 0
        calculated = row["tasks_completed"] / (row["elapsed_ms"] / 1000)
        assert abs(calculated - row["tasks_per_second"]) < 1.0
    return {
        "agents": agents,
        "elapsed_ms_median": median(r["elapsed_ms"] for r in selected),
        "queue_p99_run_median": median(
            r["scheduler_latency_p99_ms"] for r in selected
        ),
        "peak_rss_mib_max": max(r["peak_rss_bytes"] for r in selected) / 2**20,
    }

字段名应按当前报告 JSON 调整;如果报告新增 warmup、并发度或失败项,也要同步更新断言。对外发布时可以附脚本输出和原始 JSON hash,让读者验证文章表格确实来自同一个数据文件。

100 万次累计完成与 100 万个排队任务

两种说法回答的是完全不同的问题。累计完成百万任务的测试将任务持续提交给 worker,队列通常处于被消费状态;它测 throughput、排队尾延迟和长时间运行时的资源变化。backlog gate 刻意阻塞 worker,再一次性提交任务,确认大量任务真的停留在 ready queue 中;它测队列容量、对象分配与峰值内存。

backlog gate 中已观察到 99,992 项真实排队任务。这个数字比“提交了一百万项”更能直接描述积压压力。两组测试不能混成“系统同时承载一百万执行进程”的宣传语:排队任务还没有各自运行的协程,也没有各自进行模型调用。若要测试百万并发执行,工作负载和资源模型完全不同。

将提交速率与完成速率分开记录有助于解释积压变化:

queue_delta ≈ admitted_rate - completion_rate

这是连续时间的直观关系,具体离散采样还受拒绝、取消和采样窗口影响。如果提交速率短时大于完成速率,队列会上升;即使长时间平均吞吐很高,也可能在突发窗口让排队 P99 变差。峰值队列和平均吞吐应并列展示。

实际服务是否需要保留近十万积压,还取决于 max_active、背压与上游超时。无限接收后让任务排几个小时,可能比及时拒绝更差。容量 gate 应与调度策略一起讨论:队列上限、入队拒绝、客户端重试策略和租户公平性,共同决定故障时的体验。

如何读历史指标而不制造错误承诺

历史报告中的 100/1000 角色与 100,000/1,000,000 项任务构成一组有限组合;没有测试的组合不应插值成精确容量。数据在特定 Windows 机器、编译器与优化级别下采集,换到 Linux、容器限额或调试构建,RSS 与线程调度都会变化。

如果最新 commit 改了 Scheduler、任务历史清理或 Context probing,老数据仍可作为历史参考,却不再是当前代码的直接性能证明。发布新数字时,应保存 commit hash并使用完全相同的 workload 重跑;若编译器或 OS 变化,则同时保留“代码变化”和“环境变化”两种解释。

文章使用中位 elapsed、P99 的运行间中位数和 peak RSS 最大值,这些聚合各自回答不同问题。横向比较前应阅读数据文件和计算代码。只抄三列数字却不说明如何聚合,读者无法评估这些数字的稳定性。

2. 吞吐、延迟和内存分别怎样计量?

明确每项指标的起止点、样本集合和资源口径,数字才具备可比较的含义。

吞吐、排队延迟和端到端时间是三种指标

吞吐用完成任务数除以总耗时,适合观察单位时间处理量。排队延迟从提交到 runner 开始,反映任务启动前的等待。端到端任务延迟还包括 runner 工作、工具、模型、完成处理及可能的重试。

T_end_to_end = T_queue + T_execution + T_finalization
T_execution ≈ T_model + T_tools + T_local_control

这里的分解是分析模型,不表示当前所有字段都逐项测量了上述每部分。尤其 scheduler_latency_p99_ms 不能被改写为“真实 Agent 请求 P99”。把排队分位数误当端到端指标,会大幅低估远程模型应用的等待。

如果某业务平均每项任务需要 2 秒模型时间,仅凭本地 runner 的每秒一万多项完成量,不能估算该业务可承载多少并发。还要加入 API 并发上限、token 配额、工具延迟以及请求间依赖。

P99 的样本数、冷启动和运行顺序

在 100,000 个样本中,P99 大致落在最慢的一千个附近;如果每组只有少量任务,尾部分位数会非常不稳定。这里有百万样本规模,但原始延迟没有跨运行合并,因此运行内 P99 的三次中位值仍然只是描述三个独立观测的中心。它不等同于置信区间,也无法显示极端最大时延。

测量前是否 warm up 会影响分配器、代码页、文件缓存和 CPU 频率。连续跑 100、1000、1000、100 的顺序可能让后面的组受前面预热影响。更严谨的方案应随机化或交替顺序,先执行不计入结果的 warmup,再记录正式运行,并保存开始时间、机器负载与电源模式。

三次重复足以捕捉明显量级差异,却不足以稳定估算细微百分点。若两组差异只有 1%,但运行间方差也是数个百分点,就不能得出性能优胜结论。可增加重复次数、报告原始分布与离散程度,或使用固定基准机的历史窗口建立控制限。跨机器比较还需统一工具链、内存和系统版本。

P99 聚合必须选择目标问题。对每次运行分别取 P99 再看中位数,回答“一次典型运行的尾延迟是什么”;合并全部任务样本计算 P99,回答“实验整体的任务尾延迟是什么”。后一种算法保留每个样本的权重,前一种让每次运行权重相等。必须声明口径,不能只写“P99”省略聚合过程。

为什么内存必须分账

峰值 RSS 包括 Runtime 组件、上下文缓冲、任务对象、线程栈、分配器以及 benchmark 自己保存的验证数据。百万任务负载中的 work 结构和延迟记录数组也随着任务数增长,即使调度器完成历史始终只留 64 条,进程峰值仍可能升高。

因此判断历史泄漏,至少要同时看 retained_tasks、retained_bytes、结束活跃数与队列数,而不能只看一次最高 RSS。更强的长期检验可以在同一进程重复等量批次,观察排空后的内存是否持续阶梯增长;本篇没有用现有表格宣称完成了所有长期泄漏验证。

内存口径 包含什么 适合回答的问题
Context materialized bytes 当前物化表示 工作集是否按预期驱逐
retained task bytes 保留的任务数据 完成历史是否有界
规范内容大小 保留下来的完整记录 恢复的数据量是多少
RSS / Peak RSS 整个进程驻留及其峰值 测试实际占用多少系统内存

在大 Context 实验里,1 GiB 规范内容对应约 3.12 GiB 峰值 RSS,进一步说明规范原文、复制、缓存和序列化会叠加。只给出 3900 字节 WARM 概览,容易隐藏主要成本。

峰值内存与 RSS、虚拟地址和堆分配量

Windows 进程可同时观察 Working Set、Private Bytes、commit size 和 allocator 统计;Linux 则常用 RSS、PSS、虚拟地址空间和 cgroup 内存。它们回答的问题不同。RSS 表示当前驻留的物理页,不等于进程申请过的全部堆空间;Private Bytes 也不等于可立即归还给 OS 的物理内存。

协程栈懒分配可降低排队任务在未执行时承担的成本,但底层 Fiber 可能预留地址空间并按需提交页面。若只看 RSS,预留的虚拟地址空间看起来不大;若只看虚拟尺寸,又可能高估真实物理内存。跨平台比较时必须写清指标名与采集 API。

peak RSS 是采样最大值。采样每秒一次会漏掉毫秒级分配峰值;持续跟踪每项任务会增加 observer overhead。报告应说明采样频率、进程内外采集、测试工具自身开销与子进程是否计入。大规模测试中的 output buffer 若一次性保留所有结果,会显著影响 peak,但并非 Scheduler 固有内存。

allocation profiling 可以回答“谁申请了多少”,RSS 回答“进程实际驻留多少”。对象分配总量不等于同时存活量;很多短命分配可能使总 allocation 很大但 peak RSS 稳定。反过来,allocator 保留已经释放的 arena 会让 RSS 持续偏高。应结合 heap snapshot、retained object count 与 RSS 曲线判断。

排队尾延迟与 Little's Law 的使用限制

对于稳定系统,Little's Law 给出平均在系统中的工作量 L、平均到达率 λ 与平均停留时间 W 之间的关系 L = λW。这能帮助检查观测是否自洽,例如平均 queue length 与提交速率、平均等待是否在相近时间窗计算。它适用于稳定系统和一致的统计范围;突发 backlog、系统尚未排空或拒绝大量任务时,直接套用会误导。

这条关系使用平均值,无法解释 P99。一个平稳的平均队列长度可能与少数极端长等待同时存在。需要保留 latency histogram、排队长度时间序列和每任务 start timestamp,才能观察长尾由突发提交、锁争用还是少量阻塞 worker 造成。

超过稳定服务率时,队列平均长度理论上会不断增长。此时跑完整百万任务得到很高的累计完成数,并不代表系统稳定;提交窗口结束后最终 drain 也可能掩盖超载期间极长的等待。应报告负载生成速率、稳态阶段、突发阶段和排空时间。

queue percentile 还要说明取样对象。若只在任务开始时记录一次 latency,未启动任务不会出现在分布里;如果任务因队列满而 rejected,也不能从成功任务的 P99 看见它们。对服务容量评估,应把 accepted、rejected、timed out 与 completed 一起报告。

3. 怎样复现并检查原始结果?

先通过正确性检查,再复算原始数据与数量守恒,最后解释统计汇总。

如何复现,而不是只看表格

先按照仓库说明构建 runtime-bench:Windows 使用 build.ps1 -Target runtime-bench,POSIX 可使用 make build/bench-runtime。进入 backend 后:

python tests/test_runtime_benchmark.py
python tests/run_runtime_measurements.py --million \
  --compiler "实际编译器/版本/参数" --output docs

第一条是小规模正确性 smoke,覆盖任务完成、MMU、积压与回滚。第二条重新运行百万任务组合并保存数据;它需要实际承担测试负载,结果会随机器、编译参数和系统负载变化。

本篇引用的是已保存的历史测量,不代表每次 CI 都重新执行百万任务,也不代表后续每个版本都已经重新建立同样的性能基线。

正确性 gate 为什么先于跑分

负载程序必须先证明完成的是提交的那些任务,而不是漏做工作后获得高吞吐。报告检查 submitted、admitted、completed 相等,failed、rejected 为零,结束 active 与 queued 为零,并验证回滚结果。

这些检查仍不是万能的性能正确性证明。若 runner 中真正的业务计算被错误删掉,数量守恒也可能继续通过。因此文章同时公开 runner 路径、上下文探测数和原始数据,让读者知道“完成一次”具体包含什么。

小规模 smoke 不设置某个固定 tasks/s 阈值,避免共享 CI 机器的抖动被当作功能失败;它检查实际工作、状态和恢复。性能回归可以另设固定机器上的多次基线比较,并保留波动范围。

用原始 JSON 复算,而不是复制截图

下面的脚本在项目根目录运行,读取仓库保存的百万任务数据,检查数量守恒,再按角色规模打印中位耗时、吞吐、运行内 P99 的中位值与最大峰值 RSS:

import json
from pathlib import Path
from statistics import median

path = Path("backend/docs/runtime-million-2026-10-14.json")
report = json.loads(path.read_text(encoding="utf-8"))
for agents in (100, 1000):
    runs = [r for r in report["runs"] if r["agents"] == agents]
    assert len(runs) == 3
    for r in runs:
        assert r["tasks_submitted"] == r["tasks_admitted"] == r["tasks_completed"]
        assert r["tasks_failed"] == r["tasks_rejected"] == 0
        assert r["queue_at_end"] == r["active_at_end"] == 0
        assert r["retained_tasks"] <= 64
    print({
        "agents": agents,
        "elapsed_ms_median": round(median(r["elapsed_ms"] for r in runs), 3),
        "tasks_s_median": round(median(r["tasks_per_second"] for r in runs)),
        "queue_p99_ms_median": median(r["scheduler_latency_p99_ms"] for r in runs),
        "peak_rss_mib_max": round(max(r["peak_rss_bytes"] for r in runs) / 2**20, 2),
    })

聚合规则需要说清:三次运行的 P99 取中位数,不等于把三百万条原始延迟样本合并后重新计算 P99。前者反映三次实验各自尾部的典型值,后者需要完整样本或可合并直方图。报告采用前者,文章不能悄悄把它改称全体任务的 P99。

同理,峰值 RSS 取组内最大值,不能与耗时的中位值拼成一次实际存在的“最佳运行”。表格每列可能采用不同聚合策略,应保持标注一致。

把每个配置组合变成可审计的实验单元

一行结果最好能追溯到一份配置 manifest:commit hash、编译器与版本、优化标志、目标平台、CPU/内存、OS build、电源模式、worker 数、注册角色数、任务总数、每任务 probe 次数、上下文缓冲、提交策略、运行序号和开始时间。少一个关键字段,后来就难以确认两组数字是否公平可比。

manifest 应由 runner 从实际配置自动输出,而不是由人手工填一份可能过时的 README。编译器可以在 build 时注入版本字符串,runner 写入运行参数与 git revision。若构建产物没有包含 source revision,至少保存构建目录的 commit 信息和二进制 hash。重新打包或从安装器运行时,也要检查真正执行的二进制路径。

正式运行前,先用小规模参数校验字段语义和退出条件。比如 1000 任务测试可验证吞吐单位是 task/second,queue peak 不会为负,统计 JSON 能完整解析,输出路径正常。正式百万任务才进行较长压力运行,避免运行一小时后才发现字段单位错或某个计数器溢出。

实验失败也要保存原始记录。若进程崩溃、达到资源上限或任务失败,不要只丢弃那次运行后报告剩余成功数据。失败本身可能揭示规模边界;记录失败阶段、最后有效计数和是否 OOM,才便于判断成功样本是否存在幸存者偏差。

自动化数据检查可以防止基准口径悄悄漂移

一个小型 JSON schema 可定义字段类型、单位和可选条件。elapsed_ms 为有限正数,tasks_completed 是整数,RSS 使用 bytes;若缺 agents 或 workers,聚合脚本拒绝输出表格。字段单位变更时升级 schema version,避免相同名字代表不同口径。

可以做几类一致性断言:

submitted >= admitted >= completed
failed <= admitted
active_at_end == 0 且 queue_at_end == 0(测试成功条件)
retained_tasks <= history_limit
elapsed_ms > 0
tasks_per_second 与 completed / seconds 在舍入容差内
P50 <= P95 <= P99 <= max(同一运行同一口径)
peak_rss_bytes >= rss_at_end_bytes(若两个指标来自同一采样定义)

如果这些条件失败,报告生成应该终止并给出字段名,而不是继续生成看似合理的 README 表格。也可以保存 assertion 结果、解析器版本和原始文件 hash,供 CI 或发布脚本核对。

聚合代码容易发生筛选错误。例如按 agents 筛出三组时,遗漏 workload 字段可能混入 backlog gate;报告日期命名也可能误让读者以为新运行。聚合程序应该用完整配置键分组,并将输出文件命名为主题固定名,运行日期放在数据字段中。这样比在 Python 里硬编码一个 runs[:3] 稳定。

4. 如何解释已有数据与性能变化?

把测量范围和结果一起读,用阶段耗时及内存组成排查变化,避免从相关性跳到因果。

把数字与范围一起展示

注册角色 完成任务 耗时中位值 吞吐 排队 P99 峰值 RSS
100 100,000 5748.495 ms 17,396 task/s 0.422 ms 22.48 MiB
1000 100,000 5771.132 ms 17,328 task/s 0.376 ms 22.57 MiB
100 1,000,000 65971.943 ms 15,158 task/s 2.039 ms 78.65 MiB
1000 1,000,000 72279.054 ms 13,835 task/s 14.790 ms 77.92 MiB

百万任务扩展包含 100/1000 角色各 3 轮,共实际完成 600 万项任务。报告记录所有提交任务被接纳并完成,失败与拒绝为零,结束时活动数和队列数为零;完成历史保留上限为 64 条。

RSS 还包含基准自身保留的验证数组,不能把它全部归因于调度器,也不能根据长任务测试的 RSS 增加就直接判断历史泄漏。

平均吞吐不能盖住排队突增

1000 角色下,百万任务的吞吐约比已有十万任务基准下降 20%,排队 P99 上升到 14.790 ms,采样队列峰值达到 6201。这个结果值得展示,因为它明确告诉我们:长时间运行时,平均值之外还存在突发排队。

这组数据不能证明线性扩展,也没有建立生产延迟 SLO。若只保留最好的吞吐数字,读者反而无法判断系统在较长负载下的行为。

“累计完成百万任务”也不等于“百万任务同时积压”。项目另外设置 backlog gate,在十万任务提交完成前阻塞 Worker,观察到 99,992 条真实排队任务。这是独立负载,不能与普通吞吐结果混为一个场景。

尾延迟上升说明什么,又没有说明什么

1000 角色时,百万任务排队 P99 达到 14.790 ms,而十万任务的对应值为 0.376 ms。这说明该环境和负载下,较长测试存在更明显的排队尾部。它没有直接证明是哪一个锁、分配器或操作系统调度事件造成了退化。

要定位原因,下一步应对照提交采样、活跃数、队列长度与 RSS 时间线,再做单变量实验:固定任务总数,改变 worker 数;固定 worker 数,改变提交压力;固定上下文访问方式,改变角色数。没有这些对照,仅凭“角色变多了”就归因注册表查找,是证据不足的。

三次重复也不足以建立稳定的生产 SLO。机器温度、电源模式、后台程序和运行顺序都可能影响结果。当前数据适合暴露规模行为并形成后续假设,不能替代长期线上分布。

基准程序的分层成本

bench runner 取提交时间与 runner 开始时间差作为调度延迟。这一字段不包括模型端到端等待,因为本次负载没有调用生产模型。每个任务还会通过 AgentPool 找角色、组织 prompt 并访问共享 Context 数据。虽然局部操作很轻,它仍通过真实 Scheduler 与角色注册结构运行,所以可以观测队列和资源管理成本。

要理解计时窗口,建议沿源码逐个检查时间戳在哪里写入:提交前、进入 ready queue、worker 出队、runner 开始、输出生成、终态落定。若 submitted time 在锁外记录,而 worker start 在锁内记录,两者差值可能包含锁竞争;这仍然是排队体验的一部分,但不代表 CPU 等待的精确分解。

每任务执行 64 次 context probe,是固定工作量参数。它提高本地 runner 的计算与内存访问成本,使测试不至于完全被队列提交循环主导。但固定 probe 次数不代表真实 Agent 每个请求都进行 64 次相同检索,也没有包含真实 embedding、Provider tokenization 或文档解析。

基准还保留用于数量校验的 work/latency 数据结构,RSS 因此包含测试 harness 自身的占用。若要估计 Scheduler 结构单独占用,可以另做内存分解或按任务总数拟合增长斜率,再扣除固定进程基线。但基准数组与调度器分配可能具有不同生命周期,简单地将一组峰值 RSS 减去一次启动 RSS 也不一定准确。

辅助 Runtime Worker 用于测试期间的其他运行时支撑,它们在 idle 状态仍持有线程栈和少量对象。报告如果只写业务 worker 数而忽略这些后台线程,就会低估空闲基线。进程 RSS 也受操作系统页面缓存、分配器保留和栈按需提交影响,所以同一代码多次运行可能存在小幅变化。

任务规模扩展了哪些内存结构

million task test 会逐项创建任务对象,但 Scheduler 的完成历史配置只保留有限条完成记录。任务队列、运行中对象和已完成历史应分别看生命周期。若 runner 持有外部数组引用、测试 harness 保存每任务 latency 或 journal 写入磁盘,清理完成历史也不会让整个 benchmark 的 RSS 保持常数。

观察 RSS 曲线比单个 peak 更有信息:任务创建时快速增长,队列排空后回落,还是随着每一批持续阶梯上涨?峰值采样间隔可能错过短暂高点;Windows Working Set 与 Linux RSS 口径也不完全相同。报告应说明测量工具、采样频率、单位换算和进程树是否包含子进程。

可以用固定批次观察驻留变化:先运行 100,000 项并排空,记录 RSS;清理可回收历史后再运行第二批;重复多轮,比较每次排空后的基线。若内存持续上涨,再通过 heap profiling 对象类型归因。如果只有首次增长后趋稳,可能是 allocator arena 保留或缓存预热,而非每批泄漏。仍需证据区分,而不是从 RSS 曲线形状直接下结论。

完成历史上限也要检查字节维度。保留 64 个任务的条数很小,但若每个任务保存很大的 output、progress JSON 或 trace,实际内存仍可能很大。反过来,按总字节限制也需处理单个任务本身超过限制的情况。count 与 bytes 两类上限共同决定 history pruning 行为。

线程栈通常随活跃 worker 数与协程栈策略增长,而不是随等待队列中的任务数线性增长。懒分配协程是降低待运行项成本的关键设计之一,但需要测量真实库如何 reserve/commit stack。Windows Fiber、POSIX ucontext 和自定义 coroutine 的地址空间与物理 RSS 行为可能不同,跨平台不能直接共享一个内存结论。

吞吐曲线需要与并行度同时解释

对多个 worker 的理想 CPU bound workload,吞吐可能随 worker 增加直至 CPU 核数或内存带宽成为瓶颈;对 Provider 等待为主的工作,更多 worker 可能提高在途请求数,但随后受连接池、rate limit 和外部服务容量限制。若任务阻塞线程却不释放,增加 worker 可能暂时缓解,却不解决架构上的阻塞占用。

可把曲线按三种区域解释:低 worker 时并行资源不足;中间区域吞吐提升且 queue latency 可控;高 worker 区域吞吐趋平甚至因锁争用、上下文切换和内存带宽而下降。要确认具体区域,需要同时看 CPU 使用率、锁等待、context switch、worker idle 比例、队列长度和外部请求延迟。

线程数 sweep 应重复且随机顺序。例如 1→2→4→8 一次运行可能受到升温和后台负载影响;下一轮反序或随机化顺序可以检查顺序偏差。每个配置运行前恢复相同初始 state,避免上一轮留下 cache 或历史对象污染后续测试。

注册角色数 sweep 与 worker sweep 是不同实验。角色数量增大可能增加 AgentPool 查找与元数据内存,但不必增加并发 worker;线程增加影响执行并行。最好做二维因子设计,也可先固定一个变量分别测,避免一次改变角色、worker 和 probe 数后无法知道瓶颈来自哪里。

把性能退化定位到具体阶段

发现吞吐下降时,先确认任务数量守恒和同一 workload,再把总时间分段。Scheduler queue latency 增长意味着任务在开始前等待;runner duration 增长说明执行逻辑变慢;completion/finalization 增长可能来自 journal、回调或历史裁剪。任务运行时间之外的阶段如果未打点,就应先加轻量 span,而不是直接重写 heap。

如果 Worker 处于 idle 但队列存在,可能是锁或条件变量唤醒问题;如果所有 Worker 持续 busy 但 CPU 使用低,可能大量阻塞 I/O;CPU 饱和则要查看 profiling 热点和缓存 miss;队列突增同时 active 数固定,可能是提交速率超过有效服务率。每个假设都需要匹配观测。

同步 mutex 长时间持有时,可采样 lock wait 与 hold duration。高频短锁可能显示竞争但不一定显著拖慢吞吐;少数长锁会形成尾部停顿。只统计锁获取次数不足以说明影响,还需要等待时间分布和持锁上下文。

数据结构优化应有前后对照。比如将线性任务查找改为哈希索引,除了总 tasks/s,还要测 index build、内存增长、碰撞和删除历史时的维护成本。若十万规模下查找并不占 profile 主要时间,复杂度上升可能不值得。性能工程的目标是移除实际瓶颈,不是让算法符号更漂亮。

常见的图表误读

图表上的说法 数据实际能支持的结论
“百万并发 Agent” 测试累计提交并完成 1,000,000 个本地任务
“生产请求 P99 约 2 ms” 该 fixture 的 Scheduler 排队延迟 P99 约 2 ms
“注册 1000 个 Agent 没有成本” 这组测量没有看到明显吞吐差异,但样本与负载有限
“百万任务内存小于 80 MiB” 测试进程在给定机器与 harness 下的峰值 RSS 约 78–78.65 MiB
“三次测试证明稳定 SLO” 只有三次可重复历史观测,未建立生产分位数承诺
“吞吐下降由某个锁造成” 现有数据观察到下降和排队上升,根因尚待隔离实验

读者可以用反例测试解读是否准确:换一种更慢的 runner,Scheduler 是否仍能维持同样 throughput?如果不行,数据显然绑定于当前本地 runner;增加模型网络等待后,端到端吞吐也会被其他瓶颈限制。一个好的图表标题应该包含负载类型和统计口径,而不是抽掉条件后留下最大的数字。

面试中解释这组测试

可以先用一句话划定范围:“这是 Scheduler 与 AgentPool 的本地确定性工作负载,测累计百万任务和单独的大积压场景,没有请求百万次模型推理。”随后解释每任务包含哪些操作、多少 worker、角色注册规模、重复次数与聚合方法,再展示吞吐和排队 P99 同时变化的案例。

如果被问为什么角色增加后吞吐有变化,应该回答这组数据只能发现现象,不能单独定位原因。下一步会在相同机器与负载下改变角色查找方式或测量调用栈,再观察 profiling 数据。能够主动说明结论边界,比把一个不确定根因说成事实更可信。

如果被问这些数字能否预测线上容量,答案取决于线上每任务是否包含相同的本地控制工作,以及模型、工具和网络的成本。可以将 Runtime 本地成本当作系统开销测量,再把生产 Provider 的独立延迟模型加进去;必须实际跑组合工作负载,不能直接把 15,000 task/s 换算成用户数。

5. 下一轮实验怎样控制变量?

一次优先改变一个因素,同时保留运行条件与业务负载差异,才能评价优化收益。

下一轮实验的控制变量

生产模型测试需要与 Runtime 局部测量配合。远程限流和生成时间可能掩盖本地开销,分层测量有助于定位,最后仍需组合成端到端评估。

现有吞吐下降值得调查,但不能直接证明某段实现存在缺陷。应固定任务数、上下文工作量和提交压力,分别改变 worker 数与角色数,再结合热点与时间线分析。

复测必须记录代码提交、编译器与参数、机器和系统、工作负载、重复次数、采样方法及聚合规则。先确认这些条件,数字才具备比较基础;累计百万任务也不能直接换算成百万并发用户。

源码阅读基线:runner 与测量字段、测量组合与断言。

怎样设计下一组可归因的实验

当前表格对长负载中角色规模与尾部排队有关联,但不能说明因果。下一轮实验可以用逐项控制方式:

  1. 固定任务量和 8 worker,比较 100 与 1000 个注册角色,并记录角色查找耗时和队列分位数。
  2. 固定角色数和任务量,改变 1、2、4、8 worker,检查吞吐、上下文争用与锁等待。
  3. 固定 worker 与角色数,改变提交速率,以多个稳定档位测背压与峰值队列。
  4. 固定前述参数,减少或增加每任务 context probe,区分调度路径与 payload 访问成本。
  5. 每种组合至少重复多次,并随机化组合顺序,保存原始每次结果。

这个计划只能定位关联,仍需要 profiling 或事件 tracing 才能确定热点。如果角色查找耗时很小、队列尾延迟仍高,就该检查锁、工作线程调度、内存带宽或系统后台负载。性能假设应该随着数据更新,而不是先选一个看起来合理的原因再搜支持证据。

Provider 端到端实验可以随后补充,但要单独控制模型与 token 数、请求并发、网络区域、速率限制、工具调用和缓存。真实模型随机生成难以做纯性能微基准,可以通过固定输出长度和 prompt 结构减少变量,再将服务端 latency 单独计量。比较模型版本时还要避免质量差异造成输出长度不同。

比较版本时控制改变因素

同一机器同一 workload 的 before/after 比较仍可能受系统状态影响。应固定 commit、编译器、参数、数据集、worker 配置和提交速率;新旧二进制用相同脚本顺序交替运行。若改了 runner 工作量或统计字段,就要标成不同的 benchmark version,不能只对比一个 tasks/s 数值。

测试期间最好记录频率、温度或 power state。笔记本在电池模式与接电模式下可能限制 CPU;Windows 更新、杀毒软件扫描和索引任务会造成突发噪声。执行前检查后台负载并写入 manifest,不一定要因有噪声就删除数据,但要解释明显异常。

编译参数对 C runtime 有很大影响。debug build 增加检查与符号,优化级别改变内联和分配模式;静态与动态库、LTO、sanitize 也会改变速度和内存。报告应确保 benchmark 和 release 安装包使用相同配置,或明确说明两者用途不同。

跨机器的比较应采用相对基线和环境描述,不要只比较绝对值。CPU 名称之外还应写核心数、内存带宽/通道、虚拟化环境、容器 CPU quota 和存储类型。若要声称某版本提升百分比,需在同一机器上有配对测量并报告分布,而不是拿两个发布者各自的最佳结果相减。

运行稳定性需要多个时间尺度

单个百万任务运行主要观察十几秒到数分钟的行为;长时间在线稳定性可能需要数小时或数天。较长测试可以捕捉定时清理、journal 增长、定期重连、配置刷新、时间戳溢出和内存碎片化等问题。它的 workload 应逐步增加真实的慢工具、错误和取消,而非只重复最轻的 runner。

稳定性测试应定期采集 p50/p95/p99、队列长度、活动数、失败率、RSS、线程数量和日志体积。发现趋势后,再拆分一个短、可控的 reproducer;否则长测每次失败都需要等待数小时。CI 可运行短 smoke,nightly 在独立机器上进行多小时测试,保存数据和版本元信息。

当到达负载不稳定时,应显式包含 pause 与 burst。暂停阶段检查队列排空和资源回落;burst 阶段观察拒绝与超时;长时间平稳阶段查看 drift。只跑固定速率无法展示从过载中恢复时的积压和 retry storm。

需要对比代码版本时,交替运行 baseline 与 change,而不是先全部跑 baseline 再全部跑 change;机器温度或系统更新可能与版本顺序相关。若用专用基准机,应固定 CPU governor、电源模式、后台服务和系统补丁,在报告记录采集环境。

Benchmark 与业务容量规划之间还差哪些参数

本地 task/s 不能直接变成可服务用户数。业务容量还需要用户请求到任务数的分布、每个任务的模型调用次数、模型 token 限额、Provider 并发限制、工具 API 延迟、失败重试率、请求持续时间和峰值到达模型。若每个业务请求触发十个 Agent task,单任务吞吐需要再按工作流展开。

一种容量草算可以用每任务消耗的平均 worker 时间和并发度:在 C 个有效执行槽下,理论完成率约不超过 C / E[worker occupancy],还需扣除调度、锁和外部资源限制。若大多数任务阻塞在线程上等待模型,worker occupancy 会被网络等待主导;若 await 能释放 worker,则模型延迟不会同样直接占住执行槽。需要 profiling 或 trace 证明具体调度语义。

尾延迟目标还需要基于真实业务分布。如果任务依赖 3 个串行 Agent,单个 Agent 的 P99 不能简单相乘当作 Flow P99,因为变量相关、重试和工具时延都会改变分布。最好对整个工作流保存端到端样本,并同时记录各 span 以解释慢请求。

预算规划也要考虑失败突发。Provider rate limit 或某个工具不可用时,任务重试会提高请求率而不是降低;如果没有 retry budget、backoff 和熔断,系统可能在依赖故障时把本地队列推高。基准应有故障场景,观察任务是否快速失败、排队是否可控以及依赖恢复后能否逐步回到稳定态。

6. 如何发布可核对的性能结论?

报告保留数据、脚本和限制,确保读者能重新得到图表与相同口径的结论。

数据保留和图表生成的工程细节

基准 JSON 适合作为原始证据,表格和图只是视图。JSON 应包含 schema version、单位、配置和每次独立运行数据;图表生成器从同一 JSON 计算,避免文章表格手写一组值、图上又使用另一组。数值最好保留足够小数供复算,展示时再按列统一舍入。

异常值处理要先声明规则。中位数会降低单个系统抖动影响,但不能说明所有异常都无关;若某次发生 GC、系统更新、后台备份或 benchmark harness failure,应保留原始记录并解释是否纳入,而非悄悄删掉最慢一轮。可以另外报告全范围和每次运行值,让读者看到方差。

对数坐标适合展示数量级差异,但会让小值视觉差异看起来变大;截断纵轴可能夸大很小的变化。标注 P99 时要说明来自多少样本;RSS 与任务数的关系可以同时画绝对值和每任务摊销值,但后者容易掩盖固定 overhead。图上注明 fixture 类型,比加入无关装饰更有用。

保存原始数据的同时,可计算摘要 hash,并在 README 写入报告生成 commit。这样文章更新后可以确认原始 JSON 是否变过。若报告含本机用户名、完整目录或内网路径,发布前应脱敏,但不能移除理解编译工具和实验环境所需的技术信息。

发布结果前可以做一次数据审阅

审阅时先从原始 JSON 随机抽一轮,与报告中对应的行手工复算;再检查所有组的任务数、失败数、结束队列和 retained history;最后检查 README 的单位、舍入和说明文字。由另一位开发者用干净环境运行复算脚本,比作者自己重复读取同一表格更容易发现复制错误。

表格应放弃过度精度。吞吐展示到整数 task/s 即足够表达差别,耗时可保留三位小数,RSS 保留两位;原始 JSON 则保存完整浮点数。每列舍入规则写在脚注,避免读者用展示值反算时因四舍五入误以为数据不一致。

审阅后冻结报告文件 hash,并在文章中引用它。后续补充解释时不必重跑 benchmark;如果数据或聚合算法改变,应标记新 schema 或新报告版本。这样文章修订、代码变化和性能测量不会混成一个难以追溯的“最新数字”。

发布性能图时同时发布测量脚本

脚本能固定字段筛选和聚合逻辑,读者修改原始 JSON 后也可以重建表格。脚本应失败即退出,要求输入文件路径为显式参数,并输出使用的文件 hash、分组键和舍入规则。README 可附一条本地复算命令,避免作者环境中的相对路径成为隐含依赖。

若脚本在运行中自动下载模型、修改系统配置或创建大量临时文件,应明确告知并让用户显式选择;性能复算不应偷偷发起昂贵 Provider 请求。现有本地 fixture 测量可以保持离线,使读者不用配置 API key 即可理解计量方法。

图表配色和标注要保证黑白打印仍能区分系列。不同 workload 用不同图型或线型,表格列名写明单位和聚合方式。不要把失败数为零的统计压缩掉,因为它是工作量守恒的一部分。

这些测试对项目有什么帮助

它们把“Agent 很多时会不会出问题”拆成可测的问题:注册表查找是否退化?排队延迟如何变化?历史保留是否有界?结束时任务数量是否守恒?积压放行之后能否排空?

这是一组系统工程证据。对于实际业务 Agent,还要继续测模型延迟、限流、工具 I/O、外部失败和具体任务完成率。

数据:完整报告、百万任务原始 JSON。源码:基准程序。

延伸阅读与实践顺序

完成本文后,建议选取“怎样复现并检查原始结果?”中的一个分支,用固定输入记录预期行为、实际结果和差异。先验证一个具体不变量,再扩大任务规模;若结果不同,优先核对源码版本、配置与输入证据。

上一篇:可靠性与恢复;下一篇:三个上手实验。欢迎提交带机器、编译参数和原始结果的复测,让不同平台的数据能在相同口径下讨论。

在 GitHub 阅读 Markdown 源文反馈与交流 ↗