里程碑②:对标 fio
写自己的压测工具 forge-bench,输出延迟直方图和 P99;用它证明你的引擎达到 NVMe 标称 IOPS 的 70%,差距去哪儿了要能说清。
学完这节你能做到
- 实现带延迟直方图(HDR)的并发压测工具
- 跑出引擎的 QD-IOPS-延迟三维数据
- 写一页性能分析报告:瓶颈在哪、下一步优化什么
验收现场:这次对手是 fio
里程碑②要回答一个运维工程师最擅长提的问题:你这引擎,到底几斤几两?
验收目标写在最前面,后面全课围着它转:
- 自研压测工具
forge-bench,输出 HDR 延迟直方图,报告 P50/P99/P999 - forge-store 在 4k 随机读下达到 NVMe 标称 IOPS 的 70% 以上(标称值取上一课记进 README 的厂商 spec)
- 压测口径与 fio 完全对齐:
direct=1、randrepeat=0、带 ramp time - 用
cargo flamegraph找出至少两个热点,用数据解释那 30% 差距去哪了
为什么自己写压测工具而不是直接用 fio?因为 fio 压的是文件和裸盘,
而你要压的是 forge-store 的 put/get 接口 —— 中间隔着你的索引、校验和、WAL。
自研工具还有个隐藏收益:打点和统计的代码 L5 的可观测性阶段直接复用。
forge-bench 设计:负载生成、打点、直方图
压测工具三大件,每件都有你踩过的坑对应:
负载生成。预先向引擎灌入固定数量的对象(比如 100 万个 4k value),
然后按指定并发数(等价于 QD)随机读。两个细节决定数字真不真:
键的随机序列不能每轮重复(对应 fio 的 randrepeat=0,否则你的内存索引和 CPU 缓存
会「背题」),并发数要可控(上一课的结论:在途请求数决定一切)。
打点。每个请求记录发起到完成的耗时。陷阱在于测量本身的开销:
Instant::now 一次约 20 到 30 纳秒,对微秒级 IO 可接受;
但不许在热路径上打印、写日志、抢全局锁 —— 否则你测的是打点代码不是引擎。
直方图。为什么不能只存平均值?你运维时早就知道:平均延迟 100 微秒的系统,
P99 可能是 2 毫秒 —— 而用户抱怨的全是 P99。但存下每个样本再排序也不行:
一亿个 IO 就是 800MB 内存外加缓存污染。标准答案是 HDR 直方图
(High Dynamic Range histogram):按对数刻度分桶,固定几十 KB 内存,
在指定精度(比如 3 位有效数字)内回答任意分位数。Rust 生态用 hdrhistogram crate:
use hdrhistogram::Histogram;
// 1us 到 60s,3 位有效数字精度,内存占用固定且很小
let mut hist = Histogram::<u64>::new_with_bounds(1, 60_000_000, 3)?;
// 热路径:每个 IO 完成时记录一次(纳秒转微秒)
hist.record(latency_us)?;
// 收尾:任意分位数直接问
println!("p50={}us p99={}us p999={}us max={}us",
hist.value_at_quantile(0.50),
hist.value_at_quantile(0.99),
hist.value_at_quantile(0.999),
hist.max());
多个 worker 并发压测时,每个 worker 一个本地直方图,结束后合并(hist.add),
热路径零共享 —— 这个「先本地后合并」的手法,和你调优过的 per-CPU 计数器是同一个思想。
为什么压测工具用 HDR 直方图,而不是存下全部延迟样本最后排序算分位数?
和 fio 对齐口径:三个不对齐,数字全作废
自研工具最大的风险是自欺:数字很好看,但和 fio 测的不是一回事。三个必须对齐的口径:
direct=1—— forge-store 的读路径必须走 O_DIRECT。只要有一次 page cache 命中, 延迟就从 80 微秒变 1 微秒,IOPS 直接虚高一个数量级。自查手法:压测时开一个终端看iostat -x 1,盘的r/s必须和 forge-bench 报的 IOPS 同数量级,对不上就是 cache 在替你考试。randrepeat=0—— 随机序列每轮不同。fio 默认每次跑用相同种子, forge-bench 用时间做种子即可,但要在输出里打印种子,方便复现问题。- ramp time —— 前 10 秒的数据丢弃,不进直方图。原因上一课讲透了: FTL 的 GC 稳态、你自己引擎的冷索引、运行时的 JIT 式预热,前几秒的数字都偏乐观或偏悲观。
验收顺序不能反:先用 fio 在同一块盘、同参数(4k randread、iodepth 128、direct=1、 randrepeat=0、ramp_time=10)拿到裸盘基线,再跑 forge-bench。裸盘基线如果只有标称值的 85%,那你的 70% 目标实际是对着它的 82% —— 差距分析必须从真实基线出发, 不然你会为厂商 PPT 里的数字白白优化一星期。
review 按三条红线走:并发部分重点看 worker 之间是否真的零共享 (全局原子计数器可以有,锁不可以有);打点位置是否在 await/完成回调的正确一侧 (记早了把排队时间漏掉,记晚了把统计开销算进去)。最后那个反问的标准答案: 锁竞争会压低 IOPS,同时锁等待混进延迟让 P99 变差 —— 它答不到这两层就让它重想。
把 forge workspace 里的 forge-bench 扩展成完整压测工具,压 forge-store 的 BlobStore 接口: 1. 依赖增加 hdrhistogram、clap;子命令 fill 与 run: - fill:向指定目录的 forge-store 实例灌入 --count 个对象(默认 100 万),value 大小 --value-size(默认 4096)字节,内容用可复现的伪随机字节,key 为 8 字节序号; - run:--concurrency 个 worker(默认 128)对已灌好的 key 空间做随机读,--runtime-secs(默认 60),--ramp-secs(默认 10,该时段样本丢弃); 2. 每个 worker 独立持有 Histogram(范围 1us 到 60s,3 位精度)和独立的随机数发生器(种子 = 全局种子 + worker 序号,全局种子默认取时间并打印); 3. 结束后合并直方图,输出:总 IO、IOPS、P50/P90/P99/P999/max(微秒),并输出机器可读的一行 JSON 方便脚本比对; 4. 读到的 value 抽样校验(每 1000 个校验 1 个):按 key 重新生成期望字节比对,不一致立即报错退出 —— 压测工具同时是正确性哨兵; 5. 全程 Result 传播错误,热路径禁止打印和分配大对象; 6. 跑通 fill 100 万 + run 60 秒,贴出完整输出。 做完回答:如果把每个 worker 的直方图换成一个 Mutex 保护的全局直方图,P99 和 IOPS 各会发生什么?
用 flamegraph 找热点:解释那 30%
跑出数字后,验收的最后一问是「差距去哪了」。工具是 cargo flamegraph
(底下是 perf,你运维时可能用过):
cargo install flamegraph
# 压测的同时采样;Cargo.toml 的 release profile 里加 debug = true 保留符号
cargo flamegraph --release --bin forge-bench -- run --concurrency 128 --runtime-secs 60
# 产出 flamegraph.svg,浏览器打开,横轴是 CPU 时间占比,越宽越可疑
读图的方法:找又宽又平的顶。在 forge-store 这个阶段,常见嫌疑人排行:
memcpy类(copy_nonoverlapping):读路径上多余的 buffer 拷贝,一次 4k 拷贝约 100 纳秒, 路径上有三四次就吃掉可观预算crc32c:校验和该是硬件加速的每字节零点几纳秒,如果它很宽,查是不是走了软件回退实现- 分配器(
malloc/alloc):热路径上每个 IO 都Vec::new的话,分配器会荣登榜首 —— buffer 复用 - 锁(
futex相关):索引读写锁在高并发下的竞争 - 系统调用(
io_uring_enter之外还有别的 syscall 很宽?查掉它)
「找热点、改一处、重测、量化」—— 一次只改一个变量,这是你做故障复盘时就懂的纪律。 L5 的调优课会把这套流程完整走一遍,这里只要求:说清前两名热点是什么、各占百分之几。
验收清单:命令与阈值
照单执行,全过才算里程碑②交付:
# 0. 基线:同盘同参数的 fio(记录 IOPS_fio 与 P99_fio)
fio --name=base --filename=/dev/nvme0n1 --rw=randread --bs=4k --direct=1 \
--ioengine=io_uring --iodepth=128 --randrepeat=0 --ramp_time=10 --runtime=60
# 1. 灌数据
cargo run --release -p forge-bench -- fill --count 1000000 --value-size 4096
# 2. 正式压测
cargo run --release -p forge-bench -- run --concurrency 128 --runtime-secs 60 --ramp-secs 10
| 验收项 | 判定阈值 |
|---|---|
| IOPS | 不低于盘标称 4k 随机读 IOPS 的 70% |
| 正确性 | 抽样校验零失败 |
| P99 | 有解释:给出 P99 与 fio 裸盘 P99 的差值,并指出差值来自哪层(索引/校验/拷贝) |
| 热点 | flamegraph 前两名热点有名有姓有占比 |
| 复现性 | 连跑 3 次,IOPS 波动在 5% 以内(波动大说明 ramp 不够或后台 GC 在捣乱) |
达不到 70% 怎么办?这才是这个里程碑最有教学价值的部分 —— 按顺序排查: iostat 核对是否真在打盘;flamegraph 找 CPU 热点;对照上一课的 QD 曲线检查并发数 是否骑在拐点上;检查读路径的拷贝次数。通常一轮 buffer 复用 + 一次拷贝削减就能从 50% 提到 70% 以上;如果你卡在 40% 以下,九成是 O_DIRECT 对齐问题导致内核悄悄回退了 buffered 路径。
这是「让 AI 写文档」的正确姿势:数据全部由你提供,它只做整理与分摊计算。 review 时逐个核对数字来源 —— AI 在写报告时编数字圆故事的倾向比写代码时严重得多, 发现一个对不上的数字就退回重写。报告写完,里程碑②交付,L3 见: 下一步是让三台机器共享你这台已经够快的引擎。
我已完成 forge-bench 压测和 flamegraph 采样。请基于我贴出的数据(fio 基线输出、forge-bench 三次运行的 JSON、flamegraph 里前五名热点函数及占比)帮我写一页性能分析报告 docs/l2-bench-report.md,结构: 1. 环境:盘型号与标称 IOPS、CPU、内核版本、io_uring 相关配置; 2. 结果表:fio 裸盘 vs forge-bench,各列 IOPS、P50、P99、P999; 3. 差距分解:把 forge-store 相对裸盘每 IO 多出的微秒数,按热点占比分摊到索引查找、校验和、内存拷贝、其他四类,给出每类的估算依据; 4. 结论:达标与否,前两个热点是什么,各自的优化思路一句话; 5. 遗留问题:列出本次未解释的现象(比如 P999 长尾)。 报告里每个数字必须能追溯到我贴的原始数据,不许编造;估算之处明确标注"估算"。
小结
- forge-bench 三大件:可控并发的负载生成、热路径低开销打点、HDR 直方图(固定内存答任意分位数)
- 每 worker 本地直方图 + 结束合并,热路径零共享 —— per-CPU 计数器思想的用户态版
- 口径三件套 direct=1、randrepeat=0、ramp time,一个不对齐数字全作废;用 iostat 交叉验证防 cache 作弊
- flamegraph 找又宽又平的顶:拷贝、校验和软件回退、分配器、锁,一次改一处、量化一处
- 验收阈值:标称 IOPS 的 70%、抽检零错、三连跑波动 5% 以内、P99 差距分摊到层且有名有姓