面向数据的设计-不超过1-2个结构“成员”不切实际?


23

数据导向设计的常见示例是Ball结构:

struct Ball
{
  float Radius;
  float XYZ[3];
};

然后他们提出了一种迭代std::vector<Ball>向量的算法。

然后它们给您同样的东西,但是在面向数据的设计中实现:

struct Balls
{
  std::vector<float> Radiuses;
  std::vector<XYZ[3]> XYZs;
};

这样做很好,而且如果您要先迭代所有半径,然后遍历所有位置,依次类推,那么一切都很好。但是,如何移动矢量中的球?在原始版本中,如果您有std::vector<Ball> BallsAll,则可以将任何移动BallsAll[x]到任何一个BallsAll[y]

但是,对于面向数据的版本,要对每个属性执行相同的操作(对于Ball,则必须执行两次(半径和位置)。但是,如果您拥有更多的属性,情况会变得更糟。您必须为每个“球”保留一个索引,并且在尝试移动它时,必须在每个属性向量中进行移动。

这不会破坏面向数据设计的任何性能优势吗?

Answers:


23

另一个答案很好地概述了如何很好地封装面向行的存储并给出更好的视图。但是,既然您还询问性能,那么让我来解决一下:SoA布局不是灵丹妙药。这是一个相当不错的默认值(对于缓存的使用;在大多数语言中,并不是很容易实现),但这还不是全部,甚至在面向数据的设计中也是如此(无论这意味着什么)。您读过的一些介绍的作者有可能错过了这一点,只介绍了SoA布局,因为他们认为这就是DOD的全部重点。他们会错的,幸运的是,并不是每个人都陷入陷阱

您可能已经意识到,并非所有原始数据都可以从其自身的数组中提取出来。当拆分为单独阵列的组件通常分别进行访问时,SoA布局很有用。但是并不是每个微小的部分都是孤立访问的,例如,位置矢量几乎总是被读取和更新,因此自然地,您不会拆分该位置矢量。实际上,您的示例也没有这样做!同样,如果您通常访问所有 Ball的属性,因为花费了大部分时间来交换Ball集合中的Ball,那么将它们分开是没有意义的。

但是,国防部还有另一面。仅仅通过将内存布局旋转90°并做最少的工作来修复最终的编译错误,您并不能获得全部的缓存和组织优势。在此旗帜下还有其他一些常见的技巧。例如,“基于存在的处理”:如果您经常停用球并重新将其重新激活,请不要在球对象上添加标志,并且不要将标志设置为false的更新循环忽略球。将球从“活动”集合移动到“非活动”集合,并使更新循环仅检查“活动”集合。

与您的示例更为重要和相关的是:如果您花费大量时间对球阵列进行改组,则可能是您做错了什么。为什么顺序很重要?可以不要紧吗?如果是这样,您将获得以下好处:

  • 您不需要改组集合(最快的代码根本就没有代码)。
  • 您可以更轻松,更有效地添加和删除(交换到末尾,最后放下)。
  • 其余的代码可能有资格进行进一步的优化(例如您关注的布局更改)。

因此,不要盲目地向所有内容扔SoA,而要考虑您的数据及其处理方式。如果发现在一个循环中处理位置和速度,然后遍历网格,然后更新命中点,请尝试将内存布局分为这三个部分。如果发现隔离访问位置的x,y,z分量,则可以将位置向量转换为SoA。如果您发现自己对数据的处理不只是实际要做的事情,那么也许就不要对数据进行处理。


18

面向数据的心态

面向数据的设计并不意味着将SoAs应用于任何地方。这仅意味着设计主要侧重于数据表示的体系结构-特别是侧重于有效的内存布局和内存访问。

在适当的时候,这可能会导致SoA代表如下:

struct BallSoa
{
   vector<float> x;        // size n
   vector<float> y;        // size n
   vector<float> z;        // size n
   vector<float> r;        // size n
};

...这通常适用于垂直循环逻辑,该逻辑不能同时处理球心向量分量和半径(这四个场不是同时热的),而是一次(循环一个半径,另外3个循环)通过球心的各个组成部分)。

在其他情况下,如果经常一起访问字段(如果循环逻辑遍历球的所有字段而不是单独遍历)和/或需要随机访问球,则可能更适合使用AoS:

struct BallAoS
{
    float x;
    float y;
    float z;
    float r;
};
vector<BallAoS> balls;        // size n

...在其他情况下,使用兼顾两种优势的混合动力可能更合适:

struct BallAoSoA
{
    float x[8];
    float y[8];
    float z[8];
    float r[8];
};
vector<BallAoSoA> balls;      // size n/8

...您甚至可以使用半浮点数将球的大小压缩到一半,以将更多的球字段容纳到缓存行/页面中。

struct BallAoSoA16
{
    Float16 x2[16];
    Float16 y2[16];
    Float16 z2[16];
    Float16 r2[16];
};
vector<BallAoSoA16> balls;    // size n/16

...甚至访问半径的频率也几乎不像球体中心那样高(例如,您的代码库经常将它们视为点,而很少将其视为球体)。在这种情况下,您可以进一步应用热/冷字段拆分技术。

struct BallAoSoA16Hot
{
    Float16 x2[16];
    Float16 y2[16];
    Float16 z2[16];
};
vector<BallAoSoA16Hot> balls;     // size n/16: hot fields
vector<Float16> ball_radiuses;    // size n: cold fields

面向数据的设计的关键是在做出设计决策时尽早考虑所有这些类型的表示形式,而不要将自己陷于背后带有公共接口的次优表示形式。

它把重点放在了内存访问模式和相关的布局上,使它们比平时更受关注。从某种意义上说,它甚至可能会破坏抽象。通过更多地运用这种思维方式,我发现我不再关注std::deque,例如,就其算法要求而言,与其在聚集的连续块表示以及它在存储器级别的随机访问方式一样多。它在某种程度上侧重于实现细节,但是实现细节对性能的影响与描述可伸缩性的算法复杂性一样多或多。

过早优化

至少一目了然,面向数据设计的许多主要重点将以危险的方式接近过早的优化。经验通常会告诉我们,这种微优化最好是事后分析,并且需要使用探查器。

然而,也许从面向数据的设计中获得的一个重要信息是为此类优化留出空间。这就是面向数据的心态可以帮助实现的目标:

面向数据的设计可以留出喘息的空间来探索更有效的表示形式。这并不一定要一口气实现内存布局的完美,而是要事先进行适当的考虑以允许越来越好的表示形式。

面向对象的粒度设计

许多面向数据的设计讨论都将其与经典的面向对象编程概念相提并论。但是,我将提供一种看待这种方式的方法,它并不像完全消除OOP那样具有硬性。

面向对象设计的困难在于,它经常会诱使我们在非常细粒度的级别上对接口进行建模,从而使我们陷入了一次只能一次标量的思维方式,而不是并行的批量思维方式。

作为一个夸张的例子,想象一下将一个面向对象的设计思想应用于图像的单个像素。

class Pixel
{
public:
    // Pixel operations to blend, multiply, add, blur, etc.

private:
    Image* image;          // back pointer to access adjacent pixels
    unsigned char rgba[4];
};

希望没有人真正做到这一点。为了使示例更真实,我存储了指向包含像素的图像的后指针,以便它可以访问相邻像素以进行诸如模糊之类的图像处理算法。

图像后指针立即增加了巨大的开销,但是即使我们排除了它(仅使像素的公共接口提供了适用于单个像素的操作),我们最终还是只代表了一个像素。

现在,除了返回指针之外,在C ++上下文中直接开销意义上的类也没有任何问题。优化C ++编译器非常擅长采用我们构建的所有结构并将其淘汰给铁匠铺。

这里的困难在于我们要在像素级的粒度上对封装的接口进行建模。这使我们陷入了这种细粒度的设计和数据中,潜在的大量客户端依赖关系将它们耦合到此Pixel接口。

解决方案:消除颗粒状像素的面向对象的结构,并在处理大量像素(在图像级别)的更粗糙级别上开始对接口建模。

通过在批量图像级别进行建模,我们有更多的优化空间。例如,我们可以将大图像表示为16x16像素的合并图块,这些图块完全适合64字节的缓存行,但允许跨步较小的像素进行有效的相邻垂直访问(如果我们有许多图像处理算法可以(需要以垂直方式访问相邻像素)作为硬核数据导向的示例。

在更粗的层次上进行设计

上面的在图像级别对接口进行建模的示例是不费吹灰之力的示例,因为图像处理是一个非常成熟的领域,已经进行了研究和优化以至死灰复燃。但是不太明显的可能是粒子发射器中的粒子,子图形与子图形集合,边线图中的边甚至是人与人集合。

允许进行面向数据的优化(预见或后见)的关键通常归结为在更粗略的层次上大量设计接口。为单个实体设计接口的想法已被设计为具有大量操作以批量处理它们的实体的集合所取代。这尤其是立即针对需要访问所有内容且无济于事且具有线性复杂性的顺序访问循环。

面向数据的设计通常始于合并数据以形成聚合模型数据的想法。类似的心态也呼应与其伴随的界面设计。

这是我从面向数据的设计中学到的最有价值的一课,因为我对计算机体系结构的了解不足,无法在首次尝试时为某些东西找到最佳的内存布局。我手头有一个探查器,这变得很累赘(有时在我未能加快速度的过程中有一些遗漏)。但是,面向数据的设计的界面设计方面使我有余地寻求越来越多的有效数据表示形式。

关键是要在比我们通常想做的更粗糙的级别上设计接口。这通常还具有一些副作用,例如减轻与虚拟函数,函数指针调用,dylib调用相关的动态调度开销,以及无法内联的那些函数。排除所有这些的主要思想是批量处理(适用时)。


5

您所描述的是一个实现问题。OO设计显然不是与实现。

您可以将面向列的Ball容器封装在一个暴露行或列视图的接口后面。您可以实现用类似方法一球对象volumemove,这仅仅是修改底层逐列结构的相应值。同时,您的Ball容器可以公开一个接口,以进行高效的按列操作。有了适当的模板/类型和聪明的内联编译器,您可以以零运行时间成本使用这些抽象。

与按行修改数据相比,按列访问数据的频率是多少?在列存储的典型用例中,行的顺序不起作用。您可以通过添加单独的索引列来定义行的任意排列。更改顺序仅需要交换索引列中的值。

可以使用其他技术来有效地添加/删除元素:

  • 维护已删除行的位图,而不是移动元素。过于稀疏时,压缩结构。
  • 在类似B树的结构中将行分组为适当大小的块,以便在任意位置插入或删除都不需要修改整个结构。

客户代码将看到一个Ball对象序列,一个可变的Ball对象容器,一个半径序列,一个Nx3矩阵等;它不必担心那些复杂(但高效)结构的丑陋细节。这就是对象抽象为您带来的好处。


+1 AoS组织可以完美地修改为一个不错的面向实体的API,尽管要承认(除非)您想要通过伪指针伪造一个相干的实体,否则使用(ball->do_something();ball_table.do_something(ball))比较麻烦(&ball_table, index)

1
我将更进一步:可以完全从OO设计原则得出使用SoA的结论。诀窍是您需要一种方案,其中列比行是更基本的对象。球不是一个很好的例子。相反,请考虑具有各种属性(例如高度,土壤类型或降雨)的地形。每个属性都被建模为ScalarField对象,该对象具有其自己的方法(例如,gradient()或divergence()),这些方法可能会返回其他Field对象。您可以封装诸如地图分辨率之类的东西,并且地形上的不同属性可以使用不同的分辨率。
6807年

4

简短的答案:您完全正确,而与此类似的文章完全没有提到这一点。

完整的答案是:您的示例的“数组结构”方法对于某些类型的操作(“列操作”)可以具有性能优势,而对于其他类型的操作(“行操作”)可以具有“结构数组”的性能优势。 ”,就像您上面提到的那样)。相同的原则影响了数据库体系结构,有面向列的数据库与经典的面向行的数据库

因此,选择设计时要考虑的第二件事是程序中最需要哪种操作,以及这些操作是否将从不同的内存布局中受益。但是,首先要考虑的是您是否真的需要这种性能(我认为在游戏编程中,上面的文章通常是您的要求)。

当前的大多数OO语言都使用“结构数组”存储布局来存储对象和类。获得OO的优势(例如为数据创建抽象,封装以及基本功能的更多局部作用域)通常与这种内存布局有关。因此,只要您不进行高性能计算,我就不会将SoA视为主要方法。


3
DOD并不总是意味着阵列结构(SoA)布局。这很常见,因为它通常与访问模式匹配,但是当另一种布局效果更好时,则一定要使用它。DOD更为通用(而且更加模糊),更像是一种设计范例,而不是一种特定的数据布局方式。此外,尽管您所引用的文章距离最佳资源还很遥远,并且存在缺陷,但它不会宣传SoA布局。“ A”和“ B”可以是全功能Ball的,也可以是单独的floats或vec3s(它们本身会受到SoA转换)。

2
...并且您提到的面向行的设计始终包含在DOD中。它被称为结构数组(AoS),与大多数资源称为“ OOP方式”(无论是更好还是更糟糕)的区别不在于行布局与列布局,而只是该布局如何映射到内存(许多小对象)通过指针与所有记录的大连续表链接)。总之,-1是因为尽管您对OP的误解提出了很好的意见,但您却歪曲了整个DOD爵士乐的形象,而不是纠正OP对DOD的理解。

@delnan:感谢您的评论,您可能是正确的,我应该使用术语“ SoA”而不是“ DOD”。我相应地编辑了答案。
布朗

好多了,删除了downvote。请查看user2313838的答案,以了解如何通过漂亮的“面向对象”的API(在抽象,封装和“基本功能的更多本地范围”方面)将SoA进行统一。对于AoS布局,它更自然(因为数组可以是愚蠢的通用容器,而不是与元素类型结婚),但这是可行的。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.