性能比较:循环 vs 迭代器

为了确定是使用循环还是迭代器,你需要知道哪种实现更快:带有显式 for 循环的 search 函数版本,还是使用迭代器的版本。

我们运行了一个基准测试,将阿瑟·柯南·道尔爵士的《福尔摩斯探案集》的全文加载到一个 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 的更多功能,这些功能将帮助我们与世界分享这个项目。