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

Intuition about memory layout for fast SIMD / data oriented design

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

假设我们有一个 3D 动画要渲染,并且在每一帧中我们需要重新标准化我们的方向向量。

"Scalar code"

他们总是显示可能看起来像这样的代码:

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 aware code"

Every talk then shows the improved,千篇一律,版本:

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 个单独的数组还是建议的交错,您需要通过内存子系统获取的数据量都是相同的。因此,您不会保存页面加载或写入(我不明白为什么 "separate arrays" 案例应该多次读取和写入每个页面或缓存行)。

你确实分散了内存传输——在你的情况下,你的每次迭代可能有 1 个 L1 缓存未命中,在 "separate arrays" 的情况下,每第 4 次迭代有 4 个缓存未命中。我不知道哪个更受欢迎。

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

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

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

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

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

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

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

结构 使用起来稍微容易一些,但通用性差得多。但是,一旦分配器足够繁忙,它在实际场景中可能会有一些优势。

条纹布局

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

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

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

否则 Max 的观点适用:只涉及 xy 的循环将浪费 zw 成员的带宽。


它不会方式更快,但是;通过合理数量的循环展开,索引 4 个数组或递增 4 个指针几乎不比 1 差。Intel 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;等等。

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