mirror of https://github.com/razor-ai/soup.git
222 lines
7.0 KiB
Python
222 lines
7.0 KiB
Python
"""Activation offloading — move stored activations to CPU or disk.
|
|
|
|
During the backward pass, cached activations are the main VRAM cost. Offloading
|
|
them to RAM (CPU) or a scratch file (disk) trades IO/PCIe bandwidth for VRAM
|
|
headroom — useful for single-GPU large-batch training on small VRAM.
|
|
|
|
Implemented as a context manager that installs + removes the offload hooks
|
|
for the duration of ``trainer.train()``. The hooks wrap ``torch.utils.hooks``
|
|
/ saved-tensor hooks and move saved tensors to the target device.
|
|
|
|
Unlike DeepSpeed ZeRO offload (which is partitioning-aware), this is a
|
|
*per-tensor* offload that composes with any backend (DDP, FSDP, or single-GPU).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from types import ModuleType
|
|
from typing import Any, Callable, Generator, Optional, Tuple
|
|
|
|
HookPair = Tuple[Callable[[Any], Any], Callable[[Any], Any]]
|
|
|
|
|
|
def validate_offload_config(
|
|
target: Optional[str],
|
|
backend: str,
|
|
device: str,
|
|
save_dir: Optional[str] = None,
|
|
) -> list[str]:
|
|
"""Validate ``activation_offloading`` config.
|
|
|
|
Args:
|
|
target: None / 'cpu' / 'disk'.
|
|
backend: transformers / unsloth / mlx.
|
|
device: cuda / cpu / mps.
|
|
save_dir: Optional. Required when target='disk' — caller must supply
|
|
a containment-checked scratch directory.
|
|
|
|
Returns:
|
|
List of error messages. Empty if valid or disabled.
|
|
"""
|
|
errors: list[str] = []
|
|
|
|
if target is None:
|
|
return errors
|
|
|
|
if backend == "unsloth":
|
|
errors.append(
|
|
"Activation offloading is not compatible with the unsloth backend. "
|
|
"Unsloth manages its own memory. Use backend: transformers."
|
|
)
|
|
return errors
|
|
|
|
if backend == "mlx":
|
|
errors.append(
|
|
"Activation offloading is not supported on the mlx backend. "
|
|
"Use backend: transformers."
|
|
)
|
|
return errors
|
|
|
|
if device != "cuda":
|
|
errors.append(
|
|
"Activation offloading requires CUDA training. "
|
|
f"Current device: {device}."
|
|
)
|
|
|
|
if target == "disk" and not save_dir:
|
|
errors.append(
|
|
"activation_offloading='disk' requires a save_dir "
|
|
"(scratch directory for offloaded activation tensors)."
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def offload_context(
|
|
target: Optional[str], save_dir: Optional[str] = None,
|
|
) -> Generator[None, None, None]:
|
|
"""Install saved-tensor hooks for activation offloading; remove on exit.
|
|
|
|
Args:
|
|
target: 'cpu' → offload to RAM; 'disk' → offload to scratch files;
|
|
None → no-op.
|
|
save_dir: Directory for disk-mode scratch files. Required for 'disk'.
|
|
|
|
Yields:
|
|
Nothing — use as a ``with`` block around ``trainer.train()``.
|
|
|
|
Note:
|
|
When ``torch`` is not installed the hooks are silently skipped; this
|
|
keeps the context manager safe to enter from CI / CLI --help paths.
|
|
"""
|
|
if target is None:
|
|
yield
|
|
return
|
|
|
|
try:
|
|
import torch
|
|
except ImportError:
|
|
yield
|
|
return
|
|
|
|
created_files: list[str] = []
|
|
|
|
if target == "cpu":
|
|
pack_hook, unpack_hook = _make_cpu_hooks(torch)
|
|
elif target == "disk":
|
|
if not save_dir:
|
|
raise ValueError(
|
|
"activation_offloading='disk' requires save_dir"
|
|
)
|
|
pack_hook, unpack_hook = _make_disk_hooks(torch, save_dir, created_files)
|
|
else:
|
|
raise ValueError(
|
|
f"Unknown activation_offloading target: {target!r}. "
|
|
"Expected None, 'cpu', or 'disk'."
|
|
)
|
|
|
|
# saved_tensors_hooks is the public API (torch>=1.11). Older torch: no-op.
|
|
if not hasattr(torch.autograd.graph, "saved_tensors_hooks"):
|
|
yield
|
|
return
|
|
|
|
try:
|
|
with torch.autograd.graph.saved_tensors_hooks(pack_hook, unpack_hook):
|
|
yield
|
|
finally:
|
|
# Best-effort cleanup for disk mode: remove any leftover scratch files
|
|
# (e.g. from a crashed backward pass). Per-file OSErrors are swallowed
|
|
# so cleanup is never itself a crash source.
|
|
if target == "disk":
|
|
import os
|
|
for path in created_files:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _make_cpu_hooks(torch_module: ModuleType) -> HookPair:
|
|
"""Saved-tensor hooks: pack → move to CPU, unpack → move back."""
|
|
|
|
def pack(tensor: Any) -> Any:
|
|
if tensor.device.type == "cuda":
|
|
return ("cuda", tensor.device, tensor.detach().cpu())
|
|
return ("keep", None, tensor)
|
|
|
|
def unpack(payload: Any) -> Any:
|
|
kind, original_device, stored = payload
|
|
if kind == "cuda":
|
|
return stored.to(original_device, non_blocking=True)
|
|
return stored
|
|
|
|
return pack, unpack
|
|
|
|
|
|
def _make_disk_hooks(
|
|
torch_module: ModuleType,
|
|
save_dir: str,
|
|
created_files: list[str],
|
|
) -> HookPair:
|
|
"""Saved-tensor hooks: pack → save to disk, unpack → reload.
|
|
|
|
``created_files`` accumulates paths for best-effort cleanup at context exit.
|
|
The scratch fd is held open until ``torch.save`` returns to close the
|
|
TOCTOU window between ``mkstemp`` and ``save``.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
os.makedirs(save_dir, exist_ok=True)
|
|
|
|
def pack(tensor: Any) -> Any:
|
|
if tensor.device.type != "cuda":
|
|
return ("keep", None, None, tensor)
|
|
fd, path = tempfile.mkstemp(dir=save_dir, suffix=".pt")
|
|
created_files.append(path)
|
|
# Keep fd open until torch.save flushes to close the TOCTOU gap
|
|
# between mkstemp and a subsequent open-by-path.
|
|
try:
|
|
with os.fdopen(fd, "wb") as file_obj:
|
|
torch_module.save(tensor.detach().cpu(), file_obj)
|
|
except Exception:
|
|
# Hook must be best-effort; fall back to keeping tensor in VRAM.
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
created_files.remove(path)
|
|
except ValueError:
|
|
pass
|
|
return ("keep", None, None, tensor)
|
|
return ("disk", tensor.device, path, None)
|
|
|
|
def unpack(payload: Any) -> Any:
|
|
kind, original_device, path, stored = payload
|
|
if kind == "disk":
|
|
try:
|
|
loaded = torch_module.load(
|
|
path, map_location="cpu", weights_only=True,
|
|
)
|
|
except FileNotFoundError:
|
|
# File already gone (e.g. GC'd by crash + cleanup); return a
|
|
# sentinel None so autograd surfaces a clear error. This
|
|
# should not happen in a healthy run.
|
|
raise
|
|
finally:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
created_files.remove(path)
|
|
except ValueError:
|
|
pass
|
|
return loaded.to(original_device, non_blocking=True)
|
|
return stored
|
|
|
|
return pack, unpack
|