博客算法工具链征程6 BPU算子对齐优化:从28% padding到8%,帧率提升54%的实操

征程6 BPU算子对齐优化:从28% padding到8%,帧率提升54%的实操

默认265282026-08-30
23
0

我们项目里的感知系统需要同时跑检测、分割、深度估计三个任务。一开始三个模型各自编译成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。

算法工具链
社区征文征程6
评论0
0/600