Skip to content

Commit 6ae9227

Browse files
committed
feat: improve rollout-only trajectory collection
Signed-off-by: Jonas Yang <joyang@nvidia.com>
1 parent be8e7e5 commit 6ae9227

3 files changed

Lines changed: 435 additions & 37 deletions

File tree

examples/nemo_gym/grpo_workplace_assistant_nemotron_nano_v2_9b.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ env:
299299
port_range_high: 5999
300300
rollout_max_attempts_to_avoid_lp_nan: 1
301301
is_trajectory_collection: false # Set this to true to enable trajectory collection (no training). You may also want to increase `policy.generation.vllm_cfg.gpu_memory_utilization`
302+
trajectory_collection_batch_size: null # Optional positive integer; null collects the validation set in one batch
302303
config_paths:
303304
- responses_api_models/vllm_model/configs/vllm_model_for_training.yaml # Required! And it must be *for_training
304305
- resources_servers/workplace_assistant/configs/workplace_assistant.yaml

examples/nemo_gym/run_grpo_nemo_gym.py

Lines changed: 150 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import argparse
16+
import json
1617
import os
1718
import pprint
1819
import time
@@ -38,7 +39,7 @@
3839
refit_policy_generation,
3940
setup,
4041
)
41-
from nemo_rl.algorithms.utils import get_tokenizer
42+
from nemo_rl.algorithms.utils import get_tokenizer, log_generation_metrics_to_wandb
4243
from nemo_rl.data.utils import setup_response_data
4344
from nemo_rl.distributed.virtual_cluster import init_ray
4445
from nemo_rl.environments.nemo_gym import (
@@ -68,6 +69,34 @@ def parse_args() -> tuple[argparse.Namespace, list[str]]:
6869
return args, overrides
6970

7071

72+
def _pop_trajectory_collection_settings(
73+
nemo_gym_config: dict[str, object],
74+
) -> tuple[bool, int | None]:
75+
"""Remove and validate NeMo-RL trajectory-collection settings."""
76+
is_trajectory_collection = bool(
77+
nemo_gym_config.pop("is_trajectory_collection", False)
78+
)
79+
batch_size = nemo_gym_config.pop("trajectory_collection_batch_size", None)
80+
if batch_size is None:
81+
return is_trajectory_collection, None
82+
83+
if not is_trajectory_collection:
84+
raise ValueError(
85+
"env.nemo_gym.trajectory_collection_batch_size requires "
86+
"env.nemo_gym.is_trajectory_collection=true"
87+
)
88+
if (
89+
isinstance(batch_size, bool)
90+
or not isinstance(batch_size, int)
91+
or batch_size <= 0
92+
):
93+
raise ValueError(
94+
"env.nemo_gym.trajectory_collection_batch_size must be a positive integer"
95+
)
96+
97+
return is_trajectory_collection, batch_size
98+
99+
71100
# These types are directly imported from grpo_train since if something about the architecture changes we want to immediately fail.
72101
def collect_trajectories(
73102
policy: ColocatablePolicyInterface,
@@ -78,7 +107,13 @@ def collect_trajectories(
78107
logger: Logger,
79108
master_config: MasterConfig,
80109
) -> None:
81-
"""Run trajectory collection."""
110+
"""Run trajectory collection and persist every completed batch."""
111+
expected_trajectories = master_config.grpo["max_val_samples"]
112+
if expected_trajectories is None or expected_trajectories <= 0:
113+
raise ValueError(
114+
"Trajectory collection requires a non-empty validation dataset"
115+
)
116+
82117
# common config/state items
83118
colocated_inference = master_config.policy["generation"]["colocated"]["enabled"]
84119
refit_policy_generation(policy, policy_generation, colocated_inference)
@@ -87,33 +122,108 @@ def collect_trajectories(
87122

88123
print("\n🔍 Running trajectory collection...", flush=True)
89124
generation_config = master_config.policy["generation"]
90-
for val_batch in val_dataloader:
91-
nemo_gym_rollout_result = run_async_nemo_gym_rollout(
92-
policy_generation=policy_generation,
93-
input_batch=val_batch,
94-
tokenizer=tokenizer,
95-
task_to_env=val_task_to_env,
96-
max_seq_len=master_config.policy["max_total_sequence_length"],
97-
generation_config=generation_config,
98-
max_rollout_turns=None,
99-
greedy=False,
100-
)
101-
102-
rows_to_log: list[str] = []
103-
for key, value in nemo_gym_rollout_result.rollout_metrics.items():
104-
if "full_result" not in key:
105-
continue
106-
107-
value: Table
108-
data: list[list[str]] = value.data # (n, 1)
109-
rows_to_log.extend(v[0] for v in data)
110-
111-
logger.log_string_list_as_jsonl(rows_to_log, log_filename)
125+
vllm_config = generation_config.get("vllm_cfg", {})
126+
should_log_generation_metrics = (
127+
vllm_config.get("enable_vllm_metrics_logger", False)
128+
and vllm_config.get("async_engine", False)
129+
and master_config.logger["wandb_enabled"]
130+
)
131+
collected_trajectories = 0
132+
total_reward = 0.0
133+
134+
try:
135+
for batch_idx, val_batch in enumerate(val_dataloader):
136+
batch_step = batch_idx + 1
137+
if should_log_generation_metrics:
138+
policy_generation.clear_logger_metrics()
139+
140+
nemo_gym_rollout_result = run_async_nemo_gym_rollout(
141+
policy_generation=policy_generation,
142+
input_batch=val_batch,
143+
tokenizer=tokenizer,
144+
task_to_env=val_task_to_env,
145+
max_seq_len=master_config.policy["max_total_sequence_length"],
146+
generation_config=generation_config,
147+
max_rollout_turns=None,
148+
greedy=False,
149+
)
150+
if should_log_generation_metrics:
151+
generation_logger_metrics = policy_generation.get_logger_metrics()
152+
153+
rows_to_log: list[str] = []
154+
for key, value in nemo_gym_rollout_result.rollout_metrics.items():
155+
if "full_result" not in key:
156+
continue
157+
158+
value: Table
159+
data: list[list[str]] = value.data # (n, 1)
160+
rows_to_log.extend(v[0] for v in data)
161+
162+
if not rows_to_log:
163+
raise RuntimeError(
164+
f"Trajectory batch {batch_idx} did not contain any full Gym results"
165+
)
166+
167+
attributed_rows: list[str] = []
168+
batch_size = len(rows_to_log)
169+
batch_reward = 0.0
170+
for batch_position, serialized_result in enumerate(rows_to_log):
171+
result = json.loads(serialized_result)
172+
result["trajectory_collection_batch_index"] = batch_idx
173+
result["trajectory_collection_batch_position"] = batch_position
174+
result["trajectory_collection_batch_size"] = batch_size
175+
batch_reward += float(result["reward"])
176+
attributed_rows.append(json.dumps(result, separators=(",", ":")))
177+
178+
# Append after every completed batch so earlier trajectories survive a later
179+
# batch or worker failure during a long collection run.
180+
logger.log_string_list_as_jsonl(attributed_rows, log_filename)
181+
collected_trajectories += batch_size
182+
total_reward += batch_reward
183+
184+
batch_rollout_metrics = {
185+
key: value
186+
for key, value in nemo_gym_rollout_result.rollout_metrics.items()
187+
if "full_result" not in key
188+
}
189+
# Match the training prefix so rollout-only and GRPO runs expose the same
190+
# timing and rollout metric names for direct comparison.
191+
logger.log_metrics(batch_rollout_metrics, batch_step, prefix="train")
192+
if should_log_generation_metrics:
193+
log_generation_metrics_to_wandb(
194+
generation_logger_metrics,
195+
batch_step,
196+
vllm_config["vllm_metrics_logger_interval"],
197+
logger,
198+
)
199+
logger.log_metrics(
200+
{
201+
"mean_reward": total_reward / collected_trajectories,
202+
"num_trajectories": collected_trajectories,
203+
},
204+
batch_step,
205+
prefix="trajectory_collection",
206+
step_finished=True,
207+
)
208+
print(
209+
f"Collected {collected_trajectories}/{expected_trajectories} "
210+
f"trajectories after batch {batch_idx + 1}",
211+
flush=True,
212+
)
213+
finally:
214+
policy_generation.finish_generation()
112215

113-
# TODO: eventually as trajectory collection use cases exceed 4 hours, we can leverage the dataloader save functionality to resume
114-
# And also leverage the TimeoutChecker functionality as well
216+
if collected_trajectories != expected_trajectories:
217+
raise RuntimeError(
218+
"Trajectory collection was incomplete: "
219+
f"expected {expected_trajectories}, got {collected_trajectories}"
220+
)
115221

116-
policy_generation.finish_generation()
222+
print(
223+
f"Trajectory collection complete: {collected_trajectories} trajectories, "
224+
f"mean reward {total_reward / collected_trajectories:.6f}",
225+
flush=True,
226+
)
117227

118228

119229
def main() -> None:
@@ -163,6 +273,12 @@ def main() -> None:
163273
# NeMo-Gym specific config setup.
164274
setup_nemo_gym_config(config, tokenizer)
165275

276+
# These are NeMo-RL control-flow settings, not NeMo-Gym global config.
277+
(
278+
is_trajectory_collection,
279+
trajectory_collection_batch_size,
280+
) = _pop_trajectory_collection_settings(config.env["nemo_gym"])
281+
166282
# We assert here since this is right after the final config has been materialized.
167283
assert _should_use_nemo_gym(config)
168284

@@ -184,11 +300,15 @@ def main() -> None:
184300
)
185301

186302
if val_dataset is not None:
303+
val_batch_size = len(val_dataset)
304+
if trajectory_collection_batch_size is not None:
305+
val_batch_size = min(trajectory_collection_batch_size, len(val_dataset))
187306
print(
188-
f"Setting `grpo.max_val_samples` and `grpo.val_batch_size` to the length of the validation dataset, which is {len(val_dataset)}"
307+
f"Setting `grpo.max_val_samples` to {len(val_dataset)} and "
308+
f"`grpo.val_batch_size` to {val_batch_size}"
189309
)
190310
config.grpo["max_val_samples"] = len(val_dataset)
191-
config.grpo["val_batch_size"] = config.grpo["max_val_samples"]
311+
config.grpo["val_batch_size"] = val_batch_size
192312

193313
# Print config
194314
print("Final config:")
@@ -197,13 +317,6 @@ def main() -> None:
197317
with rl_init_timer.time("ray_connect"):
198318
init_ray()
199319

200-
# `is_trajectory_collection` is a NeMo-RL-side control-flow knob; pop it
201-
# before setup() so it is not forwarded into NeMo-Gym's global config (the
202-
# gym actor is now created inside setup()).
203-
is_trajectory_collection = (
204-
config.env["nemo_gym"].pop("is_trajectory_collection", False) or False
205-
)
206-
207320
with rl_init_timer.time("setup"):
208321
(
209322
policy,

0 commit comments

Comments
 (0)