在地址位移内或外部乘法更有效率吗?

时间:2016-11-26 19:37:57

标签: assembly optimization x86 offset cpu-registers

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

  1. [ESP+4]:跟随的32位整数的数量。
  2. [ESP+8]开始:要添加的32位整数列表。
  3. 它返回从[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
    

    是否有任何优势,无论是表现还是其他方面,都倾向于选择一种方式而不是另一种方式?

2 个答案:

答案 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在incadd之间的任何性能差异 - 当与其余代码隔离开来时。如果不是孤立地考虑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来保存代码大小。

实际加速

请参阅代码维基以获取效果链接,尤其是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清除低位而不是移位,然后用更高的比例因子进行补偿。)