3、Perf实战:CPU周期与指令计数

好,咱们进入正题。Perf stat 这个工具,说白了就是给你程序做「体检」的。它能告诉你程序跑了多少指令、花了多少CPU周期。这两个数字一除,就是CPI——每指令周期数。这个指标,我做了这么多年ADAS,几乎天天都要看。

3.1 perf stat 的基本用法

先来个最简单的。假设你写了个矩阵乘法程序,叫 mat_mul。想看看它跑得怎么样?一行命令搞定:

perf stat ./mat_mul

输出大概长这样:

 Performance counter stats for './mat_mul':

       12345678      cycles                    #    3.2 GHz
        9876543      instructions              #    0.80  insn per cycle
        2345678      branch-misses             #    5.6% of all branches

       0.003456789 seconds time elapsed

看到没?关键信息就几个:cycles(周期数)、instructions(指令数)、insn per cycle(IPC,每周期指令数)。IPC 和 CPI 是倒数关系,IPC=0.8 意味着 CPI=1.25。我个人习惯看 IPC,因为数字越大越好,直观。

小技巧: 如果你只想看特定事件,加 -e 参数。比如 perf stat -e cycles,instructions ./mat_mul。这样输出更干净。

3.2 理解CPI:为什么它这么重要?

CPI 是啥?就是执行一条指令平均需要多少个CPU周期。理想情况下,现代CPU可以做到 CPI=1,甚至更低(比如0.5)。但现实往往很骨感。

我在项目中遇到过一件事。有个ADAS的感知算法,跑在嵌入式平台上,帧率死活上不去。我一看 perf stat 结果,CPI 高达 3.5!这意味着CPU大部分时间都在等数据,而不是真正干活。后来发现是内存访问模式太差,cache miss 太多。

为什么会这样?你想想看,CPU 从 L1 cache 取数据只要几个周期,但从 DDR 内存取数据要几百个周期。如果程序频繁 cache miss,CPI 自然就上去了。

核心结论: CPI 越低,程序越接近CPU的极限性能。ADAS系统里,实时性要求高,CPI 通常要控制在 1.5 以下才算健康。

3.3 案例分析:矩阵乘法优化

咱们拿矩阵乘法开刀。这是ADAS里最常见的运算之一——图像变换、特征提取、神经网络推理,底层全是矩阵乘法。

先写个最朴素的版本:

// naive_mat_mul.c
void mat_mul(float *A, float *B, float *C, int N) {
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            float sum = 0.0f;
            for (int k = 0; k < N; k++) {
                sum += A[i * N + k] * B[k * N + j];
            }
            C[i * N + j] = sum;
        }
    }
}

用 perf stat 跑一下(N=1024):

perf stat ./naive_mat_mul

结果:

    5,234,567,890      cycles
    8,765,432,100      instructions
    1.67                IPC
    0.60                CPI

CPI=0.6?看起来不错?别急,这是理想情况。实际上,这个版本 cache 利用率极差。我们看看 cache miss:

perf stat -e cache-misses,cache-references ./naive_mat_mul

结果:

    1,234,567,890      cache-references
      987,654,321      cache-misses      # 80% miss rate

80% 的 cache miss!这意味着大部分时间CPU都在等内存。虽然指令级CPI看着低,但实际执行时间很长。

嗯,这里要注意。perf stat 给出的 cycles 是CPU实际运行的周期数,但其中包含了 stall 周期(等待内存、等待分支等)。所以 CPI 低不代表程序快,要看绝对时间。

3.4 优化:循环分块(Loop Tiling)

怎么优化?核心思路是提高数据局部性。让数据尽量留在 cache 里,别老往内存跑。

我常用的手法是循环分块。把大矩阵切成小块,让每个小块能塞进 L1 cache:

// tiled_mat_mul.c
void mat_mul_tiled(float *A, float *B, float *C, int N) {
    int BLOCK = 64;  // 根据L1 cache大小调整
    for (int i = 0; i < N; i += BLOCK) {
        for (int j = 0; j < N; j += BLOCK) {
            for (int k = 0; k < N; k += BLOCK) {
                // 处理一个 BLOCK x BLOCK 的小块
                for (int ii = i; ii < i + BLOCK; ii++) {
                    for (int jj = j; jj < j + BLOCK; jj++) {
                        float sum = C[ii * N + jj];
                        for (int kk = k; kk < k + BLOCK; kk++) {
                            sum += A[ii * N + kk] * B[kk * N + jj];
                        }
                        C[ii * N + jj] = sum;
                    }
                }
            }
        }
    }
}

再跑一次 perf stat:

perf stat ./tiled_mat_mul

结果:

    2,100,000,000      cycles
    8,800,000,000      instructions
    4.19                IPC
    0.24                CPI

cache miss 率从 80% 降到了 15% 左右。执行时间缩短了 60% 以上。

避坑指南: 我曾经在某个项目里,把 BLOCK 设得太大,结果 cache miss 反而更严重。因为块太大,L1 cache 装不下,频繁换入换出。记住:BLOCK 大小要小于 L1 cache 容量的一半,留点余量给其他数据。

3.5 用 perf stat 对比优化效果

优化前后对比,我习惯用这个命令:

perf stat -r 5 -e cycles,instructions,cache-misses,cache-references ./tiled_mat_mul

-r 5 表示跑5次取平均,避免偶然误差。输出结果一目了然:

指标 朴素版本 分块版本 提升
Cycles 5.23e9 2.10e9 2.5x
IPC 1.67 4.19 2.5x
Cache miss rate 80% 15% 5.3x
执行时间 1.63s 0.66s 2.5x

你看,IPC 从 1.67 涨到 4.19,CPI 从 0.6 降到 0.24。但真正重要的是执行时间缩短了 2.5 倍。这就是 perf stat 的价值——帮你找到瓶颈,验证优化效果。

我的经验: 在ADAS系统里,矩阵乘法优化通常能带来 2-5 倍的性能提升。但别只盯着 CPI。有时候 CPI 降了,但指令数增加了(比如循环展开),总时间反而没变。一定要看绝对时间。

3.6 小结

perf stat 是性能分析的「第一把刀」。它能快速告诉你:

  • 程序跑了多少指令、花了多少周期
  • CPI/IPC 是否健康
  • cache 效率如何

记住:优化不是玄学,是数据驱动的。每次改代码,跑一遍 perf stat,看数字说话。下一节咱们会深入 perf record,看看到底是哪段代码在拖后腿。