11

我试图很好地掌握面向数据的设计以及如何在考虑缓存的情况下进行最佳编程。基本上有两种情况,我无法完全确定哪个更好,为什么 - 拥有一个对象向量还是多个具有对象原子数据的向量更好?

A) 对象向量示例

struct A
{
    GLsizei mIndices;
    GLuint mVBO;
    GLuint mIndexBuffer;
    GLuint mVAO;

    size_t vertexDataSize;
    size_t normalDataSize;
};

std::vector<A> gMeshes;

for_each(gMeshes as mesh)
{
    glBindVertexArray(mesh.mVAO);
    glDrawElements(GL_TRIANGLES, mesh.mIndices, GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);

    ....
}

B) 带有原子数据的向量

std::vector<GLsizei> gIndices;
std::vector<GLuint> gVBOs;
std::vector<GLuint> gIndexBuffers;
std::vector<GLuint> gVAOs;
std::vector<size_t> gVertexDataSizes;
std::vector<size_t> gNormalDataSizes;

size_t numMeshes = ...;

for (index = 0; index++; index < numMeshes)
{
    glBindVertexArray(gVAOs[index]);
    glDrawElements(GL_TRIANGLES, gIndices[index], GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);

    ....
}

哪一个内存效率更高且缓存更友好,从而导致更少的缓存未命中和更好的性能,为什么?

4

4 回答 4

5

根据您所谈论的缓存级别,缓存的工作方式如下:

  • 如果数据已经在缓存中,那么访问速度很快
  • 如果数据不在缓存中,那么您会产生成本,但是整个缓存行(或页面,如果我们正在谈论 RAM 与交换文件而不是缓存与 RAM)被带入缓存,因此接近丢失地址的访问将不要错过。
  • 如果幸运的话,内存子系统将检测到顺序访问并预取它认为您将需要的数据。

所以天真地要问的问题是:

  1. 发生了多少缓存未命中?-- B 获胜,因为在 A 中,您为每条记录获取一些未使用的数据,而在 B 中,您在迭代结束时获取的只是一个小的舍入误差。因此,为了访问所有必要的数据,假设有大量记录,B 会获取更少的缓存行。如果记录的数量微不足道,那么缓存性能可能与代码的性能几乎没有关系或根本没有关系,因为使用足够少量数据的程序会发现它一直都在缓存中。
  2. 访问是顺序的吗?——在这两种情况下都是可以的,尽管在情况 B 中这可能更难检测到,因为有两个交错的序列,而不仅仅是一个。

所以,我有点期望 B对这段代码更快。然而:

  • 如果这是对数据的唯一访问,那么您可以通过从struct. 就这样做吧。据推测,事实上它不是对程序中数据的唯一访问,其他访问可能会以两种方式影响性能:它们实际花费的时间,以及它们是否用您需要的数据填充缓存。
  • 我所期望的和实际发生的往往是不同的事情,如果你有能力测试它,那么依赖猜测是没有意义的。在最好的情况下,顺序访问意味着在任一代码中都没有缓存未命中。测试性能不需要特殊工具(尽管它们可以使其更容易),只需一个带秒针的时钟。在紧要关头,用手机充电器制作一个钟摆。
  • 我忽略了一些并发症。根据硬件,如果您对 B 不走运,那么在最低缓存级别,您可能会发现对一个向量的访问正在驱逐对另一个向量的访问,因为相应的内存恰好使用缓存中的相同位置。这将导致每条记录有两次缓存未命中。这只会发生在所谓的“直接映射缓存”上。“双向缓存”或更好的方法将通过允许两个向量的块共存,即使它们在缓存中的首选位置相同,也可以节省时间。我不认为PC硬件通常使用直接映射缓存,但我不确定,我对GPU也不太了解。
于 2013-10-01T22:09:13.093 回答
1

我知道这部分是基于意见的,也可能是过早优化的情况,但您的第一个选择绝对具有最佳美学效果。这是一个向量与六个向量 - 在我看来没有竞争。

对于缓存性能,它应该更好。这是因为替代方案需要访问两个不同的向量,这会在您每次渲染网格时拆分内存访问。

使用结构方法,网格本质上是一个独立的对象,并且正确地暗示与其他网格没有关系。绘制时,您只能访问网格,而在渲染所有网格时,您以缓存友好的方式一次执行一个。是的,你会更快地吃掉缓存,因为你的向量元素更大,但你不会竞争它。

以后您还可能会发现使用此表示的其他好处。 ,如果您想存储有关网格的其他数据。在更多向量中添加额外数据将很快使您的代码混乱并增加犯愚蠢错误的风险,而对结构进行更改则微不足道。

于 2013-10-01T21:51:19.360 回答
1

我建议使用perfoprofile进行分析并将结果发布到此处(假设您正在运行 linux),包括您迭代的元素数量、总迭代次数以及您测试的硬件。

如果我不得不猜测(这只是一个猜测),我怀疑第一种方法可能会更快,因为每个结构中的数据都是局部性的,并且希望操作系统/硬件可以为您预取额外的元素。但同样,这将取决于缓存大小、缓存行大小和其他方面。

定义“更好”也很有趣。您是否正在寻找处理 N 个元素的总时间、每个样本的低方差、最小的缓存未命中(这将受到系统上运行的其他进程的影响)等。

不要忘记,使用 STL 向量,您也受分配器的支配……例如,它可以随时决定重新分配数组,这将使您的缓存无效。如果可以的话,另一个要尝试隔离的因素!

于 2013-10-01T21:52:25.160 回答
0

取决于您的访问模式。您的第一个版本是AoS(结构数组),第二个版本是SoA(数组结构)

如果存在通常在 AoS 表示中获得的任何类型的结构填充,SoA 往往会使用更少的内存(除非您存储的元素太少以至于数组的开销实际上并不重要)。它也往往是一个更大的 PITA 编码,因为您必须维护/同步并行数组。

AoS 倾向于擅长随机访问。举个例子,为简单起见,假设每个元素都适合缓存行并正确对齐(例如,64 字节大小和对齐)。在这种情况下,如果您随机访问一个nth元素,您将在单个缓存行中获取该元素的所有相关数据。如果您使用 SoA 并将这些字段分散到不同的数组中,则您必须将内存加载到多个缓存行中,才能加载该元素的数据。而且因为我们以随机模式访问数据,我们根本无法从空间局部性中受益,因为我们将要访问的下一个元素可能完全位于内存中的其他位置。

然而,SoA 倾向于在顺序访问方面表现出色,主要是因为对于整个顺序循环来说,首先加载到 CPU 缓存中的数据通常较少,因为它排除了结构填充和冷字段。冷字段是指您不需要在特定顺序循环中访问的字段。例如,物理系统可能不关心与粒子在用户眼中的外观有关的粒子场,例如颜色和精灵句柄。那是无关紧要的数据。它只关心粒子位置。SoA 允许您避免将不相关的数据加载到缓存行中。它允许您一次将尽可能多的相关数据加载到缓存行中,这样您就可以通过 SoA 减少强制缓存未命中(以及足够大数据的页面错误)。

这也仅涵盖内存访问模式。借助 SoA 代表,您还可以编写更高效、更简单的 SIMD 指令。但同样它主要适用于顺序访问

您也可以混合使用这两个概念。您可以将 AoS 用于以随机访问模式经常一起访问的热字段,然后取出冷字段并将它们并行存储。

于 2017-12-21T03:11:30.930 回答