在现代软件开发中,尤其是在高性能实时应用程序中,比如游戏开发、高频交易以及嵌入式系统,写出能运行的代码仅仅是个开始。瓶颈、内存泄漏与热点问题,通常会极大地拖慢系统,有时甚至会致使代码无法使用。这就是代码优化、调试以及性能分析发挥作用的地方。
在本文中,我们将主要聚焦于:
1.代码性能分析是什么
2.它为何重要
3.可用的工具和技术
4.以及在处理非常小的代码库时,可以使用的一个示例
什么是代码性能分析?
代码性能分析乃是分析程序,用以从执行时间、内存使用、函数调用以及其他指标等方面来衡量其性能的一个过程。这有助于我们去识别:
热点:消耗 CPU 时间最多的函数和代码块
频率:递归调用次数异常多的函数
低效内存使用:存在内存泄漏或过度分配的区域
缓慢的函数或者循环:一般来讲,那些得进行优化从而提高整体速度的函数。
这种分析使开发者能够明确,哪些部分对应用程序性能的拖累最大,从而集中精力进行优化,而不是随意进行更改。
为什么我们要对 C++代码进行性能分析?
C++因为它的高性能而被大家熟知,不过呢这一般也会带来更高的复杂性。像那些过度地在堆上分配内存、循环效率低以及对标准模板库(STL)容器使用不当等情况,都有可能让你的程序运行速度变慢。对 C++代码进行性能分析的一些缘由包含:
性能优化:即识别出运行速度相对较慢的代码路径了,接着对这些路径加以改进了,从而减少执行时间。
资源管理:把内存泄漏问题给减少了,这个时候也把内存使用的效率提高了。对资源进行优化管理以后,能实实在在地减少内存泄漏的状况,这样就能让内存使用的效率得到提升。在这个过程里,不但可以降低内存泄漏的可能性,还能够让内存的利用效果变得更好。
可扩展性:需让你的应用程序拥有能处理更大工作量的本事。
更好的用户体验:快速的软件能直接提升用户满意度
用于 C++代码性能分析的工具
大体上来说,有两类主要的性能分析器,它们各自具有不同的优势和适用场景:
采样型性能分析器:周期性捕获程序状态快照,轻量级且开销极小,适合长时间运行的应用程序。但对于生命周期很短的函数,可能无法提供准确的信息。例如“Very Sleepy”、谷歌性能分析工具(gperftools)、Visual Studio 性能分析器以及英特尔 VTune 放大器。
插桩型性能分析器:为每个函数调用和代码块捕获详细且准确的数据,提供更精确结果,但开销较高,不适合生产环境。例如包括 GNU 性能分析器(gprof)、Callgrind、Tracy、Remotery 和 Microprofile。
数据颗粒度分析
数据颗粒度,是指在进行性能分析之时所捕获数据的详细程度。不同工具,提供不同级别的数据颗粒度,这对于理解工具选择以及适用场景,非常有帮助。例如:
高颗粒度:插桩型工具一般会给出,行级别的数据,所以开发者可以清楚地知晓,每一行代码的执行时间。如此一来,在识别特定函数内部的问题时,就变得非常关键了。
低粒度:采样型工具给出相对来说比较低粒度的数据,重点把注意力放在函数级别的信息上面。这种办法对于长时间运转的应用程序是合适的,不过呢可能没办法及时察觉到短生命周期函数里存在的问题。
如何为你选择合适的 C++性能分析器?
挑选合适的性能分析器得依据你的具体需求。在作决定之际,要考量以下几个因素:
分析类型:你需要关于热点的高层次信息,还是需要详细时间信息?
平台兼容性:并非所有性能分析器都能在所有平台上运行。例如 gprof 和 Callgrind 仅在 Linux 环境中可用,而 Visual Studio 性能分析器仅适用于 Windows。
易用性:设置是否困难?学习曲线陡峭吗?
成本:是否免费使用或在前需要许可证?
考虑到这些因素,你或许想要使用采样型性能分析器,从而获取高层次的见解;而插桩型性能分析器则更适宜,对关键部分进行深入的分析。建议去尝试不同的工具,以便找到最适合你需求的那一款。
实际经验分享
在我的开发进程中,我曾遭遇过因为未恰当使用 STL 容器而致使程序明显变慢的状况。借助使用 gprof,我可以迅速地辨别出哪些函数占用了过多的 CPU 时间,并且有针对性地展开了优化。最终我不但提升了程序的效率,还降低了内存的使用量。这样的实践经验凸显了性能分析的重要性。
教程与实例
为了深入介绍如何使用每一种工具,这篇文章将重点关注两种特别适用于小型项目的性能分析方法——使用 gprof 以及编写一个自定义计时类。像“Valgrind”或“英特尔 VTune”这类综合性工具,通常是为更大、更复杂的项目而准备的,因此在这里不再涉及它们。对于小项目或者应用程序而言,在使用功能极为全面的性能分析器时,有时或许会显得过于复杂了。
让我们通过实现一个想要进行性能分析的示例来开始。正如你们有些人可能知道,由于堆分配次数的问题,std::make_shared()比std::shared_ptr()快得多。我们就对此进行性能分析。
// main.cpp
#include <array>
#include <iostream>
#include <memory>
struct Vec3 {
double x;
double y;
double z;
};
int main() {
std::array<std::shared_ptr<Vec3>, 1000000> shared_ptrs;
{
std::cout << "Profiling std::make_shared \n";
for (int i = 0; i < shared_ptrs.size(); i++)
shared_ptrs[i] = std::make_shared<Vec3>();
}
{
std::cout << "Profiling new allocation of std::shared_ptr \n";
for (int i = 0; i < shared_ptrs.size(); i++)
shared_ptrs[i] = std::shared_ptr<Vec3>(new Vec3());
}
return 0;
}
使用计时器类进行性能分析
为了对上述代码进行性能分析,我们首先创建一个作用域计时器(ScopedTimer)类。利用资源获取即初始化(RAII)原则,该类一旦超出作用域,就会调用析构函数并打印出执行时间:
// Scoped Timer Class
#define TIMEIT() \
ScopedTimer<> timer { \
__func__ \
}
#include <chrono>
#include <iostream>
using namespace std;
template <typename Resolution = std::chrono::microseconds,
typename ClockType = std::chrono::steady_clock>
class ScopedTimer {
public:
ScopedTimer(const char* func_name) : func_name_(func_name),
start_(ClockType::now()) {}
// delete all copy, move and assignment constructors
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
ScopedTimer(ScopedTimer&&) = delete;
ScopedTimer& operator=(ScopedTimer&&) = delete;
~ScopedTimer() {
const auto& duration =
std::chrono::duration_cast<Resolution>(ClockType::now() - start_).count();
printf("%s => %lu us\n", func_name_, duration);
}
private:
const char* func_name_;
const typename ClockType::time_point start_;
};
我们现在,能够把它归入之前提及过的那些例子里;具体情况如下:
{
std::cout << "Profiling std::make_shared : ";
TIMEIT();
for(int i = 0; i < shared_ptrs.size(); i++)
shared_ptrs[i] = std::make_shared<Vec3>();
}
{
std::cout << "Profiling new allocation of std::shared_ptr : ";
TIMEIT();
for(int i = 0; i < shared_ptrs.size(); i++)
shared_ptrs[i] = std::shared_ptr<Vec3>(new Vec3());
}
在编译并运行之后,我们会得到如下输出:
Profiling std::make_shared:main => 37502 usProfiling new allocation of std::shared_ptr:main => 83734 us
即使再多运行几次,结果也是一致的,std::make_shared的性能始终优于新分配方式。
边缘情况
当使用如此这般的计时器类之际,务必明确恰当的作用域范围,与此同时验证正在被分析的代码是精确无误且未曾经过优化的,这一点极为重要。以下是一个简略的示例用以阐明此点:
int main() {
int value = 0;
{
// 堆代码 duidaima.com
TIMEIT();
for(int i = 0; i < 1000000; i++)
value += 2;
}
std::cout << value << std::endl;
return 0;
}
表面上看,一切似乎都显得是正确的;不过当我们进行编译并且查看这段代码的汇编之时,将会看到如下的情况:

在这种状况下,我们留意到每回循环迭代的时候,value 会增长 2。所以计时器所测量的恰好就是我们期望的成果。所有事情都依照预想的那般顺利开展着。
现在考虑用“-O3”这个标志,或者在发布模式下把同一个程序给编译了。这时候编译器会对代码进行优化。要是对相同的代码块进行反汇编操作,可能就会像是这样:(优化后编译代码反汇编内容)

在这儿我们察觉到,没进行明显的加法操作;而是编译器径直把十六进制值 1E8480 压进栈里。把它换算成十进制,就是 2000000,这表明编译器早已算好结果,只是把它压进栈里,这样就彻底省掉了日前的计算过程,让计时器没法发挥作用了。
用 GNU 性能分析器(gprof)来做性能分析
尽管 gprof 是一个功能完备的工具,但其输出,并不总是易于解读。为了更好地实现可视化,我极力推荐运用 gprof2dot 工具,该工具能够把 gprof 的输出转化为点图(dot graph)。让我们重新回顾一下之前的示例。因为我们正在使用“gprof”,所以无需包含额外的打印语句,也无需使用作用域计时器类或者任何其他代码。
你要做的就是,运用“-pg”这个标志,接着借助“g++”去对“main”进行编译。
一个示例命令如下所示:
g++ -o0 -pg main.cpp -o main
这会生成两个输出文件,分别是[i]主文件和[ii]“gmon.out”。
要查看性能分析结果,只需运行gprof main gmon.out。
(示例程序 gprof 输出内容)

如上述所见,输出不是很易读,这就凸显了使用 gprof2dot 的必要性,如下所示:
gprof main gmon.out | gprof2dot -s -w | dot -Tpng -o output.png
这将为我们生成一个漂亮的执行图(如下图所示),突出显示在每个函数/代码块上花费的时间。如需查看此类图更实际示例,可以参考 gprof2dot 仓库。
(使用 gprof2dot 工具时 gprof 输出图)
关键注意事项
虽然大部分性能分析工具能直接使用,但在对代码进行评估时,找到合适的平衡很关键,既不能分析得不够,也不能分析得过多。在给你的应用程序做性能分析时,要记住下面这些建议:
热点分析:关注消耗时间最多函数或循环。
调用图分析:就是要弄明白函数调用的层级关系,与此同时把效率低的调用路径给找出来。
内存性能分析:检查堆分配、内存泄漏以及不必要对象拷贝。除此之外,要确保性能分析是在实际工作负载下进行,而不是在虚拟场景中。
测试增量变更:每次改完之后,都得做性能测试,保证相应的改进能实现。
确认正确的代码:需保证所分析的是准确且未被优化过的代码。上一节里,若编译器进行了,优化,我们或许会见到不正确的结果示例。认真验证代码的正确性,就能避免因编译器优化而致的误导。之前的讨论中,一旦编译器执行了优化操作,就有出现与预期不同结果的可能。
多种工具一起用:有的时候,单单一种办法可能不够,所以呢可以试试多种工具,这样就能全方位地了解你的代码行为啦。
总结
性能分析在开发高性能 C++应用程序中起着至关重要作用。通过利用合适工具并了解其在前功能,你可以发现关于代码行为关键见解,识别瓶颈,并针对速度和效率进行优化。精准定位热点、减少低效内存使用以及微调缓慢函数是确保你的应用程序发挥最佳性能关键步骤。利用这些工具与技术,对你的代码进行有效的性能分析吧。从今日开始,记住在优化之前,一定要先进行衡量!