我们正处于 Rust 编译器内部的一次重大变革的最后阶段。在过去的一年左右的时间里,我们一直在稳步推进一项计划,以改变我们的内部编译器管道,如下所示
也就是说,我们正在引入一个新的程序中间表示 (IR),我们称之为 **MIR**:MIR 代表 *中级* IR,因为 MIR 位于现有的 HIR(“高级 IR”,大致相当于一个 抽象语法树)和 LLVM(“低级” IR)之间。以前,编译器中的“翻译”阶段会将完整的 Rust 代码转换为类似机器码的 LLVM 代码,这只是一个相当大的步骤。但现在,它将分两个阶段完成工作,中间有一个简化版的 Rust 代码——MIR。
如果你不是编译器爱好者,这一切可能看起来很深奥,不太可能直接影响到你。但实际上,**MIR 是实现我们对 Rust 的许多最高优先级目标的关键**
-
**更快的编译时间**。我们正在努力使 Rust 的编译 *增量式*,这样当你 *重新* 编译代码时,编译器只重新计算它必须计算的部分。MIR 从一开始就被设计为考虑这种用例,因此我们更容易保存和重新加载,即使程序的其他部分在此期间发生了变化。
MIR 还为编译器中更有效的数据结构和冗余工作的消除提供了基础,这两者都应该在整个过程中加快编译速度。
-
**更快的执行时间**。你可能已经注意到,在新编译器管道中,*优化* 出现了两次。这不是偶然的:以前,编译器完全依赖 LLVM 来执行优化,但有了 MIR,我们可以在到达 LLVM 之前执行一些 *特定于 Rust 的* 优化——或者,更确切地说,在代码单态化之前。Rust 丰富的类型系统应该为超越 LLVM 的优化提供肥沃的土壤。
此外,MIR 将打开一些长期以来对 Rust 生成的代码的性能改进,例如 “非零”释放。
-
**更精确的类型检查**。今天的 Rust 编译器对 *借用* 施加了一些人为的限制,这些限制主要源于编译器当前表示程序的方式。MIR 将使 更灵活的借用 成为可能,这反过来将改善 Rust 的人体工程学和学习曲线。
除了这些面向用户的重大改进之外,**MIR 还为编译器带来了重大的工程优势**
-
**消除冗余。**目前,由于我们用完整的 Rust 语言编写了所有代码,因此存在相当多的重复。例如,安全分析和生成 LLVM IR 的后端必须就如何翻译释放达成一致,或者
match
表达式分支将被测试和执行的精确顺序(这可能非常复杂)。有了 MIR,所有这些逻辑都集中在 MIR 构造中,后面的代码只需要依赖它。 -
**提高雄心壮志。**除了更 DRY 之外,使用 MIR 也更 *容易*,因为它包含比普通 Rust 更原始的操作集。这种简化使我们能够做很多以前非常复杂的事情。我们将在本文中介绍其中一个案例——非零释放——但正如我们在最后将看到的那样,管道中已经存在许多其他案例。
不用说,我们很兴奋,Rust 社区已经全力以赴,使 MIR 成为现实。编译器可以使用 MIR 自举并运行其测试套件,并且这些测试必须在每次新提交时通过。一旦我们能够使用启用了 MIR 的 Crater 运行,并在整个 crates.io 生态系统中看到没有回归,我们将默认启用它(或者,你会原谅一个糟糕的(奇妙的)双关语,将 MIR 发射到轨道上)。
这篇博文首先概述了 MIR 的设计,展示了 MIR 能够抽象掉 Rust 语言的全部细节的一些方式。接下来,我们看看 MIR 如何帮助实现 非零释放,这是一个长期以来期待的优化。如果你在阅读完这篇文章后发现自己渴望了解更多,请查看 引入 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 {
process(elem);
}
Rust 本身提供了三种循环:for
循环,就像这个循环一样;while
和 while let
循环,它们在满足某个条件之前一直迭代;最后是简单的 loop
,它只是不断迭代,直到你跳出循环。每种循环都封装了一种特定的模式,因此在编写代码时非常有用。但对于 MIR,我们希望将所有这些循环都简化为一个核心概念。
Rust 中的 for
循环通过将一个值转换为迭代器,然后反复调用该迭代器的 next
方法来工作。这意味着我们可以将之前看到的 for
循环重写为一个类似这样的 while let
循环
let mut iterator = vec.into_iter();
while let Some(elem) = iterator.next() {
process(elem);
}
通过应用这种重写,我们可以删除所有 for
循环,但这仍然留下了多种循环。因此,接下来我们可以想象将所有 while let
循环重写为一个简单的 loop
以及一个 match
let mut iterator = vec.into_iter();
loop {
match iterator.next() {
Some(elem) => process(elem),
None => break,
}
}
我们已经消除了两种结构(for
循环和 while
循环),但我们还可以更进一步。让我们暂时离开循环,看看我们看到的那些方法调用。在 Rust 中,像 vec.into_iter()
和 iterator.next()
这样的方法调用也是一种 语法糖。这些特定方法是在特征中定义的,特征基本上是预定义的接口。例如,into_iter
是 IntoIterator
特征中的一个方法。可以转换为迭代器的类型实现了该特征,并定义了 into_iter
方法对它们的工作方式。类似地,next
在 Iterator
特征中定义。当你编写像 iterator.next()
这样的方法调用时,Rust 编译器会根据 iterator
的类型以及作用域内的特征集自动确定该方法属于哪个特征。但如果我们更喜欢明确一点,我们可以直接在特征中调用方法,使用函数调用语法
// 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(vec);
loop {
// Similarly, `iterator.next()` can be rewritten
// to make clear which trait the `next` method
// comes from. We see here that the `.` notation
// was also adding an implicit mutable reference,
// which is now made explicit.
match Iterator::next(&mut iterator) {
Some(elem) => process(elem),
None => break,
}
}
此时,我们已经设法减少了我们的小片段的语言特征集:我们现在只使用 loop
循环,并且不使用方法调用。但如果我们从 loop
和 break
转向更基本的东西:goto
,我们可以进一步减少概念集。使用 goto
,我们可以将前面的代码示例转换为类似这样的代码
let mut iterator = IntoIterator::into_iter(vec);
loop:
match Iterator::next(&mut iterator) {
Some(elem) => { process(elem); goto loop; }
None => { goto break; }
}
break:
...
我们已经走得很远,将我们的示例分解为更简单的结构。我们还没有完全完成,但在我们继续之前,值得退后一步,做一些观察
**一些 MIR 原语比它们替换的结构化结构更强大。**从某种意义上说,引入 goto
关键字是一个很大的简化:它统一并替换了大量的控制流关键字。goto
完全替换了 loop
、break
、continue
,但它也允许我们简化 if
和 match
(我们将在稍后看到 match
的更多内容)。但是,这种简化只有在 goto
比 loop
*更通用* 的情况下才有可能,而我们不希望将它引入语言本身,因为我们不希望人们能够使用复杂的控制流编写意大利面条式代码,这些代码难以阅读和理解。但将这种结构放在 MIR 中是可以的,因为我们知道它只会被用在特定的方式,例如表达 loop
或 break
。
**MIR 构造是类型驱动的。**我们看到,所有像 iterator.next()
这样的方法调用都可以被反糖化为完全限定的函数调用,例如 Iterator::next(&mut iterator)
。但是,这种重写只有在拥有完整的类型信息的情况下才有可能,因为我们必须(例如)知道 iterator
的类型才能确定 next
方法来自哪个特征。一般来说,只有在完成类型检查后才能构造 MIR。
**MIR 使所有类型都明确。**由于我们在完成主要类型检查后构造 MIR,因此 MIR 可以包含完整的类型信息。这对像借用检查器这样的分析很有用,这些分析需要局部变量的类型等等才能运行,但也意味着我们可以定期运行类型检查器作为一种健全性检查,以确保 MIR 是格式良好的。
控制流图
在上一节中,我展示了将 Rust 程序逐步“解构”为类似 MIR 的形式,但我们一直保持在文本形式。然而,在编译器内部,我们从未“解析”MIR 或将其以文本形式保存。相反,我们将 MIR 表示为一组数据结构,用于编码控制流图 (CFG)。如果您曾经使用过流程图,那么您对控制流图的概念应该很熟悉。它是一种程序表示,以非常清晰的方式揭示了底层的控制流。
控制流图被组织为一组由边连接的基本块。每个基本块包含一系列语句,并以一个终止符结束,该终止符定义了块之间的连接方式。在使用控制流图时,循环只是图中的一个循环,而break
关键字则转换为从该循环中退出的一条路径。
以下是上一节中运行的示例,以控制流图的形式表示
构建控制流图通常是任何类型流敏感分析的第一步。它也是 LLVM IR 的自然匹配,LLVM IR 也被组织成控制流图形式。MIR 和 LLVM 相互对应得相当紧密,这使得转换非常直接。它还消除了错误的来源:在今天的编译器中,用于分析的控制流图不一定与 LLVM 构建产生的控制流图相同,这会导致错误的程序被接受。
简化匹配表达式
上一节中的示例展示了如何将所有 Rust 循环有效地简化为 MIR 中的 goto,以及如何将方法调用替换为对显式特征函数调用的调用。但它忽略了一个细节:匹配表达式。
MIR 的主要目标之一是将匹配表达式简化为非常小的核心操作。我们通过引入主语言中不包含的两个结构来实现这一点:开关和变体向下转换。与goto
一样,这些是我们不希望在基础语言中出现的东西,因为它们可能被滥用来编写不良代码;但它们在 MIR 中是完全可以接受的。
通过示例解释匹配处理可能最容易。让我们考虑上一节中看到的match
表达式
match Iterator::next(&mut iterator) {
Some(elem) => process(elem),
None => break,
}
这里,调用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(&mut iterator);
// Next, we "switch" on the value to determine which it has.
switch tmp {
Some => {
// If this is a Some, we can extract the element out
// by "downcasting". This effectively asserts that
// the value `tmp` is of the Some variant.
let elem = (tmp as Some).0;
// The user's original code:
process(elem);
goto loop;
}
None => {
goto break;
}
}
break:
....
当然,实际的 MIR 是基于控制流图的,所以它看起来会像这样
显式释放和恐慌
现在我们已经看到了如何从 MIR 中删除循环、方法调用和匹配,并用更简单的等效项替换它们。但仍然有一个关键领域可以简化。有趣的是,这几乎是今天代码中不可见的事情:在发生恐慌的情况下运行析构函数和清理。
在我们之前看到的示例控制流图中,我们假设所有代码都会成功执行。但实际上,我们无法确定这一点。例如,我们看到的任何函数调用都可能发生恐慌,这将触发解开过程的开始。当我们解开堆栈时,我们将不得不为找到的任何值运行析构函数。准确地确定在每个恐慌点应该释放哪些局部变量实际上是相当复杂的,因此我们希望在 MIR 中将其明确化:这样,MIR 构建就必须弄清楚,但后面的步骤只需依赖于 MIR 即可。
我们这样做的方法是双重的。首先,我们在 MIR 中明确释放。释放是我们用来对值运行析构函数的术语。在 MIR 中,每当控制流经过一个值应该被释放的点时,我们都会添加一个特殊的drop(...)
操作。其次,我们在控制流图中添加显式边来表示潜在的恐慌以及我们必须执行的清理。
让我们先看一下显式释放。如果你还记得,我们从一个只是 for 循环的示例开始
for elem in vec {
process(elem);
}
然后我们将此 for 循环转换为显式调用IntoIterator::into_iter(vec)
,生成一个值iterator
,从中提取各种元素。好吧,这个值iterator
实际上有一个析构函数,它需要被释放(在本例中,它的工作是释放向量vec
使用的内存;这部分内存不再需要,因为我们已经完成了对向量的迭代)。使用drop
操作,我们可以调整我们的 MIR 控制流图,以明确显示iterator
值在何处被释放。看一下新的图,特别是当找到None
变体时会发生什么
这里我们看到,当循环正常退出时,我们将释放迭代器,因为它已经完成。但是如果发生恐慌怎么办?毕竟,我们看到的任何函数调用都可能发生恐慌。为了解决这个问题,我们在图中引入了恐慌边
这里我们在每个函数调用上引入了panic
边。通过查看这些边,您可以看到,如果对next
或process
的调用发生恐慌,那么我们将释放变量iterator
;但如果对into_iter
的调用发生恐慌,则iterator
尚未初始化,因此不应释放它。
一个有趣的细节:我们最近批准了RFC 1513,它允许应用程序指定恐慌应该被视为对abort
的调用,而不是触发解开。如果程序正在使用“恐慌作为中止”语义进行编译,那么这也将反映在 MIR 中,因为恐慌边和处理将简单地从图中消失。
在 play 上查看 MIR
在这一点上,我们已经将我们的示例简化为非常接近 MIR 实际外观的东西。如果您想亲眼看看,您可以查看我们示例的 MIR,在play.rust-lang.org上。只需点击此链接,然后点击顶部的“MIR”按钮。您最终会看到几个函数的 MIR,因此您必须搜索才能找到example
fn 的开始。(我不会在这里复制输出,因为它相当长。)在编译器本身中,您也可以启用 graphviz 输出。
释放和堆栈标志
到目前为止,我认为您已经对 MIR 如何表示简化的 Rust 有了感觉。让我们看一下 MIR 将允许我们实现对 Rust 的一项期待已久的改进的示例:转向非零释放。这是一种更改我们检测析构函数何时必须执行的方式,特别是当值仅有时被移动时。此移动是在RFC 320中提出的(并得到批准),但尚未实现。这主要是因为在 pre-MIR 编译器上执行此操作在架构上具有挑战性。
为了更好地理解该功能是什么,请考虑此函数send_if
,它有条件地将向量发送到另一个线程
fn send_if(data: Vec<Data>) {
// If `some_condition` returns *true*, then ownership of `data`
// moves into the `send_to_other_thread` function, and hence
// we should not free it (the other thread will free it).
if some_condition(&data) {
send_to_other_thread(data);
}
post_send();
// If `some_condition` returned *false*, the ownership of `data`
// remains with `send_if`, which means that the `data` vector
// should be freed here, when we return.
}
关键点,如注释中所示,是我们无法静态地知道是否应该释放data
。这取决于我们是否进入了if
。
为了处理这种情况,编译器目前使用清零。或者更准确地说,是覆盖。这意味着,如果data
的所有权被转移,我们将用一个特定的、独特的位模式覆盖data
的栈槽,该模式不是有效的指针(我们以前使用零,所以我们通常称之为清零,但我们已经转向了其他方法)。然后,在释放data
的时候,我们会检查它是否被覆盖。(顺便说一下,这与等效的 C++ 代码所做的事情大致相同。)
但我们希望做得更好。我们希望做的是在栈上使用布尔标志来告诉我们哪些需要释放。所以它可能看起来像这样
fn send_if(data: Vec<Data>) {
let mut data_is_owned = true;
if some_condition(&data) {
send_to_other_thread(data);
data_is_owned = false;
}
post_send();
// Free `data`, but only if we still own it:
if data_is_owned {
mem::drop(data);
}
}
当然,你不能在 Rust 中写这样的代码。你不能在if
语句之后访问变量data
,因为它可能已经被转移了。(这是另一个例子,说明我们在 MIR 中可以做一些在完整的 Rust 中不允许做的事情。)
使用这样的布尔栈标志有很多优点。首先,它更有效率:我们只需要设置一个标志,而不是覆盖整个向量。其次,它更容易优化:想象一下,通过内联或其他一些方法,编译器能够确定some_condition
总是为真。在这种情况下,标准的常量传播技术会告诉我们data_is_owned
总是为假,因此我们可以直接优化掉对mem::drop
的整个调用,从而得到更紧凑的代码。有关更多详细信息,请参阅RFC 320。
然而,在当前的编译器架构上正确实现这种优化非常困难。有了 MIR,它变得相对简单。MIR 控制流图明确地告诉我们值将在哪里被丢弃以及何时被丢弃。当 MIR 第一次生成时,我们假设丢弃移动的数据没有效果——大致类似于当前的覆盖语义。所以这意味着send_if
的 MIR 可能看起来像这样(为了简单起见,我将忽略展开边)。
然后,我们可以通过识别data
被移动或丢弃的每个位置,并检查这些位置中的任何一个是否可以相互到达,来转换这个图。在这种情况下,send_to_other_thread(data)
块可以到达drop(data)
。这表明我们需要引入一个标志,这可以通过相当机械的方式完成
最后,我们可以应用标准的编译器技术来优化这个标志(但在这种情况下,需要这个标志,因此最终结果将是相同的)。
为了说明 MIR 为什么有用,让我们考虑一个send_if
函数的变体,称为send_if2
。这个变体检查一些条件,如果满足条件,它将数据发送到另一个线程进行处理。否则,它会在本地处理它
fn send_if2(data: Vec<Data>) {
if some_condition(&data) {
send_to_other_thread(data);
return;
}
process(&data);
}
这将生成类似的 MIR
和以前一样,我们仍然在所有情况下生成data
的丢弃,至少在开始时是这样。由于仍然存在可以到达丢弃的移动,所以现在我们可以引入一个栈标志变量,就像以前一样
但在这种情况下,如果我们应用常量传播,我们可以看到,在测试data_is_owned
的每个点,我们都静态地知道它是真还是假,这将允许我们移除栈标志并优化上面的图,得到以下结果
结论
我预计 MIR 的使用将在编译器能够实现的功能方面带来巨大的变革。通过将语言简化为一组核心原语,MIR 为许多语言改进打开了大门。我们在本文中探讨了丢弃标志。另一个例子是改进 Rust 的生命周期系统,以利用控制流图来提高精度。但我认为还会有许多我们没有预见到的应用。事实上,已经出现了一个这样的例子:Scott Olson 在开发 MIR 解释器miri方面取得了巨大进展,它所探索的技术很可能成为编译器本身中更强大的常量求值器的基础。
编译器向 MIR 的过渡尚未完成,但已经非常接近了。特别感谢 Simonas Kazlauskas (nagisa) 和 Eduard-Mihai Burtescu (eddyb),他们对推动 MIR 迈向终点线做出了特别大的贡献。我们的最初目标是将我们的 LLVM 生成切换到完全从 MIR 操作。同时,也正在进行将借用检查器移植的工作。之后,我预计我们将移植编译器中目前使用 HIR 的许多其他部分。如果你有兴趣贡献,请寻找标记为A-mir
的问题,或者在 IRC 的#rustc
频道中询问。