问题 可以使用C#泛型来消除虚函数调用吗?


我同时使用C ++和C#,我想到的是,是否可以在C#中使用泛型来消除接口上的虚函数调用。考虑以下:

int Foo1(IList<int> list)
{
    int sum = 0;
    for(int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

int Foo2<T>(T list) where T : IList<int>
{
    int sum = 0;
    for(int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

/*...*/
var l = new List<int>();
Foo1(l);
Foo2(l);

在Foo1内部,每次访问list.Count和list [i]都会导致虚函数调用。如果这是使用模板的C ++,那么在调用Foo2时,编译器将能够看到虚拟函数调用可以被省略和内联,因为具体类型在模板实例化时已知。

但这同样适用于C#和泛型吗?当你调用Foo2(l)时,在编译时就知道T是List,因此list.Count和list [i]不需要涉及虚函数调用。首先,这是一个有效的优化,并没有可怕的破坏?如果是这样,编译器/ JIT是否足够聪明以进行此优化?


13100
2018-05-28 01:58


起源



答案:


这是一个有趣的问题,但不幸的是,你“欺骗”系统的方法不会提高你的程序效率。如果可以,编译器可以相对轻松地为我们做!

你在打电话时是对的 IList<T> 通过接口引用,方法在运行时调度,因此无法内联。因此来电 IList<T> 方法如 Count 并且将通过接口调用索引器。

另一方面,通过将其重写为通用方法,您无法获得任何性能优势(至少不能使用当前的C#编译器和.NET4 CLR)。

为什么不?首先是一些背景。 C#泛型工作是编译器编译具有可替换参数的泛型方法,然后在运行时使用实际参数替换它们。你已经知道了。

但是该方法的参数化版本不再了解变量类型,而是在编译时。在这种情况下,所有编译器都知道 Foo2  就是它 list 是一个 IList<int>。我们在通用中有相同的信息 Foo2 我们在非泛型中做的 Foo1

事实上,为了避免代码膨胀,JIT编译器只生成泛型方法的单个实例。 所有参考类型。这里是 Microsoft文档 描述了这种替换和实例化:

如果客户端指定了引用类型,则JIT编译器将使用Object替换服务器IL中的泛型参数,并将其编译为本机代码。该代码将用于对引用类型的任何进一步请求,而不是泛型类型参数。请注意,这样JIT编译器只重用实际代码。实例仍然根据托管堆的大小进行分配,并且没有强制转换。

这意味着JIT编译器的方法版本(对于引用类型)是 不安全 但这并不重要,因为编译器在编译时确保了所有类型安全性。但更重要的是,对于您的问题,没有任何途径可以执行内联并获得性能提升。

编辑: 最后,根据经验,我刚刚完成了两者的基准测试 Foo1 和 Foo2 它们产生相同的性能结果。换一种说法, Foo2 是  比任何更快 Foo1

让我们添加一个“无法使用”的版本 Foo0 比较:

int Foo0(List<int> list)
{
    int sum = 0;
    for (int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

这是性能比较:

Foo0 = 1719
Foo1 = 7299
Foo2 = 7472
Foo0 = 1671
Foo1 = 7470
Foo2 = 7756

所以你可以看到 Foo0,可以内联,比其他两个快得多。你也可以看到 Foo2 稍微慢一点,而不是在任何接近的地方 Foo0


8
2018-05-28 03:20



非常好。看着IL Foo1 和 Foo0 在LINQPad中很好地显示了差异。在 Foo1 该 callvirt 操作显示调用 IList<System.Int32>get_Item 和 ICollection<System.Int32>.get_Count 而 Foo0的 callvirt 操作是 List<System.Int32>.get_Item 和 List<System.Int32>.get_Count。 Foo2执行的必要性可以解释缓慢 ldarga.s 然后加上前缀 callvirt 同 constrained. 而不仅仅是执行 ldarg.1。 - arcain
这是否意味着您可以通过使结构实现您的接口来欺骗系统,然后在您的泛型方法/类中使用这些结构?由于它们是值类型,运行时将为每个结构类型创建多个具体实现... - Cesar


答案:


这是一个有趣的问题,但不幸的是,你“欺骗”系统的方法不会提高你的程序效率。如果可以,编译器可以相对轻松地为我们做!

你在打电话时是对的 IList<T> 通过接口引用,方法在运行时调度,因此无法内联。因此来电 IList<T> 方法如 Count 并且将通过接口调用索引器。

另一方面,通过将其重写为通用方法,您无法获得任何性能优势(至少不能使用当前的C#编译器和.NET4 CLR)。

为什么不?首先是一些背景。 C#泛型工作是编译器编译具有可替换参数的泛型方法,然后在运行时使用实际参数替换它们。你已经知道了。

但是该方法的参数化版本不再了解变量类型,而是在编译时。在这种情况下,所有编译器都知道 Foo2  就是它 list 是一个 IList<int>。我们在通用中有相同的信息 Foo2 我们在非泛型中做的 Foo1

事实上,为了避免代码膨胀,JIT编译器只生成泛型方法的单个实例。 所有参考类型。这里是 Microsoft文档 描述了这种替换和实例化:

如果客户端指定了引用类型,则JIT编译器将使用Object替换服务器IL中的泛型参数,并将其编译为本机代码。该代码将用于对引用类型的任何进一步请求,而不是泛型类型参数。请注意,这样JIT编译器只重用实际代码。实例仍然根据托管堆的大小进行分配,并且没有强制转换。

这意味着JIT编译器的方法版本(对于引用类型)是 不安全 但这并不重要,因为编译器在编译时确保了所有类型安全性。但更重要的是,对于您的问题,没有任何途径可以执行内联并获得性能提升。

编辑: 最后,根据经验,我刚刚完成了两者的基准测试 Foo1 和 Foo2 它们产生相同的性能结果。换一种说法, Foo2 是  比任何更快 Foo1

让我们添加一个“无法使用”的版本 Foo0 比较:

int Foo0(List<int> list)
{
    int sum = 0;
    for (int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

这是性能比较:

Foo0 = 1719
Foo1 = 7299
Foo2 = 7472
Foo0 = 1671
Foo1 = 7470
Foo2 = 7756

所以你可以看到 Foo0,可以内联,比其他两个快得多。你也可以看到 Foo2 稍微慢一点,而不是在任何接近的地方 Foo0


8
2018-05-28 03:20



非常好。看着IL Foo1 和 Foo0 在LINQPad中很好地显示了差异。在 Foo1 该 callvirt 操作显示调用 IList<System.Int32>get_Item 和 ICollection<System.Int32>.get_Count 而 Foo0的 callvirt 操作是 List<System.Int32>.get_Item 和 List<System.Int32>.get_Count。 Foo2执行的必要性可以解释缓慢 ldarga.s 然后加上前缀 callvirt 同 constrained. 而不仅仅是执行 ldarg.1。 - arcain
这是否意味着您可以通过使结构实现您的接口来欺骗系统,然后在您的泛型方法/类中使用这些结构?由于它们是值类型,运行时将为每个结构类型创建多个具体实现... - Cesar


这实际上确实有效,并且(如果函数不是虚拟的)会导致非虚拟调用。原因在于,与C ++不同,CLR泛型在JIT时为每个唯一的通用参数集定义一个特定的具体类(通过尾随1,2等反射表示)。如果该方法是虚拟的,则将导致虚拟调用,如任何具体的,非虚拟的,非泛型的方法。

关于.net泛型需要记住的是:

Foo<T>; 

然后

Foo<Int32>

是运行时的有效类型,独立且不同

Foo<String>

,并相应地处理所有虚拟和非虚拟方法。这就是你可以创建一个的原因

List<Vehicle>

并为其添加Car,但您无法创建类型的变量

List<Vehicle> 

并将其值设置为。的实例

List<Car>

。它们有不同的类型,但前者有一个 Add(...) 带参数的方法 Vehicle,超类型 Car


4
2018-05-28 02:42



我不确定可以在多大程度上避免虚函数调用,但是在接口可以由结构实现的情况下,可以通过将结构作为通用参数传递来消除装箱,该通用参数被约束到接口而不是传递它作为一个界面。 - supercat