文章标题:当代CPU性能剖析的五种优化途径
5 性能分析办法
当你开展高级优化工作,比如将更优算法整合进应用程序时,往往能轻易察觉性能是否提升,因为基准测试结果通常很明晰。从性能剖析角度看,像2倍、3倍这样的大幅提速较为显著。当你从程序中删除大量计算时,预期能看到运行时间的明显变化。
然而,在某些情形下,当你看到执行时间有细微变化,例如5%时,却不明其缘由。仅靠计时或吞吐量测量无法解释性能的升降。此时,我们需要深入探究程序的执行情况。这就需要进行性能剖析,以明晰观察到的速度变化的根本原因。
性能剖析如同侦探工作。要解开性能之谜,需收集所有可能的数据并尝试构建假设。一旦有了假设,就要设计实验来验证或推翻它。在找到线索前,可能要反复多次。就像优秀侦探,要尽可能多地收集证据来支撑或反驳假设。有了足够线索,就能对观察到的行为给出令人信服的解释。
刚开始处理性能问题时,你可能仅有测量数据,比如代码更改前后的数据。依据这些测量结果,你判定程序变慢了X%。若你知晓程序变慢发生在某提交之后,这或许已为你解决问题提供了足够信息。但若你没有良好参考点,那么导致速度变慢的可能原因众多,你需收集更多数据。收集此类数据最常用方法之一是对应用程序进行剖析并查看热点。本章将介绍该方法及其他几种经实践证明在性能工程中很有用的数据收集方法。
接下来的问题是:“有哪些可用性能数据以及如何收集这些数据?”堆栈的硬件层和软件层都有跟踪性能事件并在程序运行时记录的功能。这里,硬件指执行程序的CPU,软件指操作系统、程序库、应用程序自身以及用于分析的其他工具。通常,软件栈提供时间、上下文切换次数和页面故障等高级指标,而CPU监控缓存未命中、分支预测错误和其他CPU相关事件。根据要解决的问题,有些指标比其他指标更有用。所以,并非硬件指标总能为我们提供更精准的程序执行概览,它们只是不同而已。有些指标,比如上下文切换次数,CPU无法提供。性能剖析工具(如Linux perf)能同时利用操作系统和CPU的数据。
性能工程师可能会用到数百种数据源。本章主要聚焦硬件级信息的收集。我们将介绍一些最常用的性能剖析技术:代码插桩、跟踪、特性分析、采样和Roofline模型。我们还会探讨静态性能剖析技术和编译器优化报告,这些技术无需运行实际应用程序。
5.1 代码插桩(Code Instrumentation)
最早出现的性能分析办法之一便是代码插桩。它是一种在程序里添加额外代码来收集特定运行时信息的技术。下面有个简单的例子:在函数起始位置插入printf语句,用以表明该函数是否被调用。然后运行程序并统计输出中“foo被调用”的次数。或许每个程序员在职业生涯中都至少做过一次这样的事。
int foo(int x) {
+ printf("foo is called\n");
// 函数体...
}
行首的加号表示该行是新增的,原始代码中并无此内容。一般而言,插桩代码并非要推送到代码库中,而是用于收集所需数据,之后可删除。
下面是一个更有趣的代码插桩示例。在这个虚构的代码示例中,函数findObject在地图上搜索具有某些属性p的对象的坐标。所有对象最终都会被找到。函数getNewCoords返回作为参数提供的较大区域内的新坐标。函数findObj返回当前坐标c找到正确对象的置信度。若置信度高于阈值,我们调用zoomIn来找到更精确的对象位置;否则,我们获取搜索区域内的新坐标以便下次搜索。
工具代码由两个类构成:直方图和增量器。前者追踪我们感兴趣的变量值及其出现频率,然后在程序结束后打印直方图。后者是一个辅助类,用于将数值推送至直方图对象中。在该假设场景中,我们添加了插桩,以了解找到对象前放大的频率。变量inc.tripCount计算循环在退出前的迭代次数,变量inc.zoomCount计算。
+ struct histogram {
+ std::map<uint32_t, std::map<uint32_t, uint64_t>> hist;
+ ~histogram() {
+ for (auto& tripCount : hist)
+ for (auto& zoomCount : tripCount.second)
+ std::cout << "[" << tripCount.first << "]["
+ << zoomCount.first << "] : "
+ << zoomCount.second << "\n";
+ }
+ };
+ histogram h;
+ struct incrementor {
+ uint32_t tripCount = 0;
+ uint32_t zoomCount = 0;
+ ~incrementor() {
+ h.hist[tripCount][zoomCount]++;
+ }
+ };
Coords findObject(const ObjParams& p, Coords c, float searchRadius) {
+ incrementor inc;
while (true) {
+ inc.tripCount++;
float match = findObj(c, p);
if (exactMatch(match))
return c;
if (match > threshold) {
searchRadius = zoomIn(c, searchRadius);
+ inc.zoomCount++;
}
c = getNewCoords(searchRadius);
}
return c;
}
我们缩小搜索区域的次数(调用zoomIn)。我们总是期望inc.zoomCount小于或等于inc.tripCount。
findObject函数会在各种输入情况下被多次调用。下面是运行仪器程序后可能出现的输出结果:
// [tripCount][zoomCount]: occurences
[7][6]: 2
[7][5]: 6
[7][4]: 20
[7][3]: 156
[7][2]: 967
[7][1]: 3685
[7][0]: 251004
[6][5]: 2
[6][4]: 7
[6][3]: 39
[6][2]: 300
[6][1]: 1235
[6][0]: 91731
[5][4]: 9
[5][3]: 32
[5][2]: 160
[5][1]: 764
[5][0]: 34142
方括号中的第一个数字是循环的行程计数,第二个数字是我们在同一循环中进行的zoomIns次数。列号后的数字是该数字组合的出现次数。
例如,有两次我们观察到7次循环迭代和6次zoomIns,有251004次循环运行了7次迭代但没有zoomIns,以此类推。然后,你可以绘制数据图以获得更好的可视化效果,或者采用其他统计方法,但我们可以得出的主要结论是,zoomIns并不频繁。调用findObject的总次数约为40万次;我们可以通过对直方图中的所有桶求和来计算。如果我们将所有zoomCount不为零的水桶相加,得出的结果约为10k;这就是zoomIn函数被调用的次数。因此,每调用一次zoomIn,我们就要调用40次findObject函数。
本书后面几章将举例说明如何利用这些信息进行优化。在我们的案例中,我们得出结论:findObj经常找不到对象。这意味着循环的下一次迭代将尝试使用新坐标来查找对象,但仍在同一搜索区域内。了解到这一点后,我们可以尝试进行一些优化:1)并行运行多个搜索,如果其中任何一个搜索成功,则同步运行;2)预先计算当前搜索区域的某些内容,从而消除findObj内部的重复工作;3)编写一个软件流水线,调用getNewCoords生成下一组所需的坐标,并从内存中预取相应的地图位置。本书第二部分将更深入地探讨其中的一些技术。
当你需要具体了解程序的执行情况时,代码插桩可以提供非常详细的信息。它允许我们跟踪程序中每个变量的任何信息。
在优化大段代码时,使用这种方法往往能获得最佳的洞察力,因为你可以使用一种自上而下的方法(检测主函数,然后深入到其callees)来更好地理解应用程序的行为。通过代码工具,开发人员可以观察应用程序的架构和流程。这种技术对于处理不熟悉代码库的人员尤其有帮助。
代码插桩技术在视频游戏和嵌入式开发等实时场景的性能剖析中得到了广泛应用。有些剖析器将工具与其他技术(如跟踪或采样)相结合。我们将在第7.7节中介绍一种名为Tracy的混合剖析器。
虽然代码插桩在很多情况下都很强大,但它并不能从操作系统或CPU的角度提供代码执行的任何信息。例如,它无法提供进程调入和调出执行的频率(操作系统已知)或发生分支错误预测的次数(CPU已知)。插桩代码是应用程序的一部分,拥有与应用程序本身相同的权限。它在用户空间运行,无法访问内核。
这种技术的一个更重要的缺点是,每当有新的东西(比如说另一个变量)需要检测时,就需要重新编译。这会成为一种负担,并增加分析时间。不幸的是,这种方法还有其他缺点。由于你通常关心的是应用程序中的热点路径,因此你需要对代码中性能关键部分的内容进行检测。在热路径中注入仪器代码很容易导致整体基准测试速度降低2倍。切记不要对插桩序进行基准测试。通过检测代码,你会改变程序的行为,因此你可能无法看到与之前相同的效果。
所有上述情况都会增加实验之间的间隔时间,消耗更多的开发时间,这也是工程师们现在不经常手动检测代码的原因。不过,编译器仍在广泛使用自动代码检测。编译器能够自动检测整个程序(第三方库除外),以收集有关执行情况的有趣统计数据。最广为人知的自动探测用例是代码覆盖率分析和配置文件引导优化(参见第11.7节)。
在谈到插桩时,有必要提及二进制插桩技术。二进制工具的原理与此类似,但它是针对已构建的可执行文件而非源代码进行的。二进制工具有两种类型:静态(提前完成)和动态(程序执行时按需插入工具代码)。动态二进制工具的主要优点是不需要重新编译程序和重新链接。此外,使用动态检测,可以将检测量限制在感兴趣的代码区域,而不是检测整个程序。
二进制工具在性能剖析和调试中非常有用。英特尔Pin是最常用的二进制工具之一。Pin会在出现有趣事件时拦截程序的执行,并从程序中的这一点开始生成新的仪器代码。这样就能收集各种运行时信息。英特尔 SDE: Software Development Emulator 软件开发仿真器是建立在Pin基础上的最流行的工具之一。另一个著名的二进制工具名为DynamoRIO, 二进制插桩工具的作用:
- 指令计数和函数调用计数;
- 指令组合分析;
- 拦截应用程序中的函数调用和任何指令的执行;
- 内存强度和占用空间(参见第7.8.3节)。
与代码检测一样,二进制检测只检测用户级代码,速度可能非常慢。
5.2 跟踪(Tracing)
跟踪在概念上与插桩颇为相似,但也存在差异。代码插桩假定用户能完全访问应用程序的源代码,而跟踪依赖已有的工具。例如,strace工具可追踪系统调用,可看作是Linux内核的工具。英特尔处理器跟踪工具(Intel PT,见附录C)能够记录处理器执行的指令,可视为CPU的工具。跟踪能从经过恰当检测且不会变化的组件中获取信息。跟踪常被当作黑盒方法,即用户不能修改应用程序代码,但想深入了解程序在做什么。
下面提供了一个使用Linux strace工具跟踪系统调用的示例,它显示了运行git status命令时的前几行输出。
通过使用strace跟踪系统调用,我们可以知道每次系统调用的时间戳(最左边一列)、退出状态(在=符号之后)以及每次系统调用的持续时间(在角括号中)。
# strace -tt -T -- git status
08:48:08.432163 execve("/usr/bin/git", ["git", "status"], 0x7fffffb9c560 /* 24 vars */) = 0 <0.001054>
08:48:08.433978 brk(NULL) = 0x5a15bffda000 <0.000014>
08:48:08.434498 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1b82aa8000 <0.000021>
08:48:08.434664 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) <0.000019>
08:48:08.434923 openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 <0.000033>
08:48:08.435008 fstat(3, {st_mode=S_IFREG|0644, st_size=71351, ...}) = 0 <0.000019>
08:48:08.435089 mmap(NULL, 71351, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1b82a96000 <0.000033>
08:48:08.435197 close(3) = 0 <0.000017>
08:48:08.435270 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpcre2-8.so.0", O_RDONLY|O_CLOEXEC) =
...
跟踪的开销取决于我们试图跟踪的具体内容。例如,如果我们跟踪一个很少进行系统调用的程序,那么在strace下运行它的开销将接近于零。另一方面,如果我们跟踪一个严重依赖系统调用的程序,开销可能会非常大,例如 100倍。此外,由于跟踪不会跳过任何样本,因此会产生大量数据。为弥补这一不足,跟踪工具提供了过滤器,可将数据收集限制在特定时间片或特定代码段。与插桩类似,跟踪也可用于探索系统中的异常情况。例如,你可能想确定应用程序在10秒钟无响应期间发生了什么。正如你稍后将看到的,采样方法并非为此而设计,但通过跟踪,你可以了解导致程序无响应的原因。例如,通过英特尔PT,你可以重建程序的控制流,准确了解执行了哪些指令。
跟踪对调试也非常有用。它的基本特性使“记录和重放”成为可能。Mozilla rr调试器就是这样一个工具,它可以记录和重放进程,支持向后单步等。大多数跟踪工具都能为事件加上时间戳,这样我们就能找到事件与当时发生的外部事件之间的关联。也就是说,当我们观察到程序出现故障时,可以查看应用程序的跟踪记录,并将故障与当时整个系统中发生的事件联系起来。
5.3 收集性能监控事件(Performance Monitoring Events)
性能监控计数器(PMC Performance Monitoring Counters)是极为重要的底层性能分析工具,能提供关于程序执行的独特信息。PMC通常有两种使用模式:“计数”或“采样”。计数模式主要用于计算第4.9节提及的各类性能指标。采样模式用于查找热点,后续会探讨。
计数模式的原理很简单:我们要计算程序运行时某些性能监控事件的总数。PMC在“自顶向下微体系结构分析”(TMA)方法中得到广泛应用,我们将在第6.1节详细介绍该方法。下图展示了从程序开始到结束的性能事件计数过程。
图中概述的步骤大致代表了典型分析工具对性能事件的计数过程。perf stat工具也有类似的过程,可用于统计各种硬件事件,如指令数、周期数、缓存未命中数等。下面是perf stat输出的示例:
```
perf stat -- ls
code my.script openpbs-23.06.06 slurm-8.out snap test.sh t.out v23.06.06
Performance counter stats for 'ls':
0.52 msec task-clock # 0.691 CPUs utilized
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
96 page-faults # 183.560 K/sec
2,035,732 cycles # 3.892 GHz
2,029,699 instructions #
文章整理自互联网,只做测试使用。发布者:Lomu,转转请注明出处:https://www.it1024doc.com/12575.html