Rust 性能手册
2020 年 11 月首次出版
作者:Nicholas Nethercote 及其他贡献者
引言
性能对于许多 Rust 程序来说非常重要。
本书收录了能够改善 Rust 程序性能相关特征的技术,如运行时速度、内存使用和 二进制体积。编译时间章节还包含了能够缩短 Rust 程序编译时间的技术。有些技术仅需 更改构建配置,但许多技术需要修改代码。
有些技术完全是 Rust 特有的,另一些则涉及可以(通常经过修改后)应用于其他语言编写的 程序的思想。通用技巧章节还包含了一些适用于任何编程语言的通用原则。尽管如此,本书主要 讨论 Rust 程序的性能,不能替代通用的性能分析和优化指南。
本书还侧重于实用且经过验证的技术:许多技术附带了 pull request 或其他资源的链接, 展示了如何在真实世界的 Rust 程序中使用该技术。本书反映了主要作者的背景,在一定程度上 偏向于编译器开发,而非科学计算等其他领域。
本书有意写得简洁明了,注重广度而非深度,以便快速阅读。在适当的情况下,它会链接到提供 更深入内容的外部资源。
本书面向中高级 Rust 用户。初学者需要学习的东西已经够多了,这些技术对他们而言可能 是一种无益的干扰。
基准测试
基准测试通常涉及比较执行相同任务的两个或多个程序的性能。有时这可能需要比较两个或 更多不同的程序,例如 Firefox vs Safari vs Chrome。有时涉及比较同一程序的两个不同版本。 后一种情况让我们能够可靠地回答“这个改动是否加快了速度?“这个问题。
基准测试是一个复杂的话题,全面覆盖已超出本书的范围,但以下是基本要点。
首先,你需要用于测量的工作负载。理想情况下,你应该有各种能够代表程序实际使用情况的 工作负载。使用真实世界输入的工作负载最好,但微基准测试和压力测试适度使用也很有用。
其次,你需要一种运行工作负载的方法,这也将决定所使用的指标。
- Rust 内置的基准测试是一个简单的起点,但它们使用了不稳定的特性,因此仅适用于 nightly Rust。
- Criterion 和 Divan 是更复杂的替代方案。
- Hyperfine 是一个优秀的通用基准测试工具。
- Bencher 可以在 CI(包括 GitHub CI)上进行持续基准测试。
- Gungraun 提供了带有高精度测量的
cargo bench集成。 - 也可以使用自定义的基准测试框架。例如,rustc-perf 就是用于对 Rust 编译器进行 基准测试的框架。
至于指标,有很多选择,正确的指标取决于被基准测试的程序的性质。例如,对批处理程序 有意义的指标可能对交互式程序没有意义。墙钟时间在许多情况下是一个明显的选择,因为它 与用户感知一致。然而,它可能存在高方差的问题。特别是,内存布局的微小变化可能导致 显著但短暂的性能波动。因此,其他方差较低的指标(如周期数或指令计数)可能是合理的 替代方案。
汇总来自多个工作负载的测量结果也是一个挑战,有多种方法可以做到,但没有一种方法 明显优于其他。
好的基准测试是困难的。话虽如此,不要过于追求完美的基准测试设置,特别是在你开始 优化程序时。平庸的基准测试远胜于没有基准测试。对你正在测量的内容保持开放的心态, 随着你了解程序的性能特征,可以逐步改进基准测试。
构建配置
你可以大幅改变 Rust 程序的性能,而无需修改代码,只需更改其构建配置。每个 Rust 程序 都有许多可能的构建配置。所选配置将影响编译后代码的多个特征,例如编译时间、运行时速度、 内存使用、二进制体积、可调试性、可分析性以及编译后的程序将运行的架构。
大多数配置选择会在改善一个或多个特征的同时,使一个或多个其他特征变差。例如,一种常见 的权衡是接受更长的编译时间以换取更高的运行时速度。适合你程序的正确选择取决于你的需求 和程序的具体情况,而与性能相关的选择(大部分都是)应通过基准测试来验证。
值得仔细阅读本章以了解所有构建配置选项。不过,对于没有耐心或容易忘记的人来说,
cargo-wizard 封装了这些信息,可以帮助你选择合适的构建配置。
请注意,Cargo 仅查看工作区根目录下 Cargo.toml 文件中的 profile 设置。依赖项中定义的
profile 设置将被忽略。因此,这些选项主要与二进制 crate 相关,而非库 crate。
Release 构建
单个最重要的构建配置选择很简单但容易忽视:当你需要高性能时,请确保使用的是
release 构建而非 dev 构建。这通常通过向 Cargo 指定 --release 标志来完成。
Dev 构建是默认的。它们适合调试,但未进行优化。如果你运行 cargo build 或 cargo run,
就会生成 dev 构建。(或者,不带额外选项运行 rustc 也会生成未优化的构建。)
考虑以下 cargo build 运行的最终输出行。
Finished dev [unoptimized + debuginfo] target(s) in 29.80s
此输出表明已生成 dev 构建。编译后的代码将放置在 target/debug/ 目录中。cargo run
将运行 dev 构建。
相比之下,release 构建经过了更多优化,省略了调试断言和整数溢出检查,并省略了调试信息。
比 dev 构建快 10-100 倍是常见的!如果你运行 cargo build --release 或
cargo run --release,就会生成 release 构建。(或者,rustc 有多个优化构建选项,
如 -O 和 -C opt-level。)由于额外的优化,这通常比 dev 构建需要更长的时间。
考虑以下 cargo build --release 运行的最终输出行。
Finished release [optimized] target(s) in 1m 01s
此输出表明已生成 release 构建。编译后的代码将放置在 target/release/ 目录中。
cargo run --release 将运行 release 构建。
有关 dev 构建(使用 dev profile)和 release 构建(使用 release profile)之间差异的
更多详细信息,请参阅 Cargo profile 文档。
Release 构建中使用的默认构建配置选项在编译时间、运行时速度和二进制体积等上述特征之间 提供了良好的平衡。但有许多可能的调整,如下节所述。
最大化运行时速度
以下构建配置选项主要旨在最大化运行时速度。其中一些还可能减小二进制体积。
Codegen Units
Rust 编译器将 crate 拆分为多个 codegen units 以并行化(从而加快)编译。然而,这
可能导致编译器错过一些潜在的优化。通过将单元数设置为 1,你可能能够提高运行时速度并
减小二进制体积,但代价是增加编译时间。将以下行添加到 Cargo.toml 文件中:
[profile.release]
codegen-units = 1
链接时优化
链接时优化(LTO)是一种全程序优化技术,可以将运行时速度提高 10-20% 或更多,同时 还能减小二进制体积,但代价是编译时间变长。它有几种形式。
第一种形式的 LTO 是 thin local LTO,一种轻量级的 LTO 形式。默认情况下,编译器对任何
涉及非零优化级别的构建都使用此方式。这包括 release 构建。要显式请求此级别的 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 可以配置为使用透明大页(THP)。这可以进一步加速 程序,但可能会以更高的内存使用为代价。
通过在构建程序之前适当设置 MALLOC_CONF 环境变量(或可能 _RJEM_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 标志。例如:
RUSTFLAGS="-C target-cpu=native" cargo build --release
或者,从 config.toml 文件(适用于一个或多个项目)请求这些指令,添加以下行:
[build]
rustflags = ["-C", "target-cpu=native"]
这可以提高运行时速度,特别是当编译器在你的代码中发现向量化机会时。
如果你不确定 -C target-cpu=native 是否在最佳地工作,请比较 rustc --print cfg
和 rustc --print cfg -C target-cpu=native 的输出,以查看后者是否正确检测到了
CPU 特性。如果没有,你可以使用 -C target-feature 来针对特定特性。
配置文件引导优化
配置文件引导优化(PGO)是一种编译模型,你先编译程序,在示例数据上运行它并收集分析 数据,然后使用这些分析数据来指导程序的第二次编译。这可以将运行时速度提高 10% 或更多。 示例 1, 示例 2.
这是一项高级技术,需要付出一些努力来设置,但在某些情况下是值得的。有关详细信息,
请参阅 rustc PGO 文档。此外,cargo-pgo 命令使使用 PGO(以及类似的 BOLT)
来优化 Rust 二进制文件变得更加容易。
遗憾的是,PGO 不支持托管在 crates.io 上并通过 cargo install 分发的二进制文件,
这限制了它的可用性。
最小化二进制体积
以下构建配置选项主要旨在最小化二进制体积。它们对运行时速度的影响各不相同。
优化级别
你可以请求旨在最小化二进制体积的优化级别,方法是在 Cargo.toml 文件中添加以下行:
[profile.release]
opt-level = "z"
这也可能降低运行时速度。
替代方案是 opt-level = "s",它对最小化二进制体积的追求不那么激进。与 opt-level = "z"
相比,它允许稍多一些的内联以及循环的向量化。
panic 时中止
如果你不需要在 panic 时展开堆栈,例如因为你的程序不使用 catch_unwind,你可以
告诉编译器在 panic 时简单地中止。panic 时,你的程序仍会产生回溯信息。
这可能会略微减小二进制体积、略微提高运行时速度,甚至可能略微减少编译时间。在
Cargo.toml 文件中添加以下行:
[profile.release]
panic = "abort"
剥离符号
你可以通过向 Cargo.toml 添加以下行来告诉编译器剥离 release 构建中的符号:
[profile.release]
strip = "symbols"
示例.
然而,剥离符号可能会使编译后的程序更难调试和分析。例如,如果剥离后的程序 panic, 生成的回溯信息可能比正常情况下包含更少的有用信息。确切效果取决于平台。
Release 构建不需要剥离调试信息。默认情况下,本地 release 构建不会生成调试信息, 并且标准库的调试信息在 release 构建中已自动剥离自 Rust 1.77 起。
其他想法
有关更高级的二进制体积最小化技术,请查阅优秀的 min-sized-rust 仓库中的全面文档。
最小化编译时间
以下构建配置选项主要旨在最小化编译时间。
链接
编译时间的很大一部分实际上是链接时间,特别是在小改动后重新构建程序时。在某些平台上, 可以选择比默认链接器更快的链接器。
一个选项是 lld,可在 Linux 和 Windows 上使用。lld 在 Linux 上已经是默认链接器 自 Rust 1.90 起。在 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"]
有一个 GitHub Issue 在跟踪对 lld 的全面支持。
另一个选项是 mold,目前在 Linux 上可用。只需将上述指令中的 lld 替换为 mold。
mold 通常比 lld 更快。
示例.
它也更新,可能并非在所有情况下都有效。
最后一个选项是 wild,目前仅适用于 Linux。它可能比 mold 还快,但不够成熟。
在 Mac 上,不需要替代链接器,因为系统链接器已经很快。
与本章中的其他选项不同,选择另一个链接器没有任何权衡。只要链接器能正确地用于你的程序 (除非你在做不寻常的事情,否则很可能是这样),替代链接器可以显著加快速度,没有任何 缺点。
禁用调试信息生成
虽然 release 构建提供了最佳性能,但许多人在开发时使用 dev 构建,因为它们构建更快。 如果你使用 dev 构建但不经常使用调试器,请考虑禁用调试信息。这可以显著改善 dev 构建 时间,最多可提高 20-40%。 示例
要禁用调试信息生成,请将以下行添加到 Cargo.toml 文件中:
[profile.dev]
debug = false
请注意,这意味着堆栈跟踪将不包含行信息。如果你希望保留行信息,但不需要完整的调试器
信息,可以使用 debug = "line-tables-only" 代替,这仍然能带来大部分编译时间收益。
实验性并行前端
如果你使用 nightly Rust,可以启用实验性的并行前端。它可能会以减少编译时内存使用为 代价来缩短编译时间。它不会影响生成代码的质量。
你可以通过将 -Zthreads=N 添加到 RUSTFLAGS 来实现,例如:
RUSTFLAGS="-Zthreads=8" cargo build --release
或者,从 config.toml 文件(适用于一个或多个项目)启用并行前端,添加以下行:
[build]
rustflags = ["-Z", "threads=8"]
除了 8 之外的其他值也是可能的,但 8 往往是能给出最佳结果的值。
在最好的情况下,实验性并行前端可以将编译时间减少多达 50%。但效果差异很大,取决于 代码的特征及其构建配置,对于某些程序,编译时间没有改善。
Cranelift Codegen 后端
如果你使用 nightly Rust,可以在某些平台上启用 Cranelift codegen 后端。它可能会以 降低生成代码质量为代价来减少编译时间,因此建议用于 dev 构建而非 release 构建。
首先,使用以下 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 文档。
自定义 Profile
除了 dev 和 release profile 之外,Cargo 还支持自定义 profile。例如,如果你发现
dev 构建的运行时速度不够,而 release 构建的编译时间对于日常开发来说太慢,那么创建
一个介于 dev 和 release 之间的自定义 profile 可能会很有用。
总结
构建配置涉及许多选择。以下要点将上述信息总结为一些建议。
- 如果你想最大化运行时速度,请考虑以下所有选项:
codegen-units = 1、lto = "fat"、替代分配器和panic = "abort"。 - 如果你想最小化二进制体积,请考虑
opt-level = "z"、codegen-units = 1、lto = "fat"、panic = "abort"和strip = "symbols"。 - 无论哪种情况,如果不需要广泛的架构支持,请考虑
-C target-cpu=native;如果 与你的分发机制兼容,请考虑cargo-pgo。 - 如果你所在的平台支持更快的链接器,请始终使用它,因为这样做没有任何缺点。
- 如果你需要有关这些选择的额外帮助,请使用
cargo-wizard。 - 对所有更改逐一进行基准测试,以确保它们具有预期的效果。
最后,这个 issue 跟踪了 Rust 编译器自身构建配置的演变。Rust 编译器的构建系统比 大多数 Rust 程序更奇特、更复杂。尽管如此,这个 issue 可能有助于说明如何将构建配置 选择应用于大型程序。
代码检查
Clippy 是一个用于捕获 Rust 代码中常见错误的 lint 集合。它通常是在 Rust 代码上 运行的优秀工具。它还能帮助提升性能,因为许多 lint 与可能导致次优性能的代码模式 有关。
鉴于自动检测问题优于手动检测,本书其余部分将不再提及 Clippy 默认检测到的性能问题。
基础
安装后,运行非常简单:
cargo clippy
完整的性能 lint 列表可以通过访问 lint 列表并取消选中除“Perf“以外的所有 lint 组来查看。
除了使代码更快之外,性能 lint 的建议通常还会使代码更简单、更符合语言习惯,因此即使 对于不频繁执行的代码,也值得遵循。
相反,一些非性能的 lint 建议也能提升性能。例如,ptr_arg 风格 lint 建议将各种容器参数
改为切片,例如将 &mut Vec<T> 参数改为 &mut [T]。这样做的主要动机是切片提供了更灵活的
API,但也可能因为减少间接访问并为编译器提供更好的优化机会而产生更快的代码。
示例。
禁止使用某些类型
在接下来的章节中,我们将看到,有时值得避免使用某些标准库类型,而采用更快的替代方案。 如果你决定使用这些替代方案,很容易在某些地方意外地错误使用标准库类型。
你可以使用 Clippy 的 disallowed_types lint 来避免这个问题。例如,要禁止使用标准哈希表
(原因在哈希章节中解释),请在代码中添加一个 clippy.toml 文件,包含以下内容。
disallowed-types = ["std::collections::HashMap", "std::collections::HashSet"]
性能分析
在优化程序时,你还需要一种方法来确定程序的哪些部分是“热点“(执行频率足以影响运行时) 并值得修改。这最好通过性能分析来实现。
分析器
有许多不同的分析器可用,各有其优缺点。以下是在 Rust 程序上成功使用过的分析器的不 完整列表。
- perf 是一个使用硬件性能计数器的通用分析器。Hotspot 和 Firefox Profiler 适合查看 perf 记录的数据。它适用于 Linux。
- Instruments 是 macOS 上随 Xcode 提供的通用分析器。
- Intel VTune Profiler 是一个通用分析器。它适用于 Windows、Linux 和 macOS。
- AMD μProf 是一个通用分析器。它适用于 Windows 和 Linux。
- samply 是一个采样分析器,生成可在 Firefox Profiler 中查看的分析结果。它适用于 Mac、Linux 和 Windows。
- flamegraph 是一个 Cargo 命令,使用 perf/DTrace 分析你的代码,然后在火焰图中 显示结果。它适用于 Linux 和所有支持 DTrace 的平台(macOS、FreeBSD、NetBSD 和 可能 Windows)。
- Cachegrind 和 Callgrind 提供全局、按函数和按源代码行的指令计数以及模拟缓存 和分支预测数据。它们适用于 Linux 和其他一些 Unix 系统。
- DHAT 擅长查找代码中导致大量分配的部分,并提供对峰值内存使用的洞察。它还可以
用于识别对
memcpy的热点调用。它适用于 Linux 和其他一些 Unix 系统。dhat-rs 是一个实验性的替代方案,功能略弱,需要对 Rust 程序进行少量修改,但适用于所有平台。 - heaptrack 和 bytehound 是堆分析工具。它们适用于 Linux。
counts支持临时分析,结合使用eprintln!语句与基于频率的后处理,非常适合 获得对代码部分的领域特定洞察。它适用于所有平台。- Coz 执行因果分析来测量优化潜力,通过 coz-rs 支持 Rust。它适用于 Linux。
调试信息
要有效地分析 release 构建,你可能需要启用源代码行调试信息。为此,请将以下行添加到
Cargo.toml 文件中:
[profile.release]
debug = "line-tables-only"
有关 debug 设置的更多详细信息,请参阅 Cargo 文档。
遗憾的是,即使执行了上述步骤,你也不会获得标准库代码的详细性能分析信息。这是因为 已发布版本的 Rust 标准库在构建时未包含调试信息。
最可靠的解决方法是按照这些说明构建你自己的编译器和标准库版本,并在仓库根目录的
bootstrap.toml 文件中添加以下行:
[rust]
debuginfo-level = 1
这很麻烦,但在某些情况下可能值得付出努力。
或者,不稳定的 build-std 功能允许你将标准库作为程序正常编译的一部分进行编译, 使用相同的构建配置。但是,标准库调试信息中的文件名不会指向源代码文件,因为此功能 不会同时下载标准库源代码。因此,这种方法对需要源代码才能完全工作的分析器(如 Cachegrind 和 samply)没有帮助。
帧指针
Rust 编译器可能会优化掉帧指针,这会损害堆栈跟踪等性能分析信息的质量。要强制编译器
使用帧指针,请使用 -C force-frame-pointers=yes 标志。例如:
RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release
或者,从 config.toml 文件(适用于一个或多个项目)强制使用帧指针,添加以下行:
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
符号反修饰
Rust 使用一种名称修饰形式来编码编译代码中的函数名。如果分析器不知道这一点,其输出
可能包含以 _ZN 或 _R 开头的符号名称,例如 _ZN3foo3barE 或
_ZN28_$u7b$$u7b$closure$u7d$$u7d$E 或
_RMCsno73SFvQKx_1cINtB0_3StrKRe616263_E
此类名称可以使用 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"]
内联
进入和退出热门的、未被内联的函数通常占用了不可忽略的执行时间。内联这些函数可以消除 这些进出开销,并允许编译器进行额外的底层优化。最好的情况下,整体效果虽小,但却是 轻松获得的速度提升。
有四种可用于 Rust 函数的内联属性。
- 无。编译器将自行决定函数是否应该被内联。这取决于优化级别、函数大小、函数是否是 泛型,以及内联是否跨越 crate 边界等因素。
#[inline]。这建议函数应该被内联。#[inline(always)]。这强烈建议函数应该被内联。#[inline(never)]。这强烈建议函数不应该被内联。
内联属性并不保证函数一定被内联或不被内联,但在实践中,#[inline(always)] 会在除最
特殊情况外的所有情况下导致内联。
内联是不可传递的。如果一个函数 f 调用了一个函数 g,并且你希望这两个函数在 f 的
调用点一起被内联,那么这两个函数都应该用内联属性标记。
简单情况
最适合内联的是 (a) 非常小的函数,或 (b) 只有一个调用点的函数。即使没有内联属性, 编译器通常也会自行内联这些函数。但编译器并不总能做出最佳选择,因此有时需要属性。 示例 1, 示例 2, 示例 3, 示例 4, 示例 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 }
添加内联属性后应再次测量,因为效果可能不可预测。有时它没有效果,因为附近一个原本 被内联的函数不再被内联了。有时它会拖慢代码。内联还会影响编译时间,特别是跨 crate 内联,这涉及复制函数的内部表示。
困难情况
有时你有一个较大的函数,并且有多个调用点,但只有一个调用点是热点。你希望内联热点 调用点以提升速度,但不要内联冷调用点以避免不必要的代码膨胀。处理此问题的方法是将 函数拆分为始终内联和永不内联的变体,后者调用前者。
例如,这个函数:
#![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() {};
// 在热点调用点使用此函数。
#[inline(always)]
fn inlined_my_function() {
one();
two();
three();
}
// 在冷调用点使用此函数。
#[inline(never)]
fn uninlined_my_function() {
inlined_my_function();
}
}
外提
内联的逆操作是外提:将很少执行的代码移到一个单独的函数中。你可以为这类函数添加
#[cold] 属性,告诉编译器该函数很少被调用。这可以为热路径生成更好的代码。
示例 1,
示例 2.
哈希
HashSet 和 HashMap 是两种广泛使用的类型,有一些方法可以让它们更快。
替代哈希器
默认的哈希算法未指定,但在撰写本文时,默认算法是一种名为 SipHash 1-3 的算法。 这种算法质量很高——提供了很高的碰撞防护能力——但相对较慢,特别是对于整数这样的短键。
如果性能分析表明哈希是热点,且 HashDoS 攻击对你的应用不是问题,那么使用具有更快 哈希算法的哈希表可以带来巨大的速度提升。
rustc-hash提供了FxHashSet和FxHashMap类型,可作为HashSet和HashMap的直接替代品。它的哈希算法质量较低但速度非常快,尤其适用于整数键, 并且已被发现在 rustc 中的性能优于所有其他哈希算法。(fxhash是相同算法和类型的 较旧、维护较少的实现。)fnv提供了FnvHashSet和FnvHashMap类型。它的哈希算法质量比rustc-hash更高,但稍慢一些。ahash提供了AHashSet和AHashMap。它的哈希算法可以利用某些处理器上可用的 AES 指令支持。
如果哈希性能在程序中很重要,值得尝试多种替代方案。例如,在 rustc 中观察到了以下结果。
- 从
fnv切换到fxhash带来了高达 6% 的速度提升。 - 尝试从
fxhash切换到ahash导致了1-4% 的减速。 - 尝试从
fxhash切换回默认哈希器导致了4-84% 的减速!
如果你决定普遍使用某种替代方案,例如 FxHashSet/FxHashMap,很容易在某些地方
意外地使用 HashSet/HashMap。你可以使用 Clippy 来避免这个问题。
有些类型不需要哈希。例如,你可能有一个包装整数的 newtype,且整数值是随机的或接近随机。
对于此类类型,哈希值的分布与值本身的分布不会有太大差异。在这种情况下,
nohash_hasher crate 可能会很有用。
哈希函数设计是一个复杂的主题,已超出本书的范围。ahash 文档中有很好的讨论。
逐字节哈希
当你用 #[derive(Hash)] 注解一个类型时,生成的 hash 方法将分别哈希每个字段。
对于某些哈希函数,将类型转换为原始字节并将字节作为流进行哈希可能会更快。这对于满足
某些属性(例如没有填充字节)的类型是可行的。
zerocopy 和 bytemuck crate 都提供了 #[derive(ByteHash)] 宏,用于生成
执行这种逐字节哈希的 hash 方法。derive_hash_fast crate 的 README 提供了
关于此技术的更多细节。
这是一项高级技术,性能效果高度依赖于哈希函数和被哈希类型的具体结构。请仔细测量。
堆分配
堆分配的成本中等偏高。具体细节取决于所使用的分配器,但每次分配(和释放)通常涉及 获取全局锁、执行一些非平凡的数据结构操作,以及可能执行系统调用。小分配不一定比大 分配更便宜。值得了解哪些 Rust 数据结构和操作会引起分配,因为避免它们可以极大地 提升性能。
Rust 容器速查表提供了常见 Rust 类型的可视化展示,是以下各节的优秀伴侣。
性能分析
如果通用分析器显示 malloc、free 及相关函数是热点,那么尝试降低分配率和/或使用
替代分配器可能是值得的。
DHAT 是在降低分配率时使用的优秀分析器。它适用于 Linux 和其他一些 Unix 系统。 它精确定位热点分配位置及其分配率。确切结果会有所不同,但 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 值。
有时值得将一个 struct 或 enum 中的一个或多个字段装箱,以使类型更小。(有关更多信息, 请参阅类型大小章节。)
除此之外,Box 很直接,没有太多优化空间。
Rc/Arc
Rc/Arc 类似于 Box,但堆上的值附带两个引用计数。它们允许值共享,
这可以成为减少内存使用的有效方式。
然而,如果用于很少共享的值,它们可能会通过堆分配那些原本不会堆分配的值来增加 分配率。 示例.
与 Box 不同,对 Rc/Arc 值调用 clone 不涉及分配。相反,它只是增加
引用计数。
Vec
Vec 是一个堆分配的类型,在优化分配数量和/或最小化浪费的空间方面有很大的空间。
要做到这一点,需要理解其元素是如何存储的。
一个 Vec 包含三个字段:长度、容量和指针。如果容量非零且元素大小非零,指针将指向
堆分配的内存;否则,它将不指向已分配的内存。
即使 Vec 本身不是堆分配的,其元素(如果存在且大小非零)始终是堆分配的。如果存在
大小非零的元素,存储这些元素的内存可能大于实际需要,为未来的额外元素提供空间。
存在的元素数量是长度,而无需重新分配即可容纳的元素数量是容量。
当向量需要增长超过当前容量时,元素将被复制到一个更大的堆分配中,旧的堆分配将被释放。
Vec 增长
通过常见方式(vec![]
或 Vec::new 或 Vec::default)创建的新空 Vec 的长度和容量均为零,
不需要堆分配。如果你反复将单个元素推入 Vec 的末尾,它将定期重新分配。增长策略
未指定,但在撰写本文时,它使用一种准倍增策略,导致以下容量:0、4、8、16、32、64
等等。(它直接从 0 跳到 4,而不是经过 1 和 2,因为这在实践中避免了许多分配。)
随着向量的增长,重新分配的频率将呈指数级下降,但可能浪费的额外容量将呈指数级增加。
这种增长策略对于可增长的数据结构是典型的,在一般情况下是合理的,但如果你预先知道
向量的大致长度,通常可以做得更好。如果你有一个热门的向量分配点(例如热门的
Vec::push 调用),值得使用 eprintln! 打印该点的向量长度,然后进行一些
后处理(例如使用 counts)来确定长度分布。例如,你可能有许多短向量,或者数量
较少但非常长的向量,优化分配点的最佳方式会相应变化。
短 Vec
如果你有许多短向量,可以使用 smallvec crate 中的 SmallVec 类型。
SmallVec<[T; N]> 是 Vec 的直接替代品,它可以在 SmallVec 自身内部存储 N 个
元素,然后在元素数量超过该值时切换到堆分配。(另请注意,vec![] 字面量必须替换为
smallvec![] 字面量。)
示例 1,
示例 2.
SmallVec 在适当使用时能可靠地降低分配率,但使用它并不能保证性能提升。对于正常操作,
它比 Vec 稍慢,因为它必须始终检查元素是否在堆上分配。此外,如果 N 较大或 T 较大,
则 SmallVec<[T; N]> 本身可能比 Vec<T> 更大,并且复制 SmallVec 值会更慢。
与往常一样,需要进行基准测试来确认优化是否有效。
如果你有许多短向量并且精确知道它们的最大长度,那么来自 arrayvec crate 的
ArrayVec 是比 SmallVec 更好的选择。它不需要回退到堆分配,这使其稍微快一些。
示例.
更长的 Vec
如果你知道向量的最小或确切大小,可以使用 Vec::with_capacity、Vec::reserve 或
Vec::reserve_exact 预留特定容量。例如,如果你知道向量将增长到至少 20 个元素,
这些函数可以通过一次分配立即提供一个容量至少为 20 的向量,而逐个推送元素将导致四次
分配(容量分别为 4、8、16 和 32)。
示例.
如果你知道向量的最大长度,上述函数还可以让你避免不必要地分配多余空间。类似地,
Vec::shrink_to_fit 可用于最小化浪费的空间,但请注意它可能导致重新分配。
String
String 包含堆分配的字节。String 的表示和操作与 Vec<u8> 非常相似。许多与增长
和容量相关的 Vec 方法在 String 中都有对应方法,例如 String::with_capacity。
smallstr crate 中的 SmallString 类型类似于 SmallVec 类型。
smartstring crate 中的 String 类型是 String 的直接替代品,对于少于三个字段
大小的字符的字符串,它避免堆分配。在 64 位平台上,这是任何小于 24 字节的字符串,
包括包含 23 个或更少 ASCII 字符的所有字符串。
示例.
请注意,format! 宏会生成一个 String,这意味着它执行一次分配。如果你可以使用
字符串字面量来避免 format! 调用,就可以避免这次分配。
示例.
std::format_args 和/或 lazy_format crate 可能对此有所帮助。
哈希表
HashSet 和 HashMap 是哈希表。它们在分配方面的表示和操作与 Vec 类似:
它们有一个连续的堆分配,用于保存键和值,并在表增长时根据需要重新分配。许多与增长
和容量相关的 Vec 方法在 HashSet/HashMap 中都有对应方法,例如
HashSet::with_capacity。
clone
对包含堆分配内存的值调用 clone 通常涉及额外的分配。例如,对非空 Vec 调用
clone 需要为元素进行新的分配(但请注意,新 Vec 的容量可能与原始 Vec 的容量
不同)。例外情况是 Rc/Arc,其中 clone 调用仅增加引用计数。
clone_from 是 clone 的替代方案。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 的分配被重用
assert_eq!(v1.capacity(), 99);
}
尽管 clone 通常会引起分配,但在许多情况下使用它是合理的,并且通常可以使代码更简单。
使用性能分析数据来确定哪些 clone 调用是热点,值得花精力避免。
有时 Rust 代码最终包含不必要的 clone 调用,原因可能是 (a) 程序员错误,或 (b) 代码
变更使得以前必要的 clone 调用变得不必要。如果你看到一个看似不必要的热点 clone
调用,有时可以简单地将其移除。
示例 1,
示例 2,
示例 3.
to_owned
ToOwned::to_owned 为许多常见类型实现。它从借用的数据创建拥有的数据,通常通过
克隆,因此经常导致堆分配。例如,它可以用于从 &str 创建 String。
有时可以通过在 struct 中存储对借用数据的引用而不是拥有副本来避免 to_owned 调用
(以及相关的调用如 clone 和 to_string)。这需要在 struct 上添加 lifetime 标注,
使代码变得复杂,只有在性能分析和基准测试表明值得时才应进行。
示例.
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 也是可行的。
上述所有内容适用于数据不可变的情况。但 Cow 也允许在需要修改时将借用数据提升为
拥有数据。Cow::to_mut 将获取拥有值的可变引用,必要时进行克隆。这称为“写时复制“
(clone-on-write),也就是 Cow 名称的由来。
这种写时复制行为在你有一些主要是只读但偶尔需要修改的借用数据(如 &str)时很有用。
最后,因为 Cow 实现了 Deref,你可以直接对其包含的数据调用方法。
Cow 使用起来可能有点棘手,但通常值得付出努力。
重用集合
有时你需要分阶段构建如 Vec 这样的集合。通常修改单个 Vec 比构建多个 Vec 然后
合并它们更好。
例如,如果你有一个可能被多次调用的函数 do_stuff,它生成一个 Vec:
#![allow(unused)]
fn main() {
fn do_stuff(x: u32, y: u32) -> Vec<u32> {
vec![x, y]
}
}
更好的做法是修改传入的 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 的使用与其他
迭代无关的事实。
示例 1,
示例 2.
类似地,有时值得在 struct 中保留一个主力集合,以便在被重复调用的一个或多个方法中 重用。
从文件读取行
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 时才有效。
示例.
使用替代分配器
也可以在不更改代码的情况下改善堆分配性能,只需使用不同的分配器即可。有关详细信息, 请参阅替代分配器一节。
避免回归
为确保代码执行的分配数量和/或大小不会意外增加,你可以使用 dhat-rs 的堆使用测试 功能来编写测试,检查特定代码片段分配了预期的堆内存量。
类型大小
缩小频繁实例化的类型有助于提升性能。
例如,如果内存使用量很高,可以使用 DHAT 等堆分析器来识别热点分配点和涉及的类型。 缩小这些类型可以降低峰值内存使用量,并通过减少内存流量和缓存压力来改善性能。
此外,大于 128 字节的 Rust 类型会通过 memcpy 而非内联代码进行复制。如果 memcpy
在性能分析结果中占据了不可忽略的比例,DHAT 的“复制分析“模式将准确告诉你热点 memcpy
调用在哪里以及涉及哪些类型。将这些类型缩小到 128 字节或更少,可以通过避免 memcpy
调用和减少内存流量来使代码更快。
测量类型大小
std::mem::size_of 给出类型的大小(以字节为单位),但你通常还需要知道精确的内存布局。
例如,一个 enum 可能因为单个过大的变体而大得出奇。
-Zprint-type-sizes 选项正好能做到这一点。它在 release 版本的 rustc 上未启用,
因此你需要使用 nightly 版本的 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
输出显示了以下内容。
- 类型的大小和对齐。
- 对于 enum,discriminant 的大小。
- 对于 enum,每个变体的大小(从最大到最小排序)。
- 所有字段的大小、对齐和顺序。(注意编译器已重新排序了变体
C的字段以最小化E的大小。) - 所有填充的大小和位置。
另外,top-type-sizes crate 可用于以更紧凑的形式显示输出。
一旦你知道了热类型的布局,有多种方法可以缩小它。
字段顺序
Rust 编译器会自动排序 struct 和 enum 中的字段以最小化它们的大小(除非指定了
#[repr(C)] 属性),因此你无需担心字段顺序。但还有其他方法可以最小化热类型的大小。
更小的 Enum
如果 enum 有一个过大的变体,考虑将其中一个或多个字段装箱。例如,你可以将这个类型:
#![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 的可用性略有下降,
特别是在 match 模式中。
示例 1,
示例 2,
示例 3,
示例 4,
示例 5,
示例 6.
更小的整数
通常可以通过使用更小的整数类型来缩小类型。例如,虽然使用 usize 作为索引最为自然,
但将索引存储为 u32、u16 甚至 u8,然后在使用点转换为 usize 通常是合理的。
示例 1,
示例 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>());
}
或者,可以使用 Iterator::collect 直接从迭代器构建 boxed slice。如果迭代器的
长度是预先已知的,这样可以避免任何重新分配。
#![allow(unused)]
fn main() {
let bs: Box<[u32]> = (1..3).collect();
}
Boxed slice 可以使用 slice::into_vec 转换为向量,无需任何克隆或重新分配。
ThinVec
Boxed slices 的一个替代方案是来自 thin_vec crate 的 ThinVec。它在功能上等同于
Vec,但将长度和容量与元素(如果有的话)存储在同一个分配中。这意味着
size_of::<ThinVec<T>> 只有一个字段大小。
ThinVec 在频繁实例化的类型中,对于经常为空的向量是一个不错的选择。它也可以用于
缩小 enum 的最大变体(如果该变体包含 Vec)。
避免回归
如果一个类型非常热门,其大小会影响性能,那么使用静态断言来确保它不会意外地变大
是一个好主意。以下示例使用了来自 static_assertions crate 的宏。
// 此类型使用频繁。确保它不会无意中变大。
#[cfg(target_arch = "x86_64")]
static_assertions::assert_eq_size!(HotType, [u8; 64]);
cfg 属性很重要,因为类型大小在不同的平台上可能不同。将断言限制在 x86_64
(通常是最广泛使用的平台)很可能足以在实践中防止回归。
标准库类型
值得通读常见标准库类型(如 Vec、Option、Result 和 Rc/Arc)的
文档,以找到有时可用于提升性能的有趣函数。
了解标准库类型的高性能替代方案也很有价值,例如 Mutex、RwLock、Condvar 和
Once。
Vec
创建长度为 n 的零填充 Vec 的最佳方法是使用 vec![0; n]。这很简单,并且可能
与替代方案一样快或更快,例如使用 resize、extend 或任何涉及 unsafe 的方式,
因为它可以利用操作系统的协助。
Vec::remove 移除特定索引处的元素并将所有后续元素向左移动一位,复杂度为 O(n)。
Vec::swap_remove 用最后一个元素替换特定索引处的元素,这不保持顺序,但复杂度为 O(1)。
Vec::retain 高效地从 Vec 中移除多个元素。其他集合类型如 String、HashSet 和
HashMap 也有等效的方法。
Option 和 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()); // 总是求值 `expensive()`
}
应改为:
#![allow(unused)]
fn main() {
fn expensive() {}
let o: Option<u32> = None;
let r = o.ok_or_else(|| expensive()); // 仅在需要时求值 `expensive()`
}
示例.
Option::map_or、Option::unwrap_or、
Result::or、Result::map_or 和 Result::unwrap_or 也有类似的替代方法。
Rc/Arc
Rc::make_mut/Arc::make_mut 提供了写时复制语义。它们获取 Rc/Arc 的可变引用。
如果引用计数大于 1,它们将 clone 内部值以确保唯一所有权;否则,它们将修改原始值。
虽然不常用,但偶尔会非常有用。
示例 1,
示例 2.
Mutex、RwLock、Condvar 和 Once
parking_lot crate 提供了这些同步类型的替代实现。parking_lot 类型的 API 和语义
与标准库中等效类型相似但不完全相同。
parking_lot 版本过去在体积、速度和灵活性上始终优于标准库版本,但标准库版本在某些
平台上已经有了很大改进。因此,在切换到 parking_lot 之前应该进行测量。
如果你决定普遍使用 parking_lot 类型,很容易在某些地方意外地使用标准库的等效类型。
你可以使用 Clippy 来避免这个问题。
迭代器
collect 和 extend
Iterator::collect 将迭代器转换为集合(如 Vec),这通常需要一次分配。如果之后
只是再次迭代该集合,则应避免调用 collect。
因此,从函数返回 impl Iterator<Item=T> 这样的迭代器类型通常比返回 Vec<T> 更好。
请注意,如这篇博客文章所述,这些返回类型有时需要额外的 lifetime 标注。
示例.
类似地,你可以使用 extend 用迭代器扩展现有集合(如 Vec),而不是将迭代器收集到
Vec 中再使用 append。
最后,在编写迭代器时,如果可能的话,实现 Iterator::size_hint 或
ExactSizeIterator::len 方法通常是有价值的。使用该迭代器的 collect 和 extend
调用可能会进行更少的分配,因为它们预先知道了迭代器产生的元素数量。
链式操作
chain 非常方便,但也可能比单个迭代器慢。对于热门的迭代器,如果可能的话,
最好避免使用。
示例.
类似地,filter_map 可能比先使用 filter 再使用 map 更快。
块迭代
当需要块迭代器且已知块大小正好能整除切片长度时,请使用更快的 slice::chunks_exact
而不是 slice::chunks。
当不确定块大小是否能正好整除切片长度时,使用 slice::chunks_exact 并结合
ChunksExact::remainder 或手动处理多余元素仍然可能更快。
示例 1,
示例 2.
同样的情况也适用于相关的迭代器:
slice::rchunks、slice::rchunks_exact和RChunksExact::remainder;slice::chunks_mut、slice::chunks_exact_mut和ChunksExactMut::into_remainder;slice::rchunks_mut、slice::rchunks_exact_mut和RChunksExactMut::into_remainder.
copied
当迭代整数等小型数据类型的集合时,使用 iter().copied() 可能比 iter() 更好。
消费该迭代器的代码将按值而非按引用接收整数,LLVM 在这种情况下可能生成更好的代码。
示例 1,
示例 2.
这是一项高级技术。你可能需要检查生成的机器码以确定它是否有效。有关如何执行此操作的 详细信息,请参阅机器码章节。
边界检查
默认情况下,在 Rust 中对切片和向量等容器类型的访问会涉及边界检查。这些检查可能会 影响性能,例如在热循环中,尽管这种情况比你想象的更少见。
有几种安全的方法可以修改代码,让编译器知道容器的长度,从而优化掉边界检查。
让这些方法生效可能有些棘手。Bounds Check Cookbook 对此主题有更详细的讨论。
作为最后的手段,可以使用不安全的 get_unchecked 和 get_unchecked_mut 方法。
I/O
锁定
Rust 的 print! 和 println! 宏每次调用时都会锁定 stdout。如果你重复调用这些
宏,手动锁定 stdout 可能会更好。
例如,将这段代码:
#![allow(unused)]
fn main() {
let lines = vec!["one", "two", "three"];
for line in lines {
println!("{}", line);
}
}
改为:
#![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)?;
}
// 当 `lock` 被丢弃时,stdout 解锁
Ok(())
}
}
stdin 和 stderr 在重复操作时同样可以锁定。
缓冲
Rust 文件 I/O 默认是无缓冲的。如果你对文件或网络套接字进行大量小而重复的读取或写入
调用,请使用 BufReader 或 BufWriter。它们在内存中维护输入和输出缓冲区,
从而最大限度地减少所需的系统调用次数。
例如,将这个无缓冲写入器代码:
#![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")?;
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 = BufWriter::new(std::fs::File::create("test.txt")?);
for line in lines {
writeln!(out, "{}", line)?;
}
out.flush()?;
Ok(())
}
}
显式调用 flush 并非严格必要,因为当 out 被丢弃时会自动刷新。但是,在这种情况下,
刷新时发生的任何错误都会被忽略,而显式刷新将使该错误显现出来。
忘记缓冲在写入时更为常见。无缓冲和有缓冲的写入器都实现了 Write trait,这意味着
向无缓冲写入器写入的代码与向有缓冲写入器写入的代码基本相同。相比之下,无缓冲读取器
实现了 Read trait,而有缓冲读取器实现了 BufRead trait,这意味着从无缓冲读取器
读取的代码与从有缓冲读取器读取的代码是不同的。例如,使用无缓冲读取器逐行读取文件很困难,
但使用有缓冲读取器通过 BufRead::read_line 或 BufRead::lines 就很简单。
因此,很难像上面写入器的例子那样为读取器写出修改前后如此相似的示例。
最后,请注意缓冲也适用于 stdout,因此在多次写入 stdout 时,你可能希望将手动锁定和 缓冲结合起来。
从文件读取行
本节解释了在使用 BufRead 逐行读取文件时如何避免过多的分配。
以原始字节形式读取输入
内置的 String 类型在内部使用 UTF-8,当你将输入读入其中时,UTF-8 验证会增加一个
虽小但非零的开销。如果你只想处理输入字节而不关心 UTF-8(例如处理 ASCII 文本时),
可以使用 BufRead::read_until。
还有专门用于读取面向字节的数据行和处理字节字符串的 crate。
日志与调试
有时日志代码或调试代码会显著拖慢程序。要么是日志/调试代码本身很慢,要么是为日志/调试 提供数据的数据收集代码很慢。请确保在未启用日志/调试功能时,不会为了日志/调试目的而执行 不必要的工作。 示例 1, 示例 2, 示例 3.
注意,assert! 调用始终执行,但 debug_assert! 调用仅在 dev 构建中运行。
如果你的断言是热点但并非安全必需,请考虑将其改为 debug_assert!。
示例 1,
示例 2.
包装类型
Rust 有多种“包装“类型,例如 RefCell 和 Mutex,它们为值提供特殊的行为。
访问这些值可能需要花费不可忽略的时间。如果多个这样的值通常一起被访问,将它们放在
单个包装器内可能会更好。
例如,像这样的 struct:
#![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)>>,
}
}
这是否有助于提升性能将取决于值的具体访问模式。 示例.
机器码
Note
程序在执行时,可以将执行的路径分为冷热路径 (cold/hot path)。与之对应的,经常被执行到的代码成为热代码,与之相对的成为冷代码。 ——OI Wiki
当你有小段非常常用的代码时,检查生成的机器码以查看是否存在任何低效之处(例如可消除的
边界检查)可能是值得的。Compiler Explorer 网站是在小型代码片段上进行此操作的
优秀资源。cargo-show-asm 是一个可用于完整 Rust 项目的替代工具。
与之相关的是,core::arch 模块提供了对架构特定内建函数的访问,其中许多与
SIMD 指令有关。
并行
Rust 为安全的并行编程提供了出色的支持,这可以带来巨大的性能提升。有多种方式可以 在程序中引入并行,而最佳方式在很大程度上取决于程序的设计。
话虽如此,对并行的深入讨论已超出本书的范围。
如果你对基于线程的并行感兴趣,rayon 和 crossbeam crate 的文档是一个很好的
起点。Rust Atomics and Locks 也是一份优秀的资源。
如果你对细粒度的数据并行感兴趣,这篇博客文章对截至 2025 年 11 月 Rust 中 SIMD 支持的状况进行了很好的概述。
通用技巧
本书前面的章节讨论了 Rust 特有的技术。本节简要概述一些通用的性能原则。
只要避免了明显的陷阱(例如使用非 release 构建),Rust 代码通常运行速度快且内存占用小。 特别是如果你习惯于 Python 和 Ruby 等动态类型语言,或 Java 和 C# 等带有垃圾收集器的 静态类型语言时,这一点尤为明显。
优化后的代码通常比未优化的代码更复杂,编写起来也更费力。因此,只值得优化热代码。
最大的性能提升通常来自算法或数据结构的改变,而非底层优化。 示例 1, 示例 2.
编写与现代硬件配合良好的代码并不总是容易的,但值得努力。例如,尽可能减少缓存未命中 和分支预测错误。
大多数优化带来的速度提升都很小。虽然单个小的速度提升并不明显,但如果你能做足够的优化, 它们累积起来的效果就很可观。
不同的分析器各有其优势。使用多种分析器是好的做法。
当性能分析表明某个函数是热点时,通常有两种加速方法:(a) 让函数运行得更快,和/或 (b) 减少对它的调用。
消除愚蠢的减速通常比引入巧妙的加速更容易。
除非必要,避免计算。惰性/按需计算通常是一个胜利。 示例 1, 示例 2.
复杂的通用情况通常可以通过乐观地检查更简单的常见特殊情况来避免。 示例 1, 示例 2, 示例 3. 特别地,当小规模占主导时,专门处理包含 0、1 或 2 个元素的集合通常是一个胜利。 示例 1, 示例 2, 示例 3, 示例 4.
类似地,处理重复数据时,通常可以使用一种简单的数据压缩形式,即为常见值使用紧凑表示, 然后对不常见的值回退到辅助表。 示例 1, 示例 2, 示例 3.
当代码处理多种情况时,测量各情况的频率并优先处理最常见的情况。
当处理具有高局部性的查找时,在数据结构前放置一个小型缓存可能是一个胜利。
优化后的代码通常具有非显而易见的结构,这意味着解释性注释非常有价值,特别是那些引用 性能分析测量的注释。像“99% 的情况下这个向量有 0 或 1 个元素,所以先处理这些情况“ 这样的注释可以很有启发性。
编译时间
虽然本书主要讨论改善 Rust 程序的性能,但本节讨论的是减少 Rust 程序的编译时间, 因为这是许多人感兴趣的相关话题。
最小化编译时间一节讨论了通过构建配置选择来减少编译时间的方法。本节其余部分讨论 需要修改程序代码来减少编译时间的方法。
有关更多编译时间缩减技术,请参阅 Corrode 的全面列表:更快 Rust 编译时间的技巧。
可视化
Cargo 有一个功能可以让你可视化程序的编译过程。使用以下命令构建:
cargo build --timings
完成后,它将打印一个 HTML 文件的名称。在 Web 浏览器中打开该文件。它包含一个 甘特图,显示程序中各个 crate 之间的依赖关系。这可以显示你的 crate 图中有多少 并行度,从而指示是否有任何使编译串行化的大型 crate 应该被拆分。有关如何阅读图表的 更多详细信息,请参阅文档。
宏
有些宏会生成大量代码。这些代码随后需要时间来编译。Rust 编译器的 -Zmacro-stats 标志
可以帮助识别此类情况。
例如,如果你只想测量项目中的一个叶子 crate:
cargo +nightly rustc -- -Zmacro-stats
编译器将打印由过程宏和声明式宏生成的代码量的信息。前者通常更值得关注。
或者,如果你想测量项目中的所有 crate:
RUSTFLAGS="-Zmacro-stats" cargo +nightly build
要查看生成的代码本身,可以使用 cargo-expand。
不必担心产生少量代码的宏,但如果宏生成的代码量可与手写代码量相当,则可能可以完全 移除该宏的使用,或用更廉价的替代方案替换。 示例.
或者,也可以修改宏以生成更少的代码。 示例 1, 示例 2.
LLVM IR
Rust 编译器使用 LLVM 作为其后端。LLVM 的执行可能占据编译时间的很大一部分, 特别是当 Rust 编译器的前端生成大量需要 LLVM 花费长时间优化的 IR 时。
这些问题可以通过 cargo llvm-lines 来诊断,它显示哪些 Rust 函数导致生成了最多的
LLVM IR。泛型函数通常是最重要的,因为它们可以在大型程序中实例化数十次甚至数百次。
如果泛型函数导致 IR 膨胀,有几种修复方法。最简单的方法是让函数更小。 示例 1, 示例 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())
}
示例.
有时像 Option::map 和 Result::map_err 这样的通用工具函数会被实例化多次。
将它们替换为等价的 match 表达式有助于编译时间。
这类更改对编译时间的影响通常很小,但偶尔也可能很大。 示例.
此类更改还可以减小二进制体积。