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=1randrepeat=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

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 结对:性能分析报告pair with ai

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