13.10 性能对比:循环 vs. 迭代器
13.10.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能(本文)
13.10.1. 一个测试
为了运行基准测试,将阿瑟·柯南·道尔爵士所著的《夏洛克·福尔摩斯历险记》的全部内容加载到一个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)
迭代器版本稍微快一些!
我们不会在这里解释基准测试代码,因为重点不是证明这两个版本是等效的,而是为了大致了解这两个实现在性能方面的比较。
迭代器是Rust中的一种高层次抽象,它在编译后生成的代码几乎和我们手写的底层代码一样。这叫做零开销抽象(Zero-Cost Abstraction)。
13.10.2. 零开销抽象(Zero-Cost Abstraction)
零开销抽象意味着使用抽象不会引入额外的运行时开销。
Rust的迭代器能实现零开销抽象是因为:
1. 泛型与单态化
Rust的迭代器大量使用泛型来定义操作,比如Iterator trait。编译器在编译期间会对每个具体的类型实例化泛型代码,生成专门针对这些类型的高效机器代码,这个过程叫单态化(monomorphization)。
-
静态分发:编译器根据具体的类型生成直接调用函数的代码,不需要在运行时查找函数地址,这与通过虚表进行的动态分发不同。
-
优化机会:由于类型在编译期是已知的,编译器可以对代码进行深入优化,比如消除函数调用的开销以及内联。
2. 内联与LLVM优化
Rust使用LLVM作为其后端编译器。编译器可以通过以下方式优化迭代器链:
-
函数内联:Rust编译器会内联迭代器中的操作(如
map、filter等),将这些方法展开为一段紧凑的代码,而不会有函数调用开销。 -
循环展开与合并:多个迭代器方法的调用(如
map().filter().collect())在编译时会被合并为单个循环。 -
冗余消除:例如,对于一些多余的中间变量或操作,编译器会直接去掉。
结果是,迭代器链的最终执行代码效率几乎与手写的循环相当。
3. 惰性求值
Rust的迭代器是惰性的,这意味着:
-
在调用终结方法(如
collect()或for_each())之前,迭代器不会执行任何实际操作。 -
每个中间操作(如
map和filter)仅创建一个新迭代器,并不会立即应用操作。
这种惰性设计允许编译器在最终使用迭代器时直接生成针对具体场景优化的代码,而不会引入不必要的中间数据结构或计算。
4. 无运行时开销
Rust的设计原则之一是避免运行时成本。迭代器的实现避免了动态分配和运行时多态:
-
Rust迭代器是基于静态类型的,通常无需堆分配(除非显式使用
Box或dyn Iterator)。 -
Iteratortrait使用静态分发,避免了动态分发。即使需要动态分发,也必须显式声明为dyn Iterator。
5. 没有额外的抽象成本
Rust迭代器通过直接对底层数据结构的操作提供功能,而不会引入额外的抽象层。比如:
-
调用
.iter()生成的迭代器直接操作底层切片或集合,开销极低。 -
中间迭代器(如
Map、Filter)在编译时会被优化成一段紧凑的指令,而不会引入多余的封装。
13.10.3. 一个例子:音频解码程序
以下代码取自一个音频解码器。解码算法使用线性预测数学运算,根据先前样本的线性函数来估计未来值。这段代码使用迭代器链,对三个变量执行若干数学运算:数据的buffer切片、包含12个元素的coefficients数组,以及qlp_shift中的数据移位量。我们在这个例子中声明了变量,但没有给它们赋值。尽管这段代码在其原始上下文之外没有太多意义,但它仍然是Rust如何将高级思想转化为低级代码的一个简洁、真实的示例。
#![allow(unused)]
fn main() {
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能够应用的所有这些优化使生成的代码极其高效。现在你知道了这一点,就可以毫无顾虑地使用迭代器和闭包了!它们使代码看起来更高级,同时又不会造成运行时性能损失。