我们正处于 Rust 编译器内部机制进行重大变革的最后阶段。在过去一年左右的时间里,我们一直在稳步推进改变内部编译器流水线的计划,如下所示
也就是说,我们正在引入程序的一种新的中间表示(IR),我们称之为 MIR:MIR 代表 mid-level IR(中层中间表示),因为它介于现有的 HIR("high-level IR",高层中间表示,大致是一个抽象语法树)和 LLVM("low-level" IR,低层中间表示)之间。以前,编译器中的“翻译”阶段是将完整的 Rust 代码在一个相当大的步骤中转换为类似机器码的 LLVM。但现在,它将在两个阶段完成其工作,中间是一个极大简化的 Rust 版本——MIR。
如果你不是编译器爱好者,这一切可能看起来很神秘,不太可能直接影响到你。但实际上,MIR 是实现 Rust 许多最高优先事项的关键
-
更快的编译时间。我们正在努力使 Rust 的编译过程实现增量式,这样当你重新编译代码时,编译器只会重新计算它必须计算的部分。MIR 从一开始就考虑到了这种用例进行设计,因此即使程序其他部分在此期间发生了变化,我们也更容易保存和重新加载。
MIR 还为编译器中更高效的数据结构和消除冗余工作奠定了基础,这两者都应该全面加快编译速度。
-
更快的执行时间。你可能已经注意到,在新的编译器流水线中,优化出现了两次。这并非偶然:以前,编译器完全依赖 LLVM 进行优化,但有了 MIR,我们可以在到达 LLVM 之前(甚至在代码单态化之前)进行一些特定于 Rust 的优化。Rust 丰富的类型系统应该为超越 LLVM 的优化提供沃土。
此外,MIR 将解决一些长期以来阻碍 Rust 生成代码性能提升的问题,例如“非零化” Drop。
-
更精确的类型检查。如今的 Rust 编译器对借用施加了一些人为限制,这些限制主要源于编译器当前表示程序的方式。MIR 将实现更灵活的借用,这将反过来改善 Rust 的易用性和学习曲线。
除了这些显著的用户层面改进之外,MIR 还为编译器带来了显著的工程效益
-
消除冗余。目前,由于我们将所有 pass 都基于完整的 Rust 语言编写,因此存在相当多的重复。例如,安全分析和生成 LLVM IR 的后端都必须就如何翻译 Drop,或者
match
表达式分支将被测试和执行的精确顺序达成一致(这可能相当复杂)。有了 MIR,所有这些逻辑都集中在 MIR 构建阶段,后续 pass 只需依赖它即可。 -
提升目标。除了更符合 DRY (Don't Repeat Yourself) 原则之外,使用 MIR 也只是简单地更容易,因为它包含的操作集比普通 Rust 语言的基础得多。这种简化使我们能够做许多以前极其复杂的事情。本文将探讨其中一个案例——非零化 Drop——但正如我们稍后将看到的,流水线中已经有很多其他案例了。
毋庸置疑,我们对此感到兴奋,Rust 社区也大力支持使 MIR 成为现实。编译器可以使用 MIR 进行自举并运行其测试套件,并且这些测试必须在每次新的提交时通过。一旦我们能够启用 MIR 并使用 Crater 对整个 crates.io 生态系统进行测试而没有出现回归,我们就会将其默认开启(或者,如果你能原谅一个糟糕(精彩)的双关语,将 MIR 送入轨道)。
这篇博客文章首先概述了 MIR 的设计,展示了 MIR 如何能够抽象掉 Rust 语言的完整细节。接下来,我们探讨 MIR 如何帮助实现非零化 Drop 这一期待已久的优化。如果你读完这篇文章后意犹未尽,可以查阅引入 MIR 的 RFC,或者直接深入到代码中。(编译器爱好者可能对替代方案部分特别感兴趣,其中详细讨论了某些设计选择,例如为什么 MIR 当前不使用 SSA。)
将 Rust 简化为核心部分
MIR 将 Rust 简化为核心部分,移除几乎所有你每天使用的 Rust 语法,例如 for
循环、match
表达式,甚至是方法调用。相反,这些结构会被转换为一小组基本操作。这并不意味着 MIR 是 Rust 的一个子集。正如我们将看到的,许多这些基本操作在真实的 Rust 中是不可用的。这是因为这些基本操作如果被滥用,可能会写出不安全或不期望的程序。
MIR 支持的简单核心语言并不是你想要用来编程的。事实上,它使事情变得几乎痛苦地显式。但如果你想编写类型检查器或生成汇编代码,它就非常棒了,因为你现在只需要处理 MIR 转换后剩下的核心操作。
为了理解我的意思,我们先从简化一段 Rust 代码片段开始。起初,我们只会将 Rust 分解为“更简单的 Rust”,但最终我们将完全脱离 Rust 进入 MIR 代码。
我们的 Rust 示例以这个简单的 for
循环开始,它迭代向量中的所有元素并逐个处理它们
for elem in vec
Rust 本身提供了三种循环:for
循环(如本例);while
和 while let
循环,它们迭代直到满足某个条件;最后是简单的 loop
,它只迭代直到你跳出它。每种循环都封装了一种特定的模式,因此在编写代码时非常有用。但对于 MIR,我们希望将所有这些归约为一个核心概念。
Rust 中的 for
循环通过将一个值转换为迭代器,然后重复调用该迭代器的 next
方法来工作。这意味着我们可以将之前看到的 for
循环重写为一个 while let
循环,如下所示
let mut iterator = vec.into_iter;
while let Some = iterator.next
通过应用这种重写,我们可以消除所有 for
循环,但这仍然留下了多种类型的循环。所以接下来我们可以想象将所有 while let
循环重写为一个简单的 loop
与 match
的组合
let mut iterator = vec.into_iter;
loop
我们已经消除了两个结构(for
循环和 while
循环),但我们可以走得更远。让我们暂时离开循环,看看我们看到的方法调用。在 Rust 中,像 vec.into_iter()
和 iterator.next()
这样的方法调用也是一种语法糖。这些特定的方法是在 Trait 中定义的,Trait 基本上是预定义的接口。例如,into_iter
是 IntoIterator
Trait 中的一个方法。可以转换为迭代器的类型实现了该 Trait 并定义了 into_iter
方法对其的工作方式。类似地,next
在 Iterator
Trait 中定义。当你写像 iterator.next()
这样的方法调用时,Rust 编译器会自动根据 iterator
的类型和作用域内的 Trait 集来确定该方法属于哪个 Trait。但如果我们更喜欢显式地表达,我们可以使用函数调用语法直接调用 Trait 中的方法
// Rather than `vec.into_iter()`, we are calling
// the function `IntoIterator::into_iter`. This is
// exactly equivalent, just more explicit.
let mut iterator = IntoIterator into_iter;
loop
至此,我们已经将小片段中的语言特性集减少了很多:我们现在只使用 loop
循环,并且不使用方法调用。但如果我们将 loop
和 break
转向更基础的东西:goto
,我们可以进一步减少概念集。使用 goto
,我们可以将之前的代码示例转换为类似这样的形式
let mut iterator = IntoIterator into_iter;
loop:
match Iterator next
break:
...
我们将示例分解为更简单的结构方面取得了相当大的进展。我们还没有完全完成,但在继续之前,值得后退一步,做一些观察
一些 MIR 基本操作比它们取代的结构化构造更强大。引入 goto
关键字在某种意义上是一个巨大的简化:它统一并取代了大量的控制流关键字。goto
完全取代了 loop
、break
、continue
,它还允许我们简化 if
和 match
(稍后我们会特别看到关于 match
的更多内容)。然而,这种简化之所以可能,是因为 goto
是一个比 loop
更通用的结构,而且它是我们不希望引入语言本身的,因为我们不希望人们能够写出复杂、难以阅读和跟踪的“意大利面条式代码”。但在 MIR 中拥有这样的结构是可以的,因为我们知道它只会被以特定的方式使用,例如表达一个 loop
或一个 break
。
MIR 构建是类型驱动的。我们看到,像 iterator.next()
这样的所有方法调用都可以被“脱糖”为完全限定的函数调用,如 Iterator::next(&mut iterator)
。然而,只有具备完整的类型信息才能进行这种重写,因为我们必须(例如)知道 iterator
的类型才能确定 next
方法来自哪个 Trait。通常,只有在类型检查完成后才能构建 MIR。
MIR 使所有类型都显式化。由于我们在主要类型检查完成后构建 MIR,MIR 可以包含完整的类型信息。这对于像借用检查器这样的分析非常有用,这些分析需要局部变量的类型等等才能进行操作,但这也意味着我们可以定期运行类型检查器作为一种健全性检查,以确保 MIR 的格式正确。
控制流图
在上一节中,我展示了将 Rust 程序逐步“解构”成类似于 MIR 的形式,但我们仍然停留在文本形式。然而,在编译器内部,我们从不“解析” MIR,也没有它的文本形式。相反,我们将 MIR 表示为编码控制流图 (CFG) 的一组数据结构。如果你曾经使用过流程图,那么控制流图的概念就会相当熟悉。它是一种程序表示,以非常清晰的方式暴露了底层的控制流。
控制流图由一组由边连接的基本块构成。每个基本块包含一系列语句,并以一个终止器结尾,终止器定义了块如何相互连接。在使用控制流图时,循环简单地表现为图中的一个循环,而 break
关键字则转换为离开该循环的路径。
这是上一节中正在运行的示例,表示为控制流图
构建控制流图通常是进行任何流敏感分析的第一步。它也与 LLVM IR 自然契合,LLVM IR 也被组织成控制流图形式。MIR 和 LLVM 之间相当密切的对应关系使得翻译变得非常直接。它还消除了一个错误来源:在今天的编译器中,用于分析的控制流图与 LLVM 构建生成的控制流图不一定相同,这可能导致接受了不正确的程序。
简化 match 表达式
上一节的示例展示了如何将 Rust 的所有循环有效地简化为 MIR 中的 goto,以及如何移除方法调用而支持对 Trait 函数的显式调用。但它忽略了一个细节:match 表达式。
MIR 的一个重要目标是将 match 表达式简化为非常小的一组核心操作。我们通过引入主语言不包含的两个结构来实现这一点:开关(switches)和变体向下转型(variant downcasts)。像 goto
一样,这些是我们不希望在基础语言中拥有的东西,因为它们可能被滥用以编写糟糕的代码;但在 MIR 中它们完全没问题。
通过示例来解释 match 处理可能最容易。让我们考虑上一节中看到的 match
表达式
match Iterator next
在这里,调用 next
的结果是 Option<T>
类型,其中 T
是元素的类型。因此,match
表达式做了两件事:首先,它确定这个 Option
是带有 Some
变体的值还是 None
变体。然后,在 Some
变体的情况下,它提取出值 elem
。
在正常的 Rust 中,这两项操作是故意耦合的,因为我们不希望你在 Option
具有 Some
变体之前读取其数据(否则就相当于 C 联合体,其中读取不进行正确性检查)。
然而,在 MIR 中,我们将变体的检查与数据的提取分开了。我将首先以一种伪代码形式给出 MIR 的等效内容,因为这些操作没有实际的 Rust 语法
loop:
// Put the value we are matching on into a temporary variable.
let tmp = Iterator next;
// Next, we "switch" on the value to determine which it has.
switch tmp
break:
....
当然,实际的 MIR 是基于控制流图的,所以它看起来像这样
显式的 Drop 和 Panic
现在我们已经看到了如何从 MIR 中移除循环、方法调用和 match,并用更简单的等价物替换它们。但仍然有一个关键领域可以简化。有趣的是,这是今天代码中几乎不可见地发生的事情:在 panic 的情况下运行析构函数和进行清理。
在我们之前看到的示例控制流图中,我们假设所有代码都能成功执行。但实际上,我们无法知道这一点。例如,我们看到的任何函数调用都可能 panic,这将触发展开(unwinding)的开始。当展开栈时,我们必须运行找到的任何值的析构函数。精确地找出在每个 panic 点应该释放哪些局部变量实际上相当复杂,所以我们希望在 MIR 中明确说明这一点:这样,MIR 构建阶段必须弄清楚它,但后续的 pass 只需依赖 MIR 即可。
我们这样做的方式是双重的。首先,我们将Drop 在 MIR 中显式化。Drop 是我们用来表示在值上运行析构函数的术语。在 MIR 中,无论控制流经过一个值应该被 Drop 的点,我们都会添加一个特殊的 drop(...)
操作。其次,我们在控制流图中添加显式的边来表示潜在的 panic 以及我们必须进行的清理。
我们先看看显式的 Drop。如果你还记得,我们最初的例子只是一个 for 循环
for elem in vec
然后我们将这个 for 循环转换为显式调用 IntoIterator::into_iter(vec)
,生成一个值 iterator
,从中提取各种元素。嗯,这个值 iterator
实际上有一个析构函数,并且需要被释放(在这种情况下,它的工作是释放向量 vec
使用的内存;由于我们已经迭代完向量,这些内存不再需要了)。使用 drop
操作,我们可以调整我们的 MIR 控制流图,显式地显示 iterator
值在何处被释放。看看新的图,特别是在找到 None
变体时发生了什么
在这里我们看到,当循环正常退出时,迭代器完成工作后会被 Drop。但如果发生 panic 怎么办?毕竟,我们在这里看到的任何函数调用都可能 panic。为了处理这种情况,我们在图中引入了 panic 边
在这里,我们在每个函数调用上引入了 panic
边。通过查看这些边,你可以看到,如果调用 next
或 process
发生 panic,那么我们会 Drop 变量 iterator
;但如果调用 into_iter
发生 panic,则 iterator
尚未初始化,因此不应该被 Drop。
一个有趣的细节:我们最近批准了RFC 1513,该 RFC 允许应用程序指定 panic 应被视为调用 abort
,而不是触发展开。如果程序以“panic 即终止”的语义进行编译,那么这也会反映在 MIR 中,因为 panic 边和处理将简单地从图中消失。
在 Play 上查看 MIR
至此,我们已经将我们的示例简化到相当接近实际 MIR 的样子。如果你想亲自看看,可以在 play.rust-lang.org 上查看我们的示例的 MIR。只需点击此链接,然后按下顶部的“MIR”按钮。你会看到几个函数的 MIR,所以你需要搜索才能找到 example
函数的开头。(我不会在这里复制输出,因为它相当长。)在编译器本身中,你也可以启用 graphviz 输出。
Drop 和栈上的标记
现在我想你已经对 MIR 如何表示简化的 Rust 有了感觉。让我们看一个 MIR 将允许我们实现 Rust 中一个期待已久的改进的例子:转向非零化 Drop。这是关于我们如何检测析构函数何时必须执行的变化,特别是在值只被有时移动的情况下。这项更改在RFC 320中被提出(并获批准),但尚未实现。这主要是因为在 pre-MIR 编译器上这样做在架构上具有挑战性。
为了更好地理解这个特性是什么,考虑这个函数 send_if
,它有条件地将一个向量发送到另一个线程
关键点,正如注释所示,是我们无法静态地知道是否应该释放 data
。这取决于我们是否进入了 if
。
为了处理这种情况,今天的编译器使用归零化(zeroing)。或者更准确地说,是覆盖(overwriting)。这意味着,如果 data
的所有权被移动,我们将用一个特定的、独特的位模式覆盖 data
的栈槽,这个位模式不是一个有效的指针(我们以前使用零,所以通常称之为归零化,但后来改用了不同的东西)。然后,当需要释放 data
时,我们会检查它是否被覆盖。(顺便提一下,这大致与等效的 C++ 代码所做的事情相同。)
但我们希望做得比这更好。我们想做的是在栈上使用布尔标记来告诉我们需要释放什么。所以它可能看起来像这样
当然,你无法在 Rust 中编写这样的代码。在 if
之后,你不能访问变量 data
,因为它可能已经被移动了。(这是另一个例子,说明我们可以在 MIR 中做一些不希望在完整的 Rust 中允许的事情。)
像这样使用布尔栈标记有很多优点。首先,它更高效:我们只需要设置一个标记,而不是覆盖整个向量。而且,它更容易优化:想象一下,通过内联或其他方式,编译器能够确定 some_condition
将始终为真。在这种情况下,标准的常量传播技术会告诉我们 data_is_owned
始终为假,因此我们可以直接优化掉整个 mem::drop
调用,从而生成更紧凑的代码。有关更多详细信息,请参见RFC 320。
然而,在当前编译器架构上正确实现此优化相当困难。有了 MIR,它变得相对直接。MIR 控制流图显式地告诉我们值将在何处被 Drop 以及何时 Drop。当 MIR 首次生成时,我们假设 Drop 已移动的数据没有效果——大致类似于当前的覆盖语义。所以这意味着 send_if
的 MIR 可能看起来像这样(为简单起见,我将忽略展开边)。
然后,我们可以通过识别 data
被移动或 Drop 的每个位置,并检查这些位置中的任何一个是否可以到达另一个,来转换这个图。在这种情况下,send_to_other_thread(data)
块可以到达 drop(data)
。这表明我们需要引入一个标记,这可以相当机械地完成
最后,我们可以应用标准的编译器技术来优化这个标记(但在此情况下,标记是必需的,因此最终结果将是相同的)。
为了强调 MIR 的有用性,让我们考虑 send_if
函数的一个变体,称为 send_if2
。这个变体检查某个条件,如果满足,则将数据发送到另一个线程进行处理。否则,它在本地处理
这将生成如下所示的 MIR
像之前一样,我们仍然在所有情况下生成 data
的 Drop,至少是开始时。由于仍然存在稍后可以到达 Drop 的移动,我们现在可以引入一个栈标记变量,就像之前一样
但在这种情况下,如果我们应用常量传播,我们可以看到在每次测试 data_is_owned
的点上,我们静态地知道它是真还是假,这将允许我们移除栈标记并优化上述图,生成这个结果
结论
我预计 MIR 的使用将在编译器能够实现的功能方面带来重大变革。通过将语言简化为一组核心基本操作,MIR 为许多语言改进打开了大门。本文我们探讨了 Drop 标记。另一个例子是改进 Rust 的生命周期系统,使其能够利用控制流图提高精度。但我认为还会有许多我们尚未预见的应用。事实上,已经出现了一个这样的例子:Scott Olson 在开发 MIR 解释器 miri 方面取得了巨大进展,他正在探索的技术很可能成为编译器自身更强大的常量求值器的基础。
编译器向 MIR 的过渡尚未完成,但已经非常接近了。特别感谢 Simonas Kazlauskas (nagisa) 和 Eduard-Mihai Burtescu (eddyb),他们两人对推动 MIR 接近终点线产生了尤其大的影响。我们最初的目标是将我们的 LLVM 生成完全从 MIR 操作。将借用检查器移植的工作也在进行中。之后,我预计我们将移植编译器中目前使用 HIR 的其他部分。如果你有兴趣贡献,请查找标记为 A-mir
的 issue,或者在 IRC 上的 #rustc
频道中询问。