我正在编写一个x86汇编例程,它接受参数:
[ESP+4]
:跟随的32位整数的数量。[ESP+8]
开始:要添加的32位整数列表。它返回从[ESP+8]
开始传递的所有整数的总和。所以,基本上,函数的C原型将是:
int addN(int numberofitems, ...);
我可以选择以两种方式编写这个x86汇编程序:
第一种方式(按项目大小乘以地址位移):
addN PROC
mov ecx, dword ptr [esp+4] ; Dec ecx per argument processed
mov edx, 2 ; Offset into variable length argument list
xor eax, eax ; Accumulator
AdderLoop:
add eax, dword ptr [esp+edx*4]
inc edx
dec ecx
jnz AdderLoop
ret
addN ENDP
第二种方式(项目的大小自身添加到edx
):
addN PROC
mov ecx, dword ptr [esp+4] ; Dec ecx per argument processed
mov edx, 8 ; Offset into variable length argument list
xor eax, eax ; Accumulator
AdderLoop:
add eax, dword ptr [esp+edx]
add edx, 4
dec ecx
jnz AdderLoop
ret
addN ENDP
是否有任何优势,无论是表现还是其他方面,都倾向于选择一种方式而不是另一种方式?
答案 0 :(得分:3)
对于现代CPU来说,理解特定源的性能是非常非常棘手。但无论如何我会烧自己。
特别是因为我在过去十年中没有学过任何关于ASM性能编码的知识,所以我的大部分评论都是基于对这里和那里的微小瞥见,而不是任何全面的知识和经验。
第0步:弄清楚,您将如何分析您的代码。没有真实世界的分析,你将无处可去,因为我接下来将描述的所有内容都可以使结果更快更慢,显然在不同的目标CPU上,但即使在同一台目标机器上 - 取决于可执行文件的其余部分将如何着陆,所以如何对其他函数进行对齐以及缓存如何覆盖其他函数代码。
首先:在循环开始时使用align指令。 (或以这种方式对齐程序,循环的第一条指令将对齐)。多少?看起来16通常会加速当前大多数CPU的速度。这可能会对性能产生实际影响,但不仅仅是积极的,建议仅使用频繁分支的地址。
细微之处:
让我们测试一些变体,它们如何编译成机器代码:
0: 8b 04 94 mov eax,DWORD PTR [esp+edx*4]
3: 8b 04 14 mov eax,DWORD PTR [esp+edx*1]
6: 8b 04 24 mov eax,DWORD PTR [esp]
9: 8b 44 95 00 mov eax,DWORD PTR [ebp+edx*4+0x0]
d: 8b 44 15 00 mov eax,DWORD PTR [ebp+edx*1+0x0]
11: 8b 45 00 mov eax,DWORD PTR [ebp+0x0]
正如您所看到的,*4
vs *1
变体具有相同的长度,并且性能相同,因此您不必担心*4
在解决。
因此,使用哪种模式可以使其余代码更短/更快。 inc edx
是1B长操作码,add edx,4
是3B长,所以我会选择第一个,因为在复杂的可执行文件中,较短的机器代码将更好地适应缓存,并且不应该是现代x86在inc
和add
之间的任何性能差异 - 当与其余代码隔离开来时。如果不是孤立地考虑inc
was evil on the Intel Pentium 4 CPUs a few years back,但最近几代人再次确定,那么它应该与add
一样快。
(现在我注意到你使用了add eax,...
,所以再一次解决了一个不同的变种:
0: 42 inc edx
1: 83 c2 04 add edx,0x4
4: 03 04 94 add eax,DWORD PTR [esp+edx*4]
7: 03 44 95 00 add eax,DWORD PTR [ebp+edx*4+0x0]
b: 03 04 14 add eax,DWORD PTR [esp+edx*1]
e: 03 44 15 00 add eax,DWORD PTR [ebp+edx*1+0x0]
12: 03 45 00 add eax,DWORD PTR [ebp+0x0]
现在我以为我看到了通过esp
有额外前缀字节进行寻址的事情,但我在这里看不到它,所以它可能在16b?这也是我测试ebp
变体的原因,以摆脱esp
。但由于esp
具有较短的机器代码(ebp
强制执行位移+0x0
字节使用),我会保持它就像您现在使用它一样。
在一些较旧的CPU上,交织相关指令可能会更快:
AdderLoop:
add eax, dword ptr [esp+edx*4]
dec ecx
lea edx, [edx+1]
jnz AdderLoop
但是,后期架构使用了一些名为"macro-fusion"的指令,dec + jnz
对现在应该保持在一起。
如果你知道参数的数量在大部分时间都相当大(不太可能,因为它会溢出结果值),你当然可以循环几次迭代(4,8或16,不会&# 39;由于大代码导致缓存污染,因此会更高。)
然后,如果参数的数量相当高,你可能会等待内存加载大部分循环的值。
然后上面的任何代码变体都会以相同的性能结束,因为内存缓存缺失值数十到数百条指令,而不是寻址模式下的单指令细微差别。
我是否警告过你,这很棘手?我在第一句话中做到了。
不要为此烦恼,你在浪费时间。
只需编写最简单,最可读的来源(在您的特定情况下,我更喜欢使用" index" -like source的*4
变体。
完成申请后,请对其进行分析。
修复真正的瓶颈。
答案 1 :(得分:3)
在二进制机器代码中,比例因子被编码为2位移位计数(这就是为什么只支持从0到3的2的幂,而不是任意乘法器)。因此,机器代码中的[esp+edx]
实际上编码为[esp+edx*1]
:仍有移位金额,但它已设置为0.
Shift-count = 0(即比例因子= 1)对于硬件而言不是特殊情况,因为移位对于硬件来说非常容易。实际上,就你的内部硬件行为而言,你的两个循环都使用相同的寻址模式。
所以@ Ped7g是对的:您的循环之间的区别仅在于使用inc
代替add
来保存代码大小。
实际加速
请参阅x86代码维基以获取效果链接,尤其是Agner Fog's guides。
显然,使用SSE2或AVX2向量对数组求和会更快。使用PADDD。 (而且由于你需要一次使用16B,你不能使用INC和比例因子。你可以加4,并使用比例因子4。)
避免完全使用索引寻址模式会更有效。 Intel Sandybridge-family CPUs before Skylake can't micro-fuse indexed addressing modes
addN PROC
mov ecx, dword ptr [esp+4] ; length
lea edx, [esp+8] ; start of args
lea ecx, [edx + ecx*4] ; end pointer
xor eax, eax ; Accumulator
AdderLoop: ; do{
add eax, dword ptr [edx]
add edx, 4
cmp edx, ecx
jb AdderLoop ; } while(p < endp)
ret
addN ENDP
即使在Sandybridge上,add eax, dword ptr [edx]
也可以微融合,而CMP / JB可以在比DEC / JNZ更多的CPU上进行宏观融合。 (AMD和Intel Core2 / Nehalem只能融合CMP / JB)。请注意,这会花费我们在循环外的额外指令。
您甚至可以通过向上计数减少循环内的指令数量,并使用该计数器从数组末尾开始索引。或者,既然你只是对数组进行求和,那么顺序并不重要,你可以向下循环:
addN PROC
mov ecx, dword ptr [esp+4] ; length
xor eax, eax ; Accumulator
AdderLoop: ; do{
add eax, dword ptr [esp+8 + ecx*4-4] ; the +8 and -4 reduce down to just +4, but written this way for clarity.
dec ecx
jnz AdderLoop ; } while(idx != 0)
ret
addN ENDP
由于现代x86 CPU每个时钟可以执行两次加载,因此我们只需要在不展开的情况下获得一半的吞吐量。此技术适用于所有索引方法。
(这实际上并不是最优的。它演示了我之前提到过的向上计数向上的技术。在意识到向下循环最好之后,我没有时间重写这个。)
;; untested: unroll by two with a clever way to handle the odd element
addN PROC
mov ecx, dword ptr [esp+4] ; length
lea edx, [esp+8 + ecx*4] ; one-past-the-end
xor eax, eax ; sum1
push esi
xor esi, esi ; sum2
;; Unrolling means extra work to handle the case where the length is odd
shr ecx, 1 ; ecx /= 2, shifting the low bit into CF
cmovc eax, [esp+8] ; sum1 = first element if count was odd
neg ecx ; edx + ecx*8 == 1st or 2nd element
AdderLoop: ; do{
add eax, dword ptr [edx + ecx*8]
add esi, dword ptr [edx + ecx*8 + 4]
inc ecx
jl AdderLoop ; } while(idx < 0)
add eax, esi
pop esi
ret
addN ENDP
在某些CPU上,这应该运行速度快两倍(如果L1缓存中的数据很热)。使用多个累加器(在本例中为EAX和ESI)是一种非常有用的技术,用于更高延迟的操作,如FP add。我们这里只需要两个,因为整数ADD在每个x86微体系结构上都有1个周期延迟。
在Intel pre-Skylake上,使用非索引寻址模式(和add edx, 8
)会更好,因为每个循环有两个内存寻址操作,但仍然只有一个分支(需要CMP / JB而不是通过递增索引来测试标志。)
展开时,只需使用未展开的循环来处理第一次或最后一次遗留迭代,这种情况就更常见了。我能够使用移位和CMOV初始化其中一个累加器,因为我们只展开2,索引寻址模式达到8的比例因子。(我也可以使用{{1来掩盖ecx清除低位而不是移位,然后用更高的比例因子进行补偿。)