03 — 指标解读指南

目标: 看懂 ScratchV 的各种性能数字,知道怎么"读仪表盘"。 前置要求: 已运行过 make bench-cnnmake bench-ci


目录
  1. 指标全景:一张图看懂
  2. 静态指令数 vs 动态指令数
  3. 指令类型分解
  4. 缓存 (Cache) 指标
  5. ScratchV vs LLVM 对比
  6. Dashboard 仪表盘解读
  7. 用指标指导优化

1. 指标全景:一张图看懂

编译器的性能指标,最终都回答一个问题:这程序在 CPU 上跑起来有多快?

但"跑起来"由三个因素决定:

执行时间 ≈ 指令条数 × 每条指令的平均耗时 × 等内存的时间比例
              ↑                ↑                  ↑
          动态指令数          CPI              Cache miss
        (越多越慢)      (越复杂越慢)        (miss 越多越慢)

ScratchV 目前主要衡量动态指令数(因为我们可以精确计数),也部分衡量缓存效果


2. 静态指令数 vs 动态指令数

核心区别
静态指令数 动态指令数
是什么 编译器生成了多少条指令 CPU 实际执行了多少条指令
数法 数一遍源码,每条算 1 考虑循环,循环里的指令每次迭代都算 1
生活类比 菜谱有多少行步骤 厨师实际上做了多少次操作
受什么影响 代码生成质量 循环次数 × 每次循环的指令

一个例子

假设你要做 100 个三明治:

for i in range(100):         # 循环 100 次
    拿面包()                  # 1 条指令
    抹黄油()                  # 1 条指令
    放火腿()                  # 1 条指令
  • 静态指令 = 3 条(拿面包抹黄油放火腿
  • 动态指令 = 3 × 100 = 300 条(每条执行了 100 遍)

🔑 动态指令数才是影响速度的真正因素。优化编译器最重要的目标就是减少动态指令数。

在 CNN 中

一个 CNN 模型的 Conv2D 层有 6 层嵌套循环:

for oh in 0..OH:            输出高度,比如 62
  for ow in 0..OW:          输出宽度,比如 62
    for oc in 0..OC:        输出通道,比如 32
      for kh in 0..KH:      卷积核高度,通常 3
        for kw in 0..KW:    卷积核宽度,通常 3
          for ic in 0..IC:  输入通道,比如 3
            # 这里约 10 条指令

最内层循环里的 ~10 条指令,实际执行了 62×62×32×3×3×3 ≈ 3300 万次

这就是为什么优化最内层循环是收益最高的——里面每条指令减掉 1 条,动态指令数就少 3300 万。


3. 指令类型分解

ScratchV 把指令分成以下几类:

类型 RISC-V 指令示例 做什么
ALU (算术逻辑) add, sub, mul, div, srai, slli, and, or, xor 计算(加减乘除、位运算)
Load lw 从内存读数据到寄存器
Store sw 从寄存器写数据到内存
Branch beq, bne, blt, bge, j, jal 控制流(判断、跳转、循环)
FP (浮点) fmul.s, fadd.s, fdiv.s 浮点小数运算(仅 LLVM float32 路径)

怎么看这些比例?

一个好的 CNN 编译器,理想情况是: - ALU 占比高(说明在做实际计算) - Load/Store 占比低(说明数据复用做得好,寄存器利用充分) - Branch 占比低(说明循环开销小)

ScratchV 当前数据(cnn.onnx,优化后)
类型 ScratchV (RV32IM) LLVM (RV64FD)
ALU 60.0% 28.4%
Load 20.1% 28.6%
Store 6.7% 0.2%
Branch 6.7% 4.4%
FP 0% (无浮点指令) 28.4%

分析: - ScratchV 的 ALU 占比高是因为 Q16.16 定点运算需要额外指令(mul + srai 代替 fmul) - ScratchV 的 Store 占比远高于 LLVM,说明中间结果无法充分保留在寄存器中 - LLVM 的 FP 占比 28.4% 说明它在用硬件浮点指令直接算


4. 缓存 (Cache) 指标

缓存是什么?

CPU 里的缓存(Cache)是一块很小但很快的内存,夹在 CPU 和主内存之间。

CPU ←→ Cache (很快, 一般 32KB-256KB) ←→ 主内存 RAM (慢一些, 几 GB)
     命中: 1-2 个时钟周期              未命中: 50-200 个时钟周期

关键指标
指标 含义 越高越好?
D$ 命中率 数据缓存命中的比例 ✅ 越高越好,>90% 优秀
D$ 缺失字节 因缓存未命中而多读的字节数 ❌ 越少越好
综合加速比 综合考虑指令数和缓存后的性能比值

ScratchV 当前数据
指标 LLVM ScratchV 比值
D$ 命中率 88.75% 88.75% 1:1
D$ 缺失字节 19.1 亿 74.8 亿 3.9x

ScratchV 的缓存缺失字节是 LLVM 的 3.9 倍,因为: 1. 动态指令数更多(3.22B vs 1.06B 静态就不在一个数量级)→ 总访存次数更多 → 缺失自然多 2. 寄存器压力大 → 更多的 spill(寄存器不够用时把数据临时存到内存)


5. ScratchV vs LLVM 对比

为什么以 LLVM 为基准?

LLVM 是业界最成熟的编译器框架之一。以它为 baseline,我们知道自己离"工业级"还有多大差距。

当前核心数据(cnn.onnx)
指标 LLVM (float32) ScratchV (优化后) 比值
静态指令 1,059 749 0.71x ✅
动态指令 18.5 亿 32.2 亿 1.74x
D$ 缺失字节 19.1 亿 74.8 亿 3.9x ❌

怎么读这些数字?
  • 静态指令数 ScratchV 更少(749 vs 1059):说明 ScratchV 的手写内联代码生成器非常紧凑。✅
  • 动态指令数 ScratchV 多了 74%:因为 Q16.16 定点运算每个乘加(MAC)需要 ~12 条指令,而 LLVM 的 float32 只需 ~2 条。❌
  • 缓存缺失多了 3.9 倍:因为指令多 → 访存多 → 缓存压力大。❌

💡 这不是说"ScratchV 烂"。LLVM 用浮点指令是"作弊"——它依赖 CPU 有浮点单元。ScratchV 用纯整数指令模拟浮点运算,本身就是更难的挑战。目标是缩小差距,也就是让整数路径尽可能接近浮点路径的效率。


6. Dashboard 仪表盘解读

仪表盘长什么样?

运行 make bench-dashboard 生成 benchmark_reports/dashboard.html,用浏览器打开。

各板块说明
板块 展示内容 怎么看
静态指令对比 ScratchV vs LLVM,按算子(Conv/MaxPool/Gemm...)拆分 找哪个算子差距最大 → 优先优化那个
动态指令对比 带百分比差异 看总差距和每个算子的贡献
指令类型分布 ALU/Load/Store/Branch/FP 饼图 看 Load/Store 占比是否合理
缓存分析 命中率 + 缺失字节 找内存访问瓶颈
趋势图 (history.html) 历次提交的性能变化曲线 看优化是否有效果,什么时候退步了

趋势图使用技巧
  • 曲线下降 = 优化有效 ✅
  • 曲线上升 = 可能有性能衰退 ❌
  • 曲线平 = 最近没有实质优化

7. 用指标指导优化

优化的黄金法则

永远先测,再优化。优化完再测。没数据不说话。

ScratchV 的优化流程:

1.  benchmark  拿到当前数据
2. 分析哪个算子/哪类指令占比最高  找瓶颈
3. 提出优化方案  预计能减多少条指令
4. 实现优化   benchmark 验证
5. 对比数据  实际减了多少是否达到预期
6. 如果效果不佳  回到第 2 

实战:Conv2D 优化案例
阶段 动态指令数 相比上一步
优化前 7,802,000,000
移除边界检查 6,500,000,000 -16.7%
常量外提 5,200,000,000 -20.0%
指针步进 3,800,000,000 -26.9%
强度削减 3,222,000,000 -15.2%
累计 -58.7%

每一步都有明确的数字支撑,每一步都是针对最内层循环的优化。

从数据找方向
  1. 如果 ALU 占比很高 → 你的计算密度大,考虑减少每条 MAC 的指令数
  2. 如果 Load/Store 占比高 → 考虑更好的寄存器分配、数据复用
  3. 如果 Branch 占比高 → 考虑循环展开减少分支
  4. 如果缓存 miss 多 → 考虑数据布局优化(CHW→HWC 等)

下一步: 动手优化?看 optimization_guide。 遇到问题?看 04-故障排除FAQ