6、Valgrind系列:Callgrind的使用,生成调用图,分析函数调用次数与缓存命中率

性能优化做到一定深度,你会发现一个尴尬的事实——光靠猜是没用的

我见过太多人,盯着代码看了半天,信誓旦旦地说「肯定是这个函数慢」,结果一测,真正吃 CPU 的完全是另一个地方。所以,咱们得用工具说话。

今天聊的 Callgrind,就是 Valgrind 家族里专门干这事的。它能告诉你:每个函数被调了多少次、每次花了多少指令、缓存命中率怎么样。说白了,它帮你把程序的「执行账本」一笔一笔算清楚。

6.1 Callgrind 是什么?能干什么?

Callgrind 是一个基于模拟的 profiling 工具。它不直接跑在你的 CPU 上,而是模拟了一个虚拟 CPU,然后记录每条指令的执行情况。

嗯,这里要注意——因为它是模拟的,所以跑起来会比正常慢 10~20 倍。但换来的是极其详细的数据。

它能给你三样东西:

  • 函数调用次数:每个函数被调了多少回,谁调了它,它又调了谁。
  • 指令计数:每个函数消耗了多少条指令(不是时间,是指令数)。
  • 缓存命中率:L1 指令缓存、L1 数据缓存、LLC(最后一级缓存)的命中情况。

我个人习惯,在优化一个模块之前,先用 Callgrind 跑一遍,看看「热点」到底在哪。很多时候,结果会颠覆你的直觉。

6.2 基本用法:跑起来看看

先来个最简单的例子。假设你有这么一段代码:

// demo.c
#include <stdio.h>

void hot_function(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += i * i;
    }
    printf("sum = %d\n", sum);
}

int main() {
    for (int i = 0; i < 1000; i++) {
        hot_function(10000);
    }
    return 0;
}

编译的时候记得加 -g 选项,保留调试信息:

gcc -g -o demo demo.c

然后用 Callgrind 跑:

valgrind --tool=callgrind ./demo

跑完之后,当前目录下会生成一个文件,名字类似 callgrind.out.12345。这个文件就是原始数据。

你可以用 callgrind_annotate 直接看文本结果:

callgrind_annotate callgrind.out.12345

输出大概长这样:

--------------------------------------------------------------------------------
Profile data file: callgrind.out.12345
Events recorded:  Ir
Events shown:     Ir
...
--------------------------------------------------------------------------------
                 Ir
--------------------------------------------------------------------------------
 20,000,100,000  PROGRAM TOTALS

--------------------------------------------------------------------------------
                 Ir  file:function
--------------------------------------------------------------------------------
 20,000,000,000  demo.c:hot_function [1]
      1,000,000  demo.c:main [2]
         ...

看到没?hot_function 占了 200 亿条指令,而 main 只有 100 万条。热点一目了然。

关键点Ir 表示指令读取次数(Instruction reads)。这是 Callgrind 最核心的指标。

6.3 生成调用图:用图形说话

文本数据虽然精确,但不够直观。我更喜欢用 KCachegrind 来看图。

KCachegrind 是一个图形化工具,能把 Callgrind 的数据渲染成调用图。安装很简单:

# Ubuntu/Debian
sudo apt install kcachegrind

# macOS
brew install kcachegrind

然后直接打开:

kcachegrind callgrind.out.12345

你会看到一个界面,左边是函数列表(按消耗排序),右边是调用图。调用图里,箭头越粗,表示调用次数越多或消耗越大

我在项目中遇到过一个问题:一个看起来简单的日志函数,居然占了 30% 的 CPU。用 KCachegrind 一看,发现它在内部调用了 snprintfstrlen 各几百万次。优化方案?把日志级别判断提前,避免不必要的格式化。

小技巧:在 KCachegrind 里,你可以右键点击某个函数,选择 "Show Callers" 或 "Show Callees",快速定位调用链。

6.4 分析缓存命中率

指令计数只是第一步。很多时候,程序慢不是因为指令多,而是因为缓存没命中

Callgrind 可以模拟缓存行为。加上 --cache-sim=yes 参数:

valgrind --tool=callgrind --cache-sim=yes ./demo

这次生成的数据会包含缓存事件。用 callgrind_annotate 看:

callgrind_annotate --cache=yes callgrind.out.12345

输出会多出几列:

事件 含义
Dr 数据读取次数
Dw 数据写入次数
D1mr L1 数据缓存读未命中
D1mw L1 数据缓存写未命中
LLmr LLC 读未命中
LLmw LLC 写未命中

举个例子,如果你看到 D1mr 很高,说明你的数据访问模式对 L1 缓存不友好。可能是步长太大,或者数据结构过于分散

我曾经优化过一个图像处理程序,原始代码用了一个二维数组,按列遍历。结果 L1 缓存命中率只有 60%。改成按行遍历后,命中率直接升到 98%,性能提升了 3 倍。这就是缓存的威力。

注意:Callgrind 的缓存模拟是近似值,不是真实硬件数据。但它能帮你发现趋势——哪些函数缓存友好,哪些不友好。

6.5 实战:一个完整的分析流程

咱们来走一遍完整的流程。假设你有一个程序 app,你觉得它慢,但不知道慢在哪。

  1. 第一步:粗粒度分析
    valgrind --tool=callgrind ./app
    callgrind_annotate callgrind.out.* | head -20
    先看哪些函数指令数最多。
  2. 第二步:细粒度缓存分析
    valgrind --tool=callgrind --cache-sim=yes ./app
    callgrind_annotate --cache=yes callgrind.out.* | head -20
    看缓存命中率,找出缓存不友好的函数。
  3. 第三步:图形化分析
    kcachegrind callgrind.out.*
    在图形界面里,双击热点函数,看它的调用链和内部循环。
  4. 第四步:定位瓶颈

    比如你发现 process_pixel 函数 L1 数据缓存未命中率高达 40%。点进去一看,它访问了一个全局数组,但访问模式是跳跃的。优化方向:重新排列数据布局,或者使用局部变量缓存

核心原则:先找「指令数最多」的函数,再找「缓存命中率最低」的函数。两者结合,才是真正的瓶颈。

6.6 知识体系:Callgrind 分析全景图

下面这张图,帮你把 Callgrind 的核心逻辑串起来:

Callgrind 分析全景图 可执行程序 Callgrind 模拟执行 --tool=callgrind [--cache-sim=yes] callgrind.out.xxxxx 文本分析 图形化分析 缓存分析 指令计数 / 调用次数 调用图 / 热点函数 L1 / LLC 命中率

这张图的核心逻辑是:输入程序 → Callgrind 模拟 → 输出数据 → 三种分析方式 → 定位瓶颈。你不需要记住所有细节,只要知道「数据从哪来,到哪去」就够了。

6.7 避坑指南

最后,分享几个我踩过的坑:

  • 不要在生产环境跑 Callgrind。它会让程序慢 10~20 倍,只适合开发或测试环境。
  • 小心多线程程序。Callgrind 默认只模拟单线程。多线程程序需要加 --separate-threads=yes,但数据会变得很复杂。
  • 缓存模拟不是万能的。它模拟的是「理想缓存」,真实硬件的缓存行为可能更复杂(比如预取、TLB 等)。
  • 我曾经在一个项目里,用 Callgrind 分析一个网络库,发现某个函数缓存命中率极低。优化后性能提升明显,但上线后反而变慢了。后来发现,是因为优化破坏了另一个模块的缓存局部性。所以,优化要全局看,不能只看局部

嗯,Callgrind 的内容就这些。工具本身不复杂,复杂的是你怎么解读数据。多跑几次,多看图,慢慢你就会有感觉了。