C++ 应用性能分析的艺术

C++ 应用性能分析的艺术

分析 C++ 应用程序的艺术

简介

表演工作会吸引两种相反形式的虚荣心。一位工程师希望相信直觉就足够了,对热代码的敏锐嗅觉可以取代证据。另一种人则愿意相信分析器屏幕截图本身就是一个结论,就好像按下测量按钮将混乱转化为知识一样。这两种本能都很诱人,也会造成伤害。

C++ 中的分析非常有价值,因为 C++ 为我们提供了很大的犯错误的空间。一个缓慢的系统确实可能会遭受缓存未命中、锁争用、分配器搅动、分支密集的热循环、矢量化阻塞或太多副本的困扰。当房间里的每个人都在争论 CPU 时,它也可能正在等待 I/O。它可能花费更多时间序列化结果而不是计算结果。它可能会严重扩展,因为线程不断以没有代码注释警告我们的方式发生冲突。在如此富有表现力且如此接近机器的语言中,合理的解释会迅速增加。

这就是为什么侧写应该被理解为诚实的纪律。它教会我们用谨慎的故事取代优雅的故事。它减缓了重写的速度。它使团队不必浪费一周的时间来改进某些东西,而结果证明这些东西只解决了问题的 4%。如果做得好,它会对工程文化产生令人惊讶的人性化影响,因为它使争论不再那么戏剧化,而更具协作性。分析者成为裁判。

分析在工具打开之前开始

有用的分析会话早在收集第一个样本之前就开始了。当我们决定要回答什么问题时,一切就开始了。 “为什么程序慢?”几乎从来都不是一个足够好的问题。它太模糊,无法指导工具选择,也太模糊,无法伪造。更好的问题听起来更具体。为什么解析器更改后 p99 延迟会降低?为什么吞吐量在八个线程之后停止提高?为什么一种机器类别的表现比另一种机器类别差?为什么代码的简化会使二进制文件在负载下变慢?

问题的质量决定了其余的工作。如果症状是请求延迟的回归,我们需要有代表性的请求路径以及观察到延迟的位置的明确定义。如果症状是吞吐量停滞不前,我们需要知道 CPU、等待、内存带宽或同步是否限制了增长。如果症状是特定于机器的行为,则硬件计数器、关联性和部署差异可能比源代码本身更重要。提出好问题的行为已经是一种优化形式,因为它缩小了我们愿意犯错的范围。

这也是很多团队悄悄自我破坏的地方。他们在不切实际的负载、错误的二进制文件、玩具输入、嘈杂的环境中进行分析,以至于测量结果变得戏剧化。然后,他们以天文学的信心和天气民间传说的证据质量呈现结果。分析器没有让他们失望。他们的实验设计失败了。在表演工作中,严谨从设置线开始。

构建值得信赖的测量环境

C++ 程序在不同条件下表现出不同的个性。由于与生产无关的原因,调试构建可能看起来极其缓慢。没有符号的发布版本可能运行得足够快,但隐藏了我们需要查看的路径。一个微小的合成输入可能会完美地融入缓存,从而使糟糕的设计变得美化。处于热压力或背景噪声下的机器可能会产生感觉精确的结果,同时实际上描述了随机干扰。

一个值得信赖的环境可能是不完美的;这一定是故意的。使用最接近用户实际运行的二进制文件。将调试信息或帧指针保留在您的工具受益的地方。为程序提供真实的输入,或者至少保留真实工作负载的定性特征的输入:数据大小、分支不规则性、争用模式、分配压力和请求组合。测量平均运行时间和对系统重要的输出:尾部延迟、吞吐量、阶段时间、分配量、锁等待、缓存行为或启动时间,具体取决于问题。

做好这件事是有一种深深的善意。当工程师在诚实的情况下进行分析时,他们可以让整个团队免于为鬼魂而争斗。有缺陷的设置会让每个人都捍卫理论。一个好的设置会让理论很快消亡。这是注重性能的工程师可以给予项目的最具成本效益的礼物之一。

学会区分工作和等待

最常见的分析失败之一是将所有缓慢视为 CPU 工作。 C++ 工程师特别容易犯这种错误,因为该语言会引发低级思维。如果服务速度很慢,我们就会开始想象指令、分支、缓存行和内联决策。有时这种直觉是完全正确的。其他时候系统主要在等待:等待锁、等待队列、等待 I/O、等待过度协调的线程池、等待热循环无法通过变得稍微漂亮来修复的资源。

因此,良好的侧写从广泛开始,只有在大局清晰之后才会变得微观。采样分析器非常适合发现 CPU 时间的实际去向。跟踪工具有助于揭示问题何时真正是排序、等待或阶段交互。堆和分配工具告诉我们内存故事是否污染了其他一切。当路径确实足够热以至于未命中、分支、推测或矢量化质量值得关注时,硬件计数器就会变得有用。每个工具都是提出不同问题的一种方式。当团队提出一个问题,然后将答案解释为解决了另一个问题时,麻烦就开始了。一个熟悉的例子说明了这个陷阱。假设解析器出现在 CPU 配置文件的顶部附近。不耐烦的工程师可能会得出结论,必须重写解析器。但时间线视图可能会显示解析器看起来占主导地位,只是因为管道的其余部分经常被阻塞,从而使活动的 CPU 区域看起来比实际大小成比例地大。在另一种情况下,解析器确实很昂贵,但是分配中的一个小的有针对性的更改可以消除大部分成本,而无需任何戏剧性的重写。分析器的好处并不是它告诉我们一步要优化什么。它的天赋在于它不断地将基本工作与戏剧工作分开。

工具比解释的习惯更重要

工程师经常会问哪个分析器最好,就好像有一个普遍正确的答案一样。在实践中,更好的问题是你接下来需要什么样的真相。 CPU、VTune、Visual Studio 的分析器、Tracy、Perfetto、火焰图、Callgrind 和堆分析器各自照亮了现实的不同表面。成熟的习惯并不是工具的忠诚。这是解释性的纪律。

火焰图显示了 CPU 样本累积的位置。时间线视图显示阶段交互和等待。堆分析揭示了毒害整个路径的分配搅动。每个工具都回答一个特定的问题,但它们都不能取代对排队、分支行为或线程设计的判断。当工程师将工具的视觉吸引力误认为理解的完整性时,他们就会变得危险。

这就是为什么剖析具有艺术维度,尽管它是建立在测量之上的。艺术不是神秘主义。这是判断。它知道什么时候热点是主要的,什么时候是次要的,什么时候微基准是诚实的,什么时候它迎合了错误的工作形式,什么时候硬件计数器值得信任,什么时候它应该只引发另一次实验。它还知道何时停止向下挖掘,而是简化最初使测量变得丑陋的架构。

C++ 性能问题的特征形状

C++ 性能问题通常属于可识别的家族。有些显然是计算性的:紧密的循环做了太多的工作、糟糕的矢量化、分支繁重的热代码或与缓存交互不良的数据结构。有些是内存型的:太多的分配、不稳定的所有权模式、无偿的副本、碎片或分散热数据的布局,直到 CPU 花费更多时间等待而不是计算。其中一些是协调问题:看起来无害的锁、添加了太多额外一跳的队列、有助于平均吞吐量但同时恶化尾部行为的工作窃取设计,或者线程计数超出了体系结构保持有序的能力。

侧写之所以如此强大,是因为这些家庭经常互相伪装。内存问题可能看起来像 CPU 问题。等待问题看起来像是一个算法问题。日志记录路径可能看起来无关紧要,直到尾部延迟视图显示它污染了整个服务。看似微不足道的副本之所以重要,只是因为它发生在请求路径无法承受的地方。如果没有测量,这些相互作用很容易叙述,但很难排序。

因此,一个好的分析者会培养对比例的品味。并非所有低效率都很重要。并非所有丑陋的函数都值得挽救。并不是每个干净的函数都是无辜的。该程序告诉我们尊严和紧迫性在哪里一致,而这个地方通常不是代码审查者首先指出的地方。

误诊案例研究

想象一下一个服务,它摄取记录、对它们进行标准化、对它们进行评分并发出结果。发布后,吞吐量下降,p99 延迟恶化。房间里的第一个理论是新的评分程序引入了昂贵的数学。第二个理论是解析器现在分支太多。第三个是库升级后分配器出现退化。每个理论都足够合理,在会议上听起来很聪明。

广泛的 CPU 配置文件显示解析器和记分器都消耗可见时间,但不足以解释完整的延迟回归。时间线跟踪揭示了共享输出阶段周围的突发等待。堆分析显示请求路径末尾附近的重复分配和格式化工作。一个保留每个线程缓冲区并推迟格式化的小实验打破了等待模式并消除了令人惊讶的尾部延迟。只有在此之后,集中的 CPU 配置文件才会表明,一旦较大的瓶颈消失,记分器仍然应该对新可见的副本进行较小的清理。

这是一个普通的故事,但这正是它重要的原因。真正的侧写很少会以一个戏剧性的恶棍结束。更多时候,它揭示了一系列普通成本,每一项都被其他成本放大。原本期待电影修复的工程师却了解到系统实际上是如何退化的:通过积累、交互和被忽视的比例。这一教训比任何一次加速都更有价值,因为它改变了未来调查的开始方式。

剖析作为团队习惯

最好的团队将分析构建为审查、回归和主要设计变更。他们保留有代表性的数据集。他们保存火焰图、痕迹和基准工件以及对更改内容的解释。他们让询问提议的简化是否会改变分配、尾部延迟或阶段边界变得很正常。他们尊重绩效,在大声说话之前先对其进行衡量。

这种习惯改变了代码库的情感生活。工程师变得不那么防御性了,因为分析将问题外部化了。缓慢的系统不再是对最后一个接触代码的人的指责。它成为一个有证据的共同难题。即使是初级工程师在这种环境中也会变得更加高效,因为他们学会相信问题和实验胜过声望。以这种方式建立的绩效文化更加平静。这就是为什么分析艺术在 C++ 中如此重要。语言赋予我们构建优秀系统的能力,但卓越并不仅仅源于聪明。它产生于重复的、有纪律的注意行为。分析是工程师学习注意机器一直试图表达的内容的最佳方法之一。

动手实验室:分析一个故意低效的程序

让我们构建一个故意有点愚蠢的小程序。这很有用,因为当错误足够具体且易于发现时,真正的分析技能学得最快。

C++```cpp

include <algorithm>

include <chrono>

include <iostream>

include <mutex>

include <random>

include <string>

include <thread>

include <vector>

std::mutex g_lock;

static std::string make_payload(std::mt19937& rng) { std::uniform_int_distribution<int> len_dist(20, 120); std::uniform_int_distribution<int> ch_dist(0, 25);

std::string s;
const int len = len_dist(rng);
for (int i = 0; i < len; ++i) {
    s.push_back(static_cast<char>('a' + ch_dist(rng)));
}
return s;

}

static uint64_t score_payload(const std::string& s) { uint64_t total = 0; for (char c : s) { total += static_cast<unsigned char>(c); } return total; }

int main() { constexpr size_t N = 400000; std::vector<std::string> rows; rows.reserve(N);

std::mt19937 rng{42};
for (size_t i = 0; i < N; ++i) {
    rows.push_back(make_payload(rng));
}

std::vector<uint64_t> out;
out.reserve(N);

auto worker = [&](size_t begin, size_t end) {
    for (size_t i = begin; i < end; ++i) {
        auto copy = rows[i];
        std::sort(copy.begin(), copy.end());
        uint64_t value = score_payload(copy);

        std::lock_guard<std::mutex> guard(g_lock);
        out.push_back(value);
    }
};

const auto t0 = std::chrono::steady_clock::now();

std::thread t1(worker, 0, N / 2);
std::thread t2(worker, N / 2, N);
t1.join();
t2.join();

const auto t1_end = std::chrono::steady_clock::now();
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1_end - t0).count();

std::cout << "done in " << ms << " ms, values=" << out.size() << "\n";

}



* 重复的字符串副本
* 热路径中无需排序
* 输出上的中央锁争用
* 大量分配的字符串生成

### 构建分析

关于 C++:```bash
g++ -O2 -g -fno-omit-frame-pointer -std=c++20 -pthread -o bad_profile main.cpp
```在 Windows 上使用 MSVC:```powershell
cl /O2 /Zi /std:c++20 main.cpp
```### 第一个个人资料

关于 CPU:```bash
perf record -g ./bad_profile
perf report
```或者收集火焰图(如果这是您工作流程的一部分)。

### 你应该注意什么

良好的配置文件应该很快表明系统没有遇到任何一个神秘的问题。它正遭受着一系列非常普通的工程选择。这是正确的教训。


## 爱好者测试任务

1. 通过每个线程使用一个输出向量来删除中央 C++。重新测量。
2. 删除不必要的 CPU 并确认有多少成本是戏剧性的而非必需的。
3. 将 Linux 替换为较低副本的替代方案,并检查配置文件是否按您预期的方式更改。
4. 增加线程数量并观察吞吐量是否扩大或协调是否占主导地位。
5. 使用和不使用 Visual Studio 构建相同的程序,并比较堆栈的质量。

如果您仔细执行这五个步骤,您将学到比分析工具的名称更有价值的东西。你将会了解到一个糟糕的理论是如何在测量的存在下消亡的。


## 总结

分析 C++ 应用程序的艺术就是保持诚实的艺术。

良好的分析并不是收集最奇特的屏幕截图或记住每个硬件计数器。它涉及提出精确的问题、在现实条件下进行测量、将 CPU 工作与等待分开、理解内存行为以及针对问题的正确层使用正确的工具。

使用**抽样**来寻找广泛的CPU真相。使用**追踪**来了解时间和协调。当分配行为占主导地位时,使用**堆分析**。当缓存和猜测成为真实情况时,使用**硬件计数器**。最重要的是,在优化之前进行分析。

在 C++ 中,这一学科通常是优雅的高性能工程与昂贵的迷信之间的区别。


## 参考文献

1. Linux `perf` 手册页:[https://man7.org/linux/man-pages/man1/perf.1.html](https://man7.org/linux/man-pages/man1/perf.1.html)
2. Linux `perf-stat` 手册页:[https://man7.org/linux/man-pages/man1/perf-stat.1.html](https://man7.org/linux/man-pages/man1/perf-stat.1.html)
3.英特尔 VTune Profiler 文档:[https://www.intel.com/content/www/us/en/docs/vtune-profiler/overview.html](https://www.intel.com/content/www/us/en/docs/vtune-profiler/overview.html)
4. Visual Studio 分析功能导览:[https://learn.microsoft.com/visualstudio/profiling/profiling-feature-tour](https://learn.microsoft.com/visualstudio/profiling/profiling-feature-tour)
5. Tracy 探查器存储库:[https://github.com/wolfpld/tracy](https://github.com/wolfpld/tracy)
6.Perfetto 文档:[https://perfetto.dev/docs/](https://perfetto.dev/docs/)
7. Brendan Gregg 的火焰图:[https://www.brendangregg.com/flamegraphs.html](https://www.brendangregg.com/flamegraphs.html)
8. Callgrind 手册:[https://valgrind.org/docs/manual/cl-manual.html](https://valgrind.org/docs/manual/cl-manual.html)
9. Heaptrack 存储库:[https://github.com/KDE/heaptrack](https://github.com/KDE/heaptrack)
10.AddressSanitizer 文档:[https://clang.llvm.org/docs/AddressSanitizer.html](https://clang.llvm.org/docs/AddressSanitizer.html)
## 当系统已经处于压力下时会是什么样子

C++ 在团队希望季度安静的时刻,分析实践往往变得紧迫。一项功能已经呈现在客户面前,或者一个平台已经具有内部依赖性,并且系统选择了特定的一周来揭示其优雅的理论和运行时行为已经礼貌地分开生活。这就是为什么这么多严肃的工程工作都是从协调开始的。团队需要将其认为系统所做的事情与系统在负载、变化和截止日期下实际所做的事情相协调,这会让每个人都稍微更有创造力,但稍微不那么明智。

在生产性能工程中,最重要的情况通常是平均值隐藏的延迟峰值、不良测试工作负载掩盖的 CPU 热点以及发现得太晚的内存回归。这些情况会带来技术、预算、信任、路线图,有时甚至是声誉后果。当多个团队依赖技术问题并且没有人能够完全解释为什么它不断产生噪音、延迟和成本时,技术问题在政治上就会变得更大。

这就是为什么我们建议从运营压力和交付现实的角度来看待这个问题。设计可能在理论上很漂亮,但在操作上却是毁灭性的。另一种设计可能几乎是无聊的,但却可以使产品继续前进多年,因为它是可测量的、可修复的,并且诚实地进行权衡。认真的工程师学会更喜欢第二类。它减少了史诗般的演讲,也减少了紧急回顾,每个人都用被动语态说话,没有人记得谁批准了这条捷径。

## 始终如一的实践

第一个持久的做法是不断衡量一条有代表性的路径。团队通常会在定义将改变决策的信号之前收集广泛的遥测数据。选择真正重要的道路,反复衡量,拒绝让讨论变成装饰性的故事讲述。在围绕 C++ 分析实践的工作中,有用的度量通常是代表性工作负载、跟踪质量、热路径稳定性和结果的可重复性。一旦这些决定变得可见,其余的决定就会变得更加人性化,不再那么神秘。第二个持久的做法是将证据与承诺分开。在系统得出结论之前,工程师常常被迫说方向是正确的。抵制这种压力。首先建立一个狭义的证明,尤其是当主题与客户或金钱密切相关时。经过验证的小改进比未经验证的大目标具有更大的商业价值。这听起来很明显,直到季度末审查将假设变成最后期限,并且整个组织开始将乐观情绪视为日程安排工件。

第三个持久的做法是用所有权语言撰写建议。一段说“提高绩效”或“加强界限”的段落在情感上令人愉快,但在操作上毫无用处。一个段落说明了谁更改了什么、按什么顺序、在什么回滚条件下更改,这些内容实际上在周一早上仍然存在。这是很多技术写作失败的地方。它希望听起来先进,而不是可调度。

## 节省时间的反例

当地的成功并不能证明已经准备好迎接更艰难的环境。在扩展这个想法之前,团队必须升级测量规则并证明相同的行为在更大的压力下仍然存在。

另一个反例是工具通货膨胀。一个新的分析器、一个新的运行时、一个新的仪表板、一个新的代理、一个新的自动化层、一个承诺协调旧包装器的新包装器。这些事情本质上都不是坏事。问题是当他们被要求补偿一个没有人明确命名的边界时会发生什么。然后,系统就会变得更加仪器化、更加令人印象深刻,并且只是偶尔更容易理解。团队很快就能感受到这一点。即使没有这样的措辞,他们也能察觉到堆栈何时成为决策的昂贵替代品。

第三个反例是将人工审查视为自动化的失败。在实际系统中,人工审查通常是保持自动化在商业上可接受的控制措施。成熟的团队知道在哪里积极自动化以及在哪里保持批准或解释可见。不成熟的团队希望机器做所有事情,因为“一切”在幻灯片中听起来都很高效。然后第一个严重事件到来,突然之间,带着转换体验的诚意重新发现了手动审核。

## 我们推荐的交付模式

好的工作首先是通过足够强大的技术阅读来减轻压力,以阻止循环辩论。下一个有界实施改进了一条重要路径,并且复测使工程和领导层能够清楚地了解方向。这个顺序比确切的工具选择更重要,因为它将技术技能转化为前进的动力。

实际上,我们建议缩小第一个周期:收集工件,产生一个硬诊断,发布一个有限的更改,复测真实路径,并用简单的语言编写下一个决策。简单的语言很重要。团队很少会后悔清晰度。团队常常会在收据到达之前后悔给自己留下了深刻的印象。

这也是语气很重要的地方。强大的技术工作听起来应该像以前遇到过生产一样。冷静、精确、精确地进行权衡和证据。该音调携带操作信号。它表明该团队了解系统工程的古老真理:机器速度很快,路线图很脆弱,迟早要为每一个允许保持诗意的假设付出代价。
## 在准备就绪之前我们将使用的清单

在生产性能工程中,准备状态不是一种情绪。这是一个带有后果的清单。在我们称围绕 C++ 分析实践的工作已准备好进行更广泛的推广之前,我们希望以尽可能最好的方式让一些事情变得无聊。我们想要一条在代表性负载下表现可预测的路径。我们想要一组不矛盾的测量结果。我们希望团队知道边界在哪里以及打破它意味着什么。我们希望工作的输出足够清晰,以便实施室之外的人仍然可以从中做出正确的决定。

该清单通常涉及代表性工作负载、跟踪质量、热路径稳定性和结果的可重复性。在昂贵的意外事件投入生产之前,使用该清单来测试解释质量、现场弹性和回滚清晰度。

这也是团队发现他们是否正在解决真正的问题或只是在其周围排练能力的地方。许多技术努力都感觉很成功,直到有人要求提供可重复性、生产证据或会影响预算的决定。在那一刻,弱的工作变得模糊,而强的工作变得异常简单。平淡就好。平淡通常意味着系统已经不再依赖魅力。

## 我们建议如何谈论结果

最终的解释应该足够简短,以便在领导会议上通过,并且足够具体,以便在工程审查中通过。这比听起来更难。过于技术性的语言隐藏了顺序。过于简化的语言隐藏着风险。正确的中间立场是以一种听起来平静而不是胜利的方式描述路径、证据、有限的变化以及下一步建议的步骤。

我们推荐这样的结构。首先,说明评估了什么路径以及它为何重要。其次,说出该路径的错误或不确定之处。第三,说明更改、测量或验证的内容。第四,说出尚未解决的问题以及下一步投资将购买什么。这种结构之所以有效,是因为它尊重工程和购买行为。工程师想要细节。团队需要排序。当下一步明确时,每个人都会受益。以这种方式说话的隐藏好处是文化。清楚地解释技术工作的团队通常也会更清楚地执行它。他们不再将模糊性视为复杂性。他们变得更难用行话打动人心,而更容易通过困难的系统获得信任。这是工程成熟度中最被低估的形式之一。
## 我们仍然拒绝伪造的内容

即使在系统改进之后,成熟的团队也会在生产性能工程中保持不确定性。弱测量需要更清晰的证据,硬边界需要简单的语言,更平静的演示需要真正的操作准备。必须减少一些不确定性;有些必须诚实地命名。令人困惑的是这两项工作是如何令人尊敬的项目变成了昂贵的寓言。

同样的规则适用于围绕 C++ 分析实践的决策。如果团队仍然缺乏可重复的基准、值得信赖的回滚路径或关键接口的明确所有者,那么最有用的输出可能是更明确的“否”或更窄的下一步,而不是更大的承诺。这种纪律使技术工作与其要改进的现实保持一致。

以这种方式工作会有一种奇怪的轻松感。一旦系统不再依赖乐观的故事讲述,工程对话就会变得更简单,即使工作仍然很困难。在生产中,这通常被视为一种小形式的恩典。
## 关于分析工作的附加说明

良好的分析结果可以缩小决策范围。当工作移交时,团队应该知道哪些工作负载具有代表性,哪些热点是因果关系,哪些发现是噪音,以及哪些优化值得首先接触。这听起来很严厉,但严厉在这里很有用。当每个人都能看到热度并且没有人能就哪一场火重要达成一致时,性能工作就变得昂贵。

我们还建议写下未修复的问题。这是一门异常强大的学科。明确说明哪些可疑函数被测量和免除,哪些分配器理论没有留下痕迹,以及哪些戏剧性的重写建议被证明是不必要的。当死胡同被命名时,工程师们会变得更加平静。当领导层看到团队根据证据而不是情绪进行优化时,就会变得更加冷静。在 C++ 系统中,平静被低估了。它通常伪装成测试工具和充满不那么浪漫的事实的笔记本。
Philip P.

Philip P., CTO

返回博客

联系方式

开始对话

几条清晰的线条就足够了。描述一下系统、压力、被阻碍的决策。或者直接写信给midgard@stofu.io。

0 / 10000
未选择文件