设计快速跨平台SIMD矢量库

来源:互联网 发布:蜘蛛纸牌知乎 编辑:程序博客网 时间:2024/05/13 04:23

大部分3D应用中都有执行程序计算的矢量库,比如矢量运算,逻辑,比较,点和乘积等。尽管有无数设计这类库的方法,开发者们还是会经常忽略让这种矢量库以最快速度计算的关键要素。

大概2004年晚些时候,我接到一项任务,开发命名为VMath的矢量库,VMath代表的意思是“矢量数学(Vector Math)。”VMath的主要目标不仅仅在于最快速的运算,同时还要让它易于在不同平台之间移植。

2009年,令我惊讶的是,电脑编程技术并未改变多少。实际上本文中的结论,正是我在那时候的研究结果,让人意外的是,这几乎同我五年前开发VMath的时候一模一样。

“最快速”的库

由于本文大部分内容是以C++语言为主,且注重性能,有关最快速的库定义有可能引起歧义。

我们将最快速的库描述为,当在同样设置(当然是发布模式)下编译同样的代码时,同其他的库相比,这一代码库生成的汇编代码是最少的。这是因为最快速的库生成更少的指令来履行同样的精确运算。换句话说,最快速的库的代码膨胀是最少的。

SIMD 介绍

随着单指令多数据流(Single Instruction Multiple Data, SIMD)在处理器上的广泛应用,开发矢量库更为简单了。SIMD寄存器上的SIMD操作就像FPU寄存器上的FPU操作一样精确。尽管如此,SIMD的优势在于,SIMD寄存器通常以128位宽形成四字:四个“浮点数”或“整数”,每个32位。这让开发者们能够在单一指令下履行4D矢量运算。正因为这样,矢量库中最好的功能就是它的SIMD指令。

虽然如此,在使用SIMD指令的时候,你必须堤防能够引起库代码膨胀的普遍错误。实际上一个用SIMD方式实现的矢量库中的代码膨胀问题会在某个点激化,这样反而不如直接使用FPU指令。

矢量库接口

当设计矢量库高级接口时,运用SIMD的最佳方式是使用内建。对于大多数面向支持SIMD指令的处理器的编译器,它们都可用。而且,每个内建指令都转换为单一SIMD指令。不管怎样,使用内建指令代替直接汇编的优势在于编译器能够执行调度和表达式优化。这可以极大地将代码膨胀降到最低。

以下为内建示例:

Intel & AMD:

vr = _mm_add_ps(va, vb);

Cell Processor (SPU):

vr = spu_add(va, vb);

Altivec:

vr = vec_add(va, vb);

1.以数值形式返回结果

通过观察内建接口,矢量库必须模拟这些接口来实现性能最大化。因此,必须以数值形式返回结果而不是以参数形式返回结果,像这样:

//correct
inline Vec4 VAdd(Vec4 va, Vec4 vb)
{

return(_mm_add_ps(va, vb));

};

换句话说,如果数据以参数形式返回将导致代码膨胀。错误示例如下:

//incorrect (code bloat!)
inline void VAddSlow(Vec4& vr, Vec4 va, Vec4 vb)
{

vr = _mm_add_ps(va, vb);

};

必须以数值返回数据的原因是,四字(128位)在SIMD寄存器中为最适合。矢量库的一个关键要素在于尽可能的在这些寄存器中保存数据。通过这样做,可以避免SIMD寄存器到内存或FPU寄存器的不必要的载入和存储操作。当合并多项矢量操作时,“以数值返回”接口使编译器能很容易地通过最小化SIMD向FPU或内存传输来优化这些载入和存储。

2.“纯数据”声明

这里,“纯数据”定义为,在“class”或“struct”之外,简单地使用“typedef”或“define”进行的数据声明。当我在编写VMath之前调查各类矢量库时,我观察到所有的库中的通用样式。在所有案例中,开发者们将基础四字打包入“class”或“struct”而不是单纯地声明它,如下:

class Vec4
{

...
private:
__m128 xyzw;

};

这一类型的数据封装在C++开发者的实践中甚为普遍,用来让软件结构更具健壮性。数据被定义为保护的且只能通过类接口函数来访问。但是,这样的设计导致了不同平台上的不同编译器引起的代码膨胀,尤其是在使用GCC这类编译器移植的时候。

对于编译器一种更为友善的方法是“纯粹的”声明矢量数据,如下:

typedef __m128 Vec4;

不可否认,那样设计矢量库会失去对基础数据的好的封装和保护。虽然如此,还是取得了显著成果。让我们看一看澄清这一问题的例子:

我们可以使用Maclaurin (*)级数(麦克劳林级数)估算出正弦函数,如下:


(*)在制作代码的过程中有很多更好更快的方式来估算出正弦函数。这里使用Maclaurin只是为了举例说明。

如果开发者运用以上公式编写正弦函数的矢量版本代码,那么代码差不多会是这样:

Vec4 VSin(const Vec4& x)
{

Vec4 c1 = VReplicate(-1.f/6.f);
Vec4 c2 = VReplicate(1.f/120.f);
Vec4 c3 = VReplicate(-1.f/5040.f);
Vec4 c4 = VReplicate(1.f/362880);
Vec4 c5 = VReplicate(-1.f/39916800);
Vec4 c6 = VReplicate(1.f/6227020800);
Vec4 c7 = VReplicate(-1.f/1307674368000);

Vec4 res = x +

c1*x*x*x +
c2*x*x*x*x*x +
c3*x*x*x*x*x*x*x +
c4*x*x*x*x*x*x*x*x*x +
c5*x*x*x*x*x*x*x*x*x*x*x +
c6*x*x*x*x*x*x*x*x*x*x*x*x*x +
c7*x*x*x*x*x*x*x*x*x*x*x*x*x*x*x;


return (res);

}

现在让我们看看同一函数的汇编代码,左列是使用纯数据声明Vec 4方式编译后的代码,右列是将数据定义在类内部编译后的代码。(点击这里下载表格文件。参照Table 1)

简单的改变基础数据声明方式,这同一段代码编译后就缩减了大约15%。如果这一函数在循环内部执行运算,这就不仅是节约了代码量,显然会加快运行速度。

4.重载操作符 vs. 程序接口

因其清晰而成为当今最流行的应用,C++另外一个好的特性在于重载操作符的运用。尽管如此,通常来说重载操作符同样会引起代码膨胀。作为快速SIMD矢量库应同样提供C语言般的程序接口。

在处理简单数学表达式时,重载操作符不会引起代码膨胀。这也许就是大部分开发者之所以在第一次设计矢量库时忽略这个问题的原因吧。虽然如此,随着表达式越来越复杂,优化器必须进行额外的工作来保证正确的结果。这涉及了创建不必要的临时存储变量,通常会转化为SIMD寄存器和内存之间更多的载入/存储操作。

让我们尝试使用重载操作符和程序接口来编写一个3频均衡滤波器。然后我们查看生成的汇编代码。使用重载操作符编写的源代码如下:

Vec4 do_3band(EQSTATE* es, Vec4& sample)
{

Vec4 l,m,h;

es->f1p0 += (es->lf * (sample - es->f1p0)) + vsa;
es->f1p1 += (es->lf * (es->f1p0 - es->f1p1));
es->f1p2 += (es->lf * (es->f1p1 - es->f1p2));
es->f1p3 += (es->lf * (es->f1p2 - es->f1p3));
l = es->f1p3;

es->f2p0 += (es->hf * (sample - es->f2p0)) + vsa;
es->f2p1 += (es->hf * (es->f2p0 - es->f2p1));
es->f2p2 += (es->hf * (es->f2p1 - es->f2p2));
es->f2p3 += (es->hf * (es->f2p2 - es->f2p3));
h = es->sdm3 - es->f2p3;
m = es->sdm3 - (h + l);

l *= es->lg;
m *= es->mg;
h *= es->hg;

es->sdm3 = es->sdm2;
es->sdm2 = es->sdm1;
es->sdm1 = sample;
return(l + m + h);

}

现在使用程序接口重写同样的代码:

Vec4 do_3band(EQSTATE* es, Vec4& sample)
{
 Vec4 l,m,h;

es->f1p0 = VAdd(es->f1p0, VAdd(VMul(es->lf, VSub(sample, es->f1p0)), vsa));
es->f1p1 = VAdd(es->f1p1, VMul(es->lf, VSub(es->f1p0, es->f1p1)));
es->f1p2 = VAdd(es->f1p2, VMul(es->lf, VSub(es->f1p1, es->f1p2)));
es->f1p3 = VAdd(es->f1p3, VMul(es->lf, VSub(es->f1p2, es->f1p3)));
l = es->f1p3;

es->f2p0 = VAdd(es->f2p0, VAdd(VMul(es->hf, VSub(sample, es->f2p0)), vsa));
es->f2p1 = VAdd(es->f2p1, VMul(es->hf, VSub(es->f2p0, es->f2p1)));
es->f2p2 = VAdd(es->f2p2, VMul(es->hf, VSub(es->f2p1, es->f2p2)));
es->f2p3 = VAdd(es->f2p3, VMul(es->hf, VSub(es->f2p2, es->f2p3)));
h = VSub(es->sdm3, es->f2p3);
m = VSub(es->sdm3, VAdd(h, l));

l = VMul(l, es->lg);
m = VMul(m, es->mg);
h = VMul(h, es->hg);

es->sdm3 = es->sdm2;
es->sdm2 = es->sdm1;
es->sdm1 = sample;

return(VAdd(l, VAdd(m, h)));

}

让我们看看这两种方式的汇编代码。(点击这里下载表格文件;参照Table 2)

使用程序调用的代码大概要少21%。还要注意的是,这一代码均衡了四个同期音频流。因此,相比于通过FPU进行同样的计算,速度极大地提高了。

在此我要强调一下,我并不是不支持矢量库上的重载操作符。实际上我两个都支持,在表达式变得复杂足以导致代码膨胀的情况下,开发者们可以过程化的编写代码。

实际上在我测试VMath的时候,我通常用重载操作符写第一个数学表达式。然后用过程化对同样的代码进行测试,来看看是不是会有所不同。代码膨胀的原因取决于之前提到的复杂性和表达式。

5.内联

内联能够让编译器摆脱代价昂贵的函数调用从而以更多代码膨胀为代价优化代码。通常的做法是内联所有的矢量函数,函数调用便不执行了。

Windows 编译器(Microsoft & Intel)对于何时需要或不需要内联函数驾轻就熟。最好把这项工作留给编译器,这样在你所有的矢量函数添加内联时才不会落下什么。

尽管如此,内联的一个问题在于指令高速缓存(I-Cache)未命中。如果编译器不够好,不足以识别出函数对于目标平台“太大”,你会惹上大麻烦。不光代码会膨胀,还能引起大量的I-Cache未命中。这能导致矢量函数更为缓慢。

我没法给出Windows开发中“内联的”矢量函数引起代码膨胀而影响I-Cache的案例。实际上,甚至当我没有内联函数时windows编译器足够智能来帮我对它们进行内联。虽然这样,我在进行PSP和PS3开发时GCC端口却没有那么智能。确实曾经有过最好不要内联的例子。

但是当你查看这些特殊定案例时,它总是细碎地将你的内联函数包装为未“内联的”指定平台调用。如:

Vec4 DotPlatformSpecific(Vec4 va, Vec4 vb)
{

return (Dot(va, vb));

}

从另一方面说,如果矢量库设计中没有内联,你不能强迫它们轻而易举的内联。因此你需要依赖足够智能的编译器为你内联函数。这在不同平台的表现可能不会如你所愿。

6.在SIMD寄存器中复制结果并提供访问器

在SIMD指令下进行一次运算同时获得四个数据结果。但是如果你不需要四个寄存器的结果该怎么办?如果输入相同,你仍然具有在SIMD寄存其中复制结果的优势。在SIMD四字中复制结果能够帮助编译器优化矢量表达式。

同样的理由,提供像GetX,GetY,GetZ,和 GetZ这样的数据访问器是重要的。通过提供这一类型的接口,假设开发者们使用它,矢量库就能够最小化SIMD寄存器和FPU寄存器之间的代价昂贵的投射操作。

随后我将讨论说明这一问题的经典案例。

SIMD矢量库好的程序实践

甚至在有设计精良的矢量库,尤其在SIMD指令下工作时,你还是必须注意怎样使用它。完全依赖编译器生成最好的代码不是个好主意。

1.编写编译器友好的表达式

编写代码的方式将极大的影响最终汇编代码。甚至在使用稳健的编辑器时,所产生的不同还是显而易见。回到正弦函数的例子,如果使用更小的表达式来写函数,最终代码将有效率的多。让我们看看同样代码的新版本:

Vec4 VSin2(const Vec4& x)
{

Vec4 c1 = VReplicate(-1.f/6.f);
Vec4 c2 = VReplicate(1.f/120.f);
Vec4 c3 = VReplicate(-1.f/5040.f);
Vec4 c4 = VReplicate(1.f/362880);
Vec4 c5 = VReplicate(-1.f/39916800);
Vec4 c6 = VReplicate(1.f/6227020800);
Vec4 c7 = VReplicate(-1.f/1307674368000);

Vec4 tmp0 = x;
Vec4 x3 = x*x*x;
Vec4 tmp1 = c1*x3;
Vec4 res = tmp0 + tmp1;

Vec4 x5 = x3*x*x;
tmp0 = c2*x5;
res = res + tmp0;

Vec4 x7 = x5*x*x;
tmp0 = c3*x7;
res = res + tmp0;

Vec4 x9 = x7*x*x;
tmp0 = c4*x9;
res = res + tmp0;

Vec4 x11 = x9*x*x;
tmp0 = c5*x11;
res = res + tmp0;

Vec4 x13 = x11*x*x;
tmp0 = c6*x13;
res = res + tmp0;

Vec4 x15 = x13*x*x;
tmp0 = c7*x15;
res = res + tmp0;

return (res);

}

现在让我们比较结果。(点击这里下载表格文件。参照Table 3)

简单以更友好的方式向编译器重新写入代码,代码缩减了40%。

2.在SIMD寄存器中保留结果

正如之前所提到的,SIMD寄存器和FPU寄存器之间的投射操作代价昂贵。发生这类情况的经典案例是“点乘积”,它导致两个矢量产生一个标量。如:

Dot (Va, Vb) = (Va.x * Vb.x) + (Va.y * Vb.y) + (Va.z * Vb.z) + (Va.w * Vb.w);

现在让我们看看使用点乘积的代码片断:

Vec4& x2 = m_x[i2];

Vec4 delta = x2-x1;

float deltalength = Sqrt(Dot(delta,delta));

float diff = (deltalength-restlength)/deltalength;

x1 += delta*half*diff;

x2 -= delta*half*diff;

通过观察上述代码,“deltalength”是位于矢量“x1”和矢量“x2”之间的距离。“Dot”函数的结果为标量。然后这一标量在其余代码中被使用并修正来衡量矢量“x1”和“x2”。显而易见,从矢量到标量和进行了大量的投射操作,反之亦然。这代价昂贵,因为编译器需要生成在SIMD寄存器和FPU寄存器之间来回转移数据的代码。

虽然如此,如果我们假设以上“Dot”函数在SIMD四字中复制了结果,“w”分量清零,那就同下面重写的代码没什么不同了:

Vec4& x2 = m_x[i2];

Vec4 delta = x2-x1;

Vec4 deltalength = Sqrt(Dot(delta,delta));

Vec4 diff = (deltalength-restlength)/deltalength;

x1 += delta*half*diff;

x2 -= delta*half*diff;

因为现在“deltalength”有了在四字中复制的同样的结果,就没有必要再进行代价昂贵的转换操作了。

3.重新编排数据以便对SIMD操作友好

尽可能通过重新编排数据以便更好的利用矢量库。举例来说,在开发音频时可以让数据流存储对SIMD友好。

假设有四条音频流样本存储于四个不同的阵列中,如:

这样做的好处在于你能将阵列直接载入SIMD寄存器中并同时执行所有样本的运算。缺陷就是如果需要将数据发送到另外一个需要四条流线阵列的系统上,要重新将这些阵列安排到原始形式。如果矢量库没有在数据上执行繁重运算,就会导致代价昂贵并极大的消耗内存。

XNA Math Library – 能更快吗?

微软在DirectX上捆绑可在Windows 和Xbox360上运行的XNA Math。XNA Math是一个矢量库,包含FPU矢量接口(为向下兼容)和一个SIMD接口。

回到2004年晚些时候,尽管我那个时候没有访问XNA Math Library(如果那个时候它存在的话),我现在还是很高兴的发现XNA Math所使用的接口正是我5年前在VMath上用过的。唯一的不同点就是目标平台,在我的案例中是Windows,PS3,PSP,和PS2。

XNA Math基于这里所描述的SIMD矢量库的关键性能而设计。它以数值为返回结果,矢量数据声明纯粹,支持重载操作和程序调用,内联所有矢量函数,提供数据访问器等。

当两名独立开发者得到同样的结果,那么你已经很接近于“尽可能快速”了。因此,当你面对编写矢量库的任务时,跨平台SIMD矢量库最好的赌注就是跟随XNA Math或VMath的步伐。

想到这些,你会问,XNA Math会更快速吗?就接入SIMD指令而言,我不这么认为。尽管如此,拥有最快速接口之美就在于留下的纯粹是函数的执行。

如果用同样的接口得到函数的更快速版本,只需要插入新代码即可完成。但如果你在矢量库上工作,但库却没能提供带有SIMD指令的好的接口,即使有更好的实现也还是会将你落在后面。

XNA Math vs. VMath,重量级对抗!

假设你从事高级代码,要比XNA Math更快是非常困难的。剩下的备选方案是:优化它的函数或在精心修正过的库中查找漏洞,如果它们存在的话。但是正像之前提到的,如果你在使用XNA Math,却道怎样充分使用它的全部潜能,同样能导致代码膨胀以及运算速度缓慢。

为尝试这一挑战,我探索某些算法,能够识别出使用VMath和XNA Math编程的不同之处。我清楚仅仅涉及普通计算的算法会以相同速度运行。因此,必须使某种复杂的数学算法。

基于这一想法,我最终选择了将3频均衡器的DSP代码移植到音频数据和布料仿真器上。我在本文的参考资料中提供了样本源代码链接以及令人尊敬的作者名单。

我尽可能的尝试保持源代码完整无缺,这样就感觉像是一个端口而不是不同的执行。3频均衡器代码很简单,但是我必须为特别为布料仿真额外加入几行。

在执行过程中,我还要在样本代码中加入通用矢量等级,这里称作VClass,它没有遵循VMath或XNA Math的关键特性。它是标准矢量等级,数据被密封在等级中且不提供程序接口。我同样在VClass中接入代码,只是为了性能比较。

三频均衡器案例分析

对于均衡器算法,我承认耍了点小手腕。相比于XNA Math,我没什么能在VMath执行上可做的。实际上我所做的是使用XNA Math和重载操作符接入均衡器代码。然后对于VMath我同样这么做但使用了程序调用,因为我知道重载操作符会引起代码膨胀。

尽管如此,我是有目的的作弊,它再一次演示了,甚至当你在最快的矢量库上工作时,如果不注意编译器生成代码的方式,同样会落后。

我同时准备了SIMD友好数据。音频频道相互穿插从而让载入和存储达到最小化。尽管如此,为演奏频道我必须重新安排数据,让数据以4个流线PCM音频数据阵列的形式发送至XAudio2。那引起了大量的载入和存储。由于这个原因,我还将执行运算的FPU版本直接纳入作为音频样本的平面阵列数据的另外一个副本中。FPU版本的唯一优势在于不会重新组织数据阵列。

点击这里访问VMath 和XNA Math生成的代码 – 参照Table 4。

结果同我在“重载操作符 vs.程序接口”中所讨论的非常相似(Table 2)。下面的图示展示了带有运行时间统计的demo截屏。

图1 三频均衡器

为清晰起见,下面的表格重复了最终统计。

用VMath作为参考,只是简单使用不同的接口调用,VMath平均速度要比XNA Math快大约16%。甚至在重组要传送到XAudio2的音频数据的情况下伴随大量的载入和存储时,它还能够比FPU版本快3倍。这确实令人惊叹。

请注意。由于运行所产生的大量代码膨胀,VClass仅仅比FPU版本快5%。

布料仿真案例分析

这一仿真算法包含两个主要的循环,大部分运算都在这里发生。一个是“Verlet”积分器,如下所示:

void Cloth::Verlet()
{

   XMVECTOR d1 = XMVectorReplicate(0.99903f);
   XMVECTOR d2 = XMVectorReplicate(0.99899f);

   for(int i=0; i
   {

    XMVECTOR& x = m_x[i];
    XMVECTOR temp = x;
    XMVECTOR& oldx = m_oldx[i];
    XMVECTOR& a = m_a[i];
    x += (d1*x)-(d2*oldx)+a*fTimeStep*fTimeStep;
    oldx = temp;

   }

}

第二个是约束解算器,它使用Gauss-Seidel迭代循环查找全部约束解决方案,如下所示:

void Cloth::SatisfyConstraints()
{

    XMVECTOR half = XMVectorReplicate(0.5f);

    for(int j=0; j
    {

       m_x[0] = hook[0];
       m_x[cClothWidth-1] = hook[1];

       for(int i=0; i
       {

          XMVECTOR& x1 = m_x[i];

          for(int cc=0; cc
          {

             int i2 = cnstr[i].cIndex[cc];

             XMVECTOR& x2 = m_x[i2];
             XMVECTOR delta = x2-x1;
             XMVECTOR deltalength = XMVectorSqrt(XMVector4Dot(delta,delta));
             XMVECTOR diff = (deltalength-restlength)/deltalength;
             x1 += delta*half*diff;
             x2 -= delta*half*diff;

            }

         }

     }

}

通过查看代码,将重载操作符更改为程序调用不会起到什么作用。数学表达式很简单,优化器应该能够生成同样的代码。

我还将浮动投射从源代码中移出,正如“在SIMD寄存器中保留结果”中所描述的一样。现在循环非常紧密且对SIMD友好,还有什么呢?唯一要做的是查看XNA Math,看看是否还需要做其他事情。

它的结果正如所示。首先查看XMVector4Dot,它的执行如下:

 XMFINLINE XMVECTOR XMVector4Dot(FXMVECTOR V1, FXMVECTOR V2)
  {
   XMVECTOR vTemp2 = V2;
   XMVECTOR vTemp = _mm_mul_ps(V1,vTemp2);
   vTemp2 = _mm_shuffle_ps(vTemp2,vTemp,_MM_SHUFFLE(1,0,0,0));
   vTemp2 = _mm_add_ps(vTemp2,vTemp);
   vTemp = _mm_shuffle_ps(vTemp,vTemp2,_MM_SHUFFLE(0,3,0,0));
   vTemp = _mm_add_ps(vTemp,vTemp2);
   return _mm_shuffle_ps(vTemp,vTemp,_MM_SHUFFLE(2,2,2,2));

   }

这一执行过程由1个乘法算法,3个混淆算法和2个加法算法组成。

因此我又写了另外一个SSE2 4D Dot,这产生同样的结果但少一个混淆指令,如下:

inline Vec4 Dot(Vec4 va, Vec4 vb)
  {

   Vec4 t0 = _mm_mul_ps(va, vb);
   Vec4 t1 = _mm_shuffle_ps(t0, t0, _MM_SHUFFLE(1,0,3,2));
   Vec4 t2 = _mm_add_ps(t0, t1);
   Vec4 t3 = _mm_shuffle_ps(t2, t2, _MM_SHUFFLE(2,3,0,1));
   Vec4 dot = _mm_add_ps(t3, t2);
   return (dot);

   }

很不幸,让我惊讶的是,我的新4D Dot并未带来多少不同,跟之前差不多,因为有了更多的指令相关性。

随着我更进一步查看,我发现XNA Math上令人费解的一点。它调用倒数函数来执行除法算法。实际上,函数由重载操作符中的多项调用链接,如下所示:

XMFINLINE XMVECTOR XMVectorReciprocal(FXMVECTOR V)
  {

  return _mm_div_ps(g_XMOne,V);

  }

  XMFINLINE XMVECTOR operator/ (FXMVECTOR V1,FXMVECTOR V2)
  {

   XMVECTOR InvV = XMVectorReciprocal(V2);
   return XMVectorMultiply(V1, InvV);

  }
唯一的问题在于载入“g_XMOne”然后调用内联来执行除法算法。我不太清楚Microsoft为什么会这样执行,但是它最好还是直接简单调用除法算法。因此对于VMath,我这样执行,不要额外的载入,如:

inline Vec4 VDiv(Vec4 va, Vec4 vb)
{

return(_mm_div_ps(va, vb));

};

现在让我们看看使用两个库“Verlet”和“Constraint Solver”的汇编,在VMath中的实现了小的优化。

点击这里下载表格文件 – 参照Table 5和Table 6。

尽管,我在写入“Verlet”函数时,在VMath上使用了程序调用,而XNA Math上使用了重载操作符,没产生什么不同。我估计是数学表达式没有那么复杂,不足以引起代码膨胀。尽管如此,“Contraint Solver”由于更快速的除法运算得到了更小的指令。我在表格中突出了“g_XMOne”的额外载入,这可能是引起额外movaps指令的原因。下面的截屏显示了结果。

图 2 布料仿真

从技术上讲,这是VMath的一次胜利,由于一项指令;尽管如此,统计并不足以显示速度的极大提高 – 大概只有1%。

实际上,结果很相近,它们由于其他因素浮动,比如OS执行某些后台任务。因为这样的结果,结论就是VMath和XNA Math势均力敌。

虽然如此,两个库都能比VClass快大约50%。这还是令人惊叹,实际上主要的不同在于库接入相同内联的方式。

Intel 编译器,“黑魔法”

至今,Microsoft编译器捆绑于Visual Studio执行所有的测试。现在是时候转动齿轮在Intel编译器中编写相同的代码了。

我之前听说过Intel编译器,它以最快速而闻名。当我用Intel编写样本代码时,我确实被震撼了。实际上,Intel制作出了杰出的编译器,不仅让我更快速的得到结果,总而言之,还有很多未曾料到的方面。

让我们看看3频均衡器的编译后的汇编代码,与使用Microsoft 编译器编译的代码比较。

点击这里下载表格文件 – 参照Table 7。

Intel编译器能够使代码压缩20%左右。“Verlet Integrator”和“Constraint Solver”的结果分别为大约7%和6%。

但是“黑魔法”真正在于使用普通VClass编写的代码甚至也更小了。我还查出Intel编译器使用重载操作符所编写的代码要比使用程序调用好。另外一个有趣的结果在于等级中的数据封装只会造成非常少的代码膨胀,实际上在布料仿真的案例中没有任何膨胀。这让所有的demo统计数据或多或少的降低了。

下面是使用Intel编译器的布料demo截屏。

图3 使用Intel编译器的布料Demo

图4 使用Intel 编译器的三频均衡器

最终统计

现在所有的数据整体比较:

结论

尽管Intel编译器解决了等级数据和重载操作符生成的代码膨胀问题,我仍然不会在跨平台SIMD库中支持这一方法。这是因为我在六个不同的编译器GCC和SN系统上为PS2,PSP和PS3进行了类似的测试 – 它们的结果都比使用Microsoft编译器要差,意味着它们甚至会产生更多的代码膨胀。因此,除非你特别为只在Windows平台上运行的库编写代码,你最好的赌注还是遵循本文中关于SIMD矢量库的关键要素。

代码样本:
本文中样本代码和全部比较表格可从下面链接中下载:
http://www.guitarv.com/ComputerScience.aspx?page=articles

参考资料
[1] Microsoft, XNA Math Library Reference
[2] Intel, SHUFPS -- Shuffle Single-Precision Floating-Point Values
[3] Jakobsen, Thomas, Advanced Character Physics
[4] C., Neil, 3 Band Equaliser
[5] Wikipedia, Taylor Series

法律声明:IIEEG专稿,作品受《著作权法》保护,转载请注明出处