52

低レベルのアルゴリズムが .net でどれほど効率的になるかに興味があります。将来的には、C++ ではなく C# でより多くのコードを記述できるようにしたいと考えていますが、1 つの障害は、ループと配列へのランダム アクセスで発生する .net の境界チェックです。

動機付けとなる例は、2 つの配列内の対応する要素の積の合計を計算する関数です (これは 2 つのベクトルの内積です)。

static void SumProduct(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < length; i++) // Check X.Length instead? See below
        sum += X[i] * Y[i];
}

私が知る限り、IL または x86 をチェックするのに十分な知識がないため、コンパイラはX および Yの境界チェックを最適化しません。私は間違っていますか、またはコンパイラーが私を助けてくれるようにコードを書く方法はありますか?

詳細

特定の言語を使用することには賛否両論がありますが、特に、比例定数よりも「大規模な」アルゴリズムのコストに集中する方が良いという議論があり、高レベルの言語はこれを行うのに役立ちます。.net での境界チェックに関して、私が見つけた最良の記事は、MSDNの CLR での配列境界チェックの削除です(最適化を有効にすることの重要性に関するスタック オーバーフローの回答でも参照されています)。

これは2009年のことなので、その後大きく変わったのでしょうか。また、この記事は、私を捕らえたであろういくつかの本当の微妙な点を明らかにしているので、この理由だけでも、専門家のアドバイスを歓迎します.

たとえば、上記のコードi< X.Lengthでは、i < length. foreachまた、単一の配列を使用するアルゴリズムの場合、ループを作成すると、コンパイラに意図を宣言し、境界チェックを最適化する可能性が最も高くなると単純に想定していました。

MSDN の記事によると、SumForBAD最適化されるはずだと思っていた以下の は、そうではありません。一方SumFor、直接最適化され、SumForEach最適化もされますが、自明ではありません (また、配列が として関数に渡された場合、まったく最適化されない可能性がありますIEnumerable<int>)?

static double SumForBAD(double[] X)
{
    double sum = 0;
    int length = X.Length; // better to use i < X.length in loop
    for (int i = 0; i < length; i++)
        sum += X[i];
    return sum;
}

static double SumFor(double[] X)
{
    double sum = 0;
    for (int i = 0; i < X.Length; i++)
        sum += X[i];
    return sum;
}

static double SumForEach(double[] X)
{
    double sum = 0;
    foreach (int element in X)
        sum += element;
    return sum;
}

doug65536 の回答に基づいて調査を行いました。C++ で、境界チェックを 1 回行う SumProduct の時間を比較しました

for(int i=0; i<n; ++i) sum += v1[i]*v2[i];

2 つの境界チェックを行う別のバージョンに対して

for(int i=0; i<n1 && i <n2; ++i) sum += v1[i]*v2[i];

2 番目のバージョンの方が遅いことがわかりましたが、約 3.5% (Visual Studio 2010、最適化されたビルド、既定のオプション) だけでした。しかし、C# では、境界チェックが 3 つある可能性があることに気付きました。1 つは明示的 (この質問の冒頭のi < length関数内)、2 つは暗黙的 ( and ) です。そこで、3 つの境界チェックを使用して 3 つ目の C++ 関数をテストしました。static void SumProduct(double[] X, double[] Y)X[i]Y[i]

for(int i=0; i<n1 && i <n2 && i <n3; ++i) sum += v1[i]*v2[i];

これは最初のものより 35% 遅くなりましたが、これは気にする価値があります。この質問でさらに調査を行いました。追加のチェックインループを追加すると、一部のマシンでは大きな違いが生じ、他のマシンでは小さな違いが生じるのはなぜですか? . 興味深いことに、境界チェックのコストはマシンによって大きく異なるようです。

4

4 に答える 4

39

次の理由により、境界チェックは重要ではありません。

  • 境界チェックはcmp/jae命令のペアで構成され、最新の CPU アーキテクチャでは単一のマイクロ操作に融合されます (この用語は「マクロ操作融合」です)。比較と分岐は非常に高度に最適化されています。

  • 境界チェックは前方分岐であり、静的に実行されないと予測されるため、コストも削減されます。ブランチが取得されることはありません。(それが取られると、とにかく例外がスローされるため、予測ミスのコストはまったく無関係になります)

  • メモリ遅延が発生するとすぐに、投機的実行がループの多くの反復をキューに入れるため、余分な命令ペアをデコードするコストはほとんどなくなります。

メモリアクセスがボトルネックになる可能性が高いため、境界チェックの削除などのマイクロ最適化の効果はなくなります。

于 2013-05-23T16:43:54.887 に答える
30

64ビット

64 ビット ジッターは、境界チェックを排除するのに適しています (少なくとも単純なシナリオでは)。メソッドの最後に追加return sum;し、リリース モードで Visual Studio 2010 を使用してプログラムをコンパイルしました。以下の逆アセンブリ (C# の翻訳で注釈を付けました) では、次のことに注意してください。

  • Xコードはではなくiと比較されますが、 の境界チェックはありません。これは、この記事で説明されている動作よりも改善されています。lengthX.Length
  • メインループの前に、Y.Length >= X.Length.
  • メイン ループ (オフセット 00000032 ~ 00000052) には境界チェックは含まれません。

分解

; Register assignments:
;    rcx  := i
;    rdx  := X
;    r8   := Y
;    r9   := X.Length ("length" in your code, "XLength" below)
;    r10  := Y.Length ("YLength" below)
;    r11  := X.Length - 1 ("XLengthMinus1" below)
;    xmm1 := sum

; (Prologue)
00000000  push        rbx
00000001  push        rdi
00000002  sub         rsp,28h

; (Store arguments X and Y in rdx and r8)
00000006  mov         r8,rdx   ; Y
00000009  mov         rdx,rcx  ; X

; int XLength = X.Length;
0000000c  mov         r9,qword ptr [rdx+8]

; int XLengthMinus1 = XLength - 1;
00000010  movsxd      rax,r9d
00000013  lea         r11,[rax-1]

; int YLength = Y.Length;
00000017  mov         r10,qword ptr [r8+8]

; if (XLength != YLength)
;     throw new ArgumentException("X and Y must be same size");
0000001b  cmp         r9d,r10d
0000001e  jne         0000000000000060

; double sum = 0;
00000020  xorpd       xmm1,xmm1

; if (XLength > 0)
; {
00000024  test        r9d,r9d
00000027  jle         0000000000000054

;     int i = 0;
00000029  xor         ecx,ecx
0000002b  xor         eax,eax

;     if (XLengthMinus1 >= YLength)
;         throw new IndexOutOfRangeException();
0000002d  cmp         r11,r10
00000030  jae         0000000000000096

;     do
;     {
;         sum += X[i] * Y[i];
00000032  movsd       xmm0,mmword ptr [rdx+rax+10h]
00000038  mulsd       xmm0,mmword ptr [r8+rax+10h]
0000003f  addsd       xmm0,xmm1
00000043  movapd      xmm1,xmm0

;         i++;
00000047  inc         ecx
00000049  add         rax,8

;     }
;     while (i < XLength);
0000004f  cmp         ecx,r9d
00000052  jl          0000000000000032
; }

; return sum;
00000054  movapd      xmm0,xmm1

; (Epilogue)
00000058  add         rsp,28h
0000005c  pop         rdi
0000005d  pop         rbx
0000005e  ret

00000060  ...

00000096  ...

32ビット

残念ながら、32 ビットのジッターはそれほどスマートではありません。以下の逆アセンブルでは、次のことに注意してください。

  • Xコードはではなくiと比較されますが、 の境界チェックはありません。繰り返しますが、これは記事で説明されている動作よりも改善されています。lengthX.Length
  • メイン ループ (オフセット 00000018 ~ 0000002a) には、 の境界チェックが含まれていますY

分解

; Register assignments:
;    eax  := i
;    ecx  := X
;    edx  := Y
;    esi  := X.Length ("length" in your code, "XLength" below)

; (Prologue)
00000000  push        ebp
00000001  mov         ebp,esp
00000003  push        esi

; double sum = 0;
00000004  fldz

; int XLength = X.Length;
00000006  mov         esi,dword ptr [ecx+4]

; if (XLength != Y.Length)
;     throw new ArgumentException("X and Y must be same size");
00000009  cmp         dword ptr [edx+4],esi
0000000c  je          00000012
0000000e  fstp        st(0)
00000010  jmp         0000002F

; int i = 0;
00000012  xor         eax,eax

; if (XLength > 0)
; {
00000014  test        esi,esi
00000016  jle         0000002C

;     do
;     {
;         double temp = X[i];
00000018  fld         qword ptr [ecx+eax*8+8]

;         if (i >= Y.Length)
;             throw new IndexOutOfRangeException();
0000001c  cmp         eax,dword ptr [edx+4]
0000001f  jae         0000005A

;         sum += temp * Y[i];
00000021  fmul        qword ptr [edx+eax*8+8]
00000025  faddp       st(1),st

;         i++;
00000027  inc         eax

;     while (i < XLength);
00000028  cmp         eax,esi
0000002a  jl          00000018
; }

; return sum;
0000002c  pop         esi
0000002d  pop         ebp
0000002e  ret

0000002f  ...

0000005a  ...

まとめ

2009 年以降、ジッターは改善されており、64 ビット ジッターは 32 ビット ジッターよりも効率的なコードを生成できます。

ただし、必要に応じて、安全でないコードとポインターを使用して、配列境界チェックをいつでも完全にバイパスできます (svick が指摘しているように)。この手法は、基本クラス ライブラリの一部のパフォーマンスが重要なコードで使用されます。

于 2013-06-16T22:53:31.263 に答える