16

別の StackOverflow の質問で (コメントで)開始されたディスカッションの後に、この質問を開始しています。答えを知りたいと思っています。次の式を検討します。

var objects = RequestObjects.Where(r => r.RequestDate > ListOfDates.Max());

この場合、評価を Where 句の外に移動する (パフォーマンス) 利点はありますかListOfDates.Max()、それとも 1. コンパイラまたは 2. JIT がこれを最適化しますか?

私は、C# はコンパイル時にのみ定数の折りたたみを行うと考えており、ListOfDates 自体が何らかの形で定数でない限り、ListOfDates.Max() はコンパイル時に認識できないと主張できます。

おそらく、これが一度だけ評価されるようにする別のコンパイラ (または JIT) の最適化があるでしょうか?

4

1 に答える 1

17

うーん、ちょっと複雑な答えです。

ここには 2 つのことが関係しています。(1) コンパイラーと (2) JIT。

コンパイラ

簡単に言えば、コンパイラは C# コードを IL コードに変換するだけです。ほとんどの場合、これは非常に簡単な変換であり、.NET の核となる考え方の 1 つは、各関数が IL コードの自律的なブロックとしてコンパイルされることです。

したがって、C# -> IL コンパイラにあまり期待しないでください。

JIT

それは... もう少し複雑です。

JIT コンパイラーは、基本的に IL コードをアセンブラーに変換します。JIT コンパイラーには、SSA ベースのオプティマイザーも含まれています。ただし、コードの実行が開始されるまであまり長く待ちたくないため、時間制限があります。基本的にこれは、JIT コンパイラーが、コードを非常に高速にする超クールなことをすべて実行しないことを意味します。単純に時間がかかりすぎるためです。

もちろん、テストすることもできます:)実行時にVSが最適化されることを確認し(オプション->デバッガー->抑制[...]と私のコードのみをオフにします)、x64リリースモードでコンパイルし、ブレークポイントを配置して確認しますアセンブラー ビューに切り替えるとどうなりますか。

でもねえ、理論だけで何が楽しいの?テストしてみましょう。:)

static bool Foo(Func<int, int, int> foo, int a, int b)
{
    return foo(a, b) > 0;  // put breakpoint on this line.
}

public static void Test()
{
    int n = 2;
    int m = 2;
    if (Foo((a, b) => a + b, n, m)) 
    {
        Console.WriteLine("yeah");
    }
}

最初に注意すべきことは、ブレークポイントがヒットしたことです。これは、メソッドがインライン化されていないことをすでに示しています。もしそうなら、ブレークポイントにまったく到達しません。

次に、アセンブラの出力を見ると、アドレスを使用した「呼び出し」命令に気付くでしょう。これがあなたの機能です。よく調べてみると、デリゲートを呼び出していることがわかります。

基本的に、これは呼び出しがインライン化されていないことを意味するため、ローカル (メソッド) コンテキストに一致するように最適化されていません。つまり、デリゲートを使用せずにメソッドに何かを入れると、おそらくデリゲートを使用するよりも高速になります。

一方、通話かなり効率的です。基本的に関数ポインタは単純に渡されて呼び出されます。vtable ルックアップはなく、単純な呼び出しだけです。これは、おそらくメンバー (例えば IL ) を呼び出すよりも優れていることを意味しcallvirtます。それでも、静的呼び出し (IL call) はコンパイル時間が予測可能なため、さらに高速になるはずです。もう一度、テストしましょう。

public static void Test()
{
    ISummer summer = new Summer();
    Stopwatch sw = Stopwatch.StartNew();
    int n = 0;
    for (int i = 0; i < 1000000000; ++i)
    {
        n = summer.Sum(n, i);
    }
    Console.WriteLine("Vtable call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n);

    Summer summer2 = new Summer();
    sw = Stopwatch.StartNew();
    n = 0;
    for (int i = 0; i < 1000000000; ++i)
    {
        n = summer.Sum(n, i);
    }
    Console.WriteLine("Non-vtable call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n);

    Func<int, int, int> sumdel = (a, b) => a + b;
    sw = Stopwatch.StartNew();
    n = 0;
    for (int i = 0; i < 1000000000; ++i)
    {
        n = sumdel(n, i);
    }
    Console.WriteLine("Delegate call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n);

    sw = Stopwatch.StartNew();
    n = 0;
    for (int i = 0; i < 1000000000; ++i)
    {
        n = Sum(n, i);
    }
    Console.WriteLine("Static call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n);
}

結果:

Vtable call took 2714 ms, result = -1243309312
Non-vtable call took 2558 ms, result = -1243309312
Delegate call took 1904 ms, result = -1243309312
Static call took 324 ms, result = -1243309312

ここで興味深いのは、実は最新のテスト結果です。call静的呼び出し (IL ) は完全に決定論的であることを思い出してください。つまり、コンパイラ向けに最適化するのは比較的簡単なことです。アセンブラーの出力を調べると、Sum の呼び出しが実際にはインライン化されていることがわかります。意味あり。実際にテストすると、コードをメソッドに入れるだけで、静的呼び出しと同じくらい高速になります。

Equals についてのちょっとしたコメント

ハッシュテーブルのパフォーマンスを測定すると、私の説明では何か怪しいように見えます。あたかもIEquatable<T>物事が速くなるかのように見えます。

まあ、それは実際には本当です。:-) ハッシュ コンテナはIEquatable<T>を呼び出すために使用しますEquals。さて、ご存知のように、オブジェクトはすべて を実装していEquals(object o)ます。したがって、コンテナは または を呼び出すことができEquals(object)ますEquals(T)。通話自体のパフォーマンスは同じです。

ただし、 も実装する場合IEquatable<T>、実装は通常次のようになります。

bool Equals(object o)
{
    var obj = o as MyType;
    return obj != null && this.Equals(obj);
}

さらに、MyTypeが構造体の場合、ランタイムはボックス化とボックス化解除も適用する必要があります。を呼び出すだけの場合IEquatable<T>、これらの手順は必要ありません。そのため、速度が遅いように見えますが、これは呼び出し自体とは何の関係もありません。

あなたの質問

この場合、Where 句から ListOfDates.Max() の評価を移動することの (パフォーマンス) 利点はありますか、または 1. コンパイラまたは 2. JIT がこれを最適化しますか?

はい、利点があります。コンパイラ/JIT はそれを最適化しません。

私は、C# はコンパイル時にのみ定数の折りたたみを行うと考えており、ListOfDates 自体が何らかの形で定数でない限り、ListOfDates.Max() はコンパイル時に認識できないと主張できます。

実際、静的呼び出しを に変更するn = 2 + Sum(n, 2)と、アセンブラーの出力に4. これは、JIT オプティマイザーが一定の折り畳みを行うことを証明しています。(SSA オプティマイザーがどのように機能するかを知っていれば、これは実際には非常に明白です... const の折りたたみと単純化が数回呼び出されます)。

関数ポインター自体は最適化されていません。今後もあるかもしれませんが。

おそらく、これが一度だけ評価されるようにする別のコンパイラ (または JIT) の最適化があるでしょうか?

「別のコンパイラ」に関しては、「別の言語」を追加する意思がある場合は、C++ を使用できます。C++ では、これらの種類の呼び出しが最適化されていないことがあります。

さらに興味深いことに、Clang は LLVM に基づいており、LLVM 用の C# コンパイラもいくつかあります。Mono には LLVM に最適化するオプションがあり、CoreCLR は LLILC に取り組んでいたと思います。私はこれをテストしていませんが、LLVM は間違いなくこの種の最適化を行うことができます。

于 2016-04-09T20:37:45.190 に答える