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.
1313# limitations under the License.
1414
1515import argparse
16+ import json
1617import os
1718import pprint
1819import time
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
4243from nemo_rl .data .utils import setup_response_data
4344from nemo_rl .distributed .virtual_cluster import init_ray
4445from 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.
72101def 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
119229def 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