很多人都不知曉的CPU訪問內存知識!

CPU是怎樣訪問內存的?簡單的答案是,CPU執行一條訪存指令,把讀寫請求發往內存管理單元。內存管理單元進行虛實轉換,把命令發往總線。總線把命令傳遞給內存控制器,內存控制器再次翻譯地址,對相應內存顆粒進行存取。之後,讀取的數據寫入確認按照原路返回。再複雜些,當中插入多級緩存,在每一層緩存都未命中的情況下,訪問纔會最終達到內存顆粒。

知道了完整的路徑,開始研究每一步中的硬件到底是怎麼樣的,讀寫指令到底是怎樣在其中傳輸的。要了解硬件,首先要說下處理器。處理器的基本結構並不複雜,一般分爲取指令、譯碼、發射、執行、寫回五個步驟。而我們說的訪存,指的是訪問數據,不是指令抓取。訪問數據的指令在前三步沒有什麼特殊,在第四步,它會被髮送到存取單元,等待完成。當指令在存取單元裏的時候,產生了一些有趣的問題。

第一個問題,對於讀指令,當處理器在等待數據從緩存或者內存返回的時候,它到底是什麼狀態?是等在那不動呢,還是繼續執行別的指令?

一般來說,如果是亂序執行的處理器,那麼可以執行後面的指令,如果是順序執行,那麼會進入停頓狀態,直到讀取的數據返回。當然,這也不是絕對的。在舉反例之前,我們先要弄清什麼是亂序執行。亂序執行是說,對於一串給定的指令,爲了提高效率,處理器會找出非真正數據依賴的指令,讓他們並行執行。但是,指令執行結果在寫回到寄存器的時候,必須是順序的。也就是說,哪怕是先被執行的指令,它的運算結果也是按照指令次序寫回到最終的寄存器的。這個和很多程序員理解的亂序執行是有區別的。我發現有些人在調試軟件問題的時候,會覺得使用了一個亂序的處理器,那麼可能會使得後面的代碼先被執行,從而讓調試無法進行。

他們搞混了兩個概念,就是訪存次序和指令完成次序。對於普通的運算指令,他們僅僅在處理器內部執行,所以你看到的是寫回次序。而對於訪存指令,指令會產生讀請求,併發送到處理器外部,你看到的次序是訪存次序。對於亂序處理器,可能同時存在多個請求,而其次序,是打亂的,不按原指令順序的。但是此時,這些被髮送到外部的讀請求,並沒有拿到返回結果,指令也沒有完成。所以,這並不違反亂序執行順序完成的原則。如果有前後兩條讀指令,沒有數據相關性,哪怕是後面那條讀的數據先被返回,它的結果也不能先寫回到最終的寄存器,而是必須等到前一條完成後纔可以。

對於順序執行的處理器,同樣是兩條讀指令,一般必須等到前一條指令完成,才能執行第二條,所以在處理器外部看到的是按次序的訪問。不過也有例外,比如讀寫同時存在的時候,由於讀和寫指令實際上走的是兩條路徑,所以可能會看到同時存在。

還有,順序處理器上,哪怕是兩條讀指令,也有可能同時存在兩個外部請求。比如Cortex-A7,對於連續的讀指令,在前一條讀未命中一級緩存,到下一級緩存或者內存抓取數據的時候,第二條讀指令可以被執行。所以說,亂序和順序並不直接影響指令執行次序。他們的區別在於,亂序需要額外的緩衝和邏輯塊(稱爲重排序緩衝, re-order buffer)來計算和存儲指令間的相關性以及執行狀態,而順序處理器沒有重排序緩衝,或者非常簡單。這些額外的面積可不小,據我所看到的,可以佔到處理器核心的40%。它們所帶來的更高的並行度,性能提升卻未必有40%。因爲我們寫的單線程程序,由於存在很多數據相關,造成指令的並行是有限的,再大的重排序緩衝也解決不了真正的數據相關。所以對於功耗敏感的處理器還是使用順序執行。

還有一點需要注意,順序執行的處理器,在指令抓取,解碼和發射階段,兩條或者多條指令,是可以同時進行的。比如,無依賴關係的讀指令和運算指令,可以被同時發射到不同的執行單元,同時開始執行。但是完成還是按順序的。

但是,在有些ARM處理器上,比如Cortex-A53,向量或者加解密指令是可以亂序完成的,這類運算的結果之間並沒有數據依賴性。這點請千萬注意。

再來看看寫指令。寫和讀有個很大的不同,就是寫指令不必等待數據寫到緩存或者內存,就可以完成了。寫出去的數據會到一個叫做store buffer的緩衝,它位於一級緩存之前,只要它沒滿,處理器就可以直接往下走,不必停止並等待。所以,對於連續的寫指令,無論順序還是亂序執行處理器,都可能看到多個寫請求同時掛在處理器總線上。同時,由於處理器不必像讀指令那樣等待結果,就可以在單位時間內送出更多寫請求,所以我們可以看到寫帶寬通常是大於讀帶寬的。

以上所說的讀寫訪問都是在開啓緩存的情況。

對於同時存在的多個請求,有一個名詞來定義它,叫做outstanding transaction,簡稱OT。它和延遲一起,構成了我們對訪存性能的描述。延遲這個概念,在不同領域有不同的定義。在網絡上,網絡延遲表示單個數據包從本地出發,經過交換和路由,到達對端,然後返回,當中所花的總時間。在處理器上,我們也可以說讀寫的延遲是指令發出,經過緩存,總線,內存控制器,內存顆粒,然後原路返回所花費的時間。但是,更多的時候,我們說的訪存延遲是大量讀寫指令被執行後,統計出來的平均訪問時間。這裏面的區別是,當OT=1的時候,總延時是簡單累加。當OT>1,由於同時存在兩個訪存並行,總時間通常少於累加時間,並且可以少很多。這時候得到的平均延遲,也被稱作訪存延遲,並且用得更普遍。再精確一些,由於多級流水線的存在,假設流水線每一個階段都是一個時鐘週期,那訪問一級緩存的平均延遲其實就是一個週期.而對於後面的二級,三級緩存和內存,就讀指令來說,延遲就是從指令被髮射(注意,不是從取指)到最終數據返回的時間,因爲處理器在執行階段等待,流水線起不了作用。如果OT=2, 那麼時間可能縮短將近一半。OT>1的好處在這裏就體現出來了。當然,這也是有代價的,存儲未完成的讀請求的狀態需要額外的緩衝,而處理器可能也需要支持亂序執行,造成面積和功耗進一步上升。對於寫指令,只要store buffer沒滿,還是一個時鐘週期。當然,如果流水線上某個節拍大於一個時鐘週期,那平均的延時就會取決於這個最慢的時間。在讀取二級,三級緩存和內存的時候,我們可以把等待返回看作一個節拍,那麼就能很自然的理解此時的延遲了。由此,我們可以得到每一級緩存的延遲和訪存延遲。

上圖畫了讀寫指令經過的單元。我把流程簡單描述下:

當寫指令從存取單元LSU出發,它首先經過一個小的store queue,然後進入store buffer。之後,寫指令就可以完成了,處理器不必等待。Store buffer通常由幾個8-16字節的槽位組成,它會對自己收到的每項數據進行地址檢查,如果可以合併就合併,然後發送請求到右邊的一級緩存,要求分配一行緩存,來存放數據,直到收到響應,這稱作寫分配write allocate。當然,等待的過程可以繼續合併同緩存行數據。如果數據是Non-Cacheable的,那麼它會計算一個等待時間,然後把數據合併,發送到總線接口單元BIU裏面的寫緩衝Write buffer。 而寫緩衝在把數據發到二級緩存之前,會經過監聽控制單元,把四個核的緩存做一致性。過程和總線描述的類似,就不多講了。

當讀指令從存取單元LSU出發,無論是否Cacheable的,都會經過一級緩存。如果命中,那麼直接返回數據,讀指令完成。如果未命中,那麼Non-Cacheable的請求直接被送到Read Buffer。如果是Cacheable的,那麼一級緩存需要分配一個緩存行,並且把原來的數據寫出到替換緩衝eviction buffer,同時發起一個緩存行填充,發送到Linefill Buffer。eviction buffer會把它的寫出請求送到BIU裏面的Write buffer,和Store Buffer送過來的數據一起,發到下一級接口。然後這些請求又經過監聽控制單元做一致性檢測後,發到二級緩存。當然有可能讀取的數據存在於別的處理器一級緩存,那麼就直接從那裏抓取。

過程並不複雜,但程序員關心的是這個過程的瓶頸在哪,對讀寫性能影響如何。我們已經解釋過,對於寫,由於它可以立刻完成,所以它的瓶頸並不來自於存取單元;對於讀,由於處理器會等待,所以我們需要找到讀取路徑每一步能發出多少OT,每個OT的數據長度是多少。

拿Cortex-A7來舉例,它有2x32字節linefill buffer,支持有條件的miss-under-miss(相鄰讀指令必須在3時鐘週期內),也就是OT最多等於2,而它的數據緩存行長度是64字節,所以每個OT都是半個緩存行長度。對於Cacheable的讀來說,我還關心兩個數據,就是eviction buffer和Write buffer,它們總是伴隨着line fill。在A7中,存在一個64字節的eviction buffer和一個Write buffer。有了這些條件,那麼我就可以說,對於連續的讀指令,我能做到的OT就是2,而linefill的速度和eviction,write buffer的速度一致,因爲2x32=64字節。

那這個結論是不是正確?寫個小程序測試下就知道。我們可以關掉二級緩存,保留一級緩存,然後用以下指令去讀取一個較大的內存區域。所有的地址都是緩存行對齊,對齊的意義我就不說了,不對齊,甚至越過緩存行邊界,會把一個操作變成兩個,肯定會慢。僞代碼如下:

loopload R0, addr+0load R0, addr+4load R0, addr+8load R0, addr+12addr=addr+16

這裏通過讀取指令不斷地去讀數據。通過處理器自帶的性能計數器看了下一級緩存的未命中率,6%多一點。這恰恰是4/64字節的比率。說明對於一個新的緩存行,第一個四字節總是未命中,而後面15個四字節總是命中。當然,具體的延遲和帶寬還和總線,內存控制器有關,這裏只能通過命中率簡單驗證下。

對於有的處理器,是嚴格順序執行的,沒有A7那樣的miss-under-miss機制,所以OT=1。我在Cortex-R5上做同樣的實驗,它的緩存行長度是32字節,2xLinefill buffer是32字節。測試得到的命中率是12%多點。也完全符合估算。

但是爲什麼R5要設計兩個32字節長度的Linefill buffer?既然它的OT=1,多出來的一個豈不是沒用?實際上它是可以被用到的,而方法就是使用預取指令PLD。預取指令的特點就是,它被執行後,處理器同樣不必等待,而這個讀請求會被同樣發送到一級緩存。等到下次有讀指令來真正讀取同樣的緩存行,那麼就可能發現數據已經在那了。它的地址必須是緩存行對齊。這樣,讀也可像寫那樣把第二個 Linefill buffer給用上了。

我們把它用到前面的例子裏:

loopPLD addr+32load R0, addr+0;...;load R0, addr+28;load R0, addr+32;...;load R0, addr+60;addr=addr+64

PLD預先讀取第二行讀指令的地址。測試發現,此時的未命中率還是6%。這也符合估算,因爲第二排的讀指令總是命中,第一排的未命中率4/32,平均下就是6%。而測試帶寬提升了80%多。單單看OT=2,它應該提升100%,但實際不可能那麼理想化,80%也可以理解。

還有一種機制使得OT可以更大,那就是緩存的硬件預取。當程序訪問連續的或者有規律的地址時,緩存會自動檢測出這種規律,並且預先去把數據取來。這種方法同樣不佔用處理器時間,但是也會佔用linefill buffer,eviction buffer和write buffer。所以,如果這個規律找的不好,那麼反而會降低效率。

讀看完了,那寫呢?Cacheable的寫,如果未命中緩存,就會引發write allocate,繼而造成Linefill和eviction,也就是讀操作。這點可能很多程序員沒想到。當存在連續地址的寫時,就會伴隨着一連串的緩存行讀操作。有些時候,這些讀是沒有意義的。比如在memset函數中,可以直接把數據寫到下一級緩存或者內存,不需要額外的讀。於是,大部分的ARM處理器都實現了一個機制,當探測到連續地址的寫,就不讓store buffer把數據發往一級緩存,而是直接到write buffer。並且,這個時候,更容易合併,形成突發寫,提高效率。在Cortex-A7上它被稱作Read allocate模式,意思是取消了write allocate。而在有的處理器上被稱作streaming模式。很多跑分測試都會觸發這個模式,因此能在跑分上更有優勢。

但是,進入了streaming模式並不意味着內存控制器收到的地址都是連續的。想象一下,我們在測memcpy的時候,首先要從源地址讀數據,發出去的是連續地址,並且是基於緩存行的。過了一段時間後,緩存都被用完,那麼eviction出現了,並且它是隨機或者僞隨機的,寫出去的地址並無規律。這就打斷了原本的連續的讀地址。再看寫,在把數據寫到目的地址時,如果連續的寫地址被發現,那麼它就不會觸發額外的linefill和eviction。這是好事。可是,直接寫到下一級緩存或者內存的數據,很有可能並不是完整的緩存發突發寫,應爲store buffer也是在不斷和write buffer交互的,而write buffer還要同時接受eviction buffer的請求。其結果就是寫被分成幾個小段。這些小塊的寫地址,eviction的寫地址,混合着讀地址,讓總線和內存控制器增加了負擔。它們必須採用合適的算法和參數,才能合併這些數據,更快的寫到內存顆粒。

然而事情還沒有完。我們剛纔提到,streaming模式是被觸發的,同樣的,它也可以退出。退出條件一般是發現存在非緩存行突發的寫。這個可能受write buffer的響應時間影響。退出後,write allocate就又恢復了,從而讀寫地址更加不連續,內存控制器更加難以優化,延時進一步增加,反饋到處理器,就更難保持在streaming模式。

再進一步,streaming模式其實存在一個問題,那就是它把數據寫到了下一級緩存或者內存,萬一這個數據馬上就會被使用呢?那豈不是還得去抓取?針對這個問題,在ARM v8指令集中(適用於A53/57/72),又引入了新的一條緩存操作指令DCZVA,可以把整行緩存設成0,並且不引發write allocate。爲什麼?因爲整行數據都被要改了,而不是某個字段被改,那就沒有必要去把原來的值讀出來,所以只需要allocate,不需要讀取,但它還是會引發eviction。類似的,我們也可以在使用某塊緩存前把它們整體清除並無效化,clean&invalidate,這樣就不會有eviction。不過如果測試數據塊足夠大,這樣只是相當於提前做了eviction,並不能消除,讓寫集中在某段。使之後的讀更連續。

以上都是針對一級緩存。二級緩存的控制力度就小些,代碼上無法影響,只能通過設置寄存器,打開二級緩存預取或者設置預取偏移。我在ARM的二級緩存控制器PL301上看到的,如果偏移設置的好,抓到的數據正好被用上,可以在代碼和一級緩存優化完成的基礎上,讀帶寬再提升150%。在新的處理器上,同時可以有多路的預取,探測多組訪存模板,進一步提高效率。並且,每一級緩存後面掛的OT數目肯定大於上一級,它包含了各類讀寫和緩存操作,利用好這些OT,就能提高性能。

對於Non-Cacheable的寫,它會被store buffer直接送到write buffer進行合併,然後到下一級緩存。對於Non-Cacheable的讀,我們說過它會先到緩存看看是不是命中,未命中的話直接到read buffer,合併後發往下一級緩存。它通常不佔用linefill buffer,因爲它通常是4到8字節,不需要使用緩存行大小的緩衝。

我們有時候也可以利用Non-Cacheable的讀通道,和Cacheable的讀操作並行,提高效率。它的原理就是同時利用linefill buffer和read buffer。此時必須保證處理器有足夠的OT,不停頓。

簡而言之,訪存的軟件優化的原則就是,保持對齊,找出更多可利用的OT,訪存和預取混用,保持更連續的訪問地址,縮短每一環節的延遲。

最後解釋一下緩存延遲的產生原因。程序員可能不知道的是,不同大小的緩存,他們能達到的時鐘頻率是不一樣的。ARM的一級緩存,16納米工藝下,大小在32-64K字節,可以跑在1-2Ghz左右,和處理器同頻。處理器頻率再快,那麼訪問緩存就需要2-3個處理器週期了。而二級緩存更慢,256K字節的,能有800Mhz就很好了。這是由於緩存越大,需要查找的目錄index越大,扇出fanout和電容越大,自然就越慢。還有,通常處理器宣傳時候所說的訪問緩存延遲,存在一個前提,就是使用虛擬地址索引VIPT。這樣就不需要查找一級Tlb表,直接得到索引地址。如果使用物理地址索引PIPT,在查找一級tlb進行虛實轉換時,需要額外時間不說,如果產生未命中,那就要到二級甚至軟件頁表去找。那顯然太慢了。那爲什麼不全使用VIPT呢?因爲VIPT會產生一個問題,多個虛地址會映射到一個實地址,從而使得緩存多個表項對應一個實地址。存在寫操作時,多條表項就會引起一致性錯誤。而指令緩存通常由於是隻讀的,不存在這個問題。所以指令緩存大多使用VIPT。隨着處理器頻率越來越高,數據緩存也只能使用VIPT。爲了解決前面提到的問題,ARM在新的處理器裏面加了額外的邏輯來檢測重複的表項。

囉嗦了那麼多,該說下真正系統裏的訪存延遲到底如何了。直接上圖:

上圖的配置中,DDR4跑在3.2Gbps,總線800Mhz,內存控制器800Mhz,處理器2.25Ghz。關掉緩存,用讀指令測試。延遲包括出和進兩個方向,69.8納秒,這是在總是命中一個內存物理頁的情況下的最優結果,隨機的地址訪問需要把17.5納秒再乘以2到3。關於物理頁的解釋請參看內存一章。

在內存上花的時間是控制器+物理層+接口,總共38.9納秒。百分比55%。如果是訪問隨機地址,那麼會超過70納秒,佔70%。在總線和異步橋上花的時間是20納秒,8個總線時鐘週期,28%。處理器11.1納秒,佔16%,20個處理器時鐘週期。

所以,即使是在3.2Gbps的DDR4上,大部分時間還都是在內存,顯然優化可以從它上面入手。在處理器中的時間只有一小部分。但從另外一個方面,處理器控制着linefill,eviction的次數,地址的連續性,以及預取的效率,雖然它自己所佔時間最少,但也是優化的重點。

在ARM的路線圖上,還出現了一項並不算新的技術,稱作stashing。它來自於網絡處理器,原理是外設控制器(PCIe,網卡)向處理器發送請求,把某個數據放到緩存,過程和監聽snooping很類似。在某些領域,這項技術能夠引起質的變化。舉個例子,intel至強處理器,配合它的網絡轉發庫DPDK,可以做到平均80個處理器週期接受從PCIe網卡來的包,解析包頭後送還回去。80週期是個什麼概念?看過了上面的訪存延遲圖後你應該有所瞭解,處理器訪問下內存都需要200-300週期。而這個數據從PCIe口DMA到內存,然後處理器抓取它進行處理後,又經過DMA從PCIe口出去,整個過程肯定大於訪存時間。80週期的平均時間說明它肯定被提前送到了緩存。 但傳進來的數據很多,只有PCIe或者網卡控制器才知道哪個是包頭,才能精確的推送數據,不然緩存會被無用的數據淹沒。這個過程做好了,可以讓軟件處理以太網或者存儲單元的速度超過硬件加速器。事實上,在freescale的網絡處理器上,有了硬件加速器的幫助,處理包的平均延遲需要200處理器週期,已經慢於至強了。

還有,在ARM新的面向網絡和服務器的核心上,會出現一核兩線程的設計。處理包的任務天然適合多線程,而一核兩線程可以更有效的利用硬件資源,再加上stashing,如虎添翼。(轉自玩轉單片機)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章