Loops 删除Rust循环中的边界检查,以达到最佳编译器输出
在试图确定是否可以/应该使用Rust而不是默认的C/C++时,我正在研究各种边缘情况,主要是考虑到这个问题:在0.1%的情况下,我是否总能获得与gcc一样好的编译器输出(具有适当的优化标志)?答案很可能是否定的,但让我们看看 上有一个相当特殊的示例,它研究无分支排序算法的子例程的编译器输出 以下是基准C代码:Loops 删除Rust循环中的边界检查,以达到最佳编译器输出,loops,rust,bounds-check-elimination,Loops,Rust,Bounds Check Elimination,在试图确定是否可以/应该使用Rust而不是默认的C/C++时,我正在研究各种边缘情况,主要是考虑到这个问题:在0.1%的情况下,我是否总能获得与gcc一样好的编译器输出(具有适当的优化标志)?答案很可能是否定的,但让我们看看 上有一个相当特殊的示例,它研究无分支排序算法的子例程的编译器输出 以下是基准C代码: #包括 #包括 int32_t*foo(int32_t*元素、int32_t*缓冲区、int32_t枢轴) { 大小缓冲区索引=0; 对于(尺寸i=0;i=N); 让元素=&元素[…N];
#包括
#包括
int32_t*foo(int32_t*元素、int32_t*缓冲区、int32_t枢轴)
{
大小缓冲区索引=0;
对于(尺寸i=0;i<64;++i){
缓冲区[buffer_index]=(int32_t)i;
缓冲区索引+=(大小t)(元素[i]<枢轴);
}
}
下面是编译器输出的示例
第一次尝试生锈的情况如下所示:
pub fn foo0(elements: &Vec<i32>, mut buffer: [i32; 64], pivot: i32) -> () {
let mut buffer_index: usize = 0;
for i in 0..buffer.len() {
buffer[buffer_index] = i as i32;
buffer_index += (elements[i] < pivot) as usize;
}
}
pub fn foo0(元素:&Vec,mut buffer:[i32;64],pivot:i32)->(){
让mut buffer_index:usize=0;
对于0..buffer.len()中的i{
buffer[buffer_index]=i作为i32;
缓冲区索引+=(元素[i]
有相当多的边界检查正在进行,请参阅
下一次尝试将消除第一次边界检查:
pub unsafe fn foo1(elements: &Vec<i32>, mut buffer: [i32; 64], pivot: i32) -> () {
let mut buffer_index: usize = 0;
for i in 0..buffer.len() {
unsafe {
buffer[buffer_index] = i as i32;
buffer_index += (elements.get_unchecked(i) < &pivot) as usize;
}
}
}
pub-unsafe-fn-foo1(元素:&Vec,mut-buffer:[i32;64],pivot:i32)->(){
让mut buffer_index:usize=0;
对于0..buffer.len()中的i{
不安全{
buffer[buffer_index]=i作为i32;
缓冲区索引+=(elements.get_unchecked(i)<&pivot)作为usize;
}
}
}
这是一个更好的一点(见相同的godbolt链接如上)
最后,让我们尝试完全删除边界检查:
use std::ptr;
pub unsafe fn foo2(elements: &Vec<i32>, mut buffer: [i32; 64], pivot: i32) -> () {
let mut buffer_index: usize = 0;
unsafe {
for i in 0..buffer.len() {
ptr::replace(&mut buffer[buffer_index], i as i32);
buffer_index += (elements.get_unchecked(i) < &pivot) as usize;
}
}
}
使用std::ptr;
发布不安全的fn foo2(元素:&Vec,mut buffer:[i32;64],pivot:i32)->(){
让mut buffer_index:usize=0;
不安全{
对于0..buffer.len()中的i{
ptr::replace(&mut buffer[buffer_index],i为i32);
缓冲区索引+=(elements.get_unchecked(i)<&pivot)作为usize;
}
}
}
这将产生与foo1
相同的输出,因此ptr::replace
仍然执行边界检查。在这里,那些不安全的操作肯定超出了我的深度。这引出了我的两个问题:
- 如何消除边界检查
- 像这样分析边缘情况有意义吗?或者,如果将整个算法而不是其中的一小部分呈现出来,Rust编译器会看穿这一切吗
关于最后一点,我很好奇,总的来说,锈迹是否可以被切割到与C一样接近金属的程度。经验丰富的铁锈程序员可能会在这条调查路线上畏缩,但这是
- 如何消除边界检查
数组通过对切片的deref强制,也具有相同的性能
pub-unsafe-fn-foo(元素:&Vec,mut-buffer:[i32;64],pivot:i32){
让mut buffer_index:usize=0;
对于0..buffer.len()中的i{
不安全{
*buffer.get_unchecked_mut(buffer_index)=i为i32;
缓冲区索引+=(elements.get_unchecked(i)<&pivot)作为usize;
}
}
}
这可能会产生与使用Clang编译等效C代码所获得的机器代码相同的机器代码
- 像这样分析边缘案例有意义吗?或者,如果将整个算法而不是其中的一小部分呈现出来,Rust编译器会看穿这一切吗
像往常一样,即使您已经在代码的这一部分中发现了瓶颈,也要对这些情况中的任何一种进行基准测试。否则,这是一个过早的优化,总有一天会后悔的。尤其是在生锈的情况下,编写不安全的
代码的决定不应掉以轻心。可以肯定地说,在许多情况下,仅移除边界检查的努力和风险就超过了预期的性能好处
关于最后一点,我很好奇,总的来说,锈迹是否可以被切割到与C一样接近金属的程度
不,你不想这样做有两个主要原因:
<> LI>尽管锈的抽象性强,但不支付你不使用的原则仍然是很有针对性的,与C++类似。看见在边界检查的情况下,这仅仅是语言设计决策的结果,即当编译器无法确保这样的访问是内存安全的时候,总是执行空间检查
无论如何。它可能看起来像文字,接近金属,直到它真的不是
另见:
您可以使用老式的指针算法来实现这一点
常数N:usize=64;
发布fn foo2(元素:&Vec,mut buffer:[i32;N],pivot:i32)->(){
断言!(elements.len()>=N);
让元素=&元素[…N];
让mut buff_ptr=buffer.as_mut_ptr();
对于elements.iter().enumerate()中的(i,&elem){
不安全{
//安全性:我们严格地将ptr提高了不到N倍
*buff_ptr=i作为i32;
如果元素<枢轴{
buff_ptr=buff_ptr.add(1);
}
}
}
}
此版本编译为:
example::foo2:
push rax
cmp qword ptr [rdi + 16], 64
jb .LBB7_4
mov r9, qword ptr [rdi]
lea r8, [r9 + 256]
xor edi, edi
// Loop goes here
.LBB7_2:
mov ecx, dword ptr [r9 + 4*rdi]
mov dword ptr [rsi], edi
lea rax, [rsi + 4]
cmp ecx, edx
cmovge rax, rsi
mov ecx, dword ptr [r9 + 4*rdi + 4]
lea esi, [rdi + 1]
mov dword ptr [rax], esi
lea rsi, [rax + 4]
cmp ecx, edx
cmovge rsi, rax
mov eax, dword ptr [r9 + 4*rdi + 8]
lea ecx, [rdi + 2]
mov dword ptr [rsi], ecx
lea rcx, [rsi + 4]
cmp eax, edx
cmovge rcx, rsi
mov r10d, dword ptr [r9 + 4*rdi + 12]
lea esi, [rdi + 3]
lea rax, [r9 + 4*rdi + 16]
add rdi, 4
mov dword ptr [rcx], esi
lea rsi, [rcx + 4]
cmp r10d, edx
cmovge rsi, rcx
// Conditional branch to the loop beginning
cmp rax, r8
jne .LBB7_2
pop rax
ret
.LBB7_4:
call std::panicking::begin_panic
ud2
如您所见,循环是展开的,单个分支是循环迭代跳跃
然而,我感到惊讶的是,这个函数并没有因为没有效果而被消除:它应该被编译成简单的noop。很可能,在内联之后会这样做
另外,我想说,将参数更改为&mut不会更改代码:
example::foo2:
push rax
cmp qword ptr [rdi + 16], 64
jb .LBB7_4
mov r9, qword ptr [rdi]
lea r8, [r9 + 256]
xor edi, edi
.LBB7_2:
mov ecx, dword ptr [r9 + 4*rdi]
mov dword ptr [rsi], edi
lea rax, [rsi + 4]
cmp ecx, edx
cmovge rax, rsi
mov ecx, dword ptr [r9 + 4*rdi + 4]
lea esi, [rdi + 1]
mov dword ptr [rax], esi
lea rsi, [rax + 4]
cmp ecx, edx
cmovge rsi, rax
mov eax, dword ptr [r9 + 4*rdi + 8]
lea ecx, [rdi + 2]
mov dword ptr [rsi], ecx
lea rcx, [rsi + 4]
cmp eax, edx
cmovge rcx, rsi
mov r10d, dword ptr [r9 + 4*rdi + 12]
lea esi, [rdi + 3]
lea rax, [r9 + 4*rdi + 16]
add rdi, 4
mov dword ptr [rcx], esi
lea rsi, [rcx + 4]
cmp r10d, edx
cmovge rsi, rcx
cmp rax, r8
jne .LBB7_2
pop rax
ret
.LBB7_4:
call std::panicking::begin_panic
ud2
因此,不幸的是,rustc可能会发出函数接受缓冲区参数作为LLVM IR中的指针的消息。我当然不想暗示,或者让任何读过这篇文章的人暗示,Rust比lang X慢/快/好/坏
example::foo2:
push rax
cmp qword ptr [rdi + 16], 64
jb .LBB7_4
mov r9, qword ptr [rdi]
lea r8, [r9 + 256]
xor edi, edi
.LBB7_2:
mov ecx, dword ptr [r9 + 4*rdi]
mov dword ptr [rsi], edi
lea rax, [rsi + 4]
cmp ecx, edx
cmovge rax, rsi
mov ecx, dword ptr [r9 + 4*rdi + 4]
lea esi, [rdi + 1]
mov dword ptr [rax], esi
lea rsi, [rax + 4]
cmp ecx, edx
cmovge rsi, rax
mov eax, dword ptr [r9 + 4*rdi + 8]
lea ecx, [rdi + 2]
mov dword ptr [rsi], ecx
lea rcx, [rsi + 4]
cmp eax, edx
cmovge rcx, rsi
mov r10d, dword ptr [r9 + 4*rdi + 12]
lea esi, [rdi + 3]
lea rax, [r9 + 4*rdi + 16]
add rdi, 4
mov dword ptr [rcx], esi
lea rsi, [rcx + 4]
cmp r10d, edx
cmovge rsi, rcx
cmp rax, r8
jne .LBB7_2
pop rax
ret
.LBB7_4:
call std::panicking::begin_panic
ud2