コードを高速化してみてください。


1504

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前後の値を出力します。

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

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を出力します...-実際にはより速く実行されます!しかし、なぜ?

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

編集:ジョンスキートの優れた分析は、try-catchが何らかの理由でx86 CLRにこの特定のケースでより好ましい方法でCPUレジスターを使用させていることを示しています(そして、私たちはまだ理由を理解していません)。x64 CLRにはこの違いがなく、x86 CLRよりも高速であるというJonの発見を確認しました。またintlong型の代わりにFiboメソッド内の型を使用してテストしたところ、x86 CLRはx64 CLRと同等の速さでした。


更新:この問題はRoslynによって修正されたようです。同じマシン、同じCLRバージョン-VS 2013でコンパイルすると問題は上記のように残りますが、VS 2015でコンパイルすると問題はなくなります。


111
@ロイドは彼が彼の質問に対して答えを得ようと試みます「それは実際により速く走ります!
Andreas Niedermair 2012年

137
だから、今の良好なパフォーマンスの最適化には悪い習慣であることから渡された「例外呑む」:Pを
ルチアーノ

2
これは、未チェックまたはチェック済みの算術コンテキストですか?
Random832 2012年

7
@ taras.roshko:私はエリックに邪魔をしたくありませんが、これは実際にはC#の質問ではありません-JITコンパイラの質問です。究極の難しさは、それがないのx86 JITはのtry / catchすることなく、多くのレジスタとして使用していない理由を働いている try / catchブロック。
Jon Skeet

63
甘いので、これらのトライキャッチをネストすると、さらに速く進むことができますか?
チャックピンケルト2013

回答:


1053

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

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

これはかなり奇妙です。JITterチームのフォローアップを行い、バグを修正して修正できるかどうかを確認します。

また、C#およびVBコンパイラのアルゴリズムに対するRoslynの改善に取り組んでいます。これは、ローカルを「一時的」にすることができる時期を決定するためです。つまり、アクティベーションの期間。JITterは、レジスタ割り当てのより良い仕事をすることができると信じています。もし私たちが地元住民をより早く「死」にすることができるときについてのより良いヒントを与えれば。

これを私たちの注意を喚起してくれて、ありがとうございます。


8
なぜC#コンパイラーが非常に多くの無関係なローカルを生成するのか、いつも疑問に思っていました。たとえば、新しい配列初期化式は常にローカルを生成しますが、ローカルを生成する必要はありません。JITterがかなりパフォーマンスの高いコードを生成できるようにする場合、おそらくC#コンパイラーは不要なローカルを生成することについて少し注意する必要があります...
Timwi

33
@ティムウィ:絶対に。最適化されていないコードでは、コンパイラーはデバッグを容易にするため、不要なローカルを大幅に破棄します。最適化されたコードでは、可能であれば不要な一時ファイルを削除する必要があります。残念ながら、長年にわたって、一時的除去オプティマイザを誤って最適化しないバグが数多くありました。前述のエンジニアは、Roslynのこのコードをすべてゼロから完全にやり直しているため、Roslynコードジェネレーターの最適化された動作が大幅に改善されているはずです。
Eric Lippert、2012年

24
この問題について何か動きはありましたか?
Robert Harvey

10
Roslynが修正したようです。
ErenErsönmez15年

56
「JITterバグ」と呼ぶ機会を逃しました。
mbomb007 2017年

734

さて、あなたが物事を計っている方法は私にはかなり厄介に見えます。ループ全体の時間を計測するほうがはるかに賢明です。

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

そうすれば、小さなタイミング、浮動小数点演算、蓄積されたエラーに翻弄されることはありません。

その変更を行った後、「非キャッチ」バージョンが「キャッチ」バージョンよりもまだ遅いかどうかを確認します。

編集:さて、私はそれを自分で試してみました-そして私は同じ結果を見ています 非常に奇妙な。try / catchがいくつかの悪いインライン化を無効にしているかどうか疑問に思いましたが、[MethodImpl(MethodImplOptions.NoInlining)]代わりに使用しても役に立たなかった...

基本的には、cordbgで最適化されたJITされたコードを確認する必要があると思います...

編集:情報のいくつかのビット:

  • n++;行のみにtry / catchを配置しても、パフォーマンスは向上しますが、ブロック全体に配置するほどではありません
  • ArgumentException私のテストで)特定の例外をキャッチした場合、それはまだ高速です
  • あなたがキャッチブロックで例外を出力するならば、それはまだ速いです
  • キャッチブロックで例外を再スローすると、再び遅くなります
  • catchブロックの代わりにfinallyブロックを使用すると、再び遅くなります
  • finallyブロック catchブロックを使用すると、高速です

奇妙な...

編集:さて、私たちは分解しています...

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

高速バージョンの逆アセンブルされたコード:

 [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

いずれの場合*も、デバッガーが単純な「ステップイン」で入力した場所を示しています。

編集:さて、私はコードを調べました、そして各バージョンがどのように機能するかを見ることができると思います...そして遅いバージョンはより少ないレジスタとより多くのスタック空間を使用するので遅いと思います。値が小さい場合はn高速になる可能性がありますが、ループが時間の大半を占める場合は遅くなります。

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

編集:ちょうど私のx64マシンでこれを試してみました。x64 CLRは、このコードのx86 CLRよりもはるかに高速(約3〜4倍高速)であり、x64では、try / catchブロックは顕著な違いを生じません。


4
@GordonSimpsonしかし、特定の例外のみがキャッチされる場合、他のすべての例外はキャッチされないため、未試行の仮説に含まれるオーバーヘッドが依然として必要です。
Jon Hanna

45
レジスタ割り当ての違いのようです。高速バージョンesi,ediでは、スタックの代わりにlongの1つを使用できます。これは、使用していますebx遅いバージョンが使用するカウンタとしてesi
ジェフリーサックス2012年

13
@JeffreySax:使用されるレジスターだけなく、使用されるレジスターの数です。遅いバージョンはより多くのスタックスペースを使用し、より少ないレジスターに触れます。なぜだかわからない…
ジョン・スキート

2
CLR例外フレームは、レジスタとスタックに関してどのように処理されますか?1つを設定すると、なんらかの方法でレジスターが解放されますか?
Random832

4
IIRC x64は、x86よりも多くのレジスターを使用できます。見たスピードアップは、x86での追加のレジスタ使用を強制するtry / catchと一致します。
DanはFirelightによっていじっています

116

Jonの逆アセンブリは、2つのバージョンの違いは、高速バージョンが一対のレジスター(esi,edi)を使用して、低速バージョンが行わないローカル変数の1つを格納することを示しています。

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でも違いが見られます。


6
それが言われていると...あなたはTry / Catchを使用せずに異なるレジスタ割り当ての選択を強制できますか?この仮説のテストとして、または速度を微調整する一般的な試みのどちらですか?
WernerCD、2012年

1
この特定のケースが異なる場合がある理由はいくつかあります。多分それはトライキャッチです。多分それは変数が内部スコープで再利用されるという事実です。具体的な理由が何であれ、まったく同じコードが別のプログラムで呼び出された場合でも、保持することを期待できないのは実装の詳細です。
ジェフリーサックス2012年

4
@WernerCD CとC ++には、(A)多くの最新のコンパイラーによって無視されていること、および(B)C#を使用しないことが決定されていることを示唆するキーワードがあるという事実は、これが私たちのものではないことを示唆していますもっと直接的な方法で見ます。
Jon Hanna

2
@WernerCD-アセンブリを自分で書く場合のみ
OrangeDog

72

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

これは、遅いバージョンの逆アセンブルで際立っているものであり、ediもebxも使用されていません。

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

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

メソッドをインライン化できるタイミングを正確に決定するいくつかのルールがあります。それらは正確には文書化されていませんが、ブログの投稿で言及されています。1つのルールは、メソッド本体が大きすぎる場合には発生しないことです。これは、インライン化による利益を無効にし、L1命令キャッシュに収まらないコードを生成しすぎます。ここで適用されるもう1つの難しい規則は、try / catchステートメントを含むメソッドはインライン化されないことです。その背後にある背景は例外の実装の詳細であり、Windowsの組み込みフレームワークであるSEH(構造例外処理)の組み込みサポートに便乗しています。

ジッターにおけるレジスター割り当てアルゴリズムの1つの動作は、このコードでのプレイから推測できます。ジッターがメソッドをインライン化しようとしているときに気づいているようです。edx:eaxレジスタペアのみがlong型のローカル変数を持つインラインコードに使用できるという規則が使用されているようです。しかし、edi:ebxではありません。呼び出し元のメソッドのコード生成に悪影響を与えるため、ediとebxはどちらも重要なストレージレジスタです。

したがって、ジッターはメソッド本体にtry / catchステートメントが含まれていることを前もって知っているため、高速バージョンが得られます。インライン化できないことを知っているので、長い変数のストレージにedi:ebxを簡単に使用できます。インライン化が機能しないことをジッターが前もって認識していなかったため、遅いバージョンを取得しました。メソッド本体のコードを生成したで初めて判明しました。

その場合の欠点は、戻ってメソッドのコードを再生成しなかったことです。それが動作しなければならない時間の制約を考えると、これは理解できます。

このスローダウンはx64では発生しません。1つのレジスタには8つのレジスタがあるためです。もう1つは、1つのレジスタ(raxなど)にlongを格納できるためです。また、ジッターはレジスタを選択する際の柔軟性が大幅に向上しているため、longの代わりにintを使用してもスローダウンは発生しません。


21

私はこれが事実である可能性が高いことを本当に確信していないのでコメントとしてこれを入れましたが、私が思い出すように、それはtry / exceptステートメントがごみ処理メカニズムの方法の変更を含んでいないコンパイラーは、スタックから再帰的な方法でオブジェクトのメモリ割り当てをクリアするという点で機能します。この場合、クリアするオブジェクトがないか、forループが、ガベージコレクションメカニズムが別のコレクションメソッドを適用するのに十分であると認識するクロージャを構成している可能性があります。おそらくそうではありませんが、他で議論されているのを見たことがないので、言及する価値があると思いました。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.