我正在Rust中编写一个线性代数库。
我有一个函数来获取对给定行和列的矩阵单元格的引用。该函数从一对断言开始,即行和列在边界内:
#[inline(always)]
pub fn get(&self, row: usize, col: usize) -> &T {
assert!(col < self.num_cols.as_nat());
assert!(row < self.num_rows.as_nat());
unsafe {
self.get_unchecked(row, col)
}
}
在紧循环中,我认为跳过边界检查可能会更快,所以我提供了一个get_unchecked
方法:
#[inline(always)]
pub unsafe fn get_unchecked(&self, row: usize, col: usize) -> &T {
self.data.get_unchecked(self.row_col_index(row, col))
}
奇怪的是,当我使用这些方法来实现矩阵乘法(通过行和列迭代器)时,我的基准测试显示,当我检查边界时,它实际上快了33%。为什么会发生这种情况?
我在两台不同的计算机上尝试过,一台运行Linux,另一台运行OSX,两者都显示出了效果。
完整的代码在github上。相关文件是lib.rs。感兴趣的函数有:
get
第68行- 第81行
get_unchecked
- 551线上的
next
- 796线上的
mul
- 1038线
matrix_mul
(基准)
请注意,我使用类型级别的数字来参数化矩阵(也可以通过伪标记类型选择动态大小),因此基准是乘以两个100x100矩阵。
更新:
我已经大大简化了代码,删除了基准测试中没有直接使用的东西,并删除了通用参数。我还编写了一个不使用迭代器的乘法实现,而版本不会产生同样的效果。有关此版本的代码,请参见此处。克隆minimal-performance
分支并运行cargo bench
将对乘法的两种不同实现进行基准测试(注意,断言从该分支开始就被注释掉了)。
还需要注意的是,如果我更改get*
函数以返回数据副本而不是引用(f64
而不是&f64
),效果就会消失(但代码总体上稍微慢一些)。
这不是一个完整的答案,因为我还没有测试我的声明,但这可能会解释它。无论哪种方式,唯一确定的方法就是生成LLVM IR和汇编程序输出。如果您需要LLVM IR的手册,您可以在这里找到:http://llvm.org/docs/LangRef.html。
不管怎样,这已经够了。假设你有这个代码:
#[inline(always)]
pub unsafe fn get_unchecked(&self, row: usize, col: usize) -> &T {
self.data.get_unchecked(self.row_col_index(row, col))
}
这里的编译器将其更改为间接加载,可能会在紧密循环中进行优化。值得注意的是,每次加载都有出错的可能性:如果你的数据不可用,就会触发越界。
在边界检查与紧密循环相结合的情况下,LLVM会起到一些小作用。由于负载处于紧循环(矩阵乘法)中,并且边界检查的结果取决于循环的边界,因此它将从循环中删除边界检查,并将其放置在循环的周围。换句话说,循环本身将保持完全相同,但有一个额外的边界检查。
换句话说,代码完全相同,只是有一些细微的差异。
那么发生了什么变化呢?两件事:
如果我们有额外的边界检查,就不可能再出现越界加载。这可能会触发以前不可能的优化。不过,考虑到这些检查通常是如何实现的,这不是我的猜测。
另一件需要考虑的事情是,"不安全"这个词可能会触发一些行为,比如附加条件、pin数据或禁用GC等。我不确定Rust中的确切行为;找出这些细节的唯一方法是查看LLVM IR。