【问题标题】:Performance of Func<T> and inheritanceFunc<T> 的性能和继承
【发布时间】:2013-03-27 20:59:32
【问题描述】:

在使用继承和泛型时,我一直无法理解在整个代码中使用Func&lt;...&gt; 的性能特征——这是我发现自己一直在使用的组合。

让我从一个最小的测试用例开始,这样我们都知道我们在说什么,然后我将发布结果,然后我将解释我的预期以及为什么......

最小测试用例

public class GenericsTest2 : GenericsTest<int> 
{
    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<int, bool> func2;

    public bool Check2(int value)
    {
        return value.Equals(default(int));
    }

    public void test(Func<int, bool> 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<T>
{
    public bool Check(T value)
    {
        return value.Equals(default(T));
    }

    protected Func<T, bool> func;
}

public class Stopwatch : IDisposable
{
    public Stopwatch(Action<TimeSpan> act)
    {
        this.act = act;
        this.start = DateTime.UtcNow;
    }

    private Action<TimeSpan> 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.32 秒,因为我看不出 JIT 编译器在这种特殊情况下不内联代码的任何理由。

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

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

问题

我真的很想了解这一点...这是怎么回事,使用泛型基类比内联整个基类慢 10 倍?

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

更新

根据目前所有的 cmets(谢谢!)我做了更多的挖掘工作。

首先,当重复测试并将循环扩大 5 倍并执行 4 次时,会出现一组新结果。我使用了诊断秒表并添加了更多测试(也添加了描述)。

(Baseline implementation took 2.61s)

--- Run 0 ---
Took 3.00s for (a) => at.Check2(a)
Took 12.04s for Check3<int>
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<int>
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<int>
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<int>
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))

我从这些结果中注意到,当您开始使用泛型时,它会变得慢得多。深入挖掘我为非泛型实现找到的 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<!T>::Equals(!0)

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

【问题讨论】:

  • 在您说“调试/发布有帮助”之前,我一直与您同在……您无法在调试器中进行有意义的性能分析。调试器关闭优化器,以便程序更容易调试。
  • 为避免 JIT 问题,在进行测量之前,至少以 test 方法调用您的代理。
  • 我在 Visual Studio 外部运行发布版本时得到了类似的结果 - 最后两个测试要快得多。
  • @EricLippert 我明白,很抱歉造成混乱。显然,我在没有调试器的情况下执行了所有操作 - 当我为 Debug 目标编译时,它只是速度较慢,但​​因素仍然存在。至于秒表 - 它现在应该可以编译,以便您自己查看。
  • 您是否考虑过通过反射器、dotpeek、ilspy 或 ildasm 查看生成的 IL 的差异?

标签: c# .net performance generics inheritance


【解决方案1】:

始终运行 3 次微基准测试。第一个将触发 JIT 并排除它。检查第 2 次和第 3 次运行是否相等。这给出了:

... 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?)效果由于删除了函数调用而消失了。 前 3 个测试现在相等

在测试 4 和 5 中,编译器可以将函数参数内联到 void test(Func),而在测试 1 到 3 中,编译器要确定它们是常量还有很长的路要走。有时,从我们的编码人员的角度来看,编译器存在一些不容易看到的约束,例如来自 .Net 程序的动态特性的 .Net 和 Jit 约束,与由 c++ 生成的二进制文件相比。无论如何,正是函数 arg 的内联在这里有所不同。

4 和 5 之间的区别? 好吧,test5 看起来编译器也可以很容易地内联函数。也许他为闭包构建了一个上下文并解决它比需要的更复杂一些。没有深入了解 MSIL。

使用 .Net 4.5 进行上述测试。这里有 3.5,证明编译器通过内联变得更好:

... 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 对此有意见?

删除对聚合的继承可以恢复性能。我学会了从不使用继承,很少使用,并且强烈建议您至少在这种情况下避免使用它。 (这是我对这个问题的务实解决方案,没有任何火焰战争的意图)。我一直都在使用接口,它们不会带来性能损失。

【讨论】:

  • 这很奇怪,当我尝试完全做到这一点时,这根本不是我得到的时间......你使用的是 .NET 4.0 还是 4.5? (我使用的是 4.0)。另外,我将根据所有 cmets 发布我的一些发现。
  • 当被调用的方法不是虚拟的时,callvirt 被记录为与 call 具有完全相同的语义,除了 callvirt 在顶部进行空检查。这就是 C# 编译器生成 callvirt 而不是调用的原因;因为它知道它需要对接收器进行空检查。否则它必须生成空检查然后调用,这将是更大和更慢的代码。
  • 至于为什么泛型类的性能更差这个问题,我不知道;我不是抖动方面的专家。比较 x86 和 x64 抖动会很有趣,因为它们是完全不同的代码。
  • 事实证明,这个问题与选择正确的 Equals 调用有关(请参阅下面的答案)。至于内联/JIT'ter,事实证明你实际上可以帮助编译器:委托从不内联,但函数通常是内联的。换句话说,如果你将 func 作为参数传递给函数,它会比使用存储在对象中的委托更快......这样做很多,你最终会得到更快的代码(在我的情况下,结果是相当重要)。有关详细信息,请参阅下面的答案和 MS 连接。
  • @StefandeBruijn:如果没有 vtable,callvirt 不会进行 vtable 查找!用 callvirt 调用静态或实例方法是完全合法的;抖动将把它变成一个非虚拟调用,顶部有一个空检查。
【解决方案2】:

我将解释我认为这里发生了什么以及所有泛型。我需要一些空间来写,所以我将其发布为答案。感谢大家的评论和帮助解决这个问题,我会确保在这里和那里奖励积分。

开始...

编译泛型

众所周知,泛型是编译器在运行时填写类型信息的“模板”类型。它可以根据约束做出假设,但不会更改 IL 代码……(稍后会详细介绍)。

我的问题中的一种方法:

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

这里的限制是TObject,这意味着对Equals 的调用将转到Object.Equals。由于 T 正在实现 Object.Equals,这将如下所示:

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

我们可以通过添加约束 T : IEquatable&lt;T&gt; 明确 T 实现 Equals 来改进这一点。这会将调用更改为:

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

但是,由于 T 尚未填写,显然 IL 不支持直接调用 T::Equals(!0),即使它确实存在。编译器显然只能假设约束已经满足,因此它需要调用定义方法的IEquatable1`。

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

结论:因为不支持T::Equals(!0),所以需要vtable查找才能使其工作。一旦它变成了callvirt,JIT 编译器就很难确定它应该只使用call

应该发生什么:基本上微软应该支持T::Equals(!0),当这种方法明确存在时。这会将调用更改为 IL 中的普通 call,使其更快。

但情况会变得更糟

那么调用 Foo::Handle 呢?

令我惊讶的是,对Foo&lt;T&gt;::Handle 的调用也是callvirt 而不是call。可以为 f.ex 找到相同的行为。 List&lt;T&gt;::Add 等等。我的观察是只有使用this 的调用才会变成普通的call;其他所有内容都将编译为callvirt

结论:这种行为就像你得到了一个像Foo&lt;int&gt;:Foo&lt;T&gt;:[the rest] 这样的类结构,这实际上没有任何意义。显然,从该类外部对泛型类的所有调用都将编译一个 vtable 查找。

应该发生什么:如果方法是非虚拟的,Microsoft 应该将 callvirt 更改为 call。 callvirt 真的没有任何理由。

结论

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

恕我直言,这真是太可惜了。类型安全应该帮助开发人员,同时让你的代码更快,因为编译器可以对正在发生的事情做出假设。我从这一切中学到的教训是:不要使用泛型,除非你不关心额外的 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&lt;T&gt; 来完成。

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

public class GenericsTest<T> where T:IEquatable<T>
{
    public bool Check(T value)
    {
        return value.Equals(default(T));
    }

    protected Func<T, bool> func;
}

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

要彻底解决这个难题,还需要一个元素:.NET 4.5。 .NET 4.5 的运行时能够内联此方法,从而使其再次达到应有的速度。在 .NET 4.0(这是我目前正在使用的)中,此功能似乎不存在。在 IL 中调用仍然是 callvirt,但无论如何运行时都会解决这个难题。

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

【讨论】:

  • 太好了,你打开了一个电话,让我们知道新闻。这是关于继承、泛型以及 c# 编译器忽略密封类上可能的性能优化的一般事实。自愿为带有演示案例的 codeplex 或 github 存储库做出贡献。
  • 我知道有一个“原因”,我又找到了! “我们认为能够在 null 实例上调用方法有点奇怪。Peter Golde 做了一些测试,看看总是使用 callvirt 对性能的影响是什么,它足够小,我们决定做出改变。”对不起,伙计们,非常非常糟糕的决定,请修改它。 blogs.msdn.com/b/ericgu/archive/2008/07/02/…
  • @citykid 我从 Microsoft Connect 获得了一些结果。由于我还没有使用 .NET 4.5,但显然你是 - 你能否确认这解决了完整的难题?
猜你喜欢
  • 2013-10-26
  • 1970-01-01
  • 2013-08-14
  • 1970-01-01
  • 2012-06-27
  • 2013-11-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多