如何精確測量程式執行時間
來源:https://www.cnblogs.com/kosmanthus/articles/1423466.html
前言
對於一個嵌入式程式設計師來說,“我的程式到底執行多快”,是我們最為關心的問題,因為速度,實時性,永遠是嵌入式裝置效能優化的基本立足點之一。 可惜的是,我們平時常用的測試執行時間的方法,並不是那麼精確的。換句話說,想精確獲取程式執行時間,不是那麼容易的。也許你會想,程式不就是一條條指令 麼,每一條指令序列都有固定執行時間,為什麼不好算?真實情況下,我們的計算機並不是只執行一個程式的,程序的切換,各種中斷,共享的多使用者,網路流量, 快取記憶體的訪問,轉移預測等,都會對計時產生影響。
可惜的是,在效能測量領域,我們有gprof,有intel的vtune,卻缺少相應 的,廣泛流傳的參考文獻。如果你希望能建立起自己的工具,或者對具體的測量方式感興趣,那麼本文也許會對你有幫助。我想,應該有很多人希望知道計時機制的 原理,因為針對不同的系統,環境,會有不同的解決方案。本文主要針對Linux和X86體系環境,主要思想來源於"
程序排程和模式切換
在介紹具體方法之前,先簡單說幾句。
對 於程序排程來講,花費的時間分為三部分,第一是計時器中斷處理的時間,也就是當且僅當這個時間間隔的時候,作業系統會選擇,是繼續當前程序的執行,還是切 換到另外一個程序中去。第二是程序切換時間,當系統要從程序A切換到程序B時,它必須先進入核心模式將程序A的狀態儲存,然後恢復程序B的狀態。因此,這 個切換過程是有核心活動來消耗時間的。第三就是程序的具體執行時間了,這個時間也包括核心模式和使用者模式兩部分,模式之間的切換也是需要消耗時間,不過都 算在程序執行時間中了。
其真實模式切換非常費時,這也是很多程式中都要採用緩衝區的原因,例如,如果每讀一小段檔案什麼的就要呼叫一次 read之類的核心函式,那太受影響了。所以,為了儘量減少系統呼叫,或者說,減少模式切換的次數,我們向程式(特別是IO程式)中引入緩衝區概念,來緩 解這個問題。
一般來說呢,向處理器傳送中斷訊號的計時器間隔通常是1-10ms,太短,切換太多,效能可能會變差,太長呢,如果在任務間切換頻繁,又無法提供在同時執行多工的假象。這個時間段,也決定了一些我們下面要分析的不同方法衡量時間的差異。
方法一:間隔計數
我 們都知道,Linux下有一個命令是專門提供一個程序的執行時間的,也就是time。time可以測量特定程序執行時所需消耗的時間及系統資源等,這個時 間還可以分核心時間和使用者態時間兩部分呈現給你。它是怎麼做到的呢?其實很簡單,作業系統本身就是用計時器來記錄每個程序使用的累計時間,原理很簡單,計 時器中斷髮生時,作業系統會在當前程序列表中尋找哪個程序是活動的,一旦發現,喲,程序A跑得正歡,立馬就給程序A的計數值增加計時器的時間間隔(這也是 引起較大誤差的原因,想想)。當然不是統一增加的,還要確定這個程序是在使用者空間活動還是在核心空間活動,如果是使用者模式,就增加使用者時間,如果是核心模 式,就增加系統時間。
原理很簡單吧?但是相信一點,越簡單的東西,是不會越精確的,人品守恆,能量守恆,難度也當然會守恆了啊。下面就簡 單分析一下,為啥這玩意精度不高吧。舉個例子,如果我們有一個系統,計時器間隔為10ms,系統裡面跑了一個程序,然後我們用這種方法分析時間,測出 70ms,想一想,實際會有幾種結果?具體點,我們用這種方法對程序計時,在某個計時器中斷時,系統發現,咦,有一個程序開始跑了,好,給程序的計數值加 上10ms。但是實際上呢,這個程序可能是一開始就跑起來了,也肯能是在中斷的前1ms才開始跑的。不管是什麼原因,總之中斷時候它在跑,所以就得加 10ms。當中斷髮生時發現程序切換了,同理,可能是上一個中斷之後1ms程序就切換了,也可能人家剛剛才切換。
所以呢,如果一個程序的 執行時間很短,短到和系統的計時器間隔一個數量級,用這種方法測出來的結果必然是不夠準確的,頭尾都有誤差。不過如果程式的時間足夠長,這種誤差有時能夠 相互彌補,一些被高估一些被低估,平均下來剛好,呵呵。從理論上,我們很難分析這個誤差的值,所以一般只有程式到達秒的數量級時,用這種方式測試程式時間 才有意義。
說了半天,難道這方法沒優點了?不,這個世界沒有純善,也沒有純惡。這方法最大的優點是,它的準確性不是非常依賴於系統負載。那什麼方法依賴於系統負載呢?接下來我們會講到:)
理論陳述結束,我想應該開始關注實現方法了吧。其實超級簡單,兩種方法:
- 直接呼叫time命令(一堆雞蛋)
- 使用tms結構體和times函式
說說正經點的第二個方法吧。在Linux中,提供了一個times函式,原型是
clock_t times( struct tms *buf )
這個tms的結構體為
struct tms
{
clock_t tms_utime; // user time
clock_t tms_stime; // system time
clock_t tms_cutime; // user time of reaped children
clock_t tms_cstime; // system time of reaped children
}
怎麼使用就不用這裡教了吧?不過要說明一下的是,這裡的cutime和cstime,都是對已經終止並回收的時間的累計,也就是說,times不能監視任何正在進行中的子程序所使用的時間。
方法二:週期計數
剛 才談了半天間隔計數的不足之處,哪有不足,那就有彌補的方法,特別實在萬能的Linux中:) 為了給計時測量提供更高的準確度,很多處理器還包含一個執行在時鐘週期級別的計時器,它是一個特殊的暫存器,每個時鐘週期它都會自動加1。這個週期計數器 呢,是一個64位無符號數,直觀理解,就是如果你的處理器是1GHz的,那麼需要570年,它才會從2的64次方繞回到0,所以你大可不必考慮“萬一溢位 怎麼辦”此類問題。
看到這裡,也許你會想,哇塞,很好很強大嘛,時鐘週期,這都精確到小數點後面多少位來著了?這下無論是多快的用時多短 的程式,我們也都能進行時間測量了。Ohyeah。等等,剛才我們說過什麼來著?守恆定律啊!功能強大的東西,其他方面必有限制嘛。看到上面的介紹,聰明 的你一定能猜出來這種方法的限制是什麼了,那就是,hardware dependent。首先,並不是每種處理器都有這樣的暫存器的,其次,即使大多數都有,實現機制也不一樣,因此,我們無法用統一的,與平臺無關的介面來 使用它們。怎麼辦?這下,就要祭出上古傳說中的神器:彙編了。當然,我們在這裡實際用的是C語言的嵌入彙編:
void counter( unsigned *hi, unsigned *lo )
{
asm("rdtsc; movl %%edx,%0; movl %%eax, %1"
: "=r" (*hi), "=r" (*lo)
:
: "%edx", "%eax");
}
第一行的指令負責讀取週期計數器,後面的指令表示將其轉移到指定地點或暫存器。這樣,我們將這段程式碼封裝到函式中,就可以在需要測量的程式碼前後均加上這個函式即可。最後得到的hi和lo值都是兩個,除了相減得到間隔值外,還要進行一些處理,在此先按下不表。
不 得不提出的是,週期計數方式還有一個問題,就是我們得到了兩次呼叫counter之間總的週期數,但我們不知道是哪個程序使用了這些週期,或者說處理器是 在核心還是在使用者模式中。還記得剛才我們講間隔計數方式麼?這玩意的好處就是它是作業系統控制給程序計時的,我們可以知道具體哪個程序,哪個模式。但是周 期計數只測量經過的時間,他不管你是哪個程序使用的。所以,用週期計數的話,我們必須很小心。舉個例子
double time()
{
start_counter();
p();
get_counter();
}
這樣一段程式,如果機器的負載很重,會導致P執行時間很長,而其實P函式本身是不需要執行這麼長時間的,而是上下文切換等過程將它的時間拖長了。
而且,轉移預測(想一想,如果轉移方向和目的預測錯誤)和快取記憶體的命中率,對這個計數值也會有影響。通常情況下,為了減少快取記憶體不命中給我們程式執行時間帶來的影響,可以執行這樣的程式碼:
double time_warm( void )
{
p();
start_counter();
p();
get_counter();
}
原因不用我再解釋了吧?它讓指令快取記憶體和資料快取記憶體都得到了warm-up。
好,接下來又有問題。如果我們的應用,是屬於那種每次執行都希望訪問新的資料的那種呢?在這種情況下,我們希望讓指令快取記憶體warm-up,而資料快取記憶體不能warm-up,很明顯,time_warm函式低估我們的執行時間了。讓我們進行進一步修改:
double time_cold( void )
{
p();
clear_cache();
start_counter();
p();
get_counter();
}
注意,我們加入了一個清除資料快取的函式。這個函式的具體實現很簡單,依情況而定,比如舉個例子
volatile int tmp;
static int dummy[N]; // N是你需要清理快取的位元組數
void clear_cache( void )
{
inti, sum = 0;
for( i=1;i<N;i++ )
dummy[i] = 2;
for( i=1;i<N;i++ )
sum += dummy[i];
tmp = sum;
}
具體原理很簡單,我們在定義一個數組並在其上執行一個計算,計算過程中的資料會覆蓋高速資料快取中原有的資料。每一次的store和load都會讓高速資料快取cache這個陣列,而定義為volatile的tmp則保證這段程式碼不會被優化。
這樣做,是不是就萬無一失了呢?不是的,因為大多數處理器,L2快取記憶體是不分指令和資料的,這樣clear_cache會讓所有P的指令也被清除,只不過:L1快取中的指令還會保留而已。
其實上面提到的諸多原因,都是我們不能控制的,我們無法控制讓快取記憶體去載入什麼,不去載入什麼,載入時去掉什麼,保留什麼。而且,這些誤差通常都是會過高估計真實的執行時間。那麼具體使用時,有沒有什麼辦法來改善這種情況呢?有,就是The K-Best Measurement Scheme。這玩意其實很麻煩,所以我在具體實踐中都不用它,附上一個文件,有興趣的朋友可以下載下來看一下。
我不喜歡間隔計數的小適用範圍,也不喜歡週期計數的麻煩性,相信讀到這裡的99%的讀者也和我一種感受吧。OK,最後我們要介紹的,就是一個可移植性更好,相對較準確的方法。
方法三:gettimeofday函式計時
gettimeofday是一個庫函式,包含在time.h中。它的功能是查詢系統時鐘,以確定當前的日期和時間。它很類似於剛才所介紹的週期計時,除了測量時間是以秒為單位,而不是時鐘週期為單位的。原型如下:
struct timeval
{
long tv_sec;
long tv_usec;
}
int gettimeofday( struct timeval *tv, NULL )
這 個機制呢,具體的實現方式在不同系統上是不一樣的,而且雖然披著一個usec(us)的老虎皮,其實沒這麼精確。具體的精確程度,是和系統相關的,比如在 Linux下,是用週期計數來實現這個函式的,所以和週期計數的精確度差不多,但是在Windows NT下,使用間隔計數實現的,精確度就很低了(所以啊,萬惡的ms啊)。
具體使用的時候,就是開始來一個gettimeofday( tvstart, NULL ),結束來一個gettimeofday( tvend, NULL ),完了sec域和usec域相減的差值就是計時時間。
如何,很方便吧?應該說在Linux下,這是最有效而方便的計時方式了。從測試情況看,精確度也不錯。這種價格便宜量又足的東西嘛,大家可以隨便多用。
總結
這次的總結很簡單:沒有一個計時方法是完美的,我們所要作的,就是理解本質後,在特定的系統上去尋找特定的好方法。