【问题标题】:Is my understanding of AoS vs SoA advantages/disadvantages correct?我对 AoS 与 SoA 优势/劣势的理解是否正确?
【发布时间】:2017-03-03 00:03:10
【问题描述】:

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

首先,为了确保我的理解不是基于错误的前提,即我对 AoS 与 SoA 的功能和优缺点的理解,应用于具有“姓名”和“姓名”的“人员”记录集合与他们关联的“年龄”字段:

数组结构

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

结构数组

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

总而言之,假设您的性能瓶颈是数据访问和易于编码无关紧要,如果您几乎只需要一次访问单个字段大量数据 SoA 的性能可能更高,而如果您经常需要从同一个对象访问多个字段或处理单个对象而不是一次处理多个对象,那么 AoS 的性能会更高。

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

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

最后,我看到了 SoA 在遍历数据时可能需要更多缓存方式的说法。我不完全确定缓存方式是什么,或者如果有的话,具体是指“遍历”数据。我最好的猜测是,“缓存方式”要么是指关联缓存中潜在冲突的数量,要么与关联缓存中的潜在冲突数量相关,并且它与我上面提到的第二个 Con 相关。

【问题讨论】:

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


【解决方案1】:

“遍历”只是指遍历数据。

是的,关于缓存方式和冲突,您是对的。 64B(高速缓存行大小)内存块彼此偏移大 2 次方映射到同一组,因此彼此竞争该组中的方式,而不是缓存在不同组中。 (例如 Intel 的 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 路,但仍然容易受到这样的“混叠”影响,就像哈希表一样,其中对某些集合(存储桶)有大量需求,而对其他集合(存储桶)没有需求集(桶)。对于 CPU 缓存,“散列函数”几乎总是使用一些地址位作为索引,而忽略其他位。 (地址的高位用作标记,以确定集合中是否有任何方式实际缓存请求的块,低位用于选择缓存行中的字节。)


我认为 SoA 的好处主要来自 SIMD(自动矢量化或手动),但如果您倾向于遍历数据,只查看大多数结构中的一两个字段,并且仅在极少数情况下访问其余字段,则你会根据一个成员找到一个有趣的。

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

将最常被访问的字段组合在一起可以为这些访问带来空间局部性的好处,同时仍然让检查关键字段的搜索循环在连续内存上循环(而不是大步前进)。


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

这适用于碰撞检测蒙版(在 2D 空间游戏 (Endless Sky) 中,所有碰撞都是线段和船轮廓之间的碰撞(从精灵自动追踪),而不是两个多边形之间)。这是the original,它在double x,y 对的向量上循环(并使用一些(非内联!)函数将它们作为16B SIMD 向量进行操作,often with slow SSE3 horizontal-add instructions and stuff like that:()。

如果您无法更改数据布局,XY 对上的 SSE2/SSE3 可能总比没有好,但更改布局消除了并行执行 4 个交叉产品的所有改组。 the slides from this SIMD (SSE) intro at Insomniac Games (GDC 2015)。它从非常基本的东西开始,供以前没有使用过 SIMD 的人,并准确地解释了数组结构是如何有用的。最后,它涉及中级/高级 SSE 技术,因此即使您已经了解一些 SIMD 知识,也值得翻阅一下。另请参阅 标签 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;

}

然后我可以用类似(real code here:这是我正在进行的未清理代码,尚未准备好发送到上游)之类的东西循环它

__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;
    }

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


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

如果我想停止存储 x[i+1]-x[i] 并即时计算它,让所有 xs 连续将有很大帮助。使用我的布局,我需要在向量之间进行随机播放,而不是仅仅将未对齐的偏移量增加 1 个浮点数。

它还有望允许编译器自动矢量化某些函数(例如,对于 ARM,或者对于具有更宽矢量的 AVX/AVX2)。

当然,手动矢量化在这里会胜出,因为我正在做类似 XORing 浮点数之类的事情,因为我只关心它们的符号位作为真值,而不是先进行比较然后对比较结果进行异或。 (到目前为止,我的测试表明,将负 0 视为负数仍然会为 Mask::Intersect 提供正确的结果,但是在 C 中表达它的任何方式都将遵循 IEEE 规则,其中 x &gt;= 0x=-0. 是正确的)。


如果您几乎只需要一次访问大量数据的单个字段,AoS 可能会更高效,而如果您经常需要从同一个对象访问多个字段或处理单个对象而不是多个对象立即,SoA 将更加高效。

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

你之前写的时候是对的

当仅处理来自许多“人员”记录的部分数据时,只需将这些数据加载到内存中。

(除非我认为您的意思是“从”内存(到缓存),除非您在谈论内存映射文件和从磁盘到内存的错误页面。)


索引寻址模式

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

对于多个指针,您将需要在 x86 上使用像 [reg1 + 4*reg2] 这样的寻址模式,或者您需要在循环中单独增加一堆不同的指针。索引寻址模式在英特尔 SnB 系列上可能稍微慢一些,因为它们can't stay micro-fused with ALU uops in the out-of-order core (only in the decoders and uop cache)。 Skylake 可以让它们保持微融合,但需要进一步测试才能确定英特尔何时进行此更改。当 FMA 以外的三输入指令(如 CMOV 和 ADC)解码为单个 uop 时,也许使用 Broadwell,但这纯粹是猜测。需要对 Haswell 和 Broadwell 进行测试。

【讨论】:

  • 顺便说一句,我没有仔细阅读整个问题。这可能无法完全回答您的问题,更像是我最近一直在思考的东西的脑残。
  • 谢谢,这个回答很有帮助!无论是因为它更完整,还是只是从不同的角度来看待这个主题,它都帮助我更多地理解了这个主题。您对错别字是正确的,尽管第二个错字比懒惰使用语言要少。你明确回答了我的问题的一部分,关于索引寻址,你没有提到就含蓄地回答了。祝您继续项目顺利!
  • @P...:哦,那是真的。它将占用更多的寄存器,这些寄存器为您循环的每个单独的数组保存单独的基地址。我确实提到了我的混合交错布局的一个优点是只需要一个指针。使用多个指针,您将需要使用[reg1 + 4*reg2] 寻址模式,或者您需要在循环中单独增加一堆不同的指针。 SnB 上的索引寻址模式可能稍微慢:stackoverflow.com/questions/26046634/…
  • 啊,我明白了。我误解了您描述的优势,起初认为它只允许一次访问多个字段(取决于 SIMD 通道的数量)。感谢您的澄清!
  • 英特尔最近实际上做了很多工作,在网络研讨会上,他们有一个产品可以让人们获取 AoS 代码,并且在内存布局下面是 SoA。如果一个人真的命中了所有数据,那么 SoA 是有意义的,但它更像是一个 COBAL 或数据库,其中并没有真正发生很多事情,那么 AoS 可能会更好。如果要完成大量 SIMD 工作,那么 AoS 会有所帮助。如果需要收集数据,那么一个人会因此而损失惨重,而且会一败涂地。
猜你喜欢
  • 2011-03-25
  • 2011-02-08
  • 1970-01-01
  • 2019-12-23
  • 2015-02-24
  • 2011-11-06
  • 2013-01-08
  • 2010-10-31
  • 1970-01-01
相关资源
最近更新 更多