3

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

假设我们有一个 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 感知代码”

然后每次谈话都会显示改进的千篇一律的版本:

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(packed-4-singles)是引擎中的主要类型,
    我相信它是;
如果类型是 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

4 回答 4

3

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

您确实分散了更多的内存传输-在您的情况下,每次迭代可能有 1 个 L1 缓存未命中,在“单独的数组”情况下,每 4 次迭代可能有 4 个缓存未命中。我不知道哪个会更受欢迎。

无论如何,要点是不要将不必要的内存推送到您不与之交互的缓存中。在您的示例中,具有string既不读取也不写入但仍不必要地通过缓存推送的值会占用带宽。

于 2019-02-04T13:56:40.920 回答
2

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

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

否则 Max 的观点适用:一个只接触的循环,xy浪费zw成员的带宽。


不过,它不会更快;通过合理数量的循环展开,索引 4 个数组或递增 4 个指针几乎不比 1 差。英特尔 CPU 上的硬件预取可以跟踪每 4k 页一个前向 + 1 个后向流,因此 4 个输入流基本上没问题。

(但是 L2 在 Skylake 中是 4 路关联的,从之前的 8 路下降,因此超过 4 个输入流都具有相对于 4k 页面相同的对齐方式会导致冲突未命中/预取失败。因此,如果有超过 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]下一次迭代的值。

如果您将这些 var 读入局部变量(因此编译器确定指针赋值不会修改结构中的指针本身),您可以将它们声明为float *__restrict xs = soa.xs;,依此类推。

交错格式固有地避免了这种混叠的可能性。

于 2019-02-05T00:10:00.117 回答
1

尚未提及的一件事是内存访问有相当长的延迟。当然,当从 4 个指针读取时,当最后一个值到达时,结果是可用的。因此,即使 4 个值中有 3 个在缓存中,最后一个值也可能需要来自内存并暂停整个操作。

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

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

于 2019-02-04T14:37:51.653 回答
1

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

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

gcc -Ofast*在i5-7200Ucpu上编译时。

该结构易于使用,但通用性要低得多。然而,一旦分配器足够繁忙,它在实际场景中可能会有一点优势。

条纹布局

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;
}
于 2019-02-04T18:47:41.497 回答