Skip to content

Commit 6afeaae

Browse files
committed
feat(sc): NeMo-Gym path
Signed-off-by: Yuki Huang <yukih@nvidia.com>
1 parent 03c7f78 commit 6afeaae

5 files changed

Lines changed: 185 additions & 64 deletions

File tree

examples/nemo_gym/run_distillation_nemo_gym.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@
3232
from nemo_rl.algorithms.utils import get_tokenizer
3333
from nemo_rl.data.utils import setup_response_data
3434
from nemo_rl.distributed.virtual_cluster import init_ray
35-
from nemo_rl.environments.nemo_gym import (
36-
setup_nemo_gym_config,
37-
)
35+
from nemo_rl.environments.nemo_gym import setup_nemo_gym_config
3836
from nemo_rl.models.generation import configure_generation_config
3937
from nemo_rl.utils.config import (
4038
load_config,

examples/nemo_gym/run_grpo_nemo_gym.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@
4141
from nemo_rl.algorithms.utils import get_tokenizer
4242
from nemo_rl.data.utils import setup_response_data
4343
from nemo_rl.distributed.virtual_cluster import init_ray
44-
from nemo_rl.environments.nemo_gym import (
45-
setup_nemo_gym_config,
46-
)
44+
from nemo_rl.environments.nemo_gym import setup_nemo_gym_config
4745
from nemo_rl.experience.rollouts import run_async_nemo_gym_rollout
4846
from nemo_rl.models.generation import configure_generation_config
4947
from nemo_rl.utils.config import (

nemo_rl/algorithms/grpo.py

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import ray
2525
import torch
2626
from pydantic import BaseModel, Field, model_validator
27-
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
2827
from torchdata.stateful_dataloader import StatefulDataLoader
2928
from transformers import AutoProcessor
3029
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
@@ -80,12 +79,7 @@
8079
prepare_segment_topology,
8180
)
8281
from nemo_rl.environments.interfaces import EnvironmentInterface
83-
from nemo_rl.environments.nemo_gym import (
84-
NemoGym,
85-
NemoGymConfig,
86-
get_nemo_gym_uv_cache_dir,
87-
get_nemo_gym_venv_dir,
88-
)
82+
from nemo_rl.environments.nemo_gym import spinup_nemo_gym_actor
8983
from nemo_rl.experience.rollouts import (
9084
EffortLevelsConfig,
9185
get_nemo_gym_thinking_tags,
@@ -551,61 +545,14 @@ def init_train_dataloader(dataset, suffix: str = ""):
551545
master_config, enable_nemo_gym=enable_nemo_gym
552546
)
553547
nemo_gym_actor = None
554-
if enable_nemo_gym:
555-
nemo_gym_num_nodes = env_configs.get("nemo_gym", {}).get("num_gpu_nodes", 0)
556-
ray_runtime_ctx = ray.get_runtime_context()
557-
ray_cur_node_id = ray_runtime_ctx.get_node_id()
558-
else:
559-
nemo_gym_num_nodes = 0
560-
ray_cur_node_id = None
561548

562549
def _spinup_nemo_gym(base_urls, model_name):
563550
"""Spin up the NeMo Gym actor against the given generation server URLs."""
564551
t0 = time.perf_counter()
565-
nemo_gym_py_exec = get_actor_python_env("nemo_rl.environments.nemo_gym.NemoGym")
566-
if nemo_gym_py_exec.startswith("uv"):
567-
nemo_gym_py_exec = create_local_venv_on_each_node(
568-
nemo_gym_py_exec, "nemo_rl.environments.nemo_gym.NemoGym"
569-
)
570-
nemo_gym_dict = dict(env_configs["nemo_gym"])
571-
# NeMo-RL-side detection knobs are top-level NemoGymConfig fields
572-
# (where the detector reads them), not part of Gym's global config.
573-
invalid_tool_call_patterns = nemo_gym_dict.pop(
574-
"invalid_tool_call_patterns", None
552+
enable_router_replay = router_replay_enabled(policy_config)
553+
actor = spinup_nemo_gym_actor(
554+
env_configs, base_urls, model_name, enable_router_replay
575555
)
576-
thinking_tags = nemo_gym_dict.pop("thinking_tags", None)
577-
# Pass prebuilt cache + venv dirs through the global config so the gym reuses
578-
# image-baked venvs instead of rebuilding them.
579-
uv_cache_dir = get_nemo_gym_uv_cache_dir()
580-
if uv_cache_dir is not None:
581-
nemo_gym_dict.setdefault("uv_cache_dir", uv_cache_dir)
582-
uv_venv_dir = get_nemo_gym_venv_dir()
583-
if uv_venv_dir is not None:
584-
nemo_gym_dict.setdefault("uv_venv_dir", uv_venv_dir)
585-
nemo_gym_cfg = NemoGymConfig(
586-
model_name=model_name,
587-
base_urls=base_urls,
588-
invalid_tool_call_patterns=invalid_tool_call_patterns,
589-
thinking_tags=thinking_tags,
590-
require_routed_experts=router_replay_enabled(policy_config),
591-
initial_global_config_dict=nemo_gym_dict,
592-
)
593-
nemo_gym_opts = {}
594-
if nemo_gym_num_nodes:
595-
nemo_gym_opts["scheduling_strategy"] = NodeAffinitySchedulingStrategy(
596-
node_id=ray_cur_node_id,
597-
soft=True,
598-
)
599-
nemo_gym_opts["runtime_env"] = {
600-
"py_executable": nemo_gym_py_exec,
601-
"env_vars": {
602-
**os.environ,
603-
"VIRTUAL_ENV": nemo_gym_py_exec,
604-
"UV_PROJECT_ENVIRONMENT": nemo_gym_py_exec,
605-
},
606-
}
607-
actor = NemoGym.options(**nemo_gym_opts).remote(nemo_gym_cfg)
608-
ray.get(actor._spinup.remote())
609556
return actor, time.perf_counter() - t0
610557

611558
total_nodes = cluster_config["num_nodes"]

nemo_rl/environments/nemo_gym.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
import os
1515
import subprocess
1616
from pathlib import Path
17-
from typing import Any, Dict, List, NotRequired, TypedDict
17+
from typing import Any, Dict, List, NotRequired, Optional, TypedDict
1818

1919
import ray
2020
import torch
21+
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
2122
from transformers import PreTrainedTokenizerBase
2223

24+
from nemo_rl.distributed.ray_actor_environment_registry import get_actor_python_env
2325
from nemo_rl.distributed.virtual_cluster import (
2426
DEFAULT_GYM_PORT_RANGE_HIGH,
2527
DEFAULT_GYM_PORT_RANGE_LOW,
@@ -28,6 +30,7 @@
2830
)
2931
from nemo_rl.environments.interfaces import EnvironmentInterface
3032
from nemo_rl.utils.timer import Timer
33+
from nemo_rl.utils.venvs import create_local_venv_on_each_node
3134

3235
DEFAULT_INVALID_TOOL_CALL_PATTERNS = [
3336
"<tool_call>",
@@ -484,3 +487,66 @@ def setup_nemo_gym_config(config, tokenizer) -> None:
484487
# Stop strings or token ids are not supported
485488
generation_config["stop_strings"] = None
486489
generation_config["stop_token_ids"] = None
490+
491+
492+
def spinup_nemo_gym_actor(
493+
env_configs: dict[str, Any],
494+
base_urls: list[Optional[str]],
495+
model_name: str,
496+
enable_router_replay: bool,
497+
) -> Any:
498+
"""Spin up the NeMo-Gym actor against the given generation server URLs.
499+
500+
When ``env_configs["nemo_gym"]["num_gpu_nodes"] > 0``, the actor is
501+
scheduled with soft NodeAffinity to the current Ray node so its colocated
502+
GPU resources land where the caller expects.
503+
"""
504+
nemo_gym_dict = dict(env_configs["nemo_gym"])
505+
506+
# NeMo-RL-side detection knobs are top-level NemoGymConfig fields
507+
# (where the detector reads them), not part of Gym's global config.
508+
invalid_tool_call_patterns = nemo_gym_dict.pop("invalid_tool_call_patterns", None)
509+
thinking_tags = nemo_gym_dict.pop("thinking_tags", None)
510+
511+
# Pass prebuilt cache + venv dirs through the global config so the gym reuses
512+
# image-baked venvs instead of rebuilding them.
513+
uv_cache_dir = get_nemo_gym_uv_cache_dir()
514+
if uv_cache_dir is not None:
515+
nemo_gym_dict.setdefault("uv_cache_dir", uv_cache_dir)
516+
uv_venv_dir = get_nemo_gym_venv_dir()
517+
if uv_venv_dir is not None:
518+
nemo_gym_dict.setdefault("uv_venv_dir", uv_venv_dir)
519+
520+
nemo_gym_cfg = NemoGymConfig(
521+
model_name=model_name,
522+
base_urls=base_urls,
523+
invalid_tool_call_patterns=invalid_tool_call_patterns,
524+
thinking_tags=thinking_tags,
525+
require_routed_experts=enable_router_replay,
526+
initial_global_config_dict=nemo_gym_dict,
527+
)
528+
529+
nemo_gym_py_exec = get_actor_python_env("nemo_rl.environments.nemo_gym.NemoGym")
530+
if nemo_gym_py_exec.startswith("uv"):
531+
nemo_gym_py_exec = create_local_venv_on_each_node(
532+
nemo_gym_py_exec, "nemo_rl.environments.nemo_gym.NemoGym"
533+
)
534+
535+
nemo_gym_opts: dict[str, Any] = {}
536+
if nemo_gym_dict.get("num_gpu_nodes", 0):
537+
nemo_gym_opts["scheduling_strategy"] = NodeAffinitySchedulingStrategy(
538+
node_id=ray.get_runtime_context().get_node_id(),
539+
soft=True,
540+
)
541+
nemo_gym_opts["runtime_env"] = {
542+
"py_executable": nemo_gym_py_exec,
543+
"env_vars": {
544+
**os.environ,
545+
"VIRTUAL_ENV": nemo_gym_py_exec,
546+
"UV_PROJECT_ENVIRONMENT": nemo_gym_py_exec,
547+
},
548+
}
549+
550+
actor = NemoGym.options(**nemo_gym_opts).remote(nemo_gym_cfg)
551+
ray.get(actor._spinup.remote())
552+
return actor
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/bin/bash
2+
# SingleController + NeMo-Gym e2e smoke. Mirrors grpo_async_gym.sh but
3+
# routes everything through the SC path (TransferQueue data plane +
4+
# SingleControllerActor) instead of async_grpo_train.
5+
6+
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
7+
PROJECT_ROOT=$(realpath $SCRIPT_DIR/../..)
8+
# Mark the current repo as safe, since wandb fetches metadata about the repo
9+
git config --global --add safe.directory $PROJECT_ROOT
10+
11+
set -eou pipefail
12+
13+
EXP_NAME=$(basename $0 .sh)
14+
EXP_DIR=$SCRIPT_DIR/$EXP_NAME
15+
LOG_DIR=$EXP_DIR/logs
16+
JSON_METRICS=$EXP_DIR/metrics.json
17+
RUN_LOG=$EXP_DIR/run.log
18+
CHECKPOINT_DIR=$EXP_DIR/checkpoints
19+
DATA_DIR=$EXP_DIR/data
20+
export PYTHONPATH=${PROJECT_ROOT}:${PYTHONPATH:-}
21+
22+
rm -rf $EXP_DIR $LOG_DIR
23+
mkdir -p $EXP_DIR $LOG_DIR $CHECKPOINT_DIR $DATA_DIR
24+
25+
# clean up checkpoint directory on exit
26+
trap "rm -rf $CHECKPOINT_DIR" EXIT
27+
28+
cd $PROJECT_ROOT
29+
30+
# Follow nemo-gym instructions here to get this data:
31+
# https://docs.nvidia.com/nemo/gym/0.1.0/tutorials/nemo-rl-grpo/setup.html#training-nemo-rl-grpo-setup
32+
cd 3rdparty/Gym-workspace/Gym
33+
34+
# We need HF_TOKEN to download the data from huggingface
35+
if [[ ! -f env.yaml ]]; then
36+
if [[ -z "${HF_TOKEN:-}" ]]; then
37+
echo "[ERROR] HF_TOKEN is not set"
38+
exit 1
39+
fi
40+
echo "hf_token: $HF_TOKEN" >> env.yaml
41+
fi
42+
43+
uv run ng_prepare_data "+config_paths=[resources_servers/workplace_assistant/configs/workplace_assistant.yaml]" \
44+
+output_dirpath=data/workplace_assistant \
45+
+mode=train_preparation \
46+
+should_download=true \
47+
+data_source=huggingface
48+
cd -
49+
50+
# This trimming of the workplace assistant dataset is necessary b/c with all the tools the first prompt is >4000 tokens
51+
# which will cause vllm to return nothing on the first prompt and crash RL. Since we want to keep this test short to
52+
# smoke test, we trim all but the first tool
53+
TRAIN_PATH=$DATA_DIR/workplace_assistant_train.jsonl
54+
VALIDATION_PATH=$DATA_DIR/workplace_assistant_validation.jsonl
55+
jq -c '.responses_create_params.tools |= (.[0:1])' 3rdparty/Gym-workspace/Gym/data/workplace_assistant/train.jsonl > $TRAIN_PATH
56+
jq -c '.responses_create_params.tools |= (.[0:1])' 3rdparty/Gym-workspace/Gym/data/workplace_assistant/validation.jsonl > $VALIDATION_PATH
57+
58+
uv run coverage run -a --data-file=$PROJECT_ROOT/tests/.coverage --source=$PROJECT_ROOT/nemo_rl \
59+
$PROJECT_ROOT/examples/run_grpo_single_controller.py \
60+
--config $PROJECT_ROOT/examples/nemo_gym/grpo_qwen3_30ba3b_instruct.yaml \
61+
policy.model_name=Qwen/Qwen3-0.6B \
62+
policy.dtensor_cfg.enabled=false \
63+
policy.megatron_cfg.enabled=true \
64+
policy.megatron_cfg.tensor_model_parallel_size=1 \
65+
policy.megatron_cfg.pipeline_model_parallel_size=1 \
66+
policy.megatron_cfg.expert_model_parallel_size=1 \
67+
policy.megatron_cfg.context_parallel_size=1 \
68+
policy.megatron_cfg.sequence_parallel=false \
69+
policy.generation.vllm_cfg.tensor_parallel_size=1 \
70+
policy.generation.vllm_cfg.async_engine=true \
71+
policy.max_total_sequence_length=512 \
72+
policy.generation.colocated.enabled=false \
73+
policy.generation.colocated.resources.num_nodes=1 \
74+
policy.generation.colocated.resources.gpus_per_node=1 \
75+
grpo.num_prompts_per_step=4 \
76+
grpo.num_generations_per_prompt=2 \
77+
grpo.max_num_steps=10 \
78+
grpo.val_period=-1 \
79+
policy.train_global_batch_size=8 \
80+
policy.train_micro_batch_size=1 \
81+
cluster.gpus_per_node=2 \
82+
loss_fn.reference_policy_kl_penalty=0.01 \
83+
loss_fn.use_importance_sampling_correction=true \
84+
logger.tensorboard_enabled=true \
85+
logger.log_dir=$LOG_DIR \
86+
logger.wandb_enabled=false \
87+
logger.monitor_gpus=true \
88+
checkpointing.enabled=false \
89+
data.train.data_path=$TRAIN_PATH \
90+
data.validation.data_path=$VALIDATION_PATH \
91+
++data_plane.enabled=true \
92+
++data_plane.impl=transfer_queue \
93+
++data_plane.backend=simple \
94+
++data_plane.storage_capacity=1000000 \
95+
++data_plane.num_storage_units=2 \
96+
++data_plane.claim_meta_poll_interval_s=0.5 \
97+
++data_plane.global_segment_size=549755813888 \
98+
++data_plane.local_buffer_size=68719476736 \
99+
++async_rl.batch_selection_strategy=strict_on_policy \
100+
++async_rl.max_weight_staleness_versions=0 \
101+
++async_rl.min_prompt_groups_per_batch=4 \
102+
++async_rl.max_inflight_prompts=4 \
103+
++async_rl.max_buffered_rollouts=4 \
104+
$@ \
105+
2>&1 | tee $RUN_LOG
106+
107+
uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS
108+
109+
# Observed to be between 0.8-1.3
110+
uv run tests/check_metrics.py $JSON_METRICS \
111+
'median(data["train/gen_kl_error"]) < 1.3' \
112+
'max(data["train/reward"]) > 0'

0 commit comments

Comments
 (0)