本文档属于 Robotics Tutorial 项目,作者:Pengfei Guo,达妙科技。采用 CC BY 4.0 协议,转载请注明出处。
第 64 章 RL 的 C++ 部署——TorchScript / LibTorch + ONNX Runtime + 实时推理¶
难度: ⭐⭐ ~ ⭐⭐⭐ | 预计学时: 25-35 小时(1.5 周) | text:code = 6:4(工程实践为主章节)
一句话概要: 训好的 RL 策略是 Python 对象——要在 1 kHz 实时循环里以 \(< 1\) ms 推理,必须导出为 TorchScript/ONNX,用 LibTorch/ONNX Runtime 在 C++ 中加载,并解决零拷贝数据转换、内存预分配、安全降级等工程问题。
向前承接: Ch63 训好了 Go2 的 RL 策略(.pth checkpoint)——一个包含 Actor 网络权重的 Python 对象,此时它只能在 Python + PyTorch 环境中运行。Ch61 搭好了实时 C++ 和 ros2_control 框架——其中 update() 以 1 kHz 运行,要求每次调用的延迟严格低于 1 ms。Ch62 提供了硬件接口(Unitree SDK)——通过 UDP 协议以 500 Hz 与电机通信。本章把三者打通——策略从 Python 进入 C++ 实时循环。
向后指向: Ch65 将探索 RL+MPC 混合范式——其中 RL 部分的部署正是本章内容。Ch69(Mini-Legged 综合实战)将整合本章的部署技术完成真机验证。
前置自测¶
📋 答不出 \(\geq\) 2 题 → 先回对应章节复习
- [Ch61] 什么是
SCHED_FIFO?为什么实时控制线程需要用它而不是默认的 CFS 调度器? - [Ch61]
mlockall(MCL_CURRENT | MCL_FUTURE)的作用是什么?不调用它会导致什么实时性问题? - [Ch63] PPO 训练后得到的
.pthcheckpoint 文件包含什么内容?为什么不能直接在 C++ 中加载.pth? - [C++ 基础]
std::memcpy与std::copy在连续内存上的性能差异是什么?什么时候用哪个? - [Ch62] ros2_control 的
update()函数运行在什么线程上?它的调用频率由什么决定?
本章目标¶
学完本章,你应能:
- 理解从"Python 训好"到"C++ 部署"的完整流水线及其每一步的工程考量
- 掌握 TorchScript(trace vs script)两种导出方式的内部机制和适用场景
- 用 LibTorch C++ API 加载并运行 RL 策略,理解 Eigen 与 Tensor 之间的零拷贝转换
- 用 ONNX Runtime 作为跨框架推理引擎,理解 ONNX 格式的内部结构
- 在 ros2_control 的
update()中集成 RL 推理,满足 \(< 1\) ms 延迟约束 - 设计完整的安全降级机制——NaN 检测、数值范围检查、备用策略切换
- 精读 rl_sar 源码,理解腿足 RL 部署的工业级实现
64.1 RL 模型部署的基本问题 ⭐¶
动机:为什么不直接在 Python 里跑 RL 推理?¶
一个直觉的问题:Python + PyTorch 能跑推理,为什么还要折腾 C++?
答案在于**实时性和可靠性**的双重约束。让我们系统对比两种方案:
| 维度 | Python + PyTorch | C++ + LibTorch/ONNX |
|---|---|---|
| 推理延迟 | 1-5 ms(含 GIL、解释器开销) | 50-200 \(\mu\)s |
| 延迟确定性 | 不确定(GC 暂停、GIL 竞争) | 确定(无 GC,可 SCHED_FIFO) |
| 依赖大小 | ~2 GB(Python + PyTorch + CUDA) | ~200 MB(LibTorch)或 ~50 MB(ONNX) |
| 与 ros2_control 集成 | 需要 ROS 2 Python wrapper | 原生 C++ 插件 |
| GIL 问题 | 阻塞实时线程 | 无 GIL |
| 部署复杂度 | 低(直接用 Python) | 中(需要导出+编译) |
Python 方案在研究环境下可行(如 Gazebo 仿真里跑 RL),但在**真机部署**时有三个致命问题:
- GIL (Global Interpreter Lock): Python 的 GIL 让多线程形同虚设。如果 RL 推理线程和 ROS 2 通信线程共享 GIL,推理可能在任意时刻被阻塞。
- 垃圾回收 (GC): Python 的 GC 可能在任意时刻暂停程序数十毫秒——Ch61 讲过,这对 1 kHz 控制循环是致命的(5 ms 的暂停就可能让机器人摔倒)。
- 延迟不确定性: Python 解释器对每行代码的执行时间不做保证,无法满足硬实时约束。
因此,生产级腿足 RL 部署必须走 C++ 路线。本章的全部内容就是解决"如何优雅地把 Python 训好的模型搬到 C++ 实时循环中"这个工程问题。
跨领域类比:RL 模型从 Python 到 C++ 的部署,类似于芯片设计从 RTL 仿真到流片的过程——仿真环境(Python/PyTorch)中功能验证通过后,必须经过综合、布局布线、时序收敛等一系列工程步骤才能变成可在真实硬件上运行的芯片(C++ 二进制)。两者的共同挑战是:功能等价性验证(Python 和 C++ 的推理结果必须一致)和**时序约束满足**(推理延迟必须在截止时间内)。但不同于芯片流片的不可逆性,RL 模型部署可以反复迭代——这是软件部署的优势。
如果不用 C++ 部署会怎样¶
在真实工程中遇到过的问题:
| 问题 | Python 部署的后果 | C++ 部署的解决 |
|---|---|---|
| GIL 竞争 | RL 推理被 ROS callback 阻塞 300 ms,机器人失控摔倒 | 无 GIL,独立线程不受干扰 |
| GC 暂停 | 控制循环突然停顿 50 ms,摆动腿未能按时着地 | 无 GC(RAII 管理生命周期) |
| 依赖冲突 | PyTorch 版本与 ROS 2 的 Python 环境冲突,安装耗时数天 | LibTorch 是独立 C++ 库,与 ROS 2 无冲突 |
| 嵌入式部署 | Jetson Orin 的 Python 环境配置极其痛苦(交叉编译、conda 不兼容) | 交叉编译 C++ 二进制,干净部署 |
两大部署方案概览¶
把 PyTorch nn.Module 变成 C++ 可执行的模型,有两条主路:
方案 A:TorchScript + LibTorch(PyTorch 生态内)
优势:与 PyTorch 完全兼容,API 熟悉。劣势:依赖较大(~200 MB)。
方案 B:ONNX + ONNX Runtime(跨框架)
优势:框架无关,依赖小(~50-100 MB),可接入 TensorRT/OpenVINO 加速。劣势:新算子支持滞后 PyTorch 几个月。
两种方案的详细对比见 64.4 节末尾。先从方案 A 的第一步——TorchScript 导出开始。
⚠️ 常见陷阱¶
⚠️ 思维陷阱:认为 Python 部署"够用" 新手想法: "我在 Gazebo 里用 Python 跑得好好的,真机也可以。" 实际情况: Gazebo 是软实时(偶尔丢帧只是仿真不准确),真机是硬实时(丢帧 = 摔倒 = 硬件损坏)。Python 的不确定性延迟在软实时下可以接受,在硬实时下不可接受。 判断标准: 如果控制频率 \(\geq\) 200 Hz 且真机部署,必须 C++。如果只在仿真中验证算法,Python 可以。
⚠️ 概念误区:混淆训练和推理的计算量 新手想法: "训练要用 GPU 几十小时,推理肯定也很慢。" 实际情况: 训练慢是因为反向传播 + 4096 并行环境 + 数千次迭代。推理只是一个小 MLP 的**单次前向传播**:48 维输入 → 256 → 256 → 12 维输出,约 50k 次浮点乘加。现代 CPU 的单核算力约 10 GFLOPS,理论上 \(50000 / (10 \times 10^9) \approx 5 \mu s\)。实际框架开销让延迟到 50-100 \(\mu\)s,但仍然极快。 关键认知: RL 策略的推理**极其轻量**,瓶颈不在计算本身,而在框架调用开销。
⚠️ 编程陷阱:在 ROS 2 Python 节点中直接调用 PyTorch 错误做法: 在
rclpy节点的timer_callback中调model.forward(obs)。 后果:timer_callback与 ROS 2 的 executor 共享 GIL。如果有其他 Python callback(如 subscriber),它们可能抢占 GIL,导致推理延迟不可预测。 正确做法: 用 C++ ros2_control Controller(Ch61 讲过),RL 推理在 Controller 的update()中原生运行,不经过 Python。
练习¶
练习 64.1.1: 估算一个 MLP [48 → 256 → 256 → 12] 的前向传播需要多少次浮点乘加(FLOPs)。具体计算:第一层 \(48 \times 256\) 次乘法 + \(256\) 次加法;第二层 \(256 \times 256\);第三层 \(256 \times 12\)。假设 CPU 能做 10 GFLOPS,理论推理时间是多少微秒?
练习 64.1.2: 列出 Python + PyTorch 推理链路上的所有开销来源(从调用 model.forward(obs) 到返回结果)。提示:考虑 Python 解释器、PyTorch dispatch、tensor 创建、autograd 图构建等。哪些开销在 C++ 中可以消除?
部署的第一步是把 PyTorch 模型导出为可序列化的格式。下一节深入 TorchScript 的内部机制。
64.2 TorchScript 导出:从 .pth 到 .pt ⭐⭐¶
动机:为什么 .pth 不能直接用?¶
普通 PyTorch checkpoint .pth 文件存的是 state_dict——即模型参数(权重矩阵和偏置向量)的字典。它**不包含网络结构定义**。要使用 .pth,必须先在 Python 中定义网络类(class ActorCritic(nn.Module): ...),然后 model.load_state_dict(torch.load("model.pth"))。
这意味着:加载 .pth 需要 Python 解释器 + 模型定义源代码 + PyTorch 完整环境。在 C++ 中无法直接使用。
TorchScript .pt 文件存的是**网络结构 + 权重 + 优化信息**,可以在没有 Python 的环境中独立加载和执行。这曾经是从 Python 到 C++ 的主桥梁。需要注意的是,PyTorch 官方已经将 TorchScript 标记为 deprecated,新项目应优先评估 torch.export、ONNX 或 AOTInductor 等路线;本节保留 TorchScript,是因为大量腿足 RL 项目和旧版部署代码仍在使用 LibTorch/TorchScript。
TorchScript 的内部机制¶
TorchScript 不是简单的"序列化"——它是一个完整的**中间表示(IR)和编译器**。理解其内部机制有助于排查导出问题。
TorchScript IR 是一个 静态单赋值(SSA, Static Single Assignment) 格式的有向无环图(DAG)。其核心组件:
- Graph: 根容器,表示一个函数的完整计算图
- Node: 表示一个操作(如
aten::linear、aten::elu、prim::Constant) - Value: 表示数据流(输入 tensor、中间结果、输出 tensor),每个 Value 有显式类型
- Block: 控制流的基本单元(对应
if/else分支)
IR 经过的**优化 pass**(在 torch/csrc/jit/passes/ 中实现):
| 优化 Pass | 作用 | 对 RL 策略的影响 |
|---|---|---|
| 常量折叠 (Constant Folding) | 预计算只依赖常量的子图 | 将 bias 的 reshape 等常量操作消除 |
| 死代码消除 (DCE) | 删除未使用的计算 | 删除 Critic 相关的死代码(如果导出时包含了) |
| 公共子表达式消除 (CSE) | 合并重复计算 | 如果多个奖励项共享子表达式(不常见) |
| 算子融合 (Operator Fusion) | 多个算子合并为一个高效内核 | Linear + ELU 可能被融合,显著加速 |
这些优化让 TorchScript 导出的模型比 Python eager 模式快 20-50%。
两种导出方法¶
方法 1:torch.jit.trace(推荐用于 RL 策略)
import torch
# 1. 加载训好的模型
model = ActorCritic(num_obs=48, num_actions=12)
model.load_state_dict(torch.load("checkpoint.pth"))
model.eval() # 必须!切换到 eval 模式
# 2. 只导出 Actor 部分(部署不需要 Critic)
actor = model.actor
# 3. 用示例输入 trace
example_input = torch.randn(1, 48) # batch=1, obs_dim=48
traced_actor = torch.jit.trace(actor, example_input)
# 4. 保存
traced_actor.save("policy.pt")
# 5. 验证一致性
with torch.no_grad():
out_orig = actor(example_input)
out_traced = traced_actor(example_input)
max_err = (out_orig - out_traced).abs().max().item()
assert max_err < 1e-6, f"误差过大: {max_err:.2e}"
print(f"Trace 验证通过, 最大误差: {max_err:.2e}")
trace 的原理: 用 example_input 做一次完整的前向传播,**记录**所有经过的算子及其数据依赖关系,生成计算图(TorchScript IR)。本质上是一次"录像"——只录制实际执行的路径。
trace 的局限:
- 不记录控制流: if/else、while 等分支不被记录——只记录 trace 时实际走过的那条路径。如果另一个输入会走不同分支,trace 结果就是错的。
- 不记录动态 shape: 如果模型对不同 batch size 有不同行为,需要额外处理。
对 RL 策略为什么 trace 就够了: RL 策略网络通常是纯前馈 MLP(Linear → ELU → Linear → ELU → Linear),没有条件分支,输入 shape 固定(batch=1,obs_dim=48)。trace 完美适用。
方法 2:torch.jit.script(编译式,更严格)
script 的原理: 直接**编译** Python 源代码为 TorchScript IR。支持 if/else、for、while 等控制流,但要求代码符合 TorchScript 的语法子集(不支持某些 Python 特性,如动态类型、列表推导式等)。
选型建议: - 纯 MLP 策略(无分支,无 RNN)→ trace(简单可靠) - 有 RNN(GRU/LSTM)或条件分支 → script(更完整) - 不确定 → 先尝试 trace;如果 trace 后的输出不正确,再用 script
完整的导出脚本¶
"""export_policy.py — 导出 rsl_rl 训好的策略为 TorchScript"""
import torch
import torch.nn as nn
import argparse
def export_policy(checkpoint_path, output_path, obs_dim=48, act_dim=12):
# 1. 重建 Actor 网络结构(必须和训练时一致!)
# rsl_rl 默认: 3 层 MLP, 隐藏维度 256, 激活函数 ELU
actor = nn.Sequential(
nn.Linear(obs_dim, 256),
nn.ELU(), # 注意: rsl_rl 默认用 ELU, 不是 ReLU!
nn.Linear(256, 256),
nn.ELU(),
nn.Linear(256, 256),
nn.ELU(),
nn.Linear(256, act_dim),
)
# 2. 从 checkpoint 提取 Actor 权重
ckpt = torch.load(checkpoint_path, map_location='cpu')
actor_state = {}
for key, val in ckpt['model_state_dict'].items():
if key.startswith('actor.'):
actor_state[key.replace('actor.', '')] = val
actor.load_state_dict(actor_state)
# 3. 切换到 eval 模式
actor.eval()
# 4. Trace
example = torch.randn(1, obs_dim)
traced = torch.jit.trace(actor, example)
# 5. 冻结(将参数内联为常量,进一步优化)
traced = torch.jit.freeze(traced)
# 6. 保存
traced.save(output_path)
print(f"导出成功: {output_path}")
# 7. 加载验证
with torch.no_grad():
out_orig = actor(example)
loaded = torch.jit.load(output_path)
out_loaded = loaded(example)
max_err = (out_orig - out_loaded).abs().max().item()
print(f"最大误差: {max_err:.2e}")
assert max_err < 1e-5, f"误差过大: {max_err}"
# 8. 多输入验证(确保不是偶然一致)
for i in range(10):
test_input = torch.randn(1, obs_dim)
err = (actor(test_input) - loaded(test_input)).abs().max().item()
assert err < 1e-5, f"输入 {i} 误差过大: {err}"
print("10 个随机输入验证全部通过")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--ckpt", required=True, help="checkpoint .pth 路径")
parser.add_argument("--out", default="policy.pt", help="输出 .pt 路径")
parser.add_argument("--obs_dim", type=int, default=48)
parser.add_argument("--act_dim", type=int, default=12)
args = parser.parse_args()
export_policy(args.ckpt, args.out, args.obs_dim, args.act_dim)
⚠️ 常见陷阱¶
⚠️ 编程陷阱:导出时未切换到 eval 模式 错误做法: 直接
torch.jit.trace(model, example),model 仍在 train 模式。 后果: 如果网络有 BatchNorm 或 Dropout,train 模式和 eval 模式行为不同。train 模式下 BatchNorm 使用 mini-batch 统计量(随机的),eval 模式使用全局统计量(固定的)。导出的模型在不同输入上可能给出不一致的输出。 正确做法: 导出前必须model.eval()。⚠️ 编程陷阱:rsl_rl 的激活函数是 ELU 不是 ReLU 错误做法: 导出时用
nn.ReLU()重建网络结构。 后果: 权重加载不会报错(因为激活函数没有可学习参数,state_dict不含激活函数的 key),但推理结果完全错误。ELU 对负值输出 \(\alpha(e^x - 1)\),ReLU 直接截断为 0——行为差异巨大。 正确做法: 检查 rsl_rl 训练配置中的activation参数。默认是nn.ELU()。⚠️ 概念误区:认为 trace 和 script 在所有情况下效果相同 新手想法: "都能导出 TorchScript,用哪个都一样。" 实际情况: 对于纯 MLP 两者确实等价。但如果网络有数据依赖的分支(如
if obs[0] > 0: use_path_A() else: use_path_B()),trace 只记录一条路径——另一条路径会被丢弃。script 则编译所有分支。 最佳实践: 先尝试 trace;导出后用多个随机输入验证输出一致性。如果发现不一致,说明有条件分支,需要用 script。
练习¶
练习 64.2.1: 写一个完整的 export.py 脚本,加载你在 Ch63 训好的 Go2 checkpoint,导出为 TorchScript .pt 文件。用 10 个随机输入验证导出前后的输出误差均 < 1e-6。
练习 64.2.2: 用 torch.jit.trace 导出一个包含 if-else 分支的简单网络(if x.sum() > 0: return linear1(x) else: return linear2(x))。观察导出的模型在两种输入(正/负)上是否都正确。然后用 torch.jit.script 重新导出,对比结果。
练习 64.2.3: 查看导出的 .pt 文件的 TorchScript IR:用 traced.graph 打印计算图。能否在图中找到 ELU 算子?torch.jit.freeze 前后图有什么变化?
TorchScript 把模型从 Python 解放出来。下一步是在 C++ 中加载并运行它——这需要 LibTorch。
本质洞察:TorchScript / ONNX 等模型导出格式的本质是**将计算图与编程语言解耦**——就像 PDF 将文档排版与创作工具解耦一样。Python 是创作工具(训练方便),C++ 是阅读器(运行高效),而导出格式是两者之间的"通用表示层"。理解了这一点,你就能预判所有导出问题的根源:凡是无法被序列化为纯计算图的 Python 动态特性(控制流、动态 shape、自定义算子),都会在导出时出问题。
64.3 LibTorch C++ 加载与推理 ⭐⭐¶
动机:LibTorch 是什么?¶
LibTorch 是 PyTorch 的 C++ 运行时。它提供了与 Python PyTorch 几乎对等的 API:tensor 创建/操作、自动微分、JIT 模型加载。但它是纯 C++ 库,不依赖 Python 解释器。
LibTorch 与 PyTorch 版本号同步,可从 PyTorch 官网下载预构建包,按平台区分:CPU / CUDA 11.8 / CUDA 12.x / ARM64(Jetson)等变体。建议使用与训练端 PyTorch 相同的主版本号,以避免算子兼容性问题。
LibTorch 与 Python PyTorch API 的对照¶
学过 Python PyTorch 的开发者可以快速上手 LibTorch——两者 API 高度对称:
| 操作 | Python PyTorch | LibTorch (C++) |
|---|---|---|
| 创建零 tensor | torch.zeros(3, 3) |
torch::zeros({3, 3}) |
| 指定数据类型 | dtype=torch.float32 |
torch::dtype(torch::kFloat32) |
| 指定设备 | device='cuda:0' |
torch::Device(torch::kCUDA, 0) |
| 加载 TorchScript | torch.jit.load("p.pt") |
torch::jit::load("p.pt") |
| 前向推理 | model(input) |
module.forward({input}).toTensor() |
| 禁用梯度 | with torch.no_grad(): |
torch::NoGradGuard no_grad; |
| tensor→指针 | t.data_ptr() |
t.data_ptr<float>() |
| 从外部数据创建 | torch.from_numpy(arr) |
torch::from_blob(ptr, shape) |
最小化 C++ 推理代码¶
下面是一个完整的、可在 ros2_control Controller 中使用的 RL 推理类。每一行都有详细注释解释"为什么这样写":
#include <torch/script.h> // TorchScript 加载
#include <torch/torch.h> // tensor 操作
#include <Eigen/Dense> // 机器人状态用 Eigen 表示
#include <string>
#include <chrono>
class RLPolicy {
public:
/**
* 构造函数: 加载模型 + 预分配内存 + warmup
*
* 为什么在构造函数中做所有初始化?
* 因为构造函数在 ros2_control 的 on_configure() 阶段调用,
* 此时不在实时循环中,可以有较大延迟(秒级)。
* 而 forward() 在 update() 实时循环中调用,必须极快。
*/
RLPolicy(const std::string& model_path,
int obs_dim, int act_dim,
const std::string& device = "cpu")
: obs_dim_(obs_dim), act_dim_(act_dim) {
// 1. 加载 TorchScript 模型
try {
module_ = torch::jit::load(model_path);
} catch (const c10::Error& e) {
throw std::runtime_error(
"Failed to load TorchScript model: " + std::string(e.what()));
}
module_.eval(); // 确保 eval 模式(关闭 dropout/batchnorm 的训练行为)
// 2. 设置设备
// ⚠️ 实时部署建议使用 CPU:CUDA 的 host↔device memcpy 延迟不确定,
// 不适合 1 kHz 硬实时循环。GPU 推理适合 >10ms 预算的场景。
device_ = torch::Device(device);
module_.to(device_);
// 3. 预分配 tensor(关键的实时编程技巧,Ch61)
// 启动时分配一次,运行时只填充数据,永不释放/重建
input_tensor_ = torch::zeros(
{1, obs_dim_},
torch::TensorOptions().dtype(torch::kFloat32).device(device_)
);
// 4. 预分配 Eigen 输出向量
action_ = Eigen::VectorXd::Zero(act_dim_);
// 5. 预分配 float 缓冲区(用于 double→float 转换)
obs_float_buffer_.resize(obs_dim_, 0.0f);
// 6. Warmup(消除 JIT 首次编译延迟)
warmup(20);
}
/**
* 前向推理——在 1 kHz 实时循环中调用
* 设计目标: < 200 us 延迟, 零堆分配
*/
const Eigen::VectorXd& forward(const Eigen::VectorXd& obs) {
// Step 1: Eigen (double) → float 缓冲区
// 为什么不直接用 from_blob? 因为 Eigen 是 double, 网络要 float
for (int i = 0; i < obs_dim_; ++i) {
obs_float_buffer_[i] = static_cast<float>(obs[i]);
}
// Step 2: 填充预分配的 input tensor(零拷贝,只覆盖数据)
std::memcpy(input_tensor_.data_ptr<float>(),
obs_float_buffer_.data(),
obs_dim_ * sizeof(float));
// Step 3: 前向推理(核心,~50-100 us)
torch::NoGradGuard no_grad; // 禁用 autograd,节省 ~20% 时间
auto output = module_.forward({input_tensor_}).toTensor();
// Step 4: tensor (float) → Eigen (double)
auto output_accessor = output.cpu().accessor<float, 2>();
for (int i = 0; i < act_dim_; ++i) {
action_[i] = static_cast<double>(output_accessor[0][i]);
}
return action_;
}
/**
* 获取推理延迟统计
*/
double getLastInferenceTimeUs() const { return last_inference_us_; }
private:
void warmup(int n) {
// LibTorch 第一次前向传播会触发 JIT 编译:
// TorchScript IR → 当前 CPU/GPU 的优化机器码
// 这个编译可能耗时 100-500 ms
// 在启动阶段做 n 次 warmup,确保实时循环中不触发编译
torch::NoGradGuard no_grad;
for (int i = 0; i < n; ++i) {
module_.forward({input_tensor_});
}
}
torch::jit::script::Module module_;
torch::Device device_{torch::kCPU};
torch::Tensor input_tensor_;
Eigen::VectorXd action_;
std::vector<float> obs_float_buffer_;
int obs_dim_;
int act_dim_;
double last_inference_us_ = 0.0;
};
Eigen 与 Tensor 的数据转换:细节与陷阱¶
Eigen 和 PyTorch Tensor 的内存布局有关键差异:
| 维度 | Eigen | PyTorch Tensor |
|---|---|---|
| 默认存储顺序 | 列优先(Column-major) | 行优先(Row-major, C-contiguous) |
| 默认数据类型 | double (float64) |
float (float32) |
| 内存管理 | 栈分配(固定大小)或堆(动态大小) | 堆分配,引用计数 |
方案 A:零拷贝(from_blob)——快但有陷阱
// Eigen::VectorXf → torch::Tensor (零拷贝)
Eigen::VectorXf obs_f(48);
// ... 填充 obs_f ...
auto tensor = torch::from_blob(
obs_f.data(), // 指向 Eigen 内存的指针
{1, 48}, // shape
torch::kFloat32 // dtype
);
// 注意: tensor 不拥有这块内存!
// 如果 obs_f 被销毁或超出作用域,tensor 变成悬垂引用。
// 在实时循环中, obs_f 是成员变量(不会销毁),所以安全。
方案 B:拷贝(memcpy)——安全且对小 tensor 不比 from_blob 慢多少
// 预分配 tensor(构造时)
torch::Tensor input = torch::zeros({1, 48}, torch::kFloat32);
// 每次循环: 拷贝数据到预分配 tensor
std::memcpy(input.data_ptr<float>(), obs_float.data(), 48 * sizeof(float));
// 48 个 float = 192 字节, memcpy 耗时 < 1 us
推荐方案: 对于 RL 策略部署(obs_dim < 200),方案 B(memcpy 到预分配 tensor)更安全且性能充足。方案 A 的零拷贝优势在 < 200 维时可以忽略。
性能数据¶
典型 MLP [48 → 256 → 256 → 256 → 12] 的单次推理延迟:
| 平台 | CPU 推理 | GPU 推理 | 说明 |
|---|---|---|---|
| x86 i7-12700 | ~50 \(\mu\)s | ~200 \(\mu\)s | GPU 反而更慢(核启动+PCIe 拷贝) |
| Jetson Orin (ARM) CPU | ~100 \(\mu\)s | — | ARM CPU 较慢 |
| Jetson Orin GPU | — | ~150 \(\mu\)s | GPU 开销依然存在 |
| Jetson Nano (ARM) CPU | ~300 \(\mu\)s | — | 老平台,较慢但够用 |
关键发现: 对于参数量 < 100K 的小 MLP,CPU 推理通常比 GPU 推理更快。原因:GPU 的核启动(kernel launch ~10 \(\mu\)s)和数据拷贝(CPU→GPU→CPU,经 PCIe 总线)的固定开销超过了 GPU 并行计算的收益。只有当网络较大(参数量 > 1M,如 CNN 视觉策略)时,GPU 加速才有意义。
本质洞察:RL 策略部署的性能瓶颈**不是计算本身,而是框架调用开销**。一个 [48→256→256→12] 的 MLP 前向传播理论上只需 ~5 \(\mu\)s 的浮点运算,但 LibTorch/ONNX Runtime 的框架开销(tensor 元数据管理、算子调度、内存管理)将实际延迟推高到 50-100 \(\mu\)s——框架开销是计算本身的 10-20 倍。这解释了为什么"预分配 + 零拷贝 + warmup"这些看似琐碎的工程技巧如此关键——它们消除的不是计算量,而是框架开销。
实时循环集成¶
// 在 ros2_control Controller 的 update() 里集成 RL 推理
controller_interface::return_type
RLController::update(const rclcpp::Time& time,
const rclcpp::Duration& period) {
// 1. 从 state_interfaces 读取传感器数据
readSensorData(); // 填充 joint_positions_, joint_velocities_, imu_ 等
// 2. 构建观测向量(必须与训练时的观测构建一致!)
Eigen::VectorXd obs = buildObservation(
joint_positions_, joint_velocities_,
imu_angular_vel_, projected_gravity_,
velocity_command_, last_action_
); // shape: (obs_dim_,)
// 3. RL 推理 (~50-100 us)
const Eigen::VectorXd& action = policy_->forward(obs);
// 4. 动作后处理
// action 是关节位置偏移量, 加上默认站立姿态 -> 关节目标位置
Eigen::VectorXd q_des = default_joint_pos_ + action * action_scale_;
// 5. 安全检查 (64.7 节详述)
if (!safety_guard_->checkAction(q_des, joint_positions_, last_q_des_)) {
q_des = safety_guard_->getFallbackAction(last_safe_q_des_, joint_positions_);
}
// 6. 写入 command_interfaces
for (int i = 0; i < num_joints_; ++i) {
command_interfaces_[i].set_value(q_des[i]);
}
last_action_ = action;
last_safe_q_des_ = q_des;
return controller_interface::return_type::OK;
}
⚠️ 常见陷阱¶
⚠️ 编程陷阱:每次推理都创建新的 tensor 错误做法: 在
update()中auto tensor = torch::zeros({1, 48});或torch::from_blob(...)。 问题: 即使from_blob不分配 tensor 数据内存,它也会创建 TensorImpl 元数据对象(涉及堆分配+引用计数)。在 1 kHz 循环中,每秒 1000 次堆分配 → 内存碎片化 + 可能触发 GC(如果用了 shared_ptr)。 正确做法: 构造时预分配 tensor,每次循环只用memcpy覆盖数据。⚠️ 编程陷阱:
from_blob的生命周期陷阱 错误做法:from_blob引用了一个函数局部变量的数据:后果: 返回的 tensor 指向已销毁的栈内存——未定义行为(可能段错误,也可能读到"看起来正确"的垃圾数据,后者更难调试)。 正确做法: 确保torch::Tensor makeTensor() { float data[48] = {...}; return torch::from_blob(data, {1, 48}); // data 在栈上,函数返回后失效! }from_blob引用的数据在 tensor 使用期间始终有效(如类成员变量)。或者直接用memcpy拷贝到预分配 tensor。⚠️ 概念误区:在实时循环中用 GPU 推理小 MLP 新手想法: "GPU 更快,推理当然要用 GPU。" 实际情况: 对于 < 100K 参数的 MLP,GPU 推理的固定开销(核启动 + PCIe 数据往返)~200 \(\mu\)s,远大于 CPU 推理时间 ~50 \(\mu\)s。如果整个控制循环在 CPU 上,GPU 推理意味着每步都有 CPU→GPU→CPU 的数据往返,反而更慢。 正确做法: 小网络用 CPU 推理。大网络(如 CNN 视觉策略)或已有 GPU 数据管道时才用 GPU。
练习¶
练习 64.3.1: 写一个最小 C++ 程序:在 Python 中创建 MLP [3→8→1],导出为 TorchScript;在 C++ 中加载,传入 [1, 2, 3],验证输出与 Python 一致(误差 < 1e-6)。给出完整的 CMakeLists.txt。
练习 64.3.2: 在练习 64.3.1 基础上,用 std::chrono::high_resolution_clock 测量 1000 次推理的平均延迟和 P99 延迟。对比有/无 torch::NoGradGuard 的性能差异(百分比)。
练习 64.3.3: 实现 Eigen→Tensor 的两种转换方式(零拷贝 from_blob vs 拷贝 memcpy),各测 10000 次,对比单次转换延迟。对于 48 维向量,差异有多大?
LibTorch 与 PyTorch 生态紧密耦合。如果你想脱离 PyTorch、追求更小的部署包或使用 TensorRT 加速,ONNX Runtime 是另一个强力选择。
64.4 ONNX + ONNX Runtime 部署 ⭐⭐¶
动机:为什么考虑 ONNX?¶
ONNX(Open Neural Network Exchange)是微软、Meta 等联合推出的**跨框架模型表示格式**。使用 ONNX 的三大动机:
- 框架无关: 不依赖 PyTorch——未来如果团队换 JAX/TensorFlow 训练,部署代码不变
- 部署包小: ONNX Runtime ~50-100 MB vs LibTorch ~200 MB——在嵌入式平台(Jetson)上更友好
- 多加速后端: 可以接入 TensorRT(NVIDIA GPU 极致优化)、OpenVINO(Intel CPU)、CoreML(Apple M 系列芯片)等——同一个 ONNX 模型,不同平台用不同后端
ONNX Runtime 保持频繁的发布节奏,请以官方 GitHub Releases 页面确认当前版本及其编译器和 CUDA 版本要求。
ONNX 格式的内部结构¶
ONNX 文件是一个 protobuf 序列化的计算图。理解其结构有助于排查导出和推理问题:
ONNX Model (protobuf 序列化)
├── ir_version: 9
├── opset_import: [{domain: "", version: 17}]
├── graph: GraphProto
│ ├── name: "policy"
│ ├── input: [ValueInfoProto("obs", [1, 48], FLOAT)]
│ ├── output: [ValueInfoProto("action", [1, 12], FLOAT)]
│ ├── node: [ # 计算图的算子节点
│ │ NodeProto(op="Gemm", in=["obs","w0","b0"], out=["gemm0"]), # Linear
│ │ NodeProto(op="Elu", in=["gemm0"], out=["elu0"]),
│ │ NodeProto(op="Gemm", in=["elu0","w1","b1"], out=["gemm1"]),
│ │ ...
│ ]
│ └── initializer: [ # 模型权重(作为常量嵌入)
│ TensorProto("w0", [48, 256], data=[...]),
│ TensorProto("b0", [256], data=[...]),
│ ...
│ ]
每个 NodeProto 对应一个 ONNX 算子(如 Gemm = General Matrix Multiply = Linear 层,Elu = ELU 激活)。算子的语义由 opset 版本决定。
ONNX 导出¶
import torch
model = load_trained_actor() # 加载训好的 Actor 网络
model.eval()
example_input = torch.randn(1, 48)
torch.onnx.export(
model,
example_input,
"policy.onnx",
export_params=True, # 将权重嵌入 ONNX 文件
opset_version=17, # opset 17 支持 ELU 等算子
input_names=["obs"], # 输入名称(C++ 推理时要用)
output_names=["action"], # 输出名称
dynamic_axes=None, # RL 用固定 batch size=1
do_constant_folding=True, # 常量折叠优化
)
# 验证
import onnx
onnx_model = onnx.load("policy.onnx")
onnx.checker.check_model(onnx_model)
print("ONNX 模型验证通过")
# 用 onnxruntime 在 Python 中验证数值一致性
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession("policy.onnx")
onnx_out = sess.run(["action"], {"obs": example_input.numpy()})[0]
torch_out = model(example_input).detach().numpy()
max_err = np.abs(onnx_out - torch_out).max()
print(f"ONNX vs PyTorch 最大误差: {max_err:.2e}")
assert max_err < 1e-5
C++ ONNX Runtime 推理¶
#include <onnxruntime_cxx_api.h>
#include <Eigen/Dense>
#include <vector>
class ONNXPolicy {
public:
ONNXPolicy(const std::string& model_path, int obs_dim, int act_dim)
: obs_dim_(obs_dim), act_dim_(act_dim),
env_(ORT_LOGGING_LEVEL_WARNING, "rl_policy"),
session_(env_, model_path.c_str(), Ort::SessionOptions{}) {
// 预分配缓冲区
input_data_.resize(obs_dim, 0.0f);
input_shape_ = {1, static_cast<int64_t>(obs_dim)};
action_ = Eigen::VectorXd::Zero(act_dim);
// 创建 MemoryInfo(描述内存在 CPU 上)
memory_info_ = Ort::MemoryInfo::CreateCpu(
OrtDeviceAllocator, OrtMemTypeCPU
);
}
const Eigen::VectorXd& forward(const Eigen::VectorXd& obs) {
// 1. Eigen (double) → float 缓冲区
for (int i = 0; i < obs_dim_; ++i) {
input_data_[i] = static_cast<float>(obs[i]);
}
// 2. 创建 Ort::Value(引用预分配缓冲区,不拷贝)
Ort::Value input_tensor = Ort::Value::CreateTensor<float>(
memory_info_, input_data_.data(), input_data_.size(),
input_shape_.data(), input_shape_.size()
);
// 3. 推理
const char* input_names[] = {"obs"};
const char* output_names[] = {"action"};
auto output_tensors = session_.Run(
Ort::RunOptions{nullptr},
input_names, &input_tensor, 1,
output_names, 1
);
// 4. 提取输出
float* out_ptr = output_tensors[0].GetTensorMutableData<float>();
for (int i = 0; i < act_dim_; ++i) {
action_[i] = static_cast<double>(out_ptr[i]);
}
return action_;
}
private:
Ort::Env env_;
Ort::Session session_;
Ort::MemoryInfo memory_info_{nullptr};
std::vector<float> input_data_;
std::vector<int64_t> input_shape_;
Eigen::VectorXd action_;
int obs_dim_, act_dim_;
};
ONNX Runtime C++ API 详解 ⭐⭐¶
ONNX Runtime 的 C++ API 与 LibTorch 有显著风格差异。理解其核心概念对于正确使用至关重要:
四大核心对象:
| 对象 | 生命周期 | 职责 | 类比 |
|---|---|---|---|
Ort::Env |
全局唯一 | 运行时环境,管理线程池和日志 | 类似 CUDA context |
Ort::SessionOptions |
创建 Session 前设置 | 配置优化级别、执行后端 | 类似 CMake 选项 |
Ort::Session |
每个模型一个 | 持有加载的模型,执行推理 | 类似 torch::jit::Module |
Ort::Value |
每次推理创建 | 张量数据容器(可引用外部内存) | 类似 torch::Tensor |
Session Options 的关键配置:
Ort::SessionOptions opts;
// 优化级别:ORT_ENABLE_ALL 启用所有图优化(算子融合、常量折叠等)
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
// 线程数:RL 推理通常设 1(避免多线程调度开销)
opts.SetIntraOpNumThreads(1);
opts.SetInterOpNumThreads(1);
// 执行模式:顺序执行(确定性延迟)
opts.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL);
// 可选:启用 CUDA 加速(Jetson 上推荐搭配 TensorRT)
// OrtCUDAProviderOptions cuda_opts;
// cuda_opts.device_id = 0;
// opts.AppendExecutionProvider_CUDA(cuda_opts);
零拷贝推理模式:ONNX Runtime 支持直接引用外部内存创建张量,避免数据拷贝:
// 零拷贝模式:Ort::Value 直接引用 input_data_ 的内存
// 推理时不需要 memcpy,节省 ~5-10 us
Ort::Value input_tensor = Ort::Value::CreateTensor<float>(
memory_info_,
input_data_.data(), // 直接引用已有缓冲区
input_data_.size(), // 元素数量
input_shape_.data(), // shape 数组
input_shape_.size() // shape 维度数
);
// 注意:input_data_ 的生命周期必须覆盖整个 Run() 调用
// 如果 input_data_ 在 Run() 返回前被销毁,行为未定义
TorchScript vs ONNX 部署的定量对比 Benchmark ⭐⭐¶
以下基准测试在三个平台上对比了两种部署方案,使用的模型是典型的腿足 RL 策略(3 层 MLP [256, 128, 12],输入 48 维,输出 12 维):
平台 A:Intel i7-12700H(台式机/NUC)
| 指标 | LibTorch (CPU) | ONNX Runtime (CPU) | ONNX + OpenVINO |
|---|---|---|---|
| 平均延迟 | ~48 \(\mu\)s | ~35 \(\mu\)s | ~28 \(\mu\)s |
| P99 延迟 | ~82 \(\mu\)s | ~55 \(\mu\)s | ~42 \(\mu\)s |
| 最大延迟 | ~350 \(\mu\)s | ~120 \(\mu\)s | ~95 \(\mu\)s |
| 内存占用 | ~210 MB | ~65 MB | ~70 MB |
平台 B:Jetson Orin NX(15W 模式)
| 指标 | LibTorch (CPU) | LibTorch (CUDA) | ONNX (CPU) | ONNX + TensorRT |
|---|---|---|---|---|
| 平均延迟 | ~95 \(\mu\)s | ~120 \(\mu\)s | ~65 \(\mu\)s | ~25 \(\mu\)s |
| P99 延迟 | ~180 \(\mu\)s | ~250 \(\mu\)s | ~100 \(\mu\)s | ~45 \(\mu\)s |
| GPU 占用 | 0% | ~5% | 0% | ~3% |
关键发现: - 对于小 MLP,CPU 推理通常比 GPU 推理更快(LibTorch CUDA 的约 120 \(\mu\)s > CPU 的约 95 \(\mu\)s)——因为 CPU-GPU 数据传输开销大于 GPU 计算节省 - ONNX + TensorRT 在 Jetson 上通常是最优选择(约 25 \(\mu\)s),因为 TensorRT 将 MLP 编译为 Jetson GPU 架构的优化 kernel - LibTorch 的 P99 延迟波动较大(最大延迟约 350 \(\mu\)s vs ONNX 的约 120 \(\mu\)s),可能原因是 LibTorch 的内存分配器偶尔触发 GC
Jetson 部署实战的工程细节:
# Jetson 上安装 ONNX Runtime + TensorRT
# 1. 确认 JetPack 版本(决定 CUDA 和 TensorRT 版本)
cat /etc/nv_tegra_release
dpkg -l | grep tensorrt
# 2. 安装 ONNX Runtime(需要匹配 JetPack 版本的预构建包)
pip3 install onnxruntime-gpu # Python 端验证用
# 3. C++ 端:从 ONNX Runtime GitHub Releases 下载 aarch64 预构建包
# 注意选择与 JetPack CUDA 版本匹配的 release
TensorRT Engine 缓存:TensorRT 将 ONNX 模型编译为针对特定 GPU 架构的优化 engine。首次编译需要 30-60 秒,但编译结果可以缓存:
// 启用 TensorRT 的 engine 缓存
OrtTensorRTProviderOptions trt_opts;
trt_opts.trt_engine_cache_enable = 1;
trt_opts.trt_engine_cache_path = "/tmp/trt_cache/";
// 首次 Run() 会触发 TensorRT 编译(30-60s),后续直接从缓存加载
⚠️ 陷阱:TensorRT engine 不跨 GPU 架构兼容
在 Jetson Orin NX(Ampere 架构)上编译的 TensorRT engine 不能在 Jetson Xavier NX(Volta 架构)上运行。每次更换硬件或更新 JetPack 版本,都需要重新编译 engine。正确做法是在部署脚本中检测缓存是否有效,无效则自动重新编译。
LibTorch vs ONNX Runtime:完整工程权衡¶
| 维度 | LibTorch | ONNX Runtime |
|---|---|---|
| 生态绑定 | PyTorch 原生 | 跨框架(PyTorch/TF/JAX) |
| 依赖大小 | ~200 MB (CPU) | ~50-100 MB |
| 推理速度 (小 MLP) | ~50 \(\mu\)s | ~30-80 \(\mu\)s |
| 多后端加速 | CPU + CUDA | CPU + CUDA + TensorRT + OpenVINO + CoreML |
| 新 Op 支持 | 即时(与 PyTorch 同步) | 滞后 PyTorch 数月 |
| C++ API 风格 | 类 PyTorch(熟悉) | 独立 API(需要学习) |
| Jetson 支持 | 官方预构建 | 官方预构建 + TensorRT 后端 |
| 版本兼容性 | TorchScript 有版本要求 | ONNX opset 向后兼容好 |
选型推荐:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 研究项目/快速迭代 | LibTorch | API 与 PyTorch 一致,迭代最快 |
| 生产部署/资源受限 | ONNX Runtime | 更小、更快、更易部署 |
| Jetson GPU 加速 | ONNX + TensorRT | TensorRT 在 NVIDIA GPU 上有极致优化 |
| 多框架团队 | ONNX Runtime | 不绑定 PyTorch |
⚠️ 常见陷阱¶
⚠️ 编程陷阱:ONNX opset 版本不够高 错误做法: 导出时用
opset_version=9,但网络用了 opset 9 不支持的算子。 后果: 导出报错Unsupported: ONNX export of operator xxx。 正确做法: 使用能覆盖模型所用全部算子的最低 opset 版本,导出后用onnx.checker.check_model()验证完整性。⚠️ 概念误区:认为 ONNX Runtime 一定比 LibTorch 快 新手想法: "ONNX Runtime 更轻量,推理肯定更快。" 实际情况: 对于小 MLP(< 100K 参数),两者速度差异在 20% 以内,取决于平台和编译优化。ONNX Runtime 的真正优势在 TensorRT/OpenVINO 加速——但这些对小网络的加速也有限。 最佳实践: 对你的具体模型和目标平台做 benchmark,不凭假设选型。
练习¶
练习 64.4.1: 把 Ch63 训好的策略同时导出为 TorchScript (.pt) 和 ONNX (.onnx)。在 C++ 中分别加载两者,用 10 个随机输入验证输出一致性(互相之间误差 < 1e-5)。
练习 64.4.2: 在同一平台上,对 LibTorch 和 ONNX Runtime 做 1000 次推理的 benchmark,记录平均延迟、P99 延迟、最大延迟。哪个更快?差距多大?
选好了推理引擎,下一步是精读一个完整的开源部署框架——rl_sar,理解工业级部署的全部细节。
64.5 rl_sar 源码精读:观测一致性是 sim-to-real 的关键 ⭐⭐¶
动机:为什么精读 rl_sar?¶
rl_sar (fan-ziqi/rl_sar) 是目前最活跃的开源腿足 RL C++ 部署框架。"sar" 代表 "Simulation And Real"——它同时支持仿真验证和真机部署。截至 2026 年,rl_sar 支持 Unitree A1/Go1/Go2/Go2W/B2/B2W/G1 等多款机器人,构建方式包括 ROS 1、ROS 2 和纯 CMake。
同一作者维护的 robot_lab 仓库提供了 IsaacLab 上的训练端,与 rl_sar 形成完整的 train-deploy 流水线。
rl_sar 的核心价值:它已经踩过了部署中的所有坑——观测归一化、关节顺序映射、动作缩放、安全检查。精读它比从零踩坑高效得多。
观测构建的核心逻辑¶
// rl_sar 的观测构建(核心,简化版)
torch::Tensor RL::ComputeObservation() {
// 每个观测项都乘以 obs_scale —— 与训练时必须完全一致
torch::Tensor obs = torch::cat({
// 1. 基座角速度(从 IMU 读取,转换到身体坐标系)
QuatRotateInverse(base_quat_, base_ang_vel_) * obs_scales_.ang_vel,
// 2. 投影重力(判断身体倾斜程度)
QuatRotateInverse(base_quat_, gravity_vec_),
// 3. 速度命令(vx, vy, wz)
commands_ * commands_scale_,
// 4. 关节位置偏差(相对于默认站立姿态)
(dof_pos_ - default_dof_pos_) * obs_scales_.dof_pos,
// 5. 关节速度
dof_vel_ * obs_scales_.dof_vel,
// 6. 上一步动作
actions_,
}, /*dim=*/1); // 拼接为 (1, obs_dim) tensor
// clip 到合理范围(防止异常传感器值导致网络输出异常)
obs = torch::clamp(obs, -clip_obs_, clip_obs_);
return obs;
}
观测归一化一致性——sim-to-real 最容易出错的地方¶
训练和部署的观测归一化**必须完全一致**。这不是"大致对就行"——任何一个 scale 参数的不一致都会导致策略行为完全错误:
| 观测项 | 训练时处理 | 部署时处理 | 不一致的后果 |
|---|---|---|---|
| 关节位置 | (q - q_default) * 1.0 |
必须相同 | q_default 不一致 → 策略感知到"所有关节都偏了" |
| 关节速度 | dq * 0.05 |
必须相同 | scale 不一致 → 策略对速度感知失调 |
| 角速度 | omega * 0.25 |
必须相同 | scale 差异 → 过度/不足响应旋转 |
| 命令 | [vx*2.0, vy*2.0, wz*0.25] |
必须相同 | 命令 scale 错误 → 机器人速度异常 |
| 重力投影 | 用四元数旋转 | 必须相同 | 旋转方向错误 → 策略以为身体倒了 |
实操清单:导出策略后,把训练配置文件中**每一个** obs_scales 参数抄到 C++ 部署代码中。不要凭记忆,逐项对照。
跨领域类比:观测归一化一致性的要求,就像编译器的 ABI(Application Binary Interface)兼容性——训练端和部署端必须就"每个字段的含义、顺序、缩放"达成完全一致的协议。如果训练端认为观测向量的第 4-6 位是
[vx*2.0, vy*2.0, wz*0.25]但部署端写成了[vx, vy, wz],就像 C++ 编译的 struct padding 不一致一样——表面上能运行,但数据在语义上全乱了。与 ABI 不同的是,obs scale 不一致**不会触发任何编译或运行时错误**,它只会让策略行为诡异地"差一点",这才是最难调试的。
关节顺序映射——另一个常见的"隐形杀手"¶
不同系统的关节编号顺序可能不同:
| 系统 | 关节顺序(示例) |
|---|---|
| IsaacGym/IsaacLab | FL_hip, FL_thigh, FL_calf, FR_hip, FR_thigh, FR_calf, RL_..., RR_... |
| Unitree SDK | FR_0, FR_1, FR_2, FL_0, FL_1, FL_2, RR_..., RL_... |
| MuJoCo | 取决于 URDF 中的定义顺序 |
如果不做关节顺序映射,策略给前左腿的命令会发到前右腿——机器人的行为会像"左右脑对调"一样混乱。rl_sar 通过 robot_config.yaml 中的 joint_reorder 字段解决这个问题。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:训练和部署的关节顺序不一致 错误做法: 假设 IsaacGym 和 Unitree SDK 的关节编号顺序一样。 后果: 策略给前左腿的命令发到了前右腿,机器人动作完全混乱,立刻摔倒。 根本原因: 不同仿真器和 SDK 的关节编号约定不同,没有统一标准。 正确做法: 明确记录训练时的关节顺序,在部署代码中维护映射表。
⚠️ 编程陷阱:忘记减去
default_dof_pos错误做法: 把关节绝对位置直接作为观测传入网络。 后果: 训练时观测是"关节位置相对于默认站立姿态的偏差"(范围约 \(\pm 0.5\) rad),部署时传入绝对位置(范围约 \(0 \sim 3\) rad)。数值范围完全不同,网络输出毫无意义。 正确做法:obs_joint_pos = (q_measured - q_default) * obs_scale。⚠️ 思维陷阱:认为 rl_sar 开箱即用 新手想法: "fork rl_sar,换上我的 .pt 文件,改一下机器人名字就完了。" 实际情况: 每个训练配置的观测项数量、观测顺序、归一化参数、动作缩放、关节顺序都可能不同。rl_sar 的默认配置是为特定训练配置定制的——换了你的训练配置,必须逐项核对。 正确做法: 精读 rl_sar 的
ComputeObservation(),与你的训练配置**逐项对比**。
练习¶
练习 64.5.1: fork rl_sar,对照你在 Ch63 训练时的 legged_gym/IsaacLab 配置文件,逐项修改 rl_sar 的配置。列出你修改的每一项及其原因。
练习 64.5.2: 写一个 Python 验证脚本:用训练环境生成一组 (obs, action) 数据对,然后在 C++ rl_sar 中用相同的 obs 推理,比较 action 输出。误差应 < 1e-5。
精读了部署逻辑后,下一节深入实时推理的工程细节——这些看似细小的点决定了部署是否真正可靠。
64.6 实时推理的工程细节 ⭐⭐⭐¶
动机:推理正确了 \(\neq\) 部署成功¶
即使推理结果与 Python 完全一致,部署仍然可能失败——因为实时系统对**延迟确定性**有极高要求(Ch61 完整讲述)。以下是实时推理中必须解决的工程问题。
细节 1:内存预分配(零运行时分配)¶
实时循环中的**一切内存都必须在启动时预分配**:
// ❌ 错误:每次循环都创建新对象
void update() {
auto tensor = torch::zeros({1, 48}); // 堆分配!
std::vector<float> result(12); // 可能堆分配!
auto output = module_.forward({tensor}); // 内部可能分配!
}
// ✅ 正确:启动时分配,运行时只填充
class RLController {
torch::Tensor input_tensor_; // 构造时分配
Eigen::VectorXd action_; // 构造时分配
std::vector<float> obs_buf_; // 构造时分配
void init() {
input_tensor_ = torch::zeros({1, 48}, torch::kFloat32);
action_.resize(12);
obs_buf_.resize(48, 0.0f);
}
void update() {
// 只填充数据,不创建新对象
std::memcpy(input_tensor_.data_ptr<float>(),
obs_buf_.data(), 48 * sizeof(float));
// ... 推理 ...
}
};
细节 2:避免 CPU-GPU 数据往返¶
如果整个控制循环在 CPU 上(ros2_control 的 update() 在 CPU 线程),就**不要用 GPU 推理小模型**:
CPU 控制循环:
读传感器(CPU) → 构建 obs(CPU) → 拷贝到 GPU → GPU 推理 → 拷贝回 CPU → 发命令(CPU)
↑________50 us________↑ ↑_______50 us_______↑
PCIe 往返开销比推理本身还大!
对小 MLP,CPU 推理 ~50 \(\mu\)s < GPU 推理 + 数据往返 ~200 \(\mu\)s。
细节 3:线程亲和(Thread Affinity)¶
把 RL 推理线程绑定到 isolcpus 隔离的 CPU 核心(Ch61 详述):
// 设置当前线程亲和到 CPU 核心 3(已通过 grub isolcpus=3 隔离)
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(3, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
隔离核心不被其他进程使用,确保 RL 推理不会因缓存被刷出(cache thrashing)而延迟波动。
细节 4:消除首次推理的 JIT 编译延迟¶
LibTorch 第一次前向传播会触发 JIT 编译——将 TorchScript IR 编译为当前平台的优化机器码。延迟可达 100-500 ms。
// 在 on_configure() 或 on_activate() 阶段做 warmup
// 不是在 update() 中!
void warmupPolicy() {
torch::NoGradGuard no_grad;
auto dummy = torch::zeros({1, obs_dim_}, torch::kFloat32);
for (int i = 0; i < 20; ++i) {
module_.forward({dummy});
}
// 此后 update() 中的推理延迟将稳定在 50-100 us
}
某些 cuDNN 算子即使 warmup 也需要第一次实际调用来编译。确保在**允许较大延迟的生命周期阶段**(如 on_activate)完成所有 warmup。
细节 5:延迟监控与统计¶
生产部署中,持续监控推理延迟:
struct LatencyStats {
double sum = 0, sum_sq = 0;
double max_val = 0;
int count = 0;
std::vector<double> percentile_buf;
void update(double value_us) {
sum += value_us;
sum_sq += value_us * value_us;
max_val = std::max(max_val, value_us);
count++;
percentile_buf.push_back(value_us);
}
double mean() const { return sum / count; }
double p99() {
std::sort(percentile_buf.begin(), percentile_buf.end());
return percentile_buf[static_cast<int>(0.99 * count)];
}
};
完整延迟预算分析¶
一个 1 kHz 控制循环(1000 \(\mu\)s 周期)的延迟预算:
| 步骤 | 典型延迟 | 占比 |
|---|---|---|
| 读取传感器 (state_interfaces) | 10-30 \(\mu\)s | 1-3% |
| 构建观测向量 | 5-10 \(\mu\)s | 0.5-1% |
| RL 推理 | 50-100 \(\mu\)s | 5-10% |
| 动作后处理 + 安全检查 | 5-10 \(\mu\)s | 0.5-1% |
| 写入命令 (command_interfaces) | 10-30 \(\mu\)s | 1-3% |
| 总计 | 80-180 \(\mu\)s | 8-18% |
| 余量 | 820-920 \(\mu\)s | 82-92% |
余量充足(> 80%)——这是 RL 部署相比 MPC 部署的巨大优势。MPC 求解一次可能需要 10-50 ms(Ch55),需要双线程 Triple Buffer 架构;而 RL 推理在单线程内就能轻松完成。
延迟监控的生产级实现 ⭐⭐¶
在生产系统中,仅知道"平均延迟正常"是不够的——必须持续监控 P99 延迟和最大延迟,及时发现偶发的延迟尖刺。以下是一个轻量级的延迟统计类:
class LatencyMonitor {
public:
void recordSample(double latency_us) {
samples_[idx_ % kWindowSize] = latency_us;
idx_++;
if (idx_ % kReportInterval == 0) report();
}
private:
void report() {
std::vector<double> sorted(samples_.begin(), samples_.end());
std::sort(sorted.begin(), sorted.end());
double avg = std::accumulate(sorted.begin(), sorted.end(), 0.0) / kWindowSize;
double p50 = sorted[kWindowSize / 2];
double p99 = sorted[(int)(kWindowSize * 0.99)];
double max_val = sorted.back();
RCLCPP_INFO_THROTTLE(logger_, clock_, 5000,
"Inference latency [us]: avg=%.1f p50=%.1f p99=%.1f max=%.1f",
avg, p50, p99, max_val);
// 安全警报:P99 超过 500us 时告警
if (p99 > 500.0) {
RCLCPP_WARN(logger_, "P99 latency %.1f us exceeds 500 us threshold!", p99);
}
}
static constexpr int kWindowSize = 1000;
static constexpr int kReportInterval = 1000;
std::array<double, kWindowSize> samples_{};
size_t idx_ = 0;
};
这个监控类应在 update() 函数中包裹推理调用,在开发和部署阶段始终开启。当 P99 延迟超过阈值时自动告警——这比事后分析 rosbag 高效得多。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:在实时循环中打印日志 错误做法:
std::cout << "action: " << action.transpose() << std::endl;后果:cout+endl涉及 I/O 系统调用和缓冲区 flush,可能阻塞数十毫秒(Ch61 详述)。 正确做法: 使用限频警告(RCLCPP_WARN_THROTTLE)或无锁队列将日志发送到非实时线程。⚠️ 编程陷阱:推理前不关闭 autograd 错误做法: 不使用
torch::NoGradGuard。 后果: LibTorch 会构建计算图(为 backward 准备),即使你永远不会调 backward()。增加 ~20-30% 推理时间 + 额外内存分配。 正确做法: 所有推理代码包在torch::NoGradGuard作用域内。
练习¶
练习 64.6.1: 在你的 RL 推理代码中添加 LatencyStats 监控。运行 10000 次推理,绘制延迟直方图。P50、P99、P999 分别是多少?是否有异常尖峰?分析原因。
练习 64.6.2: 对比有/无 torch::NoGradGuard 的推理延迟差异(百分比)。用 1000 次推理的均值对比。
推理正确且延迟达标后,还需要最后一层保护:如果 RL 模型输出了异常值怎么办?
64.7 安全降级:RL 部署的最后一道防线 ⭐⭐⭐¶
动机:RL 策略不是万无一失的¶
RL 策略是神经网络——它没有形式化的安全保证。在训练分布外的输入(out-of-distribution, OOD)上,输出完全不可预测。在真机上,异常输出的后果:
| 异常类型 | 后果 |
|---|---|
| NaN 输出 | PD 控制器收到 NaN → 扭矩变 NaN → 电机可能释放最大扭矩 |
| 超出关节限位 | 电机堵转 → 齿轮组受极大应力 → 永久损坏 |
| 动作剧烈跳变 | 电机在 1 ms 内尝试转动大角度 → 极大电流冲击 |
| 持续异常输出 | 机器人缓慢偏离稳定姿态 → 几秒后摔倒 |
安全降级不是可选项——它是生产级部署的必备组件。
如果不做安全降级会怎样?2022 年某开源项目的真实案例:RL 策略在仿真中表现优秀,但部署到真机后,IMU 因电磁干扰偶发返回 NaN。NaN 传入网络后输出也是 NaN,PD 控制器收到 NaN 目标位置后将其解释为极大值,导致所有关节瞬间以最大扭矩运动——机器人像弹射一样跳起后摔碎。全过程不到 5 ms,人类来不及按急停键。一个 NaN 检查就能避免的事故,造成了数千元的硬件损失。
多层安全检查¶
class SafetyGuard {
public:
struct CheckResult {
bool safe;
std::string reason;
};
CheckResult check(const Eigen::VectorXd& action,
const Eigen::VectorXd& current_pos,
const Eigen::VectorXd& last_action) {
// Layer 1: NaN 检查 —— 最严重,立即降级
if (action.hasNaN()) {
return {false, "NaN detected"};
}
// Layer 2: 关节限位检查
for (int i = 0; i < action.size(); ++i) {
if (action[i] < lower_[i] || action[i] > upper_[i]) {
return {false, "Joint " + std::to_string(i) + " out of limits"};
}
}
// Layer 3: 动作变化率检查(防止跳变)
double max_delta = (action - last_action).cwiseAbs().maxCoeff();
if (max_delta > max_delta_per_step_) {
return {false, "Action rate " + std::to_string(max_delta) + " too high"};
}
// Layer 4: 动作幅值检查
if (action.norm() > max_action_norm_) {
return {false, "Action norm too large"};
}
return {true, ""};
}
private:
Eigen::VectorXd lower_, upper_;
double max_delta_per_step_ = 0.5; // rad/step
double max_action_norm_ = 10.0;
};
降级策略的层次设计¶
正常运行
│
│ 检测到异常
▼
┌─────────────────────────┐
│ Level 1: Hold Last │ ← 使用最近一次安全动作
│ 触发: 偶尔一次异常 │ (如推理偶尔超时)
│ 恢复: 下次正常即恢复 │
└──────────┬──────────────┘
│ 连续 N 次异常 (N ~ 10-50)
▼
┌─────────────────────────┐
│ Level 2: Stand │ ← 缓慢回到站立姿态
│ 触发: 策略持续异常 │ q_cmd += 0.001 * (q_default - q_cmd)
│ 恢复: 异常消失后缓慢 │ 插值过渡, 不是突变
└──────────┬──────────────┘
│ 持续异常 > 5 秒
▼
┌─────────────────────────┐
│ Level 3: Damping │ ← 纯阻尼模式
│ 触发: 策略完全失效 │ tau = -Kd * dq (让关节慢慢停)
│ 恢复: 需要人工重启 │
└──────────┬──────────────┘
│ 硬件级保护 (Ch62)
▼
┌─────────────────────────┐
│ Level 4: E-Stop │ ← 电机驱动器看门狗
│ 触发: 软件完全失控 │ 50ms 无有效命令 → 自动切断
│ 恢复: 手动复位 │
└─────────────────────────┘
⚠️ 常见陷阱¶
⚠️ 编程陷阱:安全检查后直接 clamp 而不是降级 错误做法:
action = action.cwiseMax(lower).cwiseMin(upper);后果: clamp 后的动作可能不是 RL 策略"想要"的——比如策略想向前走但某个关节被 clamp 了,结果变成不协调的姿态。clamp 不保证结果行为的合理性。 正确做法: 检测到超限就切换到**已知安全的降级策略**(如站立),而不是"修正"异常动作。⚠️ 思维陷阱:认为安全降级只是"加个 NaN 检查" 新手想法: "加一行
if (isnan) use_last_action就够了。" 实际情况: NaN 只是最极端的异常。更常见的是"数值在合理范围内但行为不合理"——比如策略持续输出让机器人缓慢倾斜的动作,5 秒后才摔倒。这需要**多层检查**(限位 + 变化率 + 统计分布)和**分级降级**(不是非黑即白)。⚠️ 概念误区:软件安全检查做好了就不需要硬件保护 新手想法: "代码里检查了所有异常情况,硬件保护是多余的。" 实际情况: 如果控制程序本身崩溃(段错误、assert 失败),所有软件保护都失效。电机驱动器的看门狗(Ch62)是独立于控制软件的最后防线——如果驱动器在 50 ms 内没收到有效命令,自动进入阻尼模式。 正确做法: 软件保护(本节) + 硬件看门狗(Ch62),两层独立。
练习¶
练习 64.7.1: 实现上述 SafetyGuard 类,集成到你的 ros2_control Controller 中。故意让 RL 模型输出 NaN(在 Python 中把某个权重设为 NaN 再导出),验证安全降级是否正确触发。
练习 64.7.2: 设计一个"动作分布偏移检测器":从训练 rollout 数据中统计策略输出动作的均值 \(\mu\) 和标准差 \(\sigma\)(每个关节独立)。部署时如果动作连续 10 步超出 \(\mu \pm 3\sigma\),触发 Level 2 降级。实现并测试。
64.8 从训练到部署的完整流水线 ⭐⭐¶
前面七节覆盖了部署的每个环节。这里把完整流水线串起来,并讨论 Jetson 平台部署和 sim-to-real 调优。
完整 8 步 Pipeline¶
┌──────────────────────────────────────────────┐
│ 1. 训练 (Ch63): IsaacLab + rsl_rl → ckpt.pth │
├──────────────────────────────────────────────┤
│ 2. 导出 (64.2): torch.jit.trace → policy.pt │
│ 验证: Python 输出一致性 < 1e-6 │
├──────────────────────────────────────────────┤
│ 3. 配置对齐 (64.5): 逐项核对训练/部署参数 │
│ obs_scales, joint_order, action_scale, │
│ default_dof_pos, clip_obs, clip_actions │
├──────────────────────────────────────────────┤
│ 4. C++ 仿真验证 (64.3): rl_sar + Gazebo │
│ 策略行为与 Python play.py 一致 │
├──────────────────────────────────────────────┤
│ 5. 实时性验证 (64.6): cyclictest + benchmark │
│ P99 推理延迟 < 500 us │
├──────────────────────────────────────────────┤
│ 6. 安全验证 (64.7): 注入 NaN/超限/跳变 │
│ 安全降级正确触发 │
├──────────────────────────────────────────────┤
│ 7. 真机部署: ros2_control + Unitree SDK │
│ 先低频(100 Hz) → 逐步提高到目标频率 │
├──────────────────────────────────────────────┤
│ 8. sim-to-real 调优(数天到数周) │
│ 观察真机行为 → 调参 → 必要时重训 │
└──────────────────────────────────────────────┘
Jetson 平台部署要点¶
许多腿足机器人使用 NVIDIA Jetson Orin 作为机载计算平台:
| 维度 | x86 台式机 | Jetson Orin |
|---|---|---|
| CPU 架构 | x86_64 | ARM64 (aarch64) |
| LibTorch | 官网下载 x86 版 | 需要 ARM64 版或交叉编译 |
| CUDA | 独立安装 | JetPack 预装,版本与 JetPack 绑定 |
| 功耗 | 不限 | 15-40W,需功耗管理 |
| 推荐推理引擎 | LibTorch(方便) | ONNX + TensorRT(针对 Jetson GPU 优化) |
在 Jetson 上,ONNX + TensorRT 组合通常最优:TensorRT 会将 ONNX 模型编译为 Jetson GPU 架构的高度优化 engine。
Jetson 部署的完整工程清单 ⭐⭐¶
Jetson 功耗模式选择:Jetson Orin 支持多种功耗模式,不同模式下 CPU/GPU 频率不同,直接影响推理延迟:
| 功耗模式 | CPU 频率 | GPU 频率 | RL 推理延迟 | 适用场景 |
|---|---|---|---|---|
| MAXN (50W) | 2.2 GHz (全核) | 1.3 GHz | ~60 \(\mu\)s | 实验室测试 |
| 30W | 1.5 GHz (6核) | 930 MHz | ~90 \(\mu\)s | 正常部署 |
| 15W | 1.0 GHz (4核) | 612 MHz | ~150 \(\mu\)s | 长续航任务 |
# 设置 Jetson 功耗模式
sudo nvpmodel -m 0 # MAXN (50W)
sudo nvpmodel -m 1 # 30W
sudo jetson_clocks # 锁定最高频率(避免动态降频导致延迟抖动)
交叉编译 vs 本地编译:
| 方式 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| Jetson 本地编译 | 简单,无兼容性问题 | 编译慢(ARM 性能低) | 小项目、初次尝试 |
| Docker 交叉编译 | 快(x86 编译) | 需要配置 sysroot | 持续集成(CI) |
| NVIDIA SDK Manager | 官方工具链 | 仅支持 Ubuntu | 正式产品 |
TensorRT engine 缓存策略:TensorRT 首次加载 ONNX 时会做模型优化和 kernel 选择,耗时 30-60 秒。生产部署中必须缓存优化后的 engine:
// 检查 TensorRT engine 缓存是否有效
bool engineCacheValid(const std::string& cache_path,
const std::string& onnx_path) {
// 1. 缓存文件是否存在
if (!std::filesystem::exists(cache_path)) return false;
// 2. ONNX 文件是否比缓存新(模型更新后需重新编译)
auto onnx_time = std::filesystem::last_write_time(onnx_path);
auto cache_time = std::filesystem::last_write_time(cache_path);
return cache_time > onnx_time;
}
⚠️ 陷阱:Jetson 上 GPU 内存与系统内存共享
与台式机(独立显存)不同,Jetson 的 GPU 和 CPU 共享 8-16 GB 内存。如果同时运行 elevation_mapping_cupy(GPU 高程图)+ LibTorch(RL 推理)+ ROS2 节点(系统开销),很容易耗尽内存。正确做法:用
tegrastats实时监控内存,为每个 GPU 用户设置内存上限。
sim-to-real 调优¶
第 8 步是最耗时的环节。即使仿真验证完美,真机还需要反复调试:
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 机器人抖动 | action_scale 太大,或 PD 的 Kd 太小 | 降低 action_scale 或增大 Kd |
| 走路太慢 | commands_scale 不正确 | 检查训练配置中的命令缩放 |
| 容易摔倒 | DR 范围不够大 | 回 Ch63 加大 DR(摩擦、延迟) |
| 脚步不协调 | 关节顺序映射错误 | 检查 joint_reorder 配置 |
| 电机过热 | default_dof_pos 不正确 | 偏差导致 PD 持续输出大扭矩 |
⚠️ 常见陷阱¶
⚠️ 编程陷阱:Jetson 上 LibTorch 版本与训练端不匹配 错误做法: 训练用 PyTorch 2.x.0 导出 TorchScript,Jetson 的 LibTorch 是旧版 2.y.0(主版本不一致)。 后果: TorchScript 有版本兼容性要求,可能加载失败或行为不一致。 正确做法: 确保版本一致(至少主版本号一致),或使用 ONNX 格式(版本兼容性更好)。
⚠️ 思维陷阱:跳过仿真验证直接上真机 新手想法: "Python 里验证过了,直接上真机。" 实际情况: Python→C++ 转换有太多细节可能出错。先在 Gazebo 的 C++ 仿真中验证策略行为与 Python 一致——这一步可以发现 80% 的部署 bug,成本远低于真机调试。
练习¶
练习 64.8.1: 按照 8 步 Pipeline,完成从 Ch63 训好的 Go2 策略到 Gazebo 仿真部署的全流程。记录每一步花费的时间和遇到的问题。
练习 64.8.2: 设计自动化 sim-to-sim 验证:在 Python(IsaacLab)和 C++(rl_sar + Gazebo)中用相同初始状态和命令序列跑同一策略 1000 步,比较关节轨迹的均方误差。
常见故障与排查¶
| 症状 | 可能原因 | 排查步骤 | 相关小节 |
|---|---|---|---|
ONNX/TorchScript 模型加载失败,报 Invalid model 或 Unsupported op |
导出时 opset 版本过低,或 PyTorch 与 LibTorch 版本不匹配 | 1. 使用能覆盖模型算子的最低 opset 版本导出,用 onnx.checker.check_model() 验证 2. 确认 LibTorch/ORT 版本与训练端 PyTorch 主版本一致 |
64.2, 64.4 |
| 推理延迟远超预期(>1 ms),或首次推理特别慢 | 未做 warmup 导致 JIT 首次编译延迟,或未关闭 autograd,或在实时循环中创建新 tensor | 1. 检查是否在 on_configure() 阶段做了 20 次 warmup 2. 确认使用了 torch::NoGradGuard 3. 用 LatencyStats 统计 P99 延迟,区分首次 vs 稳态 |
64.6 |
| 部署后策略行为与 Python 不一致(机器人抖动、不走路、走偏) | 观测归一化参数不一致(obs_scales/default_dof_pos/关节顺序不匹配) | 1. 逐项对比训练配置中的 obs_scales 和部署代码 2. 打印部署端和训练端的 obs 向量,逐维对比 3. 检查关节顺序映射表 |
64.5 |
| RL 推理输出 NaN 或极大值,触发安全降级 | 模型文件损坏、输入包含 NaN(IMU 故障)、或导出时激活函数不匹配(ELU vs ReLU) | 1. 在 Python 中重新加载 .pt/.onnx 验证 2. 在推理前打印输入 obs 检查 NaN 3. 确认网络结构中激活函数与训练一致 | 64.2, 64.7 |
| Action clipping 后机器人姿态不协调(能站但走路别扭) | action_scale 或 clip_actions 参数与训练不一致,导致动作范围被错误截断 | 1. 对比训练配置中的 clip_actions 和部署代码 2. 在 Python play.py 中打印 action 的实际范围,确认部署端 clip 范围覆盖它 3. 暂时放大 clip 范围观察行为变化 |
64.5, 64.7 |
本章小结¶
| 知识点 | 核心内容 | 难度 | 关键技术 |
|---|---|---|---|
| 64.1 部署基本问题 | Python vs C++、GIL/GC、两大方案 | ⭐ | LibTorch / ONNX Runtime |
| 64.2 TorchScript | trace vs script、IR 优化 pass、导出脚本 | ⭐⭐ | torch.jit.trace + freeze |
| 64.3 LibTorch | C++ 加载推理、Eigen 转换、预分配 | ⭐⭐ | torch::jit::load + memcpy |
| 64.4 ONNX Runtime | 导出、protobuf 结构、C++ 推理 | ⭐⭐ | torch.onnx.export + Ort::Session |
| 64.5 rl_sar 精读 | 观测归一化、关节映射、配置一致性 | ⭐⭐ | 配置对齐是 sim-to-real 关键 |
| 64.6 实时工程 | 预分配、warmup、线程亲和、延迟监控 | ⭐⭐⭐ | P99 < 500 \(\mu\)s |
| 64.7 安全降级 | NaN/限位/跳变/统计、4 级降级 | ⭐⭐⭐ | Hold → Stand → Damp → E-Stop |
| 64.8 完整流水线 | 8 步 pipeline、Jetson、sim-to-real 调优 | ⭐⭐ | 调优是最耗时环节 |
累积项目:本章新增模块¶
四足站立控制器进度更新:
Ch47: 加载 URDF + Pinocchio 建模 [完成]
Ch49: 正/逆运动学 + 接触 Jacobian [完成]
Ch50: QP 求解器集成 [完成]
Ch52: 摩擦锥约束 [完成]
Ch53: WBC 实现 (加权 QP) [完成]
Ch55: OCS2 MPC 集成 [完成]
Ch61: 实时部署框架 [完成]
Ch62: 硬件接口 + Unitree SDK [完成]
Ch63: RL 策略训练 [完成]
Ch64: RL 策略 C++ 部署 <-- 本章新增
|-- TorchScript 导出脚本 (export_policy.py)
|-- LibTorch C++ 推理类 (RLPolicy)
|-- ONNX Runtime C++ 推理类 (ONNXPolicy)
|-- ros2_control Controller 集成 (RLController)
|-- SafetyGuard 安全降级模块
|-- 推理延迟 benchmark 工具 (LatencyStats)
|-- rl_sar 配置对齐检查
本章新增的具体任务:
1. 将 Ch63 训好的 Go2 策略导出为 TorchScript .pt 和 ONNX .onnx
2. 编写 C++ 推理类,验证输出与 Python 一致(误差 < 1e-6)
3. 集成到 ros2_control Controller 的 update() 函数
4. 实现 SafetyGuard 安全降级模块(4 层检查 + 4 级降级)
5. 在 Gazebo 中验证完整的 train→export→deploy pipeline
6. 做推理延迟 benchmark,确认 P99 < 500 \(\mu\)s
延伸阅读¶
必读 ⭐⭐¶
| 资料 | 类型 | 说明 |
|---|---|---|
LibTorch 官方教程 (pytorch.org/tutorials/advanced/cpp_frontend.html) |
文档 | C++ 前端入门,含模型加载和推理 |
ONNX Runtime 官方文档 (onnxruntime.ai/docs/) |
文档 | C++ API 参考,版本 1.25.x |
rl_sar (github.com/fan-ziqi/rl_sar) |
代码 | 腿足 RL C++ 部署的最佳开源参考 |
robot_lab (github.com/fan-ziqi/robot_lab) |
代码 | 配套 IsaacLab 训练端 |
核心论文 ⭐⭐⭐¶
| 资料 | 类型 | 说明 |
|---|---|---|
| Hwangbo J. et al. (2019) "Learning agile and dynamic motor skills for legged robots", Science Robotics 4(26), eaau5872 | 论文 | 首次 RL 真机部署(ANYmal),含 actuator network |
| Rudin N. et al. (2022) "Learning to Walk in Minutes", CoRL | 论文 | legged_gym 框架,含部署流程 |
深入技术 ⭐⭐⭐⭐¶
| 资料 | 类型 | 说明 |
|---|---|---|
PyTorch JIT Overview (github.com/pytorch/pytorch/blob/main/torch/csrc/jit/OVERVIEW.md) |
文档 | TorchScript IR 和优化 pass 的官方详解 |
| LibTorch Stable ABI (PyTorch 2.10+) | 文档 | ABI 稳定性保证,简化版本管理 |
| ONNX Runtime 1.25 Release Notes | 文档 | CUDA Plugin EP,第三方加速后端插件化 |
与其他章节衔接¶
向前承接: - Ch61(实时 C++)→ 64.6 RL 推理的实时工程要求 - Ch62(硬件栈)→ 64.5 关节顺序映射、看门狗保护 - Ch63(RL 训练)→ 64.2 模型导出、64.5 归一化一致性
向后指向: - Ch64 → Ch65 RL+MPC 混合(RL 部分的部署正是本章内容) - Ch64 → Ch69 Mini-Legged 综合实战(真机部署完整验证) - Ch64 安全降级 → Ch70 研究方向(安全 RL、运行时验证)
分别掌握了 MPC(Ch55)和 RL(Ch63-64)之后,自然的问题是:能否融合两者的优势?Ch65 正是腿足控制最活跃的研究前沿——OCS2 MPC-Net、Cafe-MPC VWBC、RAMBO、残差 RL 四条 RL+MPC 混合路线都要讲透。Ch66 则衔接到感知的工程侧——讲清楚 Grid Map、Elevation Mapping 等数据结构和工程实现,为 Ch67 的 Perceptive MPC 算法做好数据层准备。