RSS

gRPC-Go 性能改进

在过去的几个月里,我们一直致力于改进 gRPC-Go 的性能。这包括提高网络利用率、优化 CPU 使用率和内存分配。我们近期的大部分工作都集中在改造 gRPC-Go 的流控机制上。经过多次优化和新增特性,我们取得了显著改进,特别是在高延迟网络上。我们预计使用高延迟网络和大型消息的用户将看到数量级的性能提升。基准测试结果附后。

这篇博客总结了我们迄今为止(按时间顺序)为提高性能所做的工作,并概述了我们近期的计划。

近期实现的优化

接收大消息时扩展流窗口

代码链接

这是 gRPC-C 使用的一项优化,旨在提高处理大型消息时的性能。其思路是,当应用在接收端进行活跃读取时,我们可以有效地绕过流级别流控来请求整个消息。这对于大型消息非常有用。由于应用已承诺读取并为其分配了足够的内存,因此有理由发送一个主动的大窗口更新(如有必要)来获取整个消息,而不是分块接收,并在窗口不足时才发送窗口更新。

仅此一项优化就在高延迟网络上为大型消息提供了 10 倍的性能提升。

将应用读取与连接流控解耦

代码链接

在与 gRPC-Java 和 gRPC-C 核心团队进行了多次讨论后,我们意识到 gRPC-Go 的连接级别流控过于严格,因为连接上的窗口更新取决于应用是否已从中读取数据。必须指出,流级别流控依赖于应用读取是完全合理的,但连接级别流控则不然。理由如下:一个连接由多个流(RPC)共享。如果至少有一个流读取缓慢或根本不读取,它会影响性能或完全阻塞该连接上的其他流。这是因为在我们发送窗口更新到连接之前,那个缓慢或不活跃的流必须先读取数据。因此,将连接的流控与应用读取解耦是合理的。

然而,这至少引出了两个问题

  1. 当一个流用尽时,客户端能否通过创建新流来随意向服务器发送数据?

  2. 如果流级别流控已经足够,为什么还需要连接级别流控?

第一个问题的答案简短且直接:不能。服务器可以选择限制并发服务的流数量。因此,尽管乍一看这可能是一个问题,但实际上并非如此。

连接级别流控的需求

确实,流级别流控足以限制发送方发送过多数据。但是,如果没有连接级别流控(或使用无限连接级别窗口),当一个流变慢时,开启新流会显得更快。然而,由于流的数量是有限的,这种做法只会有限地提高速度。然而,将连接级别流控窗口设置为网络的带宽延迟积(BDP),可以限制网络实际能够达到的性能上限。

附带窗口更新

代码链接

发送窗口更新本身是有代价的;需要执行 flush 操作,这会导致一个系统调用(syscall)。系统调用是阻塞且缓慢的。因此,在发送流级别窗口更新时,检查是否可以使用同一个 flush 系统调用发送连接级别窗口更新是有意义的。

BDP 估算与动态流控窗口

代码链接

这项特性是最新实现的,在某些方面也是最受期待的优化特性,它帮助我们弥合了 gRPC 和 HTTP/1.1 在高延迟网络上性能的最终差距。

带宽延迟积(BDP)是网络连接的带宽乘以其往返延迟(round-trip latency)。这有效地告诉我们,如果实现完全利用,在给定时刻有多少字节可以在“线路上”传输。

计算 BDP 并相应进行调整的算法最初由 @ejona 提出,后来由 gRPC-C 核心团队和 gRPC-Java 实现(请注意,Java 中尚未启用)。其思路简单而强大:每当接收方收到一个数据帧时,它就会发送一个 BDP ping(一个带有唯一数据,仅供 BDP 估算器使用的 ping)。此后,接收方开始计算它收到的字节数(包括触发 BDP ping 的那些字节),直到收到该 ping 的 ack。在约 1.5 个 RTT(往返时间)内收到的所有字节的总和是有效 BDP * 1.5 的近似值。如果这个值接近我们当前的窗口大小(例如,超过其三分之二),我们就必须增加窗口大小。我们将我们的窗口大小(流和连接)设置为我们采样到的 BDP(收到的所有字节总和)的两倍。

这个算法本身可能导致 BDP 估算无限增加;窗口增加会导致采样更多字节,进而导致窗口进一步增加。这种现象称为缓冲区膨胀(Bufferbloat),由 gRPC-C 核心和 gRPC-Java 的早期实现发现。解决方案是计算每个样本的带宽,并检查其是否大于迄今为止记录的最大带宽。如果是,才增加我们的窗口大小。正如我们所知,带宽可以通过将样本除以 RTT * 1.5 来计算(请记住样本时长为一又二分之一个往返时间)。如果采样字节增加而带宽没有增加,这表明这种变化是由于窗口大小增加引起的,并未真正反映网络本身的特性。

在不同大洲的虚拟机上进行实验时,我们意识到偶尔出现的异常快速的 ping-ack 在正确的时间(实际上是错误的时间)会导致我们的窗口大小增加。这是因为这样的 ping-ack 会使我们注意到 RTT 下降,并计算出较高的带宽值。现在,如果该字节样本大于我们窗口的三分之二,我们就会增加窗口大小。然而,这个 ping ack 是一个异常值,不应该完全改变我们对网络 RTT 的认知。因此,我们对记录的 RTT 进行加权移动平均,权重由一个常数决定,而不是总样本数,以便更重视近期 RTT,而较少重视过去的 RTT。这一点很重要,因为网络可能会随着时间变化。

在实现过程中,我们尝试了多个调整参数,例如用于根据样本大小计算窗口大小的乘数,以选择在增长和准确性之间取得平衡的最佳设置。

考虑到我们总是受 TCP 流控的限制,而 TCP 流控在大多数情况下上限为 4MB,我们也限制我们的窗口大小增长到同样的上限:4MB。

BDP 估算和动态调整窗口大小默认开启,可以通过手动设置连接和/或流窗口大小来关闭。

近期工作展望

我们现在正在通过提高 CPU 利用率来提升吞吐量,以下工作正与此相关。

减少 flush 系统调用

我们注意到传输层中存在一个 bug,它导致我们每写入一个数据帧就进行一次 flush 系统调用,即使同一个 goroutine 还有更多数据要发送。我们可以批量处理这些写入,只使用一次 flush。这实际上对代码本身来说并不是一个大的改动。

为了消除不必要的 flush,我们最近将一元 RPC 和服务器流式 RPC 的 headers 和数据写入在客户端合并为一次 flush。 代码链接

另一项相关建议由用户 @petermattic 在此 PR中提出,旨在将服务器对一元 RPC 的响应合并为一次 flush。我们目前也在研究这一点。

减少内存分配

从网络读取的每个数据帧都会发生一次新的内存分配。在 gRPC 层,每个新消息的解压缩和解码也同样如此。这些分配导致过多的垃圾回收(GC)周期,成本很高。复用内存缓冲区可以减轻这种 GC 压力,我们正在原型化实现方法。由于请求需要不同大小的缓冲区,一种方法是维护固定大小(二的幂)的独立内存池。因此,现在从网络读取 x 字节时,我们可以找到大于 x 的最接近的二的幂,并在可用时从缓存中复用一个缓冲区,或者在需要时分配一个新缓冲区。我们将使用 golang sync Pools,这样就不必担心垃圾回收问题。但是,在提交此更改之前,我们需要进行充分的测试。

结果

  • 真实网络上的基准测试

    • 服务器和客户端部署在不同大洲的两台虚拟机上。RTT 约为 152ms。
    • 客户端发起一个带有 payload 的 RPC,服务器返回一个空消息作为响应。
    • 测量每个 RPC 的耗时。
    • 代码链接
消息大小gRPCHTTP 1.1
1 KB~152 ms~152 ms
10 KB~152 ms~152 ms
10 KB~152 ms~152 ms
1 MB~152 ms~152 ms
10 MB~622 ms~630 ms
100 MB~5 sec~5 sec
  • 模拟网络上的基准测试
    • 服务器和客户端部署在同一台机器上,模拟了不同的网络延迟。
    • 客户端发起一个带有 1MB payload 的 RPC,服务器返回一个空消息作为响应。
    • 测量每个 RPC 的耗时。
    • 下表显示了前 10 个 RPC 的耗时。
    • 代码链接
无延迟网络
gRPCHTTP 2.0HTTP 1.1
5.097809ms16.107461ms18.298959ms
4.46083ms4.301808ms7.715456ms
5.081421ms4.076645ms8.118601ms
4.338013ms4.232606ms6.621028ms
5.013544ms4.693488ms5.83375ms
3.963463ms4.558047ms5.571579ms
3.509808ms4.855556ms4.966938ms
4.864618ms4.324159ms6.576279ms
3.545933ms4.61375ms6.105608ms
3.481094ms4.621215ms7.001607ms
RTT 为 16ms 的网络
gRPCHTTP 2.0HTTP 1.1
118.837625ms84.453913ms58.858109ms
36.801006ms22.476308ms20.877585ms
35.008349ms21.206222ms19.793881ms
21.153461ms20.940937ms22.18179ms
20.640364ms21.888247ms21.4666ms
21.410346ms21.186008ms20.925514ms
19.755766ms21.818027ms20.553768ms
20.388882ms21.366796ms21.460029ms
20.623342ms20.681414ms20.586908ms
20.452023ms20.781208ms20.278481ms
RTT 为 64ms 的网络
GRPCHTTP 2.0HTTP 1.1
455.072669ms275.290241ms208.826314ms
195.43357ms70.386788ms70.042513ms
132.215978ms70.01131ms71.19429ms
69.239273ms70.032237ms69.479335ms
68.669903ms70.192272ms70.858937ms
70.458108ms69.395154ms71.161921ms
68.488057ms69.252731ms71.374758ms
68.816031ms69.628744ms70.141381ms
69.170105ms68.935813ms70.685521ms
68.831608ms69.728349ms69.45605ms