编译到委托表达式的性能

Posted

技术标签:

【中文标题】编译到委托表达式的性能【英文标题】:Performance of compiled-to-delegate Expression 【发布时间】:2011-06-30 11:54:31 【问题描述】:

我正在生成将属性从源对象映射到目标对象的表达式树,然后将其编译为Func<TSource, TDestination, TDestination> 并执行。

这是生成的LambdaExpression 的调试视图:

.Lambda #Lambda1<System.Func`3[MemberMapper.Benchmarks.Program+ComplexSourceType,MemberMapper.Benchmarks.Program+ComplexDestinationType,MemberMapper.Benchmarks.Program+ComplexDestinationType]>(
    MemberMapper.Benchmarks.Program+ComplexSourceType $right,
    MemberMapper.Benchmarks.Program+ComplexDestinationType $left) 
    .Block(
        MemberMapper.Benchmarks.Program+NestedSourceType $Complex$955332131,
        MemberMapper.Benchmarks.Program+NestedDestinationType $Complex$2105709326) 
        $left.ID = $right.ID;
        $Complex$955332131 = $right.Complex;
        $Complex$2105709326 = .New MemberMapper.Benchmarks.Program+NestedDestinationType();
        $Complex$2105709326.ID = $Complex$955332131.ID;
        $Complex$2105709326.Name = $Complex$955332131.Name;
        $left.Complex = $Complex$2105709326;
        $left
    

清理它会是:

(left, right) =>

    left.ID = right.ID;
    var complexSource = right.Complex;
    var complexDestination = new NestedDestinationType();
    complexDestination.ID = complexSource.ID;
    complexDestination.Name = complexSource.Name;
    left.Complex = complexDestination;
    return left;

这是映射这些类型的属性的代码:

public class NestedSourceType

  public int ID  get; set; 
  public string Name  get; set; 


public class ComplexSourceType

  public int ID  get; set; 
  public NestedSourceType Complex  get; set; 


public class NestedDestinationType

  public int ID  get; set; 
  public string Name  get; set; 


public class ComplexDestinationType

  public int ID  get; set; 
  public NestedDestinationType Complex  get; set; 

执行此操作的手动代码是:

var destination = new ComplexDestinationType

  ID = source.ID,
  Complex = new NestedDestinationType
  
    ID = source.Complex.ID,
    Name = source.Complex.Name
  
;

问题是,当我编译 LambdaExpression 并对生成的 delegate 进行基准测试时,它比手动版本慢了大约 10 倍。我不知道为什么会这样。整个想法是在没有繁琐的手动映射的情况下获得最大性能。

当我从 Bart de Smet 的 blog post 中获取有关此主题的代码并将计算素数的手动版本与编译的表达式树进行基准测试时,它们的性能完全相同。

LambdaExpression 的调试视图看起来像您所期望的那样时,什么会导致这种巨大的差异?

编辑

根据要求,我添加了我使用的基准:

public static ComplexDestinationType Foo;

static void Benchmark()


  var mapper = new DefaultMemberMapper();

  var map = mapper.CreateMap(typeof(ComplexSourceType),
                             typeof(ComplexDestinationType)).FinalizeMap();

  var source = new ComplexSourceType
  
    ID = 5,
    Complex = new NestedSourceType
    
      ID = 10,
      Name = "test"
    
  ;

  var sw = Stopwatch.StartNew();

  for (int i = 0; i < 1000000; i++)
  
    Foo = new ComplexDestinationType
    
      ID = source.ID + i,
      Complex = new NestedDestinationType
      
        ID = source.Complex.ID + i,
        Name = source.Complex.Name
      
    ;
  

  sw.Stop();

  Console.WriteLine(sw.Elapsed);

  sw.Restart();

  for (int i = 0; i < 1000000; i++)
  
    Foo = mapper.Map<ComplexSourceType, ComplexDestinationType>(source);
  

  sw.Stop();

  Console.WriteLine(sw.Elapsed);

  var func = (Func<ComplexSourceType, ComplexDestinationType, ComplexDestinationType>)
             map.MappingFunction;

  var destination = new ComplexDestinationType();

  sw.Restart();

  for (int i = 0; i < 1000000; i++)
  
    Foo = func(source, new ComplexDestinationType());
  

  sw.Stop();

  Console.WriteLine(sw.Elapsed);

可以理解,第二个比手动执行要慢,因为它涉及字典查找和一些对象实例化,但第三个应该与调用的原始委托和来自 Delegate 的转换一样快到Func 发生在循环之外。

我也尝试将手动代码包装在一个函数中,但我记得它并没有产生明显的差异。无论哪种方式,函数调用都不应该增加一个数量级的开销。

我还进行了两次基准测试,以确保 JIT 不会干扰。

编辑

您可以在此处获取此项目的代码:

https://github.com/JulianR/MemberMapper/

我使用了 Bart de Smet 的博客文章中描述的 Sons-of-Strike 调试器扩展来转储生成的动态方法的 IL:

IL_0000: ldarg.2 
IL_0001: ldarg.1 
IL_0002: callvirt 6000003 ComplexSourceType.get_ID()
IL_0007: callvirt 6000004 ComplexDestinationType.set_ID(Int32)
IL_000c: ldarg.1 
IL_000d: callvirt 6000005 ComplexSourceType.get_Complex()
IL_0012: brfalse IL_0043
IL_0017: ldarg.1 
IL_0018: callvirt 6000006 ComplexSourceType.get_Complex()
IL_001d: stloc.0 
IL_001e: newobj 6000007 NestedDestinationType..ctor()
IL_0023: stloc.1 
IL_0024: ldloc.1 
IL_0025: ldloc.0 
IL_0026: callvirt 6000008 NestedSourceType.get_ID()
IL_002b: callvirt 6000009 NestedDestinationType.set_ID(Int32)
IL_0030: ldloc.1 
IL_0031: ldloc.0 
IL_0032: callvirt 600000a NestedSourceType.get_Name()
IL_0037: callvirt 600000b NestedDestinationType.set_Name(System.String)
IL_003c: ldarg.2 
IL_003d: ldloc.1 
IL_003e: callvirt 600000c ComplexDestinationType.set_Complex(NestedDestinationType)
IL_0043: ldarg.2 
IL_0044: ret 

我不是 IL 方面的专家,但这看起来很简单,也正是您所期望的,不是吗?那为什么这么慢呢?没有奇怪的装箱操作,没有隐藏的实例化,什么都没有。它与上面的表达式树并不完全相同,因为现在还有一个null 检查right.Complex

这是手动版的代码(通过Reflector获取):

L_0000: ldarg.1 
L_0001: ldarg.0 
L_0002: callvirt instance int32 ComplexSourceType::get_ID()
L_0007: callvirt instance void ComplexDestinationType::set_ID(int32)
L_000c: ldarg.0 
L_000d: callvirt instance class NestedSourceType ComplexSourceType::get_Complex()
L_0012: brfalse.s L_0040
L_0014: ldarg.0 
L_0015: callvirt instance class NestedSourceType ComplexSourceType::get_Complex()
L_001a: stloc.0 
L_001b: newobj instance void NestedDestinationType::.ctor()
L_0020: stloc.1 
L_0021: ldloc.1 
L_0022: ldloc.0 
L_0023: callvirt instance int32 NestedSourceType::get_ID()
L_0028: callvirt instance void NestedDestinationType::set_ID(int32)
L_002d: ldloc.1 
L_002e: ldloc.0 
L_002f: callvirt instance string NestedSourceType::get_Name()
L_0034: callvirt instance void NestedDestinationType::set_Name(string)
L_0039: ldarg.1 
L_003a: ldloc.1 
L_003b: callvirt instance void ComplexDestinationType::set_Complex(class NestedDestinationType)
L_0040: ldarg.1 
L_0041: ret 

和我一模一样..

编辑

我点击了 Michael B 关于该主题的回答中的链接。我尝试在接受的答案中实施这个技巧,它奏效了!如果您想总结一下技巧:它会创建一个动态程序集并将表达式树编译为该程序集中的静态方法,并且由于某种原因速度快了 10 倍。这样做的一个缺点是我的基准类是内部的(实际上,公共类嵌套在内部类中),当我尝试访问它们时它抛出异常,因为它们不可访问。似乎没有解决方法,但我可以简单地检测引用的类型是否是内部的,然后决定使用哪种编译方法。

但仍然困扰我的是为什么素数方法 在性能上与编译的表达式树相同。

再次,我欢迎任何人在该 GitHub 存储库中运行代码以确认我的测量结果并确保我没有发疯 :)

【问题讨论】:

我认为 需要查看完整用法。例如,您如何调用委托? (这很重要) 您是否将手动代码包装在委托中并以与生成代码相同的方式调用它? 在发布模式或调试模式下运行(或调试器连接与未连接)是否存在性能差异? @Marc Gravell,一旦我有了一个动作,除了更快的 Invoke() 我还能做什么? @steinberg 当我发表评论时,没有代码......我以前见过人们使用 DynamicInvoke 并期望它很快;这不适用于您的情况。 【参考方案1】:

对于这么大的偷听者来说,这很奇怪。有几件事情需要考虑。首先,VS 编译的代码应用了不同的属性,这些属性可能会影响抖动以进行不同的优化。

您是否在这些结果中包含了已编译委托的第一次执行?你不应该,你应该忽略任何一个代码路径的第一次执行。您还应该将普通代码转换为委托,因为委托调用比调用实例方法稍慢,实例方法比调用静态方法慢。

至于其他更改,有一点需要说明编译的委托有一个闭包对象,这里没有使用它,但这意味着这是一个目标委托,它的执行速度可能会慢一些。您会注意到编译后的委托有一个目标对象,并且所有参数都向下移动了一个。

lcg 生成的方法也被认为是静态的,由于寄存器切换业务,当编译为委托时往往比实例方法慢。 (Duffy 说“this”指针在 CLR 中有一个保留寄存器,当您有一个静态委托时,它必须转移到另一个寄存器,从而产生轻微的开销)。 最后,运行时生成的代码似乎比 VS 生成的代码运行得稍慢。在运行时生成的代码似乎有额外的沙盒,并且是从不同的程序集启动的(如果你不相信我,请尝试使用类似 ldftn 操作码或 calli 操作码,那些反射.emited 委托将编译但不会让你实际执行它们) 调用最小的开销。

你也是在发布模式下运行的吧? 有一个类似的主题,我们在这里查看了这个问题: Why is Func<> created from Expression<Func<>> slower than Func<> declared directly?

编辑: 另请参阅我的答案: DynamicMethod is much slower than compiled IL function

主要内容是您应该将以下代码添加到您计划在其中创建和调用运行时生成的代码的程序集中。

[assembly: AllowPartiallyTrustedCallers]
[assembly: SecurityTransparent]
[assembly: SecurityRules(SecurityRuleSet.Level2,SkipVerificationInFullTrust=true)]

并且始终使用内置委托类型或带有这些标志的程序集中的委托类型。

原因是匿名动态代码托管在始终标记为部分信任的程序集中。通过允许部分信任的呼叫者,您可以跳过部分握手。透明度意味着您的代码不会提高安全级别(即缓慢的行为),最后真正的技巧是调用托管在标记为跳过验证的程序集中的委托类型。 Func&lt;int,int&gt;#Invoke 完全受信任,因此无需验证。这将为您提供从 VS 编译器生成的代码的性能。通过不使用这些属性,您会看到 .NET 4 中的开销。您可能认为 SecurityRuleSet.Level1 是避免这种开销的好方法,但切换安全模型也很昂贵。

简而言之,添加这些属性,然后您的微循环性能测试将运行大致相同。

【讨论】:

感谢您的回答。我运行基准测试两次以排除 JIT 开销。最让我感到奇怪的是,该博客文章中的相当复杂的素数表达式树在编译时与手写的性能相同。我按照您答案中的链接进行操作,这非常有帮助,请参阅我的问题的编辑:)【参考方案2】:

听起来您遇到了调用开销。但是,无论来源如何,如果您的方法在从已编译的程序集中加载时运行得更快,只需将其编译为程序集并加载它!请参阅我在Why is Func<> created from Expression<Func<>> slower than Func<> declared directly? 的回答,了解更多详情。

【讨论】:

是的,这是我已经确定的折衷方案。但它不适用于非公共类型或泛型类型(引擎盖下的泛型类型使用System.__Canon,即internal),这是一个缺点,但我只是检测这些类型并使用较慢的编译版本.我可以接受我只是在表达式上调用Compile 会遇到某种开销,如果不是因为那个同样快的素数函数。抱歉,我要把赏金奖励给迈克尔 B,因为我很快就通过他找到了答案,但是谢谢 :) @JulianR:您不会在每次运行表达式时都调用Compile,是吗? 否 :) 它会比现在慢得多。【参考方案3】:

检查这些链接以查看编译 LambdaExpression 时会发生什么(是的,它是使用反射完成的)

    http://msdn.microsoft.com/en-us/magazine/cc163759.aspx#S3 http://blogs.msdn.com/b/ericgu/archive/2004/03/19/92911.aspx

【讨论】:

读起来很有趣,谢谢。但是我不确定您所说的“是的,它是使用反射完成的”是什么意思。我知道编译过程以某种方式使用类型元数据,但我没有测量它的开销,我测量的是结果,正如你在我的问题中看到的那样,这只是简单的 IL。【参考方案4】:

您可以通过Reflection.Emit 手动编译表达式树。它通常会提供更快的编译时间(在我的情况下,速度低于约 30 倍),并允许您调整发出的结果性能。而且这并不难做到,特别是如果您的表达式是有限的已知子集。

想法是使用ExpressionVisitor 来遍历表达式并发出对应表达式类型的IL。编写自己的访问者来处理已知的表达式子集也“非常”简单,并且对于尚不支持的表达式类型回退到正常的Expression.Compile

就我而言,我正在生成委托:

Func<object[], object> createA = state =>
    new A(
        new B(), 
        (string)state[11], 
        new ID[2]  new D1(), new D2() )  
        Prop = new P(new B()), Bop = new B() 
    ;

测试创建相应的表达式树并将其 Expression.Compile 与访问并发出 IL 然后从 DynamicMethod 创建委托进行比较。

结果:

编译表达式 3000 次:814 调用编译表达式 5000000 次:724 从表达式发出 3000 次:36 运行 Emitted Expression 5000000 次:722

手动编译时为 36 与 814。

这里是full code。

【讨论】:

【参考方案5】:

我认为这就是此时使用反射的影响。第二种方法是使用反射来获取和设置值。据我所知,在这一点上,花费时间的不是委托,而是反射。

关于第三种解决方案:还需要在运行时评估 Lambda 表达式,这也需要时间。这不是少数......

因此,您将永远无法像手动复制那样快速获得第二和第三个解决方案。

在此处查看我的代码示例。如果您不想手动编码,请认为这可能是您可以采取的快速解决方案:http://jachman.wordpress.com/2006/08/22/2000-faster-using-dynamic-method-calls/

【讨论】:

但是我在调​​用委托时使用反射。表达式树是使用反射构建的,但它被编译为一个委托,该委托应该被 JIT 编译以生成快速代码。我可以接受 JIT 编译器不会花太多时间来优化它或其他东西,但是使用 Bart de Smet 的表达式的更复杂的素数代码与普通版本一样快。所以它可以一样快,但为什么不是我的? 当然,不是第三种解决方案。但此时,JIT 编译器必须评估 Lambda 表达式。正如您已经指出的,这是评估表达式树的开销。真的是这么多。我已经为其他一些对象映射问题实现了 IQueryable 接口,并且在从代码中调用 lambda 时,确实存在令人难以置信的调用数量。 不,第三个基准测试使用已编译的委托。此外,开销“仅”是 10 倍,如果是纯反射,这将是非常非常多的。例如,我认为使用反射进行映射的 AutoMapper 库比我的测试中的手动映射慢 400 倍。

以上是关于编译到委托表达式的性能的主要内容,如果未能解决你的问题,请参考以下文章

寻找性能更优秀的动态 Getter 和 Setter 方案

C#中的委托,匿名方法和Lambda表达式

表达式树和调用委托

表达式树与委托

func和Expression

18(番外)匿名方法+lambda表达式