RUST性能手册

First published in November 2020

Written by Nicholas Nethercote and others

Chinese translated by Blues-star

简介

性能对许多Rust程序来说都很重要。

本书包含了许多可以提高Rust程序的性能-速度和内存使用率的技术,其中编译时间部分也包含了一些可以提高Rust程序编译时间的技术。编译时间]部分也包含了一些可以改善Rust程序编译时间的技术。本书的一些技术只需要改变构建配置,但许多技术需要改变代码。

一些技术完全是 Rust 特有的,而一些涉及的思想可以应用于其他编程语言编写的程序(通常需要进行修改)。General Tips 部分还包括适用于任何编程语言的一些一般原则。尽管如此,这本书主要关注 Rust 程序的性能,不能替代一本关于分析和优化的通用指南。

本书侧重于实用且经过验证的技术:许多技术都附有指向拉取请求或其他资源的链接,展示了这些技术在真实的 Rust 程序中的应用。这反映了主要作者的背景,有点偏向编译器开发,而不太涉及其他领域,如科学计算。

本书有意简洁,更注重广度而非深度,使其阅读起来迅速。在适当的情况下,它会链接到提供更深入信息的外部资源。

本书面向中级和高级 Rust 用户。初学者 Rust 用户有很多东西要学习,这些技术可能会对他们造成不必要的干扰。

Benchmarking

基准测试通常涉及比较执行相同任务的两个或多个程序的性能。有时可能涉及比较两个或多个不同的程序,例如 Firefox vs Safari vs Chrome。有时涉及比较同一程序的两个不同版本。后一种情况让我们能够可靠地回答问题“这个变化是否加快了速度?”

基准测试是一个复杂的主题,全面覆盖超出了本书的范围,但以下是基础知识。

首先,您需要工作负载来进行测量。理想情况下,您会有各种代表程序实际使用情况的工作负载。使用真实世界输入的工作负载最好,但microbenchmarks压力测试在适度的情况下也是有用的。

其次,您需要一种运行工作负载的方式,这也将决定所使用的度量标准。

Rust 内置的benchmark tests是一个简单的起点,但它们使用不稳定的功能,因此仅适用于夜间版的 Rust。 CriterionDivan 是更复杂的替代方案。 Hyperfine 是一个出色的通用基准测试工具。 也可以使用自定义基准测试工具。例如,rustc-perf 是用于对 Rust 编译器进行基准测试的工具。

在度量标准方面,有许多选择,选择合适的度量标准取决于正在进行基准测试的程序的性质。例如,对于批处理程序(batch program)有意义的度量标准可能对交互式程序(interactive program)没有意义。在许多情况下,Wall-time是一个显而易见的选择,因为它对应于用户的感知。然而,它可能受到高方差的影响。特别是,内存布局中微小的变化可能导致显著但短暂的性能波动。因此,具有较低方差的其他度量标准(如周期cycles或指令计数)可能是一个合理的替代方案。

总结来自多个工作负载的测量结果也是一个挑战,有许多方法可以做到这一点,没有一种方法显然是最好的。

良好的基准测试很困难。话虽如此,在拟进行程序优化时,不要过分强调拥有完美的基准测试设置,尤其是在开始优化程序时。一般的基准测试要比没有基准测试好得多。保持对您正在测量的内容开放的态度,随着时间的推移,您可以根据了解到的程序性能特征进行基准测试改进。

构建配置

您可以通过更改构建配置而不更改代码,从而显著改变 Rust 程序的性能。对于每个 Rust 程序,都有许多可能的构建配置。所选择的配置将影响编译代码的几个特征,如编译时间、运行时速度、内存使用、二进制大小、调试性、性能分析性以及编译程序将在哪些架构上运行。

大多数配置选择会改善一个或多个特征,同时恶化一个或多个其他特征。例如,一个常见的权衡是为了获得更高的运行时速度而接受更差的编译时间。对于您的程序来说,正确的选择取决于您的需求和程序的具体情况,与性能相关的选择(其中大部分都是)应该通过基准测试来验证。

请注意,Cargo 只查看工作区根目录下 Cargo.toml 文件中的配置设置。在依赖项中定义的配置设置将被忽略。因此,这些选项主要与二进制 crate 相关,而不是库 crate。

发布构建

最重要的一个Rust性能提示很简单,但很容易被忽视:当你想要高性能时,确保你使用的是release构建而不是debug构建。这通常是通过在Cargo中指定--release标志来实现的。

开发构建是默认设置。它们适用于调试,但没有经过优化。如果运行 cargo build 或 cargo run,则会生成这些构建。(另外,运行 rustc 而不添加额外选项也会生成未经优化的构建。)

考虑以下来自 cargo build 运行的输出的最后一行。

Finished dev [unoptimized + debuginfo] target(s) in 29.80s

这个输出表明已生成了一个开发构建。编译后的代码将放在 target/debug/ 目录中。cargo run 将运行开发构建。

相比之下,发布构建经过了更多优化,省略了调试断言和整数溢出检查,也省略了调试信息。相对于开发构建,通常可以实现 10-100 倍的速度提升!如果运行 cargo build --releasecargo run --release,则会生成这些构建。(另外,rustc 有多个选项用于优化构建,如 -O-C opt-level。)由于额外的优化,这通常会比开发构建花费更长的时间。

请看下面的"cargo build --release"运行的最后一行输出。

Finished release [optimized] target(s) in 1m 01s

这个输出表明已生成了一个发布构建。编译后的代码将放在 target/release/ 目录中。cargo run --release 将运行发布构建。

查看 Cargo 配置文件文档 以获取有关开发构建(使用 dev 配置文件)和发布构建(使用 release 配置文件)之间差异的更多详细信息。

发布构建中使用的默认构建配置选择在编译时间、运行时速度和二进制文件大小等方面提供了良好的平衡。但正如下文所述,还有许多可能的调整。

最大化运行时速度

以下构建配置选项主要旨在最大化运行时速度。其中一些选项也可能会减小二进制文件大小。

codegen units

Rust编译器将crate分割为多个codegen units以并行化编译(从而加快速度)。然而,这可能导致它错过一些潜在的优化。您可以通过将单元数设置为1来提高运行时速度并减小二进制文件大小,但这会增加编译时间。请将以下行添加到Cargo.toml文件中:

[profile.release]
codegen-units = 1

Example 1, Example 2.

链接时优化

链接时优化(LTO)是一种整体程序优化技术,可以提高运行时速度10-20%或更多,并减小二进制文件大小,但会导致较差的编译时间。它有几种形式。

LTO的第一种形式是thin local LTO,这是一种轻量级的LTO形式。默认情况下,编译器会在涉及非零优化级别的任何构建中使用此形式。这包括发布构建。要显式请求此级别的LTO,请将以下行放入Cargo.toml文件中:

[profile.release]
lto = false

LTO的第二种形式是thin LTO,它稍微更具侵略性,可能会提高运行时速度并减小二进制文件大小,同时也会增加编译时间。在Cargo.toml中使用lto = “thin“来启用它。

LTO的第三种形式是fat LTO,它更具侵略性,可能会进一步提高性能并减小二进制文件大小,同时再次增加构建时间。在Cargo.toml中使用lto = “fat“来启用它。

最后,可以完全禁用LTO,这可能会降低运行时速度并增加二进制文件大小,但会减少编译时间。在Cargo.toml中使用lto = “off“来实现此目的。请注意,这与lto = false选项不同,如上所述,后者会保留thin local LTO。

替代分配器

可以使用替代分配器替换Rust程序使用的默认(系统)堆分配器。具体效果取决于个别程序和所选择的替代分配器,但在实践中已经看到了运行时速度大幅提升和内存使用大幅减少。效果还会因平台而异,因为每个平台的系统分配器都有其优势和劣势。使用替代分配器还可能增加二进制文件大小和编译时间。

jemalloc

一种流行的适用于Linux和Mac的替代分配器是jemalloc,可通过tikv-jemallocator crate使用。要使用它,请在您的Cargo.toml文件中添加一个依赖项:

[dependencies]
tikv-jemallocator = "0.5"

然后在您的Rust代码中添加以下内容,例如在src/main.rs的顶部:

#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

此外,在Linux上,jemalloc可以配置为使用透明大页。这可以进一步加快程序的运行速度,可能会以更高的内存使用为代价。

在构建程序之前,通过适当设置 MALLOC_CONF 环境变量来执行此操作,例如:

MALLOC_CONF="thp:always,metadata_thp:always" cargo build --release

运行编译程序的系统还必须配置为支持THP。有关更多详细信息,请参阅此博客

mimalloc

另一个适用于许多平台的替代分配器是mimalloc,可通过mimalloc crate使用。要使用它,请在您的Cargo.toml文件中添加一个依赖项:

[dependencies]
mimalloc = "0.1"

然后在您的Rust代码中添加以下内容,例如在src/main.rs的顶部:

#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

使用CPU专用指令

如果您不关心二进制文件在旧版(或其他类型的)处理器上的兼容性,您可以告诉编译器生成针对特定CPU架构的最新(可能是最快的)指令,比如针对x86-64 CPU的AVX SIMD指令。

例如,如果你把-C target-cpu=native传给rustc,它将使用当前CPU的最佳指令。

$ RUSTFLAGS="-C target-cpu=native" cargo build --release

或者,要从一个[config.toml]文件(用于一个或多个项目)中请求这些指令,请添加以下行:

[build]
rustflags = ["-C", "target-cpu=native"]

这可能会有助于提升运行时的性能,特别是当编译器在你的代码中发现矢量化的机会时。

如果您不确定-C target-cpu=native是否达到了最佳效果,请比较rustc --print cfgrustc --print cfg -C target-cpu=native的输出,看看在后一种情况下是否正确检测到了CPU特性。如果没有,您可以使用-C target-feature来针对特定特性。

Profile-guided Optimization

Profile-guided optimization(PGO)是一种编译模式,即编译程序后,在收集样本数据的同时在样本数据上运行,然后用样本数据引导程序的第二次编译。这可以提升10%或更多的运行时性能 Example 1, Example 2.

这是一种高级技术,需要一些设置工作,但在某些情况下是值得的。详细信息请参阅rustc PGO文档。此外,[cargo-pgo]命令使使用PGO(以及类似的BOLT)来优化Rust二进制文件变得更加容易。

不幸的是,对于托管在crates.io上并通过cargo install分发的二进制文件,不支持PGO,这限制了其可用性。

最小化二进制文件大小

以下构建配置选项主要旨在最小化二进制文件大小。它们对运行时速度的影响各不相同。

优化级别

您可以通过向Cargo.toml文件添加以下行来请求一个旨在最小化二进制文件大小的优化级别:

[profile.release]
opt-level = "z"

这可能会降低运行时速度。

另一种选择是opt-level = "s",它针对最小化二进制文件大小的目标略微不那么激进。与opt-level = "z"相比,它允许稍微更多的内联和循环的矢量化。

panic!时中止

如果您不需要在发生恐慌时展开,例如因为您的程序不使用catch_unwind,您可以告诉编译器在恐慌时简单地abort on panic。在发生恐慌时,您的程序仍将生成回溯信息。

这可能会减小二进制文件大小并略微增加运行时速度,甚至可能会略微减少编译时间。将以下内容添加到Cargo.toml文件中:

[profile.release]
panic = "abort"

剥离调试信息和符号

您可以告诉编译器从编译后的二进制文件中[剥离]调试信息和符号。将以下内容添加到Cargo.toml中以仅剥离调试信息:

[profile.release]
strip = "debuginfo"

或者,使用strip = "symbols"来同时剥离调试信息和符号。

剥离调试信息可以极大地减小二进制文件大小。在Linux上,当剥离调试信息时,一个小型Rust程序的二进制文件大小可能会缩小4倍。剥离符号也可以减小二进制文件大小,尽管通常不会减少那么多。示例。具体效果取决于平台。

然而,剥离会使您编译的程序更难以调试和分析性能。例如,如果一个被剥离的程序发生恐慌,生成的回溯信息可能会比正常情况下包含的信息更少。两种剥离级别的具体效果取决于平台。

其他想法

要了解更多高级的二进制文件大小最小化技术,请参考优秀的min-sized-rust存储库中的全面文档。

最小化编译时间

以下构建配置选项主要旨在最小化编译时间。

链接

编译时间的一个重要部分实际上是链接时间,特别是在对程序进行小改动后重新构建时。可以选择比默认链接器更快的链接器。

一个选择是lld,它在Linux和Windows上都可用。要从命令行指定lld,请使用-C link-arg=-fuse-ld=lld标志。例如:

RUSTFLAGS="-C link-arg=-fuse-ld=lld" cargo build --release

另一种方法是从config.toml文件(针对一个或多个项目)中指定lld,添加以下内容:

[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

lld目前并不完全支持与Rust一起使用,但在Linux和Windows上的大多数用例中应该可以工作。有一个GitHub Issue跟踪lld的完全支持。

另一个选择是mold,目前在Linux和macOS上可用。只需在上述说明中用mold替换lld。mold通常比lld更快。它也要新得多,可能不适用于所有情况。

与本章中的其他选项不同,这里没有任何权衡!替代链接器可以显著提高速度,而没有任何不利影响。

实验性并行前端

如果您使用nightly版的Rust,可以启用实验性的并行前端。这可能会减少编译时间,但会增加编译时内存的使用。它不会影响生成的代码质量。

您可以通过将-Zthreads=N添加到RUSTFLAGS来实现,例如:

RUSTFLAGS="-Zthreads=8" cargo build --release

或者,要从config.toml文件(针对一个或多个项目)启用并行前端,添加以下内容:

[build]
rustflags = ["-Z", "threads=8"]

除了8之外,还可以使用其他值,但这个数字通常会产生最佳结果。

在最佳情况下,实验性并行前端可以将编译时间缩短高达50%。但效果因代码特性和构建配置的不同而异,对于某些程序,编译时间可能不会有所改喀。

Cranelift代码生成后端

如果您在x86-64/Linux或ARM/Linux上使用nightly版的Rust,可以启用Cranelift代码生成后端。它可能会减少编译时间,但会以生成的代码质量降低为代价,因此建议用于开发构建而不是发布构建。

首先,使用以下rustup命令安装后端:

rustup component add rustc-codegen-cranelift-preview --toolchain nightly

要从命令行选择Cranelift,请使用-Zcodegen-backend=cranelift标志。例如:

RUSTFLAGS="-Zcodegen-backend=cranelift" cargo +nightly build

或者,要从config.toml文件(针对一个或多个项目)指定Cranelift,添加以下内容:

[unstable]
codegen-backend = true

[profile.dev]
codegen-backend = "cranelift"

有关更多信息,请参阅Cranelift文档

自定义配置文件

除了devrelease配置文件外,Cargo还支持自定义配置文件。例如,如果您发现开发构建的运行时速度不够,发布构建的编译时间对日常开发来说太慢,那么创建一个介于devrelease之间的自定义配置文件可能会很有用。

总结

在构建配置方面有许多选择需要考虑。以下总结了上述信息并提出了一些建议。

  • 如果您想最大化运行时速度,请考虑以下所有内容:codegen-units = 1lto = "fat"、替代分配器和panic = "abort"
  • 如果您想最小化二进制文件大小,请考虑opt-level = "z"codegen-units = 1lto = "fat"panic = "abort"strip = "symbols"
  • 在任何情况下,如果不需要广泛的架构支持,请考虑使用-C target-cpu=native,如果与您的分发机制兼容,请考虑使用cargo-pgo
  • 如果您所在的平台支持更快的链接器,请始终使用它,因为这样做没有任何不利之处。
  • 逐个对所有更改进行基准测试,以确保它们产生预期效果。

最后,此问题跟踪了Rust编译器自身构建配置的演变。Rust编译器的构建系统比大多数Rust程序更奇特和复杂。尽管如此,这个问题可能有助于展示如何将构建配置选择应用于大型程序。

Linting

Clippy是一个用来捕捉Rust代码中常见错误的lints集合。它是运行在Rust代码上的一个优秀工具。它还可以帮助提高性能,因为许多lints与可能导致次优性能的代码模式有关。

鉴于自动检测问题优于手动检测问题,本书的其余部分将不会提及 Clippy 默认检测到的性能问题。

基础

Clippy 是一个 Rust 的 lint 工具,用于静态代码分析。安装后,可以通过以下命令轻松运行:

cargo clippy

可以通过访问 lint list 并取消选择除了 “Perf” 之外的所有 lint 组,查看完整的性能 lint 列表。

除了使代码更快之外,性能 lint 建议通常会导致更简单、更符合惯例的代码,因此即使对于不经常执行的代码,也值得遵循这些建议。

相反,一些非性能 lint 建议可能会提高性能。例如,ptr_arg 风格 lint 建议将各种容器参数更改为切片,例如将 &mut Vec<T> 参数更改为 &mut [T]。这里的主要动机是切片提供了更灵活的 API,但也可能由于减少间接性和为编译器提供更好的优化机会而导致更快的代码。 Example.

Disallowing Types

在接下来的章节中,我们将看到有时候值得避免使用某些标准库类型,而选择更快的替代方案。如果你决定使用这些替代方案,很容易在某些地方意外地错误使用标准库类型。

你可以使用 Clippy 的 disallowed_types lint 来避免这个问题。例如,为了禁止使用标准哈希表(原因在 Hashing 部分有解释),可以在你的代码中添加一个 clippy.toml 文件,并包含以下行。

disallowed-types = ["std::collections::HashMap", "std::collections::HashSet"]

Profiling

在优化程序时,你还需要一种方法来确定程序的哪些部分是 “热 “的(执行频率足以影响运行时间),值得修改。这一点最好通过Profiling来完成。

Profilers

有许多不同的性能分析工具可供选择,每种工具都有其优势和劣势。以下是一份未完整的性能分析工具列表,这些工具已成功用于 Rust 程序。

  • perf 是一个使用硬件性能计数器的通用性能分析工具。HotspotFirefox Profiler 适用于查看 perf 记录的数据。它适用于 Linux。
  • [Instruments] 是一个随 macOS 上的 Xcode 提供的通用性能分析工具。
  • [Intel VTune Profiler] 是一个通用性能分析工具。它适用于 Windows、Linux 和 macOS。
  • [AMD μProf] 是一个通用性能分析工具。它适用于 Windows 和 Linux。
  • [samply] 是一个采样性能分析工具,生成的分析结果可以在 Firefox Profiler 中查看。它适用于 Mac 和 Linux。
  • flamegraph 是一个 Cargo 命令,使用 perf/DTrace 对代码进行性能分析,然后在火焰图中显示结果。它适用于 Linux 和支持 DTrace 的所有平台(macOS、FreeBSD、NetBSD,可能还包括 Windows)。
  • CachegrindCallgrind 提供全局、每个函数和每个源代码行的指令计数以及模拟缓存和分支预测数据。它们适用于 Linux 和其他一些 Unix 系统。
  • DHAT 适用于找出代码中导致大量分配的部分,并提供有关峰值内存使用情况的见解。它还可以用于识别对 memcpy 的热调用。它适用于 Linux 和其他一些 Unix 系统。[dhat-rs] 是一个实验性的替代方案,功能稍弱,需要对 Rust 程序进行轻微更改,但适用于所有平台。
  • heaptrack 和 [bytehound] 是堆分析工具。它们适用于 Linux。
  • counts 支持临时性能分析,结合 eprintln! 语句和基于频率的后处理,适用于获取代码部分的领域特定见解。它适用于所有平台。
  • Coz 执行因果分析以测量优化潜力,并通过 coz-rs 支持 Rust。它适用于 Linux。

为了有效地对发布版本进行性能分析,你可能需要启用源代码行调试信息。要做到这一点,在你的 Cargo.toml 文件中添加以下行:

[profile.release]
debug = 1

查看 Cargo 文档 以获取有关 debug 设置的更多详细信息。

不幸的是,即使执行了上述步骤,你也无法获得标准库代码的详细性能分析信息。这是因为 Rust 标准库的发布版本未使用调试信息构建。

最可靠的解决方法是构建自己的编译器和标准库版本,遵循 这些说明,并在 config.toml 文件中添加以下行:

[rust]
debuginfo-level = 1

这可能有些麻烦,但在某些情况下值得努力。

另外,不稳定的 build-std 功能允许你将标准库作为程序正常编译的一部分进行编译,使用相同的构建配置。然而,标准库调试信息中存在的文件名将不指向源代码文件,因为此功能不会下载标准库源代码。因此,这种方法对于像 Cachegrind 和 Samply 这样需要源代码才能完全工作的性能分析工具并不适用。

Symbol Demangling

Rust在编译代码中使用了一个杂乱的方案来编码函数名。如果一个剖析器不知道这个方案,它的输出可能会包含像这样的符号名 _ZN3foo3barE_ZN28_$u7b$$u7b$closure$u7d$$u7d$E_ZN28_$u7b$$$u7d$E_ZN88_$LT$core.result.Result$LT$$u21$$C$$u20$E$GT$u20$as$u20$std.process.Termination$GT$6report17hfc41d0da4a40b3e8E。 像这样的名字,可以用rustfilt手动拆分。

如果在进行性能分析时遇到符号解缠混淆的问题,可能值得将 编码格式 从默认的传统格式更改为更新的 v0 格式。

要从命令行中使用 v0 格式,可以使用 -C symbol-mangling-version=v0 标志。例如:

RUSTFLAGS="-C symbol-mangling-version=v0" cargo build --release

另外,要从 config.toml 文件(针对一个或多个项目)请求这些说明,添加以下行:

[build]
rustflags = ["-C", "symbol-mangling-version=v0"]

Inlining

调用和退出热函数、未内联的函数往往占执行时间的一小部分。内联这些函数可以提供小而简单的速度优势。

有四个内联属性可以用于Rust函数。

  • None. 编译器会自己决定是否应该内联函数。这将取决于优化级别、函数的大小等。如果你没有使用链接时间优化,函数永远不会跨箱子内联。
  • #[inline]. 这表明该函数应该内嵌,包括跨越crate边界。
  • #[inline(always)]. 这强烈建议该函数应该内嵌,包括跨越crate边界。
  • #[inline(never)]. 这强烈表示该函数不应被内联。

内联属性并不保证函数是否会被内联,但实际上 #[inline(always)] 会导致内联,除非在极端情况下。

内联不具有传递性。如果函数 f 调用函数 g,并且您希望这两个函数在调用 f 的地方一起内联,那么这两个函数都应该标记为内联属性。

Simple Cases

最适合内联的是(a)非常小的函数,或者(b)只有一个调用点的函数。编译器通常会自己内联这些函数,即使没有内联属性。但是编译器不可能总是做出最好的选择,所以有时需要属性。 Example 1, Example 2, Example 3, Example 4, Example 5.

Cachegrind是一个很好的判断函数是否被内联的剖析器。当查看Cachegrind的输出时,如果(也只有当)函数的第一行和最后一行没有*标记事件数,你就可以判断该函数被内联了。 例如

      .  #[inline(always)]
      .  fn inlined(x: u32, y: u32) -> u32 {
700,000      eprintln!("inlined: {} + {}", x, y);
200,000      x + y
      .  }
      .  
      .  #[inline(never)]
400,000  fn not_inlined(x: u32, y: u32) -> u32 {
700,000      eprintln!("not_inlined: {} + {}", x, y);
200,000      x + y
200,000  }

添加内联属性后你应该再测一次,因为效果可能是不可预知的。有时它没有效果,因为附近一个之前内联的函数不再内联了。有时会拖慢代码的速度。内联也会影响编译时间,特别是交叉速率内联,它涉及到重复函数的内部表示。

Harder Cases

有时候,你有一个函数很大,有多个调用站点,但只有一个调用点是热调用点。你希望内联热调用点以提高速度,但不内联冷调用点以避免不必要的代码膨胀。处理的方法是将函数分成总是内联和从不内联的部分,后者调用前者。

例如,这个函数。

#![allow(unused)]
fn main() {
fn one() {};
fn two() {};
fn three() {};
fn my_function() {
    one();
    two();
    three();
}
}

应该修改为如下函数

#![allow(unused)]
fn main() {
fn one() {};
fn two() {};
fn three() {};
// Use this at the hot call site.
#[inline(always)]
fn inlined_my_function() {
    one();
    two();
    three();
}

// Use this at the cold call sites.
#[inline(never)]
fn uninlined_my_function() {
    inlined_my_function();
}
}

Example 1, Example 2.

哈希

HashSetHashMap 是两种广泛使用的类型。默认的哈希算法没有指定,但在撰写本文时,默认算法是一种称为 SipHash 1-3 的算法。这个算法质量很高——它提供了很高的碰撞保护,但对于短键(如整数)来说相对较慢。

如果测试显示hash是关键部分,而HashDoS attacks并不是你的应用所关心的问题,那么使用具有更快的散列算法的散列表可以提供很大的速度优势。

  • rustc-hash提供了 “FxHashSet “和 “FxHashMap “类型,它们是 “HashSet “和 “HashMap “的替代物。它的散列算法质量不高,但速度非常快,特别是对整数键而言,并且发现它的性能优于rustc内的所有其他散列算法。
  • fnv提供了FnvHashSetFnvHashMap类型。其散列算法比fxhash的质量高,但速度稍慢。
  • ahash提供AHashSetAHashMap。它的哈希算法可以采取一些处理器上的AES指令支持的优势。

如果散列性能在你的程序中很重要,那么值得尝试以上几种选择。例如,在rustc中看到以下结果。

如果您决定普遍使用替代方案之一,比如 FxHashSet/FxHashMap,很容易在某些地方意外地使用 HashSet/HashMap。您可以使用 Clippy 来避免这个问题。

有些类型不需要哈希。例如,您可能有一个包装整数的新类型,而整数值是随机的,或者接近随机的。对于这种类型,哈希值的分布与值本身的分布并没有太大不同。在这种情况下,nohash_hasher crate 可能会有用。

哈希函数设计是一个复杂的主题,超出了本书的范围。ahash 文档 中有很好的讨论。

堆分配

堆分配的代价不高。具体细节取决于使用的分配器,但每次分配和deallocation通常都需要获取一个全局锁,做一些非平凡的数据结构操作。并可能执行一个系统调用。小分配不一定比大分配便宜。值得了解哪些Rust数据结构和操作会导致分配,因为避免它们可以大大提高性能。

Rust Container Cheat Sheet有常见的Rust类型的可视化,是下面章节的绝佳配套。

Profiling

如果通用profile解析器显示 “malloc”、“free“和相关函数为热函数,那么很可能值得尝试降低分配率或使用其他分配器。

DHAT是降低分配率时可以使用的一个优秀的剖析器。它能精确地识别热分配点及其分配率。确切的结果会有所不同,但使用rustc的经验表明,每执行一百万条指令减少10条分配率可以有可衡量的性能改进(例如~1%)。

下面是DHAT的一些输出示例。

AP 1.1/25 (2 children) {
  Total:     54,533,440 bytes (4.02%, 2,714.28/Minstr) in 458,839 blocks (7.72%, 22.84/Minstr), avg size 118.85 bytes, avg lifetime 1,127,259,403.64 instrs (5.61% of program duration)
  At t-gmax: 0 bytes (0%) in 0 blocks (0%), avg size 0 bytes
  At t-end:  0 bytes (0%) in 0 blocks (0%), avg size 0 bytes
  Reads:     15,993,012 bytes (0.29%, 796.02/Minstr), 0.29/byte
  Writes:    20,974,752 bytes (1.03%, 1,043.97/Minstr), 0.38/byte
  Allocated at {
    #1: 0x95CACC9: alloc (alloc.rs:72)
    #2: 0x95CACC9: alloc (alloc.rs:148)
    #3: 0x95CACC9: reserve_internal<syntax::tokenstream::TokenStream,alloc::alloc::Global> (raw_vec.rs:669)
    #4: 0x95CACC9: reserve<syntax::tokenstream::TokenStream,alloc::alloc::Global> (raw_vec.rs:492)
    #5: 0x95CACC9: reserve<syntax::tokenstream::TokenStream> (vec.rs:460)
    #6: 0x95CACC9: push<syntax::tokenstream::TokenStream> (vec.rs:989)
    #7: 0x95CACC9: parse_token_trees_until_close_delim (tokentrees.rs:27)
    #8: 0x95CACC9: syntax::parse::lexer::tokentrees::<impl syntax::parse::lexer::StringReader<'a>>::parse_token_tree (tokentrees.rs:81)
  }
}

在这个例子中,描述所有的内容已经超出了本书的范围,但应该清楚的是,DHAT给出了大量关于分配的信息,比如它们发生的地点和频率,它们的规模有多大,它们的寿命有多长,以及它们被访问的频率。

Box

Box是最简单的堆分配类型。Box<T>值是一个在堆上分配的T值。

有时值得将结构体或枚举字段中的一个或多个字段装箱,以使类型更小。(更多信息请参见 Type Sizes 一章)。

除此之外,Box是直接的,并没有提供太多优化的空间。

Rc/Arc

[Rc]/[Arc]类似于Box,但堆上的值有两个引用计数。它们允许值共享,这可以是减少内存使用的有效方法。

[Rc]:https://doc.rust-lang.org/std/rc/struct.Rc.html [Arc]:https://doc.rust-lang.org/std/sync/struct.Arc.html

但是,如果用于很少共享的值,它们可以通过堆分配本来可能不会被堆分配的值来提高分配率。 Example.

Box不同的是,在Rc/Arc值上调用clone并不涉及分配。相反,它只是增加一个引用计数。

Vec

[Vec]是一种堆分配类型,在优化分配数量和/或尽量减少浪费的空间方面有很大的空间。要做到这一点,需要了解其元素的存储方式。

[Vec]:https://doc.rust-lang.org/std/vec/struct.Vec.html

一个 “Vec “包含三个词:一个长度、一个容量和一个指针。如果容量是非零,元素大小是非零,指针将指向堆分配的内存;否则,它将不指向分配的内存。

即使 “Vec “本身不是堆分配的,元素(如果存在且大小非零)也会是堆分配的。如果存在非零大小的元素,那么存放这些元素的内存可能会比必要的大,为未来的元素提供空间。存在的元素数就是长度,不需要重新分配就可以容纳的元素数就是容量。

当向量需要增长到超过其当前容量时,元素将被复制到一个更大的堆分配中,旧的堆分配将被释放。

Vec growth

用普通方法创建一个新的、空的Vec。 (vec![]Vec::newVec::default)的长度和容量为零,不需要进行堆分配。如果你反复将单个元素推到Vec的末端,它将周期性地重新分配。增长策略没有被指定,但在写这篇文章的时候,它使用了一个准双倍策略,结果是以下容量。0, 4, 8, 16, 32, 64, 等等. (它直接从0跳到4,而不是通过1和2,因为这在实践中避免了许多分配。) 随着向量的增长,重新分配的频率将以指数形式减少,但可能浪费的多余容量将以指数形式增加。

这种增长策略对于可增长的数据结构来说是典型的,在一般情况下是合理的,但如果你事先知道一个向量的可能长度,你可以做的往往更好。如果你有一个热向量分配站点(例如一个热的 Vec::push调用),值得使用eprintln!来打印该站点的向量长度,然后做一些后处理(例如使用counts)来确定长度分布。例如,你可能有很多短向量,也可能有较少的超长向量,优化分配站点的最佳方式也会相应变化。

Short Vecs

如果你有很多短向量,你可以使用smallveccrate中的SmallVec类型。SmallVec<[T;N]>Vec的替代物,它可以在SmallVec本身中存储N个元素,如果元素数量超过这个数量,就会切换到堆分配。(还需要注意的是,vec![]字元必须用smallvec![]字元代替。) Example 1, Example 2.

SmallVec如果使用得当,可以可靠地降低分配率,但使用它并不能保证提高性能。对于正常的操作,它比Vec稍慢,因为它必须总是检查元素是否被堆分配。另外,如果N很高或者T很大,那么SmallVec<[T; N]>本身就会比Vec<T>大,复制SmallVec值的速度会比较慢。和以往一样,需要通过基准测试来确认优化是否有效。

如果你有很多短向量,并且你精确地知道它们的最大长度,[arrayvec]箱子中的ArrayVec比SmallVec更好。它不需要回落到堆分配,这使得它更快一些。 Example.

Longer Vecs

如果你知道一个向量的最小或精确大小,你可以用Vec::with_capacityVec::reserveVec::reserve_exact来保留一个特定的容量。例如,如果你知道一个向量将成长为至少有20个元素,这些函数可以使用一次分配立即提供一个至少有20个容量的向量,而一次推送一个项目将导致四次分配(对于4、8、16和32的容量)。 Example.

如果你知道一个向量的最大长度,上述函数也可以让你不分配多余的不必要的空间。同样,Vec::shrink_to_fit也可以用来尽量减少浪费的空间,但要注意它可能会引起重新分配。

String

一个String包含堆分配的字节。String的表示和操作与Vec<u8>非常相似。许多与增长和容量有关的Vec方法与String有对应关系,如String::with_capacity

来自smallstr箱子的SmallString类型与SmallVec类型类似。

请注意,format!宏产生一个String,这意味着它进行了分配。如果你能通过使用字符串文字来避免format!的调用,就能避免这种分配。 Example.

Hash tables

HashSetHashMap是哈希表。在分配方面,它们的表示和操作与Vec的表示和操作相似:它们有一个单一的连续的堆分配,存放键和值,随着表的增长,必要时重新分配。许多与增长和容量有关的Vec方法都有与HashSet/HashMap对应的方法,如HashSet::with_capacity

Cow

有时候你有一些借来的数据,比如&str,大部分是只读的,但偶尔需要修改。每次都克隆数据会很浪费。相反,你可以通过Cow类型使用 “write-on-clone “语义,它既可以表示借来的数据,也可以表示拥有的数据。

通常情况下,当从一个借来的值x开始时,你用Cow::Borrowed(x)把它包在一个Cow中。因为Cow实现了Deref,所以你可以直接在它所包含的数据上调用非修改的方法。如果需要修改,Cow::to_mut将获得一个对所拥有的值的可修改引用,必要时进行克隆。

Cow的工作可能会很麻烦,但它往往是值得的。 Example 1, Example 2, Example 3, Example 4.

clone

在一个包含堆分配内存的值上调用[clone]通常会涉及额外的分配。例如,在一个非空的Vec上调用clone,需要对元素进行新的分配(但请注意,新Vec的容量可能与原Vec的容量不同)。例外的情况是Rc/Arcclone的调用只是增加引用数。

clone_fromclone的替代方法。a.clone_from(&b)相当于a = b.clone(),但可以避免不必要的分配。例如,如果你想在一个现有的Vec之上克隆一个Vec,现有Vec的堆分配将尽可能地被重复使用,如下例所示。

#![allow(unused)]
fn main() {
let mut v1: Vec<u32> = Vec::with_capacity(99);
let v2: Vec<u32> = vec![1, 2, 3];
v1.clone_from(&v2); // v1's allocation is reused
assert_eq!(v1.capacity(), 99);
}

虽然clone通常会造成分配,但在很多情况下使用它是一件很合理的事情,往往可以使代码更简单。使用剖析数据来查看哪些clone调用是热门的,值得花力气去避免。

有时,由于(a)程序员的错误,或(b)代码中的变化,使以前必要的clone调用变得不必要,Rust代码最终会包含不必要的clone调用。如果你看到一个似乎没有必要的热clone调用,有时可以简单地删除它。 Example 1, Example 2, Example 3.

to_owned

ToOwned::to_owned]是为许多常见类型实现的。它从借来的数据中创建拥有的数据,通常是通过克隆的方式,因此经常会引起堆分配。例如,它可以用来从一个&str创建一个String

有时,可以通过在结构中存储对借入数据的引用而不是自有副本来避免to_owned调用。这需要在结构体上做终身注解,使代码复杂化,只有在分析和基准测试表明值得时才可以这样做。 Example.

Cow

有时代码处理的是借用和拥有数据的混合。想象一下一个包含错误消息的向量,其中一些是静态字符串字面量,另一些是用 format! 构造的。显而易见的表示是 Vec<String>,如下例所示。

#![allow(unused)]
fn main() {
let mut errors: Vec<String> = vec![];
errors.push("something went wrong".to_string());
errors.push(format!("something went wrong on line {}", 100));
}

这需要一个 to_string 调用来将静态字符串字面量提升为 String,这会产生一次分配。

相反,您可以使用 Cow 类型,它可以保存借用或拥有的数据。借用值 x 被包装在 Cow::Borrowed(x) 中,拥有值 y 被包装在 Cow::Owned(y) 中。Cow 还为各种字符串、切片和路径类型实现了 From<T> trait,因此通常也可以使用 into。 (或者 Cow::from,这更长一些,但会产生更易读的代码,因为它使类型更清晰。)以下示例将所有这些内容整合在一起。

#![allow(unused)]
fn main() {
use std::borrow::Cow;
let mut errors: Vec<Cow<'static, str>> = vec![];
errors.push(Cow::Borrowed("something went wrong"));
errors.push(Cow::Owned(format!("something went wrong on line {}", 100)));
errors.push(Cow::from("something else went wrong"));
errors.push(format!("something else went wrong on line {}", 101).into());
}

现在,errors 包含了借用和拥有数据的混合,而无需进行任何额外的分配。这个示例涉及 &str/String,但其他配对,如 &[T]/Vec<T>&Path/PathBuf 也是可能的。

Example 1, Example 2.

以上所有内容适用于数据是不可变的情况。但是,Cow 也允许将借用数据提升为拥有数据,如果需要对其进行修改。Cow::to_mut 将获得一个可变引用到一个拥有值,必要时进行克隆。这就是所谓的“写时复制”,也是 Cow 名称的由来。

Reusing Collections

有时你需要分阶段建立一个集合,如Vec。通常情况下,通过修改一个Vec比建立多个Vec然后将它们组合起来更好。

例如,如果你有一个函数 “do_stuff”,产生一个 “Vec”,可能会被多次调用。

#![allow(unused)]
fn main() {
fn do_stuff(x: u32, y: u32) -> Vec<u32> {
    vec![x, y]
}
}

It might be better to instead modify a passed-in Vec:

#![allow(unused)]
fn main() {
fn do_stuff(x: u32, y: u32, vec: &mut Vec<u32>) {
    vec.push(x);
    vec.push(y);
}
}

有时,值得保留一个可以重复使用的 “主力“集合。例如,如果一个循环的每次迭代都需要一个Vec,你可以在循环外声明Vec,在循环体中使用它,然后在循环体结束时调用clear(清空Vec而不影响它的容量)。这避免了分配,但代价是掩盖了一个事实,即每次迭代对Vec的使用与其他迭代无关。 Example 1, Example 2.

同样,有时值得在一个结构中保留一个 “主力 “集合,以便在一个或多个被重复调用的方法中重用。

从文件中逐行读取

BufRead::lines 使得逐行读取文件变得很容易:

#![allow(unused)]
fn main() {
fn blah() -> Result<(), std::io::Error> {
fn process(_: &str) {}
use std::io::{self, BufRead};
let mut lock = io::stdin().lock();
for line in lock.lines() {
    process(&line?);
}
Ok(())
}
}

但它生成的迭代器返回的是 io::Result<String>,这意味着它为文件中的每一行分配内存。

另一种方法是在循环中使用一个工作用的 String,通过 BufRead::read_line

#![allow(unused)]
fn main() {
fn blah() -> Result<(), std::io::Error> {
fn process(_: &str) {}
use std::io::{self, BufRead};
let mut lock = io::stdin().lock();
let mut line = String::new();
while lock.read_line(&mut line)? != 0 {
    process(&line);
    line.clear();
}
Ok(())
}
}

这样可以将分配的次数降至最多几次,甚至可能只有一次。(确切的次数取决于需要多少次重新分配 line,这取决于文件中行长度的分布情况。)

这种方法只适用于循环体能够操作 &str 而不是 String 的情况。

Example.

使用不同的分配器

除了更改代码外,还可以通过使用不同的分配器来改善堆分配性能。请参阅 Alternative Allocators 部分获取详细信息。

避免回归

为了确保代码执行时的分配次数和/或大小不会意外增加,您可以使用 dhat-rs堆使用测试 功能编写测试,检查特定代码段分配了预期量的堆内存。

类型大小

缩小经常实例化的类型可以提高性能。

例如,如果内存使用量很高,像 [DHAT] 这样的堆分析器可以识别热点分配点和涉及的类型。缩小这些类型可以减少峰值内存使用量,并通过减少内存流量和缓存压力可能改善性能。

此外,Rust 中大于 128 字节的类型会使用 memcpy 进行复制,而不是内联代码。如果在性能分析中出现大量 memcpy,DHAT 的 “copy profiling” 模式将告诉您热点 memcpy 调用的确切位置和涉及的类型。将这些类型缩小到 128 字节或更小可以通过避免 memcpy 调用和减少内存流量使代码更快。

测量类型大小

std::mem::size_of给出了一个类型的大小,以字节为单位,但通常你也想知道确切的布局。例如,一个枚举可能会出乎意料的大,这可能是由一个超大的变体造成的。

-Zprint-type-sizes选项正是这样做的,它在rustc的发行版上没有被启用,所以你需要使用rustc的夜间版本。 下面是一个通过Cargo的可能调用

RUSTFLAGS=-Zprint-type-sizes cargo +nightly build --release

而这里是一个rustc的可能调用

rustc +nightly -Zprint-type-sizes input.rs

它将打印出所有使用中的类型的尺寸、布局和对齐方式的详细信息。例如,对于这种类型。

#![allow(unused)]
fn main() {
enum E {
    A,
    B(i32),
    C(u64, u8, u64, u8),
    D(Vec<u32>),
}
}

它打印以下信息,以及一些内置类型的信息。

print-type-size type: `E`: 32 bytes, alignment: 8 bytes
print-type-size     discriminant: 1 bytes
print-type-size     variant `D`: 31 bytes
print-type-size         padding: 7 bytes
print-type-size         field `.0`: 24 bytes, alignment: 8 bytes
print-type-size     variant `C`: 23 bytes
print-type-size         field `.1`: 1 bytes
print-type-size         field `.3`: 1 bytes
print-type-size         padding: 5 bytes
print-type-size         field `.0`: 8 bytes, alignment: 8 bytes
print-type-size         field `.2`: 8 bytes
print-type-size     variant `B`: 7 bytes
print-type-size         padding: 3 bytes
print-type-size         field `.0`: 4 bytes, alignment: 4 bytes
print-type-size     variant `A`: 0 bytes

输出显示以下内容。

  • 类型的大小和排列。
  • 对于enums,判别子的大小。
  • 对于enums,每个变量的大小(从最大到最小排序)。
  • 所有字段的大小、对齐和排序。(请注意,编译器对变体C的字段进行了重新排序,以最小化E的大小。)
  • 所有padding的大小和位置。

另外,可以使用 top-type-sizes crate 来以更紧凑的形式显示输出。

一旦你知道了热型的布局,就有多种方法来收缩它。

字段顺序

Rust编译器会自动对结构体和枚举的字段进行排序,以最小化它们的大小(除非指定了 #[repr(C)] 属性),因此您无需担心字段顺序的问题。但是,还有其他方法可以最小化热门类型的大小。

更小的枚举

如果一个枚举有一个超大的变体,可以考虑将一个或多个字段装箱。例如,你可以改变这个类型。

#![allow(unused)]
fn main() {
type LargeType = [u8; 100];
enum A {
    X,
    Y(i32),
    Z(i32, LargeType),
}
}

修改为:

#![allow(unused)]
fn main() {
type LargeType = [u8; 100];
enum A {
    X,
    Y(i32),
    Z(Box<(i32, LargeType)>),
}
}

这减少了类型大小,但代价是需要为A::Z变体分配一个额外的堆。如果A::Z变体比较少见,这更有可能成为提高性能的好方法。Box也会使A::Z的使用略微不符合人的直觉,特别是在匹配模式中。 Example 1, Example 2, Example 3, Example 4, Example 5, Example 6.

更小的intergers

通常可以通过使用较小的整数类型来缩小类型。例如,虽然对索引使用 “usize “是最自然的,但将索引存储为 “u32”、“u16”、甚至 “u8”,然后在使用点强制使用 “usize”,往往是合理的。 Example 1, Example 2.

Boxed Slices

Rust向量包含三个词:一个长度、一个容量和一个指针。如果你有一个将来不太可能被改变的向量,你可以用Vec::into_boxed_slice把它转换为一个boxed slice。一个boxed slice只包含两个词,一个长度和一个指针。任何多余的元素容量都会被丢弃,这可能会导致重新分配。

#![allow(unused)]
fn main() {
use std::mem::{size_of, size_of_val};
let v: Vec<u32> = vec![1, 2, 3];
assert_eq!(size_of_val(&v), 3 * size_of::<usize>());

let bs: Box<[u32]> = v.into_boxed_slice();
assert_eq!(size_of_val(&bs), 2 * size_of::<usize>());
}

盒状切片可以用slice::into_vec转换回一个矢量,而无需任何克隆或重新分配。

ThinVec

ThinVec是一个替代Boxed slices的选择,来自于thin_vec crate。它在功能上等同于Vec,但是在与元素相同的分配中存储长度和容量。这意味着size_of::<ThinVec<T>>只占用一个字。

在经常实例化的类型中,ThinVec是一个不错的选择,适用于经常为空的向量。它还可以用于缩小枚举的最大变体,如果该变体包含一个Vec

Avoiding Regressions

如果一个类型足够热,它的大小会影响性能,那么最好使用静态断言来确保它不会意外地回归。下面的例子使用了static_assertions中的一个宏。

  // This type is used a lot. Make sure it doesn't unintentionally get bigger.
  #[cfg(target_arch = "x86_64")]
  static_assertions::assert_eq_size!(HotType, [u8; 64]);

cfg属性很重要,因为类型大小在不同的平台上会有所不同。将断言限制在 “x86_64”(通常是最广泛使用的平台)可能足以防止实际中的回落。

Standard Library Types

值得阅读常见标准库类型的文档–如VecOptionResultRc–以找到有趣的函数,有时可以用来提高性能。

还值得了解标准库类型的高性能替代品,如MutexRwLockCondvarOnce

Box

表达式 Box::default() 的效果与 Box::new(T::default()) 相同,但可能更快,因为编译器可以直接在堆上创建值,而不是在堆栈上构造值然后复制它。 示例

Vec

创建长度为 n 的零填充 Vec 的最佳方法是使用 vec![0; n]。这种方法简单且可能比其他方法更快,比如使用 resizeextend 或涉及 unsafe 的任何操作,因为它可以利用操作系统的帮助。

Vec::remove 会移除特定索引处的元素,并将所有后续元素向左移动一个位置,这使得它的时间复杂度为 O(n)。Vec::swap_remove 会用最后一个元素替换特定索引处的元素,这不会保留顺序,但时间复杂度为 O(1)。

Vec::retain 可以高效地从 Vec 中移除多个项。其他集合类型如 StringHashSetHashMap 也有类似的方法。

Option and Result

[Option::ok_or']将Option’转换为Result',并传递一个err’参数,如果Option'值为None’,则使用该参数。err是急于计算的。如果它的计算很昂贵,你应该使用Option::ok_or_else,它通过一个闭包缓慢地计算错误值。 例如,这个。

#![allow(unused)]
fn main() {
fn expensive() {}
let o: Option<u32> = None;
let r = o.ok_or(expensive()); // always evaluates `expensive()`
}

should be changed to this:

#![allow(unused)]
fn main() {
fn expensive() {}
let o: Option<u32> = None;
let r = o.ok_or_else(|| expensive()); // evaluates `expensive()` only when needed
}

Example.

Option::map_orOption::unwrap_orResult::orResult::map_orResult::unwrap_or有类似的替代方案。

Rc/Arc

Rc::make_mut/Arc::make_mut提供了clone-on-write语义。它对Rc做了一个可改变的引用。如果refcount大于1,它将clone内部值以确保唯一的所有权;否则,它将修改原始值。它不经常需要,但偶尔会非常有用。 Example 1, Example 2.

Mutex, RwLock, Condvar, and Once

parking_lot crate提供了这些同步类型的替代实现。parking_lot 类型的API和语义与标准库中等效类型的类似但并非完全相同。

过去,parking_lot 版本通常比标准库中的版本更小、更快、更灵活,但在某些平台上,标准库版本已经有了很大改进。因此,在切换到 parking_lot 之前,您应该进行测量。

如果决定普遍使用 parking_lot 类型,很容易在某些地方意外地使用标准库的等效类型。您可以使用 Clippy 来避免这个问题。

Iterators

collect

Iterator::collect将一个迭代器转换为一个集合,如Vec,它通常需要一个分配。如果该集合只是再次迭代,你应该避免调用collect

出于这个原因,从函数中返回一个迭代器类型,比如impl Iterator<Item=T>,往往比Vec<T>更好。请注意,有时这些返回类型需要额外的生存期,正如this post所解释的那样。 Example.

同样,你可以使用extend用迭代器扩展一个现有的集合(如Vec),而不是将迭代器收集到Vec中,然后使用append

最后,当编写迭代器时,如果可能的话,实现 Iterator::size_hintExactSizeIterator::len 方法通常是值得的。使用该迭代器的 collectextend 调用可能会减少分配,因为它们提前了解迭代器产生的元素数量信息。

Chaining

chain可以非常方便,但也可能比单个迭代器慢。如果可能的话,热迭代器可能值得避免。 Example.

类似地,filter_map可能比使用filtermap更快。

Chunks

当需要一个分块迭代器,并且已知分块大小恰好能整除切片长度时,应该使用更快的 slice::chunks_exact 而不是 slice::chunks

当分块大小不确定能否恰好整除切片长度时,仍然可以更快地使用 slice::chunks_exact,结合 ChunksExact::remainder 或手动处理多余元素。 Example 1, Example 2.

同样适用于相关的迭代器:

Bounds Checks

默认情况下,在 Rust 中,对切片和向量等容器类型的访问涉及边界检查。这可能会影响性能,例如在热循环中,尽管发生的频率可能不如您所预期的那样频繁。

有几种安全的方法可以更改代码,以便编译器了解容器的长度并优化掉边界检查。

  • 在循环中,通过迭代替换直接元素访问。
  • 在循环中,不要对 Vec 进行索引,而是在循环之前创建 Vec 的切片,然后在循环中对切片进行索引。
  • 在索引变量的范围上添加断言。 Example 1, Example 2.

让这些方法起作用可能有些棘手。Bounds Check Cookbook对这个主题进行了更详细的介绍

作为最后的手段,还有不安全的方法 [get_unchecked] 和 [get_unchecked_mut]。

I/O

Locking

Rust的print!println!宏在每次调用时锁定stdout。如果你需要重复调用这些宏,最好手动锁定stdout。

例如,修改这段代码。

#![allow(unused)]
fn main() {
let lines = vec!["one", "two", "three"];
for line in lines {
    println!("{}", line);
}
}

to this:

#![allow(unused)]
fn main() {
fn blah() -> Result<(), std::io::Error> {
let lines = vec!["one", "two", "three"];
use std::io::Write;
let mut stdout = std::io::stdout();
let mut lock = stdout.lock();
for line in lines {
    writeln!(lock, "{}", line)?;
}
// stdout is unlocked when `lock` is dropped
Ok(())
}
}

当对stdin和stderr进行重复操作时,同样可以锁定它们。

缓冲

Rust文件I/O默认是无缓冲的。如果你对文件或网络套接字有许多小的和重复的读写调用,使用BufReaderBufWriter。它们为输入和输出维护了一个内存缓冲区,最大限度地减少了系统调用的次数。

例如,修改这个无缓冲的输出代码。

#![allow(unused)]
fn main() {
fn blah() -> Result<(), std::io::Error> {
let lines = vec!["one", "two", "three"];
use std::io::Write;
let mut out = std::fs::File::create("test.txt").unwrap();
for line in lines {
    writeln!(out, "{}", line)?;
}
Ok(())
}
}

修改为:

#![allow(unused)]
fn main() {
fn blah() -> Result<(), std::io::Error> {
let lines = vec!["one", "two", "three"];
use std::io::{BufWriter, Write};
let mut out = std::fs::File::create("test.txt")?;
let mut buf = BufWriter::new(out);
for line in lines {
    writeln!(buf, "{}", line)?;
}
buf.flush()?;
Ok(())
}
}

flush的显式调用并不是绝对必要的,因为当buf被丢弃时,刷新将自动发生。然而,在这种情况下,刷新时发生的任何错误都将被忽略,而显式刷新将使该错误显式化。

在编写时忘记使用缓冲区是比较常见的。无缓冲和有缓冲的写入器都实现了 [Write] trait,这意味着向无缓冲写入器和有缓冲写入器写入的代码基本相同。相比之下,无缓冲读取器实现了 [Read] trait,但有缓冲读取器实现了 [BufRead] trait,这意味着从无缓冲读取器和有缓冲读取器读取的代码是不同的。例如,使用无缓冲读取器逐行读取文件是困难的,但使用有缓冲读取器通过 [BufRead::read_line] 或 [BufRead::lines] 则很简单。因此,对于读取器来说,很难像对写入器那样编写一个示例,其中之前和之后的版本是如此相似。

最后,请注意,缓冲也适用于标准输出(stdout),因此当向标准输出频繁写入时,您可能希望结合手动锁定和缓冲。

Reading Lines from a File

这一部分解释了如何在使用 [BufRead] 逐行读取文件时避免过多的内存分配。

Reading Input as Raw Bytes

内置的String类型在内部使用UTF-8,当你读取输入到string类型时,会增加一个由UTF-8验证引起的小但非零的开销。如果你只想处理输入字节而不担心UTF-8(例如,如果你处理ASCII文本),你可以使用BufRead::read_until

还有专门的箱子用于读取面向字节的数据行和处理byte strings

Logging and Debugging

有时,日志代码或调试代码会大大降低程序的速度。要么是日志记录/调试代码本身很慢,要么是反馈到日志记录/调试代码的数据收集代码很慢。确保在不启用日志记录/调试时,不为日志记录/调试目的做不必要的工作。 Example 1, Example 2.

请注意,assert!调用总是运行,但debug_assert!调用只在调试构建中运行。如果你有一个热的断言,但对安全来说不是必需的,可以考虑把它变成一个debug_assert!Example.

Wrapper Types

Rust有多种 “封装 “类型,如RefCellMutex,它们为值提供了特殊行为。访问这些值可能会耗费大量的时间。如果多个这样的值通常是一起访问的,那么最好将它们放在一个包装器中。

例如,这样的结构。

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
struct S {
    x: Arc<Mutex<u32>>,
    y: Arc<Mutex<u32>>,
}
}

也许这样更典型。

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
struct S {
    xy: Arc<Mutex<(u32, u32)>>,
}
}

这是否有助于性能,将取决于值的具体访问模式。 Example.

Machine Code

当你有一小段非常频繁执行的热点代码时,值得检查生成的机器代码,看看是否存在一些低效之处,比如可移除的边界检查。在处理小片段时,Compiler Explorer 网站是一个很好的资源。而 cargo-show-asm 则是另一个工具,可以用于完整的 Rust 项目。

与此相关的是,core::arch模块提供了对特定架构的固有知识的访问,其中许多与SIMD指令有关。

Parallelism

Rust为安全的并行编程提供了很好的支持,这可以带来很大的性能提升。有多种方法可以将并行性引入到程序中,对于任何程序来说,最好的方式在很大程度上取决于其设计。

对并行性的深入研究超出了本书的范围。如果你对这一主题感兴趣,[rayon]和[crossbeam]板条箱的文档是一个很好的开始。

一般建议

本书前几节讨论了 Rust 特定的技术。本节简要概述了一些一般性能原则。

只要避免明显的陷阱(例如使用非发布版本构建),Rust 代码通常运行速度快且占用内存少。特别是如果你习惯于动态类型语言如 Python 和 Ruby,或者带有垃圾回收器的静态类型语言如 Java 和 C#。

优化的代码通常比未优化的代码更复杂,编写起来需要更多的工作。因此,只有值得优化热点代码时才值得进行优化。

最大的性能改进通常来自于算法或数据结构的更改,而不是低级优化。 Example 1, Example 2.

编写能够与现代硬件良好配合的代码并不总是容易的,但值得努力。例如,尽量减少缓存未命中和分支预测错误。

大多数优化只会带来轻微的加速。虽然单个小的加速可能不明显,但如果你能做足够多的优化,它们的效果会累积起来。

不同的性能分析工具各有优势。最好使用多个工具。

当性能分析表明某个函数运行热点时,有两种常见的加速方法:(a)加快函数运行速度,和/或者(b)尽量减少调用次数。

消除愚蠢的减速往往比引入巧妙的加速更容易。

除非必要,避免计算。延迟/按需计算通常是明智的选择。 Example 1, Example 2.

一般复杂的情况往往可以通过乐观地检查比较简单的常见特殊情况来避免。 Example 1, Example 2, Example 3. 尤其是在小尺寸占主导地位的情况下,特别处理0、1或2个元素的集合往往是一种好办法。 Example 1, Example 2, Example 3, Example 4.

同样,在处理重复性数据时,通常可以使用一种简单的数据压缩形式,对常见的值使用紧凑的表示方式,然后对不常见的值进行回退到二级表。 Example 1, Example 2, Example 3.

当代码涉及多种情况时,测量各种情况的频率,并首先处理最常见的情况。

在涉及高局部性的查找时,将一个小缓存放在数据结构前面可能会带来好处。

优化的代码通常具有非显而易见的结构,这意味着解释性注释非常有价值,特别是那些参考了性能分析数据的注释。例如,“99% 的情况下,这个向量有 0 或 1 个元素,因此首先处理这些情况”这样的注释可以很有启发性。

编译时间

虽然本书的主要内容是提高Rust程序的性能,但本节的内容是关于减少Rust程序的编译时间,因为这是很多人感兴趣的相关话题。

减少编译时间部分讨论了通过构建配置选择来减少编译时间的方法。本节的其余部分将讨论需要修改程序代码来减少编译时间的方法。

可视化

Rust编译器有一个功能,可以让你可视化编译你的程序。用这个命令进行编译。

cargo build --timings

完成后,它将打印一个HTML文件的名称。在Web浏览器中打开该文件。其中包含一个Gantt chart,显示程序中各个crate之间的依赖关系。这显示了您的crate图中有多少并行性,这可以表明是否应该拆分任何序列化编译的大型crate。有关如何阅读这些图表的更多详细信息,请参阅timings

LLVM IR

Rust编译器的后端使用LLVM。LLVM的执行会占到编译时间的很大一部分,尤其是当Rust编译器的前端会产生大量的IR,这需要LLVM花很长的时间去优化。

这些问题可以用[cargo llvm-line]来诊断,它显示了哪些Rust函数导致了最多的LLVM IR生成。通用函数通常是最重要的函数,因为它们在大型程序中可以被实例化几十次甚至几百次。

如果一个通用函数导致IR膨胀,有几种方法可以解决。最简单的方法就是把函数变小。 Example 1, Example 2.

另一种方法是将函数的非泛型部分移动到一个单独的非泛型函数中,该函数只会被实例化一次。是否可能取决于泛型函数的细节。当可能时,非泛型函数通常可以被整洁地编写为泛型函数内部的内部函数,就像std::fs::read的代码所示:

pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> {
    fn inner(path: &Path) -> io::Result<Vec<u8>> {
        let mut file = File::open(path)?;
        let size = file.metadata().map(|m| m.len()).unwrap_or(0);
        let mut bytes = Vec::with_capacity(size as usize);
        io::default_read_to_end(&mut file, &mut bytes)?;
        Ok(bytes)
    }
    inner(path.as_ref())
}

Example.

有时,像Option::mapResult::map_err这样的常用实用函数会被实例化多次。 用等价的match表达式替换它们可以帮助编译时间。

这些变化对编译时间的影响通常是很小的,但偶尔也会很大。 Example.

这些更改还可以减少二进制文件的大小。