快取行競爭和偽共享

珠璣位 發表於 2021-05-03

快取一致性

由於通過提升cpu頻率提升效能的道路遇到了能耗牆,進一步提升頻率可能會造成CPU溫度過高,影響穩定性。為了進一步提升cpu效能,多核CPU逐漸發展起來。然而多核也面臨著諸多問題,包括正確性和可擴充套件性。下面我們就談談多核中的快取一致性。

多核快取記憶體架構

主流的多核處理器均採用共享記憶體,但訪問記憶體耗時較長,因此在CPU和記憶體之間設立了快取記憶體,一個典型的快取記憶體架構如圖所示,分為L1、L2、L3 Cache。告訴快取以快取行 (Cacheline) 作為最小操作粒度,大小一般為64位元組。

image

在這種架構中,不同核心訪問時延會依據快取行所在位置有所差別。如核心0訪問本地的L2 cache會遠快於訪問另一個L2 cache。這種快取架構被稱為 非一致性快取訪問。除了非一致性快取訪問以外,更重要的問題就是快取一致性問題。

快取一致性的問題

  1. 多個核心對同一快取行的高頻修改會導致嚴重的效能開銷,影響多核的可擴充套件性。由於快取一致性協議同一時刻只允許一個核心獨佔修改該快取行,會造成多核執行流序列化,無法充分發揮出多核的效能優勢;此外,多個核心對於同一快取行的高頻修改還會導致高速互聯匯流排中產生大量快取一致性流量,從而造成效能瓶頸。
  2. 偽共享(False Sharing)。偽共享是指本身無需在多核之間共享的內容被錯誤地劃分到同一個快取行中,並引起了多核環境下對於單一快取行的競爭,從而導致無謂的效能開銷。

單一快取行競爭

下面舉例說明對單一緩衝行高頻修改帶來的效能斷崖,在多個核心中執行一個執行緒,競爭一個全域性的互斥鎖,並更新一個全域性計數器。如圖所示,當核數量提升到一定數量時,效能會出現斷崖式下跌。

image

由於自旋鎖是通過修改全域性單一變數 *lock 來獲取和釋放鎖,因此多核對該快取行進行高頻修改時,快取行的狀態與擁有者也不斷改變,最終消耗大量時間在快取一致性協議上,導致互斥鎖無法快速有效地在不同的核心之間傳遞。

通過 Backoff Lock 或者 MCS 鎖可以一定程度解決自旋鎖的問題,在此我們不詳述,可以參閱[1]獲取更多內容。

偽共享

偽共享的問題可以舉一個簡單的例子,如果多個核的任務是自增對同一陣列的不同位置資料,理論上應該是互不干擾的。但如果這些資料在同一個快取行,就會導致一次只有一個核可以寫資料,造成偽共享。

可以通過一段程式碼來檢驗偽共享造成的效能損失:

#include<thread>
#include <algorithm>    // std::for_each
#include<vector>
#include <functional>

using namespace std;

bool SetCPUaffinity(int param){
    cpu_set_t mask;         // CPU核的集合

    CPU_ZERO(&mask);        // 置空
    CPU_SET(param,&mask);   // 設定親和力值,第一個引數為零的時候預設為呼叫執行緒
    sched_setaffinity(0, sizeof(mask), &mask); // 設定執行緒CPU親和力
}

int num0;
int num1;

void thread0(int index){
    SetCPUaffinity(index);
    int count = 100000000;  // 1億
    while(count--){
        num0++;
    }
    return;
}

void thread1(int index){
    SetCPUaffinity(index);
    int count = 100000000;
    while(count--){
        num1++;
    }
    return;
}

int main(){
    thread proc0(thread0, 0);
    thread proc1(thread1, 1);
    proc0.join();
    proc1.join();
    return 0;
}

這段程式碼功能很簡單,就是把用兩個執行緒把 num0 和 num1 增加到100000000,為了使得兩個執行緒跑在不同的核上,我們需要設定CPU親和性。通過time命令可以獲取執行時間,可以看到實際需要跑0.65s:

image

修改程式碼,讓兩個執行緒序列執行,即把main()函式修改如下:

int main(){
    thread proc0(thread0, 0);
    proc0.join();
    thread proc1(thread1, 1);
    proc1.join();
    return 0;
}

image

可以看到時間直接減少為原來的二分之一。本應是並行的程式卻比序列的程式花費了兩倍的時間,這些多的時間都消耗在快取一致性和偽共享上。

將num0和num1放到不同的快取行,可以通過如下方式:

int num0;
int num[1000];
int num1;

再執行並行程式碼,執行時間如圖所示,可以看到只需要0.18s,接近序列的二分之一時間,和預料中一致。

image

因此偽共享不僅不會提升系統效能,反而會因為競爭同一快取行造成效能損失。

參考

1. 銀杏書-多核與多處理器

2. Linux中的CPU親和性

3. 從false sharing 到快取一致性