1 存儲訪問一致性問題介紹
當存儲系統中引入了cache和寫緩沖區(Write Buffer)時,同一地址單元的數據可能在系統中有多個副本,分別保存在cache、Write Buffer及主存中,如果系統采用了獨立的數據cache和指令cache,同一地址單元的數據還可能在數據cache和指令cache中 有不同的版本 。
位于不同物理位置的同一地址單元的數據可能會不同 ,使得數據讀操作可能得到的不是系統中“最新的數值”,這樣就帶來了存儲系統中數據的一致性問題。
- (1)cache(介于CPU和DDR) 解決CPU速度和內存速度的速度差異問題(CPU存取數據的速度非常快,而內存比較慢),內存中被CPU訪問最頻繁的數據和指令被復制到CPU中的緩存,CPU訪問數據直接去緩存中訪問就行了。
- (2)cache一致性 CPU的訪問數據與DDR的數據沒有同步。
- (3)CPU、Cache以及DDR之間訪問關系 Cache是一個介于CPU以及DDR(DRAM)之間的一個高速緩存(一般好像是SRAM),在處理器內部,讀寫速度較DDR高,但是低于CPU的速度。假如沒有Cache,直接訪問DDR,CPU的速度遠遠高于DDR,那么CPU就需要等待DDR的數據到來,才能做其他事情,就會造成CPU使用效率較低。使用Cache之后,提前將DDR的數據緩存到Cache中,如果恰好CPU訪問DDR的數據在Cache中有,那么CPU拿到數據的時間將更短,處理效率將大大增加。但是同時也會造成一致性問題,即CPU的訪問的數據(Cache)與DDR的數據沒有同步,造成執行錯誤。假設一種真實情況,CPU要訪問DDR中的一塊數據,那么這塊數據會放在Cache中,之后DMA控制器直接將外設的數據放在DDR中,更新了剛剛的那一塊CPU要訪問的數據,此時CPU要獲取數據進行處理,還是拿著Cache中未更新的數據(沒有立馬反映到DDR中),就會造成一致性問題。
- (4)解決Cache一致性問題 將Cache中的數據清空,或者將DDR與Cache的數據同步。(使用CacheFlush和Cache Invalidate操作,CacheFlush把Cache里的數據清空,將Cache內容推到DDR中;而Cache Invalidate表示當場宣布Cache內容無效,需要從DDR中重新加載數據,即把數據從DDR中拉到Cache中。)
其實涉及到了緩存,不只是邏輯上的存儲,在互聯網,分布式架構,在現在的大數據環境下,也會面臨數據一致性的問題。
怎么保持這個數據的一致性,不要讓彼此讀到了臟數據,是一個很重要的事情。
在ARM存儲系統中,數據不一致的問題則 需要通過程序設計時遵守一定的規則來保證 ,這些規則說明如下。
1.1 地址映射關系變化造成的數據不一致性
當系統中使用了MMU時,就建立了虛擬地址到物理地址的映射關系, 如果查詢cache時進行的相連比較使用的是虛擬地址 ,則當系統中虛擬地址到物理地址的映射關系發生變化時,可能造成cache中的數據和主存中數據不一致的情況。
(所以這個cache好使歸好使,但是插入了中間人,這個就會帶來一些風險與問題。)
讀
在虛擬地址到物理地址的映射關系發生變化前,如果虛擬地址A1所在的數據塊已經預取到cache中,當虛擬地址到物理地址的映射關系發生變化后,如果虛擬地址A1對應的物理地址發生了改變,則當CPU訪問A1時再使用cache中的數據塊將得到錯誤的結果。
這就很嚇人呢,你本來想看一個正常的日本動作電影,結果訪問到了錯誤的地址,運氣好點你能訪問你有權限的內存,運氣不好這個動作電影還帶了愛情,瑟瑟發抖。
寫
同樣當系統中采用了Write Buffer時,如果CPU寫入Write Buffer的地址是虛擬地址,也會發生數據不一致的情況。
在虛擬地址到物理地址的映射關系發生變化前,如果CPU向虛擬地址為A1的單元執行寫操作,該寫操作已經將A1以及對應的數據寫入到Write Buffer中,當虛擬地址到物理地址的映射關系發生變化后,如果虛擬地址A1對應的物理地址發生了改變,當Write Buffer將上面被延遲的寫操作寫到主存中時,使用的是變化后的物理地址,從而使寫操作失敗。
這個歸根結底,關鍵點就在于cache里面的數據地址是不是最新的,怎么保證是最新的,未被修改的。(閱讀這個部分的時候腦子里一定要有SMP架構,不然要是都是一個人的,那有啥不一致的呢)
為了避免發生這種數據不一致的情況, 在系統中虛擬地址到物理地址的映射關系發生變化前 ,根據系統的具體情況,執行下面操作序列中的一種或幾種:
- 1)如果數據cache為write back類型,清空該數據的cache;
- 2)使數據cache中相應的塊無效;
- 3)使指令cache中相應的塊無效;
- 4)將Write Buffer中被延遲的寫操作全部執行;
- 5)有些情況可能還要求相關的存儲區域被設置成非緩沖的。
一、CPU向cache寫入數據時的操作,兩者的區別
1、Write-through:CPU向cache寫入數據時,同時向memory(后端存儲)也寫一份,使cache和memory的數據保持一致。
2、Write-back:cpu更新cache時,只是把更新的cache區標記一下,并不同步更新memory(后端存儲)。只是在cache區要被新進入的數據取代時,才更新memory(后端存儲)。
二、兩者相比較優勢
1、Write-through:優點是簡單
2、Write-back:優點是CPU執行的效率提高
三、兩者相比較劣勢
1、Write-through:缺點是每次都要訪問memory,速度比較慢。
2、Write-back:缺點是實現起來技術比較復雜。兩者區別形象比喻:Write-through與Write-back和買賣東西相似,Write-Through就相當于你親自去買東西,你買到什么就可以親手拿到;而Write-Back就和中介差不多,你給了中介錢,然后它告訴你說你的東西買到了,然后就相信拿到這個東西了,但是要是出現特殊情況中介跑了,你再去檢查,東西原來沒有真正到手。
1.2 指令cache的數據不一致性問題
由于程序的運行而言,指令流的都流過icache,而指令中涉及到的數據流經過dcache。
所以對于自修改的代碼(Self-Modifying Code)而言,比如我們修改了內存p這個位置的代碼(典型多見于JIT compiler),這個時候我們是通過store的方式去寫的p,所以新的指令會進入dcache。
但是我們接下來去執行p位置的指令的時候,icache里面可能命中的是修改之前的指令。
相當于這個就是P位置得這個要執行得指令,本質上也是數據,我把這個指令當數據得時候,我去修改以及整理得時候走的是dcache,但是我操作得時候去讀得是icache。
當系統中采用獨立的數據cache和指令cache時,下面的操作序列可能造成指令不一致的情況:
- 1)讀取地址為A1的指令,從而包含該指令的數據塊被預取到指令cache中。
- 2)與A1在同一個數據塊中的地址為A2的存儲單元的數據被修改,這個數據寫操作可能影響數據cache、Write Buffer和主存中地址為A2的存儲單元的內容,但是不影響指令cache中地址為A2的存儲單元的內容。
- 3)如果地址A2存放的是指令,當該指令執行時,就可能發生指令不一致的問題。如果地址A2所在的塊還在指令cache中,系統將執行修改前的指令。如果地址A2所在的塊不在指令cache中,系統將執行修改后的指令。
為了避免這種指令不一致情況的發生,在上面第1)步和第2)步之間插入下面的操作序列:
- 1)對于使用統一的數據cache和指令cache的系統,不需要任何操作;
- 2)對于使用獨立的數據cache和指令cache的系統,使指令cache的內容無效;
- 3)對于使用獨立的數據cache和指令cache的系統,如果數據cache是write back類型的,清空數據cache。
當數據操作修改了指令時,最好執行上述操作序列,保證指令的一致性。下面是上述操作序列的一個典型應用場合。當可執行文件加載到主存中后,在程序跳轉到入口點處開始執行之前,先執行上述的操作序列,以保證下面指令的是新加載的可執行代碼,而不是指令中原來的舊代碼。
所以這個時候軟件需要把dcache的東西clean出去,然后讓icache invalidate,這個開銷顯然還是比較大的。但是,比如ARM64的N1處理器,它支持硬件的icache同步,詳見文檔:The Arm Neoverse N1 Platform: Building Blocks for the Next-Gen Cloud-to-Edge Infrastructure SoC
特別注意畫紅色的幾行。軟件維護的成本實際很高,還涉及到icache的invalidation向所有核廣播的動作。接下來的一個問題就是多個核之間的cache同步。下面是一個簡化版的處理器,CPU_A和B共享了一個L3,CPU_C和CPU_D共享了一個L3。實際的硬件架構由于涉及到NUMA,會比這個更加復雜,但是這個圖反映層級關系是足夠了。
比如CPU_A讀了一個地址p的變量?CPU_B、C、D又讀,難道B,C,D又必須從RAM里面經過L3,L2,L1再讀一遍嗎?這個顯然是沒有必要的,在硬件上,cache的snooping控制單元,可以協助直接把CPU_A的p地址cache拷貝到CPU_B、C和D的cache。
snooping控制單元,看到這個單詞想到了什么?是的,咱們的狗狗。嗅探檢測到改變。
這樣A-B-C-D都得到了相同的p地址的棕色小球。假設CPU B這個時候,把棕色小球寫成紅色,而其他CPU里面還是棕色,這樣就會不一致了:
這個時候怎么辦?這里面顯然需要一個協議,典型的多核cache同步協議有MESI和MOESI。
MOESI相對MESI有些細微的差異,不影響對全局的理解。下面我們重點看MESI協議。MESI協議定義了4種狀態:
- M(Modified): 當前cache的內容有效,數據已被修改而且與內存中的數據不一致, 數據只在當前cache里存在 ;類似RAM里面是棕色球,B里面是紅色球 (CACHE與RAM不一致 ),A、C、D都沒有球。
- E(Exclusive):當前cache的內容有效,數據與內存中的數據一致, 數據只在當前cache里存在 ;類似RAM里面是棕色球,B里面是棕色球(RAM和CACHE一致),A、C、D都沒有球。
- S(Shared):當前cache的內容有效,數據與內存中的數據一致,數據在多個cache里存在。類似如下圖,在CPU A-B-C里面cache的棕色球都與RAM一致。
- I(Invalid):當前cache無效。前面三幅圖里面cache沒有球的那些都是屬于這個情況。然后它有個狀態機
這個狀態機比較難記,死記硬背是記不住的,也沒必要記,它講的cache原先的狀態,經過一個硬件在本cache或者其他cache的讀寫操作后,各個cache的狀態會如何變遷。
所以,硬件上不僅僅是監控本CPU的cache讀寫行為,還會監控其他CPU的。
只需要記住一點:這個狀態機是為了保證多核之間cache的一致性,比如一個干凈的數據,可以在多個CPU的cache share,這個沒有一致性問題;但是,假設其中一個CPU寫過了,比如A-B-C本來是這樣:
然后B被寫過了:
這樣A、C的cache實際是過時的數據,這是不允許的。 這個時候,硬件會自動把A、C的cache invalidate掉 ,不需要軟件的干預,A、C其實變地相當于不命中這個球了:
- Cache Invalidate 該操作主要為解除內存與Cache的綁定關系。例如操作DMA進行數據搬移時,如果目標內存配置為可Cache,那么后續通過CPU讀取該內存數據時候,若Cache命中,則可能讀取到的數據不是DMA搬移后的數據,那么在進行DMA搬移之前,先進行Cache Invalidate操作,保證后續CPU讀取到的數據是DMA真正搬移的數據。
實際案例:軟件處理的數據異常,與期望結果不一致,通過抓取DMA搬移的源數據,與后續CPU數據進行比較,發現部分數據相同,部分數據不一致,后續確認為內存地址配置成了可Cache,導致CPU讀取進行處理的軟件數據異常。
- Cache Flush 該操作為將Cache中的數據寫回內存。
這個時候,你可能會繼續問,如果C要讀這個球呢?它目前的狀態在B里面是modified的,而且與RAM不一致,這個時候,硬件會把紅球clean,然后B、C、RAM變地一致,B、C的狀態都變化為S(Shared):
這一系列的動作雖然由硬件完成,但是對軟件而言不是免費的,因為它耗費了時間。如果編程的時候不注意,引起了硬件的大量cache同步行為,則程序的效率可能會急劇下降。
所以了解知道硬件的行為,寫出來的代碼才會更加的效率提升!!!
都到這里,不得不說前輩整的這個圖真的是非常非常NICE,感激這些前輩的分享。都到這里了,不一起來學習一個例子的話,就很不合適了。
下面我們寫一個程序,這個程序有2個線程,一個寫變量,一個讀變量:
這個程序里,x和y都是cacheline對齊的,這個程序的thread1的寫,會不停地與thread2的讀,進行cache同步。它的執行時間為:
$ time ./a.out
real 0m3.614s
user 0m7.021s
sys 0m0.004s
它在2個CPU上的userspace共運行了7.021秒,累計這個程序從開始到結束的對應真實世界的時間是3.614秒(就是從命令開始到命令結束的時間)。如果我們把程序改一句話,把thread2里面的c = x改為c = y,這樣2個線程在2個CPU運行的時候,讀寫的是不同的cacheline,就沒有這個硬件的cache同步開銷了:
它的運行時間:
$ time ./b.out
real 0m1.820s
user 0m3.606s
sys 0m0.008s
現在只需要1.8秒,幾乎減小了一半。感覺前面那個a.out,雙核的幫助甚至都不大。如果我們改為單核跑呢?
$ time taskset -c 0 ./a.out
real 0m3.299s
user 0m3.297s
sys 0m0.000s
它單核跑,居然只需要3.299秒跑完,而雙核跑,需要3.614s跑完。單核跑完這個程序,甚至比雙核還快,有沒有驚掉下巴?!!!因為單核里面沒有cache同步的開銷。下一個cache同步的重大問題,就是設備與CPU之間。
如果設備感知不到CPU的cache的話(下圖中的紅色數據流向不經過cache),這樣,做DMA前后,CPU就需要進行相關的cacheclean和invalidate的動作,軟件的開銷會比較大。
這些軟件的動作,若我們在Linux編程的時候,使用的是streaming DMA APIs的話,都會被類似這樣的API自動搞定:
dma_map_single()
dma_unmap_single()
dma_sync_single_for_cpu()
dma_sync_single_for_device()
dma_sync_sg_for_cpu()
dma_sync_sg_for_device()
如果是使用的dma_alloc_coherent() API呢,則設備和CPU之間的buffer是cache一致的,不需要每次DMA進行同步。
對于不支持硬件cache一致性的設備而言,很可能dma_alloc_coherent()會把CPU對那段DMA buffer的訪問設置為uncachable的。
這些API把底層的硬件差異封裝掉了,如果硬件不支持CPU和設備的cache同步的話,延時還是比較大的。
那么,對于底層硬件而言,更好的實現方式,應該仍然是硬件幫我們來搞定。比如我們需要修改總線協議,延伸紅線的觸角:
當設備訪問RAM的時候,可以去snoop CPU的cache:
- 如果做內存到外設的DMA,則直接從CPU的cache取modified的數據;
- 如果做外設到內存的DMA,則直接把CPU的cache invalidate掉。
這樣,就實現硬件意義上的cache同步。當然,硬件的cache同步,還有一些其他方法,原理上是類似的。
注意,這種同步仍然不是免費的,它仍然會消耗bus cycles的。實際上,cache的同步開銷還與距離相關,可以說距離越遠,同步開銷越大,比如下圖中A、B的同步開銷比A、C小。
對于一個NUMA服務器而言,跨NUMA的cache同步開銷顯然是要比NUMA內的同步開銷大。意識到CACHE的編程通過上一節的代碼,讀者應該意識到了cache的問題不處理好,程序的運行性能會急劇下降。
所以意識到cache的編程,對程序員是至關重要的。
從CPU流水線的角度講,任何的內存訪問延遲都可以簡化為如下公式:
Average Access Latency = Hit Time + Miss Rate × Miss
Penaltycache miss會導致CPU的stall狀態,從而影響性能。現代CPU的微架構分了frontend和backend。
- frontend負責fetch指令給backend執行,
- backend執行依賴運算能力和Memory子系統(包括cache)延遲。
backend執行中訪問數據導致的cache miss會導致backend stall,從而降低IPC(instructions per cycle)。
減小cache的miss,實際上是一個軟硬件協同設計的任務。
比如硬件方面,它支持預取prefetch,通過分析cache miss的pattern,硬件可以提前預取數據,在流水線需要某個數據前,提前先取到cache,從而CPU流水線跑到需要它的時候,不再miss。
當然,硬件不一定有那么聰明,也許它可以學會一些簡單的pattern。但是,對于復雜的無規律的數據,則可能需要軟件通過預取指令,來暗示CPU進行預取。
cache這個學問真的就很大了,比如MESI協議這些等等。后續好好整一個系列,學習一下。
1.3 DMA造成的數據不一致問題
DMA操作直接訪問主存,而不會更新cache和Write Buffer中相應的內容,這樣就可能造成數據的不一致。
如果DMA從主存中讀取的數據已經包含在cache中,而且cache中對應的數據已經被更新,這樣DMA讀到的將不是系統中最新的數據。同樣DMA寫操作直接更新主存中的數據,如果該數據已經包含在cache中,則cache中的數據將會比主存中對應的數據“老”,也將造成數據的不一致。
為了避免這種數據不一致情況的發生,根據系統的具體情況,執行下面操作序列中的一種或幾種:
- 1)將DMA訪問的存儲區域設置成非緩沖的,即uncachable及unbufferable;
- 2)將DMA訪問的存儲區域所涉及數據cache中的塊設置成無效,或者清空數據cache;
- 3)清空Write Buffer(執行Write Buffer中延遲的所有寫操作);
- 4)在DMA操作期間限制處理器訪問DMA所訪問的存儲區域。
1.4 指令預取和自修改代碼
在ARM中允許指令預取,在CPU執行當前指令的同時,可以從存儲器中預取其后若干條指令,具體預取多少條指令,不同的ARM實現中有不同的數值。
當用戶讀取PC寄存器的值時,返回的是當前指令下面第2條指令的地址。比如當前執行的是第N條指令,當用戶讀取PC寄存器的值時,返回的是指令N+2的地址。對于ARM指令來說,讀取PC寄存器的值時,返回當前指令地址值加8個字節;對于Thumb指令來說,讀取PC寄存器的值時,返回當前指令地址值加4個字節。
2 Linux中解決存儲訪問一致性問題的方法
在Linux中,是用barrier()宏來解決以上存儲訪問一致性問題的,barrier()的定義如下所示:
#define barrier() __asm__ __volatile__("": : :"memory")
另外在barrier()的基礎上還衍生出了很多類似的定義,如:
#define mb() __asm__ __volatile__ ("" : : : "memory")
#define rmb() mb()
#define wmb() mb()
#define smp_mb() barrier()
#define smp_rmb() barrier()
#define smp_wmb() barrier()
barrier是內存屏障的意思,CPU越過內存屏障后,將刷新自己對存儲器的緩沖狀態 。barrier()宏定義這條語句實際上不生成任何代碼, 但可使gcc在barrier()之后刷新寄存器對變量的分配。 具體分析如下。
概括起來說barrier()起到兩個作用:
- 1)告訴編譯器不要優化這部分代碼,保持原有的指令執行順序;
- 2)告訴CPU執行完barrier()之后要進行同步操作,更新registers、cache、寫緩存和內存中的內容,全部重新從內存中取數據。
評論
查看更多