-
Notifications
You must be signed in to change notification settings - Fork 502
fix(async-grpo): fail fast on generation worker errors #2368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -126,6 +126,15 @@ def __init__( | |
| # Timer for efficiency metrics | ||
| self._efficiency_timer = ThreadSafeTimer(context={"worker": "collector"}) | ||
|
|
||
| # Failure tracking for rollout batch workers. _failure_lock guards both | ||
| # _failure_count and _fatal_error_message. | ||
| self._failure_lock: _threading.Lock = _threading.Lock() | ||
| self._failure_count: int = 0 | ||
| self._fatal_error_message: str | None = None | ||
| self._max_generation_failures = ( | ||
| self.master_config.grpo.async_grpo.max_generation_failures | ||
| ) | ||
|
|
||
| def _calculate_target_weights(self, generation_weight_version: int) -> list[int]: | ||
| """Calculate target weight versions for given generation weight version. | ||
|
|
||
|
|
@@ -291,6 +300,8 @@ def _collection_loop(self): | |
|
|
||
| # Check if generation limits require pausing collection | ||
| if self._should_pause_for_generation_limits() and self.running: | ||
| self._generation_limit_cleared.clear() | ||
|
|
||
| # Only log warning once per weight version | ||
| if self._last_limit_warning_version != self.current_weight_version: | ||
| max_trajectory_age = ( | ||
|
|
@@ -307,8 +318,6 @@ def _collection_loop(self): | |
| ) | ||
| self._last_limit_warning_version = self.current_weight_version | ||
|
|
||
| self._generation_limit_cleared.clear() # Clear the event to pause | ||
|
|
||
| # Efficiently wait for generation limits to be cleared (no polling!) | ||
| with self._efficiency_timer.time("idle/generation_limit_pause"): | ||
| self._generation_limit_cleared.wait() | ||
|
|
@@ -472,6 +481,21 @@ def _run_rollout_batch() -> None: | |
| def get_weight_version(self) -> int: | ||
| return self.current_weight_version | ||
|
|
||
| def check_health(self) -> None: | ||
| """Raise the stored fatal worker error, if any. | ||
|
|
||
| Called by the trainer between sampling iterations. When a generation | ||
| worker has recorded a fatal failure (consecutive count exceeded | ||
| max_generation_failures), this raises it so the training job dies | ||
| instead of stalling on an empty replay buffer. Safe to call | ||
| repeatedly: returns silently when no fatal error is set, and raises | ||
| every time once one is. | ||
| """ | ||
| with self._failure_lock: | ||
| error_message = self._fatal_error_message | ||
| if error_message is not None: | ||
| raise RuntimeError(error_message) | ||
|
|
||
| def pause(self) -> None: | ||
| """Pause trajectory collection.""" | ||
| self._manual_pause_cleared.clear() # Signal collection to pause | ||
|
|
@@ -814,6 +838,7 @@ async def _run_rollout_batch_worker( | |
| ) -> None: | ||
| """Own one target reservation while collecting its rollout batch.""" | ||
| worker_start = time.perf_counter() | ||
| wake_generation_limits_after_cleanup = False | ||
| try: | ||
| await self._collect_rollout_batch( | ||
| repeated_batch=repeated_batch, | ||
|
|
@@ -822,22 +847,56 @@ async def _run_rollout_batch_worker( | |
| num_generations=num_generations, | ||
| use_nemo_gym=use_nemo_gym, | ||
| ) | ||
| with self._failure_lock: | ||
| if self._fatal_error_message is None: | ||
| self._failure_count = 0 | ||
| except Exception as error: | ||
| if not self.running: | ||
| return | ||
|
|
||
| self._efficiency_timer.record( | ||
| "wasted/failed_trajectory", time.perf_counter() - worker_start | ||
| ) | ||
| backend = "NeMo-Gym" if use_nemo_gym else "native" | ||
| print( | ||
| f"❌ Error in {backend} batch worker " | ||
| f"(target_weight={target_weight_version}): {error}" | ||
| ) | ||
| import traceback | ||
|
|
||
| traceback.print_exc() | ||
| failure_traceback = traceback.format_exc() | ||
| with self._failure_lock: | ||
| self._failure_count += 1 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The counter is process-lifetime, so Suggest resetting on success so the threshold counts consecutive failures: try:
await self._collect_rollout_batch(...)
with self._failure_lock:
self._failure_count = 0
except Exception as error:
...and updating the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1 action item. PR-introduced, independent of the other finding. Confirmed with a repro (below). AI-1 — normal dataloader exhaustion is counted as a generation failureThis isn't a rare race — it's the default end-of-data path. The loop spawns a worker for the last batch, immediately exhausts the dataloader, and With Repro output ( Cost: the run was ending anyway, so this isn't a corrupted-training issue — but the user gets an error blaming generation for what is actually "your dataset ran out", and the job dies at the next Action: don't count shutdown-induced enqueue aborts. Either guard the counting block with repro (save as
|
||
| failure_count = self._failure_count | ||
| failure_limit = self._max_generation_failures | ||
| is_fatal = failure_count > failure_limit | ||
| if is_fatal and self._fatal_error_message is None: | ||
| self._fatal_error_message = ( | ||
| "AsyncTrajectoryCollector aborting: " | ||
| f"{failure_count} batch-worker failure(s) exceeded " | ||
| f"max_generation_failures={failure_limit}. " | ||
| f"Last failure in {backend} batch worker for " | ||
| f"generation_weight={generation_weight_version}, " | ||
| f"target_weight={target_weight_version}: {error!r}\n" | ||
| f"Worker traceback:\n{failure_traceback}" | ||
| ) | ||
| wake_generation_limits_after_cleanup = True | ||
| print( | ||
| f"[AsyncTrajectoryCollector] {backend} batch worker FAILED " | ||
| f"(failure {failure_count}, tolerating {failure_limit}) " | ||
| f"generation_weight={generation_weight_version} " | ||
| f"target_weight={target_weight_version}\n{failure_traceback}", | ||
| flush=True, | ||
| ) | ||
| if is_fatal: | ||
| print( | ||
| f"[AsyncTrajectoryCollector] FATAL: failure count " | ||
| f"{failure_count} exceeds threshold {failure_limit}; trainer " | ||
| "will be notified on the next check_health() call.", | ||
| flush=True, | ||
| ) | ||
| finally: | ||
| self._release_target(target_weight_version) | ||
| with self._threads_lock: | ||
| self._inflight_threads.discard(_threading.current_thread()) | ||
| if wake_generation_limits_after_cleanup: | ||
| self._generation_limit_cleared.set() | ||
|
|
||
| @staticmethod | ||
| def _build_task_index_map( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1 action item, 1 follow-up. PR-introduced.
AI-1 — after a worker gives up, the gap-fill that would top the target back up never runs, so the step never completes
Simplified to one thread, this is the collector:
Normally the gap-fill handles this. Say 15 of 16 groups buffered and the 16th failed all 4 attempts (
max_attempts = 1 + _MAX_NEMO_GYM_STREAM_RETRIES, gym only — native gets 1). Groups are buffered individually as they stream (_enqueue_rollout_group), so the target is simply short by one. On the next loop iteration:That's the #2651 machinery and it is correct.
It never gets to run.
_process_batchreturns in ms; the loop's next iteration finds the slot still reserved — atmax_trajectory_age_steps: 1(L61) it is the only slot (_calculate_target_weights) — so_should_pause_for_generation_limits→ True →.clear()→ parks on.wait(). Minutes later the worker gives up and_release_targetfrees the slot but does not set_generation_limit_cleared— set only at :114 and inset_weight_version, whose callers are grpo.py:4110 (once, pre-fill) and grpo.py:4687 (post-refit). Soneededis never recomputed, the target stays at 15/16,has_complete_batchstays False, and the trainer blocks. The escape at grpo.py:4272-4285 can't fire either: it needsnot running, and a parked loop never reaches itsfinally.Measured (repro below; verbatim methods, no GPU, deterministic 3/3):
Net for this recipe:
0latches fatal before the park matters and aborts cleanly;3latches nothing and hangs. The tolerance setting is strictly worse than the default here.Action — two changes; the first alone is not enough:
A. In
_run_rollout_batch_worker'sexcept, after the increment:self._generation_limit_cleared.set().B. Move
.clear()at :324 out of theif self._last_limit_warning_version != self.current_weight_version:guard at :309. That guard logs once per weight version, but.clear()is nested in it while.wait()is not — so after the first warning the event can never be re-armed and the pause degenerates into a spin (row "fix A only": 200 dataloader batches burned, zero GPU work).Please don't put the
set()in_release_target— it also runs on the success path, where parking is correct.A startup validator on
max_generation_failures < max_trajectory_age_steps + 1isn't a substitute: atmax_age: 1it would reject every non-zero value, deleting the feature for the recipes that want it.Caveats. (a) The park needs the worker to fail slower than one loop iteration (~tens of ms; several blocking Ray round-trips) — anything with a network or subprocess round trip qualifies. (b)
Eventcheck-then-clear still has a lost-wakeup window; it never fired here, but aConditionwith a predicate loop would be airtight if you prefer.Follow-up
A regression test for a worker exhausting its retries at
max_trajectory_age_steps: 1. No current test could catch this — they all drive_run_rollout_batch_workerdirectly and never exercise the loop's pause path.repro (save as
repro.py, run from repo root, ~60 s)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 87f55d4. A failed batch worker now sets
_generation_limit_clearedafter releasing its failure state so a paused collector can re-evaluate and gap-fill the released target, and the collection loop clears the event on every limit pause rather than only in the log-once branch. I added a regression using the real collection loop and worker path withmax_trajectory_age_steps=1; it verifies the tolerated failure wakes the loop, releases the reservation, and starts gap filling without latching a fatal error.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed in 87f55d4