RSS

gRPC-Go 性能改进

在过去的几个月里,我们一直致力于提高 gRPC-Go 的性能。这包括改善网络利用率、优化 CPU 使用率和内存分配。我们近期的大部分努力都集中在改进 gRPC-Go 的流控制方面。经过几次优化和新功能,我们已经能够显著提高性能,特别是在高延迟网络上。我们预计使用高延迟网络和大消息的用户将看到数量级的性能提升。基准测试结果见末尾。

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

近期已实现的优化

接收大消息时扩展流窗口

代码链接

这是 gRPC-C 使用的一种优化,旨在为大消息实现性能优势。其思想是,当应用程序在接收端进行活动读取时,我们可以有效地绕过流级别流控制来请求整个消息。这对于大消息非常有用。由于应用程序已经致力于读取并为此分配了足够的内存,因此发送一个主动的大窗口更新(如果必要)以获取整个消息,而不是分块接收并等到窗口不足时才发送窗口更新,这是有意义的。

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

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

代码链接

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

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

  1. 当一个流耗尽时,客户端是否可以通过创建新流向服务器发送任意多的数据?

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

第一个问题的答案简单明了:不能。服务器可以选择限制其打算并发服务的流的数量。因此,尽管乍一看这可能是一个问题,但实际上并不是。

连接级流控制的必要性

确实,流级流控制足以限制发送方发送过多数据。但如果没有连接级流控制(或使用无限的连接级窗口),当一个流变慢时,打开一个新流似乎会使事情变快。但这只能做到某种程度,因为流的数量是有限的。然而,将连接级流控制窗口设置为网络的带宽延迟积(BDP)可以限制网络实际能够挤出的性能上限。

捎带窗口更新

代码链接

发送窗口更新本身会产生开销;刷新操作是必需的,这会导致系统调用。系统调用是阻塞且缓慢的。因此,在发送流级窗口更新时,同时检查是否可以使用相同的刷新系统调用发送连接级窗口更新是有意义的。

BDP 估算与动态流控制窗口

代码链接

此功能是最新的,在某些方面也是最受期待的优化功能,它帮助我们缩小了 gRPC 和 HTTP/1.1 在高延迟网络上性能之间的最终差距。

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

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

该算法本身可能导致 BDP 估算无限增加;窗口的增加将导致采样更多字节,进而导致窗口进一步增加。这种现象被称为缓冲区膨胀(buffer-bloat),并由 gRPC-C 核心和 gRPC-Java 的早期实现发现。解决方案是计算每个样本的带宽,并检查它是否大于迄今为止记录的最大带宽。如果是,则仅在此时增加我们的窗口大小。我们知道,带宽可以通过将样本除以 RTT * 1.5 来计算(请记住该样本是针对一次半往返时间)。如果带宽不随采样字节的增加而增加,则表明此更改是由于窗口大小增加所致,并不能真正反映网络本身的性质。

在不同大陆的虚拟机上运行实验时,我们意识到偶尔会出现一个异常快速的 ping-ack 在正确的时间(实际上是错误的时间)出现,导致我们的窗口大小增加。发生这种情况是因为这样的 ping-ack 会使我们注意到 RTT 降低并计算出高带宽值。如果此时采样的字节数超过我们窗口的 2/3,那么我们就会增加窗口大小。然而,这个 ping ack 是一个异常,不应该完全改变我们对网络 RTT 的认知。因此,我们保留了一个加权常数的 RTT 运行平均值,而不是总样本数,以便更多地关注近期的 RTT,而较少关注过去的 RTT。这很重要,因为网络可能会随时间变化。

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

鉴于我们总是受 TCP 流控制的约束(在大多数情况下上限为 4MB),我们将窗口大小的增长限制在相同的大小:4MB。

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

近期工作

我们现在正在研究通过更好地利用 CPU 来提高吞吐量,以下工作与此保持一致。

减少刷新系统调用

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

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

我们的用户 @petermattic 在 PR 中提出了另一个相关想法,即将一元 RPC 的服务器响应合并为一次刷新。我们目前也在研究这一点。

减少内存分配

从线路上读取的每个数据帧都会发生新的内存分配。对于 gRPC 层上的每个新消息,解压缩和解码也是如此。这些分配会导致过多的垃圾回收周期,这会带来高昂的开销。重用内存缓冲区可以减少这种 GC 压力,我们正在为此原型化各种方法。由于请求需要不同大小的缓冲区,一种方法是维护固定大小(2 的幂)的独立内存池。因此,现在从线路上读取 x 字节时,我们可以找到大于 x 的最接近的 2 的幂,并在可用时从缓存中重用缓冲区,或者在需要时分配一个新缓冲区。我们将使用 golang 的 sync.Pool,这样我们就不必担心垃圾回收。然而,在此之前,我们需要进行充分的测试。

结果

  • 真实网络上的基准测试

    • 服务器和客户端在位于不同大陆的两台虚拟机上启动。RTT 约为 152 毫秒。
    • 客户端发送了一个带有有效负载的 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 有效负载的 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 为 16 毫秒的网络
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 为 64 毫秒的网络
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