天天看點

剖析CPU性能火焰圖生成的内部原理

作者:Java架構日記

在進行CPU性能優化的時候,我們經常先需要分析出來我們的應用程式中的CPU資源在哪些函數中使用的比較多,這樣才能高效地優化。一個非常好的分析工具就是《性能之巅》作者 Brendan Gregg 發明的火焰圖。

剖析CPU性能火焰圖生成的内部原理

我們今天就來介紹下火焰圖的使用方法,以及它的工作原理。

一、火焰圖的使用

為了更好地展示火焰圖的原理,我專門寫了一小段代碼,

int main() {
    for (i = 0; i < 100; i++) {
        if (i < 10) {
            funcA();
        } else if (i < 16) {
            funcB();
        } else {
            funcC();
        }
    }
}
           

完整的源碼我放到了咱們開發内功修煉的Github上了:https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/cpu/test09/main.c。

接下來我們用這個代碼實際體驗一下火焰圖是如何生成的。在本節中,我們隻講如何使用,原理後面的小節再展開。

# gcc -o main main.c
# perf record -g ./main
           

這個時候,在你執行指令的目前目錄下生成了一個perf.data檔案。接下來咱們需要把Brendan Gregg的生成火焰圖的項目下載下傳下來。我們需要這個項目裡的兩個perl腳本。

# git clone https://github.com/brendangregg/FlameGraph.git
           

接下來我們使用 perf script 解析這個輸出檔案,并把輸出結果傳入到 FlameGraph/stackcollapse-perf.pl 腳本中來進一步解析,最後交由 FlameGraph/flamegraph.pl 來生成svg 格式的火焰圖。具體指令可以一行來完成。

# perf script | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > out.svg
           

這樣,一副火焰圖就生成好了。

剖析CPU性能火焰圖生成的内部原理

之是以選擇我提供一個 demo 代碼來生成,是因為這個足夠簡單和清晰,友善大家了解。在上面這個火焰圖中,可以看出 main 函數調用了 funcA、funcB、funcC,其中 funcA 又調用了 funcD、funcE,然後這些函數的開銷又都不是自己花掉的,而是因為自己調用的一個 CPU 密集型的函數 caculate。整個系統的調用棧的耗時統計就十厘清晰的展現在眼前了。

如果要對這個項目進行性能優化。在上方的火焰圖中看雖然funcA、funcB、funcC、funcD、funcE這幾個函數的耗時都挺長,但它們的耗時并不是自己用掉的。而且都花在執行子函數裡了。我們真正應該關注的是火焰圖最上方 caculate 這種又長又平的函數。因為它才是真正花掉 CPU 時間的代碼。其它項目中也一樣,拿到火焰圖後,從最上方開始,把耗時比較長的函數找出來,優化掉。

另外就是在實際的項目中,可能函數會非常的多,并不像上面這麼簡單,很多函數名可能都被折疊起來了。這個也好辦,svg 格式的圖檔是支援互動的,你可以點選其中的某個函數,然後就可以展開了隻詳細地看這個函數以及其子函數的火焰圖了。

怎麼樣,火焰圖使用起來是不是還挺簡單的。接下來的小節中我們再來講講火焰圖生成全過程的内部原理。了解了這個,你才能講火焰圖用的得心應手。

二、perf采樣

2.1 perf 介紹

在生成火焰圖的第一步中,就是需要對你要觀察的程序或伺服器進行采樣。采樣可用的工具有好幾個,我們這裡用的是 perf record。

# perf record -g ./main
           

上面的指令中 -g 指的是采樣的時候要記錄調用棧的資訊。./main 是啟動 main 程式,并隻采樣這一個程序。這隻是個最簡單的用法,其實 perf record 的功能非常的豐富。

它可以指定采集事件。目前系統支援的事件清單可以用過 perf list 來檢視。預設情況下采集的是 Hardware event 下的 cycles 這一事件。假如我們想采樣 cache-misses 事件,我們可以通過 -e 參數指定。

# perf record -e cache-misses  sleep 5 // 指定要采集的事件
           

還可以指定采樣的方式。該指令支援兩種采樣方式,時間頻率采樣,事件次數發生采樣。-F 參數指定的是每秒鐘采樣多少次。-c參數指定的是每發生多少次采樣一次。

# perf record -F 100 sleep 5           // 每一秒鐘采樣100次
# perf record -c 100 sleep 5           // 每發生100次采樣一次
           

還可以指定要記錄的CPU核

# perf record -C 0,1 sleep 5           // 指定要記錄的CPU号
# perf record -C 0-2 sleep 5           // 指定要記錄的CPU範圍
           

還可以采集核心的調用棧

# perf record -a -g ./main
           

在使用 perf record 執行後,會将采樣到的資料都生成到 perf.data 檔案中。在上面的實驗中,雖然我們隻采集了幾秒,但是生成的檔案還挺大的,有 800 多 KB。我們通過 perf script 指令可以解析檢視一下該檔案的内容。大概有 5 萬多行。其中的内容就是采樣 cycles 事件時的調用棧資訊。

......
59848 main 412201 389052.225443:     676233 cycles:u:
59849             55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850             55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851             55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852             7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853         41fd89415541f689 [unknown] ([unknown])
......
           

除了 perf script 外,還可以使用 perf report 來檢視和渲染結果。

# perf report -n --stdio
           
剖析CPU性能火焰圖生成的内部原理

2.2 核心工作過程

我們來簡單看一下核心是如何工作的。

perf在采樣的過程大概分為兩步,一是調用 perf_event_open 來打開一個 event 檔案,而是調用 read、mmap等系統調用讀取核心采樣回來的資料。整體的工作流程圖大概如下

剖析CPU性能火焰圖生成的内部原理

其中 perf_event_open 完成了非常重要的幾項工作。

  • 建立各種event核心對象
  • 建立各種event檔案句柄
  • 指定采樣處理回調

我們來看下它的幾個關鍵執行過程。在 perf_event_open 調用的 perf_event_alloc 指定了采樣處理回調函數為,比如perf_event_output_backward、perf_event_output_forward等

static struct perf_event *
perf_event_alloc(struct perf_event_attr *attr, ...)
{   
    ...
    if (overflow_handler) {
        event->overflow_handler = overflow_handler;
        event->overflow_handler_context = context;
    } else if (is_write_backward(event)){
        event->overflow_handler = perf_event_output_backward;
        event->overflow_handler_context = NULL;
    } else {
        event->overflow_handler = perf_event_output_forward;
        event->overflow_handler_context = NULL;
    }
    ...
}
           

當 perf_event_open 建立事件對象,并打開後,硬體上發生的事件就可以出發執行了。核心注冊相應的硬體中斷處理函數是 perf_event_nmi_handler。

//file:arch/x86/events/core.c
register_nmi_handler(NMI_LOCAL, perf_event_nmi_handler, 0, "PMI");
           

這樣 CPU 硬體會根據 perf_event_open 調用時指定的周期發起中斷,調用 perf_event_nmi_handler 通知核心進行采樣處理

//file:arch/x86/events/core.c
static int perf_event_nmi_handler(unsigned int cmd, struct pt_regs *regs)
{    
    ret = x86_pmu.handle_irq(regs);
    ...
}
           

該終端處理函數的函數調用鍊經過 x86_pmu_handle_irq 到達 perf_event_overflow。其中 perf_event_overflow 是一個關鍵的采樣函數。無論是硬體事件采樣,還是軟體事件采樣都會調用到它。它會調用 perf_event_open 時注冊的 overflow_handler。我們假設 overflow_handler 為 perf_event_output_forward

void
perf_event_output_forward(struct perf_event *event, ...)
{
    __perf_event_output(event, data, regs, perf_output_begin_forward);
}
           

在 __perf_event_output 中真正進行了采樣處理

//file:kernel/events/core.c
static __always_inline int
__perf_event_output(struct perf_event *event, ...)
{
    ...
    // 進行采樣
    perf_prepare_sample(&header, data, event, regs);
    // 儲存到環形緩存區中
    perf_output_sample(&handle, &header, data, event);
}
           

如果開啟了 PERF_SAMPLE_CALLCHAIN,則不僅僅會把目前在執行的函數名采集下來,還會把整個調用鍊都記錄起來。

//file:kernel/events/core.c
void perf_prepare_sample(...)
{

    //1.采集IP寄存器,目前正在執行的函數
    if (sample_type & PERF_SAMPLE_IP)
        data->ip = perf_instruction_pointer(regs);

    //2.采集目前的調用鍊
    if (sample_type & PERF_SAMPLE_CALLCHAIN) {
        int size = 1;

        if (!(sample_type & __PERF_SAMPLE_CALLCHAIN_EARLY))
            data->callchain = perf_callchain(event, regs);

        size += data->callchain->nr;

        header->size += size * sizeof(u64);
    }
    ...
}
           

這樣硬體和核心一起協助配合就完成了函數調用棧的采樣。後面 perf 工具就可以讀取這些資料并進行下一次的處理了。

三、FlameGraph工作過程

前面我們用 perf script 解析是看到的函數調用棧資訊比較的長。

......
59848 main 412201 389052.225443:     676233 cycles:u:
59849             55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850             55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851             55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852             7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853         41fd89415541f689 [unknown] ([unknown])
......
           

在畫火焰圖的前一步得需要對這個資料進行一下預處理。stackcollapse-perf.pl 腳本會統計每個調用棧回溯出現的次數,并将調用棧處理為一行。行前面表示的是調用棧,後面輸出的是采樣到該函數在運作的次數。

# perf script | ../FlameGraph/stackcollapse-perf.pl
main;[unknown];__libc_start_main;main;funcA;funcD;funcE;caculate 554118432
main;[unknown];__libc_start_main;main;funcB;caculate 338716787
main;[unknown];__libc_start_main;main;funcC;caculate 4735052652
main;[unknown];_dl_sysdep_start;dl_main;_dl_map_object_deps 9208
main;[unknown];_dl_sysdep_start;init_tls;[unknown] 29747
main;_dl_map_object;_dl_map_object_from_fd 9147
main;_dl_map_object;_dl_map_object_from_fd;[unknown] 3530
main;_start 273
main;version_check_doit 16041
           

上面 perf script 5 萬多行的輸出,經過 stackcollapse.pl 預處理後,輸出隻有不到 10 行。資料量大大地得到了簡化。在 FlameGraph 項目目錄下,能看到好多 stackcollapse 開頭的檔案

剖析CPU性能火焰圖生成的内部原理

這是因為各種語言、各種工具采樣輸出是不一樣的,是以自然也就需要不同的預處理腳本來解析。

在經過 stackcollapse 處理得到了上面的輸出結果後,就可以開始畫火焰圖了。flamegraph.pl 腳本工作原理是:将上面的一行繪制成一列,采樣數得到的次數越大列就越寬。另外就是如果同一級别如果函數名一樣,就合并到一起。比如現在有一下資料檔案:

funcA;funcB;funcC 2
funcA; 1
funcD; 1
           

我可以通過手工畫一個類似的火焰圖,如下:

剖析CPU性能火焰圖生成的内部原理

其中 funcA 因為兩行記錄合并,是以占據了 3 的寬度。funcD 沒有合并,占據就是1。另外 funcB、funcC都畫在A上的上方,占據的寬度都是2。

總結

火焰圖是一個非常好的用于分析熱點函數的工具,隻要你關注性能優化,就應該學會使用它來分析你的程式。我們今天的文章不光是介紹了火焰圖是如何生成的,而且還介紹了其底層的工作原理。火焰圖的生成主要分兩步,一是采樣,而是渲染。

在采樣這一步,主要依賴的是核心提供的 perf_event_open 系統調用。該系統調用在内部進行了非常複雜的處理過程。最終核心和硬體一起協同合作,會定時将目前正在執行的函數,以及函數完整的調用鍊路都給記錄下來。

在渲染這一步,Brendan Gregg 提供的腳本會出 perf 工具輸出的 perf_data 檔案進行預處理,然後基于預處理後的資料渲染成 svg 圖檔。函數執行的次數越多,在 svg 圖檔中的寬度就越寬。我們就可以非常直覺地看出哪些函數消耗的 CPU 多了。

最後再補充說一句是,我們的火焰圖隻是一個采樣的渲染結果,并不一定完全代表真實情況,但也夠用了。

原文出自公衆号:開發内功修煉

原文連結:https://mp.weixin.qq.com/s/A19RlLhSgbzw8UU4p1TZNA