AI 训练磁盘 I/O 瓶颈排查:从 fio Benchmark 到 GCP Hyperdisk 优化实践
你有没有遇到过这种情况:在 GCP 上开了一台 VM,挂上 Hyperdisk Balanced,花钱买了 160,000 预配 IOPS(provisioned IOPS)1,满怀信心地跑了一轮 fio 压测,结果却只看到大约 2,000 IOPS?
真相往往出人意料:问题通常不在 Hyperdisk 本身,而在压测方法。更准确地说,fio 的默认参数,尤其是 iodepth,通常远远不足以把通过网络连接的块存储设备真正打满。
这篇文章会把问题拆成三个核心部分:
- Queue Depth 与 Little’s Law:为什么同时挂起多少个 I/O 请求,直接决定了你能跑出多少 IOPS
- fio 参数调优:哪些默认值会严重扭曲测试结果,以及正确的压测命令应该怎么写
- AI 工作负载下的磁盘优化:训练与推理对存储的要求有什么不同,以及在 GKE 上应该如何选择合适的 Hyperdisk 配置
一个真实案例
假设你在 GCP 上运行一台 a3-megagpu-8g VM,挂载 Hyperdisk Balanced,并把磁盘直接配置到 160,000 IOPS 的上限。按理说,做一个随机读(random read)的 fio 压测,结果应该接近 160k。
但实际测出来,很可能只有大约 24k 读 IOPS,让人感觉像是“买的 IOPS 凭空消失了”。
fio 示例命令
下面是一个常见的 4K 随机读压测,直接对原始块设备(raw device)进行测试:
fio --name=randread-iops-first-try \
--filename=/dev/nvme0n1 \
--ioengine=libaio \
--direct=1 \
--rw=randread \
--bs=4k \
--iodepth=32 \
--numjobs=1 \
--runtime=30 \
--time_based \
--group_reporting
实际输出:读 IOPS 约 24k
randread-iops-first-try: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=32
fio-3.35
Starting 1 process
Jobs: 1 (f=1): [r(1)][100.0%][r=96.0MiB/s][r=24.6k IOPS][eta 00m:00s]
randread-iops-first-try: (groupid=0, jobs=1): err= 0: pid=12345: Sun Mar 29 15:54:16 2026
read: IOPS=24.1k, BW=94.1MiB/s (98.7MB/s)(2823MiB/30001msec)
slat (nsec): min=1200, max=29000, avg=4100.32, stdev=900.11
clat (usec): min=850, max=4200, avg=1310.45, stdev=210.37
lat (usec): min=860, max=4210, avg=1314.62, stdev=211.02
clat percentiles (usec):
| 1.00th=[ 980], 5.00th=[ 1057], 10.00th=[ 1123], 50.00th=[ 1303],
| 90.00th=[ 1565], 95.00th=[ 1696], 99.00th=[ 2057], 99.90th=[ 2933]
cpu : usr=1.10%, sys=6.80%, ctx=18012, majf=0, minf=15
IO depths : 1=0.1%, 2=0.1%, 4=0.2%, 8=0.3%, 16=0.6%, 32=98.7%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=722944,0,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=32
Run status group 0 (all jobs):
READ: bw=94.1MiB/s (98.7MB/s), 94.1MiB/s-94.1MiB/s (98.7MB/s-98.7MB/s), io=2823MiB (2960MB), run=30001-30001msec
Disk stats (read/write):
nvme0n1: ios=722944/0, merge=0/0, ticks=913000/0, in_queue=913000, util=99.20%
这个输出里最关键的信息是:虽然磁盘买到了 160k IOPS,但在 iodepth=32、numjobs=1 的设置下,读性能只跑到大约 24k IOPS。接下来我们就用队列深度(Queue Depth)和 Little’s Law 来解释,为什么这个结果其实是符合预期的。
Hyperdisk 基础概念
Hyperdisk vs Persistent Disk
GCP 的块存储经历了几代演进。传统的 Persistent Disk(PD)会把性能和容量绑定在一起。想要更高的 IOPS,往往就得申请更大的磁盘,即使你并不需要那么多容量。这在很多真实场景里既浪费,也不灵活。
Hyperdisk 是 Google Cloud 较新的块存储家族,数据独立于 VM 生命周期保持持久化2。它和传统 PD 相比,最大的差异包括:
- IOPS、吞吐量(throughput)和容量可以独立配置:你可以只开一个 100 GiB 的卷,但单独购买 160k IOPS
- 性能不再和容量强绑定:因此在成本控制上更灵活
- 支持 Storage Pool:在大规模部署场景中,可以让多个磁盘共享一个资源池
Hyperdisk 类型对比
| 类型 | 最大 IOPS | 最大 Throughput | 延迟 | 典型场景 |
|---|---|---|---|---|
| Hyperdisk Balanced | 160,000 | 2,400 MiB/s | < 1ms | 通用工作负载、Web 应用、中型数据库 |
| Hyperdisk Extreme | 350,000 | 5,000 MiB/s | < 1ms | 高性能数据库、OLTP、对延迟敏感的工作负载 |
| Hyperdisk ML | 最高 19,200,000 | 最高 1,200,000 MiB/s | < 1ms | AI 推理、模型加载、大规模只读共享 |
| Hyperdisk Throughput | 最高 9,600 | 最高 2,400 MiB/s | 更高 | 分析型任务与大容量冷数据场景 |
补充说明:这些数字都是单个 volume 的上限,Google Cloud 使用 MiB/s 作为标准单位1。对于 Hyperdisk Extreme,吞吐量(throughput)由 IOPS 推导而来,比例是 每 1,000 IOPS 对应 250 MiB/s,因此无法独立设置 throughput。对于 Hyperdisk ML 和 Hyperdisk Throughput,IOPS 同样由 throughput 推导。Hyperdisk ML 的设计目标之一是支持多台 VM 对同一磁盘进行大规模只读共享,但单台 VM 实际能吃到的吞吐量仍取决于机型。例如,a3-megagpu-8g 的上限是 4,800 MiB/s3。
为什么网络附加存储上的延迟如此关键
这才是整件事的核心:Hyperdisk 不是直接插在主板上的本地 SSD,而是网络附加磁盘4。每次读写都需要经历一次网络往返,平均延迟通常在 1 到 2 毫秒 这个量级。
听起来 1 到 2 ms 依然很快,但它会直接决定:为了真正用满你购买的 IOPS,你到底需要同时挂起多少个 I/O 请求。Hyperdisk 的性能不是“磁盘一挂上就自动跑满”的,你必须主动制造足够的并发 I/O,才能把它打到位。
为什么 fio 默认参数测不出 Provisioned IOPS
Queue Depth 与 Little’s Law
要理解这个问题,先从排队论里的经典公式 Little’s Law 说起。可以把它想象成一家繁忙餐厅的后厨:如果每道菜要 2 分钟才能做完,而你想做到每分钟出 100 道菜,那么厨房里就必须同时有 200 道菜在处理中。映射到磁盘 I/O 上,就是:
\[Throughput = \frac{Queue\ Depth}{Latency}\]换句话说:
\[Required\ Queue\ Depth = Desired\ IOPS \times Latency\]如果 Hyperdisk 的平均 I/O latency 是 2 ms(0.002 秒),而你想达到 160,000 IOPS,那么:
\[Required\ QD = 160{,}000 \times 0.002 = 320\]这意味着你需要维持大约 320 个正在进行中的 I/O 请求,才能真正把 160k IOPS 跑满。
iodepth=1 带来的瓶颈
默认情况下,fio 使用的是 iodepth=15。也就是说,它一次只发出一个 I/O 请求,等这个请求完成之后,才会提交下一个。如果每个请求都要 2 ms,那么:
即使你把队列深度调到 iodepth=32,在 2 ms 延迟下,理论值也不过大约 16,000 IOPS。而前面那个示例的实测延迟大约是 1.3 ms,所以 32 / 0.0013 ≈ 24k,和测试结果高度吻合。也就是说,压测结果之所以远低于预期,并不是磁盘性能不够,而是队列太浅,磁盘一直在等新的工作进来。
GCP 推荐的 Queue Depth 参考值
下表整理自 Google Cloud 关于 Hyperdisk 性能优化的建议4:
| 目标 IOPS | 推荐 Queue Depth | 说明 |
|---|---|---|
| 500 | 1 | 默认值即可 |
| 16,000 | 32 | 典型 PD 级别性能 |
| 64,000 | 128 | 中等 Hyperdisk 场景 |
| 160,000 | 320 | Hyperdisk Balanced 上限 |
| 350,000 | 640+ | Hyperdisk Extreme |
其他关键 fio 参数
I/O size
- 测 IOPS 时使用
bs=4k,因为小块读写能最大化请求次数 - 测吞吐量(throughput)时使用
bs=1M,因为大块读写能最大化传输字节数 - 如果块大小(block size)太大,可能还没碰到 IOPS 上限,就先撞到 throughput 上限
direct=1
- 设置
direct=1,让读写绕过操作系统的 page cache6 - 否则你测到的很可能是内存,而不是真实磁盘
- Google Cloud 的基准测试文档明确采用 direct I/O,就是为了避免这个问题
ioengine=libaio
libaio是 Linux 原生的异步 I/O 引擎,适合作为云块存储压测的默认选择,因为它可以同时保持大量请求处于并发执行状态5- fio 默认的
psync引擎是同步模式,因此即使你配置了较大的iodepth,它也未必能把磁盘真正压满5 io_uring是更新的异步方案,性能表现也很接近,但前提是内核版本足够新
更合理的 fio 压测命令示例
重要提示:压测时优先使用 原始块设备(raw device)(例如 /dev/sdb),而不是挂载后的文件系统路径6。如果你必须在文件系统上测试,至少应该使用大文件,例如 --filename=/mnt/test/fio-test --size=100G,并确认 journaling 等文件系统额外开销不会干扰结果。
Random Read IOPS 测试
iodepth=256 × numjobs=4 的有效队列深度是 1024,足以驱动 160k+ IOPS。在 Google Cloud 的 Hyperdisk benchmark 文档里,random read IOPS 的示例也明确使用了至少 iodepth=256,并配合多个 jobs7。
fio --name=rand-read-iops \
--filename=/dev/sdb \
--ioengine=libaio \
--direct=1 \
--rw=randread \
--bs=4k \
--iodepth=256 \
--numjobs=4 \
--runtime=60 \
--time_based \
--group_reporting
Sequential Read Throughput 测试
这个测试的目标是最大化 MiB/s,因此块大小(block size)会放大到 1M,同时使用多个 jobs 去吃满 VM 的存储与网络路径。在这个场景下,IOPS 看起来可能并不高,但带宽应该会明显提升。
常见的调优方向包括:
- 如果带宽上不去,但 latency 依然很低,通常说明
numjobs或iodepth还不够,可以把numjobs提高到 8 或 16 再试 - 如果带宽上不去,而且 system CPU 很高,要考虑 I/O 引擎或文件系统路径带来的额外开销;如果内核支持,可以尝试
io_uring - 如果带宽稳定卡在一个上限,瓶颈通常就是 VM 自身的磁盘吞吐上限,而不是队列深度(queue depth)
示例命令如下:
fio --name=seq-read-throughput \
--filename=/dev/sdb \
--ioengine=libaio \
--direct=1 \
--rw=read \
--bs=1M \
--iodepth=64 \
--numjobs=4 \
--runtime=60 \
--time_based \
--group_reporting
Mixed Read/Write 测试
很多真实工作负载都不是纯读或纯写。数据库、Feature Store、训练时一边读数据一边写 checkpoint、或者推理服务一边加载权重一边更新缓存,都会产生混合 I/O。
这类测试的关键点包括:
- 先定义读写比例,例如 70% read / 30% write
- 保持足够的并发,否则还是会重新被 latency 卡住
- 关注 latency 分布,因为写操作通常会拉高整体延迟,从而压低可实现的 read IOPS
安全提示:下面这个示例使用 rw=randrw 直接对 /dev/sdb 执行真实写入。如果你把它跑在正式磁盘上,会覆盖已有数据。这个命令只应该用于专门拿来做压测的空白测试盘;如果你要测试现有 filesystem,请改用测试文件路径,而不是 raw device。
下面是一个 70/30 的 4K 随机混合读写(random mixed workload)示例:
fio --name=mixed-rw \
--filename=/dev/sdb \
--ioengine=libaio \
--direct=1 \
--rw=randrw \
--rwmixread=70 \
--bs=4k \
--iodepth=128 \
--numjobs=4 \
--runtime=60 \
--time_based \
--group_reporting
面向 AI 工作负载的磁盘优化
AI 训练场景
AI 训练通常有两类主要的存储模式:
| 训练阶段工作 | I/O 模式 | 推荐 Hyperdisk | fio 近似方式 |
|---|---|---|---|
| Checkpoint 写入 | 大量、以顺序写为主,重点是吞吐量(throughput) | Balanced 或 Extreme,并配置足够的 provisioned throughput | bs=1M,并提高 iodepth 以拉满写带宽 |
| 训练数据加载 | 以随机读为主,重点是 IOPS | Balanced 或 Extreme,取决于目标 IOPS 和预算 | 提高 DataLoader worker 数量,避免同步 I/O 成为瓶颈 |
AI 推理场景
模型权重加载
- 推理服务启动时,往往需要一次性把整份模型权重读入内存,规模可能从数 GiB 到数百 GiB 不等
- Hyperdisk ML 就是为这种场景设计的:多台 VM 可以以只读方式同时挂载同一块磁盘,而不需要每台机器都保存一份完整拷贝8
- 这非常适合水平扩展的推理架构:许多节点共享同一份模型磁盘
- 在 GKE 上,CSI driver 支持
ReadOnlyMany这种访问模式8
如何选择合适的 Hyperdisk 类型
| 场景 | 推荐 Hyperdisk 类型 | 原因 |
|---|---|---|
| 通用训练数据存储 | Balanced | 在 IOPS、吞吐量(throughput)和成本之间取得较好平衡 |
| 高频 checkpoint + 大规模数据读取 | Extreme | 当你需要超过 160k IOPS 或超过 2.4 GiB/s 时更合适 |
| 推理模型部署 | ML | 适合多机器只读共享 |
| 大规模冷数据存储 | Throughput | 容量大、带宽强、成本更低 |
应用层优化思路
- Prefetch:提前加载下一批数据,不要等 GPU 空闲了才去读磁盘
- 异步 I/O:使用
libaio或io_uring,让多个请求同时进行 - 多 worker:例如 PyTorch 的
DataLoader(num_workers=N)或并行的tf.datapipeline,会自然拉高队列深度(queue depth) - 内存映射文件(
mmap):对大型数据集很有用,但如果访问模式高度随机,仍然可能因为频繁 page fault 带来额外开销
故障排查与可观测性
使用 iostat 观察实际状态
iostat -xdmt /dev/sdb 1
重点关注以下几个字段:
r/s/w/s:每秒实际完成的读写次数,也就是你观察到的 IOPSawait:平均 I/O 延迟,单位是毫秒aqu-sz:平均队列深度;如果它远低于你配置的iodepth,说明 fio 实际上并没有维持住那么多并发请求%util:设备利用率;对于网络附加存储,这个数值要谨慎解读,因为它可能带有误导性
如果 aqu-sz 长时间明显低于你配置的 iodepth,常见原因就是 I/O 引擎(I/O engine)实际并不是真正的异步模式,因此 fio 没有维持住你以为的并发度。
一个实用的瓶颈判断清单
- 磁盘层:IOPS 明显低于购买值,但延迟(latency)看起来正常。通常意味着队列深度(queue depth)不够,磁盘在等工作。
- VM 带宽上限:每种机型都有自己的磁盘吞吐上限,所以即使你买了更多的磁盘 IOPS,如果 VM 自己吃不下,也没有意义3。
- 应用层:磁盘指标看起来都正常,但应用仍然觉得慢。这时就应该去检查同步 I/O、单线程读、缓冲区过小之类的应用实现问题。
结语
最值得记住的一点是:云磁盘性能通常不是“默认就能完全释放”的。很多时候,你必须在应用层主动制造足够的并发 I/O,请求量上来了,购买的性能才会真正体现出来。
Little’s Law 很直观地解释了这一点:所需队列深度 = 目标 IOPS × 平均延迟。它很好地解释了为什么 fio 在 iodepth=1 时,结果会和你购买的性能档位差得这么远。问题未必在磁盘本身,很可能只是因为你没有同时发出足够多的请求4。
同样的原则也完全适用于 AI 工作负载。训练需要 checkpoint 写入和随机数据读取;推理需要快速加载模型权重,某些情况下还需要多节点共享只读存储。不同场景对 IOPS 和吞吐量(throughput)的要求不同,如果 Hyperdisk 类型选错,或者 benchmark 设置不对,瓶颈很快就会出现在存储指标和整体任务性能上。
本文通过一个发生在 GCP 上的真实 fio 压测案例,解释了结果背后的机制,并进一步延伸讨论其底层原理与应用层面的优化思路。
参考资料