阅读前注意
本文所有代码贴出来的目的是帮助大家理解,并非是要引导大家跟写,许多环境问题文件问题没有详细说明,代码也并不全面,达不到跟做的效果。建议直接阅读全文即可,我在最后会给出详细代码地址,对源代码细节更感兴趣的同学可以下载参考。
性能测试:使用日志
在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的可视化方案
我的方案是:
- 提供一种方法,可插入过程开始点,插入过程结束点,保存json文件,用于进行性能测试并生成结果。
- 提供一个加载程序,该程序可以临时搭建一个网页服务端,加载程序读取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很好用!)