What every programmer should know about memory, Part 1(筆記)
2.商用硬件現狀
現在硬件的組成對於pc機而言基本上都是一下的結構:
由2部分組成:南橋,北橋
CPU通過FSB(前端總線)連接到北橋芯片,北橋芯片主要包含內存控制器和其他一些組件,內存控制器決定了內存的類型,SDRAM,DRAM等都需要不同類型的內存控制器。
南橋芯片主要是通過多條不同的總線和設備通信,主要有PCI,SATA,USB等還支持PATA,IEEE 1394,串口和並口。
需要注意一下地方:
1.cpu之間的通信需要通過它與北橋之間的連接總線
2.與RAM的通信需要走北橋芯片
3.RAM只有一個端口
4.CPU和南橋設備通信需要經過北橋
第一個瓶頸:早期RAM的訪問都要經過cpu,影響了整體性能,所以出現了DMA(直接內存訪問),無需CPU干涉但是會導致和CPU爭奪北橋的帶寬。
第二個瓶頸:北橋和RAM之間的總線所以出現了雙通道(可以實現帶寬加倍,內存訪問在兩個通道上交錯分配)
除了並發訪問模式也是有瓶頸的看2.2
比較昂貴的系統上可能會出現:
北橋自身不帶內存控制器,而是連接到外部多個內存控制器上,好處是支持更多的內存,可以同時訪問不同的內存區,降低了延遲,但是對北橋的內部帶寬要求巨大。
使用外部內存控制器並不是唯一的辦法,比較流行的還有一種是把控制器集成到cpu內部,將內存直接連接到CPU
這樣的架構,系統里有幾個cpu就可以有幾個內存庫(memory bank),不需要強大的北橋就可以實現4倍的內存帶寬。但是缺點也是很明顯:1.導致內存不再是統一的資源(NUMA的得名),2.cpu可以正常的訪問本地內存,但是訪問其他內存時需要和其他cpu互通。
在討論訪問遠端內存的代價時,我們用「NUMA因子」這個詞。
比如說IBM的x445和SGI的Altix系列。CPU被歸入節點,節點內的內存訪問時間是一致的,或者只有很小的NUMA因子。而在節點之間的連接代價很大,而且有巨大的NUMA因子。
2.1 RAM類型
RAM主要分為2中靜態RAM,動態RAM,前者速度快,代價搞,后者速度慢代價低
2.1.1靜態RAM
主要有6個晶體管組成,核心是4個晶體管M1-M4,他們有2個穩定狀態分別代表0和1
2.1.2 動態RAM
動態RAM只有一個晶體管和一個電容
動態RAM優點是簡單,但是缺點是由於讀取狀態時需要對電容器放電,所以這一過程不能無限重復,不得不在某個點上對它重新充電。更糟糕的是,為了容納大量單元(現在一般在單個芯片上容納10的9次方以上的RAM單元),電容器的容量必須很小(0.000000000000001法拉以下)。這樣,完整充電后大約持有幾萬個電子。即使電容器的電阻很大(若干兆歐姆),仍然只需很短的時間就會耗光電荷,稱為「泄漏」。這種泄露就是現在的大部分DRAM芯片每隔64ms就必須進行一次刷新的原因。(附A關於三極管的輸入輸出特性)
2.2 DRAM訪問細節
同步DRAM,顧名思義,是參照一個時間源工作的。由內存控制器提供一個時鍾,時鍾的頻率決定了前端總線(FSB)的速度。以今天的SDRAM為例,每次數據傳輸包含64位,即8字節。所以FSB的傳輸速率應該是有效總線頻率乘於8字節(對於4倍傳輸200MHz總線而言,傳輸速率為6.4GB/s)。聽起來很高,但要知道這只是峰值速率,實際上無法達到的最高速率。我們將會看到,與RAM模塊交流的協議有大量時間是處於非工作狀態,不進行數據傳輸。我們必須對這些非工作時間有所了解,並盡量縮短它們,才能獲得最佳的性能。
2.2.1讀訪問協議
這里忽略了許多細節,我們只關注時鍾頻率、RAS與CAS信號、地址總線和數據總線。首先,內存控制器將行地址放在地址總線上,並降低RAS信號,讀周期開始。所有信號都在時鍾(CLK)的上升沿讀取,因此,只要信號在讀取的時間點上保持穩定,就算不是標准的方波也沒有關系。設置行地址會促使RAM芯片鎖住指定的行。
CAS信號在tRCD(RAS到CAS時延)個時鍾周期后發出。內存控制器將列地址放在地址總線上,降低CAS線。這里我們可以看到,地址的兩個組成部分是怎么通過同一條總線傳輸的。
既然數據的傳輸需要這么多的准備工作,僅僅傳輸一個字顯然是太浪費了。因此,DRAM模塊允許內存控制指定本次傳輸多少數據。可以是2、4或8個字。這樣,就可以一次填滿高速緩存的整條線,而不需要額外的RAS/CAS序列。另外,內存控制器還可以在不重置行選擇的前提下發送新的CAS信號。這樣,讀取或寫入連續的地址就可以變得非常快,因為不需要發送RAS信號,也不需要把行置為非激活狀態(見下文)。
在上圖中,SDRAM的每個周期輸出一個字的數據。這是第一代的SDRAM。而DDR可以在一個周期中輸出兩個字。這種做法可以減少傳輸時間,但無法降低時延。
2.2.2預充電和激活
2.2.1中的圖只是讀取數據的一部分,還有以下部分:
顯示的是兩次CAS信號的時序圖。第一次的數據在CL周期后准備就緒。圖中的例子里,是在SDRAM上,用兩個周期傳輸了兩個字的數據。如果換成DDR的話,則可以傳輸4個字。即使是在一個命令速率為1的DRAM模塊上,也無法立即發出預充電命令,而要等數據傳輸完成。在上圖中,即為兩個周期。剛好與CL相同,但只是巧合而已。預充電信號並沒有專用線,某些實現是用同時降低寫使能(WE)線和RAS線的方式來觸發。
發出預充電信命令后,還需等待tRP(行預充電時間)個周期之后才能使行被選中。在圖2.9中,這個時間(紫色部分)大部分與內存傳輸的時間(淡藍色部分)重合。不錯。但tRP大於傳輸時間,因此下一個RAS信號只能等待一個周期。
數據總線的7個周期中只有2個周期才是真正在用的。再用它乘於FSB速度,結果就是,800MHz總線的理論速率6.4GB/s降到了1.8GB/s
我們會看到預充電指令被數據傳輸時間限制(途中為COL Addr的傳輸)除此之外,SDRAM模塊在RAS信號之后,需要經過一段時間,才能進行預充電(記為tRAS)(
minimum active to precharge time(也就是RAS信號之后到充電的最小時間間隔))它的值很大,一般達到tRP的2到3倍。如果在某個RAS信號之后,只有一個CAS信號,而且數據只傳輸很少幾個周期,那么就有問題了。假設在圖2.9中,第一個CAS信號是直接跟在一個RAS信號后免的,而tRAS為8個周期。那么預充電命令還需要被推遲一個周期,因為tRCD、CL和tRP加起來才7個周期。
DDR模塊往往用w-z-y-z-T來表示。例如,2-3-2-8-T1,意思是:
w 2 CAS時延(CL)
x 3 RAS-to-CAS時延(t RCD)
y 2 RAS預充電時間(t RP)
z 8 激活到預充電時間(t RAS)
T T1 命令速率
2.2.3重充電
充電對內存是性能最大的影響,根據JEDEC規范,DRAM單元必須保持每64ms刷新一次我們在解讀性能參數時有必要知道,它也是DRAM生命周期的一個部分。如果系統需要讀取某個重要的字,而剛好它所在的行正在刷新,那么處理器將會被延遲很長一段時間。刷新的具體耗時取決於DRAM模塊本身。
2.2.4內存類型
以下是SDR(SDRAME)的操作圖比較簡單
DRAM陣列的頻率和總線的頻率保持相同,但是當所有組件頻率上升時,那么導致耗電量也上升代價很大,所以就出了DDR,在不提高頻率的狀況下提高吞吐量
SDR和DDR1的區別就是DDR1可以在上升沿和下降沿都傳輸數據,實現了雙倍的傳輸。DDR引入了一個緩沖區。緩沖區的每條數據線都持有兩位。它要求內存單元陣列的數據總線包含兩條線。實現的方式很簡單,用同一個列地址同時訪問兩個DRAM單元。對單元陣列的修改也很小。
為了進一步,於是有了DDR2,最明顯的變化是,總線的頻率加倍了。頻率的加倍意味着帶寬的加倍。如果對單元陣列的頻率加倍,顯然是不經濟的,因此DDR2要求I/O緩沖區在每個時鍾周期讀取4位。也就是說,DDR2的變化僅在於使I/O緩沖區運行在更高的速度上。這是可行的,而且耗電也不會顯著增加。DDR2的命名與DDR1相仿,只是將因子2替換成4
| 陣列頻率 | 總線頻率 | 數據率 | 名稱(速率) | 名稱 (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 |
FSB速度是用有效頻率來標記的,即把上升、下降沿均傳輸數據的因素考慮進去,因此數字被撐大了。所以,擁有266MHz總線的133MHz模塊有着533MHz的FSB“頻率”
DDR3變化更多,電壓從1.8下降到了1.5於是耗電也變小了,或者說保持相同的耗電,ddr3可以達到更高的頻率或者,保持同樣的熱能釋放,實現容量翻番
DDR3模塊的單元陣列將運行在內部總線的四分之一速度上,DDR3的I/O緩沖區從DDR2的4位提升到8位
DDR3可能會有一個問題,即在1600Mb/s或更高速率下,每個通道的模塊數可能會限制為1。在早期版本中,這一要求是針對所有頻率的。我們希望這個要求可以提高一些,否則系統容量將會受到嚴重的限制。
我們預計中各DDR3模塊的名稱。JEDEC目前同意了前四種。由於Intel的45nm處理器是1600Mb/s的FSB,1866Mb/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 |
所有的DDR內存都有一個問題:不斷增加的頻率使得建立並行數據總線變得十分困難。一個DDR2模塊有240根引腳。所有到地址和數據引腳的連線必須被布置得差不多一樣長。更大的問題是,如果多於一個DDR模塊通過菊花鏈連接在同一個總線上,每個模塊所接收到的信號隨着模塊的增加會變得越來越扭曲。
DDR2規范允許每條總線(又稱通道)連接最多兩個模塊,DDR3在高頻率下只允許每個通道連接一個模塊。每條總線多達240根引腳使得單個北橋無法以合理的方式驅動兩個通道。替代方案是增加外部內存控制器,但這會提高成本。
一種解法是,在處理器中加入內存控制器
另外一種是,Intel針對大型服務器方面的解法(至少在未來幾年),是被稱為全緩沖DRAM(FB-DRAM)的技術。FB-DRAM采用與DDR2相同的器件,因此造價低廉。不同之處在於它們與內存控制器的連接方式。FB-DRAM沒有用並行總線,而用了串行總線。串行總線可以達到更高的頻率,串行化的負面影響,甚至可以增加帶寬。使用串行總線后
- 每個通道可以使用更多的模塊。
- 每個北橋/內存控制器可以使用更多的通道。
- 串行總線是全雙工的(兩條線)。
FB-DRAM只有69個腳。通過菊花鏈方式連接多個FB-DRAM也很簡單。FB-DRAM規范允許每個通道連接最多8個模塊。在對比下雙通道北橋的連接性,采用FB-DRAM后,北橋可以驅動6個通道,而且腳數更少——6x69對比2x240。每個通道的布線也更為簡單,有助於降低主板的成本。全雙工的並行總線過於昂貴。而換成串行線后,這不再是一個問題,因此串行總線按全雙工來設計的,這也意味着,在某些情況下,僅靠這一特性,總線的理論帶寬已經翻了一倍。還不止於此。由於FB-DRAM控制器可同時連接6個通道,因此可以利用它來增加某些小內存系統的帶寬。對於一個雙通道、4模塊的DDR2系統,我們可以用一個普通FB-DRAM控制器,用4通道來實現相同的容量。串行總線的實際帶寬取決於在FB-DRAM模塊中所使用的DDR2(或DDR3)芯片的類型。
DDR2 |
FB-DRAM | |
|---|---|---|
| 腳 | 240 | 69 |
| 通道 | 2 | 6 |
| 每通道DIMM數 | 2 | 8 |
| 最大內存 | 16GB | 192GB |
| 吞吐量 | ~10GB/s | ~40GB/s |
2.2.5 結論
通過本節,大家應該了解到訪問DRAM的過程並不是一個快速的過程。至少與處理器的速度相比,或與處理器訪問寄存器及緩存的速度相比,DRAM的訪問不算快。大家還需要記住CPU和內存的頻率是不同的。Intel Core 2處理器運行在2.933GHz,而1.066GHz FSB有11:1的時鍾比率(注: 1.066GHz的總線為四泵總線)。那么,內存總線上延遲一個周期意味着處理器延遲11個周期。絕大多數機器使用的DRAM更慢,因此延遲更大。
前文中讀命令的時序圖表明,DRAM模塊可以支持高速數據傳輸。每個完整行可以被毫無延遲地傳輸。數據總線可以100%被占。對DDR而言,意味着每個周期傳輸2個64位字。對於DDR2-800模塊和雙通道而言,意味着12.8GB/s的速率。
但是,除非是特殊設計,DRAM的訪問並不總是串行的。訪問不連續的內存區意味着需要預充電和RAS信號。於是,各種速度開始慢下來,DRAM模塊急需幫助。預充電的時間越短,數據傳輸所受的懲罰越小。
硬件和軟件的預取(參見第6.3節)可以在時序中制造更多的重疊區,降低延遲。預取還可以轉移內存操作的時間,從而減少爭用。我們常常遇到的問題是,在這一輪中生成的數據需要被存儲,而下一輪的數據需要被讀出來。通過轉移讀取的時間,讀和寫就不需要同時發出了
2.3主存的其他用戶
除了CPU外,系統中還有其它一些組件也可以訪問主存。高性能網卡或大規模存儲控制器是無法承受通過CPU來傳輸數據的,它們一般直接對內存進行讀寫(直接內存訪問,DMA)。在圖2.1中可以看到,它們可以通過南橋和北橋直接訪問內存。另外,其它總線,比如USB等也需要FSB帶寬,即使它們並不使用DMA,但南橋仍要通過FSB連接到北橋。
DMA當然有很大的優點,但也意味着FSB帶寬會有更多的競爭。在有大量DMA流量的情況下,CPU在訪問內存時必然會有更大的延遲。我們可以用一些硬件來解決這個問題。例如,通過圖2.3中的架構,我們可以挑選不受DMA影響的節點,讓它們的內存為我們的計算服務。還可以在每個節點上連接一個南橋,將FSB的負荷均勻地分擔到每個節點上。
附A
三極管 有3個極1.發射極(e),基極(b),集電極(c)
輸入特性:當b輸入一定電壓是,發射端導通
輸出特性:當c,e之間加點壓,電流大小,有基極輸入電流控制c和e之間的電流大小
這樣對動態內存單元中的電容來說每次讀取就是一個放過程,所以讀完之后要重新充電
3.1 高速緩存的位置
早期的一些系統就是類似的架構。在這種架構中,CPU核心不再直連到主存。數據的讀取和存儲都經過高速緩存。主存與高速緩存都連到系統總線上,這條總線同時還用於與其它組件通信。我們管這條總線叫“FSB”.
在高速緩存出現后不久,系統變得更加復雜。高速緩存與主存之間的速度差異進一步拉大,直到加入了另一級緩存。新加入的這一級緩存比第一級緩存更大,但是更慢。由於加大一級緩存的做法從經濟上考慮是行不通的,所以有了二級緩存,甚至現在的有些系統擁有三級緩存.
L1d是一級數據緩存,L1i是一級指令緩存,等等。請注意,這只是示意圖,
真正的數據流並不需要流經上級緩存。CPU的設計者們在設計高速緩存的接口時擁有很大的自由。
我們有多核CPU,每個核心可以有多個“線程”。核心與線程的不同之處在於,核心擁有獨立的硬件資源
3.2 高級的緩存操作
默認情況下,CPU核心所有的數據的讀或寫都存儲在緩存中。當然,也有內存區域不能被緩存的
如果CPU需要訪問某個字(word),先檢索緩存。很顯然,緩存不可能容納主存所有內容(否則還需要主存干嘛)。系統用字的內存地址來對緩存條目進行標記。如果需要讀寫某個地址的字,那么根據標簽來檢索緩存即可(后面會介紹還會使用地址來計算緩存的地址)
標簽是需要額外空間的,用字作為緩存的粒度顯然毫無效率。因為標簽可能也有32位(x86上)。內存模塊在傳輸位於同一行上的多份數據時,由於不需要發送新CAS信號,甚至不需要發送RAS信號,因此可以實現很高的效率。基於以上的原因,緩存條目並不存儲單個字,而是存儲若干連續字組成的“線”。在早期的緩存中,線長是32字節,現在一般是64字節。對於64位寬的內存總線,每條線需要8次傳輸。而DDR對於這種傳輸模式的支持更為高效。
當處理器需要內存中的某塊數據時,整條緩存線被裝入L1d。緩存線的地址通過對內存地址進行掩碼操作生成。對於64字節的緩存線,是將低6位置0。這些被丟棄的位作為線內偏移量。其它的位作為標簽,並用於在緩存內定位。在實踐中,我們將地址分為三個部分。32位地址的情況如下:
如果緩存線長度為2O,那么地址的低O位用作線內偏移量。上面的S位選擇“緩存集”。后面我們會說明使用緩存集的原因。現在只需要明白一共有2S個緩存集就夠了。剩下的32 - S - O = T位組成標簽。它們用來同一個cache set中的各條線
當某條指令修改內存時,仍然要先裝入緩存線,因為任何指令都不可能同時修改整條線。因此需要在寫操作前先把緩存線裝載進來。如果緩存線被寫入,但還沒有寫回主存,那就是所謂的“臟了”。臟了的線一旦寫回主存,臟標記即被清除。為了裝入新數據,基本上總是要先在緩存中清理出位置。L1d將內容逐出L1d,推入L2(線長相同)。當然,L2也需要清理位置。於是L2將內容推入L3,最后L3將它推入主存。這種逐出操作一級比一級昂貴。(所以AMD公司通常使用exclusive caches見
附錄1,Intel使用inclusive cache)
在對稱多處理器(SMP)架構的系統中,CPU的高速緩存不能獨立的工作。在任何時候,所有的處理器都應該擁有相同的內存內容。保證這樣的統一的內存視圖被稱為“高速緩存一致性”從一個處理器直接訪問另一個處理器的高速緩存這種模型設計代價將是非常昂貴的,它是一個相當大的瓶頸。而是使用,當另一個處理器要讀取或寫入到高速緩存線上時,處理器會去檢測。
如果CPU檢測到一個寫訪問,而且該CPU的cache中已經緩存了一個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的寫訪問會導致第二個cpu的cache line的所有拷貝被標記為無效。
對於寫入,cpu不需要等待安全的寫入,只要能模擬這個效果,cpu就可以走捷徑
以下是隨機寫入的圖:
圖中有三個比較明顯的不同階段。很正常,這個處理器有L1d和L2,沒有L3。根據經驗可以推測出,L1d有2**13字節,而L2有2**20字節。因為,如果整個工作集都可以放入L1d,那么只需不到10個周期就可以完成操作。如果工作集超過L1d,處理器不得不從L2獲取數據,於是時間飄升到28個周期左右。如果工作集更大,超過了L2,那么時間進一步暴漲到480個周期以上。這時候,許多操作將不得不從主存中獲取數據。更糟糕的是,如果修改了數據,還需要將這些臟了的緩存線寫回內存。(
為什么工作集超過了L1d就會從L2中取數若為讀,當讀入的數據不在L1就要向L2獲取,不在L2就要想L3或者內存獲取,工作集越大,導致L1,L2被填滿。若是寫同理學入到臟數據存放在L1中,時間一長L1填滿依次類推)
3.3 CPU緩存實現的細節
3.3.1 關聯性
我們可以讓緩存的每條線能存放任何內存地址的數據。這就是所謂的全關聯緩存(fully associative cache)。這種緩存方式,如果處理器要訪問某條線,那么需要所有的cacheline的tag和請求的地址比較。
全關聯:優點,可以放任意地址的數據,不會出現類似直接映射一樣的狀況,如果數據分布不均勻導致被換出,從而導致miss增加
缺點,當cpu給出一個地址訪問某一個緩存線,需要掃描所有的緩存
直接映射:優點,電路簡單,查詢某個元素的速度很快。因為一個元素出現在cache的位子是固定的。
缺點,如果數據分布在同一個set那么,會導致miss大大的提高
組關聯:這個是當前普遍的使用方法。是直接映射和全關聯的折中辦法
| L2 Cache Size |
Associativity | |||||||
|---|---|---|---|---|---|---|---|---|
| Direct | 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 |
表說明:關聯度,cache大小,cacheline大小對miss的影響。從上面數據得出的結論是CL64比CL32miss要少,cache越到miss越少,關聯度越高miss越少
圖是表數據的圖標化更容易看出,問題其中CL大小為32。cache size越大miss越少,關聯越大miss 越少
在其他文獻中提到說增加關聯度和增加緩存有相同的效果,這個當然是不正確的看圖中,4m-8m直接關聯和,2路關聯是有一樣的提升,但是當緩存越來越大就不好說了。測試程序的workset只有5.6M,使用8MB之后自然無法體現優勢。但是當workset越來越大,小緩存的關聯性就體現出了巨大的優勢。
隨着多核cpu的出現,相對來說cache的大小就被平分,因此關聯性就顯得比較重要。但是已經到了16路關聯,如果再加顯然比較困難,所以就有廠家開始考慮使用3級緩存。
3.3.2 Cache的性能測試
用於測試程序的數據可以模擬一個任意大小的工作集:包括讀、寫訪問,隨機、連續訪問。在圖3.4中我們可以看到,程序為工作集創建了一個與其大小和元素類型相同的數組:
struct l {
struct l *n;
long int pad[NPAD];
};
2**N 字節的工作集包含2**N/sizeof(struct l)個元素。顯然sizeof(struct l) 的值取決於NPAD的大小。在32位系統上,NPAD=7意味着數組的每個元素的大小為32字節,在64位系統上,NPAD=7意味着數組的每個元素的大小為64字節。(
關於如何CHECK,我還不知道)
單線程順序訪問
最簡單的情況就是遍歷鏈表中順序存儲的節點。無論是從前向后處理,還是從后向前,對於處理器來說沒有什么區別。下面的測試中,我們需要得到處理鏈表中一個元素所需要的時間,以CPU時鍾周期最為計時單元。圖顯示了測試結構。除非有特殊說明, 所有的測試都是在Pentium 4 64-bit 平台上進行的,因此結構體l中NPAD=0,大小為8字節。
順序讀訪問, NPAD=0
順序讀多個字節
一開始的兩個測試數據收到了噪音的污染。由於它們的工作負荷太小,無法過濾掉系統內其它進程對它們的影響。我們可以認為它們都是4個周期以內的。這樣一來,整個圖可以划分為比較明顯的三個部分:
- 工作集小於2**14字節的。
- 工作集從2**15字節到2**20字節的。
- 工作集大於2**21字節的。
這樣的結果很容易解釋——是因為處理器有16KB的L1d和1MB的L2。
L1d的部分跟我們預想的差不多,在一台P4上耗時為4個周期左右。但L2的結果則出乎意料。大家可能覺得需要14個周期以上,但實際只用了9個周期。這要歸功於處理器先進的處理邏輯,當它使用連續的內存區時,會 預先讀取下一條緩存線的數據。這樣一來,當真正使用下一條線的時候,其實已經早已讀完一半了,於是真正的等待耗時會比L2的訪問時間少很多。
在工作集超過L2的大小之后,預取的效果更明顯了。前面我們說過,主存的訪問需要耗時200個周期以上。但在預取的幫助下,實際耗時保持在9個周期左右。200 vs 9,效果非常不錯。
在L2階段,三條新加的線基本重合,而且耗時比老的那條線高很多,大約在28個周期左右,差不多就是L2的訪問時間。這表明,從L2到L1d的預取並沒有生效。這是因為,對於最下面的線(NPAD=0),由於結構小,8次循環后才需要訪問一條新緩存線,而上面三條線對應的結構比較大,拿相對最小的NPAD=7來說,光是一次循環就需要訪問一條新線,更不用說更大的NPAD=15和31了。而預取邏輯是無法在每個周期裝載新線的,因此每次循環都需要從L2讀取,我們看到的就是從L2讀取的時延。
(有一點想不通作者這里說是28個周期是L2的訪問時間,但是上面為什么說是14個周期,有一種不靠譜的感覺。元素大小太大導致預取效果很差,但是順序的訪問方式很容易被預取,為什么不沒有呢,作者的觀點是
預取邏輯是無法在每個周期裝載新線的所以導致預取無效)
另一個導致慢下來的原因是TLB緩存的未命中。TLB是存儲虛實地址映射的緩存。為了保持快速,TLB只有很小的容量。如果有大量頁被反復訪問,超出了TLB緩存容量,就會導致反復地進行地址翻譯,這會耗費大量時間。TLB查找的代價分攤到所有元素上,如果元素越大,那么元素的數量越少,每個元素承擔的那一份就越多。
為了觀察TLB的性能,我們可以進行另兩項測試。第一項:我們還是順序存儲列表中的元素,使NPAD=7,讓每個元素占滿整個cache line,第二項:我們將列表的每個元素存儲在一個單獨的頁上,忽略每個頁沒有使用的部分以用來計算工作集的大小。結果表明,第一項測試中,每次列表的迭代都需要一個新的cache line,而且每64個元素就需要一個新的頁。第二項測試中,每次迭代都會訪問一個cache,都需要加載一個新頁。
圖 3.12: TLB 對順序讀的影響
基於可用RAM空間的有限性,測試設置容量空間大小為2的24次方字節,這就需要1GB的容量將對象放置在分頁上。NPAD等於7的曲線。我們看到不同的步長顯示了高速緩存L1d和L2的大小。第二條曲線看上去完全不同,其最重要的特點是當工作容量到達2的13次方字節時開始大幅度增長。這就是TLB緩存溢出的時候。我們能計算出一個64字節大小的元素的TLB緩存有64個輸入。成本不會受頁面錯誤影響,因為程序鎖定了存儲器以防止內存被換出。可以看出,計算物理地址並把它存儲在TLB中所花費的周期數量級是非常高的。從中可以清楚的得到:TLB緩存效率降低的一個重要因素是大型NPAD值的減緩。由於物理地址必須在緩存行能被L2或主存讀取之前計算出來,地址轉換這個不利因素就增加了內存訪問時間。這一點部分解釋了為什么NPAD等於31時每個列表元素的總花費比理論上的RAM訪問時間要高。
圖3.13 NPAD等於1時的順序讀和寫
所有情況下元素寬度都為16個字節。第一條曲線“Follow”是熟悉的鏈表走線在這里作為基線。第二條曲線,標記為“Inc”,僅僅在當前元素進入下一個前給其增加thepad[0]成員。第三條曲線,標記為"Addnext0", 取出下一個元素的thepad[0]鏈表元素並把它添加為當前鏈表元素的thepad[0]成員。
在沒運行時,大家可能會以為"Addnext0"更慢,因為它要做的事情更多——在沒進到下個元素之前就需要裝載它的值。但實際的運行結果令人驚訝——在某些小工作集下,"Addnext0"比"Inc"更快。這是為什么呢?原因在於,
系統一般會對下一個元素進行強制性預取。當程序前進到下個元素時這個元素其實早已被預取在L1d里。但是,"Addnext0"比"Inc"更快離開L2,這是因為它需要從主存裝載更多的數據。而在工作集達到2 21字節時,"Addnext0"的耗時達到了28個周期,是同期"Follow"14周期的兩倍。這個兩倍也很好解釋。"Addnext0"和"Inc"涉及對內存的修改,因此L2的逐出操作不能簡單地把數據一扔了事,而必須將它們寫入內存。因此FSB的可用帶寬變成了一半,傳輸等量數據的耗時也就變成了原來的兩倍。
圖3.14: 更大L2/L3緩存的優勢
決定順序式緩存處理性能的另一個重要因素是緩存容量。雖然這一點比較明顯,但還是值得一說。圖中展示了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,而是工作集開始增長到連末級緩存也放不下、需要主存熱情參與之后的部分。與我們預計的相似,最末級緩存越大,曲線停留在L2訪問耗時區的時間越長。在2**20字節的工作集時,第二台P4(更老一些)比第一台P4要快上一倍,這要完全歸功於更大的末級緩存。而Core 2拜它巨大的4M L2所賜,表現更為卓越。
單線程隨機訪問模式的測量
圖3.15: 順序讀取vs隨機讀取,NPAD=0
如果換成隨機訪問或者不可預測的訪問,情況就大不相同了。圖3.15比較了順序讀取與隨機讀取的耗時情況。
換成隨機之后,處理器無法再有效地預取數據,只有少數情況下靠運氣剛好碰到先后訪問的兩個元素挨在一起的情形。
圖3.15中有兩個需要關注的地方。
首先,在大的工作集下需要非常多的周期。這台機器訪問主存的時間大約為200-300個周期,但圖中的耗時甚至超過了450個周期。我們前面已經觀察到過類似現象(對比圖3.11)。這說明,處理器的自動預取在這里起到了反效果。
其次,代表隨機訪問的曲線在各個階段不像順序訪問那樣保持平坦,而是不斷攀升。為了解釋這個問題,我們測量了程序在不同工作集下對L2的訪問情況。結果如圖3.16和表3.2。
圖3.16: L2d未命中率
| Set Size |
Sequential | Random | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| L2 Hit | L2 Miss | #Iter | Ratio Miss/Hit | L2 Accesses Per Iter | L2 Hit | L2 Miss | #Iter | Ratio Miss/Hit | L2 Accesses Per Iter | |
| 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
從圖中可以看出,當工作集大小超過L2時,未命中率(L2未命中次數/L2訪問次數)開始上升。整條曲線的走向與圖3.15有些相似: 先急速爬升,隨后緩緩下滑,最后再度爬升。它與耗時圖有緊密的關聯。L2未命中率會一直爬升到100%為止。只要工作集足夠大(並且內存也足夠大),就可以將緩存線位於L2內或處於裝載過程中的可能性降到非常低。
(工作集越大,隨機訪問,命中率就會越小)
圖3.17: 頁意義上(Page-Wise)的隨機化,NPAD=7
而換成隨機訪問后,單位耗時的增長超過了工作集的增長,根源是TLB未命中率的上升。圖3.17描繪的是NPAD=7時隨機訪問的耗時情況。這一次,我們修改了隨機訪問的方式。正常情況下是把整個列表作為一個塊進行隨機(以∞表示),而其它11條線則是在小一些的塊里進行隨機。例如,標簽為'60'的線表示以60頁(245760字節)為單位進行隨機。先遍歷完這個塊里的所有元素,再訪問另一個塊。這樣一來,可以保證任意時刻使用的TLB條目數都是有限的。
(也就是上圖的性能差距主要來自於TLB的未命中率)
NPAD=7對應於64字節,正好等於緩存線的長度。由於元素順序隨機,硬件預取不可能有任何效果,特別是在元素較多的情況下。這意味着,分塊隨機時的L2未命中率與整個列表隨機時的未命中率沒有本質的差別。隨着塊的增大,曲線逐漸逼近整個列表隨機對應的曲線。這說明,在這個測試里,性能受到TLB命中率的影響很大,如果我們能提高TLB命中率,就能大幅度地提升性能(在后面的一個例子里,性能提升了38%之多)。(
作者在這里突出當元素長度>=cache line 長度的時候並且元素是隨機訪問硬件預取失效,為什么?我根據作者提供的結構體加上vtune,當結構體大小剛好為64B的時候並沒有發現L1的miss)
3.3.3 寫入時的行為
為了保持cache和內存的一致性,當cache被修改后,我們要刷新到主存中(flush),可以通過2種方式實現:1.write through(寫透),2.write back(寫回)
寫透是當修改cache會立刻寫入到主存,
缺點:速度慢,占用FSB總線
優點:實現簡單
寫回是當修改cache后不是馬上寫入到主存,而是打上已弄臟(dirty)的標記。當以后某個時間點緩存線被丟棄時,這個已弄臟標記會通知處理器把數據寫回到主存中,而不是簡單地扔掉。
優點:速度快
缺點:當有多個處理器(或核心、超線程)訪問同一塊內存時,必須確保它們在任何時候看到的都是相同的內容。如果緩存線在其中一個處理器上弄臟了(修改了,但還沒寫回主存),而第二個處理器剛好要讀取同一個內存地址,那么這個讀操作不能去讀主存,而需要讀第一個處理器的緩存線。
3.3.4 多處理器支持
直接提供從一個處理器到另一處理器的高速訪問,這是完全不切實際的。從一開始,連接速度根本就不夠快。實際的選擇是,在其需要的情況下,轉移到其他處理器
現在的問題是,當該高速緩存線轉移的時候會發生什么?這個問題回答起來相當容易:當一個處理器需要在另一個處理器的高速緩存中讀或者寫的臟的高速緩存線的時候。但怎樣處理器怎樣確定在另一個處理器的緩存中的高速緩存線是臟的?假設它僅僅是因為一個高速緩存線被另一個處理器加載將是次優的(最好的)。通常情況下,大多數的內存訪問是只讀的訪問和產生高速緩存線,並不臟。在高速緩存線上處理器頻繁的操作(當然,否則為什么我們有這樣的文件呢?),也就意味着每一次寫訪問后,都要廣播關於高速緩存線的改變將變得不切實際。
人們開發除了MESI緩存一致性協議(MESI=Modified, Exclusive, Shared, Invalid,變更的、獨占的、共享的、無效的)。協議的名稱來自協議中緩存線可以進入的四種狀態:
- 變更的: 本地處理器修改了緩存線。同時暗示,它是所有緩存中唯一的拷貝。
- 獨占的: 緩存線沒有被修改,而且沒有被裝入其它處理器緩存。
- 共享的: 緩存線沒有被修改,但可能已被裝入其它處理器緩存。
- 無效的: 緩存線無效,即,未被使用。
MESI協議開發了很多年,最初的版本比較簡單,但是效率也比較差。現在的版本通過以上4個狀態可以有效地實現寫回式緩存,同時支持不同處理器對只讀數據的並發訪問。
(寫回如何被實現,通過監聽其處理器的狀態)
在協議中,通過處理器監聽其它處理器的活動,不需太多努力即可實現狀態變更。處理器將操作發布在外部引腳上,使外部可以了解到處理過程。
一開始,所有緩存線都是空的,緩存為無效(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消息可能會被發送多次。}
緩存一致性協議的消息必須發給系統中所有處理器。只有當協議確定已經給過所有處理器響應機會之后,才能進行狀態躍遷。也就是說,協議的速度取決於最長響應時間。
對同步來說,有限的帶寬嚴重地制約着並發度。程序需要更加謹慎的設計,將不同處理器訪問同一塊內存的機會降到最低。
多線程測量
圖3.19: 順序讀操作,多線程
圖3.19展示了順序讀訪問時的性能,元素為128字節長(64位計算機,NPAD=15)。對於單線程的曲線,我們預計是與圖3.11相似,只不過是換了一台機器,所以實際的數字會有些小差別。
更重要的部分當然是多線程的環節。由於是只讀,不會去修改內存,不會嘗試同步。但即使不需要RFO,而且所有緩存線都可共享,性能仍然分別下降了18%(雙線程)和34%(四線程)。由於不需要在處理器之間傳輸緩存,因此這里的性能下降完全由以下兩個瓶頸之一或同時引起: 一是從處理器到內存控制器的共享總線,二是從內存控制器到內存模塊的共享總線。
圖3.20: 順序遞增,多線程
我們用對數刻度來展示L1d范圍的結果。可以發現,當超過一個線程后,L1d就無力了。單線程時,僅當工作集超過L1d時訪問時間才會超過20個周期,而多線程時,即使在很小的工作集情況下,訪問時間也達到了那個水平。
圖3.21: 隨機的Addnextlast,多線程
最后,在圖3.21中,我們展示了隨機訪問的Addnextlast測試的結果。這里主要是為了讓大家感受一下這些巨大到爆的數字。極端情況下,甚至用了1500個周期才處理完一個元素。如果加入更多線程,真是不可想象哪。
圖3.22: 通過並行化實現的加速因子
圖3.22中的曲線展示了加速因子,即多線程相對於單線程所能獲取的性能加成值。測量值的精確度有限,因此我們需要忽略比較小的那些數字。可以看到,在L2與L3范圍內,多線程基本可以做到線性加速,雙線程和四線程分別達到了2和4的加速因子。但是,一旦工作集的大小超出L3,曲線就崩塌了,雙線程和四線程降到了基本相同的數值(參見表3.3中第4列)。
也是部分由於這個原因,我們很少看到4CPU以上的主板共享同一個內存控制器。如果需要配置更多處理器,我們只能選擇其它的實現方式(參見第5節)。
特例: 超線程
它真正的優勢在於,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 | = | 程序的執行時間 |
(也就是說在命中的時間+非命中的時間)
圖 3.23: 最小緩存命中率-加速
紅色區域為單線程的命中率,綠色為雙線程,比如 如果單線程命中率不低於60%,那么雙線程就不能低於10%。綠色區域是我們要達到的目標,
因此,超線程只在某些情況下才比較有用。單線程代碼的緩存命中率必須低到一定程度,從而使緩存容量變小時新的命中率仍能滿足要求。只有在這種情況下,超線程才是有意義的。在實踐中,采用超線程能否獲得更快的結果,取決於處理器能否有效地將每個進程的等待時間與其它進程的執行時間重疊在一起。並行化也需要一定的開銷,需要加到總的運行時間里,這個開銷往往是不能忽略的。
3.3.5 其它細節
我們已經介紹了地址的組成,即標簽、集合索引和偏移三個部分。那么,實際會用到什么樣的地址呢?目前,處理器一般都向進程提供虛擬地址空間,意味着我們有兩種不同的地址: 虛擬地址和物理地址。
虛擬地址有個問題——並不唯一。隨着時間的變化,虛擬地址可以變化,指向不同的物理地址。
處理器指令用的虛擬地址,而且需要在內存管理單元(MMU)的協助下將它們翻譯成物理地址。這並不是一個很小的操作。在執行指令的管線(pipeline)中,物理地址只能在很后面的階段才能得到。
這意味着,緩存邏輯需要在很短的時間里判斷地址是否已被緩存過。而如果可以使用虛擬地址,緩存查找操作就可以更早地發生,一旦命中,就可以馬上使用內存的內容。結果就是,
使用虛擬內存后,可以讓管線把更多內存訪問的開銷隱藏起來。
處理器的設計人員們現在使用虛擬地址來標記第一級緩存。
對於更大的緩存,包括L2和L3等,則需要以物理地址作為標簽。因為這些緩存的時延比較大,虛擬到物理地址的映射可以在允許的時間里完成,而且由於主存時延的存在,重新填充這些緩存會消耗比較長的時間,刷新的代價比較昂貴。(刷新就是寫入到內存)
一般來說,我們並不需要了解這些緩存處理地址的細節。我們不能更改它們,而那些可能影響性能的因素,要么是應該避免的,要么是伴隨更高代價的。填滿緩存是不好的行為,緩存線都落入同一個集合,也會讓緩存早早地出問題。(和關聯性相關)
對於后一個問題,可以通過cache address中使用虛擬地址來避免(如何避免,依靠系統?),但作為一個用戶級程序,是不可能避免緩存物理地址的。我們唯一可以做的,是盡最大努力不要在同一個進程里用多個虛擬地址映射同一個物理地址(避免同一個數據在cache中有多個記錄)。
3.4 指令緩存
其實,不光處理器使用的數據被緩存,它們執行的指令也是被緩存的。只不過,指令緩存的問題相對來說要少得多,因為:
- 執行的代碼量取決於代碼大小。而代碼大小通常取決於問題復雜度。問題復雜度則是固定的。
- 程序的數據處理邏輯是程序員設計的,而程序的指令卻是編譯器生成的。編譯器的作者知道如何生成優良的代碼。
- 程序的流向比數據訪問模式更容易預測。現如今的CPU很擅長模式檢測,對預取很有利。
- 代碼永遠都有良好的時間局部性和空間局部性。
有一些准則是需要程序員們遵守的,但大都是關於如何使用工具的,我們會在第6節介紹它們。而在這里我們只介紹一下指令緩存的技術細節。
隨着CPU的核心頻率大幅上升,緩存與核心的速度差越拉越大,CPU的處理開始管線化。也就是說,指令的執行分成若干階段。首先,對指令進行解碼,隨后,准備參數,最后,執行它。這樣的管線可以很長(例如,Intel的Netburst架構超過了20個階段)。在管線很長的情況下,一旦發生延誤(即指令流中斷),需要很長時間才能恢復速度。管線延誤發生在這樣的情況下: 下一條指令未能正確預測,或者裝載下一條指令耗時過長(例如,需要從內存讀取時)。
3.4.1 自修改的代碼
3.5 緩存未命中的因素
我們已經看過內存訪問沒有命中緩存時,那陡然猛漲的高昂代價。但是有時候,這種情況又是無法避免的,因此我們需要對真正的代價有所認識,並學習如何緩解這種局面。
3.5.1 緩存與內存帶寬
圖3.24: P4的帶寬
當工作集能夠完全放入L1d時,處理器的每個周期可以讀取完整的16字節數據,即每個周期執行一條裝載指令(moveaps指令,每次移動16字節的數據)。測試程序並不對數據進行任何處理,只是測試讀取指令本身。當工作集增大,無法再完全放入L1d時,性能開始急劇下降,跌至每周期6字節。在218工作集處出現的台階是由於DTLB緩存耗盡,因此需要對每個新頁施加額外處理。由於這里的讀取是按順序的,預取機制可以完美地工作,而FSB能以5.3字節/周期的速度傳輸內容。但預取的數據並不進入L1d
(放不進L1D放去了哪里?)。當然,真實世界的程序永遠無法達到以上的數字,但我們可以將它們看作一系列實際上的極限值
更令人驚訝的是寫操作和復制操作的性能。即使是在很小的工作集下,寫操作也始終無法達到4字節/周期的速度。這意味着,Intel為Netburst處理器的L1d選擇了寫通(write-through)模式,所以寫入性能受到L2速度的限制。同時,這也意味着,復制測試的性能不會比寫入測試差太多(復制測試是將某塊內存的數據拷貝到另一塊不重疊的內存區),因為讀操作很快,可以與寫操作實現部分重疊。最值得關注的地方是,兩個操作在工作集無法完全放入L2后出現了嚴重的性能滑坡,降到了0.5字節/周期!比讀操作慢了10倍!
(慢在哪里?)顯然,如果要提高程序性能,優化這兩個操作更為重要
圖3.25采用了與圖3.24相同的刻度,以方便比較兩者的差異。圖3.25中的曲線抖動更多,是由於采用雙線程的緣故。結果正如我們預期,由於超線程共享着幾乎所有資源(僅除寄存器外),所以每個超線程只能得到一半的緩存和帶寬。所以,即使每個線程都要花上許多時間等待內存,從而把執行時間讓給另一個線程,也是無濟於事——因為另一個線程也同樣需要等待。這里恰恰展示了使用超線程時可能出現的最壞情況。
寫/復制操作的性能與P4相比,也有很大差異。處理器沒有采用寫通策略,寫入的數據留在L1d中,只在必要時才逐出。這使得寫操作的速度可以逼近16字節/周期。一旦工作集超過L1d,性能即飛速下降。由於Core 2讀操作的性能非常好,所以兩者的差值顯得特別大。當工作集超過L2時,兩者的差值甚至超過20倍!但這並不表示Core 2的性能不好,相反,Core 2永遠都比Netburst強。
圖3.27: Core 2運行雙線程時的帶寬表現
在圖3.27中,啟動雙線程,各自運行在Core 2的一個核心上。它們訪問相同的內存,但不需要完美同步。從結果上看,讀操作的性能與單線程並無區別,只是多了一些多線程情況下常見的抖動。
當工作集小於L1d時,寫操作與復制操作的性能很差,就好像數據需要從內存讀取一樣。兩個線程彼此競爭着同一個內存位置,於是不得不頻頻發送RFO消息。問題的根源在於,雖然兩個核心共享着L2,但無法以L2的速度處理RFO請求。
當工作集小於L1d時,寫操作與復制操作的性能很差,就好像數據需要從內存讀取一樣。兩個線程彼此競爭着同一個內存位置,於是不得不頻頻發送RFO消息。問題的根源在於,雖然兩個核心共享着L2,但無法以L2的速度處理RFO請求。
而當工作集超過L1d后,性能出現了迅猛提升。這是因為,由於L1d容量不足,於是將被修改的條目刷新到共享的L2。由於L1d的未命中可以由L2滿足,
只有那些尚未刷新的數據才需要RFO,所以出現了這樣的現象。這也是這些工作集情況下速度下降一半的原因。
圖3.28展示了AMD家族10h Opteron處理器的性能。這顆處理器有64kB的L1d、512kB的L2和2MB的L3,其中L3緩存由所有核心所共享。
圖3.28: AMD家族10h Opteron的帶寬表現
大家首先應該會注意到,在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: AMD Fam 10h在雙線程時的帶寬表現
讀操作的性能沒有受到很大的影響。每個線程的L1d和L2表現與單線程下相仿,L3的預取也依然表現不佳。兩個線程並沒有過渡爭搶L3。問題比較大的是寫操作的性能。兩個線程共享的所有數據都需要經過L3,而這種共享看起來卻效率很差。即使是在L3足夠容納整個工作集的情況下,所需要的開銷仍然遠高於L3的訪問時間。再來看圖3.27,可以發現,在一定的工作集范圍內,Core 2處理器能以共享的L2緩存的速度進行處理。而Opteron處理器只能在很小的一個范圍內實現相似的性能,而且,它僅僅只能達到L3的速度,無法與Core 2的L2相比。
3.5.2 關鍵字加載
事實上,內存控制器可以按不同順序去請求緩存線中的字。當處理器告訴它,程序需要緩存中具體某個字,即「關鍵字(critical word)」時,內存控制器就會先請求這個字。一旦請求的字抵達,雖然緩存線的剩余部分還在傳輸中,緩存的狀態還沒有達成一致,但程序已經可以繼續運行。這種技術叫做關鍵字優先及較早重啟(Critical Word First & Early Restart)。
圖3.30: 關鍵字位於緩存線尾時的表現
圖3.30展示了下一個測試的結果,圖中表示的是關鍵字分別在線首和線尾時的性能對比情況。元素大小為64字節,等於緩存線的長度。圖中的噪聲比較多,但仍然可以看出,當工作集超過L2后,關鍵字處於線尾情況下的性能要比線首情況下低0.7%左右。
3.5.3 緩存設定
關於各種處理器模型的優點,已經在它們各自的宣傳手冊里寫得夠多了。在每個核心的工作集互不重疊的情況下,獨立的L2擁有一定的優勢,單線程的程序可以表現優良。考慮到目前實際環境中仍然存在大量類似的情況,這種方法的表現並不會太差。不過,不管怎樣,我們總會遇到工作集重疊的情況。如果每個緩存都保存着某些通用運行庫的常用部分,那么很顯然是一種浪費。
如果像Intel的雙核處理器那樣,共享除L1外的所有緩存,則會有一個很大的優點。如果兩個核心的工作集重疊的部分較多,那么綜合起來的可用緩存容量會變大,從而允許容納更大的工作集而不導致性能的下降。如果兩者的工作集並不重疊,那么則是由Intel的高級智能緩存管理(Advanced Smart Cache management)發揮功用,防止其中一個核心壟斷整個緩存。
圖3.31: 兩個進程的帶寬表現
這次,測試程序兩個進程,第一個進程不斷用SSE指令讀/寫2MB的內存數據塊,選擇2MB,是因為它正好是Core 2處理器L2緩存的一半,第二個進程則是讀/寫大小變化的內存區域,我們把這兩個進程分別固定在處理器的兩個核心上。圖中顯示的是每個周期讀/寫的字節數,共有4條曲線,分別表示不同的讀寫搭配情況。例如,標記為讀/寫(read/write)的曲線代表的是后台進程進行寫操作(固定2MB工作集),而被測量進程進行讀操作(工作集從小到大)。
圖中最有趣的是2**20到2**23之間的部分。如果兩個核心的L2是完全獨立的,那么所有4種情況下的性能下降均應發生在221到222之間,也就是L2緩存耗盡的時候。但從圖上來看,實際情況並不是這樣,特別是背景進程進行寫操作時尤為明顯。
當工作集達到1MB(220)時,性能即出現惡化,兩個進程並沒有共享內存,因此並不會產生RFO消息。所以,完全是緩存逐出操作引起的問題。目前這種智能的緩存處理機制有一個問題,每個核心能實際用到的緩存更接近1MB,而不是理論上的2MB。
3.5.4 FSB的影響
FSB在性能中扮演了核心角色。緩存數據的存取速度受制於內存通道的速度。
圖3.32: FSB速度的影響
圖上的數字表明,當工作集大到對FSB造成壓力的程度時,高速FSB確實會帶來巨大的優勢。在我們的測試中,性能的提升達到了18.5%,接近理論上的極限。而當工作集比較小,可以完全納入緩存時,FSB的作用並不大。
附錄1
exclusive caches:就是所有的緩存中只保留一份緩存線。
好處:能存更多的數據。壞處:當L1 miss,L2 hit后,L2要把數據交換到L1上,這個操作比copy的花費要大
inclusive caches:上一級緩存有的必須在本緩存中出現一份備份
好處:當刪除一個緩存線的時候只需要掃描L2的緩存線就可以了,不需要再去掃描L1的緩存線
如果在二級緩存很大,並且cache數據比tag還要大,tags的空間就可以被節省下來,在L2中保留跟多的L1數據
壞處:如果當L2的關聯性比L1差,L1的關聯性會被L2影響(不清楚狀況)
當L2的緩存線要被換出,L1的也需要被換出,可能會再次L1的miss率上升
4 虛擬內存
4.1 簡單的地址轉化
MMU會把地址映射到基於頁的方式,想cache line的地址虛擬地址也被分為幾個部分,使用這些部分用來構建最后的物理地址
圖中前版部分指向了一個頁表,頁表中包含了物理內存頁的地址,然后再通過offset計算出頁內的偏移。這就就是一個物理地址了
頁表的被保存在內存中,OS分配一個連續的物理內存並且把基地址存入寄存器中。虛擬地址前部分被作為頁表的index
4.2多級頁表
一般頁大小是4KB,那么也就是2**12還有2**20,如果每個項為4B,那么就需要4MB大小的頁表,很不靠譜。所以分為了多級增加了頁表的緊湊性,並且頁保證了多個程序下對性能的影響不會太大。
4級頁表,是最復雜的4個頁表被分在不同的4個寄存器上,Level1是局部物理地址加上安全選項
和上面的類似,唯一不同的是多級頁表是分為多次,最后查詢到物理地址。每個進程都有自己的頁表,所以為了性能和可擴展性盡量保持較小的頁表。把虛擬地址放在一起,這樣可以減少頁表空間。小的程序只會使用頁表的很少的部分。
在現實中,因為安全性的關系可執行程序的多個部分被隨機分散到各處。若是性能比安全重要,也可以關閉隨機。
4.3 優化頁表訪問
頁表是維護在內存中的,但是需要4次訪問內存還是很慢的,訪問cache,4次訪問也是很慢的。每個絕對地址的計算都需要大量的訪問頁表4頁表級別的至少要12個cpu周期,並且可能會導致L1miss,導致管線斷裂。
所以並不緩存頁表,而是緩存計算后的物理地址。因為offset並不參與緩存。放翻譯結果的叫做TLB,很快但是也很小,現代的CPU都有提供多級的TLB,並使用LRU算法,當前,TLB引入了組相連,所以並不是最老的就會被代替了。
Tag是虛擬地址的一部分用來訪問TLB,如果找到再加上offset就是物理地址了。在某些情況下二級的TLB中沒有找到,就必須通過頁表計算,這個將是代價非常高的操作。在預取操作的時候為TLB預取是不行的,(硬件預取)只能通過指令預取才有TLB預取。
4.3.1 TLB注意事項
TLB是內核全局資源所有的線程,進程都運行在同一個TLB上,CPU不能盲目的重用,因為存的是虛擬地址會出現重復的情況。
所以有一下2個方法:
1.當頁表被修改后刷新TLB數據
2.用一個標記來識別TLB中的頁表
第一個是不行的頁表會在上下文切換的時候被天,刷新會把可能可以繼續使用的也刷新掉了
有些cpu結構優化了,把某個區間內的刷新掉,所以代價不是很高
最優的方法是新增一個唯一識別符。但是有個問題是TLB能給的標記有限,有些標示必須能夠重用,當TLB出現刷新的時候,所有的重用都要被刷新掉。
使用標記的好處是如果當前使用的被調出,下次再被調度的時候TLB任然可以使用,出了這個還有另外2個好處:
1.內核或者VMM使用通常只需要很短的時間。沒有tag會執行2次刷新,如果有tag地址就會被保留,因為內核和vmm並不經常修改tlb,所以上次保存的任然可以使用
2.若一個進程的2個線程進行切換,那么就沒有必要刷新。
4.3.2 TLB的性能影響
首先就是頁的大小,頁越大減少了轉化過程,減少了在TLB的容量
但是大的頁在物理內存上必須連續,這樣會造成內存浪費。
在x86-64中2MB的頁,是有多個小的頁組成,這些小的頁在物理上市連續的。當系統運行了一段時間后,發現要分配2MB的連續內存空間變得十分困難
在linux中會在系統啟動時使用hugetlbfs文件系統預先保留,如果要增加就必須重啟系統。大的頁可以提升性能,在資源豐富,但是安裝麻煩也不是很大的問題。比如數據庫系統。
增加最小虛擬頁的大小也是有問題,內存映射操作驗證這些頁的大小,不能讓更小的出現,若大小超過了可執行文件或者DSO的考慮范圍,就無法加載。
第二個影響就是頁表級別減少,因為需要參與映射的位數減少,減少了TLB中的使用空間,也減少了需要移動的數據。只是對對齊的需求比較大。TLB少了性能自然變好。
4.4 虛擬化的影響
VMM只是個軟件,並不實現硬件的控制。在早期,內存和處理器之外的硬件都控制在DOM0中。現在Dom0和其他的Dom一樣,在內存的控制上沒有什么區別。
為了實現虛擬化,Dom0,DomU的內核直接訪問物理內存是被限制的。而是使用頁表結構來控制Dom0,DomU上的內存使用。
不管是軟件虛擬還是硬件虛擬,用戶域中的為每個進程創建的頁表和硬件虛擬,軟件虛擬是類似的。當用戶OS修改了這些頁表,VMM就會被調用,根據修改的信息,來修改VMM中的影子頁表(本來應該是硬件處理),每次修改都需要調用VMM顯然是一個代價很高的操作。
為了減少這個代價,intel 引入了EPT,AMD引入了NPT,2個功能一樣,就是為VMM生產虛擬的物理地址,當使用內存的時候,cpu參與把這些地址進一步翻譯為物理地址,這樣就可以再非虛擬話的速度來運行,更多的vmm中關於內存控制的項被刪除,減少了vmm的內存使用,vmm只需要為每個Dom保留一份頁表即可。
為了更高效的使用TLB,就加入了ASID在初始化處理器的時候生產用於TLB的擴展,來避免TLB的刷新,intel引入了VPID(虛擬處理器的id)來避免TLB的刷新。
基於VMM的虛擬機,多有2層內存控制,vmm和os需要實現一模一樣的功能
基於KVM的虛擬機就解決了這個問題,KVM虛擬機並不是和VMM一樣運行在VMM上的,而是直接運行的內核的上面,使用內核的內存管理,虛擬化被KVM VMM進程控制。盡管還是有個2個內存控制,但是工作都是在內核中實現的,不需要再VMM再實現一遍。
虛擬化的內存訪問肯定比非虛擬話代價高,而且越去解決問題,可能代價會更高,cpu視圖解決這個問題,但是只能減弱,並不能徹底解決
5. NUMA的支持
NUMA因為的特色的體系設計,所以需要OS和應用程序做特別的支持
5.1 NUMA硬件
NUMA最大的特點是CPU直接連接內存,這樣讓cpu訪問本地的內存代價就很低,但是訪問遠程的代價就變高。
NUMA主要解決,大內存,多CPU環境下,解決,多個CPU同時訪問內存,導致內存總線熱點,或者某個內存模塊熱點,從而降低吞吐量
AMD公司提出了一個Hypertransport的傳輸技術,讓CPU通過這個傳輸技術訪問內存,而不是直接訪問內存。
圖5.1 立方體
這些節點的拓撲圖就是立方體,限制了節點的大小2**C,C是節點的互連接口個數。對於2**n個cpu立方體擁有最小的直徑(任意2點的距離)
缺點就是不能支持大量的CPU。
接下來就是給cpu分組,實現它們之間的內存共享問題,所有這樣的系統都需要特殊的硬件,2台這樣的機器可以通過共享內存,實現和工作在一台機器上一樣。互連在NUMA中是一個很重要的因素,所以系統和應用程序必須考慮到這一點
5.2 OS對NUMA的支持
為了讓NUMA盡量使用本地的內存,這里有個特殊的例子只會在NUMA體系結構中出現。DSO的文本段在內存中,但是所有cpu都要使用,這就以為這基本上都要使用遠程訪問。最理想的狀態是對所有需要訪問的做一個鏡像,但是很難實現。
為了避免類似的情況,應該防止經常切換到其他節點運行,從一個cpu切換到另外一個就以為這cache內容的丟失,如果要遷移,OS會選擇一個任務的選擇一個,但是在NUMA結構體系下,選擇是有一些限制的,新的處理器內存的訪問代價一定要比老的低,如果沒有可用的處理器符合這個條件,os就沒得選擇只能切換到一個代價較高的。
在這個狀況下有2種方向,1.只是臨時的切換,2.遷移並且把內存也遷移走(頁遷移代價太高,需要做大量的復制工作,進程也必須停止,等待數據頁遷移完畢,應該避免這種情況的發送)
我們不應該假設應用程序都使用相同大小的內存,一些程序使用大量內存,一些使用小量的,如果都是用本地,遲早本地內存會被耗盡。
為了解決這個問題,有個方法就是讓內存條帶化,但是缺點就是因為遷移,導致內存訪問的開銷變大。
5.3 相關信息
內核發布了一些關於處理器cache的信息(通過sysfs)
/sys/devices/system/cpu/cpu*/cache
這里包含了資料叫做index*,列出了CPU進程的一些信息。
| 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 | |
每個內核都有L1i,L1D,L2。L1不和其他cpu共享。cpu0和cpu1共享L2,cpu2和cpu3共享L2
以下是4路雙核cpu的cache信息
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 cache信息
和圖5.1類似,但是看表就會發現L2不和任何cpu共享
/sys/devices/system/cpu/cpu*/topology顯示了sysfs關於CPU拓撲信息
| 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拓撲信息
因為thread_sinlings都是設置了一個bit,所以沒有超線程,是一個4個cpu每個cpu有2個內核。
/sys/devices/system/node這個目錄下包含了系統中NUMA的信息,表5.4顯示了總要的信息
| cpumap | distance | |
|---|---|---|
| node0 | 00000003 | 10 20 20 20 |
| node1 | 0000000c | 20 10 20 20 |
| node2 | 00000030 | 20 20 10 20 |
| node3 | 000000c0 | 20 20 20 10 |
圖5.4 sysfs Opteron 節點信息
上圖顯示了,cpu只有4個,有4個節點,distance表示了,他們訪問內存的花費(這個是不正確的,cpu中至少有一個是連接到南橋的,所以花費可能不止20.)
5.4 遠程訪問開銷
圖5.3 多個節點的讀寫性能
讀性能優於寫,這個可以預料,2個1hop性能有一點小差別,這個不是關鍵,關鍵是2hop性能大概比0hop低了30%到49%,寫的性能2-hop比0-hop少了32%,比1-hop少了17%,下一代AMD處理器hypertransport將是4個,對於4路的cpu,間距就是1。但是8路的cpu還是有相同的問題,因為立方體中8節點的間距還是3.
最后一部分信息是來自PID文件的
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
N0-N3表示了不同的節點,后面表示在各個節點開辟的內存頁數
從圖5.3中,我們發現讀性能下降了9%-30%,如果L2失效,要用遠程的內存,那么將會增加9%-30%的開銷。
圖5.4遠程內存的操作
這里讀取性能降低了20%,但是前面圖5.3中是9%,差距怎么大,想是使用老的cpu(這些圖來自AMD的amdccnuma文檔,只有AMD知道怎么回事兒了)(
我特意去查了amdccnuma並沒有以上2個圖)
寫和復制操作也是20%,當工作集超過cache時,讀寫操作就不比本地的慢了,主要原因是訪問主存的開銷(
不明白,感覺沒有講清楚)
6 程序員應該做什么
6.1 繞過Cache line
對於並不是馬上要使用的數據,先讀取在修改並不利於性能,因為這樣會讓cache效果變低。比如矩陣寫入最后一個的時候第一個常常因為不太使用被犧牲掉了。
所以有了非臨時寫入,直接把數據寫入到內存。因為處理器會使用write-combining,使得寫入開銷並不是很大。
#include <emmintrin.h>
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);
}
因為寫入綁定寫入只在最后一條指令發送,直接寫入不但避免了先讀后修改,也避免了污染cache
下面測試矩陣訪問的2中方式:行訪問,列訪問
主要的區別是行訪問是順序的,但是列訪問時隨機的。
圖 6-1矩陣訪問模式
Inner Loop Increment |
||
|---|---|---|
| Row | Column | |
| Normal | 0.048s | 0.127s |
| Non-Temporal | 0.048s | 0.160s |
直接寫入內存和手動寫入幾乎一樣快,主要原因是使用了write-combining,還有就是寫入並不在乎順序,這樣處理器就可以直接寫回,盡可能的使用帶寬。
列訪問方式,並沒有write-combining,內存單元必須一個一個寫入,因為是隨機的。當在內存芯片上寫入新行也需要選擇這一行,有相關的延遲。
在讀入方面還沒有非零食訪問的預取,需要通過指令顯示預取,intel實現了NTA load,實現一個buffer load,每個buffer大小為一個cache line。
現在cpu對非cache數據,順序訪問訪問做了很好的優化,也可以通過cache,對內存的隨機訪問降低開銷。
6.2 Cache Access
6.2.1 優化L1D訪問
通過demo的修改,來提高L1D的訪問,demo實現以下功能:
代碼:
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * mul2[k][j];
從代碼中看到mul2的訪問方式是列的方式,基本上都是隨機的,那么我們可以先轉化以下再計算:
double tmp[N][N];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
tmp[i][j] = mul2[j][i];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * tmp[j][k];
測試結果:
| Original | Transposed | |
|---|---|---|
| Cycles | 16,765,297,870 | 3,922,373,010 |
| Relative | 100% | 23.4% |
轉化后,性能高了76.6%,主要是非順序訪問引起。
為了和cache line對齊,可以再對代碼修改:
#define SM (CLS / sizeof (double))
for (i = 0; i < N; i += SM)
for (j = 0; j < N; j += SM)
for (k = 0; k < N; k += SM)
for (i2 = 0, rres = &res[i][j],
rmul1 = &mul1[i][k]; i2 < SM;
++i2, rres += N, rmul1 += N)
for (k2 = 0, rmul2 = &mul2[k][j];
k2 < SM; ++k2, rmul2 += N)
for (j2 = 0; j2 < SM; ++j2)
rres[j2] += rmul1[k2] * rmul2[j2];
性能如下:
| Original | Transposed | Sub-Matrix | Vectorized | |
|---|---|---|---|---|
| Cycles | 16,765,297,870 | 3,922,373,010 | 2,895,041,480 | 1,588,711,750 |
| Relative | 100% | 23.4% | 17.3% | 9.47% |
和轉化比性能有了6.1%的提升,最后一個是向量結果
代碼如下:
#include <stdlib.h> #include <stdio.h> #include <emmintrin.h> #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 < N; i += SM) for (j = 0; j < N; j += SM) for (k = 0; k < N; k += SM) for (i2 = 0, rres = &res[i][j], rmul1 = &mul1[i][k]; i2 < SM; ++i2, rres += N, rmul1 += N) { _mm_prefetch (&rmul1[8], _MM_HINT_NTA); for (k2 = 0, rmul2 = &mul2[k][j]; k2 < SM; ++k2, rmul2 += N) { __m128d m1d = _mm_load_sd (&rmul1[k2]); m1d = _mm_unpacklo_pd (m1d, m1d); for (j2 = 0; j2 < SM; j2 += 2) { __m128d m2 = _mm_load_pd (&rmul2[j2]); __m128d r2 = _mm_load_pd (&rres[j2]); _mm_store_pd (&rres[j2], _mm_add_pd (_mm_mul_pd (m2, m1d), r2)); } } }
// ... use res matrix
return 0; }
對於寫入大量數據,但是同時使用到很少數據的情況是很常見的如圖就是這種情況:
順序訪問和隨機的訪問在L2緩存之前都很容易理解,但是超過L2之后,回到了10%不是因為開銷變小,是因為內存訪問開銷太大,已經不成比例了(有點沒法理解,為什么會不成比例)。
軟件pahole可以從代碼級分析這個結構體可能會用幾個cacheline
struct foo {
int a;
long fill[7];
int b;
};
在編譯的時候需要帶上調試信息才行,我是在linux 下使用。
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 */
上面的信息清晰,這個結構體需要2個cache line,大小為72字節,成員大小64字節,空的字節:4個,有1個空洞
元素b也是4個字節,作者這里說因為int 4個字節要和long 對齊所以被浪費了有個4字節是為了和fill對齊。但是我自己在測試的時候沒有發現這種情況,在虛擬機上測試的。
使用pahole可以輕易的對元素重排,並且可以確認,那些元素在一個cache line上。結構體中的元素順序很重要,所以開發人員需要遵守以下2個規則:
1.把可能是關鍵字的放到結構體的頭部
2.若沒有特定的順序,就以結構體元素的順序訪問元素
對於小的結構體可以隨意的排列結構體順序,但是對於大的結構體需要有一些規則
如果本身不是對齊的結構體,那么重新排序沒有什么太大的價值,對於結構化的類型,結構體中最大的元素決定了結構體的對齊。即使結構體小於cache line,但是對齊后可能就比cache line 大。有2個方法確保結構體設置的時候對齊。
1.顯示的對齊請求,動態的malloc分配,通常要那些和對齊匹配的通常是標准的類型(如long,double)。也可以使用posix_memalign做更高級別的對齊。若是編譯器分配的,變量可以使用變量屬性struct strtype variable __attribute((aligned(64)));這樣這個變量以64為字節對齊
使用posix_memalign會導致碎片產生,並可能讓內存浪費。使用變量屬性對齊不能使用在數組中,除非數組元素都是對齊的。
2.對於類型的對齊可以使用類型屬性
struct strtype {
...members...
} __attribute((aligned(64)));
這樣設置之后所有編譯器分配的對象都是對齊的,包括數組,但是用malloc分配的不是對齊的,還是posix_memalign對齊,可以使用gcc的alignof傳入用於第二個參數。
x86,x86-64處理器支持對非對齊訪問但是比較慢,對於RISC來說對齊並不是什么新鮮事,RISC指令本身都是對齊的,有些結構體系支持非對齊的訪問,但是速度總是比對齊的慢。
如圖是對齊訪問和非對齊訪問的對比,有意思的地方是當work set超過2mb的時候開始下滑,是因為,內存訪問變多,並且內存訪問的時間占的比重大。
關於對齊必須認真對待,竟然支持非對齊訪問,但是性能也不能可能比對齊好。
對齊也是有副作用的,如使用了自動變量對齊,那么就需要在所有用到的地方都要對齊,但是編譯器無法控制調用和堆棧,所以有2個解決方法。
1.代碼和堆棧對齊,有必要的話就填充空白.2.所有的調用都要對齊。
對於調用對齊,如果被調用要求對齊,但是調用者不對其的話就會出錯。
對於和堆棧對齊,堆棧幀沒必要是對齊大小的整數倍,所以要填充空白,編譯器是知道堆棧幀大小的,所以可以處理。
當要對VLA(通過變量決定數組長度),或者alloca的變量對齊,大小只能在運行的時候知道,所以對他們做對齊可能會照成代碼變慢,因為生產了其他代碼。
gcc提供了一個選項可以更靈活的設置stack的對齊, -mpreferred-stack-boundary =N,這個N表示對齊為2的N次。
在編譯x86程序的時候設置這個參數為2,可以減少stack的使用並提高代碼執行速度。
對於86-64,調用ABI,浮點類型,是通過SSE寄存器和SSE指令需要16個字節對齊。
對齊只是優化性能的一個方面
對於比較大的workset,重新整理數據結構是很有必要的。不是把所有的元素都放在一個結構體中,而是更具常用程度,進程組織。
若數據集中在同一個cache set中會導致conflict misses。
圖中:x表示list中2個元素的距離,y表示list總長度,z表示運行時間
當list的所有元素都在一個cache set的時候,元素個數高於關聯度時,訪問就要從L2中讀取增加了讀取時間,這就是圖中顯示的特點。(能猜測數據時均勻的分布在L1每個cache set中,能力有限未能重現conflict miss)。非對齊的訪問或增加cache line 的conflict miss。
總結:對於data cache訪問主要的優化方式是1.順序訪問(提高預取性能)2.對齊(和cache line,減少cache讀取次數) 3.減少結構體對cache的占用(也是為了減少cache讀取) 4.減少conflict miss
6.2.2 優化L1指令cache訪問
因為通過編譯器處理,所以程序員並不能直接的控制代碼,線性讓處理器可以高效獲取指令,但是如果有jump會打破這個線性,因為:1.jump是動態的,2.如果出現miss就會花費很長的時間恢復(因為pipeline)。
主要問題還是在分支預測上的成功率,分支預測在運行到這個指令之前會先把指令取來,如果預測錯誤,就會花費比較大的時間。
1.減少代碼大小,注意loop unrolling 和inlining的代碼量。
內聯對代碼分支預測有好處,但是如果內聯代碼過大,或者被對此調用反而會增加code的大小。
2.盡量線性執行,讓pipleline不會出現斷的情況
碰到跳轉,要不太執行的塊移動到外面,在gcc 中可以使用,
#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)
並且在編譯的時候使用 -freorder-blocks對代碼進行重排,還有另一選項 -freorder-blocks-and-partition 但是有使用限制,其不能和exception handling 一起使用
inter core2對於小的順序引入了一個基數LSD(loop stream detector),如果循環指令不超過18個,這個LSD,需要4個decoder,最多4個跳轉指令,執行64次以上,
那么 就會被鎖定在指令隊列中,下次運行就會變得很快。
3.如果有意義就使用代碼對齊
對齊的意義在於能夠形成順序的指令流,對齊可以讓預取更加有效。也就意味着讓decode更加有效。若出現miss,那么pipeline就會斷開。
從以下幾點對齊代碼比較有效:1.函數的開始,2.基本代碼塊的開始,3.循環的開始。
在第一點和第二點的優化中,編譯器往往通過插入nop指令,來對齊,所以有一些開銷。
對於第三點,有點不同的是,在循環之前往往是其他的順序的代碼,如果,其他代碼和循環之間有空隙,那么必須使用nop或者跳轉到循環的開始,若循環有不常執行,
那么nop和jump就會很浪費,比非對齊的開銷還要多。
-falign-functions = N,對函數的對齊,對齊2的N次
-falign-jumps = N ,對跳轉的對齊
-falign-loops = N對循環的對齊
6.2.3 優化L2的cache訪問
L1的優化和L2差不多,但是L2的特點是:1.如果miss了,代價很高,因為可能要訪問內存了。2.L2cache通常是內核共享或者超線程共享的。
由於l2cache 差異較大,並且,並不是都引用於數據的。所以需要一個動態的計算每個線程或者內核的最低安全限制,
cache結構在Linux下可在 /sys/devices/system/cpu/cpu*/cache。
6.2.4 優化TLB使用
要有話TLB,1.減少內存使用,2.減少查找代價,減少被分配的頁表的個數。
ASLR會導致頁表過多,因為ASLR會讓stack,DSO,heap隨機在執行的是否分配。為了性能可以把ASLR關掉,但是少了安全性。
還有一種方法是mmap的MAP_FIXED選項來分配內存地址,但是十分危險,只有在開發指導最后一級頁表的邊界,並能夠選擇合適地址的前提下使用。
6.3預取
6.3.1 硬件預取
只有2次以上的miss才會除非預取,單個的miss觸發預取會照成性能問題,隨機的內存訪問預取會對fsb照成沒必要的浪費。
L2或更高的預取可以和其他內核或者超線程共享,prefetch不能越過一個頁 因為硬件預取不懂語義,可能引起page fault或者fetch一個並不需要的頁。
在不需要的時候引起prefetch 需要調整程序結構才能解決 在指令中插入未定義指令是一種解決方法,體系結構提供全部或者部分關閉prefetch
6.3.2 軟件預取
硬件預取的好處是:不需要軟件處理,缺點:預取不能超過1頁。而軟件預取需要源代碼配合。
#include <xmmintrin.h>
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);
以上的命令會把指針的內容預取到cache中。如果沒有可用空間了,那么會犧牲掉其他數據,沒不要的預取應該要避免,不然反而會增加帶寬的消耗。
_MM_HINT_T0,_MM_HINT_T1,_MM_HINT_T2,_MM_HINT_NTA,分別預取到L1,L2,L3, _MM_HINT_NTA的NTA是non-temporal access的縮寫,告訴處理器盡量避免訪問這個數據,因為只是短時間使用,所以處理器如果是包含性的就不讀取到低級的cache中,如果從L1d中犧牲,不需要推到L2中,而是直接寫入內存。要小心使用,如果work set太大,犧牲了很多cache line,這些cache line 由要從內存中加載,有些得不償失了。
圖中使用NPAD=31 每個元素大小為128,只有8%的提升,很不明顯。加入預取代碼效果不是很明顯,可以使用性能計數器,在需要的位子上面加預取。
-fprefetch-loop-arrays是GCC提供的編譯選項 其將為優化數組循環插入prefetch指令,對於小的循環就沒有好處,要小心使用
6.3.3 特殊類型的預取:推測(speculation)
主要的用處是在其他不相關的代碼中映入預取,來hide延遲
6.3.4 幫助thread
把預取放在代碼中會讓邏輯變得很復雜,可以使用其他處理器來做幫助thread,幫忙預取數據。難度是不能太慢,也不能太快,增加L1i的有效性。
1.使用超線程,可以預取到l2,升至l1
2.使用dumber線程,只做簡單的讀取和預取操作
上圖看到性能有提升。
可以通過libNUMA中的函數,來知道cache的共享信息
#include <libNUMA.h>
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_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);
self 是當前的內核,hts是可以被用來做help線程的內核。
6.3.5直接cache訪問
現代的硬件可以直接寫入到內存,但是對於馬上要使用的數據,就會出現miss,所以intel有了一個技術,可以直接報包的數據寫入到cache中。使用DCA(direct cache access)來標記這個包,寫在包頭中,如圖:
左右沒有使用DCA標記,右圖有DCA標記, DCA標記如果fsb識別那么就直接傳入到L1cache中了。這個功能需要所有硬件都有相關功能才能使用。
6.4多線程優化
關於多線程的優化,主要從以下3方面展開
1.並發
2.原子性
3.帶寬
6.4.1 並發優化
通常cache的優化是那數據盡量放在一起,減少代碼footprint,能夠最大化的把內存放到cache中,但是多線程往往訪問相似的數據,這樣會導致,如果一個線程對一個內存進行修改請求,cache line 必須是 獨占(E)狀態,這就意味着要做RFO。就算所有的線程使用獨立的內存,並且是獨立的還是可能會出現這種狀況。
上圖測試在4個P4處理器上進行,在第9.3中有這個測試的demo,代碼主要是做對一個內存做5000W次的自增,藍色的值表示,線程在分開的cache上自增,紅色的表示在同一個cache上自增。
當線程寫入一個cache是,所有的其他cache都會被延遲,並且cache是獨立的。造成紅色部分比藍色部分高的原因。
上圖是intel core2 QX6700 上的測試,4核有2個獨立的L2,並沒有出現這個問題,原因就是cache是共享的。
簡單的解決問題的方法:把變量放到線程各自的cache line中,線程變量。
1.把讀寫的變量和只讀的變量發開存放。也可以用來解決讀多寫少的變量。
2.會被同時使用的變量放到一個結構體上,用結構體來保證,變量在比較近的。
3.把被不同線程讀寫的變量寫入到他們自己的cache line中。
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 } };
4.把被多線程使用,但是使用都是獨立的變量放入各自線程中,如:
int foo = 1;
__thread int bar = 2; int baz = 3; __thread int xyzzy = 4;
6.4.2 原子優化
作者只介紹了原子操作的相關操作,並說盡量減少原子操作。
6.4.3 帶寬沖突
每個處理器都有與內存連接的帶寬,由於體系結構的不同,多個處理器可能共享一個總線或者北橋。那么就可能會出現帶寬的爭用問題。
1.買速度快的服務器,如果重寫程序比較昂貴的情況下
2.優化程序避免miss,調度器並不清楚workload,只能收集到cache miss,很多情況下並沒有什么幫助
3.盡量讓cache存儲變得更有效,讓處理器處理一塊數據。
cache使用效率低下
cache 使用效率高
調度器不知道workload,所以需要開發人員自己設置調度。
進程調度:
#define _GNU_SOURCE #include <sched.h> 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);
線程的調度:
#define _GNU_SOURCE
#include <pthread.h>
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);
6.5 NUMA編程
NUMA的特點是:cpu內訪內存時,節點訪問代價高,非跨節點代價低。
2個線程在同一個內核中使用相同的cache協作比使用獨立的cache要快。
6.5.1 內存策略
對於numa來說內存的訪問,尤為重要,所有出了個內存策略也就是內存分配策略,具體說就是在哪里分配內存。
linux 內核支持的策略種類:
1.MPOL_BIND:在給定的node中分配,如果不行則失敗
2.MPOL_PREFERRED:首選在給定的節點分配,失敗考慮其他節點
3.MPOL_INTERLEAVE:通過虛擬地址區域,決定在哪個節點上分配
4.MPOL_DUFAULT:在default區域上分配
以上的遞歸的方法定義策略只是對了一半,其實,內存策略的有個一個框圖:
若地址落在VMA上那么,使用VMA策略,若在共享上,那么使用ShMem Policy,如果,這個地址沒有指定策略,那么使用task policy,如果定義了task policy那么就task policy處理,如果沒有就system default policy處理。
6.5.2 指定策略
#include <numaif.h>
long set_mempolicy(int mode,
unsigned long *nodemask,
unsigned long maxnode);
通過以上方法來設置task policy
6.5.3 交換和策略
交換后,內存中的節點信息就會消失,對於一個多處理器共享的頁來說隨着時間的推移,相關性就會被改變,
6.5.4 VMA Policy
通過函數來裝載 VMA Policy
#include <numaif.h>
long mbind(void *start, unsigned long len,
int mode,
unsigned long *nodemask,
unsigned long maxnode,
unsigned flags);
VMA區域是 start,start+len
mode 就是前面提到的4個策略
flag參數:
MPOL_MF_STRICT:如果mbind中的地址不在nodemask中,就報錯
MPOL_MF_MOVE:如果有地址不在這個node上,試圖移動。
MPOL_MF_MOVEALL:會移動所有,不單單是這個進程用到的頁表,這個參數會影響其他進程的訪問
對於一個已經保留了地址,但是沒有分配的地址空間,可以使用mbind但是不帶參數。
6.5.5 查詢節點信息
獲取numa策略
#include <numaif.h>
long get_mempolicy(int *policy,
const unsigned long *nmask,
unsigned long maxnode,
void *addr, int flags);
通過虛擬地址查詢節點信息
#include <libNUMA.h>
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);
查詢cpu對應的節點
#include <libNUMA.h>
int NUMA_cpu_to_memnode(size_t cpusetsize,
const cpu_set_t *cpuset,
size_t memnodesize,
memnode_set_t *
memnodeset);
#include <libNUMA.h>
int NUMA_memnode_to_cpu(size_t memnodesize,
const memnode_set_t *
memnodeset,
size_t cpusetsize,
cpu_set_t *cpuset);
6.5.6 cpu和節點設置
使用cpuset來限制用戶或者程序對cpu和內存的使用。
6.5.7 顯示優化NUMA
當內地內存和affinity規則,如果所有的線程要訪問同一個地址,那么訪問本地內存,和affinity是沒有效果的。
對於只讀的內存可以直接使用復制,把數據復制到要用的節點。
但是對於可讀寫的就比較麻煩,如果計算不依賴於上次的結果,可以把各個節點的計算累計到各個node上,然后再寫入內存。
如果訪問內存是固定的,遷移到本地node中,如果訪問很高,但是非本地訪問減少,那么就是有用的。但是要注意,頁遷移還是有不小的開銷的。
6.5.8
利用所有
帶寬
在圖5.4中,訪問遠程和本地並沒有明顯的差距,這個是因為,把寫入不再使用的數據,通過內存附加,寫入到了其他節點上去了,因為帶寬連接DRAM和cpu之間的內部連接是一樣的,所以,看不出來性能的差距。
利用所有的帶寬是很多方面的:一個就是確定cache是無效的,因為需要通過遠程來放置,另外一個是遠程內存是否需要帶寬
