Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from . import _extension
|
||||
from .api import CheckpointException
|
||||
from .default_planner import DefaultLoadPlanner, DefaultSavePlanner
|
||||
from .filesystem import FileSystemReader, FileSystemWriter
|
||||
from .hf_storage import HuggingFaceStorageReader, HuggingFaceStorageWriter
|
||||
from .metadata import (
|
||||
BytesStorageMetadata,
|
||||
ChunkStorageMetadata,
|
||||
Metadata,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from .optimizer import load_sharded_optimizer_state_dict
|
||||
from .planner import LoadPlan, LoadPlanner, ReadItem, SavePlan, SavePlanner, WriteItem
|
||||
from .quantized_hf_storage import QuantizedHuggingFaceStorageReader
|
||||
|
||||
# pyrefly: ignore [deprecated]
|
||||
from .state_dict_loader import load, load_state_dict
|
||||
|
||||
# pyrefly: ignore [deprecated]
|
||||
from .state_dict_saver import async_save, save, save_state_dict
|
||||
from .storage import StorageReader, StorageWriter
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# pyre-strict
|
||||
# mypy: allow-untyped-defs
|
||||
import abc
|
||||
import os
|
||||
from concurrent.futures import Future
|
||||
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
from torch.distributed.checkpoint.planner import SavePlanner
|
||||
from torch.distributed.checkpoint.storage import StorageWriter
|
||||
|
||||
|
||||
class _AsyncCheckpointExecutor(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def execute_save(
|
||||
self,
|
||||
staging_future_or_state_dict: STATE_DICT_TYPE | Future[STATE_DICT_TYPE],
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Future:
|
||||
"""
|
||||
Execute the checkpoint save request asynchronously.
|
||||
|
||||
This method is intended to be used as an abstraction for
|
||||
implementing async checkpointing. The actual checkpoint save
|
||||
operation is executed in a separate thread or process depending
|
||||
on the implementation of this interface.
|
||||
"""
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
# pyre-strict
|
||||
# mypy: allow-untyped-defs
|
||||
import gc
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
from torch.distributed import PrefixStore, TCPStore
|
||||
from torch.distributed.checkpoint._async_executor import _AsyncCheckpointExecutor
|
||||
from torch.distributed.checkpoint.logger import _dcp_method_logger, _init_logger
|
||||
from torch.distributed.checkpoint.metadata import Metadata, STATE_DICT_TYPE
|
||||
from torch.distributed.checkpoint.planner import SavePlanner
|
||||
from torch.distributed.checkpoint.storage import StorageWriter
|
||||
from torch.distributed.checkpoint.utils import _DistWrapper
|
||||
from torch.distributed.elastic.agent.server.api import _get_fq_hostname
|
||||
from torch.distributed.elastic.utils.distributed import get_free_port
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class _CheckpointSaveProcessControlOpts(Enum):
|
||||
INIT_COMPLETE = "init_complete"
|
||||
TERMINATE = "terminate"
|
||||
|
||||
|
||||
@dataclass(init=False, unsafe_hash=True)
|
||||
class _CheckpointRequestIdentifier:
|
||||
checkpoint_id: str | os.PathLike | None
|
||||
uuid: str
|
||||
|
||||
def __init__(self, checkpoint_id: str | os.PathLike | None):
|
||||
self.checkpoint_id = checkpoint_id
|
||||
self.uuid = str(uuid4())
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AsyncCheckpointRequest:
|
||||
staged_state_dict: STATE_DICT_TYPE
|
||||
checkpoint_request_id: _CheckpointRequestIdentifier
|
||||
storage_writer: StorageWriter | None = None
|
||||
planner: SavePlanner | None = None
|
||||
no_dist: bool = False
|
||||
use_collectives: bool = True
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class _ProcessGroupInitInfo:
|
||||
local_rank: int
|
||||
global_rank: int
|
||||
world_size: int
|
||||
tcp_store_master_addr: str
|
||||
tcp_store_master_port: int
|
||||
use_prefix_store: bool
|
||||
disable_automatic_gc: bool
|
||||
disable_manual_gc: bool
|
||||
|
||||
def __init__(self, process_group: dist.ProcessGroup | None = None):
|
||||
self.local_rank = dist.get_node_local_rank(fallback_rank=0)
|
||||
self.global_rank = dist.get_rank(process_group)
|
||||
self.world_size = dist.get_world_size(process_group)
|
||||
self.use_prefix_store = os.environ.get("DCP_USE_PREFIX_STORE", "0") == "1"
|
||||
self.disable_automatic_gc = (
|
||||
os.environ.get("DCP_DISABLE_AUTOMATIC_GC", "0") == "1"
|
||||
)
|
||||
self.disable_manual_gc = os.environ.get("DCP_DISABLE_MANUAL_GC", "0") == "1"
|
||||
|
||||
# Let coordinator rank find a port on the localhost.
|
||||
# Broadcast the (master_addr, port) to all ranks; each rank in the
|
||||
# checkpoint daemon process will use TCPStore (master_addr, port)
|
||||
# for collective communication.
|
||||
dist_wrapper: _DistWrapper = _DistWrapper(
|
||||
group=process_group,
|
||||
use_dist=True,
|
||||
coordinator_rank=0,
|
||||
)
|
||||
|
||||
def get_master_addr_and_port() -> tuple[str, int]:
|
||||
if self.use_prefix_store:
|
||||
master_addr = os.environ.get("MASTER_ADDR")
|
||||
master_port = os.environ.get("MASTER_PORT")
|
||||
if master_addr is None:
|
||||
raise AssertionError("DCP needs MASTER_ADDR to use prefix store")
|
||||
if master_port is None:
|
||||
raise AssertionError("DCP needs MASTER_PORT to use prefix store")
|
||||
master_port = int(master_port)
|
||||
else:
|
||||
master_addr = os.environ.get("MASTER_ADDR")
|
||||
if master_addr is None:
|
||||
master_addr = _get_fq_hostname()
|
||||
master_port = get_free_port()
|
||||
|
||||
return master_addr, master_port
|
||||
|
||||
self.tcp_store_master_addr, self.tcp_store_master_port = dist_wrapper.broadcast(
|
||||
step="get_master_addr_and_port",
|
||||
map_fun=get_master_addr_and_port,
|
||||
)
|
||||
|
||||
|
||||
class _AsyncCheckpointProcess:
|
||||
def __init__(
|
||||
self,
|
||||
pg_init_info: _ProcessGroupInitInfo,
|
||||
):
|
||||
self.ctx = mp.get_context("spawn")
|
||||
self._process_pipe, child_end = self.ctx.Pipe()
|
||||
|
||||
self._save_process = self.ctx.Process(
|
||||
target=self._checkpointing_subprocess,
|
||||
args=(
|
||||
pg_init_info,
|
||||
child_end,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
self._save_process.start()
|
||||
|
||||
# Close the parent's copy of child end after we pass it into the child,
|
||||
# so the recv()s on it will fail-fast if the child process dies.
|
||||
child_end.close()
|
||||
|
||||
# Wait for the checkpoint background process to initialize.
|
||||
# Using default GLOO init timeout.
|
||||
response = self._wait_for_response(timeout=1800)
|
||||
if not response == _CheckpointSaveProcessControlOpts.INIT_COMPLETE:
|
||||
raise AssertionError(f"Expected INIT_COMPLETE response, got {response}")
|
||||
|
||||
def __del__(self) -> None:
|
||||
if self._save_process.is_alive():
|
||||
try:
|
||||
logger.info("Terminating the checkpoint background process.")
|
||||
self._send(_CheckpointSaveProcessControlOpts.TERMINATE)
|
||||
self._save_process.join(timeout=5)
|
||||
finally:
|
||||
if self._save_process.is_alive():
|
||||
logger.warning(
|
||||
"Checkpoint background process is still alive after termination request. Sending SIGTERM."
|
||||
)
|
||||
self._save_process.terminate()
|
||||
|
||||
def _send(self, data: Any) -> None:
|
||||
self._process_pipe.send(data)
|
||||
|
||||
def _wait_for_response(self, timeout: float | None = None) -> Any:
|
||||
if not self._save_process.is_alive():
|
||||
logger.info("Checkpoint background process is dead calling join()...")
|
||||
self._save_process.join()
|
||||
raise RuntimeError(
|
||||
f"Checkpoint background process is dead. Exit code: {self._save_process.exitcode}"
|
||||
)
|
||||
|
||||
if timeout is not None and not self._process_pipe.poll(timeout=timeout):
|
||||
raise RuntimeError(
|
||||
f"Timed out after {timeout}s while waiting for response from checkpointer process pid: {self._save_process.pid}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._process_pipe.recv()
|
||||
except EOFError:
|
||||
raise RuntimeError( # noqa: B904
|
||||
f"Checkpoint background process is dead. Exit code: {self._save_process.exitcode}"
|
||||
)
|
||||
|
||||
if isinstance(response, BaseException):
|
||||
raise response
|
||||
|
||||
return response
|
||||
|
||||
def save(
|
||||
self,
|
||||
staged_state_dict: STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Metadata:
|
||||
# Create a unique identifier to locate requests/responses
|
||||
# from the checkpoint daemon process.
|
||||
checkpoint_request_id = _CheckpointRequestIdentifier(checkpoint_id)
|
||||
async_cp_request = _AsyncCheckpointRequest(
|
||||
staged_state_dict=staged_state_dict,
|
||||
checkpoint_request_id=checkpoint_request_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
self._send(async_cp_request)
|
||||
result = self._wait_for_response()
|
||||
if not isinstance(result, Metadata):
|
||||
raise AssertionError(f"Expected Metadata response, got {type(result)}")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _execute_save(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_request_id: _CheckpointRequestIdentifier,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Metadata:
|
||||
from torch.distributed.checkpoint.state_dict_saver import save
|
||||
|
||||
metadata = save(
|
||||
state_dict,
|
||||
checkpoint_id=checkpoint_request_id.checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _checkpointing_subprocess(
|
||||
pg_init_info: _ProcessGroupInitInfo,
|
||||
parent_conn,
|
||||
) -> None:
|
||||
# Phase 1: Process Group Initialization
|
||||
# Only needs to execute once during the lifetime of the checkpoint background process.
|
||||
try:
|
||||
_init_logger(pg_init_info.global_rank)
|
||||
|
||||
# Setup environment variables for process group initialization.
|
||||
os.environ["TORCHELASTIC_USE_AGENT_STORE"] = "False"
|
||||
os.environ["MASTER_ADDR"] = pg_init_info.tcp_store_master_addr
|
||||
os.environ["MASTER_PORT"] = str(pg_init_info.tcp_store_master_port)
|
||||
os.environ["LOCAL_RANK"] = str(pg_init_info.local_rank)
|
||||
os.environ["RANK"] = str(pg_init_info.global_rank)
|
||||
os.environ["WORLD_SIZE"] = str(pg_init_info.world_size)
|
||||
|
||||
logger.info(
|
||||
"Initializing dist.ProcessGroup in checkpoint background process on port %s",
|
||||
pg_init_info.tcp_store_master_port,
|
||||
)
|
||||
# NOTE: GLOO backend is enforced here.
|
||||
if pg_init_info.use_prefix_store:
|
||||
logger.info(
|
||||
"Initializing dist.ProcessGroup in checkpoint background process with prefix store"
|
||||
)
|
||||
store = PrefixStore(
|
||||
"AsyncCheckpointProcess/",
|
||||
TCPStore(
|
||||
pg_init_info.tcp_store_master_addr,
|
||||
pg_init_info.tcp_store_master_port,
|
||||
),
|
||||
)
|
||||
dist.init_process_group(
|
||||
backend=dist.Backend.GLOO,
|
||||
store=store,
|
||||
world_size=pg_init_info.world_size,
|
||||
rank=pg_init_info.global_rank,
|
||||
)
|
||||
else:
|
||||
dist.init_process_group(backend=dist.Backend.GLOO)
|
||||
dist.barrier()
|
||||
|
||||
logger.info("Checkpoint background process is running...")
|
||||
parent_conn.send(_CheckpointSaveProcessControlOpts.INIT_COMPLETE)
|
||||
|
||||
if pg_init_info.disable_automatic_gc:
|
||||
# Disable automatic garbage collection
|
||||
# GC can optionally be called manually after each checkpoint
|
||||
gc.disable()
|
||||
logger.info("Disabled automatic garbage collection")
|
||||
except BaseException as e: # noqa: B036
|
||||
logger.error(
|
||||
f"Checkpoint background process failed during initialization: {e}" # noqa: G004
|
||||
)
|
||||
parent_conn.send(e)
|
||||
return
|
||||
|
||||
# Phase 2: Serving Loop
|
||||
try:
|
||||
first_request = True
|
||||
while True:
|
||||
logger.info("Waiting for checkpoint save request...")
|
||||
obj = parent_conn.recv()
|
||||
if (
|
||||
isinstance(obj, _CheckpointSaveProcessControlOpts)
|
||||
and obj == _CheckpointSaveProcessControlOpts.TERMINATE
|
||||
):
|
||||
logger.info("Terminating the checkpoint background process.")
|
||||
return
|
||||
if not isinstance(obj, _AsyncCheckpointRequest):
|
||||
raise AssertionError(
|
||||
f"Expected _AsyncCheckpointRequest, got {type(obj)}"
|
||||
)
|
||||
logger.info(
|
||||
f"Received async checkpoint request with id={obj.checkpoint_request_id.checkpoint_id}" # noqa: G004
|
||||
)
|
||||
|
||||
try:
|
||||
response = _AsyncCheckpointProcess._execute_save(
|
||||
obj.staged_state_dict,
|
||||
checkpoint_request_id=obj.checkpoint_request_id,
|
||||
storage_writer=obj.storage_writer,
|
||||
planner=obj.planner,
|
||||
no_dist=obj.no_dist,
|
||||
use_collectives=obj.use_collectives,
|
||||
)
|
||||
parent_conn.send(response)
|
||||
logger.info(
|
||||
f"Completed checkpoint save request for checkpoint_id={obj.checkpoint_request_id}" # noqa: G004
|
||||
)
|
||||
|
||||
# in theory this manual gc should not be needed as we shouldn't be leaking anything from checkpointing process
|
||||
if (
|
||||
pg_init_info.disable_automatic_gc
|
||||
and not pg_init_info.disable_manual_gc
|
||||
):
|
||||
del obj
|
||||
|
||||
collected_objects = gc.collect()
|
||||
|
||||
logger.info(
|
||||
f"Manual garbage collection completed - collected {collected_objects} objects." # noqa: G004
|
||||
)
|
||||
if first_request:
|
||||
# Freeze GC to not check GC for large checkpoint save plans
|
||||
# After freezing, subsequent gc.collect() calls will only scan
|
||||
# NEW objects created after this point, not the frozen save plan
|
||||
logger.info(
|
||||
"First checkpoint request completed - freezing gc"
|
||||
)
|
||||
gc.freeze()
|
||||
first_request = False
|
||||
except BaseException as e: # noqa: B036
|
||||
logger.error(
|
||||
f"Checkpoint save failed for checkpoint_id={obj.checkpoint_request_id.checkpoint_id}: {e}" # noqa: G004
|
||||
)
|
||||
parent_conn.send(e)
|
||||
# Continue serving loop - don't exit process
|
||||
finally:
|
||||
logger.info("Checkpoint background process is shutting down...")
|
||||
dist.destroy_process_group()
|
||||
parent_conn.close()
|
||||
|
||||
|
||||
_CHECKPOINT_PROCESS: _AsyncCheckpointProcess | None = None
|
||||
|
||||
|
||||
class _ProcessBasedAsyncCheckpointExecutor(_AsyncCheckpointExecutor):
|
||||
def __init__(self) -> None:
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
@staticmethod
|
||||
def _execute_save_impl(
|
||||
*,
|
||||
pg_init_info: _ProcessGroupInitInfo | None,
|
||||
staging_future_or_state_dict: Future[STATE_DICT_TYPE] | STATE_DICT_TYPE,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Metadata:
|
||||
global _CHECKPOINT_PROCESS
|
||||
if _CHECKPOINT_PROCESS is None:
|
||||
if pg_init_info is None:
|
||||
raise AssertionError(
|
||||
"pg_init_info must not be None when _CHECKPOINT_PROCESS is None"
|
||||
)
|
||||
ckpt_kwargs = {}
|
||||
if (ckpt_id := getattr(storage_writer, "checkpoint_id", None)) is not None:
|
||||
ckpt_kwargs["checkpoint_id"] = ckpt_id
|
||||
ckpt_kwargs["process_group"] = process_group
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def create_checkpoint_daemon_process() -> None:
|
||||
global _CHECKPOINT_PROCESS
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
_CHECKPOINT_PROCESS = _AsyncCheckpointProcess(pg_init_info=pg_init_info)
|
||||
|
||||
create_checkpoint_daemon_process()
|
||||
|
||||
if _CHECKPOINT_PROCESS is None:
|
||||
raise AssertionError(
|
||||
"_CHECKPOINT_PROCESS must not be None after initialization"
|
||||
)
|
||||
staged_state_dict = (
|
||||
staging_future_or_state_dict.result()
|
||||
if isinstance(staging_future_or_state_dict, Future)
|
||||
else staging_future_or_state_dict
|
||||
)
|
||||
return _CHECKPOINT_PROCESS.save(
|
||||
staged_state_dict=staged_state_dict,
|
||||
checkpoint_id=checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
|
||||
def execute_save(
|
||||
self,
|
||||
staging_future_or_state_dict: Future[STATE_DICT_TYPE] | STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Future:
|
||||
"""
|
||||
NOTE:
|
||||
|
||||
- Checkpoint process is implemented as a daemon process.
|
||||
The AsyncCheckpointProcess' lifetime is tied to the lifetime of the
|
||||
main process (e.g. trainer process).
|
||||
|
||||
- The first call to execute_save_in_process() will initialize the checkpoint
|
||||
daemon process. Subsequent async checkpoint requests will not need process
|
||||
initialization. Therefore, the first async checkpoint request will take longer to complete.
|
||||
|
||||
- Process initialization can have significant overhead, dominated by latency for all ranks to spawn
|
||||
a background process + process group initialization in the background process.
|
||||
"""
|
||||
|
||||
global _CHECKPOINT_PROCESS
|
||||
pg_init_info: _ProcessGroupInitInfo | None = None
|
||||
if _CHECKPOINT_PROCESS is None:
|
||||
# Find a port on coordinator rank and broadcast
|
||||
# to all ranks.
|
||||
pg_init_info = _ProcessGroupInitInfo(process_group)
|
||||
|
||||
f: Future = self._executor.submit(
|
||||
self._execute_save_impl,
|
||||
pg_init_info=pg_init_info,
|
||||
staging_future_or_state_dict=staging_future_or_state_dict,
|
||||
checkpoint_id=checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
f.add_done_callback(lambda f: self._executor.shutdown(wait=False))
|
||||
|
||||
return f
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# pyre-strict
|
||||
# mypy: allow-untyped-defs
|
||||
import os
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.checkpoint._async_executor import _AsyncCheckpointExecutor
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
from torch.distributed.checkpoint.planner import SavePlanner
|
||||
from torch.distributed.checkpoint.storage import StorageWriter
|
||||
|
||||
|
||||
def save_wrapper(
|
||||
staging_future_or_state_dict: Future[STATE_DICT_TYPE] | STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Future:
|
||||
from torch.distributed.checkpoint.state_dict_saver import save
|
||||
|
||||
staged_dict = (
|
||||
staging_future_or_state_dict.result()
|
||||
if isinstance(staging_future_or_state_dict, Future)
|
||||
else staging_future_or_state_dict
|
||||
)
|
||||
return save(
|
||||
staged_dict,
|
||||
checkpoint_id=checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
|
||||
|
||||
class _ThreadBasedAsyncCheckpointExecutor(_AsyncCheckpointExecutor):
|
||||
def __init__(self) -> None:
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="AsyncCheckpointExecutor"
|
||||
)
|
||||
|
||||
def execute_save(
|
||||
self,
|
||||
staging_future_or_state_dict: Future[STATE_DICT_TYPE] | STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Future:
|
||||
f: Future = self._executor.submit(
|
||||
save_wrapper,
|
||||
staging_future_or_state_dict=staging_future_or_state_dict,
|
||||
checkpoint_id=checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
f.add_done_callback(lambda f: self._executor.shutdown(wait=False))
|
||||
|
||||
return f
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
from concurrent.futures import Future
|
||||
from typing import Any
|
||||
|
||||
import torch.distributed as dist
|
||||
import torch.distributed.checkpoint.state_dict_loader as loader
|
||||
import torch.distributed.checkpoint.state_dict_saver as saver
|
||||
from torch.distributed.checkpoint.metadata import Metadata, STATE_DICT_TYPE
|
||||
from torch.distributed.checkpoint.storage import (
|
||||
LoadPlanner,
|
||||
SavePlanner,
|
||||
StorageReader,
|
||||
StorageWriter,
|
||||
)
|
||||
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
class _Checkpointer:
|
||||
"""This base class specifies a high level API for saving and loading
|
||||
distributed `state_dict` 's. It provides an abstraction over the low-level APIs
|
||||
provided by :py:mod:`torch.distributed.checkpoint.storage`, essentially calling
|
||||
:py:meth: `torch.distributed.state_dict_saver.save` and
|
||||
:py:meth: `torch.distributed.state_dict_loader.load` with the provided storage
|
||||
readers and writers.
|
||||
|
||||
.. warning::
|
||||
This feature is experimental and subject to removal/change.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_writer: StorageWriter,
|
||||
storage_reader: StorageReader,
|
||||
*,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
no_dist: bool = False,
|
||||
load_planner: LoadPlanner | None = None,
|
||||
save_planner: SavePlanner | None = None,
|
||||
):
|
||||
"""Initializes the Checkpointer instance.
|
||||
|
||||
Args:
|
||||
storage_writer: Instance of StorageWrite use to perform writes.
|
||||
storage_reader: StorageReader used to load data from.
|
||||
process_group: ProcessGroup to be used for cross-rank synchronization.
|
||||
coordinator_rank: Rank to use to coordinate the checkpoint. rank0 is used by default.
|
||||
no_dist: If ``True``, distributed checkpoint will not load in SPMD style. (Default: ``False``)
|
||||
loader_planner: Instance of LoadPlanner to use when loading.
|
||||
save_planner: Instance of SavePlanner to use when saving.
|
||||
"""
|
||||
self.storage_writer = storage_writer
|
||||
self.storage_reader = storage_reader
|
||||
self.process_group = process_group
|
||||
self.coordinator_rank = coordinator_rank
|
||||
self.no_dist = no_dist
|
||||
self.load_planner = load_planner
|
||||
self.save_planner = save_planner
|
||||
|
||||
def save(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
) -> Metadata:
|
||||
"""Calls :py:meth: `torch.distributed.state_dict_saver.save`. Utilizing values passed during initialization."""
|
||||
return saver.save(
|
||||
state_dict,
|
||||
self.storage_writer,
|
||||
process_group=self.process_group,
|
||||
coordinator_rank=self.coordinator_rank,
|
||||
no_dist=self.no_dist,
|
||||
planner=self.save_planner,
|
||||
)
|
||||
|
||||
def async_save(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
) -> Future:
|
||||
"""
|
||||
Calls :py:meth: `torch.distributed.state_dict_saver._async_save`. Utilizing values passed during initialization.
|
||||
|
||||
Returns:
|
||||
Future: A future holding the resultant Metadata object from `save`.
|
||||
"""
|
||||
response = saver.async_save(
|
||||
state_dict,
|
||||
storage_writer=self.storage_writer,
|
||||
process_group=self.process_group,
|
||||
planner=self.save_planner,
|
||||
)
|
||||
if not isinstance(response, Future):
|
||||
raise AssertionError("response should be a Future instance")
|
||||
return response
|
||||
|
||||
def load(self, state_dict: dict[str, Any]) -> None:
|
||||
"""Calls :py:meth: `torch.distributed.state_dict_loader.load`. Utilizing values passed during initialization."""
|
||||
loader.load(
|
||||
state_dict,
|
||||
storage_reader=self.storage_reader,
|
||||
process_group=self.process_group,
|
||||
planner=self.load_planner,
|
||||
)
|
||||
+749
@@ -0,0 +1,749 @@
|
||||
# pyre-strict
|
||||
|
||||
import concurrent.futures
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import distributed as dist
|
||||
from torch.distributed.checkpoint._hf_utils import (
|
||||
_gen_file_name,
|
||||
_get_dcp_custom_metadata,
|
||||
_get_safetensors_file_metadata,
|
||||
_metadata_fn,
|
||||
DATA_OFFSETS_KEY,
|
||||
DEFAULT_EXTRA_METADATA_KEY,
|
||||
DTYPE_KEY,
|
||||
SAVED_OFFSETS_KEY,
|
||||
SHAPE_KEY,
|
||||
SUFFIX,
|
||||
)
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FqnData:
|
||||
"""
|
||||
Dataclass to store information about a tensor (identified by its fully qualified name).
|
||||
|
||||
Attributes:
|
||||
offset_in_file: Byte offset where this tensor's data begins in the output file
|
||||
shape_in_file: Shape of the tensor in the output file
|
||||
dtype_size: Size of the tensor's data type in bytes
|
||||
dtype_str: String representation of the tensor's data type
|
||||
"""
|
||||
|
||||
offset_in_file: int = 0
|
||||
shape_in_file: list[int] = field(default_factory=list)
|
||||
dtype_size: int = 0
|
||||
dtype_str: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OutputFileData:
|
||||
"""
|
||||
Dataclass to store information about an output safetensors file.
|
||||
|
||||
Attributes:
|
||||
metadata_size: Size of the metadata section in bytes
|
||||
fqn_data: Dictionary mapping tensor names to their metadata
|
||||
"""
|
||||
|
||||
metadata_size: int = 0
|
||||
fqn_data: dict[str, _FqnData] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _InputFileData:
|
||||
"""
|
||||
Dataclass to store information about an input safetensors file.
|
||||
|
||||
Attributes:
|
||||
metadata_size: Size of the metadata section in bytes
|
||||
metadata: Json metadata from the safetensors file
|
||||
"""
|
||||
|
||||
metadata_size: int = 0
|
||||
metadata: Any = None
|
||||
|
||||
|
||||
def _parse_input_metadata(
|
||||
input_files_data: dict[str, _InputFileData],
|
||||
output_files_data: dict[str, _OutputFileData],
|
||||
) -> None:
|
||||
"""
|
||||
Parse metadata from input safetensors files to determine the full tensor shapes and types.
|
||||
|
||||
This function analyzes the metadata from all input files to determine the complete shape
|
||||
of each tensor after consolidation. It updates the output_files_data with this information.
|
||||
|
||||
Args:
|
||||
input_files_data: dict of metadata from input safetensors files
|
||||
output_files_data: Dictionary mapping output file paths to their metadata
|
||||
|
||||
Raises:
|
||||
ValueError: If no DCP custom metadata is found in a safetensors file
|
||||
"""
|
||||
|
||||
from safetensors.torch import _getdtype # type: ignore[import]
|
||||
|
||||
# Dictionary to track the full size of each tensor across all shards
|
||||
fqn_to_size_mapping: dict[str, tuple[list[int], str]] = {}
|
||||
|
||||
for file_data in input_files_data.values():
|
||||
safetensors_metadata = file_data.metadata
|
||||
dcp_sharding_info = _get_dcp_custom_metadata(safetensors_metadata)
|
||||
if not dcp_sharding_info:
|
||||
raise ValueError(
|
||||
"No DCP custom metadata found in safetensors file. The file must be saved with DCP to be consolidated."
|
||||
)
|
||||
|
||||
for key, val in safetensors_metadata.items():
|
||||
if key == DEFAULT_EXTRA_METADATA_KEY:
|
||||
continue
|
||||
|
||||
# Get the shape of this tensor shard and its offset in the full tensor
|
||||
sizes = val[SHAPE_KEY]
|
||||
offsets = dcp_sharding_info[key][SAVED_OFFSETS_KEY]
|
||||
|
||||
if key not in fqn_to_size_mapping:
|
||||
# First time seeing this tensor - calculate its full size by adding offsets to dimensions
|
||||
cur_size = [size + offset for size, offset in zip(sizes, offsets)]
|
||||
fqn_to_size_mapping[key] = (cur_size, val[DTYPE_KEY])
|
||||
else:
|
||||
# We've seen this tensor before - update its size if this shard extends beyond current known dimensions
|
||||
cur_size = fqn_to_size_mapping[key][0]
|
||||
for i in range(len(sizes)):
|
||||
cur_size[i] = max(cur_size[i], sizes[i] + offsets[i])
|
||||
|
||||
# Now that we know the full size of each tensor, populate the output file data
|
||||
for fqn, tensor_info in fqn_to_size_mapping.items():
|
||||
tensor_size = tensor_info[0]
|
||||
dtype_str = tensor_info[1]
|
||||
for output_data in output_files_data.values():
|
||||
# Add this tensor to the output file if it's already assigned there
|
||||
if fqn in output_data.fqn_data:
|
||||
output_data.fqn_data[fqn] = _FqnData(
|
||||
shape_in_file=tensor_size,
|
||||
dtype_size=torch.finfo(_getdtype(dtype_str)).bits
|
||||
// 8, # Convert bits to bytes
|
||||
dtype_str=dtype_str,
|
||||
)
|
||||
|
||||
|
||||
def _write_metadata(
|
||||
output_files_data: dict[str, _OutputFileData],
|
||||
) -> None:
|
||||
"""
|
||||
Write metadata to the beginning of each output safetensors file.
|
||||
|
||||
This function writes the metadata section to each output file, including information
|
||||
about tensor shapes, data types, and offsets. It also updates the offset_in_file
|
||||
field for each tensor in the output_files_data.
|
||||
|
||||
Args:
|
||||
output_files_data: Dictionary mapping output file paths to their metadata
|
||||
"""
|
||||
# Process each output file
|
||||
for file_path, output_data in output_files_data.items():
|
||||
with open(file_path, "wb") as f:
|
||||
metadata = {}
|
||||
curr_offset = 0
|
||||
|
||||
# Calculate offsets for each tensor in the file
|
||||
for fqn, fqn_data in output_data.fqn_data.items():
|
||||
# Calculate the end offset by multiplying all dimensions and the data type size
|
||||
end_offset = (
|
||||
curr_offset
|
||||
+ math.prod(fqn_data.shape_in_file) * fqn_data.dtype_size
|
||||
)
|
||||
|
||||
# Store metadata for this tensor
|
||||
metadata[fqn] = {
|
||||
SHAPE_KEY: fqn_data.shape_in_file,
|
||||
DTYPE_KEY: fqn_data.dtype_str,
|
||||
DATA_OFFSETS_KEY: [
|
||||
curr_offset,
|
||||
end_offset,
|
||||
], # Start and end byte offsets
|
||||
}
|
||||
# Store the offset for later use when writing the actual tensor data
|
||||
fqn_data.offset_in_file = curr_offset
|
||||
|
||||
# Update current offset for the next tensor
|
||||
curr_offset = end_offset
|
||||
|
||||
# Convert metadata to JSON and encode as bytes
|
||||
json_metadata = json.dumps(metadata)
|
||||
json_bytes = json_metadata.encode("utf-8")
|
||||
|
||||
# Write the metadata size as an 8-byte unsigned integer (little-endian)
|
||||
size_in_bytes = len(json_bytes)
|
||||
header_len = struct.pack("<Q", size_in_bytes)
|
||||
|
||||
# Write the header length and metadata to the file
|
||||
f.write(header_len)
|
||||
f.write(json_bytes)
|
||||
|
||||
# Store the total metadata size (header + JSON) for later use
|
||||
output_data.metadata_size = f.tell()
|
||||
|
||||
|
||||
def _read_tensor_data(
|
||||
f,
|
||||
start_offset: int,
|
||||
end_offset: int,
|
||||
metadata_size: int,
|
||||
) -> bytes:
|
||||
"""
|
||||
Read a specific byte range of tensor data from an open safetensors file.
|
||||
|
||||
Args:
|
||||
f: An open file object (handle) for the safetensors file
|
||||
start_offset: Start offset of tensor data within the data section
|
||||
end_offset: End offset of tensor data within the data section
|
||||
metadata_size: Size of the metadata header
|
||||
|
||||
Returns:
|
||||
Raw tensor data as bytes
|
||||
"""
|
||||
absolute_start = metadata_size + start_offset
|
||||
length = end_offset - start_offset
|
||||
|
||||
f.seek(absolute_start)
|
||||
return f.read(length)
|
||||
|
||||
|
||||
def _process_output_file(
|
||||
output_file: str,
|
||||
output_data: _OutputFileData,
|
||||
input_files_data: dict[str, _InputFileData],
|
||||
) -> None:
|
||||
"""
|
||||
Process a single output file by writing tensor data from input files using direct reads.
|
||||
|
||||
This function is designed to be run in parallel for different output files.
|
||||
|
||||
Args:
|
||||
output_file: Path to the output file
|
||||
output_data: Metadata for the output file
|
||||
input_files_data: Dictionary mapping input file paths to their metadata
|
||||
"""
|
||||
|
||||
sorted_tensors = sorted(
|
||||
output_data.fqn_data.items(), key=lambda x: x[1].offset_in_file
|
||||
)
|
||||
|
||||
file_handles = {}
|
||||
dcp_metadata = {}
|
||||
for safetensors_file, file_data in input_files_data.items():
|
||||
dcp_metadata[safetensors_file] = _get_dcp_custom_metadata(file_data.metadata)
|
||||
|
||||
try:
|
||||
# Open all input files for reading
|
||||
for safetensors_file in input_files_data:
|
||||
file_handles[safetensors_file] = open(safetensors_file, "rb") # noqa: SIM115
|
||||
|
||||
with open(output_file, "r+b") as output_stream:
|
||||
output_stream.seek(0, os.SEEK_END)
|
||||
# Process each tensor in sequential output order
|
||||
for tensor_fqn, tensor_fqn_data in sorted_tensors:
|
||||
full_tensor_mv = memoryview(
|
||||
bytearray(
|
||||
math.prod(tensor_fqn_data.shape_in_file)
|
||||
* tensor_fqn_data.dtype_size
|
||||
)
|
||||
)
|
||||
|
||||
# Process each input safetensors file
|
||||
for safetensors_file in input_files_data:
|
||||
file_metadata = input_files_data[safetensors_file].metadata
|
||||
input_metadata_size = input_files_data[
|
||||
safetensors_file
|
||||
].metadata_size
|
||||
|
||||
if tensor_fqn not in file_metadata:
|
||||
continue
|
||||
|
||||
metadata = file_metadata[tensor_fqn]
|
||||
|
||||
data_offsets = metadata[DATA_OFFSETS_KEY]
|
||||
|
||||
# Use explicit reads to fetch tensor data efficiently
|
||||
data_to_write = _read_tensor_data(
|
||||
file_handles[safetensors_file],
|
||||
data_offsets[0],
|
||||
data_offsets[1],
|
||||
input_metadata_size,
|
||||
)
|
||||
|
||||
# Get the offsets of this tensor shard within the full tensor
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
fqn_custom_metadata = dcp_metadata[safetensors_file][tensor_fqn] # type: ignore[index]
|
||||
offsets_of_tensor_being_read = fqn_custom_metadata[
|
||||
SAVED_OFFSETS_KEY
|
||||
] # type: ignore[index]
|
||||
|
||||
# Write this tensor shard to the appropriate position in the output file
|
||||
_write_sub_tensor_to_file_optimized(
|
||||
full_tensor_mv,
|
||||
data_to_write,
|
||||
tensor_fqn_data.dtype_size, # Size of each element in bytes
|
||||
tensor_fqn_data.shape_in_file, # Full tensor shape
|
||||
offsets_of_tensor_being_read, # Where this shard belongs in the full tensor
|
||||
metadata[SHAPE_KEY], # Shape of this shard
|
||||
)
|
||||
|
||||
output_stream.write(full_tensor_mv)
|
||||
|
||||
finally:
|
||||
for f in file_handles.values():
|
||||
f.close()
|
||||
|
||||
|
||||
def _write_data(
|
||||
input_files_data: dict[str, _InputFileData],
|
||||
output_files_data: dict[str, _OutputFileData],
|
||||
num_threads: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Write tensor data from input files to the output files using memory mapping.
|
||||
|
||||
This function reads tensor data from each input file and writes it to the appropriate
|
||||
position in the output files based on the tensor's offsets. When num_threads > 1,
|
||||
the work is split across threads with each thread handling a different output file.
|
||||
|
||||
Args:
|
||||
input_files_data: Dictionary mapping input file paths to their metadata
|
||||
output_files_data: Dictionary mapping output file paths to their metadata
|
||||
num_threads: Number of threads to use for parallel processing
|
||||
"""
|
||||
if num_threads <= 1 or len(output_files_data) <= 1:
|
||||
# Sequential processing
|
||||
for output_file, output_data in output_files_data.items():
|
||||
_process_output_file(output_file, output_data, input_files_data)
|
||||
else:
|
||||
# Parallel processing with ThreadPoolExecutor
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=min(num_threads, len(output_files_data))
|
||||
) as executor:
|
||||
futures = []
|
||||
for output_file, output_data in output_files_data.items():
|
||||
futures.append(
|
||||
executor.submit(
|
||||
_process_output_file,
|
||||
output_file,
|
||||
output_data,
|
||||
input_files_data,
|
||||
)
|
||||
)
|
||||
|
||||
# Wait for all futures to complete
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
# Handle any exceptions that might have occurred
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
print(f"Error processing output file: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _write_sub_tensor_to_file_optimized(
|
||||
full_tensor_mv: memoryview,
|
||||
sub_tensor_bytes: bytes,
|
||||
element_size: int,
|
||||
tensor_shape: list[int],
|
||||
sub_tensor_offsets: list[int],
|
||||
sub_tensor_shape: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Optimized version that writes the maximum number of contiguous bytes possible.
|
||||
|
||||
Uses a unified algorithm that calculates the maximum contiguous bytes that can be
|
||||
written in each iteration and continues until the entire subtensor is written.
|
||||
Handles all sharding patterns efficiently:
|
||||
- Full sub-tensor at once for row-wise sharding
|
||||
- Row-by-row for column-wise sharding
|
||||
- Optimized chunks for other patterns
|
||||
|
||||
Args:
|
||||
full_tensor_mv: Buffer to write the full tensor to
|
||||
sub_tensor_bytes: Raw tensor data as bytes
|
||||
element_size: Size of each element in bytes
|
||||
tensor_shape: Shape of the full tensor
|
||||
sub_tensor_offsets: Starting offsets of the sub-tensor within the full tensor
|
||||
sub_tensor_shape: Shape of the sub-tensor
|
||||
"""
|
||||
# Handle empty tensors
|
||||
if not tensor_shape or not sub_tensor_shape:
|
||||
return
|
||||
|
||||
# Calculate tensor strides for efficient indexing
|
||||
tensor_strides = [1]
|
||||
for i in range(len(tensor_shape) - 1, 0, -1):
|
||||
tensor_strides.insert(0, tensor_strides[0] * tensor_shape[i])
|
||||
|
||||
sub_tensor_strides = [1]
|
||||
for i in range(len(sub_tensor_shape) - 1, 0, -1):
|
||||
sub_tensor_strides.insert(0, sub_tensor_strides[0] * sub_tensor_shape[i])
|
||||
|
||||
total_elements = math.prod(sub_tensor_shape)
|
||||
|
||||
elements_written = 0
|
||||
while elements_written < total_elements:
|
||||
# Convert linear index to multi-dimensional indices
|
||||
temp_idx = elements_written
|
||||
indices = []
|
||||
for dim_size in reversed(sub_tensor_shape):
|
||||
indices.append(temp_idx % dim_size)
|
||||
temp_idx //= dim_size
|
||||
indices.reverse()
|
||||
|
||||
# Calculate maximum contiguous elements we can write from this position
|
||||
max_contiguous = _calculate_max_contiguous_elements(
|
||||
indices, sub_tensor_shape, tensor_shape
|
||||
)
|
||||
|
||||
# Calculate source position in bytes
|
||||
src_pos = sum(idx * stride for idx, stride in zip(indices, sub_tensor_strides))
|
||||
src_byte_offset = src_pos * element_size
|
||||
|
||||
# Calculate destination position in bytes
|
||||
dest_indices = [
|
||||
idx + offset for idx, offset in zip(indices, sub_tensor_offsets)
|
||||
]
|
||||
dest_pos = sum(
|
||||
idx * stride for idx, stride in zip(dest_indices, tensor_strides)
|
||||
)
|
||||
dest_byte_offset = dest_pos * element_size
|
||||
|
||||
# Write the contiguous chunk
|
||||
bytes_to_write = max_contiguous * element_size
|
||||
chunk_data = sub_tensor_bytes[
|
||||
src_byte_offset : src_byte_offset + bytes_to_write
|
||||
]
|
||||
full_tensor_mv[dest_byte_offset : dest_byte_offset + bytes_to_write] = (
|
||||
chunk_data
|
||||
)
|
||||
|
||||
elements_written += max_contiguous
|
||||
|
||||
|
||||
def _calculate_max_contiguous_elements(
|
||||
indices: list[int],
|
||||
sub_tensor_shape: list[int],
|
||||
tensor_shape: list[int],
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the maximum number of contiguous elements that can be written from current position.
|
||||
|
||||
This determines the largest chunk by checking how elements are laid out in memory
|
||||
and finding natural boundaries where contiguity breaks.
|
||||
|
||||
Args:
|
||||
indices: Current position indices in the sub-tensor
|
||||
sub_tensor_shape: Shape of the sub-tensor being written
|
||||
tensor_shape: Shape of the full tensor
|
||||
|
||||
Raises:
|
||||
ValueError: If input lists are empty, have mismatched lengths, or contain invalid values
|
||||
"""
|
||||
# Validate input lists are not empty
|
||||
if not indices or not sub_tensor_shape or not tensor_shape:
|
||||
raise ValueError("Input lists cannot be empty")
|
||||
|
||||
# Validate all lists have the same length (same number of dimensions)
|
||||
if not (len(indices) == len(sub_tensor_shape) == len(tensor_shape)):
|
||||
raise ValueError(
|
||||
f"All input lists must have the same length. Got indices: {len(indices)}, "
|
||||
f"sub_tensor_shape: {len(sub_tensor_shape)}, tensor_shape: {len(tensor_shape)}"
|
||||
)
|
||||
|
||||
# Validate indices are within bounds of sub_tensor_shape
|
||||
for i, (idx, sub_dim) in enumerate(zip(indices, sub_tensor_shape)):
|
||||
if idx >= sub_dim:
|
||||
raise ValueError(
|
||||
f"Index {idx} at dimension {i} is out of bounds for sub-tensor shape {sub_tensor_shape}"
|
||||
)
|
||||
|
||||
# Validate sub_tensor dimensions don't exceed tensor dimensions
|
||||
for i, (sub_dim, tensor_dim) in enumerate(zip(sub_tensor_shape, tensor_shape)):
|
||||
if sub_dim > tensor_dim:
|
||||
raise ValueError(
|
||||
f"Sub-tensor dimension {sub_dim} at position {i} exceeds tensor dimension {tensor_dim}"
|
||||
)
|
||||
|
||||
# Start with elements remaining in the last dimension
|
||||
max_contiguous = sub_tensor_shape[-1] - indices[-1]
|
||||
|
||||
# Check if we can extend across multiple dimensions
|
||||
# We can write across dimension boundaries if we're writing complete "rows"
|
||||
# and the layout in destination tensor maintains contiguity
|
||||
|
||||
# For 2D case: check if we can write multiple complete rows
|
||||
if len(sub_tensor_shape) >= 2:
|
||||
# If we're at the start of a row and can write complete rows
|
||||
if indices[-1] == 0: # At start of last dimension (column)
|
||||
rows_remaining = sub_tensor_shape[-2] - indices[-2] # Rows left to write
|
||||
|
||||
# Check if writing complete rows maintains contiguity in destination
|
||||
# This is true for row-wise sharding or when sub-tensor spans full width
|
||||
if sub_tensor_shape[-1] == tensor_shape[-1]: # Full width
|
||||
max_contiguous = rows_remaining * sub_tensor_shape[-1]
|
||||
|
||||
# For higher dimensions, check if we can extend further
|
||||
if len(sub_tensor_shape) >= 3 and indices[-2] == 0:
|
||||
# Check if we can write complete 2D slices
|
||||
remaining_in_dim = sub_tensor_shape[-3] - indices[-3]
|
||||
if (
|
||||
sub_tensor_shape[-1] == tensor_shape[-1]
|
||||
and sub_tensor_shape[-2] == tensor_shape[-2]
|
||||
):
|
||||
max_contiguous = (
|
||||
remaining_in_dim * sub_tensor_shape[-2] * sub_tensor_shape[-1]
|
||||
)
|
||||
|
||||
return max_contiguous
|
||||
|
||||
|
||||
def _write_overall_metadata_file(
|
||||
output_dir: str,
|
||||
output_files_data: dict[str, _OutputFileData],
|
||||
) -> None:
|
||||
"""
|
||||
Write the overall metadata file that maps tensor names to their file locations.
|
||||
|
||||
This creates a model.safetensors.index.json file that HuggingFace models use
|
||||
to locate tensors across multiple files.
|
||||
|
||||
Args:
|
||||
output_dir: Directory where the metadata file will be written
|
||||
output_files_data: Dictionary mapping output file paths to their metadata
|
||||
"""
|
||||
total_size = 0
|
||||
weight_map = {}
|
||||
for output_path, value in output_files_data.items():
|
||||
for fqn, fqn_data in value.fqn_data.items():
|
||||
total_size += math.prod(fqn_data.shape_in_file) * fqn_data.dtype_size
|
||||
weight_map[fqn] = os.path.basename(output_path)
|
||||
|
||||
metadata_to_write: dict[str, Any] = {}
|
||||
metadata_to_write["metadata"] = {"total_size": total_size}
|
||||
metadata_to_write["weight_map"] = weight_map
|
||||
|
||||
metadata_path = os.path.join(output_dir, f"{_metadata_fn}")
|
||||
with open(metadata_path, "w") as metadata_file:
|
||||
json.dump(metadata_to_write, metadata_file, indent=2)
|
||||
|
||||
|
||||
def _consolidate_safetensors_files(
|
||||
input_dir: str,
|
||||
output_dir: str,
|
||||
fqn_to_file_mapping: dict[str, str],
|
||||
num_threads: int,
|
||||
) -> dict[str, _OutputFileData]:
|
||||
output_files_data: dict[str, _OutputFileData] = {}
|
||||
# Create multiple output files based on the provided mapping
|
||||
for fqn, filename in fqn_to_file_mapping.items():
|
||||
output_path = os.path.join(output_dir, filename)
|
||||
|
||||
if output_path not in output_files_data:
|
||||
output_files_data[output_path] = _OutputFileData(fqn_data={fqn: _FqnData()})
|
||||
else:
|
||||
output_files_data[output_path].fqn_data[fqn] = _FqnData()
|
||||
|
||||
# Find all safetensors files in the input directory
|
||||
safetensors_files = glob.glob(os.path.join(input_dir, f"*{SUFFIX}"))
|
||||
|
||||
# Read metadata from all input files
|
||||
input_files_data: dict[str, _InputFileData] = {}
|
||||
for safetensor_file in safetensors_files:
|
||||
with open(safetensor_file, "rb") as f:
|
||||
metadata, size = _get_safetensors_file_metadata(f)
|
||||
input_files_data[safetensor_file] = _InputFileData(
|
||||
metadata_size=size, metadata=metadata
|
||||
)
|
||||
# Step 1: Parse metadata to determine tensor shapes and types
|
||||
_parse_input_metadata(input_files_data, output_files_data)
|
||||
|
||||
# Step 2: Write metadata headers to output files
|
||||
_write_metadata(output_files_data)
|
||||
# Step 3: Write actual tensor data from input files to output files
|
||||
_write_data(input_files_data, output_files_data, num_threads)
|
||||
|
||||
return output_files_data
|
||||
|
||||
|
||||
def consolidate_safetensors_files(
|
||||
input_dir: str,
|
||||
output_dir: str,
|
||||
fqn_to_index_mapping: dict[str, int],
|
||||
num_threads: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Main function to consolidate sharded safetensors files into one or more output files.
|
||||
|
||||
This function orchestrates the entire consolidation process:
|
||||
1. Sets up the output file structure based on the fqn_to_index_mapping
|
||||
2. Finds all safetensors files in the input directory
|
||||
3. Parses metadata from all input files
|
||||
4. Writes metadata to the output files
|
||||
5. Writes tensor data from input files to output files
|
||||
6. Writes overall model.index.safetensors.json file with weight map
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing sharded safetensors files
|
||||
output_dir: Directory where consolidated files will be written
|
||||
fqn_to_index_mapping: Optional mapping of tensor names to output file indices.
|
||||
If None, all tensors will be consolidated into a single file.
|
||||
num_threads: Number of threads to use for parallel processing of saving data to output files.
|
||||
"""
|
||||
start_time = time.time()
|
||||
logger.info(
|
||||
"Consolidating safetensors files from %s to %s. Beginning at time %f",
|
||||
input_dir,
|
||||
output_dir,
|
||||
start_time,
|
||||
)
|
||||
|
||||
max_index = max(fqn_to_index_mapping.values())
|
||||
fqn_to_file_mapping = {
|
||||
fqn: _gen_file_name(idx, max_index) for fqn, idx in fqn_to_index_mapping.items()
|
||||
}
|
||||
|
||||
output_files_data = _consolidate_safetensors_files(
|
||||
input_dir, output_dir, fqn_to_file_mapping, num_threads
|
||||
)
|
||||
|
||||
# Step 4: Write overall model.index.safetensors.json file with weight map
|
||||
_write_overall_metadata_file(output_dir, output_files_data)
|
||||
|
||||
logger.info("Done consolidating. Took %.2f secs.", time.time() - start_time)
|
||||
|
||||
|
||||
def consolidate_safetensors_files_on_every_rank(
|
||||
input_dir: str,
|
||||
output_dir: str,
|
||||
fqn_to_index_mapping: dict[str, int],
|
||||
num_threads: int = 1,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Consolidate sharded safetensors files across multiple ranks, with each rank handling a subset of output files.
|
||||
|
||||
This function distributes the consolidation work by assigning output files to different ranks.
|
||||
All tensors with the same index in fqn_to_index_mapping are processed by the same rank,
|
||||
as they belong to the same output file.
|
||||
|
||||
If process_group is provided, rank and world_size will be derived from it. Otherwise,
|
||||
they will be automatically detected from the distributed environment if available.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing sharded safetensors files
|
||||
output_dir: Directory where consolidated files will be written
|
||||
fqn_to_index_mapping: Mapping of tensor names to output file indices
|
||||
num_threads: Number of threads to use for parallel processing on each rank
|
||||
process_group: PyTorch distributed process group (default: None, will use default group)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
# Derive rank and world_size from process_group or default distributed environment
|
||||
if dist.is_available() and dist.is_initialized():
|
||||
rank = dist.get_rank(group=process_group)
|
||||
world_size = dist.get_world_size(group=process_group)
|
||||
else:
|
||||
# Default to single process mode if distributed is not initialized
|
||||
rank = 0
|
||||
world_size = 1
|
||||
logger.warning(
|
||||
"Distributed environment not initialized. Running in single process mode."
|
||||
)
|
||||
logger.info(
|
||||
"Rank %d/%d: Consolidating safetensors files from %s to %s",
|
||||
rank,
|
||||
world_size,
|
||||
input_dir,
|
||||
output_dir,
|
||||
)
|
||||
|
||||
# Find all unique indices in the mapping
|
||||
unique_indices = set(fqn_to_index_mapping.values())
|
||||
|
||||
# Distribute indices across ranks
|
||||
indices_for_this_rank = []
|
||||
for idx in unique_indices:
|
||||
# Simple distribution: index % world_size == rank
|
||||
if idx % world_size == rank:
|
||||
indices_for_this_rank.append(idx)
|
||||
|
||||
logger.info(
|
||||
"Rank %d: Assigned %d output files out of %d total files",
|
||||
rank,
|
||||
len(indices_for_this_rank),
|
||||
len(unique_indices),
|
||||
)
|
||||
|
||||
# Filter the fqn_to_index_mapping to only include tensors for this rank
|
||||
filtered_mapping = {
|
||||
fqn: idx
|
||||
for fqn, idx in fqn_to_index_mapping.items()
|
||||
if idx in indices_for_this_rank
|
||||
}
|
||||
|
||||
output_files_data: dict[str, _OutputFileData] = {}
|
||||
if filtered_mapping:
|
||||
# Convert index mapping to filename mapping
|
||||
max_index = max(unique_indices)
|
||||
filtered_filename_mapping = {}
|
||||
for fqn, idx in filtered_mapping.items():
|
||||
filename = _gen_file_name(idx, max_index)
|
||||
filtered_filename_mapping[fqn] = filename
|
||||
|
||||
# Call the existing consolidation function with the filtered mapping
|
||||
output_files_data = _consolidate_safetensors_files(
|
||||
input_dir=input_dir,
|
||||
output_dir=output_dir,
|
||||
fqn_to_file_mapping=filtered_filename_mapping,
|
||||
num_threads=num_threads,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Rank %d: Done consolidating. Processed %d unique indices in %.2f secs.",
|
||||
rank,
|
||||
len(indices_for_this_rank),
|
||||
time.time() - start_time,
|
||||
)
|
||||
|
||||
# Wait for all ranks to complete and gather output_files_data on rank 0
|
||||
if dist.is_available() and dist.is_initialized():
|
||||
gathered_output_files_data: list[dict[str, _OutputFileData]] | None = (
|
||||
[{} for _ in range(world_size)] if rank == 0 else None
|
||||
)
|
||||
dist.gather_object(
|
||||
output_files_data,
|
||||
gathered_output_files_data,
|
||||
dst=0,
|
||||
group=process_group,
|
||||
)
|
||||
|
||||
if rank == 0:
|
||||
# Merge all output_files_data from all ranks
|
||||
all_output_files_data: dict[str, _OutputFileData] = {}
|
||||
if gathered_output_files_data is None:
|
||||
raise AssertionError
|
||||
for rank_data in gathered_output_files_data:
|
||||
all_output_files_data.update(rank_data)
|
||||
|
||||
_write_overall_metadata_file(output_dir, all_output_files_data)
|
||||
logger.info("Rank 0: Wrote overall metadata file.")
|
||||
logger.info("Total time taken: %.2f secs.", time.time() - start_time)
|
||||
dist.barrier(group=process_group)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
import dataclasses
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from torch.distributed.checkpoint.planner import SavePlan, WriteItem
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed.checkpoint.metadata import MetadataIndex
|
||||
|
||||
__all__ = ["dedup_save_plans"]
|
||||
|
||||
|
||||
def dedup_save_plans(
|
||||
all_plans: list[SavePlan],
|
||||
save_to_lowest_rank: bool = False,
|
||||
) -> list[SavePlan]:
|
||||
"""
|
||||
Removes duplicate entries from appearing on multiple SavePlans. For each duplicate across
|
||||
a set of SavePlans, only the smallest SavePlan in terms of planned storage keeps the entry.
|
||||
|
||||
Please note that this function does not modify the original SavePlans, but rather returns
|
||||
"""
|
||||
|
||||
# Map to query the plan indices that a write item is duplicated in
|
||||
write_item_to_plan_indices: dict[MetadataIndex, set[int]] = defaultdict(set)
|
||||
# Map to query the write item from its index
|
||||
write_item_idx_to_write_item: dict[MetadataIndex, WriteItem] = {}
|
||||
# Set of write item indices that are present in each plan
|
||||
# After deduplication, this will be the set of write item indices that are present in the final plans
|
||||
plan_to_item_indices: list[set[MetadataIndex]] = [
|
||||
{item.index for item in plan.items} for plan in all_plans
|
||||
]
|
||||
|
||||
for plan_idx, plan in enumerate(all_plans):
|
||||
for write_item in plan.items:
|
||||
# map each write item to its plan
|
||||
write_item_to_plan_indices[write_item.index].add(plan_idx)
|
||||
write_item_idx_to_write_item[write_item.index] = write_item
|
||||
plan_to_size = [0] * len(all_plans)
|
||||
for write_item_idx, plan_indices in write_item_to_plan_indices.items():
|
||||
if save_to_lowest_rank:
|
||||
select_plan_idx = min(plan_indices)
|
||||
else:
|
||||
select_plan_idx = min(
|
||||
plan_indices, key=lambda plan_idx: plan_to_size[plan_idx]
|
||||
)
|
||||
|
||||
write_item = write_item_idx_to_write_item[write_item_idx]
|
||||
# Ignore the storage size of anything that is not a tensor, since
|
||||
# we don't know how much storage they represent
|
||||
plan_to_size[select_plan_idx] += write_item.tensor_storage_size() or 1
|
||||
for plan_idx in plan_indices - {select_plan_idx}:
|
||||
plan_to_item_indices[plan_idx].discard(write_item_idx)
|
||||
# Sanity check
|
||||
if len(all_plans) != len(plan_to_item_indices):
|
||||
raise AssertionError("len(all_plans) != len(plan_to_item_indices)")
|
||||
# Create new plans with the updated write items post deduplication
|
||||
return [
|
||||
dataclasses.replace(
|
||||
plan, items=[item for item in plan.items if item.index in item_indexes]
|
||||
)
|
||||
for plan, item_indexes in zip(all_plans, plan_to_item_indices)
|
||||
]
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from torch.distributed.checkpoint.planner import SavePlan
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed.checkpoint.metadata import MetadataIndex
|
||||
|
||||
__all__ = ["dedup_tensors"]
|
||||
|
||||
|
||||
def init_logger() -> logging.Logger:
|
||||
logger = logging.getLogger(__name__)
|
||||
level = logging.INFO
|
||||
logger.setLevel(level)
|
||||
console = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(filename)s:%(lineno)s %(levelname)s p:%(processName)s t:%(threadName)s: %(message)s"
|
||||
)
|
||||
console.setFormatter(formatter)
|
||||
console.setLevel(level)
|
||||
logger.addHandler(console)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
|
||||
logger = init_logger()
|
||||
|
||||
|
||||
# TODO add docstring for dedup_tensors
|
||||
def dedup_tensors(all_plans: list[SavePlan]) -> list[SavePlan]:
|
||||
all_plans = list(all_plans)
|
||||
key_to_plan: dict[MetadataIndex, list[int]] = {}
|
||||
for plan_idx, plan in enumerate(all_plans):
|
||||
for write_item in plan.items:
|
||||
key_to_plan.setdefault(write_item.index, []).append(plan_idx)
|
||||
|
||||
replicated_items = {k: v for k, v in key_to_plan.items() if len(v) > 1}
|
||||
|
||||
# Remove duplicates by always keeping the first entry.
|
||||
# Compute the per-rank remove set.
|
||||
plan_to_keys: dict[int, list[MetadataIndex]] = {}
|
||||
for key, plans in replicated_items.items():
|
||||
for plan_idx in plans[1:]:
|
||||
plan_to_keys.setdefault(plan_idx, []).append(key)
|
||||
if len(plan_to_keys) > 0:
|
||||
logger.info("Duplicate keys to remove: %s", plan_to_keys)
|
||||
|
||||
for plan_idx, keys in plan_to_keys.items():
|
||||
key_set = set(keys)
|
||||
# rewrite items and remove elements
|
||||
new_items = [
|
||||
write_item
|
||||
for write_item in all_plans[plan_idx].items
|
||||
if write_item.index not in key_set
|
||||
]
|
||||
all_plans[plan_idx] = dataclasses.replace(all_plans[plan_idx], items=new_items)
|
||||
|
||||
return all_plans
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Checkpoint functionality for machine learning models.
|
||||
|
||||
This module provides classes for saving and loading model checkpoints in a distributed
|
||||
training environment. It includes functionality for coordinating checkpoint operations
|
||||
across multiple processes and customizing the checkpoint process through hooks.
|
||||
|
||||
Key components:
|
||||
- Checkpointer: Main class for orchestrating checkpoint operations (save, load)
|
||||
- CheckpointWriter: Handles writing state dictionaries to storage
|
||||
- CheckpointReader: Handles reading state dictionaries from storage read
|
||||
- Barrier: Synchronization mechanism for distributed checkpointing
|
||||
- RankInfo: Information about the current rank in a distributed environment
|
||||
"""
|
||||
|
||||
from .barriers import (
|
||||
Barrier,
|
||||
BarrierConfig,
|
||||
create_barrier_from_config,
|
||||
TCPStoreBarrier,
|
||||
)
|
||||
from .builder import make_async_checkpointer, make_sync_checkpointer
|
||||
from .checkpoint_reader import CheckpointReader
|
||||
from .checkpoint_writer import CheckpointWriter, CheckpointWriterConfig, WriterHook
|
||||
from .checkpointer import AsyncCheckpointer, Checkpointer, SyncCheckpointer
|
||||
from .config import CheckpointerConfig
|
||||
from .staging import CheckpointStager, CheckpointStagerConfig, DefaultStager
|
||||
from .types import RankInfo, STATE_DICT
|
||||
from .utils import wrap_future
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Barrier",
|
||||
"TCPStoreBarrier",
|
||||
"CheckpointReader",
|
||||
"CheckpointWriter",
|
||||
"CheckpointWriterConfig",
|
||||
"WriterHook",
|
||||
"Checkpointer",
|
||||
"SyncCheckpointer",
|
||||
"AsyncCheckpointer",
|
||||
"CheckpointerConfig",
|
||||
"BarrierConfig",
|
||||
"create_barrier_from_config",
|
||||
"CheckpointStager",
|
||||
"CheckpointStagerConfig",
|
||||
"DefaultStager",
|
||||
"RankInfo",
|
||||
"STATE_DICT",
|
||||
"wrap_future",
|
||||
"make_sync_checkpointer",
|
||||
"make_async_checkpointer",
|
||||
]
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Barrier implementations for synchronizing distributed checkpoint operations.
|
||||
|
||||
This module provides abstract and concrete barrier implementations that ensure
|
||||
all ranks in a distributed training environment complete their checkpoint operations
|
||||
before proceeding, which is essential for data consistency.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
import torch.distributed as dist
|
||||
import torch.distributed.elastic.utils.store as store_util
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
# Registry of barrier types
|
||||
BARRIER_REGISTRY: dict[str, type] = {}
|
||||
|
||||
|
||||
def register_barrier(barrier_class: type) -> type:
|
||||
"""Register a barrier class in the global registry."""
|
||||
if hasattr(barrier_class, "barrier_type"):
|
||||
BARRIER_REGISTRY[barrier_class.barrier_type] = barrier_class
|
||||
return barrier_class
|
||||
|
||||
|
||||
@dataclass
|
||||
class BarrierConfig:
|
||||
"""
|
||||
Configuration for barrier construction.
|
||||
|
||||
This class provides a flexible way to configure different barrier implementations
|
||||
with their specific constructor arguments. The barrier type will be looked up
|
||||
from a registry and instantiated with rank_info and barrier_args.
|
||||
|
||||
Attributes:
|
||||
barrier_type: A string identifying the barrier type (e.g., "tcp_store").
|
||||
If None, no barrier will be used.
|
||||
barrier_args: Dictionary of arguments to pass to the barrier constructor.
|
||||
rank_info will be automatically injected as the first argument.
|
||||
|
||||
Examples:
|
||||
# No barrier
|
||||
BarrierConfig()
|
||||
|
||||
# TCPStore barrier
|
||||
BarrierConfig(
|
||||
barrier_type="tcp_store",
|
||||
barrier_args={
|
||||
'timeout_barrier_init_secs': 30,
|
||||
'barrier_prefix_list': ['checkpoint'],
|
||||
'use_checkpoint_barrier_tcpstore_libuv': False,
|
||||
'tcpstore_port': 12345,
|
||||
'master_address': 'localhost'
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
barrier_type: str | None = None
|
||||
barrier_args: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def create_barrier_from_config(
|
||||
barrier_config: BarrierConfig,
|
||||
) -> Optional["Barrier"]:
|
||||
"""
|
||||
Create a barrier instance from BarrierConfig.
|
||||
|
||||
Args:
|
||||
barrier_config: Configuration for barrier construction.
|
||||
|
||||
Returns:
|
||||
Barrier instance or None if no barrier type is configured.
|
||||
|
||||
Raises:
|
||||
ValueError: If the barrier_type is not found in the registry.
|
||||
"""
|
||||
if barrier_config.barrier_type is None:
|
||||
return None
|
||||
|
||||
if barrier_config.barrier_type not in BARRIER_REGISTRY:
|
||||
raise ValueError(
|
||||
f"Unknown barrier type: {barrier_config.barrier_type}. "
|
||||
f"Available types: {list(BARRIER_REGISTRY.keys())}"
|
||||
)
|
||||
|
||||
barrier_class = BARRIER_REGISTRY[barrier_config.barrier_type]
|
||||
return barrier_class(**barrier_config.barrier_args)
|
||||
|
||||
|
||||
class Barrier(abc.ABC):
|
||||
"""
|
||||
Abstract base class for synchronization barriers.
|
||||
|
||||
A barrier ensures that all ranks in a distributed environment reach a certain
|
||||
point in execution before any rank proceeds further, which is essential for
|
||||
coordinating operations like checkpointing across multiple processes.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, **kwargs: dict[str, Any]):
|
||||
"""
|
||||
Initialize a barrier.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for specific barrier implementations.
|
||||
Common arguments may include rank information, barrier prefixes,
|
||||
timeout settings, and other barrier-specific configuration.
|
||||
"""
|
||||
# No implementation needed in the abstract base class
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute_barrier(self) -> None:
|
||||
"""
|
||||
Execute a synchronization barrier.
|
||||
|
||||
This method uses the barrier_prefix provided during initialization to
|
||||
coordinate synchronization across processes.
|
||||
"""
|
||||
|
||||
|
||||
@register_barrier
|
||||
class DistBarrier(Barrier):
|
||||
"""
|
||||
A barrier implementation using PyTorch's distributed barrier for synchronization.
|
||||
|
||||
This barrier uses the built-in torch.distributed.barrier() function to coordinate
|
||||
synchronization across multiple processes. It's simpler than TCPStoreBarrier but
|
||||
requires an initialized process group.
|
||||
"""
|
||||
|
||||
barrier_type = "dist_barrier"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a DistBarrier.
|
||||
|
||||
This barrier requires an initialized PyTorch distributed process group.
|
||||
No additional arguments are needed as it uses the current process group.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the distributed process group is not initialized.
|
||||
"""
|
||||
if not dist.is_initialized():
|
||||
raise AssertionError("DistBarrier requires an initialized process group.")
|
||||
|
||||
def execute_barrier(self) -> None:
|
||||
"""
|
||||
Execute a synchronization barrier using the prefix provided during initialization.
|
||||
"""
|
||||
# Note: dist.barrier() doesn't support explicit timeouts
|
||||
# The timeout is handled by the underlying implementation
|
||||
dist.barrier()
|
||||
|
||||
|
||||
@register_barrier
|
||||
class TCPStoreBarrier(Barrier):
|
||||
"""
|
||||
A barrier implementation using PyTorch's TCPStore for synchronization.
|
||||
|
||||
This barrier uses a TCP-based distributed key-value store to coordinate
|
||||
synchronization across multiple processes. It uses a single TCP store
|
||||
for all barrier operations, with different prefixes to distinguish between
|
||||
different barrier types.
|
||||
"""
|
||||
|
||||
barrier_type = "tcp_store"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
global_rank: int,
|
||||
global_world_size: int,
|
||||
barrier_prefix: str,
|
||||
timeout_barrier_init_secs: int,
|
||||
use_checkpoint_barrier_tcpstore_libuv: bool,
|
||||
tcpstore_port: int,
|
||||
master_address: str,
|
||||
timeout_secs: int,
|
||||
):
|
||||
"""
|
||||
Initialize a TCPStoreBarrier.
|
||||
|
||||
Args:
|
||||
global_rank: The rank of the current process in the distributed environment.
|
||||
global_world_size: The total number of processes in the distributed environment.
|
||||
barrier_prefix: A string prefix to identify this specific barrier.
|
||||
timeout_barrier_init_secs: Timeout in seconds for initializing the TCPStore.
|
||||
use_checkpoint_barrier_tcpstore_libuv: Whether to use libuv for the TCPStore.
|
||||
tcpstore_port: Port number for the TCPStore.
|
||||
master_address: Address of the master node for the TCPStore.
|
||||
timeout_secs: Maximum time in seconds to wait for all ranks to reach the barrier.
|
||||
"""
|
||||
logger.info(
|
||||
"Initializing TCPStore master_address=%s tcpstore_port=%s rank=%s "
|
||||
"world_size=%s barrier_prefix=%s timeout_barrier_init_secs=%s "
|
||||
"use_checkpoint_barrier_tcpstore_libuv=%s timeout_secs=%s",
|
||||
master_address,
|
||||
tcpstore_port,
|
||||
global_rank,
|
||||
global_world_size,
|
||||
barrier_prefix,
|
||||
timeout_barrier_init_secs,
|
||||
use_checkpoint_barrier_tcpstore_libuv,
|
||||
timeout_secs,
|
||||
)
|
||||
|
||||
# Counter collection to track barrier seq on a per barrier prefix basis.
|
||||
self._tcp_store_barrier_seq: Counter = Counter()
|
||||
self._barrier_prefix = barrier_prefix
|
||||
|
||||
# Store rank and world size for barrier operations
|
||||
self._global_rank = global_rank
|
||||
self._global_world_size = global_world_size
|
||||
self._timeout_secs = timeout_secs
|
||||
|
||||
# Create a single TCP store for all barrier operations
|
||||
self._tcp_store = dist.TCPStore(
|
||||
master_address,
|
||||
int(tcpstore_port),
|
||||
world_size=self._global_world_size,
|
||||
timeout=timedelta(seconds=timeout_barrier_init_secs),
|
||||
is_master=(self._global_rank == 0),
|
||||
)
|
||||
|
||||
def execute_barrier(self) -> None:
|
||||
"""
|
||||
Execute a synchronization barrier using the prefix provided during initialization.
|
||||
|
||||
The implementation uses a sequence number that is incremented every time
|
||||
a barrier is reached. The sequence number is per barrier prefix to allow
|
||||
different barriers to operate concurrently.
|
||||
"""
|
||||
barrier_prefix = self._barrier_prefix
|
||||
|
||||
logger.info(
|
||||
"Executing barrier barrier_prefix=%s timeout_secs=%s",
|
||||
barrier_prefix,
|
||||
self._timeout_secs,
|
||||
)
|
||||
|
||||
def _rank_key(rank: int) -> str:
|
||||
return f"rank{rank}"
|
||||
|
||||
# Track which barrier sequence this rank is joining.
|
||||
self._tcp_store.set(
|
||||
_rank_key(self._global_rank),
|
||||
str(self._tcp_store_barrier_seq[barrier_prefix]),
|
||||
)
|
||||
|
||||
# Execute barrier for that sequence number (for the specific prefix).
|
||||
store_util.barrier(
|
||||
store=self._tcp_store,
|
||||
world_size=self._global_world_size,
|
||||
key_prefix=(
|
||||
barrier_prefix + str(self._tcp_store_barrier_seq[barrier_prefix])
|
||||
),
|
||||
)
|
||||
self._tcp_store_barrier_seq[barrier_prefix] += 1
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Factory functions for creating checkpointer instances with sensible defaults.
|
||||
|
||||
This module provides high-level factory functions that simplify the creation
|
||||
of checkpointer instances by automatically handling component initialization
|
||||
and configuration with reasonable defaults.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import torch.distributed as dist
|
||||
|
||||
from .barriers import create_barrier_from_config
|
||||
from .checkpoint_process import CheckpointProcess
|
||||
from .checkpoint_reader import CheckpointReader
|
||||
from .checkpoint_writer import CheckpointWriter, CheckpointWriterConfig, WriterHook
|
||||
from .checkpointer import AsyncCheckpointer, SyncCheckpointer
|
||||
from .config import CheckpointerConfig
|
||||
from .staging import DefaultStager
|
||||
from .types import RankInfo
|
||||
|
||||
|
||||
def _get_default_rank_info() -> RankInfo:
|
||||
"""
|
||||
Get default rank information from the current distributed environment.
|
||||
|
||||
Returns:
|
||||
RankInfo: Rank information from the default process group if initialized,
|
||||
otherwise single-rank fallback.
|
||||
"""
|
||||
if dist.is_initialized():
|
||||
return RankInfo(
|
||||
global_world_size=dist.get_world_size(),
|
||||
global_rank=dist.get_rank(),
|
||||
)
|
||||
else:
|
||||
# Single-rank fallback
|
||||
return RankInfo(global_world_size=1, global_rank=0)
|
||||
|
||||
|
||||
def default_subprocess_init_fn(*_: Any) -> None:
|
||||
"""Default subprocess initialization function (no-op)."""
|
||||
|
||||
|
||||
def default_writer_init_fn(rank_info: RankInfo) -> CheckpointWriter:
|
||||
"""Default checkpoint writer initialization function."""
|
||||
return CheckpointWriter(
|
||||
config=CheckpointWriterConfig(),
|
||||
rank_info=rank_info,
|
||||
)
|
||||
|
||||
|
||||
def make_sync_checkpointer(
|
||||
config: CheckpointerConfig = CheckpointerConfig(),
|
||||
rank_info: RankInfo | None = None,
|
||||
commit_hook: WriterHook | None = None,
|
||||
) -> SyncCheckpointer:
|
||||
"""
|
||||
Factory function to create a SyncCheckpointer instance with sensible defaults.
|
||||
|
||||
This function creates a synchronous checkpointer with default components, automatically
|
||||
detecting rank information from the default process group if available, and using the
|
||||
provided component configurations.
|
||||
|
||||
Args:
|
||||
config: CheckpointerConfig containing component-specific configurations
|
||||
(writer_config, staging_config, process_config). Defaults to CheckpointerConfig().
|
||||
rank_info: RankInfo for distributed training. Defaults to auto-detection from
|
||||
the default PyTorch distributed process group if initialized, otherwise
|
||||
falls back to single-rank (world_size=1, rank=0).
|
||||
commit_hook: Optional hook for custom actions before and after checkpoint commits.
|
||||
|
||||
Returns:
|
||||
SyncCheckpointer: A configured synchronous checkpointer instance.
|
||||
|
||||
Examples:
|
||||
# Simplest usage - auto-detect rank, default config
|
||||
checkpointer = make_sync_checkpointer()
|
||||
|
||||
# Explicit rank configuration
|
||||
checkpointer = make_sync_checkpointer(
|
||||
rank_info=RankInfo(global_world_size=4, global_rank=0)
|
||||
)
|
||||
|
||||
# Disable barrier
|
||||
from .barriers import BarrierConfig
|
||||
config = CheckpointerConfig(barrier_config=BarrierConfig(barrier_type=None))
|
||||
checkpointer = make_sync_checkpointer(config=config)
|
||||
"""
|
||||
if rank_info is None:
|
||||
rank_info = _get_default_rank_info()
|
||||
|
||||
reader = CheckpointReader(
|
||||
rank_info=rank_info,
|
||||
)
|
||||
|
||||
barrier = create_barrier_from_config(config.barrier_config)
|
||||
|
||||
writer = CheckpointWriter(
|
||||
config=config.writer_config,
|
||||
rank_info=rank_info,
|
||||
barrier=barrier,
|
||||
commit_hook=commit_hook,
|
||||
)
|
||||
|
||||
return SyncCheckpointer(
|
||||
writer=writer,
|
||||
reader=reader,
|
||||
)
|
||||
|
||||
|
||||
def make_async_checkpointer(
|
||||
config: CheckpointerConfig = CheckpointerConfig(),
|
||||
rank_info: RankInfo | None = None,
|
||||
subprocess_init_fn: Callable[..., None] = default_subprocess_init_fn,
|
||||
subprocess_init_args: tuple[Any, ...] = (),
|
||||
checkpoint_writer_init_fn: Callable[..., CheckpointWriter] = default_writer_init_fn,
|
||||
checkpoint_writer_init_args: dict[str, Any] | None = None,
|
||||
) -> AsyncCheckpointer:
|
||||
"""
|
||||
Factory function to create an AsyncCheckpointer instance with sensible defaults.
|
||||
|
||||
This function creates an asynchronous checkpointer using the provided configuration,
|
||||
automatically detecting rank information if not provided.
|
||||
|
||||
Args:
|
||||
config: CheckpointerConfig containing component-specific configurations.
|
||||
rank_info: RankInfo for distributed training. Defaults to auto-detection.
|
||||
subprocess_init_fn: Function to initialize the subprocess. Defaults to no-op.
|
||||
subprocess_init_args: Arguments to pass to subprocess_init_fn.
|
||||
checkpoint_writer_init_fn: Function to create CheckpointWriter instance.
|
||||
checkpoint_writer_init_args: Arguments to pass to checkpoint_writer_init_fn.
|
||||
|
||||
Returns:
|
||||
AsyncCheckpointer: A configured asynchronous checkpointer instance.
|
||||
|
||||
Examples:
|
||||
# Create with default config
|
||||
checkpointer = make_async_checkpointer()
|
||||
|
||||
# Create with custom init functions
|
||||
checkpointer = make_async_checkpointer(
|
||||
subprocess_init_fn=my_subprocess_init_fn,
|
||||
checkpoint_writer_init_fn=my_writer_init_fn
|
||||
)
|
||||
"""
|
||||
if rank_info is None:
|
||||
rank_info = _get_default_rank_info()
|
||||
|
||||
reader = CheckpointReader(
|
||||
rank_info=rank_info,
|
||||
)
|
||||
|
||||
checkpoint_stager = DefaultStager(
|
||||
config=config.staging_config,
|
||||
)
|
||||
|
||||
checkpoint_writer_init_args = checkpoint_writer_init_args or {}
|
||||
|
||||
checkpoint_process = CheckpointProcess(
|
||||
rank_info=rank_info,
|
||||
config=config.process_config,
|
||||
subprocess_init_fn=subprocess_init_fn,
|
||||
subprocess_init_args=subprocess_init_args,
|
||||
checkpoint_writer_init_fn=checkpoint_writer_init_fn,
|
||||
checkpoint_writer_init_args=checkpoint_writer_init_args,
|
||||
)
|
||||
|
||||
return AsyncCheckpointer(
|
||||
checkpoint_stager=checkpoint_stager,
|
||||
checkpoint_process=checkpoint_process,
|
||||
reader=reader,
|
||||
)
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import Any
|
||||
|
||||
import torch.multiprocessing as mp
|
||||
from torch.multiprocessing.spawn import ProcessExitedException
|
||||
|
||||
from .checkpoint_writer import CheckpointWriter
|
||||
from .types import RankInfo, STATE_DICT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointProcessConfig:
|
||||
"""
|
||||
Configuration options for the CheckpointProcess.
|
||||
|
||||
This class provides configuration options for the checkpoint process,
|
||||
including initialization functions, timeouts, and writer configuration.
|
||||
|
||||
Attributes:
|
||||
subprocess_init_timeout_secs: Maximum time in seconds to wait for subprocess initialization.
|
||||
subprocess_shutdown_timeout_secs: Maximum time in seconds to wait for subprocess shutdown.
|
||||
"""
|
||||
|
||||
subprocess_init_timeout_secs: int = 30
|
||||
subprocess_shutdown_timeout_secs: int = 60
|
||||
|
||||
|
||||
class RequestType(Enum):
|
||||
PING = "ping"
|
||||
WRITE_CHECKPOINT = "write_checkpoint"
|
||||
TERMINATE_PROCESS = "exit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerRequest:
|
||||
"""
|
||||
A dataclass for storing the command to be sent to the worker process.
|
||||
Note: This relies on pickling to send the command to the worker process. Handle
|
||||
backward compatibility accordingly.
|
||||
"""
|
||||
|
||||
request_type: RequestType
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerResponse:
|
||||
request_type: RequestType
|
||||
success: bool
|
||||
error_msg: str | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CheckpointProcess:
|
||||
"""
|
||||
A checkpoint writer that writes checkpoints to a remote process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rank_info: RankInfo,
|
||||
config: CheckpointProcessConfig,
|
||||
subprocess_init_fn: Callable[[Any], None],
|
||||
subprocess_init_args: tuple[Any, ...],
|
||||
checkpoint_writer_init_fn: Callable[..., CheckpointWriter],
|
||||
checkpoint_writer_init_args: dict[str, Any],
|
||||
):
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
self._rank_info = rank_info
|
||||
self._config = config
|
||||
self._subprocess_init_fn = subprocess_init_fn
|
||||
self._subprocess_init_args = subprocess_init_args
|
||||
self._checkpoint_writer_init_fn = checkpoint_writer_init_fn
|
||||
self._checkpoint_writer_init_args = checkpoint_writer_init_args
|
||||
self.process = None
|
||||
self._parent_end: Connection | None = None
|
||||
self._child_end: Connection | None = None
|
||||
|
||||
self.process_creation_future = self._executor.submit(
|
||||
self._create_subprocess,
|
||||
config,
|
||||
)
|
||||
|
||||
def _create_subprocess(
|
||||
self,
|
||||
config: CheckpointProcessConfig,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"Creating checkpoint subprocess for rank %d", self._rank_info.global_rank
|
||||
)
|
||||
|
||||
spawn_context = mp.get_context("spawn")
|
||||
self._parent_end, child_end = spawn_context.Pipe()
|
||||
|
||||
# Known workaround for https://github.com/pytorch/pytorch/issues/37377
|
||||
os.environ["MKL_SERVICE_FORCE_INTEL"] = "GNU"
|
||||
|
||||
logger.debug("Spawning subprocess for rank_info=%s", self._rank_info)
|
||||
self.process = mp.spawn(
|
||||
fn=CheckpointProcess._subprocess,
|
||||
args=(
|
||||
self._rank_info,
|
||||
child_end,
|
||||
self._subprocess_init_fn,
|
||||
self._subprocess_init_args,
|
||||
self._checkpoint_writer_init_fn,
|
||||
self._checkpoint_writer_init_args,
|
||||
),
|
||||
nprocs=1,
|
||||
join=False,
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
# close the child end of the pipe so recv on it will fail
|
||||
# fast when the child process is terminated unexpectedly.
|
||||
child_end.close()
|
||||
self._send(
|
||||
request_type=RequestType.PING,
|
||||
payload={},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Waiting for checkpoint subprocess to initialize (timeout: %ds)",
|
||||
config.subprocess_init_timeout_secs,
|
||||
)
|
||||
|
||||
# wait for the timeout or a response from subprocess
|
||||
if self._parent_end is None:
|
||||
raise AssertionError("Parent end of pipe should be initialized")
|
||||
if not self._parent_end.poll(timeout=config.subprocess_init_timeout_secs):
|
||||
msg = f"Timed out after {config.subprocess_init_timeout_secs}s waiting for checkpoint subprocess to initialize"
|
||||
logger.error(msg)
|
||||
raise TimeoutError(msg)
|
||||
|
||||
self._recv()
|
||||
logger.info("Checkpoint subprocess initialized successfully")
|
||||
|
||||
@staticmethod
|
||||
def _subprocess(
|
||||
sub_rank: int,
|
||||
rank_info: RankInfo,
|
||||
parent_pipe: Connection,
|
||||
subprocess_init_fn: Callable[[Any], None],
|
||||
subprocess_init_args: tuple[Any, ...],
|
||||
checkpoint_writer_init_fn: Callable[..., CheckpointWriter],
|
||||
checkpoint_writer_init_args: dict[str, Any],
|
||||
) -> None:
|
||||
logger.debug(
|
||||
"Checkpoint subprocess started for rank %d/%d (PID: %d)",
|
||||
rank_info.global_rank,
|
||||
rank_info.global_world_size,
|
||||
os.getpid(),
|
||||
)
|
||||
|
||||
if sub_rank != 0:
|
||||
raise AssertionError("We need only one checkpointer per parent training")
|
||||
request = WorkerRequest(request_type=RequestType.PING, payload={})
|
||||
|
||||
try:
|
||||
# Calling initialize callback, so we can perform app-specific initialization of the subprocess.
|
||||
subprocess_init_fn(*subprocess_init_args)
|
||||
|
||||
# Initialize checkpoint writer - automatically include rank_info in init_args
|
||||
writer_init_args = dict(checkpoint_writer_init_args)
|
||||
if "rank_info" not in writer_init_args:
|
||||
writer_init_args["rank_info"] = rank_info
|
||||
checkpoint_writer = checkpoint_writer_init_fn(**writer_init_args)
|
||||
|
||||
while True:
|
||||
request = parent_pipe.recv()
|
||||
|
||||
if request.request_type == RequestType.PING:
|
||||
parent_pipe.send(
|
||||
WorkerResponse(request_type=RequestType.PING, success=True)
|
||||
)
|
||||
elif request.request_type == RequestType.WRITE_CHECKPOINT:
|
||||
path = request.payload["path"]
|
||||
logger.info("Writing checkpoint to %s", path)
|
||||
|
||||
checkpoint_writer.write(
|
||||
path=path,
|
||||
state_dict=request.payload["state_dict"],
|
||||
**request.payload["kwargs"],
|
||||
)
|
||||
|
||||
logger.info("Checkpoint written successfully to %s", path)
|
||||
parent_pipe.send(
|
||||
WorkerResponse(RequestType.WRITE_CHECKPOINT, success=True)
|
||||
)
|
||||
elif request.request_type == RequestType.TERMINATE_PROCESS:
|
||||
logger.debug("Received termination request.")
|
||||
parent_pipe.send(
|
||||
WorkerResponse(RequestType.TERMINATE_PROCESS, success=True)
|
||||
)
|
||||
logger.info("Subprocess terminated gracefully")
|
||||
break
|
||||
else:
|
||||
error_msg = f"Unknown request type: {request.request_type}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_text = traceback.format_exc()
|
||||
logger.error(
|
||||
"Exception in subprocess (%s): %s", type(e).__name__, error_text
|
||||
)
|
||||
|
||||
# Communicating exception via the queue to the main process
|
||||
parent_pipe.send(
|
||||
WorkerResponse(
|
||||
request_type=request.request_type,
|
||||
success=False,
|
||||
error_msg=error_text,
|
||||
)
|
||||
)
|
||||
parent_pipe.close()
|
||||
logger.exception("Subprocess terminated due to exception")
|
||||
|
||||
def _send(self, request_type: RequestType, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
if self._parent_end is None:
|
||||
raise AssertionError("Parent end of pipe should be initialized")
|
||||
self._parent_end.send(
|
||||
WorkerRequest(
|
||||
request_type=request_type,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
except OSError as e:
|
||||
error_msg = "Child process terminated unexpectedly"
|
||||
logger.exception(
|
||||
"Communication failed during %s request", request_type.value
|
||||
)
|
||||
raise RuntimeError(error_msg) from e
|
||||
|
||||
def _recv(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
if self._parent_end is None:
|
||||
raise AssertionError("Parent end of pipe should be initialized")
|
||||
response = self._parent_end.recv()
|
||||
if response.success is False:
|
||||
error_msg = (
|
||||
f"Unexpected response from worker process: {response.error_msg}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
return response.payload
|
||||
except (EOFError, BrokenPipeError, ConnectionResetError) as e:
|
||||
error_msg = f"Child process terminated unexpectedly: {e}"
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg) from e
|
||||
|
||||
def write(
|
||||
self,
|
||||
state_dict: STATE_DICT | Future[STATE_DICT],
|
||||
path: str,
|
||||
**kwargs: Any,
|
||||
) -> Future[None] | None:
|
||||
logger.debug("Waiting for subprocess initialization to complete")
|
||||
|
||||
# wait until the process is started
|
||||
self.process_creation_future.result()
|
||||
|
||||
return self._executor.submit(
|
||||
self._write,
|
||||
state_dict,
|
||||
path,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _write(
|
||||
self,
|
||||
state_dict: STATE_DICT | Future[STATE_DICT],
|
||||
path: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
logger.debug("Starting checkpoint write to %s", path)
|
||||
|
||||
# wait for staging state_dict to be available
|
||||
if isinstance(state_dict, Future):
|
||||
logger.debug("Waiting for state_dict Future to resolve")
|
||||
sd = state_dict.result()
|
||||
else:
|
||||
sd = state_dict
|
||||
|
||||
# Log state_dict info only if debug logging is enabled (performance-conscious)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
if hasattr(sd, "keys"):
|
||||
logger.debug("State_dict contains %d keys", len(sd.keys()))
|
||||
|
||||
self._send(
|
||||
request_type=RequestType.WRITE_CHECKPOINT,
|
||||
payload={
|
||||
"state_dict": sd,
|
||||
"path": path,
|
||||
"kwargs": kwargs,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug("Waiting for write completion response")
|
||||
# wait for response
|
||||
self._recv()
|
||||
logger.debug("Checkpoint write to %s completed successfully", path)
|
||||
|
||||
def close(self) -> None:
|
||||
logger.debug(
|
||||
"Closing CheckpointProcess for rank %d", self._rank_info.global_rank
|
||||
)
|
||||
self._executor.shutdown(wait=True, cancel_futures=True)
|
||||
|
||||
if self.process and self.process.processes[0].is_alive():
|
||||
subprocess_pid = self.process.processes[0].pid
|
||||
# send graceful termination to sub process
|
||||
try:
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
self._parent_end.send(
|
||||
WorkerRequest(
|
||||
request_type=RequestType.TERMINATE_PROCESS,
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
except BrokenPipeError:
|
||||
logger.warning(
|
||||
"BrokenPipeError when sending termination request - subprocess (PID: %d) may have already terminated",
|
||||
subprocess_pid,
|
||||
)
|
||||
# subprocess terminated unexpectedly and below code will raise a
|
||||
# ProcessExitedException.
|
||||
|
||||
logger.debug(
|
||||
"Waiting for subprocess to terminate gracefully (timeout: %ds)",
|
||||
self._config.subprocess_shutdown_timeout_secs,
|
||||
)
|
||||
|
||||
try:
|
||||
if not self.process.join(
|
||||
timeout=self._config.subprocess_shutdown_timeout_secs
|
||||
):
|
||||
# graceful shutdown failed, kill the process.
|
||||
logger.warning(
|
||||
"Subprocess (PID: %d) did not terminate gracefully within %ds, killing it",
|
||||
subprocess_pid,
|
||||
self._config.subprocess_shutdown_timeout_secs,
|
||||
)
|
||||
self.process.processes[0].kill()
|
||||
logger.info("Subprocess killed forcefully")
|
||||
except ProcessExitedException:
|
||||
logger.exception("ProcessExitedException during subprocess termination")
|
||||
raise
|
||||
|
||||
logger.debug("CheckpointProcess closed successfully")
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Checkpoint reader functionality for machine learning models.
|
||||
|
||||
This module provides classes for reading checkpoints from storage, including
|
||||
determining checkpoint layout and configuring the reader.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch._subclasses.fake_tensor import FakeTensorMode
|
||||
|
||||
from .types import RankInfo, STATE_DICT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckpointReader:
|
||||
"""
|
||||
Handles reading state dictionaries from storage.
|
||||
|
||||
This class is responsible for reading model state dictionaries from storage according
|
||||
to the specified checkpoint layout. It supports synchronization barriers to ensure
|
||||
all ranks in a distributed setting complete their checkpoint operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rank_info: RankInfo,
|
||||
):
|
||||
"""
|
||||
Initialize a CheckpointReader.
|
||||
|
||||
Args:
|
||||
rank_info: Information about the current rank in a distributed setting.
|
||||
"""
|
||||
|
||||
self._rank_info = rank_info
|
||||
|
||||
def read(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT | None = None,
|
||||
*,
|
||||
map_location: Any = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> tuple[STATE_DICT, list[str]]:
|
||||
"""
|
||||
Reads a state dictionary from storage.
|
||||
|
||||
Args:
|
||||
path (str): The path from which to read the checkpoint.
|
||||
map_location (Any): Device mapping function or device name for relocating tensors.
|
||||
**kwargs: Additional keyword arguments passed to torch.load.
|
||||
|
||||
Returns:
|
||||
STATE_DICT: The loaded state dictionary.
|
||||
list[str]: List of missing keys.
|
||||
"""
|
||||
logger.debug(
|
||||
"Reading checkpoint from %s for rank %s",
|
||||
path,
|
||||
self._rank_info.global_rank,
|
||||
)
|
||||
|
||||
dir_path = Path(path)
|
||||
file_path = dir_path / f"checkpoint_{self._rank_info.global_rank}.pt"
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
logger.error("Checkpoint file not found at %s", file_path)
|
||||
raise FileNotFoundError(f"Checkpoint file not found at {file_path}")
|
||||
|
||||
if state_dict is None:
|
||||
result: tuple[STATE_DICT, list[str]] = (
|
||||
torch.load(file_path, map_location=map_location),
|
||||
[],
|
||||
)
|
||||
else:
|
||||
result = self._partial_read(
|
||||
file_path, state_dict, map_location=map_location, **kwargs
|
||||
)
|
||||
logger.debug("Successfully read checkpoint file from %s", file_path)
|
||||
return result
|
||||
|
||||
def _partial_read(
|
||||
self,
|
||||
file_path: Path,
|
||||
state_dict: STATE_DICT,
|
||||
*,
|
||||
map_location: Any = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> tuple[STATE_DICT, list[str]]:
|
||||
"""
|
||||
Reads only the keys present in state_dict from the checkpoint file.
|
||||
|
||||
This method optimizes checkpoint loading by only loading the tensors that
|
||||
are actually needed, based on the keys present in the input state_dict.
|
||||
This can significantly reduce memory usage and loading time for large checkpoints
|
||||
when only a subset of the model needs to be loaded.
|
||||
|
||||
Args:
|
||||
file_path (str): The path to the checkpoint file.
|
||||
state_dict (STATE_DICT): The state dictionary containing keys to load.
|
||||
map_location (Any): Device mapping function or device name for relocating tensors.
|
||||
**kwargs: Additional keyword arguments passed to torch.load.
|
||||
|
||||
Returns:
|
||||
tuple[STATE_DICT, list[str]]: The updated state dictionary with loaded values and a list of missing keys.
|
||||
"""
|
||||
|
||||
with FakeTensorMode():
|
||||
metadata_dict = torch.load(file_path, map_location=map_location)
|
||||
|
||||
missing_keys = []
|
||||
|
||||
with open(file_path, "rb") as file:
|
||||
# Helper function to load tensor data from file
|
||||
def load_tensor(
|
||||
target: torch.Tensor | None, source: torch.Tensor, full_key: str
|
||||
) -> torch.Tensor:
|
||||
if target is not None and (
|
||||
target.size() != source.size() or target.dtype != source.dtype
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Target tensor size={target.size()} dtype={target.dtype} does not match "
|
||||
f"source tensor size={source.size()} dtype={source.dtype} for key {full_key}"
|
||||
)
|
||||
|
||||
tensor_offset = source.untyped_storage()._checkpoint_offset
|
||||
|
||||
if tensor_offset is None:
|
||||
raise AssertionError(
|
||||
"checkpoint_offset for tensor in torch serialized file is not set. This could "
|
||||
"happen if the checkpoint was saved with a older version of Pytorch. "
|
||||
"Please make sure that the checkpoint was saved with Pytorch 2.7 or later."
|
||||
)
|
||||
|
||||
tensor_len = source.nelement() * source.element_size()
|
||||
file.seek(
|
||||
tensor_offset + source.element_size() * int(source.storage_offset())
|
||||
)
|
||||
if target is None:
|
||||
target = torch.empty(
|
||||
source.size(), dtype=source.dtype, device=source.device
|
||||
)
|
||||
|
||||
buffer = file.read(tensor_len)
|
||||
cpu_tensor = torch.frombuffer(buffer, dtype=source.dtype)
|
||||
tensor = cpu_tensor.view(source.size())
|
||||
target.copy_(tensor)
|
||||
return target
|
||||
|
||||
# Helper function to recursively process nested structures
|
||||
def process_value(
|
||||
target_value: Any, source_value: Any, key_path: str
|
||||
) -> Any:
|
||||
source_type = type(source_value)
|
||||
if source_type is torch._subclasses.fake_tensor.FakeTensor:
|
||||
source_type = torch.Tensor
|
||||
if target_value is not None and not isinstance(
|
||||
target_value, source_type
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Target value {key_path} is set to {type(target_value)}, but source value is {type(source_value)}"
|
||||
)
|
||||
if isinstance(source_value, torch.Tensor):
|
||||
return load_tensor(target_value, source_value, key_path)
|
||||
elif isinstance(source_value, dict):
|
||||
if target_value is None:
|
||||
# create a new map with all the keys present in source_value
|
||||
target_value = dict.fromkeys(source_value.keys())
|
||||
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
for key in list(target_value.keys()):
|
||||
current_path = f"{key_path}.{key}" if key_path else key
|
||||
if key in source_value:
|
||||
target_value[key] = process_value(
|
||||
target_value[key], source_value[key], current_path
|
||||
)
|
||||
else:
|
||||
missing_keys.append(current_path)
|
||||
|
||||
return target_value
|
||||
elif isinstance(source_value, list):
|
||||
if target_value is None:
|
||||
target_value = [None] * len(source_value)
|
||||
result = []
|
||||
for i, (target_item, source_item) in enumerate(
|
||||
zip_longest(target_value, source_value, fillvalue=None)
|
||||
):
|
||||
current_path = f"{key_path}[{i}]" if key_path else f"[{i}]"
|
||||
result.append(
|
||||
process_value(target_item, source_item, current_path)
|
||||
)
|
||||
return result
|
||||
else:
|
||||
return source_value
|
||||
|
||||
# Start recursive processing from the root of the state dictionary
|
||||
updated_state_dict = process_value(state_dict, metadata_dict, "")
|
||||
|
||||
if missing_keys:
|
||||
if len(missing_keys) > 10:
|
||||
logger.warning(
|
||||
"Missing %s keys from checkpoint: %s... (and %s more)",
|
||||
len(missing_keys),
|
||||
missing_keys[:10],
|
||||
len(missing_keys) - 10,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Missing %s keys from checkpoint: %s",
|
||||
len(missing_keys),
|
||||
missing_keys,
|
||||
)
|
||||
|
||||
return updated_state_dict, missing_keys
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Checkpoint writer functionality for machine learning models.
|
||||
|
||||
This module provides classes for writing checkpoints to storage, including
|
||||
determining checkpoint layout, configuring the writer, and defining hooks
|
||||
for custom actions during the checkpoint writing process.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from .barriers import Barrier
|
||||
from .types import RankInfo, STATE_DICT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WriterHook(abc.ABC):
|
||||
"""
|
||||
Abstract base class for checkpoint commit hooks.
|
||||
|
||||
A commit hook provides callbacks that are executed before and after a checkpoint
|
||||
is committed to storage. This allows for custom actions to be performed at specific
|
||||
points in the checkpoint writing process, such as metadata updates, cleanup operations,
|
||||
or notifications.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def pre_commit(self, path: str, **kwargs: dict[str, Any]) -> None:
|
||||
"""
|
||||
Performs actions before committing the checkpoint.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def post_commit(self, path: str, **kwargs: dict[str, Any]) -> None:
|
||||
"""
|
||||
Performs actions after committing the checkpoint.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointWriterConfig:
|
||||
"""
|
||||
Configuration options for the CheckpointWriter.
|
||||
|
||||
Attributes:
|
||||
write_barrier_timeout_secs: Maximum time in seconds to wait for all ranks
|
||||
to reach the checkpoint barrier before timing out. Default is 600 seconds.
|
||||
"""
|
||||
|
||||
write_barrier_timeout_secs: int = 600
|
||||
|
||||
|
||||
class CheckpointWriter:
|
||||
"""
|
||||
Handles writing state dictionaries to storage.
|
||||
|
||||
This class is responsible for writing model state dictionaries to storage according
|
||||
to the specified checkpoint layout. It supports synchronization barriers to ensure
|
||||
all ranks in a distributed setting complete their checkpoint operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: CheckpointWriterConfig,
|
||||
rank_info: RankInfo,
|
||||
barrier: Barrier | None = None,
|
||||
commit_hook: WriterHook | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize a CheckpointWriter.
|
||||
|
||||
Args:
|
||||
config: Configuration options for the checkpoint writer.
|
||||
rank_info: Information about the current rank in a distributed setting.
|
||||
barrier: Optional synchronization barrier for distributed checkpointing.
|
||||
Note: The barrier should be initialized with the appropriate barrier_prefix
|
||||
and timeout_secs parameters.
|
||||
commit_hook: Optional hook for custom actions before and after checkpoint commits.
|
||||
"""
|
||||
|
||||
self._config = config
|
||||
self._rank_info = rank_info
|
||||
self._commit_hook = commit_hook
|
||||
self._barrier = barrier
|
||||
|
||||
def write(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Future[None] | None:
|
||||
"""
|
||||
Writes the state_dict to storage.
|
||||
|
||||
Args:
|
||||
path (str): The path to write the checkpoint to.
|
||||
state_dict (STATE_DICT): The state_dict to write.
|
||||
**kwargs: Additional keyword arguments passed to hooks.
|
||||
|
||||
Returns:
|
||||
Optional[Future[None]]: A future for tracking the write operation, if applicable.
|
||||
"""
|
||||
logger.debug(
|
||||
"Writing checkpoint to %s for rank %s",
|
||||
path,
|
||||
self._rank_info.global_rank,
|
||||
)
|
||||
dir_path = Path(path)
|
||||
full_path = dir_path / f"checkpoint_{self._rank_info.global_rank}.pt"
|
||||
os.makedirs(
|
||||
os.path.dirname(full_path),
|
||||
exist_ok=True,
|
||||
)
|
||||
torch.save(state_dict, full_path)
|
||||
logger.debug("Successfully saved checkpoint file to %s", full_path)
|
||||
|
||||
# Execute pre-commit hook if available
|
||||
commit_hook = self._commit_hook
|
||||
if commit_hook is not None:
|
||||
logger.debug("Executing pre-commit hook for %s", path)
|
||||
commit_hook.pre_commit(path, **kwargs)
|
||||
|
||||
# Wait for all ranks to finish writing if barrier is available
|
||||
barrier = self._barrier
|
||||
if barrier is not None:
|
||||
logger.info(
|
||||
"Waiting for all ranks at barrier with timeout %ss",
|
||||
self._config.write_barrier_timeout_secs,
|
||||
)
|
||||
barrier.execute_barrier()
|
||||
logger.info("All ranks passed barrier")
|
||||
else:
|
||||
logger.info("No barrier configured, skipping synchronization")
|
||||
|
||||
# Execute commit hook if available
|
||||
if commit_hook is not None:
|
||||
logger.debug("Executing commit hook for %s", path)
|
||||
commit_hook.post_commit(path, **kwargs)
|
||||
|
||||
logger.info(
|
||||
"Successfully wrote checkpoint to %s for rank %s",
|
||||
path,
|
||||
self._rank_info.global_rank,
|
||||
)
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the writer and release any resources.
|
||||
|
||||
This is a no-op for the base CheckpointWriter but may be overridden
|
||||
by subclasses that need to perform cleanup.
|
||||
"""
|
||||
logger.debug("Closing checkpoint writer")
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
import abc
|
||||
import logging
|
||||
from concurrent.futures import Future
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from .checkpoint_process import CheckpointProcess
|
||||
from .checkpoint_reader import CheckpointReader
|
||||
from .checkpoint_writer import CheckpointWriter
|
||||
from .staging import CheckpointStager
|
||||
from .types import STATE_DICT
|
||||
from .utils import wrap_future
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_INTERVAL = 60
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Checkpointer(abc.ABC):
|
||||
"""
|
||||
WARNING: This class is experimental, and is created to validate certain ideas,
|
||||
and is subjected to change or deprecation and we strong discourage any usages at
|
||||
this time.
|
||||
|
||||
Abstract base class that defines the API for checkpointing.
|
||||
|
||||
This class defines the interface for coordinating the writing and loading of model
|
||||
state dictionaries to and from storage. It provides abstract methods to save and load model states
|
||||
with support for both synchronous and asynchronous operations.
|
||||
|
||||
Concrete implementations of this class must implement all the abstract methods.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def save(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> tuple[Future, Future] | None:
|
||||
"""
|
||||
Save a state dictionary to storage.
|
||||
|
||||
Args:
|
||||
path: The path where the checkpoint should be saved.
|
||||
state_dict: The state dictionary to save.
|
||||
**kwargs: Additional keyword arguments to pass to the writer.
|
||||
|
||||
Returns:
|
||||
For synchronous implementations: None
|
||||
For asynchronous implementations: tuple of (stage_future, write_future)
|
||||
representing the staging and writing operations.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def load(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT | None = None,
|
||||
*,
|
||||
default_map_location: Any = None,
|
||||
strict: bool = False,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> STATE_DICT:
|
||||
"""
|
||||
Load a state dictionary from storage.
|
||||
|
||||
Args:
|
||||
path: The path from which to load the checkpoint.
|
||||
state_dict: Optional state dictionary to update with loaded values.
|
||||
If provided, only keys in this dictionary will be loaded.
|
||||
default_map_location: Device mapping function or device name for relocating tensors.
|
||||
strict: If True, raises an error when there are missing keys in the checkpoint.
|
||||
**kwargs: Additional keyword arguments to pass to the reader.
|
||||
|
||||
Returns:
|
||||
The loaded state dictionary.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the checkpointer and release any resources.
|
||||
|
||||
This method should be called when the checkpointer is no longer needed to ensure
|
||||
proper cleanup of resources.
|
||||
"""
|
||||
|
||||
|
||||
class SyncCheckpointer(Checkpointer):
|
||||
"""
|
||||
Synchronous implementation of Checkpointer.
|
||||
|
||||
This class coordinates the writing and loading of model state dictionaries to and from storage
|
||||
using only synchronous operations. It provides a simple, efficient interface for checkpoint
|
||||
operations without async overhead.
|
||||
|
||||
Attributes:
|
||||
_writer: CheckpointWriter for writing state dictionaries to storage.
|
||||
_reader: CheckpointReader for reading state dictionaries from storage.
|
||||
|
||||
Example:
|
||||
checkpointer = SyncCheckpointer(writer=writer, reader=reader)
|
||||
checkpointer.save(state_dict, path)
|
||||
loaded_state_dict = checkpointer.load(path)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
writer: CheckpointWriter,
|
||||
reader: CheckpointReader,
|
||||
):
|
||||
"""
|
||||
Initialize a synchronous checkpointer.
|
||||
|
||||
Args:
|
||||
writer: CheckpointWriter for writing checkpoints to storage.
|
||||
reader: CheckpointReader for reading checkpoints from storage.
|
||||
"""
|
||||
self._writer = writer
|
||||
self._reader = reader
|
||||
|
||||
def save(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> tuple[Future, Future] | None:
|
||||
"""
|
||||
Save a state dictionary to storage synchronously.
|
||||
|
||||
Args:
|
||||
path: The path where the checkpoint should be saved.
|
||||
state_dict: The state dictionary to save.
|
||||
**kwargs: Additional keyword arguments to pass to the writer.
|
||||
|
||||
Returns:
|
||||
Always returns None as operations are synchronous.
|
||||
|
||||
Example:
|
||||
checkpointer.save("/path/to/checkpoint", state_dict)
|
||||
"""
|
||||
logger.debug("Saving checkpoint synchronously to %s", path)
|
||||
self._writer.write(path, state_dict, **kwargs)
|
||||
return None
|
||||
|
||||
def load(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT | None = None,
|
||||
*,
|
||||
default_map_location: Any = None,
|
||||
strict: bool = False,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> STATE_DICT:
|
||||
"""
|
||||
Load a state dictionary from storage.
|
||||
|
||||
Args:
|
||||
path: The path from which to load the checkpoint.
|
||||
state_dict: Optional state dictionary to update with loaded values.
|
||||
If provided, only keys in this dictionary will be loaded.
|
||||
default_map_location: Device mapping function or device name for relocating tensors.
|
||||
strict: If True, raises an error when there are missing keys in the checkpoint.
|
||||
**kwargs: Additional keyword arguments to pass to the reader.
|
||||
|
||||
Returns:
|
||||
The loaded state dictionary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If strict=True and there are missing keys in the checkpoint.
|
||||
FileNotFoundError: If the checkpoint file is not found.
|
||||
"""
|
||||
logger.info("Loading checkpoint from %s", path)
|
||||
|
||||
loaded_state_dict, missing_keys = self._reader.read(
|
||||
path=path,
|
||||
state_dict=state_dict,
|
||||
map_location=default_map_location,
|
||||
**kwargs,
|
||||
)
|
||||
if strict and missing_keys is not None and missing_keys != []:
|
||||
raise RuntimeError(f"Checkpoint at {path} is missing keys: {missing_keys}")
|
||||
return loaded_state_dict
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the checkpointer and release any resources.
|
||||
|
||||
This method should be called when the checkpointer is no longer needed to ensure
|
||||
proper cleanup of resources.
|
||||
"""
|
||||
self._writer.close()
|
||||
logger.info("SyncCheckpointer closed")
|
||||
|
||||
|
||||
class AsyncCheckpointer(Checkpointer):
|
||||
"""
|
||||
Asynchronous implementation of Checkpointer.
|
||||
|
||||
This class coordinates the writing and loading of model state dictionaries to and from storage
|
||||
using asynchronous operations for saving. It provides efficient async checkpoint operations
|
||||
with staging and background writing capabilities.
|
||||
|
||||
Attributes:
|
||||
_reader: CheckpointReader for reading state dictionaries from storage.
|
||||
_checkpoint_stager: Stager for async operations.
|
||||
_checkpoint_process: Process for async operations.
|
||||
_write_future: Future representing the ongoing async write operation.
|
||||
|
||||
Example:
|
||||
checkpointer = AsyncCheckpointer(
|
||||
reader=reader,
|
||||
checkpoint_stager=stager,
|
||||
checkpoint_process=process
|
||||
)
|
||||
stage_future, write_future = checkpointer.save(state_dict, path)
|
||||
# ... do other work ...
|
||||
write_future.result() # Wait for completion
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_stager: CheckpointStager,
|
||||
checkpoint_process: CheckpointProcess,
|
||||
reader: CheckpointReader,
|
||||
):
|
||||
"""
|
||||
Initialize an asynchronous checkpointer.
|
||||
|
||||
Args:
|
||||
checkpoint_stager: Stager for async operations.
|
||||
checkpoint_process: Process for async operations.
|
||||
reader: CheckpointReader for reading checkpoints from storage.
|
||||
"""
|
||||
self._reader = reader
|
||||
self._checkpoint_stager = checkpoint_stager
|
||||
self._checkpoint_process = checkpoint_process
|
||||
self._write_future: Future[Any] | None = None
|
||||
|
||||
def save(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: Any,
|
||||
) -> tuple[Future, Future] | None:
|
||||
"""
|
||||
Save a state dictionary to storage asynchronously.
|
||||
|
||||
Args:
|
||||
path: The path where the checkpoint should be saved.
|
||||
state_dict: The state dictionary to save.
|
||||
**kwargs: Additional keyword arguments to pass to the stager and writer.
|
||||
|
||||
Returns:
|
||||
A tuple of (stage_future, write_future) representing the staging and writing operations.
|
||||
|
||||
Example:
|
||||
stage_future, write_future = checkpointer.save("/path/to/checkpoint", state_dict)
|
||||
# ... do other work ...
|
||||
write_future.result() # Wait for completion
|
||||
"""
|
||||
logger.info(
|
||||
"Initiating checkpoint save to %s. Will wait for prev checkpoints to complete.",
|
||||
path,
|
||||
)
|
||||
# Wait for previous checkpoint ops to finish and verify they are successful
|
||||
if self._write_future is not None:
|
||||
self._write_future.result()
|
||||
|
||||
logger.debug("Starting state dictionary staging")
|
||||
staging_result = self._checkpoint_stager.stage(
|
||||
state_dict=state_dict,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
logger.debug("Starting checkpoint write to %s", path)
|
||||
self._write_future = self._checkpoint_process.write(
|
||||
staging_result, path, **kwargs
|
||||
)
|
||||
logger.info("Checkpoint save to %s initiated", path)
|
||||
|
||||
# Return futures for the staging and writing operations
|
||||
if self._write_future is not None:
|
||||
return wrap_future(staging_result), self._write_future
|
||||
else:
|
||||
# This should not happen since we just assigned _write_future above
|
||||
raise RuntimeError("Write future is unexpectedly None")
|
||||
|
||||
def load(
|
||||
self,
|
||||
path: str,
|
||||
state_dict: STATE_DICT | None = None,
|
||||
*,
|
||||
default_map_location: Any = None,
|
||||
strict: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> STATE_DICT:
|
||||
"""
|
||||
Load a state dictionary from storage.
|
||||
|
||||
Loading is always performed synchronously, even in AsyncCheckpointer.
|
||||
|
||||
Args:
|
||||
path: The path from which to load the checkpoint.
|
||||
state_dict: Optional state dictionary to update with loaded values.
|
||||
If provided, only keys in this dictionary will be loaded.
|
||||
default_map_location: Device mapping function or device name for relocating tensors.
|
||||
strict: If True, raises an error when there are missing keys in the checkpoint.
|
||||
**kwargs: Additional keyword arguments to pass to the reader.
|
||||
|
||||
Returns:
|
||||
The loaded state dictionary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If strict=True and there are missing keys in the checkpoint.
|
||||
FileNotFoundError: If the checkpoint file is not found.
|
||||
"""
|
||||
logger.info("Loading checkpoint from %s", path)
|
||||
|
||||
loaded_state_dict, missing_keys = self._reader.read(
|
||||
path=path,
|
||||
state_dict=state_dict,
|
||||
map_location=default_map_location,
|
||||
**kwargs,
|
||||
)
|
||||
if strict and missing_keys is not None and missing_keys != []:
|
||||
raise RuntimeError(f"Checkpoint at {path} is missing keys: {missing_keys}")
|
||||
return loaded_state_dict
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the checkpointer and release any resources.
|
||||
|
||||
This method should be called when the checkpointer is no longer needed to ensure
|
||||
proper cleanup of async resources.
|
||||
"""
|
||||
self._checkpoint_stager.close()
|
||||
self._checkpoint_process.close()
|
||||
logger.info("AsyncCheckpointer closed")
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Configuration classes for checkpointer construction.
|
||||
|
||||
This module provides configuration dataclasses that consolidate all
|
||||
configuration options needed to construct checkpointers.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .barriers import BarrierConfig
|
||||
from .checkpoint_process import CheckpointProcessConfig
|
||||
from .checkpoint_writer import CheckpointWriterConfig
|
||||
from .staging import CheckpointStagerConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointerConfig:
|
||||
"""
|
||||
Configuration class for checkpointer construction.
|
||||
|
||||
This class consolidates the core component configuration options needed to construct
|
||||
a checkpointer, providing a clean separation of concerns where each component
|
||||
manages its own configuration.
|
||||
|
||||
Attributes:
|
||||
writer_config: Configuration options for the checkpoint writer component.
|
||||
barrier_config: Configuration for barrier construction and arguments.
|
||||
staging_config: Configuration options for the async staging component.
|
||||
process_config: Configuration options for the async checkpoint process component.
|
||||
|
||||
"""
|
||||
|
||||
writer_config: CheckpointWriterConfig = field(
|
||||
default_factory=CheckpointWriterConfig
|
||||
)
|
||||
barrier_config: BarrierConfig = field(default_factory=BarrierConfig)
|
||||
|
||||
# Below configs are used for async checkpointing
|
||||
staging_config: CheckpointStagerConfig = field(
|
||||
default_factory=CheckpointStagerConfig
|
||||
)
|
||||
process_config: CheckpointProcessConfig = field(
|
||||
default_factory=CheckpointProcessConfig
|
||||
)
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Experimental staging module for PyTorch Distributed Checkpointing.
|
||||
|
||||
This module provides advanced staging capabilities for checkpoints including:
|
||||
- Asynchronous staging using ThreadPoolExecutor
|
||||
- Pinned memory allocation for faster CPU-GPU transfers
|
||||
- Shared memory support for multi-process scenarios
|
||||
- Non-blocking CUDA operations with stream synchronization
|
||||
- Caching of frequently used storages for efficient memory management
|
||||
- Automatic resource cleanup and memory management
|
||||
|
||||
Classes:
|
||||
CheckpointStager: Abstract base class defining the staging interface
|
||||
StagingOptions: Configuration dataclass for staging behavior
|
||||
DefaultStager: Default implementation with comprehensive staging features
|
||||
"""
|
||||
|
||||
import abc
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import torch
|
||||
from torch.distributed.checkpoint._state_dict_stager import StateDictStager
|
||||
|
||||
from .types import STATE_DICT
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class CheckpointStager(abc.ABC):
|
||||
"""
|
||||
Abstract base class for checkpoint staging implementations.
|
||||
|
||||
CheckpointStager defines the interface that all staging implementations
|
||||
must follow. Staging is the process of offloading state dictionaries
|
||||
for async checkpointing.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def stage(
|
||||
self,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: Any,
|
||||
) -> STATE_DICT | Future[STATE_DICT]:
|
||||
"""
|
||||
Stage a state dictionary for checkpointing.
|
||||
|
||||
Args:
|
||||
state_dict: The state dictionary to stage
|
||||
**kwargs: Additional staging parameters
|
||||
|
||||
Returns:
|
||||
Either a staged state dictionary (synchronous) or a Future
|
||||
that will resolve to the staged state dictionary (asynchronous)
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Clean up all resources used by the stager.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointStagerConfig:
|
||||
"""
|
||||
Configuration options for checkpoint staging behavior.
|
||||
|
||||
Attributes:
|
||||
use_pinned_memory (bool): Enable pinned memory allocation for faster
|
||||
CPU-GPU transfers. Requires CUDA to be available. Default: True
|
||||
use_shared_memory (bool): Enable shared memory for multi-process
|
||||
scenarios. Useful when multiple processes need access to the
|
||||
same staged data. Default: True
|
||||
use_async_staging (bool): Enable asynchronous staging using a
|
||||
background thread pool. Allows overlapping computation with
|
||||
staging operations. Requires CUDA. Default: True
|
||||
use_non_blocking_copy (bool): Use non-blocking device memory
|
||||
copies with stream synchronization. Improves performance by
|
||||
allowing CPU work to continue during GPU transfers. Default: True
|
||||
|
||||
Note:
|
||||
CUDA-dependent features will raise exception if CUDA is not available.
|
||||
"""
|
||||
|
||||
use_pinned_memory: bool = True
|
||||
use_shared_memory: bool = True
|
||||
use_async_staging: bool = True
|
||||
use_non_blocking_copy: bool = True
|
||||
|
||||
|
||||
class DefaultStager(CheckpointStager):
|
||||
"""
|
||||
DefaultStager provides a full-featured staging implementation that combines
|
||||
multiple optimization techniques for efficient checkpoint preparation.
|
||||
|
||||
The staging process works as follows:
|
||||
1. State dictionary is submitted for staging (sync or async)
|
||||
2. Tensors are copied from GPU to optimized CPU storage
|
||||
3. CUDA operations are synchronized if non-blocking copies are used
|
||||
4. Staged state dictionary is returned or made available via Future
|
||||
|
||||
NOTE: state_dict should be deep-copyable object as staging will create a
|
||||
copy of it.
|
||||
|
||||
Usage Patterns:
|
||||
# Synchronous staging
|
||||
stager = DefaultStager(CheckpointStagerConfig(use_async_staging=False))
|
||||
staged_dict = stager.stage(state_dict)
|
||||
stager.close()
|
||||
|
||||
# Asynchronous staging
|
||||
stager = DefaultStager(CheckpointStagerConfig(use_async_staging=True))
|
||||
future = stager.stage(state_dict)
|
||||
# ... do other work ...
|
||||
staged_dict = future.result()
|
||||
stager.close()
|
||||
|
||||
# Context manager pattern (recommended)
|
||||
with DefaultStager(config) as stager:
|
||||
result = stager.stage(state_dict)
|
||||
# Automatic cleanup on exit
|
||||
|
||||
Performance Considerations:
|
||||
- Async staging provides best performance when model computation
|
||||
can overlap with staging operations
|
||||
- Pinned memory improves CPU-GPU transfer speeds but uses more memory
|
||||
- Shared memory allows efficient IPC to checkpoint process
|
||||
- Non-blocking copies reduce GPU idle time during memory transfers
|
||||
|
||||
Thread Safety:
|
||||
DefaultStager is not thread-safe. Each thread should use its own
|
||||
instance, or external synchronization should be provided.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: CheckpointStagerConfig = CheckpointStagerConfig(),
|
||||
):
|
||||
self._config = config
|
||||
self._state_dict_stager = StateDictStager(
|
||||
pin_memory=config.use_pinned_memory, share_memory=config.use_shared_memory
|
||||
)
|
||||
self._staging_executor = None
|
||||
self._staging_stream = None
|
||||
|
||||
if self._config.use_async_staging:
|
||||
self._staging_executor = ThreadPoolExecutor(max_workers=1)
|
||||
if torch.accelerator.is_available():
|
||||
# Note: stream needs to be initialized on the main thread after default cuda
|
||||
# stream is setup/used to avoid the risk of accidentally reusing the main
|
||||
# compute stream or in other cases kernels actually launching from the
|
||||
# main thread.
|
||||
self._staging_stream = torch.Stream()
|
||||
|
||||
if self._config.use_non_blocking_copy:
|
||||
if not torch.accelerator.is_available():
|
||||
raise AssertionError(
|
||||
"Non-blocking copy requires that the current accelerator is available."
|
||||
)
|
||||
|
||||
def stage(
|
||||
self,
|
||||
state_dict: STATE_DICT,
|
||||
**kwargs: Any,
|
||||
) -> STATE_DICT | Future[STATE_DICT]:
|
||||
if self._config.use_async_staging:
|
||||
if self._staging_executor is None:
|
||||
raise AssertionError(
|
||||
"Staging executor should be initialized for async staging"
|
||||
)
|
||||
return self._staging_executor.submit(
|
||||
self._stage,
|
||||
state_dict,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
return self._stage(state_dict, **kwargs)
|
||||
|
||||
def _stage(self, state_dict: STATE_DICT, **kwargs: Any) -> STATE_DICT:
|
||||
state_dict = self._state_dict_stager.stage(
|
||||
state_dict, non_blocking=self._config.use_non_blocking_copy, **kwargs
|
||||
)
|
||||
|
||||
if self._config.use_non_blocking_copy:
|
||||
if not (self._staging_stream or not self._config.use_async_staging):
|
||||
raise AssertionError(
|
||||
"Non-blocking copy in a background thread for async staging needs staging_stream to be initialized."
|
||||
)
|
||||
|
||||
# waits for the enqued copy operations to finish.
|
||||
self._staging_stream.synchronize() if self._staging_stream else torch.accelerator.synchronize()
|
||||
|
||||
return state_dict
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Clean up all resources used by the DefaultStager. Shuts down the ThreadPoolExecutor
|
||||
used for async staging operations and cleans up the underlying StateDictStager's
|
||||
cached storages. Should be called when the stager is no longer needed to prevent
|
||||
resource leaks, especially in long-running applications. After calling close(),
|
||||
the stager should not be used for further staging operations.
|
||||
|
||||
state_dict should be deep-copyable object.
|
||||
|
||||
Example:
|
||||
stager = DefaultStager(CheckpointStagerConfig(use_async_staging=True))
|
||||
# ... do staging operations ...
|
||||
stager.close() # Clean up all resources
|
||||
"""
|
||||
if self._staging_executor:
|
||||
self._staging_executor.shutdown(wait=True)
|
||||
|
||||
self._state_dict_stager.close()
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Type definitions for distributed training and checkpointing.
|
||||
|
||||
This module provides type definitions and classes for managing rank information
|
||||
in distributed training environments, which is essential for proper checkpoint
|
||||
saving and loading.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
|
||||
# Type alias for state dictionaries used in checkpointing
|
||||
STATE_DICT: TypeAlias = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RankInfo:
|
||||
"""
|
||||
Information about the current rank in a distributed training environment.
|
||||
|
||||
Attributes:
|
||||
global_rank: The global rank ID of the current process.
|
||||
global_world_size: The total number of processes in the distributed environment.
|
||||
"""
|
||||
|
||||
global_rank: int
|
||||
global_world_size: int
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Utility functions for the experimental checkpoint module.
|
||||
|
||||
This module contains helper functions and utilities used across the experimental
|
||||
checkpoint functionality.
|
||||
"""
|
||||
|
||||
from concurrent.futures import Future
|
||||
from typing import Any
|
||||
|
||||
|
||||
def wrap_future(original_result: Any) -> Future[None]:
|
||||
"""
|
||||
Wraps a result (Future or not) to return a Future with None result.
|
||||
|
||||
If the input is a Future, returns a new Future that completes with None when
|
||||
the original Future completes successfully, or propagates any exception.
|
||||
If the input is not a Future, returns a completed Future with None result.
|
||||
|
||||
Args:
|
||||
original_result: The result to wrap (Future or any other value).
|
||||
|
||||
Returns:
|
||||
A Future that completes with None on success or propagates exceptions.
|
||||
"""
|
||||
masked_future: Future[None] = Future()
|
||||
|
||||
if isinstance(original_result, Future):
|
||||
|
||||
def on_complete(_: Future[Any]) -> None:
|
||||
try:
|
||||
original_result.result()
|
||||
masked_future.set_result(None)
|
||||
except Exception as e:
|
||||
masked_future.set_exception(e)
|
||||
|
||||
original_result.add_done_callback(on_complete)
|
||||
else:
|
||||
# Return a completed future with None result
|
||||
masked_future.set_result(None)
|
||||
|
||||
return masked_future
|
||||
@@ -0,0 +1,222 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
import abc
|
||||
import io
|
||||
from collections.abc import Sequence
|
||||
from typing import cast, IO
|
||||
|
||||
# introduced as collections.abc.Buffer in Python 3.12
|
||||
from typing_extensions import Buffer
|
||||
|
||||
from torch._utils import try_import
|
||||
|
||||
|
||||
# NOTE: everything in this file is experimental, and subject to
|
||||
# change. Feedback and bug fixes are always welcome.
|
||||
|
||||
pyzstd_module_name = "pyzstd"
|
||||
pyzstd = try_import(pyzstd_module_name)
|
||||
zstandard_module_name = "zstandard"
|
||||
zstandard = try_import(zstandard_module_name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Extension",
|
||||
"StreamTransformExtension",
|
||||
"ZStandard",
|
||||
"ExtensionRegistry",
|
||||
]
|
||||
|
||||
|
||||
class Extension(abc.ABC):
|
||||
"""
|
||||
Extensions provide modular additions to functionality within distributed checkpointing,
|
||||
which affect the layout or format of the written artifacts. Extensions may be
|
||||
built into pytorch, or provided externally.
|
||||
|
||||
When writing, the caller provides a list of extension instances of the appropriate
|
||||
type. Each extension can output a descriptor which is used to reconstitute the
|
||||
extension at read-time.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def registry_name() -> str:
|
||||
"""
|
||||
See ExtensionRegistry.from_descriptor_list
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def from_descriptor(version: str) -> "Extension":
|
||||
"""
|
||||
See ExtensionRegistry.from_descriptor_list
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_descriptor(self) -> str:
|
||||
"""
|
||||
Return descriptor name to be included in metadata. The form should be
|
||||
"extension_name[@local-domain][/version]".
|
||||
"""
|
||||
|
||||
|
||||
class StreamTransformExtension(Extension):
|
||||
"""
|
||||
An extension which performs transformation on a byte stream, such as compression
|
||||
or encryption.
|
||||
|
||||
Implementations should try to be memory friendly and performant. For example, don't
|
||||
read the whole input, then transform it, and write it back. If at all possible, do it in
|
||||
chunks. But, don't read/transform/write one byte at a time, either.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def transform_to(self, output: IO[bytes]) -> IO[bytes]:
|
||||
"""
|
||||
Takes a writeable output stream, and generates a new stream which implements the
|
||||
output transform. Input data written to the returned stream will be transformed
|
||||
and written to the `output` argument stream.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def transform_from(self, input: IO[bytes]) -> IO[bytes]:
|
||||
"""
|
||||
Takes a readable input stream, and generates a new stream which implements the
|
||||
input transform. When the returned stream is read, data will be read from the
|
||||
'input' stream, transformed, and returned.
|
||||
"""
|
||||
|
||||
|
||||
class ZStandard(StreamTransformExtension):
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
return zstandard is not None or pyzstd is not None
|
||||
|
||||
@staticmethod
|
||||
def from_descriptor(version: str) -> "ZStandard":
|
||||
if version.partition(".")[0] != "1":
|
||||
raise ValueError(f"Unknown extension {version=}")
|
||||
if not ZStandard.is_available():
|
||||
raise ValueError(
|
||||
f"Stream with ZStandard compression cannot be processed because "
|
||||
f"no module named '{zstandard_module_name}' or '{pyzstd_module_name}'"
|
||||
)
|
||||
return ZStandard()
|
||||
|
||||
@staticmethod
|
||||
def registry_name() -> str:
|
||||
return "stream.zstd"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
if not ZStandard.is_available():
|
||||
raise ValueError(
|
||||
f"ZStandard extension is unavailable because no module named '{zstandard_module_name}' or '{pyzstd_module_name}'"
|
||||
)
|
||||
|
||||
def get_descriptor(self) -> str:
|
||||
return f"{self.registry_name()}/1"
|
||||
|
||||
def transform_to(self, output: IO[bytes]) -> IO[bytes]:
|
||||
if zstandard is not None:
|
||||
compressor = zstandard.ZstdCompressor() # type: ignore[union-attr]
|
||||
return compressor.stream_writer(output)
|
||||
|
||||
class Writer(io.RawIOBase):
|
||||
def __init__(self, output: IO[bytes]) -> None:
|
||||
self.output = output
|
||||
self.compressor = pyzstd.ZstdCompressor() # type: ignore[union-attr]
|
||||
|
||||
def writeable(self) -> bool:
|
||||
return True
|
||||
|
||||
def write(self, b: Buffer) -> int | None:
|
||||
outdata = self.compressor.compress(b)
|
||||
if outdata:
|
||||
self.output.write(outdata)
|
||||
return len(memoryview(b))
|
||||
|
||||
def flush(self) -> None:
|
||||
outdata = self.compressor.flush()
|
||||
if outdata:
|
||||
self.output.write(outdata)
|
||||
self.output.flush()
|
||||
|
||||
return cast(IO[bytes], Writer(output))
|
||||
|
||||
def transform_from(self, input: IO[bytes]) -> IO[bytes]:
|
||||
if zstandard is not None:
|
||||
decompressor = zstandard.ZstdDecompressor() # type: ignore[union-attr]
|
||||
return decompressor.stream_reader(input)
|
||||
|
||||
class Reader(io.RawIOBase):
|
||||
def __init__(self, input: IO[bytes]) -> None:
|
||||
self.input = input
|
||||
self.decompressor = pyzstd.EndlessZstdDecompressor() # type: ignore[union-attr]
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def readinto(self, b: Buffer) -> int | None:
|
||||
# This needs to read enough so it can decompress
|
||||
# something so the output doesn't look like EOF. This
|
||||
# means reading at least one block. The max block
|
||||
# size is 128KB, so we read that plus some
|
||||
# overhead to be sure.
|
||||
|
||||
if self.decompressor.needs_input:
|
||||
indata = self.input.read((128 + 6) * 1024)
|
||||
else:
|
||||
indata = b""
|
||||
|
||||
bview = memoryview(b)
|
||||
blen = len(bview)
|
||||
outdata = self.decompressor.decompress(indata, blen)
|
||||
if outdata is None:
|
||||
return None
|
||||
|
||||
count = len(outdata)
|
||||
bview[:count] = outdata
|
||||
return count
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return False
|
||||
|
||||
return cast(IO[bytes], Reader(input))
|
||||
|
||||
|
||||
class ExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
# Populate default registry contents
|
||||
self.extensions: dict[str, type[Extension]] = {
|
||||
cls.registry_name(): cls for cls in (ZStandard,)
|
||||
}
|
||||
|
||||
def register(self, cls: type[Extension]) -> None:
|
||||
self.extensions[cls.registry_name()] = cls
|
||||
|
||||
def from_descriptor_list(self, descriptors: Sequence[str]) -> Sequence[Extension]:
|
||||
"""
|
||||
Given a seuquence of descriptor strings as returned by
|
||||
Extension.get_descriptor at save time, creates a sequence of
|
||||
Extension instances. The name[@local-domain] preceding the
|
||||
version number is used to look up an implementation class in
|
||||
the registry, and the version is passed to the class's
|
||||
from_descriptor static method. If the registry contains no
|
||||
match, this will throw ValueError. If the from_descriptor
|
||||
method raises an exception, that will pass through to the
|
||||
caller.
|
||||
"""
|
||||
|
||||
def from_descriptor(desc: str) -> Extension:
|
||||
name, _, version = desc.partition("/")
|
||||
if version is None:
|
||||
version = 0
|
||||
ext = self.extensions.get(name)
|
||||
if not ext:
|
||||
raise ValueError(f"Unknown extension {name=}")
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
return ext.from_descriptor(version)
|
||||
|
||||
return [from_descriptor(desc) for desc in descriptors]
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
# Mypy will not try inferring the types of any 3rd party libraries installed.
|
||||
# mypy: ignore-errors
|
||||
|
||||
import io
|
||||
import os
|
||||
from collections.abc import Generator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fsspec.core import url_to_fs
|
||||
|
||||
from torch.distributed.checkpoint._extension import StreamTransformExtension
|
||||
from torch.distributed.checkpoint.filesystem import (
|
||||
FileSystemBase,
|
||||
FileSystemReader,
|
||||
FileSystemWriter,
|
||||
SerializationFormat,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fsspec import AbstractFileSystem
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FsspecWriter",
|
||||
"FsspecReader",
|
||||
]
|
||||
|
||||
|
||||
class FileSystem(FileSystemBase):
|
||||
def __init__(self) -> None:
|
||||
self.fs: AbstractFileSystem | None = None
|
||||
|
||||
@contextmanager
|
||||
def create_stream(
|
||||
self, path: str | os.PathLike, mode: str
|
||||
) -> Generator[io.IOBase, None, None]:
|
||||
if self.fs is None:
|
||||
raise AssertionError("fs should not be None")
|
||||
path = os.fspath(path)
|
||||
|
||||
# fsspec does not support concurrent transactions, and not all
|
||||
# AbstractFileSystem have working rollback implementations, so
|
||||
# just manually delete the file if necessary on errors.
|
||||
with self.fs.open(path, mode) as stream:
|
||||
try:
|
||||
yield stream
|
||||
except: # noqa: B001,E722
|
||||
if any(ch in mode for ch in "w+a"): # cleanup file if not read-only
|
||||
try:
|
||||
self.rm_file(path)
|
||||
except: # noqa: B001,E722
|
||||
pass
|
||||
raise
|
||||
|
||||
def concat_path(self, path: str | os.PathLike, suffix: str) -> str | os.PathLike:
|
||||
return os.path.join(path, suffix)
|
||||
|
||||
def init_path(self, path: str | os.PathLike, **kwargs) -> str | os.PathLike:
|
||||
self.fs, _ = url_to_fs(path, **kwargs)
|
||||
return path
|
||||
|
||||
def rename(self, path: str | os.PathLike, new_path: str | os.PathLike) -> None:
|
||||
self.fs.rename(path, new_path)
|
||||
|
||||
def mkdir(self, path: str | os.PathLike) -> None:
|
||||
self.fs.makedirs(path, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
if isinstance(checkpoint_id, Path):
|
||||
return False
|
||||
|
||||
try:
|
||||
url_to_fs(checkpoint_id)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def exists(self, path: str | os.PathLike) -> bool:
|
||||
return self.fs.exists(path)
|
||||
|
||||
def rm_file(self, path: str | os.PathLike) -> None:
|
||||
self.fs.rm(path)
|
||||
|
||||
def ls(self, path: str | os.PathLike) -> list[str]:
|
||||
# setting detail to False explicitly to keep the list[str] return type,
|
||||
# instead of the list[Dict] return type when detail=True
|
||||
return self.fs.ls(path, detail=False)
|
||||
|
||||
|
||||
# TODO: add the dcp.async_save mixin
|
||||
class FsspecWriter(FileSystemWriter):
|
||||
"""
|
||||
Basic implementation of StorageWriter using FFspec.
|
||||
|
||||
This implementation makes the following assumptions and simplifications:
|
||||
|
||||
* The checkpoint path is an empty or non-existing directory.
|
||||
* File creation is atomic
|
||||
|
||||
The checkpoint consist of one file per write request plus
|
||||
a `.metadata` file with the serialized metadata.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | os.PathLike,
|
||||
single_file_per_rank: bool = True,
|
||||
sync_files: bool = True,
|
||||
thread_count: int = 1,
|
||||
per_thread_copy_ahead: int = 10_000_000,
|
||||
overwrite: bool = True,
|
||||
_extensions: Sequence[StreamTransformExtension] | None = None,
|
||||
serialization_format: SerializationFormat = SerializationFormat.TORCH_SAVE,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the writer pointing to `path`.
|
||||
|
||||
Args:
|
||||
path: directory where the checkpoint will be written to.
|
||||
single_file_per_rank: Produce one file per rank instead of one file per tensor/blob. Default to True.
|
||||
sync_files : force files to be synced to permanent storage. Default to True.
|
||||
thread_count: Number of IO threads to use to write. Default to 1.
|
||||
per_thread_copy_ahead: How many bytes to copy from the GPU ahead of saving then. Default 10Mb.
|
||||
overwrite: Whether to allow overwriting existing checkpoints. Defaults to True.
|
||||
_extensions: Extensions to apply to output streams (EXPERIMENTAL)
|
||||
|
||||
N. B. If sync_files is disabled, there's no guarantee that the checkpoint will be consistent in the case of a failure.
|
||||
"""
|
||||
super().__init__(
|
||||
path,
|
||||
single_file_per_rank,
|
||||
sync_files,
|
||||
thread_count,
|
||||
per_thread_copy_ahead,
|
||||
overwrite=overwrite,
|
||||
_extensions=_extensions,
|
||||
serialization_format=serialization_format,
|
||||
)
|
||||
self.fs = FileSystem()
|
||||
self.path = self.fs.init_path(path, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
return FileSystem.validate_checkpoint_id(checkpoint_id)
|
||||
|
||||
|
||||
class FsspecReader(FileSystemReader):
|
||||
def __init__(self, path: str | os.PathLike, **kwargs) -> None:
|
||||
super().__init__(path)
|
||||
self.fs = FileSystem()
|
||||
self.path = self.fs.init_path(path, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
return FileSystem.validate_checkpoint_id(checkpoint_id)
|
||||
@@ -0,0 +1,106 @@
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
_metadata_fn: str = "model.safetensors.index.json"
|
||||
|
||||
FILE_NAME = "model-{cpt_idx}-of-{num_files}"
|
||||
SHARDED_FILE_NAME = "shard-{shard_idx}-model-{cpt_idx}-of-{num_files}"
|
||||
SUFFIX = ".safetensors"
|
||||
|
||||
# metadata keys
|
||||
CUSTOM_METADATA_KEY = "DCP_SHARDING_INFO"
|
||||
DEFAULT_EXTRA_METADATA_KEY = "__metadata__"
|
||||
SAVED_OFFSETS_KEY = "saved_offsets"
|
||||
SHAPE_KEY = "shape"
|
||||
DATA_KEY = "data"
|
||||
DTYPE_KEY = "dtype"
|
||||
DATA_OFFSETS_KEY = "data_offsets"
|
||||
|
||||
DTYPE_MAP = {
|
||||
"F16": torch.float16,
|
||||
"F32": torch.float32,
|
||||
"F64": torch.float64,
|
||||
"I8": torch.int8,
|
||||
"U8": torch.uint8,
|
||||
"I16": torch.int16,
|
||||
"I32": torch.int32,
|
||||
"I64": torch.int64,
|
||||
"BF16": torch.bfloat16,
|
||||
}
|
||||
|
||||
HF_DCP_VERSION: float = 1.0
|
||||
DCP_VERSION_KEY = "DCP_VERSION"
|
||||
DCP_SHARDING_INFO_KEY = "DCP_SHARDING_INFO"
|
||||
|
||||
FORMAT_KEY = "format"
|
||||
FORMAT_VALUE = "pt"
|
||||
|
||||
NUM_BYTES_FOR_HEADER_LEN = 8
|
||||
|
||||
SHARDED_DIR_NAME = "sharded"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _HFStorageInfo:
|
||||
"""This is the per entry storage info."""
|
||||
|
||||
relative_path: str
|
||||
shape: torch.Size
|
||||
dtype: torch.dtype
|
||||
|
||||
|
||||
def _gen_file_name(
|
||||
index: int, largest_index: int, shard_index: int | None = None
|
||||
) -> str:
|
||||
if shard_index is not None:
|
||||
return (
|
||||
SHARDED_FILE_NAME.format(
|
||||
shard_idx=f"{shard_index}".zfill(5),
|
||||
cpt_idx=f"{index}".zfill(5),
|
||||
num_files=f"{largest_index}".zfill(5),
|
||||
)
|
||||
+ SUFFIX
|
||||
)
|
||||
else:
|
||||
return (
|
||||
FILE_NAME.format(
|
||||
cpt_idx=f"{index}".zfill(5), num_files=f"{largest_index}".zfill(5)
|
||||
)
|
||||
+ SUFFIX
|
||||
)
|
||||
|
||||
|
||||
def _get_safetensors_file_metadata(file_bytes: io.IOBase) -> tuple[Any, int]:
|
||||
# this uses the same logic that's done in HF code base
|
||||
# https://github.com/2404589803/huggingface_hub/blob/main/src/huggingface_hub/hf_api.py#L5308
|
||||
# and follows their documentation on how their files are serialized
|
||||
# https://huggingface.co/docs/safetensors/index#format
|
||||
|
||||
header_len_bytes = file_bytes.read(NUM_BYTES_FOR_HEADER_LEN)
|
||||
header_len = struct.unpack("<Q", header_len_bytes)[0]
|
||||
header_json = file_bytes.read(header_len)
|
||||
metadata = json.loads(header_json)
|
||||
return (metadata, header_len + NUM_BYTES_FOR_HEADER_LEN)
|
||||
|
||||
|
||||
def _get_dtype(dtype_str: str) -> torch.dtype:
|
||||
try:
|
||||
dtype = DTYPE_MAP[dtype_str]
|
||||
except KeyError:
|
||||
dtype = torch.get_default_dtype()
|
||||
|
||||
return dtype
|
||||
|
||||
|
||||
def _get_dcp_custom_metadata(metadata: Any) -> Any | None:
|
||||
if DEFAULT_EXTRA_METADATA_KEY in metadata:
|
||||
custom_metadata = metadata[DEFAULT_EXTRA_METADATA_KEY]
|
||||
if CUSTOM_METADATA_KEY in custom_metadata:
|
||||
return json.loads(custom_metadata[CUSTOM_METADATA_KEY])
|
||||
return None
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
|
||||
from . import _version
|
||||
from ._traverse import (
|
||||
OBJ_PATH,
|
||||
set_element,
|
||||
STATE_DICT_ITEM,
|
||||
traverse_state_dict,
|
||||
traverse_state_dict_v_2_3,
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
TODO:
|
||||
Need to add ability to handle tuple, OrderedDict, NamedTuple.
|
||||
Update mappings from dict to a class.
|
||||
Change set_element to recreate the right type for tuple, OrderedDict, and NamedTuple.
|
||||
"""
|
||||
|
||||
|
||||
FLATTEN_MAPPING = dict[str, OBJ_PATH]
|
||||
|
||||
|
||||
# TODO: Update Docstring for nested_dict.py
|
||||
def flatten_state_dict(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
) -> tuple[STATE_DICT_TYPE, FLATTEN_MAPPING]:
|
||||
"""
|
||||
Flatten ``state_dict`` made of nested dicts and lists into a top level dictionary.
|
||||
|
||||
Use ``unflatten_state_dict`` to revert this process.
|
||||
Returns:
|
||||
A tuple with the flatten state_dict and a mapping from original to new state_dict.
|
||||
N.B. The new keys are derived from the object paths, joined by dot.
|
||||
For example: ``{ 'a': {'b':...}}`` results in the key `a.b`.
|
||||
"""
|
||||
flattened: STATE_DICT_TYPE = {}
|
||||
mappings: FLATTEN_MAPPING = {}
|
||||
|
||||
def flat_copy(path: OBJ_PATH, value: STATE_DICT_ITEM) -> None:
|
||||
new_fqn = ".".join(map(str, path))
|
||||
if new_fqn in flattened:
|
||||
raise ValueError(f"duplicated flatten key {new_fqn}")
|
||||
flattened[new_fqn] = value
|
||||
mappings[new_fqn] = path
|
||||
|
||||
# We started to flatten dictionary since v2.4. But in order to not break
|
||||
# the checkpoints that were saved before v2.4, we need to keep the old
|
||||
# traversal so that we can reconstruct those checkpoints.
|
||||
use_v_2_3 = (
|
||||
_version._derived_version is not None and _version._derived_version == "2_3"
|
||||
)
|
||||
if use_v_2_3:
|
||||
traverse_state_dict_v_2_3(state_dict, flat_copy)
|
||||
else:
|
||||
traverse_state_dict(state_dict, flat_copy)
|
||||
return flattened, mappings
|
||||
|
||||
|
||||
def unflatten_state_dict(
|
||||
state_dict: STATE_DICT_TYPE, mapping: FLATTEN_MAPPING
|
||||
) -> STATE_DICT_TYPE:
|
||||
"""Restore the original nested state_dict according to ``mapping`` and the flattened ``state_dict``."""
|
||||
nested: STATE_DICT_TYPE = {}
|
||||
for key, value in state_dict.items():
|
||||
set_element(nested, mapping[key], value)
|
||||
return nested
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
import logging
|
||||
import pickle
|
||||
import time
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import cast, TypeVar
|
||||
|
||||
import torch
|
||||
from torch.distributed import ProcessGroup, Work
|
||||
from torch.distributed._shard.sharded_tensor import (
|
||||
Shard as ShardedTensorShard,
|
||||
ShardedTensor,
|
||||
ShardMetadata,
|
||||
)
|
||||
from torch.distributed._shard.sharded_tensor.metadata import ShardedTensorMetadata
|
||||
from torch.distributed.tensor import _DTensorSpec, DTensor
|
||||
from torch.utils._pytree import (
|
||||
KeyPath,
|
||||
tree_flatten_with_path,
|
||||
tree_unflatten,
|
||||
TreeSpec,
|
||||
)
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TensorMeta:
|
||||
"""
|
||||
This is the metadata for a tensor that is used to transfer checkpoints.
|
||||
It contains the shape, the dtype, the storage offset and the stride of the
|
||||
tensor.
|
||||
|
||||
This must be pickleable so that it can be sent over the wire.
|
||||
"""
|
||||
|
||||
shape: torch.Size
|
||||
dtype: torch.dtype
|
||||
storage_offset: int
|
||||
stride: tuple[int, ...]
|
||||
nbytes: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DTensorMeta:
|
||||
"""
|
||||
This is the metadata for a DTensor that is used to transfer checkpoints.
|
||||
It contains the metadata for the local tensor and the spec of the DTensor.
|
||||
|
||||
This must be pickleable so that it can be sent over the wire.
|
||||
"""
|
||||
|
||||
local: _TensorMeta
|
||||
spec: _DTensorSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ShardedTensorMeta:
|
||||
"""
|
||||
This is the metadata for a ShardedTensor that is used to transfer checkpoints.
|
||||
It contains the metadata for all local shards and the global tensor metadata.
|
||||
|
||||
This must be pickleable so that it can be sent over the wire.
|
||||
"""
|
||||
|
||||
local_shards_meta: list[_TensorMeta]
|
||||
local_shards_shard_metadata: list[
|
||||
ShardMetadata
|
||||
] # Original shard metadata for each local shard
|
||||
sharded_tensor_metadata: ShardedTensorMetadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StateDictMeta:
|
||||
"""
|
||||
This is the metadata for a state dict that is used to transfer checkpoints.
|
||||
It contains the step, the pytree spec of the state dict and the metadata for
|
||||
each tensor in the state dict.
|
||||
|
||||
This must be pickleable so that it can be sent over the wire.
|
||||
|
||||
Args:
|
||||
step: the step of the checkpoint to verify consistency
|
||||
treespec: the pytree spec of the state dict
|
||||
paths: the path of each leaf in the state dict
|
||||
non_tensor_leaves: the metadata for each tensor in the state dict and any
|
||||
non-tensor leaves in the state dict
|
||||
"""
|
||||
|
||||
treespec: TreeSpec
|
||||
paths: list[KeyPath]
|
||||
non_tensor_leaves: list[object | _TensorMeta | _DTensorMeta | _ShardedTensorMeta]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _timeit(name: str) -> Generator[None, None, None]:
|
||||
start = time.perf_counter()
|
||||
yield
|
||||
dur = time.perf_counter() - start
|
||||
logger.info("%s took %ss", name, dur)
|
||||
|
||||
|
||||
def _prepare_tensor(tensor: torch.Tensor) -> tuple[torch.Tensor, _TensorMeta]:
|
||||
return (
|
||||
_cast_tensor(tensor, torch.uint8),
|
||||
_TensorMeta(
|
||||
shape=tensor.shape,
|
||||
dtype=tensor.dtype,
|
||||
storage_offset=cast(int, tensor.storage_offset()),
|
||||
stride=tensor.stride(),
|
||||
nbytes=tensor.untyped_storage().nbytes(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_state_dict(
|
||||
state_dict: object,
|
||||
device: torch.device,
|
||||
) -> tuple[_StateDictMeta, list[torch.Tensor]]:
|
||||
leaves: list[tuple[KeyPath, object]]
|
||||
leaves, treespec = tree_flatten_with_path(state_dict)
|
||||
|
||||
paths: list[KeyPath] = []
|
||||
non_tensor_leaves: list[
|
||||
object | _TensorMeta | _DTensorMeta | _ShardedTensorMeta
|
||||
] = []
|
||||
tensors: list[torch.Tensor] = []
|
||||
for key_path, v in leaves:
|
||||
paths.append(key_path)
|
||||
|
||||
if isinstance(v, DTensor):
|
||||
tensor, tensor_meta = _prepare_tensor(v._local_tensor)
|
||||
|
||||
tensors.append(tensor)
|
||||
|
||||
non_tensor_leaves.append(
|
||||
_DTensorMeta(
|
||||
local=tensor_meta,
|
||||
spec=v._spec,
|
||||
)
|
||||
)
|
||||
elif isinstance(v, ShardedTensor):
|
||||
# Handle ShardedTensor by extracting all local shards
|
||||
local_shards = v.local_shards()
|
||||
|
||||
# Prepare metadata for all local shards
|
||||
local_shards_meta = []
|
||||
local_shards_shard_metadata = []
|
||||
for shard in local_shards:
|
||||
tensor, tensor_meta = _prepare_tensor(shard.tensor)
|
||||
tensors.append(tensor)
|
||||
local_shards_meta.append(tensor_meta)
|
||||
local_shards_shard_metadata.append(shard.metadata)
|
||||
|
||||
non_tensor_leaves.append(
|
||||
_ShardedTensorMeta(
|
||||
local_shards_meta=local_shards_meta,
|
||||
local_shards_shard_metadata=local_shards_shard_metadata,
|
||||
sharded_tensor_metadata=v.metadata(), # Complete metadata
|
||||
)
|
||||
)
|
||||
elif isinstance(v, torch.Tensor):
|
||||
tensor, tensor_meta = _prepare_tensor(v)
|
||||
tensors.append(tensor)
|
||||
non_tensor_leaves.append(tensor_meta)
|
||||
else:
|
||||
non_tensor_leaves.append(v)
|
||||
|
||||
return (
|
||||
_StateDictMeta(
|
||||
treespec=treespec,
|
||||
paths=paths,
|
||||
non_tensor_leaves=non_tensor_leaves,
|
||||
),
|
||||
tensors,
|
||||
)
|
||||
|
||||
|
||||
def _cast_tensor(tensor: torch.Tensor, dtype: torch.dtype) -> torch.Tensor:
|
||||
"""
|
||||
Casts the underlying storage to a tensor of the given dtype.
|
||||
|
||||
The returned tensor will be of size ``storage.nbytes``.
|
||||
|
||||
This works for all datatypes and supports strided/offset tensors with the
|
||||
caveat that the cast tensor may be larger than the original tensor due to
|
||||
the differences in striding.
|
||||
"""
|
||||
if type(tensor) is not torch.Tensor:
|
||||
raise AssertionError(f"can only cast standard tensors not {type(tensor)}")
|
||||
storage = tensor.untyped_storage()
|
||||
ret = torch.tensor(storage, dtype=dtype, device=tensor.device)
|
||||
if ret.untyped_storage() is not storage:
|
||||
raise AssertionError("storage should be the same")
|
||||
return ret
|
||||
|
||||
|
||||
class PGTransport:
|
||||
"""
|
||||
This is a checkpoint transport that uses the process group to transfer checkpoints.
|
||||
This allows for fast recovery of workers by fetching the current weights
|
||||
from an existing worker.
|
||||
|
||||
Args:
|
||||
pg: the process group to use for communication
|
||||
timeout: the timeout for communication
|
||||
device: the device to use for tensors
|
||||
state_dict: if specified this function will be called to do an inplace
|
||||
receive into the returned state_dict. This is much faster than
|
||||
having to allocate new tensors and transferring them to the CPU.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pg: ProcessGroup,
|
||||
timeout: timedelta,
|
||||
device: torch.device,
|
||||
state_dict: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
self._work: list[Work] = []
|
||||
self._pg = pg
|
||||
self._timeout = timeout
|
||||
self._device = device
|
||||
self._state_dict = state_dict
|
||||
|
||||
def send_checkpoint(self, dst_ranks: list[int], state_dict: object) -> None:
|
||||
"""
|
||||
Send a checkpoint to multiple destination ranks.
|
||||
|
||||
The process:
|
||||
1. Prepares the state dict by converting tensors to a serializable format
|
||||
2. Sends metadata as pickled data
|
||||
3. Sends each tensor sequentially to all destination ranks
|
||||
|
||||
Args:
|
||||
dst_ranks: List of destination ranks to send the checkpoint to
|
||||
state_dict: The state dictionary containing model parameters
|
||||
"""
|
||||
with _timeit("preparing state_dict"):
|
||||
meta, tensors = _prepare_state_dict(state_dict, device=self._device)
|
||||
|
||||
work = []
|
||||
|
||||
with _timeit("send meta"):
|
||||
buf = pickle.dumps(meta)
|
||||
len_t = torch.tensor([len(buf)], dtype=torch.int64, device=self._device)
|
||||
buf_t = torch.frombuffer(buf, dtype=torch.uint8).to(self._device)
|
||||
for dst_rank in dst_ranks:
|
||||
work.append(self._pg.send([len_t], dst_rank, tag=1))
|
||||
work.append(self._pg.send([buf_t], dst_rank, tag=2))
|
||||
|
||||
with _timeit("send tensors"):
|
||||
for i, t in enumerate(tensors):
|
||||
original_device = t.device
|
||||
t = t.to(self._device)
|
||||
for dst_rank in dst_ranks:
|
||||
work.append(self._pg.send([t], dst_rank, tag=3 + i))
|
||||
|
||||
# if we did a copy we should wait for the work to complete so we
|
||||
# can free the memory to avoid OOMs
|
||||
if original_device == torch.device("cpu"):
|
||||
for w in work:
|
||||
w.wait()
|
||||
work = []
|
||||
|
||||
for w in work:
|
||||
w.wait()
|
||||
|
||||
def recv_checkpoint(self, src_rank: int) -> object:
|
||||
"""
|
||||
Receive a checkpoint from a source rank.
|
||||
|
||||
The process:
|
||||
1. Receives metadata about the checkpoint structure
|
||||
2. Receives each tensor, potentially reusing existing tensors for in-place updates
|
||||
3. Reconstructs the original state dict structure
|
||||
|
||||
Args:
|
||||
src_rank: The source rank to receive the checkpoint from
|
||||
|
||||
Returns:
|
||||
The reconstructed state dictionary with model parameters
|
||||
"""
|
||||
state_dict = self._state_dict() if self._state_dict else {}
|
||||
state_dict_leaves, _ = tree_flatten_with_path(state_dict)
|
||||
|
||||
dst_tensors: dict[KeyPath, object] = dict(state_dict_leaves)
|
||||
|
||||
len_t = torch.zeros(1, dtype=torch.int64, device=self._device)
|
||||
self._pg.recv([len_t], src_rank, tag=1).wait()
|
||||
length = cast(int, len_t.item())
|
||||
|
||||
buf = torch.empty(length, dtype=torch.uint8, device=self._device)
|
||||
self._pg.recv([buf], src_rank, tag=2).wait()
|
||||
|
||||
meta: _StateDictMeta = pickle.loads(buf.cpu().numpy().tobytes())
|
||||
|
||||
i: int = 0
|
||||
works: list[Work] = []
|
||||
|
||||
def recv(path: KeyPath, v: _TensorMeta) -> torch.Tensor:
|
||||
nonlocal i
|
||||
|
||||
inplace = dst_tensors.get(path)
|
||||
if (
|
||||
isinstance(inplace, torch.Tensor)
|
||||
and inplace.device.type == self._device.type
|
||||
):
|
||||
if isinstance(inplace, DTensor):
|
||||
inplace = inplace._local_tensor
|
||||
t = _cast_tensor(inplace, torch.uint8)
|
||||
if t.nbytes != v.nbytes:
|
||||
raise AssertionError("inplace tensor storage must be the same size")
|
||||
else:
|
||||
t = torch.empty(v.nbytes, dtype=torch.uint8, device=self._device)
|
||||
|
||||
work = self._pg.recv([t], src_rank, tag=3 + i)
|
||||
i += 1
|
||||
|
||||
if inplace is None:
|
||||
# if not inplace we need to copy it to CPU to avoid OOMing
|
||||
work.wait()
|
||||
t = t.cpu()
|
||||
else:
|
||||
works.append(work)
|
||||
|
||||
return torch.as_strided(
|
||||
t.view(v.dtype),
|
||||
size=v.shape,
|
||||
stride=v.stride,
|
||||
storage_offset=v.storage_offset,
|
||||
)
|
||||
|
||||
values: list[object] = []
|
||||
for path, v in zip(meta.paths, meta.non_tensor_leaves):
|
||||
if isinstance(v, _TensorMeta):
|
||||
values.append(recv(path, v))
|
||||
elif isinstance(v, _DTensorMeta):
|
||||
tensor = recv(path, v.local)
|
||||
# pyrefly: ignore [bad-argument-type, bad-argument-count, unexpected-keyword]
|
||||
values.append(DTensor(tensor, v.spec, requires_grad=False))
|
||||
elif isinstance(v, _ShardedTensorMeta):
|
||||
# Receive all local shards that were sent to us
|
||||
local_shards = []
|
||||
current_rank = self._pg.rank()
|
||||
|
||||
# Receive tensors for each local shard that was sent
|
||||
for j, shard_meta in enumerate(v.local_shards_meta):
|
||||
tensor = recv(path, shard_meta)
|
||||
|
||||
# Use the original shard metadata that was stored during preparation
|
||||
# but update the placement to reflect the current rank/device
|
||||
original_shard_metadata = v.local_shards_shard_metadata[j]
|
||||
updated_shard_metadata = ShardMetadata(
|
||||
shard_offsets=original_shard_metadata.shard_offsets,
|
||||
shard_sizes=original_shard_metadata.shard_sizes,
|
||||
placement=f"rank:{current_rank}/{tensor.device.type}",
|
||||
)
|
||||
|
||||
local_shard = ShardedTensorShard(
|
||||
tensor=tensor, metadata=updated_shard_metadata
|
||||
)
|
||||
local_shards.append(local_shard)
|
||||
|
||||
# Use complete metadata to reconstruct ShardedTensor
|
||||
sharded_tensor = (
|
||||
ShardedTensor._init_from_local_shards_and_global_metadata(
|
||||
local_shards=local_shards,
|
||||
sharded_tensor_metadata=v.sharded_tensor_metadata,
|
||||
)
|
||||
)
|
||||
values.append(sharded_tensor)
|
||||
else:
|
||||
values.append(v)
|
||||
|
||||
for work in works:
|
||||
work.wait()
|
||||
|
||||
return tree_unflatten(values, meta.treespec)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch.distributed as dist
|
||||
from torch.distributed._shard.sharded_tensor import Shard, ShardedTensor, ShardMetadata
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
from torch.distributed.remote_device import _remote_device
|
||||
|
||||
from ._traverse import OBJ_PATH, set_element, STATE_DICT_ITEM, traverse_state_dict
|
||||
from .utils import _element_wise_add, _normalize_device_info
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed._shard.sharded_tensor.metadata import ShardedTensorMetadata
|
||||
|
||||
|
||||
# TODO: We need to refactor this code.
|
||||
def _flatten_sharded_tensors(state_dict: STATE_DICT_TYPE) -> STATE_DICT_TYPE:
|
||||
r"""
|
||||
Transform ``state_dict`` by flattening all nested ShardedTensor instances found.
|
||||
|
||||
The resulting ShardedTensor instances are only correct regarding the local shard and
|
||||
MUST not be used for any other purpose but checkpointing, as no operator will work with them.
|
||||
|
||||
This function should be used in conjunction with a state_dict produced by FSDP's
|
||||
StateDictType.SHARDED_STATE_DICT methods.
|
||||
"""
|
||||
new_state_dict: STATE_DICT_TYPE = {}
|
||||
|
||||
def rewrite_dict(path: OBJ_PATH, value: STATE_DICT_ITEM) -> None:
|
||||
if not isinstance(value, ShardedTensor):
|
||||
set_element(new_state_dict, path, value)
|
||||
return
|
||||
shards = value.local_shards()
|
||||
|
||||
if len(shards) == 0:
|
||||
return
|
||||
if len(shards) != 1:
|
||||
set_element(new_state_dict, path, value)
|
||||
return
|
||||
|
||||
outer_shard = shards[0]
|
||||
|
||||
inner_st = outer_shard.tensor
|
||||
if not isinstance(inner_st, ShardedTensor):
|
||||
set_element(new_state_dict, path, value)
|
||||
return
|
||||
|
||||
if len(inner_st.local_shards()) != 1:
|
||||
raise ValueError("Cannot handle inner tensor with more than 1 shard")
|
||||
inner_shard = inner_st.local_shards()[0]
|
||||
|
||||
local_shards = [
|
||||
Shard(
|
||||
tensor=inner_shard.tensor,
|
||||
metadata=ShardMetadata(
|
||||
shard_offsets=_element_wise_add(
|
||||
outer_shard.metadata.shard_offsets,
|
||||
inner_shard.metadata.shard_offsets,
|
||||
),
|
||||
shard_sizes=inner_shard.metadata.shard_sizes,
|
||||
placement=f"rank:{dist.get_rank()}/{inner_shard.tensor.device}",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
st_meta: ShardedTensorMetadata = copy.deepcopy(value.metadata())
|
||||
other_rank = 0 if dist.get_rank() > 0 else 1
|
||||
device_info = _normalize_device_info(inner_shard.tensor.device.type, 0)
|
||||
|
||||
# Remove the outer ST shard the inner ST covers
|
||||
for i, shard_md in enumerate(st_meta.shards_metadata):
|
||||
if shard_md.shard_offsets == outer_shard.metadata.shard_offsets:
|
||||
st_meta.shards_metadata.pop(i)
|
||||
break
|
||||
|
||||
# Attribute other rank for the other shards
|
||||
for shard_md in st_meta.shards_metadata:
|
||||
shard_md.placement = _remote_device(f"rank:{other_rank}/{device_info}")
|
||||
|
||||
# Add other inner shards from the inner tensor
|
||||
for inner_md in inner_st.metadata().shards_metadata:
|
||||
if inner_md.shard_offsets != inner_shard.metadata.shard_offsets:
|
||||
st_meta.shards_metadata.append(
|
||||
ShardMetadata(
|
||||
shard_offsets=_element_wise_add(
|
||||
outer_shard.metadata.shard_offsets,
|
||||
inner_md.shard_offsets,
|
||||
),
|
||||
shard_sizes=inner_md.shard_sizes,
|
||||
placement=f"rank:{other_rank}/{device_info}",
|
||||
)
|
||||
)
|
||||
|
||||
# Finally add this shard
|
||||
st_meta.shards_metadata.append(local_shards[0].metadata)
|
||||
|
||||
st = ShardedTensor._init_from_local_shards_and_global_metadata(
|
||||
local_shards=local_shards,
|
||||
sharded_tensor_metadata=st_meta,
|
||||
)
|
||||
set_element(new_state_dict, path, st)
|
||||
|
||||
traverse_state_dict(state_dict, rewrite_dict)
|
||||
return new_state_dict
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import types
|
||||
import warnings
|
||||
import weakref
|
||||
from copyreg import dispatch_table
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.cuda._pin_memory_utils as pin_memory_utils
|
||||
from torch.storage import UntypedStorage
|
||||
from torch.utils.weak import WeakIdKeyDictionary
|
||||
|
||||
|
||||
class StateDictStager:
|
||||
"""
|
||||
A class for optimizing storage objects during staging for async checkpointing.
|
||||
|
||||
StateDictStager stages the state_dict to CPU DRAM while applying optimizations
|
||||
like memory sharing and pinning to improve performance. It caches storage objects
|
||||
to avoid redundant copies and can be configured to automatically share memory
|
||||
(for multi-process usage) and pin memory (for faster CPU-GPU transfers).
|
||||
|
||||
Attributes:
|
||||
pin_memory (bool): Whether to pin CPU memory for faster CPU-GPU transfers
|
||||
share_memory (bool): Whether to share memory across processes
|
||||
pin_memory_min_bytes (int): Minimum tensor size in bytes to pin memory (default: 5)
|
||||
_cached_storage_mapping (WeakIdKeyDictionary): Maps storage objects to optimized CPU storages using weak references
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pin_memory: bool = False,
|
||||
share_memory: bool = False,
|
||||
pin_memory_min_bytes: int = 5,
|
||||
):
|
||||
if pin_memory and not torch.cuda.is_available():
|
||||
warnings.warn(
|
||||
"Ignoring pin_memory flag for checkpoint staging as pinning memory"
|
||||
"requires CUDA, but CUDA is not available. ",
|
||||
stacklevel=2,
|
||||
)
|
||||
self.pin_memory = False
|
||||
else:
|
||||
self.pin_memory = pin_memory
|
||||
self.share_memory = share_memory
|
||||
# Mapping from original storage objects to CPU storages using weak references
|
||||
self._cached_storage_mapping = WeakIdKeyDictionary()
|
||||
self.pin_memory_min_bytes = pin_memory_min_bytes
|
||||
|
||||
def _deepcopy_atomic(x, _):
|
||||
return x
|
||||
|
||||
def _deepcopy_list(x, memo, non_blocking=False):
|
||||
y: list = []
|
||||
memo[id(x)] = y
|
||||
append = y.append
|
||||
for a in x:
|
||||
append(
|
||||
self.deepcopy_with_tensor_offload(
|
||||
a, memo, non_blocking=non_blocking
|
||||
)
|
||||
)
|
||||
return y
|
||||
|
||||
def _deepcopy_tuple(x, memo, non_blocking=False):
|
||||
y = [
|
||||
self.deepcopy_with_tensor_offload(a, memo, non_blocking=non_blocking)
|
||||
for a in x
|
||||
]
|
||||
# We're not going to put the tuple in the memo, but it's still important we
|
||||
# check for it, in case the tuple contains recursive mutable structures.
|
||||
try:
|
||||
return memo[id(x)]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Check if any elements changed during deepcopy
|
||||
for k, j in zip(x, y):
|
||||
if k is not j:
|
||||
# At least one element changed, create new tuple
|
||||
return tuple(y)
|
||||
|
||||
# No elements changed, return original tuple
|
||||
return x
|
||||
|
||||
def _deepcopy_dict(x, memo, non_blocking=False):
|
||||
y: dict = {}
|
||||
memo[id(x)] = y
|
||||
for key, value in x.items():
|
||||
y[
|
||||
self.deepcopy_with_tensor_offload(
|
||||
key, memo, non_blocking=non_blocking
|
||||
)
|
||||
] = self.deepcopy_with_tensor_offload(
|
||||
value, memo, non_blocking=non_blocking
|
||||
)
|
||||
return y
|
||||
|
||||
def _deepcopy_method(x, memo, non_blocking=False): # Copy instance methods
|
||||
return type(x)(
|
||||
x.__func__,
|
||||
self.deepcopy_with_tensor_offload(
|
||||
x.__self__, memo, non_blocking=non_blocking
|
||||
),
|
||||
)
|
||||
|
||||
d: dict[Any, Any] = {}
|
||||
self._deepcopy_dispatch = d
|
||||
d[type(None)] = _deepcopy_atomic
|
||||
d[int] = _deepcopy_atomic
|
||||
d[float] = _deepcopy_atomic
|
||||
d[bool] = _deepcopy_atomic
|
||||
d[complex] = _deepcopy_atomic
|
||||
d[bytes] = _deepcopy_atomic
|
||||
d[str] = _deepcopy_atomic
|
||||
d[types.CodeType] = _deepcopy_atomic
|
||||
d[type] = _deepcopy_atomic
|
||||
d[range] = _deepcopy_atomic
|
||||
d[types.BuiltinFunctionType] = _deepcopy_atomic
|
||||
d[types.FunctionType] = _deepcopy_atomic
|
||||
d[weakref.ref] = _deepcopy_atomic
|
||||
d[property] = _deepcopy_atomic
|
||||
d[types.MethodType] = _deepcopy_method
|
||||
d[dict] = _deepcopy_dict
|
||||
d[tuple] = _deepcopy_tuple
|
||||
d[list] = _deepcopy_list
|
||||
|
||||
def _stage_untyped_storage(
|
||||
self,
|
||||
storage: UntypedStorage,
|
||||
non_blocking: bool = False,
|
||||
):
|
||||
"""
|
||||
Called from the hooked storage_deepcopy function in torch.Tensor.__deepcopy__.
|
||||
|
||||
This method handles the storage optimization logic for the StagingStateDict class.
|
||||
It checks if the storage has already been cached, and if so, reuses it.
|
||||
Otherwise, it creates a new CPU storage and applies memory optimizations.
|
||||
|
||||
Args:
|
||||
storage: The storage to optimize
|
||||
|
||||
Returns:
|
||||
The optimized storage
|
||||
"""
|
||||
# Check if we've already cached this storage
|
||||
if storage in self._cached_storage_mapping:
|
||||
cached_storage = self._cached_storage_mapping[storage]
|
||||
if cached_storage.size() != storage.size():
|
||||
raise AssertionError(
|
||||
"For async checkpointing, We cache storages in DRAM and reuse them."
|
||||
"Cached storage size does not match original storage size."
|
||||
"This should never happen as we track the original storage weakref "
|
||||
"and clean up the cache storage. Please report this to PyTorch Distributed Checkpointing."
|
||||
)
|
||||
# Reuse cached storage but update with new data
|
||||
cached_storage.copy_(storage, non_blocking=non_blocking)
|
||||
return cached_storage
|
||||
|
||||
# Create new CPU storage
|
||||
if self.share_memory:
|
||||
new_storage = type(storage)._new_shared(storage.size(), device="cpu")
|
||||
else:
|
||||
new_storage = type(storage)(storage.size(), device="cpu")
|
||||
|
||||
# Skip pinning for tensors below the minimum size threshold
|
||||
# Small tensors (e.g., optimizer step counters, scalars) have negligible
|
||||
# transfer time improvement from pinning, but pinning overhead is significant
|
||||
if self.pin_memory and new_storage.nbytes() >= self.pin_memory_min_bytes:
|
||||
pin_memory_utils.pin_memory(new_storage.data_ptr(), new_storage.nbytes())
|
||||
# Set up a weak reference to unpin when cpu storage is garbage collected
|
||||
f = weakref.finalize(
|
||||
new_storage, pin_memory_utils.unpin_memory, new_storage.data_ptr()
|
||||
)
|
||||
# This makes sure that the finalizer is not called after
|
||||
# cuda context is destroyed.
|
||||
f.atexit = False
|
||||
|
||||
new_storage.copy_(storage, non_blocking=non_blocking)
|
||||
|
||||
# Cache the storage - WeakIdKeyDictionary will automatically clean up when storage is garbage collected
|
||||
self._cached_storage_mapping[storage] = new_storage
|
||||
return new_storage
|
||||
|
||||
@torch.no_grad()
|
||||
def stage(
|
||||
self,
|
||||
state_dict: Any,
|
||||
non_blocking: bool = False,
|
||||
) -> Any:
|
||||
return self.deepcopy_with_tensor_offload(state_dict, None, [], non_blocking)
|
||||
|
||||
def _offload_tensor(self, x, memo, non_blocking=False):
|
||||
"""
|
||||
Deep copy a PyTorch tensor with optimized storage handling.
|
||||
|
||||
This method creates a CPU copy of a tensor while applying memory optimizations
|
||||
like sharing and pinning based on the StateDictStager configuration.
|
||||
|
||||
Args:
|
||||
x: The tensor to copy
|
||||
memo: Memo dictionary for tracking already copied objects
|
||||
non_blocking: Whether to perform non-blocking copies where possible
|
||||
|
||||
Returns:
|
||||
A CPU copy of the tensor with optimized storage
|
||||
"""
|
||||
# if data_ptr is not 0, we allocate a new storage below. so we can skip
|
||||
# memory allocation by using [] for size.
|
||||
y = x.new_empty([] if x.data_ptr() != 0 else x.size(), device="cpu")
|
||||
|
||||
# Store in memo dict early to handle recursive references
|
||||
d = id(x)
|
||||
memo[d] = y
|
||||
|
||||
if type(x) is torch.Tensor or x.data_ptr() != 0:
|
||||
# Get the untyped storage
|
||||
untyped_storage = x.untyped_storage()
|
||||
storage_id = id(untyped_storage)
|
||||
|
||||
# Check if this storage has already been staged in this deepcopy operation
|
||||
# This handles the case where different tensors share the same storage
|
||||
# (e.g., FSDP state_dict where norm.weight and norm_weight reference same storage)
|
||||
# PyTorch caches untyped_storage() calls, so same storage -> same id
|
||||
if storage_id in memo:
|
||||
copied_storage = memo[storage_id]
|
||||
else:
|
||||
# Storage not seen before in this operation, stage it
|
||||
copied_storage = self._stage_untyped_storage(
|
||||
untyped_storage, non_blocking=non_blocking
|
||||
)
|
||||
# Add to memo to avoid re-staging if we see this storage again
|
||||
memo[storage_id] = copied_storage
|
||||
|
||||
# Set the tensor data using the staged storage
|
||||
y.set_(copied_storage, x.storage_offset(), x.size(), x.stride())
|
||||
|
||||
# Copy any attributes the tensor might have
|
||||
if hasattr(x, "__dict__"):
|
||||
for attr_name, attr_value in x.__dict__.items():
|
||||
setattr(
|
||||
y,
|
||||
attr_name,
|
||||
self.deepcopy_with_tensor_offload(
|
||||
attr_value, memo, non_blocking=non_blocking
|
||||
),
|
||||
)
|
||||
|
||||
if hasattr(x, "__slots__"):
|
||||
for slot in x.__slots__:
|
||||
if hasattr(x, slot):
|
||||
setattr(
|
||||
y,
|
||||
slot,
|
||||
self.deepcopy_with_tensor_offload(
|
||||
getattr(x, slot), memo, non_blocking=non_blocking
|
||||
),
|
||||
)
|
||||
|
||||
return y
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Clean up all cached storages and release associated resources.
|
||||
|
||||
This method clears the internal storage cache, allowing garbage collection
|
||||
of cached CPU storages. Any pinned memory associated with cached storages
|
||||
will be automatically unpinned through weak reference finalizers.
|
||||
|
||||
It also clears the _deepcopy_dispatch dict to break the reference cycle
|
||||
created by closures that capture self. Without this, it may
|
||||
cause memory leaks.
|
||||
"""
|
||||
self._cached_storage_mapping.clear()
|
||||
self._deepcopy_dispatch.clear()
|
||||
|
||||
@torch.no_grad()
|
||||
def deepcopy_with_tensor_offload(self, x, memo=None, _nil=[], non_blocking=False): # noqa: B006
|
||||
"""Deep copy operation on arbitrary Python objects with special handling for PyTorch tensors.
|
||||
|
||||
This implementation extends the standard deepcopy functionality to handle PyTorch tensors
|
||||
and their storages in a way that optimizes memory usage and performance, similar to the
|
||||
stage method. It applies memory sharing and pinning optimizations based on the StateDictStager
|
||||
configuration.
|
||||
|
||||
Args:
|
||||
x: The object to deep copy
|
||||
memo: Memo dictionary for tracking already copied objects
|
||||
_nil: Sentinel value for memo dictionary
|
||||
non_blocking: Whether to perform non-blocking copies where possible
|
||||
|
||||
Returns:
|
||||
A deep copy of the input object with optimized tensor storage handling
|
||||
"""
|
||||
if memo is None:
|
||||
memo = {}
|
||||
|
||||
d = id(x)
|
||||
y = memo.get(d, _nil)
|
||||
if y is not _nil:
|
||||
return y
|
||||
|
||||
cls = type(x)
|
||||
|
||||
# tensors and subclasses of tensors are handled separately
|
||||
if isinstance(x, torch.Tensor):
|
||||
y = self._offload_tensor(x, memo, non_blocking=non_blocking)
|
||||
else:
|
||||
# Use the dispatch table for standard types
|
||||
copier = self._deepcopy_dispatch.get(cls)
|
||||
if copier is not None:
|
||||
# Check if this is an atomic copier (only accepts x and memo)
|
||||
if copier.__name__ == "_deepcopy_atomic":
|
||||
y = copier(x, memo)
|
||||
else:
|
||||
y = copier(x, memo, non_blocking=non_blocking)
|
||||
else:
|
||||
if issubclass(cls, type):
|
||||
# type copier is also atomic
|
||||
y = self._deepcopy_dispatch[type](x, memo)
|
||||
else:
|
||||
copier = getattr(x, "__deepcopy__", None)
|
||||
if copier is not None:
|
||||
y = copier(memo)
|
||||
else:
|
||||
reductor = dispatch_table.get(cls)
|
||||
if reductor:
|
||||
rv = reductor(x)
|
||||
else:
|
||||
reductor = getattr(x, "__reduce_ex__", None)
|
||||
if reductor is not None:
|
||||
rv = reductor(4)
|
||||
else:
|
||||
reductor = getattr(x, "__reduce__", None)
|
||||
if reductor:
|
||||
rv = reductor()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"un(deep)copyable object of type {cls}"
|
||||
)
|
||||
if isinstance(rv, str):
|
||||
y = x
|
||||
else:
|
||||
# Unpack rv tuple elements (up to 5 from pickle protocol)
|
||||
# and explicitly pass non_blocking as keyword arg
|
||||
if len(rv) == 2:
|
||||
func, args = rv
|
||||
y = self._reconstruct(
|
||||
x, memo, func, args, non_blocking=non_blocking
|
||||
)
|
||||
elif len(rv) == 3:
|
||||
func, args, state = rv
|
||||
y = self._reconstruct(
|
||||
x,
|
||||
memo,
|
||||
func,
|
||||
args,
|
||||
state,
|
||||
non_blocking=non_blocking,
|
||||
)
|
||||
elif len(rv) == 4:
|
||||
func, args, state, listiter = rv
|
||||
y = self._reconstruct(
|
||||
x,
|
||||
memo,
|
||||
func,
|
||||
args,
|
||||
state,
|
||||
listiter,
|
||||
non_blocking=non_blocking,
|
||||
)
|
||||
elif len(rv) == 5:
|
||||
func, args, state, listiter, dictiter = rv
|
||||
y = self._reconstruct(
|
||||
x,
|
||||
memo,
|
||||
func,
|
||||
args,
|
||||
state,
|
||||
listiter,
|
||||
dictiter,
|
||||
non_blocking=non_blocking,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Unexpected pickle protocol return value length: {len(rv)}"
|
||||
)
|
||||
|
||||
# If is its own copy, don't memoize.
|
||||
if y is not x:
|
||||
memo[d] = y
|
||||
self._keep_alive(x, memo) # Make sure x lives at least as long as d
|
||||
return y
|
||||
|
||||
def _keep_alive(self, x, memo):
|
||||
"""Keeps a reference to the object x in the memo.
|
||||
|
||||
Because we remember objects by their id, we have
|
||||
to assure that possibly temporary objects are kept
|
||||
alive by referencing them.
|
||||
We store a reference at the id of the memo, which should
|
||||
normally not be used unless someone tries to deepcopy
|
||||
the memo itself...
|
||||
"""
|
||||
try:
|
||||
memo[id(memo)].append(x)
|
||||
except KeyError:
|
||||
# aha, this is the first one :-)
|
||||
memo[id(memo)] = [x]
|
||||
|
||||
def _reconstruct(
|
||||
self,
|
||||
x,
|
||||
memo,
|
||||
func,
|
||||
args,
|
||||
state=None,
|
||||
listiter=None,
|
||||
dictiter=None,
|
||||
non_blocking=False,
|
||||
):
|
||||
deep = memo is not None
|
||||
if deep and args:
|
||||
args = tuple(
|
||||
self.deepcopy_with_tensor_offload(arg, memo, non_blocking=non_blocking)
|
||||
for arg in args
|
||||
)
|
||||
y = func(*args)
|
||||
if deep:
|
||||
memo[id(x)] = y
|
||||
|
||||
if state is not None:
|
||||
if deep:
|
||||
state = self.deepcopy_with_tensor_offload(
|
||||
state, memo, non_blocking=non_blocking
|
||||
)
|
||||
if hasattr(y, "__setstate__"):
|
||||
y.__setstate__(state)
|
||||
else:
|
||||
if isinstance(state, tuple) and len(state) == 2:
|
||||
state, slotstate = state
|
||||
else:
|
||||
slotstate = None
|
||||
if state is not None:
|
||||
y.__dict__.update(state)
|
||||
if slotstate is not None:
|
||||
for key, value in slotstate.items():
|
||||
setattr(y, key, value)
|
||||
|
||||
if listiter is not None:
|
||||
if deep:
|
||||
for item in listiter:
|
||||
item = self.deepcopy_with_tensor_offload(
|
||||
item, memo, non_blocking=non_blocking
|
||||
)
|
||||
y.append(item)
|
||||
else:
|
||||
for item in listiter:
|
||||
y.append(item)
|
||||
if dictiter is not None:
|
||||
if deep:
|
||||
for key, value in dictiter:
|
||||
key = self.deepcopy_with_tensor_offload(
|
||||
key, memo, non_blocking=non_blocking
|
||||
)
|
||||
value = self.deepcopy_with_tensor_offload(
|
||||
value, memo, non_blocking=non_blocking
|
||||
)
|
||||
y[key] = value
|
||||
else:
|
||||
for key, value in dictiter:
|
||||
y[key] = value
|
||||
return y
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
|
||||
from .filesystem import FileSystemReader, FileSystemWriter
|
||||
from .storage import StorageReader, StorageWriter
|
||||
|
||||
|
||||
def _storage_setup(
|
||||
storage: StorageReader | StorageWriter | None,
|
||||
checkpoint_id: str | os.PathLike | None,
|
||||
reader: bool = False,
|
||||
) -> StorageReader | StorageWriter | None:
|
||||
if storage:
|
||||
if checkpoint_id is not None:
|
||||
storage.reset(checkpoint_id)
|
||||
return storage
|
||||
|
||||
if not checkpoint_id:
|
||||
raise RuntimeError(
|
||||
"`checkpoint_id` must be specified if "
|
||||
"storage_reader/storage_writer is None."
|
||||
)
|
||||
|
||||
targets: list[type[StorageReader | StorageWriter]] = []
|
||||
if reader:
|
||||
targets = [
|
||||
FileSystemReader,
|
||||
]
|
||||
else:
|
||||
targets = [
|
||||
FileSystemWriter,
|
||||
]
|
||||
try:
|
||||
from ._fsspec_filesystem import FsspecReader, FsspecWriter
|
||||
|
||||
targets.append(FsspecReader if reader else FsspecWriter)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for target in targets:
|
||||
if target.validate_checkpoint_id(checkpoint_id):
|
||||
storage = target(checkpoint_id) # type: ignore[call-arg]
|
||||
storage.reset(checkpoint_id)
|
||||
return storage
|
||||
|
||||
raise RuntimeError(
|
||||
"Cannot detect which StorageReader or StorageWriter to use. "
|
||||
"Please specify the storage_reader/storage_writer."
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
# ruff: noqa: F821
|
||||
# flake8: noqa: F821
|
||||
from collections.abc import Callable, Collection, Mapping, MutableMapping
|
||||
from typing import cast, TypeVar
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
import torch
|
||||
from torch.distributed._shard.sharded_tensor.api import ShardedTensor
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
from torch.distributed.tensor import DTensor
|
||||
|
||||
|
||||
PATH_ITEM = str | int
|
||||
OBJ_PATH = tuple[PATH_ITEM, ...]
|
||||
T = TypeVar("T")
|
||||
|
||||
STATE_DICT_ITEM = object
|
||||
CONTAINER_TYPE = MutableMapping[PATH_ITEM, STATE_DICT_ITEM]
|
||||
|
||||
__all__ = ["traverse_state_dict", "set_element", "get_element", "print_tensor"]
|
||||
|
||||
|
||||
def _keep_visiting_tensors(value: STATE_DICT_ITEM) -> TypeIs[torch.Tensor]:
|
||||
return isinstance(value, torch.Tensor)
|
||||
|
||||
|
||||
# TODO: update docstring for traverse.py
|
||||
def traverse_state_dict(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
visitor: Callable[[OBJ_PATH, STATE_DICT_ITEM], None],
|
||||
keep_traversing: Callable[[STATE_DICT_ITEM], bool] = _keep_visiting_tensors,
|
||||
) -> None:
|
||||
"""
|
||||
Invoke ``visitor`` for each value recursively in ``state_dict``.
|
||||
Mapping will be traversed and ``visitor`` will be applied to the leaf elements.
|
||||
``visitor`` will only be applied to elements in a list or a tuple, if the
|
||||
container contains tensors or mappings.
|
||||
"""
|
||||
|
||||
def _is_terminal(value: STATE_DICT_ITEM) -> bool:
|
||||
values: Collection[STATE_DICT_ITEM]
|
||||
if isinstance(value, Mapping):
|
||||
return False
|
||||
elif isinstance(value, list):
|
||||
values = value
|
||||
else:
|
||||
return True
|
||||
|
||||
for entry in values:
|
||||
if isinstance(entry, (Mapping, list)) and not _is_terminal(entry):
|
||||
return False
|
||||
if keep_traversing is not None and keep_traversing(entry):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _traverse_obj(path: OBJ_PATH, value: STATE_DICT_ITEM) -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for k, v in value.items():
|
||||
_traverse_obj(path + (str(k),), v)
|
||||
elif _is_terminal(value):
|
||||
visitor(path, value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for i, v in enumerate(value):
|
||||
_traverse_obj(path + (i,), v)
|
||||
|
||||
for key, value in state_dict.items():
|
||||
_traverse_obj((str(key),), value)
|
||||
|
||||
# release reference cycle to prevent memory leaks in async_save
|
||||
del _traverse_obj, _is_terminal
|
||||
|
||||
|
||||
def traverse_state_dict_v_2_3(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
visitor: Callable[[OBJ_PATH, STATE_DICT_ITEM], None],
|
||||
keep_traversing: Callable[[STATE_DICT_ITEM], bool] = _keep_visiting_tensors,
|
||||
) -> None:
|
||||
"""
|
||||
Traversal is short-circuited when if finds a collection for which ``keep_visiting_tensors`` evaluates
|
||||
to false for all elements.
|
||||
By default, all collections with at least one ``torch.Tensor`` element are traversed.
|
||||
Visitor takes a path argument that is a tuple of the keys used to reach it.
|
||||
"""
|
||||
|
||||
# a value is terminal if it has no other containers values inside it
|
||||
def _is_terminal(value: STATE_DICT_ITEM) -> bool:
|
||||
values: Collection[STATE_DICT_ITEM]
|
||||
if isinstance(value, Mapping):
|
||||
values = value.values()
|
||||
elif isinstance(value, list):
|
||||
values = value
|
||||
else:
|
||||
return True
|
||||
|
||||
for entry in values:
|
||||
if isinstance(entry, (Mapping, list)) and not _is_terminal(entry):
|
||||
return False
|
||||
if keep_traversing is not None and keep_traversing(entry):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _traverse_obj(path: OBJ_PATH, value: STATE_DICT_ITEM) -> None:
|
||||
if _is_terminal(value):
|
||||
visitor(path, value)
|
||||
elif isinstance(value, Mapping):
|
||||
for k, v in value.items():
|
||||
_traverse_obj(path + (str(k),), v)
|
||||
elif isinstance(value, list):
|
||||
for i, v in enumerate(value):
|
||||
_traverse_obj(path + (i,), v)
|
||||
|
||||
for key, value in state_dict.items():
|
||||
_traverse_obj((str(key),), value)
|
||||
|
||||
# release reference cycle to prevent memory leaks in async_save
|
||||
del _traverse_obj, _is_terminal
|
||||
|
||||
|
||||
def set_element(
|
||||
root_dict: STATE_DICT_TYPE, path: OBJ_PATH, value: STATE_DICT_ITEM
|
||||
) -> None:
|
||||
"""Set ``value`` in ``root_dict`` along the ``path`` object path."""
|
||||
cur_container = cast(CONTAINER_TYPE, root_dict)
|
||||
|
||||
def extend_list(lst: list[STATE_DICT_ITEM], idx: int) -> None:
|
||||
while len(lst) <= idx:
|
||||
lst.append(None)
|
||||
|
||||
for i in range(1, len(path)):
|
||||
prev_key = path[i - 1]
|
||||
key = path[i]
|
||||
def_val = cast(STATE_DICT_ITEM, {} if type(key) is str else [])
|
||||
|
||||
if isinstance(cur_container, Mapping):
|
||||
cur_container = cast(
|
||||
CONTAINER_TYPE, cur_container.setdefault(prev_key, def_val)
|
||||
)
|
||||
else:
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
extend_list(cur_container, prev_key)
|
||||
if cur_container[prev_key] is None:
|
||||
cur_container[prev_key] = def_val
|
||||
cur_container = cur_container[prev_key]
|
||||
|
||||
key = path[-1]
|
||||
if type(key) is int:
|
||||
extend_list(cast(list[STATE_DICT_ITEM], cur_container), key)
|
||||
|
||||
cur_container[key] = value
|
||||
|
||||
|
||||
def get_element(
|
||||
root_dict: STATE_DICT_TYPE,
|
||||
path: OBJ_PATH,
|
||||
default_value: T | None = None,
|
||||
) -> T | None:
|
||||
"""Retrieve the value at ``path``from ``root_dict``, returning ``default_value`` if not found."""
|
||||
cur_value = cast(CONTAINER_TYPE, root_dict)
|
||||
for part in path:
|
||||
if type(part) is int:
|
||||
if not isinstance(cur_value, list) or len(cur_value) < part:
|
||||
return default_value
|
||||
elif not isinstance(cur_value, Mapping) or part not in cur_value:
|
||||
return default_value
|
||||
|
||||
cur_value = cast(CONTAINER_TYPE, cur_value[part])
|
||||
return cast(T | None, cur_value)
|
||||
|
||||
|
||||
def _print_nested(
|
||||
value: STATE_DICT_ITEM,
|
||||
prefix: str = "",
|
||||
print_fun: Callable[[str], None] = print,
|
||||
) -> None:
|
||||
if type(value) is ShardedTensor:
|
||||
print_fun(f"{prefix} ShardedTensor size: {value.size()}")
|
||||
for shard in value.local_shards():
|
||||
_print_nested(
|
||||
shard.tensor,
|
||||
f"{shard.metadata.shard_offsets} ",
|
||||
print_fun=print_fun,
|
||||
)
|
||||
elif type(value) is (DTensor):
|
||||
print_fun(f"{prefix} DistributedTensor size: {value.size()}")
|
||||
# TODO: add local offset for _local_tensor in print_nested.
|
||||
_print_nested(
|
||||
value._local_tensor,
|
||||
print_fun=print_fun,
|
||||
)
|
||||
elif isinstance(value, torch.Tensor):
|
||||
print_fun(f"{prefix} Tensor size: {value.size()}")
|
||||
else:
|
||||
print_fun(f"{prefix} Type: {type(value)}")
|
||||
|
||||
|
||||
def print_tensor(
|
||||
path: OBJ_PATH,
|
||||
value: STATE_DICT_ITEM,
|
||||
print_fun: Callable[[str], None] = print,
|
||||
) -> None:
|
||||
"""
|
||||
Use this callback with traverse_state_dict to print its content.
|
||||
|
||||
By default the content is printed using the builtin ``print`` but this can
|
||||
be change by passing a different ``print_fun` callable.
|
||||
"""
|
||||
_print_nested(value, prefix=str(path), print_fun=print_fun)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
|
||||
_derived_version: str | None = None
|
||||
@@ -0,0 +1,50 @@
|
||||
import traceback as tb
|
||||
from typing import Any
|
||||
|
||||
|
||||
WRAPPED_EXCEPTION = tuple[BaseException, tb.StackSummary]
|
||||
|
||||
__all__ = ["CheckpointException"]
|
||||
|
||||
|
||||
def _wrap_exception(exc: BaseException) -> WRAPPED_EXCEPTION:
|
||||
summary = tb.extract_tb(exc.__traceback__)
|
||||
# Python 3.13+ stores bytecode objects in FrameSummary._code,
|
||||
# which cannot be pickled. Clear them so gather_object succeeds
|
||||
# and the real exception is reported instead of a misleading
|
||||
# "cannot pickle code objects" TypeError.
|
||||
for frame in summary:
|
||||
if hasattr(frame, "_code"):
|
||||
object.__setattr__(frame, "_code", None)
|
||||
return (exc, summary)
|
||||
|
||||
|
||||
def _is_wrapped_exception(obj: Any) -> bool:
|
||||
if not isinstance(obj, tuple):
|
||||
return False
|
||||
if len(obj) != 2:
|
||||
return False
|
||||
return isinstance(obj[0], BaseException) and isinstance(obj[1], tb.StackSummary)
|
||||
|
||||
|
||||
class CheckpointException(BaseException):
|
||||
"""Exception raised if failure was detected as part of a checkpoint load or save."""
|
||||
|
||||
def __init__(self, msg: str, failures: dict[int, WRAPPED_EXCEPTION]):
|
||||
super().__init__(msg, failures)
|
||||
self._failures = failures
|
||||
|
||||
@property
|
||||
def failures(self) -> dict[int, WRAPPED_EXCEPTION]:
|
||||
"""Return a dictionary mapping node ranks to their associated exceptions in case of failure."""
|
||||
return self._failures
|
||||
|
||||
def __str__(self) -> str:
|
||||
str = f"CheckpointException ranks:{self._failures.keys()}\n"
|
||||
for rank, exc_pair in self._failures.items():
|
||||
exc, trace = exc_pair
|
||||
str += f"Traceback (most recent call last): (RANK {rank})\n"
|
||||
if trace is not None:
|
||||
str += "".join(tb.format_list(trace))
|
||||
str += "".join(tb.format_exception_only(type(exc), value=exc))
|
||||
return str
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
import dataclasses
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from bisect import bisect_right, insort
|
||||
from collections import ChainMap
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
from torch.distributed._shard._utils import narrow_tensor_by_index
|
||||
from torch.distributed.checkpoint._dedup_save_plans import dedup_save_plans
|
||||
from torch.distributed.checkpoint._nested_dict import (
|
||||
FLATTEN_MAPPING,
|
||||
flatten_state_dict,
|
||||
)
|
||||
from torch.distributed.checkpoint._sharded_tensor_utils import _flatten_sharded_tensors
|
||||
from torch.distributed.checkpoint._traverse import set_element
|
||||
from torch.distributed.checkpoint.metadata import (
|
||||
BytesStorageMetadata,
|
||||
ChunkStorageMetadata,
|
||||
Metadata,
|
||||
MetadataIndex,
|
||||
STATE_DICT_TYPE,
|
||||
STORAGE_TYPES,
|
||||
StorageMeta,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from torch.distributed.checkpoint.planner import (
|
||||
LoadPlan,
|
||||
LoadPlanner,
|
||||
ReadItem,
|
||||
SavePlan,
|
||||
SavePlanner,
|
||||
WriteItem,
|
||||
WriteItemType,
|
||||
)
|
||||
from torch.distributed.checkpoint.planner_helpers import (
|
||||
_compare_save_plans,
|
||||
_contains_usable_plan,
|
||||
_create_default_metadata_only_plan,
|
||||
_create_read_items,
|
||||
_create_write_items,
|
||||
_init_state_dict,
|
||||
_merge_delta_local_plans,
|
||||
)
|
||||
from torch.distributed.checkpoint.utils import find_state_dict_object
|
||||
from torch.distributed.tensor import DTensor
|
||||
|
||||
from . import _version
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefaultSavePlanner",
|
||||
"DefaultLoadPlanner",
|
||||
"create_default_local_load_plan",
|
||||
"create_default_global_load_plan",
|
||||
"create_default_local_save_plan",
|
||||
"create_default_global_save_plan",
|
||||
]
|
||||
|
||||
|
||||
# TODO: Update docstrings for default_planner.py
|
||||
class DefaultSavePlanner(SavePlanner):
|
||||
mappings: FLATTEN_MAPPING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flatten_state_dict: bool = True,
|
||||
flatten_sharded_tensors: bool = True,
|
||||
dedup_replicated_tensors: bool | None = None,
|
||||
dedup_save_to_lowest_rank: bool = False,
|
||||
enable_plan_caching: bool = False,
|
||||
) -> None:
|
||||
self.flatten_state_dict = flatten_state_dict
|
||||
self.flatten_sharded_tensors = flatten_sharded_tensors
|
||||
self.mappings = {}
|
||||
self.dedup_save_to_lowest_rank = dedup_save_to_lowest_rank
|
||||
if dedup_replicated_tensors is not None:
|
||||
logger.warning(
|
||||
"DefaultSavePlanner's `dedup_replicated_tensors` argument is being "
|
||||
"deprecated, and no longer has any effect. Please remove this argument "
|
||||
"from your call."
|
||||
)
|
||||
self._cached_plans_key: str = self.__class__.__name__
|
||||
self._enable_plan_caching = enable_plan_caching
|
||||
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
storage_meta: StorageMeta | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
if self.flatten_state_dict:
|
||||
state_dict, self.mappings = flatten_state_dict(state_dict)
|
||||
if self.flatten_sharded_tensors:
|
||||
state_dict = _flatten_sharded_tensors(state_dict)
|
||||
self.state_dict = state_dict
|
||||
self.is_coordinator = is_coordinator
|
||||
|
||||
def create_local_plan(self) -> SavePlan:
|
||||
plan = create_default_local_save_plan(self.state_dict, self.is_coordinator)
|
||||
if self.flatten_state_dict:
|
||||
plan = dataclasses.replace(plan, planner_data=self.mappings)
|
||||
self.plan = plan
|
||||
|
||||
if self._enable_plan_caching:
|
||||
# If plans are equal, we can skip sending the plan to the coordinator.
|
||||
if (
|
||||
self._cached_plans_key in SavePlanner._cached_save_plan
|
||||
and _compare_save_plans(
|
||||
plan, SavePlanner._cached_save_plan[self._cached_plans_key]
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
"No change in the local plan. Skipping sending the plan to the coordinator"
|
||||
)
|
||||
return SavePlan([], usable=False)
|
||||
else:
|
||||
# Store the plan as pending. It will be promoted to the
|
||||
# class-level cache in finish_plan after the global plan
|
||||
# has succeeded. This avoids a stale local cache when
|
||||
# the global plan fails (e.g. validation error) but the
|
||||
# local cache was already populated.
|
||||
self._pending_local_plan = plan
|
||||
|
||||
return self.plan
|
||||
|
||||
def _dedup_save_plans(self, all_plans: list[SavePlan]) -> list[SavePlan]:
|
||||
return dedup_save_plans(all_plans, self.dedup_save_to_lowest_rank)
|
||||
|
||||
def _create_global_plan(
|
||||
self, all_plans: list[SavePlan]
|
||||
) -> tuple[list[SavePlan], Metadata]:
|
||||
deduped_plans = self._dedup_save_plans(all_plans)
|
||||
|
||||
global_plan, metadata = create_default_global_save_plan(deduped_plans)
|
||||
|
||||
if self.flatten_state_dict:
|
||||
# | does not work for Python 3.8 or older version.
|
||||
# merged_mappings = reduce(
|
||||
# lambda x, y: x | y, (p.planner_data for p in global_plan)
|
||||
# )
|
||||
planner_data_dict = [p.planner_data for p in global_plan]
|
||||
merged_mappings = dict(ChainMap(*planner_data_dict))
|
||||
metadata = dataclasses.replace(metadata, planner_data=merged_mappings)
|
||||
|
||||
validation_errors = _validate_global_plan(global_plan, metadata)
|
||||
if validation_errors:
|
||||
error_summary = "; ".join(validation_errors)
|
||||
if len(error_summary) > 500:
|
||||
error_summary = error_summary[:500] + "... (truncated)"
|
||||
raise ValueError(f"Failed to validate global plan: {error_summary}")
|
||||
|
||||
return global_plan, metadata
|
||||
|
||||
def _create_global_plan_with_caching(
|
||||
self, all_plans: list[SavePlan]
|
||||
) -> tuple[list[SavePlan], list[SavePlan], Metadata]:
|
||||
"""
|
||||
Create global plan with caching.
|
||||
Returns a tuple of global_plan_delta, global_plan, metadata.
|
||||
"""
|
||||
global_plan_delta: list[SavePlan] = []
|
||||
|
||||
if self._cached_plans_key not in SavePlanner._cached_all_plans:
|
||||
# Case 1: If the plans are not cached, the cache will be hydrated with the
|
||||
# all_plans, global_plans (Deduped), and metadata.
|
||||
|
||||
# First create and validate the global plan. Only cache everything
|
||||
# after success to avoid partial cache state
|
||||
global_plan, metadata = self._create_global_plan(all_plans)
|
||||
|
||||
# Cache all plans atomically after successful validation
|
||||
SavePlanner._cached_all_plans[self._cached_plans_key] = all_plans
|
||||
SavePlanner._cached_global_plan[self._cached_plans_key] = global_plan
|
||||
SavePlanner._cached_metadata[self._cached_plans_key] = metadata
|
||||
# If plans are not cached, global_plan delta will be the same as global plan.
|
||||
return global_plan, global_plan, metadata
|
||||
|
||||
# Case 2: Plans are cached
|
||||
if not _contains_usable_plan(all_plans):
|
||||
# Case 2.1: Plans are cached and the local plans have NOT changed (No usable plans).
|
||||
# Global plan delta will be empty plans to avoid the collective overhead.
|
||||
# We can reuse the deduped global plan and metadata from the cache directly.
|
||||
global_plan_delta = [SavePlan([], usable=False)] * len(all_plans)
|
||||
global_plan = SavePlanner._cached_global_plan[self._cached_plans_key]
|
||||
metadata = SavePlanner._cached_metadata[self._cached_plans_key]
|
||||
else:
|
||||
# Case 2.2: Plans are cached but the local plans have changed.
|
||||
# We will merge the changed local plans with the cached local plans.
|
||||
# Updated plans will overwrite the cached plans. New global plan and metadata will be created and cached.
|
||||
# Global plan delta will be created by comparing the new global plan with the cached global plan.
|
||||
# Only the global plan delta (updated ones) will be sent to the coordinator to avoid the collective overhead.
|
||||
merged_plans = _merge_delta_local_plans(
|
||||
SavePlanner._cached_all_plans[self._cached_plans_key], all_plans
|
||||
)
|
||||
# Cache the updated local plans
|
||||
SavePlanner._cached_all_plans[self._cached_plans_key] = merged_plans
|
||||
global_plan, metadata = self._create_global_plan(merged_plans)
|
||||
|
||||
if self._cached_plans_key in self._cached_global_plan:
|
||||
for cached_plan, new_plan in zip(
|
||||
SavePlanner._cached_global_plan[self._cached_plans_key], global_plan
|
||||
):
|
||||
if _compare_save_plans(cached_plan, new_plan):
|
||||
global_plan_delta.append(SavePlan([], usable=False))
|
||||
else:
|
||||
global_plan_delta.append(new_plan)
|
||||
|
||||
# Cache the new global plan and the metadata
|
||||
SavePlanner._cached_global_plan[self._cached_plans_key] = global_plan
|
||||
SavePlanner._cached_metadata[self._cached_plans_key] = metadata
|
||||
|
||||
return global_plan_delta, global_plan, metadata
|
||||
|
||||
def create_global_plan(
|
||||
self, all_plans: list[SavePlan]
|
||||
) -> tuple[list[SavePlan], Metadata]:
|
||||
global_plan_delta: list[SavePlan] = []
|
||||
if self._enable_plan_caching:
|
||||
# If the plans are cached, we only need to send the global plan delta to be scattered
|
||||
# across ranks. Ranks will use the cached final plans instead.
|
||||
(
|
||||
global_plan_delta,
|
||||
global_plan,
|
||||
metadata,
|
||||
) = self._create_global_plan_with_caching(all_plans)
|
||||
else:
|
||||
global_plan, metadata = self._create_global_plan(all_plans)
|
||||
# If the caching is not enabled, global delta plan will always be same as the new global plan.
|
||||
global_plan_delta = global_plan
|
||||
|
||||
self.global_plan = global_plan
|
||||
self.metadata = metadata
|
||||
|
||||
return global_plan_delta, self.metadata
|
||||
|
||||
def _finish_plan_with_caching(self, new_plan: SavePlan) -> SavePlan:
|
||||
finished_plan: SavePlan = new_plan
|
||||
|
||||
if not new_plan.usable:
|
||||
finished_plan = SavePlanner._cached_final_save_plan[self._cached_plans_key]
|
||||
else:
|
||||
finished_plan = new_plan
|
||||
SavePlanner._cached_final_save_plan[self._cached_plans_key] = new_plan
|
||||
return finished_plan
|
||||
|
||||
def finish_plan(self, new_plan: SavePlan) -> SavePlan:
|
||||
finished_plan: SavePlan = new_plan
|
||||
|
||||
if self._enable_plan_caching:
|
||||
finished_plan = self._finish_plan_with_caching(new_plan)
|
||||
|
||||
# Promote the pending local plan to the class-level cache now
|
||||
# that the global plan has succeeded and we are finalizing.
|
||||
# This ensures the local cache is only populated after a
|
||||
# successful end-to-end checkpoint plan creation.
|
||||
if hasattr(self, "_pending_local_plan"):
|
||||
SavePlanner._cached_save_plan[self._cached_plans_key] = (
|
||||
self._pending_local_plan
|
||||
)
|
||||
del self._pending_local_plan
|
||||
|
||||
self.plan = finished_plan
|
||||
return self.plan
|
||||
|
||||
def resolve_data(self, write_item: WriteItem) -> torch.Tensor | io.BytesIO:
|
||||
object = self.lookup_object(write_item.index)
|
||||
return self.transform_object(write_item, object)
|
||||
|
||||
def lookup_object(self, index: MetadataIndex) -> Any:
|
||||
"""Extension from the planner interface to make it easy to extend the default planner."""
|
||||
return find_state_dict_object(self.state_dict, index)
|
||||
|
||||
def transform_object(self, write_item: WriteItem, object: Any):
|
||||
"""Extension from the planner interface to make it easy to extend the default planner."""
|
||||
if write_item.type == WriteItemType.BYTE_IO:
|
||||
bytes = io.BytesIO()
|
||||
torch.save(object, bytes)
|
||||
object = bytes
|
||||
return object
|
||||
|
||||
|
||||
class DefaultLoadPlanner(LoadPlanner):
|
||||
"""
|
||||
DefaultLoadPlanner that adds multiple features on top of LoadPlanner.
|
||||
|
||||
In particular it adds the following:
|
||||
|
||||
flatten_state_dict: Handle state_dict with nested dicts
|
||||
flatten_sharded_tensors: For FSDP in 2D parallel mode
|
||||
allow_partial_load: If False, will raise a runtime error if a key is present in state_dict, but not in the checkpoint.
|
||||
"""
|
||||
|
||||
original_state_dict: STATE_DICT_TYPE
|
||||
mappings: FLATTEN_MAPPING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flatten_state_dict: bool = True,
|
||||
flatten_sharded_tensors: bool = True,
|
||||
allow_partial_load: bool = False,
|
||||
) -> None:
|
||||
self.flatten_state_dict = flatten_state_dict
|
||||
self.flatten_sharded_tensors = flatten_sharded_tensors
|
||||
self.original_state_dict = {}
|
||||
self.mappings = {}
|
||||
self.allow_partial_load = allow_partial_load
|
||||
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
metadata: Metadata | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
_init_state_dict(state_dict)
|
||||
self.original_state_dict = state_dict
|
||||
|
||||
if self.flatten_sharded_tensors:
|
||||
state_dict = _flatten_sharded_tensors(state_dict)
|
||||
|
||||
if self.flatten_state_dict:
|
||||
state_dict, self.mappings = flatten_state_dict(state_dict)
|
||||
|
||||
self.state_dict = state_dict
|
||||
self.metadata = metadata
|
||||
self.is_coordinator = is_coordinator
|
||||
|
||||
def create_local_plan(self) -> LoadPlan:
|
||||
if self.metadata is None:
|
||||
raise AssertionError("self.metadata is not None")
|
||||
if self.flatten_state_dict:
|
||||
# To support checkpoints that are saved before v2.4, we have to
|
||||
# differentiate if the missing keys are due to old checkpoints.
|
||||
# The contracts are:
|
||||
# 1. There are 3 cases when we found a missing key.
|
||||
# 1.1 Actual missing key, but allow_partial_load is False
|
||||
# 1.2 Actual missing key, but allow_partial load is True
|
||||
# 1.3 Old checkpoint, but allow_partial_load is False
|
||||
# 1.4 Old checkpoint, but allow_partial_load is True
|
||||
# 2. If we found a missing key, we first convert the keys back to
|
||||
# the key format of v2.3
|
||||
# 3. If the previous missing keys are in the v2.3 keys, we assume
|
||||
# this is a old checkpoint.
|
||||
# 4. Pass the state_dict to `create_default_local_load_plan()`,
|
||||
# which has the logic to check missing for allow_partial_load.
|
||||
# So for 1.2 and 1.4 cases, we delegate allow_partial_load check to
|
||||
# `create_default_local_load_plan()`. The logic here is to determine
|
||||
# whether the checkpoint belong to 2.3 (or before) or 2.4 (or after).
|
||||
current_keys = set(self.state_dict.keys())
|
||||
load_keys = set(self.metadata.state_dict_metadata.keys())
|
||||
missing_keys = load_keys - current_keys
|
||||
if missing_keys:
|
||||
_version._derived_version = "2_3"
|
||||
old_state_dict, old_mappings = flatten_state_dict(
|
||||
self.original_state_dict
|
||||
)
|
||||
old_keys = set(old_state_dict.keys())
|
||||
if old_keys & missing_keys:
|
||||
self.state_dict, self.mappings = old_state_dict, old_mappings
|
||||
# _derived_version is only used by flatten_state_dict now.
|
||||
# Set it back to None so that later we can save to a new version.
|
||||
_version._derived_version = None
|
||||
|
||||
return create_default_local_load_plan(
|
||||
self.state_dict, self.metadata, not self.allow_partial_load
|
||||
)
|
||||
|
||||
def create_global_plan(self, global_plan: list[LoadPlan]) -> list[LoadPlan]:
|
||||
return create_default_global_load_plan(global_plan)
|
||||
|
||||
def finish_plan(self, new_plan: LoadPlan) -> LoadPlan:
|
||||
return new_plan
|
||||
|
||||
def load_bytes(self, read_item: ReadItem, value: io.BytesIO) -> None:
|
||||
if self.flatten_state_dict:
|
||||
set_element(
|
||||
self.original_state_dict,
|
||||
self.mappings[read_item.dest_index.fqn],
|
||||
torch.load(value, weights_only=False),
|
||||
)
|
||||
else:
|
||||
self.state_dict[read_item.dest_index.fqn] = torch.load(
|
||||
value, weights_only=False
|
||||
)
|
||||
|
||||
def resolve_tensor(self, read_item: ReadItem):
|
||||
tensor = self.lookup_tensor(read_item.dest_index)
|
||||
return self.transform_tensor(read_item, tensor)
|
||||
|
||||
def commit_tensor(self, read_item: ReadItem, tensor: torch.Tensor) -> None:
|
||||
pass
|
||||
|
||||
def lookup_tensor(self, index: MetadataIndex) -> torch.Tensor:
|
||||
"""Extension from the planner interface to make it easy to extend the default planner."""
|
||||
return find_state_dict_object(self.state_dict, index)
|
||||
|
||||
def transform_tensor(self, read_item: ReadItem, tensor: torch.Tensor):
|
||||
"""Extension from the planner interface to make it easy to extend the default planner."""
|
||||
return narrow_tensor_by_index(tensor, read_item.dest_offsets, read_item.lengths)
|
||||
|
||||
|
||||
class _EmptyStateDictLoadPlanner(DefaultLoadPlanner):
|
||||
"""
|
||||
Extension of DefaultLoadPlanner, which rebuilds state_dict from the saved metadata.
|
||||
Useful for loading in state_dict without first initializing a model, such as
|
||||
when converting a DCP checkpoint into a Torch save file.
|
||||
|
||||
. N.B. `state_dict` must be an empty dictionary when used with this LoadPlanner
|
||||
|
||||
.. warning::
|
||||
Because the entire state dict is initialized, It's recommended to only utilize
|
||||
this LoadPlanner on a single rank or process to avoid OOM.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, keys=None, *args, **kwargs):
|
||||
self.keys = keys
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _should_include_key(self, key: str, metadata: Metadata) -> bool:
|
||||
if self.keys is None:
|
||||
return True
|
||||
|
||||
if key in self.keys:
|
||||
return True
|
||||
|
||||
unflattened_keys: list[str] = []
|
||||
planner_data = metadata.planner_data.get(key)
|
||||
for unflattened_key in planner_data:
|
||||
if unflattened_keys:
|
||||
unflattened_keys.append(
|
||||
".".join([unflattened_keys[-1], str(unflattened_key)])
|
||||
)
|
||||
|
||||
else:
|
||||
unflattened_keys.append(unflattened_key)
|
||||
|
||||
if any(unflattened_key in self.keys for unflattened_key in unflattened_keys):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
metadata: Metadata | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
if state_dict:
|
||||
raise AssertionError("not state_dict")
|
||||
if metadata is None:
|
||||
raise AssertionError("metadata is not None")
|
||||
|
||||
# rebuild the state dict from the metadata
|
||||
for k, v in metadata.state_dict_metadata.items():
|
||||
if not self._should_include_key(k, metadata):
|
||||
continue
|
||||
|
||||
if isinstance(v, TensorStorageMetadata):
|
||||
v = torch.empty(v.size, dtype=v.properties.dtype) # type: ignore[assignment]
|
||||
if metadata.planner_data is not None and k in metadata.planner_data:
|
||||
set_element(state_dict, metadata.planner_data[k], v)
|
||||
else:
|
||||
state_dict[k] = v
|
||||
|
||||
super().set_up_planner(state_dict, metadata, is_coordinator)
|
||||
|
||||
|
||||
def create_default_local_load_plan(
|
||||
state_dict: dict[str, Any], metadata: Metadata, strict: bool = True
|
||||
) -> LoadPlan:
|
||||
requests = []
|
||||
"""
|
||||
Create the ``LoadPlan`` used by DefaultLoadPlanner.
|
||||
|
||||
It produces one read item per value in ``state_dict`` using the metadata in ``metadata``.
|
||||
|
||||
The default behavior is to match key exactly between state_dict and metadata.
|
||||
It handles resharding by issuing multiple read requests against storage in order to match
|
||||
load requirements.
|
||||
"""
|
||||
|
||||
for fqn, obj in state_dict.items():
|
||||
# ignore state_dict keys which do not exist in `state_dict` if strict=False
|
||||
if fqn not in metadata.state_dict_metadata:
|
||||
if strict:
|
||||
raise RuntimeError(f"Missing key in checkpoint state_dict: {fqn}.")
|
||||
else:
|
||||
continue
|
||||
|
||||
md = metadata.state_dict_metadata[fqn]
|
||||
if (
|
||||
isinstance(md, TensorStorageMetadata)
|
||||
and getattr(obj, "size", None) is not None
|
||||
and md.size != obj.size()
|
||||
):
|
||||
raise ValueError(
|
||||
f"Size mismatch between saved {md.size} and current: {obj.size()} for {fqn}",
|
||||
)
|
||||
# Since DTensor supports submesh, adding extra check to ensure _create_read_items()
|
||||
# gets called only when the current rank is part of the mesh for the corresponding DTensor.
|
||||
if isinstance(obj, DTensor):
|
||||
if obj.device_mesh.get_coordinate() is not None:
|
||||
requests += _create_read_items(fqn, md, obj)
|
||||
else:
|
||||
requests += _create_read_items(fqn, md, obj)
|
||||
|
||||
return LoadPlan(requests)
|
||||
|
||||
|
||||
def create_default_global_load_plan(
|
||||
all_plans: list[LoadPlan],
|
||||
) -> list[LoadPlan]:
|
||||
"""
|
||||
Create global load plan used by DefaultLoadPlanner.
|
||||
|
||||
The default load behavior involved no global coordination and this function
|
||||
currently doesn't change the local plans.
|
||||
"""
|
||||
return all_plans
|
||||
|
||||
|
||||
def create_default_local_save_plan(
|
||||
state_dict: dict[str, Any], is_coordinator: bool
|
||||
) -> SavePlan:
|
||||
"""
|
||||
Create the ``SavePlan`` used by DefaultSavePlanner.
|
||||
|
||||
On non-coordinator ranks, this function ignores tensors and non-tensor objects,
|
||||
only producing writes for ShardedTensor objects.
|
||||
|
||||
On the coordinator rank, produce writes for all values.
|
||||
"""
|
||||
requests = []
|
||||
for fqn, obj in state_dict.items():
|
||||
# Since DTensor supports submesh, adding extra check to ensure _create_write_items()
|
||||
# gets called only when the current rank is part of the mesh for the corresponding DTensor.
|
||||
if isinstance(obj, DTensor):
|
||||
if obj.device_mesh.get_coordinate() is not None:
|
||||
requests += _create_write_items(fqn, obj)
|
||||
else:
|
||||
# For the plain tensor and non-tensor values, add the request for all
|
||||
# the ranks. Coordinator will decides whether to deduplicate the
|
||||
# values based on the keys.
|
||||
requests += _create_write_items(fqn, obj)
|
||||
|
||||
return SavePlan(requests)
|
||||
|
||||
|
||||
def create_default_global_save_plan(
|
||||
all_plans: list[SavePlan],
|
||||
rewrite_index_hints: bool = True,
|
||||
) -> tuple[list[SavePlan], Metadata]:
|
||||
"""
|
||||
Create the global plan and metadata used by DefaultSavePlanner.
|
||||
|
||||
Metadata is produced by concatenating the metadata of all ``WriteItem`` from the supplied plans.
|
||||
|
||||
The only global planning change is to update index hints in all ``MetadataIndex`` objects if
|
||||
``rewrite_index_hints`` is True.
|
||||
"""
|
||||
md: dict[str, STORAGE_TYPES] = {}
|
||||
new_plans = []
|
||||
for plan in all_plans:
|
||||
new_items = []
|
||||
for item in plan.items:
|
||||
if item.type != WriteItemType.SHARD:
|
||||
if item.index.fqn in md:
|
||||
raise AssertionError("item.index.fqn not in md")
|
||||
|
||||
if item.type == WriteItemType.BYTE_IO:
|
||||
md[item.index.fqn] = BytesStorageMetadata()
|
||||
new_items.append(item)
|
||||
else:
|
||||
if item.tensor_data is None:
|
||||
raise AssertionError("item.tensor_data is not None")
|
||||
tensor_md = cast(
|
||||
TensorStorageMetadata,
|
||||
md.setdefault(
|
||||
item.index.fqn,
|
||||
TensorStorageMetadata(
|
||||
properties=item.tensor_data.properties,
|
||||
size=item.tensor_data.size,
|
||||
chunks=[],
|
||||
),
|
||||
),
|
||||
)
|
||||
new_item = item
|
||||
if rewrite_index_hints:
|
||||
new_index = dataclasses.replace(
|
||||
item.index, index=len(tensor_md.chunks)
|
||||
)
|
||||
new_item = dataclasses.replace(item, index=new_index)
|
||||
new_items.append(new_item)
|
||||
|
||||
if item.tensor_data.chunk is None:
|
||||
raise AssertionError(f"""
|
||||
Cannot create MD for tensor without bounds.
|
||||
FQN: {item.index.fqn}
|
||||
""")
|
||||
tensor_md.chunks.append(item.tensor_data.chunk)
|
||||
new_plans.append(dataclasses.replace(plan, items=new_items))
|
||||
return (new_plans, Metadata(md))
|
||||
|
||||
|
||||
def _create_default_local_metadata(state_dict: STATE_DICT_TYPE) -> Metadata:
|
||||
"""Return the ``Metadata`` if DefaultSavePlanner was used to checkpoint ``state_dict``."""
|
||||
plan = _create_default_metadata_only_plan(state_dict)
|
||||
_, md = create_default_global_save_plan([plan])
|
||||
return md
|
||||
|
||||
|
||||
def _check_box_overlap(box0: ChunkStorageMetadata, box1: ChunkStorageMetadata) -> bool:
|
||||
"""Check if two boxes overlap. Tuples are (offset, lengths)."""
|
||||
# For each dim of each shard, check if one shard resides on the other
|
||||
# end of second shard with respect to that dim. As an example for a 2D
|
||||
# shard, we would check if one shard is above or on the left of the
|
||||
# other shard.
|
||||
ndims = len(box0.offsets)
|
||||
for i in range(ndims):
|
||||
if box0.offsets[i] >= box1.offsets[i] + box1.sizes[i]:
|
||||
return False
|
||||
if box1.offsets[i] >= box0.offsets[i] + box0.sizes[i]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _check_box_bounds(
|
||||
outer_box_size: torch.Size, inner_box: ChunkStorageMetadata
|
||||
) -> bool:
|
||||
for i in range(len(outer_box_size)):
|
||||
if inner_box.offsets[i] < 0:
|
||||
return False
|
||||
if inner_box.sizes[i] < 0:
|
||||
return False
|
||||
if inner_box.offsets[i] + inner_box.sizes[i] > outer_box_size[i]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_global_plan(global_plan: list[SavePlan], metadata: Metadata) -> list[str]:
|
||||
"""Validate the global plan and return a list of error messages (empty if valid)."""
|
||||
errors: list[str] = []
|
||||
for key, value in metadata.state_dict_metadata.items():
|
||||
if isinstance(value, BytesStorageMetadata):
|
||||
continue
|
||||
if len(value.size) == 0:
|
||||
continue
|
||||
chunks = value.chunks
|
||||
chunks_volume = 0
|
||||
for chunk in chunks:
|
||||
# Compute the volume
|
||||
if not _check_box_bounds(value.size, chunk):
|
||||
msg = (
|
||||
f"key:{key} has out of bounds chunk: "
|
||||
f"tensor-size:{value.size} chunk: {chunk}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
errors.append(msg)
|
||||
chunks_volume += math.prod(chunk.sizes)
|
||||
|
||||
if len(chunks) > 1:
|
||||
dims = len(value.size)
|
||||
sweep_dim = max(range(dims), default=0, key=lambda d: value.size[d])
|
||||
sorted_indices = sorted(
|
||||
range(len(chunks)),
|
||||
key=lambda idx: (
|
||||
chunks[idx].offsets[sweep_dim],
|
||||
*(chunks[idx].offsets[d] for d in range(dims)),
|
||||
),
|
||||
)
|
||||
active: list[tuple[int, int]] = []
|
||||
for idx in sorted_indices:
|
||||
current = chunks[idx]
|
||||
start = current.offsets[sweep_dim]
|
||||
end = start + current.sizes[sweep_dim]
|
||||
|
||||
cutoff = bisect_right(active, (start, sys.maxsize))
|
||||
if cutoff:
|
||||
del active[:cutoff]
|
||||
|
||||
for _, other_idx in active:
|
||||
other = chunks[other_idx]
|
||||
if _check_box_overlap(current, other):
|
||||
msg = f"key:{key} has overlapping chunks: {current} {other}"
|
||||
logger.warning(msg)
|
||||
errors.append(msg)
|
||||
|
||||
insort(active, (end, idx))
|
||||
|
||||
# Check whether combined chunk cover the whole tensor
|
||||
tensor_volume = math.prod(value.size)
|
||||
if len(global_plan) > 1 and chunks_volume != tensor_volume:
|
||||
msg = (
|
||||
f"key:{key} invalid fill tensor-volume: "
|
||||
f"{tensor_volume} chunks-volume: {chunks_volume}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
errors.append(msg)
|
||||
|
||||
return errors
|
||||
File diff suppressed because it is too large
Load Diff
+292
@@ -0,0 +1,292 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import argparse
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed._shard._utils import narrow_tensor_by_index
|
||||
from torch.distributed.checkpoint import FileSystemReader, FileSystemWriter
|
||||
from torch.distributed.checkpoint._nested_dict import flatten_state_dict
|
||||
from torch.distributed.checkpoint.default_planner import (
|
||||
_EmptyStateDictLoadPlanner,
|
||||
DefaultLoadPlanner,
|
||||
)
|
||||
from torch.distributed.checkpoint.metadata import (
|
||||
Metadata,
|
||||
STATE_DICT_TYPE,
|
||||
STORAGE_TYPES,
|
||||
TensorProperties,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from torch.distributed.checkpoint.planner import LoadItemType, LoadPlan, LoadPlanner
|
||||
from torch.distributed.checkpoint.planner_helpers import _create_chunk_list
|
||||
from torch.distributed.checkpoint.state_dict_loader import _load_state_dict
|
||||
from torch.distributed.checkpoint.state_dict_saver import _save_state_dict
|
||||
from torch.distributed.checkpoint.storage import StorageReader
|
||||
from torch.futures import Future
|
||||
|
||||
|
||||
__all__ = [
|
||||
"dcp_to_torch_save",
|
||||
"torch_save_to_dcp",
|
||||
"BroadcastingTorchSaveReader",
|
||||
"DynamicMetaLoadPlanner",
|
||||
]
|
||||
|
||||
|
||||
class BroadcastingTorchSaveReader(StorageReader):
|
||||
"""
|
||||
StorageReader for reading a Torch Save file. This reader will read the entire checkpoint
|
||||
on the coordinator rank, and then broadcast and shard each tensor to all ranks.
|
||||
|
||||
. N.B. Intended to be used with DynamicMetaLoadPlanner
|
||||
|
||||
.. warning::
|
||||
Current implementation only supports loading Tensors.
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> sd = {"mode": model}
|
||||
>>> dcp.load(
|
||||
>>> sd,
|
||||
>>> storage_reader=BroadcastingTorchSaveReader(),
|
||||
>>> planner=DynamicMetaLoadPlanner(),
|
||||
>>> checkpoint_id="path_to_model.pt"
|
||||
>>> )
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
) -> None:
|
||||
self.checkpoint_id = checkpoint_id
|
||||
self.coordinator_rank = coordinator_rank
|
||||
|
||||
# pyrefly: ignore [bad-override]
|
||||
def read_metadata(self) -> Metadata:
|
||||
"""Extends the default StorageReader to support building the metadata file"""
|
||||
# Metadata is built in planner.set_up_planner, since we are not actually reading metadata from
|
||||
# the disk
|
||||
return Metadata(state_dict_metadata={})
|
||||
|
||||
def read_data(self, plan: LoadPlan, planner: LoadPlanner) -> Future[None]:
|
||||
"""
|
||||
Reads torch save data on the coordinator rank, and broadcast afterwards
|
||||
this incurrs a communication cost, but avoids having to load
|
||||
the entire checkpoint on each rank, hopefully preventing OOM issues
|
||||
"""
|
||||
planner = cast(DefaultLoadPlanner, planner)
|
||||
|
||||
# data is read in on the coordinator rank, and broadcast afterwards
|
||||
# this incurs a communication cost, but it avoids having to load
|
||||
# the entire checkpoint on each rank, hopefully preventing OOM issues
|
||||
# TODO: read on each host, instead of only the coordinator
|
||||
if self.is_coordinator:
|
||||
if self.checkpoint_id is None:
|
||||
raise AssertionError("checkpoint_id must be set before reading data")
|
||||
torch_state_dict = torch.load(
|
||||
self.checkpoint_id, map_location="cpu", weights_only=False
|
||||
)
|
||||
if planner.flatten_state_dict:
|
||||
torch_state_dict, _ = flatten_state_dict(torch_state_dict)
|
||||
else:
|
||||
torch_state_dict = None
|
||||
|
||||
for req in plan.items:
|
||||
if req.type == LoadItemType.BYTE_IO:
|
||||
raise RuntimeError(
|
||||
f"Non-tensor value identified at {req.storage_index.fqn}. "
|
||||
f"At this time {type(self).__name__} only supports loading Tensors."
|
||||
)
|
||||
|
||||
# Broadcast the tensor from the coordinator rank
|
||||
if self.is_coordinator:
|
||||
pg_device = dist.distributed_c10d._get_pg_default_device()
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
tensor = torch_state_dict[req.storage_index.fqn].to(pg_device)
|
||||
else:
|
||||
tensor = torch.empty_like(planner.state_dict[req.storage_index.fqn])
|
||||
|
||||
dist.broadcast(tensor, src=self.coordinator_rank, async_op=False)
|
||||
|
||||
tensor = narrow_tensor_by_index(tensor, req.storage_offsets, req.lengths)
|
||||
target_tensor = planner.resolve_tensor(req).detach()
|
||||
if not target_tensor.size() == tensor.size():
|
||||
raise AssertionError(
|
||||
f"req {req.storage_index} mismatch sizes, "
|
||||
f"{target_tensor.size()} vs {tensor.size()}"
|
||||
)
|
||||
target_tensor.copy_(tensor)
|
||||
planner.commit_tensor(req, target_tensor)
|
||||
|
||||
fut: Future = Future()
|
||||
fut.set_result(None)
|
||||
return fut
|
||||
|
||||
# pyrefly: ignore [bad-override]
|
||||
def set_up_storage_reader(self, metadata: Metadata, is_coordinator: bool) -> None:
|
||||
"""Implementation of the StorageReader method"""
|
||||
self.is_coordinator = is_coordinator
|
||||
if self.is_coordinator:
|
||||
if not dist.get_rank() == self.coordinator_rank:
|
||||
raise AssertionError(
|
||||
f"Coordinator rank mismatch: expected {self.coordinator_rank}, "
|
||||
f"got {dist.get_rank()}"
|
||||
)
|
||||
|
||||
if self.checkpoint_id is None:
|
||||
raise AssertionError(
|
||||
"checkpoint_id must be set before setting up storage reader"
|
||||
)
|
||||
|
||||
def prepare_local_plan(self, plan: LoadPlan) -> LoadPlan:
|
||||
"""Implementation of the StorageReader method"""
|
||||
return plan
|
||||
|
||||
def prepare_global_plan(self, global_plan: list[LoadPlan]) -> list[LoadPlan]:
|
||||
"""Implementation of the StorageReader method"""
|
||||
return global_plan
|
||||
|
||||
def reset(self, checkpoint_id: str | os.PathLike | None = None) -> None:
|
||||
"""Implementation of the StorageReader method"""
|
||||
self.checkpoint_id = checkpoint_id
|
||||
|
||||
@classmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
"""Implementation of the StorageReader method"""
|
||||
return os.path.isfile(checkpoint_id)
|
||||
|
||||
|
||||
class DynamicMetaLoadPlanner(DefaultLoadPlanner):
|
||||
"""
|
||||
Extension of DefaultLoadPlanner, which creates a new Metadata object based on the passed in state dict,
|
||||
avoiding the need to read metadata from disk. This is useful when reading formats which don't have a
|
||||
metadata file, like Torch Save files.
|
||||
|
||||
. N.B. Intended to be used with BroadcastingTorchSaveReader
|
||||
|
||||
.. warning::
|
||||
Current implementation only supports loading Tensors.
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> sd = {"mode": model}
|
||||
>>> dcp.load(
|
||||
>>> sd,
|
||||
>>> storage_reader=BroadcastingTorchSaveReader(),
|
||||
>>> planner=DynamicMetaLoadPlanner(),
|
||||
>>> checkpoint_id="path_to_model.pt"
|
||||
>>> )
|
||||
"""
|
||||
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
metadata: Metadata | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
"""Setups of the planner, extnding default behavior by creating the Metadata object from the state dict"""
|
||||
super().set_up_planner(state_dict, metadata, is_coordinator)
|
||||
|
||||
state_dict_metadata: dict[str, STORAGE_TYPES] = {}
|
||||
for key, tensor in self.state_dict.items():
|
||||
if not torch.is_tensor(tensor):
|
||||
raise RuntimeError(
|
||||
f"Non-tensor value identified at {key}. "
|
||||
f"At this time {type(self).__name__} only supports loading Tensors."
|
||||
)
|
||||
|
||||
state_dict_metadata[key] = TensorStorageMetadata(
|
||||
TensorProperties(dtype=tensor.dtype),
|
||||
tensor.size(),
|
||||
_create_chunk_list(tensor),
|
||||
)
|
||||
self.metadata = Metadata(state_dict_metadata=state_dict_metadata)
|
||||
|
||||
|
||||
def dcp_to_torch_save(
|
||||
dcp_checkpoint_dir: str | os.PathLike,
|
||||
torch_save_path: str | os.PathLike,
|
||||
):
|
||||
"""
|
||||
Given a directory containing a DCP checkpoint, this function will convert it into a
|
||||
Torch save file.
|
||||
|
||||
Args:
|
||||
dcp_checkpoint_dir: Directory containing the DCP checkpoint.
|
||||
torch_save_path: Filename to store the converted Torch save file.
|
||||
|
||||
.. warning::
|
||||
To avoid OOM, it's recommended to only run this function on a single rank.
|
||||
"""
|
||||
sd: STATE_DICT_TYPE = {}
|
||||
_load_state_dict(
|
||||
sd,
|
||||
storage_reader=FileSystemReader(dcp_checkpoint_dir),
|
||||
planner=_EmptyStateDictLoadPlanner(),
|
||||
no_dist=True,
|
||||
)
|
||||
torch.save(sd, torch_save_path)
|
||||
|
||||
|
||||
def torch_save_to_dcp(
|
||||
torch_save_path: str | os.PathLike,
|
||||
dcp_checkpoint_dir: str | os.PathLike,
|
||||
):
|
||||
"""
|
||||
Given the location of a torch save file, converts it into a DCP checkpoint.
|
||||
|
||||
Args:
|
||||
torch_save_path: Filename of the Torch save file.
|
||||
dcp_checkpoint_dir: Directory to store the DCP checkpoint.
|
||||
|
||||
.. warning::
|
||||
To avoid OOM, it's recommended to only run this function on a single rank.
|
||||
"""
|
||||
|
||||
state_dict = torch.load(torch_save_path, weights_only=False)
|
||||
# we don't need stateful behavior here because the expectation is anything loaded by
|
||||
# torch.load would not contain stateful objects.
|
||||
_save_state_dict(
|
||||
state_dict, storage_writer=FileSystemWriter(dcp_checkpoint_dir), no_dist=True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
class FormatMode(Enum):
|
||||
TORCH_TO_DCP = "torch_to_dcp"
|
||||
DCP_TO_TORCH = "dcp_to_torch"
|
||||
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
type=str,
|
||||
help="Conversion mode",
|
||||
choices=[m.value for m in FormatMode],
|
||||
default=FormatMode.TORCH_TO_DCP,
|
||||
)
|
||||
parser.add_argument("src", type=str, help="Path to the source model")
|
||||
parser.add_argument("dst", type=str, help="Path to the destination model")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(
|
||||
f"Converting checkpoint from {args.src} to {args.dst} using method: '{args.mode}'"
|
||||
)
|
||||
checkpoint_missing_warning = (
|
||||
f"No checkpoint found at {args.src}. Skipping conversion."
|
||||
)
|
||||
if args.mode == FormatMode.TORCH_TO_DCP.value:
|
||||
if os.path.isfile(args.src):
|
||||
torch_save_to_dcp(args.src, args.dst)
|
||||
else:
|
||||
print(checkpoint_missing_warning)
|
||||
elif args.mode == FormatMode.DCP_TO_TORCH.value:
|
||||
if os.path.isdir(args.src):
|
||||
dcp_to_torch_save(args.src, args.dst)
|
||||
else:
|
||||
print(checkpoint_missing_warning)
|
||||
else:
|
||||
raise ValueError(f"Unknown conversion mode: {args.mode}")
|
||||
@@ -0,0 +1,391 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed.checkpoint import FileSystemReader, FileSystemWriter
|
||||
from torch.distributed.checkpoint._consolidate_hf_safetensors import (
|
||||
consolidate_safetensors_files,
|
||||
)
|
||||
from torch.distributed.checkpoint._hf_utils import (
|
||||
_gen_file_name,
|
||||
_HFStorageInfo,
|
||||
_metadata_fn,
|
||||
CUSTOM_METADATA_KEY,
|
||||
SAVED_OFFSETS_KEY,
|
||||
SHARDED_DIR_NAME,
|
||||
SUFFIX,
|
||||
)
|
||||
from torch.distributed.checkpoint.filesystem import SerializationFormat
|
||||
from torch.distributed.checkpoint.metadata import (
|
||||
ChunkStorageMetadata,
|
||||
Metadata,
|
||||
MetadataIndex,
|
||||
StorageMeta,
|
||||
TensorProperties,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from torch.distributed.checkpoint.planner import (
|
||||
LoadPlan,
|
||||
LoadPlanner,
|
||||
ReadItem,
|
||||
SavePlan,
|
||||
SavePlanner,
|
||||
WriteItem,
|
||||
)
|
||||
from torch.distributed.checkpoint.storage import WriteResult
|
||||
from torch.futures import Future
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["HuggingFaceStorageWriter", "HuggingFaceStorageReader"]
|
||||
|
||||
|
||||
class HuggingFaceStorageWriter(FileSystemWriter):
|
||||
"""
|
||||
A writer that writes to storage in the huggingface safetensors format.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
fqn_to_index_mapping: dict[str, int] | None = None,
|
||||
thread_count: int = 1,
|
||||
save_distributed: bool = False,
|
||||
enable_consolidation: bool = False,
|
||||
thread_count_consolidation: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the huggingface writer pointing to path.
|
||||
|
||||
Args:
|
||||
path: directory where the checkpoint will be read from.
|
||||
fqn_to_index_mapping: A mapping from tensor FQN to the index of the file that the tensor should be written to.
|
||||
Indices are from 1 to N, where N is the number of files. If not provided,
|
||||
the tensors will be written to a single file. If none, then all the tensors on the
|
||||
same rank will be written to the same file.
|
||||
thread_count: Number of threads to use to write distributed checkpoint. Default to 1.
|
||||
save_distributed: If True, save the checkpoint using distributed APIs where every rank saves its own shard.
|
||||
Default is False which assumes rank-0 checkpointing of the full state_dict.
|
||||
enable_consolidation: If True, consolidate the sharded checkpoint after saving. The sharded tensors will be
|
||||
saved to path/sharded and the full tensors will be saved to path. Default to False.
|
||||
thread_count_consolidation: Number of threads to use for parallel processing of saving data
|
||||
to consolidated output files. Default to 1.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
path=path,
|
||||
serialization_format=SerializationFormat.SAFETENSORS,
|
||||
thread_count=thread_count,
|
||||
)
|
||||
self.fqn_to_index_mapping: dict[str, int] | None = fqn_to_index_mapping
|
||||
self.save_distributed: bool = save_distributed
|
||||
self.enable_consolidation: bool = enable_consolidation
|
||||
self.consolidated_output_path: str | None = None
|
||||
if self.enable_consolidation:
|
||||
self.consolidated_output_path = str(self.path)
|
||||
self.path = self.fs.concat_path(self.path, SHARDED_DIR_NAME)
|
||||
self.thread_count_consolidation = thread_count_consolidation
|
||||
|
||||
def prepare_global_plan(self, plans: list[SavePlan]) -> list[SavePlan]:
|
||||
new_plans = []
|
||||
for i, plan in enumerate(plans, start=1):
|
||||
storage_data: dict[str, Any] = {}
|
||||
if self.fqn_to_index_mapping is not None:
|
||||
storage_data["fqn_to_index_mapping"] = self.fqn_to_index_mapping
|
||||
if self.save_distributed:
|
||||
storage_data["shard_index"] = i
|
||||
|
||||
new_plans.append(dataclasses.replace(plan, storage_data=storage_data))
|
||||
|
||||
return new_plans
|
||||
|
||||
def write_data(
|
||||
self,
|
||||
plan: SavePlan,
|
||||
planner: SavePlanner,
|
||||
) -> Future[list[WriteResult]]:
|
||||
if len(plan.items) == 0:
|
||||
fut: Future = Future()
|
||||
fut.set_result([])
|
||||
return fut
|
||||
|
||||
# storage_plan is a map from key to file index
|
||||
storage_data: dict[str, Any] = plan.storage_data
|
||||
storage_plan: dict[str, int] | None = None
|
||||
shard_index: int | None = None
|
||||
if "fqn_to_index_mapping" in storage_data:
|
||||
storage_plan = storage_data["fqn_to_index_mapping"]
|
||||
if "shard_index" in storage_data:
|
||||
shard_index = storage_data["shard_index"]
|
||||
|
||||
buckets = self._split_by_storage_plan(storage_plan, plan.items)
|
||||
highest_index = max(storage_plan.values()) if storage_plan is not None else 1
|
||||
|
||||
file_queue: queue.Queue = queue.Queue()
|
||||
for file_index, write_items in buckets.items():
|
||||
file_name = _gen_file_name(file_index, highest_index, shard_index)
|
||||
file_queue.put(
|
||||
(self.fs.concat_path(self.path, file_name), file_name, write_items)
|
||||
)
|
||||
|
||||
return super()._write_data(planner, file_queue)
|
||||
|
||||
def finish(self, metadata: Metadata, results: list[list[WriteResult]]) -> None:
|
||||
if self.save_distributed and not self.enable_consolidation:
|
||||
# if we are saving distributed, without consolidating,
|
||||
# then we have no metadata to write because a metadata
|
||||
# file with fqn to file mapping doesn't make sense
|
||||
# in this case, because fqns will be in multiple files
|
||||
logger.info("Not consolidating sharded checkpoint in finish step.")
|
||||
return
|
||||
if self.save_distributed:
|
||||
fqn_to_index_mapping: dict[str, int] = (
|
||||
self.fqn_to_index_mapping
|
||||
if self.fqn_to_index_mapping is not None
|
||||
else dict.fromkeys(metadata.state_dict_metadata.keys(), 1)
|
||||
)
|
||||
|
||||
return consolidate_safetensors_files(
|
||||
input_dir=str(self.path),
|
||||
output_dir=self.consolidated_output_path, # type: ignore[arg-type]
|
||||
num_threads=self.thread_count_consolidation,
|
||||
fqn_to_index_mapping=fqn_to_index_mapping,
|
||||
)
|
||||
|
||||
# writing a model.index.safetensors.json file with fqn to file mapping
|
||||
# for the rank-0 checkpointing case
|
||||
metadata_to_write = {}
|
||||
storage_md = {}
|
||||
total_size = 0
|
||||
for wr_list in results:
|
||||
storage_md.update(
|
||||
{wr.index.fqn: wr.storage_data.relative_path for wr in wr_list}
|
||||
)
|
||||
total_size += sum([wr.storage_data.length for wr in wr_list])
|
||||
metadata_to_write["metadata"] = {"total_size": total_size}
|
||||
metadata_to_write["weight_map"] = storage_md
|
||||
|
||||
metadata_path = self.fs.concat_path(self.path, f"{_metadata_fn}")
|
||||
with self.fs.create_stream(metadata_path, "w") as metadata_file:
|
||||
json.dump(metadata_to_write, metadata_file, indent=2)
|
||||
|
||||
def _split_by_storage_plan(
|
||||
self, storage_plan: dict[str, int] | None, items: list[WriteItem]
|
||||
) -> dict[int, list[WriteItem]]:
|
||||
# storage_plan is a map from key to index
|
||||
if storage_plan is None:
|
||||
return {1: items}
|
||||
|
||||
buckets = {}
|
||||
for item in items:
|
||||
key = item.index.fqn
|
||||
|
||||
idx = storage_plan[key]
|
||||
if idx not in buckets:
|
||||
buckets[idx] = [item]
|
||||
else:
|
||||
buckets[idx].append(item)
|
||||
|
||||
return buckets
|
||||
|
||||
@property
|
||||
def metadata_path(self) -> str:
|
||||
return _metadata_fn
|
||||
|
||||
|
||||
class HuggingFaceStorageReader(FileSystemReader):
|
||||
"""
|
||||
A reader that reads a checkpoint in the huggingface safetensors format.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, thread_count: int = 1) -> None:
|
||||
"""
|
||||
Initialize the huggingface reader pointing to path.
|
||||
|
||||
Args:
|
||||
path: directory where the checkpoint will be read from.
|
||||
thread_count: Number of threads to use to read distributed checkpoint. Default to 1.
|
||||
"""
|
||||
|
||||
super().__init__(path=path)
|
||||
self.thread_count = thread_count
|
||||
|
||||
def _process_read_request(self, f, req: ReadItem, planner: LoadPlanner) -> None:
|
||||
"""Helper function to process a single read request."""
|
||||
# Create slices for each dimension based on offsets and lengths
|
||||
slices = tuple(
|
||||
slice(offset, offset + length)
|
||||
for offset, length in zip(req.storage_offsets, req.lengths)
|
||||
)
|
||||
tensor = f.get_slice(req.storage_index.fqn)[slices]
|
||||
target_tensor = planner.resolve_tensor(req).detach()
|
||||
|
||||
if target_tensor.size() != tensor.size():
|
||||
raise AssertionError(
|
||||
f"req {req.storage_index} mismatch sizes {target_tensor.size()} vs {tensor.size()}"
|
||||
)
|
||||
|
||||
target_tensor.copy_(tensor)
|
||||
planner.commit_tensor(req, target_tensor)
|
||||
|
||||
def _read_files_from_queue(
|
||||
self,
|
||||
file_queue: queue.Queue,
|
||||
result_queue: queue.Queue,
|
||||
planner: LoadPlanner,
|
||||
) -> None:
|
||||
from safetensors import safe_open # type: ignore[import]
|
||||
|
||||
try:
|
||||
while True:
|
||||
file_name, reqs = file_queue.get_nowait()
|
||||
with safe_open(filename=file_name, framework="pt") as f:
|
||||
for req in reqs:
|
||||
self._process_read_request(f, req, planner)
|
||||
result_queue.put(True) # Signal that this file has been processed
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def read_data(self, plan: LoadPlan, planner: LoadPlanner) -> Future[None]:
|
||||
from safetensors import safe_open # type: ignore[import]
|
||||
|
||||
per_file: dict[str, list[ReadItem]] = {}
|
||||
|
||||
for read_item in plan.items:
|
||||
item_md: _HFStorageInfo = self.storage_data[read_item.storage_index]
|
||||
file_name = item_md.relative_path
|
||||
per_file.setdefault(file_name, []).append(read_item)
|
||||
|
||||
if self.thread_count <= 1 or len(per_file) <= 1:
|
||||
for file_name, reqs in per_file.items():
|
||||
with safe_open(filename=file_name, framework="pt") as f:
|
||||
for req in reqs:
|
||||
self._process_read_request(f, req, planner)
|
||||
else:
|
||||
# Use parallel implementation with thread pool
|
||||
file_queue: queue.Queue = queue.Queue()
|
||||
result_queue: queue.Queue = queue.Queue()
|
||||
|
||||
# Fill the queue with files to process
|
||||
for file_name, reqs in per_file.items():
|
||||
file_queue.put((file_name, reqs))
|
||||
|
||||
# Create and start worker threads
|
||||
threads = []
|
||||
num_threads = min(self.thread_count, len(per_file))
|
||||
for _ in range(num_threads):
|
||||
t = threading.Thread(
|
||||
target=self._read_files_from_queue,
|
||||
args=(file_queue, result_queue, planner),
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
# Wait for all threads to complete
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Check if all files were processed
|
||||
processed_count = 0
|
||||
try:
|
||||
while True:
|
||||
result_queue.get_nowait()
|
||||
processed_count += 1
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
if processed_count != len(per_file):
|
||||
raise AssertionError(
|
||||
f"Not all files were processed: {processed_count} out of {len(per_file)}"
|
||||
)
|
||||
|
||||
fut: Future = Future()
|
||||
fut.set_result(None)
|
||||
return fut
|
||||
|
||||
# pyrefly: ignore [bad-override]
|
||||
def read_metadata(self) -> Metadata:
|
||||
from safetensors import safe_open # type: ignore[import]
|
||||
from safetensors.torch import _getdtype # type: ignore[import]
|
||||
|
||||
state_dict_metadata: dict[str, TensorStorageMetadata] = {}
|
||||
storage_data: dict[MetadataIndex, _HFStorageInfo] = {}
|
||||
|
||||
safetensors_files = []
|
||||
for file in self.fs.ls(self.path):
|
||||
if file.endswith(SUFFIX):
|
||||
safetensors_files.append(file)
|
||||
|
||||
for safetensor_file in safetensors_files:
|
||||
with safe_open(safetensor_file, framework="pt") as f:
|
||||
keys = f.keys()
|
||||
extra_metadata = f.metadata()
|
||||
|
||||
dcp_sharding_info = None
|
||||
if extra_metadata and extra_metadata.get(CUSTOM_METADATA_KEY):
|
||||
dcp_sharding_info = json.loads(
|
||||
extra_metadata.get(CUSTOM_METADATA_KEY)
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
shape = f.get_slice(key).get_shape()
|
||||
dtype = f.get_slice(key).get_dtype()
|
||||
# construct state_dict_metadata
|
||||
if dcp_sharding_info is not None:
|
||||
offset = dcp_sharding_info[key][SAVED_OFFSETS_KEY]
|
||||
else:
|
||||
offset = [0] * len(shape)
|
||||
|
||||
if key not in state_dict_metadata:
|
||||
state_dict_metadata[key] = TensorStorageMetadata(
|
||||
properties=TensorProperties(dtype=_getdtype(dtype)),
|
||||
size=torch.Size(
|
||||
[saved + offset for saved, offset in zip(shape, offset)]
|
||||
),
|
||||
chunks=[
|
||||
ChunkStorageMetadata(
|
||||
offsets=torch.Size(offset),
|
||||
sizes=torch.Size(shape),
|
||||
)
|
||||
],
|
||||
)
|
||||
else:
|
||||
state_dict_metadata[key].chunks.append(
|
||||
ChunkStorageMetadata(
|
||||
torch.Size(offset), sizes=torch.Size(shape)
|
||||
)
|
||||
)
|
||||
size = list(state_dict_metadata[key].size)
|
||||
for i in range(len(size)):
|
||||
size[i] = max(size[i], shape[i] + offset[i])
|
||||
state_dict_metadata[key].size = torch.Size(size)
|
||||
|
||||
# construct storage data
|
||||
if dcp_sharding_info is not None:
|
||||
metadata_index = MetadataIndex(
|
||||
fqn=key, offset=dcp_sharding_info[key][SAVED_OFFSETS_KEY]
|
||||
)
|
||||
else:
|
||||
metadata_index = MetadataIndex(fqn=key, offset=[0] * len(shape))
|
||||
storage_data[metadata_index] = _HFStorageInfo(
|
||||
relative_path=safetensor_file,
|
||||
shape=torch.Size(shape),
|
||||
dtype=_getdtype(dtype),
|
||||
)
|
||||
|
||||
metadata = Metadata(
|
||||
state_dict_metadata=state_dict_metadata, # type: ignore[arg-type]
|
||||
storage_data=storage_data,
|
||||
)
|
||||
|
||||
if getattr(metadata, "storage_meta", None) is None:
|
||||
metadata.storage_meta = StorageMeta()
|
||||
metadata.storage_meta.load_id = self.load_id # type: ignore[union-attr]
|
||||
|
||||
return metadata
|
||||
@@ -0,0 +1,121 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
from typing_extensions import ParamSpec
|
||||
from uuid import uuid4
|
||||
|
||||
import torch.distributed.c10d_logger as c10d_logger
|
||||
from torch.distributed.checkpoint.logging_handlers import DCP_LOGGER_NAME
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
# pyrefly: ignore [unknown-name]
|
||||
global _dcp_logger
|
||||
_dcp_logger = c10d_logger._get_or_create_logger(DCP_LOGGER_NAME)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def _msg_dict_from_dcp_method_args(*args, **kwargs) -> dict[str, Any]:
|
||||
"""
|
||||
Extracts log data from dcp method args
|
||||
"""
|
||||
msg_dict = {}
|
||||
|
||||
# checkpoint ID can be passed in through the serializer or through the checkpoint id directly
|
||||
storage_writer = kwargs.get("storage_writer")
|
||||
storage_reader = kwargs.get("storage_reader")
|
||||
planner = kwargs.get("planner")
|
||||
|
||||
checkpoint_id = kwargs.get("checkpoint_id")
|
||||
if not checkpoint_id and (serializer := storage_writer or storage_reader):
|
||||
checkpoint_id = getattr(serializer, "checkpoint_id", None)
|
||||
|
||||
msg_dict["checkpoint_id"] = (
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
str(checkpoint_id) if checkpoint_id is not None else checkpoint_id
|
||||
)
|
||||
|
||||
# Uniquely identify a _dcp_method_logger wrapped function call.
|
||||
msg_dict["uuid"] = str(uuid4().int)
|
||||
|
||||
if storage_writer:
|
||||
msg_dict["storage_writer"] = storage_writer.__class__.__name__
|
||||
|
||||
if storage_reader:
|
||||
msg_dict["storage_reader"] = storage_reader.__class__.__name__
|
||||
|
||||
if planner:
|
||||
msg_dict["planner"] = planner.__class__.__name__
|
||||
|
||||
return msg_dict
|
||||
|
||||
|
||||
def _get_msg_dict(func_name, *args, **kwargs) -> dict[str, Any]:
|
||||
msg_dict = _msg_dict_from_dcp_method_args(*args, **kwargs)
|
||||
msg_dict.update(c10d_logger._get_msg_dict(func_name, *args, **kwargs))
|
||||
|
||||
return msg_dict
|
||||
|
||||
|
||||
def _dcp_method_logger(
|
||||
log_exceptions: bool = False, **wrapper_kwargs: Any
|
||||
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: # pyre-ignore
|
||||
"""This method decorator logs the start, end, and exception of wrapped events."""
|
||||
|
||||
def decorator(func: Callable[_P, _T]):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
|
||||
msg_dict = _get_msg_dict(
|
||||
func.__name__, *args, **{**wrapper_kwargs, **kwargs}
|
||||
)
|
||||
|
||||
# log start event
|
||||
msg_dict["event"] = "start"
|
||||
t0 = time.time_ns()
|
||||
msg_dict["time"] = t0
|
||||
msg_dict["log_exceptions"] = log_exceptions
|
||||
_dcp_logger.debug(msg_dict)
|
||||
|
||||
# exceptions
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
except BaseException as error:
|
||||
if log_exceptions:
|
||||
msg_dict["event"] = "exception"
|
||||
msg_dict["error"] = f"{error}"
|
||||
msg_dict["time"] = time.time_ns()
|
||||
_dcp_logger.error(msg_dict)
|
||||
raise
|
||||
|
||||
# end event
|
||||
msg_dict["event"] = "end"
|
||||
t1 = time.time_ns()
|
||||
msg_dict["time"] = time.time_ns()
|
||||
msg_dict["times_spent"] = t1 - t0
|
||||
_dcp_logger.debug(msg_dict)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _init_logger(rank: int):
|
||||
logger.setLevel(logging.INFO)
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter(
|
||||
f"[{rank}] %(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import logging
|
||||
|
||||
from torch.distributed.logging_handlers import _log_handlers
|
||||
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
DCP_LOGGER_NAME = "dcp_logger"
|
||||
|
||||
_log_handlers.update(
|
||||
{
|
||||
DCP_LOGGER_NAME: logging.NullHandler(),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed.checkpoint.stateful import StatefulT
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChunkStorageMetadata",
|
||||
"TensorStorageMetadata",
|
||||
"BytesStorageMetadata",
|
||||
"Metadata",
|
||||
"MetadataIndex",
|
||||
"TensorProperties",
|
||||
"StorageMeta",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkStorageMetadata:
|
||||
"""
|
||||
Each chunk is expected to have the same properties of the TensorStorageMetadata
|
||||
that includes it.
|
||||
"""
|
||||
|
||||
offsets: torch.Size
|
||||
sizes: torch.Size
|
||||
|
||||
|
||||
class _MEM_FORMAT_ENCODING(Enum):
|
||||
"""Describe the memory format of a tensor."""
|
||||
|
||||
TORCH_CONTIGUOUS_FORMAT = 0
|
||||
TORCH_CHANNELS_LAST = 1
|
||||
TORCH_PRESERVE_FORMAT = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class TensorProperties:
|
||||
"""Properties used to create :class:`Tensor`"""
|
||||
|
||||
# Regular tensor fields
|
||||
dtype: torch.dtype = field(default_factory=torch.get_default_dtype)
|
||||
# This field is deprecated.
|
||||
layout: torch.layout = field(default=torch.strided)
|
||||
# This field is deprecated.
|
||||
requires_grad: bool = False
|
||||
# This field is deprecated.
|
||||
memory_format: torch.memory_format = field(default=torch.contiguous_format)
|
||||
# This field is deprecated.
|
||||
pin_memory: bool = False
|
||||
|
||||
def __getstate__(self):
|
||||
# Since torch.memory_format cannot be pickled!
|
||||
memory_format = self.memory_format
|
||||
if memory_format == torch.contiguous_format:
|
||||
mem_format_encoding = _MEM_FORMAT_ENCODING.TORCH_CONTIGUOUS_FORMAT
|
||||
elif memory_format == torch.channels_last:
|
||||
mem_format_encoding = _MEM_FORMAT_ENCODING.TORCH_CHANNELS_LAST
|
||||
elif memory_format == torch.preserve_format:
|
||||
mem_format_encoding = _MEM_FORMAT_ENCODING.TORCH_PRESERVE_FORMAT
|
||||
else:
|
||||
raise RuntimeError(f"Invalid torch.memory_format: {memory_format}")
|
||||
|
||||
return (
|
||||
self.dtype,
|
||||
self.layout,
|
||||
self.requires_grad,
|
||||
mem_format_encoding,
|
||||
self.pin_memory,
|
||||
)
|
||||
|
||||
def __setstate__(
|
||||
self,
|
||||
state,
|
||||
):
|
||||
(
|
||||
self.dtype,
|
||||
self.layout,
|
||||
self.requires_grad,
|
||||
mem_format_encoding,
|
||||
self.pin_memory,
|
||||
) = state
|
||||
|
||||
if mem_format_encoding == _MEM_FORMAT_ENCODING.TORCH_CONTIGUOUS_FORMAT:
|
||||
memory_format = torch.contiguous_format
|
||||
elif mem_format_encoding == _MEM_FORMAT_ENCODING.TORCH_CHANNELS_LAST:
|
||||
memory_format = torch.channels_last
|
||||
elif mem_format_encoding == _MEM_FORMAT_ENCODING.TORCH_PRESERVE_FORMAT:
|
||||
memory_format = torch.preserve_format
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Invalid torch.memory_format encoding: {mem_format_encoding}"
|
||||
)
|
||||
|
||||
self.memory_format = memory_format
|
||||
|
||||
@staticmethod
|
||||
def create_from_tensor(tensor: torch.Tensor) -> "TensorProperties":
|
||||
return TensorProperties(
|
||||
dtype=tensor.dtype,
|
||||
layout=tensor.layout,
|
||||
requires_grad=tensor.requires_grad,
|
||||
memory_format=torch.contiguous_format,
|
||||
pin_memory=tensor.is_pinned(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TensorStorageMetadata:
|
||||
properties: TensorProperties
|
||||
size: torch.Size
|
||||
chunks: list[ChunkStorageMetadata]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BytesStorageMetadata:
|
||||
pass
|
||||
|
||||
|
||||
STORAGE_TYPES = TensorStorageMetadata | BytesStorageMetadata
|
||||
STATE_DICT_TYPE = dict[str, StatefulT | Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageMeta:
|
||||
checkpoint_id: str | os.PathLike | None = None
|
||||
save_id: str | None = None
|
||||
load_id: str | None = None
|
||||
modules: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
"""This class represents the metadata of the checkpoint."""
|
||||
|
||||
# Keys are the same from the `state_dict` used.
|
||||
state_dict_metadata: dict[str, STORAGE_TYPES]
|
||||
# It is the responsibility of the planner and storage plugins to ensure
|
||||
# backward compatibility of the planner_data and storage_data. DCP will
|
||||
# also ensure the backward compatibility of the metadata in this file and
|
||||
# the metadata of the built-in planner and storage plugins.
|
||||
planner_data: Any = None
|
||||
storage_data: Any = None
|
||||
storage_meta: StorageMeta | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataIndex:
|
||||
"""This class represents a lookup key for items in a state dict or Metadata."""
|
||||
|
||||
fqn: str
|
||||
"""Fully Qualified Name of the object"""
|
||||
|
||||
offset: torch.Size | None = None
|
||||
"""If the object is a tensor, offset into the tensor we're looking for"""
|
||||
|
||||
index: int | None = field(hash=False, compare=False, default=None)
|
||||
"""
|
||||
Index hint when searching for tensor chunk to speedup lookups (optional)
|
||||
|
||||
A common representation of a sharded tensor is as a list of chunks so to
|
||||
find the index in such a list you need to linear search it.
|
||||
|
||||
When constructing an instance of MetadataIndex that points to that list,
|
||||
one can provide the index as a hint and it will be probed first before
|
||||
the linear search and thus making it significantly faster.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fqn: str,
|
||||
offset: Sequence[int] | None = None,
|
||||
index: int | None = None,
|
||||
):
|
||||
# We must use object.__setattr__ due to frozen=True
|
||||
object.__setattr__(self, "fqn", fqn)
|
||||
object.__setattr__(self, "index", index)
|
||||
if offset is not None:
|
||||
object.__setattr__(self, "offset", torch.Size(offset))
|
||||
@@ -0,0 +1,360 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch._utils import _get_device_module
|
||||
from torch.distributed._shard.sharded_tensor.api import ShardedTensor
|
||||
from torch.distributed._shard.sharded_tensor.metadata import (
|
||||
TensorProperties as ShardTensorProperties,
|
||||
)
|
||||
from torch.distributed._shard.sharded_tensor.shard import Shard
|
||||
from torch.distributed._shard.sharding_spec.chunk_sharding_spec import ChunkShardingSpec
|
||||
from torch.distributed.checkpoint._nested_dict import unflatten_state_dict
|
||||
from torch.distributed.checkpoint.default_planner import DefaultLoadPlanner
|
||||
from torch.distributed.checkpoint.metadata import (
|
||||
BytesStorageMetadata,
|
||||
ChunkStorageMetadata,
|
||||
Metadata,
|
||||
MetadataIndex,
|
||||
STATE_DICT_TYPE,
|
||||
TensorProperties,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from torch.distributed.checkpoint.planner import LoadPlan, LoadPlanner
|
||||
from torch.distributed.checkpoint.planner_helpers import (
|
||||
_create_read_items,
|
||||
create_read_items_for_chunk_list,
|
||||
)
|
||||
|
||||
# pyrefly: ignore [deprecated]
|
||||
from torch.distributed.checkpoint.state_dict_loader import load_state_dict
|
||||
from torch.distributed.checkpoint.storage import StorageReader
|
||||
from torch.distributed.checkpoint.utils import (
|
||||
_element_wise_add,
|
||||
_element_wise_sub,
|
||||
_normalize_device_info,
|
||||
)
|
||||
from torch.distributed.distributed_c10d import _get_default_group
|
||||
from torch.distributed.fsdp._shard_utils import _create_chunk_sharded_tensor
|
||||
from torch.distributed.remote_device import _remote_device
|
||||
from torch.distributed.tensor import DTensor
|
||||
|
||||
|
||||
STATE_DICT_2D_LAYOUT = dict[str, tuple[Sequence[int] | None, Sequence[int]]]
|
||||
|
||||
|
||||
# TODO: Update docstrings for optimizer.py
|
||||
__all__ = [
|
||||
"load_sharded_optimizer_state_dict",
|
||||
]
|
||||
|
||||
|
||||
def _gen_rank_device(global_rank: int, device_type: str = "cuda") -> str:
|
||||
if device_type == "cpu":
|
||||
return "cpu"
|
||||
device_module = _get_device_module(device_type)
|
||||
if device_module.is_available():
|
||||
return _normalize_device_info(
|
||||
device_type, global_rank % device_module.device_count()
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _create_colwise_spec(
|
||||
pg: dist.ProcessGroup | None = None,
|
||||
) -> ChunkShardingSpec:
|
||||
pg_device_type = dist.distributed_c10d._get_pg_default_device(pg).type
|
||||
if pg is None:
|
||||
placements = [
|
||||
f"rank:{idx}/{_gen_rank_device(idx, pg_device_type)}"
|
||||
for idx in range(dist.get_world_size())
|
||||
]
|
||||
else:
|
||||
placements = [
|
||||
f"rank:{idx}/{_gen_rank_device(dist.get_global_rank(pg, idx), pg_device_type)}"
|
||||
for idx in range(pg.size())
|
||||
]
|
||||
return ChunkShardingSpec(
|
||||
dim=0,
|
||||
placements=cast(list[_remote_device | str], placements),
|
||||
)
|
||||
|
||||
|
||||
def _is_nested_tensor(val: torch.Tensor) -> bool:
|
||||
if type(val) is ShardedTensor:
|
||||
if len(val.local_shards()) == 0:
|
||||
return False
|
||||
if type(val.local_shards()[0].tensor) is ShardedTensor:
|
||||
return True
|
||||
if type(val.local_shards()[0].tensor) is DTensor:
|
||||
raise ValueError("Cannot handle DTensor nested inside ShardedTensor")
|
||||
elif type(val) is DTensor and (
|
||||
type(val._local_tensor) is DTensor or type(val._local_tensor) is ShardedTensor
|
||||
):
|
||||
raise ValueError("Cannot handle nested DTensor")
|
||||
return False
|
||||
|
||||
|
||||
def _alloc_tensor(
|
||||
props: TensorProperties, size: Sequence[int], device_type: str = "cuda"
|
||||
) -> torch.Tensor:
|
||||
if device_type == "cpu":
|
||||
device = cast(torch.device, _get_device_module(device_type).current_device())
|
||||
else:
|
||||
device = torch.device(
|
||||
device_type, _get_device_module(device_type).current_device()
|
||||
)
|
||||
|
||||
return torch.empty(
|
||||
size=size,
|
||||
dtype=props.dtype,
|
||||
layout=props.layout,
|
||||
requires_grad=props.requires_grad,
|
||||
pin_memory=props.pin_memory,
|
||||
device=device,
|
||||
)
|
||||
|
||||
|
||||
def _get_state_dict_2d_layout(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
) -> tuple[STATE_DICT_2D_LAYOUT, dist.ProcessGroup | None]:
|
||||
"""
|
||||
Load the right TP slice of the optimizer state.
|
||||
|
||||
This is not easy since the per-tensor slicing can't be inferred from checkpoint metadata.
|
||||
We take advantage of the model state_dict producing a sliced ST to figure out what we need to load.
|
||||
This is pretty fragile and it might be easier for FSDP to compute this info for us.
|
||||
Returns a dictionary where keys are the same of the state_dict and the value is a tuple of
|
||||
(offset, size) for the current rank TP slice.
|
||||
N.B. The state_dict *MUST* come from FSDP.sharded_state_dict.
|
||||
"""
|
||||
specs: STATE_DICT_2D_LAYOUT = {}
|
||||
dp_pg: dist.ProcessGroup | None = None
|
||||
for key, value in state_dict.items():
|
||||
specs[key] = (None, value.size())
|
||||
if _is_nested_tensor(value):
|
||||
if not len(value.local_shards()) == 1:
|
||||
raise AssertionError("Cannot handle ST with multiple shards")
|
||||
if not isinstance(value, ShardedTensor):
|
||||
raise AssertionError("Can only handle nested ShardedTensor")
|
||||
shard = value.local_shards()[0]
|
||||
specs[key] = (
|
||||
shard.metadata.shard_offsets,
|
||||
shard.metadata.shard_sizes,
|
||||
)
|
||||
dp_pg = shard.tensor._process_group # type: ignore[attr-defined]
|
||||
|
||||
return (
|
||||
specs,
|
||||
dp_pg,
|
||||
)
|
||||
|
||||
|
||||
class _ReaderWithOffset(DefaultLoadPlanner):
|
||||
translation: dict[MetadataIndex, MetadataIndex]
|
||||
state_dict: STATE_DICT_TYPE
|
||||
# pyrefly: ignore [bad-override]
|
||||
metadata: Metadata
|
||||
|
||||
def __init__(self, fqn_to_offset: dict[str, Sequence[int]]) -> None:
|
||||
super().__init__()
|
||||
self.fqn_to_offset = fqn_to_offset
|
||||
self.metadata = Metadata({})
|
||||
self.state_dict = {}
|
||||
self.translation = {}
|
||||
|
||||
def create_local_plan(self) -> LoadPlan:
|
||||
requests = []
|
||||
self.translation = {}
|
||||
for fqn, obj in self.state_dict.items():
|
||||
md = self.metadata.state_dict_metadata[fqn]
|
||||
if not isinstance(obj, ShardedTensor):
|
||||
requests += _create_read_items(fqn, md, obj)
|
||||
continue
|
||||
|
||||
if fqn not in self.fqn_to_offset:
|
||||
requests += _create_read_items(fqn, md, obj)
|
||||
continue
|
||||
|
||||
offset = self.fqn_to_offset[fqn]
|
||||
|
||||
if not len(obj.local_shards()) == 1:
|
||||
raise AssertionError("Expected exactly one local shard")
|
||||
original_shard = obj.local_shards()[0]
|
||||
local_chunks = [
|
||||
ChunkStorageMetadata(
|
||||
offsets=torch.Size(
|
||||
_element_wise_add(original_shard.metadata.shard_offsets, offset)
|
||||
),
|
||||
sizes=torch.Size(original_shard.metadata.shard_sizes),
|
||||
)
|
||||
]
|
||||
|
||||
reqs = create_read_items_for_chunk_list(
|
||||
fqn, cast(TensorStorageMetadata, md), local_chunks
|
||||
)
|
||||
# TODO: The ReadItems will have a displaced MetadataIndex, fix it.
|
||||
# TODO: we should change _create_sharded_read_items to have more ergonomic API
|
||||
for ri in reqs:
|
||||
if ri.dest_index.offset is None:
|
||||
raise AssertionError("dest_index.offset must not be None")
|
||||
original_offset = _element_wise_sub(ri.dest_index.offset, offset)
|
||||
original_index = dataclasses.replace(
|
||||
ri.dest_index, offset=torch.Size(original_offset)
|
||||
)
|
||||
self.translation[ri.dest_index] = original_index
|
||||
|
||||
requests += reqs
|
||||
return LoadPlan(requests)
|
||||
|
||||
def lookup_tensor(self, index: MetadataIndex) -> torch.Tensor:
|
||||
return super().lookup_tensor(self.translation.get(index, index))
|
||||
|
||||
|
||||
def load_sharded_optimizer_state_dict(
|
||||
model_state_dict: STATE_DICT_TYPE,
|
||||
optimizer_key: str,
|
||||
storage_reader: StorageReader,
|
||||
planner: LoadPlanner | None = None,
|
||||
) -> STATE_DICT_TYPE:
|
||||
"""
|
||||
Load a state_dict in conjunction with FSDP sharded optimizer state.
|
||||
|
||||
This is the current recommended way to checkpoint FSDP.
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> import torch.distributed.checkpoint as dist_cp
|
||||
>>> # Save
|
||||
>>> model: torch.nn.Model
|
||||
>>> optim_params = model.parameters()
|
||||
>>> optim = torch.optim.SGD(optim_params, lr=0.01)
|
||||
>>> # Save
|
||||
>>> with FSDP.state_dict_type(model, StateDictType.SHARDED_STATE_DICT):
|
||||
>>> state_dict = {
|
||||
>>> "optimizer": FSDP.optim_state_dict(model, optim),
|
||||
>>> "model": model.state_dict()
|
||||
>>> }
|
||||
>>> dist_cp.save_state_dict(
|
||||
>>> state_dict=optim_state,
|
||||
>>> storage_writer=dist_cp.FileSystemWriter("checkpoint"),
|
||||
>>> planner=dist_cp.DefaultSavePlanner(),
|
||||
>>> )
|
||||
>>>
|
||||
>>> # Load
|
||||
>>> with FSDP.state_dict_type(model_tp, StateDictType.SHARDED_STATE_DICT):
|
||||
>>> model_state_dict = model_tp.state_dict()
|
||||
>>> checkpoint = {
|
||||
>>> "model": model_state_dict
|
||||
>>> }
|
||||
>>> dist_cp.load_state_dict(
|
||||
>>> state_dict=checkpoint,
|
||||
>>> storage_reader=dist_cp.FileSystemReader(checkpoint_file),
|
||||
>>> planner=dist_cp.DefaultLoadPlanner(),
|
||||
>>> )
|
||||
>>> model.load_state_dict(checkpoint["model_state"])
|
||||
>>>
|
||||
>>> optim_state = dist_cp.load_sharded_optimizer_state_dict(
|
||||
>>> model_state_dict,
|
||||
>>> optimizer_key="optimizer",
|
||||
>>> storage_reader=dist_cp.FileSystemReader("checkpoint"),
|
||||
>>> )
|
||||
>>>
|
||||
>>> flattened_osd = FSDP.optim_state_dict_to_load(
|
||||
>>> model, optim, optim_state["optimizer"]
|
||||
>>> )
|
||||
>>>
|
||||
>>> optim.load_state_dict(flattened_osd)
|
||||
"""
|
||||
metadata = storage_reader.read_metadata()
|
||||
|
||||
layout_specs, dp_pg = _get_state_dict_2d_layout(model_state_dict)
|
||||
dp_pg_device_type = dist.distributed_c10d._get_pg_default_device(dp_pg).type
|
||||
device_module = _get_device_module(dp_pg_device_type)
|
||||
|
||||
if dp_pg is None:
|
||||
placements = []
|
||||
for i in range(dist.get_world_size()):
|
||||
device_info = _normalize_device_info(
|
||||
dp_pg_device_type, i % device_module.device_count()
|
||||
)
|
||||
placements.append(f"rank:{i}/{device_info}")
|
||||
sharding_spec = ChunkShardingSpec(dim=0, placements=placements) # type: ignore[arg-type]
|
||||
else:
|
||||
sharding_spec = _create_colwise_spec(dp_pg)
|
||||
|
||||
# Create a state_dict for optimizer state
|
||||
state_dict: STATE_DICT_TYPE = {}
|
||||
|
||||
fqn_to_offset: dict[str, Sequence[int]] = {}
|
||||
for key, value in metadata.state_dict_metadata.items():
|
||||
key_path = metadata.planner_data[key]
|
||||
if key_path[0] != optimizer_key:
|
||||
continue
|
||||
|
||||
if isinstance(value, BytesStorageMetadata):
|
||||
state_dict[key] = "<bytes_io>"
|
||||
continue
|
||||
|
||||
# value: TensorStorageMetadata
|
||||
if value.size.numel() == 1:
|
||||
state_dict[key] = _alloc_tensor(
|
||||
value.properties, value.size, dp_pg_device_type
|
||||
)
|
||||
elif dp_pg is None:
|
||||
state_dict[key] = _create_chunk_sharded_tensor(
|
||||
_alloc_tensor(value.properties, value.size, dp_pg_device_type),
|
||||
rank=dist.get_rank(),
|
||||
world_size=dist.get_world_size(),
|
||||
num_devices_per_node=device_module.device_count(),
|
||||
pg=_get_default_group(),
|
||||
)
|
||||
else:
|
||||
spec_key = key_path[2]
|
||||
alloc_size = layout_specs.get(spec_key, (None, value.size))[1]
|
||||
|
||||
properties = ShardTensorProperties(
|
||||
dtype=value.properties.dtype,
|
||||
layout=value.properties.layout,
|
||||
requires_grad=value.properties.requires_grad,
|
||||
memory_format=value.properties.memory_format,
|
||||
pin_memory=value.properties.pin_memory,
|
||||
)
|
||||
|
||||
st_md = sharding_spec.build_metadata(torch.Size(alloc_size), properties)
|
||||
local_shards = []
|
||||
current_rank = dist.get_rank(dp_pg)
|
||||
for shard_md in st_md.shards_metadata:
|
||||
if cast(_remote_device, shard_md.placement).rank() != current_rank:
|
||||
continue
|
||||
local_shards.append(
|
||||
Shard(
|
||||
tensor=_alloc_tensor(
|
||||
value.properties, shard_md.shard_sizes, dp_pg_device_type
|
||||
),
|
||||
metadata=shard_md,
|
||||
)
|
||||
)
|
||||
|
||||
st = ShardedTensor._init_from_local_shards_and_global_metadata(
|
||||
local_shards, st_md, process_group=dp_pg
|
||||
)
|
||||
|
||||
if spec_key in layout_specs and layout_specs[spec_key][0] is not None:
|
||||
fqn_to_offset[key] = cast(Sequence[int], layout_specs[spec_key][0])
|
||||
|
||||
state_dict[key] = st
|
||||
|
||||
# Whether we unflatten before or after doesn't matter
|
||||
load_state_dict(
|
||||
state_dict=state_dict,
|
||||
storage_reader=storage_reader,
|
||||
# FIXME the type of planner is wrong in load_state_dict
|
||||
planner=_ReaderWithOffset(fqn_to_offset) if dp_pg is not None else planner,
|
||||
)
|
||||
|
||||
state_dict = unflatten_state_dict(state_dict, metadata.planner_data)
|
||||
|
||||
return state_dict
|
||||
@@ -0,0 +1,450 @@
|
||||
import abc
|
||||
import io
|
||||
import operator
|
||||
from dataclasses import dataclass
|
||||
from enum import auto, Enum
|
||||
from functools import reduce
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed.checkpoint.metadata import (
|
||||
ChunkStorageMetadata,
|
||||
Metadata,
|
||||
MetadataIndex,
|
||||
STATE_DICT_TYPE,
|
||||
StorageMeta,
|
||||
TensorProperties,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WriteItemType",
|
||||
"LoadItemType",
|
||||
"BytesIOWriteData",
|
||||
"TensorWriteData",
|
||||
"WriteItem",
|
||||
"ReadItem",
|
||||
"SavePlan",
|
||||
"LoadPlan",
|
||||
"SavePlanner",
|
||||
"LoadPlanner",
|
||||
]
|
||||
|
||||
|
||||
class WriteItemType(Enum):
|
||||
TENSOR = auto()
|
||||
SHARD = auto()
|
||||
BYTE_IO = auto()
|
||||
|
||||
|
||||
class LoadItemType(Enum):
|
||||
TENSOR = auto()
|
||||
BYTE_IO = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BytesIOWriteData:
|
||||
nbytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TensorWriteData:
|
||||
chunk: ChunkStorageMetadata
|
||||
properties: TensorProperties
|
||||
size: torch.Size
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteItem:
|
||||
"""Dataclass which holds information about what needs to be written to storage."""
|
||||
|
||||
index: MetadataIndex
|
||||
type: WriteItemType
|
||||
|
||||
# Size of bytesIO data to be written.
|
||||
bytes_io_data: BytesIOWriteData | None = None
|
||||
|
||||
# Value present if it's a tensor write
|
||||
tensor_data: TensorWriteData | None = None
|
||||
|
||||
def tensor_storage_size(self) -> int | None:
|
||||
"""
|
||||
Calculates the storage size of the underlying tensor, or None if this is not a tensor write.
|
||||
|
||||
Returns:
|
||||
Optional[int] storage size, in bytes of underlying tensor if any.
|
||||
"""
|
||||
if self.tensor_data is None:
|
||||
return None
|
||||
|
||||
numels = reduce(operator.mul, self.tensor_data.size, 1)
|
||||
dtype_size = torch._utils._element_size(self.tensor_data.properties.dtype)
|
||||
return numels * dtype_size
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadItem:
|
||||
# Read Item
|
||||
type: LoadItemType
|
||||
|
||||
# Index into the state_dict
|
||||
dest_index: MetadataIndex
|
||||
# Offsets into destination tensor
|
||||
dest_offsets: torch.Size
|
||||
|
||||
# Index into the checkpoint
|
||||
storage_index: MetadataIndex
|
||||
# Offset into the checkpoint data
|
||||
storage_offsets: torch.Size
|
||||
|
||||
# Size of the hypercube to copy
|
||||
lengths: torch.Size
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SavePlan:
|
||||
items: list[WriteItem]
|
||||
storage_data: Any = None
|
||||
planner_data: Any = None
|
||||
# This is used to indicate that the ranks should
|
||||
# use the cached plans to write data instead.
|
||||
usable: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoadPlan:
|
||||
items: list[ReadItem]
|
||||
storage_data: Any = None
|
||||
planner_data: Any = None
|
||||
|
||||
|
||||
class SavePlanner(abc.ABC):
|
||||
"""
|
||||
Abstract class defining the protocol used by save_state_dict to plan the save process.
|
||||
|
||||
SavePlanners are stateful objects that can be used to customize the whole save process.
|
||||
|
||||
SavePlanner acts as an access proxy to the state_dict, so any transformation done to it
|
||||
will be visible to the whole process.
|
||||
|
||||
A planner subclass can expect the following sequence of calls during save_state_dict:
|
||||
|
||||
1) set_up_planner - called on all ranks.
|
||||
Signals the start of a checkpoint save.
|
||||
|
||||
2) create_local_plan - called on all ranks.
|
||||
Process the state_dict and produces a `SavePlan` that will be sent for global planning.
|
||||
|
||||
3) create_global_plan - called on the coordinator rank only.
|
||||
Takes the SavePlan from all ranks and make any global decision.
|
||||
|
||||
4) finish_plan - called on all ranks.
|
||||
This gives each rank a chance to adjust to global planning decisions.
|
||||
|
||||
5) resolve_data - called multiple times on each rank
|
||||
Lookups a value on the `state_dict` for the storage layer to write.
|
||||
|
||||
Users are recommended to extend DefaultSavePlanner instead of this interface directly as
|
||||
most changes can be expressed by changes in a single method.
|
||||
|
||||
There are 3 usual patterns of extension:
|
||||
|
||||
Rewriting state_dict. This is the simplest way to extend the save process as it
|
||||
doesn't requite understanding the intrincacies of how SavePlan works:
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> class RenamePlanner(DefaultSavePlanner):
|
||||
>>> def set_up_planner(
|
||||
>>> self,
|
||||
>>> state_dict: STATE_DICT_TYPE,
|
||||
>>> storage_meta: Optional[StorageMeta],
|
||||
>>> is_coordinator: bool,
|
||||
>>> ) -> None:
|
||||
>>> # prefix all keys with `foo_``
|
||||
>>> super().set_up_planner({"foo_" + k: v for k, v in state_dict.items()}, storage_meta, is_coordinator)
|
||||
|
||||
Modifying local plan and lookup in tandem. This is useful when fine control of how data is persisted
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> class FP16Planner(DefaultSavePlanner):
|
||||
>>> def create_local_plan(self):
|
||||
>>> plan = super().create_local_plan()
|
||||
>>> for p in plan:
|
||||
>>> if p.tensor_data is not None:
|
||||
>>> p.tensor_data.properties.dtype = torch.float16
|
||||
>>> return plan
|
||||
>>>
|
||||
>>> def resolve_data(self, write_item):
|
||||
>>> item = super().resolve_data(write_item)
|
||||
>>> return item if write_item.type == WriteItemType.BYTE_IO else item.to(torch.float16)
|
||||
|
||||
Using the global planning step to make central decisions that can't be made individually by each rank
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> from itertools import zip_longest
|
||||
>>> from dataclasses import replace
|
||||
>>> class DDPLoadBalancingPlanner(DefaultSavePlanner):
|
||||
>>> # This uses the default local plan behavior of having all non-sharded writes in rank 0
|
||||
>>> # This sample doesn't handle ShardedTensors
|
||||
>>> def create_global_plan(self, all_plans):
|
||||
>>> iters = [iter(all_plans[0].items)] * len(all_plans)
|
||||
>>> items_per_rank = [
|
||||
>>> [item for item in items if item is not None]
|
||||
>>> for items in zip(*zip_longest(*iters), strict=True)
|
||||
>>> ]
|
||||
>>> all_plans = [
|
||||
>>> replace(plan, items=items)
|
||||
>>> for plan, items in zip(all_plans, items_per_rank, strict=True)
|
||||
>>> ]
|
||||
>>> return super().create_global_plan(all_plans)
|
||||
|
||||
Finally, some planners need to save additional metadata in the checkpoint, this is
|
||||
accomplished by having each rank contribute their data items in the local plan and
|
||||
the global planner aggregate them:
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> class SaveExtraDataPlanner(DefaultSavePlanner):
|
||||
>>> def create_local_plan(self) -> SavePlan:
|
||||
>>> plan = super().create_local_plan()
|
||||
>>> return replace(plan, planner_data="per-rank-data")
|
||||
>>>
|
||||
>>> def create_global_plan(self, all_plans: List[SavePlan]) -> Tuple[List[SavePlan], Metadata]:
|
||||
>>> global_plan, metadata = super().create_global_plan(all_plans)
|
||||
>>> merged_data = [p.planner_data for p in global_plan]
|
||||
>>> metadata = replace(metadata, planner_data=merged_data)
|
||||
>>> return global_plan, metadata
|
||||
"""
|
||||
|
||||
# Save plan for the current rank as computed by `create_local_plan` API
|
||||
# Cached on the local rank.
|
||||
_cached_save_plan: dict[str, SavePlan] = {}
|
||||
# Final save plan for the current rank.
|
||||
# This is created by merging the plan created by `create_local_plan` API
|
||||
# and the result of `create_global_plan` for the given rank.
|
||||
# This is the final plan computed by the `finish_plan` API that gets
|
||||
# sent to the `write_data`.
|
||||
# Cached on the local rank.
|
||||
_cached_final_save_plan: dict[str, SavePlan] = {}
|
||||
# Collection of all the local plans from all the ranks.
|
||||
# This is the input to the `create_global_plan` API.
|
||||
# Cached on the coordinator rank.
|
||||
_cached_all_plans: dict[str, list[SavePlan]] = {}
|
||||
# Global checkpoint plan as computed by `create_global_plan` API.
|
||||
# Cached on the coordinator rank.
|
||||
_cached_global_plan: dict[str, list[SavePlan]] = {}
|
||||
# Metadata for the global checkpoint plan as computed by `create_global_plan` API.
|
||||
# Cached on the coordinator rank.
|
||||
_cached_metadata: dict[str, Metadata] = {}
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
storage_meta: StorageMeta | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize this planner to save ``state_dict``.
|
||||
|
||||
Implementations should save those values as they won't be provided lated in the save process.
|
||||
|
||||
This is called on all ranks.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_local_plan(self) -> SavePlan:
|
||||
"""
|
||||
Compute the save plan for the current rank.
|
||||
|
||||
This will be aggregated and passed to create_global_plan.
|
||||
Planner specific data can be passed through SavePlan::planner_data.
|
||||
|
||||
This is called on all ranks.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_global_plan(
|
||||
self, all_plans: list[SavePlan]
|
||||
) -> tuple[list[SavePlan], Metadata]:
|
||||
"""
|
||||
Compute the global checkpoint plan and return the local plan of each rank.
|
||||
|
||||
This is called on the coordinator rank only.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def finish_plan(self, new_plan: SavePlan) -> SavePlan:
|
||||
"""
|
||||
Merge the plan created by `create_local_plan` and the result of `create_global_plan`.
|
||||
|
||||
This is called on all ranks.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def resolve_data(self, write_item: WriteItem) -> torch.Tensor | io.BytesIO:
|
||||
"""
|
||||
Transform and prepare ``write_item`` from ``state_dict`` for storage, ensuring idempotency and thread-safety.
|
||||
|
||||
Lookup the object associated with ``write_item`` in ``state_dict`` and apply any
|
||||
transformation (such as serialization) prior to the storage layer consuming it.
|
||||
|
||||
Called on each rank multiple times, at least once per WriteItem in the final SavePlan.
|
||||
|
||||
This method should be idempotent and thread-save. StorageWriter implementations
|
||||
are free to call it as frequently as they need.
|
||||
|
||||
Any transformation that allocates memory should be lazily done when his method
|
||||
is called in order to reduce peak memory required by checkpointing.
|
||||
|
||||
When returning tensors, they can be on any device or format, they can be views too.
|
||||
It's the storage layer responsibility to figure out how to save them.
|
||||
"""
|
||||
|
||||
|
||||
class LoadPlanner:
|
||||
"""
|
||||
Abstract class defining the protocol used by load_state_dict to plan the load process.
|
||||
|
||||
LoadPlanner are stateful objects that can be used to customize the whole load process.
|
||||
|
||||
LoadPlanner acts as an access proxy to the state_dict, so any transformation done to it
|
||||
will be visible to the whole process.
|
||||
|
||||
A planner subclass can expect the following sequence of calls during load_state_dict:
|
||||
|
||||
1) set_up_planner - called on all ranks.
|
||||
Signals the start of loading a checkpoint.
|
||||
|
||||
2) create_local_plan - called on all ranks.
|
||||
Process the state_dict and produces a `LoadPlan` that will be sent for global planning.
|
||||
|
||||
3) create_global_plan - called on the coordinator rank only.
|
||||
Takes the LoadPlan from all ranks and make any global decision.
|
||||
|
||||
4) load_bytes - called multiple times on each rank
|
||||
This is called once per non-tensor value in state_dict.
|
||||
|
||||
5) resolve_tensor and commit_tensor - called multiple times on each rank
|
||||
They are called in pair for each Tensor value in state_dict.
|
||||
|
||||
Users are recommended to extend DefaultLoadPlanner instead of this interface directly as
|
||||
most changes can be expressed by changes in a single method.
|
||||
|
||||
There are two usual patterns of extension:
|
||||
|
||||
Rewriting state_dict. This is the simplest way to extend the load process as it
|
||||
doesn't requite understanding the intrincacies of how LoadPlan works. We need
|
||||
to keep a reference to the original state_dict as load happens in place so
|
||||
we need to be able to perform it in place
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> class RenamePlanner(DefaultLoadPlanner):
|
||||
>>> def set_up_planner(
|
||||
>>> self,
|
||||
>>> state_dict: STATE_DICT_TYPE,
|
||||
>>> metadata: Metadata,
|
||||
>>> is_coordinator: bool,
|
||||
>>> ) -> None:
|
||||
>>> self.original_state_dict = state_dict
|
||||
>>> state_dict = {"foo_" + k: v for k, v in state_dict.items()}
|
||||
>>>
|
||||
>>> if self.flatten_sharded_tensors:
|
||||
>>> state_dict = _flatten_sharded_tensors(state_dict)
|
||||
>>>
|
||||
>>> if self.flatten_state_dict:
|
||||
>>> state_dict, self.mappings = flatten_state_dict(state_dict)
|
||||
>>>
|
||||
>>> self.state_dict = state_dict
|
||||
>>> self.metadata = metadata
|
||||
>>> self.is_coordinator = is_coordinator
|
||||
>>>
|
||||
>>> def load_bytes(self, read_item, value):
|
||||
>>> # Remove the "foo_" prefix
|
||||
>>> self.original_state_dict[read_item.dest_index.fqn[4:]] = torch.load(value, weights_only=False)
|
||||
|
||||
|
||||
Modifying resolve_tensor and commit_tensor to handle load time transformation.
|
||||
|
||||
>>> # xdoctest: +SKIP("undefined vars")
|
||||
>>> class MetaModelMaterialize(DefaultSavePlanner):
|
||||
>>> def resolve_tensor(self, read_item):
|
||||
>>> tensor = super().resolve_tensor(read_item)
|
||||
>>> return torch.empty_like(tensor, device="cpu")
|
||||
>>>
|
||||
>>> def commit_tensor(self, read_item, tensor):
|
||||
>>> self.state_dict[read_item.dest_index.fqn] = tensor
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_up_planner(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
metadata: Metadata | None = None,
|
||||
is_coordinator: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize this instance to load data into ``state_dict``.
|
||||
|
||||
. N.B. This is called on every rank.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_local_plan(self) -> LoadPlan:
|
||||
"""
|
||||
Create a LoadPlan based on state_dict and metadata provided by set_up_planner.
|
||||
|
||||
. N.B. This is called on every rank.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_global_plan(self, global_plan: list[LoadPlan]) -> list[LoadPlan]:
|
||||
"""
|
||||
Compute the global load plan and return plans for each rank.
|
||||
|
||||
. N.B. This is called on the coordinator rank only
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def finish_plan(self, central_plan: LoadPlan) -> LoadPlan:
|
||||
"""Accept the plan from coordinator and return final LoadPlan."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def load_bytes(self, read_item: ReadItem, value: io.BytesIO) -> None:
|
||||
"""
|
||||
Load the item described by ``read_item``and ``value``.
|
||||
|
||||
This method is expected to modify in-place the underlying state_dict.
|
||||
|
||||
The contents of ``value`` are defined by the SavePlanner used to produce
|
||||
the checkpoint being loaded.
|
||||
"""
|
||||
|
||||
def resolve_bytes(self, read_item: ReadItem) -> io.BytesIO:
|
||||
"""
|
||||
Return the BytesIO to be used by the StorageReader to load `read_item`.
|
||||
|
||||
The BytesIO should alias with one on the underlying state_dict as StorageReader will replace its contents.
|
||||
"""
|
||||
raise NotImplementedError("LoadPlanner.resolve_bytes is not implemented")
|
||||
|
||||
@abc.abstractmethod
|
||||
def resolve_tensor(self, read_item: ReadItem) -> torch.Tensor:
|
||||
"""
|
||||
Return the tensor described by ``read_item`` to be used by the StorageReader to load `read_item`.
|
||||
|
||||
The tensor should alias with one on the underlying state_dict as StorageReader will replace its contents.
|
||||
If, for any reason, that's not possible, the planner can use the ``commit_tensor`` method to copy the data
|
||||
back to the one in state_dict.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def commit_tensor(self, read_item: ReadItem, tensor: torch.Tensor) -> None:
|
||||
"""
|
||||
Call once the StorageReader finished loading data into ``tensor``.
|
||||
|
||||
The provided tensor is the same one returned by the call to ``resolve_tensor``.
|
||||
This method is only needed if this LoadPlanner needs to post process ``tensor`` prior to
|
||||
copying it back to the one in the state_dict.
|
||||
|
||||
The contents of tensor will follow its device synchronization model.
|
||||
"""
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import io
|
||||
import itertools
|
||||
from bisect import bisect_right, insort
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch._utils import _get_device_module
|
||||
from torch.distributed._shard.metadata import ShardMetadata
|
||||
from torch.distributed._shard.sharded_tensor import ShardedTensor
|
||||
from torch.distributed.tensor import DTensor
|
||||
from torch.distributed.tensor._utils import compute_local_shape_and_global_offset
|
||||
|
||||
from .metadata import (
|
||||
BytesStorageMetadata,
|
||||
ChunkStorageMetadata,
|
||||
MetadataIndex,
|
||||
STATE_DICT_TYPE,
|
||||
STORAGE_TYPES,
|
||||
TensorProperties,
|
||||
TensorStorageMetadata,
|
||||
)
|
||||
from .planner import (
|
||||
LoadItemType,
|
||||
ReadItem,
|
||||
SavePlan,
|
||||
TensorWriteData,
|
||||
WriteItem,
|
||||
WriteItemType,
|
||||
)
|
||||
from .resharding import (
|
||||
_check_shard_metadata_pair_overlap,
|
||||
_shards_get_overlap_region_wrt_saved_tensor,
|
||||
)
|
||||
|
||||
|
||||
__all__: list[str] = ["create_read_items_for_chunk_list"]
|
||||
|
||||
|
||||
def _compare_save_plans(plan: SavePlan, other_plan: SavePlan) -> bool:
|
||||
"""
|
||||
Compare the two Save plans and return True if they are equal.
|
||||
|
||||
Args:
|
||||
plan (SavePlan): First SavePlan to compare.
|
||||
other_plan (SavePlan): Second SavePlan to compare.
|
||||
|
||||
Returns:
|
||||
True if the two plans are equal, False otherwise.
|
||||
"""
|
||||
if plan.usable != other_plan.usable:
|
||||
return False
|
||||
|
||||
# Both the plans should have the same number of items
|
||||
if len(plan.items) != len(other_plan.items):
|
||||
return False
|
||||
|
||||
# Both the plans should have the same write items.
|
||||
for plan_item, other_plan_item in zip(plan.items, other_plan.items):
|
||||
# Write item type should be same
|
||||
if plan_item.type != other_plan_item.type:
|
||||
return False
|
||||
|
||||
plan_metadata_index = plan_item.index
|
||||
other_plan_metadata_index = other_plan_item.index
|
||||
|
||||
# Write item metadata_index should be same
|
||||
if (
|
||||
plan_metadata_index.fqn != other_plan_metadata_index.fqn
|
||||
or plan_metadata_index.offset != other_plan_metadata_index.offset
|
||||
or plan_metadata_index.index != other_plan_metadata_index.index
|
||||
):
|
||||
return False
|
||||
|
||||
# Write item tensor_data should be present in both the write items plans, if it exists in either of them.
|
||||
tensor_data = plan_item.tensor_data
|
||||
other_tensor_data = other_plan_item.tensor_data
|
||||
if (tensor_data and not other_tensor_data) or (
|
||||
not tensor_data and other_tensor_data
|
||||
):
|
||||
return False
|
||||
|
||||
if tensor_data and other_tensor_data:
|
||||
# Write item tensor_data size should be same
|
||||
if tensor_data.size != other_tensor_data.size:
|
||||
return False
|
||||
|
||||
# Write item tensor_data chunk should be present in both the write items, if it exists in either of them.
|
||||
chunk = tensor_data.chunk
|
||||
other_chunk = other_tensor_data.chunk
|
||||
if (chunk and not other_chunk) or (not chunk and other_chunk):
|
||||
return False
|
||||
|
||||
# Write item tensor_data chunk offsets and sizes should be same
|
||||
if chunk and other_chunk:
|
||||
if (
|
||||
chunk.offsets != other_chunk.offsets
|
||||
or chunk.sizes != other_chunk.sizes
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _contains_usable_plan(delta_plans: list[SavePlan]) -> bool:
|
||||
"""
|
||||
Check if any delta plan is usable, indicating the plan has changed.
|
||||
|
||||
Args:
|
||||
delta_plans (List[SavePlan]): A list of delta plans to check.
|
||||
Returns:
|
||||
True if any delta plan is usable, False otherwise.
|
||||
"""
|
||||
return any(delta_plan and delta_plan.usable for delta_plan in delta_plans)
|
||||
|
||||
|
||||
def _merge_delta_local_plans(
|
||||
cached_plans: list[SavePlan],
|
||||
delta_plans: list[SavePlan],
|
||||
) -> list[SavePlan]:
|
||||
"""
|
||||
Merge a list of delta plans into a single plan.
|
||||
|
||||
Args:
|
||||
cached_plans (List[SavePlan]): A list of cached plans.
|
||||
delta_plans (List[SavePlan]): A list of delta plans to merge. It can contain empty plans
|
||||
|
||||
Returns:
|
||||
A single merged plan. If a delta plan is not usable, use the cached plan. Otherwise, use the delta plan.
|
||||
"""
|
||||
merged_plans = []
|
||||
|
||||
for cached_plan, delta_plan in zip(cached_plans, delta_plans):
|
||||
if delta_plan and not delta_plan.usable:
|
||||
merged_plans.append(cached_plan)
|
||||
else:
|
||||
merged_plans.append(delta_plan)
|
||||
|
||||
return merged_plans
|
||||
|
||||
|
||||
def _create_chunk_from_tensor(tensor: torch.Tensor) -> ChunkStorageMetadata:
|
||||
return ChunkStorageMetadata(
|
||||
offsets=torch.Size([0] * len(tensor.size())), sizes=tensor.size()
|
||||
)
|
||||
|
||||
|
||||
def _chunk_for_shard(shard_md: ShardMetadata) -> ChunkStorageMetadata:
|
||||
return ChunkStorageMetadata(
|
||||
offsets=torch.Size(shard_md.shard_offsets),
|
||||
sizes=torch.Size(shard_md.shard_sizes),
|
||||
)
|
||||
|
||||
|
||||
def _sharded_tensor_metadata(
|
||||
sharded_tensor: ShardedTensor, shard_md: ShardMetadata
|
||||
) -> TensorWriteData:
|
||||
shard_properties = sharded_tensor.metadata().tensor_properties
|
||||
|
||||
properties = TensorProperties(
|
||||
dtype=shard_properties.dtype,
|
||||
layout=shard_properties.layout,
|
||||
requires_grad=shard_properties.requires_grad,
|
||||
memory_format=shard_properties.memory_format,
|
||||
pin_memory=shard_properties.pin_memory,
|
||||
)
|
||||
|
||||
return TensorWriteData(
|
||||
chunk=_chunk_for_shard(shard_md),
|
||||
properties=properties,
|
||||
size=sharded_tensor.metadata().size,
|
||||
)
|
||||
|
||||
|
||||
def _create_write_items_for_dtensor(fqn: str, tensor: DTensor) -> WriteItem:
|
||||
sizes, offsets = compute_local_shape_and_global_offset(
|
||||
tensor.shape, tensor.device_mesh, tensor.placements
|
||||
)
|
||||
sizes, offsets = torch.Size(sizes), torch.Size(offsets)
|
||||
|
||||
return WriteItem(
|
||||
index=MetadataIndex(fqn, offsets),
|
||||
type=WriteItemType.SHARD,
|
||||
tensor_data=TensorWriteData(
|
||||
chunk=ChunkStorageMetadata(
|
||||
offsets=offsets,
|
||||
sizes=sizes,
|
||||
),
|
||||
properties=TensorProperties.create_from_tensor(tensor.to_local()),
|
||||
size=tensor.size(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_write_item_for_shard(
|
||||
fqn: str, sharded_tensor: ShardedTensor, shard_md: ShardMetadata
|
||||
) -> WriteItem:
|
||||
offsets = torch.Size(shard_md.shard_offsets)
|
||||
return WriteItem(
|
||||
index=MetadataIndex(fqn, offsets),
|
||||
type=WriteItemType.SHARD,
|
||||
tensor_data=_sharded_tensor_metadata(sharded_tensor, shard_md),
|
||||
)
|
||||
|
||||
|
||||
def _create_write_item_for_tensor(fqn: str, tensor: torch.Tensor) -> WriteItem:
|
||||
offsets = torch.Size([0] * len(tensor.size()))
|
||||
return WriteItem(
|
||||
index=MetadataIndex(fqn, offsets),
|
||||
type=WriteItemType.TENSOR,
|
||||
tensor_data=TensorWriteData(
|
||||
chunk=ChunkStorageMetadata(offsets=offsets, sizes=tensor.size()),
|
||||
properties=TensorProperties.create_from_tensor(tensor),
|
||||
size=tensor.size(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_write_item_for_bytesio(fqn: str, bytes: Any):
|
||||
return WriteItem(
|
||||
index=MetadataIndex(fqn),
|
||||
type=WriteItemType.BYTE_IO,
|
||||
)
|
||||
|
||||
|
||||
def _create_read_item_for_byteio(
|
||||
dest_index, dest_offset, storage_index, storage_offset, length
|
||||
):
|
||||
return ReadItem(
|
||||
type=LoadItemType.BYTE_IO,
|
||||
dest_index=dest_index,
|
||||
dest_offsets=torch.Size((dest_offset,)),
|
||||
storage_index=storage_index,
|
||||
storage_offsets=torch.Size((storage_offset,)),
|
||||
lengths=torch.Size((length,)),
|
||||
)
|
||||
|
||||
|
||||
def _create_read_item_for_tensor(
|
||||
dest_index, dest_offsets, storage_index, storage_offsets, lengths
|
||||
):
|
||||
return ReadItem(
|
||||
type=LoadItemType.TENSOR,
|
||||
dest_index=dest_index,
|
||||
dest_offsets=torch.Size(dest_offsets),
|
||||
storage_index=storage_index,
|
||||
storage_offsets=torch.Size(storage_offsets),
|
||||
lengths=torch.Size(lengths),
|
||||
)
|
||||
|
||||
|
||||
def create_read_items_for_chunk_list(
|
||||
fqn: str,
|
||||
checkpoint_md: TensorStorageMetadata,
|
||||
local_chunks: list[ChunkStorageMetadata],
|
||||
) -> list[ReadItem]:
|
||||
"""
|
||||
Create a list of ``ReadItem`` based on the checkpoint and local chunks.
|
||||
|
||||
This applies the resharding algorithm and computes the reads needed
|
||||
to satisfy ``local_chunks`` with a checkpoint described by ``checkpoint_md``.
|
||||
|
||||
Args:
|
||||
fqn (str) : The state_dict FQN to pass to ``ReadItem``.
|
||||
checkpoint_md (TensorStorageMetadata): metadata for a given tensor
|
||||
from a checkpoint.
|
||||
local_chunks (List[ChunkStorageMetadata]): Local chunks that needs to be
|
||||
loaded.
|
||||
|
||||
Returns:
|
||||
A list of ``ReadItem`` that will satisfy all input chunks.
|
||||
"""
|
||||
read_items: list[ReadItem] = []
|
||||
saved_chunks = checkpoint_md.chunks
|
||||
|
||||
if not local_chunks or not saved_chunks:
|
||||
return read_items
|
||||
|
||||
num_dims = len(local_chunks[0].offsets)
|
||||
|
||||
# Find sweep dimension (dimension with largest extent for better pruning)
|
||||
sweep_dim = 0
|
||||
if num_dims > 1:
|
||||
max_size = 0
|
||||
for dim in range(num_dims):
|
||||
dim_size = max(
|
||||
chunk.offsets[dim] + chunk.sizes[dim]
|
||||
for chunk in itertools.chain(local_chunks, saved_chunks)
|
||||
)
|
||||
if dim_size > max_size:
|
||||
max_size = dim_size
|
||||
sweep_dim = dim
|
||||
|
||||
# Pre-compute bounds: (start, end) for each chunk in sweep dimension
|
||||
# For 0-d tensors, use (0, 1) so all chunks overlap in the sweep line
|
||||
if num_dims == 0:
|
||||
saved_bounds = [(0, 1)] * len(saved_chunks)
|
||||
local_bounds = [(0, 1)] * len(local_chunks)
|
||||
else:
|
||||
saved_bounds = [
|
||||
(c.offsets[sweep_dim], c.offsets[sweep_dim] + c.sizes[sweep_dim])
|
||||
for c in saved_chunks
|
||||
]
|
||||
local_bounds = [
|
||||
(c.offsets[sweep_dim], c.offsets[sweep_dim] + c.sizes[sweep_dim])
|
||||
for c in local_chunks
|
||||
]
|
||||
|
||||
saved_sorted_indices = sorted(
|
||||
range(len(saved_chunks)),
|
||||
key=lambda idx: saved_bounds[idx][0],
|
||||
)
|
||||
local_sorted_indices = sorted(
|
||||
range(len(local_chunks)),
|
||||
key=lambda idx: local_bounds[idx][0],
|
||||
)
|
||||
|
||||
active_saved: list[tuple[int, int]] = []
|
||||
saved_ptr = 0
|
||||
num_saved = len(saved_sorted_indices)
|
||||
|
||||
for local_idx in local_sorted_indices:
|
||||
local_chunk = local_chunks[local_idx]
|
||||
local_start, local_end = local_bounds[local_idx]
|
||||
|
||||
cutoff = bisect_right(active_saved, (local_start, -1))
|
||||
if cutoff:
|
||||
del active_saved[:cutoff]
|
||||
|
||||
while saved_ptr < num_saved:
|
||||
storage_idx = saved_sorted_indices[saved_ptr]
|
||||
storage_chunk = saved_chunks[storage_idx]
|
||||
saved_start, saved_end = saved_bounds[storage_idx]
|
||||
|
||||
if saved_start >= local_end:
|
||||
break
|
||||
|
||||
insort(active_saved, (saved_end, storage_idx))
|
||||
saved_ptr += 1
|
||||
|
||||
for _, storage_idx in active_saved:
|
||||
storage_chunk = saved_chunks[storage_idx]
|
||||
if not _check_shard_metadata_pair_overlap(local_chunk, storage_chunk):
|
||||
continue
|
||||
|
||||
storage_offsets = []
|
||||
dest_offsets = []
|
||||
lengths = []
|
||||
for (
|
||||
_dim,
|
||||
offset_for_saved_tensor,
|
||||
offset_for_current_tensor,
|
||||
length,
|
||||
) in _shards_get_overlap_region_wrt_saved_tensor(
|
||||
saved_shard=storage_chunk, current_shard=local_chunk
|
||||
):
|
||||
storage_offsets.append(offset_for_saved_tensor)
|
||||
dest_offsets.append(offset_for_current_tensor)
|
||||
lengths.append(length)
|
||||
|
||||
read_items.append(
|
||||
_create_read_item_for_tensor(
|
||||
dest_index=MetadataIndex(fqn, local_chunk.offsets, local_idx),
|
||||
dest_offsets=dest_offsets,
|
||||
storage_index=MetadataIndex(
|
||||
fqn, storage_chunk.offsets, storage_idx
|
||||
),
|
||||
storage_offsets=storage_offsets,
|
||||
lengths=lengths,
|
||||
)
|
||||
)
|
||||
return read_items
|
||||
|
||||
|
||||
def _create_default_metadata_only_plan(state_dict: STATE_DICT_TYPE) -> SavePlan:
|
||||
requests = []
|
||||
for fqn, obj in state_dict.items():
|
||||
if isinstance(obj, DTensor):
|
||||
requests.append(_create_write_items_for_dtensor(fqn, obj))
|
||||
elif isinstance(obj, ShardedTensor):
|
||||
requests.extend(
|
||||
_create_write_item_for_shard(fqn, obj, shard_md)
|
||||
for shard_md in obj.metadata().shards_metadata
|
||||
)
|
||||
elif isinstance(obj, torch.Tensor):
|
||||
requests.append(_create_write_item_for_tensor(fqn, obj))
|
||||
else:
|
||||
requests.append(_create_write_item_for_bytesio(fqn, obj))
|
||||
return SavePlan(requests)
|
||||
|
||||
|
||||
def _create_write_items(fqn: str, object: Any) -> list[WriteItem]:
|
||||
if hasattr(object, "__create_write_items__"):
|
||||
# DTensor implements _Checkpointable
|
||||
return object.__create_write_items__(fqn, object)
|
||||
elif isinstance(object, ShardedTensor):
|
||||
return [
|
||||
_create_write_item_for_shard(fqn, object, shard.metadata)
|
||||
for shard in object.local_shards()
|
||||
]
|
||||
elif isinstance(object, torch.Tensor):
|
||||
return [_create_write_item_for_tensor(fqn, object)]
|
||||
else:
|
||||
return [_create_write_item_for_bytesio(fqn, object)]
|
||||
|
||||
|
||||
def _create_chunk_from_dtensor(tensor: DTensor) -> ChunkStorageMetadata:
|
||||
sizes, offsets = compute_local_shape_and_global_offset(
|
||||
tensor.shape, tensor.device_mesh, tensor.placements
|
||||
)
|
||||
sizes, offsets = torch.Size(sizes), torch.Size(offsets)
|
||||
return ChunkStorageMetadata(
|
||||
offsets=offsets,
|
||||
sizes=sizes,
|
||||
)
|
||||
|
||||
|
||||
def _create_chunk_list(tensor: torch.Tensor) -> list[ChunkStorageMetadata]:
|
||||
if hasattr(tensor, "__create_chunk_list__"):
|
||||
# DTensor implements _Checkpointable
|
||||
local_chunks = tensor.__create_chunk_list__() # type: ignore[attr-defined]
|
||||
elif isinstance(tensor, ShardedTensor):
|
||||
local_chunks = [
|
||||
_chunk_for_shard(shard.metadata) for shard in tensor.local_shards()
|
||||
]
|
||||
elif isinstance(tensor, torch.Tensor):
|
||||
local_chunks = [_create_chunk_from_tensor(tensor)]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Unsupported Type, expecting one of [Tensor, DTensor, ShardedTensor] "
|
||||
f",but got {type(tensor)}"
|
||||
)
|
||||
|
||||
return local_chunks
|
||||
|
||||
|
||||
def _create_read_items(fqn: str, md: STORAGE_TYPES, obj: Any) -> list[ReadItem]:
|
||||
if not isinstance(md, BytesStorageMetadata):
|
||||
try:
|
||||
local_chunks = _create_chunk_list(obj)
|
||||
except ValueError as ex:
|
||||
raise ValueError(
|
||||
f"Invalid checkpoint metadata for {fqn}, "
|
||||
+ f"expected BytesStorageMetadata but found {type(md)}",
|
||||
) from ex
|
||||
|
||||
return create_read_items_for_chunk_list(fqn, md, local_chunks)
|
||||
else:
|
||||
return [
|
||||
_create_read_item_for_byteio(
|
||||
dest_index=MetadataIndex(fqn),
|
||||
dest_offset=0,
|
||||
storage_index=MetadataIndex(fqn),
|
||||
storage_offset=0,
|
||||
length=0,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _init_state_dict(state_dict: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Initializes meta tensor if the meta tensor is DTensor or torch.Tensor.
|
||||
"""
|
||||
|
||||
def dtensor_func(value: DTensor):
|
||||
device = getattr(value, "device", None)
|
||||
if device == torch.device("meta"):
|
||||
device_type = dist.distributed_c10d._get_pg_default_device().type
|
||||
device = cast(
|
||||
torch.device, _get_device_module(device_type).current_device()
|
||||
)
|
||||
new_local_tensor = torch.empty_like(value.to_local(), device=device)
|
||||
# We need to pass shape and stride explicitly, since DTensor might be
|
||||
# sharded unevenly.
|
||||
dtensor = DTensor.from_local(
|
||||
new_local_tensor,
|
||||
device_mesh=value.device_mesh,
|
||||
placements=value.placements,
|
||||
shape=value.size(),
|
||||
stride=value.stride(),
|
||||
)
|
||||
return dtensor
|
||||
else:
|
||||
return value
|
||||
|
||||
def sharded_tensor_func(value: Any):
|
||||
device = getattr(value, "device", None)
|
||||
if device == torch.device("meta"):
|
||||
raise RuntimeError(
|
||||
f"Found unsupported type {type(value)} for meta device loading."
|
||||
)
|
||||
else:
|
||||
return value
|
||||
|
||||
def tensor_func(value: torch.Tensor):
|
||||
device = getattr(value, "device", None)
|
||||
if device == torch.device("meta"):
|
||||
device_type = dist.distributed_c10d._get_pg_default_device().type
|
||||
device = cast(
|
||||
torch.device, _get_device_module(device_type).current_device()
|
||||
)
|
||||
tensor = torch.empty_like(value, device=device)
|
||||
return tensor
|
||||
else:
|
||||
return value
|
||||
|
||||
_iterate_state_dict(
|
||||
state_dict,
|
||||
dtensor_func,
|
||||
sharded_tensor_func,
|
||||
tensor_func,
|
||||
)
|
||||
|
||||
|
||||
def _iterate_state_dict(
|
||||
iter_object: Any,
|
||||
dtensor_func: Callable,
|
||||
sharded_tensor_func: Callable,
|
||||
tensor_func: Callable,
|
||||
):
|
||||
"""
|
||||
Iterate through the state dict, applying the given functions to each tensor type
|
||||
and update the state dict in place.
|
||||
|
||||
Args:
|
||||
iter_object (Any): the target state_dict.
|
||||
sharded_tensor_func (Callable): the function to apply to ShardedTensor
|
||||
dtensor_func (Callable): the function to apply to DTensor
|
||||
tensor_func (Callable): the function to apply to Tensor
|
||||
|
||||
# TODO: let state_dict_util._iterate_state_dict() to support in place option
|
||||
so we don't need to have two versions of _iterate_state_dict.
|
||||
"""
|
||||
|
||||
if isinstance(iter_object, DTensor):
|
||||
return dtensor_func(iter_object)
|
||||
elif isinstance(iter_object, ShardedTensor):
|
||||
return sharded_tensor_func(iter_object)
|
||||
elif isinstance(iter_object, torch.Tensor):
|
||||
return tensor_func(iter_object)
|
||||
elif (
|
||||
isinstance(iter_object, (int, float, str, bytes, io.BytesIO))
|
||||
or iter_object is None
|
||||
):
|
||||
return iter_object
|
||||
elif isinstance(iter_object, dict):
|
||||
for key, value in iter_object.items():
|
||||
iter_object[key] = _iterate_state_dict(
|
||||
value, dtensor_func, sharded_tensor_func, tensor_func
|
||||
)
|
||||
return iter_object
|
||||
elif isinstance(iter_object, (list, tuple)):
|
||||
ret = [
|
||||
_iterate_state_dict(v, dtensor_func, sharded_tensor_func, tensor_func)
|
||||
for v in iter_object
|
||||
]
|
||||
if isinstance(iter_object, tuple):
|
||||
ret = tuple(ret) # type: ignore[assignment]
|
||||
return ret
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed.checkpoint._hf_utils import _metadata_fn
|
||||
from torch.distributed.checkpoint.metadata import TensorStorageMetadata
|
||||
from torch.distributed.checkpoint.planner import LoadPlanner, ReadItem
|
||||
|
||||
from .hf_storage import HuggingFaceStorageReader
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["QuantizedHuggingFaceStorageReader"]
|
||||
|
||||
|
||||
class QuantizedHuggingFaceStorageReader(HuggingFaceStorageReader):
|
||||
"""
|
||||
Extension of HuggingFaceStorageReader that handles quantized tensors.
|
||||
Checkpoint should have the full tensor in a SafeTensor file. The quantized
|
||||
tensor should not be sharded across multiple files.
|
||||
|
||||
This reader handles the dequantization of tensors during the read process,
|
||||
converting them from quantized blocks to full dequantized tensors before
|
||||
copying to the target tensor.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
thread_count: int = 1,
|
||||
target_dtype: torch.dtype = torch.float32,
|
||||
block_size: int = 128,
|
||||
):
|
||||
"""
|
||||
Initialize the HuggingFace storage reader to load quantized checkpoints
|
||||
|
||||
Args:
|
||||
path: directory where the checkpoint will be read from.
|
||||
thread_count: Number of threads to use to read distributed checkpoint. Defaults to 1.
|
||||
target_dtype: Target dtype for dequantized tensor. Defaults to torch.float32.
|
||||
block_size: Fixed block size for dequantization. Defaults to 128.
|
||||
"""
|
||||
super().__init__(path=path, thread_count=thread_count)
|
||||
|
||||
self.target_dtype: torch.dtype = target_dtype
|
||||
self.block_size: int = block_size
|
||||
self._weight_scale_mapping: dict[str, str] = {}
|
||||
# Track which file contains each tensor
|
||||
self._weight_map: dict[str, str] = {}
|
||||
# Cache for full tensor shapes (fqn -> shape)
|
||||
self._tensor_full_shapes: dict[str, torch.Size] = {}
|
||||
|
||||
def read_metadata(self) -> Any:
|
||||
metadata = super().read_metadata()
|
||||
|
||||
# Load quantization metadata first.
|
||||
self._load_quantization_metadata()
|
||||
|
||||
# Build a cache of FQN -> full tensor shape, correcting for quantized tensors.
|
||||
for fqn, tensor_metadata in metadata.state_dict_metadata.items():
|
||||
# Only process TensorStorageMetadata which has size attribute.
|
||||
if isinstance(tensor_metadata, TensorStorageMetadata):
|
||||
# Check if this is a MXFP4 quantized tensor that needs shape correction.
|
||||
if fqn.endswith("_blocks"):
|
||||
# Save the quantized tensor shapes for lookup when dequantization.
|
||||
self._tensor_full_shapes[fqn + "_quantized"] = tensor_metadata.size
|
||||
*prefix_shape, G, B = tensor_metadata.size
|
||||
dequantized_size = torch.Size([*prefix_shape, G * B * 2])
|
||||
|
||||
# Update the metadata with the size after dequantization.
|
||||
# Metadata used by planner to slice state dict.
|
||||
tensor_metadata.size = dequantized_size
|
||||
self._tensor_full_shapes[fqn] = dequantized_size
|
||||
else:
|
||||
self._tensor_full_shapes[fqn] = tensor_metadata.size
|
||||
|
||||
return metadata
|
||||
|
||||
def _load_quantization_metadata(self):
|
||||
"""Load quantization metadata from the checkpoint."""
|
||||
checkpoint_path = Path(self.path)
|
||||
# Load weight mapping from index file
|
||||
index_file = checkpoint_path / _metadata_fn
|
||||
|
||||
with open(index_file) as f:
|
||||
index_data = json.load(f)
|
||||
weight_map = index_data.get("weight_map", {})
|
||||
self._build_weight_scale_mapping(weight_map)
|
||||
|
||||
def _build_weight_scale_mapping(self, weight_map: dict[str, str]):
|
||||
"""Analyze and build weight-scale tensor pairs from weight mapping."""
|
||||
# Store the complete weight map for file location lookups.
|
||||
self._weight_map = weight_map
|
||||
|
||||
for tensor_name in weight_map:
|
||||
if tensor_name.endswith(".weight_scale_inv"):
|
||||
weight_name = tensor_name.replace(".weight_scale_inv", ".weight")
|
||||
if weight_name in weight_map:
|
||||
self._weight_scale_mapping[weight_name] = tensor_name
|
||||
# Handle MXFP4 format: _blocks and _scales.
|
||||
elif tensor_name.endswith("_scales"):
|
||||
blocks_name = tensor_name.replace("_scales", "_blocks")
|
||||
if blocks_name in weight_map:
|
||||
self._weight_scale_mapping[blocks_name] = tensor_name
|
||||
|
||||
def _process_read_request(
|
||||
self, f: Any, req: ReadItem, planner: LoadPlanner
|
||||
) -> None:
|
||||
"""Override the Helper function that processes a single read request."""
|
||||
tensor_fqn = req.storage_index.fqn
|
||||
|
||||
# Check if this is a quantized tensor that needs dequantization
|
||||
if self._is_tensor_quantized(tensor_fqn):
|
||||
tensor = self._read_quantized_tensor_with_block_alignment(req, f)
|
||||
else:
|
||||
# Standard tensor reading
|
||||
slices = tuple(
|
||||
slice(offset, offset + length)
|
||||
for offset, length in zip(req.storage_offsets, req.lengths)
|
||||
)
|
||||
tensor = f.get_slice(tensor_fqn)[slices]
|
||||
|
||||
target_tensor = planner.resolve_tensor(req).detach()
|
||||
|
||||
if target_tensor.size() != tensor.size():
|
||||
raise AssertionError(
|
||||
f"req {req.storage_index} mismatch sizes {target_tensor.size()} vs {tensor.size()}"
|
||||
)
|
||||
|
||||
target_tensor.copy_(tensor)
|
||||
planner.commit_tensor(req, target_tensor)
|
||||
|
||||
def _get_slice_to_block_mapping(
|
||||
self, req: ReadItem
|
||||
) -> tuple[tuple[int, int], tuple[int, int], slice, slice]:
|
||||
"""
|
||||
Calculate which blocks correspond to the requested slice.
|
||||
|
||||
Args:
|
||||
req: Read request containing tensor info and required slices
|
||||
|
||||
Returns:
|
||||
Tuple of (row_block_range, col_block_range, row_slice, col_slice)
|
||||
"""
|
||||
# Get the slice information
|
||||
row_slice = slice(
|
||||
req.storage_offsets[0], req.storage_offsets[0] + req.lengths[0]
|
||||
)
|
||||
col_slice = slice(
|
||||
req.storage_offsets[1], req.storage_offsets[1] + req.lengths[1]
|
||||
)
|
||||
|
||||
# Calculate which blocks this slice spans
|
||||
row_start_block = row_slice.start // self.block_size
|
||||
row_end_block = (row_slice.stop - 1) // self.block_size + 1 # Inclusive end
|
||||
|
||||
col_start_block = col_slice.start // self.block_size
|
||||
col_end_block = (col_slice.stop - 1) // self.block_size + 1 # Inclusive end
|
||||
|
||||
return (
|
||||
(row_start_block, row_end_block),
|
||||
(col_start_block, col_end_block),
|
||||
row_slice,
|
||||
col_slice,
|
||||
)
|
||||
|
||||
def _dequantize_tensor_mxfp4(
|
||||
self,
|
||||
blocks: torch.Tensor,
|
||||
scales: torch.Tensor,
|
||||
req: ReadItem,
|
||||
group_start: int,
|
||||
offset_in_first_group: int,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Dequantize a 4D tensor using MXFP4 format.
|
||||
Adapted from openai's implementation:
|
||||
https://github.com/openai/gpt-oss/blob/8890e95919f975a490fc0ba09ffb10890ec7319d/gpt_oss/torch/weights.py#L68
|
||||
|
||||
Args:
|
||||
blocks: Sliced quantized weight tensor of shape [a_slice, b_slice, groups_slice, B] in uint8
|
||||
scales: FULL scale tensor of shape [a, b, c] in uint8 (will be converted to exponents)
|
||||
req: Read request containing slice information
|
||||
group_start: The starting group index in the checkpoint
|
||||
offset_in_first_group: Offset in values within the first group
|
||||
|
||||
Returns:
|
||||
Dequantized tensor matching the requested shape
|
||||
"""
|
||||
# FP4 lookup table
|
||||
FP4_VALUES = [
|
||||
+0.0,
|
||||
+0.5,
|
||||
+1.0,
|
||||
+1.5,
|
||||
+2.0,
|
||||
+3.0,
|
||||
+4.0,
|
||||
+6.0,
|
||||
-0.0,
|
||||
-0.5,
|
||||
-1.0,
|
||||
-1.5,
|
||||
-2.0,
|
||||
-3.0,
|
||||
-4.0,
|
||||
-6.0,
|
||||
]
|
||||
|
||||
# blocks: [a_slice, b_slice, groups_slice, B] uint8.
|
||||
# Read slightly more groups than needed, and slice at the end.
|
||||
|
||||
# Slice the scales to match the blocks dimensions.
|
||||
# [a_full, b_full, c_full] -> [a_slice, b_slice, groups_slice]
|
||||
dim0_start = req.storage_offsets[0]
|
||||
dim0_end = dim0_start + req.lengths[0]
|
||||
dim1_start = req.storage_offsets[1]
|
||||
dim1_end = dim1_start + req.lengths[1]
|
||||
num_groups = blocks.shape[2]
|
||||
scales = scales[
|
||||
dim0_start:dim0_end,
|
||||
dim1_start:dim1_end,
|
||||
group_start : group_start + num_groups,
|
||||
]
|
||||
|
||||
scales = scales.to(torch.int32) - 127
|
||||
|
||||
if blocks.shape[:-1] != scales.shape:
|
||||
raise AssertionError(f"{blocks.shape=} does not match {scales.shape=}")
|
||||
|
||||
lut = torch.tensor(FP4_VALUES, dtype=self.target_dtype, device=blocks.device)
|
||||
|
||||
*prefix_shape, G, B = blocks.shape
|
||||
rows_total = math.prod(prefix_shape) * G
|
||||
|
||||
blocks = blocks.reshape(rows_total, B)
|
||||
scales = scales.reshape(rows_total, 1)
|
||||
|
||||
out = torch.empty(
|
||||
rows_total, B * 2, dtype=self.target_dtype, device=blocks.device
|
||||
)
|
||||
|
||||
rows_per_chunk = 16384 * 512
|
||||
|
||||
for r0 in range(0, rows_total, rows_per_chunk):
|
||||
r1 = min(r0 + rows_per_chunk, rows_total)
|
||||
|
||||
blk = blocks[r0:r1]
|
||||
exp = scales[r0:r1]
|
||||
|
||||
# nibble indices -> int64
|
||||
idx_lo = (blk & 0x0F).to(torch.long)
|
||||
idx_hi = (blk >> 4).to(torch.long)
|
||||
|
||||
sub = out[r0:r1]
|
||||
sub[:, 0::2] = lut[idx_lo]
|
||||
sub[:, 1::2] = lut[idx_hi]
|
||||
|
||||
torch.ldexp(sub, exp, out=sub)
|
||||
|
||||
del idx_lo, idx_hi, blk, exp
|
||||
|
||||
result = out.reshape(*prefix_shape, G, B * 2).view(*prefix_shape, G * B * 2)
|
||||
|
||||
# Slice the last dimension to match the requested range.
|
||||
if offset_in_first_group > 0 or result.shape[-1] > req.lengths[2]:
|
||||
end_offset = offset_in_first_group + req.lengths[2]
|
||||
result = result[..., offset_in_first_group:end_offset]
|
||||
|
||||
return result
|
||||
|
||||
def _dequantize_tensor(
|
||||
self,
|
||||
weight: torch.Tensor,
|
||||
scale_inv: torch.Tensor,
|
||||
full_tensor_shape: torch.Size,
|
||||
slice_info: tuple[tuple[int, int], tuple[int, int], slice, slice],
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Dequantize a sliced tensor using the appropriate portion of the scale tensor.
|
||||
|
||||
Args:
|
||||
weight: Sliced quantized weight tensor
|
||||
scale_inv: Full scale inverse tensor for dequantization
|
||||
full_tensor_shape: Shape of the original full tensor
|
||||
slice_info: Block mapping information from _get_slice_to_block_mapping
|
||||
|
||||
Returns:
|
||||
Dequantized tensor
|
||||
"""
|
||||
(row_block_range, col_block_range, row_slice, col_slice) = slice_info
|
||||
|
||||
# Convert to float32 for computation
|
||||
# Certain quantized dtypes like Float8_e4m3fn
|
||||
# don't support multiplication on CPU yet in PyTorch.
|
||||
upcasted_weight = weight.to(torch.float32)
|
||||
|
||||
# Create output tensor in target dtype
|
||||
dequantized = weight.detach().to(dtype=self.target_dtype, copy=True)
|
||||
|
||||
# Get the actual slice boundaries
|
||||
row_start_global = row_slice.start
|
||||
row_end_global = row_slice.stop
|
||||
col_start_global = col_slice.start
|
||||
col_end_global = col_slice.stop
|
||||
|
||||
# Apply scaling factors to each block that intersects with our slice
|
||||
for block_i in range(row_block_range[0], row_block_range[1]):
|
||||
for block_j in range(col_block_range[0], col_block_range[1]):
|
||||
# Calculate the block boundaries in global coordinates
|
||||
block_row_start_global = block_i * self.block_size
|
||||
block_row_end_global = min(
|
||||
block_row_start_global + self.block_size, full_tensor_shape[0]
|
||||
)
|
||||
block_col_start_global = block_j * self.block_size
|
||||
block_col_end_global = min(
|
||||
block_col_start_global + self.block_size, full_tensor_shape[1]
|
||||
)
|
||||
|
||||
# Find the intersection of the block with our slice
|
||||
intersect_row_start = max(block_row_start_global, row_start_global)
|
||||
intersect_row_end = min(block_row_end_global, row_end_global)
|
||||
intersect_col_start = max(block_col_start_global, col_start_global)
|
||||
intersect_col_end = min(block_col_end_global, col_end_global)
|
||||
|
||||
# Skip if no intersection
|
||||
if (
|
||||
intersect_row_start >= intersect_row_end
|
||||
or intersect_col_start >= intersect_col_end
|
||||
):
|
||||
continue
|
||||
|
||||
# Convert global coordinates to local coordinates in the sliced tensor
|
||||
local_row_start = intersect_row_start - row_start_global
|
||||
local_row_end = intersect_row_end - row_start_global
|
||||
local_col_start = intersect_col_start - col_start_global
|
||||
local_col_end = intersect_col_end - col_start_global
|
||||
|
||||
# Get the block from the sliced tensor
|
||||
block = upcasted_weight[
|
||||
local_row_start:local_row_end, local_col_start:local_col_end
|
||||
]
|
||||
|
||||
# Apply the scale factor
|
||||
scale = scale_inv[block_i, block_j]
|
||||
block = block * scale
|
||||
|
||||
# Convert block to target dtype and store
|
||||
block_converted = block.to(dtype=self.target_dtype)
|
||||
dequantized[
|
||||
local_row_start:local_row_end, local_col_start:local_col_end
|
||||
] = block_converted
|
||||
|
||||
return dequantized
|
||||
|
||||
def _is_tensor_quantized(self, tensor_fqn: str) -> bool:
|
||||
"""
|
||||
Check if a tensor is a quantized.
|
||||
|
||||
Args:
|
||||
tensor_fqn: Fully qualified name of the tensor
|
||||
|
||||
Returns:
|
||||
True if tensor is quantized and has a corresponding scale tensor,
|
||||
False otherwise
|
||||
"""
|
||||
# Skip scale tensors themselves
|
||||
if tensor_fqn.endswith((".weight_scale_inv", "_scales")):
|
||||
return False
|
||||
|
||||
# Check if this weight tensor has a corresponding scale tensor
|
||||
if tensor_fqn not in self._weight_scale_mapping:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _read_quantized_tensor_with_block_alignment(
|
||||
self, req: ReadItem, safetensor_file: Any
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Read a quantized tensor with block alignment.
|
||||
|
||||
Args:
|
||||
req: Read request containing tensor info and required slices
|
||||
safetensor_file: Open safetensors file handle
|
||||
|
||||
Returns:
|
||||
Dequantized tensor ready for use
|
||||
"""
|
||||
tensor_fqn = req.storage_index.fqn
|
||||
scale_fqn = self._weight_scale_mapping[tensor_fqn]
|
||||
|
||||
try:
|
||||
group_start = 0
|
||||
offset_in_first_group = 0
|
||||
if tensor_fqn.endswith("_blocks"):
|
||||
# Full tensor is a 4D MXFP4 quantized tensor: [..., G, B].
|
||||
# Each group G produces B * 2 dequantized values.
|
||||
# Checkpoint [..., G, B] -> dequantized [..., G*B*2].
|
||||
|
||||
# The planner gives 3D requests based on the dequantized shape.
|
||||
# Need to figure out which groups (dimension 2 in checkpoint) to read.
|
||||
|
||||
# Use the quantized checkpoint shape to get the correct B.
|
||||
*prefix_shape, B = self._tensor_full_shapes[tensor_fqn + "_quantized"]
|
||||
values_per_group = B * 2 # Each byte has 2 nibbles (4-bit values).
|
||||
|
||||
# Calculate which groups we need based on the requested range in dim 2.
|
||||
# Ensure the reequest is in 3D.
|
||||
if len(req.storage_offsets) != 3:
|
||||
raise AssertionError
|
||||
|
||||
# Positions in dequantized space.
|
||||
dim2_start_deq = req.storage_offsets[2]
|
||||
dim2_length_deq = req.lengths[2]
|
||||
dim2_end_deq = dim2_start_deq + dim2_length_deq
|
||||
|
||||
# Convert to group indices.
|
||||
group_start = dim2_start_deq // values_per_group
|
||||
group_end = (dim2_end_deq + values_per_group - 1) // values_per_group
|
||||
|
||||
# Read only the necessary groups from checkpoint.
|
||||
weight_slices_4d = (
|
||||
slice(
|
||||
req.storage_offsets[0], req.storage_offsets[0] + req.lengths[0]
|
||||
),
|
||||
slice(
|
||||
req.storage_offsets[1], req.storage_offsets[1] + req.lengths[1]
|
||||
),
|
||||
slice(group_start, group_end),
|
||||
slice(None), # Read all B values for each group.
|
||||
)
|
||||
quantized_tensor = safetensor_file.get_slice(tensor_fqn)[
|
||||
weight_slices_4d
|
||||
]
|
||||
|
||||
# Also track the offset within the first group
|
||||
offset_in_first_group = dim2_start_deq - (
|
||||
group_start * values_per_group
|
||||
)
|
||||
else:
|
||||
# 2D quantized tensor, use 2d block partition.
|
||||
weight_slices = tuple(
|
||||
slice(offset, offset + length)
|
||||
for offset, length in zip(req.storage_offsets, req.lengths)
|
||||
)
|
||||
quantized_tensor = safetensor_file.get_slice(tensor_fqn)[weight_slices]
|
||||
|
||||
# Load the corresponding scale inverse tensor (full tensor)
|
||||
scale_file_name = self._weight_map.get(scale_fqn)
|
||||
if scale_file_name is None:
|
||||
raise ValueError(f"Scale tensor {scale_fqn} not found in weight_map")
|
||||
|
||||
# Check if scale tensor is in the same file as the weight tensor
|
||||
weight_file_name = self._weight_map.get(tensor_fqn)
|
||||
|
||||
if scale_file_name == weight_file_name:
|
||||
# Scale tensor is in the same file, use current handle
|
||||
scale_inv = safetensor_file.get_tensor(scale_fqn)
|
||||
else:
|
||||
# Scale tensor is in a different file, need to open it
|
||||
from safetensors import safe_open # type: ignore[import]
|
||||
|
||||
scale_file_path = Path(self.path) / scale_file_name
|
||||
with safe_open(
|
||||
scale_file_path, framework="pt", device="cpu"
|
||||
) as scale_file:
|
||||
scale_inv = scale_file.get_tensor(scale_fqn)
|
||||
|
||||
# Get the full tensor shape from our O(1) lookup cache
|
||||
full_tensor_shape = self._tensor_full_shapes.get(tensor_fqn)
|
||||
if full_tensor_shape is None:
|
||||
raise ValueError(f"Could not find full tensor shape for {tensor_fqn}")
|
||||
|
||||
# Determine which dequantization function to use.
|
||||
if len(full_tensor_shape) == 2:
|
||||
# 2D block-wise quantization, e.g., used in deepseek v3.1
|
||||
slice_info = self._get_slice_to_block_mapping(req)
|
||||
dequantized_tensor = self._dequantize_tensor(
|
||||
weight=quantized_tensor,
|
||||
scale_inv=scale_inv,
|
||||
full_tensor_shape=full_tensor_shape,
|
||||
slice_info=slice_info,
|
||||
)
|
||||
elif tensor_fqn.endswith("_blocks"):
|
||||
# 4D with blocks along dimension 2, used in MXFP4, e.g. gpt-oss
|
||||
dequantized_tensor = self._dequantize_tensor_mxfp4(
|
||||
blocks=quantized_tensor,
|
||||
scales=scale_inv,
|
||||
req=req,
|
||||
group_start=group_start,
|
||||
offset_in_first_group=offset_in_first_group,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unsupported quantization types")
|
||||
|
||||
return dequantized_tensor
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to read the quantized tensor!!")
|
||||
raise e
|
||||
@@ -0,0 +1,69 @@
|
||||
from torch.distributed.checkpoint.metadata import ChunkStorageMetadata
|
||||
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
def _check_shard_metadata_pair_overlap(
|
||||
shard1: ChunkStorageMetadata, shard2: ChunkStorageMetadata
|
||||
) -> bool:
|
||||
"""Check if two shards overlap."""
|
||||
# For each dim of each shard, check if one shard resides on the other
|
||||
# end of second shard with respect to that dim. As an example for a 2D
|
||||
# shard, we would check if one shard is above or on the left of the
|
||||
# other shard.
|
||||
ndims = len(shard1.offsets)
|
||||
for i in range(ndims):
|
||||
if shard1.offsets[i] >= shard2.offsets[i] + shard2.sizes[i]:
|
||||
return False
|
||||
if shard2.offsets[i] >= shard1.offsets[i] + shard1.sizes[i]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _shards_get_overlap_region_wrt_saved_tensor(
|
||||
saved_shard: ChunkStorageMetadata, current_shard: ChunkStorageMetadata
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""
|
||||
Return the overlapping region between saved_shard and current_shard.
|
||||
|
||||
There returned list has the same number of elements as the tensor's dimension.
|
||||
For each element, we produce a tuple with the following contents:
|
||||
(dimension, `saved_shard` offset, `current_shard` offset, length)
|
||||
|
||||
Offsets are relative to each shard.
|
||||
"""
|
||||
narrows = []
|
||||
for dim, (
|
||||
saved_shard_offset,
|
||||
current_shard_offset,
|
||||
saved_shard_size,
|
||||
current_shard_size,
|
||||
) in enumerate(
|
||||
zip(
|
||||
saved_shard.offsets,
|
||||
current_shard.offsets,
|
||||
saved_shard.sizes,
|
||||
current_shard.sizes,
|
||||
)
|
||||
):
|
||||
min_range_end = min(
|
||||
saved_shard_offset + saved_shard_size,
|
||||
current_shard_offset + current_shard_size,
|
||||
)
|
||||
|
||||
length = min_range_end - max(current_shard_offset, saved_shard_offset)
|
||||
|
||||
if saved_shard_offset > current_shard_offset:
|
||||
offset_for_saved_tensor = 0
|
||||
offset_for_current_tensor = saved_shard_offset - current_shard_offset
|
||||
else:
|
||||
offset_for_saved_tensor = current_shard_offset - saved_shard_offset
|
||||
offset_for_current_tensor = 0
|
||||
|
||||
narrows.append(
|
||||
(dim, offset_for_saved_tensor, offset_for_current_tensor, length)
|
||||
)
|
||||
|
||||
return narrows
|
||||
@@ -0,0 +1,477 @@
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
from typing_extensions import deprecated, Protocol, runtime_checkable
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed import ProcessGroup
|
||||
from torch.distributed._state_dict_utils import _copy_state_dict, _create_cpu_state_dict
|
||||
from torch.distributed.checkpoint._pg_transport import PGTransport
|
||||
from torch.distributed.checkpoint._state_dict_stager import StateDictStager
|
||||
from torch.distributed.checkpoint.metadata import STATE_DICT_TYPE
|
||||
|
||||
|
||||
__all__ = ["AsyncStager", "BlockingAsyncStager", "DefaultStager", "StagingOptions"]
|
||||
|
||||
"""
|
||||
Experimental staging module for PyTorch Distributed Checkpointing.
|
||||
This module provides advanced staging capabilities for checkpoints including:
|
||||
- Asynchronous staging using ThreadPoolExecutor
|
||||
- Pinned memory allocation for faster CPU-GPU transfers
|
||||
- Shared memory support for multi-process scenarios
|
||||
- Non-blocking CUDA operations with stream synchronization
|
||||
- Caching of frequently used storages for efficient memory management
|
||||
- Automatic resource cleanup and memory management
|
||||
Classes:
|
||||
AsyncStager: Protocol defining the staging interface
|
||||
StagingOptions: Configuration dataclass for staging behavior
|
||||
DefaultStager: Default implementation with comprehensive staging features
|
||||
BlockingAsyncStager: Implementation of AsyncStager which stages the state_dict
|
||||
on CPU RAM and blocks until the copy is complete. Please use DefaultStager instead.
|
||||
"""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncStager(Protocol):
|
||||
"""
|
||||
This protocol is meant to provide customization and extensibility for dcp.async_save, allowing users
|
||||
to customize how data is staged previous to executing the usual dcp.save path in parallel.
|
||||
The expected order of operations (concretely defined in `torch.distributed.state_dict_saver.async_save`)
|
||||
is the following:
|
||||
|
||||
1. AsyncStager.stage_data(state_dict):
|
||||
This call gives the AsyncStager the opportunity to 'stage'
|
||||
the state_dict. The expectation and purpose of staging in this context is to create a "training-safe"
|
||||
representation of the state dict, meaning that any updates to module data after staging is complete
|
||||
should not be reflected in the state dict returned from this method. For example, in the default
|
||||
case a copy of the entire state dict is created on CPU RAM and returned here, allowing users
|
||||
to continue training without risking changes to data which is being serialized.
|
||||
|
||||
2. dcp.save is called on the state_dict returned from stage in parallel. This call is responsible
|
||||
for serializing the state_dict and writing it to storage.
|
||||
|
||||
3. If AsyncStager.should_synchronize_after_execute is True, this method will be called immediately after
|
||||
the serialization thread starts and before returning from dcp.async_save. If this is set to False,
|
||||
the assumption is the user has defined a custom synchronization point for the purpose of further
|
||||
optimizing save latency in the training loop (for example, by overlapping staging with the
|
||||
forward/backward pass), and it is the respondsibility of the user to call `AsyncStager.synchronize_staging`
|
||||
at the appropriate time.
|
||||
|
||||
"""
|
||||
|
||||
# default to True since the common case is to stage synchronously
|
||||
_synchronize_after_execute: bool = True
|
||||
|
||||
@property
|
||||
def should_synchronize_after_execute(self) -> bool:
|
||||
"""
|
||||
Whether to synchronize after executing the stage.
|
||||
"""
|
||||
return self._synchronize_after_execute
|
||||
|
||||
def stage(
|
||||
self, state_dict: STATE_DICT_TYPE
|
||||
) -> Future[STATE_DICT_TYPE] | STATE_DICT_TYPE:
|
||||
"""
|
||||
Returns a "staged" copy of `state_dict`. The expectation of the staged copy is that it is
|
||||
inoculated from any updates incurred after the stage call is complete.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} must implement stage method"
|
||||
)
|
||||
|
||||
@deprecated(
|
||||
"`synchronize_staging` is deprecated and will be removed in future versions."
|
||||
"Please use staging_future from AsyncSaveResponse instead.",
|
||||
category=FutureWarning,
|
||||
)
|
||||
def synchronize_staging(self) -> None:
|
||||
"""
|
||||
In the case `stage` is async in some way, this method should be called to ensure staging
|
||||
is complete and it is safe to begin modifying the original `state_dict`
|
||||
"""
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Clean up all resources used by the stager.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagingOptions:
|
||||
"""
|
||||
Configuration options for checkpoint staging behavior.
|
||||
|
||||
Attributes:
|
||||
use_pinned_memory (bool): Enable pinned memory allocation for faster
|
||||
CPU-GPU transfers. Requires CUDA to be available. Default: True
|
||||
use_shared_memory (bool): Enable shared memory for multi-process
|
||||
scenarios. Useful when multiple processes need access to the
|
||||
same staged data. Default: True
|
||||
use_async_staging (bool): Enable asynchronous staging using a
|
||||
background thread pool. Allows overlapping computation with
|
||||
staging operations. Requires CUDA. Default: True
|
||||
use_non_blocking_copy (bool): Use non-blocking device memory
|
||||
copies with stream synchronization. Improves performance by
|
||||
allowing CPU work to continue during GPU transfers. Default: True
|
||||
|
||||
Note:
|
||||
CUDA-dependent features will raise exception if CUDA is not available.
|
||||
"""
|
||||
|
||||
use_pinned_memory: bool = True
|
||||
use_shared_memory: bool = True
|
||||
use_async_staging: bool = True
|
||||
use_non_blocking_copy: bool = True
|
||||
|
||||
|
||||
class DefaultStager(AsyncStager):
|
||||
"""
|
||||
DefaultStager provides a full-featured staging implementation that combines
|
||||
multiple optimization techniques for efficient checkpoint preparation.
|
||||
|
||||
The staging process works as follows:
|
||||
1. State dictionary is submitted for staging (sync or async)
|
||||
2. Tensors are copied from GPU to optimized CPU storage
|
||||
3. CUDA operations are synchronized if non-blocking copies are used
|
||||
4. Staged state dictionary is returned or made available via Future
|
||||
|
||||
Usage Patterns:
|
||||
# Synchronous staging
|
||||
stager = DefaultStager(StagingOptions(use_async_staging=False))
|
||||
staged_dict = stager.stage(state_dict)
|
||||
stager.close()
|
||||
|
||||
# Asynchronous staging
|
||||
stager = DefaultStager(StagingOptions(use_async_staging=True))
|
||||
future = stager.stage(state_dict)
|
||||
# ... do other work ...
|
||||
staged_dict = future.result()
|
||||
stager.close()
|
||||
|
||||
# Context manager pattern (recommended)
|
||||
stager = DefaultStager(config)
|
||||
with stager:
|
||||
result = stager.stage(state_dict)
|
||||
|
||||
Performance Considerations:
|
||||
- Async staging provides best performance when model computation
|
||||
can overlap with staging operations
|
||||
- Pinned memory improves CPU-GPU transfer speeds but uses more memory
|
||||
- Shared memory allows efficient IPC to checkpoint process
|
||||
- Non-blocking copies reduce GPU idle time during memory transfers
|
||||
|
||||
Thread Safety:
|
||||
DefaultStager is not thread-safe. Each thread should use its own
|
||||
instance, or external synchronization should be provided.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: StagingOptions = StagingOptions(),
|
||||
):
|
||||
self._config = config
|
||||
self._state_dict_stager = StateDictStager(
|
||||
pin_memory=config.use_pinned_memory, share_memory=config.use_shared_memory
|
||||
)
|
||||
self._staging_executor = None
|
||||
self._staging_stream = None
|
||||
if self._config.use_async_staging:
|
||||
self._staging_executor = ThreadPoolExecutor(max_workers=1)
|
||||
if torch.accelerator.is_available():
|
||||
# Note: stream needs to be initialized on the main thread after default cuda
|
||||
# stream is setup/used to avoid the risk of accidentally reusing the main
|
||||
# compute stream or in other cases kernels actually launching from the
|
||||
# main thread.
|
||||
self._staging_stream = torch.Stream()
|
||||
|
||||
if self._config.use_non_blocking_copy:
|
||||
if not torch.accelerator.is_available():
|
||||
raise AssertionError(
|
||||
"Non-blocking copy requires that the current accelerator is available."
|
||||
)
|
||||
|
||||
self._staging_future: Future[STATE_DICT_TYPE] | None = None
|
||||
|
||||
def stage(
|
||||
self,
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
**kwargs: Any,
|
||||
) -> STATE_DICT_TYPE | Future[STATE_DICT_TYPE]:
|
||||
"""
|
||||
This function is responsible for staging staging the state_dict.
|
||||
See class docstring for more details on staging.
|
||||
If use_async_staging is True, it will return a Future object that will be
|
||||
fulfilled when staging is complete.
|
||||
If use_async_staging is False, it will return the fully staged state_dict.
|
||||
|
||||
Args:
|
||||
state_dict (STATE_DICT_TYPE): The state_dict to be staged.
|
||||
"""
|
||||
if self._config.use_async_staging:
|
||||
if self._staging_executor is None:
|
||||
raise AssertionError(
|
||||
"staging_executor should not be None for async staging"
|
||||
)
|
||||
self._staging_future = self._staging_executor.submit(
|
||||
self._stage,
|
||||
state_dict,
|
||||
**kwargs,
|
||||
)
|
||||
return self._staging_future
|
||||
else:
|
||||
return self._stage(state_dict, **kwargs)
|
||||
|
||||
def _stage(self, state_dict: STATE_DICT_TYPE, **kwargs: Any) -> STATE_DICT_TYPE:
|
||||
if self._config.use_non_blocking_copy:
|
||||
if not (self._staging_stream or not self._config.use_async_staging):
|
||||
raise AssertionError(
|
||||
"Non-blocking copy in a background thread for async staging needs staging_stream to be initialized."
|
||||
)
|
||||
with (
|
||||
self._staging_stream
|
||||
if self._staging_stream is not None
|
||||
else nullcontext()
|
||||
):
|
||||
state_dict = self._state_dict_stager.stage(
|
||||
state_dict, non_blocking=self._config.use_non_blocking_copy
|
||||
)
|
||||
# waits for the enqued copy operations to finish.
|
||||
self._staging_stream.synchronize() if self._staging_stream else torch.accelerator.synchronize()
|
||||
else:
|
||||
state_dict = self._state_dict_stager.stage(state_dict, non_blocking=False)
|
||||
|
||||
# release reference cycle to prevent memory leaks in async_save
|
||||
# created by _deepcopy_dispatch that capture self
|
||||
self._state_dict_stager.close()
|
||||
|
||||
return state_dict
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Clean up all resources used by the DefaultStager. Shuts down the ThreadPoolExecutor
|
||||
used for async staging operations and cleans up the underlying StateDictStager's
|
||||
cached storages. Should be called when the stager is no longer needed to prevent
|
||||
resource leaks, especially in long-running applications. After calling close(),
|
||||
the stager should not be used for further staging operations.
|
||||
|
||||
Example Usage:
|
||||
stager = DefaultStager(StagingOptions(use_async_staging=True))
|
||||
future = stager.stage(state_dict)
|
||||
result = future.result()
|
||||
stager.close() # Clean up all resources
|
||||
"""
|
||||
if self._staging_executor:
|
||||
self._staging_executor.shutdown(wait=True)
|
||||
self._state_dict_stager.close()
|
||||
|
||||
def synchronize_staging(self) -> None:
|
||||
"""
|
||||
When use_async_staging is True, this method will wait until staging is complete.
|
||||
If use_async_staging is False, this method is a no-op.
|
||||
"""
|
||||
if self._staging_future is not None:
|
||||
self._staging_future.result()
|
||||
|
||||
|
||||
class BlockingAsyncStager(AsyncStager):
|
||||
"""
|
||||
An implementation of AsyncStager which stages the state_dict on CPU RAM and blocks until the copy is complete.
|
||||
This implementation also provides an option to optimize stage latency using pinned memory.
|
||||
|
||||
N.B. synchronize_staging is a no-op in this case.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# default to True since the common case is to stage synchronously
|
||||
_synchronize_after_execute: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_staged_state_dict: bool = False,
|
||||
type_check: bool = False,
|
||||
):
|
||||
"""
|
||||
Initializes the BlockingAsyncStager.
|
||||
|
||||
Args:
|
||||
cache_staged_state_dict: Whether to cache the staged state_dict. This option decreases staging latency
|
||||
at the cost of increases memory usage. Additionally, if this parameter is set to True, it's the expectation
|
||||
that the stager is maintained and reused for multiple dcp.async_save calls. Default to False.
|
||||
type_check: Whether to perform a type check during cpu_offload. Defaults to False.
|
||||
|
||||
"""
|
||||
self.cache_staged_state_dict = cache_staged_state_dict
|
||||
self.type_check = type_check
|
||||
self.state_dict_cache: STATE_DICT_TYPE | None = None
|
||||
|
||||
def stage(self, state_dict: STATE_DICT_TYPE) -> STATE_DICT_TYPE:
|
||||
"""
|
||||
Returns a copy of `state_dict` on the CPU.
|
||||
"""
|
||||
|
||||
if not self.cache_staged_state_dict:
|
||||
staged_state_dict = _create_cpu_state_dict(state_dict)
|
||||
_copy_state_dict(state_dict, staged_state_dict, type_check=self.type_check)
|
||||
return staged_state_dict
|
||||
|
||||
if self.state_dict_cache is None:
|
||||
self.state_dict_cache = _create_cpu_state_dict(state_dict, pin_memory=True)
|
||||
return _copy_state_dict(state_dict, self.state_dict_cache)
|
||||
|
||||
def synchronize_staging(self) -> None:
|
||||
"""
|
||||
No-op function, since staging is blocking.
|
||||
"""
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _ReplicationStager(AsyncStager):
|
||||
"""
|
||||
An AsyncStager implementation that replicates state_dict across training ranks
|
||||
using PGTransport.
|
||||
|
||||
Args:
|
||||
pg: ProcessGroup for distributed communication
|
||||
timeout: Timeout for communication operations
|
||||
device: Device to use for tensor operations
|
||||
storage_dir: Directory to store persisted state_dicts
|
||||
|
||||
Warning: This is experimental and subject to change.
|
||||
"""
|
||||
|
||||
_synchronize_after_execute: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pg: ProcessGroup,
|
||||
timeout: timedelta = timedelta(minutes=30),
|
||||
device: torch.device = torch.device("cpu"),
|
||||
storage_dir: str | None = None,
|
||||
):
|
||||
self._pg = pg
|
||||
self._timeout = timeout
|
||||
self._device = device
|
||||
self._transport = PGTransport(pg, timeout, device, None)
|
||||
|
||||
# Set up storage directory for persisting exchanged state_dicts
|
||||
if storage_dir is None:
|
||||
self._storage_dir = tempfile.mkdtemp(prefix="replication_stager_")
|
||||
else:
|
||||
self._storage_dir = storage_dir
|
||||
os.makedirs(self._storage_dir, exist_ok=True)
|
||||
|
||||
def stage(
|
||||
self, state_dict: STATE_DICT_TYPE
|
||||
) -> Future[STATE_DICT_TYPE] | STATE_DICT_TYPE:
|
||||
"""
|
||||
Stage the state_dict by replicating it across ranks. Returns a state_dict representing
|
||||
the received replica.
|
||||
|
||||
Perform the actual replication logic. Creates bidirectional pairs where each rank exchanges
|
||||
state_dict with its partner at (rank + world_size//2) % world_size.
|
||||
Uses simple rank-based ordering to prevent deadlocks.
|
||||
|
||||
Assumes world_size is always even.
|
||||
"""
|
||||
if not dist.is_initialized():
|
||||
return state_dict
|
||||
|
||||
world_size = dist.get_world_size()
|
||||
|
||||
current_rank = dist.get_rank()
|
||||
|
||||
# Calculate partner rank using half-world offset
|
||||
# creates bidirectional pairs for replication.
|
||||
offset = world_size // 2
|
||||
partner_rank = (current_rank + offset) % world_size
|
||||
|
||||
# Use simple rank-based ordering to prevent deadlocks.
|
||||
# Lower-numbered rank sends first, higher-numbered rank receives first.
|
||||
if current_rank < partner_rank:
|
||||
# Send first, then receive
|
||||
self._transport.send_checkpoint([partner_rank], state_dict)
|
||||
received_state_dict = self._transport.recv_checkpoint(partner_rank)
|
||||
else:
|
||||
# Receive first, then send
|
||||
received_state_dict = self._transport.recv_checkpoint(partner_rank)
|
||||
self._transport.send_checkpoint([partner_rank], state_dict)
|
||||
|
||||
# Persist the received state_dict for future discoverability
|
||||
received_state_dict = cast(STATE_DICT_TYPE, received_state_dict)
|
||||
self._persist_state_dict(received_state_dict, current_rank, partner_rank)
|
||||
|
||||
return received_state_dict
|
||||
|
||||
def _persist_state_dict(
|
||||
self, state_dict: STATE_DICT_TYPE, current_rank: int, partner_rank: int
|
||||
) -> None:
|
||||
"""
|
||||
Persist the received state_dict to disk for future discoverability.
|
||||
Only keeps one replica per rank, overwriting any previous replica.
|
||||
Uses atomic write pattern (temp file + rename).
|
||||
|
||||
Args:
|
||||
state_dict: The state_dict received from partner rank
|
||||
current_rank: Current rank that received the state_dict
|
||||
partner_rank: Rank that sent the state_dict
|
||||
"""
|
||||
final_path = self._get_persisted_path(current_rank, partner_rank)
|
||||
temp_path = final_path + ".tmp"
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists and is writable
|
||||
os.makedirs(os.path.dirname(final_path), exist_ok=True)
|
||||
|
||||
# Write to temporary file with explicit flushing
|
||||
with open(temp_path, "wb") as f:
|
||||
torch.save(state_dict, f)
|
||||
# Flush application buffers to OS buffers
|
||||
f.flush()
|
||||
# Force OS buffers to disk for durability
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# Atomic rename to final location
|
||||
os.rename(temp_path, final_path)
|
||||
except Exception as e:
|
||||
# Clean up temp file if it exists
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
# Re-raise the original exception with more context
|
||||
raise RuntimeError(
|
||||
f"Failed to persist state_dict from rank {partner_rank} to rank {current_rank}: {e}"
|
||||
) from e
|
||||
|
||||
def _get_persisted_path(self, current_rank: int, partner_rank: int) -> str:
|
||||
"""
|
||||
Get the file path where a state_dict would be persisted.
|
||||
|
||||
Args:
|
||||
current_rank: Current rank
|
||||
|
||||
Returns:
|
||||
File path for the persisted state_dict
|
||||
"""
|
||||
filename = f"rank_{current_rank}_replica_partner_{partner_rank}.pt"
|
||||
return os.path.join(self._storage_dir, filename)
|
||||
|
||||
def synchronize_staging(self) -> None:
|
||||
"""
|
||||
No-op function, since staging is blocking.
|
||||
"""
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Clean up resources. Persisted files are intentionally left for future discovery.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
+401
@@ -0,0 +1,401 @@
|
||||
# mypy: allow-untyped-decorators
|
||||
# mypy: allow-untyped-defs
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from typing_extensions import deprecated
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.checkpoint.default_planner import _EmptyStateDictLoadPlanner
|
||||
from torch.distributed.checkpoint.logger import _dcp_method_logger
|
||||
from torch.distributed.checkpoint.stateful import Stateful
|
||||
|
||||
from ._storage_utils import _storage_setup
|
||||
from .default_planner import DefaultLoadPlanner
|
||||
from .planner import LoadPlan, LoadPlanner
|
||||
from .storage import StorageReader
|
||||
from .utils import _api_bc_check, _DistWrapper, _profile
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed.checkpoint.metadata import Metadata
|
||||
|
||||
__all__ = ["load_state_dict", "load"]
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`load_state_dict` is deprecated and will be removed in future versions. "
|
||||
"Please use `load` instead.",
|
||||
category=FutureWarning,
|
||||
)
|
||||
def load_state_dict(
|
||||
state_dict: dict[str, Any],
|
||||
storage_reader: StorageReader,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
no_dist: bool = False,
|
||||
planner: LoadPlanner | None = None,
|
||||
) -> None:
|
||||
"""This method is deprecated. Please switch to 'load'."""
|
||||
storage_reader.reset()
|
||||
with _profile():
|
||||
# TODO: test returning `load` here instead.
|
||||
return _load_state_dict(
|
||||
state_dict,
|
||||
storage_reader,
|
||||
process_group,
|
||||
coordinator_rank,
|
||||
no_dist,
|
||||
planner,
|
||||
)
|
||||
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True)
|
||||
@_api_bc_check
|
||||
def load(
|
||||
state_dict: dict[str, Any],
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_reader: StorageReader | None = None,
|
||||
planner: LoadPlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Load a checkpoint into a distributed state dict in SPMD style.
|
||||
|
||||
Each rank must have the same keys in their ``state_dict`` provided to this
|
||||
API. Mismatched keys may result in hangs or errors. If unsure, you can use
|
||||
the ``utils._assert_same_keys`` API to check (but may incur communication
|
||||
costs).
|
||||
|
||||
Each rank will try to read the least amount of data necessary
|
||||
to fulfill the requested `state_dict`. When loading :class:`ShardedTensor`
|
||||
or :class:`DTensor` instances, each rank only reads data for their local shards.
|
||||
|
||||
For each ``Stateful`` object (having both a ``state_dict`` and a ``load_state_dict``),
|
||||
load will first call ``state_dict`` before attempting deserialization, followed by
|
||||
``load_state_dict`` once the deserialization is complete.
|
||||
For each non-``Stateful`` object, load will deserialize the object, and then replace
|
||||
it in the ``state_dict`` with the deserialized object.
|
||||
|
||||
.. warning::
|
||||
All tensors in ``state_dict`` must be allocated on their
|
||||
destination device *prior to* calling this function.
|
||||
|
||||
All non-tensor data is loaded using `torch.load()` and modified in place
|
||||
on state_dict.
|
||||
|
||||
.. warning::
|
||||
Users must call `load_state_dict` on the root module to ensure load
|
||||
pos-processing and non-tensor data properly propagates.
|
||||
|
||||
.. note:
|
||||
If no process group is initialized, this function will assume the intent
|
||||
is to load a checkpoint into the local process. This can be useful in the
|
||||
case of local inference, and when using regular Tensors (as opposed to DTensor
|
||||
or ShardedTensor)
|
||||
|
||||
.. note:
|
||||
Rank 0 is assumed to be the coordinator rank.
|
||||
|
||||
Args:
|
||||
state_dict (Dict[str, Any]): The state_dict to load the checkpoint into.
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is a key-value store.
|
||||
(Default: ``None``)
|
||||
storage_reader (Optional[StorageReader]):
|
||||
Instance of StorageWriter used to perform reads. If this is not
|
||||
specified, DCP will automatically infer the reader based on the
|
||||
checkpoint_id. If checkpoint_id is also None, an exception will
|
||||
be raised. (Default: ``None``)
|
||||
planner (Optional[LoadPlanner]):
|
||||
Instance of LoadPlanner. If this is not specified, the default
|
||||
planner will be used. (Default: ``None``)
|
||||
process_group (Optional[ProcessGroup]):
|
||||
ProcessGroup to be used for cross-rank synchronization.
|
||||
(Default: ``None``)
|
||||
no_dist (bool): If ``True``, this function will assume the intent is to load
|
||||
a checkpoint without using cross-rank synchronization. (Default: ``False``)
|
||||
Returns:
|
||||
None.
|
||||
|
||||
Examples
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> my_model = MyModule()
|
||||
>>> optimizer = Adagrad(my_model.parameters())
|
||||
>>> model_state_dict = my_model.state_dict()
|
||||
>>> fs_storage_reader = torch.distributed.checkpoint.FileSystemReader(
|
||||
... "/checkpoint/1"
|
||||
... )
|
||||
|
||||
>>> torch.distributed.checkpoint.load_state_dict(
|
||||
>>> state_dict=model_state_dict,
|
||||
>>> storage_reader=fs_storage_reader,
|
||||
>>> )
|
||||
|
||||
>>> # module.load_state_dict() function might have customized steps
|
||||
>>> # to flush the state_dict, must call it to
|
||||
>>> # ensure correct behavior.
|
||||
>>> my_model.load_state_dict(model_state_dict)
|
||||
|
||||
.. note::
|
||||
load_state_dict uses collectives to coordinate reads across ranks.
|
||||
For NCCL-based process groups, internal tensor representations of
|
||||
objects must be moved to the GPU device before communication takes place.
|
||||
In this case, the device used is given by ``torch.cuda.current_device()``
|
||||
and it is the user's responsibility to ensure that this is set so that each
|
||||
rank has an individual GPU, via ``torch.cuda.set_device()``.
|
||||
"""
|
||||
|
||||
no_dist = no_dist or (not dist.is_available()) or (not dist.is_initialized())
|
||||
if no_dist:
|
||||
warnings.warn(
|
||||
"torch.distributed is disabled, unavailable or uninitialized, assuming the intent is to load in a single process.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
with _profile():
|
||||
storage_reader = cast(
|
||||
StorageReader, _storage_setup(storage_reader, checkpoint_id, reader=True)
|
||||
)
|
||||
|
||||
# All ranks must have the same keys in their `state_dict` provided to
|
||||
# this API. See documentation for more details.
|
||||
# Here we simply sort the keys to ensure that all ranks load values in
|
||||
# the same order.
|
||||
keys = sorted(state_dict.keys())
|
||||
|
||||
stateful_sd = {}
|
||||
for key in keys:
|
||||
if key not in state_dict:
|
||||
continue
|
||||
elem = state_dict[key]
|
||||
stateful_sd[key] = elem.state_dict() if isinstance(elem, Stateful) else elem
|
||||
|
||||
_load_state_dict(
|
||||
state_dict=stateful_sd,
|
||||
storage_reader=storage_reader,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
planner=planner,
|
||||
)
|
||||
for key in keys:
|
||||
if key not in state_dict:
|
||||
continue
|
||||
elem = state_dict[key]
|
||||
if isinstance(elem, Stateful):
|
||||
# If the state_dict is a Stateful object,
|
||||
# DCP does an in-place load in the original state dict.
|
||||
elem.load_state_dict(stateful_sd[key])
|
||||
else:
|
||||
# Otherwise, replace the state_dict with the loaded state_dict.
|
||||
state_dict[key] = stateful_sd[key]
|
||||
|
||||
|
||||
def _load_state_dict(
|
||||
state_dict: dict[str, Any],
|
||||
storage_reader: StorageReader,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
no_dist: bool = False,
|
||||
planner: LoadPlanner | None = None,
|
||||
) -> None:
|
||||
torch._C._log_api_usage_once("torch.distributed.checkpoint.load_state_dict")
|
||||
|
||||
distW = _DistWrapper(process_group, not no_dist, coordinator_rank)
|
||||
if planner is None:
|
||||
planner = DefaultLoadPlanner()
|
||||
|
||||
ckpt_kwargs = {}
|
||||
if (ckpt_id := getattr(storage_reader, "checkpoint_id", None)) is not None:
|
||||
ckpt_kwargs["checkpoint_id"] = ckpt_id
|
||||
ckpt_kwargs["process_group"] = distW.group
|
||||
|
||||
use_collectives = True
|
||||
metadata: Metadata | None = None
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def local_step():
|
||||
nonlocal use_collectives
|
||||
nonlocal metadata
|
||||
|
||||
# Use global metadata if available, otherwise fallback to rank local metadata
|
||||
global_metadata_exc: Exception | None = None
|
||||
rank_metadata_exc: Exception | None = None
|
||||
try:
|
||||
metadata = storage_reader.read_metadata()
|
||||
except Exception as e:
|
||||
global_metadata_exc = e
|
||||
logger.warning(
|
||||
"Global metadata is not found. Falling back to rank local metadata.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if (
|
||||
not metadata
|
||||
and "kwargs" in inspect.signature(storage_reader.read_metadata).parameters
|
||||
):
|
||||
try:
|
||||
metadata = storage_reader.read_metadata(rank=distW.rank) # noqa: F841
|
||||
use_collectives = False
|
||||
except Exception as e:
|
||||
rank_metadata_exc = e
|
||||
logger.warning("Rank local metadata is not found.", exc_info=True)
|
||||
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
if metadata is None:
|
||||
error_parts = ["metadata is None"]
|
||||
if global_metadata_exc is not None:
|
||||
error_parts.append(
|
||||
f"global metadata read failed: {global_metadata_exc}"
|
||||
)
|
||||
if rank_metadata_exc is not None:
|
||||
error_parts.append(
|
||||
f"rank local metadata read failed: {rank_metadata_exc}"
|
||||
)
|
||||
raise AssertionError("; ".join(error_parts))
|
||||
planner.set_up_planner(state_dict, metadata, distW.is_coordinator)
|
||||
|
||||
if (
|
||||
"kwargs"
|
||||
in inspect.signature(storage_reader.set_up_storage_reader).parameters
|
||||
):
|
||||
storage_reader.set_up_storage_reader(
|
||||
metadata,
|
||||
distW.is_coordinator,
|
||||
rank=distW.rank,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
else:
|
||||
storage_reader.set_up_storage_reader(metadata, distW.is_coordinator)
|
||||
|
||||
local_plan = planner.create_local_plan()
|
||||
local_plan = storage_reader.prepare_local_plan(local_plan)
|
||||
return local_plan
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def global_step(all_local_plans):
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
all_local_plans = planner.create_global_plan(all_local_plans)
|
||||
all_local_plans = storage_reader.prepare_global_plan(all_local_plans)
|
||||
return all_local_plans
|
||||
|
||||
central_plan: LoadPlan | None = None
|
||||
if use_collectives:
|
||||
central_plan = distW.reduce_scatter("plan", local_step, global_step)
|
||||
else:
|
||||
local_plan: LoadPlan = local_step()
|
||||
global_plan: list[LoadPlan] = global_step([local_plan])
|
||||
central_plan = global_plan[0]
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def read_data():
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
if central_plan is None:
|
||||
raise AssertionError("central_plan is None")
|
||||
final_local_plan = planner.finish_plan(central_plan)
|
||||
all_reads = storage_reader.read_data(final_local_plan, planner)
|
||||
|
||||
all_reads.wait()
|
||||
return None
|
||||
|
||||
if use_collectives:
|
||||
_ = distW.all_gather("read", read_data)
|
||||
else:
|
||||
read_data()
|
||||
distW.barrier()
|
||||
|
||||
|
||||
def _load_state_dict_from_keys(
|
||||
keys: set[str] | str | None = None,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_reader: StorageReader | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Load only the specified keys from the checkpoint, if no keys are specified, the entire
|
||||
checkpoint will be loaded. Note, this method completely loads the checkpoint into the
|
||||
current process and is not distributed.
|
||||
|
||||
.. warning::
|
||||
|
||||
|
||||
.. warning::
|
||||
|
||||
All non-tensor data is loaded using `torch.load()`
|
||||
|
||||
.. note:
|
||||
As opposed to the usual pattern, this function does not take a state dict as input
|
||||
and does not load inplace. Instead, a new state dict is directly initialized and read
|
||||
from file.
|
||||
|
||||
.. note:
|
||||
If no process group is initialized, this function will assume the intent
|
||||
is to load a checkpoint into the local process. This can be useful in the
|
||||
case of local inference, and when using regular Tensors (as opposed to DTensor
|
||||
or ShardedTensor)
|
||||
|
||||
.. note:
|
||||
Rank 0 is assumed to be the coordinator rank.
|
||||
|
||||
Args:
|
||||
keys (Optional[Union[set[str], str]]):
|
||||
Loads any key specified in this set. If no keys are specified, the entire checkpoint
|
||||
is loaded.
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is a key-value store.
|
||||
(Default: ``None``)
|
||||
storage_reader (Optional[StorageReader]):
|
||||
Instance of StorageWriter used to perform reads. If this is not
|
||||
specified, DCP will automatically infer the reader based on the
|
||||
checkpoint_id. If checkpoint_id is also None, an exception will
|
||||
be raised. (Default: ``None``)
|
||||
process_group (Optional[ProcessGroup]):
|
||||
ProcessGroup to be used for cross-rank synchronization.
|
||||
(Default: ``None``)
|
||||
|
||||
Returns:
|
||||
State dict from specified keys
|
||||
"""
|
||||
torch._C._log_api_usage_once(
|
||||
"torch.distributed.checkpoint._load_state_dict_from_keys"
|
||||
)
|
||||
|
||||
no_dist = not (dist.is_available() and dist.is_initialized())
|
||||
if no_dist:
|
||||
warnings.warn(
|
||||
"torch.distributed is unavailable or uninitialized, assuming the intent is to load in a single process.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
storage_reader = cast(
|
||||
StorageReader, _storage_setup(storage_reader, checkpoint_id, reader=True)
|
||||
)
|
||||
|
||||
if isinstance(keys, str):
|
||||
keys = {keys}
|
||||
|
||||
sd: dict[str, Any] = {}
|
||||
_load_state_dict(
|
||||
state_dict=sd,
|
||||
storage_reader=storage_reader,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
planner=_EmptyStateDictLoadPlanner(keys=keys),
|
||||
)
|
||||
|
||||
return sd
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
# mypy: allow-untyped-decorators
|
||||
# mypy: allow-untyped-defs
|
||||
import inspect
|
||||
import os
|
||||
import warnings
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from typing_extensions import deprecated
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed._state_dict_utils import STATE_DICT_TYPE
|
||||
from torch.distributed.checkpoint._async_process_executor import (
|
||||
_ProcessBasedAsyncCheckpointExecutor,
|
||||
)
|
||||
from torch.distributed.checkpoint._async_thread_executor import (
|
||||
_ThreadBasedAsyncCheckpointExecutor,
|
||||
)
|
||||
from torch.distributed.checkpoint._storage_utils import _storage_setup
|
||||
from torch.distributed.checkpoint.default_planner import DefaultSavePlanner
|
||||
from torch.distributed.checkpoint.logger import _dcp_method_logger
|
||||
from torch.distributed.checkpoint.metadata import Metadata
|
||||
from torch.distributed.checkpoint.planner import SavePlan, SavePlanner
|
||||
from torch.distributed.checkpoint.staging import (
|
||||
AsyncStager,
|
||||
DefaultStager,
|
||||
StagingOptions,
|
||||
)
|
||||
from torch.distributed.checkpoint.stateful import Stateful
|
||||
from torch.distributed.checkpoint.storage import StorageWriter, WriteResult
|
||||
from torch.distributed.distributed_c10d import _get_default_group
|
||||
|
||||
from .utils import _api_bc_check, _DistWrapper, _profile
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed.checkpoint._async_executor import _AsyncCheckpointExecutor
|
||||
|
||||
|
||||
__all__ = [
|
||||
"save_state_dict",
|
||||
"save",
|
||||
"async_save",
|
||||
"AsyncCheckpointerType",
|
||||
"AsyncSaveResponse",
|
||||
]
|
||||
|
||||
|
||||
class AsyncCheckpointerType(Enum):
|
||||
"""Enum for async checkpointer type."""
|
||||
|
||||
THREAD = "thread"
|
||||
PROCESS = "process"
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`save_state_dict` is deprecated and will be removed in future versions."
|
||||
"Please use `save` instead.",
|
||||
category=FutureWarning,
|
||||
)
|
||||
def save_state_dict(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
storage_writer: StorageWriter,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
no_dist: bool = False,
|
||||
planner: SavePlanner | None = None,
|
||||
) -> Metadata:
|
||||
"""This method is deprecated. Please switch to 'save'."""
|
||||
storage_writer.reset()
|
||||
|
||||
# TODO: test returning `save` here instead.
|
||||
with _profile():
|
||||
return _save_state_dict(
|
||||
state_dict,
|
||||
storage_writer,
|
||||
process_group,
|
||||
coordinator_rank,
|
||||
no_dist,
|
||||
planner,
|
||||
)
|
||||
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True) # type: ignore[arg-type]
|
||||
@_api_bc_check
|
||||
def save(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Metadata:
|
||||
"""
|
||||
Save a distributed model in SPMD style.
|
||||
|
||||
This function is different from ``torch.save()`` as it handles
|
||||
``ShardedTensor`` , and ``DTensor`` by having each rank only save their local shards.
|
||||
|
||||
For each ``Stateful`` object (having both a ``state_dict`` and a ``load_state_dict``),
|
||||
save will call ``state_dict`` before serialization.
|
||||
|
||||
.. warning::
|
||||
There is no guarantees of Backwards Compatibility across PyTorch versions
|
||||
for saved state_dicts.
|
||||
|
||||
.. warning::
|
||||
If using the `process_group` argument, make sure that only its ranks
|
||||
call `save_state_dict` and that all data in state_dict belong to it.
|
||||
|
||||
.. note::
|
||||
When saving checkpoint for FSDP's `ShardingStrategy.HYBRID_SHARD`, only one of
|
||||
the shard_group should be calling `save_state_dict` and the corresponding process
|
||||
group needs to be passed in.
|
||||
|
||||
.. note::
|
||||
If no process group is available, this function assumes the intention is to save the
|
||||
state_dict in the local process.
|
||||
|
||||
.. note:
|
||||
Rank 0 is assumed to be the coordinator rank.
|
||||
|
||||
|
||||
Args:
|
||||
state_dict (Dict[str, Any]): The state_dict to save.
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is a key-value store.
|
||||
(Default: ``None``)
|
||||
storage_writer (Optional[StorageWriter]):
|
||||
Instance of StorageWriter used to perform writes. If this is not
|
||||
specified, DCP will automatically infer the writer based on the
|
||||
checkpoint_id. If checkpoint_id is also None, an exception will
|
||||
be raised. (Default: ``None``)
|
||||
planner (Optional[SavePlanner]):
|
||||
Instance of SavePlanner. If this is not specified, the default
|
||||
planner will be used. (Default: ``None``)
|
||||
process_group (Optional[ProcessGroup]):
|
||||
ProcessGroup to be used for cross-rank synchronization.
|
||||
(Default: ``None``)
|
||||
no_dist (bool):
|
||||
If ``True``, this function will assume the intent is to load
|
||||
a checkpoint on a single rank/process.
|
||||
(Default: ``False``)
|
||||
use_collectives (bool): If ``False``, this function will assume the intent is to save
|
||||
a checkpoint without using cross-rank synchronization.
|
||||
(Default: ``True``)
|
||||
This configuration is experimental and should be used with caution.
|
||||
It will change the format of the saved checkpoint and may not be backward compatible.
|
||||
|
||||
Returns:
|
||||
Metadata: Metadata object for the saved checkpoint.
|
||||
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> my_model = MyModule()
|
||||
|
||||
>>> state_dict = {"model": my_model}
|
||||
|
||||
>>> fs_storage_writer = torch.distributed.checkpoint.FileSystemWriter(
|
||||
... "/checkpoint/1"
|
||||
... )
|
||||
>>> torch.distributed.checkpoint.save(
|
||||
>>> state_dict=state_dict,
|
||||
>>> storage_writer=fs_storage_writer,
|
||||
>>> )
|
||||
|
||||
.. note::
|
||||
save_state_dict uses collectives to coordinate writes across ranks.
|
||||
For NCCL-based process groups, internal tensor representations of
|
||||
objects must be moved to the GPU device before communication takes place.
|
||||
In this case, the device used is given by ``torch.cuda.current_device()``
|
||||
and it is the user's responsibility to ensure that this is set so that
|
||||
each rank has an individual GPU, via ``torch.cuda.set_device()``.
|
||||
"""
|
||||
torch._C._log_api_usage_once("torch.distributed.checkpoint.save")
|
||||
|
||||
no_dist = no_dist or (not dist.is_available()) or (not dist.is_initialized())
|
||||
if no_dist:
|
||||
warnings.warn(
|
||||
"torch.distributed is disabled, unavailable or uninitialized, assuming the intent is to save in a single process.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
with _profile():
|
||||
storage_writer = cast(
|
||||
StorageWriter, _storage_setup(storage_writer, checkpoint_id, reader=False)
|
||||
)
|
||||
|
||||
return _save_state_dict(
|
||||
state_dict=_stateful_to_state_dict(state_dict),
|
||||
storage_writer=storage_writer,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
planner=planner,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AsyncSaveResponse:
|
||||
"""This class contains futures for staging and upload completion.
|
||||
It is returned by async_save().
|
||||
staging_completion is a future that indicates when local copy
|
||||
of state_dict is complete.
|
||||
upload_completion is a future that indicates when a checkpoint
|
||||
completed saving.
|
||||
"""
|
||||
|
||||
staging_completion: Future[None]
|
||||
upload_completion: Future[None]
|
||||
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True)
|
||||
def async_save(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
*,
|
||||
checkpoint_id: str | os.PathLike | None = None,
|
||||
storage_writer: StorageWriter | None = None,
|
||||
planner: SavePlanner | None = None,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
async_checkpointer_type: AsyncCheckpointerType = AsyncCheckpointerType.THREAD,
|
||||
async_stager: AsyncStager | None = None,
|
||||
no_dist: bool = False,
|
||||
use_collectives: bool = True,
|
||||
) -> Future | AsyncSaveResponse:
|
||||
"""Asynchronous version of ``save``. This code first de-stages the state_dict on to the
|
||||
staging storage (defaults to CPU memory), and then calls the `save` in a separate thread.
|
||||
|
||||
.. warning::
|
||||
This feature is experimental and subject to change.
|
||||
MUST CALL CLOSE AFTER LAST CHECKPOINT IS SAVED
|
||||
|
||||
Args:
|
||||
state_dict (Dict[str, Any]): The state_dict to save.
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is a key-value store.
|
||||
(Default: ``None``)
|
||||
storage_writer (Optional[StorageWriter]):
|
||||
Instance of StorageWriter used to perform 'stage' and 'save'. If
|
||||
this is not specified, DCP will automatically infer the writer based on the
|
||||
checkpoint_id. If checkpoint_id is also None, an exception will
|
||||
be raised. (Default: ``None``)
|
||||
planner (Optional[SavePlanner]):
|
||||
Instance of SavePlanner. If this is not specified, the default
|
||||
planner will be used. (Default: ``None``)
|
||||
process_group (Optional[ProcessGroup]):
|
||||
ProcessGroup to be used for cross-rank synchronization.
|
||||
(Default: ``None``)
|
||||
async_checkpointer_type (AsyncCheckpointerType):
|
||||
whether to do checkpoint in separate thread or process
|
||||
(Default: ``AsyncCheckpointerType.THREAD``)
|
||||
async_stager (AsyncStager):
|
||||
provides staging implementation. If storage_writer implements AsyncStager
|
||||
and async_stager is provided, async_stager will be used for staging
|
||||
no_dist (bool):
|
||||
If ``True``, this function will assume the intent is to save
|
||||
a checkpoint on a single rank/process.
|
||||
(Default: ``False``)
|
||||
use_collectives: If False, Save the checkpoint without rank coordination. (Default: ``True``)
|
||||
This configuration is experimental and should be used with caution.
|
||||
It will change the format of the saved checkpoint and may not be backward compatible.
|
||||
|
||||
Returns:
|
||||
Future: A future holding the resultant Metadata object from `save`.
|
||||
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> my_model = MyModule()
|
||||
|
||||
>>> state_dict = {"model": my_model}
|
||||
|
||||
>>> fs_storage_writer = torch.distributed.checkpoint.FileSystemWriter(
|
||||
... "/checkpoint/1"
|
||||
... )
|
||||
>>> checkpoint_future = torch.distributed.checkpoint.async_save(
|
||||
>>> state_dict=state_dict,
|
||||
>>> storage_writer=fs_storage_writer,
|
||||
>>> )
|
||||
>>>
|
||||
>>> # ... do some work ...
|
||||
>>>
|
||||
>>> checkpoint_future.result()
|
||||
|
||||
"""
|
||||
torch._C._log_api_usage_once("torch.distributed.checkpoint.async_save")
|
||||
|
||||
if dist.is_available() and dist.is_initialized():
|
||||
pg = process_group or _get_default_group()
|
||||
if torch.device("cpu") not in pg._device_types:
|
||||
raise AssertionError(
|
||||
"A CPU backend must be enabled for async save; try initializing process group with 'cpu:gloo,cuda:nccl'"
|
||||
)
|
||||
|
||||
if async_stager is None:
|
||||
if storage_writer is not None and isinstance(storage_writer, AsyncStager):
|
||||
# bwc with old storage_writers
|
||||
async_stager = storage_writer
|
||||
else:
|
||||
async_stager = DefaultStager(
|
||||
StagingOptions(
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
)
|
||||
|
||||
state_dict = _stateful_to_state_dict(state_dict)
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True)
|
||||
def stage_state_dict() -> Future[STATE_DICT_TYPE] | STATE_DICT_TYPE:
|
||||
return async_stager.stage(state_dict)
|
||||
|
||||
staging_future_or_state_dict = stage_state_dict()
|
||||
|
||||
upload_executor: _AsyncCheckpointExecutor = (
|
||||
_ProcessBasedAsyncCheckpointExecutor()
|
||||
if async_checkpointer_type == AsyncCheckpointerType.PROCESS
|
||||
else _ThreadBasedAsyncCheckpointExecutor()
|
||||
)
|
||||
|
||||
upload_future: Future = upload_executor.execute_save(
|
||||
staging_future_or_state_dict,
|
||||
checkpoint_id=checkpoint_id,
|
||||
storage_writer=storage_writer,
|
||||
planner=planner,
|
||||
process_group=process_group,
|
||||
no_dist=no_dist,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
|
||||
if isinstance(staging_future_or_state_dict, Future):
|
||||
staging_future = staging_future_or_state_dict
|
||||
return_staging_future: Future[None] = Future()
|
||||
|
||||
def callback(
|
||||
original_staging_future: Future[STATE_DICT_TYPE],
|
||||
return_staging_future: Future[None] = return_staging_future,
|
||||
):
|
||||
try:
|
||||
original_staging_future.result()
|
||||
return_staging_future.set_result(None)
|
||||
except Exception as e:
|
||||
return_staging_future.set_exception(e)
|
||||
|
||||
if not staging_future.done():
|
||||
staging_future.add_done_callback(callback)
|
||||
else:
|
||||
return_staging_future.set_result(None)
|
||||
|
||||
# return new AsyncSaveResponse for users using new ZOC implementation
|
||||
return AsyncSaveResponse(
|
||||
staging_completion=return_staging_future, upload_completion=upload_future
|
||||
)
|
||||
else:
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True)
|
||||
def maybe_synchronize_staging():
|
||||
if async_stager.should_synchronize_after_execute:
|
||||
async_stager.synchronize_staging()
|
||||
|
||||
maybe_synchronize_staging()
|
||||
return upload_future
|
||||
|
||||
|
||||
@_dcp_method_logger(log_exceptions=True)
|
||||
def _stateful_to_state_dict(state_dict: STATE_DICT_TYPE) -> STATE_DICT_TYPE:
|
||||
"""Creates a shallow copy of `state_dict` where `state_dict` is called for each Stateful object."""
|
||||
stateful_state_dict = {}
|
||||
for key, elem in state_dict.items():
|
||||
# Apply _dcp_method_logger to each state_dict() call
|
||||
def _elem_to_state_dict(elem):
|
||||
return elem.state_dict() if isinstance(elem, Stateful) else elem
|
||||
|
||||
_elem_to_state_dict.__name__ = f"_stateful_to_state_dict.{key}"
|
||||
|
||||
stateful_state_dict[key] = _dcp_method_logger(log_exceptions=True)(
|
||||
_elem_to_state_dict
|
||||
)(elem)
|
||||
return stateful_state_dict
|
||||
|
||||
|
||||
def _save_state_dict(
|
||||
state_dict: STATE_DICT_TYPE,
|
||||
storage_writer: StorageWriter,
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
coordinator_rank: int = 0,
|
||||
no_dist: bool = False,
|
||||
planner: SavePlanner | None = None,
|
||||
use_collectives: bool = True,
|
||||
) -> Metadata:
|
||||
torch._C._log_api_usage_once("torch.distributed.checkpoint.save_state_dict")
|
||||
|
||||
distW = _DistWrapper(process_group, not no_dist, coordinator_rank)
|
||||
if planner is None:
|
||||
planner = DefaultSavePlanner()
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
|
||||
global_metadata = None
|
||||
|
||||
ckpt_kwargs = {}
|
||||
if (ckpt_id := getattr(storage_writer, "checkpoint_id", None)) is not None:
|
||||
ckpt_kwargs["checkpoint_id"] = ckpt_id
|
||||
ckpt_kwargs["process_group"] = distW.group
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def local_step():
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
storage_meta = storage_writer.storage_meta()
|
||||
if "storage_meta" not in inspect.signature(planner.set_up_planner).parameters:
|
||||
warnings.warn(
|
||||
"The function definition for SavePlanner.set_up_planner has been updated"
|
||||
" to include the storage_meta argument. Please update your implementation"
|
||||
" to include this parameter.",
|
||||
stacklevel=2,
|
||||
)
|
||||
planner.set_up_planner(state_dict, distW.is_coordinator) # type: ignore[call-arg, arg-type]
|
||||
else:
|
||||
planner.set_up_planner(
|
||||
state_dict=state_dict,
|
||||
storage_meta=storage_meta,
|
||||
is_coordinator=distW.is_coordinator,
|
||||
)
|
||||
|
||||
if (
|
||||
"kwargs"
|
||||
in inspect.signature(storage_writer.set_up_storage_writer).parameters
|
||||
):
|
||||
storage_writer.set_up_storage_writer(
|
||||
distW.is_coordinator,
|
||||
rank=distW.rank,
|
||||
use_collectives=use_collectives,
|
||||
)
|
||||
else:
|
||||
storage_writer.set_up_storage_writer(distW.is_coordinator)
|
||||
|
||||
local_plan = planner.create_local_plan()
|
||||
local_plan = storage_writer.prepare_local_plan(local_plan)
|
||||
return local_plan
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def global_step(all_local_plans):
|
||||
nonlocal global_metadata
|
||||
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
all_local_plans, global_metadata = planner.create_global_plan(all_local_plans)
|
||||
all_local_plans = storage_writer.prepare_global_plan(all_local_plans)
|
||||
return all_local_plans
|
||||
|
||||
central_plan: SavePlan | None = None
|
||||
if use_collectives:
|
||||
central_plan = distW.reduce_scatter("plan", local_step, global_step)
|
||||
else:
|
||||
local_plan: SavePlan = local_step()
|
||||
global_plan: list[SavePlan] = global_step([local_plan])
|
||||
central_plan = global_plan[0]
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def write_data():
|
||||
if planner is None:
|
||||
raise AssertionError("planner is None")
|
||||
if central_plan is None:
|
||||
raise AssertionError("central_plan is None")
|
||||
final_local_plan = planner.finish_plan(central_plan)
|
||||
all_writes = storage_writer.write_data(final_local_plan, planner)
|
||||
|
||||
all_writes.wait()
|
||||
return all_writes.value()
|
||||
|
||||
@_dcp_method_logger(**ckpt_kwargs)
|
||||
def finish_checkpoint(all_results):
|
||||
if global_metadata is None:
|
||||
raise AssertionError("global_metadata is None")
|
||||
storage_writer.finish(metadata=global_metadata, results=all_results)
|
||||
return global_metadata
|
||||
|
||||
if use_collectives:
|
||||
metadata = distW.all_reduce("write", write_data, finish_checkpoint)
|
||||
else:
|
||||
write_results: list[WriteResult] = write_data()
|
||||
metadata = finish_checkpoint([write_results])
|
||||
distW.barrier()
|
||||
|
||||
return metadata
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Any, TypeVar
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
|
||||
|
||||
__all__ = ["Stateful", "StatefulT"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Stateful(Protocol):
|
||||
"""
|
||||
Stateful protocol for objects that can be checkpointed and restored.
|
||||
"""
|
||||
|
||||
def state_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Objects should return their state_dict representation as a dictionary.
|
||||
The output of this function will be checkpointed, and later restored in
|
||||
`load_state_dict()`.
|
||||
|
||||
.. warning::
|
||||
Because of the inplace nature of restoring a checkpoint, this function
|
||||
is also called during `torch.distributed.checkpoint.load`.
|
||||
|
||||
|
||||
Returns:
|
||||
Dict: The objects state dict
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
|
||||
"""
|
||||
Restore the object's state from the provided state_dict.
|
||||
|
||||
Args:
|
||||
state_dict: The state dict to restore from
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
StatefulT = TypeVar("StatefulT", bound=Stateful)
|
||||
@@ -0,0 +1,288 @@
|
||||
import abc
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from torch.distributed.checkpoint.metadata import Metadata, MetadataIndex, StorageMeta
|
||||
from torch.distributed.checkpoint.planner import (
|
||||
LoadPlan,
|
||||
LoadPlanner,
|
||||
SavePlan,
|
||||
SavePlanner,
|
||||
)
|
||||
from torch.futures import Future
|
||||
|
||||
|
||||
__all__ = ["WriteResult", "StorageWriter", "StorageReader"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteResult:
|
||||
index: MetadataIndex
|
||||
|
||||
size_in_bytes: int
|
||||
storage_data: Any
|
||||
|
||||
|
||||
class StorageWriter(abc.ABC):
|
||||
"""
|
||||
Interface used by ``save_state_dict`` to write to storage.
|
||||
|
||||
One StorageWriter instance acts as both the coordinator and the follower
|
||||
in a distributed checkpoint. As part of initialization, each instance
|
||||
is told its role.
|
||||
|
||||
A subclass should expect the following sequence of calls.
|
||||
|
||||
0) (all ranks) set checkpoint_id if users pass a valid checkpoint_id.
|
||||
1) (all ranks) set_up_storage_writer()
|
||||
2) (all ranks) prepare_local_plan()
|
||||
3) (coordinator) prepare_global_plan()
|
||||
4) (all ranks) write_data()
|
||||
5) (coordinator) finish()
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def reset(self, checkpoint_id: str | os.PathLike | None = None) -> None:
|
||||
"""
|
||||
Calls to indicates a brand new checkpoint write is going to happen.
|
||||
A checkpoint_id may be present if users set the checkpoint_id for
|
||||
this checkpoint write. The meaning of the checkpiont_id is
|
||||
storage-dependent. It can be a path to a folder/file or a key for
|
||||
a key-value storage.
|
||||
|
||||
Args:
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is a key-value store.
|
||||
(Default: ``None``)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_up_storage_writer(
|
||||
self, is_coordinator: bool, *args: Any, **kwargs: Any
|
||||
) -> None:
|
||||
"""
|
||||
Initialize this instance.
|
||||
|
||||
Args:
|
||||
is_coordinator (bool): Whether this instance is responsible for coordinating
|
||||
the checkpoint.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def prepare_local_plan(self, plan: SavePlan) -> SavePlan:
|
||||
"""
|
||||
Perform storage-specific local planning.
|
||||
|
||||
While this method can produce a completely different plan, the recommended
|
||||
way is to store storage specific data in SavePlan::storage_data.
|
||||
|
||||
Args:
|
||||
plan (SavePlan): The local plan from the ``SavePlanner`` in use.
|
||||
|
||||
Returns:
|
||||
A transformed ``SavePlan`` after storage local planning
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def prepare_global_plan(self, plans: list[SavePlan]) -> list[SavePlan]:
|
||||
"""
|
||||
Perform centralized planning of storage.
|
||||
|
||||
This method is only called on the coordinator instance.
|
||||
|
||||
While this method can produce a completely different plan, the preferred
|
||||
way is to store storage specific data in SavePlan::storage_data.
|
||||
|
||||
Args:
|
||||
plans: A list of ``SavePlan`` instances, one for each rank.
|
||||
|
||||
Returns:
|
||||
A list of transformed ``SavePlan`` after storage global planning
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def write_data(
|
||||
self, plan: SavePlan, planner: SavePlanner
|
||||
) -> Future[list[WriteResult]]:
|
||||
"""
|
||||
Write all items from ``plan`` using ``planner`` to resolve the data.
|
||||
|
||||
A subclass should call ``SavePlanner::resolve_data`` on each item
|
||||
from the plan to get access to the underlying object to write.
|
||||
|
||||
Subclasses should lazily call `resolve_data` as it can allocate memory.
|
||||
In case of tensors, make following assumptions:
|
||||
|
||||
- They might be on any device, including not matching the one on ``WriteItem::tensor_data``
|
||||
- They might be views or not contiguous. Only the projection needs to be saved.
|
||||
|
||||
Args:
|
||||
plan (SavePlan): The save plan to execute.
|
||||
planner (SavePlanner): Planner object to be used to resolve items to data.
|
||||
|
||||
Returns:
|
||||
A future that completes to a list of WriteResult
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def finish(self, metadata: Metadata, results: list[list[WriteResult]]) -> None:
|
||||
"""
|
||||
Write the metadata and marks the current checkpoint as successful.
|
||||
|
||||
The actual format/schema used for serializing `metadata` is an
|
||||
implementation detail. The only requirement is that it's recoverable
|
||||
in to the same object graph.
|
||||
|
||||
Args:
|
||||
metadata (Metadata): metadata for the new checkpoint
|
||||
results: A list of WriteResults from all ranks.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
"""
|
||||
Check if the given checkpoint_id is supported by the storage. This allow
|
||||
us to enable automatic storage selection.
|
||||
"""
|
||||
...
|
||||
|
||||
def storage_meta(self) -> StorageMeta | None:
|
||||
"""
|
||||
Return the storage-specific metadata. This is used to store additional information
|
||||
in a checkpoint that can be useful for providing request-level observability. StorageMeta
|
||||
is passed to the ``SavePlanner`` during save calls. Returns None by default.
|
||||
|
||||
TODO: provide an example
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class StorageReader(abc.ABC):
|
||||
"""
|
||||
Interface used by ``load_state_dict`` to read from storage.
|
||||
|
||||
One StorageReader instance acts as both the coordinator and the follower
|
||||
in a distributed checkpoint. As part of initialization, each instance
|
||||
is told its role.
|
||||
|
||||
A subclass should expected the following sequence of calls by ``load_state_dict``:
|
||||
|
||||
0) (all ranks) set checkpoint_id if users pass a valid checkpoint_id.
|
||||
1) (all ranks) read_metadata()
|
||||
2) (all ranks) set_up_storage_reader()
|
||||
3) (all ranks) prepare_local_plan()
|
||||
4) (coordinator) prepare_global_plan()
|
||||
5) (all ranks) read_data()
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def reset(self, checkpoint_id: str | os.PathLike | None = None) -> None:
|
||||
"""
|
||||
Calls to indicates a brand new checkpoint read is going to happen.
|
||||
A checkpoint_id may be present if users set the checkpoint_id for
|
||||
this checkpoint read. The meaning of the checkpiont_id is
|
||||
storage-dependent. It can be a path to a folder/file or a key for
|
||||
a key-value storage.
|
||||
|
||||
Args:
|
||||
checkpoint_id (Union[str, os.PathLike, None]):
|
||||
The ID of this checkpoint instance. The meaning of the checkpoint_id
|
||||
depends on the storage. It can be a path to a folder or to a file.
|
||||
It can also be a key if the storage is more like a key-value store.
|
||||
(Default: ``None``)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def read_metadata(self, *args: Any, **kwargs: Any) -> Metadata:
|
||||
"""
|
||||
Read the checkpoint metadata.
|
||||
|
||||
Returns:
|
||||
The metadata object associated with the checkpoint being loaded.
|
||||
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_up_storage_reader(
|
||||
self, metadata: Metadata, is_coordinator: bool, *args: Any, **kwargs: Any
|
||||
) -> None:
|
||||
"""
|
||||
Initialize this instance.
|
||||
|
||||
Args:
|
||||
metadata (Metadata): The metadata schema to use.
|
||||
is_coordinator (bool): Whether this instance is responsible for coordinating
|
||||
the checkpoint.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def prepare_local_plan(self, plan: LoadPlan) -> LoadPlan:
|
||||
"""
|
||||
Perform storage-specific local planning.
|
||||
|
||||
While this method can produce a completely different plan, the recommended
|
||||
way is to store storage specific data in LoadPlan::storage_data.
|
||||
|
||||
Args:
|
||||
plan (LoadPlan): The local plan from the ``LoadPlan`` in use.
|
||||
|
||||
Returns:
|
||||
A transformed ``LoadPlan`` after storage local planning
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def prepare_global_plan(self, plans: list[LoadPlan]) -> list[LoadPlan]:
|
||||
"""
|
||||
Perform centralized planning of storage loading.
|
||||
|
||||
This method is only called on the coordinator instance.
|
||||
|
||||
While this method can produce a completely different plan, the preferred
|
||||
way is to store storage specific data in LoadPlan::storage_data.
|
||||
|
||||
Args:
|
||||
plans: A list of ``LoadPlan`` instances, one for each rank.
|
||||
|
||||
Returns:
|
||||
A list of transformed ``LoadPlan`` after storage global planning
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def read_data(self, plan: LoadPlan, planner: LoadPlanner) -> Future[None]:
|
||||
"""
|
||||
Read all items from ``plan`` using ``planner`` to resolve the data.
|
||||
|
||||
A subclass should call ``LoadPlanner::load_bytes`` to deserialize a BytesIO
|
||||
object into the right place.
|
||||
|
||||
A subclass should call ``LoadPlanner::resolve_tensor`` to get access to the
|
||||
tensors that in should load data into.
|
||||
|
||||
It's the StorageLayer responsibility to properly schedule any cross device copies
|
||||
required.
|
||||
|
||||
Args:
|
||||
plan (LoadPlan): The local plan to execute on
|
||||
planner (LoadPlanner): The planner object to use to resolve items.
|
||||
|
||||
Returns:
|
||||
A future that completes once all reads are finished.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def validate_checkpoint_id(cls, checkpoint_id: str | os.PathLike) -> bool:
|
||||
"""
|
||||
Check if the given checkpoint_id is supported by the storage. This allow
|
||||
us to enable automatic storage selection.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,487 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import cProfile
|
||||
import inspect
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from pstats import Stats
|
||||
from typing import Any, cast, TypeVar
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed._shard.sharded_tensor import ShardedTensor
|
||||
from torch.distributed._shard.sharded_tensor.shard import Shard
|
||||
|
||||
from .api import (
|
||||
_is_wrapped_exception,
|
||||
_wrap_exception,
|
||||
CheckpointException,
|
||||
WRAPPED_EXCEPTION,
|
||||
)
|
||||
from .metadata import MetadataIndex, STATE_DICT_TYPE
|
||||
|
||||
|
||||
__all__ = ["find_tensor_shard", "find_state_dict_object"]
|
||||
|
||||
T = TypeVar("T")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def _get_failure_dict(
|
||||
results: list[T | WRAPPED_EXCEPTION],
|
||||
) -> dict[int, WRAPPED_EXCEPTION]:
|
||||
return cast(
|
||||
dict[int, WRAPPED_EXCEPTION],
|
||||
{i: err for i, err in enumerate(results) if _is_wrapped_exception(err)},
|
||||
)
|
||||
|
||||
|
||||
def _all_gather_keys(
|
||||
local_dict: dict[str, Any], group: dist.ProcessGroup | None = None
|
||||
) -> set[str]:
|
||||
"""Gathers all keys, and returns them sorted."""
|
||||
keys = list(local_dict.keys())
|
||||
gathered_keys: list[list[str]] = [None] * dist.get_world_size(group) # type: ignore[list-item]
|
||||
|
||||
dist.all_gather_object(gathered_keys, keys, group=group)
|
||||
return set(itertools.chain.from_iterable(gathered_keys))
|
||||
|
||||
|
||||
def _assert_same_keys(
|
||||
state_dict: dict[str, Any], process_group: dist.ProcessGroup | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Asserts that all ranks have the same keys in their state dict.
|
||||
This is a collective call which requires all ranks in ``process_group`` to
|
||||
join. It will also induce cross-rank communication and block CPU.
|
||||
"""
|
||||
|
||||
if dist.get_world_size(process_group) == 1:
|
||||
return
|
||||
|
||||
all_keys = _all_gather_keys(state_dict, process_group)
|
||||
my_keys = set(state_dict.keys())
|
||||
diff = all_keys - my_keys
|
||||
if len(diff) > 0:
|
||||
raise AssertionError(
|
||||
f"Key(s) present in other ranks but not this one, difference: {diff}"
|
||||
)
|
||||
|
||||
|
||||
class _DistWrapper:
|
||||
"""
|
||||
This is a wrapper around PG that provides a series of features around object collectives.
|
||||
|
||||
It works without distributed initialized, where most collectives turns into nops.
|
||||
|
||||
All variants that take functions are exception robust, meaning that if one or more
|
||||
ranks raise errors, all ranks will observe those.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: dist.ProcessGroup | None,
|
||||
use_dist: bool,
|
||||
coordinator_rank: int,
|
||||
):
|
||||
self.group = group
|
||||
self.use_dist = use_dist
|
||||
self.coordinator_rank = coordinator_rank
|
||||
if self.use_dist:
|
||||
self.global_coordinator_rank = (
|
||||
dist.get_global_rank(group, coordinator_rank)
|
||||
if group is not None
|
||||
else coordinator_rank
|
||||
)
|
||||
self.rank = dist.get_rank(group)
|
||||
self.is_coordinator = self.rank == coordinator_rank
|
||||
else:
|
||||
self.global_coordinator_rank = 0
|
||||
self.rank = 0
|
||||
self.is_coordinator = True
|
||||
|
||||
def get_rank(self) -> int:
|
||||
return self.rank
|
||||
|
||||
def get_world_size(self) -> int:
|
||||
if self.use_dist:
|
||||
return dist.get_world_size(self.group)
|
||||
return 1
|
||||
|
||||
def broadcast_object(self, object: T | None) -> T:
|
||||
"""Implement functionality similar to c10d::broadcast_object_list but without distributed enabled."""
|
||||
object_list = [object]
|
||||
if self.use_dist:
|
||||
dist.broadcast_object_list(
|
||||
object_list=object_list,
|
||||
group=self.group,
|
||||
src=self.global_coordinator_rank,
|
||||
)
|
||||
return cast(T, object_list[0])
|
||||
|
||||
def gather_object(self, object: T) -> list[T] | None:
|
||||
"""Implement functionality similar to c10d::gather_object but without distributed enabled."""
|
||||
if self.use_dist:
|
||||
gather_objs = (
|
||||
cast(list[T], [None] * dist.get_world_size(self.group))
|
||||
if self.is_coordinator
|
||||
else None
|
||||
)
|
||||
|
||||
dist.gather_object(
|
||||
obj=object,
|
||||
object_gather_list=gather_objs if self.is_coordinator else None,
|
||||
dst=self.global_coordinator_rank,
|
||||
group=self.group,
|
||||
)
|
||||
result = gather_objs
|
||||
else:
|
||||
result = [object]
|
||||
return result
|
||||
|
||||
def all_gather_object(self, object: T) -> list[T]:
|
||||
"""Implement functionality similar to c10d::all_gather_object but without distributed enabled."""
|
||||
if self.use_dist:
|
||||
gather_objs = cast(list[T], [None] * dist.get_world_size(self.group))
|
||||
|
||||
dist.all_gather_object(
|
||||
object_list=gather_objs, obj=object, group=self.group
|
||||
)
|
||||
else:
|
||||
gather_objs = [object]
|
||||
return gather_objs
|
||||
|
||||
def scatter_object(self, object_list: list[T] | None) -> T:
|
||||
"""Implement functionality similar to c10d::scatter_object but without distributed enabled."""
|
||||
if self.use_dist:
|
||||
gather_result = cast(list[T], [None])
|
||||
dist.scatter_object_list(
|
||||
scatter_object_output_list=gather_result,
|
||||
scatter_object_input_list=object_list if self.is_coordinator else None,
|
||||
src=self.global_coordinator_rank,
|
||||
group=self.group,
|
||||
)
|
||||
|
||||
local_reply = gather_result[0]
|
||||
else:
|
||||
if object_list is None:
|
||||
raise AssertionError("object_list is None")
|
||||
local_reply = object_list[0]
|
||||
return local_reply
|
||||
|
||||
def reduce_scatter(
|
||||
self,
|
||||
step: str,
|
||||
map_fun: Callable[[], T],
|
||||
reduce_fun: Callable[[list[T]], list[R]],
|
||||
) -> R:
|
||||
"""
|
||||
Compute a value on each rank, then do centralized reduce on a single rank, followed by a scatter.
|
||||
|
||||
This method operates in the following way:
|
||||
Run ``map_fun`` on all ranks
|
||||
Gather results on rank 0
|
||||
Call ``reduce_fun`` on all those values
|
||||
Scatter to each rank part of the result.
|
||||
"""
|
||||
local_data: WRAPPED_EXCEPTION | T
|
||||
try:
|
||||
local_data = map_fun()
|
||||
except BaseException as e: # noqa: B036
|
||||
local_data = _wrap_exception(e)
|
||||
|
||||
all_data = self.gather_object(local_data)
|
||||
all_results: list[R | CheckpointException] | None = None
|
||||
if self.is_coordinator:
|
||||
if all_data is None:
|
||||
raise AssertionError("all_data is None")
|
||||
node_failures = _get_failure_dict(all_data)
|
||||
|
||||
if len(node_failures) == 0:
|
||||
try:
|
||||
# N.B. why can't mypy cast List[R] to List[Union[R, WRAPPED_EXCEPTION]]?
|
||||
all_results = cast(
|
||||
list[R | CheckpointException],
|
||||
reduce_fun(cast(list[T], all_data)),
|
||||
)
|
||||
except BaseException as e: # noqa: B036
|
||||
node_failures[self.rank] = _wrap_exception(e)
|
||||
|
||||
if len(node_failures) > 0:
|
||||
all_results = [
|
||||
CheckpointException(step, node_failures)
|
||||
] * self.get_world_size()
|
||||
|
||||
result = self.scatter_object(all_results)
|
||||
if isinstance(result, CheckpointException):
|
||||
raise result
|
||||
return result
|
||||
|
||||
def all_reduce(
|
||||
self,
|
||||
step: str,
|
||||
map_fun: Callable[[], T],
|
||||
reduce_fun: Callable[[list[T]], R],
|
||||
) -> R:
|
||||
"""
|
||||
Compute a value on each rank, then do centralized reduce on a single rank, followed by a broadcast.
|
||||
|
||||
This method operates in the following way:
|
||||
Run ``map_fun`` on all ranks
|
||||
Gather results on rank 0
|
||||
Call ``reduce_fun`` on all those values
|
||||
Broadcast the reduced value to all ranks.
|
||||
"""
|
||||
local_data: T | WRAPPED_EXCEPTION
|
||||
try:
|
||||
local_data = map_fun()
|
||||
except BaseException as e: # noqa: B036
|
||||
local_data = _wrap_exception(e)
|
||||
|
||||
all_data = self.gather_object(local_data)
|
||||
result: R | CheckpointException | None = None
|
||||
if self.is_coordinator:
|
||||
if all_data is None:
|
||||
raise AssertionError("all_data is None")
|
||||
node_failures = _get_failure_dict(all_data)
|
||||
if len(node_failures) == 0:
|
||||
try:
|
||||
result = reduce_fun(cast(list[T], all_data))
|
||||
except BaseException as e: # noqa: B036
|
||||
node_failures[self.rank] = _wrap_exception(e)
|
||||
|
||||
if len(node_failures) > 0:
|
||||
result = CheckpointException(step, node_failures)
|
||||
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
final_result = self.broadcast_object(result)
|
||||
if isinstance(final_result, CheckpointException):
|
||||
raise final_result
|
||||
# pyrefly: ignore [redundant-cast]
|
||||
return cast(R, final_result)
|
||||
|
||||
def all_gather(
|
||||
self,
|
||||
step: str,
|
||||
map_fun: Callable[[], T],
|
||||
) -> list[T]:
|
||||
"""
|
||||
Compute a value on each rank, then all_gather them.
|
||||
|
||||
This method operates in the following way:
|
||||
Run ``map_cp`` on all ranks
|
||||
all_gather the values to all ranks
|
||||
"""
|
||||
result: T | WRAPPED_EXCEPTION
|
||||
try:
|
||||
result = map_fun()
|
||||
except BaseException as e: # noqa: B036
|
||||
result = _wrap_exception(e)
|
||||
|
||||
all_results = self.all_gather_object(result)
|
||||
|
||||
node_failures = _get_failure_dict(all_results)
|
||||
if len(node_failures) > 0:
|
||||
raise CheckpointException(step, node_failures)
|
||||
return cast(list[T], all_results)
|
||||
|
||||
def broadcast(
|
||||
self,
|
||||
step: str,
|
||||
map_fun: Callable[[], T],
|
||||
) -> T:
|
||||
"""
|
||||
Compute a value on rank 0 and broadcast it.
|
||||
|
||||
This method operates in the following way:
|
||||
Run ``map_cp`` on rank 0
|
||||
broadcast the value
|
||||
"""
|
||||
result: T | CheckpointException | None = None
|
||||
if self.is_coordinator:
|
||||
try:
|
||||
result = map_fun()
|
||||
except BaseException as e: # noqa: B036
|
||||
result = CheckpointException(step, {self.rank: _wrap_exception(e)})
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
final_result = self.broadcast_object(result)
|
||||
if isinstance(final_result, CheckpointException):
|
||||
raise final_result
|
||||
# pyrefly: ignore [redundant-cast]
|
||||
return cast(T, final_result)
|
||||
|
||||
def barrier(self) -> None:
|
||||
"""
|
||||
Add a synchronization point across all processes when using distributed.
|
||||
If torch.distributed is initialized, this function will invoke a barrier across the global process group.
|
||||
If torch.distributed is not initialized, this function is a no-op.
|
||||
"""
|
||||
if not self.use_dist:
|
||||
return
|
||||
dist.barrier(group=self.group)
|
||||
|
||||
|
||||
def _find_shard(tensor: ShardedTensor, index: MetadataIndex) -> Shard:
|
||||
if index.offset is None:
|
||||
raise ValueError(
|
||||
f"Cannot lookup {index.fqn} since its a ShardedTensor and no offset was provided"
|
||||
)
|
||||
|
||||
shards = tensor.local_shards()
|
||||
# index fast path
|
||||
if index.index is not None:
|
||||
if (
|
||||
len(shards) > index.index
|
||||
and torch.Size(shards[index.index].metadata.shard_offsets) == index.offset
|
||||
):
|
||||
return shards[index.index]
|
||||
|
||||
for shard in shards:
|
||||
if torch.Size(shard.metadata.shard_offsets) == index.offset:
|
||||
return shard
|
||||
raise ValueError(f"Could not find shard at '{index.offset}' for FQN: '{index.fqn}'")
|
||||
|
||||
|
||||
def find_tensor_shard(tensor: torch.Tensor, index: MetadataIndex) -> torch.Tensor:
|
||||
if hasattr(tensor, "__get_tensor_shard__"):
|
||||
# DTensor implements _Checkpointable
|
||||
return tensor.__get_tensor_shard__(index) # type: ignore[attr-defined]
|
||||
if isinstance(tensor, ShardedTensor):
|
||||
return _find_shard(tensor, index).tensor
|
||||
if index.offset is not None:
|
||||
# special case looking up a tensor by origin
|
||||
if index.offset == torch.Size([0] * len(tensor.size())):
|
||||
return tensor
|
||||
raise ValueError(
|
||||
f"FQN: '{index.fqn}' is not a ShardedTensor, can't find by offset: '{index.offset}'"
|
||||
)
|
||||
return tensor
|
||||
|
||||
|
||||
def find_state_dict_object(state_dict: STATE_DICT_TYPE, index: MetadataIndex) -> Any:
|
||||
if index.fqn not in state_dict:
|
||||
raise ValueError(f"Could not find FQN: '{index.fqn}'")
|
||||
obj = state_dict[index.fqn]
|
||||
|
||||
if isinstance(obj, torch.Tensor):
|
||||
return find_tensor_shard(obj, index)
|
||||
elif index.offset is not None:
|
||||
raise ValueError(
|
||||
f"FQN: '{index.fqn}' is not a ShardedTensor, can't find by offset: '{index.offset}'"
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def _element_wise_add(a: Sequence[int], b: Sequence[int]) -> list[int]:
|
||||
return [i_a + i_b for i_a, i_b in zip(a, b)]
|
||||
|
||||
|
||||
def _element_wise_sub(a: Sequence[int], b: Sequence[int]) -> list[int]:
|
||||
return [i_a - i_b for i_a, i_b in zip(a, b)]
|
||||
|
||||
|
||||
class _ReaderView(io.IOBase):
|
||||
def __init__(self, base_stream: io.IOBase, offset: int, len: int):
|
||||
super().__init__()
|
||||
self.offset = offset
|
||||
self.len = len
|
||||
self.base_stream = base_stream
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, offset: int, whence: int = os.SEEK_SET, /) -> int:
|
||||
if whence == os.SEEK_SET:
|
||||
offset = self.offset + offset
|
||||
elif whence == os.SEEK_END:
|
||||
whence = os.SEEK_SET
|
||||
offset = (self.offset + self.len) - offset
|
||||
return self.base_stream.seek(offset, whence)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.base_stream.tell() - self.offset
|
||||
|
||||
def readable(self) -> bool:
|
||||
return self.base_stream.readable()
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return self.base_stream.seekable()
|
||||
|
||||
def readinto(self, b):
|
||||
max_size = self.len - self.tell()
|
||||
if max_size == 0:
|
||||
return 0
|
||||
if len(b) > max_size:
|
||||
b = memoryview(b)[:max_size]
|
||||
return self.base_stream.readinto(b) # type: ignore[attr-defined]
|
||||
|
||||
def read(self, size=-1):
|
||||
max_size = self.len - self.tell()
|
||||
if size == -1 or size > max_size:
|
||||
size = max_size
|
||||
return self.base_stream.read(size)
|
||||
|
||||
|
||||
def _create_file_view(file: io.IOBase, offset: int, length: int) -> io.IOBase:
|
||||
# FIXME (kumpera) torch.load fails if we wrap with io.BufferedReader
|
||||
return _ReaderView(file, offset, length)
|
||||
|
||||
|
||||
def _normalize_device_info(device_type: str, device_id: int) -> str:
|
||||
"""Device info normalization."""
|
||||
if device_type == "cpu":
|
||||
return "cpu"
|
||||
return f"{device_type}:{device_id}"
|
||||
|
||||
|
||||
# TODO: integrate with distributed logging flag
|
||||
ENABLE_PROFILE = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _profile():
|
||||
# Only log the profiling when it is enable and is on rank0 or dist is not
|
||||
# available.
|
||||
if ENABLE_PROFILE and (not dist.is_available() or dist.get_rank() == 0):
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
profiler.disable()
|
||||
stats = Stats(profiler)
|
||||
stats.sort_stats("time").print_stats(10)
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
def _api_bc_check(func):
|
||||
@wraps(func)
|
||||
def inner_func(*args, **kwargs) -> Any:
|
||||
if len(args) == 2:
|
||||
warnings.warn(
|
||||
f"The argument order of {func.__name__} has been changed. "
|
||||
"Please check the document to avoid future breakages.",
|
||||
stacklevel=2,
|
||||
)
|
||||
sig = inspect.signature(func)
|
||||
kwonlyargs = [
|
||||
p.name for p in sig.parameters.values() if p.kind == p.KEYWORD_ONLY
|
||||
]
|
||||
if "storage_writer" in kwonlyargs:
|
||||
if "storage_writer" in kwargs:
|
||||
raise AssertionError(f"storage_writer in kwargs: {(args, kwargs)}")
|
||||
kwargs["storage_writer"] = args[1]
|
||||
elif "storage_reader" in kwonlyargs:
|
||||
if "storage_reader" in kwargs:
|
||||
raise AssertionError(f"storage_reader in kwargs: {(args, kwargs)}")
|
||||
kwargs["storage_reader"] = args[1]
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected kwonlyargs = {kwonlyargs}")
|
||||
return func(args[0], **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return inner_func
|
||||
Reference in New Issue
Block a user