我想跳入这些已经非常出色的答案中,并承认我采用了一种丑陋的方法,即实际上向后工作,将反过来将多态代码更改为具有测得的增益switches
或if/else
分支的反模式。但是我并没有这样做,只是针对最关键的路径。它不必如此黑与白。
作为免责声明,我在诸如光线追踪这样的领域工作,在这些领域中,很难实现正确性(并且通常还是模糊和近似),而速度通常是所寻求的最具竞争力的质量之一。减少渲染时间通常是用户最普遍的要求之一,我们不断摸索,想出如何在最关键的测量路径上实现它。
条件的多态重构
首先,值得理解的是,为什么从可维护性的角度来看,多态性比条件分支(switch
或一堆if/else
语句)更可取。这里的主要好处是可扩展性。
使用多态代码,我们可以在代码库中引入一个新的子类型,将其实例添加到某些多态数据结构中,并使所有现有的多态代码仍然可以自动进行,而无需进行进一步的修改。如果您的代码堆分散在整个大型代码库中,类似于“如果此类型为'foo',请执行此操作”的形式,那么您可能会感到负担重重,需要更新50个不同的代码部分以进行介绍一种新的事物,但最终仍然缺少一些东西。
如果您的代码库中只有几个甚至一个部分需要进行这种类型检查,那么多态性的可维护性优势自然就会减少。
优化壁垒
我建议不要过多地从分支和流水线的角度看待这一问题,而应该从优化障碍的编译器设计思想上多看待它。有两种方法可以改进适用于两种情况的分支预测,例如根据子类型对数据进行排序(如果适合序列)。
这两种策略之间最大的不同是优化器预先拥有的信息量。已知的函数调用可提供更多信息,而在编译时调用未知函数的间接函数调用会导致优化障碍。
当已知要调用的函数时,编译器可以清除结构并将其压缩为smithereens,内联调用,消除潜在的别名开销,在指令/寄存器分配方面做得更好,甚至可能重新排列循环和其他形式的分支,从而产生困难适当的情况switch
下使用经代码编码的微型LUT(最近,GCC 5.3的一项声明使我感到惊讶,因为它使用硬编码的数据LUT作为结果而不是跳转表)。
当我们开始将编译时未知量引入混合时,其中的一些好处就失去了,例如间接函数调用的情况,而条件分支最有可能在其中提供优势。
记忆体最佳化
以视频游戏为例,该视频游戏包含在紧密循环中重复处理一系列生物。在这种情况下,我们可能会有一些这样的多态容器:
vector<Creature*> creatures;
注意:为简单起见,我unique_ptr
在这里避免。
...其中Creature
是多态基本类型。在这种情况下,多态容器的困难之一是它们经常希望分别/单独地为每个子类型分配内存(例如:operator new
对每个生物使用默认投掷)。
通常,这将成为基于内存而不是分支的优化的优先级(我们需要)。这里的一种策略是为每个子类型使用固定的分配器,通过分配大块并为分配的每个子类型分配内存来鼓励连续表示。通过这种策略,绝对可以帮助creatures
根据子类型(以及地址)对该容器进行排序,因为这不仅可能会改善分支预测,而且可以提高引用的局部性(允许访问同一子类型的多个生物)从逐出之前的单个缓存行开始)。
数据结构和循环的部分虚拟化
假设您经历了所有这些动作,但您仍然希望提高速度。值得注意的是,我们在这里迈出的每一步都在降低可维护性,而且我们已经处在金属研磨阶段,性能回报不断下降。因此,如果我们涉足这一领域,就需要一个相当大的性能需求,在该领域,我们愿意为获得越来越小的性能提升而进一步牺牲可维护性。
但是,下一步的尝试(并且如果没有帮助,总是愿意放弃所做的更改)可能是手动虚拟化。
版本控制提示:除非您比我更精通优化,否则在此时创建一个新分支并值得一去,如果我们的优化工作错过了,这很可能会失败,那么就值得放弃。对我来说,即使有了分析器,这一切都是经过反复尝试才能得出的结论。
不过,我们不必完全采用这种思维方式。继续我们的示例,到目前为止,假设该视频游戏主要由人类生物组成。在这种情况下,我们可以通过吊起人类动物并为其创建单独的数据结构来使其仅具有虚拟化。
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
这意味着我们代码库中需要处理生物的所有区域都需要一个单独的特殊循环来处理人类生物。但这消除了迄今为止人类最常见的生物类型的动态调度开销(或更恰当地说,可能是优化障碍)。如果这些区域数量众多并且我们负担得起,我们可以这样做:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
...如果我们负担得起,那么次要的路径可以保持原样,并简单地抽象处理所有生物类型。关键路径可以humans
在一个循环和other_creatures
第二个循环中处理。
我们可以根据需要扩展此策略,并有可能以此方式挤压一些收益,但值得注意的是,我们在此过程中降低了可维护性。在此处使用功能模板可以帮助为人类和生物生成代码,而无需手动复制逻辑。
类的部分去虚拟化
我几年前所做的事情确实很糟糕,而且我什至不确定它是否有益(在C ++ 03时代),这是对类的部分虚拟化。在那种情况下,我们已经在每个实例中存储了一个类ID用于其他目的(通过非虚拟基类中的访问器访问)。在那里,我们做了类似的事情(我的记忆有些朦胧):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... virtual_do_something
实现了在子类中调用非虚拟版本的位置。我知道,做一个显式的静态下调来虚拟化函数调用是很麻烦的。我不知道这现在有多大益处,因为我已经多年没有尝试过这种事情了。接触到面向数据的设计后,我发现上述以热/冷方式拆分数据结构和循环的策略更加有用,为优化策略打开了更多的大门(并且丑陋得多)。
批发虚拟化
我必须承认,我到目前为止还没有应用优化思想,所以我不知道这样做的好处。在我只知道一组中央条件(例如:仅具有一个中央位置处理事件的事件处理),但从未开始采用多态心态并一直进行优化的情况下,我避免了预见中的间接功能。到这里。
从理论上讲,除了完全消除这些优化障碍之外,此处的直接收益可能是与虚拟指针相比可能更小的标识类型的方法(例如,如果您可以承诺存在256个或更少的唯一类型,则为单个字节)。 。
如果您只使用一个中央switch
语句而不必根据子类型拆分数据结构和循环,或者在有顺序的情况下,在某些情况下也可能有助于编写易于维护的代码(与上面优化的手动去虚拟化示例相比)-在这种情况下,必须以精确的顺序处理事物(即使这会导致我们在整个分支机构分支),因此需要依赖。这适用于您没有太多需要做的地方的情况switch
。
我通常不建议这样做,即使是非常注重性能的心态,除非这样做相当容易维护。“易于维护”将取决于两个主要因素:
- 没有真正的可扩展性需求(例如:肯定要处理8种类型的事物,并且永远不再需要)。
- 您的代码中没有很多地方需要检查这些类型(例如:一个中心位置)。
...但是我建议在大多数情况下使用上述方案,并根据需要通过部分虚拟化进行迭代以找到更有效的解决方案。它为您提供了更多的喘息空间,以平衡可扩展性和可维护性需求与性能。
虚拟函数与函数指针
最重要的是,我在这里注意到有关虚拟函数与函数指针的一些讨论。确实,虚函数需要一些额外的工作来调用,但这并不意味着它们会变慢。违反直觉,它甚至可能使它们更快。
这在这里是违反直觉的,因为我们习惯于根据指令来衡量成本,而没有关注可能会产生更大影响的内存层次结构的动态性。
如果我们将a class
与20个虚函数进行比较,而a 与struct
存储20个函数指针的情况进行比较,并且都实例化了多次,则class
在这种情况下,每个实例的内存开销在64位计算机上为8个字节的虚拟指针的开销struct
为160个字节。
函数指针表与使用虚拟函数的类相比,使用强制函数表和使用虚拟函数的类可能会有更多的强制性和非强制性高速缓存未命中(以及可能在足够大的输入范围内出现页面错误)。这种成本往往使索引虚拟表所付出的额外工作变得微不足道。
我还处理过旧的C代码库(比我老),在其中structs
填充了函数指针,并实例化了无数次,通过将它们转换为带有虚函数的类,实际上获得了显着的性能提升(超过100%的改进)。由于大量减少了内存使用,提高了缓存友好性等。
另一方面,当人们对苹果进行比较时,我同样发现从C ++虚拟函数思维方式转换为C风格函数指针思维方式的相反思维方式在以下类型的场景中很有用:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
...该类存储一个单一的,可覆盖的函数(如果我们计算虚拟析构函数,则存储两个)。在这些情况下,它绝对可以在关键路径上帮助您将其转化为:
void (*func_ptr)(void* instance_data);
...理想情况下是在类型安全的界面后面,以隐藏来回的危险转换 void*
。
在那些我们想将类与单个虚函数一起使用的情况下,它可以快速帮助您改用函数指针。一个很大的原因甚至不必降低调用函数指针的成本。这是因为,如果我们将每个单独的functionoid聚集到一个持久的结构中,就不再面临在堆的分散区域上分配每个单独的functionoid的诱惑。例如,如果实例数据是同质的,并且仅行为发生变化,则这种方法可以更轻松地避免堆相关的内存碎片开销。
因此,在某些情况下肯定可以使用函数指针来提供帮助,但是如果我们将一堆函数指针表与单个vtable进行比较(通常每个类实例只需要存储一个指针),通常我会发现另一种情况。该vtable经常会处于紧密循环中,位于一个或多个L1高速缓存行中。
结论
所以无论如何,这是我在这个话题上的小话题。我建议在这些区域进行冒险操作。信任度量不是本能,并且鉴于这些优化通常会降低可维护性的方式,只能尽您所能(并且明智的做法是在可维护性方面犯错误)。