以 Go 語言爲例解釋什麼是僞共享以及如何解決
本文翻譯自:What’s false sharing and how to solve it (using Golang as example)
譯文
在解釋 僞共享(false sharing) 之前,有必要簡要介紹一下 CPU 架構中緩存是如何工作的。
CPU 緩存中的最小單位是緩存行(cache line)(如今,CPU 中常見的緩存行大小爲 64 字節)。因此,當 CPU 從內存讀取一個變量時,它會同時讀取該變量附近的所有變量。圖 1 是一個簡單的例子:
當 core1 從內存讀取變量 a 時,它會同時將變量 b 讀入緩存。(順便說一句,我認爲 CPU 從內存批量讀取變量的主要原因是基於空間局部性理論:當 CPU 訪問一個變量時,它可能很快就會讀取旁邊的變量。)
這種緩存架構存在一個問題:如果一個變量像圖 2 那樣存在於不同 CPU 核心的兩個緩存行中:
當 core1 更新變量 a 時:
即使變量 b 沒有被修改,它也會導致 core2 的緩存失效,因此 core2 將重新加載緩存行中的所有變量,如圖 4 所示:
這就是所謂的僞共享:一個核心更新一個變量會迫使其他核心也更新緩存。我們都知道,CPU 從緩存讀取變量比從內存讀取要快得多。因此,當這個變量總是存在於多核中時,這將顯著影響性能。
解決這個問題的常見方法是緩存填充(cache padding):在變量之間填充一些無意義的變量。這將迫使一個變量單獨佔用一個核心的緩存行,所以當其他核心更新緩存變量時,不會使該核心從內存中重新加載變量。
讓我們使用下面的 Go 代碼片段簡要介紹這個僞共享的概念。
這裏是一個包含 3 個 uint64 的 Go 結構體,
type NoPad struct {
a uint64
b uint64
c uint64
}
func (myatomic *NoPad) IncreaseAllEles() {
atomic.AddUint64(&myatomic.a, 1)
atomic.AddUint64(&myatomic.b, 1)
atomic.AddUint64(&myatomic.c, 1)
}
還有另一個我在變量之間添加了 [8]uint64 以進行填充的結構體:
type Pad struct {
a uint64
_p1 [8]uint64
b uint64
_p2 [8]uint64
c uint64
_p3 [8]uint64
}
func (myatomic *Pad) IncreaseAllEles() {
atomic.AddUint64(&myatomic.a, 1)
atomic.AddUint64(&myatomic.b, 1)
atomic.AddUint64(&myatomic.c, 1)
}
然後我編寫了一個簡單的代碼來運行基準測試:
func testAtomicIncrease(myatomic MyAtomic) {
paraNum := 1000
addTimes := 1000
var wg sync.WaitGroup
wg.Add(paraNum)
for i := 0; i < paraNum; i++ {
go func() {
for j := 0; j < addTimes; j++ {
myatomic.IncreaseAllEles()
}
wg.Done()
}()
}
wg.Wait()
}
func BenchmarkNoPad(b *testing.B) {
myatomic := &NoPad{}
b.ResetTimer()
testAtomicIncrease(myatomic)
}
func BenchmarkPad(b *testing.B) {
myatomic := &Pad{}
b.ResetTimer()
testAtomicIncrease(myatomic)
}
在 2014 年的 MBA 上運行的基準測試如下:
$> go test -bench=.
BenchmarkNoPad-4 2000000000 0.07 ns/op
BenchmarkPad-4 2000000000 0.02 ns/op
PASS
ok 1.777s
基準測試的結果顯示,性能從 0.07 ns/op 提高到了 0.02 ns/op,這是一個很大的改進。
你也可以在其他語言(如 Java)中測試這一點,我相信你會得到相同的結果。
在你將其應用到生產環境之前,你應該瞭解兩個關鍵點:
-
確保瞭解你係統中 CPU 的緩存行大小:這與你使用的緩存填充大小有關。
-
填充更多變量意味着你將消耗更多的內存資源。運行基準測試並確保你的投入是值得的。
我所有的示例代碼都在 GitHub 上。
P.S.
之所以選擇翻譯此文,是因爲我正在寫關於 Go 結構體內存對齊的文章,需要介紹「僞共享」這個概念,受限於篇幅所限,就決定針對僞共享這個概念單獨寫一篇文章。我在查閱資料過程中發現此文講解淺顯易懂,於是想着把此文翻譯下共讀者查閱。
不過雖然這篇文章思路清晰易懂,但作者提供的基準測試代碼並不夠嚴謹,我在 2022 款 M2 芯片的 MBA 上測試得到如下結果:
$ go test -bench=. -v
goos: darwin
goarch: arm64
pkg: false-sharing
BenchmarkNoPad
BenchmarkNoPad-8 1000000000 0.09618 ns/op
BenchmarkPad
BenchmarkPad-8 1000000000 0.1065 ns/op
PASS
ok false-sharing 2.368s
跟作者的結果完全相反,哈哈😄。
我們可以寫一個簡單的基準測試來驗證使用 cache padding 來解決 false sharing 的效果:
package main
import (
"sync/atomic"
"testing"
)
func BenchmarkPadding(b *testing.B) {
b.Run("without_padding", func(b *testing.B) {
nums := [128]atomic.Int64{}
i := atomic.Int64{}
b.RunParallel(func(pb *testing.PB) {
id := i.Add(1)
for pb.Next() {
nums[id].Add(1)
}
})
})
b.Run("with_padding", func(b *testing.B) {
type pad struct {
val atomic.Int64
_ [8]uint64
}
nums := [128]pad{}
i := atomic.Int64{}
b.RunParallel(func(pb *testing.PB) {
id := i.Add(1)
for pb.Next() {
nums[id].val.Add(1)
}
})
})
}
在 without_padding 場景中,由於 nums 數組的元素可能共享相同的緩存行,多個 goroutine 同時修改相鄰元素時會導致緩存行失效,從而降低性能。
而在 with_padding 場景中,通過在高頻訪問的變量之間加入緩存填充 _ [8]uint64,使得每個元素都佔據獨立的緩存行,減少了這種緩存行的失效情況,預期能觀察到性能的提升。
執行基準測試代碼,輸出如下:
$ go test -bench=. -v
goos: darwin
goarch: arm64
pkg: false-sharing
BenchmarkPadding
BenchmarkPadding/without_padding
BenchmarkPadding/without_padding-8 55441728 22.09 ns/op
BenchmarkPadding/with_padding
BenchmarkPadding/with_padding-8 1000000000 1.075 ns/op
PASS
ok false-sharing 4.255s
基準測試的結果顯示,性能從 22.09 ns/op 提高到了 1.075 ns/op。
這段代碼由原文評論區提供,你可以自行嘗試驗證。
延伸閱讀
- 原文地址: https://medium.com/@genchilu/whats-false-sharing-and-how-to-solve-it-using-golang-as-example-ef978a305e10
聯繫我
-
公衆號:Go 編程世界
-
微信:jianghushinian
-
郵箱:jianghushinian007@outlook.com
-
博客:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aM5jhfqWVu5FZDn6krV3tg