探讨SSE指令

   比较一下3DNow和浮点指令的性能差异,可以看出,3DNow指令集在运算速度上要远远
超过浮点指令。那么,SSE性能如何呢,它是否有能力同3DNow一拚高低?我想,很难说
那一个更好一些,因为它们都有着很高的性能。不过单从指令集上看,SSE还是要略胜一
筹的。毕竟是新增了8个128位的寄存器,而且指令的功能也要强大一些。3DNow使用MMX
指令的寄存器,可以借助MMX指令的强大功能,不必设计太多的新功能,不需要操作系
统提供专门的支持,而且口碑颇佳!从流水线的设计上看,双方也是各有所长。Pentiu
m III每个时钟周期最多可以解码3条指令,执行5个微操作,它把一些重要的微操作(例
如乘法和加法)分派到不同的端口去执行。 3DNow则是在两条流水线间共享3DNow的执行
单元和部分MMX的执行单元,所有的3DNow指令都是有两个时钟周期的延迟,并且完全被
流水线化。
最近,AMD的处理器似乎有了很大的变化,我看过一些有关它的64位处理器的资料,也是
添加了一堆寄存器,不过我没有仔细看,毕竟没有哪个缘份一睹芳颜。intel公司当然也
没有闲着,它的64位处理器则不能用“变化”二字来形容了,那简直可以说是脱胎换骨
,全新的指令,全新的体系!不过,咱们老百姓恐怕不会在短时间内用上这种处理器,
拥有三百多个寄存器的CPU肯定会处于我无法接受的价位。这样也还是有一个好处的,那
就是SSE指令集在短时间内不会过时,毕竟,转移到64位阵营还是要经历一个漫长的过程
。而且,在IA-64体系中专门提供了三条指令在32位代码和64位代码之间进行跳转,也就
是说,你可以在程序中任意使用两种代码。
所以,如果你想针对intel系列的处理器进行优化的话,就努力学好SSE吧,在相当长的
时间里都会大有用处的。
本文不会详细介绍每一个SSE指令,只是讨论一些重要的,常用的,能够对性能产生较大
影响的指令。如果你想更全面的了解SSE,请参阅 SSE指令简明参考。你可以从中查到每
条SSE指令的功能。
通过程序来讨论指令的用法是最好的办法。以前写过的两篇文章,一篇是关于浮点指令
优化的,一篇是关于3DNow指令优化的,这两篇文章都是使用了矩阵相乘作为例子程序,
因此本文还是以矩阵相乘为例,看一看SSE究竟有什么优势!它与单纯使用浮点指令的程
序相比效率能提高多少!
准备工作
选择合适的编译器
目前我还没有发现哪个编译器能够对SSE提供内联支持,据intel声称,它的C++ 编译器
可以做到,但是,恐怕没有几个人用过。建议大家使用MASM6.14,它支持SSE和3DNow。
大家可以从本站下载MASM6.14。高级语言的编译器也是要有的,我使用的是VC6.0。因为
VC在浮点程序方面比 C++ Builder优化的更好,这样就可以与汇编的优化结果进行比较
了。
设置编译器
在汇编程序里,应该加入伪指令来指示编译器支持何种指令集。“.xmm”表示要求编译
器支持SSE指令集。“.k3d”则是要求编译器支持3DNow指令集。
VC提供了一些支持,可以自动的编译汇编文件,你可以按照以下步骤进行:
在菜单中选择“Project | Setting”
选中指定的汇编文件(单击即可)
选中Custom Build页
在Commands中输入:
如果是DEBUG模式,则输入:
path e:\masm32\bin
ml /c /coff /Zi /FoDEBUG\$(InputName).obj $(InputPath)
如果是RELEASE模式,则输入:
path e:\masm32\bin
ml /c /coff /FoRELEASE\$(InputName).obj $(InputPath)
在Outputs中输入:
如果是DEBUG模式,则输入:
DEBUG\$(InputName).obj
如果是RELEASE模式,则输入:
RELEASE\$(InputName).obj
如果你的没有把masm安装在E盘,则要作相应的修改。
学习指令
你首先应该对SSE指令有所了解才能更好的阅读本文。SSE指令集是一个比较新的体系,
如果你没有学过MMX或者3DNow,还是有一定困难的。在全面优化Pentium III一文中对P
entium III 的体系有比较全面的阐述。
优化方针
针对SSE优化还是比较困难的,下面提出一些方法,以供参考:
摆脱高级语言的桎梏,根据硬件的特点,指令的功能,量体裁衣地设计算法。要知道,
汇编语言的算法与高级语言是有很大的不同的,只有重新设计的算法才有可能发挥出处
理器的最大潜力。
熟练使用一些常用的指令,知道它们的延迟和吞吐量是多少。本文的例子中所用的一些
重要的指令有:ADDPS,MULPS,SHUFPS,MOVSS,MOVAPS。关于它们的执行单元的相关数
据可以查阅处理器执行单元列表。
充分利用新增加的八个寄存器,减小内存的压力;设计并行算法,减轻流水线的延迟。

综合考虑解码器,流水线,执行端口等多方面因素,尽量增强处理器的并行处理的能力

举例详解
下面的程序是一个矩阵相乘的函数。在三维图形空间变换中,要用到4乘4的浮点矩阵,
而矩阵相乘的运算是很常用的。下面的函数的参数都是4乘4的浮点矩阵。写成这种形式
是为了保持比较强的伸缩性。
void MatMul_cpp(float *dest, float *m1, float *m2)
{
    for(int i = 0; i < 4; i ++)
    {
        for(int j = 0; j < 4; j ++)
        {
            dest[i*4+j] =
                m1[i*4+0]*m2[0*4+j] +
                m1[i*4+1]*m2[1*4+j] +
                m1[i*4+2]*m2[2*4+j] +
                m1[i*4+3]*m2[3*4+j] ;
        }
    }
}
VC的优化能力是很强的,象上面这样的比较常规的算法,你很难做出比它快得多的代码
。不过使用SSE以后就不一样了。下面是一个汇编函数,使用SSE 指令进行计算。注意,
这个函数只能运行于32位的环境中。“.xmm”指示编译器使用SSE指令集进行编译。
函数的C语言原型是这样的:
extern "C"
{
void __stdcall MatMul_xmm(float *dest, float *m1, float *m2);
}
对于一些不太常用汇编语言编程的朋友来说,下面的程序可能比较难于理解。我将对一
些常识性的东西做一下简单介绍。
在C语言中,代码段都是以“_TEXT”作为段名的。“use32”告诉编译器将代码编译为3
2位。
有些人看到“_MatMul_xmm@12”这个函数名以后可能会产生疑问。其实这只是遵循了VC
所采用的命名规范。在VC中,所有标志为“__stdcall” 调用的,采用“C”链接的函数
都要加下划线作为前缀,并且加上“@N”作为后缀,其中,“N”为参数的字节数。注意
,上面的函数是采用“C”链接的,如果是“C++”链接,命名规范就太复杂了。如果你
使用的是C++ Builder,命名规范就十分简单了,照搬函数名就行了。不同的调用规范将
采用不同的命名方法,即使对相同的调用规范,不同的编译器也不一定兼容。有一种调
用格式是每一个C++编译器都支持并且兼容的,那就是“__cdecl”。
各种调用格式所采用的堆栈操作也不太一样。使用“__stdcall”时,参数从右向左依次
入栈,参数的弹出需要函数自己来处理。这种做法和“__cdecl” 调用方式不太一样,
“__cdecl”的参数弹出需要调用者来处理。现在很流行的一种调用格式是“__fastcal
l”,也就是寄存器调用。这种调用方式通过寄存器“EAX”,“ECX”,“EDX”传递参
数,不过很可惜,这种调用也不是在各个编译器中兼容的。Inprise在C++ Builder中提
供了一个关键字“__msfastcall” 用来和微软兼容,如果你采用这种调用规范就可以在
多个编译器中正常调用了。不过还有一件事让人很受打击,VC没有对“__fastcall”提
供很好的优化,使用这种调用反而会降低效率。
并不是所有的寄存器都能够随意使用的,多数32位寄存器都要先保存的。你可以不必保
存的32位寄存器只有三个----“EAX”,“ECX”,“EDX”,其它的就只好“PUSH”,“
POP”了。另外,浮点堆栈寄存器是不必保存的;MMX 寄存器和浮点堆栈共享,也是不必
保存的;XMM寄存器不必保存。
很多SSE指令都会加上“ps”或“ss”后缀。“ps”表示“Packed Single-FP”,即打包
的浮点数,带这种后缀的指令通常是一次性对四个数进行操作的。“ss” 表示“Scala
r Single-FP”,带这种后缀的指令通常是对最低位的单精度数进行操作的。
下面这个汇编函数是一行一行计算的,咱们先用类似于C的语法简述一下第一行的计算过
程:
    xmm0 = m1[0],m1[0],m1[0],m1[0];
    xmm1 = m1[1],m1[1],m1[1],m1[1];
    xmm2 = m1[2],m1[2],m1[2],m1[2];
    xmm3 = m1[3],m1[3],m1[3],m1[3];
    xmm4 = m2[0],m2[1],m2[2],m2[3];
    xmm5 = m2[4],m2[5],m2[6],m2[7];
    xmm6 = m2[8],m2[9],m2[10],m2[11];
    xmm7 = m2[12],m2[13],m2[14],m2[15];
    xmm0 *= xmm4;
    xmm1 *= xmm5;
    xmm2 *= xmm6;
    xmm3 *= xmm7;
    xmm1 += xmm0;
    xmm2 += xmm1;
    xmm3 += xmm2;
    dst[0],dst[1],dst[2],dst[3] = xmm3;
上面的代码可读性还是比较好的,因为只进行了第一行的计算。实际运算中,为了增强
并行度,为了减小指令的延迟,实际上是两行并行计算的。而且,运算过程并不是象算
法描述那样写得那么有规律。
        .686p
        .xmm
        .model flat
_TEXT segment public use32 'CODE'
public _MatMul_xmm@12
_MatMul_xmm@12 proc
;;parameters
retaddress = 0
dst = retaddress+4
m1 = dst+4
m2 = m1+4
        mov          edx,     [esp+m1]
        mov          ecx,     [esp+m2]
        mov          eax,     [esp+dst]
        movss        xmm0,    [edx+16*0+4*0]   ;读入第一行的数据
        movaps       xmm4,    [ecx+16*0]
        movss        xmm1,    [edx+16*0+4*1]
        shufps       xmm0,    xmm0,    00h
        movaps       xmm5,    [ecx+16*1]
        movss        xmm2,    [edx+16*0+4*2]
        shufps       xmm1,    xmm1,    00h
        mulps        xmm0,    xmm4
        movaps       xmm6,    [ecx+16*2]
        mulps        xmm1,    xmm5
        movss        xmm3,    [edx+16*0+4*3]
        shufps       xmm2,    xmm2,    00h
        movaps       xmm7,    [ecx+16*3]
        shufps       xmm3,    xmm3,    00h
        mulps        xmm2,    xmm6
        addps        xmm1,    xmm0
        movss        xmm0,    [edx+16*1+4*0]   ;读入第二行的数据
        mulps        xmm3,    xmm7
        shufps       xmm0,    xmm0,    00h
        addps        xmm2,    xmm1
        movss        xmm1,    [edx+16*1+4*1]
        mulps        xmm0,    xmm4
        shufps       xmm1,    xmm1,    00h
        addps        xmm3,    xmm2
        movss        xmm2,    [edx+16*1+4*2]
        mulps        xmm1,    xmm5
        shufps       xmm2,    xmm2,    00h
        movaps       [eax+16*0],    xmm3
        movss        xmm3,    [edx+16*1+4*3]
        mulps        xmm2,    xmm6
        shufps       xmm3,    xmm3,    00h
        addps        xmm1,    xmm0
        movss        xmm0,    [edx+16*2+4*0]   ;读入第三行的数据
        mulps        xmm3,    xmm7
        shufps       xmm0,    xmm0,    00h
        addps        xmm2,    xmm1
        movss        xmm1,    [edx+16*2+4*1]
        mulps        xmm0,    xmm4
        shufps       xmm1,    xmm1,    00h
        addps        xmm3,    xmm2
        movss        xmm2,    [edx+16*2+4*2]
        mulps        xmm1,    xmm5
        shufps       xmm2,    xmm2,    00h
        movaps       [eax+16*1],    xmm3
        movss        xmm3,    [edx+16*2+4*3]
        mulps        xmm2,    xmm6
        shufps       xmm3,    xmm3,    00h
        addps        xmm1,    xmm0
        movss        xmm0,    [edx+16*3+4*0]   ;读入第四行的数据
        mulps        xmm3,    xmm7
        shufps       xmm0,    xmm0,    00h
        addps        xmm2,    xmm1
        movss        xmm1,    [edx+16*3+4*1]
        mulps        xmm0,    xmm4
        shufps       xmm1,    xmm1,    00h
        addps        xmm3,    xmm2
        movss        xmm2,    [edx+16*3+4*2]
        mulps        xmm1,    xmm5
        shufps       xmm2,    xmm2,    00h
        movaps       [eax+16*2],    xmm3
        movss        xmm3,    [edx+16*3+4*3]
        mulps        xmm2,    xmm6
        shufps       xmm3,    xmm3,    00h
        addps        xmm1,    xmm0
        mulps        xmm3,    xmm7
        addps        xmm2,    xmm1
        addps        xmm3,    xmm2
        movaps       [eax+16*3],    xmm3
        ret          12
_MatMul_xmm@12 endp
_TEXT ends
        end
上面的代码几乎没有加什么注释,只是在读入每行第一个数据时作了标记。因为,SSE
的指令可读性还是比较好的,除了要加上一些后缀以外,它们和普通的整数运算指令很
相似。
一些关键性的指令有必要解释一下:
movss和movaps:
movss是将一个单精度数传输到xmm寄存器的低32位,而movaps则是一次性向寄存器中写
入四个单精度数。也许有些人会认为movaps效率更高一些,其实并不一定是这样。从处
理器执行单元列表中,你可以查到这些指令的延迟。如果都是从寄存器中读取数据,两
个指令的延迟是一样的。如果是从内存中读取数据,movss只有一个时钟周期的延迟,而
movaps却有四个时钟周期的延迟。
上面的汇编代码混合使用了这两条指令。那么,应该在什么时候选择哪一条指令呢?这
要看你对数据的需求了。如果你希望能够尽快地使用数据,就应当首选movss,因为它几
乎能够让你立即使用数据。如果你并不急于使用某些数据,只是想先把它读入寄存器,
那么毫无疑问movaps是你的最佳选择。 movaps使用端口2读取数据,如果在它执行完毕
之前你不去使用它的数据,这条指令的实际延迟就只有一个时钟周期。考虑到处理器能
够在5个端口并行执行微操作,那么这条指令的延迟可能还不到一个时钟周期。
从上面的代码中,你可以看到,每一条movaps指令和它的相关指令之间都至少插入了四
条指令,这样可以基本上避免延迟。
虽然movss指令只有一个时钟周期的延迟,但是这也并不意味着你可以把这条指令和它的
相关指令写在一起,因为这有可能会影响处理器的并行度。虽然 Pentium III有着强大
的乱序执行的能力,可是这毕竟是不太保险的,还是自己动手,丰衣足食吧。
SHUFPS
这是一条可以将操作数打乱顺序的指令。这一条指令有很多种用法,它根据常量参数的
不同执行不同的功能。本文中只使用了一种用法:
    shufps      xmmreg,  xmmreg,  00h
这条指令的作用是把某个寄存器的最低位的单精度数传输到该寄存器的其它三个部分。

在某些时候,shufps和unpcklps(或unpckhps)可以执行相同的功能。这时,推荐使用
shufps,因为这条指令有两个时钟周期的延迟。unpcklps和unpckhps 都是有三个时钟周
期的延迟。
ADDPS和MULPS
这两条指令是很重要的计算指令,有必要弄清楚它们的执行情况。
addps有4个时钟周期的延迟,mulps有5个时钟周期的延迟,我们应该根据这些数据考虑
清楚,究竟在它们的相关代码中插入多少条指令。
这两条指令都是每两个时钟周期才允许执行一次,如果你把相同的两条这样的指令写在
一起,第二条指令就有可能被延误一个时钟周期。应该插入一些其它指令来掩盖这段延
迟。
mulps在端口0执行,addps在端口1执行,如果你的代码把乘法和加法指令写在一起,它
们会被分配到不同的端口并行执行,这比只有一条流水线的FPU要高效的多。
优化思路:
下面将解释一下上面代码的优化思路。
打乱指令
在算法描述中,各条操作写得非常有规律,但是在真正编程的时候却不是这样。为了保
证流水线的流畅运作,就要把相关的代码分离开来,尽量避免或减轻指令的延迟。这样
就要打乱指令,在两条相关指令之间插入一些其它的指令,同时也要考虑指令之间是否
存在资源的竞争。
并行算法
多个数据并行计算是解决指令延迟问题的有效方法。我们不能傻傻地等待一条指令的计
算结果,而是要在等待的过程中进行其它数据的计算。在上面程序的算法中,每当寄存
器有了空闲,就马上从内存中读入新的数据,尽量保证有两组数据在寄存器中并行计算

内存访问
访问内存的指令不要过于密集,这一方面可以减轻对带宽的需求,另一方面也会提高解
码的效率。访问内存的指令至少有两个微操作,这样的指令只能每个时钟周期解码一条
,而Pentium III的解码极限可是每个时钟周期三条指令啊。为了提高处理器的并行度,
有必要在内存访问指令上下功夫。在我的代码中,内存访问指令的排布还是比较有规律
的,差不多是每隔三条指令访问一次内存。当然,在计算第一行数据时,因为要读取一
些初始化的数据,内存访问比后面的代码要频繁。
灵活性
矩阵的运算是一行一行进行的,每一行数据只被读取一次。这就意味着,我们可以把运
算结果保存在任何一个矩阵里,即保存在m1或者m2中,因为这两个矩阵中的数据已经不
会被再次读取了,也就不用担心破坏数据。这种灵活性可以是我们轻而易举地完成矩阵
左乘或者右乘的代码。在Direct3D中,空间变换是按照如下方式进行计算的:
在进行多次变换时,只要在原有的矩阵上右乘一个变换矩阵就可以了。下面的代码就是
这样的一个例子:
MatMul_xmm(m1, m1, m2);
如果使用高级语言来实现恐怕就要麻烦一些,你要使用一些中间变量,程序如下所示:

void MatMul_Right_cpp(float *dest, float *m)
{
    float tmp[16];
    MatMul_cpp(tmp, dest, m)
    memcpy(dest, tmp, 16*4);
}
 

Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=1270109

posted on 2007-09-04 22:35  cutepig  阅读(5603)  评论(1编辑  收藏  举报

导航