面向对象编程、数据导向编程、缓存污染和缓存明显性。

9
在常规面向对象编程实践中,对象具有多个不相关的成员属性并不罕见。当处理对象时,很常见会进行不同的操作,以针对其属性的不同部分。
在这方面,创建对象集合的典型方法似乎不是非常高效的。考虑到计算机访问内存的方式以及缓存行的平均大小,缓存存储器很可能被填满了不需要的内容,但只是因为那些内容恰好相邻,所以它们浪费了缓存的容量,并增加了执行的停顿和延迟。
更糟糕的是使用多态性和动态分配对象的做法,而没有使用内存池和自定义分配器。在这种情况下,不仅缓存被填满了不需要的数据,而且由于动态内存分配使用的任意地址,预取器也无法正常工作。
解救之道是回到面向数据的时代,并选择数据导向,这似乎是开发性能关键应用程序、操作系统等的首选选择。但为什么不采用两者的混合版本呢?类似于“数据导向对象编程”?
在这长篇序言之后,让我们来谈谈手头的问题。我没有一个足够大的项目来测试这个概念的效率,所以社区的理论专业知识非常受欢迎。
如果对象只存储对其数据成员存储在其自己的容器中的集合的引用,而它们的成员方法从这些容器返回数据,那么不需要的数据到达 CPU 的几率应该会降低,需要在“未来”中使用的数据的几率会增加。逻辑假设是,这种方法将提高预取器的效率、缓存命中和使用效率,并且还将减少自动和手动并行化所涉及的延迟。
你认为呢?
晚些编辑:如果考虑结构体和类的填充,应用“数据导向模式”可能会更有益,如果一个“模型”有一个 char 和一个 int 数据成员,在面向对象编程方式下,它将被填充,这只会进一步污染缓存,但数据导向存储模式可以将所有 char 和所有 int 顺序存储,没有空间和缓存浪费。

5
这是一篇有趣且相关的PDF文档:http://harmful.cat-v.org/software/OO_programming/_pdf/Pitfalls_of_Object_Oriented_Programming_GCAP_09.pdf,我刚刚偶然发现它。 - ArjunShankar
3
谢谢您提供链接,看起来性能改进比我预期的还要显著。令人惊讶的是,在我学过的几本编程材料中,我从未听到任何关于这个主题的讨论。 - dtech
我知道这个叫做“结构体数组”和“数组结构体”的区别。如果你在谷歌上搜索这两个术语,会得到相当多的资料。 - Fred Foo
非常好的问题,我也注意到这个主题缺乏资料。 - JBentley
事实上,我越想越喜欢这个概念。我正准备重构一些表现不佳的基于面向对象的C++代码,用于一个高性能项目,并且我想尝试这个想法。希望几个月后我能够发布一些结果。 - JBentley
2个回答

0
我认为,在对象级别上使用多态性如果在非常细粒度的对象(例如抽象的IPixel接口)级别上使用,本质上是昂贵的。在这种情况下,围绕IPixel依赖项的视频处理软件从效率的角度来看将会非常糟糕,因为它没有优化的余地。除了每个像素的动态分派成本之外,甚至在此处所需的虚拟指针可能比整个像素本身还要大,从而使内存使用量增加一倍或两倍。此外,我们不能再以超出单个像素的方式玩弄像素表示,最可怕的是,图像中的相邻像素甚至可能不连续地表示在内存中。
与此同时,IImage可以提供充足的优化空间,因为图像模拟了像素的集合/容器,并且仍然具有足够的灵活性(例如,每个像素格式都有不同的具体图像表示)。现在,每个图像的动态分派是廉价的,虚拟指针的大小对于整个图像来说是微不足道的。我们还可以探索如何以允许我们高效处理多个像素的方式表示像素。因此,我认为,与您类似,这是在适当粗糙的级别上设计对象的问题,这通常意味着事物的集合,以减少所有开销和障碍对优化的影响。
如果对象不再存储自己的数据成员,而是仅存储对集合的引用,其中它们的数据成员按顺序存储在自己的容器中,并且它们的成员方法从这些容器返回数据,那么无用数据到达 CPU 的几率应该会降低,需要在不久的“未来”使用的数据的几率会增加。
我喜欢这个想法,但是如果你在多态上下文中走得太远,你可能会回到自定义内存分配器和基础指针排序。我经常发现这种设计的用途是提高单个元素的便利性,在需要聚合以提高效率的情况下(一种情况是将其放入使用 SoA 表示的容器中,另一种情况我将在下面介绍)。
多态情况并没有那么大的好处,因为固有问题在于一次处理非同质颗粒的非同质处理。要恢复效率,我们必须为关键循环恢复同质性。
非同质关键循环

Orc继承CreatureHuman继承CreatureElf继承Elves为例,但是人类、兽人和精灵都有不同的大小/领域、不同的对齐要求和不同的虚表。在这种情况下,当客户端代码想要处理非同质列表并将多态基指针存储到生物体中时,可以这样做:

for each creature in creatures:
     creature.do_something();

...相对于这种会牺牲多态性的方法:

for each orc in orcs:
     orc.do_something();
for each human in humans:
     humans.do_something();
for each elf in elves:
     elves.do_something();

...如果我们需要在许多地方引入新类型的生物,每次都需要扩展,那么这将是一个真正的麻烦...

...然后,如果我们想保持多态解决方案,但仍以非同质方式逐个处理每个生物,无论每个生物是否只存储指向容器的回指针,我们最终仍会失去时间和空间局部性。由于我们可能在一次迭代中访问一个vtable,然后在下一次迭代中访问另一个vtable,因此我们会失去vtables上的时间局部性。这里的内存访问模式也可能是随机和零散的,导致空间局部性的丧失,因此我们最终会遇到大量的缓存未命中。

因此,在我看来,在这种情况下,如果您想要继承和多态性,则应在容器级别进行抽象:Orcs继承CreaturesHumans继承CreaturesElves继承Creatures。这会使客户端代码在想要表达特定生物的操作时变得更加复杂,但现在上述顺序循环可以像这样编写:

for each creatures in creature_types:
     creatures.do_something();

在第一次迭代中,可能对一个兽人列表执行某些操作(可能是存储在数组中的一百万个兽人)。现在,该列表中的所有兽人都可以连续存储,并且我们正在对该列表中的所有兽人应用同质功能。在这种情况下,我们有很多空间来进行优化而不改变设计。
我们仍然有一个非同质循环,利用多态性,但现在它非常便宜,因为我们只为整个生物容器支付开销,而不是为每个单独的生物支付开销。处理单个生物的循环现在是同质的。这类似于使用抽象“IImage”来调整一组图像(一堆像素容器)的亮度,而不是一次对一个实现了“IPixel”的抽象像素对象进行操作。
同质循环和表示
这样,将处理所有种类的不同数据的非同质循环的重要工作转移到了处理同质数据的同质循环中,这些数据被连续存储。

这是我在接口设计方面考虑的一般策略。如果它容易产生难以优化的热点,那么我认为固有问题在于界面设计过于细粒度(Creature而不是Creatures)。

因此,如果希望利用OOP,我认为应该采取这种方法来解决这个问题。我认为您的设计思路可以简化客户端代码必须表达仅适用于一个特定生物的操作的情况,在这种情况下,他们可以通过某种指向容器的代理对象进行工作,可能存储对特定条目的索引或指针,使其更加方便,例如CreatureHandle,它引用抽象Creatures容器中的特定条目。


0
首先,幻灯片做得很好。根据我的理解,您的问题与演示方式有所不同。变量和对象属性都是随机存储在主内存中的。如果您尝试为连续的数据结构分配内存,那么您的数据结构大小将受到主内存中最大“空气泡”的限制,否则它就不会是纯连续的。也许您想到了类似这样的东西:
class MyClass
{
public:
    MyClass()
    {
        m_dataMembers = new GenericObject[DATA_MEMBERS_AMOUNT];

        //initialize array values...
    }

    int getMyVar()
    {
        return (int)m_dataMembers[MY_VAR_INDEX];
    }

    //others functions...

private:
    GenericObject* m_dataMembers;
}

这样做会有一些问题。首先,您需要一个通用的对象类来存储任何类型的变量。然后,您需要知道每个变量在数据结构中的位置,然后您需要知道数据结构中每个变量的类型,以正确地转换为getter。他在演示中实际上是通过使用引用来减少类的大小,使其更好地适合缓存页面,并减少缓存中的使用,而不是主内存。我希望我没有误解您。


1
我认为你没有正确理解我的概念。顺便说一下,减少类只是优化的第一步,与移动到平面内存模型相比,它并没有带来太多的性能提升。基本上,我的想法是,我们不再像 O1M1M2M3M4 O2M1M2M3M4 O3M1M2M3M4 这样将对象和成员放在一起,而是像 O1O2O3 M1M1M1 M2M2M2 M3M3M3 M4M4M4 这样对齐,因此在需要 M1 成员的传递中,我们可以获得更多的对象及其 M1 成员,而不会用 M2、M3 和 M4 成员污染缓存。 - dtech
你能进一步解释一下你所说的“变量在主存中是随机存储的,甚至对象属性也是如此”吗?我从未听过编译器会将数据成员随意放在内存中。例如,请参考这个答案。无论如何,可以通过容器(如std::vector)或自定义分配器来保证连续性。 - JBentley

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