来源:
notes/documents/01_dg板卡X5_BPU推理服务_原理_问答_代码.mddg 板卡 X5 BPU 推理服务:原理、问答与代码
日期:2026-09-19
一句话结论
dg 是 D-Robotics RDK X5 开发板,板子上最值钱的算力是 BPU(Brain Processing Unit,地平线自研的神经网络加速器)。它没有摄像头、麦克风、屏幕,但只要有网络就能当一台 AI 推理服务器:图片从网络进来,检测框和分类结果从网络出去。
我把这个想法做成了一个常驻服务,部署在 /opt/bpu-service,systemd 托管,访问地址 http://10.71.48.154:8080/。它直接调用板载 34 个官方 .bin 模型,不用做任何模型转换,实测 yolov8 端到端约 52 ms/请求、板上 4 并发 37.9 req/s。
1. 我做了什么
| 部分 | 内容 |
|---|---|
| 推理服务 | 纯标准库 HTTP 服务(ThreadingHTTPServer),零额外依赖 |
| 模型来源 | /opt/hobot/model/x5/basic 下 34 个官方 .bin,即插即用 |
| 解码能力 | YOLOv5 锚框解码、YOLOv8/v10/v11/v12 DFL 解码、ImageNet 分类 top-k、未知模型返回原始张量统计 |
| 输入路径 | 编码图片(jpg/png)和原始 NV12 帧两条,后者是以后接摄像头/解码器用的 |
| 工程能力 | 并发限流、延迟百分位指标、批量离线处理 CLI、内置上传页面、systemd 开机自启 |
| 代码规模 | 服务本体 1430 行 Python(engine 442 / decode 351 / server 275 / cli 234 / preprocess 125),加测试与工具共 1731 行 |
HTTP 接口:
GET / 内置上传页面(浏览器直接拖图片)
GET /healthz 健康检查
GET /v1/models 模型列表(含未加载的)
GET /v1/metrics 请求数、错误数、延迟百分位
POST /v1/infer 编码图片推理,?annotate=1 返回画好框的 jpeg
POST /v1/infer/nv12 原始 NV12 帧推理(摄像头/解码器路径)
POST /v1/perf 在服务进程里跑基准测试
2. 原理
2.1 BPU 到底是什么
BPU 是地平线自研的神经网络加速器。CPU(ARM Cortex-A55)擅长逻辑控制和串行任务,GPU 擅长图形和大规模并行浮点,而 BPU 是为卷积神经网络定制的专用集成电路:把卷积、池化、激活这些算子做成硬件流水线,用定点(INT8/INT16)运算换能效比。
所以在这块板子上,正确分工是:
- CPU 干”后勤”:解码 JPEG、缩放、颜色空间转换、后处理(反量化、解码检测框、NMS)、发 HTTP。
- BPU 干”正事”:把 640×640 的输入张量过一遍神经网络,吐出原始输出张量。
CPU 上跑 YOLOv8 可能几百毫秒一张,BPU 上只要 10 毫秒——这就是要调用 BPU 的原因。
2.2 X5 板子的实测实况
| 项 | 实测值 |
|---|---|
| 板卡 | D-Robotics RDK X5 V1.0(X5M) |
| 系统 | Ubuntu 22.04.5,RDK OS 3.5.0,内核 6.1.83 |
| CPU | 8× Cortex-A55,1.2 GHz,governor=schedutil |
| 内存 | 4 GB LPDDR4(系统可见 3062 MB,可用约 2.2 GB),另有 8 GB swap |
| 存储 | 根分区 57 GB,已用 20 GB,剩余 35 GB |
| BPU 频率 | 标称 500 MHz / 1 GHz 两档,实测运行在 996 MHz |
| 模型库 | /opt/hobot/model/x5/basic,34 个 .bin(检测 / 分类 / 分割 / 姿态各若干) |
| 设备节点 | /dev/bpu(10,85)、/dev/bpu_core0(10,84)、/dev/ion(10,127) |
| 内核模块 | bpu_hw_io_x5 ← bpu_cores ← bpu_framework |
| 摄像头 | 无。/dev/video* 不存在 |
| 麦克风/喇叭 | 无声卡外设。/dev/snd 能枚举到 ES8326 codec,但采集到的是一段饱和噪声(峰值削顶),属悬空输入,不可用 |
| 显示 | 无 /dev/fb*,只有 /dev/dri(GPU 渲染节点) |
2.3 从我的 Python 到 BPU 硬件的完整调用链
这是”如何精准调用到 BPU”的答案。整条链路是分层的,我的代码只碰最上面一层:
我的 Python 代码
│ from hbm_runtime import HB_HBMRuntime
▼
/usr/local/lib/python3.10/dist-packages/hbm_runtime/HB_HBMRuntime.so
│ pybind11 编译出的 aarch64 扩展,把 C++ 类暴露成 Python 类
│ 仅 NEEDED: libdnn.so(没有 RPATH,靠系统 ld.so 缓存找)
▼
/lib/libdnn.so ← 地平线 DNN 运行时,提供 hbDNN* C API
├─ /usr/hobot/lib/libcnn_intf.so.1
├─ /usr/hobot/lib/libhbmem.so.1 ← BPU 专用共享内存分配(ion)
└─ /lib/libhbrt_bayes_aarch64.so ← hbrt(BPU Runtime),版本 3.15.55
▼
Linux 内核模块:bpu_framework → bpu_cores → bpu_hw_io_x5
▼
字符设备 /dev/bpu_core0(10,84)、/dev/bpu(10,85)、/dev/ion(10,127)
▼
BPU 硬件(BPU core array)
用 ldd 可以验证这条链:
ldd /usr/local/lib/python3.10/dist-packages/hbm_runtime/HB_HBMRuntime.so
# libdnn.so => /lib/libdnn.so
# libcnn_intf.so.1 => /usr/hobot/lib/libcnn_intf.so.1
# libhbmem.so.1 => /usr/hobot/lib/libhbmem.so.1
# libhbrt_bayes_aarch64.so => /lib/libhbrt_bayes_aarch64.so
lsmod | grep -i bpu
# bpu_hw_io_x5 24576 0
# bpu_cores 28672 3 bpu_hw_io_x5
# bpu_framework 77824 4 bpu_hw_io_x5,bpu_cores
2.4 五个”契约点”——为什么这样写就能精准命中 BPU
调用 BPU 并不是”写对一行 import”就完了,真正的难点是让数据和元信息都符合运行时的约定。我踩过、并且必须同时满足的契约有五个:
契约一:模型必须是 BPU 编译后的 .bin。
BPU 不认识 PyTorch 的 .pt / ONNX。模型必须先用地平线 OpenExplorer 工具链做量化、编译、图优化,产出 .bin(里面是 BPU 指令 + 权重)。这一步决定了”能不能上 BPU”。
契约二:张量名不能硬编码,必须运行时读取。
input_names / output_names 只能从 HB_HBMRuntime 实例里读,因为不同模型的命名不同。我的代码里这一层是懒加载的:
class RuntimeSlot:
"""A loaded HB_HBMRuntime plus the metadata needed to build inputs."""
def __init__(self, spec: ModelSpec):
from hbm_runtime import HB_HBMRuntime
self.rt = HB_HBMRuntime(spec.path)
self.model_name = self.rt.model_names[0]
self.input_names = list(self.rt.input_names[self.model_name])
self.output_names = list(self.rt.output_names[self.model_name])
契约三:输入不是 [1,3,640,640] 的 RGB 张量,而是展平的 packed NV12。
这是最容易掉进去的坑,下一节单独讲。运行时报告的 input_shape 只是”逻辑形状”,真正要喂的 buffer 长度是 W*H*3/2,并且要包成 {模型名: {输入名: buffer}}。
契约四:量化的输出必须按量化参数反量化。
BPU 用定点运算,输出的可能是 int32/int8 加一个逐通道 scale。不是转成 float 就完事,必须按量化参数还原。
契约五:调度参数用默认值即可,但不能假设单实例。
同进程里对同一个 .bin 第二次建 HB_HBMRuntime,runtime 会打印 has loaded, Discard the latest 并去重——它不会给你两个独立句柄。所以并发模型必须是”单实例 + 信号量 + 多线程调 run()“。
2.5 一张 JPEG 的完整数据流
HTTP 请求体(JPEG bytes,137 KB)
① cv2.imdecode → BGR ndarray 810×1080×3
② letterbox 等比缩放 + 填 114 → BGR 640×640×3(记录 scale / pad,用于把框映射回原图)
③ BGR → YUV_I420 → Y+交错UV → packed NV12,长度 640*640*3/2 = 614400 字节
④ {model_name: {input_name: nv12}} → HB_HBMRuntime.run()
⑤ BPU 前向 → 原始输出张量(int32 / int8 / f32)
⑥ 按 output_quants 反量化 → float32
⑦ head 解码 + sigmoid + NMS → 检测框(模型坐标系)
⑧ 按 letterbox 参数逆映射回原图 → 原图像素坐标
⑨ JSON 序列化 → HTTP 响应
第 ③ 步是整套代码里最”反直觉”的一步。
2.6 为什么输入必须是 packed NV12
模型报告 input_shape = [1, 3, 640, 640],看起来像 RGB 三通道。但 X5 的 BPU 实际吃的是 NV12(一种 YUV 4:2:0 半平面格式):
- Y 平面:640×640 字节(亮度)
- UV 平面:640×320 字节,U 和 V 交错存放(色度,分辨率减半)
- 总计 640×640×3/2 = 614400 字节
也就是说,3 个”通道”在内存里不是 RGB 平面,而是 Y + 交错 UV。如果直接塞一个 [1,3,640,640] 的 RGB 数组,尺寸和排布都对不上。
我的转换代码:
def bgr_to_nv12(img: np.ndarray) -> np.ndarray:
"""Convert a BGR image to packed NV12 (Y plane followed by interleaved UV)."""
height, width = img.shape[:2]
if height % 2 or width % 2:
raise ValueError("NV12 requires even width/height")
area = height * width
yuv420p = cv2.cvtColor(img, cv2.COLOR_BGR2YUV_I420).reshape(area * 3 // 2)
y = yuv420p[:area]
u = yuv420p[area:area + area // 4].reshape(height // 2, width // 2)
v = yuv420p[area + area // 4:].reshape(height // 2, width // 2)
uv = np.stack((u, v), axis=-1).reshape(-1) # U/V 交错
return np.concatenate([y, uv]).astype(np.uint8)
def build_input_tensor(model_name: str, input_name: str, packed_nv12: np.ndarray) -> dict:
"""Wrap a packed NV12 buffer the way hbm_runtime.run() expects it."""
return {model_name: {input_name: np.ascontiguousarray(packed_nv12, dtype=np.uint8)}}
顺带一个实测教训:竖图必须用 resize=stretch,不能用 letterbox。 一张 1279×1920 的扣篮照片,letterbox 会把它缩成 640×426、上下各填 107 像素黑边,目标变得很小,最高置信度只有 0.65;改成 stretch 直接拉成 640×640 后,同一目标升到 0.86。
2.7 输出反量化
BPU 的输出张量可能是定点的。以 yolov8 的 box 分支为例,它是 int32 加逐通道 scale(axis=3),必须先还原成 float 再解码:
def dequantize(tensor: np.ndarray, quant) -> np.ndarray:
"""Convert a quantized output tensor to float32 using its QuantParams."""
if quant is None:
return tensor.astype(np.float32, copy=False)
qtype = str(getattr(quant, "quant_type", "")).split(".")[-1]
if qtype not in ("SCALE", "1"):
return tensor.astype(np.float32, copy=False)
scale = np.asarray(getattr(quant, "scale", np.array([])), dtype=np.float32)
zero_point = np.asarray(getattr(quant, "zero_point", np.array([])), dtype=np.float32)
if scale.size == 0:
return tensor.astype(np.float32, copy=False)
t = tensor.astype(np.float32)
if scale.size == 1:
return (t - float(zero_point.reshape(-1)[0])) * float(scale.reshape(-1)[0])
axis = int(getattr(quant, "axis", 0))
shape = [1] * t.ndim
shape[axis % t.ndim] = -1
return (t - zero_point.reshape(shape)) * scale.reshape(shape)
2.8 三种解码器
BPU 只负责吐张量,张量到”人看得懂的结果”这一步全靠 CPU 后处理。我实现了三种:
- YOLOv5:锚框 + 网格。head 通道数
255 = 3 锚框 × (5 + 80 类),所以类别数是channels/3 - 5,不是channels - 5。写错会让所有类别整体错位。 - YOLOv8 / v10 / v11 / v12:DFL(Distribution Focal Loss)布局。box 分支最后一维是
4 × 16 = 64,用 softmax 对 16 个 bin 求期望得到 ltrb 距离。做了 sigmoid 预筛优化:sigmoid 单调递增,先用inv_sigmoid(阈值)过滤 logit,再做激活,单请求后处理从 35 ms 降到约 5 ms。 - 分类:找
(1, C, 1, 1)形状的 head 做 top-k。这里有个坑:板上的 ImageNet 模型输出已经过 softmax(sum≈1),不能再算一次,否则置信度会塌到 0.002。我的代码会自动判断:
if mode == "auto":
total = float(values.sum())
mode = "none" if values.min() >= -1e-3 and 0.9 <= total <= 1.1 else "softmax"
YOLOv8 解码的核心:
logits = cls_t.reshape(-1, num_classes)
# sigmoid 单调,所以 max-logit >= inv_sigmoid(thr) 等价于命中
raw_thr = _logit_threshold(score_thres)
cand = np.flatnonzero(logits.max(axis=1) >= raw_thr)
probs = sigmoid(logits[cand])
cls_ids = probs.argmax(axis=1)
scores = probs[np.arange(probs.shape[0]), cls_ids]
keep = cand[scores >= score_thres]
# DFL:对 16 个 bin 做 softmax 求期望,得到 left/top/right/bottom 距离
dist = box_t.reshape(-1, 4, DFL_BINS)[keep]
ltrb = np.sum(softmax(dist, axis=2) * np.arange(DFL_BINS), axis=2)
centers = gen_anchor(grid)[keep]
boxes = np.hstack([centers - ltrb[:, 0:2], centers + ltrb[:, 2:4]]) * stride
2.9 并发模型:为什么是”单实例 + 信号量”
BPU 是单核的。我实测过纯 BPU 前向吞吐:
| 线程数 | yolov8 FPS | p50 延迟 |
|---|---|---|
| 1 | 100.1 | 9.8 ms |
| 2 | 99.7 | 20.1 ms |
| 4 | 99.4 | 30.0 ms |
线程数从 1 加到 4,吞吐几乎不变,但单次延迟线性上涨——说明 BPU 已经跑满,多出来的线程只是在排队。而且同一个 .bin 在同一进程里重复加载会被运行时去重,拿不到第二个独立句柄。所以正确的并发模型是:
class ModelRunner:
def ensure_loaded(self) -> None:
if self.slot is not None:
return
with self._load_lock:
if self.slot is None:
self.slot = RuntimeSlot(self.spec)
self._sem = threading.BoundedSemaphore(max(1, self.spec.workers))
def infer(self, tensors, timeout: float = 20.0):
self.ensure_loaded()
if not self._sem.acquire(timeout=timeout): # 限流,防止无限排队
raise BusyError(f"{self.spec.name} saturated")
try:
outputs = self.slot.run(tensors) # 多线程并发调 run() 是安全的
finally:
self._sem.release()
实测多线程并发调 run() 结果完全一致(0 处偏差),所以单实例不会引入竞态问题。默认 workers=2:既能吃到一点 I/O 与 CPU 后处理的重叠,又不会让排队延迟失控。
3. 实测结果
3.1 性能
纯 BPU 前向(cli bench,零拷贝输入,板上本机):
| 模型 | 1 线程 | 2 线程 | 4 线程 |
|---|---|---|---|
| yolov8_640x640 | 100 FPS (9.8 ms) | 99.7 FPS | 99.4 FPS |
| yolov5s_v6_640x640 | 49.8 FPS (20.1 ms) | 50.2 FPS | 50.4 FPS |
| yolo11m_640x640 | 25.0 FPS (40.0 ms) | 24.9 FPS | 25.3 FPS |
| ssd_mobilenetv1_300 | 252 FPS (3.9 ms) | 243 FPS | 235 FPS |
端到端 HTTP(含 JPEG 解码、letterbox、NV12 打包、反量化、解码、NMS、JSON):
| 场景 | 单请求 | 1 客户端 | 4 客户端 | 8 客户端 |
|---|---|---|---|---|
| 板上本机 yolov8 | 46-52 ms | 17.3 req/s | 37.9 req/s | 37.5 req/s |
| 跨 ZeroTier yolov8 | 330-353 ms | 3.6 req/s | 14.2 req/s | 25.1 req/s |
跨 ZeroTier 的差距几乎全部来自网络:请求体 137 KB 要过一条跨公网的虚拟局域网,单张图光传输就占掉几百毫秒。服务本身的算力瓶颈不在 BPU,而在 Python 侧(GIL + OpenCV 后处理)。
服务常驻加载 3 个模型约 97 MB 内存。
3.2 图片处理结果
第一张:官方 COCO 测试图 bus.jpg(810×1080,137 KB)
| 模型 | 结果 | 推理耗时 |
|---|---|---|
| yolo11m | bus 0.9255,person 0.9042 / 0.8951 / 0.8896 / 0.7761(5 个目标) | 43.2 ms |
| yolov12n | bus 0.8648,person 0.8185 / 0.7761 / 0.7460 / 0.5249 | 25.4 ms |
| yolov8 | bus 0.8193,person 0.7449 / 0.6944 / 0.6743 / 0.2721 | 10.0 ms |
| yolov5s_v6 | person 0.7773 / 0.7729 / 0.7192,bus 0.7443 | 20.2 ms |
| mobilenetv2(分类) | minibus 0.4805,police van 0.1538 | 2.6 ms |
| resnet18(分类) | minibus 0.4095 | 4.2 ms |
| googlenet(分类) | minibus 0.4485 | 3.1 ms |
结论:检测模型全部认出了巴士和人,大模型(yolo11m)置信度最高、小模型(yolov12n)速度更快。分类模型把”一辆大巴”归类为 minibus(小巴)也属合理。
第二张:SBA 2026 扣篮照片(1279×1920,331 KB,竖图)
| 模型 | 结果 | 备注 |
|---|---|---|
| yolo11m(stretch) | 空中扣篮者 person 0.8645,篮下球员 person 0.7673,场边 4 人 0.6789 / 0.5634 / 0.5449 / 0.4527,另有 backpack 0.2607、tvmonitor 0.2588(误检) | 38.5 ms |
| yolo11m(letterbox) | 最高只有 person 0.6473 | 竖图被压缩,明显劣化 |
| mobilenetv2(分类) | scoreboard 0.5416,jigsaw puzzle 0.1291 | 2.4 ms |
| googlenet(分类) | scoreboard 0.1217,jigsaw puzzle 0.1006,ballplayer 0.0833 | 3.1 ms |
分类结果里 googlenet 能给出 ballplayer(棒球/篮球运动员),说明模型确实抓到了运动场景特征。
标注图已保存在 dg-bpu-service/tests/annotated_bus.jpg 和 annotated_dunk.jpg,两张图都已通过 lark-cli 发到飞书。
4. 你问过的问题与解答
Q1:dg 机器是地瓜机器人 X5,帮我看一下它的 BPU 可以拿来做什么?
BPU 是地平线为自家芯片设计的神经网络加速器。在 X5 上它可以做四类事:
- 目标检测:YOLO 系列(v5/v8/v10/v11/v12)、SSD、FCOS、CenterNet,输出检测框。
- 图像分类:MobileNet、ResNet、GoogLeNet、EfficientNet 系列,输出 top-k 类别。
- 语义分割:DeepLabv3+、STDC,输出逐像素类别。
- 多模态大模型:X5 上有适配的 CLIP 文本编码、LLaMA.cpp 等 TROS 包(
tros-humble-hobot-clip-encode-text、tros-humble-hobot-llamacpp)。
而 X5 的 BPU 真正有优势的地方是:算力比 CPU 高一个量级,功耗只有几瓦,适合做常驻的视觉感知。板载模型库 /opt/hobot/model/x5/basic 里有 34 个已经编译好的 .bin,开箱即用。
Q2:现在 dg 机器上没有摄像头、麦克风、扩音器,它现在可以做什么?
实测确认:/dev/video* 不存在(无摄像头),/dev/snd 能枚举到 ES8326 codec 但采集到的是饱和噪声(悬空输入,不是有效麦克风),也没有 /dev/fb* 显示设备。
所以”感知输入”这条腿是断的,但”算力”这条腿是好的,可以做的事:
- 网络推理服务(我做的就是这个):图片从 HTTP 进来,结果从 HTTP 出去。任何能发 HTTP 的设备都能把 X5 当 AI 后端。
- 离线批量处理:
cli watch <图片目录>把目录里的图一张张跑完,输出 JSONL。适合”把一批历史图片过一遍”。 - 边缘计算节点:放在网络里,给其他没有 AI 算力的设备提供推理能力。
- 网络音视频流(后续):
/v1/infer/nv12接口已经预留——只要以后接上摄像头或用 RTSP 流解码出 NV12 帧,不用改解码层就能直接推。 - 传感器/控制类:板子有 40-pin GPIO、I2C、SPI、UART,可以接传感器、电机、继电器做控制类项目,这部分不依赖摄像头。
一句话:现在它是一台”纯算力服务器”,输入输出都走网络。
Q3:你写的代码是如何精准调用到地瓜的 BPU 的?
不是靠猜路径,是靠分层调用 + 五个契约点:
- 我的 Python 只做一件事:
from hbm_runtime import HB_HBMRuntime。这是地平线官方发布的 Python 包,导入时 Python 按标准sys.path机制在/usr/local/lib/python3.10/dist-packages/hbm_runtime/找到它。 HB_HBMRuntime是一个用 pybind11 编译出来的 aarch64 原生扩展(HB_HBMRuntime.so),它在 C++ 层调用libdnn.so的hbDNN*系列 C API。libdnn.so往下依赖libhbrt_bayes_aarch64.so(BPU Runtime)、libhbmem.so.1(BPU 共享内存)、libcnn_intf.so.1。- 这些符号最后由 Linux 内核模块
bpu_framework/bpu_cores/bpu_hw_io_x5接管,通过字符设备/dev/bpu_core0、/dev/bpu、/dev/ion操作硬件。 - “精准”的关键不在调用,而在数据:模型必须是 BPU 编译的
.bin;张量名必须运行时读取;输入必须是 packed NV12;量化输出必须按output_quants反量化;并发必须走单实例 + 信号量。
详细分层图见第 2.3 节,五个契约点见第 2.4 节。
Q4:所有是地平线封装了官方的 Python 库吗?
是。hbm_runtime 就是地平线官方提供的 Python 封装,不是我自己写的,也不是第三方。
证据链:
dpkg -l | grep hobot-spdev
# ii hobot-spdev 3.0.9 Python and C/C++ Development Interface
dpkg -L hobot-spdev | grep whl
# /usr/lib/hobot_spdev/hbm_runtime-3.0.9-py3-none-any.whl
# /usr/lib/hobot_spdev/hobot_dnn-3.0.9-py3-none-any.whl
# /usr/lib/hobot_spdev/hobot_vio-3.0.9-py3-none-any.whl
grep pip3 /var/lib/dpkg/info/hobot-spdev.postinst
# pip3 install /usr/lib/hobot_spdev/hbm_runtime*.whl
分工是:hobot-dnn(BPU Runtime Support Package)提供底层的 libdnn.so / libhbrt;hobot-spdev(Python and C/C++ Development Interface)提供 Python wheel,安装时自动 pip3 install 到 dist-packages。我现在用的版本都是 3.0.9。
Q5:那你写的代码,是如何找到官方封装的库的?
我没有做任何路径 hack。查找分两层,都是 Linux/Python 的标准机制:
Python 层:hobot-spdev 的 postinst 脚本在装包时执行 pip3 install /usr/lib/hobot_spdev/hbm_runtime*.whl,wheel 被装进 /usr/local/lib/python3.10/dist-packages/hbm_runtime/。这个目录本来就在 Python 默认的 sys.path 里,所以 import hbm_runtime 直接命中。可以在板上验证:
python3 -c "import hbm_runtime, os; print(hbm_runtime.__file__)"
# /usr/local/lib/python3.10/dist-packages/hbm_runtime/__init__.py
原生库层:HB_HBMRuntime.so 自己的 NEEDED 条目里只写了 libdnn.so,没有 RPATH,所以它靠系统的动态链接器配置找库。/etc/ld.so.conf.d/ 下有地平线装的配置:
# /etc/ld.so.conf.d/hobot-dnn.conf
/usr/lib/hbbpu
# /etc/ld.so.conf.d/hobot-meltimedia.conf
/usr/hobot/lib
/usr/hobot/lib/sensor
ldconfig 把这些目录写进缓存,ldd 就能解析出 /lib/libdnn.so、/usr/hobot/lib/libhbmem.so.1 等。
我代码里跟”找库”有关的代码只有一行懒加载 import:
class RuntimeSlot:
def __init__(self, spec: ModelSpec):
from hbm_runtime import HB_HBMRuntime # ← 唯一的入口,无路径 hack
self.rt = HB_HBMRuntime(spec.path)
Q6:你现在代码写在哪里?代码量大吗?
- 开发副本:
/home/admin/code/cc-connect-work-space/dg-bpu-service/ - 板上部署:
dg:/opt/bpu-service/,由 systemd 单元bpu-service.service托管(enabled + active,User=root) - 同步方式:
rsync -az --delete --exclude '__pycache__' ./ dg:/opt/bpu-service/
代码量(wc -l):
| 文件 | 行数 | 职责 |
|---|---|---|
bpu_service/engine.py |
442 | 模型注册、加载、并发限流、指标 |
bpu_service/decode.py |
351 | 反量化、YOLOv5/v8 解码、NMS、分类 top-k |
bpu_service/server.py |
275 | HTTP API + 内置上传页面 |
bpu_service/cli.py |
234 | serve / models / infer / bench / watch / selftest |
bpu_service/preprocess.py |
125 | 图片解码、letterbox/stretch、BGR→NV12 |
bpu_service/__init__.py |
3 | 版本号 |
| 服务本体小计 | 1430 | |
tests/smoke_test.py |
177 | 解码 + 并发 + HTTP 全链路自检 |
tests/load_test.py |
62 | HTTP 并发压测 |
tools/probe_runtime.py |
29 | 探测单个模型的输入/输出/量化信息 |
tools/probe_all.py |
33 | 批量探测整个模型库 |
| 合计 | 1731 |
不算大:一个 1400 行的纯标准库服务,没有第三方 Web 框架、没有数据库、没有前端构建链。
5. 关键代码
5.1 模型加载与懒注册(engine.py)
配置里没列出的模型也能调——只要 models_dir 下有这个 .bin,第一次请求时自动注册:
def runner(self, name: str, auto_register: bool = True) -> ModelRunner:
with self._lock:
runner = self._runners.get(name)
if runner is not None:
return runner
spec = self.specs.get(name)
if spec is None:
if not auto_register:
raise UnknownModelError(name)
path = os.path.join(self.models_dir, f"{name}.bin")
if not os.path.exists(path):
raise UnknownModelError(name)
spec = ModelSpec.from_config(name, {"path": path}, self.defaults)
self.specs[name] = spec
runner = ModelRunner(spec)
self._runners[name] = runner
return runner
5.2 图片推理主流程(engine.py)
def infer_image(self, name: str, image, opts: Optional[dict] = None) -> dict:
"""Inference on encoded image bytes or a BGR ndarray."""
opts = dict(opts or {})
runner = self.runner(name)
runner.ensure_loaded()
img = pre.decode_image(bytes(image)) if isinstance(image, (bytes, bytearray, memoryview)) else image
input_h, input_w = int(runner.slot.input_shape[2]), int(runner.slot.input_shape[3])
mode = opts.get("resize") or runner.spec.resize
packed, meta = pre.preprocess(img, input_w, input_h, mode) # BGR → packed NV12
outputs, elapsed = runner.infer({runner.slot.input_names[0]: packed},
timeout=float(opts.get("timeout", 20.0)))
decoded = self._decode(runner, outputs, meta, img, opts) # 反量化 → 解码 → NMS
decoded.update({
"model": name,
"runtime_model": runner.model_name,
"infer_ms": round(elapsed, 2),
"image": {"width": meta.ori_w, "height": meta.ori_h, "resize": mode},
})
return decoded
5.3 任务类型自动推断(engine.py)
不需要人工给每个模型标”这是检测还是分类”,按输出张量形状推断:
def kind(self) -> str:
"""Resolve the decode task, inspecting tensor shapes when the config says 'auto'."""
if self.spec.kind != "auto":
return self.spec.kind
self.ensure_loaded()
shapes = self.slot.output_shapes
if any(len(s) == 4 and s[0] == 1 and s[1] > 1 and s[-1] == 1 and s[-2] == 1 for s in shapes.values()):
return "classification"
if dec.is_yolov8_family(shapes):
return "yolov8"
# ... 再尝试 yolov5 / generic
5.4 letterbox 与坐标还原(preprocess.py)
if mode == "stretch":
resized = cv2.resize(img, (input_w, input_h), interpolation=cv2.INTER_LINEAR)
return resized, ResizeMeta(ori_w, ori_h, input_w, input_h, "stretch")
scale = min(input_w / ori_w, input_h / ori_h)
new_w, new_h = int(round(ori_w * scale)), int(round(ori_h * scale))
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
pad_w = (input_w - new_w) / 2.0
pad_h = (input_h - new_h) / 2.0
top, left = int(round(pad_h - 0.1)), int(round(pad_w - 0.1))
bottom, right = input_h - new_h - top, input_w - new_w - left
out = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114,) * 3)
对应的逆变换(把模型坐标的框映射回原图):
def scale_back(self, boxes: np.ndarray) -> np.ndarray:
out = boxes.copy()
if self.mode == "letterbox":
out[:, [0, 2]] = (out[:, [0, 2]] - self.pad_w) / self.scale
out[:, [1, 3]] = (out[:, [1, 3]] - self.pad_h) / self.scale
else:
out[:, [0, 2]] *= self.ori_w / self.input_w
out[:, [1, 3]] *= self.ori_h / self.input_h
return out
5.5 HTTP 入口(server.py)
def do_POST(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
query = urllib.parse.parse_qs(parsed.query)
path = parsed.path.rstrip("/")
if not self._authorized(query):
return self._send_json({"error": "unauthorized"}, 401)
model = (query.get("model") or [""])[0]
try:
if path == "/v1/infer":
return self._handle_infer(model, query)
if path == "/v1/infer/nv12":
return self._handle_infer_nv12(model, query)
if path == "/v1/perf":
return self._handle_perf(model, query)
except UnknownModelError as exc:
return self._send_json({"error": "unknown model", "model": str(exc)}, 404)
except BusyError as exc:
return self._send_json({"error": "busy", "detail": str(exc)}, 503)
5.6 原始 NV12 输入(engine.py,为未来接摄像头预留)
def infer_nv12(self, name: str, buffer: bytes, width: int, height: int, opts=None) -> dict:
"""Inference straight from a packed NV12 frame (camera / video path)."""
runner = self.runner(name)
runner.ensure_loaded()
exp_w, exp_h = pre.validate_nv12_size(tuple(runner.slot.input_shape), width, height)
arr = np.frombuffer(buffer, dtype=np.uint8)
need = exp_w * exp_h * 3 // 2
if arr.size < need:
raise ValueError(f"nv12 buffer too small: {arr.size} < {need}")
meta = pre.ResizeMeta(exp_w, exp_h, exp_w, exp_h, "stretch")
outputs, elapsed = runner.infer({runner.slot.input_names[0]: arr[:need]})
...
6. 踩坑清单(按踩到的顺序)
input_shape骗人:[1,3,640,640]但实际要喂 614400 字节的 packed NV12。第一版直接喂 RGB 数组,报维度错。- 重复加载被去重:同进程对同一
.bin建第二个 runtime,日志打印has loaded, Discard the latest,拿不到独立句柄。改成单实例 + 信号量。 - YOLOv5 head 通道数:
255 = 3×(5+80),类别数是channels/3 - 5。一开始写成channels - 5,类别全错位。 - 分类头重复 softmax:板上模型输出已归一化(sum=1),再过 softmax 置信度从 0.48 塌到 0.002。改成自动判断。
- 竖图 letterbox 掉点:1279×1920 竖图 letterbox 后最高 0.65,改 stretch 后 0.86。
- 后处理太慢:全量 sigmoid 后处理要 35 ms。用 sigmoid 单调性先按
inv_sigmoid(阈值)预筛 logit,降到约 5 ms。 - 量化输出没还原:YOLOv8 box 分支是 int32 + 逐通道 scale(axis=3),不反量化框全是乱的。
- hbrt 版本告警:板端
hbrt 3.15.55与部分模型 build 版本(3.15.47/3.15.49)不一致,日志刷Inconsistency between the hbrt library version,实测不影响结果。 - HTTP 不支持 chunked:
BaseHTTPRequestHandler读的是Content-Length,没有它就读不到 body。用curl --data-binary这类会带 Content-Length 的方式没问题。
7. 相关文件
| 路径 | 说明 |
|---|---|
/home/admin/code/cc-connect-work-space/dg-bpu-service/ |
开发副本(完整源码、README、测试、工具) |
dg:/opt/bpu-service/ |
板上部署目录,systemd 托管 |
dg-bpu-service/README.md |
服务 README(API、配置、性能、踩坑) |
dg-bpu-service/tests/bus.jpg / annotated_bus.jpg |
第一张测试图与标注结果 |
dg-bpu-service/tests/annotated_dunk.jpg |
第二张扣篮图标注结果 |
dg-bpu-service/config/models.json |
模型注册表与服务配置 |
dg-bpu-service/deploy/bpu-service.service |
systemd 单元文件 |
服务地址:http://10.71.48.154:8080/(ZeroTier 内网,浏览器可直连)。
8. HTML 可视化版
上面这些内容的可视化 HTML 版本(调用链分层图、数据流图、性能对比、问答卡片)见配套笔记:
notes/documents/02_dg板卡X5_BPU推理服务_可视化HTML.md
那一篇是纯 HTML 内嵌 Markdown的页面,只在博客渲染(飞书文档导入会丢弃 <div> / <style> 这类原生 HTML 标签,实测确认),用于直观展示整套系统的结构。