面向数据的心态
面向数据的设计并不意味着将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调用相关的动态调度开销,以及无法内联的那些函数。排除所有这些的主要思想是批量处理(适用时)。
ball->do_something();
与ball_table.do_something(ball)
)比较麻烦(&ball_table, index)
。