Skip to content

Commit 2a055a3

Browse files
committed
Support remote storage (fsspec URLs) for FSDP checkpoints
1 parent fe6b1cc commit 2a055a3

11 files changed

Lines changed: 402 additions & 60 deletions

File tree

docs/source-fabric/guide/checkpoint/distributed_checkpoint.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ The distributed checkpoint format is the default when you train with the :doc:`F
4848
With ``state_dict_type="sharded"``, each process/GPU will save its own file into a folder at the given path.
4949
This reduces memory peaks and speeds up the saving to disk.
5050

51+
.. note::
52+
53+
The path can also be a remote filesystem URL supported by `fsspec <https://filesystem-spec.readthedocs.io/>`_, such as ``s3://my-bucket/checkpoint``, ``gs://my-bucket/checkpoint``, or ``abfs://my-container/checkpoint``.
54+
This requires the corresponding fsspec implementation to be installed (e.g., ``s3fs``, ``gcsfs``, or ``adlfs``).
55+
5156
.. collapse:: Full example
5257

5358
.. code-block:: python
@@ -140,6 +145,8 @@ You can easily load a distributed checkpoint in Fabric if your script uses :doc:
140145
141146
Note that you can load the distributed checkpoint even if the world size has changed, i.e., you are running on a different number of GPUs than when you saved the checkpoint.
142147

148+
As with saving, the path can point to a remote filesystem URL supported by fsspec (e.g., ``s3://``, ``gs://``, ``abfs://``), and the checkpoint will be read directly from remote storage without needing to download it first.
149+
143150
.. collapse:: Full example
144151

145152
.. code-block:: python

docs/source-pytorch/advanced/model_parallel/fsdp.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,11 @@ The resulting checkpoint folder will have this structure:
400400
401401
The “sharded” checkpoint format is the most efficient to save and load in Lightning.
402402

403+
.. note::
404+
405+
The checkpoint path can also be a remote filesystem URL supported by `fsspec <https://filesystem-spec.readthedocs.io/>`_, such as ``s3://my-bucket/checkpoint``, ``gs://my-bucket/checkpoint``, or ``abfs://my-container/checkpoint``.
406+
This requires the corresponding fsspec implementation to be installed (e.g., ``s3fs``, ``gcsfs``, or ``adlfs``).
407+
403408
**Which checkpoint format should I use?**
404409

405410
- ``state_dict_type="sharded"``: Use for pre-training very large models. It is fast and uses less memory, but it is less portable. An extra step is needed to :doc:`convert the sharded checkpoint into a regular checkpoint file <../../common/checkpointing_expert>`.
@@ -427,6 +432,8 @@ The Trainer will automatically recognize whether the provided path contains a ch
427432
Checkpoints saved with ``state_dict_type="full"`` can be loaded by all strategies, but sharded checkpoints can only be loaded by FSDP.
428433
Read :ref:`the checkpoints guide <checkpointing>` to explore more features.
429434

435+
As with saving, the checkpoint path can point to a remote filesystem URL supported by fsspec (e.g., ``s3://``, ``gs://``, ``abfs://``), and the checkpoint will be read directly from remote storage without needing to download it first.
436+
430437

431438
----
432439

src/lightning/fabric/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1010

1111
- Added `using_sparse_model` and `sparse_cuda_acceleration_factor` parameters to `Throughput` so MFU defaults to the dense peak and opts into the sparse peak explicitly ([#21743](https://github.com/Lightning-AI/pytorch-lightning/pull/21743))
1212

13+
- Added support for remote storage (fsspec URLs) when saving and loading distributed checkpoints with `FSDPStrategy` ([#21775](https://github.com/Lightning-AI/pytorch-lightning/pull/21775))
14+
1315
### Changed
1416

1517
-

src/lightning/fabric/strategies/fsdp.py

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
import shutil
1514
import warnings
1615
from collections.abc import Generator
1716
from contextlib import AbstractContextManager, ExitStack, nullcontext
@@ -49,6 +48,19 @@
4948
_Sharded,
5049
_validate_keys_for_strict_loading,
5150
)
51+
from lightning.fabric.utilities.cloud_io import (
52+
_atomic_save,
53+
_checkpoint_join,
54+
_get_distributed_checkpoint_reader,
55+
_get_distributed_checkpoint_writer,
56+
_is_checkpoint_dir,
57+
_is_local_file_protocol,
58+
_load,
59+
_prepare_directory_checkpoint,
60+
_remove_checkpoint,
61+
_resolve_path,
62+
get_filesystem,
63+
)
5264
from lightning.fabric.utilities.distributed import (
5365
ReduceOp,
5466
_distributed_is_initialized,
@@ -439,8 +451,8 @@ def save_checkpoint(
439451
)
440452

441453
# broadcast the path from rank 0 to ensure all the states are saved in a common path
442-
path = Path(self.broadcast(path))
443-
if path.is_dir() and self._state_dict_type == "full" and not _is_sharded_checkpoint(path):
454+
path = _resolve_path(self.broadcast(path))
455+
if self._state_dict_type == "full" and _is_checkpoint_dir(path) and not _is_sharded_checkpoint(path):
444456
raise IsADirectoryError(f"The checkpoint path exists and is a directory: {path}")
445457

446458
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
@@ -461,9 +473,7 @@ def save_checkpoint(
461473
module = modules[0]
462474

463475
if self._state_dict_type == "sharded":
464-
if path.is_file():
465-
path.unlink()
466-
path.mkdir(parents=True, exist_ok=True)
476+
_prepare_directory_checkpoint(path)
467477

468478
state_dict_ctx = _get_sharded_state_dict_context(module)
469479

@@ -488,11 +498,11 @@ def save_checkpoint(
488498
_distributed_checkpoint_save(converted_state, path)
489499

490500
if self.global_rank == 0:
491-
torch.save(metadata, path / _METADATA_FILENAME)
501+
_atomic_save(metadata, _checkpoint_join(path, _METADATA_FILENAME))
492502

493503
elif self._state_dict_type == "full":
494504
if _is_sharded_checkpoint(path):
495-
shutil.rmtree(path)
505+
_remove_checkpoint(path)
496506

497507
state_dict_ctx = _get_full_state_dict_context(module, world_size=self.world_size)
498508
full_state: dict[str, Any] = {}
@@ -507,7 +517,7 @@ def save_checkpoint(
507517
_apply_filter(key, filter or {}, converted, full_state)
508518

509519
if self.global_rank == 0:
510-
torch.save(full_state, path)
520+
_atomic_save(full_state, path)
511521
else:
512522
raise ValueError(f"Unknown state_dict_type: {self._state_dict_type}")
513523

@@ -527,7 +537,7 @@ def load_checkpoint(
527537
" FSDPStrategy.load_checkpoint(..., state={'model': model, ...})"
528538
)
529539
# broadcast the path from rank 0 to ensure all the states are loaded from a common path
530-
path = Path(self.broadcast(path))
540+
path = _resolve_path(self.broadcast(path))
531541

532542
if isinstance(state, Module):
533543
from lightning.fabric.strategies.model_parallel import _load_raw_module_state_from_path
@@ -568,11 +578,9 @@ def load_checkpoint(
568578
module.load_state_dict(module_state[module_key], strict=strict)
569579

570580
if optimizers:
571-
from torch.distributed.checkpoint import FileSystemReader
572-
573581
# TODO: replace with newer APIs
574582
# https://github.com/pytorch/pytorch/issues/119800#issuecomment-1942156271
575-
reader = FileSystemReader(path=path)
583+
reader = _get_distributed_checkpoint_reader(path)
576584
# the optimizer states must be loaded separately
577585
for optim_key, optim in optimizers.items():
578586
optim_state = load_sharded_optimizer_state_dict(
@@ -588,7 +596,7 @@ def load_checkpoint(
588596
optim.load_state_dict(flattened_osd)
589597

590598
# Load metadata (anything not a module or optimizer)
591-
metadata = torch.load(path / _METADATA_FILENAME, weights_only=weights_only)
599+
metadata = _load(_checkpoint_join(path, _METADATA_FILENAME), weights_only=weights_only)
592600
requested_metadata_keys = state.keys() - modules.keys() - optimizers.keys()
593601
_validate_keys_for_strict_loading(requested_metadata_keys, metadata.keys(), strict=strict)
594602
for key in requested_metadata_keys:
@@ -600,7 +608,7 @@ def load_checkpoint(
600608
return metadata
601609

602610
if _is_full_checkpoint(path):
603-
checkpoint = _lazy_load(path)
611+
checkpoint = _lazy_load(path) if _is_local_file_protocol(str(path)) else _load(path, weights_only=False)
604612

605613
from lightning.fabric.strategies.model_parallel import (
606614
_load_raw_module_state,
@@ -901,13 +909,19 @@ def _get_full_state_dict_context(
901909
return state_dict_type_context # type: ignore[return-value]
902910

903911

904-
def _is_sharded_checkpoint(path: Path) -> bool:
912+
def _is_sharded_checkpoint(path: _PATH) -> bool:
905913
"""A heuristic check to determine whether the path points to a directory with checkpoint shards."""
906-
return path.is_dir() and (path / _METADATA_FILENAME).is_file()
914+
if _is_local_file_protocol(str(path)):
915+
path = Path(path)
916+
return path.is_dir() and (path / _METADATA_FILENAME).is_file()
917+
fs = get_filesystem(path)
918+
return fs.isfile(str(path).rstrip("/") + "/" + _METADATA_FILENAME)
907919

908920

909-
def _is_full_checkpoint(path: Path) -> bool:
910-
return path.is_file()
921+
def _is_full_checkpoint(path: _PATH) -> bool:
922+
if _is_local_file_protocol(str(path)):
923+
return Path(path).is_file()
924+
return get_filesystem(path).isfile(str(path))
911925

912926

913927
def _has_fsdp_modules(module: object) -> TypeGuard[Module]:
@@ -928,38 +942,29 @@ def _move_torchmetrics_to_device(module: torch.nn.Module, device: torch.device)
928942
metric.to(device) # `.to()` is in-place
929943

930944

931-
def _distributed_checkpoint_save(converted_state: dict[str, Any], path: Path) -> None:
945+
def _distributed_checkpoint_save(converted_state: dict[str, Any], path: _PATH) -> None:
932946
if _TORCH_GREATER_EQUAL_2_3:
933947
from torch.distributed.checkpoint import save
934948

935-
# let torch automatically infer the writer to use. This might also support fsspec paths in the future
936-
# https://github.com/pytorch/pytorch/issues/118036
937949
save(converted_state, checkpoint_id=path)
938950
else: # deprecated
939-
from torch.distributed.checkpoint import FileSystemWriter
940-
941951
if _TORCH_GREATER_EQUAL_2_2:
942952
from torch.distributed.checkpoint import save
943953
else:
944954
from torch.distributed.checkpoint import save_state_dict as save
945-
# FSDP's FileSystemWriter streams the tensors to disk to minimize memory peaks
946-
writer = FileSystemWriter(path=path, single_file_per_rank=True)
955+
writer = _get_distributed_checkpoint_writer(path)
947956
save(converted_state, writer)
948957

949958

950-
def _distributed_checkpoint_load(module_state: dict[str, Any], path: Path) -> None:
959+
def _distributed_checkpoint_load(module_state: dict[str, Any], path: _PATH) -> None:
951960
if _TORCH_GREATER_EQUAL_2_3:
952961
from torch.distributed.checkpoint import load
953962

954-
# let torch automatically infer the reader to use. This might also support fsspec paths in the future
955-
# https://github.com/pytorch/pytorch/issues/118036
956963
load(module_state, checkpoint_id=path)
957964
else: # deprecated
958-
from torch.distributed.checkpoint import FileSystemReader
959-
960965
if _TORCH_GREATER_EQUAL_2_2:
961966
from torch.distributed.checkpoint import load
962967
else:
963968
from torch.distributed.checkpoint import load_state_dict as load
964-
reader = FileSystemReader(path=path)
969+
reader = _get_distributed_checkpoint_reader(path)
965970
load(module_state, reader)

src/lightning/fabric/strategies/model_parallel.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
_BackwardSyncControl,
4444
_validate_keys_for_strict_loading,
4545
)
46+
from lightning.fabric.utilities.cloud_io import _is_local_file_protocol, _load
4647
from lightning.fabric.utilities.distributed import (
4748
ReduceOp,
4849
_distributed_is_initialized,
@@ -529,15 +530,18 @@ def _has_dtensor_modules(module: object) -> TypeGuard[Module]:
529530
return isinstance(module, Module) and any(isinstance(t, DTensor) for t in module.parameters())
530531

531532

532-
def _load_raw_module_state_from_path(path: Path, module: Module, world_size: int, strict: bool = True) -> None:
533+
def _load_raw_module_state_from_path(path: _PATH, module: Module, world_size: int, strict: bool = True) -> None:
533534
"""Loads the state dict from a file path into the FSDP module."""
534535
if not _is_full_checkpoint(path):
535536
raise ValueError(
536537
"Failed to load checkpoint directly into the model. The given path must be a single file containing the"
537538
f" full state dict: {path}"
538539
)
539-
# Use `lazy_load`/`mmap` instead to avoid storing a copy of the full checkpoint per rank
540-
state_dict = torch.load(path, mmap=True, map_location="cpu") if _TORCH_GREATER_EQUAL_2_3 else _lazy_load(path)
540+
if _is_local_file_protocol(str(path)):
541+
# Use `lazy_load`/`mmap` instead to avoid storing a copy of the full checkpoint per rank
542+
state_dict = torch.load(path, mmap=True, map_location="cpu") if _TORCH_GREATER_EQUAL_2_3 else _lazy_load(path)
543+
else:
544+
state_dict = _load(path, map_location="cpu")
541545
_load_raw_module_state(state_dict=state_dict, module=module, world_size=world_size, strict=strict)
542546

543547

src/lightning/fabric/utilities/cloud_io.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
"""Utilities related to data saving/loading."""
1515

1616
import errno
17+
import importlib
1718
import io
1819
import logging
20+
import shutil
1921
from pathlib import Path
2022
from typing import IO, Any, Optional, Union
2123

@@ -176,3 +178,98 @@ def _is_dir(fs: AbstractFileSystem, path: Union[str, Path], strict: bool = False
176178

177179
def _is_local_file_protocol(path: _PATH) -> bool:
178180
return fsspec.utils.get_protocol(str(path)) == "file"
181+
182+
183+
def _resolve_path(path: _PATH) -> Union[str, Path]:
184+
"""Return a ``Path`` for local file paths and a plain ``str`` for remote fsspec URLs.
185+
186+
``Path()`` collapses the double slash in a URL (e.g. ``gs://bucket`` -> ``gs:/bucket``),
187+
corrupting it, so remote URLs must be kept as strings.
188+
189+
"""
190+
if _is_local_file_protocol(str(path)):
191+
return Path(path)
192+
return str(path)
193+
194+
195+
def _checkpoint_join(path: Union[str, Path], name: str) -> Union[str, Path]:
196+
"""Join ``name`` onto a checkpoint ``path`` without corrupting remote URLs."""
197+
if isinstance(path, Path):
198+
return path / name
199+
return str(path).rstrip("/") + "/" + name
200+
201+
202+
def _is_checkpoint_dir(path: Union[str, Path]) -> bool:
203+
"""Return whether ``path`` points to an existing directory, supporting fsspec paths."""
204+
if isinstance(path, Path):
205+
return path.is_dir()
206+
return get_filesystem(path).isdir(str(path))
207+
208+
209+
def _prepare_directory_checkpoint(path: Union[str, Path]) -> None:
210+
"""Ensure ``path`` is a directory for a sharded checkpoint.
211+
212+
Removes a conflicting file sitting at ``path`` and creates the directory. Creating a
213+
directory is a no-op on object storage, which has no real directories.
214+
215+
"""
216+
if isinstance(path, Path):
217+
if path.is_file():
218+
path.unlink()
219+
path.mkdir(parents=True, exist_ok=True)
220+
return
221+
fs = get_filesystem(path)
222+
if fs.isfile(str(path)):
223+
fs.rm(str(path))
224+
if not _is_object_storage(fs):
225+
fs.makedirs(str(path), exist_ok=True)
226+
227+
228+
def _remove_checkpoint(path: Union[str, Path]) -> None:
229+
"""Remove a checkpoint file or directory (recursively), supporting fsspec paths."""
230+
if isinstance(path, Path):
231+
if path.is_dir():
232+
shutil.rmtree(path)
233+
elif path.exists():
234+
path.unlink()
235+
return
236+
fs = get_filesystem(path)
237+
if fs.exists(str(path)):
238+
fs.rm(str(path), recursive=True)
239+
240+
241+
def _get_distributed_checkpoint_writer(path: _PATH) -> Any:
242+
if _is_local_file_protocol(str(path)):
243+
from torch.distributed.checkpoint import FileSystemWriter
244+
245+
# FSDP's FileSystemWriter streams the tensors to disk to minimize memory peaks
246+
return FileSystemWriter(path=path, single_file_per_rank=True)
247+
FsspecWriter = _import_fsspec_dcp_filesystem("FsspecWriter")
248+
return FsspecWriter(path=str(path), single_file_per_rank=True)
249+
250+
251+
def _get_distributed_checkpoint_reader(path: _PATH) -> Any:
252+
if _is_local_file_protocol(str(path)):
253+
from torch.distributed.checkpoint import FileSystemReader
254+
255+
return FileSystemReader(path=path)
256+
FsspecReader = _import_fsspec_dcp_filesystem("FsspecReader")
257+
return FsspecReader(path=str(path))
258+
259+
260+
def _import_fsspec_dcp_filesystem(name: str) -> Any:
261+
"""Import ``FsspecReader``/``FsspecWriter`` from torch's private DCP fsspec module.
262+
263+
These live in a private module that not every PyTorch build ships, so raise an actionable error
264+
instead of letting a bare ``ImportError`` surface from deep in the call stack.
265+
266+
"""
267+
try:
268+
module = importlib.import_module("torch.distributed.checkpoint._fsspec_filesystem")
269+
except ImportError as e:
270+
raise ImportError(
271+
"Remote (fsspec) distributed checkpoints require"
272+
" `torch.distributed.checkpoint._fsspec_filesystem`, which is not available in this"
273+
" PyTorch build. Use a local checkpoint path or upgrade PyTorch."
274+
) from e
275+
return getattr(module, name)

src/lightning/pytorch/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1414

1515
- Added `log_key_prefix` parameter to `LearningRateMonitor` callback for prefixing logged metric names ([#21612](https://github.com/Lightning-AI/pytorch-lightning/issues/21612))
1616

17+
- Added support for remote storage (fsspec URLs) when saving and loading distributed checkpoints with `FSDPStrategy` ([#21775](https://github.com/Lightning-AI/pytorch-lightning/pull/21775))
18+
1719
### Changed
1820

1921
-

0 commit comments

Comments
 (0)