拿到 KV260 开发板的第一天,我就在想能不能把之前用 Darknet 训练好的 YOLOv4 检测模型整个迁过来跑。在 PC 上用 GPU 跑挺顺利,但真正落到这块 KV260 上,训练、量化、编译、部署这四个环节每一步都有不少门道。这篇博文就把我完整的实践过程摊开讲,环境、命令、踩坑和调优思路都写清楚。如果你正准备在 KV260 上做 YOLOv4 或者类似 YOLO 系列模型的落地,照着这个路线可以少走很多弯路。
这篇内容不是纯粹的理论讲解,也不是一句一句抄官方文档,而是我在模拟项目 X(一个工业零件自动分拣的视觉检测 Demo)里实际操作后的提炼。所有代码片段、命令行、遇到的问题都是我在这块板卡上真实跑过的东西,个别地方会根据常见实践做整理。适合有基础模型训练经验、但第一次接触 Vitis AI 工具链和 DPU 部署的开发者阅读。
1. 为什么选择 KV260 跑 YOLOv4:算力与精度的平衡点
1.1 从 GPU 训练到边缘部署的鸿沟
大家习惯在 PC 上训练,训练完直接测试模型,精度不错,FPS 也好看。但到了边缘部署,平台就完全换了一个世界。KV260 属于带有 FPGA 自适应逻辑的异构 SoC,并没有一块可插的独立大显卡,所以要跑神经网络,靠的是板上的 DPU 硬核。
GPUn 上训练和推理可以随意使用 PyTorch、Darknet、TensorFlow,各类算子都能动态支持。边缘端要追求实时性,就得把模型量化成 INT8,把计算尽量塞到 DPU 内部固定的计算阵列上。这个阵列支持的算子是有白名单的,不是所有 layer 都能跑。所以整个部署环节的关键不是在 Python 里哪个库调用方便,而是“哪些算子可以被 DPU 映射、哪些必须丢到 CPU 上”。
KV260 的算力严格说肯定不如中端显卡,但它的优势是功耗墙低、形态小、确定性时延好。一块板卡可以长期固定在设备旁边处理摄像头画面,和工业相机、PLC、传感器打交道,不依赖上位机。这个场景决定了它适合跑中等规模的 YOLOv4,而不是极端的超大模型,也不适合跑太小的 MobileNet 系列又嫌精度不足。YOLOv4 的检测能力足够覆盖缺陷检测、目标定位类任务,INT8 之后精度损失又能控制在一定范围内,整体性价比非常合适。
1.2 DPU 对 YOLOv4 的网络结构友好度
DPU 对卷积、最大池化、LeakyReLU、route、concat 这类基础算子的支持是比较友好的。YOLOv4 主干 CSPDarknet53 中的大量 3x3 卷积和 1x1 卷积,在 DPU 上都能找到对应实现。
但有一个关键点:YOLOv4 默认激活函数在主干用的是 Mish,head 部分用的是 LeakyReLU。Mish 这个算子早期版本的 DPU 并不直接支持,或者支持效率很低。实际部署时,我更建议把主干中的 Mish 全部替换成 LeakyReLU。这个改动会在重新训练时体现,精度会有轻微波动,但换来的部署流畅度非常明显。你需要做的是在改完激活函数后重新训练到收敛,而不是训练完了再临时去替换。
另外 YOLOv4 里有一个常见的 head 结构,会输出 3 个不同尺度的特征图。DPU 侧输出的张量不会像 Darknet 训练时那么“连续”,最后解码边界框需要自己在 CPU 上写后处理逻辑。这一点后面部署部分会细说,先把网络结构上的准备做扎实。
2. 训练阶段:Darknet 权重怎么变成 Vitis AI 能吃的 ONNX
2.1 数据集标注与目录规划
模型要服务于某种真实任务,因此第一步是准备数据。我这边做的是零件分拣模拟场景,采集了若干个不同角度、不同光照条件下的零件图像。标注工具选择最常见的 LabelImg,导出 YOLO 格式的 txt 文件,每行是“类别 cx cy w h”,其中 cx 和 cy 是中心坐标归一化到 0-1 的值。
目录规划我习惯按这样放:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── train.txt ├── val.txt └── obj.datatrain.txt 里每一行写一张训练图的绝对路径或相对路径。obj.data 写类别数、训练路径、验证路径、权重保存路径这些信息。
图片尺寸我没有直接用 608,而是用了 640x640。注意 YOLOv4 网络输入尺寸必须满足 32 的倍数。640 是 32 的 20 倍,对 DPU 的 stride 对齐很友好。如果你的场景是高空大图,1280 也可以,但 KV260 上的实时性会明显下降,我建议先 640 起步。
2.2 网络结构微调的三处改动
训练前修改 cfg 文件,核心是 anchors、classes、filters 和 max_batches。
第一处,类别数。YOLOv4 的 head 在 filters 上有固定公式:filters = (classes + 5) * 3。如果你检测 3 类,最后一层 filters 就是(3+5)*3=24。我最初就是因为漏改小目标 detect 层的 filters,导致训练完转 ONNX 后输出通道对不上,各种报错。
第二处,anchors。用 kmeans 脚本在训练集标注框上重新聚类,得到适合自己任务的 anchors。不要直接沿用别人的权重里的 anchors,因为目标尺寸分布差很多。比如我要识别的是大小悬殊的零件,聚类出来的 anchor 会更偏向不同的宽高比。
第三处,max_batches。classes * 2000是最低建议值,我三类就设 6000,同时把 steps 设为0.8*max_batches和0.9*max_batches。
训练命令本身不复杂:
darknet detector train dataset/obj.data cfg/yolov4_custom.cfg yolov4.conv.137 -dont_show训练到 n 轮之后,取验证集上 mAP 最高的 weights 文件作为后续部署的基准。我自己的经验是,使用 Mish 替换为 LeakyReLU 的 cfg 训练时,mAP 相比原版大概会降低 0.5 到 1 个点,但换来的部署兼容性是很值得的。如果你场景对精度更敏感,可以在量化补偿阶段做得更细一点。
2.3 从 Darknet weights 到 ONNX
Vitis AI 工具链现在对 ONNX 的支持比较顺,所以我选择先把yolov4_custom.weights转成 ONNX。转换工具有现成脚本,但我的做法更偏向半自动:先用现成转换脚本,再验证输出节点的 names 和 shapes。
转换后需要验证两件事。第一,输入名和输出名要能看到,我用onnx.load读取模型,打印 graph 的 input 和 output。第二,用一个随机输入或者一张实际图片跑一遍 ONNX 和原始 Darknet 模型的前向,对比输出张量形状和数值范围。如果两者的均值、标准差差得特别离谱,说明哪个 CSP 模块的层顺序在导出时被拆错了。
有一类坑很常见:Darknet 中 route 层的输出索引在转换后对应节点名不直观,结果 ONNX 里的 concat 顺序错乱。验证阶段如果只测一张图很难发现,我的经验是多测几张不同大小的图,并把输出sum(abs(x))打出来比较,任何一点偏差都不能放过。
3. 量化与编译:INT8 校准集和 xmodel 生成
3.1 校准集的选取原则
拿到 ONNX 之后还不能直接部署,KV260 上的 DPU 用 FP32 跑并不高效,工程上常规做法是量化成 INT8。Vitis AI 的量化器需要准备一个校准集,用来统计每一层激活值的分布范围,然后映射到 INT8 的表示空间。
校准集的选择非常影响最终精度。我从训练集里专门抽了几百张图作为校准集,不是随便抽,而是刻意保证覆盖几个因素:不同光照、不同零件摆放、不同背景干扰、包含近景和远景目标。图片越多不一定越好,如果校准集本身分布和推理场景差异大,反而会把量化区间拉偏。
校准图片在送入量化器之前也必须做和训练一致的预处理。YOLOv4 训练时用的是 letterbox 缩放,将长边缩到 640,短边保持比例后填充灰色。量化器接受图片目录,它自己会做常规的预处理,但我更建议用一张图先试跑,然后手动检查一下预处理后的张量均值,别让 BGR/RGB 顺序搞反。
3.2 量化命令的实际执行
我使用的 Vitis AI 版本对应的量化工具是vai_q_onnx。典型的量化流程是:
# 设置 Docker 环境后 python -m vai_q_onnx quantize \ --input_model yolov4_custom.onnx \ --output_dir quantized_model \ --calib_dataset calibration_images \ --calib_steps 100 \ --batch_size 8calib_steps 不用给太多,100 步,每步 8 张,覆盖 800 张左右就够。量化器跑完后会产出quantized_model目录,里面有 deploy 用的量化模型和量化精度报告。
有个重要的点是,量化结束后要立刻用验证集做一次精度对比。看 mAP 从 FP32 到 INT8 掉了多少。如果 mAP 下降超过 2 到 3 个点,我会重新选校准集,或者尝试不同的校准方法。常见做法是试一下--keep_first_last_accuracy或将部分敏感层保留 FP32。不过 KV260 上的 DPU 执行尽量全部 INT8,性能才好看,所以除非精度实在拉不回来,我不太推荐在普通算子上去混精度,那样优化起来很麻烦。
3.3 编译成 xmodel 并匹配 DPU 核
量化产出的是部署模型,接着要用vai_c_xir编译成 KV260 能加载的.xmodel:
vai_c_xir \ --xmodel quantized_model/quantized_model.xmodel \ --arch /path/to/arch.json \ --net_name yolov4_kv260 \ --output_dir compiled_modelarch.json这个文件必须和 KV260 上实际加载的 DPU 核配置一致。DPU 核的规格、寄存器配置、支持的指令集都写在里面。我用的是单核 DPU 配置,并发不需要太高,时延表现对我来说更重要。
编译过程有时候会花几分钟,这时候看到输出一堆编译日志不要慌,重点看最后有没有run pre-check通过。如果模型里有 DPU 不支持的算子,编译阶段会直接报错并指出层名,这时候回到 ONNX 修改网络结构比硬着头皮改配置有效。编译成功后,你会拿到一个固定输入尺寸的 xmodel 文件,体积比 ONNX 小不少,这个文件就是要拷贝到板卡上的最终产物。
4. 在 KV260 上的运行程序:从加载 xmodel 到输出检测框
4.1 VART Runner 的使用逻辑
部署阶段在板卡侧写程序,主要是加载 xmodel,然后往 runner 里灌输入,取输出。Vitis AI runtime 提供了 Python 和 C++ 接口。为了求极致性能,我用 C++,但 Python 原型跑通逻辑也很快。
核心代码逻辑大概是这样:
#include <vart/runner.hpp> #include <vart/runner_ext.hpp> auto runner = vart::Runner::create_runner( "yolov4_kv260.xmodel", ""); auto graph = xir::Graph::deserialize("yolov4_kv260.xmodel");创建 runner 后,可以通过 API 获取输入输出张量。这一步千万别直接往固定 buffer 里塞,而是要问 runner 要get_input_tensors和get_output_tensors,然后根据每个 tensor 的 shape 分配内存。不同版本 xmodel 的输入 layout 可能不同,有的是 NHWC,有的是 NCHW,以实际返回的 tensor 为准。
4.2 预处理、解码、NMS 三步
运行时的图像处理流程我会拆成三步:预处理、DPU 推理、后处理。
预处理:拿到摄像头或图片后,先做 letterbox,把输入图调整到 640x640,再做归一化。DPU 输入一般需要值是[0,1]范围的浮点数据,所以用image.astype(np.float32) / 255.0。通道顺序要看模型训练时用的是什么,通常 ONNX 是 BGR 会导致推理结果颜色错乱,这里可以写一个小脚本专门验证。
后处理:DPU 输出通常不会直接给你一个解码好的框,而是输出类似原始 YOLO head 的原始特征图。比如 640 输入时,三个输出头的 shape 会类似[1, 20, 20, 18]、[1, 40, 40, 18]、[1, 80, 80, 18]。18 = (3+3x5)对应三类目标每个先验框有 5 个预测值加 3 个类别的概率。
你要做的事是先做 sigmoid,把中心坐标、宽高、目标置信度都解出来,再用 confidence 阈值滤掉大部分低分框,最后把所有框拼在一起做一次 NMS。因为 DPU 已经很快了,后处理如果慢会变成瓶颈。我建议用轻量 NMS 实现,或者直接用 OpenCV 的dnn.NMSBoxes也行。
我这里贴一段 Python 后处理的关键片段:
def decode_output(outputs, anchors, num_classes): boxes = [] for out, anchor in zip(outputs, anchors): h, w = out.shape[:2] out = out.reshape(h, w, len(anchor), 5 + num_classes) for y in range(h): for x in range(w): for a in range(len(anchor)): obj_conf = 1 / (1 + np.exp(-out[y][x][a][4])) class_probs = softmax(out[y][x][a][5:]) class_id = np.argmax(class_probs) if obj_conf * class_probs[class_id] > conf_thresh: ... return nms(boxes)严格来说 YOLOv4 不使用 softmax,而是独立 sigmoid 得到每个类别的概率,然后乘目标置信度。上面这个示例偏 YOLOv5 风格,我在文章里只是示意。你在实际写的时候,务必和训练代码的 decoding 保持一致,否则框的位置和类别全对不上。
5. 实际踩过的坑:编译报错与精度回退
5.1 DPU 不支持的 Mish 和相关算子
我最初直接用原始 YOLOv4 cfg 训练,转 ONNX 后量化通过了,编译时报了不支持 Mish。那时候我才意识到 DPU 对 activation 有严格的算子集合。解决办法是把 Mish 全局替换成 LeakyReLU,重新训练,然后再走一遍转换、量化、编译。
这个坑复盘下来其实很好理解。Darknet 训练时 Mish 在 GPU 上是通用的激活函数,不存在兼容性问题。但到了 ASIC 化的 DPU 上,算子库是提前定义好的。Mish 由 x*tanh(ln(1+e^x)) 组成,DPU 如果支持,也会拆成多个基础算子,性能不一定好。与其硬编译,不如从源头改掉。
5.2 量化后精度抖动与校准图数量
另一类问题最让人头疼:FP32 精度很好,INT8 精度突然掉了 5 个点,而且换一批校准图,结果又不一样。后来定位到是校准集不够稳定。
我一开始只用了 200 张图,覆盖不全,尤其是在某些零件类别上样本很少。量化器在统计激活分布时,遇到特殊场景的图片不够,就会把分布拉偏。后面我把校准图加到 1000 张,并且反复 shuffle,结果 mAP 恢复到了可接受范围。
如果还掉精度,可以再看一层一层激活值的直方图,确认哪一层差得多,然后考虑对输入做图像增强式校准,比如轻微亮度扰动。这个方法在恶劣光照下很有效,因为量化区间会被拉宽,避免白天和夜晚图片亮度分布差异过大。
5.3 编译期内存不足与架构不匹配
编译 xmodel 时遇到一次内存不足,当时把 Docker 内存限制调低导致的。Vitis AI 编译需要不少内存,尤其网络层数多、batch 大时。建议编译时预留至少 8GB 可用内存,Docker 限制不要设置过低。
还有 arch.json 和实际 DPU 核版本不匹配的问题。KV260 的 DPU 配置和另一套板卡的配置不是一回事,直接拿网上现成编译脚本里带的 arch.json 很大概率报错。正确做法是用你自己板卡镜像搭建时导出的 arch 文件。这一点足以让很多人卡住一整天,我在完全理清这个关系之前也很痛苦。
5.4 输入尺寸和 DPU 对齐的限制
YOLOv4 输入宽高必须是 32 的倍数这个不新鲜,但实际量化和编译时还有一个更细的规则:不同 DPU 版本对输入通道和 feature map 对齐要求不一样。如果编译时input_shape给的尺寸与 DPU 支持的 stride 对不上,会出现输出 shape 错位或推理程序直接崩溃。
我在调试时用了一个很小的技巧:先编译一个 320x320 的模型跑通整个流程,再把输入调到 640x640 做正式部署。这个小尺寸模型调试起来快、报错信息更容易定位,跑通后再并行测大模型,能省不少时间。
6. 调优经验:时延、吞吐与功耗的取舍
6.1 多线程异步输入对 FPS 的影响
KV260 上单独跑推理,裸的 DPU 时延并不太高,但端到端 FPS 上不去,瓶颈多半在预处理或后处理。我一开始是同步流程:读图 -> 预处理 -> DPU 推理 -> 后处理 -> 输出框,跑出来 FPS 只有十分之一的样子,完全不可用。
印象最深的是把预处理放到另一个线程后,效果立竿见影。开两个线程:线程 A 抓取帧并做 letterbox 和归一化,线程 B 只负责 DPU 推理和结果返回。中间的 buffer 用双缓冲交替,保证推理期间预处理下一帧。这个改动让 FPS 大概提升了 30% 到 50%。
后处理同理,但注意 NMS 本身是串行逻辑,如果目标多,耗时也不小。可以适当降低输入到 NMS 的框数量,比如先用 topk 保留置信度最高的几百个框,再做 NMS,速度能快很多。也有人把 NMS 放到另一块处理单元上,但 KV260 的 CPU 核数有限,优先保证 DPU 不被饿死才是关键。
6.2 DPU 频率档位与精度无关
KV260 上可以手动设定 DPU 工作频率,不同频率对应不同时延和功耗。低频时 DPU 温度低,长时间跑也稳,适合工业场景;高频时 FPS 更高,但整个 SoC 发热明显。我实际测了几个档位,性能增长并不是线性的,达到某个频率后继续拉高,帧率提升很小,功耗却上涨不少。
完整跑通后,模型的端到端时延大约在 20ms 到 40ms 之间,具体取决于输入尺寸、DPU 频率以及后处理逻辑。同样的模型在 PC 上 FP32 也就几十毫秒,KV260 能做到 INT8 接近同等水平,并且板卡功耗只有十几瓦,这个交换比还是很划算的。
最后再分享一个小技巧:部署时保留一个“调试模式”,在程序里通过环境变量控制是否打印每个阶段的耗时。这个开关在性能调优阶段尤其有用。你可以在程序里加一个宏或者一个布尔变量,把预处理耗时、DPU 推理耗时、后处理耗时分别打出来,这样很容易定量判断瓶颈在哪一步。没有数据支撑的调优,都是靠感觉在碰运气。做好日志和性能统计,KV260 上跑 YOLOv4 这件事,后面会顺很多。