File size: 16,548 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
# 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 shutil
import subprocess
import time

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,
    fast_copy_tree,
    find_nvidia_egl_vendor_file,
    get_root,
    run_subprocess_step,
    start_server_process,
    timed,
    wait_for_server_ready,
)


logger = logging.getLogger(__name__)


REPO_ROOT = get_root()

TRAINING_STEPS = 2

README = REPO_ROOT / "examples/LIBERO/README.md"

DATASET_REL_PATH = pathlib.Path("examples/LIBERO/libero_spatial_no_noops_1.0.0_lerobot")
DATASET_ROOT = REPO_ROOT / DATASET_REL_PATH
SHARED_DATASETS_ROOT = TEST_CACHE_PATH / "datasets"

SHARED_DATASET_ROOT = SHARED_DATASETS_ROOT / DATASET_REL_PATH
MODEL_CHECKPOINT = pathlib.Path(f"/tmp/libero_spatial/checkpoint-{TRAINING_STEPS}")

LIBERO_REPO_PATH = REPO_ROOT / "external_dependencies/LIBERO"
SHARED_LIBERO_REPO = TEST_CACHE_PATH / "repos/LIBERO"

LIBERO_UV_ENV = REPO_ROOT / "gr00t/eval/sim/LIBERO/libero_uv"
SHARED_LIBERO_VENV = TEST_CACHE_PATH / "repos/LIBERO/venv"


def _libero_submodule_initialized() -> bool:
    """Return True when the LIBERO submodule is properly git-initialized."""
    return (LIBERO_REPO_PATH / ".git").is_file()


def _git_modules_path(submodule_path: pathlib.Path) -> pathlib.Path | None:
    """Resolve the .git/modules/<name> path from a submodule's .git file."""
    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 _copy_tree_with_timing(
    src: pathlib.Path,
    dst: pathlib.Path,
    label: str,
    *,
    symlinks: bool = False,
) -> None:
    """fast_copy_tree(src β†’ dst) wrapped in `timed(...)` plus a files/s summary.

    Delegates the bulk copy to ``fast_copy_tree``, which streams the tree
    through a single ``tar`` pipe instead of paying per-file open/stat/read/
    close NFS round-trips. Job 313566325 observed 3.4 files/s with the
    previous ``shutil.copytree`` and timed out; the tar-pipe path collapses
    those round-trips into one sequential read.

    File counting is done from the destination after the copy: ``os.walk``
    on the local destination is metadata-cheap, while walking ``src`` over
    a contended NFS mount could itself add minutes of stat-bound delay
    that would muddy the reported throughput.
    """
    with timed(label):
        t0 = time.perf_counter()
        fast_copy_tree(src, dst, symlinks=symlinks)
        elapsed = max(time.perf_counter() - t0, 1e-9)
        file_count = sum(len(files) for _, _, files in os.walk(dst))
        print(
            f"[libero] {label}: {file_count} files, {file_count / elapsed:.1f} files/s",
            flush=True,
        )


def _prepare_libero_repo(env: dict[str, str]) -> None:
    """Populate external_dependencies/LIBERO, reusing shared cache when available.

    The cache stores both the working tree (which includes the .git pointer file)
    and the git modules directory, so that after restore git sees a fully
    initialized submodule and ``git submodule update --init`` is a fast no-op.
    """
    if _libero_submodule_initialized():
        return

    wt_cache = SHARED_LIBERO_REPO / "wt"
    modules_cache = SHARED_LIBERO_REPO / "modules"

    wt_hit = (wt_cache / ".git").is_file()
    modules_hit = modules_cache.exists()
    cache_hit = wt_hit and modules_hit
    print(
        f"[libero/3a] cache: wt_hit={wt_hit} modules_hit={modules_hit} hit={cache_hit}",
        flush=True,
    )

    if cache_hit:
        # Fast path: restore working tree and git modules from cache.
        _copy_tree_with_timing(wt_cache, LIBERO_REPO_PATH, "step 3a.hit.1: copy wt cache β†’ repo")
        modules_path = _git_modules_path(LIBERO_REPO_PATH)
        if modules_path is not None:
            modules_path.mkdir(parents=True, exist_ok=True)
            _copy_tree_with_timing(
                modules_cache,
                modules_path,
                "step 3a.hit.2: copy modules cache β†’ .git/modules",
            )
        return

    # Slow path: git submodule init, then populate cache.
    with timed("step 3a.miss.1: git submodule update --init"):
        run_subprocess_step(
            ["git", "submodule", "update", "--init", "external_dependencies/LIBERO"],
            step="libero_repo_init",
            cwd=REPO_ROOT,
            env=env,
            log_prefix="libero",
        )
    if TEST_CACHE_PATH.exists():
        modules_path = _git_modules_path(LIBERO_REPO_PATH)
        wt_cache.mkdir(parents=True, exist_ok=True)
        _copy_tree_with_timing(LIBERO_REPO_PATH, wt_cache, "step 3a.miss.2: cache wt β†’ shared")
        if modules_path is not None:
            modules_cache.mkdir(parents=True, exist_ok=True)
            _copy_tree_with_timing(
                modules_path, modules_cache, "step 3a.miss.3: cache modules β†’ shared"
            )


def _libero_venv_ready(root: pathlib.Path = LIBERO_UV_ENV) -> bool:
    """Return True when the libero uv venv looks usable.

    Two subtleties:
    1. Uses pyvenv.cfg rather than bin/python: uv creates bin/python as a
       symlink into /root/.local/share/uv/python/..., which is inaccessible to
       non-root callers.  Path.is_file() returns False for unreadable symlink
       targets, so the caching gate would silently never fire.
    2. Checks for libero-*.dist-info rather than a libero/ source dir: libero
       is installed with --config-settings editable_mode=compat, which creates
       dist-info + a .pth file but no top-level package directory.
    """
    sp = root / ".venv/lib/python3.10/site-packages"
    libero_ok = (sp / "libero").is_dir() or any(sp.glob("libero-*.dist-info"))
    return (root / ".venv/pyvenv.cfg").is_file() and libero_ok


def _prepare_libero_venv(setup_block: str, env: dict[str, str]) -> None:
    """Set up the libero sim venv, using shared cache when available.

    setup_libero.sh always rm -rf's the venv before reinstalling, so this
    function skips it entirely on a cache hit and only re-runs the fast
    register_libero_envs() call that writes $HOME/.libero.
    """
    venv_python = str(LIBERO_UV_ENV / ".venv/bin/python")
    register_cmd = (
        "import os; os.environ.setdefault('MUJOCO_GL','egl');"
        "os.environ.setdefault('PYOPENGL_PLATFORM','egl');"
        "from gr00t.eval.sim.LIBERO.libero_env import register_libero_envs;"
        "register_libero_envs()"
    )

    local_ready = _libero_venv_ready()
    shared_ready = _libero_venv_ready(SHARED_LIBERO_VENV) if not local_ready else False
    print(f"[libero/3b] readiness: local={local_ready} shared={shared_ready}", flush=True)

    if local_ready:
        print("[libero/3b] venv local hit β€” skipping setup_libero.sh", flush=True)
    elif shared_ready:
        LIBERO_UV_ENV.parent.mkdir(parents=True, exist_ok=True)
        # Wipe any stale local state from a previous run before copying.
        if LIBERO_UV_ENV.is_symlink():
            LIBERO_UV_ENV.unlink()
        elif LIBERO_UV_ENV.is_dir():
            shutil.rmtree(LIBERO_UV_ENV)
        _copy_tree_with_timing(
            SHARED_LIBERO_VENV,
            LIBERO_UV_ENV,
            "step 3b.hit: copy shared venv β†’ local (avoid NFS-bound imports)",
            symlinks=True,
        )
    else:
        print("[libero/3b] venv cache miss β€” running setup_libero.sh", flush=True)
        with timed("step 3b.miss.1: setup_libero.sh (uv install)"):
            run_bash_blocks([setup_block], cwd=REPO_ROOT, env=env)
        if TEST_CACHE_PATH.exists() and _libero_venv_ready():
            _copy_tree_with_timing(
                LIBERO_UV_ENV,
                SHARED_LIBERO_VENV,
                "step 3b.miss.2: cache venv β†’ shared",
                symlinks=True,
            )
        return  # setup_libero.sh already ran register_libero_envs

    # Fast path: venv came from cache β€” just re-register envs (~5 s)
    import subprocess as _sp

    with timed("step 3b.hit: register_libero_envs subprocess"):
        _sp.run([venv_python, "-c", register_cmd], env=env, check=True, input=b"n\n")


def _dataset_ready(dataset_root: pathlib.Path) -> bool:
    """Return True when the LIBERO dataset looks complete enough to reuse."""
    modality_path = dataset_root / "meta/modality.json"
    videos_dir = dataset_root / "videos"
    if not modality_path.is_file() or not videos_dir.is_dir():
        return False
    return next(videos_dir.rglob("*.mp4"), None) is not None


def _point_repo_dataset_to_shared() -> None:
    """Point the repo-local dataset path at the shared cached dataset."""
    if DATASET_ROOT.is_symlink():
        if DATASET_ROOT.resolve() == SHARED_DATASET_ROOT.resolve():
            return
        DATASET_ROOT.unlink()
    elif DATASET_ROOT.exists():
        # Keep an existing real local dataset intact rather than replacing it.
        return

    DATASET_ROOT.parent.mkdir(parents=True, exist_ok=True)
    DATASET_ROOT.symlink_to(SHARED_DATASET_ROOT, target_is_directory=True)


def _prepare_libero_dataset(blocks: list, env: dict[str, str]) -> None:
    """Populate the LIBERO spatial dataset once on shared storage and reuse it."""
    if _dataset_ready(DATASET_ROOT):
        return

    if _dataset_ready(SHARED_DATASET_ROOT):
        _point_repo_dataset_to_shared()
        return

    download_code = find_block(
        blocks, "libero_spatial_no_noops_1.0.0_lerobot", language="bash"
    ).code
    if TEST_CACHE_PATH.exists():
        download_code = download_code.replace(
            "examples/LIBERO/libero_spatial_no_noops_1.0.0_lerobot/",
            f"{SHARED_DATASET_ROOT}/",
        )

    run_bash_blocks([download_code], cwd=REPO_ROOT, env=env)

    if _dataset_ready(SHARED_DATASET_ROOT):
        _point_repo_dataset_to_shared()
        return

    assert _dataset_ready(DATASET_ROOT), f"Expected LIBERO dataset at {DATASET_ROOT}"


@pytest.mark.gpu
@pytest.mark.timeout(1200)
def test_libero_readme_workflow_executes_via_subprocess() -> None:
    """Run the LIBERO README finetune (libero_spatial) then server+client eval."""

    print(f"[egl] NVIDIA EGL vendor file: {find_nvidia_egl_vendor_file()}", flush=True)

    env = {**os.environ, "MUJOCO_GL": "egl", "PYOPENGL_PLATFORM": "egl"}
    blocks = extract_code_blocks(README)

    # Step 1: Download + copy modality once, preferring the shared mounted dataset cache.
    with timed("step 1: dataset prep"):
        _prepare_libero_dataset(blocks, env)

    # Remove any leftover output dir so the trainer starts fresh rather than
    # trying to resume from a stale checkpoint (which would fail with --skip_weight_loading
    # if a previous run used the real weights and produced a different architecture).
    if MODEL_CHECKPOINT.parent.exists():
        shutil.rmtree(MODEL_CHECKPOINT.parent)

    # Step 2: Finetune β€” inline README values are replaced to keep the run short.
    # --skip_weight_loading skips loading the 3B checkpoint weights (saves ~80s); the
    # processor/tokenizer is still loaded from the checkpoint so the data pipeline
    # is exercised correctly.  Weights don't matter for a 2-step smoke test.
    finetune_code = replace_once(
        replace_once(
            replace_once(
                replace_once(
                    find_block(blocks, "--output-dir /tmp/libero_spatial", language="bash").code,
                    "NUM_GPUS=8",
                    "NUM_GPUS=1",
                ),
                "MAX_STEPS=20000",
                f"MAX_STEPS={TRAINING_STEPS}",
            ),
            "SAVE_STEPS=1000",
            f"SAVE_STEPS={TRAINING_STEPS}",
        ),
        "GLOBAL_BATCH_SIZE=640",
        "GLOBAL_BATCH_SIZE=2",
    )
    # Pass --skip_weight_loading after `--` so finetune.sh routes it to EXTRA_ARGS and on
    # to launch_finetune.py (unknown args before `--` cause finetune.sh to exit 1).
    finetune_code = finetune_code.rstrip() + " -- --skip_weight_loading"
    with timed("step 2: 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}"
    )

    model_server_host = "127.0.0.1"
    model_server_port = 5552

    # Build server and rollout command strings now (checkpoint exists after finetune).
    server_code = replace_once(
        find_block(blocks, "checkpoints/GR00T-N1.7-LIBERO/libero_10", language="bash").code,
        "checkpoints/GR00T-N1.7-LIBERO/libero_10",
        str(MODEL_CHECKPOINT),
    )
    server_code += f" --device cuda:0 --host {model_server_host} --port {model_server_port}"

    rollout_code = replace_once(
        replace_once(
            replace_once(
                replace_once(
                    find_block(blocks, "libero_uv/.venv/bin/python", 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",
    )

    # Steps 3 + 4 overlapped: start the model server immediately after finetune so
    # model loading runs in parallel with the libero sim venv setup (which takes
    # several minutes on a cache miss).
    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 3a: libero repo prep"):
        _prepare_libero_repo(env)
    with timed("step 3b: sim venv setup"):
        _prepare_libero_venv(find_block(blocks, "setup_libero.sh", language="bash").code, 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("LIBERO_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="libero_rollout",
                cwd=REPO_ROOT,
                env=env,
                log_prefix="libero",
                failure_prefix="LIBERO 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)