CPU快取重新整理的誤解

doubel_山裡娃發表於2020-11-24

時間 2014-01-02 14:01:56 segmentfault資訊原文 http://segmentfault.com/a/1190000000375559
即使是資深的技術人員,我經常聽到他們談論某些操作是如何導致一個CPU快取的重新整理。看來這是關於CPU快取如何工作和快取子系統如何與執行核心互動的一個常見誤區。本文將致力於解釋CPU快取的功能以及執行程式指令的CPU核心如何與快取互動。我將以最新的Intel x86 CPU為例進行說明,其他CPU也使用相似技術以達到相同目的。
絕大部分常見的現代系統都被設計成在多處理器上共享記憶體。共享記憶體的系統都有一個單獨的記憶體資源,它會被兩個或者更多的獨立CPU核心同時訪問。核心到主存的延遲變化範圍很大,大約在10-100納秒。在100ns內,一個3GH的CPU可以處理多達1200條指令。每一個Sandy Bridge的CPU核心,在每個CPU時鐘內可以並行處理4條指令。CPU使用快取子系統避免了處理核心直接訪問主存的延遲,這樣能使CPU更高效的處理指令。一些快取很小、非常快速並且整合在每個核心之內;而另一些則慢一些、更大、在各個核心間共享。這些快取與暫存器和主記憶體一起構成了非永續性的記憶體體系。
當你在設計一個重要演算法時要記住,快取不命中所導致的延遲,可能會使你失去執行500條指令時間!這還僅是在單插槽(single-socket)系統上,如果是多插槽(multi-socket)系統,由於記憶體訪問需要跨槽互動,可能會導致雙倍的效能損失。
記憶體體系
在這裡插入圖片描述

圖1.對於2012 Sandy Bridge核心來說,記憶體模型可以大致按照如下進行分解:

  1. 暫存器: 在每個核心上,有160個用於整數和144個用於浮點的暫存器單元。訪問這些暫存器只需要一個時鐘週期,這構成了對執行核心來說最快的記憶體。編譯器會將本地變數和函式引數分配到這些暫存器上。當使用超執行緒技術(hyperthreading
    )時,這些暫存器可以在超執行緒協同下共享。

  2. 記憶體排序緩衝(Memory Ordering Buffers MOB) :MOB由一個64長度的load緩衝和36長度的store緩衝組成。這些緩衝用於記錄等待快取子系統時正在執行的操作。store緩衝是一個完全的相關性佇列,可以用於搜尋已經存在store操作,這些store操作在等待L1快取的時候被佇列化。在資料與快取子系統傳輸時, 緩衝可以讓處理器非同步運轉。當處理器非同步讀或者非同步寫的時候,結果可以亂序返回。為了使之與已釋出的記憶體模型( memory model )一致,MOB用於消除load和store的順序。

  3. Level 1 快取: L1是一個本地核心內的快取,被分成獨立的32K資料快取和32K指令快取。訪問需要3個時鐘週期,並且當指令被核心流水化時, 如果資料已經在L1快取中的話,訪問時間可以忽略。

  4. L2快取: L2快取是一個本地核心內的快取,被設計為L1快取與共享的L3快取之間的緩衝。L2快取大小為256K,主要作用是作為L1和L3之間的高效記憶體訪問佇列。L2快取同時包含資料和指令。L2快取的延遲為12個時鐘週期。

  5. L3快取: 在同插槽的所有核心都共享L3快取。L3快取被分為數個2MB的段,每一個段都連線到槽上的環形網路。每一個核心也連線到這個環形網路上。地址通過hash的方式對映到段上以達到更大的吞吐量。根據快取大小,延遲有可能高達38個時鐘週期。在環上每增加一個節點將消耗一個額外的時鐘週期。快取大小根據段的數量最大可以達到20MB。L3快取包括了在同一個槽上的所有L1和L2快取中的資料。這種設計消耗了空間,但是使L3快取可以攔截對L1和L2快取的請求,減輕了各核心私有的L1和L2快取的負擔。

  6. 主記憶體 :在快取完全沒命中的情況下,DRAM通道到每個槽的延遲平均為65ns。具體延遲多少取決於很多因素,比如,下一次對同一快取行中資料的訪問將極大降低延遲,而當佇列化效果和記憶體重新整理週期衝突時將顯著增加延遲。每個槽使用4個記憶體通道聚合起來增加吞吐量,並通過在獨立記憶體通道上流水線化(pipelining )將隱藏這種延遲。

  7. NUMA: 在一個多插槽的伺服器上,會使用非一致性記憶體訪問( non-uniform memory access )。所謂的非一致性是指,需要訪問的記憶體可能在另一個插槽上,並且通過 QPI 匯流排訪問需要額外花費40ns。 Sandy Bridge對於以往的相容系統來說,在2插槽系統上是一個巨大的進步。在 Sandy Bridge上,QPI匯流排的能力從6.4GT/s提升到8.0GT/s,並且可以使用兩條線路,消除了以前系統的瓶頸。對於 Nehalem and Westmere 來說,QPI只能使用記憶體控制器為一個單獨插槽分配的頻寬中的40%,這使訪問遠端記憶體成為一個瓶頸。另外,現在QPI連結可以使用預讀取請求,而前一代系統不行。

關聯度(Associativity Levels)

快取是一個依賴於hash表的高效硬體。使用hash函式常常只是將地址中低位bit 進行對映 ,以實現快取索引。hash表需要有解決對於同一位置衝突的機制。 關聯度就是hash表中槽(slot)的數量,也被稱為組(ways)和集合(sets),可以用來儲存一個記憶體地址的hash版本。關聯度的多少需要在儲存資料的容量,耗電量和查詢時間之間尋找平衡。(校對注:關聯度越高,槽的數量越多,hash衝突越小,查詢速度越快)
對於Sandy Bridge,L1和L2是8路組相連 ,L3是12路組相連 。

快取一致性

由於一些快取在核心本地,我們需要一些方法保證一致性,使所有核心的記憶體檢視一致。對於主流系統來說,記憶體子系統需要考慮“真實的來源(source of truth)”。如果資料只從快取中來,那麼它永遠不會過期;當資料同時在快取和主記憶體中存在時,快取中存的是主拷貝(master copy)。這種記憶體管理被稱為寫-回( write-back ),在此方式下,當新的快取行佔用舊行,導致舊行被驅逐時,快取資料只會被寫回主記憶體中。x86架構的每個快取塊的大小為64 bytes,稱為快取行(
cache-line )。其它種類的處理器的快取行大小可能不同。更大的快取行容量降低延遲,但是需要更大的頻寬*(**校對*注:資料匯流排頻寬)。
為了保證快取的一致性,快取控制器跟蹤每一個快取行的狀態,這些狀態的數量是有限的。Intel使用 MESIF 協議,AMD使用 MOESI 。在MESIF協議下,快取行處於以下5個狀態中的1個。

  • 被修改(Modified): 表明快取行已經過期,在接下來的場景中要寫回主記憶體。當寫回主記憶體後狀態將轉變為排它( Exclusive )。
  • 獨享(Exclusive) :表明快取行被當前核心單獨持有,並且與主記憶體中一致。當被寫入時,狀態將轉變為修改(Modified)。要進入這個狀態,需要傳送一個 Request-For-Ownership (RFO)訊息,這包含一個讀操作再加上廣播通知其他拷貝失效。
  • 共享(Shared): 表明快取行是一個與主記憶體一致的拷貝。
  • 失效(Invalid): 表明是一個無效的快取行。
    向前( Forward ): 一個特殊的共享狀態。用來表示在NUMA體系中響應其他快取的特定快取。
    為了從一個狀態轉變為另一個狀態,在快取之間,需要傳送一系列的訊息使狀態改變生效。對於上一代(或之前)的 Nehalem 核心的Intel CPU和 Opteron 核心的AMD CPU,插槽之間確保快取一致性的流量需要通過記憶體匯流排共享,這極大地限制了可擴充套件性。如今,記憶體控制器的流量使用一個單獨的匯流排來傳輸。例如,Intel的QPI和AMD的 HyperTransport 就用於插槽間的快取一致性通訊。
    快取控制器作為L3快取段的一個模組連線到插槽上的環行匯流排網路。每一個核心,L3快取段,QPI控制器,記憶體控制器和整合圖形子系統都連線到這個環行匯流排上。環由四個獨立的通道構成,用於:在每個時鐘內完成請求、嗅探、確認和傳輸32-bytes的資料。L3快取包含所有L1和L2快取中的快取行,這有助於幫助核心在嗅探變化時快速確認改變的行。用於L3快取段的快取控制器記錄了哪個核心可能改變自己的快取行。
    如果一個核心想要讀取一些資料,並且這些資料在快取中並不處於共享、獨佔或者被修改狀態;那麼它就需要在環形匯流排上做一個讀操作。它要麼從主記憶體中讀取(快取沒命中),要麼從L3快取讀取(如果沒過期或者被其他核心嗅探到改變)。在任何情況下,一致性協議都能保證,讀操作永遠不會從快取子系統返回一份過期拷貝。
    併發程式設計
    如果我們的快取總是保證一致性,那麼為什麼我們在寫併發程式時要擔心可見性?這是因為核心為了得到更好的效能,對於其它執行緒來說,可能會出現資料修改的亂序。這麼做主要有兩個理由。
    首先,我們的編譯器在生成程式程式碼時,為了效能,可能讓變數在暫存器中存在很長的時間,例如,變數在一個迴圈中重複使用。如果我們需要這些變數在核心之間可見,那麼變數就不能在暫存器分配。在C語言中,可以新增“volatile”關鍵字達到這個目標。要記住,c/c++中volatile並不能保證讓編譯器不重排我們的指令。因此,需要使用記憶體屏障。
    排序的第二個主要問題是,一個執行緒寫了一個變數,然後很快讀取,有可能從讀緩衝中獲得比快取子系統中最新值要舊的值。這對於遵循單寫入者原則( Single Writer Principle )的程式來說沒有任何問題,但是對於
    Dekker 和 Peterson 鎖演算法就是個很大問題。為了克服這一點,並且確保最新值可見,執行緒不能從本地讀緩衝中讀取值。可以使用屏障指令,防止下一個讀操作在另一執行緒的寫操作之前發生。在Java中對一個volatile變數進行寫操作,除了永遠不會在暫存器中分配之外,還會伴隨一個完全的屏障指令。在x86架構上,屏障指令在讀緩衝排空之前,會顯著影響放置屏障的執行緒的執行。在其它處理器上,屏障有更有效率的實現,例如 Azul Vega在讀緩衝上放置一個標誌用於邊界搜尋。
    當遵循單寫入者原則時,要確保Java執行緒之間的記憶體次序,避免store屏障,那麼就使用j.u.c.Atomic(Int|Long|Reference).lazySet()方法,而非放置一個volatile變數。
    誤區
    回到作為併發演算法中的一部分的“重新整理快取”誤區上,我想,可以說我們永遠不會在使用者空間的程式上“重新整理”CPU快取。我相信這個誤區的來源是由於在某些併發演算法需要重新整理、標記或者清空store緩衝以使下一個讀操作可以看到最新值。為了達到這點,我們需要記憶體屏障而非重新整理快取。
    這個誤解的另一個可能來源是,L1快取,或者 TLB ,在上下文切換的時候可能需要根據地址索引策略進行重新整理。ARM,在ARMv6之前,沒有在TLB條目上使用地址空間標籤,因此在上下文切換的時候需要重新整理整個L1快取。許多處理器因為類似的理由需要L1指令快取重新整理,在許多場景下,僅僅是因為指令快取沒有必要保持一致。上下文切換消耗很大,除了汙染L2快取之外,上下文切換還會導致TLB和/或者L1快取重新整理。Intel x86處理器在上下文切換時僅僅需要TLB重新整理。
    (校對注:TLB是Translation lookaside buffer,即頁表緩衝;裡面存放的是一些頁表檔案,又稱為快表技術,由於“頁表”儲存在主儲存器中,查詢頁表所付出的代價很大,由此產生了TLB。)

相關文章