具有动态变量如何影响性能?


127

我对dynamicC#的性能有疑问。我读过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));
    }

不,它不会运行编译器,这会使它在第一次通过时就变慢。类似于Reflection,但具有很多智能,可以跟踪以前做过的事情,以最大程度地减少开销。Google的“动态语言运行时”提供了更多的见识。不,它永远不会接近“本地”循环的速度。
汉斯·帕桑

Answers:


232

我读过动态使编译器再次运行,但它能做什么。它是否必须使用动态参数作为参数重新编译整个方法,还是使用动态行为/上下文(?)

这是交易。

对于程序中动态类型的每个表达式,编译器都会发出代码,该代码生成代表操作的单个“动态调用站点对象”。因此,例如,如果您有:

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多少次,我们都会生成一次呼叫站点。一旦生成一次,呼叫站点将永久存在。呼叫站点是一个对象,表示“这里将是对Foo的动态呼叫”。

好,现在您已经有了呼叫站点,调用如何工作?

呼叫站点是动态语言运行时的一部分。DLR表示:“嗯,有人试图对此here对象进行方法foo的动态调用。我对此一无所知吗?不。那么我最好找出来。”

然后DLR会查询d1中的对象,看是否有什么特别之处。可能是旧版COM对象,Iron Python对象,Iron Ruby对象或IE DOM对象。如果不是这些,则它必须是普通的C#对象。

这是编译器再次启动的点。不需要词法分析器或解析器,因此DLR启动了C#编译器的特殊版本,该版本仅具有元数据分析器,表达式的语义分析器以及发出表达式树而不是IL的发射器。

元数据分析器使用Reflection确定d1中对象的类型,然后将其传递给语义分析器以询问在方法Foo上调用该对象时会发生什么。重载分辨率分析器会找出这一点,然后构建一个表达式树(就像您在表达式树lambda中调用Foo一样)来表示该调用。

然后,C#编译器将该表达式树与缓存策略一起传递回DLR。该策略通常是“第二次看到这种类型的对象时,您可以重新使用此表达式树,而无需再次给我回电”。然后DLR在表达式树上调用Compile,后者会调用表达式树到IL的编译器,并在委托中吐出一块动态生成的IL。

然后,DLR将此委托缓存在与呼叫站点对象关联的缓存中。

然后,它调用委托,然后进行Foo调用。

第二次您致电M,我们已经有一个呼叫站点。DLR再次询问该对象,如果该对象与上次的类型相同,则它将委托从缓存中取出并调用它。如果对象的类型不同,则高速缓存将丢失,并且整个过程将重新开始;否则,整个过程将重新开始。我们对调用进行语义分析,并将结果存储在缓存中。

对于涉及动态的每个表达式都会发生这种情况。因此,例如,如果您有:

int x = d1.Foo() + d2;

那么就有三个动态呼叫站点。一种用于对Foo的动态调用,一种用于动态加法,一种用于从dynamic到int的动态转换。每个人都有自己的运行时分析和自己的分析结果缓存。

合理?


只是出于好奇,通过将特殊标志传递给标准csc​​.exe来调用不带解析器/语法分析器的特殊编译器版本?
罗曼·罗伊特2011年

@Eric,能麻烦您指出我以前的博客文章,其中谈论short,int等的隐式转换吗?我记得您在其中提到过如何/为什么在Convert.ToXXX中使用dynamic导致编译器启动。我敢肯定,我正在努力处理细节,但希望您知道我在说什么。
亚当·拉基斯

4
@Roman:否。csc.exe用C ++编写,我们需要可以从C#轻松调用的东西。同样,主线编译器具有自己的类型对象,但是我们需要能够使用反射类型对象。我们从csc.exe编译器中提取了C ++代码的相关部分,并将它们逐行转换为C#,然后在其中构建了一个库供DLR调用。
埃里克·利珀特

9
@Eric,“我们从csc.exe编译器中提取了C ++代码的相关部分,并将它们逐行转换为C#”,是因为当时人们认为罗斯林可能值得追求:)
ShuggyCoUk 2011年

5
@ShuggyCoUk:将编译器作为服务的想法已经出现了一段时间,但是实际上需要运行时服务来执行代码分析是该项目的一大动力,是的。
埃里克·利珀特

107

更新:添加了预编译和延迟编译的基准

更新2:事实证明,我错了。有关完整且正确的答案,请参见Eric Lippert的帖子。为了基准数据,我将其保留在此处

*更新3:基于Mark Gravell对这个问题的回答,添加了IL发射和惰性IL发射基准。

据我所知,使用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种不同的方式调用一个简单的no-op方法:

  1. 直接方法调用
  2. 使用 dynamic
  3. 通过反思
  4. 使用Action在运行时进行预编译的,从而从结果中排除编译时间。
  5. 使用Action第一次需要时编译的,使用非线程安全的Lazy变量(因此包括编译时间)
  6. 使用在测试之前创建的动态生成的方法。
  7. 使用在测试过程中延迟实例化的动态生成的方法。

在一个简单的循环中,每个调用一百万次。计时结果如下:

直接:3.4248ms
动态:45.0728ms
反射:888.4011ms
预编译:21.9166ms
Lazy 编译:30.2045ms
ILEmitted:8.4918ms
LazyILEmitted:14.3483ms

因此,虽然使用dynamic关键字花费的时间比直接调用该方法长一个数量级,但它仍设法在约50毫秒内完成一百万次操作,这比反射速度要快得多。如果我们调用的方法试图做一些密集的事情,例如将几个字符串组合在一起或在一个集合中搜索一个值,那么这些操作可能会远远超过直接调用和dynamic调用之间的差异。

性能只是不要使用dynamic不必要的许多好理由之一,但是当您处理真正的dynamic数据时,性能所带来的好处远大于缺点。

更新4

根据Johnbot的评论,我将反射区域分为四个独立的测试:

    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();
    }),

...这是基准测试结果:

在此处输入图片说明

因此,如果可以预先确定需要大量调用的特定方法,则引用该方法的缓存委托的调用与调用该方法本身一样快。但是,如果您需要确定要调用的方法,就必须为其创建委托,这非常昂贵。


2
这么详细的回复,谢谢!我也想知道实际数字。
谢尔盖·西罗特金

4
好吧,动态代码启动了编译器的元数据导入器,语义分析器和表达式树发射器,然后在其输出上运行了一个表达式树到il的编译器,所以我认为可以说它开始了在运行时启动编译器。仅仅因为它不运行词法分析器,而解析器似乎几乎不相关。
埃里克·利珀特

6
您的性能数据肯定显示了DLR积极的缓存策略是如何得到回报的。如果您的示例做了一些愚蠢的事情,例如每次调用时都有不同的接收类型,那么您会发现动态版本在无法利用其先前编译的分析结果缓存时非常慢。但是当它可以利用这一点时,圣洁的善良就永远如此。
埃里克·利珀特

1
按照埃里克(Eric)的建议,有些愚蠢。通过交换注释哪一行进行测试。8964ms vs 814ms,dynamic当然会输:public class ONE<T>{public object i { get; set; }public ONE(){i = typeof(T).ToString();}public object make(int ix){ if (ix == 0) return i;ONE<ONE<T>> x = new ONE<ONE<T>>();/*dynamic x = new ONE<ONE<T>>();*/return x.make(ix - 1);}}ONE<END> x = new ONE<END>();string lucky;Stopwatch sw = new Stopwatch();sw.Start();lucky = (string)x.make(500);sw.Stop();Trace.WriteLine(sw.ElapsedMilliseconds);Trace.WriteLine(lucky);
Brian

1
公平地反思并从方法信息中创建一个委托:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.