天天看點

每個程式員都應該了解的 CPU 高速緩存

英文原文:Memory part 2: CPU caches 來源:oschina

[編者按:這是Ulrich Drepper寫“程式員都該知道存儲器”的第二部。那些沒有讀過第一部 的讀者可能希望從這一部開始。這本書寫的非常好,并且感謝Ulrich授權我們出版。

一點說明:書籍出版時可能會有一些印刷錯誤,如果你發現,并且想讓它在後續的出版中更正,請将意見發郵件到[email protected] ,我們一定會更正,并回報給Ulrich的文檔副本,别的讀者就不會受到這些困擾。]

現在的CPU比25年前要精密得多了。在那個年代,CPU的頻率與記憶體總線的頻率基本在同一層面上。記憶體的通路速度僅比寄存器慢那麼一點點。但是,這一局面在上世紀90年代被打破了。CPU的頻率大大提升,但記憶體總線的頻率與記憶體晶片的性能卻沒有得到成比例的提升。并不是因為造不出更快的記憶體,隻是因為太貴了。記憶體如果要達到目前CPU那樣的速度,那麼它的造價恐怕要貴上好幾個數量級。

如果有兩個選項讓你選擇,一個是速度非常快、但容量很小的記憶體,一個是速度還算快、但容量很多的記憶體,如果你的工作集比較大,超過了前一種情況,那麼人們總是會選擇第二個選項。原因在于輔存(一般為磁盤)的速度。由于工作集超過主存,那麼必須用輔存來儲存交換出去的那部分資料,而輔存的速度往往要比主存慢上好幾個數量級。

好在這問題也并不全然是非甲即乙的選擇。在配置大量DRAM的同時,我們還可以配置少量SRAM。将位址空間的某個部分劃給SRAM,剩下的部分劃給DRAM。一般來說,SRAM可以當作擴充的寄存器來使用。

上面的做法看起來似乎可以,但實際上并不可行。首先,将SRAM記憶體映射到程序的虛拟位址空間就是個非常複雜的工作,而且,在這種做法中,每個程序都需要管理這個SRAM區記憶體的配置設定。每個程序可能有大小完全不同的SRAM區,而組成程式的每個子產品也需要索取屬于自身的SRAM,更引入了額外的同步需求。簡而言之,快速記憶體帶來的好處完全被額外的管理開銷給抵消了。 基于以上的原因,我們不将SRAM放在OS或使用者的控制下,而是将它交由處理器來使用和管理。在這種模式下,SRAM用于對存儲在主存中、即将使用的資料進行臨時拷貝(換句話說,緩存)。這種做法的依據是程式代碼和資料具有時間局部性和空間局部性。也就是說,在一段較短的時間内,同一份代碼和資料有很大的可能被重複使用。對代碼來說,是循環,即同一段代碼被反複執行(完美的 空間局部性)。對資料來說,是反複通路某一小片區域中的資料。即使在短時間内對記憶體的通路并不連續,但仍有很大可能在不長的時間内重複通路同一份資料( 空間局部性)。這兩個局部性是我們了解CPU高速緩存的關鍵。

我們先用一個簡單的計算來展示一下高速緩存的效率。假設,通路主存需要200個周期,而通路高速緩存需要15個周期。如果使用100個資料元素100次,那麼在沒有高速緩存的情況下,需要2000000個周期,而在有高速緩存、而且所有資料都已被緩存的情況下,隻需要168500個周期。節約了91.5%的時間。

用作高速緩存的SRAM容量比主存小得多。以我的經驗來說,高速緩存的大小一般是主存的千分之一左右(目前一般是4GB主存、4MB緩存)。這一點本身并不是什麼問題。隻是,計算機一般都會有比較大的主存,是以工作集的大小總是會大于緩存。特别是那些運作多程序的系統,它的工作集大小是所有程序加上核心的總和。

處理高速緩存大小的限制需要制定一套很好的政策來決定在給定的時間内什麼資料應該被緩存。由于不是所有資料的工作集都是在完全相同的時間段内被使用的,我們可以用一些技術手段将需要用到的資料臨時替換那些目前并未使用的緩存資料。這種預取将會減少部分通路主存的成本,因為它與程式的執行是異步的。所有的這些技術将會使高速緩存在使用的時候看起來比實際更大。我們将在3.3節讨論這些問題。 我們将在第6章讨論如何讓這些技術能很好地幫助程式員,讓處理器更高效地工作。

3.1 高速緩存的位置

在深入介紹高速緩存的技術細節之前,有必要說明一下它在現代計算機系統中所處的位置。

每個程式員都應該了解的 CPU 高速緩存

圖3.1展示了最簡單的高速緩存配置。早期的一些系統就是類似的架構。在這種架構中,CPU核心不再直連到主存。{在一些更早的系統中,高速緩存像CPU與主存一樣連到系統總線上。那種做法更像是一種hack,而不是真正的解決方案。}資料的讀取和存儲都經過高速緩存。CPU核心與高速緩存之間是一條特殊的快速通道。在簡化的表示法中,主存與高速緩存都連到系統總線上,這條總線同時還用于與其它元件通信。我們管這條總線叫“FSB”——就是現在稱呼它的術語,參見第2.2節。在這一節裡,我們将忽略北橋。

在過去的幾十年,經驗表明使用了馮諾伊曼結構的 計算機,将用于代碼和資料的高速緩存分開是存在巨大優勢的。自1993年以來,Intel 并且一直堅持使用獨立的代碼和資料高速緩存。由于所需的代碼和資料的記憶體區域是幾乎互相獨立的,這就是為什麼獨立緩存工作得更完美的原因。近年來,獨立緩存的另一個優勢慢慢顯現出來:常見處理器解碼 指令的步驟 是緩慢的,尤其當管線為空的時候,往往會伴随着錯誤的預測或無法預測的分支的出現, 将高速緩存技術用于 指令 解碼可以加快其執行速度。

在高速緩存出現後不久,系統變得更加複雜。高速緩存與主存之間的速度差異進一步拉大,直到加入了另一級緩存。新加入的這一級緩存比第一級緩存更大,但是更慢。由于加大一級緩存的做法從經濟上考慮是行不通的,是以有了二級緩存,甚至現在的有些系統擁有三級緩存,如圖3.2所示。随着單個CPU中核數的增加,未來甚至可能會出現更多層級的緩存。

每個程式員都應該了解的 CPU 高速緩存

圖3.2展示了三級緩存,并介紹了本文将使用的一些術語。L1d是一級資料緩存,L1i是一級指令緩存,等等。請注意,這隻是示意圖,真正的資料流并不需要流經上級緩存。CPU的設計者們在設計高速緩存的接口時擁有很大的自由。而程式員是看不到這些設計選項的。

另外,我們有多核CPU,每個核心可以有多個“線程”。核心與線程的不同之處在于,核心擁有獨立的硬體資源({早期的多核CPU甚至有獨立的二級緩存。})。在不同時使用相同資源(比如,通往外界的連接配接)的情況下,核心可以完全獨立地運作。而線程隻是共享資源。Intel的線程隻有獨立的寄存器,而且還有限制——不是所有寄存器都獨立,有些是共享的。綜上,現代CPU的結構就像圖3.3所示。

每個程式員都應該了解的 CPU 高速緩存

在上圖中,有兩個處理器,每個處理器有兩個核心,每個核心有兩個線程。線程們共享一級緩存。核心(以深灰色表示)有獨立的一級緩存,同時共享二級緩存。處理器(淡灰色)之間不共享任何緩存。這些資訊很重要,特别是在讨論多程序和多線程情況下緩存的影響時尤為重要。

3.2 進階的緩存操作

了解成本和節約使用緩存,我們必須結合在第二節中講到的關于計算機體系結構和RAM技術,以及前一節講到的緩存描述來探讨。

預設情況下,CPU核心所有的資料的讀或寫都存儲在緩存中。當然,也有記憶體區域不能被緩存的,但是這種情況隻發生在作業系統的實作者對資料考慮的前提下;對程式實作者來說,這是不可見的。這也說明,程式設計者可以故意繞過某些緩存,不過這将是第六節中讨論的内容了。

如果CPU需要通路某個字(word),先檢索緩存。很顯然,緩存不可能容納主存所有内容(否則還需要主存幹嘛)。系統用字的記憶體位址來對緩存條目進行标記。如果需要讀寫某個位址的字,那麼根據标簽來檢索緩存即可。這裡用到的位址可以是虛拟位址,也可以是實體位址,取決于緩存的具體實作。

标簽是需要額外空間的,用字作為緩存的粒度顯然毫無效率。比如,在x86機器上,32位字的标簽可能需要32位,甚至更長。另一方面,由于空間局部性的存在,與目前位址相鄰的位址有很大可能會被一起通路。再回憶下2.2.1節——記憶體子產品在傳輸位于同一行上的多份資料時,由于不需要發送新CAS信号,甚至不需要發送RAS信号,是以可以實作很高的效率。基于以上的原因,緩存條目并不存儲單個字,而是存儲若幹連續字組成的“線”。在早期的緩存中,線長是32位元組,現在一般是64位元組。對于64位寬的記憶體總線,每條線需要8次傳輸。而DDR對于這種傳輸模式的支援更為高效。

當處理器需要記憶體中的某塊資料時,整條緩存線被裝入L1d。緩存線的位址通過對記憶體位址進行掩碼操作生成。對于64位元組的緩存線,是将低6位置0。這些被丢棄的位作為線内偏移量。其它的位作為标簽,并用于在緩存内定位。在實踐中,我們将位址分為三個部分。32位位址的情況如下:

每個程式員都應該了解的 CPU 高速緩存

如果緩存線長度為2O,那麼位址的低O位用作線内偏移量。上面的S位選擇“緩存集”。後面我們會說明使用緩存集的原因。現在隻需要明白一共有2S個緩存集就夠了。剩下的32 – S – O = T位組成标簽。它們用來區分别名相同的各條線{有相同S部分的緩存線被稱為有相同的别名。}用于定位緩存集的S部分不需要存儲,因為屬于同一緩存集的所有線的S部分都是相同的。

當某條指令修改記憶體時,仍然要先裝入緩存線,因為任何指令都不可能同時修改整條線(隻有一個例外——第6.1節中将會介紹的寫合并(write-combine))。是以需要在寫操作前先把緩存線裝載進來。如果緩存線被寫入,但還沒有寫回主存,那就是所謂的“髒了”。髒了的線一旦寫回主存,髒标記即被清除。

為了裝入新資料,基本上總是要先在緩存中清理出位置。L1d将内容逐出L1d,推入L2(線長相同)。當然,L2也需要清理位置。于是L2将内容推入L3,最後L3将它推入主存。這種逐出操作一級比一級昂貴。這裡所說的是現代AMD和VIA處理器所采用的獨占型緩存(exclusive cache)。而Intel采用的是包容型緩存(inclusive cache),{并不完全正确,Intel有些緩存是獨占型的,還有一些緩存具有獨占型緩存的特點。}L1d的每條線同時存在于L2裡。對這種緩存,逐出操作就很快了。如果有足夠L2,對于相同内容存在不同地方造成記憶體浪費的缺點可以降到最低,而且在逐出時非常有利。而獨占型緩存在裝載新資料時隻需要操作L1d,不需要碰L2,是以會比較快。

處理器體系結構中定義的作為存儲器的模型隻要還沒有改變,那就允許多CPU按照自己的方式來管理高速緩存。這表示,例如,設計優良的處理器,利用很少或根本沒有記憶體總線活動,并主動寫回主記憶體髒高速緩存行。這種高速緩存架構在如x86和x86-64各種各樣的處理器間存在。制造商之間,即使在同一制造商生産的産品中,證明了的記憶體模型抽象的力量。

在對稱多處理器(SMP)架構的系統中,CPU的高速緩存不能獨立的工作。在任何時候,所有的處理器都應該擁有相同的記憶體内容。保證這樣的統一的記憶體視圖被稱為“高速緩存一緻性”。如果在其自己的高速緩存和主記憶體間,處理器設計簡單,它将不會看到在其他處理器上的髒高速緩存行的内容。從一個處理器直接通路另一個處理器的高速緩存這種模型設計代價将是非常昂貴的,它是一個相當大的瓶頸。相反,當另一個處理器要讀取或寫入到高速緩存線上時,處理器會去檢測。

如果CPU檢測到一個寫通路,而且該CPU的cache中已經緩存了一個cache line的原始副本,那麼這個cache line将被标記為無效的cache line。接下來在引用這個cache line之前,需要重新加載該cache line。需要注意的是讀通路并不會導緻cache line被标記為無效的。

更精确的cache實作需要考慮到其他更多的可能性,比如第二個CPU在讀或者寫他的cache line時,發現該cache line在第一個CPU的cache中被标記為髒資料了,此時我們就需要做進一步的處理。在這種情況下,主存儲器已經失效,第二個CPU需要讀取第一個CPU的cache line。通過測試,我們知道在這種情況下第一個CPU會将自己的cache line資料自動發送給第二個CPU。這種操作是繞過主存儲器的,但是有時候存儲控制器是可以直接将第一個CPU中的cache line資料存儲到主存儲器中。對第一個CPU的cache的寫通路會導緻本地cache line的所有拷貝被标記為無效。

随着時間的推移,一大批緩存一緻性協定已經建立。其中,最重要的是MESI,我們将在第3.3.4節進行介紹。以上結論可以概括為幾個簡單的規則:

一個髒緩存線不存在于任何其他處理器的緩存之中。

同一緩存線中的幹淨拷貝可以駐留在任意多個其他緩存之中。

如果遵守這些規則,處理器甚至可以在多處理器系統中更加有效的使用它們的緩存。所有的處理器需要做的就是監控其他每一個寫通路和比較本地緩存中的位址。在下一節中,我們将介紹更多細節方面的實作,尤其是存儲開銷方面的細節。

最後,我們至少應該關注高速緩存命中或未命中帶來的消耗。下面是英特爾奔騰 M 的資料:

每個程式員都應該了解的 CPU 高速緩存

這是在CPU周期中的實際通路時間。有趣的是,對于L2高速緩存的通路時間很大一部分(甚至是大部分)是由線路的延遲引起的。這是一個限制,增加高速緩存的大小變得更糟。隻有當減小時(例如,從60納米的Merom到45納米Penryn處理器),可以提高這些資料。

表格中的數字看起來很高,但是,幸運的是,整個成本不必須負擔每次出現的緩存加載和緩存失效。某些部分的成本可以被隐藏。現在的處理器都使用不同長度的内部管道,在管道内指令被解碼,并為準備執行。如果資料要傳送到一個寄存器,那麼部分的準備工作是從存儲器(或高速緩存)加載資料。如果記憶體加載操作在管道中足夠早的進行,它可以與其他操作并行發生,那麼加載的全部發銷可能會被隐藏。對L1D常常可能如此;某些有長管道的處理器的L2也可以。

提早啟動記憶體的讀取有許多障礙。它可能隻是簡單的不具有足夠資源供記憶體通路,或者位址從另一個指令擷取,然後加載的最終位址才變得可用。在這種情況下,加載成本是不能隐藏的(完全的)。

對于寫操作,CPU并不需要等待資料被安全地放入記憶體。隻要指令具有類似的效果,就沒有什麼東西可以阻止CPU走捷徑了。它可以早早地執行下一條指令,甚至可以在影子寄存器(shadow register)的幫助下,更改這個寫操作将要存儲的資料。

每個程式員都應該了解的 CPU 高速緩存

圖3.4展示了緩存的效果。關于産生圖中資料的程式,我們會在稍後讨論。這裡大緻說下,這個程式是連續随機地通路某塊大小可配的記憶體區域。每個資料項的大小是固定的。資料項的多少取決于選擇的工作集大小。Y軸表示處理每個元素平均需要多少個CPU周期,注意它是對數刻度。X軸也是同樣,工作集的大小都以2的n次方表示。

圖中有三個比較明顯的不同階段。很正常,這個處理器有L1d和L2,沒有L3。根據經驗可以推測出,L1d有213位元組,而L2有220位元組。因為,如果整個工作集都可以放入L1d,那麼隻需不到10個周期就可以完成操作。如果工作集超過L1d,處理器不得不從L2擷取資料,于是時間飄升到28個周期左右。如果工作集更大,超過了L2,那麼時間進一步暴漲到480個周期以上。這時候,許多操作将不得不從主存中擷取資料。更糟糕的是,如果修改了資料,還需要将這些髒了的緩存線寫回記憶體。

看了這個圖,大家應該會有足夠的動力去檢查代碼、改進緩存的利用方式了吧?這裡的性能改善可不隻是微不足道的幾個百分點,而是幾個數量級呀。在第6節中,我們将介紹一些編寫高效代碼的技巧。而下一節将進一步深入緩存的設計。雖然精彩,但并不是必修課,大家可以選擇性地跳過。

3.3 CPU 緩存實作細節

高速緩存的實作者遇到這樣的難題:巨大的主記憶體中每一個單元都潛在的需要緩存。如果程式的工作集足夠大,這意味着很多主記憶體單元競争高速緩存的每一個地方。先前有過提示,主存和高速緩存的大小比是1000:1,這是不常見的。

3.3.1 關聯性

可以這樣實作一個高速緩存,每個高速緩存段(高速緩存行:cache line)都可以容納任何記憶體位置的一個副本。這就是所謂的全關聯。要通路一個緩存段,處理器核心不得不用所有緩存段的标簽和請求位址的标簽一一做比較。标簽将包含除去緩存段的偏移量全部的位址,(譯注:也就是去除3.2節中圖的O)(這意味着,S在3.2節的圖中是零)

高速緩存有類似這樣的實作,但是,看看在今天使用的L2的數目,表明這是不切實際的。給定4MB的高速緩存和64B的高速緩存段,高速緩存将有65,536個項。為了達到足夠的性能,緩存邏輯必須能夠在短短的幾個時鐘周期内,從所有這些項中,挑一個比對給定的标簽。實作這一點的工作将是巨大的。

每個程式員都應該了解的 CPU 高速緩存

對于每個高速緩存行,比較器是需要比較大标簽(注意,S是零)。每個連接配接旁邊的字母表示位的寬度。如果沒有給出,它是一個單比特線。每個比較器都要比較兩個T-位寬的值。然後,基于該結果,适當的高速緩存行的内容被選中,并使其可用。這需要合并多套O資料線,因為他們是緩存桶(譯注:這裡類似把O輸出接入多選器,是以需要合并)。實作僅僅一個比較器,需要半導體的數量就非常大,特别是因為它必須非常快。沒有疊代比較器是可用的。節省比較器的數目的唯一途徑是通過反複比較标簽,以減少它們的數目。這是不适合的,出于同樣的原因,疊代比較器不可用:它的時間太長。

全關聯高速緩存對 小緩存是實用的(例如,在某些Intel處理器的TLB緩存是全關聯的),但這些緩存都很小,非常小的。我們正在談論的最多幾十項。

對于L1i,L1d和更進階别的緩存,需要采用不同的方法。可以做的就是是限制搜尋。最極端的限制是,每個标簽映射到一個明确的緩存條目。計算很簡單:給定的4MB/64B緩存有65536項,我們可以使用位址的bit6到bit21(16位)來直接尋址高速緩存的每一個項。位址的低6位作為高速緩存段的索引。

每個程式員都應該了解的 CPU 高速緩存

在圖3.6中可以看出,這種直接映射的高速緩存,速度快,比較容易實作。它隻是需要一個比較器,一個多路複用器(在這個圖中有兩個,标記和資料是分離的,但是對于設計這不是一個硬性要求),和一些邏輯來選擇隻是有效的高速緩存行的内容。由于速度的要求,比較器是複雜的,但是現在隻需要一個,結果是可以花更多的精力,讓其變得快速。這種方法的複雜性在于在多路複用器。一個簡單的多路轉換器中的半導體的數量增速是O(log N)的,其中N是高速緩存段的數目。這是可以容忍的,但可能會很慢,在某種情況下,速度可提升,通過增加多路複用器半導體數量,來并行化的一些工作和自身增速。半導體的總數隻是随着快速增長的高速緩存緩慢的增加,這使得這種解決方案非常有吸引力。但它有一個缺點:隻有用于直接映射位址的相關的位址位均勻分布,程式才能很好工作。如果分布的不均勻,而且這是常态,一些緩存項頻繁的使用,并是以多次被換出,而另一些則幾乎不被使用或一直是空的。

每個程式員都應該了解的 CPU 高速緩存

可以通過使高速緩存的組關聯來解決此問題。組關聯結合高速緩存的全關聯和直接映射高速緩存特點,在很大程度上避免那些設計的弱點。圖3.7顯示了一個組關聯高速緩存的設計。标簽和資料存儲分成不同的組并可以通過位址選擇。這類似直接映射高速緩存。但是,小數目的值可以在同一個高速緩存組緩存,而不是一個緩存組隻有一個元素,用于在高速緩存中的每個設定值是相同的一組值的緩存。所有組的成員的标簽可以并行比較,這類似全關聯緩存的功能。

其結果是高速緩存,不容易被不幸或故意選擇同屬同一組編号的位址所擊敗,同時高速緩存的大小并不限于由比較器的數目,可以以并行的方式實作。如果高速緩存增長,隻(在該圖中)增加列的數目,而不增加行數。隻有高速緩存之間的關聯性增加,行數才會增加。今天,處理器的L2高速緩存或更高的高速緩存,使用的關聯性高達16。 L1高速緩存通常使用8。

每個程式員都應該了解的 CPU 高速緩存

給定我們4MB/64B高速緩存,8路組關聯,相關的緩存留給我們的有8192組,隻用标簽的13位,就可以尋址緩集。要确定哪些(如果有的話)的緩存組設定中的條目包含尋址的高速緩存行,8個标簽都要進行比較。在很短的時間内做出來是可行的。通過一個實驗,我們可以看到,這是有意義的。

表3.1顯示一個程式在改變緩存大小,緩存段大小和關聯集大小,L2高速緩存的緩存失效數量(根據Linux核心相關的方面人的說法,GCC在這種情況下,是他們所有中最重要的标尺)。在7.2節中,我們将介紹工具來模拟此測試要求的高速緩存。

萬一這還不是很明顯,所有這些值之間的關系是高速緩存的大小為:

cache line size × associativity × number of sets

位址被映射到高速緩存使用

O = log 2 cache line size

S = log 2 number of sets

在第3.2節中的圖顯示的方式。

每個程式員都應該了解的 CPU 高速緩存

圖3.8表中的資料更易于了解。它顯示一個固定的32個位元組大小的高速緩存行的資料。對于一個給定的高速緩存大小,我們可以看出,關聯性,的确可以幫助明顯減少高速緩存未命中的數量。對于8MB的緩存,從直接映射到2路組相聯,可以減少近44%的高速緩存未命中。組相聯高速緩存和直接映射緩存相比,該處理器可以把更多的工作集保持在緩存中。

在文獻中,偶爾可以讀到,引入關聯性,和加倍高速緩存的大小具有相同的效果。在從4M緩存躍升到8MB緩存的極端的情況下,這是正确的。關聯性再提高一倍那就肯定不正确啦。正如我們所看到的資料,後面的收益要小得多。我們不應該完全低估它的效果,雖然。在示例程式中的記憶體使用的峰值是5.6M。是以,具有8MB緩存不太可能有很多(兩個以上)使用相同的高速緩存的組。從較小的緩存的關聯性的巨大收益可以看出,較大工作集可以節省更多。

在一般情況下,增加8以上的高速緩存之間的關聯性似乎對隻有一個單線程工作量影響不大。随着介紹一個使用共享L2的多核處理器,形勢發生了變化。現在你基本上有兩個程式命中相同的緩存, 實際上導緻高速緩存減半(對于四核處理器是1/4)。是以,可以預期,随着核的數目的增加,共享高速緩存的相關性也應增長。一旦這種方法不再可行(16 路組關聯性已經很難)處理器設計者不得不開始使用共享的三級高速緩存和更進階别的,而L2高速緩存隻被核的一個子集共享。

從圖3.8中,我們還可以研究緩存大小對性能的影響。這一資料需要了解工作集的大小才能進行解讀。很顯然,與主存相同的緩存比小緩存能産生更好的結果,是以,緩存通常是越大越好。

上文已經說過,示例中最大的工作集為5.6M。它并沒有給出最佳緩存大小值,但我們可以估算出來。問題主要在于記憶體的使用并不連續,是以,即使是16M的緩存,在處理5.6M的工作集時也會出現沖突(參見2路集合關聯式16MB緩存vs直接映射式緩存的優點)。不管怎樣,我們可以有把握地說,在同樣5.6M的負載下,緩存從16MB升到32MB基本已沒有多少提高的餘地。但是,工作集是會變的。如果工作集不斷增大,緩存也需要随之增大。在購買計算機時,如果需要選擇緩存大小,一定要先衡量工作集的大小。原因可以參見圖3.10。

每個程式員都應該了解的 CPU 高速緩存

我們執行兩項測試。第一項測試是按順序地通路所有元素。測試程式循着指針n進行通路,而所有元素是連結在一起的,進而使它們的被通路順序與在記憶體中排布的順序一緻,如圖3.9的下半部分所示,末尾的元素有一個指向首元素的引用。而第二項測試(見圖3.9的上半部分)則是按随機順序通路所有元素。在上述兩個測試中,所有元素都構成一個單向循環連結清單。

3.3.2 Cache的性能測試

用于測試程式的資料可以模拟一個任意大小的工作集:包括讀、寫通路,随機、連續通路。在圖3.4中我們可以看到,程式為工作集建立了一個與其大小和元素類型相同的數組:

struct l {
    struct l *n;
    long int pad[NPAD];
  };
           

n字段将所有節點随機得或者順序的加入到環形連結清單中,用指針從目前節點進入到下一個節點。pad字段用來存儲資料,其可以是任意大小。在一些測試程式中,pad字段是可以修改的, 在其他程式中,pad字段隻可以進行讀操作。

在性能測試中,我們談到工作集大小的問題,工作集使用結構體l定義的元素表示的。2N 位元組的工作集包含

2 N/sizeof(struct l)

個元素. 顯然sizeof(struct l) 的值取決于NPAD的大小。在32位系統上,NPAD=7意味着數組的每個元素的大小為32位元組,在64位系統上,NPAD=7意味着數組的每個元素的大小為64位元組。

單線程順序通路

最簡單的情況就是周遊連結清單中順序存儲的節點。無論是從前向後處理,還是從後向前,對于處理器來說沒有什麼差別。下面的測試中,我們需要得到處理連結清單中一個元素所需要的時間,以CPU時鐘周期最為計時單元。圖3.10顯示了測試結構。除非有特殊說明, 所有的測試都是在Pentium 4 64-bit 平台上進行的,是以結構體l中NPAD=0,大小為8位元組。

每個程式員都應該了解的 CPU 高速緩存
每個程式員都應該了解的 CPU 高速緩存

一開始的兩個測試資料收到了噪音的污染。由于它們的工作負荷太小,無法過濾掉系統内其它程序對它們的影響。我們可以認為它們都是4個周期以内的。這樣一來,整個圖可以劃分為比較明顯的三個部分:

工作集小于214位元組的。

工作集從215位元組到220位元組的。

工作集大于221位元組的。

這樣的結果很容易解釋——是因為處理器有16KB的L1d和1MB的L2。而在這三個部分之間,并沒有非常銳利的邊緣,這是因為系統的其它部分也在使用緩存,我們的測試程式并不能獨占緩存的使用。尤其是L2,它是統一式的緩存,處理器的指令也會使用它(注: Intel使用的是包容式緩存)。

測試的實際耗時可能會出乎大家的意料。L1d的部分跟我們預想的差不多,在一台P4上耗時為4個周期左右。但L2的結果則出乎意料。大家可能覺得需要14個周期以上,但實際隻用了9個周期。這要歸功于處理器先進的處理邏輯,當它使用連續的記憶體區時,會 預先讀取下一條緩存線的資料。這樣一來,當真正使用下一條線的時候,其實已經早已讀完一半了,于是真正的等待耗時會比L2的通路時間少很多。

在工作集超過L2的大小之後,預取的效果更明顯了。前面我們說過,主存的通路需要耗時200個周期以上。但在預取的幫助下,實際耗時保持在9個周期左右。200 vs 9,效果非常不錯。

我們可以觀察到預取的行為,至少可以間接地觀察到。圖3.11中有4條線,它們表示處理不同大小結構時的耗時情況。随着結構的變大,元素間的距離變大了。圖中4條線對應的元素距離分别是0、56、120和248位元組。

圖中最下面的這一條線來自前一個圖,但在這裡更像是一條直線。其它三條線的耗時情況比較差。圖中這些線也有比較明顯的三個階段,同時,在小工作集的情況下也有比較大的錯誤(請再次忽略這些錯誤)。在隻使用L1d的階段,這些線條基本重合。因為這時候還不需要預取,隻需要通路L1d就行。

在L2階段,三條新加的線基本重合,而且耗時比老的那條線高很多,大約在28個周期左右,差不多就是L2的通路時間。這表明,從L2到L1d的預取并沒有生效。這是因為,對于最下面的線(NPAD=0),由于結構小,8次循環後才需要通路一條新緩存線,而上面三條線對應的結構比較大,拿相對最小的NPAD=7來說,光是一次循環就需要通路一條新線,更不用說更大的NPAD=15和31了。而預取邏輯是無法在每個周期裝載新線的,是以每次循環都需要從L2讀取,我們看到的就是從L2讀取的時延。

更有趣的是工作集超過L2容量後的階段。快看,4條線遠遠地拉開了。元素的大小變成了主角,左右了性能。處理器應能識别每一步(stride)的大小,不去為NPAD=15和31擷取那些實際并不需要的緩存線(參見6.3.1)。元素大小對預取的限制是根源于硬體預取的限制——它無法跨越頁邊界。如果允許預取器跨越頁邊界,而下一頁不存在或無效,那麼OS還得去尋找它。這意味着,程式需要遭遇一次并非由它自己産生的頁錯誤,這是完全不能接受的。在NPAD=7或者更大的時候,由于每個元素都至少需要一條緩存線,預取器已經幫不上忙了,它沒有足夠的時間去從記憶體裝載資料。 另一個導緻慢下來的原因是TLB緩存的未命中。TLB是存儲虛實位址映射的緩存,參見第4節。為了保持快速,TLB隻有很小的容量。如果有大量頁被反複通路,超出了TLB緩存容量,就會導緻反複地進行位址翻譯,這會耗費大量時間。TLB查找的代價分攤到所有元素上,如果元素越大,那麼元素的數量越少,每個元素承擔的那一份就越多。

為了觀察TLB的性能,我們可以進行另兩項測試。第一項:我們還是順序存儲清單中的元素,使NPAD=7,讓每個元素占滿整個cache line,第二項:我們将清單的每個元素存儲在一個單獨的頁上,忽略每個頁沒有使用的部分以用來計算工作集的大小。(這樣做可能不太一緻,因為在前面的測試中,我計算了結構體中每個元素沒有使用的部分,進而用來定義NPAD的大小,是以每個元素占滿了整個頁,這樣以來工作集的大小将會有所不同。但是這不是這項測試的重點,預取的低效率多少使其有點不同)。結果表明,第一項測試中,每次清單的疊代都需要一個新的cache line,而且每64個元素就需要一個新的頁。第二項測試中,每次疊代都會在一個新的頁中加載一個新的cache line。

每個程式員都應該了解的 CPU 高速緩存

結果見圖3.12。該測試與圖3.11是在同一台機器上進行的。基于可用RAM空間的有限性,測試設定容量空間大小為2的24次方位元組,這就需要1GB的容量将對象放置在分頁上。圖3.12中下方的紅色曲線正好對應了圖3.11中NPAD等于7的曲線。我們看到不同的步長顯示了高速緩存L1d和L2的大小。第二條曲線看上去完全不同,其最重要的特點是當工作容量到達2的13次方位元組時開始大幅度增長。這就是TLB緩存溢出的時候。我們能計算出一個64位元組大小的元素的TLB緩存有64個輸入。成本不會受頁面錯誤影響,因為程式鎖定了存儲器以防止記憶體被換出。

可以看出,計算實體位址并把它存儲在TLB中所花費的周期數量級是非常高的。圖3.12的表格顯示了一個極端的例子,但從中可以清楚的得到:TLB緩存效率降低的一個重要因素是大型NPAD值的減緩。由于實體位址必須在緩存行能被L2或主存讀取之前計算出來,位址轉換這個不利因素就增加了記憶體通路時間。這一點部分解釋了為什麼NPAD等于31時每個清單元素的總花費比理論上的RAM通路時間要高。

每個程式員都應該了解的 CPU 高速緩存

通過檢視連結清單元素被修改時測試資料的運作情況,我們可以窺見一些更詳細的預取實作細節。圖3.13顯示了三條曲線。所有情況下元素寬度都為16個位元組。第一條曲線“Follow”是熟悉的連結清單走線在這裡作為基線。第二條曲線,标記為“Inc”,僅僅在目前元素進入下一個前給其增加thepad[0]成員。第三條曲線,标記為”Addnext0″, 取出下一個元素的thepad[0]連結清單元素并把它添加為目前連結清單元素的thepad[0]成員。

在沒運作時,大家可能會以為”Addnext0″更慢,因為它要做的事情更多——在沒進到下個元素之前就需要裝載它的值。但實際的運作結果令人驚訝——在某些小工作集下,”Addnext0″比”Inc”更快。這是為什麼呢?原因在于,系統一般會對下一個元素進行強制性預取。當程式前進到下個元素時,這個元素其實早已被預取在L1d裡。是以,隻要工作集比L2小,”Addnext0″的性能基本就能與”Follow”測試媲美。

但是,”Addnext0″比”Inc”更快離開L2,這是因為它需要從主存裝載更多的資料。而在工作集達到2 21位元組時,”Addnext0″的耗時達到了28個周期,是同期”Follow”14周期的兩倍。這個兩倍也很好解釋。”Addnext0″和”Inc”涉及對記憶體的修改,是以L2的逐出操作不能簡單地把資料一扔了事,而必須将它們寫入記憶體。是以FSB的可用帶寬變成了一半,傳輸等量資料的耗時也就變成了原來的兩倍。

每個程式員都應該了解的 CPU 高速緩存

決定順序式緩存處理性能的另一個重要因素是緩存容量。雖然這一點比較明顯,但還是值得一說。圖3.14展示了128位元組長元素的測試結果(64位機,NPAD=15)。這次我們比較三台不同計算機的曲線,兩台P4,一台Core 2。兩台P4的差別是緩存容量不同,一台是32k的L1d和1M的L2,一台是16K的L1d、512k的L2和2M的L3。Core 2那台則是32k的L1d和4M的L2。

圖中最有趣的地方,并不是Core 2如何大勝兩台P4,而是工作集開始增長到連末級緩存也放不下、需要主存熱情參與之後的部分。

每個程式員都應該了解的 CPU 高速緩存

表3.2: 順序通路與随機通路時L2命中與未命中的情況,NPAD=0

與我們預計的相似,最末級緩存越大,曲線停留在L2通路耗時區的時間越長。在220位元組的工作集時,第二台P4(更老一些)比第一台P4要快上一倍,這要完全歸功于更大的末級緩存。而Core 2拜它巨大的4M L2所賜,表現更為卓越。

對于随機的工作負荷而言,可能沒有這麼驚人的效果,但是,如果我們能将工作負荷進行一些裁剪,讓它比對末級緩存的容量,就完全可以得到非常大的性能提升。也是由于這個原因,有時候我們需要多花一些錢,買一個擁有更大緩存的處理器。

單線程随機通路模式的測量

前面我們已經看到,處理器能夠利用L1d到L2之間的預取消除通路主存、甚至是通路L2的時延。

每個程式員都應該了解的 CPU 高速緩存

但是,如果換成随機通路或者不可預測的通路,情況就大不相同了。圖3.15比較了順序讀取與随機讀取的耗時情況。

換成随機之後,處理器無法再有效地預取資料,隻有少數情況下靠運氣剛好碰到先後通路的兩個元素挨在一起的情形。

圖3.15中有兩個需要關注的地方。首先,在大的工作集下需要非常多的周期。這台機器通路主存的時間大約為200-300個周期,但圖中的耗時甚至超過了450個周期。我們前面已經觀察到過類似現象(對比圖3.11)。這說明,處理器的自動預取在這裡起到了反效果。

其次,代表随機通路的曲線在各個階段不像順序通路那樣保持平坦,而是不斷攀升。為了解釋這個問題,我們測量了程式在不同工作集下對L2的通路情況。結果如圖3.16和表3.2。

從圖中可以看出,當工作集大小超過L2時,未命中率(L2未命中次數/L2通路次數)開始上升。整條曲線的走向與圖3.15有些相似: 先急速爬升,随後緩緩下滑,最後再度爬升。它與耗時圖有緊密的關聯。L2未命中率會一直爬升到100%為止。隻要工作集足夠大(并且記憶體也足夠大),就可以将緩存線位于L2内或處于裝載過程中的可能性降到非常低。

緩存未命中率的攀升已經可以解釋一部分的開銷。除此以外,還有一個因素。觀察表3.2的L2/#Iter列,可以看到每個循環對L2的使用次數在增長。由于工作集每次為上一次的兩倍,如果沒有緩存的話,記憶體的通路次數也将是上一次的兩倍。在按順序通路時,由于緩存的幫助及完美的預見性,對L2使用的增長比較平緩,完全取決于工作集的增長速度。

每個程式員都應該了解的 CPU 高速緩存
每個程式員都應該了解的 CPU 高速緩存

而換成随機通路後,機關耗時的增長超過了工作集的增長,根源是TLB未命中率的上升。圖3.17描繪的是NPAD=7時随機通路的耗時情況。這一次,我們修改了随機通路的方式。正常情況下是把整個清單作為一個塊進行随機(以∞表示),而其它11條線則是在小一些的塊裡進行随機。例如,标簽為’60’的線表示以60頁(245760位元組)為機關進行随機。先周遊完這個塊裡的所有元素,再通路另一個塊。這樣一來,可以保證任意時刻使用的TLB條目數都是有限的。 NPAD=7對應于64位元組,正好等于緩存線的長度。由于元素順序随機,硬體預取不可能有任何效果,特别是在元素較多的情況下。這意味着,分塊随機時的L2未命中率與整個清單随機時的未命中率沒有本質的差别。随着塊的增大,曲線逐漸逼近整個清單随機對應的曲線。這說明,在這個測試裡,性能受到TLB命中率的影響很大,如果我們能提高TLB命中率,就能大幅度地提升性能(在後面的一個例子裡,性能提升了38%之多)。

3.3.3 寫入時的行為

在我們開始研究多個線程或程序同時使用相同記憶體之前,先來看一下緩存實作的一些細節。我們要求緩存是一緻的,而且這種一緻性必須對使用者級代碼完全透明。而核心代碼則有所不同,它有時候需要對緩存進行轉儲(flush)。

這意味着,如果對緩存線進行了修改,那麼在這個時間點之後,系統的結果應該是與沒有緩存的情況下是相同的,即主存的對應位置也已經被修改的狀态。這種要求可以通過兩種方式或政策實作:

寫通(write-through)

寫回(write-back)

寫通比較簡單。當修改緩存線時,處理器立即将它寫入主存。這樣可以保證主存與緩存的内容永遠保持一緻。當緩存線被替代時,隻需要簡單地将它丢棄即可。這種政策很簡單,但是速度比較慢。如果某個程式反複修改一個本地變量,可能導緻FSB上産生大量資料流,而不管這個變量是不是有人在用,或者是不是短期變量。

寫回比較複雜。當修改緩存線時,處理器不再馬上将它寫入主存,而是打上已弄髒(dirty)的标記。當以後某個時間點緩存線被丢棄時,這個已弄髒标記會通知處理器把資料寫回到主存中,而不是簡單地扔掉。

寫回有時候會有非常不錯的性能,是以較好的系統大多采用這種方式。采用寫回時,處理器們甚至可以利用FSB的空閑容量來存儲緩存線。這樣一來,當需要緩存空間時,處理器隻需清除髒标記,丢棄緩存線即可。

但寫回也有一個很大的問題。當有多個處理器(或核心、超線程)通路同一塊記憶體時,必須確定它們在任何時候看到的都是相同的内容。如果緩存線在其中一個處理器上弄髒了(修改了,但還沒寫回主存),而第二個處理器剛好要讀取同一個記憶體位址,那麼這個讀操作不能去讀主存,而需要讀第一個處理器的緩存線。在下一節中,我們将研究如何實作這種需求。

在此之前,還有其它兩種緩存政策需要提一下:

寫入合并

不可緩存

這兩種政策用于真實記憶體不支援的特殊位址區,核心為位址區設定這些政策(x86處理器利用記憶體類型範圍寄存器MTRR),餘下的部分自動進行。MTRR還可用于寫通和寫回政策的選擇。

寫入合并是一種有限的緩存優化政策,更多地用于顯示卡等裝置之上的記憶體。由于裝置的傳輸開銷比本地記憶體要高的多,是以避免進行過多的傳輸顯得尤為重要。如果僅僅因為修改了緩存線上的一個字,就傳輸整條線,而下個操作剛好是修改線上的下一個字,那麼這次傳輸就過于浪費了。而這恰恰對于顯示卡來說是比較常見的情形——螢幕上水準鄰接的像素往往在記憶體中也是靠在一起的。顧名思義,寫入合并是在寫出緩存線前,先将多個寫入通路合并起來。在理想的情況下,緩存線被逐字逐字地修改,隻有當寫入最後一個字時,才将整條線寫入記憶體,進而極大地加速記憶體的通路。

最後來講一下不可緩存的記憶體。一般指的是不被RAM支援的記憶體位置,它可以是寫死的特殊位址,承擔CPU以外的某些功能。對于商用硬體來說,比較常見的是映射到外部卡或裝置的位址。在嵌入式主機闆上,有時也有類似的位址,用來開關LED。對這些位址進行緩存顯然沒有什麼意義。比如上述的LED,一般是用來調試或報告狀态,顯然應該盡快點亮或關閉。而對于那些PCI卡上的記憶體,由于不需要CPU的幹涉即可更改,也不該緩存。

3.3.4 多處理器支援

在上節中我們已經指出當多處理器開始發揮作用的時候所遇到的問題。甚至對于那些不共享的高速級别的緩存(至少在L1d級别)的多核處理器也有問題。

直接提供從一個處理器到另一處理器的高速通路,這是完全不切實際的。從一開始,連接配接速度根本就不夠快。實際的選擇是,在其需要的情況下,轉移到其他處理器。需要注意的是,這同樣應用在相同處理器上無需共享的高速緩存。

現在的問題是,當該高速緩存線轉移的時候會發生什麼?這個問題回答起來相當容易:當一個處理器需要在另一個處理器的高速緩存中讀或者寫的髒的高速緩存線的時候。但怎樣處理器怎樣确定在另一個處理器的緩存中的高速緩存線是髒的?假設它僅僅是因為一個高速緩存線被另一個處理器加載将是次優的(最好的)。通常情況下,大多數的記憶體通路是隻讀的通路和産生高速緩存線,并不髒。在高速緩存線上處理器頻繁的操作(當然,否則為什麼我們有這樣的檔案呢?),也就意味着每一次寫通路後,都要廣播關于高速緩存線的改變将變得不切實際。

多年來,人們開發除了MESI緩存一緻性協定(MESI=Modified, Exclusive, Shared, Invalid,變更的、獨占的、共享的、無效的)。協定的名稱來自協定中緩存線可以進入的四種狀态:

變更的: 本地處理器修改了緩存線。同時暗示,它是所有緩存中唯一的拷貝。

獨占的: 緩存線沒有被修改,而且沒有被裝入其它處理器緩存。

共享的: 緩存線沒有被修改,但可能已被裝入其它處理器緩存。

無效的: 緩存線無效,即,未被使用。

MESI協定開發了很多年,最初的版本比較簡單,但是效率也比較差。現在的版本通過以上4個狀态可以有效地實作寫回式緩存,同時支援不同處理器對隻讀資料的并發通路。

每個程式員都應該了解的 CPU 高速緩存

在協定中,通過處理器監聽其它處理器的活動,不需太多努力即可實作狀态變更。處理器将操作釋出在外部引腳上,使外部可以了解到處理過程。目标的緩存線位址則可以在位址總線上看到。在下文講述狀态時,我們将介紹總線參與的時機。

一開始,所有緩存線都是空的,緩存為無效(Invalid)狀态。當有資料裝進緩存供寫入時,緩存變為變更(Modified)狀态。如果有資料裝進緩存供讀取,那麼新狀态取決于其它處理器是否已經狀态了同一條緩存線。如果是,那麼新狀态變成共享(Shared)狀态,否則變成獨占(Exclusive)狀态。

如果本地處理器對某條Modified緩存線進行讀寫,那麼直接使用緩存内容,狀态保持不變。如果另一個處理器希望讀它,那麼第一個處理器将内容發給第一個處理器,然後可以将緩存狀态置為Shared。而發給第二個處理器的資料由記憶體控制器接收,并放入記憶體中。如果這一步沒有發生,就不能将這條線置為Shared。如果第二個處理器希望的是寫,那麼第一個處理器将内容發給它後,将緩存置為Invalid。這就是臭名昭著的”請求所有權(Request For Ownership,RFO)”操作。在末級緩存執行RFO操作的代價比較高。如果是寫通式緩存,還要加上将内容寫入上一層緩存或主存的時間,進一步提升了代價。 對于Shared緩存線,本地處理器的讀取操作并不需要修改狀态,而且可以直接從緩存滿足。而本地處理器的寫入操作則需要将狀态置為Modified,而且需要将緩存線在其它處理器的所有拷貝置為Invalid。是以,這個寫入操作需要通過RFO消息發通知其它處理器。如果第二個處理器請求讀取,無事發生。因為主存已經包含了目前資料,而且狀态已經為Shared。如果第二個處理器需要寫入,則将緩存線置為Invalid。不需要總線操作。

Exclusive狀态與Shared狀态很像,隻有一個不同之處: 在Exclusive狀态時,本地寫入操作不需要在總線上聲明,因為本地的緩存是系統中唯一的拷貝。這是一個巨大的優勢,是以處理器會盡量将緩存線保留在Exclusive狀态,而不是Shared狀态。隻有在資訊不可用時,才退而求其次選擇shared。放棄Exclusive不會引起任何功能缺失,但會導緻性能下降,因為E→M要遠遠快于S→M。

從以上的說明中應該已經可以看出,在多處理器環境下,哪一步的代價比較大了。填充緩存的代價當然還是很高,但我們還需要留意RFO消息。一旦涉及RFO,操作就快不起來了。

RFO在兩種情況下是必需的:

線程從一個處理器遷移到另一個處理器,需要将所有緩存線移到新處理器。

某條緩存線确實需要被兩個處理器使用。{對于同一處理器的兩個核心,也有同樣的情況,隻是代價稍低。RFO消息可能會被發送多次。}

多線程或多程序的程式總是需要同步,而這種同步依賴記憶體來實作。是以,有些RFO消息是合理的,但仍然需要盡量降低發送頻率。除此以外,還有其它來源的RFO。在第6節中,我們将解釋這些場景。緩存一緻性協定的消息必須發給系統中所有處理器。隻有當協定确定已經給過所有處理器響應機會之後,才能進行狀态躍遷。也就是說,協定的速度取決于最長響應時間。{這也是現在能看到三插槽AMD Opteron系統的原因。這類系統隻有三個超級鍊路(hyperlink),其中一個連接配接南橋,每個處理器之間都隻有一跳的距離。}總線上可能會發生沖突,NUMA系統的延時很大,突發的流量會拖慢通信。這些都是讓我們避免無謂流量的充足理由。

此外,關于多處理器還有一個問題。雖然它的影響與具體機器密切相關,但根源是唯一的——FSB是共享的。在大多數情況下,所有處理器通過唯一的總線連接配接到記憶體控制器(參見圖2.1)。如果一個處理器就能占滿總線(十分常見),那麼共享總線的兩個或四個處理器顯然隻會得到更有限的帶寬。

即使每個處理器有自己連接配接記憶體控制器的總線,如圖2.2,但還需要通往記憶體子產品的總線。一般情況下,這種總線隻有一條。退一步說,即使像圖2.2那樣不止一條,對同一個記憶體子產品的并發通路也會限制它的帶寬。

對于每個處理器擁有本地記憶體的AMD模型來說,也是同樣的問題。的确,所有處理器可以非常快速地同時通路它們自己的記憶體。但是,多線程呢?多程序呢?它們仍然需要通過通路同一塊記憶體來進行同步。

對同步來說,有限的帶寬嚴重地制約着并發度。程式需要更加謹慎的設計,将不同處理器通路同一塊記憶體的機會降到最低。以下的測試展示了這一點,還展示了與多線程代碼相關的其它效果。

多線程測量

為了幫助大家了解問題的嚴重性,我們來看一些曲線圖,主角也是前文的那個程式。隻不過這一次,我們運作多個線程,并測量這些線程中最快那個的運作時間。也就是說,等它們全部運作完是需要更長時間的。我們用的機器有4個處理器,而測試是做多跑4個線程。所有處理器共享同一條通往記憶體控制器的總線,另外,通往記憶體子產品的總線也隻有一條。

每個程式員都應該了解的 CPU 高速緩存

圖3.19展示了順序讀通路時的性能,元素為128位元組長(64位計算機,NPAD=15)。對于單線程的曲線,我們預計是與圖3.11相似,隻不過是換了一台機器,是以實際的數字會有些小差别。

更重要的部分當然是多線程的環節。由于是隻讀,不會去修改記憶體,不會嘗試同步。但即使不需要RFO,而且所有緩存線都可共享,性能仍然分别下降了18%(雙線程)和34%(四線程)。由于不需要在處理器之間傳輸緩存,是以這裡的性能下降完全由以下兩個瓶頸之一或同時引起: 一是從處理器到記憶體控制器的共享總線,二是從記憶體控制器到記憶體子產品的共享總線。當工作集超過L3後,三種情況下都要預取新元素,而即使是雙線程,可用的帶寬也無法滿足線性擴充(無懲罰)。

當加入修改之後,場面更加難看了。圖3.20展示了順序遞增測試的結果。

每個程式員都應該了解的 CPU 高速緩存

圖中Y軸采用的是對數刻度,不要被看起來很小的內插補點欺騙了。現在,雙線程的性能懲罰仍然是18%,但四線程的懲罰飙升到了93%!原因在于,采用四線程時,預取的流量與寫回的流量加在一起,占滿了整個總線。

我們用對數刻度來展示L1d範圍的結果。可以發現,當超過一個線程後,L1d就無力了。單線程時,僅當工作集超過L1d時通路時間才會超過20個周期,而多線程時,即使在很小的工作集情況下,通路時間也達到了那個水準。

這裡并沒有揭示問題的另一方面,主要是用這個程式很難進行測量。問題是這樣的,我們的測試程式修改了記憶體,是以本應看到RFO的影響,但在結果中,我們并沒有在L2階段看到更大的開銷。原因在于,要看到RFO的影響,程式必須使用大量記憶體,而且所有線程必須同時通路同一塊記憶體。如果沒有大量的同步,這是很難實作的,而如果加入同步,則會占滿執行時間。

每個程式員都應該了解的 CPU 高速緩存

最後,在圖3.21中,我們展示了随機通路的Addnextlast測試的結果。這裡主要是為了讓大家感受一下這些巨大到爆的數字。極端情況下,甚至用了1500個周期才處理完一個元素。如果加入更多線程,真是不可想象哪。我們把多線程的效能總結了一下:

每個程式員都應該了解的 CPU 高速緩存

這個表展示了圖3.21中多線程運作大工作集時的效能。表中的數字表示測試程式在使用多線程處理大工作集時可能達到的最大加速因子。雙線程和四線程的理論最大加速因子分别是2和4。從表中資料來看,雙線程的結果還能接受,但四線程的結果表明,擴充到雙線程以上是沒有什麼意義的,帶來的收益可以忽略不計。隻要我們把圖3.21換個方式呈現,就可以很容易看清這一點。

每個程式員都應該了解的 CPU 高速緩存

圖3.22中的曲線展示了加速因子,即多線程相對于單線程所能擷取的性能加成值。測量值的精确度有限,是以我們需要忽略比較小的那些數字。可以看到,在L2與L3範圍内,多線程基本可以做到線性加速,雙線程和四線程分别達到了2和4的加速因子。但是,一旦工作集的大小超出L3,曲線就崩塌了,雙線程和四線程降到了基本相同的數值(參見表3.3中第4列)。也是部分由于這個原因,我們很少看到4CPU以上的主機闆共享同一個記憶體控制器。如果需要配置更多處理器,我們隻能選擇其它的實作方式(參見第5節)。

可惜,上圖中的資料并不是普遍情況。在某些情況下,即使工作集能夠放入末級緩存,也無法實作線性加速。實際上,這反而是正常的,因為普通的線程都有一定的耦合關系,不會像我們的測試程式這樣完全獨立。而反過來說,即使是很大的工作集,即使是兩個以上的線程,也是可以通過并行化受益的,但是需要程式員的聰明才智。我們會在第6節進行一些介紹。

特例: 超線程

由CPU實作的超線程(有時又叫對稱多線程,SMT)是一種比較特殊的情況,每個線程并不能真正并發地運作。它們共享着除寄存器外的絕大多數處理資源。每個核心和CPU仍然是并行工作的,但核心上的線程則受到這個限制。理論上,每個核心可以有大量線程,不過到目前為止,Intel的CPU最多隻有兩個線程。CPU負責對各線程進行時分複用,但這種複用本身并沒有多少厲害。它真正的優勢在于,CPU可以在目前運作的超線程發生延遲時,排程另一個線程。這種延遲一般由記憶體通路引起。

如果兩個線程運作在一個超線程核心上,那麼隻有當兩個線程合起來的運作時間少于單線程運作時間時,效率才會比較高。我們可以将通常先後發生的記憶體通路疊合在一起,以實作這個目标。有一個簡單的計算公式,可以幫助我們計算如果需要某個加速因子,最少需要多少的緩存命中率。

程式的執行時間可以通過一個隻有一級緩存的簡單模型來進行估算(參見[htimpact]):

T exe = N[(1-F mem )T proc + F mem (G hit T cache + (1-G hit )T miss )]

各變量的含義如下:

N = 指令數

Fmem = N中通路記憶體的比例

Ghit = 命中緩存的比例

Tproc = 每條指令所用的周期數

Tcache = 緩存命中所用的周期數

Tmiss = 緩沖未命中所用的周期數

Texe = 程式的執行時間

為了讓任何判讀使用雙線程,兩個線程之中任一線程的執行時間最多為單線程指令的一半。兩者都有一個唯一的變量緩存命中數。 如果我們要解決最小緩存命中率相等的問題需要使我們獲得的線程的執行率不少于50%或更多,如圖 3.23.

每個程式員都應該了解的 CPU 高速緩存

X軸表示單線程指令的緩存命中率Ghit,Y軸表示多線程指令所需的緩存命中率。這個值永遠不能高于單線程命中率,否則,單線程指令也會使用改良的指令。為了使單線程的命中率在低于55%的所有情況下優于使用多線程,cup要或多或少的足夠空閑因為緩存丢失會運作另外一個超線程。

綠色區域是我們的目标。如果線程的速度沒有慢過50%,而每個線程的工作量隻有原來的一半,那麼它們合起來的耗時應該會少于單線程的耗時。對我們用的示例系統來說(使用超線程的P4機器),如果單線程代碼的命中率為60%,那麼多線程代碼至少要達到10%才能獲得收益。這個要求一般來說還是可以做到的。但是,如果單線程代碼的命中率達到了95%,那麼多線程代碼要做到80%才行。這就很難了。而且,這裡還涉及到超線程,在兩個超線程的情況下,每個超線程隻能分到一半的有效緩存。因為所有超線程是使用同一個緩存來裝載資料的,如果兩個超線程的工作集沒有重疊,那麼原始的95%也會被打對折——47%,遠低于80%。

是以,超線程隻在某些情況下才比較有用。單線程代碼的緩存命中率必須低到一定程度,進而使緩存容量變小時新的命中率仍能滿足要求。隻有在這種情況下,超線程才是有意義的。在實踐中,采用超線程能否獲得更快的結果,取決于處理器能否有效地将每個程序的等待時間與其它程序的執行時間重疊在一起。并行化也需要一定的開銷,需要加到總的運作時間裡,這個開銷往往是不能忽略的。

在6.3.4節中,我們會介紹一種技術,它将多個線程通過公用緩存緊密地耦合起來。這種技術适用于許多場合,前提是程式員們樂意花費時間和精力擴充自己的代碼。

如果兩個超線程執行完全不同的代碼(兩個線程就像被當成兩個處理器,分别執行不同程序),那麼緩存容量就真的會降為一半,導緻緩沖未命中率大為攀升,這一點應該是很清楚的。這樣的排程機制是很有問題的,除非你的緩存足夠大。是以,除非程式的工作集設計得比較合理,能夠确實從超線程獲益,否則還是建議在BIOS中把超線程功能關掉。{我們可能會因為另一個原因 開啟 超線程,那就是調試,因為SMT在查找并行代碼的問題方面真的非常好用。}

3.3.5 其它細節

我們已經介紹了位址的組成,即标簽、集合索引和偏移三個部分。那麼,實際會用到什麼樣的位址呢?目前,處理器一般都向程序提供虛拟位址空間,意味着我們有兩種不同的位址: 虛拟位址和實體位址。

虛拟位址有個問題——并不唯一。随着時間的變化,虛拟位址可以變化,指向不同的實體位址。同一個位址在不同的程序裡也可以表示不同的實體位址。那麼,是不是用實體位址會比較好呢?

問題是,處理器指令用的虛拟位址,而且需要在記憶體管理單元(MMU)的協助下将它們翻譯成實體位址。這并不是一個很小的操作。在執行指令的管線(pipeline)中,實體位址隻能在很後面的階段才能得到。這意味着,緩存邏輯需要在很短的時間裡判斷位址是否已被緩存過。而如果可以使用虛拟位址,緩存查找操作就可以更早地發生,一旦命中,就可以馬上使用記憶體的内容。結果就是,使用虛拟記憶體後,可以讓管線把更多記憶體通路的開銷隐藏起來。

處理器的設計人員們現在使用虛拟位址來标記第一級緩存。這些緩存很小,很容易被清空。在程序頁表樹發生變更的情況下,至少是需要清空部分緩存的。如果處理器擁有指定變更位址範圍的指令,那麼可以避免緩存的完全重新整理。由于一級緩存L1i及L1d的時延都很小(~3周期),基本上必須使用虛拟位址。

對于更大的緩存,包括L2和L3等,則需要以實體位址作為标簽。因為這些緩存的時延比較大,虛拟到實體位址的映射可以在允許的時間裡完成,而且由于主存時延的存在,重新填充這些緩存會消耗比較長的時間,重新整理的代價比較昂貴。

一般來說,我們并不需要了解這些緩存處理位址的細節。我們不能更改它們,而那些可能影響性能的因素,要麼是應該避免的,要麼是有很高代價的。填滿緩存是不好的行為,緩存線都落入同一個集合,也會讓緩存早早地出問題。對于後一個問題,可以通過緩存虛拟位址來避免,但作為一個使用者級程式,是不可能避免緩存實體位址的。我們唯一可以做的,是盡最大努力不要在同一個程序裡用多個虛拟位址映射同一個實體位址。

另一個細節對程式員們來說比較乏味,那就是緩存的替換政策。大多數緩存會優先逐出最近最少使用(Least Recently Used,LRU)的元素。這往往是一個效果比較好的政策。在關聯性很大的情況下(随着以後核心數的增加,關聯性勢必會變得越來越大),維護LRU清單變得越來越昂貴,于是我們開始看到其它的一些政策。

在緩存的替換政策方面,程式員可以做的事情不多。如果緩存使用實體位址作為标簽,我們是無法找出虛拟位址與緩存集之間關聯的。有可能會出現這樣的情形: 所有邏輯頁中的緩存線都映射到同一個緩存集,而其它大部分緩存卻空閑着。即使有這種情況,也隻能依靠OS進行合理安排,避免頻繁出現。

虛拟化的出現使得這一切變得更加複雜。現在不僅作業系統可以控制實體記憶體的配置設定。虛拟機螢幕(VMM,也稱為 hypervisor)也負責配置設定記憶體。

對程式員來說,最好 a) 完全使用邏輯記憶體頁面 b) 在有意義的情況下,使用盡可能大的頁面大小來分散實體位址。更大的頁面大小也有其他好處,不過這是另一個話題(見第4節)。

3.4 指令緩存

其實,不光處理器使用的資料被緩存,它們執行的指令也是被緩存的。隻不過,指令緩存的問題相對來說要少得多,因為:

執行的代碼量取決于代碼大小。而代碼大小通常取決于問題複雜度。問題複雜度則是固定的。

程式的資料處理邏輯是程式員設計的,而程式的指令卻是編譯器生成的。編譯器的作者知道如何生成優良的代碼。

程式的流向比資料通路模式更容易預測。現如今的CPU很擅長模式檢測,對預取很有利。

代碼永遠都有良好的時間局部性和空間局部性。

有一些準則是需要程式員們遵守的,但大都是關于如何使用工具的,我們會在第6節介紹它們。而在這裡我們隻介紹一下指令緩存的技術細節。

随着CPU的核心頻率大幅上升,緩存與核心的速度差越拉越大,CPU的處理開始管線化。也就是說,指令的執行分成若幹階段。首先,對指令進行解碼,随後,準備參數,最後,執行它。這樣的管線可以很長(例如,Intel的Netburst架構超過了20個階段)。在管線很長的情況下,一旦發生延誤(即指令流中斷),需要很長時間才能恢複速度。管線延誤發生在這樣的情況下: 下一條指令未能正确預測,或者裝載下一條指令耗時過長(例如,需要從記憶體讀取時)。

為了解決這個問題,CPU的設計人員們在分支預測上投入大量時間和晶片資産(chip real estate),以降低管線延誤的出現頻率。

在CISC處理器上,指令的解碼階段也需要一些時間。x86及x86-64處理器尤為嚴重。近年來,這些處理器不再将指令的原始位元組序列存入L1i,而是緩存解碼後的版本。這樣的L1i被叫做“追蹤緩存(trace cache)”。追蹤緩存可以在命中的情況下讓處理器跳過管線最初的幾個階段,在管線發生延誤時尤其有用。

前面說過,L2以上的緩存是統一緩存,既儲存代碼,也儲存資料。顯然,這裡儲存的代碼是原始位元組序列,而不是解碼後的形式。

在提高性能方面,與指令緩存相關的隻有很少的幾條準則:

生成盡量少的代碼。也有一些例外,如出于管線化的目的需要更多的代碼,或使用小代碼會帶來過高的額外開銷。

盡量幫助處理器作出良好的預取決策,可以通過代碼布局或顯式預取來實作。

這些準則一般會由編譯器的代碼生成階段強制執行。至于程式員可以參與的部分,我們會在第6節介紹。

3.4.1 自修改的代碼

在計算機的早期歲月裡,記憶體十分昂貴。人們想盡千方百計,隻為了盡量壓縮程式容量,給資料多留一些空間。其中,有一種方法是修改程式自身,稱為自修改代碼(SMC)。現在,有時候我們還能看到它,一般是出于提高性能的目的,也有的是為了攻擊安全漏洞。

一般情況下,應該避免SMC。雖然一般情況下沒有問題,但有時會由于執行錯誤而出現性能問題。顯然,發生改變的代碼是無法放入追蹤緩存(追蹤緩存放的是解碼後的指令)的。即使沒有使用追蹤緩存(代碼還沒被執行或有段時間沒執行),處理器也可能會遇到問題。如果某個進入管線的指令發生了變化,處理器隻能扔掉目前的成果,重新開始。在某些情況下,甚至需要丢棄處理器的大部分狀态。

最後,由于處理器認為代碼頁是不可修改的(這是出于簡單化的考慮,而且在99.9999999%情況下确實是正确的),L1i用到并不是MESI協定,而是一種簡化後的SI協定。這樣一來,如果萬一檢測到修改的情況,就需要作出大量悲觀的假設。

是以,對于SMC,強烈建議能不用就不用。現在記憶體已經不再是一種那麼稀缺的資源了。最好是寫多個函數,而不要根據需要把一個函數改來改去。也許有一天可以把SMC變成可選項,我們就能通過這種方式檢測入侵代碼。如果一定要用SMC,應該讓寫操作越過緩存,以免由于L1i需要L1d裡的資料而産生問題。更多細節,請參見6.1節。

在Linux上,判斷程式是否包含SMC是很容易的。利用正常工具鍊(toolchain)建構的程式代碼都是寫保護(write-protected)的。程式員需要在連結時施展某些關鍵的魔術才能生成可寫的代碼頁。現代的Intel x86和x86-64處理器都有統計SMC使用情況的專用計數器。通過這些計數器,我們可以很容易判斷程式是否包含SMC,即使它被準許運作。

3.5 緩存未命中的因素

我們已經看過記憶體通路沒有命中緩存時,那陡然猛漲的高昂代價。但是有時候,這種情況又是無法避免的,是以我們需要對真正的代價有所認識,并學習如何緩解這種局面。

3.5.1 緩存與記憶體帶寬

為了更好地了解處理器的能力,我們測量了各種理想環境下能夠達到的帶寬值。由于不同處理器的版本差别很大,是以這個測試比較有趣,也因為如此,這一節都快被測試資料灌滿了。我們使用了x86和x86-64處理器的SSE指令來裝載和存儲資料,每次16位元組。工作集則與其它測試一樣,從1kB增加到512MB,測量的具體對象是每個周期所處理的位元組數。

每個程式員都應該了解的 CPU 高速緩存

圖3.24展示了一顆64位Intel Netburst處理器的性能圖表。當工作集能夠完全放入L1d時,處理器的每個周期可以讀取完整的16位元組資料,即每個周期執行一條裝載指令(moveaps指令,每次移動16位元組的資料)。測試程式并不對資料進行任何處理,隻是測試讀取指令本身。當工作集增大,無法再完全放入L1d時,性能開始急劇下降,跌至每周期6位元組。在218工作集處出現的台階是由于DTLB緩存耗盡,是以需要對每個新頁施加額外處理。由于這裡的讀取是按順序的,預取機制可以完美地工作,而FSB能以5.3位元組/周期的速度傳輸内容。但預取的資料并不進入L1d。當然,真實世界的程式永遠無法達到以上的數字,但我們可以将它們看作一系列實際上的極限值。

更令人驚訝的是寫操作和複制操作的性能。即使是在很小的工作集下,寫操作也始終無法達到4位元組/周期的速度。這意味着,Intel為Netburst處理器的L1d選擇了寫通(write-through)模式,是以寫入性能受到L2速度的限制。同時,這也意味着,複制測試的性能不會比寫入測試差太多(複制測試是将某塊記憶體的資料拷貝到另一塊不重疊的記憶體區),因為讀操作很快,可以與寫操作實作部分重疊。最值得關注的地方是,兩個操作在工作集無法完全放入L2後出現了嚴重的性能滑坡,降到了0.5位元組/周期!比讀操作慢了10倍!顯然,如果要提高程式性能,優化這兩個操作更為重要。

再來看圖3.25,它來自同一顆處理器,隻是運作雙線程,每個線程分别運作在處理器的一個超線程上。

每個程式員都應該了解的 CPU 高速緩存

圖3.25采用了與圖3.24相同的刻度,以友善比較兩者的差異。圖3.25中的曲線抖動更多,是由于采用雙線程的緣故。結果正如我們預期,由于超線程共享着幾乎所有資源(僅除寄存器外),是以每個超線程隻能得到一半的緩存和帶寬。是以,即使每個線程都要花上許多時間等待記憶體,進而把執行時間讓給另一個線程,也是無濟于事——因為另一個線程也同樣需要等待。這裡恰恰展示了使用超線程時可能出現的最壞情況。

每個程式員都應該了解的 CPU 高速緩存

再來看Core 2處理器的情況。看看圖3.26和圖3.27,再對比下P4的圖3.24和3.25,可以看出不小的差異。Core 2是一顆雙核處理器,有着共享的L2,容量是P4 L2的4倍。但更大的L2隻能解釋寫操作的性能下降出現較晚的現象。

當然還有更大的不同。可以看到,讀操作的性能在整個工作集範圍内一直穩定在16位元組/周期左右,在220處的下降同樣是由于DTLB的耗盡引起。能夠達到這麼高的數字,不但表明處理器能夠預取資料,并且按時完成傳輸,而且還意味着,預取的資料是被裝入L1d的。

寫/複制操作的性能與P4相比,也有很大差異。處理器沒有采用寫通政策,寫入的資料留在L1d中,隻在必要時才逐出。這使得寫操作的速度可以逼近16位元組/周期。一旦工作集超過L1d,性能即飛速下降。由于Core 2讀操作的性能非常好,是以兩者的內插補點顯得特别大。當工作集超過L2時,兩者的內插補點甚至超過20倍!但這并不表示Core 2的性能不好,相反,Core 2永遠都比Netburst強。

每個程式員都應該了解的 CPU 高速緩存

在圖3.27中,啟動雙線程,各自運作在Core 2的一個核心上。它們通路相同的記憶體,但不需要完美同步。從結果上看,讀操作的性能與單線程并無差別,隻是多了一些多線程情況下常見的抖動。

有趣的地方來了——當工作集小于L1d時,寫操作與複制操作的性能很差,就好像資料需要從記憶體讀取一樣。兩個線程彼此競争着同一個記憶體位置,于是不得不頻頻發送RFO消息。問題的根源在于,雖然兩個核心共享着L2,但無法以L2的速度處理RFO請求。而當工作集超過L1d後,性能出現了迅猛提升。這是因為,由于L1d容量不足,于是将被修改的條目重新整理到共享的L2。由于L1d的未命中可以由L2滿足,隻有那些尚未重新整理的資料才需要RFO,是以出現了這樣的現象。這也是這些工作集情況下速度下降一半的原因。這種漸進式的行為也與我們期待的一緻: 由于每個核心共享着同一條FSB,每個核心隻能得到一半的FSB帶寬,是以對于較大的工作集來說,每個線程的性能大緻相當于單線程時的一半。

由于同一個廠商的不同處理器之間都存在着巨大差異,我們沒有理由不去研究一下其它廠商處理器的性能。圖3.28展示了AMD家族10h Opteron處理器的性能。這顆處理器有64kB的L1d、512kB的L2和2MB的L3,其中L3緩存由所有核心所共享。

每個程式員都應該了解的 CPU 高速緩存

大家首先應該會注意到,在L1d緩存足夠的情況下,這個處理器每個周期能處理兩條指令。讀操作的性能超過了32位元組/周期,寫操作也達到了18.7位元組/周期。但是,不久,讀操作的曲線就急速下降,跌到2.3位元組/周期,非常差。處理器在這個測試中并沒有預取資料,或者說,沒有有效地預取資料。

另一方面,寫操作的曲線随幾級緩存的容量而流轉。在L1d階段達到最高性能,随後在L2階段下降到6位元組/周期,在L3階段進一步下降到2.8位元組/周期,最後,在工作集超過L3後,降到0.5位元組/周期。它在L1d階段超過了Core 2,在L2階段基本相當(Core 2的L2更大一些),在L3及主存階段比Core 2慢。

複制的性能既無法超越讀操作的性能,也無法超越寫操作的性能。是以,它的曲線先是被讀性能壓制,随後又被寫性能壓制。

圖3.29顯示的是Opteron處理器在多線程時的性能表現。

每個程式員都應該了解的 CPU 高速緩存

讀操作的性能沒有受到很大的影響。每個線程的L1d和L2表現與單線程下相仿,L3的預取也依然表現不佳。兩個線程并沒有過渡争搶L3。問題比較大的是寫操作的性能。兩個線程共享的所有資料都需要經過L3,而這種共享看起來卻效率很差。即使是在L3足夠容納整個工作集的情況下,所需要的開銷仍然遠高于L3的通路時間。再來看圖3.27,可以發現,在一定的工作集範圍内,Core 2處理器能以共享的L2緩存的速度進行處理。而Opteron處理器隻能在很小的一個範圍内實作相似的性能,而且,它僅僅隻能達到L3的速度,無法與Core 2的L2相比。

3.5.2 關鍵字加載

記憶體以比緩存線還小的塊從主存儲器向緩存傳送。如今64位可一次性傳送,緩存線的大小為64或128比特。這意味着每個緩存線需要8或16次傳送。

DRAM晶片可以以觸發模式傳送這些64位的塊。這使得不需要記憶體控制器的進一步指令和可能伴随的延遲,就可以将緩存線充滿。如果處理器預取了緩存,這有可能是最好的操作方式。

如果程式在通路資料或指令緩存時沒有命中(這可能是強制性未命中或容量性未命中,前者是由于資料第一次被使用,後者是由于容量限制而将緩存線逐出),情況就不一樣了。程式需要的并不總是緩存線中的第一個字,而資料塊的到達是有先後順序的,即使是在突發模式和雙倍傳輸率下,也會有明顯的時間差,一半在4個CPU周期以上。舉例來說,如果程式需要緩存線中的第8個字,那麼在首字抵達後它還需要額外等待30個周期以上。

當然,這樣的等待并不是必需的。事實上,記憶體控制器可以按不同順序去請求緩存線中的字。當處理器告訴它,程式需要緩存中具體某個字,即「關鍵字(critical word)」時,記憶體控制器就會先請求這個字。一旦請求的字抵達,雖然緩存線的剩餘部分還在傳輸中,緩存的狀态還沒有達成一緻,但程式已經可以繼續運作。這種技術叫做關鍵字優先及較早重新開機(Critical Word First & Early Restart)。

現在的處理器都已經實作了這一技術,但有時無法運用。比如,預取操作的時候,并不知道哪個是關鍵字。如果在預取的中途請求某條緩存線,處理器隻能等待,并不能更改請求的順序。

每個程式員都應該了解的 CPU 高速緩存

在關鍵字優先技術生效的情況下,關鍵字的位置也會影響結果。圖3.30展示了下一個測試的結果,圖中表示的是關鍵字分别線上首和線尾時的性能對比情況。元素大小為64位元組,等于緩存線的長度。圖中的噪聲比較多,但仍然可以看出,當工作集超過L2後,關鍵字處于線尾情況下的性能要比線首情況下低0.7%左右。而順序通路時受到的影響更大一些。這與我們前面提到的預取下條線時可能遇到的問題是相符的。

3.5.3 緩存設定

緩存放置的位置與超線程,核心和處理器之間的關系,不在程式員的控制範圍之内。但是程式員可以決定線程執行的位置,接着高速緩存與使用的CPU的關系将變得非常重要。

這裡我們将不會深入(探讨)什麼時候選擇什麼樣的核心以運作線程的細節。我們僅僅描述了在設定關聯線程的時候,程式員需要考慮的系統結構的細節。

超線程,通過定義,共享除去寄存器集以外的所有資料。包括 L1 緩存。這裡沒有什麼可以多說的。多核處理器的獨立核心帶來了一些樂趣。每個核心都至少擁有自己的 L1 緩存。除此之外,下面列出了一些不同的特性:

早期多核心處理器有獨立的 L2 緩存且沒有更高層級的緩存。

之後英特爾的雙核心處理器模型擁有共享的L2 緩存。對四核處理器,則分對擁有獨立的L2 緩存,且沒有更高層級的緩存。

AMD 家族的 10h 處理器有獨立的 L2 緩存以及一個統一的L3 緩存。

關于各種處理器模型的優點,已經在它們各自的宣傳手冊裡寫得夠多了。在每個核心的工作集互不重疊的情況下,獨立的L2擁有一定的優勢,單線程的程式可以表現優良。考慮到目前實際環境中仍然存在大量類似的情況,這種方法的表現并不會太差。不過,不管怎樣,我們總會遇到工作集重疊的情況。如果每個緩存都儲存着某些通用運作庫的常用部分,那麼很顯然是一種浪費。

如果像Intel的雙核處理器那樣,共享除L1外的所有緩存,則會有一個很大的優點。如果兩個核心的工作集重疊的部分較多,那麼綜合起來的可用緩存容量會變大,進而允許容納更大的工作集而不導緻性能的下降。如果兩者的工作集并不重疊,那麼則是由Intel的進階智能緩存管理(Advanced Smart Cache management)發揮功用,防止其中一個核心壟斷整個緩存。

即使每個核心隻使用一半的緩存,也會有一些摩擦。緩存需要不斷衡量每個核心的用量,在進行逐出操作時可能會作出一些比較差的決定。我們來看另一個測試程式的結果。

每個程式員都應該了解的 CPU 高速緩存

這次,測試程式兩個程序,第一個程序不斷用SSE指令讀/寫2MB的記憶體資料塊,選擇2MB,是因為它正好是Core 2處理器L2緩存的一半,第二個程序則是讀/寫大小變化的記憶體區域,我們把這兩個程序分别固定在處理器的兩個核心上。圖中顯示的是每個周期讀/寫的位元組數,共有4條曲線,分别表示不同的讀寫搭配情況。例如,标記為讀/寫(read/write)的曲線代表的是背景程序進行寫操作(固定2MB工作集),而被測量程序進行讀操作(工作集從小到大)。

圖中最有趣的是220到223之間的部分。如果兩個核心的L2是完全獨立的,那麼所有4種情況下的性能下降均應發生在221到222之間,也就是L2緩存耗盡的時候。但從圖上來看,實際情況并不是這樣,特别是背景程序進行寫操作時尤為明顯。當工作集達到1MB(220)時,性能即出現惡化,兩個程序并沒有共享記憶體,是以并不會産生RFO消息。是以,完全是緩存逐出操作引起的問題。目前這種智能的緩存處理機制有一個問題,每個核心能實際用到的緩存更接近1MB,而不是理論上的2MB。如果未來的處理器仍然保留這種多核共享緩存模式的話,我們唯有希望廠商會把這個問題解決掉。

推出擁有雙L2緩存的4核處理器僅僅隻是一種臨時措施,是開發更進階緩存之前的替代方案。與獨立插槽及雙核處理器相比,這種設計并沒有帶來多少性能提升。兩個核心是通過同一條總線(被外界看作FSB)進行通信,并沒有什麼特别快的資料交換通道。

未來,針對多核處理器的緩存将會包含更多層次。AMD的10h家族是一個開始,至于會不會有更低級共享緩存的出現,還需要我們拭目以待。我們有必要引入更多級别的緩存,因為頻繁使用的高速緩存不可能被許多核心共用,否則會對性能造成很大的影響。我們也需要更大的高關聯性緩存,它們的數量、容量和關聯性都應該随着共享核心數的增長而增長。巨大的L3和适度的L2應該是一種比較合理的選擇。L3雖然速度較慢,但也較少使用。

對于程式員來說,不同的緩存設計就意味着排程決策時的複雜性。為了達到最高的性能,我們必須掌握工作負載的情況,必須了解機器架構的細節。好在我們在判斷機器架構時還是有一些支援力量的,我們會在後面的章節介紹這些接口。

3.5.4 FSB的影響

FSB在性能中扮演了核心角色。緩存資料的存取速度受制于記憶體通道的速度。我們做一個測試,在兩台機器上分别跑同一個程式,這兩台機器除了記憶體子產品的速度有所差異,其它完全相同。圖3.32展示了Addnext0測試(将下一個元素的pad[0]加到目前元素的pad[0]上)在這兩台機器上的結果(NPAD=7,64位機器)。兩台機器都采用Core 2處理器,一台使用667MHz的DDR2記憶體,另一台使用800MHz的DDR2記憶體(比前一台增長20%)。

每個程式員都應該了解的 CPU 高速緩存

圖上的數字表明,當工作集大到對FSB造成壓力的程度時,高速FSB确實會帶來巨大的優勢。在我們的測試中,性能的提升達到了18.5%,接近理論上的極限。而當工作集比較小,可以完全納入緩存時,FSB的作用并不大。當然,這裡我們隻測試了一個程式的情況,在實際環境中,系統往往運作多個程序,工作集是很容易超過緩存容量的。

如今,一些英特爾的處理器,支援前端總線(FSB)的速度高達1,333 MHz,這意味着速度有另外60%的提升。将來還會出現更高的速度。速度是很重要的,工作集會更大,快速的RAM和高FSB速度的記憶體肯定是值得投資的。我們必須小心使用它,因為即使處理器可以支援更高的前端總線速度,但是主機闆的北橋晶片可能不會。使用時,檢查它的規範是至關重要的。

繼續閱讀