Skip to content

Commit 1708b9d

Browse files
authored
Merge pull request #36 from KraHsu/feat/fetch-progress-and-total-num-envs
fetch_asset progress hook + total --num-envs semantics
2 parents af92c5f + 35abc9a commit 1708b9d

7 files changed

Lines changed: 392 additions & 46 deletions

File tree

‎src/genelab/cli/__init__.py‎

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from genelab.cli._interactive import pick_task_interactively
2323
from genelab.cli._prof import prof_app
24+
from genelab.cli._progress import fetch_progress
2425
from genelab.cli._render import (
2526
render_cache,
2627
render_entry_info,
@@ -86,13 +87,16 @@ class _RegistryKindArg(str, Enum):
8687
Runner flags (used when an RL runner is engaged):
8788
8889
\b
89-
--num_envs N Parallel environments.
90-
--agent KIND one of: zero, random, trained (play only).
91-
--checkpoint PATH Resume from a checkpoint.
92-
--seed N RNG seed.
93-
--log_dir PATH Override the log directory.
94-
--max_iterations N Cap training iterations (train only).
95-
--gpus N Distributed training across N GPUs (train only).
90+
--num_envs N Total parallel environments across all ranks.
91+
Must divide evenly by --gpus when both are set.
92+
--num_envs_per_gpu N Per-rank parallel environments. Mutually exclusive
93+
with --num_envs.
94+
--agent KIND one of: zero, random, trained (play only).
95+
--checkpoint PATH Resume from a checkpoint.
96+
--seed N RNG seed.
97+
--log_dir PATH Override the log directory.
98+
--max_iterations N Cap training iterations (train only).
99+
--gpus N Distributed training across N GPUs (train only).
96100
97101
Profiling flags (forwarded to torch.profiler; rank-0 only):
98102
@@ -226,7 +230,8 @@ def info_cmd(
226230
],
227231
) -> None:
228232
_load_extensions(_state(ctx))
229-
render_entry_info(name)
233+
with fetch_progress():
234+
render_entry_info(name)
230235

231236

232237
@app.command(
@@ -331,7 +336,8 @@ def _configured_task(
331336
task_id, overrides = parse_run_args([*tokens, picked])
332337

333338
try:
334-
task = cast(_RunnableTask, TASKS.get(task_id))
339+
with fetch_progress():
340+
task = cast(_RunnableTask, TASKS.get(task_id))
335341
except KeyError as exc:
336342
raise SystemExit(str(exc)) from exc
337343

@@ -387,13 +393,15 @@ def _dispatch_play(
387393
task_cfg = getattr(task, "cfg", None)
388394
agent_cfg = getattr(task_cfg, "agent", None) if task_cfg is not None else None
389395
checkpoint_raw = runner_args.get("checkpoint")
390-
num_envs_raw = runner_args.get("num_envs")
391396
agent_raw = runner_args.get("agent")
392397
if agent_raw is not None and agent_raw not in _AGENT_KINDS:
393398
raise SystemExit(f"--agent must be one of {{zero, random, trained}}; got {agent_raw!r}")
399+
# play is always single-process; either flag is accepted but mapped through the same
400+
# resolver so the mutual-exclusion guard fires on misuse.
401+
num_envs_per_rank = _resolve_per_rank_num_envs(runner_args, gpus=1)
394402
if (
395403
checkpoint_raw is None
396-
and num_envs_raw is None
404+
and num_envs_per_rank is None
397405
and agent_raw is None
398406
and agent_cfg is None
399407
and not prof_args
@@ -408,7 +416,7 @@ def _dispatch_play(
408416
play_task(
409417
task_id,
410418
checkpoint=Path(checkpoint_raw) if checkpoint_raw is not None else None,
411-
num_envs=int(num_envs_raw) if num_envs_raw is not None else None,
419+
num_envs=num_envs_per_rank,
412420
agent=cast("AgentKind | None", agent_raw),
413421
**_coerce_prof_kwargs(prof_args),
414422
)
@@ -435,44 +443,79 @@ def _dispatch_train(
435443

436444
gpus_raw = runner_args.pop("gpus", None)
437445
gpus = int(gpus_raw) if gpus_raw is not None else 1
446+
num_envs_per_rank = _resolve_per_rank_num_envs(runner_args, gpus=gpus)
438447
if gpus > 1 and "TORCHELASTIC_RUN_ID" not in os.environ:
439-
_relaunch_under_torchrun(gpus, agent_cfg, runner_args)
448+
_relaunch_under_torchrun(gpus, agent_cfg, runner_args, num_envs_per_rank)
440449
return
441450

442-
num_envs_raw = runner_args.get("num_envs")
443451
max_iter_raw = runner_args.get("max_iterations")
444452
seed_raw = runner_args.get("seed")
445453
log_dir_raw = runner_args.get("log_dir")
446454
train_task(
447455
task_id,
448456
agent_cfg,
449-
num_envs=int(num_envs_raw) if num_envs_raw is not None else None,
457+
num_envs=num_envs_per_rank,
450458
max_iterations=int(max_iter_raw) if max_iter_raw is not None else None,
451459
seed=int(seed_raw) if seed_raw is not None else None,
452460
log_dir=Path(log_dir_raw) if log_dir_raw is not None else None,
453461
**_coerce_prof_kwargs(prof_args),
454462
)
455463

456464

465+
def _resolve_per_rank_num_envs(runner_args: dict[str, str], *, gpus: int) -> int | None:
466+
"""Pop ``num_envs`` / ``num_envs_per_gpu`` from ``runner_args`` and return per-rank N.
467+
468+
``--num-envs N`` is interpreted as the **total** across all ranks: ``N // gpus``
469+
becomes the per-rank count and ``N`` must be divisible by ``gpus``.
470+
``--num-envs-per-gpu M`` is verbatim per-rank. Passing both is a hard error so users
471+
don't quietly get one or the other's semantics. Returns ``None`` when neither flag
472+
was set so callers can defer to the cfg default.
473+
"""
474+
total_raw = runner_args.pop("num_envs", None)
475+
per_gpu_raw = runner_args.pop("num_envs_per_gpu", None)
476+
if total_raw is not None and per_gpu_raw is not None:
477+
raise SystemExit(
478+
"--num-envs and --num-envs-per-gpu are mutually exclusive; pass exactly "
479+
"one (or neither, to use the task's cfg default)."
480+
)
481+
if per_gpu_raw is not None:
482+
return int(per_gpu_raw)
483+
if total_raw is None:
484+
return None
485+
total = int(total_raw)
486+
if gpus <= 0:
487+
raise SystemExit(f"--gpus must be a positive integer (got {gpus})")
488+
if total % gpus != 0:
489+
raise SystemExit(
490+
f"--num-envs {total} is not divisible by --gpus {gpus}; pick a multiple "
491+
f"of {gpus} or use --num-envs-per-gpu instead."
492+
)
493+
return total // gpus
494+
495+
457496
def _relaunch_under_torchrun(
458497
gpus: int,
459498
agent_cfg: Any,
460499
runner_args: dict[str, str],
500+
num_envs_per_rank: int | None,
461501
) -> None:
462502
"""Re-exec the current ``train`` invocation under torchrun.
463503
464504
The parent precomputes the log directory and forwards it via ``--log-dir`` so all
465505
ranks land in the same directory (avoiding per-rank timestamp drift). The original
466-
argv is forwarded verbatim minus the ``--gpus N`` tokens so env overrides such as
467-
``--env.simulation.steps 5`` survive the relaunch.
506+
argv is forwarded verbatim minus the ``--gpus`` / ``--num-envs`` / ``--num-envs-per-gpu``
507+
tokens; if either env-count flag was set, an authoritative ``--num-envs-per-gpu N``
508+
is injected so each worker sees the parent-resolved per-rank value.
468509
"""
469510
from genelab.rl.runner import resolve_log_dir
470511

471512
log_root_raw = runner_args.get("log_dir")
472513
log_root = Path(log_root_raw) if log_root_raw else Path("logs") / "rsl_rl"
473514
log_dir = resolve_log_dir(log_root, agent_cfg.experiment_name, agent_cfg.run_name)
474515

475-
inner = _strip_gpus_flag(sys.argv[1:])
516+
inner = _strip_distributed_flags(sys.argv[1:])
517+
if num_envs_per_rank is not None:
518+
inner += ["--num-envs-per-gpu", str(num_envs_per_rank)]
476519
if not _has_log_dir_flag(inner):
477520
inner += ["--log-dir", str(log_dir)]
478521

@@ -489,18 +532,24 @@ def _relaunch_under_torchrun(
489532
os.execvp(cmd[0], cmd)
490533

491534

492-
def _strip_gpus_flag(tokens: list[str]) -> list[str]:
493-
"""Drop ``--gpus N`` (two-token form) and ``--gpus=N`` (single-token) from tokens."""
535+
_STRIPPABLE_DISTRIBUTED_FLAGS: Final[frozenset[str]] = frozenset(
536+
{"--gpus", "--num-envs", "--num_envs", "--num-envs-per-gpu", "--num_envs_per_gpu"}
537+
)
538+
539+
540+
def _strip_distributed_flags(tokens: list[str]) -> list[str]:
541+
"""Drop ``--gpus`` / ``--num-envs`` / ``--num-envs-per-gpu`` (and the ``_``-spelt forms),
542+
in both ``--flag value`` and ``--flag=value`` shapes, from a forwarded argv slice."""
494543
out: list[str] = []
495544
skip_next = False
496545
for tok in tokens:
497546
if skip_next:
498547
skip_next = False
499548
continue
500-
if tok == "--gpus":
549+
if tok in _STRIPPABLE_DISTRIBUTED_FLAGS:
501550
skip_next = True
502551
continue
503-
if tok.startswith("--gpus="):
552+
if any(tok.startswith(f"{flag}=") for flag in _STRIPPABLE_DISTRIBUTED_FLAGS):
504553
continue
505554
out.append(tok)
506555
return out

‎src/genelab/cli/_argv.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@
1919
_RUN_BOOL_SHORTCUTS: Final[frozenset[str]] = frozenset({"-v", "--vis", "--gpu", *_PROF_BOOL_FLAGS})
2020

2121
RUNNER_KEYS: Final[frozenset[str]] = frozenset(
22-
{"num_envs", "checkpoint", "max_iterations", "seed", "log_dir", "agent", "gpus"}
22+
{
23+
"num_envs",
24+
"num_envs_per_gpu",
25+
"checkpoint",
26+
"max_iterations",
27+
"seed",
28+
"log_dir",
29+
"agent",
30+
"gpus",
31+
}
2332
)
2433

2534
PROF_KEYS: Final[frozenset[str]] = frozenset(

‎src/genelab/cli/_progress.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Rich-based progress reporter for :func:`genelab.utils.download.fetch_asset`.
2+
3+
The download helper itself stays UI-agnostic and only invokes a
4+
:class:`ProgressCallback`. This module is the CLI's adapter: it installs a
5+
Rich-backed callback into :data:`genelab.utils.download.PROGRESS_CALLBACK` for the
6+
duration of a ``with fetch_progress():`` block so any asset fetched while building
7+
task / env / robot configs renders a per-asset download bar on stderr.
8+
9+
Single concurrent bar suffices today — asset-zoo factories are called serially during
10+
cfg construction, never in threads. If that ever changes the Rich Progress widget will
11+
DTRT (it tracks tasks by id).
12+
"""
13+
14+
from collections.abc import Generator
15+
from contextlib import contextmanager
16+
17+
from rich.console import Console
18+
from rich.progress import (
19+
BarColumn,
20+
DownloadColumn,
21+
Progress,
22+
TaskID,
23+
TextColumn,
24+
TimeRemainingColumn,
25+
TransferSpeedColumn,
26+
)
27+
28+
from genelab.utils.download import PROGRESS_CALLBACK
29+
30+
31+
@contextmanager
32+
def fetch_progress(console: Console | None = None) -> Generator[None, None, None]:
33+
"""Install a Rich-rendered progress reporter for the duration of the block.
34+
35+
Nested usage is a no-op — the inner block sees the outer's ContextVar binding and
36+
skips reinstalling. Pass ``console`` to render onto a specific :class:`Console`;
37+
defaults to a stderr console so stdout output (e.g. ``info`` table) stays clean
38+
for downstream piping.
39+
"""
40+
41+
if PROGRESS_CALLBACK.get() is not None:
42+
yield
43+
return
44+
45+
target = console if console is not None else Console(stderr=True)
46+
progress = Progress(
47+
TextColumn("[bold blue]fetch[/] {task.fields[asset]}"),
48+
BarColumn(),
49+
DownloadColumn(),
50+
TransferSpeedColumn(),
51+
TimeRemainingColumn(),
52+
console=target,
53+
transient=True,
54+
)
55+
task_ids: dict[str, TaskID] = {}
56+
57+
def _callback(done: int, total: int | None, *, name: str) -> None:
58+
task_id = task_ids.get(name)
59+
if task_id is None:
60+
task_id = progress.add_task("download", asset=name, total=total)
61+
task_ids[name] = task_id
62+
progress.update(task_id, completed=done, total=total)
63+
64+
token = PROGRESS_CALLBACK.set(_callback)
65+
progress.start()
66+
try:
67+
yield
68+
finally:
69+
progress.stop()
70+
PROGRESS_CALLBACK.reset(token)

‎src/genelab/utils/__init__.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""Shared utility helpers for GeneLab."""
22

3-
from genelab.utils.download import AssetDownloadError, AssetSpec, fetch_asset
3+
from genelab.utils.download import (
4+
PROGRESS_CALLBACK,
5+
AssetDownloadError,
6+
AssetSpec,
7+
ProgressCallback,
8+
fetch_asset,
9+
)
410
from genelab.utils.math import (
511
matrix_from_quat,
612
normalize,
@@ -17,8 +23,10 @@
1723
)
1824

1925
__all__ = [
26+
"PROGRESS_CALLBACK",
2027
"AssetDownloadError",
2128
"AssetSpec",
29+
"ProgressCallback",
2230
"fetch_asset",
2331
"matrix_from_quat",
2432
"normalize",

0 commit comments

Comments
 (0)