Storforge
里程碑预计 50 分钟

里程碑②:对标 fio

写自己的压测工具 forge-bench,输出延迟直方图和 P99;用它证明你的引擎达到 NVMe 标称 IOPS 的 70%,差距去哪儿了要能说清。

学完这节你能做到

  • 实现带延迟直方图(HDR)的并发压测工具
  • 跑出引擎的 QD-IOPS-延迟三维数据
  • 写一页性能分析报告:瓶颈在哪、下一步优化什么

验收现场:这次对手是 fio

里程碑②要回答一个运维工程师最擅长提的问题:你这引擎,到底几斤几两?

验收目标写在最前面,后面全课围着它转:

  1. 自研压测工具 forge-bench,输出 HDR 延迟直方图,报告 P50/P99/P999
  2. forge-store 在 4k 随机读下达到 NVMe 标称 IOPS 的 70% 以上(标称值取上一课记进 README 的厂商 spec)
  3. 压测口径与 fio 完全对齐:direct=1、randrepeat=0、带 ramp time
  4. 用 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 计数器是同一个思想。

Checkpoint单选

为什么压测工具用 HDR 直方图,而不是存下全部延迟样本最后排序算分位数?

和 fio 对齐口径:三个不对齐,数字全作废

自研工具最大的风险是自欺:数字很好看,但和 fio 测的不是一回事。三个必须对齐的口径:

  1. direct=1 —— forge-store 的读路径必须走 O_DIRECT。只要有一次 page cache 命中, 延迟就从 80 微秒变 1 微秒,IOPS 直接虚高一个数量级。自查手法:压测时开一个终端看 iostat -x 1,盘的 r/s 必须和 forge-bench 报的 IOPS 同数量级,对不上就是 cache 在替你考试。
  2. randrepeat=0 —— 随机序列每轮不同。fio 默认每次跑用相同种子, forge-bench 用时间做种子即可,但要在输出里打印种子,方便复现问题。
  3. ramp time —— 前 10 秒的数据丢弃,不进直方图。原因上一课讲透了: FTL 的 GC 稳态、你自己引擎的冷索引、运行时的 JIT 式预热,前几秒的数字都偏乐观或偏悲观。
!对照组先行:先跑 fio,再跑自己

验收顺序不能反:先用 fio 在同一块盘、同参数(4k randread、iodepth 128、direct=1、 randrepeat=0、ramp_time=10)拿到裸盘基线,再跑 forge-bench。裸盘基线如果只有标称值的 85%,那你的 70% 目标实际是对着它的 82% —— 差距分析必须从真实基线出发, 不然你会为厂商 PPT 里的数字白白优化一星期。

⚡AI 结对:实现 forge-benchpair with ai

不用读代码也能盯住两件事。第一,让 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 结对:性能分析报告pair with ai

这是「让 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 差距分摊到层且有名有姓