240以上の要素を持つ配列をループするときにパフォーマンスに大きな影響があるのはなぜですか?


230

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());
}


4
たぶん240であなたはCPUキャッシュラインをオーバーフローしていますか?その場合、結果はCPU固有のものになります。
ロドリゴ

11
ここで再現。今、それはループのアンロールと関係があると思います。
ロドリゴ

回答:


355

まとめ:240未満では、LLVMは内部ループを完全に展開します。これにより、繰り返しループを最適化してベンチマークを破ることができることがわかります。



LLVMが特定の最適化の実行を停止する魔法のしきい値を見つけました。しきい値は8バイト* 240 = 1920バイトです(配列はusizesの配列なので、長さは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番目の例でこれを変更しても、放出されるアセンブリに顕著な違いはありません。パフォーマンスに悪影響を与えることが測定されない限り、短いバージョンと慣用的なバージョンを使用する必要があります。


2
@ lukas-kalbertodt素晴らしい答えをありがとう!sumこれで、ローカルでsはなく直接更新された元のコードの実行速度が大幅に低下した理由も理解できました。for i in 0..arr.len() { sum += arr[i]; }
ガイコーランド

4
@LukasKalbertodt LLVMでAVX2 をオンにして何か他のことが起こっていても、それほど大きな違いはないはずです。サビでも
再現

4
@Mgetz面白い!しかし、完全に展開されたループ内の命令の数を最終的に決定するので、そのしきい値を使用可能なSIMD命令に依存させるのは、私にはあまりにも奇妙に聞こえません。しかし残念ながら、私は確かに言うことはできません。LLVMの開発者にこれに答えてもらえるとうれしいです。
Lukas Kalbertodt

7
コンパイラまたはLLVMは、計算全体がコンパイル時に実行できることを認識しないのはなぜですか?ループの結果がハードコーディングされることを期待していました。それともInstantそれを防ぐための使用ですか?
非創造的な名前

4
@JosephGarvin:完全に展開すると、後の最適化パスでそれを確認できるためと考えられます。最適化コンパイラーは、効率的なasmを作成するだけでなく、迅速にコンパイルすることを引き続き考慮しているため、複雑なループを含む厄介なソースコードをコンパイルするのに数時間/日もかからないように、分析の最悪の場合の複雑さを制限する必要があります。 。しかし、はい、これは明らかにサイズ> = 240の最適化を見逃しています。ループ内のループを最適化しないことは、単純なベンチマークを壊さないようにするための意図的なものなのでしょうか。おそらくそうではないかもしれませんが、そうかもしれません。
Peter Cordes

30

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

3
または、もっと良いの(0..CAPACITY).sum::<usize>() * IN_LOOPSは、同じ結果が得られることです。
Chris Morgan

11
アセンブリが実際に計算を行っていないことを実際に説明しますが、この場合、LLVMが答えを事前に計算しました。
Josep

rustcこの筋力低下をする機会を逃していることに、ちょっと驚いています。ただし、この特定のコンテキストでは、これはタイミングループのように見えるため、意図的に最適化されないようにする必要があります。重要なのは、その回数だけ最初から計算を繰り返し、繰り返し回数で除算することです。Cでは、その(非公式の)イディオムは、ループカウンターをとして宣言することですvolatile(例:LinuxカーネルのBogoMIPSカウンター)。Rustでこれを達成する方法はありますか?あるかもしれませんが、私はそれを知りません。外部を呼び出すとfn役立つ場合があります。
Davislor

1
@Davislor:volatileそのメモリを強制的に同期させます。ループカウンターに適用すると、ループカウンター値の実際のリロード/ストアのみが強制されます。ループ本体には直接影響しません。これが、通常、実際の重要な結果をvolatile int sinkループの後に(ループキャリーの依存関係がある場合)または反復ごとに割り当てることにより、コンパイラーがループカウンターを最適化するように最適化して強制することである理由です。必要な結果をレジスターに具体化してそれを格納できるようにする。
Peter Cordes

1
@Davislor:Rustには、GNU Cのようなインラインasm構文があると思います。インラインasmを使用すると、コンパイラーに、値を強制的に格納せずに、レジスター内の値を具体化せることができます。各ループ反復の結果にそれを使用すると、最適化が中止されるのを防ぐことができます。(ただし、注意していない場合は自動ベクトル化からも取得できます)。たとえば、MSVCの「Escape」と「Clobber」に相当するものは、 2つのマクロ(実際には不可能であるMSVCに移植する方法を尋ねている間)を説明し、Chandler Carruthの講演にリンクして、その使用法を示しています。
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.