我AOS的理解VS SoA的优势/劣势是否正确?

问题描述 投票:11回答:1

我最近一直念叨AoS vs SoA结构设计和data-oriented design。这是很奇怪很难找到任何信息,以及什么我发现似乎假定的处理器功能更深入的了解比我拥有。这就是说,我做什么都懂特别是导致一些问题,我想我应该能够理解的答案,前者的话题。

首先,以确保我没有立足我的理解掀起了虚假的前提下,我的功能和优点和AOS VS的SOA利弊的理解,应用到“人”记录的集合与“名”和“年龄”字段与之相关联的:

阵列的结构

  • 将数据存储为作为People对象的单个结构组成多个阵列,例如以场为Names字符串数组和作为Ages整数数组。
  • 信息,说,第三人的名单将通过类似People.Names[2]People.Ages[2]给予
  • 优点: 当只有一些从许多“人”记录数据的工作,只需将数据从内存中加载。 上述数据存储在一个均匀的方式,使高速缓存在大多数这种情况下付诸更好地利用SIMD指令。
  • 缺点: - 当多个领域需要一次访问,以上优势消失。 - 访问的所有数据的一个或几个对象的效率变低。 - 大多数编程语言需要更多的冗长,难以读/写代码,因为没有明确的“人”的结构。

结构数组

  • 将数据存储为多个结构,其中每一个有一个完整的一组字段,本身存储在所有这样的结构的阵列,例如People对象,其具有作为Person一个字符串字段和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可以要求更多的缓存方式的要求。我不能完全肯定的方式是什么缓存或者什么,如果有的话,特别是通过“穿越”数据的意思。我最好的猜测是,“缓存的方式”要么是指或潜在的冲突的关联高速缓存的数量相关,它涉及到我上面提到的第二精读。

caching memory sse simd data-oriented-design
1个回答
11
投票

“穿越”只是意味着遍历数据。

是的,你说得对缓存的方式和碰撞。 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路,但仍然容易受到“走样”就是这样,就像一个哈希表,其中有很多对一些套的需求(桶)和其他组没有需求(桶)。对于CPU的缓存中,“散列函数”几乎总是使用一些地址位作为索引,而忽略其他位。 (一个地址的高位用作标签,以确定是否在该组的任何方式被实际缓存所请求的块,和低比特被用于选择该高速缓存行内的字节。)


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

对每个单独的东西阵列(或事物组)你看一起混合方法可能是有意义的,与数据的结构的数组,其余为每个对象。我想象大多数对象基于寻找一个int场拒绝了线性搜索循环,但对于通过该测试的几个对象,你看所有的领域。

分组在一起大多是一起访问为您提供了这些访问的空间局部性的优势领域,同时还让那检查过连续内存的关键领域循环搜索循环(而不是一个大跨步)。


我目前正在在SIMD向量大小的组交错的布局进行试验。最横穿数据从每一个对象需要的所有字段的代码,并做这种方式的装置的循环仅需要一个指针,并且所有的存储器被分配为单个块。

这是碰撞检测口罩(在2D空间游戏(无尽的天空),其中它是一个线段和船的轮廓(从精灵自动跟踪之间的所有碰撞),而不是两个多边形之间)。这里的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翻转。也看到了一些其他的链接标签维基。


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

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 ::向量,相对于这个循环指针常量偏移向量负荷。

然而,标回退循环在相同的数据不太好看。 (实际上我在手动量化部分使用循环像这样(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或铛为自动向量化本,即使是容易的情况下,没有条件语句(如刚刚发现的最小范围从查询X,Y在碰撞面罩的任何位置,而不是检查是否一个点在范围内)。


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

把所有的xs连续将是一个很大的帮助,如果我想停止存储x[i+1]-x[i],并计算它的飞行。随着我的布局,我需要向量之间的洗牌,而不是仅仅做一个不对齐的1条浮法偏移。

这将有希望也允许编译器自动向量化一些功能(与更广泛的载体,例如用于ARM,或用于AVX / AVX2)。

当然,手动矢量化会在这里夺冠,因为我做的东西像异或漂浮起来,因为我只关心自己的签位作为真值,而不是做一个比较,然后进行异或比较结果。 (我的测试,到目前为止已经表明,治疗的面膜负0负仍然给出正确的结果::相交,但是任何的方式来表达在C是要遵循IEEE规章制度x >= 0x=-0.真)。


如果你几乎只需要在一次访问大量数据的AOS很可能是更好的性能,单场,而如果你经常需要从同一个对象访问多个字段或处理单个对象,而不是多一次, SOA将是更好的性能。

你有这正好反了。这是一个错字?把所有的foo[i].key领域为foo.key[i]阵列意味着他们都挤在一起在缓存中,这样访问许多对象只是一个字段意味着你使用的全部64个字节,你接触每个高速缓存行的。

你猜对了早期正确的,当你写

当只有一些从许多“人”记录数据的工作,只需要该数据被加载到内存中。

(除非我想你“由”记忆(到高速缓存中的意思),除非你是在谈论一个内存映射文件和断层从磁盘页面到内存中。)


索引寻址模式:

在你看每个对象两个或三个字段的情况下,SOA的布局是要占用更多的寄存器,单独持有的基址为你遍历每个单独的数组。

随着多个指针,你会想在x86要么使用寻址模式像[reg1 + 4*reg2],或者你将需要分别增加您的循环中一群不同的指针。索引寻址模式可能在Intel SNB-家庭稍微慢一些,因为他们can't stay micro-fused with ALU uops in the out-of-order core (only in the decoders and uop cache)。 SKYLAKE微架构可以让他们的微稠,但需要进一步测试以找出当英特尔提出这种变化。也许Broadwell微架构时超越FMA(如CMOV和ADC)三输入指令解码为单个微操作,但是这是一个纯粹的猜测。需要有关的Haswell和Broadwell微架构测试。

© www.soinside.com 2019 - 2024. All rights reserved.