为什么从Expression<Func<>>创建的Func<>比直接声明的Func<>慢?

24

为什么通过 .Compile() 从 Expression<Func<>> 创建的 Func<> 比直接声明的 Func<> 运行速度慢得多?

我在正在开发的应用程序中切换了使用从 Expression<Func<IInterface, object>> 创建的 Func<IInterface, object>,结果发现性能下降了。

我进行了一个小测试,从表现上来看,从 Expression 创建的 Func<> 运行时间是直接声明的 Func<> 的“几乎”两倍。

在我的机器上,直接声明的 Func<> 大约需要 7.5 秒钟,而 Expression<Func<>> 则需要大约 12.6 秒钟。

下面是我使用的测试代码(运行于 Net 4.0)

// Direct
Func<int, Foo> test1 = x => new Foo(x * 2);

int counter1 = 0;

Stopwatch s1 = new Stopwatch();
s1.Start();
for (int i = 0; i < 300000000; i++)
{
 counter1 += test1(i).Value;
}
s1.Stop();
var result1 = s1.Elapsed;



// Expression . Compile()
Expression<Func<int, Foo>> expression = x => new Foo(x * 2);
Func<int, Foo> test2 = expression.Compile();

int counter2 = 0;

Stopwatch s2 = new Stopwatch();
s2.Start();
for (int i = 0; i < 300000000; i++)
{
 counter2 += test2(i).Value;
}
s2.Stop();
var result2 = s2.Elapsed;



public class Foo
{
 public Foo(int i)
 {
  Value = i;
 }
 public int Value { get; set; }
}

如何提高代码性能?

是否有什么方法可以让从 Expression<Func<>> 创建的 Func<> 性能与直接声明的函数一样好呢?


2
有趣的问题;实际上,对于直接情况,我得到了接近4倍的性能。 - Marc Gravell
3
反思并阅读每个机制生成的IL代码可能会让人有所启发。 - cdhowie
1
@cdhowie 我无法为这个构建一个反汇编 :| http://dotnetpad.net/ViewPaste/_Vx1bk-DVkqxCcSU1HE8tw# - jcolebrand
生成的IL代码只有2个字节稍有不同。一个原因是参数不同(ldarg0与ldarg1),另一个原因是令牌不同,因为它们托管在不同的模块中。 - Michael B
感谢所有的好答案。选择哪一个接受并不总是容易的 :) Gabe 还写了一个如何提高性能的例子,这也是我的问题的一部分,并且得到了最多的赞,所以我已经接受了他的答案。 - MartinF
显示剩余5条评论
7个回答

19

正如其他人所提到的,调用动态委托的开销导致了您的减速。在我的电脑上,当我的CPU为3GHz时,这种开销约为12ns。解决方法是从已编译的程序集中加载该方法,像这样:

var ab = AppDomain.CurrentDomain.DefineDynamicAssembly(
             new AssemblyName("assembly"), AssemblyBuilderAccess.Run);
var mod = ab.DefineDynamicModule("module");
var tb = mod.DefineType("type", TypeAttributes.Public);
var mb = tb.DefineMethod(
             "test3", MethodAttributes.Public | MethodAttributes.Static);
expression.CompileToMethod(mb);
var t = tb.CreateType();
var test3 = (Func<int, Foo>)Delegate.CreateDelegate(
                typeof(Func<int, Foo>), t.GetMethod("test3"));

int counter3 = 0;
Stopwatch s3 = new Stopwatch();
s3.Start();
for (int i = 0; i < 300000000; i++)
{
    counter3 += test3(i).Value;
}
s3.Stop();
var result3 = s3.Elapsed;

当我添加上述代码时,result3始终比result1高大约1ns的开销。
那么为什么要使用编译后的lambda表达式(test2),当你可以拥有更快的委托(test3)呢?因为创建动态程序集在一般情况下会有更高的开销,并且每次调用只能节省10-20ns。

2
非常好。我很快地将其封装成扩展方法,我的“速度”得到了提高(增加了约30-40%)。谢谢! :) - MartinF
1
FYI,在.NET 4.5中,我测量到编译表达式和上述编译为方法的方法之间没有任何区别。 - Nick Strupat

6

(这不是一个正确的答案,而是旨在帮助发现答案的材料。)

从Mono 2.6.7 - Debian Lenny - Linux 2.6.26 i686 - 2.80GHz单核收集的统计数据:

      Func: 00:00:23.6062578
Expression: 00:00:23.9766248

在Mono上,这两种机制生成的IL代码看起来是等效的。
以下是Mono的gmcs生成的匿名方法的IL代码:
// method line 6
.method private static  hidebysig
       default class Foo '<Main>m__0' (int32 x)  cil managed
{
    .custom instance void class [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::'.ctor'() =  (01 00 00 00 ) // ....

    // Method begins at RVA 0x2204
    // Code size 9 (0x9)
    .maxstack 8
    IL_0000:  ldarg.0
    IL_0001:  ldc.i4.2
    IL_0002:  mul
    IL_0003:  newobj instance void class Foo::'.ctor'(int32)
    IL_0008:  ret
} // end of method Default::<Main>m__0

我将致力于提取表达式编译器生成的IL代码。

1
我的担忧是,Mono运行时与.Net运行时不够相似,因此比较并没有什么用处。 - Gabe
Mono使用reflection.emit来编译C#,因此由表达式树生成的代码也同样快速。 - Michael B
@Michael:你的意思是编译后的表达式树在Mono上运行缓慢,还是编译后的程序集运行快? - cdhowie
@Gabe:这个方法的IL应该足够简单,以至于两个运行时都能将匿名方法和表达式树编译成相同的IL。以上的IL已经是最优化的了。我并不打算从表达式树中提取编译后的IL,因为这不是本问题的目标。但以上的IL可以作为一个很好的参考进行比较。(此外,目前我只能使用Mono。) - cdhowie
我已经修改了我的答案,以展示两个表达式的IL。我认为Mono可能实际上有一个优势,因为C#动态模块标记解析器在动态方法中有很多类型时做得不好,因为它进行迭代,而不是像Mono编译器那样进行某种形式的查找。不过不要引用我。 - Michael B

4
最终的问题是,Expression<T>不是预编译的委托,它只是一个表达式树。在LambdaExpression上调用Compile(Expression<T>实际上就是这个)将在运行时生成IL代码,并为其创建类似于DynamicMethod的东西。
如果你只是在代码中使用Func<T>,它会像任何其他委托引用一样进行预编译。
因此,这里有两个缓慢的来源:
1. 编译Expression<T>成委托的初始编译时间。这非常巨大。如果您每次调用都要这样做-绝对不要这样做(但这不是情况,因为您在调用Compile之后使用了Stopwatch)。
2. 在调用Compile之后,它基本上是DynamicMethod。即使是强类型的委托也比直接调用执行速度慢。在动态发射IL和编译时发射的IL之间有性能比较。随机URL:http://www.codeproject.com/KB/cs/dynamicmethoddelegates.aspx?msg=1160046 此外,在测试Expression<T>的Stopwatch时,应在i = 1时启动计时器,而不是0。我相信你的编译Lambda将不会被JIT编译,直到第一次调用,因此第一次调用会有性能损失。

虽然你对秒表的看法是正确的,但在这种情况下它并不相关,因为 JIT 编译 lambda 只需要微秒级别的时间(在我的电脑上可能只需要 3 微秒)。 - Gabe
真的。现在仍然是一个众所周知的事实,动态生成的方法调用速度比预编译的方法慢。 - Jeff
1
顺便说一下,这并不总是正确的!我曾经编写过委托,并将它们重写为表达式,因为编译后的表达式执行速度几乎快了一倍。 - Michael B
1
Michael B:JeffN825说动态方法调用速度较慢(在我的机器上大约慢了12ns),而不是它们执行得更慢。换句话说,函数调用开销更高。 - Gabe
@Jeff,你能否解释一下当“将Expression<T>编译成委托的初始编译时间”在循环外部且未包含在测量中时,如何减慢执行速度? - Ark-kun
显示剩余3条评论

1

这很可能是因为代码的第一次调用没有被JIT编译。 我决定查看IL,它们几乎完全相同。

Func<int, Foo> func = x => new Foo(x * 2);
Expression<Func<int, Foo>> exp = x => new Foo(x * 2);
var func2 = exp.Compile();
Array.ForEach(func.Method.GetMethodBody().GetILAsByteArray(), b => Console.WriteLine(b));

var mtype = func2.Method.GetType();
var fiOwner = mtype.GetField("m_owner", BindingFlags.Instance | BindingFlags.NonPublic);
var dynMethod = fiOwner.GetValue(func2.Method) as DynamicMethod;
var ilgen = dynMethod.GetILGenerator();


byte[] il = ilgen.GetType().GetMethod("BakeByteArray", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(ilgen, null) as byte[];
Console.WriteLine("Expression version");
Array.ForEach(il, b => Console.WriteLine(b));

这段代码获取了字节数组并将它们打印到控制台。以下是在我的机器上的输出:
2
24
90
115
13
0
0
6
42
Expression version
3
24
90
115
2
0
0
6
42

这是反射器对第一个函数的版本:

   L_0000: ldarg.0 
    L_0001: ldc.i4.2 
    L_0002: mul 
    L_0003: newobj instance void ConsoleApplication7.Foo::.ctor(int32)
    L_0008: ret 

整个方法中只有2个字节不同!它们是第一个操作码,即第一个方法的ldarg0(加载第一个参数),但在第二个方法中是ldarg1(加载第二个参数)。这里的区别是因为生成的表达式对象实际上具有Closure对象的目标。这也可能是一个因素。

两者的下一个操作码都是ldc.i4.2(24),这意味着将2加载到堆栈上,下一个是mul(90)的操作码,下一个操作码是newobj(115)的操作码。接下来的4个字节是.ctor对象的元数据标记。它们不同,因为这两种方法实际上托管在不同的程序集中。匿名方法位于匿名程序集中。不幸的是,我还没有完全弄清楚如何解析这些标记。最后的操作码是42,即ret。每个CLI函数必须以ret结束,即使是不返回任何内容的函数。

有几种可能性,闭包对象在某种程度上导致事情变慢,这可能是真的(但不太可能),Jitter没有jit该方法,并且由于您正在快速旋转连续发射,因此它没有时间jit该路径,从而调用较慢的路径。 VS中的C#编译器也可能会发出不同的调用约定和MethodAttributes,这可能作为提示,让Jitter执行不同的优化。

最终,我甚至不会稍微担心这种差异。如果您确实在应用程序过程中调用了30亿次函数,并且产生的差异是5秒钟,那么您可能会没问题。


1
你是在暗示编译一个包含5条指令的函数需要几秒钟的时间吗? - Gabe
我同意你的观点,认为我不应该太在意这个微小的差异。但是当你编写一段将根据其性能进行评估的软件时,像这样的测试用例将被用来与其他竞争对手进行比较,如果你严重依赖委托并突然看到性能下降了30-40%,那么这确实很重要。幸运的是,获取表达式使我有可能优化 lambda 中正在发生的事情,并使其比直接方法更快。 - MartinF
向上级销售的更大卖点是,表达式方法允许你比手动实现更容易地实现模式和委托。因此,虽然我们都同意手工调整的 C# 可能更好,但如果您需要为每种类型或更糟的情况编写特定代码,则手动编写无数个案例将需要大量开发工作,而您可以编写相当优化的表达式树,可以动态生成代码以在运行时调整行为。 - Michael B

1
仅供记录:我可以使用上面的代码复制这些数字。
需要注意的一件事是,每次迭代时,两个委托都会创建一个新的Foo实例。这可能比如何创建委托更重要。这不仅会导致大量堆分配,而且GC也可能影响这里的数字。
如果我将代码更改为
Func<int, int> test1 = x => x * 2;

Expression<Func<int, int>> expression = x => x * 2;
Func<int, int> test2 = expression.Compile();

性能数字几乎相同(实际上,result2比result1稍微好一些)。这支持了一个理论,即昂贵的部分是堆分配和/或收集,而不是委托的构造方式。

更新

根据Gabe的评论,我尝试将Foo更改为结构体。不幸的是,这产生了与原始代码几乎相同的数字,因此堆分配/垃圾收集可能并不是问题的原因。

但是,我还验证了Func<int,int>类型的委托的数字,它们非常相似,并且远低于原始代码的数字。

我会继续深入挖掘,并期待看到更多/更新的答案。


谢谢您的回复。我也注意到了这种行为,并在我的问题下面写了一个注释。顺便说一下,不错的博客 :) - MartinF
1
我将 Foo 从类(class)改为结构体(struct),并且注意到两种情况下的时间都减少了1秒,但相对差异并没有减少。我怀疑你测量的可能不是你想要的。 - Gabe
@Gabe:我使用了问题中的代码,并按照我的答案更改了声明。我还确保在测量时间之前每个委托都被调用了一次。我会尝试使用结构体并更新我的答案。 - Brian Rasmussen
1
我认为你的 Func<int,int> 数字较低是因为 JIT 编译器可以进行一些优化(内联、寄存器分配),这些优化并不适用于所有情况。 - Gabe
@Gabe:很有可能是这样。下一步是比较两者之间的JIT编译代码。 - Brian Rasmussen

0
我把这个放进BenchmarkDotNet来得到一些更可靠的数据。据我所知,在.NET 7上,ExpressionFunc稍微快一点。这个答案可能解释了原因。经过一些重复的基准测试运行,我得到了以下典型结果:
方法 平均值 误差 标准差
函数 4.274 纳秒 0.1302 纳秒 0.1447 纳秒
表达式 3.598 纳秒 0.1055 纳秒 0.1903 纳秒

这是我的硬件和软件:

BenchmarkDotNet v0.13.9+228a464e8be6c580ad9408e98f18813f6407fb5a, Windows 11 (10.0.22631.2338)
AMD Ryzen 9 5900HX with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100-rc.1.23455.8
  [Host]     : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2

这是基准代码:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq.Expressions;

BenchmarkRunner.Run<FuncVsExpression>();

public class FuncVsExpression
{
    private Func<int, Foo> myFunc;
    private Func<int, Foo> myExpressionFunc;

    [GlobalSetup]
    public void Setup()
    {
        this.myFunc = x => new Foo(x * 2);

        Expression<Func<int, Foo>> expression = x => new Foo(x * 2);
        this.myExpressionFunc = expression.Compile();
    }

    [Benchmark]
    public Foo Func() => myFunc(42);

    [Benchmark]
    public Foo Expression() => myExpressionFunc(42);
}

public class Foo
{
    public Foo(int i)
    {
        Value = i;
    }

    public int Value { get; set; }
}

0
我对Michael B.的答案很感兴趣,所以在计时器开始之前,我在每种情况下都添加了额外的调用。在调试模式下,编译(第二种情况)的方法快了近两倍(6秒到10秒),而在发布模式下,两个版本的速度相当(差异约为0.2秒)。
现在,令我惊讶的是,如果将JIT排除在外,我得到的结果与Martin相反。
编辑:最初我错过了Foo,因此上面的结果是针对具有字段而不是属性的Foo进行比较的,使用原始Foo进行比较的结果相同,只是时间更长--直接函数需要15秒,编译版本需要12秒。同样,在发布模式下,时间相似,现在差异约为0.5。
然而,这表明,如果您的表达式更复杂,即使在发布模式下也会有真正的差异。

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