SIMD操作从根本上与寄存器宽度相关。
1976年的Cray-1以“向量处理器”(vector processor)著称。它有八个64元素向量寄存器,每个寄存器保存64位值。
与我们看到的SIMD类似,Cray-1不需要64条单独的ADD指令。那么,既然这显然是SIMD式的,为什么我们不直接称它为SIMD呢?因为存在架构上的区别。SIMD在固定宽度(fixed-width)的寄存器上操作,而Cray-1的向量寄存器更像是一个“序列容器”。
向量处理器通常以存在SET VECTOR SIZE指令为特征。这个向量功能单元可以在多个周期内处理元素。
Cray-1有一个**VL(向量长度)**寄存器,以及能够设置该长度的指令。向量功能单元随后在多个周期内处理那么多元素。数组中其余多余的元素根本没有被使用/循环。
Cray-1的VL寄存器是7位,尽管该架构实际上只使用到64(2⁶)以内的值,因为Cray-1向量寄存器最多包含64个元素。另外,一个值得一提的小细节是:Cray-1将VL=0解释为64,即处理向量寄存器中的所有64个元素。
正如我们之前看到的,一个巧妙的设计决定是英特尔没有添加全新的寄存器文件。相反,MMX复用了浮点栈寄存器。
MM0 MM1 MM2 MM3 MM4 MM5 MM6 MM7 每个MMX寄存器为64位宽。在内部,MMX寄存器是x87浮点寄存器的别名。这些底层浮点寄存器为80位长,但MMX只访问其低64位。
这一决定节省了硅片面积,但也造成了一个重要的限制:
别担心……我们很快就会回来详细讨论这些限制。
单个MMX寄存器可以容纳不同的布局:
根据所使用的指令,同一个寄存器可以表示这些布局中的任何一种。
假设我们要将8个像素相加(假设没有编译器优化):
for (int i = 0; i < n; i++) { dst[i] = src1[i] + src2[i]; }
MMX版本看起来像这样:
movq mm0, [src1] ; 加载8个字节 movq mm1, [src2] ; 加载8个字节 paddb mm0, mm1 ; 同时执行8个字节加法 movq [dst], mm0 ; 存储结果 一条指令替代了八条独立的加法指令!
MMX另一个真正出色的特性是能够执行“饱和”(saturating)算术。
当我们将两个值加在一起时,普通整数算术会回绕(wrap around)。
250 + 20 // = 270,回绕后变成14
上面的加法超过了255,通常回绕到14。
整数算术的“回绕”特性处理起来可能很麻烦,尤其是在处理图形时。如果我们要将像素值相加,我们希望最大亮度保持为最大亮度!
MMX针对此类情况提供了饱和功能。指令很简单:
paddusb mm0, mm1 这里的us代表无符号饱和(unsigned saturation),将最大结果值限制为255。
250 + 20 // = 255
同样:
10 - 40 // = 0
这使得亮度调整和混合操作简单得多。
人们很自然会想到MMX的强大功能应用于图形和其他多媒体任务。例如,假设我们要创建一个掩码,其中每个大于另一个的值变为白色。
pcmpgtb mm0, mm1
每个字节变为0xFF或0x00。
这些掩码在实现阈值滤镜、精灵透明或碰撞逻辑时非常有用。
如果你上过我们的课程,你就会知道我们经常使用位移(bitshifting)来乘除2。考虑到在旧CPU中乘法(尤其是除法)指令非常昂贵,向左和向右移位是乘以或除以2的幂的更快方式。
MMX提供了一种同时移位打包值的方法:
psllw mm0, 1 ; 将每个16位值乘以2 psrlw mm0, 1 ; 除以2 同样,所有打包值同时被移位!
需要将打包值乘以2以外的数?没问题。MMX也支持打包乘法。
pmullw mm0, mm1
每对16位整数独立相乘。这在以下场景中变得有用:
还记得MMX的一大特点是它“接管”了x87的浮点寄存器吗?这意味着MMX没有浮点SIMD,因此MMX程序严重依赖定点算术。面向英特尔x86 CPU的真正浮点SIMD直到几年后随奔腾III系列推出的SSE(流式SIMD扩展)才出现。
假设你负责编写一个用于提亮图像的小例程。在这幅图像中,每个像素占一个字节。没有MMX时,我们的代码可能看起来像这样:
int intensity = 20; for (int i = 0; i < num_pixels; i++) { pixels[i] += intensity; if (pixels[i] > 255) { pixels[i] = 255; } } 使用MMX,对像素块进行迭代的代码看起来像这样:
`section .data ; 我们在内存中放入8个值为20(十六进制0x14)的字节 intensity: times 8 db 20 ; 14 14 14 14 14 14 14 14
section .text ; esi = 指向像素的指针 ; ecx = 8像素块的数量
loop: movq mm0, [esi] ; 加载8个像素 movq mm1, [intensity] ; 加载八个20 paddusb mm0, mm1 ; 饱和加法 movq [esi], mm0 ; 存储8个像素
add esi, 8 ; 处理下一个8像素 dec ecx jnz loop
emms ; 退出MMX状态 ` 使用MMX,一次处理八个像素。这正是图形库所钟爱的那种优化。
正如我们刚刚了解的,MMX“借用”了原始x86架构中的浮点寄存器。
这意味着当你不再使用MMX时,必须告诉CPU你已用完。
emms
这会清除MMX状态,使x87浮点指令能够再次正常工作。
如果我们忘记调用EMMS,程序中可能会在稍后出现奇怪的浮点错误。
相信我,每个MMX程序员迟早都会学到这一课。
奇怪的是,实际使用MMX的(游戏)并不像你想象的那么多。除了MMX本身受限之外,你还要记住,MMX发布的时间恰逢GPU和3D硬件加速卡(如S3 ViRGE、ATI 3D Rage和3dfx Voodoo)进入PC市场。因此,到MMX问世时,专用3D硬件已经开始接管那些原本可能受益于CPU端优化的工作负载。
话虽如此,在DOS晚期和Windows 95早期,对于未加速的游戏,CPU通常是瓶颈。
NovaLogic旗下Comanche 3的主要程序员Kyle Freeman经常提到,该游戏在音频和图形方面都大量使用了MMX指令。
需要或受益于软件渲染器中MMX增强功能(或需要MMX增强才能运行)的游戏包括Eraser Turnabout、POD和Extreme Assault。我还读到过一些传闻,称有一个为古墓丽影添加MMX增强(用于光照和纹理)的补丁,但我个人从未见过可靠的来源证实这个版本存在。如果你有相关信息,请告诉我!
尽管Quake和Quake II经常被用作受益于MMX技术的游戏例子,但需要指出的是,Quake著名的软件渲染器其实早于MMX。原版Quake于1996年发布,而英特尔在1997年才推出奔腾MMX。因此,Quake的高性能主要是通过寄存器分配、循环优化、查找表和手写x86等技术实现的;并非依赖MMX。
正如我们稍后将讨论的,实际使用MMX的游戏并不多。但确实使用了MMX的应用程序,会附带可选的MMX代码路径,并在CPU支持新指令时选择该路径。当然,开发人员通常也会维护非MMX版本,因为数百万用户仍在使用较老的奔腾处理器。
软件不能简单地假设MMX可用。常见的方法是使用CPUID指令。
CPUID是一条x86 CPU指令,用于向处理器询问其支持的功能并获取CPU信息。
mov eax, 1 cpuid ; 获取CPU信息 test edx, 1 ; 检查EDX的第23位 EDX寄存器的第23位表示MMX支持。如果该位被置位,程序就可以安全地执行MMX指令。否则,应回退到标量(非MMX)实现。
EDX中的其他位会告诉我们其他受支持的技术。例如:
多年后,Quake II增加了对一项名为3DNow!的技术的支持。3DNow!由AMD设计,概念上与MMX类似;它为基础x86指令集添加了SIMD指令,使用向量寄存器实现浮点SIMD操作。
3DNow!也使用64位MMX寄存器,但增加了面向浮点SIMD的指令。第一款搭载3DNow!技术的处理器是1998年的AMD K6-2。
Id Software于2001年以GPL许可证发布了Quake II 3.19的源代码。但请注意,3DNow!代码并不包含在最初的3.19源码版本中。相反,AMD/id Software分发了一个单独的、经过3DNow!优化的Quake II v3.20构建版本。当然,这些是以预编译二进制形式(没有源代码)分发的。
不。尽管一些热门游戏加入了3DNow!增强,但3DNow!从未比MMX更流行。事实上,MMX要普及得多,因为它有巨大的先发优势。英特尔于1997年1月随奔腾MMX推出了MMX,而AMD的3DNow!直到1998年才随K6-2出现。
更重要的是,英特尔在x86市场占有巨大份额,因此选择MMX意味着可能支持更大比例的PC游戏玩家。归根结底,3DNow!是AMD特有的扩展。
开发人员面临支持英特尔CPU还是AMD CPU的选择。如果你编写了针对3DNow!优化的渲染器,它只对支持该技术的AMD芯片有益。与此同时,MMX得到了英特尔以及随后AMD、Cyrix和其他厂商的支持。
最后,对AMD 3DNow!的致命一击是英特尔在1999年随奔腾III推出了SSE(流式SIMD扩展)。