我们项目里的感知系统需要同时跑检测、分割、深度估计三个任务。一开始三个模型各自编译成hbm分别加载,内存占用直接乘3,BPU core还经常抢资源。花了一周多研究工具链的model_group和BPU调度机制,最后把三个模型打包成一个hbm,实现了统一调度和内存复用,内存省了40%,总延迟从65ms降到38ms。这篇把完整打包过程和调度优化的经验记下来。
一、单hbm vs 多hbm的性能对比
先看一下分别加载三个hbm的问题:
```bash
hb_mapper makehbm --model detect.onnx --output detect.hbm
hb_mapper makehbm --model seg.onnx --output seg.hbm
hb_mapper makehbm --model depth.onnx --output depth.hbm
```
板端C++分别加载:
```cpp
auto detect_model = hbDNNInitializeFromFiles("detect.hbm");
auto seg_model = hbDNNInitializeFromFiles("seg.hbm");
auto depth_model = hbDNNInitializeFromFiles("depth.hbm");
```
实测资源占用:
指标 三个单hbm 打包后单hbm 节省
内存峰值 384MB 228MB -40.6%
初始化时间 2.1s 0.8s -61.9%
总延迟(串行) 65ms 38ms -41.5%
BPU利用率 67% 89% +22%
内存占用高的原因是三个模型各自分配input/output tensor,1920x1080x3的图像同时存在3套buffer。打包后共享input buffer和部分中间activation,省了一大块。
二、统一编译:全局内存优化的关键
打包有两种方式:分别编译后merge,或者统一编译。推荐统一编译,因为工具链能在全局视角做内存优化。
方式A(不推荐):分别编译后merge
```bash
hb_mapper makehbm --model detect_quant.onnx --output detect.hbm
hb_mapper makehbm --model seg_quant.onnx --output seg.hbm
hb_mapper makehbm --model depth_quant.onnx --output depth.hbm
# 合并
hb_model_merge detect.hbm seg.hbm depth.hbm --output multi_task.hbm
```
简单但内存优化有限,三个模型各自编译,activation buffer不会共享。
方式B(推荐):统一编译
把所有子模型放在一个ONNX文件里,用不同的输出节点区分:
```python
import onnx
from onnx import helper
# 加载三个子模型
detect = onnx.load('detect_quant.onnx')
seg = onnx.load('seg_quant.onnx')
depth = onnx.load('depth_quant.onnx')
# 给每个子模型的tensor加前缀,避免名字冲突
detect_prefix = 'detect_'
for node in detect.graph.node:
node.name = detect_prefix + node.name
node.input[:] = [detect_prefix + i if not i.endswith(('.weight', '.bias')) else i for i in node.input]
node.output[:] = [detect_prefix + o for o in node.output]
# seg和depth同理加前缀...
# 创建合并后的graph
merged_graph = helper.make_graph(
nodes=list(detect.graph.node) + list(seg.graph.node) + list(depth.graph.node),
name='multi_task',
inputs=[detect.graph.input[0]], # 共享同一个输入
outputs=list(detect.graph.output) + list(seg.graph.output) + list(depth.graph.output),
initializer=list(detect.graph.initializer) + list(seg.graph.initializer) + list(depth.graph.initializer),
)
merged_model = helper.make_model(merged_graph)
onnx.save(merged_model, 'multi_task.onnx')
```
然后统一编译:
```bash
hb_mapper makehbm --model multi_task.onnx --output multi_task.hbm
```
注意两个坑:
1. tensor名字冲突:三个子模型可能有同名tensor,合并前必须加前缀。
2. 输入量化参数一致:三个模型的input quantization scale必须相同,否则工具链报错。我的做法是三个模型用同一套校准数据。
三、BPU调度策略:串行还是并行?
打包成单hbm之后,调度策略很关键。征程6是双核BPU,两个core可以同时跑不同的subgraph。
我测试了三种策略:
策略1:串行执行
```cpp
for (auto& model : {detect, seg, depth}) {
hbDNNRun(model, input, output);
}
```
三个模型依次跑,总延迟=18+15+22=55ms。实现简单但延迟高。
策略2:双core并行
```cpp
// 两个线程
std::thread t1([&](){ hbDNNRun(detect, input, detect_out); });
std::thread t2([&](){ hbDNNRun(seg, input, seg_out); });
t1.join(); t2.join();
// depth等其中一个core空出来再跑
hbDNNRun(depth, input, depth_out);
```
两个core并行跑detect和seg(延迟=max(18,15)=18ms),然后跑depth(22ms),总延迟=40ms。
策略3:模型内pipeline + core间串行(推荐)
编译时开启pipeline优化:
```yaml
compiler_parameters:
optimize_level: O3
core_num: 2
pipeline: true
compile_mode: latency
```
工具链自动把每个模型内部的算子分配到两个core上形成流水线。三个模型串行执行,但每个模型内部是并行的:
模型 内部并行延迟
detect 12ms
seg 10ms
depth 16ms
总延迟=38ms,比策略2还快2ms。因为工具链的pipeline优化比手动多线程调度更高效,省去了线程切换和缓存竞争的开销。
四、内存优化:共享buffer的实践
统一编译成单hbm之后,内存优化主要通过三点实现:
1. 输入buffer共享
三个模型共享同一个输入图像,工具链编译时识别到input shape相同,自动复用同一块DDR内存。
2. 中间activation复用
不同模型的推理过程中,如果某些中间tensor的生命周期不重叠,工具链会把它们放到同一块物理内存。编译日志里会输出复用率:
```
[INFO] Activation buffer reuse ratio: 71.3%
```
我们三个模型打包后的复用率是71.3%,比分别编译时的23%高了很多。
3. 手动管理output buffer
C++代码里显式复用input buffer:
```cpp
// 只申请一次input内存
hbDNNTensor input_tensor;
hbDNNAllocateTensor(&input_tensor);
// 三个output各自申请(shape不同,没法共享)
hbDNNTensor detect_out, seg_out, depth_out;
hbDNNAllocateTensor(&detect_out);
hbDNNAllocateTensor(&seg_out);
hbDNNAllocateTensor(&depth_out);
// 推理时复用同一个input
hbDNNRun(detect_model, &input_tensor, &detect_out);
hbDNNRun(seg_model, &input_tensor, &seg_out);
hbDNNRun(depth_model, &input_tensor, &depth_out);
```
五、不同帧率任务的调度
有些场景下三个任务的帧率要求不一样。比如检测需要30fps(安全关键),分割和深度只需要10fps(用于建图)。这种情况下不需要每帧都跑三个模型。
我写了一个轻量的调度器:
```cpp
class MultiTaskScheduler {
public:
void RunFrame(cv::Mat& input_image) {
frame_counter++;
// 每帧都跑检测
auto detections = RunDetection(input_image);
// 每3帧跑一次分割(10fps)
if (frame_counter % 3 == 0) {
auto seg_mask = RunSegmentation(input_image);
}
// 每3帧跑一次深度(10fps),和分割错开
if (frame_counter % 3 == 1) {
auto depth_map = RunDepth(input_image);
}
}
private:
int frame_counter = 0;
};
```
这种调度方式在C++层实现,不依赖BPU硬件,灵活性很高。征程6的BPU core在这种策略下负载比较均衡,不会某个core一直满载另一个空闲。
六、踩坑记录
坑1:模型加载顺序
三个模型加载顺序会影响内存分配。建议先加载最大的模型(占用内存最多),再加载小的。这样内存碎片更少。
坑2:量化参数不一致
三个模型的input quantization scale不一样时,工具链会报错。确保三个模型用同一套校准数据,或者分别校准但确保input scale一致。
坑3:输出节点名冲突
合并ONNX时如果输出节点名字相同,工具链会覆盖。加前缀是最简单的解决方案。
坑4:调试困难
打包后的模型调试比单模型麻烦。建议先分别调通每个单模型,确认精度没问题,再打包。打包后如果精度下降,用hb_model_verifier逐层对比每个subgraph的输出。
七、性能数据汇总
配置 内存峰值 总延迟 帧率 备注
三个单hbm串行 384MB 65ms 15.4fps 基准
三个单hbm并行 384MB 42ms 23.8fps 手动多线程
打包hbm串行 228MB 55ms 18.2fps 无pipeline
打包hbm+pipeline 228MB 38ms 26.3fps 推荐方案
打包hbm+调度器 228MB - 30fps检测+10fps其他 最终产品配置
最终产品用的是"打包hbm+调度器"方案,检测30fps,分割和深度10fps,内存228MB,满足系统要求。
八、注意事项总结
1. 统一编译优于分别编译后merge:全局内存优化效果更好,activation复用率更高。
2. 注意tensor名字冲突:合并前给每个子模型的tensor加前缀。
3. 输入量化参数要一致:统一校准数据或者确认三个模型的input scale相同。
4. pipeline编译优于手动多线程:工具链的模型内pipeline优化比手动调度更稳。
5. 内存手动管理:C++代码里显式复用input buffer,output按需分配。
6. 帧率不同用软件调度器:C++层做帧率控制,灵活而且不浪费BPU资源。
7. 先调通单模型再打包:打包后调试困难,单模型精度没问题再合并。
8. 加载顺序从大到小:减少内存碎片,避免OOM。
