ARM攢機指南-基礎篇
在開篇里,我們對晶元PPA有了初步的認識。下面,讓我們從訪存這個簡單的問題開始展開介紹晶元基礎概念。
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沒滿,還是一個時鐘周期。當然,如果流水線上某個節拍大於一個時鐘周期,那平均的延時就會取決於這個最慢的時間。在讀取二級,三級緩存和內存的時候,我們可以把等待返回看作一個節拍,那麼就能很自然的理解此時的延遲了。由此,我們可以得到每一級緩存的延遲和訪存延遲。上圖畫了ARM某處理器讀寫指令經過的單元,簡單流程如下:
當寫指令從存取單元LSU出發,它首先經過一個小的store queue,然後進入store buffer。之後,寫指令就可以完成了,處理器不必等待。Store
buffer通常由幾個8-16位元組的槽位組成,它會對自己收到的每項數據進行地址檢查,如果可以合併就合併,然後發送請求到右邊的一級緩存,要求分配一行緩存,來存放數據,直到收到響應,這稱作寫分配writeallocate。當然,等待的過程可以繼續合併同緩存行數據。如果數據是Non-Cacheable的,那麼它會計算一個等待時間,然後把數據合併,發送到匯流排介面單元BIU裡面的寫緩衝Writebuffer。 而寫緩衝在把數據發到二級緩存之前,會經過監聽控制單元,把四個核的緩存做一致性檢測。
當讀指令從存取單元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和Writebuffer,它們總是伴隨著line fill。在A7中,存在一個64位元組的eviction buffer和一個Write buffer。有了這些條件,那麼我就可以說,對於連續的讀指令,我能做到的OT就是2,而linefill的速度和eviction,write buffer的速度一致,因為2x32=64位元組。那這個結論是不是正確?寫個小程序測試下就知道。我們可以關掉二級緩存,保留一級緩存,然後用以下指令去讀取一個較大的內存區域。所有的地址都是緩存行對齊。不對齊,甚至越過緩存行邊界,會把一個操作變成兩個,肯定會慢。偽代碼如下:
loop
load R0, addr+0
load R0, addr+4
load R0, addr+8
load R0, addr+12
addr=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給用上了。我們把它用到前面的例子里:
loop
PLD addr+32
load 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處理器都實現了一個機制,當探測到連續地址的寫,就不讓storebuffer把數據發往一級緩存,而是直接到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模式其實存在一個問題,那就是它把數據寫到了下一級緩存或者內存,萬一這個數據馬上就會被使用呢?那豈不是還得去抓取?針對這個問題,在ARMv8指令集中,又引入了新的一條緩存操作指令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位元組,可以跑在2Ghz左右,和處理器同頻。處理器頻率再快,那麼訪問緩存就需要2-3個處理器周期了。但由於訪問一級緩存的時間一般不會超過3個始終周期,每增加一個周期,性能就會有明顯的下降。而二級緩存更慢,256K位元組的,能有800Mhz就很好了。這是由於緩存越大,需要查找的目錄index越大,扇出fanout和電容越大,自然就越慢。但由於訪問二級緩存本身的延遲就有10個時鐘周期左右,多一個周期影響沒有那麼明顯。還有,通常處理器宣傳時候所說的訪問緩存延遲,存在一個前提,就是使用虛擬地址索引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處理器周期,已經慢於至強了。其原因是訪問硬體加速器本身需要設置4-8次的寄存器,而訪問一次寄存器的延遲是幾十納秒,反而成為了瓶頸。
如果上面一段看完你沒什麼感覺,那我可以換個說法:對於沒有完整支持stashing的ARM SoC,哪怕處理器跑在10Ghz,網路加速器性能強的翻天,基於DPDK的簡單包轉發(快於Linux內核網路協議棧轉發幾十倍)還是只能到至強的30%,而包轉發是網路處理器的最重要的指標之一,也是伺服器跑網路轉發軟體的指標之一,更可以用在存儲領域,加速SPDK之類的存儲應用。
還有,在ARM新的面向網路和伺服器的核心上,會出現一核兩線程的設計。處理包的任務天然適合多線程,而一核兩線程可以更有效的利用硬體資源,再加上stashing,如虎添翼。
弄清了訪存的路徑,可能就會想到一個問題:處理器發出去的讀寫請求到底是個什麼東西?要想搞清楚它,就需要引入匯流排。下文我拿ARM的AXI/ACE匯流排協議以及由它衍生的匯流排結構來展開討論。這兩個協議廣泛用於主流的手機晶元上,是第四代AMBA(Advanced Microcontroller Bus Architecture)標準。
簡單的匯流排就是一些地址線和數據線,再加一個仲裁器,就可以把處理器發過來的讀寫請求送到內存或者外設,再返回數據。在這個過程中,我們需要一個主設備,一個從設備,所有的傳輸都是主設備發起,從設備回應。讓我們把處理器和它包含的緩存看作一個主設備,把內存控制器看作從設備。處理器發起訪問請求,如果是讀,那麼匯流排把這個請求(包括地址)送到內存控制器,然後等待回應。過了一段時間,內存控制器把內存顆粒裡面讀出的數據交給匯流排,匯流排又把數據交給處理器。如果數據無誤(ECC或者奇偶校驗不出錯),那麼這個讀操作就完成了。如果是寫,處理器把寫請求(包括地址)和數據交給匯流排,匯流排傳遞給內存控制器,內存控制器寫完後,給出一個確認。這個確認經由匯流排又回到了處理器,寫操作完成。
以上過程有幾個重點。第一,處理器中的單個讀指令,被分為了請求(地址),完成(數據)階段。寫指令也被分為了請求(地址,數據),完成(寫入確認)階段。第二,作為從設備,內存控制器永遠都無法主動發起讀寫操作。如果一定要和處理器通訊,比如發生了讀寫錯誤,那就得使用中斷,然後讓處理器來發起讀寫內存控制器狀態的請求。第三,未完成的讀寫指令就變成了OT,匯流排可以支持多個OT。然而,匯流排支持多OT並不表示處理器能發送這麼多請求出來,尤其是讀。所以瓶頸可能還是在處理器。
我遇到過幾次這樣的情況,在跑某個驅動的時候,突然系統掛死。但是別的設備中斷還能響應,或者報個異常後系統又繼續跑了。如果我們把上文的內存控制器替換成設備控制器,那就不難理解這個現象了。假設處理器對設備發起讀請求,而設備沒有回應,那處理器就會停在那等待。我看到的處理器,包括PowerPC, ARM,都沒有針對這類情況的超時機制。如果沒有中斷,那處理器無法自己切換到別的線程(Linux等操作系統的獨佔模式),就會一直等待下去,系統看上去就掛住了。有些設備控制器可以自動探測這類超時,並通過中斷調用相應的異常或者中斷處理。在中斷處理程序中,可以報個錯,修改返回地址,跳過剛才的指令往下走,系統就恢復了。也有些處理器在觸發某類異常後能自動跳到下一行指令,避免掛死。但是如果沒有異常或者中斷髮生,那就永遠掛在那。
繼續回到匯流排。在AXI/ACE匯流排協議中,讀和寫是分開的通道,因為他們之間並沒有必然聯繫。更細一些,匯流排上規定了五個組,分別是讀操作地址(主到從),讀操作數據(從到主),寫操作地址(主到從),寫操作數據(主到從),寫操作確認(從到主)。讀和寫兩大類操作之間,並沒有規定先後次序。而在每一類操作之內的組之間,是有先後次序的,比如地址是最先發出的,數據隨後,可以有很多拍,形成突發操作。而確認是在寫操作中,從設備收到數據之後給出的。對內存控制器,必須在數據最終寫入到顆粒之後再給確認,而不是收到數據放到內部緩存中的時候。當然,這一點可以有例外,那就是提前應答early
response。中間設備為了提高效,維護自己的一塊緩衝,在收到數據後,直接向傳遞數據的主設備確認寫入,使得上層設備釋放資源。但是這樣一來,由於數據並沒有真正寫入最終從設備,發出提前應答的中間設備必須自己維護好數據的一致性和完整性,稍不小心就會造成死鎖。ARM的現有匯流排都不支持這個操作,都是不會告知主設備early response的,所有的內部緩衝,其實是一個FIFO,不對訪問次序和應答做任何改動。對於同一個通道,如果收到連續的指令,他們之間的次序是怎麼樣的呢?AXI/ACE協議規定,次序可以打亂。拿讀來舉例,前後兩條讀指令的數據返回是可以亂序的。這裡包含了一個問題,匯流排怎麼區分住前後兩讀條指令?很簡單,在地址和數據組裡加幾根信號,作為標誌符,來區分0-N號讀請求和完成。每一對請求和完成使用相同的標誌符。有了這個標誌符,就不必等前一個請求完成後才開始第二個請求,而是讓他們交替進行,這樣就可以實現匯流排的OT,極大提高效率。當然,也需要提供相應的緩衝來存儲這些請求的狀態。並且最大的OT數取決於緩衝數和標誌符中小的那個。原因很簡單,萬一緩衝或者標誌符用完了,但是所有的讀操作全都是請求,沒有一個能完成怎麼辦?那隻好讓新的請求等著了。於是就有了AXI/ACE匯流排的一條規則,同一個讀或者寫通道中,相同標誌符的請求必須按順序完成。
有時候,處理器也會拿這個標誌符作為它內部的讀寫請求標誌符,比如Cortex-A7就是這麼乾的。這樣並不好,因為這就等於給自己加了限制,最大發出的OT不得大於匯流排的每通道標誌符數。當一個處理器組裡有四個核的時候,很可能就不夠用了,人為限制了OT數。
最後,讀寫通道之間是沒有規定次序的,哪怕標誌相同。
看到這裡可能會產生一個問題,讀寫指令裡面有一個默認原則,就是相同地址,或者地址有重疊的時候,訪存必須是順序的。還有,如果訪問的內存類型是設備,那麼必須保證訪存次序和指令一致。這個怎麼在匯流排上體現出來呢?匯流排會檢查地址來保證次序,一般是內存訪問前後亂序地址不能64位元組內,設備訪問前後亂序地址不能在4KB內。
在AXI/ACE中,讀和寫通道的比例是一比一。實際上,在日常程序中,讀的概率比寫要大。當然,寫緩存實際上伴隨著緩存行填充linefill(讀),而讀緩存會造成緩存行移除eviction(寫),再加上合併和次序調整,所以並不一定就是讀寫指令的比例。我看到Freescale
PowerPC的匯流排CCB,讀寫通道的比率是二比一。我不知道為什麼ARM並沒有做類似的設計來提高效率,也許一比一也是基於手機典型應用統計所得出的最好比例。至此,我們已經能夠在腦海中想像一對讀寫通道中讀寫操作的傳輸情況了。那多個主從設備組合起來是怎麼樣的情況?是不是簡單的疊加?這涉及到了匯流排設計最核心的問題,拓撲結構。
在ARM當前所有的匯流排產品里,根據拓撲的不同可以分為三類產品:NIC/CCI系列是交叉矩陣的(Crossbar),CCN/CMN系列是基於環狀和網狀匯流排的(Ring/Mesh),NoC系列是包轉發匯流排(Router)。他們各有特點,適合不同場景。交叉矩陣連接的主從設備數量受到限制,但是效率最高,讀寫請求可以在1到2個周期內就直達從設備。如下圖所示,這就是一個5x4的交叉矩陣:
根據我看到的數據,在28納米製程上,5x4的配置下,這個匯流排的頻率可以跑到300Mhz。如果進一步增加主從對數量,那麼由於扇出增加,電容和走線增加,就必須通過插入更多的寄存器來增加頻率。但這樣一來,從主到從的延遲就會相應增加。哪怕就是保持5x3的配置,要想進一步提高到500Mhz,要麼使用更好的工藝,16納米我看到的是800Mhz;要麼插入2-3級寄存器,這樣,讀寫延時就會達到4-5個匯流排時鐘周期,請求加完成來回總共需要10個。如果匯流排和處理器的倍頻比率為1:2,那麼僅僅是在匯流排上花費的時間,就需要至少20個處理器時鐘周期。倍率為4,時間更長,40個時鐘周期。要知道處理器訪問二級緩存的延遲通常也不過10多個處理器周期。當然,可以通過增加OT數量減少平均延遲,可是由於處理器的OT數是有限制的,對於順序處理器,可能也就是1-2個。所以,要達到更高的頻率,支持更多的主從設備,就需要引入環狀匯流排CCN系列,如下圖:
CCN匯流排上的每一個節點,除了可以和相鄰的兩個節點通訊之外,還可以附加兩個節點組件,比如處理器組,三級緩存,內存控制器等。在節點內部,還是交叉的,而在節點之間,是環狀的。這樣使得匯流排頻率在某種程度上擺脫了連接設備數量的限制(當然,還是受布線等因素的影響),在16納米下,可以達到1.2GHz以上。當然,代價就是節點間通訊更大的平均延遲。為了減少平均延遲,可以把經常互相訪問的節點放在靠近的位置。
在有些系統里,要求連接更多的設備,並且,頻率要求更高。此時環狀匯流排也不夠用了,這時需要網狀匯流排CMN。ARM的網狀匯流排,符合AMBA5.0的CHI介面,支持原子操作(直接在緩存運算,不用讀取到處理器),stashing和直接訪問(跳過中間的緩存,縮短路徑)等特性,適用於伺服器或者網路處理器。
但是有時候,系統需要連接的設備數據寬度,協議,電源,電壓,頻率,都不一樣,這時就需要NoC出馬了,如下圖:
這個圖中,剛才提到的交叉矩陣,可以作為整個網路的某部分。而連接整個系統的,是位於NoC內的節點。每個節點都是一個小型路由,它們之間傳輸的,是非同步的包。這樣,就不必維持路由和路由之間很大數量的連線,從而提高頻率,也能支持更多的設備。當然,壞處就是更長的延遲。根據我看到的數據,在16納米上,頻率可以跑到1.5Ghz。並且它所連接每個子模塊之間,頻率和拓撲結構可以是不同的。可以把需要緊密聯繫的設備,比如CPU簇,GPU放在一個子網下減少通訊延遲。
在實際的ARM生態系統中,以上三種拓撲結構的使用情況是怎麼樣的呢?一般手機晶元上使用交叉矩陣,網路處理器和伺服器上使用環狀和網狀拓撲,而NoC也被大量應用於手機晶元。最後一個的原因倒不是手機上需要連接的設備數太多,而是因為ARM的AXI匯流排NIC400對於交叉訪問(interleaving)支持的非常有限。在手機裡面,GPU和顯示控制器對內存帶寬要求是很高的。一個1080p的屏幕,每秒要刷新60次,2百萬個像素,每個像素32比特顏色,再加上8層圖層,就需要4GB/s的數據,雙向就是8GB/s。而一個1.6GHz傳輸率的LPDDR4控制器,64位數據,也只能提供12.8GB/s的的理論帶寬。理論帶寬和實際帶寬由於各種因素的影響,會有很大差別,複雜場景下能做到70%的利用率就不錯了,那也就是9GB/s。那處理器怎麼辦?其他各類控制器怎麼辦?只能增加內存控制器的數量。但是,不能簡單的增加數量。成本和功耗是一個原因,並且如果僅僅把不同的物理地址請求發送到不同的內存控制器上,很可能在某段時間內,所有的物理地址全都是對應於其中某一個,還是不能滿足帶寬要求。解決方法就是,對於任何地址,盡量平均的送到不同的內存控制器。並且這件事最好不是處理器來干,因為只有匯流排清楚有多少個內存控制器。最好處理器只管發請求,匯流排把所有請求平均分布。
有時候,傳輸塊大於256位元組,可以採用一個方法,把很長的傳輸拆開(Splitting),分送到不同的內存控制器。不幸的是,AXI匯流排天然就不支持一對多的訪問。原因很簡單,會產生死鎖。想像一下,有兩個主設備,兩個從設備,通過交叉矩陣連接。M1發送兩個讀請求,標誌符都是1,先後送到到S1和S2,並等待完成。然後M2也做同樣的事情,標誌符都是2,先後送到S2和S1。此時,假設S2發現它如果把返回的數據次序交換一下,會更有效率,於是它就這麼做了。但是M1卻不能接收S2的返回數據,因為根據同標誌符必須順序完成的原則,它必須先等S1的返回數據。而S1此時也沒法送數據給M2,因為M2也在等待S2返回的數據,死鎖就出現了。解決方法是,AXI的Master不要發出相同標誌的操作。如果標誌相同時,則必須等待上一次操作完成。或者,拆分和設置新標識符操作都由匯流排來維護,而主設備不關心,只管往外發。
在實際情況下,拆分主要用於顯示,視頻和DMA。ARM的CPU和GPU永遠不會發出大於64位元組的傳輸,不需要拆分。
現在的中低端手機很多都是8核,而根據ARM的設計,每個處理器組中最多有四個核。這就需要放兩個處理器組在系統中,而他們之間的通訊,包括大小核的實現,就需要用到匯流排一致性。每個處理器組內部也需要一致性,原理和外部相同,我就不單獨解釋了。使用軟體實可以現一致性,但是那樣需要手動的把緩存內容刷到下一級緩存或者內存,對於一個64位元組緩存行的64KB緩存來說,需要1000次刷新,每次就算是100納秒,且OT=4的話,也需要25微秒。對處理器來說這是一個非常長的時間。ARM使用了一個協處理器來做這個事情,這是一個解決方案。為了用硬體解決,ARM引入了幾個支持硬體一致性的匯流排,下圖是第一代方案CCI400:
CCI400是怎麼做到硬體一致性的呢?簡單來說,就是處理器組C1,發一個包含地址信息的特殊讀寫的命令到匯流排,然後匯流排把這個命令轉給另一個處理器組C2。C2收到請求後,根據地址逐步查找二級和一級緩存,如果發現自己也有,那麼就返回數據或者做相應的緩存一致性操作,這個過程稱作snooping(監聽)。具體的操作我不展開,ARM使用MOESI一致性協議,裡面都有定義。在這個過程中,被請求的C2中的處理器核心並不參與這個過程,所有的工作由緩存和匯流排介面單元BIU等部件來做。為了符合從設備不主動發起請求的定義,需要兩組主從設備,每個處理器組佔一個主和一個從。這樣就可以使得兩組處理器互相保持一致性。而有些設備如DMA控制器,它本身不包含緩存,也不需要被別人監聽,所以它只包含從設備,如上圖桔黃色的部分。在ARM的定義中,具有雙向功能的介面被稱作ACE,只能監聽別人的稱作ACE-Lite。它們除了具有AXI的讀寫通道外,還多了個監聽通道,如下圖:
多出來的監聽通道,同樣也有地址(從到主),回應(主到從)和數據(主到從)。每組信號內都包含和AXI一樣的標誌符,用來支持多OT。如果在主設備找到數據(稱為命中),那麼數據通道會被使用,如果沒有,那告知從設備未命中就可以了,不需要傳數據。由此,對於上文的DMA控制器,它永遠不可能傳數據給別人,所以不需要數據組,這也就是ACE和ACE-Lite的主要區別。
我們還可以看到,在讀通道上有個額外的線RACK,它的用途是,當從設備發送讀操作中的數據給主,它並不知道何時主能收到這個數據,因為我們說過插入寄存器會導致匯流排延遲變長。萬一這個時候,對同樣的地址A,它需要發送新的監聽請求給主,就會產生一個問題:主是不是已經收到前面發出的地址A的數據了呢?如果沒收到,那它可能會告知監聽未命中。但實際上地址A的數據已經發給主了,它該返回命中。加了這個RACK後,從設備在收到主給的確認RACK之前,不會發送新的監聽請求給主,從而避免了上述問題。寫通道上的WACK同樣如此。
我們之前計算過NIC400上的延遲,有了CCI400的硬體同步,是不是訪問更快了呢?首先,硬體一致性的設計目的不是為了更快,而是軟體更簡單。而實際上,它也未必就快。因為給定一個地址,我們並不知道它是不是在另一組處理器的緩存內,所以無論如何都需要額外的監聽動作。當未命中的時候,這個監聽動作就是多餘的,因為我們還是得從內存去抓數據。這個多餘的動作就意味著額外的延遲,10加10一共20個匯流排周期,增長了100%。當然,如果命中,雖然匯流排總共上也同樣需要10周期,可是從緩存拿數據比從內存拿快些,所以此時是有好處的。綜合起來看,當命中大於一定比例,總體還是受益的。
可從實際的應用程序情況來看,除了特殊設計的程序,通常命中不會大於10%。所以我們必須想一些辦法來提高性能。一個辦法就是,無論結果是命中還是未命中,都讓匯流排先去內存抓數據。等到數據抓回來,我們也已經知道監聽的結果,再決定把哪邊的數據送回去。這個辦法的缺點,功耗增大,因為無論如何都要去讀內存。第二,在內存訪問本身就很頻繁的時候,這麼做會降低總體性能。
另外一個方法就是,如果預先知道數據不在別的處理器組緩存,那就可以讓發出讀寫請求的主設備,特別註明不需要監聽,匯流排就不會去做這個動作。這個方法的缺點就是需要軟體干預,雖然代價並不大,分配操作系統頁面的時候設下寄存器就可以,可是對程序員的要求就高了,必須充分理解目標系統。
CCI匯流排還使用了一個新的方法來提高性能,那就是在匯流排里加入一個監聽過濾器(Snoop
Filter)。這其實也是一塊緩存(TAG RAM),把它所有處理器組內部一級二級緩存的狀態信息都放在裡面。數據緩存(DATA RAM)是不需要的,因為它只負責查看命中與否。這樣做的好處就是,監聽請求不必發到各組處理器,在匯流排內部就可以完成,省了將近10個匯流排周期,功耗也優於訪問內存。它的代價是增加了一點緩存(一二級緩存10%左右的容量)。並且,如果監聽過濾器里的某行緩存被替換(比如寫監聽命中,需要無效化(Invalidate)緩存行,MOESI協議定義),同樣的操作必須在對應處理器組的一二級緩存也做一遍,以保持一致性。這個過程被稱作反向無效化,它添加了額外的負擔,因為在更新一二級緩存的時候,監聽過濾器本身也需要追蹤更新的狀態,否則就無法保證一致性。幸好,在實際測試中發現,這樣的操作並不頻繁,一般不超過5%的可能性。當然,有些測試代碼會頻繁的觸發這個操作,此時監聽過濾器的缺點就顯出來了。以上的想法在CCI500中實現,示意圖如下:
在經過實際性能測試後,CCI設計人員發現匯流排瓶頸移到了訪問這個監聽過濾器的窗口,這個瓶頸其實掩蓋了上文的反向無效化問題,它總是先於反向無效化被發現。把這個窗口加大後,又在做測試時發現,如果每個主從介面都拚命灌數據(主從設備都是OT無限大,並且一主多從有前後交叉),在主從設備介面處經常出現等待的情況,也就是說,明明數據已經準備好了,設備卻來不及接收。於是,又增加了一些緩衝來存放這些數據。其代價是稍大的面積和功耗。請注意,這個緩衝和存放OT的狀態緩衝並不重複。
根據實測數據,在做完所有改進後,新的匯流排帶寬性能同頻增加50%以上。而頻率可以從500Mhz提高到1GMhz。當然這個結果只是一個模糊的統計,如果我們考慮處理器和內存控制器OT數量有限,被監聽數據的百分比有不同,命中率有變化,監聽過濾器大小有變化,那肯定會得到不同的結果。
作為一個手機晶元領域的匯流排,需要支持傳輸的多優先順序也就是QoS。因為顯示控制器等設備對實時性要求高,而處理器組的請求也很重要。支持QoS本身沒什麼困難,只需要把各類請求放在一個緩衝,根據優先順序傳送即可。但是在實際測試中,發現如果各個設備的請求太多太頻繁,緩衝很快就被填滿,從而阻塞了新的高優先順序請求。為了解決這個問題,又把緩衝按優先順序分組,每一組只接受同等或更高優先順序的請求,這樣就避免了阻塞。
此外,為了支持多時鐘和電源域,使得每一組處理器都可以動態調節電壓和時鐘頻率,CCI系列匯流排還可以搭配非同步橋ADB(Asynchronous Domain Bridge)。它對於性能有一定的影響,在倍頻是2的時候,信號穿過它需要一個額外的匯流排時鐘周期。如果是3,那更大些。在對於訪問延遲有嚴格要求的系統裡面,這個時間不可忽略。如果不需要額外的電源域,我們可以不用它,省一點延遲。NIC/CCI/CCN/NoC匯流排天然就支持非同步傳輸。
和一致性相關的是訪存次序和鎖,有些程序員把它們搞混了。假設我們有兩個核C0和C1。當C0和C1分別訪問同一地址A0,無論何時,都要保證看到的數據一致,這是一致性。然後在C0裡面,它需要保證先後訪問地址A0和A1,這稱作訪問次序,此時不需要鎖,只需要壁壘指令。如果C0和C1上同時運行兩個線程,當C0和C1分別訪問同一地址A0,並且需要保證C0和C1按照先後次序訪問A0,這就需要鎖。所以,單單壁壘指令只能保證單核單線程的次序,多核多線程的次序需要鎖。而一致性保證了在做鎖操作時,同一變數在緩存或者內存的不同拷貝,都是一致的。
ARM的壁壘指令分為強壁壘DSB和弱壁壘DMB。我們知道讀寫指令會被分成請求和完成兩部分,強壁壘要求上一條讀寫指令完成後才能開始下一個請求,弱壁壘則只要求上一條讀寫指令發出請求後就可以繼續下一條讀寫指令的請求,且只能保證,它之後的讀寫指令完成時,它之前的讀寫指令肯定已經完成了。顯然,後一種情況性能更高,OT>1。但測試表明,多個處理器組的情況下,壁壘指令如果傳輸到匯流排,只能另整體系統性能降低,因此在新的ARM匯流排中是不支持壁壘的,必須在晶元設計階段,通過配置選項告訴處理器自己處理壁壘指令,不要送到匯流排。但這並不影響程序中的壁壘指令,處理器會在匯流排之前把它過濾掉。
具體到CCI匯流排上,壁壘機制是怎麼實現的呢?首先,壁壘和讀寫一樣,也是使用讀寫通道的,只不過它地址總是0,且沒有數據。標誌符也是有的,此外還有額外的2根線BAR0/1,表明本次傳輸是不是壁壘,是哪種壁壘。他是怎麼傳輸的呢?先看弱壁壘,如下圖:
Master0寫了一個數據data,然後又發了弱壁壘請求。CCI和主設備介面的地方,一旦收到壁壘請求,立刻做兩件事,第一,給Master0發送壁壘響應;第二,把壁壘請求發到和從設備Slave0/1的介面。Slave1介面很快給了壁壘響應,因為它那裡沒有任何未完成傳輸。而Slave0介面不能給壁壘響應,因為data還沒發到從設備,在這條路徑上的壁壘請求必須等待,並且不能和data的寫請求交換次序。這並不能阻撓Master0發出第二個數據,因為它已經收到它的所有下級(Master0介面)的壁壘回應,所以它又寫出了flag。如下圖:
此時,flag在Master0介面中等待它的所有下一級介面的壁壘響應。而data達到了Slave0後,壁壘響應走到了Master0介面,flag繼續往下走。此時,我們不必擔心data沒有到slave0,因為那之前,來自Slave0介面的壁壘響應不會被送到Master0介面。這樣,就做到了弱壁壘的次序保證,並且在壁壘指令完成前,flag的請求就可以被送出來。
對於強壁壘指令來說,僅僅有一個區別,就是Master0介面在收到所有下一級介面的壁壘響應前,它不會發送自身的壁壘響應給Master0。這就造成flag發不出來,直到壁壘指令完成。如下圖:
這樣,就保證了強壁壘完成後,下一條讀寫指令才能發出請求。此時,強壁壘前的讀寫指令肯定是完成了的。
另外需要特別注意的是,ARM的弱壁壘只是針對顯式數據訪問的次序。什麼叫顯式數據訪問?讀寫指令,緩存,TLB操作都算。相對的,什麼是隱式數據訪問?在處理器那一節,我們提到,處理器會有推測執行,預先執行讀寫指令;緩存也有硬體預取機制,根據之前數據訪問的規律,自動抓取可能用到的緩存行。這些都不包含在當前指令中,弱壁壘對他們無能為力。因此,切記,弱壁壘只能保證你給出的指令次序,並不能保證在它們之間沒有別的模塊去訪問內存,哪怕這個模塊來自於同一個核。
簡單來說,如果只需要保證讀寫次序,用弱壁壘;如果需要某個讀寫指令完成才能做別的事情,用強壁壘。以上都是針對普通內存類型。當我們把類型設成設備時,自動保證強壁壘。
我們提到,壁壘只是針對單核。在多核多線程時,哪怕使用了壁壘指令,也沒法保證讀寫的原子性。解決辦法有兩個,一個是軟體鎖,一個是原子操作。AXI/ACE協議不支持原子操作。所以手機通常需要用到軟體鎖。
軟體鎖中有個自旋鎖,能用一個ARM硬體機制exclusive access來實現。當使用特殊指令對一個地址寫入值,相應緩存行上會做一個特殊標記,表示還沒有別的核去寫這行緩存。然後下條指令讀這個行,如果標記沒變,說明寫和讀之間沒有人打擾,那麼就拿到鎖了。如果變了,那麼回到寫的過程重新獲取鎖。由於緩存一致性,這個鎖變數可以被多個核與線程使用。當然,過程中還是需要壁壘指令來保證次序。
在支持ARMv8.2和AMBA 5.0 CHI介面的系統中,原子操作被重新引入。在硬體層面,其實原子操作非常容易理解,如果某個數據存在於自己的緩存,那就直接修改;如果存在於別人的緩存,那對所有其他緩存執行Eviction操作,踢出後,放到自己的緩存繼續操作。這個過程其實和exclusive access非常類似。
對於普通內存,還會產生一個問題,就是讀寫操作可能會經過緩存,你不知道數據是否最終寫到了內存中。通常我們使用clean操作來刷緩存。但是刷緩存本身是個模糊的概念,緩存存在多級,有些在處理器內,有些在匯流排之後,到底刷到哪裡算是終結呢?還有,為了保證一致性,刷的時候是不是需要通知別的處理器和緩存?為了把這些問題規範化,ARM引入了Point of Unification/Coherency,Inner/Outer Cacheable和System/Inner/Outer/Non Shareable的概念。
PoU是指,對於某一個核Master,附屬於它的指令,數據緩存和TLB,如果在某一點上,它們能看到一致的內容,那麼這個點就是PoU。如上圖右側,MasterB包含了指令,數據緩存和TLB,還有二級緩存。指令,數據緩存和TLB的數據交換都建立在二級緩存,此時二級緩存就成了PoU。而對於上圖左側的MasterA,由於沒有二級緩存,指令,數據緩存和TLB的數據交換都建立在內存上,所以內存成了PoU。還有一種情況,就是指令緩存可以去監聽數據緩存,此時,不需要二級緩存也能保持數據一致,那一級數據緩存就變成了PoU。
PoC是指,對於系統中所有Master(注意是所有的,而不是某個核),如果存在某個點,它們的指令,數據緩存和TLB能看到同一個源,那麼這個點就是PoC。如上圖右側,二級緩存此時不能作為PoC,因為MasterB在它的範圍之外,直接訪問內存。所以此時內存是PoC。在左圖,由於只有一個Master,所以內存是PoC。
再進一步,如果我們把右圖的內存換成三級緩存,把內存接在三級緩存後面,那PoC就變成了三級緩存。
有了這兩個定義,我們就可以指定TLB和緩存操作指令到底發到哪個範圍。比如在下圖的系統上,有兩組A15,每組四個核,組內含二級緩存。系統的PoC在內存,而A15的PoU分別在它們自己組內的二級緩存上。在某個A15上執行Clean清指令緩存,範圍指定PoU。顯然,所有四個A15的一級指令緩存都會被清掉。那麼其他的各個Master是不是受影響?那就要用到Inner/Outer/Non Shareable。
Shareable的很容易理解,就是某個地址的可能被別人使用。我們在定義某個頁屬性的時候會給出。Non-Shareable就是只有自己使用。當然,定義成Non-Shareable不表示別人不可以用。某個地址A如果在核1上映射成Shareable,核2映射成Non-Shareable,並且兩個核通過CCI400相連。那麼核1在訪問A的時候,匯流排會去監聽核2,而核2訪問A的時候,匯流排直接訪問內存,不監聽核1。顯然這種做法是錯誤的。
對於Inner和Outer Shareable,有個簡單的的理解,就是認為他們都是一個東西。在最近的ARM A系列處理器上上,配置處理器RTL的時候,會選擇是不是把inner的傳輸送到ACE口上。當存在多個處理器簇或者需要雙向一致性的GPU時,就需要設成送到ACE埠。這樣,內部的操作,無論inner shareable還是outer shareable,都會經由CCI廣播到別的ACE口上。
說了這麼多概念,你可能會想這有什麼用處?回到上文的Clean指令,PoU使得四個A7的指令緩存中對應的行都被清掉。由於是指令緩存操作,Inner Shareable屬性使得這個操作被擴散到匯流排。而CCI400匯流排會把這個操作廣播到所有可能接受的口上。ACE口首當其衝,所以四個A15也會清它們對應的指令緩存行。對於Mali和DMA控制器,他們是ACE-Lite,本不必清。但是請注意它們還連了DVM介面,專門負責收發緩存維護指令,所以它們的對應指令緩存行也會被清。不過事實上,它們沒有對應的指令緩存,所以只是接受請求,並沒有任何動作。
要這麼複雜的定義有什麼用?用處是,精確定義TLB/緩存維護和讀寫指令的範圍。如果我們改變一下,匯流排不支持Inner/Outer Shareable的廣播,那麼就只有A7處理器組會清緩存行。顯然這麼做在邏輯上不對,因為A7/A15可能運行同一行代碼。並且,我們之前提到過,如果把讀寫屬性設成Non-Shareable,那麼匯流排就不會去監聽其他主,減少訪問延遲,這樣可以非常靈活的提高性能。
再回到前面的問題,刷某行緩存的時候,怎麼知道數據是否最終寫到了內存中?對不起,非常抱歉,還是沒法知道。你只能做到把範圍設成PoC。如果PoC是三級緩存,那麼最終刷到三級緩存,如果是內存,那就刷到內存。不過這在邏輯上沒有錯,按照定義,所有Master如果都在三級緩存統一數據的話,那就不必刷到內存了。
簡而言之,PoU/PoC定義了指令和命令的所能抵達的緩存或內存,在到達了指定地點後,Inner/Outer Shareable定義了它們被廣播的範圍。
再來看看Inner/Outer Cacheable,這個就簡單了,僅僅是一個緩存的前後界定。一級緩存一定是Inner Cacheable的,而最外層的緩存,比如三級,可能是Outer Cacheable,也可能是Inner Cacheable。他們的用處在於,在定義內存頁屬性的時候,可以在不同層的緩存上有不同的處理策略。
在ARM的處理器和匯流排手冊中,還會出現幾個PoS(Point of Serialization)。它的意思是,在匯流排中,所有主設備來的各類請求,都必須由控制器檢查地址和類型,如果存在競爭,那就會進行串列化。這個概念和其他幾個沒什麼關係。
縱觀整個匯流排的變化,還有一個核心問題並沒有被提及,那就是動態規劃re-scheduling與合併Merging。處理器和內存控制器中都有同樣的模塊,專門負責把所有的傳輸進行分類,合併,調整次序,甚至預測未來可能接收到的讀寫請求地址,以實現最大效率的傳輸。這個問題在分析性能時會重新提到。但是在匯流排這層,軟體能起的影響很小。清楚了匯流排延遲和OT最大的好處是可以和性能計數器的統計結果精確匹配,看看是不是達到預期了。
現在手機和平板上最常見的用法,CCI連接CPU和GPU,作為子網,網內有硬體一致性。NoC連接子網,同時連接其餘的設備,包括多個內存控制器和視頻,顯示控制器,不需要一致性。優點是兼顧一致性,大帶寬和靈活性,缺點是CPU/GPU到內存控制器要跨過兩個網,延遲有點大。
訪存路徑的最後一步是內存。有的程序員認為內存是一個所有地址訪問時間相等的設備,是這樣的么?這要看情況。
DDR地址有三個部分組成,行,bank,列。一旦這三個部分定了,那麼就可以選中確定的一個物理頁,通常有2-8KB大小。我們買內存的時候,有3個性能參數,比如10-10-10。這個表示訪問一個地址所需要的三個操作時間,行有效(包括選bank),列選通(命令/數據訪問),還有預充電。前兩個好理解,第三個的意思是,某個內存物理頁暫時用不著,必須關閉,保持電容電壓,否則再次使用這頁數據就丟失了。如果連續的內存訪問都是在同行同bank,那麼第一和第三個10都可以省略,每一次訪問只需要10單位時間;同行不同bank,表示需要打開一個新的頁,只有第三個10可以省略,共20單位時間;不同行同bank,那麼需要關閉老頁面,打開一個新頁面,預充電沒法省,共30單位時間。
我們得到什麼結論?如果控制好物理地址,就能使某段時間內的訪存都集中在一個頁內,從而節省大量的時間。根據經驗,在突發訪問時,最多可以省50%。那怎麼做到這一點?去查查晶元手冊中物理內存地址到內存管腳的映射,就可以得到需要的物理地址。然後調用系統函數,為這個物理地址分配虛擬地址,就可以使得程序只訪問某個固定的物理內存頁。
在訪問有些數據結構時,特定的大小和偏移有可能會不小心觸發不同行同bank這個條件。 這樣可能每次訪問都是最差情況。 為了避免這種最差情況的產生,有些內存控制器可以自動讓最終地址哈希化,打亂原有的不同行同bank條件,從而在一定程度上減少延遲。我們也可以通過計算和調整軟體物理地址來避免上述情況的發生。
在實際的訪問中,通常無法保證訪問只在一個頁中。DDR內存支持同時打開多個頁,比如4個。而通過交替訪問,我們可以同時利用這4個頁,不必等到上一次完成就開始下一個頁的訪問。這樣就可以減少平均延遲。如下圖:
我們可以通過突發訪問,讓上圖中的綠色數據塊更長,那麼相應的利用率就越高。此時甚至不需要用到四個bank,如下圖:
如果做的更好些,我們可以通過軟體控制地址,讓上圖中的預充電,甚至行有效盡量減少,那麼就可以達到更高的效率。還有,使用更好的內存顆粒,調整配置參數,減少行有效,列選通,還有預充電的時間,提高DDR傳輸頻率,也是好辦法,這點PC機超頻玩家應該有體會。此外,在DDR板級布線的時候,控制每組時鐘,控制線,數據線之間的長度差,調整好走線阻抗,做好自校準,設置合理的內存控制器參數,調好眼圖,都有助於提高信號質量,從而可以使用更短的時序參數。
如果列出所有數據突髮長度情況,我們就得到了下圖:
上面這個圖包含了更直觀的信息。它模擬內存控制器連續不斷的向內存顆粒發起訪問。X軸表示在訪問某個內存物理頁的時候,連續地址的大小。這裡有個默認的前提,這塊地址是和內存物理頁對齊的。Y軸表示同時打開了多少個頁。Z軸表示內存控制器訪問內存顆粒時帶寬的利用率。我們可以看到,有三個波峰,其中一個在128位元組,利用率80%。而100%的情況下,訪問長度分別為192位元組和256位元組。這個大小恰恰是64位元組緩存行的整數倍,意味著我們可以利用三個或者四個8拍的突發訪問完成。此時,我們需要至少4個頁被打開。
還有一個重要的信息,就是X軸和Z軸的斜率。它對應了DDR時序參數中的tFAW,限定單位時間內同時進行的頁訪問數量。這個數字越小,性能可能越低,但是同樣的功耗就越低。
對於不同的DDR,上面的模型會不斷變化。而設計DDR控制器的目的,就是讓利用率盡量保持在100%。要做到這點,需要不斷的把收到的讀寫請求分類,合併,調整次序。而從軟體角度,產生更多的緩存行對齊的讀寫,保持地址連續,盡量命中已打開頁,減少行地址和bank地址切換,都是減少內存訪問延遲的方法。
交替訪問也能提高訪存性能。上文已經提到了物理頁的交替,還可以有片選信號的交替訪問。當有兩個內存控制器的時候,控制器之間還可以交替。無論哪種交替訪問,都是在前一個訪問完成前,同時開始下一個傳輸。當然,前提必須是他們使用的硬體不衝突。物理頁,片選,控制器符合這一個要求。交替訪問之後,原本連續分布在一個控制器的地址被分散到幾個不同的控制器。最終期望的效果如下圖:
這種方法對連續的地址訪問效果最好。但是實際的訪存並沒有上圖那麼理想,因為哪怕是連續的讀,由於緩存中存在替換eviction和硬體預取,最終送出的連續地址序列也會插入擾動,而如果取消緩存直接訪存,可能又沒法利用到硬體的預取機制和額外的OT資源。實測下來,可能會提升30%左右。此外,由於多個主設備的存在,每一個主都產生不同的連續地址,使得效果進一步降低。因此,只有採用交織訪問才能真正的實現均勻訪問多個內存控制器。當然,此時的突髮長度和粒度要匹配,不然粒度太大也沒法均勻,就算均勻了也未必是最優的。對於某個內存控制來說,最好的期望是總收到同一個物理頁內的請求。
還有一點需要提及。如果使用了帶ecc的內存,那麼最好所有的訪問都是ddr帶寬對齊(一般64位)。因為使能ecc後,所有內存訪問都是帶寬對齊的,不然ecc沒法算。如果你寫入小於帶寬的數據,內存控制器需要知道原來的數據是多少,於是就去讀,然後改動其中一部分,再計算新的ecc值,再寫入。這樣就多了一個讀的過程。根據經驗,如果訪存很多,關閉ecc會快8%。
下面是軟體層面可以使用的優化手段:
面向處理器結構的優化可以從以下幾個方向入手:緩存命中,指令預測,數據預取,數據對齊,內存拷貝優化,ddr訪問延遲,硬體內存管理優化,指令優化,編譯器優化等級以及性能描述工具。
緩存未命中是處理器的主要性能瓶頸之一。在FSL的powerpc上,訪問一級緩存是3個時鐘周期,二級是12個,3級30多個,內存100個以上。一級緩存和內存訪問速度差30多倍。我們可以算一下,如果只有一級緩存和內存,100條存取指令,100%命中和95%命中,前者300周期,後者95*3+5*100=785周期,差了1.6倍。這個結果的前提是powerpc上每個核心只有1個存取單元,使得多發射也無法讓存取指令更快完成。當然,如果未命中的指令分布的好,當中穿插了很多別的非存取指令那就可以利用亂序多做些事情,提高效率。
我們可以用指令預測和數據預取。
指令預測很常見,處理器預測將要執行的一個分支,把後續指令取出來先執行。等真正確定判斷條件的時候,如果預測對了,提交結果,如果不對,丟掉預先執行的結果,重新抓取指令。此時,結果還是正確的,但是性能會損失。指令預測是為了減少流水線空泡,不預測或者預測錯需要排空流水線並重新從正確指令地址取指令,這個代價(penalty)對流水線深度越深的處理器影響越大,嚴重影響處理器性能。
指令預測一般是有以下幾種辦法:分支預測器(branch predictor)+btb+ras(Return
Address Stack)+loop buffer。根據處理器類型和等級不同從以上幾種組合。btb的話主要是為了在指令解碼前就能預測一把指令跳轉地址,所以btb主要是針對跳轉地址固定的分支指令做優化(比如jump到一個固定地址),目的也是為了減少空泡。否則正常情況下即使預測一條分支跳轉,也要等到解碼後才能知道它是一條分支指令,進而根據branch predictor的預測結果發起預測的取指。而btb可以在解碼前就通過對比pc發起取指。這樣對每一條命中btb的分支指令一般可以省好幾個時鐘周期。大致方法是,對於跳轉指令,把它最近幾次的跳轉結果記錄下來,作為下一次此處程序分支預測的依據。舉個例子,for循環1000次,從第二次開始到999次,每次都預取前一次的跳轉地址,那麼預測準確率接近99.9%。這是好的情況。不好的情況,在for循環裡面,有個if(a[i])。假設這個a[i]是個0,1,0,1序列,這樣每次if的預測都會錯誤,預取效率就很低了。改進方法是,把if拆開成兩個,一個專門判斷奇數次a[i],一個判斷偶數次,整體循環次數減少一半,每次循環的判斷增加一倍,這樣每次都是正確的。如果這個序列的數字預先不可見,只能知道0多或者1多,那麼可以用c語言裡面的LIKELY/UNLIKELY修飾判斷條件,也能提高準確率。需要注意的是,btb表項是會用完的,也就是說,如果程序太久沒有走到上次的記錄點,那麼記錄就會被清掉,下次再跑到這就得重新記錄了。分支預測有個有趣的效應,如果一段代碼處於某個永遠不被觸發的判斷分支中,它仍然可能影響處理器的分支預測,從而影響總體性能。如果你刪掉它,說不定會發現程序奇蹟般的更快了。數據預取,和指令預測類似,也是處理器把可能會用到的數據先拿到緩存,之後就不必去讀內存了。它又分為軟體預取和硬體預取兩種,硬體的是處理器自己有個演算法去預測抓哪裡的數據,比如在訪問同一類型數據結構的某個元素,處理器會自動預取下一個偏移的數據。當然,具體演算法不會這麼簡單。軟體預取就是用編譯器的預編譯宏修飾某個將要用到的變數,生成相應指令,手工去內存抓某個程序員認為快要用到的數據。為什麼要提前?假設抓了之後,在真正用到數據前,有100條指令,就可以先執行那些指令,同時數據取到了緩存,省了不少時間。
需要注意的是,如果不是計算密集型的代碼,不會跑了100個周期才有下一條存取指令。更有可能10條指令就有一次訪存。如果全都未命中,那麼這個預取效果就會打不少折扣。並且,同時不宜預取過多數據,因為取進來的是一個緩存行,如果取得過多,會把本來有用的局部數據替換出去。按照經驗同時一般不要超過4條預取。此外,預取指令本身也要佔用指令周期,過多的話,會增加每次循環執行時間。要知道有時候1%的時間都是要省的。
在訪問指令或者數據的時候,有一個非常重要的事項,就是對齊。四位元組對齊還不夠,最好是緩存行對齊,一般是在做內存拷貝,DMA或者數據結構賦值的時候用到。處理器在讀取數據結構時,是以行為單位的,長度可以是32位元組或更大。如果數據結構能夠調整為緩存行對齊,那麼就可以用最少的次數讀取。在DMA的時候一般都以緩存行為單位。如果不對齊,就會多出一些傳輸,甚至出錯。還有,在SoC系統上,對有些設備模塊進行DMA時,如果不是緩存行對齊,那麼可能每32位元組都會被拆成2段分別做DMA,這個效率就要差了1倍了。
如果使用了帶ecc的內存,那麼更需要ddr帶寬對齊了。因為使能ecc後,所有內存訪問都是帶寬對齊的,不然ecc沒法算。如果你寫入小於帶寬的數據,內存控制器需要知道原來的數據是多少,於是就去讀,然後改動其中一部分,再計算新的ecc值,再寫入。這樣就多了一個讀的過程,慢不少。
還有一種需要對齊情況是數據結構賦值。假設有個32位元組的數據結構,裡面全是4位元組元素。正常初始化清零需要32/4=8次賦值。而有一些指令,可以直接把緩存行置全0或1。這樣時間就變成1/8了。更重要的是,寫緩存未命中實際上是需要先從內存讀取數據到緩存,然後再寫入。這就是說寫的未命中和讀未命中需要一樣的時間。而用了這個指令,可以讓存指令不再去讀內存,直接把全0/1寫入緩存。這在邏輯上是沒問題的,因為要寫入的數據(全0/1)已經明確,不需要去讀內存。以後如果這行被替換出去,那麼數據就寫回到內存。當然,這個指令的限制也很大,必須全緩存行替換,沒法單個位元組修改。這個過程其實就是優化後的memset()函數。如果調整下你的大數據結構,把同一時期需要清掉的元素都放一起,再用優化的memset(),效率會高很多。同理,在memcpy()函數裡面,由於存在讀取源地址和寫入目的地址,按上文所述,可能有兩個未命中,需要訪存兩次。現在我們可以先寫入一個緩存行(沒有寫未命中),然後再讀源地址,寫入目的地址,就變成了總共1個訪存操作。至於寫回數據那是處理器以後自己去做的事情,不用管。
標準的libc庫裡面的內存操作函數都可以用類似方法優化,而不僅僅是四位元組對齊。不過需要注意的是,如果給出的源和目的地址不是緩存行對齊的,那麼開頭和結尾的數據需要額外處理,不然整個行被替換了了,會影響到別的數據。此外,可以把預取也結合起來,把要用的頭尾東西先拿出來,再作一堆判斷邏輯,這樣又可以提高效率。不過如果先處理尾巴,那麼當內存重疊時,會發生源地址內容被改寫,也需要注意。如果一個項目的程序員約定下,都用緩存行對齊,那麼還能提高C庫的效率。
如果確定某些緩存行將來不會被用,可以用指令標記為無效,下次它們就會被優先替換,給別人留地。不過必須是整行替換。還有一點,可以利用一些64位浮點寄存器和指令來讀寫,這樣可以比32為通用寄存器快些。
再說說ddr訪問優化。通常軟體工程師認為內存是一個所有地址訪問時間相等的設備,是這樣的么?這要看情況。我們買內存的時候,有3個性能參數,比如10-10-10。這個表示訪問一個地址所需要的三個操作時間,行選通,數據延遲還有預充電。前兩個好理解,第三個的意思是,我這個頁或者單元下一次訪問不用了,必須關閉,保持電容電壓,否則再次使用這頁數據就丟失了。ddr地址有三個部分組成,列,行,頁。根據這個原理,如果連續的訪問都是在同行同頁,每一個只需要10單位時間;不同行同頁,20單位;同行不同頁,30單位。所以我們得到什麼結論?相鄰數據結構要放在一個頁,並且絕對避免出現同行不同頁。這個怎麼算?每個處理器都有手冊,去查查物理內存地址到內存管腳的映射,推導一下就行。此外,ddr還有突發模式,ddr3為例,64位帶寬的話,可以一個命令跟著8次讀,可以一下填滿一行64位元組的緩存行。而極端情況(同頁訪問)平均位元組訪問時間只有10/64,跟最差情況,30/64位元組差了3倍。當然,內存裡面的技巧還很多,比如故意哈希化地址來防止最差情況訪問,兩個內存控制器同時開工,並且地址交織來形成流水訪問,等等,都是優化的方法。不過通常我們跑的程序由於調度程序的存在,地址比較隨機不需要這麼優化,優化有時候反而有負面效應。另外提一句,如果所有數據只用一次,那麼瓶頸就變成了訪存帶寬,而不是緩存。所以顯卡不強調緩存大小。當然他也有寄存器文件,類似緩存,只不過沒那麼大。
每個現代處理器都有硬體內存管理單元,說穿了就兩個作用,提供虛地址到時地址映射和實地址到外圍模塊的映射。不用管它每個欄位的定義有多麼複雜,只要關心給出的虛地址最終變成什麼實地址就行。在此我想說,powerpc的內存管理模塊設計的真的是很簡潔明了,相比之下x86的實在是太羅嗦了,那麼多模式需要兼容。當然那也是沒辦法,通訊領域的處理器就不需要太多兼容性。通常我們能用到的內存管理優化是定義一個大的硬體頁表,把所有需要頻繁使用的地址都包含進去,這樣就不會有頁缺失,省了頁缺失異常調用和查頁表的時間。在特定場合可以提高不少效率。
這裡描述下最慢的內存訪問:L1/2/3緩存未命中->硬體頁表未命中->缺頁異常代碼不在緩存->讀取代碼->軟體頁表不在緩存->讀取軟體頁表->最終讀取。同時,如果每一步裡面訪問的數據是多核一致的,每次前端匯流排還要花十幾個周期通知每個核的緩存,看看是不是有臟數據。這樣一圈下來,幾千個時鐘周期是需要的。如果頻繁出現最慢的內存訪問,前面的優化是非常有用的,省了幾十倍的時間。具體的映射方法需要看處理器手冊,就不多說了。
指令優化,這個就多了,每個處理器都有一大堆。常見的有單指令多數據流,特定的運算指令化,分支指令間化,等等,需要看每家處理器的手冊,很詳細。我這有個數據,快速傅立葉變化,在powerpc上如果使用軟浮點,性能是1,那麼用了自帶的矢量運算協處理器(運算能力不強,是浮點器件的低成本替換模塊)後,gcc自動編譯,性能提高5倍。然後再手工寫彙編優化函數庫,大量使用矢量指令,又提高了14倍。70倍的提升足以顯示純指令優化的重要性。
GCC的優化等級有三四個,一般使用O2是一個較好的平衡。O3的話可能會打亂程序原有的順序,調試的時候很麻煩。可以看下GCC的幫助,裡面會對每一項優化作出解釋,這裡就不多說了。編譯的時候,可以都試試看,可能會有百分之幾的差別。
最後是性能描述工具。Linux下,用的最多的應該是KProfile/OProfile。它的原理是在固定時間打個點,看下程序跑到哪了,足夠長時間後告訴你統計結果。由此可以知道程序里那些函數是熱點,佔用了多少比例的執行時間,還能知道具體代碼的IPC是多少。IPC的意思是每周期多少條指令。在雙發射的powerpc上,理論上最多是2,實際上整體能達到1.1就很好了。太低的話需要找具體原因。而這點,靠Profile就不行了,它沒法精確統計緩存命中,指令周期數,分支預測命中率等等,並且精度不高,有時會產生誤導。這時候就需要使用處理器自帶的性能統計寄存器了。處理器手冊會詳細描述用法。有了這些數據,再不斷改進,比較結果,最終達到想要的效果。
很重要的一點,我們不能依靠工具來作為唯一的判別手段。很多時候,需要在更高一個或者幾個層次上優化。舉個例子,辛辛苦苦優化某個演算法,使得處理器的到最大利用,提高了20%性能,結果發現演算法本身複雜度太高了,改進下演算法,可能是幾倍的提升。還有,在優化之前,自己首先要對數據流要有清楚的認識,然後再用工具來印證這個認識。就像設計前端數字模塊,首先要在心裡有大致模型,再去用描述語言實現,而不是寫完代碼綜合下看看結果。
小節下,提高傳輸率的方法有:
緩存對齊,減少訪問次數 訪存次序重新調度,合併相近地址,提高效率 提高ddr頻率減小延遲 使用多控制器提高帶寬 使能ddr3的讀寫命令合併 使能突發模式,讓緩存行訪問一次完成 指令和數據預取,提高空閑時利用率 在內存帶ecc時,使用和內存位寬(比如64位)相同的指令寫,否則需要額外的一次讀操作 控制器交替訪問,比如訪問第一個64位數據放在第一個內存控制器,第二個放在第二個控制器,這樣就可以錯開。 物理地址哈希化,防止ddr反覆打開關閉過多bank。 還有個終極殺招,計算物理地址,把相關數據結構放在ddr同物理頁內,減少ddr傳輸3個關鍵步驟(行選擇,命令,預充電)中第1,3步出現的概率
推薦閱讀:
※MCU: ARM的調試架構
※為ARM Cortex-M系列晶元編寫Bootloader
※如何評價 ARM 的 big.LITTLE 大小核切換技術?
※為什麼高通、聯發科(或許應該問ARM)他們不能研發一款像蘋果A系列處理器那樣單核性能突出的處理器呢?