13

継承とジェネリックを使用するときにコード全体で使用するパフォーマンス特性を理解するのに苦労していFunc<...>ます。これは、私が常に使用している組み合わせです。

最小限のテストケースから始めて、私たちが話していることを理解してから、結果を投稿してから、期待されることとその理由を説明します...

最小限のテストケース

public class GenericsTest2 : GenericsTest<int> 
{
    static void Main(string[] args)
    {
        GenericsTest2 at = new GenericsTest2();

        at.test(at.func);
        at.test(at.Check);
        at.test(at.func2);
        at.test(at.Check2);
        at.test((a) => a.Equals(default(int)));
        Console.ReadLine();
    }

    public GenericsTest2()
    {
        func = func2 = (a) => Check(a);
    }

    protected Func<int, bool> func2;

    public bool Check2(int value)
    {
        return value.Equals(default(int));
    }

    public void test(Func<int, bool> func)
    {
        using (Stopwatch sw = new Stopwatch((ts) => { Console.WriteLine("Took {0:0.00}s", ts.TotalSeconds); }))
        {
            for (int i = 0; i < 100000000; ++i)
            {
                func(i);
            }
        }
    }
}

public class GenericsTest<T>
{
    public bool Check(T value)
    {
        return value.Equals(default(T));
    }

    protected Func<T, bool> func;
}

public class Stopwatch : IDisposable
{
    public Stopwatch(Action<TimeSpan> act)
    {
        this.act = act;
        this.start = DateTime.UtcNow;
    }

    private Action<TimeSpan> act;
    private DateTime start;

    public void Dispose()
    {
        act(DateTime.UtcNow.Subtract(start));
    }
}

結果

Took 2.50s  -> at.test(at.func);
Took 1.97s  -> at.test(at.Check);
Took 2.48s  -> at.test(at.func2);
Took 0.72s  -> at.test(at.Check2);
Took 0.81s  -> at.test((a) => a.Equals(default(int)));

私が期待することとその理由

私は、このコードが 5 つのメソッドすべてでまったく同じ速度で実行されることを期待していました。より正確には、これよりもさらに速く、つまり次のように高速です。

using (Stopwatch sw = new Stopwatch((ts) => { Console.WriteLine("Took {0:0.00}s", ts.TotalSeconds); }))
{
    for (int i = 0; i < 100000000; ++i)
    {
        bool b = i.Equals(default(int));
    }
}
// this takes 0.32s ?!?

この特定のケースで JIT コンパイラーがコードをインライン化しない理由が見当たらないので、0.32 秒かかると予想しました。

よく調べてみると、これらのパフォーマンスの数値がまったくわかりません。

  • at.func関数に渡され、実行中に変更することはできません。これがインライン化されていないのはなぜですか?
  • at.Checkは明らかに より高速ですがat.Check2、どちらもオーバーライドできず、クラス GenericsTest2 の場合の at.Check の IL は岩のように固定されています。
  • に変換されたメソッドの代わりにFunc<int, bool>インラインを渡すときに遅くなる理由はわかりませんFuncFunc
  • そして、なぜテスト ケース 2 と 3 の差はなんと 0.5 秒なのに対し、ケース 4 と 5 の差は 0.1 秒なのでしょうか。

質問

私は本当にこれを理解したいと思います...ジェネリック基本クラスを使用すると、全体をインライン化するよりもなんと10倍も遅いということは、ここで何が起こっているのでしょうか?

したがって、基本的な質問は、なぜこれが起こっているのか、どうすれば修正できるのかということです。

アップデート

これまでのすべてのコメントに基づいて (ありがとう!)、さらに掘り下げました。

まず、テストを繰り返し、ループを 5 倍大きくして 4 回実行したときの新しい結果セット。診断ストップウォッチを使用し、さらにテストを追加しました (説明も追加しました)。

(Baseline implementation took 2.61s)

--- Run 0 ---
Took 3.00s for (a) => at.Check2(a)
Took 12.04s for Check3<int>
Took 12.51s for (a) => GenericsTest2.Check(a)
Took 13.74s for at.func
Took 16.07s for GenericsTest2.Check
Took 12.99s for at.func2
Took 1.47s for at.Check2
Took 2.31s for (a) => a.Equals(default(int))
--- Run 1 ---
Took 3.18s for (a) => at.Check2(a)
Took 13.29s for Check3<int>
Took 14.10s for (a) => GenericsTest2.Check(a)
Took 13.54s for at.func
Took 13.48s for GenericsTest2.Check
Took 13.89s for at.func2
Took 1.94s for at.Check2
Took 2.61s for (a) => a.Equals(default(int))
--- Run 2 ---
Took 3.18s for (a) => at.Check2(a)
Took 12.91s for Check3<int>
Took 15.20s for (a) => GenericsTest2.Check(a)
Took 12.90s for at.func
Took 13.79s for GenericsTest2.Check
Took 14.52s for at.func2
Took 2.02s for at.Check2
Took 2.67s for (a) => a.Equals(default(int))
--- Run 3 ---
Took 3.17s for (a) => at.Check2(a)
Took 12.69s for Check3<int>
Took 13.58s for (a) => GenericsTest2.Check(a)
Took 14.27s for at.func
Took 12.82s for GenericsTest2.Check
Took 14.03s for at.func2
Took 1.32s for at.Check2
Took 1.70s for (a) => a.Equals(default(int))

これらの結果から、ジェネリックを使用し始めた瞬間に、速度が大幅に低下することに気付きました。非ジェネリック実装で見つけた IL をもう少し掘り下げます。

L_0000: ldarga.s 'value'
L_0002: ldc.i4.0 
L_0003: call instance bool [mscorlib]System.Int32::Equals(int32)
L_0008: ret 

そして、すべての一般的な実装について:

L_0000: ldarga.s 'value'
L_0002: ldloca.s CS$0$0000
L_0004: initobj !T
L_000a: ldloc.0 
L_000b: box !T
L_0010: constrained. !T
L_0016: callvirt instance bool [mscorlib]System.Object::Equals(object)
L_001b: ret 

これのほとんどは最適化callvirtできますが、ここで問題になる可能性があると思います。

高速化するために、メソッドの定義に 'T : IEquatable' 制約を追加しました。結果は次のとおりです。

L_0011: callvirt instance bool [mscorlib]System.IEquatable`1<!T>::Equals(!0)

パフォーマンスについては理解できましたが (vtable ルックアップを作成するため、おそらくインライン化できません)、まだ混乱しています: 単純に T::Equals を呼び出さないのはなぜですか? 結局のところ、私それがそこにあると明記しています...

4

2 に答える 2

8

マイクロ ベンチマークを常に 3 回実行します。最初のものは JIT をトリガーし、それを除外します。2 回目と 3 回目が等しいかどうかを確認します。これは与える:

... run ...
Took 0.79s
Took 0.63s
Took 0.74s
Took 0.24s
Took 0.32s
... run ...
Took 0.73s
Took 0.63s
Took 0.73s
Took 0.24s
Took 0.33s
... run ...
Took 0.74s
Took 0.63s
Took 0.74s
Took 0.25s
Took 0.33s

この線

func = func2 = (a) => Check(a);

追加の関数呼び出しを追加します。によってそれを削除します

func = func2 = this.Check;

与えます:

... 1. run ...
Took 0.64s
Took 0.63s
Took 0.63s
Took 0.24s
Took 0.32s
... 2. run ...
Took 0.63s
Took 0.63s
Took 0.63s
Took 0.24s
Took 0.32s
... 3. run ...
Took 0.63s
Took 0.63s
Took 0.63s
Took 0.24s
Took 0.32s

これは、関数呼び出しを削除したことにより、1. と 2. の実行の間の (JIT?) 効果がなくなったことを示しています。最初の 3 つのテストが等しくなりました。

テスト 4 と 5 では、コンパイラは関数の引数を void test(Func<>) にインライン化できますが、テスト 1 から 3 では、それらが定数であることをコンパイラが把握するには長い道のりが必要です。C++ で作成されたバイナリと比較して、.Net プログラムの動的な性質に起因する .Net および Jit の制約など、コーダーの観点からは簡単に確認できないコンパイラーへの制約が存在する場合があります。いずれにせよ、ここで違いを生むのは関数 arg のインライン化です。

4と5の違い?さて、test5 は、コンパイラーが関数を非常に簡単にインライン化できるように見えます。おそらく、彼はクロージャーのコンテキストを構築し、それを必要以上に複雑に解決します。把握するために MSIL を掘り下げませんでした。

上記の.Net 4.5でのテスト。ここでは 3.5 を使用して、インライン化によってコンパイラが改善されたことを示しています。

... 1. run ...
Took 1.06s
Took 1.06s
Took 1.06s
Took 0.24s
Took 0.27s
... 2. run ...
Took 1.06s
Took 1.08s
Took 1.06s
Took 0.25s
Took 0.27s
... 3. run ...
Took 1.05s
Took 1.06s
Took 1.05s
Took 0.24s
Took 0.27s

および.Net 4:

... 1. run ...
Took 0.97s
Took 0.97s
Took 0.96s
Took 0.22s
Took 0.30s
... 2. run ...
Took 0.96s
Took 0.96s
Took 0.96s
Took 0.22s
Took 0.30s
... 3. run ...
Took 0.97s
Took 0.96s
Took 0.96s
Took 0.22s
Took 0.30s

GenericTest<> を GenericTest に変更しました!!

... 1. run ...
Took 0.28s
Took 0.24s
Took 0.24s
Took 0.24s
Took 0.27s
... 2. run ...
Took 0.24s
Took 0.24s
Took 0.24s
Took 0.24s
Took 0.27s
... 3. run ...
Took 0.25s
Took 0.25s
Took 0.25s
Took 0.24s
Took 0.27s

これは C# コンパイラからの驚きであり、仮想関数呼び出しを回避するためにクラスをシールする際に遭遇したのと同様です。多分エリック・リッパートはそれについて言葉を持っていますか?

アグリゲーションへの継承を削除すると、パフォーマンスが回復します。私は継承を決して使用しないことを学びました.OKは非常にめったにありません.少なくともこの場合はそれを避けることを強くお勧めします. (これは、この質問に対する私の実用的な解決策であり、フレームワークは意図されていません)。私はインターフェイスをずっとタフに使用しており、パフォーマンスの低下はありません。

于 2013-03-28T10:55:11.937 に答える
3

ここで、すべてのジェネリックで何が起こっていると思うかを説明します。書くスペースが必要だったので、これを回答として投稿します。コメントしてこれを理解するのを手伝ってくれてありがとう、私はあちこちでポイントを授与するようにします.

始めるには...

ジェネリックのコンパイル

ご存知のように、ジェネリックは、コンパイラが実行時に型情報を入力する「テンプレート」型です。制約に基づいて仮定を立てることができますが、IL コードは変更されません (ただし、これについては後で詳しく説明します)。

私の質問からの方法:

public class Foo<T>
{
    public void bool Handle(T foo) 
    {
        return foo.Equals(default(T));
    }
}

ここでの制約は です。これTObject、への呼び出しがEqualsObject.Equals に行くことを意味します。T は Object.Equals を実装しているため、次のようになります。

L_0016: callvirt instance bool [mscorlib]System.Object::Equals(object)

制約を追加してT実装することを明示することで、これを改善できます。これにより、呼び出しが次のように変更されます。EqualsT : IEquatable<T>

L_0011: callvirt instance bool [mscorlib]System.IEquatable`1<!T>::Equals(!0)

ただし、T はまだ入力されていないため、ILT::Equals(!0)は確かに存在するにもかかわらず、直接呼び出しをサポートしていないようです。コンパイラは明らかに、制約が満たされているとしか想定できないためIEquatable、メソッドを定義する 1` への呼び出しを発行する必要があります。

どうやらsealed違いはありませんが、違いはありません。

結論:T::Equals(!0)はサポートされていないため、機能させるには vtable ルックアップが必要です。になるとcallvirt、JIT コンパイラーがcall.

何が起こるべきか: 基本的T::Equals(!0)に、この方法が明らかに存在する場合、Microsoft はサポートする必要があります。callこれにより、呼び出しが ILの通常の呼び出しに変更され、はるかに高速になります。

しかし、それは悪化します

では、Foo::Handle を呼び出すとどうなるでしょうか。

私が驚いたのは、 への呼び出しFoo<T>::Handleも であり、callvirtではないことcallです。f.ex についても同じ動作が見られます。List<T>::Add等々。私の観察では、 を使用thisする呼び出しだけが通常になるということでしたcall。他のすべては としてコンパイルされますcallvirt

結論: のようなクラス構造を取得したかのように動作しますがFoo<int>:Foo<T>:[the rest]、これはあまり意味がありません。どうやら、そのクラスの外部からのジェネリック クラスへのすべての呼び出しは、vtable ルックアップをコンパイルします。

どうなるか:メソッドが非仮想の場合、 Microsoft はcallvirtを a に変更する必要があります。callcallvirt を使用する理由はまったくありません。

結論

別の型のジェネリックを使用する場合は、必要がない場合でも、 a のcallvirt代わりに aを取得する準備をしてください。call結果として得られるパフォーマンスは、基本的にそのような呼び出しから期待できるものです...

私見これは本当に残念です。タイプ セーフは開発者を支援すると同時に、何が起こっているかについてコンパイラが推測できるため、コードを高速化する必要があります。これらすべてから学んだ私の教訓は次のとおりです。余分な vtable ルックアップを気にしない限り、ジェネリックを使用しないでください (Microsoft がこれを修正するまで)

これからの仕事

まず、これを Microsoft Connect に投稿します。これは、正当な理由もなくパフォーマンスを低下させる .NET の深刻なバグだと思います。( https://connect.microsoft.com/VisualStudio/feedback/details/782346/using-generics-will-always-compile-to-callvirt-even-if-this-is-not-necessary )


Microsoft Connect の結果

はい、結果が出ました。Mike Danes に感謝します!

メソッド呼び出しfoo.Equals(default(T))は にコンパイルされます。Object.Equals(boxed[new !0])これは、すべての T が共通して持っている唯一の等号が であるためですObject.Equals。これにより、ボックス化操作と vtable ルックアップが発生します。

正しい Equals を使用する必要がある場合は、コンパイラにヒントを与える必要があります。つまり、型が implement であるということbool Equals(T)です。Tこれは、型が を実装していることをコンパイラに伝えることで実行できますIEquatable<T>

つまり、クラスのシグネチャを次のように変更します。

public class GenericsTest<T> where T:IEquatable<T>
{
    public bool Check(T value)
    {
        return value.Equals(default(T));
    }

    protected Func<T, bool> func;
}

このようにすると、ランタイムは正しいEqualsメソッドを見つけます。ふぅ…

パズルを完全に解決するには、もう 1 つの要素が必要です: .NET 4.5。.NET 4.5 のランタイムは、このメソッドをインライン化できるため、本来の速度に戻すことができます。.NET 4.0 (私が現在使用しているもの) では、この機能はないようです。呼び出しは引き続きcallvirtIL になりますが、ランタイムは関係なくパズルを解決します。

このコードをテストすると、最速のテスト ケースと同じくらい速くなるはずです。誰かがこれを確認できますか?

于 2013-03-28T12:55:29.213 に答える