ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

长上下文推理I/O瓶颈破解:双路径网卡直读方案全记录

长上下文推理I/O瓶颈破解:双路径网卡直读方案全记录

最近这段时间,“V4底座”这几个字在AI圈里讨论度确实很高。不过说句实在话,我在跑推理压测时,关注的不是模型层的那些改动,而是另一件事:长上下文I/O瓶颈。128K甚至更长上下文一上来,算力反而不是最吃紧的,真正被拖死的是数据在GPU、CPU、网卡之间来回倒腾的速度。这篇文章是我们近期的一次完整调优记录:把机器里闲置的网卡重新拉回数据通路,用“双路径”的思路把长上下文推理的I/O瓶颈打开一个口子。

文章会覆盖问题拆解、方案选型、具体配置、实测对比和现场踩坑,偏实际落地。适合正在做推理部署、长上下文服务,或者关注AI Infra 的读者。你不用有太深的网络背景,只要做过基础的大模型推理,跟着走一遍就能理解这套思路的价值。

1. 先说结论:长上下文真正卡的不是算力,而是I/O

大模型推理分两个阶段:prefill(预填充)和 decode(逐 token 生成)。prefill 阶段把整个输入序列一次性算完,生成KV cache;decode 阶段每生成一个 token,都要从头到尾去读这整份 KV cache。序列越长,KV cache 越大,后续每个 token 的读取压力就越大。

这里有个特别直观的计算,我每次讲都会列出来。假设模型有 32 层、32 个头、head dim 是 128,dtype 用 FP16(2字节),那么每个 token 的 KV cache 大小是:

KV_cache_per_token = 2 × num_layers × num_heads × head_dim × dtype_size = 2 × 32 × 32 × 128 × 2 = 512KB

也就是说,处理一条 128K 长度的上下文,KV cache 就有 128 × 1024 × 512KB = 64GB。这个量级早就超过单卡显存了,必须考虑跨卡、跨节点读写。

问题就出在这里:传统推理架构里,跨设备搬运KV cache 走的是 CPU 内存中转——GPU 先把数据拷到主机内存,CPU 做一次 memcpy,再交给网卡发出去,对端收到后再拷进 GPU 显存。每一步都消耗内存带宽、PCIe带宽和CPU中断。你想想一个 64GB 的东西,就算只搬运一小部分,延迟也高得吓人,更别说高并发下多个请求同时抢这些资源。

我打个比方:你请了个大厨(GPU),食材放在仓库(远端显存)。传统做法是大厨要亲自跑到仓库,把食材一箱箱搬回厨房,做完一道菜再跑一趟。大厨的大部分时间不是花在炒菜上,而是花在路上。旁边明明有传送带(网卡DMA能力),你却从来不用它。这套双路径方案,本质上就是把传送带接进厨房,让食材自己流到灶台边,大厨只负责炒菜。

所以结论很直接:长上下文推理的瓶颈不在 FLOPs,而在每 token 的字节搬运量。叠再多的算力,只要数据搬运链路堵着,延迟一样下不来。

2. 双路径方案到底是怎么设计的

所谓“双路径”,我们在实际落地时把它拆成了两层含义。第一层:控制路径和数据路径分离。请求进来、调度元数据、返回结果这些“小流量”走传统TCP管理通道;而KV cache、中间激活这些“大流量”走RDMA数据通道。第二层:prefill 和 decode 各自的 I/O 链路分开,避免互相挤占。

2.1 路径A:本地显存直读路径

路径A处理的是单机内、单卡内的常规计算流程。prefill 阶段,输入经过 attention 计算后生成 KV cache,直接写在当前 GPU 显存;decode 阶段,本地显存里的 KV cache 被高带宽读取,供 attention 计算使用。这条路径的特点是频率高、每次读取的数据量相对可控,需要的是显存带宽和 L2 cache 的命中率。

本地这一路我们没做太多改动,主要做两件事:一是保证显存中KV cache 的布局尽量连续,降低地址跳跃带来的带宽浪费;二是把不需要实时同步的部分留在显存里,不急着往外搬。很多团队一提到长上下文就想着全部offload,其实没必要,本地显存能放多少就放多少,放不下的才走远端路径。

2.2 路径B:RDMA远端直读路径

路径B针对的是跨卡、跨节点搬运。传统做法是GPU→主机内存→网卡→远端主机内存→远端GPU,全程经过CPU管理。双路径的做法是启用RDMA和GPUDirect,让网卡直接读远端GPU显存里的数据,源端和目的端都绕过CPU、绕过内存拷贝、绕过内核协议栈。

这里的关键是“直读”两个字。RDMA网卡内部有 DMA 引擎,只要把远端 GPU 显存的物理地址映射成可访问的内存区域,网卡自己就能把数据拉过来,CPU几乎感知不到。配合 GPU Direct,本地的GPU也可以作为RDMA读写的源或目的地址,连主机内存都不用碰。整个过程就像两台电脑之间用一条专用光缆直接对拷硬盘数据,不再走U盘中转。

2.3 为什么必须“双路径”,而不是“一条更宽的路”

我最早也想偷懒,是不是把网卡从25G换成100G,宽带翻四倍就解决了?实测下来并不行。问题是单条路径上,控制流量和数据流量混在一起,长连接里的 KV cache 大数据块一旦开始传输,管理报文、任务结果全被堵在后面;反过来,控制平面的抖动也会拖慢数据吞吐。

这种互相干扰在RoCE这种无损网络上尤其明显。PFC是基于优先级做流控的,如果控制面和数据面流量混在一起,一个突发拥塞可能触发整条链路暂停,所有流量一起等。而两条独立路径可以用不同的优先级、不同的队列、甚至不同的网口,一边堵了另一边不倒,天然隔离故障域。

还有一点:prefill 和 decode 的 I/O 特征差异很大。prefill 是突发写入大量 KV cache,带宽需求高;decode 是持续小块读取,延迟敏感。把它们放到同一条逻辑链路上,要么为突发预留过量带宽,要么因为小块读取被大块传输塞住导致 delay 抖动。拆成两条独立链路后,可以用不同的队列深度、不同的预取策略去适配各自的特征。

3. 唤醒闲置网卡的完整实操

这套方案操作起来不复杂,但细节非常琐碎。我们踩了不少坑才把所有环节串起来。下面按步骤写,方便直接抄。

3.1 先确认网卡是真的“闲置”

很多机器上的第二张网卡常年没人用,但你可能意识不到。我建议先跑几条命令确认一下。

# 看有几张网卡、什么状态 lspci | grep -i ethernet # 看网卡驱动和固件版本 ethtool -i eth1 # 看实际收发字节数,和理论带宽做对比 ip -s link show eth1

如果你发现某张网卡的 RX/TX bytes 比另一张低好几个数量级,而且带宽速率被限制在很低的档位,那它基本就是闲置的。另外用rdma link show检查这张卡有没有 RDMA 能力,很多闲置网卡本身就是支持 RoCE 的,只是没被启用。

这个步骤不能省。我们第一次以为机器上全是100G卡,结果lspci -vvv一看,其中一张被插在 PCIe 3.0 x4 的槽位上,实际吞吐上限只有 3.9GB/s,跑满100G网络根本不可能。物理拓扑先看明白,后面才不白忙。

3.2 把网卡变成可直读显存的通道

这一步是整个方案的技术核心,目的只有一个:让网卡能通过 RDMA 直接读写 GPU 显存。

先装驱动底座。不同的网卡对应不同的发布包,但内核态的通用部分是rdma-core,用户态的工具集包括ibstat、ib_write_bw、rdma link这些都来自于它。装完确认一下模块有没有加载:

lsmod | grep rdma lsmod | grep mlx # 如果用的是某厂商网卡

然后装 GPU Direct 的内核模块。某GPU厂商官方提供了一个叫nvidia_peer_mem的内核模块,装上之后,GPU 显存地址才能被 RDMA 网卡以 peer 内存的形式访问。模块没加载的时候,即使 RDMA 链路是通的,数据也只能先到主机内存,再拷进 GPU,效果大打折扣。

RoCE 网络本身要配成无损网络。简单说要开 PFC 和 ECN,给数据流量打上高优先级,同时要确保交换机的buffer策略和网卡一致。不同厂商交换机命令差别很大,我建议先把 MTU 拉齐,统一切 9000 jumbo frame,否则大包传输性能上不去,还会偶发丢包。

最后强烈建议用官方压测工具跑一遍裸带宽。比如ib_write_bw分别测 CPU 到 CPU、GPU 到 GPU 两条路径:

# 服务端:-d mlx5_0 -d 指 RDMA device ib_write_bw -d mlx5_0 -s 1048576 -n 100000 # 客户端连上来 ib_write_bw -d mlx5_0 -s 1048576 -n 100000 <server_ip>

如果 GPU Direct 配置正确,ib_write_bw应该能直接访问 GPU 显存地址。我们当时测出来的从 CPU 路径到 GPU 路径的带宽差距非常大,依赖这个结果定位到 peer_mem 模块没加载的问题。

3.3 双路径调度与KV Cache预取

网络通了,还要让推理框架真正用起来。我们在调度层面做了两处改造。

第一处是控制请求和数据搬运分离。推理服务监听管理端口,接收HTTP/gRPC请求,这是控制面;KV cache 的读写请求直接投递到 RDMA 的 QP(Queue Pair)上,这是数据面。调度器为每条请求建立两个队列,一个管计算,一个管搬运,互相不阻塞。

第二处是 KV cache 预取。decode 阶段每个 token 都要读当前时刻需要的 KV cache,如果等到需要时再去远端读,延迟完全暴露到请求路径上。我们的做法是按 block 预取:算完当前 block 的 attention 时,异步把接下来几个block 通过 RDMA 预取到本地显存。这样传输延迟被计算时间掩盖掉,流水线就转速起来了。

伪代码大概是这样的:

# 双路径KV Cache预取调度示意 current_block = compute_window[0] for step in range(max_steps): # 路径B:异步RDMA预取后续block,不阻塞计算 for bid in compute_window[1:]: if bid not in local_cache: rdma_async_read(remote_addr[bid], gpu_buffer[bid]) # 路径A:当前block本地计算 attention_out = attention_compute(local_cache[current_block]) # 滑窗 compute_window.remove(current_block) compute_window.append(next_free_block()) current_block = compute_window[0]

注意,预取的块大小要跟显存余量匹配。我们一开始贪心,预取窗口开太大,直接把显存打爆,训练好的模型参数反而没地方放。后来按单卡显存大小动态调节预取窗口,才稳定下来。

3.4 容量规划与带宽估算

很多人问,100G网卡到底够不够用?我提供一个估算方法,自己就能算。

回到前面的公式:一个 token 是 512KB KV cache(32层模型)。假设跨节点需要搬 64GB 数据,如果要在 10 秒内连续搬完,理论带宽要求是:

带宽 = 64GB / 10s = 6.4GB/s ≈ 51.2Gbps

理论上 100G 网卡是够的。但 decode 阶段不是一次性搬完,而是每个 token 都要读。假设生成速度是 20 tokens/s(即每 token 50ms),那么每秒钟要读到的 KV cache 数据量是:

每秒读取量 = 20 × 512KB = 10MB

这看起来不多,但这些数据要分散到多个远端GPU上,并且本地显存有限,不能全放,只能在需要前几毫秒预取进来。真正的挑战是延迟和抖动,而不是平均带宽。

所以在规划时建议盯两个数:一是ib_write_bw测出的峰值带宽,二是实测预取后 decode 尾延迟的 P99。如果峰带宽不到标称值的一半,先查 PCIe 和 NUMA;如果 P99 经常超预算,多半是预取窗口太小或网络 QoS 没调好。

4. 实测效果:数据不会说谎

我们在两台测试机上做了对比,每台四块GPU加一张双口100G网卡,128K上下文压测。环境不算新,但足够说明趋势。

方案吞吐(req/s)TTFT(s)TPOT(ms)CPU占用
基线:TCP直传+内存拷贝2.11.87875%
单路径RDMA:只替换数据通道3.41.56142%
双路径:控制/数据分离+预取4.61.24528%

上面数据来自我们的模拟压测环境,硬件配置不同会有差异,但方向很稳定:双路径基本把吞吐翻了一倍多,TTFT和TPOT都在明显下降,CPU占用从75%直接掉到28%。

4.1 收益到底从哪里来

第一块收益是从 CPU 手里抢回来的时间。TCP路径每次搬运都要经过内核协议栈、socket缓冲、memcpy,这些都是 CPU 密集操作。双路径下 KV cache 搬运完全交给网卡 DMA 引擎,CPU 只负责调度,不再当搬运工。CPU 占用降到 28%,这部分资源被我们转投到 prefill 和采样上,GPU 利用率直接抬升。

第二块收益是传输延迟被流水线隐藏。基线是“用到再拷”,等数据到了才能算;双路径是“算着下一块同时拷下一块”,传输时间被计算时间覆盖掉了。TPOT 降下来的主要原因不是网络更快了,而是等待被消除了。

第三块收益是抖动被隔离。控制面走 TCP,数据面走 RDMA 独立链路,哪怕压测过程中控制报文突发,也不会干扰 KV cache 预取;反过来,数据面拥塞也不会堵住管理响应。

4.2 一个反直觉的发现

测试中有个数据很有意思:双路径下 TPOT 的 P99 比基线低了非常多,但 P50 其实只降了20%左右。说明双路径不只是提带宽,更重要的是削峰填谷,把偶发的传输卡顿移除掉了。长上下文服务对尾延迟敏感,这个特性比平均收益更值钱。

如果你做的场景是短上下文、小批量,这套方案收益不会太明显,因为搬运量根本构不成瓶颈,反而白白引入了RDMA配置复杂度。但一旦上下文冲到64K以上,或者并发一上来,立刻就能拉开差距。

5. 现场踩过的坑与排查速查表

这里把我们在落地双路径时碰到的坑列成速查表。每一条都是真实调过的,希望能帮你少花几天时间。

症状根因解决方式
网卡TX/RX只有理论值的1/4PCIe插在x4槽位上,或链路退化为Gen3换插到x16槽位,检查lspci -vvv
RDMA握手成功但传输闪断MTU不一致,或PFC优先级映射没配对整条链路统一切MTU 9000,逐端口核对QoS
GPU到GPU带宽比CPU到CPU低很多GPU Direct peer_mem模块没加载加载模块,用ib_write_bw重测
启用RDMA后CPU反而飙高网卡中断没绑核,或忙轮询模式吃满CPU中断绑到专用核,或改用事件驱动
长上下文一压就OOM预取窗口开太大,显存被KV cache挤占动态调预取窗口,限制远端缓存块数量
跨交换机吞吐极低交换机buffer太小,PFC死锁调节流控缓存参数,缩小拥塞阈值

5.1 最容易被忽视的硬件细节

很多问题的根子是 NUMA 架构。网卡插在 CPU0 的 PCIe 域,但 GPU 挂在 CPU1 下面,跨 NUMA 访问比本地访问慢很多。用lstopo把设备拓扑画出来,把网卡和 GPU 安排在同一 NUMA 节点,能省下不少隐性延迟。

还有光模块和线缆的问题。别以为网卡是100G就用100G模块,对端交换机的端口速率也要匹配,特别要检查模块是 DAC 还是光模块,出现大量丢包时优先换线验证。我们有一次调了半天,结果就是一根光模块的收发功率不达标。

5.2 软件层容易“假成功”的地方

RDMA 连接建起来不代表路径就对了。我们遇到过一次握手成功、小包正常、但大包性能极差的场面,查了半天是 RoCE 的 GID index 不一致。两个节点选的 GID index 不同,一个是 RoCE v1,一个是 RoCE v2,连接能建立,但数据包进了不同 VLAN 域,直接导致吞吐混乱。

建议把rdma link show和/etc/rdma/rdma.conf里的配置固定下来,RoCE v2 + 同一个 GID index,双端保持一致。另外,如果机器上有防火墙或者 iptables 规则,一定确认没有拦掉 RoCE 的 UDP 端口。这个坑特别隐蔽,表面看连接正常,实际上关键的握手包被静默丢弃了。

6. 几句个人体会

调完这套双路径方案,我最大的感受是:长上下文的性能问题,本质上是流水线设计问题,不是在某个单点加带宽就能解决的。把计算路径和 I/O 路径拆开,让网卡干网卡该干的活,让 GPU 专心算矩阵乘法,收益比单纯堆硬件大得多。

另外也想提醒一句,这套方案不是银弹。上下文不够长、并发不高、单机就能塞下全部 KV cache 的时候,老老实实做显存优化就够了,没必要上一套 RDMA 双路径。判断标准很简单:KV cache 总大小除以你期望的端到端延迟,看这个值是不是接近或者超过网卡带宽。如果没超过,那就先用软件优化。

最后分享一个实操小技巧:任何网络优化方案,先用裸测工具把底数测清楚,再谈框架改造。我们用ib_write_bw验证了网卡和 GPU Direct 的底数,后续调整框架就很有方向感。不要一上来就改代码,否则出了问题你根本分不清是网络的问题、驱动的问题,还是推理框架侧的逻辑问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进