Train a robot in a simulated environment with Isaac Lab
Reinforcement learning teaches a robot by trial and error. A policy (a neural network) maps what the robot observes to how it moves its joints, and rewards for good behavior improve the policy over many attempts in simulation.
In this example, we’ll run a headless instance of Isaac Lab to train a policy that teaches Anymal-D, a quadruped robot, to obey a velocity command and walk over rough terrain. We train on a multi-GPU Modal Function and save two videos to a Volume: the policy at iteration 91 beside NVIDIA’s pretrained policy, and the policy progression accross three checkpoints.
Isaac Lab is NVIDIA’s open source Python framework for robot learning with GPUs. It’s built on top of Isaac Sim, NVIDIA’s open source robotics simulation platform. Isaac Sim utilizes Omniverse (simulation and rendering) and PhysX (physics engine), which both take advantage of GPUs for acceleration.
Isaac Lab integrates with a variety of RL frameworks. Here, we’ll use rsl-rl, an open source reinforcement learning library for robotics training, with PPO as the training algorithm. All of these details are transparent to our use, as Isaac Lab ships a pre-made task which we’ll take advantage of.
Run this example with:
modal run isaac_lab.pySet up
import os
import re
import shutil
import subprocess
from pathlib import Path
import modalWe train on four GPUs, with 4096 simulated robots per GPU, for 120 iterations.
NUM_GPUS = 4
MINUTES = 60
TASK = "Isaac-Velocity-Rough-AnymalD"
TRAINING_ITERATIONS = 121
RENDER_ITERATIONS = (60, 70, 91)Isaac Lab and its Python environment live at these paths in NVIDIA’s image.
ISAACLAB_DIR = Path("/workspace/isaaclab")
ISAAC_PYTHON = ISAACLAB_DIR / "_isaac_sim/python.sh"Isaac Lab uses Omniverse Kit to manage the simulation and rendering.
KIT_ARGS = "--/log/level=error --/log/fileLogLevel=error --/log/outputStreamLevel=error --/omni.kit.plugin/usdMuteDiagnosticMessage=true"We’ll replay our policy at several checkpoints and render the output into videos. The first Kit launch in the container spends minutes while the NVIDIA driver compiles RTX shaders into this cache. Subsequent launches to render videos will reuse it.
DRIVER_SHADER_CACHE_DIR = Path("/isaac-sim/kit/cache/nv_shadercache")Both stitched videos are saved to a Volume so you can grab them after the run.
OUTPUT_DIR = "/output"
COMPARISON_VIDEO_NAME = "anymal_d_final_vs_pretrained.mp4"
PROGRESS_VIDEO_NAME = "anymal_d_training_progress.mp4"
output_vol = modal.Volume.from_name("isaac-demo-output", create_if_missing=True)Install dependencies
NVIDIA’s official container bundles Isaac Lab and all the necessary dependencies for this example. We add moviepy, which Isaac Lab uses to record videos, and ffmpeg, which tiles the clips together.
image = (
modal.Image.from_registry("nvcr.io/nvidia/isaac-lab:3.0.0-rc1", add_python="3.11")
.entrypoint([])
.apt_install("ffmpeg")
.env({"ACCEPT_EULA": "Y"})
.run_commands(f"{ISAAC_PYTHON} -m pip install moviepy==1.0.3")
)
ffmpeg_image = modal.Image.debian_slim(python_version="3.11").apt_install("ffmpeg")
app = modal.App("example-isaac-lab")Train the policy
During training, each iteration spawns thousands of robots in parallel. Each gets a random commanded velocity and a reward (track velocity, keep back horizontal, etc.) that shapes the policy. Curriculum learning is enforced by rough terrain that ramps up in difficulty over time.
@app.function(image=image, gpu=f"L40S:{NUM_GPUS}", timeout=60 * MINUTES)
def train(iterations: int = TRAINING_ITERATIONS) -> dict[int, tuple[int, bytes]]:
subprocess.run(
[
str(ISAAC_PYTHON),
"-m",
"torch.distributed.run",
"--standalone",
"--nproc_per_node",
str(NUM_GPUS),
"scripts/reinforcement_learning/train.py",
"--rl_library",
"rsl_rl",
"--task",
TASK,
"--num_envs",
"4096",
"--max_iterations",
str(iterations),
"--distributed",
"--run_name",
"training_run",
"--kit_args",
KIT_ARGS,
"agent.save_interval=10",
],
check=True,
cwd=ISAACLAB_DIR,
env={**os.environ, "NCCL_CUMEM_HOST_ENABLE": "0"},
)
print("Training completed.")
checkpoints = {}
for iteration in RENDER_ITERATIONS:
actual_iteration, path = _checkpoint_at_or_before(iteration)
checkpoints[iteration] = (actual_iteration, path.read_bytes())
return checkpointsRender each checkpoint
Each checkpoint is replayed in one container. The first launch compiles RTX shaders into a cache on that container’s disk, and later renders copy the cache instead of compiling again. Up to two renders run at once. Each of those copies the cache into its own directory first, because the NVIDIA driver gives every process its own cache file, and a second process sharing the directory would recompile.
@app.cls(
image=image,
gpu="L40S",
timeout=60 * MINUTES,
max_containers=1,
scaledown_window=10 * MINUTES,
)
@modal.concurrent(max_inputs=2)
class Renderer:
@modal.method()
def render(self, clip_name: str, checkpoint: bytes | None = None) -> bytes:
checkpoint_path = None
if checkpoint is None:
DRIVER_SHADER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_path = DRIVER_SHADER_CACHE_DIR
else:
cache_path = Path(f"/tmp/nv_shadercache_{clip_name}")
if DRIVER_SHADER_CACHE_DIR.is_dir():
shutil.copytree(DRIVER_SHADER_CACHE_DIR, cache_path, dirs_exist_ok=True)
run_dir = Path("/tmp/play_runs") / clip_name
run_dir.mkdir(parents=True, exist_ok=True)
checkpoint_path = run_dir / f"{clip_name}.pt"
checkpoint_path.write_bytes(checkpoint)
kit_args = KIT_ARGS + f" --/rtx/shaderDb/driverShaderCachePath={cache_path}"
return _render(checkpoint_path, kit_args)Tile the clips
@app.function(image=ffmpeg_image, volumes={OUTPUT_DIR: output_vol})
def tile_clips(
comparison: list[tuple[bytes, str, str]],
progress: list[tuple[bytes, str, str]],
):
_write_video(comparison, COMPARISON_VIDEO_NAME)
_write_video(progress, PROGRESS_VIDEO_NAME)
def _write_video(clips: list[tuple[bytes, str, str]], video_name: str):
inputs, filters = [], []
for i, (video, label, color) in enumerate(clips):
path = Path(f"/tmp/{video_name}_{i}.mp4")
path.write_bytes(video)
inputs += ["-i", str(path)]
text = label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "")
filters.append(
f"[{i}:v]drawtext=text='{text}':x=20:y=20:fontsize=30:fontcolor={color}"
f":box=1:boxcolor=black@0.5:boxborderw=10[v{i}]"
)
streams = "".join(f"[v{i}]" for i in range(len(clips)))
filters.append(
f"{streams}hstack=inputs={len(clips)}:shortest=1,format=yuv420p[outv]"
)
subprocess.run(
[
"ffmpeg",
"-y",
*inputs,
"-filter_complex",
";".join(filters),
"-map",
"[outv]",
f"{OUTPUT_DIR}/{video_name}",
],
check=True,
)Put it all together
First, we kick off rendering the pretrained clip. It doesn’t depend on training, and the first render compiles the RTX shaders so the later clips reuse them. In parallel, we start training the policy. Then we render the checkpoints in the same warm container. Finally, we stitch two videos from those renders.
@app.local_entrypoint()
def main(iterations: int = TRAINING_ITERATIONS):
renderer = Renderer()
expert_call = renderer.render.spawn("expert")
checkpoints = train.remote(iterations)
expert_video = expert_call.get()
rendered = list(
renderer.render.starmap(
[
(str(iteration), checkpoints[iteration][1])
for iteration in RENDER_ITERATIONS
]
)
)
videos = dict(zip(RENDER_ITERATIONS, rendered))
final = RENDER_ITERATIONS[-1]
tile_clips.remote(
[
(videos[final], f"{checkpoints[final][0]} iters", "cyan"),
(expert_video, "pretrained", "lime"),
],
[
(videos[60], f"{checkpoints[60][0]} iters", "yellow"),
(videos[70], f"{checkpoints[70][0]} iters", "orange"),
(videos[final], f"{checkpoints[final][0]} iters", "cyan"),
],
)
print("Done. Download the videos with:")
print(f" modal volume get isaac-demo-output {COMPARISON_VIDEO_NAME}")
print(f" modal volume get isaac-demo-output {PROGRESS_VIDEO_NAME}")Demo rendering and video stitching utilities
def _checkpoint_at_or_before(iteration: int) -> tuple[int, Path]:
by_iteration = {}
for path in ISAACLAB_DIR.glob("logs/rsl_rl/*/*training_run/model_*.pt"):
match = re.search(r"model_(\d+)\.pt$", path.name)
if match is None:
continue
by_iteration[int(match.group(1))] = path
eligible = [i for i in by_iteration if i <= iteration]
if not eligible:
saved = ", ".join(str(i) for i in sorted(by_iteration)) or "none"
raise FileNotFoundError(
f"No checkpoint at or before iteration {iteration}. Saved checkpoints: {saved}."
)
actual = max(eligible)
return actual, by_iteration[actual]
def _render(checkpoint_path: Path | None, kit_args: str) -> bytes:
# Similar to train.py, Isaac Lab ships a play.py script for rendering this policy.
terrain = "env.scene.terrain.terrain_generator"
velocity = "env.commands.base_velocity"
subprocess.run(
[
str(ISAAC_PYTHON),
"scripts/reinforcement_learning/play.py",
"--rl_library",
"rsl_rl",
"--task",
TASK,
"--device",
"cuda:0",
"--num_envs",
"1",
"--video",
"--video_length",
"200",
"--seed",
"3",
"--kit_args",
kit_args,
"--checkpoint",
str(checkpoint_path) if checkpoint_path else "pretrained",
"env.viewer.eye=[5.2,5.2,2.7]",
"env.viewer.lookat=[0.0,0.0,0.55]",
f"{terrain}.num_rows=1",
f"{terrain}.num_cols=1",
f"{terrain}.sub_terrains.pyramid_stairs_inv.proportion=0.0",
f"{terrain}.sub_terrains.boxes.proportion=0.0",
f"{terrain}.sub_terrains.random_rough.proportion=0.0",
f"{terrain}.sub_terrains.hf_pyramid_slope.proportion=0.0",
f"{terrain}.sub_terrains.hf_pyramid_slope_inv.proportion=0.0",
f"{velocity}.debug_vis=false",
f"{velocity}.heading_command=false",
f"{velocity}.ranges.lin_vel_x=[1.0,1.0]",
f"{velocity}.ranges.lin_vel_y=[0.0,0.0]",
f"{velocity}.ranges.ang_vel_z=[0.0,0.0]",
],
check=True,
cwd=ISAACLAB_DIR,
)
# play.py records to <checkpoint dir>/videos/play/<name>.mp4
if checkpoint_path:
videos = list(checkpoint_path.parent.glob("videos/play/*.mp4"))
searched = checkpoint_path.parent / "videos/play"
else:
searched = ISAACLAB_DIR / ".pretrained_checkpoints" / "rsl_rl"
videos = list(searched.glob("**/videos/play/*.mp4"))
if not videos:
raise FileNotFoundError(f"No play video was generated under {searched}.")
latest = max(videos, key=lambda path: path.stat().st_mtime)
return latest.read_bytes()