C++中的人工智能应用:虚函数的成本有多高?有哪些可能的优化方法?

17
在我正在用C++编写的AI应用程序中,
  1. 数值计算不多
  2. 需要运行时多态的结构很多
  3. 在计算过程中,经常有几个多态结构互相交互
在这种情况下,是否有任何优化技术?虽然我现在不关心优化应用程序,但选择C++而不是Java进行项目的一个方面是为了能够更好地优化,并能够使用非面向对象的方法(模板、过程、重载)。
特别地,与虚拟函数相关的优化技术有哪些?虚拟函数是通过内存中的虚拟表实现的。是否有一种方式可以预取这些虚拟表到L2缓存中(从内存/L2缓存中检索的成本正在增加)?
除此之外,在C++中有哪些关于数据局部性的优化技巧?这些技巧将减少计算所需的数据从L2缓存中获取的等待时间。 更新:还请参阅以下相关论坛:使用接口的性能惩罚多级基类是否减慢了C++中的类/结构体
15个回答

28

虚函数非常高效。假设使用32位指针,其内存布局大致如下:

classptr -> [vtable:4][classdata:x]
vtable -> [first:4][second:4][third:4][fourth:4][...]
first -> [code:x]
second -> [code:x]
...
指向的内存通常位于堆上,有时位于栈上,以及以一个指向该类vtable的四个字节指针开头。但要记住的重要一点是,vtable本身不是分配的内存。它是一个静态资源,所有相同类类型的对象都将指向完全相同的内存位置其vtable数组。调用不同实例不会将不同的内存位置拉入L2高速缓存中。
这个来自MSDN的示例显示了具有虚拟func1、func2和func3的A类的vtable。仅有12个字节而已。不同类的vtable也很有可能在编译库中物理相邻(如果您特别关心,则需要验证),这可以微观地提高缓存效率。
CONST SEGMENT
??_7A@@6B@
   DD  FLAT:?func1@A@@UAEXXZ
   DD  FLAT:?func2@A@@UAEXXZ
   DD  FLAT:?func3@A@@UAEXXZ
CONST ENDS

另一个性能上的考虑是通过vtable函数进行调用时的指令开销。这也非常高效,几乎与调用非虚函数相同。再次参考msdn的示例

; A* pa;
; pa->func3();
mov eax, DWORD PTR _pa$[ebp]
mov edx, DWORD PTR [eax]
mov ecx, DWORD PTR _pa$[ebp]
call  DWORD PTR [edx+8]
在这个例子中,ebp(栈帧基指针)的零偏移位置有变量A* pa。将寄存器eax加载为 [ebp] 位置上的值,因此它拥有A*,并且将edx加载到 [eax] 位置上的值,因此它具有A类 vtable。然后将ecx加载到 [ebp],因为ecx代表 "this",所以现在它持有A*,最后调用位于vtable中第三个函数地址的 [edx+8] 位置上的值。
如果此函数调用不是虚拟的,则不需要 mov eax 和 mov edx,但性能差异微乎其微。

1
不错的例子。还有其他人在调用之前,由于冗余的内存访问而感到痒吗?使用mov ecx,eax就可以解决这个问题。 - plinth
在现代CPU上,movs指令可能会被完全流水线化。 - Jimmy J
8
需要注意的是,虽然调用虚函数的指令开销很小,但它们可能会以其他方式对性能产生负面影响。首先,它们阻止了许多优化,例如内联和分支预测。其次,它们可能会损害I-Cache的性能。 - Andrew Grant
1
间接跳转的分支预测错误所创建的流水线泡沫怎么办? - Crashworks

11

就像被接受的答案一样,它忽略了一个重要的点:通过虚函数表进行间接调用的开销远非性能下降的唯一可能原因。 - underscore_d

3

你是否已经进行过分析,找到了需要优化的地方和内容?

当你发现虚函数调用实际上是瓶颈时,请着手进行优化。


3
我能想到的唯一优化是Java的JIT编译器。如果我理解正确,它会在代码运行时监视调用,如果大多数调用只针对特定实现,则在类正确时插入条件跳转到该实现。这样,大部分时间都不需要进行虚函数表查找。当然,对于我们传递不同类的罕见情况,仍然会使用虚函数表。
据我所知,没有任何C++编译器/运行时使用这种技术。

1
这对于Java非常重要,因为默认情况下每个方法都是虚拟的。而C++需要程序员自己进行分析和直接调用修复。 - Zan Lynx

3
虚函数通常是查找和间接函数调用。在某些平台上,这很快。但在其他平台上,比如一些流行的用于游戏机的PPC架构上,这就不那么快了。
优化通常围绕着在调用栈中更高层次地表达可变性,这样你就不需要在热点内多次调用虚函数。

2

2

2
动态多态性的解决方案可能是静态多态性,如果您的类型在编译时已知,则可以使用CRTP(奇妙递归模板模式)。

http://en.wikipedia.org/wiki/Curiously_recurring_template_pattern

维基百科上的解释已经足够清晰了,如果你真的决定虚拟方法调用是性能瓶颈的来源,它也许可以帮助你。

2

我强调所有已经表达的答案:

  • 如果你实际上不知道这是一个问题,那么任何关于解决它的担忧都可能是不必要的。

你想知道的是:

  • 执行时间(实际运行时)中有多少时间花费在调用方法的过程中,特别是哪些方法是最耗时的(按此度量)。

一些分析工具可以间接地提供这些信息。它们需要在语句级别进行总结,但不包括在方法本身中花费的时间。

我最喜欢的技术是在调试器下暂停它多次。

如果虚拟函数调用的时间显著,比如说20%,那么平均每5个样本中就会显示一个,在调用堆栈的底部,在反汇编窗口中,指令将跟随虚拟函数指针。

如果你实际上没有看到这个,那就不是一个问题。

在这个过程中,你可能会看到其他更高层次的调用堆栈,它们实际上是不需要的,可以节省很多时间。


@Downvoter:我说了什么不正确的话,还是说了与你所学相反的观点? - Mike Dunlavey

2
正如其他答案所述,虚函数调用的实际开销相当小。在每秒调用数百万次的紧密循环中可能会有所不同,但这很少是一个大问题。
然而,它可能仍然对编译器优化造成更大的影响。它无法内联函数调用,因为它不知道在编译时将调用哪个函数。这也使一些全局优化变得更加困难。这会给你带来多少性能损失?这取决于情况。通常没有什么可担心的,但有些情况下可能意味着显著的性能损失。
当然,这也取决于CPU架构。在某些架构上,它可能会变得非常昂贵。
但值得记住的是,任何类型的运行时多态性都具有或多或少相同的开销。通过switch语句或类似方法实现相同功能以在可能的函数之间进行选择可能并不便宜。
唯一可靠的优化方式是如果您可以将部分工作移动到编译时。如果可以将其部分实现为静态多态性,则可能会加速一些。
但首先,请确保您有问题。代码是否真的太慢以至于无法接受? 第二,通过分析器找出是什么使它变慢。 第三,修复它。

我只想补充一点,优化被高估了。它只在代码中有重要作用,其中1)PC花费了大量时间(如果包含调用,则可能不会),并且2)您实际编译(即非库例程)。 - Mike Dunlavey

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