我目前正在c++(c++11)开发一个开源3D应用程序框架。我自己的数学库的设计类似XNA math library,同时考虑到SIMD。但目前它并不是很快,它存在内存问题,但在另一个问题上有更多问题。
前几天我问自己为什么要写自己的SSE代码。编译器还可以在启用优化时生成高优化代码。我还可以使用vector extension的“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操作的动态容器类一样,需要额外的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]
,并在store
等每个方法中使用load
和multiplyElements(...)
。这些方法使用SSE内部。
我不想使用其他库,因为我想了解有关SIMD和大规模软件设计的更多信息。但欢迎图书馆的例子。
PS:这不是一个真正的问题,而是一个设计问题。
答案 0 :(得分: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];
};
};
现在好处是,由于匿名联合(UB,我知道,但是向我展示了一个SSE的平台,但这不起作用)你可以在必要时使用标准的浮点数组(如{{1或初始化(不要使用operator[]
))并在适当时仅使用SSE。使用现代内联编译器,封装可能没有成本(我很惊讶VC10如何利用这个向量类优化SSE指令进行一系列计算,不用担心不必要的移动到临时内存变量,因为VC8似乎甚至喜欢没有封装)。
唯一的缺点是,你需要保持正确的对齐,因为未对齐的向量不会给你任何东西,甚至可能比非SSE慢。但幸运的是_mm_set_ps
的对齐要求将传播到__m128
(和任何周围的类),你只需要处理动态分配,C ++有很好的手段。您只需要创建一个基类,其vec4
和operator new
函数(当然在所有类型中)都会正确地重载,并且您的矢量类将从中派生。要将您的类型与标准容器一起使用,您当然还需要专门化operator delete
(可能std::allocator
和std::get_temporary_buffer
为了完整性),因为它将使用全局{{1}否则。
但真正的缺点是,您还需要关心任何具有SSE向量作为成员的类的动态分配,这可能是单调乏味的,但也可以通过从{{派生这些类来再次自动化一点1}}并将整个std::return_temporary_buffer
专业化混乱放入一个方便的宏。
JamesWynn指出,这些操作通常在一些特殊的重型计算块(如纹理过滤或顶点变换)中聚集在一起,但另一方面,使用这些SSE向量封装并不会引入任何标准的开销{ {1}} - 矢量类的实现。你需要将这些值从内存中获取到寄存器中(无论是x87堆栈还是标量SSE寄存器)才能进行任何计算,所以为什么不立刻将它们全部取出(这应该比移动单个更慢)如果正确对齐则值并且并行计算。因此,您可以自由地为非SSE进行SSE-inplementation而不会产生任何开销(如果我的推理错误,请纠正我)。
但是如果确保所有具有operator new
成员的类对齐对你来说太麻烦了(这是恕我直言这种方法的唯一缺点),你还可以定义一个你使用的专用SSE矢量类型用于计算并使用标准的非SSE向量进行存储。
编辑:好的,看看这里的开销参数(最初看起来很合理),让我们进行一系列计算,看起来很干净,由于运算符过载:
aligned_storage
看看VC10对它的看法:
std::allocator
即使没有彻底分析每一条指令及其用途,我也非常有信心说没有任何不必要的加载或存储,除了开头的那些(好吧,我让它们没有初始化)无论如何,这是必要的,以便将它们从记忆中转移到计算寄存器中,最后,这是必要的,如下面的表达式float[4]
将被推出。它甚至没有将任何内容存回vec4
和#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;
}
,因为它们只是我不能再使用的临时变量。一切都完美内联和优化。即使...
; 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
返回w
,它甚至设法无缝地混合点积的结果用于以下乘法,而不会离开XMM寄存器。 {1}}秒。
所以,即使我,通常对编译器的能力有点过分,但不得不说,将你自己的内在函数手工制作成特殊功能并不是真正付出代价而不是你获得的干净和富有表现力的代码封装。虽然您可以创建一些杀手级示例,其中手工制作内在术可能确实可以为您节省一些指示,但是您首先必须超越优化器。
编辑:好的,Ben Voigt指出了除了(最可能没有问题的)内存布局不兼容之外的另一个联合问题,即它违反了严格的别名规则并且编译器可能会优化指令以使代码无效的方式访问不同的联合成员。我还没想过。我不知道它是否在实践中出现任何问题,它当然需要调查。
如果确实存在问题,我们很遗憾地需要删除dot
成员并单独使用float
。对于初始化,我们现在必须再次使用_mm_store_ss
和haddps
。 data_[4]
变得有点复杂,可能需要__m128
和_mm_set_ps
的某种组合。但是对于非const版本,您必须使用某种代理对象将赋值委托给相应的SSE指令。必须研究编译器在特定情况下可以优化这种额外开销的方式。
或者你只使用SSE矢量进行计算,只需在一个整体上建立一个非SSE矢量转换接口,然后用于计算的外围设备(因为你经常不需要)访问冗长计算中的各个组件)。这似乎是 glm 处理此问题的方式。但我不确定 Eigen 如何处理它。
但无论如何处理它,仍然没有必要在不使用运算符重载的好处的情况下手工制作SSE instrisics。
答案 1 :(得分:4)
我建议您了解表达式模板(使用代理对象的自定义运算符实现)。通过这种方式,您可以避免围绕每个单独的操作执行性能破坏加载/存储,并且只对整个计算执行一次。
答案 2 :(得分:2)
我建议在严格控制的函数中使用裸simd代码。由于开销,您不会将它用于主矢量乘法,因此根据DOD,此函数可能应该采用需要操作的Vector3对象列表。哪里有一个,就有很多。