本文所有程式碼貼出來的目的是幫助大家理解,並非是要引導大家跟寫,許多環境問題檔案問題沒有詳細說明,程式碼也並不全面,達不到跟做的效果。建議直接閱讀全文即可,我在最後會給出詳細程式碼地址,對原始碼細節更感興趣的同學可以下載參考。
在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++好做視覺化的東西嗎?靠!?難不成還要上正規表示式嗎?
可惡!不想幹啦!
全文完
全文還沒完!世界還沒毀滅呢!
是的!你想到的東西大部分都會有現成的實現,如果你有谷歌瀏覽器的話,你可以嘗試在位址列輸入以下地址:
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什麼還是有點大費周章了,並且我也並不熟悉這個領域,因此我將採用其它辦法。
我的方案是:
方案確定,開始實施!
首先是目標1,提供一種方法,可插入過程開始點,插入過程結束點,儲存json檔案,用於進行效能測試並生成結果。
在具體實施之前,我們有必要了解下tracing json的格式,一個 tracing json檔案內可包含甚多‘事件’,‘事件’的種類很多,不同的事件最終視覺化的顯示效果也不近相同,我們的效能測試場景只需要給出一段段過程的視覺化顯示,所以用到的事件並不多。
關於其他未使用到的時間,感興趣的同學可以存取網站:https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/edit 地址在牆外。
我們用一個事件表示一個過程的開始,一個事件表示過程的結束,有開始和結束就能描述所有測試點了。
我們需要使用的事件在上邊的例子中並沒有出現,在這裡我詳細介紹一下我們需要了解的欄位。
好了,我們瞭解這麼多就夠了,接下來,我將會實現一些方法/類,來輔助我們在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中看看吧!
效果還不錯~,不過手動選檔案還是有些繁瑣。
沒錯,藉助之前大佬提供的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很好用!)