[博客翻译]Rust与C的速度比较


原文地址:https://kornel.ski/rust-c-speed


Rust语言编写的程序在运行时速度和内存使用上应与C语言编写的程序大致相同,但由于这两种语言的编程风格差异较大,很难概括它们的速度。以下是它们相同之处、C语言更快之处以及Rust更快之处的概述。

免责声明:此比较并非旨在作为揭示这些语言不容置疑真理的客观基准。理论上这些语言能够实现的和实际使用中的差异是显著的。此次比较基于我个人的主观经验,包括面临截止日期、编写错误和懒惰情绪。我已经使用Rust作为我的主要编程语言超过4年,而在此之前使用C语言达十年之久。我在这里特别只将Rust与C语言进行比较,因为与C++的比较会涉及更多的“如果”和“但是”,这些是我不想深入讨论的。

简而言之:Rust的抽象是一把双刃剑。它们可能隐藏了次优代码,但也使得算法改进和利用高度优化的库变得更加容易。

3.png

我从不担心会在Rust中遇到性能瓶颈。总有“不安全”的紧急出口允许进行非常低级别的优化(而且通常并不需要)。

无畏并发是真实存在的。借用检查器的偶尔笨拙在实际并行编程中得到了回报。

我整体的感觉是,如果我能无限制地投入时间和精力,我的C程序会和Rust一样快或者更快,因为理论上C能做的Rust也能做。但实际上,C语言的抽象更少,标准库原始,依赖情况糟糕,而我没有时间每次都重新发明轮子,并且每次都达到最优。

两者都是“可移植的汇编器”

Rust和C都能控制数据结构的布局、整数大小、栈与堆内存分配、指针间接引用,通常能被翻译成易于理解的机器代码,编译器插入的“魔法”很少。Rust甚至承认字节有8位,有符号整数可以溢出!

尽管Rust有更高级的结构,如迭代器、特征和智能指针,它们被设计为可预测地优化为直接的机器代码(也就是“零成本抽象”)。Rust类型的内存布局很简单,例如,可增长的字符串和向量确切地是{byte*, capacity, length}。Rust没有类似移动或复制构造函数的概念,因此对象的传递保证不会比传递一个指针或memcpy更复杂。

借用检查仅是编译时的静态分析。它不做任何事情,甚至在代码生成之前完全剥离了生命周期信息。没有自动装箱或任何类似的聪明技巧。

Rust在成为“愚蠢”的代码生成器方面稍微不足的一个例子是解除。虽然Rust不使用异常进行正常错误处理,但panic(未处理的致命错误)可能会选择性地表现得像C++异常。它可以在编译时禁用(panic = abort),但即便如此,Rust并不喜欢与C++异常或longjmp混合使用。

相同的LLVM后端

Rust与LLVM集成良好,因此它支持链接时优化,包括ThinLTO,甚至可以跨C/C++/Rust语言边界进行内联。还有基于配置文件的优化。尽管rustc生成的LLVM IR比clang更啰嗦,但优化器仍能处理得很好。

我的一些C代码在用GCC编译时比用LLVM稍快一些,而且现在还没有Rust的GCC前端,所以Rust错过了这一点。

理论上,由于更严格的不变性和别名规则,Rust允许比C进行更好的优化,但实践中这还没有发生。超出C所做的优化是LLVM中正在进行的工作,因此Rust尚未发挥其全部潜力。

两者都允许手动调整,只有少数例外

Rust代码的底层和可预测性足以让我手动调整它将优化成的汇编。Rust支持SIMD内置函数,对内联、调用约定等有很好的控制。Rust与C足够相似,以至于C的分析器通常可以直接用于Rust(例如,我可以在一个Rust-C-Swift混合程序上使用Xcode的Instruments)。

通常,在性能绝对关键且需要手动优化到最后一位时,优化Rust与C没有太大不同。

Rust没有一些低级功能的适当替代品:

计算goto。在Rust中,goto的“无聊”用法可以用其他结构替代,比如loop {break}。在C中,许多使用goto是为了清理,而Rust由于RAII/析构函数不需要这样做。然而,有一个非标准的goto *addr扩展,对于解释器非常有用。Rust不能直接做到(你可以写一个match并希望它会优化),但另一方面,如果我需要一个解释器,我会尝试利用Cranelift JIT代替。

alloca和C99可变长度数组。这些即使在C中也是有争议的,所以Rust远离它们。

值得注意的是,Rust目前只支持一种16位架构。第一层支持集中在32位和64位平台上。

Rust的小额开销

然而,在Rust没有手动调整的地方,一些低效可能会悄然出现:

Rust缺乏隐式类型转换,只能用usize进行索引,这促使用户仅使用这种类型,即使在更小的类型就足够的情况下也是如此。这与C形成对比,在C中32位int是受欢迎的选择。在64位平台上优化usize索引更容易,不依赖于未定义行为,但额外的位可能会对寄存器和内存产生更大压力。

习惯性的Rust总是为字符串和切片传递指针和大小。直到我将一些代码库从C移植到Rust,我才意识到有多少C函数只接受内存指针,而不带大小,希望一切顺利(大小要么从上下文间接知道,要么只是假设足够大以完成任务)。

并非所有边界检查都被优化掉。for item in arr或arr.iter().for_each(…)是尽可能高效的,但如果需要使用for i in 0..len {arr[i]}的形式,那么性能取决于LLVM优化器是否能够证明长度匹配。有时它不能,边界检查阻碍了自动向量化。当然,对此有各种安全和不安全的解决方法。

Rust中不鼓励“巧妙”的内存使用。在C中,什么都行。例如,在C中,我会倾向于将为一个目的分配的缓冲区稍后用于另一个目的(一种称为HEARTBLEED的技术)。为可变大小数据拥有固定大小的缓冲区很方便(例如,PATH_MAX),以避免(重新)分配增长的缓冲区。习惯性的Rust仍然对内存分配有很多控制权,并且可以做基础事情,如内存池、合并多个分配到一个、预分配空间等,但总的来说,它引导用户朝着“无聊”的内存使用方向发展。

在借用检查规则使事情变得困难的情况下,简单的方法是进行额外的复制或使用引用计数。随着时间的推移,我学到了很多借用检查器的技巧,并调整了我的编码风格以适应借用检查器,所以这种情况不再经常出现。这从来不会成为一个主要问题,因为如果必要的话,总有回退到“原始”指针的选择。

Rust的借用检查器因讨厌双向链表而臭名昭著,但幸运的是,链表在21世纪的硬件上反正也是慢的(缓存局部性差,没有向量化)。Rust的标准库有链表,也有更快且借用检查器友好的容器可供选择。

还有两种情况是借