Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / components / checkpoint / dcp_checkpointer.py: 65%

278 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2025-2026 Bytedance Ltd. and/or its affiliates 

2# Copyright 2026 Huawei Technologies Co., Ltd 

3# 

4# Licensed under the Apache License, Version 2.0 (the "License"); 

5# you may not use this file except in compliance with the License. 

6# You may obtain a copy of the License at 

7# 

8# http://www.apache.org/licenses/LICENSE-2.0 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

13# See the License for the specific language governing permissions and 

14# limitations under the License. 

15# ============================================================================ 

16"""Distributed-checkpoint (DCP) backend for :class:`CheckpointerBase`.""" 

17 

18__all__ = [ 

19 "STEP_PREFIX", 

20 "DistributedCheckpointer", 

21 "initialize_optimizer_state", 

22] 

23 

24import gc 

25import logging 

26import os 

27from functools import partial 

28from typing import Any, Dict, List, Optional 

29 

30import torch # pylint: disable=forbidden-backend-import 

31import torch.distributed as dist # pylint: disable=forbidden-backend-import 

32 

33from hyper_parallel import SkipDTensorDispatch 

34from hyper_parallel.components.optim.mixed_precision_optimizer import ( 

35 FP32_MAIN_PARAM_STATE_KEY, 

36 MIXED_PRECISION_OPTIMIZER_STATE_KEY, 

37 MixedPrecisionOptimizer, 

38) 

39from hyper_parallel.models.build_options import ( 

40 IS_CUDA_AVAILABLE, 

41 IS_NPU_AVAILABLE, 

42 get_torch_device, 

43) 

44from hyper_parallel.components.checkpoint.base import CheckpointerBase 

45from hyper_parallel.core.distributed_checkpoint import ( 

46 StandardLoadPlanner, 

47 async_save as dcp_async_save, 

48 load as dcp_load, 

49 save as dcp_save, 

50) 

51from hyper_parallel.core.distributed_checkpoint.utils import flatten_state_dict 

52from hyper_parallel.core.optimizer.adamw import AdamW as HyperAdamW 

53from hyper_parallel.core.optimizer.muon import Muon as HyperMuon 

54from hyper_parallel.core.optimizer.optimizer import ChainedOptimizer 

55 

56 

57logger = logging.getLogger(__name__) 

58 

59 

60def _empty_cache() -> None: 

61 """Collect host and device memory around checkpoint I/O.""" 

62 gc.collect() 

63 if IS_CUDA_AVAILABLE or IS_NPU_AVAILABLE: 

64 get_torch_device().empty_cache() 

65 

66# Layout of a checkpoint root: 

67# 

68# checkpoints/ 

69# ├── latest_checkpoint_iteration.txt # step number of the newest checkpoint 

70# └── global_step_42/ 

71# ├── .metadata # DCP global metadata, written last 

72# ├── __0_0.safetensors, ... # DCP shard payloads 

73# └── extra_state/ 

74# └── extra_state_rank_{R}.pt # per-rank training state 

75# 

76# ``.metadata`` doubles as the completeness signal: ``FileSystemWriter``'s 

77# ``finalize_checkpoint`` only writes it after every rank's shard writes have 

78# been collected, so a directory carrying it holds a fully persisted checkpoint 

79# and one missing it was interrupted mid-save. 

80STEP_PREFIX = "global_step_" 

81LATEST_CHECKPOINT_FILE = "latest_checkpoint_iteration.txt" 

82_EXTRA_STATE_DIR = "extra_state" 

83_EXTRA_STATE_FILE = "extra_state_rank_{rank}.pt" 

84_DCP_METADATA_FILE = ".metadata" 

85 

86# ``extra_state`` must carry this key; the checkpointer stamps a sentinel over 

87# it in the skeleton handed to an embedded-bundle load, so a load that read 

88# nothing back is distinguishable from a genuine restore. A real checkpoint is 

89# never written at a step this low. 

90_STEP_KEY = "global_step" 

91_UNRESTORED_STEP = -1 

92 

93 

94def _get_rank() -> int: 

95 """Return this process' global rank, or 0 outside a process group.""" 

96 if dist.is_available() and dist.is_initialized(): 

97 return dist.get_rank() 

98 return 0 

99 

100 

101def _barrier() -> None: 

102 """Synchronize all ranks, tolerating single-process (non-distributed) runs.""" 

103 if dist.is_available() and dist.is_initialized(): 

104 dist.barrier() 

105 

106 

107def _prepare_main_param_state_for_dcp_load( 

108 state_dict: Dict[str, Any], 

109 available_keys: set[str], 

110) -> None: 

111 """Select the ordinary or fp32-main optimizer checkpoint branch.""" 

112 flattened_state, _ = flatten_state_dict(state_dict) 

113 optimizer_state = state_dict.get("optimizer") 

114 optimizer_state_is_list = isinstance(optimizer_state, list) 

115 optimizer_states = ( 

116 optimizer_state 

117 if optimizer_state_is_list 

118 else [optimizer_state] 

119 ) 

120 for optimizer_index, optimizer_state in enumerate(optimizer_states): 

121 if not isinstance(optimizer_state, dict): 

122 continue 

123 if MIXED_PRECISION_OPTIMIZER_STATE_KEY not in optimizer_state: 

124 continue 

125 

126 state_prefix = ( 

127 f"optimizer.{optimizer_index}" 

128 if optimizer_state_is_list 

129 else "optimizer" 

130 ) 

131 main_param_prefix = ( 

132 f"{state_prefix}.{MIXED_PRECISION_OPTIMIZER_STATE_KEY}." 

133 f"{FP32_MAIN_PARAM_STATE_KEY}." 

134 ) 

135 expected_keys = { 

136 key for key in flattened_state if key.startswith(main_param_prefix) 

137 } 

138 if not expected_keys: 

139 continue 

140 

141 checkpoint_keys = { 

142 key for key in available_keys if key.startswith(main_param_prefix) 

143 } 

144 if not checkpoint_keys: 

145 optimizer_state.pop(MIXED_PRECISION_OPTIMIZER_STATE_KEY) 

146 continue 

147 

148 missing_keys = sorted(expected_keys - checkpoint_keys) 

149 if missing_keys: 

150 preview = ", ".join(missing_keys[:10]) 

151 raise RuntimeError( 

152 "Mixed-precision optimizer checkpoint is incomplete; missing " 

153 f"{len(missing_keys)} fp32 main parameter(s): {preview}" 

154 ) 

155 

156 

157class _ModelStrictLoadPlanner(StandardLoadPlanner): 

158 """Allow partial optimizer state while still requiring a complete model. 

159 

160 A parameter that never received a gradient --- a frozen weight, an MoE expert 

161 no batch routed to, or simply a checkpoint written before the first 

162 ``step()`` --- has no optimizer state to save. Meanwhile the load side primes 

163 the optimizer (see :func:`initialize_optimizer_state`) so DCP has entries to 

164 fill, which means it legitimately asks for keys the checkpoint does not 

165 carry. Under the planner's default strict mode that is a hard error, so 

166 ``allow_partial_load`` lets those entries keep the zero-initialised values 

167 the optimizer would have created on its next step anyway. 

168 

169 ``allow_partial_load`` is global to the planner and cannot be scoped to the 

170 optimizer subtree, so model completeness is re-checked here: a truncated 

171 model checkpoint must still fail loudly instead of resuming half-loaded. 

172 """ 

173 

174 def __init__(self, strict_model: bool = True) -> None: 

175 """Load partially, optionally demanding every requested model key.""" 

176 super().__init__(allow_partial_load=True) 

177 self.strict_model = strict_model 

178 

179 def configure_planner( 

180 self, 

181 state_dict: Dict[str, Any], 

182 metadata: Any, 

183 **kwargs: Any, 

184 ) -> None: 

185 """Normalize optional optimizer state before flattening the load skeleton.""" 

186 _prepare_main_param_state_for_dcp_load( 

187 state_dict, 

188 set(metadata.state_dict_metadata), 

189 ) 

190 super().configure_planner(state_dict, metadata, **kwargs) 

191 

192 def build_local_plan(self) -> Any: 

193 """Validate model coverage, then defer to the standard planner.""" 

194 if self.state_dict is None or self.metadata is None: 

195 return super().build_local_plan() 

196 

197 available = self.metadata.state_dict_metadata 

198 missing = [fqn for fqn in self.state_dict if fqn not in available] 

199 

200 missing_model = sorted(fqn for fqn in missing if fqn.startswith("model.")) 

201 if self.strict_model and missing_model: 

202 preview = ", ".join(missing_model[:10]) 

203 suffix = " ..." if len(missing_model) > 10 else "" 

204 raise RuntimeError( 

205 f"Checkpoint is missing {len(missing_model)} model key(s) required " 

206 f"for a full-model resume: {preview}{suffix}" 

207 ) 

208 

209 missing_optimizer = sorted(fqn for fqn in missing if fqn.startswith("optimizer.")) 

210 if missing_optimizer: 

211 preview = ", ".join(missing_optimizer[:3]) 

212 logger.warning( 

213 "%s optimizer entries are absent from the checkpoint and keep their " 

214 "zero-initialised values (e.g. %s). Expected for parameters that " 

215 "never received a gradient, or for a checkpoint written with " 

216 "save_optimizer=false.", 

217 len(missing_optimizer), 

218 preview, 

219 ) 

220 

221 return super().build_local_plan() 

222 

223 

224def _optimizer_parameters(optimizer: Any) -> List[torch.Tensor]: 

225 """Return trainable parameters managed by an optimizer.""" 

226 parameters = [] 

227 for group in optimizer.param_groups: 

228 for param in group["params"]: 

229 if param.requires_grad: 

230 parameters.append(param) 

231 return parameters 

232 

233 

234def _prime_optimizer_state( 

235 optimizer: Any, 

236 params: List[torch.Tensor], 

237) -> None: 

238 """Run a parameter-neutral optimizer step to materialize its state.""" 

239 for param in params: 

240 param.grad = torch.zeros_like(param) 

241 for group in optimizer.param_groups: 

242 if "lr" in group: 

243 group["lr"] = 0.0 

244 if "weight_decay" in group: 

245 group["weight_decay"] = 0.0 

246 # zeros_like must retain DTensor dispatch so newly created state keeps the parameter mesh and placements. 

247 with SkipDTensorDispatch(no_skip={torch.zeros_like}): 

248 optimizer.step() 

249 

250 

251def _materialize_hyper_optimizer_state( 

252 optimizer: Any, 

253 params: List[torch.Tensor], 

254) -> bool: 

255 """Create lazy Hyper AdamW/Muon state without executing an update step.""" 

256 if isinstance(optimizer, HyperMuon): 

257 state_keys = ("momentum_buffer",) 

258 elif isinstance(optimizer, HyperAdamW): 

259 state_keys = ("exp_avg", "exp_avg_sq") 

260 else: 

261 return False 

262 

263 param_ids = {id(param) for param in params} 

264 # zeros_like must retain DTensor dispatch so newly created state keeps the parameter mesh and placements. 

265 with SkipDTensorDispatch(no_skip={torch.zeros_like}): 

266 for group in optimizer.param_groups: 

267 group["step"] = group.get("step") or 0 

268 for param in group["params"]: 

269 if id(param) not in param_ids: 

270 continue 

271 state = optimizer.state[param] 

272 for state_key in state_keys: 

273 state.setdefault( 

274 state_key, 

275 torch.zeros_like(param, memory_format=torch.preserve_format), 

276 ) 

277 if isinstance(optimizer, HyperAdamW) and group.get("amsgrad", False): 

278 state.setdefault( 

279 "max_exp_avg_sq", 

280 torch.zeros_like(param, memory_format=torch.preserve_format), 

281 ) 

282 return True 

283 

284 

285def _restore_optimizer_hyperparams( 

286 optimizer: Any, 

287 saved_hyperparams: List[tuple[Optional[float], Optional[float]]], 

288) -> None: 

289 """Restore optimizer hyperparameters after state priming.""" 

290 for group, (lr, weight_decay) in zip(optimizer.param_groups, saved_hyperparams): 

291 if lr is not None: 

292 group["lr"] = lr 

293 if weight_decay is not None: 

294 group["weight_decay"] = weight_decay 

295 optimizer.zero_grad(set_to_none=True) 

296 

297 

298def _leaf_optimizers(optimizer: Any) -> List[Any]: 

299 """Return the concrete optimizers that own lazy tensor state.""" 

300 inner_optimizer = ( 

301 optimizer.optimizer 

302 if isinstance(optimizer, MixedPrecisionOptimizer) 

303 else optimizer 

304 ) 

305 if isinstance(inner_optimizer, ChainedOptimizer): 

306 return list(inner_optimizer.chained_optimizers) 

307 return [inner_optimizer] 

308 

309 

310def initialize_optimizer_state(optimizer: Any) -> bool: 

311 """Materialize optimizer state so a DCP load has entries to fill. 

312 

313 ``Optimizer.state`` is empty until the first ``step()``, and DCP loads 

314 strictly into the keys the caller's state_dict already contains. Restoring a 

315 freshly built optimizer therefore reads *nothing* and silently resumes with 

316 zeroed moments --- the model continues from the checkpoint while Adam starts 

317 over. Priming with zero gradients creates the ``exp_avg`` / ``exp_avg_sq`` 

318 entries the checkpoint will overwrite. 

319 

320 Hyper AdamW and Muon states are created directly. This avoids running Muon's 

321 HSDP communication and Newton-Schulz update merely to allocate zero momentum. 

322 Unknown optimizer implementations retain the parameter-neutral priming-step 

323 fallback, with ``lr`` and ``weight_decay`` forced to zero. 

324 

325 Args: 

326 optimizer: Optimizer whose state is materialized. 

327 

328 Returns: 

329 Whether the optimizer now carries per-parameter state. 

330 """ 

331 leaf_optimizers = _leaf_optimizers(optimizer) 

332 if all(leaf_optimizer.state for leaf_optimizer in leaf_optimizers): 

333 return True 

334 

335 leaf_parameters = { 

336 leaf_optimizer: _optimizer_parameters(leaf_optimizer) 

337 for leaf_optimizer in leaf_optimizers 

338 if not leaf_optimizer.state 

339 } 

340 if any(not params for params in leaf_parameters.values()): 

341 return False 

342 if any( 

343 param.grad is not None 

344 for params in leaf_parameters.values() 

345 for param in params 

346 ): 

347 logger.warning( 

348 "Optimizer parameters already carry gradients; skipping state priming " 

349 "to avoid corrupting them. Optimizer state may not be restored." 

350 ) 

351 return False 

352 

353 for leaf_optimizer, params in leaf_parameters.items(): 

354 if _materialize_hyper_optimizer_state(leaf_optimizer, params): 

355 continue 

356 saved_hyperparams = [ 

357 (group.get("lr"), group.get("weight_decay")) 

358 for group in leaf_optimizer.param_groups 

359 ] 

360 try: 

361 _prime_optimizer_state(leaf_optimizer, params) 

362 finally: 

363 _restore_optimizer_hyperparams(leaf_optimizer, saved_hyperparams) 

364 

365 return all(leaf_optimizer.state for leaf_optimizer in leaf_optimizers) 

366 

367 

368class DistributedCheckpointer(CheckpointerBase): 

369 """Persist a training payload through ``hyper_parallel``'s DCP. 

370 

371 ``extra_state`` can live in either of two layouts, chosen by 

372 ``extra_state_per_rank`` at save time: 

373 

374 * ``True`` --- one ``torch.save`` file per rank under ``extra_state/``. 

375 Always correct. 

376 * ``False`` --- embedded in the DCP payload. Fewer files, but DCP 

377 deduplicates entries sharing an FQN across ranks (see 

378 ``distributed_checkpoint/utils.py::plan_ownership_masks``), so every rank 

379 restores whichever rank's copy won. Only valid when the extra state is 

380 rank-invariant. 

381 

382 :meth:`load` auto-detects which layout a checkpoint on disk used, so the 

383 flag may differ from the run that produced it. 

384 """ 

385 

386 def __init__(self, extra_state_per_rank: bool = True) -> None: 

387 """Select the ``extra_state`` layout used by :meth:`save`.""" 

388 self.extra_state_per_rank = extra_state_per_rank 

389 self._async_save_response: Optional[Any] = None 

390 

391 # ------------------------------------------------------------------ 

392 # Layout helpers 

393 # ------------------------------------------------------------------ 

394 

395 @staticmethod 

396 def _extra_state_path(checkpoint_dir: str, rank: int) -> str: 

397 """Return this rank's extra_state file inside ``checkpoint_dir``.""" 

398 return os.path.join( 

399 checkpoint_dir, _EXTRA_STATE_DIR, _EXTRA_STATE_FILE.format(rank=rank) 

400 ) 

401 

402 @staticmethod 

403 def _is_complete(checkpoint_path: str) -> bool: 

404 """Report whether ``checkpoint_path`` holds a fully persisted checkpoint. 

405 

406 Presence of the DCP metadata file is the signal: it is written only once 

407 every rank's shards are on disk. ``use_collectives=False`` names it 

408 ``{rank}.metadata`` instead of a single shared ``.metadata``, so both 

409 spellings count. 

410 """ 

411 if not os.path.isdir(checkpoint_path): 

412 return False 

413 if os.path.isfile(os.path.join(checkpoint_path, _DCP_METADATA_FILE)): 

414 return True 

415 return any( 

416 name.endswith(_DCP_METADATA_FILE) for name in os.listdir(checkpoint_path) 

417 ) 

418 

419 @staticmethod 

420 def _latest_pointer_path(checkpoint_dir: str) -> str: 

421 """Return the path of the newest-checkpoint pointer file.""" 

422 return os.path.join(checkpoint_dir, LATEST_CHECKPOINT_FILE) 

423 

424 def _write_latest_pointer(self, checkpoint_dir: str, global_step: int) -> None: 

425 """Record which checkpoint is newest, so resume need not scan the tree. 

426 

427 Only the step number is stored: the directory name is 

428 ``STEP_PREFIX + step``, and that prefix is a module constant rather than 

429 anything configurable, so writing it down would just be a second copy of 

430 a value the reader already has. 

431 """ 

432 with open(self._latest_pointer_path(checkpoint_dir), "w", encoding="utf-8") as handle: 

433 handle.write(f"{global_step}\n") 

434 

435 def _read_latest_pointer(self, checkpoint_dir: str) -> Optional[str]: 

436 """Resolve the pointer file to a usable checkpoint directory. 

437 

438 Returns ``None`` when the file is absent, unreadable, or names a 

439 checkpoint that is missing or incomplete --- every one of which is a 

440 reason to fall back to scanning rather than to fail. 

441 """ 

442 pointer_path = self._latest_pointer_path(checkpoint_dir) 

443 if not os.path.isfile(pointer_path): 

444 return None 

445 

446 try: 

447 with open(pointer_path, encoding="utf-8") as handle: 

448 global_step = int(handle.read().strip()) 

449 except (OSError, ValueError): 

450 logger.warning( 

451 "Ignoring unreadable %s; falling back to scanning %s.", 

452 pointer_path, 

453 checkpoint_dir, 

454 ) 

455 return None 

456 

457 candidate = os.path.join(checkpoint_dir, f"{STEP_PREFIX}{global_step}") 

458 if not self._is_complete(candidate): 

459 logger.warning( 

460 "%s points at %s, which is missing or incomplete; falling back to " 

461 "scanning %s.", 

462 pointer_path, 

463 candidate, 

464 checkpoint_dir, 

465 ) 

466 return None 

467 return candidate 

468 

469 def _list_checkpoints(self, checkpoint_dir: str) -> List[tuple]: 

470 """List ``(step, path)`` for every complete checkpoint, oldest first.""" 

471 if not os.path.isdir(checkpoint_dir): 

472 return [] 

473 

474 found = [] 

475 for entry in os.listdir(checkpoint_dir): 

476 entry_path = os.path.join(checkpoint_dir, entry) 

477 if not os.path.isdir(entry_path) or not entry.startswith(STEP_PREFIX): 

478 continue 

479 try: 

480 step = int(entry[len(STEP_PREFIX):]) 

481 except ValueError: 

482 continue 

483 if self._is_complete(entry_path): 

484 found.append((step, entry_path)) 

485 

486 found.sort(key=lambda item: item[0]) 

487 return found 

488 

489 def find_latest_checkpoint(self, checkpoint_dir: str) -> Optional[str]: 

490 """Find the newest complete checkpoint under ``checkpoint_dir``. 

491 

492 The pointer file written after each save answers this in one read. It is 

493 only a cache, though --- checkpoints produced before it existed, or 

494 copied in by hand, are still found by scanning the directory. A 

495 checkpoint interrupted mid-save has no DCP metadata and is skipped by 

496 both paths, so resume never lands on a half-written one. 

497 """ 

498 from_pointer = self._read_latest_pointer(checkpoint_dir) 

499 if from_pointer is not None: 

500 return from_pointer 

501 

502 candidates = self._list_checkpoints(checkpoint_dir) 

503 if not candidates: 

504 return None 

505 return candidates[-1][1] 

506 

507 # ------------------------------------------------------------------ 

508 # Async coordination 

509 # ------------------------------------------------------------------ 

510 

511 def maybe_wait_for_async_save(self) -> None: 

512 """Block until an in-flight async save finishes. 

513 

514 Safe to call when nothing is pending. This is the single entry point for 

515 async-save coordination --- it runs before every new save, before a 

516 restore, and at train end, so two saves never overlap and the process 

517 never exits with a half-persisted checkpoint. Publishing the checkpoint 

518 is not done here: the persist callback has already written the pointer by 

519 the time the future resolves, and waiting for this call to come around 

520 would leave the checkpoint unnamed until the next save. 

521 """ 

522 if self._async_save_response is None: 

523 return 

524 

525 rank = _get_rank() 

526 try: 

527 logger.info("[rank %s] waiting for async DCP save to finish...", rank) 

528 self._async_save_response.persist_completion.result() 

529 logger.info("[rank %s] async DCP save has been finished...", rank) 

530 except Exception: # pylint: disable=broad-except 

531 logger.exception("[rank %s] async DCP save failed", rank) 

532 raise 

533 finally: 

534 self._async_save_response = None 

535 

536 def raise_for_failed_async_save(self) -> None: 

537 """Re-raise an in-flight async save's failure as soon as it is known. 

538 

539 Non-blocking: a save still running is left alone, so a training loop can 

540 afford to ask every step. That is the point --- the thread that resolves 

541 the future cannot propagate anything into the training loop, so a failure 

542 otherwise waits for the next :meth:`maybe_wait_for_async_save`, which is 

543 the next save, and a long ``save_steps`` interval turns one failed save 

544 into thousands of wasted steps. 

545 """ 

546 response = self._async_save_response 

547 if response is None or not response.persist_completion.done(): 

548 return 

549 

550 # Dropped before the raise, so one failed save is reported once rather 

551 # than at every step that follows it. The drain has nothing left to wait 

552 # for either way. 

553 self._async_save_response = None 

554 response.persist_completion.result() 

555 

556 def _publish_latest(self, save_dir: str, step: int) -> None: 

557 """Point the latest-checkpoint file at a checkpoint that is fully on disk. 

558 

559 Deliberately collective-free: the async path calls this from its persist 

560 callback, which runs on a background thread while the training loop is 

561 still issuing its own collectives, and a ``dist`` call from there would 

562 race them. Rank 0 writing on its own is enough, because the persisting 

563 child processes coordinate among themselves and rank 0 hosts their 

564 coordinator --- its success already implies every rank's shards and the 

565 shared ``.metadata`` have landed. 

566 """ 

567 if _get_rank() == 0: 

568 self._write_latest_pointer(os.path.dirname(save_dir), step) 

569 

570 logger.info( 

571 "Distributed checkpoint saved successfully: global_step=%s, dir=%s", 

572 step, 

573 save_dir, 

574 ) 

575 

576 def _finalize_checkpoint(self, save_dir: str, step: int) -> None: 

577 """Publish a synchronously written checkpoint as the newest one. 

578 

579 The leading barrier holds rank 0 until every rank has finished writing, 

580 so the pointer is never published over a checkpoint that is still being 

581 assembled; the trailing one keeps the ranks in step afterwards. The async 

582 path cannot borrow this and publishes from its persist callback instead. 

583 """ 

584 _barrier() 

585 self._publish_latest(save_dir, step) 

586 

587 # ------------------------------------------------------------------ 

588 # Save 

589 # ------------------------------------------------------------------ 

590 

591 def save( 

592 self, 

593 path: str, 

594 state: Dict[str, Any], 

595 *, 

596 global_step: int, 

597 save_async: bool = False, 

598 ) -> None: 

599 """Write ``state`` into ``path``; see :meth:`CheckpointerBase.save`.""" 

600 # Never let two saves overlap: they would compete for host memory and, 

601 # in async mode, race on the checkpoint directory. 

602 self.maybe_wait_for_async_save() 

603 

604 payload = dict(state) 

605 extra_state = payload.pop("extra_state", None) 

606 

607 if extra_state is not None and self.extra_state_per_rank: 

608 os.makedirs(os.path.join(path, _EXTRA_STATE_DIR), exist_ok=True) 

609 _barrier() 

610 # Written before the shards so that every persisted model/optimizer 

611 # payload is already backed by the progress state to resume from it. 

612 torch.save(extra_state, self._extra_state_path(path, _get_rank())) 

613 else: 

614 os.makedirs(path, exist_ok=True) 

615 _barrier() 

616 if extra_state is not None: 

617 payload["extra_state"] = extra_state 

618 

619 # Free residual training-step allocations before DCP allocates its 

620 # collective buffers for the gather pass. 

621 _empty_cache() 

622 

623 if save_async: 

624 # Publishing from the persist callback, rather than from the next 

625 # ``maybe_wait_for_async_save``, is what keeps the pointer honest: 

626 # a run that dies after an async save has landed but before the 

627 # next save would otherwise leave a complete checkpoint on disk 

628 # that the pointer never names. 

629 self._async_save_response = dcp_async_save( 

630 payload, 

631 checkpoint_id=path, 

632 callback=partial(self._publish_latest, path, global_step), 

633 ) 

634 logger.info("Async DCP save dispatched: %s", path) 

635 else: 

636 dcp_save(payload, checkpoint_id=path) 

637 logger.info("Sync DCP save completed: %s", path) 

638 

639 _empty_cache() 

640 

641 if not save_async: 

642 self._finalize_checkpoint(path, global_step) 

643 

644 # ------------------------------------------------------------------ 

645 # Load 

646 # ------------------------------------------------------------------ 

647 

648 def load( 

649 self, 

650 path: str, 

651 state: Dict[str, Any], 

652 *, 

653 strict_model: bool = True, 

654 extra_state_skeleton: Optional[Dict[str, Any]] = None, 

655 ) -> Dict[str, Any]: 

656 """Fill ``state`` from ``path``; see :meth:`CheckpointerBase.load`.""" 

657 self.maybe_wait_for_async_save() 

658 

659 # Auto-detect the layout the checkpoint was written with, so the 

660 # configured ``extra_state_per_rank`` may differ from the producing run. 

661 extra_state_file = self._extra_state_path(path, _get_rank()) 

662 from_file = os.path.isfile(extra_state_file) 

663 

664 if extra_state_skeleton is not None and not from_file: 

665 skeleton = dict(extra_state_skeleton) 

666 skeleton[_STEP_KEY] = _UNRESTORED_STEP 

667 state["extra_state"] = skeleton 

668 

669 try: 

670 dcp_load( 

671 state, 

672 checkpoint_id=path, 

673 planner=_ModelStrictLoadPlanner(strict_model=strict_model), 

674 ) 

675 except Exception: 

676 logger.exception("Failed to load checkpoint from %s", path) 

677 raise 

678 

679 if extra_state_skeleton is not None: 

680 state["extra_state"] = self._read_extra_state( 

681 path, state, extra_state_file, from_file 

682 ) 

683 

684 # Hold every rank here until all of them have finished reading, so no 

685 # rank starts training against a checkpoint others are still loading. 

686 _barrier() 

687 return state 

688 

689 def _read_extra_state( 

690 self, 

691 path: str, 

692 state: Dict[str, Any], 

693 extra_state_file: str, 

694 from_file: bool, 

695 ) -> Dict[str, Any]: 

696 """Return the extra_state bundle from wherever the checkpoint kept it.""" 

697 if from_file: 

698 logger.info("Reading extra_state from %s", extra_state_file) 

699 return torch.load(extra_state_file, weights_only=False) 

700 

701 extra = state.get("extra_state") or {} 

702 if extra.get(_STEP_KEY, _UNRESTORED_STEP) == _UNRESTORED_STEP: 

703 raise FileNotFoundError( 

704 f"Checkpoint {path} carries no training state for rank " 

705 f"{_get_rank()}: neither a per-rank file ({extra_state_file}) nor " 

706 "an extra_state entry inside the DCP. Resume needs the progress " 

707 "state saved beside the model shards; set " 

708 "checkpoint.restore_train_state=false to load the weights only." 

709 ) 

710 logger.info("Read extra_state embedded in the DCP state dict") 

711 return extra