[博客翻译]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的标准库有链表,也有更快且借用检查器友好的容器可供选择。

还有两种情况是借用检查器无法容忍的:内存映射文件(进程外的神奇变化违反了引用的不可变^独占语义)和自引用结构(按值传递结构会使其内部指针悬挂)。这些情况要么通过和C中一样安全的原始指针解决,要么通过精神体操围绕它们制作安全抽象解决。

对Rust来说,单线程程序根本就不存在作为一个概念。Rust允许个别数据结构出于性能考虑而不是线程安全的,但任何被允许在线程间共享的东西(包括全局变量)必须同步或标记为不安全。

我一直忘记Rust的字符串支持一些廉价的就地操作,比如make_ascii_lowercase()(C中我会做的直接等价物),并不必要地使用Unicode感知的、复制的.to_lowercase()。说到字符串,UTF-8编码看起来可能是个大问题,但实际上并不是,因为字符串有.as_bytes()视图,所以如果需要,可以以不知道Unicode的方式处理它们。

libc竭尽全力使stdout和putc相当快。Rust的libstd魔法较少,因此I/O不会缓冲,除非包装在BufWriter中。我见过人们抱怨他们的Rust比Python慢,原因是Rust花了99%的时间按字节刷新结果,正如所指示的那样。

可执行文件大小

每个操作系统都会附带一些内置的标准C库,大约有30MB的代码,C可执行文件可以“免费”获得,例如,一个小的“Hello World”C可执行文件实际上不能打印任何东西,它只是调用操作系统附带的printf。Rust不能指望操作系统内置Rust的标准库,所以Rust可执行文件会捆绑自己的标准库(300KB或更多)。幸运的是,这是一次性开销,可以减少。对于嵌入式开发,可以关闭标准库,Rust将生成“裸”代码。

在每个函数的基础上,Rust代码的大小大致与C相同,但存在“泛型膨胀”的问题。泛型函数为它们使用的每种类型生成优化版本,因此可能最终有8个相同函数的版本。cargo-bloat有助于找到这些。

在Rust中使用依赖关系非常容易。类似于JS/npm,有一种制作小型单一用途库的文化,但它们确实会累积。最终,我的所有可执行文件都包含Unicode规范化表、7种不同的随机数生成器,以及具有Brotli支持的HTTP/2客户端。cargo-tree对于去重和修剪它们很有用。

Rust的小胜利

我已经谈了很多开销,但Rust也有更高效和更快的地方:

C库通常返回指向其数据结构的不透明指针,以隐藏实现细节并确保每个结构实例只有一个副本。这会造成堆分配和指针间接引用的成本。Rust的内置隐私、单一所有权规则和编码惯例允许库公开它们的对象而无需间接引用,以便调用者可以决定将它们放在堆上还是栈上。栈上的对象可以非常激进地优化,甚至完全优化掉。

Rust默认可以内联来自标准库、依赖关系和其他编译单元的函数。在C中,我有时会不愿意拆分文件或使用库,因为它会影响内联,并需要对头文件和符号可见性进行微观管理。

结构字段被重新排序以最小化填充。用-Wpadding编译C显示我多么经常忘记这个细节。

字符串在它们的“胖”指针中编码了它们的大小。这使得长度检查快速,消除了意外O(n²)字符串循环的风险,并允许就地制作子字符串(例如,将字符串分割成标记)而无需修改内存或复制以添加\0终止符。

像C++模板一样,Rust为它们使用的每种类型生成泛型代码的副本,因此像sort()这样的函数和像哈希表这样的容器总是针对它们的类型优化。在C中,我必须在使用宏的技巧或使用void*的效率较低的函数之间做出选择,这些函数工作在运行时变量大小上。

Rust迭代器可以组合成链,作为一个单元一起优化。因此,与可能多次重写相同缓冲区的一系列调用buy(it); use(it); break(it); change(it); mail(upgrade(it));不同,我可以调用it.buy().use().break().change().upgrade().mail(),它编译为一个buy_use_break_change_mail_upgrade(it)函数,优化为在一个单一的组合通道中完成所有这些。 (0..1000).map(|x| x*2).sum()编译为返回999000。

类似地,有Read和Write接口允许函数流式传输未缓冲的数据。它们可以很好地组合,因此我可以将数据写入一个流,在传输时即时计算CRC,根据需要添加帧/转义,压缩它,并将其写入网络,所有这些都在一个调用中完成。我可以将这样的组合流作为输出流传