☰
criterion.rs 自定义测量(Custom Measurements)完全指南:接入硬件计数器、CPU 时间等替代计时方案
2026/10/12 2:20:02 网站建设 项目流程
  • 开发工具
  • 性能测试

【免费下载链接】criterion.rs

Statistics-driven benchmarking library for Rust

项目地址:https://gitcode.com/gh_mirrors/cr/criterion.rs
点击查看免费下载

Criterion.rs 默认使用墙钟时间(wall-clock time)来衡量基准测试耗时,但许多场景需要测量其他指标——例如硬件性能计数器(hardware performance counters)、POSIX 的 CPU 时间、GPU 计数器等。自 0.3.0 版本起,Criterion.rs 通过criterion::measurement模块中的两个 trait(Measurement与ValueFormatter)开放了完整的测量插件机制。读完本文,你将掌握如何从零定义一套自定义测量类型、如何编写配套的数值格式化器,以及如何通过with_measurement让整个基准测试套件使用你的测量方案,并理解这些 trait 在 Criterion.rs 内部采样、分析与报告链路中的真实调用方式。

为什么需要自定义测量

默认情况下,Criterion.rs 测量的是被测函数消耗的墙钟时间,对应的实现是criterion::measurement模块中的WallTime结构体。然而墙钟时间并非唯一有价值的性能指标:

  • 硬件性能计数器可以统计指令数、缓存未命中数、分支预测失败次数等微观指标;
  • POSIX 的 CPU 时间(进程/线程累计 CPU 时间)可以排除调度与系统负载带来的噪声;
  • GPU 计数器适合测量 GPU 计算任务的执行情况。

从源码注释可以确认这一设计初衷,测量模块 开篇即写道:该模块定义了一组 trait,用于将不同的测量(例如 Unix 的 Processor Time、CPU 或 GPU 性能计数器等)接入 Criterion.rs。

需要说明的是,当前版本的自定义测量机制存在两条限制(这也与原文档描述一致):

  1. 目前仅支持"计时类"测量(timing measurements);
  2. 每个基准测试只能使用一种测量,并且每次测量只能返回一个f64值用于统计分析。

这两条限制可能在未来版本中放宽。

核心 Trait 一:Measurement——定义"测什么"与"怎么测"

自定义测量的入口是一对 trait,二者都定义在criterion::measurement中。其中主体是Measurementtrait,其完整定义见 src/measurement.rs:

pub trait Measurement { type Intermediate; type Value; fn start(&self) -> Self::Intermediate; fn end(&self, i: Self::Intermediate) -> Self::Value; fn add(&self, v1: &Self::Value, v2: &Self::Value) -> Self::Value; fn zero(&self) -> Self::Value; fn to_f64(&self, value: &Self::Value) -> f64; fn formatter(&self) -> &dyn ValueFormatter; }

start 与 end:测量的核心方法

start与end及其关联类型Intermediate、Value是整个 trait 中最重要的部分:

  • start(&self) -> Self::Intermediate:在迭代被测函数之前被调用,返回一个"中间值"。例如墙钟时间测量会在start时读取系统时钟;
  • end(&self, i: Self::Intermediate) -> Self::Value:在迭代之后被调用,接收start的返回值,产出最终的测量值。墙钟时间测量会再次读取系统时钟并计算两次调用之间的时间差。

"测量前读取某个系统计数器、测量后再次读取并计算差值"(read-and-difference)是性能测量的通用模式,WallTime正是如此实现的(见 src/measurement.rs):

pub struct WallTime; impl Measurement for WallTime { type Intermediate = Instant; type Value = Duration; fn start(&self) -> Self::Intermediate { Instant::now() } fn end(&self, i: Self::Intermediate) -> Self::Value { i.elapsed() } fn add(&self, v1: &Self::Value, v2: &Self::Value) -> Self::Value { *v1 + *v2 } fn zero(&self) -> Self::Value { Duration::from_secs(0) } fn to_f64(&self, val: &Self::Value) -> f64 { val.as_nanos() as f64 } fn formatter(&self) -> &dyn ValueFormatter { &DurationFormatter } }

add 与 zero:支持分批测量

Criterion.rs 有时需要把一次采样拆分成多个批次分别测量,再把各批次的结果累加成一个总体值——例如Bencher::iter_batched和iter_batched_ref就是这种批量迭代计时方式。因此 trait 要求实现:

  • add(&self, v1, v2):把两个测量值相加;
  • zero(&self):返回一个"零"值,作为累加的初始值。

从 Bencher 实现 可以直观看到这两个方法的调用位置:在iter_batched中,每完成一个批次就执行self.value = self.measurement.add(&self.value, &end),而self.value的初始值正是self.measurement.zero()。

to_f64:统一单位送入统计分析

to_f64负责把测量值转换为f64,供 Criterion.rs 的统计模块(bootstrap 重采样、Tukey 离群点检测、均值/中位数估计等)进行分析。由于f64本身不携带单位信息,实现者需要谨慎选择单位,避免出现过大或过小的数值导致浮点精度问题——墙钟时间测量选择的是纳秒(nanoseconds)。

在内部调用链上,routine.rs 的 Function::bench 在每批迭代结束后都会执行m.to_f64(&b.value),把原始测量值转成f64后再收集成样本序列。

formatter:挂接显示层

formatter(&self) -> &dyn ValueFormatter返回一个ValueFormatter的 trait 对象引用,负责把f64形式的测量值渲染成对人类(命令行、HTML 报告)和机器(CSV 输出)都友好的字符串。它会在报告与分析阶段被调用,例如 benchmark_group.rs 在生成组级汇总报告时会调用self.criterion.measurement.formatter()。

一个完整的 Measurement 实现示例

原文档用"半秒"(HalfSeconds)这个刻意设计的测量类型演示完整实现——它本质上仍是墙钟时间,只是把结果折算成以"半个秒"为单位:

const NANOS_PER_SEC: u64 = 1_000_000_000; /// Silly "measurement" that is really just wall-clock time reported in half-seconds. struct HalfSeconds; impl Measurement for HalfSeconds { type Intermediate = Instant; type Value = Duration; fn start(&self) -> Self::Intermediate { Instant::now() } fn end(&self, i: Self::Intermediate) -> Self::Value { i.elapsed() } fn add(&self, v1: &Self::Value, v2: &Self::Value) -> Self::Value { *v1 + *v2 } fn zero(&self) -> Self::Value { Duration::from_secs(0) } fn to_f64(&self, val: &Self::Value) -> f64 { let nanos = val.as_secs() * NANOS_PER_SEC + u64::from(val.subsec_nanos()); nanos as f64 } fn formatter(&self) -> &dyn ValueFormatter { &HalfSecFormatter } }

注意这里to_f64先把Duration拆成"整秒 + 亚秒纳秒"再合成纳秒总数,与WallTime直接调用as_nanos()在语义上等价;它演示了在兼容旧版 Rust/自定义场景下手动换算单位的写法。NANOS_PER_SEC常量在仓库的 custom_measurement 基准示例 中也有同样定义。

核心 Trait 二:ValueFormatter——决定测量值如何呈现

ValueFormatter负责把f64测量值渲染成字符串,定义见 src/measurement.rs:

pub trait ValueFormatter { fn format_value(&self, value: f64) -> String { ... } fn format_throughput(&self, throughput: &Throughput, value: f64) -> String { ... } fn scale_values(&self, typical_value: f64, values: &mut [f64]) -> &'static str; fn scale_throughputs(&self, typical_value: f64, throughput: &Throughput, values: &mut [f64]) -> &'static str; fn scale_for_machines(&self, values: &mut [f64]) -> &'static str; }

所有方法接收的都是f64值,且与to_f64返回的量纲相同——但不一定是相同的数值:传入的可能是一组样本的均值,而非单次原始测量值。例如,如果to_f64返回的是"以千为单位的周期数",那么这里收到的也是同样的单位。

format_value 与 format_throughput

format_value格式化单个测量值(如每次迭代的平均耗时);format_throughput则把测量值与吞吐量信息结合,生成类似"每秒字节数"的描述。trait 为两者都提供了默认实现,它们内部会调用scale_values/scale_throughputs完成单位换算后再格式化(src/measurement.rs),你可以重写它们以定制输出格式。

Throughput枚举定义了吞吐量口径(见 src/lib.rs),当前仓库包含四个变体:

变体含义典型用途
Throughput::Bytes(u64)每秒字节数(二进制前缀 Ki/Mi/Gi)输入串或&[u8]的长度
Throughput::BytesDecimal(u64)每秒字节数(十进制前缀 K/M/G)同上,但用十进制单位
Throughput::Elements(u64)每秒元素数集合大小、输入文本行数、待解析数值个数
Throughput::Bits(u64)每秒比特数网络函数传输的比特数

scale_values / scale_throughputs / scale_for_machines:单位换算

  • scale_values(typical_value, values):接收 Criterion.rs 选定的"典型值"(typical value)和一个可变的数值切片。实现应依据典型值选择一个合适的单位,把切片中所有数值换算到该单位,并返回表示该单位的&'static str。例如,测量值以纳秒为单位时,若想把图表显示为毫秒,就把所有值乘以10.0f64.powi(-6)并返回"ms"。typical_value会在同一张图的多组数据间保持一致,确保单位统一;输入与输出均不允许出现 NaN。
  • scale_throughputs(typical_value, throughput, values):与上者类似,但把测量值切片换算成对应的吞吐量数值并选择单位。
  • scale_for_machines(values):用于生成机器可读输出(例如 CSV 文件)。它不接受典型值,因为该函数必须始终返回同一单位,保证输出序列可直接被机器解析。

数值可读性:SI 前缀与精度控制

格式化目标是"人能一眼看懂":"1,500,000 ns"远不如"1.5 ms"清晰。推荐用一组条件判断叠加 SI 前缀来简化数值。原文档给出了以纳秒为输入的时间格式化示例:

if ns < 1.0 { // ns = time in nanoseconds per iteration format!("{:>6} ps", ns * 1e3) } else if ns < 10f64.powi(3) { format!("{:>6} ns", ns) } else if ns < 10f64.powi(6) { format!("{:>6} us", ns / 1e3) } else if ns < 10f64.powi(9) { format!("{:>6} ms", ns / 1e6) } else { format!("{:>6} s", ns / 1e9) }

同时应限制浮点输出精度——10.2896653s与10.2896654s的差异对用户毫无意义,"约 10.290 秒每次迭代"才是更有效的呈现。

作为参照,Criterion.rs 自带的DurationFormatter(src/measurement.rs)实现了完整的 SI 前缀换算:scale_values依据纳秒数量级在 ps/ns/µs/ms/s 之间选择;scale_throughputs则对四种吞吐量变体分别生成B/s、KiB/s、MiB/s、GiB/s(以及十进制KB/s等)、elem/s、b/s等带前缀的单位;scale_for_machines固定返回"ns",保证 CSV 输出的量纲恒定。

完整的 ValueFormatter 实现示例

与HalfSeconds配套的格式化器如下(同时参考 仓库示例,它已按当前仓库的Throughput四变体扩展):

struct HalfSecFormatter; impl ValueFormatter for HalfSecFormatter { fn format_value(&self, value: f64) -> String { // The value will be in nanoseconds so we have to convert to half-seconds. format!("{} s/2", value * 2f64 * 10f64.powi(-9)) } fn format_throughput(&self, throughput: &Throughput, value: f64) -> String { match *throughput { Throughput::Bytes(bytes) | Throughput::BytesDecimal(bytes) => { format!("{} b/s/2", (bytes as f64) / (value * 2f64 * 10f64.powi(-9))) } Throughput::Bits(bits) => { format!("{} bits/s/2", (bits as f64) / (value * 2f64 * 10f64.powi(-9))) } Throughput::Elements(elems) => format!( "{} elem/s/2", (elems as f64) / (value * 2f64 * 10f64.powi(-9)) ), } } fn scale_values(&self, _typical: f64, values: &mut [f64]) -> &'static str { for val in values { *val *= 2f64 * 10f64.powi(-9); } "s/2" } fn scale_throughputs( &self, _typical: f64, throughput: &Throughput, values: &mut [f64], ) -> &'static str { match *throughput { Throughput::Bytes(bytes) | Throughput::BytesDecimal(bytes) => { for val in values { *val = (bytes as f64) / (*val * 2f64 * 10f64.powi(-9)); } "b/s/2" } Throughput::Bits(bits) => { for val in values { *val = (bits as f64) / (*val * 2f64 * 10f64.powi(-9)); } "bits/s/2" } Throughput::Elements(elems) => { for val in values { *val = (elems as f64) / (*val * 2f64 * 10f64.powi(-9)); } "elem/s/2" } } } fn scale_for_machines(&self, values: &mut [f64]) -> &'static str { for val in values { *val *= 2f64 * 10f64.powi(-9); } "s/2" } }

换算逻辑可以这样理解:测量值以纳秒为单位,乘以2 × 10⁻⁹后得到"半个秒"数,因此单位字符串为"s/2";吞吐量方向则是用字节数/元素数/比特数除以换算后的半秒数,得到b/s/2、elem/s/2等。注意scale_for_machines没有typical参数,必须恒定输出"s/2"单位以保证机器可读输出的一致性。

使用自定义测量:接入 Criterion 配置与基准函数

方式一:直接构造配置(criterion_group 宏)

一旦你(或某个第三方 crate)定义了Measurement,使用它只需三步:

  1. 把Criterion结构体泛型参数改为自定义测量类型(默认为WallTime);
  2. 用with_measurement方法替换默认测量对象;
  3. 基准函数的签名也要声明对应的测量类型。

with_measurement的实现见 src/lib.rs:它接收M2: Measurement,把Criterion<M>整体转换为Criterion<M2>,其他配置字段(采样大小、置信水平、输出目录等)全部原样保留。

fn fibonacci_cycles(criterion: &mut Criterion<HalfSeconds>) { // Use the criterion struct as normal here. criterion.bench_function("fibonacci_custom_measurement", |bencher| { bencher.iter(|| fibonacci_slow(black_box(10))); }); } fn alternate_measurement() -> Criterion<HalfSeconds> { Criterion::default().with_measurement(HalfSeconds) } criterion_group! { name = benches; config = alternate_measurement(); targets = fibonacci_cycles }

要点拆解:

  • Criterion<HalfSeconds>表明该基准函数运行在自定义测量之上,Bencher内部的value: M::Value字段(见 src/bencher.rs)也随之变成Duration;
  • alternate_measurement()作为config传入criterion_group!宏,整套基准组共享这一测量配置;
  • 基准函数体内的用法与默认测量完全一致——criterion.bench_function(...)、bencher.iter(...)、black_box等 API 不因测量类型改变。

仓库中 benches/benchmarks/custom_measurement.rs 提供了这段代码的完整可编译版本(包含fibonacci_slow实现与所有use导入),可直接对照学习。

测量类型如何贯穿整个运行链路

从源码可以梳理出自定义测量在 Criterion.rs 内部的完整生命周期,理解它有助于排查自定义测量接入后的问题:

  1. 采样阶段:Bencher::iter 在迭代前调用self.measurement.start(),迭代结束后调用self.measurement.end(start)得到本批测量值;iter_batched/iter_batched_ref则额外使用zero()+add()逐批累加;
  2. 数值化阶段:Function::bench 对每批测量值调用m.to_f64(&b.value),得到f64样本序列,随后进入 analysis 模块 的估计与离群点分析;
  3. 呈现阶段:分析完成后的报告(CLI、HTML)通过criterion.measurement.formatter()取得ValueFormatter(src/analysis/mod.rs、src/benchmark_group.rs)渲染均值、置信区间与吞吐量;与 cargo-criterion 连接时,格式化器还会通过serve_value_formatter下发给外部进程。

换句话说:Measurement决定测得什么数值,ValueFormatter决定数值如何显示,二者共同构成一套完整的测量方案。

实践建议与已知限制

  • 单位与浮点精度:to_f64产出的数值会直接进入统计分析。请选择让数值落在合理区间(如纳秒、周期数)的单位,避免极端大/小值引发精度问题;WallTime选择纳秒正是出于这一考虑。
  • 吞吐量口径:Throughput的四个变体(Bytes/BytesDecimal/Elements/Bits)都要在你的format_throughput与scale_throughputs中妥善处理,否则会出现未覆盖的 match 分支导致运行时 panic。原文档示例只覆盖了Bytes与Elements,对应旧版枚举;当前仓库示例 custom_measurement.rs 已补齐全部变体。
  • 恒定机器单位:scale_for_machines不允许依赖 typical 值做动态单位切换,因为 CSV 等机器输出要求单位跨样本一致。
  • 版本限制:当前自定义测量仅支持计时类测量、单测量、单f64返回值;如果你需要同时测量多种指标(如同时记录指令数与周期数),需要等待未来版本解除限制,或自行在Value中编码后通过to_f64输出单一综合指标。

小结

自定义测量把 Criterion.rs 从"墙钟时间基准工具"扩展为通用的"统计驱动性能测量框架":Measurementtrait 定义了采样的数据来源(start/end/add/zero/to_f64),ValueFormattertrait 定义了呈现规则(format_value/format_throughput/scale_values/scale_throughputs/scale_for_machines),with_measurement则把整套方案无缝接入Criterion<M>的配置体系。无论是接入硬件计数器、CPU 时间还是自定义时钟,只需实现这两个 trait 并替换默认WallTime,即可复用 Criterion.rs 完整的 bootstrap 置信区间、回归分析与报告生成能力。

相关源码与示例:

  • 测量 trait 与 WallTime 默认实现
  • with_measurement 方法定义
  • Throughput 枚举定义
  • Bencher 计时循环中对 start/end/zero/add 的调用
  • 采样数值化调用链
  • 自定义测量完整可运行示例
  • 开发工具
  • 性能测试

【免费下载链接】criterion.rs

Statistics-driven benchmarking library for Rust

项目地址:https://gitcode.com/gh_mirrors/cr/criterion.rs
点击查看免费下载

相关推荐

上一篇:API 入门导论:从零理解程序之间的通信(easy-vibe 项目实战篇)
下一篇:Quasar CLI with Vite 项目目录结构全解析:从 src 到 dist 的每个目录与文件

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询