面向对象、面向数据、缓存污染和缓存明显性

Posted

技术标签:

【中文标题】面向对象、面向数据、缓存污染和缓存明显性【英文标题】:Object orientation, data orientation, cache pollution and cache obviousness 【发布时间】:2012-09-04 03:16:36 【问题描述】:

在常规的面向对象实践中,很少有对象具有多个不相关的成员属性。并且在处理对象时,经常会在不同的通道中完成,这些通道针对其属性的不同部分。

在这方面,创建对象集合的典型方法似乎不是一种非常有效的方法。考虑到计算机访问内存的方式和高速缓存行的平均大小,高速缓存很有可能会被不需要的内存填满,而只是碰巧相邻,因此最终会浪费缓存的容量并增加停顿和执行延迟。

更糟糕的是使用多态性和动态分配对象的做法,没有内存池和自定义分配器。在这种情况下,不仅缓存中充满了不需要的数据,而且由于动态内存分配使用的任意地址,预取器也无法充分工作。

拯救是回到 OOP 之前的时代并选择面向数据,这似乎是开发性能关键应用程序、操作系统等的偏好选择。但是为什么不使用两者的混合版本呢? 面向数据的对象编程

在漫长的序曲之后,让我们开始讨论手头的问题。我没有足够庞大的项目来测试这个概念的效率,因此非常欢迎社区的理论专业知识。

如果对象不存储自己的数据成员,它们只存储对集合的引用,它们的数据成员按顺序存储在自己的容器中,并且它们的成员方法从这些容器中返回数据,这样的可能性应该减少最终到达 CPU 的不需要的数据,并增加在不久的“未来”需要的数据的几率。合乎逻辑的假设是,这种方法将提高预取器效率、缓存命中率和使用效率,还将减少延迟,涉及自动和手动并行化。

你怎么看?

后期编辑:如果我们以 OOP 方式考虑结构和类填充,如果“模型”具有 charint 数据成员,则应用“数据导向模式”可能会更有益它将被填充,只会进一步污染缓存,但是面向数据的存储模式可以顺序存储所有chars和所有ints,完全没有空间和缓存浪费。

【问题讨论】:

刚刚遇到的Here is an interesting and related PDF。 @ArjunShankar - 感谢您的链接,看来性能改进比我预期的还要重要。令人惊讶的是,在我浏览过的几种编程材料中,我从未听到有人谈论过这个主题...... 我知道这是“结构数组”与“数组结构”的区别。如果你用谷歌搜索这两个术语,你会得到相当多的材料。 很好的问题,我也注意到这个主题缺乏材料。 其实我越想越喜欢这个概念。我即将开始在一个高性能项目中重构一些基于 OOP 的 C++ 代码,我想我会尝试这个想法。希望我能在几个月后发布一些结果。 【参考方案1】:

首先,漂亮的幻灯片演示。好吧,据我所知,您的问题与演示方法完全不同。变量随机存储在主内存中,甚至对象属性。如果您尝试将内存分配给连续的数据结构,那么您的数据结构的大小将受到主内存中最大的“气泡”的限制,否则它不会是纯粹的连续的。也许你是这样想的:

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 中正确转换。他在演示文稿中实际做的是减少他的类大小,使用引用,使其更好地适合缓存页面,并减少缓存中的使用,而不是主内存中的使用。希望我没有误解你。

【讨论】:

我认为您没有正确理解我的概念。顺便说一句,减少类只是优化的第一步,它并没有像迁移到平面内存模型那样提供几乎一样多的性能改进。基本上我的想法不是像O1M1M2M3M4 O2M1M2M3M4 O3M1M2M3M4那样将对象和成员放在一起,而是像O1O2O3 M1M1M1 M2M2M2 M3M3M3 M4M4M4这样对齐,所以在只需要M1成员的通道中,在缓存中我们得到更多的对象和更多的M1成员,没有用 M2、M3 和 M4 成员污染它。 您能否进一步解释一下“变量随机存储在主内存中,甚至对象属性” 的含义?我从未听说过编译器将数据成员随机放置在内存中。例如,请参阅this answer。在任何情况下,都可以使用std::vector 或自定义分配器等容器来保证连续性。【参考方案2】:

我看到的方式是,如果您在非常精细的粒度对象级别(例如抽象的IPixel 接口)使用对象级别的多态性,它本质上是昂贵的。在这种情况下,围绕IPixel 依赖关系的视频处理软件从效率的角度来看会非常糟糕,因为它没有喘息的空间来优化。除了每个像素的动态调度成本之外,甚至这里所需的虚拟指针也可能比整个像素本身大,内存使用量增加一倍或三倍。此外,我们不能再以超出单个像素的方式来处理像素表示,而且最可怕的是,图像中的相邻像素甚至可能不会在内存中连续表示。

与此同时,IImage 可以提供充足的优化空间,因为图像模拟了像素的集合/容器,并且仍然具有很大的灵活性(例如:每种像素格式的不同具体图像表示)。现在每个图像的动态调度很便宜,并且虚拟指针的大小对于整个图像可以忽略不计。我们还可以探索如何以允许我们一次有效地处理多个像素的方式将像素表示为我们内心的内容。因此,与您类似,我认为它是在适当的粗略级别设计对象的问题,这通常意味着事物的集合,以减少所有开销和优化所面临的障碍。

如何代替存储自己的数据成员的对象,它们 仅存储对集合的引用,其中它们的数据成员 依次存储在它们自己的容器中,它们的成员 方法从这些容器返回数据,这样的可能性 应该减少最终到达 CPU 的不需要的数据,并且 增加了在不久的“未来”所需的数据几率。

我喜欢这个想法,但如果你对多态上下文太过分了,你可以回到自定义内存分配器和排序基指针。我经常发现这种设计的用途是在需要聚合以提高效率的情况下提高使用单个元素的便利性(一种情况是使用 SoA 表示的容器,另一种我将在下面介绍)。

多态情况不一定有那么多好处,因为固有的问题在于非同质一次处理粒状事物。为了恢复效率,我们必须恢复关键循环的同质性。

非同质关键循环

Orc 继承CreatureHuman 继承Creature 为例,Elf 继承Elves,但是人类和兽人和精灵有不同的大小/字段,不同的对齐要求和不同的vtables .在这种情况下,当客户端代码想要处理它们的非同构列表时,它们存储指向生物的多态基指针,如下所示:

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();

...如果我们每次引入一种新型生物时都需要在许多地方这样做,这将是一个真正的 PITA 扩展...

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

所以在这种情况下,如果你想要继承和多态,我的解决方案是在容器级别进行抽象:Orcs 继承 CreaturesHumans 继承 CreaturesElves 继承 Creatures。当客户端代码想要表达对特定生物执行的操作时,这会给客户端代码带来一些额外的复杂性,但现在上面的顺序循环可以写成这样:

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

在第一次迭代中,可能会对整个兽人列表(可能就像存储在数组中的一百万兽人)做一些事情。现在该列表中的所有兽人都可以连续存储,我们正在对该列表中的所有兽人应用同构功能。在这种情况下,我们有大量的喘息空间可以在不改变设计的情况下进行优化。

我们在这里仍然有一个利用多态性的非同质循环,但现在这样便宜得多,因为我们只为整个容器的生物支付开销,而不是为每个单独的生物支付开销。处理单个生物的循环现在是同质的。这类似于使用抽象IImage 来使一堆图像(一堆像素容器)变亮,而不是对一次实现IPixel 的一个抽象像素对象这样做。

同构循环和表示

这样就可以将繁重的关键循环从非同质循环转移到一次一个地处理各种不同数据的非同质循环,并将它们转移到同质循环处理连续存储的同质数据。

这是我对界面设计的一般策略。如果它容易以难以优化的方式产生热点,那么在我看来,固有的问题是界面的设计过于精细(Creature,而不是Creatures)。

如果希望使用 OOP,这就是我解决此问题的方法。我认为您的设计理念类型可能有用的地方是简化客户端代码必须表达仅适用于一个特定生物的操作的情况,此时它们可以通过某种指向容器的代理对象工作,并且可能会存储指向特定条目的索引或指针以方便使用,例如 CreatureHandle 它引用抽象 Creatures 容器之一中的特定条目。

【讨论】:

以上是关于面向对象、面向数据、缓存污染和缓存明显性的主要内容,如果未能解决你的问题,请参考以下文章

Android数据库设计——2,面向对象(ORM)操作表:增删改查

Android数据库设计——2,面向对象(ORM)操作表:增删改查

游戏设计模式——面向数据编程(新)

Atitit 面向对象弊端与问题 坏处 缺点

第六章:面向对象(下)

面向对象-面向对象和面向过程的区别