铁锈公司庞大的编译单位

2020-06-23 12:19:22

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

最近,我们正在探索铁锈的设计是如何阻碍快速编译的。在本系列的上一篇文章中,我们讨论了实现泛型所需的编译时困难的权衡。

编译单元是语言编译器运行的基本工作单元。在C和C++中,编译单元是源文件。在Java中,它是一个源文件。在Rust中,编译单元是一个板条箱,由许多文件组成。

编译单元的大小需要进行大量的权衡。与较小的编译单元相比,较大的编译单元需要更长的分析、转换和优化时间。通常,当对单个编译单元进行更改时,必须重新编译整个编译单元。

更多、更小的板条箱改善了编译时间(如果不是总编译时间)的感觉,因为单个更改可能会强制重新编译较少的项目。这有利于“部分重新编译”用例。但是,由于各种因素,一个有更多板条箱的项目可能会在完全重新编译上做更多的工作,我将在本文的末尾总结这一点。

生锈的板条箱不一定要很大,但有很多因素促使它们变得很大。第一个简单的问题是向铁锈项目添加新板条箱与向板条箱添加新模块的相对复杂性。除非特别注意抽象的边界,否则新的铁锈项目往往会变成巨石。

在机箱内,没有对模块相互依赖的基本限制,尽管有一些语言功能允许在机箱内隐藏一定数量的信息。将模块共存于同一机箱的最大优点和风险在于它们可能是相互依赖的,两个模块都依赖于从另一个导出的名称。这里的示例与TiKV中遇到的许多示例类似,在TiKV中,引擎导入(“使用”)network::message,network导入storage::Engine。

Mod storage{use network::message;pub struct Engine;Impl Engine{pub FN Handle_Message(&;self,msg:message)->;result<;()>;{.}mod network{use storage::engine;pub enum message{.}struct Server{Engine:Engine,}Impl Server{fn Handle_Message(&;self,msg:message)->;result<;()。{.。self.Engine.handleMessage(消息)?;.}

相互依赖的模块对于降低认知复杂性很有用,因为它们将代码分解成更小的单元。尽管它们是欺骗性的,但它们是抽象的边界:它们并不是真正独立的,也不能简单地进一步简化为单独的板条箱。

这是因为板条箱之间的依赖关系必须形成有向无环图(DAG);它们不支持相互依赖。

板条箱锈迹斑驳,主要是由于型式检查和结构复杂的根本原因。如果板条箱允许相互依赖,那么它们将不再是自包含的编译单元。

在准备这篇博客的过程中,我问了几个人,他们是否能回忆起铁锈板条箱必须组成DAG的原因,格雷登给出了一个典型的全面而权威的答案:

Graydon:禁止跨机箱的定义之间的相互递归,允许明显的确定的自下而上的构建计划,而无需进行一些定点迭代或将声明与定义分开,并允许需要遍历完整定义的阶段(如类型检查)逐个进行(支持一定程度的增量/并行性)。

格雷登:(一旦你知道你已经看到了递归类型中的所有循环,你就可以检查它的有限性,然后停止在任何盒装变量上扩展它-即使它们跨板条箱-并在那些盒边放一个懒惰/占位符定义;但你需要知道那些变量不会循环回来!)。

Graydon:高阶模块的周期性问题甚至更糟,我在进行早期设计时花了相当多的时间来研究这些模块。我见过的大多数系统都需要在一组相互递归的定义周围画出某种边界才能解析它们,所以箱子看起来就像是一个天然的单元。

Graydon:然后还有版本控制的问题,我认为这个问题在我的脑海中相当沉重(特别是在经历了单调和GIT之后):如果没有按构造的非循环引用,很多版本控制问题就没有实际意义。例如,如果A1.0依赖于B1.0,而B1.0依赖于A2.0,那么为了解决这些依赖关系,您同样需要做一些奇怪的、固定的、可能相当武断的、很难向任何人解释的事情。

格雷登:还要回想一下,我们很早就想要能够进行热代码加载,这意味着就像版本解析、编译或链接一样,如果您必须按照自然的拓扑顺序加载或卸载东西,那么您将真正处于一个简单得多的位置。您可以仅通过引用计数来确定一个板条箱是否仍然有效,不需要计算出循环依赖关系并将它们按某个随机顺序打破,等等。

格雷登:我不确定哪一个(如果有的话)是最主要的担忧。如果我必须猜测,我会说避免了单独编译递归定义和管理代码版本控制的问题。回想一下手册中的语言/板条箱的基本原理:“编译和版本化单位”。这些都是将它们作为独立于模块存在的考虑因素。模块必须是递归的。板条箱,不。因为与“编译和版本化”有关的事情。

格雷登:我不能对此做一个简单的论证,因为我对模块系统还不够聪明--完整的内容已经在德雷耶的论文中进行了阐述,并在这里以较短的幻灯片形式进行了讨论--但我可以说,递归模块可以通过两条可能被认为相等但并不容易确定的途径看到“相同”的不透明类型,我认为部分原因是模块提供的不透明混合,而且你必须部分地看清这一点,这在一定程度上是因为模块提供的不透明混合在一起,而且你必须部分地看清这一事实,这两条路径可能被认为是相等的,但并不容易确定是相等的。我认为,部分原因是模块提供的不透明度混合在一起,而且你必须部分地了解这一事实。所以不管怎样,我认为这可能是进入了“研究”阶段,我应该避开问题空间,使用非循环模块。

尽管受到基本约束的驱动,但板条箱的硬匕首是有用的,原因有很多:它强制执行仔细的抽象,定义并行编译单元,定义基本合理的代码生成单元,并极大地降低语言和编译器的复杂性(即使编译器将来可能走向全程序、需求驱动、编译)。

注意对并行性的强调。机箱DAG是Rust可以访问的编译时并行性的最简单来源。Cargo Today将使用DAG自动将工作划分为并行编译作业。

因此,这是非常可取的铁锈代码被分解成板条箱,形成一个广泛的DAG。

在我的经验中,虽然项目往往在单个板条箱中开始,而不太关注它们的内部依赖图,而且一旦编译时间成为一个问题,他们已经创建了一个意大利面依赖图,很难将其重构为更小的板条箱。

这发生在Servo上,这也是我在TiKV上的经验,在TiKV上,我多次尝试从主程序中提取各种模块,但都以解开内部依赖的长提交序列进行,但都以失败告终。我怀疑避免有问题的巨石是Rust开发人员从经验中学到的东西,但这在大型Rust项目中是重复出现的现象。

由于一种叫做孤儿规则的东西,铁锈的特性系统进一步使使用板条箱作为抽象边界变得具有挑战性。

特征是在Rust中创建抽象的最常用工具。它们是强大的,但就像铁锈的大部分力量一样,它也是有权衡的。

孤立规则有助于维护特征一致性,其存在是为了确保Rust编译器永远不会遇到同一类型的两个特征实现。如果它遇到两个这样的实现,那么它将需要解决冲突,同时确保结果是正确的。

孤立规则本质上说的是,对于任何IMPL,要么必须在当前板条箱中定义特征,要么必须在当前板条箱中定义类型。

这可能会在Rust的抽象之间产生紧密的耦合,阻止分解成板条箱-有时需要大量的仪式、样板和创造力来遵守Rust的一致性规则,同时还保持原则性的抽象边界,感觉不值得付出这样的努力,所以它没有发生。

这个主题值得更多的例子和更有力的论证,但我现在对它没有热情。

Rust的特征所基于的Haskell类型类没有孤儿规则。我不知道这在实践中给Haskell带来了多大的问题。在铁锈设计的时候,它被认为有足够的问题需要纠正。

由于板条箱是编译流水线中的主要并行性单位,理论上希望有一个具有粗略复杂的板条箱的宽板条箱DAG,这样编译器就可以一直使用所有的机器内核。尽管在实践中几乎总是存在瓶颈,其中只有一个编译器实例在单个机箱上运行。

因此,除了Cargos并行板条箱编译之外,rustc本身在单个板条箱上也是并行的。它不是被设计成平行的,所以它的并行性是有限的,来之不易。

今天,rustc中唯一真正的内部并行性是使用codegen单元,通过该单元,rustc在转换过程中自动将一个机箱划分为多个LLVM模块。通过这样做,它可以并行执行代码生成。与机箱一样,Rust codegen-Units也是编译单元,但它是LLVM编译单元。

与增量编译相结合,可以避免重新翻译运行中没有改变的编解码器单元,减少部分重建时间。不幸的是,代码生成单元和增量编译对编译时和运行时性能的影响很难预测:缩短重建时间依赖于rustc成功地将机箱划分为独立的单元,这些单元不太可能在更改时强制彼此重新编译,而且人类应该如何编写代码来帮助rustc完成此任务并不明显;并且任意将机箱划分为代码生成单元会对内联造成任意障碍,从而导致意外的反优化。

编译器的其余工作完全是串行的,不过很快它应该会并行执行一些分析。

受编译单元大小影响的编译属性很大,我已经放弃了尝试连贯地解释它们。下面是其中一些的清单。

内联和优化-内联发生在编译单元级别,而内联是解锁优化的关键,因此较大的编译单元会得到更好的优化。不过,通过链接时间优化(LTO),这个故事变得复杂起来。

优化复杂性--优化往往在代码大小上具有超线性的复杂性,因此较大的编译单元会非线性地增加编译时间。

下游单词化-泛型只有在实例化之后才会被转换,因此即使所有板条箱的大小完全相等以进行并行编译,它们的泛型类型也要到板条箱图的后期阶段才会被转换。这可能会导致“最终”板条箱与其他板条箱相比具有不成比例的平移量。

泛型复制-泛型在实例化它们的板条箱中进行翻译,因此使用相同泛型的板条箱越多,意味着翻译时间越长。

链接时间优化(LTO)-发布版本往往有最后的“链接时间优化”步骤,该步骤跨多个代码单元执行优化,而且非常昂贵。

保存和恢复元数据-Rust每次运行时都需要保存和加载有关每个机箱和每个依赖项的元数据,因此更多的机箱意味着更多的冗余加载。

并行“codegen unit”-rustc可以自动将其LLVM IR拆分成多个编译单元,称为“codegen unit”。它在这方面的有效程度在很大程度上取决于板条箱的内部依赖项是如何组织的,以及编译器理解它们的能力。这可能会导致更快的部分重新编译,但会以优化为代价,因为内联机会会丢失。

编译器-内部并行性-rustc本身的部分是并行的。这种内部并行性有其自身不可预测的瓶颈和与外部构建系统并行性不可预测的交互。

不幸的是,由于所有这些变数,对于任何给定的项目来说,重构成更小的板条箱将会产生什么影响并不明显。由于并行性增加而导致的预期胜利通常会被下游单形化、泛型复制和LTO等其他因素擦除。

在本系列的下一集中,我们将用一些较小的慢编译花絮来结束对铁锈编译速度慢的原因的探索。

很多人为这个博客系列提供了帮助。特别感谢Graydon Hoare的反馈,以及Calvin Weng的校对和编辑。