C#泛型性能与接口对比

3
考虑下面的 C# 代码:
interface IFace
{
    void Do();
}

class Foo: IFace
{
    public void Do() { /* some action */ }
}

class Bar
{
    public void A(Foo foo) 
    {
        foo.Do();
    }

    public void B<T>(T foo)
        where T: IFace
    {
        foo.Do();
    }

    public void C(IFace foo)
    {
        foo.Do();
    }

    public void D<T>(T foo)
        where T: class, IFace
    {
        foo.Do();
    }
}

使用如下:
Foo foo = new Foo();
Bar bar = new Bar();

MeasureExecutionTime(() => bar.A(foo), "A");
MeasureExecutionTime(() => bar.B(foo), "B");
MeasureExecutionTime(() => bar.C(foo), "C");
MeasureExecutionTime(() => bar.D(foo), "D");

结果(VS2015,.NET 4.5.2)如下:

A:3,00 ns/op,333.4 mop/s

B:5.74 ns/op,174.3 mop/s

C:5.55 ns/op,180.3 mop/s

D:5.64 ns/op,177.4 mop/s

我想知道为什么在 x86 和 x64 模式下,使用泛型方法 B 完全没有优势,与使用接口相同(就像 C++ 模板 vs 虚拟调用)。甚至泛型方法比非泛型基于接口的方法稍微慢一些(当 B 和 C 测量互换时,这种效果是稳定的并且保持不变)。
附录:MeasureExecutionTime 代码可以在此处找到: https://gist.github.com/anonymous/9d60f5d09868ed3a00ec00f413f6afb0 更新:我已经在 Mono 上测试了代码,结果如下:
andrew@ubuntu-nas:/data/mono/json/x64$ mono Test.exe A: 3.40 ns/op, 294.0 mop/s B: 3.40 ns/op, 293.7 mop/s C: 6.80 ns/op, 147.1 mop/s D: 3.40 ns/op, 294.2 mop/s
可以在这里找到生成的IL代码: https://gist.github.com/anonymous/58df84eda906e83c64ce1b4fdc5497fb MS和Mono生成相同的IL代码,除了方法D。然而,这不能解释方法B的差异。如果我在没有重新编译的情况下在Mono中运行由MS生成的代码,则方法D的结果变为与B相同。

为什么会有任何区别呢?通用实现与您的接口方法具有相同的条件,即提供作为参数的两个实例都必须是IFace类型。where T: IFace给你什么,(IFace foo)没有给你的呢? - MakePeaceGreatAgain
泛型不像C++的模板;它们在编译时不会被编译成使用的形式。 - Mattias Åslund
因为我期望T是一个具体的类Foo,而IFace是一个契约。为什么CLR在运行时知道类型却不生成有效的代码呢? - Andrey Nasonov
@MattiasÅslund,我知道区别。但CLR在编译泛型方法时知道类型。 - Andrey Nasonov
关于信息;在我自己的实现中,我看到了非常不同的结果 - 我得到A比B/C快两倍。我添加了一个D,它是带有T:class, IFace约束的B,它的行为完全相同。在32位和64位的当前CLR下,在发布控制台中运行。脚本:http://pastie.org/10914172 - Marc Gravell
显示剩余3条评论
3个回答

9
我想知道为什么在x86和x64模式下,使用通用方法B与使用接口完全没有优势(就像C++模板vs虚拟调用一样)。
CLR泛型不是C++模板。
模板基本上是一种搜索和替换机制;如果您有10个模板实例,则会生成10个源代码副本,并且所有副本都会编译和优化。这种方式在编译时提高了优化效果,但增加了编译时间和二进制文件大小。
相比之下,泛型由C#编译器编译为IL一次,然后由Jitter为每个泛型实例生成代码。然而,作为一个实现细节,给类型参数提供引用类型的所有实例化都使用相同的生成代码。因此,如果您有一个方法C.M(T t),并且它被调用时T既是string又是IList,则生成的x86(或其他)代码只生成一次并用于两种情况。
因此,无论是虚函数调用还是接口调用都无法避免任何惩罚。 (它们使用类似但略有不同的机制。)例如,如果在方法内调用T.ToString(),则Jitter不会说“哦,我碰巧知道如果T是string,那么ToString是一个标识符;我将省略虚函数调用”,或者内联体,或任何这样的事情。
此优化在减少Jit时间和较小内存使用方面进行交换,以稍微慢一些的调用为代价。
如果这种性能折衷不是您想要的,则不要使用泛型、接口或虚函数调用。

这让我明白了。现在我相信观察到的效果只是由Mono JIT产生的内联所引起的。 - Andrey Nasonov
当代码访问typeof(T)时,它是如何工作的? - Tim
@Tim:typeof是对GetTypeFromHandle的一种语法糖。 - Eric Lippert
@Tim:我意识到我已经在回避问题。我们如何知道要传递给GTFH的句柄是什么?虽然我没有编写那个机制,但我们可以根据经验猜测它的工作原理。C#生成了一个ldtoken T指令。由于即时编译器在即时编译泛型方法的调用者时知道T的值,它可以选择生成代码,将T的值的句柄存储在被调用的泛型函数所知道的位置上。有趣的是,我们可以查看CLR源代码,确切地了解它是如何实现的。 - Eric Lippert
1
我想最简单的实现方式是在底层将句柄作为参数传递。但是,确实很有趣看看它是如何实际完成的。 - Tim

1
如果您编译并查看IL,您会发现泛型版本与非泛型接口版本完全相同,只是首先进行了附加类型约束检查,这使得它稍微慢一些,尽管在实际代码中差异可能可以忽略不计。即使在接口上进行虚拟调用也会产生更大的差异,而干净的代码通常比这里或那里的纳秒更重要。

https://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.constrained(v=vs.110).aspx

Bar.A:
IL_0000:  ldarg.1     
IL_0001:  callvirt    UserQuery+Foo.Do
IL_0006:  ret         

Bar.B:
IL_0000:  ldarga.s    01 
IL_0002:  constrained. 01 00 00 1B 
IL_0008:  callvirt    UserQuery+IFace.Do
IL_000D:  ret         

Bar.C:
IL_0000:  ldarg.1     
IL_0001:  callvirt    UserQuery+IFace.Do
IL_0006:  ret   

在 .net 中,泛型不同于 C++ 中的模板。

我已经查看了生成的汇编代码,并尝试在Mono上运行程序。似乎问题是即使设置了MethodImplOptions.AggressiveInlining,Microsoft CLR也不执行内联操作。 - Andrey Nasonov
1
“约束(constrained)”并不是一个真正的运行时检查;它只是一种提示给JIT的暗示,帮助其优化调用(在某些情况下可以将callvirt替换为call),特别是当使用值类型时。由于JIT是对所有引用类型只编译一次,而对每个值类型都编译一次,所以这个检查不应该有任何影响 - 尤其是在这种情况下T是引用类型,因此约束调用实际上会变成一个普通的callvirt调用。 - Marc Gravell

0

我认为这是因为你同时承担了接口约束检查和泛型本身的开销,相比只传递一个接口类型的参数。不过,这个差别并不是很大。


1
我想在服务器上运行一些计算密集型的代码(大型结构比较和合并),对于我来说,泛型和接口导致的性能下降2-3倍是非常重要的。 - Andrey Nasonov
是的,直接输入更快,但我指的是通用版本和接口版本之间的区别,因为我认为你的问题更多关于这一部分。 - VidasV
问题是为什么当了解具体类型时,CLR不会从IL生成有效的代码。 - Andrey Nasonov

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