|
在前兩篇文章里,我們討論了程序性能的兩個(gè)方面,一是算法(廣義的算法,即解決問(wèn)題的方法),二是編譯器。通過(guò)這兩個(gè)方面,我想表達(dá)的意思是,一段程序的執(zhí)行效率,是很難從表面現(xiàn)象得出結(jié)論的,至少?gòu)囊恍┖?jiǎn)單的層面,如代碼的長(zhǎng)度是幾乎難以說(shuō)明任何問(wèn)題——因此一定要進(jìn)行Profiling才能做到有效的優(yōu)化。而現(xiàn)在,我們假設(shè)兩段程序算法基本相同,編譯器也只是進(jìn)行簡(jiǎn)單的“翻譯”,那么……我們能從“表面”看出性能高下嗎?
那么就從一個(gè)最簡(jiǎn)單的例子看起吧。假設(shè)DoSomethingA和DoSomethingB里做的事情是固定的,那么您認(rèn)為下面兩種寫法的哪個(gè)性能更好?
for (int i = 0; i < 100; i++){ DoSomethingA(); DoSomethingB();}
for (int i = 0; i < 100; i++) DoSomethingA();for (int i = 0; i < 100; i++) DoSomethingB();
這兩段邏輯的算法基本上完全相同,如果編譯器只是進(jìn)行直接“翻譯”而不進(jìn)行優(yōu)化,那么第一種做法對(duì)于i的累加和條件跳轉(zhuǎn)比較少,因此您可能會(huì)得出結(jié)論:“很明顯”第一段代碼的執(zhí)行效率比較高。只可惜事實(shí)并非那么簡(jiǎn)單,因?yàn)橛绊懗绦蛐阅艿牧硪粋€(gè)關(guān)鍵因素是:緩存。
“緩存”無(wú)處不在。在CPU中,性能最快的存儲(chǔ)設(shè)備當(dāng)屬“寄存器”,不過(guò)眾所周知寄存器的數(shù)量是極其有限的。因此,CPU都會(huì)有L1 Cache和L2 Cache的多級(jí)緩存機(jī)制。其中,L2 Cache的性能比L1 Cache和寄存器都要慢,但還是比內(nèi)存要快許多。當(dāng)某個(gè)Core需要從內(nèi)存中獲取數(shù)據(jù)的時(shí)候,便會(huì)從L1 Cache獲取數(shù)據(jù),如果L1 Cache沒(méi)有那么就會(huì)從多個(gè)核共用的L2 Cache拿,再?zèng)]有便會(huì)從內(nèi)存拿——由于操作系統(tǒng)的虛擬內(nèi)存機(jī)制,可能還要從磁盤的交換頁(yè)中獲取數(shù)據(jù),此時(shí)性能自然相當(dāng)差了。
雖然寄存器只使用一個(gè)字長(zhǎng)(如4字節(jié))的數(shù)據(jù),但是L1 Cache從L2 Cache拿數(shù)據(jù)時(shí)總是“一塊一塊”拿的——這么一塊往往就是連續(xù)的64個(gè)字節(jié)。換句話說(shuō),在CPU讀取的一個(gè)地址的數(shù)據(jù)之后,讀取其他一些地址上的數(shù)據(jù)便會(huì)比另一些特別快,因?yàn)樗鼈兌家呀?jīng)在L1 Cache中了。如果一個(gè)程序能夠利用起CPU的這個(gè)特性,那它的性能往往便可以更好一些(自然還有很多其他影響性能的因素)。
局部性(Locality),便是用來(lái)描述程序是否能利用好緩存的名詞。我們說(shuō)一個(gè)程序的局部性比較好,那么就表示它能夠較好地利用起CPU的緩存機(jī)制。局部性分“空間局部性”和“時(shí)間局部性”兩方面,前者是指“加載一個(gè)地址的數(shù)據(jù)之后,繼續(xù)加載它附近的數(shù)據(jù)”,后者表示“在加載一個(gè)地址的數(shù)據(jù)之后,短時(shí)間內(nèi)重新加載這塊數(shù)據(jù)”。無(wú)論是哪一方面,目的都是希望從較快的緩存中加載“熱”的數(shù)據(jù)。為什么冷啟動(dòng)總是很慢?為什么有人說(shuō)系統(tǒng)從開機(jī)后會(huì)越跑越快?其實(shí)道理都差不多。
那么現(xiàn)在,您還能判斷上面兩種做法的效率孰高孰低?雖然第一種做法減少了i的累加次數(shù)和條件跳轉(zhuǎn)的次數(shù),但是它在一次循環(huán)中做了兩件事情,可能在執(zhí)行DoSomethingB方法的時(shí)候,DoSomethingA方法中剛剛進(jìn)入緩存的數(shù)據(jù)便冷卻了,于是在下次執(zhí)行DoSomethingA時(shí)又要重新從較慢的存儲(chǔ)設(shè)備中加載數(shù)據(jù)。而在第二種做法中,我們“密集”地執(zhí)行完100次DoSomethingA或DoSomethingB的調(diào)用,而此間大量的數(shù)據(jù)訪問(wèn)都是集中在L1 Cache上,性能優(yōu)勢(shì)不言而喻。
我以前的文章《計(jì)算機(jī)體系結(jié)構(gòu)與程序性能》在第一部分里也討論了局部性對(duì)程序性能的影響,講的更為具體一些,您也可以參考其中的內(nèi)容。
由于程序指令不是執(zhí)行效率的唯一因素,因此從代碼長(zhǎng)短上判斷程序性能也是非常不靠譜的事情。當(dāng)然,從任何獨(dú)立的角度來(lái)判斷性能可能都不合適。例如在那篇文章里提到,出于程序性能的考慮應(yīng)該使用全局變量——當(dāng)然作者也認(rèn)為這不是好的設(shè)計(jì),事實(shí)上在我們剛才的例子中,在一個(gè)循環(huán)中做多件事情可能也值得重構(gòu)。如果您使用全局變量,它的確省下了push,pop等指令的開銷,但是這么一個(gè)全局變量——例如是一個(gè)靜態(tài)變量,它存儲(chǔ)在堆的某一個(gè)地方,訪問(wèn)它并非是一個(gè)局部性方面的優(yōu)秀實(shí)踐。與之相反,由于L1 Cache的作用,在調(diào)用棧上訪問(wèn)“參數(shù)”或“局部變量”并不會(huì)比訪問(wèn)寄存器慢多少,此時(shí)push,pop幾個(gè)指令的開銷可能就不算什么了。更何況,如果編譯器/運(yùn)行時(shí)內(nèi)聯(lián)了這個(gè)方法,這樣連push,pop等指令也不會(huì)出現(xiàn)了。
記得前一段時(shí)間在有某些朋友在我的博客上發(fā)布一些較為“激進(jìn)”的說(shuō)法,例如“學(xué)底層只是對(duì)寫.NET程序沒(méi)有幫助,因?yàn)榫退隳阒懒诉@些,C#也沒(méi)有辦法內(nèi)嵌匯編”。我不同意這個(gè)說(shuō)法,因?yàn)榧幢闶?NET程序,它也是在符合計(jì)算機(jī)體系結(jié)構(gòu)的規(guī)律下運(yùn)行的,我們完全可以在一定程度上了解一段代碼在執(zhí)行時(shí)的表現(xiàn)。
就拿目前談到的“局部性”來(lái)說(shuō),我們便可以把握很多東西。比如,我們知道每個(gè)線程的調(diào)用棧在默認(rèn)情況下是1兆大小,因此兩個(gè)線程調(diào)用棧上的數(shù)據(jù)幾乎不可能出現(xiàn)在同一個(gè)Cache條目中。再比如,由于“時(shí)間局部性”,最近使用的數(shù)據(jù)最有可能出現(xiàn)在緩存中,因此在.NET 4.0的并行庫(kù)在調(diào)度“私有隊(duì)列”的任務(wù)時(shí)會(huì)傾向于執(zhí)行最新創(chuàng)建的任務(wù)。再比如,您是使用兩個(gè)int數(shù)組來(lái)表示一系列坐標(biāo)的x值和y值,還是構(gòu)造一個(gè)struct Point數(shù)組來(lái)保存它們呢?雖然使用兩個(gè)int數(shù)組更節(jié)省內(nèi)存,但是從局部性考慮問(wèn)題的話,您會(huì)發(fā)現(xiàn)同一個(gè)坐標(biāo)的x值和y值存放在一起可能更為合適。
我的這幾篇文章,其實(shí)也都在強(qiáng)調(diào)從代碼表面判斷程序性能的“不確定性”。同樣道理,即便是把它們的匯編代碼(片斷)放在您面前,您也可能很難“看出”性能區(qū)別。這也從側(cè)面說(shuō)明了Profiling的重要性:閱讀代碼是靜態(tài)的,而程序執(zhí)行和Profiling都是動(dòng)態(tài)的。之前有朋友對(duì)我說(shuō)“你最近迷上Profiler啦?”其實(shí)我這里的Profiling泛指“一種探索程序性能的方式”,并不是指某個(gè)特定的手段,更不是某個(gè)具體的工具——不過(guò)無(wú)論是使用VS的Profiler也好,還是自己搞一個(gè)CodeTimer,都比“讀代碼”來(lái)的可靠。
it知識(shí)庫(kù):淺談代碼的執(zhí)行效率(3):緩存與局部性,轉(zhuǎn)載需保留來(lái)源!
鄭重聲明:本文版權(quán)歸原作者所有,轉(zhuǎn)載文章僅為傳播更多信息之目的,如作者信息標(biāo)記有誤,請(qǐng)第一時(shí)間聯(lián)系我們修改或刪除,多謝。