.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%遅くなり、気にする価値があります。私はこの質問についてさらに調査しました ループに追加のチェックを追加すると、一部のマシンでは大きな違いが生じ、他のマシンでは小さな違いが生じるのはなぜですか? 。興味深いことに、境界チェックのコストはマシンによって大きく異なるようです。
以下の理由により、境界チェックは問題になりません。
境界チェックはcmp
/jae
命令ペアで構成され、最新のCPUアーキテクチャで単一のマイクロopに融合されます(用語は「マクロop融合」)。比較と分岐は非常に高度に最適化されています。
境界チェックは前方分岐であり、静的に予測されないことが予測され、コストも削減されます。ブランチは決して行われません。 (これが採用された場合、とにかく例外がスローされるため、予測ミスのコストはまったく無関係になります)
メモリの遅延が発生するとすぐに、投機的実行はループの多くの反復をキューに入れるため、余分な命令ペアをデコードするコストはほとんどなくなります。
メモリアクセスがボトルネックになる可能性が高いため、境界チェックの削除などのマイクロ最適化の効果はなくなります。
64ビットのジッタは、境界チェックをなくすのに役立ちます(少なくとも簡単なシナリオでは)。メソッドの最後にreturn sum;
を追加し、リリースモードでVisual Studio 2010を使用してプログラムをコンパイルしました。以下の逆アセンブリ(C#変換で注釈を付けた)では、次のことに注意してください。
X.Length
ではなくX
をi
と比較しても、length
の境界チェックはありません。これは、記事で説明されている動作を改善したものです。Y.Length >= X.Length
であることを確認する単一のチェックがあります。逆アセンブリ
; 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ビットのジッタはそれほどスマートではありません。以下の逆アセンブリでは、次のことに注意してください。
X.Length
ではなくX
をi
と比較しても、length
の境界チェックはありません。繰り返しますが、これは記事で説明されている動作を改善したものです。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が指摘するように)安全でないコードとポインタを使用して、常に配列の境界チェックをバイパスできます。この手法は、基本クラスライブラリのパフォーマンスが重要な一部のコードで使用されます。
境界チェックが実行されないようにする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;
}
まず最初に、元のOPから非常に詳細で洞察に満ちた説明を提供してくれた連中まで、この投稿で話してくれたすべての人に感謝したい。既存の回答を読んで本当に楽しんでいました。ループがどのように、そしてなぜそれらがどのように機能するかについての理論はすでに豊富にあるので、私はいくつかの経験的な(ある定義では権威ある)測定を提供したいと思います:
結論:
.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