Linux C/C++调试之四:callgrind的局限

在上篇文章中我介绍了callgrind的大致用法,可以看出来,callgrind是一个非侵入式的,使用起来也很傻瓜的调优工具。初用时感觉这个工具非常趁手,是个程序都想用callgrind去分析一下。但深入使用后发现,callgrind不是银弹,它还是有一些缺陷的。这些缺陷的根源在于:callgrind使用指令数来衡量性能,而程序员用耗时来衡量性能,指令数与耗时仅仅是一个正相关的关系,而非成比例的关系,这就导致了用插时间戳统计出来的性能数据,和用callgrind统计出来的性能数据有出入。为了说明这些出入,我举3个实例。

软硬件环境:

OS: Ubuntu 18.04.3(Linux 4.15.0-58-generic)

CPU: Intel® Core™ i5-8250U @1.60GHz

Compiler: gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)

cache的影响

对于二维数组,使用行优先访问要比使用列优先要快,这是CPU cache的功劳。看下面的例子:

#include "tool.h"
#include <vector>

const int SIZE = 2000;
std::vector<double> rowFirstVector;
std::vector<double> columnFirstVector;

double rowFirst()
{
    Logger l("row first access");
    double total;
    for (int i = 0; i < SIZE; i++)
    {
        for (int j = 0; j < SIZE; j++)
        {
            total += rowFirstVector[i * SIZE + j];
        }
    }
    return total;
}

double columnFirst()
{
    Logger l("column first access");
    double total;
    for (int i = 0; i < SIZE; i++)
    {
        for (int j = 0; j < SIZE; j++)
        {
            total += columnFirstVector[j * SIZE + i];
        }
    }
    return total;
}

int main()
{
    rowFirstVector = std::vector<double>(SIZE * SIZE);
    rowFirst();

    columnFirstVector = std::vector<double>(SIZE * SIZE);
    columnFirst();

    return 0;
}

代码中的Logger类用来记录时间戳打印耗时数据,使用了RAII的手法来实现。由于二维数组在内存中也是线性的,所以就用vector来代替了。rowFirst使用行优先访问内存,columnFirst使用列优先访问内存,开启O2优化编译源码,运行结果如下:

row first access cost 15.018 ms
column first access cost 22.841 ms

行优先比列优先快,符合我们的预期。而callgrind却得到了这样的结果:

在这里插入图片描述

callgrind看来,rowFirstcolumnFirst的性能平分秋色,因为他们执行了相同多的指令。

当然,在使用callgrind的时候可以打开cache仿真来统计cache miss的数据,但是这个数据无法与指令计数数据综合在一起,只能各自比较,并不直观。

单指令耗时的影响

浮点指令比整数指令要慢(至少在我的机器上是),所以有了这个例子:

#include "tool.h"
#include <vector>

const int SIZE = 10000000;
std::vector<int> intVector;
std::vector<double> doubleVector;

int intMultiply()
{
    Logger l("intMultiply");
    int result;
    for (int i = 0; i < SIZE; i++)
    {
        result += intVector[i] * 1;
    }
    return result;
}

double doubleMultiply()
{
    Logger l("doubleMultiply");
    double result;
    for (int i = 0; i < SIZE; i++)
    {
        result += doubleVector[i] * 1.0;
    }
    return result;
}

int main()
{
    intVector = std::vector<int>(SIZE);
    intMultiply();
    doubleVector = std::vector<double>(SIZE);
    doubleMultiply();
    return 0;
}

执行结果为如下,符合我们的预期:

intMultiply cost 7.388 ms
doubleMultiply cost 29.994 ms

而callgrind的结果是:

在这里插入图片描述

没错,仍然分不出来哪个快哪个慢,还是因为它们执行了相同多的指令。

系统调用性能无法统计

关于这一条,主要是受callgrind实现机制所限,callgrind实际上是实现了一个虚拟机,对程序每条汇编指令解释执行同时计数。但是对于系统调用没办法解释执行啊,只能交给真实的CPU去执行,在内核空间的系统调用实现也都会在真实CPU上执行,callgrind无法统计它们的指令数量。因此我们有了下面的例子:

#include "tool.h"
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>

const int MB_BYTES = 1024 * 1024;

void read1M()
{
    Logger l("read1M");
    int fd = open("./1M.txt", O_RDONLY);
    assert(fd >= 0);
    const int SIZE = 1 * MB_BYTES;
    char *buf = static_cast<char *>(malloc(SIZE));
    int result = read(fd, buf, SIZE);
    assert(result = SIZE);
    close(fd);
}

void read10M()
{
    Logger l("read10M");
    int fd = open("./10M.txt", O_RDONLY);
    assert(fd >= 0);
    const int SIZE = 10 * MB_BYTES;
    char *buf = static_cast<char *>(malloc(SIZE));
    int result = read(fd, buf, SIZE);
    assert(result = SIZE);
    close(fd);
}

int main()
{
    read1M();
    read10M();
    return 0;
}

1M.txt是一个大小为1MB的文件,10M.txt是一个大小为10MB的文件,我们会非常自然地期望读取前者比读取后者要快,即使速度与大小不见得成比例。执行结果如下,符合我们的预期:

read1M cost 1.801 ms
read10M cost 8.935 ms

callgrind的统计结果却是这样的:

在这里插入图片描述

总结

这里我列举出来3个因素来说明问题,实际可能有更多的因素。可以想想,程序规模大了以后,这些因素会一起叠加起来影响callgrind的统计结果,导致数据参考价值降低。

我当然不是说callgrind一无是处,只是当老板问你哪个模块是性能瓶颈的时候,你可千万别直接丢一份callgrind统计的数据过去,这个数据我们自己参考就行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章