铁锈编译缓慢的几个原因

2020-06-30 21:11:46

在本系列中,我们将探讨Rust在TiKV上下文中的编译时间,TiKV是TiDB数据库背后的键值存储。

最近,我们正在探索Rust的设计是如何阻碍快速编译的。在本系列的上一篇文章中,我们讨论了编译单元,为什么Rust如此之大,以及这对编译时间有何影响。

这一次,我们将用几个更多的主题来结束对Rust运行缓慢原因的讨论:LLVM、编译器体系结构和链接。

rustc使用LLVM生成代码。LLVM可以生成非常快的代码,但这是有代价的。LLVM是一个非常大的系统。事实上,LLVM代码构成了Rust代码库的大部分。而且它跑得也不是特别快。事实上,LLVM的最新版本对Rust的编译时间造成了重大的倒退,但并没有带来什么特别的好处。

Rust编译器需要跟上LLVM版本,以避免痛苦的维护问题,因此Rust构建很快就会毫无意义但不可避免地变慢。

即使当rustc生成调试构建(应该构建得很快,但却允许运行缓慢)时,生成机器码仍然需要相当长的时间。

在TiKV发布版本中,LLVM遍占用了84%的构建时间,而在调试构建中,LLVM遍占用了35%的构建时间(完整的细节要点)。

不过,LLVM在快速生成代码方面很差(即使生成的代码很慢)并不是该语言的固有属性,正在努力使用Cranelift创建第二个后端,Cranelift是用Rust编写的代码生成器,旨在快速生成代码。

除了集成到Rustc中,Cranelift还集成到了SpiderMonkey中,作为其WebAssembly代码生成器。

然而,将所有代码生成缓慢归咎于LLVM是不公平的。Rustc在生成LLVM IR的方式上不会给LLVM带来任何好处。

Rustc臭名昭著,因为它将大量未经优化的LLVM IR扔给LLVM,并期望LLVM将其全部优化。这(可能)是Rust调试二进制文件如此缓慢的主要原因。

因此,LLVM正在做大量的工作来尽可能快地使Rust变得更快。

这是编译器架构的另一个问题--严重依赖LLVM来创建Rust要比聪明地知道Rustc将多少信息交给LLVM进行优化要容易得多。

因此,随着时间的推移,这个问题可以解决,这也是编译器团队改进编译时性能的主要途径之一。

还记得前几集我们讨论单态化是如何工作的吗?它如何为每个实例化类型参数组合复制函数定义?嗯,这不仅是机器代码膨胀的一个来源,也是LLVM IR膨胀的一个原因。这些函数中的每一个都填充了重复的、未优化的LLVM IR。

rustc正在慢慢修改,以便它可以在自己的MIR(中级IR)上执行自己的优化,关键的是,MIR表示是预单形化的。这意味着MIR级优化对于每个泛型函数只需要执行一次,进而生成更小的单值化LLVM IR,这样LLVM(理论上)可以比目前使用其未优化的函数更快地进行转换。

事实证明,rustc的整个体系结构是错误的,大多数编译器的体系结构也是错误的。

编译器通过将其所有源代码解析成AST来消耗整个编译单元。

通过一连串的传递,AST被提炼成越来越详细的中间表示(IRS)。

这是批处理编译模型。几十年来,学术界对编译器的描述很模糊;历史上大多数编译器都是这样实现的;Rustc最初也是这样架构的。但是它不是一个很好地支持现代开发人员的工作流和他们的工具的体系结构,也不支持快速重新编译。

今天,开发人员希望得到有关他们正在攻击的代码的即时反馈。当他们编写类型错误时,IDE应该立即在他们的代码下面画一个红色的符号,并告诉他们这一点。理想情况下,即使源代码没有完全解析,它也应该这样做。

批处理编译模型不太适合这一点。它要求针对源代码的每个增量更改重新分析整个编译单元,以便为分析产生增量更改。在过去十年左右的时间里,编译器工程师们关于如何构建编译器的思想已经从批处理编译转变为响应编译,通过这种编译,编译器可以尽可能快地在尽可能小的代码子集上运行整个编译流水线,以回答特定的问题。例如,对于响应式编译,您可以问";此函数类型是否检查?";或";此结构的类型依赖项是什么?";。

这种能力有助于感知编译器的速度,因为用户在工作时不断得到必要的反馈。它可以极大地缩短纠正类型检查错误的反馈周期,而且在Rust中,让程序成功地进行类型检查占用了开发人员很大一部分时间。

我想现有技术是广泛的,但一个值得注意的创新响应编译器是Roselyn.NET编译器;响应编译器的概念最近随着Language Server Protocol的采用而有了很大提高。这两个都是Microsoft项目。

在今天的Rust中,我们通过Rust language Server(RLS)支持此IDE用例。但许多Rust开发人员都知道,RLS的体验可能相当令人失望,在输入和获取反馈之间存在巨大的延迟。有时RLS找不到预期的结果,或者干脆完全失败。RLS的失败主要是由于构建在批处理模型编译器之上,而不是响应编译器。

RLS正逐渐被锈蚀分析器取代,这实质上相当于至少在其分析阶段对Rustc进行了彻底的重写,以支持响应编译。预计随着时间的推移,防锈分析仪和防锈软件将共享越来越多的代码。

达到极限时,响应式编译器体系结构自然有助于快速响应重新生成机器代码之类的请求,但仅适用于自上次运行编译器以来更改过的函数。因此,响应编译不仅支持IDE分析用例,还支持重新编译为机器代码的用例。今天,第二种用例在rustc中得到了增量编译的支持,但它相当粗糙,每次编译器调用都会有大量重复工作。我们应该预料到,随着rustc变得更具响应性,增量编译最终将完成尽可能少的工作,只重新编译必须重新编译的内容。

但是,通过增量编译生成的机器代码的质量存在权衡-由于内联的神秘挑战,增量重新编译的代码不太可能像高度优化的批编译的机器代码那样快。换句话说,您可能永远不会想要在您的生产版本中使用增量编译,但是它可以极大地加快开发体验,同时生成相对较快的代码。

Niko在PLISS2019大会上的响应式编译器演讲中谈到了这个架构。在那次演讲中,他还提供了一些例子,说明Rust语言如何意外地被错误地设计成响应性编译。这是一个完全值得关注的关于编译器工程的演讲,我推荐您去看看。

Cargo允许使用两种类型的自定义Rust程序定制构建:构建脚本和过程宏。每种方法的机制都不同,但它们都类似地在编译过程中引入了任意计算。

首先,这些类型的程序有自己的板条箱生态系统,也需要编译,因此使用过程宏通常还需要编译像syn这样的板条箱。

其次,这些工具经常用于代码生成,它们的调用有时会扩展到大量代码。

第三,过程性宏会阻碍像sccache这样的分布式构建缓存工具。令我惊讶的原因是-rustc今天将过程性宏作为动态库加载,这是Rust生态系统中动态库为数不多的常见用法之一。sccache无法缓存动态库工件,因为它无法查看链接器是如何被调用来创建动态库的。因此,使用sccache构建严重依赖过程性宏的项目通常不会加快构建速度。

这一点很容易被忽视,但对黑客测试周期有潜在的重大影响。人们最喜欢Rust的一件事是-它生成一个部署起来很简单的静态二进制文件,还需要Rust编译器做大量的工作来将该二进制文件链接在一起。

每次构建可执行文件时,rustc都需要运行链接器。这包括每次重新构建以运行测试时。在相同的实验中,我计算了在LLVM中花费的构建时间,Rust在链接器中花费了11%的调试构建时间。令人惊讶的是,在发布模式下,不包括LTO,它花费了不到1%的时间链接。

使用动态链接,链接的成本被推迟到运行时,并且链接过程的部分可以延迟完成,或者根本不完成,因为实际调用了函数。

我们将四集改编成一部电视剧,原本应该是为了加快TiKV的编译速度,但到目前为止,我们主要是对铁锈的编译时间提出了很深的抱怨。

在决定编译器的编译时间以及由此产生的其输出的运行时性能时,涉及到很多因素。优化编译器终究会终止,而且它们生成的代码速度如此之快,这简直是个奇迹。对于人类来说,预测如何组织他们的代码以找到编译时间和运行时间的正确平衡几乎是不可能的。

编译单元的大小和组织对编译时间有很大的影响,在Rust中很难控制编译单元的大小,很难创建能够并行构建的编译单元。内部编译器并行性目前不能弥补编译单元之间并行性的损失。

多种因素导致Rust的构建-测试周期不佳,包括泛型模型、链接需求和编译器体系结构。

在本系列的下一集中,我们将做一个实验来说明Rust中动态和静态分派之间的权衡。

很多人为这个博客系列提供了帮助。特别感谢Ted Mielczarek的真知灼见,以及Calvin Weng的校对和编辑。