まとめ:240未満では、LLVMは内部ループを完全に展開します。これにより、繰り返しループを最適化してベンチマークを破ることができることがわかります。
LLVMが特定の最適化の実行を停止する魔法のしきい値を見つけました。しきい値は8バイト* 240 = 1920バイトです(配列はusize
sの配列なので、長さは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命令であり、複数の値を同時に合計できます。さらに、2つの16バイトSIMDレジスタ(xmm0
およびxmm1
)が並列に使用されるため、CPUの命令レベルの並列処理では、基本的にこれらの2つの命令を同時に実行できます。結局のところ、それらは互いに独立しています。最後に、両方のレジスタが加算され、水平方向に合計されてスカラー結果になります。
最新の主流x86 CPU(低電力のAtomではない)は、L1dキャッシュにヒットしたときに実際に1クロックあたり2つのベクトルロードを実行でき、paddq
スループットも少なくとも2クロックで、ほとんどのCPUでは1サイクルのレイテンシです。代わりに、(ドット積のFP FMAの)レイテンシとスループットのボトルネックを隠すための複数のアキュムレータに関するhttps://agner.org/optimize/およびこのQ&Aを参照してください。
LLVMは、一部の小さなループを完全にアンロールしない場合にアンロールし、それでも複数のアキュムレータを使用します。したがって、通常、フロントエンドの帯域幅とバックエンドのレイテンシのボトルネックは、完全に展開しなくても、LLVMで生成されたループにとって大きな問題ではありません。
ただし、ループのアンロールは、80倍のパフォーマンスの違いには関与しません。少なくとも、ループのアンロールのみではループしません。1つのループを別のループ内に配置する実際のベンチマークコードを見てみましょう。
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
は正常に見えます。2つのネストされたループです。(関数の最初には、初期化のためのコードがいくつかありますが、無視します。)ただし、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()
は、配列のすべての要素を合計するためのより良い方法です。そして、2番目の例でこれを変更しても、放出されるアセンブリに顕著な違いはありません。パフォーマンスに悪影響を与えることが測定されない限り、短いバージョンと慣用的なバージョンを使用する必要があります。