"""Script to train RL agent with RSL-RL.""" import json import logging import os import sys from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path from typing import Literal # 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。 TRAINER_ROOT = Path(__file__).resolve().parents[1] SERVICE_ROOT = TRAINER_ROOT.parent for source_root in (TRAINER_ROOT, SERVICE_ROOT): if str(source_root) not in sys.path: sys.path.insert(0, str(source_root)) import tyro import warp as wp # mjlab 1.2.0 的 GPU CUDA-graph 检查仍访问旧公开路径 wp.context; # Warp 1.15 已把实现移到 warp._src.context,但保留了相同 runtime 契约。 if not hasattr(wp, "context"): from warp._src import context as warp_context wp.context = warp_context # type: ignore[attr-defined] from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg from mjlab.rl import MjlabOnPolicyRunner, RslRlBaseRunnerCfg, RslRlVecEnvWrapper from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls from mjlab.tasks.tracking.mdp import MotionCommandCfg from mjlab.utils.gpu import select_gpus from mjlab.utils.os import dump_yaml, get_checkpoint_path from mjlab.utils.torch import configure_torch_backends from mjlab.utils.wrappers import VideoRecorder from task_config import OBSTACLE_TASK, deployment_metadata, validate_task_config from tuning.schema import apply_reward_configuration, validate_configuration @dataclass(frozen=True) class TrainConfig: env: ManagerBasedRlEnvCfg agent: RslRlBaseRunnerCfg motion_file: str | None = None video: bool = False video_length: int = 200 video_interval: int = 2000 enable_nan_guard: bool = False torchrunx_log_dir: str | None = None gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0]) output_dir: str | None = None resume_checkpoint: str | None = None pretrained_checkpoint: str | None = None pretrained_upload_manifest: str | None = None pretrained_onnx: str | None = None pretrained_source_id: str | None = None pretrained_allowed_roots: list[str] = field(default_factory=list) reward_config: str | None = None reward_config_json: str | None = None task_config: str | None = None @staticmethod def from_task(task_id: str) -> "TrainConfig": env_cfg = load_env_cfg(task_id) agent_cfg = load_rl_cfg(task_id) return TrainConfig(env=env_cfg, agent=agent_cfg) def _load_reward_config(path: str | None, inline: str | None, task_id="Unitree-Go2-Flat") -> dict | None: if path is not None and inline is not None: raise ValueError("Use only one of reward_config and reward_config_json") if inline is not None: if len(inline.encode("utf-8")) > 64 * 1024: raise ValueError("Reward configuration is larger than 64 KiB") return validate_configuration(json.loads(inline), task_id) if path is None: return None source = Path(path).expanduser().resolve(strict=True) if source.stat().st_size > 64 * 1024: raise ValueError("Reward configuration is larger than 64 KiB") with source.open(encoding="utf-8") as stream: return validate_configuration(json.load(stream), task_id) def _load_task_config(task_id: str, path: str | None, seed: int) -> dict | None: if path is None: return validate_task_config(task_id, {}, seed) source = Path(path).expanduser().resolve(strict=True) if source.stat().st_size > 128 * 1024: raise ValueError("Task configuration is larger than 128 KiB") payload = json.loads(source.read_text(encoding="utf-8")) allowed = {"terrainPreset", "terrainParams", "sensorCfg", "seed", "customTerrainBoxes"} if not isinstance(payload, dict) or payload.keys() - allowed: raise ValueError("Unknown task configuration fields") if ( isinstance(payload.get("seed"), bool) or not isinstance(payload.get("seed"), int) or payload.get("seed") != seed ): raise ValueError("Task configuration seed must equal the agent seed") if payload.get("sensorCfg") is None: payload.pop("sensorCfg", None) return validate_task_config(task_id, payload, seed) def _configure_task_and_rewards(task_id: str, cfg: TrainConfig): custom = _load_task_config(task_id, cfg.task_config, cfg.agent.seed) if custom is not None: from src.tasks.obstacle_avoidance.env_cfg import apply_obstacle_configuration from src.tasks.obstacle_avoidance.terrain import apply_terrain_configuration if task_id == OBSTACLE_TASK: apply_obstacle_configuration(cfg.env, custom) else: apply_terrain_configuration(cfg.env, custom) deployment = deployment_metadata(task_id, custom, cfg.agent.seed) reward_config = _load_reward_config(cfg.reward_config, cfg.reward_config_json, task_id) if reward_config is not None: apply_reward_configuration(cfg.env, reward_config, task_id) if task_id == OBSTACLE_TASK: deployment["navigation"]["speed"] = reward_config["params"]["target_velocity"] deployment["sensorCfg"]["avoidanceWeight"] = reward_config["weights"]["avoidance_weight"] return deployment, reward_config def _load_pretrained(cfg: TrainConfig): if cfg.pretrained_checkpoint is None: if (cfg.pretrained_onnx is not None or cfg.pretrained_allowed_roots or cfg.pretrained_source_id or cfg.pretrained_upload_manifest): raise ValueError("Pretrained options require --pretrained-checkpoint (.pt), not ONNX alone") return None if cfg.resume_checkpoint is not None or cfg.agent.resume: raise ValueError("Pretrained warm-start and resume are mutually exclusive") from pretrained import read_pretrained_source options = {"onnx_path": cfg.pretrained_onnx} if cfg.pretrained_upload_manifest is not None: if cfg.pretrained_onnx is not None: raise ValueError("Uploaded actor cannot use adjacent ONNX sidecars") from pretrained_upload import read_uploaded_source read_pretrained_source = read_uploaded_source options = {"manifest_path": cfg.pretrained_upload_manifest} source = read_pretrained_source( cfg.pretrained_checkpoint, allowed_roots=cfg.pretrained_allowed_roots, **options, target_env=asdict(cfg.env), target_agent=asdict(cfg.agent), ) if cfg.pretrained_source_id is not None and source.manifest["source_id"] != cfg.pretrained_source_id: raise ValueError("基础策略SHA身份变化,拒绝初始化") return source def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None: deployment, reward_config = _configure_task_and_rewards(task_id, cfg) # Validate source identity/semantics before allocating a simulation or optimizer. pretrained = _load_pretrained(cfg) cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "") if cuda_visible == "": device = "cpu" seed = cfg.agent.seed rank = 0 else: local_rank = int(os.environ.get("LOCAL_RANK", "0")) rank = int(os.environ.get("RANK", "0")) # Set EGL device to match the CUDA device. os.environ["MUJOCO_EGL_DEVICE_ID"] = str(local_rank) device = f"cuda:{local_rank}" # Set seed to have diversity in different processes. seed = cfg.agent.seed + local_rank configure_torch_backends() cfg.agent.seed = seed cfg.env.seed = seed print(f"[INFO] Training with: device={device}, seed={seed}, rank={rank}") # Check if this is a tracking task by checking for motion command. is_tracking_task = "motion" in cfg.env.commands and isinstance( cfg.env.commands["motion"], MotionCommandCfg ) if is_tracking_task: if not cfg.motion_file: raise ValueError("For tracking tasks, --motion-file must be set ...") motion_path = Path(cfg.motion_file).expanduser().resolve() if not motion_path.exists(): raise FileNotFoundError(f"Motion file not found: {motion_path}") motion_cmd = cfg.env.commands["motion"] assert isinstance(motion_cmd, MotionCommandCfg) motion_cmd.motion_file = str(motion_path) print(f"[INFO] Using motion file: {motion_cmd.motion_file}") # Check if motion_file is already set (e.g., via CLI --env.commands.motion.motion-file). if motion_cmd.motion_file and Path(motion_cmd.motion_file).exists(): print(f"[INFO] Using local motion file: {motion_cmd.motion_file}") # Enable NaN guard if requested. if cfg.enable_nan_guard: cfg.env.sim.nan_guard.enabled = True print(f"[INFO] NaN guard enabled, output dir: {cfg.env.sim.nan_guard.output_dir}") if rank == 0: print(f"[INFO] Logging experiment in directory: {log_dir}") env = ManagerBasedRlEnv( cfg=cfg.env, device=device, render_mode="rgb_array" if cfg.video else None ) if pretrained is not None: from pretrained import validate_runtime_contract try: validate_runtime_contract(env) except Exception: env.close() raise # The ONNX runner exports this exact snapshot beside and inside policy.onnx. env.platform_deployment = deployment log_root_path = log_dir.parent # Go up from specific run dir to experiment dir. resume_path: Path | None = None explicit_resume = cfg.resume_checkpoint is not None if explicit_resume: resume_path = Path(cfg.resume_checkpoint).expanduser().resolve(strict=True) elif cfg.agent.resume: # Load checkpoint from local filesystem. resume_path = get_checkpoint_path( log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint ) # Only record videos on rank 0 to avoid multiple workers writing to the same files. if cfg.video and rank == 0: env = VideoRecorder( env, video_folder=Path(log_dir) / "videos" / "train", step_trigger=lambda step: step % cfg.video_interval == 0, video_length=cfg.video_length, disable_logger=True, ) print("[INFO] Recording videos during training.") env = RslRlVecEnvWrapper(env, clip_actions=cfg.agent.clip_actions) agent_cfg = asdict(cfg.agent) env_cfg = asdict(cfg.env) runner_cls = load_runner_cls(task_id) if runner_cls is None: runner_cls = MjlabOnPolicyRunner runner_kwargs = {} runner = runner_cls(env, agent_cfg, str(log_dir), device, **runner_kwargs) runner.add_git_repo_to_log(__file__) if pretrained is not None: from pretrained import initialize_runner initialization = initialize_runner(runner, pretrained) env.unwrapped.platform_initialization = initialization if rank == 0: (log_dir / "initialization.json").write_text( json.dumps(initialization, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) print(f"[INFO] Warm-start actor from source {initialization['source_id']}; fresh critic/optimizer, iteration=0") if resume_path is not None: print(f"[INFO]: Loading model checkpoint from: {resume_path}") runner.load(str(resume_path), map_location=device) # Promotion copies the origin manifest, never the original actor weights. origin_path = resume_path.parent / "initialization.json" if origin_path.is_file(): if origin_path.stat().st_size > 64 * 1024: raise ValueError("Initialization provenance exceeds 64 KiB") origin = json.loads(origin_path.read_text(encoding="utf-8")) if not isinstance(origin, dict) or origin.get("mode") != "pretrained-warm-start": raise ValueError("Invalid initialization provenance") env.unwrapped.platform_initialization = origin if rank == 0: (log_dir / "initialization.json").write_text(json.dumps(origin, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if explicit_resume: # RSL-RL stores the last completed zero-based iteration and otherwise # repeats it after load. Explicit tuning promotion uses an absolute target. runner.current_learning_iteration += 1 # Only write config files from rank 0 to avoid race conditions. if rank == 0: dump_yaml(log_dir / "params" / "env.yaml", env_cfg) dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg) if reward_config is not None: reward_snapshot = log_dir / "params" / "reward_config.json" reward_snapshot.parent.mkdir(parents=True, exist_ok=True) reward_snapshot.write_text( json.dumps(reward_config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) iterations = cfg.agent.max_iterations if explicit_resume: iterations = max(0, cfg.agent.max_iterations - runner.current_learning_iteration) print( f"[INFO] Learning target: current={runner.current_learning_iteration}, " f"additional={iterations}, target={cfg.agent.max_iterations}", flush=True, ) runner.learn(num_learning_iterations=iterations, init_at_random_ep_len=True) env.close() def launch_training(task_id: str, args: TrainConfig | None = None): args = args or TrainConfig.from_task(task_id) # Create log directory once before launching workers. if args.output_dir: log_dir = Path(args.output_dir).expanduser().resolve() else: log_root_path = (Path("logs") / "rsl_rl" / args.agent.experiment_name).resolve() log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") if args.agent.run_name: log_dir_name += f"_{args.agent.run_name}" log_dir = log_root_path / log_dir_name log_dir.mkdir(parents=True, exist_ok=True) # Select GPUs based on CUDA_VISIBLE_DEVICES and user specification. selected_gpus, num_gpus = select_gpus(args.gpu_ids) # Set environment variables for all modes. if selected_gpus is None: os.environ["CUDA_VISIBLE_DEVICES"] = "" else: os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected_gpus)) os.environ["MUJOCO_GL"] = "egl" if num_gpus <= 1: # CPU or single GPU: run directly without torchrunx. run_train(task_id, args, log_dir) else: # Multi-GPU: use torchrunx. import torchrunx # torchrunx redirects stdout to logging. logging.basicConfig(level=logging.INFO) # Configure torchrunx logging directory. # Priority: 1) existing env var, 2) user flag, 3) default to {log_dir}/torchrunx. if "TORCHRUNX_LOG_DIR" not in os.environ: if args.torchrunx_log_dir is not None: # User specified a value via flag (could be "" to disable). os.environ["TORCHRUNX_LOG_DIR"] = args.torchrunx_log_dir else: # Default: put logs in training directory. os.environ["TORCHRUNX_LOG_DIR"] = str(log_dir / "torchrunx") print(f"[INFO] Launching training with {num_gpus} GPUs", flush=True) torchrunx.Launcher( hostnames=["localhost"], workers_per_host=num_gpus, backend=None, # Let rsl_rl handle process group initialization. copy_env_vars=torchrunx.DEFAULT_ENV_VARS_FOR_COPY + ("MUJOCO*",), ).run(run_train, task_id, args, log_dir) def main(): # Parse first argument to choose the task. # Import tasks to populate the registry. import mjlab.tasks # noqa: F401 import src.tasks # noqa: F401 all_tasks = list_tasks() chosen_task, remaining_args = tyro.cli( tyro.extras.literal_type_from_choices(all_tasks), add_help=False, return_unknown_args=True, config=mjlab.TYRO_FLAGS, ) args = tyro.cli( TrainConfig, args=remaining_args, default=TrainConfig.from_task(chosen_task), prog=sys.argv[0] + f" {chosen_task}", config=mjlab.TYRO_FLAGS, ) del remaining_args launch_training(task_id=chosen_task, args=args) if __name__ == "__main__": main()