僞共享與CPU cache line

僞共享與CPU cache line

CPU緩存;

先看定義:

CPU 緩存(Cache Memory)是位於 CPU 與內存之間的臨時存儲器,它的容量比內存小的多但是交換速度卻比內存要快得多。
高速緩存的出現主要是爲了解決 CPU 運算速度與內存讀寫速度不匹配的矛盾,因爲 CPU 運算速度要比內存讀寫速度快很多,這樣會使 CPU 花費很長時間等待數據到來或把數據寫入內存。
在緩存中的數據是內存中的一小部分,但這一小部分是短時間內 CPU 即將訪問的,當 CPU 調用大量數據時,就可避開內存直接從緩存中調用,從而加快讀取速度。

CPU 和主內存之間有好幾層緩存,因爲即使直接訪問主內存也是非常慢的。如果你正在多次對一塊數據做相同的運算,那麼在執行運算的時候把它加載到離 CPU 很近的地方就有意義了。

下圖是一個很經典的圖:
在這裏插入圖片描述
這個數據可能不準了(10年前的數據),但是整體還是能夠說明問題:離CPU越近,讀寫數據就越快。

多核機器的存儲結構如下圖所示:
在這裏插入圖片描述
當 CPU 執行運算的時候,它先去 L1 查找所需的數據,再去 L2,然後是 L3,最後如果這些緩存中都沒有,所需的數據就要去主內存拿。走得越遠,運算耗費的時間就越長。所以如果你在做一些很頻繁的事,你要確保數據在 L1 緩存中。

緩存行

緩存系統中是以緩存行(cache line)爲單位存儲的。緩存行通常是 64 字節(譯註:本文基於 64 字節,其他長度的如 32 字節等不適本文討論的重點),並且它有效地引用主內存中的一塊地址。一個 Java 的 long 類型是 8 字節,因此在一個緩存行中可以存 8 個 long 類型的變量。所以,如果你訪問一個 long 數組,當數組中的一個值被加載到緩存中,它會額外加載另外 7 個,以致你能非常快地遍歷這個數組。事實上,你可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。而如果你在數據結構中的項在內存中不是彼此相鄰的(如鏈表),你將得不到免費緩存加載所帶來的優勢,並且在這些數據結構中的每一個項都可能會出現緩存未命中。

每一個緩存行都有自己的狀態維護,假設一個CPU緩存行中有8個long型變量:a, b, c, d, e, f, g, h。這8個數據中的a被修改了之後又修改了b,那麼當再次讀取a的時候這個緩存行就失效了,需要重新從主內存中load。

舉個例子:有多個線程操作不同的成員變量,但是這些變量在相同的緩存行,這個時候會發生什麼?。沒錯,僞共享(False Sharing)問題就發生了!有張 Disruptor 項目的經典示例圖,如下:
在這裏插入圖片描述
上圖中,一個運行在處理器 core1上的線程想要更新變量 X 的值,同時另外一個運行在處理器 core2 上的線程想要更新變量 Y 的值。但是,這兩個頻繁改動的變量都處於同一條緩存行。兩個線程就會輪番發送 RFO(Request for owner) 消息,佔得此緩存行的擁有權。當 core1 取得了擁有權開始更新 X,則 core2 對應的緩存行需要設爲 I 狀態(緩存行失效狀態)。當 core2 取得了擁有權開始更新 Y,則 core1 對應的緩存行需要設爲 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,L1 和 L2 緩存上都是失效數據,只有 L3 緩存上是同步好的數據。從前一篇我們知道,讀 L3 的數據非常影響性能。更壞的情況是跨槽讀取,L3 都要 miss,只能從內存上加載。

表面上 X 和 Y 都是被獨立線程操作的,而且兩操作之間也沒有任何關係。只不過它們共享了一個緩存行,但所有競爭衝突都是來源於共享。

僞共享

僞共享的非標準定義爲:緩存系統中是以緩存行(cache line)爲單位存儲的,當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是僞共享。

下面是一個跑的例子:

package test

import (
	"sync/atomic"
	"testing"
)

type NoPad struct {
	a uint64
	b uint64
	c uint64
}

func (np *NoPad) Increase() {
	atomic.AddUint64(&np.a, 1)
	atomic.AddUint64(&np.b, 1)
	atomic.AddUint64(&np.c, 1)
}

type Pad struct {
	a   uint64
	_p1 [7]uint64
	b   uint64
	_p2 [7]uint64
	c   uint64
	_p3 [7]uint64
}

func (p *Pad) Increase() {
	atomic.AddUint64(&p.a, 1)
	atomic.AddUint64(&p.b, 1)
	atomic.AddUint64(&p.c, 1)
}
func BenchmarkPad_Increase(b *testing.B) {
	pad := &Pad{}
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			pad.Increase()
		}
	})
}
func BenchmarkNoPad_Increase(b *testing.B) {
	nopad := &NoPad{}
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			nopad.Increase()
		}
	})
}

測試結果:

goos: darwin
goarch: amd64
BenchmarkPad_Increase
BenchmarkPad_Increase-4     	31773807	        39.6 ns/op
BenchmarkNoPad_Increase
BenchmarkNoPad_Increase-4   	22885161	        52.6 ns/op
PASS

每次跑的結果不一樣,但是加了Padding的性能肯定是比沒有Padding的要好得多。

解決僞共享的方案就是padding。現在分析上面的例子,我們知道一條緩存行有 64 bytes,go裏面uint64是8個字節,我們通過在a、b、c後面pading保證每個變量處於不同的緩存行,就避免了僞共享( 64 位系統超過緩存行的 64 字節也無所謂,只要保證不同線程不操作同一緩存行就可以)。

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