性能比较:循环与迭代器

要确定是否使用循环或迭代器,您需要知道哪个实现更快:search 函数的显式 for 循环版本还是迭代器版本。

我们通过将阿瑟·柯南·道尔爵士的《福尔摩斯探案集》的全部内容加载到一个String中,并在内容中查找单词the来进行了一次基准测试。以下是使用for循环和使用迭代器的search版本的基准测试结果:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

迭代器版本稍微快一些!我们不会在这里解释基准测试代码,因为重点不是证明这两个版本是等价的,而是大致了解这两种实现的性能比较。

为了进行更全面的基准测试,你应该使用各种大小的文本作为contents,不同的单词和不同长度的单词作为query,以及其他各种变化。重点是:迭代器虽然是一种高级抽象,但编译后的代码大致与你自己编写低级代码的结果相同。迭代器是Rust的零成本抽象之一,我们的意思是使用这种抽象不会增加任何额外的运行时开销。这类似于C++的原始设计者和实现者Bjarne Stroustrup在“C++的基础”(2012年)中定义的零开销

通常,C++ 实现遵循零开销原则:你不用的,你就不需要为此付费。而且:你所使用的,你无法手动编写得更好。

作为另一个例子,以下代码来自一个音频解码器。解码算法使用线性预测数学运算根据先前样本的线性函数来估计未来的值。这段代码使用迭代器链对作用域内的三个变量进行一些数学运算:一个buffer数据切片,一个包含12个coefficients的数组,以及一个用于在qlp_shift中移动数据的量。我们在示例中声明了这些变量,但没有赋予它们任何值;虽然这段代码在其上下文之外没有太多意义,但它仍然是一个简洁的、现实世界的例子,展示了Rust如何将高层次的概念转化为低层次的代码。

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

为了计算 prediction 的值,此代码遍历 coefficients 中的每个 12 个值,并使用 zip 方法将系数值与 buffer 中的前 12 个值配对。然后,对于每对值,我们将它们相乘,将所有结果相加,并将和右移 qlp_shift 位。

在像音频解码器这样的应用程序中的计算通常最优先考虑性能。在这里,我们创建了一个迭代器,使用了两个适配器,然后消费了值。这段 Rust 代码会编译成什么样的汇编代码?嗯,截至本文撰写时,它编译成与你手工编写的相同汇编代码。根本没有与 coefficients 中的值迭代相对应的循环:Rust 知道有 12 次迭代,所以它“展开”了循环。展开是一种优化,它消除了循环控制代码的开销,而是为循环的每次迭代生成重复的代码。

所有系数都存储在寄存器中,这意味着访问这些值非常快。在运行时对数组访问没有边界检查。所有这些 Rust 能够应用的优化使生成的代码极其高效。现在你了解了这一点,可以放心地使用迭代器和闭包!它们使代码看起来像是更高层次的,但并不会因此而带来运行时性能损失。

摘要

闭包和迭代器是受函数式编程语言思想启发的 Rust 特性。它们有助于 Rust 清晰地表达高层次的想法,同时保持低层次的性能。闭包和迭代器的实现方式不会影响运行时性能。这是 Rust 力求提供零成本抽象目标的一部分。

现在我们已经提高了 I/O 项目的表达能力,让我们来看看 cargo 的一些更多功能,这些功能将帮助我们与世界分享该项目。