【问题标题】:Indexed branch overhead on X86 64 bit modeX86 64 位模式下的索引分支开销
【发布时间】:2018-04-13 15:12:21
【问题描述】:

这是对之前这个帖子中的一些 cmets 的跟进:

Recursive fibonacci Assembly

以下代码 sn-ps 计算斐波那契,第一个示例带有循环,第二个示例带有计算跳转(索引分支)到展开循环。这是在带有 Intel 3770K 3.5ghz 处理器的 Windows 7 Pro 64 位模式下使用 Visual Studio 2015 Desktop Express 进行测试的。通过单循环测试 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

运行 100 万 (1048576) 次循环的测试代码使用 37%93 的倍数计算 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,并添加 5 个零以循环 0x100000 次:0x1bb433812a62b1dc000000。由于 64 位数学运算,前 6 个半字节被截断。

我制作了一个全汇编版本以更好地控制代码位置。循环版本的“if 1”更改为“if 0”。循环版本大约需要 1.465 到 2.000 秒,具体取决于用于将关键位置放置在偶数或奇数 16 字节边界上的 nop 填充(参见下面的 cmets)。计算出的跳跃版本大约需要 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

【问题讨论】:

  • @PeterCordes - 我更新了我的帖子。显然代码对齐影响了循环版本。因此,通过强制对齐,循环恢复到 1.43 秒,索引分支恢复到 1.03 秒
  • 现代分支预测方法,包括间接分支,例如 TAGE 和 ITAGE,可能会将一个分支的历史分散到大量内部状态。在原型设计中,具有足够不同历史的分支可以使用大部分分支预测器进行预测(受方式数量限制)。这就是现代预测器背后的秘诀之一:每台 PC 的存储空间不受 BP 状态的一小部分限制,而是可以在有用的情况下扩展。
  • 对条件分支和间接分支都可以做到这一点的一种方法是对 路径历史记录 进行哈希处理。这基本上只是最后 N 次跳转目标的哈希值。对于与“取 1 位/不是每个分支”方法不同但同样强大的条件分支。它可以处理控制流收敛的情况(例如,不同 PC 上的两个分支跳转到同一个位置,然后有另一个分支):它将这些分开,而 T/N 方法将它们视为相同。另一方面...
  • @PeterCordes - 在条件分支的相同情况下,使用随机输入,我必须在预测器开始失败之前使循环有 数千 的周期。例如,成功地预测了 1000 个随机条件分支的周期性循环非常。历史长度不需要接近 1000 位:它只需要足够长以唯一地识别周期中的 1000 个位置,对于合理的速率,这可能类似于 lg(1000) + 1 = ~11 位.循环退出预测不会接近 1000,因为历史是低熵:它们是最坏的情况。
  • FWIW,您观察到在 94 周期循环中的未命中率约为 1.8%。要获得 1.8% 的“虚假混叠”率,需要一个包含约 5000 个元素的查找表,这意味着最小历史大小刚好超过 12 位。即使在混叠的情况下,您也可能有 50% 的机会获得正确的目标,因为您通常会使用其他 1 个分支进行混叠,并且它们实现“反乒乓”算法,因此实际表可能只有一半。不过,总表存储限制了这些完全随机的情况,在 Skylake 上拥有约 2500 个 i-branch 条目似乎是合理的。

标签: performance optimization x86-64 micro-optimization branch-prediction


【解决方案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

OOO 执行还会在任何 Fib 工作完成之前隐藏分支错误预测的成本。 last fib(n) dep 链的工作仍在进行中,并在该错误预测期间进行处理。 (现代 Intel CPU 只回滚到错误预测的分支,并且可以在错误预测被解决的同时继续执行分支之前的微指令。)

计算分支版本在这里很好是有道理的,因为您主要是 uop 吞吐量的瓶颈,并且循环退出分支的错误预测与进入展开的间接分支错误预测的成本大致相同版本。 IvB 可以将sub/jcc 宏融合到端口 5 的单个微指令中,因此 40% 的数字非常匹配。 (3 个 ALU 执行单元,因此在循环开销上花费 1/3 或 ALU 执行吞吐量就可以解释这一点。分支错误预测差异和 OOO 执行的限制解释了其余部分)


我认为在大多数实际用例中,延迟可能是相关的。也许吞吐量仍然是最重要的,但除此之外的任何事情都会使延迟更加变得重要,因为这甚至根本不使用结果。当然,在恢复间接分支错误预测时,管道中可以处理以前的工作是正常的,但这会延迟准备好的结果,这可能意味着如果大多数指令在@987654330 之后会停止@ 返回取决于结果。但如果它们不是(例如,大量重新加载和计算将结果放在哪里的地址),让前端从fib() 之后尽快开始发出微指令是一件好事。

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


另请注意,您的循环版本的初始值依赖于 n。在我们之前的讨论中,I pointed out 认为避免这种情况对于乱序执行会更好,因为它让add 链在n 准备好之前开始工作。我认为这不是一个重要因素,因为调用者对n 的延迟很低。但它确实将循环分支错误预测放在了n -> fib(n) dep 链的末尾而不是中间退出循环。 (如果sub ecx, 2 低于零而不是零,我正在描绘一个无分支的lea / cmov 在循环之后进行一次迭代。)

【讨论】:

    猜你喜欢
    • 2017-01-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-05-22
    • 1970-01-01
    • 2016-05-12
    • 2023-03-22
    相关资源
    最近更新 更多