毫无疑问,数学库是图形程序的基石,是图形程序运行效率的关键之一。一个优秀的数学库可以让图形程序运行得更流畅,甚至要快上几十倍上百倍。有时候替换一条除法运算会带来成倍的效率增长,比如用乘以 1/op 替换 vector 里的 operator /。当然,更高级的优化是使用 SIMD 优化海量运算,这就是本文的中心——SSE/SSE2 优化。
在描述 SSE/SSE2 优化前,我先介绍一般的 vector/matrix 库构造。当然,在 OpenEXR 里已经有一个非常优秀的 Imath 实现了,数学库的实现细节可以参照它。
在图形程序里我们经常会遇到向量运算,这是标准C++编译器所不能直接支持的,如三维空间向量。传统的C图形程序会使用“数组+宏”的实现方式:
typedef float vector[3];
到了C++时代,一般会封装成:
class Vector
{
private:
float x , y , z;
};
然后加入通常的各种method,如
const float& X( void ) const {
return x;
} 标准的向量算法如内积、外积、单位化、长度、运算等等,都可以封装为成员函数。
Vector operator + ( const Vector& a , const float & b ) {
return Vector( a.x + b , a.y + b , a.z + b );
}
类似的数学库可以在Aqsis等一些开源的图形程序里找到。不过这些结构并不适合接下来我们要讨论的SSE/SSE2优化。
SSE – Streaming SIMD Extension,是Intel从PIII开始加入的一种x86扩展指令集。在SSE以前,x86的浮点运算都是以栈式FPU完成的,有一定x86汇编经验的人应该不会对那些复杂的fld、fst指令陌生吧。而SSE一方面让浮点运算可以像整数运算的模式、如 add eax , ebx 那样通过直接访问寄存器完成,绕开了讨厌的栈,另一方面引入了SIMD这个概念。SIMD – Single Instruction Multiply Data,顾名思义,它可以同时让一条指令在多个数据上执行,这种体系结构在一度在大型机上非常流行,需要经常进行海量运算的大型机器通常会通过一个数学SIMD虚拟机加快处理速度,比如同时让一组数据执行一个变换,数据的规模有上百万之巨,而SIMD则可以优化数据的存储与运算,减免某些切换Context的开销。
在硬件层面上面支持SIMD,某程度是因游戏需要的驱使,因为越来越多的3D游戏涉及大量的向量操作,一般的浮点运算优化已经不能再适应这种并行运算的需要了,而直接从指令上支持SIMD操作则可以进一步简化向量运算的优化,提高指令执行效率。像 addps 这样的SSE指令,可以并行执行四个32位浮点数的加法运算,而延迟只有4 cycle;相比之下,原来的fadd指令光执行一个32位单精度浮点数加法的延迟已经达到了3 cycle了,还没计算fst等存储指令的延迟。(具体见后面的指令执行单元表)
显然,SSE能给图形程序带来极大的优化,其提高远胜于基于整数的MMX与双单元单精度浮点数的3DNow!。但SSE对数据组织的要求是苛刻的,若要发挥SSE的最大威力,我们还需要进行对齐向量数据,把向量对齐到16字节。如果我们正在使用一般的三分量向量,那么就意味着有要浪费四分之一的存储空间来换取速度。当然,这4字节还可以有很多用途,只是你必须处理得非常小心,因为任何运算都将同时应用到四个分量上。
要使用SSE,必须先确认你的编译器是否支持新的指令集。VC6 sp6、VC.net、.net 2003、ICL、GCC 、nasm 都支持SSE指令集。我推荐使用ICL,它的优化做得最棒,生成的指令最紧凑、效率最高。使用SSE有两种途径,一是直接编写汇编代码,但难度较大,需要有一定的汇编经验;二是使用SSE intrinsic,一种直接在C/C++里使用SSE指令的伪函数调用。在图形运算的核心环节上、如raytrace核心,我建议使用汇编,这样才能极大地体现出SSE的优势、与x86指令混合使用,并充分使用它的并行性。而在大多数场合下则推荐使用intrinsic,它的可读性高,而且编译器会在最后把函数调用替换成SSE指令,这样既不需要写内嵌汇编代码,又可以保证代码的执行效率。