Rust 和 C 语言速度比较

用 Rust 编写的程序的运行速度和内存使用量应该与用 C 编写的程序大致相同,但这些语言的整体编程风格不同,因此很难概括它们的速度。这里总结了它们相同的地方、C 语言更快的地方以及 Rust 更快的地方。

免责声明:本文无意成为揭示这些语言不争真理的客观基准。这些语言在理论上可以实现的功能与实际使用中的效果存在很大差异。这种特殊的比较是基于我自己的主观经验,其中包括最后期限、编写 bug 和懒惰。我使用 Rust 作为我的主要语言已经有 4 年多了,而在此之前使用 C 语言也有 10 年了。我在这里特别只与 C 语言进行比较,因为与 C++ 比较会有更多的 “如果 “和 “但是”,我不想多说。

简而言之

  • Rust 的抽象是一把双刃剑。它们可以隐藏次优代码,但也能让算法改进和利用高度优化的库变得更容易。
  • 我从不担心使用 Rust 会陷入性能死胡同。Rust 总是有一个不安全的逃生舱门,允许进行非常底层的优化(而且并不经常需要)。
  • 无畏并发是真实存在的。借用检查器偶尔的笨拙也能让并行编程变得实用。

我的总体感觉是,如果我能花费无限的时间和精力,我的 C 语言程序会和 Rust 一样快,甚至更快,因为从理论上讲,没有什么是 C 语言做不到而 Rust 能做到的。但在实践中,C 语言的抽象较少,标准库原始,依赖性强,而且我没有时间每次都重新发明轮子,优化程序。

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

Rust 和 C 都能控制数据结构的布局、整数大小、堆栈与堆内存分配、指针间接,而且一般都能转化为可理解的机器代码,编译器几乎不会插入什么 “魔法”。Rust 甚至承认字节有 8 位,有符号整数会溢出!

尽管 Rust 拥有迭代器、特质和智能指针等更高层次的构造,但它们的设计目的是为了可预测地优化为简单的机器代码(又称 “零成本抽象”)。Rust 类型的内存布局非常简单,例如,可增长字符串和向量完全是 {byte*, capacity, length}。Rust 没有任何类似于移动或复制构造函数的概念,因此对象的传递不会比传递指针或 memcpy 更复杂。

借用检查只是一种编译时静态分析。它不会做任何事情,生命周期信息甚至会在代码生成前被完全剥离。不存在自动分箱或任何类似的聪明做法。

Rust 无法成为 “哑巴 ()dumb)”代码生成器的一个原因是unwinding。虽然 Rust 在正常错误处理中不使用异常,但 panic(未处理的致命错误)可以选择性地表现得像 C++ 异常。它可以在编译时禁用(panic = abort),但即便如此,Rust 也不喜欢与 C++ 异常或 longjmp 混用。

相同的 LLVM 后端

Rust 与 LLVM 有很好的集成,因此它支持 Link-Time Optimization(链接时间优化),包括 ThinLTO,甚至支持跨 C/C++/Rust 语言边界的内联。还有配置文件引导的优化。尽管 rustc 生成的 LLVM IR 比 clang 更冗长,但优化器仍能很好地处理它。

我的一些 C 代码在使用 GCC 编译时要比 LLVM 快一些,而 GCC 还没有 Rust 前端,所以 Rust 就错过了这个机会。

从理论上讲,由于采用了更严格的不变性和别名规则,Rust 可以实现比 C 更好的优化,但在实践中还没有实现。在 LLVM 中,C 语言之外的优化仍在进行中,因此 Rust 仍未充分发挥其潜力。

两者都允许手动调整,但有一些小例外

Rust 代码级别低,可预测性强,我可以手动调整它将优化到什么程序集。Rust 支持 SIMD 内核,对内联、调用约定等有很好的控制。Rust 与 C 语言足够相似,因此 C 语言剖析器通常可以与 Rust 兼容(例如,我可以在 Rust-C-Swift 三明治程序上使用 Xcode 的 Instruments)。

一般来说,如果性能绝对关键,需要手工优化到最后一点,那么 Rust 的优化与 C 语言并无太大区别。

有些底层特性,Rust 并没有合适的替代品:

  • computed  goto。在 Rust 中,goto 的 “无聊 “用法可以用其他结构来替代,比如loop {break}。在 C 语言中,goto 的许多用途都是为了清理,而 Rust 由于有了 RAII/destructors,所以并不需要。不过,有一种非标准的 goto *addr 扩展对解释器非常有用。Rust 无法直接做到这一点(你可以写一个匹配并希望它能优化),但反过来说,如果我需要一个解释器,我会尝试使用 Cranelift JIT 来代替它。
  • alloca 和 C99 变长数组。即使在 C 语言中,这些都是有争议的,所以 Rust 对它们敬而远之。

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

Rust 的微小开销

然而,在 Rust 未经人工调整的情况下,一些低效可能会悄然出现:

  • Rust 缺乏隐式类型转换,而且只使用 usize 进行索引,这促使用户只使用这种类型,即使更小的类型也足够了。这与 C 语言形成了鲜明对比,在 C 语言中,32 位 int 是最受欢迎的选择。在 64 位平台上,使用 usize 索引更容易优化,无需依赖未定义的行为,但额外的位数可能会给寄存器和内存带来更大压力。
  • 对于字符串和片段,惯用的 Rust 总是传递指针和大小。直到我将几个代码库从 C 语言移植到 Rust 后,我才意识到有多少 C 语言函数只获取指向内存的指针,而不获取大小,并寄希望于最好的结果(大小要么是从上下文中间接得知的,要么只是假定对任务来说足够大)。
  • for item in arrarr.iter().for_each(...) 尽可能高效,但如果需要使用 for i in 0..len {arr[i]} 的形式,那么性能取决于 LLVM 优化器能否证明长度匹配。有时无法证明,约束检查就会抑制自动矢量化。当然,有各种安全和不安全的解决方法。
  • 在 Rust 中,”巧妙 “使用内存是不受欢迎的。而在 C 语言中,一切皆有可能。举例来说,在 C 语言中,我很容易将分配给某一用途的缓冲区重复用于另一用途(这就是所谓的 HEARTBLEED 技术)。为可变大小的数据(例如 PATH_MAX)提供固定大小的缓冲区很方便,可以避免(重新)分配不断增长的缓冲区。Idiomatic Rust 仍然提供了大量内存分配控制功能,可以实现内存池、将多个分配合并为一个、预分配空间等基本功能,但总的来说,它会引导用户 “无聊 “地使用内存。
  • 如果借用检查规则让事情变得棘手,简单的办法就是进行额外复制或使用引用计数。随着时间的推移,我学会了很多借用检查技巧,并调整了我的编码风格,使之对借用检查友好,所以这种情况不再经常出现。这从来不会成为一个大问题,因为如果有必要,我们总是可以退而求其次,使用 “原始 “指针。

    Rust 的借用检查(borrow checker)程序因讨厌双链表而臭名昭著,但幸运的是,链表在 21 世纪的硬件上运行速度很慢(缓存位置性差,没有矢量化)。Rust 的标准库中有链接表,也有更快且对借用检查器友好的容器可供选择。

    还有两种情况是借用检查程序无法容忍的:内存映射文件(来自进程外部的神奇变化违反了引用的不可变^排他性语义)和自引用结构体(按值传递结构体会使其内部指针悬空)。解决这些问题的方法要么是使用与 C 语言中所有指针一样安全的原始指针,要么是在它们周围进行安全抽象的智力练习。

  • 对于 Rust 来说,单线程程序是一个不存在的概念。Rust 允许单个数据结构为了性能而采用非线程安全数据结构,但任何允许线程间共享的数据结构(包括全局变量)都必须同步或标记为不安全数据结构。
  • 我总是忘记 Rust 的字符串支持一些廉价的就地操作,例如 make_ascii_lowercase()(直接等同于我在 C 语言中的操作),而且不必要地使用了 Unicode 感知、复制 .to_lowercase()。说到字符串,UTF-8 编码并不像看上去的那样是个大问题,因为字符串有 .as_bytes() 视图,所以如果需要,可以用无视 Unicode 的方式处理它们。
  • libc 不遗余力地提高 stdoutputc 的速度。Rust 的 libstd 就没那么神奇了,所以除非使用 BufWriter 封装,否则 I/O 不会缓冲。我见过有人抱怨 Rust 比 Python 慢,那是因为 Rust 花了 99% 的时间逐字节刷新结果,完全按照要求。

可执行文件大小

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

就每个函数而言,Rust 代码的大小与 C 语言差不多,但存在 “泛型膨胀 “问题。泛型函数会针对其使用的每种类型获得优化版本,因此同一个函数可能会有 8 个版本。 cargo-bloat帮助找到这些。

在 Rust 中使用依赖关系超级简单。与 JS/npm 类似,在 Rust 中也有一种制作小型单用途库的文化,但这些库的数量确实在不断增加。最终,我的所有可执行文件都包含了 Unicode 规范化表、7 种不同的随机数生成器,以及支持 Brotli 的 HTTP/2 客户端。cargo-tree 可用于对它们进行删减。

Rust 的小胜利

我已经谈了很多关于开销的问题,但 Rust 也有自己更高效、更快速的地方:

  • C 语言库通常会返回指向其数据结构的不透明指针,以隐藏实现细节并确保结构体的每个实例只有一个副本。这需要耗费堆分配和指针间接。Rust 内建的隐私、单一所有权规则和编码约定让库可以不经间接就公开其对象,这样调用者就可以决定是将其放在堆上还是栈上。堆栈上的对象可以进行非常积极的优化,甚至可以完全优化掉。
  • Rust 默认可以内联标准库、依赖库和其他编译单元中的函数。在 C 语言中,我有时不愿意拆分文件或使用库,因为这会影响内联,并需要对头文件和符号可见性进行微观管理。
  • 结构字段会重新排序,以尽量减少填充。使用 -Wpadding 编译 C 语言时,我经常会忘记这个细节。
  • 字符串的大小编码在其 “胖 “指针中。这使得长度检查变得快速,消除了意外的 O(n²) 字符串循环的风险,并允许就地制作子串(例如,将字符串分割为标记),而无需修改内存或复制以添加 \0 结束符。
  • 与 C++ 模板一样,Rust 会为每种类型生成通用代码的副本,因此函数(如 sort())和容器(如哈希表)总是针对其类型进行优化。而在 C 语言中,我只能选择使用宏或效率较低的函数,这些函数适用于 void* 和运行时变量大小。
  • Rust 的迭代器可以组合成链,作为一个单元一起优化。因此,我可以调用 it.buy().use().break().change().upgrade().mail(),而不是调用 buy(it);use(it);break(it);change();mail(upgrade(it)),这样就可以编译成一个 buy_use_break_change_mail_upgrade(it),从而在一次组合传递中完成所有优化。(0..1000).map(|x| x*2).sum() 编译后返回 999000。
  • 同样,还有读取和写入接口,允许函数将未缓冲数据流化。它们很好地结合在一起,因此我可以将数据写入一个数据流,该数据流会即时计算数据的 CRC、在需要时添加框架/转码、压缩数据并将其写入网络,所有这一切只需一次调用。我还可以将这样的组合流作为输出流传递给我的 HTML 模板引擎,因此现在每个 HTML 标签都能聪明地发送压缩后的数据。底层机制只是一个普通 next_stream.write(bytes) 调用的金字塔,因此从技术上讲,没有什么能阻止我在 C 语言中做同样的事情,只是 C 语言中缺乏特质和泛型,这意味着在实际中很难做到这一点,除非在运行时设置回调,但这并不高效。
  • 在 C 语言中,过度使用线性搜索和链表是完全合理的,因为谁会去维护另一个哈希表的半吊子实现呢?没有内置的容器,依赖关系又很麻烦,所以我只能偷工减料来完成工作。除非万不得已,我不会费心去写一个复杂的 B 树实现。我会使用 qsort + bisect,然后收工。相反,在 Rust 中,只需一两行代码,就能高质量地实现各种容器。这意味着我的 Rust 程序每次都能使用适当的、优化得令人难以置信的数据结构。
  • 如今,似乎所有东西都需要 JSON。Rust 的 serde 是世界上最快的 JSON 解析器之一,它可以直接将 JSON 解析为 Rust 结构,因此使用解析后的数据也非常快速高效。

Rust 的重大胜利

Rust 对所有代码和数据都强制执行线程安全,即使是第三方库中的代码,即使这些代码的作者并不关注线程安全。所有东西要么坚持特定的线程安全保证,要么就不允许跨线程使用。如果我编写了任何不符合线程安全的代码,编译器会准确指出其不安全之处。

这与 C 语言的情况截然不同。通常情况下,除非有明确的文档说明,否则不能相信任何库函数是线程安全的。程序员必须确保所有代码都是正确的,编译器通常对此无能为力。多线程 C 代码承担着更多的责任和风险,因此,假装多核 CPU 只是一种时尚,想象用户可以用剩下的 7 核或 15 核做更好的事情,是很有吸引力的。

Rust 能保证不出现数据竞赛和内存不安全问题(例如use-after-free bugs,甚至跨线程)。不仅仅是一些可以通过启发式方法或在运行时通过仪器构建发现的竞赛,而是所有地方的所有数据竞赛。这是救命稻草,因为数据竞赛是最糟糕的并发错误。它们会发生在用户的机器上,但不会发生在我的调试器中。还有其他类型的并发错误,例如锁定原语使用不当导致高层逻辑竞赛条件或死锁,Rust 也无法消除它们,但它们通常更容易重现和修复。

在 C 语言中,我不敢在简单的 for 循环中使用更多的 OpenMP 实用程序。我曾尝试过对任务和线程进行更大胆的处理,结果每次都后悔莫及。

Rust 有很好的数据并行、线程池、队列、任务、无锁数据结构等库。有了这些构件的帮助,再加上类型系统强大的安全网,我可以很轻松地并行化 Rust 程序。在某些情况下,只需将 iter() 替换为 par_iter(),只要能编译,就能运行!这并不总是线性提速(阿姆达尔定律很残酷),但通常只需相对较少的工作就能实现 2×-3× 的提速。

Rust 和 C 语言库记录线程安全的方式有一个有趣的区别。Rust 有线程安全特定方面的词汇,如 SendSync、guard 和 cells。在 C 语言中,没有 “你可以在一个线程中分配它,在另一个线程中释放它,但你不能同时在两个线程中使用它 “这样的词汇。Rust 用数据类型来描述线程安全,并将其推广到所有使用数据类型的函数。而在 C 语言中,线程安全是在单个函数和配置标志的背景下讨论的。Rust 的保证往往是编译时的,或者至少是无条件的。在 C 语言中,”只有当 turboblub 选项设置为 7 时,该函数才是线程安全的 “这种说法很常见。

总结一下

较高层次的抽象、简易的内存管理和丰富的可用库往往会让 Rust 程序拥有更多代码、做更多事情,如果不加以控制,可能会导致程序臃肿。然而,Rust 程序的优化效果也相当不错,有时甚至优于 C 语言。C 语言适合逐字节逐指针地编写最少的代码,而 Rust 则拥有强大的功能,可以将多个函数甚至整个程序库有效地组合在一起。

但 Rust 最大的潜力在于,它能无畏地将大部分 Rust 代码并行化,即使同等的 C 代码并行化风险太大。在这方面,Rust 是一种比 C 语言成熟得多的语言。

本文文字及图片出自 Speed of Rust vs C

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号