NVIDIA 把一台 SO-101 从单个 MuJoCo CPU 世界搬到 2,048 个并行 MJWarp 环境,API 改动只有一张对照表。真正难的是搬完之后怎么确认没搬错:容量溢出只打印警告、.numpy() 静默同步、计时不同步测的是入队速度——三类错误都不抛异常,只让数字失真。
NVIDIA 的仿真团队 9 月 23 日在 Hugging Face 博客上更新了《State of Simulation for Physical AI》系列的第二篇——How to Use NVIDIA Warp and MjWarp to Accelerate Robotics Simulation and Learning Workflows,署名 Johnny Nuñez Cano、Asier Arranz、Rishabh Chadha、Ben Oliveri,四人均来自 NVIDIA。文章拿一台 SO-101 从臂(follower arm,遥操作主从对里跟随主臂动作的那一端)的常规 MuJoCo 流程出发,一路搬到 2,048 个并行的 MJWarp 环境里。
读完整篇会发现,它真正花力气写的并不是"怎么把代码改成 GPU 版"——API 层面的改动小到一张对照表就说完了。它花力气写的是搬完之后你凭什么相信搬对了:哪些错误不会抛异常、哪些测量方式会给出一个漂亮但无意义的数字、哪一行没写会让后面所有指标一起失准。对于国内正在把强化学习采样从 CPU 集群往单机多卡上收的团队,这后半部分才是稀缺的。下面按这条线索拆。
经典 MuJoCo 是一套很快的 CPU 物理引擎,用来开发、调试、控制一台机器人,也能把采样按 CPU 核数铺开。但当学习类工作负载变重,问题就换了:不再是"一个世界能跑多快",而是"能同时跑多少个世界"。
原文把这两件事的口径定得很死,值得照抄进自己的文档:
MJWarp 的价值"不一定是让单个世界的一步变快",而是能同时推进成百上千个世界,给 GPU 喂足并行度。这句话决定了它适合谁:强化学习和大规模采样看重的是攒经验的速度;单机器人 MPC、遥操作这类看重单环境延迟的活,留在 MuJoCo CPU 上更合适。原文给的选型捷径是一张分诊表——要在原生 MuJoCo 物理上榨吞吐用 MJWarp(或 mjlab),要 JAX 训练配方用 MuJoCo Playground / MJX(impl='warp'),要多求解器加 Isaac Lab 集成则等 Newton。

原文图 1:从 Python 到 GPU 的分层。图片来源:NVIDIA / Hugging Face Blog
NVIDIA Warp 是用 Python 写高性能 GPU kernel 的框架:静态类型的 kernel 由 Python 写成,编译到 CPU 或 CUDA 执行;首次启动会构建并缓存原生模块,之后复用——所以第一次跑慢不是性能问题,是编译。kernel 语言是 Python 的一个面向性能的子集,而配置、分配、启动编排仍然是普通 Python 的活。
原文给的最小示例是一个在重力下推进质点位置的 kernel:
import numpy as np
import warp as wp
@wp.kernel
def integrate(
positions: wp.array[wp.vec3],
velocities: wp.array[wp.vec3],
dt: float,
):
i = wp.tid()
velocities[i] += wp.vec3(0.0, 0.0, -9.81) * dt
positions[i] += velocities[i] * dt
wp.init()
device = "cuda:0" if wp.is_cuda_available() else "cpu"
start = np.array([[0.0, 0.0, 0.5], [0.2, 0.0, 0.5]], dtype=np.float32)
positions = wp.array(start, dtype=wp.vec3, device=device)
velocities = wp.zeros_like(positions)
wp.launch(integrate, dim=len(start), inputs=[positions, velocities, 0.01], device=device)
wp.synchronize_device(device)
print(positions.numpy())
一个逻辑线程管一个质点,所以同一段代码从两个点扩到上百万个点,控制流里不需要出现任何 GPU 术语。原文提炼出三条对机器人仿真真正有用的性质:
一、并行粒度是显式的。 wp.tid() 标识当前逻辑线程所拥有的那个点、接触、刚体或世界。写代码时先想清楚"一个线程负责什么",后面扩批量就只是换个数字。
二、设备数组是显式的。 数组归属于选定的设备。对一个 CUDA 数组调 .numpy() 会同步并拷回 CPU 内存,这不是零拷贝路径。如果下游是常驻显存的 PyTorch 或 JAX 管线,要走 Warp 的框架适配器或 DLPack 风格的共享,而不是 .numpy() 绕一圈。这一条后面会变成一个具体的坑。
免费获取企业 AI 成熟度诊断报告,发现转型机会
三、kernel 启动可组合。 程序可以连续启动一串职责单一的 kernel,并把受支持的 CUDA 工作捕获进一张图(CUDA Graph)以削掉重复的调度开销。原文特意补了一句限制:图捕获是对已有缓冲区重放启动序列,它不会融合任意 kernel。把 CUDA Graph 当成"自动算子融合"来期待,会对不上账。
还有两项能力在这次 SO-101 流程里没用到,但值得知道:Warp 的 kernel 是可微的,wp.Tape 记录上下文内的前向启动,backward() 时反向重放其伴随;Warp 1.15 起支持确定性执行——GPU 原子操作默认依赖调度器,同一个 kernel 重复启动结果可能有微小差异,开启确定性模式会牺牲一部分性能换取可复现的顺序,用于仿真、验证和回归测试。原文在这里的措辞很克制,值得原样搬过来:这些是 Warp 的能力,不等于整条 MJWarp rollout 可微或确定。想要 GPU 确定性,pip install warp-lang 需要 ≥ 1.15。
原文把"world"定义为场景及其状态的一份独立副本——一个 world 里是 SO-101 去够方块,另一个可以是同一台臂从略微不同的姿态起步。MuJoCo 适合开发和检视一个或少数几个 CPU world;MJWarp 则是把模型和一整批独立状态放到 GPU 上,一次 mjw.step 推进整批。
API 的迁移面确实很小:
| MuJoCo 主机流程 | MJWarp 流程 |
|---|---|
mujoco.MjModel | mjw.put_model(mjm) 创建设备模型 |
mujoco.MjData | mjw.put_data(mjm, mjd, ...) 保留并批量化已有状态 |
mujoco.mj_step(mjm, mjd) | mjw.step(m, d) 推进 d 里的每一个世界 |
主机数组如 mjd.ctrl | 批量设备数组如 d.ctrl,形状 (nworld, nu) |
选哪个入口有讲究:想要默认的、全新的状态用 mjw.make_data();必须把那份已经初始化好的 MuJoCo 状态原样带过迁移边界时,用 mjw.put_data()。分配批量资源要定三个参数:nworld(并行环境总数)、nconmax(单个世界的预期接触数,整体容量约为 nconmax * nworld)、njmax(每个世界的约束数硬上限);另有 naconmax 是"所有环境合计的全局接触上限",两者同时定义时以它为准。
原文把迁移拆成四步,每一步都有一个可以卡住的检查点。这个结构比任何一段代码都值钱。
闸门 1,先把 CPU 基线立住。 场景是普通 MJCF:一台 SO-101、一张桌子、两个要摞起来的方块。有两处细节容易被跳过。其一,MJCF 的 box size 是半边长,size="0.022 …" 意味着 44 毫米见方的方块,任务的成功阈值就建立在这个尺寸上。其二,控制频率和物理步长必须对齐:50 Hz 控制帧、每帧 10 个物理子步,物理步长就得是 0.002 秒,写成 mjm.opt.timestep = frame_dt / sim_substeps,而且要在 CPU rollout 之前、在 mjw.put_model 上传模型之前设好。原文对漏掉这行的后果说得很直接:此后每一项测量都继承这个错配——对齐性比较、以"仿真秒"计的吞吐数字,以及任何动作频率不再匹配部署的已训策略。
成功判据也被定义成两个可测条件:两个方块中心的水平误差 xy_err ≤ 0.015 m,中心垂直间距 0.035 m ≤ dz ≤ 0.055 m(一个方块边长,留出沉降余量),且要等方块静止之后再判。原文补了一句该被贴在墙上的话:进程正常退出本身并不能证明任务成功。

原文图 2:用于 MJWarp 对齐验证的同一套机器人与场景。图片来源:NVIDIA / Hugging Face Blog
闸门 2,先跑一个世界,对齐再说。 上传模型、按 nworld=1 分配批量状态、用主机端已初始化的状态播种、mjw.forward 跑一次前向:
wp.init()
import mujoco_warp as mjw
device = wp.get_device()
m = mjw.put_model(mjm)
d = mjw.make_data(mjm, nworld=1, nconmax=spec.nconmax, njmax=spec.njmax)
wp.copy(d.qpos, wp.array(mjd.qpos[None, :], dtype=wp.float32, device=device))
wp.copy(d.qvel, wp.array(mjd.qvel[None, :], dtype=wp.float32, device=device))
wp.copy(d.ctrl, wp.array(mjd.ctrl[None, :], dtype=wp.float32, device=device))
mjw.forward(m, d)
每个设备数组都带一个领头的世界维度,所以主机状态要按 mjd.qpos[None, :] 索引,形状从 (nq,) 变成 (1, nq);后面扩到几千个世界,变的只是这个领头维度,调用本身不变。另一个容易被忽略的好处:mjw.put_model() 兼容性检查是会抛错的——模型用了不支持的特性它会报错,而不是悄悄丢掉。
这一步的帧循环把内层的 step 改到 GPU 再镜像回主机,于是每个子步都有 .numpy() 的同步与拷贝。原文明确定性地说:这是任务验证路径,不是吞吐基准。它把逆运动学、可视化、任务判定都留在主机上,方便你在同一个 viewer 里看同一个任务、比同两个数字。还有一处连锁细节:把 qpos、qvel 拷回主机后要调 mujoco.mj_forward(mjm, mjd) 刷新 mjd.xpos 这类派生量再拿去控制、显示或判定——循环结束后直接读那些字段,它们不会自动刷新。
闸门 3,把接触与约束容量量出来。 MJWarp 在开始步进之前就分配好接触和约束缓冲区,超出容量会让受影响的那次 rollout 在验证和基准上都失效。这里藏着全文最该被记住的一条行为差异:溢出是"报告"而不是"抛出"。在 Option.warn_overflow 的默认设置下,MJWarp 会把要加到多少的预算打印到终端或 viewer("narrowphase overflow - please increase nconmax to …"),并在 Data.overflow 里标出受影响的世界,等你自己回读;只有 mjw.put_data 会直接抛错,因为它手上有一份 MuJoCo 状态可以拿来比对预算。
所以容量要按任务里接触最密的那一刻来定——对抓取-放置来说,是两个夹爪和桌面同时接触方块的瞬间,而不是机械臂在空中悬停的时候。SO-101 的起手容量是 nconmax=128、njmax=300。mjwarp-testspeed --measure_alloc 会报告场景实际消耗的接触数和约束数,并在任何一个世界溢出时立刻中止 rollout 并给出出错的世界 ID。原文要求把这些报告当失败处理:先调大再重跑,然后在模型、碰撞几何或任务变化时重新收紧。
闸门 4,扩到 2,048 个世界。 相对上一步只变两件事:nworld,以及每步不再有任何数据过 PCIe 总线。
nworld = 2_048
d = mjw.make_data(mjm, nworld=nworld, nconmax=spec.nconmax, njmax=spec.njmax)
wp.copy(d.qpos, wp.array(np.tile(mjd.qpos, (nworld, 1)), dtype=wp.float32, device=device))
wp.copy(d.qvel, wp.array(np.tile(mjd.qvel, (nworld, 1)), dtype=wp.float32, device=device))
wp.copy(d.ctrl, wp.array(np.tile(mjd.ctrl, (nworld, 1)), dtype=wp.float32, device=device))
mjw.forward(m, d)
with wp.ScopedCapture() as capture:
mjw.step(m, d)
step_graph = capture.graph
np.tile 让每个世界都从同一个初始状态出发,这是做吞吐测量的正确基线;要做按世界随机化,就该在设备上写入 d.qpos 的不同行。CUDA Graph 复用的是捕获当时的模型和数据缓冲区,因此在重放之间原地更新 d.ctrl,而在替换缓冲区、改变 nworld 或重建模型之后必须重新捕获。图捕获只在 CUDA 下可用。

原文图 3:原文标注这是概念示意图,用于说明以"世界步/墙上时钟秒"衡量的聚合吞吐。图片来源:NVIDIA / Hugging Face Blog
GPU 启动是异步的,所以一个朴素的计时器测到的是 Python 把活排进队列有多快,不是 GPU 把活做完有多快。原文给的模板是:先热身(头几次启动要付 kernel 编译和分配的成本),然后在计时区间的前后各同步一次:
import time
for _ in range(10): # 热身:编译、分配、缓存
wp.capture_launch(step_graph)
wp.synchronize()
t0 = time.perf_counter()
for _ in range(200):
wp.capture_launch(step_graph)
wp.synchronize() # 少了这句,你计的是队列不是工作
elapsed = time.perf_counter() - t0
print(f"{200 * nworld / elapsed:,.0f} world-steps/second")
报告口径同样被写死:聚合世界步/秒与每批步的毫秒数一起报,并带上批量大小。用实测曲线找出增加世界数还能提升吞吐的区间,以及内存或算力开始吃紧的拐点。结果依赖场景、仿真设置和硬件;一次单世界的延迟对比不能用来论证批量吞吐。原文给了扫描脚本 scaling_study.py --worlds 1 64 1024 2048 8192 --steps 100,在自己的卡上把这条曲线跑出来。
值得注意的是,正文不报任何数字,只给你一个在自己卡上把 ms/step、吞吐和 speedup 跑出来的脚本——scaling_study.py 扫批量大小、三个量一起打印,但原文正文里既没有出现"提速 N 倍",也没有指名任何一块 GPU。这在一篇 NVIDIA 署名的文章里是个反常的克制,而且和它反复强调的"结果依赖场景与硬件"是自洽的。代价是你没法拿它去做采购论证,只能拿它去建自己的测量流程。
原文的复现命令里留着发布遗留,动手前先核对:
其一,CPU 基线那段命令给的是 git clone https://github.com/NVIDIA/accelerated-computing-hub.git blogs,但紧接着原文自己标注了一行发布阻塞项——在发布这些说明之前需要确认可访问的仓库 URL 以及钉住的依赖与资产版本,下面这个仓库占位符不是可执行的 URL。也就是说这段命令是带着"未确认"标记发出来的,照抄 clone 不一定落到正确的路径上。
其二,同一段里先 cd blogs/tutorials/sim2real-blogs/notebooks/mujoco,随后又出现一行 cd /tutorials/sim2real-blogs/notebooks/mujoco——带根斜杠的绝对路径,在已经进入该目录之后再执行必然失败,应是相对路径的笔误。后面 part2 那段也是同样的写法。
另外 Menagerie 的资产是会变的,原文把机械臂钉在一个已知可用的 commit 上,并提醒把整个场景当模板而不是当成品;可选的 reBot 变体走的是另一套 profile(nconmax=256、njmax=500),要单独验证后再报告结果。
第一,它把"吞吐"从一句口号变成了一个有分母的指标。你在对外报"并行 N 个环境"之前,先回答两个问题:每步状态有没有回拷主机?计时区间的前后有没有各调一次 wp.synchronize()?按原文这套口径复述一遍自己的数字,很多"并行"会当场缩水——闸门 2 那条每子步 .numpy() 的路径在功能上完全正确,看起来也确实是 GPU 在跑,但它的吞吐和 CPU 基线不会有本质差别。这是一个容易自欺的位置。
第二,它把风险集中在"不报错的错误"上。四道闸门里真正能卡住人的三件事——容量溢出只打印警告、.numpy() 静默同步、计时没同步——共同点是程序照常跑完、照常打印结果,只是结果不对。这类错误在 CPU 仿真时代不常见,因为 CPU 路径大多是同步且立即失败的。团队从 CPU 迁到 GPU 采样时,代码评审的重点也得跟着换:从"有没有异常"换成"这个数字是在什么同步状态下测出来的"。
第三,它把选型问题前置了。要不要上 MJWarp,取决于你是在做单机器人的 MPC 和遥操作,还是在攒策略训练用的经验。前者留在 MuJoCo CPU 上是对的,后者才需要这套批量语义。而如果你的路线最终要走到多格式资产、可换求解器、传感器与 IK 辅助以及 Isaac Lab 集成,那 MJWarp 只是底层——NVIDIA 已经预告下一篇会把同一个 MJCF 环境移植到 Newton,由 newton.solvers.SolverMuJoCo 把 MJWarp 垫在下面。现在就按这条路径组织代码,比先写死再改要省事。
上手成本本身不高:pip install warp-lang(GPU 确定性需 ≥ 1.15)后 python -m warp.examples.browse 看例子,pip install mujoco-warp 后 mjwarp-viewer benchmarks/humanoid/humanoid.xml 看批量场景。真正要花时间的是那四道闸门——它们不产出性能,只产出"这些数字可信"这一件事。








关注公众号

扫码关注,获取最新 AI 资讯
3 步完成企业诊断,获取专属转型建议
已有 200+ 企业完成诊断