Skip to content

Commit 7f05fb4

Browse files
committed
feat(perf): support Kubeflow v1 PyTorchJob + Run:ai scheduling
Add --kubeflow_api_version {v1,v2} so the Kubeflow launch path can target the Training Operator v1 PyTorchJob (kubeflow.org/v1) in addition to the v2 TrainJob. v1 is required on clusters running the v1 operator — notably NVIDIA Run:ai. kubeflow_executor() picks nemo_run's PyTorchJobExecutor for v1 (clear error if the installed nemo_run predates it); since it inherits the same dataclass fields, the existing field reconciliation works unchanged. Extend RunAIPlugin with optional scheduler_name (-> spec.schedulerName, e.g. runai-scheduler, so raw PyTorchJob/TrainJob submissions gang-schedule under Run:ai instead of the default scheduler) and a generic labels passthrough for project/queue membership (key varies by Run:ai version). Both route through pod_spec_overrides / pod_labels, which apply to v1 and v2 alike. Wire through build_csp_plugin and add --runai_scheduler_name / --runai_labels_json. Extend the CSP wiring test to assert the new values reach the plugin. Note: requires bumping the nemo_run pin to a commit that provides PyTorchJobExecutor and the pod_annotations/pod_labels/extra_resource_* fields. Signed-off-by: Rafael M. Koike <koike.rafael@gmail.com>
1 parent 8325ba6 commit 7f05fb4

5 files changed

Lines changed: 299 additions & 18 deletions

File tree

scripts/performance/argument_parser.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,17 @@ def parse_cli_args():
537537
help="Kubernetes namespace for Kubeflow TrainJob. When set, uses the Kubeflow executor instead of Slurm.",
538538
required=False,
539539
)
540+
kubeflow_args.add_argument(
541+
"--kubeflow_api_version",
542+
type=str,
543+
choices=["v1", "v2"],
544+
default="v2",
545+
help="Kubeflow Training-Operator API to submit against. 'v2' (default) uses the "
546+
"TrainJob (trainer.kubeflow.org) via KubeflowExecutor. 'v1' uses the PyTorchJob "
547+
"(kubeflow.org/v1) via PyTorchJobExecutor — required for clusters running the v1 "
548+
"operator, notably NVIDIA Run:ai. Only applies when --kubeflow_namespace is set.",
549+
required=False,
550+
)
540551
kubeflow_args.add_argument(
541552
"--csp",
542553
type=str,
@@ -591,6 +602,24 @@ def parse_cli_args():
591602
required=False,
592603
default=None,
593604
)
605+
kubeflow_args.add_argument(
606+
"--runai_scheduler_name",
607+
type=str,
608+
help="Kubernetes scheduler to pin Run:ai workload pods to (spec.schedulerName), "
609+
"e.g. 'runai-scheduler'. Required for raw PyTorchJob/TrainJob submissions (not via "
610+
"the runai CLI) so Run:ai gang-scheduling and quota apply. Omit to use the default scheduler.",
611+
required=False,
612+
default=None,
613+
)
614+
kubeflow_args.add_argument(
615+
"--runai_labels_json",
616+
type=str,
617+
help="JSON-encoded dict of extra pod labels for Run:ai project/queue membership "
618+
'(e.g. \'{"project": "bench"}\' or \'{"kai.scheduler/queue": "bench"}\'); the exact '
619+
"key depends on your Run:ai version.",
620+
required=False,
621+
default=None,
622+
)
594623
kubeflow_args.add_argument(
595624
"--kubeflow_workdir_pvc",
596625
type=str,

scripts/performance/setup_experiment.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,43 @@ def maybe_increase_n_attempts_on_flaky_failure(
382382
return n_attempts
383383

384384

385+
def build_csp_plugin(
386+
csp: Optional[str],
387+
*,
388+
runai_extended_resources_json: Optional[str] = None,
389+
runai_annotations_json: Optional[str] = None,
390+
runai_pvc_claim_name: Optional[str] = None,
391+
runai_pvc_mount_path: str = "/nemo-workspace",
392+
runai_large_shm: bool = True,
393+
runai_env_json: Optional[str] = None,
394+
runai_scheduler_name: Optional[str] = None,
395+
runai_labels_json: Optional[str] = None,
396+
):
397+
"""Map ``--csp`` (+ its ``--runai_*`` args) to a single CSP fabric plugin.
398+
399+
Returns the plugin instance for the selected CSP, or ``None`` when no CSP is
400+
selected. Kept as a small pure function (no executor/cluster state) so the
401+
args -> plugin wiring is unit-testable without a live cluster — see
402+
``tests/.../test_setup_experiment_csp.py``.
403+
"""
404+
if csp == "aws":
405+
return EKSEnvPlugin()
406+
if csp == "gcp":
407+
return GKEEnvPlugin()
408+
if csp == "runai":
409+
return RunAIPlugin(
410+
extended_resources=json.loads(runai_extended_resources_json) if runai_extended_resources_json else {},
411+
annotations=json.loads(runai_annotations_json) if runai_annotations_json else {},
412+
large_shm=runai_large_shm,
413+
pvc_claim_name=runai_pvc_claim_name,
414+
pvc_mount_path=runai_pvc_mount_path,
415+
env_vars=json.loads(runai_env_json) if runai_env_json else {},
416+
scheduler_name=runai_scheduler_name,
417+
labels=json.loads(runai_labels_json) if runai_labels_json else {},
418+
)
419+
return None
420+
421+
385422
def main(
386423
use_recipes: bool,
387424
model_family_name: str,
@@ -459,12 +496,15 @@ def main(
459496
kubeflow_container_kwargs_json: Optional[str],
460497
kubeflow_labels_json: Optional[str],
461498
kubeflow_pod_annotations_json: Optional[str],
499+
kubeflow_api_version: str = "v2",
462500
runai_extended_resources_json: Optional[str] = None,
463501
runai_annotations_json: Optional[str] = None,
464502
runai_pvc_claim_name: Optional[str] = None,
465503
runai_pvc_mount_path: str = "/nemo-workspace",
466504
runai_large_shm: bool = True,
467505
runai_env_json: Optional[str] = None,
506+
runai_scheduler_name: Optional[str] = None,
507+
runai_labels_json: Optional[str] = None,
468508
deterministic: bool = False,
469509
config_variant: str = "v1",
470510
gres: Optional[str] = None,
@@ -621,6 +661,7 @@ def main(
621661
container_kwargs=json.loads(kubeflow_container_kwargs_json) if kubeflow_container_kwargs_json else None,
622662
labels=json.loads(kubeflow_labels_json) if kubeflow_labels_json else None,
623663
pod_annotations=(json.loads(kubeflow_pod_annotations_json) if kubeflow_pod_annotations_json else None),
664+
api_version=kubeflow_api_version,
624665
)
625666
else:
626667
executor = slurm_executor(
@@ -652,21 +693,19 @@ def main(
652693
# aws -> EKSEnvPlugin (EFA), gcp -> GKEEnvPlugin (gIB),
653694
# runai -> RunAIPlugin (RoCE/SR-IOV). Networking/fabric only;
654695
# arch/recipe/perf env stays in PerfEnvPlugin / the recipe.
655-
if csp == "aws":
656-
plugins.append(EKSEnvPlugin())
657-
elif csp == "gcp":
658-
plugins.append(GKEEnvPlugin())
659-
elif csp == "runai":
660-
plugins.append(
661-
RunAIPlugin(
662-
extended_resources=json.loads(runai_extended_resources_json) if runai_extended_resources_json else {},
663-
annotations=json.loads(runai_annotations_json) if runai_annotations_json else {},
664-
large_shm=runai_large_shm,
665-
pvc_claim_name=runai_pvc_claim_name,
666-
pvc_mount_path=runai_pvc_mount_path,
667-
env_vars=json.loads(runai_env_json) if runai_env_json else {},
668-
)
669-
)
696+
csp_plugin = build_csp_plugin(
697+
csp,
698+
runai_extended_resources_json=runai_extended_resources_json,
699+
runai_annotations_json=runai_annotations_json,
700+
runai_pvc_claim_name=runai_pvc_claim_name,
701+
runai_pvc_mount_path=runai_pvc_mount_path,
702+
runai_large_shm=runai_large_shm,
703+
runai_env_json=runai_env_json,
704+
runai_scheduler_name=runai_scheduler_name,
705+
runai_labels_json=runai_labels_json,
706+
)
707+
if csp_plugin is not None:
708+
plugins.append(csp_plugin)
670709

671710
if not use_recipes:
672711
plugins.append(
@@ -1048,6 +1087,15 @@ def main(
10481087
kubeflow_container_kwargs_json=args.kubeflow_container_kwargs_json,
10491088
kubeflow_labels_json=args.kubeflow_labels_json,
10501089
kubeflow_pod_annotations_json=args.kubeflow_pod_annotations_json,
1090+
kubeflow_api_version=args.kubeflow_api_version,
1091+
runai_extended_resources_json=args.runai_extended_resources_json,
1092+
runai_annotations_json=args.runai_annotations_json,
1093+
runai_pvc_claim_name=args.runai_pvc_claim_name,
1094+
runai_pvc_mount_path=args.runai_pvc_mount_path,
1095+
runai_large_shm=args.runai_large_shm,
1096+
runai_env_json=args.runai_env_json,
1097+
runai_scheduler_name=args.runai_scheduler_name,
1098+
runai_labels_json=args.runai_labels_json,
10511099
deterministic=args.deterministic,
10521100
config_variant=config_variant,
10531101
gres=args.gres,

scripts/performance/utils/csp_plugins.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,17 @@ class RunAIPlugin(Plugin):
133133
pvc_mount_path: Container mount path for the PVC.
134134
env_vars: Additional environment variables injected into the training
135135
container (e.g. ``TRANSFORMERS_OFFLINE``, ``HF_HOME``).
136+
scheduler_name: If set, pin the workload pods to this Kubernetes
137+
scheduler (``spec.schedulerName``). Run:ai gang-schedules through its
138+
own scheduler — typically ``"runai-scheduler"`` — so raw
139+
PyTorchJob/TrainJob submissions (i.e. not via the ``runai`` CLI) must
140+
name it explicitly or the default scheduler will place the pods and
141+
bypass Run:ai quota/fair-share. Left unset (default) so non-Run:ai
142+
paths are unaffected.
143+
labels: Extra pod labels merged onto the workload pods. Run:ai expresses
144+
project/queue membership through a pod label whose key varies by
145+
version (e.g. ``project`` or ``kai.scheduler/queue``); pass the
146+
key/value your cluster expects here rather than hardcoding one.
136147
"""
137148

138149
extended_resources: Dict[str, str] = field(default_factory=dict)
@@ -141,12 +152,28 @@ class RunAIPlugin(Plugin):
141152
pvc_claim_name: Optional[str] = None
142153
pvc_mount_path: str = "/nemo-workspace"
143154
env_vars: Dict[str, str] = field(default_factory=dict)
155+
scheduler_name: Optional[str] = None
156+
labels: Dict[str, str] = field(default_factory=dict)
144157

145158
def setup(self, task: Union["run.Partial", "run.Script"], executor: "run.Executor") -> None:
146159
"""Layer the Run:ai RoCE/SR-IOV fabric onto a Kubeflow executor."""
147160
if not isinstance(executor, KubeflowExecutor):
148161
return
149162

163+
if self.scheduler_name:
164+
# pod_spec_overrides merges into the (v2 TrainJob) podTemplateOverrides
165+
# spec and the (v1 PyTorchJob) replica pod spec alike.
166+
executor.pod_spec_overrides = {
167+
**executor.pod_spec_overrides,
168+
"schedulerName": self.scheduler_name,
169+
}
170+
171+
if self.labels:
172+
if hasattr(executor, "pod_labels"):
173+
executor.pod_labels = {**executor.pod_labels, **self.labels}
174+
else:
175+
executor.labels = {**executor.labels, **self.labels}
176+
150177
if self.extended_resources:
151178
executor.extra_resource_requests = {**executor.extra_resource_requests, **self.extended_resources}
152179
executor.extra_resource_limits = {**executor.extra_resource_limits, **self.extended_resources}

scripts/performance/utils/executors.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def kubeflow_executor(
198198
container_kwargs: Optional[Dict[str, Any]] = None,
199199
labels: Optional[Dict[str, Any]] = None,
200200
pod_annotations: Optional[Dict[str, Any]] = None,
201+
api_version: str = "v2",
201202
) -> run.KubeflowExecutor:
202203
"""Build a Kubeflow Training Operator executor.
203204
@@ -239,9 +240,16 @@ def kubeflow_executor(
239240
labels: Pod labels.
240241
pod_annotations: Annotations applied to the trainer pod template metadata
241242
(e.g. ``networking.gke.io/interfaces`` for GKE RDMA NIC attachment).
243+
api_version: Which Kubeflow Training-Operator API to submit against.
244+
``"v2"`` (default) builds a ``run.KubeflowExecutor`` (TrainJob,
245+
``trainer.kubeflow.org``). ``"v1"`` builds a
246+
``run.PyTorchJobExecutor`` (PyTorchJob, ``kubeflow.org/v1``) for
247+
clusters running the v1 operator — notably **NVIDIA Run:ai**. Both
248+
share the same configuration surface; only the submitted custom
249+
resource and the distributed-launch env contract differ.
242250
243251
Returns:
244-
Configured ``run.KubeflowExecutor`` instance.
252+
Configured ``run.KubeflowExecutor`` (or ``run.PyTorchJobExecutor`` for v1).
245253
"""
246254
# K8s/Kubeflow jobs deliberately do NOT inherit PERF_ENV_VARS. That dict was
247255
# tuned for the Slurm perf-benchmark path; the verified standalone K8s launch
@@ -272,7 +280,7 @@ def kubeflow_executor(
272280
}
273281
labels = {**ci_labels, **(labels or {})}
274282

275-
executor = run.KubeflowExecutor(
283+
kf_kwargs = dict(
276284
# Launch each replica's entrypoint under torchrun so the torch-distributed
277285
# ClusterTrainingRuntime's rendezvous env (MASTER_ADDR, nnodes, nproc) is
278286
# consumed and a single WORLD_SIZE = num_nodes * gpus_per_node process
@@ -308,7 +316,8 @@ def kubeflow_executor(
308316
# `kubectl get trainjob -l` and `kubectl get pods -l` resolve the origin.
309317
pod_labels=labels,
310318
# pod_annotations land on the trainer pod template metadata (e.g. GKE
311-
# networking.gke.io/interfaces to attach the RDMA NICs for gIB).
319+
# networking.gke.io/interfaces to attach the RDMA NICs for gIB, or the
320+
# Run:ai Multus k8s.v1.cni.cncf.io/networks RoCE rail attachments).
312321
pod_annotations=pod_annotations or {},
313322
# include_submodules=True: KubeflowExecutor.package() ships the packager
314323
# tarball to <workdir_pvc_path>/<user>/code, which the launcher overlays
@@ -321,4 +330,49 @@ def kubeflow_executor(
321330
# are harmless cost there.)
322331
packager=run.GitArchivePackager(include_submodules=True),
323332
)
333+
334+
# Select the Training-Operator API. v1 (PyTorchJob) lives in a separate
335+
# nemo_run executor subclass; fall back with a clear error if the installed
336+
# nemo_run predates it rather than silently launching a v2 TrainJob onto a
337+
# v1-only cluster (e.g. Run:ai), which would never schedule.
338+
if api_version in ("v1", "pytorchjob", "PyTorchJob"):
339+
executor_cls = getattr(run, "PyTorchJobExecutor", None)
340+
if executor_cls is None:
341+
raise RuntimeError(
342+
"kubeflow_api_version='v1' requires a nemo_run build that provides "
343+
"PyTorchJobExecutor (Kubeflow Training Operator v1 / PyTorchJob). "
344+
"Upgrade nemo_run (NVIDIA-NeMo/Run) to a commit that includes it, or "
345+
"use --kubeflow_api_version v2 for the TrainJob (v2) operator."
346+
)
347+
else:
348+
executor_cls = run.KubeflowExecutor
349+
350+
# Reconcile against the installed executor API. Older releases (e.g.
351+
# 0.9.0rc0) predate the granular ``pod_labels`` / ``pod_annotations`` fields
352+
# (they expose a single ``annotations``) and ``train_job_basename``. Rather
353+
# than pin a newer nemo_run, degrade gracefully so the same recipe launches
354+
# across versions — folding the granular values into the legacy fields so
355+
# RoCE/Multus pod annotations and CI-origin labels are not silently dropped.
356+
# PyTorchJobExecutor inherits the same dataclass fields as KubeflowExecutor,
357+
# so this reconciliation works identically for either class.
358+
_kf_fields = set(
359+
getattr(executor_cls, "model_fields", None)
360+
or getattr(executor_cls, "__dataclass_fields__", {})
361+
or {}
362+
)
363+
if _kf_fields:
364+
if "pod_annotations" not in _kf_fields and kf_kwargs.get("pod_annotations"):
365+
if "annotations" in _kf_fields:
366+
kf_kwargs["annotations"] = {
367+
**(kf_kwargs.get("annotations") or {}),
368+
**kf_kwargs["pod_annotations"],
369+
}
370+
if "pod_labels" not in _kf_fields and kf_kwargs.get("pod_labels"):
371+
kf_kwargs["labels"] = {
372+
**(kf_kwargs.get("labels") or {}),
373+
**kf_kwargs["pod_labels"],
374+
}
375+
kf_kwargs = {k: v for k, v in kf_kwargs.items() if k in _kf_fields}
376+
377+
executor = executor_cls(**kf_kwargs)
324378
return executor

0 commit comments

Comments
 (0)