Valgrind Callgrind是一个可以分析代码并报告其资源使用情况的程序。这是Valgrind提供的另一个工具,它还可以帮助检测内存问题。事实上,Valgrind框架支持多种运行时分析工具,包括memcheck(检测内存错误/泄漏)、massif(报告堆使用情况)、helgrind(检测多线程竞争条件)和callgrind/cachegrind(评测CPU/缓存性能)。本工具指南将向你介绍Valgrind callgrind评测工具。
代码分析
代码分析器是一种分析程序并报告其资源使用情况的工具(其中“资源”是内存、CPU周期、网络带宽等)。程序优化的第一步是使用分析器从代表性的程序执行中收集实际的定量数据。分析数据将深入了解资源消耗的模式和峰值,因此你可以确定是否存在问题,如果存在,问题集中在哪里,从而使你能够将精力集中在最需要关注的段落上。你还可以使用探查器进行测量和重新测量,以验证你的努力是否取得了成果。
大多数代码分析器通过动态分析进行操作——也就是说,它们观察正在执行的程序并进行实时测量——而不是静态分析,后者检查源代码并预测行为。动态探查器以多种方式运行:一些通过向程序中插入计数代码,另一些以高频率对其活动进行采样,另一些在模拟环境中运行程序并内置监控。
用于评测的标准C/unix工具是gprof
,gnu评测器。这个不虚饰的工具是一个统计采样器,用于跟踪在功能级别花费的时间。它定期拍摄运行程序的快照,并跟踪函数调用堆栈(就像你在crash reporter中所做的那样!)以观察活动。如果你对gprof
的功能和使用感到好奇,可以查看在线gprof手册。
Valgrind分析工具是cachegrind和callgrind。cachegrind工具模拟一级/二级缓存并统计缓存未命中/命中。callgrind工具统计函数调用和每个调用中执行的CPU指令,并构建函数调用图。callgrind工具包括一个从cachegrind中采用的缓存模拟功能,因此你可以实际使用callgrind进行CPU和缓存评测。callgrind工具的工作原理是使用额外的指令来检测程序,这些指令记录活动并保留计数器。
指令计数
要评测,你可以在Valgrind下运行程序,并显式请求callgrind工具(如果未指定,该工具默认为memcheck)。
valgrind --tool=callgrind program-to-run program-arguments
上面的命令启动valgrind
并在其中运行程序。由于Valgrind的检测,程序将正常运行,尽管速度稍慢。完成后,它会报告收集的事件总数:
==22417== Events : Ir
==22417== Collected : 7247606
==22417==
==22417== I refs: 7,247,606
Valgrind已将上述700万个收集事件的信息写入名为callgrind.out.pid
的输出文件。(pid
替换为进程id,在上面的运行中为22417,id显示在最左边的列中)。
callgrind输出文件是一个文本文件,但其内容不是供你阅读的。相反,你可以在此输出文件上运行注解器callgrind_annotate
,以有用的方式显示信息(用进程id替换pid
):
callgrind_annotate --auto=yes callgrind.out.pid
注解器的输出将以Ir
计数给出,Ir
计数是“指令读取”事件。输出显示每个函数内发生的事件总数(即执行的指令数),并显示按计数递减顺序排序的函数列表。你的高流量函数将列在顶部。callgrind_annotate
选项--auto=yes
通过报告每个C语句的计数来进一步细分结果(如果没有auto选项,则会在函数级别汇总计数,这通常过于粗糙而没有用处)。
那些用大指令注解的行是程序的“热点”。这些高流量执行路径具有大量计数,因为它们被执行多次和/或对应于大量指令序列。在加速你的程序方面,努力简化这些执行路径将为你带来最好的回报。
添加缓存模拟
为了额外监控缓存命中/未命中,请使用--simulate cache=yes
选项调用valgrind callgrind,如下所示:
valgrind --tool=callgrind --simulate-cache=yes program-to-run program-arguments
现在输出在最后的简要摘要将包括收集的访问L1和L2缓存的事件,如下所示:
==16409== Events : Ir Dr Dw I1mr D1mr D1mw I2mr D2mr D2mw
==16409== Collected : 7163066 4062243 537262 591 610 182 16 103 94
==16409==
==16409== I refs: 7,163,066
==16409== I1 misses: 591
==16409== L2i misses: 16
==16409== I1 miss rate: 0.0%
==16409== L2i miss rate: 0.0%
==16409==
==16409== D refs: 4,599,505 (4,062,243 rd + 537,262 wr)
==16409== D1 misses: 792 ( 610 rd + 182 wr)
==16409== L2d misses: 197 ( 103 rd + 94 wr)
==16409== D1 miss rate: 0.0% ( 0.0% + 0.0% )
==16409== L2d miss rate: 0.0% ( 0.0% + 0.0% )
==16409==
==16409== L2 refs: 1,383 ( 1,201 rd + 182 wr)
==16409== L2 misses: 213 ( 119 rd + 94 wr)
==16409== L2 miss rate: 0.0% ( 0.0% + 0.0% )
在该输出文件上运行callgrind_annotate
时,注解现在将包括缓存活动和指令计数。
解释结果
了解Ir
计数。Ir
计数基本上是执行的汇编指令计数。一条C语句可以转换为1、2或多条汇编指令。请考虑下面由callgrind_annotate
注解的段落。分析程序使用选择排序对1000个成员的数组进行排序。对swap
函数的单个调用需要15条指令:3条指令用于开场白,3条指令用于赋值给tmp
,4条指令用于从*b
复制到*a
,3条指令用于从tmp
赋值,另外2条指令用于尾声。(请注意,开场白和尾声的成本在开头和结尾的大括号中进行了注解。)有1000次呼叫需要交换,总共占15000条指令。
. void swap(int *a, int *b)
3,000 {
3,000 int tmp = *a;
4,000 *a = *b;
3,000 *b = tmp;
2,000 }
.
. int find_min(int arr[], int start, int stop)
3,000 {
2,000 int min = start;
2,005,000 for(int i = start+1; i <= stop; i++)
4,995,000 if (arr[i] < arr[min])
6,178 min = i;
1,000 return min;
2,000 }
. void selection_sort(int arr[], int n)
3 {
4,005 for (int i = 0; i < n; i++) {
9,000 int min = find_min(arr, i, n-1);
7,014,178 => sorts.c:find_min (1000x)
10,000 swap(&arr[i], &arr[min]);
15,000 => sorts.c:swap (1000x)
. }
2 }
.
callgrind_annotate
包括一个函数调用摘要,按计数递减顺序排序,如下所示:
7,014,178 sorts.c:find_min [sorts]
25,059 ???:do_lookup_x [/lib/ld-2.5.so]
23,010 sorts.c:selection_sort [sorts]
20,984 ???:_dl_lookup_symbol_x [/lib/ld-2.5.so]
15,000 sorts.c:swap [sorts]
默认情况下,计数是独占的——函数的计数只包括在该函数中花费的时间,而不包括在它调用的函数中花费的时间。例如,selection_sort
函数计算的23010条指令包括9000条设置和调用find_min
的指令,但不包括find_min
本身执行的700万条指令。另一种计算方法是包括在内的(如果你更喜欢这种统计方法,请使用选项--inclusive=yes
来进行callgrind_annotate
)将子函数调用的成本包含在上层总计中。一般来说,使用排他计数是突出瓶颈的好方法——花费时间最多的函数/语句是你希望减少调用次数或简化调用中发生的事情的函数/语句。通过寻找最高计数,可以很容易地发现流量最大的执行路径。在上面的代码中,工作集中在循环中以查找最小值——缓存最小数组元素而不是重新获取和展开循环等技术在这里可能很有用。
了解缓存统计信息。高速缓存模拟器模拟了一台具有拆分一级高速缓存(单独的指令I1和数据D1)的机器,该机器由一个统一的二级高速缓存(L2)支持。这符合大多数现代机器的一般缓存设计。缓存模拟器记录的事件包括:
Ir
: I缓存读取(执行的指令)I1mr
: I1缓存读取未命中(指令不在I1缓存中,但在二级缓存中)I2mr
: 二级缓存指令读取未命中(指令不在I1或二级缓存中,必须从内存中提取)Dr
: D缓存读取(内存读取)D1mr
: D1缓存读取未命中(数据位置不在D1缓存中,但在L2中)D2mr
: 二级缓存数据读取未命中(位置不在D1或L2中)Dw
: D缓存写入(内存写入)D1mw
: D1缓存写入未命中(位置不在D1缓存中,但在L2中)D2mw
: 二级缓存数据写入未命中(位置不在D1或L2中)
同样,策略是使用callgrind_annotate
来报告每个语句的命中/未命中计数,并查找那些导致总读/写次数过多或缓存未命中次数过多的语句。即使是少量的未命中也是非常重要的,因为L1未命中通常会花费5-10个周期,L2未命中可能会花费100-200个周期,因此即使是其中的几个也会带来很大的提升。
查看前一个选择排序程序的带注解的结果表明,代码对缓存非常友好——在沿着数组遍历时只发生了几次未命中,否则会出现大量I1和D1命中。
--------------------------------------------------------------------------------
-- Auto-annotated source: sorts.c
--------------------------------------------------------------------------------
Ir Dr Dw I1mr D1mr D1mw I2mr D2mr D2mw
. . . . . . . . . void swap(int *a, int *b)
3,000 0 1,000 1 0 0 1 . . {
3,000 2,000 1,000 . . . . . . int tmp = *a;
4,000 3,000 1,000 . . . . . . *a = *b;
3,000 2,000 1,000 . . . . . . *b = tmp;
2,000 2,000 . . . . . . . }
. . . . . . . . .
. . . . . . . . . int find_min(int arr[], int start, int stop)
3,000 0 1,000 1 0 0 1 . . {
2,000 1,000 1,000 0 0 1 0 0 1 int min = start;
2,005,000 1,002,000 500,500 . . . . . . for(int i = start+1; i <= stop; i++)
4,995,000 2,997,000 0 0 32 0 0 19 . if (arr[i] < arr[min])
6,144 3,072 3,072 . . . . . . min = i;
1,000 1,000 . . . . . . . return min;
2,000 2,000 . . . . . . . }
. . . . . . . . . void selection_sort(int arr[], int n)
3 0 1 1 0 0 1 . . {
4,005 2,002 1,001 . . . . . . for (int i = 0; i < n; i++) {
9,000 3,000 5,000 . . . . . . int min = find_min(arr, i, n-1);
7,014,144 4,006,072 505,572 1 32 1 1 19 1 => sorts.c:find_min (1000x)
10,000 4,000 3,000 . . . . . . swap(&arr[i], &arr[min]);
15,000 9,000 4,000 1 0 0 1 . . => sorts.c:swap (1000x)
. . . . . . . . . }
2 2 . . . . . . . }
提示和技巧
关于有效使用callgrind的一些提示:
- 通常,我们建议你在未经优化的情况下对编译的代码进行调试/测试(即,
-O0
),但衡量性能有点不同。即使在编译器的优化帮助下,你也希望执行优化的代码以找出存在哪些瓶颈。 - Callgrind只测量所执行的代码,因此请确保你正在进行各种各样的、具有代表性的运行,以执行所有适当的代码路径。
- 你可以比较多次运行的结果,以了解不同输入的性能变化。
- Callgrind记录指令的计数,而不是在函数中花费的实际时间。如果你有一个瓶颈是文件I/O的程序,那么与读取和写入文件相关的成本不会显示在分析文件中,因为这些不是CPU密集型任务。
- 如果编译器内联函数,它将不会显示为单独的条目。相反,内联函数的成本将包含在调用方的成本中。还要记住,
inline
关键字仅仅是建议性的;编译器可自行决定是否内联。 - 通过使用选项
--toggle-collect=function_name
,可以将callgrind限制为仅计算指定函数中的指令。 - 每个函数的注解通常过于粗糙而没有用处,逐行计数是获得更多有用细节的关键。你甚至可以下拉以观察程序集级别的事件计数。通过编辑
Makefile
以包含编译器标志-Wa,--gstabs -save-temps
,使用程序集级调试信息构建代码。然后,在运行callgrind时,使用选项--dump-instr=yes
,该选项对每个汇编指令的请求计数。当注解此输出时,callgrind_annotate
现在将事件与汇编语句匹配。 - 二级缓存未命中比一级未命中要昂贵得多,因此请注意
D2mr
或D2mw
计数较高的执行路径。你可以使用callgrind_annotate
show/sort
选项来关注关键事件,例如callgrind_annotate --show=D2mr --sort=D2mr
将突出显示D2mr
计数。 - 为多线程程序做分析时,请使用
--separate-threads=yes
选项,从而可以为每个线程产生分析报告。 - 在注解分析报告时,为方便查看函数的调用结构,可以使用
--tree=both
选项为函数的调用者和被调用者的数据行排在一起。 - 还有其他callgrind功能,允许你微调模拟参数并控制收集事件的时间,以及注解器分析报告显示方式的许多选项。有关更多详细信息,请参阅联机Callgrind手册。