import os
import pickle
from dataclasses import fields, field, dataclass
from typing import Any
from collections.abc import Callable
from enum import Enum
from collections import defaultdict
from pathlib import Path
import torch
from psutil import Process
from interpretune.utils import rank_zero_warn, resolve_funcs, _get_rank, rank_zero_only
from interpretune.protocol import AutoStrEnum, CoreSteps
from interpretune.config import ITSerializableCfg
[docs]
class DefaultMemHooks(AutoStrEnum):
pre_forward = "interpretune.extensions.memprofiler._hook_npp_pre_forward"
post_forward = "interpretune.extensions.memprofiler._hook_npp_post_forward"
reset_state = "interpretune.extensions.memprofiler._reset_memory_hooks_state"
[docs]
@dataclass(kw_only=True)
class MemProfilerHooks(ITSerializableCfg):
pre_forward_hooks: list[str | Callable] = field(default_factory=lambda: [DefaultMemHooks.pre_forward.value])
post_forward_hooks: list[str | Callable] = field(default_factory=lambda: [DefaultMemHooks.post_forward.value])
# the provided reset_state_hooks will be called with the model and the `save_hook_attrs` list
reset_state_hooks: list[str | Callable] = field(default_factory=lambda: [DefaultMemHooks.reset_state.value])
[docs]
@dataclass(kw_only=True)
class MemProfilerFuncs(ITSerializableCfg): # can specify arbitrary list of `memprofilable` decorated function names
cuda: list[str | Enum] = field(default_factory=lambda: list(step.name for step in CoreSteps))
cpu: list[str | Enum] = field(default_factory=lambda: list(step.name for step in CoreSteps))
cuda_allocator_history: list[str | Enum] = field(default_factory=lambda: list(step.name for step in CoreSteps))
[docs]
@dataclass(kw_only=True)
class MemProfilerSchedule(ITSerializableCfg):
# keeping schedule simple as possible for now, may expand to accommodate more flexible schedules in the future
warmup_steps: int = 0
max_step: int | None = None
[docs]
@dataclass(kw_only=True)
class MemProfilerCfg(ITSerializableCfg):
enabled: bool = False
cuda_allocator_history: bool = False
schedule: MemProfilerSchedule = field(default_factory=MemProfilerSchedule)
save_dir: str | os.PathLike | None = None
enabled_funcs: MemProfilerFuncs = field(default_factory=MemProfilerFuncs)
enable_memory_hooks: bool = True
enable_saved_tensors_hooks: bool = True
memory_hooks: MemProfilerHooks = field(default_factory=MemProfilerHooks)
saved_tensors_funcs: list = field(
default_factory=lambda: list(("interpretune.extensions.memprofiler._npp_hook", lambda x: x))
)
# if you add custom hooks, make sure to add the desired module state attributes to save to `save_hook_attrs`
save_hook_attrs: list = field(
default_factory=lambda: [
"rss_pre_forward",
"rss_post_forward",
"rss_diff",
"npp_pre_forward",
"npp_post_forward",
"npp_diff",
]
)
# since we cannot reliably ascertain when all MemProfilerFuncs will be executed, memory hooks will
# only be removed once the funcs in this list have reached `max_step`
retain_hooks_for_funcs: list[str | Enum] = field(default_factory=lambda: list(step.name for step in CoreSteps))
def __post_init__(self) -> None:
if (
not torch.cuda.is_available()
and self.enabled
and any((self.enabled_funcs.cuda_allocator_history, self.enabled_funcs.cuda, self.cuda_allocator_history))
):
rank_zero_warn("Disabling CUDA memory profiling functionality since no CUDA device detected.")
self.enabled_funcs.cuda, self.enabled_funcs.cuda_allocator_history = [], []
self.cuda_allocator_history = False
has_hooks = any(getattr(self.memory_hooks, ht.name) for ht in fields(self.memory_hooks))
if self.enabled and not has_hooks:
rank_zero_warn(
"MemProfilerCfg is configured to enable memory hooks but MemProfilerHooks does not have any specified."
)
# TODO: enable once these hooks are added
# @dataclass(kw_only=True)
# class PyTorchProfilerCfg(ITSerializableCfg):
# # pytorch_profiler_enabled: bool = False
# # pytorch_profiler_cfg: dict[str, Any] = field(default_factory=dict)
# accessed in global scope to track non-parameter packed bytes (npp) as a simple proxy (ceiling) for activation memory
_npp_bytes = 0
def _hook_npp_pre_forward(module, *args, **kwargs):
mem = module.mem_info_handle()
global _npp_bytes
module.npp_pre_forward = _npp_bytes
module.rss_pre_forward = mem.rss
return None
def _hook_npp_post_forward(module, *args, **kwargs):
global _npp_bytes
module.npp_post_forward = _npp_bytes
module.npp_diff = module.npp_post_forward - module.npp_pre_forward
mem = module.mem_info_handle()
module.rss_post_forward = mem.rss
rss_diff = module.rss_post_forward - module.rss_pre_forward
module.rss_diff = rss_diff + (module.rss_diff if hasattr(module, "rss_diff") else 0)
return None
def _reset_memory_hooks_state(model, reset_attrs: list[str]):
global _npp_bytes
_npp_bytes = 0
for module in model.modules():
for attr in reset_attrs:
setattr(module, attr, 0)
def _npp_hook(x):
global _npp_bytes
if not isinstance(x, torch.nn.Parameter):
_npp_bytes += x.nbytes
return x
class MemProfiler:
MODEL_MISSING_MSG = "Module or model is not available"
def __init__(self, *args, **kwargs) -> None:
super().__init__()
self.memory_stats = defaultdict(dict)
self._enabled = {}
self._module = None
self._cuda_snapshot_dir = None
self._curr_pid = None
self._rank = _get_rank() or 0 # for future use, currently only single rank supported
self._snap_indices = {}
self._configured_hooks = {}
self._saved_tensors_funcs = []
self._hook_handles = defaultdict(list)
self._done_prof_funcs = []
def connect(self, obj_ref: Any) -> None:
self._module = obj_ref
self._curr_pid = Process(os.getpid())
if self.memprofiler_cfg.enable_saved_tensors_hooks:
self._saved_tensors_funcs = resolve_funcs(cfg_obj=self.memprofiler_cfg, func_type="saved_tensors_funcs")
@property
def memprofiler_cfg(self) -> MemProfilerCfg:
assert self._module is not None and self._module.it_cfg is not None, "Module or IT config is not available"
return self._module.it_cfg.memprofiler_cfg
@property
def schedule(self) -> MemProfilerSchedule:
assert self._module is not None and self._module.it_cfg is not None, "Module or IT config is not available"
return self._module.it_cfg.memprofiler_cfg.schedule
def _get_pytorch_model(self) -> torch.nn.Module:
"""Get the actual PyTorch module for hook registration and attribute access.
For NNsight LanguageModel, we need to access the underlying _module. For other models (TransformerLens, plain
HF), we use the model directly.
"""
# TODO: refactor this to property wrapping to be a property/part of our adapter/model abstraction system
# in our NNsight adapter attribute mixin
assert self._module is not None and self._module.model is not None, self.MODEL_MISSING_MSG
model = self._module.model
if hasattr(model, "_module"):
# NNsight LanguageModel wraps the actual model in _module
return model._module
return model
def remove_memprofiler_hooks(self) -> None:
for handle_list in self._hook_handles.values():
for handle in handle_list:
handle.remove()
def exec_reset_state_hooks(self) -> None:
for hook in self._configured_hooks["reset_state_hooks"]:
hook(self._get_pytorch_model(), self.memprofiler_cfg.save_hook_attrs)
def add_memprofiler_hooks(self) -> None:
# TODO: extend supported hook points (e.g. backward, etc.) and if/once supporting additional hook points,
# use a hook_type to registration function mapping
memory_hooks_cfg = self.memprofiler_cfg.memory_hooks
for supported_hooks in fields(memory_hooks_cfg):
if getattr(memory_hooks_cfg, supported_hooks.name):
self._configured_hooks[supported_hooks.name] = resolve_funcs(
cfg_obj=memory_hooks_cfg, func_type=supported_hooks.name
)
assert self._curr_pid is not None, "Memory profiling process not initialized"
pytorch_model = self._get_pytorch_model()
for module in pytorch_model.modules():
module.mem_info_handle = self._curr_pid.memory_info # type: ignore[assignment]
for hook_func in self._configured_hooks["pre_forward_hooks"]:
self._hook_handles[hook_func].append(module.register_forward_pre_hook(hook_func))
for hook_func in self._configured_hooks["post_forward_hooks"]:
self._hook_handles[hook_func].append(module.register_forward_hook(hook_func))
self.exec_reset_state_hooks()
def init_cuda_snapshots_dir(self) -> None:
assert self._module is not None and self._module.core_log_dir is not None, (
"Module or core log directory is not available"
)
save_dir = self.memprofiler_cfg.save_dir
if save_dir is not None:
self._cuda_snapshot_dir = Path(save_dir)
else:
self._cuda_snapshot_dir = Path(self._module.core_log_dir) / "memprofiler"
self._cuda_snapshot_dir.mkdir(exist_ok=True, parents=True)
def cuda_allocator_history_snap(self, snap_key: str) -> dict:
assert self._cuda_snapshot_dir is not None, "CUDA snapshot directory not initialized"
cuda_snapshot_file = self._cuda_snapshot_dir / f"cuda_alloc_{snap_key}.pickle"
torch.cuda.memory._dump_snapshot(str(cuda_snapshot_file))
return {"cuda_snapshot_file": str(cuda_snapshot_file)}
def done(self, step_idx: int) -> bool:
return bool(self.schedule.max_step and step_idx >= self.schedule.max_step)
def _process_hooks(self, snap_key) -> None:
if self.memprofiler_cfg.enable_memory_hooks:
if len(self._hook_handles) == 0:
self.add_memprofiler_hooks()
pytorch_model = self._get_pytorch_model()
collected = {attr: getattr(pytorch_model, attr, None) for attr in self.memprofiler_cfg.save_hook_attrs}
self.memory_stats[snap_key].update(collected)
def _collect_snap(self, snap_key, reset_mem_hooks: bool = False) -> None:
_, phase, *_ = snap_key
snap_key = ".".join(map(str, snap_key))
mem_cfg = self.memprofiler_cfg
self._process_hooks(snap_key)
if phase in mem_cfg.enabled_funcs.cpu:
assert self._curr_pid is not None, "Memory profiling process not initialized"
mem = self._curr_pid.memory_info()
self.memory_stats[snap_key].update({"rss": mem.rss, "vms": mem.vms})
if phase in mem_cfg.enabled_funcs.cuda:
self.memory_stats[snap_key].update(torch.cuda.memory_stats())
if phase in mem_cfg.enabled_funcs.cuda_allocator_history and mem_cfg.cuda_allocator_history:
self.cuda_allocator_history_snap(snap_key)
if mem_cfg.enable_memory_hooks and reset_mem_hooks:
self.exec_reset_state_hooks()
@property
def _should_remove_hooks(self) -> bool:
return all(func in self._done_prof_funcs for func in self.memprofiler_cfg.retain_hooks_for_funcs)
def teardown_prof(self, phase: str, step_ctx: str) -> None:
self._enabled[(phase, step_ctx)] = False
if not any(self._enabled[(phase, step_ctx)] for step_ctx in ["start", "end"]):
self._done_prof_funcs.append(CoreSteps[phase])
if self.memprofiler_cfg.retain_hooks_for_funcs and self._should_remove_hooks:
self.remove_memprofiler_hooks()
self.memprofiler_cfg.enable_memory_hooks = False
def gen_snap_keys(
self, phase: str, step_ctx: str, epoch_idx: int | None = None, step_idx: int | None = None
) -> tuple[int, int, tuple]:
# NOTE [Memprofiler Key Format]:
# snap key format is rank.phase.epoch_idx.step_idx.step_ctx
# e.g. 0.training_step.0.0.end keys hook output for the end of training step 0, epoch 0 for rank 0
# 0.training_step.1.2.start keys mem stats for the start of training step 2, epoch 1 for rank 0
assert self._module is not None, "Module not connected"
epoch_idx = next(e_idx for e_idx in (epoch_idx, self._module.current_epoch) if e_idx is not None)
if step_idx is None:
step_idx = self._snap_indices.get((phase, step_ctx), 0)
# epoch_idx or step_idx should not be None
assert epoch_idx is not None, "Epoch index is None"
assert step_idx is not None, "Step index is None"
return epoch_idx, step_idx, (self._rank, phase, epoch_idx, step_idx, step_ctx)
def maybe_init_phase(self, phase: str, step_ctx: str) -> None:
if not self._snap_indices.get((phase, step_ctx), None):
self._snap_indices[(phase, step_ctx)] = 0
self._enabled[(phase, step_ctx)] = True
def snap(
self,
phase: str,
step_ctx: str,
epoch_idx: int | None = None,
step_idx: int | None = None,
reset_mem_hooks: bool = False,
) -> None:
self.maybe_init_phase(phase, step_ctx)
if not self._enabled[(phase, step_ctx)]:
return
epoch_idx, step_idx, snap_key = self.gen_snap_keys(phase, step_ctx, epoch_idx, step_idx)
if step_idx >= self.schedule.warmup_steps:
if not self.done(step_idx):
self._collect_snap(snap_key, reset_mem_hooks)
else:
self.teardown_prof(phase, step_ctx)
if self._enabled[(phase, step_ctx)]:
self._snap_indices[(phase, step_ctx)] += 1
@rank_zero_only
def dump_memory_stats(self) -> None:
# TODO: all gather memory stats in the future if/when multiple ranks are supported
assert self._cuda_snapshot_dir is not None, "CUDA snapshot directory not initialized"
filename = self._cuda_snapshot_dir / "memory_stats.pickle"
with open(filename, "wb") as f:
pickle.dump(self.memory_stats, f)