AI 训练磁盘 I/O 瓶颈排查:从 fio Benchmark 到 GCP Hyperdisk 优化实践

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=32numjobs=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,那么:

\[Max\ IOPS = \frac{1}{0.002} = 500\ IOPS\]

即使你把队列深度调到 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 依然很低,通常说明 numjobsiodepth 还不够,可以把 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:使用 libaioio_uring,让多个请求同时进行
  • 多 worker:例如 PyTorch 的 DataLoader(num_workers=N) 或并行的 tf.data pipeline,会自然拉高队列深度(queue depth)
  • 内存映射文件(mmap:对大型数据集很有用,但如果访问模式高度随机,仍然可能因为频繁 page fault 带来额外开销

故障排查与可观测性

使用 iostat 观察实际状态

iostat -xdmt /dev/sdb 1

重点关注以下几个字段:

  • r/s / w/s:每秒实际完成的读写次数,也就是你观察到的 IOPS
  • await:平均 I/O 延迟,单位是毫秒
  • aqu-sz:平均队列深度;如果它远低于你配置的 iodepth,说明 fio 实际上并没有维持住那么多并发请求
  • %util:设备利用率;对于网络附加存储,这个数值要谨慎解读,因为它可能带有误导性

如果 aqu-sz 长时间明显低于你配置的 iodepth,常见原因就是 I/O 引擎(I/O engine)实际并不是真正的异步模式,因此 fio 没有维持住你以为的并发度。

一个实用的瓶颈判断清单

  1. 磁盘层:IOPS 明显低于购买值,但延迟(latency)看起来正常。通常意味着队列深度(queue depth)不够,磁盘在等工作。
  2. VM 带宽上限:每种机型都有自己的磁盘吞吐上限,所以即使你买了更多的磁盘 IOPS,如果 VM 自己吃不下,也没有意义3
  3. 应用层:磁盘指标看起来都正常,但应用仍然觉得慢。这时就应该去检查同步 I/O、单线程读、缓冲区过小之类的应用实现问题。

结语

最值得记住的一点是:云磁盘性能通常不是“默认就能完全释放”的。很多时候,你必须在应用层主动制造足够的并发 I/O,请求量上来了,购买的性能才会真正体现出来。

Little’s Law 很直观地解释了这一点:所需队列深度 = 目标 IOPS × 平均延迟。它很好地解释了为什么 fioiodepth=1 时,结果会和你购买的性能档位差得这么远。问题未必在磁盘本身,很可能只是因为你没有同时发出足够多的请求4

同样的原则也完全适用于 AI 工作负载。训练需要 checkpoint 写入和随机数据读取;推理需要快速加载模型权重,某些情况下还需要多节点共享只读存储。不同场景对 IOPS 和吞吐量(throughput)的要求不同,如果 Hyperdisk 类型选错,或者 benchmark 设置不对,瓶颈很快就会出现在存储指标和整体任务性能上。

本文通过一个发生在 GCP 上的真实 fio 压测案例,解释了结果背后的机制,并进一步延伸讨论其底层原理与应用层面的优化思路。

参考资料

Eason Cao
Eason Cao Eason is an engineer working at FANNG and living in Europe. He was accredited as AWS Professional Solution Architect, AWS Professional DevOps Engineer and CNCF Certified Kubernetes Administrator. He started his Kubernetes journey in 2017 and enjoys solving real-world business problems.
comments powered by Disqus