1

c++可视化性能测试 - Smalldy

 1 year ago
source link: https://www.cnblogs.com/xdblog/p/cpp-visual-tracing.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

阅读前注意

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

性能测试:使用日志

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK