MIR 介绍

2016 年 4 月 19 日 · Niko Matsakis

我们正处于 Rust 编译器内部重大改造的最后阶段。在过去的一年左右的时间里,我们一直在稳步推进一项计划,以改变我们的内部编译器流水线,如下所示:

Compiler Flowchart

也就是说,我们正在引入程序的一种新的中间表示(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 生成的代码带来一些长期存在的性能改进,例如“非归零” drop

  • 更精确的类型检查。今天的 Rust 编译器对借用施加了一些人为的限制,这些限制很大程度上源于编译器当前表示程序的方式。MIR 将实现更灵活的借用,这将反过来改善 Rust 的人体工程学和学习曲线。

除了这些面向用户的重大改进之外,MIR 还为编译器带来了巨大的工程优势

  • 消除冗余。 目前,由于我们所有的 pass 都是根据完整的 Rust 语言编写的,因此存在大量的重复。例如,安全分析和生成 LLVM IR 的后端都必须就如何翻译 drop,或者测试和执行 match 表达式分支的精确顺序(这可能变得非常复杂)达成一致。使用 MIR,所有这些逻辑都集中在 MIR 构建中,后面的 pass 可以直接依赖它。

  • 提高雄心。 除了更加DRY之外,使用 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 {
    process(elem);
}

Rust 本身提供了三种循环:for 循环,如这个;whilewhile 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 循环重写为与 match 组合的简单 loop

let mut iterator = vec.into_iter();
loop {
    match iterator.next() {
        Some(elem) => process(elem),
        None => break,
    }
}

我们已经消除了两种构造(for 循环和 while 循环),但是我们可以更进一步。让我们暂时从循环转向我们看到的方法调用。在 Rust 中,诸如 vec.into_iter()iterator.next() 之类的方法调用也是一种语法糖。这些特定方法是在 trait 中定义的,trait 基本上是预定义的接口。例如,into_iterIntoIterator trait 中的一个方法。可以转换为迭代器的类型实现该 trait 并定义 into_iter 方法如何为它们工作。类似地,nextIterator 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(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 循环,并且不使用方法调用。但是,如果我们要从 loopbreak 转向更基础的东西: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 完全取代了 loopbreakcontinue,但它也允许我们简化 ifmatch(稍后我们将看到关于 match 的更多信息)。但是,这种简化只有在 gotoloop通用的构造时才有可能,而且我们不想将其引入到语言本身,因为我们不希望人们能够编写像意大利面条一样的代码,其中包含难以阅读和后续理解的复杂控制流。但是,在 MIR 中拥有这样的构造是可以的,因为我们知道它只会以特定的方式使用,例如表示 loopbreak

MIR 构建是类型驱动的。 我们看到所有像 iterator.next() 这样的方法调用都可以被解糖为完全限定的函数调用,如 Iterator::next(&mut iterator)。但是,只有在拥有完整类型信息的情况下才能执行此重写,因为我们必须(例如)知道 iterator 的类型才能确定 next 方法来自哪个 trait。一般来说,只有在完成类型检查后才能构造 MIR。

MIR 使所有类型明确。 由于我们在完成主要的类型检查后构造 MIR,MIR 可以包含完整的类型信息。这对于像借用检查器这样的分析非常有用,它需要局部变量等的类型才能运行,但也意味着我们可以定期运行类型检查器作为一种健全性检查,以确保 MIR 是格式良好的。

控制流图

在上一节中,我介绍了 Rust 程序逐步“解构”为类似于 MIR 的过程,但我们仍然采用文本形式。但是,在编译器的内部,我们从不“解析”MIR 或使其采用文本形式。相反,我们将 MIR 表示为编码控制流图 (CFG)数据结构集。如果您曾经使用过流程图,那么控制流图的概念对您来说应该非常熟悉。它是您程序的表示,以非常清晰的方式公开了底层的控制流。

控制流图的结构为一组由边连接的基本块。每个基本块包含一系列语句,并以定义块之间如何相互连接的终止符结尾。使用控制流图时,循环只是在图中显示为一个循环,并且 break 关键字会转换为该循环的路径。

以下是上一节中的运行示例,以控制流图的形式表示:

Control-flow-graph

构建控制流图通常是任何类型的流敏感分析的第一步。它也与 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(&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 是基于控制流图的,所以它看起来会像这样

Loop-break control-flow graph

显式 drop 和 panic

现在我们已经了解了如何从 MIR 中删除循环、方法调用和 match,并将其替换为更简单的等效项。但仍然有一个关键领域可以简化。有趣的是,它在今天的代码中几乎是不可见的:在 panic 的情况下运行析构函数和清理。

在我们之前看到的示例控制流图中,我们假设所有代码都会成功执行。但实际上,我们无法知道这一点。例如,我们看到的任何函数调用都可能 panic,这将触发展开的开始。当我们展开堆栈时,我们必须为找到的任何值运行析构函数。准确找出在每个 panic 点应该释放哪些局部变量实际上有点复杂,因此我们希望在 MIR 中显式地做到这一点:这样,MIR 的构建就必须弄清楚,但稍后的步骤可以只依赖于 MIR。

我们通过两种方式来实现这一点。首先,我们在 MIR 中显式地进行 drops。Drop 是我们用来对值运行析构函数的术语。在 MIR 中,只要控制流经过一个应该 drop 值的地方,我们就会添加一个特殊的 drop(...) 操作。其次,我们在控制流图中添加显式边来表示潜在的 panic,以及我们必须进行的清理。

让我们先看看显式的 drop。如果你还记得,我们从一个简单的 for 循环开始

for elem in vec {
    process(elem);
}

然后,我们将此 for 循环转换为显式调用 IntoIterator::into_iter(vec),生成一个值 iterator,我们从中提取各种元素。好吧,这个值 iterator 实际上有一个析构函数,它需要被释放(在这种情况下,它的工作是释放向量 vec 使用的内存;因为我们已经完成了对向量的迭代,所以不再需要此内存)。使用 drop 操作,我们可以调整我们的 MIR 控制流图,以显式地显示 iterator 值被释放的位置。看看新的图,特别是当找到 None 变体时会发生什么

Drop control-flow graph

这里我们看到,当循环正常退出时,我们会在迭代器完成后 drop 它。但是如果发生 panic 呢?毕竟,我们在这里看到的任何函数调用都可能 panic。为了解决这个问题,我们在图中引入了 panic 边

Panic control-flow graph

这里,我们在每个函数调用上都引入了 panic 边。通过查看这些边,您可以看到,如果对 nextprocess 的调用发生 panic,那么我们将 drop 变量 iterator;但是如果对 into_iter 的调用发生 panic,则 iterator 尚未初始化,因此不应 drop 它。

一个有趣的细节:我们最近批准了 RFC 1513,它允许应用程序指定将 panic 视为调用 abort,而不是触发展开。如果程序是使用“panic as abort”语义编译的,那么这也会反映在 MIR 中,因为 panic 边和处理将简单地从图中消失。

在 play 上查看 MIR

此时,我们已经将示例简化为非常接近 MIR 实际的样子。如果您想亲自查看,可以在 play.rust-lang.org查看示例的 MIR。只需点击此链接,然后按顶部的“MIR”按钮。您最终会看到几个函数的 MIR,因此您必须搜索以找到 example 函数的开头。(我不会在此处重现输出,因为它相当长。)在编译器本身中,您还可以启用 graphviz 输出。

Drop 和堆栈标志

现在,我认为您已经了解了 MIR 如何表示简化的 Rust。让我们看一个例子,看看 MIR 如何让我们实现 Rust 中期待已久的改进:转向非零 drop。这是对我们如何检测何时必须执行析构函数的更改,特别是当值只是有时被移动时。此移动在 RFC 320 中提出(并获得批准),但尚未实施。这主要是因为在 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 始终为 false,因此我们可以优化掉对 mem::drop 的整个调用,从而获得更紧凑的代码。有关更多详细信息,请参阅 RFC 320

然而,在当前的编译器架构上正确实现此优化非常困难。有了 MIR,它变得相对简单。MIR 控制流图明确告诉我们何时以及在何处 drop 值。当 MIR 首次生成时,我们假设 drop 移动的数据没有效果——大致类似于当前的覆盖语义。所以这意味着 send_if 的 MIR 可能看起来像这样(为简单起见,我将忽略展开边)。

Non-zeroing drop example

然后,我们可以通过识别 data 被移动或 drop 的每个位置,并检查这些位置是否可以相互到达来转换此图。在这种情况下,send_to_other_thread(data) 块可以到达 drop(data)。这表明我们需要引入一个标志,这可以相当机械地完成

Non-zeroing drop with flags

最后,我们可以应用标准的编译器技术来优化此标志(但在这种情况下,需要该标志,因此最终结果将是相同的)。

为了说明 MIR 的用处,让我们考虑一个名为 send_if2send_if 函数的变体。此变体检查某些条件,如果满足条件,则将数据发送到另一个线程进行处理。否则,它会在本地处理

fn send_if2(data: Vec<Data>) {
    if some_condition(&data) {
        send_to_other_thread(data);
        return;
    }

    process(&data);
}

这将生成类似这样的 MIR

Control-flow graph for send_if2

和以前一样,我们仍然在所有情况下生成 data 的 drops,至少一开始是这样。由于仍然存在可以稍后到达 drop 的移动,我们现在可以像以前一样引入一个堆栈标志变量

send_if2 with flags

但是在这种情况下,如果我们应用常量传播,我们可以看到,在我们测试 data_is_owned 的每个点,我们都静态地知道它是 true 还是 false,这将允许我们删除堆栈标志并优化上面的图,从而产生以下结果

Optimized send_if2

结论

我预计 MIR 的使用会在编译器可以完成的工作方面发生巨大的变革。通过将语言简化为一组核心原语,MIR 为许多语言改进打开了大门。我们在本文中研究了 drop 标志。另一个例子是改进 Rust 的生命周期系统,以利用控制流图获得更高的精度。但我认为会有许多我们尚未预见到的应用。事实上,已经出现了一个这样的例子:Scott Olson 在开发 MIR 解释器 miri 方面取得了巨大进展,它正在探索的技术很可能构成编译器中更强大的常量求值器的基础。

编译器中向 MIR 的过渡尚未完成,但它已经非常接近。特别感谢 Simonas Kazlauskas (nagisa) 和 Eduard-Mihai Burtescu (eddyb),他们都在将 MIR 推向终点方面产生了特别大的影响。我们的最初目标是切换我们的 LLVM 生成,使其完全从 MIR 运行。借用检查器的移植工作也在进行中。之后,我希望我们将移植编译器上的许多其他目前正在使用 HIR 的部分。如果您有兴趣做出贡献,请查找 标记为 A-mir 的问题或在 IRC 上的 #rustc 频道中询问。