close

前言



這絕不僅僅是一篇講內聯意義的文章

參考我的學習過程,可能對你的知識整合有很大幫助




之前寫了一篇總結c++面試的文章,被大佬糾出來很多關於內聯的問題與錯誤。抱着不誤導別人的態度(也因為上篇文章承諾要給大家深入分析一下內聯函數),我在最近的一個月里抽了很多時間去重新研究inline,確實學到了很多以前不了解的知識。學習麼~就是一個不斷打破之前認知並重構知識的過程,每個人都是從一個什麼都不懂的菜鳥逐漸成長為一個大牛的。

在這篇文章里,我會由淺入深的分析不同階段的我對內聯函數的認識,重構我的知識體系。即使你之前對inline不了解,也可以看得懂這篇文章。

由於篇幅比較長,會分成上下兩個部分。另外,文中會有很多引用的參考鏈接,我會統一放到文末的位置。這次我也重新的對文章做了排版,方便大家閱讀。(不過由於我公眾號開的較晚,開啟評論功能是遙遙無期了)


01:菜鳥階段


上大學第一次接觸C++,然後了解到了內聯函數。啥是內聯函數?簡單理解就是編譯時把函數的定義替換到調用的位置。

inline int Add(int a, int b){ return a + b;}int main(){ int num1 = 1; int num2 = 2; int myNum = Add(num1, num2);}//這樣的代碼內聯之後大概就是int main(){ int num1 = 1; int num2 = 2; int myNum = num1 + num2;}

好的,感覺好像還挺簡單的。啥?你問我啥是編譯?嗯。。編譯就是把你的代碼通過編譯器分析一下然後轉換成計算機能直接讀懂的語言(匯編),最後生成一個可執行的程序(或可被調用的庫)。

當然,我這麼解釋有點不太權威,咱們再看看維基百科關於內聯函數的定義:

在計算機科學中,內聯函數(有時稱作在線函數或編譯時期展開函數)是一種編程語言結構,用來建議編譯器對一些特殊函數進行內聯擴展(有時稱作在線擴展);也就是說建議編譯器將指定的函數體插入並取代每一處調用該函數的地方(上下文),從而節省了每次調用函數帶來的額外時間開支。但在選擇使用內聯函數時,必須在程序占用空間和程序執行效率之間進行權衡,因為過多的比較複雜的函數進行內聯擴展將帶來很大的存儲資源開支。【參考1:內聯函數維基百科】

那麼內聯函數有什麼有點呢?當然是減少函數調用帶來的開銷了,幾乎每本C++入門書籍、百科以及博客都是這麼說的。不過,什麼是函數調用開銷?額,反正調用函數肯定要消耗CPU運算吧,肯定也有內存參與,肯定有開銷,嗯。

另外,我還從書上了解一些相關的知識,如直接在類的頭文件裡面定義的函數都是自動內聯的(並不對),內聯相比宏定義有類型檢查、可支持類的訪問控制等優點。


這時候的我知道的專業名詞有:匯編、編譯、內聯、CPU、函數調用、內存地址,但是他們之間的關係幾乎是一頭霧水了。就如下圖一樣,


02:初識階段


之前總是說減少函數調用開銷,那麼這個調用開銷到底是指什麼?這時候的我發現有一些面試裡面會問到這個問題,所以還真有必要理解一下了。

我們常說,C語言程序內存分為常量區、代碼區、靜態全局區、棧區、堆區。當我們的程序運行時,我們的編譯後的二進制程序(這個二進制程序的分布格式差不多就是前面說的那幾個區,裡面會有各種匯編命令)就會被放到操作系統的內存裡面,函數代碼段被放在所謂的代碼區,局部變量與函數參數被放在棧區。函數調用就發生在棧區裡面,每次調用的時候會把當前函數的相關內容壓入到棧裡面處理寄存器相關的數據信息(所謂沒有地址的右值一般就是通過寄存器存儲的數據);然後,調用地址指向我們要執行的函數位置,開始處理函數內部的指令進行計算,當函數執行結束後,要彈出相關數據,處理棧內數據以及寄存器數據。【參考2:淺談C/C++堆棧指引——C/C++堆棧很強大】

這個過程也就是所謂的「函數調用開銷」。

到現在為止,我們不妨先總結一下消除函數調用的直接好處【參考3:Inline expansion 】:

1.它消除了函數調用過程中所需的各種指令:包括在堆棧或寄存器中放置參數,調用函數指令,返回函數過程,獲取返回值,從堆棧中刪除參數並恢復寄存器等。

2.由於不需要寄存器來傳遞參數,因此減少了寄存器溢出的概率。當使用引用調用(或通過地址調用或通過共享調用)時,它消除了必須傳遞引用然後取消引用它們。

當然缺點我們也應該了解,使用不當的話就會造成代碼膨脹(也就是生成的可執行程序會變大);影響cache對數據的命中;如果你設計了一個函數庫,調用你的內聯函數還會造成客戶代碼的重新編譯。

一般一級高速緩存裡面會分為指令緩存(instruction cache)以及數據緩存(data cache),inline的使用不當對二者都可能造成影響。首先,過多的內聯代碼會使原來本可以存儲到ICache的指令分散,導致指令緩存的命中降低,從內存取數據會嚴重影響效率。其次,inline會導致代碼膨脹,增加可執行程序(動態庫、靜態庫)體積,造成額外的換頁行為,進而可能會導致數據緩存的命中率降低。

上面說的缺點還比較抽象,很多情況好像都可以接受。而還有一些特定情況,內聯將會造成很嚴重的後果,如遞歸函數的內聯可能造成代碼的無限inline循環。所以編譯器在這些特殊情況下會拒絕內聯,常見的包括虛調用,函數體積過大,有遞歸,可變數目參數,通過函數指針調用,調用者異常類型不同,declspec宏、使用alloca、使用setjump等。不過這些情況編譯器也並不是一定會拒絕,虛調用在某些情況下就可以被內聯,會在第三部分細說。

這時候,我認識到,其實內聯inline只是建議性的關鍵字,編譯器並不一定會聽你的,畢竟他比你更了解你的代碼編譯後是什麼樣子的,而所謂的內聯也不單單是指inline這個關鍵字了,他本質上是一種編譯器的優化方式。另外,在windows上平台我還經常能看到forceinline【參考4:MS Doc】(GCC上的【always_inline】)這樣的關鍵字,字面意思是強制內聯。不過經過查閱,發現一般只是對代碼體積不做限制了,或者說在Debug模式(不不開啟優化的情況)下也會儘量按照開發者的意願去內聯。無論如何,最終的決定權還是交給編譯器去處理。

在這個階段的學習過程中,我發現想理解程序的編譯與運行,還不得不去看看程序的反匯編代碼,看看編譯器編譯後的代碼是什麼樣子的。畢竟很多時候,我們需要親自手動操作才能真正的理解其中的原理。

雖然我上學時很討厭這門課,但是我發現想大概看懂反匯編代碼,並不需要非常完善的匯編知識,只要把常見的一些命令記住並理解就行了。【參考5:手把手教你棧溢出從入門到放棄 】

還是前面那段代碼,測試在VS2017下的匯編代碼(方法參考上圖,代碼主要看紅色部分)

inline int Add(int a, int b){ return a + b;}

int main(){ int num1 = 1; int num2 = 2; int myNum = Add(num1, num2);}

//Debug模式下無內聯優化的匯編代碼,需要跳到Add函數的地址去執行計算int main(){//前面匯編代碼省略

01232547 mov eax,0CCCCCCCCh 0123254C rep stos dword ptr es:[edi] 0123254E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (01247008h) 01232553 call @__CheckForDebuggerJustMyCode@4 (0123142Eh)

intnum1=1;

01232558 mov dword ptr [num1],1

intnum2=2;

0123255F mov dword ptr [num2],2

intmyNum=Add(num1,num2);

01232566 mov eax,dword ptr [num2] 01232569 push eax 0123256A mov ecx,dword ptr [num1] 0123256D push ecx 0123256E call Add (01231726h) 01232573 add esp,8 01232576 mov dword ptr [myNum],eax }

int Add(int a, int b){//前面匯編代碼省略

00891E67 mov eax,0CCCCCCCCh 00891E6C rep stos dword ptr es:[edi] 00891E6E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (08A7008h) 00891E73 call @__CheckForDebuggerJustMyCode@4 (089142Eh)

return a + b;00891E78 mov eax,dword ptr [a] 00891E7B add eax,dword ptr [b] }

//Debug模式下開啟內聯(/Ob2,參考上圖)後的匯編代碼,無需跳轉到Add函數的位置,直接優化計算

int main(){//前面匯編代碼省略

00F41F67 mov eax,0CCCCCCCCh 00F41F6C rep stos dword ptr es:[edi] 00F41F6E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (0F57008h) 00F41F73 call @__CheckForDebuggerJustMyCode@4 (0F4142Eh)

intnum1=1;

00F41F78 mov dword ptr [num1],1

intnum2=2;

00F41F7F mov dword ptr [num2],2

intmyNum=Add(num1,num2);

00F41F86 mov eax,dword ptr [num1] 00F41F89 add eax,dword ptr [num2] 00F41F8C mov dword ptr [myNum],eax }

通過觀察匯編代碼,我發現經過內聯處理後的匯編代碼可以直接進行兩個參數的累加而不需要去調用Add函數。

當然你也可以在這裡【參考6:Compiler Explorer】試試其他的編譯器,如GCC、ICC、Clang。關於VS控制內聯的參數,可以看這裡【參考7:Microsoft Doc Inline Option】。

後來,我又看了《深入探索C++對象模型》這本書,印象很深的就是我們以為的代碼在編譯器處理後並不是我們以為的那樣,裡面有各種mangling【參考8:name mangling】,添加各種附加代碼,那些看起來空空如也的的構造函數(析構函數同理)裡面也可能有着幾十行或者上百行的複雜代碼。想象一下,你把這些構造代碼內聯的到處都是,你確定你的程序能得到優化麼?


到這個階段,我發現我能稍微的理解高級語言與匯編語言之間的關係,函數調用的基本原理,程序與內存之間的關係等,現在知識圖譜大概變成這樣了:

在下個階段,我開始了解到一些編譯器相關的內容,對內聯的認識也進一步提升。

上篇文章總結了前兩個階段我對內聯以及相關知識的理解,大部分內容都是我之前的理解。這篇文章我會繼續深入分析,談談最近一個月學習的成果。

如果沒有看過上篇,建議先閱讀【這裡】。文中會有很多引用的參考鏈接,而且很多內容都是英文的(建議找時間慢慢學習),我會統一放到文末的位置,很多鏈接不適合在微信裡面查看,建議用瀏覽器打開。


03:進階階段


由於前一陣總結的文章被指出inline的總結內容有諸多不妥,所以我開始換一個角度去理解inline。說實話,大佬文章中很多名詞我聽都沒聽過,因為之前除了學完編譯原理這門課之後就完全與編譯器拜拜了(雖然我無時無刻不在用IDE提供的編譯器)。

首先是關於內聯的意義,前面說過內聯的直接優點就是減少函數調用,這個是毋庸置疑的,但是他更大意義是它允許編譯器進行進一步優化

【參考1:Inline expansion ;Reducing Indirect Function Call Overhead In C++ Programs;CppCon 2014: Andrei Alexandrescu "Optimization Tips" 】。

這點是我之前沒有去想過,因為我們平時都在寫業務代碼,大部分情況下不需要考慮語言層面的問題。不過,我個人處於遊戲行業,對「優化」一詞還是比較敏感的,每次編譯引擎(項目)所花費的時間、運行時的效率、調試效率、遊戲幀數、打包時間等這些其實與我們的業務是息息相關的。一個龐大的項目一旦編譯起來就花費很長時間,所以會有Debug、Development、Shipping等各種版本來滿足我們不同情況下的需求。想要調試一個項目,當然是儘可能把優化都關掉才好;對於一個發行出去的遊戲,當然是越小巧、高度優化、執行效率越高越好了。然而這些工作其實都是編譯器在默默的幫助我們去做的(也可以說是各位編譯領域相關的大佬幫我們做的),這時候我突然覺得我們連Debug與Release配置都搞不清真的有點對不起他們的工作了。

還是拿剛才的代碼來說,我們再看一下內聯後的匯編代碼。

int main(){ //......省略 int num1 = 1; 00F41F78 mov dword ptr [num1],1 int num2 = 2; 00F41F7F mov dword ptr [num2],2 int myNum = Add(num1, num2); 00F41F86 mov eax,dword ptr [num1] 00F41F89 add eax,dword ptr [num2] 00F41F8C mov dword ptr [myNum],eax }

對於任何一個能看懂代碼的人,我們都知道myNum就是2,所以集人類智慧於一身的編譯器也應該知道。除了把函數return a+b這段代碼內聯過來之後還應該直接算出答案,這就是說inline後的代碼與之前已經完全不同了,所以編譯器也有必要再看看這個地方有沒有什麼值得優化的。事實證明如果我把這個程序改為release版本的,這段代碼就直接返回了,不客氣的說,我連 myNum=2 這個都可以直接優化掉,因為這個局部變量看起來並沒有什麼意義。雖然不同的編譯器的反匯編代碼有所不一樣,但是他們都在努力的用內聯去調整編譯後的結果。

樸素一點的理解,所謂的內聯就是為了方便編譯器看到更多源碼信息,如果我們能把所有函數內聯到Main函數裡面,那理論上我們可以就可以得到最佳的優化代碼,可能一段非常複雜的代碼到最後只要一個指令就足夠了。關於編譯器的優化方案,非常多而且大佬們還在不斷的優化提出更多的優化方案,常見的有死代碼刪除、循環不變代碼外提、常數摺疊等等。

【參考2:Category:Compiler optimizations Reducing ;Indirect Function Call Overhead In C++ Programs】

既然談到新的優化方案,就正好說一下虛函數調用。在比較老的編譯器上,我們不會去對虛函數內聯,原因很簡單,因為虛函數的執行屬於運行時動態,我們需要動態查閱虛函數表來找到對應的虛函數。由於根本不知道運行的時候到底是哪個類會執行這個虛函數,當然也就不知道到底調用的是哪個子類下override的版本。但是,大佬們自然不會輕易放棄,能優化一點咱們就儘量優化一點。當我們的編譯器可以分析出當前的程序,如

struct A{ virtual ~A() {} virtual int foo() { return 0; }};inline int do_something(A& obj){ return obj.foo();}struct B : A{ virtual ~B() {} virtual int foo() { return 1; }}; int main(){ B b; return do_something(b);}

這樣的代碼的時候,我們就可以確定B就是繼承樹上的最終的子節點,也就可以將虛函數的查表調用改為直接調用進而進行內聯優化,這種優化方式叫做Devirtualization。當然,這種代碼確實過於理想的簡單,我們常見的項目代碼一定是分為多個編譯單元的,編譯器想進行跨編譯單元的優化就還需要另一個方案,LTO(Link Time Optimization)即鏈接時優化。

【參考3: C++ Devirtualization Devirtualization in LLVM and Clang】

LTO顧名思義,就是在編譯器進行鏈接時進行相關的代碼優化,不同編譯單元在鏈接的時候將其內部表示轉儲到磁盤,然後組成單個模塊並進行優化。也因此,之前大佬糾正我說「寫在cpp裡面的函數也可以內聯,每次修改會重新編譯頭文件增加編譯時間這句話也說錯誤的」。【參考4:Link-time optimization for the kernel LLVM Link Time Optimization: Design and Implementation】

可是看完LTO相關資料後,我又產生了疑問,編譯器優化不是還有IPO麼。所謂IPO,Interprocedural Optimization,即過程間優化,傳統的編譯器是先將編譯每個源文件成獨立的目標文件,然後再通過鏈接器將目標文件鏈接成可執行文件(或庫),其編譯優化主要集中在每個源文件內部,而IPO可以打破這個局限對整個程序進行全局的優化。那麼IPO與LTO是什麼關係呢?看了wiki上的資料後,我大概理解為,LTO屬於IPO的子集,IPO是一個可以在編譯過程的任何階段都能執行優化的解決方案,LTO只針對鏈接時優化,不過應該屬於IPO一個最強有力的方案了。【參考5:Interprocedural optimization 現代C/C++編譯器有多智能?能做出什麼厲害的優化?】另外,C/C++這種純編譯型語言都可以做到鏈接時內聯優化,而對於C#、Java這種半編譯半解釋型的語言,其優化的時機豈不是可以更為靈活隨意了?

這時候我才發現,這麼多關於內聯的調整的優化好像都是編譯器在搞,無論什麼語言、什麼平台,本質上都逃不過編譯器的審核與優化。

那麼,我們顯示聲明inline還有什麼意義呢?好像我們寫不寫inline,沒什麼意義啊。只要是發行出去的版本,編譯器自己決定,分分鐘給你各種優化,你的各種inline建議好像都沒什麼意義了。不過,帶着疑問我又去查了查資料,發現好像還沒問想的那麼簡單,最起碼inline還有兩點意義:

1. 編譯器並不是萬能的,有時候人工的內聯建議確實能解決一些編譯器優化的盲點【參考6:libcxx修改1 、libcxx修改2 】

2. inline並不只是只有把函數內聯到調用的地方這個意義,他還關係到ODR (定義與單一定義規則)。所謂ODR,就是任何變量、函數、類類型、枚舉類型、概念 (C++20 起)或模板,在每個翻譯單元中都只允許有一個定義(它們有些可以有多個聲明,但定義只允許有一個)。不過具體一點的說又有很多種情況,對於非inline且odr-used的變量、函數,要求全局只能有一個定義;Inline變量、函數在每個編譯單元都有有一個定義;需要使用類的時候,在每個翻譯單元都需要一個定義。

例如:你如果把一個非成員函數放到.h文件裡面並被多個編譯單元包含,那麼在鏈接的時候就會報錯。因為非inline的全局函數在全局只能有一個定義,如果每個編譯單元都有一個成員函數,編譯器不知道鏈接哪一個。如果給這個函數加上inline的話,就可以解決這個問題。而如果你在多個cpp裡面定義了函數簽名完全相同的但是內容不同inline函數,也不會發生編譯失敗,不過具體鏈接到哪個版本的inline函數可能是未定義行為。【參考7:One Definition Rule 既然編譯器可以判斷一個函數是否適合 inline,那還有必要自己加 inline 關鍵字嗎?最近看到陳碩的一本書提了一個問題,「編譯器如何處理inline函數中的static變量?」】

關於優化,這裡還涉及到一個概念, zero-overhead abstraction,即「零代價的抽象」(Rust裡面叫zero-cost abstractions),簡單來說就是抽象的同時不需要付出額外的代價,比如說vector<int> 數組在優化理想的編譯器的發行版本下與int類型的數組開銷應該是幾乎相同的。因此,我們可以認為C++中的zero-overhead abstraction與編譯器的優化是密不可分的,更進一步的說,C++語言本身的優良與編譯器也是密不可分的。每當C++新的標準出來後,各大編譯器團隊都要及時的去支持這些新的特性,併兼顧語言的優化問題。【參考8:Rust所宣稱的zero-cost abstractions是怎麼回事?Zero-cost abstractions abstraction and hand-crafted code】

最後,再簡單提一下LLVM。前面的很多鏈接裡面都提到了LLVM(很久以前,LLVM曾經是Low Level Virtual Machine的縮寫,現在已經不是了),它是一個編譯器基礎設施框架,包含了我們編寫編譯器需要的一系列庫(如程序分析、代碼優化、機器代碼生成等),並且提供了調用這些庫的相關工具。Clang, llbc++, lld等項目就是基於LLVM開發的,Objective-C編譯器也是基於LLVM開發的。LLVM的編譯過程與傳統的編譯器有所差異(參考下圖),中間會生成一套通用的與語言無關的中間語言LLVM IR,我們可以去閱讀這個中間語言了解更多信息【參考9:測試工具】,所以編譯優化與使用方式也是非常靈活(我目前在VS上還沒有找到可以閱讀的單個編譯單元編譯後的文件)。

我沒有深入了解,更多內容可以【參考10:LLVM 誰說不能與龍一起跳舞:Clang / LLVM (1) 周末花了點時間看 LLVM IR, 閒扯幾句 編譯器LLVM淺淺玩 】


這段時間查了各種資料,算是勉強到了一個新的階段。這個階段的我開始關注了core language之外的東西——編譯器,雖然研究的並不是很透徹,但是在概念上我對程序的運行編譯、程序與操作系統之間的關係有了進一步的理解,有時候會覺得豁然開朗,看起來毫無關係的知識突然就聯繫在了一起。

知識圖譜又稍微修改了一下:



也許這篇文章看完,你會覺得這個內聯好像懂了以後對編程也沒多少幫助,但正如我知乎上的評論所說的,

其實這個就像我們學的數理化那些,看起來生活中很少或者基本不會直接用到,但是裡面的思想會影響到我們對事物做出的邏輯判斷,甚至萬一遇到問題了還能解決,就拿匯編來說,大部分做業務的程序員基本不會用到,但是一旦遇到問題甚至要在沒有單步調試環境的下面debug,反編譯後能看下匯編代碼,調試起來還是會比不會匯編的人方便快捷很多。很多東西怕就怕萬一用到了~

End


參考鏈接:

【1】.Inline expansion;

https://en.wikipedia.org/wiki/Inline_expansion

CppCon 2014: Andrei Alexandrescu "Optimization Tips"

https://www.youtube.com/watch?v=Qq_WaiwzOtI

【2】.Category:Compiler optimizations Reducing ;

https://en.wikipedia.org/wiki/Category:Compiler_optimizations

Reducing Indirect Function Call Overhead In C++ Programs;

http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.27.5761&rep=rep1&type=pdf

【3】.C++ Devirtualization

http://lazarenko.me/devirtualization/

Devirtualization in LLVM and Clang

http://link.zhihu.com/?http://blog.llvm.org/2017/03/devirtualization-in-llvm-and-clang.html

【4】.Link-time optimization for the kernel LLVM

http://llvm.org/docs/LinkTimeOptimization.html

Link Time Optimization: Design and Implementation

https://lwn.net/Articles/512548/

【5】.Interprocedural optimization

https://en.wikipedia.org/wiki/Interprocedural_optimization

現代C/C++編譯器有多智能?能做出什麼厲害的優化?

https://www.zhihu.com/question/43598164/answer/122186527

【6】.libcxx修改

https://reviews.llvm.org/D22782

https://reviews.llvm.org/D22834

【7】.One Definition Rule

https://en.wikipedia.org/wiki/One_Definition_Rule

既然編譯器可以判斷一個函數是否適合 inline,那還有必要自己加 inline 關鍵字嗎?

https://www.zhihu.com/question/53082910

【8】.Rust所宣稱的zero-cost abstractions是怎麼回事?

https://www.zhihu.com/question/31645634

Zero-costabstractions

https://ruudvanasseldonk.com/2016/11/30/zero-cost-abstractions

Interview with Bjarne Stroustrup - abstraction and hand-crafted code

https://stackoverflow.com/questions/20134585/interview-with-bjarne-stroustrup-abstraction-and-hand-crafted-code

【9】.測試工具http://ellcc.org/demo/index.cgi

【10】.LLVM

http://www.aosabook.org/en/llvm.html

誰說不能與龍一起跳舞:Clang / LLVM (1)

https://zhuanlan.zhihu.com/p/21889573

周末花了點時間看 LLVM IR, 閒扯幾句

https://segmentfault.com/a/1190000002669213

編譯器LLVM淺淺玩

https://medium.com/@zetavg/%E7%B7%A8%E8%AD%AF%E5%99%A8-llvm-%E6%B7%BA%E6%B7%BA%E7%8E%A9-42a58c7a7309



arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()