首页
/ EXO Exo-Bench 基准测试方法论:提示词构造、冷缓存计时与并发吞吐的完整实现解析

EXO Exo-Bench 基准测试方法论:提示词构造、冷缓存计时与并发吞吐的完整实现解析

2026-09-05 20:20:52作者:蔡怀权

EXO 的基准测试工具 exo bench 用于在受控条件下测量集群的推理吞吐率与资源消耗。读完本篇,你将掌握 bench/METHODOLOGY.md 中定义的完整方法论——从精确控制 prompt 长度的提示词构造算法,到服务端 /bench/chat/completions 端点的三项关键改动、TPS 计算公式、并发压测机制与功耗采样原理,并能结合 bench/exo_bench.pybatch_generate.py 的源码理解每个数字是如何产生的。


一、为什么需要专用的基准端点

基准测试的目标是获得准确、透明、可复现的数字,用来比较不同模型、不同集群配置下的速度与扩展性,并随 EXO 优化与功能迭代持续追踪结果变化。

普通 chat completion 端点做不到这一点:前一次请求的 KV 前缀缓存会污染下一次 prefill 计时;模型可能提前生成 EOS 提前结束,导致每次生成长度不一致;模型输出的工具调用标签或结构化内容还可能干扰文本统计。因此 EXO 单独提供了 /bench/chat/completions 端点,它在三个地方与正常 chat completion 不同:

  1. KV 前缀缓存默认禁用:每个请求都从冷缓存开始,确保 prefill 计时不受先前请求影响。
  2. 禁用 EOS 令牌:一个 logits 处理器会抑制所有 end-of-sequence 令牌,强制模型恰好生成 max_tokens 个 token,保证生成长度一致、TPS 比较公平。
  3. 不做模型输出解析:bench 收集路径直接拼接原始 token 文本,不经过 thinking 标签提取、结构化输出处理等模型特定的后处理,避免输出内容破坏基准——速度测试只关心速度,性能质量指标请见 Exo-Eval(bench/exo_eval.py)。

服务端实现证据

端点注册在 src/exo/api/main.py#L365-L367bench_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" 一节)分三步:

  1. 通过模型的 apply_chat_template() 对一条样例消息做 tokenize,测量模板带来的开销(system token、特殊 token、chat 格式标记);
  2. 对一个重复的原子字符串(默认 "a ")做二分搜索,找到使模板展开后恰好等于目标 token 数的内容长度;
  3. 同时返回内容字符串和验证后的 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-L357L418-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。

统计结果封装在 GenerationStatssrc/exo/api/types/api.py#L163-L168)中,包含 prompt_tpsgeneration_tpsprompt_tokensgeneration_tokenspeak_memory_usageprefix_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 中有完整实现:

  1. prompt 只构造一次,所有线程共享(prompt_sizer.build(pp) 在提交线程池之前调用);
  2. 每个线程持有自己的 HTTP 连接(ExoClient 每线程新建);
  3. threading.Barrier(concurrency) 阻塞所有线程直到全部就绪;
  4. 第一个穿过 barrier 的线程记录批次开始时间(batch_t0)并 set 一个 threading.Event 通知其他线程;
  5. 所有线程以同一开始时间为基准发出 HTTP 请求;
  6. 每个线程的 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-L700L898),让集群在采样窗口之间回稳。


五、前缀缓存模式(--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

实现是 SystemMetricsSamplerbench/exo_bench.py#L180-L213),采样键正是这五项:

_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")

能量计算采用梯形积分:对每个推理窗口(单个基准请求或并发批次的墙钟跨度)内的功耗采样做积分,平均功率 = total_joules / total_inference_seconds。对应 energy_betweenL215-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_energybench/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_soutput_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_idplacement_shardingplacement_instance_metaplacement_nodes
    • 运行元数据:pp_tokenstgrepeat_indexconcurrencyconcurrent_index
    • download_duration_s(若模型是本次新下载的)
  • cluster:基准测试时刻的集群状态快照(capture_cluster_snapshot 在首个 placement 前抓取,L607
  • system_metrics:逐节点时序采样(GPU、功耗、温度)的 min/max/mean/samples 汇总(SystemMetricsSampler.summarizeL228-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_sbench/exo_bench.py#L593-L605)。


九、方法论小结

EXO Exo-Bench 的设计原则可以归纳为三点:

  1. 变量隔离:冷缓存、禁 EOS、无解析、barrier 同步——把网络、缓存、提前停止、输出解析等干扰全部排除,让 TPS 只反映推理本身;
  2. 权威计时在服务端:客户端 elapsed_s 只是端到端延迟的便捷值,generation_stats 中的服务端逐任务计时才是比较依据;
  3. 每个数字可追溯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 计算),可沿这些路径进一步深入。

登录后查看全文
热门项目推荐
相关项目推荐