web-dev-qa-db-ja.com

動的変数を持つことはパフォーマンスにどのように影響しますか?

C#でのdynamicのパフォーマンスについて質問があります。 dynamicを読んでコンパイラを再び実行しますが、それは何をしますか?

パラメータとして使用されるdynamic変数を使用してメソッド全体を再コンパイルする必要がありますか、または動的な動作/コンテキストを持つ行のみを再コンパイルする必要がありますか?

dynamic変数を使用すると、単純なforループが2桁遅くなることに気付きました。

私が遊んだコード:

internal class Sum2
{
    public int intSum;
}

internal class Sum
{
    public dynamic DynSum;
    public int intSum;
}

class Program
{
    private const int ITERATIONS = 1000000;

    static void Main(string[] args)
    {
        var stopwatch = new Stopwatch();
        dynamic param = new Object();
        DynamicSum(stopwatch);
        SumInt(stopwatch);
        SumInt(stopwatch, param);
        Sum(stopwatch);

        DynamicSum(stopwatch);
        SumInt(stopwatch);
        SumInt(stopwatch, param);
        Sum(stopwatch);

        Console.ReadKey();
    }

    private static void Sum(Stopwatch stopwatch)
    {
        var sum = 0;
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }

    private static void SumInt(Stopwatch stopwatch)
    {
        var sum = new Sum();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.intSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Class Sum int Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }

    private static void SumInt(Stopwatch stopwatch, dynamic param)
    {
        var sum = new Sum2();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.intSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Class Sum int Elapsed {0} {1}", stopwatch.ElapsedMilliseconds, param.GetType()));
    }

    private static void DynamicSum(Stopwatch stopwatch)
    {
        var sum = new Sum();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.DynSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(String.Format("Dynamic Sum Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }
117
Lukasz Madon

私は、コンパイラがコンパイラを再び実行するように動的に読み取りましたが、それは何をします。パラメータとして使用されるダイナミックを使用してメソッド全体を再コンパイルする必要がありますか、それとも動的な動作/コンテキスト(?)を含む行を再コンパイルする必要がありますか?

これが取引です。

プログラム内の動的タイプのすべてのexpressionに対して、コンパイラは、操作を表す単一の「動的呼び出しサイトオブジェクト」を生成するコードを生成します。たとえば、次の場合:

class C
{
    void M()
    {
        dynamic d1 = whatever;
        dynamic d2 = d1.Foo();

コンパイラーは道徳的にこのようなコードを生成します。 (実際のコードはかなり複雑です。これは表示のために単純化されています。)

class C
{
    static DynamicCallSite FooCallSite;
    void M()
    {
        object d1 = whatever;
        object d2;
        if (FooCallSite == null) FooCallSite = new DynamicCallSite();
        d2 = FooCallSite.DoInvocation("Foo", d1);

これがこれまでどのように機能するかを参照してください? Mを何度呼び出しても、呼び出しサイトを1回生成します。呼び出しサイトは、一度生成すると永久に残ります。呼び出しサイトは、「ここでFooを動的に呼び出す」ことを表すオブジェクトです。

さて、呼び出しサイトを取得したので、呼び出しはどのように機能しますか?

呼び出しサイトは、動的言語ランタイムの一部です。 DLRは、「うーん、誰かがこのfooオブジェクトのメソッドfooの動的呼び出しを試みています。それについて何か知っていますか?いいえ。それから私は見つけた方がよいでしょう」と言います。

次に、DLRはd1のオブジェクトに問い合わせて、それが特別なものであるかどうかを確認します。たぶん、それはレガシーCOMオブジェクト、またはIron Pythonオブジェクト、またはIron Rubyオブジェクト、またはIE DOMオブジェクト:これらのいずれでもない場合、通常のC#オブジェクトである必要があります。

これは、コンパイラが再び起動するポイントです。レクサーやパーサーは必要ありません。そのため、DLRは、メタデータアナライザー、式のセマンティックアナライザー、およびILの代わりに式ツリーを出力するエミッターのみを備えた特別なバージョンのC#コンパイラーを起動します。

メタデータアナライザーは、Reflectionを使用してd1のオブジェクトのタイプを判別し、それをセマンティックアナライザーに渡して、そのようなオブジェクトがメソッドFooで呼び出されたときに何が起こるかを尋ねます。オーバーロード解決アナライザーはそれを把握し、式ツリーラムダでFooを呼び出したかのように、その呼び出しを表す式ツリーを構築します。

その後、C#コンパイラは、その式ツリーをキャッシュポリシーとともにDLRに返します。通常、ポリシーは「このタイプのオブジェクトを2回目に表示した場合、再度コールバックするのではなく、この式ツリーを再利用できます」です。次に、DLRは式ツリーでCompileを呼び出します。これは、式ツリーからILコンパイラを呼び出し、動的に生成されたILのブロックをデリゲートに吐き出します。

次に、DLRはこのデリゲートを、呼び出しサイトオブジェクトに関連付けられたキャッシュにキャッシュします。

次に、デリゲートを呼び出し、Foo呼び出しが発生します。

Mを2回目に呼び出すと、すでに呼び出しサイトがあります。 DLRはオブジェクトに再度問い合わせを行い、オブジェクトが前回と同じタイプである場合、デリゲートをキャッシュからフェッチして呼び出します。オブジェクトのタイプが異なる場合、キャッシュが失われ、プロセス全体が再び開始されます。呼び出しのセマンティック分析を行い、結果をキャッシュに保存します。

これは、ダイナミックを伴うすべての式で発生します。たとえば、次の場合:

int x = d1.Foo() + d2;

次に、three動的呼び出しサイトがあります。 1つはFooへの動的呼び出し、1つは動的な追加、もう1つは動的からintへの動的な変換です。それぞれに独自のランタイム分析と分析結果のキャッシュがあります。

理にかなっていますか?

219
Eric Lippert

更新:プリコンパイル済みベンチマークとレイジーコンパイル済みベンチマークを追加

更新2:判明しました、私は間違っています。完全で正しい答えについては、Eric Lippertの投稿を参照してください。ベンチマークの数値のためにここに置いておきます

*更新3: この質問に対するMark Gravellの回答 に基づいて、IL-EmittedおよびLazy IL-Emittedベンチマークを追加しました。

私の知る限り、dynamicキーワードを使用しても、実行時およびそれ自体で余分なコンパイルは発生しません(動的変数をサポートしているオブジェクトのタイプに応じて、特定の状況で実行できると思いますが) 。

パフォーマンスに関して、dynamicは本質的にいくらかのオーバーヘッドをもたらしますが、あなたが考えているほどではありません。たとえば、次のようなベンチマークを実行しました。

void Main()
{
    Foo foo = new Foo();
    var args = new object[0];
    var method = typeof(Foo).GetMethod("DoSomething");
    dynamic dfoo = foo;
    var precompiled = 
        Expression.Lambda<Action>(
            Expression.Call(Expression.Constant(foo), method))
        .Compile();
    var lazyCompiled = new Lazy<Action>(() =>
        Expression.Lambda<Action>(
            Expression.Call(Expression.Constant(foo), method))
        .Compile(), false);
    var wrapped = Wrap(method);
    var lazyWrapped = new Lazy<Func<object, object[], object>>(() => Wrap(method), false);
    var actions = new[]
    {
        new TimedAction("Direct", () => 
        {
            foo.DoSomething();
        }),
        new TimedAction("Dynamic", () => 
        {
            dfoo.DoSomething();
        }),
        new TimedAction("Reflection", () => 
        {
            method.Invoke(foo, args);
        }),
        new TimedAction("Precompiled", () => 
        {
            precompiled();
        }),
        new TimedAction("LazyCompiled", () => 
        {
            lazyCompiled.Value();
        }),
        new TimedAction("ILEmitted", () => 
        {
            wrapped(foo, null);
        }),
        new TimedAction("LazyILEmitted", () => 
        {
            lazyWrapped.Value(foo, null);
        }),
    };
    TimeActions(1000000, actions);
}

class Foo{
    public void DoSomething(){}
}

static Func<object, object[], object> Wrap(MethodInfo method)
{
    var dm = new DynamicMethod(method.Name, typeof(object), new Type[] {
        typeof(object), typeof(object[])
    }, method.DeclaringType, true);
    var il = dm.GetILGenerator();

    if (!method.IsStatic)
    {
        il.Emit(OpCodes.Ldarg_0);
        il.Emit(OpCodes.Unbox_Any, method.DeclaringType);
    }
    var parameters = method.GetParameters();
    for (int i = 0; i < parameters.Length; i++)
    {
        il.Emit(OpCodes.Ldarg_1);
        il.Emit(OpCodes.Ldc_I4, i);
        il.Emit(OpCodes.Ldelem_Ref);
        il.Emit(OpCodes.Unbox_Any, parameters[i].ParameterType);
    }
    il.EmitCall(method.IsStatic || method.DeclaringType.IsValueType ?
        OpCodes.Call : OpCodes.Callvirt, method, null);
    if (method.ReturnType == null || method.ReturnType == typeof(void))
    {
        il.Emit(OpCodes.Ldnull);
    }
    else if (method.ReturnType.IsValueType)
    {
        il.Emit(OpCodes.Box, method.ReturnType);
    }
    il.Emit(OpCodes.Ret);
    return (Func<object, object[], object>)dm.CreateDelegate(typeof(Func<object, object[], object>));
}

コードからわかるように、7つの異なる方法で単純なノーオペレーションメソッドを呼び出そうとします。

  1. 直接メソッド呼び出し
  2. dynamicを使用する
  3. 反射によって
  4. 実行時にプリコンパイルされたActionを使用します(結果からコンパイル時間を除外します)。
  5. スレッドセーフではないLazy変数を使用して、最初に必要なときにコンパイルされるActionを使用する(したがって、コンパイル時間を含む)
  6. テストの前に作成される動的に生成されたメソッドを使用します。
  7. テスト中に遅延インスタンス化される動的に生成されたメソッドを使用します。

それぞれが単純なループで100万回呼び出されます。タイミングの結果は次のとおりです。

ダイレクト:3.4248ms
動的:45.0728ms
反射:888.4011ms
プリコンパイル済み:21.9166ms
LazyCompiled:30.2045ms
ILEmitted:8.4918ms
LazyILEmitted:14.3483ms

したがって、dynamicキーワードを使用すると、メソッドを直接呼び出すよりも桁違いに長くなりますが、約50ミリ秒で何百万回も操作を完了することができ、リフレクションよりもはるかに高速になります。呼び出すメソッドが、いくつかの文字列を組み合わせたり、値のコレクションを検索したりするような集中的な処理を行おうとした場合、これらの操作は直接呼び出しとdynamic呼び出しの違いをはるかに上回ります。

パフォーマンスはdynamicを不必要に使用しない多くの理由の1つにすぎませんが、真にdynamicデータを処理する場合、欠点をはるかに上回る利点を提供できます。

更新4

Johnbotのコメントに基づいて、Reflectionエリアを4つの個別のテストに分解しました。

    new TimedAction("Reflection, find method", () => 
    {
        typeof(Foo).GetMethod("DoSomething").Invoke(foo, args);
    }),
    new TimedAction("Reflection, predetermined method", () => 
    {
        method.Invoke(foo, args);
    }),
    new TimedAction("Reflection, create a delegate", () => 
    {
        ((Action)method.CreateDelegate(typeof(Action), foo)).Invoke();
    }),
    new TimedAction("Reflection, cached delegate", () => 
    {
        methodDelegate.Invoke();
    }),

...ベンチマーク結果は次のとおりです。

enter image description here

したがって、多くの呼び出しが必要な特定のメソッドを事前に決定できる場合、そのメソッドを参照するキャッシュされたデリゲートの呼び出しは、メソッド自体の呼び出しとほぼ同じ速さです。ただし、呼び出しようとしているときに呼び出すメソッドを決定する必要がある場合、そのメソッドのデリゲートを作成するのは非常に高価です。

99