从Java视角理解CPU缓存(CPU Cache)
http://coderplay.iteye.com/blog/1485760
眾所周知, CPU是計算機的大腦, 它負責執行程序的指令; 內存負責存數據, 包括程序自身數據. 同樣大家都知道, 內存比CPU慢很多. 其實在30年前, CPU的頻率和內存總線的頻率在同一個級別, 訪問內存只比訪問CPU寄存器慢一點兒. 由于內存的發展都到技術及成本的限制, 現在獲取內存中的一條數據大概需要200多個CPU周期(CPU cycles), 而CPU寄存器一般情況下1個CPU周期就夠了.?
CPU緩存?
網頁瀏覽器為了加快速度,會在本機存緩存以前瀏覽過的數據; 傳統數據庫或NoSQL數據庫為了加速查詢, 常在內存設置一個緩存, 減少對磁盤(慢)的IO. 同樣內存與CPU的速度相差太遠, 于是CPU設計者們就給CPU加上了緩存(CPU Cache). 如果你需要對同一批數據操作很多次, 那么把數據放至離CPU更近的緩存, 會給程序帶來很大的速度提升. 例如, 做一個循環計數, 把計數變量放到緩存里,就不用每次循環都往內存存取數據了. 下面是CPU Cache的簡單示意圖.??
?
隨著多核的發展, CPU Cache分成了三個級別: L1, L2, L3. 級別越小越接近CPU, 所以速度也更快, 同時也代表著容量越小. L1是最接近CPU的, 它容量最小, 例如32K, 速度最快,每個核上都有一個L1 Cache(準確地說每個核上有兩個L1 Cache, 一個存數據 L1d Cache, 一個存指令 L1i Cache). L2 Cache 更大一些,例如256K, 速度要慢一些, 一般情況下每個核上都有一個獨立的L2 Cache; L3 Cache是三級緩存中最大的一級,例如12MB,同時也是最慢的一級, 在同一個CPU插槽之間的核共享一個L3 Cache.?
| 從CPU到 | 大約需要的CPU周期 | 大約需要的時間(單位ns) |
| 寄存器 | 1 cycle | ? |
| L1 Cache | ~3-4 cycles | ~0.5-1 ns |
| L2 Cache | ~10-20 cycles | ~3-7 ns |
| L3 Cache | ~40-45 cycles | ~15 ns |
| 跨槽傳輸 | ? | ~20 ns |
| 內存 | ~120-240 cycles | ~60-120ns |
感興趣的同學可以在Linux下面用cat /proc/cpuinfo, 或Ubuntu下lscpu看看自己機器的緩存情況, 更細的可以通過以下命令看看:?
Shell代碼??
就像數據庫cache一樣, 獲取數據時首先會在最快的cache中找數據, 如果沒有命中(Cache miss) 則往下一級找, 直到三層Cache都找不到,那只要向內存要數據了. 一次次地未命中,代表取數據消耗的時間越長.?
緩存行(Cache line) ?
為了高效地存取緩存, 不是簡單隨意地將單條數據寫入緩存的.? 緩存是由緩存行組成的, 典型的一行是64字節. 讀者可以通過下面的shell命令,查看cherency_line_size就知道知道機器的緩存行是多大.?
Shell代碼??
CPU存取緩存都是按行為最小單位操作的. 在這兒我將不提及緩存的associativity問題, 將問題簡化一些. 一個Java long型占8字節, 所以從一條緩存行上你可以獲取到8個long型變量. 所以如果你訪問一個long型數組, 當有一個long被加載到cache中, 你將無消耗地加載了另外7個. 所以你可以非常快地遍歷數組.?
實驗及分析 ?
我們在Java編程時, 如果不注意CPU Cache, 那么將導致程序效率低下. 例如以下程序, 有一個二維long型數組, 在我的32位筆記本上運行時的內存分布如圖:?
?
32位機器中的java的數組對象頭共占16字節(詳情見? 鏈接 ), 加上62個long型一行long數據一共占512字節. 所以這個二維數據是順序排列的.?
Java代碼??
編譯后運行,結果如下?
Shell代碼??
然后我們將22-26行的注釋取消, 將28-32行注釋, 編譯后再次運行,結果是不是比我們預想得還糟??
Shell代碼??
前面只花了1.4秒的程序, 只做一行的對調要運行22秒. 從上節我們可以知道在加載longs[i][j]時, longs[i][j+1]很可能也會被加載至cache中, 所以立即訪問longs[i][j+1]將會命中L1 Cache, 而如果你訪問longs[i+1][j]情況就不一樣了, 這時候很可能會產生 cache miss導致效率低下.?
下面我們用perf來驗證一下,先將快的程序跑一下.?
Shell代碼??
一共164,625,965次L1 cache miss, 再看看慢的程序?
Shell代碼??
這回產生了1,421,402,322次 L1-dcache-load-misses, 所以慢多了.?
以上我只是示例了在L1 Cache滿了之后才會發生的cache miss. 其實cache miss的原因有下面三種:?
1. 第一次訪問數據, 在cache中根本不存在這條數據, 所以cache miss, 可以通過prefetch解決.?
2. cache沖突, 需要通過補齊來解決.?
3. 就是我示例的這種, cache滿, 一般情況下我們需要減少操作的數據大小, 盡量按數據的物理順序訪問數據.?
具體的信息可以參考 這篇 論文.?
從我的前一篇博文中, 我們知道了CPU緩存及緩存行的概念, 同時用一個例子說明了編寫單線程Java代碼時應該注意的問題. 下面我們討論更為復雜, 而且更符合現實情況的多核編程時將會碰到的問題. 這些問題更容易犯, 連j.u.c包作者Doug Lea大師的JDK代碼里也存在這些問題.?
MESI協議及RFO請求?
從前一篇我們知道, 典型的CPU微架構有3級緩存, 每個核都有自己私有的L1, L2緩存. 那么多線程編程時, 另外一個核的線程想要訪問當前核內L1, L2 緩存行的數據, 該怎么辦呢??
有人說可以通過第2個核直接訪問第1個核的緩存行. 這是可行的, 但這種方法不夠快. 跨核訪問需要通過Memory Controller(見上一篇的示意圖), 典型的情況是第2個核經常訪問第1個核的這條數據, 那么每次都有跨核的消耗. 更糟的情況是, 有可能第2個核與第1個核不在一個插槽內.況且Memory Controller的總線帶寬是有限的, 扛不住這么多數據傳輸. 所以, CPU設計者們更偏向于另一種辦法: 如果第2個核需要這份數據, 由第1個核直接把數據內容發過去, 數據只需要傳一次.?
那么什么時候會發生緩存行的傳輸呢? 答案很簡單: 當一個核需要讀取另外一個核的臟緩存行時發生. 但是前者怎么判斷后者的緩存行已經被弄臟(寫)了呢??
下面將詳細地解答以上問題. 首先我們需要談到一個協議--MESI協議(鏈接). 現在主流的處理器都是用它來保證緩存的相干性和內存的相干性. M,E,S和I代表使用MESI協議時緩存行所處的四個狀態:?
- M(修改, Modified): 本地處理器已經修改緩存行, 即是臟行, 它的內容與內存中的內容不一樣. 并且此cache只有本地一個拷貝(專有).
- E(專有, Exclusive): 緩存行內容和內存中的一樣, 而且其它處理器都沒有這行數據
- S(共享, Shared): 緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝
- I(無效, Invalid): 緩存行失效, 不能使用
上圖源自于內核開發者Ulrich Drepper著名的What Every Programmer Should Know About Memory一書( 下載 ), 簡要地展示了緩存行的四種狀態轉換. 不過他的書中沒有說明白這四個狀態是怎么轉換的, 下面我用小段文字來說明一下.?
初始 ?一開始時, 緩存行沒有加載任何數據, 所以它處于I狀態.?
本地寫(Local Write) 如果本地處理器寫數據至處于I狀態的緩存行, 則緩存行的狀態變成M.?
本地讀(Local Read) ?如果本地處理器讀取處于I狀態的緩存行, 很明顯此緩存沒有數據給它. 此時分兩種情況: (1)其它處理器的緩存里也沒有此行數據, 則從內存加載數據到此緩存行后, 再將它設成E狀態, 表示只有我一家有這條數據, 其它處理器都沒有 (2)其它處理器的緩存有此行數據, 則將此緩存行的狀態設為S狀態.?
P.S.如果處于M狀態的緩存行, 再由本地處理器寫入/讀出, 狀態是不會改變的.?
遠程讀(Remote Read) ?假設我們有兩個處理器c1和c2. 如果c2需要讀另外一個處理器c1的緩存行內容, c1需要把它緩存行的內容通過內存控制器(Memory Controller)發送給c2, c2接到后將相應的緩存行狀態設為S. 在設置之前, 內存也得從總線上得到這份數據并保存.?
遠程寫(Remote Write) ?其實確切地說不是遠程寫, 而是c2得到c1的數據后, 不是為了讀, 而是為了寫. 也算是本地寫, 只是c1也擁有這份數據的拷貝, 這該怎么辦呢? c2將發出一個RFO(Request For Owner)請求, 它需要擁有這行數據的權限, 其它處理器的相應緩存行設為I, 除了它自已, 誰不能動這行數據. 這保證了數據的安全, 同時處理RFO請求以及設置I的過程將給寫操作帶來很大的性能消耗.?
以上只是列舉了一些狀態轉換, 為下文做鋪墊. 如果全部描述,需要非常大量的文字, 大家參考 這張圖 就知道原因了, 可以通過此圖了解MESI協議更詳細的信息.?
偽共享 ?
我們從上節知道, 寫操作的代價很高, 特別當需要發送RFO消息時. 我們編寫程序時, 什么時候會發生RFO請求呢? 有以下兩種:?
1. 線程的工作從一個處理器移到另一個處理器, 它操作的所有緩存行都需要移到新的處理器上. 此后如果再寫緩存行, 則此緩存行在不同核上有多個拷貝, 需要發送RFO請求了.?
2. 兩個不同的處理器確實都需要操作相同的緩存行?
由上一篇我們知道, 在Java程序中,數組的成員在緩存中也是連續的. 其實從Java對象的相鄰成員變量也會加載到同一緩存行中. 如果多個線程操作不同的成員變量, 但是相同的緩存行, 偽共享(False Sharing)問題就發生了. 下面引用 Disruptor 項目Lead的 博文 中的示例圖和實驗例子(偷會懶,但會加上更詳細的profile方法).?
?
一個運行在處理器core 1上的線程想要更新變量X的值, 同時另外一個運行在處理器core 2上的線程想要更新變量Y的值. 但是, 這兩個頻繁改動的變量都處于同一條緩存行. 兩個線程就會輪番發送RFO消息, 占得此緩存行的擁有權. 當core 1取得了擁有權開始更新X, 則core 2對應的緩存行需要設為I狀態. 當core 2取得了擁有權開始更新Y, 則core 1對應的緩存行需要設為I狀態(失效態). 輪番奪取擁有權不但帶來大量的RFO消息, 而且如果某個線程需要讀此行數據時, L1和L2緩存上都是失效數據, 只有L3緩存上是同步好的數據.從 前一篇 我們知道, 讀L3的數據非常影響性能. 更壞的情況是跨槽讀取, L3都要miss,只能從內存上加載.?
表面上X和Y都是被獨立線程操作的, 而且兩操作之間也沒有任何關系.只不過它們共享了一個緩存行, 但所有競爭沖突都是來源于共享.?
實驗及分析 ?
引用Martin的例子, 稍做修改,代碼如下:?
Java代碼??
代碼的邏輯是默認4個線程修改一數組不同元素的內容.? 元素的類型是VolatileLong, 只有一個長整型成員value和6個沒用到的長整型成員. value設為volatile是為了讓value的修改所有線程都可見. 在一臺Westmere(Xeon E5620 8core*2)機器上跑一下看?
Shell代碼??
把以上代碼49行注釋掉, 看看結果:?
Shell代碼??
兩個邏輯一模一樣的程序, 前者只需要9秒, 后者跑了將近一分鐘, 這太不可思議了! 我們用偽共享(False Sharing)的理論來分析一下. 后面的那個程序longs數組的4個元素,由于VolatileLong只有1個長整型成員, 所以整個數組都將被加載至同一緩存行, 但有4個線程同時操作這條緩存行, 于是偽共享就悄悄地發生了. 讀者可以測試一下2,4,8, 16個線程分別操作時分別是什么效果, 什么樣的趨勢.?
那么怎么避免偽共享呢? 我們未注釋的代碼就告訴了我們方法. 我們知道一條緩存行有64字節, 而Java程序的對象頭固定占8字節(32位系統)或12字節(64位系統默認開啟壓縮, 不開壓縮為16字節), 詳情見? 鏈接 . 我們只需要填6個無用的長整型補上6*8=48字節, 讓不同的VolatileLong對象處于不同的緩存行, 就可以避免偽共享了(64位系統超過緩存行的64字節也無所謂,只要保證不同線程不要操作同一緩存行就可以). 這個辦法叫做補齊(Padding).?
如何從系統層面觀察到這種優化是切實有效的呢? 很可惜, 由于很多計算機的微架構不同, 我們沒有工具來直接探測偽共享事件(包括Intel Vtune和Valgrind). 所有的工具都是從側面來發現的, 下面通過Linux利器 OProfile 來證明一下. 上面的程序的數組只是占64 * 4 = 256字節, 而且在連續的物理空間, 照理來說數據會在L1緩存上就命中, 肯定不會傳入到L2緩存中, 只有在偽共享發生時才會出現. 于是, 我們可以通過觀察L2緩存的IN事件就可以證明了,步驟如下:?
Shell代碼??
比較一下兩個版本的結果, 慢的版本:?
Shell代碼??
快的版本:?
Shell代碼??
慢的版本由于False Sharing引發的L2緩存IN事件達34085次, 而快版本的為0次.?
總結 ?
偽共享在多核編程中很容易發生, 而且比較隱蔽. 例如, 在JDK的LinkedBlockingQueue中, 存在指向隊列頭的引用head和指向隊列尾的引用last. 而這種隊列經常在異步編程中使有,這兩個引用的值經常的被不同的線程修改, 但它們卻很可能在同一個緩存行, 于是就產生了偽共享. 線程越多, 核越多,對性能產生的負面效果就越大.?
某些Java編譯器會將沒有使用到的補齊數據, 即示例代碼中的6個長整型在編譯時優化掉, 可以在程序中加入一些代碼防止被編譯優化.?
Java代碼??
另外, 由于Java的GC問題. 數據在內存和對應的CPU緩存行的位置有可能發生變化, 所以在使用pad的時候應該注意GC的影響.?
小結:
如果兩個核上的線程處理的不是同一緩存行,那么每個核上的L1,L2都會緩存自己要處理的部分
如果處理的是同一緩存行,為了加快速度,一個核會將另外一個核上的L1上的緩存行拷貝到自己的緩存行中,但是又發現拷貝過來的數據已經成為了臟數據,那么又要從內存中重新讀入
最后感謝同事撒迦,?長仁在Java對象內存布局及Profile工具上給予的幫助.?
2012年4月19日更新:?
發現netty和grizzly的代碼中的LinkedTransferQueue中都使用了PaddedAtomicReference<QNode>來代替原來的Node, 使用了補齊的辦法解決了隊列偽共享的問題. 不知道是不是JSR-166的人開發的, 看來他們早就意識到這個問題了. 但是從Doug Lea JSR-166的cvs看不到這個變化, 不知道究竟是誰改的? 他們的repository到底是在哪??
2012年5月19日更新:?
為了區別Cache Coherence和Cache Consistency兩個概念, 不讓讀者混淆, 這里把Cache Coherence改翻譯成緩存相干性.?
總結
以上是生活随笔為你收集整理的从Java视角理解CPU缓存(CPU Cache)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 由Thread.sleep引发的
- 下一篇: Java JUC之Atomic系列12大