人人都應該知道的CPU快取執行效率

張哥說技術發表於2023-05-10

來源:開發內功修煉

大家好,我是飛哥!

提到CPU效能,大部分同學想到的都是CPU利用率,這個指標確實應該首先被關注。但是除了利用率之外,還有很容易被人忽視的指標,就是指令的執行效率。如果執行效率不高,那CPU利用率再忙也都是瞎忙,產出並不高。

這就好比人,每天都是很忙,但其實每天的效率並不一樣。有的時候一天干了很多事情,但有的時候只是瞎忙了一天,回頭一看,啥也沒幹!

一、CPU 硬體執行效率

那啥是CPU的執行效率呢?介紹這個之前我們得先來簡單回顧下CPU的構成和工作原理。CPU在生產過程結束後,在硬體上就被光刻機刻成了各種各樣的模組。

人人都應該知道的CPU快取執行效率

在上面的物理結構圖中,可以看到每個物理核和L3 Cache的分佈情況。另外就是在每個物理核中,還包括了更多元件。每個核都會整合自己獨佔使用的暫存器和快取,其中快取包括L1 data、L1 code 和L2。

人人都應該知道的CPU快取執行效率

服務程式在執行的過程中,就是CPU核不斷地從儲存中獲取要執行的指令,以及需要運算的資料。這裡所謂的儲存包括暫存器、L1 data快取、L1 code快取、L2 快取、L3快取,以及記憶體。
當一個服務程式被啟動的時候,它會透過缺頁中斷的方式被載入到記憶體中。當 CPU 執行服務時,它不斷從記憶體讀取指令和資料,進行計算處理,然後將結果再寫回記憶體。

人人都應該知道的CPU快取執行效率

不同的 CPU 流水線不同。在經典的 CPU 的流水線中,每個指令週期通常包括取指、譯碼、執行和訪存幾個階段。
  • 在取指階段,CPU 從記憶體中取出指令,將其載入到指令暫存器中。
  • 在譯碼階段,CPU 解碼指令,確定要執行的操作型別,並將運算元載入到暫存器中。
  • 在執行階段,CPU 執行指令,並將結果儲存在暫存器中。
  • 在訪存階段,CPU  根據需要將資料從記憶體寫入暫存器中,或將暫存器中的資料寫回記憶體。

但,記憶體的訪問速度是非常慢的。CPU一個指令週期一般只是零點幾個納秒,但是對於記憶體來說,即使是最快的順序 IO,那也得 10 納秒左右,如果碰上隨機IO,那就是 30-40 納秒左右的開銷。可以參見我以前寫過的幾篇文章。

  • 記憶體隨機也比順序訪問慢,帶你深入理解記憶體IO過程
  • 實際測試記憶體在順序IO和隨機IO時的訪問延時差異

所以CPU為了加速運算,自建了臨時資料儲存倉庫。就是我們上面提到的各種快取,包括每個核都有的暫存器、L1 data、L1 code 和L2快取,也包括整個CPU共享的L3,還包括專門用於虛擬記憶體到實體記憶體地址轉換的TLB快取。

拿最快的暫存器來說,耗時大約是零點幾納秒,和CPU就工作在一個節奏下了。再往下的L1大約延遲在 2 ns 左右,L2大約 4 ns 左右,依次上漲。

但速度比較慢的儲存也有個好處,離CPU核更遠,可以把容量做到更大。所以CPU訪問的儲存在邏輯上是一個金字塔的結構。越靠近金字塔尖的儲存,其訪問速度越快,但容量比較小。越往下雖然速度略慢,但是儲存體積更大。

人人都應該知道的CPU快取執行效率

基本原理就介紹這麼多。現在我們開始思考指令執行效率。根據上述金字塔圖我們可以很清楚地看到,如果服務程式執行時所需要的指令儲存都位於金字塔上方的話,那服務執行的效率就高。如果程式寫的不好,或者核心頻繁地把程式在不同的物理核之間遷移(不同核的L1和L2等快取不是共享的),那上方的快取就會命中率變低,更多的請求穿透到L3,甚至是更下方的記憶體中訪問,程式的執行效率就會變差。

那如何衡量指令執行效率呢?指標主要有以下兩類

第一類是CPI和IPC

CPI全稱是cycle per instruction,指的是平均每條指令的時鐘週期個數。IPC的全稱是instruction per cycle,表示每時鐘週期執行多少個指令。這兩個指標可以幫助我們分析我們的可執行程式執行的快還是慢。由於這二位互為倒數,所以實踐中只關注一個CPI就夠了。

CPI 指標可以讓我們從整體上對程式的執行速度有一個把握。假如我們的程式執行快取命中率高,大部分資料都在快取中能訪問到,那 CPI 就會比較的低。假如說我們的程式的區域性性原理把握的不好,或者是說核心的排程演算法有問題,那很有可能執行同樣的指令就需要更多的CPU週期,程式的效能也會表現的比較的差,CPI 指標也會偏高。

第二類是快取命中率

快取命中率指標分析的是程式執行時讀取資料時有多少沒有被快取兜住,而穿透訪問到記憶體中了。穿透到記憶體中訪問速度會慢很多。所以程式執行時的 Cachemiss 指標就是越低越好了。

二、如何評估CPU硬體效率

上一小節我們說到CPU硬體工作效率的指標主要有 CPI 和快取命中率。那麼我們該如何獲取這些指標呢?

2.1 使用 perf 工具

第一個辦法是採用 Linux 預設自帶的 perf 工具。使用 perf list 可以檢視當前系統上支援的硬體事件指標。

# perf list hw cache
List of pre-defined events (to be used in -e):

  branch-instructions OR branches                    [Hardware event]
  branch-misses                                      [Hardware event]
  bus-cycles                                         [Hardware event]
  cache-misses                                       [Hardware event]
  cache-references                                   [Hardware event]
  cpu-cycles OR cycles                               [Hardware event]
  instructions                                       [Hardware event]
  ref-cycles                                         [Hardware event]

  L1-dcache-load-misses                              [Hardware cache event]
  L1-dcache-loads                                    [Hardware cache event]
  L1-dcache-stores                                   [Hardware cache event]
  L1-icache-load-misses                              [Hardware cache event]
  branch-load-misses                                 [Hardware cache event]
  branch-loads                                       [Hardware cache event]
  dTLB-load-misses                                   [Hardware cache event]
  dTLB-loads                                         [Hardware cache event]
  dTLB-store-misses                                  [Hardware cache event]
  dTLB-stores                                        [Hardware cache event]
  iTLB-load-misses                                   [Hardware cache event]
  iTLB-loads                                         [Hardware cache event]

上述輸出中我們挑幾個重要的來解釋一下

  • cpu-cycles: 消耗的CPU週期
  • instructions: 執行的指令計數,結合cpu-cycles可以計算出CPI(每條指令需要消耗的平均週期數)
  • L1-dcache-loads: 一級資料快取讀取次數
  • L1-dcache-load-missed: 一級資料快取讀取失敗次數,結合L1-dcache-loads可以計算出L1級資料快取命中率
  • dTLB-loads:dTLB快取讀取次數
  • dTLB-load-misses:dTLB快取讀取失敗次數,結合dTLB-loads同樣可以算出快取命中率

使用 perf stat 命令可以統計當前系統或者指定程式的上面這些指標。直接使用 perf stat 可以統計到CPI。(如果要統計指定程式的話只需要多個 -p 引數,寫名 pid 就可以了)

# perf stat sleep 5
Performance counter stats for 'sleep 5':
    ......
    1,758,466      cycles                    #    2.575 GHz
      871,474      instructions              #    0.50  insn per cycle

從上述結果 instructions 後面的註釋可以看出,當前系統的 IPC 指標是 0.50,也就是說平均一個 CPU 週期可以執行 0.5 個指令。前面我們說過 CPI 和 IPC 互為倒數,所以 1/0.5 我們可以計算出 CPI 指標為 2。也就是說平均一個指令需要消耗 2 個CPU週期。

我們再來看看 L1 和 dTLB 的快取命中率情況,這次需要在 perf stat 後面跟上 -e 選項來指定要觀測的指標了,因為這幾個指標預設都不輸出。

# perf stat -e L1-dcache-load-misses,L1-dcache-loads,dTLB-load-misses,dTLB-loads sleep 5
Performance counter stats for 'sleep 5':
    22,578      L1-dcache-load-misses     #   10.22% of all L1-dcache accesses
   220,911      L1-dcache-loads
     2,101      dTLB-load-misses          #    0.95% of all dTLB cache accesses
   220,911      dTLB-loads

上述結果中 L1-dcache-load-misses 次數為22,578,總的 L1-dcache-loads 為 220,911。可以算出 L1-dcache 的快取訪問失敗率大約是 10.22%。同理我們可以算出 dTLB cache 的訪問失敗率是 0.95。這兩個指標雖然已經不高了,但是實踐中仍然是越低越好。

2.2 直接使用核心提供的系統呼叫

雖然 perf 給我們提供了非常方便的用法。但是在某些業務場景中,你可能仍然需要自己程式設計實現資料的獲取。這時候就只能繞開 perf 直接使用核心提供的系統呼叫來獲取這些硬體指標了。

開發步驟大概包含這麼兩個步驟

  • 第一步:呼叫 perf_event_open 建立 perf 檔案描述符
  • 第二步:定時 read 讀取 perf 檔案描述符獲取資料

其核心程式碼大概如下。為了避免干擾,我只保留了主幹。完整的原始碼我放到我們們開發內功修改的 Github 上了。

Github地址

int main()
{
    // 第一步:建立perf檔案描述符
    struct perf_event_attr attr;
    attr.type=PERF_TYPE_HARDWARE; // 表示監測硬體
    attr.config=PERF_COUNT_HW_INSTRUCTIONS; // 標誌監測指令數
    
    // 第一個引數 pid=0 表示只檢測當前程式
    // 第二個引數 cpu=-1 表示檢測所有cpu核
    int fd=perf_event_open(&attr,0,-1,-1,0);

    // 第二步:定時獲取指標計數
    while(1)
    {   
        read(fd,&instructions,sizeof(instructions));
        ...
    }
}

在原始碼中首先宣告瞭一個建立 perf 檔案所需要的 perf_event_attr 引數物件。這個物件中 type 設定為 PERF_TYPE_HARDWARE 表示監測硬體事件。config 設定為 PERF_COUNT_HW_INSTRUCTIONS 表示要監測指令數。

然後呼叫 perf_event_open系統呼叫。在該系統呼叫中,除了 perf_event_attr 物件外,pid 和 cpu 這兩個引數也是非常的關鍵。其中 pid 為 -1 表示要監測所有程式,為 0 表示監測當前程式,> 0 表示要監測指定 pid 的程式。對於 cpu 來說。-1 表示要監測所有的核,其它值表示只監測指定的核。

核心在分配到 perf_event 以後,會返回一個檔案控制程式碼fd。後面這個perf_event結構可以透過read/write/ioctl/mmap通用檔案介面來操作。

perf_event 程式設計有兩種使用方法,分別是計數和取樣。本文中的例子是最簡單的技術。對於取樣場景,支援的功能更豐富,可以獲取呼叫棧,進而渲染出火焰圖等更高階的功能。這種情況下就不能使用簡單的 read ,需要給 perf_event 分配 ringbuffer 空間,然後透過mmap系統呼叫來讀取了。在 perf 中對應的功能是 perf record/report 功能。

將完整的原始碼編譯執行後。

# gcc main.c -o main
# ./main
instructions=1799
instructions=112654
instructions=123078
instructions=133505
...

三、perf內部工作原理

你以為看到這裡本文就結束了?大錯特錯!只講用法不講原理從來不是我們們開發內功修煉公眾號的風格。

所以介紹完如何獲取硬體指標後,我們們接下來也會展開聊聊上層的軟體是如何和CPU硬體協同來獲取到底層的指令數、快取命中率等指標的。展開聊聊底層原理。

CPU的硬體開發者們也想到了軟體同學們會有統計觀察硬體指標的需求。所以在硬體設計的時候,加了一類專用的暫存器,專門用於系統效能監視。關於這部分的描述參見Intel官方手冊的第18節。這個手冊你在網上可以搜到,我也會把它丟到我的讀者群裡,還沒進群的同學加我微信 zhangyanfei748527。

這類暫存器的名字叫硬體效能計數器(PMC: Performance Monitoring Counter)。每個PMC暫存器都包含一個計數器和一個事件選擇器,計數器用於儲存事件發生的次數,事件選擇器用於確定所要計數的事件型別。例如,可以使用PMC暫存器來統計 L1 快取命中率或指令執行週期數等。當CPU執行到 PMC 暫存器所指定的事件時,硬體會自動對計數器加1,而不會對程式的正常執行造成任何干擾。

有了底層的支援,上層的 Linux 核心就可以透過讀取這些 PMC 暫存器的值來獲取想要觀察的指標了。整體的工作流程圖如下

人人都應該知道的CPU快取執行效率

接下來我們再從原始碼的視角展開看一下這個過程。

3.1 CPU PMU 的初始化

Linux 的 PMU (Performance Monitoring Unit)子系統是一種用於監視和分析系統效能的機制。它將每一種要觀察的指標都定義為了一個 PMU,透過 perf_pmu_register 函式來註冊到系統中。

其中對於 CPU 來說,定義了一個針對 x86 架構 CPU 的 PMU,並在開機啟動的時候就會註冊到系統中。

//file:arch/x86/events/core.c
static struct pmu pmu = {
    .pmu_enable     = x86_pmu_enable,
    .read           = x86_pmu_read,
    ...
}

static int __init init_hw_perf_events(void)
{
    ...
    err = perf_pmu_register(&pmu, "cpu", PERF_TYPE_RAW);
}

3.2 perf_event_open 系統呼叫

在前面的例項程式碼中,我們看到是透過 perf_event_open 系統呼叫來建立了一個 perf 檔案。我們來看下這個建立過程都做了啥?

//file:kernel/events/core.c
SYSCALL_DEFINE5(perf_event_open,
        struct perf_event_attr __user *, attr_uptr,
        pid_t, pid, int, cpu, int, group_fd, unsigned long, flags)
{
    ...

    // 1.為呼叫者申請新檔案控制程式碼
    event_fd = get_unused_fd_flags(f_flags);

    ...
    // 2.根據使用者引數 attr,定位 pmu 物件,透過 pmu 初始化 event
    event = perf_event_alloc(&attr, cpu, task, group_leader, NULL,
                 NULLNULL, cgroup_fd);
    pmu = event->pmu;

    // 3.建立perf_event_context ctx物件, ctx儲存了事件上下文的各種資訊
    ctx = find_get_context(pmu, task, event);


    // 4.建立一個檔案,指定 perf 型別檔案的操作函式為 perf_fops
    event_file = anon_inode_getfile("[perf_event]", &perf_fops, event,
                    f_flags);

    // 5. 把event安裝到ctx中
    perf_install_in_context(ctx, event, event->cpu);

    fd_install(event_fd, event_file);
    return event_fd;
}

上面的程式碼是 perf_event_open 的核心原始碼。其中最關鍵的是 perf_event_alloc 的呼叫。在這個函式中,根據使用者傳入的 attr 來查詢 pmu 物件。回憶本文的例項程式碼,我們指定的是要監測CPU硬體中的指令數。

    struct perf_event_attr attr;
    attr.type=PERF_TYPE_HARDWARE; // 表示監測硬體
    attr.config=PERF_COUNT_HW_INSTRUCTIONS; // 標誌監測指令數

所以這裡就會定位到我們3.1節提到的 CPU PMU 物件,並用這個 pmu 初始化 新event。接著再呼叫 anon_inode_getfile 建立一個真正的檔案物件,並指定該檔案的操作方法是 perf_fops。perf_fops 定義的操作函式如下:

//file:kernel/events/core.c
static const struct file_operations perf_fops = {
    ...
    .read               = perf_read,
    .unlocked_ioctl     = perf_ioctl,
    .mmap               = perf_mmap,
};

在建立完 perf 核心物件後。還會觸發在perf_pmu_enable,經過一系列的呼叫,最終會指定要監測的暫存器。

perf_pmu_enable
-> pmu_enable
  -> x86_pmu_enable
    -> x86_assign_hw_event
//file:arch/x86/events/core.c
static inline void x86_assign_hw_event(struct perf_event *event,
                struct cpu_hw_events *cpuc, int i)

{
    struct hw_perf_event *hwc = &event->hw;
    ...
    if (hwc->idx == INTEL_PMC_IDX_FIXED_BTS) {
        hwc->config_base = 0;
        hwc->event_base = 0;
    } else if (hwc->idx >= INTEL_PMC_IDX_FIXED) {
        hwc->config_base = MSR_ARCH_PERFMON_FIXED_CTR_CTRL;
        hwc->event_base = MSR_ARCH_PERFMON_FIXED_CTR0 + (hwc->idx - INTEL_PMC_IDX_FIXED);
        hwc->event_base_rdpmc = (hwc->idx - INTEL_PMC_IDX_FIXED) | 1<<30;
    } else {
        hwc->config_base = x86_pmu_config_addr(hwc->idx);
        hwc->event_base  = x86_pmu_event_addr(hwc->idx);
        hwc->event_base_rdpmc = x86_pmu_rdpmc_index(hwc->idx);
    }
}

3.3 read 讀取計數

在例項程式碼的第二步中,就是定時呼叫 read 系統呼叫來讀取指標計數。在 3.2 節中我們看到了新建立出來的 perf 檔案物件在核心中的操作方法是 perf_read。

//file:kernel/events/core.c
static const struct file_operations perf_fops = {
    ...
    .read               = perf_read,
    .unlocked_ioctl     = perf_ioctl,
    .mmap               = perf_mmap,
};

perf_read 函式實際上支援可以同時讀取多個指標出來。但為了描述起來簡單,我只描述其讀取一個指標時的工作流程。其呼叫鏈如下:

perf_read
    __perf_read
        perf_read_one
            __perf_event_read_value
                perf_event_read
                    __perf_event_read_cpu
                perf_event_count

其中在 perf_event_read 中是要讀取硬體暫存器中的值。

static int perf_event_read(struct perf_event *event, bool group)
{
    enum perf_event_state state = READ_ONCE(event->state);
    int event_cpu, ret = 0;
    ...

again:
    //如果event正在執行嘗試更新最新的資料
    if (state == PERF_EVENT_STATE_ACTIVE) {
        ...
        data = (struct perf_read_data){
            .event = event,
            .group = group,
            .ret = 0,
        };
        (void)smp_call_function_single(event_cpu, __perf_event_read, &data, 1);
        preempt_enable();
        ret = data.ret;
    } else if (state == PERF_EVENT_STATE_INACTIVE) {
        ...
    }
    return ret;
}

smp_call_function_single 這個函式是要在指定的 CPU 上執行某個函式。因為暫存器都是 CPU 專屬的,所以讀取暫存器應該要指定 CPU 核。要執行的函式就是其引數中指定的 __perf_event_read。在這個函式中,真正讀取了 x86 CPU 硬體暫存器。

__perf_event_read
-> x86_pmu_read
  -> intel_pmu_read_event
    -> x86_perf_event_update

其中 __perf_event_read 呼叫到 x86 架構這塊是透過函式指標指過來的。

//file:kernel/events/core.c
static void __perf_event_read(void *info)
{
    ...
    pmu->read(event);
}

在 3.1 中我們介紹過 CPU 的這個 pmu,它的 read 函式指標是指向 x86_pmu_read 的。

//file:arch/x86/events/core.c
static struct pmu pmu = {
    ...
    .read           = x86_pmu_read,
}

這樣就會執行到 x86_pmu_read,最後就會呼叫到 x86_perf_event_update。在 x86_perf_event_update 中呼叫 rdpmcl 彙編指令來獲取暫存器中的值。

//file:arch/x86/events/core.c
u64 x86_perf_event_update(struct perf_event *event)
{
    ...
    rdpmcl(hwc->event_base_rdpmc, new_raw_count);
    return new_raw_count
}

最後返回到 perf_read_one 中會呼叫 copy_to_user 將值真正複製到使用者空間中,這樣我們的程式就讀取到了暫存器中的硬體執行計數了。

//file:kernel/events/core.c
static int perf_read_one(struct perf_event *event,
                 u64 read_format, char __user *buf)

{

    values[n++] = __perf_event_read_value(event, &enabled, &running);
    ...

    copy_to_user(buf, values, n * sizeof(u64))
    return n * sizeof(u64);
}

總結

雖然記憶體很快,但它的速度在 CPU 面前也只是個弟弟。所以 CPU 並不直接從記憶體中獲取要執行的指令和資料,而是優先使用自己的快取。只有快取不命中的時候才會請求記憶體,效能也會變低。

那觀察 CPU 使用快取效率高不高的指標主要有 CPI 和快取命中率幾個指標。CPU 硬體在實現上,定義了專門 PMU 模組,其中包含專門使用者計數的暫存器。當CPU執行到 PMC 暫存器所指定的事件時,硬體會自動對計數器加1,而不會對程式的正常執行造成任何干擾。有了底層的支援,上層的 Linux 核心就可以透過讀取這些 PMC 暫存器的值來獲取想要觀察的指標了。

我們可以使用 perf 來觀察,也可以直接使用核心提供的 perf_event_open 系統呼叫獲取 perf 檔案物件,然後自己來讀取。

人人都應該知道的CPU快取執行效率

歡迎把這篇文章分享給你團隊的小夥伴,大家一起成長!


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2951103/,如需轉載,請註明出處,否則將追究法律責任。

相關文章