Ulrich Drepper:what Every Programmer Should Know About Memory@2007
程式設計師應了解的記憶體知識
本文件旨在闡述現代電腦系統中記憶體子系統的複雜性,並解釋程式設計師應如何理解這些複雜性,以撰寫出高效能的應用程式。核心論點在於,隨著中央處理器 (CPU) 核心速度不斷提升且數量增加,程式效能的限制因素已從 CPU 運算轉移到記憶體存取延遲。硬體設計師開發了精密的記憶體處理技術,如 CPU 快取、先進的記憶體控制器設計及非統一記憶體存取 (NUMA) 架構,但這些技術若沒有程式設計師的協助,將無法發揮最佳效能。
1. CPU 與記憶體的巨大速度差距:效能瓶頸的根源
早期電腦系統的各組件性能相對均衡。然而,隨著 CPU 技術的快速進步,記憶體技術受限於成本和物理定律,其存取速度的提升遠不及 CPU。這種速度上的巨大差異導致 CPU 頻繁地等待資料,形成嚴重的記憶體瓶頸。例如,對比暫存器(數個時鐘週期)和主記憶體(數百個時鐘週期)的存取時間,兩者之間存在數個數量級的差距。這種延遲成為大多數運算密集型程式效能的限制因素。
2. 硬體的解決方案:記憶體階層與快取
為了緩解 CPU 與主記憶體的速度差異,現代電腦系統引入了記憶體階層 (Memory Hierarchy)。這通常包含多個層級的快取記憶體 (Cache)。快取是位於 CPU 內部或附近的少量高速 SRAM,用於儲存 CPU 可能很快再次存取的資料副本。這些快取按速度和容量分為不同級別:L1 快取 (通常分為 L1 指令快取 L1i 和 L1 資料快取 L1d) 速度最快但容量最小,緊隨其後的是更大但較慢的 L2 快取,一些系統還有更大更慢的 L3 快取,最後才是容量最大的主記憶體 (DRAM)。
快取的運作基於程式的「局部性原理」(Locality):時間局部性(最近存取的資料很可能很快再次存取)和空間局部性(存取某個記憶體位置後,很可能很快存取其附近的記憶體位置)。快取以「快取行」(Cache Line) 為單位儲存資料(通常為 64 或 128 位元組)。當 CPU 需要資料時,首先搜尋快取;若命中 (Cache Hit),資料可快速取得;若未命中 (Cache Miss),則需要從下一級記憶體或主記憶體載入整個快取行,這會產生顯著的延遲。
3. DRAM 的技術細節:存取模式的影響
主記憶體 (DRAM) 的存取並非瞬時的,其底層技術決定了存取具有特定的延遲。DRAM 單元由一個電晶體和一個電容組成,電容儲存電荷代表資料。存取(讀或寫)需要先透過位址解多工器選取記憶體陣列中的「行」(Row),然後選取「列」(Column)。讀取時,電容會放電,因此每次讀取後都需要刷新。電容的充放電需要時間,這導致了存取延遲,由 CAS 延遲 (CL)、RAS-to-CAS 延遲 (tRCD) 和行預充電時間 (tRP) 等時序參數決定。
DRAM 的位址傳輸採用分時多工,先傳輸行位址(RAS),再傳輸列位址(CAS),這節省了針腳數量,但也增加了存取延遲。然而,一旦選定一行,可以快速循序地存取同一行的多個列。這解釋了為何循序存取(如讀取陣列)的效能遠高於隨機存取,因為循序存取能更好地利用 DRAM 的內部結構和爆發傳輸模式。不同世代的 DDR 技術(DDR2, DDR3)透過提高傳輸頻率和每次週期傳輸的資料量(雙倍或四倍傳輸速率)來增加頻寬,但核心的存取延遲改進較慢。
4. 多核心與多處理器系統的快取一致性與 NUMA 問題
在多核心或多處理器系統中,每個核心都有自己的 L1 快取,L2/L3 快取可能由部分或所有核心共享。這帶來了「快取一致性」(Cache Coherency) 問題:如何確保所有核心看到同一記憶體位置的最新資料。MESI (Modified, Exclusive, Shared, Invalid) 協定是常見的解決方案,它定義了快取行的四種狀態。當一個核心修改了一個快取行(狀態變為 Modified),其他核心的該快取行副本必須失效(Invalidated)。若其他核心需要該快取行,則必須從修改它的核心獲取最新資料(Request For Ownership, RFO),這會產生額外的匯流排流量和延遲。
若多個核心頻繁讀寫同一快取行,即使它們存取的是快取行中不同的位元組,也會因為需要不斷爭奪該快取行的 Modified 狀態而導致性能急劇下降,這稱為「假共享」(False Sharing)。這是多執行緒程式設計中需要特別注意的問題。
此外,在非統一記憶體存取 (NUMA) 系統中(如具有整合式記憶體控制器的多處理器系統),每個處理器可能擁有本地記憶體,存取本地記憶體比存取其他處理器連接的遠端記憶體更快。這種存取成本的差異(NUMA 因子)會顯著影響程式效能。作業系統會盡量將行程或執行緒分配到擁有其所需記憶體本地副本的處理器,但也需要處理記憶體在不同節點間的遷移,這會產生額外開銷。
5. 虛擬記憶體與 TLB 的開銷
現代作業系統為每個行程提供獨立的虛擬位址空間。虛擬位址需要透過記憶體管理單元 (MMU) 和分頁表轉換為實體位址。分頁表通常是儲存在主記憶體中的多層樹狀結構。每次虛擬位址轉換都需要「走訪」分頁表,這是一個耗時的操作。為了加速轉換,CPU 使用「轉譯後備緩衝區」(Translation Look-Aside Buffer, TLB),這是一個快取,用於儲存近期使用的虛擬到實體位址轉換結果。
TLB 通常容量很小但速度極快。TLB 未命中會觸發分頁表走訪,帶來顯著的延遲。TLB 未命中的成本通常高於資料快取未命中。多層分頁表結構、不同分頁大小的使用(如 4KB、2MB、1GB 大頁)以及位址空間配置隨機化 (ASLR) 都會影響 TLB 的效能。虛擬化技術(如 Xen, KVM)會引入額外的位址轉換層級(如陰影分頁表或 EPT/NPT),使 TLB 未命中的開銷更高。
6. 程式設計師可以採取的優化措施
理解上述硬體複雜性後,程式設計師可以透過以下方式改善程式效能:
-
優化資料佈局與存取模式:
-
利用快取行: 將經常一起存取的資料元素打包在同一個結構中,並確保該結構的大小和排列能有效地利用快取行,減少不必要的資料載入。使用
pahole等工具分析結構填充 (Padding) 和空洞 (Hole),並進行結構重排和填充以緊密打包資料。 - 資料對齊: 將經常存取的資料(特別是陣列、結構以及用於 SIMD 指令的資料)對齊到快取行邊界,避免單一存取跨越快取行,從而減少快取未命中和提高頻寬利用率。
- 避免假共享: 在多執行緒程式中,將由不同執行緒獨立修改的變數放置在不同的快取行上(透過填充或使用執行緒局部儲存 TLS),以避免快取一致性協定的開銷。
-
使用非暫時性存取: 對於僅寫入一次或很少重複使用的資料(如串流資料),使用非暫時性寫入指令(如 SSE 的
_mm_stream_*)直接寫入主記憶體,避免快取污染。
-
利用快取行: 將經常一起存取的資料元素打包在同一個結構中,並確保該結構的大小和排列能有效地利用快取行,減少不必要的資料載入。使用
-
優化演算法以提高局部性: 重新設計演算法,將操作集中在較小的資料子集上,使其能容納在快取中,例如矩陣乘法的分塊技術。
-
精細控制多執行緒與 NUMA 排程:
-
執行緒親和性: 使用作業系統提供的介面(如
sched_setaffinity)將執行緒綁定到特定的 CPU 核心,以控制快取共享和 NUMA 節點的本地性。例如,將存取相同資料集的執行緒綁定到共享快取的核心,將存取不同資料集的執行緒分散到具有獨立快取的核心。 -
NUMA 記憶體策略: 使用
mbind,set_mempolicy等介面控制記憶體分配的位置,使資料盡可能分配在將處理它的執行緒的本地記憶體節點上。理解遠端記憶體存取的成本,並盡量減少跨節點存取。 - 複製資料: 對於經常被不同節點讀取的資料,可以考慮在每個相關節點上建立副本,以消除遠端讀取開銷。
-
執行緒親和性: 使用作業系統提供的介面(如
-
利用預取技術: 雖然硬體會自動預取,但對於複雜或跨頁面的存取模式效果有限。程式設計師可以插入軟體預取指令(如
_mm_prefetch)來提示 CPU 提前載入資料到快取,以隱藏記憶體延遲。精心調整預取距離非常重要。輔助執行緒也可以用於在背景進行預取。 -
優化虛擬記憶體和 TLB 使用: 盡量使用大頁(Huge Pages),特別是對大型連續記憶體區域,以減少所需的分頁表項數量和 TLB 未命中率。利用
hugetlbfs或 System V 共享記憶體來分配大頁。 -
優化指令快取: 透過編譯器選項控制程式碼佈局,將熱點程式碼(頻繁執行的程式碼)放在一起並對齊,將非熱點程式碼移開,以提高指令快取利用率和預取效率。精準的程式碼對齊(如函數、跳躍目標)有助於前端效能。
-
謹慎使用原子操作: 原子操作是多執行緒同步所必需的,但它們涉及快取一致性協定和潛在的匯流排爭奪,成本較高。應優先使用硬體直接支援的原子算術指令,而非基於 CAS 的通用實現。理解不同原子操作的效能特點並在需要時才使用。
7. 效能分析工具的利用
為了識別記憶體相關的效能瓶頸並驗證優化效果,必須使用相應的工具:
* CPU 效能計數器 (如 oprofile): 監控低層級硬體事件,如快取未命中次數(L1d, L2, L3)、TLB 未命中次數、匯流排利用率、分支預測失誤等,精確定位熱點程式碼行。
* 快取模擬器 (如 cachegrind): 在不同快取配置下模擬程式執行,分析快取行為,即使沒有對應的硬體也能評估不同優化策略的效果。
* 記憶體分配分析工具 (如 massif, memusage): 追蹤程式的記憶體分配和釋放行為,識別大量小物件分配或分配模式不佳的位置,指導程式設計師改進記憶體管理。
* 分頁錯誤分析工具 (如 pagein): 分析程式執行過程中觸發分頁錯誤的順序和位置,幫助優化程式啟動或其他關鍵階段的程式碼和資料佈局,減少分頁錯誤開銷。
8. 未來趨勢的啟示
未來的硬體將繼續朝著增加核心數量、深化記憶體階層(更多級別的快取)、提高 NUMA 系統的複雜性、以及可能引入事務性記憶體 (Transactional Memory) 等方向發展。記憶體存取延遲預計會繼續增加。這意味著程式設計師對記憶體子系統的理解將變得越來越重要:
* 更複雜的快取和 NUMA 拓撲: 需要更精細的執行緒排程和記憶體策略來匹配硬體結構。
* 事務性記憶體: 作為一種新的同步原語,可能簡化某些無鎖定資料結構的實現,但需要理解其底層機理和與快取的互動。
* 向量運算與協處理器: 寬向量指令和專用協處理器將提供巨大的運算潛力,但資料如何在 CPU、快取、協處理器記憶體之間有效傳輸將是關鍵。
總而言之,程式設計師不能再將記憶體視為一個簡單的、均勻的儲存空間。必須深入理解記憶體階層、快取運作原理、虛擬記憶體管理、多處理器快取一致性以及 NUMA 架構的特點。透過優化程式碼和資料佈局、使用恰當的演算法、與作業系統協同以及利用分析工具,才能在現代硬體上充分發揮程式的效能潛力。對記憶體系統的深入了解將是高效能程式設計不可或缺的能力。
comments
comments for this post are closed