我应该使用SIMD或矢量扩展还是别的?

pea*_*ing 17 c++ gcc sse simd

我目前正在开发一个开源的3D应用程序框架(用).我自己的数学库的设计类似于XNA数学库,同时考虑了SIMD.但目前它并不是很快,而且它在内存中存在问题,但在另一个问题上更多.

几天前我问自己为什么要编写自己的SSE代码.编译器还可以在启用优化时生成高优化代码.我也可以使用GCC的" 向量扩展 " .但这一切都不是真正的便携式.

我知道当我使用自己的SSE代码时,我有更多的控制权,但通常这种控制是不公平的.

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);
    
    Run Code Online (Sandbox Code Playgroud)
  • 或者编译器应该做脏工作吗?

    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];
    
    Run Code Online (Sandbox Code Playgroud)
  • 或者我应该使用SIMD和其他代码吗?就像具有SIMD操作的动态容器类一样,需要额外的loadstore指令.

    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);
    
    Run Code Online (Sandbox Code Playgroud)

    上面的例子使用了一个虚构的类,使用float[4]内部和使用,storeload在每个方法中使用multiplyElements(...).该方法使用SSE内部.

我不想使用其他库,因为我想了解有关SIMD和大规模软件设计的更多信息.但欢迎图书馆的例子.

PS:这不是一个真正的问题,而是一个设计问题.

Chr*_*ica 13

好吧,如果你想使用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];
    };
};
Run Code Online (Sandbox Code Playgroud)

现在好消息是,由于匿名联合(UB,我知道,但是向我展示一个SSE平台,这不起作用)你可以在必要时使用标准的float数组(比如operator[]或初始化(不要使用_mm_set_ps))并且只在适当时使用SSE.使用现代内联编译器,封装可能没有成本(我很惊讶VC10如何利用此向量类优化SSE指令进行一系列计算,不必担心不必要的移动到临时内存变量,因为VC8似乎甚至喜欢没有封装).

唯一的缺点是,你需要注意正确的对齐,因为未对齐的向量不会给你任何东西,甚至可能比非SSE慢.但幸运的是,对象的对齐要求__m128会传播到vec4(和任何周围的类)中,你只需要处理动态分配,C++有很好的手段.你只需要做出一个基类,其operator newoperator delete功能(当然,在所有口味)正确超载,从你的载体类将派生.要将您的类型与标准容器一起使用,您当然也需要专门化std::allocator(并且可能std::get_temporary_buffer并且std::return_temporary_buffer为了完整性),因为它将使用全局operator new否则.

但真正的缺点是,你还需要关心任何具有你的SSE向量作为成员的类的动态分配,这可能是单调乏味的,但也可以通过从这些类派生这些类aligned_storage并进行整体std::allocator专业化来再次自动化.乱成一个方便的宏.

JamesWy​​nn指出,这些操作通常在一些特殊的重型计算块(如纹理过滤或顶点变换)中聚集在一起,但另一方面,使用这些SSE向量封装不会引入任何标准的任何开销 - float[4]矢量类的实现.你需要将这些值从内存中获取到寄存器中(无论是x87堆栈还是标量SSE寄存器)才能进行任何计算,所以为什么不立刻将它们全部取出(这应该比移动单个更慢)如果正确对齐则值并且并行计算.因此,您可以自由地为非SSE进行SSE-inplementation而不会产生任何开销(如果我的推理错误,请纠正我).

但是如果确保所有具有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;
}
Run Code Online (Sandbox Code Playgroud)

看看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 __xmm@0
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 __xmm@1
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 __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
mulps   xmm1, xmm3
minps   xmm0, xmm3
addps   xmm1, xmm0
movaps  XMMWORD PTR _v$[esp+32], xmm1
...
Run Code Online (Sandbox Code Playgroud)

即使没有彻底分析每一条指令及其使用,我也非常有信心说没有任何不必要的加载或存储,除了开头的那些(好吧,我让它们未初始化),无论如何都是必要的它们从内存到计算寄存器,最后,这是必要的,如下面的表达式v将被推出.它甚至没有存储任何回uw,因为它们是我不进一步使用任何唯一的临时变量.一切都完美内联和优化.它甚至设法无缝地对随后乘法的点积的结果进行洗牌,而不会离开XMM寄存器,尽管该dot函数在s 之后float使用实际返回a ._mm_store_sshaddps

因此,即使我,通常对编译器的能力有点过分,也不得不说,与通过封装获得的干净且富有表现力的代码相比,将自己的内在函数手工制作成特殊函数并不能真正付出代价.虽然你可以创建一些杀手级的例子,其中手工制作内在术可能确实可以省去一些指令,但是你再次首先要超越优化器.


编辑:好的,Ben Voigt指出了除了(最可能没有问题)内存布局不兼容之外的联合的另一个问题,即它违反了严格的别名规则,并且编译器可以优化访问不同联合成员的指令,使得代码无效.我还没想过.我不知道它在实践中是否有任何问题,当然需要调查.

如果它真的是一个问题,我们不幸的是需要放弃该data_[4]成员并__m128单独使用它.对于初始化我们现在不得不求助于_mm_set_ps_mm_loadu_ps试.将operator[]变得更加复杂一点,可能需要一些组合_mm_shuffle_ps_mm_store_ss.但是对于非const版本,您必须使用某种代理对象将赋值委托给相应的SSE指令.必须研究编译器在特定情况下可以优化这种额外开销的方式.

或者你只使用SSE向量进行计算,只需要创建一个接口,用于在整个非SSE向量之间进行转换,然后在计算的外围使用(因为你通常不需要访问里面的单个组件)冗长的计算).这似乎是glm处理这个问题的方式.但我不确定Eigen如何处理它.

但是无论如何处理它,仍然没有必要在不使用运算符重载的好处的情况下手工制作SSE instrisics.

  • @ omercan1993看看吧.制作一些这些矢量并用它们做一堆计算.如果它不会导致一堆SSE指令而没有任何不必要的加载或存储(放弃函数调用),我会感到惊讶.在这方面,我认为最新的gcc在任何方面都不比VC10差.当然,在这些计算模块的开始处,您可能会有一些负载,但无论如何都存在非SSE向量或手写内在函数. (3认同)