跳转到主要内容

把 GPU 服务的请求循环搬进内核,能省下 CPU 吗?

bpf-gpu-server 让 eBPF 和 iouring 接管 GPU 服务的 TCP 路径,稳态下没有任何用户态线程处理请求。和驱动同一个 GPU 执行器的高效 epoll 服务器相比,它的 CPU 消耗和延迟都与对照组持平。本文解释每个请求的开销到底花在哪里。

设想一台 GPU 服务器在处理大量小请求:一个小语言模型生成 64 个 token,或者一次很短的 cuBLAS、cuFFT 调用。每个请求的 GPU 计算都很短,主机侧的开销就显得突出了。请求要经过网络协议栈,唤醒服务线程,被拷进用户态内存,排队等 GPU,最后再经过一次发送回到客户端。

现在的 Linux 已经提供了足够的部件,可以把这个服务线程整个去掉。sockmap BPF 程序可以在内核里解析 TCP socket 上的请求;在 Linux 7.3 这样的新内核上,可睡眠的 BPF 程序可以自己提交和完成 io_uring 操作;常驻的 GPU kernel 可以在没有主机参与的情况下启动预先捕获的 CUDA graph。组合起来,用户态只需要把服务启动起来,之后读请求、调度、发送回复都不再经过任何用户态线程。

直觉上这应该能省 CPU、降低尾延迟,因为它省掉了系统调用、内存拷贝和线程唤醒。我们把这个设计实现为 bpf-gpu-server,并做了仔细的对比测量。在我们的测试机上,这两点都没有实现。本文介绍实验设置、结果和背后的原因,这个原因对这个项目之外的很多设计同样适用。四页的论文版本可以在这里下载。

系统做了什么

启动时,一个普通的用户态进程创建监听 socket,捕获调用方的 CUDA graph,映射一块 CPU 和 GPU 都能访问的共享内存页,挂载 BPF 程序,再启动常驻的 GPU 调度器。之后它就退到一边:进程仍然活着,负责持有这些内存和处理退出,但不再碰任何一个具体请求。

flowchart LR
    C1[客户端] --> RX["Linux TCP/IP 收包<br/>(内核原生代码)"]
    RX --> SK["sockmap BPF<br/>解析帧、准入"]
    SK --> Q["共享队列<br/>(主机映射内存页)"]
    Q --> G["常驻 GPU 调度器<br/>启动 CUDA graph"]
    G --> L["可睡眠 BPF 循环<br/>io_uring,60 µs 定时器"]
    L --> TX["Linux TCP/IP 发送<br/>(内核原生代码)"]
    TX --> C2[客户端]

请求先经过正常的 Linux TCP 协议栈到达。sockmap BPF 程序把它切成帧、做校验,然后发布到共享页里的队列。GPU 调度器取到请求,启动对应的计算图,再把输出发布回共享页。挂在 io_uring 上的 BPF 程序发现计算完成后提交发送操作,保证回复顺序,并回收已经结束的连接。有请求在处理时,这个循环靠下一个完成事件或者一个 60 µs 的定时器醒来。

同一套传输层上跑了两类计算:原样的 cuBLAS、cuFFT 计算图,以及 LLM 生成。后者复用上游 vLLM 或 Hugging Face 为 Qwen2.5-0.5B 和 SmolLM2-135M 捕获的计算图。在 Linux 7.3 上,整套系统不需要自定义内核模块。

怎样做公平的对比

我们真正关心的问题很窄。随手写的 Python 服务器几乎会输给任何方案,赢它说明不了什么。所以对照组是一个 C++ 服务器:它用带超时的 epoll 和非阻塞读写处理网络,驱动的是同一个 GPU 调度器、同一个队列和同一批计算图,两者只有网络循环放在哪里不同。下文把它叫作原生路径。

LLM 场景里我们还跑了官方 vLLM,使用它默认的优化配置。vLLM 有自己的 API、调度器和显存管理,所以它反映的是整体设计和生产级服务器的差距;原生路径则把传输层的影响单独隔离出来。

CPU 的统计需要格外小心,因为把代码搬进内核,会改变这部分开销记在谁的头上。我们把记在服务进程线程上的 CPU(用户态加系统态,包括 io_uring 的工作线程)和整机总量分开报告,两者从不相加。

服务器是一台 Intel Core Ultra 9 285K(24 个逻辑 CPU),配一张 RTX 5090,运行 Linux 7.3-rc3,负载由另一台机器发出。每个条件都跑五组配对的进程,所有输出都经过逐 token 精确重放校验。

结果:网络循环放在哪里,没有带来差别

LLM 场景是 Qwen2.5-0.5B,每秒 192 个请求,持续 60 秒。两条路径的吞吐都是每秒 11,645 个 token。完成延迟 P99 在内核路径上是 3,304 ms,原生路径是 3,314 ms,差值的置信区间跨过了零。记在服务进程上的 CPU,内核路径每个请求 0.835 ms,原生路径 0.812 ms。

小请求实验才是主机开销最应该显形的地方。我们用 cuBLAS GEMM 和 cuFFT 处理 280 字节的请求、256 字节的回复,走 128 条长连接,扫描了计算量、请求速率(一直加到饱和)以及服务能用的 CPU 数量,再用新的随机种子复测按固定规则选出的三个点,总共 312 万个请求,没有一个失败。实验前我们就写下了什么才算优势:CPU 至少降低 10% 且尾延迟变差不超过 5%,或者尾延迟至少改善 10% 且 CPU 增加不超过 5%,两种情况下吞吐损失都要小于 2%。三个确认点都没有达到。整个扫描里最大的 CPU 降幅约为 5.8%;把服务限制在一个逻辑 CPU 上时,内核路径的 P99 反而差了 2.6 倍。

拆开 CPU 就能看出原因:

每个请求(FFT,每秒 1,000 个请求)用户态时间系统态时间合计
原生 epoll11.3 µs26.2 µs37.56 µs
内核接管2.8 µs35.2 µs37.96 µs

去掉用户态循环之后,用户态时间确实少了四分之三;同样的工作随后以系统态时间的形式出现,合计基本持平,FFT 只差 1.1%,GEMM 只差 1.9%。整机 CPU 的结论也一样。

开销花在哪里

表里的现象可以用三个原因解释。

每个请求最贵的部分是 TCP/IP 协议栈本身:收包时的中断和 softirq、socket 缓冲区、TCP 状态机、ACK 和发送。sockmap 运行在 TCP 完成重组之后,所以两种设计都要完整付出协议栈的成本。内核路径省掉的只是系统调用边界和一次拷贝到用户态的开销,这在这台机器上每个请求约 38 µs 的成本里只占一小部分。

服务循环也只是换了地方。总得有人发现 GPU 算完了,再提交回复。GPU 很难低成本地直接触发内核工作,所以 BPF 循环跑在服务进程的一个线程上:靠完成事件或定时器醒来,通过 BPF map 读取队列状态,再提交 io_uring 操作。这个线程的时间都记成了系统态。epoll 服务器还有批处理的优势:一次 epoll_wait 能拿到许多就绪的 socket,而 BPF 路径每个请求都要付出程序调用、map 操作和 io_uring 记账的开销。

LLM 场景下的 profile 把这种差别看得很清楚。在修掉一个忙轮询问题之前,内核路径的服务线程占用 CPU 的次数大约是原生线程的四倍;它 71% 的内核采样落在循环协调和 BPF map 操作上,这些工作在 epoll 设计里根本不存在。原生线程 85% 的内核采样则落在网络和 softirq 上,这是两种设计都必须做的事。修掉忙轮询之后,LLM 场景下内核路径每个请求的 CPU 从 1.70 ms 降到 0.63 ms,但在小请求实验里,剩下的协调开销仍然抵消了用户态省下的时间。

CPU 紧张时,延迟还会变差。只有一个逻辑 CPU 时,BPF 循环要和网络 softirq 抢同一个核,定时器驱动的唤醒又会带来 epoll 按就绪事件唤醒时没有的等待,这和 P99 差了 2.6 倍的结果吻合。

真正改变数字的是什么

带来大幅变化的改动都同时作用于两条路径,其中几条对任何给 GPU 写 TCP 前端的人都有参考价值。

LLM 尾延迟最大的改善来自容量。16 个 GPU 槽位时,每秒 192 个请求已经超过执行器的交付能力,队列在整个实验期间一直在增长:首 token 延迟从前 10 秒的约 235 ms 涨到最后 10 秒的约 3,003 ms,而生成本身一直在 86 ms 左右。把容量提到 32 个槽位后(这组实验只在原生路径上测量),完成延迟 P99 从 3,337 ms 降到 217 ms,在吞吐相同的情况下低于官方 vLLM 的 406 ms。代价是每个 token 的时间略有增加,占用更多显存,每个请求的整卡能耗也比 vLLM 多约 0.34 J。

流式小回复还撞上了 Nagle 算法和延迟 ACK。在抓到的一个例子里,一次 4 字节的写入之后,后面 36 字节在发送队列里等了约 41 ms 才等来 ACK。在监听 socket 开启 TCP_NODELAY 之前,每条流都要承受这 40 ms 的停顿;开启之后,token 间隔 P99 从 40.5 ms 降到 2.3 ms,吞吐从每秒 8,842 个 token 升到 11,260 个。

接着出问题的是连接回收。持续负载下,内核路径要等一个与之无关的全局队列清空,才会释放已经结束的连接,结果近 3,000 个 socket 停在 CLOSE_WAIT,socket map 被占满,新请求随之失败。改成每条连接在自己的工作完成后就回收,4,157 次超时全部消失。

把新请求的 prefill 和进行中的 decode 放进同一个 GPU 批次(与 Sarathi-Serve 的思路相同),让两条路径每个 token 的平均时间都下降了约 14%。

常驻 GPU 执行器每个请求消耗的进程 CPU 也只有 vLLM 服务器的十分之一到十六分之一。这个差距主要来自把逐步调度挪到了 GPU 上,以及返回 token ID 而不是 JSON 文本。两条传输路径都享有这个好处,所以它和内核网络无关。

什么情况下把工作搬进内核才划算

获得大幅加速的 eBPF 项目,都是在昂贵的层次之前截住工作:BMC 和 Electrode 在网络协议栈之前的 XDP 层处理,XRP 在 NVMe 驱动里直接重新提交存储请求。让 CPU 退出数据路径的 GPU 网络系统,则是完全绕开主机协议栈:GPUnet 用的是 RDMA,NVIDIA DOCA GPUNetIO 让 GPU kernel 直接操作网卡队列。bpf-gpu-server 为了兼容性有意保留了普通 Linux TCP,这个选择意味着每个请求都要付出协议栈的成本,与由哪个线程或程序驱动循环无关。

如果想拿到这个设计最初期望的 CPU 节省,下一步应该改变包被处理的位置,比如用 AF_XDP 把包直接收进 GPU 可访问的内存,或者走网卡直连 GPU 的路径,同时接受随之而来的协议和硬件限制。

局限

这些结论都来自同一台主机:24 个逻辑 CPU、一张 RTX 5090、一个远端客户端、小模型、固定形状的请求、二进制协议,没有 TLS。记在服务进程上的 CPU 不包括记到其他上下文里的 softirq 工作,整机计数器里也混有无关进程和我们自己的测量开销。如果主机的 CPU 本来就很紧张、网卡针对这类流量专门调过参数,或者换成绕开主机协议栈的设计,结论可能会不同。

几点经验

把代码跨过用户态和内核的边界之前,先测清楚成本的下限:如果每个请求的 CPU 大部分花在 TCP 协议栈里,挪动循环的位置省不掉它。对照组要和实验组共享除被测变量之外的一切。CPU 要在每个统计口径下都算一遍,因为用户态省下的时间可能原样记到系统态上。做小模型服务时,先检查容量和 TCP 小包发送的行为,在这个项目里它们把尾延迟改变了一个数量级,传输层的位置则没有带来可测的差别。

bpf-gpu-server 作为开源研究原型继续保留,可以用来在标准 Linux 上围绕可复用的 CUDA graph 做内核接管 TCP 的实验。代码仓库里有源码、原始数据和每一条实验结论,包括失败的尝试。四页论文在这里。