【发布时间】:2018-04-13 15:12:21
【问题描述】:
这是对之前这个帖子中的一些 cmets 的跟进:
以下代码 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