我应该使用SIMD还是向量扩展或其他什么
Should I use SIMD or vector extensions or something else?
我目前正在用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操作的动态容器类,它需要额外的
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]
,并且在类似multiplyElements(...)
的每个方法中使用store
和load
。这些方法使用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 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 __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
中,因为它们只是临时变量,我不再使用它们。所有内容都经过了完美的内联和优化。尽管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对象列表。有一个就有很多。
- 在执行其他功能的同时播放动画(LED矩阵和Arduino/ESP8266)
- 将数组作为参数传递给函数安全吗?作为第三方职能部门,可以探索他们想要的之外的其他元素
- 是否可以通过C++扩展强制多个python进程共享同一内存
- static_assert在宏中,但也可以扩展到可以用作函数参数的东西
- 有没有什么方法可以使用一个函数中定义的常量变量,也可以由c++中同一程序中的其他函数使用
- GL_SHADERSTORAGE_BUFFER位置是否与其他着色器位置冲突
- 为什么我不能在 C++ 中的特定函数重载中调用同一函数的任何其他重载?
- 如何将这个C++哈希表转换为动态扩展和收缩,而不是使用硬设置的最大值
- 在其他文件中创建类时在 c++ 项目中不起作用
- 扩展光电二极管探测器以支持多个传感器
- 类与私有变量的其他类之间的线程安全性
- 针对特殊情况,使用其他状态信息扩展基元类型
- 我如何使我的Python扩展需要其他论点
- 我应该使用SIMD还是向量扩展或其他什么
- 使用C++实验时忽略具有其他扩展名的文件的最佳方法<filesystem>?
- 在WinAPI、POSIX或API-OS等价物的其他扩展中,是否存在来自C++11的所有级别的内存屏障
- 扩展类以进行调试:公共API、隐藏实现或其他什么
- 其他类数据类型的扩展boost::lexical_cast
- 使用python扩展时链接到其他库(例如boost)
- 扩展std::vector以从其他vector类型移动元素