- 向量编程
因为微处理器的最大时钟频率存在技术限制,增加处理器吞吐率的趋势是并行处理多个数据。单指令多数据(SIMD)编程的原理是在一个大的寄存器中封装一个向量或一组数据,在一次操作中一起处理。有数以百计的SIMD指令可用。在《IA-32 Intel Architecture Software Developer’s Manual》卷2A及2B,以及《AMD64 Architecture Programmer’s Manual》卷4中列出这些指令。
多个数据可以下面的方式封装到64位MMX寄存器、128位XMM寄存器或256位YMM寄存器中:
数据类型 | 每封装数据 | 寄存器大小 | 指令集 |
8位整数 | 8 | 64位(MMX) | MMX |
16位整数 | 4 | 64位(MMX) | MMX |
32位整数 | 2 | 64位(MMX) | MMX |
64位整数 | 1 | 64位(MMX) | SSE2 |
32位浮点 | 2 | 64位(MMX) | 3DNow(过时) |
8位整数 | 16 | 128位(XMM) | SSE2 |
16位整数 | 8 | 128位(XMM) | SSE2 |
32位整数 | 4 | 128位(XMM) | SSE2 |
64位整数 | 2 | 128位(XMM) | SSE2 |
32位浮点 | 4 | 128位(XMM) | SSE |
64位浮点 | 2 | 128位(XMM) | SSE2 |
8位整数 | 32 | 256位(YMM) | AVX2 |
16位整数 | 16 | 256位(YMM) | AVX2 |
32位整数 | 8 | 256位(YMM) | AVX2 |
64位整数 | 4 | 256位(YMM) | AVX2 |
32位浮点 | 8 | 256位(YMM) | AVX |
64位浮点 | 4 | 256位(YMM) | AVX |
8位整数 | 64 | 512位(ZMM) | AVX-512BW |
16位整数 | 32 | 512位(ZMM) | AVX-512BW |
32位整数 | 16 | 512位(ZMM) | AVX-512 |
64位整数 | 8 | 512位(ZMM) | AVX-512 |
32位浮点 | 16 | 512位(ZMM) | AVX-512 |
64位浮点 | 8 | 512位(ZMM) | AVX-512 |
表13.1. 向量类型
可以通过CUPID指令确定在一个特定微处理器上是否支持不同的指令集,如第143页所述。64位MMX寄存器不能与x87形式浮点寄存器一起使用。向量寄存器仅在操作系统支持时可用。如何检查操作系统是否启用向量寄存器,参考第144页。
要在一个向量寄存器中封装尽可能多的数据,选择合适目的的最小数据尺寸是有利的。数学计算可能要求双精度(64位)浮点,避免中间计算的精度损失,即使单精度对最终结果已足够。
在你选择使用向量指令前,你必须考虑结果代码是否比不使用向量的简单指令更快。使用向量代码,有时会在琐碎的事情上花费更多指令,比如移动数据到寄存器正确的位置,模拟条件移动。下面的例子13.8是这样的一个例子。在较旧的处理器上,向量指令相对较慢,但许多较新的处理器,在许多情形里,执行向量计算与标量(单)计算一样快。
对浮点计算,最好使用XMM寄存器,而不是x87寄存器,即使没有机会并行处理数据。XMM寄存器以一个比旧式x87寄存器栈更为直接的方式处理,某些CPU的x87指令性能很差。
没有VEX前缀XMM指令的内存操作数必须对齐到16。YMM指令的内存操作数最好对齐到32,ZMM对齐到64,但这不是必须的。如何对齐内存中数据,参考第81页。
大多数通用算术与逻辑操作可以在向量寄存器中进行。下面的例子展示了两个数组的相加:
; Example 13.1a. Adding two arrays using vectors
; float a[128], b[128], c[128];
; for (int i = 0; i < 128; i++) a[i] = b[i] + c[i];
; Assume that a, b and c are aligned by 32
xor ecx, ecx ; Loop counter i = 0
L: vmovaps ymm0, [b+rcx] ; Load 8 elements from b
vaddps ymm0, ymm0, [c+rcx] ; Add 8 elements from c
vmovaps [a+rcx], ymm0 ; Store result in a
add ecx, 32 ; 8 elements * 4 bytes = 32
cmp ecx, 512 ; 128 elements * 4 bytes = 512
jb L ; Loop
没有整数除法指令。一个常量除数的整数除法可以实现为乘法与偏移,使用第148页描述的方法,或http://www.agner.org/optimize/#vectorclass向量类库,www.agner.org/optimize/asmlib.zip汇编库。
-
- 使用AVX指令集与YMM寄存器
AVX指令集将128位XMM寄存器扩展为256位YMM寄存器,并支持256位向量中的浮点操作。AVX2指令集还支持YMM寄存器里的整数向量操作。AVX512指令集有512位向量可用,很可能在将来进一步扩展到1024位以及可能2048位。
每个XMM寄存器构成YMM寄存器的低半部。一个YMM寄存器可以保存一个8单精度向量或一个4双精度向量。使用AVX2,在一个YMM寄存器中,可以有4个64位整数,8个32位整数,16个16位整数或32个8位整数。
大多数AVX与AVX2指令允许3个操作数:一个目标与两个源操作数。这有输入寄存器不会被结果改写的好处。
在AVX指令集中,所有现存的XMM指令有两个版本。一个不改变目标YMM寄存器高半部(128 ~ 255比特)的遗留版本,以及将高半部置零以消除假依赖的VEX前缀版本。VEX前缀版本在名字中有一个V前缀,在大多数情形里有3个操作数:
; Example 13.2. Legacy and VEX versions of the same instruction
addps xmm1, xmm2 ; xmm1 = xmm1 + xmm2
vaddps xmm1, xmm2, xmm3 ; xmm1 = xmm2 + xmm3
如果代码序列同时包含128位与256位指令,使用128位指令的VEX版本是重要的。混用256位指令与没有VEX前缀遗留128位指令,将导致某些Intel处理器在不同状态间切换寄存器文件,这将花费许多时钟周期。这适用于Intel Sandy Bridge,Ivy Bridge,Haswell与Broadwell处理器,但不适用Skylake与更新的处理器以及AMD处理器。
为了与现存代码兼容,在Sandy Bridge,Ivy Bridge与Broadwell处理器上,寄存器组有三个不同的状态:
- (干净状态)。YMM寄存器的高半部未使用,且已知为零。
- (修改状态)。至少一个YMM寄存器的高半部被使用且包含数据。
- (保存状态)。所有的YMM寄存器分解为两个。低半部由不改变高半部的遗留XMM指令使用。所有高半部保存在一个暂存器里。如果需要,通过转换到状态B,每个寄存器的两部分将被再次合并。
为了状态间的快速转换,有两条指令可用。将所有YMM寄存器置零的VZEROALL,及将所有YMM寄存器高半部置零的VZEROUPPER。这两条指令将处理器维持在状态A。下面的状态转换表展示了状态的转换:
当前状态 à | A | B | B |
指令 ↓ | |||
VZEROALL / UPPER | A | A | A |
XMM | A | C | C |
VEX / XMM | A | B | B |
YMM | B | B | B |
表13.2. YMM状态转换
状态A是中性的初始状态。在使用完整的YMM寄存器时,需要状态B。在从状态B调用时,需要状态C使遗留XMM代码快速且没有假依赖。从状态B到状态C的转换代价高,因为所有的YMM寄存器必须被分解为分别保存的两部分、从状态C到状态B的转换同样代价高,因为所有的寄存器必须被再次合并。从状态C到A的转换也是高代价的,因为保存寄存器高半部的暂存器不支持乱序访问,且必须保护不受可能误预测分支推测执行的影响。转换BàC,CàB及CàA,在某些Intel处理器上非常耗时,因为它们必须等待所有寄存器回收。转换AàB与BàA是快的,最多需要1时钟周期。C应该被视为不期望的状态,转换AàC是不可能的。在Intel Sandy Bridge,Ivy Bridge,Haswell与Broadwell处理器上,根据我的测量,不期望的转换需要大约70时钟周期。在AMD处理器与Intel Skylake及更新的处理器上,这个问题不再存在,这些转换不需要额外时间。
下面的例子展示了状态转换:
; Example 13.3a. Transition between YMM states
vaddps ymm0, ymm1, ymm2 ; State B
addss xmm3, xmm4 ; State C
vmulps ymm0, ymm0, ymm5 ; State B
例子13.3a有两个昂贵的状态转换,从B到C,然后回到状态B。通过VEX版本的VADDSS替换遗留ADDSS指令,可以避免状态转换:
; Example 13.3b. Transition between YMM states avoided
vaddps ymm0, ymm1, ymm2 ; State B
vaddss xmm3, xmm3, xmm4 ; State B
vmulps ymm0, ymm0, ymm5 ; State B
在调用使用XMM指令的库函数时,不能使用这个方法。这里的解决方案是保存任何使用的YMM寄存器,进入状态A:
; Example 13.3c. Transition to state A
vaddps ymm0, ymm1, ymm2 ; State B
vmovaps [mem], ymm0 ; Save ymm0
vzeroupper ; State A
call XMM_Function ; Legacy function
vmovaps ymm0, [mem] ; Restore ymm0
vmulps ymm0, ymm0, ymm5 ; State B
vzeroupper ; Go to state A before returning
ret
...
XMM_Function proc near
addss xmm3, xmm4 ; State A
ret
混用XMM与YMM代码的程序应该遵循特定指引,以避免高代价的自/至状态C的转换:
- 使用YMM指令的函数应该在返回前发布一条VZEROALL或VZEROUPPER,如果存在返回到XMM代码的可能性。
- 在调用任何可能包含XMM代码的函数前,使用YMM指令的函数应该保存使用的YMM寄存器,发布一条VZEROALL或VZEROUPPER。
- 具有一个CPU分派器,如果可以选择YMM代码,否则XMM代码的函数应该在离开YMM部分前,应该发布一条VZEROALL或VZEROUPPER。
换而言之:这个建议允许任何函数使用XMM或YMM寄存器,但在调用任何具有未知VEX状态的函数后,或返回到任何具有未知VEX状态的函数前,使用YMM寄存器的函数必须将寄存器保留在状态A。
显然,这不适用于使用完整YMM寄存器来传递参数或返回的函数。将YMM寄存器用于参数传递的函数,在入口处可以假定状态B。将YMM寄存器(YMM0)用于返回值的函数在返回时仅可以在状态B,总是应该避免状态C。
在大多数处理器上,指令VZEROUPPER比VZEROALL快。因此,建议使用VZEROUPPER,而不是VZEROALL,除非你希望完全的初始化。VZEROALL不能用在64位Windows中,因为ABI声明XMM6 ~ XMM15有被调用者保存状态。换句话说,调用函数可以假设寄存器XMM6 ~ XMM15在返回后不改变,但YMM寄存器的高半部不是。在32位Windows或任何Unix系统(Linux,BSD,Mac)中,没有XMM或YMM寄存器有被调用者保存状态。因此,在如64位Linux里使用VZEROALL是可以的。显然,如果任何YMM寄存器包含函数参数或返回值,不能使用VZEROALL。在这些情形里,必须使用VZEROUPPER。
在Intel Sandy Bridge,Ivy Bridge,Haswell与Broadwell处理器上,混用VEX与非VEX代码有性能损失。Intel Skylake处理器对混用VEX与非VEX代码没有性能损失,AMD处理器没有这样的性能损失,在所有具有高代价状态转换的处理器上,VZEROUPPER与VZEROALL指令都是快的。
不过,在Knights Landing处理器(第一个支持AVX512的处理器)上,VZEROUPPER与VZEROALL指令代价很高。在Knights Landing上不建议使用这些指令。何时使用VZEROUPPER以及何时不使用的问题,目前在https://software.intel.com/en-us/forums/intel-isa-extensions/topic/704023上讨论。这个讨论尚未解决,但可以遵循得出的初步结论:
- 尽可能避免混用VEX与非VEX代码
- 在支持AVX但不支持AVX512的处理器上:在调用具有未知VEX状态的函数前,以及在返回到具有未知VEX状态的函数前,使用VZEROUPPER。
- 在支持AVX512的处理器上:不要使用VZEROUPPER(仅寄存器zmm0 ~ zmm15受VZEROUPPER/VZEROALL影响,zmm16 ~ zmm31没有)。
- 在通用函数库中:使CPU分配到依赖支持指令集的多个分支中。仅在支持AVX但不支持AVX512的处理器分支里使用VZEROUPPER。
混用VEX与非VEX代码是一个常见的错误,很容易犯,但很难检测。代码仍然工作,但在某些处理器性能下降。强烈建议再三检测代码中混用的VEX与非VEX指令。
预热时间
某些高端处理器能在不用时关闭256位执行单元的高128位来节省电能。在一个空闲期间后,启动高半部需要大约14 μs。在预热期间,256位向量指令的吞吐率要低得多,因为执行一个256位操作,处理器需要使用低半部128位单元两次。在需要256位单元之前,通过在合适时间执行一条伪256位指令,预先预热256位单元是可能的。在没有256位指令的大约675 μs后,256位单元的高半部将再次关闭。这个现象在手册3《Intel,AMD与VIA CPU微架构》中描述。
操作系统支持
使用YMM寄存器或VEX编码指令的代码仅能运行在支持它的操作系统上,因为在任务切换时,操作系统必须保存YMM寄存器。下面的操作系统版本支持AVX与XMM寄存器:Windows 7,Windows server 2008 SP2,Linux内核2.6.30或更新版本。
YMM与系统代码
状态B与C之间的转换必定发生的一个情形是,YMM代码被中断,且中断处理句柄包含保存XMM寄存器,但不保存完整YMM寄存器的遗留XMM代码。事实上,状态C的发明就是为了在这个情形里保留YMM寄存器的高半部。
在编写可能被中断句柄调用的设备驱动与其他代码时,遵循某些规则是非常重要的。如果在系统代码中一条YMM指令或VEX-XMM指令修改了YMM寄存器,首先使用XSAVE保存整个寄存器暂停,在返回前使用XRESTORE恢复它是必要的。独立保存YMM寄存器是不够的,因为未来的处理器可能进一步扩展YMM寄存器为512位ZMM寄存器(或者将来的称谓),在执行YMM指令时将零扩展YMM寄存器到ZMM,因而破坏了ZMM寄存器的最高部分。XSAVE / XRESTORE是仅有的、与未来超过256位扩展兼容的、保存这些寄存器的方式。未来的扩展将不使用每条YMM指令两个版本的复杂方法。
如果一个设备驱动不使用XSAVE / XRESTORE,存在不经意使用YMM寄存器的风险,即使程序员不打算这么做。不是为系统代码准备的编译器可能插入对库函数,如memset与memcpy的隐式调用。通常这些函数有自己的CPU分派器,可能选择最大的可用寄存器。因此,使用为制作系统代码准备的编译器与函数库是必要的。
这些规则在手册5《调用惯例》中进一步描述。
使用非破坏性三操作数指令
定义YMM指令的AVX指令集,通过将现有的前缀与转义码替换为新的VEX前缀,还定义了所有现存XMM指令的另一个编码。VEX前缀有定义了一个额外寄存器操作数的进一步好处。几乎所有之前有两个操作数的XMM指令,在使用VEX前缀时,都有三个操作数。
两操作数版本的指令通常将同一个寄存器用作目标以及其中一个源操作数:
; Example 13.4a. Two-operand instruction
addsd xmm0, xmm2 ; xmm0 = xmm0 + xmm2
这有结果改写其中一个源操作数的坏处。在使用三操作数加法时,可以避免例子13.4a里的移动指令:
; Example 13.4b. Three-operand instruction
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2
这里,没有源操作数被破坏,因为结果保存在另一个目标寄存器里。这有帮于避免寄存器-寄存器移动。例子13.4a与13.4b中的addsd与vaddsd指令具有相同的长度。因此,使用三操作数版本没有性能损失。名字以ps结尾的指令(封装单精度),如果目标寄存器不是xmm8 ~ xmm15,两操作数版本比三操作数版本要短1字节。在少数情形里,三操作数版本比两操作数版本短。在大多数情形里,两操作数与三操作数版本有相同的长度。
只要寄存器集在状态A,在代码中混用两操作数与三操作数指令是可以的。但如果寄存器集不管什么原因,恰好在状态C,那么混用VEX与非VEX的XMM指令,在每次指令类型改变时,会导致高代价的状态改变。因此,仅使用VEX版本或非VEX版本更好。如果使用了YMM寄存器(状态B),所有XMM指令应该仅使用VEX前缀版本,直到这个VEX段以VZEROALL或VZEROUPPER结束。
64位MMX指令与大多数通用寄存器指令没有三操作数版本。混用MMX与VEX指令没有性能损失。少数通用寄存器指令也有三操作数。
非对齐内存访问
所有带有一个内存操作数、VEX编码的XMM及YMM指令允许非对齐内存访问,除了显式要求对齐的指令VMOVAPS,VMOVAPD,VMOVDQA,VMOVNTPS,VMOVNTPD,VMOVNTDQ。因此,将YMM操作数保存在栈上,无需保持栈对齐到32是可以的。
编译器支持
Microsoft,Intel与Gnu编译器支持VEX指令集。
编译器将对所有XMM指令使用VEX前缀的版本,如果编译AVX指令集,包括固有函数。在从使用AVX编译的模块迁移到不使用AVX编译的模块或库之前,发布一条VZEROUPPER指令,是程序员的责任。
融合乘加指令
在仅能执行一次乘法的时间里,融合乘加(FMA)指令可以进行一次浮点乘法,后跟一次浮点加法或减法。FMA操作具有形式:
d = a * b + c
FMA指令有称为FMA3与FMA4的两个变种。FMA4指令可将4个不同的寄存器用于操作数a,b,c及d。FMA3仅能有3个操作数,其中目标d必须使用与输入操作数a,b,c其中一个相同的寄存器,一开始Intel设计FMA4指令集,但当前仅支持FMA3。最新的AMD处理器同时支持FMA3与FMA4(参考en.wikipedia.org/wiki/FMA_instruction_set#History)。
例子
第95页的例子12.6f展示了对一个DAXPY计算使用AVX指令集。第107页的例子12.12e展示了对一个泰勒序列展开使用AVX指令集。这两个例子说明了3操作数指令以及256位向量的使用。例子12.6h与12.6g分别展示了FMA3与FMA4指令的使用。
-
- 使用AVX512指令集与ZMM寄存器
伴随AVX512指令集,向量寄存器被扩展到512比特。在64位模式中,向量寄存器数增加到32个,在32位模式中,仅有8个向量寄存器。
有8个新的称为k0 ~ k7的掩码寄存器。掩码寄存器k1 ~ k7可用于每个向量元素上的条件操作,如第124页所述。在数组大小不能被向量寄存器大小整除时,条件操作对数组循环特别有用。这在第100页解释了。
对AVX512有几个额外的扩展。所有支持AVX512的处理器基于这些扩展的某些,但目前为止(2016年)没有处理器具有所有。对AVX512已知且已计划的扩展有这些:
- AVX512F。基础(Foundation)。所有AVX512处理器都有这。包括在512位向量里的32位与64位整数、浮点及双精度数上的操作,以及掩蔽操作。
- AVX512L。在128位与256位向量上包含相同的操作,包括掩蔽操作与32个向量寄存器。
- AVX512BW。在512位向量里8位与16位整数上的操作。
- AVX512DQ。带有64位整数的乘法与转换指令。在浮点与双精度上的其他各种指令。
- AVX512ER。快速倒数、平方根倒数与指数函数。单精度上精确,双精度上近似。
- AVX512CD。冲突检测。在一个向量中找出重复的元素。
- AVX512PF。使用gather/scatter逻辑预取指令。
- AVX512VBMI。8位粒度的排列(permutation)与偏移(shift)。
- AVX512IFMA。52位整数上的融合乘加。
- AVX512_4VNNIW。16位整数上的迭代点积。
- AVX512_4FMAPS。单精度的,迭代融合乘加。
- Xmm与ymm寄存器里的条件移动
考虑在4对值里查找最大值的这个C++代码:
// Example 13.5a. Loop to find maximums
float a[4], b[4], c[4];
for (int i = 0; i < 4; i++) {
c[i] = a[i] > b[i] ? a[i] : b[i];
}
如果我们希望使用XMM寄存器实现这个代码,那么不能对循环里的分支使用条件跳转,因为对所有4个元素分支条件不是相同的。
幸好,有一条做这件事的maximum指令:
; Example 13.5b. Maximum in XMM
movaps xmm0, [a] ; Load a vector
maxps xmm0, [b] ; max(a,b)
movaps [c], xmm0 ; c = a > b ? a : b
对单精度与双精度浮点、8位与16位整数,都存在minimum与maximum向量指令。浮点向量元素的绝对值可以通过AND掉符号位来计算,如第134页例子13.16所示。用于整数向量元素绝对值的指令存在于“补充SSE3(Supplementary SSE3)”指令集中。整数饱和加法向量指令(如PADDSW)也可用于查找最大、最小值,或把值限定在一个指定区域。
不过,这些方法不是太通用。在向量寄存器中进行条件移动的最通用方式是使用布尔向量(Boolean vector)指令。下面的例子是上面例子的一个修改,其中我们不能使用MAXPS指令:
// Example 13.6a. Branch in loop
float a[4], b[4], c[4], x[4], y[4];
for (int i = 0; i < 4; i++) {
c[i] = x[i] > y[i] ? a[i] : b[i];
}
通过制作一个掩码,在条件成立时包含全1,在条件不成立时包含全0,可以完成必须的条件移动。a[i]与这个掩码AND,b[i]与掩码的取反AND:
; Example 13.6b. Conditional move in XMM registers
movaps xmm1, [y] ; Load y vector
cmpltps xmm1, [x] ; Compare with x. xmm1 = mask for y < x
movaps xmm0, [a] ; Load a vector
andps xmm0, xmm1 ; a AND mask
andnps xmm1, [b] ; b AND NOT mask
orps xmm0, xmm1 ; (a AND mask) OR (b AND NOT mask)
movaps [c], xmm0 ; c = x > y ? a : b
制作这个条件的向量(例子13.6b中的x与y)以及被选择的向量(例子13.6b中的a与b)不需要是相同的类型。例如,x与y可以是整数。但它们应该有相同的元素数。如果a与b是double,每个向量2个元素,x与y是32位整数,每个向量4个元素。那么我们必须复制x与y里的每个元素,以得到正确大小的掩码(参考下面的例子13.8b)。
注意,AND-NOT指令(andnps,andnpd,pandn)取反目标操作数,不是源操作数。这意味着它破坏了掩码。因此,在例子13.6b中,我们必须在andnps前有andps。如果支持SSE4.1,我们可以使用BLENDVPS指令:
; Example 13.6c. Conditional move in XMM registers, using SSE4.1
movaps xmm0, [y] ; Load y vector
cmpltps xmm0, [x] ; Compare with x. xmm0 = mask for y < x
movaps xmm1, [a] ; Load a vector
blendvps xmm1, [b], xmm0 ; Blend a and b
movaps [c], xmm0 ; c = x > y ? a : b
如果多次需要这个掩码, a与b的XOR和掩码AND更高效。这在下一个例子中展示,它进行a和b条件交换:
// Example 13.7a. Conditional swapping in loop
float a[4], b[4], x[4], y[4], temp;
for (int i = 0; i < 4; i++) {
if (x[i] > y[i]) {
temp = a[i]; // Swap a[i] and b[i] if x[i] > y[i]
a[i] = b[i];
b[i] = temp;
}
}
现在使用XMM向量的汇编代码:
; Example 13.7b. Conditional swapping in XMM registers, SSE
movaps xmm2, [y] ; Load y vector
cmpltps xmm2, [x] ; Compare with x. xmm2 = mask for y < x
movaps xmm0, [a] ; Load a vector
movaps xmm1, [b] ; Load b vector
xorps xmm0, xmm1 ; a XOR b
andps xmm2, xmm0 ; (a XOR b) AND mask
xorps xmm1, xmm2 ; b XOR ((a XOR b) AND mask)
xorps xmm2, [a] ; a XOR ((a XOR b) AND mask)
movaps [b], xmm1 ; (x[i] > y[i]) ? a[i] : b[i]
movaps [a], xmm2 ; (x[i] > y[i]) ? b[i] : a[i]
指令xorps xmm0, xmm1产生a与b间比特差异的一个模式。这个比特模式与掩码AND,如果a与b应该交换,使xmm2包含需要改变的比特,如果不需要交换,包含零。最后两条xorps指令翻动,如果a与b应该交换,必须改变的比特,如果不应该,则不改变。
也可以通过使用算术右移指令psrad,将符号位拷贝到所有比特位置,产生用于条件移动的掩码。这在下一个例子中展示,其中向量元素有不相同的整数指数。我们使用第112页例子12.16a中的方法计算指数。
// Example 13.8a. Raise vector elements to different integer powers
double x[2], y[2]; unsigned int n[2];
for (int i = 0; i < 2; i++) {
y[i] = pow(x[i],n[i]);
}
如果n的元素都相等,那么最简单的解决方案是使用一个分支。但如果指数不同,我们必须使用条件移动:
; Example 13.8b. Raise vector to power, using integer mask, SSE2
.data ; Data segment
align 16 ; Must be aligned
ONE DQ 1.0, 1.0 ; Make constant 1.0
X DQ ?, ? ; x[0], x[1]
Y DQ ?, ? ; y[0], y[1]
N DD ?, ? ; n[0], n[1]
.code
; register use:
; xmm0 = xp
; xmm1 = power
; xmm2 = i (i0 and i1 each stored twice as DWORD integers)
; xmm3 = 1.0 if not(i & 1)
; xmm4 = xp if (i & 1)
movq xmm2, [N] ; Load n0, n1
punpckldq xmm2, xmm2 ; Copy to get n0, n0, n1, n1
movapd xmm0, [X] ; Load x0, x1
movapd xmm1, [one] ; power initialized to 1.0
mov eax, [N] ; n0
or eax, [N+4] ; n0 OR n1 to get highest significant bit
xor ecx, ecx ; 0 if n0 and n1 are both zero
bsr ecx, eax ; Compute repeat count for max(n0,n1)
L1: movdqa xmm3, xmm2 ; Copy i
pslld xmm3, 31 ; Get least significant bit of i
psrad xmm3, 31 ; Copy to all bit positions to make mask
psrld xmm2, 1 ; i >>= 1
movapd xmm4, xmm0 ; Copy of xp
andpd xmm4, xmm3 ; xp if bit = 1
andnpd xmm3, [one] ; 1.0 if bit = 0
orpd xmm3, xmm4 ; (i & 1) ? xp : 1.0
mulpd xmm1, xmm3 ; power *= (i & 1) ? xp : 1.0
mulpd xmm0, xmm0 ; xp *= xp
sub ecx, 1 ; Loop counter
jns L1 ; Repeat ecx+1 times
movapd [Y], xmm1 ; Store result
循环的重复次数在循环外单独计算,以减少循环内的指令数。
在P4E上例子13.8b的时间分析:有4条连续的依赖链:xmm0:7时钟周期,xmm1:7时钟周期,xmm2:4时钟周期,ecx:1时钟周期。不同执行单元的吞吐率:MMX-SHIFT:3 μop,6时钟周期。MMX-ALU:3 μop,6时钟周期。FP-MUL:2 μop,4时钟周期。端口1的吞吐率:8 μop,8时钟周期。因此,循环看起来受限于端口1的吞吐率。我们可以期望的最好时间是每迭代8时钟周期,这是必须去往端口1的μop数。不过,3条连续依赖链由两个被破坏但相当长的,涉及xmm3与xmm4,分别需时23与29时钟周期的依赖链互联。这有阻碍μop最优重排的趋势。测得时间大约是每迭代10 μop。这个时序实际上要求相当令人印象深刻的重排能力,考虑几次迭代必须重叠且几条依赖链相互交织,以满足所有端口与执行单元的限制。
在通用寄存器里的条件移动使用CMOVcc,使用FCMOVcc的浮点寄存器不会被XMM寄存器快。
在具有SSE4.1指令集的处理器上,我们可以使用PBLENDVB,BLENDVPS或BLENDVPD指令,替代AND/OR操作,就像上面的例子13.6c中那样。在具有XOP指令集的AMD处理器上,我们可以相同的方式使用VPCMOV指令作为替代。
-
- 使用AVX512的条件移动
AVX512指令集支持由一个掩码寄存器控制的掩蔽操作。一条掩蔽向量指令将仅在掩码寄存器对应比特为1的向量元素上执行操作。我们可以重写例子13.6来展示这:
// Example 13.9a. Branch in loop using AVX512
float a[16], b[16], c[16], x[16], y[16];
for (int i = 0; i < 16; i++) {
c[i] = x[i] > y[i] ? a[i] : b[i];
}
使用AVX512实现这是相当高效的:
; Example 13.9b. Conditional move in ZMM registers
vmovaps zmm1, [y] ; Load vector y
vcmpltps k1, zmm1, [x] ; Compare with x. k1 = mask for y < x
vmovaps zmm0, [b] ; Load vector b
vmovaps zmm0{k1}, [a] ; Load vector a for elements with mask bit 1
vmovaps [c], zmm0 ; c = x > y ? a : b
在AVX512下,大多数向量指令可以被掩蔽,例如:
// Example 13.10a. Conditional addition using AVX512
float a[16], b[16];
for (int i = 0; i < 16; i++) {
if (a[i] < 0) {
a[i] += b[i];
}
}
这可以使用一个掩蔽加法实现:
; Example 13.10b. Conditional add in ZMM registers
vmovaps zmm1, [a] ; Load vector a
vpxord zmm0, zmm0, zmm0 ; Make zero
vcmpltps k1, zmm1, zmm0 ; Compare with zero. k1 = mask for a < 0
vaddps zmm1{k1}, zmm1, [b] ; Masked addition
vmovaps [a], zmm1 ; Store result
掩蔽指令也可以用在更小的xmm与ymm寄存器上,如果支持指令集扩展AVX512VL。如果支持AVX512DQ,可以vpxord指令替换vxorps。
在上面的例子中,掩码比特为0的向量元素不改变。将禁用的元素置零,而不是使它们不改变是可能的,通过声明{z}选项:
; Example 13.11. Masking and zeroing
mov eax, 000FH ; make constant
kmovw k1, eax ; copy constant to mask register
vmovaps zmm1{k1}, [a] ; conditional move
vmovaps zmm2{k1}{z}, [b] ; conditional move with zeroing
在这个例子中,数组a的前4个元素被读入zmm1,zmm1余下的元素不改变。数组b的前4元素读入zmm2,zmm2余下的元素置为零。置零选项是有用的,因为它消除了zmm2对zmm2之前值上的依赖。建议尽可能使用置零选项,因为它使得掩蔽指令的乱序执行更高效。
掩蔽是免费的,就大多数指令而言,有没有掩蔽,不管掩码的值为何,时延与吞吐率都是相同的。另一方面,你可能把时间浪费在掩码是全零的计算上,如果掩码是全零,跳过耗时的条件计算是有利的。例如:
// Example 13.12a. Skip calculations if mask is all zeroes
float a[16], b[16];
for (int i = 0; i < 16; i++) {
if (a[i] >= 0) {
b[i] = sqrt(a[i]);
}
}
如果数组a所有的元素经常是负数,我们可以跳过耗时的平方根:
; Example 13.12b. Skip calculations if mask is all zeroes
vmovaps zmm1, [a] ; Load vector a
vpxord zmm0, zmm0, zmm0 ; Make zero
vcmpltps k1, zmm1, zmm0 ; Compare with zero. k1 = mask for a < 0
kortestw k1, k1 ; test bits of k1
jz L1 ; jump if all zero
vmovaps zmm2, [b] ; Load vector b
vsqrtps zmm2{k1}, zmm1 ; square root of non-negative elements
vmovaps [b], zmm2 ; Store modified b
L1:
一条掩蔽指令通常可以替换两条指令,因为它进行两件事。甚至有一个掩码寄存器的比较指令也可以被掩蔽。带有一个掩码寄存器作为输出,并施行另一个掩码的比较与测试指令,总是有一个隐含的置零选项,使输出掩码为零,而不是不改变对应输入掩码为零的比特。结果是,比较结果与输入掩码的逻辑AND合并。这意味着AND合并可以比OR合并更高效地实现。这展示在下面的例子中:
// Example 13.13a. Use of masks
float a[16], b[16], c[16];
for (int i = 0; i < 16; i++) {
if (a[i] > 0 || b[i] > 0) {
c[i] = a[i] + b[i];
}
else {
c[i] = a[i] - b[i];
}
}
在这个例子中,根据公式:a || b = !(!a && !b),通过取反所有输入与输出,可以将OR(||)转换为AND(&&)。这将代码改为:
// Example 13.13b. Use of masks, convert OR to AND
float a[16], b[16], c[16];
for (int i = 0; i < 16; i++) {
if (a[i] <= 0 && b[i] <= 0) {
c[i] = a[i] - b[i];
}
else {
c[i] = a[i] + b[i];
}
}
这是有用的,因为可以使用第一个的结果掩蔽第二条比较指令,优化AND操作:
; Example 13.13c. Efficient use of masks
vmovaps zmm1, [a] ; Load vector a
vmovaps zmm2, [b] ; Load vector b
vpxord zmm0, zmm0, zmm0 ; Make zero
vcmpleps k1, zmm1, zmm0 ; k1 = mask for a <= 0
vcmpleps k2{k1}, zmm2, zmm0 ; k2 = mask for a <= 0 && b <= 0
vaddps zmm0, zmm1, zmm2 ; a + b
vsubps zmm0{k2}, zmm1, zmm2 ; a - b if mask
vmovaps [c], zmm0 ; Store result
-
- 以意图以外的其他类型使用向量指令
大多数XMM、YMM与ZMM指令是有类型的,在于它们意图用于特定的数据类型。例如,使用一条指令把整数加上浮点数是不合理的。但仅移动数据的指令将对任何数据类型都有效,即使它们专门用于一个特定类型的数据。如果你拥有的数据类型不存在等效的指令,或者另一个数据类型指令更高效,这是有用的。
所有移动、混排(shuffle)、混合(blend)或偏移(shift)数据的向量指令,以及布尔指令可用于它们目标类型以外的其他数据类型。但执行任何算术操作、类型转换或精度转换的指令,仅能用于它的目标类型。例如,指令FLD不仅移动浮点数据,它还转换到另一个精度。如果你尝试使用FLD与FSTP来移动整数数据,那么在整数恰好不能表示一个正常的浮点数的情形下,你将得到次正规操作数的异常。在某些情形里,这条指令甚至可能改变数据的值。但指令MOVAPS,它也是意图用于移动浮点数据,不会转换精度或别的。它只是移动数据。因此,使用MOVAPS移动整数数据没有问题。
如果你怀疑一条特定的指令是否对任何数据类型有效,检查Intel或AMD的软件手册。如果指令可以产生任何种类的“浮点异常”,那么它不应该用于目标类型以外的类型。在某些处理器上,使用错误的类型会有性能损失。这是因为对整数与浮点数据,处理器可能有不同的数据总线或执行单元。在整数与浮点执行单元间移动数据,取决于处理器,需要1个或多个时钟周期,如表13.3列出的那样。
处理器 | 旁路时延,时钟周期 |
Intel Core 2及更早的处理器 | 1 |
Intel Nehalem | 2 |
Intel Sandy Bridge与更新的处理器 | 0-1 |
Intel Atom | 0 |
AMD | 2 |
VIA Nano | 2-3 |
表13.3. 整数与浮点执行单元间数据旁路时延
在Intel Core2与更早的Intel 处理器上,某些浮点指令在整数单元中执行。这包括XMM移动指令,布尔及某些混排与封装指令。在与使用浮点单元的指令混合时,这些指令有一个旁路时延。在大多数其他处理器上,根据指令名使用执行单元,比如MOVAPS XMM1, XMM2使用浮点单元,MOVDQA XMM1, XMM2使用整数单元。读或写内存的指令使用一个单独的单元。在某些处理器上,从内存单元到浮点单元的旁路时延可能比到整数单元长,但它不依赖于指令的类型。因此,MOVAPS XMM0, [MEM]与MOVDQA XMM0, [MEM]的时延没有差别,但不保证在将来的处理器上会有差别。
不同处理器执行单元的更多细节可以在手册3《Intel,AMD与VIA CPU微架构》中找到。手册4《指令表》有所有指令的列表,指出它们使用哪些执行单元。
在没有旁路时延以及吞吐率比时延重要的情形下,使用错误类型的指令会是一个优势。某些情形描述如下。
使用最短的指令
封装单精度浮点数、名字以PS结尾的指令,比用于双精度或整数的等效指令短。例如,可使用MOVAPS替换MOVAPD或MOVDQA自/至内存或寄存器间移动数据。在一些处理器上,在使用MOVAPS移动整数指令的结果到另一个寄存器时,有旁路时延,但自/至内存移动数据时不会。
使用更高效的指令
将向量寄存器设置为零的一个有效方式是PXOR XMM0, XMM0。许多处理器将这条指令识别位与XMM0的先前值无关,但不是所有的处理器同样识别XOPRS与XORPD。因此,将寄存器置零,优先使用XOR指令。
布尔向量指令(PAND,PANDN,POR,PXOR)的整数版本,在某些AMD与Intel处理器上,可以比等效的浮点指令(ANDPS等)使用更多不同的执行单元。
使用其他数据类型不可用的指令
有许多情形,对目标类型以外的类型使用指令是有利的,只是因为对你所有的数据类型,不存在等效的指令。用于单精度浮点向量的指令在SSE指令集中可用,而双精度与整数的等效指令要求SSE2指令集。使用MOVAPS替换MOVAPD或MOVDQA移动数据,使代码与支持SSE,但不支持SSE2的处理器兼容。
许多用于数据混排与混合的指令仅对一种数据类型可用。这些指令很容易用于它们目标类型以外的类型。旁路时延,如果有,可能小于替代方案的代价。数据混排指令在下一段落列出。
-
- 混排数据
向量化代码有时需要大量的指令来交换、拷贝向量元素,把数据放入向量正确的位置。这些额外指令的需求,降低了使用向量操作的优势。使用目标类型是你的类型以外的混排指令是可能的,如前面章节里解释的那样。一些对数据混排有用的指令列在下面。
在128位通道内排列数据
指令 | 数据块大小,比特 | 描述 | 指令集 |
PSHUFD | 32 | 一般排列 | SSE2 |
PSHUFLW | 16 | 仅排列寄存器低半部 | SSE2 |
PSHUFHW | 16 | 仅排列寄存器高半部 | SSE2 |
SHUFPS | 32 | 排列 | SSE |
SHUFPD | 64 | 排列 | SSE2 |
PSLLDQ | 8 | 偏移到另一个位置,把原来位置置零 | SSE2 |
PSHUFB | 8 | 一般排列 | Suppl. SSE3 |
PALIGNR | 8 | 旋转8或16字节的向量 | Suppl. SSE3 |
PINSRB | 8 | 向向量插入字节 | SSE4.1 |
PINSRW | 16 | 向向量插入字 | SSE |
PINSRD | 32 | 向向量插入双字 | SSE4.1 |
PINSRQ | 64 | 向向量插入四字 | SSE4.1 |
INSERTPS | 32 | 向向量插入双字 | SSE4.1 |
VPERMILPS | 32 | 可变选择子(selector)排列 | AVX |
VPERMILPD | 64 | 可变选择子(selector)排列 | AVX |
VPPERM | 8 | 可变选择子(selector)排列 | AMD XOP |
表13.4. 排列指令
跨128位通道排列数据
指令 | 数据块大小,比特 | 描述 | 指令集 |
VPERMW | 16 | 可变选择子(selector)排列 | AVX512BW |
VPERMD | 32 | 可变选择子(selector)排列 | AVX2 |
VPERMQ | 64 | 可变选择子(selector)排列 | AVX2 |
VPERMPD | 32 | 可变选择子(selector)排列 | AVX2 |
VPERMPD | 64 | 可变选择子(selector)排列 | AVX2 |
VPERMI/T2B | 8 | 从两个源排列 | AVX512VBMI |
VPERMI/T2W | 16 | 从两个源排列 | AVX512BW |
VPERMI/T2D | 32 | 从两个源排列 | AVX512 |
VPERMI/T2Q | 64 | 从两个源排列 | AVX512 |
VPERMI/T2PS | 32 | 从两个源排列 | AVX512 |
VPERMI/T2PD | 64 | 从两个源排列 | AVX512 |
VPERM2I128 | 128 | 从两个源排列 | AVX2 |
VPERM2F128 | 128 | 从两个源排列 | AVX |
表13.5. 完全排列的指令
从两个不同源合并数据
指令 | 数据块大小,比特 | 描述 | 指令集 |
SHUFPS | 32 | 目标任意位置低2个双字,源任意位置高2个双字 | SSE |
SHUFPD | 64 | 目标任意位置低四字,源任意位置高四字 | SSE2 |
MOVLPS/D | 64 | 内存低四字,高四字不变 | SSE/SSE2 |
MOVHPS/D | 64 | 内存高四字,低四字不变 | SSE/SSE2 |
MOVLHPS | 64 | 源低半部的高四字,低四字不变 | SSE |
MOVHLPS | 64 | 源高半部的高四字,低四字不变 | SSE |
MOVSS | 32 | 源最低双字(仅寄存器),比特32 ~ 127不变 | SSE |
MOVSD | 64 | 源低四字(仅寄存器),高四字不变 | SSE2 |
PUNPCKLBW | 8 | 源与目标的低8字节交织 | SSE2 |
PUNPCKLWD | 16 | 源与目标的低4字交织 | SSE2 |
PUNPCKLDQ | 32 | 源与目标的低2双字交织 | SSE2 |
PUNPCKLQDQ | 64 | 源低半部的高四字,低四字不变 | SSE2 |
PUNPCKHBW | 8 | 源与目标的高8字节交织 | SSE2 |
PUNPCKHWD | 16 | 源与目标的高4字交织 | SSE2 |
PUNPCKHDQ | 32 | 源与目标的高2双字交织 | SSE2 |
PUNPCKHQDQ | 64 | 目标高半部低四字,源高半部高四字 | SSE2 |
PACKUSWB | 8 | 目标8字的低8字节,源8字的高8字节。通过无符号饱和转换。 | SSE2 |
PACKSSWB | 8 | 目标8字的低8字节,源8字的高8字节。通过有符号饱和转换。 | SSE2 |
PACKSSDW | 16 | 目标4双字的低4字,源4双字的高4字。通过有符号饱和转换。 | SSE2 |
PINSRB | 8 | 向量中插入字节 | SSE4.1 |
PINSRW | 16 | 向量中插入字 | SSE |
PINSRD | 32 | 向量中插入双字 | SSE4.1 |
INSERTPS | 32 | 向量中插入双字 | SSE4.1 |
PINSRQ | 64 | 向量中插入四字 | SSE4.1 |
VINSERTF128 | 128 | I向量中插入xmmword | AVX |
PALIGNR | 8 | 双精度偏移(类似于SHRD) | Suppl. SSE3 |
PBLENDW | 16 | 从两个不同的源混合 | SSE4.1 |
BLENDPS | 32 | 从两个不同的源混合 | SSE4.1 |
BLENDPD | 64 | 从两个不同的源混合 | SSE4.1 |
PBLENDVB | 8 | 多路选择器(Multiplexer ) | SSE4.1 |
BLENDVPS | 32 | 多路选择器 | SSE4.1 |
BLENDVPD | 64 | 多路选择器 | SSE4.1 |
VPCMOV | 1 | 多路选择器 | AMD XOP |
VPBLENDD | 32 | 多路选择器 | AVX2 |
VPBLENDMB | 8 | 使用掩码作为选择子的多路选择器 | AVX512BW |
VPBLENDMW | 16 | 使用掩码作为选择子的多路选择器 | AVX512BW |
VPBLENDMD | 32 | 使用掩码作为选择子的多路选择器 | AVX512 |
VPBLENDMQ | 64 | 使用掩码作为选择子的多路选择器 | AVX512 |
VBLENDMPS | 32 | 使用掩码作为选择子的混合 | AVX512 |
VBLENDMPD | 64 | 使用掩码作为选择子的混合 | AVX512 |
表13.6. 合并数据
向一个寄存器的所有元素广播数据
指令 | 数据块大小,比特 | 描述 | 指令集 |
PSHUFD xmm2, xmm1, 0 | 32 | 广播双字 | SSE2 |
PSHUFD xmm2, xmm1, 0EEH | 64 | 广播四字 | SSE2 |
MOVDDUP | 64 | 广播四字 | SSE3 |
MOVSLDUP | 32 | 双字0与2各2个拷贝 | SSE3 |
MOVSHDUP | 32 | 双字1与3各2个拷贝 | SSE3 |
VBROADCASTSS | 32 | 从内存广播双字 | AVX |
VBROADCASTSS | 32 | 从寄存器广播双字 | AVX512 |
VBROADCASTSD | 64 | 从内存广播四字 | AVX |
VBROADCASTSD | 64 | 从寄存器广播四字 | AVX512 |
VBROADCASTF128 | 128 | 从内存广播16字节 | AVX |
VPBROADCASTB | 8 | 从寄存器或内存广播字节 | AVX2 |
VPBROADCASTW | 16 | 从寄存器或内存广播字 | AVX2 |
VPBROADCASTD | 32 | 从寄存器或内存广播双字 | AVX2 |
VPBROADCASTQ | 64 | 从寄存器或内存广播四字 | AVX2 |
VBROADCASTF32X2 | 64 | 从寄存器或内存广播四字 | AVX512DQ |
VBROADCASTF32X4 | 128 | 从内存广播16字节 | AVX512 |
VPBROADCASTMB2Q | 8 | 向向量广播掩码寄存器 | AVX512CD |
VPBROADCASTMW2D | 16 | 向向量广播掩码寄存器 | AVX512CD |
表13.7. 移动与广播数据
除了这些指令,许多AVX512指令有一个选项来使用广播内存操作数。
从不同内存位置合并数据到一个向量
指令 | 数据块大小,比特 | 描述 | 指令集 |
VPGATHERDD | 32 | 使用双字索引收集双字 | AVX2 |
VPGATHERQD | 32 | 使用四字索引收集双字 | AVX2 |
VPGATHERDQ | 64 | 使用双字索引收集四字 | AVX2 |
VPGATHERQQ | 64 | 使用四字索引收集四字 | AVX2 |
VGATHERDPS | 32 | 使用双字索引收集双字 | AVX2 |
VGATHERQPS | 32 | 使用四字索引收集双字 | AVX2 |
VGATHERDPD | 64 | 使用双字索引收集四字 | AVX2 |
VGATHERQPD | 64 | 使用四字索引收集四字 | AVX2 |
表13.8. 收集指令
向量表查找
如果整个表可以包含在一个或几个向量寄存器里,排列指令可用于表查找。如果表太大,你必须使用更慢的收集指令进行表查找。
例子:水平加法
下面的例子展示了如何相加一个向量的所有元素
; Example 13.14a. Add 16 elements in vector of 8-bit unsigned integers
; (SSE2)
movaps xmm1, [source] ; Source vector, 16 8-bit unsigned integers
pxor xmm0, xmm0 ; 0
psadbw xmm1, xmm0 ; Sum of 8 differences
pshufd xmm0, xmm1, 0EH ; Get bit 64-127 from xmm1
paddd xmm0, xmm1 ; Sum
movd [sum], xmm0 ; Store sum
; Example 13.14b. Add eight elements in vector of 16-bit integers
; (SUPPL. SSE3)
movaps xmm0, [source] ; Source vector, 8 16-bit integers
phaddw xmm0, xmm0
phaddw xmm0, xmm0
phaddw xmm0, xmm0
movq [sum], xmm0 ; Store sum
; Example 13.14c. Add eight elements in vector of 32-bit integers
; (AVX)
vmovaps ymm0, [source] ; Source vector, 8 32-bit floats
vextractf128 xmm1, ymm0, 1 ; Get upper half
vaddps xmm0, xmm0, xmm1 ; Add
vhaddps xmm0, xmm0, xmm0
vhaddps xmm0, xmm0, xmm0
vmovsd [sum], xmm0 ; Store sum
-
- 产生常量
没有将常量移入一个XMM寄存器的指令。将常量放入XMM寄存器的缺省方式是从内存常量读入。如果缓存不命中罕见,这也是最高效的方式。但如果缓存不命中频繁,我们可能要寻找替代方案。
一个替代方案是将常量从静态内存位置拷贝到最里层循环外的栈,在最里层循环里使用栈拷贝。栈上内存位置导致缓存不命中的可能性小于常量数据段的内存位置。不过,在库函数中,这个选项也许是不可能的。
第二个替代方案是使用整数指令将常量保存到栈内存,然后从栈内存将值读入XMM寄存器。
第三个替代方案是通过聪明地使用各种指令产生常量。这不使用数据缓存,但在代码缓存中占据更多空间。代码缓存不太可能产生缓存不命中,因为代码是连续的。
常量可以重用许多次,只要别的什么不需要这个寄存器。
下面表13.9展示如何在XMM寄存器中产生各种整数常量。在向量所有元素中产生相同值。
在XMM寄存器中产生整数向量常量
值 | 8 位 | 16 位 | 32 位 | 64 位 |
0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 |
1 | pcmpeqw xmm0,xmm0 pabsb xmm0,xmm0 | pcmpeqw xmm0,xmm0 psrlw xmm0,15 | pcmpeqd xmm0,xmm0 psrld xmm0,31 | pcmpeqw xmm0,xmm0 psrlq xmm0,63 |
2 | pcmpeqw xmm0,xmm0 pabsb xmm0,xmm0 paddb xmm0,xmm0 | pcmpeqw xmm0,xmm0 psrlw xmm0,15 psllw xmm0,1 | pcmpeqd xmm0,xmm0 psrld xmm0,31 pslld xmm0,1 | pcmpeqw xmm0,xmm0 psrlq xmm0,63 psllq xmm0,1 |
3 | pcmpeqw xmm0,xmm0 psrlw xmm0,14 packuswb xmm0,xmm0 | pcmpeqw xmm0,xmm0 psrlw xmm0,14 | pcmpeqd xmm0,xmm0 psrld xmm0,30 | pcmpeqw xmm0,xmm0 psrlq xmm0,62 |
4 | pcmpeqw xmm0,xmm0 pabsb xmm0,xmm0 psllw xmm0,2 | pcmpeqw xmm0,xmm0 psrlw xmm0,15 psllw xmm0,2 | pcmpeqd xmm0,xmm0 psrld xmm0,31 pslld xmm0,2 | pcmpeqw xmm0,xmm0 psrlq xmm0,63 psllq xmm0,2 |
-1 | pcmpeqw xmm0,xmm0 | pcmpeqw xmm0,xmm0 | pcmpeqd xmm0,xmm0 | pcmpeqw xmm0,xmm0 |
-2 | pcmpeqw xmm0,xmm0 paddb xmm0,xmm0 | pcmpeqw xmm0,xmm0 psllw xmm0,1 | pcmpeqd xmm0,xmm0 pslld xmm0,1 | pcmpeqw xmm0,xmm0 psllq xmm0,1 |
其他值 | mov eax, value*01010101H movd xmm0,eax pshufd xmm0,xmm0,0 | mov eax, value*10001H movd xmm0,eax pshufd xmm0,xmm0,0 | mov eax,value movd xmm0,eax pshufd xmm0,xmm0,0 | mov rax,value movq xmm0,rax punpcklqdq xmm0,xmm0(仅64位模式) |
表13.9. 产生整数向量常量
下面表13.10展示如何在XMM寄存器中产生各种浮点常量。在向量所有元素中产生相同值:
在XMM寄存器中产生浮点常量
值 | 单精度标量 | 双精度标量 | 单精度向量 | 双精度向量 |
0.0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 | pxor xmm0,xmm0 |
0.5 | pcmpeqw xmm0,xmm0 pslld xmm0,26 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,55 psrlq xmm0,2 | pcmpeqw xmm0,xmm0 pslld xmm0,26 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,55 psrlq xmm0,2 |
1.0 | pcmpeqw xmm0,xmm0 pslld xmm0,25 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,54 psrlq xmm0,2 | pcmpeqw xmm0,xmm0 pslld xmm0,25 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,54 psrlq xmm0,2 |
1.5 | pcmpeqw xmm0,xmm0 pslld xmm0,24 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,53 psrlq xmm0,2 | pcmpeqw xmm0,xmm0 pslld xmm0,24 psrld xmm0,2 | pcmpeqw xmm0,xmm0 psllq xmm0,53 psrlq xmm0,2 |
2.0 | pcmpeqw xmm0,xmm0 pslld xmm0,31 psrld xmm0,1 | pcmpeqw xmm0,xmm0 psllq xmm0,63 psrlq xmm0,1 | pcmpeqw xmm0,xmm0 pslld xmm0,31 psrld xmm0,1 | pcmpeqw xmm0,xmm0 psllq xmm0,63 psrlq xmm0,1 |
-2.0 | pcmpeqw xmm0,xmm0 pslld xmm0,30 | pcmpeqw xmm0,xmm0 psllq xmm0,62 | pcmpeqw xmm0,xmm0 pslld xmm0,30 | pcmpeqw xmm0,xmm0 psllq xmm0,62 |
符号位 | pcmpeqw xmm0,xmm0 pslld xmm0,31 | pcmpeqw xmm0,xmm0 psllq xmm0,63 | pcmpeqw xmm0,xmm0 pslld xmm0,31 | pcmpeqw xmm0,xmm0 psllq xmm0,63 |
非符号位 | pcmpeqw xmm0,xmm0 psrld xmm0,1 | pcmpeqw xmm0,xmm0 psrlq xmm0,1 | pcmpeqw xmm0,xmm0 psrld xmm0,1 | pcmpeqw xmm0,xmm0 psrlq xmm0,1 |
其他值(32位模式) | mov eax, value movd xmm0,eax | mov eax, value>>32 movd xmm0,eax psllq xmm0,32 | mov eax, value movd xmm0,eax shufps xmm0,xmm0,0 | mov eax, value>>32 movd xmm0,eax pshufd xmm0,xmm0,22H |
其他值(64位模式) | mov eax, value movd xmm0,eax | mov rax, value movq xmm0,rax | mov eax, value movd xmm0,eax shufps xmm0,xmm0,0 | mov rax, value movq xmm0,rax shufpd xmm0,xmm0,0 |
表13.10. 产生浮点向量常量
“符号位”是一个带有符号位、其他比特都是零的值。这用于改变变量的符号设置。例如在xmm0里改变一个2*双精度向量的符号:
; Example 13.15. Change sign of 2*double vector
pcmpeqw xmm7, xmm7 ; All 1's
psllq xmm7, 63 ; Shift out the lower 63 1's
xorpd xmm0, xmm7 ; Flip sign bit of xmm0
“非符号位”是“符号位”的取反值。它的符号位是0,其他位是1。这用于获取变量的绝对值。例如获取xmm0中一个2*双精度向量的绝对值:
; Example 13.16. Absolute value of 2*double vector
pcmpeqw xmm6, xmm6 ; All 1's
psrlq xmm6, 1 ; Shift out the highest bit
andpd xmm0, xmm6 ; Set sign bit to 0
在32位模式中产生一个任意双精度值更复杂。表13.10中的方法仅用于值64位表示的高32位,假定该值较低二进制数都是0,或者一个可接受的近似。例如,要产生双精度值9.25,我们首先使用编译器或汇编器找出9.25的16字节表示是4022800000000000H。低32位可以忽略,因此我们可以这样做:
; Example 13.17a. Set 2*double vector to arbitrary value (32 bit mode)
mov eax, 40228000H ; High 32 bits of 9.25
movd xmm0, eax ; Move to xmm0
pshufd xmm0, xmm0, 22H ; Get value into dword 1 and 3
在64位模式中,我们可以使用64位整数寄存器:
; Example 13.17b. Set 2*double vector to arbitrary value (64 bit mode)
mov rax, 4022800000000000H ; Full representation of 9.25
movq xmm0, rax ; Move to xmm0
shufpd xmm0, xmm0, 0 ; Broadcast
注意,对在通用寄存器与XMM寄存器间移动64比特的指令,某些汇编器使用非常误导的名字movd,而不是movq。
-
- 访问非对齐数据与向量局部
所有使用向量寄存器读写的数据最好对齐到向量大小。如何对齐数据,参考第78页。
较旧的处理器对访问非对齐数据有性能损失,而现代处理器处理非对齐数据几乎与对齐数据一样快。
存在对齐是不可能的情形,例如,一个库函数接收一个数组指针,未知该数组是否对齐。
下面的方法可用于读非对齐向量:
使用非对齐读指令
指令MOVDQU,MOVUPS,MOVUPD与LDDQU都能读非对齐向量。在P4E与PM处理器上,LDDQU比其他都快,但在更新的处理器上不是。在较旧的Intel处理器及Intel Atom上,非对齐读指令相对慢,但在Nehalem与更新的Intel处理器,以及AMD与VIA处理器上快。
; Example 13.18. Unaligned vector read
; esi contains pointer to unaligned array
movdqu xmm0, [esi] ; Read vector unaligned
在当代处理器上,如果数据实际上是对齐的,使用非对齐指令MOVDQU,而不是MOVDQA,并没有性能损失。因此,如果你不确定数据是否对齐,使用MOVDQU是方便的。
使用VEX前缀指令
带有VEX前缀的指令允许非对齐内存操作数,而没有VEX前缀的相同指令将产生一个异常,如果操作数没有正确对齐:
; Example 13.19. VEX versus non-VEX access to unaligned data
movups xmm1, [esi] ; unaligned operand must be read first
addps xmm0, xmm1
; VEX prefix allows unaligned operand
vaddps xmm0, [esi] ; unaligned operand allowed here
如果使用了256位YMM寄存器,不应该混用VEX与非VEX代码。不过,混用AVX与AVX512指令是可以的。参考章节13.1。
将非对齐读分解为两个
读不超过8字节的指令没有对齐要求,通常相当快,除非这个读跨了缓存边界。例如:
; Example 13.20. Unaligned 16 bytes read split in two
; esi contains pointer to unaligned array
movq xmm0, qword ptr [esi] ; Read lower half of vector
movhps xmm0, qword ptr [esi+8] ; Read upper half of vector
在较旧的Intel处理器上,这个方法比使用非对齐读指令要快,但在Intel Nehalem及更新的Intel处理器是不是。没有理由在任何较新的处理器上使用这个方法。
部分重叠读
在非对齐地址进行第一个读,在后面最接近的16字节边界处进行下一个读。因此,前两个读将可能重叠:
; Example 13.21. First unaligned read overlaps next aligned read
; esi contains pointer to unaligned array
movdqu xmm1, [esi] ; Read vector unaligned
add esi, 10H
and esi, -10H ; = nearest following 16B boundary
movdqa xmm2, [esi] ; Read next vector aligned
这里,在xmm1与xmm2里的数据将部分重叠,如果esi不能被16整除。当然,这仅后面的算法允许重复数据才可用。最后的向量读也会与倒数第二重叠,如果数组末尾没有对齐。
从前面最接近的16字节边界读
从一个非对齐数组前面最接近的16字节边界开始读是可能的。无关的数据将放入向量寄存器的部分,这些数据必须被忽略。类似的,最后的读可能超出数组末尾,直到下一个16字节边界:
; Example 13.22. Reading from nearest preceding 16-bytes boundary
; esi contains pointer to unaligned array
mov eax, esi ; Copy pointer
and esi, -10H ; Round down to value divisible by 16
and eax, 0FH ; Array is misaligned by this value
movdqa xmm1, [esi] ; Read from preceding 16B boundary
movdqa xmm2, [esi+10H] ; Read next block
在上面的例子中,xmm1包含eax个垃圾字节,后跟(16 – eax)个有用的字节。Xmm2包含向量剩下的eax个字节,后跟要么是垃圾字节、要么属于下一个向量的(16 – eax)个字节。因此,eax中的值应该用于掩蔽或忽略寄存器包含垃圾数据的部分。
这里,我们利用向量大小、缓存行大小与内存页大小总是2的指数这个事实。因此,缓存行边界与内存页边界将与向量边界吻合。当我们使用这个方法读某些无关数据时,我们将不载入任何不必要的缓存行,因为缓存行总是16的某个倍数。因此,读无关数据没有代价。更重要的,我们不会从读不存在的内存地址得到一个读失败,因为总是以4096(=212)个或更多字节的页分配数据内存,因此对齐的向量读不会跨页边界。
这个方法的使用展示在www.agner.org/optimize/asmexamples.zip附录的strlenSSE2.asm例子中。
将两个非对齐向量部分合并为一个对齐向量
可以通过将两个寄存器的有效部分合并为一整个寄存器,扩展上面的方法。如果例子13.22中的xmm1右移非对齐量(eax),xmm2左移(16 – eax),那么这两个寄存器可以合并为一个仅包含有效数据的寄存器。
不幸的是,没有指令根据一个变量计数器,将整个向量寄存器向左或向右偏移(类似于shr eax, cl),因此我们必须使用一条混排指令。下一个例子使用一个字节混排指令,PSHUFB,通过合适的掩码值左移或右移一个向量寄存器:
; Example 13.23. Combining two unaligned parts into one vector.
; (Supplementary-SSE3 instruction set required)
; This example takes the squareroot of n floats in an unaligned
; array src and stores the result in an aligned array dest.
; C++ code:
; const int n = 100;
; float * src;
; float dest[n];
; for (int i=0; i<n; i++) dest[i] = sqrt(src[i]);
; Define masks for using PSHUFB instruction as shift instruction:
; The 16 bytes from SMask[16+a] will shift right a bytes
; The 16 bytes from SMask[16-a] will shift left a bytes
.data
SMask label xmmword
DB -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1
DB 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
DB -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1
.code
mov esi, src ; Unaligned pointer src
lea edi, dest ; Aligned array dest
mov eax, esi
and eax, 0FH ; Get misalignment, a
movdqu xmm4, [SMask+10H+eax] ; Mask for shift right by a
movdqu xmm5, [SMask+eax] ; Mask for shift left by 16-a
and esi, -10H ; Nearest preceding 16B boundary
xor ecx, ecx ; Loop counter i = 0
L: ; Loop
movdqa xmm1, [esi+ecx] ; Read from preceding boundary
movdqa xmm2, [esi+ecx+10H] ; Read next block
pshufb xmm1, xmm4 ; shift right by a
pshufb xmm2, xmm5 ; shift left by 16-a
por xmm1, xmm2 ; combine blocks
sqrtps xmm1, xmm1 ; compute four squareroots
movaps [edi+ecx], xmm1 ; Save result aligned
add ecx, 10H ; Loop to next four values
cmp ecx, 400 ; 4*n
jb L ; Loop
尽可能将SMask对齐到64,避免跨缓存行边界的非对齐读。
如果非对齐量(eax)已知为常量,这个方法变得更容易。我们可以使用PSRLDQ与PSLLDQ,而不是PSHUFB,将寄存器右移及左移。PSRLDQ与PSLLDQ属于SSE2指令集,而PSHUFB要求SSSE3。在偏移值是常量时,通过使用PALIGNR指令(SSSE3),指令数可以减少。在非对齐量是4,8或12时,如下面例子13.24b所示,使用MOVSS或MOVSD的某些技巧是可能的。
最快的实现有16个代码分支,eax中每个可能对齐值一个。参考www.agner.org/optimize/asmexamples.zip附录中memcpy与memset的例子。
合并同一个数组的非对齐向量部分
当在同一个数组的两个元素间执行一个操作,且这两个元素间的距离不是向量大小的倍数,非对齐是不可避免的。在下面的C++例子里,每个数组元素被加到前面的元素:
// Example 13.24a. Add each array element to the preceding element
float x[100];
for (int i = 0; i < 99; i++) x[i] += x[i+1];
这里x[i+1]不可避免地不对齐于x[i]。我们仍然可以使用合并两个对齐向量部分的方法,利用非对齐量是一个已知常量4的事实。
; Example 13.24b. Add each array element to the preceding element
xor ecx, ecx ; Loop counter i = 0
lea esi, x ; Pointer to aligned array
movaps xmm0, [esi] ; x3,x2,x1,x0
L: ; Loop
movaps xmm2, xmm0 ; x3,x2,x1,x0
movaps xmm1, [esi+ecx+10H] ; x7,x6,x5,x4
; Use trick with movss to combine parts of two vectors
movss xmm2, xmm1 ; x3,x2,x1,x4
; Rotate right by 4 bytes
shufps xmm2, xmm2, 00111001B ; x4,x3,x2,x1
addps xmm0, xmm2 ; x3+x4, x2+x3, x1+x2, x0+x1
movaps [esi+ecx], xmm0 ; save aligned
movaps xmm0, xmm1 ; Save for next iteration
add ecx, 16 ; i += 4
cmp ecx, 400-16 ; Repeat for 96 values
jb L
; Do the last odd one:
L: movaps xmm2, xmm0 ; x99,x98,x97,x96
xorps xmm1, xmm1 ; 0, 0, 0, 0
movss xmm2, xmm1 ; x99, x98, x97, 0
; Rotate right by 4 bytes
shufps xmm2, xmm2, 00111001B ; 0, x99, x98, x97
addps xmm0, xmm2 ; x99+0, x98+x99, x97+x98, x96+x97
movaps [esi+ecx], xmm0 ; save aligned
现在,我们已经讨论了读非对齐向量的几个方法。当然,我们还必须讨论如何写非对齐地址的向量。某些方法是大同小异的。
一次写8个字节
写不超过8字节的指令没有对齐要求,通常相当快,除非写跨了缓存行边界。例如:
; Example 13.26. Unaligned vector read split in two
; edi contains pointer to unaligned array
movq qword ptr [edi], xmm0 ; Write lower half of vector
movhps qword ptr [edi+8], xmm0 ; Write upper half of vector
在较旧的Intel处理器上,这个方法比使用MOVDQU快,但在Nehalem及更新的Intel处理器或现代AMD与VIA Intel处理器上不是。
部分重叠写
在非对齐地址进行第一个写,在后面最接近的16字节边界处进行下一个写。因此,前两个读将可能重叠:
; Example 13.27. First unaligned write overlaps next aligned write
; edi contains pointer to unaligned array
movdqu [edi], xmm1 ; Write vector unaligned
add edi, 10H
and edi, 0FH ; = nearest following 16B boundary
movdqa [edi], xmm2 ; Write next vector aligned
这里,在xmm1与xmm2里的数据将部分重叠,如果esi不能被16整除。当然,这仅后面的算法允许重复数据才可用。最后的向量读也会与倒数第二重叠,如果数组末尾没有对齐。参考在www.agner.org/optimize/asmexamples.zip附录中的memset例子。
分开写开头与末尾
使用非向量指令写一个非对齐数组的开头,直到第一个16字节边界,然后从最后的16字节边界写到数组末尾。参考www.agner.org/optimize/asmexamples.zip附录里的memcpy例子。
使用非对齐写指令
指令movdqu,movups与movupd都能写非对齐向量。在较旧的Intel处理器上非对齐写指令相对慢,但在Nehalem与更新的Intel处理器,以及现代的AMD与VIA处理器上快。
; Example 13.25. Unaligned vector write
; edi contains pointer to unaligned array
movdqu [edi], xmm0 ; Write vector unaligned
使用掩蔽写
指令VPMASKMOVD(AVX2)与VMASKMOVPS(AVX)等可用于写一个非对齐数组的第一部分,直到第一个16字节边界,以及最后16字节边界后的最后部分。
非VEX版本的掩蔽移动指令极慢,比如MASKMOVDQU,因为它们绕过缓存,写入主内存(所谓的非临时写)。在Intel处理器上VEX版本比非VEX版本快,但在AMD Bulldozer及Piledriver处理器上不是。
这些指令绝对应该避免。
-
- 通用寄存器中的向量操作
有时,在32位或64位通用寄存器中处理封装数据是可能的。你可以在整数操作比向量操作快的处理器上,或者没有合适的向量操作时,使用这个方法。
一个64位寄存器可以保存2个32位整数,4个16位整数,8个8位整数,或64个布尔值。当在32位或64位寄存器中的封装整数上进行计算时,如果溢出可能发生,你必须特别小心避免一个操作数的进位进入下一个操作数。如果所有操作数都是正数且很小不会发生溢出,进位就不会出现。例如,如果确定不会发生溢出,你可以把4个正16位整数封装入RAX,使用ADD RAX, RBX,而不是PADDW MM0, MM1。如果不能排除进位,那么你必须屏蔽掉最高位,如下面的例子,它将2加到EAX中的所有4个字节上:
; Example 13.28. Byte vector addition in 32-bit register
mov eax, [esi] ; read 4-bytes operand
mov ebx, eax ; copy into ebx
and eax, 7f7f7f7fh ; get lower 7 bits of each byte in eax
xor ebx, eax ; get the highest bit of each byte
add eax, 02020202h ; add desired value to all four bytes
xor eax, ebx ; combine bits again
mov [edi], eax ; store result
这里,每个字节的最高位被屏蔽了,以避免每个字节在加1时可能的进位。代码使用XOR,而不是ADD,来放回最高位,以避免进位。如果第二个加数有可能设置了最高位,它也必须被屏蔽。如果两个加数都没有最高位,不需要屏蔽。
查找一个特定字节也是可能的。这个C代码通过检查一个32位整数是否至少有一个字节为零,展示了这个原理:
// Example 13.29. Return nonzero if dword contains null byte
inline int dword_has_nullbyte(int w) {
return ((w - 0x01010101) & ~w & 0x80808080);}
如果w的所有4个字节都是非零,输出是0。输出将在第一个为零字节处有0x80。如果超过一个字节为零,无需表示后续字节。(这个方法由Alan Mycroft发明并在1987年发布。我在1996年在本手册的第一版中发布了相同的方法,不知道Mycroft在我之前做了相同的发明)。
这个原理可用于通过查找第一个零字节,找出一个零结尾字符串的长度。它比使用REPNE SCASB快:
; Example 13.30, optimized strlen procedure (32-bit):
_strlen PROC NEAR
; extern "C" int strlen (const char * s);
push ebx ; ebx must be saved
mov ecx, [esp+8] ; get pointer to string
mov eax, ecx ; copy pointer
and ecx, 3 ; lower 2 bits, check alignment
jz L2 ; s is aligned by 4. Go to loop
and eax, -4 ; align pointer by 4
mov ebx, [eax] ; read from preceding boundary
shl ecx, 3 ; *8 = displacement in bits
mov edx, -1
shl edx, cl ; make byte mask
not edx ; mask = 0FFH for false bytes
or ebx, edx ; mask out false bytes
; check first four bytes for zero
lea ecx, [ebx-01010101H] ; subtract 1 from each byte
not ebx ; invert all bytes
and ecx, ebx ; and these two
and ecx, 80808080H ; test all sign bits
jnz L3 ; zero-byte found
; Main loop, read 4 bytes aligned
L1: add eax, 4 ; increment pointer
L2: mov ebx, [eax] ; read 4 bytes of string
lea ecx, [ebx-01010101H] ; subtract 1 from each byte
not ebx ; invert all bytes
and ecx, ebx ; and these two
and ecx, 80808080H ; test all sign bits
jz L1 ; no zero bytes, continue loop
L3: bsf ecx, ecx ; find right-most 1-bit
shr ecx, 3 ; divide by 8 = byte index
sub eax, [esp+8] ; subtract start address
add eax, ecx ; add index to byte
pop ebx ; restore ebx
ret ; return value in eax
_strlen ENDP
对齐检查确保我们仅从对齐到4的地址读。这个函数可能在这个字符串之前和之后进行读,但因为所有的读都是对齐的,我们不会不必要地跨任何缓存行边界或页边界。最重要的,读超出分配的内存时,不会有任何页失败,因为页边界总是对齐到212或更大。
如果SSE2指令集可用,那么它可能比使用具有相同对齐技巧的XMM指令更快。例子13.30以及类似使用XMM寄存器的函数,在www.agner.org/optimize/asmexamples.zip的附录里。
其他查找特殊字节的普通函数,比如strcpy,strchr,memchr可用使用同样的技巧。要查找非零字节,只要与期望的字节XOR,如这个C代码所示:
// Example 13.31. Return nonzero if byte b contained in dword w
inline int dword_has_byte(int w, unsigned char b) {
w ^= b * 0x01010101;
return ((w - 0x01010101) & ~w & 0x80808080);}
注意,如果回退查找(比如strrchr),在b在w中多次出现的情形下,上面的方法将表示第一次出现的位置。