提高C++性能的编程技术笔记:内联+测试代码
內聯類似于宏,在調用方法內部展開被調用方法,以此來代替方法的調用。一般來說表達內聯意圖的方式有兩種:一種是在定義方法時添加內聯保留字的前綴;另一種是在類的頭部聲明中定義方法。
雖然內聯方法的調用方式和普通方法相同,但其編譯過程卻相差甚遠。由于內聯方法的代碼必須內聯展開,這就要求調用內聯方法的代碼段必須有權訪問該內聯方法的定義。而內聯方法的定義需要整合到其調用方法之中,這就使得任何針對內聯方法的更改,都將引起所有調用該方法模塊的重新編譯。所以,內聯在顯著提升性能的同時,也增加了編譯時間。編譯時間的增加有時是適度的,但有時卻極大,并且在大多數極端情況下,對內聯方法的一處修改可能會要求整體程序重新進行編譯。因此,將內聯過程擱置到代碼開發階段后期是明智的做法。
?從邏輯上說,編譯器將方法內聯化的步驟如下:首先將待內聯方法的連續代碼塊復制到調用方法中的調用點處。然后在塊中為所有內聯方法的局部變量分配內存。之后將內聯方法的輸入參數和返回值映射到調用方法的局部變量空間內。最后,如果內聯方法有多個返回點,將其轉變為內聯代碼塊末尾的分支。經過這樣的處理即可消除所有與調用相關的痕跡以及性能損失。避免方法調用僅僅是內聯可提升的性能空間的一半。調用間(cross-call)優化是內聯可提升的性能空間的另外一半。優秀的、經過優化的編譯器可以使內聯方法的邊界痕跡難以區分。方法中大量的甚至是所有的代碼經過優化后都將不復存在,因為編譯器可能會對方法中大部分代碼進行重新排列。因此,盡管在邏輯上可以將方法內聯化看作是對一定內聚度的維持,不過編譯器并未強制執行這種優化措施,這也是內聯的優點之一。
大部分系統都有3或4個”內務處理”寄存器:指令指針(Instruction Pointer,常被稱為程序計數器----Program Counter,但其功能并非對程序進行計數),鏈接寄存器(Link Register),棧指針(Stack Pointer), 幀指針(Frame Pointer)以及自變量指針(Argument Pointer),分別可以記作IP、LR、SP、FP以及AP。
指令指針(IP):存放下一條將要執行的指令地址。調用方法時,程序要跳轉到被調用方法的指令并修改IP。但不能只是簡單地重新IP。重寫前必須先保存其舊值,否則無法返回至原調用方法。
鏈接寄存器(LR):存儲某一方法的IP的地址,該方法對當前方法進行了調用。這個地址就是方法執行完畢后返回的地方。LR通常和體系結構調用指令的操作綁定在一起,執行調用操作時其值會被自動設定。LR是單個寄存器而非多寄存器集合。因此,如果某方法調用了其它方法,則必須保存LR的值以防止其被重寫,因為調用者標識消失后,就很難有效地從調用中返回。在某些體系結構中,LR的功能是通過自動或顯示地調用方法IP壓入程序進程堆棧來實現的,因此這些體系結構不存在明確的LR。
棧指針(SP):方法的局部(自)變量是在進程堆棧上分配的。而SP的功能就是跟蹤記錄堆棧的使用情況。調用操作會消耗堆棧空間,返回操作則會釋放之前分配的堆棧空間。類似于調用者IP和LR,調用返回之后,必須根據傳遞到堆棧的參數來進行可能的調整以恢復堆棧。這就意味著SP也必須被保存為方法調用的一部分。
自變量指針(AP)和幀指針(FP)的存在隨系統而異。某些體系結構不包含這兩個寄存器,某些僅包含一個,而另外一些則兩者兼備。FP的作用是標識堆棧中兩個區域的邊界:第一個區域供調用方法用來保存需要記錄狀態的寄存器;第二個區域為被調用方法的自變量分配內存。在方法執行期間SP一般會頻繁地變化。FP通常被當成方法中局部變量的固定引用指針。
良好的調用性能要求系統只保存方法用到的寄存器。每次調用都保存全部寄存器是無謂的浪費,但是只保存部分寄存器會導致在傳給方法的參數和分配給方法自變量之間產生潛在的內存分配。如果數量可變的寄存器存儲和某個給定的方法調用相關聯(也即寄存器值存儲的數量依賴于調用方法的狀態),就需要用AP指出傳給方法的參數在堆棧中的位置。
使用寄存器的典型調用順序如下:
(1). 調用方法整理需要傳給被調用方法的參數。此步驟通常意味著要將參數壓棧,壓棧時一般采用倒序。所有參數入棧后,SP將指向第一個參數。
(2). 把將要返回的指令地址壓棧,然后調用指令跳轉到被調用方法的第一條指令處。
(3). 由被調用方法在堆棧中保存調用方法的SP、AP以及FP,并調整每個”管家”寄存器以反映被調用方法的上下文環境。
(4). 同時,被調用方法保存(壓入堆棧)其將會用到的所有其它寄存器(此步驟是必要的,以便被調用方法返回后不會中斷調用方法的上下文環境,通常要保存另外的3或4個寄存器)。
清除調用的典型返回序列如下:
(1). 如果方法有返回值,則通常將該返回值存儲到寄存器0(有時是寄存器1)中。這就意味著寄存器0和寄存器1必須是暫時性寄存器(此類寄存器的存儲和恢復不是方法調用和返回的一部分)。通過寄存器使得返回操作的堆棧清理工作更加容易。
(2). 將由于方法調用而保存的寄存器從堆棧中恢復至其初始位置。
(3). 將保存的調用者的FP和AP寄存器值從堆棧中恢復到其相應的位置。
(4). 修改SP使其指向將方法第一個參數壓棧前的位置。
(5). 從堆棧中找到返回地址并將其存入IP,強制返回至調用者中緊接調用點的位置。
簡單算一算單次方法調用過程中的數據移動次數可以看出,6~8個寄存器(4個寄存器用于維護現場,2~4個供方法使用)被保存過,其中4個稍后被修改過。通常情況下這些操作至少需要12個時鐘周期(實際中數據移入/移出內存很少只花費單個時鐘周期),有時甚至會消耗多達40個時鐘周期。因此,就機器時鐘周期方面的花費而言,與方法調用相關的操作的代價非常昂貴。不幸的是,以上所述只是所有開銷的一半。方法返回時,為調用過程所做的工作必須全部撤銷。之前保存的值必須從堆棧中恢復,機器狀態也必須恢復到和調用前類似。這就意味著單次方法調用通常需要消耗25~100個時鐘周期,有時這甚至僅是保守估計。之所以說是保守估計,部分原因與參數的準備及獲取有關。被壓棧的參數作為調用開始階段的一部分,通常直接映射到被調用方法的內存映像中。對于引用來說始終如此,而對于指針和對象則是有時如此。因而會產生額外的調用開銷,這種開銷與調用前參數的壓棧以及被調用方法將其從堆棧中讀回的操作相關。某些情況下可以通過寄存器進行參數傳遞,雖然這種機制可以提供很好的性能特性(盡管不無代價),但是公認的機制還是使用內存來傳遞參數。
如果方法有返回值,特別當其返回值是一個對象時,被調用方法將對象復制到調用方法為返回值預留的存儲空間中也是一筆開銷。對于較大的對象而言,這筆額外開銷將更加客觀,尤其是使用復雜的拷貝構造函數執行該任務時(這種會產生兩份調用/返回開銷:一份開銷是因為對方法的顯示調用,另一份則是拷貝構造函數返回一個對象時產生的開銷)。如果將所有調用者/被調用者的通信因素和系統維護因素也考慮在內,方法調用的代價大約為25~250個時鐘周期。通常被調用方法越大,所產生的開銷也越大,取決于保存恢復所有寄存器、傳遞大量參數、調用自定義方法以構造返回值等操作的最大開銷。
使用異常處理可以顯著降低內聯返回值優化的性能。從邏輯上來說,返回值的復制工作是作為被調用方法返回過程一部分的、由拷貝構造函數執行的原子操作。這就意味著如果返回前有異常被拋出,則返回值將不會返回,而且存放方法返回值的變量也不會改變,從而導致異常發生時,必須為返回值使用復制語義。這在某些情況下也成為避免使用異常處理的正當理由。理想情況下,為達到優化目的,如果存在一些語法標記以允許異常發生時對返回值進行優化,將會帶來極大便利。某些針對異常發生時的返回值優化已經可以實現。例如,如果返回值變量的作用域和內聯方法處在同一try代碼塊內,則返回值可被優化。不幸的是,盡管這種情形在大多數情況下可以輕易確定,但其要求的調用間優化往往代價高昂且實現起來十分復雜。
內聯的另一個好處是無須跳轉執行被調用方法。跳轉,即便是無條件跳轉,都會對現代處理器的性能產生負面影響。頻繁跳轉會造成執行流水線遲滯,這是因為預取緩存中沒有需要執行的指令。跳轉還需要運算單元為其確定跳轉目標地址,使指令直到跳轉地址可知方可執行。流水線的延遲意味著處理器將會因為大量時間被用于重定向指令流而處于閑置狀態。每次方法調用時這種情況都會發生兩次----方法被調用階段和返回階段。
在某些情況下,方法調用最大的代價就是無法對跨越方法邊界的代碼進行優化。
內聯可能是C++中可用的最有效的性能提升機制。通過內聯的方式,無須進行任何重寫即可使大型的系統迅速提升性能。
內聯是一種由編譯器/配置器/優化器執行的、基于編譯和配置的優化操作。
保留字”inline”僅表示對編譯器的一種建議。它告訴編譯器,將方法代碼內聯展開而不是調用可以獲得更佳性能。但是編譯器沒有義務答應內聯請求。因此編譯器可以根據自己的意愿或者能力來選擇是否進行內聯。這就意味著即使沒有被明確告知需要內聯(對低價值方法編譯器會自動內聯,這常常是優化的副作用)編譯器也會這樣去做,或者即便被明確告知需要內聯卻不進行內聯。
內聯還會引起一些值得注意的副作用:從邏輯上來說,雖然經常被存放于單獨的.inl文件中,但其實內聯方法的定義應為類頭文件的一部分。頭文件及其邏輯上包含的.inl文件隨后被用到它們的.c或.cpp文件包含。源文件被編譯為目標文件后,就不需要在目標文件做任何標示以說明目標文件包含哪些內聯方法了。也就是說,通常情況下,目標文件已完全解析了內聯方法且不需要再對其存在性進行保存(不存在鏈接需求)。因此,盡管C++語言明文禁止,但是源文件仍然可以和內聯方法的定義一起編譯,而另一源文件也可以和另一版本不同但方法相同的文件一起編譯。
如果編譯器足夠完善,許多對虛方法的調用是可以內聯化的。因此,如果配置文件指出某些虛方法需要占用程序過多的運行時間,則可通過將部分方法調用內聯化來挽回一些開銷。這也說明如果編譯器有能力并且選擇了將虛方法內聯化,那么幾乎可以保證一定會有一些針對同一方法的內聯調用實例以及虛方法調用實例。
內聯就是用方法的代碼來替換對方法的調用。
內聯通過消除調用開銷來提升性能,并且允許進行調用間優化。
內聯的主要作用是對運行時間進行優化,當然它也可以使可執行映像變得更小。
調用間(cross-call)優化:面向某一方法的調用過程,基于對上下文場景更加全面的理解,使得編譯器在源代碼層面及機器代碼層面對方法進行優化。這種優化的一般形式為:在編譯期間進行一部分預處理,從而避免在運行時重復類似的過程。內聯的這類優化應是編譯器的職責,而不是程序員的。
與避免方法調用這種簡單的做法相比,調用間代碼優化更可能獲得巨大的性能提升。但從另一個角度來看,避免方法調用獲得的性能提升是確定的,雖然有時效果并不盡如人意,但這種做法具有普遍性。代碼優化與編譯器密切相關,高層次的優化方法將會使編譯過程變得漫長,實際上有時還會打亂代碼。
何時避免內聯:當程序中所有能夠內聯的方法都進行內聯,代碼膨脹將不可估量,這將對性能產生巨大的二次負面影響,如緩存命中問題和頁面錯誤,而這些將令我們的工作得不償失。另一方面,濫用的內聯程序將執行較少的指令,但會耗費較多的時鐘周期。內聯的濫用導致的緩存錯誤會使性能銳減。代碼膨脹所帶來的副作用可能是無法承受的。
內聯所引發的代碼膨脹現象有時會導致另一類退化特征。將某個方法內聯可能會導致指數級的代碼膨脹。這種現象通常會發生在相對大規模的例程互相內聯的情況下。
內聯方法不僅在實現層面會產生編譯依賴,在接口層面亦然。正因如此,對于在程序開發階段經常發生變動的方法,不應將其列入可內聯的范疇。可以用一條規則來總結:能夠縮減代碼大小的內聯都是可取的,而任何顯著增大代碼大小的內聯都是不可取的。第二條有用的規則:如果方法的實現是易變的,則不應將其內聯。
通常應避免遞歸方法內聯。
從邏輯上講,內聯方法應定義在其類的頭文件中。這對于使用內聯方法代碼體的那些調用者是必要的。不幸的是,一旦內聯方法的內容有所改變,都將導致用到內聯方法的相關模塊重新編譯----是重新編譯而不只是重新連接。對于大規模程序來說,由于每次編譯都會帶來額外的時間消耗,這無疑增加了程序的開發時間。
針對內聯方法的調試較為復雜,因為單個斷點無法跟蹤內聯方法的入口和出口。
內聯方法通常并不出現在程序的配置表中(配置表基于某些示例程序的模板,顯示程序的執行行為)。配置表有時無法察覺對內聯方法的”調用”。
基于配置的內聯:配置是尋求適合內聯的方法的最佳手段,尤其當我們擁有可以用來產生配置數據的代表性數據樣本時,配置的優勢將會更加明顯。配置(Profiling)是一種依靠配置工具(軟件包)的性能測試技術,通過為程序生成工具代碼(插入測試代碼),在程序樣本執行期間使性能具備某些特征。生成配置的數據樣本質量直接決定了配置文件的質量。配置文件的形式和大小不拘一格,其可能的輸出范圍也是千差萬別,然而一般來說,所有的配置文件都應至少提供如:哪些方法正在執行,這些方法多久被調用一次等相關信息。
配置文件要同時兼顧指令數的計算和時間的度量。時間是一種更為精確的度量指標,但指令數的生成更為簡單,并且它所提供的數據可以用做內聯決策的依據。
編譯器通常禁止內聯復雜的方法。
內聯規則:
(1).唯一化(singleton)方法:是指方法在程序中的調用點是唯一的,而它并不代表方法在程序執行過程中只被調用一次。某個方法或許會出現在循環中,被成千上萬次調用,但是只要其在程序中的調用點唯一,我們就稱其為唯一化方法。唯一化方法具備與生俱來的內聯特性。唯一的調用點意味著我們不需要考慮方法的大小和調用頻率,對于內聯后的唯一化方法,其代碼將比原先更小,運行更快。或許由此獲得的性能提升并不明顯,但是有一點要清楚,我們在內聯方面付出的努力并不總是能帶來性能上的提升。一般來說,唯一化方法的鑒別較為困難,方法的唯一化往往是臨時的,并且與環境相關,而有時,唯一化是設計的產物。
(2).精簡化(trivial)方法:都是一些小型方法,通常包含4條以下的源代碼級語句,這些語句被編譯后將形成10條以下的匯編指令。這些方法包含的語句很少,從而產生代碼膨脹的可能性幾乎為零。將小規模的精簡化方法內聯實際上將減少代碼量,將較大規模的精簡化方法內聯可能會使總體代碼量略微增加。總的來說,將精簡化方法進行內聯的最終效果不會影響代碼大小。
直接量參數與內聯結合使用,為編譯器性能的大幅提升開辟了更為廣闊的空間。
使用內聯有時會適得其反,尤其是濫用的情況下,內聯可能會使代碼量變大,而代碼量增多后會較原先出現更多的緩存失敗和頁面錯誤。
非精簡方法的內聯決策應根據樣本執行的配置文件來制定,不能主觀臆斷。
對于那些調用頻率高的方法,如果其靜態尺寸較大,而動態尺寸較小,可以考慮將其重寫,從而抽取其核心的動態特性,并將動態組件內聯。
精簡化與唯一化方法總是可以被內聯。
條件內聯:編譯、調試和配置等過程與內聯是有一些沖突的,做這些工作時,都希望將內聯決策推遲到開發周期的后期,在大部分調試工作完成之后進行。預處理可以協助我們實現在內聯與外聯之間的輕松轉移。這項技術的基本思路是利用編譯器行參數向編譯器傳遞一個宏定義。輸入參數用來定義名為INLINE的宏,也可以忽略這個參數而不定義INLINE。這種技術基于對兩種定義的劃分,即需要內聯的方法及需要外聯的方法。外聯方法包含于標準的.c文件中,需要內聯的方法放置在.inl文件中。如果對.inl文件中的方法內聯,可以在編譯命令中使用-D選項來定義INLINE宏。
選擇性內聯:內聯機制的語法和機動性是C++最嚴重的缺陷之一。盡管通常情況下它很有用,但卻存在一個令人頭疼的缺陷,即它沒有針對選擇性內聯的機制;所謂選擇性內聯,是指在某些場合下對方法進行內聯而在另外一些場合則不然。這種缺陷使得內聯決策成為非全則無的選擇,從而忽略了快速路徑優化的真實情況。
遞歸內聯:直接遞歸方法是無法內聯的。尾部遞歸是遞歸方法中的一種。它表現為方法在達到它的基線條件之前一直遞歸下降,當到達基線條件后執行一些操作并終止方法,可能還會返回一個值。典型的二叉樹搜索就是一個很好的尾部遞歸方法的例子。
對靜態局部變量進行內聯:對基于編譯器的內聯解決方案來說,局部靜態變量可能會造成很大問題。這是因為一些編譯器會拒絕內聯任何包含靜態變量聲明的方法。還有一些編譯器允許內聯靜態變量,但是在運行時它們會錯誤地為這些內聯變量創建多個實例。
與體系結構有關的注意事項:對于各種不同體系結構來說,它們的調用/返回性能不盡相同。
內聯可以改善性能。目標是找到程序的快速路徑,然后內聯它,盡管內聯這個路徑可能要費點工夫。
條件內聯可以阻止內聯的發生。這樣就減少了編譯時間,同時也簡化了開發前期的調試工作。
選擇性內聯是一種只在某些地方內聯方法的技術。在對方法進行內聯時,為了抵消可能的代碼尺寸膨脹的影響,選擇性內聯只在對性能有重大影響的路徑上對方法調用進行內聯。
遞歸內聯是一種讓人感覺別扭,但對于改善遞歸方法性能卻很有效的技術。
內聯的目標是消除調用開銷。在使用內聯之前須先弄清當前系統中真正的調用代價。
以下是測試代碼(inline.cpp):
#include "inline.hpp"
#include <iostream>
#include <chrono>
#include <string>
#include <random>
#include <cmath>namespace inline_ {// reference: 《提高C++性能的編程技術》:第八、九、十章:內聯
//
void generator_random_number(double* data, int length, double a, double b)
{//std::random_device rd; std::mt19937 generator(rd()); // 每次產生不固定的不同的值std::default_random_engine generator; // 每次產生固定的不同的值std::uniform_real_distribution<double> distribution(a, b);for (int i = 0; i < length; ++i) {data[i] = distribution(generator);}
}double calc1(double a, double b) // 非內聯
{return (a+b);
}inline double calc2(double a, double b) // 內聯
{return (a+b);
}int test_inline_1()
{using namespace std::chrono;high_resolution_clock::time_point time_start, time_end;const int count{2000};const int cycle_number1{count}, cycle_number2{count}, cycle_number3{count};double x[count], y[count], z1[count], z2[count];generator_random_number(x, count, -1000., 1000.);generator_random_number(y, count, -10000, 10000.);{ // 測試簡單的非內聯函數調用執行時間time_start = high_resolution_clock::now();for (int j = 0; j < cycle_number1; ++j) {for (int i = 0; i < cycle_number2; ++i) {for (int k = 0; k < cycle_number3; ++k) {z1[i] = calc1(x[k], y[k]);}}}time_end = high_resolution_clock::now();fprintf(stdout, "z1: %f, %f, %f, no inline calc time spent: %f seconds\n",z1[0], z1[1], z1[2], (duration_cast<duration<double>>(time_end - time_start)).count());
}{ // 測試簡單的內聯函數調用執行時間time_start = high_resolution_clock::now();for (int j = 0; j < cycle_number1; ++j) {for (int i = 0; i < cycle_number2; ++i) {for (int k = 0; k < cycle_number3; ++k) {z2[i] = calc2(x[k], y[k]);}}}time_end = high_resolution_clock::now();fprintf(stdout, "z2: %f, %f, %f, inline calc time spent: %f seconds\n",z2[0], z2[1], z2[2], (duration_cast<duration<double>>(time_end - time_start)).count());
}return 0;
}} // namespace inline_
執行結果如下:
GitHub:?https://github.com/fengbingchun/Messy_Test?
總結
以上是生活随笔為你收集整理的提高C++性能的编程技术笔记:内联+测试代码的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Windows10上使用VS2017编译
- 下一篇: Windows10上编译MXNet源码操