对性能要求非常高的大项目真的不用多态吗?

Big projects that require very good performance really don't use polymorphism?

很长一段时间以来,我一直对 C++ 的性能感兴趣。

很多事情不断涌现,无论是在会议上还是在书中:

不使用虚函数,数据在缓存、分支等

有许多带有视频游戏示例的基准测试可以显示性能差异。 问题是,示例总是非常简单。

这在超过 20 行的代码中如何真正起作用?在 AAA 视频游戏、金融等领域

如果我有 100 种 objects 有不同的更新、不同的行为和其他乐趣,通过多态或函数指针很容易设置。

现在,按照给出的建议来制作功能强大的代码,上述选项是不可能的。 因此,我们更愿意有 100 个数组,我们将单独更新它们。 我们将可以很好地访问缓存,功能可能会内联等。总之,原则上性能会更好。

因此,我必须在每一帧调用 100 个数组和 100 个不同的函数。 表格会根据发生的事情、新玩家出现、怪物死亡等动态变化。 一些数组将有 0 个元素处于活动状态,其他 10 ... 我会调用没有工作的函数(没有活动元素的数组)但我别无选择,我必须有一个标志或查看我的数组中的元素是否活动。

我最终得到这样的结果:

obj1update ();
obje2update ();
....

obj1behavior ();
obj2behavior ();
....

obj1render ();
obj2render ();
.....

objectxy ();
....

当然,毫无疑问会有一个函数来管理 objects 上的所有更新调用,一个用于行为等,但为了简化它,如上所示。

留在电子游戏中,如果一个玩家的x个动作导致y个怪物的到来,那么有几种不同功能的怪物。 因此,我们将把怪物放在我们的表中,这次处理这些怪物的函数将起作用。

我们可以使用 ECS 模式或派生模式,但它们可能具有非常不同的行为(指导它们的 ia 或其他)、不同的组件,因此需要不同的功能来处理它们。 它们将在代码中被硬调用,因为我们没有多态性或函数指针,我们将不得不在每一帧检查它们是否有要处理的东西。

真的是这样做的吗?假设我有 500 种类型? 1000?

编辑:

很多评论,所以我会在这里回复你。

正如 Federico 所说,我想知道这些建议是否对书籍有益,但在实践中则不然。

我看过的几个资源:

https://www.agner.org/optimize/#testp 好几本书

www.youtube.com/watch?v=WDIkqP4JbkE&t Scott Meyers 谈记忆

https://people.freebsd.org/~lstewart/articles/cpumemory.pdf 在内存中

www.youtube.com/watch?v=rX0ItVEVjHc&t Data-oriented 编程

https://www.dataorienteddesign.com/dodbook/ 面向数据的设计书籍

还有其他资源,但它已经让您了解我的基础

不,真正的程序不是那样写的。真正的程序是通过注意到所有怪物都有一堆共同点,并使用相同的代码来完成这些事情而编写的。他们都是寻路者,但他们能走的距离不同?伟大的。添加一个max_walking_distance变量,每次调用同一个函数。

你所有的怪物都有3D模型?那么你不需要虚拟 render 方法。您可以只渲染模型。

您不必根据“合理”的界限划分数据。您 不必 拥有 struct monster。你可以有一个 struct monster_pathfinding 和一个 struct monster_position 和一个 struct monster_3d_model。即使你只是把它们放在并行数组中(即怪物 123 在 monsters_pathfinding[123] 中有它的寻路信息,它的位置在 monster_positions[123] 中)这可以更有效地使用数据缓存,因为寻路代码不会t 将 3D 模型指针加载到缓存中。如果某些怪物寻路或渲染,您可以通过跳过条目变得更聪明。从本质上讲,建议您根据数据的使用方式而不是根据您对游戏中事物的心智模型将数据分组在一起以获得性能。是的,跳过条目会使删除怪物变得更加困难。不过你刷怪的次数多,删怪的次数也不多吧?

也许只有少数怪物向玩家开枪(其余的试图吃掉玩家)。你可以有一个 struct monster_gun_data {int ammunition; int max_ammunition; int reload_time; monster_position *position;}; 然后如果你有 200 个怪物,但其中只有 10 个有枪,你的 monstersShootGunsAtPlayers 函数只需要迭代 monster_gun_data 数组中的 10 个条目(和通过指针加载它们的位置)。或者,您可能会分析它并发现因为游戏中 most 怪物有枪,所以迭代 all 怪物并检查会稍微快一些他们的 MONSTER_HAS_GUN 标志,而不是通过不能轻易预取的指针访问位置。

你如何进行不同种类的怪物攻击?好吧,如果它们 完全 不同(近战与远程),您可能会像您描述的那样使用不同的功能来完成它们。或者,您可能只在确定怪物要攻击玩家后才检查攻击类型。您似乎建议怪物使用不同的攻击代码,但我敢打赌这对几乎所有怪物都有效:

if(wantsToAttack(monster, player)) {
    if((monster->flags & HAS_RANGED_ATTACK) && distance(monster, player) > monster->melee_attack_distance)
        startRangedAttack(monster, player);
    else
        startMeleeAttack(monster, player);
}

带枪的怪物和带弓箭的怪物真正有什么不同?攻击速度、动画、射弹的移动速度、射弹的 3D 模型以及它造成的伤害量。这就是所有数据。那不是不同的代码。

最后,如果您有一些完全不同的东西,您可以考虑将其设为具有虚函数的“策略对象”。或者只是一个普通的函数指针,如果可以的话。请注意,Monster 对象仍然不是多态的,因为如果是,我们就不能拥有它们的数组,这会减慢所有公共代码的速度。只有我们所说的怪物的特定部分是多态的,实际上是多态的。

void SpecialBossTickFunction(Monster *monster) {
    // special movement, etc
}
// ...
monster->onTick = &SpecialBossTickFunction;
// monster is still not polymorphic except for this one field

你也可以这样做:

struct SpecialBossTickStrategy : TickStrategy {
    void onTick(Monster *monster) override {...}
    // then you can also have extra fields if needed
    // but you also have more indirection
};
monster->onTick = new SpecialBossTickStrategy;

并且不要做不必要的事情。尝试以事件为导向,而不是每次都做事:

// bad because we're calling this function unnecessarily every tick
void SpecialUndeadMonsterTickFunction(Monster *monster) {
    if(monster->isDead) {
        // do some special reanimation sequence
    }
}
monster->onTick = &SpecialUndeadMonsterTickFunction;

// better (for performance)
void SpecialUndeadMonsterTickWhileDeadFunction(Monster *monster) {
    // do some special reanimation sequence
    if (finished doing whatever) {
        monster->onTick = NULL;
    }
}
void SpecialUndeadMonsterDeathFunction(Monster *monster) {
    monster->onTick = &SpecialUndeadMonsterTickWhileDeadFunction;
}
// ...
monster->onDead = &SpecialUndeadMonsterDeathFunction;

// Also better (for performance)
void DoUndeadMonsterReanimationSequences() { // not virtual at all, called from the main loop
    for(Monster *monster : special_undead_monsters_which_are_currently_dead) {
        // do some special reanimation sequence
    }
}

// Not great, but perhaps still less bad than the first one!
void DoUndeadMonsterReanimationSequences() { // not virtual at all, called from the main loop
    for(Monster &monster : all_monsters) {
        if(monster.type == TYPE_SPECIAL_UNDEAD_MONSTER && monster.isDead) {
            // do some special reanimation sequence
        }
    }
}

请注意,在第三个示例中,您必须使此数组 special_undead_monsters_which_are_currently_dead 保持最新。没关系,因为您只需要在怪物生成、消失、死亡或恢复死亡时更改它。这些都是相对罕见的事件。您在这些事件中做了更多的工作,以节省每个时间点的工作。


最后,请记住这些技术可能会或可能不会提高您的实际程序的性能。我将 DOD 视为一个集思广益的宝库。 它并没有说你必须完全按照特定的方式编写程序,但它提供了一堆非常规的建议,解释它们为什么工作的理论,以及其他人如何工作的例子已经设法在他们的程序中使用它们。由于 DOD 通常建议您完全重组数据结构,因此您可能只想在程序的性能关键区域实施它。

只是为了对顶级问题添加更多观点:

Big projects that require very good performance really don't use polymorphism?

您错过了整个类别的多态性。

我经常在一个项目中混合使用以下三种样式,因为并非所有代码都具有相同的性能要求:

  1. 设置和配置代码通常不需要(非常)快。使用 OO 风格和 运行time 多态性所有你想要的属性,工厂,等等。

    运行时多态泛指虚函数。

  2. 需要快速的稳态代码通常可以使用编译时多态性。这适用于具有相似接口的静态已知(最好是小的)类型集合。

    编译时多态性意味着模板(函数模板、类型模板、用等效策略替换 运行 时策略模式等)

  3. 性能要求最严格的代码可能需要面向数据(即围绕缓存友好性设计)。

    这不是项目中的所有代码,甚至可能不是所有需要快速的代码。这是所有需要快速的代码,性能受缓存影响

    如果你只有一个对象的副本,你可以尽可能多地内联(并尽量将它放入尽可能少的缓存行),但是将它分成四个不同的数组,每个数组只有一个元素大有作为。