2

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

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

不要使用虚函数,将数据保存在缓存、分支等中。

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

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

如果我有 100 种类型的对象,它们具有不同的更新、不同的行为和其他乐趣,那么通过多态性或函数指针来设置很容易。

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

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

我最终得到这样的结果:

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

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

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

objectxy ();
....

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

留在电子游戏中,如果一个玩家的 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 面向数据的编程

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

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

4

2 回答 2

3

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

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

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

也许只有少数怪物向玩家开枪(其余的试图吃掉玩家)。如果你有 200 个怪物,但其中只有 10 个有枪,你可以有一个struct monster_gun_data {int ammunition; int max_ammunition; int reload_time; monster_position *position;};然后,你的monstersShootGunsAtPlayers函数只需要遍历monster_gun_data数组中的 10 个条目(并通过指针加载它们的位置)。或者,您可能会对此进行分析并发现由于您游戏中的大多数怪物都有枪,因此迭代所有怪物并检查它们的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 通常建议您完全重组数据结构,因此您可能只想在程序的性能关键区域中实现它。

于 2021-07-28T12:59:02.143 回答
1

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

需要非常好的性能的大项目真的不用多态吗?

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

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

  1. 设置和配置代码通常不需要(非常)快。对属性、工厂等使用 OO 风格和运行时多态性。

    运行时多态广义上是指虚函数。

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

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

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

    这不是项目中的所有代码,甚至可能不是所有需要快速的代码。这是所有需要快速且性能由缓存效果主导的代码。

    如果您只有一个对象的一个​​副本,那么您可能会尽可能地内联(并尝试将其放入尽可能少的缓存行中),但是将其拆分为四个不同的数组,每个数组只有一个元素不会取得太多效果。

于 2021-07-29T15:49:29.463 回答