cacheline 對 Go 程序的影響

五嘎子 5年前發布 | 12K 次閱讀 Google Go/Golang開發 Go

首先來了解一下來自維基百科上關于CPU緩存的介紹。

在計算機系統中,CPU高速緩存(英語:CPU Cache,在本文中簡稱緩存)是用于減少處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位于自頂向下的第二層,僅次于CPU寄存器。其容量遠小于內存,但速度卻可以接近處理器的頻率。

當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。如果存在(命中),則不經訪問內存直接返回該數據;如果不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。

緩存之所以有效,主要是因為程序運行時對內存的訪問呈現局部性(Locality)特征。這種局部性既包括空間局部性(Spatial Locality),也包括時間局部性(Temporal Locality)。有效利用這種局部性,緩存可以達到極高的命中率。

在處理器看來,緩存是一個透明部件。因此,程序員通常無法直接干預對緩存的操作。但是,確實可以根據緩存的特點對程序代碼實施特定優化,從而更好地利用緩存。

結構上,一個直接映射(Direct Mapped)緩存由若干緩存塊(Cache Block,或Cache Line)構成。每個緩存塊存儲具有連續內存地址的若干個存儲單元。在32位計算機上這通常是一個雙字(dword),即四個字節。因此,每個雙字具有唯一的塊內偏移量。每個緩存塊還可對應若干標志位,包括有效位(valid bit)、臟位(dirty bit)、使用位(use bit)等。這些位在保證正確性、排除沖突、優化性能等方面起著重要作用。

cacheline 對 Go 程序的影響

Intel的x86架構CPU從386開始引入使用SRAM技術的主板緩存,大小從16KB到64KB不等。486引入兩級緩存。其中8KBL1緩存和CPU同片,而L2緩存仍然位于主板上,大小可達268KB。將二級緩存置于主板上在此后十余年間一直設計主流。但是由于SDRAM技術的引入,以及CPU主頻和主板總線頻率的差異不斷拉大,主板緩存在速度上的對內存優勢不斷縮水。因此,從Pentium Pro起,二級緩存開始和處理器一起封裝,頻率亦與CPU相同(稱為全速二級緩存)或為CPU主頻的一半(稱為半速二級緩存)。

AMD則從K6-III開始引入三級緩存。基于Socket 7接口的K6-III擁有64KB和256KB的同片封裝兩級緩存,以及可達2MB的三級主板緩存。

今天的CPU將三級緩存全部集成到CPU芯片上。多核CPU通常為每個核配有獨享的一級和二級緩存,以及各核之間共享的三級緩存。

當從內存中取單元到cache中時,會一次取一個cacheline大小的內存區域到cache中,然后存進相應的cacheline中, 所以當你讀取一個變量的時候,可能會把它相鄰的變量也讀取到CPU的緩存中(如果正好在一個cacheline中),因為有很大的幾率你回繼續訪問相鄰的變量,這樣CPU利用緩存就可以加速對內存的訪問。

在多核的情況下,如果兩個CPU同時訪問某個變量,可能兩個CPU都會把變量以及相鄰的變量都讀入到自己的緩存中:

cacheline 對 Go 程序的影響

這會帶來一個問題:當第一個CPU更新一個變量a的時候,它會導致第二個CPU讀取變量b cachemiss, 即使變量b的值實際并沒有變化。因為CPU的最小讀取單元是cacheline,所以你可以看作a和b是一個整體,這就是偽共享:**一個核對緩存中的變量的更新會強制其它核也更新變量。

偽共享帶來了性能的損失,因為從CPU緩存中讀取變量要比從內存中讀取變量快的多, 這里有一個很經典的圖:

cacheline 對 Go 程序的影響

在并發編程中,經常會有共享數據被多個goroutine同時訪問, 所以如何有效的進行數據的設計,就是一個相當有技巧的操作。最常用的技巧就是 Padding 。現在大部分的CPU的cahceline是64字節,將變量補足為64字節可以保證它正好可以填充一個cacheline。

臺灣的 盧俊錡 Genchi Lu 提供了一個很好的例子來比較pad和沒有padding的性能(我稍微改了一下)。

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 [8]uint64
    b   uint64
    _p2 [8]uint64
    c   uint64
    _p3 [8]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()
        }
    })
}

運行結果:

go test -gcflags "-N -l" -bench .
goos: darwin
goarch: amd64
BenchmarkPad_Increase-4     30000000           56.4 ns/op
BenchmarkNoPad_Increase-4   20000000           91.4 ns/op

可能每次運行的結果不相同,但是基本上Padding后的數據結構要比沒有padding的數據結構要好的多。

Java中知名的高性能的 disruptor庫 中的設計中也采用了padding的方式避免偽共享。

你可以使用 intel-go/cpuid 獲取CPU的cacheline的大小, 官方庫 x/sys/cpu 也提供了一個 CacheLinePad struct用來padding,你只需要在你的struct定義的第一行增加 _ CacheLinePad 這么一行即可:

var X86 struct {
    _            CacheLinePad
    HasAES       bool // AES hardware implementation (AES NI)
    HasADX       bool // Multi-precision add-carry instruction extensions
    ......

參考資料

  1. https://zh.wikipedia.org/wiki/CPU緩存
  2. https://medium.com/@genchilu/whats-false-sharing-and-how-to-solve-it-using-golang-as-example-ef978a305e10
  3. https://github.com/golang/go/issues/14980
  4. https://github.com/klauspost/cpuid
  5. https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/
  6. https://luciotato.svbtle.com/golangs-duffs-devices
  7. https://stackoverflow.com/questions/14707803/line-size-of-l1-and-l2-caches
  8. https://luciotato.svbtle.com/golangs-duffs-devices
  9. https://github.com/golang/go/issues/25203

 

來自: https://colobu.com/2019/01/24/cacheline-affects-performance-in-go/

 本文由用戶 五嘎子 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!