第三部分 · 3 / 9

CGO 开销与原生 RDMA 的差距

「Go 经 cgo 调用 libibverbs,所以一定比 C 慢」——这是个流传很广、但被实测推翻的直觉。 本节用同机同口径的实测数据说明:cgo 确实有每次调用的固定开销,但它既不大、也不是 gordma 与原生 perftest 差距的主因;Go 写的工具在状态好时能打满线速、追平 C

先破一个误解 下面的数字来自一台 共享 GPU 主机(400G RoCE v2,mlx5_1)。这类机器 CPU 调频、c-state、 邻居租户都会让结果大幅抖动——同一个工具同一条命令,吞吐能在 ±25% 间跳。所以 单次数字不能比,必须同一时间窗交替跑、多次取中位数。本页结论正是这么测出来的。

实测:Go 能追平 C

64KB 消息、-n 1000000,三个 Send/Recv 带宽工具换算到同一单位后的最好成绩

工具语言/路径峰值吞吐峰值消息率
ib_send_bwC(原生 verbs)~392 Gb/s0.747 Mpps
go_send_bwGo + cgo(perftest 包)~392 Gb/s0.747 Mpps
go_rdmanet_bw --rawGo + cgo(RawConn~232 Gb/s0.443 Mpps

go_send_bw 在状态好时跑出 0.747 Mpps,和 C 的 ib_send_bw 完全一致。 这直接说明:Go + cgo 不存在「追不上 C」的固有差距。早先看到的「go_send_bw 只有 ~314」是机器状态差时的数,不是 cgo 的锅。

单位陷阱 三个工具原始输出单位各不相同,差出 8 倍:ib_send_bw 是 MiB/s(2²⁰)、 go_send_bw 是 MB/s(10⁶)、旧版 --raw 曾用 Gb/s(10⁹ bit)。直接比原始数会得出完全错误的结论 (现在 --raw 已统一成 MiB/s,详见 go_rdmanet_bw 详解)。

cgo 开销有多大:实测拆解

GORDMA_PROBE=1 把发送循环的时间拆成提交 WR(post)忙等完成(poll)两段。 同机交替跑,两个 Go 工具的 post 成本几乎一模一样

工具post 占比post 每 WRpoll 占比
go_send_bw~15%~300 ns~58%
RawConn~13%~310 ns~59%

一次 C.ibv_post_send(...) 含 cgo 跨界约 300ns(栈切换 + 调度器协调 + 调用本身)。 但它只占总时间 ~15%,而且两个工具完全相同。换句话说:

  • cgo 提交开销是真实存在的(每 WR ~300ns),但占比小;
  • 不是 RawConn 慢于 go_send_bw 的原因——两者的提交成本一样。

那差距到底在哪:poll(完成到达)

把 poll 段换成每 WR 耗时,真相就出来了:

go_send_bw poll : 0.75 ~ 1.33 µs/WR   # 剧烈波动
RawConn   poll : ~1.40 µs/WR(三次纹丝不动)

poll 是忙轮询等网卡把完成写回 CQ,poll 时间短=网卡实际吞吐快。关键观察:

  • go_send_bw 的 poll 在 0.75~1.33µs 间大幅跳:状态好时 0.75µs→打满线速 0.747 Mpps,状态差时 1.33µs→掉到 0.449 Mpps。
  • RawConn 的 poll 死死钉在 ~1.40µs:三次几乎不动,消息率稳定封顶在 ~0.42 Mpps。

所以「RawConn 比 perftest 慢 1.7 倍」不是一个固定结论:同窗交替实测中,两者差距在 1.05× ~ 1.76× 之间晃动,某些轮次(go_send 状态差时)几乎持平。 差距大小取决于 go_send_bw 那次能不能让网卡跑满,而非一个稳定的库缺陷

结论:cgo 不是主角

  • Go vs C:没有固有差距。go_send_bw 实测能追平 ib_send_bw(同为 0.747 Mpps)。
  • 提交路径(post):cgo 每 WR ~300ns、占比 ~15%,两个 Go 工具相同——不解释任何差距
  • 曾经待查、现已查明:RawConn 的完成到达(poll)被稳定压在 ~1.40µs/WR,而 go_send_bw 偶尔能降到 0.75µs。 两者 QP/CQ 创建、txDepth、MTU、收发循环结构逐行核对一致——真凶不在代码里,而是 Go 的信号式异步抢占 周期性切碎忙轮询自旋,详见下一节 异步抢占与抖动
  • 机器抖动 vs 抢占:共享 GPU 主机的 CPU 调频确有噪声,但「忽高忽低」的主因其实是上面那个异步抢占—— 下一节用 GORDMA_PROBE 证伪了「锁核 / NUMA」假设(taskset 到任意核都一样),并给出一行环境变量就能稳定打满线速的修复。

cgo 仍值得优化的地方

虽然 cgo 不是这里的瓶颈,但「减少穿越次数」在小包高频场景仍然重要——那时每 WR 搬的字节少, ~300ns 的固定提交开销占比会飙升:

  • 批量提交(PostSendBatch / PipelineBatch:C 侧 WR 链表把 N 个请求一次 ibv_post_send 提交,把过路费从「每 WR」降到「每批」。小包消息率可提升约一个数量级。
  • 忙轮询(PollBusy:避免事件驱动每次唤醒一次系统调用,热路径直接读 CQ 内存。
  • 复用 scratch / 预注册内存PostSend 用每 QP 预分配的 C 侧 SGE 数组、零拷贝 SendBuffer 省 bounce 拷贝,压低每 WR 的 CPU 成本。

libffi 行不行

不行,libffi 只会更慢。它在运行时读类型描述符、动态拼调用栈帧(反射式调用),比 cgo 编译期 生成的静态 wrapper 慢;而且「栈切换 + 调度器协调」那道栅栏它一样省不掉。libffi/purego 的价值是 免 C 工具链、纯 Go 交叉编译,不是性能。而且如上所述——提交路径本就不是瓶颈,换它也没用。

一句话 Go + cgo 能追平 C;cgo 提交开销真实但小(~15%、每 WR ~300ns),不是 gordma 与原生差距的主因。 想看它在你的负载上占多少,跑 GORDMA_PROBE=1 看 post/poll 拆分;小包场景用批量 + 忙轮询把 穿越次数压下去即可。
gordma 教程