Skip to content

Commit 7b7682c

Browse files
[feat][SFT] Preserve promotable periodic checkpoints
Let hosted SFT runs retain inference-formatted checkpoints at the same cadence as resumable DCP state, so selected intermediate steps can be promoted after training without reconnecting to the trainer. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 124c261 commit 7b7682c

9 files changed

Lines changed: 329 additions & 8 deletions

File tree

skyrl/backends/fireworks/runtime.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ class SamplerVersion:
2929
snapshot_path: str
3030

3131

32+
@dataclass(frozen=True)
33+
class PromotableCheckpoint:
34+
"""Inference checkpoint persisted for later model promotion."""
35+
36+
snapshot_path: str
37+
checkpoint_resource: str | None
38+
checkpoint_type: str | None
39+
40+
3241
@dataclass(frozen=True)
3342
class FireworksInferenceEndpoint:
3443
"""Native OpenAI-compatible endpoint for the managed deployment."""
@@ -72,6 +81,7 @@ def __init__(
7281
self.tokenizer = tokenizer
7382
self.config = config
7483
self._state_lock = threading.Condition()
84+
self._provider_operation_lock = threading.RLock()
7585
self._publish_lock = asyncio.Lock()
7686
self._sampler: Any | None = None
7787
self._sampler_identity: SamplerVersion | None = None
@@ -238,6 +248,91 @@ def _save() -> str:
238248
self._next_version = version + 1
239249
return identity
240250

251+
def save_promotable_checkpoint(
252+
self,
253+
checkpoint_name: str,
254+
*,
255+
appear_timeout_s: float = 90.0,
256+
poll_s: float = 3.0,
257+
) -> PromotableCheckpoint:
258+
with self._provider_operation_lock:
259+
with self._state_lock:
260+
if self._closed:
261+
raise RuntimeError("Fireworks runtime is closed")
262+
263+
result = self.training_client.save_weights_for_sampler(
264+
checkpoint_name,
265+
checkpoint_type="base",
266+
).result(timeout=self.config.request_timeout_s)
267+
snapshot_path = str(getattr(result, "path", "") or "")
268+
if not snapshot_path:
269+
raise RuntimeError(f"Fireworks save_weights_for_sampler({checkpoint_name!r}) returned no path")
270+
271+
snapshot_id = snapshot_path.rstrip("/").rsplit("/", 1)[-1]
272+
deadline = time.monotonic() + appear_timeout_s
273+
matches: list[dict] = []
274+
while time.monotonic() < deadline:
275+
try:
276+
rows = self.service.list_checkpoints(self.trainer_job_id)
277+
except Exception:
278+
time.sleep(poll_s)
279+
continue
280+
matches = [row for row in rows if row.get("promotable") and _checkpoint_short_name(row) == snapshot_id]
281+
if matches:
282+
break
283+
time.sleep(poll_s)
284+
if len(matches) > 1:
285+
raise RuntimeError(
286+
f"Expected at most one promotable Fireworks checkpoint for {checkpoint_name!r}, "
287+
f"got {len(matches)}"
288+
)
289+
if not matches:
290+
warnings.warn(
291+
f"Promotable Fireworks checkpoint {checkpoint_name!r} was saved but did not "
292+
"surface on the control plane before the visibility timeout",
293+
RuntimeWarning,
294+
)
295+
return PromotableCheckpoint(
296+
snapshot_path=snapshot_path,
297+
checkpoint_resource=None,
298+
checkpoint_type=None,
299+
)
300+
checkpoint = matches[0]
301+
return PromotableCheckpoint(
302+
snapshot_path=snapshot_path,
303+
checkpoint_resource=checkpoint["name"],
304+
checkpoint_type=checkpoint.get("checkpointType"),
305+
)
306+
307+
async def promote_checkpoint_resource(
308+
self,
309+
*,
310+
checkpoint: PromotableCheckpoint,
311+
output_model_id: str,
312+
) -> dict[str, Any]:
313+
if not checkpoint.checkpoint_resource:
314+
raise ValueError("Promotable checkpoint has no control-plane resource")
315+
316+
def _promote() -> dict:
317+
with self._provider_operation_lock:
318+
with self._state_lock:
319+
if self._closed:
320+
raise RuntimeError("Fireworks runtime is closed")
321+
return self.service.promote_checkpoint(
322+
name=checkpoint.checkpoint_resource,
323+
output_model_id=output_model_id,
324+
base_model=self.config.base_model,
325+
)
326+
327+
model = await asyncio.to_thread(_promote)
328+
return {
329+
"sampler_path": checkpoint.snapshot_path,
330+
"checkpoint_resource": checkpoint.checkpoint_resource,
331+
"checkpoint_type": checkpoint.checkpoint_type,
332+
"output_model_id": output_model_id,
333+
"model": model,
334+
}
335+
241336
async def promote_final_model(
242337
self,
243338
*,
@@ -397,4 +492,9 @@ def _wait_for_samples() -> tuple[Any | None, int]:
397492
)
398493
if sampler is not None:
399494
await asyncio.to_thread(_close_quietly, sampler)
400-
await asyncio.to_thread(_close_quietly, self.service)
495+
496+
def _close_service() -> None:
497+
with self._provider_operation_lock:
498+
_close_quietly(self.service)
499+
500+
await asyncio.to_thread(_close_service)

skyrl/backends/fireworks/training_backend.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,25 @@ def save_checkpoint(self, model: str, ckpt_dir: str, tokenizer=None) -> None:
174174
if not provider_path:
175175
raise RuntimeError(f"Fireworks save_state({checkpoint_name!r}) returned no checkpoint path")
176176

177+
promotable = None
178+
if self.fireworks_config.save_promotable_checkpoints:
179+
promotable = self.runtime.save_promotable_checkpoint(checkpoint_name)
180+
177181
manifest = {
178182
"format_version": 1,
179183
"checkpoint_kind": "fireworks_dcp",
180184
"checkpoint_name": checkpoint_name,
181185
"provider_path": provider_path,
182186
"source_trainer_job_id": self.runtime.trainer_job_id,
187+
"base_model": self.fireworks_config.base_model,
183188
"includes_optimizer_state": True,
184189
}
190+
if promotable is not None:
191+
manifest["promotable_checkpoint"] = {
192+
"snapshot_path": promotable.snapshot_path,
193+
"checkpoint_resource": promotable.checkpoint_resource,
194+
"checkpoint_type": promotable.checkpoint_type,
195+
}
185196
io.makedirs(ckpt_dir, exist_ok=True)
186197
manifest_path = os.path.join(ckpt_dir, self._CHECKPOINT_MANIFEST)
187198
with io.open_file(manifest_path, "w") as f:

skyrl/train/config/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,8 @@ class FireworksConfig(BaseConfig):
942942
cleanup_on_exit: bool = True
943943
output_model_id: Optional[str] = None
944944
"""Optional Fireworks model ID promoted from the final sampler checkpoint."""
945+
save_promotable_checkpoints: bool = False
946+
"""Save a promotable inference snapshot alongside each resumable checkpoint."""
945947
delete_trainer_after_promotion: bool = True
946948
cleanup_deployment_on_close: str = "delete"
947949
"""``"delete"`` or ``"scale_to_zero"`` for the SDK-created deployment."""

skyrl/train/config/sft_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,8 @@ def validate_fireworks_sft_cfg(cfg: SFTConfig) -> None:
572572
raise ValueError("Fireworks SFT requires ckpt_path when ckpt_interval > 0")
573573
if cfg.resume_from == "latest" and not cfg.ckpt_path:
574574
raise ValueError("Fireworks SFT requires ckpt_path when resume_from='latest'")
575+
if cfg.fireworks.save_promotable_checkpoints and not cfg.ckpt_path:
576+
raise ValueError("Fireworks SFT requires ckpt_path when save_promotable_checkpoints=true")
575577
if cfg.fireworks.output_model_id:
576578
if not cfg.ckpt_path:
577579
raise ValueError("Fireworks SFT requires ckpt_path when output_model_id is set")

skyrl/train/fireworks_sft_trainer.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import os
88

9-
from skyrl.backends.fireworks.runtime import FireworksRuntime
9+
from skyrl.backends.fireworks.runtime import FireworksRuntime, PromotableCheckpoint
1010
from skyrl.backends.fireworks.sft import FireworksSFTDispatch
1111
from skyrl.backends.skyrl_train.utils.io import io
1212
from skyrl.train.sft_trainer import SFTTrainer
@@ -80,12 +80,32 @@ def export_final_model(self) -> dict | None:
8080
if not checkpoint_name:
8181
raise ValueError(f"Fireworks DCP manifest has no checkpoint_name: {dcp_manifest_path}")
8282

83-
result = asyncio.run(
84-
self._fireworks_runtime.promote_final_model(
85-
checkpoint_name=checkpoint_name,
86-
output_model_id=output_model_id,
83+
promotable = dcp_manifest.get("promotable_checkpoint")
84+
snapshot_path = promotable.get("snapshot_path") if isinstance(promotable, dict) else None
85+
checkpoint_resource = promotable.get("checkpoint_resource") if isinstance(promotable, dict) else None
86+
if (
87+
isinstance(snapshot_path, str)
88+
and snapshot_path
89+
and isinstance(checkpoint_resource, str)
90+
and "/checkpoints/" in checkpoint_resource
91+
):
92+
result = asyncio.run(
93+
self._fireworks_runtime.promote_checkpoint_resource(
94+
checkpoint=PromotableCheckpoint(
95+
snapshot_path=snapshot_path,
96+
checkpoint_resource=checkpoint_resource,
97+
checkpoint_type=promotable.get("checkpoint_type"),
98+
),
99+
output_model_id=output_model_id,
100+
)
101+
)
102+
else:
103+
result = asyncio.run(
104+
self._fireworks_runtime.promote_final_model(
105+
checkpoint_name=checkpoint_name,
106+
output_model_id=output_model_id,
107+
)
87108
)
88-
)
89109
manifest = {
90110
"format_version": 1,
91111
"global_step": self.global_step,

tests/backends/fireworks/test_runtime.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from skyrl.backends.fireworks.runtime import FireworksRuntime
7+
from skyrl.backends.fireworks.runtime import FireworksRuntime, PromotableCheckpoint
88
from skyrl.train.config import FireworksConfig
99

1010

@@ -225,6 +225,71 @@ async def test_publish_reuses_one_stable_sampler() -> None:
225225
await runtime.close()
226226

227227

228+
def test_save_promotable_checkpoint_uses_base_sampler_and_control_plane_row() -> None:
229+
service = _Service()
230+
service.checkpoint_rows = [
231+
{
232+
"name": "accounts/test/rlorTrainerJobs/trainer/checkpoints/step-2-cafebabe",
233+
"promotable": True,
234+
"checkpointType": "CHECKPOINT_TYPE_INFERENCE_BASE",
235+
}
236+
]
237+
training = _TrainingClient()
238+
runtime = _runtime(
239+
service=service,
240+
training_client=training,
241+
config=FireworksConfig(request_timeout_s=123),
242+
)
243+
244+
result = runtime.save_promotable_checkpoint("step-2", poll_s=0)
245+
246+
assert training.names == ["step-2"]
247+
assert training.checkpoint_types == ["base"]
248+
assert result.snapshot_path == "snapshot://step-2-cafebabe"
249+
assert result.checkpoint_resource.endswith("step-2-cafebabe")
250+
assert result.checkpoint_type == "CHECKPOINT_TYPE_INFERENCE_BASE"
251+
252+
253+
def test_save_promotable_checkpoint_keeps_path_when_control_plane_lags() -> None:
254+
runtime = _runtime()
255+
256+
with pytest.warns(RuntimeWarning, match="visibility timeout"):
257+
result = runtime.save_promotable_checkpoint("step-2", appear_timeout_s=0, poll_s=0)
258+
259+
assert result.snapshot_path == "snapshot://step-2-cafebabe"
260+
assert result.checkpoint_resource is None
261+
assert result.checkpoint_type is None
262+
263+
264+
@pytest.mark.asyncio
265+
async def test_promote_checkpoint_resource_uses_existing_control_plane_row() -> None:
266+
service = _Service()
267+
runtime = _runtime(
268+
service=service,
269+
config=FireworksConfig(base_model="accounts/fireworks/models/qwen3-4b"),
270+
)
271+
checkpoint = PromotableCheckpoint(
272+
snapshot_path="snapshot://step-2-cafebabe",
273+
checkpoint_resource="accounts/test/rlorTrainerJobs/trainer/checkpoints/step-2-cafebabe",
274+
checkpoint_type="CHECKPOINT_TYPE_INFERENCE_BASE",
275+
)
276+
277+
result = await runtime.promote_checkpoint_resource(
278+
checkpoint=checkpoint,
279+
output_model_id="sft-step-2",
280+
)
281+
282+
assert service.promotions == [
283+
{
284+
"name": checkpoint.checkpoint_resource,
285+
"output_model_id": "sft-step-2",
286+
"base_model": "accounts/fireworks/models/qwen3-4b",
287+
}
288+
]
289+
assert result["checkpoint_resource"] == checkpoint.checkpoint_resource
290+
assert result["model"]["name"] == "accounts/test/models/sft-step-2"
291+
292+
228293
@pytest.mark.asyncio
229294
async def test_promote_final_model_saves_base_sampler_and_uses_control_plane_row() -> None:
230295
service = _Service()

tests/backends/fireworks/test_training_backend.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ def test_policy_dispatch_saves_and_cross_job_loads_dcp_checkpoint(tmp_path) -> N
138138
manifest = json.loads((ckpt_dir / "fireworks_checkpoint.json").read_text())
139139
assert manifest["source_trainer_job_id"] == "source-trainer"
140140
assert manifest["includes_optimizer_state"] is True
141+
assert "promotable_checkpoint" not in manifest
141142

142143
runtime.trainer_job_id = "target-trainer"
143144
dispatch.load_checkpoint(
@@ -155,6 +156,40 @@ def test_policy_dispatch_saves_and_cross_job_loads_dcp_checkpoint(tmp_path) -> N
155156
]
156157

157158

159+
def test_policy_dispatch_saves_promotable_checkpoint_when_enabled(tmp_path) -> None:
160+
training_client = _TrainingClient()
161+
saved_promotable = []
162+
163+
def save_promotable(name):
164+
saved_promotable.append(name)
165+
return SimpleNamespace(
166+
snapshot_path=f"snapshot://{name}-cafebabe",
167+
checkpoint_resource=f"accounts/test/rlorTrainerJobs/trainer/checkpoints/{name}-cafebabe",
168+
checkpoint_type="CHECKPOINT_TYPE_INFERENCE_BASE",
169+
)
170+
171+
runtime = SimpleNamespace(
172+
training_client=training_client,
173+
trainer_job_id="source-trainer",
174+
save_promotable_checkpoint=save_promotable,
175+
)
176+
cfg = _cfg()
177+
cfg.trainer.fireworks.save_promotable_checkpoints = True
178+
dispatch = FireworksPolicyDispatch(runtime, cfg.trainer.fireworks, cfg.trainer.policy.optimizer_config)
179+
ckpt_dir = tmp_path / "global_step_7" / "policy"
180+
181+
dispatch.save_checkpoint("policy", str(ckpt_dir), tokenizer="unused")
182+
183+
manifest = json.loads((ckpt_dir / "fireworks_checkpoint.json").read_text())
184+
checkpoint_name = manifest["checkpoint_name"]
185+
assert saved_promotable == [checkpoint_name]
186+
assert manifest["promotable_checkpoint"] == {
187+
"snapshot_path": f"snapshot://{checkpoint_name}-cafebabe",
188+
"checkpoint_resource": (f"accounts/test/rlorTrainerJobs/trainer/checkpoints/{checkpoint_name}-cafebabe"),
189+
"checkpoint_type": "CHECKPOINT_TYPE_INFERENCE_BASE",
190+
}
191+
192+
158193
def test_policy_dispatch_loads_same_trainer_checkpoint_by_name(tmp_path) -> None:
159194
training_client = _TrainingClient()
160195
runtime = SimpleNamespace(training_client=training_client, trainer_job_id="same-trainer")

0 commit comments

Comments
 (0)