本文经 叉鸽 授权,由七牛开发者账号翻译。叉鸽 也是七牛开发者 Router 内容计划的第 6 位作者。
Router 内容计划是由七牛开发者发起的 AI Coding 实践分享计划。我们希望成为连接开发者与开发者的“路由器”:持续收集、整理并分享一线开发者在 AI Coding 中的真实经验,包括工具选择、工作流改造、踩坑复盘和效率实践,让更多人的实践被看见,也让更多开发者可以从中获得参考。
作者叉鸽,活跃在开源社区的程序员,Foyer 项目作者。关注开源与高性能基础设施开发。喜欢看看电影喝喝啤酒,希望能为开源社区做更多贡献。
foyer 是一个用 Rust 编写的高效、用户友好的 Hybrid Cache 库,它将内存缓存和磁盘缓存无缝集成,旨在提供高并发、低延迟的数据存储与读取能力。GitHub 地址:github.com/foyer-rs/foyer
任务失败得很成功:把网卡和磁盘带宽跑满
AI 时代来得比我们大多数人预想得更快。Agentic coding 彻底改变了我的日常工作方式。老实说,我已经有一段时间没有在工作里亲手写过一行代码了。是的,这是真的,一行都没有!!虽然但是这并不影响“我的”代码在几百台 HPC 服务器组成的集群上以峰值性能运行。
当然,我们不写代码,甚至没有完整 review 每一行代码,并不代表我们就真在乱点一通,像猴子打字一样碰运气。需求还是要分析,设计得和 agent 一起细化,demo 要搭,模拟实验要跑,小规模测试结果要看,发现问题之后还要继续迭代,完整可靠的测试流程也要维护。诸如此类,该做的事一样都少不了。

图注:敲键盘的猴 🐒
不过,有了 AI 和 agentic coding,很多事情确实变快了。有时,代码产出的速度会快过我们理解它的速度,甚至快过 AI 自己理解它的速度。没错,你没看错。这篇文章就来自这样一个例子。
我给 agent 发了一个 prompt,让它帮我优化系统性能。AI 很快就把系统吞吐从大约一半提升到了接近跑满。问题是,它对“为什么能生效”的解释完全错了。换句话说,这是一次非常典型的 task failed successfully。

图注:任务失败得很成功
这篇文章不讨论 AI 为什么会“失败得很成功”。它主要记录这次系统性能优化背后的分析和调试过程。
用 1 张网卡和 8 块盘优化一个 Demo
为了把注意力放在性能优化上,我们先把复杂业务抽象成一个简单模型:
一个单线程向 8 块 NVMe 盘发起 1 MiB 的随机 Direct I/O 读请求,然后通过 RDMA WRITE 操作把数据发送到远端主机。现在的目标很直接:把网卡带宽跑满。
具体来说,每块 NVMe 硬盘最高可以提供 7 GiB/s 的读取吞吐,网卡提供 400 Gb/s 的网络带宽。所有相关设备都连接在同一个 NUMA 节点上。worker 线程被固定绑定到一个 CPU 核心上,而且这个核心不是 CPU0。主机的 IOMMU 运行在 passthrough 模式,这次涉及的 I/O 设备都没有经过 IOMMU 做地址转换。
具体实现上,我(其实是我的 AI agent)搭了一个很简单的事件循环:client 向 server 发送读请求;server 轮询 RDMA CQ,接收传入的请求,然后通过 io_uring 提交读操作,再轮询对应的 CQE,最后通过 RDMA WRITE 把数据发回去。

这个 demo 的设计很简单,也基本排除了大部分干扰因素。除了我们要跑满的网卡,其他组件都有足够余量:网卡的理论最大吞吐是 46.6 GiB/s;每块盘的平均读取吞吐低于 6 GiB/s;总 IOPS 保持在 50,000 以下;CPU 也还有充足空间。
环境准备好了,接下来看看测试结果。

结果有点出乎意料。系统在 I/O depth 只有 16 时就已经撞上瓶颈,总吞吐只达到网卡带宽的大约一半。同时,CPU 利用率已经到了 100%。
这里肯定有什么地方不对劲。于是,我在 I/O depth 为 16 的情况下用 perf 做了 profiling。下面是火焰图。

图注:Simple Demo 火焰图,iodepth=16
从火焰图可以看到,大部分 CPU 时间都花在了 io_submit_sqes 上,它占到了总 CPU 开销的 81.62%。因为这个 demo 使用的是 Direct I/O,所以每次提交 I/O 时,内核都要根据用户态 buffer 构造一份给块设备使用的 DMA 元数据。这条路径里最耗时的部分主要有:
__bio_iov_iter_get_pages:把iov转成biopages。pin_user_pages_fast:把一段用户空间虚拟地址转换成一组struct page指针,并把这些页面 pin 住。这样设备执行 DMA 时,这些页面就不会被回收、迁移或换出。
bio_set_pages_dirty:把 buffer pages 标记为 dirty。使用 Direct I/O 时,NVMe 设备会通过 DMA 直接把数据写进用户空间 buffer 背后的页面里。之后,这些页面需要被标记为 dirty,避免 VM 把它们当成 clean pages 处理。folio_*:更新和 folio 相关的 VM 状态,包括引用计数、dirty 状态、mapping、locking,以及和回收相关的状态。在 Linux VM 里,folio 是一组物理连续页面的统一抽象。
简单来说,io_submit_sqes 那个很宽的栈帧,代表的是为 Direct I/O DMA 准备用户内存时,一路累积下来的成本。每个 SQE 里只有一个用户空间指针和长度。内核必须遍历页表,找到并 pin 住背后的 struct page,构造 bio_vec 条目,更新 folio 状态,最后提交生成的 bio。
这些工作大多是按内存页来计费的。一个由 4 KiB 页支撑的 1 MiB 读请求,大约会涉及 256 个页。于是,一次逻辑读请求就会变成几百次页表查找、page pin、folio 更新和 bio-vector 操作。每秒 20,000 到 50,000 次读取时,系统每秒要通过 GUP,也就是 Get User Pages,处理大约 500 万到 1300 万个页。
如果这段虚拟地址背后的物理内存碎片很多,还可能带来同等规模的 folio 元数据更新、原子 refcount / pincount 更新,以及潜在的跨核心 cache line 争用。
所以,如果能避免每次 I/O 都重新处理一遍用户空间 buffer,性能应该就能上去。幸运的是,liburing 正好提供了这样的办法。io_uring_register_buffers(3) 允许我们提前注册 I/O buffer,把这部分元数据准备工作从每次 I/O 的路径里挪出去。
具体来说,io_uring_register_buffers(3) 会提前完成下面这些工作:
提前校验
iovecs,检查地址范围、长度、对齐方式和数量限制。对这些 buffer 执行 GUP,把用户空间虚拟地址转换成对应的
struct pages/ folios,并在注册有效期内 pin 住这些页面。构造并保留内核侧的 buffer 元数据,为每个注册的 buffer 建好
io_mapped_ubuf。
这些正好就是我们刚才在火焰图里看到的主要开销。现在我们来试试看效果:
在这个 demo 中,我们引入了一块 64 MiB 的 read arena,并把它切成一个个 1 MiB 的 slot,刚好和 I/O size 对齐。启动时,我们通过 io_uring_register_buffers(3),把这块包含 64 个 slot 的 read arena 注册成 64 个 io_uring 固定缓冲区,每个 slot 对应一个 iovec。
每次读取时,我们把 opcode 从 opcode::Read 换成 opcode::ReadFixed,并把 buf_index 设置成对应的 slot。这样一来,I/O 路径就可以直接使用注册好的 buffers。
下面是测试结果:

随着 I/O depth 增加,吞吐继续上升。在 I/O depth 为 64 时,它已经接近跑满网卡带宽。

和 baseline 相比,在较低的 I/O depth 下,两者吞吐差不多,因为这时 CPU 还没有成为瓶颈。到了 I/O depth 16,baseline 已经开始明显感受到 CPU 压力;再往后,每次 I/O 都要处理 buffer,这部分开销就彻底被 CPU 卡住了。
READ_FIXED 去掉了这个瓶颈,让吞吐可以继续往上扩展,直到把网卡带宽跑满。
火焰图也能佐证这一点。

图注:READ_FIXED 的 Simple Demo 火焰图,iodepth=16
扩展到更大的部署规模
Simple Demo 这边处理完之后,我们可以继续看一个更大规模的 demo。这个版本会更接近真实部署环境。
在这个大规模 demo 里,client 是一台单节点机器,上面配了 8 张 400 Gb/s 的 NIC。server 端由 4 台节点组成。每台 server 有 2 个 NUMA 节点;每个 NUMA 节点上有 1 张 400 Gb/s NIC,以及 8 块和前面 demo 相同的 NVMe 盘。
I/O size 也从 1 MiB 增加到了 1,028 KiB,因为还需要额外 4 KiB 来存放元数据。
事件循环和前面的版本差不多。不过,为了更接近真实负载,server 在每次读完之后,会先校验这次读取数据的 CRC,再通过 RDMA WRITE 发回去。CRC 计算用的是 crc-fast 这个 crate,里面包含了用 AVX-512 VPCLMULQDQ 指令优化过的实现。单核上,它大约可以提供 50 GiB/s 的 checksum 吞吐。
单看 CRC 计算,这个吞吐刚好够应付当前负载。但一个 CPU 核还要跑事件循环,还要处理其他应用逻辑,所以每个 NUMA 节点只放一个 worker 线程就不太够了。于是这里改成:每个 NUMA 节点由多个 worker 线程共同服务,这些 worker 线程共享同一个 index 和本地 8 块 NVMe 盘。index 本身也做了分片,避免全局 index 锁变成性能瓶颈。
每个 worker 线程都会和 client 节点上的每一张 NIC 建立连接。每条连接都有一个独立的 QP;而同一个 worker 线程拥有的所有 QP,会共享一个 CQ,用来处理 completion。

为了让后面的讨论更清楚,我会用下面几个词描述 server 端不同层级的分片:

现在,我们对这个大规模 demo 的结构已经比较清楚了,至少希望如此。接下来给它上压力!
下面是测试结果。这里的 T 代表每个 shard 的 worker 线程数,所有吞吐数字单位都是 GiB/s。

理论上,系统应该能在所有 8 张网卡上达到 372.5 GiB/s 的总吞吐。但实际结果只有大约一半。
新的瓶颈来了!
接下来,我们一步步看火焰图,找出瓶颈在哪里。
排除 iou-wrk 的影响
为了定位瓶颈,我们先看新的火焰图。它来自一次 T=8、I/O depth 为 64 的运行。
提示:完整火焰图太大,无法直接嵌入这里。可以下载 SVG 后在浏览器里打开,方便看细节。

[图:Large Demo Flamegraph,T=8,iodepth=64]
虽然多 shard 的火焰图看起来比较复杂,但它其实很清楚地分成了两块:左边是 16 个 iou-wrk 线程,右边是 16 个 worker 线程。
前面给 Simple Demo 抓火焰图时,系统里只有一个 worker 线程,所以我们只 profile 了那个线程,没有把 iou-wrk 线程算进去。再加上 Simple Demo 当时已经能把 NIC 跑满,这也让我们很容易忽略 iou-wrk 可能带来的影响。
这个瓶颈会不会是 iou-wrk 的开销造成的?要回答这个问题,先看一下 iou-wrk 线程是从哪里来的。
iou-wrk 是 io_uring 使用的内核 worker 线程。当提交 I/O 的线程没办法立刻完成一个请求时,io_uring 就会把这个请求交给 iou-wrk,让它沿着内核提交路径,或者可能阻塞的路径,继续异步执行。
那为什么我们的读请求会被 offload 到 iou-wrk 呢?
大规模 demo 沿用了 Simple Demo 里的 read arena 设计:buffer 会提前注册到 io_uring,读取时使用 READ_FIXED。这样可以避开每次 I/O 都做页表遍历和 page pinning 的成本,但底层物理内存依然是由分散的 4 KiB 页支撑的。
在构造 bio 和 request 时,如果支撑一个 1,028 KiB buffer 的 257 个 4 KiB 页,对应的 PFN 并不连续,它们就没法合并成更大的物理连续区间。于是,从 block layer 和 DMA engine 的视角看,这次 I/O 大概就会表现为 257 个 scatter-gather segments。
但硬件和系统都有各自的限制,没办法把这 257 个 segment 全部塞进同一个 bio 里。下面这些参数,都可能导致一个 bio 被拆分:

在 server 上,这些参数配置如下:
> dev=nvme0n1
> for f in \
max_segments \
max_segment_size \
max_sectors_kb \
max_hw_sectors_kb \
nr_requests
do
printf "%-24s " "$f"
cat "/sys/block/$dev/queue/$f"
done
max_segments 128
max_segment_size 4294967295
max_sectors_kb 1280
max_hw_sectors_kb 4096
nr_requests 1023
因为 257 除以 128 还会多出 1,所以每个 1,028 KiB 请求会被拆成 3 个 bio:前两个 bio 各包含 128 个 segment,也就是 512 KiB;最后一个 bio 只包含 1 个 4 KiB segment。算下来,平均 bio size 就是 342.67 KiB。
这一点也可以通过 iostat 里的 rareq-sz 指标确认:
> iostat -x -d /dev/nvme0n1
Device ... rareq-sz ...
nvme0n1 ... 342.65 ...
所以,一个 1,028 KiB 请求的触发链路大致是这样:
1,028 KiB buffer,没有使用 hugepage
→ 257 个物理 4 KiB segment
→ 超过 max_segments = 128
→ 进入 multi-bio / split 路径
→ io_uring 先用 NOWAIT 提交
→ block Direct I/O 路径发现 iterator 里还有数据要处理,无法继续 inline 执行
→ 返回 -EAGAIN
→ io_uring 调用 io_queue_async / io_queue_iowq
→ iou-wrk 接手
到这里,我们已经知道是什么触发了 iou-wrk。接下来的问题是:iou-wrk 本身到底是不是那个让吞吐继续上不去的瓶颈?
有点遗憾,答案是 NO。几个额外实验可以把它排除掉。在运行大规模 demo 的时候,我还单独做了一组关闭 CRC 的实验。结果如下,单位是 GiB/s。

结果可以看到,即使 bio split 和 iou-wrk 仍然存在,关闭 CRC 之后,性能还是有了明显提升。Simple Demo 也指向同一个结论:它完全没有把 iou-wrk 的开销算进去,却能很轻松地跑满了 NIC。
还有几个额外观察,也支持这个判断。
我追踪了从 io_uring_queue_async_work 到 io_wq_submit_work 的交接过程,发现排队延迟只有几微秒级别:

把 Little’s Law 应用到 io-wq 队列上:
Lq = λ × Wq
算下来,平均队列深度低于 1。换句话说,平均来看,io-wq 队列里甚至连一个等待中的 disk read 都不到。
这是一个很有力的反证:如果 iou-wrk 的 backlog 真的是那堵吞吐墙,队列不可能一直这么浅。
worker pool 这边也不是线程不够。系统在饱和状态下确实会创建很多临时的 iou-wrk 线程,但真正处于 runnable 状态的几乎没有:

所以,这也不是 worker pool 被耗尽的问题。磁盘队列同样没有跑满:每块盘的 aqu-sz 大约是 3,而 nr_requests 是 1023。
最后,为了单独看 split 操作本身的影响,我们又做了一组对照实验:只降低 block queue 的 max_sectors_kb。
更低的 rareq-sz 说明,请求确实被拆得更细了,从 342.67 KiB 降到了 205.60 KiB。也就是说,这个对照实验真的把 split 数量从每个 GET 3 个 request 增加到了 5 个 request。但吞吐没有变化:

这就排除了几个可能性:request splitting 本身、每次 split 带来的 CPU 开销,以及 async 和 inline 路径之间的切换,都不足以解释这段吞吐差距。
它们确实都会产生影响,但单独看,谁都不是那堵真正的墙。
排除 fget 的影响
在 Simple Demo 和大规模 demo 的 per-shard 火焰图里,我们还发现 io_uring_enter 里有另一块主要开销,其中大部分来自 fget。这里可以拿大规模 demo 某次运行里的 shard 48 举个例子。

图注:Large Demo 火焰图,shard=48
fget 可能来自两个地方:一是在 disk SQE 路径上查找目标文件描述符,也就是每次真实 disk I/O 都要查一次;二是在每次调用 io_uring_enter 时,查找 ring 文件描述符本身。那 fget 会不会就是性能瓶颈的来源?
和 READ_FIXED 类似,io_uring 也提供了机制,可以把这两类开销都消掉:registered files 可以去掉 disk-I/O 路径上的 fd lookup;registered ring fd 则可以去掉每次 io_uring_enter 调用时对 ring fd 本身的查找。两者合起来,就能消掉这两类 fget。

图注:Large Demo 火焰图,shard=48,regfiles=on,regring=on
从火焰图可以确认,fget 开销现在几乎完全消失了。然而,瓶颈依然还在。下面的吞吐数字单位是 GiB/s:

这说明,fget 并不是性能瓶颈的来源。火焰图里还有一个现象:虽然 fget 已经消失了,但 io_uring_enter 的开销还在。为什么?
剩下的这部分开销,来自我们的 busy-polling loop:当没有 I/O 完成,而 CPU 也暂时没别的事情可做时,io_uring 的 poll 路径就会一直 spin。
这也从侧面说明,当前瓶颈并不是被 CPU 算力卡住的。
排除 CRC 计算的影响
虽然上一个章节的实验足够说明瓶颈并没有卡在 CPU 算力上,但为了稳妥起见,我们还是继续排除 CRC 计算这个可能的竞争来源。毕竟前面的实验里,关掉 CRC 之后,性能确实突破了那堵瓶颈。
这一次,我们用三种配置更仔细地验证这个假设:
开启 CRC
关闭 CRC
关闭 CRC,但每次读完之后,以 64 B 的步长 touch 一遍 read buffer
这里的 touch=on 指的是一次 cache line 粒度的扫描:每次读完后,worker 会在整个 1,028 KiB buffer 上,每隔 64 B 做一次 load,但不做 CRC 计算。

有意思的事情出现了:完全关闭 CRC 时,性能确实突破了瓶颈。但如果只是关闭 CRC 计算,同时在 read buffer 上每隔 64 B 做一次 load,性能又撞回了同一个瓶颈。
这已经足够说明,CRC 计算本身并不是瓶颈来源。更关键的是,我们似乎找到了那个能复现瓶颈的关键操作。
真正的瓶颈:TLB Miss
在上面章节中,我们观察到了一个有意思的现象:只要读完之后,以 64 B 的步长 touch 一遍 buffer,即使完全不做任何计算,系统还是会撞上瓶颈。
再回头看 Simple Demo 和「排除 iou-wrk 的影响」的实验,我们已经不止一次遇到 4 KiB 页带来的问题。那么,这里的瓶颈会不会其实没有卡在计算上,也没有卡在 I/O 上,而是卡在 4 KiB 页的地址转换停顿上?
为了验证这个假设,我们把原来由 4 KiB 页支撑的 64 MiB read arena,换成由 1 GiB hugepage 支撑的 read arena,然后对比测试结果。下面的吞吐数字单位是 GiB/s。

结果显示,开启 hugepage 之后,即使 CRC 仍然打开,在 T=8 的配置下,系统也已经几乎可以把 NIC 跑满。
这足以确认,hugepage 确实能有效解决这个瓶颈。同时也说明,「扩展到更大的部署规模」里被我们排除掉的那三个因素,都不是根因。
不过,瓶颈虽然已经被消掉了,但我们还不能就此断定它一定来自地址转换。还需要更硬的证据。于是,我重新跑了一次实验,并用 perf stat 统计 CPU 的 L1D miss 和 dTLB miss。

结果已经很清楚了。使用 4 KiB 页时,无论是开启 CRC 的运行,还是关闭 CRC、但以 64 B 步长扫描 read buffer 的运行,平均每个 GET 都会产生 80 多次 dTLB miss。
一旦开启 hugepage,dTLB miss 数量就会降到和完全关闭 CRC 时差不多的水平。这给出了直接证据:dTLB miss 才是吞吐瓶颈的根因。
相比之下,在所有测试配置里,L1D miss 数量变高,并没有和吞吐下降形成对应关系。这也排除了 L1D miss 作为瓶颈的可能。
为什么 TLB miss 会带来这么大的性能下降?
TLB 可以理解成 CPU 的地址转换缓存:它保存最近用过的虚拟页到物理页的映射关系。CPU 在从内存里 load 数据,或者向内存 store 数据之前,得先知道这个虚拟地址背后对应的是哪个物理页。TLB hit 很快;一旦 miss,CPU 就得去走页表。
TLB miss 之所以重要,是因为它会在真正访问数据之前让执行停下来。对于 4 KiB 页里的一个地址,转换过程可能需要多级页表遍历,比如经过 PGD、PUD、PMD 和 PTE 这些条目。而这些页表项本身也要从 cache 或内存里取出来,过程中还可能继续引发 cache miss。
对于一条连续扫描 1,028 KiB value 的路径来说,CPU 会一边读取 cache line,一边不断跨过 4 KiB 页边界。一个 1,028 KiB value 会横跨 257 个 4 KiB 页。只要当前活跃的地址转换装不进 dTLB,CPU 就会反复执行 page walk,吞吐也就被直接拉低了。
hugepage 有用,是因为它把地址转换的粒度变大了。使用 4 KiB 页时,一个 1,028 KiB value 需要 257 次页转换;使用 1 GiB hugepage 时,同样这段 1,028 KiB 区域,通常只需要一个 large-page translation 就能覆盖。这样一来,少量 TLB entry 就能覆盖更多数据,dTLB miss 和 4 KiB page-walk reload 也会明显减少。
到这里,整条线终于串起来了。READ_FIXED 去掉了内核侧反复发现并 pin 用户页的成本,但它没有改变应用后续扫描数据时 CPU 需要付出的代价。使用 4 KiB 页时,每个 1,028 KiB value 仍然会逼着 CPU 跨过几百次页转换;CRC 只是把这种扫描显性化了,touch=on 则在完全不做 checksum 计算的情况下,复现了同样的压力。
hugepage 补上了缺的那一环,让数据路径对地址转换也更友好。这个瓶颈没有落在磁盘、NIC、io_uring offload、fd lookup 或 CRC 算术计算上。真正拖慢系统的,是这些组件共同经过的那段内存地址转换成本。
X. “A Planet Upside Down”
事实上,最早用 AI 调这个性能瓶颈时,它一开始就建议我用 hugepage 来优化 read arena,而且这确实足够把 NIC 跑满。
但它没有找到真正的底层原因:TLB miss。相反,它一直在围绕前面已经发现的那些问题打转。
对于有 HPC 和存储性能优化经验的工程师来说,这类问题也许凭经验就能闻出一点味道。但这次调试过程中留下的线索可能太稀疏,AI 没有足够证据把那条推理路径完整还原出来。它给出了答案,却没有给出通向答案的完整推理链。
我想,这大概也是这篇博客值得写下来的原因之一。
说实话,当 AI 完成最初那次性能优化时,我的工作本来就该结束了。讽刺的是,弄清楚这次优化为什么能生效,最后花掉的时间远远超过了原任务本身。随着 AI 模型能力越来越强,它们即使没有真正理解底层原理,也能搭出相当不错的系统。
但把这些原理追清楚,一直是我作为程序员的一点小执念。在 AI 的浪潮里,这也许也是我避免自己滑向无意义感的一种方式。
最后,我想分享几句最近常听的一首歌里的歌词。敬这个时代里,仍然努力让自己脚踩实地的人。

图:A Planet Upside Down - Pearl & The Oysters
原文链接:blog.mrcroxx.com/posts/task-failed-successfully-saturating-nic-and-disk-bandwidth/