破解/優化C++程式碼:消除冗餘程式碼
這篇文章講述了消除冗餘程式碼(Dead Code Elimination)的優化方法,我簡寫為DCE。顧名思義:只要其計算結果沒有被程式所使用, 該計算就被丟棄。
這時你可能會說,你的程式碼只會計算有用的結果,從沒有無用的東西,只有白痴才會平白無故地新增那些無用的程式碼—–例如,會在做一些有用事情的同時,還在計算著圓周率的前1000位。那麼消除冗餘程式碼的優化,到底什麼時候才有用呢?
我之所以這麼早就開始講述DCE的優化,是因為如果不清楚DCE的話,在探索其他一些更有趣的優化方法中會造成一些破壞和混亂。看一下下面的小例子,檔案Sum.cpp:
int main() {
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
}
我們對於在計算這十億個數的和時,迴圈的執行速度很感興趣。(的確,這種做法太愚蠢了,我們高中時就學過有一個閉公式可以計算出結果,但這不是重點)
使用命令 CL /Od /FA Sum.cpp來構建這個程式,並用Sum命令來執行程式。注意這個構建使用/Od開關禁用了程式碼優化。 在我的PC機上,執行這個程式花費了4秒。 現在試著使用CL /O2 /FA Sum.cpp 來編譯優化過的程式碼。這次執行很快,幾乎察覺不到有延遲。編譯器對我們的程式碼的優化有這麼出色嗎?答案是否定的(但它的確是以一種奇怪的方式改變了我們的程式碼)
我們看一下/Od版本生成的程式碼,它儲存在Sum.asm裡。我精減了一些程式碼並註釋了一些文字,讓它只顯示迴圈體:
mov QWORD PTR s$[rsp], 0 ;; long long s = 0
mov QWORD PTR i$1[rsp], 1 ;; long long i = 1
jmp SHORT [email protected]
mov rax, QWORD PTR i$1[rsp] ;; rax = i
inc rax ;; rax += 1
mov QWORD PTR i$1[rsp], rax ;; i = rax
cmp QWORD PTR i$1[rsp], 1000000000 ;; i <= 1000000000 ?
jg SHORT [email protected] ;; no – we’re done
mov rax, QWORD PTR i$1[rsp] ;; rax = i
mov rcx, QWORD PTR s$[rsp] ;; rcx = s
add rcx, rax ;; rcx += rax
mov rax, rcx ;; rax = rcx
mov QWORD PTR s$[rsp], rax ;; s = rax
jmp SHORT [email protected] ;; loop
這些指令跟你預料中的差不多。 變數i儲存以在RSP為暫存器,i$1為偏移量的棧上;在asm檔案的其他地方,我們發現i$1=0. 使用RAX暫存器讓i自增長。同樣地,變數s儲存在RSP為暫存器,S$為偏移量的棧上,s$=8. 並在RCX中來計算每次迴圈的累積和。
我們注意到每次迴圈時,先從棧上獲取i的值,再把新值寫回去,變數s同樣如此。可以說這段程式碼很幼稚—–它是由很愚蠢的編譯器生成的(也就說,優化被禁用了)。 例如,我們本來可以將變數i和s一直儲存在暫存器中,而不用每次迴圈迭代時都要訪問記憶體。
關於未優化的程式碼就說這麼多了。那麼進行優化後所生成程式碼是什麼樣呢? 來看一下使用/O2構建的程式對應的Sum.asm檔案,再一次把檔案精簡到只剩下迴圈體的實現,
結果是:
there’s nothing here!
對,它是空的,沒有任何計算s的指令。
你可能會說,那這個答案肯定錯了.但是我們怎麼知道這個答案就是錯的呢?因為優化器已經推斷出程式在任何時候都沒用到S, 所以才懶得計算它。你不能說答案是錯的,除非你需要核對該答案,對吧?
我們這不是被DCE優化給耍了嗎? 如果你不需要觀察計算結果,程式是不會進行計算的。
優化器的這個問題其實跟量子物理學的基本原理很類似,可以用大眾科普文章裡經常提到的一句話來解釋,“如果森林裡一棵樹倒下了,但是如果周圍都沒人,它還會有聲音嗎?”。
我們可以在程式碼裡新增列印變數s的語句來觀察計算結果,程式碼如下:
#include <stdio.h>
int main() {
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
printf("%lld ", s);
}
執行/Od版本的程式打印出了正確結果,還是花費了4秒,/O2版本打印出同樣的結果,速度卻快得多(具體快多少,可以看下下面的可選部分,實際上,速度高達7倍多)。
到現在為止,我已經講述了這篇文章的主要觀點:在進行編譯器優化分析的時候一定要十分小心,衡量它們的優點時,千萬不要被DCE給誤導了。 下面是使用DCE優化時需要注意的四個步驟:
檢查計時,確保沒有突然提高一個數量級;
檢查生成的程式碼(使用 /FA)
如果不太確定,可以新增一個printf語句
把你感興趣的程式碼放到一個獨立的.CPP檔案裡,和含有main函式的檔案分開。只要你不進行整個程式的優化,就一定會奏效(使用/GL,我們後面會講到)。
不管怎麼樣,我們從這個例子中還是學到了一些很有意思的東西,下面四個小節為可選部分。
可選1:/O2版本的Codegen 部分
為什麼/O2版本(新增printf語句來阻止優化)會比/Od版本快這麼多呢? 下面是從Sum.asm檔案中提取的/O2版本的迴圈部分:
xor edx, edx
mov eax, 1
mov ecx, edx
mov r8d, edx
mov r9d, edx
npad 13
inc r9
add r8, 2
add rcx, 3
add r9, rax ;; r9 = 2 8 18 32 50 ...
add r8, rax ;; r8 = 3 10 21 36 55 ...
add rcx, rax ;; rcx = 4 12 24 40 60 ...
add rdx, rax ;; rdx = 1 6 15 28 45 ...
add rax, 4 ;; rax = 1 5 9 13 17 ...
cmp rax, 1000000000 ;; i <= 1000000000 ?
jle SHORT [email protected] ;; yes, so loop back
注意看迴圈體包含了和未優化版本一樣多的指令,為什麼會快很多呢?那是因為優化後的迴圈體的指令使用的是暫存器,而不是記憶體地址。 我們都知道,暫存器的訪問速度比記憶體快得多。 下面的延遲就展示了記憶體訪問時如何將你的程式降到蝸牛般的速度:
Location延時
Register1 cycle
L14 cycles
L210 cycles
L375 cycles
DRAM60 ns
所以,未優化版本是在棧上進行讀寫的,比起在暫存器中進行計算慢多了(暫存器的存取時間只需一個週期)。
但是還有其他原因的,注意/Od版本的執行迴圈的時候計數器每次加1,/O2版本的計數器(儲存在RAX暫存器中)每次加4。
優化器已經展開迴圈,每次迭代都會把四項加起來,像這樣:
s = (1 + 2 + 3 + 4) + (5 + 6 + 7 + 8) + (9 + 10 + 11 + 12) + (13 + . . .
通過展開這個迴圈,可以看到每四次迭代才對迴圈做一個判斷,而不是每次都進行判斷,這樣CPU可以節省更多的時間做一些有用的事,而不是在不停地進行迴圈判斷。
還有,它不是將結果存在一個地方,而是使用了四個獨立的暫存器,分別求和,像這樣:
RDX = 1 + 5 + 9 + 13 + ... = 1, 6, 15, 28 ...
R9 = 2 + 6 + 10 + 14 + ... = 2, 8, 18, 32 ...
R8 = 3 + 7 + 11 + 15 + ... = 3, 10, 21, 36 ...
RCX = 4 + 8 + 12 + 16 + ... = 4, 12, 24, 40 ...
迴圈結束時,再把四個暫存器的和加起來,得到最終結果。
(讀者朋友們可以思考下這個練習,如果迴圈總數不是4的倍數,那優化器會怎麼處理?)
可選2: 精確的效能測試
之前,我在沒有使用printf函式的/O2版本的程式中說過,“執行速度之快以致於你察覺不到有任何延遲”, 下面就用一個例子更準確地描述下這種說法:
#include <stdio.h>
#include <windows.h>
int main() {
LARGE_INTEGER start, stop;
QueryPerformanceCounter(&start);
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
QueryPerformanceCounter(&stop);
double diff = stop.QuadPart - start.QuadPart;
printf("%f", diff);
}
程式中使用了QueryPerformanceCounter 來計算執行時間(這就是我之前的部落格裡寫到的精簡版本的高解析度計時器)。 在測量效能的時候,心中一定謹記一些注意事項(我以前有寫過一個列表),但是對這個特殊的例子,其實也沒什麼用,我們一會兒就能看到:
我在PC機上運行了/Od版本的程式,列印diff的值,大約為7百萬。(計算結果的單位並不重要,只需知道 這個值越大,程式執行的時間就越長)。而/O2版本,diff的值則為0.原因就得歸功於DCE優化了。
我們為了阻止DCE,新增一個printf函式,/Od版本的diff值大約為1百萬—-速度提升了7倍。
可選3:x64 彙編程式 “擴充套件”
我們在回頭看看文章裡的彙編程式碼部分,在初始化暫存器部分,會發現有點奇怪:
xor edx, edx ;; rdx = 0 (64-bit!)
mov eax, 1 ;; rax = i = 1 (64-bit!)
mov ecx, edx ;; rcx = 0 (64-bit!)
mov r8d, edx;; r8 = 0 (64-bit!)
mov r9d, edx ;; r9 = 0 (64-bit!)
npad 13 ;; multi-byte nop alignment padding
記得原始C++語言會使用long long型別的變數來儲存迴圈計數器和總和。 在VC++編譯器中,會對映成64位的整數,所以我們會料到生成碼應該會用x64的64位暫存器。
上一篇文章中,我已經講過了,指令xor reg, reg是用來將reg的值置為0的一種高效的方法。但是第一條指令是在對EDX暫存器(RDX暫存器的低32位位元組)進行xor運算,下一條指令是將 EAX(也就是RAX暫存器的低32位位元組)賦值為1。下面的三條指令也是同樣的方式。從表面來看,這樣每一個目標暫存器的高32位位元組都儲存的是一個任意的隨機數,而迴圈體的計算部分是在擴充套件的64位暫存器上進行的,這樣的計算結果怎麼可能是對的?
答案是因為最初由AMD釋出的x64位指令集,會將64位的目標暫存器的高32位位元組自動擴充套件為零。 下面是該手冊的3.4.5小節的兩個知識點:
1. 32位暫存器的零擴充套件: 如果暫存器為32位,自動將通用目標暫存器的高32位擴充套件為零。
2. 8位和16位位元組暫存器 無擴充套件: 如果是8位和16位暫存器,則不對64位通用暫存器做改變。
最後,注意一下npad 13 這條指令(其實是一個偽操作,一條彙編指令)。用來確保下一條指令(從迴圈體開始)遵循16位元組的記憶體對齊,可以提高效能(有時,用於微架構)。
可選4: printf 和 std::out
你也許會問,在上個實驗中,為什麼我使用了C的printf函式,而不是C++的std::out呢? 試試看,其實兩者都可以,但是後者生成的asm檔案要大很多,所以瀏覽起來不太方便: 相比前面的1.7K位元組檔案, 後者生成的檔案達0.7M 位元組。
有興趣的小夥伴課加群:941636044 一起交流學習!