ChatGPT解决这个技术问题 Extra ChatGPT

动态变量如何影响性能?

我对 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));
    }
不,它不运行编译器,这会使它在第一遍时惩罚缓慢。有点类似于反射,但有很多智能来跟踪之前所做的事情以最小化开销。谷歌“动态语言运行时”以获得更多洞察力。不,它永远不会接近“原生”循环的速度。

E
Eric Lippert

我读过动态使编译器再次运行,但它做了什么。它是否必须使用作为参数的动态重新编译整个方法,或者更确切地说是那些具有动态行为/上下文的行(?)

这是交易。

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

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 的发射器。

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

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

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

然后它调用委托,发生 Foo 调用。

第二次打电话给 M 时,我们已经有了一个调用站点。 DLR 再次询问对象,如果对象与上次的类型相同,它会从缓存中取出委托并调用它。如果对象属于不同类型,则缓存未命中,整个过程重新开始;我们对调用进行语义分析并将结果存储在缓存中。

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

int x = d1.Foo() + d2;

然后是三个动态调用站点。一种用于动态调用 Foo,一种用于动态加法,一种用于从 dynamic 到 int 的动态转换。每个都有自己的运行时分析和自己的分析结果缓存。

说得通?


@Roman:不。csc.exe 是用 C++ 编写的,我们需要可以从 C# 轻松调用的东西。此外,主线编译器有自己的类型对象,但我们需要能够使用反射类型对象。我们从 csc.exe 编译器中提取了 C++ 代码的相关部分,并将它们逐行翻译成 C#,然后从中构建一个库供 DLR 调用。
@Eric,“我们从 csc.exe 编译器中提取了 C++ 代码的相关部分,并将它们逐行翻译成 C#”,当时人们认为 Roslyn 可能值得追求 :)
@ShuggyCoUk:编译器即服务的想法已经存在了一段时间,但实际上需要运行时服务来进行代码分析是该项目的一大推动力,是的。
@NikolaMalešević:缓存是一本字典。对不起,如果我暗示不是;这不是我的意图。
@NikolaMalešević:更一般地说,不同策略有多个可能的缓存。有一个“为这些类型的参数缓存这个调用站点”策略,有一个“缓存这个调用站点,但只针对这些特定参数”策略,有一个“不缓存这个”策略,也许还有更多从现在到我最后一次查看该代码的十五年里,我已经忘记了。
C
Community

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

更新2:原来,我错了。有关完整且正确的答案,请参阅 Eric Lippert 的帖子。为了基准数字,我将其留在这里

*更新 3:添加了基于 Mark Gravell's answer to this question 的 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>));
}

从代码中可以看出,我尝试以七种不同的方式调用一个简单的无操作方法:

直接方法调用使用动态通过反射使用在运行时预编译的操作(因此从结果中排除编译时间)。使用第一次需要编译的动作,使用非线程安全的惰性变量(因此包括编译时间)使用在测试之前创建的动态生成的方法。使用在测试期间延迟实例化的动态生成的方法。

每个都在一个简单的循环中被调用 100 万次。以下是计时结果:

直接:3.4248ms 动态:45.0728ms 反射:888.4011ms 预编译:21.9166ms LazyCompiled: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();
    }),

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

https://i.stack.imgur.com/ufSE0.png

因此,如果您可以预先确定需要大量调用的特定方法,则调用引用该方法的缓存委托与调用方法本身的速度差不多。但是,如果您需要在即将调用它时确定要调用哪个方法,那么为它创建一个委托是非常昂贵的。


这么详细的回复,谢谢!我也想知道实际数字。
好吧,动态代码启动编译器的元数据导入器、语义分析器和表达式树发射器,然后在其输出上运行表达式树到 IL 编译器,所以我认为可以说它启动在运行时启动编译器。仅仅因为它不运行词法分析器并且解析器似乎几乎不相关。
您的性能数据肯定显示了 DLR 的积极缓存策略是如何获得回报的。如果您的示例做了一些愚蠢的事情,例如,如果您每次调用时都有不同的接收类型,那么当动态版本无法利用其先前编译的分析结果缓存时,您会发现它非常慢.但是,当它可以利用这一点时,天哪,它永远很快。
根据埃里克的建议,有些愚蠢。通过交换注释的行进行测试。 8964 毫秒与 814 毫秒,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);
公平地反映并从方法信息中创建一个委托:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);