我对AoS vs SoA优缺点的理解是否正确?

时间:2016-10-20 20:14:16

标签: caching memory sse simd data-oriented-design

我最近一直在阅读AoS vs SoA结构设计和data-oriented design。很难找到关于这两者的信息,而我所发现的似乎比我拥有更多的处理器功能。也就是说,我对前一个主题的理解特别导致了一些我认为应该能够理解答案的问题。

首先,为了确保我的理解不是基于错误的前提,我对AoS vs SoA的功能和利弊的理解,适用于“人物”的集合。记录名称'和'年龄'与它们相关的字段:

阵列结构

  • 将数据存储为由多个数组组成的单个结构,例如作为People对象,字段Names作为字符串数组,Ages作为整数数组。
  • 列表中第三个人的信息将来自People.Names[2]People.Ages[2]
  • 优点:
    • 仅处理来自许多人的一些数据时。记录,只需要从内存中加载数据。
    • 所述数据以同类方式存储,允许在大多数此类情况下通过SIMD指令更好地使用缓存。
  • 缺点:      - 当需要一次访问多个字段时,上述优点就会消失。      - 访问一个或几个对象的所有数据变得效率较低。      - 大多数编程语言需要更冗长,更难以读/写的代码,因为没有明确的“人”。结构

结构数组

  • 将数据存储为多个结构,每个结构都有一整套字段,这些字段本身存储在所有此类结构的数组中,例如PeoplePerson对象数组,其中Name 1}}作为字符串字段,Age作为整数字段。
  • 第三人的信息将由People[2].NamePeople[2].Age
  • 之类的信息提供
  • 优点:
    • 代码围绕一个更简单的心智模型构建,间接被抽象化。
    • 单个记录易于访问和使用。
    • Person结构的存在使得在大多数编程语言中编写代码变得更加直接。
  • 缺点:
    • 当处理大量记录中的一些数据时,需要将整组结构加载到内存中,包括不相关的数据。
    • 结构阵列不是同质的,在这种情况下限制了SIMD指令可以提供的优势。

它的长短似乎是,假设为了论证,你的性能瓶颈是数据访问和编码的简易性是无关紧要的,如果你几乎只需要一次访问一个字段大量数据SoA可能性能更高,而如果您经常需要从同一个对象访问多个字段或处理单个对象而不是一次处理多个字段,AoS将更具性能。

尽管如此,我所阅读的一些内容似乎让图片变得混乱。首先,多个消息来源已经声明SoA需要索引寻址,据称这种寻址效率低下。我无法理解这一点,也无法找到任何解释。在我看来,AoS和SoA需要完全相同的操作才能访问任何特定的数据,尽管顺序不同,除了SoA需要一个额外的指针(可能多于一个,具体取决于所使用的结构类型)。稍微简化一下,为了在AoS下面的上面例子中得到第五个人的年龄,你首先得到指向数组的指针,向它添加4,在数组的那个元素处获取结构指针,添加一个大小字符串指向它,因为age是第二个字段,然后访问该指针处的整数。在SoA下,您将获得指向结构的指针并向其添加字符串数组指针的大小以获取年龄列表,然后获取指向存储在那里的整数列表的指针并向其添加4,然后获取整数存储在那里。

其次,我不清楚SoA的好处在多大程度上取决于特定的CPU架构。一方面,我对上述优点的理解并不依赖于任何特定的体系结构,除了SIMD指令在某些情况下可以提供AoS下无法提供的额外好处。另一方面,我已经看到声称可以限制SoA的优势,具体取决于特定SIMD架构中可用的通道数量。同样,这似乎只会影响SIMD指令可以提供的更多通用缓存优势的额外好处。

最后,我已经看到SoA在遍历数据时需要更多缓存方式的说法。我不完全确定缓存方式是什么或者什么,如果有的话,特别是通过“遍历”来表示。数据。我最好的猜测是'缓存方式'或者引用或关联关联缓存中潜在冲突的数量,并且它与上面提到的第二个Con相关。

1 个答案:

答案 0 :(得分:9)

"横动"只是意味着循环数据。

是的,你对缓存方式和冲突是正确的。 64B(高速缓存行大小)存储器块彼此偏移2的大功率映射到同一组,因此相互竞争该组中的方式,而不是在不同的集合中高速缓存。 (例如,英特尔的L1数据缓存为32kiB,8路关联,64B线。32kiB / 64 B/line = 512 lines分组为512 lines / 8 ways/set = 64 sets

加载9个项目相互偏移4kiB(64B/line * 64 sets,而不是巧合的页面大小)将驱逐第一个。

L2和L3缓存具有更高的关联性,如16或24路,但仍然容易出现#34;别名"像这样,就像哈希表一样,对某些集合(存储桶)有很多需求而对其他集合(存储桶)没有需求。对于CPU缓存,"哈希函数"几乎总是使用一些地址位作为索引,并忽略其他位。 (地址的高位用作标记,以确定集合中的任何方式是否实际缓存了所请求的块,低位用于选择缓存行中的字节。)

我认为SoA的好处主要来自SIMD(自动矢量化或手动),但是如果你倾向于在数据上循环只查看大多数结构中的一个或两个字段,并且只在极少数情况下访问其余字段你会发现一个基于一个成员的有趣的人。

对于您在一起查看的每个事物(或事物组),使用单独数组的混合方法可能是有意义的,其中每个对象的其余数据都在结构数组中。我想象一个线性搜索循环,其中大多数对象基于查看一个int字段而被拒绝,但是对于通过该测试的少数对象,您会查看所有字段。

将大多数一起访问的字段组合在一起可以为这些访问提供空间局部性的好处,同时仍然允许检查关键字段的搜索循环遍历连续的内存(而不是大步)。

我目前正在尝试在SIMD矢量大小的组中交错的布局。遍历数据的大多数代码都需要来自每个对象的所有字段,并且这样做意味着循环只需要一个指针,并且所有内存都被分配为单个块。

这是用于碰撞检测蒙版(在2D空间游戏(无尽的天空)中,它在线段和船轮廓之间的所有碰撞(从精灵自动跟踪),而不是在两个多边形之间)。这里的the original循环遍历double x,y对的向量(并使用一些(非内联!)函数对它们作为16B SIMD向量进行操作,{{3} } :()。

如果你不能改变数据布局,那么XY对上的SSE2 / SSE3可能总比没有好,但改变布局会消除并行执行4个交叉产品的所有混乱。 请参阅often with slow SSE3 horizontal-add instructions and stuff like that。对于那些在使用SIMD之前没有做过任何事情的人来说,它是从非常基本的东西开始的,并且确切地解释了数组的结构是如何有用的。最后,它获得了中级/高级SSE技术,所以即使你已经知道一些SIMD的东西,它也值得翻阅。另请参阅the slides from this SIMD (SSE) intro at Insomniac Games (GDC 2015)标记wiki以获取其他一些链接。

无论如何,这是我提出的交错数据结构:

class Mask {
...

struct xy_interleave {
    static constexpr unsigned vecSize = 4;
    static constexpr unsigned alignMask = vecSize-1;
    alignas(64) float x[vecSize];
    float y[vecSize];
    // TODO: reduce cache footprint by calculating this on the fly, maybe with an unaligned load?
    float dx[vecSize]; // next - current;   next.x = x+dx
    float dy[vecSize];
};
std::vector<xy_interleave> outline_simd;

}

然后我可以用(之类的东西循环它:这是我正在进行的未清理的代码,它还没有准备好向上游发送)

__m128 minus_point_ps = _mm_cvtpd_ps(-point);    // + is commutative, which helps the compiler with AVX
const __m128 minus_px = _mm_set1_ps(minus_point_ps[0]);
const __m128 minus_py = _mm_set1_ps(minus_point_ps[1]);
const __m128 range2 = _mm_set1_ps(float(range*range));

for(const xy_interleave &curr : outline_simd)
{
    __m128 dx = _mm_load_ps(curr.x) + minus_px;
    __m128 dy = _mm_load_ps(curr.y) + minus_py;
    // this is using GNU Vector Extensions for + and *, instead of _mm_add_ps and _mm_mul_ps, since GNU C++ defines __m128 in terms of __v4sf
    __m128 cmp = _mm_cmplt_ps(dx*dx - range2, dy*dy);  // transform the inequality for more ILP
    // load the x and y fields from this group of 4 objects, all of which come from the same cache line.

    if(_mm_movemask_ps(cmp))
        return true;
}

这会编译为非常漂亮的asm循环,只有一个指针循环遍历std :: vector,而向量从相对于该循环指针的常量偏移量加载。

但是,相同数据的标量回退循环不太常见。 (实际上我也在手动矢量化的部分中使用这样的循环(带j+=4),所以我可以在不破坏代码的情况下改变交错。它完全编译,或者变成展开)。

// TODO: write an iterator or something to make this suck less
for(const xy_interleave &curr : outline_simd)
    for (unsigned j = 0; j < curr.vecSize; ++j)
    {
        float dx = curr.x[j] - px;
        float dy = curr.y[j] - py;
        if(dx*dx + dy*dy < range2)
            return true;
    }

不幸的是,即使对于没有条件的简单案例(例如,只是从查询x,y到碰撞掩码中的任何点找到最小范围),我也没有运气获得gcc或clang来自动矢量化这一点。 ,而不是检查一个点是否在范围内。)

我可能放弃这个想法并使用单独的x和y数组。 (也许在同一个std::vector<float>(使用对齐的分配器)中尾部打包以保持它是一个分配的一部分,但这仍然意味着循环需要单独的x和y指针,因为x和y之间的偏移对于给定的顶点,它将是运行时变量,而不是编译时常量。)

如果我想停止存储x并在运行中计算它,那么所有x[i+1]-x[i] s连续将是一个很大的帮助。在我的布局中,我需要在向量之间进行混洗,而不是仅仅通过1个浮点数进行未对齐的偏移。

希望编译器能够自动矢量化一些函数(例如ARM,或者AVX / AVX2更宽的矢量)。

当然,手动矢量化将在这里获胜,因为我正在做像XORing一样的东西浮在一起因为我只关心它们的符号位作为真值,而不是做比较然后对比较结果进行异或。 (到目前为止,我的测试表明,将负0视为负值仍然可以为Mask :: Intersect提供正确的结果,但是在C中表达它的任何方式都将遵循IEEE x >= 0x=-0.为真的规则}})。

  

如果您几乎完全需要在大量数据上一次访问一个字段,那么AoS可能会更高效,而如果您经常需要从同一个对象访问多个字段或处理单个对象而不是许多同时,SoA将更具性能。

你完全倒退了。这是一个错字吗?将所有foo[i].key字段分组到foo.key[i]数组意味着它们全部打包在缓存中,因此只访问许多对象中的那一个字段意味着您将使用所有64字节的字段您触摸的每个缓存行。

你在写作

之前就更正确了
  

仅处理来自许多人的一些数据时。记录,只需要将数据加载到内存中。

(除了我认为你的意思是&#34;来自&#34;内存(进入缓存),除非你在谈论内存映射文件和从磁盘到内存的错误页面。)

索引寻址模式

在您查看每个对象中的两个或三个字段的情况下, SoA布局将占用更多寄存器,这些寄存器为您要循环的每个独立阵列保存单独的基址。

使用多个指针,您可能希望在x86上使用[reg1 + 4*reg2]等寻址模式,或者您需要在循环内单独增加一堆不同的指针。在Intel SnB系列上,索引寻址模式可能略微慢,因为它们real code here。 Skylake可以将它们保持微融合,但需要进一步测试以确定英特尔何时进行此项更改。也许与Broadwell在FMA之外的三输入指令(如CMOV和ADC)解码为单个uop,但这是一个纯粹的猜测。需要对Haswell和Broadwell进行测试。