第 24 章 大规模训练、性能优化与 NaN 排查
前置自测
📋 答不出 ≥ 2 题 → 先回对应章节复习
- [Ch03 MuJoCo Warp] CUDA Graph 的 capture 机制对数组地址有什么要求?如果 domain randomization 扩展了 per-world 字段,需要做什么操作?
- [Ch07 训练管线] PPO 的
num_steps_per_env和num_mini_batches分别控制 rollout 的什么维度?它们对 GPU 显存有什么影响? - [Ch22 DIY] 一个自定义环境的 smoke test 通过但 500 iterations 训练后出现 NaN。按 Ch22 §22.7 的排查优先级,你应该先检查什么?
- [系统]
CUDA_VISIBLE_DEVICES=2,3后,PyTorch 的cuda:0对应物理 GPU 几号?这个映射为什么容易出错? - [分布式] 数据并行(Data Parallel)和环境并行(Environment Parallel)在多 GPU 场景下的区别是什么?哪种更适合 GPU 仿真 RL?
本章目标
学完本章后,你应该能够:
- 配置双框架的多 GPU 训练——mjlab 的
--gpu-ids+ torchrunx 和 Isaac Lab 的 torchrun - 诊断训练中 NaN 的根因——使用
--enable-nan-guard、viz-nan和系统化的排查优先级表 - 量化环境的性能瓶颈——区分 physics cost、sensor cost、manager cost,用 profiler 定位热点
- 理解 AGILE 的四阶段工业级 workflow——Prepare → Train → Evaluate → Deploy 及其算法工具箱
- 设计公平的大规模实验——控制总样本量、管理 checkpoint/resume、组织可复查的运行包
24.1 为什么需要大规模训练 ⭐
这一节解决什么问题:从"能训练"到"高效训练"的工程跨越需要关注什么?
动机:单 GPU 的天花板
Ch13-Ch23 的所有实战都在单 GPU 上完成——4096 个并行环境、10000-20000 iterations、通常 1-4 小时 wall-clock。对于四足速度跟踪这类任务,单 GPU 足够。但当你面对以下场景时,单 GPU 成为瓶颈:
- 高 DOF 机器人:29-DOF 人形的状态空间比 12-DOF 四足大 10 倍,收敛需要更多数据
- 复杂任务:loco-manipulation 需要同时学习移动和操作,探索空间指数增长
- 大规模 DR:宽范围的 domain randomization 意味着策略需要在更大的参数空间上泛化
- 超参搜索:找到最优的 reward 权重组合需要并行跑多个实验
- 多 seed 评估:发论文需要至少 3-5 个 seed 的统计显著性
单 GPU 的吞吐天花板:在单块 A100 上,4096 个并行环境的典型吞吐量是 150k-250k env-steps/s(12-DOF 四足)或 80k-120k env-steps/s(29-DOF 人形)。超过 4096 envs 后吞吐提升趋缓(GPU 计算资源饱和),增加 envs 主要增加显存占用而非速度。
何时需要多 GPU? 不是"越多越好",而是有明确的决策标准:
| 信号 | 建议 |
|---|---|
| 单 GPU 4096 envs 吞吐已饱和(增加 envs 不增速) | 多 GPU 环境并行 |
| 需要 >10k envs 做大 DR 范围覆盖 | 多 GPU 环境并行 |
| 需要同时跑 5+ seed | 多 GPU 各跑一个 seed |
| 需要搜索 10+ 超参组合 | 多 GPU + WandB Sweep |
| 单 GPU 显存不足(>40 GB VRAM) | 多 GPU 或减小模型 |
| 以上都不是 | 不需要多 GPU |
本质洞察:多 GPU 训练不是"加速按钮"——它是一个工程复杂度的放大器。单 GPU 上的 bug 在多 GPU 上更难排查(因为需要区分是哪个 rank 的问题)、checkpoint 更难管理(多个 rank 写同一个文件)、实验结果更难比较(样本量核算更复杂)。只有在单 GPU 确实不够用时才引入多 GPU。
跨领域类比:多 GPU 训练就像从单厨师厨房升级到多厨师厨房。如果只有一道菜要做,多一个厨师反而碍手碍脚(沟通成本)。但如果要同时准备 5 道不同的菜(多 seed),或者一道菜需要两个人同时操作不同部分(环境并行),多厨师就有价值了。关键是明确"为什么需要多人",而不是"人多力量大"。
⚠️ 常见陷阱
⚠️ 思维陷阱:认为"多 GPU 训练更好" - 多 GPU 的总样本量更大(更多 envs),但每个 update 的 PPO batch 组成可能不同(不同 GPU 上的 env 状态分布不同) - 如果不正确核算样本量,可能得出"多 GPU 收敛更快"的错误结论——实际上只是"多 GPU 看了更多数据" - 正确做法:比较时使用相同的总样本量(total env steps),而非相同的 iteration 数
⚠️ 编程陷阱:CUDA_VISIBLE_DEVICES 映射混淆
- 设置 CUDA_VISIBLE_DEVICES=2,3 后,PyTorch 的 cuda:0 对应物理 GPU 2,cuda:1 对应物理 GPU 3
- 如果在代码中硬编码 --gpu-ids "[2, 3]",程序看不到逻辑 id 2 和 3——因为可见设备只有 0 和 1
- 正确做法:传 --gpu-ids "[0, 1]",让框架自动映射到 CUDA_VISIBLE_DEVICES 指定的物理设备
练习
- [计算题] 单 GPU A100 上,4096 envs 的四足任务吞吐量约 200k steps/s。计算训练 10000 iterations(每 iteration 采 24 steps/env)需要多少 wall-clock 时间。
- [设计题] 你需要为一篇论文跑 5 个 seed × 3 个 reward 权重配置 = 15 个实验。单个实验需要 2 小时。设计一个最优的 GPU 分配方案(假设有 4 块 GPU)。
- [思考题] 为什么过了 4096 envs 后继续增加 envs 不再线性提升吞吐?从 GPU 计算与访存的比例角度解释。
有了"何时需要大规模训练"的判断标准,下一步是学习具体的多 GPU 配置方法——双框架各有不同的实现路径。
24.2 多 GPU 训练:双框架配置详解 ⭐⭐⭐
这一节解决什么问题:在 mjlab 和 Isaac Lab 中分别如何配置多 GPU 训练?数据并行和环境并行有什么区别?
数据并行 vs 环境并行 ⭐⭐
GPU 仿真 RL 的多 GPU 有两种范式:
| 维度 | 数据并行 (Data Parallel) | 环境并行 (Environment Parallel) |
|---|---|---|
| 核心思想 | 每个 GPU 跑相同数量的 envs,梯度在 GPU 间同步 | 每个 GPU 跑独立的 envs,样本量翻倍 |
| 实现 | PyTorch DDP (all-reduce gradients) | 每个 GPU 独立 rollout,汇总后 PPO update |
| 总 envs 数 | 不变(4096 = 4 GPU × 1024) | 翻倍(4096 × N_GPU) |
| PPO batch 大小 | 不变 | 翻倍 |
| 通信开销 | 每次 update 一次 all-reduce | 每次 update 一次 gather + all-reduce |
| 适用场景 | 显存不够放 4096 envs | 需要更多 envs 做 DR 覆盖 |
| 推荐度 | ⭐⭐(除非显存不足否则不优先) | ⭐⭐⭐(GPU 仿真的主要多 GPU 模式) |
关键差异:在 GPU 仿真 RL 中,env step 通常是计算瓶颈(占总时间 60-80%),而非 PPO update。因此环境并行(更多 envs → 更多数据 → 更快收敛)比数据并行(相同数据量 → 梯度同步 → 不增加收敛速度)更有效。
双重解读:多 GPU 训练可以从两个完全不同的角度理解。从统计学的角度,更多 GPU = 更多并行 envs = 每次 PPO update 的 batch size 更大。大 batch 降低了梯度估计的方差(更多样本 → 更准确的梯度),但也可能降低泛化能力(过度拟合于大 batch 的统计特性)。这就是为什么 AGILE 的默认 4096 envs 是一个经过验证的平衡点——增加到 8192+ 的收益递减。从系统工程的角度,更多 GPU = 更高的通信开销(DDP all-reduce)+ 更复杂的日志管理 + 更难排查的 bug(rank 间异步性)。每多一块 GPU,工程复杂度增加的不是线性而是超线性。这两个视角的交汇点在于:多 GPU 的最优配置不是"尽可能多",而是"刚好够用"——从统计角度确定需要多少 envs,从工程角度确定能稳定管理多少 GPU。
mjlab 和 Isaac Lab 都默认使用环境并行 + DDP 梯度同步的混合模式——每个 GPU 运行独立的环境集合(环境并行),PPO update 时梯度通过 DDP all-reduce 同步(数据并行)。
mjlab 多 GPU 配置 ⭐⭐⭐
mjlab 使用 torchrunx 实现多 GPU 训练。torchrunx 是一个基于 SSH 的纯 Python 分布式启动器——比标准 torchrun 更灵活(支持从单个 Python 脚本启动多节点训练,无需 SLURM)。
# === mjlab 单 GPU 训练(基准) ===
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 10000 \
--gpu-ids "[0]"
# === mjlab 双 GPU 训练 ===
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 10000 \
--gpu-ids "[0, 1]"
# 效果:每个 GPU 各跑 4096 envs,总共 8192 envs
# 每次 PPO update 的样本量翻倍
# === mjlab 四 GPU 训练 ===
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 2500 \
--gpu-ids "[0, 1, 2, 3]"
# 注意:要与单 GPU 的 10000 iter 等样本量,iteration 数应 ÷4(每次 update 样本量 ×4)
# 总样本量 = 4096 × 24 × 4 × 2500 = 4096 × 24 × 1 × 10000(与单 GPU 基准等价)
torchrunx 的工作原理:
启动进程 (rank 0 on GPU 0)
├── 创建 mjlab 环境 (4096 envs on GPU 0)
├── 创建 PPO runner (actor+critic on GPU 0)
└── DDP wrapper (gradient all-reduce)
启动进程 (rank 1 on GPU 1)
├── 创建 mjlab 环境 (4096 envs on GPU 1)
├── 创建 PPO runner (actor+critic on GPU 1)
└── DDP wrapper (gradient all-reduce)
每个 iteration:
1. 每个 rank 独立 rollout 24 steps
2. 每个 rank 计算 local gradients
3. DDP all-reduce: 平均梯度 across ranks
4. 每个 rank 用平均梯度更新策略
→ 所有 rank 的策略权重保持同步
多 GPU 训练的样本量核算 ⭐⭐⭐:
# === 样本量核算公式 ===
samples_per_update = num_envs_per_gpu * num_steps_per_env * num_gpus
total_env_steps = samples_per_update * max_iterations
# 单 GPU 基准
single_gpu = 4096 * 24 * 1 * 10000 # = 983,040,000
# 双 GPU(相同 iterations)
dual_gpu_same_iter = 4096 * 24 * 2 * 10000 # = 1,966,080,000 (2× 数据!)
# 双 GPU(公平比较:缩放 iterations 使总样本量相同)
dual_gpu_fair = 4096 * 24 * 2 * 5000 # = 983,040,000 (相同!)
反事实推理:如果用双 GPU 跑相同的 iterations 而不缩放会怎样? 你会看到双 GPU 的 reward 曲线更好——但这不是因为"双 GPU 训练效果更好",而是因为双 GPU 总共看了 2 倍的数据。这就像两个学生做不同数量的练习题后比考试成绩——做更多题的当然分更高,但这不能证明两人的学习能力有差异。在论文中报告多 GPU 结果时,必须明确标注 x 轴是 total env steps 而非 iterations。
Isaac Lab 多 GPU 配置 ⭐⭐⭐
Isaac Lab 使用标准的 PyTorch torchrun 启动分布式训练:
# === Isaac Lab 单 GPU 训练 ===
# 官方 RL 入口是 scripts/reinforcement_learning/<rl_library>/train.py,不是 isaaclab.app
./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \
--task Isaac-Velocity-Flat-G1-v0 \
--num_envs 4096 \
--headless
# === Isaac Lab 双 GPU 训练 ===
# 多 GPU 用 torch.distributed.run 调用通用 train.py,并加 --rl_library 与 --distributed
python -m torch.distributed.run --nnodes=1 --nproc_per_node=2 \
scripts/reinforcement_learning/train.py --rl_library rsl_rl \
--task=Isaac-Velocity-Flat-G1-v0 \
--num_envs 4096 \
--headless --distributed
# === Isaac Lab 多节点训练 ===
# 节点 0:
python -m torch.distributed.run --nnodes=2 --nproc_per_node=4 \
--node_rank=0 --master_addr=10.0.0.1 --master_port=29500 \
scripts/reinforcement_learning/train.py --rl_library rsl_rl \
--task=Isaac-Velocity-Flat-G1-v0 --headless --distributed
# 节点 1:
python -m torch.distributed.run --nnodes=2 --nproc_per_node=4 \
--node_rank=1 --master_addr=10.0.0.1 --master_port=29500 \
scripts/reinforcement_learning/train.py --rl_library rsl_rl \
--task=Isaac-Velocity-Flat-G1-v0 --headless --distributed
NVIDIA OSMO 是 Isaac Lab 的生产级多节点编排器——支持 AWS/GCP/Azure/Alibaba Cloud + on-prem K8s。对于大规模集群训练(>8 GPU),OSMO 比手动 torchrun 更可靠。
双框架多 GPU 对比
| 维度 | mjlab | Isaac Lab |
|---|---|---|
| 启动器 | torchrunx (--gpu-ids) |
torchrun (--nproc_per_node) |
| 配置方式 | CLI 参数 | CLI 参数 |
| 通信后端 | NCCL (via PyTorch DDP) | NCCL (via PyTorch DDP) |
| 多节点 | torchrunx SSH | torchrun + OSMO |
| 日志隔离 | 每个 rank 独立日志目录 | 每个 rank 独立日志目录 |
| Checkpoint | rank 0 写入 | rank 0 写入 |
| 兼容 RL 库 | RSL-RL | RSL-RL / rl_games / SKRL |
单写者原则 ⭐⭐
多 GPU 训练中一个高频 bug 是多个 rank 同时写同一个文件——导致 checkpoint 损坏、日志混乱或视频帧交错。
核心原则:所有文件写操作只在 rank 0 执行。
# === 单写者原则的实现 ===
import torch.distributed as dist
def is_main_process():
"""判断是否是 rank 0(主进程)。"""
if not dist.is_initialized():
return True
return dist.get_rank() == 0
# Checkpoint 保存
if is_main_process():
torch.save(policy.state_dict(), "model.pt")
# WandB 日志
if is_main_process():
wandb.log({"reward": reward_mean}, step=iteration)
# 视频录制
if is_main_process():
recorder.record_frame(env.render())
# TensorBoard
if is_main_process():
writer.add_scalar("reward", reward_mean, iteration)
如果忘记了 rank 检查——比如每个 rank 都往同一个 TensorBoard 目录写 events 文件——TensorBoard 会显示混乱的多条曲线(每个 rank 一条,x 轴重叠),你可能误以为训练不稳定(因为看到了多条振荡的曲线),实际上只是日志重复。
Checkpoint 与 Resume 链路 ⭐⭐
多 GPU 训练的 checkpoint 管理比单 GPU 复杂——需要处理"从哪个 rank 加载"和"resume 后 rank 数量变化"的问题:
# === Checkpoint 保存(只 rank 0) ===
def save_checkpoint(runner, path, iteration):
if is_main_process():
state = {
"iteration": iteration,
"policy_state_dict": runner.policy.state_dict(),
"optimizer_state_dict": runner.optimizer.state_dict(),
"obs_normalizer": runner.obs_normalizer.state_dict(),
"world_size": dist.get_world_size() if dist.is_initialized() else 1,
"num_envs_per_gpu": runner.env.num_envs,
}
torch.save(state, path)
print(f"Checkpoint saved: {path}")
# === Checkpoint 恢复(所有 rank 都加载同一个文件) ===
def load_checkpoint(runner, path):
state = torch.load(path, map_location=runner.device)
runner.policy.load_state_dict(state["policy_state_dict"])
runner.optimizer.load_state_dict(state["optimizer_state_dict"])
if "obs_normalizer" in state:
runner.obs_normalizer.load_state_dict(state["obs_normalizer"])
# 检查 world_size 变化
saved_ws = state.get("world_size", 1)
current_ws = dist.get_world_size() if dist.is_initialized() else 1
if saved_ws != current_ws:
print(f"⚠️ World size changed: {saved_ws} → {current_ws}")
print(f" 总样本量核算需要调整 max_iterations!")
return state["iteration"]
Resume 时的样本量一致性:如果你用 2 GPU 训练了 5000 iterations 后中断,用 4 GPU resume,每个 iteration 的样本量变成了原来的 2 倍——如果不调整 max_iterations,总样本量会超出预期。resume 前必须重新核算。
⚠️ 常见陷阱
⚠️ 编程陷阱:多个 rank 都写 WandB - 每个 rank 独立创建 WandB run,结果出现 N 个重复的 run - 正确做法:只在 rank 0 初始化 WandB
⚠️ 编程陷阱:GPU id 映射错误
- CUDA_VISIBLE_DEVICES=2,3 + --gpu-ids "[2, 3]" → 设备不可见错误
- 正确做法:--gpu-ids "[0, 1]"(逻辑 id 相对于 CUDA_VISIBLE_DEVICES)
⚠️ 思维陷阱:多 GPU 但不缩放 iterations - 双 GPU × 10000 iterations vs 单 GPU × 10000 iterations:前者看了 2× 数据 - 论文中报告时必须标明 x 轴是 total env steps 还是 iterations
⚠️ 编程陷阱:torchrunx rank 1 崩溃但 rank 0 日志正常
- torchrunx 的每个 rank 有独立的日志文件(在 torchrunx/ 目录下)
- 只看 rank 0 的标准输出可能漏掉 rank 1 的 CUDA crash
- 正确做法:检查所有 rank 的日志,特别是 stderr
练习
- [计算题] 4 GPU 训练,每 GPU 4096 envs,
num_steps_per_env=24,max_iterations=5000。计算总 env steps。如果要和单 GPU × 4096 envs × 24 steps × 20000 iterations 公平比较,应该怎么设置? - [实践题] 在一台双 GPU 机器上,分别用 mjlab 的
--gpu-ids "[0]"和--gpu-ids "[0, 1]"跑 100 iterations,记录两者的 wall-clock 时间和 total env steps。双 GPU 的加速比是多少? - [分析题] 为什么 GPU 仿真 RL 偏好环境并行而非纯数据并行?从 "env step 是瓶颈" 的角度解释。
多 GPU 解决了"训练规模"的问题。但在训练过程中,一个更紧迫的问题经常出现——训练突然崩溃,reward 变成 NaN。这种问题在多 GPU 环境中更难排查,因此我们先在单 GPU 上建立 NaN 排查方法论。
24.3 NaN 排查:从症状到根因的系统方法论 ⭐⭐⭐
这一节解决什么问题:训练中突然出现 NaN 时,如何系统化地定位根因并修复?
动机:NaN 是 RL 训练中最高频的致命错误
NaN(Not a Number)是 RL 训练中最常见的崩溃原因——策略输出的动作、reward 计算的结果或 obs 中的某个维度突然变成 NaN,从此训练不可恢复。在 GPU 并行仿真中,NaN 尤其危险:一个 env 中的 NaN 可能通过 batch 操作传播到所有 env,在一帧之内感染整个训练。
跨领域类比:NaN 在 RL 训练中就像心脏骤停在临床上——它不是疾病本身,而是某种底层病因的终端表现。你不能只治疗心脏骤停(清除 NaN)——你必须找到并治疗底层病因(导致 NaN 的物理/算法配置)。就像急诊流程先 CPR(恢复心跳)再找病因,NaN 排查也是先定位哪个 env/step/variable 最先出现 NaN(等价于 CPR),再追溯根因。
NaN 的五大根因(按频率排序) ⭐⭐⭐
根据 mjlab、Isaac Lab 社区和本教材作者的经验,NaN 的根因按频率排序如下:
| 排名 | 根因 | 典型症状 | 频率 |
|---|---|---|---|
| 1 | 接触求解器发散 | 高冲击接触后 qvel 中出现 NaN | 30% |
| 2 | Reward 函数中的除零/溢出 | reward 计算时出现 inf → NaN | 25% |
| 3 | Obs normalizer 未预热 | 前几步 obs 方差为 0,归一化除零 | 20% |
| 4 | Policy std 变负 | log_prob 计算时出现 NaN | 15% |
| 5 | CUDA Graph capture 失效 | 随机化改变内存布局后 graph 无效 | 10% |
根因 1:接触求解器发散 ⭐⭐
症状:训练前 1000 iterations 正常,然后突然某些 env 的 qvel 出现 NaN。通常发生在策略学到了高速动作(接触力增大)或 DR 引入了低摩擦(接触更容易滑动)之后。
根因:MuJoCo 的接触求解器在处理高 condim(如 condim=6,带扭转摩擦)+ 高摩擦值(如 friction=5.0)的接触时,可能产生数值不稳定的 constraint force。PhysX 也有类似问题——solver_position_iteration_count 太少时穿透修正不足。
排查代码:
# === 接触发散 NaN 排查 ===
def diagnose_contact_nan(env, max_steps=10000):
"""逐步运行环境,在 NaN 出现前捕获物理状态。"""
obs, _ = env.reset()
policy = load_policy("model.pt")
for step in range(max_steps):
action = policy(obs['policy'])
obs, reward, done, truncated, info = env.step(action)
# 检查 qvel 中的 NaN
qvel = env.robot.data.joint_vel
if torch.isnan(qvel).any():
nan_envs = torch.isnan(qvel).any(dim=-1).nonzero().squeeze()
print(f"NaN at step {step}, envs: {nan_envs.tolist()}")
# 打印 NaN 前一步的状态
if step > 0:
print(f" Previous step action: {prev_action[nan_envs[0]]}")
print(f" Previous step qpos: {prev_qpos[nan_envs[0]]}")
print(f" Contact count: {env.sim.data.ncon}")
# 查看接触力
for c_idx in range(min(5, env.sim.data.ncon)):
contact = env.sim.data.contact[c_idx]
# 注意:mjContact 没有 `force` 字段;接触力需用 mj_contactForce(model, data, c_idx, buf) 计算
print(f" Contact {c_idx}: "
f"geom1={contact.geom1}, geom2={contact.geom2}, "
f"dist={contact.dist:.4f}")
break
# 保存当前步状态用于下一步对比
prev_action = action.clone()
prev_qpos = env.robot.data.joint_pos.clone()
修复方向:
# 修复 1:降低 condim 和摩擦
# MJCF 中
# <geom condim="4" friction="0.8 0.005 0.001"/> # 而非 condim=6 friction=5.0
# 修复 2:增加求解器迭代次数
sim = SimCfg(
mujoco=MujocoCfg(
iterations=15, # 从 10 增到 15
ls_iterations=25, # 从 20 增到 25
),
)
# 修复 3:减小 timestep(更小的 dt = 更精确的积分)
sim = SimCfg(
mujoco=MujocoCfg(timestep=0.002), # 从 0.005 减到 0.002
# 注意:decimation 也需要调整以保持相同的策略频率
# 新 decimation = old_decimation * (old_dt / new_dt) = 4 * (0.005/0.002) = 10
)
# 修复 4(PhysX):增加求解器迭代
articulation_props=sim_utils.ArticulationRootPropertiesCfg(
solver_position_iteration_count=8, # 从 4 增到 8
solver_velocity_iteration_count=8,
)
根因 2:Reward 函数中的除零/溢出 ⭐⭐
症状:某个 reward term 在特定 env 状态下返回 inf 或 NaN。通常是 1/distance 形式的 reward 在距离趋近零时溢出。
排查:逐项检查每个 reward term 的输出范围。
# === Reward 函数 NaN 排查 ===
def diagnose_reward_nan(env, policy, num_steps=100):
"""分项检查每个 reward term 的输出。"""
obs, _ = env.reset()
for step in range(num_steps):
action = policy(obs['policy'])
obs, reward, done, truncated, info = env.step(action)
# 分项打印 reward
for name, value in env.reward_manager.compute_terms().items():
if torch.isnan(value).any():
nan_envs = torch.isnan(value).nonzero()
print(f" NaN in reward '{name}' at step {step}, "
f"envs: {nan_envs.squeeze().tolist()}")
if torch.isinf(value).any():
inf_envs = torch.isinf(value).nonzero()
print(f" Inf in reward '{name}' at step {step}, "
f"envs: {inf_envs.squeeze().tolist()}")
修复:永远不用 1/d 形式的 reward——改用 exp(-d²/σ²)。
# ❌ 错误:距离趋零时溢出
def bad_distance_reward(env):
d = compute_distance(env)
return 1.0 / (d + 1e-8) # d=0 时返回 1e8,梯度爆炸
# ✅ 正确:指数核,有界且处处可导
def good_distance_reward(env, sigma=0.25):
d = compute_distance(env)
return torch.exp(-d**2 / sigma**2) # d=0 返回 1.0,d→∞ 返回 0
根因 3:Obs normalizer 未预热 ⭐⭐
症状:训练的前 1-10 个 iteration 出现 NaN(非常早期)。
根因:RSL-RL 的 EmpiricalNormalization 使用 Welford 算法在线计算 running mean 和 std。在训练刚开始时,std 可能为零(所有 obs 相同)或接近零(方差很小),归一化操作 (obs - mean) / std 产生极大的数值 → PPO 的 loss 溢出 → 梯度 NaN。
修复:在正式训练前用 random agent 预热 normalizer。
# === Normalizer 预热 ===
def warmup_normalizer(env, policy, num_warmup_steps=500):
"""用随机动作预热 obs normalizer。"""
obs, _ = env.reset()
for _ in range(num_warmup_steps):
action = torch.randn(env.num_envs, env.action_space.shape[-1],
device=env.device)
obs, _, _, _, _ = env.step(action)
# normalizer 在 env.step 内自动更新
print(f"Normalizer warmup done. "
f"obs_mean range: [{policy.obs_normalizer.running_mean.min():.2f}, "
f"{policy.obs_normalizer.running_mean.max():.2f}]")
print(f"obs_std range: [{policy.obs_normalizer.running_var.sqrt().min():.4f}, "
f"{policy.obs_normalizer.running_var.sqrt().max():.4f}]")
# 检查是否有零方差的维度
zero_var = (policy.obs_normalizer.running_var < 1e-8).nonzero()
if len(zero_var) > 0:
print(f"⚠️ 零方差 obs 维度: {zero_var.squeeze().tolist()}")
print(" 这些维度可能是常数 obs(如固定 command)——检查是否合理")
根因 4:Policy std 变负 ⭐⭐
症状:PPO update 时 log_prob 计算出现 NaN。
根因:某些 RSL-RL 版本中 policy 的 action std 直接用线性层输出——如果输出值为负数,Normal(mean, std) 中 std < 0 导致 NaN。
修复:使用 softplus(std) 或 log 参数化。
# ❌ 错误:std 可能为负
self.std = nn.Parameter(torch.ones(action_dim) * init_noise_std)
# 训练中 std 可能被梯度更新为负数
# ✅ 正确:log 参数化
self.log_std = nn.Parameter(torch.zeros(action_dim) + np.log(init_noise_std))
# std = exp(log_std) > 0,永远为正
# ✅ 正确:softplus 参数化
self.raw_std = nn.Parameter(torch.ones(action_dim))
# std = softplus(raw_std) > 0
根因 5:CUDA Graph capture 失效 ⭐
症状:训练中突然出现随机的 NaN——不是每次都在同一个 step 或同一个 env。
根因:Domain randomization 的某些 EventTerm 改变了 GPU 数组的地址或形状(如 expand_model_fields() 扩展了 per-world 摩擦数组),但 CUDA Graph 仍在 replay 旧地址的 kernel——访问了无效内存。
排查:
# 检查是否有 CUDA Graph 相关的 warning
# mjlab 中会打印:
# "[WARN] Graph invalidated after expand_model_fields, re-capturing..."
# 如果没有这个 warning,说明 graph 没有被重新 capture
# 修复 1:确认 expand_model_fields 后调用了 create_graph()
# 修复 2:禁用 CUDA Graph 测试是否 NaN 消失
# 如果禁用后 NaN 消失 → 确认是 graph 问题
# 如果禁用后 NaN 仍在 → graph 不是根因
mjlab 的 NaN Guard 工具 ⭐⭐⭐
mjlab 提供了内置的 NaN 检测和 dump 机制:
# 启用 NaN guard
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 256 \
--agent.max-iterations 100 \
--enable-nan-guard True
# NaN 发生时自动 dump:
# /tmp/mjlab/nan_dumps/nan_dump_latest.npz
# 包含: env_id, step, qpos, qvel, qacc, action, obs, reward
# 可视化 NaN dump
uv run viz-nan /tmp/mjlab/nan_dumps/nan_dump_latest.npz
# 在 Viser viewer 中显示 NaN 发生时的物理状态
NaN dump 的分析流程:
首选用官方交互式查看器(可逐步回放、对比 env、可视化机器人状态):
uv run viz-nan /tmp/mjlab/nan_dumps/nan_dump_latest.npz
如需脚本化分析,必须按当前 mjlab NaN Guard 的真实 npz 结构读取:
- _metadata: dict,含 num_envs_total / nan_env_ids / dumped_env_ids 等
- states_step_NNNNNN: 每个 step 捕获的状态,形状 [num_envs_dumped, state_size]
它不是顶层的 env_id/qpos/qvel/action/obs/reward 键。
# === 分析 NaN dump(匹配当前 mjlab 格式)===
import numpy as np
def analyze_nan_dump(path):
"""分析 mjlab 的 NaN dump 文件(states_step_* + _metadata 结构)。"""
dump = np.load(path, allow_pickle=True)
meta = dump["_metadata"].item() # dict
print(f"=== NaN Dump Analysis ===")
print(f"num_envs_total : {meta.get('num_envs_total')}")
print(f"nan_env_ids : {meta.get('nan_env_ids')}")
print(f"dumped_env_ids : {meta.get('dumped_env_ids')}")
# 遍历每个 step 的状态数组,定位首次出现 NaN 的 step 与 env
step_keys = sorted(k for k in dump.files if k.startswith("states_step_"))
for k in step_keys:
states = dump[k] # [num_envs_dumped, state_size]
nan_mask = np.isnan(states)
if nan_mask.any():
bad_env_rows = np.where(nan_mask.any(axis=1))[0]
print(f" {k}: NaN 出现在 dumped-env 行 {bad_env_rows[:10]} "
f"(state 维度内 NaN 列示例: {np.where(nan_mask[bad_env_rows[0]])[0][:10]})")
break
else:
print(f" {k}: no NaN (range: [{states.min():.2f}, {states.max():.2f}])")
# 根因仍需结合 reward/obs 侧日志判断:接触发散→qvel/qacc 爆;reward 除零;normalizer 等。
analyze_nan_dump("/tmp/mjlab/nan_dumps/nan_dump_latest.npz")
NaN 排查优先级表 ⭐⭐
当 NaN 发生时,按以下优先级逐步排查——每步确认无问题后再进入下一步:
| 优先级 | 检查项 | 检查方法 | 修复方向 |
|---|---|---|---|
| 1 | Reward 中是否有除零 | 分项打印 reward term | 替换为 exp(-d²/σ²) |
| 2 | Action scale 是否过大 | 打印 action 范围 | 减小到 0.5× 试试 |
| 3 | Obs normalizer 是否预热 | 打印 running_var | 加 warmup 步骤 |
| 4 | Actuator gains 是否合理 | PD 阶跃响应测试 | 降低 kp/kd |
| 5 | Timestep 是否太大 | 自由落体弹跳测试 | 减小 dt |
| 6 | Solver iterations 是否足够 | 接触力稳定性检查 | 增加 iterations |
| 7 | nconmax/njmax 是否足够 | 检查 warning 信息 | 增大 buffer |
| 8 | Contact 参数 | solref/solimp 检查 | 使用更软的接触 |
| 9 | Policy std 参数化 | 检查 std 是否有负值 | 用 softplus/log |
| 10 | CUDA Graph 是否有效 | 禁用 graph 重试 | 重新 capture |
排查的黄金法则:先缩小复现范围,再定位根因。 不要在 4096 envs 上排查 NaN——先用 16 envs + 短训练复现。如果 16 envs 不复现,逐步增大 envs 数量直到复现——这能帮你判断 NaN 是否和 env 数量(内存、CUDA Graph)相关。
反事实推理:如果不用 NaN guard 直接凭经验猜会怎样? 你看到 reward 变成 NaN,猜测"可能是 contact 参数不对",花了两天调 solref/solimp——结果问题是 reward 函数中的
1/d在距离趋零时溢出。NaN guard 的 dump 能在 5 分钟内告诉你"NaN 首先出现在 reward,不是 qvel"——直接指向 reward 函数而非 contact 参数。
⚠️ 常见陷阱
⚠️ 编程陷阱:用 torch.nan_to_num() 掩盖 NaN
- 某些框架在 reward 计算后用 nan_to_num 把 NaN 替换为 0——这消除了错误信号但不修复根因
- 训练可能继续但策略行为诡异(因为某些 reward 被静默替换为 0)
- 正确做法:找到并修复 NaN 的根因,而非掩盖它
⚠️ 思维陷阱:NaN + 多 GPU 一起排查 - 如果每个 rank 都偶发 NaN,不要在多 GPU 环境下排查 - 先缩小到单 GPU + 小 env 数定位根因 - 确认修复后再扩展回多 GPU 验证
⚠️ 编程陷阱:NaN 在 curriculum 阶段切换后出现 - Curriculum 推进时新增了 reward term 或修改了权重,如果新 term 有 NaN 风险且旧 term 没有,NaN 只在推进后出现 - 排查:固定 curriculum 在推进前后的阶段各跑 100 iterations,确认 NaN 是哪个阶段的问题
练习
- [实践题] 在你的自定义环境中故意引入一个
1/d形式的 reward(距离趋零时溢出)。运行训练,观察 NaN 何时出现。然后用--enable-nan-guard重新运行,分析 dump。 - [分析题] 一个训练在第 3000 iteration 出现 NaN(之前 2999 iterations 正常)。列出三个可能的触发条件,并解释为什么这些条件在训练后期才触发。
- [跨章综合题] 结合 Ch08 的 DR 配置,分析以下场景:DR 的摩擦范围从 [0.5, 1.0] 扩大到 [0.1, 2.0] 后训练开始频繁 NaN。可能的根因是什么?应该怎么修复?(提示:低摩擦 + 高速接触 → 求解器发散)
CUDA Graph 诊断与修复 ⭐⭐
CUDA Graph 是 mjlab 和 Isaac Lab 高吞吐的关键机制——但它也是 NaN 和性能异常的隐蔽来源。当 Graph 失效但未被正确重新 capture 时,GPU 可能访问无效的内存地址,产生随机的错误值。
CUDA Graph 失效的三种场景:
| 场景 | 触发条件 | 症状 | 修复 |
|---|---|---|---|
| DR 扩展 model fields | expand_model_fields() 改变了 per-world 数组 |
随机 NaN 或物理行为异常 | 确认 create_graph() 被重新调用 |
| Sensor 配置变更 | set_sensor_context() 改变传感器数组 |
sense graph 失效,传感器返回旧值 | 重新 capture sense graph |
| Env 数量变化 | resume 时 num_envs 和 capture 时不同 | 维度不匹配崩溃 | 用相同 num_envs resume |
诊断脚本:
# === CUDA Graph 状态诊断 ===
def diagnose_cuda_graph(sim):
"""检查 CUDA Graph 的状态是否有效。"""
print("=== CUDA Graph Diagnostics ===")
# 检查 graph 是否存在
has_step = hasattr(sim, 'step_graph') and sim.step_graph is not None
has_sense = hasattr(sim, 'sense_graph') and sim.sense_graph is not None
has_reset = hasattr(sim, 'reset_graph') and sim.reset_graph is not None
print(f" step_graph: {'✅' if has_step else '❌ Missing'}")
print(f" sense_graph: {'✅' if has_sense else '❌ Missing'}")
print(f" reset_graph: {'✅' if has_reset else '❌ Missing'}")
# 检查是否有 expanded fields(可能需要 re-capture)
if hasattr(sim, '_expanded_fields'):
expanded = sim._expanded_fields
if expanded:
print(f" ⚠️ Expanded fields: {list(expanded.keys())}")
print(f" → 这些字段被 expand 后是否重新 capture 了 graph?")
else:
print(f" ✅ No expanded fields")
# 检查 device
print(f" Device: {sim.device}")
print(f" Num worlds: {sim.num_worlds}")
# 尝试禁用 graph 运行一步,对比
print("\n Running one step WITHOUT graph (direct call)...")
try:
import mujoco.warp as mjwarp
mjwarp.step(sim.wp_model, sim.wp_data)
print(" ✅ Direct step succeeded")
except Exception as e:
print(f" ❌ Direct step failed: {e}")
Graph 失效的修复模式:
# 当 DR 修改了 model fields 时的正确流程:
def apply_dr_with_graph_rebuild(sim, dr_events):
"""应用 DR 并正确重建 CUDA Graph。"""
# 1. 应用 DR(可能 expand fields)
for event in dr_events:
event.apply(sim)
# 2. 检查是否有 field 被 expand
if sim._needs_graph_rebuild:
# 3. 重建所有 graph
sim.create_graph()
sim._needs_graph_rebuild = False
print("[INFO] CUDA Graph rebuilt after DR expansion")
多 GPU 环境下的 NaN 特殊处理 ⭐⭐
多 GPU 训练中 NaN 的排查更困难——因为 NaN 可能只发生在某个 rank 上,而其他 rank 的日志看起来正常。
# === 多 GPU NaN 监控 ===
def check_nan_all_ranks(tensor, name="tensor"):
"""跨 rank 检查 NaN 并报告哪个 rank 有问题。"""
has_nan = torch.isnan(tensor).any()
if dist.is_initialized():
# 收集所有 rank 的 NaN 状态
nan_counts = torch.tensor([has_nan.int()], device=tensor.device)
dist.all_reduce(nan_counts, op=dist.ReduceOp.SUM)
if nan_counts.item() > 0:
rank = dist.get_rank()
if has_nan:
print(f"⚠️ Rank {rank}: NaN detected in {name}")
print(f" Shape: {tensor.shape}")
nan_idx = torch.isnan(tensor).nonzero()
print(f" NaN indices: {nan_idx[:5].tolist()}")
dist.barrier() # 所有 rank 同步
return True
elif has_nan:
print(f"⚠️ NaN detected in {name}")
return True
return False
# 在训练循环中使用
for iteration in range(max_iterations):
obs, reward, done = rollout(env, policy)
if check_nan_all_ranks(obs['policy'], "observation"):
print("Saving NaN dump and stopping...")
save_nan_dump(env, iteration)
break
if check_nan_all_ranks(reward, "reward"):
print("Saving NaN dump and stopping...")
save_nan_dump(env, iteration)
break
NaN 排查建立了"训练能稳定运行"的基础。但"能运行"不等于"运行得快"——环境的吞吐量瓶颈可能在物理计算、传感器、或 Python 层面。下一节讲如何量化和优化性能。
24.4 性能优化:从 Profiling 到 Tuning ⭐⭐⭐
这一节解决什么问题:如何量化训练的性能瓶颈?在物理计算、传感器、Manager 和 PPO update 之间,时间花在了哪里?
性能度量标准 ⭐⭐
RL 训练的性能不只是"steps/s"一个数字——需要区分多个层次:
| 度量 | 定义 | 含义 |
|---|---|---|
physics_sps |
物理步/秒(含 decimation 内的所有 substep) | 物理引擎的原始吞吐 |
env_sps |
环境步/秒(不含 decimation) | 策略频率下的吞吐 |
train_sps |
训练步/秒(含 PPO update) | 端到端训练速度 |
VRAM_MB |
GPU 显存占用 | 决定能跑多少 envs |
wall_time_per_iter |
每次 PPO iteration 的 wall-clock 时间 | 决定总训练时间 |
关键关系:env_sps = physics_sps / decimation,train_sps < env_sps(因为 PPO update 也需要时间)。
如果 physics_sps 很高但 env_sps 很低,瓶颈在传感器或 Manager 的 Python 逻辑。如果 env_sps 很高但 train_sps 很低,瓶颈在 PPO update(网络太大或 mini_batch 太多)。
性能 Profiling 工具链 ⭐⭐
快速 Benchmark(5 分钟):
# 测量 env 吞吐(不包含 PPO update)
# 在 play 模式下用 random agent 跑 1000 steps
time uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
--agent random --num-envs 4096 --max-steps 1000 --no-render
# 输出的 wall time 用于计算:
# env_sps = num_envs * max_steps / wall_time_seconds
# 测量 train 吞吐(包含 PPO update)
time uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 --agent.max-iterations 10 \
--agent.logger tensorboard --headless
# train_sps = num_envs * num_steps_per_env * 10 / wall_time_seconds
深度 Profiling(30 分钟):
# === torch.profiler 深度分析 ===
import torch.profiler
def profile_training(env, policy, runner, num_iters=5):
"""使用 torch.profiler 分析训练的时间分布。"""
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA,
],
schedule=torch.profiler.schedule(
wait=1, warmup=1, active=3, repeat=1
),
on_trace_ready=torch.profiler.tensorboard_trace_handler("./profile"),
record_shapes=True,
with_stack=True,
) as prof:
for i in range(num_iters):
runner.train_one_iteration()
prof.step()
# 打印 top 20 耗时操作
print(prof.key_averages().table(
sort_by="cuda_time_total", row_limit=20
))
# 查找瓶颈
events = prof.key_averages()
total_cuda = sum(e.cuda_time_total for e in events)
for e in sorted(events, key=lambda x: x.cuda_time_total, reverse=True)[:5]:
pct = e.cuda_time_total / total_cuda * 100
print(f" {e.key:40s} {e.cuda_time_total/1000:.1f} ms ({pct:.1f}%)")
NVIDIA nsys Profiling(GPU kernel 级):
# 使用 nsys 分析 GPU kernel 执行
nsys profile --trace=cuda,nvtx \
--output=train_profile \
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 --agent.max-iterations 5 --headless
# 打开 nsys UI 分析 timeline
nsys-ui train_profile.nsys-rep
传感器成本量化 ⭐⭐
传感器(contact sensor、height scan、camera)通常是环境吞吐的最大瓶颈。以下代码展示如何量化传感器的性能开销:
# === 传感器成本量化 ===
import time
def benchmark_sensor_cost(task, num_envs=4096, num_steps=500):
"""量化传感器对吞吐的影响。"""
# 1. 有传感器的吞吐
env_with_sensor = make(task, num_envs=num_envs)
obs, _ = env_with_sensor.reset()
t0 = time.perf_counter()
for _ in range(num_steps):
action = torch.randn(num_envs, env_with_sensor.action_space.shape[-1],
device="cuda")
env_with_sensor.step(action)
t_with = time.perf_counter() - t0
sps_with = num_envs * num_steps / t_with
env_with_sensor.close()
# 2. 无传感器的吞吐(需要修改 env config 移除 sensor)
# 或者用更简单的方法:对比有无 height scan
# task_no_sensor = task.replace("Rough", "Flat") # Flat 没有 height scan
# ...
print(f"With sensors: {sps_with:.0f} steps/s")
# print(f"Without sensors: {sps_without:.0f} steps/s")
# print(f"Sensor overhead: {(1 - sps_with/sps_without)*100:.1f}%")
各组件的典型成本比例(在 A100 上,4096 envs,29-DOF 人形):
| 组件 | 占比 | 说明 |
|---|---|---|
| 物理引擎 (mj_step) | 40-50% | 接触检测 + 求解器 |
| 传感器 (sense) | 15-30% | height scan > contact > raycast |
| Manager (obs/reward/term) | 10-20% | Python 层 dispatch + tensor ops |
| PPO update | 10-20% | 网络 forward + backward |
| 其他 (reset/logging/CUDA sync) | 5-10% |
nconmax/njmax 调优 ⭐⭐
nconmax(用于推导全局 contact 容量——MJWarp 中 contacts 存放在异构数组里,单个 world 的接触数可以超过 nconmax,只要所有 world 的总接触数不超过 nworld × nconmax)和 njmax(每个 world 严格的最大约束行数上限)直接影响 GPU 显存和性能。设太小会导致接触/约束截断(穿透、物理不稳定);设太大浪费显存和降低 cache locality。
正确的调优方法:先用默认值跑,记录实际接触数分布,然后设为 1.5× 最大观测值。
# === nconmax 调优 ===
def find_optimal_nconmax(env, num_steps=1000):
"""记录实际接触数分布,推荐 nconmax。"""
obs, _ = env.reset()
ncon_history = []
for _ in range(num_steps):
action = torch.randn(env.num_envs, env.action_space.shape[-1],
device=env.device)
env.step(action)
# 记录每个 env 的接触数
ncon = env.sim.data.ncon # [num_envs] or scalar
if isinstance(ncon, int):
ncon_history.append(ncon)
else:
ncon_history.extend(ncon.tolist())
ncon_arr = np.array(ncon_history)
print(f"Contact count statistics:")
print(f" mean: {ncon_arr.mean():.1f}")
print(f" p50: {np.percentile(ncon_arr, 50):.0f}")
print(f" p95: {np.percentile(ncon_arr, 95):.0f}")
print(f" max: {ncon_arr.max():.0f}")
recommended = int(ncon_arr.max() * 1.5)
print(f" Recommended nconmax: {recommended}")
return recommended
num_envs 选择指南 ⭐⭐
# === 自动寻找最优 num_envs ===
def find_optimal_num_envs(task, env_range=[256, 512, 1024, 2048, 4096, 8192]):
"""找到吞吐最高的 num_envs。"""
results = []
for n in env_range:
try:
env = make(task, num_envs=n)
obs, _ = env.reset()
# Warmup
for _ in range(50):
env.step(torch.randn(n, env.action_space.shape[-1], device="cuda"))
# Benchmark
t0 = time.perf_counter()
for _ in range(200):
env.step(torch.randn(n, env.action_space.shape[-1], device="cuda"))
wall = time.perf_counter() - t0
sps = n * 200 / wall
vram = torch.cuda.max_memory_allocated() / 1e6
results.append({"num_envs": n, "sps": sps, "vram_mb": vram})
print(f" num_envs={n:5d}: {sps:.0f} sps, {vram:.0f} MB VRAM")
env.close()
torch.cuda.empty_cache()
except RuntimeError as e:
if "out of memory" in str(e):
print(f" num_envs={n:5d}: OOM")
break
raise
# 找到 sps 最高的配置
best = max(results, key=lambda x: x["sps"])
print(f"\nOptimal: num_envs={best['num_envs']} "
f"({best['sps']:.0f} sps, {best['vram_mb']:.0f} MB)")
return best
性能调优决策表 ⭐⭐
| 现象 | 优先怀疑 | 第一检查项 | 调整方向 |
|---|---|---|---|
| steps/s < 预期的 50% | sensor 过重 | 关闭 sensor 对比 | 降低 sensor 数量/分辨率 |
| GPU 利用率 < 50% | num_envs 太小 | 增大 num_envs | 翻倍到 GPU 饱和 |
| VRAM 接近上限 | num_envs 或 buffer 太大 | nvidia-smi 监控 | 降低 num_envs 或 nconmax |
| 训练前几步极慢 | CUDA Graph capture | 检查 warning 信息 | 等待 capture 完成 |
| PPO update 占比 > 30% | 网络太大或 mini_batch 太多 | profiler 检查 | 减小网络层或 mini_batch |
| physics_sps 低但 env_sps 合理 | timestep/iterations 过多 | 增大 timestep 试试 | 权衡精度和速度 |
⚠️ 常见陷阱
⚠️ 编程陷阱:在 reward 函数中用 Python for 循环 - 对每个 env 单独计算 reward(而非 batch tensor 操作)——N 个 env 就执行 N 次 Python 循环 - 修复:所有 reward/obs 函数必须是 batch tensor 操作
⚠️ 思维陷阱:性能不好就换框架 - "mjlab 太慢,换 Isaac Lab"——更可能的原因是 sensor 配置或 num_envs 不对 - 正确做法:先 profile,找到瓶颈再决定
⚠️ 编程陷阱:benchmark 时包含了 warmup - 前几步包含 CUDA Graph capture、JIT 编译等一次性开销——会拉低平均 steps/s - 正确做法:先跑 50-100 步 warmup,再开始计时
练习
- [实践题] 对你的自定义环境运行
find_optimal_num_envs()。画出 num_envs vs steps/s 和 num_envs vs VRAM 的曲线。最优 num_envs 是多少? - [实验题] 在一个有 height scan sensor 的四足任务上,分别测量有无 height scan 的 env_sps。Sensor 带来了多大的开销百分比?
- [分析题] 为什么过大的 nconmax 会降低性能?从 GPU cache locality 的角度解释。
完整性能 Profiling 案例 ⭐⭐⭐
以下是一个真实的性能优化案例——从"训练太慢"到"找到瓶颈"到"优化后加速 2.5×"的完整流程。
背景:Unitree G1 29-DOF 人形 locomotion 任务,4096 envs,单 A100。初始 train_sps ≈ 45,000 steps/s,目标 >100,000 steps/s。
Step 1:定位瓶颈层次
# 分层测量
# 1. 纯物理 step(无 sensor,无 manager)
physics_sps = benchmark_physics_only(env) # 220,000 sps
# 2. 物理 + sensor
physics_sensor_sps = benchmark_with_sensors(env) # 120,000 sps
sensor_overhead = 1 - physics_sensor_sps / physics_sps # 45%!
# 3. 物理 + sensor + managers
env_sps = benchmark_env_step(env) # 95,000 sps
manager_overhead = 1 - env_sps / physics_sensor_sps # 21%
# 4. 完整训练(含 PPO update)
train_sps = benchmark_training(env, runner) # 45,000 sps
ppo_overhead = 1 - train_sps / env_sps # 53%!
Step 2:逐项优化
发现 1: sensor 开销 45% → 罪魁祸首是 1024-ray height scan
修复: 减少到 121 rays (11×11 grid) → sensor 开销降到 15%
发现 2: PPO 开销 53% → critic 网络太大 [1024, 512, 256]
修复: 改为 [512, 256, 128] (AGILE 默认) → PPO 开销降到 25%
发现 3: manager overhead 21% → reward 函数中有 per-env 循环
修复: 改为 batch tensor 操作 → manager 开销降到 8%
Step 3:优化后结果
优化前: train_sps = 45,000
优化后: train_sps = 115,000 (2.56× 加速)
分解:
Physics: 220,000 → 220,000 (不变)
+ Sensors: 120,000 → 195,000 (+63%)
+ Managers: 95,000 → 180,000 (+89%)
+ PPO: 45,000 → 115,000 (+156%)
关键教训:性能瓶颈很少在你猜测的地方——这个案例中,用户最初怀疑"MuJoCo Warp 物理引擎太慢",但实际瓶颈在 sensor 和 PPO 网络大小。没有 profiling 的猜测只会浪费时间。
Timestep × Decimation × 策略频率的权衡 ⭐⭐
这三个参数之间的关系经常被混淆,但它们直接影响训练质量和速度:
# 参数关系
physics_freq = 1.0 / timestep # 物理更新频率
policy_freq = physics_freq / decimation # 策略决策频率
control_dt = timestep * decimation # 策略的控制周期
# 典型配置
# 四足 locomotion:
# timestep=0.005, decimation=4 → physics 200Hz, policy 50Hz
# 人形 locomotion:
# timestep=0.002, decimation=10 → physics 500Hz, policy 50Hz
# 灵巧手操作:
# timestep=0.001, decimation=5 → physics 1000Hz, policy 200Hz
| 如果改变... | 物理精度 | 训练速度 | 策略频率 | 策略质量 |
|---|---|---|---|---|
| 减小 timestep | ↑ | ↓ | 不变(需调 decimation) | ↑(更稳定的物理) |
| 增大 decimation | 不变 | ↑ | ↓ | ↓(策略看到更旧的信息) |
| 两者同时调 | 取决于比例 | 取决于比例 | 取决于比例 | 需要实验验证 |
反事实推理:如果把 timestep 从 0.005 增大到 0.01(为了加速),但 decimation 不变会怎样? Policy 频率从 50Hz 降到 25Hz。对于四足行走,25Hz 可能足够(步态周期 ~0.5s,每步 12.5 个决策点)。但对于跑步(步态周期 ~0.3s),25Hz 只有 7.5 个决策点——可能不够精细,导致脚步落点不精确。此外,更大的 timestep 会降低接触求解的精度——高冲击接触(如足端着地)可能产生更大的穿透和不稳定。
Isaac Lab vs mjlab 性能对比 ⭐⭐
两个框架在同一硬件上的性能差异主要来自物理引擎(PhysX vs MuJoCo Warp)和框架层开销:
| 维度 | mjlab (MuJoCo Warp) | Isaac Lab (PhysX) |
|---|---|---|
| 接触求解器 | Newton solver (GPU) | TGS solver (GPU) |
| 典型 env_sps (四足) | 150k-250k | 120k-200k |
| 典型 env_sps (人形) | 80k-120k | 60k-100k |
| 接触精度 | 高(Newton 全局求解) | 中(TGS 迭代求解) |
| CUDA Graph 支持 | 原生 | 部分(取决于 PhysX 版本) |
| 多 GPU 方式 | torchrunx | torchrun |
| Sensor 成本 | 中(raycast in Warp) | 中(raycast in PhysX) |
| Camera sensor 成本 | 高(需要渲染) | 高(但 tiled rendering 优化) |
注意:这些数字是 order-of-magnitude 参考,实际值取决于具体任务配置、GPU 型号和框架版本。不应该仅基于 steps/s 选择框架——物理精度、API 设计、生态支持和部署管线的完整性更重要。
Benchmark 协议必备字段 ⭐⭐
报告性能数字时,必须附带完整的实验协议——否则数字无法被复查。"mjlab 比 X 快 3 倍"缺少了使结论有效的全部上下文:什么任务?什么机器人?什么传感器?多少并行环境?什么硬件?warmup 如何处理?没有这些上下文的数字,就像说"A 比 B 跑得快"——但 A 在塑胶跑道上穿跑鞋,B 在沙滩上赤脚。
一个规范的性能报告应当包含以下字段:
| 字段 | 示例写法 | 缺少时的风险 |
|---|---|---|
| task_id | Mjlab-Velocity-Flat-Unitree-Go1 |
不同任务不可比 |
| robot | Unitree Go1 (12-DOF) | DOF 不同性能差异巨大 |
| sensors | height_scan 121 rays + contact | sensor 是主要性能瓶颈 |
| num_envs | 4096 | 不同 env 数吞吐不同 |
| GPU | NVIDIA A100 40GB | 不同 GPU 不可比 |
| framework_version | mjlab v0.3.1 / Isaac Lab v2.1 | 版本间性能可能差异 20%+ |
| warmup_steps | 100 steps (excluded) | 包含 warmup 会拉低平均值 |
| measurement_steps | 1000 steps | 太少方差大 |
| metric | env_sps (steady-state) | env_sps ≠ train_sps |
| CUDA Graph | enabled / disabled | graph 启用后快 30-50% |
| viewer/video | disabled | 开启 viewer 性能降 2-5× |
规范的性能结论示例:"在 Mjlab-Velocity-Flat-Unitree-Go1 任务上,使用 RTX 4090、4096 envs、无 viewer/video、121-ray height scan、100 步 warmup 后测量 1000 步,steady-state env_sps 为 185,000。该数字只代表环境步吞吐,不代表训练收敛速度。"
显存估算与 num_envs 上限 ⭐⭐
# === 显存估算公式 ===
def estimate_vram(
num_envs: int,
num_joints: int,
nconmax: int,
policy_params: int, # actor + critic 参数量
obs_dim: int,
num_steps: int = 24, # PPO rollout length
) -> float:
"""估算训练所需的 GPU 显存 (MB)。"""
# Per-env 物理状态(qpos + qvel + qacc + ctrl + sensor)
physics_per_env = (num_joints * 4 + nconmax * 10) * 4 # bytes, float32
# Per-env obs/action/reward rollout buffer
rollout_per_env = (obs_dim + num_joints + 1) * num_steps * 4
# Policy 网络参数 + optimizer states
policy_mem = policy_params * 4 * 3 # param + grad + optimizer state
# 总计
total_mb = (
num_envs * (physics_per_env + rollout_per_env) / 1e6
+ policy_mem / 1e6
)
return total_mb
# 示例:G1 29-DOF, 4096 envs, AGILE 网络
vram = estimate_vram(
num_envs=4096, num_joints=29, nconmax=35,
policy_params=500000, obs_dim=48,
)
print(f"Estimated VRAM: {vram:.0f} MB")
# 按本公式实际算出约 ~44 MB(physics≈1864 B/env、rollout≈7488 B/env,4096 envs≈38 MB,再加 ~6 MB 网络)。
# ⚠️ 这只是一个"下界玩具估算":它严重低估真实训练显存——未计入 CUDA context、MJWarp data/constraint
# buffers、sensor buffers、actor/critic 前向激活、Adam 的两组动量状态、多个 obs group、logging/video 等。
# 真实显存请用 torch.cuda.max_memory_allocated() / nvidia-smi / 官方 benchmark 脚本实测,不要据此公式判断能否放下 4096 envs。
# 估算最大 num_envs
for n in [4096, 8192, 16384, 32768]:
v = estimate_vram(n, 29, 35, 500000, 48)
print(f" {n} envs: ~{v:.0f} MB {'✅' if v < 38000 else '❌ OOM'}")
性能优化确保了训练"跑得快"。但如果训练是在云端进行的,还需要管理集群生命周期和成本控制——这是下节的内容。
24.5 云端训练与成本控制 ⭐⭐
这一节解决什么问题:如何在云端 GPU 上运行训练?如何控制成本?
SkyPilot 云端训练 ⭐⭐
SkyPilot 是 mjlab 和 Isaac Lab 社区常用的云端 GPU 编排工具——它抽象了 AWS/GCP/Azure 等不同云厂商的 GPU 资源,用统一的 YAML 配置启动训练。
# === sky_train.yaml: SkyPilot 训练配置 ===
name: go1-velocity-training
resources:
accelerators: A100:1 # 1 块 A100
use_spot: true # 使用 spot 实例节省成本
disk_size: 100 # GB
setup: |
# 安装 mjlab
pip install uv
uvx --from mjlab demo # 验证安装
run: |
# 训练
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 10000 \
--agent.run-name cloud_go1_flat \
--agent.logger wandb
# 训练完成后自动上传 checkpoint
uv run upload-artifacts logs/rsl_rl/
# === SkyPilot 命令 ===
# 启动训练:YAML 里的 name 只是 task name(仅用于展示);
# 不带 -c/--cluster 时 SkyPilot 会自动生成 cluster name。sky logs/down 针对的是 cluster name,
# 因此显式用 -c 指定一个固定 cluster name,后续命令才能稳定引用。
sky launch -c go1-velocity-training sky_train.yaml
# 查看状态
sky status
# 查看日志(针对 cluster name)
sky logs go1-velocity-training
# 停止并释放资源(重要!忘记会持续计费)
sky down go1-velocity-training
云端成本控制 ⭐⭐
| GPU 类型 | 按需价格 ($/hr) | Spot 价格 ($/hr) | 4096 envs 四足 10k iter 时间 | 总成本 |
|---|---|---|---|---|
| A100 40GB | ~3.0 | ~1.0 | ~2 hr | ~$2-6 |
| A100 80GB | ~4.0 | ~1.5 | ~2 hr | ~$3-8 |
| H100 | ~5.0 | ~2.0 | ~1.5 hr | ~$3-10 |
| L40S | ~1.5 | ~0.5 | ~3 hr | ~$1.5-4.5 |
成本控制清单:
- [ ] 使用 spot 实例(节省 50-70%),但需要处理中断
- [ ] 训练完成后立即
sky down——idle 也计费 - [ ] 设置
max_iterations上限——防止训练跑飞 - [ ] 先用小 num_envs smoke test 确认能跑,再启动大规模训练
- [ ] 使用 WandB 远程监控——不需要 SSH 进去看日志
反事实推理:如果忘记
sky down会怎样? SkyPilot 启动的 GPU 实例不会自动关闭(除非配置了自动关闭策略)。一块 A100 闲置一天的成本是 ~\(72。一个周五下午忘记关的实例到周一上午已经烧掉了 ~\)200。正确做法:在 WandB 的训练完成 callback 中自动执行sky down,或设置最大运行时间。
WandB Sweep 超参搜索 ⭐
当你需要搜索多组超参时,WandB Sweep 可以自动化管理:
# === wandb_sweep.yaml ===
# 注意:W&B Sweep 配置中 program 是必填项——agent 需要知道启动哪个脚本;
# 否则 sweep 创建后 agent 不知道如何运行训练命令。command 用于把超参映射到 CLI。
program: train
command:
- ${env}
- uv
- run
- train
- Mjlab-Velocity-Flat-Unitree-G1
- --agent.algorithm.learning-rate=${learning_rate}
- --env.actions.base_velocity.scale=${action_scale}
method: bayes # 贝叶斯优化
metric:
name: reward_mean
goal: maximize
parameters:
learning_rate:
min: 0.0001
max: 0.01
action_scale:
values: [0.15, 0.2, 0.25, 0.3]
entropy_coef:
min: 0.001
max: 0.02
reward_tracking_weight:
values: [1.0, 1.5, 2.0]
# 创建 sweep
wandb sweep wandb_sweep.yaml
# 输出: wandb sweep agent <SWEEP_ID>
# 在每块 GPU 上启动一个 agent
CUDA_VISIBLE_DEVICES=0 wandb agent <SWEEP_ID> &
CUDA_VISIBLE_DEVICES=1 wandb agent <SWEEP_ID> &
CUDA_VISIBLE_DEVICES=2 wandb agent <SWEEP_ID> &
CUDA_VISIBLE_DEVICES=3 wandb agent <SWEEP_ID> &
# 4 个 agent 并行搜索超参
⚠️ 常见陷阱
⚠️ 成本陷阱:Spot 实例被中断丢失 checkpoint - Spot 实例可能被云厂商随时回收 - 正确做法:每 500 iterations 自动保存 checkpoint + 上传到 WandB 或 S3
⚠️ 编程陷阱:Sweep agent 抢同一 GPU
- 多个 agent 不加 CUDA_VISIBLE_DEVICES 限制,全部用 GPU 0
- 正确做法:每个 agent 绑定独立 GPU
练习
- [设计题] 为一篇论文的 3 seed × 4 配置 = 12 个实验设计云端训练方案。假设单个实验需要 A100 × 2 小时。计算总 GPU-hours 和预估成本(spot 价格)。
- [实践题] 在本地用
sky launch启动一次小规模 smoke test(10 iterations)。验证sky down是否成功释放资源。记录 provision、setup、run 各阶段耗时。
24.6 实验管理与可复现性 ⭐⭐
这一节解决什么问题:如何组织大规模训练的实验,使每个结论都可追溯到具体的配置和数据?
运行包协议 ⭐⭐
每次训练都应该产出一个可交接的"运行包"——包含复现该训练所需的所有信息。
runs/go1_flat_v2_seed0/
├── command.txt ← 完整命令(从 shell history 复制,不是回忆版)
├── sample_ledger.yaml ← 样本量核算
├── params/
│ ├── env_cfg.yaml ← rank 0 写出的完整环境配置快照
│ └── agent_cfg.yaml ← PPO 超参
├── git_info.txt ← commit hash + branch + status
├── git_diff.patch ← 完整未提交 patch(git diff --binary,可复现修改)
├── logs/
│ ├── tensorboard/ ← TensorBoard events
│ └── wandb/ ← WandB 本地备份
├── checkpoints/
│ ├── model_5000.pt
│ └── model_10000.pt
├── eval/
│ ├── eval_report.html ← AGILE Stage 3 评估报告
│ └── eval_videos/ ← 评估视频
└── deployment/
├── policy.onnx
└── deploy.yaml
样本量账本(sample_ledger.yaml)是运行包的核心——没有它,任何关于"多少数据"或"收敛快慢"的结论都无法验证:
# sample_ledger.yaml
num_envs_per_gpu: 4096
num_steps_per_env: 24
num_gpus: 1
max_iterations: 10000
samples_per_update: 98304 # 4096 * 24
total_env_steps: 983040000 # 98304 * 10000
comparison_mode: same_total_samples
x_axis: env_steps # 论文中的 x 轴
seed: 42
wall_time_hours: 2.3
gpu_type: A100-40GB
framework: mjlab
自动化实验记录 ⭐⭐
# === 自动化实验记录 ===
import subprocess
import yaml
import os
def create_run_package(run_dir, env_cfg, agent_cfg, command):
"""创建标准化的运行包。"""
os.makedirs(run_dir, exist_ok=True)
# 1. 保存命令
with open(f"{run_dir}/command.txt", "w") as f:
f.write(command)
# 2. 保存 Git 信息
try:
git_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"]).decode().strip()
git_branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode().strip()
# 注意:--stat 只记录文件/行数统计,无法复现未提交修改。
# 要能复现,必须保存完整 patch(--binary 兼顾二进制改动),并记录 status。
git_status = subprocess.check_output(
["git", "status", "--porcelain=v1"]).decode()
git_diff = subprocess.check_output(
["git", "diff", "--binary"]).decode()
git_diff_cached = subprocess.check_output(
["git", "diff", "--cached", "--binary"]).decode()
with open(f"{run_dir}/git_info.txt", "w") as f:
f.write(f"commit: {git_hash}\n")
f.write(f"branch: {git_branch}\n")
f.write(f"status:\n{git_status}\n")
with open(f"{run_dir}/git_diff.patch", "w") as f:
f.write(git_diff)
f.write(git_diff_cached)
except subprocess.CalledProcessError:
print("⚠️ Git info not available")
# 3. 保存配置快照
# 注意:vars() 只取浅层 __dict__,对嵌套 configclass/dataclass、tensor、callable 字段无法生成可复现 YAML。
# 优先用框架自带的递归导出工具(Isaac Lab: isaaclab.utils.io.dump_yaml / class_to_dict;
# 或 configclass.to_dict()),下面的 vars() 仅作占位示意。
os.makedirs(f"{run_dir}/params", exist_ok=True)
with open(f"{run_dir}/params/env_cfg.yaml", "w") as f:
yaml.dump(vars(env_cfg), f, default_flow_style=False)
with open(f"{run_dir}/params/agent_cfg.yaml", "w") as f:
yaml.dump(vars(agent_cfg), f, default_flow_style=False)
# 4. 计算样本量账本
num_envs = env_cfg.scene.num_envs
num_steps = agent_cfg.runner.num_steps_per_env
num_gpus = len(env_cfg.gpu_ids) if hasattr(env_cfg, 'gpu_ids') else 1
max_iters = agent_cfg.runner.max_iterations
ledger = {
"num_envs_per_gpu": num_envs,
"num_steps_per_env": num_steps,
"num_gpus": num_gpus,
"max_iterations": max_iters,
"samples_per_update": num_envs * num_steps * num_gpus,
"total_env_steps": num_envs * num_steps * num_gpus * max_iters,
}
with open(f"{run_dir}/sample_ledger.yaml", "w") as f:
yaml.dump(ledger, f)
print(f"✅ Run package created: {run_dir}")
return run_dir
公平实验对比的原则 ⭐⭐
在论文或报告中对比不同配置的训练结果时,必须确保对比的公平性:
| 维度 | 公平做法 | 不公平做法 |
|---|---|---|
| 样本量 | 相同 total env steps | 相同 iterations(但每 iter 样本量不同) |
| Seed | 多 seed (≥3) + 标准差 | 单 seed |
| x 轴 | env_steps 或 wall_time | iterations(不同 GPU 数时误导) |
| 基准 | 冻结的 baseline(不随实验修改) | 每次实验都调整 baseline |
| 配置 | 只改一个变量 | 同时改多个变量 |
结论分级:不是所有结论都有相同的可信度。
| 层级 | 需要什么 | 可以说什么 |
|---|---|---|
| L1 | smoke test 通过 | "配置可启动" |
| L2 | 单 seed 训练完成 | "reward 有上升趋势" |
| L3 | 3+ seed 完成 + 标准差 | "方法 A 的平均 reward 高于方法 B" |
| L4 | 消融实验 + 统计检验 | "改进来自组件 X(p<0.05)" |
| L5 | 跨任务/机器人验证 | "方法具有泛化性" |
失败案例库 ⭐⭐
以下是多 GPU 和大规模训练中的常见失败案例——从中可以学到工程直觉。
案例 1: GPU id 映射误解
- 背景:CUDA_VISIBLE_DEVICES=2,3 后仍传 --gpu-ids "[2, 3]"
- 症状:程序看不到逻辑 id 2 和 3,报设备不可见
- 修复:传 [0, 1]
- 教训:日志中 cuda:0 不等于物理 GPU 0
案例 2: 多 GPU 样本量不公平
- 背景:1 GPU 与 4 GPU 都跑 6000 iterations
- 症状:4 GPU 曲线更好被误写成"算法更优"
- 修复:按目标缩放 max_iterations
- 教训:报告中必须写 total env steps 和比较口径
案例 3: 云端 job 失败仍计费
- 背景:run step 崩溃后集群保持 UP
- 症状:sky status 仍显示运行
- 修复:立刻 sky down
- 教训:成本 checklist 必须执行
案例 4: 每个 rank 都写视频 - 背景:自定义代码没有 rank 判断 - 症状:视频文件损坏且 I/O 拖慢训练 - 修复:只在 rank 0 创建 recorder - 教训:所有副作用先问 rank
案例 5: Sweep agent 抢同一 GPU - 背景:多个 agent 手工启动没有限制资源 - 症状:利用率冲突,曲线互相污染 - 修复:每个 agent 绑定独立 GPU - 教训:agent 数量等于 GPU 数
故障分流表 ⭐⭐
| 失败层 | 典型现象 | 第一证据 | 优先处理 |
|---|---|---|---|
| CLI/配置 | 参数不识别 | stdout 报错信息 | 修命令或 registry |
| GPU 选择 | invalid device | CUDA_VISIBLE_DEVICES 值 |
修 --gpu-ids |
| torchrunx | rank 未启动 | torchrunx 目录下的 rank 日志 | 修 launch 配置 |
| MuJoCo Warp | 某 rank 崩溃 | rank stderr、NaN dump | 转 §24.3 流程 |
| RSL-RL | all-reduce 报错 | runner 日志 | 查 batch/world size |
| Logger | W&B 登录失败 | W&B stderr | 切 TensorBoard 或修凭据 |
| Cloud | capacity/setup/billing | SkyPilot status/logs | 换资源或 teardown |
分流能减少无效调参。云端 capacity 不足不是 PPO 参数问题。W&B 登录失败不是模型问题。Rank 1 的 CUDA crash 不一定能从 rank 0 曲线看出来。
⚠️ 常见陷阱
⚠️ 思维陷阱:不记录实验就开始调参 - "跑了十次实验,第三次效果最好但不记得配置是什么" - 正确做法:每次实验自动生成运行包,用 WandB 或 Git 追踪
⚠️ 编程陷阱:Checkpoint 名字不匹配
- Resume 时传 model_latest.pt,但 run files 只有 model_500.pt
- 正确做法:显式指定存在的 checkpoint 文件名
练习
- [设计题] 为一个双 GPU 同总样本量实验创建完整的
sample_ledger.yaml。验证单 GPU baseline 和双 GPU variant 的total_env_steps相等。 - [分析题] 一个报告写"双 GPU 训练更快达到 90% success rate",但没有 world_size、num_steps_per_env 和 x_axis。列出需要补充的字段,并解释缺少每个字段会导致什么误读。
- [实践题] 使用
create_run_package()为你的一次训练生成完整运行包。检查运行包是否包含复现该训练的所有必要信息。
24.7 精读:AGILE 四阶段工业级 Workflow ⭐⭐⭐
这一节解决什么问题:AGILE(NVIDIA, arXiv:2603.20147)提出了一个覆盖"准备→训练→评估→部署"全流程的工业级 workflow。它如何组织大规模训练的每个环节?
AGILE 的四阶段架构 ⭐⭐
AGILE(缩写展开为 A Generic Isaac-Lab based Engine;论文题名为 A Comprehensive Workflow for Humanoid Loco-Manipulation Learning)是 NVIDIA 在 2026 年发布的人形 RL 工程化框架。它不是一个算法——而是一套标准化的工程流程,把大规模训练中的每个环节(调试、训练、评估、部署)形式化为可重复的阶段。
Stage 1: Prepare (准备)
─────────────────────
├── Joint Position GUI: 逐关节 slider 测试
├── Object Manipulation GUI: 6-DOF 物体交互
├── Reward Visualizer: 逐项 reward 实时叠加
└── 目标: 在几分钟内发现配置 bug
Stage 2: Train (训练)
─────────────────────
├── Git commit + branch + diff 自动记录
├── Docker 化训练环境(可复现)
├── WandB 日志 + checkpoint 管理
├── Scaled-dict 超参搜索
└── 目标: 可复现的训练
Stage 3: Evaluate (评估)
───────────────────────
├── 确定性场景测试(fixed commands)
├── 随机性 rollout(1k envs, randomized)
├── 运动质量诊断:
│ ├── RMS 关节加速度
│ ├── RMS jerk
│ ├── 关节限位违规率
│ └── 高频能量比(>10 Hz 占比)
└── 目标: 自动化的 HTML 评估报告
Stage 4: Deploy (部署)
─────────────────────
├── TorchScript / ONNX 导出
├── 自动生成 YAML descriptor
│ ├── joint_names
│ ├── observation_ordering
│ ├── history_buffer_length
│ └── action_scaling
├── Sim2Sim 验证
└── 真机 C++ controller
AGILE 的算法工具箱 ⭐⭐⭐
AGILE 提供了一系列可开关的 PPO 增强技术——每个都有明确的适用场景和默认参数。
L2C2(Local Lipschitz Constraint for Continuous Control):
# === L2C2: 平滑策略的正则化 ===
# 思想:相邻状态的策略输出应该相似(Lipschitz 约束)
# 实现:在 PPO update 时,对 obs 做微小扰动,要求输出变化也小
def l2c2_loss(policy, obs, obs_next, lambda_pi=1.0, lambda_v=0.1):
"""L2C2 正则化损失。
在 obs_t 和 obs_{t+1} 之间插值,
要求插值点的 policy 输出和 obs_t 的输出接近。
"""
alpha = torch.rand(obs.shape[0], 1, device=obs.device)
obs_interp = obs + alpha * (obs_next - obs)
# 策略平滑性
action_t = policy.actor(obs)
action_interp = policy.actor(obs_interp)
pi_loss = lambda_pi * (action_interp - action_t).pow(2).mean()
# 价值函数平滑性(如果 critic 有 privileged obs)
if hasattr(policy, 'critic'):
v_t = policy.critic(obs) # 简化,实际用 privileged obs
v_interp = policy.critic(obs_interp)
v_loss = lambda_v * (v_interp - v_t).pow(2).mean()
else:
v_loss = 0.0
return pi_loss + v_loss
在线 Reward 归一化:
# === AGILE 的 reward normalization ===
# 问题:不同 reward term 的量级差异很大
# 解决:EMA 估计 reward 的标准差,动态归一化
class OnlineRewardNormalizer:
"""AGILE 风格的在线 reward 归一化。"""
def __init__(self, gamma=0.99, beta=0.999, epsilon=0.01):
self.sigma = 1.0 # EMA 估计的 reward std
self.beta = beta
self.epsilon = epsilon
# 折扣回报修正因子
self.phi_gamma = 1.0 / np.sqrt(1 - gamma**2)
def normalize(self, reward):
"""归一化 reward。"""
# EMA 更新 sigma
batch_var = reward.var().item()
self.sigma = self.beta * self.sigma + (1 - self.beta) * np.sqrt(batch_var)
# 归一化
return reward / (self.sigma * self.phi_gamma + self.epsilon)
Virtual Harness(虚拟安全绳):
# === Virtual Harness: 训练初期的辅助力 ===
# 思想:在训练初期用外部 PD 力辅助机器人站立,
# 随训练进展逐渐衰减到零——类似婴儿学步器
class VirtualHarness:
"""虚拟安全绳——训练初期的辅助力。"""
def __init__(self, kp_pos=100, kd_pos=10, kp_rot=50, kd_rot=5,
decay_mode="exponential", decay_iters=2000):
self.kp_pos = kp_pos
self.kd_pos = kd_pos
self.kp_rot = kp_rot
self.kd_rot = kd_rot
self.decay_mode = decay_mode
self.decay_iters = decay_iters
def get_scale(self, iteration):
"""计算当前迭代的 harness 强度 s ∈ [0, 1]。"""
t = min(iteration / self.decay_iters, 1.0)
if self.decay_mode == "linear":
return 1.0 - t
elif self.decay_mode == "exponential":
return np.exp(t * np.log(0.01)) # 从 1.0 衰减到 0.01
elif self.decay_mode == "adaptive":
# 只有当站立比例 > 阈值时才衰减
return None # 需要外部 standing_ratio 驱动
def compute_force(self, env, iteration):
"""计算辅助力/力矩。"""
s = self.get_scale(iteration)
if s is None or s < 0.01:
return torch.zeros_like(env.robot.data.root_link_lin_vel_w)
# 位置 PD:把 base 拉向目标高度
pos_error = env.target_height - env.robot.data.root_link_pos_w[:, 2:3]
vel = env.robot.data.root_link_lin_vel_w[:, 2:3]
f_z = s * (self.kp_pos * pos_error - self.kd_pos * vel)
# 姿态 PD:保持直立
rpy = env.robot.data.root_link_euler_w
ang_vel = env.robot.data.root_link_ang_vel_w
tau_rp = s * (self.kp_rot * (-rpy[:, :2]) - self.kd_rot * ang_vel[:, :2])
return f_z, tau_rp
Virtual Harness 对人形站立训练特别有价值——没有它,人形在训练初期几乎立刻摔倒(因为策略还是随机的),产生的数据全是"摔倒"的经历,PPO 无法从中学到"如何站立"。Harness 给予辅助力让人形在初期能保持站立,策略可以在站立状态下探索——随着训练进展,辅助力逐渐衰减,策略必须自己维持平衡。
Value-Bootstrapped Terminations:
# === 防"自杀"的 termination 处理 ===
# 问题:terminal state 的 V(s) = 0(按 RL 定义)
# 但如果 termination 是因为摔倒(负 reward 状态),
# 策略可能学到"早点摔倒结束 episode"来避免累积更多负 reward
# AGILE 的解决方案:terminal state 也 bootstrap V(s)
def value_bootstrapped_terminal(reward, value, done, gamma=0.99, sigma=5.0):
"""对 terminal state 做 value bootstrapping。
不把 terminal V(s) 设为 0,而是设为一个大负值
(通过 value prediction + penalty),阻止策略主动摔倒。
"""
# 正常 GAE 计算中,done=True 时 V(s') = 0
# 修改为:done=True 时 V(s') = V_predicted - sigma
# sigma=5 在 gamma=0.99 下约等于 value space 中的 500
# 这让"摔倒"的代价远大于"继续活着"
terminal_penalty = done.float() * sigma
adjusted_value = value - terminal_penalty
return adjusted_value
AGILE 的 PPO 默认配置 ⭐⭐
以下是 AGILE 在 Unitree G1 和 Booster T1 上验证过的 PPO 默认配置——可以作为新项目的起点:
| 参数 | AGILE 默认值 | 说明 |
|---|---|---|
| Actor 网络 | [256, 256, 128], ELU | 比 [256, 128] 多一层,容量更大 |
| Critic 网络 | [512, 256, 128], ELU | Critic 更大(处理 privileged obs) |
| Learning rate | 1e-3 | 自适应 LR schedule |
| γ (discount) | 0.99(locomotion),0.995(stand-up) | 长时程任务需要更大 γ |
| GAE λ | 0.95 | 标准值 |
| Clip ε | 0.2 | 标准值 |
| Epochs per update | 5 | |
| Mini-batches | 4 | |
| Entropy coeff | 0.005 | 比默认 0.01 略小 |
| Num envs | 4096 | |
| Num steps per env | 24 | |
| Max iterations | ~20k |
AGILE 与本书前序章节的对应 ⭐⭐
| AGILE 阶段 | 本书对应 | 章节 |
|---|---|---|
| Stage 1: Prepare (Joint GUI) | Zero agent + 逐关节测试 | Ch22 §22.2 |
| Stage 1: Prepare (Reward Viz) | 分项 reward 打印 | Ch06 §06.7 |
| Stage 2: Train (reproducible) | Git hash + WandB 日志 | Ch07 §07.5 |
| Stage 2: Train (scaled-dict) | Reward 权重搜索 | Ch06 §06.7 |
| Stage 3: Evaluate (rollout) | Sim2Sim 验证 | Ch23 §23.5 |
| Stage 3: Evaluate (motion quality) | 行为质量诊断 | Ch25 §25.1 |
| Stage 4: Deploy (export) | ONNX 导出 + metadata | Ch23 §23.3 |
| Stage 4: Deploy (descriptor) | deploy.yaml | Ch23 §23.5 |
AGILE Stage 3: 运动质量诊断代码 ⭐⭐⭐
AGILE 的 Stage 3 不只看 reward 曲线——它还计算一系列运动质量指标(motion quality metrics),用于判断策略的行为是否适合真机部署:
# === AGILE Stage 3: 运动质量诊断 ===
def evaluate_motion_quality(env, policy, num_episodes=100):
"""AGILE 风格的运动质量评估。"""
metrics = {
"rms_joint_acc": [], # 关节加速度的 RMS
"rms_jerk": [], # 关节 jerk (加速度的导数) 的 RMS
"joint_limit_violations": [], # 关节角超限次数
"hf_energy_ratio": [], # 高频 (>10 Hz) 能量占比
"tracking_error": [], # 速度跟踪误差
"survival_time": [], # 存活时间
}
obs, _ = env.reset()
ep_step = torch.zeros(env.num_envs, device=env.device)
prev_vel = torch.zeros_like(env.robot.data.joint_vel)
prev_acc = torch.zeros_like(env.robot.data.joint_vel)
for step in range(5000):
with torch.no_grad():
action = policy(obs['policy'])
obs, reward, done, truncated, info = env.step(action)
ep_step += 1
# 关节加速度
curr_vel = env.robot.data.joint_vel
acc = (curr_vel - prev_vel) / env.dt
prev_vel = curr_vel.clone()
# Jerk
jerk = (acc - prev_acc) / env.dt
prev_acc = acc.clone()
# 记录指标
if step > 10: # 跳过初始化阶段
metrics["rms_joint_acc"].append(acc.pow(2).mean(dim=-1).sqrt().mean().item())
metrics["rms_jerk"].append(jerk.pow(2).mean(dim=-1).sqrt().mean().item())
# 关节限位违规
joint_pos = env.robot.data.joint_pos
lo = env.robot.data.soft_joint_pos_limits[..., 0]
hi = env.robot.data.soft_joint_pos_limits[..., 1]
violations = ((joint_pos < lo) | (joint_pos > hi)).float().sum(dim=-1)
metrics["joint_limit_violations"].append(violations.mean().item())
# 记录完成的 episode
finished = done | truncated
if finished.any():
for idx in finished.nonzero(as_tuple=True)[0]:
metrics["survival_time"].append(
ep_step[idx].item() * env.dt
)
ep_step[finished] = 0
# 高频能量分析
acc_array = np.array(metrics["rms_joint_acc"])
if len(acc_array) > 100:
# FFT 分析
from scipy.fft import rfft, rfftfreq
spectrum = np.abs(rfft(acc_array))
freqs = rfftfreq(len(acc_array), d=env.dt)
high_freq_mask = freqs > 10.0 # >10 Hz
hf_energy = spectrum[high_freq_mask].sum()
total_energy = spectrum.sum()
hf_ratio = hf_energy / (total_energy + 1e-8)
metrics["hf_energy_ratio"] = [hf_ratio]
# 汇总报告
print("=" * 60)
print(" AGILE Stage 3: Motion Quality Report")
print("=" * 60)
print(f" RMS Joint Acceleration: {np.mean(metrics['rms_joint_acc']):.2f} rad/s²")
print(f" (< 50 rad/s² 为佳, > 100 可能有高频振荡)")
print(f" RMS Jerk: {np.mean(metrics['rms_jerk']):.0f} rad/s³")
print(f" (< 500 为佳, > 2000 表示动作不平滑)")
print(f" Joint Limit Violations: {np.mean(metrics['joint_limit_violations']):.2f} per step")
print(f" (0 为最佳, > 0.5 表示策略不尊重关节限位)")
if metrics["hf_energy_ratio"]:
hf = metrics["hf_energy_ratio"][0]
print(f" High-Freq Energy Ratio (>10Hz): {hf:.1%}")
print(f" (< 10% 为佳, > 30% 表示策略有高频抖动)")
print(f" Avg Survival Time: {np.mean(metrics['survival_time']):.1f} s")
print(f" Completed Episodes: {len(metrics['survival_time'])}")
# 判定
issues = []
if np.mean(metrics['rms_joint_acc']) > 100:
issues.append("⚠️ 关节加速度过高 → 增大 action_rate penalty")
if np.mean(metrics['rms_jerk']) > 2000:
issues.append("⚠️ Jerk 过高 → 增大 action 平滑惩罚")
if np.mean(metrics['joint_limit_violations']) > 0.5:
issues.append("⚠️ 关节限位违规 → 增大 dof_pos_limits penalty")
if metrics["hf_energy_ratio"] and metrics["hf_energy_ratio"][0] > 0.3:
issues.append("⚠️ 高频能量占比过高 → 检查 PD gains 和 timestep")
if issues:
print("\n ⚠️ 发现以下运动质量问题:")
for issue in issues:
print(f" {issue}")
else:
print("\n ✅ 运动质量诊断通过")
return metrics
运动质量判定标准:
| 指标 | 良好 | 可接受 | 需要修复 |
|---|---|---|---|
| RMS 关节加速度 | < 50 rad/s² | 50-100 | > 100 |
| RMS Jerk | < 500 rad/s³ | 500-2000 | > 2000 |
| 关节限位违规 | 0 per step | < 0.1 | > 0.5 |
| 高频能量比 | < 10% | 10-30% | > 30% |
这些指标直接影响 sim2real 的成功率——如果仿真中就有高频振荡或关节限位违规,真机上的表现只会更差(因为真实电机响应比仿真慢)。
本质洞察:AGILE 的核心贡献不是某个新算法,而是把"好的工程实践"标准化为可强制执行的流程。在没有 AGILE 的情况下,一个团队的训练质量取决于最有经验的成员是否在场;有了 AGILE,即使新手也能通过遵循四阶段流程达到接近专家的工程质量。这就是"流程"相对于"经验"的价值——它让质量不再依赖个人。
AGILE 的对称性增强 ⭐⭐
四足和人形机器人通常具有左右对称性——左前腿和右前腿的结构完全相同(镜像)。策略如果学到了左腿的控制,理论上应该能直接镜像应用到右腿。对称性增强(Symmetry Augmentation)利用这个结构先验,在训练数据中注入镜像样本。
# === 对称性增强 ===
class SymmetryAugmentation:
"""AGILE / WoCoCo 风格的对称性增强。
在每次 PPO update 前,把 rollout buffer 中的数据
镜像一份(左右互换),加入训练 batch。
效果:相当于 2× 数据量 + 强制左右对称行为。
"""
def __init__(self, obs_mirror_indices, action_mirror_indices,
obs_negate_indices=None, action_negate_indices=None):
"""
Args:
obs_mirror_indices: obs 中左右互换的维度对
例如: [(3, 9), (4, 10), (5, 11)] 表示
dim 3↔9, 4↔10, 5↔11 互换
action_mirror_indices: action 中左右互换的维度对
obs_negate_indices: obs 中镜像后需要取反的维度
例如: lateral velocity 在镜像后方向反转
action_negate_indices: action 中镜像后需要取反的维度
"""
self.obs_swap = obs_mirror_indices
self.act_swap = action_mirror_indices
self.obs_neg = obs_negate_indices or []
self.act_neg = action_negate_indices or []
def mirror_obs(self, obs):
"""镜像 observation。"""
mirrored = obs.clone()
for i, j in self.obs_swap:
mirrored[:, i], mirrored[:, j] = obs[:, j].clone(), obs[:, i].clone()
for idx in self.obs_neg:
mirrored[:, idx] *= -1
return mirrored
def mirror_action(self, action):
"""镜像 action。"""
mirrored = action.clone()
for i, j in self.act_swap:
mirrored[:, i], mirrored[:, j] = action[:, j].clone(), action[:, i].clone()
for idx in self.act_neg:
mirrored[:, idx] *= -1
return mirrored
def augment_batch(self, obs_batch, action_batch, reward_batch, **kwargs):
"""把原始 batch 和镜像 batch 拼接。"""
mirror_obs = self.mirror_obs(obs_batch)
mirror_action = self.mirror_action(action_batch)
# reward 不变(左右对称的行为应该获得相同的 reward)
aug_obs = torch.cat([obs_batch, mirror_obs], dim=0)
aug_action = torch.cat([action_batch, mirror_action], dim=0)
aug_reward = torch.cat([reward_batch, reward_batch], dim=0)
return aug_obs, aug_action, aug_reward
# 使用示例(四足机器人)
# FL/FR 和 HL/HR 的关节索引互换
symmetry = SymmetryAugmentation(
obs_mirror_indices=[
(3, 6), (4, 7), (5, 8), # FL ↔ FR joint pos
(9, 12), (10, 13), (11, 14), # HL ↔ HR joint pos
# ... joint vel 同理 ...
],
action_mirror_indices=[
(0, 3), (1, 4), (2, 5), # FL ↔ FR action
(6, 9), (7, 10), (8, 11), # HL ↔ HR action
],
obs_negate_indices=[1], # lateral velocity 取反
action_negate_indices=[],
)
对称性增强的效果:在四足 locomotion 中,开启对称性增强通常能: - 减少步态不对称(左右腿行为一致) - 加速收敛 15-30%(等效于 2× 数据量) - 提高 sim2real 成功率(真机的左右腿硬件差异更小于仿真随机性)
注意事项:对称性增强假设 reward 对左右对称——如果你的 reward 本身不对称(如"只用右手抓取"),不要开启对称性增强。
AGILE 的状态缓存训练技巧 ⭐⭐
对于人形 stand-up 任务,策略需要从各种倒地姿态恢复站立。传统方法是在训练中让机器人自己摔倒产生不同的起始姿态——但这很慢,因为"摔倒"本身需要仿真时间。AGILE 的 State Caching 技巧是:
# === State Caching: 预生成多样化初始状态 ===
def generate_diverse_initial_states(env, num_states=10000):
"""通过随机 rollout 预生成多样化的初始状态。
运行一次随机策略 rollout,在不同时间点保存物理状态,
之后的训练直接从这些保存的状态 reset——
不需要每次都重新模拟"摔倒"过程。
"""
obs, _ = env.reset()
states = []
for step in range(num_states * 10):
# 随机动作 + 外部扰动(产生各种姿态)
action = torch.randn(env.num_envs, env.action_space.shape[-1],
device=env.device) * 2.0
obs, _, done, _, _ = env.step(action)
# 每隔 10 步保存一次状态
if step % 10 == 0:
state = {
"qpos": env.robot.data.joint_pos.clone(),
"qvel": env.robot.data.joint_vel.clone(),
"root_pos": env.robot.data.root_link_pos_w.clone(),
"root_quat": env.robot.data.root_link_quat_w.clone(),
}
states.append(state)
if len(states) >= num_states:
break
print(f"Generated {len(states)} diverse initial states")
# 分析姿态分布
heights = [s["root_pos"][:, 2].mean().item() for s in states]
print(f" Height range: [{min(heights):.2f}, {max(heights):.2f}] m")
print(f" States with height < 0.3m (fallen): "
f"{sum(1 for h in heights if h < 0.3)} / {len(states)}")
return states
# 在训练中使用缓存的状态做 reset
class CachedStateReset:
"""从预缓存的状态中随机选取做 reset。"""
def __init__(self, cached_states):
self.states = cached_states
def reset(self, env, env_ids):
# 随机选取一个缓存状态
idx = torch.randint(0, len(self.states), (1,)).item()
state = self.states[idx]
# 设置物理状态
env.robot.write_root_state_to_sim(
state["root_pos"][env_ids],
state["root_quat"][env_ids],
)
env.robot.write_joint_state_to_sim(
state["qpos"][env_ids],
state["qvel"][env_ids],
)
State Caching 的工程价值:对于 stand-up 任务,传统方法需要每次 episode 先"摔倒 2 秒"再"站起来 3 秒"——摔倒的 2 秒是浪费的(策略不学习任何东西)。State Caching 直接从倒地状态开始——节省 40% 的仿真时间。
本质洞察:AGILE 不是新算法——而是把本书前 23 章教过的零散工程最佳实践组织成一条流水线。如果你已经掌握了 Ch01-Ch23 的内容,AGILE 的每个阶段你都能理解并实现。AGILE 的价值在于形式化和标准化——把"好的工程习惯"变成"必须遵循的流程步骤",从而在团队协作中确保一致性。
AGILE 的 Scaled-Dict 超参搜索 ⭐⭐
传统的超参搜索对每个参数独立搜索——如果有 10 个 reward 权重,搜索空间是 10 维。AGILE 的 scaled-dict 技巧把搜索空间压缩到 1 维:
# === Scaled-Dict 超参搜索 ===
# 思想:reward 权重之间的相对比例通常是合理的
# 只搜索一个全局缩放因子来调整整组权重
class ScaledDictSearch:
"""把 N 维 reward 权重搜索压缩为 1 维。"""
def __init__(self, base_weights: dict):
self.base = base_weights # 基准权重
# 例如: {"tracking": 1.5, "regularization": 0.05, "contact": 0.2}
def apply_scale(self, group_name: str, scale: float) -> dict:
"""对某组权重应用缩放。"""
scaled = self.base.copy()
for key in scaled:
if group_name in key:
scaled[key] *= scale
return scaled
# 使用示例:
# 搜索 regularization 组的缩放因子 ∈ [0.5, 2.0]
# 而非独立搜索 action_rate, torque, acc 各自的权重
searcher = ScaledDictSearch({
"tracking_lin": 1.5, "tracking_ang": 0.75,
"reg_action_rate": 0.01, "reg_torque": 0.0002, "reg_acc": 2.5e-7,
"contact_air_time": 0.2, "contact_slip": 1.0,
})
⚠️ 常见陷阱
⚠️ 概念误区:把 AGILE 当作"又一个 RL 框架" - AGILE 不提供 env.step()、不实现 PPO——它是工程流程的组织框架 - 它在 Isaac Lab 之上运行,使用 RSL-RL 做训练
⚠️ 思维陷阱:跳过 Stage 1 (Prepare) 直接 Stage 2 (Train) - "我的 reward 应该没问题,直接训练吧"——AGILE 的经验表明,90% 的训练失败可以在 Stage 1 的 5 分钟 GUI 检查中提前发现
练习
- [实践题] 用 AGILE 的 PPO 默认配置(见上表)训练一个四足速度跟踪任务 5000 iterations,和你之前使用的配置对比 reward 曲线。
- [设计题] 为一个人形站立任务实现 Virtual Harness。设计衰减曲线:前 1000 iterations 保持 harness,然后 exponential 衰减到 3000 iterations 时为零。
- [分析题] 为什么 Value-Bootstrapped Terminations 能防止策略"自杀"?用一个简化的 2-state MDP 例子说明:如果 terminal V(s)=0,策略如何利用这一点来避免负 reward。
24.8 训练诊断预览:reward 曲线之外的五个信号 ⭐⭐
这一节解决什么问题:当 reward 曲线"看起来还行"但行为不对时,还应该看什么指标?
动机
"reward 在涨"是一个必要但不充分的信号——策略可能在 reward hacking(找到了不合理但高 reward 的行为)、action 在饱和(输出都在 ±1 附近)、entropy 塌缩(策略变成了确定性的,不再探索)、value function 不准(GAE 的 baseline 估计偏差大)。这些问题不会让 reward 下降,但会让部署失败。
五个信号的联合阅读 ⭐⭐
# === PPO 训练诊断五信号联合监控 ===
def log_diagnostics(runner, iteration):
"""记录五个关键诊断信号到 WandB。"""
stats = runner.get_training_stats()
diagnostics = {
# 1. Reward(任务完成度)
"diag/reward_mean": stats["reward_mean"],
"diag/reward_std": stats["reward_std"],
# 2. KL divergence(策略更新幅度)
"diag/kl_divergence": stats["kl"],
# 正常: 0.005-0.02, 异常: >0.05 (LR 太大) 或 ≈0 (LR 太小)
# 3. Entropy(探索程度)
"diag/entropy": stats["entropy"],
# 正常: 逐渐下降但不陡降
# 异常: 突然从 3.0 降到 0.5 → mode collapse
# 4. Value loss(critic 精度)
"diag/value_loss": stats["value_loss"],
# 正常: 先升后降,最终稳定
# 异常: 持续上升 → critic 无法拟合 value function
# 5. Episode length(行为质量)
"diag/episode_length_mean": stats["ep_len_mean"],
# 正常: 逐渐增长(活得更久)
# 异常: 始终很短 → termination 太严格 或 初始化有问题
}
if runner.logger:
runner.logger.log(diagnostics, step=iteration)
return diagnostics
五信号联合诊断表:
| 症状组合 | 可能原因 | 修复方向 |
|---|---|---|
| reward↑ + entropy 陡降 | Mode collapse,策略找到固定解 | 增大 entropy coeff |
| reward 平坦 + KL≈0 | LR 太小,策略几乎不更新 | 增大 LR |
| reward 平坦 + KL>0.05 | LR 太大,每步更新过大导致振荡 | 减小 LR |
| reward↑ + value_loss 持续升 | Critic 拟合不了(网络太小或 obs 不够) | 增大 critic 网络 |
| reward↑ + ep_len 不变 | 可能是 reward hacking | 看 viewer 确认行为 |
| reward↓ + ep_len 短 | 训练崩溃 → NaN 或 termination 突变 | 转 §24.3 NaN 排查 |
| reward↑ + action 饱和 (>90% 在 ±1) | Action scale 太小或探索不足 | 增大 action_scale 或 init_noise |
Action 饱和检测 ⭐⭐
Action 饱和(策略输出持续接近 ±1)是一个常被忽视但影响严重的问题——饱和意味着策略被"困在角落",无法精细调节动作。
# === Action 饱和检测 ===
def check_action_saturation(env, policy, num_steps=500):
"""检测策略输出是否饱和。"""
obs, _ = env.reset()
all_actions = []
for _ in range(num_steps):
with torch.no_grad():
action = policy(obs['policy'])
all_actions.append(action)
obs, _, _, _, _ = env.step(action)
actions = torch.cat(all_actions, dim=0) # [N*B, act_dim]
abs_actions = actions.abs()
# 统计各维度的饱和率(|a| > 0.9 的比例)
saturation_rate = (abs_actions > 0.9).float().mean(dim=0)
print("Action saturation rate per dimension:")
for i, rate in enumerate(saturation_rate):
status = "⚠️" if rate > 0.5 else "✅"
print(f" dim {i}: {rate:.1%} {status}")
overall = saturation_rate.mean().item()
print(f"\nOverall saturation: {overall:.1%}")
if overall > 0.3:
print("⚠️ 策略输出过度饱和!")
print(" 可能原因: action_scale 太小,策略需要更大的动作幅度")
print(" 修复: 增大 action_scale 或增大 init_noise_std")
return saturation_rate
Reward Hacking 检测 ⭐⭐
Reward hacking 是策略找到了一种"技术上"满足 reward 定义但"物理上"不合理的行为——例如通过快速抖动来获得高 tracking reward(因为抖动的平均速度恰好接近目标速度),或者通过把脚卡在地面裂缝中来获得稳定的 air_time reward。
检测方法:定期用 viewer 观察行为,或用 §24.7 的运动质量诊断自动检测。
# === Reward Hacking 检测 ===
def detect_reward_hacking(env, policy, num_steps=1000):
"""通过对比 reward 和行为质量检测 reward hacking。"""
obs, _ = env.reset()
rewards = []
tracking_errors = []
action_rates = []
prev_action = None
for step in range(num_steps):
with torch.no_grad():
action = policy(obs['policy'])
obs, reward, _, _, _ = env.step(action)
rewards.append(reward.mean().item())
# 实际跟踪误差(不通过 reward 函数)
actual_vel = env.robot.data.root_link_lin_vel_b[:, :2]
cmd_vel = env.command_manager.get_command("base_velocity")[:, :2]
error = (actual_vel - cmd_vel).norm(dim=-1).mean().item()
tracking_errors.append(error)
# Action rate
if prev_action is not None:
rate = (action - prev_action).norm(dim=-1).mean().item()
action_rates.append(rate)
prev_action = action.clone()
# 如果 reward 高但 tracking error 也高 → reward hacking
avg_reward = np.mean(rewards)
avg_error = np.mean(tracking_errors)
avg_rate = np.mean(action_rates) if action_rates else 0
print(f"Avg reward: {avg_reward:.3f}")
print(f"Avg tracking error: {avg_error:.3f} m/s")
print(f"Avg action rate: {avg_rate:.4f}")
if avg_reward > 0.5 and avg_error > 0.3:
print("⚠️ 可能的 reward hacking: reward 高但 tracking 差")
print(" 检查 reward 定义是否和实际任务目标一致")
if avg_rate > 0.3:
print("⚠️ 动作抖动频率过高")
print(" 增大 action_rate_l2 penalty")
从诊断到调参的决策流程 ⭐⭐
当训练"看起来不太对"时,按以下决策树行动:
训练表现不符合预期
│
├── Reward 在涨吗?
│ ├── 不涨(平坦)
│ │ ├── KL ≈ 0 → LR 太小,增大
│ │ ├── KL > 0.05 → LR 太大,减小
│ │ ├── Entropy 很低 → Mode collapse,增大 entropy coeff
│ │ └── Episode length 很短 → Termination 太严格,放宽
│ │
│ └── 在涨
│ ├── 行为合理吗?(viewer 检查)
│ │ ├── 合理 → 继续训练
│ │ └── 不合理 → Reward hacking
│ │ ├── 动作抖动 → 增大 action_rate penalty
│ │ ├── 利用仿真 bug → 修复物理配置
│ │ └── 忽略命令 → 检查 command obs 是否正确
│ │
│ ├── Action 饱和 > 30%?
│ │ └── 是 → 增大 action_scale
│ │
│ └── Value loss 持续升?
│ └── 是 → Critic 网络太小,增大隐藏层
│
├── 有 NaN 吗?
│ └── 是 → 转 §24.3 NaN 排查
│
└── 太慢了?
└── 是 → 转 §24.4 性能优化
跨领域类比:训练诊断就像医生的"查房"——不是只看血压(reward),还要看心率(KL)、体温(entropy)、血氧(value loss)和意识状态(episode length)。五个指标联合阅读才能得出正确的诊断。只看 reward 曲线做决策,就像只看血压就开药一样危险。
⚠️ 常见陷阱
⚠️ 思维陷阱:reward 在涨就不看其他指标 - reward 涨但 entropy 陡降 = 策略找到了一个固定解不再探索 - 正确做法:五个信号联合阅读
⚠️ 思维陷阱:性能不好先换算法 - "PPO 太慢,换 SAC"——更可能的原因是 env step 慢或 reward 设计不好 - 正确做法:先 profile,区分是 env 慢还是 PPO 慢
练习
- [实践题] 对你训练中的策略运行
check_action_saturation()。哪些 action 维度饱和了?这和你的 action_scale 设置有关吗? - [分析题] 设计一个 WandB Dashboard,同时显示五个诊断信号。描述你会如何在 Dashboard 中布局这些图表,以及什么"模式"表示训练健康。
- [跨章综合题] 结合 Ch06 的 reward 设计和 §24.7 的 AGILE 运动质量诊断,设计一个自动化的"训练健康检查"脚本——每 1000 iterations 自动运行并生成报告。
下一章预告:Ch25 将深入训练诊断——把本节初步介绍的五信号联合阅读发展为覆盖全书所有任务的"症状→调参"索引表。Ch25 的贡献是:当你遇到任何训练问题时,查表就能找到对应的修复方向——而不需要从头推理。AGILE 的 Stage 3 评估报告(§24.7 的运动质量诊断)和本节的五信号诊断表都是 Ch25 索引表的输入。
全书定位:Ch24 是 Part VI(大规模训练与调试)的第一章。它解决的是"如何高效、稳定、可复现地训练"——从多 GPU 配置到 NaN 排查到性能优化到云端训练到 AGILE 工业级流程。Ch25 紧接其后解决"如何诊断和修复训练问题"——两章合在一起构成了"训练工程"的完整工具箱。
本章小结
| 知识点 | 核心内容 | 对应练习/实战 |
|---|---|---|
| 多 GPU 配置 | mjlab torchrunx --gpu-ids / Isaac Lab torchrun |
§24.2 |
| 样本量核算 | total = envs × steps × gpus × iters |
§24.2 公平比较 |
| 单写者原则 | 只 rank 0 写文件 | §24.2 代码模板 |
| NaN 五大根因 | 接触发散/除零/normalizer/std 负/graph 失效 | §24.3 排查表 |
| NaN Guard | --enable-nan-guard + viz-nan |
§24.3 代码 |
| 性能 Profiling | torch.profiler + nsys + 传感器成本量化 | §24.4 代码 |
| nconmax/njmax 调优 | 记录实际分布 → 设为 1.5× max | §24.4 代码 |
| num_envs 选择 | 逐步增大找吞吐峰值 | §24.4 代码 |
| SkyPilot 云端 | launch → run → down 生命周期 | §24.5 |
| WandB Sweep | 自动化超参搜索 | §24.5 代码 |
| AGILE Prepare | Joint GUI + Reward Viz(5 分钟发现 bug) | §24.7 |
| AGILE Train | Git hash + Docker + scaled-dict | §24.7 |
| AGILE Evaluate | 确定性+随机性 rollout + HTML 报告 | §24.7 |
| AGILE Deploy | TorchScript + YAML descriptor | §24.7 |
| L2C2 | 相邻状态 policy 输出平滑正则 | §24.7 代码 |
| Reward 归一化 | EMA σ + 折扣修正 | §24.7 代码 |
| Virtual Harness | 训练初期辅助力 + 衰减 | §24.7 代码 |
| Value-Bootstrap | 防止策略"自杀" | §24.7 代码 |
累积项目:本章新增模块
| # | 模块 | 描述 | 依赖 |
|---|---|---|---|
| 1 | 多 GPU 训练脚本 | 带样本量核算的 --gpu-ids 配置 |
§24.2 |
| 2 | NaN 排查工具链 | --enable-nan-guard + dump 分析脚本 |
§24.3 |
| 3 | 性能 Benchmark 脚本 | env_sps / train_sps / VRAM 自动测量 | §24.4 |
| 4 | nconmax 调优工具 | 记录接触数分布 + 推荐值 | §24.4 |
| 5 | SkyPilot 训练配置 | sky_train.yaml 模板 | §24.5 |
| 6 | AGILE 工具箱 | L2C2 / reward norm / virtual harness / value bootstrap | §24.7 |
延伸阅读
| 资料 | 难度 | 说明 |
|---|---|---|
| AGILE(arXiv:2603.20147) | ⭐⭐⭐ | 四阶段工业级 workflow + 算法工具箱 |
| torchrunx(github.com/apoorvkh/torchrunx) | ⭐⭐ | mjlab 使用的分布式启动器 |
| PyTorch DDP 文档 | ⭐⭐ | 理解 all-reduce 和进程组 |
| SkyPilot 文档 | ⭐⭐ | 云端任务生命周期和成本管理 |
| WandB Sweep 文档 | ⭐ | 超参搜索配置 |
| RSL-RL(arXiv:2509.10771) | ⭐⭐ | EmpiricalNormalization + PPO 实现 |
| MuJoCo Warp 文档 | ⭐⭐ | CUDA Graph capture + nconmax/njmax |
| NVIDIA OSMO | ⭐⭐⭐ | Isaac Lab 的生产级多节点编排 |
🔧 故障排查手册
| 症状 | 可能原因 | 排查步骤 | 相关节 |
|---|---|---|---|
| 训练后突然 NaN | 接触/reward/normalizer | 1. nan_guard 2. viz-nan 3. 按 §24.3 优先级 | §24.3 |
| 多 GPU 训练但 reward 曲线重复 | 多 rank 都写了日志 | 1. 检查 rank 判断 2. 确认只 rank 0 写 | §24.2 |
--gpu-ids 报设备不可见 |
CUDA_VISIBLE_DEVICES 冲突 | 1. 传逻辑 id [0,1] 而非物理 id | §24.2 |
| steps/s 远低于预期 | sensor/nconmax/num_envs | 1. 关闭 sensor 对比 2. 调 nconmax 3. 找最优 num_envs | §24.4 |
| GPU 利用率 < 50% | num_envs 太小 | 1. 增大 num_envs 2. profiler 检查 | §24.4 |
| SkyPilot job 失败仍计费 | 未 sky down |
1. sky status 2. sky down |
§24.5 |
| Sweep agent 抢同一 GPU | 未绑定 CUDA_VISIBLE_DEVICES | 1. 每个 agent 绑定独立 GPU | §24.5 |
| Curriculum 推进后 NaN | 新 reward term 有除零风险 | 1. 固定 curriculum 两侧各跑 100 iter | §24.3 |
| Checkpoint resume 后行为不同 | normalizer 未恢复 | 1. 检查 state_dict 内容 2. 加载 normalizer | §24.2 |
| CUDA Graph 失效警告 | DR 改变了数组地址 | 1. 确认 create_graph() 被调用 | §24.3 |
训练诊断的完整自动化脚本 ⭐⭐
以下脚本整合了五信号监控、action 饱和检测、reward hacking 检测和运动质量诊断,可以在训练过程中每 1000 iterations 自动运行:
# === 训练健康检查自动化 ===
class TrainingHealthChecker:
"""训练过程中的自动化健康检查。
集成五信号监控 + action 饱和 + reward hacking + 运动质量,
每隔 check_interval iterations 自动运行。
"""
def __init__(self, env, policy, check_interval=1000, logger=None):
self.env = env
self.policy = policy
self.interval = check_interval
self.logger = logger
self.history = []
def check(self, iteration, training_stats):
"""运行完整的健康检查并返回报告。"""
if iteration % self.interval != 0:
return None
report = {"iteration": iteration, "issues": []}
# 1. 五信号诊断
report["reward_mean"] = training_stats.get("reward_mean", 0)
report["kl"] = training_stats.get("kl", 0)
report["entropy"] = training_stats.get("entropy", 0)
report["value_loss"] = training_stats.get("value_loss", 0)
report["ep_len_mean"] = training_stats.get("ep_len_mean", 0)
if report["kl"] > 0.05:
report["issues"].append("KL 过高 → 减小 LR")
if report["entropy"] < 0.5:
report["issues"].append("Entropy 过低 → 增大 entropy_coef")
# 2. Action 饱和检测(采样 200 步)
obs, _ = self.env.reset()
abs_actions = []
for _ in range(200):
with torch.no_grad():
action = self.policy(obs['policy'])
abs_actions.append(action.abs())
obs, _, _, _, _ = self.env.step(action)
saturation = torch.cat(abs_actions).gt(0.9).float().mean().item()
report["action_saturation"] = saturation
if saturation > 0.3:
report["issues"].append(f"Action 饱和 {saturation:.0%} → 增大 action_scale")
# 3. 汇总
status = "✅ 健康" if not report["issues"] else "⚠️ 需要关注"
print(f"\n[Iter {iteration}] 训练健康检查: {status}")
for issue in report["issues"]:
print(f" {issue}")
if self.logger:
self.logger.log({
"health/saturation": saturation,
"health/num_issues": len(report["issues"]),
}, step=iteration)
self.history.append(report)
return report
# 在训练循环中使用
checker = TrainingHealthChecker(env, policy, check_interval=1000)
for iteration in range(max_iterations):
stats = runner.train_one_iteration()
checker.check(iteration, stats)
从"单次训练"到"批量实验"的工程升级路径 ⭐
本章教授的工具按使用阶段可以分为三个层次:
| 层次 | 工具 | 什么时候用 |
|---|---|---|
| L1 单次训练 | NaN Guard, profiler, num_envs 调优 | 每次训练都用 |
| L2 对比实验 | 样本量账本, 公平比较, 多 seed | 写论文/做消融时用 |
| L3 生产级训练 | AGILE workflow, 运行包, SkyPilot, OSMO | 团队协作/大规模训练时用 |
新手应该从 L1 开始——确保单次训练稳定、高效。当你需要做消融实验或对比不同配置时,升级到 L2——确保比较的公平性。当你在团队中工作或需要大规模训练时,升级到 L3——确保结果的可复现性和流程的标准化。
跨领域类比:这三个层次就像软件开发中的"个人脚本 → 单元测试 → CI/CD 流水线"的升级路径。个人脚本能跑就行(L1),单元测试确保功能正确(L2),CI/CD 确保团队协作中的质量一致性(L3)。不需要一开始就搭建完整的 CI/CD——但当团队和项目规模增长时,这些基础设施是必要的。
典型训练时间参考 ⭐
以下是不同任务和配置的典型训练时间参考(单 A100,4096 envs):
| 任务 | DOF | Iterations | Wall-clock | 说明 |
|---|---|---|---|---|
| 四足 flat velocity | 12 | 5,000 | ~1 hr | 基础任务 |
| 四足 rough velocity | 12 | 10,000 | ~2 hr | + 地形 curriculum |
| 人形 flat velocity | 29 | 15,000 | ~4 hr | 更大的状态空间 |
| 人形 stand-up | 29 | 20,000 | ~6 hr | 需要 virtual harness |
| 人形 motion imitation | 29 | 20,000 | ~6 hr | + 参考动作数据 |
| 操作 lift cube | 7 | 5,000 | ~1 hr | 固定基座 |
| Loco-manipulation | 19 | 20,000 | ~8 hr | 底盘 + 臂 |
| 网球击球 | 29 | 30,000+ | ~12+ hr | 多阶段 curriculum |
这些时间参考帮助你判断"训练是不是太慢了"——如果你的四足 flat 训练需要 8 小时(而参考是 1 小时),说明性能有问题,应该用 §24.4 的 profiling 工具定位瓶颈。
下一章预告:Ch25 将深入训练诊断——把 §24.8 初步介绍的五信号联合阅读发展为覆盖全书所有任务的"症状→调参"索引表。AGILE 的 Stage 3 评估报告和 §24.8 的五信号诊断表都是 Ch25 索引表的输入。
全书定位:Ch24 是 Part VI(大规模训练与调试)的第一章。它解决的是"如何高效、稳定、可复现地训练"——从多 GPU 配置到 NaN 排查到性能优化到云端训练到 AGILE 工业级流程。Ch25 紧接其后解决"如何诊断和修复训练问题"——两章合在一起构成了"训练工程"的完整工具箱。
快速定位决策树(可打印版) ⭐
遇到训练问题时,按此决策树在 5 分钟内定位到正确的排查方向:
训练出了问题
│
├── 崩溃了(NaN / OOM / CUDA Error)?
│ ├── NaN in reward → §24.3 根因 2:检查除零
│ ├── NaN in qvel → §24.3 根因 1:接触求解器
│ ├── NaN in obs → §24.3 根因 3:normalizer 预热
│ ├── NaN in log_prob → §24.3 根因 4:policy std
│ ├── OOM → 降低 num_envs 或 nconmax(§24.4)
│ ├── CUDA Error → CUDA Graph 失效(§24.3 根因 5)
│ └── 不确定 → `--enable-nan-guard True` 重跑
│
├── 太慢了(steps/s 低于预期)?
│ ├── 有 sensor?→ 关闭 sensor 测 env_sps 差异(§24.4)
│ ├── GPU 利用率低?→ 增大 num_envs(§24.4)
│ ├── PPO update 慢?→ 减小网络或 mini_batch(§24.4)
│ └── 不确定?→ torch.profiler / nsys 定位(§24.4)
│
├── 不收敛(reward 不涨)?
│ ├── KL ≈ 0 → LR 太小(§24.8)
│ ├── KL > 0.05 → LR 太大(§24.8)
│ ├── Entropy 陡降 → Mode collapse(§24.8)
│ ├── Ep_len 很短 → Termination 太严格
│ └── 不确定?→ 五信号联合阅读(§24.8)
│
├── 收敛了但行为不对?
│ ├── 原地抖动 → 缺 action_rate penalty
│ ├── Action 饱和 → action_scale 太小(§24.8)
│ ├── Reward hacking → 检查 reward 定义(§24.8)
│ └── 不确定?→ AGILE 运动质量诊断(§24.7)
│
├── 多 GPU 问题?
│ ├── 设备不可见 → GPU id 映射(§24.2)
│ ├── 曲线重复 → 多 rank 写日志(§24.2)
│ ├── Rank 崩溃 → 检查所有 rank 日志(§24.2)
│ └── Resume 异常 → normalizer 未恢复(§24.2)
│
└── 结果不可复现?
├── 缺 seed → 加 seed 参数
├── 缺配置 → 运行包协议(§24.6)
└── 比较不公平 → 样本量账本(§24.6)
这棵决策树覆盖了本章 90% 以上的故障场景。把它打印贴在屏幕旁边——每次训练出问题时,从根节点开始走,通常 2-3 步就能找到正确的排查方向。
Ch24 与全书的关系图 ⭐
Ch01-03 仿真基础设施
↓
Ch04-10 RL 工程方法论
↓
Ch11-12 机器人建模
↓
Ch13-21 单/复合形态实战
↓
Ch22 DIY 自定义环境(环境搭建)
Ch23 Sim2Real 部署(部署链路)
↓
Ch24 ←── 你在这里(训练工程)
│
│ 解决: 如何高效、稳定、可复现地训练
│ 工具: NaN Guard + Profiler + AGILE + 运行包
│
↓
Ch25 训练诊断(调参地图)
↓
Ch26-28 网球机器人综合项目
Ch24 是 Part I-V 所有知识的"工程化整合"——你在前面学到的 obs/reward/DR/distillation 等技术,到了大规模训练阶段会面临新的工程挑战(NaN、性能瓶颈、实验管理)。Ch24 提供了应对这些挑战的系统化方法论。
本章核心方法论总结
本章可以浓缩为三条核心方法论:
方法论一:分层诊断。 无论是 NaN、性能问题还是训练不收敛,都不要直接猜测原因——先用工具定位问题层级(物理层?算法层?系统层?),再针对该层排查。NaN Guard 定位到物理层还是算法层;profiler 定位到 sensor 层还是 PPO 层;五信号诊断定位到 reward 设计还是超参设置。
方法论二:先缩小再放大。 在多 GPU 上出 NaN?先缩到单 GPU 16 envs 复现。训练太慢?先关掉所有 sensor 测基础吞吐。结果不可复现?先固定单 seed 确认基线。从最小的复现范围开始排查,确认后再扩展。
方法论三:流程标准化。 AGILE 的核心价值不是某个算法,而是把"经验"变成"流程"。运行包协议确保结果可追溯,样本量账本确保比较公平,四阶段 workflow 确保每个环节都不被跳过。流程的力量在于:它让质量不再依赖于"最有经验的人是否在场"。
这三条方法论不仅适用于机器人 RL 训练——它们是任何复杂工程系统的通用调试方法论。分层诊断对应软件工程中的"从日志定位模块",先缩小再放大对应"最小复现用例",流程标准化对应"CI/CD 和代码审查"。掌握了这些方法论,你面对任何训练问题都不会"无从下手"——你总有一个系统化的排查起点。
致读者:本章涵盖的工具和方法论是你在 Ch13-Ch23 实战中可能已经"凭直觉"在使用的——比如"训练 NaN 了就减小 action scale"、"太慢了就增大 num_envs"。本章的价值在于把这些直觉系统化——给出完整的排查优先级表、profiling 工具链、和标准化流程。当直觉失效时(比如减小 action scale 后仍然 NaN),你可以回到本章的决策树,按系统化的步骤逐一排查——而不是继续猜测。
写给第二次阅读本章的读者:第一次阅读本章时,你可能只需要 §24.3(NaN 排查)和 §24.4(性能优化)——因为这是你在训练中最常遇到的问题。当你开始做论文实验时,回来看 §24.6(实验管理)和 §24.8(训练诊断)。当你在团队中协作或使用云端 GPU 时,回来看 §24.2(多 GPU)、§24.5(SkyPilot)和 §24.7(AGILE)。本章是一个工具箱——不需要一次性全部掌握,需要什么查什么。
从 Part V 到 Part VI 的过渡:Part V(Ch19-Ch23)教你"怎么搭建和部署",Part VI(Ch24-Ch25)教你"怎么高效和诊断"。两者合在一起构成了完整的机器人 RL 工程能力——从环境搭建到策略训练到部署验证到问题诊断。从 Ch26 开始的 Part VII(网球项目)将在一个综合案例中同时使用 Part V 和 Part VI 的所有工具。
本章命令速查表 ⭐
以下是本章涉及的所有关键命令,按使用场景分组:
# === NaN 排查 ===
uv run train <TASK> --enable-nan-guard True --env.scene.num-envs 16 --agent.max-iterations 100
uv run viz-nan /tmp/mjlab/nan_dumps/nan_dump_latest.npz
# === 性能 Benchmark ===
time uv run play <TASK> --agent random --num-envs 4096 --max-steps 1000 --no-render
nsys profile --trace=cuda,nvtx uv run train <TASK> --agent.max-iterations 5 --headless
# === 多 GPU 训练 ===
# mjlab
uv run train <TASK> --gpu-ids "[0, 1]" --env.scene.num-envs 4096
# Isaac Lab
python -m torch.distributed.run --nnodes=1 --nproc_per_node=2 \
scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=<TASK> --num_envs 4096 --headless --distributed
# === 云端训练 ===(-c 指定 cluster name;sky logs/down 针对 cluster name 而非 task name)
sky launch -c <CLUSTER_NAME> sky_train.yaml
sky status
sky logs <CLUSTER_NAME>
sky down <CLUSTER_NAME> # 重要!
# === WandB Sweep ===
wandb sweep sweep.yaml
CUDA_VISIBLE_DEVICES=0 wandb agent <SWEEP_ID> &
CUDA_VISIBLE_DEVICES=1 wandb agent <SWEEP_ID> &