性能剖析 C++ 程序:把工具交到你手上
本文要回答的问题
- 剖析 C++ 程序和剖析 C 程序有什么不一样?难点在哪?
- 为什么
-O0剖出来的热点,在-O2下完全不是那么回事? - perf 怎么一步步从"整个程序"缩小到"某一个函数"再到"某一行"?
- C++ 程序常见的性能坑有哪些?虚函数、string、容器、拷贝,哪个最致命?
- 拿到热点之后,正确的优化循环是什么?
一、为什么 C++ 的剖析要单独讲
本站的核心是 Linux CPU 性能剖析,前面的文章已经把 top、perf、火焰图讲透了。但 C++ 程序的剖析确实值得单独开一篇——不是因为工具不同,而是因为C++ 的"性能坑"长得和 C 不一样。
C 程序的热点通常很直白:一个 memcpy、一个排序、一个紧循环。C++ 程序的热点则往往藏在语言的"便利"里:
- 虚函数调用是间接跳转(vtable 查表),编译器没法预测目标地址,分支预测器也失效;
std::string拼接、std::vector扩容背后是隐藏的堆分配与内存拷贝,源码上看不见;std::unordered_map查找是哈希计算加桶遍历,还有缓存不友好;- RAII 析构、异常栈展开在出错的路径上成本惊人;
- 模板实例化代码量大,但热点可能被内联得面目全非。
换句话说:C++ 剖析的难点不是"怎么测",而是"热点为什么在这"——语言抽象层太厚,CPU 时间经常花在你看不见的角落。所以这篇文章的实测选择了一个特殊的观察角度:造三个"你认为的热点",让 perf 告诉你真实的热点在哪。
二、剖析的第一步:编译选项
工具再强,前提是程序里得有符号。剖析 C++ 程序,两个编译选项是命根:
g++ -O2 -g -fno-omit-frame-pointer -o hotspot hotspot.cpp-g:生成调试符号,perf 才能把地址翻译成函数名 + 源文件行号,perf annotate才能展示行级热点。没有它,火焰图里全是0x7f9a...之类的裸地址。-fno-omit-frame-pointer:保留帧指针。默认-O2会省略帧指针来省一个寄存器,但这会让 perf 的调用栈回溯失效——火焰图会塌成一条线。代价极小(一个寄存器),收益是完整的调用链。-O2:真实场景的优化级别。千万别拿-O0的结果当真相——-O0下编译器不内联、不优化,热点分布和线上运行完全是两码事。
-O2 与 -O0 的画像差异,对 C++ 尤其致命:内联、去虚化(devirtualization)、循环优化会彻底改写热点分布。下面实测里会看到活生生的例子。
三、实测:perf 从"程序"定位到"函数"
实验程序(demos/cpp-expert/perf-profiling-cpp/hotspot.cpp)制造了三个"设计好的"热点,作者直觉认为:
- 虚函数派发(
virtual_work):每次循环都走 vtable 间接跳转,应该最贵,约占 50%; - unordered_map 查找(
map_work):哈希加桶遍历,约占 30%; - string 拼接(
string_work):反复扩容拷贝,约占 20%。
编译 -O2 -g -fno-omit-frame-pointer 后跑 perf:
perf record -g ./hotspot
perf report --stdio实测结果(AMD EPYC 7K62,GCC 10.2.1,431 个采样):
76.10% __libc_start_main
|--41.07%--map_work # unordered_map 查找——实际最大热点
|--8.82%--string_work # string 拼接
|--8.12%--virtual_work # 虚函数派发——远低于直觉直觉全军覆没。作者认为最贵的虚函数派发只有 8%,认为次贵的 map 查找反而占 41%。原因有两层:
- 去虚化(devirtualization):
-O2下 GCC 对shapes[i % 3]->area()这类调用做了优化——当编译器能证明调用点实际只会命中少数几个类型时,会把它展开成类型判断加直接调用(甚至纯直接调用)。间接跳转的代价被编译器悄悄吃掉了,这正是专家层《虚函数、虚表与多态机制》里讲过的"devirtualization 去虚化"在真实性能画像上的体现。 - 哈希查找改不了:
unordered_map的哈希计算、桶定位、缓存不友好的内存访问,属于标准库的通用代码,编译器没法替它优化——它"侥幸"成为最大热点。
这个结果就是全篇最重要的论点:写 C++ 不能靠直觉猜热点,-O2 的优化会让你的心智模型失效。perf 不是锦上添花的工具,是判断热点在哪的唯一可靠途径。
四、perf annotate:从函数到行
perf report 告诉你热点在哪个函数,perf annotate 则把镜头推进到源代码行:
perf annotate --stdio | sed -n '/map_work/,+25p'输出里每一行汇编/源码旁边都有采样占比百分数,能直接看到热点集中在哪个循环体的哪条指令——是哈希取模,还是桶链表遍历。配上 -g 的行号信息,你甚至能看到是 m[i % 1000] 这一行在烧 CPU。
行级定位的意义在于:函数级告诉你"哪里",行级告诉你"为什么"。一个函数占 40% 时间,可能是循环体本身慢,也可能是被它调用的内联代码慢——annotate 把两者分开,你才知道该优化算法(换容器)还是优化实现(减少分配)。
五、C++ 特有的热点模式
实测验证了"别猜"之后,还是值得把 C++ 程序最常见的几个热点模式列出来,作为排查时的"先看这里"清单:
| 模式 | 为什么慢 | 排查手段 | 常见解法 |
|---|---|---|---|
| 虚函数密集调用 | vtable 间接跳转 + 分支预测失效 | annotate 看是否被去虚化 | 减少虚调用、final 类、if-else 替代 |
| string/vector 反复扩容 | 隐藏的堆分配 + 拷贝 | perf 看 operator new/memcpy 占比 | reserve() 预分配、string_view |
| unordered_map/set | 哈希计算 + 缓存不友好 | 热点在 _M_find_before_node 等 | 换 flat_hash_map、普通数组下标 |
| 深拷贝传递 | 拷贝构造函数消耗 | 看 memcpy 占比 | 移动语义、传引用、返回值优化 |
| 异常频繁抛出 | 栈展开成本高 | 热点在 __cxa_throw | 用错误码代替异常控制流 |
| 模板元编程实例化 | 编译期膨胀,运行期多内联 | 体积/编译耗时异常 | 减少实例化组合、extern template |
这六个模式有一个共同点:源码里看不到成本。s += "abc" 一行,背后是分配-复制-释放的循环;m[i] 一行,背后是哈希表的三级内存访问。这就是为什么剖析 C++ 时,perf report 里 operator new、memcpy、__libc_calloc 这类"系统库符号"的占比往往比你的业务函数还高——它们不是别人的代码,是你代码的隐藏成本。
六、优化循环:测 → 改 → 再测
拿到热点后的正确流程,和本站主线讲的完全一致,只是对 C++ 多两条纪律:
- 先改最热的一个点,改完重新
perf record对比。一次改多个,分不清谁起作用。 - 每步都保留 -O2 复测。
-O0下"优化"出来的效果,在-O2下可能是负优化。 - 用火焰图看整体。
perf record -g+ 火焰图脚本,一眼看出调用链上哪条路径最宽——注意火焰图顶部的"平顶"往往是内联的库函数,点开才能看到真正的业务代码。

上图是 C++ 性能剖析的标准循环:编译选项打底(-O2 -g -fno-omit-frame-pointer),perf record 采样,report 缩小到函数,annotate 深入到行,改最热的一处,回到采样再测。每轮只改一处、每轮都复测,这个循环走对了,性能优化的绝大部分收益都在里面。
C 对照
| 维度 | C | C++ |
|---|---|---|
| 热点来源 | 算法本身、memcpy、系统调用 | 算法 + 隐藏的抽象成本(虚表/分配/容器) |
| 编译要求 | -O2 -g -fno-omit-frame-pointer | 相同(C++ 更依赖,符号被内联/去虚化打散) |
| 剖析工具 | perf / gprof | 完全相同,工具不分语言 |
| 系统库符号 | memcpy、malloc | 外加 operator new、__cxa_throw、vtable 相关 |
| 直觉可靠性 | 中等(代码直接映射汇编) | 低(抽象层厚,优化幅度大) |
C 和 C++ 用同一套工具,差别全在"热点从哪来":C 代码基本一眼能看出成本,C++ 的成本藏在语言特性里,只能靠实测。
一句话总结
剖析 C++ 程序,第一步是 -O2 -g -fno-omit-frame-pointer 编译保证符号,第二步是 perf 从函数级缩到行级,第三步是牢记"直觉不可靠"——-O2 的去虚化和内联会彻底改写热点分布,唯一的真相在 perf report 的百分比里,而 C++ 最常见的隐藏成本(虚函数、分配、容器)恰恰是源码里看不见的那部分。
上一篇:内存序与 atomic 深入 下一篇:00-C++ 环境与第一个程序(C++ 系列闭环:入门 64 篇 → 高手 20 篇 → 专家 12 篇,从这里重新开始一轮)