里程碑②:对标 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 里的数字白白优化一星期。
不用读代码也能盯住两件事。第一,让 AI 说清楚 worker 之间到底还共享着什么 —— 全局原子计数器可以有,热路径上的锁不可以;它要是说「没有共享」, 就让它把那个共用直方图的对照实验真跑一遍,两组数字一摆就知道有没有糊弄你。 第二,让它讲清楚延迟是从哪一刻记到哪一刻:记早了会把排队时间漏掉, 记晚了会把统计开销算进去,讲到你能复述这两个时刻各自在做什么。 最后那个反问的标准答案有两层:锁竞争压低 IOPS,锁等待混进延迟让 P99 变差 —— 只答出一层就让它重想。
目标:把 forge-bench 扩展成完整的压测工具。它压的是 forge-store 的对象读写接口(不是裸盘),产出的数字要能和 fio 的裸盘基线对表。 行为规格: 1. 两个子命令 —— 灌数据和跑压测: - 灌数据:向指定目录的 forge-store 实例写入若干对象(默认 100 万个,每个 value 默认 4096 字节),value 的内容由键唯一决定且可复现(后面要靠这一点做正确性校验),键用 8 字节序号; - 跑压测:用指定并发数(默认 128)在已灌好的键空间上做随机读,默认跑 60 秒; 2. 开头一段时间(默认 10 秒,可调)的样本必须丢弃、不进统计。理由上一课讲透了:FTL 的 GC 还没进稳态、你自己的索引还是冷的,这段数字不作数; 3. 延迟统计用 HDR 直方图,量程 1 微秒到 60 秒、3 位有效数字精度。每个并发 worker 各持有自己的直方图和自己的随机数发生器,跑完再合并 —— 热路径上不许出现跨 worker 的共享锁; 4. 随机序列每轮不同(对应 fio 的 randrepeat=0),但全局种子要打印出来,并且支持指定种子复现同一次负载; 5. 输出:总 IO 数、IOPS、P50/P90/P99/P999/最大延迟(微秒),另外再给一行机器可读的 JSON,方便我用脚本比对多次运行; 6. 压测工具同时兼职正确性哨兵:每读 1000 个抽 1 个,按键重新算出期望内容比对,一旦不一致立刻停下报错 —— 一个会读错数据的引擎,跑多快都没有意义; 7. 热路径上不许打印、不许写日志、不许每个请求都新分配大块内存。测量本身的开销要小到可以忽略;如果不能忽略,告诉我它占单次延迟的百分之多少。 交付:实现 + 灌 100 万对象再跑 60 秒的完整输出 + 回答一个问题:如果把每个 worker 各自的直方图换成一个共用的、加锁保护的直方图,IOPS 和 P99 分别会怎么变?
- 压测时另开终端看 iostat -x 1,盘的 r/s 必须和 forge-bench 报的 IOPS 同数量级。对不上就是 page cache 在替你考试,这一条不过,后面全是假数字
- 顺序不能反:先用 fio 在同一块盘、同参数拿到裸盘基线,再看 forge-bench 的数字。拿自己的数字去对厂商 PPT,只会白优化一星期
- 对照实验:把并发数从 128 改成 1 再跑,延迟应该回落到单请求水平(和上一课 QD1 那一档对得上)。数字不动,说明并发参数根本没接到实际的在途请求上
- 连跑 3 次,IOPS 波动超过 5% 就说明 ramp 时间不够,或者后台还有别的东西在跟你抢盘
- 破坏性验证:手工把已灌好的数据文件里改掉一个字节,再跑压测,抽样校验必须能报错退出 —— 校验不报错,说明这个哨兵是装样子的
- 压测跑起来的时候,同一时刻真的有多少个读请求在盘上?这个数字和我设的并发数是同一个吗?
- 记一次延迟要花多长时间?这笔开销相对一次 4k 读的延迟占多大比例?
- 所有 worker 共用一个加锁的直方图,IOPS 和 P99 各会怎么变?这两件事分别是被什么拖累的?
- 开头 10 秒的样本被丢掉了。如果不丢,最终数字会偏乐观还是偏悲观?为什么?
用 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 写文档」的正确姿势:数据全部由你提供,它只做整理与分摊计算。 你不需要懂代码,只需要当一个较真的审稿人 —— 对每个数字都追问一句「这是从哪来的」, 答不上来的就是编的。报告写完,里程碑②交付,L3 见: 下一步是让三台机器共享你这台已经够快的引擎。
目标:把我这次压测的原始数据整理成一页性能分析报告 docs/l2-bench-report.md,回答里程碑②的最后一问 —— forge-store 和裸盘之间那段差距,到底去哪了。 行为规格: 1. 数据全部由我提供:fio 裸盘基线输出、forge-bench 三次运行的 JSON、火焰图里前五名热点函数及占比。报告只能用这些数据,一个数字都不许编; 2. 环境一节记清楚:盘型号与厂商标称 IOPS、CPU、内核版本、这次用的 IO 相关配置(队列深度、是否 O_DIRECT、有没有开轮询); 3. 结果表把 fio 裸盘和 forge-bench 并排放,每行给出 IOPS、P50、P99、P999; 4. 差距分解是重点:把 forge-store 相对裸盘每个 IO 多出来的微秒数,按热点占比分摊到索引查找、校验和、内存拷贝、其他四类,每一类都要写清楚这个数字是怎么算出来的; 5. 结论一节:达标还是没达标,前两名热点是什么,各给一句话优化思路; 6. 遗留问题一节:老实列出这次解释不了的现象,比如 P999 的长尾; 7. 凡是推算出来的数字都明确标注为估算,不要和实测数字混在一起看不出区别。 交付:报告本身 + 一份数字溯源清单 —— 报告里每一个数字,对应到我贴给你的哪一段原始输出。
- 拿着溯源清单逐个数字回查原始输出,发现一个对不上就退回重写 —— AI 写报告时编数字圆故事的倾向,比写代码时严重得多
- 把差距分解四类的微秒数加起来,总和要和实测的每 IO 差值对得上;对不上说明分摊逻辑有问题,不是四舍五入的事
- 报告里出现的标称值全部回厂商 spec 核一遍,别让它把你自己压出来的裸盘基线当成标称值 —— 这两个数混了,达标结论就是假的
- 破坏性验证:故意在贴给它的数据里留一个明显矛盾(比如把某次的 P99 改成比 max 还大),看它是当场指出来,还是照单全收编出一段解释。照单全收的报告不能信
- 差距分解里每一类的微秒数,你是怎么从热点占比推到时间的?这个推法成立的前提是什么?
- 三次运行之间的波动有多大?报告里用的是哪一次的数字,为什么选它?
- 有没有哪个热点你其实解释不了,只是硬塞进了「其他」那一类?
小结
- forge-bench 三大件:可控并发的负载生成、热路径低开销打点、HDR 直方图(固定内存答任意分位数)
- 每 worker 本地直方图 + 结束合并,热路径零共享 —— per-CPU 计数器思想的用户态版
- 口径三件套 direct=1、randrepeat=0、ramp time,一个不对齐数字全作废;用 iostat 交叉验证防 cache 作弊
- flamegraph 找又宽又平的顶:拷贝、校验和软件回退、分配器、锁,一次改一处、量化一处
- 验收阈值:标称 IOPS 的 70%、抽检零错、三连跑波动 5% 以内、P99 差距分摊到层且有名有姓