在多處理器的機器上,每個 CPU 都有自己的 cache,以彌補 CPU 和主記憶體(Main memory) 之間較慢的存取速度,當一個特定資料首次被特定的 CPU 讀取時,此資料顯然不存在於該 CPU 的 cache 中 (即 cache miss),意味著 CPU 需要自主記憶體中讀取資料 (為此,CPU 通常要等待數百個 cycle),此資料最終將會存放於 CPU 的 cache 中,這樣後續就能直接自 cache 上快速存取,不需要再透過主記憶體。因為每個 CPU 上的 cache 資料是 private 的,因此在進行寫入操作時,會造成其他 CPU 上的 cache 資料不一致的問題。
e.g.
0xA
,資料內容為 20xA
,更新資料在自己的 cache 上,並在稍後補回 memory,而更新內容為 3。0xA
上所存的資料是 2,但顯然這份資料已經過時。為了確保所有 CPU 上的 cache 資料是一致的,當某個 CPU 進行寫入操作時,它必須保證其他 CPU 已將資料從他的 cache 中移除 (方可確保一致性),僅在移除操作完成後,此 CPU 才能安全的修改資料。專門處理一致性協定統稱為 cache-coherence protocol。透過 cache-coherence protocol,任何一個 CPU 要寫入資料前,都要先確保其他 CPU 已 invalid 同一位置的 cache 後 (希望寫入的 CPU 廣播 invalidate,其它 CPU 回 invalidate ack), 才能寫資料到自己的 cache,並在稍後補寫回 memory。
在介紹 cache-coherence protocol 前,先講解不同 CPU 間是如何進行溝通的。CPU 之間的溝通主要是透過 system bus,system bus 主要是用來連接 CPU、主記憶體和其他 I/O 設備,因為 CPU 中的 cache 進行 cache coherency 時,也是透過 system bus 來廣播 Probe Read 以及 Probe Write 請求,其他 CPU 也是透過 system bus 進行相對應的回應。因此,我們也稱 system bus 為 Cache Coherent Interconnect。
這邊介紹兩種 cache-coherence protocol:
在實作這兩種 cache-coherence protocol,會先定義一系列請求,這些請求有 CPU 對於自己的 cache 的請求,也有 CPU 對於其他 CPU 的 cache 的請求。定義如下:
MESI protocol 就是為每一個 cacheline 標上狀態,狀態分為 MESI 三種:
實作上,用 3 個 bit 來表示該 cacheline 的狀態,分別是 valid bit、dirty bit 和 shared bit,由這三個 bit 來組合出 MESI
4 個不同的狀態:
Valid bit | Dirty bit | Shared bit | |
---|---|---|---|
Modified | 1 | 1 | 0 |
Exclusive | 1 | 0 | 0 |
Shared | 1 | 0 | 1 |
Invalid | 0 | X | X |
X = doesn’t matter
我們舉個例子來說明 MESI protocol 的流程:
Exclusive
Shared
Modified
,他必須發 Probe Write 給 Processor 1,去看 Processor 1 的 cache 中是否含有 Block 0。接著 Processor 1 會收到 Probe write hit,因為其 cache 中有 Block 0 的資料。此時,Processor 1 含有 Block 0 的 cacheline 也會被設定成 Invalid
,因為其資料已過時。Modified
,當收到 Probe read hit 時,會將這份資料寫回主記憶體中,並將狀態改為 Shared
,接著 Processor 1 從 Processor 0 讀取資料,並將其 cacheline 狀態設定成 Shared
MESI protocol 的缺點就是每次發送 Probe read 讀到一個狀態為 Modified
的 cacheline 時,就要將資料更新回主記憶體,但將資料寫回主記憶體是非常浪費時間的。
為了避免將資料寫回主記憶體,MOESI protocol 加入了一個新的狀態 Owner,並稍微修改 Shared 狀態的定義:
實作上,用 3 個 bit 來表示該 cacheline 的狀態,分別是 valid bit、dirty bit 和 shared bit,由這三個 bit 來組合出 MOESI
5 個不同的狀態:
Valid bit | Dirty bit | Shared bit | |
---|---|---|---|
Modified | 1 | 1 | 0 |
Owner | 1 | 1 | 1 |
Exclusive | 1 | 0 | 0 |
Shared | 1 | 0 | 1 |
Invalid | 0 | X | X |
X = doesn’t matter
我們一樣舉個例子來說明 MOESI protocol 的流程:
Exclusive
Modified
,他必須發 Probe Write 給 Processor 1,但因為 Processor 1 的 cache 中有是空的,所以沒有任何改變。Shared
,而 Processor 0 上的 cacheline 狀態則會從 Modified
變為 Owner
。Modified
被設定成 Owner
,他必須發 Probe Write 給 Processor 1,去看 Processor 1 的 cache 中是否含有 Block 0。接著 Processor 1 會收到 Probe write hit,因為其 cache 中有 Block 0 的資料。此時,Processor 1 含有 Block 0 的 cacheline 也會被設定成 Invalid
,因為其資料已過時。Shared
,而 Processor 0 上的 cacheline 狀態則會從 Modified
變為 Owner
。透過上述案例我們可以發現,整個過程中並沒有涉及到任何將 cache 中資料更新回主記憶體的動作,因此,MOESI protocol 成功解決要將資料寫回主記憶體的問題。
上面有提到,一個 CPU 要寫入資料前,必須發送 Probe write 去確保其他 CPU 已 invalid 同一位置的 cache,等到其他 CPU 回應 invalidate ack 後,才能寫資料到自己的 cache 中。
由上圖可知,這個設計確保資料的一致性,但是代價是寫入 cache 的速度較慢,因為某一 CPU 在等待其他 CPU 回應 invalidate ack 前必須閒置。為了縮短 CPU 等待 invalidate ack 的閒置時間,store buffer 的設計被引入,其實作如下:
第一個實作機制稱為 store buffer forwarding,在更新資料時,CPU 會先將更新後的資料置於 store buffer 中,由於訊號發送是非同步,cache 和 store buffer 內部資料可能不一致,最新版本的資料會存在 store buffer 中,於是在讀取時的先後順序為 store buffer -> cache -> memory。
第二個實作機制稱為 invalidate queue,由於 store buffer size 很小,快速的更新資料會導致 store buffer 很快就被填滿。此時,CPU 依然要等待其他 CPU 回應 invalidate ack 才能繼續更新資料。另外,其他 CPU 在收到 invalidate 請求時,他可能仍然在處理其他的事情,無法立即處理 invalidate 請求及回應 invalidate ack,這樣的情況導致想進行寫入的 CPU 進入閒置狀態並等待其他 CPU 回應 invalidate ack。為了解決這個問題,invalidate queue 被引入,CPU 收到 invalidate 請求時,會先記錄到 invalidate queue 裡,先回 invalidate ack,稍後再處理 invalidate。
store buffer forwarding 和 invalidate queue 機制雖然縮短了 CPU 閒置的時間,但在某些情況下會產生 memory ordering 的問題,以下會利用實際案例進行說明。
我們的測試程式碼如下,初始情況假定 a 和 b 都為 0,且 a 在 CPU 1 的 cacheline 中,而 b 在 CPU 0 的 cache line 中,CPU 0 執行 foo(),CPU 1 執行 bar()。
假設程式執行順序如下:
Exclusive
,故可以直接將值 1 寫入 cacheline,cacheline 狀態變更為 Modified
,並發送 Probe Write (要注意這邊還沒有處理其他 CPU 回應的 invalidate ack 和 CPU1 對變數 b 的 Probe Read 請求)Shared
Invalid
並回應 invalidate ack 給 CPU 0如果程式按照上述的執行順序,那 CPU 0 看到的執行順序是先做 a = 1 再做 b = 1,但 CPU 1 看到的則是先做 b = 1 再做 a = 1,因為 CPU 1 先處理自己發出去的 Probe Read 的回應(b = 1)才處理 CPU 0 發過來的 Probe Write 請求(a = 1),對 CPU 1 來說,CPU 0 發生 I/O reordering。
store buffer forwarding 這個機制的問題在於他只保證某一 CPU 自身看到的執行順序,例如 CPU 0 看到的執行順序是對的,但對於其他 CPU 看到的執行順序則不保證。
這個問題的解決方式就是引入 memory barrier,測試程式碼如下,在 memory barrier 之後執行的寫入操作,必須先把 store buffer 的內容處理完成。對於 memory barrier,CPU可以有兩種策略:
這邊的案例使用第二種策略,程式執行順序如下:
memory_barrier
,將目前在 store buffer 中的 entry 做標記Exclusive
,但 store buffer 中有被標記的 entry,所以不能直接將值 1 寫入 cacheline,故先將 b = 1 寫入 store buffer 但不作任何標記Shared
,但注意這邊 b 的值是 0,因為新的版本來在 store buffer 中,還沒寫到 cacheInvalid
並回應 invalidate ack 給 CPU 0Modified
Shared
(CPU 1 的 cache 中也有一份),所以在寫入前要發送 Probe WriteInvalid
並回應 invalidate ack 給 CPU 0Modified
Shared
Invalid
了,所以 CPU 1 會發送 Probe ReadShared
若 CPU 需要對其他 CPU 發送 Probe Write 前,必須要先檢查 invalidate queue 中有無該 cacheline 的 invalidate 請求,若有,則需要先行處理。若不先處理invalidate queue 中對應的 Invalidate 消息直接對其他 CPU 發送 Probe Write,那麼,這次的寫入操作就會直接被送到 store buffer 中,後續 CPU 再進行處理時,store buffer 有寫該 cacheline 的請求,而 invalidate queue 有讓該 cacheline 狀態改為 Invlaid
的請求,那到底該以哪個為準 ? 若都以 store buffer 為準,假如 invalidate queue 中的請求是後於 store buffer 產生的,表示其他 CPU 後續又對該cacheline 進行了寫入,但此時 store buffer 中的舊值會被寫到 cacheline 且標示 Modified
,這顯然是錯的。反之,以 invalidate queue 為準,那 store buffer 的請求後於 invalidate queue,那一樣也會產生錯誤。
舉個例子,測試程式碼如下,初始情況假定 a 和 b 都為 0,且 a 同時在 CPU 0 和 CPU 1 的cacheline 中,b 只在 CPU 0 的 cacheline 中。由此,a 對應的 cacheline 狀態為Shared
,b 對應的 cacheline 狀態為 Exclusive
,CPU 0 執行 foo(),CPU 1 執行 bar()。
假設程式執行順序如下:
Shared
,便發送 Probe Write 給其他 CPU,同時將 a 的值 1 寫入store buffermemory_barrier
,將目前在 store buffer 中的 entry 做標記Modified
Exclusive
,故可以直接將值 1 寫入 cacheline,將cacheline 狀態變更為 Modified
Invalid
Invalid
了,等同於 a 不在 cache 中,所以 CPU 1 會發送 Probe Read從上述執行結果來看,引入了 invalidate queue 確實加速了 CPU 0 的執行,但即使加上了 memory barrier
,對 CPU 1 來說,CPU 0 仍然發生了 I/O reordering。原因在於兩者對於 invalidate cacheline 的理解不一致,CPU 0 認為既然發出了 invalidate acknowledge,你的 cache 中就不會有 a 對應的 cacheline 了,因為那條 cacheline 被設為了 Invlaid
,而 CPU 1 則認為將 invalidate 消息放入 invalidate queue,算是已經讓 a 的 cacheline 失效了。
為了讓這兩個 CPU 達成共識,我們仍然是透過 memory barrier,memory barrier 會要求該 CPU 必須先處理完在 invalidate queue 中所有的請求,修改後程式碼如下:
加入 memory barrier 後程式執行順序如下:
Shared
,便發送 Probe Write 給其他 CPU,同時將 a 的值 1 寫入store buffermemory_barrier
,將目前在 store buffer 中的 entry 做標記Modified
Exclusive
,故可以直接將值 1 寫入 cacheline,將 cacheline 狀態變更為 Modified
memory_barrier
,memory_barrier
要求 CPU 1 必須先處理完 invalidate queue 中的請求,於是 Probe Write 的請求是關於 a 的,故將 a 對應的 cacheline 設為 Invalid
Invalid
,於是發出 Probe ReadShared
,將 a 的資料送給 CPU 1從上述執行結果來看,memory_barrier
再次保證了並行多處理器程式的執行達到預期結果。當然,使用 memory_barrier
後執行成本增加了,因為必須先處理 store buffer 或是 invalidate queue,而且以上問題也是在特定的執行順序下才會發生。不過,當執行次數夠多時,上述情形必然會發生,而且資料的正確才是我們最在乎的。
我們為了約束與 store buffer 和 invalidate queue,用到了memory_barrier
。也就是說,CPU 執行指令中如果遇到了 memory_barrier
,則需要處理 store buffer 和 invalidate queue。但其實從程式碼來看,foo( )
中的 memory_barrier
其實只需要處理 store buffer 的部分,而 bar( )
中的則只需要處理 invalidate queue 。有些 CPU 提供了更為細分的 memory_barrier
,細分為 read memory barrier
和 write memory barrier
,前者只會處理 invalidate queue,而後者只會處理 store buffer。顯然,只處理其中之一肯定比同時處理二者效率要高,不過約束就會更少,可能的行為也就越多。
參考 I/O ordering 學習紀錄 中的實驗,我們利用 litmus7 在硬體架構為 arm64
得機器上進進行實驗,實驗程式碼如下:
該實驗程式碼是 litmus7 的語法,首先宣告兩個共享變數 x, y,且初始化為 0,接著,CPU 0 所做的事情是寫入 a = 1 和讀取 b,而 CPU 1 所做的事情是寫入 b = 1 和讀取 a,兩個 CPU 都是做一個 store 指令接著一個 load 指令,而這個實驗想驗證的是有沒有可能出現最後 CPU 0 讀到的 b 是 0,而 CPU 1 讀到的 a 也是 0 的情況。
利用 litmus7 執行上面提供的測試程式,並重複執行 1000000 次,結果如下:
根據上訴實驗結果,我們可以發現出現了 15 次 CPU 0 讀到的 b 是 0,而 CPU 1 讀到的 a 也是 0 的情況,代表其中一個或兩個 CPU 發生了 memory reordering 的情況,否則正常執行下至少會有一個 CPU 讀到的值為 1。
為了測試 memory barrier 是否有效,我們在 store 指令與 load 指令之間加上 arm64 架構提供的 memory barrier 指令 DMB
。
擷取 ARM Compiler toolchain Assembler Reference Version 4.1:
Data Memory Barrier acts as a memory barrier. It ensures that all explicit memory accesses that appear in program order before the DMB instruction are observed before any explicit memory accesses that appear in program order after the DMB instruction. It does not affect the ordering of any other instructions executing on the processor.
也就是說 DMB 保證在 DMB 之後 access memory 相關的指令都看得到 DMB 之前 access memory 相關的指令的結果。
加入 DMB 的實驗程式碼如下:
利用 litmus7 執行上面提供的測試程式,並重複執行 1000000 次,結果如下:
透過實驗結果可以發現加入 memory barrier 之後,不再出現 CPU 0 讀到的 b 是 0,而 CPU 1 讀到的 a 也是 0 的情況。另外,也可以觀察到加入 memory barrier 之後的執行時間比之前還慢。由實驗證實,雖然加入 memory barrier 確實會影響效能,但為了保證資料的正確性,這是必要的犧牲。
Memory ordering 發生的情況有兩種,一種是上面提到 cache 中的 store buffer forwarding 以及 invalidate queue 所造成的 reordering,稱為 Processor reordering
或 CPU reordering
,另一種是 compiler 在做最佳化時將 load 指令和 store 指令重排所造成的 memory reordering,稱為 Complier reordering
。這兩種 reordering 是可能同時發生的,如下圖所示,前者是在執行時期所發生的,而後者則是在編譯時期所發生。
為了優化程式碼以提高效能,編譯器(compiler)可能在不改變程式行為的情況下重排指令。因為編譯器不知道什麼樣的程式碼需要 thread-safe,所以在優化時都會假設程式碼是單一執行序的,優化時只會保證單一執行緒的程式行為不改變並進行指令重排。但可想而知,程式明明是多執行緒卻被認為是單一執行緒而進行指令重排,必然會產生問題。而解決方法就是明確的告訴編譯器,在程式碼可能因指令重排而錯誤的部分不要進行重排。
舉個例子,考慮到以下程式碼:
我們透過 gcc 來編譯並比較有無最佳化的組合語言
觀察兩份組合語言可以發現,在沒有最佳化下,程式會先做完 x = y + 1
的 store 指令接著再完成 y = 0
的 store 指令,但在利用 O2
最佳化的情況下,程式先做 y = 0
的 store 指令,再做x = y + 1
的 store 指令,因為程式把 y 的值先存在暫存器 w2
中,後續的計算直接從暫存器 w2
取值,這樣的指令重排並不會影響單一執行緒的資料正確性。
然而,在並行多執行緒程式中將可能導致資料的不正確,試想,如果程式中的 y 作用如果是 lock,在設成 0 之後其他執行緒才能進入 critical section 中更改 x 的資料,但在指令重排之後,某一執行緒把 y 先被設成 0,才改動 x 的資料,而另一執行緒看到 y 變成了 0,也馬上進到 critical section 中去改 x 的資料,同時讓兩個執行緒進入 critical section 修改同一份資料顯然違反了程式的正確性。
上面有提到,解決方法就是透過 compiler barrier,compiler barrier 的作用就是明確的告訴編譯器,不管進行何種最佳化手段,在 compiler barrier 出現後的讀寫操作都要確實地去記憶體位置上讀寫,不能用 barrier 之前的臨時結果(比如用 O2 最佳化後的測試程式用存在暫存器 w2
中的結果)。
上述提到的都是 memory ordering 是由什麼造成的,而這邊要講的是當 reordering 發生時,同一個 CPU 上的 load 指令和 store 指令是怎麼樣被重排的,可以分為以下四種,分別說明這四種 reordering 可能發生之問題:
b
值 10,再 sotre 更新後的 ready
值 1,那 Thread2 先 load ready
,接著檢查 ready
是否為 1,如果為 1 再 load b
,就可以取得最新版本的 b
值。但如果 load ready
和 load b
對調,這樣就可能在 b 還沒更新時就讀取,因而讀到舊版本的 b
。b
值 10,再 sotre 更新後的 ready
值 1,那 Thread2 先 load ready
,接著檢查 ready
是否為 1,如果為 1 再 load b
,就可以取得最新版本的 b
值。但如果 sotre 更新後的 b
值 10 和 sotre 更新後的 ready
值 1 對調,這樣 Thread2 可能讀到 ready
是 1,接著 load b
,但更新後的 b
值此時可能還沒 store,所以讀到的是舊版本的 b
。Flag1
值為 1,接著 load Flag2
,而 P2 會先 sotre 更新後的 Flag2
值為 1,接著 load Flag1
。我們預期的程式行為是某一時刻只會有 P1 或是 P2 其中一個進入 critical section,然而,當兩個執行緒同時發生,或其中有一個發生 store 指令和 load 指令對調,那將可能導致 P1 和 P2 看到的 Flag2
和 Flag1
都為 0,一起進入 critical section。a
和 store b
,或者是 load b
和 store a
進行亂序 (out-of-order) 執行。引用並行程式設計: Atomics 操作文中內容,在亂序 (out-of-order) 執行時,一個處理器真正執行指令的順序,是由可用的輸入資料決定,而非程式開發者在程式碼規範的順序。現代處理器可分 in-order 和 out-of-order 等二種實作。
由上可見,亂序執行相比循序執行能夠避免等待不可用的 input operands (即上述 in-order 執行的第二步),從而提高執行速度。現代電腦系統中,處理器執行的速度遠高於主記憶體,in-order 處理器花在等待可用資料的時間中,out-of-order 處理器已可處理大量指令。
總結 out-or-order 處理器的流程:
由於底層處理器硬體的實作以及使用的 toolchain 不同,發生的 memory ordering 種類也不同,hardware memory model 主要功能就是告訴使用者,在該處理器架構或是該 toolchain 上執行程式時,會發生哪些類型的 memory ordering,而這邊強調的是 CPU reodering,而不是 compiler reodering。
參考 Weak vs. Strong Memory Models,文中作者將 memory model 分為四種:
Storeload
和 StoreStore
,只要沒有資料相依關係,對指令的執行順序沒有要求。StoreLoad
reorderingTSO 是一種 Usually strong 的 memory model,只允許 StoreLoad
reordering 發生,主要應用於 x86 和 x64 硬體架構下。
為何唯獨 store
指令後接 load
指令這種情況,被 x86 允許重排呢?因為寫入一則資料的動作若尚未完成,一般沒多大影響,反之,如果讀取一則資料的操作沒完成,就可能對後面依賴這個資料的指令造成影響,因此 CPU 會優先保證讀取操作的完成。