关于快速SIMD /面向数据设计的内存布局的直觉

时间:2019-02-04 12:54:52

标签: c++ memory simd data-oriented-design

最近我一直在观看面向数据的设计讲座,但是我从来不理解他们一致选择内存布局背后的原因。

让我们说我们要渲染一个3D动画,并且在每一帧中我们都需要对方向矢量进行重新规范化。

“标量代码”

它们总是显示看起来像这样的代码:

let scene = [{"camera1", vec4{1, 1, 1, 1}}, ...]

for object in scene
    object.orientation = normalize(object.orientation)

到目前为止很好... &scene上的内存可能看起来大致如此:

[string,X,Y,Z,W,string,X,Y,Z,W,string,X,Y,Z,W,...]

“支持SSE的代码”

然后,每次谈话都显示改进的 cookie-cutter 版本:

let xs = [1, ...]
let ys = [1, ...]
let zs = [1, ...]
let ws = [1, ...]
let scene = [{"camera1", ptr_vec4{&xs[1], &ys[1], &zs[1], &ws[1]}}, ...]

for (o1, o2, o3, o4) in scene
    (o1, o2, o3, o4) = normalize_sse(o1, o2, o3, o4)

由于其内存布局,因此不仅内存效率更高,而且可以一次处理场景4个对象。
&xs&ys&zs&ws的内存

[X,X,X,X,X,X,...]
[Y,Y,Y,Y,Y,Y,...]
[Z,Z,Z,Z,Z,Z,...]
[W,W,W,W,W,W,...]

但是为什么要有4个单独的数组?

如果__m128(四列包装)是引擎中的主要类型,
我相信是的
如果类型是128位长,
绝对是
并且如果缓存行宽 / 128 = 4,
几乎总是这样做;
如果x86_64仅能写入完整的缓存行,
我几乎可以肯定
-为什么数据的结构不是这样?!

&packed_orientations处的内存:

[X,X,X,X,Y,Y,Y,Y,Z,Z,Z,Z,W,W,W,W,X,X,...]
 ^---------cache-line------------^

我没有基准可以对此进行测试,并且我对内在函数的理解还不够,甚至无法尝试,但是根据我的直觉,这不是行之有效的方法吗? 我们将节省4倍的页面加载和写入,简化分配并节省指针,并且代码将更简单,因为我们可以代替4个指针来进行指针添加。我错了吗?

谢谢! :)

4 个答案:

答案 0 :(得分:4)

无论您是执行4个单独的阵列还是建议进行交错操作,通过内存子系统获取的数据量都是相同的。因此,您不会保存页面加载或写入操作(我不明白为什么“独立数组”的情况下应该多次读取和写入每个页面或缓存行)。

您的确分散了更多的内存传输-在“独立数组”情况下,每次迭代可能有1个L1缓存未命中,每第4次迭代有4个缓存未命中。我不知道哪一个是首选。

无论如何,要点是不要让不需要交互的不必要的内存通过缓存。在您的示例中,string既不被读取也不被写入但仍被高速缓存推入的值会不必要地占用带宽。

答案 1 :(得分:3)

交织矢量宽度的一个主要缺点是,您需要更改布局以利用更宽的矢量。 (AVX,AVX512)。

但是,是的,当您纯粹是手动向量化时(没有循环,编译器可能会选择向量宽度来自动向量化),如果所有(重要)循环始终使用所有结构成员,则可能值得

否则Max的观点适用:仅触摸xy的循环将浪费zw成员的带宽。 < / p>


虽然不会更快;通过合理数量的循环展开,索引4个数组或增加4个指针几乎不比1差。在Intel CPU上进行硬件预取可以每4k页跟踪一个正向+ 1个反向流,因此4个输入流基本上是可以的。

(但是L2在Skylake中是4路关联,早于8个,因此相对于4k页面,具有相同对齐方式的多于4个输入流将导致冲突丢失/失败的预取。因此,大于4个大输入流/页对齐的数组,交错格式可以避免该问题。)

对于小型阵列而言,整个交错的内容都适合一个4k页面,是的,这是一个潜在的优势。否则,触摸的页面总数和潜在的TLB丢失数量大约相同,通常是4倍,而不是4个一组。这对于TLB预取来说可能更好,如果它可以提前进行一次页面遍历同时被多个TLB未命所淹没。


调整SoA结构:

这可能有助于让编译器知道每个指针所指向的内存是不重叠的。大多数C ++编译器(包括所有4个主要的x86编译器,gcc / clang / MSVC / ICC)都支持__restrict作为关键字,其语义与C99 restrict相同。或出于可移植性的考虑,使用#ifdef / #definerestrict关键字定义为空或__restrict或其他任何适用于编译器的关键字。

struct SoA_scene {
        size_t size;
        float *__restrict xs;
        float *__restrict ys;
        float *__restrict zs;
        float *__restrict ws;
};

这肯定可以帮助进行自动矢量化,否则编译器不知道xs[i] = foo;不会在下一次迭代中更改ys[i+1]的值。

如果您将这些变量读入局部变量(因此编译器确保指针分配不会修改结构体中的指针本身),则可以将 them 声明为float *__restrict xs = soa.xs;,然后等等。

交错格式从本质上避免了这种混叠的可能性。

答案 2 :(得分:1)

尚未提及的事情之一是内存访问有很多延迟。当然,当从4个指针读取时,当 last 值到达时,结果可用。因此,即使4个值中有3个位于缓存中,最后一个值可能也需要来自内存,从而使整个操作停止。

这就是为什么SSE甚至不支持此模式的原因。您的所有值都必须在内存中是连续的,并且必须在相当长的一段时间内对齐(以便它们不能越过缓存行边界)。

重要的是,这意味着您的示例(阵列结构)不适用于SSE硬件。您不能在一次操作中使用4个不同向量中的元素[1]。您可以使用单个向量中的元素[0][3]

答案 3 :(得分:1)

我已经为这两种方法实现了一个简单的基准。

结果:条形布局最多比标准布局*快10%*。但是使用SSE4.1,我们可以做得更好。

*在gcc -Ofast CPU上使用i5-7200U编译时。

结构 易于使用,但通用性却差很多。但是,一旦分配器足够繁忙,在实际情况下它可能会有一点优势。

条纹布局

Time 4624 ms

Memory usage summary: heap total: 713728, heap peak: 713728, stack peak: 2896
         total calls   total memory   failed calls
 malloc|          3         713728              0
realloc|          0              0              0  (nomove:0, dec:0, free:0)
 calloc|          0              0              0
   free|          1         640000
#include <chrono>
#include <cstdio>
#include <random>
#include <vector>
#include <xmmintrin.h>

/* -----------------------------------------------------------------------------
        Striped layout [X,X,X,X,y,y,y,y,Z,Z,Z,Z,w,w,w,w,X,X,X,X...]
----------------------------------------------------------------------------- */

using AoSoA_scene = std::vector<__m128>;

void print_scene(AoSoA_scene const &scene)
{
        // This is likely undefined behavior. Data might need to be stored
        // differently, but this is simpler to index.
        auto &&punned_data = reinterpret_cast<float const *>(scene.data());
        auto scene_size = std::size(scene);

        // Limit to 8 lines
        for(size_t j = 0lu; j < std::min(scene_size, 8lu); ++j) {
                for(size_t i = 0lu; i < 4lu; ++i) {
                        printf("%10.3e ", punned_data[j + 4lu * i]);
                }
                printf("\n");
        }
        if(scene_size > 8lu) {
                printf("(%lu more)...\n", scene_size - 8lu);
        }
        printf("\n");
}

void normalize(AoSoA_scene &scene)
{
        // Euclidean norm, SIMD 4 x 4D-vectors at a time.
        for(size_t i = 0lu; i < scene.size(); i += 4lu) {
                __m128 xs = scene[i + 0lu];
                __m128 ys = scene[i + 1lu];
                __m128 zs = scene[i + 2lu];
                __m128 ws = scene[i + 3lu];

                __m128 xxs = _mm_mul_ps(xs, xs);
                __m128 yys = _mm_mul_ps(ys, ys);
                __m128 zzs = _mm_mul_ps(zs, zs);
                __m128 wws = _mm_mul_ps(ws, ws);

                __m128 xx_yys = _mm_add_ps(xxs, yys);
                __m128 zz_wws = _mm_add_ps(zzs, wws);

                __m128 xx_yy_zz_wws = _mm_add_ps(xx_yys, zz_wws);

                __m128 norms = _mm_sqrt_ps(xx_yy_zz_wws);

                scene[i + 0lu] = _mm_div_ps(xs, norms);
                scene[i + 1lu] = _mm_div_ps(ys, norms);
                scene[i + 2lu] = _mm_div_ps(zs, norms);
                scene[i + 3lu] = _mm_div_ps(ws, norms);
        }
}

float randf()
{
        std::random_device random_device;
        std::default_random_engine random_engine{random_device()};
        std::uniform_real_distribution<float> distribution(-10.0f, 10.0f);
        return distribution(random_engine);
}

int main()
{
        // Scene description, e.g. cameras, or particles, or boids etc.
        // Has to be a multiple of 4!   -- No edge case handling.
        std::vector<__m128> scene(40'000);

        for(size_t i = 0lu; i < std::size(scene); ++i) {
                scene[i] = _mm_set_ps(randf(), randf(), randf(), randf());
        }

        // Print, normalize 100'000 times, print again

        // Compiler is hopefully not smart enough to realize
        // idempotence of normalization
        using std::chrono::steady_clock;
        using std::chrono::duration_cast;
        using std::chrono::milliseconds;
        // >:(

        print_scene(scene);
        printf("Working...\n");

        auto begin = steady_clock::now();
        for(int j = 0; j < 100'000; ++j) {
                normalize(scene);
        }
        auto end = steady_clock::now();
        auto duration = duration_cast<milliseconds>(end - begin);

        printf("Time %lu ms\n", duration.count());
        print_scene(scene);

        return 0;
}

SoA布局

Time 4982 ms

Memory usage summary: heap total: 713728, heap peak: 713728, stack peak: 2992
         total calls   total memory   failed calls
 malloc|          6         713728              0
realloc|          0              0              0  (nomove:0, dec:0, free:0)
 calloc|          0              0              0
   free|          4         640000
#include <chrono>
#include <cstdio>
#include <random>
#include <vector>
#include <xmmintrin.h>

/* -----------------------------------------------------------------------------
        SoA layout [X,X,X,X,...], [y,y,y,y,...], [Z,Z,Z,Z,...], ...
----------------------------------------------------------------------------- */

struct SoA_scene {
        size_t size;
        float *xs;
        float *ys;
        float *zs;
        float *ws;
};

void print_scene(SoA_scene const &scene)
{
        // This is likely undefined behavior. Data might need to be stored
        // differently, but this is simpler to index.

        // Limit to 8 lines
        for(size_t j = 0lu; j < std::min(scene.size, 8lu); ++j) {
                printf("%10.3e ", scene.xs[j]);
                printf("%10.3e ", scene.ys[j]);
                printf("%10.3e ", scene.zs[j]);
                printf("%10.3e ", scene.ws[j]);
                printf("\n");
        }
        if(scene.size > 8lu) {
                printf("(%lu more)...\n", scene.size - 8lu);
        }
        printf("\n");
}

void normalize(SoA_scene &scene)
{
        // Euclidean norm, SIMD 4 x 4D-vectors at a time.
        for(size_t i = 0lu; i < scene.size; i += 4lu) {
                __m128 xs = _mm_load_ps(&scene.xs[i]);
                __m128 ys = _mm_load_ps(&scene.ys[i]);
                __m128 zs = _mm_load_ps(&scene.zs[i]);
                __m128 ws = _mm_load_ps(&scene.ws[i]);

                __m128 xxs = _mm_mul_ps(xs, xs);
                __m128 yys = _mm_mul_ps(ys, ys);
                __m128 zzs = _mm_mul_ps(zs, zs);
                __m128 wws = _mm_mul_ps(ws, ws);

                __m128 xx_yys = _mm_add_ps(xxs, yys);
                __m128 zz_wws = _mm_add_ps(zzs, wws);

                __m128 xx_yy_zz_wws = _mm_add_ps(xx_yys, zz_wws);

                __m128 norms = _mm_sqrt_ps(xx_yy_zz_wws);

                __m128 normed_xs = _mm_div_ps(xs, norms);
                __m128 normed_ys = _mm_div_ps(ys, norms);
                __m128 normed_zs = _mm_div_ps(zs, norms);
                __m128 normed_ws = _mm_div_ps(ws, norms);

                _mm_store_ps(&scene.xs[i], normed_xs);
                _mm_store_ps(&scene.ys[i], normed_ys);
                _mm_store_ps(&scene.zs[i], normed_zs);
                _mm_store_ps(&scene.ws[i], normed_ws);
        }
}

float randf()
{
        std::random_device random_device;
        std::default_random_engine random_engine{random_device()};
        std::uniform_real_distribution<float> distribution(-10.0f, 10.0f);
        return distribution(random_engine);
}

int main()
{
        // Scene description, e.g. cameras, or particles, or boids etc.
        // Has to be a multiple of 4!   -- No edge case handling.
        auto scene_size = 40'000lu;
        std::vector<float> xs(scene_size);
        std::vector<float> ys(scene_size);
        std::vector<float> zs(scene_size);
        std::vector<float> ws(scene_size);

        for(size_t i = 0lu; i < scene_size; ++i) {
                xs[i] = randf();
                ys[i] = randf();
                zs[i] = randf();
                ws[i] = randf();
        }

        SoA_scene scene{
                scene_size,
                std::data(xs),
                std::data(ys),
                std::data(zs),
                std::data(ws)
        };
        // Print, normalize 100'000 times, print again

        // Compiler is hopefully not smart enough to realize
        // idempotence of normalization
        using std::chrono::steady_clock;
        using std::chrono::duration_cast;
        using std::chrono::milliseconds;
        // >:(

        print_scene(scene);
        printf("Working...\n");

        auto begin = steady_clock::now();
        for(int j = 0; j < 100'000; ++j) {
                normalize(scene);
        }
        auto end = steady_clock::now();
        auto duration = duration_cast<milliseconds>(end - begin);

        printf("Time %lu ms\n", duration.count());
        print_scene(scene);

        return 0;
}

AoS布局

自SSE4.1以来,似乎还有第三种选择-迄今为止最简单,最快的选择。

Time 3074 ms

Memory usage summary: heap total: 746552, heap peak: 713736, stack peak: 2720
         total calls   total memory   failed calls
 malloc|          5         746552              0
realloc|          0              0              0  (nomove:0, dec:0, free:0)
 calloc|          0              0              0
   free|          2         672816
Histogram for block sizes:
    0-15              1  20% =========================
 1024-1039            1  20% =========================
32816-32831           1  20% =========================
   large              2  40% ==================================================

/* -----------------------------------------------------------------------------
        AoS layout [{X,y,Z,w},{X,y,Z,w},{X,y,Z,w},{X,y,Z,w},...]
----------------------------------------------------------------------------- */

using AoS_scene = std::vector<__m128>;

void print_scene(AoS_scene const &scene)
{
        // This is likely undefined behavior. Data might need to be stored
        // differently, but this is simpler to index.
        auto &&punned_data = reinterpret_cast<float const *>(scene.data());
        auto scene_size = std::size(scene);

        // Limit to 8 lines
        for(size_t j = 0lu; j < std::min(scene_size, 8lu); ++j) {
                for(size_t i = 0lu; i < 4lu; ++i) {
                        printf("%10.3e ", punned_data[j * 4lu + i]);
                }
                printf("\n");
        }
        if(scene_size > 8lu) {
                printf("(%lu more)...\n", scene_size - 8lu);
        }
        printf("\n");
}

void normalize(AoS_scene &scene)
{
        // Euclidean norm, SIMD 4 x 4D-vectors at a time.
        for(size_t i = 0lu; i < scene.size(); i += 4lu) {
                __m128 vec = scene[i];
                __m128 dot = _mm_dp_ps(vec, vec, 255);
                __m128 norms = _mm_sqrt_ps(dot);
                scene[i] = _mm_div_ps(vec, norms);
        }
}

float randf()
{
        std::random_device random_device;
        std::default_random_engine random_engine{random_device()};
        std::uniform_real_distribution<float> distribution(-10.0f, 10.0f);
        return distribution(random_engine);
}

int main()
{
        // Scene description, e.g. cameras, or particles, or boids etc.
        std::vector<__m128> scene(40'000);

        for(size_t i = 0lu; i < std::size(scene); ++i) {
                scene[i] = _mm_set_ps(randf(), randf(), randf(), randf());
        }

        // Print, normalize 100'000 times, print again

        // Compiler is hopefully not smart enough to realize
        // idempotence of normalization
        using std::chrono::steady_clock;
        using std::chrono::duration_cast;
        using std::chrono::milliseconds;
        // >:(

        print_scene(scene);
        printf("Working...\n");

        auto begin = steady_clock::now();
        for(int j = 0; j < 100'000; ++j) {
                normalize(scene);
                //break;
        }
        auto end = steady_clock::now();
        auto duration = duration_cast<milliseconds>(end - begin);

        printf("Time %lu ms\n", duration.count());
        print_scene(scene);

        return 0;
}