Func 的性能和inheritance

在使用inheritance和generics时,我一直无法理解在整个代码中使用Func的性能特征 – 这是我发现自己一直使用的组合。

让我从一个最小的测试用例开始,这样我们都知道我们在谈论什么,然后我会发布结果,然后我将解释我期望的内容以及为什么……

最小的测试用例

 public class GenericsTest2 : GenericsTest { static void Main(string[] args) { GenericsTest2 at = new GenericsTest2(); at.test(at.func); at.test(at.Check); at.test(at.func2); at.test(at.Check2); at.test((a) => a.Equals(default(int))); Console.ReadLine(); } public GenericsTest2() { func = func2 = (a) => Check(a); } protected Func func2; public bool Check2(int value) { return value.Equals(default(int)); } public void test(Func func) { using (Stopwatch sw = new Stopwatch((ts) => { Console.WriteLine("Took {0:0.00}s", ts.TotalSeconds); })) { for (int i = 0; i < 100000000; ++i) { func(i); } } } } public class GenericsTest { public bool Check(T value) { return value.Equals(default(T)); } protected Func func; } public class Stopwatch : IDisposable { public Stopwatch(Action act) { this.act = act; this.start = DateTime.UtcNow; } private Action act; private DateTime start; public void Dispose() { act(DateTime.UtcNow.Subtract(start)); } } 

结果

 Took 2.50s -> at.test(at.func); Took 1.97s -> at.test(at.Check); Took 2.48s -> at.test(at.func2); Took 0.72s -> at.test(at.Check2); Took 0.81s -> at.test((a) => a.Equals(default(int))); 

我期待什么,为什么

我希望这些代码能够以完全相同的速度运行所有5种方法,更精确,甚至比任何一种方法都快,即:

 using (Stopwatch sw = new Stopwatch((ts) => { Console.WriteLine("Took {0:0.00}s", ts.TotalSeconds); })) { for (int i = 0; i < 100000000; ++i) { bool b = i.Equals(default(int)); } } // this takes 0.32s ?!? 

我预计它需要0.32s,因为我没有看到任何理由让JIT编译器不在这种特殊情况下内联代码。

仔细观察,我根本不理解这些性能数字:

  • at.func传递给函数,在执行期间无法更改。 为什么不这样内联?
  • at.Check显然比at.Check2快,但两者都不能被覆盖而且在类GenericsTest2的情况下at.Check的IL就像岩石一样固定
  • 我认为当传递内联Func而不是转换为Func的方法时, Func没有理由变慢
  • 为什么测试案例2和3之间的差异高达0.5秒而案例4和5之间的差异为0.1秒 – 他们不应该是相同的吗?

我真的很想理解这一点……在这里发生的事情是,使用通用基类的速度比内联整体慢了10倍?

所以,基本上问题是:为什么会发生这种情况,我该如何解决?

UPDATE

基于到目前为止的所有评论(谢谢!)我做了一些挖掘。

首先,在重复测试并使循环增大5倍并执行4次时获得一组新结果。 我使用了Diagnostics秒表并添加了更多测试(也添加了说明)。

 (Baseline implementation took 2.61s) --- Run 0 --- Took 3.00s for (a) => at.Check2(a) Took 12.04s for Check3 Took 12.51s for (a) => GenericsTest2.Check(a) Took 13.74s for at.func Took 16.07s for GenericsTest2.Check Took 12.99s for at.func2 Took 1.47s for at.Check2 Took 2.31s for (a) => a.Equals(default(int)) --- Run 1 --- Took 3.18s for (a) => at.Check2(a) Took 13.29s for Check3 Took 14.10s for (a) => GenericsTest2.Check(a) Took 13.54s for at.func Took 13.48s for GenericsTest2.Check Took 13.89s for at.func2 Took 1.94s for at.Check2 Took 2.61s for (a) => a.Equals(default(int)) --- Run 2 --- Took 3.18s for (a) => at.Check2(a) Took 12.91s for Check3 Took 15.20s for (a) => GenericsTest2.Check(a) Took 12.90s for at.func Took 13.79s for GenericsTest2.Check Took 14.52s for at.func2 Took 2.02s for at.Check2 Took 2.67s for (a) => a.Equals(default(int)) --- Run 3 --- Took 3.17s for (a) => at.Check2(a) Took 12.69s for Check3 Took 13.58s for (a) => GenericsTest2.Check(a) Took 14.27s for at.func Took 12.82s for GenericsTest2.Check Took 14.03s for at.func2 Took 1.32s for at.Check2 Took 1.70s for (a) => a.Equals(default(int)) 

我从这些结果中注意到,在你开始使用generics的那一刻,它变慢了。 我在非generics实现中发现了更多的IL:

 L_0000: ldarga.s 'value' L_0002: ldc.i4.0 L_0003: call instance bool [mscorlib]System.Int32::Equals(int32) L_0008: ret 

以及所有通用实现:

 L_0000: ldarga.s 'value' L_0002: ldloca.s CS$0$0000 L_0004: initobj !T L_000a: ldloc.0 L_000b: box !T L_0010: constrained. !T L_0016: callvirt instance bool [mscorlib]System.Object::Equals(object) L_001b: ret 

虽然大部分可以优化,但我认为callvirt在这里可能是一个问题。

为了使速度更快,我将“T:IEquatable”约束添加到方法的定义中。 结果是:

 L_0011: callvirt instance bool [mscorlib]System.IEquatable`1::Equals(!0) 

虽然我现在对性能有了更多的了解(它可能无法内联,因为它创建了一个vtable查找),但我仍然感到困惑:为什么它不简单地调用T :: Equals? 毕竟,我确实指出它会在那里……

运行微基准测试总是3次。 第一个将触发JIT并将其排除在外。 检查第二次和第三次运行是否相等。 这给出了:

 ... run ... Took 0.79s Took 0.63s Took 0.74s Took 0.24s Took 0.32s ... run ... Took 0.73s Took 0.63s Took 0.73s Took 0.24s Took 0.33s ... run ... Took 0.74s Took 0.63s Took 0.74s Took 0.25s Took 0.33s 

这条线

 func = func2 = (a) => Check(a); 

添加一个额外的函数调用。 删除它

func = func2 = this.Check;

得到:

 ... 1. run ... Took 0.64s Took 0.63s Took 0.63s Took 0.24s Took 0.32s ... 2. run ... Took 0.63s Took 0.63s Took 0.63s Took 0.24s Took 0.32s ... 3. run ... Took 0.63s Took 0.63s Took 0.63s Took 0.24s Took 0.32s 

这表明由于删除了函数调用,1.和2. run之间的(JIT?)效果消失了。 前三个测试现在是相同的

在测试4和5中,编译器可以将函数参数内联到void test(Func <>),而在测试1到3中,编译器可能需要很长时间才能确定它们是常量。 有时从编码器的角度来看编译器存在一些限制,例如.Net和Jit约束来自.Net程序的动态特性,而不是来自c ++的二进制文件。 无论如何,它是函数arg的内联,在这里产生了不同。

4和5之间的差异? 好吧,test5看起来像编译器也很容易内联函数。 也许他为闭包构建了一个上下文,并且解决它比需要的复杂一点。 没有挖到MSIL弄清楚。

使用.Net 4.5进行上述测试。 这里有3.5,certificate编译器在内联方面做得更好:

 ... 1. run ... Took 1.06s Took 1.06s Took 1.06s Took 0.24s Took 0.27s ... 2. run ... Took 1.06s Took 1.08s Took 1.06s Took 0.25s Took 0.27s ... 3. run ... Took 1.05s Took 1.06s Took 1.05s Took 0.24s Took 0.27s 

和.Net 4:

 ... 1. run ... Took 0.97s Took 0.97s Took 0.96s Took 0.22s Took 0.30s ... 2. run ... Took 0.96s Took 0.96s Took 0.96s Took 0.22s Took 0.30s ... 3. run ... Took 0.97s Took 0.96s Took 0.96s Took 0.22s Took 0.30s 

现在将GenericTest <>更改为GenericTest !!

 ... 1. run ... Took 0.28s Took 0.24s Took 0.24s Took 0.24s Took 0.27s ... 2. run ... Took 0.24s Took 0.24s Took 0.24s Took 0.24s Took 0.27s ... 3. run ... Took 0.25s Took 0.25s Took 0.25s Took 0.24s Took 0.27s 

这是C#编译器的一个惊喜,类似于我遇到的密封类以避免虚函数调用。 也许Eric Lippert对此有所说明?

将inheritance移除到聚合会带来性能。 我学会了永远不会使用inheritance,非常非常非常,并且强烈建议你至少在这种情况下避免使用它。 (这是我对这个问题的务实解决方案,没有预期的火焰战争)。 我一直使用接口很难,它们没有性能损失。

我将解释我的想法以及所有仿制药。 我需要一些空间来写,所以我发布这个作为答案。 感谢大家的评论和帮助解决这个问题,我将确保在这里和那里奖励积分。

开始…

编译generics

众所周知,generics是“模板”类型,编译器在运行时填写类型信息。 它可以基于约束做出假设,但它不会改变IL代码……(但稍后会更多)。

我的问题的方法:

 public class Foo { public void bool Handle(T foo) { return foo.Equals(default(T)); } } 

这里的约束是T是一个Object ,这意味着对Equals的调用将转向Object.Equals。 由于T正在实现Object.Equals,因此它将如下所示:

 L_0016: callvirt instance bool [mscorlib]System.Object::Equals(object) 

我们可以通过添加约束T : IEquatable来明确表示T实现Equals ,从而改进这一点。 这会将调用更改为:

 L_0011: callvirt instance bool [mscorlib]System.IEquatable`1::Equals(!0) 

但是,由于T还没有被填充,显然IL不支持直接调用T::Equals(!0) ,即使它肯定存在。 编译器显然只能假设约束已经完成,因此它需要发出对定义方法的IEquatable 1`的调用。

显然像sealed提示并没有什么不同,即使它们应该有。

结论:由于不支持T::Equals(!0) ,因此需要进行vtable查找才能使其正常工作。 一旦它成为一个callvirt ,JIT编译器很难弄清楚它应该刚刚使用了一个call

应该发生什么:当这种方法明显存在时,微软应该支持T::Equals(!0) 。 这会将调用更改为IL中的正常call ,从而使其更快。

但它变得更糟

那么调用Foo :: Handle怎么样?

让我感到惊讶的是,对Foo::Handlecall也是一个callvirt而不是一个call 。 f.ex可以找到相同的行为。 List::Add等等。 我的观察是,只有使用this通话才能成为正常call ; 其他一切都将编译成一个callvirt

结论:行为就像你得到一个类结构,如Foo:Foo:[the rest] ,这没有多大意义。 显然,从该类外部调用generics类将编译vtable查找。

应该发生什么:如果方法是非虚拟的,Microsoft应该将callvirt更改为call 。 Threre对于callvirt来说根本没有理由。

结论

如果您使用其他类型的generics,请准备好获得callvirt而不是call ,即使这不是必需的。 由此产生的性能基本上可以从这样的呼叫中得到…

恕我直言,这是一个真正的耻辱。 类型安全应该可以帮助开发人员,同时使代码更快,因为编译器可以对正在发生的事情做出假设。 我从这一切中吸取的教训是: 不要使用generics,除非你不关心额外的vtable查找(直到微软修复此问题)

未来的工作

首先,我将在Microsoft Connect上发布此消息。 我认为这是.NET中的一个严重错误,它会在没有任何充分理由的情况下耗尽性能。 ( https://connect.microsoft.com/VisualStudio/feedback/details/782346/using-generics-will-always-compile-to-callvirt-even-if-this-is-not-necessary


Microsoft Connect的结果

是的,我们有结果,我非常感谢Mike Danes!

foo.Equals(default(T))的方法调用将编译为Object.Equals(boxed[new !0])因为唯一的等于所有T的共同点是Object.Equals 。 这将导致装箱操作和vtable查找。

如果我们希望事物使用正确的Equals,我们必须给编译器一个提示,即类型实现bool Equals(T) 。 这可以通过告诉编译器类型T实现IEquatable

换句话说:更改类的签名如下:

 public class GenericsTest where T:IEquatable { public bool Check(T value) { return value.Equals(default(T)); } protected Func func; } 

当你这样做时,运行时将找到正确的Equals方法。 呼…

要完全解决这个难题,还需要一个元素:.NET 4.5。 .NET 4.5的运行时能够内联此方法,从而使其尽可能快地再次运行。 在.NET 4.0中(这就是我目前使用的),此function似乎并不存在。 这个调用仍然是IL中的一个callvirt ,但运行时无论如何都会解决这个难题。

如果您测试此代码,它应该与最快的测试用例一样快。 有人可以确认一下吗?