为什么在包含范围内的迭代会在 Rust 中生成更长的程序集?
Why does iteration over an inclusive range generate longer assembly in Rust?
这两个循环在 C++ 和 Rust 中是等价的:
#include <cstdint>
uint64_t sum1(uint64_t n) {
uint64_t sum = 0;
for (uint64_t j = 0; j <= n; ++j) {
sum += 1;
}
return sum;
}
pub fn sum1(num: u64) -> u64 {
let mut sum: u64 = 0;
for j in 0u64..=num {
sum += 1;
}
return sum;
}
但是 C++ 版本生成了一个非常简洁的程序集:
sum1(unsigned long): # @sum1(unsigned long)
xor eax, eax
.LBB0_1: # =>This Inner Loop Header: Depth=1
add rax, 1
cmp rax, rdi
jbe .LBB0_1
ret
而 Rust 的版本很长,循环中有两个检查而不是一个:
example::sum1:
xor eax, eax
xor ecx, ecx
.LBB0_1:
mov rdx, rcx
cmp rcx, rdi
adc rcx, 0
add rax, 1
cmp rdx, rdi
jae .LBB0_3
cmp rcx, rdi
jbe .LBB0_1
.LBB0_3:
ret
神马:https://godbolt.org/z/xYW94qxjK
Rust 本质上试图阻止 C++ 无忧无虑的是什么?
迭代器状态溢出。
如果输入足够大,C++ 版本将永远循环:
#include <cstdint>
[[gnu::noinline]]
std::uint64_t sum1(std::uint64_t n) {
std::uint64_t sum = 0;
for (std::uint64_t j = 0; j <= n; ++j) {
sum += 1;
}
return sum;
}
#include <iostream>
int main() {
std::cout << sum1(UINT64_C(0xffffffff'ffffffff)) << std::endl;
return 0;
}
这是因为当循环计数器 j
最终达到 0xffffffff'ffffffff
时,递增它会返回到 0,这意味着循环不变量 j <= n
始终满足并且循环永远不会退出.
严格来说,用 0xffffffff'ffffffff
infamously triggers undefined behaviour, though not because of overflow itself, but since infinite loops without externally-visible side effects are UB ([intro.progress]/1 调用 sum1
)。这就是为什么我将 [[gnu::noinline]]
属性应用于函数以防止编译器在优化过程中利用它的“优势”。
另一方面,Rust 版本不仅定义完美,而且迭代次数与范围的基数完全一样:
use std::num::Wrapping;
fn sum1(num: u64) -> u64 {
let mut sum = Wrapping(0);
for _ in 0..=num {
sum += Wrapping(1);
}
return sum.0;
}
fn main() {
println!("{}", sum1(0xffffffff_ffffffff));
}
上面的程序(稍作修改以避免陷入调试与发布模式在求和方面的差异)将在恰好 18446744073709551616 次迭代后终止并打印 0; Rust 版本仔细维护迭代器状态,以便迭代器中不会发生溢出。
These two loops are equivalent in C++ and Rust
您的两个代码片段不具有相同的行为。 for (uint64_t j = 0; j <= n; ++j)
不处理 n == uint64_t::MAX
(使其无限循环)而 for j in 0u64..=num
处理(永远不会进入无限循环)。
Rust 等效代码可以是:
pub fn sum1(num: u64) -> u64 {
let mut sum: u64 = 0;
let mut j = 0;
while j <= num {
sum = sum.wrapping_add(1);
j = j.wrapping_add(1);
}
sum
}
当前生成以下 asm godbolt:
example::sum1:
xor eax, eax
.LBB0_1:
add rax, 1
cmp rax, rdi
jbe .LBB0_1
ret
正确识别了差异:Rust 中的溢出检查,而不是 C++ 中的溢出检查。
不过,问题仍然是为什么在 Rust 版本中每个循环有 2 次检查而不是 1 次,答案是 LLVM 不够智能 and/or RangeInclusive
设计不能很好地适应 LLVM1.
短 循环的最佳代码生成是to split the loop,转换:
for j in 0u64..=num {
sum += 1;
}
进入:
for j in 0u64..num { // equivalent to for (auto j = 0; j < num; ++j)
sum += 1;
}
if 0 <= num {
sum += 1;
}
这将导致主循环中只有一个分支,并允许 LLVM 将其切换为封闭公式2.
循环拆分优化适用于 RangeInclusive
和大多数其他 Chain
迭代器,因为实际上 RangeInclusive
可以被认为是一次迭代器和半独占范围的链迭代器(以任一顺序)。 并非总是赢家:与内联一样,它意味着重复循环体的代码,如果循环体的代码很大,可能会导致代码大小的显着开销。
不幸的是,LLVM 无法拆分循环。我不确定这是因为优化完全缺失,还是由于某种原因未能在此处应用。我很期待 rustc_codegen_gcc
后端,因为 GCC 7 向 GCC 添加了循环拆分,它可能能够在那里生成更好的代码。
1 参见 this comment 我遗留了 RangeInclusive
的性能问题。我在 2019 年花了很多时间思考这个问题,我非常希望 RangeInclusive
(以及所有范围)不是 Iterator
;这是一个代价高昂的错误。
2 链迭代器,一般来说,使用 .for_each(...)
, specifically because there the loop is (manually) split. See the playground 的性能要好得多,因为 (0..=num).for_each(|_| sum += 1)
减少到 num + 1
.
这两个循环在 C++ 和 Rust 中是等价的:
#include <cstdint>
uint64_t sum1(uint64_t n) {
uint64_t sum = 0;
for (uint64_t j = 0; j <= n; ++j) {
sum += 1;
}
return sum;
}
pub fn sum1(num: u64) -> u64 {
let mut sum: u64 = 0;
for j in 0u64..=num {
sum += 1;
}
return sum;
}
但是 C++ 版本生成了一个非常简洁的程序集:
sum1(unsigned long): # @sum1(unsigned long)
xor eax, eax
.LBB0_1: # =>This Inner Loop Header: Depth=1
add rax, 1
cmp rax, rdi
jbe .LBB0_1
ret
而 Rust 的版本很长,循环中有两个检查而不是一个:
example::sum1:
xor eax, eax
xor ecx, ecx
.LBB0_1:
mov rdx, rcx
cmp rcx, rdi
adc rcx, 0
add rax, 1
cmp rdx, rdi
jae .LBB0_3
cmp rcx, rdi
jbe .LBB0_1
.LBB0_3:
ret
神马:https://godbolt.org/z/xYW94qxjK
Rust 本质上试图阻止 C++ 无忧无虑的是什么?
迭代器状态溢出。
如果输入足够大,C++ 版本将永远循环:
#include <cstdint>
[[gnu::noinline]]
std::uint64_t sum1(std::uint64_t n) {
std::uint64_t sum = 0;
for (std::uint64_t j = 0; j <= n; ++j) {
sum += 1;
}
return sum;
}
#include <iostream>
int main() {
std::cout << sum1(UINT64_C(0xffffffff'ffffffff)) << std::endl;
return 0;
}
这是因为当循环计数器 j
最终达到 0xffffffff'ffffffff
时,递增它会返回到 0,这意味着循环不变量 j <= n
始终满足并且循环永远不会退出.
严格来说,用 0xffffffff'ffffffff
infamously triggers undefined behaviour, though not because of overflow itself, but since infinite loops without externally-visible side effects are UB ([intro.progress]/1 调用 sum1
)。这就是为什么我将 [[gnu::noinline]]
属性应用于函数以防止编译器在优化过程中利用它的“优势”。
另一方面,Rust 版本不仅定义完美,而且迭代次数与范围的基数完全一样:
use std::num::Wrapping;
fn sum1(num: u64) -> u64 {
let mut sum = Wrapping(0);
for _ in 0..=num {
sum += Wrapping(1);
}
return sum.0;
}
fn main() {
println!("{}", sum1(0xffffffff_ffffffff));
}
上面的程序(稍作修改以避免陷入调试与发布模式在求和方面的差异)将在恰好 18446744073709551616 次迭代后终止并打印 0; Rust 版本仔细维护迭代器状态,以便迭代器中不会发生溢出。
These two loops are equivalent in C++ and Rust
您的两个代码片段不具有相同的行为。 for (uint64_t j = 0; j <= n; ++j)
不处理 n == uint64_t::MAX
(使其无限循环)而 for j in 0u64..=num
处理(永远不会进入无限循环)。
Rust 等效代码可以是:
pub fn sum1(num: u64) -> u64 {
let mut sum: u64 = 0;
let mut j = 0;
while j <= num {
sum = sum.wrapping_add(1);
j = j.wrapping_add(1);
}
sum
}
当前生成以下 asm godbolt:
example::sum1:
xor eax, eax
.LBB0_1:
add rax, 1
cmp rax, rdi
jbe .LBB0_1
ret
不过,问题仍然是为什么在 Rust 版本中每个循环有 2 次检查而不是 1 次,答案是 LLVM 不够智能 and/or RangeInclusive
设计不能很好地适应 LLVM1.
短 循环的最佳代码生成是to split the loop,转换:
for j in 0u64..=num {
sum += 1;
}
进入:
for j in 0u64..num { // equivalent to for (auto j = 0; j < num; ++j)
sum += 1;
}
if 0 <= num {
sum += 1;
}
这将导致主循环中只有一个分支,并允许 LLVM 将其切换为封闭公式2.
循环拆分优化适用于 RangeInclusive
和大多数其他 Chain
迭代器,因为实际上 RangeInclusive
可以被认为是一次迭代器和半独占范围的链迭代器(以任一顺序)。 并非总是赢家:与内联一样,它意味着重复循环体的代码,如果循环体的代码很大,可能会导致代码大小的显着开销。
不幸的是,LLVM 无法拆分循环。我不确定这是因为优化完全缺失,还是由于某种原因未能在此处应用。我很期待 rustc_codegen_gcc
后端,因为 GCC 7 向 GCC 添加了循环拆分,它可能能够在那里生成更好的代码。
1 参见 this comment 我遗留了 RangeInclusive
的性能问题。我在 2019 年花了很多时间思考这个问题,我非常希望 RangeInclusive
(以及所有范围)不是 Iterator
;这是一个代价高昂的错误。
2 链迭代器,一般来说,使用 .for_each(...)
, specifically because there the loop is (manually) split. See the playground 的性能要好得多,因为 (0..=num).for_each(|_| sum += 1)
减少到 num + 1
.