EXO Exo-Bench 基准测试方法论:提示词构造、冷缓存计时与并发吞吐的完整实现解析
EXO 的基准测试工具 exo bench 用于在受控条件下测量集群的推理吞吐率与资源消耗。读完本篇,你将掌握 bench/METHODOLOGY.md 中定义的完整方法论——从精确控制 prompt 长度的提示词构造算法,到服务端 /bench/chat/completions 端点的三项关键改动、TPS 计算公式、并发压测机制与功耗采样原理,并能结合 bench/exo_bench.py 与 batch_generate.py 的源码理解每个数字是如何产生的。
一、为什么需要专用的基准端点
基准测试的目标是获得准确、透明、可复现的数字,用来比较不同模型、不同集群配置下的速度与扩展性,并随 EXO 优化与功能迭代持续追踪结果变化。
普通 chat completion 端点做不到这一点:前一次请求的 KV 前缀缓存会污染下一次 prefill 计时;模型可能提前生成 EOS 提前结束,导致每次生成长度不一致;模型输出的工具调用标签或结构化内容还可能干扰文本统计。因此 EXO 单独提供了 /bench/chat/completions 端点,它在三个地方与正常 chat completion 不同:
- KV 前缀缓存默认禁用:每个请求都从冷缓存开始,确保 prefill 计时不受先前请求影响。
- 禁用 EOS 令牌:一个 logits 处理器会抑制所有 end-of-sequence 令牌,强制模型恰好生成
max_tokens个 token,保证生成长度一致、TPS 比较公平。 - 不做模型输出解析:bench 收集路径直接拼接原始 token 文本,不经过 thinking 标签提取、结构化输出处理等模型特定的后处理,避免输出内容破坏基准——速度测试只关心速度,性能质量指标请见 Exo-Eval(bench/exo_eval.py)。
服务端实现证据
端点注册在 src/exo/api/main.py#L365-L367。bench_chat_completions 处理器(src/exo/api/main.py#L950-L965)会在下发任务参数时强制打上两个标志:
task_params = task_params.model_copy(
update={
"stream": False,
"bench": True,
"use_prefix_cache": payload.use_prefix_cache,
}
)
其中 use_prefix_cache 是请求体里的显式字段,默认 False——这正是"默认冷缓存"的实现位置。
KV 缓存的开关逻辑在 MLLM 批生成器 batch_generate.py#L157-L166 中:
is_bench = task_params.bench
...
if self.kv_prefix_cache is not None and (
not is_bench or task_params.use_prefix_cache
):
cache, remaining_tokens, matched_index, is_exact_hit = (
self.kv_prefix_cache.get_kv_cache(...)
)
即只有 bench=True 且显式请求 use_prefix_cache=True 时才会查询前缀缓存;bench 请求结束后同样只在满足该条件时才写回缓存(batch_generate.py#L267-L272)。
EOS 抑制则实现为在采样器之前插入一个 ban_token_ids 的 logits 处理器(batch_generate.py#L294-L297):
if is_bench:
# Only sample length eos tokens
eos_ids = eos_ids_from_tokenizer(self.tokenizer)
logits_processors = [ban_token_ids(eos_ids)] + logits_processors
这样模型无法"偷懒"提前停止,每次运行都精确产出 tg(--tg 指定的 max_tokens)个 token。
二、提示词构造:如何在只有 chat 端点时得到精确的 token 长度
基准测试需要 token 长度精确可控的 prompt,但客户端拿不到模型本体,只有 chat completion 端点。EXO 的解法(bench/METHODOLOGY.md "Prompt Construction" 一节)分三步:
- 通过模型的
apply_chat_template()对一条样例消息做 tokenize,测量模板带来的开销(system token、特殊 token、chat 格式标记); - 对一个重复的原子字符串(默认
"a ")做二分搜索,找到使模板展开后恰好等于目标 token 数的内容长度; - 同时返回内容字符串和验证后的 token 数。
实际 token 数会记录在每条结果行的 pp_tokens 字段中,便于下游分析确认 prompt 命中目标。
客户端实现是 bench/exo_bench.py 中的 PromptSizer 类(L357-L425),与方法论描述一一对应:
class PromptSizer:
def __init__(self, tokenizer: Any, atom: str = "a "):
self.tokenizer = tokenizer
self.atom = atom
self.count_fn = PromptSizer._make_counter(tokenizer)
self.base_tokens = self.count_fn("")
_make_counter内部对{"role": "user", "content": ...}执行apply_chat_template(messages, tokenize=True, add_generation_prompt=True),得到包含全部模板开销的 token 总数(L364-L386);self.base_tokens = self.count_fn("")就是空内容时的模板开销基线。build(target_prompt_tokens)先用 100 个原子的样本估算"每原子 token 数",再二分搜索精确的原子的个数(L405-L415):
low, high = 0, estimated_atoms * 2 + 100
while low < high:
mid = (low + high) // 2
tok = self.count_fn(self.atom * mid)
if tok < target:
low = mid + 1
else:
high = mid
content = self.atom * low
tok = self.count_fn(content)
if tok != target:
raise RuntimeError(
f"Overshot: got {tok} tokens (target {target}). "
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
)
两个细节值得注意:
- 如果目标长度小于模板开销,会直接抛错(L390-L393):"Target ... is smaller than template overhead"。这解释了方法论中"chat 模板格式意味着某些很小的 pp 基准可能做不到,例如
pp=32可能无法工作"——这是为了可复现性而主动放弃的边界情况,因为极短 prompt 的结果对真实场景参考价值有限。 - 二分可能"越过"目标(例如 token 数从
target-1直接跳到target+1),此时报错并建议换一个原子(' a'、'\n'、'0 ')。
另外,load_tokenizer_for_bench 对 Kimi 系列做了特殊处理:Kimi 使用自定义的 TikTokenTokenizer,transformers 5.x 无法通过 AutoTokenizer 直接加载,因此客户端会下载 tokenizer 文件、动态加载 tokenization_kimi.py 并修补 encode。这说明方法论第 1 步的"通过模型模板测开销"在客户端是真实跑了一遍完整 tokenize 流程,而非近似。
三、计时:Prefill TPS 与 Generation TPS 的精确公式
Prefill TPS
服务端逐任务测量:
prefill_tps = num_prompt_tokens / prefill_wall_seconds
Generation TPS
服务端逐任务测量。每个任务在 token 到达时记录墙钟时间戳:
- 第一个生成 token:记录时间戳
- 之后每个 token:更新时间戳
生成完成后:
gen_span = last_token_time - first_token_time
generation_tps = (completion_tokens - 1) / gen_span
分子排除第一个 token,是因为该速率度量的是逐 token 吞吐(inter-token throughput)——用首尾 token 的时间跨度除以区间数。直接对应 batch_generate.py#L354-L357 与 L418-L424:
now = time.perf_counter()
if state.first_gen_token_time is None:
state.first_gen_token_time = now
state.last_gen_token_time = now
...
if state.completion_tokens > 1:
gen_span = state.last_gen_token_time - state.first_gen_token_time
generation_tps = (
(state.completion_tokens - 1) / gen_span
if gen_span > 0
else 0.0
)
else:
generation_tps = 0.0
由此推出一个约束:tg=1 无法工作——只有 1 个 token 时没有区间可测,generation_tps 恒为 0。
统计结果封装在 GenerationStats(src/exo/api/types/api.py#L163-L168)中,包含 prompt_tps、generation_tps、prompt_tokens、generation_tokens、peak_memory_usage 和 prefix_cache_hit 六个字段,随非流式响应返回给客户端。客户端在 bench/exo_bench.py#L297 从响应的 generation_stats 字段取出这些数字。
四、并发:barrier 同步、batch wall time 与聚合 TPS
单请求
客户端对 HTTP 往返记录墙钟 elapsed_s(网络延迟 + 服务端 prefill + 生成 + 响应序列化),作为端到端延迟的便捷指标。但权威的 TPS 数字来自服务端的逐任务计时(generation_stats),而非客户端的往返时间。
并发请求
当 --concurrency N(N > 1)时,所有 N 个请求必须在同一瞬间打到服务端。机制如下,bench/exo_bench.py#L732-L799 中有完整实现:
- prompt 只构造一次,所有线程共享(
prompt_sizer.build(pp)在提交线程池之前调用); - 每个线程持有自己的 HTTP 连接(
ExoClient每线程新建); threading.Barrier(concurrency)阻塞所有线程直到全部就绪;- 第一个穿过 barrier 的线程记录批次开始时间(
batch_t0)并set一个threading.Event通知其他线程; - 所有线程以同一开始时间为基准发出 HTTP 请求;
- 每个线程的
elapsed_s= 共享开始时间 → 该请求响应完成。
barrier = threading.Barrier(concurrency)
batch_start = threading.Event()
...
def _run_concurrent(idx, ...):
nonlocal batch_t0
c = ExoClient(args.host, args.port, timeout_s=args.timeout)
if _barrier.wait() == 0:
batch_t0 = time.perf_counter()
_batch_start.set()
else:
_batch_start.wait()
t0 = batch_t0
out = c.post_bench_chat_completions(_payload)
elapsed = time.perf_counter() - t0
batch wall time 是 N 个请求中 elapsed_s 的最大值——最后一个请求完成为止的时间(L795-L799)。
聚合 TPS
per_req_tps = max(N 个并发请求的 generation_tps)
agg_gen_tps = per_req_tps * concurrency
对应 bench/exo_bench.py#L829-L844:
valid_gen_tps = [
x["stats"]["generation_tps"]
for x, _ in batch_results
if x["stats"]["generation_tps"] > 0
]
per_req_tps = (max(valid_gen_tps) if valid_gen_tps else 0.0)
agg_gen_tps = per_req_tps * concurrency
为什么用 max 而不是 mean? 因为所有请求是并行地打向同一个模型:最快请求的生成速率代表系统的单流吞吐能力,再乘以并发度即得聚合吞吐。若取均值,慢请求会把指标拉低,反而无法反映系统的真实并发容量。
pp/tg 配对与重复执行
命令行参数 --pp 和 --tg 都支持逗号分隔的列表。两者的配对规则(L520-L527):
- 长度相同 → 按位置 zip 成对执行(tandem 模式);
- 长度不同,或显式传
--all-combinations→ 笛卡尔积,跑全部组合。
每一对 (pp, tg) 再乘以 --concurrency 列表与 --repeat 重复次数。每次 repeat 之间 time.sleep(3),每个 (pp, tg, concurrency) 组结束后 time.sleep(2)(L699-L700、L898),让集群在采样窗口之间回稳。
五、前缀缓存模式(--use-prefix-cache)
默认关闭前缀缓存是为了冷 prefill 测量。但当你测的重点是生成吞吐或功耗(而不是 prompt 处理速度)时,可以传 --use-prefix-cache 让 KV 前缀缓存保持激活,跳过重复 prefill 工作,加速多配置扫描。
此时每个响应的 stats 中带 prefix_cache_hit 字段("none" / "partial" / "exact",定义见 batch_generate.py#L87):
| 取值 | 含义 | prompt_tps 的含义 |
|---|---|---|
none |
冷 prefill,无缓存 KV 状态可用 | 真实的 prefill 吞吐 |
partial |
prompt 的前缀命中缓存,只对剩余 token 做 prefill | 未缓存部分的真实吞吐 |
exact |
整个 prompt 都在缓存里,本次没有 prefill 工作 | 报告的是缓存条目最初创建时的 TPS,不是新测量值 |
partial 的典型场景是递增的 --pp 共享前缀:例如 --pp 1000,5000,5000-token 的 prompt 会复用 1000-token 的缓存条目,只 prefill 剩余 4000 个 token。
该模式下 prompt TPS 是近似值。 exact 命中的运行报告的是当初冷/部分 prefill 时存储的 TPS,而非新测值。要获得准确的冷 prefill 数字,请不带 --use-prefix-cache 运行。
实操建议:--pp 用升序(如 --pp 1000,5000,10000)能拿到最有用的数据——除第一个(冷)外每个尺寸都是有意义的 partial 命中;降序则产生 exact 命中,TPS 只是长 prompt 原始运行的近似值。工具自身也会在检测到非升序 --pp 时告警(bench/exo_bench.py#L511-L518):
if args.use_prefix_cache:
logger.warning(
"--use-prefix-cache: prompt TPS will be approximate. See METHODOLOGY.md for details."
)
if pp_list != sorted(pp_list):
logger.warning(
"--pp values are not in ascending order: prompt TPS will be less accurate. ..."
)
注意客户端在构造 payload 时也会把该标志原样传给服务端(bench/exo_bench.py#L283-L289 的 "use_prefix_cache": use_prefix_cache),与 batch_generate.py#L164-L166 的判断逻辑闭环。
六、Warmup 与系统级指标
Warmup
在正式测量前,--warmup N(默认 0)个丢弃请求会使用第一个 pp/tg 对发出,warmup 结果不进入输出。实现见 bench/exo_bench.py#L683-L685:
for i in range(args.warmup):
_do_one(client, pp_list[0], tg_list[0])
logger.debug(f" warmup {i + 1}/{args.warmup} done")
系统指标
一个后台守护线程以 1 Hz 轮询每个节点(--metrics-interval 可调,默认 1.0s;--no-system-metrics 可完全关闭),采集:
- GPU 利用率(%)
- 温度(°C)
- 系统功耗(W)
- CPU 簇使用率(性能核与能效核,
pcpuUsage/ecpuUsage)
实现是 SystemMetricsSampler(bench/exo_bench.py#L180-L213),采样键正是这五项:
_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")
能量计算采用梯形积分:对每个推理窗口(单个基准请求或并发批次的墙钟跨度)内的功耗采样做积分,平均功率 = total_joules / total_inference_seconds。对应 energy_between(L215-L226):
for i in range(1, len(window)):
dt = window[i][0] - window[i - 1][0]
avg_power = (window[i][1] + window[i - 1][1]) / 2
total_joules += avg_power * dt
服务端侧则更细:每个非流式 /bench/chat/completions 响应额外返回 power_usage 块,把能量按 prefill / generation 两阶段拆分,分界锚定在 runner 发出的第一个非 PrefillProgressChunk 上。客户端收集路径 _collect_text_generation_with_stats 中的 PowerSampler 在收到第一个生成 chunk 时调用 sampler.mark_prefill_done()(L805-L809),最后随响应一起返回(power_usage=sampler.result(),L859)。
客户端打印的 per-(pp,tg,concurrency) 汇总行即由两部分组成:本地采样器积分得到的总能量 + 服务端拆分出的 prefill_energy / gen_energy(bench/exo_bench.py#L868-L897):
prompt_tps=... gen_tps=... prompt_tokens=... gen_tokens=... peak_memory=...
energy=...J (...W avg over ...s inference)
prefill_energy=...J gen_energy=...J
七、输出格式
结果写为 JSON(默认 bench/results.json,可用 --json-out 指定路径或 --stdout 打到标准输出),三个顶层键(bench/exo_bench.py#L919-L923):
runs:逐请求结果对象数组,每个对象包含:elapsed_s、output_text_preview(前 200 字符)stats:{ prompt_tps, generation_tps, prompt_tokens, generation_tokens, peak_memory_usage }power_usage:服务端总量 + prefill/generation 拆分 + 逐节点明细(仅非流式请求;流式路径下为None,见 bench/exo_bench.py#L334)- 放置元数据:
model_id、placement_sharding、placement_instance_meta、placement_nodes - 运行元数据:
pp_tokens、tg、repeat_index、concurrency、concurrent_index download_duration_s(若模型是本次新下载的)
cluster:基准测试时刻的集群状态快照(capture_cluster_snapshot在首个 placement 前抓取,L607)system_metrics:逐节点时序采样(GPU、功耗、温度)的 min/max/mean/samples 汇总(SystemMetricsSampler.summarize,L228-L243)
八、复现一条基准
方法论给出的标准复现命令如下(两节点张量并行、两个 prompt 长度、128 个生成 token、每点重复 3 次、1 次 warmup):
cd bench && uv run python exo_bench.py \
--model "mlx-community/Qwen3.5-27B-4bit" \
--instance-meta jaccl \
--sharding tensor \
--min-nodes 2 --max-nodes 2 \
--pp 512 4096 --tg 128 \
--repeat 3 \
--warmup 1
常用可选项(均来自 bench/exo_bench.py 的 argparse 定义):
| 参数 | 默认值 | 说明 |
|---|---|---|
--pp / --tg |
必填 | prompt 长度提示 / 生成长度,接受逗号分隔列表 |
--repeat |
1 | 每个 (pp, tg) 对的重复次数 |
--concurrency |
1 | 并发级别列表,如 --concurrency 1,2,4,8 |
--warmup |
0 | 每个 placement 的 warmup 次数(用第一个 pp/tg) |
--use-prefix-cache |
关 | bench 期间启用 KV 前缀缓存(prompt TPS 变近似) |
--stream |
关 | 走流式 SSE 路径(仍应用 bench 语义:禁 EOS、默认无 KV 缓存) |
--no-system-metrics |
关 | 关闭 GPU 利用率/温度/功耗采集 |
--metrics-interval |
1.0 | 系统指标轮询间隔(秒) |
--json-out |
bench/results.json |
原始逐 run 结果 JSON 输出路径 |
--stdout |
关 | 结果打到标准输出 |
--dry-run |
关 | 仅列出选中的 placement 后退出 |
--all-combinations |
关 | pp/tg 等长时也强制笛卡尔积 |
完整参数可运行 --help 查看。运行前提是集群已有 exo 服务在监听(默认 host/port 可通过 harness 的公共参数覆盖),且目标模型可下载或已在集群缓存中;工具会自动走规划阶段检查下载,把下载耗时记入 download_duration_s(bench/exo_bench.py#L593-L605)。
九、方法论小结
EXO Exo-Bench 的设计原则可以归纳为三点:
- 变量隔离:冷缓存、禁 EOS、无解析、barrier 同步——把网络、缓存、提前停止、输出解析等干扰全部排除,让 TPS 只反映推理本身;
- 权威计时在服务端:客户端
elapsed_s只是端到端延迟的便捷值,generation_stats中的服务端逐任务计时才是比较依据; - 每个数字可追溯:
pp_tokens验证 prompt 命中、prefix_cache_hit标注缓存命中类型、power_usage按阶段拆分能量、cluster快照冻结测试时的集群状态——结果 JSON 的每个字段都能回答"这个数字是怎么来的"。
该方法论与基准实现可能随时间演进,bench/METHODOLOGY.md 会随变更保持更新;实现层的关键入口是 bench/exo_bench.py(客户端与指标采集)、src/exo/api/main.py(bench 端点与 PowerSampler 收集)、src/exo/worker/engines/mlx/generator/batch_generate.py(KV 缓存开关、EOS 抑制与 TPS 计算),可沿这些路径进一步深入。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00