这篇内容打算聊一次完整的部署经历,从在英伟达 GPU 工作站上训练强化学习步态策略,到把策略搬进一台 25 厘米高的 Microduck 四足机器人,用一块 RK3566 驱动整机站起来、走出去。整件事最花时间的不是训练,也不是调参,而是“部署”这两个字背后的工程细节。
1. 为什么选 RK3566:25 厘米小机器人的算力与功耗账
1.1 先说说 Microduck 整机的情况
Microduck 是一台 25 厘米左右高度的四足机器人,和常见的 Mini Cheetah 那种研究平台相比,它工艺上更像消费级工程样机:12 个关节,每条腿 3 个自由度,机体内部预留给主控板的空间不大。唤醒赛道的无人机和机器狗都在往紧凑方向走,25 厘米级机体要在有限空间里塞进电池、电机驱动板、IMU 和主控制板,所以主控选型相当关键。
在做部署之前得先想明白一个问题:这台机器人要承担多少计算?强化学习推理的量级其实没有大家想象中大,策略网络往往是多层 MLP,输入维度在几十到一百上下,输出 12 个关节动作,参数量几千到几十万级别。真正消耗算力的是高频控制回路,电机力矩指令的生成频率最低也要 200Hz 以上,控制器要在几毫秒内完成“传感器读取、状态估计、策略推理、力矩输出”一整条链路,这个才考验主控的实时性。
1.2 RK3566 和树莓派 / Jetson Nano 的取舍
我之前在别的项目上用过树莓派 4B 和 Jetson Nano,拿来推过强化学习模型。树莓派的 CPU 是四核 Cortex-A72,单核性能尚可,但 GPIO、I2C、SPI 和 CAN 的调度不够硬实时,在线控制循环里很容易出现周期抖动。Jetson Nano 的 GPU 算力强,跑 CUDA 肯定没有压力,但它功耗高、启动慢、体积大,塞进 25 厘米的小机身有点杀鸡用牛刀,而且整个部署链路的镜像尺寸也不太友好。
RK3566 是瑞芯微推出的一颗四核 Cortex-A55 SoC,集成 Mali-G52 GPU 和 0.8 TOPS 级别的 NPU,整体功耗可以压在 3W 到 6W 的范围内。A55 的性能跑 MLP 推理完全够用,NPU 虽然算力不高,但用来推这类小网络反而有优势。再加上 RK3566 原生支持 CAN、SPI、UART、I2C,电机控制板走 CAN 总线还是 SPI,都留了余地。存储可以用 eMMC 加 TF 卡,系统启动到完全运行状态大概 10 秒内,开机即用,非常适合这种嵌入型机器人。
1.3 算力需求和功耗预算怎么算
我按实际需要做了个粗略预算。控制主频跑在 1.8GHz 时,RK3566 CPU 的持续算力大约是 10000 Dhrystone MIPS 的几分之一,这个数字看起来不亮眼,但策略网络单次推理也就几百万次浮点运算。按 400Hz 控制频率,每秒需要 1.6 亿次浮点运算量,A55 是可以轻松承受的。真正不能忽略的是 DDR 带宽和缓存命中率,推理时如果频繁做动态内存分配,带宽会拖后腿。
功耗方面,整机电池如果是 2S 锂电池,容量大约 1200mAh,主控板加电机驱动板的总电流预算大概在 1.5A 到 2A 之间。RK3566 与电机驱动、IMU 加在一起,能够控制在 5W 附近,剩余的功耗全部要留给关节电机。综合来看,RK3566 在 25 厘米这个尺寸等级的机器人上是一个比较合理的“甜点”选择。
2. 训练侧准备:别等训练完了才考虑部署
2.1 策略网络结构与输入输出
很多人在训练阶段完全不考虑部署,训练完了做导出才发现模型里塞满了自定义算子,或者输出层带了额外处理逻辑,跑到嵌入式设备上不是性能差就是直接不支持。我这次的策略网络结构比较常规,动作空间和观测空间设计如下表:
| 模块 | 维度 | 说明 |
|---|---|---|
| 观测输入 | 大约 70 维 | 关节角度、关节角速度、IMU 四元数、角速度、上一时刻动作 |
| 隐藏层 | [256, 256] | MLP,激活函数用 ReLU(导出友好) |
| 动作输出 | 12 维 | 每条腿 3 个关节位置增量(或目标位置) |
| 控制频率 | 250Hz 到 400Hz | 训练时固定为 250Hz,实机根据电机驱动能力调整 |
训练用的框架是 Isaac Gym 这类 GPU 并行环境,单张 RTX 4090 跑了大概三个小时,大概 12000 个仿真环境并行采样,拿到了一个能稳定走路的策略。训练阶段零零散散也会用 MuJoCo 导出验证,但最终真机部署用的还是 Isaac Gym 产出的 ONNX。
有一点必须强调:策略网络的输出一定要保持“纯网络输出”,不要在后处理里加滤波或者限幅。这些逻辑放到嵌入式端代码里做,一旦写进网络,导出后处理算子会非常痛苦,一些轻量推理引擎根本不支持。所以我训练时网络输出就是 12 个动作的原始值,所有动作缩放、限幅、平滑全放到部署侧代码实现。
2.2 域随机化其实是给部署减负
单独说说域随机化。很多做强化学习的朋友在训练时喜欢把摩擦、质量、电机力矩上限这些参数全部固定,仿真里跑得很好,一上真机就摔。Microduck 这类小尺寸机器人在实机上受电池电压波动、地面摩擦差异、IMU 噪声的影响比大型平台更大,所以训练时必须做域随机化。
我在 Isaac Gym 里随机化的参数包括:地面摩擦系数(0.4 到 1.2)、电机力矩常数(±15%)、机身质量(±10%)、IMU 角速度噪声(±0.2 rad/s)、动作延迟(1 到 2 个控制步)。这些随机化参数直接决定了 sim-to-real 迁移是否顺利,本质上是在训练侧就把部署端可能遇到的误差提前纳入考虑。
训练结束后的关键指标有两个。一是仿真内平均奖励和成功率,二是策略对不同随机种子的鲁棒性。我会在训练环境里换几组极端摩擦参数跑测试,如果策略在这些条件下还能维持一定速度前进,才敢往真机走。只要训练侧把域随机化做好,RK3566 部署端就能少做很多补偿。
2.3 训练到什么程度算“能导出”
“能导出”这件事需要提前 10 分钟思考,而不是最后才做。我的要求包括:
- 网络必须写成继承
torch.nn.Module的标准模块,尽量不用torch.jit.script,也不在 forward 里用 Python 控制流。 - 只保留 policy 部分,也就是纯 actor 网络,critic 在导出时直接丢弃。
- 所有输入输出必须是连续张量,不要用字典、tuple 或者自定义类。
- 训练时需要保存跑得最好的 checkpoint,而不是最后一步的 checkpoint。强化学习训练到后期可能 policy 会退化,保存的 checkpoint 要确保在评测里能连续走完几十秒。
我用 PyTorch 训练,最终导出时调用torch.onnx.export。导出之前先写好一个 wrapper 类,把输入张量的维度和名字固定下来。例如输入命名obs,输出命名action,这个命名在 RK3566 上初始化 session 时会直接用。提前把导出脚本写好,每次训练结束后跑一次,确认 ONNX 能正常输出。
还有一点,训练时控制频率和实机控制频率如果有差异,最好在导出前通过动作时间常数匹配。比如训练用 250Hz,实机跑 400Hz,那么动作平滑参数要按时间常数重新算,否则机器人的步态频率和训练时的频率感完全不同,关节会发飘。
3. ONNX 导出与算子梳理:部署路上最容易堵车的路段
3.1 固定动态轴:把 ONNX 输出形状钉死
接触过 ONNX 的人都知道,PyTorch 默认导出的模型经常带dynamic_axes参数,能导出一个 batch 维度可变的图。灵活是好事,但在嵌入式推理引擎上,动态 shape 反而容易触发额外的内存分配和算子优化失效。我这边直接固定 batch size 为 1。
torch.onnx.export( actor, (obs_tensor,), "microduck_policy.onnx", input_names=["obs"], output_names=["action"], opset_version=11, do_constant_folding=True, dynamic_axes=None, )dynamic_axes=None这个参数很关键,意味着所有输入输出张量都是静态 shape。RK3566 上用 ONNXRuntime 初始化 session 的时候,静态图能够直接做算子融合和内存规划,推理延迟比动态图稳定得多。固定完 shape 之后用onnx.checker校验一遍图结构,确认所有节点输入输出匹配。
3.2 算子检查和 FP16 转换
RK3566 CPU 默认跑 FP32,如果转成 RKNN 用 NPU,则要考虑量化。但在最初的 CPU 版本里,FP32 就够了。检查算子的方法有两种,一是用onnxruntime直接加载跑一次推理,二是用onnx.shape_inference做完整推断。只要能跑通的算子,后面转 RKNN 时才有参考。
有一个小坑是opset_version。我一开始用了 opset 17,里面有些算子比如ReduceSum的 axes 行为变了,ONNXRuntime 老版本解析会有问题。后来统一改用 opset 11,兼容性最好,RKNN 转换工具对 opset 11 的支持也最成熟。如果训练代码里用到了较新的层,尽可能在导出时先简化到 opset 11 能表达的范围内。
FP16 转换的做法是:先用onnxconverter_common里的float16工具,把模型转成 FP16,再用onnxruntime在 x86 CPU 上比较原模型和 FP16 模型的输出差异。但实际测试下来 RK3566 的 CPU 跑 FP16 并没有性能优势,所以最终实机用的是 FP32,FP16 没有采用。不过在做 RKNN 量化时,这个 FP16 版本能作为调试参考。
3.3 用 ONNXRuntime 在本地先验证
本地验证这一步一定不能省。我通常会在训练机上直接跑一段模拟在线控制的循环,用 ONNXRuntime 加载导出的模型,输入随机的观测张量,检查输出维度、数值范围是否和 PyTorch 原模型一致。更严格的做法是把一段真实 rollout 的观测数据全部回放,对比每一步的动作输出误差。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession( "microduck_policy.onnx", providers=["CPUExecutionProvider"], ) obs = np.random.randn(1, 70).astype(np.float32) action = sess.run(["action"], {"obs": obs})[0] print(action.shape, action)如果这一步数值与 PyTorch 输出误差在 1e-4 以内,就可以进入 RK3566 实机阶段。很多人在这一步漏了验证,结果到板子上发现输出全是 NaN,回溯了很久才发现是 ONNX 导出时某个输入没有归一化。所以请在进入硬件之前先把软件环境排查干净。
4. RK3566 实机部署:先跑 CPU 再考虑 NPU
4.1 开发环境与交叉编译
RK3566 的板子我用的是一块标准的 Radxa 或者类似量产板,系统是 Debian 系的 ARM64 镜像。开发机上先安装好交叉编译工具链,也可以用板子直接编译,但板子上编译一次很费时间,我建议代码量不大就直接在开发机交叉编译,代码量大就先在板子上把环境写好,再同步代码。
推理引擎优先选 ONNXRuntime 的 ARM64 版本。RK3566 是 ARMv8 架构,ONNXRuntime 官方仓库里没有直接提供这么具体的 release,但可以自己在板子上跑pip install onnxruntime,或者用 RKNN-Toolkit2 附带的部分依赖。实测下来直接安装 ARM64 Linux 版本的 ONNXRuntime 是可行路径。
控制主循环我用 C++ 写,方便做高优先级实时线程。Python 绑定的 ONNXRuntime 用来做功能验证,实机控制不用 Python,因为 Python 的 GC 和 GIL 会在一个控制周期里造成随机性,这个不确定性对步态控制是致命的。
4.2 控制循环的主线程优先级
RK3566 虽然是四核 A55,但是 Linux 默认调度器并不严格保证某线程的周期性。为了跑 400Hz 的控制循环,我用了pthread_setschedparam把控制线程设置成SCHED_FIFO,优先级设到 80 左右。同时把控制线程绑定到 CPU2 或 CPU3,避免和其他系统进程争抢。这里不要绑 CPU0,因为内核很多进程和中断都跑在 CPU0 上,绑上去会发生周期抖动。
控制循环的整体结构大概是:
- 从电机驱动板读取 12 个关节的角度和角速度,读取 IMU 数据。
- 拼接观测向量(状态估计结果、关节信息、IMU 数据、上一帧动作)。
- 调用 ONNXRuntime session 做一次推理。
- 对输出动作做缩放与限幅,生成目标位置或目标力矩。
- 通过 CAN 或 SPI 总线把力矩指令发给电机驱动板。
有一个细节值得注意:控制线程里绝对不能做任何动态内存分配。ONNXRuntime 的 session 在初始化时已经分配好了所有中间张量缓存,运行时推理只要保证输入输出张量的内存地址和大小不变,就不会触发新的分配。我在初始化阶段创建一个固定的输入缓冲区std::vector<float> obs(70),每次推理直接填充这个缓冲区,再把指向该缓冲区的指针传给 ONNXRuntime。
4.3 RKNN 的诱惑与陷阱
RK3566 的 NPU 算力大约是 0.8 TOPS,对一个小 MLP 来说理论上可以跑得很快。但直接用 RKNN 需要把 ONNX 转成 RKNN 格式,量化成 INT8 或者 FP16。这个过程中最大的麻烦不是转换本身,而是量化误差。
强化学习策略对动作的精度其实很敏感,尤其是关节位置增量这种连续量。INT8 量化后,动作输出可能会从原来的连续值变成几个离散档位,机器人跑步时关节会明显抖动。解决方法是做混合量化,把敏感层保留 FP16 或者 FP32,但这样一来 NPU 的加速收益就打了折扣。
我最终的方案是:RK3566 CPU 跑 ONNXRuntime FP32。实测推理延迟约 2 到 3 毫秒,加上状态读取和电机指令下发,整个控制循环能稳定在 1ms 到 2ms 内完成,跑到 500Hz 也没有压力。RKNN 留给后续有时间再做优化。
如果你确实考虑用 NPU,建议多准备一组带标签的观测数据作为量化校准集,量化后必须在实机上对比步态稳定性,不要只看模型输出的数值误差。另外需要留意的是 RKNN-Toolkit2 版本和板子固件的匹配问题,不同版本转出来的模型在驱动上兼容性不一致,建议直接使用开发板供应商推荐的版本组合。
5. 实机联调:从关节回中到站立的演进过程
5.1 关节回中和编码器校准
第一次上电千万不要直接把策略跑起来。先做电机零位校准。Microduck 的每个关节电机通常带磁编码器或者 AB 相编码器,断电后再上电,编码器读数会丢失绝对位置。需要把每个关节手动拨到机械零位附近,然后让驱动板记录当前编码器值作为零位偏移。
我写了个简单的测试程序,逐关节发送零位力矩,观察电机是否缓慢回到机械位置,然后手动把目标角度设为 0,记录编码器读数。这个步骤虽然枯燥,但直接影响后续所有控制。零位不准,后续的 PD 控制目标角度和实际机械角度会存在恒定偏差,机器人站起来的时候会像喝醉了一样往一边倒。
校准完成后,把 12 个零位偏移值存成配置文件,每次开机读取。注意不同批次的电机在安装时机械零位可能不同,不要把这些偏移硬编码进程序里。
5.2 单腿 PD 测试,别急着全机跑
整机站立之前先做单腿测试。把机器人侧躺或者固定在支架上,单独对其中一条腿的三个关节发送位置指令,验证电机方向、速度反馈和 PD 控制是否正常。这一步能暴露很多方向性问题,比如电机正反方向接反、编码器读数正负相反、CAN 通信偶发丢包等。
我实际踩过一个坑:个别关节的编码器读数的正方向与其他关节相反,这会导致强化学习的观测值直接是反的,机器人会觉得某个关节已经到位,实际电机已经转过了。解决方法是逐关节测试位置跟随,给一个正弦波目标,观察实际反馈是否同相位。
单腿测试通过的标志是:给定一个固定角度,电机能在 300ms 内到达并且不震荡;给定正弦波轨迹,实际角度和指令角度的相位差不超过一两个控制周期。这一步全部通过后再解锁其他三条腿。
5.3 站立、试探、行走——第一次跑起来的经验
当 12 个关节的 PD 都稳定后,先不要加载神经网络。我会有另一个环节:直接用一组固定的关节角度让机器人站起来,确认它能维持平衡而不倒。这个时候 PD 增益需要够大,但也不能太大,否则会产生高频振荡。
Microduck 这种小机器人在桌面或者地板上站立时,机身的轻微抖动是很正常的。如果发生明显的身体抖动,先降低 PD 增益里的微分项,或者降低动作转换时间常数,而不是上来就改动策略网络。
第一次加载策略时,我会把策略输出乘以一个缩放系数 0.3,让机器人的动作幅度非常小,观察它是否试图做出步态动作而不是直接倒下。确认没有剧烈异常后逐渐放大缩放系数到 1.0。这个过程类似于电机调试时的安全边际,能在有问题时减小物理损伤概率。
真正跑起来之后的经验是:训练时的动作延迟如果和实机不一致,步态会很难看。比如训练时动作延迟 1 个控制周期,实机如果不做延迟匹配,关节目标位置和实际位置会有一个周期偏移,等效于增加了相位滞后。为了消除这个差异,我在部署代码里手动加入了动作延迟缓冲,缓存两帧之前的动作作为当前实际执行的动作,这样和训练的时序对应起来,走路的稳定性和姿态会明显改善。
6. 实测数据复盘:步态频率、延迟和能耗
6.1 推理延迟和控制频率
实机联调结束后我做了一轮性能测试,把数据整理成表格方便后续定位问题。测试环境是 RK3566 四核 A55 跑 1.8GHz,系统大概占用一个核,控制线程绑定在 CPU2,ONNXRuntime FP32 单次推理,关掉所有打印输出。
| 测量项 | 数据 | 说明 |
|---|---|---|
| 控制循环平均周期 | 约 2.1ms | 对应约 476Hz,实际跑 400Hz 留有余量 |
| 控制循环最大周期 | 约 3.5ms | 出现在系统中断占用 CPU 时 |
| ONNXRuntime 单次推理延迟 | 约 2.3ms | 输入 70 维,输出 12 维,256x256 网络 |
| 整机功耗 | 约 7W | 主控加电机空载,不包含高强度行走 |
| 电池续航 | 约 18 到 22 分钟 | 2S 1200mAh 电池,混合步态实测 |
推理延迟 2.3ms 是这组网络规模下的正常水平。如果你把网络压缩到 [128, 128],延迟可以降到 1ms 左右,但步态质量可能会下降。控制周期稳定在 2.5ms 以内,对 400Hz 控制频率来说没问题。如果哪天看到最大周期超过 4ms,要先检查是不是有别的线程频繁抢占 CPU、DDR 带宽是否被占满、或者板子上 TF 卡读写拖了后腿。
6.2 稳定性优化的几个细节
跑动过程中出现过几次不稳定现象,排查下来原因各不相同,这里分享三个比较典型的经验。
第一个是 CAN 总线丢包。RK3566 与电机驱动板之间如果用 SPI 转 CAN 模块,在高负载时可能出现偶发丢帧,表现为某条腿瞬间失去力矩指令然后又开始动作。我最后在代码里加了一个通信超时保护:如果连续 20ms 没有收到电机反馈,就让所有电机进入安全模式,输出零力矩并停止步态。同时把 CAN 波特率从 500k 调整到 1M,丢包率明显下降。
第二个是 IMU 数据的预处理。RK3566 的 I2C 读取 IMU 数据如果直接在控制线程里做阻塞读取,偶尔会拖慢整个循环。我的办法是开一个独立的 IMU 读取线程,使用 I2C 轮询加 FIFO 缓存,控制线程拿到的始终是最近一次完整 IMU 数据,不会因为在控制循环里等 I2C 而卡住。实测下来,这能让控制周期抖动降低一半。
第三个是打印日志对实时性的影响。开发阶段大家喜欢在控制循环里加printf,在 RK3566 上这会让周期变得非常不稳定,串口打印一次可能消耗几百微秒到几毫秒。我的做法是所有日志通过环形缓冲区记录到内存,再通过另一个低优先级线程定期输出,控制线程保持干干净净零打印。
还有一个容易被忽略的点是 CPU 频率调节。Debian 系统的默认 CPU 调度可能是ondemand或者schedutil,在负载突增时频率切换会产生延迟尖峰。建议把 RK3566 的 CPU governor 固定到performance,用cpufreq-set -g performance,确保控制线程所在核心始终跑在最高频率。
最后说一点关于后续优化方向:RK3566 上 NPU 跑 INT8 量化策略是可以尝试的,但一定要用校准集做精度验证,并且在实际步态中评估;如果你的目标只是稳定运行,ONNXRuntime FP32 + 400Hz 控制循环已经是一个足够好的组合。还有一个很实用的扩展方向是把策略换成稀疏化版本,去掉网络中权重接近 0 的神经元,A55 上的推理延迟可以进一步压缩,不过这属于锦上添花,先把当前版本跑稳定比什么都重要。