IO基础:io_uring、NVMe测试指标与GPUDirect Storage
本文最后更新于 2026年5月8日 晚上
1. 先说结论
版本说明:本文参考的是2026-05-08访问的Linux man-pages io_uring(7)、fio官方文档和NVIDIA GPUDirect Storage cuFile API Reference。IO栈和GDS行为都和内核版本、文件系统、驱动、CUDA/GDS版本、NVMe拓扑强相关,实际测试要以本机环境为准。
这篇文章讲三件事:
- Linux
io_uring为什么比传统同步IO更适合高性能异步IO。 - NVMe IO测试里,IOPS、bandwidth、latency、queue depth、block size这些指标到底怎么看。
- GPUDirect Storage,也就是GDS里,cuFile API是怎么让NVMe数据进GPU显存的。
先给一个总览。
| 主题 | 核心问题 | 关键概念 |
|---|---|---|
io_uring |
应用如何低开销提交大量异步IO | SQ、CQ、SQE、CQE、in-flight IO、SQPOLL |
| NVMe测试 | 如何判断盘、系统、IO路径的性能 | IOPS、BW、latency、iodepth、bs、numjobs、p99 |
| GDS/cuFile | 如何让存储直接读写GPU memory | cuFileDriverOpen、cuFileHandleRegister、cuFileBufRegister、cuFileRead/Write |
一句话概括:
高性能IO不是单纯“读一次有多快”,而是看应用能否持续提交足够多的请求,让设备队列保持忙碌,同时又控制好平均延迟和尾延迟。
2. 从最普通的read开始
最简单的读文件代码可能是:
char buf[4096];
int fd = open("data.bin", O_RDONLY);
read(fd, buf, 4096);这很直观:
- 应用调用
read。 - 进入内核。
- 内核发起磁盘IO。
- 如果数据没准备好,线程睡眠。
- 数据回来后,线程被唤醒。
read返回。
这种方式叫同步阻塞IO。
优点:
- 简单。
- 容易理解。
- 适合低并发、低性能要求。
缺点:
- 一个线程一次通常只能等一个IO。
- 每个IO都要系统调用。
- 线程阻塞和唤醒有开销。
- 很难把高速NVMe打满。
NVMe设备很快。单个IO的延迟可能几十微秒,但设备内部可以同时处理很多请求。如果应用一次只发一个请求,设备大部分能力没有用上。
3. 为什么需要异步IO
异步IO的基本想法是:
不要提交一个IO就傻等。
而是连续提交多个IO,让设备同时处理。
之后再批量收完成事件。比如同步方式:
submit IO 1
wait IO 1 done
submit IO 2
wait IO 2 done
submit IO 3
wait IO 3 done异步方式:
submit IO 1
submit IO 2
submit IO 3
submit IO 4
wait completions这会让多个IO同时在设备里飞行,也就是in-flight。
这个in-flight数量,fio里通常叫iodepth,硬件/驱动里常说queue depth。
4. io_uring是什么
io_uring是Linux的异步IO接口。
它的核心不是“又多了一个read API”,而是引入了用户态和内核态共享的环形队列。
官方man page里说,io_uring使用shared ring buffers作为用户态和内核态沟通的主要方式。
它有两个核心队列:
- Submission Queue,简称SQ。
- Completion Queue,简称CQ。
直白理解:
SQ:
应用把想做的IO请求放进去。
CQ:
内核把已经完成的IO结果放进去。每个提交项叫:
SQE = Submission Queue Entry每个完成项叫:
CQE = Completion Queue Entry5. io_uring的基本流程
一个典型io_uring流程是:
sequenceDiagram
participant App as 应用线程
participant SQ as Submission Queue
participant K as Kernel
participant Dev as NVMe/文件系统
participant CQ as Completion Queue
App->>SQ: 填写SQE read/write
App->>K: io_uring_enter 或 SQPOLL唤醒
K->>SQ: 取出SQE
K->>Dev: 提交底层IO
Dev-->>K: IO完成
K->>CQ: 写入CQE res/user_data
App->>CQ: 读取CQE
App->>App: 根据user_data找到原请求
图里要注意:应用不是每个IO都同步等待设备完成,而是把请求放进SQ,再从CQ里收完成事件。
1. io_uring_setup 创建ring
2. mmap SQ/CQ共享内存
3. 应用拿一个SQE
4. 填入opcode、fd、buffer地址、长度、offset
5. 提交SQE
6. 内核执行IO
7. 内核写入CQE
8. 应用读取CQE,检查结果可以画成:
应用线程
|
| 写SQE
v
Submission Queue ----> Kernel ----> NVMe / FS / Network
|
v
Completion Queue <---- CQE -----------+
^
| 读CQE
|
应用线程一个SQE里通常要描述:
- 做什么操作:read、write、fsync、accept等。
- 对哪个fd操作。
- buffer地址在哪里。
- IO大小是多少。
- 文件offset是多少。
user_data是什么。
user_data很重要。因为异步IO可能乱序完成,应用必须知道这个CQE对应哪个请求。
例如:
提交顺序:
IO A
IO B
IO C
完成顺序:
IO B
IO A
IO C所以每个SQE要带一个ID:
sqe->user_data = request_id收到CQE后:
cqe->user_data就能知道完成的是谁。
6. io_uring为什么高效
传统系统调用模式是:
每个IO:
syscall enter
kernel处理
syscall returnio_uring把提交和完成都放到共享ring里,减少了来回系统调用和数据复制。
它的高效主要来自:
- SQ/CQ是共享内存。
- 一次可以提交多个SQE。
- 一次可以收多个CQE。
- 支持固定文件、固定buffer,减少重复注册开销。
- 支持SQPOLL,减少提交时系统调用。
- 支持多种异步操作,不只是磁盘读写。
6.1 SQPOLL是什么
SQPOLL是Submission Queue Polling。
普通模式下,应用放了SQE后,要通过io_uring_enter告诉内核:
我放请求了,你来处理。SQPOLL模式下,内核有一个polling线程盯着SQ。
应用把SQE放进去后,内核线程会自己发现并提交。
好处:
- 减少系统调用。
- 降低提交延迟。
- 高IOPS场景更有用。
代价:
- 需要一个CPU核或内核线程持续poll。
- 空闲时也可能消耗CPU。
- 配置和权限更复杂。
所以SQPOLL不是无脑开启。它适合极致低延迟/高IOPS场景。
7. io_uring和NVMe队列的关系
io_uring是Linux应用和内核之间的提交/完成机制。
NVMe本身也有队列:
NVMe Submission Queue
NVMe Completion Queue两者不是同一个东西。
可以理解成两层队列:
应用
-> io_uring SQ/CQ
-> Linux block layer / filesystem
-> NVMe driver queue
-> NVMe controller应用看到的是io_uring队列。
NVMe设备看到的是NVMe队列。
中间还有:
- VFS
- 文件系统
- page cache或direct IO
- block layer
- IO scheduler
- NVMe driver
所以如果fio里设置:
iodepth=64并不等于NVMe设备内部某个队列一定正好深度64。
它表示fio尽量保持64个IO in-flight。中间层可能合并、拆分、阻塞或重排。
8. NVMe测试最核心的指标
NVMe测试里最常见指标:
- IOPS
- bandwidth
- latency
- queue depth
- block size
- utilization
- CPU usage
- percentile latency
8.1 IOPS
IOPS是每秒完成多少个IO。
例如:
1秒完成1,000,000个4K随机读
IOPS = 1MIOPS适合看小块随机IO。
比如:
4K randread
4K randwrite8.2 Bandwidth
Bandwidth是吞吐带宽。
例如:
1M IOPS * 4KB = 4GB/s如果是:
100K IOPS * 128KB = 12.8GB/s所以IOPS不能单独看,必须和block size一起看。
小块IO看IOPS,大块顺序IO看带宽。
8.3 Latency
latency是一个IO从发出到完成的时间。
fio里常见三个延迟:
slatclatlat
slat
slat是submission latency。
大致表示fio从创建IO到把IO提交给ioengine所花的时间。
对异步ioengine,它包括queue/commit相关时间。
如果slat高,说明应用侧提交路径有压力,例如:
- CPU不够。
- ioengine开销大。
- 系统调用/锁/内存管理开销高。
- 提交batch设置不合理。
clat
clat是completion latency。
大致表示IO提交出去后,到fio收回完成事件的时间。
这是最常看的设备/系统IO完成延迟。
如果clat高,可能是:
- 设备本身慢。
- queue depth太高排队。
- 文件系统或block layer慢。
- 写入触发GC或flush。
- 混合读写互相干扰。
lat
lat是total latency。
fio文档里说,total latency是:
也就是从fio创建IO到IO完成的总时间。
8.4 平均延迟和尾延迟
只看平均延迟很危险。
比如两个盘平均延迟都是100us:
盘A:
p50 = 90us
p99 = 150us
p99.9 = 300us
盘B:
p50 = 50us
p99 = 5ms
p99.9 = 50ms平均值可能差不多,但盘B对在线服务很危险。
所以要看:
- p50
- p95
- p99
- p99.9
- max
对存储系统,尾延迟经常比平均值更重要。
9. Queue Depth是什么
Queue depth表示同时在飞的IO数量。
fio里对应:
iodepth官方fio文档说,iodepth是要保持in flight的IO units数量。
例如:
iodepth=1表示一次只挂一个IO。
iodepth=64表示尽量保持64个IO同时在飞。
9.1 为什么queue depth提高后吞吐会上升
单个IO延迟假设是100us。
如果iodepth=1,那么理论最大IOPS约为:
即使设备能做100万IOPS,你也只能做到约1万,因为你每次都等。
如果iodepth=64:
这就是queue depth的意义:
用并发IO隐藏单个IO延迟。
9.2 Little’s Law
IO系统里经常可以用Little’s Law直觉理解:
其中:
- 是系统里的in-flight请求数,也就是queue depth。
- 是吞吐,也就是IOPS。
- 是平均响应时间,也就是latency。
换一下:
这只是近似,但很有用。
例子:
latency = 100us
QD = 32理论吞吐:
如果你想达到1M IOPS,平均latency还是100us,那么需要:
这解释了为什么高速NVMe测试必须提高iodepth和numjobs。
9.3 queue depth太高的代价
queue depth不是越高越好。
QD提高后:
- 设备更忙,吞吐上升。
- 但排队也更严重。
- 平均延迟可能上升。
- p99/p99.9可能明显变差。
可以这样理解:
低QD:
延迟低,但吞吐可能打不满。
中等QD:
吞吐接近峰值,延迟还能接受。
过高QD:
吞吐提升不明显,但尾延迟明显变差。所以测试NVMe时,要扫iodepth:
iodepth=1,2,4,8,16,32,64,128,256观察:
- IOPS何时接近平台峰值。
- latency从哪里开始明显上升。
- p99从哪里开始不可接受。
10. Block Size是什么
Block size,也就是fio里的:
bs表示每个IO请求大小。
常见:
4k
8k
16k
128k
1m不同bs测试不同能力。
10.1 4K随机读
bs=4k
rw=randread主要看:
- IOPS。
- 小IO延迟。
- 控制器处理能力。
- block layer开销。
10.2 128K/1M顺序读
bs=128k
rw=read或:
bs=1m
rw=read主要看:
- 带宽。
- PCIe链路能力。
- 文件系统连续读能力。
- 多盘聚合能力。
10.3 为什么大bs带宽高
每个IO都有固定开销。
如果bs很小:
每4KB做一次提交/完成开销很高。
如果bs很大:
每1MB做一次提交/完成单位数据的管理开销更低。
所以大块顺序读更容易打满带宽。
但大bs也会增加单个IO延迟。
11. numjobs是什么
fio的numjobs表示克隆多少个job。
例如:
fio --name=randread --numjobs=4 --iodepth=32总in-flight IO大致是:
也就是:
为什么需要numjobs?
因为单个线程可能提交不够快。
多job可以:
- 利用多个CPU core。
- 触发多个硬件/软件队列。
- 更容易打满设备。
但numjobs也不是越多越好:
- CPU竞争会上升。
- 文件系统锁竞争会上升。
- NUMA不合理会变慢。
- latency可能变差。
12. direct=1为什么重要
fio测试NVMe通常会用:
direct=1它表示使用direct IO,绕过page cache。
如果不用direct IO,读可能命中Linux page cache。
结果就变成测内存,而不是测NVMe。
例如第一次读:
NVMe -> page cache -> user buffer第二次读同样数据:
page cache -> user buffer这时速度可能非常高,但不是盘的真实性能。
所以测试设备性能时,通常要:
direct=1并且测试文件大小要大于内存或至少避免cache命中。
13. fio测试例子
13.1 4K随机读IOPS
fio --name=randread4k \
--filename=/dev/nvme0n1 \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--direct=1 \
--iodepth=64 \
--numjobs=4 \
--time_based=1 \
--runtime=60 \
--group_reporting=1关注:
IOPSclat平均值clatpercentiles- CPU usage
- disk util
- achieved IO depths
解释:
bs=4k:
小随机IO
iodepth=64:
每个job保持64个IO在飞
numjobs=4:
总深度约256如果IOPS很低,但latency也很低,可能是iodepth不够。
如果IOPS不再提高,但latency继续上升,说明已经过了最佳QD。
13.2 1M顺序读带宽
fio --name=seqread1m \
--filename=/dev/nvme0n1 \
--rw=read \
--bs=1m \
--ioengine=io_uring \
--direct=1 \
--iodepth=32 \
--numjobs=1 \
--time_based=1 \
--runtime=60 \
--group_reporting=1关注:
- bandwidth。
- clat。
- PCIe是否打满。
- CPU是否成为瓶颈。
顺序读通常不需要特别多numjobs,一个job配合足够iodepth可能就能打满。
13.3 QD扫描
不要只测一个点。
可以扫:
for qd in 1 2 4 8 16 32 64 128 256; do
fio --name=randread4k \
--filename=/dev/nvme0n1 \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--direct=1 \
--iodepth=$qd \
--numjobs=1 \
--time_based=1 \
--runtime=30 \
--group_reporting=1
done然后画出:
x轴: iodepth
y轴1: IOPS
y轴2: p99 latency你会看到:
IOPS先上升,然后趋于平
latency通常持续上升,尤其尾延迟合理的运行点不是IOPS最大点,而是吞吐和尾延迟都能接受的点。
14. 如何读fio输出
fio输出里常见:
read: IOPS=850k, BW=3320MiB/s
slat (usec): min=..., avg=...
clat (usec): min=..., avg=...
lat (usec): min=..., avg=...
clat percentiles (usec):
50.00th=...
99.00th=...
99.90th=...14.1 先看是否打满设备
看:
- IOPS/BW是否接近设备规格。
- disk util是否接近100%。
- CPU是否满。
- achieved iodepth是否真的达到设置值。
如果设置iodepth=128,但fio输出的IO depth分布显示大部分在<=1,说明测试没有真正把队列打起来。
常见原因:
- 使用了同步ioengine。
- 没有
direct=1导致异步行为受限。 - numjobs太少。
- 文件系统/设备限制。
fio文档也提醒:提高iodepth对同步ioengine基本没用。
14.2 再看latency
重点看:
- avg
- p99
- p99.9
- max
如果平均值很好但p99很差,要小心。
对在线系统,p99通常比平均值更重要。
14.3 看slat和clat谁高
如果slat高:
应用提交IO慢
CPU/系统调用/ioengine/锁可能是瓶颈如果clat高:
IO完成慢
设备/队列/文件系统/写放大可能是瓶颈如果lat明显大于clat,说明提交侧开销不可忽略。
15. NVMe测试常见坑
15.1 测到了page cache
没有direct=1,或者测试文件太小,可能测到内存。
现象:
- 带宽离谱高。
- 第二次比第一次快很多。
- disk util不高。
15.2 iodepth没有真正生效
使用同步ioengine时,iodepth可能没意义。
要检查fio输出的IO depth distribution。
15.3 QD太高导致尾延迟爆炸
为了追求峰值IOPS,把iodepth设很高。
结果:
IOPS提升不多
p99/p99.9大幅变差线上服务不能只追峰值。
15.4 NUMA/PCIe拓扑不对
NVMe挂在某个CPU socket下。
GPU也挂在某个PCIe root complex下。
如果应用线程跑在另一个socket,路径可能跨NUMA。
现象:
- CPU开销高。
- latency变差。
- GDS带宽不稳定。
测试前应该看:
lspci -tv
nvidia-smi topo -m
numactl -H15.5 温度和SLC cache
很多SSD有SLC cache和热降频。
短时间测试可能很好。
长时间写入可能掉速。
所以写测试要看:
- runtime够不够长。
- 是否跨过SLC cache。
- 盘温度。
- steady-state表现。
16. GDS解决什么问题
普通GPU数据加载路径通常是:
flowchart LR
subgraph Normal[普通路径]
NVMe1[NVMe SSD] --> CPU[CPU内存 / Page Cache或Bounce Buffer]
CPU --> GPU1[GPU显存]
end
subgraph GDS[GPUDirect Storage路径]
NVMe2[NVMe SSD] --> GPU2[GPU显存]
end
普通路径多经过一次CPU内存。GDS的目标是让storage到GPU memory的路径更短,减少CPU参与和额外拷贝。
普通GPU数据加载路径通常是:
NVMe -> CPU内存 -> GPU显存也就是:
- 先读到host memory。
- 再用PCIe/NVLink拷到GPU memory。
这有几个问题:
- 多一次内存拷贝。
- CPU参与多。
- CPU内存带宽成为瓶颈。
- latency增加。
GPUDirect Storage希望路径变成:
NVMe -> GPU显存尽量绕过CPU bounce buffer。
NVIDIA的cuFile API就是GDS给应用使用的接口。
17. cuFile基本流程
一个典型GDS读流程:
1. cuFileDriverOpen
2. open文件
3. cuFileHandleRegister
4. cudaMalloc分配GPU buffer
5. cuFileBufRegister注册GPU buffer
6. cuFileRead把文件读到GPU buffer
7. cuFileBufDeregister
8. cuFileHandleDeregister
9. close文件
10. cuFileDriverClose17.1 cuFileDriverOpen / Close
cuFileDriverOpen();
...
cuFileDriverClose();这是初始化/关闭cuFile driver状态。
通常程序启动时open,退出时close。
17.2 cuFileHandleRegister
先用普通Linux API打开文件:
int fd = open(path, O_RDONLY | O_DIRECT);然后注册成cuFile handle:
CUfileDescr_t desc;
memset(&desc, 0, sizeof(desc));
desc.handle.fd = fd;
desc.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD;
CUfileHandle_t fh;
cuFileHandleRegister(&fh, &desc);NVIDIA文档说,cuFileHandleRegister是required,会做额外检查,并把结果缓存起来,让后续cuFile操作更快。
用完要:
cuFileHandleDeregister(fh);只close(fd)不等于释放cuFile内部资源。
17.3 cuFileBufRegister
先分配GPU memory:
void* d_buf;
cudaMalloc(&d_buf, size);再注册:
cuFileBufRegister(d_buf, size, 0);文档明确说,cuFileBufRegister有显著性能成本,应该提前注册,并把成本摊销掉。
直白理解:
不要每次读4KB都注册/反注册buffer。
应该启动时或buffer pool初始化时注册好。用完:
cuFileBufDeregister(d_buf);17.4 cuFileRead / cuFileWrite
同步读:
ssize_t ret = cuFileRead(
fh,
d_buf,
size,
file_offset,
devPtr_offset);参数意思:
fh:cuFile文件句柄。d_buf:GPU或host buffer基地址。size:读多少字节。file_offset:从文件哪里读。devPtr_offset:写到buffer基地址之后的哪个偏移。
注意file_offset和devPtr_offset不是一个东西。
例子:
file_offset = 1GB
devPtr_offset = 0表示:
从文件1GB位置开始读
写到GPU buffer开头如果:
file_offset = 1GB
devPtr_offset = 4MB表示:
从文件1GB位置开始读
写到GPU buffer + 4MB的位置文档还强调:如果使用registered buffer,bufPtr_base必须是注册时的base address。
也就是说:
cuFileBufRegister(d_buf, size, 0);
cuFileRead(fh, d_buf + 4096, read_size, off, 0); // 不推荐/可能不按注册buffer路径正确方式是:
cuFileRead(fh, d_buf, read_size, off, 4096);18. GDS同步API和异步API
cuFileRead和cuFileWrite是同步API。
调用返回时,IO已经完成或报错。
新版本GDS也有stream/async相关API,例如:
cuFileReadAsync
cuFileWriteAsync
cuFileStreamRegisterstream API支持把IO放进CUDA stream顺序里。
直白理解:
同步cuFileRead:
CPU调用后等待IO完成。
cuFileReadAsync:
把IO排到stream相关流程里,允许和GPU计算更好地重叠。实际是否可用,要看CUDA/GDS版本和头文件。
测试时不要只看API名字,要确认:
- 当前CUDA版本。
- GDS版本。
libcufile.so。- 头文件里是否有对应声明。
- 驱动和
nvidia-fs.ko状态。
19. GDS测试指标怎么看
GDS测试也要看:
- bandwidth
- IOPS
- latency
- CPU usage
- GPU copy/compute overlap
- 是否走了真正GDS路径
19.1 关键不是只看带宽
假设两种路径:
路径A:
NVMe -> CPU memory -> GPU
带宽 10GB/s
CPU占用高
路径B:
NVMe -> GPU
带宽 10GB/s
CPU占用低只看带宽,两者一样。
但路径B更好,因为CPU被释放出来,数据路径更短,也更容易和GPU计算流水化。
19.2 小IO不一定适合GDS
cuFile文档提到,不使用cuFileBufRegister时,small IO可能性能不好,因为内部注册buffer会带来额外开销。
即使用注册buffer,小IO也可能受:
- 提交开销。
- 文件系统开销。
- 对齐要求。
- NVMe最小IO粒度。
- GPU page mapping。
影响。
所以GDS更常见收益场景是:
- 大块数据加载。
- 深度学习训练数据集。
- 数据分析/ETL。
- checkpoint读写。
- 大规模embedding/向量数据流。
19.3 对齐很重要
Direct IO和GDS通常对对齐敏感。
要关注:
- 文件offset是否4K对齐。
- IO size是否4K对齐。
- buffer地址是否满足要求。
- 文件系统是否支持。
不对齐可能导致:
- fallback。
- 内部bounce buffer。
- 性能明显下降。
- 直接报错。
20. GDS和io_uring的关系
io_uring和GDS不是同一层东西。
io_uring:
Linux通用异步IO提交/完成接口GDS/cuFile:
NVIDIA提供的GPU memory和storage之间的IO路径/API它们都解决IO效率,但路径不同。
普通io_uring读文件到CPU memory:
NVMe -> CPU memory再拷到GPU:
CPU memory -> GPU memorycuFile读文件到GPU memory:
NVMe -> GPU memory如果做GPU训练/推理数据加载,GDS更直接。
如果做普通服务端文件IO、网络IO、日志、KV存储,io_uring更通用。
21. 一个GDS读文件例子
伪代码:
cuFileDriverOpen();
int fd = open("data.bin", O_RDONLY | O_DIRECT);
CUfileDescr_t desc;
memset(&desc, 0, sizeof(desc));
desc.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD;
desc.handle.fd = fd;
CUfileHandle_t fh;
cuFileHandleRegister(&fh, &desc);
void* d_buf;
cudaMalloc(&d_buf, 128 * 1024 * 1024);
cuFileBufRegister(d_buf, 128 * 1024 * 1024, 0);
ssize_t n = cuFileRead(
fh,
d_buf,
128 * 1024 * 1024,
0,
0);
// GPU kernel can consume d_buf here.
cuFileBufDeregister(d_buf);
cudaFree(d_buf);
cuFileHandleDeregister(fh);
close(fd);
cuFileDriverClose();这个例子里,数据目标是GPU buffer d_buf。
如果GDS路径可用,数据可以绕过CPU bounce buffer,直接进入GPU memory。
22. GDS测试常见坑
22.1 没有真的走GDS路径
可能原因:
- 文件系统不支持。
- NVMe和GPU拓扑不支持P2P路径。
nvidia-fs.ko没加载。- 驱动版本不匹配。
- IO不对齐。
- buffer没注册或走了compat path。
要用NVIDIA工具检查,例如:
gdscheck -p还要看日志:
/var/log/cufile.log22.2 每次IO都注册buffer
错误方式:
for each IO:
cudaMalloc
cuFileBufRegister
cuFileRead
cuFileBufDeregister
cudaFree这样测出来的不是GDS读性能,而是注册/释放开销。
正确方式:
初始化buffer pool
注册buffer
循环复用buffer做IO
程序结束时反注册22.3 IO太小
4K小IO可能无法体现GDS优势。
GDS更适合大块流式读写。
可以测试:
4K
64K
128K
1M
4M观察带宽和CPU占用变化。
22.4 CPU和GPU/NVMe拓扑不对
即使GDS可用,拓扑不合理也会影响性能。
要确认:
- GPU和NVMe是否在同一PCIe root complex附近。
- 是否跨NUMA。
- PCIe链路是否降速。
- 是否经过ACS/IOMMU导致P2P受限。
23. IO性能分析的基本方法
遇到IO性能不符合预期,不要直接猜。
可以按这个顺序查:
23.1 先确认测试目标
你要测的是:
- 设备峰值带宽?
- 4K随机IOPS?
- 单IO最低延迟?
- p99尾延迟?
- GDS到GPU的端到端带宽?
- CPU占用?
不同目标用不同参数。
23.2 再确认是否绕过缓存
对NVMe测试:
direct=1
测试文件足够大
避免page cache对GDS:
确认是否走GDS path
确认没有compat fallback23.3 扫block size和queue depth
不要只测一个点。
建议至少扫:
bs: 4K, 16K, 128K, 1M
iodepth: 1, 4, 16, 64, 128
numjobs: 1, 4, 823.4 看平均和尾延迟
只看IOPS/BW不够。
要看:
avg clat
p99 clat
p99.9 clat
max clat23.5 看CPU和拓扑
如果设备没打满:
- CPU是不是满了?
- fio进程跑在哪个NUMA node?
- NVMe中断在哪个CPU?
- GPU和NVMe是否跨socket?
- 文件系统是否成为瓶颈?
24. fio参数组合速查
24.1 测最低延迟
fio --name=lat \
--filename=/dev/nvme0n1 \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--direct=1 \
--iodepth=1 \
--numjobs=1 \
--time_based=1 \
--runtime=30目标:
看单IO延迟24.2 测随机读峰值IOPS
fio --name=iops \
--filename=/dev/nvme0n1 \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--direct=1 \
--iodepth=128 \
--numjobs=4 \
--time_based=1 \
--runtime=60 \
--group_reporting=1目标:
看设备小IO吞吐上限24.3 测顺序读带宽
fio --name=bw \
--filename=/dev/nvme0n1 \
--rw=read \
--bs=1m \
--ioengine=io_uring \
--direct=1 \
--iodepth=32 \
--numjobs=1 \
--time_based=1 \
--runtime=60 \
--group_reporting=1目标:
看大块吞吐和PCIe带宽24.4 测尾延迟
fio --name=tail \
--filename=/dev/nvme0n1 \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--direct=1 \
--iodepth=32 \
--numjobs=4 \
--time_based=1 \
--runtime=120 \
--percentile_list=50:90:95:99:99.9:99.99 \
--group_reporting=1目标:
看p99/p99.9是否稳定25. 总结
IO性能要分层看。
第一层是应用提交层。io_uring通过SQ/CQ共享ring,让应用能低开销提交和收割大量异步IO。
第二层是设备执行层。NVMe需要足够queue depth才能打满,但QD太高会增加排队和尾延迟。
第三层是测试指标层。IOPS、bandwidth、latency必须结合block size、iodepth、numjobs一起看。
第四层是GPU数据路径。GDS/cuFile让数据可以从storage直接进入GPU memory,减少CPU bounce buffer和额外拷贝。
最重要的判断是:
IO测试不是追一个最大数字,而是找到目标负载下吞吐、平均延迟、尾延迟、CPU开销和资源占用之间的平衡点。
一句话概括:
io_uring解决“怎么高效提交很多IO”,NVMe指标告诉你“设备和系统实际承受了什么”,GDS/cuFile解决“数据怎么更直接地进入GPU”。
26. 参考
- Linux man-pages:io_uring(7),https://man7.org/linux/man-pages/man7/io_uring.7.html
- fio官方文档,https://fio.readthedocs.io/en/master/fio_doc.html
- NVIDIA GPUDirect Storage cuFile API Reference,https://docs.nvidia.com/gpudirect-storage/api-reference-guide/index.html
- NVIDIA GPUDirect Storage Best Practices Guide,https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html