c++視覺化效能測試

2022-06-18 21:00:37

閱讀前注意

本文所有程式碼貼出來的目的是幫助大家理解,並非是要引導大家跟寫,許多環境問題檔案問題沒有詳細說明,程式碼也並不全面,達不到跟做的效果。建議直接閱讀全文即可,我在最後會給出詳細程式碼地址,對原始碼細節更感興趣的同學可以下載參考。

效能測試:使用紀錄檔

在c++中進行效能測試是令人頭疼的問題,我們往往需要在數以千計的log中分析出效能瓶頸————找出最耗時的部分。而這部分工作是極其枯燥的:

首先,我們需要準備好一個計算時間的工具類,好在我們擁有std::chrono,有了它我們就可計算出過程經歷的時間。聰明的你或許會搞出這樣一個東西:

//時間計量工具最簡單的樣子
class TimeTool {
public:
    //desp 表示輸出的紀錄檔 紀錄檔字串中可能會用一些文字替換的方式輸出時間
    //例如 $ST 表示開始時間  $ET 表示結束時間 %DT 表示他們的差
    //它很可能是這樣的 「xxx cost time $DT, st = %ST  et = $ET」
    TimeTool(const std::string& desp);
    //在解構時自動輸出紀錄檔
    ~TimeTool();
}

哦!我覺得他已經足夠好了,或許還可以改進,不過現在它能夠完成最基本的任務了!

完了嗎?當然沒有,還有更多的工作要做,接下來最重要的是……

我們不得不在我們富有美感的程式碼中插入這些令人糟心的「探針」,說不定還會加上一連串的{},讓本來漂亮的程式碼變得層層深入,令人頭大不已!

我手頭正好有一份程式碼:

void saveTheWorld() {
    Hero h = makeHero("smalldy");
    WorldList& wlist = findBadWorld();
    World target;
    int rank = 0;
    for(auto & w : wlist) {
        if(w.rank() > rank) {
            target = w;
            rank = w.rank();
        } 
    }

    hero.save(target);
}

哇,很好的故事不是嗎?(並不,你只關心效能測試,卻沒發現英雄已經掛了!)

現在,我們要對此程式碼片段進行效能測試:

void saveTheWorld() {
    TimeTool save_function_cost("函數saveTheWorld耗時 $DT");
    
    {
        TimeTool make_hero_cost("makeHero耗時 $DT");
        Hero h = makeHero("smalldy");
    }
    {
        TimeTool find_world("findBadWorld耗時 $DT");
        WorldList& wlist = findBadWorld();
    }
    World target;
    int rank = 0;
    {
        TimeTool find_rank("查詢最危險的世界耗時 $DT");
        for(auto & w : wlist) {
            if(w.rank() > rank) {
                target = w;
                rank = w.rank();
            } 
        }
    }
    {
        TimeTool hero_save("英雄耗時 $DT");
        hero.save(target);
    }
}

天哪!這簡直糟糕透了!它甚至不能正確的執行,因為區域性變數將在作用域結束後銷燬,英雄還沒上場,就已經魂歸高天了。或許我們可以對TimeTool類加以改動,讓他提供主動的計時結束函數,這樣,我們就可以去掉該死的{},然後手動設定開始點和結束點了,當然,這樣的話,就要書寫更多的「探針」程式碼了。

好吧,假設我們已經完成了這樣工作,我想聰明的你一定不想讓我再貼一遍這些無意義的程式碼了,你一定能想象到新的時間工具會長成什麼樣子了。我們把它跑起來,就會得到一小串紀錄檔啦!

TimeTool make_hero_cost("makeHero耗時 200ms");
TimeTool find_world("findBadWorld耗時 200ms");
TimeTool find_rank("查詢最危險的世界耗時 100ms");
TimeTool hero_save("英雄耗時 1500ms");
函數saveTheWorld耗時 2000ms

我們清楚的看到效能瓶頸所——這個英雄似乎不太給力,他居然耗費了1500ms!你在幹什麼!Hero!

當然,在這個例子中,我無法再繼續深究下去,畢竟我也不知道英雄如何更加快速的拯救世界,優化也就無從談起了,但是從這個糟糕的例子中,我們至少知道了通過紀錄檔記錄可以幫助我們進行效能測試,從而觀察到哪些步驟耗費了更多的時間。

實際情況可要比這個複雜多了,我是說,這種級別的效能測試,完全不能解決實際的需求,在真實的專案環境下,程式輸出的紀錄檔可能有成千上萬條,你幾乎不能再實際執行的過程中去認真閱讀紀錄檔的時間戳,而在log檔案中,尋找你需要的條目——怎麼說呢,這個挑戰對我來說是十分不愉快的。我完全不想在我一天的工作中,插入這樣的流程,這太折磨人了,更別提並行環境下的紀錄檔了,你甚至不能確定他們的順序!

視覺化可太煩啦!

視覺化是個不錯的點子,我喜歡視覺化,尤其是在文字讓我眼花繚亂的情況下,視覺化更加讓我感到親切,比起從該死的紀錄檔中扣出我想要的條目,如果有一張圖表展現在我的面前,那就更好不過了!

什麼?開發一個視覺化工具?

啊,這個目標著實有些大,我還要分析紀錄檔嗎?分析得到的資料該如何呈現吶?c++好做視覺化的東西嗎?靠!?難不成還要上正規表示式嗎?

可惡!不想幹啦!

全文完

Google Chrome Tracing!

全文還沒完!世界還沒毀滅呢!

是的!你想到的東西大部分都會有現成的實現,如果你有谷歌瀏覽器的話,你可以嘗試在位址列輸入以下地址:

chrome://tracing

此網頁可接受一個Json檔案,然後根據Json檔案的內容,生成圖表,我這裡有一份從網上拷貝Json範例,你可以將其儲存在.json檔案中,然後點選網頁上的Load按鈕,選擇你的檔案。


[
    {"name": "休息", "cat": "測試", "ph": "X", "ts": 0, "pid": 0, "tid": 1, "dur": 28800000000, "args": {"duration_hour": 8, "start_hour": 0}},  
    {"name": "學習", "cat": "測試", "ph": "X", "ts": 28800000000, "pid": 0, "tid": 1, "dur":3600000000 , "args": {"duration_hour": 1, "start_hour": 8}},
 
    {"name": "休息", "cat": "測試", "ph": "X", "ts": 0, "pid": 0, "tid": 2, "dur": 21600000000} ,
 
    {"name": "process_name", "ph": "M", "pid": 0, "args": {"name": "一週時間管理"}},
    {"name": "thread_name", "ph": "M", "pid": 0, "tid": 1, "args": {"name": "第一天"}},
    {"name": "thread_name", "ph": "M", "pid": 0, "tid": 2, "args": {"name": "第二天"}}

]

不方便測試的同學也沒關係,結果是這樣的:

點選對應的條目,下方還會出現json中一些欄位的資料,這些我不再進行展示。

回到正題,如果我們效能測試的結果以這種方式進行展示的話,那可就清晰多了!它足夠簡單,也足夠清晰了,甚至不用我寫一行關於視覺化的程式碼,簡直是我的完美選擇。唯一的不足點是,它非常依賴谷歌瀏覽器,而且還要手動的選擇json檔案,這讓我非常不爽。

幸運的是,已經有大佬將核心網頁程式碼提取出來了!我無法確定我閱讀的文章是否為原創,因此,只能按照名稱搜尋,從若干網站中選出了一個我認為是原作者的網址:

https://2010-2021.limboy.me/2020/03/21/chrome-trace-viewer/

(CSDN盜版文章太多了!)

在這篇文章中,作者給出了一個html檔案,並讓其可以線上使用,按作者的說法來講

通過 chrome://tracing 的方式來使用 Tracer Viewer 還是不太方便,也不利於傳播,Google 雖然在 catapult 裡提供了 trace2html,但包含的檔案很多,使用起來還是有點麻煩,於是參考了 go trace 的原始碼,把相關檔案上傳到了 CDN,然後在一個 html 檔案裡參照,這樣只需一個檔案即可。

題外話,具體的html檔案我不在這裡貼了,有點長,而且我也不會原封不動的使用,所以貼上來沒有什麼意義,感興趣的同學可以存取下作者的文章網址,也算是給正版引流(如果有的話)了罷。

不得不說,作者的想法非常好,不過我認為,使用CDN什麼還是有點大費周章了,並且我也並不熟悉這個領域,因此我將採用其它辦法。

基於chrome tracing的視覺化方案

我的方案是:

  1. 提供一種方法,可插入過程開始點,插入過程結束點,儲存json檔案,用於進行效能測試並生成結果。
  2. 提供一個載入程式,該程式可以臨時搭建一個網頁伺服器端,載入程式讀取json檔案,並自動開啟瀏覽器存取服務網址,從而呈現出結果。

方案確定,開始實施!

Tracing Tool

首先是目標1,提供一種方法,可插入過程開始點,插入過程結束點,儲存json檔案,用於進行效能測試並生成結果。

在具體實施之前,我們有必要了解下tracing json的格式,一個 tracing json檔案內可包含甚多‘事件’,‘事件’的種類很多,不同的事件最終視覺化的顯示效果也不近相同,我們的效能測試場景只需要給出一段段過程的視覺化顯示,所以用到的事件並不多。

關於其他未使用到的時間,感興趣的同學可以存取網站:https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/edit 地址在牆外。

我們用一個事件表示一個過程的開始,一個事件表示過程的結束,有開始和結束就能描述所有測試點了。

我們需要使用的事件在上邊的例子中並沒有出現,在這裡我詳細介紹一下我們需要了解的欄位。

  • name 條形圖上顯示的名字
  • cat 分類
  • ph 圖表種類 B 表示開始點 E表示結束點
  • ts 時間戳
  • pid 程序名 顯示
  • tid 執行緒名 顯示
  • args 一段json文字 部分事件需要特定的引數(本文不會用到)

好了,我們瞭解這麼多就夠了,接下來,我將會實現一些方法/類,來輔助我們在json中插入事件。

我們需要一個json工具,我比較懶,不想手寫json,因此我們選擇了nlohman json作為我們的json寫入工具,get_json_writer可以獲得json物件,從而支援寫入資料,gen_json顧名思義,就是生成json檔案,將json物件寫入到磁碟檔案中。

namespace cpp_visual {
namespace json_tool {
nlohmann::json &get_json_writer();
std::string gen_json(const std::string &json_path);
} // namespace json_tool

由於chrome tracing需要的時間戳都是從0開始的相對時間,因此我們不能簡單的插入時間戳,而是要計算一個測試開始到當前時間的差值,這樣才能正常的進行繪製,所以我們寫一個非常簡單的純工具類。

class TracingTool {
public:
  static int64_t currentDurationTs();
private:
  static int64_t start_time_;
};

這樣的話我們只需呼叫currentDurationTs就可以獲得合理的時間戳了。

接下來,我們需要對事件進行抽象,提取出一個基礎類別。

class TracingEvent {
public:
  template <typename FieldType>
  void setEventField(const std::string &name, const FieldType &value) {
    event_json_[name] = value;
  }
  void commitEvent();

private:
  nlohmann::json event_json_;
};

TracingEvent,它將成為所有事件的基礎類別,即便目前我們並沒有這麼多事件,但是設計上還是要認真做。它內含一個json物件,它描述一個事件,此物件將會儲存所有必須的欄位,這個物件將會作為片段插入最終的json檔案中。

呼叫setEventField可以新增欄位,呼叫commitEvent可以將新增好的欄位寫入到json物件中。

現在我們擁有了一個易於擴充套件的基礎類別,之後我們便可以實現一個更加方便的「過程事件」,他可以幫我們自動填寫一些可自動計算的欄位——例如時間戳,讓使用者手動填寫那些需要使用者才能決定的欄位——例如程序名,執行緒名等等。

class TracingDuration : public TracingEvent {
public:
  TracingDuration(const std::string &task_name, const std::string &thread_name,
                  const std::string &duration_name);
  virtual ~TracingDuration() = default;
  void begin();
  void end();
};

值得注意的是,我將原本程序的概念在引數中寫為了任務(task),這是為了提示使用者,不必拘泥於此,不需要所有的測試點都使用同一個程序名,我們可以將我們的程式劃分為許多工,這些任務可能是單執行緒完成的,也可能是多執行緒完成的,這種基於任務的劃分,在圖表上有更好的表現力,當然,這也是作者的個人感受和意見。

TracingDuration類強制我們建立此物件是提供任務名,執行緒名,以及過程名,呼叫begin可以確定一個開始點,end確定一個結束點,使用起來非常方便,為了免去重複書寫的體力勞動,我還提供了兩個宏定義,分別用於標記開始和結束:

#define TRACING_VISUAL_B(__TASK__, __THREAD__, __DURATION_NAME__)              \
  cpp_visual::TracingDuration __DURATION_NAME__##_BEGIN(                       \
      #__TASK__, #__THREAD__, #__DURATION_NAME__);                             \
  __DURATION_NAME__##_BEGIN.begin()

#define TRACING_VISUAL_E(__TASK__, __THREAD__, __DURATION_NAME__)              \
  cpp_visual::TracingDuration __DURATION_NAME__##_END(#__TASK__, #__THREAD__,  \
                                                      #__DURATION_NAME__);     \
  __DURATION_NAME__##_END.end()

這組宏僅僅是簡單的建立物件並呼叫開始和結束函數,並沒有什麼複雜的操作。為了方便大家理解,我提供了範例:

// 在程式碼中插入開始點結束點
// 生成tracing json檔案
// 使用 tracing loader 進行視覺化
int main(int argc, char **argv) {
  // 使用宏
  {
    // 任務名 執行緒名 過程名 建立開始點
    TRACING_VISUAL_B(MAIN, MAIN_THREAD, READY);
    std::this_thread::sleep_for(std::chrono::milliseconds(40));
  }

  // 自己建立
  cpp_visual::TracingDuration duration("Main", "main_thread", "hello");
  duration.begin();
  cout << "hello world!" << endl;
  std::this_thread::sleep_for(std::chrono::milliseconds(20));
  cpp_visual::TracingDuration duration2("Main", "main_thread", "hello2");
  duration2.begin();
  std::this_thread::sleep_for(std::chrono::milliseconds(20));
  duration2.end();
  duration.end();

  TRACING_VISUAL_B(MAIN, MAIN_THREAD, WORLD);
  std::this_thread::sleep_for(std::chrono::milliseconds(20));
  TRACING_VISUAL_E(MAIN, MAIN_THREAD, WORLD);

  // 測試開始和結束不在一個作用域也可以
  { TRACING_VISUAL_E(MAIN, MAIN_THREAD, READY); } // 建立結束點
  // 寫入
  std::string path = "./json_result/";
  std::string file = "result.json";
  std::filesystem::create_directories(path);

  cpp_visual::json_tool::gen_json(path + file);

  return 0;
}

生成的json如下:

[{"name":"READY","ph":"B","pid":"MAIN","tid":"MAIN_THREAD","ts":21},{"name":"hello","ph":"B","pid":"Main","tid":"main_thread","ts":33179},{"name":"hello2","ph":"B","pid":"Main","tid":"main_thread","ts":64416},{"name":"hello2","ph":"E","pid":"Main","tid":"main_thread","ts":95692},{"name":"hello","ph":"E","pid":"Main","tid":"main_thread","ts":95697},{"name":"WORLD","ph":"B","pid":"MAIN","tid":"MAIN_THREAD","ts":95723},{"name":"WORLD","ph":"E","pid":"MAIN","tid":"MAIN_THREAD","ts":126935},{"name":"READY","ph":"E","pid":"MAIN","tid":"MAIN_THREAD","ts":126940}]

我們將他放到谷歌tracing中看看吧!

效果還不錯~,不過手動選檔案還是有些繁瑣。

tracing loader

沒錯,藉助之前大佬提供的html檔案,我們有希望做出一個命令列工具,用來載入json檔案!

使用cli11庫提供命令列解析;使用cpp-httplib建立一個單頁面的伺服器端。有些這些現成的輪子,我們寫起來簡直無比輕鬆!

int main(int argc, char **argv) {
  CLI::App app("tracing loader command line tool");
  // app.add_flag("-h,--help", "print this help")->configurable(false);
  std::string file;
  app.add_option("-f,--file", file, "the tracing json file to load")
      ->capture_default_str()
      ->run_callback_for_default()
      ->check(CLI::ExistingFile);

  CLI11_PARSE(app, argc, argv);

  if (app.get_option("--help")
          ->as<bool>()) { // NEW: print configuration and exit
    std::cout << app.config_to_str(true, false);
    return 0;
  }

  if (!file.empty()) {
    cout << "the tracing file = \t" << file << std::endl;
#if OS_WINDOWS
    system("start http://localhost:8081/tracingtool.html");
    cout << "exec = \t"
         << "start http://localhost:8081/tracingtool.html" << std::endl;
#elif OS_LINUX
    system("xdg-open http://localhost:8081/tracingtool.html");
    cout << "exec = \t"
         << "xdg - open http://localhost:8081/tracingtool.html" << std::endl;
#endif
    if (std::filesystem::exists("./resource/tracing.json")) {
      std::filesystem::remove("./resource/tracing.json");
    }
    std::filesystem::copy_file(file, "./resource/tracing.json");
  }

  httplib::Server server;
  server.set_mount_point("/", "./resource");
  server.listen("0.0.0.0", 8081);

  return 0;
}

可以說,除了檢查檔案存在和複製檔案是我自己寫的,其他的程式碼隨便抄抄庫的範例程式就好了。比較煩人的是開啟瀏覽器,由於手頭也沒有一個跨平臺的openUrl函數,所以只能自己分開來寫,而且還是使用的system命令,多少有些難繃。

還記得之前的html檔案嗎?之前的html檔案採用連結傳遞引數的方式選擇json檔案,既然我們現在通過命令列手動讓使用者載入josn檔案,其實是沒必要傳遞引數的,因此我將html中的引數解析部分直接換成了固定位置的檔案讀取,所以你可以看到在上邊的程式碼中出現了一部複製檔案的操作。html中的細節我就不描述了,隊大家也沒有多少幫助,我也是個門外漢,不想說錯了產生誤導。

程式碼寫完,我們可以嘗試載入一個json檔案,這個命令列的用法是:

tracing_loader -f xxxx.json

在我自己的專案中,我測試了一下(windows測試的,所以是\)

❯ .\tracingloader.exe -f  .\json_result\result.json
the tracing file =      .\json_result\result.json
exec =  start http://localhost:8081/tracingtool.html

隨後自動開啟瀏覽器存取上邊的網址,

總結

使用紀錄檔進行效能測試繁瑣枯燥,視覺化方法可以讓我們更加輕鬆的分析效能問題,借用chrome tracing工具,我們可以輕鬆的對程式碼進行視覺化效能測試!本文提供了簡單的測試方法以及視覺化方法,希望對各位小有幫助。

倉庫地址:https://gitee.com/smalldyy/cpp-visual-tracing
注意:本文提交時,gitee正在進行開源申請,可能無法存取。近日即可解鎖。

(專案使用xmake作為構建系統,xmake很好用!)