优化正确的代码要比调试优化的代码容易。
--Yves Deville
可能你不知道gcc工具集中gcov有什么用途,可能你手头的新项目要调整,或者你的用户要求你们发布的软件要有一定的测试覆盖度,你不知道该怎么作。本文将结合GNU工具(gcov和gprof)介绍覆盖度测量和性能度量的基本概念。
覆盖度测量就是代码执行路径的记录。覆盖度可以分成各种粒度,最粗一级的覆盖是函数的覆盖,测量那些函数被调用过,然后是语句覆盖,测量哪一行语句执行过,最后是分支覆盖,测量分支语句中那些逻辑条件满足过。通常讨论覆盖测量是指语句覆盖和分支覆盖,gcov是标准的GNU覆盖测量工具,它运行需要GCC。
即使你有最新最快的CPU和内存,蜗牛般运行的软件还是可以抵消摩尔定律和便宜的硬件带来的好处,这很不幸,软件会消耗掉所有资源,更不要说那些资源受限的系统,象PDA和嵌入式系统了,即使买来新的硬件也不能解决它们的性能问题。
性能测量试可以让你测量到底是哪段代码耗掉了大部分运行时间。就像打开了一扇窗,它可以让你可以观察到程序的运行时实际的行为,让你知道那一部分是可以优化的热点(hot spots)。通常情况下简单的代码审查或者想当然的认为代码应该这样那样运行并不能发现这些热点,所以性能测量工具(profiler)是必不可少的。
性能测量是覆盖测量的一个超集,但是它们目的不同。覆盖测量可以让你知道那些代码没有运行,而性能测量则让你知道那些代码消耗了大部分时间。性能测量工具可以测量函数被调用的次数,任何一行代码或者逻辑分支的运行次数。调用函数的调用图和程序各每个部分消耗的时间。Gprof是标准的GNU性能测量工具,它也需要GCC。
覆盖度测量:干吗这么麻烦?
虽然在某些行业,比如航空业,需要覆盖度测量,但是好像在这些行业之外这项技术很少使用。这是因为覆盖度测量与其他调试技术比如内存泄漏检测和重写检测相比有点不太直接,覆盖度测试仅仅是个测试,它不能并没有自动找到BUG或者提高代码质量。
覆盖度测试说明的是你的测试的完善程度,如果你根本不作测试或者不作系统化的测试就不要谈测量覆盖度。更进一步讲,如果你没有什么测试标准,自动化测试套件(如DejaGNU),收集覆盖度测量数据简直就是件吃力不讨好而且容易出错的事,而且你很难有效的解释你的测量数据。
即使看起来很全面的测试集实际上也不完美,在我们第一个使用覆盖度测量的项目中,在运行了我们引以为傲的标准回归测试集之后,得到的行代码覆盖率只有63%,如果你从来没有做过这种事情,你可能会想:“才63%,你的测试好像不怎么样”,实际上我们很高兴能达到这样高的比率,我们的系统里面有大量处理系统故障的错误处理程序,其中有些错误我们的测试集不能触发,像内存不足,文件描述符用完等。我还知道业内新开发的测试集平均的覆盖度接近50%,所以我们很高兴我们能够作的这么好。
假设数字降到50%,平均的全面测试集仅仅执行了要测试的50%的代码。如果你不做过覆盖度测试,你根本不知道你做的怎么样-没有标准可以告诉你怎样做的更好,很难去优化没有测量过的代码。
知道你的代码通过测试集执行了多少,仅仅是开始,不是结束。一旦你了解了这些,你就要仔细的观察覆盖度报告,找到那些没有执行到的代码并准备增加新的测试。
用gcov作覆盖度测试
要使用gcov你必须使用gcc作编译器。另外你还要在编译时加上编译选项-fprofile-arcs -ftest-coverage,这会使GCC在创建目标文件时使插入一些代码,这些代码将记录gcov需要的信息并将其保存到硬盘上。
要使用gcov你必须使用gcc作编译器。另外你还要在编译时加上编译选项-fprofile-arcs -ftest-coverage,这会使GCC在创建目标文件时使插入一些代码,这些代码将记录gcov需要的信息并将其保存到硬盘上。
GCC的手册说,目标文件包含了.da文件的绝对路径,所以在创建(build)后你不能移动源文件然后收集覆盖度测试数据。这样作会在保存数据时产生错误信息Can't open output file filename。Gcov每次只显示一个文件的结果,不能提供汇总信息。
创建gcov报告很简单,我使用 glib(Gtk+ 和 GNOME的基础库, www.gtk.org)为例。首先 从Gtk+官方网站或其映象上下载glib,我使用 glib-2.2.1,这是我写文章时候的最新版本。
为了进行覆盖度测量,设置环境变量CFLAGS成-fprofile-arcs -ftest-coverage运行 ./configure; make; make check.遵循GNU建议发布的软件都可以这样运行。
make check完成之后就可以用gcov创建覆盖度报告了,我们得到的最基础的报告是语句覆盖度报告。
[zfrey@warthog glib]$ gcov garray.c
68.37% of 215 source lines executed in file garray.c
Creating garray.c.gcov.
除了输出统计总结信息,gcov还创建了注释过的源文件列表,对每条可执行语句,这个列表显示它执行的的次数或者标识出它根本没有运行。
表1 注释过的gcov文件片断
代码: |
Listing 1. Excerpt of a gcov Annotated Source Listing GArray* g_array_sized_new (gboolean zero_terminated, gboolean clear, guint elt_size, guint reserved_size) 40 { 40 GRealArray *array; 40 G_LOCK (array_mem_chunk); 40 if (!array_mem_chunk) 14 array_mem_chunk = g_mem_chunk_new ("array mem chunk", sizeof (GRealArray), 1024, G_ALLOC_AND_FREE); 40 array = g_chunk_new (GRealArray, array_mem_chunk); |
如果要看分支覆盖度,使用-b选项
代码: |
[zfrey@warthog glib]$ gcov -b garray.c 68.37% of 215 source lines executed in file garray.c 59.02% of 122 branches executed in file garray.c 46.72% of 122 branches taken at least once in file garray.c 45.35% of 86 calls executed in file garray.c Creating garray.c.gcov. |
注释过的源文件现在包含了分支数据。
表2。注释过分支数据的文件片断
代码: |
GArray* g_array_sized_new ( gboolean zero_terminated, gboolean clear, guint elt_size, guint reserved_size) 40 { 40 GRealArray *array; 40 G_LOCK (array_mem_chunk); branch 0 taken = 40% branch 1 taken = 58% call 2 returns = 100% branch 3 taken = 100% call 4 returns = 100% 40 if (!array_mem_chunk) branch 0 taken = 65% 14 array_mem_chunk = g_mem_chunk_new ( "array mem chunk", call 0 returns = 100% sizeof (GRealArray), 1024, G_ALLOC_AND_FREE); 1024, G_ALLOC_AND_FREE); 40 array = g_chunk_new (GRealArray, array_mem_chunk); call 0 returns = 100% |
要查看源文件的函数级覆盖度,使用-f选项,它可以和-b选项一起使用得到以函数为单位的分支覆盖度。不幸的是,gcov没有提供多个文件报告汇总的功能。
GGCov: Gtk+ 的gcov图形界面
GGCov是基于Gtk+的gcov图形前端,作者是Greg Banks(www.alphalink.com.au/~gnb/ggcov )我下载了最新版(0.1.1),编译和安装都没有问题。使用./configure; make; make install安装,prefix是 /usr/local
进入源文件目录,运行GGCov,GGCov会寻找目录下覆盖度测量数据并且创建汇总总结(如图1),GNU gcov本身没有这个能力。
图 1. GGCov 总结报告
选择文件按钮激活下拉列表,这个下拉列表里有本目录下所有包含覆盖度信息的文件。选择文件就能得到针对该文件的统计信息(图2)。
图 2. GGCov 文件报告
选择函数按钮激活下拉列表,这个列表里有本目录下所有包含覆盖度信息的文件中的函数名。选择函数就能得到针对该函数的统计信息(图3)。
图 3. GGCov函数报告
如果你还觉着不满意,就试试范围按钮,它允许选择一个文件中特定范围行数的代码,创建仅针对该范围内函数的总结。除了全部汇总总结外,所有的总结都有一个查看按钮,如果点击它就会产生一个新窗口,显示选定文件中注释过的代码。
总而言之,我发现GGCov提供的功能简单而且有效。GGCov还能显示调用图。然而在Red Hat 8.0上, glib会导致GGCov segfault,我联系了Greg Banks,他说调用图功能还不完善,他希望在未来的版本中解决这些问题。
使用gprof作性能测量
和gcov一样gprof也需要在程序编译的时候使用特定的选项使gcc可以在你的代码中插入适当的指令。要使用gprof,你必须使用-pg选项。缺省情况下,这将分析每个执行的函数。如果要对每行都分析就要同时使用-g选项,GCC会插入调试信息,这样gprof就可以产生行报告。
程序运行时,函数调用和耗时将保存在内存中,程序退出时,这些信息写到gmon.out文件中。Gprof使用这个文件产生性能测量报告。gprof仅使用一个文件保存数据,不像覆盖度测量,将不同源文件的数据保存在不同的文件中。gmon.out也不一定保存在源文件目录相同的目录下,而是保存在程序退出时的当前目录中,所以要注意程序里的chdir()调用。
缺省情况下,测量数据累计每个函数和它调用的函数的执行次数,一个采样进程负责收集运行时数据,这个程序每个采样周期检查一次,记录下当前执行的函数。Gprof解释这个采样来报告每段代码用了多长时间。
因为这是统计取样,所有统计误差。关于误差计算的详细信息请参考gprof手册。简而言之,gprof报告的运行时间可能不是很准确,更有甚者,如果有个函数执行时间非常短,它就可能错过取样而显示执行时间为0.0。这对性能分析不是问题。如果某个函数或者某段代码运行时间不足以取样,那么我们可能就不需要花时间去优化它。
然而,如果你还要用gprof显示语句和分支的覆盖度的话,统计误差就成了问题。
如果要精确统计每行代码实际运行了多少次,就要在编译时使用-a选项进行基本块(basic block)计数。这会在目标文件增加计算每段代码执行的指令,这也使gprof能够精确的显示每行代码究竟执行了多少次,即使取样结果是0次。
使用gprof的-s选项,可以合并组合多个gmon.out文件,创建多次运行的分析报告。它可以用任意数目的gmon.out文件创建一个新的gmon.sum综合数据文件。如果用gmon.sum作为输入文件,还可以递增式的添加性能分析数据。
我原本希望再用glib作为gprof的例子,但是gcov和gprof的行为差别很大,所以这个想法很难实现。每次运行make check,glib测试程序就会执行一次,所以每次测试的gmon.out都会被下一次的测试覆盖,另外,测试程序实际上是shell脚本,为了运行,它设置了相当复杂的环境变量,这些都保存在一个隐藏目录里。从这件事是可以吸取的教训是:gcov才是对glib作覆盖度测试的合适工具。
所以我创建了一个简单的从零到42计算Fibonacci数的程序,Fibonacci序列的初始值是0,1 ,后面的数是前面两个数的和(0,1,1,2,3,5,8,13,21,...),一个很好的介绍Fibonacci数和相关的数学概念的网站在www.mcs.surrey.ac.uk/Personal/R.Knott/Fibonacci/fib.html,之所以选42,不是因为我是Douglas Adams崇拜者,而是因为取47的时候带符号的32为整数就溢出了,所以选择了一个小一点的数。
Fibonacci数列计算是递归函数的经典例子,因为Fibonacci数是前两个数的和,最简单的方法是创建一个函数int fibonacci(int n),它调用自己的n-1和n-2并且求和返回结果(表3)。
表 3. fib.c
代码: |
#include <stdio.h> int fibonacci(int n); int main (int argc, char **argv) { int fib; int n; for (n = 0; n <= 42; n++) { fib = fibonacci(n); printf("fibonnaci(%d) = %d\n", n, fib); } return 0; } int fibonacci(int n) { int fib; if (n <= 0) { fib = 0; } else if (n == 1) { fib = 1; } else { fib = fibonacci(n -1) + fibonacci(n - 2); } return fib; } |
下面是Fibonacci程序的输出
代码: |
[zfrey@warthog prof]$ time ./fib fibonnaci(0) = 0 fibonnaci(1) = 1 fibonnaci(2) = 1 fibonnaci(3) = 2 fibonnaci(4) = 3 ... fibonnaci(40) = 102334155 fibonnaci(41) = 165580141 fibonnaci(42) = 267914296 real 3m12.580s user 3m10.566s sys 0m0.127s [zfrey@warthog prof]$ |
现在我们看一下性能分析数据:
代码: |
[zfrey@warthog prof]$ gprof -b ./fib gmon.out Flat profile: Each sample counts as 0.00195312 seconds. % cumulative self self total time seconds seconds calls ms/call ms/call name 95.13 16.34 16.34 43 380.02 380.02 fibonacci 4.87 17.18 0.84 main Call graph index % time self children called name [1] 100.0 0.84 16.34 main [1] 16.34 0.00 43/43 fibonacci [2] ----------------------------------------------- 2269806252 fibonacci [2] 16.34 0.00 43/43 main [1] [2] 95.1 16.34 0.00 43+2269806252 fibonacci [2] 2269806252 fibonacci [2] ----------------------------------------------- Index by function name [2] fibonacci [1] main |
很显然这个程序的瓶颈在fibonnaci这个函数自身。如果读一下代码,这更是显而易见的。性能分析告诉我们,虽然在代码中递归调用的次数看起来不是很多,虽然fibonnaci自己被main()直接调用了仅仅43次,但是大约有2.3亿次递归调用。
幸运的是还有其他方法,查阅关于fibonnaci数的参考资料,我们找到直接用n计算fibonnaci数的公式:
F(n) = round(Phin / sqrt(5))
我不打算解释这个数学公式 的推导,原因很简单,我自己也不明白。有了这个改进的算法,我们重写了程序,又用gcov分析(列表4):
表4 新版Fibonacci函数
代码: |
#define PHI 1.6180339887498948 int fibonacci(int n) { return (int) rint(pow(PHI, n) / sqrt(5)); } |
我们看看gprof 这次的结果
代码: |
Call graph granularity: each sample hit covers 4 byte(s) no time propagated index % time self children called name 0.00 0.00 43/43 main [8] [1] 0.0 0.00 0.00 43 fibonacci [1] ----------------------------------------------- Index by function name [1] fibonacci |
我们的程序实在太快了,以至于取样程序不能计算它的运行时间。一方面这个结果很好,但是另一方面我们没有办法继续改进这个程序。
Gprof手册提供的标准方法是多次运行这个程序再汇总结果,像这样:
代码: |
[zfrey@warthog prof]$ mkdir runfib2; cd runfib2 [zfrey@warthog runfib2]$ for i in `seq 1 1000` ; \ do ../fib2 > /dev/null; mv gmon.out gmon.out.$i; done [zfrey@warthog runfib2]$ gprof -s ../fib2 gmon.out.* |
然而结果仍然是0。显然,现在需要别的方法,我创建了文件fib3.c,里面有两个变化,一,我将0-42的循环执行了1000次以增加运行时间,第二,我在fibonacci函数中增加了一个局部变量,这样将计算分成一行一个操作,以便进行行分析。这次我们有了足够的运行时间,gprof产生了如下报告:
表 5. fib3.c
代码: |
#include <stdio.h> #include <math.h> int fibonacci(int n); int main (int argc, char **argv) { int fib; int n; int i; for (i = 0; i < 1000; i++) { for (n = 0; n <= 42; n++) { fib = fibonacci(n); printf("fibonnaci(%d) = %d\n", n, fib); } } return 0; } #define PHI 1.6180339887498948 int fibonacci(int n) { double temp; temp = pow(PHI,n); temp = temp / sqrt(5); return (int) rint(temp); } |
在进行行报告之前,我像前面一样创建了一个多次运行的报告总结。
现在我们看一下行报告。
代码: |
[zfrey@warthog runfib3]$ gprof -b -l -p ../fib3 gmon.sum Flat profile: Each sample counts as 0.00195312 seconds. % cumulative self self total time seconds seconds calls ps/call ps/call name 38.25 1.12 1.12 fibonacci (fib3.c:31) 27.97 1.94 0.82 fibonacci (fib3.c:30) 14.49 2.36 0.42 fibonacci (fib3.c:29) 5.91 2.53 0.17 main (fib3.c:16) 4.64 2.67 0.14 main (fib3.c:15) 3.14 2.76 0.09 fibonacci (fib3.c:32) 1.87 2.82 0.05 main (fib3.c:14) 1.54 2.86 0.04 main (fib3.c:14) 0.83 2.89 0.02 43000000 567.77 567.77 fibonacci (fib3.c:26) 0.77 2.91 0.02 main (fib3.c:21) 0.53 2.92 0.02 main (fib3.c:13) 0.07 2.93 0.00 main (fib3.c:20) |
31行消耗了最大比率的运行时间,其次是30行,我们对31行毫无办法,因为printf()是必要的操作。但是对于30行,我们注意到我们每次都要计算5的平方根。因为这个结果不会变化,下一个优化措施是用常量代替它。这将执行时间从136毫秒降到22毫秒.
KProf—gprof的KDE图形界面
Kprof是一个基于KDE的gprof数据查看程序。可以从kprof.sourceforge.net下载,使用标准的./configure; make; make install安装。
运行了Kprof后,打开进行测量的执行文件或者包含gprof报告的文本文件,如果你打开的是执行文件,kprof假设数据保存在本目录的gmon.out文件中。
图4 是Kprof显示的fib的运行结果,环形的箭头表示Fibonacci是递归调用。