-
Notifications
You must be signed in to change notification settings - Fork 502
feat: dist_muon offloading in megatron #2739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1a3715a
f9bbfaa
f10775b
d18b353
651c271
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,14 +21,21 @@ | |
|
|
||
| Both directory arguments are checkpoint *bases*; the per-rank layout underneath is | ||
| this file's business, matching the layout of the live scratch directory. | ||
|
|
||
| Muon takes a different route. Its state already rides Megatron's | ||
| ``ChunkedOptimizerStateOffloader``, whose only tie to host memory is one allocator, so | ||
| ``setup_muon_state_on_disk`` swaps that allocator for file-backed tensors and leaves the | ||
| rest alone. Those buffers are unlinked once mapped, so they show up in ``df``, not ``du``. | ||
| """ | ||
|
|
||
| import atexit | ||
| import ctypes | ||
| import errno | ||
| import json | ||
| import logging | ||
| import os | ||
| import shutil | ||
| import tempfile | ||
| import time | ||
| from types import MethodType | ||
| from typing import TYPE_CHECKING, NamedTuple | ||
|
|
@@ -68,14 +75,23 @@ def _resize(tensor: torch.Tensor, numel: int) -> None: | |
| tensor.untyped_storage().resize_(numel * tensor.element_size()) | ||
|
|
||
|
|
||
| def _allocate_file(path: str, nbytes: int) -> int: | ||
| fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600) | ||
| def _reserve(fd: int, nbytes: int) -> None: | ||
| """Reserve blocks up front, so a full filesystem fails here as ENOSPC. | ||
|
|
||
| Sizing a file with ftruncate alone leaves it sparse: the mapping succeeds and the | ||
| process dies on SIGBUS at first touch instead, with nothing to point at. | ||
| """ | ||
| try: | ||
| os.posix_fallocate(fd, 0, nbytes) | ||
| except OSError as e: | ||
| if e.errno not in (errno.EOPNOTSUPP, errno.ENOTSUP, errno.EINVAL): | ||
| raise | ||
| os.ftruncate(fd, nbytes) | ||
|
|
||
|
|
||
| def _allocate_file(path: str, nbytes: int) -> int: | ||
| fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600) | ||
| _reserve(fd, nbytes) | ||
| return fd | ||
|
|
||
|
|
||
|
|
@@ -89,6 +105,43 @@ def _rw_full(op, fd: int, offset: int, buf) -> None: | |
| done += n | ||
|
|
||
|
|
||
| def _disk_backed_like(tensor: torch.Tensor, directory: str) -> torch.Tensor: | ||
| nbytes = max(tensor.numel() * tensor.element_size(), 1) | ||
| fd, path = tempfile.mkstemp(dir=directory, suffix=".bin") | ||
| try: | ||
| _reserve(fd, nbytes) | ||
| finally: | ||
| os.close(fd) | ||
| storage = torch.UntypedStorage.from_file(path, shared=True, nbytes=nbytes) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. codex + claude comment, looks reasonable UntypedStorage.from_file(..., shared=True) expands the file with ftruncate but does not reserve physical blocks. If optimizer state exceeds available NVMe capacity, mapping succeeds and later writes can terminate the process with SIGBUS instead of producing an actionable ENOSPC error. Preallocate with posix_fallocate, as the existing _allocate_file path already does, before mapping and unlinking the file. |
||
| os.unlink(path) | ||
| buffer = torch.empty(0, dtype=tensor.dtype).set_(storage, 0, tensor.shape) | ||
| buffer._miles_disk_backed = True | ||
| return buffer | ||
|
|
||
|
|
||
| def _is_disk_backed(tensor: torch.Tensor) -> bool: | ||
| return getattr(tensor, "_miles_disk_backed", False) | ||
|
|
||
|
|
||
| _MS_SYNC = 4 | ||
| _libc = ctypes.CDLL(None, use_errno=True) | ||
|
|
||
|
|
||
| def _flush_mapping(tensor: torch.Tensor) -> int: | ||
| """msync one file-backed buffer, returning the bytes it covered. | ||
|
|
||
| Checkpointing calls os.fsync on its own files, which waits on the kernel's writeback | ||
| queue -- and our mappings are rewritten every step, so that queue is carrying gigabytes | ||
| of our dirty pages by then. Flushing them here keeps that cost attributable and cheap | ||
| to repeat: msync over an already-clean mapping returns immediately. | ||
| """ | ||
| storage = tensor.untyped_storage() | ||
| nbytes = storage.nbytes() | ||
| if _libc.msync(ctypes.c_void_p(storage.data_ptr()), ctypes.c_size_t(nbytes), _MS_SYNC) != 0: | ||
| raise OSError(ctypes.get_errno(), "msync of optimizer state mapping failed") | ||
| return nbytes | ||
|
|
||
|
|
||
| def plan_buckets(entries_by_ddp_bucket: dict, limit: int = BUCKET_NUMEL_LIMIT) -> list[list[_Entry]]: | ||
| planned, current, numel = [], [], 0 | ||
| for _, entries in sorted(entries_by_ddp_bucket.items(), key=lambda kv: kv[0]): | ||
|
|
@@ -450,7 +503,7 @@ def setup_optimizer_state_streaming(args, optimizer) -> None: | |
| """ | ||
| from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer | ||
|
|
||
| dir_root = os.path.join(args.offload_train_disk_dir, "optimizer_state") | ||
| dir_root = _state_dir_root(args) | ||
| _purge_rank_dir(dir_root) | ||
| for dist_opt in optimizer.chained_optimizers: | ||
| assert isinstance( | ||
|
|
@@ -468,7 +521,57 @@ def setup_optimizer_state_streaming(args, optimizer) -> None: | |
| _bind(dist_opt, store) | ||
|
|
||
|
|
||
| def _purge_rank_dir(dir_root: str) -> None: | ||
| def setup_muon_state_on_disk(args) -> None: | ||
| """Back the chunked offloader's host buffers with files, for Muon's optimizer state. | ||
|
|
||
| Must run before the optimizer is built, which is when the offloader is constructed. | ||
| """ | ||
| from megatron.core.optimizer import optimizer as consuming_module | ||
| from megatron.core.optimizer.cpu_offloading import chunked_optimizer_state_offload as defining_module | ||
|
|
||
| base = defining_module.ChunkedOptimizerStateOffloader | ||
| if base.__name__ == "DiskOptimizerStateOffloader": | ||
| return | ||
| rank_dir = _purge_rank_dir(_state_dir_root(args)) | ||
|
|
||
| class DiskOptimizerStateOffloader(base): | ||
| state_dir = rank_dir | ||
| _disk_bytes = 0 | ||
|
|
||
| def _new_cpu_buffer(self, tensor: torch.Tensor) -> torch.Tensor: # type: ignore[override] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. codex comment, looks reasonable: Preserve existing mmap state during checkpoint adoption — miles_plugins/optimizers/nvme_stream.py:503 |
||
| # adopt_cpu_optimizer_state reallocates every non-pinned CPU tensor it finds in | ||
| # optimizer.state, and ours never report pinned, so each checkpoint would otherwise | ||
| # copy the whole state into fresh mappings. | ||
| if _is_disk_backed(tensor): | ||
| return tensor | ||
| buffer = _disk_backed_like(tensor, self.state_dir) | ||
| self._disk_bytes += buffer.numel() * buffer.element_size() | ||
| return buffer | ||
|
|
||
| def step(self) -> None: # type: ignore[override] | ||
| super().step() | ||
| logger.info(f"Muon disk state step: {self._disk_bytes / 1024**3:.2f} GB file-backed") | ||
|
|
||
| def synchronize_for_checkpoint(self) -> None: # type: ignore[override] | ||
| # After super(), because it offloads the master weights and so can add mappings. | ||
| super().synchronize_for_checkpoint() | ||
| flushed = 0 | ||
| for state in self._cpu_state.values(): | ||
| flushed += sum(_flush_mapping(t) for t in state.values() if _is_disk_backed(t)) | ||
| flushed += sum(_flush_mapping(t) for t in self._cpu_master.values() if _is_disk_backed(t)) | ||
| logger.info(f"Muon disk state flushed before checkpoint: {flushed / 1024**3:.2f} GB") | ||
|
|
||
| # optimizer.py imported the name directly, so rebinding only the defining module is a no-op. | ||
| defining_module.ChunkedOptimizerStateOffloader = DiskOptimizerStateOffloader | ||
| consuming_module.ChunkedOptimizerStateOffloader = DiskOptimizerStateOffloader | ||
| logger.info(f"Muon optimizer state on disk: buffers backed by files under {rank_dir}") | ||
|
|
||
|
|
||
| def _state_dir_root(args) -> str: | ||
| return os.path.join(args.offload_train_disk_dir, "optimizer_state") | ||
|
|
||
|
|
||
| def _purge_rank_dir(dir_root: str) -> str: | ||
| """Drop everything this rank left behind, before any store claims its own path. | ||
|
|
||
| A store only removes the exact path it is about to use, so state written under a | ||
|
|
@@ -482,6 +585,7 @@ def _purge_rank_dir(dir_root: str) -> None: | |
| rank_dir = os.path.join(dir_root, f"rank{torch.distributed.get_rank():05d}") | ||
| shutil.rmtree(rank_dir, ignore_errors=True) | ||
| os.makedirs(rank_dir, exist_ok=True) | ||
| return rank_dir | ||
|
|
||
|
|
||
| def _bind(dist_opt: "DistributedOptimizer", store: NVMeOptimizerStateStore) -> None: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we default
--optimizer-state-offload-chunk-size-mbto be a non-0 value, or add a warning? The current default is 0, and in megatron it seems 0 -> non-streaming, so the peak memory cannot be saved. The user might now be aware of thisThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Megatron has this warning already