03 — 指标解读指南
目标: 看懂 ScratchV 的各种性能数字,知道怎么"读仪表盘"。 前置要求: 已运行过
make bench-cnn或make bench-ci
目录
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 仪表盘解读
仪表盘长什么样?
💡 这不是说"ScratchV 烂"。LLVM 用浮点指令是"作弊"——它依赖 CPU 有浮点单元。ScratchV 用纯整数指令模拟浮点运算,本身就是更难的挑战。目标是缩小差距,也就是让整数路径尽可能接近浮点路径的效率。
仪表盘长什么样?
运行 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% |
每一步都有明确的数字支撑,每一步都是针对最内层循环的优化。
从数据找方向
- 如果 ALU 占比很高 → 你的计算密度大,考虑减少每条 MAC 的指令数
- 如果 Load/Store 占比高 → 考虑更好的寄存器分配、数据复用
- 如果 Branch 占比高 → 考虑循环展开减少分支
- 如果缓存 miss 多 → 考虑数据布局优化(CHW→HWC 等)
下一步: 动手优化?看 optimization_guide。
遇到问题?看 04-故障排除FAQ。
下一步: 动手优化?看 optimization_guide。 遇到问题?看 04-故障排除FAQ。