RustROS 转发分析
RustROS 和 Linux(内核转发、nftables、XDP)在同一台测试机、同一种虚拟机配置、同一个拓扑下的转发性能对比,以及 RustROS 每个包的 CPU 开销分解。
嵌套虚拟化。测试机本身是一台 KVM 虚拟机(Xeon Gold 6138,2.0 GHz),被测虚拟机是嵌套运行的。每次 VM exit 要经过两层 hypervisor,代价约 15 µs。RustROS 和 Linux 条件相同,对比是公平的;但和通知设备有关的开销在生产环境里会小好几倍,不能直接照搬。
结论
- RustROS 在每项测试中都不低于 Linux 内核转发;TCP 远高于 XDP(XDP 会关掉接收大包合并)。
- 回归套件里的 udp64 测的其实是发包工具 iperf3 的上限,要改用 pktgen。
- 4 条流比 1 条流慢,原因是 RustROS 自己每包的开销,不是宿主机的 vhost 线程(见第 6 节的多队列诊断)。
1. 发包工具本身的上限
不经过路由器,fa 到 fb 直连 veth:
| 测试 | 三次结果 | 结论 |
|---|---|---|
| iperf3 64 字节 UDP | 211–218 kpps | 经过 RustROS 也是约 230 kpps,测的是 iperf3,不是路由器 |
| iperf3 TCP 1 流 | 26.5–26.8 Gbit/s | 远高于经过路由器的结果,TCP 测试有效 |
| iperf3 TCP 4 流 | 86.1–88.0 Gbit/s | 同上 |
小包因此改用内核的 pktgen:从 fa 命名空间往同一个 tap 发包,在 fb 的 tap 上数收到多少。
2. 64 字节小包
柱高为三次的中位数,细线是三次的范围。nftables 三次之间波动很大,只能看量级。
| 路由器 | 1 条流(kpps) | 16 条流(kpps) | 说明 |
|---|---|---|---|
| RustROS | 403.5400.3–414.7 | 388.1372.4–414.0 | CPU 跑满;出口侧丢包 |
| Linux 内核转发 | 319.6312.8–332.9 | 302.0278.9–322.6 | |
| Linux nftables | 104.980.3–152.4 | 52.842.4–109.6 | 有状态的连接跟踪 |
| Linux XDP | 412.9368.7–414.9 | 406.7403.3–411.7 | 收进来 508–542 kpps,发出去同样约 410 kpps |
XDP 收得比 RustROS 快,但发出去的上限一样,约 410 kpps,两者都在出口侧丢包。在这个拓扑里,这是出口网卡发送路径的共同上限,不是路由器本身的处理能力。
3. TCP 和流数
点为三次的中位数。
| 路由器 | 1 流 | 2 流 | 4 流 | 8 流 | iperf udp64 |
|---|---|---|---|---|---|
| RustROS | 17.8117.80–18.15 | 16.8116.65–17.02 | 15.1315.07–15.63 | 15.2915.20–15.38 | 230.05 kpps228.96–232.13 |
| Linux 内核转发 | 16.9916.83–17.21 | 15.6915.65–15.79 | 13.5213.26–13.61 | 13.8113.72–13.89 | 47.20 kpps47.00–48.66 |
| Linux nftables | 16.4016.00–16.49 | — | 13.3513.26–13.41 | — | 42.40 kpps42.25–43.42 |
| Linux XDP | 5.285.16–5.39 | 4.884.85–4.91 | 4.834.72–4.90 | 4.604.57–4.71 | 238.79 kpps237.19–240.03 |
TCP 单位为 Gbit/s。XDP 跑 TCP 慢,是因为 virtio-net 挂上 XDP 后会关掉接收大包合并,每个 1500 字节的包都单独处理。
4. RustROS 的时间花在哪里
测量版内核(cargo 功能 stages)在包路径每个环节前后计 CPU 周期。下面是每个包的周期数,三次的中位数,按 2.0 GHz 换算。
64 字节小包(pktgen,约 413 kpps,CPU 跑满,每批平均 31–32 个包)
合计约 4,500 周期(2.3 µs)。通知约每秒 1.7 万次,每 24 个包一次;每次至少约 3 万周期(嵌套环境)。
| 环节 | 64 字节 | TCP 1 流 | TCP 4 流 | iperf udp64 |
|---|---|---|---|---|
| 接收(驱动) | 582 | 19,226 | 21,932 | 596 |
| 包处理模块 | 352 | 489 | 516 | 435 |
| egress 钩子 | 363 | 378 | 371 | 398 |
| 交给协议栈 | 22 | 29 | 31 | 39 |
| 发送(驱动,含等待) | 1,932 | 1,040 | 1,228 | 905 |
| 其中等待发送槽 | 873 | 0 | 0 | 0 |
| 通知设备 | 1,230 | 2,955 | 2,786 | 2,151 |
| 每批平均包数 | 31–32 | 24–25 | 21–23 | 13 |
TCP 时"每包"包括数据段和 ACK(数据段平均约 52 KB),所以接收环节的平均值被 ACK 拉低;数据段本身的接收开销更高,是 TCP 的主要瓶颈。iperf 的 UDP 到达不均匀,每批只有 13 个包,按批计的通知开销摊到每包上翻倍。
5. 逐项检查
| 检查项 | 结果 |
|---|---|
| 每包内存分配和拷贝 | 小包没有,缓冲区零拷贝交换。大包有,而且是 TCP 的主要开销:宿主机把约 52 KB 的 TCP 段拆在多个 2 KB 接收缓冲区里,RustROS 每段新分配一块缓冲区把碎片拷进去,发送完又为大缓冲区分配一块小的补回。 |
| 批处理 | 有(每批最多 32 个包),但每批通知一次设备;到达不均匀时每包分摊的固定开销翻倍。egress 钩子每批调用所有包处理模块,包括没实现它的 router。 |
| 软件校验和 | 开销很小:router 校验 20 字节 IP 头、增量更新 TTL;TCP 的部分校验和直接透传。 |
| 锁 | 没有:单核,包路径上没有锁。 |
| 虚拟网卡通知 | 太频繁。每批两块网卡的接收补充和发送各通知一次,每发 32 个包和每次等发送槽各再通知一次;没有启用 event_idx。发送环满时在转发路径里忙等最长 50 µs:三次测量合计约 30 秒的满负荷流量里,累计等待约 22.6 万次、丢包约 5.5 万个,而出口 vhost 线程只有 37–39% 忙。 |
6. 为什么 4 条流比 1 条流慢
- 不是发包工具:不经过路由器时 4 流是 1 流的 3.3 倍。
- 不是大包变小:1 流和 4 流的数据段平均都约 52 KB。
- 三种路由器都变慢:RustROS 降 15%,Linux 内核转发降 20%,没有大包合并的 XDP 降 9%。
- RustROS 内部:4 流时接收每包多约 14%(19,226 → 21,932 周期);1 流时接收环从没满过,2 流及以上每次测量出现几次。
多队列诊断
同样的虚拟机,每块网卡改成 4 对队列(仍然 1 个 vCPU)。每个 vhost 工作线程降到 17–60% 忙,已经不是瓶颈,但吞吐量反而下降:
| 测试 | 1 对队列 | 4 对队列 | 通知开销(周期/包,1 对 → 4 对) |
|---|---|---|---|
| TCP 1 流(Gbit/s) | 17.8117.80–18.15 | 13.6213.57–15.10 | 2,955 → 3,236–3,494 |
| TCP 2 流 | 16.8116.65–17.02 | 14.5514.40–16.63 | — |
| TCP 4 流 | 15.1315.07–15.63 | 12.4912.21–13.22 | 2,786 → 14,427–16,909 |
| TCP 8 流 | 15.2915.20–15.38 | 10.7610.45–11.82 | — |
| 64 字节 16 流(kpps) | 388.1372.4–414.0 | 196.6196.3–197.0 | 1,304 → 8,105–8,962 |
限制来自 RustROS 的通知次数,不是宿主机的 vhost 线程。开了多队列以后,每批要分别通知多个队列:64 字节时通知从每秒约 1.7 万次增加到约 3 万次,每次在嵌套环境里约 3 万周期。
单队列下 4 流比 1 流慢,来自接收端重组拷贝的开销增加(每包多约 14%)和每批包数变少(21–23 个对 24–25 个)。第 8 节第 1 项(大包不重组)做完后,这个差距应该大部分消失,届时再测一次验证。
第二台生产机目前用 4 对队列。它不是嵌套虚拟化,每次通知便宜得多,影响会小一些,需要用新的通知计数器在那台机器上确认。
7. 生产机和测试机的网卡
| 阿里云实例 | 第二台生产机 | 测试机上的被测虚拟机 | |
|---|---|---|---|
| 网卡 | legacy virtio-net | modern virtio-net | modern virtio-net |
| 队列 | 1 对(宿主机不支持多队列) | 用 4 对(宿主机最多 20) | 1 对 |
| 环大小(收 / 发) | 4096 / 4096 | 512 / 256 | 256 / 256 |
event_idx | 不支持 | 支持 | 支持 |
启用 event_idx 在阿里云上无效,那里只能靠"每轮只通知一次"省开销;阿里云的发送环有 4096 个槽,发送环满时的忙等应该少很多。
8. 发现的问题,按收益排序
- 大包不重组、直接转发下一阶段把接收到的缓冲区链原样作为发送描述符链发出,不拷贝、不分配。对象是 TCP 时约 80% 的 CPU。
- 减少通知下一阶段启用
event_idx(宿主机支持时);每轮主循环只通知一次而不是每批一次;接收环还够用时不发补充通知。64 字节时约 27% 的 CPU,TCP 时约 12%。 - 发送环满时不忙等下一阶段改用软件积压队列,下一轮再发,不空转、不丢包。64 字节时约 19% 的 CPU,以及满负荷时每秒约 1,800 个丢包。
- egress 钩子跳过没实现它的模块没有整形器时约 8%。
- router 按批缓存路由和邻居查找现在每包两次跨模块调用加线性查找,约 8%。
- 小包接收路径减少每包的内存屏障和环状态读取,约 13% 中的一部分。
9. 默认启动镜像
| 部分 | 字节 |
|---|---|
| ELF 头加 LZ4 压缩的内核及解压桩 | 91,632 |
| 对齐空隙(含 64 字节节头表) | 528 |
| 启动模块 bundle 头 | 512 |
| 启动模块(LZ4 压缩后;解压后 281,288) | 169,277 |
| 结尾补到扇区边界 | 195 |
| 实际内容 | 261,421 |
| 内核槽大小 | 262,144 |
| 剩余余量 | 723 |
262,144 是补齐后的大小。余量基本用完,内核或启动模块以后增加代码,都要先在别处省出空间。