1. 程式人生 > >如何精確測量程式執行時間

如何精確測量程式執行時間

來源:https://www.cnblogs.com/kosmanthus/articles/1423466.html

前言

對於一個嵌入式程式設計師來說,“我的程式到底執行多快”,是我們最為關心的問題,因為速度,實時性,永遠是嵌入式裝置效能優化的基本立足點之一。 可惜的是,我們平時常用的測試執行時間的方法,並不是那麼精確的。換句話說,想精確獲取程式執行時間,不是那麼容易的。也許你會想,程式不就是一條條指令 麼,每一條指令序列都有固定執行時間,為什麼不好算?真實情況下,我們的計算機並不是只執行一個程式的,程序的切換,各種中斷,共享的多使用者,網路流量, 快取記憶體的訪問,轉移預測等,都會對計時產生影響。

可惜的是,在效能測量領域,我們有gprof,有intel的vtune,卻缺少相應 的,廣泛流傳的參考文獻。如果你希望能建立起自己的工具,或者對具體的測量方式感興趣,那麼本文也許會對你有幫助。我想,應該有很多人希望知道計時機制的 原理,因為針對不同的系統,環境,會有不同的解決方案。本文主要針對Linux和X86體系環境,主要思想來源於"

Computer System A Programmer's Perspective",夾雜了一些自己的理解,並試圖給出我自己寫的一個通用測量工具,支援使用者自配置。本文有時的物件是程式有時描述物件是程序,這個請自行理解,因為一個程式就是在一個程序裡面執行的。
 

程序排程和模式切換

在介紹具體方法之前,先簡單說幾句。

對 於程序排程來講,花費的時間分為三部分,第一是計時器中斷處理的時間,也就是當且僅當這個時間間隔的時候,作業系統會選擇,是繼續當前程序的執行,還是切 換到另外一個程序中去。第二是程序切換時間,當系統要從程序A切換到程序B時,它必須先進入核心模式將程序A的狀態儲存,然後恢復程序B的狀態。因此,這 個切換過程是有核心活動來消耗時間的。第三就是程序的具體執行時間了,這個時間也包括核心模式和使用者模式兩部分,模式之間的切換也是需要消耗時間,不過都 算在程序執行時間中了。

其真實模式切換非常費時,這也是很多程式中都要採用緩衝區的原因,例如,如果每讀一小段檔案什麼的就要呼叫一次 read之類的核心函式,那太受影響了。所以,為了儘量減少系統呼叫,或者說,減少模式切換的次數,我們向程式(特別是IO程式)中引入緩衝區概念,來緩 解這個問題。

一般來說呢,向處理器傳送中斷訊號的計時器間隔通常是1-10ms,太短,切換太多,效能可能會變差,太長呢,如果在任務間切換頻繁,又無法提供在同時執行多工的假象。這個時間段,也決定了一些我們下面要分析的不同方法衡量時間的差異。
 

方法一:間隔計數

我 們都知道,Linux下有一個命令是專門提供一個程序的執行時間的,也就是time。time可以測量特定程序執行時所需消耗的時間及系統資源等,這個時 間還可以分核心時間和使用者態時間兩部分呈現給你。它是怎麼做到的呢?其實很簡單,作業系統本身就是用計時器來記錄每個程序使用的累計時間,原理很簡單,計 時器中斷髮生時,作業系統會在當前程序列表中尋找哪個程序是活動的,一旦發現,喲,程序A跑得正歡,立馬就給程序A的計數值增加計時器的時間間隔(這也是 引起較大誤差的原因,想想)。當然不是統一增加的,還要確定這個程序是在使用者空間活動還是在核心空間活動,如果是使用者模式,就增加使用者時間,如果是核心模 式,就增加系統時間。

原理很簡單吧?但是相信一點,越簡單的東西,是不會越精確的,人品守恆,能量守恆,難度也當然會守恆了啊。下面就簡 單分析一下,為啥這玩意精度不高吧。舉個例子,如果我們有一個系統,計時器間隔為10ms,系統裡面跑了一個程序,然後我們用這種方法分析時間,測出 70ms,想一想,實際會有幾種結果?具體點,我們用這種方法對程序計時,在某個計時器中斷時,系統發現,咦,有一個程序開始跑了,好,給程序的計數值加 上10ms。但是實際上呢,這個程序可能是一開始就跑起來了,也肯能是在中斷的前1ms才開始跑的。不管是什麼原因,總之中斷時候它在跑,所以就得加 10ms。當中斷髮生時發現程序切換了,同理,可能是上一個中斷之後1ms程序就切換了,也可能人家剛剛才切換。

所以呢,如果一個程序的 執行時間很短,短到和系統的計時器間隔一個數量級,用這種方法測出來的結果必然是不夠準確的,頭尾都有誤差。不過如果程式的時間足夠長,這種誤差有時能夠 相互彌補,一些被高估一些被低估,平均下來剛好,呵呵。從理論上,我們很難分析這個誤差的值,所以一般只有程式到達秒的數量級時,用這種方式測試程式時間 才有意義。

說了半天,難道這方法沒優點了?不,這個世界沒有純善,也沒有純惡。這方法最大的優點是,它的準確性不是非常依賴於系統負載。那什麼方法依賴於系統負載呢?接下來我們會講到:)

理論陳述結束,我想應該開始關注實現方法了吧。其實超級簡單,兩種方法:
 

  1. 直接呼叫time命令(一堆雞蛋)
  2. 使用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下,這是最有效而方便的計時方式了。從測試情況看,精確度也不錯。這種價格便宜量又足的東西嘛,大家可以隨便多用。
 

總結

這次的總結很簡單:沒有一個計時方法是完美的,我們所要作的,就是理解本質後,在特定的系統上去尋找特定的好方法。