web-dev-qa-db-ja.com

forループ本体で1つの基本的な算術演算が2つよりも遅い算術演算で実行されるのはなぜですか?

算術演算の実行時間を測定する実験をしているときに、非常に奇妙な動作に遭遇しました。ループ本体に1つの算術演算を含むforループを含むコードブロックは、alwaysが同じコードブロックよりも低速で実行されましたが、 forループ本体での2つの算術演算。これが私がテストしたコードです:

#include <iostream>
#include <chrono>

#define NUM_ITERATIONS 100000000

int main()
{
    // Block 1: one operation in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    // Block 2: two operations in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    return 0;
}

これをさまざまなレベルのコード最適化(-O0-O1-O2-O3)、さまざまなオンラインコンパイラー(例: onlinegdb.com )を使用して、私の作業マシン、hame PCとラップトップ、RaspberryPiと同僚のコンピューターで。これら2つのコードブロックを再配置し、繰り返し、定数を変更し、操作を変更しました(+-<<=など)、整数型を変更。しかし、私はいつも同じような結果を得ました。ループ内の1行のブロックは、2行のブロックよりも[〜#〜]遅い[〜#〜]です。

1.05681秒。 x、y = 3100000000、0
0.90414秒。 x、y = 1700000000、-3700000000

https://godbolt.org/ でアセンブリの出力を確認しましたが、すべてが期待したように見えました。2番目のブロックには、アセンブリの出力でもう1つ操作がありました。

3つの操作は常に期待どおりに動作しました:oneより遅く、より高速ですfourより。では、なぜtwo演算がそのような異常を引き起こすのでしょうか?

編集:

繰り返しますが、コードが最適化されていないすべてのWindowsおよびUnixマシンでこのような動作をしています。実行するアセンブリ(Visual Studio、Windows)を見て、そこでテストしたい命令を見つけました。とにかく、ループが最適化されていれば、残ったコードには何も質問されません。 「最適化されていないコードを測定しない」という回答を回避するために、質問に最適化の通知を追加しました。問題は、私のコンピューターが2つの操作を1つよりも速く実行する理由です。まず、これらの操作が最適化されていないコードで実行されます。実行時間の違いは私のテストで5-25%です(かなり目立ちます)。

15
Oliort

@PeterCordesは、多くの仮定でこの答えを間違っていると証明しましたが、問題の盲目的な調査の試みとしては依然として役立つ可能性があります。

いくつかのクイックベンチマークを設定しました。コードメモリのアライメントに何らかの形で関連しているのではないかと考え、本当にクレイジーな考えでした。

しかし、@ Adrian McCarthyは動的周波数スケーリングでそれを正しく理解しているようです。

とにかくベンチマークは、いくつかのNOPを挿入すると問題が解決する可能性があることを示しています。ブロック1のx + = 31の後に15のNOPを指定すると、ブロック2とほぼ同じパフォーマンスになります。

http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA

私はまた、-OFastを試してみましたが、コンパイラはそのようなNOPを挿入するコードメモリを捨てるほど賢いかもしれませんが、そうではないようです。 http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX

Edit:@PeterCordesのおかげで、最適化が上記のベンチマークで期待どおりに機能しないことが明らかになりました(グローバル変数がメモリにアクセスするための命令を追加する必要があるため) 、新しいベンチマーク http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE は、ブロック1とブロック2のパフォーマンスがスタック変数で等しいことを明確に示しています。ただし、NOPは、グローバル変数にアクセスするループを備えたシングルスレッドアプリケーションで引き続き役立つ可能性があります。この場合はおそらく使用しないで、ループの後でグローバル変数をローカル変数に割り当てるだけです。

編集2:クイックベンチマークマクロが変数アクセスを揮発性にし、重要な最適化を妨げているため、実際には最適化は機能しませんでした。ループで変数を変更するだけなので、変数を1回だけロードするのは論理的であり、ボトルネックとなっている揮発性または無効化された最適化です。したがって、この答えは基本的に間違っていますが、少なくとも、NOPが現実の世界で意味をなす場合、最適化されていないコードの実行をどのように高速化できるかを示しています(バケットカウンターのようなより良い方法があります)。

2
Sasha Knorre

最近のプロセッサーは非常に複雑で、推測しかできません。

コンパイラが発行したアセンブリは、実際に実行されるものではありません。 CPUのマイクロコード/ファームウェア/ CPUはそれを解釈して、C#やJavaのようなJIT言語のように、実行エンジンの命令に変換します。

ここで考慮すべきことの1つは、ループごとに1または2の命令ではなく、n + 2があることです。これは、iをインクリメントして反復回数と比較するためです。ほとんどの場合、それは重要ではありませんが、ここでは重要です。ループ本体は非常に単純だからです。

アセンブリを見てみましょう:

いくつかの定義:

#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31

C/C++:

for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     QWORD PTR [rbp-32], 0
.L13:
    cmp     QWORD PTR [rbp-32], 999999999
    jg      .L12
    add     QWORD PTR [rbp-24], 17
    add     QWORD PTR [rbp-32], 1
    jmp     .L13
.L12:

C/C++:

for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}

ASM:

    mov     QWORD PTR [rbp-80], 0
.L21:
    cmp     QWORD PTR [rbp-80], 999999999
    jg      .L20
    add     QWORD PTR [rbp-64], 17
    sub     QWORD PTR [rbp-72], 31
    add     QWORD PTR [rbp-80], 1
    jmp     .L21
.L20:

したがって、両方のアセンブリはかなり似ています。しかし、次に考え直してみましょう。最近のCPUには、レジスタサイズよりも広い値で動作するALUがあります。したがって、最初のケースよりも可能性があり、xとiの演算は同じ計算ユニットで実行されます。ただし、この操作の結果に条件を設定するため、再度iを読み取る必要があります。そして読書は待つことを意味します。

したがって、最初のケースでは、xで反復するために、CPUがiでの反復と同期している必要があります。

2番目のケースでは、多分xとyは、iを扱うユニットとは異なるユニットで扱われます。したがって、実際には、ループ本体はそれを駆動する条件よりも並行して実行されます。そして、誰かが停止するように言うまで、CPUコンピューティングとコンピューティングは続きます。行き過ぎてもかまいません。ループ数をさかのぼることは、取得した時間と比較しても問題ありません。

したがって、比較したいもの(1つの操作と2つの操作)を比較するには、邪魔にならないようにする必要があります。

1つの解決策は、whileループを使用して完全に取り除くことです:C/C++:

while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }

ASM:

.L15:
    movabs  rax, 16999999999
    cmp     QWORD PTR [rbp-40], rax
    jg      .L14
    add     QWORD PTR [rbp-40], 17
    jmp     .L15
.L14:

もう1つは、antequated "register" Cキーワードを使用することです:C/C++:

register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     ebx, 0
.L17:
    cmp     rbx, 999999999
    jg      .L16
    add     QWORD PTR [rbp-48], 17
    add     rbx, 1
    jmp     .L17
.L16:

これが私の結果です:

x1 for:10.2985秒。 x、y = 17000000000、0
x1 while:8.00049秒。 x、y = 17000000000、0
x1 register-for:7.31426秒。 x、y = 17000000000、0
x2 for:9.30073秒。 x、y = 17000000000、-31000000000
x2 while:8.88801秒。 x、y = 17000000000、-31000000000
x2 register-for:8.70302秒。 x、y = 17000000000、-31000000000

コードはこちら: https://onlinegdb.com/S1lAANEhI

1
Jérôme Gardou