18

(使用)开发一个开源 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]内部和使用store以及load在每个方法中,例如multiplyElements(...). 这些方法使用 SSE 内部。

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

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

4

3 回答 3

14

好吧,如果你想使用 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_buffer并且std::return_temporary_buffer为了完整起见),operator new否则它将使用全局。

但真正的缺点是,您还需要关注将 SSE 向量作为成员的任何类的动态分配,这可能很乏味,但也可以通过从这些类派生aligned_storage并放置整个std::allocator专业化来再次自动化一点乱成一个方便的宏。

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

但是,如果确保所有vec4作为成员的类的对齐对您来说太乏味(恕我直言,这是这种方法的唯一缺点),您还可以定义一个专门的 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将被输出。它甚至没有将任何东西存储回uand w,因为它们只是我不再使用的临时变量。一切都完美内联和优化。它甚至设法无缝地打乱点积的结果以进行以下乘法,而不会离开 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。但是对于非常量版本,您必须使用某种代理对象,将分配委托给相应的 SSE 指令。必须研究编译器可以在特定情况下以何种方式优化这种额外的开销。

或者您只使用 SSE 向量进行计算,并且只制作一个接口,用于在整个非 SSE 向量之间进行转换,然后在计算的外围设备中使用(因为您通常不需要访问内部的单个组件冗长的计算)。这似乎是glm处理这个问题的方式。但我不确定Eigen是如何处理它的。

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

于 2012-06-01T13:29:28.820 回答
4

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

于 2012-06-01T13:36:39.283 回答
2

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

于 2012-05-23T11:40:17.023 回答