Skip to content

[train] Add optional step and weight-sync timeouts that raise typed errors - #2381

Open
dyurk-lila wants to merge 1 commit into
errors/2-picklable-http-errorsfrom
errors/3-step-deadline
Open

dyurk-lila wants to merge 1 commit into
errors/2-picklable-http-errorsfrom
errors/3-step-deadline

Conversation

@dyurk-lila

@dyurk-lila dyurk-lila commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds two optional driver-side budgets, trainer.step_timeout_s and trainer.weight_sync_timeout_s. A hung training step becomes a typed, picklable StepTimeoutError / WeightSyncTimeoutError that names the step, stage and operation.

TLDR: the deadline enters asyncio.timeout, so every await in the step (generation, inference-engine pause/resume and sleep/wake_up, the fully-async buffer wait, to_thread saves) is cancelled on expiry. The ray.gets behind a step's WorkerDispatch calls and PPORayActorGroup offload/backload go through deadline.ray_get. Both trainer keys default to None; control-plane HTTP requests have a separate 600 s default timeout.

Usage

trainer:
  step_timeout_s: 1800        # one step: generation through weight sync
  weight_sync_timeout_s: 900  # each weight sync, incl. the initial one; must be <= step_timeout_s
StepTimeoutError: step 12 exceeded its 1800s budget after 1800.0s (stage='train_critic_and_policy', operation='forward_backward')
  • Checkpoint and HF saves made outside a step (the final saves, and the fully-async trainer's saves) each get their own step_timeout_s budget. Saves inside a sync-trainer step share the step's budget.
  • Eval is not covered.
  • Only the driver's wait is interrupted: a hung worker keeps running until teardown.
  • SKYRL_INFERENCE_CONTROL_PLANE_TIMEOUT_S bounds each inference-server control request (default 600 s; <= 0 disables it). A request timeout raises picklable InferenceServerTimeoutError. It does not apply to data-plane generation requests.

How it works

skyrl/train/utils/deadline.py:

  • step_deadline(global_step, budget_s, error_cls) sets a _Deadline in a ContextVar and wraps its body in asyncio.timeout(budget_s). It is a no-op for None. A nested deadline can only shorten its parent: if the parent expires first, the parent, and its error class, stay in effect.
  • On expiry, the cancellation unwinds through the Timers and deadline.operation(...) scopes. Each records itself on the deadline, innermost first, and the deadline raises error_cls(step, stage, operation or "await", …). A TimeoutError from the body that isn't this deadline's passes through unchanged.
  • ray_get(refs, operation) calls ray.get(refs, timeout=remaining()) and turns GetTimeoutError into the deadline's error.
  • stage(name) names the current stage, and operation(name) the call awaited within it: RemoteInferenceClient._call_all_servers names its endpoint and the sync trainer names generate, so a timeout during a weight sync reads e.g. stage='sync_weights', operation='/pause'. Timer.__enter__/__aenter__ enters it with the timer's message, so every existing Timer("…") / timed_phase("…") names its stage with no new bookkeeping. asyncio.to_thread copies the context, so ray_get in worker threads sees both the deadline and the stage.
  • StepTimeoutError(RuntimeError) passes all five fields to super().__init__, so default pickling round-trips them. It is deliberately not a TimeoutError, so except TimeoutError retry paths (e.g. RemoteInferenceClient's) can't swallow it.
  • WeightSyncTimeoutError subclasses it.

cleanup_preserving_primary's failure-path cleanup (the resume_generation / wake_up in save_weights_for_sampler) runs under asyncio.timeout(deadline.remaining()). It is skipped, with a warning, once the deadline has expired, and a cleanup that hangs past it adds a note to the primary exception. Without a deadline it is unbounded, as before. asyncio.timeout cancels only once, so without this a wedged engine would hang the unwind after the deadline fired.

Trainer scopes: the step block (async with self._step_deadline(), Timer("step", …)), each weight sync, including the initial one (_weight_sync_deadline()), and each save outside a step. The synchronous, fully-async and example async trainers all enter these scopes.

RemoteInferenceClient uses an aiohttp.ClientTimeout for each control-plane request, including LoRA load/unload. This bounds calls made outside a training step too.

validate_cfg:

  • rejects <= 0 values and weight_sync_timeout_s > step_timeout_s;
  • warns when a budget is below SKYRL_WORKER_NCCL_TIMEOUT_IN_S, because the driver would then give up before the NCCL watchdog reports which collective is stuck.

The keys' docstrings render in the config API reference. The troubleshooting page gets a "Step timeouts" section.

Alternatives considered:

  • Per-call timeout= arguments: they would have to be threaded through every dispatch signature, and they can't express "whatever is left of this step".
  • A watchdog thread that kills the process: that is the os._exit pattern the previous PR removes.

Test plan

tests/train/test_deadline.py:

  • Nested deadlines keep the shorter budget and its error class; ray_get uses the remaining budget and the enclosing Timer stage.
  • A real Ray actor blocked in backload_to_gpu times out through the actor-group call site; both error classes survive pickling.
  • An awaited operation is cancelled on expiry with its stage and operation, including the "await" fallback; an inner weight-sync deadline raises WeightSyncTimeoutError.
  • Unrelated TimeoutErrors pass through. After expiry, cleanup is skipped; a cleanup that hangs after a body error is cut off and adds a note to the original error.
  • The example async trainer's blocked generation-buffer wait times out and cancels its generator task.

tests/train/test_config.py: parameterized rejection of invalid values and the NCCL warning.

test_remote_inference_client.py: a real mock-server control request that sleeps past a short budget raises a picklable InferenceServerTimeoutError.

Validation (whole stack)

Current CPU revision: PR61 trainer tests: 19 passed. PR63 focused stack tests: 322 passed. Ruff passes on all changed Python files. A full-suite rerun stopped around the existing Ray dispatch tests; the standalone dispatch test also timed out after 120 s. A prior stack revision passed the full CPU command (RAY_ADDRESS=local uv run --isolated --extra skyrl-train --extra dev pytest -q tests/train/ tests/backends/skyrl_train/ --ignore=tests/backends/skyrl_train/gpu -m "not vllm"): 2020 passed, 32 skipped, 6 deselected.

Prior GPU end-to-end validation (earlier stack revision; 1× H100 node, 4 GPUs, Qwen2.5-0.5B-Instruct on GSM8K, FSDP + vLLM 0.30). A small driver runs the trainer as a Ray task and inspects the exception ray.get raises on the driver. Fault injection wraps generator.generate.

Scenario Trainer Driver sees
healthy, step_timeout_s=1800, weight_sync_timeout_s=900, ckpt at step 2 fully-async ✅ 3 steps, no error
resume from that checkpoint fully-async ✅ trains step 4
generate raises after 40 calls fully-async ✅ RayTaskError(RuntimeError) with the worker traceback, notes ['raised in background generation worker']
step_timeout_s=2 fully-async ✅ StepTimeoutError(1, 'wait_for_generation_buffer', 'await', 2, 2.0008)
generate raises on call 2 example async trainer ✅ RayTaskError(RuntimeError), notes ['raised in background generator']
default config (no timeouts), ckpt sync ✅ 2 steps, no error
resume with timeouts set sync ✅ trains step 3
weight_sync_timeout_s=0.01 sync ✅ WeightSyncTimeoutError(0, 'sync_weights', 'backload_to_gpu', 0.01, …)
step_timeout_s=3 sync ✅ StepTimeoutError(1, 'generate', 'generate', 3, 3.0005)

Before this stack, the injected-failure rows end in os._exit(1) / sys.exit(1), so the driver sees the worker process die.

Prior GPU CI subset (-m "not (integrations or megatron or mooncake)"), run on an earlier stack revision and on unmodified main in the same environment:

Test file main this stack
test_save_weights_for_sampler.py 3 passed 3 passed
test_offload_kv_weight_sync.py 1 passed 1 passed
inference_servers/test_new_inference_generation.py 19 errors (vLLM engine failed to start in the module fixture) 19 passed
test_worker_dispatch_offload.py 4 passed 4 passed
test_trainer_full_checkpointing.py -k fsdp 2 passed 2 passed
inference_servers/test_weight_sync.py 2 failed, 2 errors 3 failed, 1 error

test_weight_sync.py fails the same 4 tests on main, for the same two reasons on both sides: AttributeError: 'types.SimpleNamespace' object has no attribute 'speculative_config', and a vLLM engine-core init failure. test_update_weights_rdt hits one reason on each side.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core training loops, Ray dispatch waits, and inference control-plane HTTP; mis-tuned timeouts could abort long but healthy steps, though defaults remain off (None).

Overview
Adds optional driver-side time budgets so hung training no longer blocks forever. Set trainer.step_timeout_s (whole step: generation through weight sync) and optionally trainer.weight_sync_timeout_s (must be ≤ step budget); when exceeded the driver raises picklable StepTimeoutError / WeightSyncTimeoutError with step, stage (Timer name), and operation.

New skyrl.train.utils.deadline wraps steps in asyncio.timeout, bounds ray.get via deadline.ray_get across WorkerDispatch and worker offload/backload, and tags awaits with deadline.operation. Sync, fully-async, and example async trainers enter _step_deadline() / _weight_sync_deadline() around steps, weight syncs, and out-of-step checkpoint/HF saves. Timer registers stages for error messages; failure-path cleanup_preserving_primary skips or caps cleanup once the deadline expires.

Inference control plane calls (pause/resume, weight updates, LoRA, etc.) use SKYRL_INFERENCE_CONTROL_PLANE_TIMEOUT_S (default 600s) and raise InferenceServerTimeoutError. Config validation rejects invalid timeout pairs and warns if budgets are below the NCCL watchdog. Troubleshooting docs describe usage and NCCL interaction. Only the driver wait is cancelled—workers keep running until teardown.

Reviewed by Cursor Bugbot for commit 30669e0. Bugbot is set up for automated code reviews on this repo. Configure here.

Bound awaited sections, Ray waits, and inference control-plane requests. Cover the example async trainer, preserve the primary error during bounded cleanup, and keep the deadline tests focused on real call paths and distinct failure modes.
@dyurk-lila
dyurk-lila added this pull request to stack #2382 October 1, 2026 14:21

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces driver-side step and weight sync deadlines to prevent training steps from hanging indefinitely. It adds step_timeout_s and weight_sync_timeout_s configuration options to TrainerConfig, which raise StepTimeoutError or WeightSyncTimeoutError when exceeded. A new deadline utility module is introduced to manage these budgets, replacing standard ray.get calls with bounded deadline.ray_get calls across workers and dispatchers. Additionally, control-plane requests to inference servers are now bounded by SKYRL_INFERENCE_CONTROL_PLANE_TIMEOUT_S, raising InferenceServerTimeoutError on timeout. Relevant documentation, configuration validation, and comprehensive unit tests have been added to support these changes. I have no feedback to provide as there are no review comments.

@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 2/5

[Medium risk] Adds optional training-step timeout budgets with new error types.

The PR is not safe to merge until unbounded step waits, continuing checkpoint writes, and skipped external-inference recovery are addressed.

Findings

  1. P1 Unbounded waits remain inside steps ▶
  2. P1 Timed-out saves keep writing ▶
  3. P1 Expired sync skips engine recovery ▶
  4. P2 Non-finite budgets pass validation ▶

Reviews (1) · Last reviewed commit: "[train] Add typed step and weight-sync d..."

Comment thread skyrl/train/trainer.py
await self._vllm_metrics_scraper.start("vllm/train")
self._vllm_metrics_scraper.pause()
with Timer("step", self.all_timings):
async with self._step_deadline(), Timer("step", self.all_timings):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unbounded waits remain inside steps

With policy profiling enabled, a step calls _profiler_step(), whose worker dispatch still uses an unbounded synchronous ray.get. If that worker hangs, it blocks the event loop, so the new step deadline cannot raise StepTimeoutError and the driver remains stuck. Checkpoint cleanup has the same gap through run_on_each_node().

Knowledge Base Used: Trainer execution and evaluation

Comment on lines +638 to +640
async with self._step_deadline():
with self._phase_gauge.timed_phase("save_checkpoints", self.all_timings):
await asyncio.to_thread(self.save_checkpoints)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Timed-out saves keep writing

If the deadline expires during asyncio.to_thread(self.save_checkpoints), it cancels the await but not the save thread. That thread can keep writing after StepTimeoutError propagates, and the pending-save drain does not wait for it. The base save writes the latest-step marker before the fully asynchronous save writes fully_async_state.pt, which resume requires, so a timed-out save can leave a checkpoint that cannot be resumed. The HF save has the same continuing-thread problem.

Knowledge Base Used: Trainer execution and evaluation

Comment on lines +88 to +91
remaining_s = deadline.remaining()
if remaining_s == 0:
logger.warning(f"Skipping {description} during cleanup: the step deadline has expired")
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Expired sync skips engine recovery

If a weight-sync deadline expires after an external inference server has been paused or put to sleep, this branch skips the matching resume_generation or wake_up call. The trainer then propagates the error without restoring that external server, leaving it paused or asleep after the training job exits.

Knowledge Base Used:

Comment on lines +365 to +366
if value <= 0:
raise ValueError(f"trainer.{name} must be > 0, got {value}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-finite budgets pass validation

The positivity check accepts NaN and infinity as timeout values. A NaN budget can expire immediately, while an infinite budget provides no useful bound. Rejecting non-finite values would keep accepted configurations meaningful.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 30669e0. Configure here.


pbar.close()
if self.cfg.trainer.ckpt_interval > 0:
with Timer("save_checkpoints", self.all_timings):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Periodic saves omit timeout budget

Medium Severity

Interval checkpoint and HF saves in the example async trainer sit outside the step scope and never enter _step_deadline(), unlike the final saves in the same loop. With step_timeout_s set, a hung save_checkpoints or save_models call still blocks the driver indefinitely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30669e0. Configure here.

trained_steps_this_epoch = self.async_train_dataloader.num_trained() // self.mini_batch_size
for _step_idx in range(self.global_step, (1 + epoch) * self.num_steps_per_epoch + 1):
with Timer("step", self.all_timings):
async with self._step_deadline(), Timer("step", self.all_timings):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early-exit saves reuse leftover budget

Low Severity

The sample_full_batch epoch-exhaustion checkpoint and HF saves still run inside the step’s _step_deadline(), so they inherit whatever time is left after collection. Other fully-async saves open a fresh budget. A long collect followed by this early-exit path can fail the save with StepTimeoutError even though the step’s work already finished.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30669e0. Configure here.

This branch was successfully deployed

1 active deployment
Preview — 30669e05 Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant