c++可视化性能测试

阅读前注意

本文所有代码贴出来的目的是帮助大家理解,并非是要引导大家跟写,许多环境问题文件问题没有详细说明,代码也并不全面,达不到跟做的效果。建议直接阅读全文即可,我在最后会给出详细代码地址,对源代码细节更感兴趣的同学可以下载参考。

性能测试:使用日志

在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

img

此网页可接受一个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": "第二天"}}

]

不方便测试的同学也没关系,结果是这样的:
img

点击对应的条目,下方还会出现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中看看吧!
img

效果还不错~,不过手动选文件还是有些繁琐。

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很好用!)

posted @ 2022-06-18 18:07  Smalldy  阅读(1107)  评论(0编辑  收藏  举报