我应该使用SIMD还是向量扩展或其他什么



我目前正在用c++(使用c++11)开发一个开源的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);
    
  • 或者编译器应该做肮脏的工作?

    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操作的动态容器类,它需要额外的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);
    

    上面的例子使用了一个虚类,该虚类内部使用float[4],并且在类似multiplyElements(...)的每个方法中使用storeload。这些方法使用SSE内部。

我不想使用另一个库,因为我想了解更多关于SIMD和大规模软件设计的信息。但图书馆的例子是受欢迎的。

附言:这不是一个真正的问题,而是一个设计问题。

好吧,如果你想使用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的平台,但这不起作用),您可以在必要的时候使用标准浮点数组(如operator[]或初始化(不要使用_mm_set_ps)),并且只在适当的时候使用SSE。使用现代内联编译器,封装可能是免费的(我很惊讶VC10对SSE指令的优化效果如何,可以用这个向量类进行一系列计算,而不用担心不必要的移动到临时内存变量中,就像VC8似乎喜欢的那样,即使没有封装)。

唯一的缺点是,你需要注意正确的对齐,因为未对齐的矢量不会给你带来任何好处,甚至可能比非SSE慢。但幸运的是,__m128的对齐要求将传播到vec4(以及任何周围的类)中,您只需要注意动态分配,C++对此有很好的方法。您只需要创建一个基类,它的operator newoperator delete函数(当然在所有类型中)都被正确重载,并且您的向量类将从中派生。要将您的类型与标准容器一起使用,您当然还需要专门化std::allocator(为了完整性,可能还有std::get_temporary_bufferstd::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 __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
...

即使没有彻底分析每一条指令及其使用,我也很有信心地说,除了开头的加载或存储(好吧,我没有初始化它们),它们无论如何都是必要的,以便将它们从内存中放入计算寄存器,最后,如下式所示,v将被输出。它甚至没有将任何内容存储回uw中,因为它们只是临时变量,我不再使用它们。所有内容都经过了完美的内联和优化。尽管dot函数在haddps秒之后使用实际的_mm_store_ss返回float,但它甚至成功地无缝地打乱了点积的结果,以进行下一次乘法运算,而不会离开XMM寄存器。

因此,即使是我,通常也有点过于怀疑编译器的能力,不得不说,与通过封装获得的干净且富有表现力的代码相比,将自己的内部函数手工制作成特殊函数并没有真正的好处。尽管您可能能够创建杀手级的例子,手工处理intrinics可能确实会省去一些指令,但您首先必须智胜优化器。


编辑:好的,Ben Voigt指出了并集除了内存布局不兼容之外的另一个问题(很可能没有问题),那就是它违反了严格的混叠规则,编译器可能会优化访问不同并集成员的指令,从而使代码无效。我还没想过。我不知道它在实践中是否会产生任何问题,当然需要调查。

如果这真的是一个问题,我们不幸地需要删除data_[4]成员并单独使用__m128。对于初始化,我们现在必须再次使用_mm_set_ps_mm_loadu_ps。CCD_ 32变得有点复杂,并且可能需要CCD_ 33和CCD_ 34的某种组合。但对于非常量版本,您必须使用某种代理对象,将赋值委托给相应的SSE指令。必须研究编译器在特定情况下如何优化这种额外的开销。

或者,您只使用SSE矢量进行计算,只需创建一个接口,用于在整体上转换到非SSE矢量,然后在计算的外围设备上使用(因为您通常不需要访问冗长计算中的单个组件)。这似乎是glm处理此问题的方式。但我不确定Eigen是如何处理的。

但是,无论你如何处理它,仍然没有必要在不利用运营商过载的好处的情况下手工制作SSE指令。

我建议您了解表达式模板(使用代理对象的自定义运算符实现)。通过这种方式,您可以避免在每个单独的操作周围进行破坏性能的加载/存储,并且在整个计算中只进行一次。

我建议在严格控制的函数中使用裸simd代码。由于开销的原因,您不会将其用于主向量乘法,因此根据DOD,此函数可能会获取需要操作的Vector3对象列表。有一个就有很多。

最新更新