系統性能優化-2 CPU
其實除了 CPU 的頻率,多核架構以及多 CPU 架構對系統運行的性能也是很大影響的,那么該如何充分利用 CPU 呢?
CPU 架構
首先介紹一下當前主流的 CPU 架構,現在的系統基本都是多 CPU,一個 CPU 處理器中一般有多個運行核心
,我們把一個運行核心稱為一個物理核
,每個物理核都可以運行應用程序
。每個物理核都擁有私有的一級緩存(Level 1 cache,簡稱 L1 cache),包括一級指令緩存和一級數據緩存,以及私有的二級緩存(Level 2 cache,簡稱 L2 cache)。同時,一個 CPU 上的所有物理核共享一個三級緩存。
Centos 可以使用 lscpu
查看系統 CPU 信息
[root@VM-16-11-centos zwj]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 2
On-line CPU(s) list: 0,1
Thread(s) per core: 1
Core(s) per socket: 2
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 94
Model name: Intel(R) Xeon(R) Gold 6148 CPU @ 2.40GHz
Stepping: 3
CPU MHz: 2394.364
BogoMIPS: 4788.72
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 32K
L1i cache: 32K
L2 cache: 4096K
L3 cache: 28160K
NUMA node0 CPU(s): 0,1
程序在執行時,會先將內存中的數據載入三級緩存,再進入每顆核心獨有的二級緩存,最后進入最快的一級緩存,之后才會被 CPU 使用。
緩存比內存的訪問速度要快很多,訪問內存要100個時鐘周期以上,一級緩存只需要4~5個時鐘周期,二級緩存大約12個時鐘周期,三級緩存大約30個時鐘周期。因此如果 CPU 所要操作的數據可以命中緩存,會帶來一定的性能提升。
NUMA 架構
一個 CPU 的結構我們清楚了,但現在的系統基本都是多 CPU(也稱為多 CPU Socket),CPU 之間通過總線連接,如下圖所示:
在多 CPU 架構上,應用程序可以在不同的 CPU 上執行,如果應用程序先在一個 Socket 上運行,并且把數據保存到了內存,然后被調度到另一個 Socket 上運行,此時,應用程序再進行內存訪問時,就需要訪問之前 Socket 上連接的內存,這種訪問屬于遠端內存訪問
。和訪問 Socket 直接連接的內存相比,遠端內存訪問會增加應用程序的延遲。
在多 CPU 架構下,一個應用程序訪問所在 Socket 的本地內存和訪問遠端內存的延遲并不一致,所以,我們也把這個架構稱為非統一內存訪問架構(Non-Uniform Memory Access,NUMA 架構)。
所以 CPU 架構對應用程序的運行主要有以下影響:
- cpu 緩存訪問速度遠遠大于內存,因此盡量緩存命中,多利用緩存
- NUMA 架構可能會出現遠端內存訪問的情況,這會直接增加應用程序的執行時間
提升緩存命中率
Centos 運行 lscpu
[root@VM-16-11-centos zwj]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
L1d cache: 32K
L1i cache: 32K
L2 cache: 4096K
L3 cache: 28160K
NUMA node0 CPU(s): 0,1
可以看到 L1 cache 并不是單獨的,而是分為 L1d cache 和 L1i cache,也就是 數據緩存 和 指令緩存,因此提高緩存命中率也需要分別考慮
數據緩存
思考這樣一個場景,有一個二維數組需要進行一次遍歷
int arr[10][10];for (int i = 0; i < 10; i++) {for (int j = 0; j < 10; i++) {printf("%d", arr[i][j])}
}
int arr[10][10];for (int i = 0; i < 10; i++) {for (int j = 0; j < 10; i++) {printf("%d", arr[j][i])}
}
不要思考這個場景是否合理,比如需要遍歷一個數組把所有值加入到一個 set 中,思考這兩種方式的執行速度,其實方式1是遠優于方式2的(如果是 Python,會由于數組的設計效果沒那么明顯)。
原因是 CPU 其實有 Cache Line 的概念,CPU 在讀取數據時會一次性讀取 Cache Line 大小的數據到緩存中,而我們又知道數組在內存中是緊密排放的
arr[0][0] arr[0][1] arr[0][2]
arr[1][0]
arr[2][0]
如果按照第一種方式遍歷,就可以利用 CPU 緩存加快訪問
Linux 可以通過 cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size
查看 Cache Line 大小
[root@VM-16-11-centos zwj]# cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size
64
Cache Line 在 nginx、redis 的設計中都有所體現:
- nginx 使用哈希表存放域名、http頭部信息,哈希表里桶的大小如 server_names_hash_bucket_size,它默認就等于 CPU Cache Line 的值。由于所存放的字符串長度不能大于桶的大小,所以當需要存放更長的字符串時,就需要修改桶大小,但 Nginx 官網上明確建議它應該是 CPU Cache Line 的整數倍。
- redis 的 sds 中,embstr 的 44 字節,其實也是 CPU Cache Line 的體現,當 robj + 字符串 + ‘\0’ 的長度不超過 64 時,此時編碼就是 embstr,在讀取到 robj 時就直接取到了字符串數據,無需再次訪問內存。
執行 perf stat 可以統計出進程運行時的系統信息(通過 -e 選項指定要統計的事件,如果要查看三級緩存總的命中率,可以指定緩存未命中 cache-misses 事件,以及讀取緩存次數 cache-references 事件,兩者相除就是緩存的未命中率,用 1 相減就是命中率。類似的,通過 L1-dcache-load-misses 和 L1-dcache-loads 可以得到 L1 緩存的命中率)
指令緩存
上面只是介紹了提高數據緩存的命中率,還有指令緩存的命中率
假如有一個數組,里面是一些 0~255 的數字,需要找出其中 < 128 的置為 0 并進行排序,方法1是先找到對應數據并置為0再排序,方法2是先排序再置為0。
for(i = 0; i < N; i++) {if (array [i] < 128) array[i] = 0;
}
sort(array, array + N);
sort(array, array + N);
for(i = 0; i < N; i++) {if (array [i] < 128) array[i] = 0;
}
先排序的方法速度其實是要更快的,原因是因為循環中有大量的 if 條件分支,而 CPU含有分支預測器。當代碼中出現 if、switch 等語句時,意味著此時至少可以選擇跳轉到兩段不同的指令去執行。如果分支預測器可以預測接下來要在哪段代碼執行(比如 if 還是 else 中的指令),就可以提前把這些指令放在緩存中,CPU 執行時就會很快。當數組中的元素完全隨機時,分支預測器無法有效工作,而當 array 數組有序時,分支預測器會動態地根據歷史命中數據對未來進行預測,命中率就會非常高
綁核
多 CPU 和多核心是不可避免的,為了緩解 NUMA 及應用程序重新調度后需要再次加載數據到 CPU 緩存的問題,操作系統提供了綁核指令,ls cpu
指令可以查看每個 CPU 對應的核心編號,可以使用 taskset
命令行指令在啟動時將應用程序與 CPU 進行綁定,也可以使用不同語言的 API (如sched_setaffinity)通過系統調用在程序代碼中設置進程的 CPU 親和性。此外,Perf 工具也提供了 cpu-migrations 事件,它可以顯示進程從不同的 CPU 核心上遷移的次數。
綁核是把雙刃劍,如果你的程序有很多的后臺線程,例如 redis 需要子線程生成 rdb、aof 重寫、刪除過期 key,就會導致子進程、后臺線程和主線程競爭 CPU 資源,一旦子進程或后臺線程占用 CPU 時,主線程就會被阻塞,導致 Redis 請求延遲增加。當然也有緩解的辦法,比如讓程序綁定一個具有多個邏輯核心的核,可以在一定程度上緩解 CPU 資源競爭。
不過 Redis 6 好像已經提供了支持 CPU 核綁定的配置操作了~