加入 Hanzo 测试,任意付费方案立减 50%

GB10 上每秒五十万次请求:瓶颈在网络,不在机器

我们想看看云数据平面在 NVIDIA GB10 上能跑多快。答案教给我们的,与其说是我们自己的代码,不如说是网卡接收队列——而且在同一根线上,吞吐量翻了一倍还多。

本页内容

我们的云建在一个 Go 二进制上——一份制品,挂上 AI 网关、商业、IAM 和 KMS,对外提供 api.hanzo.ai。它的 HTTP 表面是 zip,我们的 ZAP 原生 Web 框架,包在 fasthttp 之上。于是我们问了一个简单的问题:这东西实际上能跑多快,又是什么把它拦住?

诚实的答案让我们吃惊了三次。下面是整段侦探故事,数字都在。一切公开在 hanzoai/benchmarks。

第一:框架是那笔税吗

不是。zip 在它所包裹的原始 fiber/fasthttp 引擎之上,只多出恒定的约 24 纳秒和一次 48 字节分配——在任何真实处理函数上都不到 1%。端到端、在回环上,zip 做到原始 fasthttp 的 0.95×。框架是噪声。好。继续。

框架请求/秒
原始 fasthttp365–400k
zip (HTTP)338–348k

第二:分布式的结果反而更慢

有意思的地方从这里开始。两台机器用一条直连的 2.5 GbE 网线连着:spark(一台 NVIDIA GB10,20 核)当服务器,evo(32 核 x86)当负载发生器。把负载挪出这台机器,本应腾出 spark 的核去服务——我们以为数字会往上走。

它往下走了。194k 请求/秒。连接再加、再往上顶,情况更差:4,000 个连接时 167k,8,000 个时 154k,p99 延迟从 5 ms 爬到 52 ms。这是教科书上的拥塞崩溃。

可链路只有大约 ~70 MB/s,是 2.5 GbE 的 22%。不是带宽。spark 的 20 个核也大多闲着。不是 CPU。那是什么?

祸首:一条接收队列

ethtool -l 把它露了出来:网卡正以一条硬件 RX 队列运行。每个入站数据包都由一个核的 softirq 处理——那个核就是天花板。更糟的是,驱动干脆拒绝增加队列(ethtool -L → "Operation not supported")。

于是我们最快的开发机,被一条铲数据包的内核线程卡住,另外十九个核在旁边看着。

修法:在软件里引导数据包

硬件不肯多队列,内核肯——接收数据包引导在软件里把流哈希到各个核上。我们在全部 20 个核上打开 RPS(以及 RFS/XPS),把接收积压从 1,000 提到 300,000,套接字缓冲设为 256 MiB,并钉住性能调控器。一份幂等的 tune.sh,两台机器都跑。

同一根线。同一个处理函数。每秒 518,000 次请求——2.67× 的跳跃,峰值 696k。

并发未调优调优后
1,000194k518k
4,000154k474k(峰值 696k)

关键在这里:调优后的网络数字(518k)现在超过了回环(365–400k)——因为在线上,spark 的核全时服务,不必再和负载发生器分摊。518k 时机器只闲着约 ~12%。这已经接近单台加载器能顶到的真实天花板。

第三:ZAP 本该更快——于是我们让它更快了

zip 是 ZAP 原生的:一个光秃的监听地址说我们的二进制传输;http:// 说 HTTP。我们以为二进制传输会赢,做了一个 ZAP 负载客户端(HTTP 工具说不了它),然后测量。

ZAP 是 91k。HTTP 是 481k。原生传输输了——输了 5×。

我们追下去,问题不在线,在编解码器。延迟的尾巴把故事说完了(p50 健康,303 µs,但 p99 在 13 ms——垃圾回收的签名),把 GOGC 从 100 放到 800,ZAP 翻了一倍还多,到 203k。六秒的一轮里,ZAP 服务器触发了 2,203 次垃圾回收;HTTP 服务器 57 次。三个分配点扛着它:编组器每次调用新造一块帧缓冲,头部解码把每个字符串拷进一张映射,还有最大的一处——zaphttp 的服务器按请求分配新的请求上下文,而 fasthttp 自己的服务器是按连接池化的。

所以我们修了。改动(zap-proto/http,分支 perf/zero-alloc-codec)是机械的,不是架构的:请求上下文按连接池化一次,手写一个往池化缓冲里追加的帧编码器,并用 bufio.Peek/Discard 抽掉 4 字节长度前缀,让头部数组不逃到堆上。服务循环用 AppendResponse 串行化进那块复用缓冲,并就地解码头部——于是在一条热连接上,它每个请求什么都不分配——黄金测试确认线上逐字节相同,改前改后都一样。纯粹的编解码器重写,协议不变。

结果:ZAP 走到 91k → 570k 请求/秒,6.3× 的抬升,超过 HTTP 的 481k——在回环上 ZAP 现在以 1.18× 领先,p99 从 13 ms 塌到 1.19 ms,GC 从 2,203 降到 62。

有两件事我得自己纠正,而且修完之后它们仍然成立,所以值得说清楚。第一,赢的是 CPU,不是线。 ZAP 帧实际上比 HTTP 更大(一次 /health 请求是 124 对 75 字节);ZAP 赢,是因为每个字节花的周期更少,不是因为字节更少。第二,zaphttp 并不跳过 JSON。 它在两种传输上都把 HTTP 正文当作不透明字节来携带,所以处理函数两边付同样的编组成本——在聊天形状的 JSON 载荷上,ZAP 实际上是 HTTP 的 0.95×,把正文拷进帧尾带来一点损失。省略 JSON 是原生 ZAP 类型化 RPC 的性质,是 zaphttp 碰不到的另一根轴。这里的回环胜利是诚实的,纯粹是 CPU 和分配的胜利。那真正的 2.5 GbE 线上呢?两种传输在 ~530k 打平——链路饱和了,编解码器不再是瓶颈。

第四:那么 ZAP 真的跳过 JSON 吗

最后那一句——「省略 JSON 是原生 ZAP 的性质,zaphttp 并不行使它」——是一张欠条,读者应当让我兑现。zaphttp 把正文当不透明数据携带,所以它从不检验二进制协议应当赢的那一点:不解析文本就读出结构化数据。于是我做了做这件事的测试。

一条共享记录——形状像 eth_getBalance 的调用,每个方向四个字段(account、block、id → balance、nonce、blockHash)——以四种方式往返:标准库的 encoding/json、goccy/go-json 和 sonic(Go 里最快的两个 JSON 库),以及 zap-proto/go 的类型化二进制。进去的字段相同,计算相同,出来的字段相同;往返等价测试强制逐字节的再编码,谁也不能省掉对方在做的工作。而且我把 JSON 按在它最快的实现上,不是标准库,免得有人把结果挥手打发掉。

解码是一场屠杀。 读这个四字段请求:

编解码器解码分配
JSON 标准库2334 ns13
JSON sonic845 ns8
JSON goccy473 ns4
类型化 ZAP13 ns0

十三纳秒,零分配——比最快的 JSON 快 35×,比标准库快 174×。 没有解析:字段坐在已知偏移上,你去读它。这就是类型化二进制线路的全部主张,而且它站得住。

但我答应过,不给 ZAP 长脸的部分也要报,所以:

  • 编码是平局,而且它分配得更多。 ZAP 用 629 ns、12 次分配完成序列化,goccy 是 426 ns / 4。已发布的 zap-proto/go v1.3.0 构建器把变长尾部通过一次拷贝推迟,并让对象构建器逃到堆上;修好它的池化定宽 API 不在已发布的标签里,所以这就是诚实的数字。轧差之后,完整的服务器处理函数(解码 + 计算 + 编码)确实偏向 ZAP——274 ns 对 goccy 的 431——因为解码的胜利淹没了编码的平局。但扛着的是解码。
  • 端到端,174× 缩到大约 ~1.2×。 在真实的 /rpc 端点上:ZAP 535k 请求/秒,sonic 483k(1.11×),goccy 452k(1.18×),标准库 407k(1.31×)。是真的,但离微基准的差距很远——因为在每秒五十万次请求时,套接字和调度器才是成本,序列化只是薄薄一片。编解码器早在你察觉它变快之前,就不再是瓶颈了。

有一个意外倒向 ZAP 这一边:在真实的结构化记录上,它的线路更小,不是更大——162 对 233 字节——因为 JSON 把字节花在 0x 十六进制字符串、十进制整数、引号和字段名上。成帧测试里「ZAP 更大」的保留意见,只对微不足道的 /health 尺寸载荷成立;给它真正的类型化字段,二进制在尺寸上也赢。

所以,老实说:ZAP 跳过 JSON 吗?跳过——而且在读路径上,差距并不接近。 你感不感觉得到,取决于序列化是不是你的瓶颈。每秒 500k 的小记录,它不是。换成肥胖的嵌套载荷,或解码很重的扇出,那个 174× 就会出现在你过日子的地方。

我们学到的

  • 测量整条路径,并质疑工具。 hey 把我们卡在大约 ~100k——那是客户端,不是服务器。bombardier 露出了多 4× 的东西。
  • 机器很少是第一处瓶颈。 一条网卡 RX 队列,在我们碰应用代码之前,就拿走了 2.67×。
  • 测量分配,不只是吞吐量。 ZAP「慢」离明显是编解码器问题只差一个 GOGC 标志——一旦我们池化上下文并把分配清零,原生传输就超过了 HTTP。
  • 微基准和负载测试回答的是不同的问题。 类型化解码单独比 JSON 快 174×,端到端快 1.2×——两个数字都是真的,只引其中一个就是以省略撒谎。
  • 公平地、按实况报告。 同一个处理函数,热连接,不挑 GOGC,JSON 按在最快的库上而不是最慢的,把脚本公开。诚实的数字是一次失利时——编码的平局、被压缩的端到端差距——我们也把失利交了出去。

零分配的 ZAP 编解码器做完了,而且它赢。剩下的停点是物理的:双网卡加载器(通往七位数的并行接收路径)、更快的互连(10 GbE / RDMA)、一次 writev 拆分,让 ZAP 的帧尾在大载荷上省掉一次正文拷贝,以及 Go 1.26 原生交给我们的后量子传输——PQ-TLS 1.3、ZAP-over-PQ-TLS 和 PQ-QUIC——我们将在那里把 ML-KEM 握手的成本从稳态吞吐量里分开。

需要走多远,我们就能走多远。

评论

使用 Hanzo ID 登录后即可评论。

正在加载评论…

继续阅读