Skip to content

Commit 2ff9d9f

Browse files
committed
Support remote storage (fsspec URLs) for FSDP checkpoints
TAG=agy CONV=e8cb1344-4be9-41e9-b673-24718e930cbc
1 parent fe6b1cc commit 2ff9d9f

7 files changed

Lines changed: 378 additions & 59 deletions

File tree

src/lightning/fabric/strategies/fsdp.py

Lines changed: 61 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,17 @@
4948
_Sharded,
5049
_validate_keys_for_strict_loading,
5150
)
51+
from lightning.fabric.utilities.cloud_io import (
52+
_atomic_save,
53+
_checkpoint_join,
54+
_is_checkpoint_dir,
55+
_is_local_file_protocol,
56+
_load,
57+
_prepare_directory_checkpoint,
58+
_remove_checkpoint,
59+
_resolve_path,
60+
get_filesystem,
61+
)
5262
from lightning.fabric.utilities.distributed import (
5363
ReduceOp,
5464
_distributed_is_initialized,
@@ -439,8 +449,9 @@ def save_checkpoint(
439449
)
440450

441451
# 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):
452+
# remote URLs are kept as strings; only local paths are wrapped in `Path`
453+
path = _resolve_path(self.broadcast(path))
454+
if self._state_dict_type == "full" and _is_checkpoint_dir(path) and not _is_sharded_checkpoint(path):
444455
raise IsADirectoryError(f"The checkpoint path exists and is a directory: {path}")
445456

446457
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
@@ -461,9 +472,7 @@ def save_checkpoint(
461472
module = modules[0]
462473

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

468477
state_dict_ctx = _get_sharded_state_dict_context(module)
469478

@@ -488,11 +497,11 @@ def save_checkpoint(
488497
_distributed_checkpoint_save(converted_state, path)
489498

490499
if self.global_rank == 0:
491-
torch.save(metadata, path / _METADATA_FILENAME)
500+
_atomic_save(metadata, _checkpoint_join(path, _METADATA_FILENAME))
492501

493502
elif self._state_dict_type == "full":
494503
if _is_sharded_checkpoint(path):
495-
shutil.rmtree(path)
504+
_remove_checkpoint(path)
496505

497506
state_dict_ctx = _get_full_state_dict_context(module, world_size=self.world_size)
498507
full_state: dict[str, Any] = {}
@@ -507,7 +516,7 @@ def save_checkpoint(
507516
_apply_filter(key, filter or {}, converted, full_state)
508517

509518
if self.global_rank == 0:
510-
torch.save(full_state, path)
519+
_atomic_save(full_state, path)
511520
else:
512521
raise ValueError(f"Unknown state_dict_type: {self._state_dict_type}")
513522

@@ -527,7 +536,7 @@ def load_checkpoint(
527536
" FSDPStrategy.load_checkpoint(..., state={'model': model, ...})"
528537
)
529538
# broadcast the path from rank 0 to ensure all the states are loaded from a common path
530-
path = Path(self.broadcast(path))
539+
path = _resolve_path(self.broadcast(path))
531540

532541
if isinstance(state, Module):
533542
from lightning.fabric.strategies.model_parallel import _load_raw_module_state_from_path
@@ -568,11 +577,9 @@ def load_checkpoint(
568577
module.load_state_dict(module_state[module_key], strict=strict)
569578

570579
if optimizers:
571-
from torch.distributed.checkpoint import FileSystemReader
572-
573580
# TODO: replace with newer APIs
574581
# https://github.com/pytorch/pytorch/issues/119800#issuecomment-1942156271
575-
reader = FileSystemReader(path=path)
582+
reader = _get_distributed_checkpoint_reader(path)
576583
# the optimizer states must be loaded separately
577584
for optim_key, optim in optimizers.items():
578585
optim_state = load_sharded_optimizer_state_dict(
@@ -588,7 +595,7 @@ def load_checkpoint(
588595
optim.load_state_dict(flattened_osd)
589596

590597
# Load metadata (anything not a module or optimizer)
591-
metadata = torch.load(path / _METADATA_FILENAME, weights_only=weights_only)
598+
metadata = _load(_checkpoint_join(path, _METADATA_FILENAME), weights_only=weights_only)
592599
requested_metadata_keys = state.keys() - modules.keys() - optimizers.keys()
593600
_validate_keys_for_strict_loading(requested_metadata_keys, metadata.keys(), strict=strict)
594601
for key in requested_metadata_keys:
@@ -600,7 +607,7 @@ def load_checkpoint(
600607
return metadata
601608

602609
if _is_full_checkpoint(path):
603-
checkpoint = _lazy_load(path)
610+
checkpoint = _lazy_load(path) if _is_local_file_protocol(str(path)) else _load(path)
604611

605612
from lightning.fabric.strategies.model_parallel import (
606613
_load_raw_module_state,
@@ -901,13 +908,19 @@ def _get_full_state_dict_context(
901908
return state_dict_type_context # type: ignore[return-value]
902909

903910

904-
def _is_sharded_checkpoint(path: Path) -> bool:
911+
def _is_sharded_checkpoint(path: _PATH) -> bool:
905912
"""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()
913+
if _is_local_file_protocol(str(path)):
914+
path = Path(path)
915+
return path.is_dir() and (path / _METADATA_FILENAME).is_file()
916+
fs = get_filesystem(path)
917+
return fs.isfile(str(path).rstrip("/") + "/" + _METADATA_FILENAME)
907918

908919

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

912925

913926
def _has_fsdp_modules(module: object) -> TypeGuard[Module]:
@@ -928,38 +941,54 @@ def _move_torchmetrics_to_device(module: torch.nn.Module, device: torch.device)
928941
metric.to(device) # `.to()` is in-place
929942

930943

931-
def _distributed_checkpoint_save(converted_state: dict[str, Any], path: Path) -> None:
944+
def _get_distributed_checkpoint_writer(path: _PATH) -> Any:
945+
if _is_local_file_protocol(str(path)):
946+
from torch.distributed.checkpoint import FileSystemWriter
947+
948+
# FSDP's FileSystemWriter streams the tensors to disk to minimize memory peaks
949+
return FileSystemWriter(path=path, single_file_per_rank=True)
950+
from torch.distributed.checkpoint._fsspec_filesystem import FsspecWriter
951+
952+
return FsspecWriter(path=str(path), single_file_per_rank=True)
953+
954+
955+
def _get_distributed_checkpoint_reader(path: _PATH) -> Any:
956+
if _is_local_file_protocol(str(path)):
957+
from torch.distributed.checkpoint import FileSystemReader
958+
959+
return FileSystemReader(path=path)
960+
from torch.distributed.checkpoint._fsspec_filesystem import FsspecReader
961+
962+
return FsspecReader(path=str(path))
963+
964+
965+
def _distributed_checkpoint_save(converted_state: dict[str, Any], path: _PATH) -> None:
932966
if _TORCH_GREATER_EQUAL_2_3:
933967
from torch.distributed.checkpoint import save
934968

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
969+
# torch automatically infers the writer to use from the checkpoint_id, including
970+
# FsspecWriter for remote (fsspec) URLs. https://github.com/pytorch/pytorch/issues/118036
937971
save(converted_state, checkpoint_id=path)
938972
else: # deprecated
939-
from torch.distributed.checkpoint import FileSystemWriter
940-
941973
if _TORCH_GREATER_EQUAL_2_2:
942974
from torch.distributed.checkpoint import save
943975
else:
944976
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)
977+
writer = _get_distributed_checkpoint_writer(path)
947978
save(converted_state, writer)
948979

949980

950-
def _distributed_checkpoint_load(module_state: dict[str, Any], path: Path) -> None:
981+
def _distributed_checkpoint_load(module_state: dict[str, Any], path: _PATH) -> None:
951982
if _TORCH_GREATER_EQUAL_2_3:
952983
from torch.distributed.checkpoint import load
953984

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
985+
# torch automatically infers the reader to use from the checkpoint_id, including
986+
# FsspecReader for remote (fsspec) URLs. https://github.com/pytorch/pytorch/issues/118036
956987
load(module_state, checkpoint_id=path)
957988
else: # deprecated
958-
from torch.distributed.checkpoint import FileSystemReader
959-
960989
if _TORCH_GREATER_EQUAL_2_2:
961990
from torch.distributed.checkpoint import load
962991
else:
963992
from torch.distributed.checkpoint import load_state_dict as load
964-
reader = FileSystemReader(path=path)
993+
reader = _get_distributed_checkpoint_reader(path)
965994
load(module_state, reader)

src/lightning/fabric/strategies/model_parallel.py

Lines changed: 8 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,19 @@ 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+
# mmap cannot be used over object storage; read the object through fsspec
545+
state_dict = _load(path, map_location="cpu")
541546
_load_raw_module_state(state_dict=state_dict, module=module, world_size=world_size, strict=strict)
542547

543548

src/lightning/fabric/utilities/cloud_io.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import errno
1717
import io
1818
import logging
19+
import shutil
1920
from pathlib import Path
2021
from typing import IO, Any, Optional, Union
2122

@@ -176,3 +177,61 @@ def _is_dir(fs: AbstractFileSystem, path: Union[str, Path], strict: bool = False
176177

177178
def _is_local_file_protocol(path: _PATH) -> bool:
178179
return fsspec.utils.get_protocol(str(path)) == "file"
180+
181+
182+
def _resolve_path(path: _PATH) -> Union[str, Path]:
183+
"""Return a ``Path`` for local file paths and a plain ``str`` for remote fsspec URLs.
184+
185+
``Path()`` collapses the double slash in a URL (e.g. ``gs://bucket`` -> ``gs:/bucket``),
186+
corrupting it, so remote URLs must be kept as strings.
187+
188+
"""
189+
if _is_local_file_protocol(str(path)):
190+
return Path(path)
191+
return str(path)
192+
193+
194+
def _checkpoint_join(path: Union[str, Path], name: str) -> Union[str, Path]:
195+
"""Join ``name`` onto a checkpoint ``path`` without corrupting remote URLs."""
196+
if isinstance(path, Path):
197+
return path / name
198+
return str(path).rstrip("/") + "/" + name
199+
200+
201+
def _is_checkpoint_dir(path: Union[str, Path]) -> bool:
202+
"""Return whether ``path`` points to an existing directory, supporting fsspec paths."""
203+
if isinstance(path, Path):
204+
return path.is_dir()
205+
return get_filesystem(path).isdir(str(path))
206+
207+
208+
def _prepare_directory_checkpoint(path: Union[str, Path]) -> None:
209+
"""Ensure ``path`` is a directory for a sharded checkpoint.
210+
211+
Removes a conflicting file sitting at ``path`` and creates the directory. Creating a
212+
directory is a no-op on object storage, which has no real directories.
213+
214+
"""
215+
if isinstance(path, Path):
216+
if path.is_file():
217+
path.unlink()
218+
path.mkdir(parents=True, exist_ok=True)
219+
return
220+
fs = get_filesystem(path)
221+
if fs.isfile(str(path)):
222+
fs.rm(str(path))
223+
if not _is_object_storage(fs):
224+
fs.makedirs(str(path), exist_ok=True)
225+
226+
227+
def _remove_checkpoint(path: Union[str, Path]) -> None:
228+
"""Remove a checkpoint file or directory (recursively), supporting fsspec paths."""
229+
if isinstance(path, Path):
230+
if path.is_dir():
231+
shutil.rmtree(path)
232+
elif path.exists():
233+
path.unlink()
234+
return
235+
fs = get_filesystem(path)
236+
if fs.exists(str(path)):
237+
fs.rm(str(path), recursive=True)

0 commit comments

Comments
 (0)