使用 Rust 语言编写的程序,其运行时速度和内存使用情况应该和用 C 语言编写的程序相差不大,但是,由于这些语言的整体编程风格不同,所以它们的速度很难一概而论。本文总结了 Rust 和 C 有何相同之处,以及什么情况 C 更快,什么情况 Rust 更快。
声明:本文并非一个客观的基准,只是揭示了这些语言无可争辩的事实。这两种语言理论上能够实现什么,以及在实践中如何使用,存在显著的差异。这种特别的比较是基于我个人的主观经验,包括有交付截止日期、有 Bug,还有懒惰。Rust 语言作为我的主要编程语言已经超过 4 年了,而之前我使用 C 语言也有 10 年之久。在本文中,我专门将 Rust 与 C 进行比较,因为与 C++ 相比,将会有更多的“如果”和“但是”,而我并不想深入讨论。
简而言之:
Rust 的抽象是把双刃剑。它们能够隐藏不良代码,但是也使得改进算法和利用高度优化的库更加容易。
我从来没有担心过使用 Rust 会陷入性能死胡同。总是存在一个不安全的逃生舱口,可以进行非常低级的优化(而且通常不需要)。
无畏并发(fearless concurrency)确实存在。借用检查器(borrow checker)偶尔的“笨拙”使并行编程变得实用。
我的总体感觉是,如果可以花费无穷无尽的时间和精力,我的 C 程序将和 Rust 一样快,甚至比 Rust 还快,因为在理论上,没有什么是 C 做不到而 Rust 可以做到的。但实际上,C 的抽象较少,标准库很原始,依赖情况也很糟糕,我真的没有时间每次都重新“发明轮子”。
Rust 和 C 的相似与不同
两者都是“可移植汇编器”
Rust 和 C 都给出了对数据结构布局、整数大小、堆与堆内存分配、指针间接寻址控制,一般来说,只要编译器插入一点“魔法”,就可以翻译成可理解的机器代码。Rust 甚至承认,字节有 8 位,带符号的整数可能会溢出!
虽然 Rust 具有更高级别的结构,比如迭代器、特性(traits)和智能指针,但是这些结构被设计成可以预测的优化直接机器代码(也就是“零成本抽象”)。Rust 的类型的内存布局很简单,例如,可增长的字符串和向量正是 {byte,capacity,length}。Rust 没有任何像 move 或 copy 构造函数这样的概念,因此保证对象的传递并不比传递指针或 memcpy 复杂。
借用检查只是编译时的一种静态分析。在生成代码之前,它什么也不做,生命周期信息就被完全剥离了。不存在自动装箱(autoboxing)之类的聪明做法。
Rust 不是“愚蠢”的代码生成器的一个例子是展开(unwinding)。尽管 Rust 不是用异常来处理正常的错误,但是 panic(未处理的致命错误)可以有选择地以 C++ 异常的形式出现。可能会在编译时禁用(panic = abort),但即便如此,Rust 也不喜欢与 C++ 异常或 longjmp 混在一起。
老样子的 LLVM 后端
由于 Rust 与 LLVM 集成非常好,因此它支持链接时优化(Link-Time Optimization,LTO),包括 ThinLTO,甚至支持跨 C/C++/Rust 语言边界的内联,还有配置文件引导的优化。虽然 rustc 生成的 LLVM IR 比 clang 冗长得多,但是优化器能够很好地处理。
在使用 GCC 编译时,我的一些 C 代码会比 LLVM 更快一些,而且 GCC 没有 Rust 前端,而 Rust 没有做到这一点。
从理论上讲,Rust 允许比 C 更好的优化,因为它具有更严格的不可变性和别名规则,但是实际上这还没有发生。对于 LLVM,除 C 外的优化工作正在进行,所以 Rust 还没有充分发挥出它的潜力。
除少数例外,这两者都允许手动调优
Rust 代码是低级的,而且很容易预测,我可以手动调优它所优化的汇编。Rust 支持 SIMD,能够很好地控制对内联、调用约定等。Rust 语言与 C 语言很相似,以至于 C 语言的 profiler 分析器通常可以与 Rust 语言一起使用(例如,我可以在一个 Rust-C-Swift 三明治式程序上使用 Xcode 的工具)。
一般来说,在性能绝对关键且需要手工优化到最后一点时,优化 Rust 语言与优化 C 语言之间并无太大差别。
有些低级的功能,Rust 并没有合适的替代:
计算的 goto。goto 的“无聊”用法可以被 loop{break} 等其他 Rust 构造所替代。很多 goto 的用法在 C 语言中是用来清理的,而且由于 RAII/destructors 的存在,Rust 不需要清理。但是,有一个非标准的 goto *addr 扩展可以用于解释器。Rust 不能直接执行(你可以写一个匹配,并希望它能优化),但是另一方面,如果我需要一个解释器,我将尝试使用 Cranelift JIT 来代替。
alloca 和 C99 可变长度数组。它们甚至在 C 语言中也存在争议,因此 Rust 语言不会使用它们。
Rust 少量开销
但是,如果 Rust 没有进行手动调优,则会出现一些低效问题:
Rust 缺乏针对 usize 进行索引的隐式类型转换,这促使用户仅使用该类型,即使在较小的类型足够时也是如此。和 C 语言形成鲜明对比的是,32 位的 int 是最受欢迎的选择。通过 usize 索引在 64 位平台上更容易优化,无需依赖于未定义的行为,但是额外的位会给寄存器和内存带来更大的压力。
惯用的 Rust 总是将指针和大小传递给字符串和切片。在将 C 语言的几个代码库移植到 Rust 之前,我还没有意识到有多少 C 语言的函数仅仅使用一个指向内存的指针,而没有任何大小,并且希望得到最佳结果(这些大小可以从上下文中间接地知道,或者仅仅假定它足够执行该任务)。
并非所有的边界检查都得到了优化。用于 arr 中的 item 或者 arr.iter().for_each(...) 都是尽可能高效的,但是如果 i 的形式在 0..len {arr[i]} 中是必需的,那么性能就取决于 LLVM 优化器能否证明长度匹配。有时候无法进行,约束检查就会抑制自动向量化(autovectorization)。有各种变通方法,当然,有安全的,也有不安全的。
“聪明”地使用内存在 Rust 中不受欢迎。对于 C,任何东西都可以。举例来说,在 C 语言中,我可能会尝试将为某个用途而分配的缓冲区再用于其他用途(这种技术叫做 HEARTBLEED)。对于可变大小的数据(如 PATH_MAX),使用固定大小的缓冲区是很方便的,可以避免(重新)分配不断增长的缓冲区。惯用的 Rust 仍然对内存分配有很大的控制权,可以做一些基本的事情,如内存池、将多个分配合并为一个,预分配空间等等,但是总体来说,它会引导用户使用“无聊”的用法或内存。
如果借用检查规则使事情变得困难,那么一个简单的解决办法就是进行额外的复制或者使用引用计数。久而久之,我学到了很多关于借用检查器的技巧,并且调整了我的编码风格,使之更适合于借用检查器,因此这种情况已经很少发生了。它永远不会成为一个大问题,因为在必要的情况下,总有一个可以回退到“原始”指针。
Rust 的借用检查器以讨厌双向链表而臭名昭著,但幸运的是,链表在目前的硬件上的运行非常缓慢(缓存局部性差,而且没有向量化)。Rust 的标准库提供了链表,以及更快、更适合于借用检查器的容器可供选择。
有两种借用检查器无法忍受的情况:内存映射文件(来自进程外的神奇变化与引用的不可变性 ^ 排他性语义相冲突)和自引用结构(通过值传递结构将内部指针悬空)。这种情况可以通过原始指针解决,就像 C 语言中的每个指针一样安全,也可以通过心理体操来抽象出这些指针的安全。
在 Rust 中,单线程程序只是不作为一个概念存在而已。为了提高性能,Rust 允许使用单个数据结构而忽视线程安全,但是任何允许在线程之间共享的东西(包括全局变量)必须同步,或者标记为不安全。
Rust 的字符串支持一些廉价的就地操作,例如 make_ascii_lowercase()(直接与 C 语言中的操作等同),而 .to_lowercase() 的复制不需要使用 Unicode-aware 的方式。说到字符串,UTF-8 编码并不像看上去那么麻烦,因为字符串具有 .as_bytes() 视图,所以如果需要的话,可以使用 Unicode-ignorant 的方式来处理。
libc 会尽其所能让 stdout 和 putc 变得相当快。Rust 的 libstd 没有这么神奇,因此除非用 BufWriter 进行包装,否则不会缓冲 I/O。有些人抱怨说 Rust 比 Python 慢,这是因为 Rust 花了 99% 的时间逐字节刷新结果,这与我们所说的完全相同。
可执行文件的大小
每一种操作系统都会内置一些标准的 C 库,这些 C 库是 C 可执行文件“免费”得到的约 30MB 的代码,比如一个小小的“Hello World” C 可执行文件实际上无法输出任何内容,它只是调用操作系统附带的 printf。Rust 不能指望操作系统会内置 Rust 的标准库,因此 Rust 可执行文件捆绑了自己的标准库(300KB 以上)。幸好,这是可以减少的一次性开销。在嵌入式开发中,标准库可以关闭,Rust 将生成“裸”代码。
Rust 代码的大小与 C 语言中每个函数的大小相差不多,但存在“泛型膨胀”(generics bloat)的问题。对于每一种类型,都会有泛型函数经过优化的版本,因此有可能同一个函数最终有 8 个版本,cargo-bloat 工具可以帮助查找它们。
在 Rust 中使用依赖关系非常简单。类似于 JS/npm ,也有一种制作小型单用途库的文化,但它们确实是合二为一。最终,我所有的可执行文件都包含了 Unicode 规范化表、7 个不同的随机数生成器,以及一个支持 Brotli 的 HTTP/2 客户端。在重复数据删除(deduping)和删除数据时,cargo-tree 非常有用。
Rust 取得小胜之处
在讨论开销时,我已经讨论了许多,但是 Rust 还存在一些地方,它最终更加高效和快速:
为了隐藏实现细节,C 库经常返回不透明的数据结构指针,并确保结构的每个实例只有一个副本。它会消耗堆分配和指针间接寻址的成本。Rust 内置的隐私、单一所有权规则和编码惯例允许库暴露其对象,而不需要间接性,这样,调用者可以决定将其放入堆(heap)上还是栈(stack)中。可以主动或彻底地优化栈上的对象。
缺省情况下,Rust 可以将来自标准库、依赖项和其他编译单元的函数内联。对于 C 语言,我有时不愿意拆分文件或使用库,因为这会影响内联,而且需要对头和符号可见性进行微观管理。
对结构体字段进行重新排序,减少数据结构的填充(padding)。当用 -Wpadding 编译 C 语言时,会显示我有多经常忘记这个细节。
字符串的大小在它的“胖”指针中进行编码。这使得长度检查速度很快,避免了意外的 O(n²) 字符串循环,并允许就地生成子串(例如将一个字符串分割成标记),无需通过修改内存或复制来添加 \0 终止符。
与 C++ 模板类似,Rust 也会为它们使用的每个类型生成泛型代码的副本,因此像 sort() 这样的函数和像哈希表这样的容器总是针对它们的类型进行优化。对于 C 语言,我必须在修改宏或者处理 void* 和运行时变量大小的效率较低的函数之间做出选择。
可以将 Rust 迭代器组合成链,作为一个单元进行优化。因此,与调用 ibuy(it); use(it); break(it); change(it); mail(upgrade(it)); 一系列可能会多次重写同一缓冲区调用的操作相比,我更愿意这样调用 it.buy().use().break().change().upgrade().mail(),编译成 buy_use_break_change_mail_upgrade(),优化后在一次组合传递中完成所有这些操作。(0..1000).map(|x| x*2).sum() 编译为返回 999000。
另外,还有读取和写入接口,允许函数流式传输未缓冲的数据。它们结合得很好,所以我可以把数据写到一个流中,这个流可以动态地计算数据的 CRC,如果需要,还可以添加 framing/escaping,对数据进行压缩,然后将数据写入网络,所有这些都是一次性调用。同时,我还可以将这样的组合流作为输出流传递给我的 HTML 模板引擎,因此现在每个 HTML 标签都足够智能,可以压缩后发送。底层机制就像普通的 next_stream.write(bytes) 调用的金字塔一样,所以从技术上讲,没有什么可以阻止我在 C 语言中做同样的事情,只是 C 语言中缺乏特性和泛型,这意味着在实际操作中很难做到这一点,而且除了在运行时设置回调之外,其他的效率都不高。
对于 C 语言来说,过度使用线性搜索和链表是完全合理的,因为谁会去维护又一个半吊子哈希表的实现呢?由于没有内置的容器,依赖性非常麻烦,所以我偷工减料去完成任务。我不会去写一个复杂的 B 树实现,除非绝对必要。我会用 qsort + bisect,然后收工。在 Rust 中,OTOH 仅需 1 到 2 行代码就能实现各种容器,其质量非常高。那就意味着我的 Rust 程序每次都可以使用适当的、难以置信的、经过优化的良好数据结构。
如今似乎一切都需要 JSON。Rust 的 serde 是世界上最快的 JSON 解析器之一,它可以直接解析到 Rust 结构中,因此使用解析后的数据也是非常快速和高效的。
Rust 取得大胜之处
即使是在第三方库中,Rust 也会强制实现所有代码和数据的线程安全,哪怕那些代码的作者没有注意线程安全。一切都遵循一个特定的线程安全保证,或者不允许跨线程使用。当我编写的代码不符合线程安全时,编译器会准确地指出不安全之处。
它和 C 语言中的情况完全不同。一般来说,除非库函数具有明确的文档说明,否则不能相信它们线程安全。程序员需要确保所有代码都是正确的,而编译器对此通常无能为力。多线程化的 C 代码有更多的责任和风险,因此假装多核 CPU 是一种时尚,并且想象用户有更好的事情可以用剩下的 7 到 15 个核来做,这非常吸引人。
Rust 保证了不受数据争用和内存不安全的影响(例如,释放后使用(use-after-free)bug,甚至跨线程)。并非只有一些争用可以通过启发式方法或者工具构建在运行时被发现,而是所有的数据争用都可以被发现。它是救命稻草,因为数据争用是并行错误中最糟糕的。它们会发生在我用户的机器上,而不会发生在我的调试器中。也有其他类型的并发错误,比如锁基元使用不当导致更高级别的逻辑争用条件或死锁,Rust 无法消除这些错误,但它们通常更容易重现和修复。
我不敢用 C 语言在简单的 for 循环上使用更多的 OpenMP 实用程序。我曾试图更多地在任务和线程上冒险,但是结果总是令人遗憾。
Rust 已经有了很多库,如数据并行、线程池、队列、任务、无锁数据结构等。有了这类构件的帮助,再加上类型系统强大的安全网,我就可以很轻松地并行化 Rust 程序了。有些情况下,用 par_iter() 代替 iter() 是可以的,只要能够进行编译,就可以正常工作!这并不总是线性加速( 阿姆达尔定律(Amdahl's law)很残酷),但往往是相对较少的工作就能加速 2~3 倍。
延伸:阿姆达尔定律,一个计算机科学界的经验法则,因 Gene Amdahl 而得名。它代表了处理器并行计算之后效率提升的能力。
在记录线程安全方面,Rust 和 C 有一个有趣的不同。Rust 有一个词汇表用于描述线程安全的特定方面,如 Send 和 Sync、guards 和 cell。对于 C 库,没有这样的说法:“可以在一个线程上分配它,在另一个线程上释放它,但不能同时从两个线程中使用它”。根据数据类型,Rust 描述了线程安全性,它可以泛化到所有使用它们的函数。对于 C 语言来说,线程安全只涉及单个函数和配置标志。Rust 的保证通常是在编译时提供的,至少是无条件的。对于 C 语言,常见的是“仅当 turboblub 选项设置为 7 时,这才是线程安全的”。
总结
Rust 足够低级,如果有必要,它可以像 C 一样进行优化,以实现最高性能。抽象层次越高,内存管理越方便,可用库越丰富,Rust 程序代码就越多,做的事情越多,但如果不进行控制,可能导致程序膨胀。然而,Rust 程序的优化也很不错,有时候比 C 语言更好,C 语言适合在逐个字节逐个指针的级别上编写最小的代码,而 Rust 具有强大的功能,能够有效地将多个函数甚至整个库组合在一起。
但是,最大的潜力是可以无畏地并行化大多数 Rust 代码,即使等价的 C 代码并行化的风险非常高。在这方面,Rust 语言是比 C 语言更为成熟的语言。
作者介绍:
Kornel,程序员,专长图像压缩领域。喜欢闲聊。博客写手。