Sourcegraph精确代码智能后端的性能改进

2020-06-23 04:05:00

在Sourcegraph 3.16中,我们提到了组成精确编码英特尔后端的服务从TypeScript to Go的重写。重写的原因有很多,但我想在这里探讨一个特别的原因:作为一个团队,我们知道如何改进在大规模数据上操作的GO代码,而我们在服务器端做同样的打字脚本的经验较少。

这并不是说打字作为一种语言有任何负面影响。老实说,在其中工作是一件令人愉快的事情,我会偶尔跳到网络代码中去挠痒痒。这并不是说不可能编写在Node.js环境中运行良好的代码。V8是世界级的JIT,是真正的工程野兽。这并不是说缺少帮助开发人员分析和改进代码的工具。

这就是说,我认为执行重写让后端团队发挥我们的优势是一项很好的举措。用这样一种语言重写代码:我们对语义和性能有更好的心理模型,对生态系统有更好的把握,编写高性能代码的实际经验使我们能够在未来以足够的速度前进,重写所花费的时间将在短时间内得到回报。

这篇文章概述了为提高精确代码英特尔查询的性能、提高原始lsif上传处理的性能以及减小磁盘上的精确代码英特尔捆绑包大小所做的许多更高级别的更改。

下表显示了与前两个Sourcegraph版本相比,运行我们的集成测试套件时查询延迟的减少。测试套件通过来自etcd-io/etcd、pingcap/tidb和Distributedio/itan的三次提交以及来自uber-go/zap的两次提交来查询交叉回购定义和引用。

结合本文讨论的所有更改,与Sourcegraph 3.15相比,查询和上传处理的延迟减少了两倍,磁盘上捆绑包的大小也减少了两倍。

希望这篇文章可以作为非Sourceraphers性能改进灵感的来源,并可以作为当前和未来Sourceraphers的历史提交文档,这比我通常的提交模式更有用:

本节描述在体系结构级别所做的更改,更改哪些服务与哪些内容通信、哪些数据归哪些服务所有以及服务具有哪些职责。

在port to go之前,有三个服务是用打字稿编写的,它们组成了精确的代码intel后端:

Precision-code-intel-worker,它将原始LSIF输入转换为捆绑包管理器可读的SQLite文件。

在到达端口后,API服务器成为包管理器查询的一个非常薄的包装器。不管性能结果如何,为了直接从前端(API的唯一使用者)查询包管理器,该服务注定要被删除。

为了以最小的差异完成此操作,我们通过使用假HTTP请求直接调用HTTP处理程序来替换对API服务器的网络调用。这使我们可以折叠网络边界,而不会触及很多API服务器客户端或API服务器中的HTTP处理程序代码。这是一个相当巧妙的把戏!

需要额外的步骤来将这些层减少为直接的函数调用,但是剩下的步骤只是为了提高代码的安全性,并不是巨大的性能提升。

本节介绍为拆分工作所做的一些更改,以便部分工作可以并发完成(当当前任务被阻塞时切换到另一个挂起的任务)或并行完成(在物理不同核心上同时执行)。

即使由于JSON解析减少了CPU时间和分配压力,它仍然是瓶颈。馈送到相关器进程的行几乎立即被消耗,这使得这成为一个慢生产者/快消费者的问题(这是一个比慢消费者更容易管理的问题)。

为了提高读取吞吐量,我们需要并行解析JSON行,同时保持顺序不变(这对相关器很重要)。使用通道作为有界队列允许我们将问题分成几个部分,如下所示。

我们继续以输入允许的速度和消费者接受输入的速度逐行读取原始数据。这里使用通道充当预读缓冲区。许多反编组例程从通道读取行,对其进行解析,并将结果放到输出通道上。批处理程序进程批量使用元素通道中的项目,按照输入ID(而不是发送到通道的顺序)对它们重新排序,以便顺序与原始LSIF输入一致。批处理从通道接收到预期数量的值后,会向解组程序发送一个信号,以释放它们继续工作。此信令过程确保解组人员不会在批处理窗口之后寻找工作。每个完成的批次都可以自由地传递给相关器。

定义数据,它充当按其导出名字对象的范围的查找表,以及。

在此更改之前,工作进程将启动四个Goroutine,每个类别一个,并将顺序地将批数据写入目标表。SQLite批处理插入器实用程序几乎是最快的:它设置正确的编译指示,使用单个事务,并通过在每个INSERT语句中压缩尽可能多的行来最小化命令数量。

为了提高写入吞吐量,我们将并行性移到了写入器层,以便可以更严密地控制它。在此更改之后,文档数据、结果块数据、定义数据和引用数据将按顺序写入包,但每个写入操作将并行地将数据发送到n批。这将按与核心数量成比例的方式增加写入并行度。

该结构还具有与上面详述的高效输入流改变类似的好处。在将行写入批处理之前,必须将其序列化为正确的数据格式。在更改之前,每个文档行都在单个goroutine中按顺序序列化。更改之后,可以并行序列化n行并将其发送到不同的批。

本节介绍几项更改,这些更改只是为了减少计算时间以减少操作延迟。下面的每一节都标识了正在执行冗余或不必要工作的代码部分,以及如何更改代码以跳过浪费这些周期的操作。

在此更改之前,如上所述,定义和引用数据作为单独的行写入SQLite文件。每行由自动生成的唯一标识符、名字对象数据(方案和标识符)以及与该名字对象关联的源位置(相对文件路径、开始行、开始字符、结束行和结束字符)组成,每行总共有8个值。为了按名字快速查找,在方案和标识符列上有一个索引。捆绑包中可以有许多(数百万)行,这些行可以导出许多标识符,或者使用许多外部包。

此PR更改定义和引用表,以按唯一名字对象对数据进行分组,并将与该名字对象相关联的所有源位置序列化为单个压缩的有效负载。这将写入这些表的行数减少了几个数量级。我们对文档数据和结果块数据使用相同的技术。现在,每行都包含一个绰号方案和标识符列(这是一个复合主键),以及一个数据BLOB列。

我们在查询路径中没有任何损失,因为前面的查询模式检索了与给定名字对象相关联的所有源位置。这显著减小了包的大小(初始测试中使用的大多数包的大小约为50%),因为我们重新存储的数据在压缩时比存储为单个元组值时更小。

另一个好处是,我们能够在单个查询中插入更多定义和引用行(每个查询从124行增加到333行),从而减少了编写包所需的时间。有关SQLite中的硬插入限制,请参见SQLITE MAXVARIABLE_NUMBER的定义。

这一更改用gzip的gob编码结构替换了gzip的JSON编码的包有效负载。这一变化在时间上做了很小的改进,减少了分配,减少了捆绑包的大小,更重要的是,我们摆脱了由于数据形状逐渐退化而导致的一些技术债务。

当你制造垃圾时,总得有人来清理。Go使用非世代三色标记和清扫收集器,这意味着清扫阶段花费的时间与整个堆成比例。你分配的越多,需要清理的就越多。

可以避免短期分配的一个地方是创建集合。紧接着要走的端口,很多集合都是初始化的,没有容量:

如果集合的大小超过集合的容量,则追加到切片或为映射赋值会导致基础结构调整大小。收集器的容量在每次调整时翻了一番,并且每次插入都可以在摊销的恒定时间内完成。但是,每次调整大小也会放弃以前的堆数据,这些数据需要在将来的某个时候进行清理。在此工作负载中,每个集合的插入数量可能非常大,这会导致短期分配堆积。

如果您可以估计将插入到结构中的元素的数量(或至少接近它),就可以节省运行时为所有中间集合分配空间。

这没有产生很大的影响,但确实产生了一些影响,这是值得考虑的非常容易获得的成果(另外一个好处是,向读者提供了关于集合的目的的额外提示-这应该总是受到开发人员同事的欢迎)。

较大的回报是由于传递给json.marshal的数据发生了很小的变化。紧跟在port to go之后,序列化代码看起来类似于下面的内容。

func(*defaultSerializer)MarshalDocumentData(d个类型.DocumentData)([]字节,错误){//.。编码,错误:=json。Marshal(map[string]interface{}{";range";:map[string]interface{}{";type";:";map";,";value";:rangePair},";hoverResults";:map[string]interface{}{";type";:";map";,";value";:hoverResultPair},";名字对象";:map[String]interface{}{";type";:";map";,";value";:monikerPair},";PackageInformation";:map[string]interface{}{";type";:";map";,";value";:PackageInformationPair},}//.}。

代码最初是以这种方式编写的,以便与服务的旧版本生成的包保持读/写兼容性,而不会在移植的代码中过于冗长。虫子喜欢躲在冗长中。此有效负载的属性被序列化为标记的数据类型,以便支持集合的序列化,这增加了GO中的冗长,而用TypeScript编写则相当简洁。

通过在堆栈上分配值类型,为这些有效负载定义适当的结构减少了需要分配的短期堆分配映射的数量。

type SerializingTaggedValue struct{Type string`json:";type";`value interface{}`json:";value";`}func(*jsonSerializer)MarshalDocumentData(d Types.DocumentData)([]byte,error){//.。编码,错误:=json。Marshal(SerializingDocument{Ranges:SerializingTaggedValue{Type:";map";,Value:rangePair},HoverResults:SerializingTaggedValue{Type:";Map";,Value:hoverResultPair},名字:SerializingTaggedValue{Type:";map";,Value:monikerPair。

隐藏在这份公关中的是一系列快速积累起来的变化(它完全抹去了与英特尔相关的旧代码GraphQL代码)。GraphQL解析器处理大量数据,自然包含许多循环-其中许多循环的形状类似于下面的内容。

ResolvedLocations:=make([]gql.LocationResolver,0,len(Locations))for_,location:=Range Locations{Resolver,err:=ResolveLocation(ctx,locationResolver,location)if err!=nil{return nil,err}if Resolver==nil{Continue}ResolvedLocations=append(ResolvedLocations,Resolver)}。

位置片保存(非指针)结构值,每个值有近20个字段。在每次迭代中,必须将组成该索引处的值的堆上的内存范围复制到堆栈上当前函数的激活记录中的类似大小的内存区域中。

启用go-lint会对其中许多循环发出以下警告,如果您盲目订阅值语义==fast";cult的核心原则,则可以忽略不计。

ResolvedLocations:=make([]gql.LocationResolver,0,len(Locations))for i:=Range Locations{Resolver,err:=ResolveLocation(ctx,locationResolver,location[i])if err!=nil{return nil,err}if solvedLocations==nil{Continue}Resolution vedLocations=append(Resolution vedLocations,solvedLocator)}。

现在,它不是将216个字节复制到临时变量(Location)中,然后在每次迭代时再次复制到Resolution veLocation调用的激活记录中,而是只进行后一次复制,并有效地将移动的字节数减半。最初令人惊讶的是,此更改对查询路径延迟的影响如此之大。同样,您可以在这216个字节的空间中容纳27个64位整数,这看起来确实很多。

使用bufio.scanner逐行读取原始LSIF数据,使用json.Unmarshal将其解析为信封顶点或边结构,并将其传递给相关器进程,该进程将构建LSIF图的内存表示。

关联过程几乎不做任何I/O操作:它只是将标识符插入到正确的映射、切片和集合结构中,以便进行下一步处理。性能分析显示,此处理阶段的大部分CPU时间都花在";编码/json&34;包上(因为它大量使用反射)。类似地,分配的最高数量也来自于将值解码为结构(紧随其后的是在写入时序列化不同的结构)。

我们接受的输入不容易改变:LSIF协议定义了输出格式,我们希望能够接受任何协议确认的索引器输出。要求Sourcegraph特定的输入格式是不可能的,这会将可用的索引器限制为那些专门为Sourcegraph编写的索引器,并破坏与现有用户的向后兼容性。

相反,我们研究了其他库来解析JSON输入。我们选择了json-iterator/go,这是一个高性能的临时替代工具。切换到这个库是一个非常小的改变,并且带来了非常好的速度提升。这个库在解码小结构时特别快,而且大多数LSIF顶点和边定义都非常小(除了一些高度包含边的情况,就像非常长的文件一样)。

我们考虑的其他库包括easyjson、fast json和ffjson等。其中一些库需要额外的工程工作,因为API不等同于由";编码/json&34;包定义的API。

捆绑包管理器是精确代码英特尔世界中所有持久化的东西的守护者。当工作人员需要处理原始LSIF数据时,该数据需要向包管理器请求数据。对称地说,一旦工作人员序列化了包,它就会被传递回包管理器进行永久存储。

在此更改之前,包管理器客户端将通过HTTP请求请求数据,将其写入磁盘,然后将文件名传递给工作进程。工作人员将打开并读取该文件以进行处理。

我们可以通过简单地将HTTP响应读取器传递回Worker而不是要求它命中磁盘来减少I/O。这也为我们节省了在开始处理数据之前等待整个传输完成并刷新到磁盘所需的时间。由于原始LSIF索引可能相当大(数千兆字节),这在许多情况下提供了不可忽略的提升。

我们计划继续这条性能改进之路,下一版本将专门关注并发处理多个捆绑包,以便成倍增加这些近期性能提升带来的好处。

如果您对这篇文章中的材料感兴趣,请来帮助我们挤压更多的精确代码英特尔服务的性能!我们正在招聘。