[英]Why is there a large performance impact when looping over an array with 240 or more elements?
當在Rust中的數組上運行求和循環時,我注意到當CAPACITY
> = 240時性能下降很大CAPACITY
= 239大約快80倍。
是否有特殊的編譯優化Rust正在為“短”數組做什么?
用rustc -C opt-level=3
編譯。
use std::time::Instant;
const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;
fn main() {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
let now = Instant::now();
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
println!("sum:{} time:{:?}", sum, now.elapsed());
}
總結 :在240以下,LLVM完全展開內循環,讓它注意到它可以優化重復循環,打破你的基准。
您找到了一個魔術閾值,高於該閾值LLVM停止執行某些優化 。 閾值是8字節* 240 = 1920字節(您的數組是usize
的數組,因此假設x86-64 CPU,長度乘以8字節)。 在這個基准測試中,一個特定的優化 - 僅對長度239執行 - 是造成巨大速度差異的原因。 但讓我們慢慢開始:
(此答案中的所有代碼都是使用-C opt-level=3
編譯的)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
這個簡單的代碼將大致產生一個人們期望的組件:一個循環添加元素。 但是,如果將240
更改為239
,則發出的組件會有很大差異。 在Godbolt Compiler Explorer上看到它 。 這是裝配的一小部分:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
這就是所謂的循環展開 :LLVM粘貼循環體一堆時間,以避免必須執行所有那些“循環管理指令”,即遞增循環變量,檢查循環是否已經結束以及跳轉到循環的開始。
如果你想知道: paddq
和類似的指令是SIMD指令,它允許並行地匯總多個值。 此外,並行使用兩個16字節SIMD寄存器( xmm0
和xmm1
),以便CPU的指令級並行性基本上可以同時執行這些指令中的兩個。 畢竟,他們是彼此獨立的。 最后,將兩個寄存器相加,然后水平求和到標量結果。
現代主流x86 CPU(不是低功耗Atom)在L1d緩存中實際上每個時鍾可以執行2個向量加載,並且paddq
吞吐量每個時鍾至少2個,在大多數CPU上有1個周期延遲。 請參閱https://agner.org/optimize/以及有關多個累加器的問答,以隱藏延遲(針對點積的FP FMA)和吞吐量的瓶頸。
LLVM在未完全展開時會展開一些小循環,並且仍使用多個累加器。 通常,即使沒有完全展開,前端帶寬和后端延遲瓶頸對於LLVM生成的循環也不是一個大問題。
但是,循環展開不對因子80的性能差異負責! 至少不是單獨循環展開。 讓我們看看實際的基准測試代碼,它將一個循環放在另一個循環中:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( 在Godbolt Compiler Explorer上 )
CAPACITY = 240
的程序集看起來很正常:兩個嵌套循環。 (在函數的開頭有一些代碼只是用於初始化,我們將忽略它。)然而,對於239,它看起來非常不同! 我們看到初始化循環和內循環已展開:到目前為止如此預期。
重要的區別是,對於239,LLVM能夠弄清楚內部循環的結果不依賴於外部循環! 因此,LLVM發出的代碼基本上首先只執行內部循環(計算總和),然后通過多次加sum
模擬外部循環!
首先,我們看到幾乎與上面相同的程序集(代表內部循環的程序集)。 之后我們看到了這個(我評論說明了匯編;帶*
的評論特別重要):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
正如你在這里看到的那樣,內循環的結果被采用,就像外循環運行然后返回一樣。 LLVM只能執行此優化,因為它了解內部循環獨立於外部循環。
這意味着運行時從CAPACITY * IN_LOOPS
更改為CAPACITY + IN_LOOPS
。 這是造成巨大性能差異的原因。
另外一個注意事項:你能對此做些什么嗎? 並不是的。 LLVM必須具有這樣的魔術閾值,如果沒有它們,LLVM優化可能需要永遠完成某些代碼。 但我們也同意這段代碼非常人為。 在實踐中,我懷疑會發生如此巨大的差異。 在這些情況下,由於完整循環展開的差異通常不是因子2。 所以不必擔心真實的用例。
作為關於慣用Rust代碼的最后一個注釋: arr.iter().sum()
是一種更好的方法來總結數組的所有元素。 在第二個示例中更改此設置不會導致發出的組件有任何顯着差異。 您應該使用簡短版本和慣用版本,除非您測量它會損害性能。
除了Lukas的回答,如果你想使用迭代器,試試這個:
const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;
pub fn bar() -> usize {
(0..CAPACITY).sum::<usize>() * IN_LOOPS
}
感謝@Chris Morgan關於范圍模式的建議。
優化的組裝非常好:
example::bar:
movabs rax, 14340000000
ret
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.