[英]Indexed branch overhead on X86 64 bit mode
這是對先前線程中的一些評論的跟進:
以下代碼片段計算斐波那契數,第一個示例帶有循環,第二個示例帶有計算跳轉(索引分支)進入展開循環。 這是在帶有 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 填充(請參閱下面的評論)。 計算的跳躍版本大約需要 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.4 倍。 IDK 究竟為什么用 1 周期add
循環攜帶的依賴鏈來累積結果會產生如此大的不同。 嘗試有趣的事情:將它存儲到內存中(例如將它分配給一個volatile int discard
),這樣 asm dep 鏈就不會只是以一個被破壞的寄存器結束。 硬件可能會優化它(例如,一旦確定結果已死,就丟棄 uops)。 英特爾表示 Sandybridge-family 可以為shl reg,cl
中的標志結果 uops 之一做到這一點。
舊答案:為什么計算的跳轉比結果未使用的循環快 1.4 倍
您在這里測試的是吞吐量,而不是延遲。 在我們之前的討論中,我主要關注延遲。 那可能是個錯誤; 吞吐量對調用者的影響通常與延遲一樣重要,這取決於調用者在執行多少操作后對結果有數據依賴性。
亂序執行隱藏了延遲,因為一次調用的結果不是 arg 到下一次調用的輸入依賴性。 並且 IvyBridge 的亂序窗口足夠大,可以在這里有用: 168-entry ROB(從 issue 到退休),和 54-entry scheduler(從 issue 到 execute) ,以及一個 160-entry 的物理寄存器文件。 另請參閱PRF 與 OOO 窗口大小的 ROB 限制。
OOO 執行還隱藏了在任何 Fib 工作完成之前分支錯誤預測的成本。 來自最后一個fib(n)
dep 鏈的工作仍在進行中,並且在該錯誤預測期間正在處理中。 (現代 Intel CPU 只回滾到錯誤預測的分支,並且可以在錯誤預測得到解決時繼續從分支之前執行 uops。)
計算分支版本在這里很好是有道理的,因為您主要在 uop 吞吐量方面遇到瓶頸,並且循環退出分支的錯誤預測成本與進入展開版本時的間接分支錯誤預測的成本大致相同。 IvB 可以將sub/jcc
宏融合為端口 5 的單個 uop,因此 40% 的數字非常匹配。 (3 個 ALU 執行單元,因此在循環開銷上花費 1/3 或您的 ALU 執行吞吐量解釋了這一點。分支錯誤預測差異和 OOO 執行的限制解釋了其余部分)
我認為在大多數實際用例中,延遲可能是相關的。 也許吞吐量仍然是最重要的,但除此之外的任何事情都會使延遲變得更加重要,因為這甚至根本不使用結果。 當然,在從間接分支錯誤預測中恢復時,管道中會有先前的工作可以處理是正常的,但這會延遲結果准備就緒,這可能意味着如果fib()
回報取決於結果。 但是,如果他們不是(如大量的重載和在哪里把結果地址的計算),具有前端開始從后發出的微指令fib()
遲早是一件好事。
我認為這里的一個很好的中間立場是展開 4 或 8 次,在展開循環之前進行檢查以確保它應該運行一次。 (例如sub rcx,8
/ jb .cleanup
)。
另請注意,您的循環版本對初始值的n
具有數據依賴性。 在我們之前的討論中, 我指出避免這種情況對於亂序執行會更好,因為它讓add
鏈在n
准備好之前開始工作。 我認為這不是一個重要因素,因為調用者對n
延遲很低。 但它確實將循環分支錯誤預測放在n
-> fib(n)
dep 鏈的末尾而不是中間退出循環。 (如果sub ecx, 2
低於零而不是零sub ecx, 2
我正在想象循環后的無cmov
lea
/ cmov
再進行一次迭代。)
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.