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

Is it more efficient to multiply within the address displacement or outside it?

我正在编写一个接受参数的 x86 汇编例程:

  1. [ESP+4]:后面的32位整数的个数。
  2. [ESP+8] 开始:要相加的 32 位整数列表。

它 returns 从 [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

在性能方面或其他方面,优先选择一种方式是否有任何优势?

对于现代 CPU,非常 难以对特定源的性能进行理论化。但无论如何我都会燃烧自己。

特别是我在过去十年里没有费心去学习任何关于 ASM 性能编码的知识,所以我的大部分评论都是基于对事物的零星瞥见,而不是任何透彻的知识和经验。

第零步:弄清楚您将如何分析您的代码。如果没有现实世界的分析,你将一事无成,因为我接下来将要描述的一切都会使结果变得更快或更慢,显然在不同的目标 CPU 上,但即使在同一台目标机器上——取决于可执行文件的其余部分将如何着陆,所以其他函数的对齐结果如何以及缓存将如何覆盖其他函数代码。

第一件事:在循环开始时使用对齐指令。 (或以这种方式对齐程序开始,循环的第一条指令将对齐)。多少?看起来 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在寻址中。

所以使用任何一种模式来制作剩余的代码shorter/faster。 inc edx 是 1B 长的操作码,add edx,4 是 3B 长,所以我会选择第一个,因为在复杂的可执行文件中,较短的机器码更适合缓存,并且不应该有任何性能incadd 之间在现代 x86 上的区别 - 当与其余代码隔离开来考虑时。如果不孤立地考虑,,但最近几代又可以了,所以它应该和 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,由于大型代码的缓存污染,不会更高。


然后,如果参数的数量相当多,您可能会结束等待内存加载大部分循环的值。

然后上面的任何代码变体都将以相同的性能结束,因为内存缓存未命中相当于数十到数百条指令,而不是寻址模式下的单指令细微差别。

我是否警告过您,这非常棘手?我做到了,在第一句话中。


结论:

别管这个,你在浪费时间。

只需编写最简单、最易读的正确源代码(在您的特定情况下,我更喜欢 *4 变体和 "index" 类源代码)。

完成申请后,对其进行分析。

解决真正的瓶颈。

在二进制机器代码中,比例因子被编码为 2 位移位计数(这就是为什么只支持从 0 到 3 的 2 的幂,而不支持任意乘数的原因)。所以[esp+edx]在机器码中真的编码为[esp+edx*1]:还有一个shift-amount,只是设置为0.

Shift-count=0(即scale-factor=1)不是硬件的特例,因为移位对于硬件来说真的很容易。所以实际上,就硬件的内部行为而言,您的两个循环都使用相同的寻址模式。

所以@Ped7g 是对的:循环之间的区别在于使用 inc 而不是 add 来节省代码大小。


实际加速

参见 tag wiki for performance links, especially 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

add eax, dword ptr [edx] 甚至可以在 Sandybridge 上进行微融合,CMP/JB 可以在比 DEC/JNZ 更多的 CPU 上进行宏融合。 (AMD,和英特尔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 的比例因子。(我也可以用 and ecx, ~1 屏蔽 ecx清除低位而不是移动它,然后用更高的比例因子进行补偿。)