assembly 在地址位移内乘法还是在地址位移外乘法效率更高?

2uluyalo  于 2023-01-02  发布在  其他
关注(0)|答案(2)|浏览(118)

我正在编写一个x86汇编例程,它的参数为:
1.在[ESP+4]处:后面的32位整数的个数。
1.从[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

在性能方面或其他方面,偏好一种方式胜过另一种方式是否有任何优势?

u1ehiz5o

u1ehiz5o1#

在二进制机器码中,比例因子编码为2位移位计数(这就是为什么只支持2的0到3的幂,而不支持任意乘法器),因此机器码中的[esp+edx]实际上编码为[esp+edx*1]:仍然存在偏移量,但它被设置为0。
移位计数=0(即比例因子=1)不是硬件的特例,因为移位对硬件来说非常容易。因此,就硬件内部行为而言,两个环路实际上使用的是相同的寻址模式。
所以@Ped7g是对的:循环之间的区别在于使用inc而不是add可以节省代码长度。

实际加速

查看x86标签wiki以获取性能链接,特别是Agner Fog's guides
显然,使用SSE 2或AVX 2向量对数组求和会快得多,使用PADDD(由于需要一次处理16 B,因此不能同时使用INC和比例因子,可以使用4加,比例因子为4)。
完全避免使用索引寻址模式会更有效,Skylake之前的Intel Sandybridge系列CPU不能微熔丝索引寻址模式。

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

add eax, dword ptr [edx]甚至可以在Sandybridge上进行微熔丝,CMP/JB可以在比DEC/JNZ更多的CPU上进行宏熔丝。(AMD和Intel Core 2/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每个时钟周期可以执行两次加载,如果不展开,我们只能获得一半的吞吐量。
(This实际上并不是最优的。它演示了我之前提到的从上到零的技术。在意识到向下循环是最好的之后,我没有时间重写这个。)

;; 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。(我也可以用and ecx, ~1屏蔽ECX,清除低位,而不是移位,然后用更高的比例因子进行补偿。)

voase2hg

voase2hg2#

用现代的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*1变体具有相同的长度,性能也相同,因此在寻址时不必担心*4
因此,请使用任何一种模式,使其余代码更短/更快。inc edx是1B长的操作码,add edx,4是3B长的操作码,所以我会选择第一种模式,因为在复杂的可执行文件中,较短的机器码更适合缓存,并且在现代x86上,incadd之间不应该有任何性能差异--当与其余代码隔离考虑时。如果不孤立地考虑inc was evil on the Intel Pentium 4 CPUs a few years back,但最近几代产品也可以,因此它应该与add一样快。
(Now我确实注意到您使用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次,由于大代码的缓存污染,不会再高了)。
同样,如果参数的数量相当多,您可能会停止等待内存加载大部分循环的值。
然后,上面的任何代码变体都将以相同的性能结束,因为内存缓存未命中值数十到数百条指令,而不是寻址模式中的单指令细微差别。
我警告过你吗,这很棘手?我警告过,在第一句话里。

结论:

别为这事费心了,你在浪费时间。
只需编写最简单、可读性最强的正确源代码(在您的特定情况下,我更喜欢带有类似"index"的源代码的*4变体)。
一旦你完成了你的申请,分析它。
解决真正的瓶颈。

相关问题