X86 64位模式下的索引分支开销

时间:2017-11-01 02:51:35

标签: performance optimization x86-64

这是对此前一个帖子中的一些评论的跟进:

Recursive fibonacci Assembly

以下代码片段计算Fibonacci,第一个带循环的示例,第二个带有计算跳转(索引分支)的示例到展开循环中。这是使用Visual Studio 2015 Desktop Express在Windows 7 Pro 64位模式下使用Intel 3770K 3.5ghz处理器进行测试的。使用单个循环测试fib(0)到fib(93),我获得的循环版本的最佳时间是~1.901微秒,并且对于计算的跳跃是~1.324微秒。使用外部循环重复此过程1,048,576次,循环版本大约需要1.44秒,计算的跳转大约需要1.04秒。在两组测试中,循环版本比计算的跳转版本慢约40%。

问题:为什么循环版本比计算的跳转版本对代码位置更敏感?在先前的测试中,一些代码位置组合导致循环版本时间从大约1.44秒增加到1.93秒,但我从未发现组合显着影响计算的跳转版本时间。

部分答案:计算出的跳转版本分支到280字节范围内的94个可能的目标位置,显然分支目标缓冲区(缓存)可以很好地优化它。对于循环版本,使用align 16将基于程序集的fib()函数放在16字节边界上解决了大多数情况下的循环版本时间问题,但对main()的一些更改仍然影响时间。我需要找到一个相当小且可重复的测试用例。

循环版本(注意我已经读过| dec | jnz |比| loop |更快:

        align   16
fib     proc                            ;rcx == n
        mov     rax,rcx                 ;br if < 2
        cmp     rax,2
        jb      fib1
        mov     rdx,1                   ;set rax, rdx
        and     rax,rdx
        sub     rdx,rax
        shr     rcx,1
fib0:   add     rdx,rax
        add     rax,rdx
        dec     rcx
        jnz     fib0
fib1:   ret     
fib     endp

计算跳转(索引分支)到展开循环版本:

        align   16
fib     proc                            ;rcx == n
        mov     r8,rcx                  ;set jmp adr
        mov     r9,offset fib0+279
        lea     r8,[r8+r8*2]
        neg     r8
        add     r8,r9
        mov     rax,rcx                 ;set rax,rdx
        mov     rdx,1
        and     rax,rdx
        sub     rdx,rax
        jmp     r8
fib0:   ; assumes add xxx,xxx takes 3 bytes
        rept    46
        add     rax,rdx
        add     rdx,rax
        endm
        add     rax,rdx
        ret
fib     endp

使用37%93的倍数测试运行100万(1048576)次循环以计算fib(0)fib(93)的代码,因此顺序不是连续的。在我的系统上,循环版本大约需要1.44秒,索引分支版本需要大约1.04秒。

#include <stdio.h>
#include <time.h>

typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;

extern "C" uint64_t fib(uint64_t);

/* multiples of 37 mod 93 + 93 at end */
static uint64_t a[94] = 
     {0,37,74,18,55,92,36,73,17,54,
     91,35,72,16,53,90,34,71,15,52,
     89,33,70,14,51,88,32,69,13,50,
     87,31,68,12,49,86,30,67,11,48,
     85,29,66,10,47,84,28,65, 9,46,
     83,27,64, 8,45,82,26,63, 7,44,
     81,25,62, 6,43,80,24,61, 5,42,
     79,23,60, 4,41,78,22,59, 3,40,
     77,21,58, 2,39,76,20,57, 1,38,
     75,19,56,93};

/* x used to avoid compiler optimizing out result of fib() */
int main()
{
size_t i, j;
clock_t cbeg, cend;
uint64_t x = 0;
    cbeg = clock();
    for(j = 0; j < 0x100000; j++)
        for(i = 0; i < 94; i++)
            x += fib(a[i]);
    cend = clock();
    printf("%llx\n", x);
    printf("# ticks = %u\n", (uint32_t)(cend-cbeg));
    return 0;
}

x的输出为0x812a62b1dc000000。十六进制的fib(0)到fib(93)的总和是0x1bb433812a62b1dc0,并且为循环0x100000次增加5个零:0x1bb433812a62b1dc000000。由于64位数学运算,上面的6个半字节被截断。

我制作了一个所有程序集版本,以便更好地控制代码位置。 &#34;如果1&#34;更改为&#34;如果为0&#34; for loop版本。循环版本大约需要1.465到2.000秒,具体取决于用于将关键位置放在偶数或奇数16字节边界上的nop填充(请参阅下面的注释)。计算出的跳转版本大约需要1.04秒,边界的时间差异小于1%。

        includelib msvcrtd
        includelib oldnames

        .data
; multiples of 37 mod 93 + 93 at the end
a       dq      0,37,74,18,55,92,36,73,17,54
        dq     91,35,72,16,53,90,34,71,15,52
        dq     89,33,70,14,51,88,32,69,13,50
        dq     87,31,68,12,49,86,30,67,11,48
        dq     85,29,66,10,47,84,28,65, 9,46
        dq     83,27,64, 8,45,82,26,63, 7,44
        dq     81,25,62, 6,43,80,24,61, 5,42
        dq     79,23,60, 4,41,78,22,59, 3,40
        dq     77,21,58, 2,39,76,20,57, 1,38
        dq     75,19,56,93
        .data?
        .code
;       parameters      rcx,rdx,r8,r9
;       not saved       rax,rcx,rdx,r8,r9,r10,r11
;       code starts on 16 byte boundary
main    proc
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbp
        mov     rbp,rsp
        and     rsp,0fffffffffffffff0h
        sub     rsp,64
        mov     r15,offset a
        xor     r14,r14
        mov     r11,0100000h
;       nop padding effect on loop version (with 0 padding in padx below)
;        0 puts main2 on  odd 16 byte boundary  clk = 0131876622h => 1.465 seconds
;        9 puts main1 on  odd 16 byte boundary  clk = 01573FE951h => 1.645 seconds
        rept    0
        nop
        endm
        rdtsc
        mov     r12,rdx
        shl     r12,32
        or      r12,rax
main0:  xor     r10,r10
main1:  mov     rcx,[r10+r15]
        call    fib
main2:  add     r14,rax
        add     r10,8
        cmp     r10,8*94
        jne     main1
        dec     r11
        jnz     main0
        rdtsc
        mov     r13,rdx
        shl     r13,32
        or      r13,rax
        sub     r13,r12
        mov     rdx,r14
        xor     rax,rax
        mov     rsp,rbp
        pop     rbp
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        ret
main    endp

        align   16
padx    proc
;       nop padding effect on loop version with 0 padding above
;        0 puts fib on  odd 16 byte boundary    clk = 0131876622h => 1.465 seconds
;       16 puts fib on even 16 byte boundary    clk = 01A13C8CB8h => 2.000 seconds
;       nop padding effect on computed jump version with 9 padding above
;        0 puts fib on  odd 16 byte boundary    clk = 00D979792Dh => 1.042 seconds
;       16 puts fib on even 16 byte boundary    clk = 00DA93E04Dh => 1.048 seconds
        rept    0
        nop
        endm
padx    endp

        if      1       ;0 = loop version, 1 = computed jump version

fib     proc                            ;rcx == n
        mov     r8,rcx                  ;set jmp adr
        mov     r9,offset fib0+279
        lea     r8,[r8+r8*2]
        neg     r8
        add     r8,r9
        mov     rax,rcx                 ;set rax,rdx
        mov     rdx,1
        and     rax,rdx
        sub     rdx,rax
        jmp     r8
fib0:   ; assumes add xxx,xxx takes 3 bytes
        rept    46
        add     rax,rdx
        add     rdx,rax
        endm
        add     rax,rdx
        ret
fib     endp

        else

fib     proc                            ;rcx == n
        mov     rax,rcx                 ;br if < 2
        cmp     rax,2
        jb      fib1
        mov     rdx,1                   ;set rax, rdx
        and     rax,rdx
        sub     rdx,rax
        shr     rcx,1
fib0:   add     rdx,rax
        add     rax,rdx
        dec     rcx
        jnz     fib0
fib1:   ret     
fib     endp

        endif
        end

1 个答案:

答案 0 :(得分:1)

这是原始问题的答案,关于为什么当结果完全未使用时,循环占用计算跳转版本时间的1.4倍。 IDK正是为什么用1周期add循环携带的依赖链来累积结果会产生很大的不同。有趣的事情要尝试:将其存储到内存中(例如将其分配给volatile int discard),这样asm dep链就不会以破坏的寄存器结束。硬件可能会优化它(例如,一旦确定结果已经死亡,就丢弃它)。 Intel says Sandybridge-family can do that for one of the flag-result uops in shl reg,cl

旧答案:为什么计算跳转比未使用结果的循环快1.4倍

您在这里测试吞吐量,而不是延迟。在我们之前的讨论中,我主要关注延迟。这可能是一个错误;吞吐量对调用者的影响通常与延迟相关,具体取决于调用者在对结果产生数据依赖之后所执行的操作量。

乱序执行会隐藏延迟,因为一次调用的结果不是arg对下一次调用的输入依赖性。而IvyBridge的无序窗口足够大,可以在这里使用:168-entry ROB (from issue to retirement), and 54-entry scheduler (from issue to execute)和一个160项的物理寄存器文件。另请参阅PRF vs. ROB limits for OOO window size

在任何Fib工作完成之前,OOO执行还隐藏了分支错误预测的成本。来自最后 fib(n) dep链的工作仍在进行中,并在错误预测期间进行处理。 (现代英特尔CPU仅回滚到错误预测的分支,并且可以在错误预测解决时继续从分支之前执行uops。)

计算分支版本在这里是有意义的,因为你在uop吞吐量方面主要是瓶颈,而来自loop-exit分支的错误预测与进入的间接分支错误预测大致相同。展开的版本。 IvB可以将sub/jcc宏链接到端口5的单个uop,因此40%的数字非常匹配。 (3个ALU执行单元,因此在循环开销上花费1/3或ALU执行吞吐量就可以解释它。分支错误预测差异和OOO执行的限制解释了其余部分)

我认为在大多数实际使用案例中,延迟可能是相关的。也许吞吐量仍然是最重要的,但除此之外的任何事情都会使延迟更多重要,因为这根本不会使用结果。当然,正常情况下,管道中的先前工作可以在间接分支误预测恢复的情况下进行,但这会延迟结果准备就绪,这可能意味着如果大部分fib()返回后的指令取决于结果。但是如果它们不是(例如,很多重新加载和计算地址以便放置结果),让前端开始从fib()之后开始发布uops是好事。

我认为这里有一个好的中间地带是4或8的展开,在展开的循环之前检查以确保它应该运行一次。 (例如sub rcx,8 / jb .cleanup)。

另请注意,循环版本对n的初始值具有数据依赖性。在我们之前的讨论中,I pointed out避免这种情况对于无序执行会更好,因为它允许add链在n准备就绪之前开始工作。我认为这不是一个重要因素,因为调用者对n的延迟较低。但它确实将循环分支错误预测置于n - >结束时退出循环。 fib(n) dep链而不是中间。 (如果lea低于零而不是零,我会在循环之后想象一个无分支cmov / sub ecx, 2进行一次迭代。)