- 开发工具
- 性能测试
【免费下载链接】criterion.rs
Statistics-driven benchmarking library for Rust
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。
需要说明的是,当前版本的自定义测量机制存在两条限制(这也与原文档描述一致):
- 目前仅支持"计时类"测量(timing measurements);
- 每个基准测试只能使用一种测量,并且每次测量只能返回一个
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,使用它只需三步:
- 把
Criterion结构体泛型参数改为自定义测量类型(默认为WallTime); - 用
with_measurement方法替换默认测量对象; - 基准函数的签名也要声明对应的测量类型。
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 内部的完整生命周期,理解它有助于排查自定义测量接入后的问题:
- 采样阶段:Bencher::iter 在迭代前调用
self.measurement.start(),迭代结束后调用self.measurement.end(start)得到本批测量值;iter_batched/iter_batched_ref则额外使用zero()+add()逐批累加; - 数值化阶段:Function::bench 对每批测量值调用
m.to_f64(&b.value),得到f64样本序列,随后进入 analysis 模块 的估计与离群点分析; - 呈现阶段:分析完成后的报告(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
相关推荐
FormatterKit 集成测试策略:确保格式化器在不同语言环境下的正确性
FormatterKit 集成测试策略:确保格式化器在不同语言环境下的正确性 FormatterKit 是一个为开发者提供高级字符串格式化功能的工具库,其核心价
开发工具Pathway 自定义 Python 连接器(Custom Python Connector)实战:把 Twitter 等任意数据流接入实时表
Pathway 自定义 Python 连接器(Custom Python Connector)实战:把 Twitter 等任意数据流接入实时表 本篇以仓库内 C
后端流处理实时分析数据工程人工智能RAGlightweight-charts 时区支持实践指南:为时间轴接入自定义时区的完整方案
lightweight charts 时区支持实践指南:为时间轴接入自定义时区的完整方案 导读 本文基于 lightweight charts 4.1 版本官方
前端图表库金融科技数据可视化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考