上一講我們討論了關於CPU的性能指標和分析。CPU和內存是和程序性能最相關的兩個領域;那么這一講,我們就來討論和內存相關的性能指標和性能分析的工具。
內存方面的性能指標,主要有緩存命中率、緩存一致性、內存帶寬、內存延遲、內存的使用大小及碎片、內存的分配和回收速度等,接下來我會逐一進行介紹。現代很多CPU都是NUMA架構的,所以我也會介紹NUMA的影響和常用的工具。
緩存和緩存命中率
我們先看看緩存,也就是Cache。
緩存是CPU與內存之間的臨時數據交換器,是為了解決兩種速度不匹配的矛盾而設計的。這個矛盾就是CPU運行處理速度與內存讀寫速度不匹配的矛盾。CPU處理指令的速度,比內存的速度快得多了,有百倍的差別,這一點我們已經在上一講討論過。
緩存的概念極為重要。不止是CPU,緩存的策略也用在計算機和互聯網服務中很多其他的地方,比如外部存儲、文件系統,以及程序設計上。有人甚至開玩笑說,計算機的各種技術說到底就是三種——Cache(緩存)、Hash(哈希處理)和Trash(資源回收)。這種說法當然有點偏頗,但你也能從中看到緩存技術的重要性。
現在回到CPU緩存的討論上來。
我們前面也講了,隨着多核CPU的發展,CPU緩存通常分成了三個級別:L1、L2、L3。一般而言,每個核上都有L1和L2緩存。L1緩存其實分成兩部分:一個用於存數據,也就是L1d Cache(Data Cache),另外一個用於存指令,L1i Cache(Instruction Cache)。
L1緩存相對較小,每部分差不多只有幾十KB。L2緩存更大一些,有幾百KB,速度就要慢一些了。L2一般是一個統一的緩存,不把數據和指令分開。L3緩存則是三級緩存中最大的一級,可以達到幾個MB大小,同時也是最慢的一級了。你要注意,在同一個處理器上,所有核共享一個L3緩存。
為什么要采用多級緩存,並逐級增加緩存大小呢?
這個目的,就是為了提高各級緩存的命中率,從而最大限度地降低直接訪問內存的概率。每一級緩存的命中率都很重要,尤其是L1的命中率。這是因為緩存的命中率對總體的訪問時間延遲影響很大,而且下一級緩存的訪問延遲往往是上一級緩存延遲的很多倍。
為了加深你的理解,我還是用文章中圖片里的延遲數據來舉例說明一下。
在圖片里你可以看到,L1的訪問時間是3個時鍾周期,L2的訪問時間是12個時鍾周期。假如在理想情況下,L1i的命中率是100%,就是說每條指令都從L1i里面取;那么平均指令訪問時間也是3個時鍾周期。作為對比,如果L1i命中率變成90%,也就是只有90%的指令從L1i里面取到,而剩下的10%需要L2來提供。
那么平均指令訪問時間就變成了3.9個指令周期(也就是:90%*3+10%*12)。雖然看起來只有10%的指令沒有命中,但是相對於L1命中率100%的情況,平均訪問時間延遲差不多增大了多少呢?高達30%。
緩存一致性
雖然緩存能夠極大地提升運算性能,但也帶來了一些其他的問題,比如“緩存一致性問題(cache coherence)”。
如果不能保證緩存一致性,就可能造成結果錯誤。因為每個核都有自己的L1和L2緩存,當在不同核上運行同一個進程的不同線程時,如果這些線程同時操作同一個進程內存,就可能互相沖突,最終產生錯誤的結果。
舉個例子,你可以設想這樣一個場景,假設處理器有兩個核,core-A和core-B。這兩個核同時運行兩個線程,都操作共同的變量,i。假設它們並行執行i++。如果i的初始值是0,當兩個線程執行完畢后,我們預期的結果是i變成2。但是,如果不采取必要的措施,那么在實際執行中就可能會出錯,什么樣的錯誤呢?我們來探討一下。
運行開始時,每個核都存儲了i的值0。當第core-A做i++的時候,其緩存中的值變成了1,i需要馬上回寫到內存,內存之中的i也就變成了1。但是core-B緩存中的i值依然是0,當運行在它上面的線程執行i++,然后回寫到內存時,就會覆蓋core-A內核的操作,使得最終i 的結果是1,而不是預期中的2。
為了達到數據訪問的一致,就需要各個處理器和內核,在訪問緩存和寫回內存時遵循一些協議,這樣的協議就叫緩存一致性協議。常見的緩存一致性協議有MSI、MESI等。
緩存一致性協議解決了緩存內容不一致的問題,但同時也造成了緩存性能的下降。在有些情況下性能還會受到嚴重影響。我們下一講還會仔細分析這一點,並且討論怎樣通過優化代碼來克服這樣的性能問題。
內存帶寬和延遲
我們討論了緩存,接下來探討內存帶寬和內存訪問延遲。
計算機性能方面的一個趨勢就是,內存越來越變成主要的性能瓶頸。內存對性能的制約包括三個方面:內存大小、內存訪問延遲和內存帶寬。
第一個方面就是內存的使用大小,這個最直觀,大家都懂。這方面的優化方式也有很多,包括采用高效的,使用內存少的算法和數據結構。
第二個方面是內存訪問延遲,這個也比較好理解,我們剛剛討論的各級緩存,都是為了降低內存的直接訪問,從而間接地降低內存訪問延遲的。如果我們盡量降低數據和程序的大小,那么各級緩存的命中率也會相應地提高,這是因為緩存可以覆蓋的代碼和數據比例會增大。
第三個方面就是內存帶寬,也就是單位時間內,可以並行讀取或寫入內存的數據量,通常以字節/秒為單位表示。一款CPU的最大內存帶寬往往是有限而確定的。並且一般來說,這個最大內存帶寬只是個理論最大值,實際中我們的程序使用只能達到最大帶寬利用率的60%。如果超出這個百分比,內存的訪問延遲會急劇上升。
文章中的圖片就展示了幾款Intel的CPU的內存訪問延遲和內存帶寬的關系(圖片來自https://images.anandtech.com)。
圖中內存帶寬使用是橫軸,相對應的,內存訪問延遲是縱軸。你可以清楚地看到,當內存帶寬較小時,內存訪問延遲很小,而且基本固定,最多緩慢上升。而當內存帶寬超過一定值后,訪問延遲會快速上升,最終增加到不能接受的程度。
那么一款處理器的內存總帶寬取決於哪些因素呢?
答案是,有四個因素,內存總帶寬的大小就是這些因素的乘積。這四個因素是:DRAM時鍾頻率、每時鍾的數據傳輸次數、內存總線帶寬(一般是64字節)、內存通道數量。
我們來用幾個實際的Intel CPU為例,來看看內存帶寬的變化。
文章中的這個表格大體上總結了5款Intel CPU的各級緩存大小、內存通道數目、可使用內存帶寬(這里取最大值的50%)、內存頻率和速度。你可以看到,每款新的CPU,它的內存帶寬一般還是增加的,這主要歸功於內存頻率的提升。
內存的分配
講過內存帶寬,我們再來看看內存的分配。程序使用的內存大小很關鍵,是影響一個程序性能的重要因素,所以我們應該盡量對程序的內存使用大小進行調優,從而讓程序盡量少地使用內存。
不知道你有沒有過系統內存用光的經驗?每當發生這種情況,系統就會被迫殺掉一些進程,並且拋出一個系統錯誤:內存用光“OOM(Out of memory)”。所以,一個應用程序用的內存越少,那么OOM錯誤就越不太可能發生。
還有,服務器等容量也是公司運營成本的一部分。如果一台服務器的內存資源足夠,那么這樣一個服務器系統就可以同時運行多個程序或進程,以最大限度地提高系統利用率,這樣就節省了公司運營成本。
再進一步講,應用程序向操作系統申請內存時,系統會分配內存,這中間總要花些時間,因為操作系統需要查看可用內存並分配。一個系統的空閑內存越多,應用程序向操作系統申請內存的時候,就越快地拿到所申請的內存。反之,應用程序就有可能經歷很大的內存請求分配延遲。
比如說,在系統空閑內存很少的時候,程序很可能會變得超級慢。因為操作系統對內存請求進行(比如malloc())處理時,如果空閑內存不夠,系統需要采取措施回收內存,這個過程可能會阻塞。
我們寫程序時,或許習慣直接使用new、malloc等API申請分配內存,直觀又方便。但這樣做有個很大的缺點,就是所申請內存塊的大小不定。當這樣的內存申請頻繁操作時,會造成大量的內存碎片;這些內存碎片會導致系統性能下降。
一般來講,開發應用程序時,采用內存池(Memory Pool)可以看作是一種內存分配方式的優化。
所謂的內存池,就是提前申請分配一定數量的、大小仔細考慮的內存塊留作備用。當線程有新的內存需求時,就從內存池中分出一部分內存塊。如果已分配的內存塊不夠,那么可以繼續申請新的內存塊。同樣,線程釋放的內存也暫時不返還給操作系統,而是放在內存池內留着備用。
這樣做的一個顯著優點是盡量避免了內存碎片,使得內存分配效率和系統的總體內存使用效率得到提升。
NUMA的影響
我們剛剛談了內存性能的幾個方面,最后看看多處理器使用內存的情景,也就是NUMA場景。NUMA系統現在非常普遍,它和CPU和內存的性能都很相關。簡單來說,NUMA包含多個處理器(或者節點),它們之間通過高速互連網絡連接而成。每個處理器都有自己的本地內存,但所有處理器可以訪問全部內存。
因為訪問遠端內存的延遲遠遠大於本地內存訪問,操作系統的設計已經將內存分布的特點考慮進去了。比如一個線程運行在一個處理器中,那么為這個線程所分配的內存,一般是該處理器的本地內存,而不是外部內存。但是,在特殊情況下,比如本地內存已經用光,那就只能分配遠端內存。
我們部署應用程序時,最好將訪問相同數據的多個線程放在相同的處理器上。根據情況,有時候也需要強制去綁定線程到某個節點或者CPU核上。
工具
內存相關的工具也挺多的。比如,你最熟的內存監測命令或許是free了。這個命令會簡單地報告總的內存、使用的內存、空閑內存等。
vmstat(Virtual Meomory Statistics, 虛擬內存統計)也是Linux中監控內存的常用工具,可以對操作系統的虛擬內存、進程、CPU等的整體情況進行監視。
我建議你也盡量熟悉一下Linux下的/proc文件系統。這是一個虛擬文件系統,只存在內存當中,而不占用外存空間。這個目錄下有很多文件,每一個文件的內容都是動態創建的。這些文件提供了一種在Linux內核空間和用戶間之間進行通信的方法。比如/proc/meminfo就對內存方面的監測非常有用。這個文件里面有幾十個條目,比如SwapFree,顯示的是空閑swap總量等。
另外,/proc這個目錄下還可以根據進程的ID來查看每個進程的詳細信息,包括分配到進程的內存使用。比如/proc/PID/maps文件,里面的每一行都描述進程或線程中連續虛擬內存的區域;這些信息提供了更深層次的內存剖析。
總結
我們都知道宋代詞人辛棄疾,他曾經這樣憧憬他的戰場夢想:“馬作的盧飛快,弓如霹靂弦驚。” 我們開發的應用程序對內存的分配請求延遲,也有相似的期盼,就是要動作飛快。如果內存分配延遲太大,整個程序的性能自然也高不上去。
如何實現這個夢想呢?就需要我們的代碼和程序,盡量降低對內存的使用大小和內存帶寬,盡量少地請求分配和釋放內存,幫助系統內存狀態不至於太過碎片化,並且對代碼結構做一些相應地優化