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的緩存不命中率。我們提出了三個方案,分別是緊湊化數據、軟件指令預取和去除偽共享緩存。

尤其是第三個方案解決的偽共享緩存問題,對大多數程序員和運維人員而言,不太容易理解。為什么難理解?是因為它牽扯了軟件(比如多線程)和硬件(比如緩存一致性和緩存行的大小)的交互。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM