【Rust自学】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的迭代器大量使用泛型(generics)来定义操作,比如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
)。 -
Iterator
trait使用静态分发,避免了动态分发(即使需要动态分发,也需显式声明为dyn Iterator
)。
5. 没有额外的抽象成本
Rust迭代器通过直接对底层数据结构的操作提供功能,而不会引入额外的抽象层。比如:
-
调用
.iter()
生成的迭代器直接操作底层切片或集合,开销极低。 -
中间迭代器(如Map、Filter)在编译时会被优化成一段紧凑的指令,而不会引入多余的封装。
13.10.3. 一个例子:音频解码程序
以下代码取自音频解码器。这 解码算法使用线性预测数学运算 根据先前样本的线性函数估计未来值。这 代码使用迭代器链对范围内的三个变量进行一些数学运算: 数据的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 能够应用的所有这些优化使生成的代码极其高效。现在您知道了这一点,您可以毫无恐惧地使用迭代器和闭包!它们使代码看起来更高级别,但不会因此而造成运行时性能损失。