4. 性能调优基础

性能调优这件事,说白了就是回答三个问题:哪里慢?为什么慢?怎么改? 很多工程师一上来就对着代码瞎猜,改几行重新编译,跑一遍看看——这叫“试错法”,不是调优。

我刚开始做嵌入式的时候也犯过这毛病。有一次一个音频解码任务总是卡顿,我怀疑是算法效率低,花了两天重写了核心循环,结果性能只提升了3%。后来用工具一查,发现真正的瓶颈是内存拷贝——一个memcpy占了40%的CPU时间。嗯,从那以后我就学乖了:先测量,再动手。

4.1 性能计数器(PMU)原理

现代CPU内部都集成了一组硬件计数器,叫PMU(Performance Monitoring Unit)。它们能记录各种底层事件:指令执行了多少条、缓存命中/缺失了多少次、分支预测错了多少回……

你想想看,这些数据是CPU硬件直接统计的,几乎零开销。比起你在代码里插时间戳,PMU的精度和效率都高得多。

PMU事件类型 典型事件 调优意义
指令计数 CPU_CYCLES, INSTRUCTIONS 计算CPI(每指令周期数)
缓存事件 L1D_CACHE_MISS, LLC_MISS 评估内存访问效率
分支事件 BRANCH_MISS 发现分支预测惩罚
内存访问 MEM_LOAD_RETIRED 定位慢速内存操作

核心指标:CPI(Cycles Per Instruction)

CPI = CPU_CYCLES / INSTRUCTIONS。理想情况下CPI接近1.0。如果超过2.0,说明CPU经常在等待——可能是缓存缺失、内存延迟或分支预测错误。我见过一个项目CPI高达4.5,最后发现是频繁访问未对齐的内存地址导致的。

4.2 Profiling工具:perf 与 gprof

工具方面,我主要用两个:perf 和 gprof。它们各有侧重,我一般搭配着用。

4.2.1 perf:Linux下的瑞士军刀

perf 直接利用PMU硬件,能统计到内核态和用户态的事件。我个人习惯先用 perf stat 看整体概况,再用 perf record/report 定位热点函数。

# 统计程序整体性能指标
perf stat ./my_app

# 采样记录热点(默认按周期采样)
perf record -F 99 ./my_app
perf report

输出示例:

Performance counter stats for './my_app':

    1,234,567,890      cycles                    # 0.77 GHz
      987,654,321      instructions              # 0.80  insn per cycle
       12,345,678      L1-dcache-load-misses     # 5.2% of all L1-dcache hits
        1,234,567      branch-misses             # 3.1% of all branches

采样频率怎么选?

我一般用 -F 99(每秒99次采样),对大多数嵌入式场景够用了。频率太高会增加开销,太低又可能漏掉短时热点。99Hz是个不错的折中。

4.2.2 gprof:函数级调用图

gprof 是GNU工具链自带的,使用起来很简单:编译时加 -pg 选项,运行程序后会自动生成 gmon.out,然后 gprof 解析。

# 编译
gcc -pg -o my_app my_app.c

# 运行(会生成 gmon.out)
./my_app

# 分析
gprof my_app gmon.out

输出会包含两部分:Flat Profile(每个函数的总耗时)和 Call Graph(调用关系及耗时传播)。

gprof 的局限性

gprof 基于编译插桩,会改变代码布局,对性能有影响(大约10-30%)。而且它只统计用户态函数,内核调用、中断处理一概不统计。我曾经在一个实时系统上用gprof,结果发现测量结果和实际表现对不上——因为大部分时间花在了内核调度上,gprof根本看不到。

4.3 函数调用耗时分析实战

光说不练假把式。我们拿一个实际例子走一遍流程。

4.3.1 场景:图像处理流水线

假设有一个图像处理程序,包含三个主要阶段:读取 → 滤波 → 输出。用户反馈处理一帧需要200ms,但目标要求是50ms。

4.3.2 第一步:perf stat 看全局

perf stat ./image_process

输出显示CPI高达3.8,L1数据缓存缺失率18%。这说明CPU大量时间在等内存数据。

4.3.3 第二步:perf record 定位热点

perf record -F 99 ./image_process
perf report

热点函数排名:

函数名 占比 说明
gaussian_filter 62% 高斯滤波
read_frame 18% 从文件读取
write_frame 12% 写入输出
其他 8% 杂项

很明显,gaussian_filter 是头号瓶颈。

4.3.4 第三步:gprof 看调用关系

gprof image_process gmon.out

调用图显示 gaussian_filter 内部调用了 get_pixel 和 set_pixel 各数百万次,而且 get_pixel 每次都要做边界检查——这个检查占了该函数40%的时间。

优化方案

  1. 消除边界检查:在滤波循环外部预先处理边界像素,内部循环不做检查。实测减少30%耗时。
  2. 缓存友好访问:将二维数组按行连续访问,避免跨行跳跃。L1缺失率从18%降到6%。
  3. 循环展开:手动展开内层循环,减少分支开销。再降15%。

最终,gaussian_filter 从120ms降到35ms,整帧处理从200ms降到65ms——虽然还没到50ms,但已经接近了。剩下的优化空间在I/O层面,那是另一个故事了。

4.4 知识体系:性能分析流程

我把整个流程画成了一张图,方便你对照着用。

性能分析实战流程 1. 定义目标 2. perf stat 全局扫描 3. perf record 定位热点 4. gprof 分析调用链 5. 针对性优化 & 验证 未达标则回退 关键指标 • CPI < 2.0 • L1缺失率 < 10% • 分支缺失率 < 5% 常见陷阱 • 优化了非热点函数 • 忽略I/O和调度开销 • 一次改太多无法验证

4.5 避坑指南

  • 不要只看一个指标。CPI低不一定代表性能好——可能是指令执行了很多无用的循环。要结合指令数、缓存缺失一起看。
  • 采样次数要够。perf默认采样时间可能太短,尤其是程序启动阶段。我一般跑至少10秒,或者用 -a 全系统采样。
  • gprof的结果要交叉验证。我曾经遇到gprof报告某个函数占80%时间,但perf显示只有30%。后来发现是编译优化选项和-pg冲突,导致插桩代码被错误优化了。
  • 注意上下文切换。如果系统里有很多后台任务,perf统计的cycles可能包含其他进程的时间。用 perf stat --per-thread 可以只看当前线程。

我的个人习惯

拿到一个新项目,我第一件事不是看代码,而是跑一遍 perf stat 看看CPI和缓存缺失率。如果这两个指标正常,那问题多半在算法逻辑或I/O上;如果不正常,先优化内存访问模式。这个“先看硬件指标”的习惯帮我省了很多冤枉时间。