Skip to content

Commit a2ad84a

Browse files
sjayaram-nvsudostock
authored andcommitted
Support for NVCRE init containers (#6375)
Signed-off-by: Smitha Jayaram <sjayaram@nvidia.com> Signed-off-by: Alex Filby <afilby@nvidia.com>
1 parent 008fed4 commit a2ad84a

7 files changed

Lines changed: 450 additions & 11 deletions

File tree

‎scripts/performance/argument_parser.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -738,12 +738,17 @@ def parse_cli_args():
738738
required=False,
739739
default=None,
740740
)
741+
nvcre_args.add_argument(
742+
"--nvcre_use_mbridge_commit",
743+
action="store_true",
744+
help=("Run this Megatron-Bridge checkout in the pods instead of the image's /opt/Megatron-Bridge"),
745+
)
741746
nvcre_args.add_argument(
742747
"--nvcre_timeout_per_job",
743748
type=str,
744-
help="Per-job timeout passed to Nvcre orchestration (e.g. '24h').",
749+
help="Per-job timeout passed to Nvcre orchestration (e.g. '12h').",
745750
required=False,
746-
default="24h",
751+
default="12h",
747752
)
748753

749754
nvcre_args.add_argument(

‎scripts/performance/setup_experiment.py‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@
6161
except (ImportError, ModuleNotFoundError):
6262
from .perf_plugins import NsysPlugin, PreemptionPlugin, PyTorchProfilerPlugin
6363

64+
try:
65+
from utils.nvcre_bridge import (
66+
BRIDGE_POD_ROOT,
67+
WORKSPACE_MOUNT,
68+
WORKSPACE_VOLUME,
69+
LocalBridge,
70+
bridge_init_container,
71+
local_bridge_source,
72+
)
73+
except (ImportError, ModuleNotFoundError):
74+
from .utils.nvcre_bridge import (
75+
BRIDGE_POD_ROOT,
76+
WORKSPACE_MOUNT,
77+
WORKSPACE_VOLUME,
78+
LocalBridge,
79+
bridge_init_container,
80+
local_bridge_source,
81+
)
82+
6483
SCRIPT_DIR = Path(__file__).parent.resolve()
6584
REPO_ROOT = SCRIPT_DIR.parents[1]
6685
BRIDGE_SOURCE_DIR = REPO_ROOT / "src" / "megatron" / "bridge"
@@ -95,7 +114,7 @@ def _filter_run_script_args(argv: List[str]) -> List[str]:
95114
Value-taking flags are passed as ``--flag value`` or ``--flag=value``.
96115
Boolean flags (no following value) are listed in ``_LAUNCHER_ONLY_BOOL``.
97116
"""
98-
_LAUNCHER_ONLY_BOOL = {"--offline", "--dryrun", "-d"}
117+
_LAUNCHER_ONLY_BOOL = {"--offline", "--dryrun", "-d", "--nvcre_use_mbridge_commit"}
99118

100119
def _is_launcher_only(flag: str) -> bool:
101120
return (
@@ -158,6 +177,17 @@ def _bridge_source_mount(in_container_script_dir: str) -> str:
158177
return f"{BRIDGE_SOURCE_DIR}:{target}"
159178

160179

180+
def _resolve_local_bridge(nvcre_use_mbridge_commit: bool, nvcre_namespace: Optional[str]) -> Optional[LocalBridge]:
181+
"""The checkout the pods should run for ``--nvcre_use_mbridge_commit``, or None when the flag is off."""
182+
if not nvcre_use_mbridge_commit:
183+
return None
184+
if nvcre_namespace is None:
185+
raise ValueError("--nvcre_use_mbridge_commit requires --nvcre_namespace.")
186+
local_bridge = local_bridge_source(REPO_ROOT)
187+
logger.info("Pods will run Megatron-Bridge %s@%s from the init container", local_bridge.url, local_bridge.commit)
188+
return local_bridge
189+
190+
161191
def _build_nemorun_script(
162192
*,
163193
script_path: str,
@@ -168,10 +198,11 @@ def _build_nemorun_script(
168198
) -> run.Script:
169199
"""Build the rank-local task and apply optional Kubeflow NUMA binding."""
170200
in_container_repo_root = Path(script_dir).parents[1]
201+
python_path = [script_dir, str(in_container_repo_root / "src")]
171202
task = run.Script(
172203
path=script_path,
173204
entrypoint="python",
174-
env={"PYTHONPATH": f"{script_dir}:{in_container_repo_root / 'src'}:$PYTHONPATH"},
205+
env={"PYTHONPATH": ":".join([*python_path, "$PYTHONPATH"])},
175206
args=args,
176207
)
177208
if kubeflow_namespace and _kubeflow_numa_binding_enabled(custom_env_vars):
@@ -578,7 +609,8 @@ def main(
578609
nvcre_node_selector_json: Optional[str] = None,
579610
nvcre_volumes_json: Optional[str] = None,
580611
nvcre_volume_mounts_json: Optional[str] = None,
581-
nvcre_timeout_per_job: str = "24h",
612+
nvcre_use_mbridge_commit: bool = False,
613+
nvcre_timeout_per_job: str = "12h",
582614
nvcre_kubeconfig: Optional[str] = None,
583615
nvcre_kube_context: Optional[str] = None,
584616
nvcre_gang_scheduler_name: Optional[str] = None,
@@ -656,8 +688,11 @@ def main(
656688
# Kubeflow the trainer pod runs the image — which ships Megatron-Bridge at
657689
# /opt/Megatron-Bridge — and custom_mounts do not apply, so the launcher's
658690
# /tmp path does not exist in the pod; use the image's script path instead.
691+
# On NVCRE, --nvcre_use_mbridge_commit has an init container clone this checkout's commits into the pod.
692+
local_bridge = _resolve_local_bridge(nvcre_use_mbridge_commit, nvcre_namespace)
659693
if kubeflow_namespace or nvcre_namespace:
660-
in_container_script_dir = "/opt/Megatron-Bridge/scripts/performance"
694+
bridge_root = BRIDGE_POD_ROOT if local_bridge else "/opt/Megatron-Bridge"
695+
in_container_script_dir = f"{bridge_root}/scripts/performance"
661696
in_container_script_path = f"{in_container_script_dir}/{script_name}"
662697
else:
663698
in_container_script_dir = str(SCRIPT_DIR)
@@ -720,6 +755,9 @@ def main(
720755
from utils.executors import nvcre_executor
721756
except ImportError:
722757
from .utils.executors import nvcre_executor
758+
init_containers = [bridge_init_container(local_bridge, container_image)] if local_bridge else None
759+
extra_volumes = [dict(WORKSPACE_VOLUME)] if local_bridge else []
760+
extra_mounts = [dict(WORKSPACE_MOUNT)] if local_bridge else []
723761
executor = nvcre_executor(
724762
namespace=nvcre_namespace,
725763
container_image=container_image,
@@ -730,8 +768,9 @@ def main(
730768
workdir_pvc_path=nvcre_workdir_pvc_path,
731769
workdir_local_path=nvcre_workdir_local_path,
732770
node_selector=json.loads(nvcre_node_selector_json) if nvcre_node_selector_json else None,
733-
volumes=json.loads(nvcre_volumes_json) if nvcre_volumes_json else None,
734-
volume_mounts=json.loads(nvcre_volume_mounts_json) if nvcre_volume_mounts_json else None,
771+
volumes=(json.loads(nvcre_volumes_json) if nvcre_volumes_json else []) + extra_volumes,
772+
volume_mounts=(json.loads(nvcre_volume_mounts_json) if nvcre_volume_mounts_json else []) + extra_mounts,
773+
init_containers=init_containers,
735774
timeout_per_job=nvcre_timeout_per_job,
736775
kubeconfig=nvcre_kubeconfig,
737776
kube_context=nvcre_kube_context,
@@ -1062,7 +1101,7 @@ def main(
10621101
task=args.task,
10631102
compute_dtype=args.compute_dtype,
10641103
gpu=args.gpu,
1065-
hf_token=args.hf_token or os.environ.get("HF_TOKEN"),
1104+
hf_token=args.hf_token,
10661105
offline=args.offline,
10671106
# Force detach for Nvcre — llmb-run polls for completion via nvcrectl
10681107
detach=True if args.nvcre_namespace else args.detach,
@@ -1150,6 +1189,7 @@ def main(
11501189
nvcre_node_selector_json=args.nvcre_node_selector_json,
11511190
nvcre_volumes_json=args.nvcre_volumes_json,
11521191
nvcre_volume_mounts_json=args.nvcre_volume_mounts_json,
1192+
nvcre_use_mbridge_commit=args.nvcre_use_mbridge_commit,
11531193
nvcre_timeout_per_job=args.nvcre_timeout_per_job,
11541194
nvcre_kubeconfig=args.nvcre_kubeconfig,
11551195
nvcre_kube_context=args.nvcre_kube_context,

‎scripts/performance/utils/executors.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ def nvcre_executor(
381381
node_selector: Optional[dict] = None,
382382
volumes: Optional[list] = None,
383383
volume_mounts: Optional[list] = None,
384-
timeout_per_job: str = "24h",
384+
init_containers: Optional[list] = None,
385+
timeout_per_job: str = "12h",
385386
kubeconfig: Optional[str] = None,
386387
kube_context: Optional[str] = None,
387388
gang_scheduler_name: Optional[str] = None,
@@ -400,7 +401,9 @@ def nvcre_executor(
400401
node_selector: Kubernetes node selector labels for targeting specific nodes.
401402
volumes: Additional Kubernetes volume specs to attach to the pod.
402403
volume_mounts: Additional Kubernetes volume mount specs.
403-
timeout_per_job: Maximum wall-clock time per job (e.g. ``"24h"``).
404+
init_containers: Kubernetes init container specs emitted as the WorkloadRun's ``spec.initContainers``
405+
(e.g. to clone a pinned Megatron-Bridge into a shared volume that replaces the image's copy).
406+
timeout_per_job: Maximum wall-clock time per job (e.g. ``"12h"``).
404407
kubeconfig: Path to the kubeconfig file. Defaults to the cluster default.
405408
kube_context: Kubernetes context to use from the kubeconfig.
406409
gang_scheduler_name: Name of the gang scheduler (e.g. ``"kai-scheduler"``).
@@ -413,6 +416,8 @@ def nvcre_executor(
413416
"NvcreExecutor is unavailable: the installed nemo_run does not provide nemo_run.core.execution.nvcre"
414417
)
415418

419+
# Only passed when requested, so a nemo_run without ``init_containers`` still works for jobs that don't use it.
420+
extra = {"init_containers": init_containers} if init_containers else {}
416421
return NvcreExecutor(
417422
namespace=namespace,
418423
container_image=container_image,
@@ -429,4 +434,5 @@ def nvcre_executor(
429434
kubeconfig=kubeconfig,
430435
kube_context=kube_context,
431436
gang_scheduler_name=gang_scheduler_name,
437+
**extra,
432438
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Run the launcher's Megatron-Bridge checkout in NVCRE pods instead of the image's copy.
16+
17+
On Slurm the launcher's checkout is bind-mounted over the image's ``/opt/Megatron-Bridge``.
18+
Pods have no such mount, so an init container clones the same Megatron-Bridge commit into
19+
a volume that the training container shares."""
20+
21+
import re
22+
import shlex
23+
import subprocess
24+
from pathlib import Path
25+
from typing import Any, NamedTuple
26+
from urllib.parse import urlsplit, urlunsplit
27+
28+
29+
BRIDGE_POD_ROOT = "/mnt/workspace/megatron-bridge"
30+
WORKSPACE_VOLUME: dict[str, Any] = {"name": "workspace", "emptyDir": {}}
31+
WORKSPACE_MOUNT: dict[str, Any] = {"name": "workspace", "mountPath": "/mnt/workspace"}
32+
33+
34+
class LocalBridge(NamedTuple):
35+
"""Where the launcher's checkout can be fetched from, and at which commit."""
36+
37+
url: str
38+
commit: str
39+
40+
41+
def _git(repo_root: Path, *args: str) -> str:
42+
return subprocess.run(
43+
["git", "-C", str(repo_root), *args], check=True, capture_output=True, text=True
44+
).stdout.strip()
45+
46+
47+
def pod_clone_url(remote_url: str) -> str:
48+
"""HTTPS URL a pod can clone anonymously: SSH remotes are rewritten and any credentials dropped."""
49+
# scp-style ``git@host:path``: the colon separates host from path.
50+
scp = re.match(r"^git@([^:/]+):(.+)$", remote_url)
51+
if scp:
52+
return f"https://{scp.group(1)}/{scp.group(2)}"
53+
parts = urlsplit(remote_url)
54+
if parts.scheme == "ssh" and parts.hostname:
55+
# The port of an ssh:// URL is the SSH port, which does not apply over HTTPS.
56+
return urlunsplit(("https", parts.hostname, parts.path, "", ""))
57+
if parts.scheme in ("http", "https") and parts.hostname:
58+
host = parts.hostname + (f":{parts.port}" if parts.port else "")
59+
return urlunsplit((parts.scheme, host, parts.path, "", ""))
60+
raise ValueError(f"Cannot derive a clone URL a pod can reach from remote {remote_url!r}")
61+
62+
63+
def local_bridge_source(repo_root: Path) -> LocalBridge:
64+
"""The origin URL and checked-out commit of the Megatron-Bridge checkout at ``repo_root``."""
65+
try:
66+
url = pod_clone_url(_git(repo_root, "config", "--get", "remote.origin.url"))
67+
commit = _git(repo_root, "rev-parse", "HEAD")
68+
# Submodule pointers are ignored: only this repository's own commit is fetched.
69+
dirty = _git(repo_root, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules=all")
70+
except subprocess.CalledProcessError as exc:
71+
raise RuntimeError(
72+
f"--nvcre_use_mbridge_commit needs {repo_root} to be a git checkout with an 'origin' remote: {exc.stderr}"
73+
) from exc
74+
if dirty:
75+
raise RuntimeError(
76+
f"--nvcre_use_mbridge_commit runs commit {commit} in the pods, but {repo_root} has uncommitted changes "
77+
"to tracked files. Commit and push them, or stash them, and resubmit."
78+
)
79+
return LocalBridge(url, commit)
80+
81+
82+
def bridge_init_container(source: LocalBridge, image: str) -> dict[str, Any]:
83+
"""Init container that clones ``source`` into ``BRIDGE_POD_ROOT``; ``image`` is the training image (has git)."""
84+
steps = [
85+
"set -ex",
86+
f"git init -q {BRIDGE_POD_ROOT}",
87+
f"cd {BRIDGE_POD_ROOT}",
88+
f"git remote add origin {shlex.quote(source.url)}",
89+
f"git fetch -q --depth 1 origin {shlex.quote(source.commit)}",
90+
"git checkout -q FETCH_HEAD",
91+
]
92+
return {
93+
"name": "megatron-bridge-clone",
94+
"image": image,
95+
"command": ["/bin/bash", "-c"],
96+
"args": ["\n".join(steps)],
97+
"volumeMounts": [dict(WORKSPACE_MOUNT)], # a copy: shared objects would be dumped as YAML anchors
98+
}

‎tests/unit_tests/scripts/performance/test_executors.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,40 @@ def test_nvcre_executor_optional_fields():
279279
assert executor.volume_mounts == []
280280

281281

282+
@pytest.mark.skipif(not HAS_NVCRE, reason="nemo_run nvcre not available")
283+
def test_nvcre_executor_init_containers_default_to_none():
284+
"""Without init containers the executor is built exactly as before."""
285+
executor = nvcre_executor(namespace="ns", container_image="img:latest", nodes=1, num_gpus_per_node=8)
286+
287+
assert not getattr(executor, "init_containers", [])
288+
289+
290+
@pytest.mark.skipif(
291+
not HAS_NVCRE or "init_containers" not in getattr(NvcreExecutor, "__dataclass_fields__", {}),
292+
reason="nemo_run NvcreExecutor without init_containers",
293+
)
294+
def test_nvcre_executor_forwards_init_containers():
295+
"""init_containers must reach NvcreExecutor unchanged."""
296+
init_container = {
297+
"name": "megatron-bridge-clone",
298+
"image": "nvcr.io/nvidia/nemo:26.08.01",
299+
"command": ["/bin/bash", "-c"],
300+
"args": ["git clone https://github.com/NVIDIA-NeMo/Megatron-Bridge.git /mnt/workspace/megatron-bridge"],
301+
"volumeMounts": [{"name": "workspace", "mountPath": "/mnt/workspace"}],
302+
}
303+
executor = nvcre_executor(
304+
namespace="ns",
305+
container_image="img:latest",
306+
nodes=1,
307+
num_gpus_per_node=8,
308+
volumes=[{"name": "workspace", "emptyDir": {}}],
309+
volume_mounts=[{"name": "workspace", "mountPath": "/mnt/workspace"}],
310+
init_containers=[init_container],
311+
)
312+
313+
assert executor.init_containers == [init_container]
314+
315+
282316
@pytest.mark.skipif(not HAS_NVCRE, reason="nemo_run nvcre not available")
283317
def test_nvcre_executor_has_no_test_scale():
284318
"""test_scale must not be wired up — it was intentionally removed."""

0 commit comments

Comments
 (0)