File size: 16,641 Bytes
e479c46 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
import os
import pathlib
import shlex
import shutil
import subprocess
import pytest
from test_support.readme import extract_code_blocks, find_block, replace_once, run_bash_blocks
from test_support.runtime import (
DEFAULT_SERVER_STARTUP_SECONDS,
TEST_CACHE_PATH,
assert_port_available,
demo_dataset_tree_ready,
get_root,
resolve_model_checkpoint_path,
run_subprocess_step,
start_server_process,
timed,
wait_for_server_ready,
)
REPO_ROOT = get_root()
LOGGER = logging.getLogger(__name__)
README = REPO_ROOT / "examples/robocasa/README.md"
ROBOCASA_CHECKPOINT_PATH = os.environ.get("ROBOCASA_CHECKPOINT_PATH", "")
ROBOCASA_EMBODIMENT_TAG = os.environ.get("ROBOCASA_EMBODIMENT_TAG", "ROBOCASA_PANDA_OMRON")
TRAINING_STEPS = 2
MODEL_CHECKPOINT = pathlib.Path(f"/tmp/robocasa_finetune/checkpoint-{TRAINING_STEPS}")
_ROBOCASA_DATASET_ENV = "ROBOCASA_DATASET_PATH"
_SHARED_ROBOCASA_DATASET = TEST_CACHE_PATH / "datasets/robocasa"
ROBOCASA_SUBMODULE_PATH = REPO_ROOT / "external_dependencies/robocasa"
SHARED_ROBOCASA_REPO = TEST_CACHE_PATH / "repos/robocasa"
ROBOCASA_ASSETS_REPO_DIR = REPO_ROOT / "external_dependencies/robocasa/robocasa/models/assets"
ROBOCASA_ASSETS_SHARED_DIR = TEST_CACHE_PATH / "robocasa-assets"
# Version file written alongside cached assets to detect robocasa submodule updates.
_ASSETS_VERSION_FILE = ROBOCASA_ASSETS_SHARED_DIR / ".robocasa_commit"
def _robocasa_submodule_commit() -> str:
"""Return the robocasa submodule commit hash recorded in the main repo HEAD.
Uses ``git ls-tree`` against the main repo so this works even before the
submodule is initialized.
"""
try:
result = subprocess.run(
["git", "ls-tree", "HEAD", "external_dependencies/robocasa"],
capture_output=True,
text=True,
check=True,
cwd=REPO_ROOT,
)
# output: "160000 commit <hash>\texternal_dependencies/robocasa"
parts = result.stdout.split()
return parts[2] if len(parts) >= 3 else "unknown"
except Exception:
return "unknown"
def _shared_asset_dirs() -> list[pathlib.Path]:
"""Return all top-level subdirectories in the shared asset cache."""
if not ROBOCASA_ASSETS_SHARED_DIR.is_dir():
return []
return [p for p in ROBOCASA_ASSETS_SHARED_DIR.iterdir() if p.is_dir()]
def _shared_assets_ready() -> bool:
"""Return True when the shared asset cache is present, non-empty, and matches
the current robocasa submodule commit.
A stale cache (e.g. from a previous robocasa version that lacked newer
fixture files) is treated as not-ready so the assets are re-downloaded.
"""
if not _ASSETS_VERSION_FILE.is_file():
return False
if _ASSETS_VERSION_FILE.read_text().strip() != _robocasa_submodule_commit():
return False
for d in _shared_asset_dirs():
try:
if next((f for f in d.rglob("*") if f.is_file()), None) is not None:
return True
except OSError:
pass
return False
def _assert_required_assets_present() -> None:
"""Raise if the repo asset directory is empty."""
if not ROBOCASA_ASSETS_REPO_DIR.is_dir() or not any(ROBOCASA_ASSETS_REPO_DIR.iterdir()):
raise RuntimeError(f"RoboCasa assets missing at {ROBOCASA_ASSETS_REPO_DIR}")
def _point_repo_assets_to_shared() -> None:
"""Symlink all shared asset subdirectories into the repo asset path."""
ROBOCASA_ASSETS_REPO_DIR.mkdir(parents=True, exist_ok=True)
for shared_dir in _shared_asset_dirs():
repo_dir = ROBOCASA_ASSETS_REPO_DIR / shared_dir.name
if repo_dir.is_symlink():
if repo_dir.resolve() == shared_dir.resolve():
continue
repo_dir.unlink()
elif repo_dir.exists():
shutil.rmtree(repo_dir)
repo_dir.symlink_to(shared_dir, target_is_directory=True)
def _move_repo_assets_to_shared() -> None:
"""Move all downloaded repo asset subdirectories into the shared cache.
Also writes a version file recording the current robocasa submodule commit
so that stale caches are detected when the submodule is updated.
"""
ROBOCASA_ASSETS_SHARED_DIR.mkdir(parents=True, exist_ok=True)
if not ROBOCASA_ASSETS_REPO_DIR.is_dir():
return
for src in ROBOCASA_ASSETS_REPO_DIR.iterdir():
if not src.is_dir() or src.is_symlink():
continue
dst = ROBOCASA_ASSETS_SHARED_DIR / src.name
if dst.exists() or dst.is_symlink():
if dst.is_dir() and not dst.is_symlink():
shutil.rmtree(dst)
else:
dst.unlink()
# Use cp -r + rm -rf instead of shutil.move: the repo and the shared
# PVC are on different filesystems, so shutil.move falls back to a slow
# Python-level copytree that times out on large asset dirs (e.g.
# generative_textures with thousands of PNGs).
subprocess.run(["cp", "-r", str(src), str(dst)], check=True)
shutil.rmtree(str(src))
_ASSETS_VERSION_FILE.write_text(_robocasa_submodule_commit())
def _remove_dangling_repo_asset_symlinks() -> None:
"""Delete repo asset symlinks that point to missing targets."""
if not ROBOCASA_ASSETS_REPO_DIR.is_dir():
return
for repo_dir in ROBOCASA_ASSETS_REPO_DIR.iterdir():
if repo_dir.is_symlink() and not repo_dir.exists():
repo_dir.unlink()
def _robocasa_submodule_initialized() -> bool:
return (ROBOCASA_SUBMODULE_PATH / ".git").is_file()
def _git_modules_path(submodule_path: pathlib.Path) -> pathlib.Path | None:
git_file = submodule_path / ".git"
if not git_file.is_file():
return None
content = git_file.read_text().strip()
if not content.startswith("gitdir:"):
return None
rel = content[len("gitdir:") :].strip()
return (submodule_path / rel).resolve()
def _prepare_robocasa_repo(env: dict[str, str]) -> None:
"""Populate external_dependencies/robocasa from shared cache, or init and cache it."""
if _robocasa_submodule_initialized():
return
wt_cache = SHARED_ROBOCASA_REPO / "wt"
modules_cache = SHARED_ROBOCASA_REPO / "modules"
if (wt_cache / ".git").is_file() and modules_cache.exists():
print(f"[robocasa] restoring submodule from cache {wt_cache}", flush=True)
shutil.copytree(wt_cache, ROBOCASA_SUBMODULE_PATH, dirs_exist_ok=True)
modules_path = _git_modules_path(ROBOCASA_SUBMODULE_PATH)
if modules_path is not None:
modules_path.mkdir(parents=True, exist_ok=True)
shutil.copytree(modules_cache, modules_path, dirs_exist_ok=True)
return
# The directory may exist but be uninitialized (no .git file) due to CI
# checkout strategies that populate submodule dirs without git-initializing them.
# Remove it so git submodule update --init can clone cleanly.
if ROBOCASA_SUBMODULE_PATH.exists() and not _robocasa_submodule_initialized():
shutil.rmtree(ROBOCASA_SUBMODULE_PATH)
run_subprocess_step(
["git", "submodule", "update", "--init", "external_dependencies/robocasa"],
step="robocasa_repo_init",
cwd=REPO_ROOT,
env=env,
log_prefix="robocasa",
)
if TEST_CACHE_PATH.exists():
modules_path = _git_modules_path(ROBOCASA_SUBMODULE_PATH)
print(f"[robocasa] caching submodule to {wt_cache}", flush=True)
wt_cache.mkdir(parents=True, exist_ok=True)
shutil.copytree(ROBOCASA_SUBMODULE_PATH, wt_cache, dirs_exist_ok=True)
if modules_path is not None:
modules_cache.mkdir(parents=True, exist_ok=True)
shutil.copytree(modules_path, modules_cache, dirs_exist_ok=True)
def _build_runtime_env(skip_download_assets: str) -> dict[str, str]:
"""Build the runtime environment used by setup, model server, and rollout."""
return {**os.environ, "SKIP_DOWNLOAD_ASSETS": skip_download_assets, "INSTALL_FLASH_ATTN": "0"}
def _resolve_robocasa_dataset() -> pathlib.Path:
"""Return the RoboCasa LeRobot dataset used for short finetuning."""
env_path_str = os.environ.get(_ROBOCASA_DATASET_ENV, "").strip()
if env_path_str:
env_path = pathlib.Path(env_path_str).expanduser().resolve()
assert demo_dataset_tree_ready(env_path), (
f"{_ROBOCASA_DATASET_ENV} does not point to a complete LeRobot dataset: {env_path}"
)
return env_path
if demo_dataset_tree_ready(_SHARED_ROBOCASA_DATASET):
return _SHARED_ROBOCASA_DATASET
pytest.skip(
"RoboCasa finetune dataset not found. Set "
f"{_ROBOCASA_DATASET_ENV} to a RoboCasa LeRobot dataset from Hugging Face or "
f"populate {_SHARED_ROBOCASA_DATASET}."
)
def _resolve_robocasa_checkpoint(blocks, env: dict[str, str]) -> str:
"""Use ROBOCASA_CHECKPOINT_PATH or create a short fine-tuned checkpoint."""
if ROBOCASA_CHECKPOINT_PATH:
return ROBOCASA_CHECKPOINT_PATH
dataset_path = _resolve_robocasa_dataset()
with timed("step 1a: base model prep"):
base_model_path = resolve_model_checkpoint_path(
hf_repo_id="nvidia/GR00T-N1.7-3B",
path_override_env="GROOT_MODEL_PATH",
repo_root=REPO_ROOT,
)
if MODEL_CHECKPOINT.parent.exists():
shutil.rmtree(MODEL_CHECKPOINT.parent)
finetune_code = replace_once(
replace_once(
replace_once(
replace_once(
replace_once(
replace_once(
replace_once(
replace_once(
find_block(
blocks,
"--output-dir /tmp/robocasa_finetune",
language="bash",
).code,
"NUM_GPUS=8",
"NUM_GPUS=1",
),
"MAX_STEPS=60000",
f"MAX_STEPS={TRAINING_STEPS}",
),
"SAVE_STEPS=2000",
f"SAVE_STEPS={TRAINING_STEPS}",
),
"GLOBAL_BATCH_SIZE=512",
"GLOBAL_BATCH_SIZE=2",
),
"nvidia/GR00T-N1.7-3B",
str(base_model_path),
),
'--dataset-path "$DATASET_PATH"',
f"--dataset-path {shlex.quote(str(dataset_path))}",
),
"--embodiment-tag ROBOCASA_PANDA_OMRON",
f"--embodiment-tag {ROBOCASA_EMBODIMENT_TAG}",
),
"--output-dir /tmp/robocasa_finetune",
f"--output-dir {MODEL_CHECKPOINT.parent}",
)
finetune_code = finetune_code.rstrip() + " -- --skip_weight_loading"
with timed("step 1b: finetune"):
run_bash_blocks(
[finetune_code],
cwd=REPO_ROOT,
env={
**env,
"USE_WANDB": "0",
"DATALOADER_NUM_WORKERS": "0",
"SHARD_SIZE": "64",
"NUM_SHARDS_PER_EPOCH": "1",
},
)
assert MODEL_CHECKPOINT.exists(), (
f"Expected model checkpoint after finetune: {MODEL_CHECKPOINT}"
)
return str(MODEL_CHECKPOINT)
@pytest.mark.gpu
@pytest.mark.timeout(2700)
def test_robocasa_readme_eval_flow() -> None:
"""Run the RoboCasa README finetune/server/client eval flow."""
# Environment setup:
# 1) If assets already exist on shared PVC, reuse them by symlinking.
# 2) Otherwise run setup with download enabled.
shared_assets_ready = _shared_assets_ready()
if not shared_assets_ready:
_remove_dangling_repo_asset_symlinks()
skip_download_assets = "1" if shared_assets_ready else "0"
env = _build_runtime_env(skip_download_assets=skip_download_assets)
blocks = extract_code_blocks(README)
with timed("step 1: checkpoint prep"):
checkpoint_path = _resolve_robocasa_checkpoint(blocks, env)
with timed("step 2: robocasa repo prep"):
_prepare_robocasa_repo(env)
if shared_assets_ready:
_point_repo_assets_to_shared()
# Step 2: Setup sim
with timed("step 3: sim venv setup (setup_RoboCasa.sh)"):
run_bash_blocks(
[find_block(blocks, "setup_RoboCasa.sh", language="bash")],
cwd=REPO_ROOT,
env=env,
force_yes=True,
)
# When setup performs a fresh download, move those assets into shared PVC
# so subsequent runs can skip download and reuse the cached shared copy.
if not shared_assets_ready:
_move_repo_assets_to_shared()
_point_repo_assets_to_shared()
_assert_required_assets_present()
model_server_host = "127.0.0.1"
model_server_port = 5551
# Step 4: Server — N1.7 RoboCasa evaluation requires a finetuned checkpoint.
server_code = replace_once(
replace_once(
find_block(blocks, "<path-to-finetuned-robocasa-checkpoint>", language="bash").code,
"<path-to-finetuned-robocasa-checkpoint>",
checkpoint_path,
),
"--embodiment-tag ROBOCASA_PANDA_OMRON",
f"--embodiment-tag {ROBOCASA_EMBODIMENT_TAG}",
)
server_code += f" --device cuda:0 --host {model_server_host} --port {model_server_port}"
# Step 5: Rollout — substitute test-safe values
rollout_code = replace_once(
replace_once(
replace_once(
replace_once(
find_block(blocks, "rollout_policy.py", language="bash").code,
"--n-episodes 10",
"--n-episodes 1",
),
"--policy-client-port 5555",
f"--policy-client-port {model_server_port}",
),
"--max-episode-steps 720",
"--max-episode-steps 2",
),
"--n-envs 5",
"--n-envs 1",
)
assert_port_available(model_server_host, model_server_port)
model_server_proc, server_log = start_server_process(server_code, cwd=REPO_ROOT, env=env)
with timed("step 4: server startup"):
wait_for_server_ready(
proc=model_server_proc,
host=model_server_host,
port=model_server_port,
timeout_s=float(
os.getenv("ROBOCASA_SERVER_STARTUP_SECONDS", str(DEFAULT_SERVER_STARTUP_SECONDS))
),
server_log=server_log,
)
try:
with timed("step 5: rollout"):
simulation_result, _ = run_subprocess_step(
["bash", "-c", rollout_code],
step="robocasa_rollout",
cwd=REPO_ROOT,
env=env,
log_prefix="robocasa",
failure_prefix="RoboCasa rollout failed",
output_tail_chars=4000,
)
simulation_output = (simulation_result.stdout or "") + (simulation_result.stderr or "")
assert "results:" in simulation_output, (
"Simulation output did not include expected 'results:' marker.\n"
f"output_tail=\n{simulation_output[-4000:]}"
)
assert "success rate:" in simulation_output, (
"Simulation output did not include expected 'success rate:' marker.\n"
f"output_tail=\n{simulation_output[-4000:]}"
)
finally:
if model_server_proc.poll() is None:
model_server_proc.terminate()
try:
model_server_proc.wait(timeout=15)
except subprocess.TimeoutExpired:
model_server_proc.kill()
model_server_proc.wait(timeout=15)
|