gRPC-Go 性能改进
在过去的几个月里,我们一直在努力提高 gRPC-Go 的性能。这包括提高网络利用率、优化 CPU 使用率和内存分配。我们最近的大部分工作都集中在改进 gRPC-Go 的流量控制上。经过多次优化和新功能,我们已经能够显著提高性能,尤其是在高延迟网络上。我们预计使用高延迟网络和大型消息的用户将看到数量级的性能提升。基准测试结果在文末。
这篇博客总结了我们迄今为止为提高性能所做的工作(按时间顺序),并列出了我们近期的计划。
最近实现的优化
接收大型消息时扩展流窗口
这是 gRPC-C 使用的一种优化方法,可为大型消息实现性能优势。其思想是,当接收端应用程序正在进行读取时,我们可以有效地绕过流级流量控制来请求整个消息。这对于大型消息非常有用。由于应用程序已经承诺读取并为此分配了足够的内存,因此我们应该发送主动的大窗口更新(如果需要)以获取整个消息,而不是分块接收并在窗口不足时发送窗口更新。
仅此优化就为高延迟网络上的大型消息提供了 10 倍的改进。
将应用程序读取与连接流量控制分离
在与 gRPC-Java 和 gRPC-C 核心团队进行了多次讨论后,我们意识到 gRPC-Go 的连接级流量控制过于严格,因为它对连接的窗口更新取决于应用程序是否已从中读取数据。必须注意的是,让流级流量控制依赖于应用程序读取是完全合理的,但对于连接级流量控制则不然。其基本原理如下:一个连接由多个流(RPC)共享。如果至少有一个流读取缓慢或根本不读取,它会妨碍该连接上其他流的性能或完全停止。发生这种情况是因为在慢速或非活动流读取数据之前,我们不会在连接上发送窗口更新。因此,将连接的流量控制与应用程序读取分离是有意义的。
然而,这至少引出了两个问题
当一个客户端用完时,它是否能够通过创建新流向服务器发送尽可能多的数据?
如果流级流量控制足够,为什么还需要连接级流量控制?
第一个问题的答案很简单:不会。服务器可以选择限制它打算同时服务的流的数量。因此,尽管乍一看似乎是一个问题,但实际上并不是。
对连接级流量控制的需求
流级别的流量控制确实足以限制发送方发送过多数据。但是,如果没有连接级别的流量控制(或使用无限的连接级别窗口),当某个流上的速度变慢时,打开一个新的流会显得更快。但这只能解决一时的问题,因为流的数量是有限的。然而,将连接级别的流量控制窗口设置为网络的带宽延迟积 (BDP) 可以限制网络能够实际发挥出的最大性能。
捎带窗口更新
发送窗口更新本身也有成本;需要进行刷新操作,这会导致系统调用。系统调用是阻塞且缓慢的。因此,在发送流级别窗口更新时,检查是否可以使用相同的刷新系统调用发送连接级别窗口更新是有意义的。
BDP 估计和动态流量控制窗口
此功能是最新的,并且在某种程度上是最受期待的优化功能,它帮助我们弥合了 gRPC 和 HTTP/1.1 在高延迟网络上的性能差距。
带宽延迟积 (BDP) 是网络连接的带宽乘以其往返延迟。这有效地告诉我们,如果实现充分利用,在给定时刻“在网络上传输”的字节数。
计算 BDP 并进行相应调整的算法 最初由 @ejona 提出,后来由 gRPC-C 核心和 gRPC-Java 实现(请注意,它尚未在 Java 中启用)。这个想法很简单且强大:每次接收方收到数据帧时,它都会发送 BDP ping(一个仅由 BDP 估计器使用的带有唯一数据的 ping)。之后,接收方开始计算它接收到的字节数(包括触发 BDP ping 的字节数),直到它收到该 ping 的确认。大约 1.5 RTT(往返时间)内接收到的所有字节的总和是有效 BDP * 1.5 的近似值。如果这接近我们当前的窗口大小(例如,大于其 2/3),我们必须增加窗口大小。我们将窗口大小(流和连接)设置为我们采样的 BDP 的两倍(接收到的所有字节的总和)。
此算法本身可能会导致 BDP 估计无限增加;窗口的增加将导致采样更多字节,这反过来会导致窗口进一步增加。这种现象被称为缓冲膨胀,在 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 来提高吞吐量,以下工作与此相关。
减少 flush 系统调用
我们注意到传输层中的一个错误,该错误导致我们为写入的每个数据帧进行刷新系统调用,即使同一个 goroutine 有更多数据要发送。我们可以批量处理这些写入以仅使用一次刷新。实际上,这对代码本身不会有很大的改变。
为了消除不必要的刷新,我们最近将客户端单项和服务器流式 RPC 的标头和数据写入合并为一个刷新。链接到代码
我们的用户 @petermattic 在 此 PR 中提出的另一个相关想法是将服务器对单项 RPC 的响应合并为一个刷新。我们目前也在研究这个问题。
减少内存分配
对于从网络读取的每个数据帧,都会发生新的内存分配。对于 gRPC 层,对每个用于解压缩和解码的新消息也是如此。这些分配会导致过多的垃圾回收周期,而这是昂贵的。重用内存缓冲区可以减少这种 GC 压力,并且我们正在原型化实现此目的的方法。由于请求需要不同大小的缓冲区,一种方法是维护固定大小(2 的幂)的单独内存池。因此,现在当从网络读取 x 字节时,我们可以找到大于 x 的最接近的 2 的幂,并重用缓存中的缓冲区(如果可用),或者在需要时分配一个新的缓冲区。我们将使用 golang sync Pools,因此不必担心垃圾回收。但是,在提交之前,我们需要运行足够的测试。
结果
在真实网络上的基准测试
- 服务器和客户端在不同大陆的两个虚拟机上启动。RTT 约为 152 毫秒。
- 客户端使用有效负载进行 RPC,服务器使用空消息进行响应。
- 测量每次 RPC 所花费的时间。
- 代码链接
消息大小 | gRPC | HTTP 1.1 |
---|---|---|
1 KB | ~152 毫秒 | ~152 毫秒 |
10 KB | ~152 毫秒 | ~152 毫秒 |
10 KB | ~152 毫秒 | ~152 毫秒 |
1 MB | ~152 毫秒 | ~152 毫秒 |
10 MB | ~622 毫秒 | ~630 毫秒 |
100 MB | ~5 秒 | ~5 秒 |
- 在模拟网络上的基准测试
- 服务器和客户端在同一台机器上启动,并模拟不同的网络延迟。
- 客户端使用 1MB 的有效负载进行 RPC,服务器使用空消息进行响应。
- 测量每次 RPC 所花费的时间。
- 下表显示了前 10 个 RPC 所花费的时间。
- 代码链接
无延迟网络
gRPC | HTTP 2.0 | HTTP 1.1 |
---|---|---|
5.097809 毫秒 | 16.107461 毫秒 | 18.298959 毫秒 |
4.46083 毫秒 | 4.301808 毫秒 | 7.715456 毫秒 |
5.081421 毫秒 | 4.076645 毫秒 | 8.118601 毫秒 |
4.338013 毫秒 | 4.232606 毫秒 | 6.621028 毫秒 |
5.013544 毫秒 | 4.693488 毫秒 | 5.83375 毫秒 |
3.963463 毫秒 | 4.558047 毫秒 | 5.571579 毫秒 |
3.509808 毫秒 | 4.855556 毫秒 | 4.966938 毫秒 |
4.864618 毫秒 | 4.324159 毫秒 | 6.576279 毫秒 |
3.545933 毫秒 | 4.61375 毫秒 | 6.105608 毫秒 |
3.481094 毫秒 | 4.621215 毫秒 | 7.001607 毫秒 |
RTT 为 16 毫秒的网络
gRPC | HTTP 2.0 | HTTP 1.1 |
---|---|---|
118.837625 毫秒 | 84.453913 毫秒 | 58.858109 毫秒 |
36.801006 毫秒 | 22.476308 毫秒 | 20.877585 毫秒 |
35.008349 毫秒 | 21.206222 毫秒 | 19.793881 毫秒 |
21.153461 毫秒 | 20.940937 毫秒 | 22.18179 毫秒 |
20.640364 毫秒 | 21.888247 毫秒 | 21.4666 毫秒 |
21.410346 毫秒 | 21.186008 毫秒 | 20.925514 毫秒 |
19.755766 毫秒 | 21.818027 毫秒 | 20.553768 毫秒 |
20.388882 毫秒 | 21.366796 毫秒 | 21.460029 毫秒 |
20.623342毫秒 | 20.681414毫秒 | 20.586908毫秒 |
20.452023毫秒 | 20.781208毫秒 | 20.278481毫秒 |
RTT 为 64 毫秒的网络
GRPC | HTTP 2.0 | HTTP 1.1 |
---|---|---|
455.072669毫秒 | 275.290241毫秒 | 208.826314毫秒 |
195.43357毫秒 | 70.386788毫秒 | 70.042513毫秒 |
132.215978毫秒 | 70.01131毫秒 | 71.19429毫秒 |
69.239273毫秒 | 70.032237毫秒 | 69.479335毫秒 |
68.669903毫秒 | 70.192272毫秒 | 70.858937毫秒 |
70.458108毫秒 | 69.395154毫秒 | 71.161921毫秒 |
68.488057毫秒 | 69.252731毫秒 | 71.374758毫秒 |
68.816031毫秒 | 69.628744毫秒 | 70.141381毫秒 |
69.170105毫秒 | 68.935813毫秒 | 70.685521毫秒 |
68.831608毫秒 | 69.728349毫秒 | 69.45605毫秒 |