web-dev-qa-db-ja.com

試してみてコードの速度を上げますか?

Try-catchの影響をテストするためのコードをいくつか書きましたが、驚くべき結果がいくつかあります。

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

私のコンピュータでは、これは一貫して0.96前後の値を出力します。

このようにtry-catchブロックでFibo()の内部でforループをラップすると、

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

今では0.69を常に出力しています... - 実際にはより速く実行されます!しかし、なぜ?

注:これはRelease構成を使用してコンパイルし、EXEファイルを直接実行しました(Visual Studioの外部)。

編集: Jon Skeetのexcellent分析 は、この特定のケースではtry-catchがどういうわけかx86 CLRにもっと有利な方法でCPUレジスタを使わせていることを示しています。理由を理解してください)。私はJonがx64 CLRにはこの違いがないこと、そしてそれがx86 CLRより速いことを確認しました。 int型の代わりにFiboメソッド内でlong型を使用してテストしたところ、x86 CLRはx64 CLRと同じくらい高速でした。


更新: この問題はロザリンによって修正されたようです。同じマシン、同じCLRバージョン - VS 2013でコンパイルしても問題は上記と同じですが、VS 2015でコンパイルしても問題は解決します。

1415
Eren Ersönmez

スタック使用の最適化を理解することを専門とする Roslyn エンジニアの1人がこれを見て、C#コンパイラがローカル変数ストアを生成する方法とローカル変数ストアを生成する方法の間の相互作用に問題があるようです。 _ jit _ コンパイラは、対応するx86コードに登録スケジューリングを行います。その結果、ローカルのロードとストアに対する次善のコード生成となります。

私たち全員には不明な何らかの理由で、ブロックがtry-protected領域にあることをJITterが知っているときに問題のあるコード生成パスは避けられます。

これはかなり変です。私たちはJITterチームにフォローアップして、彼らがこれを直すことができるようにバグが入ることができるかどうか確かめます。

また、RoslynでC#およびVBコンパイラのアルゴリズムを改良して、いつローカルを「一時的」にすることができるかを決定します。つまり、割り当てを行うのではなく、スタックにプッシュしてポップするだけです。起動中のスタック上の特定の場所。私たちはJITterがより良いレジスタ割り当ての仕事をすることができると信じています、そして我々がそれを早く地元の人々が "死に"させることができる時についてもっと良いヒントを与えるならば。

私たちの注意を引いてくれてありがとう、そして奇妙な振る舞いをお詫び申し上げます。

981
Eric Lippert

さて、あなたが物事をタイミングする方法は私にとってかなり厄介に見えます。ループ全体を計時するだけの方がはるかに賢明です。

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

そのようにして、あなたは小さなタイミング、浮動小数点演算、そして累積されたエラーの恩恵を受けることはできません。

その変更を行ったので、 "non-catch"バージョンがまだ "catch"バージョンより遅いかどうかを見てください。

編集:さて、私はそれを自分で試した - そして私は同じ結果を見ている。非常に奇妙な。 try/catchが悪いインライン展開を無効にしているのではないかと思いましたが、代わりに[MethodImpl(MethodImplOptions.NoInlining)]を使用しても役に立ちませんでした...

基本的にcordbgの下で最適化されたJITtedコードを見る必要があるでしょう、と私は思います...

編集:もう少し情報を:

  • Try/catchをn++;行だけに配置してもパフォーマンスは向上しますが、ブロック全体に配置するほどではありません。
  • 特定の例外(私のテストではArgumentException)をキャッチしたとしても、それはまだ速いです。
  • あなたがcatchブロックの中に例外をプリントするなら、それはまだ速いです
  • Catchブロックで例外を再スローすると、また遅くなります
  • Catchブロックの代わりにfinallyブロックを使用すると、やはり遅くなります。
  • Finallyブロック および catchブロックを使用すると、高速です。

変な...

編集:さて、私たちは逆アセンブルしている...

これは、C#2コンパイラと.NET 2(32ビット)CLRを使用し、mdbgと逆アセンブルします(私のマシンにはcordbgがありません)。デバッガの下でも、私はまだ同じパフォーマンス効果を見ます。高速版では、変数宣言とreturn文の間のすべてを囲むtryブロックを、catch{}ハンドラだけで使用します。明らかに遅いバージョンはtry/catchなしで同じです。呼び出しコード(つまりMain)はどちらの場合も同じで、同じAssembly表現を持ちます(したがってインライン化の問題ではありません)。

速い版のための逆アセンブルされたコード:

 [0000] Push        ebp
 [0001] mov         ebp,esp
 [0003] Push        edi
 [0004] Push        esi
 [0005] Push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

遅いバージョンのための逆アセンブルされたコード:

 [0000] Push        ebp
 [0001] mov         ebp,esp
 [0003] Push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

いずれの場合も*はデバッガがどこに入ったかを簡単な "step-into"で示しています。

編集:さて、私は今コードを見て、私は各バージョンがどのように動作するか見ることができると思うと思います...そしてより遅いバージョンはより少ないレジスタとより多くのスタックスペースを使うのでより遅いと思います。 nの値が小さいとおそらく速くなります - しかし、ループが時間の大部分を占めている時は遅くなります。

おそらく、try/catchブロック により moreレジスタが保存および復元されるため、JITはそれらをループにも使用します。これにより、全体的なパフォーマンスが向上します。 JITが「通常の」コードで使用するレジスタと同じ数の not を使用することが合理的な決定かどうかは明らかではありません。

編集:ちょうど私のx64マシン上でこれを試してみました。 x64 CLRは、このコードではx86 CLRよりも はるかに 速い(約3-4倍速い)。x64の下では、try/catchブロックはそれほど大きな違いはない。

713
Jon Skeet

Jonの分解は、2つのバージョンの違いは、高速バージョンでは低速バージョンではないローカル変数の1つを格納するために一対のレジスタ(esi,edi)を使用することです。

JITコンパイラーは、try-catchブロックを含むコードとそうでないコードのレジスター使用に関して、異なる想定をしています。これにより、異なるレジスタ割り当ての選択が行われます。この場合、これはtry-catchブロックを持つコードを優先します。コードが異なると逆の効果が生じる可能性があるため、これを汎用のスピードアップ手法とは見なしません。

結局、どのコードが最速で実行されるのかを見分けるのは非常に困難です。レジスタ割り当てのようなものとそれに影響を与える要因は、そのような低レベルの実装の詳細であり、特定の手法で確実に高速なコードを生成する方法はわかりません。

たとえば、次の2つの方法を検討してください。それらは現実の例から適応されました:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

1つは他の一般的なバージョンです。ジェネリック型をStructArrayに置き換えると、メソッドが同一になります。 StructArrayは値型であるため、汎用メソッドの独自のコンパイル済みバージョンを取得します。それでも、実際の実行時間は特殊な方法よりもかなり長くなりますが、x86のみです。 x64では、タイミングはほとんど同じです。他の場合では、私はx64のための相違点も観察しました。

112
Jeffrey Sax

これはインライン展開がうまくいっていないようです。 x86コアでは、ジッタにはローカル変数の汎用ストレージに使用できるebx、edx、esi、およびediレジスタがあります。 ecxレジスタは静的メソッドで利用可能になり、thisを格納する必要はありません。 eaxレジスタは多くの場合、計算に必要です。しかし、これらは32ビットレジスタです。long型の変数では、一対のレジスタを使用する必要があります。どちらが計算用のedx:eaxとストレージ用のedi:ebxです。

これは遅いバージョンのための逆アセンブリで際立っているものです、ediもebxも使われません。

ジッタがローカル変数を格納するのに十分なレジスタを見つけることができないとき、それはそれらをスタックフレームからロードし格納するためのコードを生成しなければなりません。それはコードを遅くします、それは "レジスタリネーム"という名前のプロセッサ最適化、レジスタの複数のコピーを使用して、スーパースカラ実行を可能にする内部プロセッサコア最適化トリックを防ぎます。これにより、同じレジスタを使用している場合でも、複数の命令を同時に実行できます。十分なレジスタがないことは、8個の追加レジスタ(r9からr15)を持つx64で解決されているx86コアでよくある問題です。

ジッタは別のコード生成最適化を適用するために最善を尽くします、それはあなたのFibo()メソッドをインライン化しようとします。つまり、メソッドを呼び出さずに、Main()メソッド内でメソッドのコードをインラインで生成します。 1つは、C#クラスのプロパティを無料で作成し、フィールドの機能を提供するという非常に重要な最適化です。メソッドを呼び出してそのスタックフレームを設定することによるオーバーヘッドを回避し、数ナノ秒を節約します。

メソッドをインライン化できるタイミングを正確に決定する規則がいくつかあります。それらは正確には文書化されていませんが、ブログ投稿で言及されています。 1つの規則は、メソッド本体が大きすぎる場合には起こらないということです。インライン化によるメリットがなくなるため、L1命令キャッシュには収まりきらないほど多くのコードが生成されます。ここで適用されるもう1つの難しい規則は、メソッドにtry/catchステートメントが含まれている場合、そのメソッドはインライン化されないということです。その背後にある背景は、例外の実装の詳細です。スタックフレームベースのSEH(Structure Exception Handling)に対するWindowsの組み込みサポートにピギーバックします。

ジッタにおけるレジスタ割り当てアルゴリズムの動作の1つは、このコードを再生することから推測できます。ジッタがいつメソッドをインライン化しようとしているのかを認識しているように見えます。 long型のローカル変数を持つインラインコードには、edx:eaxレジスタペアのみを使用できるという規則があります。しかしedi:ebxは違います。呼び出し側のメソッドのコード生成には有害すぎるので、ediとebxはどちらも重要な記憶域レジスタです。

メソッド本体にtry/catchステートメントが含まれていることがジッタには事前に認識されているため、高速バージョンが得られます。インライン展開できないことを知っているので、長い変数の格納にedi:ebxをすぐに使用します。ジッタがインライン展開がうまくいかないことを事前に知らなかったので、あなたは遅いバージョンを手に入れました。メソッド本体のコードを生成しているのはafterのみでした。

問題はそれから戻ってこなかったことと、そのメソッドのコードである{re-generate}。それが動作しなければならない時間的制約を考えると、これは理解可能です。

X64では、このような速度低下は起こりません。なぜなら、x64ではもう8つのレジスタがあるからです。それは(raxのように)ただ一つのレジスタにlongを格納できるからです。ジッターはレジスタを選択する際の柔軟性がはるかに高いため、longの代わりにintを使用してもスローダウンは発生しません。

67
Hans Passant

私はこれが事実であることが本当らしいことを本当に確信していないので私はこれをコメントとして入れたであろう、しかし私がそれがtry/exceptステートメントではないコンパイラは、スタックから再帰的にオブジェクトメモリの割り当てをクリアするという点で機能します。この場合、片付けられるオブジェクトがないか、またはforループが、ガベージコレクションメカニズムが別のコレクションメソッドを実行するのに十分であると認識するクロージャを構成することがあります。たぶんそうではないが、私はそれがどこか他で議論されるのを見たことがなかったのでそれは言及する価値があると思った。

19