-
Notifications
You must be signed in to change notification settings - Fork 32
/
Copy pathsearch_plus_index.json
1 lines (1 loc) · 388 KB
/
search_plus_index.json
1
{"./":{"url":"./","title":"關於","keywords":"","body":"每位程式開發者都該有的記憶體知識 本文翻譯自 Ulrich Drepper 於 2007 年撰寫的論文〈What Every Programmer Should Know About Memory〉(版次: 1.0),原文共 114 頁。 隨著 CPU 核 (core) 的速度和數量的增長,記憶體存取成為制約當今大多數程式效率的因素,且在未來一段時間內仍然如此。 儘管硬體設計者提越來越複雜的記憶體處理和加速機制,例如 CPU 快取,但若程式開發者無法善加利用,這些硬體機制仍無法發揮有效作用。 不幸的是,大多數程式開發者對於計算機的記憶體子系統或 CPU 快取,無論是其內部結構還是存取成本,仍然相當陌生。 本文旨在解釋現代電腦硬體中記憶體子系統的結構,闡述 CPU 快取的發展考量,及它們的運作方式。 同時,本文也提供針對記憶體操作進行調整、達到最佳效能的建議。 翻譯資訊 譯者: Chi-En Wu, Jim Huang [info] 關於繁體中文翻譯內容的修正、改進建議,和貢獻,請造訪 sysprog21/cpumemory-zhtw "},"introduction.html":{"url":"introduction.html","title":"1. 引言","keywords":"","body":"1. 引言 早期的電腦相對簡單,系統中的各個元件,如 CPU、記憶體、大容量儲存裝置 (mass storage) 及網路介面 (network interface),在相近時間發展,因此在效能表現相對平衡。 以資料存取為例,記憶體和網路介面的速度儘管不若 CPU 快,但差異不顯著。 隨著電腦基礎結構的穩定及硬體開發者專注於最佳化獨立的子系統,這種情況發生變化:一些電腦元件的效能突然大幅落後,成為系統的瓶頸。 尤其是大容量儲存裝置和記憶體,由於成本的限制,與其他系統相比,改進相對緩慢。 大容量儲存裝置的效能問題主要藉由軟體技術解決:作業系統 (operating system) 將最常使用 (且最有可能使用) 的資料保存在主記憶體 (main memory) 中, 其存取速度比硬碟 (hard disk) 快了幾個數量級。 此外,快取也加到儲存裝置中,以提升效能,這不需要對作業系統進行任何改變1。 由於偏離本文的主旨,我們不會深入探討有關大型儲存裝置存取的軟體最佳化細節。 相較於儲存子系統,解決主記憶體的瓶頸被證實更加困難,因為幾乎所有的解決方法都需要改變硬體。 目前有以下主要方式來改善主記憶體的效能: RAM 的硬體設計,包括速度和平行度 (parallelism) 的提升 記憶體控制器(controller)的設計改進 CPU 快取的運用 裝置的直接記憶體存取 (Direct Memory Access,簡稱 DMA) 本文主要涉及 CPU 快取和記憶體控制器設計對系統性能的影響,探索這些主題的過程中,我們將深入了解直接記憶體存取 (DMA)。 不過,我們首先需要從現今商用硬體的設計概觀開始,這是理解如何高效使用記憶體子系統以及其限制的必要條件。 同時,我們也提及不同類型的 RAM,以及為何這些差異仍然存在。 值得一提的是,本文件並非最終且完整的版本,僅涵蓋商用硬體的一個子集,且許多主題的討論僅止於基本概念。 對於這些主題,建議讀者參照更詳盡的文件。 提及特定作業系統的細節和解決方法時,本文僅針對 Linux,不會包含其他作業系統的任何資訊,作者無意詳述其他作業系統。 若讀者覺得需要使用不同的作業系統,建議參照相應供應商所提供的文件。 最後,本文中多處使用「通常」和類似修飾詞,討論的技術在現實世界中存在許多不同的變體,本文僅針對最常見且主流的版本進行討論。 因此,這些技術往往沒有絕對的答案或解決方案。 文件結構 本文主要針對軟體開發者撰寫,不細究對於硬體方向讀者有用的硬體技術細節。 然而,談及能為開發者提供實用資訊之前,有一些基礎知識是必要的。 為了達到這個目標,第二節將以技術細節介紹隨機存取記憶體 (Random-Access Memory,簡稱 RAM),這一節的內容值得一讀,但對於後面幾節的理解並非必要。 在需要這節內容的地方,我們會提供相應的引用 (back reference),所以急於掌握主題的讀者可暫時略過本節的大部分內容。 第三節描述多項 CPU 快取行為的細節。為了避免冗長的文字,我們將使用一些圖表來說明。 這一節的內容對於理解後續章節是必不可少的。 第四節簡要介紹虛擬記憶體 (virtual memory) 的實作方式,這也是後續內容的基礎。 第五節則詳細論述了非均勻記憶體存取 (Non-Uniform Memory Access,簡稱 NUMA) 系統的細節。 第六節是本文的關鍵章節,將整合前幾節的知識,並向程式開發者提供如何在不同情境下撰寫高效程式的建議。 對於非常急切的讀者,可從這一節開始,並在需要時回顧前幾節的基礎知識。 第七節介紹一些能夠幫助程式開發者提升效能的工具。 即使完全理解這些技術,解決複雜軟體專案的問題仍然遠遠不夠,因此某些工具是必要的。 最後,在第八節中,我們將展望未來幾年中可期待或希望擁有的技術發展。 回報問題 作者有意更新這份文件一段時間,包括隨著技術的進展而進行更新,及修正錯誤。 如果讀者願意回報問題,請透過電子郵件與作者聯繫,並於回報中提供精確的版本資訊,這些資訊可以在文件的最後一頁找到。 非常感謝您的貢獻! 致謝 作者要特別感謝 Johnray Fuller 及 LWN 的團隊成員們 (尤其是 Jonathan Corbet 承擔著改寫為典雅英文風格的艱鉅任務)。 同時,也要感謝 Markus Armbruster 為本文提供許多寶貴的建議,指出其中的問題和疏漏。 關於本文 本文標題〈What Every Programmer Should Know About Memory〉向 David Goldberg 的經典論文〈What Every Computer Scientist Should Know About Floating-Point Arithmetic〉致敬。 儘管該論文鮮少人知,然而它闡述任何致力於嚴謹地撰寫程式的人的先備知識。 1. 然而,為確保在使用儲存裝置快取時的資料完整性 (data integrity),必須進行相應的改變。 ↩ "},"commodity-hardware-today.html":{"url":"commodity-hardware-today.html","title":"2. 現代商用硬體","keywords":"","body":"2. 現代商用硬體 肇因於專屬硬體的式微,了解商用硬體變得非常重要。 如今,水平發展比垂直發展更常見,這意味著使用許多小型且相互連接的商用電腦成本效益更高,而非少數幾個巨大且極快 (也非常昂貴) 的系統。 這是因為快速且便宜的網路硬體已經普及,雖然大型專用系統仍在某些情況下佔有一席之地,且仍具有商機,但整體市場已被商用硬體市場所主導。 Red Hat 在 2007 年預測,未來大多數資料中心的「標準建構元件」(building block) 將是一台具有最多 4 個插槽 (socket) 的電腦, 每個插槽具備一顆 4 核的處理器,這些處理器 (例如 Intel 公司生產的 CPU) 將使用超執行緒 (hyper-threading,簡稱 HT) 技術2。 這意味著標準資料中心系統將具有最多 64 個虛擬處理器譯註1。 當然,也可支援更大的機器,但 2007 年原文撰寫之際,4 個插槽、4 核的處理器會是最適合的配置,且大多數最佳化都針對這種硬體配置進行。 商用元件所構建的電腦在結構上存在著重大的差異,儘管如此,我們將專注於最重要的差異,以涵蓋超過 90% 的這類硬體。 需要注意的是,這些技術細節日新月異,因此建議讀者留意原文撰寫日期。 過去的數年中,個人電腦和小型伺服器已標準化為一個晶片組 (chipset),並由二個主要組件組成:北橋 (Northbridge) 和南橋 (Southbridge)。 圖 2.1 展現這種結構。 圖 2.1:包含北橋與南橋的結構 所有 CPU (前例中有二顆,但可有更多) 都透過一條共用的匯流排,即前端匯流排 (Front Side Bus,簡稱 FSB,連接到北橋。 北橋包含記憶體控制器 (memory controller),其實作決定在電腦中使用的 RAM 晶片類型。 不同類型的 RAM,諸如 DRAM、Rambus 和 SDRAM,需要不同的記憶體控制器。 為與其他系統裝置連接,北橋必須與南橋進行通訊,其中南橋通常稱為 I/O 橋接,藉由各種不同的匯流排與各個裝置進行通訊。 現今南橋支援 PCI、PCI Express、SATA 和 USB 等最重要的匯流排,及 PATA、IEEE 1394、序列埠 (serial port) 和平行埠 (parallel port)。 在較老的系統中,北橋附帶有 AGP 槽,這是由於南北橋之間的連接速度不夠快的性能因素,然而,現代的 PCI-E 槽都連接到南橋上。 這種系統結構具有一些值得注意的結果: 從一顆 CPU 到另一顆 CPU 的所有資料通訊,都必須經由與北橋通訊的同一條匯流排。 所有與 RAM 的通訊都必須經由北橋進行。 RAM 只有單個埠口3。 一顆 CPU 與一個依附於南橋的裝置之間的通訊會經由北橋。 幾個瓶頸立刻在這種設計中顯現出來,其一涉及裝置對 RAM 的存取。 在早期的個人電腦中,所有裝置 (無論在南橋還是北橋上) 與 RAM 的通訊都必須經過 CPU,這對整體系統效能產生負面影響。 為解決這個問題,一些裝置開始支援直接記憶體存取 (Direct Memory Access,簡稱 DMA),後者允許裝置藉由北橋的幫助, 在不需要 CPU 介入 (及相應的效能成本) 的情況下,直接儲存和接收 RAM 中的資料。 現今所有連接到匯流排上的高效能裝置都可使用 DMA,儘管這大幅減輕 CPU 的工作負擔,但這也引起北橋頻寬的競爭, 因為 DMA 請求與 CPU 對 RAM 存取的競爭。因此,這個問題必須納入考慮。 第二個瓶頸涉及從北橋到 RAM 的匯流排,其具體細節取決於所使用的記憶體類型。 在較舊的系統中,只有一條匯流排連接所有的 RAM 晶片,因此平行存取是不可能的。 近期的記憶體類型需要二條獨立的匯流排 (或稱通道〔channel〕,例如 DDR2 所稱之,見圖 2.8),以增加可用的頻寬。 北橋交錯使用這些通道進行記憶體存取,更現代的記憶體技術 (如 FB-DRAM) 則引入更多通道。 由於頻寬有限,以最小化延遲的方式安排記憶體存取對效能至關重要。 如同我們將在後面所見,處理器比記憶體快得多,且必須等待存取記憶體,即便使用 CPU 快取仍是。 如果多個超執行緒 (HT) 或多個處理器核心同時存取記憶體,那麼記憶體存取的等待時間可能會更長,對於 DMA 操作也是如此。 然而,除了並行性 (concurrency) 之外,存取記憶體還存在許多問題。存取模式 (access pattern) 本身也會對記憶體子系統的效能產生顯著影響, 尤其是在具有多個記憶體通道的情況下,在第 2.2 節中,我們將深究 RAM 存取模式的更多細節。 在一些高價的系統中,北橋不含記憶體控制器,相反地,北橋可連接到多個外部記憶體控制器 (下例中,共有 4 個控制器)。 圖 2.2:包含外部控制器的北橋 該架構的好處是擁有多個記憶體匯流排,從而增加整體可用頻寬,此設計還支援多個記憶體模組。 並行記憶體存取模式藉由同時存取不同的記憶庫 (memory bank) 來降低延遲,尤其是在多個處理器直接連接到北橋的情況下 (如圖 2.2 所示)。 對於這種設計,主要的限制在於北橋的內部頻寬,對於這種 (來自 Intel 公司) 的架構而言,該頻寬非常大4。 使用多個外部記憶體控制器並非提高記憶體頻寬的唯一方法,另一種越來越受歡迎的方式是將記憶體控制器整合到 CPU 中,並將記憶體附加到每顆 CPU 上。 該架構在基於 AMD 公司的 Opteron 處理器的 SMP 系統中很流行,如圖 2.3 所示。 Intel 也從 Nehalem 微處理器架構開始,支援通用系統介面 (Common System Interface,簡稱 CSI),基本上也是相同的方法:一個整合式記憶體控制器,讓每個處理器都擁有區域記憶體 (local memory)。 圖 2.3:整合式記憶體控制器 採用這種架構,每個處理器都有一個可用的記憶庫,例如在一台 4 核 CPU 的機器上,無需複雜的北橋即可將記憶體頻寬提高 4 倍。 整合到 CPU 的記憶體控制器也具有一些額外的優點,但在此我們不會深究這些技術。 然而,這種架構也有一些缺點。 首先,由於系統上的所有記憶體都需要供所有處理器存取,記憶體不再是均勻的 (uniform), 這種系統稱為非均勻記憶體架構 (Non-Uniform Memory Architecture,簡稱 NUMA)。 當存取附屬於其他處理器的記憶體時,必須藉由處理器之間的交互連線 (interconnect), 從 CPU1 存取 CPU2 的記憶體就需要一條交互連線,而從 CPU1 存取 CPU4 的記憶體則需要二條交互連線。 每次這樣的通訊都會帶來一些成本,我們稱之為「NUMA 因子」(NUMA factor),用於描述存取遠端 (remote) 記憶體所需的額外時間。 在圖 2.3 所示的範例架構中,每顆 CPU 都有二個層級:與其鄰近的 CPU 和相隔二條交互連線的 CPU,在較複雜的系統中,層級的數量會顯著增加。 某些架構 (如 IBM 公司的 x445 和 SGI 公司的 Altix 系列) 具有不止一種連線類型。CPU 被組織成節點,存取同一節點內的記憶體具有一致或較低的 NUMA 因子。 然而,節點之間的連線成本很高,NUMA 因子也很高。 現在商用的 NUMA 機器已問世,並可能在未來扮演更重要的角色。預計到 2008 年末,每個 SMP 機器都將使用 NUMA譯註2。 當一個程式在 NUMA 機器上運行時,了解 NUMA 相關的成本非常重要。我們將在第五節中討論更多有關機器架構以及 Linux 核心 (kernel譯註3) 為這些程式提供的技術。 除了本節描述的技術細節外,尚有許多其他影響 RAM 效能的因素無法由軟體控制,這也是為何它們不在本節中涵蓋。 對於對 RAM 技術希望有更全面理解的讀者來說,這些因素有助於在購買電腦時做出更好的選擇。 接下來的二節將在邏輯閘 (gate) 層級討論硬體細節,並介紹記憶體控制器和 DRAM 晶片之間的通訊協定 (protocol)。 對程式開發者而言,這些資訊可能會帶來更深入的理解,因為它們解釋 RAM 存取的運作原理。 然而,這些都是選讀的知識,對於那些更關注與日常生活直接相關的主題的讀者來說,可直接跳到第 2.2. 5節。 2. 超執行緒 (HT) 使得一顆處理器核僅需少量的額外硬體,就能被用來同時處理 2 個或多個任務。 ↩ 3. 我們不會在本文討論多埠 RAM,因為這種 RAM 在商用硬體中並不常見,至少不在尋常程式開發者可存取的範圍內。多埠 RAM 主要用於依賴極限速度的專用硬體,例如網路路由器等設備中。 ↩ 4. 完整起見,這裡需要提到一下,這類記憶體控制器佈局可以被用於其它用途,像是「記憶體 RAID」,它很適合與熱插拔(hotplug)記憶體組合使用。 ↩ 譯註1. 這句的原文是 \"the standard system in the data center will have up to 64 virtual processors\",注意到本文發表的時間點在 2007 年,本句是 Red Hat 公司之前的推論,64 個虛擬處理器核意味著 4 個插槽、HT (即 2 個硬體執行緒),和每個 CPU 要有 8 核,不過這樣的硬體配置要到 2014 年的 POWER8 才出現,後者的每個 CPU 可有 6 或 12 核。原文作者 Ulrich Drepper 撰寫文章時,任職於 Red Hat 公司,這也是為何文中提及 Red Hat,後來他一度在高盛服務,並於 2017 年回歸。 ↩ 譯註2. 這陳述不成立,如今 SMP 架構廣為個人電腦、移動裝置,甚至是嵌入式系統所採納,但 NUMA 仍只在部分 SMP 系統存在。 ↩ 譯註3. 為了區分 (OS) kernel 和 (processor) core,本文將前者稱為「核心」(例如 Linux 核心),而將後者稱為「核」(例如處理器核)。 ↩ "},"commodity-hardware-today/ram-types.html":{"url":"commodity-hardware-today/ram-types.html","title":"2.1. RAM 的種類","keywords":"","body":"2.1. RAM 的種類 這些年來,已經有許多不同種類的 RAM,而每種類型都各有 ── 有時是非常顯著的 ── 差異。只有歷史學家會對那些較老舊的類型有興趣。而我們將不會探究它們的細節。我們將會聚焦於現代的 RAM 類型;我們僅會觸及其問題的表面,探究系統核心或是應用程式能透過其效能特性看見的一些細節。 第一個有趣的事情是,圍繞於在同一台機器中會有不同種類的 RAM 的原因。更具體地說,為何既有靜態 RAM(Static RAM,SRAM5)又有動態 RAM(Dynamic RAM,DRAM)。前者更加快速,而且提供了相同的功能。為何一台機器裡的 RAM 不全是 SRAM?答案是 ── 也許正是你所預期的 ── 成本。生產與使用 SRAM 比起 DRAM 都更加昂貴。這二個成本因素都很重要,而且後者變得越來越重要。為了瞭解這些差異,我們要稍微研究一下 SRAM 與 DRAM 儲存的實作方式。 在本節的其餘部分,我們將會討論到一些 RAM 實作的底層細節。我們將會讓細節盡可能地底層。最後,我們將會從「邏輯層級」討論訊號(signal),而非從硬體設計師所需的那種層級。那種細節層級對我們這裡的目的來說是不必要的。 5. 根據不同前後文,SRAM 指的可能是「同步(synchronous)RAM」。 ↩ "},"commodity-hardware-today/ram-types/static-ram.html":{"url":"commodity-hardware-today/ram-types/static-ram.html","title":"2.1.1. 靜態 RAM","keywords":"","body":"2.1.1. 靜態 RAM 圖 2.4:6-T 靜態 RAM 圖 2.4 展示了一組由 6 個電晶體 (transistor) 構成的 SRAM 記憶單元(cell)的結構。這個記憶單元的中心是四個電晶體 M1 \\mathbf{M_{1}} M1 到 M4 \\mathbf{M_{4}} M4,其形成二個交叉耦合(cross-coupled)的反相器(inverter)。它們有二個穩定狀態,分別表示 0 與 1。只要 Vdd \\mathbf{V_{dd}} Vdd 維持通電,狀態就是穩定的。 若是需要存取記憶單元的狀態,就提高字組存取線路(word access line)WL \\mathbf{WL} WL 的電位。若是必須複寫記憶單元的狀態,則要先將 BL \\mathbf{BL} BL 與 BL‾ \\overline{\\mathbf{BL}} BL 線路設為想要的值,然後再提高 WL \\mathbf{WL} WL 的電位。由於外部的驅動者(driver)強於四個電晶體(M1 \\mathbf{M_{1}} M1 到 M4 \\mathbf{M_{4}} M4),這使得舊的狀態得以被覆寫。 需要更多記憶單元運作方式的詳細描述,請見 [20]。為了接下來的討論,要注意的重點是 一個記憶單元需要六個電晶體。也有四個電晶體的變體,但其有些缺點。 維持記憶單元的狀態需要持續供電。 當提高字組存取線路 WL \\mathbf{WL} WL 的電位時,幾乎能立即取得記憶單元的狀態。其訊號如同其它電晶體控制的訊號,是直角的(rectangular)(在二個二元狀態間迅速地轉變)。 記憶單元的狀態是穩定的,不需要再充電週期(refresh cycle)。 也有其它可用的 SRAM 形式 –– 較慢且較省電。但因為我們尋求的是更快的 RAM,所以在此我們對它並不感興趣。這些慢的變種引發關注的主要原因是,它們比起動態 RAM 更容易被用在系統中,因為它們的介面較為簡單。 "},"commodity-hardware-today/ram-types/dynamic-ram.html":{"url":"commodity-hardware-today/ram-types/dynamic-ram.html","title":"2.1.2. 動態 RAM","keywords":"","body":"2.1.2. 動態 RAM 動態 RAM 在其結構上比靜態 RAM 簡單許多。圖 2.5 示意了常見的 DRAM 記憶單元設計結構。它僅由一個電晶體以及一個電容(capacitor)組成。複雜度上的巨大差異,自然意味著其與靜態 RAM 的運作方式非常不同。 圖 2.5:1-T 動態 RAM 一個動態 RAM 的記憶單元在電容 C \\mathbf{C} C 中保存其狀態。電晶體 M \\mathbf{M} M 用以控制狀態的存取。為了讀取記憶單元的狀態,要提高存取線路 AL \\mathbf{AL} AL 的電位;這要不使得電流流經資料線路(data line) DL \\mathbf{DL} DL、要不沒有,取決於電容中的電量。要寫到記憶單元中,則要適當地設置資料線路 DL \\mathbf{DL} DL,然後將 AL \\mathbf{AL} AL 的電位提高至足夠長的時間,以讓電容充電或放電。 動態 RAM 有許多設計上的難題。使用電容意味著讀取記憶單元時會對電容放電。這件事無法不斷重複,必須在某個時間點上對電容重新充電。更糟的是,為了容納大量的記憶單元(晶片有著 109 或者更多的記憶單元在現今是很普遍的),電容的電量必須很低(在飛〔femto,10-15〕法拉範圍內或者更小)。完全充電後的電容容納了數萬個電子。儘管電容的電阻很高(幾兆歐姆),耗盡電容仍舊只需要很短的時間。這個問題被稱為「漏電(leakage)」。 這種洩漏是 DRAM 必須被持續充電的原因。對於現今大部分 DRAM 晶片,每 64ms 就必須重新充電一次。在重新充電的期間內是無法存取記憶體的,因為重新充電基本上就是直接丟棄結果的讀取操作。對某些工作而言,這個額外成本可能會延誤高達 50% 的記憶體存取(見 [3])。 第二個 –– 因微小電量而造成 –– 的問題是,從記憶單元讀取的資訊無法直接使用。資料線路必須被連接到感測放大器(sense amplifier),其能夠根據仍需計作 1 的電量範圍來分辨儲存的 0 或 1。 第三個問題是,從記憶單元進行讀取會消耗電容的電量。這代表每次的讀取操作都必須接著進行重新對電容充電的操作。這能夠透過將感測放大器的輸出餵回到電容裡頭來自動達成。雖然這代表讀取記憶體內容需要額外的電力以及 –– 更為重要地 –– 時間。 第四個問題是,對電容充放電並不是立即完成的。由於感測放大器接收到的訊號並不是直角的,因此必須使用一個謹慎的估計,以得知何時可以使用記憶單元的輸出。對電容充放電的公式為 QCharge(t)=Q0(1−e−tRC)QDischarge(t)=Q0e−tRC \\begin{aligned} Q_{\\text{Charge}}(t) &= Q_{0}(1 - e^{-\\frac{t}{RC}}) \\\\ Q_{\\text{Discharge}}(t) &= Q_{0} e^{-\\frac{t}{RC}} \\end{aligned} QCharge(t)QDischarge(t)=Q0(1−e−RCt)=Q0e−RCt 這代表讓電容充電或放電需要一些時間(由電量 C 與電阻 R 決定)。這也代表無法立即使用能被感測放大器偵測的電流。圖 2.6 顯示了充電與放電的曲線。X 軸以 RC(電阻乘上電量)為單位,這是一種時間單位。 圖 2.6:電容充電與放電時間 不像靜態 RAM 能夠在字組存取線路的電位提高時立即取得輸出結果,它會花費一些時間以讓電容被充分放電。這個延遲嚴重地限制了 DRAM 能夠達到的速度。 簡單的方法也有其優點。最主要的優點是容量。比起一個 SRAM 的記憶單元,一個 DRAM 的記憶單元所需的晶片面積要小好幾倍。SRAM 記憶單元也需要個別的電力來維持電晶體的狀態。DRAM 記憶單元的結構也較為簡單,這代表能較輕易地將許多記憶單元緊密地塞在一起。 總體來說,贏在(極為戲劇性的)成本差異。除了在專門的硬體 –– 舉例來說,網路路由器 –– 之外,我們必須採用基於 DRAM 的主記憶體。這對程式開發者有著巨大的影響,我們將會在本文的其餘部分討論它們。但首先,我們需要先多理解一些實際使用 DRAM 記憶單元的細節。 "},"commodity-hardware-today/ram-types/dram-access.html":{"url":"commodity-hardware-today/ram-types/dram-access.html","title":"2.1.3. DRAM 存取","keywords":"","body":"2.1.3. DRAM 存取 一支程式使用虛擬位址(virtual address)來選擇記憶體位置。處理器將其轉譯(translate)成實體位址(physical address),最終由記憶體控制器選擇對應於這個位址的 RAM 晶片。為了選擇 RAM 晶片中的個別記憶單元,實體位址的一部分會以數條位址線(address line)的形式被傳遞進去。 由記憶體控制器個別定址(address)記憶體位置是極為不切實際的:4GB 的 RAM 會需要 232 條位址線。作為替代,位址會使用較小的一組位址線,編碼成二進位數值傳遞。以這種方式傳遞到 DRAM 晶片的位址必須先被解多工(demultiplex)。有 N N N 條位址線的解多工器(demultiplexer)將會有 2N N N 條輸出線(output line)。這些輸出線能被用以選擇記憶單元。對於小容量的晶片而言,使用這種直接的方法是沒什麼大問題的。 但假如記憶單元的數量增加,這個方法就不再合適。一個 1Gbit6 容量的晶片將會需要 30 條位址線以及 230 選擇線(select line)。在不犧牲速度的前提下,解多工器的容量會隨著輸入線(input line)的數量以指數成長。用於 30 條位址線的解多工器需要大量的晶片空間,外加解多工器的(尺寸與時間)複雜度。更重要的是,同時在位址線上傳輸 30 個脈衝(impulse)比「只」傳輸 15 個脈衝還要難得多。只有少數的位址線能夠以長度完全相同或適當安排時間的方式排版。7 圖 2.7:動態 RAM 示意圖 圖 2.7 顯示了以極高階角度示意的 DRAM 晶片。DRAM 記憶單元被組織在列(row)與行(column)中。雖然它們可以全都排成一列,但 DRAM 晶片會因而需要一個龐大的解多工器。藉由陣列(array)的方式,便能夠以各為一半容量的一個解多工器與一個多工器達到這種目的。8這從各方面來說都是個大大的節約。在這個例子中,位址線 a0 \\mathbf{a_{0}} a0 與 a1 \\mathbf{a_{1}} a1 透過列位址選擇(row address selection)(RAS‾ \\overline{\\text{RAS}} RAS)9解多工器選擇一整列記憶單元的位址線。在讀取時,所有記憶單元的內容都能夠被行位址選擇(column address selection)多工器(multiplexer)(CAS‾ \\overline{\\text{CAS}} CAS)取得。基於位址線 a2 \\mathbf{a_{2}} a2 與 a3 \\mathbf{a_{3}} a3,其中一行的內容便能夠提供給 DRAM 晶片的資料針腳(pin)。這會在許多 DRAM 晶片上平行地發生多次,以產生對應於資料匯流排寬度的所有位元。 對於寫入操作,新的記憶單元的值會被置於資料匯流排中,然後 ── 當記憶單元藉由 RAS‾ \\overline{\\text{RAS}} RAS 與 CAS‾ \\overline{\\text{CAS}} CAS 選取時 ── 儲存到資料單元中。相當直觀的設計。這實際上有著顯然地更多的困難。需要規範發出訊號之後,在資料能夠由資料匯流排讀取之前有多少延遲。如同上節所述,電容無法立即充放電。來自於記憶單元的訊號太微弱了,以致於它非得被放大(amplify)不可。對於寫入操作,必須指定設置完 RAS‾ \\overline{\\text{RAS}} RAS 與 CAS‾ \\overline{\\text{CAS}} CAS 之後,資料必須在匯流排維持多久,才能夠成功地在記憶單元中儲存新值(再提醒一次,電容不會立即被充放電)。這些時間常數(constant)對於 DRAM 晶片的效能而言是至關重要的。我們將會在下一節討論這些。 一個次要的可擴展性問題是,令 30 條位址線都連接到每個 RAM 晶片也不大可行。一個晶片的針腳是個寶貴的資源。必須盡可能多地平行傳輸資料(像是一次 64 位元)已經夠「糟」。記憶體控制器必須能夠定址每個 RAM 模組(module)(RAM 晶片的集合)。假如因為效能因素,需要平行存取多個 RAM 模組,並且每個 RAM 模組需要它所擁有的一組 30 條或者更多條位址線,那麼僅為了處理位址,以 8 個 RAM 模組而言,記憶體控制器就必須要有多達 240+ 根針腳。 為了克服這些次要的可擴展性問題,DRAM 晶片長期以來必須自行多工位址。這代表位址會被轉變成二個部分。由位址位元(圖 2.7 的例子中的 a0 \\mathbf{a_{0}} a0 與 a1 \\mathbf{a_{1}} a1)組成的第一個部分選取列。這個選擇直到撤銷之前都會維持有效。接著第二個部分,位址位元 a2 \\mathbf{a_{2}} a2 與 a3 \\mathbf{a_{3}} a3,選取行。決定性的差異在於,只需要二條外部的位址線。譯註需要額外一些少量的線路來代表能否取得 RAS‾ \\overline{\\text{RAS}} RAS 與 CAS‾ \\overline{\\text{CAS}} CAS 的線路,但這對於減半位址線來說,這只是個很小的代價。不過,這種位址多工帶來了一些自身的問題。我們將會在 2.2 節討論這些問題。 6. 我厭惡這些 SI 前綴(prefix)。對我來說一個 giga-bit 永遠是 230 而非 109 位元。 ↩ 7. 現代 DRAM 類型(如 DDR3)可以自動調整時序,但可以容忍的範圍有限。 ↩ 8. 多工器與解多工器是對等的,並且這裡的多工器在寫入時需要如解多工器一般運作。所以從現在開始我們要忽略其差異。 ↩ 9. 名字上的線表示訊號是反相的(negated)。 ↩ 譯註. 這裡的意思單看字面可能比較難理解。以圖 2.7 為例,記憶體由 4 × 4 的記憶單元組成。對外僅提供二條位址線。在選擇記憶單元時,首先由二條外部位址線指定列,並將結果暫存起來。接著,再利用同樣的二條外部位址線指定行。便能夠只用二條外部位址線來指定 16 個記憶單元中的一個。詳見 2.2 節。 ↩ "},"commodity-hardware-today/ram-types/conclusions.html":{"url":"commodity-hardware-today/ram-types/conclusions.html","title":"2.1.4. 結論","keywords":"","body":"2.1.4. 結論 若是本節中的細節稍微令人喘不過氣,別擔心。要從本節得到的重點為: 有著為何不是所有記憶體都為 SRAM 的理由 記憶單元需要被個別選取使用 位址線的數量直接反映記憶體控制器、主機板(motherboard)、DRAM 模組、與 DRAM 晶片的成本 在讀取或寫入操作的結果有效之前得花上一段時間 接下來的章節將會深入更多存取 DRAM 記憶體的實際過程的細節。我們不會深入存取 SRAM 的細節,它通常是直接定址的。這是基於速度考量,並且因為 SRAM 記憶體受限於其容量。SRAM 目前被用於 CPU 快取並內建於晶片上(on-die),其連線較少、並且完全在 CPU 設計者的控制中。CPU 快取是我們將會在之後談及的主題,但我們所需知道的是,SRAM 記憶單元有著確切的最大速度,這取決於在 SRAM 上所花的努力。速度可以從略微慢於 CPU 核到慢於一或二個數量級。 "},"commodity-hardware-today/dram-access-technical-details.html":{"url":"commodity-hardware-today/dram-access-technical-details.html","title":"2.2. DRAM 存取技術細節","keywords":"","body":"2.2. DRAM 存取技術細節 在介紹 DRAM 的章節中,我們看到為了節省位址針腳,DRAM 晶片多工了位址。我們也看到因為記憶單元中的電容無法立即放電以產生穩定的訊號,存取 DRAM 記憶單元會花點時間;我們也看到 DRAM 記憶單元必須被重新充電。現在,是時候將這全都擺在一起,看看這些因子是如何決定 DRAM 存取是怎麼運作的。 我們將會聚焦於目前的技術上;我們不會討論非同步(asynchronous)DRAM 及其變種,因為它們完全與此無關。對這個主題感興趣的讀者請參見 [3] 與 [19]。我們也不會談及 Rambus DRAM(RDRAM),縱使這項技術並不過時。只是它並沒有被廣泛使用於系統記憶體中。我們僅會聚焦於同步 DRAM(Synchronous DRAM,SDRAM)與其後繼者雙倍資料傳輸速率 DRAM(Double Data Rate DRAM,DDR)。 同步 DRAM,如同名稱所暗示的,其運作與一種時間源(time source)有關。記憶體控制器提供一個時鐘(clock),其頻率決定前端匯流排(FSB)── DRAM 晶片使用的記憶體控制器介面 ── 的速度。在撰寫此文時,頻率可達 800MHz、1,066MHz、甚至到 1,333MHz,並宣稱在下個世代會達到更高的頻率(1,600MHz)。這不代表用於匯流排的頻率真的這麼高。而是現今的匯流排都是二或四倍頻(double- or quad-pumped),代表資料在每個週期傳輸二或四次。數字越高、賣得越好,因此廠商慣於將四倍頻 200MHz 匯流排宣傳成「實質上的(effective)」800MHz 匯流排。 對於現今的 SDRAM 來說,每次資料傳輸以 64 位元 (即 8 位元組) 組成。FSB 的傳輸率因此為 8 位元組乘上實際的匯流排頻率(以四倍頻 200MHz 匯流排來說,6.4GB/s)。這聽起來很多,但這是尖峰速度 ── 永遠無法超越的最大速度。如同我們將會看到的,現今與 RAM 模組溝通的協定中有許多的閒置期(downtime),這時是沒有資料可以被傳輸的。這個閒置期正是我們必須瞭解、並減到最小,以達到最佳效能的東西。 "},"commodity-hardware-today/dram-access-technical-details/read-access-protocol.html":{"url":"commodity-hardware-today/dram-access-technical-details/read-access-protocol.html","title":"2.2.1. 讀取協定","keywords":"","body":"2.2.1. 讀取協定 圖 2.8:SDRAM 讀取時序 圖 2.8 顯示 DRAM 模組上一些連線的活動,發生在三個標上不同顏色的階段。像往常一樣,時間從左到右流動。許多細節被省略。這裡我們僅討論匯流排時鐘、RAS‾ \\overline{\\text{RAS}} RAS 與 CAS‾ \\overline{\\text{CAS}} CAS 訊號、以及位址與資料匯流排。讀取週期從記憶體控制器在位址匯流排提供列位址、並降低 RAS‾ \\overline{\\text{RAS}} RAS 訊號的電位開始。所有訊號都會在時鐘(CLK)的上升邊沿(rising edge)被讀取,因此若是訊號並不是完全的方波也無所謂,只要在讀取的這個時間點是穩定的就行。設置列位址會使得 RAM 晶片鎖上(latch)指定的列。 經過 tRCD(RAS‾ \\overline{\\text{RAS}} RAS 至 CAS‾ \\overline{\\text{CAS}} CAS 的延遲)個時脈週期之後,便能發出 CAS‾ \\overline{\\text{CAS}} CAS 訊號。這時行位址便能藉由位址匯流排提供、以及降低 CAS‾ \\overline{\\text{CAS}} CAS 線路的電位來傳輸。這裡我們可以看到,位址的二個部分(約莫是對半分,其餘的情況並不合理)是如何透過同樣的位址匯流排來傳輸。 現在定址已經完成,可以傳輸資料。為此 RAM 晶片需要一點時間準備。這個延遲通常被稱作 CAS‾ \\overline{\\text{CAS}} CAS 等待時間(CAS‾ \\overline{\\text{CAS}} CAS Latency,CL)。在圖 2.8 中,CAS‾ \\overline{\\text{CAS}} CAS 等待時間為 2。這個值可高可低,取決於記憶體控制器、主機板、以及 DRAM 模組的品質。等待時間也可以是半週期。以 CL=2.5 而言,資料將能夠在藍色區塊的第一個下降邊沿時取得。 對於取得資料的這些準備工作而言,僅傳輸一個字組的資料是很浪費的。這即是為何 DRAM 模組允許記憶體控制器指定要傳輸多少資料。通常選擇在 2、4、或 8 字組之間。這便能在不提供新的 RAS‾ \\overline{\\text{RAS}} RAS/CAS‾ \\overline{\\text{CAS}} CAS 序列的情況下填滿快取中的整行(line)。記憶體控制器也能夠在不重設列的選取的情況下發出新的 CAS‾ \\overline{\\text{CAS}} CAS 訊號。藉由這種方式,能夠非常快速地讀取或寫入連續的記憶體位址,因為不必發出 RAS‾ \\overline{\\text{RAS}} RAS 訊號,也不必將列無效化(deactivate)(見後文)。記憶體控制器必須決定是否讓列保持「開啟(open)」。一直任其開啟,對實際的應用程式來說有些負面影響(見 [3])。發出新的 CAS‾ \\overline{\\text{CAS}} CAS 訊號僅受 RAM 模組的命令速率(command rate)控制(通常設為 Tx,其中 x 為像是 1 或 2 的值;每個週期都接受命令的高效能 DRAM 模組會設為 1)。 在這個例子中,SDRAM 在每個週期吐出一個字組。第一世代就是這麼做的。DDR 能夠在每個週期傳輸二個字組。這減少傳輸時間,但沒有改變等待時間。雖然在實務上看起來不同,但原理上 DDR2 運作如斯。這裡沒有再深入細節的必要。能夠注意到 DDR2 可以變得更快、更便宜、更可靠、並且更省電(更多資訊見 [6])就夠。 "},"commodity-hardware-today/dram-access-technical-details/precharge-and-activation.html":{"url":"commodity-hardware-today/dram-access-technical-details/precharge-and-activation.html","title":"2.2.2. 預充電與有效化","keywords":"","body":"2.2.2. 預充電與有效化 圖 2.8 未涵蓋整個週期。它只顯示出存取 DRAM 的完整循環的一部分。在能夠發送新的 RAS‾ \\overline{\\text{RAS}} RAS 訊號之前,必須無效化(deactivate)目前鎖上的列,並對新的列預充電(precharge)。這裡我們僅聚焦在藉由明確命令來執行的情況。有些協定上的改進 ── 在某些情況下 ── 能夠避免這個額外步驟。不過由預充電引入的延遲仍然會影響操作。 圖 2.9:SDRAM 預充電與有效化 圖 2.9 示意從 CAS‾ \\overline{\\text{CAS}} CAS 訊號開始、到另一列的 CAS‾ \\overline{\\text{CAS}} CAS 訊號為止的活動。與先前一樣,經過 CL 週期後,便能夠取得以第一個 CAS‾ \\overline{\\text{CAS}} CAS 訊號請求的資料。在這個例子中,請求二個字組,其 ── 在一個簡易的 SDRAM 上 ── 花了二個週期來傳輸。也可以想像成是在一張 DDR 晶片上傳輸四個字組。 即使在命令速率為 1 的 DRAM 模組上,也無法立即發出預充電命令。它必須等待與傳輸資料一樣長的時間。在這個例子中,它花了二個循環。雖然與 CL 相同,但這只是巧合。預充電訊號沒有專用的線路;有些實作是藉由同時降低允寫(Write Enable,WE‾ \\overline{\\text{WE}} WE)與 RAS‾ \\overline{\\text{RAS}} RAS 的電位來發出這個命令。這個組合本身沒什麼特別意義(編碼細節見 [18])。 一旦發出預充電命令,它會花費 tRP(列預充電時間)個週期,直到列能被選取為止。在圖 2.9 中,大部分的時間(以紫色標示)與記憶體傳輸時間(淺藍)重疊。這滿好的!但 tRP 比傳輸時間還長,所以下一個 RAS‾ \\overline{\\text{RAS}} RAS 訊號會被延誤一個週期。 假使我們延伸圖表的時間軸,我們會發現下一次資料傳輸發生在前一次停止的 5 個週期之後。這表示在七個週期中,只有二個週期有用到資料匯流排。將這乘上 FSB 的速度,對 800MHz 匯流排而言,理論上的 6.4GB/s 就變成 1.8GB/s。這太糟,而且必須避免。在第六節描述的技術能幫忙提升這個數字。程式開發者通常也得盡一份力。 對於 SDRAM 模組,還有一些沒有討論過的時間值。在圖 2.9 中,預充電命令受限於資料傳輸時間。另一個限制是,在 RAS‾ \\overline{\\text{RAS}} RAS 訊號之後,SDRAM 模組需要一些時間才能夠為另一列預充電(記作 tRAS)。這個數字通常非常大,為 tRP 值的二到三倍。假如 ── 在 RAS‾ \\overline{\\text{RAS}} RAS 訊號之後 ── 只有一個 CAS‾ \\overline{\\text{CAS}} CAS 訊號,並且資料傳輸在少數幾個週期內就完成,這就是問題。假設在圖 2.9 中,起始的 CAS‾ \\overline{\\text{CAS}} CAS 訊號是直接接在 RAS‾ \\overline{\\text{RAS}} RAS 訊號之後,並且 tRAS 為 8 個週期。預充電命令就必須要延遲一個額外的週期,因為 tRCD、CL、與 tRP(因為它比資料傳輸時間還長)的總和只有 7 個週期。 DDR 模組經常以一種特殊的標記法描述:w-x-y-z-T。舉例來說:2-3-2-8-T1。這代表: w 2 CAS‾ \\overline{\\text{CAS}} CAS 等待時間(CL) x 3 RAS‾ \\overline{\\text{RAS}} RAS 至 CAS‾ \\overline{\\text{CAS}} CAS 等待時間(tRCD) y 2 RAS‾ \\overline{\\text{RAS}} RAS 預充電(tRP) z 8 有效化至預充電延遲(tRAS) T T1 命令速率 還有許多其它會影響命令的發送或處理方式的時間常數。不過在實務上,這五個常數就足以判定模組的效能。 知道這些關於電腦的資訊,有時有助於解釋某些量測結果。購買電腦的時候,知道這些細節顯然是有用的,因為它們 ── 以及 FSB 與 SDRAM 模組的速度 ── 是決定一台電腦速度的最重要因素。 非常大膽的讀者也可以試著調校(tweak)系統。有時候 BIOS 允許修改某些或者全部的值。SDRAM 模組擁有能夠設定這些值的可程式化暫存器(register)。通常 BIOS 會挑選最佳的預設值。如果 RAM 模組的品質很好,可能可以在不影響電腦穩定性的前提下降低某些延遲。網路上眾多的超頻網站提供大量的相關文件。儘管如此,請自行承擔風險,可別說你沒被警告過。 "},"commodity-hardware-today/dram-access-technical-details/recharging.html":{"url":"commodity-hardware-today/dram-access-technical-details/recharging.html","title":"2.2.3. 再充電","keywords":"","body":"2.2.3. 再充電 談及 DRAM 存取時,一個最常被忽略的主題是再充電(recharging)。如同 2.1.2 節所述,DRAM 記憶單元必須被持續地重新充電。對於系統的其餘部分來說,這並不那麼容易察覺。在對列10重新充電的時候,是不能對它存取的。在 [3] 的研究中發現「出乎意料地,DRAM 再充電的安排可能大大地影響效能」。 根據 JEDEC(聯合電子裝置工程委員會,Joint Electron Device Engineering Council)規範,每個 DRAM 記憶單元每隔 64ms 都必須重新充電。假如一個 DRAM 陣列有 8,192 列,這代表記憶體控制器平均每 7.8125μs 都得發出再充電命令(再充電命令能夠佇列等待〔queue〕,因此在實務上,二次請求間的最大間隔能更長一些)。為再充電命令排程是記憶體控制器的職責。DRAM 模組紀錄最後一次再充電的列的位址,並且自動為新的請求增加位址計數器。 關於再充電與發出命令的時間點,程式開發者能做的真的不多。但在解釋量測結果時,務必將 DRAM 生命週期的這個部分記在心上。假如必須從正在被重新充電的列中取得一個關鍵的字組,處理器會被延誤很長一段時間。重新充電要花多久則視 DRAM 模組而定。 10. 不管 [3] 與其它文獻怎麼說,列都是再充電的單位(見 [18])。 ↩ "},"commodity-hardware-today/dram-access-technical-details/memory-types.html":{"url":"commodity-hardware-today/dram-access-technical-details/memory-types.html","title":"2.2.4. 記憶體類型","keywords":"","body":"2.2.4. 記憶體類型 值得花點時間來看看目前以及即將到來的記憶體類型。我們將從 SDR(單倍資料傳輸速率,Single Data Rate)SDRAM 開始,因為它們是 DDR(雙倍資料傳輸速率,Double Data Rate)SDRAM 的基礎。SDR 十分簡單。記憶單元與資料傳輸速率是一致的。 圖 2.10:SDR SDRAM 的運作 在圖 2.10 中,記憶單元陣列能夠以等同於經由記憶體匯流排傳輸的速率輸出記憶體內容。假如 DRAM 記憶單元陣列能夠以 100MHz 運作,單一記憶單元的匯流排的資料傳輸率便為 100Mb/s。所有元件的頻率 f f f 都是一樣的。由於耗能會隨著頻率增加而增加,因此提升 DRAM 晶片的吞吐量(throughput)的代價很高。由於使用大量的陣列單元,代價高得嚇人。11實際上,提升頻率通常也需要提升電壓,以維持系統的穩定性,這更是一個問題。DDR SDRAM(追溯地稱為 DDR1)設法在不提高任何相關頻率的情況下提升吞吐量。 圖 2.11:DDR1 SDRAM 的運作 SDR 與 DDR1 之間的差異是 ── 如同在圖 2.11 所見、以及從它們名稱來猜測的 ── 每個週期傳輸二倍的資料量。也就是說,DDR1 晶片會在上升與下降邊沿傳輸資料。這有時被稱作一條「二倍頻(double-pumped)」匯流排。為了不提升記憶單元陣列的頻率,必須引入一個緩衝區(buffer)。這個緩衝區會持有每條資料線的二個位元。這轉而要求令 ── 圖 2.7 的記憶單元陣列中的 ── 資料匯流排由二條線路組成。實作的方式很直觀:只要對二個 DRAM 記憶單元使用相同的行位址,並且平行存取它們就行。這個實作對記憶單元陣列的改變也非常小。 SDR DRAM 直接採用其頻率來命名(例如 PC100 代表 100MHz SDR)。由於 DDR1 DRAM 的頻率不會改變,廠商必須想出新的命名方式,讓它聽起來更厲害。他們提出的名字包含一個 DDR 模組(擁有 64 位元匯流排)能夠維持、以位元組為單位的傳輸速率: 100MHz×64bit×2=1,600MB/s 100\\text{MHz} \\times 64\\text{bit} \\times 2 = 1,600\\text{MB/s} 100MHz×64bit×2=1,600MB/s 於是一個頻率為 100MHz 的 DDR 模組就稱為 PC1600。因為 1600 > 100,滿足一切銷售需求;這聽起來更棒,縱使實際上只提升成二倍而已。12 圖 2.12:DDR2 SDRAM 的運作 為了突破這些記憶體技術,DDR2 包含少許額外的革新。能從圖 2.12 上看到的最明顯的改變是,匯流排的頻率加倍。頻率加倍意味著頻寬加倍。由於頻率加倍對記憶單元陣列來說並不經濟,因此現在需要由 I/O 緩衝區在每個時脈週期讀取四個位元,然後才送到匯流排上。這代表 DDR2 模組的改變是,只讓 DIMM 的 I/O 緩衝區元件擁有能以更快速度運轉的能力。這當然是可能的,而且不需太多能量,因為這只是個小元件,而非整個模組。廠商為 DDR2 想出的名稱與 DDR1 的名稱相似,只是在計算值的時候,乘以二變成乘以四(我們現在有條四倍頻〔quad-pumped〕匯流排)。表 2.1 顯示現今使用的模組名稱。 陣列頻率 匯流排頻率 資料速率 名稱(速率) 名稱(FSB) 133MHz 266MHz 4,256MB/s PC2-4200 DDR2-533 166MHz 333MHz 5,312MB/s PC2-5300 DDR2-667 200MHz 400MHz 6,400MB/s PC2-6400 DDR2-800 250MHz 500MHz 8,000MB/s PC2-8000 DDR2-1000 266MHz 533MHz 8,512MB/s PC2-8500 DDR2-1066 表 2.1:DDR2 模組名稱 命名上還有個彆扭之處。用於 CPU、主機板、以及 DRAM 模組的 FSB 速度是使用實質上的頻率來指定的。也就是將時脈週期的二個邊沿都納入傳輸的因素,從而浮誇數字。因此,一個擁有 266MHz 匯流排的 133MHz 模組,它的 FSB「頻率」為 533MHz。 沿著轉變到 DDR2 的路線,DDR3(真正的 DDR3,而非用於顯卡中的假 GDDR3)的規範尋求更多的改變。電壓從 DDR2 的 1.8V 降至 DDR3 的 1.5V。由於功率消耗公式是使用電壓的平方來算的,因此光這點就改善 30%。加上晶粒(die)尺寸的縮小以及其它電氣相關的改進,DDR3 能夠在相同的頻率下降低一半的功率消耗。或者,在相同功率包絡(envelope)的情況下達到更高的頻率。又或者,在維持相同熱能排放量的情況下加倍容量。 DDR3 模組的記憶單元陣列會以外部匯流排的四分之一速度運轉,其需要將 DDR2 的 4 位元 I/O 緩衝區加大到 8 位元。示意圖見圖 2.13。 圖 2.13:DDR3 SDRAM 的運作 起初,DDR3 模組的 CAS‾ \\overline{\\text{CAS}} CAS 等待時間可能會略高一些,因為 DDR2 技術更為成熟。這會導致 DDR3 只有在頻率高於 DDR2 能達到的頻率時才有用,且更甚的是通常只有在頻寬比延遲更加重要的時候才有用。已有達到與 DDR2 相同 CAS‾ \\overline{\\text{CAS}} CAS 等待時間的 1.3V 模組的風聲。無論如何,因更快的匯流排而達到更高速度的可能性將會比增加的延遲更重要。 一個 DDR3 的可能問題是,對於 1,600Mb/s 或更高的傳輸率,每個通道的模組數量可能會減至僅剩一個。在早期的版本中,對於任何頻率都有這個要求,所以可以期待在某個時間點,這項要求會被剔除。否則會嚴重地限制系統的能力。 表 2.2 列出我們很可能會看到的 DDR3 模組名稱。JEDEC 到目前為止接受前四種。鑑於 Intel 的 45nm 處理器擁有速度為 1,600Mb/s 的 FSB,1,866Mb/s 便為超頻市場所需。隨著 DDR3 的發展,我們大概會看到更多的類型。 陣列頻率 匯流排頻率 資料速率 名稱(速率) 名稱(FSB) 100MHz 400MHz 6,400MB/s PC3-6400 DDR3-800 133MHz 533MHz 8,512MB/s PC3-8500 DDR3-1066 166MHz 667MHz 10,667MB/s PC3-10667 DDR3-1333 200MHz 800MHz 12,800MB/s PC3-12800 DDR3-1600 233MHz 933MHz 14,933MB/s PC3-14900 DDR3-1866 表 2.2:DDR3 模組名稱 所有的 DDR 記憶體都有個問題:匯流排頻率的提升,會使得建立平行資料匯流排變得困難。DDR2 模組有 240 根針腳。必須要規劃所有連結到資料與位址針腳的佈線,以讓它們有大略相同的長度。還有個問題是,假如多過一個 DDR 模組被菊花鏈結(daisy-chain)在同一條匯流排上,對於每個附加的模組而言,訊號會變得越來越歪曲。DDR2 規範只允許在每個匯流排(亦稱作通道)上有二個模組,DDR3 規範在高頻時只能有一個。由於每個通道有 240 根針腳,使得單一北橋無法合理地驅動多於二個通道。替代方式是擁有外部的記憶體控制器(如圖 2.2),但這代價不小。 這所代表的是,商用主機板受限於至多持有四個 DDR2 或 DDR3 模組。這大大地限制一個系統能夠擁有的記憶體總量。即使是老舊的 32 位元 IA-32 處理器都能擁有 64GB 的 RAM,即使對於家用,記憶體需求也在持續增長,所以必須作點什麼。 一個解決辦法是將記憶體控制器加到每個處理器中,如同第二節所述。AMD 的 Opteron 系列就是這麼做的,Intel 也將以他們的 CSI 技術來達成。只要處理器所能使用的、適當的記憶體容量都能被連接到單一處理器上,這會有所幫助。在某些情況下並非如此,這個設置會引入 NUMA 架構,伴隨著其負面影響。對某些情況來說,則需要另外的解法。 Intel 針對大型伺服器機器的解法 ── 至少在現在 ── 被稱為全緩衝 DRAM (Fully Buffered DRAM,FB-DRAM)。FB-DRAM 模組使用與現今 DDR2 模組相同的記憶體晶片,這使得它的生產相對便宜。差異在連結到記憶體控制器的連線中。FB-DRAM 使用的並非平行資料匯流排,而是一條序列匯流排(也能追溯 Rambus DRAM、PATA 的後繼者 SATA 以及 PCI/AGP 的後繼者 PCI Express)。序列匯流排能以極高頻驅動、恢復序列化的負面影響,甚至提升頻寬。使用序列匯流排的主要影響為 每個通道能使用更多模組。 每個北橋/記憶體控制器能使用更多通道。 序列匯流排是被設計成全雙工的(fully-duplex)(二條線)。 實作一條差動(differential)匯流排(每個方向二條線)足夠便宜,因而能提高速度。 相比於 DDR2 的 240 根針腳,一個 FB-DRAM 模組只有 69 根針腳。由於能較為妥善地處理匯流排的電氣影響,菊花鏈結 FB-DRAM 模組要簡單許多。FB-DRAM 規範允許每通道至多 8 個 DRAM 模組。 以雙通道北橋的連線需求來比較,現在可能以更少的針腳來驅動 FB-DRAM 的六個通道:2 × 240 根針腳對比於 6 × 69 根針腳。每個通道的佈線更為簡單,這也有助於降低主機板的成本。 對於傳統 DRAM 模組來說,全雙工平行匯流排貴得嚇人,因為要將所有線路變為二倍的成本高昂。使用序列線路(即使是如 FB-DRAM 所需的差動式)情況就不同,序列匯流排因而被設計為全雙工,意味著在某些情況下,理論上光是如此頻寬就能加倍。但這並非唯一使用平行化來提升頻寬之處。由於一個 FB-DRAM 控制器能夠同時連接多達六個通道,即使對於較小 RAM 容量的系統,使用 FB-DRAM 也能夠提升頻寬。在具有四個模組的 DDR2 系統擁有二個通道的情況下,相同的能力得以使用一個普通的 FB-DRAM 控制器,經由四個通道來達成。序列匯流排的實際頻寬取決於用在 FB-DRAM 模組的 DDR2(或 DDR3) 晶片類型。 我們能像這樣總結優點: DDR2 FB-DRAM 針腳數 240 69 通道數 2 6 DIMM 數/通道數 2 8 最大記憶體13 16GB14 192GB 吞吐量15 ~10GB/s ~40GB/s 如果要在一個通道上使用多個 DIMM,FB-DRAM 有幾個缺點。在鏈結的每個 DIMM 上,訊號會被延遲 ── 儘管很小,因而增加等待時間。第二個問題是,由於非常高的頻率和驅動匯流排的需求,晶片驅動序列匯流排需要大量的能量。但有著同樣頻率、同樣記憶體容量的 FB-DRAM 總是比 DDR2 與 DDR3 還快,至多四個的 DIMM 每個都能擁有自己的通道;對於大型記憶體系統,DDR 完全沒有任何使用商用元件的解決方法。 11. 功率 = 動態電容 × 電壓2 × 頻率 ↩ 12. 我會使用二作為倍率,但我不必喜歡浮誇的數字。 ↩ 13. 假設為 4GB 模組。 ↩ 14. 一份 Intel 的簡報 ── 基於某些我不理解的原因 ── 說是 8GB... ↩ 15. 假設為 DDR2-800 模組。 ↩ "},"commodity-hardware-today/dram-access-technical-details/conclusions.html":{"url":"commodity-hardware-today/dram-access-technical-details/conclusions.html","title":"2.2.5. 結論","keywords":"","body":"2.2.5. 結論 這一節應該已經顯示出存取 DRAM 並不是一個非常快速的過程。至少與處理器執行、以及存取暫存器與快取的速度相比並不怎麼快。務必將 CPU 與記憶體頻率之間的差異放在心上。一顆以 2.933GHz 運作的 Intel Core 2 處理器以及一條 1.066GHz FSB 的時脈比率(clock ratio)為 11:1(註:1.066GHz 匯流排是四倍頻的)。每在記憶體匯流排延誤一個週期,意味著延誤處理器 11 個週期。對於大多數機器來說,實際使用的 DRAM 要更慢一些,也因此增加延遲。當我們在接下來的章節中談論延誤的時候,要把這些數字記在心中。 讀取命令的時序圖顯示 DRAM 模組的持續資料傳輸速率(sustained data rate)很高。整個 DRAM 列能夠在毫無延誤的情況下傳輸。資料匯流排能夠 100% 持續使用。對於 DDR 模組而言,這代表每個週期傳輸二個 64 位元的字組。以 DDR2-800 模組與雙通道來說,這代表速率為 12.8GB/s。 但是,除了為此而設計的情況,DRAM 並非總是循序存取的。使用不連續的記憶體區域,意味著需要預充電以及新的 RAS‾ \\overline{\\text{RAS}} RAS 訊號。這即是工作慢下來、並且 DRAM 需要協助的時候。能越早進行預充電以及發送 RAS‾ \\overline{\\text{RAS}} RAS,真的需要那列時的損失(penalty)就越小。 硬體與軟體預取(prefetch)(見 6.3 節)能夠用以創造更多時間上的重疊並減少延誤。預取也有助於及時搬移記憶體操作,以讓之後 ── 在資料真的被需要之前 ── 少點爭奪。當必須儲存這一輪所產生的資料、並且必須讀取下一輪所需的資料時,這是個常見的問題。藉由及時搬移讀取操作,就不必在基本上相同的時間發出寫入與讀取操作。 "},"commodity-hardware-today/other-main-memory-users.html":{"url":"commodity-hardware-today/other-main-memory-users.html","title":"2.3. 其它主記憶體使用者","keywords":"","body":"2.3. 其它主記憶體使用者 除了 CPU 之外,還有其它能夠存取主記憶體的系統元件。像是網路與大容量儲存裝置控制器等高效能擴充卡(card)無法負擔透過 CPU 輸送所有它所需要或提供的資料的成本。作為替代,它們會直接從/往主記憶體讀取或寫入資料(直接記憶體存取〔Direct Memory Access〕,DMA)。圖 2.1 中,我們能夠看到,擴充卡能夠透過南北橋直接與記憶體溝通。其它匯流排,像是 USB,也需要 FSB 頻寬 ── 即使它們並不使用 DMA,因為南橋也會經由北橋、透過 FSB 連接到處理器。 雖然 DMA 無疑是有益的,但它意味著有更多 FSB 頻寬的競爭。在 DMA 流量很大的時候,CPU 可能會在等待從主記憶體來的資料時,延誤得比平常更久。假如有對的硬體的話,有許多繞過這個問題的方法。藉由如圖 2.3 的架構,可以設法確保使用不受 DMA 影響的節點上的記憶體來進行運算。也可能讓一個南橋依附在每一個節點上,均等地分配 FSB 在每個節點上的負載。還有無數的可能性。在第六節,我們將會介紹可望從軟體上協助達成改進的技術與程式設計介面。 最後需要提一下,有些廉價系統的圖形系統缺乏獨立且專用的視訊 RAM(video RAM)。這些系統會使用主記憶體的一部分作為視訊 RAM。由於視訊 RAM 的存取很頻繁(對於一個 16 bpp、60Hz 的 1024x768 的顯示器而言,即為 94MB/s),而且系統記憶體 ── 不像顯示卡上的 RAM ── 並不具有二個埠,而這會嚴重地影響系統效能,尤其是等待時間。當以效能為優先時,最好是忽略這種系統。它們的問題比它們的價值還多。購買這些機器的人們知道它們無法獲得最好的效能。 "},"cpu-caches.html":{"url":"cpu-caches.html","title":"3. CPU 快取","keywords":"","body":"3. CPU 快取 比起僅僅 25 年前的 CPU,現今的 CPU 複雜得多。當時,CPU 核的頻率與記憶體匯流排在相同的等級,主記憶體存取僅略慢於暫存器存取。但這點在 1990 年代初期有所改觀,那時 CPU 設計者提升 CPU 核的頻率,但記憶體匯流排的頻率以及 RAM 晶片的效能並無等比例地成長。這不是因為無法發展出更快的 RAM,而是如前一節所解釋的。這是可能的,但並不經濟。與目前 CPU 核一樣快的 RAM,比起任何動態 RAM 都要貴上好幾個數量級。 一台有著非常小、非常快的 RAM 的機器,以及一台有著許多相對快速的 RAM 的機器,如果要在二者間擇一,在給定超過小小的 RAM 容量的工作集(working set)容量、以及存取硬碟這類次級儲存(secondary storage)媒體的成本之後,後者永遠是贏家。這裡的問題在於次級儲存裝置 –– 通常是硬碟 –– 的速度,它必須用以保存部分被移出(swap out)的工作集。存取這些硬碟甚至比 DRAM 存取要慢上好幾個數量級。 幸運的是,不必做出非全有即全無 (all-or-nothing) 的選擇。一台電腦可以有一個小容量的高速 SRAM,再加上大容量的 DRAM。一個可能的實作會是,將處理器定址空間的某塊區域劃分來容納 SRAM,剩下的則給 DRAM。作業系統的任務就會是最佳化地分配資料以善用 SRAM。基本上,在這種情境下,SRAM 是作為處理器的暫存器集的擴充來使用的。 雖然這是可能的實作,但並不可行。忽略將 SRAM 記憶體的實體資源映射到處理器的虛擬(virtual)定址空間的問題(這本身就非常難),這個方法會需要令每個行程(process)在軟體上管理記憶體區域的分配。記憶體區域的容量因處理器而異(也就是說,處理器有著不同容量的昂貴 SRAM 記憶體)。組成一支程式的每個模組都會要求它的那份快速記憶體,這會由於同步的需求而引入額外的成本。簡而言之,擁有快速記憶體的獲益將會完全被管理資源的間接成本(overhead)給吃掉。 所以,並非將 SRAM 置於作業系統或者使用者的控制之下,而是讓它變成由處理器透明地使用與管理的資源。在這種方式下,SRAM 是用以產生主記憶體中可能不久就會被處理器用到的資料的暫時副本。因為程式碼與資料具有時間(temporal)與空間局部性(spatial locality)。這表示,在短時間內,很可能會重複用到同樣的程式碼或資料。對程式碼來說,這表示非常有可能會在程式碼中循環(loop),使得相同的程式碼一次又一次地執行(空間局部性的完美例子)。資料存取在理想上也會被限定在一小塊區域中。即使在短時間內用到的記憶體並非鄰近,同樣的資料也有很高的機會在不久後再次用到(時間局部性)。對程式碼來說,代表 –– 舉例來說 –– 在一輪迴圈中會產生一次函式呼叫(function call),這個函式在記憶體中可能很遠,但呼叫這個函式在時間上則會很接近。對資料來說,代表一次使用的記憶體總量(工作集容量)理想上是有限的,但使用的記憶體 –– 由於 RAM 隨機存取的本質 –– 並不是相鄰的。理解局部性的存在是 CPU 快取概念的關鍵,因為我們至今仍在使用它們。 一個簡單的計算就能看出快取在理論上有多有效。假設存取主記憶體花費 200 個週期,而存取快取記憶體花費 15 個週期。接著,程式使用 100 個資料元素各 100 次,若是沒有快取,將會在記憶體操作上耗費 2,000,000 個循環,而若是所有資料都被快取過,只要 168,500 個週期。提升了 91.5%。 用作快取的 SRAM 容量比起主記憶體小了好幾倍。根據作者使用具有 CPU 快取的工作站(workstation)的經驗,快取的容量總是主記憶體容量的 1/1000 左右(現今:4MB 快取與 4GB 主記憶體)。單是如此並不會形成問題。假如工作集(正在處理的資料集)的容量小於快取,這無傷大雅。但是電腦沒理由不擁有大量的主記憶體。工作集必定會比快取還大。尤其是執行多個行程的系統,其工作集的容量為所有個別的行程與系統核心的容量總和。 應對快取的容量限制所需要的是,一組能在任何給定的時間點決定什麼該快取的策略。由於並非所有工作集的資料都會正好在相同的時間點使用,所以我們可以使用一些技術來暫時地將一些快取中的資料替換成別的資料。而且這也許能在真的需要資料之前就搞定。這種預取會去除一些存取主記憶體的成本,因為對程式的執行而言,這是非同步進行的。這所有的技術都能用來讓快取看起來比實際上還大。我們將會在 3.3 節討論它們。一旦探究完這全部的技術,協助處理器就是程式開發者的責任了。這些作法將會在第六節中討論。 "},"cpu-caches/cpu-caches-in-the-big-picture.html":{"url":"cpu-caches/cpu-caches-in-the-big-picture.html","title":"3.1. 概觀 CPU 快取","keywords":"","body":"3.1. 概觀 CPU 快取 在深入 CPU 快取的技術細節之前,某些讀者或許會發現,先理解快取是如何融入現代電腦系統的「大局(big picture)」是有所幫助的。 圖 3.1:最簡易的快取配置 圖 3.1 顯示了最簡易的快取配置。其與能在早期找到的、採用 CPU 快取的系統架構是一致的。CPU 核不再直接連結到主記憶體。16所有的載入與儲存都必須經過快取。CPU 核與快取之間的連線是一條特殊的、快速的連線。在這個簡化的示意圖上,主記憶體與快取都被連結到系統匯流排,其也會用來跟其它系統元件通訊。我們已經以「FSB」介紹過系統匯流排,這是它現今使用的名稱;見 2.2 節。在這一節中,我們會省略北橋;假定它存在,以方便 CPU 與主記憶體的溝通。 即便過去數十年來的大多電腦都採用馮紐曼架構(von Neumann architecture),但實驗證實分離程式碼與資料的快取是比較好的。Intel 自 1993 年起採用分離程式碼與資料的快取,就再也沒有回頭過。程式碼與資料所需的記憶體區域彼此相當獨立,這也是獨立的快取運作得更好的原因。近年來,另一個優點逐漸浮現:對大多數常見的處理器而言,指令解碼(decoding)的步驟是很慢的;快取解碼過的指令能夠讓執行加速,在不正確地預測或者無法預測的分支(branch)使得管線(pipeline)為空的情況下尤其如此。 在引入快取之後不久,系統變得越來越複雜。快取與主記憶體之間的速度差異再次增加,直到加入了另一層級的快取,比起第一層快取來得更大也更慢。僅僅提升第一層快取的容量,以經濟因素來說並非一個可行的辦法。今日,甚至有正常使用、具有三層快取的機器。具有這種處理器的系統看起來就像圖 3.2 那樣。隨著單一 CPU 中的核數增加,未來快取層級也許會變得更多。 圖 3.2:具有三層快取的處理器 圖 3.2 顯示了三層快取,並引入了我們將會在本文其餘部分使用的術語。L1d 是一階資料快取、L1i 是一階指令快取等等。注意,這只是張示意圖;實際上資料流從處理器核到主記憶體的路上並不需要通過任何較高層級的快取。CPU 設計者在快取介面的設計上有著很大的自由。對程式開發者來說,是看不到這些設計上的抉擇的。 此外,我們有多核的處理器,每個處理器核都能擁有多條「執行緒」(thread)。一個處理器核與一條執行緒的差別在於,不同的處理器核擁有(幾乎17)所有硬體資源各自的副本。除非同時用到相同的資源 –– 像是對外連線,否則處理器核是能夠完全獨立運作的。另一方面,執行緒則共享幾乎所有處理器的資源。Intel 的執行緒實作只讓其擁有個別的暫存器,甚至還是有限的 –– 某些暫存器是共享的。所以,現代 CPU 的完整架構看起來就像圖 3.3。 圖 3.3:多處理器、多核、多執行緒 在這張圖中,我們有二顆處理器,每顆二個核,各自擁有二個執行緒。執行緒共享一階快取。處理器核(以深灰色為底)擁有獨立的一階快取。所有 CPU 核共享更高層級的快取。二顆處理器(二個淺灰色為底的大方塊)自然不會共享任何快取。這些全都很重要,在我們討論快取對多行程與多執行緒應用程式的影響時尤為如此。 16. 在更早期的系統中,快取是如同 CPU 與主記憶體一樣接到系統匯流排上的。這比起實際的解法,更像是一種臨時解(hack)。 ↩ 17. 早期的多核處理器甚至有分離的第 2 階快取 (L2),且無第 3 階快取。 ↩ "},"cpu-caches/cache-operation-at-high-level.html":{"url":"cpu-caches/cache-operation-at-high-level.html","title":"3.2. 高階快取操作","keywords":"","body":"3.2. 高階快取操作 我們必須結合第二節所學到的機器架構與 RAM 技術、以及前一節所描述的快取結構,以瞭解使用快取的開銷與節約之處。 預設情況下,由 CPU 核讀取或寫入的所有資料都存在快取中。有些記憶體區域無法被快取,但只有作業系統實作者得去掛慮這點;這對應用程式開發者而言是不可見的。也有一些指令能令程式開發者刻意地繞過某些快取。這些將會在第六節中討論。 假如 CPU 需要一個資料字組,會先從快取開始搜尋。顯而易見地,快取無法容納整個主記憶體的內容(不然我們就不需要快取),但由於所有記憶體位址都能被快取,所以每個快取項目(entry)都會使用資料字組在主記憶體中的位址來標記(tag)。如此一來,讀取或寫入到某個位址的請求便會在快取中搜尋符合的標籤。在這個情境中,位址可以是虛擬或實體的,視快取的實作而有所不同。 除了真正的記憶體之外,標籤也會需要額外的空間,因此使用一個字組作為快取的粒度(granularity)是很浪費的。對於一台 x86 機器上的一個 32 位元字組而言,標籤本身可能會需要 32 位元以上。再者,由於空間局部性是作為快取基礎的其中一個原理,不將此納入考量並不太好。由於鄰近的記憶體很可能會一起被用到,所以它也應該一起被載入到快取中。也要記得我們在 2.2.1 節所學到的:假如 RAM 模組能夠在不需新的 CAS‾ \\overline{\\text{CAS}} CAS、甚至是 RAS‾ \\overline{\\text{RAS}} RAS 訊號的情況下傳輸多個資料字組,這是更有效率的。所以儲存在快取中的項目並非單一字組,而是多個連續字組的「行(line)」。在早期的快取中,這些行的長度為 32 位元組;如今一般是 64 位元組。假如記憶體匯流排的寬度是 64 位元,這表示每個快取行要傳輸 8 次。DDR 有效地支援這種傳輸方式。 當記憶體內容為處理器所需時,整個快取行都會被載入到 L1d 中。每個快取行的記憶體位址會根據快取行的容量,以遮罩(mask)位址值的方式來計算。對於一個 64 位元組的快取行來說,這表示低 6 位元為零。捨棄的位元則用作快取行內的偏移量(offset)譯註。剩餘的位元在某些情況下用以定位快取中的行、以及作為標籤。在實務上,一個位址值會被切成三個部分。對於一個 32 位元的位址來說,這看來如下: 一個容量為 2O \\mathbf{O} O 的快取行,低 O \\mathbf{O} O 位元用作快取行內的偏移量。接下來的 S \\mathbf{S} S 位元選擇「快取集(cache set)」。我們馬上就會深入更多為何快取行會使用集合 –– 而非一個一組(single slot)–– 的細節。現在只要知道有 2S \\mathbf{S} S 個快取行的集合就夠。剩下的 32−S−O=T 32 - \\mathbf{S} - \\mathbf{O} = \\mathbf{T} 32−S−O=T 位元組成標籤。這 T \\mathbf{T} T 個位元是與每個快取行相關聯、以區分在同一快取集中所有別名(alias)18的值。不必儲存用以定址快取集的 S \\mathbf{S} S 位元,因為它們對同個集合中的所有快取行而言都是相同的。 當一個指令修改記憶體時,處理器依舊得先載入一個快取行,因為沒有指令能夠一次修改一整個快取行(這個規則有個例外:合併寫入〔write-combining〕,會在 6.1 節說明)。因此在寫入操作之前,得先載入快取行的內容。快取無法持有不完全的快取行。已被寫入、並且仍未寫回主記憶體的快取行被稱為「髒的(dirty)」。一旦將其寫入,髒旗標(dirty flag)便會被清除。 為了能夠在快取中載入新的資料,幾乎總是得先在快取中騰出空間。從 L1d 的逐出操作(eviction)會將快取行往下推入 L2(使用相同的快取行容量)。這自然代表 L2 也得騰出空間。這可能轉而將內容推入 L3,最終到主記憶體中。每次逐出操作都會越來越昂貴。這裡所描述的是現代 AMD 與 VIA 處理器所優先採用的獨占式快取(exclusive cache)模型。Intel 實作包含式快取(inclusive caches)19,其中每個在 L1d 中的快取行也會存在 L2 中。因此,從 L1d 進行逐出操作是更為快速的。有了足夠的 L2 快取的話,將內容存在二處而造成記憶體浪費的缺點是很小的,而這在逐出操作時會帶來回報。獨占式快取的一個可能的優點是,載入一個新的快取行只需碰到 L1d 而不需 L2,這會快上一些。 只要為了處理器架構而規定的記憶體模型沒有改變,CPU 是被允許以它們想要的方式來管理快取的。舉例來說,善用少量或沒有記憶體匯流排活動的時段,並主動地將髒的快取行寫回到主記憶體中,對處理器來說是非常好的。x86 與 x86-64 –– 不同廠商、甚至是同一廠商的不同型號之間 –– 的處理器之間有著各式各樣的快取架構,證明記憶體模型抽象化的能力。 在對稱式多處理器(Symmetric Multi-Processor,SMP)系統中,CPU 的快取無法獨立於彼此運作。所有處理器在任何時間都假定要看到相同的記憶體內容。這種記憶體一致觀點的維持被稱為「快取一致性(cache coherency)」。假如一個處理器只看它自己擁有的快取與主記憶體,它就不會看到其它處理器中的髒快取行的內容。提供從一個處理器到另一個處理器快取的直接存取會非常昂貴,而且是個極大的瓶頸。取而代之地,處理器會在另一個處理器要讀取或寫入到某個快取行時察覺到。 假如偵測到一次寫入存取,並且處理器在其快取中有這個快取行的乾淨副本,這個快取行就會被標為無效(invalid)。未來的查詢會需要重新載入這個快取行。注意到在另一顆 CPU 上的讀取存取並不需要進行無效化,多個乾淨副本能夠被保存得很好。 更加複雜的快取實作容許其它的可能性發生。假設在一個處理器快取中的一個快取行是髒的,並且第二個處理器想要讀取或寫入這個快取行。在這個情況下,主記憶體的內容太舊,而請求的處理器必須 –– 作為替代 –– 從第一個處理器取得快取行的內容。第一個處理器經由窺探注意到這個狀況,並自動地將資料寄送給請求的處理器。這個動作繞過主記憶體,雖然在某些實作中,是假定記憶體控制器會注意到這個直接傳輸、並將更新的快取行內容儲存到主記憶體中。假如是為了寫入而進行存取,第一個處理器便會將它的區域快取行的副本無效化。 許多快取一致化的協定隨著時間被逐漸發展出來。最重要的為 MESI,我們將會 3.3.4 節中介紹它。這所有的結果可以被總結為一些簡單的規則: 一個髒的快取行不會出現在任何其它處理器的快取中。 相同快取行的乾淨副本能夠存在任意數量的快取中。 假如能夠維持這些規則,即便在多處理器的系統中,處理器也能夠高效地使用它們的快取。所有處理器所需要做的,就是去監控其它處理器的寫入存取,並將這個位址與它們區域快取中的位址做比較。在下一節,我們將會深入更多實作、尤其是成本的一些細節。 最後,我們該至少給個快取命中(hit)與錯失(miss)相關成本的印象。這些是 Intel 針對 Pentium M 列出的數字: 到 週期 暫存器 L1d ~3 L2 ~14 主記憶體 ~240 這些是以 CPU 週期測量的實際存取時間。有趣的是,對內建於晶片上的 L2 快取而言,大部分(甚至可能超過一半)的存取時間都是由線路延遲造成的。這是一個只會隨著快取容量變大而變糟的實體限制。只有製程的縮小(舉例來說,從 Intel 系列中 Merom 的 60nm 到 Penryn 的 45nm)能提升這些數字。 表格中的數字看起來很大,但 –– 幸運地 –– 不必在每次發生快取載入與錯失時都負擔全部的成本。一部分的成本可以被隱藏。現今的處理器全都會使用不同長度的內部管線,指令會在其中被解碼、並且為執行而準備。部份的準備是從記憶體(或快取)載入值,假如它們要被傳輸到暫存器的話。假如記憶體載入操作能夠足夠早就在管線中開始,它也許會與其它操作平行進行,而整個載入成本就可能被隱藏。這對 L1d 經常是可能的;對某些有著長管線的處理器來說,L2 亦是如此。 提早開始記憶體讀取有著諸多阻礙。也許簡單得像是沒有足夠的資源來存取記憶體,或者可能是載入的最終位址之後才會作為另一個指令的結果取得。在這些情況中,載入成本無法被(完全地)隱藏。 對於寫入操作,CPU 不必一直等到值被安然地儲存進記憶體中為止。只要接下來指令的執行就像是與值已被存入記憶體有著似乎相同的效果,就沒有什麼能阻止 CPU 走捷徑。它能夠早點開始執行下個指令。有著影子暫存器(shadow register)–– 其能夠持有一般暫存器無法取得的值 –– 的幫助,甚至可能改變未完成的寫入操作所要儲存的值。 圖 3.4:隨機寫入的存取時間 有關快取行為影響的圖表,見圖 3.4。我們稍候會談到產生資料的程式;這是個不停地以隨機的方式存取可控制記憶體總量的程式的簡易模擬。每筆資料有著固定的容量。元素的數量視選擇的工作集容量而定。Y 軸表示處理一個元素所花費的 CPU 週期的平均;注意到 Y 軸為對數刻度。這同樣適用於所有這類圖表的 X 軸。工作集的容量總是以二的冪表示。 這張圖顯示三個不同的平穩階段。這並不讓人意外:這個處理器有 L1d 與 L2 快取,但沒有 L3。經由一些經驗,我們可以推論這個 L1d 容量為 213 位元組,而 L2 容量為 220 位元組。假如整個工作集能塞進 L1d 中,對每個元素的每次操作的週期數會低於 10。一旦超過 L1d 的容量,處理器就必須從 L2 載入資料,而平均時間則迅速成長到 28 左右。一旦 L2 也不夠大,時間便飆升到 480 個週期以上。這即是許多、或者大部分操作必須從主記憶體載入資料的時候。更糟的是:由於資料被修改,髒的快取行也必須被寫回。 這張圖應該有給予探究程式撰寫上的改進、以協助提升快取使用方式的充分動機。我們在這裡所談論的並不是幾個少得可憐的百分點;我們說的是有時可能的幾個數量級的提升。在第六節,我們將會討論能讓我們寫出更有效率的程式的技術。下一節會深入更多 CPU 快取設計的細節。有這些知識很好,但對於本文其餘部分並非必要。所以這一節可以跳過。 譯註. 用來作為快取行內某個字組的索引。 ↩ 18. 所有位址有著相同 S \\mathbf{S} S 部分的快取行都被視為相同的別名。 ↩ 19. 這個概括並不完全正確。一些快取是獨占式的,而部分包含式快取具有獨占式快取的特性。 ↩ "},"cpu-caches/cpu-cache-implementation-details.html":{"url":"cpu-caches/cpu-cache-implementation-details.html","title":"3.3. CPU 快取實作細節","keywords":"","body":"3.3. CPU 快取實作細節 快取實作者有個麻煩是,在龐大的主記憶體中,每個記憶單元都可能得被快取。假如一支程式的工作集足夠大,這表示有許多為了快取中的各個地方打架的主記憶體位置。先前曾經提過,快取與主記憶體容量的比率為 1 比 1000 的情況並不罕見。 "},"cpu-caches/cpu-cache-implementation-details/associativity.html":{"url":"cpu-caches/cpu-cache-implementation-details/associativity.html","title":"3.3.1. 關聯度","keywords":"","body":"3.3.1. 關聯度 實作一個每個快取行都能保存任意記憶體位置副本的快取是有可能的(見圖 3.5)。這被稱為一個全關聯式快取(fully associative cache)。要存取一個快取行,處理器核必須要將每個快取行的標籤與請求位址的標籤進行比較。標籤會由位址中不是快取行偏移量的整個部分組成(這表示在 3.2 節圖示中的 S \\mathbf{S} S 為零)。 有些快取是像這樣實作的,但是看看現今使用的 L2 數量,證明這是不切實際的。給定一個有著 64B 快取行的 4MB 快取,這個快取將會有 65,536 個項目。為了達到足夠的效能,快取邏輯必須要能夠在短短幾個週期內,從這所有的項目中挑出符合給定標籤的那一個。實作這點要付出龐大的精力。 圖 3.5:全關聯式快取示意圖 對每個快取行來說,都需要一個比較器(comparator)來比對很大的標籤(注意,S \\mathbf{S} S 為零)。緊鄰著每條連線的字母代表以位元為單位的寬度。假如沒有給定,那麼它就是一條單一位元的線路。每個比較器都必須比對二個 T \\mathbf{T} T 位元寬的值。接著,基於這個結果,選擇合適的快取行內容,並令它能被取得。有多少快取行,都得合併多少組 O \\mathbf{O} O 資料線。實作一個比較器所需的電晶體數量很大,特別是它必須運作地非常快的時候。疊代比較器(iterative comparator)是不可用的。節省比較器數量的唯一方式,就是反覆地比較標籤以減少比較器的數量。這與疊代比較器並不合適的理由相同:它太花時間了。 全關聯式快取對小快取(例如在某些 Intel 處理器的 TLB 快取就是全關聯式的)來說是有實用價值的,但那些快取都很小,非常小。我們所指的是至多只有幾十個項目的情況。 對 L1i、L1d、以及更高層級的快取來說,需要採用不同的方法。我們所能做的是限縮搜尋。在最極端的限制中,每個標籤都恰好對映到一個快取項目。計算方式很簡單:給定 4MB/64B、有著 65,536 個項目的快取,我們能夠直接使用位址的 6 到 21 位元(16 個位元)來直接定址每個項目。低 6 位元是快取行內部的索引。 圖 3.6:直接對映式快取示意圖 如圖 3.6 所見到的,這種直接對映式快取(direct-mapped cache)很快,而且實作起來相對簡單。它需要一個比較器、一個多工器(在這張示意圖中有二個,標籤與資料是分離的,但在這個設計上,這點並不是個硬性要求)、以及一些用以選擇有效快取行內容的邏輯。比較器是因速度要求而複雜,但現在只有一個;因此,便能夠花費更多的精力來讓它變快。在這個方法中,實際的複雜之處都落在多工器上。在一個簡易的多工器上,電晶體的數量以 O(logN) O(\\log N) O(logN) 成長,其中 N N N 為快取行的數量。這能夠容忍,但可能會慢了點,在這種情況下,藉由在多工器中增加更多的電晶體以平行化某些工作,便能夠提升速度。電晶體的總數能夠緩慢地隨著快取容量的成長而成長,使得這種解法非常有吸引力。但它有個缺點:只有在程式用到的位址,對於用以直接映射的位元來說是均勻分佈的情況下,它才能運作得很好。若非如此,而且經常這樣的話,某些快取項目會因為頻繁地使用而被重複地逐出,而其餘的項目則幾乎完全沒用到、或者一直是空的。 圖 3.7:集合關聯式快取示意圖 這個問題能藉由讓快取集合關聯(set associative)來解決。一個集合關聯式快取結合了全關聯式以及直接對映式快取的良好特質,以在很大程度上避免了那些設計的弱點。圖 3.7 顯示了一個集合關聯式快取的設計。標籤與資料的儲存被分成集合,其中之一會被快取行的位址所選擇。這與直接對映式快取相似。但少數的值能以相同的集合編號快取,而非令快取中的每個集合編號都只有一個元素。所有集合內成員的標籤會平行地比對,這與全關聯式快取的運作方式相似。 結果是,快取不容易被不幸地 –– 或者蓄意地 –– 以相同集合編號的位址選擇所擊敗,同時快取的容量也不會受限於能被經濟地實作的比較器的數量。假如快取增長,它(在這張圖中)只有行數會增加,列數則否。行數(以及比較器)只會在快取的關聯度(associativity)增加的時候才會增加。現今的處理器為 L2 或者更高層級的快取所使用的關聯度層級高達 24。L1 快取通常使用 8 個集合。 給定我們的 4MB/64B 快取以及 8 路(8-way)集合關聯度,於是這個快取便擁有 8,192 個集合,並且僅有 13 位元的標籤被用於定址快取集。要決定快取集中的哪個(如果有的話)項目包含被定址的快取行,必須要比較 8 個標籤。在非常短的時間內做到如此是可行的。藉由實驗我們能夠看到,這是合理的。 L2 快取容量 關聯度 直接 2 4 8 CL=32 CL=64 CL=32 CL=64 CL=32 CL=64 CL=32 CL=64 512k 27,794,595 20,422,527 25,222,611 18,303,581 24,096,510 17,356,121 23,666,929 17,029,334 1M 19,007,315 13,903,854 16,566,738 12,127,174 15,537,500 11,436,705 15,162,895 11,233,896 2M 12,230,962 8,801,403 9,081,881 6,491,011 7,878,601 5,675,181 7,391,389 5,382,064 4M 7,749,986 5,427,836 4,736,187 3,159,507 3,788,122 2,418,898 3,430,713 2,125,103 8M 4,731,904 3,209,693 2,690,498 1,602,957 2,207,655 1,228,190 2,111,075 1,155,847 16M 2,620,587 1,528,592 1,958,293 1,089,580 1,704,878 883,530 1,671,541 862,324 表 3.1:快取容量、關聯度、以及快取行容量的影響 表 3.1 顯示了對於一支程式(在這個例子中是 gcc,根據 Linux 系統核心的人們的說法,它是所有基準中最重要的一個)在改變快取容量、快取行容量、以及關聯度集合容量時,L2 快取錯失的次數。在 7.2 節中,我們將會介紹對於這個測試,所需要用以模擬快取的工具。 以防這些值的關聯仍不明顯,這所有的值的關係是,快取的容量為 快取行容量×關聯度×集合的數量 \\text{快取行容量} \\times \\text{關聯度} \\times \\text{集合的數量} 快取行容量×關聯度×集合的數量 位址是以 3.2 節的圖中示意的方式,使用 O=log2快取行容量S=log2集合的數量 \\begin{aligned} \\mathbf{O} &= \\log_{2} \\text{快取行容量} \\\\ \\mathbf{S} &= \\log_{2} \\text{集合的數量} \\end{aligned} OS=log2快取行容量=log2集合的數量 來對映到快取中的。 圖 3.8:快取容量 vs 關聯度(CL=32) 圖 3.8 讓這個表格的數據更容易理解。它顯示了快取行容量固定為 32 位元組的數據。看看對於給定快取容量的數字,我們可以發現關聯度確實有助於顯著地降低快取錯失的次數。以一個 8MB 快取來說,從直接對映式變成 2 路集合關聯式避免了幾乎 44% 的快取錯失。相比於一個直接對應式快取,使用一個集合關聯式快取的話,處理器能夠在快取中保存更多的工作集。 在文獻中,偶爾會讀到引入關聯度與加倍快取容量有著相同的影響。在某些極端的例子中,如同能夠從 4MB 跳到 8MB 快取所看到的,確實如此。但再一次加倍關聯度的話,顯然就不是如此了。如同我們能從數據中所看到的,接下來的提升要小得多。不過,我們不該完全低估這個影響。在範例程式中,記憶體使用的尖峰為 5.6M。所以使用一個 8MB 快取,同樣的快取集不大可能被多次(超過二次)使用。有個較大的工作集的話,能夠節約的更多。如同我們能夠看到的,對於較小的快取容量來說,關聯度的獲益較大。 一般來說,將一個快取的關聯度提升到 8 以上,似乎對一個單執行緒的工作量來說只有很小的影響。隨著共享第一層快取的 HT 處理器、以及使用一個共享 L2 快取的多核處理器的引入,形勢轉變了。現在你基本上會有二支程式命中相同的快取,這導致關聯度會在實務上打對折(對四核處理器來說是四分之一)。所以能夠預期,提升處理器核的數量,共享快取的關聯度也應該成長。一旦這不再可能(16 路集合關聯度已經很難了),處理器設計師就必須開始使用共享的 L3 或者更高層級的快取,而 L2 快取則是潛在地由處理器核的子集所共享。 我們能在圖 3.8 學到的另一個影響是,增加快取容量是如何提升效能的。這個數據無法在不知道工作集容量的情況下解釋。顯然地,一個與主記憶體一樣大的快取,會導致比一個較小快取更好的結果,所以一般來說不會有帶著可預見優勢的最大快取容量的限制。 如同上面所提到的,工作集容量的尖峰為 5.6M。這並沒有給我們任何最佳快取容量的確切數字,但它能讓我們估算出這個數字。問題是,並非所有被用到的記憶體都是連續的,因此我們會有 –– 即使是以一個 16M 的快取與一個 5.6M 的工作集 –– 衝突(conflict)譯註(看看 2 路集合關聯式的 16MB 快取相較於直接對映版本的優勢)。但有把握的是,以同樣的工作量,一個 32MB 快取的獲益是可以忽略不計的。但誰說過工作集容量必須維持不變了?工作量是隨著時間成長的,快取容量也應該如此。在購買機器、並且在你得去挑選願意為此買單的快取容量時,是值得去衡量工作集容量的。在圖 3.10 中能夠看到這件事何以重要。 圖 3.9:測試記憶體佈局 執行了二種類型的測試。在第一個測試中,元素是循序處理的。測試程式沿著指標(pointer)n 前進,但陣列元素會以令它們以在記憶體中排列的順序被巡訪的方式鏈結。這能夠在圖 3.9 的下半部看到。有個來自最後一個元素的回溯參考。在第二個測試中(圖中的上半部),陣列元素是以隨機順序巡訪的。在這二種情況中,陣列元素都會形成一個循環的單向鏈結串列 (singly-linked list)。 譯註. 這裡指的是上文描述直接對映式快取時所提到的缺點。 ↩ "},"cpu-caches/cpu-cache-implementation-details/measurements-of-cache-effects.html":{"url":"cpu-caches/cpu-cache-implementation-details/measurements-of-cache-effects.html","title":"3.3.2. 快取影響的量測","keywords":"","body":"3.3.2. 快取影響的量測 所有的圖表都是由一支能模擬任意容量的工作集、讀取與寫入存取、以及循序或隨機存取的程式所產生的。我們已經在圖 3.4 中看過一些結果。這支程式會產生與工作集容量相同、這種型別的陣列: struct l { struct l *n; long int pad[NPAD]; }; 所有的項目都使用 n 元素,以循序或是隨機的順序,鏈結在一個循環的串列中。即使元素是循序排列的,從一個項目前進到下一個項目總是會用到這個指標。pad 元素為資料負載(payload),並且能成長為任意容量。在某些測試中,資料會被修改,而在其餘的情況中,程式只會執行讀取操作。 在效能量測中,我們討論的是工作集的容量。工作集是由一個 struct l 元素的陣列所組成的。一個 2N 位元組的工作集包含 2N / sizeof(struct l) 個元素。顯而易見地,sizeof(struct l) 視 NPAD 的值而定。以 32 位元的系統來說,NPAD=7 代表每個陣列元素的容量為 32 位元組,以 64 位元的系統來說,容量為 64 位元組。 單執行緒循序存取 圖 3.10:循序讀取存取,NPAD=0 最簡單的情況就是直接走遍串列中的所有項目。串列元素是循序排列、緊密地塞在一起的。不管處理的順序是正向或反向都無所謂,處理器在二個方向上都能處理得一樣好。我們這裡 –– 以及在接下來的所有測試中 –– 所要量測的是,處理一個單向串列元素要花多久。時間單位為處理器週期。圖 3.10 顯示了這個結果。除非有另外說明,否則所有的量測都是在一台 Pentium 4 以 64 位元模式獲得的,這表示 NPAD=0 的結構 l 容量為八位元組。 前二個量測結果受到了雜訊的污染。測量的工作量太小了,因而無法過濾掉其餘系統的影響。我們能夠放心地假設這些值都在 4 個週期左右。考慮到這點,我們能夠看到三個不同的水平(level): 工作集容量至多到 214 位元組。 從 215 位元組到 220 位元組。 221 位元組以上。 這些階段能夠輕易地解讀:處理器擁有一個 16kB L1d 與 1MB L2。我們沒有在從一個水平到另一個水平的轉變之處看到尖銳的邊緣,因為快取也會被系統的其它部分用到,因此快取並不是專門給這支程式的資料所使用的。特別是 L2 快取,它是一個統一式快取(unified cache),也會被用來存放指令(註:Intel 使用包含式快取)。 或許完全沒有預期到的是,對於不同工作集容量的實際時間。L1d 命中的時間是預期中的:在 P4 上,L1d 命中之後的載入時間大約是 4 個週期。但 L2 存取怎麼樣呢?一旦 L1d 不足以保存資料,可以預期這會讓每個元素花上 14 個週期以上,因為這是 L2 的存取時間。但結果顯示只需要大約 9 個週期。這個差異能夠以處理器中的先進邏輯來解釋。預期使用連續的記憶體區域時,處理器會預取下一個快取行。這表示,當真的用到下個快取行時,它已經載入一半了。等待下一個快取行載入所需的延遲因而比 L2 存取時間要少得多。 一旦工作集容量成長到超過 L2 的容量,預取的效果甚至更明顯。先前我們說過,一次主記憶體存取要花費 200+ 個週期。只有利用有效的預取,處理器才可能讓存取時間維持在低至 9 個週期。如同我們能從 200 與 9 之間的差異所看到的,它的效果很好。 圖 3.11:循序讀取多種容量 我們能夠在預取的時候 –– 至少間接地 –– 觀察處理器。在圖 3.11 中,我們看到的是相同工作集容量的時間,但這次我們看到的是不同 l 結構容量的曲線。這表示在串列中有比較少、但比較大的元素。不同容量有著令(仍然連續的)串列中的 n 元素之間的距離成長的影響。在圖中的四種情況,距離分別為 0、56、120、248 位元組。 在底部我們可以看到圖 3.10 的線,但這時它看起來差不多像是條平坦的線。其它情況的時間要糟得多了。我們也能在這張圖中看到三個不同的水平,我們也看到在工作集容量很小的情況下有著很大的誤差(再次忽略它們)。只要僅有 L1d 牽涉其中,這些線差不多都相互重合。因為不需要預取,所以每次存取時,所有元素大小都直接命中 L1d。 在 L2 快取命中的情況下,我們看到三條新的線相互重合得很好,但它們位在比較高的水平上(大約 28)。這是 L2 存取時間的水平。這表示從 L2 到 L1d 的預取基本上失效了。即使是 NPAD=7,我們在迴圈的每一次疊代都需要一個新的快取行;以 NPAD=0 而言,在需要下一個快取行之前,迴圈得疊代八次。預取邏輯無法每個週期都載入一個新的快取行。因此,我們看到的便是在每次疊代時,從 L2 載入的延誤。 一旦工作集容量超過 L2 的容量,甚至變得更有趣了。現在四條線全都離得很遠。不同的元素容量顯然在效能差異上扮演著一個重大的角色。處理器應該要識別出步伐(stride)的容量,不為 NPAD=15 與 31 獲取不必要的快取行,因為元素的容量是比預取窗(prefetch window)還小的(見 6.3.1 節)。元素容量妨礙預取效果之處,是一個硬體預取限制的結果:它無法橫跨分頁(page)邊界。我們在每次增加容量時,都減少了 50% 硬體排程器(scheduler)的效率。假如硬體預取器(prefetcher)被允許橫跨分頁邊界,並且下一個分頁不存在或者無效時,作業系統就得被捲入分頁的定位中。這表示程式要經歷並非由它自己產生的分頁錯誤(page fault)。這是完全無法接受的,因為處理器並不知道一個分頁是不在記憶體內還是不存在。在後者的情況下,作業系統必須要中斷行程。在任何情況下,假定 –– 以 NPAD=7 或以上而言 –– 每個串列元素都需要一個快取行,硬體預取器便愛莫能助了。由於處理器一直忙著讀取一個字組、然後載入下一個元素,根本沒有時間去從記憶體載入資料。 變慢的另一個主要原因是 TLB 快取的錯失。這是一個儲存了從虛擬位址到實體位址的轉譯結果的快取,如同在第四節所詳細解釋的那樣。由於 TLB 快取必須非常地快,所以它非常地小。假如重複存取的分頁數比 TLB 快取擁有的還多,就必須不斷地重算代表著虛擬到實體位址的轉譯結果的項目。這是一個非常昂貴的操作。對比較大的元素容量而言,一次 TLB 查詢的成本是分攤在較少的元素上的。這表示對於每個串列元素,必須要計算的 TLB 項目總數較多。 為了觀察 TLB 的影響,我們可以執行一個不同的測試。對於第一個量測,我們像往常一樣循序地擺放元素。我們使用 NPAD=7 作為佔據一整個快取行的元素。對於第二個量測,我們將每個串列元素放置在個別的分頁中。每個分頁的其餘部分維持原樣,我們不會將它算在工作集容量的總和中。20結果是,對於第一個量測,每次串列疊代都需要一個新的快取行,並且每 64 個元素一個新的分頁。對第二個量測而言,每次疊代都需要載入一個在另一個分頁上的快取行。 圖 3.12:TLB 對循序讀取的影響 結果可以在圖 3.12 中看到。量測都是在與圖 3.11 相同的機器上執行的。由於可用 RAM 的限制,工作集容量必須限制在 224 位元組,其需要 1GB 以將物件放置在個別的分頁上。下方的紅色曲線正好對應到圖 3.11 中的 NPAD=7 曲線。我們看到了顯示了 L1d 與 L2 快取容量的不同階段。第二條曲線看起來完全不同。重要的特徵是,當工作集容量達到 213 位元組時開始的大幅飆升。這即是 TLB 快取溢出(overflow)的時候了。由於一個元素容量為 64 位元組,我們能夠計算出 TLB 快取有 64 個項目。由於程式鎖定了記憶體以避免它被移出,所以成本不會受分頁錯誤影響。 可以看出,計算實體位址、並將它儲存在 TLB 中所花的週期數非常高。圖 3.12 中的曲線顯示了極端的例子,但現在應該能清楚的一點是,對於較大的 NPAD 值而言,一個變慢的重大因素即是 TLB 快取效率的降低。由於實體位址必須要在快取行能從 L2 或主記憶體讀取前算出來,因此位址轉譯的損失就被附加到了記憶體存取時間上。這在某種程度上解釋了,為何每個串列元素在 NPAD=31 的總成本會比 RAM 在理論上的存取時間還高的原因。 圖 3.13:循序讀取與寫入,NPAD=1 我們可以透過觀察修改串列元素的測試執行的數據,來一瞥預取實作的多一些細節。圖 3.13 顯示了三條線。在所有情況中的元素寬度都是 16 位元組。第一條線是現在已經很熟悉的串列巡訪,它會被當作一條基準線。第二條線 –– 標為「Inc」–– 僅會在前往下一個元素前,增加目前元素的 pad[0] 成員的值。第三條線 –– 標為「Addnext0」–– 會取下一個元素的 pad[0] 的值,並加到目前串列元素的 pad[0] 成員中。 天真的假設大概是「Addnext0」測試跑得比較慢,因為它有更多工作得做。在前進到下一個串列元素之前,就必須載入這個元素的值。這即是看到這個測試實際上 –– 對於某些工作集容量而言 –– 比「Inc」測試還快這點會令人吃驚的原因了。對此的解釋是,載入下個串列元素基本上就是一次強制的預取。無論程式在何時前進到下個串列元素,我們都確切地知道這個元素已經在 L1d 快取中了。因此我們看到,只要工作集容量能塞進 L2 快取,「Addnext0」就執行得跟單純的「Follow」一樣好。 不過「Addnext0」測試比「Inc」測試更快耗盡 L2。因為它需要從主記憶體載入更多的資料。這即是在工作集容量為 221 位元組時,「Addnext0」測試達到 28 個循環水平的原因了。28 循環水平是「Follow」測試所達到的 14 循環水平的二倍高。這也很容易解釋。由於其它二個測試都修改了記憶體,L2 快取為了騰出空間給新的快取行的逐出操作便不能直接把資料丟掉。它必須被寫到記憶體中。這表示 FSB 中的可用頻寬被砍了一半,因此加倍了資料從主記憶體傳輸到 L2 所花的時間。 圖 3.14:較大 L2/L3 快取的優勢 循序、高效的快取管理的最後一個面向是快取的容量。雖然這應該很明顯,但仍需要被提出來。圖 3.14 顯示了以 128 位元組元素(在 64 位元機器上,NPAD=15)進行 Increment 測試的時間。這次我們看到量測結果來自三台不同的機器。前二台機器為 P4,最後一台為 Core2 處理器。前二台由不同的快取容量來區分它們自己。第一個處理器有一個 32k L1d 與一個 1M L2。第二個處理器有 16k L1d、512k L2、與 2M L3。Core2 處理器有 32k L1d 與 4M L2。 這張圖有趣的部分不必然是 Core2 處理器相對於其它二個表現得有多好(雖然這令人印象深刻)。這裡主要有興趣的地方是,工作集容量對於各自的最後一階快取來說太大、並使得主記憶體得大大地涉入其中之處。 如同預期,最後一階的快取越大,曲線在相應於 L2 存取成本的低水平停留得越久。要注意的重要部分是它所提供的效能優勢。第二個處理器(它稍微舊了一點)在 220 位元組的工作集上能夠以二倍於第一個處理器的速度執行。這全都歸功於最後一階快取容量的提升。有著 4M L2 的 Core2 處理器甚至表現得更好。 對於隨機的工作量而言,這可能不代表什麼。但若是工作量能被裁剪成最後一階快取的容量,程式效能便能夠極為大幅地提升。這也是有時候值得為擁有較大快取的處理器花費額外金錢的原因。 單執行緒隨機存取 我們已經看過,處理器能夠藉由預取快取行到 L2 與 L1d,來隱藏大部分主記憶體、甚至是 L2 的存取等待時間。不過,這只有在能夠預測記憶體的存取時才能良好運作。 圖 3.15:循序 vs 隨機讀取,NPAD=0 若是存取模式是不可預測、或者隨機的,情況便大大地不同。圖 3.15 比較了循序存取每個串列元素的時間(如圖 3.10)以及當串列元素是隨機分布在工作集時的時間。順序是由隨機化的鏈結串列所決定的。沒有讓處理器能夠確實地預取資料的方法。只有一個元素偶然在另一個在記憶體中也彼此鄰近的元素不久之後用到,這才能起得了作用。 在圖 3.15 中,有二個要注意的重點。第一點是,增長工作集容量需要大量的週期數。機器能夠在 200-300 個週期內存取主記憶體,但這裡我們達到了 450 個週期以上。我們先前已經看過這個現象了(對比圖 3.11)。自動預取在這裡實際上起了反效果。 圖 3.16:L2d 錯失率 第二個有趣的地方是,曲線並不像在循序存取的例子中那樣,在多個平緩階段變得平坦。曲線持續上升。為了解釋這點,我們能夠針對不同的工作集容量量測程式的 L2 存取次數。結果能夠在圖 3.16 與表 3.2 看到。 圖表顯示,當工作集容量大於 L2 的容量時,快取錯失率(L2 存取數 / L2 錯失數)就開始成長了。這條曲線與圖 3.15 的曲線有著相似的形式:它快速地上升、略微下降、然後再度開始上升。這與每串列元素所需循環數的曲線圖有著密切的關聯。L2 錯失率最終會一直成長到接近 100% 為止。給定一個足夠大的工作集(以及 RAM),任何隨機選取的快取行在 L2 或是載入過程中的機率便能夠被隨心所欲地降低。 集合容量 循序 隨機 L2 命中數 L2 錯失數 疊代次數 錯失/命中比率 每疊代 L2 存取數 L2 命中數 L2 錯失數 疊代次數 錯失/命中比率 每疊代 L2 存取數 220 88,636 843 16,384 0.94% 5.5 30,462 4721 1,024 13.42% 34.4 221 88,105 1,584 8,192 1.77% 10.9 21,817 15,151 512 40.98% 72.2 222 88,106 1,600 4,096 1.78% 21.9 22,258 22,285 256 50.03% 174.0 223 88,104 1,614 2,048 1.80% 43.8 27,521 26,274 128 48.84% 420.3 224 88,114 1,655 1,024 1.84% 87.7 33,166 29,115 64 46.75% 973.1 225 88,112 1,730 512 1.93% 175.5 39,858 32,360 32 44.81% 2,256.8 226 88,112 1,906 256 2.12% 351.6 48,539 38,151 16 44.01% 5,418.1 227 88,114 2,244 128 2.48% 705.9 62,423 52,049 8 45.47% 14,309.0 228 88,120 2,939 64 3.23% 1,422.8 81,906 87,167 4 51.56% 42,268.3 229 88,137 4,318 32 4.67% 2,889.2 119,079 163,398 2 57.84% 141,238.5 表 3.2:循序與隨機巡訪時的 L2 命中與錯失,NPAD=0 光是快取錯失率的提高就能夠解釋一部分成本。但有著另一個因素。看看表 3.2,我們能夠看到在 L2 / 疊代數那欄,程式每次疊代所使用的 L2 總數都在成長。每個工作集都是前一個的二倍大。所以,在沒有快取的情況下,我們預期主記憶體的存取次數會加倍。有了快取以及(幾乎)完美的可預測性,我們看到顯示在循序存取的數據中,L2 使用次數增長得很保守。其增長除了工作集容量的增加以外,就沒有別的原因了。 圖 3.17:逐頁(page-wise)隨機化,NPAD=7 對於隨機存取,每次工作集容量加倍的時候,每個元素的存取時間都超過二倍。這表示每個串列元素的平均存取時間增加了,因為工作集容量只有變成二倍而已。背後的原因是 TLB 錯失率提高了。在圖 3.17 中,我們看到在 NPAD=7 時隨機存取的成本。只是這次,隨機化的方式被修改了。一般的情況下,是將整個串列作為一個區塊(block)隨機化(以標籤〔label〕 ∞ \\infty ∞ 表示),而其它的 11 條曲線則表示在比較小的區塊內進行隨機化。標記為「60」的曲線,代表每組由 60 個分頁(245,760 位元組)組成的集合會分別進行隨機化。這表示在走到下一個區塊的元素之前,會先巡訪過所有區塊內的串列元素。這使得在任何一個時間點使用的 TLB 項目的數量有所限制。 在 NPAD=7 時的元素容量為 64 位元組,這與快取行容量一致。由於串列元素的順序被隨機化了,因此硬體預取器不大可能有任何效果,尤其在有一堆元素的情況下。這表示 L2 快取的錯失率與在一個區塊內的整個串列隨機化相比並不會有顯著地不同。測試的效能隨著區塊容量增加而逐漸地逼近單一區塊隨機化的曲線。這表示後者的測試案例的效能顯著地受到了 TLB 錯失的影響。假如 TLB 錯失次數能夠降低,效能便會顯著地提升(在我們稍候將會看到的測試中,高達 38%)。 20. 是的,這有點不一致,因為在其它的測試中,我們把結構中沒用到的部分也算在元素容量裡,而且我們能夠定義 NPAD 以讓每個元素填滿一個分頁。在這種情況中,工作集的容量會差得很多。不過這並不是這個測試的重點,而且無論如何預取都沒什麼效率,因此沒有什麼差別。 ↩ "},"cpu-caches/cpu-cache-implementation-details/write-behavior.html":{"url":"cpu-caches/cpu-cache-implementation-details/write-behavior.html","title":"3.3.3. 寫入行為","keywords":"","body":"3.3.3. 寫入行為 在我們開始研究在多執行環境(execution context)(執行緒或行程)使用相同記憶體的快取行為之前,我們必須先探究一個快取實作的細節。快取是假定為一致的(coherent),而且對使用者層級的程式而言,這個一致性是假定為完全透明的。系統核心程式是不同的情況;它偶爾會要求快取沖出(flush)。 這具體意味著,假如一個快取行被修改了,在這個時間點之後,對系統而言的結果與根本沒有快取、並且是主記憶體位置本身被修改的情況是相同的。這能以二種方式或策略來實行: 直寫式(write-through)快取實作; 回寫式(write-back)快取實作。 直寫式快取是最簡單的快取一致性的實行方式。若是快取行被寫入的話,處理器也會立即將快取行寫到主記憶體中。這保證了主記憶體與快取永遠保持一致。能夠在任何快取行被取代的時候直接丟棄快取的內容。這個快取策略很簡單,但並不是非常快。舉例來說,一支不斷地修改一個區域變數的程式會在 FSB 產生大量的流量,儘管資料很可能不會在別處用到、而且可能只會短暫存在。 回寫式策略更為複雜。這時處理器不會立即將被修改的快取行寫回到主記憶體裡。取而代之地,快取行只會被標記為髒的。當快取行在未來的某個時間點從快取被丟棄時,髒位(dirty bit)將會在這時通知處理器去把資料寫回去,而不是直接丟棄內容。 寫回式快取有機會做得好非常多,這即是大多有著像樣處理器的系統中,記憶體都會以這種方式快取的原因了。處理器甚至能在快取行必須被清除之前,利用 FSB 的閒置容量來儲存快取行的內容。這使得髒位被清除,並且在需要快取中的空間的時候,處理器能夠直接丟棄這個快取行。 但回寫式實作也有個重大的問題。當有多於一個處理器(或是處理器核或 HT),並且存取到同樣的記憶體時,它仍舊必須保證每個處理器看到的一直都是相同的記憶體內容。假如一個快取行在一個處理器上是髒的(也就是說,它還沒被寫回去),並且第二個處理器試著讀取相同的記憶體位置,這個讀取操作就不能直接送到主記憶體去。而是需要第一個處理器的快取行的內容。在下一節,我們將會看到這在目前是如何實作的。 在此之前,還有二種快取策略要提一下: 合併寫入(write-combining);以及 不可快取(uncacheable) 這二種策略都是用在定址空間中、並非被真正的 RAM 所支援的特殊區域。系統核心為這些位址範圍設置了這些策略(在使用了記憶體型態範圍暫存器〔Memory Type Range Register,MTRR〕的 x86 處理器上),剩下的部分自動地進行。MTRR 也能用於在直寫式與回寫式策略之間選擇。 合併寫入是一種受限的快取最佳化,更常用於顯示卡一類裝置上的 RAM。由於對裝置來說,傳輸成本比區域 RAM 存取的成本還高得多,因此避免過多的傳輸是更為重要的。僅因為快取行中的一個字組被修改,就傳輸一整個快取行,在下一個操作修改了下一個字組的情況下是很浪費的。能夠輕易地想像,一種常見的情況是,表示螢幕上水平相鄰的像素點的記憶體,在多數情況下也是相鄰的。如同名字所暗示的,合併寫入會在快取行被寫出去之前合併多個寫入存取。在理想情況下,快取行會被一個字組接著一個字組地修改,並且只有在寫入最後一個字組之後,快取行才會被寫到裝置中。這能夠顯著地加速裝置對 RAM 的存取。 最後,不可快取的記憶體。這通常表示記憶體位置根本不被 RAM 所支援。它可能是一個被寫死的特殊位址,以擁有某個在 CPU 外部實作的功能。對商用硬體來說,最常見的例子是記憶體對映的(memory-mapped)位址範圍,轉譯為對附屬於匯流排上的擴充卡以及裝置(PCIe 等等)的存取。在嵌入式單板(embedded board)上,有時候會發現能夠用來開關 LED 的記憶體位址。快取這類位址顯然是個壞點子。在這種情境下的 LED 是用以除錯或者狀態回報的,會想要儘可能快地看到它。在 PCIe 擴充卡上的記憶體能夠在不與 CPU 互動的情況下改變,因此這種記憶體不應該被快取。 "},"cpu-caches/cpu-cache-implementation-details/multi-processor-support.html":{"url":"cpu-caches/cpu-cache-implementation-details/multi-processor-support.html","title":"3.3.4. 多處理器支援","keywords":"","body":"3.3.4. 多處理器支援 在上一節,我們已經指出,當多處理器開始起作用時我們會遇到的問題。多核處理器甚至有那些並沒有被共享的快取層級(至少 L1d)的問題。 提供從一個處理器到另一個處理器的快取的直接存取是完全不切實際的。首先,連線根本不夠快。實際的替代方案是,將快取內容傳輸給另一個處理器 –– 假如需要的話。注意到這也同樣適用於不在相同處理器上共享的快取。 現在的問題是,什麼時候得傳輸這個快取行?這是個相當容易回答的問題:當一個處理器需要讀取或寫入一個快取行,而其在另一個處理器的快取上是髒的。但處理器要怎麼樣才能判斷一個快取行在另一個處理器的快取上是髒的呢?僅因為一個快取行被另一個處理器載入就假定如此,(至多)也是次佳的(suboptimal)。通常,大多數的記憶體存取都是讀取操作,產生的快取行也不是髒的。處理器對快取行的操作是很頻繁的(那當然,不然我們怎麼會有這篇論文?),這表示在每次寫入操作之後,都去廣播被改變的快取行的資訊是不切實際的。 這些年來所發展出來的就是 MESI 快取一致性協定(修改〔Modified〕、獨占〔Exclusive〕、共享〔Shared〕、無效〔Invalid〕)。這個協定的名稱來自採用 MESI 協定時、一個快取行能夠變成的四個狀態: 修改 本地的處理器已經修改過快取行。這也暗指它是在任何快取中的唯一副本。 獨占 快取行沒有被修改過,但已知沒有被載入到任何其它處理器的快取中。 共享 快取行沒有被修改過,並且可能存在於另一個處理器的快取中。 無效 快取行是無效的 –– 也就是說,沒有被使用。 多年來,這個協定從比較不複雜、但也比較沒效率的較簡易版本開始發展。有了這四個狀態,便可能有效率地實作回寫式快取,而又支援同時在不同的處理器上使用唯讀的資料。 圖 3.18:MESI 協定的狀態轉換 藉由處理器監聽 –– 或者窺探 –– 其它處理器的運作,不用太多精力便得以完成狀態改變。處理器執行的某些操作會被發佈在外部針腳上,因而讓處理器的快取處理能被外界看到。處理中的快取行位址能在位址匯流排上看到。在接下來對狀態與其轉換(顯示在圖 3.18)的描述中,我們會指出匯流排是何時被牽扯進來的。 起初所有快取行都是空的,因此也是無效的。若是資料是為了寫入而載入快取,則改為修改。若是資料是為了讀取而載入,新的狀態則取決於另一個處理器是否也已載入這個快取行。如果是的話,新的狀態為共享,否則為獨占。 若是一個修改的快取行從本地處理器被讀取或寫入,這個指令能夠使用目前的快取內容,並且狀態不變。若是第二個處理器想要讀取這個快取行,第一個處理器就必須將它的快取內容寄送給第二個處理器,然後它就能將狀態改為共享。寄送給第二個處理器的資料也會被記憶體控制器接收並處理,其會將內容儲存在記憶體中。假如沒有這麼做,快取行就不能被標為共享。若是第二個處理器想要寫入快取行,第一個處理器便會寄送快取行的內容,並將自己的快取行標為無效。這即是惡名昭彰的「所有權請求(Request For Ownership,RFO)」操作。在最後一個層級的快取中執行這個操作,就像是 I→M 的轉換一樣,相當昂貴。對直寫式快取而言,我們也得加上它將新的快取行內容寫入到更高階快取或主記憶體所花費的時間,進而提高了成本。 若是一個快取行處於共享狀態,並且本地處理器要讀取它,那麼就不必改變狀態,讀取請求能夠由這個快取來達成。若是快取行要在本地寫入,也能夠使用這個快取行,但狀態會被改成修改。這也需要令其它處理器的所有可能的快取行副本被標為無效。因此,寫入操作必須要透過一個 RFO 訊息發佈給其它處理器。若是快取行被第二個處理器請求讀取,那麼什麼也不必做。主記憶體包含目前的資料,本地的狀態也已經是共享了。在第二個處理器想要寫入到快取行的情況下(RFO),就直接將快取行標為無效。不需要匯流排操作。 獨占狀態與共享狀態大致相同,只有一個重大的不同:本地的寫入操作不必發佈到匯流排上。因為已經知道本地快取是唯一一個持有這個獨有的快取行的了。這會是一個巨大的優勢,所以處理器會試著令盡可能多的快取行維持在獨占狀態,而非共享。後者是在這種時刻,無法取得這個資訊的退而求其次。獨占狀態也能夠在完全不引發功能問題的情況下被省去。唯一會變糟的只有效能,因為 E→M 轉換比 S→M 轉換要快得多了。 從這些狀態轉換的描述中,應該很清楚多處理器操作特有的成本在哪了。是的,填入快取仍舊昂貴,但現在我們也必須留意 RFO 訊息。每當必須發送這種訊息時,工作就會變慢。 有二種必須要 RFO 訊息的情況: 一條執行緒從一個處理器遷移到另一個,並且所有快取行都必須一起移動到新的處理器上。 一個快取行真的被二個不同的處理器所需要。21 在多執行緒或多行程的程式中,總是有一些同步的需求;這種同步是使用記憶體實作的。所以有些有根據的 RFO 訊息。它們仍舊得盡可能地降低頻率。不過,還有其他 RFO 訊息的來源。我們將會在第六節解釋這些情況。快取一致性協定的訊息必須被分發給系統中的處理器。MESI 轉換直到確定系統中的所有處理器都有機會回覆訊息之前都不會發生。這表示一個回覆能花上的最長可能時間決定了一致性協定的速度。22可能會有匯流排上的衝突、NUMA 系統的等待時間會很長、而且突發的流量當然也會讓事情變慢。這全都是專注在避免不必要流量的好理由。 還有一個與擁有多於一個處理器有關的問題。這個影響是與機器高度相關的,但原理上這個問題總是存在:FSB 是一個共享的資源。在大多數機器上,所有處理器會透過單一一條匯流排連結到記憶體控制器(見圖 2.1)。假如單一個處理器能夠佔滿匯流排(通常是這樣),那麼共享相同匯流排的二或四個處理器甚至會更加地限制每個處理器的可用頻寬。 即使每個處理器都如圖 2.2 一樣,有它自己的、連結到記憶體控制器的匯流排,但仍舊有連結到記憶體模組的匯流排。通常這是唯一一條匯流排,而 –– 即使在圖 2.2 的擴充模型中 –– 同時存取相同的記憶體模組將會限制頻寬。 每個處理器都能擁有本地記憶體的 AMD 模型亦是如此。所有處理器確實能快速地並行存取它們的本地記憶體,尤其在使用整合式記憶體控制器的情況。但多執行緒與多行程程式 –– 至少偶爾 –– 必須存取相同的記憶體區域以進行同步。 並行是受可用於必要的同步實作的有限頻寬所嚴重地限制的。程式需要被小心地設計,以將不同處理器核對相同記憶體位置的存取降到最小。接下來的量測將會顯示這點、以及其它與多執行緒程式有關的快取影響。 多執行緒存取 為了確保大家理解在不同處理器上同時使用相同快取行所引入的問題的嚴重性,我們將會在這裡多看到一些針對我們先前用過的相同程式的效能圖表。不過,這次會同時執行多於一條執行緒。所要量測的是最快的執行緒的執行時間。這意味著完成所有執行緒的完整執行時間還會更長。使用的機器有四個處理器;測試使用至多四條執行緒。所有處理器共享連結到記憶體控制器的匯流排,而且僅有一條連結到記憶體模組的匯流排。 圖 3.19:循序讀取,多條執行緒 圖 3.19 顯示了循序唯讀存取 128 位元組項目的效能(在 64 位元機器上,NPAD=15)。對於單執行緒的曲線,我們能預期是條與圖 3.11 相似的曲線。量測使用了一台不同的機器,所以實際的數字會有所不同。 這張圖中重要的部分當然是執行多條執行緒時的行為。注意到在走訪鏈結串列時,沒有記憶體會被修改,亦無讓執行緒保持同步的企圖。儘管不必有 RFO 訊息、而且所有的快取行都能被共享,但我們看到當使用二條執行緒時,效能減低了高達 18%,而使用四條執行緒時則高達 34%。由於沒有必須在處理器之間傳輸的快取行,因此變慢僅僅是由二個瓶頸中的一或二者所引起的:從處理器到記憶體控制的共享匯流排、以及從記憶體控制器到記憶體模組的匯流排。一旦工作集容量大於這臺機器的 L3 快取,圖上三種數量的執行緒都會預取新的串列元素。即便只有二條執行緒,可用頻寬也不足以線性延展(scale)(即,沒有執行多條執行緒帶來的損失)。 圖 3.20:循序 Increase,多條執行緒 當我們修改記憶體時,情況變得更可怕了。圖 3.20 顯示了循序 Increase 測試的結果。這個圖表的 Y 軸使用了對數尺度。所以,別被看似很小的差異給騙了。我們在執行二條執行緒的時候仍有大約 18% 的損失,而執行四條執行緒則是驚人的 93% 損失。這表示,在使用四條執行緒時,預取流量加上回寫流量就把匯流排佔得非常滿了。 我們使用對數尺度來顯示 L1d 範圍的結果。能夠看到的是,一旦執行了多於一條執行緒,L1d 基本上就沒什麼效果了。只有在 L1d 不足以容納工作集的時候,單執行緒的存取時間才會超過 20 個週期。當執行了多條執行緒時,存取時間卻立即就達到了 –– 即便使用的是最小的工作集容量。 這裡沒有顯示出問題的一個面向。這個特定的測試程式是難以量測的。即使測試修改了記憶體、而我們因此預期必定會有 RFO 訊息,但當使用了多於一條執行緒時,我們並沒有在 L2 範圍內看到更高的成本。程式必須要使用大量的記憶體,並且所有執行緒必須要平行地存取相同的記憶體。沒有大量的同步 –– 其會佔據大多的執行時間 –– 這是很難實現的。 圖 3.21:隨機 Addnextlast,多條執行緒 最後在圖 3.21,我們有 Addnextlast 測試以隨機的方式存取記憶體的數據。提供這張圖主要是為了顯示出這些高得嚇人的數字。現在在極端的狀況下,處理一個單一的串列元素要花上大約 1,500 個週期。使用更多執行緒的情況還要更加嚴重。我們能使用一張表格來總結多條執行緒的效率。 #執行緒 循序讀取 循序遞增 隨機增加 2 1.69 1.69 1.54 4 2.98 2.07 1.65 表 3.3:多條執行緒的效率 表格顯示了在圖 3.19、3.20、與 3.21 中,多執行緒以最大工作集容量執行的效率。數據表示在使用二或四條執行緒處理最大的工作集容量時,測試程式可能達到的最佳加速。以二條執行緒而言,加速的理論極限為 2,對於四條執行緒而言為 4。二條執行緒的數據並沒有那麼糟。但對於四條執行緒,最後一個測試的數據顯示了,幾乎不值得擴展到超過二條執行緒。額外的獲益是非常小的。如果我們以略為不同的方式表示圖 3.21 的資料,我們便能更輕易地看出這點。 圖 3.22:經由平行化的加速 圖 3.22 的曲線顯示了加速因子 –– 也就是相比於以單一執行緒執行的程式的相對效能。我們得忽略最小容量的情況,因為量測結果不夠精確。以 L2 與 L3 快取的範圍而言,我們能夠看到我們確實達到了幾乎是線性的加速。我們分別達到了差不多 2 與 4 倍速。但一旦 L3 快取不足以容納工作集,數字就往下掉了。二條與四條執行緒的加速因子都掉到一樣的值(見表 3.3 的第四行)。這是難以找到主機板有著超過四個全都使用同個記憶體控制器的 CPU 插槽的其中一個理由。有著更多處理器的機器必須要以不同的方式來做(見第五節)。 這些數字並不普遍。在某些情況下,甚至連能塞進最後一階快取的工作集都無法做到線性加速。事實上,這才是常態,因為執行緒通常並不若這個測試程式的例子一般解耦(decoupled)。另一方面,是可能運作在大工作集上,而仍舊擁有多於二條執行緒的優勢的。不過,做到這點需要一些思考。我們會在第六節討論一些方法。 特例:Hyper-Threading Hyper-Threading (簡稱 HT,有時稱為對稱多執行緒〔Symmetric Multi-Threading,SMT〕)由 CPU 實作,並且是個特例,因為個別執行緒無法真的同時執行。它們全都共享著暫存器集以外、幾乎所有的處理資源。個別的處理器核與 CPU 仍然平行地運作,但實作在每顆處理器核上的執行緒會受到這個限制。理論上,每顆處理器核可以有許多執行緒,但是 –– 到目前為止 –– Intel CPU 的每顆處理器核至多僅有二條執行緒。CPU 有時域多工(time-multiplex)執行緒的職責。不過單是如此並沒太大意義。實際的優點是,當同時執行的 HT 被延遲時,CPU 可以調度另一條 HT ,並善用像是算術邏輯一類的可用資源。在大多情況下,這是由記憶體存取造成的延遲。 假如二條執行緒執行在一顆 HT 核上,那麼只有在二條執行緒合併的(combined)執行時間小於單執行緒程式的執行時間時,程式才會比單執行緒程式還有效率。藉由重疊經常重複發生的不同記憶體存取的等待時間,這是可能的。一個簡單的計算顯示了為了達到某程度的加速,快取命中率的最小需求。 一支程式的執行時間能夠以一個僅有一層快取的簡易模型來估算,如下(見 [16]): Texe=N[(1−Fmem)Tproc+Fmem(GhitTcache+(1−Ghit)Tmiss)] T_{\\text{exe}} = N [ (1 - F_{\\text{mem}}) T_{\\text{proc}} + F_{\\text{mem}} (G_{\\text{hit}} T_{\\text{cache}} + (1 - G_{\\text{hit}}) T_{\\text{miss}}) ] Texe=N[(1−Fmem)Tproc+Fmem(GhitTcache+(1−Ghit)Tmiss)] 變數的意義如下: N=指令數Fmem=N 次中存取記憶體的比率Ghit=載入次數中命中快取的比率Tproc=每個指令的週期數Tcache=快取命中的週期數Tmiss=快取錯失的週期數Texe=程式執行時間 \\begin{aligned} N &= \\text{指令數} \\\\ F_{\\text{mem}} &= N \\text{ 次中存取記憶體的比率} \\\\ G_{\\text{hit}} &= \\text{載入次數中命中快取的比率} \\\\ T_{\\text{proc}} &= \\text{每個指令的週期數} \\\\ T_{\\text{cache}} &= \\text{快取命中的週期數} \\\\ T_{\\text{miss}} &= \\text{快取錯失的週期數} \\\\ T_{\\text{exe}} &= \\text{程式執行時間} \\end{aligned} NFmemGhitTprocTcacheTmissTexe=指令數=N 次中存取記憶體的比率=載入次數中命中快取的比率=每個指令的週期數=快取命中的週期數=快取錯失的週期數=程式執行時間 為了要讓使用二條執行緒有任何意義,二條執行緒任一的執行時間都必須至多為單執行緒程式碼的一半。在任一邊的唯一變數為快取命中的數量。若是我們求解方程式,以得到令執行緒的執行不減慢超過 50% 以上所需的最小快取命中率,我們會得到圖 3.23 的結果。 圖 3.23:加速的最小快取命中率 輸入 –– 刻在 X 軸上 –– 為單執行緒程式碼的快取命中率 Ghit G_{\\text{hit}} Ghit。Y 軸顯示了多執行緒程式碼的快取命中率。這個值永遠不能高於單執行緒的命中率,不然單執行緒程式碼也會使用這個改良的程式碼。以單執行緒的命中率 –– 在這個特定的情況下 –– 低於 55% 而言,在所有情況下程式都能夠因為使用執行緒而獲益。由於快取錯失,CPU 或多或少有足夠的空閒來執行第二條 HT。 綠色的區域是目標。假如對執行緒而言的減慢小於 50%,且每條執行緒的工作量都減半,那麼合併的執行時間就可能會小於單執行緒的執行時間。以用作模型的處理器(使用一個有著 HT 的 P4 的數據)而言,一支命中率為 60% 的單執行緒程式,對雙執行緒程式來說需要至少 10% 的命中率。這通常是做得到的。但若是單執行緒程式的命中率為 95%,那麼多執行緒程式就需要至少 80% 的命中率。這更難了。尤其 –– 這是使用 HT 的問題 –– 因為現在每條 HT 可用的有效快取容量(這裡是 L1d,在實際上 L2 也是如此)被砍半了。 HT 都使用相同的快取來載入它們的資料。若是二條執行緒的工作集沒有重疊,那麼原始的 95% 命中率也會打對折,因而遠低於所需要的 80%。 HT 因而只有在有限範圍的情境中有用。單執行緒程式的快取命中率必須足夠低,以在給定上面的等式、以及減小的快取容量時,新的命中率仍然滿足要求。這時,也只有這時,才有任何使用 HT 的意義。實際上結果是否比較快,取決於處理器是否足以能將一條執行緒的等待時間重疊在另一條執行緒的執行時間上。平行化程式碼的間接成本必須被加到新的總執行時間上,這個額外成本經常無法忽視。 在 6.3.4 節,我們將會看到一種執行緒緊密合作、而通過共有快取的緊密耦合竟然是個優點的技術。這個技術能夠用於多種情境,只要程式開發者樂於將時間與精力投入到擴展他們的程式碼的話。 應該清楚的是,假如二條 HT 執行完全不同的程式碼(也就是說,二條硬體執行緒被作業系統如同單獨的處理器一般對待,以執行個別的行程),快取容量固然會減半,這表示快取錯失的顯著攀升。除非快取足夠大,不然這種作業系統排程的實行是有問題的。除非機器由行程組成的負載確實 –– 經由它們的設計 –– 能夠獲益於 HT ,否則最好在電腦的 BIOS 把 HT 關掉。23 21. 以相同處理器上的二顆處理器核而言,在較小的層級也是如此。成本只小了一點點。RFO 訊息可能會被多次送出。 ↩ 22. 這即是為何我們現今會看到 –– 舉例來說 –– 有三個插槽的 AMD Opteron 系統的原因。假定處理器只擁有三條超連結(hyperlink),而且一條是北橋連接所需,每個處理器都正好相隔一跳(hop)。 ↩ 23. 另一個令 HT 維持開啟的理由是除錯。SMT 令人驚訝地善於在平行程式中找出好幾組問題。 ↩ "},"cpu-caches/cpu-cache-implementation-details/other-details.html":{"url":"cpu-caches/cpu-cache-implementation-details/other-details.html","title":"3.3.5. 其它細節","keywords":"","body":"3.3.5. 其它細節 到目前為止,我們已經討論過由三個部分 –– 標籤、集合索引、以及快取行偏移量 –– 組成的位址。但實際使用的位址是什麼呢?所有有關的處理器現今都是將虛擬定址空間提供給行程,這代表有二種不同的位址:虛擬的以及實體的。 虛擬記憶體的問題是,它們不是唯一的。虛擬記憶體能夠 –– 隨著時間 –– 指涉到不同的實體記憶體位址。在不同行程中的相同位址也可能會指涉到不同的實體位址。所以使用實體記憶體位址永遠比較好,對吧? 這裡的問題是,在執行期間使用的虛擬記憶體必須在記憶體管理單元(Memory Management Unit,MMU)的幫助下轉譯成實體位址。這是個不單純的操作。在執行一個指令的管線中,實體位址可能只有在之後的階段才能取得。這表示快取邏輯必須非常快速地判定這個記憶體位置是否被快取了。若是能夠使用虛擬位址,快取查詢就能夠早點在管線中進行,並且在快取命中的情況下,就能夠取得記憶體內容了。結果是,管線能夠隱藏更多記憶體存取的成本。 處理器設計者目前是使用虛擬位址來標記第一層級的快取。這些快取非常地小,而且清除也不會太費力。若是一個行程的分頁表樹(page table tree)改變了,至少必須局部地清理快取。假如處理器擁有能夠指定被改變的虛擬位址範圍的指令,就有可能避免一次完整的沖出。考慮到 L1i 與 L1d 快取的等待時間很短(~3 週期),使用虛擬位址幾乎是強迫性的。 對於大一點的快取,包含 L2、L3、... 快取,是需要實體位址標記的。這些快取有比較長的等待時間,而虛擬→實體位址轉譯能夠在時間內完成。因為這些快取比較大(即,當它們被沖出時,會損失大量的資訊),並且因為主記憶體存取的等待時間,重新填入它們會花上很久的時間,因此沖出它們的代價經常不小。 一般來說,應該沒必要知道在那些快取中的位址管理的細節。它們無法改變,而且所有會影響效能的因子通常是應該避免、或者是與高成本相關聯的東西。塞滿快取容量是很糟的,而且若是多數被使用的快取行都落在同一組集合中,所有快取都會很快地碰到問題。後者能夠以虛擬定址的快取來避免,但對於使用者層級的行程來說,要避免使用實體位址定址的快取是不可能的。或許唯一應該記住的細節是,可能的話,別在同個行程裡將同個實體記憶體位置映射到二個以上的虛擬位址。 另一個對程式開發者來說滿無趣的快取細節是快取的替換策略。大多快取會先逐出近期最少使用的(Least Recently Used,LRU)元素。以較大的關聯度(由於增加更多的處理器核,關聯度可能會在接下來幾年內進一步地增長)維持 LRU 清單會變得越來越昂貴,我們可能會看到被採用的不同策略。 至於快取的替換,一介程式開發者能做的不多。若是快取是使用實體位址的標籤,就沒有任何辦法能找出虛擬位址與快取集之間的關聯。所有邏輯分頁的快取行可能都映射到相同的快取集,留著大量的快取不用。如果是這樣的話,不讓這太常發生就是作業系統的工作了。 隨著虛擬化(virtualization)的出現,事情變得更加複雜。現在甚至不是作業系統擁有對實體記憶體指派的控制。而是虛擬機器監視器(Virtual Machine Monitor,VMM,又稱 Hypervisor)有指派實體記憶體的職責。 一介程式開發者能做的最多就是 a) 完全使用邏輯記憶體分頁 b) 使用盡可能大的分頁容量,以盡可能地多樣化實體位址。較大的分頁容量也有其它好處,但這是另一個主題(見第四節)。 "},"cpu-caches/instruction-cache.html":{"url":"cpu-caches/instruction-cache.html","title":"3.4. 指令快取","keywords":"","body":"3.4. 指令快取 並非只有處理器用到的資料有被快取;處理器執行的指令也會被快取。然而,這個快取比起資料快取,問題少了許多。有幾個理由: 執行的程式碼的量取決於所需的程式碼容量。程式碼的容量一般視問題的複雜度而定。而問題的複雜度是固定的。 程式的資料管理是由程式開發者所設計的,而程式的指令通常是由編譯器產生的。編譯器撰寫者知道產生良好程式的規則。 程式流程比起資料存取模式更加能夠預測。現今的 CPU 非常擅於發現模式。這有助於預取。 程式碼總是有相當好的空間與時間局部性。 有一些程式開發者應該遵循的規則,但這些主要都是如何使用工具的規則。我們將會在第六節討論它們。這裡我們僅討論指令快取的技術細節。 自從 CPU 核時脈急遽增加、以及快取(即使是第一層快取)與核之間的速度差距成長以來,CPU 便以管線來設計了。這表示一道指令的執行會分階段進行。一道指令會先被解碼、接著準備參數、最後再執行它。這種管線能夠非常長(以 Intel 的 Netburst 架構而言,> 20 個階段)。一條很長的管線意味著,若是管線延誤了(即,通過它的指令流被中斷了),它會花上一段時間才能恢復速度。管線拖延發生在 –– 舉例來說 –– 下一道指令的位置無法被正確地預測、或者載入下一道指令花了太長時間(如,當它必須從記憶體讀取的時候)的時候。 因此 CPU 設計者花費了大量的時間與晶片面積在分支預測上,以盡可能地降低管線延誤發生的頻率。 在 CISC 處理器上,解碼階段也會花上一些時間。x86 與 x86-64 處理器尤其受此影響。在最近幾年,這些處理器因而不在 L1i 上快取指令的原始位元組序列,而是快取被解碼的指令。在這種情況下的 L1i 被稱作「追蹤快取(trace cache)」。追蹤快取令處理器能夠在快取命中的情況下略過管線的前面幾步,這在管線被延誤時格外有效。 如同先前說過的,L2 的快取是包含程式碼與資料的統一式快取。在這裡,程式碼顯然是以位元組序列的形式被快取,而不是被解碼過的。 為了達到最好的效能,只有一些與指令快取相關的規則: 產生盡可能小的程式碼。有些例外,像是為了使用管線的軟體管線化(software pipelining)需要建立額外的程式碼的時候、以及使用小程式碼的間接成本太高之處。 協助處理器做出好的預取決策。這能夠通過程式佈局或是顯式的預取來做到。 這些規則通常由編譯器的程式碼產生(code generation)來強制執行。有一些程式開發者能做的事情,我們會在第六節討論它們。 "},"cpu-caches/instruction-cache/self-modifying-code.html":{"url":"cpu-caches/instruction-cache/self-modifying-code.html","title":"3.4.1. 自我修改的程式碼","keywords":"","body":"3.4.1. 自我修改的程式碼 在電腦時代的早期,記憶體是很珍貴的。人們不遺餘力地減少程式的容量,以為程式資料騰出更多的空間。一個經常使用的技巧是,隨著時間改變程式自身。偶爾仍舊會找到這種自我修改的程式碼(Self Modifying Code,SMC),如今多半是為了效能因素、或者用在安全漏洞上。 一般來說應該避免 SMC。雖然它通常都被正確地執行,但有著並非如此的邊界案例(boundary case),而且沒有正確完成的話,它會產生效能問題。顯然地,被改變的程式碼無法維持在保存被解碼指令的追蹤快取中。但即使程式碼完全(或者有時候)不會被執行,因而不會使用到追蹤快取,處理器也可能會有問題。若是接下來的指令在它已經進入管線的期間被改變,處理器就得丟掉大量的成果,然後從頭開始。甚至有處理器的大多狀態都必須被丟棄的情況。 最後,由於處理器假定 –– 為了簡化起見,而且因為這在 99.9999999% 的情況下都成立 –– 程式碼分頁是不可修改的(immutable),所以 L1i 的實作不會採用 MESI 協定,而是一種簡化的 SI 協定。這表示,若是偵測到修改,就必須做出許多的悲觀假設。 強烈地建議盡可能避免 SMC。記憶體不再是如此稀有的資源。最好是撰寫各自的函式,而非根據特定的需求修改一個函式。或許有天 SMC 的支援能夠是可選的,而我們就能夠以這種方式偵測出嘗試修改程式碼的漏洞程式碼(exploit code)。若是真的必須使用 SMC,寫入操作應該要繞過快取,以免因為 L1i 所需的 L1d 的資料造成問題。關於這些指令的更多訊息,見 6.1 節。 在 Linux 上,識別出包含 SMC 的程式通常非常容易。使用正規工具鏈(toolchain)建構的話,所有程式的程式碼都是防寫的(write-protected)。程式開發者必須在連結期(link time)施展強大的魔法,以產生程式分頁能夠被寫入的可執行檔。當這種情況發生時,現代的 Intel x86 與 x86-64 處理器具有專門的、計算自我修改的程式碼使用次數的效能計數器。有了這些計數器的幫助,非常輕易就能夠識別有著 SMC 的程式,即使程式由於寬鬆的許可而成功執行。 "},"cpu-caches/cache-miss-factors.html":{"url":"cpu-caches/cache-miss-factors.html","title":"3.5. 快取錯失的因素","keywords":"","body":"3.5. 快取錯失的因素 我們已經看過,在記憶體存取沒有命中快取時的成本一飛沖天。有時候這是無可避免的,而瞭解實際的成本、以及能做些什麼來減輕問題是很重要的。 "},"cpu-caches/cache-miss-factors/cache-and-memory-bandwidth.html":{"url":"cpu-caches/cache-miss-factors/cache-and-memory-bandwidth.html","title":"3.5.1. 快取與記憶體頻寬","keywords":"","body":"3.5.1. 快取與記憶體頻寬 為了更好地理解處理器的能力,我們要量測在最理想情況下的可用頻寬。這個量測格外有趣,因為不同處理器版本的差異很大。這即是本節充滿著數個不同機器數據的原因。量測效能的程式使用 x86 與 x86-64 處理器的 SSE 指令以同時載入或儲存 16 位元組。就如同我們的其它測試一樣,工作集從 1kB 增加到 512MB,並量測每個週期能夠載入或儲存多少位元組。 圖 3.24:Pentium 4 的頻寬 圖 3.24 顯示在一台 64 位元 Intel Netburst 處理器上的效能。對於能夠塞進 L1d 的工作集容量,處理器每個週期能夠讀取完整的 16 位元組 –– 即,每個週期執行一次載入指令(movaps 指令一次搬移 16 位元組)。測試不會對讀取的資料做任何事,我們測試的僅有讀取指令本身。一旦 L1d 不再足夠,效能就立刻大幅地降低到每週期少於 6 位元組。在 218 的一步是因為 DTLB 快取的枯竭,表示每個新分頁的額外工作。由於讀取是循序的,預取能夠完美地預測存取,並且對於所有工作集容量,FSB 能以大約每週期 5.3 位元組傳輸記憶體內容。不過,預取的資料不會傳播到 L1d。這些當然是真實程式中永遠無法達到的數字。將它們想成實際上的極限吧。 比起讀取的效能,令人更為吃驚的是寫入與複製的效能。寫入的效能 –– 即便對於很小的工作集容量 –– 始終不會上升到每週期 4 位元組以上。這暗示著,在這些 Netburst 處理器上,Intel 為 L1d 選擇使用直寫模式,其中的效能顯然受限於 L2 的速度。這也代表複製測試 –– 其從一個記憶體區域複製到第二個、不重疊的記憶體區域 –– 的效能並沒有顯著地變差。所需的讀取操作要快得多,並且能夠與寫入操作部分重疊。寫入與複製量測中,最值得注意的細節是,當 L2 快取不再足夠時的低效能。效能跌落到每週期 0.5 位元組!這表示寫入操作比讀取操作慢十倍。這意味著,對於這個程式的效能而言,最佳化那些操作是更加重要的。 圖 3.25:有著 2 條 HT 的 P4 頻寬 在圖 3.25 中,我們看到在相同處理器上、但以二條執行緒執行的結果,每條執行緒各自歸屬於處理器的二條 HT 的其中一條上。這張圖表與前一張使用相同的刻度,以闡明二者的差異。曲線有些微抖動,僅是因為量測二條並行執行緒的問題。結果如同預期。由於 HT 共享暫存器以外的所有資源,每條執行緒都只有一半的快取與可用頻寬。這表示,即使每條執行緒都必須等待很久、並能夠將執行時間撥給另一條執行緒,這也沒有造成任何不同,因為另一條執行緒也必須等待記憶體。這忠實地顯示使用 HT 的最差情況。 圖 3.26:Core 2 的頻寬 圖 3.27:有著 2 條 HT 的 Core 2 頻寬 對比圖 3.24 與圖 3.25,對於 Intel Core 2 處理器,圖 3.26 與 3.27 的結果看起來差異甚大。這是一個有著共享 L2 的雙核處理器,其 L2 是 P4 機器上的 L2 的四倍大。不過,這只解釋寫入與複製效能延後下降的原因。 有另一個、更大的不同。整個工作集範圍內的讀取效能停留在大約是最佳的每週期 16 位元組。讀取效能在 220 位元組之後的下降同樣是因為工作集對 DTLB 來說太大。達到這麼高的數字代表處理器不僅能夠預取資料、還及時傳輸資料。這也代表資料被預取至 L1d 中。 寫入與複製的效能也大為不同。處理器沒有直寫策略;寫入的資料被儲存在 L1d 中,而且僅會在必要時逐出。這使得寫入速度接近於最佳的每週期 16 位元組。一旦 L1d 不再足夠,效能便顯著地降低。如同使用 Netburst 處理器的情況,寫入的效能顯著地降低。由於讀取效能很高,這裡的差距甚至更大。事實上,當 L2 也不再足夠時,速度差異甚至提升到 20 倍!這不代表 Core 2 處理器表現得很糟。相反的,它們的效能一直都比 Netburst 處理器核還好。 在圖 3.27 中,測試執行二條執行緒,各自在 Core 2 處理器二顆處理器核的其中一顆上。二條執行緒都存取相同的記憶體,不過不需要完美地同步。讀取效能的結果跟單執行緒的情況沒什麼不同。看得到稍微多一點的抖動,這在任何多執行緒的測試案例裡都在預期之中。 有趣的一點是,對於能塞進 L1d 的工作集容量的寫入與複製效能。如同圖中能看到的,效能就像是資料必須從主記憶體讀取一樣。二條執行緒都爭奪著相同的記憶體位置,因而必須送出快取行的 RFO 訊息。麻煩之處在於,即使二顆處理器核共享快取,這些請求也不是以 L2 快取的速度處理。一旦 L1d 快取不再足夠,被修改的項目會從每顆處理器核的 L1d 沖出到共享的 L2。這時,由於 L1d 的錯失被 L2 快取所彌補、而且只有在資料還沒被沖出時才需要 RFO 訊息,效能便顯著地增加。這即是我們看到,對於這些工作集容量,速度降低 50% 的原因。這個漸近行為如同預期一般:由於二顆處理器核共享相同的 FSB,每顆處理器核會得到一半的 FSB 頻寬,這表示對於大工作集而言,每條執行緒的效能大約是單執行緒時的一半。 圖 3.28:AMD 10h 家族 Opteron 的頻寬 即使同個供應商的處理器版本之間都有顯著的差異,所以當然也值得看看其它供應商的處理器效能。圖 3.28 顯示一個 AMD 10h 家族 Opteron 處理器的效能。這個處理器擁有 64kB L1d、512kB L2、以及 2MB 的 L3。L3 快取被處理器的所有核所共享。效能測試的結果能在圖 3.28 看到。 注意到的第一個關於數字的細節是,假如 L1d 快取足夠的話,處理器每個週期能夠處理二道指令。讀取效能超過每週期 32 位元組,甚至連寫入效能都很高 –– 每週期 18.7 位元組。不過,讀取的曲線立刻就掉下去,而且非常低 –– 每週期 2.3 位元組。對於這個測試,處理器沒有預取任何資料,至少不怎麼有效率。 另一方面,寫入曲線的表現則取決於不同快取的容量。在 L1d 全滿時達到效能高峰,於 L2 降到 6 位元組,於 L3 降到 2.8 位元組,最後在連 L3 也無法容納所有資料時,降到每週期 .5 位元組。在 L1d 快取時的效能超越(較舊的)Core 2 處理器,L2 存取一樣快(因為 Core 2 有個比較大的快取),而 L3 與主記憶體存取則比較慢。 複製的效能無法比讀取或寫入的效能還要來得好。這即是我們看到,這條曲線起初被壓在讀取效能下面、而後又被壓在寫入效能下面的原因。 圖 3.29:有著 2 條 HT 的 AMD 10h 家族的頻寬 Opteron 處理器的多執行緒效能顯示於圖 3.29。讀取效能基本上不受影響。每條執行緒的 L1d 與 L2 如先前一般運作,而在這個例子下的 L3 快取也沒預取得很好。二條執行緒沒有因其目的而過分地壓榨 L3。在這個測試中的大問題是寫入的效能。所有執行緒共享的資料都得通過 L3 快取。這種共享看起來非常沒有效率,因為即使 L3 快取的容量足以容納整個工作集,成本也遠大於一次 L3 存取。將這張圖與圖 3.27 相比,我們看到在適當的工作集容量範圍中,Core 2 處理器的二條執行緒是以共享的 L2 快取的速度來運作的。對於 Opteron 處理器,這種效能水平只有在一個非常小範圍的工作集容量內才能達到,而即使在這裡,它也只能接近 L3 的速度,比 Core 2 的 L2 還慢。 "},"cpu-caches/cache-miss-factors/critical-word-load.html":{"url":"cpu-caches/cache-miss-factors/critical-word-load.html","title":"3.5.2. 關鍵字組的載入","keywords":"","body":"3.5.2. 關鍵字組的載入 記憶體以比快取行容量還小的區塊從主記憶體傳輸到快取中。現今是一次傳輸 64 位元,而快取行的容量為 64 或 128 位元組。這表示每個快取行需要 8 或 16 次傳輸。 DRAM 晶片能夠以突發(burst)模式傳輸那些 64 位元組的區塊。這能夠在沒有來自記憶體控制器的額外命令、以及可能伴隨的延遲的情況下填滿快取行。若是處理器預取快取行,這可能是最好的操作方式。 若是一支程式的資料或快取存取沒有命中(這表示,這是個強制性快取錯失〔compulsory cache miss〕–– 因為資料是第一次使用、或者是容量性快取錯失〔capacity cache miss〕–– 因為受限的快取容量需要逐出快取行),情況便不同。程式繼續執行所需的快取行裡頭的字組也許不是快取行中的第一個字組。即使在突發模式下、並以雙倍資料速率來傳輸,個別的 64 位元區塊也會在明顯不同的時間點抵達。每個區塊會在前一個區塊抵達之後 4 個 CPU 週期以上抵達。若是程式繼續執行所需的字組是快取行的第八個,程式就必須在第一個字組抵達之後,等待額外的 30 個週期以上。 事情並不必然非得如此。記憶體控制器能夠以不同的順序隨意請求快取行的字組。處理器能夠傳達程式正在等待哪個字組 –– 即關鍵字組,而記憶體控制器能夠先請求這個字組。一旦這個字組抵達,程式便能夠在快取行其餘部分抵達、並且快取還不在一致狀態的期間繼續執行。這個技術被稱為關鍵字組優先與提早重新啟動(Critical Word First & Early Restart)。 現今的處理器實作這項技術,但有些不可能達成的情況。若是處理器預取資料,並且關鍵字組是未知的。萬一處理器在預取操作的途中請求這個快取行,就必須在不能夠影響順序的情況下,一直等到關鍵字組抵達為止。 圖 3.30:在快取行末端的關鍵字組 即使在適當的地方有了這些最佳化,關鍵字組在快取行的位置也很重要。圖 3.30 顯示循序與隨機存取的 Follow 測試結果。顯示的是以用來巡訪的指標位在第一個字組來執行測試,對比指標位在最後一個字組的情況下的速度減慢的結果。元素容量為 64 位元組,與快取行的容量一致。數字受到許多雜訊干擾,但能夠看到,一旦 L2 不再足以持有工作集容量,關鍵字組在末端時的效能立刻就慢約 0.7%。循序存取似乎受到多一點影響。這與前面提及的、預取下個快取行時的問題一致。 "},"cpu-caches/cache-miss-factors/cache-placement.html":{"url":"cpu-caches/cache-miss-factors/cache-placement.html","title":"3.5.3. 快取的配置","keywords":"","body":"3.5.3. 快取的配置 快取在與 HT 及處理器核的關係中的位置並不在程式開發者的控制之下。但程式開發者能夠決定執行緒要在何處執行,於是快取如何與使用的 CPU 共處就變得很重要。 這裡我們不會深入在何時選擇哪顆處理器核來執行執行緒的細節。我們只會描述在設置執行緒的親和性(affinity)時,程式開發者必須要考慮的架構細節。 HT,根據定義,共享暫存器集以外的所有東西。這包含 L1 快取。這裡沒什麼好說的。有趣之處從一個處理器的個別處理器核開始。每顆處理器核至少擁有它自己的 L1 快取。除此之外,現今共有的細節並不多: 早期的多核處理器完全不共享快取。 之後的 Intel 模型的雙核處理器擁有共享的 L2 快取。對於四核處理器,我們必須為由二顆處理器核組成的每一對處理個別的 L2 快取。沒有更高層級的快取。 AMD 的 10h 處理器家族擁有獨立的 L2 快取與一個統一式 L3 快取。 在處理器供應商的宣傳品中已經寫許多關於它們各自的模型的優點。若是由處理器核處理的工作集並不重疊,擁有不共享的快取就有一些優勢。這對於單執行緒程式而言非常有用。由於這仍經常是當下的真實情況,因此這種做法並不怎麼差。但總是會有一些重疊的。快取都包含通用執行期函式庫(runtime library)中最活躍使用的部分,代表有一些快取空間會被浪費。 與 Intel 的雙核處理器一樣完全共享 L1 以外的所有快取有個大優點。若是在二顆處理器核上的執行緒工作集有大量的重疊,可用的快取記憶體總量也會增加,工作集也能夠更大而不致降低效能。若是工作集沒有重疊,Intel 的進階智慧型快取(Advanced Smart Cache)管理應該要防止任何一顆處理器核獨占整個快取。 不過,如果二顆處理器核為了它們各自的工作集使用大約一半的快取,也會有一些衝突。快取必須不斷地掂量二顆處理器核的快取使用量,而作為這個重新平衡的一部分而執行的逐出操作可能會選得很差。為了看到這個問題,讓我們看看另一個測試程式的結果。 測試程式擁有一個不斷 –– 使用 SSE 指令 –– 讀取或寫入一個 2MB 記憶體區塊的行程。選擇 2MB 是因為這是這個 Core 2 處理器的 L2 快取容量的一半。行程被釘在一顆處理器核上,而第二個行程則被釘在另一顆處理器核上。第二個行程讀寫一塊可變容量的記憶體區域。圖表顯示每週期被讀取或寫入的位元組數。顯示四條不同的曲線,每條代表一種行程讀取與寫入的組合。其中 read/write 曲線代表一個總是寫入 2MB 工作集的背景行程,和一個讀取可變工作集、用於測量的行程。 圖 3.31:二個行程的頻寬 這張圖有趣的部分在於 220 與 223 位元組之間。若是二顆處理器核的 L2 快取完全分離,我們能夠預期四個測試的效能全都會在 221 與 222 之間 –– 這表示,L2 快取耗盡的時候 –– 往下掉。如同我們能在圖 3.31 中看到的,情況並非如此。以在背景行程寫入的情況而言,這是最明顯的。效能在工作集容量達到 1MB 之前就開始下降。二個行程沒有共享記憶體,因此行程也不會導致 RFO 訊息被產生。這純粹是逐出的問題。智慧型快取管理有它的問題,導致感覺到的快取容量比起每顆處理器核可用的 2MB,更接近於 1MB。只能期望,若是在處理器核之間共享的快取依舊是未來處理器的特徵的話,智慧型快取管理所使用的演算法會被修正。 有一個擁有二個 L2 快取的四核處理器僅是能夠引入更高層級快取之前的權宜之計。比起獨立的插槽與雙核處理器,這個設計並沒有什麼顯著的效能優勢。二顆處理器核透過在外部被視為 FSB 的相同的匯流排溝通。沒有什麼特別快的資料交換。 針對多核處理器的快取設計的未來將會有更多的層級。AMD 的 10h 處理器家族起個頭。我們是否會繼續看到被一個處理器核的一個子集所共享的更低層級的快取仍有待觀察(在 2008 年處理器的世代中,L2 快取沒有被共享)。額外的快取層級是必要的,因為高速與頻繁使用的快取無法被多顆處理器核所共享。效能會受到影響。也會需要非常大的高關聯度快取。快取容量以及關聯度二者都必須隨著共享快取的處理器核數量而增長。使用一個大的 L3 快取以及合理容量的 L2 快取是個適當的權衡。L3 快取較慢,但它理想上並不如 L2 快取一樣常被使用。 對程式開發者而言,所有這些不同的設計都代表進行排程決策時的複雜性。為了達到最好的效能,必須知道工作負載以及機器架構的細節。幸運的是,我們擁有確定機器架構的依據。這些介面會在之後的章節中介紹。 "},"cpu-caches/cache-miss-factors/fsb-influence.html":{"url":"cpu-caches/cache-miss-factors/fsb-influence.html","title":"3.5.4. FSB 的影響","keywords":"","body":"3.5.4. FSB 的影響 圖 3.32:FSB 速度的影響 FSB 在機器的效能中扮演一個重要的角色。快取內容只能以跟記憶體的連線所允許的一樣快地被儲存與寫入。我們能夠藉由在二台僅在記憶體模組速度上有差異的機器上執行一支程式,來看看到底怎麼樣。圖 3.32 顯示以一台 64 位元機器、NPAD=7 而言,Addnext0 測試(將下一個元素的 pad[0] 元素加到自己的 pad[0] 元素上)的結果。二台機器都擁有 Intel Core 2 處理器,第一台使用 667MHz DDR2 模組,第二台則是 800MHz 模組(提升 20%)。 數據顯示,當 FSB 真的受很大的工作集容量所壓迫時,我們的確看到巨大的優勢。在這項測試中,量測到的最大效能提升為 18.2%,接近理論最大值。這表示,更快的 FSB 確實能夠省下大量的時間。當工作集容量能塞入快取時(這些處理器有一個 4MB L2),這並不重要。必須記在心上的是,這裡我們測量的是一支程式。一個系統的工作集包含所有同時執行的行程所需的記憶體。如此一來,以小得多的程式就可能輕易超過 4MB 以上的記憶體。 現今,一些 Intel 的處理器支援加速到 1,333MHz 的 FSB,這可能代表著額外 60% 的提升。未來將會看到更高的速度。若是速度很重要、並且工作集容量更大,肯定是值得投資金錢在快速的 RAM 與很高的 FSB 速度的。不過必須小心,因為即使處理器可能會支援更高的 FSB 速度,但主機板/北橋可能不會。檢查規格是至關重要的。 "},"virtual-memory.html":{"url":"virtual-memory.html","title":"4. 虛擬記憶體","keywords":"","body":"4. 虛擬記憶體 一個處理器的虛擬記憶體(virtual memory,VM)子系統實作了提供給每個行程的虛擬定址空間。這令每個行程都認為它是獨自在系統中的。虛擬記憶體的優點清單會在其它地方仔細地描述,所以這裡就不重複這些了。這一節會聚焦在虛擬記憶體子系統的實際的實作細節、以及與此相關的成本。 虛擬定址空間是由 CPU 的記憶體管理單元(Memory Management Unit,MMU)實作的。作業系統必須填寫分頁表(page table)資料結構,但大多數 CPU 會自行做掉剩下的工作。這真的是個非常複雜的機制;理解它的最佳方式是引入使用的資料結構來描述虛擬定址空間。 由 MMU 實行的位址轉譯的輸入為一個虛擬位址。它的值通常有極少量 –– 如果有的話 –– 的限制。在 32 位元系統上的虛擬位址為 32 位元的值,而在 64 位元系統上為 64 位元的值。在某些系統上,像是 x86 與 x86-64,使用的位址實際上牽涉到另一層級的間接性:這些架構使用了分段(segment),其只不過是將偏移量加到每個邏輯位址上。我們可以忽略位址產生過程的這個部分,它很瑣碎,而且就記憶體管理的效能而言,不是程式開發者必須要關心的東西。24 24. x86 上的分段限制是攸關效能的,但這又是另一個故事了。 ↩ "},"virtual-memory/simplest-address-translation.html":{"url":"virtual-memory/simplest-address-translation.html","title":"4.1. 最簡單的位址轉譯","keywords":"","body":"4.1. 最簡單的位址轉譯 有趣的部分是虛擬位址到實體位址的轉譯。MMU 能夠逐個分頁重新映射位址。就如同定址快取行的時候一樣,虛擬位址會被切成多個部分。這些部分用來索引多個用以建構最終實體位址的表格。以最簡單的模型而言,我們僅有一個層級的表格。 圖 4.1:一層位址轉譯 圖 4.1 顯示了到底是怎麼使用虛擬位址的不同部分的。開頭的部分用以選擇一個分頁目錄(Page Directory)中的一個項目;在這個目錄中的每個項目都能由作業系統個別設定。分頁目錄項目決定了一個實體記憶體分頁的位址;在分頁目錄中,能夠有多於一個指到相同實體位址的項目。記憶單元的完整實體位址是由分頁目錄的分頁位址、結合虛擬位址的低位元所決定的。分頁目錄項目也包含一些像是存取權限這類關於分頁的額外資訊。 分頁目錄的資料結構儲存於主記憶體中。作業系統必須分配連續的實體記憶體、並將這個記憶體區域的基底位址(base address)儲存在一個特殊的暫存器中。虛擬記憶體中適當的位元量接著會被用作一個分頁目錄的索引 –– 它實際上是一個目錄項目的陣列。 作為一個實際的例子,以下是在 x86 機器上的 4MB 分頁所使用的佈局。虛擬記憶體的偏移量部分的容量為 22 位元,足以定址一個 4MB 分頁中的每個位元組。虛擬記憶體剩餘的 10 位元選擇了分頁目錄裡 1024 個項目中的其中一個。每個項目包含一個 4MB 分頁的一個 10 位元的基底位址,其會與偏移量結合以構成完整的 32 位元位址。 "},"virtual-memory/multi-level-page-tables.html":{"url":"virtual-memory/multi-level-page-tables.html","title":"4.2. 多層級分頁表","keywords":"","body":"4.2. 多層級分頁表 4MB 的分頁並非常態,它們會浪費很多的記憶體,因為作業系統必須執行的許多操作都需要與記憶體分頁對齊(align)。以 4kB 分頁而言(32 位元機器、甚至經常是 64 位元機器上的常態),虛擬位址的偏移量部分的容量僅有 12 位元。這留了 20 位元作為分頁目錄的選擇器。一個有著 220 個項目的表格是不切實際的。即使每個項目只會有 4 位元組,表格容量也會有 4MB。由於每個行程都可能擁有它自己獨有的分頁目錄,這些分頁目錄會佔據系統中大量的實體記憶體。 解決方法是使用多個層級的分頁表。階層於是形成一個巨大、稀疏的分頁目錄;沒有真的用到的定址空間範圍不需要被分配的記憶體。這種表示法因而緊密得多了,使得記憶體中能夠擁有許多行程的分頁表,而不會太過於影響效能。 圖 4.2:四層位址轉譯 現今最複雜的分頁表結構由四個層級所構成。圖 4.2 顯示了這種實作的示意圖。虛擬記憶體 –– 在這個例子中 –– 被切成至少五個部分。其中四個部分為不同目錄的索引。第四層目錄被 CPU 中一種特殊用途的暫存器所指涉。第二層到第四層目錄的內容為指向更低層級目錄的參考。若是一個目錄項目被標記為空,它顯然不需要指到任何更低層的目錄。如此一來,分頁表樹便能夠稀疏且緊密。第一層目錄的項目為 –– 就像在圖 4.1 一樣 –– 部份的實體位址,加上像存取權限這類輔助資料。 要確定對應到一個虛擬位址的實體位址,處理器首先會確定最高層目錄的位址。這個位址通常儲存在一個暫存器中。CPU 接著取出對應到這個目錄的虛擬記憶體的索引部分,並使用這個索引來挑選合適的項目。這個項目是下一個目錄的位址,使用虛擬位址的下一個部分來索引。這個過程持續到抵達第一層目錄,這時目錄項目的值為實體位址的高位部分。加上來自虛擬記憶體的分頁偏移位元便組成了完整的實體位址。這個過程被稱為分頁樹走訪(page tree walking)。有些處理器(像是 x86 與 x86-64)會在硬體中執行這個操作,其它的則需要來自作業系統的協助。 每個在系統中執行的行程會需要它自己的分頁表樹。部分地共享樹是可能的,但不如說這是個例外狀況。因此,如果分頁表樹所需的記憶體盡可能地小的話,對效能與延展性而言都是有益的。理想的情況是將用到的記憶體彼此靠近地擺在虛擬定址空間中;實際用到的實體位址則無關緊要。對一支小程式而言,僅僅使用在第二、三、四層各自的一個目錄、以及少許第一層目錄,可能還過得去。在有著 4kB 分頁以及每目錄 512 個項目的 x86-64 上,這能夠以總計 4 個目錄(每層一個)來定址 2MB。1GB 的連續記憶體能夠以一個第二到第四層目錄、以及 512 個第一層目錄來定址。 不過,假設能夠連續地分配所有記憶體也太過於簡化了。為了彈性起見,一個行程的堆疊(stack)與堆積(heap)區域 –– 在大多情況下 –– 會被分配在定址空間中極為相對的兩端。這令二個區域在需要時都能盡可能地增長。這表示,最有可能是需要兩個第二層目錄,以及與此相應的、更多低層級的目錄。 但即使如此也不總是符合目前的實際狀況。為了安全考量,一個可執行程式的多個部分(程式碼、資料、堆積、堆疊、動態共享物件〔Dynamic Shared Object,DSO〕,又稱共享函式庫〔shared library〕)會被映射在隨機化的位址上 [9]。隨機化擴大了不同部份的相對位置;這暗示著,在一個行程裡使用中的不同記憶體區域會廣泛地散布在虛擬定址空間中。藉由在隨機化的位址的位元數上施加一些限制也能夠限制範圍,但這無疑 –– 在大多情況下 –– 會讓一個行程無法以僅僅一或二個第二與第三層目錄來執行。 若是效能比起安全性真的重要太多了,也能夠把隨機化關閉。於是作業系統通常至少會在虛擬記憶體中連續地載入所有的 DSO。 "},"virtual-memory/optimizing-page-table-access.html":{"url":"virtual-memory/optimizing-page-table-access.html","title":"4.3. 最佳化分頁表存取","keywords":"","body":"4.3. 最佳化分頁表存取 分頁表的所有資料結構都會被保存在主記憶體中;作業系統就是在這裡建構並更新表格的。在一個行程的創建、或是分頁表的一次修改之後,都會立即通知 CPU。分頁表是用以將每個虛擬位址,使用上述的分頁表走訪來轉成實體位址。更準確地說:每一層至少會有一個目錄會被用在轉換一個虛擬位址的過程中。這需要高達四次記憶體存取(以執行中行程的一個單一存取而言),這很慢。將這些目錄表的項目視為普通的資料、並在 L1d、L2、等等快取它們是辦得到的,但這可能還是太慢了。 從最早期的虛擬記憶體開始,CPU 設計者便已採用了一種不同的最佳化。一個簡單的計算能夠顯示,僅將目錄表的項目保存在 L1d 以及更高層級的快取中會招致可怕的效能。每個獨立的位址計算會需要相符於分頁表深度的若干 L1d 存取。這些存取無法平行化,因為它們都依賴於前一次查詢的結果。單是這樣就會 –– 在一台有著四個分頁表階層的機器上 –– 需要至少 12 個週期。再加上 L1d 錯失的機率,結果是指令管道無法隱藏任何東西。額外的 L1d 存取也需要將寶貴的頻寬偷到快取去。 所以,不只是將目錄表的項目快取起來,而是連實體分頁位址的完整計算結果也會被快取。跟程式碼與資料快取行得通的理由相同,這種快取的位址計算結果是很有效的。由於虛擬位址的分頁偏移量的部分不會參與到實體分頁位址的計算,僅有虛擬位址的剩餘部分會用來作為快取的標籤。視分頁容量而定,這代表數百或數千條的指令或資料物件會共享相同的標籤,因而共享相同的實體位址前綴(prefix)。 儲存計算得來的值的快取被稱為轉譯後備緩衝區(Translation Look-Aside Buffer,TLB)。它通常是個很小的快取,因為它必須非常快。現代的 CPU 提供了多層 TLB 快取,就如同其他快取一樣;更高層的快取更大也更慢。L1TLB 的小容量通常藉由令快取為全關聯式、加上 LRU 逐出策略來彌補。近來,這種快取的容量已經持續成長,並且 –– 在進行中 –– 被轉變為集合關聯式。因此,當一個新的項目必須被加入時,被逐出並取代的項目可能不是最舊的一個。 如同上面所註記的,用來存取 TLB 的標籤為虛擬位址的一部分。若是在快取中有比對到標籤,最終的實體位址就能夠藉由將來自虛擬位址的分頁偏移量加到被快取的值上而計算出來。這是個非常快的過程;它必須如此,因為實體位址必須可用於每條使用獨立位址的指令、以及 –– 在某些情況下 –– 使用實體位址作為鍵值(key)的 L2 查詢。若是 TLB 查詢沒有命中,處理器必須要進行一次分頁表走訪;這可能是非常昂貴的。 透過軟體或硬體預取程式碼或資料時,若是位址在另一個分頁上,能夠暗自預取 TLB 的項目。這對於硬體預取而言是不可能的,因為硬體可能會引發無效的分頁表走訪。程式開發者因而無法仰賴硬體預取來預取 TLB 項目。必須明確地使用預取指令來達成。TLB –– 就像是資料與指令快取 –– 能夠出現在多個層級。就如同資料快取一樣,TLB 通常有二種:指令 TLB(ITLB)以及資料 TLB(DTLB)。像是 L2TLB 這種更高層級的 TLB 通常是統一式的,與其它快取的情況相同。 "},"virtual-memory/optimizing-page-table-access/caveats-of-using-a-tlb.html":{"url":"virtual-memory/optimizing-page-table-access/caveats-of-using-a-tlb.html","title":"4.3.1. 使用 TLB 的預警","keywords":"","body":"4.3.1. 使用 TLB 的預警 TLB 是個處理器核的全域(global)資源。所有執行在處理器核的執行緒與行程都使用相同的 TLB。由於虛擬到實體位址的轉譯是看設置的是哪一個分頁表樹而定的,因此若是分頁表被更改了,CPU 就不能盲目地重複使用快取的項目。每個行程有個不同的分頁表樹(但同個行程中的執行緒並非如此)。假如有的話,系統核心與 VMM(虛擬機器監視器)亦是如此。一個行程的定址空間佈局也是可能改變的。有二種處理這個問題的方式: 每當分頁表樹被更改都沖出 TLB。 擴充 TLB 項目的標籤,以額外且唯一地識別它們所指涉到的分頁表樹。 在第一種情況中,每當情境切換(context switch)都會沖出 TLB。由於 –– 在大多作業系統中 –– 從一個執行緒/行程切換到另一個時,需要執行一些系統核心的程式碼,TLB 沖出會被限制在離開(有時候是進入)系統核心定址空間時。在虛擬化的系統上,當系統核心必須呼叫 VMM、並在返回的途中時,這也會發生。若是系統核心和/或 VMM 不必使用虛擬位址、或是能夠重複使用與發出系統/VMM 呼叫的行程或系統核心相同的虛擬位址(即,定址空間被重疊了),TLB 只須在 –– 離開系統核心或 VMM 後 –– 處理器恢復一個不同的行程或系統核心的執行時沖出。 沖出 TLB 有效但昂貴。舉例來說,在執行一個系統呼叫時,系統核心程式可能會被限制在數千行觸及 –– 或許 –– 少數的新分頁(或者一個大分頁,如同在某些架構上的 Linux 的情況)的指令。這個操作僅會取代與被觸及的分頁一樣多的 TLB 項目。以 Intel 的 Core2 架構、附加它的 128 ITLB 與 256 DTLB 的項目而言,一次完整的沖出可能意味著被不必要地沖出的項目(分別)會超過 100 與 200 個。當系統呼叫返回(return)到相同的行程時,所有那些被沖出的 TLB 項目都能夠被再次用到,但它們將會被丟掉。對於在系統核心或 VMM 中經常用到的程式碼亦是如此。儘管系統核心以及 VMM 的分頁表通常不會改變,因此 TLB 項目 –– 理論上 –– 能夠被保存相當長的一段時間,但在每次進入系統核心時,TLB 也必須從零開始填入。這也解釋了為何現今處理器中的 TLB 快取並沒有更大的原因:程式的執行時間非常可能不會長到足以填入這所有的項目。 這個事實 –– 當然 –– 不會逃出 CPU 架構師的掌心。最佳化快取沖出的一個可能性是,單獨令 TLB 項目失效。舉例來說,若是系統核心與資料落在一個特殊的位址範圍,那麼僅有落在這個位址範圍的分頁必須從 TLB 逐出。這只需要比對標籤,因而不怎麼昂貴。這個方法在定址空間的一部分 –– 例如,透過一次 munmap 呼叫 –– 被更改的情況下也是有用的。 一個好得多的解法是擴充用來 TLB 存取的標籤。若是 –– 除了虛擬位址的部分以外 –– 為每個分頁表樹(即,一個行程的定址空間)加上一個唯一的識別子(identifier),TLB 根本就不必完全沖出。系統核心、VMM、以及獨立的行程全都能夠擁有唯一的識別子。採用這個方案的唯一議題是,可用於 TLB 標籤的位元數量會被嚴重地限制,而定址空間的數量則否。這表示是有必要重複使用某些識別子的。當這種情況發生時,TLB 必須被部分沖出(如果可能的話)。所有帶著被重複使用的識別子的項目都必須被沖出,但這 –– 但願如此 –– 是個非常小的集合。 當多個行程執行在系統中時,這種擴充的 TLB 標記在虛擬化的範圍之外是有優勢的。假如每個可執行行程的記憶體使用(是故 TLB 項目的使用)受限了,有個好機會是,當一個行程再次被排程時,它最近使用的 TLB 項目仍然在 TLB 中。但還有二個額外的優點: 特殊的定址空間 –– 像是那些被系統核心或 VMM 所用到的 –– 通常只會被進入一段很短的時間;後續的控制經常是返回到啟動這次進入的定址空間。沒有標籤的話,便會執行一或二次 TLB 沖出。有標籤的話,呼叫定址空間的快取轉譯會被保留,而且 –– 由於系統核心與 VMM 定址空間根本不常更改 TLB 項目 –– 來自前一次系統呼叫的轉譯等仍然可以被使用。 當在二條相同行程的執行緒之間切換時,根本不需要 TLB 沖出。不過,沒有擴充的 TLB 標籤的話,進入系統核心就會銷毀第一條執行緒的項目。 某些處理器已經 –– 一段時間了 –– 實作了這些擴充標籤。AMD 以 Pacifica 虛擬化擴充引入了一種 1 位元的標籤擴充。這個 1 位元定址空間 ID(Address Space ID,ASID)是 –– 在虛擬化的情境中 –– 用以從客戶域(guest domain)的定址空間區別出 VMM 的定址空間。這使得作業系統得以避免在每次進入 VMM(舉例來說,處理一個分頁錯誤〔page fault〕)時沖出客戶端的 TLB 項目、或者在返回客戶端時沖出 VMM 的 TLB 項目。這個架構未來將會允許使用更多的位元。其它主流處理器可能也會遵循這套方法並支援這個功能。 "},"virtual-memory/optimizing-page-table-access/influencing-tlb-performance.html":{"url":"virtual-memory/optimizing-page-table-access/influencing-tlb-performance.html","title":"4.3.2. 影響 TLB 效能","keywords":"","body":"4.3.2. 影響 TLB 效能 有幾個影響 TLB 效能的因素。第一個是分頁的容量。顯然地,分頁越大、會被塞進去的指令或資料物件也越多。所以一個比較大的快取容量減少了所需位址轉譯的整體數量,代表 TLB 快取中需要更少的項目。大部分架構現今允許使用多種不同的分頁容量;有些容量能夠並存地使用。舉例來說,x86/x86-64 處理器擁有尋常的 4kB 分頁容量,但它們也分別能夠使用 4MB 與 2MB 的分頁。IA-64 與 PowerPC 允許像是 64kB 的容量作為基礎分頁容量。 不過,大分頁尺寸的使用也隨之帶來了一些問題。為了大分頁而使用的記憶體區域在實體記憶體中必須是連續的。若是實體記憶體管理的單位容量被提高到虛擬記憶體分頁的容量,浪費的記憶體總量就會增加。各種記憶體操作(像是載入可執行程式)需要對齊到分頁邊界。這表示,平均而言,在每次映射的實體記憶體中,每次映射浪費了一半的分頁容量。這種浪費能夠輕易地累加;這因此對實體記憶體分配的合理單位容量加了個上限。 將單位容量提升到 2MB,以容納 x86-64 上的大分頁無疑並不實際。這個容量太大了。但這又意味著每個大分頁必須由多個較小的分頁所構成。而且這些小分頁在實體記憶體中必須是連續的。以 4kB 的單位分頁容量分配 2MB 的連續實體記憶體具有挑戰性。這需要尋找一個有著 512 個連續分頁的空閒區域。在系統執行一段時間、並且實體記憶體變得片段之後,這可能極端困難(或者不可能)。 因此在 Linux 上,有必要在系統啟動的時候使用特殊的 hugetlbfs 檔案系統來分配這些大分頁。一個固定數量的實體分頁會被保留來專門作為大虛擬分頁來使用。這綁住了可能不會一直用到的資源。這也是個有限的池(pool);增加它通常代表著重新啟動系統。儘管如此,在效能貴重、資源充足、且麻煩的設置不是個大阻礙的情況下,龐大的分頁便為大勢所趨。資料庫伺服器就是個例子。 $ eu-readelf -l /bin/ls Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align ... LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0132ac 0x0132ac R E 0x200000 LOAD 0x0132b0 0x00000000006132b0 0x00000000006132b0 0x001a71 0x001a71 RW 0x200000 ... 圖 4.3:ELF 程式標頭指示了對齊需求 提高最小的虛擬分頁容量(對比於可選的大分頁)也有它的問題。記憶體映射操作(例如,載入應用程式)必須遵循這些分頁容量。不可能有更小的映射。一個可執行程式不同部分的位置 –– 對大多架構而言 –– 有個固定的關係。若是分頁容量增加到超過在可執行程式或者 DSO 創建時所考慮的容量時,就無法執行載入操作。將這個限制記在心上是很重要的。圖 4.3 顯示了能夠如何決定一個 ELF 二進位資料(binary)的對齊需求的。它被編碼在 ELF 的程式標頭(header)。在這個例子中,一個 x86-64 的二進位資料,值為 20000016=2,097,152=2MB 200000_{16} = 2,097,152 = \\text{2MB} 20000016=2,097,152=2MB,與處理器所支援的最大分頁容量相符。 使用較大的分頁容量有個次要的影響:分頁表樹的層級數量會被減低。由於對應到分頁偏移量的虛擬位址部分增加了,就沒有剩下那麼多需要透過分頁目錄處理的位元了。這表示,在一次 TLB 錯失的情況下,必須完成的工作總量減少了。 除了使用大分頁尺寸外,也可能藉由將同時用到的資料搬移到較少的分頁上,以減少所需的 TLB 項目數量。這類似於我們先前討論的針對快取使用的一些最佳化。不過現在所需的對齊更大。考慮到 TLB 項目的數量非常少,這會是個重要的最佳化。 "},"virtual-memory/impact-of-virtualization.html":{"url":"virtual-memory/impact-of-virtualization.html","title":"4.4. 虛擬化的影響","keywords":"","body":"4.4. 虛擬化的影響 作業系統映像(image)的虛擬化會變得越來越流行;這表示記憶體管理的另一層會被加到整體中。行程(基本上為監獄〔jail〕)或作業系統容器(container)的虛擬化並不屬於這個範疇,因為只有一個作業系統會牽涉其中。像 Xen 或 KVM 這類技術能夠 –– 無論有沒有來自處理器的協助 –– 執行獨立作業系統映像。在這些情況下,只有一個直接控制實體記憶體存取的軟體。 圖 4.4:Xen 虛擬化模型 在 Xen 的情況下(見圖 4.4),Xen VMM 即是這個軟體。不過 VMM 本身並不實作太多其它的硬體控制。不若在其它較早期的系統(以及首次釋出的 Xen VMM)上的 VMM,除了記憶體與處理器之外的硬體是由具有特權的 Dom0 域所控制的。目前,這跟運作於非特權模式的 DomU 具備相同的系統核心,而且 –– 就所關心的記憶體管理而言 –– 它們沒什麼區別。重要的是,VMM 將實體記憶體分發給了 Dom0 與 DomU 系統核心,其因而實作了普通的記憶體管理,就好像它們是直接執行在一個處理器上一樣。 為了實現完成虛擬化所需的域的分離,Dom0 與 DomU 系統核心中的記憶體處理並不具有無限制的實體記憶體存取。VMM 不是藉由分發獨立的實體分頁、並讓客戶端作業系統處理定址的方式來分發記憶體;這不會提供任何針對有缺陷或者流氓客戶域的防範。取而代之地,VMM 會為每個客戶域建立它自己擁有的分頁表樹,並使用這些資料結構來分發記憶體。好處是能夠控制對分頁表樹的管理資訊的存取。若是程式沒有合適的權限,它就什麼也無法做。 這種存取控制被利用在 Xen 提供的虛擬化之中,無論使用的是半虛擬化(paravirtualization)或是硬體虛擬化(亦稱全虛擬化)。客戶域採用了有意與半虛擬化以及硬體虛擬化十分相似的方式,為每個行程建立了它們的分頁表樹。無論客戶端作業系統在何時修改了它的分頁表,都會呼叫 VMM。VMM 於是使用在客戶域中更新的資訊來更新它自己擁有的影子分頁表。這些是實際被硬體用到的分頁表。顯然地,這個過程相當昂貴:分頁表樹每次修改都需要一次 VMM 的呼叫。在沒有虛擬化的情況下對記憶體映射的更動並不便宜,而它們現在甚至變得更昂貴了。 考慮到從客戶端作業系統到 VMM 的更改並返回,它們本身已經非常昂貴,額外的成本可能非常大。這即是為何處理器開始擁有額外的功能,以避免影子分頁表的建立。這很好,不僅因為速度的關係,它也減少了 VMM 的記憶體消耗。Intel 有擴充分頁表(Extended Page Table,EPT),而 AMD 稱它為巢狀分頁表(Nested Page Table,NPT)。基本上這二個技術都擁有客戶端作業系統從「客戶虛擬位址(guest virtual address)」產生「宿主虛擬位址(host virtual address)」的分頁表。宿主虛擬位址接著必須被進一步 –– 使用每個域的 EPT/NPT 樹 –– 轉譯成真正的實體位址。這會令記憶體處理以幾乎是非虛擬化情況的速度來進行,因為大多數記憶體管理的 VMM 項目都被移除了。它也減少了 VMM 的記憶體使用,因為現在每個域(對比於行程)都僅有一個必須要維護的分頁。 這個額外的位址轉譯步驟的結果也會儲存在 TLB 中。這表示 TLB 不會儲存虛擬的實體位址,而是查詢的完整結果。已經解釋過 AMD 的 Pacifica 擴充引入了 ASID 以避免在每個項目上的 TLB 沖出。ASID 的位元數量在最初釋出的處理器擴充中只有一位;這足以區隔 VMM 與客戶端作業系統了。Intel 擁有用於相同目的的虛擬處理器 ID(virtual processor ID,VPID),只不過有更多的位元數。但是對於每個客戶域而言,VPID 都是固定的,因此它無法被用來標記個別的行程,也不能在這個層級避免 TLB 沖出。 每次定址空間修改所需的工作量是有著虛擬化作業系統的一個問題。不過,基於 VMM 的虛擬化還有另一個固有的問題:沒有辦法擁有兩層記憶體處理。但是記憶體處理很難(尤其在將像 NUMA 這類難題納入考慮的時候,見第五節)。Xen 使用一個分離 VMM 的方式使得最佳化的(甚至是好的)處理變得困難,因為所有的記憶體管理實作的難題 –– 包含像記憶體區域的探尋這類「瑣碎」事 –– 都必須在 VMM 中重複。作業系統擁有成熟且最佳化的實作;真的應該避免重複這些事。 圖 4.5:KVM 虛擬化模型 這即是為何廢除 VMM/Dom0 模型是個如此有吸引力的替代方案。圖 4.5 顯示了 KVM Linux 系統核心擴充是如何試著解決這個問題的。沒有直接執行在硬體上、並控制所有客戶的分離 VMM;而是一個普通的 Linux 系統核心接管了這個功能。這表示在 Linux 系統核心上完整且精密的記憶體處理功能被用來管理系統中的記憶體。客戶域與被創造者稱為「客戶模式(guest mode)」的普通的使用者層級行程一同執行。虛擬化功能 –– 半虛擬化或全虛擬化 –– 是由 KVM VMM 所控制。這只不過是另一個使用者層級的行程,使用系統核心實作的特殊 KVM 裝置來控制一個客戶域。 這個模型相較於 Xen 模型的分離 VMM 的優點是,即使在使用客戶端作業系統時仍然有二個運作的記憶體處理者,但只需要唯一一種在 Linux 系統核心中的實作。沒有必要像 Xen VMM 一樣在另一段程式碼中重複相同的功能。這導致更少的工作、更少的臭蟲、以及 –– 也許 –– 更少二個記憶體管理者接觸的摩擦,因為在一個 Linux 客戶端中的記憶體管理者會與外部在裸機上執行的 Linux 系統核心的記憶體管理者做出相同的假設。 總而言之,程式開發者必須意識到,採用虛擬化的時候,快取錯失(指令、資料、或 TLB)的成本甚至比起沒有虛擬化還要高。任何減少這些工作的最佳化,在虛擬化的環境中甚至會獲得更多的回報。處理器設計者將會 –– 隨著時間的推移 –– 透過像是 EPT 與 NPT 這類技術來逐漸減少這個差距,但它永遠也不會完全消失。 "},"numa-support.html":{"url":"numa-support.html","title":"5. NUMA 支援","keywords":"","body":"5. NUMA 支援 在第二節我們看過,在某些機器上,存取實體記憶體特殊區域的成本差異是視存取的源頭而定的。這種類型的硬體需要來自作業系統以及應用程式的特殊照顧。我們會以 NUMA 硬體的一些細節開頭,接著我們會涵蓋 Linux 系統核心為 NUMA 提供的一些支援。 "},"numa-support/numa-hardware.html":{"url":"numa-support/numa-hardware.html","title":"5.1. NUMA 硬體","keywords":"","body":"5.1. NUMA 硬體 非均勻記憶體架構正變得越來越普遍。在最簡單的 NUMA 類型中,一個處理器能夠擁有本地記憶體(見圖 2.3),存取它比存取其它處理器的本地記憶體還便宜。對這種類型的 NUMA 系統而言,成本差異並不大 –– 即,NUMA 因子很小。 NUMA 也會 –– 而且尤其會 –– 用在大機器中。我們已經描述過擁有多個存取相同記憶體的處理器的問題。對商用硬體而言,所有處理器都會共享相同的北橋(此刻忽略 AMD Opteron NUMA 節點,它們有它們自己的問題)。這使得北橋成為一個嚴重的瓶頸,因為所有記憶體流量都會經過它。當然,大機器能夠使用客製的硬體來代替北橋,但除非使用的記憶體晶片擁有多個埠 –– 即,它們能夠從多條匯流排使用 –– 不然依舊有個瓶頸在。多埠 RAM 很複雜、而且建立與支援起來很昂貴,因此幾乎不會被使用。 下一個複雜度上的改進為 AMD 使用的模型,其中的互連機制(在 AMD 情況下為超傳輸〔Hyper Transport〕,是它們由 Digital 授權而來的技術)為沒有直接連接到 RAM 的處理器提供了存取。能夠以這種方式組成的結構容量是有限的,除非你想要任意地增加直徑(diameter)(即,任意二節點之間的最大距離)。 圖 5.1:超立方體 一種連接節點的高效拓撲(topology)為超立方體(hypercube),其將節點的數量限制在 2C 2^{C} 2C,其中 C C C 為每個節點擁有的交互連接介面的數量。以所有有著 2n 2^{n} 2n 個 CPU 與 n n n 條交互連接的系統而言,超立方體擁有最小的直徑。圖 5.1 顯示了前三種超立方體。每個超立方體擁有絕對最小(the absolute minimum)的直徑 C C C。AMD 第一世代的 Opteron 處理器,每個處理器擁有三條超傳輸連結。至少有一個處理器必須有個附屬在一條連結上的南橋,代表 –– 目前而言 –– 一個 C=2 C = 2 C=2 的超立方體能夠直接且有效率地實作。下個世代將在某個時間點擁有四條連結,屆時將可能有 C=3 C = 3 C=3 的超立方體。 不過,這不代表無法支援更大的處理器集合體(accumulation)。有些公司已經開發出能夠使用更大的處理器集合的 crossbar(例如,Newisys 的 Horus)。但這些 crossbar 提高了 NUMA 因子,而且在一定數量的處理器上便不再有效。 下一個改進為連接 CPU 的群組,並為它們全體實作一個共享的記憶體。所有這類系統都需要特製化的硬體,絕不是商用系統。這樣的設計存在多方面的複雜度。一個仍然十分接近於商用機器的系統為 IBM x445 以及類似的機器。它們能夠當作有著 x86 與 x86-64 的普通 4U、8 路機器購買。二台(某些時候高達四台)這種機器就能夠被連接起來運作,如同一台有著共享記憶體的機器。使用的交互連接引入了一個作業系統 –– 以及應用程式 –– 必須納入考量的重要的 NUMA 因子。 在光譜的另一端,像 SGI 的 Altix 這樣的機器是專門被設計來互連的。SGI 的 NUMAlink 互連結構非常地快,同時擁有很短的等待時間;二個特性對於高效能計算(high-performance computing,HPC)都是必要條件,尤其是在使用訊息傳遞介面(Message Passing Interface,MPI)的時候。當然,缺點是,這種精密與特製化是非常昂貴的。它們令合理地低的 NUMA 因子成為可能,但以這些機器能擁有的 CPU 數量(幾千個)以及有限的互連能力,NUMA 因子實際上是動態的,而且可能因工作負載而達到不可接受的程度。 更常使用的解決方法是,使用高速網路將許多商用機器連接起來,組成一個集群(cluster)。不過,這些並非 NUMA 機器;它們沒有實作共享的定址空間,因此不會歸於這裡討論的任何一個範疇中。 "},"numa-support/os-support-for-numa.html":{"url":"numa-support/os-support-for-numa.html","title":"5.2.作業系統對 NUMA 的支援","keywords":"","body":"5.2.作業系統對 NUMA 的支援 為了支援 NUMA 機器,作業系統必須將記憶體分散式的性質納入考量。舉例來說,若是一個行程執行在一個給定的處理器上,被指派給行程定址空間的實體 RAM 理想上應該要來自本地記憶體。否則每道指令都必須為了程式碼與資料去存取遠端的記憶體。有些僅存於 NUMA 機器的特殊情況要被考慮進去。DSO 的文字區段(text segment)在一台機器的實體 RAM 中通常正好出現一次。但若是 DSO 被所有 CPU 上的行程與執行緒用到(例如,像 libc 這類基本的執行期函式庫),這表示並非一些、而是全部的處理器都必須擁有遠端的位址。作業系統理想上會將這種 DSO「映像(mirror)」到每個處理器的實體 RAM 中,並使用本地的副本。這並非一種最佳化,而是個必要條件,而且通常難以實作。它可能沒有被支援、或者僅以有限的形式支援。 為了避免情況變得更糟,作業系統不該將一個行程或執行緒從一個節點遷移到另一個。作業系統應該已經試著避免在一般的多處理器機器上遷移行程,因為從一個處理器遷移到另一個處理器意味著快取內容遺失。若是負載分配需要將一個行程或執行緒遷出一個處理器,作業系統通常能夠挑選任一個擁有足夠剩餘容量的新處理器。在 NUMA 環境中,新處理器的選擇受到稍微多一些的限制。對於行程正在使用的記憶體,新選擇的處理器不該有比舊的處理器還高的存取成本;這限制目標的清單。若是沒有符合可用標準的空閒處理器,作業系統除了遷移到記憶體存取更為昂貴的處理器以外別無他選。 在這種情況下,有二種可能的方法。首先,可以期盼這種情況是暫時的,而且行程能夠被遷回到一個更合適的處理器上。或者,作業系統也能夠將行程的記憶體遷移到更靠近新使用的處理器的實體分頁上。這是個相當昂貴的操作。可能得要複製大量的記憶體,儘管不必在一個步驟中完成。當發生這種情況的時候,行程必須 –– 至少短暫地 –– 中止,以正確地遷移對舊分頁的修改。有了讓分頁遷移高效又快速,有整整一串其它的必要條件。簡而言之,作業系統應該避免它,除非它是真的有必要的。 一般來說,不能夠假設在一台 NUMA 機器上的所有行程都使用等量的記憶體,使得 –– 由於遍及各個處理器的行程的分佈 –– 記憶體的使用也會被均等地分配。事實上,除非執行在機器上的應用程式非常特殊(在 HPC 世界中很常見,但除此之外則否),不然記憶體的使用是非常不均等的。某些應用程式會使用巨量的記憶體,其餘的幾乎不用。若總是分配產生請求的處理器本地的記憶體,這將會 –– 或早或晚 –– 造成問題。系統最終將會耗盡執行大行程的節點本地的記憶體。 為了應對這些嚴重的問題,記憶體 –– 預設情況下 –– 不會只分配給本地的節點。為了利用所有系統的記憶體,預設策略是條帶化(stripe)記憶體。這保證所有系統記憶體的同等使用。作為一個副作用,有可能變得能在處理器之間自由遷移行程,因為 –– 平均而言 –– 對於所有用到的記憶體的存取成本沒有改變。由於很小的 NUMA 因子,條帶化是可以接受的,但仍不是最好的(見 5.4 節的數據)。 這是個幫助系統避免嚴重問題、並在普通操作下更為能夠預測的劣化(pessimization)。但它降低整體的系統效能,在某些情況下尤為顯著。這即是 Linux 允許每個行程選擇記憶體分配規則的原因。一個行程能夠為它自己以及它的子行程選擇不同的策略。我們將會在第六節介紹能用於此的介面。 "},"numa-support/published-information.html":{"url":"numa-support/published-information.html","title":"5.3. 被發布的資訊","keywords":"","body":"5.3. 被發布的資訊 系統核心透過 sys 虛擬檔案系統(sysfs)將處理器快取的資訊發布在 /sys/devices/system/cpu/cpu*/cache 在 6.2.1 節,我們會看到能用來查詢不同快取容量的介面。這裡重要的是快取的拓樸。上面的目錄包含列出 CPU 擁有的不同快取資訊的子目錄(叫做 index*)。檔案 type、level、與 shared_cpu_map 是在這些目錄中與拓樸有關的重要檔案。一個 Intel Core 2 QX6700 的資訊看起來就如表 5.1。 type level shared_cpu_map cpu0 index0 Data 1 00000001 index1 Instruction 1 00000001 index2 Unified 2 00000003 cpu1 index0 Data 1 00000002 index1 Instruction 1 00000002 index2 Unified 2 00000003 cpu2 index0 Data 1 00000004 index1 Instruction 1 00000004 index2 Unified 2 0000000c cpu3 index0 Data 1 00000008 index1 Instruction 1 00000008 index2 Unified 2 0000000c 表 5.1:Core 2 CPU 快取的 sysfs 資訊 這份資料的意義如下: 每顆處理器核25擁有三個快取:L1i、L1d、L2。 L1d 與 L1i 快取沒有被任何其它的處理器核所共享 –– 每顆處理器核有它自己的一組快取。這是由 shared_cpu_map 中的位元圖(bitmap)只有一個被設置的位元所暗示的。 cpu0 與 cpu1 的 L2 快取是共享的,正如 cpu2 與 cpu3 上的 L2 一樣。 若是 CPU 有更多快取階層,也會有更多的 index* 目錄。 type level shared_cpu_map cpu0 index0 Data 1 00000001 index1 Instruction 1 00000001 index2 Unified 2 00000001 cpu1 index0 Data 1 00000002 index1 Instruction 1 00000002 index2 Unified 2 00000002 cpu2 index0 Data 1 00000004 index1 Instruction 1 00000004 index2 Unified 2 00000004 cpu3 index0 Data 1 00000008 index1 Instruction 1 00000008 index2 Unified 2 00000008 cpu4 index0 Data 1 00000010 index1 Instruction 1 00000010 index2 Unified 2 00000010 cpu5 index0 Data 1 00000020 index1 Instruction 1 00000020 index2 Unified 2 00000020 cpu6 index0 Data 1 00000040 index1 Instruction 1 00000040 index2 Unified 2 00000040 cpu7 index0 Data 1 00000080 index1 Instruction 1 00000080 index2 Unified 2 00000080 表 5.2:Opteron CPU 快取的 sysfs 資訊 對於一個四槽、雙核的 Opteron 機器,快取資訊看起來如表 5.2。可以看出這些處理器也有三種快取:L1i、L1d、L2。沒有處理器核共享任何階層的快取。這個系統有趣的部分在於處理器拓樸。少了這個額外資訊,就無法理解快取資料。sys 檔案系統將這個資訊擺在下面這個檔案 /sys/devices/system/cpu/cpu*/topology 表 5.3 顯示了在 SMP Opteron 機器的這個階層裡頭的令人感興趣的檔案。 physical_package_id core_id core_siblings thread_siblings cpu0 0 0 00000003 00000001 cpu1 1 00000003 00000002 cpu2 1 0 0000000c 00000004 cpu3 1 0000000c 00000008 cpu4 2 0 00000030 00000010 cpu5 1 00000030 00000020 cpu6 3 0 000000c0 00000040 cpu7 1 000000c0 00000080 表 5.3:Opteron CPU 拓樸的 sysfs 資訊 將表 5.2 與 5.3 擺在一起,我們能夠發現 沒有 CPU 擁有 HT (thethread_siblings 位元圖有一個位元被設置)、 這個系統實際上共有四個處理器(physical_package_id 0 到 3)、 每個處理器有二顆核、以及 沒有處理器核共享任何快取。 這正好與較早期的 Opteron 一致。 目前為止提供的資料中完全缺少的是,有關這台機器上的 NUMA 性質的資訊。任何 SMP Opteron 機器都是一台 NUMA 機器。為了這份資料,我們必須看看在 NUMA 機器上存在的 sys 檔案系統的另一個部分,即下面的階層中 /sys/devices/system/node 這個目錄包含在系統上的每個 NUMA 節點的子目錄。在特定節點的目錄中有許多檔案。在前二張表中描述的 Opteron 機器的重要檔案與它們的內容顯示在表 5.4。 cpumap distance node0 00000003 10 20 20 20 node0 0000000c 20 10 20 20 node2 00000030 20 20 10 20 node3 000000c0 20 20 20 10 表 5.4:Opteron 節點的 sysfs 資訊 這個資訊將所有的一切連繫在一起;現在我們有個機器架構的完整輪廓了。我們已經知道機器擁有四個處理器。每個處理器構成它自己的節點,能夠從 node* 目錄的 cpumap 檔案中的值裡頭設置的位元看出來。在那些目錄的 distance 檔案包含一組值,一個值代表一個節點,表示在各個節點上存取記憶體的成本。在這個例子中,所有本地記憶體存取的成本為 10,所有對任何其它節點的遠端存取的成本為 20。26這表示,即使處理器被組織成一個二維超立方體(見圖 5.1),在沒有直接連接的處理器之間存取也沒有比較貴。成本的相對值應該能用來作為存取時間的實際差距的估計。所有這些資訊的準確性是另一個問題了。 25. cpu0 到 cpu3 為處理器核的相關資訊來自於另一個將會簡短介紹的地方。 ↩ 26. 順帶一提,這是不正確的。這個 ACPI 資訊明顯是錯的,因為 –– 雖然用到的處理器擁有三條連貫的超傳輸連結 –– 至少一個處理器必須被連接到一個南橋上。至少一對節點必須因此有比較大的距離。 ↩ "},"numa-support/remote-access-costs.html":{"url":"numa-support/remote-access-costs.html","title":"5.4. 遠端存取成本","keywords":"","body":"5.4. 遠端存取成本 圖 5.2:多節點的讀/寫效能 不過,距離是有關係的。AMD 在 [1] 提供了一台四槽機器的 NUMA 成本的文件。寫入操作的數據顯示在圖 5.2。寫入比讀取還慢,這並不讓人意外。有趣的部分在於 1 與 2 跳(1- and 2-hop)情況下的成本。二個 1 跳的成本實際上有略微不同。細節見 [1]。2 跳讀取與寫入(分別)比 0 跳讀取慢了 30% 與 49%。2 跳寫入比 0 跳寫入慢了 32%、比 1 跳寫入慢了 17%。處理器與記憶體節點的相對位置能夠造成很大的差距。來自 AMD 下個世代的處理器將會以每個處理器四條連貫的超傳輸連結為特色。在這個例子中,一台四槽機器的直徑會是一。但有八個插槽的話,同樣的問題又 –– 來勢洶洶地 –– 回來了,因為一個有著八個節點的超立方體的直徑為三。 所有這些資訊都能夠取得,但用起來很麻煩。在 6.5 節,我們會看到較容易存取與使用這個資訊的介面。 00400000 default file=/bin/cat mapped=3 N3=3 00504000 default file=/bin/cat anon=1 dirty=1 mapped=2 N3=2 00506000 default heap anon=3 dirty=3 active=0 N3=3 38a9000000 default file=/lib64/ld-2.4.so mapped=22 mapmax=47 N1=22 38a9119000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1 38a911a000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1 38a9200000 default file=/lib64/libc-2.4.so mapped=53 mapmax=52 N1=51 N2=2 38a933f000 default file=/lib64/libc-2.4.so 38a943f000 default file=/lib64/libc-2.4.so anon=1 dirty=1 mapped=3 mapmax=32 N1=2 N3=1 38a9443000 default file=/lib64/libc-2.4.so anon=1 dirty=1 N3=1 38a9444000 default anon=4 dirty=4 active=0 N3=4 2b2bbcdce000 default anon=1 dirty=1 N3=1 2b2bbcde4000 default anon=2 dirty=2 N3=2 2b2bbcde6000 default file=/usr/lib/locale/locale-archive mapped=11 mapmax=8 N0=11 7fffedcc7000 default stack anon=2 dirty=2 N3=2 圖 5.3:/proc/PID/numa_maps 的內容 系統提供的最後一塊資訊就在行程自身的狀態中。能夠確定記憶體映射檔、寫時複製(Copy-On-Write,COW)27分頁與匿名記憶體(anonymous memory)是如何散布在系統中的節點上的。系統核心為每個處理器提供一個虛擬檔(pseudo-file) /proc/PID/numa_maps,其中 PID 為行程的 ID ,如圖 5.3 所示。檔案中的重要資訊為 N0 到 N3 的值,它們表示為節點 0 到 3 上的記憶體區域分配的分頁數量。一個可靠的猜測是,程式是執行在節點 3 的核上。程式本身與被弄髒的分頁被分配在這個節點上。唯讀映射,像是 ld-2.4.so 與 libc-2.4.so 的第一次映射、以及共享檔案 locale-archive 是被分配在其它節點上的。 如同我們已經在圖 5.2 看到的,當橫跨節點操作時,1 與 2 跳讀取的效能分別掉了 9% 與 30%。對執行來說,這種讀取是必須的,而且若是沒有命中 L2 快取的話,每個快取行都會招致這些額外成本。若是記憶體離處理器很遠,對超過快取容量的大工作負載而言,所有量測的成本都必須提高 9%/30%。 圖 5.4:在遠端記憶體操作 為了看到在現實世界的影響,我們能夠像 3.5.1 節一樣測量頻寬,但這次使用的是在遠端、相距一跳的節點上的記憶體。這個測試相比於使用本地記憶體的數據的結果能在圖 5.4 中看到。數字在二個方向都有一些大起伏,這是一個測量多執行緒程式的問題所致,能夠忽略。在這張圖上的重要資訊是,讀取操作總是慢了 20%。這明顯慢於圖 5.2 中的 9%,這極有可能不是連續讀/寫操作的數字,而且可能與較舊的處理器修訂版本有關。只有 AMD 知道了。 以塞得進快取的工作集容量而言,寫入與複製操作的效能也慢了 20%。當工作集容量超過快取容量時,寫入效能不再顯著地慢於本地節點上的操作。互連的速度足以跟上記憶體的速度。主要因素是花費在等待主記憶體的時間。 27. 當一個記憶體分頁起初有個使用者,然後必須被複製以允許獨立的使用者時,寫時複製是一種經常在作業系統實作用到的方法。在許多情境中,複製 –– 起初或完全 –– 是不必要的。在這種情況下,只在任何一個使用者修改記憶體的時候複製是合理的。作業系統攔截寫入操作、複製記憶體分頁、然後允許寫入指令繼續執行。 ↩ "},"what-programmers-can-do.html":{"url":"what-programmers-can-do.html","title":"6. 程式開發者能做些什麼?","keywords":"","body":"6. 程式開發者能做些什麼? 在前面幾節的描述之後,無疑地,程式開發者有非常非常多 –– 正向或者負向地 –– 影響程式效能的機會。而這裡僅討論與記憶體有關的操作。我們將會全面地解釋這些部分,由最底層的物理 RAM 存取以及 L1 快取開始,一路涵蓋到影響記憶體管理的作業系統功能。 "},"what-programmers-can-do/bypassing-the-cache.html":{"url":"what-programmers-can-do/bypassing-the-cache.html","title":"6.1. 繞過快取","keywords":"","body":"6.1. 繞過快取 當資料被產生、並且沒有(立即)被再次使用時,記憶體儲存操作會先讀取完整的快取行然後修改快取資料,這點對效能是有害的。這個操作會將可能再次用到的資料踢出快取,以讓給那些短期內不會再次被用到的資料。尤其是像矩陣 –– 它會先被填值、接著才被使用 –– 這類大資料結構。在填入矩陣的最後一個元素前,第一個元素就會因為矩陣太大被踢出快取,導致寫入快取喪失效率。 對於這類情況,處理器提供對非暫存(non-temporal)寫入操作的支援。這個情境下的非暫存指的是資料在短期內不會被使用,所以沒有任何快取它的理由。這些非暫存的寫入操作不會先讀取快取行然後才修改它;反之,新的內容會被直接寫進記憶體。 這聽來代價高昂,但並不是非得如此。處理器會試著使用合併寫入(見 3.3.3 節)來填入整個快取行。若是成功,那麼記憶體讀取操作是完全不必要的。如 x86 以及 x86-64 架構,gcc 提供若干 intrinsic 函式 譯註: #include void _mm_stream_si32(int *p, int a); void _mm_stream_si128(int *p, __m128i a); void _mm_stream_pd(double *p, __m128d a); #include void _mm_stream_pi(__m64 *p, __m64 a); void _mm_stream_ps(float *p, __m128 a); #include void _mm_stream_sd(double *p, __m128d a); void _mm_stream_ss(float *p, __m128 a); 最有效率地使用這些指令的情況是一次處理大量資料。資料從記憶體載入、經過一或多步處理、而後寫回記憶體。資料「流(stream)」經處理器,這些指令便得名於此。 記憶體位置必須各自對齊至 8 或 16 位元組。在使用多媒體擴充(multimedia extension)的程式碼中,也可以用這些非暫存的版本替換一般的 _mm_store_* 指令。我們並沒有在 A.1 節的矩陣相乘程式中這麼做,因為寫入的值會在短時間內被再次使用。這是串流指令無所助益的一個例子。6.2.1 節會更加深入這段程式碼。 處理器的合併寫入緩衝區可以將部分寫入快取行的請求延遲一小段時間。一個接著一個執行所有修改單一快取行的指令,以令合併寫入能真的發揮功用通常是必要的。以下是一個如何實踐的例子: #include void setbytes(char *p, int c) { __m128i i = _mm_set_epi8(c, c, c, c, c, c, c, c, c, c, c, c, c, c, c, c); _mm_stream_si128((__m128i *)&p[0], i); _mm_stream_si128((__m128i *)&p[16], i); _mm_stream_si128((__m128i *)&p[32], i); _mm_stream_si128((__m128i *)&p[48], i); } 假設指標 p 被適當地對齊,呼叫這個函式會將指向的快取行中的所有位元組設為 c。合併寫入邏輯會看到四個生成的 movntdq 指令,並僅在最後一個指令被執行之後,才對記憶體發出寫入命令。總而言之,這段程式不僅避免在寫入前讀取快取行,也避免快取被並非立即需要的資料污染。這在某些情況下有著巨大的好處。一個經常使用這項技術的例子即是 C 函式庫中的 memset 函式,它在處理大塊記憶體時應該要使用類似於上述程式的作法。 某些架構提供專門的解法。PowerPC 架構定義 dcbz 指令,它能用以清除整個快取行。這個指令不會真的繞過快取,因為快取行仍會被分配來存放結果,但沒有任何資料會從記憶體被讀出來。這相比於非暫存儲存指令更加受限,因為快取行只能全部被清空而污染快取(在資料為非暫存的情況),但其不需合併寫入邏輯來達到這個結果。 為了一探非暫存指令的運作,我們將觀察一個用以測量矩陣 –– 由一個二維陣列所組成 –– 寫入效能的新測試。編譯器將矩陣置放於記憶體中,以令最左邊的(第一個)索引指向一列在記憶體中連續置放的所有元素。右邊的(第二個)索引指向一列中的元素。測試程式以二種方式迭代矩陣:第一種是在內部迴圈增加行號,第二種是在內部迴圈增加列號。這代表其行為如圖 6.1 所示。 圖 6.1:矩陣存取模式 我們測量初始化一個 3000 × 3000 矩陣所花的時間。為了觀察記憶體的表現,我們採用不會使用快取的儲存指令。在 IA-32 處理器上,「非暫存提示(non-temporal hint)」即被用在於此。作為比較,我們也測量一般的儲存操作。結果見於表 6.1。 內部迴圈增加 列 行 一般 0.048s 0.127s 非暫存 0.048s 0.160s 表 6.1:矩陣初始化計時 對於使用快取的一般寫入操作,我們觀察到預期中的結果:若是記憶體被循序地使用,我們會得到比較好的結果,整個操作費 0.048s,相當於 750MB/s,幾近於隨機存取的情況卻花 0.127s(大約 280MB/s)。這個矩陣已經大到令快取沒那麼有效。 我們感興趣的部分主要是繞過快取的寫入操作。可能令人吃驚的是,在這裡循序存取跟使用快取的情況一樣快。這個結果的原因是處理器執行上述的合併寫入操作。此外,對於非暫存寫入的記憶體排序(memory ordering)規則亦被放寬:程式需要明確地插入記憶體屏障(memory barriers)(如 x86 與 x86-64 處理器的 sfence 指令)。意味著處理器在寫回資料時有著更多的自由,因此能盡可能地善用可用的頻寬。 內部迴圈以行向(column-wise)存取的情況就不同。無快取存取的結果明顯地慢於快取存取(0.16s,約 225MB/s)。這裡我們可以理解到,合併寫入是不可能的,每個記憶單元都必須被獨立處理。這需要不斷地從 RAM 晶片上選取新的幾列,附帶著與此相應的延遲。結果是比有快取的情況還慢 25%。 在讀取操作上,處理器 –– 直到最近 –– 除了非暫存存取(Non-Temporal Access,NTA)預取指令的弱提示之外,仍欠缺相應的支援。沒有與合併寫入對等的讀取操作,這對諸如記憶體對映 I/O(memory-mapped I/O)這類無法被快取的記憶體尤其糟糕。Intel 附帶 SSE4.1 擴充引入 NTA 載入。它們以一些串流載入緩衝區(streaming load buffer)實作;每個緩衝區包含一個快取行。針對一個快取行的第一個 movntdqa 指令會將快取行載入一個緩衝區 –– 可能會替換掉另一個快取行。隨後,對同一個快取行、以 16 位元組對齊的存取操作將會由載入緩衝區以少量的成本來提供服務。除非有其它理由,快取行將不會被載入到快取中,於是便能夠在不污染快取的情況下載入大量的記憶體。編譯器為這個指令提供一個 intrinsic 函式: #include __m128i _mm_stream_load_si128 (__m128i *p); 這個 intrinsic 函式應該要以 16 位元組區塊的地址做為參數執行多次,直到每個快取行都被讀取為止。在這時才應該開始處理下一個快取行。由於只有少數幾個串流讀取緩衝區,可能要一次從二個記憶體位置進行讀取。 我們應該從這個實驗得到的是,現代的 CPU 非常巧妙地最佳化無快取寫入 –– 近來甚至包括讀取操作,只要它們是循序操作的。在處理只會被用到一次的大資料結構時,這個知識是非常有用的。再者,快取能夠降低一些 –– 但不是全部 –– 隨機記憶體存取的成本。在這個例子中,由於 RAM 存取的實作,導致隨機存取慢 70%。在實作改變以前,無論何時都應該避免隨機存取。 我們將會在談論預取的章節再次一探非暫存旗標。 譯註. intrinsic 函式可簡稱 intrinsics,由編譯器提供,類似 inline 函式,但跟微處理器架構緊密相關,因為編譯器知道如何運用最佳的方式來輸出對應的微處理器指令。有些狀況下,intrinsics 可能會呼叫標準函式庫或執行環境的函式,甚至可能會有跨越處理器之間 intrinsics 的轉換,例如譯者維護的 SSE2NEON 專案。 ↩ "},"what-programmers-can-do/cache-access.html":{"url":"what-programmers-can-do/cache-access.html","title":"6.2. 快取存取","keywords":"","body":"6.2. 快取存取 希望改進他們程式效能的程式開發者會發現,最好聚焦在影響一階快取的改變上,因為這很可能會產生最好的結果。我們將會在討論延伸到其它層級之前先討論它。顯然地,所有針對一階快取的最佳化也會影響其它快取。所有記憶體存取的主題都是相同的:改進局部性(空間與時間)並對齊程式碼與資料。 "},"what-programmers-can-do/cache-access/optimizing-level-1-data-cache-access.html":{"url":"what-programmers-can-do/cache-access/optimizing-level-1-data-cache-access.html","title":"6.2.1. 最佳化一階資料快取存取","keywords":"","body":"6.2.1. 最佳化一階資料快取存取 在 3.3 節,我們已經看過 L1d 快取的有效使用能夠提升效能。在這一節,我們會展示什麼樣的程式碼改變能夠協助改進這個效能。延續前一節,我們首先聚焦在循序存取記憶體的最佳化。如同在 3.3 節中看到的數字,處理器在記憶體被循序存取的時候會自動預取資料。 使用的範例程式碼為矩陣乘法。我們使用二個 1000×1000 1000 \\times 1000 1000×1000 double 元素的方陣(square matrices)。對於那些忘記數學的人,給定元素為 aij a_{ij} aij 與 bij b_{ij} bij 的矩陣 A A A 與 B B B,0≤i,jN 0 \\leq i,j 0≤i,jN,乘積為 (AB)ij=∑k=0N−1aikbkj=ai1b1j+ai2b2j+⋯+ai(N−1)b(N−1)j (AB)_{ij} = \\sum^{N - 1}_{k = 0} a_{ik} b_{kj} = a_{i1} b_{1j} + a_{i2} b_{2j} + \\cdots + a_{i(N - 1)} b_{(N - 1)j} (AB)ij=k=0∑N−1aikbkj=ai1b1j+ai2b2j+⋯+ai(N−1)b(N−1)j 一個直觀的 C 實作看起來可能像這樣 for (i = 0; i 二個輸入矩陣為 mul1 與 mul2。假定結果矩陣 res 全被初始化為零。這是個既好又簡單的實作。但應該很明顯的是,我們有個正好是在圖 6.1 解釋過的問題。在 mul1 被循序存取的時候,內部的迴圈增加 mul2 的列號。這表示 mul1 是像圖 6.1 中左邊的矩陣那樣處理,而 mul2 是像右邊的矩陣那樣處理。這可能不太好。 有一個能夠輕易嘗試的可能補救方法。由於矩陣中的每個元素會被多次存取,是值得在使用第二個矩陣 mul2 之前將它重新排列(數學術語的話,「轉置〔transpose〕」)的。 (AB)ij=∑k=0N−1aikbjkT=ai1bj1T+ai2bj2T+⋯+ai(N−1)bj(N−1)T (AB)_{ij} = \\sum^{N - 1}_{k = 0} a_{ik} b^{\\text{T}}_{jk} = a_{i1} b^{\\text{T}}_{j1} + a_{i2} b^{\\text{T}}_{j2} + \\cdots + a_{i(N - 1)} b^{\\text{T}}_{j(N - 1)} (AB)ij=k=0∑N−1aikbjkT=ai1bj1T+ai2bj2T+⋯+ai(N−1)bj(N−1)T 在轉置之後(通常以上標「T」表示),我們現在循序地迭代二個矩陣。就 C 程式而言,現在看起來像這樣: double tmp[N][N]; for (i = 0; i 我們建立一個容納被轉置的矩陣的暫時變數(temporary variable)。這需要動到額外的記憶體,但這個成本會被 –– 希望如此 –– 彌補回來,因為每行 1000 次非循序存取是更為昂貴的(至少在現代的硬體上)。是進行一些效能測試的時候。在有著 2666MHz 時脈的 Intel Core 2 上的結果為(以時鐘週期為單位): 原始 轉置 週期數 16,765,297,870 3,922,373,010 相對值 100% 23.4% 雖然只是個簡單的矩陣轉置,但我們能達到 76.6% 的加速!複製操作的損失完全被彌補。1000 次非循序存取真的很傷。 下個問題是,我們是否能做得更好。無論如何,我們確實需要一個不需額外複製的替代方法。我們並不是總有餘裕能進行複製:矩陣可能太大、或者可用的記憶體太小。 替代實作的探尋應該從徹底地檢驗涉及到的數學與原始實作所執行的操作開始。簡單的數學知識讓我們能夠發現,只要每個加數(addend)正好出現一次,對結果矩陣的每個元素執行的加法順序是無關緊要的。28這個理解讓我們能夠尋找將執行在原始程式碼內部迴圈的加法重新排列的解法。 現在,讓我們來檢驗在原始程式碼執行中的實際問題。被存取的 mul2 元素的順序為:(0,0) (0, 0) (0,0)、(1,0) (1, 0) (1,0)、 ... 、(N−1,0) (N - 1, 0) (N−1,0)、(0,1) (0,1) (0,1)、(1,1) (1, 1) (1,1)、 ...。元素 (0,0) (0, 0) (0,0) 與 (0,1) (0, 1) (0,1) 位於同一個快取行中,但在內部迴圈完成一輪的時候,這個快取行早已被逐出。以這個例子而言,每一輪內部迴圈都需要 –– 對三個矩陣的每一個而言 –– 1000 個快取行(Core 2 處理器為 64 位元組)。這加起來遠比 L1d 可用的 32k 還多。 但若是我們在執行內部迴圈的期間,一起處理中間迴圈的二次迭代呢?在這個情況下,我們使用二個來自必定在 L1d 中的快取行的 double 值。我們將 L1d 錯失率減半。譯註這當然是個改進,但 –– 視快取行的容量而定 –– 也許仍不是我們能夠得到的最好結果。Core 2 處理器有個快取行容量為 64 位元組的 L1d。實際的容量能夠使用 sysconf (_SC_LEVEL1_DCACHE_LINESIZE) 在執行期查詢、或是使用命令列(command line)的 getconf 工具程式(utility),以讓程式能夠針對特定的快取行容量編譯。以 sizeof(double) 為 8 來說,這表示 –– 為了完全利用快取行 –– 我們應該展開內部迴圈 8 次。繼續這個想法,為了有效地使用 res 矩陣 –– 即,為了同時寫入 8 個結果 –– 我們也該展開外部迴圈 8 次。我們假設這裡的快取行容量為 64,但這個程式碼也能在 32 位元組快取行的系統上運作,因為快取行也會被 100% 利用。一般來說,最好在編譯期像這樣使用 getconf 工具程式來寫死(hardcode)快取行容量: gcc -DCLS=$(getconf LEVEL1_DCACHE_LINESIZE) ... 若是二元檔是假定為一般化(generic)的話,應該使用最大的快取行容量。使用非常小的 L1d 表示並非所有資料都能塞進快取,但這種處理器無論如何都不適合高效能程式。我們寫出的程式碼看起來像這樣: #define SM (CLS / sizeof (double)) for (i = 0; i 這看起來超可怕的。在某種程度上它是如此,但只是因為它包含一些技巧。最顯而易見的改變是,我們現在有六層巢狀迴圈。外部迴圈以 SM(快取行容量除掉 sizeof(double))為間隔迭代。這將乘法切成多個能夠以更多快取局部性處理的較小的問題。內部迴圈迭代外部迴圈漏掉的索引。再一次,這裡有三層迴圈。這裡唯一巧妙的部分是 k2 與 j2 迴圈的順序不同。這是因為在實際運算中,僅有一個表示式取決於 k2、但有二個取決於 j2。 這裡其餘的複雜之處來自 gcc 在最佳化陣列索引的時候並不是非常聰明的結果。額外變數 rres、rmul1、與 rmul2 的引入,藉由將內部迴圈的常用表示式(expression)盡可能地拉出來,以最佳化程式碼。C 與 C++ 語言預設的別名規則(aliasing rule)並不能幫助編譯器做出這些決定(除非使用 restrict,所有指標存取都是別名的潛在來源)。這即是為何對於數值程式設計而言,Fortran 仍是一個偏好語言的原因:它令快速程式的撰寫更簡單。29 原始 轉置 子矩陣 向量化 週期數 16,765,297,870 3,922,373,010 2,895,041,480 1,588,711,750 相對值 100% 23.4% 17.3% 9.47% 表 6.2:矩陣乘法計時 所有努力所帶來的成果能夠在表 6.2 看到。藉由避免複製,我們增加額外的 6.1% 效能。此外,我們不需要任何額外的記憶體。只要結果矩陣也能塞進記憶體,輸入矩陣可以是任意容量的。這是我們現在已經達成的一個通用解法的一個必要條件。 在表 6.2 中還有一欄沒有被解釋過。大多現代處理器現今包含針對向量化(vectorization)的特殊支援。經常被標為多媒體擴充,這些特殊指令能夠同時處理 2、4、8、或者更多值。這些經常是 SIMD(單指令多資料,Single Instruction, Multiple Data)操作,藉由其它操作的協助,以便以正確的形式獲取資料。由 Intel 處理器提供的 SSE2 指令能夠在一個操作中處理二個 double 值。指令參考手冊列出提供對這些 SSE2 指令存取的 intrinsic 函式。若是使用這些 intrinsic 函式,程式執行會變快 7.3%(相對於原始實作)。結果是,一支以原始程式碼 10% 的時間執行的程式。翻譯成人們認識的數字,我們從 318 MFLOPS 變為 3.35 GFLOPS。由於我們在這裡僅對記憶體的影響有興趣,程式的原始碼被擺到 A.1 節。 應該注意的是,在最後一版的程式碼中,我們仍然有一些 mul2 的快取問題;預取仍然無法運作。但這無法在不轉置矩陣的情況下解決。或許快取預取單元將會變得聰明地足以識別這些模式,那時就不需要額外的更動。不過,以一個 2.66 GHz 處理器上的單執行緒程式而言,3.19 GFLOPS 並不差。 我們在矩陣乘法的例子中最佳化的是被載入的快取行的使用。一個快取行的所有位元組總是會被用到。我們只是確保在快取行被逐出前會用到它們。這當然是個特例。 更常見的是,擁有塞滿一或多個快取行的資料結構,而程式在任何時間點都只會使用幾個成員。我們已經在圖 3.11 看過,大結構尺寸在只有一些成員被用到時的影響。 圖 6.2:散布在多個快取行中 圖 6.2 顯示使用現在已熟知的程式執行另一組基準測試的結果。這次會加上同個串列元素的二個值。在一個案例中,二個元素都在同一個快取行內;在另一個案例中,一個元素位在串列元素的第一個快取行,而第二個位在最後一個快取行。這張圖顯示我們正遭受的效能衰減。 不出所料,在所有情況下,若是工作集塞得進 L1d 就不會有任何負面影響。一旦 L1d 不再充足,則是使用一個行程的二個快取行來償付損失,而非一個。紅線顯示串列被循序地排列時的數據。我們看到尋常的二步模式:當 L2 快取充足時的大約 17% 的損失、以及當必須用到主記憶體時的大約 27% 的損失。 在隨機記憶體存取的情況下,相對的數據看起來有點不同。對於塞得進 L2 的工作集而言的效能衰減介於 25% 到 35% 之間。再往後它下降到大約 10%。這不是因為損失變小,而是因為實際的記憶體存取不成比例地變得更昂貴。這份數據也顯示,在某些情況下,元素之間的距離是很重要的。Random 4 CLs 的曲線顯示較高的損失,因為用到第一個與第四個快取行。 要查看一個資料結構對比於快取行的佈局,一個簡單的方法是使用 pahole 程式(見 [4])。這個程式檢驗定義在二進位檔案中的資料結構。取一個包含這個定義的程式: struct foo { int a; long fill[7]; int b; }; 當在一台 64 位元機器上編譯時,pahole 程式的輸出(在其它東西之中)包含顯示於圖 6.3 的輸出。這個輸出結果告知我們很多東西。首先,它顯示這個資料結構使用超過一個快取行。這個工具假設目前使用的處理器的快取行容量,但這個值能夠使用一個命令列參數來覆寫。尤其在結構容量幾乎沒有超過一個快取行、以及許多這種型別的物件會被分配的情況下,尋求一個壓縮這種結構的方式是合理的。或許幾個元素能有比較小的型別、又或者某些欄位實際上是能使用獨立位元來表示的旗標。 struct foo { int a; /* 0 4 */ /* XXX 4 bytes hole, try to pack */ long int fill[7]; /* 8 56 */ /* --- cacheline 1 boundary (64 bytes) --- */ int b; /* 64 4 */ }; /* size: 72, cachelines: 2 */ /* sum members: 64, holes: 1, sum holes: 4 */ /* padding: 4 */ /* last cacheline: 8 bytes */ 圖 6.3:pahole 執行的輸出 在這個範例的情況中,壓縮是很容易的,而且它也被這支程式所暗示。輸出顯示在第一個元素後面有個四位元的洞(hole)。這個洞是由結構的對齊需求以及 fill 元素所造成的。很容易發現元素 b –– 其容量為四位元組(由那行結尾的 4 所指出的)–– 完美地與這個間隔(gap)相符。在這個情況下的結果是,間隔不再存在,而這個資料結構塞得進一個快取行中。pahole 工具能自己完成這個最佳化。若是使用 --reorganize 參數,並將結構的名稱加到命令列的結尾,這個工具的輸出即是最佳化的結構、以及使用的快取行。除了移動欄位以填補間隔之外,這個工具也能夠最佳化位元欄位以及合併填充(padding)與洞。更多細節見 [4]。 有個正好大得足以容納尾端元素的洞當然是個理想的情況。為了讓這個最佳化有用,物件本身也必須對齊快取行。我們馬上就會開始處理這點。 pahole 輸出也能夠輕易看出元素是否必須被重新排列,以令那些一起用到的元素也會被儲存在一起。使用 pahole 工具,很容易就能夠確定哪些元素要在同個快取行,而不是必須在重新排列元素時才能達成。這並不是一個自動的過程,但這個工具能幫助很多。 各個結構元素的位置、以及它們被使用的方式也很重要。如同我們已經在 3.5.2 節看到的,晚到快取行的關鍵字組的程式效能是很糟的。這表示一位程式開發者應該總是遵循下列二條原則: 總是將最可能為關鍵字組的結構元素移到結構的開頭。 存取資料結構、以及存取順序不受情況所約束時,以它們定義在結構中的順序來存取。 以小結構而言,這表示元素應該以它們可能被存取的順序排列。這必須以靈活的方式處理,以允許其它像是補洞之類的最佳化也能被使用。對於較大的資料結構,每個快取行容量的區塊應該遵循這些原則來排列。 不過,若是物件自身不若預期地對齊,就不值得花時間來重新排列它。一個物件的對齊,是由資料型別的對齊需求所決定的。每個基礎型別有它自己的對齊需求。對於結構型別,它的任意元素中最大的對齊需求決定這個結構的對齊。這幾乎總是小於快取行容量。這表示即使一個結構的成員被排列成塞得進同一個快取行,一個被分配的物件也可能不具有相符於快取行容量的對齊。有二種方法能確保物件擁有在設計結構佈局時使用的對齊: 物件能夠以明確的對齊需求分配。對於動態分配(dynamic allocation),呼叫 malloc 僅會以相符於最嚴格的標準型別(通常是 long double)的對齊來分配物件。不過,使用 posix_memalign 請求較高的對齊也是可能的。 #include int posix_memalign(void **memptr, size_t align, size_t size); 這個函式將一個指到新分配的記憶體的指標儲存到由 memptr 指到的指標變數中。記憶體區塊容量為 size 位元組,並在 align 位元組邊界上對齊。 對於由編譯器分配的物件(在 .data、.bss 等,以及在堆疊中),能夠使用一個變數屬性(attribute): struct strtype variable __attribute((aligned(64))); 在這個情況下,不管 strtype 結構的對齊需求為何,variable 都會在 64 位元組邊界上對齊。這對全域變數與自動變數也行得通。 對於陣列,這個方法並不如你可能預期的那般運作。只有陣列的第一個元素會被對齊,除非每個元素的容量是對齊值的倍數。這也代表每個單一變數都必須被適當地標註。posix_memalign 的使用也不是完全不受控制的,因為對齊需求通常會導致碎片與/或更高的記憶體消耗。 一個使用者定義型別的對齊需求能夠使用一個型別屬性來改變: struct strtype { ...members... } __attribute((aligned(64))); 這會使編譯器以合適的對齊來分配所有的物件,包含陣列。不過,程式開發者必須留意針對動態分配物件的合適對齊的請求。這裡必須再一次使用 posix_memalign。使用 gcc 提供的 alignof 運算子(operator)、並將這個值作為第二個參數傳遞給 posix_memalign 是很簡單的。 之前在這一節提及的多媒體擴充幾乎總是需要對齊記憶體存取。即,對於 16 位元組的記憶體存取而言,位址是被假定以 16 位元組對齊的。x86 與 x86-64 處理器擁有能夠處理非對齊存取的記憶體操作的特殊變體,但這些操作比較慢。對於所有記憶體存取都需要完全對齊的大多 RISC 架構而言,這種嚴格的對齊需求並不新奇。即使一個架構支援非對齊的存取,這有時也比使用合適的對齊還慢,尤其是在不對齊導致一次載入或儲存使用二個快取行、而非一個的情況下。 圖 6.4:非對齊存取的間接成本 圖 6.4 顯示非對齊記憶體存取的影響。現已熟悉的測試會在(循序或隨機)走訪記憶體被量測的期間遞增一個資料元素,一次使用對齊的串列元素、一次使用刻意不對齊的元素。圖表顯示程式因非對齊存取而招致的效能衰減。循序存取情況下的影響比起隨機的情況更為顯著,因為在後者的情況下,非對齊存取會部分地被一般來說較高的記憶體存取成本所隱藏。在循序的情況下,對於塞得進 L2 快取的工作集容量來說,效能衰減大約是 300%。這能夠由 L1 快取的有效性降低來解釋。某些遞增操作現在會碰到二個快取行,而且現在在一個串列元素上操作經常需要二次快取行的讀取。L1 與 L2 之間的連接簡直太壅塞。 對於非常大的工作集容量,非對齊存取的影響仍然是 20% 至 30% –– 考慮到對於這種容量的對齊存取時間很長,這是非常多的。這張圖表應該顯示對齊是必須被嚴加對待的。即使架構支援非對齊存取,也絕對不要認為「它們跟對齊存取一樣好」。 不過,有一些來自這些對齊需求的附帶結果。若是一個自動變數擁有一個對齊需求,編譯器必須確保它在所有情況下都能夠被滿足。這並不容易,因為編譯器無法控制呼叫點(call site)與它們處理堆疊的方式。這個問題能夠以二種方式處理: 產生的程式主動地對齊堆疊,必要時插入間隔。這需要程式檢查對齊、建立對齊、並在之後還原對齊。 要求所有的呼叫端都將堆疊對齊。 所有常用的應用程式二進位介面(application binary interface,ABI)都遵循第二條路。如果一個呼叫端違反規則、並且對齊為被呼叫端所需,程式很可能會失去作用。不過,對齊的完美保持並不會平白得來。 在一個函式中使用的一個堆疊框(frame)的容量不必是對齊的倍數。這表示,若是從這個堆疊框呼叫其它函式,填充就是必要的。很大的不同是,在大部分情況下,堆疊框的容量對編譯器而言是已知的,因此它知道如何調整堆疊指標,以確保任何從這個堆疊框呼叫的函式的對齊。事實上,大多編譯器會直接將堆疊框的容量調高,並以它來完成操作。 如果使用可變長度陣列(variable length array,VLA)或 alloca,這種簡單的對齊處理方式就不合適。在這種情況下,堆疊框的總容量只會在執行期得知。在這種情況下可能會需要主動的對齊控制,使得產生的程式碼(略微地)變慢。 在某些架構上,只有多媒體擴充需要嚴格的對齊;在那些架構上的堆疊總是當作普通的資料型別進行最低限度的對齊,對於 32 與 64 位元架構通常分別是 4 或 8 位元組。在這些系統上,強制對齊會招致不必要的成本。這表示,在這種情況下,我們可能會想要擺脫嚴格的對齊需求,如果我們知道不會依賴它的話。不進行多媒體操作的尾端函式(tail function)(那些不呼叫其它函式的函式)不必對齊。只呼叫不需對齊的函式的函式也不用。若是能夠識別出夠大一組函式,一支程式可能會想要放寬對齊需求。對於 x86 的二元檔,gcc 擁有寬鬆堆疊對齊需求的支援: -mpreferred-stack-boundary=2 若是這個選項(option)的值為 N N N,堆疊對齊需求將會被設為 2N 2^{N} 2N 位元組。所以,若是使用 2 為值,堆疊對齊需求就被從預設值(為 16 位元組)降低成只有 4 位元組。在大多情況下,這表示不需額外的對齊操作,因為普通的堆疊推入(push)與彈出(pop)操作無論如何都是在四位元組邊界上操作的。這個機器特定的選項能夠幫忙減少程式容量,也能夠提升執行速度。但它無法被套用到許多其它的架構上。即使對於 x86-64,一般來說也不適用,因為 x86-64 ABI 要求在 SSE 暫存器中傳遞浮點數參數,而 SSE 指令需要完整的 16 位元組對齊。然而,只要能夠使用這個選項,就能造成明顯的差別。 結構元素的高效擺放與對齊並非資料結構影響快取效率的唯一面向。若是使用一個結構的陣列,整個結構的定義都會影響效能。回想一下圖 3.11 的結果:在這個情況中,我們增加陣列元素中未使用的資料總量。結果是預取越來越沒效果,而程式 –– 對於大資料集 –– 變得越來越沒效率。 對於大工作集,盡可能地使用可用的快取是很重要的。為了達到如此,可能有必要重新排列資料結構。雖然對程式開發者而言,將所有概念上屬於一塊兒的資料擺在同個資料結構是比較簡單的,但這可能不是最大化效能的最好方法。假設我們有個如下的資料結構: struct order { double price; bool paid; const char *buyer[5]; long buyer_id; }; 進一步假設這些紀錄會被存在一個大陣列中,並且有個經常執行的工作(job)會加總所有帳單的預期付款。在這種情境中,buyer 與 buyer_id 使用的記憶體是不必被載入到快取中的。根據圖 3.11 的資料來判斷,程式將會表現得比它能達到的還糟了高達五倍。 將 order 切成二塊,前二個欄位儲存在一個結構中,而另一個欄位儲存在別處要好得多。這個改變無疑提高程式的複雜度,但效能提升證明這個成本的正當性。 最後,讓我們考慮一下另一個 –– 雖然也會被應用在其它快取上 –– 主要是影響 L1d 存取的快取使用的最佳化。如同在圖 3.8 看到的,增加的快取關聯度有利於一般的操作。快取越大,關聯度通常也越高。L1d 快取太大,以致於無法為全關聯式,但又沒有足夠大到要擁有跟 L2 快取一樣的關聯度。若是工作集中的許多物件屬於相同的快取集,這可能會是個問題。如果這導致由於過於使用一組集合而造成逐出,即使大多快取都沒被用到,程式還是可能會受到延遲。這些快取錯失有時被稱為衝突性錯失(conflict miss)。由於 L1d 定址使用虛擬位址,這實際上是能夠受程式開發者控制的。如果一起被用到的變數也儲存在一塊兒,它們屬於相同集合的可能性是被最小化的。圖 6.5 顯示多快就會碰上這個問題。 圖 6.5:快取關聯度影響 在這張圖中,現在熟悉的、使用 NPAD=15 的 Follow30 測試是以特殊的配置來量測的。X 軸是二個串列元素之間的距離,以空串列元素為單位量測。換句話說,距離為 2 代表下一個元素的位址是在前一個元素的 128 位元組之後。所有元素都以相同的距離在虛擬記憶體空間中擺放。Y 軸顯示串列的總長度。僅會使用 1 至 16 個元素,代表工作集總容量為 64 至 1024 位元組。Z 軸顯示尋訪每個串列元素所需的平均週期數。 圖中顯示的結果應該不讓人吃驚。若是被用到的元素很少,所有的資料都塞得進 L1d,而每個串列元素的存取時間僅有 3 個週期。對於幾乎所有串列元素的安排都是如此:虛擬位址以幾乎沒有衝突的方式,被良好地映射到 L1d 的槽(slot)中。(在這張圖中)有二個情況不同的特殊距離值。若是距離為 4096 位元組(即,64 個元素的距離)的倍數、並且串列的長度大於八,每個串列元素的平均週期數便大幅地增加。在這些情況下,所有項目都在相同的集合中,並且 –– 一旦串列長度大於關聯度 –– 項目會從 L1d 被沖出,而下一輪必須從 L2 重新讀取。這造成每個串列元素大約 10 個週期的成本。 使用這張圖,我們能夠確定使用的處理器擁有一個關聯度 8、且總容量為 32kB 的 L1d 快取。這表示,這個測試能夠 –– 必要的話 –– 用以確定這些值。可以為 L2 快取量測相同的影響,但在這裡更為複雜,因為 L2 快取是使用實體位址來索引的,而且它要大得多。 但願程式開發者將這個數據視為值得關注集合關聯度的一種暗示。將資料擺放在二的冪的邊界上足夠常見於現實世界中,但這正好是容易導致上述影響與效能下降的情況。非對齊存取可能會提高衝突性錯失的可能性,因為每次存取都可能需要額外的快取行。 圖 6.6:AMD 上 L1d 的 Bank 位址 如果執行這種最佳化,另一個相關的最佳化也是可能的。AMD 的處理器 –– 至少 –– 將 L1d 實作為多個獨立的 bank。只有當二個資料字組儲存在不同的 bank 中或儲存在同一索引(index)下相同的 bank 中,L1d 快取才能在每一個週期裡拿到二個字組。bank 位址是以虛擬位址的低位元編碼的,如圖 6.6 所示。假若會共同使用的變數也儲存在一起,則它們也會有高可能性在不同的 bank 中或在同一索引下相同的 bank 中。 28. 我們這裡忽略可能會改變上溢位(overflow)、下溢位(underflow)、或是四捨五入(rounding)的發生的算術影響。 ↩ 譯註. 原文說法較簡略,作者的意思是:在一開始三層迴圈的實作中,最內部的每一次 k 迴圈迭代同時處理 res[i][j] += mul1[i][k] * mul2[k][j] 與 res[i][j + 1] += mul1[i][k] * mul2[k][j + 1]。由於才剛存取過 mul2[k][j] 與 res[i][j],所以 mul2[k][j + 1] 與 res[i][j + 1] 還在 L1d 快取中,因而降低錯失率。後述的方法是這個方法的一般化(generalization)。 ↩ 29. 理論上在 1999 年修訂版引入 C 語言的 restrict 關鍵字應該解決這個問題。不過編譯器還是不理解。原因主要是存在著太多不正確的程式碼,其會誤導編譯器、並導致它產生不正確的目的碼(object code)。 ↩ 30. 測試是在一台 32 位元機器上執行的,因此 NPAD=15 代表每個串列元素一個 64 位元組快取行。 ↩ "},"what-programmers-can-do/cache-access/optimizing-level-1-instruction-cache-access.html":{"url":"what-programmers-can-do/cache-access/optimizing-level-1-instruction-cache-access.html","title":"6.2.2. 最佳化一階指令快取存取","keywords":"","body":"6.2.2. 最佳化一階指令快取存取 準備有效使用 L1i 的程式碼需要與有效使用 L1d 類似的技術。不過,問題是,程式開發者通常不會直接影響 L1i 的使用方式,除非他以組合語言來撰寫程式。若是使用編譯器,程式開發者能夠透過引導編譯器建立更好的程式佈局,來間接地決定 L1i 的使用。 程式有跳躍(jump)之間為線性的優點。在這些期間,處理器能夠有效地預取記憶體。跳躍打破這個美好的想像,因為 跳躍目標(target)可能不是靜態決定的; 而且即使它是靜態的,若是它錯失所有快取,記憶體獲取可能會花上很長一段時間。 這些問題造成執行中的停頓,可能嚴重地影響效能。這即是為何現今的處理器在分支預測(branch prediction,BP)上費盡心思的原因。高度特製化的 BP 單元試著盡可能遠在跳躍之前確定跳躍的目標,使得處理器能夠開始將新的位置的指令載入到快取中。它們使用靜態與動態規則、而且越來越擅於判定執行中的模式。 對指令快取而言,盡早將資料拿到快取甚至是更為重要的。如同在 3.1 節提過的,指令必須在它們被執行之前解碼,而且 –– 為了加速(在 x86 與 x86-64 上很重要)–– 指令實際上是以被解碼的形式、而非從記憶體讀取的位元組/字組的形式被快取的。 為了達到最好的 L1i 使用,程式開發者至少應該留意下述的程式碼產生的面向: 盡可能地減少程式碼量(code footprint)。這必須與像是迴圈展開(loop unrolling)與行內展開(inlining)等最佳化取得平衡。 程式執行應該是沒有氣泡(bubble)的線性的。31 合理的情況下,對齊程式碼。 我們現在要看一些根據這些面向、可用於協助最佳化程式的編譯器技術。 編譯器有啟動不同最佳化層級的選項,特定的最佳化也能夠個別地啟用。在高最佳化層級(gcc 的 -O2 與 -O3)啟用的許多最佳化處理迴圈最佳化與函式行內展開。一般來說,這些是不錯的最佳化。如果以這些方式最佳化的程式碼佔了程式總執行時間的很重要的一部分,便能夠提升整體的效能。尤其是,函式的行內展開允許編譯器一次最佳化更大的程式碼塊(chunk),從而能夠產生更好地利用處理器的管線架構的機器碼。當程式較大的一部分能被視為一個單一單元時,程式碼與資料的處理(透過死碼消除〔dead code elimination〕或值域傳播〔value range propagation〕、等等)的效果更好。 較大的程式容量意味著 L1i(以及 L2 與更高階層)快取上的壓力更大。這可能導致較差的效能。較小的程式可能比較快。幸運的是,gcc 有一個針對於此的最佳化選項。如果使用 -Os,編譯器將會為程式容量最佳化。已知會增加程式容量的最佳化會被關掉。使用這個選項經常產生驚人的結果。尤其在編譯器無法真的獲益於迴圈展開與行內展開的情況下,這個選項就是首選。 行內展開也能被個別處理。編譯器擁有引導行內展開的啟發法(heuristic)與限制;這些限制能夠由程式開發者控制。-finlinelimit 選項指定對行內展開而言,必須被視為過大的函式有多大。若是一個函式在多處被呼叫,在所有函式中行內展開它便會導致程式容量的劇增。但還有更多細節。假設一個函式 inlcand 在二個函式 f1 與 f2 中被呼叫。函式 f1 與 f2 本身是先後被呼叫的。 start f1 code f1 inlined inlcand more code f1 end f1 start f2 code f2 inlined inlcand more code f2 end f2 start inlcand code inlcand end inlcand start f1 code f1 end f1 start f2 code f2 end f2 表 6.3:行內展開 Vs 沒有行內展開 表 6.3 顯示在二個函式中沒有行內展開與行內展開的情況下,產生的程式碼看起來會怎麼樣。若是函式 inlcand 在 f1 與 f2 中被行內展開,產生的程式碼的容量為 size f1 + size f2 + 2× 2 \\times 2× size inlcand。如果沒有進行行內展開的話,總容量減少 size inlcand。這即是在 f1 與 f2 相互在不久後呼叫的話,L1i 與 L2 快取額外所需的量。再加上:若是 inlcand 沒被行內展開,程式碼可能仍在 L1i 中,而它就不必被再次解碼。再加上:分支預測單元或許能更好地預測跳躍,因為它已經看過這段程式。如果對程式而言,被行內展開的函式容量上限的編譯器預設值並不是最好的,它應該要被降低。 不過,有些行內展開總是合理的情況。假如一個函式只會被呼叫一次,它也應該被行內展開。這給編譯器執行更多最佳化的機會(像是值域傳播,其會顯著地改進程式碼)。行內展開也許會受選擇限制所阻礙。對於像這樣的情況,gcc 有個選項來明確指定一個函式總是要被行內展開。加上 always_inline 函式屬性會命令編譯器執行恰如這個名稱所指示的操作。 在相同的情境下,若是即便一個函式足夠小也不該被行內展開,能夠使用 noinline 函式屬性。假如它們經常從多處被呼叫,即使對於小函式,使用這個屬性也是合理的。若是 L1i 內容能被重複使用、並且整體的程式碼量減少,這往往彌補額外函式呼叫的附加成本。如今分支預測單元是非常可靠的。若是行內展開能夠促成更進一步的最佳化,情況就不同。這是必須視情況來決定的。 如果行內展開的程式碼總是會被用到的話,always_inline 屬性表現得很好。但假如不是這樣呢?如果偶爾才會呼叫被行內展開的函式會怎麼樣: void fct(void) { ... code block A ... if (condition) inlfct() ... code block C ... 為這種程式序列產生的程式碼一般來說與原始碼的結構相符。這表示首先會是程式區塊 A、接著是一個條件式跳躍 –– 假如條件式被求值為否(false),就往前跳躍。接下來是為行內展開的 inlfct 產生的程式碼,最後是程式區塊 C。這看起來全都很合理,但它有個問題。 若是 condition 經常為否,執行就不是線性的。中間有一大塊沒用到的程式碼,不僅因為預取污染 L1i,它也會造成分支預測的問題。若是分支預測錯,條件表示式可能非常沒有效率。 這是個普遍的問題,而且並不專屬於函式的行內展開。無論在何時用到條件執行、而且它是不對稱的(即,表示式比起某一種結果還要更常產生另一種結果),就有不正確的靜態分支預測、從而有管線中的氣泡的可能性。這能夠藉由告知編譯器,以將較不常執行的程式碼移出主要的程式路徑來避免。在這種情況下,為一個 if 敘述產生的條件分支將會跳躍到一個跳脫順序的地方,如下圖所示。 上半部表示單純的程式佈局。假如區域 B –– 即,由上面被行內展開的函式 inlfct 所產生的 –– 因為條件 I 跳過它而經常不被執行,處理器的預取會拉進包含鮮少用到的區塊 B 的快取行。這能夠藉由區塊的重新排列來改變,其結果能夠在圖的下半部看到。經常執行的程式碼在記憶體中是線性的,而鮮少執行的程式碼被移到不傷及預取與 L1i 效率的某處。 gcc 提供二個實現這點的方法。首先,編譯器能夠在重新編譯程式碼的期間將效能分析(profiling)的輸出納入考量,並根據效能分析擺放程式區塊。我們將會在第七節看到這是如何運作的。第二個方法則是藉由明確的分支預測。gcc 認得 __builtin_expect: long __builtin_expect(long EXP, long C); 這個結構告訴編譯器,表示式 EXP 的值非常有可能會是 C。回傳值為 EXP。__builtin_expect 必須被用在條件表示式中。在幾乎所有的情況中,它會被用在布林表示式的情境中,在這種情況下定義二個輔助巨集(macro)要更方便一些: #define unlikely(expr) __builtin_expect(!!(expr), 0) #define likely(expr) __builtin_expect(!!(expr), 1) 然後可以像這樣用這些巨集 if (likely(a > 1)) 若是程式開發者使用這些巨集、然後使用 -freorder-blocks 最佳化選項,gcc 會如上圖那樣重新排列區塊。這個選項會隨著 -O2 啟用,但對於 -Os 會被停用。有另一個重新排列區塊的 gcc 選項(-freorder-blocks-and-partition),但它的用途有限,因為它不適用於例外處理。 還有另一個小迴圈的大優點,至少在某些處理器上。Intel Core 2 前端有一個特殊的功能,稱作迴圈指令流檢測器(Loop Stream Detector,LSD)。若是一個迴圈擁有不多於 18 道指令(沒有一個是對子程式〔routine〕的呼叫)、僅要求至多 4 次 16 位元組的解碼器擷取、擁有至多 4 條分支指令、並且被執行超過 64 次,那麼這個迴圈有時會被鎖在指令佇列中,因而在迴圈被再次用到的時候能夠更為快速地使用。舉例來說,這適用於會通過一個外部迴圈進入很多次的很小的內部迴圈。即使沒有這種特化的硬體,小巧的迴圈也有優點。 就 L1i 而言,行內展開並非最佳化的唯一面向。另一個面向是對齊,就如資料一般。但有些明顯的差異:程式大部分是線性的一團,其無法任意地擺在定址空間中,而且它無法直接受程式開發者影響,因為是編譯器產生這些程式的。不過,有些程式開發者能夠控制的面向。 對齊每條單一指令沒有任何意義。目標是令指令流為連續的。所以對齊僅在戰略要地上才有意義。為了決定要在何處加上對齊,理解能有什麼好處是必要的。有條在一個快取行開頭的指令32代表快取行的預取是最大化的。對指令而言,這也代表著解碼器是更有效的。很容易看出,若是執行一條在快取行結尾的指令,處理器就必須準備讀取一個新的快取行、並對指令解碼。有些事情可能會出錯(像是快取行錯失),代表平均而言,一條在快取行結尾的指令執行起來並不跟在開頭的指令一樣有效。 如果控制權剛轉移到在快取行結尾的指令(因此預取無效),則情況最為嚴重。將以上推論結合後,我們得出對齊程式碼最有用的地方: 在函式的開頭; 在僅會通過跳躍到達的基礎區塊的開頭; 對某些擴充而言,在迴圈的開頭。 在前二種情況下,對齊的成本很小。在一個新的位置繼續執行,假如決定讓它在快取行的開頭,我們便最佳化預取與解碼。33編譯器藉由無操作(no-op)指令的插入,填滿因對齊程式產生的間隔,而實現這種對齊。這種「死碼(dead code)」佔用一些空間,但通常不傷及效能。 第三種情況略有不同:對齊每個迴圈的開頭可能會造成效能問題。問題在於,一個迴圈的開頭往往是連續地接在其它的程式碼之後。若是情況不是非常湊巧,便會有個在前一道指令與被對齊的迴圈開頭之間的間隔。不像前二種情況,這個間隔無法完全不造成影響。在前一道指令執行之後,必須執行迴圈中的第一道指令。這表示,在前一道指令之後,要不是非得有若干條無操作指令以填補間隔、要不就是非得有個到迴圈開頭的無條件跳躍。二種可能性都不是免費的。特別是迴圈本身並不常被執行的話,無操作指令或是跳躍的開銷可能會比對齊迴圈所省下的還多。 有三種程式開發者能夠影響程式對齊的方法。顯然地,若是程式是以組合語言撰寫,其中的函式與所有的指令都能夠被明確地對齊。組合語言為所有架構提供 .align 假指令(pseudo-op)以做到這點。對高階語言而言,必須將對齊需求告知編譯器。不像資料型別與變數那樣,這在原始碼中是不可能的。而是要使用一個編譯器選項: -falign-functions=N 這個選項命令編譯器將所有函式對齊到下一個大於 N 的二的冪的邊界。這表示會產生一個至多 N 位元組的間隔。對小函式而言,使用一個很大的 N 值是個浪費。對只有難得才會執行的程式也相同。在可能同時包含常用與沒那麼常用的介面的函式庫中,後者可能經常發生。選項值的明智選擇可以藉由避免對齊來讓工作加速或是節省記憶體。能夠藉由使用 1 作為 N 的值、或是使用 -fno-align-functions 選項來關掉所有的對齊。 有關前述的第二種情況的對齊 –– 無法循序達到的基礎區塊的開頭 –– 能夠使用一個不同的選項來控制: -falign-jumps=N 所有其它的細節都相同,關於浪費記憶體的警告也同樣適用。 第三種情況也有它自己的選項: -falign-loops=N 再一次,同樣的細節與警告都適用。除了在這裡,如同先前解釋過的,對齊會造成執行期的成本,因為在對齊的位址會被循序地抵達的情況下,要不是必須執行無操作指令就是必須執行跳躍指令。 gcc 還知道一個用來控制對齊的選項,在這裡提起它僅是為了完整起見。-falign-labels 對齊了程式中的每個單一標籤(label)(基本上是每個基礎區塊的開頭)。除了一些例外狀況之外,這都會讓程式變慢,因而不該被使用。 31. 氣泡生動地描述在一個處理器的管線中執行的空洞,其會在執行必須等待資源的時候發生。關於更多細節,請讀者參閱處理器設計的文獻。 ↩ 32. 對某些處理器而言,快取行並非指令的最小區塊(atomic block)。Intel Core 2 前端會將 16 位元組區塊發給解碼器。它們會被適當的對齊,因此沒有任何被發出的區塊能橫跨快取行邊界。對齊到快取行的開頭仍有優點,因為它最佳化預取的正面影響。 ↩ 33. 對於指令解碼,處理器往往會使用比快取行還小的單元,在 x86 與 x86-64 的情況中為 16 位元組。 ↩ "},"what-programmers-can-do/cache-access/optimizing-level-2-and-higher-cache-access.html":{"url":"what-programmers-can-do/cache-access/optimizing-level-2-and-higher-cache-access.html","title":"6.2.3. 最佳化二階與更高階快取存取","keywords":"","body":"6.2.3. 最佳化二階與更高階快取存取 關於一階快取的最佳化所說的一切也適用於二階與更高階快取存取。有二個最後一階快取的額外面向: 快取錯失一直都非常昂貴。L1 錯失(希望)頻繁地命中 L2 與更高階快取,於是限制其損失,但最後一階快取顯然沒有後盾。 L2 快取與更高階快取經常由多顆處理器核與/或 HT 所共享。每個執行單元可用的有效快取容量因而經常小於總快取容量。 為了避免快取錯失的高成本,工作集容量應該配合快取容量。若是資料只需要一次,這顯然不是必要的,因為快取無論如何都沒有效果。我們要討論的是被需要不只一次的資料集的工作負載。在這種情況下,使用一個太大而不能塞得進快取的工作集將會產生大量的快取錯失,即使預取成功地執行,也會拖慢程式。 即使資料集太大,一支程式也必須完成它的職責。以最小化快取錯失的方式完成工作是程式開發者的職責。對於最後一階快取,是可能 –– 如同 L1 快取 –– 以較小的部分來執行工作的。這與表 6.2 最佳化的矩陣乘法非常雷同。不過,有一點不同在於,對於最後一階快取,要處理的資料區塊可能比較大。如果也需要 L1 最佳化,程式會變得更加複雜。想像一個矩陣乘法,其資料集 –– 二個輸入矩陣與輸出矩陣 –– 無法同時塞進最後一階快取。在這種情況下,或許適合同時最佳化 L1 與最後一階快取存取。 眾多處理器世代中的 L1 快取行容量經常是固定的;即使不同,差異也很小。假設為較大的容量是沒什麼大問題的。在有著較小快取容量的處理器中,會用到二個或更多快取行、而非一個。在任何情況下,寫死快取行容量、並為此最佳化程式都是合理的。 對於較高層級的快取,若程式是假定為一般化的話,就不是這樣。那些快取的容量可能有很大的差異。八倍或更多倍並不罕見。將較大的快取容量假定為預設容量是不可能的,因為這可能表示,除了那些有著最大快取的機器之外,程式在所有機器上都會表現得很差。相反的選擇也很糟:假定為最小的快取,代表浪費掉 87% 或者更多的快取。這很糟;如同我們能從圖 3.14 看到的,使用大快取對程式的速度有著巨大的影響。 這表示程式必須動態地將自身調整為快取行容量。這是一種程式特有的最佳化。我們這裡能說的是,程式開發者應該正確地計算程式的需求。不僅資料集本身需要,更高層級的快取也會被用於其它目的;舉例來說,所有執行的指令都是從快取載入的。若是使用函式庫裡頭的函式,這種快取的使用可能會加總為一個可觀的量。那些函式庫函式也可能需要它們自己的資料,進一步減少可用的記憶體。 一旦我們有一個記憶體需求的公式,我們就能夠將它與快取容量作比較。如同先前所述,快取可能會被許多其它處理器核所共享。目前34,在沒有寫死知識的情況下,取得正確資訊的唯一方法是透過 /sys 檔案系統。在表 5.2,我們已經看過系統核心發布的有關於硬體的資訊。程式必須在目錄: /sys/devices/system/cpu/cpu*/cache 找到最後一階快取。這能夠由在這個目錄裡的層級檔案中的最高數值來辨別出來。當目錄被識別出來時,程式應該讀取在這個目錄中的 size 檔案的內容,並將數值除以 shared_cpu_map 檔案中的位元遮罩中設置的數字。 以這種方式計算的值是個安全的下限。有時一支程式會知道多一些有關其它執行緒或行程的行為。若是那些執行緒被排程在共享這個快取的處理器核或 HT 上、並且已知快取的使用不會耗盡它在總快取容量中所佔的那份,那麼計算出的限制可能會太小,而不是最佳的。是否要比公平共享應該使用的還多,真的要視情況而定。程式開發者必須做出抉擇,或者必須讓使用者做個決定。 34. 當然很快就會有更好的方法! ↩ "},"what-programmers-can-do/cache-access/optimizing-tlb-usage.html":{"url":"what-programmers-can-do/cache-access/optimizing-tlb-usage.html","title":"6.2.4. 最佳化 TLB 使用","keywords":"","body":"6.2.4. 最佳化 TLB 使用 有二種 TLB 使用的最佳化。第一種最佳化是減少一支程式必須使用的分頁數。這會自動導致較少的 TLB 錯失。第二種最佳化是藉由減少必須被分配的較高層目錄表的數量,以令 TLB 查詢便宜一些。較少的目錄表代表使用的記憶體較少,這可能使得目錄查詢有較高的快取命中率。 第一種最佳化與分頁錯誤的最小化密切相關。我們將會在 7.5 節仔細地涵蓋這個主題。分頁錯誤經常是個一次性的成本,但由於 TLB 快取通常很小而且會被頻繁地沖出,因此 TLB 錯失是個長期的損失。分頁錯誤比起 TLB 錯失還貴了數個數量級,但若是一支程式跑得足夠久、而且程式的某些部分會被足夠頻繁地執行,TLB 錯失甚至可能超過分頁錯誤的成本。因此重要的是,不僅要從分頁錯誤的角度、也要從 TLB 錯失的角度來考慮分頁最佳化。差異在於,分頁錯誤的最佳化只要求分頁範圍內的程式碼與資料分組,而 TLB 最佳化則要求 –– 在任何時間點 –– 盡可能少的 TLB 項目。 第二種 TLB 最佳化甚至更難控制。必須使用的分頁目錄數量是視行程的虛擬定址空間中使用的位址範圍分佈而定的。定址空間中廣泛多樣的位置代表著更多的目錄。 一個難題是,定址空間佈局隨機化(Address Space Layout Randomization,ASLR)恰好造成這種狀況。堆疊、DSO、堆積、與可能的可執行檔的載入位址會在執行期隨機化,以防止機器的攻擊者猜出函式或變數的位址。 只有在最大效能至關重要的情況下,才應該關掉 ASLR。額外目錄的成本低到足以令這步是不必要的,除了一些極端的狀況之外。系統核心能隨時執行的一個可能的最佳化是,確保一個單一的映射不會橫跨二個目錄之間的記憶體空間邊界。這會以最小的方式限制 ASLR,但不足以大幅地削弱它。 程式開發者直接受此影響的唯一方式是在明確請求一個定址空間區域的時候。這會在以 MAP_FIXED 使用 mmap 的時候發生。以這種方式分配定址空間區域非常危險,人們幾乎不會這麼做。如果程式開發者使用上述方法且允許自由選取位址,則他們應該要知道最後一階分頁目錄的邊界,及適當挑選所請求的位址。 "},"what-programmers-can-do/prefetching.html":{"url":"what-programmers-can-do/prefetching.html","title":"6.3. 預取","keywords":"","body":"6.3. 預取 預取的目的是隱藏記憶體存取的等待時間。現今處理器的命令管道與亂序(out-of-order,簡稱 OoO)執行的功能能夠隱藏一些等待時間,但最多也只是對命中快取的存取而言。要掩蓋主記憶體存取的等待時間,命令佇列可能得要非常地長。某些沒有 OoO 的處理器試著藉由提高核的數量來補償,但除非所有使用的程式碼都被平行化,否則這是個不太好的交易。 預取能進一步幫助隱藏等待時間。處理器靠它自己執行預取,由某些事件觸發(硬體預取)或是由程式明確地請求(軟體預取)。 "},"what-programmers-can-do/prefetching/hardware-prefetching.html":{"url":"what-programmers-can-do/prefetching/hardware-prefetching.html","title":"6.3.1. 硬體預取","keywords":"","body":"6.3.1. 硬體預取 CPU 啟動硬體預取的觸發,通常是二或多個快取錯失的某種模式的序列。這些快取錯失可能在快取行之前或之後。在舊的實作中,只有鄰近快取行的快取錯失會被識別出來。使用當代硬體,步伐也會被識別出來,代表跳過固定數量的快取行會被識別為一種模式並被適當地處理。 若每次單一的快取錯失都會觸發一次硬體預取,對於效能來說大概很糟。隨機記憶體存取模式 –– 例如存取全域變數 –– 是非常常見的,而產生的預取會大大地浪費 FSB 頻寬。這即是為何啟動預取需要至少二次快取錯失。處理器現今全都預期有多於一條記憶體存取的串流。處理器試著自動將每個快取錯失指派給這樣的一條串流,並且在達到門檻時啟動硬體預取。CPU 現今能追蹤更高階快取的八到十六條單獨的串流。 負責模式識別的單元與各自的快取相關聯。可以有一個 L1d 與 L1i 快取的預取單元。很可能有一個 L2 與更高階快取的預取單元。L2 與更高階快取的預取單元是被所有使用相同快取的其它處理器核與 HT 所共享。八到十六條單獨串流預取單元的數量便因而迅速減少。 預取有個大弱點:它無法跨越分頁邊界。理解到 CPU 支援需求分頁(demand paging)時,原因應該很明顯。若是預取被允許橫跨分頁邊界,存取可能會觸發一個事件,以令分頁能夠被取得。這本身可能很糟,尤其是對效能而言。更糟的是預取器並不知道程式或作業系統本身的語義(semantic)。它可能因此預取實際上永遠不會被請求的分頁。如此意味著預取器會運行超過處理器曾以可識別模式存取過的記憶體區域盡頭。這不只可能,而且非常有可能。若是處理器 –– 作為一次預取的一個副作用 –– 觸發對這樣的分頁的請求,作業系統甚至可能會在這種請求永遠也不會發生時完全扔掉它的追蹤紀錄。 因此重要的是認識到,無論預取器在預測模式上有多厲害,程式也會在分頁邊界上歷經快取錯失,除非它明確地從新的分頁預取或是讀取。這是如 6.2 節描述的最佳化資料佈局、以藉由將不相關的資料排除在外來最小化快取污染的另一個理由。 由於這個分頁限制,處理器現今並沒有非常複雜的邏輯來識別預取模式。以仍佔主導地位的 4k 分頁容量而言,有意義的也就這麼多。這些年來已經提高識別步伐的位址範圍,但超過現今經常使用的 512 位元組窗格(window)可能沒太大意義。目前的預取單元並不認得非線性的存取模式。這種模式較有可能是真的隨機、或者至少足夠不重複到令試著識別它們不具意義。 若是硬體預取被意外地觸發,能做的只有這麼多。一個可能是試著找出這個問題,並稍微改變資料與/或程式佈局。這大概滿困難的。可能有特殊的在地化(localized)解法,像是在 x86 與 x86-64 處理器上使用 ud2 指令35。這個無法自己執行的指令是在一條間接的跳躍指令後被使用;它被作為指令獲取器(fetcher)的一個信號使用,表示處理器不應浪費精力解碼接下來的記憶體,因為執行將會在一個不同的位置繼續。不過,這是個非常特殊的情況。在大部分情況下,必須要忍受這個問題。 能夠完全或部分地停用整個處理器的硬體預取。在 Intel 處理器上,一個特定模型暫存器(Model Specific Register,MSR)便用於此(IA32_MISC_ENABLE,在許多處理器上為位元 9;位元 19 只停用鄰近快取行預取)。這在大多情況下必須發生在系統核心中,因為它是個特權操作。若是數據分析顯示,執行於系統上的一個重要的應用程式因硬體快取而遭受頻寬耗竭與過早的快取逐出,使用這個 MSR 是一種可能性。 35. 或是 non-instruction。這是推薦的未定義操作碼。 ↩ "},"what-programmers-can-do/prefetching/software-prefetching.html":{"url":"what-programmers-can-do/prefetching/software-prefetching.html","title":"6.3.2. 軟體預取","keywords":"","body":"6.3.2. 軟體預取 硬體預取的優勢在於不必調整程式。缺點如同方才描述的,存取模式必須很直觀,而且預取無法橫跨分頁邊界進行。因為這些原因,我們現在有更多可能性,軟體預取它們之中最重要的。軟體預取不需藉由插入特殊的指令來修改原始碼。某些編譯器支援編譯指示(pragma)以或多或少地自動插入預取指令。 在 x86 和 x86-64,intrinsic 函式會由編譯器產生特殊的指令: #include enum _mm_hint { _MM_HINT_T0 = 3, _MM_HINT_T1 = 2, _MM_HINT_T2 = 1, _MM_HINT_NTA = 0 }; void _mm_prefetch(void *p, enum _mm_hint h); 程式能夠在程式中的任何指標上使用 _mm_prefetch intrinsic 函式。許多處理器(當然包含所有 x86 與 x86-64 處理器)都會忽略無效指標產生的錯誤,這令程式開發者的生活好過非常多。若是被傳遞的指標指向合法的記憶體,會命令預取單元將資料載入到快取中,並且 –– 必要的話 –– 逐出其它資料。不必要的預取應該被確實地避免,因為這會降低快取的有效性,而且它會耗費記憶體頻寬(在被逐出的快取行是髒的情況下,可能需要二個快取行的頻寬)。 要與 _mm_prefetch 一起使用的不同提示(hint)是由實作定義的。這表示每個處理器版本能夠(稍微)不同地實作它們。一般能說的是,_MM_HINT_T0 會為包含式快取將資料獲取到所有快取層級,並為獨占式快取獲取到最低層級的快取。若是資料項目在較高層級的快取中,它會被載入到 L1d 中。_MM_HINT_T1 提示將資料拉進 L2 而非 L1d。若是有個 L3 快取,_MM_HINT_T2 能做到類似於此的事情。不過,這些是沒怎麼被明確指定的細節,需要對所使用的實際處理器進行驗證。一般來說,若是資料在使用 _MM_HINT_T0 之後立刻被用到就沒錯。當然這要求 L1d 快取容量要大得足以容納所有被預取的資料。若是立即被使用的工作集容量太大,將所有東西預取到 L1d 就是個壞點子,而應該使用其它二種提示。 第四種提示,_MM_HINT_NTA 能夠吩咐處理器特殊地對待預取的快取行。NTA 代表非暫存對齊(non-temporal aligned),我們已經在 6.1 節解釋過。程式告訴處理器應該盡可能地避免以這個資料污染快取,因為資料只在一段很短的期間內會被使用。對於包含式快取實作,處理器因而能夠在載入時避免將資料讀取進較低層級的快取。當資料從 L1d 逐出時,資料不必被推進 L2 或更高層級的快取中,但能夠直接寫到記憶體中。可能有其它處理器設計師在給定這個提示時能夠佈署的其它手法。程式開發者必須謹慎地使用這個提示:若是目前的工作集容量太大,並強制逐出以 NTA 提示載入的快取行,就要重新從記憶體載入。 圖 6.7:使用預取的平均,NPAD=31 圖 6.7 顯示使用現已熟悉的指標追逐框架(pointer chasing framework)的測試結果。串列是隨機地被擺放在記憶體中的。與先前測試的不同之處在於,程式真的會在每個串列節點上花一些時間(大約 160 週期)。如同我們從圖 3.15 的數據中學到的,一旦工作集容量大於最後一階快取,程式的效能就會受到嚴重的影響。 我們現在能夠試著在計算之前發出預取請求來改善這種狀況。即,我們在迴圈的每一輪預取一個新元素。串列中被預取的節點與正在處理的節點之間的距離必須被謹慎地選擇。假定每個節點在 160 週期內被處理、並且我們必須預取二個快取行(NPAD=31),五個串列元素的距離便足夠。 圖 6.7 的結果顯示預取確實有幫助。只要工作集容量不超過最後一階快取的容量(這台機器擁有 512kB = 219B 的 L2),數字就是相同的。預取指令並不會增加能量測出來的額外負擔。一旦超過 L2 容量,預取省下 50 到 60 週期之間,高達 8%。預取的使用無法隱藏任何損失,但它稍微有點幫助。 AMD 在它們 Opteron 產品線的 10h 家族實作另一個指令:prefetchw。在 Intel 這邊迄今仍沒有這個指令的等價物,也不能透過 intrinsic 使用。prefetchw 指令要求 CPU 將快取行預取到 L1 中,就如同其它預取指令一樣。差異在於快取行會立即變成「M」狀態。若是之後沒有接著對快取行的寫入,這將會是個不利之處。但若是有一或多次寫入,它們將會被加速,因為寫入操作不必改變快取狀態 –– 其在快取行被預取時就被設好。這對於競爭的快取行尤為重要,其中在另一個處理器的快取中的快取行的一次普通的讀取操作會先在二個快取中將狀態改成「S」。 預取可能有比我們這裡達到的微薄的 8% 還要更大的優勢。但它是眾所皆知地難以做得正確,尤其是在預期相同的二元檔在各種各樣的機器上都表現良好的情況。由 CPU 提供的效能計數器能夠幫助程式開發者分析預取。能夠被計數並取樣的事件包含硬體預取、軟體預取、有用的/使用的軟體預取、在不同層級的快取錯失、等等。在 7.1 節,我們將會介紹這些事件。這所有的計數器都是機器特有的。 在分析程式時,應該要先看看快取錯失。找出大量快取錯失來源的所在時,應該試著針對碰上問題的記憶體存取加上預取指令。這應該一次處理一個地方。每次修改的結果應該藉由觀察量測有用預取指令的效能計數器來檢驗。若是那些計數器沒有提升,那麼預取可能是錯的,它並沒有給予足夠的時間來從記憶體載入,或者預取從快取逐出仍然需要的記憶體。 gcc 現今能夠在唯一一種情況下自動發出預取指令。若是一個迴圈疊代在一個陣列上,能夠使用下面的選項: -fprefetch-loop-arrays 編譯器會計算出預取是否合理,以及 –– 如果是的話 –– 它應該往前看多遠。對小陣列而言,這可能是個不利之處,而且若是在編譯期不知道陣列的容量的話,結果可能更糟。gcc 手冊提醒道,這個好處極為仰賴於程式碼的形式,而在某些情況下,程式可能真的會跑得比較慢。程式開發者必須謹慎地使用這個選項。 "},"what-programmers-can-do/prefetching/special-kind-of-prefetch-speculation.html":{"url":"what-programmers-can-do/prefetching/special-kind-of-prefetch-speculation.html","title":"6.3.3. 特殊的預取類型:猜測","keywords":"","body":"6.3.3. 特殊的預取類型:猜測 一個現代處理器的 OoO 執行能力允許在不與彼此衝突的情況下搬移指令。舉例來說(這次使用 IA-64 為例): st8 [r4] = 12 add r5 = r6, r7;; st8 [r18] = r5 這段程式序列將 12 儲存至由暫存器 r4 指定的位址、將 r6 與 r7 暫存器的內容相加、並將它儲存在暫存器 r5 中。最後,它將總和儲存至由暫存器 r18 指定的位址。這裡的重點在於,加法指令能夠在第一個 st8 指令之前 –– 或者同時 –– 執行,因為並沒有資料的依賴關係。但假如必須載入其中一個加數會怎麼樣呢? st8 [r4] = 12 ld8 r6 = [r8];; add r5 = r6, r7;; st8 [r18] = r5 額外的 ld8 指令將值載入到由 r8 指令的位址。在這個載入指令與接下來的 add 指令之間有個明確的資料依賴關係(這便是指令後面的 ;; 的理由,感謝提問)。這裡的關鍵在於,新的 ld8 指令 –– 不若 add 指令 –– 無法被移到第一個 st8 前面。處理器無法在指令解碼的期間足夠快速地決定儲存與載入是否衝突 –– 即,r4 與 r8 是否可能有相同的值。假如它們有相同的值,st8 指令會決定載入到 r6 的值。更糟的是,在載入錯失快取的情況下,ld8 可能也會隨之帶來漫長的等待時間。IA 64 架構針對這種情況支援猜測式載入(speculative load): ld8.a r6 = [r8];; [... other instructions ...] st8 [r4] = 12 ld8.c.clr r6 = [r8];; add r5 = r6, r7;; st8 [r18] = r5 新的 ld8.a 與 ld8.c.clr 指令是一對的,並取代前一段程式序列的 ld8 指令。ld8.a 為猜測式載入。這個值無法被直接使用,但處理器能開始運作。這時,當到達 ld8.c.clr 指令的時候,這個內容可能已經被載入(假定這個間隔中有足夠數量的指令)。這個指令的引數(argument)必須與 ld8.a 指令相符。若是前面的 st8 指令沒有覆寫這個值(即 r4 與 r8 相同譯註),就什麼也不必做。猜測式載入做它的工作,而載入的等待時間被隱藏。若是載入與儲存衝突,ld8.c.clr 會重新從記憶體載入值,而我們最終會得到一個正常的 ld8 指令的語義。 猜測式載入(仍?)沒有被廣泛使用。但如同這個例子所顯示的,它是個非常簡單而有效的隱藏等待時間的方法。預取基本上是等同的東西,並且對有著少量暫存器的處理器而言,猜測式載入可能沒多大意義。猜測式載入有直接將值載入到暫存器中,而不載入到可能會被再次逐出的快取行(舉例來說,當執行緒被移出排程〔deschedule〕的時候)這個(有時很大的)優點。如果能夠使用猜測的話,應該要使用它。 譯註. r4 與 r8 相同指的是「值會被覆寫的情況」。 ↩ "},"what-programmers-can-do/prefetching/helper-threads.html":{"url":"what-programmers-can-do/prefetching/helper-threads.html","title":"6.3.4. 輔助執行緒","keywords":"","body":"6.3.4. 輔助執行緒 在嘗試使用軟體預取時,往往會碰到程式複雜度的問題。若是程式必須迭代於一個資料結構上(在我們的情況中是個串列),必須在同個迴圈中實作二個獨立的迭代:執行作業的普通迭代、與往前看以使用預取的第二個迭代。這輕易地變得足夠複雜到容易產生失誤。 此外,決定要往前看多遠是必要的。太短的話,記憶體將無法及時被載入。太遠的話,剛載入的資料可能會被再一次逐出。另一個問題是,雖然它不會阻擋或等待記憶體載入,但預取指令很花時間。指令必須被解碼,假如解碼器太忙碌的話 –– 舉例來說,由於良好撰寫/產生的程式碼 –– 這可能很明顯。最後,迴圈的程式容量會增加。這降低 L1i 的效率。若藉由一次發出多個預取指令來試著避免部分成本,則會碰到顯著的預取請求數的問題。 一個替代方法是完全獨立地執行一般的操作與預取。這能使用二條普通的執行緒來進行。執行緒顯然必須被排程,以令預取執行緒填充一個被二條執行緒存取的快取。有二個值得一提的特殊解法: 在相同的處理器核上使用 HT (見 3.3.4 節,Hyper-Threading)。在這種情況下,預取能夠進入 L2(或者甚至是 L1d)。 使用比 SMT 執行緒「更愚笨的(dumber)」執行緒,其除預取與其它簡單的操作之外什麼也不做。這是個處理器廠商可能會探究的選項。 HT 的使用是尤其令人感興趣的。如同我們已經在 3.3.4 節看到的,假如 HT 執行獨立的程式碼的話,快取的共享是個問題。反而,在一條執行緒被用作一條預取輔助執行緒(helper thread)時,這並不是個問題。與此相反,這是個令人渴望的結果,因為最低層級的快取被預載。此外,由於預取執行緒大多是空閒或者在等待記憶體,所以假如不必自己存取主記憶體的話,其餘 HT 的一般操作並不會太受干擾。後者正好是預取輔助執行緒所預防的。 唯一棘手的部分是確保輔助執行緒不會往前跑得太遠。它不能完全污染快取,以致最早被預取的值被再次逐出。在 Linux 上,使用 futex 系統呼叫 [7] 或是 –– 以稍微高一些的成本 –– 使用 POSIX 執行緒同步基本指令(primitive),是很容易做到同步的。 圖 6.8:使用輔助執行緒的平均,NPAD=31 這個方法的好處能夠在圖 6.8 中看到。這是與圖 6.7 中相同的測試,只不過加上額外的結果。新的測試建立一條額外的輔助執行緒,往前執行大約 100 個串列項目,並讀取(不只預取)每個串列元素的所有快取行。在這種情況下,我們每個串列元素有二個快取行(在一台有著 64 位元組快取行容量的 32 位元機器上,NPAD=31)。 二條執行緒被排程在相同處理器核的二條 HT 上。測試機僅有一顆處理器核,但結果應該與多於一顆處理器核的結果大致相同。親和性函式 –– 我們將會在 6.4.3 節介紹 –– 被用來將執行緒綁到合適的 HT 上。 要確定作業系統知道哪二個 (或更多) 處理器為 HT ,可以使用來自 libNUMA 的 NUMA_cpu_level_mask 介面(見附錄 D)。 #include ssize_t NUMA_cpu_level_mask(size_t destsize, cpu_set_t *dest, size_t srcsize, const cpu_set_t*src, unsigned int level); 這個介面能用來決定透過快取與記憶體連結的 CPU 階層架構。這裡感興趣的是對應於 HT 的一階快取。為在二條 HT 上排程二條執行緒,能夠使用 libNUMA 函式(為簡潔起見,省略錯誤處理): cpu_set_t self; NUMA_cpu_self_current_mask(sizeof(self), &self); cpu_set_t hts; NUMA_cpu_level_mask(sizeof(hts), &hts, sizeof(self), &self, 1); CPU_XOR(&hts, &hts, &self); 在執行這段程式之後,我們有二個 CPU 位元集。self 能用來設定目前執行緒的親和性,而 hts 中的遮罩能被用來設定輔助執行緒的親和性。這在理想上應該在執行緒被建立前發生。在 6.4.3 節,我們會介紹設定親和性的介面。若是沒有可用的 HT ,NUMA_cpu_level_mask 函式會回傳 1。這能夠用以作為避免這個最佳化的徵兆。 這個基準測試的結果可能出乎意料(也可能不會)。若是工作集塞得進 L2,輔助執行緒的間接成本將效能降低 10% 到 60% 之間(主要在比較低的那端,再次忽略最小的工作集容量,雜訊太多)。這應該在預料之中,因為若是所有資料都已經在 L2 快取中,預取輔助執行緒僅僅使用系統資源,卻沒有對執行有所貢獻。 不過,一旦不再足夠的 L2 容量耗盡,情況就改變。預取輔助執行緒協助將執行時間降低大約 25%。我們仍舊看到一條上升的曲線,只不過是因為無法足夠快速地處理預取。不過,主執行緒執行的算術操作與輔助執行緒的記憶體載入操作彼此互補。資源衝突是最小的,其導致這種相輔相成的結果。 這個測試的結果應該能夠被轉移到更多其它的情境。由於快取污染而經常無用的 HT ,在這些情境中表現出眾,並且應該被善用。附錄 D 介紹的 NUMA 函式庫令執行緒兄弟的找尋非常容易(見這個附錄中的範例)。若是函式庫不可用,sys 檔案系統令一支程式能夠找出執行緒的兄弟(見表 5.3 的 thread_siblings 欄位)。一旦能夠取得這個資訊,程式就必須定義執行緒的親和性,然後以二種模式執行迴圈:普通的操作與預取。被預取的記憶體總量應該視共享的快取容量而定。在這個例子中,L2 容量是有關的,程式能夠使用 sysconf(_SC_LEVEL2_CACHE_SIZE) 來查詢容量。輔助執行緒的進度是否必須被限制取決於程式。一般來說,最好確定有一些同步,因為排程細節可能會導致顯著的效能降低。 "},"what-programmers-can-do/prefetching/direct-cache-access.html":{"url":"what-programmers-can-do/prefetching/direct-cache-access.html","title":"6.3.5. 直接快取存取","keywords":"","body":"6.3.5. 直接快取存取 在現代作業系統中,快取錯失的一個來源是到來的資料流量的處理。像網路介面卡(Network Interface Card,NIC)與硬碟控制器等現代硬體,能夠在不涉及 CPU 的情況下,直接將接收或讀取的資料寫入到記憶體中。這對於我們現今擁有的裝置的效能而言至關重要,但它也造成問題。假使有個從網路傳入的封包:作業系統必須檢查封包的標頭(header)以決定要如何處理它。NIC 將封包擺進記憶體,然後通知處理器它的到來。處理器沒有機會去預取資料,因為它並不知道資料將何時抵達,甚至可能不會確切知道它將會被存在哪。結果是在讀取標頭時的一次快取錯失。 Intel 已經在它們的晶片組與 CPU 中加上技術以緩解這個問題 [14]。構想是將封包的資料填入將會被通知到來的封包的 CPU 的快取。封包的承載內容在這裡並不重要,這個資料一般將會由更高階的函數 –– 要不是在系統核心中、就是在使用者層級 –– 處理。封包標頭被用來決定封包必須以什麼方式處理,因此這個資料是立即所需的。 網路 I/O 硬體已有 DMA 以寫入封包。這表示它直接地與潛在整合在北橋中的記憶體控制器進行溝通。記憶體控制器的另一邊是通過 FSB 到處理器的介面(假設記憶體控制器沒有被整合到 CPU 自身)。 (a) 啟動 DMA (b) 執行 DMA 與 DCA 圖 6.9:直接快取存取 直接快取存取(Direct Cache Access,DCA)背後的想法是,擴充 NIC 與記憶體控制器之間的通訊協定。在圖 6.9 中,第一張圖顯示在一台有著南北橋的正規機器上的 DMA 傳輸的起始。NIC 被連接到南橋上(或作為其一部分)。它啟動 DMA 存取,但提供關於封包標頭的新資訊,其應該被推進處理器的快取中。 在第二步中,傳統的行為僅會是以連結到記憶體的連線完成 DMA 傳輸。對於被設置 DCA 旗標的 DMA 傳輸,北橋會以特殊的、新的 DCA 旗標在 FSB 上同時送出資料。處理器一直窺探著 FSB,並且若是它認出 DCA 旗標,它會試著將寄給處理器的資料載入到最低階快取中。事實上,DCA 旗標是個提示;處理器能夠自由地忽略它。在 DMA 傳輸完成之後,會以信號通知處理器。 在處理封包時,作業系統必須先確定是哪種封包。若是 DCA 提示沒有被忽略的話,作業系統必須執行、以識別封包的載入操作很有可能會命中快取。將每個封包數以百計個循環的節約,乘上每秒能處理的成千上萬個封包,節省的加總量是個非常可觀的數字,尤其在談到等待時間的時候。 少了 I/O 硬體(在這個例子中為 NIC)、晶片組與 CPU 的整合,這種最佳化是不可能的。因此,假如需要這個技術的話,確保明智地挑選平台是必要的。 "},"what-programmers-can-do/multi-thread-optimizations.html":{"url":"what-programmers-can-do/multi-thread-optimizations.html","title":"6.4. 多執行緒最佳化","keywords":"","body":"6.4. 多執行緒最佳化 關於多執行緒,有三個快取使用的面向是很重要的: 並行(Concurrency) 最小處理(Atomicity) 頻寬 這些面向也適用於多行程的情況,但因為多行程(大多數)是獨立的,因此為它們最佳化並沒有那麼容易。可能的多行程最佳化是那些可用於多執行緒情況的子集。所以這裡我們會專門討論後者。 在這種前後文下,並行指的是在一次執行多於一條執行緒時,一個行程所歷經的記憶體影響。執行緒的一個特性是它們全都共享相同的定址空間,因此全都能夠存取相同的記憶體。在理想的情況下,執行緒所使用的記憶體區域在多數時候都是不同的。在這種情況下,那些執行緒僅稍許耦合(couple)(舉例來說,共有的輸入與/或輸出)。若是多於一條執行緒使用相同的資料,就需要協調:這即是最小處理發揮作用的時候。最後,視機器架構而定,可用的記憶體與可用於處理器的處理器之間的匯流排頻寬是有限的。我們將會在接下來的章節分別論及這三個面向 –– 雖然它們是緊密相連的。 "},"what-programmers-can-do/multi-thread-optimizations/concurrency-optimizations.html":{"url":"what-programmers-can-do/multi-thread-optimizations/concurrency-optimizations.html","title":"6.4.1. 並行最佳化","keywords":"","body":"6.4.1. 並行最佳化 一開始,我們將會在本節討論二個個別的議題,其實際上需要對立的最佳化。一個多執行緒應用程式在一些它的執行緒中使用共有的資料。一般的快取最佳化要求將資料保存在一起,使得應用程式的記憶體使用量很小,從而最大化在任意時間塞得進快取的記憶體總量。譯註1 不過,使用這個方法有個問題:若是多條執行緒寫入到一個記憶體位置,每個相對應處理器核的 L1d 中的快取行必須處於「E」(獨占)狀態。這表示會送出許多的 RFO 訊息。在最糟的情況下,每次寫入存取都會送出一個訊息。所以一個普通的寫入將會突然變得非常昂貴。若是使用相同的記憶體位置,同步就是必須的(可能透過 atomic 操作譯註3的使用,其會在下個章節討論到)。不過,當所有執行緒都使用不同的記憶體位置、並且可能是獨立的時候,問題也顯而易見。 圖 6.10:並行快取行存取的間接成本 圖 6.10 顯示這種「假共享(false sharing)」的結果。測試程式(顯示於 A.3 節)建立若干執行緒,其除遞增一個記憶體位置(5 億次)外什麼也不做。量測的時間是從程式啟動、直到程式等待最後一條執行緒結束之後。執行緒被釘在獨立的處理器上。機器擁有四個 P4 處理器。藍色值表示被指派到每條執行緒的記憶體分配位在個別快取行上的執行時間。紅色部分為執行緒的位置被移到僅一個快取行時出現的損失。 藍色的量測(使用獨立的快取行時所需的時間)與預期的相符。程式在無損失的情況下延展至多條執行緒。每個處理器都將它的快取行保存在它擁有的 L1d 中,而且沒有頻寬問題,因為不必讀取太多程式碼或資料(事實上,它們全都被快取)。量測的些微提升其實是系統的雜訊、和可能的一些預取影響(執行緒使用連續的快取行)。 使用唯一一個快取行所需的時間、以及每條執行緒一個個別的快取行所需的時間相除所計算出的量測的間接成本分別是 390%、734%、以及 1,147%。乍看之下,這些很大的數字可能很令人吃驚,但考慮到需要的快取交互影響,這應該很顯而易見。已經完成寫入到快取行之後,就從一個處理器的快取拉出快取行。譯註2在任何給定的時刻,除了擁有快取行的處理器以外,所有處理器都會被延遲,無法做任何事。每個額外的處理器都會導致更多的延遲。 圖 6.11:四個處理器核的間接成本 由於這些量測,清楚的是這種情況必須在程式中避免。考慮到巨大的損失,在許多情況下,這個問題是很顯而易見的(至少,效能分析會顯示程式位置),但有個使用現代硬體的陷阱。圖 6.11 顯示當程式執行在一台單一處理器節點具備四核的機器上(Intel Core 2 QX 6700)的等價量測。即使使用這個處理器的二個個別的 L2,測試案例也沒有顯示出任何可延展性的問題。當相同的快取行被使用超過一次時有些許的間接成本,但它並沒有隨著處理器核的數量增加。36若是用多於一個這種處理器,我們自然會看到類似於那些在圖 6.10 中的結果。儘管越來越多多核處理器的使用,許多機器還是會繼續使用多處理器。因此,正確的處理這種狀況是很重要的,這可能意味著要在真實的 SMP 機器上測試程式。 有個針對這個問題的非常簡單的「修正」:將每個變數擺在它們自己的快取行。這是與先前提到的發揮作用的最佳化的衝突之處,具體來說就是應用程式的記憶體使用量會增加許多。這是不能忍受的;因此有必要想出一個更聰明的解法。 需要確定哪些變數一次只會被唯一一條執行緒使用到,始終只有一條執行緒使用的那些變數、也可能是那些不時會被爭奪的變數。針對這些情況的每一個的不同解法是可能而且有用的。以變數的區分來說,最基本的標準是:它們是否曾被寫入過、以及這有多常發生。 不曾被寫入、以及那些僅會被初始化一次的變數基本上是常數(constant)。由於僅有寫入操作需要 RFO 訊息,因此能夠被在快取中共享常數(「S」狀態)。所以,不必特別處理這些變數;將它們歸在一起很好。若是程式開發者正確地以 const 標記這些變數,工具鏈將會把這些變數從普通的變數移出到 .rodata(唯讀資料)或 .data.rel.ro(重定位〔relocation〕後唯讀) 資料段(section)。37不需其他特別的行為。若是出於某些理由,變數無法正確地以 const 標記,程式開發者能夠藉由將它們指派到一個特殊的資料段來影響它們的擺放。 當連結器構造出最後的二元檔時,它首先會附加來自所有輸入檔、具有相同名稱的資料段;那些資料段接著會以連結器腳本所決定的順序排列。這表示,藉由將所有基本上為常數、但沒被這樣標記的變數移到一個特殊的資料段,程式開發者便能夠將那些變數全部群組在一起。它們之中不會有個經常被寫入的變數。藉由適當地對齊在這個資料段中的第一個變數,就可能保證不會發生假共享。假定這個小例子: int foo = 1; int bar __attribute__((section(\".data.ro\"))) = 2; int baz = 3; int xyzzy __attribute__((section(\".data.ro\"))) = 4; 假如被編譯的話,這個輸入檔定義四個變數。有趣的部分是,變數 foo 與 baz、以及 bar 與 xyzzy 被各自群組在一起。少了那個屬性定義,編譯器就會以原始碼中定義的順序將四個變數全都分配在一個叫做 .data 的資料段中。38使用現有這段程式,變數 bar 與 xyzzy 會被放置在一個叫做 .data.ro 的資料段中。將這個資料段叫做 .data.ro 或多或少有些隨意。一個 .data. 的前綴保證 GNU 連結器會將這個資料段與其它資料段擺在一起。 相同的技術能被用於分離出主要是讀取、但偶爾也會被寫入的變數。只要選擇一個不同的資料段名稱就可以。在某些像是 Linux 系統核心的情況中,這種分離看起來很合理。 若是一個變數永遠僅會被一條執行緒用到的話,有另一個指定變數的方式。在這種情況下,使用執行緒區域變數(thread-local variable)是可能而且有用的(見 [8])。gcc 中的 C 與 C++ 語言允許使用 __thread 關鍵字將變數定義為各條執行緒的。 int foo = 1; __thread int bar = 2; int baz = 3; __thread int xyzzy = 4; 變數 bar 與 xyzzy 並非被分配在普通的資料段中;而是每條執行緒擁有它自己的、儲存這種變數的分離區域。這些變數能夠擁有靜態初始子(static initializer)。所有執行緒區域變數都能夠被所有其它的執行緒定址,但除非一條執行緒將執行緒區域變數的指標傳遞給那些其它的執行緒,其它執行緒也沒法找到這個變數。由於變數為執行緒區域的,假共享就不是個問題 –– 除非程式人為地造成問題。這個解法很容易設置(編譯器與連結器做了所有的事),但它有它的成本。當建立執行緒時,它必須花上一些時間來設置執行緒區域變數,這需要時間與記憶體。此外,定址執行緒區域變數通常比使用全域或自動變數更昂貴(如何自動地將成本最小化 –– 如果可能的話 –– 的解釋見 [8])。 另一個使用執行緒區域儲存區(thread-local storage,TLS)的缺點是,假如變數的使用轉移給另一條執行緒,在舊執行緒的目前值是無法被新執行緒取得的。每條執行緒的變數副本都是不同的。通常這根本不是問題,但假如是的話,轉移到新的執行緒就需要協調,能夠在這個時刻複製目前值。 一個更大的問題是可能浪費資源。假如在任何時候都僅有一條執行緒會使用這個變數,所有執行緒都必須付出記憶體的代價。若是一條執行緒不使用任何 TLS 變數的話,TLS 記憶體區域的惰性分配(lazy allocation)會防止它成為問題(除了在應用程式本身的 TLS)。若是一條執行緒僅在 DSO 中使用一個 TLS 變數,所有在這個物件中的其它 TLS 變數也都會被分配記憶體。假如大規模地使用 TLS,這可能會潛在地累加。 一般來說,可以給出的最好的建議是 至少分離唯讀(初始化之後)與讀寫變數。可能將這種分離擴展到,以主要是讀取的變數作為第三種類別。 將一起用到的讀寫變數一起群組在一個結構中。使用結構,是確保在某種程度上,被所有 gcc 版本一致翻譯成,所有那些變數的記憶體區域都緊靠在一起的唯一方法。 將經常被不同執行緒寫入的讀寫變數移到它們自己的快取行。這可能代表要在末端加上填充,以填滿快取行的剩餘部分。若是結合步驟 2,這經常不是真的浪費。擴展上面的例子,我們可能會產生下列程式(假定 bar 與 xyzzy 要一起使用): int foo = 1; int baz = 3; struct { struct al1 { int bar; int xyzzy; }; char pad[CLSIZE sizeof(struct al1)]; } rwstruct __attribute__((aligned(CLSIZE))) = { { .bar = 2, .xyzzy = 4 } }; 某些程式的改變是必要的(bar 的參考必須被取代為 rwstruct.bar,xyzzy 亦同),但就這樣。編譯器與連結器會做完剩下的事情。39 若是一個變數被多條執行緒用到,但每次使用都是獨立的,則將變數移入 TLS。 譯註1. 因為快取的最小單位為快取行。因此若是資料擺在一起,代表它們所佔用的快取行數量較少,因此一次能快取的資料量就變多。 ↩ 譯註2. 因為所有執行緒寫入的資料都在同個快取行內。因此剛寫入的快取行立刻就會因為其它執行緒也要對相同的快取行進行寫入,而變為「I(無效)」狀態。 ↩ 譯註3. 原子的英文 \"atom\" 源於希臘文 ἄτομος (拉丁轉寫為 atomos),意思是「不可分割的單位」,十五世紀晚期 atomos 這詞去除後綴轉寫為現代英語,成為 atom。電腦科學進一步借用 atomic 一詞來表示「不可再拆分的」,於是 \"atomic operation\" 寓意為「不可再拆分的執行步驟」,也就是「最小操作」,即某個動作執行時,中間沒有辦法分割。倘若我們將 atomic operation 翻譯為「原子操作」,可能會讓人聯想到高科技或者核能 (nuclear),但事實根本不是這個意思,於是這裡保留原文。 ↩ 36. 我無法解釋在四顆處理器核全都用上時的較低的數字,但它是能夠重現的。 ↩ 37. 資料段,由它們的名字所識別,為一個 ELF 檔案中包含程式與資料的 atomic 單元。 ↩ 38. 這並不受 ISO C 標準保證,但 gcc 是這麼做的。 ↩ 39. 到目前為止,這段程式都必須在命令列以 -fms-extensions 編譯。 ↩ "},"what-programmers-can-do/multi-thread-optimizations/atomicity-optimizations.html":{"url":"what-programmers-can-do/multi-thread-optimizations/atomicity-optimizations.html","title":"6.4.2. 最小操作的最佳化","keywords":"","body":"6.4.2. 最小操作的最佳化 假如多條執行緒同時修改相同的記憶體位置,處理器並不保證任何具體的結果。這是個為了避免在所有情況的 99.999% 中的不必要成本而做出的慎重決定。舉例來說,若有個在「S」狀態的記憶體位置、並且有二條執行緒同時必須增加它的值的時候,在從快取讀出舊值以執行加法之前,執行管線不必等待快取行變為「E」狀態。而是會讀取目前快取中的值,並且一旦快取行變為「E」狀態,新的值便會被寫回去。若是在二條執行緒中的二次快取讀取同時發生,結果並不如預期;其中一個加法會沒有效果。 對於可能發生並行操作的情況,處理器提供 atomic 操作。舉例來說,這些 atomic 操作可能在直到能以像是 atomic 地對記憶體位置進行加法的方式執行加法之前,不會讀取舊值。除了等待其它處理器核之外,某些處理器甚至會將特定位址的 atomic 操作發給在主機板上的其它裝置。這全都會令 atomic 操作變慢。 處理器廠商決定提供不同的一組 atomic 操作。早期的 RISC 處理器,與代表簡化(reduced)的「R」相符,提供非常少的 atomic 操作,有時僅有一個 atomic 的位元設置與測試。40在光譜的另一端,我們有提供大量 atomic 操作的 x86 與 x86-64。普遍來說可用的 atomic 操作能夠歸納成四類: 位元測試 這些操作 atomic 地設置或者清除一個位元,並回傳一個代表位元先前是否被設置的狀態。 載入鎖定/條件儲存(Load Lock/Store Conditional,LL/SC)41 LL/SC 操作成對使用,其中特殊的載入指令用以開始一個事務(transaction),而最後的儲存僅會在這個位置沒有在這段期間內被修改的情況才會成功。儲存操作指出成功或失敗,所以程式能夠在必要時重複它的工作。 比較並交換(Compare-and-Swap,CAS 這是個三元(ternary)操作,僅在目前值與第三個參數值相同的時候,將一個以參數提供的值寫入到一個位址中(第二個參數); atomic 算術 這些操作僅在 x86 與 x86-64 可用,其能夠在記憶體位置上執行算術與邏輯操作。這些處理器擁有對這些操作的非 atomic 版本的支援,但 RISC 架構則否。所以,怪不得它們的可用性是有限的。 一個處理器架構可能會支援 LL/SC 指令或 CAS 指令其一,不會二者都支援。二種方法基本上相同;它們能提供一樣好的 atomic 算術操作實作,但看起來 CAS 是近來偏好的方法。其它所有的操作都能夠間接地以它來實作。例如,一個 atomic 加法: int curval; int newval; do { curval = var; newval = curval + addend; } while (CAS(&var, curval, newval)); 呼叫 CAS 的結果指出操作是否成功。若是它回傳失敗(非零的值),迴圈會再次執行、執行加法、並且再次嘗試呼叫 CAS。這會重複到成功為止。這段程式值得注意的是,記憶體位置的位址必須以二個獨立的指令來計算。42對於 LL/SC,程式看起來大致相同: int curval; int newval; do { curval = LL(var); newval = curval + addend; } while (SC(var, newval)); 這裡我們必須使用一個特殊的載入指令(LL),而且我們不必將記憶體位置的目前值傳遞給 SC,因為處理器知道記憶體位置是否曾在這期間被修改過。 for (i = 0; i for (i = 0; i for (i = 0; i 1. 做加法並讀取結果 2. 做加法並回傳舊值 3. atomic 地以新值替換 圖 6.12:在一個迴圈中 atomic 遞增 The big differentiators are x86 and x86-64, where we have the atomic operations and, here, it is important to select the proper atomic operation to achieve the best result. 圖 6.12 顯示實作一個 atomic 遞增操作的三種方法。在 x86 與 x86-64 上,三種方法全都會產生不同的程式,而在其它的架構上,程式則可能完全相同。效能的差異很大。下面的表格顯示由四條並行的執行緒進行 1 百萬次遞增的執行時間。程式使用 gcc 的內建函式(__sync_*) 1. Exchange Add 2. Add Fetch 3. CAS 0.23s 0.21s 0.73s 前二個數字很相近;我們看到回傳舊值稍微快一點。重要的資訊在被強調的那一欄,使用 CAS 時的成本。毫不意外,它要昂貴許多。對此有諸多理由 有二個記憶體操作; CAS 操作本身比較複雜,甚至需要條件操作; 整個操作必須在一個迴圈中完成,以防二個同時的存取造成一次 CAS 呼叫失敗。 現在讀者可能會問個問題:為什麼有人會使用這種利用 CAS 的複雜、而且較長的程式?對此的回答是:複雜性通常會被隱藏。如同先前提過的,CAS 是橫跨所有有趣架構的統一 atomic 操作。所以有些人認為,以 CAS 定義所有的 atomic 操作就足夠。這令程式更為簡單。但就如數字所顯示的,這絕對不是最好的結果。CAS 解法的記憶體管理的間接成本很大。下面示意僅有二條執行緒的執行,每條在它們自己的處理器核上。 執行緒 #1 執行緒 #2 var 快取狀態 v = var 在 Proc 1 上為「E」 n = v + 1 v = var 在 Proc 1+2 上為「S」 CAS(var) n = v + 1 在 Proc 1 上為「E」 CAS(var) 在 Proc 2 上為「E」 我們看到,在這段很短的執行期間內,快取行狀態至少改變三次;二次改變為 RFO。再加上,因為第二個 CAS 會失敗,所以這條執行緒必須重複整個操作。在這個操作的期間,相同的情況可能會再度發生。 相比之下,在使用 atomic 算術操作時,處理器能夠將執行加法(或者其它什麼的)所需的載入與儲存操作保持在一起。能夠確保同時發出的快取行請求直到 atomic 操作完成前都會被阻擋。 因此,在範例中的每次迴圈迭代至多會產生一次 RFO 快取請求,就沒有別的。 這所有的一切都意味著,在一個能夠使用 atomic 算術與邏輯操作的層級定義機器抽象是很重要的。CAS 不該被普遍地用作統一的機制。 對於大多數處理器而言, atomic 操作本身一直是 atomic。對於不需要 atomic 的情況,只有藉由提供完全獨立的程式路徑時,才能夠避免這點。 This means more code, a conditional, and further jumps to direct execution appropriately. 對於 x86 與 x86-64,情況就不同:相同的指令能夠以 atomic 與非 atomic 的方式使用。為了令它們 atomic 化,便對指令用上一個特殊的前綴:lock 前綴。假如在一個給定的情況下, atomic 需求是不必要的,這為 atomic 操作提供避免高成本的機會。例如,在函式庫中,在需要時必須一直是執行緒安全(thread-safe)的程式就能夠受益於此。沒有撰寫程式時所需的資訊,決策能夠在執行期進行。技巧是跳過 lock 前綴。這個技巧適用於 x86 與 x86-64 允許以 lock 前綴的所有指令。 cmpl $0, multiple_threads je 1f lock 1: add $1, some_var 如果這段組合語言程式看起來很神秘,別擔心,它很簡單的。第一個指令檢查一個變數是否為零。非零在這個情況中表示有多於一條執行中的執行緒。若是這個值為零,第二個指令就會跳到標籤 1。否則,就執行下一個指令。這就是狡猾的部分。若是 je 沒有跳躍,add 指令便會以 lock 前綴執行。否則,它會在沒有 lock 前綴的情況下執行。 增加像是條件跳躍這樣一個潛在昂貴的操作(在分支預測錯誤的情況下是很昂貴的)看似事與願違。確實可能如此:若是大多時候都有多條執行緒在執行中,效能會進一步降低,尤其在分支預測不正確的情況。但若是有許多僅有一條執行緒在使用中的情況,程式是明顯比較快的。使用一個 if-then-else 構造的替代方法在二種情況下都會引入額外的非條件跳躍,這可能更慢。假定一次 atomic 操作花費大約 200 個週期,使用這個技巧(或是 if-then-else 區塊)的交叉點是相當低的。這肯定是個要記在心上的技術。不幸的是,這表示無法使用 gcc 的 __sync_* 內建函式。 40. HP Parisc 仍然沒有提供更多的操作... ↩ 41. 有些人會使用「鏈結(linked)」而非「鎖定」,這是一樣的。 ↩ 42. x86 與 x86-64 上的 CAS 操作碼(opcode)能夠避免第二次與後續迭代中的值的載入,但在這個平台上,我們能夠用一個單一的加法操作碼、以一個較簡單的方式來撰寫 atomic 加法。 ↩ "},"what-programmers-can-do/multi-thread-optimizations/bandwidth-considerations.html":{"url":"what-programmers-can-do/multi-thread-optimizations/bandwidth-considerations.html","title":"6.4.3. 頻寬考量","keywords":"","body":"6.4.3. 頻寬考量 當使用多條執行緒、並且它們不會因為在不同的處理器核上使用相同的快取行而造成快取爭奪時,仍然會有潛在的問題。每個處理器擁有連接到與這個處理器上所有處理器核與 HT 共享的記憶體的最大頻寬。取決於機器架構(如,圖 2.1 中的那個),多核可能會共享連結到記憶體或北橋的相同的匯流排。 處理器核本身即便在完美的情況下全速運轉,到記憶體的連線也無法在不等待的前提下滿足所有載入與儲存的請求。現在,將可用的頻寬進一步以處理器核、HT、以及共享一條到北橋的連線的處理器的數量劃分,平行突然變成一個大問題。有效率程式的效能可能會受限於可用的記憶體頻寬。 圖 3.32 顯示增加處理器的 FSB 速度能幫上大忙。這就是為什麼隨著處理器核數量的成長,我們也會看到 FSB 速度上的提升。儘管如此,若是程式使用很大的工作集,並且被充分最佳化過,這也永遠不會足夠。程式開發者必須準備好識別由有限頻寬所致的問題。 現代處理器的效能量測計數器能夠觀察到 FSB 的爭奪。在 Core 2 處理器上,NUS_BNR_DRV 事件計算一顆處理器核因為匯流排尚未準備就緒而必須等待的週期數。這指出匯流排被重度使用,而且載入或儲存至主記憶體要花費比平常還要更長的時間。Core 2 處理器支援更多事件,能夠計算像 RFO 或一般的 FSB 使用率等特定的匯流排行為。在開發期間研究一個應用程式的可延展性的可能性的時候,後者可能會派上用場。若是匯流排使用率已接近 1.0,可延展性的機會是最小的。 若是識別出一個頻寬問題,有幾件能夠做到的事情。它們有時候是矛盾的,所以某些實驗可能是必要的。一個解法是去買更快的電腦,假如有什麼可買的話。取得更多的 FSB 速度、更快的 RAM 模組、或許還有處理器本地的記憶體,大概 –– 而且很可能會 –– 有幫助。不過,這可能成本高昂。若是程式僅在一台機器(或少數幾台機器)上需要,硬體的一次性開銷可能會比重寫程式的成本還低。不過一般來說,最好是對程式下手。 在最佳化程式碼本身以避免快取錯失之後,達到更好頻寬使用率的唯一剩餘選項是將執行緒更妥善地放在可用的處理器核上。預設情況下,系統核心中的排程器會根據它自己的策略,將一條執行緒指派給一個處理器。將一條執行緒從一顆處理器核移到另一顆是被盡可能避免的。不過,排程器並不真的知道關於工作負載的任何事情。它能夠從快取錯失等收集資訊,但這在許多情況下並不是非常有幫助。 圖 6.13:沒效率的排程 一個可能導致很大的記憶體匯流排使用率的情況,是在二條執行緒被排程在不同的處理器(或是在不同快取區域的核)上、而且它們使用相同的資料集的時候。圖 6.13 顯示這種狀況。處理器核 1 與 3 存取相同的資料(以相同顏色的存取指示與記憶體區域表示)。同樣地,處理器核 2 與 4 存取相同的資料。但執行緒被排程在不同的處理器上。這表示每次資料集都必須要從記憶體讀取二次。這種狀況能夠被更好地處理。 圖 6.14:有效率的排程 在圖 6.14 中,我們看到理想上來看應該要是怎麼樣。現在被使用的總快取容量減少,因為現在處理器核 1 與 2 以及 3 與 4 都在相同的資料上運作。資料集只需從記憶體讀取一次。 這是個簡單的例子,但透過擴充,它適用於許多情況。如同先前提過的,作業系統核心中的排程器對資料的使用並沒有深刻的理解,所以程式開發者必須確保排程是被有效率地完成的。沒有很多作業系統核心的介面可用於傳達這個需求。事實上,只有一個:定義執行緒親和性。 執行緒親和性表示,將一條執行緒指派給一顆或多顆處理器核。排程器接著將會在決定在哪執行這條執行緒的時候,(只)在那些處理器核中選擇。即使有其它閒置的處理器核,它們也不會被考慮。這聽來可能像是個缺陷,但這是必須償付的代價。假如太多執行緒排外地執行在一組處理器核上,剩餘的處理器核可能大多數都是閒置的,而除了改變親和性之外就沒什麼能做的。預設情況下,執行緒能夠執行在任一處理器核上。 有一些查詢與改變一條執行緒的親和性的介面: #define _GNU_SOURCE #include int sched_setaffinity(pid_t pid, size_t size, const cpu_set_t *cpuset); int sched_getaffinity(pid_t pid, size_t size, cpu_set_t *cpuset); 這二個介面必須要被用在單執行緒的程式上。pid 引數指定哪個行程的親和性應該要被改變或測定。呼叫者顯然需要適當的權限來做這件事。第二與第三個參數指定處理器核的位元遮罩。第一個函數需要填入位元遮罩,使得它能夠設定親和性。第二個函數以選擇的執行緒的排程資訊來填充位元遮罩。這些介面都被宣告在 中。 cpu_set_t 型別也和一些操作與使用這個型別物件的巨集一同被定義在這個標頭檔中。 #define _GNU_SOURCE #include #define CPU_SETSIZE #define CPU_SET(cpu, cpusetp) #define CPU_CLR(cpu, cpusetp) #define CPU_ZERO(cpusetp) #define CPU_ISSET(cpu, cpusetp) #define CPU_COUNT(cpusetp) CPU_SETSIZE 指定有多少 CPU 能夠以這個資料結構表示。其它三個巨集運用 cpu_set_t 物件。要初始化一個物件,應該使用 CPU_ZERO;其它二個巨集應該用以選擇或取消選擇個別的處理器核。CPU_ISSET 測試一個指定的處理器是否為集合的一部分。CPU_COUNT 回傳集合中被選擇的處理器核數量。cpu_set_t 型別為 CPU 數量的上限提供一個合理的預設值。隨著時間推移,肯定會證實它太小;在這個時間點,這個型別將會被調整。這表示程式必須一直將這個容量放在心上。上述的便利巨集根據 cpu_set_t 的定義,隱式地處理這個容量。若是需要更動態的容量管理,應該使用一組擴充的巨集: #define _GNU_SOURCE #include #define CPU_SET_S(cpu, setsize, cpusetp) #define CPU_CLR_S(cpu, setsize, cpusetp) #define CPU_ZERO_S(setsize, cpusetp) #define CPU_ISSET_S(cpu, setsize, cpusetp) #define CPU_COUNT_S(setsize, cpusetp) 這些介面接收一個對應於這個容量的額外參數。為了能夠分配動態容量的 CPU 集,提供三個巨集: #define _GNU_SOURCE #include #define CPU_ALLOC_SIZE(count) #define CPU_ALLOC(count) #define CPU_FREE(cpuset) CPU_ALLOC_SIZE 巨集的回傳值為,必須為一個能夠處理 CPU 計數的 cpu_set_t 結構而分配的位元組數量。為了分配這種區塊,能夠使用 CPU_ALLOC 巨集。以這種方式分配的記憶體必須使用一次 CPU_FREE 的呼叫來釋放。這些巨集可能會在背後使用 malloc 與 free,但並不是非得要維持這種方式。 最後,定義了一些對 CPU 集物件的操作: #define _GNU_SOURCE #include #define CPU_EQUAL(cpuset1, cpuset2) #define CPU_AND(destset, cpuset1, cpuset2) #define CPU_OR(destset, cpuset1, cpuset2) #define CPU_XOR(destset, cpuset1, cpuset2) #define CPU_EQUAL_S(setsize, cpuset1, cpuset2) #define CPU_AND_S(setsize, destset, cpuset1, cpuset2) #define CPU_OR_S(setsize, destset, cpuset1, cpuset2) #define CPU_XOR_S(setsize, destset, cpuset1, cpuset2) 這二組四個巨集的集合能夠檢查二個集合的相等性,以及對集合執行邏輯 AND、OR、與 XOR 操作。這些操作在使用一些 libNUMA 函數(見附錄 D)的時候會派上用場。 一個行程能夠使用 sched_getcpu 介面來確定它目前跑在哪個處理器上: #define _GNU_SOURCE #include int sched_getcpu(void); 結果為 CPU 在 CPU 集中的索引。由於排程的本質,這個數字並不總是 100% 正確。在回傳結果的時間、與執行緒回到使用者層級的時間之間,執行緒可能已經被移到一個不同的 CPU 上。程式必須總是將這種不正確的可能性納入考量。在任何情況下,更為重要的是被允許執行執行緒的那組 CPU。這個集合能夠使用 sched_getaffinity 來查詢。集合會被子執行緒與行程繼承。執行緒不能指望集合在生命週期中是穩定的。親和性遮罩能夠從外界設置(見上面原型中的 pid 參數);Linux 也支援 CPU 熱插拔(hot-plugging),這意味著 CPU 能夠從系統中消失 –– 因此,也能從親和 CPU 集消失。 在多執行緒程式中,個別的執行緒並沒有如 POSIX 定義的正式行程 ID,因此無法使用上面二個函數。作為替代, 宣告四個不同的介面: #define _GNU_SOURCE #include int pthread_setaffinity_np(pthread_t th, size_t size, const cpu_set_t *cpuset); int pthread_getaffinity_np(pthread_t th, size_t size, cpu_set_t *cpuset); int pthread_attr_setaffinity_np( pthread_attr_t *at, size_t size, const cpu_set_t *cpuset); int pthread_attr_getaffinity_np( pthread_attr_t *at, size_t size, cpu_set_t *cpuset); 前二個介面基本上與我們已經看過的那二個相同,除了它們在第一個參數拿的是一個執行緒的控制柄(handle),而非一個行程 ID。這能夠定址在一個行程中的個別執行緒。這也代表這些介面無法在另一個行程使用,它們完全是為了行程內部使用的。第三與第四個介面使用一個執行緒屬性。這些屬性是在建立一條新的執行緒的時候使用的。藉由設置屬性,一條執行緒能夠在開始時就被排程在一個特定的 CPU 集合上。這麼早選擇目標處理器 –– 而非在執行緒已經啟動之後 –– 能夠在許多不同層面上受益,包含(而且尤其是)記憶體分配(見 6.5 節的 NUMA)。 說到 NUMA,親和性介面在 NUMA 程式設計中也扮演著一個重要的角色。我們不久後就會回到這個案例。 目前為止,我們已經談過二條執行緒的工作集重疊、使得在相同處理器核上擁有二條執行緒是合理的情況。反之亦然。假如二條執行緒在個別的資料集上運作,將它們排程在相同的處理器核上可能是個問題。二條執行緒為相同的快取鬥爭,因而相互減少快取的有效使用。其次,二個資料集都得被載入到相同的快取中;實際上,這增加必須載入的資料總量,因此可用的頻寬被砍半。 在這種情況中的解法是,設置執行緒的親和性,使得它們無法被排程在相同的處理器核上。這與先前的情況相反,所以在做出任何改變之前,理解試著要最佳化的情況是很重要的。 針對快取共享最佳化以最佳化頻寬,實際上是將會在下一節涵蓋的 NUMA 程式設計的一個面相。只要將「記憶體」的概念擴充至快取。一旦快取的層級數增加,這會變得越來越重要。基於這個理由,NUMA 支援函式庫中提供一個多核排程的解決方法。在不寫死系統細節、或是鑽入 /sys 檔案系統的深度的前提下,決定親和性遮罩的方法請參閱附錄 D 中的程式例子。 "},"what-programmers-can-do/numa-programming.html":{"url":"what-programmers-can-do/numa-programming.html","title":"6.5. NUMA 程式設計","keywords":"","body":"6.5. NUMA 程式設計 以 NUMA 程式設計而言,目前為止說過的有關快取最佳化的所有東西也都適用。差異僅在這個層級以下才開始。NUMA 在存取定址空間的不同部分時引入不同的成本。使用均勻記憶體存取的話,我們能夠最佳化以最小化分頁錯誤(見 7.5 節),但這是對它而言。所有建立的分頁都是均等的。 NUMA 改變這點。存取成本可能取決於被存取的分頁。存取成本的差異也增加針對記憶體分頁的局部性進行最佳化的重要性。NUMA 對於大多 SMP 機器而言都是無可避免的,因為有著 CSI 的 Intel(for x86, x86-64, and IA-64)與 AMD(for Opteron)都會使用它。隨著每個處理器核數量的增加,我們很可能會看到被使用的 SMP 系統急遽減少(至少除了資料中心與有著非常高 CPU 使用率需求的人們的辦公室之外)。大多家用機器僅有一個處理器就很好,因此沒有 NUMA 的問題。但這一來不是說程式開發者可忽略 NUMA,再者也不表示沒有相關的問題。 假如理解 NUMA 的一般化的話,也能快速意識到拓展至處理器快取的概念。在處理器核上使用相同快取的二條執行緒,會合作得比在處理器核上不共享快取的執行緒還快。這不是個杜撰的狀況: 早期的雙核處理器沒有 L2 共享。 舉例來說,Intel 的 Core 2 QX 6700 與 QX 6800 四核晶片擁有二個獨立的 L2 快取。 正如早先猜測的,由於一片晶片上的更多核、以及統一快取的渴望,我們將會有更多層的快取。 快取形成它們自己的階層結構;執行緒在核上的擺放,對於許多快取的共享(或者沒有)來說變得很重要。這與 NUMA 面對的問題並沒有很大的不同,因此能夠統一這二個概念。即使是只對非 SMP 機器感興趣的人也該讀一讀本節。 在 5.3 節中,我們已經看到 Linux 系統核心提供許多在 NUMA 程式設計中有用 –– 而且需要 –– 的資訊。不過,收集這些資訊並沒有這麼簡單。以這個目的而言,目前在 Linux 上可用的 NUMA 函式庫是完全不足的。一個更為合適的版本正由本作者建造中。 現有的 NUMA 函式庫,libnuma –– numactl 套件(package)的一部分 –– 並不提供對系統架構資訊的存取。它僅是一個對可用系統呼叫的包裝(wrapper)、與針對常用操作的一些方便的介面。現今在 Linux 上可用的系統呼叫為: mbind 選擇指定記憶體分頁的連結(binding)。 set_mempolicy 設定預設的記憶體連結策略。 get_mempolicy 取得預設的記憶體連結策略。 migrate_pages 將一組給定節點上的一個行程的所有分頁遷移到一組不同的節點上。 move_pages 將選擇的分頁移到給定的節點、或是請求關於分頁的節點資訊。 這些介面被宣告在與 libnuma 函式庫一起出現的 標頭檔中。在我們深入更多細節之前,我們必須理解記憶體策略的概念。 "},"what-programmers-can-do/numa-programming/memory-policy.html":{"url":"what-programmers-can-do/numa-programming/memory-policy.html","title":"6.5.1. 記憶體策略","keywords":"","body":"6.5.1. 記憶體策略 定義一個記憶體策略背後的構想是,令現有的程式在不大幅度修改的情況下,能夠在一個 NUMA 環境中適度良好地運作。策略由子行程繼承,這使得我們能夠使用 numactl 工具。這個工具的用途之一是能夠用來以給定的策略來啟動一支程式。 Linux 系統核心支援下列策略: MPOL_BIND 記憶體只會從一組給定的節點分配。假如不能做到,則分配失敗。 MPOL_PREFERRED 記憶體最好是從一組給定的節點分配。若是這失敗,才考慮來自其它節點的記憶體。 MPOL_INTERLEAVE 記憶體是平等地從指定的節點分配。節點要不是針對基於 VMA 的策略,以虛擬記憶體區域中的偏移量來選擇、就是針對基於任務(task)的策略,透過自由執行的計數器來選擇。 MPOL_DEFAULT 根據記憶體區域的預設值來選擇分配方式。 圖 6.15:記憶體策略階層結構 這份清單似乎遞迴地定義策略。這對了一半。事實上,記憶體策略形成一個階層結構(見圖 6.15)。若是一個位址被一個 VMA 策略所涵蓋,就會使用這個策略。一種特殊的策略被用在共享的記憶體區段上。假如沒有針對特定位址的策略,就會使用任務的策略。若是連這也沒有,便使用系統的預設策略。 系統預設是分配請求記憶體的那條執行緒本地的記憶體。預設不會提供任務與 VMA 策略。對於一個有著多條執行緒的行程,本地節點為首先執行行程的「家」節點。上面提到的系統呼叫能夠用來選擇不同的策略。 "},"what-programmers-can-do/numa-programming/specifying-policies.html":{"url":"what-programmers-can-do/numa-programming/specifying-policies.html","title":"6.5.2. 指定策略","keywords":"","body":"6.5.2. 指定策略 set_mempolicy 呼叫能夠用以為目前的執行緒(對系統核心來說的任務)設定任務策略。僅有目前的執行緒會受影響,而非整個行程。 #include long set_mempolicy(int mode, unsigned long *nodemask, unsigned long maxnode); mode 參數必須為前一節介紹過的其中一個 MPOL_* 常數。nodemask 參數指定未來分配要使用的記憶體節點,而 maxnode 為 nodemask 中的節點(即位元)數量。若是 mode 為 MPOL_DEFAULT,就不需要指定記憶體節點,並且會忽略 nodemask 參數。若是為 MPOL_PREFERRED 傳遞一個空指標作為 nodemask,則會選擇本地節點。否則,MPOL_PREFERRED 會使用 nodemask 中設置的位元所對應的最低的節點編號。 對於已經分配的記憶體,設定策略並沒有任何影響。分頁不會被自動遷移;只有未來的分配會受影響。注意到記憶體分配與預留定址空間之間的不同:一個使用 mmap 建立的定址空間區域通常不會被自動分配。在記憶體區域上首次的讀取或寫入操作會分配合適的分頁。若是策略在存取相同定址空間區域的不同分頁之間發生改變,或者策略允許記憶體的分配來自不同節點,那麼一個看似均勻的定址空間區域可能會被分散在許多記憶體節點之中。 "},"what-programmers-can-do/numa-programming/swapping-and-policies.html":{"url":"what-programmers-can-do/numa-programming/swapping-and-policies.html","title":"6.5.3. 置換與策略","keywords":"","body":"6.5.3. 置換與策略 若是實體記憶體耗盡,系統就必須丟棄乾淨的分頁,並將髒的分頁儲存到置換空間(swap)中。Linux 的置換實作會在將分頁寫入置換空間的時候丟棄節點資訊。這表示當分頁被重複使用並載入分頁(page in)時,將會從頭開始選擇被使用的節點。執行緒的策略很可能會導致一個靠近執行中處理器的節點被選到,但這個節點可能跟先前使用的節點不同。 這種變換的關聯(association)意味著節點關聯無法藉由一支程式被儲存為分頁的一個屬性。關聯可能會隨著時間改變。對於與其它行程共享的分頁,這也可能會因為一個行程的請求而發生(見下面 mbind 的討論)。系統核心本身能夠在一個節點耗盡空間、而其它節點仍有閒置空間的時候遷移分頁。 任何使用者層級程式得知的節點關聯因而只能在一段很短的時間內為真。它比起純粹的資訊,更像是一個提示。每當需要精確的消息時,應該使用 get_mempolicy 介面(見 6.5.5 節)。 "},"what-programmers-can-do/numa-programming/vma-policy.html":{"url":"what-programmers-can-do/numa-programming/vma-policy.html","title":"6.5.4. VMA 策略","keywords":"","body":"6.5.4. VMA 策略 要為一個位址範圍設定 VMA 策略,必須使用一個不同的介面: #include long mbind(void *start, unsigned long len, int mode, unsigned long *nodemask, unsigned long maxnode, unsigned flags); 這個介面為位址範圍 [start, start + len) 註冊一個新的 VMA 策略。由於記憶體管理是在分頁上操作,所以起始位址必須是對齊分頁的。len 值會被無條件進位至下一個分頁容量。 mode 參數再次指定策略;這個值必須從 6.5.1 節的清單中挑選。與使用 set_mempolicy 相同,nodemask 參數只會用在某些策略上。它的處理是一樣的。 mbind 介面的語義取決於 flags 參數的值。預設情況下,若是 flags 為零,系統呼叫會為這個位址範圍設定 VMA 策略。現有的對映不受影響。假如這還不夠,目前有三種修改這種行為的旗標;它們能夠被單獨或者一起被選擇: MPOL_MF_STRICT 假如並非所有分頁都在由 nodemask 指定的節點上,對 mbind 的呼叫將會失敗。在這個旗標與 MPOL_MF_MOVE 和/或 MPOL_MF_MOVEALL 一起使用的情況下,呼叫會在任何分頁無法被移動的時候失敗。 MPOL_MF_MOVE 系統核心將會試著移動位址範圍中、任何分配在一個不在由 nodemask 指定的集合中的節點上的分頁。預設情況下,僅有被目前行程的分頁表專用的分頁會被移動。 MPOL_MF_MOVEALL 如同 MPOL_MF_MOVE,但系統核心會試著移動所有分頁,而非僅有那些獨自被目前行程的分頁表使用的分頁。這個操作具有系統層面的影響,因為它也影響其它 –– 可能不是由相同使用者所擁有的 –– 行程的記憶體存取。因此 MPOL_MF_MOVEALL 是個特權操作(需要 CAP_NICE 的能力)。 注意到針對 MPOL_MF_MOVE 與 MPOL_MF_MOVEALL 的支援僅在 2.6.16 Linux 系統核心中才被加入。 在沒有任何旗標的情況下呼叫 mbind,在任何分頁真的被分配之前必須為一個新預留的位址範圍指定策略的時候是最有用的。 void *p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANON, -1, 0); if (p != MAP_FAILED) mbind(p, len, mode, nodemask, maxnode, 0); 這段程式序列保留一段 len 位元組的定址空間範圍,並指定應該使用指涉 nodemask 中的記憶體節點的策略 mode。除非 MAP_POPULATE 旗標與 mmap 一起使用,否則在 mbind 呼叫的時候並不會分配任何記憶體,因此新的策略會套用在這個定址空間區域中的所有分頁。 單獨的 MPOL_MF_STRICT 旗標能用來確定,傳給 mbind 的 start 與 len 參數所描述的位址範圍中的任何分頁,是否都被分配在那些由 nodemask 指定的那些節點以外的節點上。已分配的分頁不會被改變。若是所有分頁都被分配在指定的節點上,那麼定址空間區域的 VMA 策略會根據 mode 改變。 有時候是需要記憶體的重新平衡的。在這種情況下,可能必須將一個節點上分配的分頁移到另一個節點上。以設置 MPOL_MF_MOVE 呼叫的 mbind 會盡最大努力來達成這點。僅有單獨被行程的分頁表樹指涉到的分頁會被考慮是否移動。可能有多個以執行緒或其他行程的形式共享部分分頁表樹的使用者。不可能影響碰巧映射相同資料的其它行程。這些分頁並不共享分頁表項目。 若是傳遞給 mbind 的 flags 參數中設置 MPOL_MF_STRICT 與 MPOL_MF_MOVE 位元,系統核心會試著移動並非分配在指定節點上的所有分頁。假如這無法做到,這個呼叫將會失敗。這種呼叫可能有助於確定是否有個節點(或是一組節點)能夠容納所有的分頁。可以連續嘗試多種組合,直到找到一個合適的節點。 除非執行目前的行程是這台電腦的主要目的,否則 MPOL_MF_MOVEALL 的使用是較難以證明為正當的。理由是,即使是出現在多張分頁表的分頁也會被移動。這會輕易地以負面的方式影響其它行程。因而應該要謹慎地使用這個操作。 "},"what-programmers-can-do/numa-programming/querying-node-information.html":{"url":"what-programmers-can-do/numa-programming/querying-node-information.html","title":"6.5.5. 查詢節點資訊","keywords":"","body":"6.5.5. 查詢節點資訊 get_mempolicy 介面能用以查詢關於一個給定位址的 NUMA 狀態的各種事實。 #include long get_mempolicy(int *policy, const unsigned long *nmask, unsigned long maxnode, void *addr, int flags); 當 get_mempolicy 以 0 作為 flags 參數呼叫時,關於位址 addr 的策略資訊會被儲存在由 policy 指到的字組、以及由 nmask 指到的節點的位元遮罩中。若是 addr 落在一段已經被指定一個 VMA 策略的定址空間範圍中,就回傳關於這個策略的資訊。否則,將會回傳關於任務策略或者 –– 必要的話 –– 系統預設策略的資訊。 若是 flags 中設定 MPOL_F_NODE、並且管理 addr 的策略為 MPOL_INTERLEAVE,那麼 policy 所指到的字組為要進行下一次分配的節點索引。這個資訊能夠潛在地用來設定打算要在新分配的記憶體上運作的一條執行緒的親和性。這可能是實現逼近的一個比較不昂貴的方法,尤其是在執行緒仍未被建立的情況。 MPOL_F_ADDR 旗標能用來檢索另一個完全不同的資料項目。假如使用這個旗標,policy 所指到的字組為已經為包含 addr 的分頁分配記憶體的記憶體節點索引。這個資訊能用來決定可能的分頁遷移、決定哪條執行緒能夠最有效率地運作在記憶體位置上、還有更多更多的事情。 一條執行緒正在使用的 CPU –– 以及因此用到的記憶體節點 –– 比起它的記憶體分配還要更加變化無常。在沒有明確請求的情況下,記憶體分頁只會在極端的情況下被移動。一條執行緒能被指派給另一個 CPU,作為重新平衡 CPU 負載的結果。關於目前 CPU 與節點的資訊可能因而僅在短期內有效。排程器會試著將執行緒維持在同一個 CPU 上,甚至可能在相同的核上,以最小化由於冷快取(cold cache)造成的效能損失。這表示,查看目前 CPU 與節點的資訊是有用的;只要避免假設關聯性不會改變。 libNUMA 提供二個介面,以查詢一段給定虛擬定址空間範圍的節點資訊: #include int NUMA_mem_get_node_idx(void *addr); int NUMA_mem_get_node_mask(void *addr, size_t size, size_t __destsize, memnode_set_t *dest); NUMA_mem_get_node_mask 根據管理策略,在 dest 中設置代表所有分配(或者可能分配)範圍 [addr, addr+size) 中的分頁的記憶體節點的位元。NUMA_mem_get_node 只看位址 addr,並回傳分配(或者可能分配)這個位址的記憶體節點的索引。這些介面比 get_mempolicy 還容易使用,而且應該是首選。 目前正由一條執行緒使用的 CPU 能夠使用 sched_getcpu 來查詢(見 6.4.3 節)。使用這個資訊,一支程式能夠使用來自 libNUMA 的 NUMA_cpu_to_memnode 介面來確定 CPU 本地的記憶體節點(們): #include int NUMA_cpu_to_memnode(size_t cpusetsize, const cpu_set_t *cpuset, size_t memnodesize, memnode_set_t * memnodeset); 對這個函式的一次呼叫會設置所有對應於任何在第二個參數指到的集合中的 CPU 本地的記憶體節點的位元。就如同 CPU 資訊本身,這個資訊直到機器的配置改變(例如,CPU 被移除或新增)時才會是正確的。 memnode_set_t 物件中的位元能被用在像 get_mempolicy 這種低階函式的呼叫上。使用 libNUMA 中的其它函式會更加方便。反向映射能透過下述函式取得: #include int NUMA_memnode_to_cpu(size_t memnodesize, const memnode_set_t * memnodeset, size_t cpusetsize, cpu_set_t *cpuset) 在產生的 cpuset 中設置的位元為任何在 memnodeset 中設置的位元所對應的記憶體節點本地的那些 CPU。對於這二個介面,程式開發者都必須意識到,資訊可能隨著時間改變(尤其是使用 CPU 熱插拔的情況)。在許多情境中,在輸入的位元集中只會設置單一個位元,但舉例來說,將 sched_getaffinity 呼叫檢索到的整個 CPU 集合傳遞到 NUMA_cpu_to_memnode,以確定哪些記憶體節點能夠被執行緒直接存取到,也是有意義的。 "},"what-programmers-can-do/numa-programming/cpu-and-node-sets.html":{"url":"what-programmers-can-do/numa-programming/cpu-and-node-sets.html","title":"6.5.6. CPU 與節點集合","keywords":"","body":"6.5.6. CPU 與節點集合 藉由將程式改變為使用目前為止所描述的介面來為 SMP 與 NUMA 環境調整程式,在來源譯註無法取得的情況下可能會極為昂貴(或者不可能)。此外,系統管理員可能想要對使用者和/或行程能夠使用的資源施加限制。對於這些情境,Linux 系統核心支援所謂的 CPU 集。這個名稱有一點誤導,因為記憶體節點也被涵蓋其中。它們也與 cpu_set_t 資料型別無關。 此刻,CPU 集的介面為一個特殊的檔案系統。它通常沒有被掛載(mount)(至少到目前為止)。這能夠使用 mount -t cpuset none /dev/cpuset 改變。掛載點 /dev/cpuset 在這個時間點當然必須存在。這個目錄的內容為預設(根)CPU 集的描述。它起初由所有的 CPU 與所有的記憶體節點所構成。這個目錄中的 cpus 檔案顯示在 CPU 集中的 CPU、mems 檔案顯示記憶體節點、tasks 檔案顯示行程。 為了建立一個新的 CPU 集,只要在階層結構中的某個地方建立一個新的目錄。新的 CPU 集會繼承來自父集合的所有設定。接著,新的 CPU 集的 CPU 與記憶體節點能夠藉由將新值寫到在新目錄中的 cpus 與 mems 虛擬檔案來更改。 若是一個行程屬於一個 CPU 集,CPU 與記憶體節點的設定會被用作親和性與記憶體策略位元遮罩的遮罩。這表示,程式無法在親和性遮罩裡選擇不在行程正在使用的 CPU 集(即,它在 tasks 檔案中列出的位置)的 cpus 檔案中的任何 CPU。對於記憶體策略的節點遮罩與 mems 檔案也是類似的。 除非位元遮罩在遮罩後為空,否則程式不會經歷任何錯誤,因此 CPU 集是一種控制程式執行的近乎無形的手段。這種方法在有著大量 CPU 與/或記憶體節點時是尤其有效率的。將一個行程移到一個新的 CPU 集,就跟將行程 ID 寫到合適 CPU 集的 tasks 檔案一樣簡單。 CPU 集的目錄包含許多其它檔案,能用來指定像是記憶體壓力下、以及獨占存取 CPU 與記憶體節點時的行為。感興趣的讀者請參閱系統核心原始碼樹中的檔案 Documentation/cpusets.txt。 譯註. 根據前後文猜測,這裡的「來源」指的應該是程式使用的 CPU 與記憶體節點。 ↩ "},"what-programmers-can-do/numa-programming/explicit-numa-optimizations.html":{"url":"what-programmers-can-do/numa-programming/explicit-numa-optimizations.html","title":"6.5.7. 明確的 NUMA 最佳化","keywords":"","body":"6.5.7. 明確的 NUMA 最佳化 假如所有節點上的所有執行緒都需要存取相同的記憶體區域時,所有的本地記憶體與親和性規則都無法幫上忙。當然,簡單地將執行緒的數量限制為直接連接到記憶體節點的處理器所能支援的數量是可能的。不過,這並沒有善用 SMP NUMA 機器,因此並不是個實際的選項。 若是所需的資料是唯讀的,有個簡單的解法:複製(replication)。每個節點能夠得到它自己的資料副本,這樣就不必進行節點之間的存取。這樣做的程式碼看起來可能像這樣: void *local_data(void) { static void *data[NNODES]; int node = NUMA_memnode_self_current_idx(); if (node == -1) /* Cannot get node, pick one. */ node = 0; if (data[node] == NULL) data[node] = allocate_data(); return data[node]; } void worker(void) { void *data = local_data(); for (...) compute using data } 在這段程式中,函式 worker 藉由一次 local_data 的呼叫來取得一個資料的本地副本的指標來進行準備。接著它繼續執行使用這個指標的迴圈。local_data 函式保存一個已經被分配的資料副本的列表。每個系統擁有有限的記憶體節點,所以帶有各節點記憶體副本的指標的陣列容量是受限的。來自 libNUMA 的 NUMA_memnode_system_count 函式回傳這個數字。若是給定節點的記憶體還沒被分配給目前節點(由 data 在 NUMA_memnode_self_current_idx 呼叫所回傳的索引位置的空指標來識別),就分配一個新的副本。 重要的是要意識到,如果在 getcpu 系統呼叫之後,執行緒被排程在另一個連接到不同記憶體節點的 CPU 上時,是不會發生什麼可怕的事情的。43它只代表在 worker 中使用 data 變數的存取,存取另一個記憶體節點上的記憶體。這直到 data 被重新計算為止會拖慢程式,但就這樣。系統核心總是會避免不必要的、每顆 CPU 執行佇列的重新平衡。若是這種轉移發生,通常是為了一個很好的理由,並且在不久的未來不會再次發生。 當處理中的記憶體區域是可寫入的,事情會更為複雜。在這種情況下,單純的複製是行不通的。根據具體情況,或許有一些可能的解法。 舉例來說,若是可寫入的記憶體區域是用來累積(accumulate)結果的,首先為結果被累積的每個記憶體節點建立一個分離的區域。接著,當這項工作完成時,所有的每節點的記憶體區域會被結合以得到全體的結果。即使運作從不真的停止,這項技術也能行得通,但中介結果是必要的。這個方法的必要條件是,結果的累積是無狀態的(stateless)。即,它不會依賴先前收集起來的結果。 不過,擁有對可寫入記憶體區域的直接存取總是比較好的。若是對記憶體區域的存取數量很可觀,那麼強迫系統核心將處理中的記憶體分頁遷移到本地節點可能是個好點子。若是存取的數量非常高,並且在不同節點上的寫入並未同時發生,這可能有幫助。但要留意,系統核心無法產生奇蹟:分頁遷移是一個複製操作,並且以此而論並不便宜。這個成本必須被分期償還。 43. 使用者層級的 sched_getcpu 介面是使用 getcpu 系統呼叫來實作的。後者不該被直接使用,並且有一個不同的介面。 ↩ "},"what-programmers-can-do/numa-programming/utilizing-all-bandwidth.html":{"url":"what-programmers-can-do/numa-programming/utilizing-all-bandwidth.html","title":"6.5.8. 利用所有頻寬","keywords":"","body":"6.5.8. 利用所有頻寬 在圖 5.4 中的數據顯示,當快取無效時,對遠端記憶體的存取並不顯著慢於對本地記憶體的存取。這表示,一支程式也許能藉著將它不必再次讀取的資料寫入到附屬於另一個處理器的記憶體中來節省頻寬。到 DRAM 模組的連線頻寬與交互連線的頻寬大多數是獨立的,所以平行使用能提升整體效能。 這是否真的可能,取決於許多因素。必須確保快取無效,否則與遠端存取相關的減慢是很顯著的。另一個大問題是,遠端節點是否有任何它所擁有的記憶體頻寬的需求。在採用這個方法之前,必須詳加檢驗這種可能性。理論上,使用一個處理器可用的所有頻寬可能有正面影響。一個 10h Opteron 家族的處理器能夠直接連接到高達四個其它的處理器。假如系統的其餘部分合作的話,利用所有這種額外頻寬,也許結合合適的預取(尤其是 prefetchw),可能致使改進。 "},"memory-performance-tools.html":{"url":"memory-performance-tools.html","title":"7. 記憶體性能量測工具","keywords":"","body":"7. 記憶體性能量測工具 許多工具可協助程式開發者理解程式效能、快取和記憶體使用狀況等資訊。現代處理器具備可用的效能監控硬體。但有些事件(event)很難精確測量,因此需要模擬(simulation)的空間。當牽涉到較高層次的功能時,有一些特殊工具可監控進程的執行。我們將介紹一組常用的工具,這些工具在大多數 Linux 系統上都可用。 "},"memory-performance-tools/memory-operation-profiling.html":{"url":"memory-performance-tools/memory-operation-profiling.html","title":"7.1. 記憶體操作分析","keywords":"","body":"7.1 記憶體操作分析 進行記憶體操作分析需要與硬體的進行整合。當然也可只透過軟體收集某些資訊,但這些資訊往往過於粗糙或屬於模擬而非真實量測。第 7.2 和 7.5 節裡我們會展示一些模擬的例子。在此章節,我們會專注在可量測的記憶體所帶來的功效。 在 Linux 作業系統可透過 Oprofile 來監控效能。如參考書目2所寫,Oprofile 提供不間斷的效能分析與友善的界面來提供量測功能並以統計數據方式呈現。Oprofile 並非效能測量的唯一的方式,許多 Linux 開發者也正在開發 pfmon 來適用於某些特殊情境。 Oprofile 提供簡單的界面,即便選擇 GUI 仍可運行於裝置底層。使用者必須在處理器間發生的事件 (event) 中選擇。儘管處理器的架構說明書記載了這些事件的細節,但若需對資料進行分析往往需要對處理器本身有深入了解。另一個問題是對收集的數據進行解釋時。由於量測結果為絕對值且可任意增長。對於某個已知的計數器來說,數字要多高才算太高呢? 針對這個問題首先避免觀測單一計數器的絕對值,可將多個計數器的數值一同納入參考。處理器一次可監控不只一個事件;再來可計算所有收集數據的比值。可能可獲得更好比較的結果。計算時通常以處理時間 (process time) 作除數,例如時脈週期數 (clock cycles) 或指令數。以程式性能的初步測試而言,這二個數字具有參考的價值。 圖 7.1 為對一個簡單隨機「跟隨」測試資料進行量測的結果,縱軸為 CPI(Cycles Per Instruction)數值,橫軸則為工作集 (working set) 容量。在大多數 Intel 處理器上紀錄這個事件的變數名稱為 CPU_CLK_UNHALTED 和 INST_RETIRED 。從縮寫大概可猜到,前者計算 cpu 的時脈週期,後者則計算指令數。我們看到類似於每個元素的週期量測的圖片。對小工作集 (working set) ,比值為 1.0 有時甚至更低。這些計算結果皆在 Intel Core 2 處理器上進行,此處理器具有多純量 (multi-scalar) 特性,可同時處理多個指令。對於不受記憶體頻寬限制的程式,比率可顯著低於 1.0 ,但以此案例來說,1.0 的結果已經非常好了。 圖 7.1:時脈指令數 當 L1d 快取無法儲存所有計算數值時,CPI 會跳升到接近但低於 3.0 。需要注意 CPI 比率是將所有指令對 L2 的存取成本再取平均,而非只計算牽涉記憶體存取的指令。透過列表上的資料的週期,可計算出每個列表上項目所需的指令數量。即使 L2 快取不足時,CPI 比率會跳躍至超過 20。但這些都是可預期的結果。 但效能測量計數器應該要讓我們能更深入地了解處理器內部運作。因此我們需要思考處理器的實作細節。在這個章節我們非常在乎快取處理的細節,因此必須查看與快取相關的事件。但這些事件以及相關名稱與計數方式都與處理器息息相關。這就是為什麼 oprofile 有其不便,就算使用介面非常友善:程式開發者仍必須事先了解計數器相關詳細資料。在附錄 B 中會展示一些處理器的詳細資訊。 對於 Intel Core 2 處理器,所對應的事件分別為 L1D_REPL 、DTLB_MISSES 和 L2_LINES_IN。後者可測量所有未命中以及由指令所造成的未命中,而非硬體預取 (prefetch) 所造成 。隨機「跟隨」測試的結果如圖 7.2 所示。 所有比值結果都是使用除役 (retired) 指令(INST_RETIRED)的數量計算。因此要計算快取未命中率,還必須從 INST_RETIRED 中減去所有讀取資料和儲存資料的指令,使得記憶體操作的實際快取未命中率比圖中顯示的數字更高。 其中 L1d 的快取未命中占大多數,對 Intel 處理器而言由於使用包含快取(inclusive caches),代表可能會出現 L1d 快取未命中的情況。該處理器具有 32k 容量的 L1d 快取,因此可看到,如同預期,L1d 未命中率從零開始上升到將近工作集的容量(除了列表資料集外,還可能有其他原因觸發快取,因此增加集中在 16k 和 32k 之間 。硬體預取可將 L1d 快取未命中率保持在約 1%,直到工作集超過 64k 之後,L1d 未命中率急劇上升。 圖 7.2:快取未命中率 (隨機版本「跟隨」) L2 快取未命中率一直保持為零,直到 L2 快取完全用盡為止。其他原因造成的 L2 快取未命中可姑且忽略不計,一旦超過 L2 快取容量(221位元),未命中率就會上升。另一件值得注意的部份為 L2 快取需求未命中率並非為零。表示硬體預取器沒有加載後續指令所需的所有高速快取內容。隨機存取會影響指令預取的效果。可跟圖 7.3 循序非隨機讀取的測試版本一起比較。 圖 7.3:快取未命中率 (循序版本「跟隨」) 這張圖中可看到 L2 的需求未命中率基本上為零(注意此圖的度量衡與圖 7.2 不同)。對於循序存取硬體預取器運作得非常完美:幾乎所有 L2 快取未命中都由預取器所造成。從 L1d 和 L2 快取未命中率相同來判斷,幾乎所有 L1d 快取未命中都被 L2 快取處理,沒有造成進一步延遲。這樣的情境對所有程式都是最理想卻很難成真。 在這二個圖中,第四條線是 DTLB 未命中率( Intel 有專門為程式碼和資料所設置的 TLB ,DTLB 就是儲存資料用的 TLB )。對隨機存取的情況,DTLB 的未命中率就非常高且會導致延遲。但有趣的地方在 DTLB 的代價早在 L2 快取未命中之前就已確定。對循序存取情況,DTLB 的未命中率基本上為零。 回到 6.2.1 節中的矩陣乘法示例以及 A.1 節中的示例程式碼,我們可利用另外三個計數器。SSE_HIT_PRE 、SSE_PRE_MISS 和 LOAD_PRE_EXEC 來查看軟體預取 (prefetch) 的效果如何。如果執行 A.1 節中的程式碼,我們可得到以下結果: 描述 比值 有用的 NTA 預取 2.84% 延遲的 NTA 預取 2.56% NTA(非時間對齊)預取比率表示有多少的預取指令已被執行,因此不需要額外的功夫來處理。這表示處理器必須浪費額外的時間來解譯預取的指令並到快取尋找相關內容。因此軟體的表現很大程度取決於所使用的處理器快取容量;以及硬體預取器的效能。 若是只看 NTA 預取比值不太準確。因為比值表示所有預取指令中有 2.65% 產生延遲無法發揮效益。這些預取指令在執行之前需要將相關的資料從快取中取出。因此從數值來看只有 2.84% + 2.65% = 5.5% 的預取指令有用。以所有有用的 NTA 預取指令來看 48% 沒有及時完成。由此得知程式碼還有改進的空間如下: 大多數的預取指令沒有幫助 預取指令要依照硬體特性做調整 這個部份可留給讀者去練習如何在硬體上調整出最佳解決方案。硬體規格扮演至關重要的角色,對 Intel Core 2 處理器而言,SSE 運算的延遲為 1 個時脈週期譯註2。若是較舊版本則為 2 個時脈週期,這表示硬體預取器和預取指令有更多的前置時間來讀取數據。 Oprofile 執行隨機分析。但只記錄某個事件的第 N 次(其中 N 是每個事件最小值的閾值),以避免造成系統的負擔。某些情況可能會觸發 100 次事件,但很有可能不會在報告中顯示。 並非所有事件都能被精準的記錄。例如在記錄特定事件時指令計數器(instruction pointer)譯註1的結果往往可能是錯誤。處理器的多純量 (multi-scalar) 特性可使計算結果很難 100% 正確。但在某些處理器上特定事件可精確紀錄。 這些已標記的列表不僅有助於解讀預取資訊,每個事件都以程式計數器來記錄,因此可精準定位程式中的其他熱點。執行頻繁的位置通常會是許多 INST_RETIRED 事件的來源,因此值得進行額外調整。如果觀測結果有許多快取未命中時,則需要透過預取指令來避免快取未命中。 有一種事件可在沒有硬體支援的情況下測量分頁錯誤 (page fault) 。作業系統在解決分頁錯誤時會將結果紀錄。並分成以下二種類型的分頁錯誤: 次要分頁錯誤 對於未使用的匿名頁面(未被任何檔案紀錄使用)、寫入時才複製 (copy-on-write) 頁面以及內容已經在記憶體某處的其他頁面。 主要分頁錯誤 需要讀取磁碟以獲取檔案支援(或替換頁面出去 (swapped-out) )的資料。 很明顯,主要分頁錯誤比次要分頁錯誤要付出更多的代價。但次要分頁錯誤所造成的損失也不容忽視。在任何情況下分頁錯誤都需要進入作業系統核心來進行操作,無論是尋找新的頁面,清除或寫入需要的資料,都必須修改頁表樹 (page table tree) 。最後還需同步那些讀取或修改頁表樹的其他任務並可能引發更多延遲。 最簡單讀取分頁錯誤的方法是透過 time 工具,而不是 shell 的內建命令。如下圖 7.4 中所示。 $ time ls /etc [...] 0.00user 0.00system 0:00.02elapsed 17%CPU (0avgtext+0avgdata 0maxresident)k 0inputs+0outputs (1major+335minor)pagefaults 0swaps 圖 7.4:time 工具的相關內容 注意圖中最後一行。time 指出 1 個主要分頁錯誤的和 335 個次要的分頁錯誤。確切數字可能不同;特別是如果立刻重複運行可能會顯示現在完全沒有主要的分頁錯誤。如果程式執行相同操作,在系統環境沒有任何變化情況下,總分頁錯誤的數字將保持穩定。 對分頁錯誤影響最大階段在程式啟動階段。使用每個頁面都會產生分頁錯誤;特別是 GUI 應用程式,使用的頁面越多,程式運作所需的時間與準備時間就越長。在 7.5 節中,我們會介紹一個專門用於測量初始化時間的工具。 time 透過 rusage 來運作。wait4 系統呼叫在親代行程等待子行程終止時會寫入 strcut rusage ; 正好符合 time 的需求。但行程仍可獲取自身資源使用資訊(正是 rusage 命名由來)或已終止子行程的資源使用資訊。 #include int getrusage(__rusage_who_t who, struct rusage *usage) getrusage透過參數 who 明確表示哪個行程要求資訊。目前只有 RUSAGE_SELF 和 RUSAGE_CHILDREN 二個變數有定義。資源使用狀況計算到當每個子行程截至終止的加總,而非個別使用情況。為了能夠允許存取特定執行緒的資料,未來可能會新增 RUSAGE_THREAD 來處理單一個別執行緒計算。rusage 結構定義了包括執行時間、發送和使用行程間通訊(IPC)數量及分頁錯誤數在內的各項計算值。分頁錯誤資訊可在 ru_minflt 和ru_majflt 中獲得。 對於想透過減少分頁錯誤來增加程式效能的程式開發者可查看這些資訊,收集分析並做出比較。以系統面而言,如果使用者擁有足夠系統權限,也可透過 /proc//stat 看到相關資訊。其中 是欲查看的行程 ID ,分頁錯誤的數值則位在第 10 到第 14 欄。分別是行程及其子程的累進次要和主要分頁錯誤數。 譯註1. 在 x86 家族的微處理器,稱為 instruction pointer,或簡稱 IP,詳細可見 Program counter。 ↩ 譯註2. x86 家族微處理器的指令延遲可見: https://www.agner.org/optimize/instruction_tables.pdf。 ↩ "},"memory-performance-tools/simulating-cpu-caches.html":{"url":"memory-performance-tools/simulating-cpu-caches.html","title":"7.2. 模擬處理器快取行為","keywords":"","body":"7.2 模擬處理器快取行為 就算快取的運作原理相當容易理解,但實際程式究竟如何與快取互動並不容易觀察。其實程式開發者並不在乎資料所在的記憶體位置。記憶體位址產生是由連結器 (linker) 或運行當下由動態連結器與作業系統決定。產生的組合語言能夠處理所有可能的記憶體定址。因此程式碼中甚至看不到任何關於記憶體位置的線索。也因為如此要理解程式如何使用記憶體真的非常困難。 我們在 7.1 節所提到的 cpu 層面的分析工具像 oprofile 有助於我們了解快取的使用情況。所有數據都直接由硬體收集,在不追求高精度的狀況下十分好用。一旦牽涉高精度需求的資料時,oprofile 就不適用;因為執行緒可能會被中斷非常多次。因此若要查看程式在不同處理器上的記憶體使用行為,實際上必須湊齊這些不同的機台並直接在上面做實驗,但往往窒礙難行。像圖 3.8 的數據。若是要使用 oprofile 收集這些資料,必須要有 24 台不同的機器,但其中許多機器根本不存在。 ==19645== I refs: 152,653,497 ==19645== I1 misses: 25,833 ==19645== L2i misses: 2,475 ==19645== I1 miss rate: 0.01% ==19645== L2i miss rate: 0.00% ==19645== ==19645== D refs: 56,857,129 (35,838,721 rd + 21,018,408 wr) ==19645== D1 misses: 14,187 ( 12,451 rd + 1,736 wr) ==19645== L2d misses: 7,701 ( 6,325 rd + 1,376 wr) ==19645== D1 miss rate: 0.0% ( 0.0% + 0.0% ) ==19645== L2d miss rate: 0.0% ( 0.0% + 0.0% ) ==19645== ==19645== L2 refs: 40,020 ( 38,284 rd + 1,736 wr) ==19645== L2 misses: 10,176 ( 8,800 rd + 1,376 wr) ==19645== L2 miss rate: 0.0% ( 0.0% + 0.0% ) 圖 7.5:cachegrind 統計結果 圖中是使用快取模擬器所得到的結果。cachegrind 使用 valgrind 框架是為了檢查程式中的記憶體運用相關問題所開發。valgrind 模擬程式執行過程並在執行期間可使用各種擴充功能,例如 cachegrind 可竄入執行框架。cachegrind 利用這個功能來攔截紀錄所有記憶體地址的使用狀況,然後模擬所有 L1i 、L1d 和 L2 快取在任何指定資料容量的行為。若要使用這個功能,必須在使用 valgrind 時給定參數如下: valgrind --tool=cachegrind command arg 上圖的範例同時模擬三個快取以及對應的資料容量與處理器。當程式在運行時,部分結果會寫入到標準錯誤(stderr),其中包含快取總使用量的統計結果,如圖 7.5 所示。圖中顯示指令和記憶體位置參考的總數以及 L1i/L1d 和 L2 快取產生的未命中次數、未命中率等等。該工具甚至可將 L2 存取細分為指令存取和資料存取,所有資料快取都區分為讀取和寫入二種不同行為。 有了基礎的認知後我們可透過輸入不同的參數來進行更複雜的操作。 使用 --I1 、 --D1 和 --L2 參數, cachegrind 可忽略實體處理器的快取結構,模擬參數所指定的結構。 如下面的命令: valgrind --tool=cachegrind \\ --L2=8388608,8,64 command arg 模擬 8MB 的 L2 快取容量並支援 8 路組 (8-way set) , 每個快取行的容量為 64 位元。 注意 --L2 需要放在 command arg 的前面。 此外 cachegrind 還提供更多功能。在行程結束之前會產生一個 cachegrind.out.XXXXX 檔,其中 XXXXX 是行程的 PID 。這個檔案包含程式中每個函式和原始程式碼中快取使用的摘要和詳細內容。可使用 cg annotate 來查看這些資料。 程式所產生的結果包含快取使用摘要,以及每個函式對快取使用的詳細摘要。要獲得資料需要 cg annotate 將記憶體位置與函式做配對,這表示最好要有除錯相關的資訊。雖然檔案可提供一些幫助,但由於內部符號 (internal symbol) 沒有列在動態符號表中,因此結果不夠完整。圖 7.6 顯示了與圖 7.5 相同的程式運行的部分結果。 -------------------------------------------------------------------------------- Ir I1mr I2mr Dr D1mr D2mr Dw D1mw D2mw file:function -------------------------------------------------------------------------------- 53,684,905 9 8 9,589,531 13 3 5,820,373 14 0 ???:_IO_file_xsputn@@GLIBC_2.2.5 36,925,729 6,267 114 11,205,241 74 18 7,123,370 22 0 ???:vfprintf 11,845,373 22 2 3,126,914 46 22 1,563,457 0 0 ???:__find_specmb 6,004,482 40 10 697,872 1,744 484 0 0 0 ???:strlen 5,008,448 3 2 1,450,093 370 118 0 0 0 ???:strcmp 3,316,589 24 4 757,523 0 0 540,952 0 0 ???:_IO_padn 2,825,541 3 3 290,222 5 1 216,403 0 0 ???:_itoa_word 2,628,466 9 6 730,059 0 0 358,215 0 0 ???:_IO_file_overflow@@GLIBC_2.2.5 2,504,211 4 4 762,151 2 0 598,833 3 0 ???:_IO_do_write@@GLIBC_2.2.5 2,296,142 32 7 616,490 88 0 321,848 0 0 dwarf_child.c:__libdw_find_attr 2,184,153 2,876 20 503,805 67 0 435,562 0 0 ???:__dcigettext 2,014,243 3 3 435,512 1 1 272,195 4 0 ???:_IO_file_write@@GLIBC_2.2.5 1,988,697 2,804 4 656,112 380 0 47,847 1 1 ???:getenv 1,973,463 27 6 597,768 15 0 420,805 0 0 dwarf_getattrs.c:dwarf_getattrs 圖 7.6:cg annotate 結果 Ir 、Dr 和 Dw 這幾個欄位顯示的是總快取使用量,而非快取未命中數,這些未命中次數會在後面二欄顯示。這些數據可用於辨識出發生最多快取未命中的程式碼。首先可先關注 L2 的快取未命中,再延伸處理 L1i/L1d 快取未命中。 cg annotate 可提供更詳細的資料。如果有特別指定某一個檔案,甚至可逐行顯示出對應的快取命中數和未命中數。這些資訊可讓程式開發者深入到確切行數,雖然使用介面粗糙。本文書寫之際,cachegrind 資料檔案和原始檔必須在同一個目錄中。 再次強調,cachegrind 是模擬器,不會使用來自處理器的測量數據。因此與處理器中實際的快取實作方式很可能會非常不同。cachegrind 使用最近最少使用 (LRU) 策略,但對具高關聯性的高速快取而言,這會造成額外的成本。除此之外,模擬器並沒有考慮上下文交換 (context switch) 和系統呼叫 (system call)的成本。這二者都可能破壞大部分 L2 快取且必須更新 L1i 和 L1d 的快取內容。會導致模擬結果的總快取未命中數低於實際的狀況。儘管如此,cachegrind 仍是一個很好的工具,可了解程式的記憶體使用情況以及其相關的問題。 "},"memory-performance-tools/measuring-memory-usage.html":{"url":"memory-performance-tools/measuring-memory-usage.html","title":"7.3. 計算記憶體使用","keywords":"","body":"7.3 計算記憶體使用 了解程式使用了多少記憶體並更進一步知道實際發生位置,是增進記憶體使用率的第一步。目前已有一些非常好用的工具可參考,而且還不需要重新編譯或特別修改程式。 第一個要介紹的工具叫 massif ,提供以時間為單位計算記憶體使用情況的概觀而且還可保留編譯器自動產生的除錯 (debug) 資訊。圖 7.7 為範例。massif 與 cachegrind(7.2)相同都使用 valgrind 為基礎架構。使用方法如下: valgrind --tool=massif command arg 圖 7.7: massif 統計結果 在行程終止前,massif 會新增二個檔案分別為:massif.XXXXX.txt 與 massif.XXXXX.ps ,其中 XXXXX 是行程的 PID。.txt 檔案是行程所有呼叫點的記憶體使用摘要,而 .ps 檔案內容如圖 7.7 。 massif 還可記錄程式堆疊的使用情況,可用來確定應用程式的總記憶體使用量,但並不適用於所有狀況。在某些情況下(執行緒的堆疊或使用 signaltstack 時),valgrind 無法知道堆疊的使用限制,在這個例子就不會把這些堆疊數字加到總和中。還有一些情況,同樣也無法準確紀錄堆疊。如果程式屬於這些情況,可在開始運行 massif 時將參數設定 --stacks=no 。要注意是這些 valgrind 的使用參數,必須加在觀察程式之前。 有些程式內建客製化的記憶體配置器,或改寫系統提供的記憶體配置器。第一種情況,配置器通常會直接忽略;第二種情況下,記錄會因為系統的記憶體配置程式被多包一層導致一些資訊隱匿,因為只有最外層的函式呼叫位址會記錄下來。因此最好紀錄這些記憶體配置程式。參數 --alloc-fn=xmalloc 指定的 xmalloc 也是一種記憶體配置函式,這在 GNU 程式中很常見。因此只能將 xmalloc 的呼叫次數記錄下來,xmalloc 內部所做的呼叫不會被記錄。 第二個工具名為 memusage,是 GNU C 函式庫的一部分,可看作 massif 的簡化版本 (但早於 massif 許久)。它只記錄堆積的總使用量(包括可能的 mmap 使用等等,如果使用 -m),也可選擇紀錄堆疊。結果圖是隨時間變化的總記憶體使用情況,或者是記憶體配置函式使用的線性變化圖。這些圖表是由 memusage 腳本單獨新增,與使用 valgrind 相同,必須使用 memusage 腳本來啟動應用程式如下: memusage command arg 必須使用 -p IMGFILE 參數來指定在 PNG 檔案 IMGFILE 中產生圖形。收集資料的程式碼會在程式中實際運行,而不像 valgrind 透過模擬機。因此 memusage 的速度比 massif 快得多,並且可適用於 massif 無法使用的情況。除了總記憶體消耗外,程式碼還記錄記憶體的配置容量,程式結束時,會以直方圖顯示所有使用的記憶體配置容量。並把這些資料寫進標準錯誤 (stderr) 。 有些情況不能夠直接呼叫待觀察程式。例如 gcc 的編譯階段是由 gcc 驅動程式啟動。在這種情況下使用 memusage 命令可透過 -n NAME 提供被觀察程式的名稱。就算被觀察的程式啟動了其他程式,也可使用此參數。如果沒有指定程式名稱,所有啟動的程式都會被記錄。 massif 和 memusage 都有提供額外的選項。程式開發者應先查閱使用說明手冊來確認這些額外功能是否有提供。 現在我們已知道如何捕獲有關記憶體配置的資料,要解讀這些資料有必要從主記憶體和快取的互動中著手。有效的動態線性記憶體配置往往與高效率預取和減少快取未命中息息相關。 需要讀取特定數量資料才能進行後續動作的程式可通過新增一個列表來改善效能,其中每個列表項目都包含一個新的資料。這種配置方法的開銷可能是最小的(使用單向鏈結串列),但使用資料時的快取效果可能會大大降低性能。 其中一個問題,按時序配置的記憶體區塊在主記憶體上並非按順序排列。有許多可能的原因: 由記憶體配置器管理的大型記憶體塊中的記憶體區塊實際上是從後面往前算 原本的記憶體區塊用盡,並在定址空間索取不同位置的新記憶體區塊 配置請求的容量不同,從不同的記憶體池中提供服務 多執行緒程式的各個執行緒交錯配置記憶體區塊 如果程式必須事先配置記憶體才能進行後續動作,使用鏈結串列顯然不適合。因為列表中連續項目在主記憶體中的儲存位置並不一定按順序排列。如果要確保主記憶體上的順序,就不能以小記憶體區塊進行配置。必須使用另一層記憶體處理程式。程式開發者可自己實作客製化記憶體配置器。另一個選擇是使用 GNU C 函式庫中的 obstacks 實作。這個配置器先從系統的記憶體配置器請求大塊記憶體,再分成任意容量的小記憶體區塊。但這些配置區塊會呈連續排列,除非大記憶體區塊已用盡,主要取決於要求的容量但非常罕見。obstacks 不能完全取代記憶體配置器,其釋放記憶體的部份有限制。詳細資訊可參閱 GNU C 函式庫的使用說明書。 那如何從圖表中識別出何時該用 obstacks 呢?若不查看原始程式碼,就無法確定哪些部分適合修改並運用,但圖表可提供搜索的進入點。如果看到同一位置進行許多記憶體配置,可能表示一次大塊的記憶體配置可能有所幫助。在圖 7.7 中,我們可在地址 0x4c0e7d5 的配置中看到這樣的情況。從 800 毫秒到 1800 毫秒,他是唯一一個記憶體增長的區域(除了綠色區域)。此外斜率不陡,表示有大量的小塊記憶體配置發生。確實是使用 obstacks 或類似技術的絕佳時機。 另一個問題是圖中顯示總配置次數很高的情況。如果圖形不是按時間線性繪製而是按呼叫次數線性繪製( memusage 的原始設定)就不容易看出來。在這種情況圖中的緩坡表示有很多小塊記憶體配置發生。memusage 不會顯示記憶體配置發生的位置,但可通過與 massif 的輸出來確定,或者使用者可能會立即看出。許多小記憶體配置應該合併以實踐線性記憶體配置。 這表示程式使用的記憶體與系統或記憶體配置器所使用的記憶體互相交錯。因此我們可能會看到像以下的圖: 圖中每一個方塊代表一個記憶體字 (word)) 。在比較小的記憶體區域中,有四個已配置的記憶體方塊。由於方塊內的標頭容量和對齊填充開銷就佔50% 。因為標頭的放置方式會讓處理器的有效預取率降低 50% 。如果按排列順序處理這些方塊(最適合預取),處理器會讀取所有標頭和對齊填充字到快取中,即便應用程式不應該讀取或寫入它們這些記憶體區塊。標頭字只有在程式運行中與釋放記憶體時才會使用。 要改善這樣的情況其實可以改變實作方式,將維護記憶體操作所需的資料(administrative data)置於他處。在某些狀況可能是個好主意。但是仍有許多事情需要考慮,其中安全性是不可忽視的因素。不論未來有任何狀況,記憶體對齊所造成的填充問題永遠不會消失(如果忽略標頭則對齊填充佔了 16% 的資料)。只有當程式開發者直接控制記憶體配置器時,才能避免這種情況。就算有對齊造成的空洞,也在可控範圍內。 "},"memory-performance-tools/improving-branch-prediction.html":{"url":"memory-performance-tools/improving-branch-prediction.html","title":"7.4. 改善分支預測","keywords":"","body":"7.4 改善分支預測 6.2.2 節中,我們提到了二個改善 L1i 使用的方法,分別是藉由分支預測和重新排序執行區塊:藉由 __builtin_expect 進行靜態預測和狀態偵測的最佳化(PGO)。正確的分支預測對軟體性能有巨大的影響,但在這裡我們會著重在記憶體使用的改善狀況。 使用 __builtin_expect (或 likely 和 unlikely 二個巨集)的方式非常簡單。只要把相關的定義放在中央標頭檔裡編譯器即可發揮作用。但仍有可能在需要使用 likely 的狀況卻使用 unlikely ,反之亦然。即便可用類似 oprofile 的工具來測量分支預測錯誤和 L1i 未命中,但這些問題仍不易排除。 雖然如此仍有一個簡單的方法可使用。A.2 節的程式碼另外定義了 likely 和 unlikely 二個巨集,可在程式執行階段主動測量靜態預測是否正確。且使用者或測試人員可就檢查結果進行調整。但測量不會考量程式的性能,只會測試程式設計師所做的靜態假設。詳細內容以及程式碼可在先前的章節中找到。 PGO 對 gcc 而言相當容易使用。只需三個步驟,但須滿足某些先決條件。首先所有來源檔案都須使用額外 -fprofile-generate 選項進行編譯。這個選項必須被傳遞到所有編譯器運行的程式或連結該程式的命令中。雖然可混合使用已啟用和未啟用此選項的檔案,但對沒有啟用此選項的目標檔案,PGO 不會被觸發。 開啟 PGO 後,除了編譯時間大幅增加外,編譯器產生的二進位檔案與尋常檔案無異,因為檔案記錄(和儲存)有關分支是否被採用等等相關資訊。編譯器還為每個輸入檔案新增一個名為 .gcno 的擴增檔案。這個檔案包含與程式碼中分支相關的資訊。這個檔案須保留以便後續使用。 當程式的二進位檔準備好時,應該用來執行某代表性工作集。不論哪種工作,二進位檔都會針對這項任務進行最佳化。程式的連續執行是可能且必要;每次的執行都會對同一個輸出檔做出貢獻。在程式結束之前,收集的資料會寫入 .gcda 擴增檔案中。這些檔案會在包含來源 (source) 檔案的目錄中新增。程式可從任何目錄執行,且二進位檔也可任意複製,但是來源目錄必須具備可使用且可寫入的權限。同樣地,為每個輸入的來源檔案新增一個輸出檔案。如果多次啟動程式,須要在來源目錄中找到之前執行所留下的 .gcda 檔案,否則後續執行資料就無法累積在同一個檔案中。 執行一組代表性測試後,就可重新編譯應用程式。編譯器必須能夠在包含來源檔案的同一目錄中找到 .gcda 檔。千萬不能移動這些檔案,否則編譯器會找不到,嵌入的檔案校驗也無法匹配。重新編譯時,要將 -fprofile-generate 參數替換為 -fprofile-use ,但原始碼更改有些限制。更改空格和注釋沒問題,但加上更多分支或程式碼區塊會使收集的資料無效,導致編譯失敗。 這些就是程式開發者該有的準備,不難吧?最重要是選擇最適合的工具來進行測量。如果測試工作不適合程式實際使用方式,執行結果可能會更糟。也因為如此使用 PGO 來進行函式庫最佳化非常困難。函式庫可在許多情境下使用,除非使用案例相似,否則最好使用 __builtin_expect 的靜態分支預測。 而對 .gcno 和 .gcda 二個檔案而言,有幾點需要注意。這些二進位檔案不能直接用來檢查。但可使用 gcc 套件中的 gcov 工具來進行檢視。這個工具主要用於覆蓋分析(因此得名),使用檔案格式與 PGO 相同。 gcov 工具為每個包含執行程式碼的來源檔案(可能包括系統標頭)生成 .gcov 擴增檔案。這些檔案依據給定的 gcov 參數,對原始程式碼標注 (annotate) 分支計數器和程式碼執行的機率等資訊。 "},"memory-performance-tools/page-fault-optimization.html":{"url":"memory-performance-tools/page-fault-optimization.html","title":"7.5. 分頁錯誤最佳化","keywords":"","body":"7.5 分頁錯誤最佳化 在 Linux 這樣具有隨選分頁 (demand paging) 的作業系統上,使用 mmap 只修改頁面表內容,它確保對檔案支持的頁面可找到底層的資料。對於匿名的記憶體空間,則提供初始化的頁面並將內容設定為 0。mmap 使用時並不會實際配置記憶體。第一次讀取頁面時不管是讀寫資料或執行程式碼時,才會真的配置記憶體空間。當出現分頁錯誤時,作業系統核心會根據頁表樹決定需要在頁面上出現的資料,進行分頁錯誤處理。處理分頁錯誤的成本很高,但在行程使用每個記憶體頁面都時常發生。 為了降低分頁錯誤的成本,減少使用的總頁面數是個好方法。透過減少程式碼數量可幫助實現此目的。為了減少特定程式路徑(例如程式啟動)的成本,重新排列程式碼以便在該程式路徑中減少接觸到的頁面數量也有幫助。但要正確排序並不容易。 圖 7.8 的範例中,程式從地址 3000000B5016 開始執行,系統接著讀取位於地址 300000000016 的頁面。不久後,下一個頁面也跟著被載入,被呼叫的函式 _dl_start 位於此頁面上。最初的程式碼存取位於地址 7FF00000016 的變數。發生在第一次分頁錯誤後 3,320 個指令週期,很可能是程式的第二道指令(第一個指令後三個位元處)。如果查看程式碼,注意這個記憶體存取有一些奇怪的地方。此指令僅是呼叫指令,並沒有載入或儲存任何資料,但是它會在堆疊上儲存返回位址。但這並不是行程的堆疊,而是 valgrind 內部的堆疊 。這表示在解釋頁面置換的結果時,valgrind 也會造成影響。 0 0x3000000000 C 0 0x3000000B50: (within /lib64/ld-2.5.so) 1 0x 7FF000000 D 3320 0x3000000B53: (within /lib64/ld-2.5.so) 2 0x3000001000 C 58270 0x3000001080: _dl_start (in /lib64/ld-2.5.so) 3 0x3000219000 D 128020 0x30000010AE: _dl_start (in /lib64/ld-2.5.so) 4 0x300021A000 D 132170 0x30000010B5: _dl_start (in /lib64/ld-2.5.so) 5 0x3000008000 C 10489930 0x3000008B20: _dl_setup_hash (in /lib64/ld-2.5.so) 6 0x3000012000 C 13880830 0x3000012CC0: _dl_sysdep_start (in /lib64/ld-2.5.so) 7 0x3000013000 C 18091130 0x3000013440: brk (in /lib64/ld-2.5.so) 8 0x3000014000 C 19123850 0x3000014020: strlen (in /lib64/ld-2.5.so) 9 0x3000002000 C 23772480 0x3000002450: dl_main (in /lib64/ld-2.5.so) 圖 7.8:pagein 輸出結果 pagein 的輸出可用來確定程式碼中最理想的相鄰順序。從 /lib64/ld-2.5.so 的程式碼可發現,最初的指令呼叫函式為 _dl_start ,但這二個函式位於不同的頁面。重新排列程式碼會把程式碼移動到同一頁面以避免或延遲分頁錯誤。但要確認最佳程式碼順序是一個繁瑣的過程。由於設計上 pagein 不會紀錄第二次使用頁面的紀錄,因此要使用試錯法來查看更改的影響。並使用呼叫圖分析來猜測可能的呼叫序列,有助於加快對函式和變數排序的過程。 如果只想有粗略概念,可通過查看組成可執行檔或動態函式庫的目標檔來查看呼叫序列。從單個或多個進入點(即函式名稱)開始,可計算函式的互相依賴的關係。以目標檔案 (object file) 上來說很容易。每一輪開始之前先確定目標檔案是否包含所需函式和變數的。但種子 (seed) 集合必須明確指定。然後將這些目檔案中所有未定義的引用 (undefined reference) 都加到所需符號集合 (needed symbols)。重複執行直到集合穩定。 第二步驟是決定順序。各個目標檔案希望能使用最少的頁面數。更進一步達到沒有任何一個函式橫跨不同頁面。此情況中的複雜之處是:為了最佳地安排目標檔案,必須知道連結器 (linker) 之後的行為。在此例連結器將按照它們出現在輸入檔和命令行中的順序將目標檔案放入可執行檔或 DSO 中。對程式開發者來說相對可預期。 願意投入更多時間的使用者可使用自動追蹤呼叫的函式來重新排序,方法是透過 gcc 在 -finstrument-functions 選項插入 __cyg_profile_func_enter 和 __cyg_profile_func_exit 。詳細使用方式跟功能請參閱 gcc 使用手冊以獲取有關這些 __cyg_*譯註1 使用界面的更多資訊。通過追蹤程式的執行,程式開發者可更準確地確定函式呼叫鏈。 [17] 的結果是通過重新排序函式來減少 5% 的啟動成本。主要好處在於減少了分頁錯誤數量,但 TLB 快取也發揮了作用因為在虛擬化環境中,TLB 未命中的代價變得更加昂貴。 透過 pagein 工具的分析結果與呼叫順序資訊,可能對減少程式中某些階段(例如啟動階段)分頁錯誤數量有所幫助。 Linux 系統核心提供了二個額外的機制可避免頁錯誤。第一個機制是 mmap 系統呼叫的標誌 (flag) ,可告訴作業系統核心先去修改頁面表的資料,甚至對映射區域中的所有頁面先標記分頁錯誤。只需在 mmap 的第四個參數中加入 MAP_POPULATE 標誌即可。但這也導致 mmap 的使用成本顯著增加。如果這些映射空間的所有頁面都立即使用,好處十分顯著。相較於一次處理多個分頁錯誤,每次處理一個分頁錯誤的成本可能更高,因為還需要處理同步化等等其他問題,與其如此不如讓程式只有一個昂貴的 mmap 呼叫。但使用此標誌也有一些缺點,當映射頁面中大部分的頁面在呼叫後沒有使用會造成時間和記憶體的浪費。預先處理的頁面如果沒有在短時間內使用也會造成系統阻塞。由於記憶體在頁面使用之前就已配置,這可能導致記憶體短缺。在最糟的情況下,同一個頁面可能因其他用途被重新使用(因為尚未修改),雖然不造成嚴重系統負擔,但仍會增加處理時間,加上記憶體配置成本。 但 MAP_POPULATE 的方式有些過於粗糙。而且還有第二個問題是實際上沒有必要將所有頁面都映射進來。如果系統太忙無法執行操作,已經預取的頁面也可能會被丟棄。一旦真的使用了該頁面,程式就會出現分頁錯誤。另一種方法是使用 posix_madvise 函式的 POSIX_MADV_WILLNEED 。這會提示作業系統程式在不久後會需要目標頁面。當然作業系統核心可能忽略此設定或預先取好目標頁面。優點在於操作更細微,任何映射地址空間區域中的單個頁面或多個頁面範圍都可被預先提取。對於許多在運行時不使用資料的記憶體映射檔案,可能比使用 MAP_POPULATE 效果還好。 除了這些主動減少分頁錯誤的方法之外,還可採用一些硬體設計師喜歡的方法。一個 DSO 佔據定址空間中的相鄰頁面。頁面尺寸越小,需要的頁面越多,分頁錯誤的次數也會增加。對於較大的頁面容量,映射(或匿名記憶體)所需的頁面數量減少,分頁錯誤的數量也隨之減少。 大多數的架構都支援 4K 的頁面容量。在 IA-64 和 PPC64 架構上,64K 的頁面容量也十分常見。這表示配置記憶體的最小單位為 64K 。這個值必須在編譯核心時就設定好,而且無法在執行時期更改(至少目前無法)。 ABI 提供多種頁面容量設定可讓應用程式選擇並在執行時做出調整。較大的頁面空間可能會浪費更多空間,但在某些情況下還算可接受。 大多數架構還支援 1MB 或甚至更大的頁面容量。這樣的頁面在某些情況下很有用,但這樣的記憶體配置法對實體記憶體空間太過浪費。大頁面的優點在巨大資料集的情況,以 x86-64 上的 2MB 頁面比使用 4k 頁面節省了 511 個分頁錯誤(每個大頁面)。這可能會產生很大的差異。解決方法是有選擇地請求記憶體配置,僅某些地址範圍使用大型記憶體頁面,但對同一行程中的所有其他映射使用正常的頁面容量。 使用大頁面的代價在實體記憶體空間必須連續,因此存在內存碎裂的問題,可能在一段時間後就無法配置新的頁面空間。但要處理記憶體碎片化非常複雜。例如對 2MB 的大頁面,要取得 512 個連續頁面非常困難,除非在系統啟動階段。這就是為什麼目前大頁面的解決方案是使用特殊檔案系統 hugetlbfs 。可通過寫入要保留的大頁面數量來做配置,檔案位置如下: /proc/sys/vm/nr_hugepages 如果無法找到足夠連續的記憶體空間,操作可能會失敗。在使用虛擬化技術時,情況會變得特別有趣。由於 VMM譯註2 模型的虛擬系統並不能直接管理實體的記憶體空間,因此無法自行配置 hugetlbfs 。必須依賴 VMM 代為處理,但這個功能並不是所有虛擬機都有。對於 KVM 模型,運行 KVM 模組的 Linux 作業系統核心可執行 hugetlbfs 配置,並可能將其中的一部分頁面傳遞給其中一個客戶空間。 當一個程式需要大頁面時有多種可行的方法: 程式可使用 System V 共享記憶體界面並設定 SHM_HUGETLB 標誌 (flag) 實際掛載 hugetlbfs 類型的檔案系統,程式可在掛載點新增一個檔案,並使用 mmap 將一個或多個頁面映射為記憶體空間。 第一種情況下,hugetlbfs 不需要掛載。 請求一個或多個大頁面的程式碼可能如下: key_t k = ftok(\"/some/key/file\", 42); int id = shmget(k, LENGTH, SHM_HUGETLB|IPC_CREAT|SHM_R|SHM_W); void *a = shmat(id, NULL, 0); 這段程式碼的關鍵實作透過 shmget 中使用 SHM_HUGETLB 旗標和選擇正確的 LENGTH。 LENGTH 必須是系統大頁面的倍數。不同的架構會需要填入不同的值。使用 System V 共享記憶體介面有一個麻煩的問題,因為需要透過 key 參數以區分(或共享)映射區域而且很容易跟 ftok 介面產生衝突因此最好使用其他機制。 如果可掛載 hugetlbfs 檔案系統,最好不要使用 System V 的共享記憶體。使用特殊檔案系統的唯一問題是作業系統必須支援,但目前尚未有標準化的掛載點。一旦檔案系統成功掛載,例如在 /dev/hugetlb ,程式就可輕鬆使用它: int fd = open(\"/dev/hugetlb/file1\",O_RDWR|O_CREAT, 0700); void *a = mmap(NULL, LENGTH,PROT_READ|PROT_WRITE,fd, 0); 透過在 open 呼叫中使用相同的檔名,多個行程可共享相同的大頁面並協作。也可將頁面設置為可執行,只是在 mmap 呼叫中必須設定 PROT_EXEC 標誌。與 System V 共享記憶體相同, LENGTH 的值必須是系統的大頁面尺寸的倍數。 一個防寫入的程式(所有程式都應該如此)可使用以下函式在運行時確定掛載點存在: char *hugetlbfs_mntpoint(void) { char *result = NULL; FILE *fp = setmntent(_PATH_MOUNTED, \"r\"); if (fp != NULL) { struct mntent *m; while ((m = getmntent(fp)) != NULL) { if (strcmp(m->mnt_fsname, \"hugetlbfs\") == 0) { result = strdup(m->mnt_dir); break; } } endmntent(fp); } return result; } 關於這二種情況的更多資訊,可參見 Linux 核心原始程式碼內附文件 hugetlbpage.txt,該檔還描述 IA-64 特有處理機制。為了說明大頁面的優點,圖 7.9 顯示了對於 NPAD=0 的隨機 「跟隨」 測試結果。與圖 3.15 相同,但這次也測量了使用大頁面配置記憶體的數據。可看到效能優勢非常大。對於 $2^{20}$ 位元組,使用大頁面的測試速度快了 57% 。是由於這個容量完全適合一個 2MB 的頁面,因此不會發生 DTLB 未命中。 圖 7.9:「跟隨」在大頁面的輸出結果 數字一開始比較小,但隨著工作集容量增加而開始遞增。使用大頁面測試,在 512MB 的工作集快了 38%。大頁面測試曲線在大約 250 個週期處形成一個高原。當工作集超過 $2^{27}$ 個字元時數字再次顯著上升。高原形成的原因是 64 個 2MB 的 TLB 可涵蓋 $2^{27}$ 個字元。 正如這些數字所顯示,使用大工作集的成本中有很大一部分來自 TLB 未命中。也因此使用本節中描述的界面可能會帶來不錯的成果。圖表中的數字很可能是理想值的上限,但在現實中的某些應用程式也可透過大頁面有顯著的加速效果。例如:資料庫:因為使用案例多是大量資料存取,是今天使用大頁面的應用程式之一。 目前還沒有辦法將大頁面映射至檔案支持 (file-backed) 的記憶體區塊。但目前為止仍有一些實作提案,但無非都是明確使用大頁面,並搭配 hugetlbfs 檔案系統。但這種方式令人無法接受。大頁面使用必須透明,這樣作業系統核心才可輕鬆地確定哪些映射範圍適合並自動使用大頁面。問題在作業系統核心不會永遠知道記憶體的配置模式。如果一個可使用大頁面的記憶體空間要求 4k 頁面精細度(例:因為使用 mprotect 更改了部分記憶體的保護範圍),在線性的實體記憶體空間則會浪費很多寶貴的資源。因此成功實現這種方法肯定需要更多時間。 譯註1. 這些函式之所以會用 _cyg 開頭,是因收錄來自 Cygnus Solutions 公司這間早期的 GCC 貢獻者的程式碼,後來這間公司被 Red Hat 公司收購,其開發者持續投入 GCC 的維護,並維護前述函式的實作至今。 ↩ 譯註2. VMM 是 virtual machine manager 或 virtual machine monitor 的簡稱,詳見 Wikipedia hypervisor 詞目 ↩ "},"examples-and-benchmark-programs.html":{"url":"examples-and-benchmark-programs.html","title":"A. 範例與基準測試程式","keywords":"","body":"A. 範例與基準測試程式 "},"examples-and-benchmark-programs/matrix-multiplication.html":{"url":"examples-and-benchmark-programs/matrix-multiplication.html","title":"A.1 矩陣乘法","keywords":"","body":"A.1 矩陣乘法 這是在 6.2.1 節的矩陣乘法的完整基準測試程式。有關使用的 intrinsic 函式的細節,請讀者參閱 Intel 的參考手冊。 #include #include #include #define N 1000 double res[N][N] __attribute__ ((aligned (64))); double mul1[N][N] __attribute__ ((aligned (64))); double mul2[N][N] __attribute__ ((aligned (64))); #define SM (CLS / sizeof (double)) int main (void) { // ... Initialize mul1 and mul2 int i, i2, j, j2, k, k2; double *restrict rres; double *restrict rmul1; double *restrict rmul2; for (i = 0; i 迴圈的結構跟 6.2.1 節的最終型態幾乎完全相同。唯一的大改變是 rmul1[k2] 值的載入被拉出內部迴圈了,因為我們必須建立一個擁有二個相同元素值的向量。這即是 _mm_unpacklo_pd() intrinsic 函式所為。 其餘唯一值得注意的事情是,我們明確地對齊了三個陣列,以令我們預期會位在同個快取行的值真的在那裡找到。 "},"bibliography.html":{"url":"bibliography.html","title":"參考書目","keywords":"","body":"參考書目 Performance Guidelines for AMD AthlonTM 64 and AMD OpteronTM ccNUMA Multiprocessor Systems. Advanced Micro Devices, June 2006. 5.4 Jennifer M. Anderson, Lance M. Berc, Jeffrey Dean, Sanjay Ghemawat, Monika R. Henzinger, Shun-Tak A. Leung, Richard L. Sites, Mark T. Vandevoorde, Carl A. Waldspurger, and William E. Weihl. Continuous profiling: Where have all the cycles gone. In Proceedings of the 16th ACM Symposium of Operating Systems Principles, pages 1–14, October 1997. 7.1 Vinodh Cuppu, Bruce Jacob, Brian Davis, and Trevor Mudge. High-Performance DRAMs in Workstation Environments. IEEE Transactions on Computers, 50(11):1133–1153, November 2001. 2.1.2, 2.2, 2.2.1, 2.2.3, 10 Arnaldo Carvalho de Melo. The 7 dwarves: debugging information beyond gdb. In Proceedings of the Linux Symposium, 2007. 6.2.1 Simon Doherty, David L. Detlefs, Lindsay Grove, Christine H. Flood, Victor Luchangco, Paul A. Martin, Mark Moir, Nir Shavit, and Jr. Guy L. Steele. DCAS is not a Silver Bullet for Nonblocking Algorithm Design. In SPAA ’04: Proceedings of the Sixteenth Annual ACM Symposium on Parallelism in Algorithms and Architectures, pages 216–224, New York, NY, USA, 2004. ACM Press. ISBN 1-58113-840-7. 8.1 M. Dowler. Introduction to DDR-2: The DDR Memory Replacement, May 2004. 2.2.1 Ulrich Drepper. Futexes Are Tricky, December 2005. URL http://people.redhat.com/drepper/futex.pdf. 6.3.4 Ulrich Drepper. ELF Handling For Thread-Local Storage. Technical report, Red Hat, Inc., 2003. URL http://people.redhat.com/drepper/tls.pdf. 6.4.1 Ulrich Drepper. Security Enhancements in Red Hat Enterprise Linux, 2004. URL http://people.redhat.com/drepper/nonselsec.pdf. 4.2 Dominique Fober, Yann Orlarey, and Stephane Letz. Lock-Free Techniques for Concurrent Access to Shared Objects. In GMEM, editor, Actes des Journes d’Informatique Musicale JIM2002, Marseille, pages 143–150, 2002. 8.1, 8.1 Joe Gebis and David Patterson. Embracing and Extending 20th-Century Instruction Set Architectures. Computer, 40(4):68–75, April 2007. 8.4 David Goldberg. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys, 23(1):5–48, March 1991. 1 Maurice Herlihy and J. Eliot B. Moss. Transactional memory: Architectural support for lock-free data structures. In Proceedings of 20th International Symposium on Computer Architecture, 1993. 8.2, 8.2.2, 8.2.3, 8.2.4 Ram Huggahalli, Ravi Iyer, and Scott Tetrick. Direct Cache Access for High Bandwidth Network I/O, 2005. 6.3.5 Intel R 64 and IA-32 Architectures Optimization Reference Manual. Intel Corporation, May 2007. B.3 William Margo, Paul Petersen, and Sanjiv Shah. Hyper-Threading Technology: Impact on Compute-Intensive Workloads. Intel Technology Journal, 6(1), 2002. URL ftp://download.intel.com/technology/itj/2002/volume06issue01/art06_computeintensive/vol6iss1_art06. 3.3.4 Caola ́n McNamara. Controlling symbol ordering. http://blogs.linux.ie/caolan/2007/04/24/controlling-symbol-ordering/, April 2007. 7.5 Double Data Rate (DDR) SDRAM MT46V. Micron Technology, 2003. Rev. L 6/06 EN. 2.2.2, 10 Jon “Hannibal” Stokes. Ars Technica RAM Guide, Part II: Asynchronous and Synchronous DRAM. http://arstechnica.com/paedia/r/ram_guide/ram_guide.part2-1.html, 2004. 2.2 Wikipedia. Static random access memory, 2006. 2.1.1 "}}