70

次のコードを使用して、配列の要素をスケーリングおよび合計するルーチンのタイミングを計ることにより、L1 キャッシュとメモリに配列を持つことの影響を把握しようとしています (結果を 'ポイントは、ループ内で乗算と加算の両方を実行することです-これまでのところ、コンパイラは「a」を因数分解することを理解していません):

double sum(double a,double* X,int size)
{
    double total = 0.0;
    for(int i = 0;  i < size; ++i)
    {
        total += a*X[i];
    }
    return total;
}

#define KB 1024
int main()
{
    //Approximately half the L1 cache size of my machine
    int operand_size = (32*KB)/(sizeof(double)*2);
    printf("Operand size: %d\n", operand_size);
    double* X = new double[operand_size];
    fill(X,operand_size);

    double seconds = timer();
    double result;
    int n_iterations = 100000;
    for(int i = 0; i < n_iterations; ++i)
    {
        result = sum(3.5,X,operand_size);
        //result += rand();  
    }
    seconds = timer() - seconds; 

    double mflops = 2e-6*double(n_iterations*operand_size)/seconds;
    printf("Vector size %d: mflops=%.1f, result=%.1f\n",operand_size,mflops,result);
    return 0;
}

timer() および fill() ルーチンは簡潔にするために含まれていないことに注意してください。コードを実行する場合は、完全なソースをここで見つけることができます。

http://codepad.org/agPWItZS

さて、ここからが興味深いところです。これは出力です:

Operand size: 2048
Vector size 2048: mflops=588.8, result=-67.8

X のすべての要素をループ反復間でキャッシュに保持する必要があるにもかかわらず、これは完全にキャッシュされていないパフォーマンスです。によって生成されたアセンブリ コードを見ると、次のようになります。

g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp

sum 関数のループに奇妙な点が 1 つあります。

L55:
    movsd   (%r12,%rax,8), %xmm0
    mulsd   %xmm1, %xmm0
    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)
    incq    %rax
    cmpq    $2048, %rax
    jne L55

説明書:

    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)

"total" の値をスタックの sum() に格納し、ループの反復ごとに読み書きしていることを示します。このオペランドがレジスタに保持されるようにアセンブリを変更しました。

...
addsd   %xmm0, %xmm3
...

この小さな変更により、パフォーマンスが大幅に向上します

Operand size: 2048
Vector size 2048: mflops=1958.9, result=-67.8

tl;dr 私の質問は、単一のメモリ ロケーション アクセスをレジスタに置き換えると、コードが大幅に高速化されるのはなぜですか? これを可能にするアーキテクチャ上の要因は何ですか? 1 つのスタック位置を繰り返し書き込むと、キャッシュの有効性が完全に失われるというのは非常に奇妙に思えます。

付録

私のgccバージョンは次のとおりです。

Target: i686-apple-darwin10
Configured with: /var/tmp/gcc/gcc-5646.1~2/src/configure --disable-checking --enable-werror --prefix=/usr --mandir=/share/man --enable-languages=c,objc,c++,obj-c++ --program-transform-name=/^[cg][^.-]*$/s/$/-4.2/ --with-slibdir=/usr/lib --build=i686-apple-darwin10 --with-gxx-include-dir=/include/c++/4.2.1 --program-prefix=i686-apple-darwin10- --host=x86_64-apple-darwin10 --target=i686-apple-darwin10
Thread model: posix
gcc version 4.2.1 (Apple Inc. build 5646) (dot 1)

私のCPUは:

インテル Xeon X5650

4

3 に答える 3

61

これは、より長い依存関係の連鎖と Load Misprediction* の組み合わせである可能性があります。


より長い依存チェーン:

まず、重要な依存パスを特定します。次に、 http://www.agner.org/optimize/instruction_tables.pdf (117 ページ)で提供される命令レイテンシーを調べます。

最適化されていないバージョンでは、重要な依存関係のパスは次のとおりです。

  • addsd -72(%rbp), %xmm0
  • movsd %xmm0, -72(%rbp)

内部的には、おそらく次のように分割されます。

  • 負荷 (2 サイクル)
  • 追加 (3 サイクル)
  • ストア (3 サイクル)

最適化されたバージョンを見ると、次のようになります。

  • 追加 (3 サイクル)

したがって、8 サイクルと 3 サイクルがあります。ほぼ3倍。

Nehalem プロセッサ ラインが依存関係のストアロードにどの程度影響を受けやすく、転送がどの程度うまく機能するかはわかりません。しかし、それがゼロではないと信じるのは合理的です。


ロードストアの予測ミス:

最新のプロセッサは、想像できるより多くの方法で予測を使用します。これらの中で最も有名なのはおそらくBranch Predictionです。あまり知られていないものの 1 つは、負荷予測です。

プロセッサがロードを確認すると、保留中のすべての書き込みが完了する前に、すぐにロードします。これらの書き込みは、ロードされた値と競合しないと想定されます。

以前の書き込みがロードと競合することが判明した場合は、ロードを再実行し、計算をロードの時点までロールバックする必要があります。(分岐の予測ミスがロールバックするのとほぼ同じ方法で)

ここでの関連性:

言うまでもなく、最新のプロセッサは、このループの複数の反復を同時に実行できます。したがって、プロセッサは、前の反復からのaddsd -72(%rbp), %xmm0)ストア ( ) を終了する前に、ロード ( ) を実行しようとします。movsd %xmm0, -72(%rbp)

結果?前のストアがロードと競合するため、予測ミスとロールバックが発生します。

*「負荷予測」という名前がよくわからないことに注意してください。私はそれについてインテルのドキュメントで読んだだけで、名前が付けられていないようでした。

于 2013-03-27T17:45:03.713 に答える
16

問題はキャッシュ/メモリ アクセスではなく、プロセッサ (コードの実行) にあると思います。ここにはいくつかの目に見えるボトルネックがあります。

ここでのパフォーマンスの数値は、私が使用していたボックス (sandybridge または westmere) に基づいています。

プロセッサは加算と乗算を同時に実行できるため、スカラー演算のピーク パフォーマンスは 2.7Ghz x2 FLOPS/Clock x2 です。コードの理論上の効率は 0.6/(2.7*2) = 11%

必要な帯域幅: (+) および (x) ごとに 2 つの double -> 4 バイト/フロップ 4 バイト * 5.4GFLOPS = 21.6GB/秒

最近読み取られたことがわかっている場合は、L1 (89GB/秒)、L2 (42GB/秒)、または L3 (24GB/秒) である可能性が高いため、キャッシュ B/W を除外できます。

メモリ サブシステムは 18.9 GB/秒であるため、メイン メモリでも、ピーク パフォーマンスは 18.9/21.6GB/秒 = 87.5 % に近づくはずです。

  • 可能な限り早い段階で (アンロールを介して) リクエストをまとめたい場合があります

投機的実行でも、 tot += a *X[i] tot(n+1) を開始する前に tot(n) を評価する必要があるため、追加はシリアル化されます

最初にループ
移動 i を 8 ずつアンロールして do

{//your func
    for( int i = 0; i < size; i += 8 ){
        tot += a * X[i];
        tot += a * X[i+1];
        ...
        tot += a * X[i+7];
    }
    return tot
}

複数のアキュムレータを使用する
これにより依存関係が壊れ、追加パイプラインでのストールを回避できます

{//your func//
    int tot,tot2,tot3,tot4;
    tot = tot2 = tot3 = tot4 = 0
    for( int i = 0; i < size; i += 8 ) 
        tot  += a * X[i];
        tot2 += a * X[i+1];
        tot3 += a * X[i+2];
        tot4 += a * X[i+3];
        tot  += a * X[i+4];
        tot2 += a * X[i+5];
        tot3 += a * X[i+6];
        tot4 += a * X[i+7];
    }
    return tot + tot2 + tot3 + tot4;
}

更新 SandyBridgeボックスでこれを実行した後、私はアクセスできます:(2.7GHZ SandyBridge with -O2 -march=native -mtune=native

元のコード:

Operand size: 2048  
Vector size 2048: mflops=2206.2, result=61.8  
2.206 / 5.4 = 40.8%

改善されたコード:

Operand size: 2048  
Vector size 2048: mflops=5313.7, result=61.8  
5.3137 / 5.4 = 98.4%  
于 2013-03-27T17:56:54.830 に答える
8

私のコンパイラ(gcc 4.7.2)がtotalレジスタを保持しているため、実際にはこれを再現できません。

遅さの主な理由は L1 キャッシュに関係しているのではなく、ストア間のデータ依存関係によるものだと思います。

movsd   %xmm0, -72(%rbp)

および後続の反復の負荷:

addsd   -72(%rbp), %xmm0
于 2013-03-27T17:43:33.080 に答える