Skip to content

Commit 64d8cd4

Browse files
feat(fireworks): retry + reconnect training-client RPCs on transient faults (#708)
A transient blip on the shared training client around the per-step weight hot-load (eager_parameter_sync_step=1) crashed entire multi-hour runs: the Fireworks SDK's forward_backward / optim_step / save_and_hotload have no retry ("failures propagate so the training loop can crash"), and a step-N forward_backward TimeoutError at step ~17 tore down the run. Wrap the per-step training-client RPCs (forward, forward_backward incl. aux, optim_step, save_and_hotload) in a retry helper that, on a transient error (timeout / connection / channel reset / 5xx markers), optionally reconnects the client to the SAME job and retries with linear backoff. Non-transient errors (bad data) and cancellation propagate immediately. - Reconnect refreshes the ReconnectableClient via the SDK's own _connect path (rlor_mgr.wait_for_existing(job_id) -> client._use_endpoint), re-attaching to the same job so server-side optimizer/gradient state is preserved. Used for forward/forward_backward/optim_step. save_and_hotload retries without reconnect (the WeightSyncer holds the raw client; re-dispatch is idempotent). - Config (fireworks_infra.common): step_max_retries (default 2, 0 disables), step_retry_backoff_s (default 10). Each retry logs at WARNING. CAVEAT (documented in _run_training_op): forward_backward / optim_step mutate server-side state. A retry assumes the failed RPC didn't commit it — true for channel/dispatch failures; a *false* timeout could double-apply for one step. Retries are kept low; this bounded, rare perturbation beats crashing the run. For genuine slowness, raise step_timeout instead. Adds tests/trainer/test_fireworks_policy_trainer_retry.py (transient classification, retry-succeeds, retry-exhausted, non-transient-not-retried, zero-retries-disables, reconnect on/off, reconnect-without-mgr no-op). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9eeb220 commit 64d8cd4

3 files changed

Lines changed: 264 additions & 5 deletions

File tree

rllm/trainer/config/rllm/backend/fireworks.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ fireworks_infra:
9999
learning_rate: 1e-5 # placeholder for fireworks managed flow. Should be overridden in the training codes.
100100
max_seq_len: null # null = use the training shape's default context length
101101
step_timeout: 3600 # SDK default (3600s)
102+
# Transient-fault tolerance for training-client RPCs (fwd/bwd, optim, weight
103+
# hot-load). A blip on the shared training client around the per-step
104+
# hot-load otherwise crashes the whole run, since the SDK does not retry.
105+
step_max_retries: 2 # retries on transient errors (timeout/connection); 0 disables
106+
step_retry_backoff_s: 10 # base linear backoff between retries (seconds)
102107
weight_sync_timeout: 600
103108

104109
deployments:

rllm/trainer/fireworks/fireworks_policy_trainer.py

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import tinker
2323
from fireworks.training.sdk import WeightSyncer
24+
from omegaconf import OmegaConf
2425
from tinker.types import AdamParams
2526
from training.utils.client import ReconnectableClient
2627

@@ -109,6 +110,9 @@ def __init__(
109110
self.weight_syncer = weight_syncer
110111
self._rlor_mgr = rlor_mgr
111112
self._policy_job_id = policy_job_id
113+
# Transient-fault tolerance for training-client RPCs (see _run_training_op).
114+
self._step_max_retries = max(0, int(OmegaConf.select(config, "fireworks_infra.common.step_max_retries", default=2)))
115+
self._step_retry_backoff_s = float(OmegaConf.select(config, "fireworks_infra.common.step_retry_backoff_s", default=10.0))
112116
self._resume_checkpoint_name = self.config.training.get("resume_from_dcp_checkpoint")
113117
self._resume_source_job_id = self.config.training.get("resume_from_fireworks_job_id") or policy_job_id
114118

@@ -117,6 +121,102 @@ def __init__(
117121
self.algorithm_config = algorithm_config or AlgorithmConfig.from_config(self.config.rllm.algorithm)
118122
self.resolve_builtin_loss(self.algorithm_config)
119123

124+
# ------------------------------------------------------------------
125+
# Transient-fault tolerance for training-client RPCs
126+
# ------------------------------------------------------------------
127+
128+
# Substrings marking a retryable transient failure on the shared training
129+
# client — channel/deadline blips (typically around the per-step weight
130+
# hot-load), not data errors. Mirrors the rollout engine's transient markers.
131+
_TRANSIENT_MARKERS = (
132+
"timeout",
133+
"timed out",
134+
"deadline",
135+
"unavailable",
136+
"connection",
137+
"connection reset",
138+
"broken pipe",
139+
"eof",
140+
"502",
141+
"503",
142+
"504",
143+
"goaway",
144+
"rst_stream",
145+
)
146+
147+
def _is_transient(self, exc: BaseException) -> bool:
148+
"""Whether *exc* looks like a transient channel/timeout fault (vs a data error)."""
149+
if isinstance(exc, TimeoutError | ConnectionError):
150+
return True
151+
text = f"{type(exc).__name__}: {exc}".lower()
152+
return any(marker in text for marker in self._TRANSIENT_MARKERS)
153+
154+
def _reconnect_training_client(self) -> bool:
155+
"""Best-effort refresh of the training client's channel to the **same** job.
156+
157+
Re-resolves the trainer endpoint via the job manager and rebinds the
158+
``ReconnectableClient`` to it (the SDK's own ``_connect`` path, which we
159+
replicate here because ``from_training_client`` instances carry no job
160+
manager). Server-side optimizer/gradient state lives with the job, which
161+
is unchanged — this only replaces a stale channel. Returns True on success.
162+
163+
Note: this refreshes ``self.training_client`` (used by forward / fwd-bwd /
164+
optim_step) only; the ``WeightSyncer`` holds the raw client, so its calls
165+
are retried without reconnect.
166+
"""
167+
mgr, job_id = self._rlor_mgr, self._policy_job_id
168+
if mgr is None or not job_id:
169+
logger.warning("Cannot reconnect training client: rlor_mgr/policy_job_id unset")
170+
return False
171+
try:
172+
endpoint = mgr.wait_for_existing(job_id)
173+
self.training_client._use_endpoint(endpoint)
174+
logger.info("Reconnected training client to job %s", job_id)
175+
return True
176+
except Exception as exc:
177+
logger.warning("Training client reconnect failed for job %s: %s", job_id, exc)
178+
return False
179+
180+
async def _run_training_op(self, fn, *args, op_name: str, reconnect: bool = False, **kwargs):
181+
"""Run a blocking training-client RPC in a thread, retrying transient faults.
182+
183+
On a transient error (timeout / connection / channel reset — typically a
184+
blip on the shared training client around the per-step weight hot-load)
185+
we optionally reconnect to the same job and retry, up to
186+
``step_max_retries`` times with linear backoff. Non-transient errors
187+
(e.g. malformed data) and cancellation propagate immediately.
188+
189+
CAVEAT: forward_backward / optim_step mutate server-side state (gradient
190+
accumulation / optimizer). A retry assumes the failed RPC did NOT commit
191+
that mutation — true for channel/dispatch failures, where the server
192+
never ran it. A *false* timeout (server finished but the ack was lost)
193+
could double-apply for that one step; retries are kept low and this
194+
bounded, rare perturbation is preferred over crashing a multi-hour run.
195+
For genuine slowness, raise ``step_timeout`` instead of relying on retry.
196+
"""
197+
attempt = 0
198+
while True:
199+
try:
200+
return await asyncio.to_thread(fn, *args, **kwargs)
201+
except Exception as exc: # noqa: BLE001 — re-raised below unless transient
202+
if attempt >= self._step_max_retries or not self._is_transient(exc):
203+
raise
204+
attempt += 1
205+
backoff = self._step_retry_backoff_s * attempt
206+
logger.warning(
207+
"Training op %r failed (%s: %s); %sretry %d/%d after %.0fs",
208+
op_name,
209+
type(exc).__name__,
210+
exc,
211+
"reconnect+" if reconnect else "",
212+
attempt,
213+
self._step_max_retries,
214+
backoff,
215+
)
216+
if reconnect:
217+
self._reconnect_training_client()
218+
await asyncio.sleep(backoff)
219+
120220
# ------------------------------------------------------------------
121221
# Initialization
122222
# ------------------------------------------------------------------
@@ -225,10 +325,13 @@ async def _sync_weights(self, name: str, checkpoint_type: str | None = None) ->
225325
Returns the snapshot_name on success, None on failure."""
226326
if self.weight_syncer is None:
227327
return None
228-
snapshot_name = await asyncio.to_thread(
328+
# reconnect=False: the syncer holds the raw client, not self.training_client;
329+
# re-dispatching save_and_hotload (idempotent) is the available recovery.
330+
snapshot_name = await self._run_training_op(
229331
self.weight_syncer.save_and_hotload,
230332
name,
231333
checkpoint_type=checkpoint_type,
334+
op_name="save_and_hotload",
232335
)
233336
logger.debug("Weights synced to deployment: %s", name)
234337
return snapshot_name
@@ -307,10 +410,12 @@ async def _compute_proximal_logprobs(
307410
308411
Only called when ``bypass_mode=False`` (3-policy / decoupled PPO).
309412
"""
310-
prox_fwd = await asyncio.to_thread(
413+
prox_fwd = await self._run_training_op(
311414
self.training_client.forward,
312415
datums,
313416
"cross_entropy",
417+
op_name="forward",
418+
reconnect=True,
314419
)
315420
return [out["logprobs"].data for out in prox_fwd.loss_fn_outputs]
316421

@@ -536,11 +641,13 @@ async def forward_backward_from_trajectory_groups(
536641
)
537642

538643
kernel_loss, kernel_config = self._builtin_loss
539-
fwd_bwd_result = await asyncio.to_thread(
644+
fwd_bwd_result = await self._run_training_op(
540645
self.training_client.forward_backward,
541646
builtin_datums,
542647
kernel_loss,
543648
loss_fn_config=kernel_config,
649+
op_name="forward_backward",
650+
reconnect=True,
544651
)
545652

546653
# Merge remote fwd/bwd metrics (e.g. loss) into adv_metrics
@@ -552,10 +659,12 @@ async def forward_backward_from_trajectory_groups(
552659
# Auxiliary-loss passes: each accumulates gradients on top of the policy
553660
# gradient before the (externally invoked) optim_step.
554661
for aux, datums in aux_passes:
555-
aux_fwd_bwd = await asyncio.to_thread(
662+
aux_fwd_bwd = await self._run_training_op(
556663
self.training_client.forward_backward,
557664
datums,
558665
"cross_entropy",
666+
op_name="forward_backward(aux)",
667+
reconnect=True,
559668
)
560669
if hasattr(aux_fwd_bwd, "metrics") and aux_fwd_bwd.metrics:
561670
for k, v in aux_fwd_bwd.metrics.items():
@@ -613,10 +722,12 @@ async def optim_step(
613722
# gradient, so we disable server-side grad-accumulation normalization.
614723
if build_aux_losses(self.algorithm_config):
615724
grad_norm = GradAccNormalization.NONE
616-
optim_result = await asyncio.to_thread(
725+
optim_result = await self._run_training_op(
617726
self.training_client.optim_step,
618727
adam_params,
619728
grad_accumulation_normalization=grad_norm,
729+
op_name="optim_step",
730+
reconnect=True,
620731
)
621732

622733
metrics = {}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Transient-fault tolerance for Fireworks training-client RPCs.
2+
3+
Covers ``FireworksPolicyTrainer._run_training_op`` / ``_is_transient`` /
4+
``_reconnect_training_client`` in isolation (the trainer is built via
5+
``__new__`` so the heavy SDK init is skipped). Run via ``asyncio.run`` — no
6+
pytest-asyncio needed.
7+
"""
8+
9+
import asyncio
10+
11+
import pytest
12+
13+
from rllm.trainer.fireworks.fireworks_policy_trainer import FireworksPolicyTrainer
14+
15+
16+
class _FakeClient:
17+
def __init__(self):
18+
self.endpoints = []
19+
20+
def _use_endpoint(self, ep):
21+
self.endpoints.append(ep)
22+
23+
24+
class _FakeMgr:
25+
def wait_for_existing(self, job_id):
26+
return f"ep-{job_id}"
27+
28+
29+
def _trainer(*, max_retries=2, backoff=0.0, rlor_mgr=None, job_id="job-1", client=None):
30+
"""A trainer with only the attributes the fault-tolerance helpers touch."""
31+
t = FireworksPolicyTrainer.__new__(FireworksPolicyTrainer)
32+
t._step_max_retries = max_retries
33+
t._step_retry_backoff_s = backoff
34+
t._rlor_mgr = rlor_mgr
35+
t._policy_job_id = job_id
36+
t.training_client = client
37+
return t
38+
39+
40+
def test_is_transient_classification():
41+
t = _trainer()
42+
assert t._is_transient(TimeoutError("timed out"))
43+
assert t._is_transient(ConnectionError("reset"))
44+
assert t._is_transient(RuntimeError("StatusCode.UNAVAILABLE"))
45+
assert t._is_transient(RuntimeError("deadline exceeded"))
46+
assert t._is_transient(RuntimeError("upstream returned 503"))
47+
# Data / programming errors are NOT transient — must propagate.
48+
assert not t._is_transient(ValueError("malformed datum"))
49+
assert not t._is_transient(KeyError("missing field"))
50+
51+
52+
def test_retry_succeeds_after_transient_blips():
53+
calls = []
54+
55+
def fn():
56+
calls.append(1)
57+
if len(calls) < 3:
58+
raise TimeoutError("blip")
59+
return "ok"
60+
61+
t = _trainer(max_retries=3)
62+
assert asyncio.run(t._run_training_op(fn, op_name="x")) == "ok"
63+
assert len(calls) == 3 # 2 failures + 1 success
64+
65+
66+
def test_retry_exhausted_reraises():
67+
def fn():
68+
raise TimeoutError("persistent")
69+
70+
t = _trainer(max_retries=2)
71+
with pytest.raises(TimeoutError):
72+
asyncio.run(t._run_training_op(fn, op_name="x"))
73+
74+
75+
def test_non_transient_not_retried():
76+
calls = []
77+
78+
def fn():
79+
calls.append(1)
80+
raise ValueError("bad data")
81+
82+
t = _trainer(max_retries=5)
83+
with pytest.raises(ValueError):
84+
asyncio.run(t._run_training_op(fn, op_name="x"))
85+
assert len(calls) == 1 # failed once, no retry
86+
87+
88+
def test_zero_retries_disables():
89+
calls = []
90+
91+
def fn():
92+
calls.append(1)
93+
raise TimeoutError("blip")
94+
95+
t = _trainer(max_retries=0)
96+
with pytest.raises(TimeoutError):
97+
asyncio.run(t._run_training_op(fn, op_name="x"))
98+
assert len(calls) == 1
99+
100+
101+
def test_reconnect_invoked_between_retries_when_enabled():
102+
client = _FakeClient()
103+
t = _trainer(max_retries=2, rlor_mgr=_FakeMgr(), job_id="j", client=client)
104+
calls = []
105+
106+
def fn():
107+
calls.append(1)
108+
if len(calls) < 2:
109+
raise TimeoutError("blip")
110+
return "ok"
111+
112+
assert asyncio.run(t._run_training_op(fn, op_name="x", reconnect=True)) == "ok"
113+
assert client.endpoints == ["ep-j"] # same-job channel refresh, once
114+
115+
116+
def test_reconnect_skipped_when_disabled():
117+
client = _FakeClient()
118+
t = _trainer(max_retries=2, rlor_mgr=_FakeMgr(), client=client)
119+
calls = []
120+
121+
def fn():
122+
calls.append(1)
123+
if len(calls) < 2:
124+
raise TimeoutError("blip")
125+
return "ok"
126+
127+
asyncio.run(t._run_training_op(fn, op_name="x", reconnect=False))
128+
assert client.endpoints == [] # never reconnected
129+
130+
131+
def test_reconnect_without_mgr_is_safe_noop():
132+
# _reconnect returns False (no rlor_mgr) but the retry loop still proceeds.
133+
t = _trainer(max_retries=1, rlor_mgr=None)
134+
calls = []
135+
136+
def fn():
137+
calls.append(1)
138+
if len(calls) < 2:
139+
raise TimeoutError("blip")
140+
return "ok"
141+
142+
assert asyncio.run(t._run_training_op(fn, op_name="x", reconnect=True)) == "ok"
143+
assert t._reconnect_training_client() is False

0 commit comments

Comments
 (0)