编译为委托表达式的性能

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在他关于此主题的博客文章中的代码,并对手动计算素数的版本与编译的表达式树进行基准测试时,它们在性能上完全相同。
当调试视图中的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);
}

第二种方法比手动执行慢是可以理解的,因为它涉及到字典查找和一些对象实例化,但第三种方法应该与手动执行一样快,因为它调用的是原始委托,并且从DelegateFunc的转换发生在循环外部。
我也尝试将手动代码封装成一个函数,但我记得没有明显的区别。无论如何,函数调用不应该增加数量级的开销。
我还做了两次基准测试,以确保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 存储库中的代码,确认我的测量结果,并确保我没有发疯 :)


2
需要看到完整的使用方式。例如,你是如何“调用”委托的?(这很重要) - Marc Gravell
2
你是否将手动编写的代码封装在委托中,并以与生成的代码相同的方式调用它? - Steven
2
运行在发布模式下和调试模式下(或连接调试器与否)是否会有性能差异? - Markus Johnsson
1
@Marc Gravell,一旦我有了一个 Action,除了 Invoke() 之外还有什么更快的方法吗? - steinberg
1
@steinberg 当我发表那条评论时,还没有代码……我曾经看到过人们使用DynamicInvoke,并期望它很快;但这在你的情况下并不适用。 - Marc Gravell
显示剩余6条评论
5个回答

20
这对于如此庞大的开销来说相当奇怪。有几件事情需要考虑。首先,VS编译的代码应用了不同的属性,可能会影响JIT编译器的不同优化方式。
您是否包括编译后委托的第一次执行在这些结果中?您不应该这样做,应该忽略任何一个代码路径的第一次执行。您还应该将普通代码转换为委托,因为委托调用比调用实例方法稍微慢一些,而调用静态方法比调用实例方法更慢。
至于其他更改,需要考虑编译后的委托具有未在此处使用的闭包对象,但这意味着这是一个有针对性的委托,可能会表现得稍微慢一些。您会注意到编译后的委托具有目标对象,并且所有参数都向下移动了一个位置。
此外,由LCG生成的方法被认为是静态方法,与实例方法相比,编译为委托时 tend to be slower,因为涉及寄存器切换操作。(Duffy表示,“this”指针在CLR中具有保留寄存器,当您为静态方法创建委托时,它必须移到不同的寄存器中,从而引发轻微的开销)。最后,运行时生成的代码似乎比VS生成的代码运行稍微慢一些。在运行时生成的代码似乎具有额外的沙箱保护,并且是从不同的程序集启动的(如果您不相信我,请尝试使用类似于ldftn opcode或calli opcode的东西,这些反射.emited委托将编译但不会让您实际执行它们),这会引发一些开销。

您正在运行发布模式,对吗? 这里有一个类似的主题,我们在这里讨论了这个问题: 为什么从Expression<Func<>>创建的Func<>比直接声明的Func<>慢?

编辑: 还请查看我的答案: DynamicMethod比编译的IL函数慢得多

最重要的是,您应该将以下代码添加到计划创建和调用运行时生成的代码的程序集中。

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

请始终使用内置委托类型或来自具有这些标志的程序集中的一个委托类型。原因是匿名动态代码托管在始终标记为部分信任的程序集中。通过允许部分信任调用方,您可以跳过握手的一部分。透明度意味着您的代码不会提高安全级别(即减慢行为),最后真正的技巧是调用托管在标记为跳过验证的程序集中的委托类型。Func<int,int>#Invoke是完全受信任的,因此不需要验证。这将为您提供从VS编译器生成的代码的性能。如果不使用这些属性,则在.NET 4中会产生开销。您可能认为SecurityRuleSet.Level1是避免此开销的好方法,但切换安全模型也很昂贵。

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


感谢您的回答。我运行了两次基准测试,以排除JIT开销。最让我感到奇怪的是,当编译时,那篇博客文章中相当复杂的质数表达式树与手写的表达式树在性能上是完全相同的。我按照您回答中的链接进行了操作,非常有帮助,详见我问题的编辑 :) - JulianR

3
您可以通过Reflection.Emit手动编译表达式树,这通常可以提供更快的编译时间(在我的情况下,大约快了30倍),并且允许您调整发射结果的性能。如果您的表达式是有限已知子集,则这样做不会太难。
具体想法是使用ExpressionVisitor遍历表达式,并为相应的表达式类型发出IL代码。编写自己的Visitor来处理已知的表达式子集也是“相当”简单的,对于尚未支持的表达式类型,则可以回退到正常的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
运行已发出的表达式 5000000 次:722

在手动编译时,“36 vs 814”。
完整代码请参见此处

3

是的,那就是我达成的妥协。但它不能用于非公共类型或泛型类型(底层使用 System.__Canon ,这是 internal 的),这是一个缺点,但我只需检测这些类型并使用较慢的编译版本。如果不是因为那个同样快速的质数函数,我可能会接受仅在表达式上调用 Compile 时遇到一些开销的事实。抱歉,但我会将奖励授予 Michael B,因为我通过他更早地找到了答案,但还是谢谢 :) - JulianR
@JulianR:你不是每次运行表达式都在调用Compile,对吧? - Gabe
不 :) 这样会比现在慢很多。 - JulianR

2

1
有趣的阅读,谢谢。但我不确定你所说的“是的,它是使用反射完成的”是什么意思。我知道编译过程以某种方式使用类型元数据,但我测量的是结果,就像您在我的问题中看到的那样,只是普通的 IL。 - JulianR

1

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

关于第三个解决方案:Lambda 表达式也需要在运行时进行评估,这同样需要时间。而且这并不少...

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

在这里查看我的代码示例。如果你不想手动编码,那么这可能是最快的解决方案:http://jachman.wordpress.com/2006/08/22/2000-faster-using-dynamic-method-calls/


4
但是在调用委托时,我没有使用反射。表达式树是使用反射构建的,但它被编译为一个委托,应该被JIT编译以生成快速代码。我可能会接受JIT编译器不花费太多时间来优化它,但使用Bart de Smet的表达式编写的更加复杂的质数代码与正常版本一样快。所以它可以与正常版本一样快,但为什么我的不是呢? - JulianR
当然,不是在第三个解决方案中。但此时,JIT编译器必须评估Lambda表达式。正如您已经指出的那样,这就是评估表达式树的开销。确实是这样。我已经为其他一些对象映射问题实现了IQueryable接口,并且在从代码调用lambda时您看不到的调用数量真的超乎想象。 - BitKFu
1
不,第三个基准测试使用编译的委托。此外,开销“仅仅”是10倍,如果是纯反射,那么这个开销将会更大得多。例如,我相信 AutoMapper 库在其映射中确实使用了反射,但从我的测试结果来看,它比手动映射慢了400倍。 - JulianR

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接