堆分配

堆分配成本适中。具体细节取决于使用的分配器,但每次分配(和释放)通常涉及获取全局锁、执行一些非平凡的数据结构操作,以及可能执行系统调用。小型分配并不一定比大型分配更便宜。值得了解的是,哪些 Rust 数据结构和操作会引起分配,因为避免它们可以极大地提高性能。

Rust 容器速查表 提供了常见 Rust 类型的可视化,并且是以下部分的绝佳参考。

分析

如果通用性能分析器显示 mallocfree 和相关函数为热点,则尝试减少分配速率和/或使用替代分配器可能是值得的。

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 值。

有时在结构体或枚举字段中使用一个或多个 Box 来使类型更小是值得的。(有关此内容的更多信息,请参阅 类型大小 章节。)

除此之外,Box 是直接的,没有太多优化的余地。

Rc/Arc

Rc/ArcBox 类似,但堆上的值伴随着两个引用计数。它们允许值共享,这可以是减少内存使用的有效方式。

然而,如果用于很少被共享的值,它们可能会增加分配速率,因为会为本来可能不会被堆分配的值进行堆分配。 示例

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

Vec

Vec 是一个具有大量优化空间的堆分配类型,可以优化分配数量和/或最小化浪费空间的量。要做到这一点,需要了解其元素是如何存储的。

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

即使 Vec 本身不是堆分配的,但元素(如果存在且大小非零)总是会被堆分配。如果存在大小非零的元素,则保存这些元素的内存可能比必要的更大,以提供额外的未来元素的空间。存在的元素数量称为长度,而无需重新分配的元素数量称为容量。

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

Vec 的增长

通过常见方式创建的新的空的 Vecvec![]Vec::newVec::default)的长度和容量为零,不需要堆分配。如果你反复将单个元素推送到 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 中的 ArrayVecSmallVec 更好。它不需要回退到堆分配,这使得它稍微快一点。

示例

更长的 Vec

如果你知道向量的最小或确切大小,你可以使用 Vec::with_capacityVec::reserveVec::reserve_exact 预留特定的容量。例如,如果你知道向量将至少增长到具有 20 个元素,这些函数可以立即提供至少容量为 20 的向量,只需一个分配,而逐个推送项目则会导致四个分配(容量分别为 4、8、16 和 32)。

示例

如果你知道向量的最大长度,上述函数也让你不必不必要地分配多余的空间。同样,Vec::shrink_to_fit 可用于最小化浪费的空间,但请注意,它可能导致重新分配。

String(字符串)

String 包含堆分配的字节。String 的表示和操作与 Vec<u8> 非常相似。许多与 Vec 的增长和容量相关的方法在 String 中也有等效方法,例如 String::with_capacity

smallstr 包中的 SmallString 类型类似于 SmallVec 类型。

smartstring 包中的 String 类型是 String 的一种可替代类型,对于包含少于三个字的字符串避免了堆分配。在 64 位平台上,这意味着任何长度小于 24 字节的字符串,包括所有包含 23 个或更少 ASCII 字符的字符串。示例

请注意,format! 宏会生成一个 String,这意味着它会执行一次分配。如果可以通过使用字符串字面值避免调用 format!,那么就可以避免此分配。示例std::format_args 和/或 lazy_format 包可能有助于实现这一点。

哈希表

HashSetHashMap 是哈希表。它们的表示和操作类似于 Vec,在分配方面:它们有一个单一的连续堆分配,保存键和值,并在表增长时根据需要重新分配。许多与 Vec 的增长和容量相关的方法在 HashSet/HashMap 中也有等效方法,例如 HashSet::with_capacity

clone(克隆)

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

clone_fromclone 的一种替代方法。a.clone_from(&b) 等效于 a = b.clone(),但可能会避免不必要的分配。例如,如果要将一个 Vec 克隆到现有 Vec 的顶部,则会尝试重用现有 Vec 的堆分配,如以下示例所示。

虽然 clone 通常会导致分配,但在许多情况下,这是合理的使用方式,而且通常可以使代码更简洁。使用性能分析数据来查看哪些 clone 调用是热点,并值得付出努力来避免。

有时 Rust 代码会包含不必要的 clone 调用,原因是(a)程序员错误,或者(b)代码更改使以前必要的 clone 调用变得不必要。如果看到一个热点 clone 调用似乎不必要,有时可以简单地将其删除。

to_owned(转为拥有的)

ToOwned::to_owned 实现了许多常见类型。它从借用数据创建拥有的数据,通常通过克隆,并因此经常导致堆分配。例如,它可以用于从 &str 创建 String

有时可以通过将借用数据的引用存储在结构体中而不是拥有的副本来避免 to_owned 调用(以及相关调用,如 cloneto_string)。这需要在结构体上添加生命周期注释,使代码复杂化,并且只有在分析和基准测试表明这样做是值得的时候才应该这样做。

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 也是可能的。

重复使用集合

有时您需要逐步构建集合,比如 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

类似地,有时值得在结构体内保留一个工作集合,以便在重复调用的一个或多个方法中重复使用。

从文件中读取行

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>,这意味着它为文件中的每一行进行分配。

使用循环遍历 [BufRead::read_line] 中的工作集合 String 是一种替代方法:

#![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 时,这才有效。

示例

使用替换分配器

还可以通过使用不同的分配器来提高堆分配性能,而不必更改代码。有关详细信息,请参阅[替代分配器]部分。

避免退化

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