百度筆試題7.5

題目五:

http://topic.csdn.net/t/20061008/22/5068270.html

3.10分)某型CPU的一級數據緩存大小爲16K字節,cache塊大小爲64字節;二級緩存大小爲256K字節,cache塊大小爲4K字節,採用二路組相聯。經測試,下面兩段代碼運行時效率差別很大,請分析哪段代碼更好,以及可能的原因。  

  爲了進一步提高效率,你還可以採取什麼辦法?  

  A段代碼  

  int   matrix[1023][15];  

  const   char   *str   =   "this   is   a   str";  

  int   i,   j,   tmp,   sum   =   0;  

   

  tmp   =   strlen(str);  

  for(i   =   0;   i   <   1023;   i++)   {  

  for(j   =   0;   j   <   15;   j++)   {  

  sum   +=   matrix[i][j]   +   tmp;  

  }  

  }  

   

  B段代碼  

  int   matrix[1025][17];  

  const   char   *str   =   "this   is   a   str";  

  int   i,   j,   sum   =   0;  

   

  for(i   =   0;   i   <   17;   i++)   {  

  for(j   =   0;   j   <   1025;   j++)   {  

  sum   +=   matrix[j][i]   +   strlen(str);  

  }  

  }

 

 

Answer1

http://blog.chinaunix.net/u/23701/showart_175838.html

A段代碼效率要遠遠高於B段代碼,原因有三:

1   

B效率低最要命的地方就是每次都要調用strlen()函數,這是個嚴重問題,屬於邏輯級錯誤。假設A的兩層循環都不改變,僅僅是把A的那個循環裏面的temp換成strlen()調用,在Windows 2000 (Intel ) 下測試,竟然是A的執行時間的3.699倍。(這裏沒有涉及不同CPU有不同的Cache設計)僅僅是這一點就已經說明B段代碼垃圾代碼。

 

2

       這也是一個邏輯級的錯誤。在這裏我們再做個試驗,AB段代碼分別採用大小一樣的數組[1023][15][1023][16][1023][17],只是在循環上採取了不同的方式。兩者在運行時間上也是有很大差異的了。B的運行時間大概是A1.130倍。

 

       那麼這是因爲什麼呢?其實也很簡單,那就是A段代碼中的循環執行語句對內存的訪問是連續的,而B段代碼中的循環執行語句對內存的訪問是跳躍的。直接降低了B代碼的運行效率。

 

       這裏不是內層循環執行多少次的問題,而是一個對內存訪問是否連續的問題

 

3

A的二維數組是[1023][15]B的二維數組是[1027][17],在這裏B段代碼有犯了一個CPU級錯誤(或者是Cache級的錯誤)。

 

因爲在Cache中數據或指令是以行爲單位存儲的(也可以說是Cache),一行又包含了很多字。如現在主流的設計是一行包含64Byte。每一行擁有一個Tag。因此,假設CPU需要一個標爲Tag 1的行中的數據,它會通過CAMCache中的行進行查找,一旦找到相同Tag的行,就對其中的數據進行讀取。

 

A的是15 *4B 60B,一個Cache行剛好可以存儲。B的是17*4B 68B,超過了一個Cache行所存儲的數據。很明顯17的時候命中率要低於15的時候。

 

現在我們先不管AB的循環嵌套的順序,僅僅拿A段代碼來做個試驗,我們將會分三種情況來進行:

 

[1023][15]           [1023][16]     [1023][17]

 

運行結果並沒有出乎意料之外 17 的時候的運行時間大概是 15 的時候的1.399倍,除去有因爲17的時候多執行循環,17/15 1.133 。進行折算,17的時候大概是15的時候的1.265倍。

 

16的時候的執行時間要比15的時候的執行時間要短,因爲是16的時候,Cache命中率更高。16/15 1.066 ,而15的執行時間卻是161.068倍,加上16多執行的消耗,進行折算,15的時候大概是16的時候執行時間的1.134倍。

 

因爲A段代碼是15,而B段代碼是17,在這一點上B段代碼的效率要低於A段代碼的效率。這是一個CPU級的錯誤(或者是Cache級的錯誤),這裏涉及到Cache的塊大小,也就涉及到Cache命中率,也就影響到代碼效率。

 

不再假設什麼,僅僅對A段和B段代碼進行測試,B段代碼的執行效率將是A段代碼執行效率的3.95倍。當然最大的罪魁禍首就是B中的重複調用strlen()函數。後面兩個錯誤告訴我們當需要對大量數據訪問的時候,一定要注意對內存的訪問要儘量是連續而且循環內層的訪問接近Cache的塊大小,以提高Cache的命中率,從而提高程序的運行效率。  

 

所以可以對代碼進行一下修改:

 

#define XX    15   

 

#define YY    1023

 

int matrix[XX][YY];

 

const char *str = "this is a str";

 

int i, j, tmp, sum = 0;

 

tmp = strlen(str);

 

for(i = 0; i < XX; i++)

 

   for(j = 0; j < YY; j++)

 

      sum += matrix[i][j] + tmp;

 

這個程序僅僅是把數組的聲明給顛倒了一下,循環也顛倒了一下,看起來和運行起來和上面給出的A段代碼沒有多大的區別。但是如果當XX很小,比如:8,那麼這段程序和給出的A段代碼就有區別了。這是因爲這樣做可以提高Cache的命中率。

 

 

 

 

 

 

Answer2:

http://rednaxelafx.javaeye.com/blog/412560

這個問題的主要關注點很明顯是關於存儲器層次(memory hierarchy)與緩存(caching)的。先看看相關背景。

 

存儲技術在幾個不同層次上發展,其中存儲密度高、單價便宜的存儲器速度比較慢,速度快的存儲器的存儲密度則相對較低且價格昂貴。爲了在性能與價格間找到好的平衡點,現代計算機系統大量採用了緩存機制,使用小容量的高速存儲器爲大容量的低速存儲器提供緩存。

最快的存儲器是CPU裏的各種寄存器,其次是在CPU芯片內的L1緩存,再次是在CPU芯片內或者離CPU很近的L2緩存,然後可能還有L3緩存,接着到主內存,後面就是各種外部存儲設備如磁盤之類,最後還有諸如網絡存儲器之類的更慢的存儲器。

L1緩存可能會成對出現,一個用於指令,另一個用於數據。L2緩存和後面的緩存則設計得更通用些。由於主內存比磁盤快很多但相對來說價格昂貴許多,而同時運行多個程序所需要的存儲空間通常不能直接被主內存滿足,所以現代操作系統一般還有虛擬內存,使用磁盤作爲主內存的擴充。虛擬內存也可以反過來看作“所有虛擬內存都是在磁盤上的,將其中活躍的一些放在物理內存裏是一種優化”(Eric Lippert如是說)。

 

如果要訪問的數據位於存儲器層次的較低層,則數據是逐層傳遞到CPU的。例如,程序要訪問某個地址的數據,在L1緩存裏沒有發現(稱爲L1緩存不命中,L1 cache miss),則跑到L2緩存找;找到的話,會先把這一數據及鄰近的一塊數據複製到L1緩存裏,然後再從L1緩存把需要的數據傳給CPU

 

爲了充分利用緩存機制,程序應該有良好的局部性(locality)。局部性指的是程序行爲的一種規律性:在程序運行中的短時間內,程序訪問數據位置的集合限於局部範圍。局部性有兩種基本形式:時間局部性(temporal locality)與空間局部性(spatial locality)。由於指令也可以看作數據的一種特殊形式,因而局部性對指令來說也有效。

時間局部性指的是反覆訪問同一個位置的數據:如果程序在某時刻訪問了存儲器的某個地址,則程序很可能會在短時間內再次訪問同一地址。例如,在執行一個循環,則循環的代碼就有好的時間局部性;又例如在循環裏訪問同一個變量,則對該變量的訪問也有好的時間局部性。

空間局部性指的是反覆訪問相鄰的數據:如果程序在某時刻訪問了存儲器的某個地址,則程序很可能會在短時間內訪問該地址附近的存儲器空間。例如按順序執行的指令就有良好的空間局部性;又例如按存儲順序挨個遍歷數組,也有良好的空間局部性。

 

還有很多很重要的背景信息,這裏就不詳細寫了。我主要是讀《Computer Organization and Architechture: Designing for Performance, 5th Edition》和《Computer Systems: A Programmer's Perspective》學習的。大一的時候也好好上了計算機組成與結構的課,用的課本就是前一本書,還能記得一些。

 

那麼回到開頭的題目。兩段代碼有一些特徵是相同的,包括:

(1) 它們都使用了一個int矩陣,而且行的寬度比列的長度要短。

(2) 它們都含有一個char指針,指向的是一個字符串字面量。這意味着對該字符串調用strlen()總是會得到同一個值,而且該值在編譯時可計算。

(3) 它們都遍歷了整個矩陣,並且對矩陣中每個元素的值求和。

 

兩段代碼主要的差異是:

(1) 遍歷順序不同。A按行遍歷,B按列遍歷。

(2) 內外循環的分佈不同。使用兩層的嵌套循環來遍歷這個數組,則:按行遍歷的話,外層循環次數等於行數,內層循環次數等於列數;按列遍歷則正好相反。A的外層循環比內層循環次數多很多;而B的內層循環次數比外層多。

(3) 循環中是否重複求值。A在遍歷矩陣前預先對字符串調用了strlen(),將結果保存在一個臨時變量裏;遍歷矩陣時訪問臨時變量來獲取該值;B在遍歷矩陣時每輪都調用strlen()

(4) AB的矩陣大小不同,A較小而B較大。

 

========================================================================

 

Computer Systems: A Programmer's Perspective》的6.2.1小節,Locality of References to Program Data介紹了程序數據的局部性。其中提到一個概念:訪問連續存儲空間中每隔k個的元素,稱爲stride-k reference pattern。連續訪問相鄰的元素就是stride-1訪問模式,是程序中空間局部性的重要來源。一般來說,隨着stride的增大,空間局部性也隨之降低。

 

C的二維數組在內存中是按行優先的順序儲存的。許多其它編程語言也是如此,但並非全部;FORTRAN就是一種典型的例外,採用列優先的存儲順序。在C中,按行遍歷二維數組,遍歷順序就與存儲順序一致,因而是stride-1訪問模式。如果按列訪問一個int matrix[M][N],則是stride-(N*sizeof(int))訪問模式。

由此可知,差異(1)使得A段代碼比B段代碼有更好的空間局部性,因而應該能更好的利用緩存層次。

 

結合兩段代碼中矩陣的大小來看看緩存不命中的狀況。題目沒有提到“某型CPU”上int的長度是多少,也沒有提到內存的尋址空間有多大。這裏把兩者都假設是32位的來分析。

 

32位的int意味着sizeof(int)等於4B。則A段代碼的matrix大小爲sizeof(int)*15*1023 == 61380B,小於128KBmatrix的每行大小爲sizeof(int)*15 == 60B,小於64BB段代碼的matrix大小爲sizeof(int)*17*1025 == 69700B,也小於128KB;每行大小爲sizeof(int)*17 == 68B,大於64B

 

題中L1緩存大小是16KB,每條cache line大小是64B,也就是說一共有256cache line。沒有說明L1L2緩存的映射方式,假設是直接映射。

L2緩存大小是256KB,每條cache line大小4KB,也就是說一共有64cache line。因爲L2緩存是二路組相聯,所以這些cache line被分爲每兩條cache line一組,也就是分爲32組。同樣因爲是二路組相聯,所以主內存中地址連續的數據在把L2緩存的一半填滿之後,要繼續填就要開始出現衝突了。幸好L2緩存有256KB,兩段代碼中都能順利裝下各自的matrix。假設兩層緩存都採用LRUleast recently used)算法來替換緩存內容。

 

A段代碼中,matrix的一行可以完整的放在一條L1 cache line裏,一條L2 cache line可以裝下68行多一些。觀察其遍歷的方式。假設兩層緩存剛開始都是“冷的”,訪問matrix[0][0]時它尚未被加載到L2緩存,並且假設matrix[0][0]被映射到一條L2 cache line的起始位置(意味着matrix4KB對齊的地址上)。

這樣,在第一輪內層循環時訪問matrix[0][0],會發生一次L1緩存不命中和一次L2緩存不命中,需要從主內存讀4KBL2緩存,再將其中64字節讀到L1緩存。第二輪內層循環時,訪問matrix[0][1]L1緩存命中。第三輪也是L1緩存命中。直到讀到matrix[1][1]的時候纔會再發生一次L1緩存不命中,此時L2緩存命中,又從L2緩存讀出64字節複製到L1緩存。重複這個過程,直到遍歷了68行多一些的時候,又會發生一次L2緩存不命中,需要從主內存讀數據。tmpsum變量有良好的時間局部性,應該能一直在寄存器或者L1緩存中。以此類推,可以算出:每輪外層循環都執行15輪內層循環,遍歷了matrix的一整行;每遍歷16行會發生大約15L1緩存不命中(如果matrix[0][0]不是被映射到cache line的開頭的話,會發生16次);每遍歷1023行會發生大約15L2緩存不命中。加起來,A段代碼在循環中大概會遇到15L2緩存不命中,960L1緩存不命中。

 

B段代碼中,matrix的一行無法完整的放在一條L1 cache line裏,一條L2 cache line可以裝下60行多一些。遍歷的元素相隔一行。同樣假設兩層緩存剛開始都是“冷的”,則遍歷過程中剛開始每訪問matrix的一個元素都會發生1L1緩存不命中,每訪問60行多一些就會發生1L2緩存不命中。等遍歷完了matrix的第一列之後,經過了1輪外層循環(1025輪內層循環);此時兩層緩存都已經熱起來,整個matrix都被緩存到L2中;根據LRU算法,matrix[0][0]已經不在L1緩存中。照此觀察,後續的遍歷過程中都不會再出現L2緩存不命中,但每訪問一個元素仍然會發生一次L1緩存不命中。加起來,B段代碼在循環中大概會遇到18L2緩存不命中,17425L1緩存不命中。

 

遍歷順序與矩陣大小結合起來,使A段代碼發生L1緩存不命中的次數遠小於B段代碼的,而兩者的L2緩存不命中次數差不多。因此,從緩存的角度看,A段代碼會比B段代碼執行得更有效率。

 

========================================================================

 

然後再看看在循環中調用strlen()的問題。從源碼表面上看,B段代碼的每輪內層循環中都要調用一次strlen(),其中要遍歷一次str字符串。strlen()本身的時間開銷是O(L)的(L爲字符串長度),放在M×N的嵌套循環裏調用,會帶來OL×M×N)的時間開銷,相當可觀。

但前面也分析過,題中兩段代碼都是對字符串字面量調用strlen(),是編譯時可以計算的量,所以會被編譯器優化爲常量。事實上VCGCC都會將這種情況下的strlen()的調用優化爲常量。所以這題裏在循環中調用strlen()並不會帶來額外的開銷——因爲編譯出來的代碼裏就不會在循環裏調用strlen()了。

即使不是對字符串字面量調用strlen(),如果str在循環中沒有改變,那麼strlen(str)的結果也應該是循環不變量,理論上B段代碼可以被編譯器自動優化爲A段代碼的形式,將strlen()的調用外提。不過在許多例子裏,VCGCC似乎都沒能成功的進行這種優化。以後會找個實際例子來看看。

 

========================================================================

 

這個題目裏的代碼還不僅涉及緩存層次的問題,還涉及到指令執行的問題。現代CPU一般都支持指令流水線(instruction pipelining)和預測性執行(speculative execution)。通過將一條指令拆分爲多個可以並行執行的階段,CPU的一個執行核心可以在一個時鐘週期內處理多條指令;通過預先將後面的指令讀進CPU執行,CPU可以預測將來的執行結果。爲了能儘可能多的預測執行結果,CPU會對分支指令也做預測,猜測其會進行跳轉(branch taken)還是不跳轉(branch not-taken)。實際執行到跳轉指令的時候,並不是“發現需要跳轉到某地址”,而是“印證先前就發現的跳轉的猜測”。如果猜中了,則執行結果就會從一個緩存寫到寄存器中;如果猜錯了,就只能刷掉之前猜測的執行結果,重新讀取指令,重新開始流水線的執行,從而帶來相當的開銷。對分支的預測稱爲branch prediction,猜錯的情況稱爲branch misprediction。分支預測有許多算法,多數都會考慮某條分支指令上一次或多次的跳轉情況。

 

爲了讓循環能正常結束,循環一般都有循環條件。這樣就至少有一個條件跳轉。可以想象,重複多次的循環,控制其結束的條件分支,除了最後一次都應該是向同一個目標跳轉的。這樣,每個循環至少會導致一次分支預測錯誤。計算循環條件本身也有一定開銷,與分支預測錯誤一起,都是循環的固有開銷。

在嵌套循環中,無論是內層循環還是外層循環,都是循環,固有開銷是避免不了的。把重複次數多的循環放在內層與外層會導致總的循環次數的不同。開頭的題目中,如果ABmatrix都統一爲1024*16的大小,則A總共要執行1024+1024*16 = 17408次循環,而B總共要執行16 + 16*1024 = 16400次循環。顯然,把重複次數多的循環放在內層比放在外層需要執行的循環次數少,相應需要付出的循環固有開銷也小。

 

題目問到要進一步提高效率應該採用什麼辦法。從前面的分析看,A在緩存方面有優勢但在指令執行方面有劣勢。如果要改進,可以把A中的matrix轉置爲int matrix[15][1023],使行的寬度比列的長度長。這樣在按行遍歷時重複次數較多的就從外層循環移到了內層,扭轉了A段代碼在指令執行方面的劣勢。

 

========================================================================

 

前面的分析都屬於“理想分析”,現實中我們寫的程序在實際機器上到底是怎麼執行的,那簡直就是magic。雖然Eric說別把東西想象成magic,但這裏我沒辦法……

 

例如說,我們不知道題目中的程序一共開了多少個線程。既然題目沒說“某型CPU”是多核的,假設它是單核的,那麼多個線程都要共享同一個L1L2緩存,留給A段代碼用的緩存到底有多少呢?就算不考慮線程的多少,操作系統也有些核心數據會盡量一直待在高速緩存裏,留給應用程序的緩存有多少呢?

 

既然我們知道要遍歷連續的數據,那與其讓它逐漸進入緩存,還不如先一口氣都放進緩存,後面實際訪問數據的時候就不會遇到緩存不命中。這叫做預取(prefetch)。在x86上有專門的指令prefetch-*來滿足預取的需求,如非時間性的prefetchnta與時間性的prefetcht0prefetcht1等等。編譯器有沒有爲代碼生成預取指令?使用預取之後緩存不命中的狀況能減少多少?不針對具體情況都沒辦法回答。畢竟有些CPU實現的時候乾脆就忽略指令中的預取,又或者編譯器生成了很糟糕的預取指令反而降低了程序性能,這些極端的可能性都存在。

 

另外一個要考慮的因素是,應用程序構建在操作系統之上,而操作系統一般有采用分頁的虛擬內存。像32Windows的頁大小就是4KBmatrix60KB左右,無法完整放在一頁裏。頁在映射到物理內存的時候,並不保證在matrix跨越不同頁仍然保持在物理內存中地址的連續性。所以matrix是否能理想的緩存到L2緩存而不發生衝突,其實不好說。

 

CPU支持的指令集與其實際執行的方式也不完全一致。像x86這樣的指令集早就成爲“遺留接口”了,實際硬件用類似RISC的方式去實現了CISCx86指令集,通過指令級並行執行來提高CPU的吞吐量。

x86一個很討厭的地方就是它可用的通用寄存器(general purpose register)的數量太少了,32GPR只有8個。那麼少的寄存器,指令是怎麼並行起來的呢?其實那8GPR也是假象,CPU可以通過寄存器重命名(register renaming)的方式讓一些指令可以直接把計算結果傳給下一條指令而不需要實際經過寄存器。預測性執行的結果也不是直接寫到寄存器,而是等分支預測被確認正確後才寫進去。這樣就能夠預測性執行多條指令而不破壞“當前”的CPU狀態。

 

It's magic...應用程序員一般也不會需要關心這種magic般的細節。在合適的抽象層次上選用合適的算法,用清晰的方式把代碼組織起來,遠比關心這種細節要重要得多。不過如果要寫編譯器的話,這些細節就是惡魔了。Devil is in the details……

 

========================================================================

 

Jay同學對編譯器處理循環和strlen()的方式感興趣。下一篇簡單分析一下strlen()的特性。

發佈了35 篇原創文章 · 獲贊 0 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章