Должен ли я использовать SIMD или векторные расширения или что-то еще?

В настоящее время я разрабатываю фреймворк с открытым исходным кодом 3D ), Моя собственная математическая библиотека построена как математическая библиотека также с SIMD. Но в настоящее время он не очень быстрый, и он имеет проблемы с выравниванием памяти, но больше об этом в другом вопросе.

Несколько дней назад я спросил себя, почему я должен написать свой собственный SSE. Компилятор также может генерировать высокий оптимизированный код, когда оптимизация включена. Я также могу использовать "векторное расширение "GCC. Но все это не очень портативно.

Я знаю, что у меня больше контроля, когда я использую свой собственный SSE-код, но часто этот элемент управления является unnessary.

Одной из больших проблем SSE является использование динамической памяти, которая с помощью пулов памяти и ориентированного на данные дизайна максимально ограничена.

Теперь на мой вопрос:

  • Должен ли я использовать голый SSE? Возможно инкапсулированный.

    __m128 v1 = _mm_set_ps(0.5f, 2, 4, 0.25f);
    __m128 v2 = _mm_set_ps(2, 0.5f, 0.25f, 4);
    
    __m128 res = _mm_mul_ps(v1, v2);
    
  • Или должен ли компилятор выполнять грязную работу?

    float v1 = {0.5f, 2, 4, 0.25f};
    float v2 = {2, 0.5f, 0.25f, 4};
    
    float res[4];
    res[0] = v1[0]*v2[0];
    res[1] = v1[1]*v2[1];
    res[2] = v1[2]*v2[2];
    res[3] = v1[3]*v2[3];
    
  • Или мне нужно использовать SIMD с дополнительным кодом? Как динамический класс контейнера с SIMD-операциями, для которого требуются дополнительные инструкции load и store.

    Pear3D::Vector4f* v1 = new Pear3D::Vector4f(0.5f, 2, 4, 0.25f);
    Pear3D::Vector4f* v2 = new Pear3D::Vector4f(2, 0.5f, 0.25f, 4);
    
    Pear3D::Vector4f res = Pear3D::Vector::multiplyElements(*v1, *v2);
    

    В приведенном выше примере используется воображаемый класс с использованием float[4] internal и использует store и load в каждом методе, например multiplyElements(...). Методы используют внутренний SSE.

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

PS: Это не проблема, а проблема дизайна.

Ответы

Ответ 1

Хорошо, если вы хотите использовать SIMD-расширения, хороший подход - использовать встроенные средства SSE (конечно же, непременно оставайтесь в стороне от встроенной сборки, но, к счастью, вы не указали ее как альтернативу). Но для чистоты вы должны инкапсулировать их в класс с хорошим вектором с перегруженными операторами:

struct aligned_storage
{
    //overload new and delete for 16-byte alignment
};

class vec4 : public aligned_storage
{
public:
    vec4(float x, float y, float z, float w)
    {
         data_[0] = x; ... data_[3] = w; //don't use _mm_set_ps, it will do the same, followed by a _mm_load_ps, which is unneccessary
    }
    vec4(float *data)
    {
         data_[0] = data[0]; ... data_[3] = data[3]; //don't use _mm_loadu_ps, unaligned just doesn't pay
    }
    vec4(const vec4 &rhs)
        : xmm_(rhs.xmm_)
    {
    }
    ...
    vec4& operator*=(const vec4 v)
    {
         xmm_ = _mm_mul_ps(xmm_, v.xmm_);
         return *this;
    }
    ...

private:
    union
    {
        __m128 xmm_;
        float data_[4];
    };
};

Теперь хорошо, из-за анонимного объединения (UB, я знаю, но покажу мне платформу с SSE, где это не работает) вы можете использовать стандартный массив float всякий раз, когда это необходимо (например, operator[] или инициализация (не используйте _mm_set_ps)) и при необходимости используйте SSE. С современным встроенным компилятором инкапсуляция приходит, вероятно, без затрат (я был довольно удивлен, насколько хорошо VC10 оптимизировал инструкции SSE для кучи вычислений с этим векторным классом, не опасаясь ненужных перемещений во временные переменные памяти, поскольку VC8, казалось, даже нравился без инкапсуляции).

Единственным недостатком является то, что вам нужно позаботиться о правильном выравнивании, так как не выровненные векторы ничего не покупают и могут даже быть медленнее, чем не SSE. Но, к счастью, требование выравнивания __m128 будет распространяться на vec4 (и любой окружающий класс), и вам просто нужно позаботиться о динамическом распределении, для которого С++ имеет хорошие возможности. Вам просто нужно создать базовый класс, функции которого operator new и operator delete (во всех вкусах, конечно) перегружены должным образом и из которых будет получен ваш векторный класс. Разумеется, для использования вашего типа со стандартными контейнерами вам также необходимо специализироваться std::allocator (и, возможно, std::get_temporary_buffer и std::return_temporary_buffer для полноты), так как в противном случае он будет использовать глобальный operator new.

Но реальным недостатком является то, что вам также нужно уделить внимание динамическому распределению любого класса, который имеет ваш SSE-вектор в качестве члена, что может быть утомительным, но может быть снова автоматизировано путем получения этих классов из aligned_storage и помещая весь беспорядок специализации std::allocator в удобный макрос.

JamesWynn указывает, что эти операции часто объединяются в некоторые специальные тяжелые вычислительные блоки (такие как фильтрация текстур или преобразование вершин), но, с другой стороны, использование этих векторных инкапсуляций SSE не вносит никаких накладных расходов по стандарту float[4] -выполнение векторного класса. Вам нужно получить эти значения из памяти в регистры в любом случае (будь то стек x87 или скалярный регистр SSE), чтобы делать какие-либо вычисления, поэтому почему бы не взять их все сразу (что должно быть IMHO не медленнее, чем перемещение одного значение, если оно правильно выровнено) и вычислять параллельно. Таким образом, вы можете свободно использовать SSE-дополнение для не SSE без каких-либо накладных расходов (исправьте меня, если мои рассуждения ошибочны).

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


РЕДАКТИРОВАТЬ: Хорошо, чтобы посмотреть на служебный аргумент, который идет здесь (и сначала выглядит вполне разумным), пусть возьмет кучу вычислений, которые выглядят очень чистыми из-за перегруженных операторов

#include "vec.h"
#include <iostream>

int main(int argc, char *argv[])
{
    math::vec<float,4> u, v, w = u + v;
    u = v + dot(v, w) * w;
    v = abs(u-w);
    u = 3.0f * w + v;
    w = -w * (u+v);
    v = min(u, w) + length(u) * w;
    std::cout << v << std::endl;
    return 0;
}

и посмотрите, что думает об этом VC10:

...
; 6   :     math::vec<float,4> u, v, w = u + v;

movaps  xmm4, XMMWORD PTR _v$[esp+32]

; 7   :     u = v + dot(v, w) * w;
; 8   :     v = abs(u-w);

movaps  xmm3, XMMWORD PTR [email protected]
movaps  xmm1, xmm4
addps   xmm1, XMMWORD PTR _u$[esp+32]
movaps  xmm0, xmm4
mulps   xmm0, xmm1
haddps  xmm0, xmm0
haddps  xmm0, xmm0
shufps  xmm0, xmm0, 0
mulps   xmm0, xmm1
addps   xmm0, xmm4
subps   xmm0, xmm1
movaps  xmm2, xmm3

; 9   :     u = 3.0f * w + v;
; 10   :    w = -w * (u+v);

xorps   xmm3, xmm1
andnps  xmm2, xmm0
movaps  xmm0, XMMWORD PTR [email protected]
mulps   xmm0, xmm1
addps   xmm0, xmm2

; 11   :    v = min(u, w) + length(u) * w;

movaps  xmm1, xmm0
mulps   xmm1, xmm0
haddps  xmm1, xmm1
haddps  xmm1, xmm1
sqrtss  xmm1, xmm1
addps   xmm2, xmm0
mulps   xmm3, xmm2
shufps  xmm1, xmm1, 0

; 12   :    std::cout << v << std::endl;

mov edi, DWORD PTR [email protected]@@[email protected][email protected]@[email protected]@@[email protected]
mulps   xmm1, xmm3
minps   xmm0, xmm3
addps   xmm1, xmm0
movaps  XMMWORD PTR _v$[esp+32], xmm1
...

Даже без тщательного анализа каждой отдельной инструкции и ее использования я уверен, что нет никаких лишних загрузок или хранилищ, кроме тех, которые были в начале (Ok, я оставил их неинициализированными), которые необходимы в любом случае, чтобы получить их из памяти в вычислительные регистры, а в конце, что необходимо, как в следующем выражении v, будет выпущено. Он даже не сохранил ничего в u и w, поскольку они являются лишь временными переменными, которые я больше не использую. Все идеально встраивается и оптимизируется. Даже удалось легко перетасовать результат точечного продукта для следующего умножения, не покидая регистр XMM, хотя функция dot возвращает float с использованием фактического _mm_store_ss после haddps s.

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


EDIT:Хорошо, Бен Вейгт указал на еще одну проблему объединения, помимо несовместимости (скорее всего, не проблема) памяти, которая заключается в том, что она нарушает строгие правила псевдонимов, а компилятор может оптимизировать инструкции, обращающиеся к различным членам профсоюза, таким образом, что код недействителен, Я еще об этом не думал. Я не знаю, действительно ли это создает какие-либо проблемы на практике, это, безусловно, требует расследования.

Если это действительно проблема, нам, к сожалению, нужно отбросить член data_[4] и использовать только __m128. Для инициализации теперь нужно снова обратиться к _mm_set_ps и _mm_loadu_ps. operator[] становится немного сложнее и может потребоваться некоторая комбинация _mm_shuffle_ps и _mm_store_ss. Но для версии non-const вам нужно использовать какой-то прокси-объект, делегирующий присвоение соответствующим инструкциям SSE. Он должен быть исследован, каким образом компилятор может оптимизировать эти дополнительные накладные расходы в определенных ситуациях.

Или вы используете только SSE-вектор для вычислений и просто создаете интерфейс для преобразования в и из не-SSE-векторов в целом, который затем используется в периферийных устройствах вычислений (поскольку вам часто не нужен доступ отдельные компоненты внутри длинных вычислений). Похоже, что glm справляется с этой проблемой. Но я не уверен, как Эйген справляется с этим.

Но как бы вы ни справлялись с этим, все еще нет необходимости в инструментах SSE для ручной работы без использования преимуществ перегрузки оператора.

Ответ 2

Я предлагаю вам узнать о шаблонах выражений (пользовательские реализации операторов, которые используют объекты-прокси). Таким образом, вы можете избежать загрузки/сохранения производительности при каждой отдельной операции и выполнять их только один раз для всего вычисления.

Ответ 3

Я бы предложил использовать голый код simd в строго контролируемой функции. Так как вы не будете использовать его для первичного векторного умножения из-за накладных расходов, эта функция должна, вероятно, взять список объектов Vector3, которые нужно манипулировать, в соответствии с DOD. Там, где есть, их много.