web-dev-qa-db-ja.com

.net 4以降の配列境界チェック効率

.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。私は間違っていますか、そして/またはコンパイラが私を助けることを可能にする私のコードを書く方法はありますか?

詳細

特定の言語を使用することには、多くの効率的な議論があります。特に、定数の定数よりも「ビッグO」アルゴリズムのコストに集中する方が良いということです。より高水準の言語は、これを行うのに役立ちます。 .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_関数内のstatic void SumProduct(double[] X, double[] Y)でこの質問の冒頭にあります)、もう2つは暗黙的な(_X[i]_および_Y[i]_)です。だから私は3つの境界チェックで3番目のC++関数をテストしました

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

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

49
TooTone

以下の理由により、境界チェックは問題になりません。

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

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

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

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

34
doug65536

64ビット

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

  • コードがX.LengthではなくXiと比較しても、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.LengthではなくXiと比較しても、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が指摘するように)安全でないコードとポインタを使用して、常に配列の境界チェックをバイパスできます。この手法は、基本クラスライブラリのパフォーマンスが重要な一部のコードで使用されます。

27
Michael Liu

境界チェックが実行されないようにする1つの方法は、ポインターを使用することです。これは、C#の非セーフモードで実行できます(これには、プロジェクトプロパティでフラグを設定する必要があります)。

private static unsafe double SumProductPointer(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");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}

私はあなたの元の方法、X.Length変更とポインタを使用したコード。Net4.5でx86とx64の両方としてコンパイルされています。具体的には、長さ10 000のベクトルのメソッドを計算して、そのメソッドを10 000回実行しました。

結果は、Michael Liuの回答とほぼ一致しています。3つの方法の間に測定可能な違いはありません。つまり、境界チェックが行われていないか、パフォーマンスへの影響がわずかであるということです。ただし、x86とx64の間には測定可能な違いがありました。x64は約34%遅くなりました。

私が使用した完全なコード:

static void Main()
{
    var random = new Random(42);
    double[] x = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
    double[] y = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();

    // make sure JIT doesn't affect the results
    SumProduct(x, y);
    SumProductLength(x, y);
    SumProductPointer(x, y);

    var stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < 10000; i++)
    {
        SumProduct(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductLength(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductPointer(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
}

private static double 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++)
        sum += X[i] * Y[i];
    return sum;
}

private static double SumProductLength(double[] X, double[] Y)
{
    double sum = 0;
    if (X.Length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < X.Length; i++)
        sum += X[i] * Y[i];
    return sum;
}

private static unsafe double SumProductPointer(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");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}
12
svick

まず最初に、元のOPから非常に詳細で洞察に満ちた説明を提供してくれた連中まで、この投稿で話してくれたすべての人に感謝したい。既存の回答を読んで本当に楽しんでいました。ループがどのように、そしてなぜそれらがどのように機能するかについての理論はすでに豊富にあるので、私はいくつかの経験的な(ある定義では権威ある)測定を提供したいと思います:

結論:

  • ForeachループはForループより高速です。
  • ローカル変数は配列.Lengthプロパティより高速です。
  • unsafe fixedを使用したGCピニングは、通常のForループより高速ではありません。

ベンチマークコード:

using System;
using System.Diagnostics;
using System.Runtime;

namespace demo
{
    class MainClass
    {
        static bool ByForArrayLength (byte[] data)
        {
            for (int i = 0; i < data.Length; i++)
                if (data [i] != 0)
                    return false;
            return true;
        }

        static bool ByForLocalLength (byte[] data)
        {
            int len = data.Length;
            for (int i = 0; i < len; i++)
                if (data [i] != 0)
                    return false;
            return true;
        }

        static unsafe bool ByForUnsafe (byte[] data)
        {
            fixed (byte* datap = data)
            {
                int len = data.Length;
                for (int i = 0; i < len; i++)
                    if (datap [i] != 0)
                        return false;
                return true;
            }
        }

        static bool ByForeach (byte[] data)
        {
            foreach (byte b in data)
                if (b != 0)
                    return false;
            return true;
        }

        static void Measure (Action work, string description)
        {
            GCSettings.LatencyMode = GCLatencyMode.LowLatency;
            var watch = Stopwatch.StartNew ();
            work.Invoke ();
            Console.WriteLine ("{0,-40}: {1} ms", description, watch.Elapsed.TotalMilliseconds);
        }

        public static void Main (string[] args)
        {
            byte[] data = new byte[256 * 1024 * 1024];
            Measure (() => ByForArrayLength (data), "For with .Length property");
            Measure (() => ByForLocalLength (data), "For with local variable");
            Measure (() => ByForUnsafe (data), "For with local variable and GC-pinning");
            Measure (() => ByForeach (data), "Foreach loop");
        }
    }
}

結果:(Monoランタイムを使用)

$ mcs Program.cs -optimize -unsafe
For with .Length property               : 440,9208 ms
For with local variable                 : 333,2252 ms
For with local variable and GC-pinning  : 330,2205 ms
Foreach loop                            : 280,5205 ms
1
ArekBulski