以 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)中測試這一點,我相信你會得到相同的結果。

在你將其應用到生產環境之前,你應該瞭解兩個關鍵點:

  1. 確保瞭解你係統中 CPU 的緩存行大小:這與你使用的緩存填充大小有關。

  2. 填充更多變量意味着你將消耗更多的內存資源。運行基準測試並確保你的投入是值得的。

我所有的示例代碼都在 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

這段代碼由原文評論區提供,你可以自行嘗試驗證。

延伸閱讀

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aM5jhfqWVu5FZDn6krV3tg