我可以使用AVX / SSE调配AoS布局而不是SoA吗?

时间:2017-12-02 07:21:13

标签: c++ vectorization sse simd avx

我想加速一个简单的积分器,它根据位置和速度描述一组无质量粒子。我不是SSE / AVX专家,但我觉得有趣的是SIMD扩展可以在这里产生什么。

许多论文建议使用数组结构:

struct {
  static float2 xy[OUGHTA_BE_ENOUGH];
  static float2 vxvy[OUGHTA_BE_ENOUGH];
} Particles;

// in main loop:
Particles.xy[i] += time_delta * Particles.vxvy[i];

然而,对于许多应用而言,相反的方法将是有益的:

struct {
  float2 xy;
  float2 vxvy;
} Particle;

// in main loop:
particles[i].xy += time_delta * particles[i].vxvy;

虽然我模糊地了解了要搜索数组结构版本的内容,但我怀疑有没有办法将SIMD与结构数组版本一起使用,因为字段访问或“调配”。

是否有任何技术可以使用SIMD进行上述计算,或者我错过了内在函数?

2 个答案:

答案 0 :(得分:5)

有关某些链接,请参阅代码Wiki,尤其是SIMD at Insomniac Games (GDC 2015)。循环很多粒子与在游戏世界中循环很多对象的问题完全相同,所以提到了这种循环,以及尝试使用AoS的问题。

你根本不需要数组结构; xy[i]vxvy[i]之间的距离是编译时常量并不重要。 (尽管如此,它可能会保存一个寄存器,并且可能会增加一些循环开销。但严重的是,大多数人都没有使用巨型结构,如果尺寸不知道,他们会使用单独分配的数组。但是在编译时。他们可能会将所有指针保留在结构中。)

你(或编译器)可以随机播放并为你的AoS方法获得超越标量的加速,但是如果你无论如何都要在每个粒子上循环,你就会自己拍摄通过这样做的脚。您的float2 xy对只有64位块,因此您无法使用128位存储。使用两倍多的64位存储很糟糕:你输掉SSE功率的一半,或AVX功率的75%。最重要的是,您需要花费额外的指令进行改组或加载以及存储。

围绕成本移动数据的数量与实际的乘法/数量相同或更多,尤其是吞吐量而非延迟。即使具有完美的SoA布局,它也不会成为瓶颈的FMA单元,它将是加载/存储吞吐量,或者没有显着的循环展开的总指令吞吐量(CPU前端)。在此基础上添加任何开销意味着您只是在前端的瓶颈(或随机吞吐量)。

无论是否明确存储到vxvy,当它们与vxvy交错时,都无法弄清楚包含xy的缓存行因此,对于像这样的问题,您将始终需要两倍的存储带宽与SoA。

对于糟糕的AoS布局使用AVX,手动随机播放然后执行存储新xy值的256b存储并使用之前加载的值重写vxvx是值得的,但是the compiler isn't allowed to do that when auto-vectorizing除非整个程序优化证明此代码是单线程的。 C11和C ++ 11内存模型同意,当一个线程编写某些数组元素或结构成员而其他线程正在编写其他元素时,它不是数据竞争。当源只读取这些元素时,不允许vxvy成员的非原子读 - 修改 - 写。 (即,编译器不允许发明写入未由原始代码写入的内存位置/对象,即使它正在重写原来的数据。)当然,如果您使用内在函数手动执行此操作,编译器可以做到。如果需要,甚至particles[i].vxvy = particles[i].vxvy;甚至可以给编译器许可证进行读/重写/重写。

实际上,编译器可以通过vmaskmovps使用屏蔽存储而不是常规vmovups存储来这种方式进行矢量化。它比常规商店慢(Haswell / Skylake:需要p0,p1,p4和p23的4个融合域uops,而普通商店是需要p4和p237的单个微融合uop)。 Normally you want to avoid it,但是当编译器需要以避免重写vxvy字节时,使用它自动向量化可能仍然比单独的64位存储更好。特别是对于AVX512,屏蔽存储将允许使用512b(64字节)向量进行自动向量化,该向量一次存储4 xy对。 (而不是8作为SoA格式)。

我查看了gcc和ICC如何自动矢量化您的第一个版本,其中xy仍然在AoS中,但是在匹配vxvy的布局中,因此它可以使用纯垂直自动矢量化SIMD操作。 (source + asm output on the Godbolt compiler explorer)。 gcc没问题,用一条vfmadd213ps指令创建一个循环。 ICC似乎被float2结构混淆了,而且(我认为)实际上是在乘法/加法之前进行去交错,然后重新交错! (我没有让ICC使用AVX或AVX512,因为更长的矢量意味着更多的改组,所以它更难以看到它正在做什么。)这是ICC自动化的罕见时间之一矢量化比gcc差。

gcc和ICC都无法自动向量化update_aos。这是我如何手动矢量化它(对于AVX + FMA):

// struct definitions and float2 operator*(float scalar, const float2 &f2)
// included in the Godbolt link, see above.

#include <immintrin.h>
void update_aos_manual(Particle *particles, size_t size, float time_delta_scalar)
{
    __m256 time_delta = _mm256_set1_ps(time_delta_scalar);
    // note: compiler can't prove this loop isn't infinite.  (because i+=2 could wrap without making the condition false)
    for(size_t i=0 ; i<size ; i+=2) {
        float *ptr = (float*)(particles + i);
        __m256 p = _mm256_load_ps(ptr); // xy0 vxvx0 | xy1 vxvy1
        __m256 vx0 = _mm256_unpackhi_ps(p, _mm256_setzero_ps()); // vx0  0 | vx1   0
        p = _mm256_fmadd_ps(time_delta, vx0, p);   // p = td*vx0 + p
        _mm256_store_ps(ptr, p);

        //particles[i].xy += time_delta * particles[i].vxvy;
        //particles[i].vxvy += 0.0f * particles[i].vxvy;
    }
}

使用gcc和ICC,这将编译为内部循环,如

 ## gcc7.2 -O3 -march=haswell
 # various scalar setup for the loop:
     vbroadcastss    ymm0, xmm0        # ymm0 set1(time_delta_scalar)
     vxorps  xmm3, xmm3, xmm3          # ymm3 = setzero_ps
.L27:
    vmovaps ymm2, YMMWORD PTR [rdi]        # load 2 struct Particle
    add     rdi, 32                        # ptr+=2 (16 bytes per element)
    vunpckhps       ymm1, ymm2, ymm3       # unpack high half of each lane with zero
    vfmadd132ps     ymm1, ymm2, ymm0       # xy += delta*vxvy; vxvy += delta*0
    vmovaps YMMWORD PTR [rdi-32], ymm1    # store back into the array
    cmp     rdi, rax
    jne     .L27

这浪费了一半的商店带宽(不可避免),以及其FMA吞吐量的一半,但我认为你不能做得更好。好吧,展开会有所帮助,但是改组/改组和使用更少的FMA可能仍然是前端的瓶颈。通过展开,您可以在Haswell / Skylake上以每个时钟几乎一个32B的存储(每个时钟4个融合域uop)运行。

答案 1 :(得分:2)

本文中建议的方法可能更好,因为如果使用SSE编译,编译器可以同时处理4个浮点运算。因此可以扩展循环以同时处理两个粒子。实际上,使用gcc和O3,只需看看反汇编。

使用您建议的方法,这仍然是可能的,但它需要一个调整开销。

如果您担心保持单线性内存访问,我对现代处理器的理解在这里不会产生任何影响。处理器可以处理与缓存有关的多个线性内存访问。

现在,由于您正在模拟一组粒子,我建议对多个粒子进行矢量化。

struct Pack
{
    floatN x; // N = 2, 4, 8 depending on your vectorization target
    floatN y;
};

通过这种方式,您可以同时处理多个粒子,并且更容易编写矢量化。

f(Pack& pos, Pack speed) {
    pos.x = add(pos.x, speed.x);
    pos.y = add(pos.y, speed.y);
}

缺点是,如果计算同时涉及两个粒子,它可能不适用:particleASpeed += force(particleBPos, particleAPos)

另外,考虑使用OpenCL或Cuda进行此类计算,并考虑在GPU上运行它们。

最后,虽然它可能会更早出现,但请记住测量您之前和之后要优化的内容。我的建议充其量是一种有根据的猜测,根据您的实际实施情况和问题的大小,可能不会改变任何内容。