现代硬件算法[5.3]: 程序仿真
程序仿真
最后一种分析方法(或者更确切地说是一组分析方法)不是通过实际运行程序来收集数据,而是通过使用专用工具模拟程序来分析应该发生的事情。
这种profilers有很多类别,区别在于模拟计算过程中的不同部分。在本文中,我们将重点关注缓存和分支预测,并为此使用Cachegrind,这是Valgrind中一个面向性能分析的部分,Valgrind 是一个有名的内存泄漏检测和内存调试工具。
使用Cachegrind进行性能分析
Cachegrind本质上检查二进制文件中是否有“感兴趣的”指令——执行内存读/写和条件跳转/间接跳转的指令——并用相应的代码替换它们,这些代码使用软件数据结构模拟相应的硬件操作。因此,它不需要访问源代码,可以分析已经编译的程序,并且可以在任何程序上运行,如下所示:
valgrind --tool=cachegrind --branch-sim=yes ./run |
它检测所有涉及的二进制文件,运行它们,并输出类似于perf stat的摘要:
I refs: 483,664,426 |
我们给 Cachegrind 提供了与上一节完全相同的测试代码:创建一个由一百万个随机整数组成的数组,对其进行排序,然后对其执行一百万次二分查找。Cachegrind 显示的数字与perf大致相同,除了perf测量的内存读取和分支的数量由于预测执行而略有膨胀之外:预测执行确实发生在硬件中,因此会使得硬件计数增加,但会被丢弃,不会影响实际性能,因此在仿真中被忽略。
Cachegrind 只对一级缓存(D1
表示数据,I1
表示指令)和最后一级缓存(统一用LL
表示)进行建模,缓存的规格是从系统中推断出来的。当然你不会因此而受限,因为你也可以通过命令行来设置它们,例如,为二级缓存建模:--LL=<size>,<associativity>,<line-size>
。
到目前为止,Cachegrind 所做的似乎只是让我们的程序跑的更慢了,除此之外,并没有为我们提供任何perf stat
无法提供的信息。为了从中获得更多信息,我们可以查看一个包含分析信息的特殊文件,默认情况下,Cachegrind会将其dump到同一目录下,并以cachegrind.out.<pid>
命令。它是可读的,可以通过cg_annotate
命令读取:
cg_annotate cachegrind.out.4159404 --show=Dr,D1mr,DLmr,Bc,Bcm |
首先,它会显示了运行过程中使用的参数,包括缓存系统的规格:
I1 cache: 32768 B, 64 B, 8-way associative |
它没有完全正确地使用三级缓存:三级缓存不是统一内存(总共8M,但单个内核只能看到4M),而且是16-way associative的,但我们现在先忽略这一点。
接下来,它输出一个类似于perf report
关于每个函数的摘要:
Dr D1mr DLmr Bc Bcm file:function |
你可以看到在排序阶段有很多分支预测失误,在二分查找过程中也有很多一级缓存未命中和分支预测失误。我们无法通过perf获得这些信息——它只会告诉我们整个程序的这些计数值。
Cachegrind的另一个很棒的功能是可以对源代码进行逐行注释。为此,你需要带调试信息(-g
)编译程序,然后显式地告诉cg_annotate
要注释哪些源文件,或者只传递--auto=yes
选项,以便它注释所有可以访问的内容(包括标准库源代码)。
因此,基于源代码分析的整个过程如下:
g++ -O3 -g sort-and-search.cc -o run |
由于glibc实现的可读性不强,为了便于说明,我们将lower_bound
替换为自己实现的二分查找,其注释如下:
Dr D1mr DLmr Bc Bcm |
不幸的是,Cachegrind只能跟踪访存和分支。当性能瓶颈是由其他原因引起时,我们需要其他仿真工具。