Rust 编译模型之殇

做者介绍:html

Brian Anderson 是 Rust 编程语言及其姊妹项目 Servo Web 浏览器的共同创始人之一。他目前在 PingCAP 担任高级数据库工程师。git

感谢 Rust 中文社区翻译小组对本文翻译及审校上的贡献:github

  • 翻译:张汉东、黄珏珅
  • 审校 :吴聪

Rust 编译缓慢的根由在于语言的设计。web

个人意思并不是是此乃 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 是一个至关巨大的代码库,它拥有 200 万行 Rust 代码。相比之下,Rust 自身包含超过 300 万行 Rust 代码,而 Servo 包含 270 万行(请参阅 此处的完整行数统计)。浏览器

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 其中的一份。我会开始构建 Workspace 1,切换终端,记住在 Workspace 2 发生了什么,临时作一下修改,而后再开始构建 Workspace 2,切换终端,等等。整个流程比较零碎且常常切换上下文。

这(可能)也是其余 Rust 开发者的平常。我如今对 TiKV 也常常在作相似的 Hacking 测试。

那么,从历史上看,Rust 编译时间有多糟糕呢?这里有一个简单的统计表,能够看到 Rust 的自举(Self-Hosting)时间在过去几年里发生了怎样的变化,也就是使用 Rust 来构建它本身的时间。出于各类缘由,Rust 构建本身不能直接与 Rust 构建其余项目相比,但我认为这能说明一些问题。

首个 Rust 编译器 叫作 rustboot,始于 2010 年,是用 OCaml 编写的,它最终目的是被用于构建第二个由 Rust 实现的编译器 rustc,并由此开启了 Rust 自举的历程。除了基于 Rust 编写以外,rustc 还使用了 LLVM 做为后端来生成机器代码,来代替以前 rustboot 的手写 x86 代码生成器。

Rust 须要自举,那样就能够做为一种“自产自销(Dog-Fooding)”的语言。使用 Rust 编写编译器意味着 Rust 的做者们须要在语言设计过程的早期,使用本身的语言来编写实用的软件。在实现自举的过程当中让 Rust 变成一种实用的语言。

Rust 第一次自举构建是在 2011 年 4 月 20 日。该过程总共花了 一个小时,这个编译时间对当时而言,很漫长,甚至还以为有些好笑。

最初那个超级慢的自举程序慢的有些反常,在于其包含了糟糕的代码生成和其余容易修复的早期错误(可能,我记不清了)。rustc 的性能很快获得了改善,Graydon 很快就 抛弃了旧的 rustboot 编译器 ,由于没有足够的人力和动力来维护两套实现。

在 2010 年 6 月首次发布的 11 个月以后,Rust 漫长而艰难的编译时代就此开始了。

注意

我本想在这里分享一些有历史意义的自举时间,但在经历了数小时,以及试图从2011年开始构建 Rust 修订版的障碍以后,我终于放弃了,决定在没有它们的状况下发布这篇文章。做为补充,这里做一个类比:

  • 兔子飞奔几米(7):rustboot 构建 Rust 的时间;
  • 仓鼠狂奔一千米(49):在 rustboot 退役后使用 rustc 构建 Rust 的时间;
  • 树獭移动一万米(188):在 2020 年构建 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 反馈回路的例子:

Rust 和 Servo 的共同发展创造了一个 良性循环 ,使这两个项目蓬勃发展。今天,Servo 组件被深度集成到火狐(Firefox)中,确保在火狐存活的时候,Rust 不会死去。

任务完成了。

前面提到的早期自举对 Rust 的设计一样相当重要,使得 Rust 成为构建 Rust 编译器的优秀语言。一样,Rust 和 WebAssembly 是在密切合做下开发的(我与 Emscripten 的做者,Cranelift 的做者并排工做了好几年),这使得 WASM 成为了一个运行 Rust 的优秀平台,而 Rust 也很是适合 WASM。

遗憾的是,没有这样的加强来缩短 Rust 编译时间。事实可能正好相反——Rust 越是被认为是一种快速语言,它成为最快的语言就越重要。并且,Rust 的开发人员越习惯于跨多个分支开发他们的 Rust 项目,在构建之间切换上下文,就越不须要考虑编译时间。

直到 2015 年 Rust 1.0 发布并开始获得更普遍的应用后,这种状况才真正有所改变。

多年来,Rust 在糟糕的编译时间的“温水中”被慢慢“烹煮”,当意识到它已经变得多么糟糕时,已为时已晚。已经 1.0 了,那些(设计)决策早已被锁定了。

这一节包含了太多使人厌倦的隐喻,抱歉了。

运行时优先于编译时的早期决策

若是是 Rust 设计致使了糟糕的编译时间,那么这些设计具体又是什么呢?我会在这里简要地描述一些。本系列的下一集将会更加深刻。有些在编译时的影响比其余的更大,可是我断言,全部这些都比其余的设计耗费更多的编译时间。

如今回想起来,我不由会想,“固然,Rust 必须有这些特性”。确实,若是没有这些特性,Rust 将会是另外一门彻底不一样的语言。然而,语言设计是折衷的,这些并非注定要成 Rust 的部分。

  • 借用(Borrowing)——Rust 的典型功能。其复杂的指针分析以编译时的花费来换取运行时安全。
  • 单态化(Monomorphization)——Rust 将每一个泛型实例转换为各自的机器代码,从而致使代码膨胀并增长了编译时间。
  • 栈展开(Stack unwinding)——不可恢复异常发生后,栈展开向后遍历调用栈并运行清理代码。它须要大量的编译时登记(book-keeping)和代码生成。
  • 构建脚本(Build scripts)——构建脚本容许在编译时运行任意代码,并引入它们本身须要编译的依赖项。它们未知的反作用和未知的输入输出限制了工具对它们的假设,例如限制了缓存的可能。
  • 宏(Macros)——宏须要屡次遍历才能展开,展开获得的隐藏代码量惊人,并对部分解析施加限制。过程宏与构建脚本相似,具备负面影响。
  • LLVM 后端(LLVM backend)——LLVM 产生良好的机器代码,但编译相对较慢。
  • 过于依赖LLVM优化器(Relying too much on the LLVM optimizer)——Rust 以生成大量 LLVM IR 并让 LLVM 对其进行优化而闻名。单态化则会加重这种状况。
  • 拆分编译器/软件包管理器(Split compiler/package manager)——尽管对于语言来讲,将包管理器与编译器分开是很正常的,可是在 Rust 中,至少这会致使 cargo 和 rustc 同时携带关于整个编译流水线的不完善和冗余的信息。当流水线的更多部分被短路以便提升效率时,则须要在编译器实例之间传输更多的元数据。这主要是经过文件系统进行传输,会产生开销。
  • 每一个编译单元的代码生成(Per-compilation-unit code-generation)——rustc 每次编译单包(crate)时都会生成机器码,可是它不须要这样作,由于大多数 Rust 项目都是静态连接的,直到最后一个连接步骤才须要机器码。能够经过彻底分离分析和代码生成来提升效率。
  • 单线程的编译器(Single-threaded compiler)——理想状况下,整个编译过程都将占用全部 CPU 。然而,Rust 并不是如此。因为原始编译器是单线程的,所以该语言对并行编译不够友好。目前正在努力使编译器并行化,但它可能永远不会使用全部 CPU 核心。
  • trait 一致性(trait coherence)——Rust 的 trait(特质)须要遵循“一致性(conherence)”,这使得开发者不可能定义相互冲突的实现。trait 一致性对容许代码驻留的位置施加了限制。这样,很难将 Rust 抽象分解为更小的、易于并行化的编译单元。
  • “亲密”的代码测试(Tests next to code)——Rust 鼓励测试代码与功能代码驻留在同一代码库中。因为 Rust 的编译模型,这须要将该代码编译和连接两次,这份开销很是昂贵,尤为是对于有不少包(crate)的大型项目而言。

改善 Rust 编译时间的最新进展

现状并不是没有改善的但愿。一直有不少工做在努力改善 Rust 的编译时间,但仍有许多途径能够探索。我但愿咱们能持续看到进步。如下是我最近一两年所知道的一些进展。感谢全部为该问题提供帮助的人。

对于未上榜的人员或项目,我须要说一声抱歉。

下集预告

因此多年来,Rust 把本身深深地逼进了一个死角,并且极可能会持续逼进,直到玩完。Rust 的编译时可否从 Rust 自身的运行时成功中获得拯救?TiKV 的构建速度可否让个人管理者满意吗?

在下一集中,咱们将深刻讨论 Rust 语言设计的细节,这些细节会致使它编译缓慢。

继续享受 Rust 吧,朋友们!

鸣谢:

不少人参与了本系列博客。特别感谢 Niko Matsakis、Graydon Hoare 和 Ted Mielczarek 的真知卓见,以及 Calvin Weng 的校对和编辑。

💡 有兴趣可点击查看 英文原版

相关文章
相关标签/搜索