1. 程式人生 > 實用技巧 >21-CPU案例:如何提高LLC(最後一級快取)的命中率

21-CPU案例:如何提高LLC(最後一級快取)的命中率

面兩講中,我介紹了效能優化的六大原則和十大策略。從今天開始,我們來通過具體案例的解決方案講解,瞭解這些原則和策略是如何應用的。

首先,我們要來探討的是一個CPU相關的效能優化案例。

這個效能案例,是關於CPU的最後一級快取的。你應該知道,最後一級快取(一般也就是L3),如果命中率不高的話,對系統性能會有極壞的影響(相關基礎知識建議回顧第15講)。所以對這一問題,我們要及時準確地監測、暴露出來。

至於具體解決方案,我這裡建議採取三種性能優化策略,來提高最後一級快取的命中率。分別是:緊湊化資料結構軟體預取資料去除偽共享快取。它們分別適用於不同的情況。

效能問題:最後一級快取(LLC)不命中率太高

一切問題的解決都要從效能問題開始入手,我們首先來看看最後一級快取不命中率太高這個效能問題本身。

快取的命中率,是CPU效能的一個關鍵效能指標。我們知道,CPU裡面有好幾級快取(Cache),每一級快取都比後面一級快取訪問速度快。最後一級快取叫LLC(Last Level Cache);LLC的後面就是記憶體。

當CPU需要訪問一塊資料或者指令時,它會首先檢視最靠近的一級快取(L1);如果資料存在,那麼就是快取命中(Cache Hit),否則就是不命中(Cache Miss),需要繼續查詢下一級快取。

快取不命中的比例對CPU的效能影響很大,尤其是最後一級快取的不命中時,對效能的損害尤其嚴重。這個損害主要有兩方面的效能影響:

第一個方面的影響很直白,就是CPU的速度受影響。我們前面講過,記憶體的訪問延遲,是LLC的延遲的很多倍(比如五倍);所以LLC不命中對計算速度的影響可想而知。

第二個方面的影響就沒有那麼直白了,這方面是關於記憶體頻寬。我們知道,如果LLC沒有命中,那麼就只能從記憶體裡面去取了。LLC不命中的計數,其實就是對記憶體訪問的計數,因為CPU對記憶體的訪問總是要經過LLC,不會跳過LLC的。所以每一次LLC不命中,就會導致一次記憶體訪問;反之也是成立的:每一次記憶體訪問都是因為LLC沒有命中。

更重要的是,我們知道,一個系統的記憶體頻寬是有限制的,很有可能會成為效能瓶頸。從記憶體裡取資料,就會佔用記憶體頻寬。因此,如果LLC不命中很高,那麼對記憶體頻寬的使用就會很大。記憶體頻寬使用率很高的情況下,記憶體的存取延遲會急劇上升。更嚴重的是,最近幾年計算機和網際網路發展的趨勢是,後臺系統需要對越來越多的資料進行處理,因此記憶體頻寬越來越成為效能瓶頸

LLC不命中率的測量

針對LLC不命中率高的問題,我們需要衡量一下問題的嚴重程度。在Linux系統裡,可以用Perf這個工具來測量LLC的不命中率(在第15講中提到過)。

那麼Perf工具是怎麼工作的呢?

它是在內部使用效能監視單元,也就是PMU(Performance Monitoring Units)硬體,來收集各種相關CPU硬體事件的資料(例如快取訪問和快取未命中),並且不會給系統帶來太大開銷。 這裡需要你注意的是,PMU硬體是針對每種處理器特別實現的,所以支援的事件集合以及具體事件原理,在處理器之間可能有所不同。

PMU尤其可以監測LLC相關的指標資料,比如LLC讀寫計數、LLC不命中計數、LLC預先提取計數等指標。具體用Perf來測量LLC各種計數的命令格式是:

perf stat -e LLC-loads,LLC-load-misses,LLC-stores,LLC-store-misses

下圖顯示的是一次Perf執行結果。

我們可以看到,在這段取樣時間內,有1951M(19.51億)次LLC的讀取,大約16%是不命中。有313M(3.13億)次LLC的寫入,差不多24%是不命中。

如何降低LLC的不命中率?

那麼如何降低LLC的不命中率,也就是提高它的命中率呢?根據具體的問題,至少有三個解決方案。而且,這三個方案也不是互相排斥的,完全可以同時使用。

第一個方案,也是最直白的方案,就是縮小資料結構,讓資料變得緊湊。

這樣做的道理很簡單,對一個系統而言,所有的快取大小,包括最後一級快取LLC,都是固定的。如果每個資料變小,各級快取自然就可以快取更多條資料,也就可以提高快取的命中率。這個方案很容易理解。

舉個例子,開源的C++ Folly庫裡面有很多類,比如F14ValueMap,就比一般的標準庫實現小很多,從而佔用比較少的記憶體;採用它的話,自然快取的命中率就比較高。

第二個方案,是用軟體方式來預取資料

這個方案也就是通過合理預測,把以後可能要讀取的資料提前取出,放到快取裡面,這樣就可以減少快取不命中率。“用軟體方式來預取資料”理論上也算是一種“用空間來換時間”的策略(參見第20講),因為付出的代價是佔用了快取空間。當然,這個預測的結果可能會不正確。

第三個方案,是具體為了解決一種特殊問題:就是偽共享快取。偽共享快取這個問題,我會在後面詳細講到。這個方案也算是一種“空間換時間”的策略,是通過讓每個資料結構變大,犧牲一點儲存空間,來解決偽共享快取的問題。

除了最直白的縮小資料結構,另外兩個解決方案(用軟體方式來預取資料、去除偽共享快取)都需要著重探討。

軟體提前預取指令

我們先展開討論一下第二種方案,也就是用軟體提前預取指令。

現代CPU其實一般都有硬體指令資料預取功能,也就是根據程式的執行狀態進行預測,並提前把指令和資料預取到快取中。這種硬體預測針對連續性的記憶體訪問特別有效。

但是在相當多的情況下,程式對記憶體的訪問模式是隨機、不規則的,也就是不連續的。硬體預取器對於這種隨機的訪問模式,根本無法做出正確的預測,這就需要使用軟體預取

軟體預取就是這樣一種預取到快取中的技術,以便及時提供給CPU,減少CPU停頓,從而降低快取的不命中率,也就提高了CPU的使用效率。

現代CPU都提供相應的預取指令,具體來講,Windows下可以使用VC++提供的_mm_prefetch函式,Linux下可以使用GCC提供的__builtin_prefetch函式。GCC提供了這樣的介面,允許開發人員向編譯器提供提示,從而幫助GCC為底層的編譯處理器產生預取指令。這種策略在硬體預取不能正確、及時地預取資料時,極為有用。

但是軟體預取也是有代價的。

一是預取的操作本身也是一種CPU指令,執行它就會佔用CPU的週期。更重要的是,預取的記憶體資料總是會佔用快取空間。因為快取空間很有限,這樣可能會踢出其他的快取的內容,從而造成被踢出內容的快取不命中。如果預取的資料沒有及時被用到,或者帶來的好處不大,甚至小於帶來的踢出其他快取相對應的代價,那麼軟體預取就不會提升效能。

我自己在這方面的實踐經驗,有這麼幾條:

  1. 軟體預取最好只針對絕對必要的情況,就是對會實際嚴重導致CPU停頓的資料進行預取。
  2. 對於很長的迴圈(就是迴圈次數比較多),儘量提前預取後面的兩到三個迴圈所需要的資料。
  3. 而對於短些的迴圈(迴圈次數比較少),可以試試在進入迴圈之前,就把資料提前預取到。

去除偽共享快取

好了,我們接著來討論第三個方案:去除偽共享快取。

什麼是偽共享快取呢?

我們都知道,記憶體快取系統中,一般是以快取行(Cache Line)為單位儲存的。最常見的快取行大小是64個位元組。現代CPU為了保證快取相對於記憶體的一致性,必須實時監測每個核對快取相對應的記憶體位置的修改。如果不同核所對應的快取,其實是對應記憶體的同一個位置,那麼對於這些快取位置的修改,就必須輪流有序地執行,以保證記憶體一致性。

但是,這將導致核與核之間產生競爭關係,因為一個核對記憶體的修改,將導致另外的核在該處記憶體上的快取失效。在多執行緒的場景下就會導致這樣的問題。當多執行緒修改看似互相獨立的變數時,如果這些變數共享同一個快取行,就會在無意中影響彼此的效能,這就是偽共享

你可以參考下面這張Intel公司提供的圖,兩個執行緒執行在不同的核上,每個核都有自己單獨的快取,並且兩個執行緒訪問同一個快取行。

如果執行緒0修改了快取行的一部分,比如一個位元組,那麼為了保證快取一致性,這個核上的整個快取行的64位元組,都必須寫回到記憶體;這就導致其他核的對應快取行失效。其他核的快取就必須從記憶體讀取最新的快取行資料。這就造成了其他執行緒(比如執行緒1)相對較大的停頓。

這個問題就是偽共享快取。之所以稱為“偽共享”,是因為,單單從程式程式碼上看,好像執行緒間沒有衝突,可以完美共享記憶體,所以看不出什麼問題。由於這種衝突性共享導致的問題不是程式本意,而是由於底層快取按塊存取和快取一致性的機制導致的,所以才稱為“偽共享”。

我工作中也觀察到好多次這樣的偽共享快取問題。經常會有產品組來找我們,說他們的產品吞吐量上不去,後來發現就是這方面的問題。所以,我們開發程式時,不同執行緒的資料要儘量放到不同的快取行,避免多執行緒同時頻繁地修改同一個快取行。

舉個具體例子,假如我們要寫一個多執行緒的程式來做分散式的統計工作,為了避免執行緒對於同一個變數的競爭,我們一般會定義一個數組,讓每個執行緒修改其中一個元素。當需要總體統計資訊時,再將所有元素相加得到結果。

但是,如果這個陣列的元素是整數,因為一個整數只佔用幾個位元組,那麼一個64位元組的快取行會包含多個整數,就會導致幾個執行緒共享一個快取行,產生“偽共享”問題。

這個問題的解決方案,是讓每個元素單獨佔用一個快取行,比如64位元組,也就是按快取行的大小來對齊(Cache Line Alignment)。具體方法怎麼實現呢?其實就是插入一些無用的位元組(Padding)。這樣的好處,是多個執行緒可以修改各自的元素和對應的快取行,不會存在快取行競爭,也就避免了“偽共享”問題。

總結

這一講,我們介紹了CPU方面的優化案例,重點討論了如何降低LLC的快取不命中率。我們提出了三個方案,分別是緊湊化資料、軟體指令預取和去除偽共享快取。

尤其是第三個方案解決的偽共享快取問題,對大多數程式設計師和運維人員而言,不太容易理解。為什麼難理解?是因為它牽扯了軟體(比如多執行緒)和硬體(比如快取一致性和快取行的大小)的互動。

當多執行緒共用同一個快取行,並且各自頻繁訪問時,會導致嚴重的稱為“偽共享”的效能問題。這種問題,恰如清代詞人朱彝尊的兩句詞,“共眠一舸聽秋雨,小簟輕衾各自寒”。所以需要我們狠狠心,把它們強行分開;“棒打鴛鴦”,讓它們“大難臨頭各自飛”,其實呢,是為了它們都好。