当零成本抽象不是零成本时

2021-08-10 06:43:04

Rust 是围绕“零成本抽象”的概念构建的。这个想法是,您可以编写人性化的高级代码,编译器将为您提供至少与您自己编写的任何优化的低级代码一样好的免费性能。通过零成本抽象,您不再需要在可维护性和性能之间进行权衡。不幸的是,很难确保零成本抽象是真正的零成本,而 Rust 在实践中往往无法满足这个崇高的理想。在这篇文章中,我将展示两个示例,其中即使是看似简单的零成本抽象实际上也不是零成本。最基本的抽象之一是 newtype 模式。这就是用户定义的类型是另一种类型的简单包装器的地方,除了封装之外没有其他数据或行为更改。例如,我们可以像这样定义 u8 类型的包装器:此类新类型有多种用途。例如,使用丰富的语义类型有助于捕获错误。您可以定义包装 int 类型的自定义 Height 和 Weight 类型,而不是传递裸整数作为高度和重量,然后编译器将捕获您错误地使用高度值设置重量的任何错误,反之亦然,所有这些都没有运行时成本。此外,在 Rust 中,由于一致性规则,新类型通常是实现 trait 所必需的。如果您想与另一个库进行交互,或者您只需要覆盖标准特征定义,那么这样做的方法是使用包装器类型。最后,也许是最重要的,newtypes 提供了封装。通过在库中公开包装器类型而不是其内容,您知道该类型的所有值都是通过正常使用库的 API 生成的。此外,库用户不会意外依赖实现细节,因此您可以灵活地在未来对库进行改进。在这个例子中,WrappedByte 类型的用户唯一知道的是它是可复制和可比较的。我们可以用任何整数类型,甚至更奇特的组合替换实现,而不会破坏库用户。我们可以进一步限制——例如,如果它是一个随机的不透明令牌,我们可以拒绝实施 Ord 以防止用户滥用它。

在 Java 或 Python 之类的语言中,定义这样的包装器类型会产生运行时成本,迫使程序员在抽象和性能之间做出选择。然而,Rust 是一种具有优化编译器的高性能系统编程语言,因此人们会期望我们的 WrappedByte 类型具有完全相同的性能,就像我们在任何地方都使用 u8 而没有包装一样。不幸的是,事实证明并非总是如此。让我们看看我们的 WrappedByte 类型是否真的是零成本。这是一段简单的代码,它只是测量分配一个巨大的零字节数组所需的时间:不错。 Rust 非常擅长分配内存并且不使用它。现在让我们看看当我们使用包装类型时会发生什么:到底是什么?!简单地添加一个新类型导致代码慢了一百万倍!这是在发布模式下,使用 rustc 和 LLVM 的全面优化功能来解决问题。它应该需要完全相同的时间来运行。从历史上看,Rust 是基于参数多态的概念。这意味着泛型代码必须定义它被实例化的类型所期望的接口,并且只依赖于该接口。由于不允许通用代码越过其特征边界,它必须平等地对待实现该特征的每个输入。这强制了一个抽象边界,代码的用户可以在其中提供任何等效的 trait 实现并获得相同的行为。不幸的是,抽象障碍有一个缺点。虽然防止一段代码对另一段代码做出假设对于模块化和可维护性来说非常有用,但对于优化来说却不是那么好,因为对其他代码做出假设允许您跳过在一个用户的特定上下文中不必要的步骤,甚至选择专门的算法。因此,Rust 最近引入了专业化,这是一项实验性功能,允许泛型代码基于实际实例化的类型(或类型的特征)使用不同的实现,而不是编码到表面上的接口。到目前为止,它只用在标准库中的少数地方,但其中之一恰好是 vec![v; 函数。 n] 调用,并且该函数具有针对 u8 的专门实现。

当专门用 u8 调用时, vec![v; n] 将调用一个优化函数,如果 v 为 0,则分配归零的内存,或者如果 v 非零,则用固定字节模式填充内存。但是,当 v 具有任意类型时,它只会对数组的每个元素进行 clone(),这会慢很多数量级。零成本抽象就到此为止。堆叠借用是一种将 Rust 引用的低级语义形式化的提议,并弄清楚程序员究竟是什么和不允许对它们做什么,因此,编译器在优化它们时允许和不允许做出哪些假设。这些保证之一是编译器假定传递给函数的引用在函数的整个持续时间内都是有效的。这对于启用某些优化很有用。例如,考虑以下代码: 其中 bar 是在代码中其他地方定义的函数,甚至可能在单独的 crate 中。在某些情况下,可能需要在调用 bar 之后移动 r 的读取。也许它可以避免溢出寄存器或改进流水线或其他东西。这种优化应该有效吗?在安全的 Rust 中,这是毫无疑问的。然而,安全的 Rust 必须与不安全的 Rust 共存,不安全的 Rust 可能在幕后做各种微妙的指针技巧。如果 bar 在别处定义,编译器不知道它的定义是什么样的(以及它内部是否使用了不安全的代码),因此必须把它当作一个黑盒子来对待。像 C 这样的语言会在那时放弃,但 Rust 渴望更好的东西。 Stacked Borrows 项目的目标是使编译器即使在存在黑盒函数的情况下也能进行优化。为此,Stacked Borrows 包含的规则是,如果函数将引用作为参数,则该引用必须在函数调用的整个生命周期内保持有效,并且任何不安全的代码都必须遵循该规则。这允许编译器在对 bar 的实现一无所知的情况下进行优化,因为即使 bar 包含不安全代码,该不安全代码也不允许违反 Stacked Borrows 规则,如果是,则是不安全代码的错代码作者而不是编译器作者。

这个规则可能看起来有点人为。为什么只将其应用于引用?为什么不直接说一个函数的所有生命周期参数都必须比该函数存活时间长,无论它们是直接引用参数还是埋在某个结构中?不幸的是,事实证明这是不可能的。考虑以下代码: fn break_it ( rc : & RefCell < i32 > , r : Ref < '_ , i32 > ) { // `r` 有一个共享引用,它作为参数传入,因此 // 一个保护器是补充说,在这个函数的整个持续时间内将此内存标记为只读。下降 ( r ); // *oops* 在这里我们可以改变内存。 * rc.borrow_mut() = 2; } fn main () { let rc = RefCell :: new ( 0 ); break_it ( & rc , rc .borrow ()) } 在这段代码中,RefCell 的保护类型 Ref 有一个假的生命周期参数。但是,Ref 仅在 Ref 值存在时保护对底层单元格的访问。在函数中间删除 Ref 在安全 Rust 中是完全有效的,因此假设它的虚假生命周期参数比函数调用更有效是无效的。 Stacked Borrow 作者没有尝试修复 Ref 的实现,而是选择将此规则限制为裸引用参数。不幸的是,虽然可以理解,但在忽略一般情况的同时优化一个特定案例的愿望再次打破了零成本抽象。例如,假设我们从上面获取我们的 foo 函数并将引用放在一个 newtype 中:人们希望它具有与没有包装器相同的性能,但在 Stacked Borrows 提案下,它可能不会。这是因为添加包装器意味着不再允许编译器假定生命周期比函数调用更有效,因此不再允许在调用 bar 之后将读取的引用移动到。这篇文章的重点不是抨击 Rust 团队,而是提高认识。语言设计是一个充满矛盾权衡的艰难过程。如果没有清楚地了解您最看重哪些属性,很容易意外违反您认为自己所做的保证。对于像 C++ 和 Rust 这样的复杂系统语言来说尤其如此,它们试图为所有人提供一切并且不遗余力地进行潜在的优化。