Rust编译缓慢的根由在于语言的设计。
我的意思并非是此乃Rust语言的设计目标。正如语言设计者们相互争论时经常说的那样,编程语言的设计总是充满了各种权衡。其中最主要的权衡就是:运行时性能和编译时性能。而Rust团队几乎总是选择运行时而非编译时。
因此,Rust编译时间很慢。这有点让人恼火,因为Rust在其他方面的表现都非常好,唯独Rust编译时间却表现如此糟糕。
Rust与TiKV的编译时冒险:第1集
在PingCAP,我们基于Rust开发了分布式存储系统TiKV。然而它的编译速度慢到足以让公司里的许多人不愿使用Rust。我最近花了一些时间,与TiKV团队及其社区中的其他几人一起调研了TiKV编译时间缓慢的问题。
通过这一系列博文,我将会讨论在这个过程中的收获:
为什么Rust编译那么慢,或者说让人感觉那么慢;Rust的发展如何造就了编译时间的缓慢;编译时用例;我们测量过的,以及想要测量但还没有或者不知道如何测量的项目;改善编译时间的一些思路;事实上未能改善编译时间的思路;TiKV编译时间的历史演进;有关如何组织Rust项目可加速编译的建议;最近和未来,上游将对编译时间的改进。
PingCAP的阴影:TiKV编译次数“余额不足”
在PingCAP,我的同事用Rust写TiKV。它是我们的分布式数据库TiDB的存储节点。采用这样的架构,是因为他们希望该系统中作为最重要的节点,能被构造得快速且可靠,至少是在一个最大程度的合理范围内(译注:通常情况下人们认为快和可靠是很难同时做到的,人们只能在设计/构造的时候做出权衡。选择Rust是为了尽可能让TiKV能够在尽可能合理的情况下去提高它的速度和可靠性)。
这是一个很棒的决定,并且团队内大多数人对此都非常满意。
但是许多人抱怨构建的时间太长。有时,在开发模式下完全重新构建需要花费15分钟,而在发布模式则需要30分钟。对于大型系统项目的开发者而言,这看上去可能并不那么糟糕。但是它与许多开发者从现代的开发环境中期望得到的速度相比则慢了很多。TiKV是一个相当巨大的代码库,它拥有万行Rust代码。相比之下,Rust自身包含超过万行Rust代码,而Servo包含万行(请参阅此处的完整行数统计)。
TiDB中的其他节点是用Go编写的,当然,Go与Rust有不同的优点和缺点。PingCAP的一些Go开发人员对于不得不等待Rust组件的构建而表示不满。因为他们习惯于快速的构建-测试迭代。
在Go开发人员忙碌工作的同时,Rust开发人员却在编译时间休息(喝咖啡、喝茶、抽烟,或者诉苦)。Rust开发人员有多余的时间来跨越内心的“阴影(译注:据说,TiKV一天只有24次编译机会,用一次少一次)。
概览:TiKV编译时冒险历程
本系列的第一篇文章只是关于Rust在编译时间方面的历史演进。因为在我们深入研究TiKV编译时间的具体技术细节之前,可能需要更多的篇章。所以,这里先放一个漂亮的图表,无需多言。
TiKV的Rust编译时间
造就编译时间缓慢的Rust设计
Rust编译缓慢的根由在于语言的设计。
我的意思并非是此乃Rust语言的设计目标。正如语言设计者们相互争论时经常说的那样,编程语言的设计总是充满了各种权衡。其中最主要的权衡就是:运行时性能和编译时性能。而Rust团队几乎总是选择运行时而非编译时。
刻意的运行时/编译时权衡不是Rust编译时间差劲的唯一原因,但这是一个大问题。还有一些语言设计对运行时性能并不是至关重要,但却意外地有损于编译时性能。Rust编译器的实现方式也抑制了编译时性能。
所以,Rust编译时间的差劲,既是刻意为之的造就,又有出于设计之外的原因。尽管编译器的改善、设计模式和语言的发展可能会缓解这些问题,但这些问题大多无法得到解决。还有一些偶然的编译器架构原因导致了Rust的编译时间很慢,这些需要通过大量的工程时间和精力来修复。
如果迅速地编译不是Rust的核心设计原则,那么Rust的核心设计原则是什么呢?下面列出几个核心设计原则:
实用性(Practicality):它应该是一种可以在现实世界中使用的语言;务实(Pragmatism):它应该是符合人性化体验,并且能与现有系统方便集成的语言;内存安全性(Memory-safety):它必须加强内存安全,不允许出现段错误和其他类似的内存访问违规操作;高性能(Performance):它必须拥有能和C++比肩的性能;高并发(Concurrency):它必须为编写并发代码提供现代化的解决方案。但这并不是说设计者没有为编译速度做任何考虑。例如,对于编译Rust代码所要做的任何分析,团队都试图确保合理的算法复杂度。然而,Rust的设计历史也是其一步步陷入糟糕的编译时性能沼泽的历史。
讲故事的时间到了。
Rust的自举
我不记得自己是什么时候才开始意识到,Rust糟糕的编译时间其实是该语言的一个战略问题。在面对未来底层编程语言的竞争时可能会是一个致命的错误。在最初的几年里,我几乎完全是对Rust编译器进行Hacking(非常规暴力测试),我并不太关心编译时间的问题,我也不认为其他大多数同事会太关心该问题。我印象中大部分时间Rust编译时总是很糟糕,但不管怎样,我能处理好。
针对Rust编译器工作的时候,我通常都会在计算机上至少保留三份存储库副本,在其他所有的编译器都在构建和测试时,我就会Hacking其中的一份。我会开始构建Workspace1,切换终端,记住在Workspace2发生了什么,临时做一下修改,然后再开始构建Workspace2,切换终端,等等。整个流程比较零碎且经常切换上下文。
这(可能)也是其他Rust开发者的日常。我现在对TiKV也经常在做类似的Hacking测试。
那么,从历史上看,Rust编译时间有多糟糕呢?这里有一个简单的统计表,可以看到Rust的自举(Self-Hosting)时间在过去几年里发生了怎样的变化,也就是使用Rust来构建它自己的时间。出于各种原因,Rust构建自己不能直接与Rust构建其他项目相比,但我认为这能说明一些问题。
首个Rust编译器叫做rustboot,始于年,是用OCaml编写的,它最终目的是被用于构建第二个由Rust实现的编译器rustc,并由此开启了Rust自举的历程。除了基于Rust编写之外,rustc还使用了LLVM作为后端来生成机器代码,来代替之前rustboot的手写x86代码生成器。
Rust需要自举,那样就可以作为一种“自产自销(Dog-Fooding)”的语言。使用Rust编写编译器意味着Rust的作者们需要在语言设计过程的早期,使用自己的语言来编写实用的软件。在实现自举的过程中让Rust变成一种实用的语言。
Rust第一次自举构建是在年4月20日。该过程总共花了一个小时,这个编译时间对当时而言,很漫长,甚至还觉得有些可笑。
最初那个超级慢的自举程序慢的有些反常,在于其包含了糟糕的代码生成和其他容易修复的早期错误(可能,我记不清了)。rustc的性能很快得到了改善,Graydon很快就抛弃了旧的rustboot编译器,因为没有足够的人力和动力来维护两套实现。
在年6月首次发布的11个月之后,Rust漫长而艰难的编译时代就此开始了。
注意
我本想在这里分享一些有历史意义的自举时间,但在经历了数小时,以及试图从年开始构建Rust修订版的障碍之后,我终于放弃了,决定在没有它们的情况下发布这篇文章。作为补充,这里作一个类比:
兔子飞奔几米(7):rustboot构建Rust的时间;仓鼠狂奔一公里(49):在rustboot退役后使用rustc构建Rust的时间;树獭移动一万米():在年构建rustc所需的时间。反正,几个月前我构建Rust的时候,花了五个小时。
Rust语言开发者们已经适应了Rust糟糕的自举时间,并且在Rust的关键早期设计阶段未能识别或处理糟糕编译时间问题的严重性。
(非)良性循环
在Rust项目中,我们喜欢能够增强自身基础的流程。无论是作为语言还是社区,这都是Rust取得成功的关键之一。
一个明显非常成功的例子就是Servo。Servo是一个基于Rust构建的Web浏览器,并且Rust也是为了构建Servo而诞生。Rust和Servo是姊妹项目。它们是由同一个(初始)团队,在(大致)同一时间创造的,并同时进化。不只是为了创造Servo而创建Rust,而且Servo也是为了解Rust的设计而构建的。
这两个项目最初的几年都非常困难,两个项目都是并行发展的。此处非常适合用忒修斯之船做比喻——我们不断地重建Rust,以便在Sevro的海洋中畅行。毫无疑问,使用Rust构建Servo的经验,来构建Rust语言本身,直接促进了很多好的决定,使得Rust成为了实用的语言。
这里有一些关于Servo-Rust反馈回路的例子:
为了自动生成HTML解析器,实现了带标签的break和continue。在分析了Servo内闭包使用情况之后实现了,所有权闭包(Ownedclosures)。外部函数调用曾经被认为是安全的。这部分变化(改为了Unsafe)得益于Servo的经验。从绿色线程迁移到本地线程,也是由构建Sevro、观察Servo中SpiderMonkey集成的FFI开销以及剖析“hotsplits”的经验所决定的,其中绿色线程堆栈需要扩展和收缩。Rust和Servo的共同发展创造了一个良性循环,使这两个项目蓬勃发展。今天,Servo组件被深度集成到火狐(Firefox)中,确保在火狐存活的时候,Rust不会死去。
任务完成了。
前面提到的早期自举对Rust的设计同样至关重要,使得Rust成为构建Rust编译器的优秀语言。同样,Rust和WebAssembly是在密切合作下开发的(我与Emscripten的作者,Cranelift的作者并排工作了好几年),这使得WASM成为了一个运行Rust的优秀平台,而Rust也非常适合WASM。
遗憾的是,没有这样的增强来缩短Rust编译时间。事实可能正好相反——Rust越是被认为是一种快速语言,它成为最快的语言就越重要。而且,Rust的开发人员越习惯于跨多个分支开发他们的Rust项目,在构建之间切换上下文,就越不需要考虑编译时间。
直到年Rust1.0发布并开始得到更广泛的应用后,这种情况才真正有所改变。
多年来,Rust在糟糕的编译时间的“温水中”被慢慢“烹煮”,当意识到它已经变得多么糟糕时,已为时已晚。已经1.0了,那些(设计)决策早已被锁定了。
这一节包含了太多令人厌倦的隐喻,抱歉了。
运行时优先于编译时的早期决策
如果是Rust设计导致了糟糕的编译时间,那么这些设计具体又是什么呢?我会在这里简要地描述一些。本系列的下一集将会更加深入。有些在编译时的影响比其他的更大,但是我断言,所有这些都比其他的设计耗费更多的编译时间。
现在回想起来,我不禁会想,“当然,Rust必须有这些特性”。确实,如果没有这些特性,Rust将会是另一门完全不同的语言。然而,语言设计是折衷的,这些并不是注定要成Rust的部分。
借用(Borrowing)——Rust的典型功能。其复杂的指针分析以编译时的花费来换取运行时安全。单态化(Monomorphization)——Rust将每个泛型实例转换为各自的机器代码,从而导致代码膨胀并增加了编译时间。栈展开(Stackunwinding)——不可恢复异常发生后,栈展开向后遍历调用栈并运行清理代码。它需要大量的编译时登记(book-keeping)和代码生成。构建脚本(Buildscripts)——构建脚本允许在编译时运行任意代码,并引入它们自己需要编译的依赖项。它们未知的副作用和未知的输入输出限制了工具对它们的假设,例如限制了缓存的可能。宏(Macros)——宏需要多次遍历才能展开,展开得到的隐藏代码量惊人,并对部分解析施加限制。过程宏与构建脚本类似,具有负面影响。LLVM后端(LLVMbackend)——LLVM产生良好的机器代码,但编译相对较慢。过于依赖LLVM优化器(RelyingtoomuchontheLLVMoptimizer)——Rust以生成大量LLVMIR并让LLVM对其进行优化而闻名。单态化则会加剧这种情况。拆分编译器/软件包管理器(Split