Skip to content

Commit ef4c070

Browse files
committed
feat: wire NeMo RL setup to RLix clusters
1 parent 5816eb7 commit ef4c070

3 files changed

Lines changed: 245 additions & 19 deletions

File tree

external/NeMo

rlix/pipeline/nemo_rl_pipeline.py

Lines changed: 236 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
import logging
2828
import os
2929
import threading
30-
from typing import Any, List, Optional
30+
from pathlib import Path
31+
from typing import Any, Dict, List, Optional
3132

3233
import ray
3334

@@ -50,6 +51,12 @@
5051
_BOOTSTRAP_CACHE_VERSION = -1
5152

5253

54+
def _config_get(config: Any, key: str, default: Any = None) -> Any:
55+
if isinstance(config, dict):
56+
return config.get(key, default)
57+
return getattr(config, key, default)
58+
59+
5360
# ---------------------------------------------------------------------------
5461
# RLix hooks — real implementation injected into async_grpo_train
5562
# ---------------------------------------------------------------------------
@@ -593,26 +600,237 @@ def run(self) -> None:
593600
def _setup_nemo_rl_objects(self) -> tuple:
594601
"""Create NeMo RL runtime objects from pipeline_config.
595602
596-
In the full implementation this mirrors examples/run_grpo.py:
597-
- Create Policy (Megatron backend) on shared PG from F12.
598-
- Create VllmGeneration on shared PG from F12.
599-
- Build dataloader, tokenizer, loss_fn, checkpointer.
600-
- Return them for async_grpo_train.
603+
Mirrors ``examples/run_grpo.py`` through tokenizer, generation config,
604+
response data, and ``grpo.setup()``. The only RLix-specific difference is
605+
that training and inference clusters are injected as shared-PG backed
606+
``RLixVirtualClusterAdapter`` instances instead of letting NeMo RL create
607+
standalone ``RayVirtualCluster`` placement groups.
608+
"""
609+
from omegaconf import OmegaConf
610+
611+
from nemo_rl.algorithms.grpo import setup as grpo_setup
612+
from nemo_rl.algorithms.utils import get_tokenizer
613+
from nemo_rl.data.utils import setup_response_data
614+
from nemo_rl.models.generation import configure_generation_config
615+
from nemo_rl.utils.config import (
616+
load_config,
617+
parse_hydra_overrides,
618+
register_omegaconf_resolvers,
619+
)
620+
from nemo_rl.utils.logger import get_next_experiment_dir
601621

602-
Feature 12 dependency: Policy and VllmGeneration must be initialized
603-
on placement groups obtained from RollResourceManagerProxy (shared PG),
604-
not via RayVirtualCluster.create() which would conflict with ROLL workers
605-
in mixed-deployment mode.
622+
nemo_config_path = self._resolve_nemo_config_path()
623+
register_omegaconf_resolvers()
624+
cfg = load_config(nemo_config_path)
606625

607-
Raises:
608-
NotImplementedError: Until Feature 12 (shared PG) is implemented
609-
and wired into this method.
610-
"""
611-
raise NotImplementedError(
612-
"_setup_nemo_rl_objects requires Feature 12 (shared PlacementGroup) "
613-
"to be implemented. In the meantime, call async_grpo_train directly "
614-
"from your training script and pass rlix_hooks=NemoRLRLixHooks(pipeline)."
626+
overrides = _config_get(self._pipeline_config, "nemo_config_overrides", None)
627+
if overrides:
628+
cfg = parse_hydra_overrides(cfg, list(overrides))
629+
630+
master_config = OmegaConf.to_container(cfg, resolve=True)
631+
if not isinstance(master_config, dict):
632+
raise RuntimeError(
633+
f"NeMo config {nemo_config_path!s} did not resolve to a dict"
634+
)
635+
636+
logger.info("[%s] Loaded NeMo RL config from %s", self._pipeline_id, nemo_config_path)
637+
638+
if bool(_config_get(self._pipeline_config, "nemo_increment_log_dir", True)):
639+
master_config["logger"]["log_dir"] = get_next_experiment_dir(
640+
master_config["logger"]["log_dir"]
641+
)
642+
643+
tokenizer = get_tokenizer(master_config["policy"]["tokenizer"])
644+
if master_config["policy"]["generation"] is None:
645+
raise RuntimeError("NeMo RL GRPO requires policy.generation config")
646+
has_refit_draft_weights = bool(master_config["policy"]["draft"]["enabled"])
647+
master_config["policy"]["generation"] = configure_generation_config(
648+
master_config["policy"]["generation"],
649+
tokenizer,
650+
has_refit_draft_weights=has_refit_draft_weights,
651+
)
652+
653+
dataset, val_dataset, task_to_env, val_task_to_env = setup_response_data(
654+
tokenizer,
655+
master_config["data"],
656+
master_config["env"],
657+
)
658+
659+
train_device_mapping = self._resolve_device_mapping(
660+
master_config, "train_device_mapping"
661+
)
662+
infer_device_mapping = self._resolve_device_mapping(
663+
master_config, "infer_device_mapping"
664+
)
665+
train_cluster = self._make_rlix_virtual_cluster(
666+
name=f"{self._pipeline_id}_nemo_train",
667+
device_mapping=train_device_mapping,
668+
max_colocated_worker_groups=1,
669+
sorted_bundle_indices=train_device_mapping,
615670
)
671+
infer_cluster = self._make_rlix_virtual_cluster(
672+
name=f"{self._pipeline_id}_nemo_infer",
673+
device_mapping=infer_device_mapping,
674+
max_colocated_worker_groups=1,
675+
sorted_bundle_indices=None,
676+
)
677+
678+
(
679+
policy,
680+
policy_generation,
681+
_clusters,
682+
dataloader,
683+
val_dataloader,
684+
loss_fn,
685+
nemo_logger,
686+
checkpointer,
687+
grpo_save_state,
688+
master_config,
689+
) = grpo_setup(
690+
master_config,
691+
tokenizer,
692+
dataset,
693+
val_dataset,
694+
external_train_cluster=train_cluster,
695+
external_inference_cluster=infer_cluster,
696+
)
697+
698+
if policy_generation is not None:
699+
setattr(policy_generation, "_rlix_device_mapping", list(infer_device_mapping))
700+
701+
self._policy = policy
702+
self._policy_generation = policy_generation
703+
if self._model_update_service is None:
704+
self._create_model_update_service()
705+
706+
async_cfg = master_config["grpo"]["async_grpo"]
707+
return (
708+
policy,
709+
policy_generation,
710+
dataloader,
711+
val_dataloader,
712+
tokenizer,
713+
loss_fn,
714+
task_to_env,
715+
val_task_to_env,
716+
nemo_logger,
717+
checkpointer,
718+
grpo_save_state,
719+
master_config,
720+
int(async_cfg["max_trajectory_age_steps"]),
721+
)
722+
723+
def _resolve_nemo_config_path(self) -> Path:
724+
raw_path = (
725+
_config_get(self._pipeline_config, "nemo_config_path")
726+
or _config_get(self._pipeline_config, "nemo_rl_config_path")
727+
or _config_get(self._pipeline_config, "config")
728+
)
729+
if not raw_path:
730+
raise RuntimeError(
731+
"NemoRLFullFinetunePipeline requires pipeline_config.nemo_config_path"
732+
)
733+
path = Path(str(raw_path)).expanduser()
734+
if not path.is_absolute():
735+
path = Path.cwd() / path
736+
if not path.exists():
737+
raise FileNotFoundError(f"NeMo RL config not found: {path}")
738+
return path
739+
740+
def _resolve_device_mapping(self, master_config: Dict[str, Any], key: str) -> List[int]:
741+
explicit = _config_get(self._pipeline_config, key)
742+
if explicit is None:
743+
explicit = (
744+
master_config.get("rlix", {}).get(key)
745+
if isinstance(master_config.get("rlix"), dict)
746+
else None
747+
)
748+
if explicit is None:
749+
raise RuntimeError(
750+
f"Missing {key}; provide pipeline_config.{key} or "
751+
f"nemo_config.rlix.{key}"
752+
)
753+
mapping = [int(x) for x in explicit]
754+
if not mapping:
755+
raise RuntimeError(f"{key} must be non-empty")
756+
return mapping
757+
758+
def _make_rlix_virtual_cluster(
759+
self,
760+
*,
761+
name: str,
762+
device_mapping: List[int],
763+
max_colocated_worker_groups: int,
764+
sorted_bundle_indices: Optional[List[int]],
765+
) -> Any:
766+
from rlix.pipeline.nemo_rl_virtual_cluster_adapter import RLixVirtualClusterAdapter
767+
768+
pg_alloc = self._allocate_shared_pg(device_mapping=device_mapping)
769+
placement_groups = self._extract_placement_groups(pg_alloc)
770+
bundle_ct_per_node_list = self._extract_bundle_counts(
771+
pg_alloc=pg_alloc,
772+
placement_groups=placement_groups,
773+
device_mapping=device_mapping,
774+
)
775+
return RLixVirtualClusterAdapter(
776+
placement_groups=placement_groups,
777+
bundle_ct_per_node_list=bundle_ct_per_node_list,
778+
num_gpus_per_node=int(_config_get(self._pipeline_config, "num_gpus_per_node", 1)),
779+
use_gpus=True,
780+
max_colocated_worker_groups=max_colocated_worker_groups,
781+
name=name,
782+
sorted_bundle_indices=sorted_bundle_indices,
783+
device_mapping=device_mapping,
784+
)
785+
786+
def _allocate_shared_pg(self, *, device_mapping: List[int]) -> Any:
787+
from roll.distributed.scheduler.resource_manager import RollResourceManagerProxy
788+
789+
proxy = RollResourceManagerProxy(
790+
num_gpus_per_node=int(_config_get(self._pipeline_config, "num_gpus_per_node", 1))
791+
)
792+
if hasattr(proxy, "allocate_placement_group"):
793+
return proxy.allocate_placement_group(
794+
world_size=len(device_mapping),
795+
device_mapping=list(device_mapping),
796+
)
797+
798+
if sorted(device_mapping) != list(range(len(device_mapping))):
799+
raise RuntimeError(
800+
"RollResourceManagerProxy has no allocate_placement_group(); "
801+
"fallback node2pg mode only supports contiguous zero-based "
802+
f"device mappings, got {device_mapping!r}"
803+
)
804+
return proxy
805+
806+
def _extract_placement_groups(self, pg_alloc: Any) -> List[Any]:
807+
for attr in ("placement_groups", "pgs", "node_placement_groups"):
808+
value = getattr(pg_alloc, attr, None)
809+
if value:
810+
return list(value.values()) if isinstance(value, dict) else list(value)
811+
node2pg = getattr(pg_alloc, "node2pg", None)
812+
if node2pg:
813+
return [node2pg[k] for k in sorted(node2pg)]
814+
if isinstance(pg_alloc, (list, tuple)):
815+
return list(pg_alloc)
816+
raise RuntimeError(
817+
"Unable to extract placement groups from RollResourceManagerProxy allocation"
818+
)
819+
820+
def _extract_bundle_counts(
821+
self,
822+
*,
823+
pg_alloc: Any,
824+
placement_groups: List[Any],
825+
device_mapping: List[int],
826+
) -> List[int]:
827+
for attr in ("bundle_ct_per_node_list", "bundle_counts", "workers_per_node"):
828+
value = getattr(pg_alloc, attr, None)
829+
if value:
830+
return [int(x) for x in value]
831+
if len(placement_groups) == 1:
832+
return [len(device_mapping)]
833+
return [int(getattr(pg, "bundle_count")) for pg in placement_groups]
616834

617835
# ------------------------------------------------------------------
618836
# Phase helpers — stubs for other Features

rlix/pipeline/nemo_rl_virtual_cluster_adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,17 @@ def __init__(
3333
use_gpus: bool = True,
3434
max_colocated_worker_groups: int = 1,
3535
name: str = "",
36+
sorted_bundle_indices: Optional[List[int]] = None,
37+
device_mapping: Optional[List[int]] = None,
3638
) -> None:
3739
self._placement_groups: List[Any] = list(placement_groups)
3840
self._bundle_ct_per_node_list: List[int] = list(bundle_ct_per_node_list)
41+
self._sorted_bundle_indices: Optional[List[int]] = (
42+
list(sorted_bundle_indices) if sorted_bundle_indices is not None else None
43+
)
44+
self.device_mapping: Optional[List[int]] = (
45+
list(device_mapping) if device_mapping is not None else None
46+
)
3947
self.num_gpus_per_node: int = num_gpus_per_node
4048
self.use_gpus: bool = use_gpus
4149
self.max_colocated_worker_groups: int = max_colocated_worker_groups

0 commit comments

Comments
 (0)