Интуиция о расположении памяти для быстрого SIMD / ориентированного на данные дизайна - PullRequest
0 голосов
/ 04 февраля 2019

Я недавно смотрел переговоры по проектированию, ориентированному на данные, но никогда не понимал причины, по которым единогласно выбрал схему памяти.

Допустим, у нас есть 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 (упакованные 4 сингла) является преобладающим типом в двигателях,
, который я считаю;
, и если тип имеет длину 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------------^

IУ меня нет эталона, чтобы проверить это, и я недостаточно разбираюсь в сути, чтобы даже попробовать , но, по моей интуиции, разве это не должно быть на способ быстрее?Мы бы экономили 4x загрузок и записей страниц, упрощая распределение и сохраняя указатели, и код был бы проще, поскольку вместо 4 указателей мы можем добавлять указатели.Я не прав?

Спасибо!:)

Ответы [ 4 ]

0 голосов
/ 05 февраля 2019

Одним из основных недостатков чередования по ширине вектора является необходимость изменения макета, чтобы использовать преимущества более широких векторов.(AVX, AVX512).

Но да, когда вы выполняете чисто ручную векторизацию (без циклов, которые компилятор может автоматически векторизовать при выборе ширины вектора), возможно, это того стоитесли все ваши (важные) циклы всегда используют все элементы структуры.

В противном случае применяется точка Макса: цикл, который касается только x и y, будет тратить пропускную способность на z и w члены.


Это не будет способ быстрее, хотя ;при разумном количестве циклов развертывания индексирование 4 массивов или увеличение 4 указателей едва ли хуже, чем 1. Предварительная выборка HW на процессорах Intel может отслеживать один прямой + 1 обратный поток на страницу 4k, поэтому 4 входных потока в основном хороши.

(Но L2 является 4-сторонней ассоциативностью в Skylake, по сравнению с 8 в предыдущем, поэтому более 4 входных потоков с одинаковым выравниванием по отношению к странице 4k вызовут пропуски конфликтов / поражение предварительной выборки. То есть более 4 больших /Выровненные по страницам массивы, чересстрочный формат мог бы избежать этой проблемы.)

Для небольших массивов, где вся чередующаяся вещь помещается на одной странице 4k, да, это потенциальное преимущество.В противном случае это примерно то же самое общее количество страниц, к которым обращаются, и потенциальные пропуски TLB, с одним 4-кратным, как часто, вместо того, чтобы приходить группами 4. Это может быть лучше для предварительной выборки TLB, если вместо этого она может выполнить обход одной страницы раньше временибыть перегруженным множеством промахов TLB, приходящих одновременно.


Настройка структуры SoA:

Может помочь компилятору узнать, что памятьУказанный каждым из указателей не перекрывается.Большинство компиляторов C ++ (включая все 4 основных компилятора x86, gcc / clang / MSVC / ICC) поддерживают __restrict в качестве ключевого слова с той же семантикой, что и C99 restrict.Или для переносимости, используйте #ifdef / #define, чтобы определить ключевое слово restrict как пустое или __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] для следующей итерации.

Если вы читаете эти переменные в локальные переменные (поэтому компилятор уверен, что указательприсваивания не изменяют сам указатель в структуре), вы можете объявить их как float *__restrict xs = soa.xs; и т. д.

Чередующийся формат по своей сути избегает этой возможности наложения имен.

0 голосов
/ 04 февраля 2019

Одна из вещей, которые еще не упомянуты, заключается в том, что доступ к памяти имеет небольшую задержку.И, конечно же, при чтении из 4 указателей результат доступен, когда приходит значение last .Таким образом, даже если 3 из 4 значений находятся в кэше, последнее значение может потребоваться из памяти и остановить всю вашу работу.

Именно поэтому SSE даже не поддерживает этот режим.Все ваши значения должны быть смежными в памяти, и в течение некоторого времени они должны были быть выровнены (чтобы они не могли пересекать границу строки кэша).

Важно, что это означает, что ваш пример (структура массивов) не работает на оборудовании SSE .Вы не можете использовать элемент [1] из 4 различных векторов в одной операции.Вы можете использовать элементы [0] до [3] из одного вектора.

0 голосов
/ 04 февраля 2019

Я реализовал простой тест для обоих методов.

Результат: Полосатый макет в лучшем случае на 10% быстрее стандартного макета *.Но с SSE4.1 мы можем сделать намного лучше.

* При компиляции с gcc -Ofast на 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;
}
0 голосов
/ 04 февраля 2019

Количество данных, которое необходимо получить через подсистему памяти, одинаково, независимо от того, используете ли вы 4 отдельных массива или предлагаемое чередование.Поэтому вы не сохраняете загрузки страниц или записи (я не понимаю, почему в случае «отдельных массивов» каждая страница или строка кэша должны считываться и записываться более одного раза).

Вы расходуете больше памяти- у вас может быть 1 кэш L1 пропустить каждую итерацию в вашем случае, и 4 кеша пропускает каждую 4-ю итерацию в случае «отдельных массивов».Я не знаю, какой из них предпочтительнее.

В любом случае, главное, чтобы в ваших кешах не было ненужной памяти, с которой вы не взаимодействовали.В вашем примере, наличие string значений, которые не читаются и не записываются, но все же проталкиваются через кеши, без необходимости занимают полосу пропускания.

...