一个新的protobuf发电机

2021-06-04 02:31:59

虽然应用程序和Vitess数据库之间的主要界面是通过MySQL协议,但Vitess是一个大而复杂的分布式系统,并且通过GRPC执行VITESS集群中的不同服务之间的所有通信。

因此,所有服务边界和Vitess&#39之间的消息;使用协议缓冲区指定系统。 Vitess&#39的历史;与协议缓冲区的集成相同涉及:自从最早的发布以来,我们一直在使用并保持最新与Go Protocol Buffers封装,直到去年5月,当谷歌发布了用于协议缓冲区的新GO API时,这不是向后兼容的使用之前的Go Package。

我们立即升级到新API的机会有几个原因:升级是非琐碎的,特别是对于像玻璃体一样大的项目;它没有向我们提供任何有形的好处,因为我们使用协议缓冲区是非常基本的,并且我们不会在我们的代码库中的任何地方使用反射;最重要的是:它意味着一个非常重要的性能回归。

虽然Protobuf APIV2中的新(UN)编程代码不会比APIV1中的一个慢得多(但实际上,大多数等价物),Vitess尚未使用API​​V1编解码器一段时间。今年早些时候,我们将Gogo Protobuf编译器介绍给我们的Codebase,具有令人印象深刻的性能结果。

对于那些不了解的人,Gogo Protobuf是原始Protobuf APIV1的叉子,包括一个自定义代码生成器,可选支持许多性能相关的功能。它们中最值得注意的是,我们为Vitess启用的那个是为代码库中的所有消息产生完全展开的蒙太展和解码代码。与使用Protobuf APIV1中的默认编解码器进行默认编解码器相比,这是一个非常重要的性能提升,这在运行时使用反射完全实现。具有可以提前编译的静态代码会导致CPU使用率的大量使用率和最令人难以置信的RPC呼叫的响应时间更低。

尽管将Gogo Protobuf编译器引入我们的Codebase的结果,但有些关于优化的东西:Gogo项目目前没有积极寻找新的所有权。这是主要原因是Protobuf APIV2的发布:Gogo Protobuf是APIV1编译器的叉子,因此将其更新为支持APIV2将是一个完整的重写项目,就像APIV2是APIV1的完整重写一样。 Gogo的维护者,可理解的是,无法达到巨大的任务。

因此,我们很清楚,在Vitess中引入Gogo Protobuf生成的代码将是一个非常短暂的优化:一旦我们决定将我们的项目升级到Protobuf APIV2,我们必须完全丢弃Gogo并回到基于默认的反射(联合国)母校,但我们仍然脱颖而出,确保尽可能少的Gogo专用特征来使最终升级更痛苦。

这是一个坏主意吗?也许。本月早些时候,我们决定尝试将Vitess升级到Protobuf APIV2,主要是为了了解过程是多么努力,以及删除Gogo的自动化代码时的性能回归程度如何。升级没有没有打嗝,但我们设法在几周的努力之后,我们设法使用新的API和测试套件全面使用。

然而,我们的基准的结果令人沮丧。我们的夜间基准系统是在所有基准测试期间检测到CPU使用率的非常大幅增加:高达19%,导致系统的总吞吐量降低约3%。

我们真的很难在Vitess版本之间恢复性能,因此我们开始评估我们的选项来执行此升级并留下Protobuf APIV1封装(现在已被弃用)以及Gogo Protobuf编译器(现在不明意),同时保持Vitess尽可能快地保持Vitess 。

最明显的选择是借鉴我们的Gogo Protobuf的所有权,但它的维护者是对的:将其升级到APIV2是一个大规模的事业。我们没有人员配备能力,以无限期地升级和拥有此类项目。

所以,我们试图建立一个protobuf编译器,同时从Gogo protobuf的课程中学习了我们的性能需求:Vtprotobuf是一个Protobuf编译器,用于GoIV2的高度优化(UN)编号,同时(希望)在长期内更容易维护。

与Gogo Protobuf相比,VTProtobuf编译器会产生不同的设计选择:它不是APIV2编译器的叉子,而是一个独立的插件,它与APIV2的上游编译器一起运行,并将优化的代码作为opt-in enders一起运行。这是一个权衡,因为它意味着我们无法实现Gogo支持的一些优化,例如使消息字段不可以,或别名生成的Protobuf消息中的生成类型。这些是Gogo Protobuf功能,一些Go项目已经使用成功来减少处理协议缓冲区消息时生成的垃圾量,但Vitess从未选择过那些,因此我们对我们的APIV2港口没有意义发电机。

相反,通过专注于高度优化的元帅和解体代码,VTProtobuf可以在一个小写字母中实现,只依赖于公共和稳定的Google.golang.org/protobuf/compiler/protogen包,而原始的apiv2代码生成器伴随它并生成实际的Protobuf消息和相关元数据以进行反射。

我们相信这是一个坚实的权衡,它将使VTProtobuf从长远来看很容易维持,确保我们总是在上游进行的任何变化都很好的,因为我们不再携带自己的叉子。

第一个Beta版本的VTPROTOBUF现在可公开可用:它支持用于元帅,单声道和大小的优化代码。得到的Codegen基于Gogo Protobuf中的原始实现,但它完全适合Protobuf APIV2消息,并且已经接收了许多微优化,使其在所有基准中的快速或更快地运行。此外,我们还实现了一个新功能,在原始Gogo Protobuf编译器中不可用:内存池。

在GO期间的总体性能差的总体性能有两种主要原因:依赖于拼警和解体的反射,以及内存分配的开销。

通过生成优化的代码来处理具有反射的第一个问题,以对每个特定消息执行序列化和反序列化的序列化和反序列化。第二个问题更难解决。

其他protobuf实现,如c ++,通过使用内存arenas解决内存分配问题:大的内存块,可用于分配带有凸点指针分配器(非常有效)的内存,并且可以立即释放。这是一种在典型请求 - 响应RPC系统中起作用的模式,其中常用于协议缓冲区。然而,竞技场是不可行的,因为它是垃圾收集的语言。

Gogo Protobuf编译器通过允许用户在其生成的消息结构中选择更少的指针字段,从而减少了牺牲了人体工程学的分配数量(很难判断嵌套消息是否丢失) )和向后兼容上游Protobuf生成的消息。如前所述,我们选择不完全修改生成的消息结构,因为不携带Protobuf的整个叉子去生成器,因此这不是我们的选择。

因此,下一个最佳选择是单个对象的内存池,这是VTPROTOBUF现在提供的功能。在Protoc-Gen-Go-Vtproto中启用池功能时,我们可以标记单个Protobuf消息,因此它们始终在单个解体后的内存池中分配。标记的消息接收辅助方法,以便在不再需要时可以直接返回到内存池,以及零零的特殊重置实现,同时确保保持尽可能多的底层内存(例如,嵌套切片,映射和对象),以便稍后从内存池中检索这些对象,导致解体时的分配较少。

内存池远离性能银弹远非:许多Protobuf消息太小,无法从池中受益,有些具有如此多的嵌套复杂性,即递归汇集其字段的开销首先击败优化。此外,Go编程语言在其类型系统中提供了无法安全地管理这些池对象的功能。如果没有非常仔细使用,它很容易损坏内存并导致带有池的Protobuf消息的数据比赛。

尽管有其局限性,但我们发现内存池是Vitess中非常有效的优化。当在API中使用井有意义,它比没有指针字段的自定义Gogo Protobuf消息变得明显快,具有普通的Monforshing时代,对于基于C ++基于竞技场的解析,大型消息竞争。我们现在看到一个实用的例子!

Vitess'复制引擎,抗匹配,是Vitess集群中最常规的重型路径之一。它是一个强大而多功能的MySQL复制引擎,能够在不同的Vitess键空间之间保持同步中的表副本,并换算表的偏见视图。每当我们提交影响GRPC或PROTOBUF编组的更改或优化时,我们都会非常关注它们对复制子系统的影响。由于整个复制过程通过GRPC执行,并且在生产部署中,复制的表通常是大规模的(terabytes),因此该路径中的任何性能回归通常会导致真实的世界时间在分钟和小时之间增加范围。

为了测量Protobuf变化在复制过程中的影响,我们建立了一个合成压力测试,在两个Vitess键之间执行了一个大型MySQL表的全部复制。这是一个现实的示例,其练习复制过程的最昂贵的部分:两个Vitess片剂之间所有行数据的初始副本。

作为我们的性能测量的基线,我们将使用旧的Vitess提交来执行复制,我们仍在使用protobuf apiv1但没有任何Gogo Protobuf优化。

Vitess集群目的地平板电脑中的火焰图清楚地显示了RPC开销的来源:

基于反射的解压缩代码并不是特别有效,但绝大多数时间实际上是为正在流式传输的单个行的内容分配内存。通过整个基准测试,我们共度7.47秒在解体开销中进行了7.47秒。

让我们将其与Protobuf APIV1进行比较相同的复制过程,但使用Gogo Protobuf生成的解码代码。由于我们只对更快(UN)铭牌代码感兴趣,因此我们不需要修改我们的.proto文件以启用优化。我们可以简单地用Gogo项目提供的Protoc-Gen-Gofast更换默认的Protoc-Go-Go Gen Gen Geacer:

没有进一步的变化需要享受优化的好处。让我们通过再次运行基准来看看它对解体开销的影响:

通过使用所有基于反射的代码替换为优化的预先生成的解码代码,我们可以看到实际解析Protobuf消息的开销已经大大减少。我们不再呼入ProTO包,而是我们在vstreamRowsResponse结构中使用专门的rsshal方法来执行解体。现在,大多数CPU时间都在分配行数据的内存中。总的来说,我们现在在解压开销中支出4.24 CPU秒。

我们现在看看我们升级到Protobuf APIV2后看起来像什么表现。我们需要从Gogo Protobuf中删除Protoc-Gen-Gofast发电机,并用新的APIV2发电机替换它。另请注意,现在GRPC生成器自己运行,而不是将传递传递给默认生成器。

通过现已消失的优化Gogo代码,我们期待性能回归。让我们找出实践中有多糟糕:

不好了!我们回到了广场。使用反射的开销是回来的,尽管Protobuf APIV2中的新反射的解析器略微比V1中的一个稍慢,但与我们的Gogo Protobuf解码代码相比,解马语的总开销是大量的。我们现在花了7.17秒。

让我们启用vtprotobuf的优化代码生成。我们需要与Protoc-Gen-Go和Protoc-Gen-Go-Grpc发电机一起运行Protoc-Gen-Go-VTProto发生器:

只需为我们的protobuf消息生成优化的ressshaling和解体求助者是不够的。我们需要通过注入特定编解码器来选择我们的RPC框架。 Vtprotobuf Readme具有不同的GO RPC框架的说明,包括GRPC。

再次运行基准时,我们希望看到与Gogo Protobuf非常相似的性能,但在APIV2 Protobuf消息的顶部工作:

这很棒。反射使用现已走了,我们可以看到GRPC如何直接呼叫我们的专门用于每个Protobuf消息的助手助手。内存分配仍然存在固定的开销,但我们现在正在开销中的4.15 CPU秒。我们比Gogo Protobuf略微快,我们我们是未分校进入前向兼容的APIV2 Protobuf消息。

我们可以很容易地停止这里,因为我们已经成功地升级了Vitess来使用Protobuf APIV2而不具有性能回归,但我们希望进一步走一步。 vReplication过程中的vStreamRows RPC呼叫是内存池的理想用例。

要启用内存池,我们将池功能添加到ProToc-Gen-Go-Vtproto调用,并指定池中需要存储的对象。目前,我们只关注vReplication中使用的行和vstreamrowsresponse消息。要指定必须池的邮件,我们可以使用像gogo protobuf等protobuf扩展,但为了保留我们的.proto文件,但我们还添加了直接配置池作为Commandline标志的选项。

一旦生成了我们的消息的池申请人,我们必须更新调用代码以确保我们从流中汇集邮件。对于此特定的VStreamRows API,Vitess已经在流后面提供了基于回调的抽象,因此从池中获取和返回消息非常易于实现,假设发送(R)回调的接收器不会保持消息 - 他们没有。

通过这些微不足道的变化,我们可以再次运行我们的复制基准并查看未制作行的开销:

不,当裁剪火焰图时,我没有搞砸。一旦我们为vstreamRowsResponse对象启用内存池,就不再需要为基础行数据分配内存。我们现在在解释的CPU秒中花费0.63 CPU秒,因为我们只是在从源VITESS平板电脑复制行的同时又一次地重新使用相同的响应对象。

在针对所有不同Protobuf代码生成器的CPU使用情况下绘制时,内存池优化的影响清晰可见:

协议缓冲区的性能在GO中是一个艰难的主题,它只与Protobuf APIV2发布的遗传更复杂,并且Gogo Protobuf的弃用,我们过去的最佳配方,以减少Marshaling&amp的CPU使用情况;在我们的RPCS中解开开销。

有许多开源和专有的Go项目,可以卡在Protobuf APIV1中(因为它们依赖于Gogo Protobuf)或已升级到APIV2并遭受性能回归。我们知道VTPROTOBUF无法处理Gogo Protobuf所做的所有用例,但我们希望它能够使许多项目迁移到Protbuf APIV2而不会遭受严重的表现惩罚,就像我们在Vitess中所做的那样,并且导致更统一和更有表现的GO生态系统。

我们已经积极测试Vitess Master分支中VTProtobuf的Beta,我们希望将优化的Codegen作为我们的下一个主要玻璃体发布的默认值运送。请随时尝试自己的项目,并报告任何性能回归或不兼容。