好吧,如果您想使用 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 new
and operator delete
函数(当然是所有类型的)都被正确重载,并且您的向量类将从中派生。要将您的类型与标准容器一起使用,您当然还需要专门化std::allocator
(有可能std::get_temporary_buffer
and std::return_temporary_buffer
为了完整起见),因为它将使用全局operator new
否则。
但真正的缺点是,您还需要关心以您的 SSE 向量作为成员的任何类的动态分配,这可能很乏味,但可以通过从这些类派生来再次自动化一点aligned_storage
并把整个std::allocator
专业化混乱成一个方便的宏。
JamesWynn 有一个观点,即这些操作通常集中在一些特殊的重型计算块中(例如纹理过滤或顶点变换),但另一方面,使用这些 SSE 向量封装不会比标准引入任何开销float[4]
-向量类的实现。无论如何,您都需要将这些值从内存中获取到寄存器中(无论是 x87 堆栈还是标量 SSE 寄存器)才能进行任何计算,所以为什么不一次将它们全部取出(恕我直言,这应该不会比移动单个值慢)值(如果正确对齐)并并行计算。因此,您可以自由地将 SSE 实现切换为非 SSE 实现,而不会产生任何开销(如果我的推理错误,请纠正我)。
但是如果确保所有类别的对齐vec4
因为成员对你来说太乏味了(恕我直言,这是这种方法的唯一缺点),你还可以定义一个专门的 SSE 向量类型,用于计算并使用标准的非 SSE 向量进行存储。
EDIT:好吧,看看这里的开销参数(乍一看很合理),让我们进行一堆计算,由于重载的运算符,这些计算看起来非常干净:
#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
将会被赶出去。它甚至没有将任何东西存储回u
and w
,因为它们只是临时变量,我不再使用它们。一切都完美内联并优化。它甚至成功地无缝地对接下来的乘法的点积结果进行洗牌,而无需离开 XMM 寄存器,尽管dot
函数返回一个float
使用实际的_mm_store_ss
之后haddps
s.
因此,即使是我,通常对编译器的能力有点过度怀疑,也不得不说,与通过封装获得的干净且富有表现力的代码相比,将自己的内在函数手工制作成特殊函数并不真正值得。尽管您也许能够创建杀手级示例,其中手工编写的内在函数确实可以为您节省一些指令,但话又说回来,您首先必须比优化器更聪明。
EDIT:好的,Ben Voigt 指出了联合体的另一个问题,除了(很可能没有问题)内存布局不兼容之外,它违反了严格的别名规则,并且编译器可能会优化访问不同联合体成员的指令,从而使代码无效。我还没想过。我不知道它在实践中是否会产生任何问题,当然需要调查。
如果这确实是一个问题,不幸的是我们需要放弃data_[4]
会员并使用__m128
独自的。对于初始化,我们现在必须求助于_mm_set_ps
and _mm_loadu_ps
再次。这operator[]
变得有点复杂,可能需要一些组合_mm_shuffle_ps
and _mm_store_ss
。但对于非常量版本,您必须使用某种代理对象将分配委托给相应的 SSE 指令。必须研究编译器在特定情况下可以以何种方式优化这种额外的开销。
或者,您只使用 SSE 向量进行计算,并创建一个接口用于与非 SSE 向量进行整体转换,然后在计算的外围设备中使用该接口(因为您通常不需要访问内部的各个组件)冗长的计算)。这似乎就是这样glm处理这个问题。但我不知道如何Eigen处理它。
但是无论您如何解决它,如果不利用运算符重载的好处,仍然不需要手工制作 SSE 本质。