模型部署到板端之后,"能跑"只是第一步,"跑得快"才是产品的要求。地平线提供了一套性能分析工具,可以详细记录模型在BPU上的每一层耗时、内存占用、算子执行顺序。这篇结合一个真实项目的Profiling过程,讲讲怎么用这些数据做优化决策,顺便分享几个我用这些工具时踩的坑。
一、工具链的性能分析工具全家桶
地平线工具链提供了三个核心的性能分析工具:
1. hb_perf:生成详细的性能报告(HTML格式),看每层延迟和内存
2. hb_trace:抓取BPU执行trace,看算子级别的执行顺序和core分配
3. hrt_model_exec:标准化的性能测试工具,输出延迟分布和利用率
我一般的用法是先用hb_trace抓数据,再用hb_perf生成可视化报告,最后用hrt_model_exec做基准对比。
二、hb_trace的基本用法
```bash
# 确认trace工具可用
which hb_trace
# /usr/bin/hb_trace
# 对hbm模型做trace
hb_trace --model ./model.hbm \
--input ./input_640x640.bin \
--output trace_result.json \
--trace-level full
```
--trace-level有几个选项:
· basic:只记录每层总耗时
· full:记录每层耗时 + 算子详细信息 + 内存分配
· memory:重点关注内存分配和复用
· pipeline:记录BPU双核的流水线执行情况
我一般直接用full,信息最全,后续分析不用重跑。
注意一个坑:
trace抓数据的时候,如果输入bin文件的尺寸和模型期望的不一致,工具不会报错,但抓出来的数据是错的。我有一次用640x640的模型但输入bin是512x512,trace结果里所有层的延迟都异常低(因为实际只处理了一部分数据),排查了半天才发现是输入尺寸的问题。
三、Trace结果的结构解读
Trace输出是JSON格式,结构大概这样:
```json
{
"model_info": {
"model_name": "yolov5m",
"input_shape": [1, 3, 640, 640],
"output_shape": [1, 25200, 85],
"total_layers": 157
},
"execution_trace": [
{
"layer_id": 0,
"layer_name": "conv_stem",
"op_type": "Conv2d",
"input_shape": [1, 3, 640, 640],
"output_shape": [1, 32, 320, 320],
"kernel_size": [6, 6],
"stride": [2, 2],
"timing": {
"bpu_cycles": 420000,
"total_us": 2100,
"start_time": 0,
"end_time": 2100
},
"memory": {
"input_size_bytes": 1228800,
"output_size_bytes": 13107200,
"weight_size_bytes": 6912
},
"core_id": 0,
"pipeline_stage": 0
}
],
"summary": {
"total_bpu_cycles": 85000000,
"total_time_us": 45000,
"bpu_utilization": 76.5,
"memory_peak_mb": 142.3
}
}
```
关键字段:
字段 含义 优化关注点
bpu_cycles BPU实际执行周期 越大说明计算量越大
total_us 实际耗时 定位最耗时的层
core_id 执行该层的BPU core 看负载是否均衡
pipeline_stage 流水线阶段 看双核流水线利用
memory_peak_mb 内存峰值 是否超板端DDR
四、Profiling分析实战
我分析了一个检测模型,总延迟45ms,帧率22fps,目标是做到30fps(延迟
步骤1:找出Top耗时层
写了个Python脚本解析trace:
```python
import json
with open('trace_result.json') as f:
trace = json.load(f)
layers = trace['execution_trace']
sorted_layers = sorted(layers, key=lambda x: x['timing']['total_us'], reverse=True)
print("Top 10最耗时层:")
for i, layer in enumerate(sorted_layers[:10]):
print(f"{i+1}. {layer['layer_name']}: {layer['timing']['total_us']/1000:.2f}ms "
f"({layer['op_type']}, out={layer['output_shape']})")
```
输出:
```
Top 10最耗时层:
1. backbone_stage4_conv3: 8.45ms (Conv2d, out=[1,512,20,20])
2. neck_pan_conv1: 6.23ms (Conv2d, out=[1,256,40,40])
3. backbone_stage3_conv2: 5.67ms (Conv2d, out=[1,256,40,40])
4. detect_head_cls: 4.12ms (Conv2d, out=[1,80,80,80])
5. backbone_stage4_conv1: 3.89ms (Conv2d, out=[1,512,20,20])
```
前3层加起来20.35ms,占总延迟45%。优化这几层收益最大。
步骤2:分析为什么慢
看第1层backbone_stage4_conv3,输出shape是[1,512,20,20],kernel=3x3, stride=1。512通道的大卷积在20x20的feature map上跑。
这一层的bpu_cycles是1200万,而同计算量的另一层backbone_stage3_conv2(256通道,40x40 feature map)只有450万cycles。512通道的卷积虽然FLOPs只多了50%,但cycles多了167%。
原因是512太大了,BPU的SRAM装不下全部weight,需要分多次从DDR加载,每次加载之间要等待数据传输。这是典型的内存带宽瓶颈,不是计算瓶颈。
步骤3:针对性优化
方向A:降通道数
如果512通道对精度不是必须的,用bottleneck结构降通道:
```python
# 原始
nn.Conv2d(512, 512, 3, padding=1)
# 优化
nn.Sequential(
nn.Conv2d(512, 256, 1), # 1x1降通道
nn.Conv2d(256, 256, 3, padding=1), # 3x3计算
nn.Conv2d(256, 512, 1), # 1x1升通道
)
```
bottleneck结构虽然多了两层1x1卷积,但3x3卷积的通道从512降到了256,weight加载量减少了一半。实测延迟从8.45ms降到了4.2ms,精度掉0.4%。
方向B:调整weight缓存策略
在编译配置里调weight_cache_strategy:
```yaml
compiler_parameters:
optimize_level: O3
core_num: 2
pipeline: true
memory_parameters:
weight_cache_strategy: round_robin
weight_prefetch: true
```
round_robin下,工具链把weight轮流加载到两个core的SRAM,减少单个core的等待。实测backbone_stage4_conv3的延迟从8.45ms降到了6.8ms。
步骤4:CPU fallback层排查
Trace里core_id=-1的层表示fallback到CPU执行。我项目里有3个:
· focus_slice:slice操作,BPU不支持
· upsample_nearest:3x上采样,BPU只支持2x/4x/8x
· concat_3:3路tensor拼接,shape不一致时BPU不支持
focus_slice解决:替换成stride=2的6x6卷积
```python
# 原始Focus层
class Focus(nn.Module):
def forward(self, x):
return torch.cat([
x[..., ::2, ::2],
x[..., 1::2, ::2],
x[..., ::2, 1::2],
x[..., 1::2, 1::2]
], dim=1)
# 替换
self.stem = nn.Conv2d(3, 32, 6, stride=2, padding=2)
```
省了7ms。
upsample_nearest解决:改用转置卷积
```python
# 原始:3x上采样(fallback到CPU)
F.interpolate(x, scale_factor=3, mode='nearest')
# 替换:转置卷积
nn.ConvTranspose2d(channels, channels, 2, stride=3, padding=1)
```
省了5ms。
五、hb_perf生成可视化报告
```bash
hb_perf --model yolov5m.hbm --output perf_report.html
```
生成的HTML报告包含:
· 每层延迟的柱状图
· 内存占用的堆叠图
· BPU core利用率曲线
· pipeline stage的执行时序图
一个小技巧: 报告里有"Layer Dependency Graph",可以看到算子之间的数据依赖关系。如果发现有大量的cross-core数据搬运(从一个core的输出直接是另一个core的输入),说明pipeline没有优化好,可以尝试调整编译参数或者模型结构来减少跨core搬运。
六、优化前后的完整对比
经过三轮优化:
指标 优化前 优化后 提升
总延迟 45ms 28ms -38%
帧率 22fps 35.7fps +62%
BPU利用率 76% 91% +15%
CPU fallback层数 3 0 -3
内存峰值 142MB 108MB -24%
mAP 82.1% 81.7% -0.4%
帧率从22fps干到35.7fps,超过30fps目标。精度只掉0.4%,在可接受范围。
七、hrt_model_exec的标准化测试
社区里有人推荐用hrt_model_exec做标准化的性能基准:
```bash
hrt_model_exec --model yolov5m.hbm \
--input input_640x640.bin \
--thread-num 1 \
--frame-count 100 \
--warmup-frame 10 \
--output perf.json
```
这个工具会输出:
· 平均延迟
· P50/P90/P99延迟(百分位延迟)
· BPU利用率
· DDR带宽占用
重点看P99延迟。
平均延迟28ms看着不错,但P99可能有42ms。在自动驾驶场景里,最坏情况比平均情况更关键。建议产品验收时以P90或P99为准。
顺便提一嘴,hrt_model_exec的--thread-num参数不要设超过BPU core数。征程6M是双核BPU,设2就够了,设4反而因为线程切换变慢。
八、自定义Trace点
如果模型里有自定义的C++后处理,可以手动插入trace点:
```cpp
#include
void my_nms(float* boxes, int num) {
HB_TRACE_BEGIN("custom_nms");
// ... NMS逻辑
HB_TRACE_END("custom_nms");
}
```
自定义trace点会出现在最终的trace报告里,帮助分析后处理的耗时。我项目里的NMS后处理最开始占8ms,优化了IOU计算(用SIMD加速)之后降到了2.5ms。
九、注意事项总结
1. 先用checker预检查:比等编译报错再排查效率高10倍。
2. trace抓数据时确认输入尺寸:输入bin尺寸不对会导致trace数据全错。
3. Top耗时层决定总延迟:前20%的层通常占延迟的60-70%。
4. 区分计算瓶颈和内存瓶颈:cycles高但shape小,大概率是weight加载瓶颈。
5. CPU fallback层逐个消除:替换优于fallback,性能差距10-50倍。
6. 看P99不要只看平均:长尾延迟才是产品稳定性的杀手。
7. 线程数不超过core数:J6M双核,最多2线程做推理。
8. 自定义trace点分析后处理:HB_TRACE_BEGIN/END插到关键函数里。
9. pipeline优化对多层小卷积效果最好:1x1卷积堆叠的bottleneck结构收益最大。
10. 跨core数据搬运要关注:hb_perf报告里的dependency graph会暴露这个问题。
