The Scaling Flip: From Fast Steps to Many Worlds
MuJoCo has been the workhorse CPU simulator for robotics—fast, deterministic, trusted. But as RL workloads exploded, the bottleneck shifted. The question stopped being how quickly can one simulation step? and became how many independent worlds can we advance at once?
NVIDIA's new MJWarp implementation tackles exactly that flip. Built on NVIDIA Warp (their Python-to-CUDA kernel compiler), MJWarp takes compatible MuJoCo MJCF models and runs them in batches on GPU—potentially thousands of parallel environments in a single mjw.step() call. The Hugging Face tutorial walks through migrating an SO-101 robotic arm from classic MuJoCo to as many as 2,048 parallel MJWarp environments, and the migration workflow is surprisingly concrete.
This isn't about making one world faster. It's about changing the unit of work from step latency to aggregate throughput—total world-steps per second across the entire batch. That's the metric that matters when you're sampling millions of experiences for policy training.
What Warp Actually Gives You
Before MJWarp makes sense, you need to understand what NVIDIA Warp is. It's a Python framework for writing GPU kernels without leaving Python. You annotate a function with @wp.kernel, use statically-typed Warp primitives (wp.vec3, wp.array), and Warp JIT-compiles it to native CUDA on first launch.
Here's the gravity integration example from the guide:
@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
One logical thread per point. Scale from two points to millions without rewriting the control flow. No explicit block dimensions, no manual memory management at kernel level. The orchestration stays in normal Python.
Three properties make this powerful for robotics simulation:
- Explicit parallelism:
wp.tid()identifies which world/body/contact the current thread owns - Device-resident arrays: No hidden copies—arrays live on GPU,
.numpy()synchronizes explicitly - Composable launches: Chain focused kernels, then capture the sequence into a CUDA graph to amortize dispatch overhead
Warp also supports differentiable kernels (record forward passes, replay adjoints in reverse) and deterministic execution modes (opt-in reproducibility despite GPU atomics). Neither is guaranteed end-to-end for MJWarp rollouts, but they're available primitives.
MJWarp: MuJoCo Physics in Warp Kernels
MJWarp is a Warp-based reimplementation of MuJoCo's physics pipeline. Same MJCF models, same Newton constraint solver, but the state lives on GPU and one mjw.step(m, d) advances an entire batch of independent worlds.
The core API transition is small:
| MuJoCo CPU | MJWarp GPU |
|---|---|
mujoco.MjModel | mjw.put_model(mjm) creates device model |
mujoco.MjData | mjw.put_data(mjm, mjd, ...) batches existing state |
mujoco.mj_step(mjm, mjd) | mjw.step(m, d) advances all worlds |
Host arrays like mjd.ctrl | Batched device arrays d.ctrl with shape (nworld, nu) |
Use mjw.make_data() for default initialization. Use mjw.put_data() when you need to preserve an exact initialized MuJoCo state across the migration boundary—critical for validation.
The Migration Workflow (and Where It Gets Real)
The tutorial's migration recipe is methodical:
1. Establish CPU Baseline
Start with ordinary MJCF. The example scene: an SO-101 follower arm, table, two cubes (red 44mm, blue 44mm) for a stacking task. Run it in mujoco-viewer, record ground truth.
2. Move One World to MJWarp
Load the model, call mjw.put_data() to transfer the initialized state, step on GPU. Verify output matches CPU within tolerance. This is where contact buffer sizing starts to matter—if nconmax (expected contacts per world) is too small, you'll overflow and get silent truncation or crashes.
3. Form a Batch
Allocate nworld copies. Now you're specifying three sizing parameters:
nworld: total parallel environmentsnconmax: expected contacts per individual worldnjmax: hard upper limit on constraints per world
Memory and compute scale with these. Set them too high, you waste capacity. Too low, you overflow. The guide recommends using mjwarp-testspeed --measure_alloc to empirically tune, and watching for overflows in mjwarp-viewer.
4. Verify the Batch
Run the batch forward, compare aggregate statistics or spot-check individual worlds against CPU. The tutorial validates the SO-101 rollout by comparing final cube positions—determinism isn't guaranteed by default (GPU atomics depend on scheduler order), but Warp 1.15+ offers opt-in deterministic modes if you need bitwise reproducibility.
5. Measure Correctly
Aggregate throughput = (nworld × steps) / wall_time. Don't measure first-launch compilation. Do warm up the graph capture. The guide emphasizes CUDA graph capture as the primary performance optimization:
with wp.ScopedCapture() as capture:
mjw.step(m, d)
wp.capture_launch(capture.graph) # replay the graph
Capture once, replay thousands of times. This doesn't fuse kernels—it records the launch sequence and replays it with reduced overhead.
What This Doesn't Cover (Yet)
The tutorial explicitly scopes out several topics:
- Policy training: MJWarp provides physics throughput, not the training loop. For that, integrate via Isaac Lab (next article in their series),
mjlab(manager API on MJWarp + PyTorch), or MuJoCo Playground withimpl='warp'. - Multi-GPU: The compact solver and multi-GPU configurations are separate tuning topics.
- Solver iteration limits and CCD settings: Contact buffer tuning is covered, but solver convergence knobs and continuous collision detection (
nccdmax) are left as "additional considerations."
This is a migration guide, not a training guide. It gets you from validated single-CPU world to validated GPU batch. The learning stack sits on top.
Decision Heuristic: When to Reach for MJWarp
The guide offers a clean decision table:
- Single-robot MPC / teleoperation: stick with MuJoCo CPU
- Max throughput on raw MuJoCo physics: MJWarp (or
mjlab) - JAX training recipes: MuJoCo Playground or MJX with
impl='warp' - Multi-solver + Isaac Lab integration: Newton (covered in their next post)
MJWarp shines when your bottleneck is experience collection at scale, not when you're hand-tuning one controller in real-time.
Why This Matters Beyond MuJoCo
The broader pattern here is about maintaining trust across a performance migration. MuJoCo has two decades of validation. You can't just yolo a GPU port and hope the learned policies transfer. The workflow—baseline, single-world verification, batch allocation tuning, aggregate validation—is a template for any simulator migration.
The Warp layer is also quietly significant. It gives you a Pythonic kernel language with autodiff and DLPack interop, so simulation can sit inside an ML training loop without serialization boundaries. That's the integration point that makes differentiable physics and end-to-end learning pipelines tractable.
Install: pip install warp-lang mujoco-warp. Try the scene viewer with mjwarp-viewer path/to/scene.xml or the Colab tutorial linked in the post. The SO-101 example is concrete enough to fork and adapt.
The Bottom Line
MJWarp isn't magic—it's a physics reimplementation with careful API parity and explicit batch semantics. The value is in taking a trusted CPU simulator and giving it GPU-scale throughput without breaking the validation chain. The tutorial's methodical migration workflow—baseline, verify, batch, tune, measure—is the real deliverable here. If you're scaling RL data collection and your pipeline is MuJoCo-shaped, this is the path to 1000+ parallel worlds without rewriting your task logic.