什么是 cpu cache

最近閱讀 nginx, go 代碼時經常看到結構體 cache line 對齊,比如 go timer 全局數組。周末 google、知呼 搜索了相關文檔,梳理一下做個總結分享出來。


c語言數組

先看例子,代碼很好理解。棧上創建一個二維數組 1024 * 1024 * 4 = 4M, 遍歷數組初始化為 1,先行后列賦值,注釋代碼為先列后行。

#include<stdio.h>

int main(){
    int a[1024][1024];
    for (int i = 0; i < 1024; ++i){
        for(int j = 0; j < 1024;j++){
            a[i][j]=1;
            //a[j][i]=1;
        }
    }
    return 0;
}

這兩種方式有什么差別么?先看執行時間

// a[i][j] = 1 先行后列
root@:~/dongzerun# gcc -std=c99 test.c && time ./a.out
real    0m0.013s
user    0m0.000s
sys     0m0.012s
// a[j][i] = 1 先列后行
root@:~/dongzerun# gcc -std=c99 test.c && time ./a.out

real    0m0.039s
user    0m0.032s
sys     0m0.004s

性能差距很明顯,先行后列消耗 0.01s, 先列后行消耗 0.03s. 可以反復測試,調大 n 值,會發現性能差距更明顯。為什么會有這個差距呢?gdb 看一下 c 語言二維數組的內存布局:

(gdb) p/x &a[0][0] # 第一行第一個元素地址
$1 = 0x7fffffbfe1f0
(gdb) p/x &a[1][0] # 第二行第一個元素地址
$2 = 0x7fffffbff1f0
(gdb) p/x &a[0][1023] # 第一行最后一個元素地址
$3 = 0x7fffffbff1ec

可以看到 a[1][0] 與 a[0][0] 地址相減是 4096,也就是 1024 個 int,a[0][1023] 和
a[1][0] 地址相減是 4,也就是 1 個 int. 那么由此推斷,c 語言二維數組內存布局:按行順序存儲,完全可以轉換成一維數組訪問。

root@:~/dongzerun# gcc -std=c99 test.c && perf stat -e L1-dcache-load-misses ./a.out

 Performance counter stats for './a.out':

         1,217,476      L1-dcache-load-misses

       0.042906978 seconds time elapsed

root@:~/dongzerun# gcc -std=c99 test.c && perf stat -e L1-dcache-load-misses ./a.out

 Performance counter stats for './a.out':

           162,461      L1-dcache-load-misses

       0.010097747 seconds time elapsed

使用 perf 來查看程序運行時的 cache miss 信息。先列后行,產生了大量的 cpu cache miss, 遍歷時不得不從內存中加載數據,性能自然下降很多。cache 的存在有兩個核心約束:空間局部性和時間局部性,在很短時間內,訪問一個資源時,她附近的資源很可能也會被訪問。

為什會有 cpu cache

廣義來講,為了解決快速設備和慢速設備不匹配的問題,計算機系統結構的每個層面都有 cache. 做為 DBA, 肯定知道,Raid 卡如果帶有 Cache 那么磁盤寫入速度相當快,所以陣列卡成為了數據庫的標配。那么同樣,內存訪問速度相比 cpu 時鐘周期,就成了慢速度備。


Numbers every one should know

L1 cache 訪問需要 0.5ns, 但是主存 Mainmemory 居然 100ns, 慢了幾個數量級。

如何查看 cpu cache

測試機器為公司服務器,DELL PowerEdge R720xd. 查看 lscpu

......
CPU(s):                32 # 32 個processors
On-line CPU(s) list:   0-31
Thread(s) per core:    2 # 每個 core 2 超線程
Core(s) per socket:    8 # 每個 cpu 8 核心
Socket(s):             2 #雙 cpu
NUMA node(s):          2
Vendor ID:             GenuineIntel
CPU family:            6
......
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              20480K
NUMA node0 CPU(s):     0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30
NUMA node1 CPU(s):     1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31

Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz,雙 cpu, 每個 8 cores, 每個 core 2 個超線程(Hyper Thread),所以 OS 會看到 2 * 8 * 2 = 32 個 processor. 當前 cpu 有 L3 cache、L2 cache、L1i 指令cache、L1d 數據cache

cache 架構

那么具體 cpu 有多少 cache, 并且這些 cache 是如何分布的呢?

root@:~# ls /sys/devices/system/cpu/cpu0/cache
index0  index1  index2  index3
root@:~# ls /sys/devices/system/cpu/cpu0/cache/index0/
coherency_line_size  number_of_sets           shared_cpu_list  size  ways_of_associativity
level                physical_line_partition  shared_cpu_map   type
root@:~# ls -l /sys/devices/system/cpu/cpu0/cache/index0/
total 0
-r--r--r-- 1 root root 4096 Jun 24 17:06 coherency_line_size
-r--r--r-- 1 root root 4096 Jun 16 00:00 level
-r--r--r-- 1 root root 4096 Jun 24 17:06 number_of_sets
-r--r--r-- 1 root root 4096 Jun 24 17:06 physical_line_partition
-r--r--r-- 1 root root 4096 Jun 24 17:06 shared_cpu_list
-r--r--r-- 1 root root 4096 Jun 16 00:00 shared_cpu_map
-r--r--r-- 1 root root 4096 Jun 16 00:00 size
-r--r--r-- 1 root root 4096 Jun 16 00:00 type
-r--r--r-- 1 root root 4096 Jun 24 17:06 ways_of_associativity

系統目錄 /sys/devices/system/cpu 有 32 個 cpu processor, 對應 cache 目錄存放相關配置信息,index0, index1 分別是 L1 的數據和指令 cache, index2, index3 分別對應 L2 和 L3 cache. 通過查看得知:服務器有 2 顆 cpu, 每顆有一個 20M L3, 被 8 個 core (2 個超線程)共享,每個核有一個 256K L2, 一個 32K L1i, 一個 32K L1d

coherency_line_size: cache line size 對齊大小,64 字節,cache line 是 cpu cache 使用的最小單元
number_of_sets: 一共多少個 cache line set 集合,L2 是 512 
shared_cpu_list: 被哪些 cpu processor 共享,id 列表
shared_cpu_map: 同上,十六進制表示
size: 對應 cache 大小
ways_of_associativity: 多少路關聯

比如一個 cpu0 所使用的 L2 cache

root@:~# cat /sys/devices/system/cpu/cpu0/cache/index2/number_of_sets
512
root@:~# cat /sys/devices/system/cpu/cpu0/cache/index2/shared_cpu_list
0,16
root@:~# cat /sys/devices/system/cpu/cpu0/cache/index2/size
256K
root@:~# cat /sys/devices/system/cpu/cpu0/cache/index2/ways_of_associativity
8
root@:~# cat /sys/devices/system/cpu/cpu0/cache/index2/coherency_line_size
64

那么有如下公式成立:number_of_sets * coherency_line_size * ways_of_associativity = size 那么問題來了,這個所謂的 ways_of_associativity 多路關聯是什么意思?

cache 與 內存的映射關系

現代 cpu 中,cache 都劃分成以 cache line (cache block) 為單位,在 x86_64 體系下一般都是 64 字節,cache line 是操作的最小單元。程序即使只想讀內存中的 1 個字節數據,也要同時把附近 63 節字加載到 cache 中,如果讀取超個 64 字節,那么就要加載到多個 cache line 中。由于局部性原因,這樣 cache 使用最為高效,但同時也會帶來 cache contention 競爭問題。

cache line.png

那么問題來了,訪問一個內存地址是如何映射到 cache 中的呢?首先我們的程序都有自己的內存地址空間,也就是說,存在一個虛擬地址到物理地址的轉換,需要查看 page table, 這個翻譯很浪費時間,會把這種映射放到 TLB cache 中。

那么TLB和Cache有什么關系呢?可以說TLB命中是Cache命中的基本條件。TLB不命中,會更新TLB項,這個代價非常大,Cache命中的好處基本都沒有了。在TLB命中的情況下,物理地址才能夠被選出,Cache的命中與否才能夠達成

內存和 cache 的映射主要有三種:

  • 直接映射(Direct Mapping) : 故名思義,內存地址在 cache 中都有唯一的對應關系,比如按地址取余,算法簡單。但是問題很大,cache 命中率極低。因為 cache 有空間局部性和時間局部性,同一時間數據都在附近,某些 cache line 會頻繁換入換出。

  • 全相聯映射(Fully Associative Mapping): 沒有地址對應關系,所有數據都可以存到 cpu cache,但是問題也比較大,很容易被大量數據污染,可以類比 MySQL Innodb Buffer Pool, 只有一個 Pool 的版本,很容易被 Batch 操作將熱數據從 Pool 中刷掉,在后來的 5.6 才引入多個 Pool

  • n路組相聯映射(n-ways Set-Associative mapping): 一個 cache 劃分成 n 個組,每個組有一定數量的 cache line, 然后把內存按地址映射,某一塊物理內存映射到某個組 set

查看系統 ways_of_associativity 得知,當前 Xeon E5-2650 cpu 的 L1、L2 均為 8 組,L3 20 組

cache 一致性問題:

當數據被多個 cpu processors 共享時,自然會有一致性問題。L1、L2 均為 cpu 獨占,同一份數據,被多個 cache 緩存,那么一個 cpu modify 修改數據,同時要 invalid 其它 cpu cache 數據,造成了其它 cpu cache miss 和一致性問題。行業內比較通用的做法是通過MESI協議來保證Cache的一致性:

  • M (Modify) 這行數據有效,數據被修改了,與內存中的不一致,數據只存在于本 cache 中
  • E (Exclusive) 這行數據有效,數據和內存中的一致,并且只存在于本 cache 中
  • S (Shared) 這行數據有效,數據和內存中的一致,存在于多份 cache 中
  • I (Invalid) 無效,讀取操作會觸發 cache miss
    下圖為 MESI 狀態機


    MESI 狀態機

    仔細看一下,真的很復雜,軟件開時也不多使用多級 cache,軟件很多技術都是硬件玩剩下的概念。

cache 競爭問題: true sharing 與 false sharing

現代多核處理器,每個 core 都有自己的 cache, 比如這次測試的 Xeon E5-2650, 每個 core 擁有自己的 L1、L2, 被每個 core 的 2 個超線程共享(Hyper Thread). 這些私有 cache 就是產生竟爭問題的根源。

程序一般都是多進程運行,每個進程綁定某一個 core, 典型如 Nginx master-worker 架構。當這些進程,讀取同一份數據,或是數據恰巧相鄰,那么 cpu 就會 copy 這些數據到自己的 cache. 當某個進程修改這份數據時,就會使其它 cpu cache 內容失效,根據 MESI 協義來 Invalid. 后續其它進程想要訪問同一份數據,就會觸發 cache miss, 重新從內存裝載,非常耗時。大量的 cache miss 嚴重降低多并發程序性能。

cache contention 分為兩種類型 true sharing 和 false sharing

  • True sharing : 多核競爭的,是同一份將要訪問的數據,比如全局變量的修改。
total := 0
for i := 0; i < 32; i++ {
    go func() {
        for n := 0; n < 1000000; n++ {
            total = i
        }
    }()
}

衡量是否有 cpu cache contention 問題的標準在于,程序性能是否隨著 cpu core 的增加而線性擴展。如果不升反降,那就需要考濾優化了。

  • False sharing : 多核訪問的不是同一份數據,但是因為內存相鄰或映射,恰巧加載到了同一個 cache line. 這個解決方案一般是加 padding 數據,使得共享數據間隔超過一個 cache line, 參考 go 1.10 定時器數組的實現:
var timers [timersLen]struct {
    timersBucket

    // The padding should eliminate false sharing
    // between timersBucket values.
    pad [sys.CacheLineSize - unsafe.Sizeof(timersBucket{})%sys.CacheLineSize]byte
}

但是不當的內存對齊,也會產生問題,可以參考 How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses

小結

業務 RD 一般不關心底層原理,api 請求 1s 的比比皆是。但是做基礎組件研發,尤其是網關、內核、負載均衡,必須要關注。現代 cpu 架構一直在演進,smp, numa 影響程序性能的因素很多很多。

寫的有點雜,如有出入,還請指正~~

參考文章

關于CPU Cache -- 程序猿需要知道的那些事
cpu cache 入門
cache 是如何組織工作的
Cache為什么有那么多級?為什么一級比一級大?是不是Cache越大越好?
Dynamic Cache Contention Detection in Multi-threaded Applications
L1 L2 L3 究竟在哪里?
X86高性能編程之洞悉CPU Cache
CPU Cache Line偽共享問題的總結和分析

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容