web-dev-qa-db-ja.com

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

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

Summary: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キャッシュでヒットすると、クロックあたり2つのベクトルロードを実行できます。また、paddqスループットもクロックあたり少なくとも2で、ほとんどのCPUで1サイクルのレイテンシがあります。 https://agner.org/optimize/ および このQ&A を参照して、レイテンシーを隠すための複数のアキュムレーターについて(FP代わりにスループットのボトルネック。

LLVMは、someが完全に展開されていないsomeときに小さなループを展開し、引き続き複数のアキュムレーターを使用します。したがって、通常、フロントエンドの帯域幅とバックエンドのレイテンシのボトルネックは、完全に展開しなくても、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 code:arr.iter().sum()についての最後の注意として、配列のすべての要素を合計するより良い方法です。2番目の例でこれを変更しても、放出されるアセンブリの顕著な違い:パフォーマンスを損なうと測定しない限り、短くて慣用的なバージョンを使用する必要があります。

347

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
29
mja