from __future__ import annotations
import functools
import logging
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any
import torch
from modelexpress.refit.reshard.types import (
CaptureResult,
OpChain,
OpSpec,
RecordedCopy,
UnsupportedReshard,
summarize_unsupported,
)
logger = logging.getLogger(__name__)
__all__ = [
"CaptureResult",
"LazyWeight",
"OpChain",
"OpSpec",
"RecordedCopy",
"UnsupportedReshard",
"build_lazy_weights",
"capture_geometry",
"capture_weights",
"convert_source_weights",
]
_SUPPORTED_OPS: dict[Callable, str] = {
torch.Tensor.narrow: "narrow",
torch.Tensor.select: "select",
torch.Tensor.view: "view",
torch.Tensor.reshape: "reshape",
torch.Tensor.__getitem__: "__getitem__",
torch.Tensor.unsqueeze: "unsqueeze",
torch.Tensor.squeeze: "squeeze",
torch.Tensor.transpose: "transpose",
torch.Tensor.t: "t",
torch.Tensor.permute: "permute",
torch.Tensor.flatten: "flatten",
torch.Tensor.contiguous: "contiguous",
torch.Tensor.chunk: "chunk",
torch.Tensor.split: "split",
torch.Tensor.unbind: "unbind",
}
def _freeze_kwargs(kwargs: dict[str, Any]) -> tuple:
return tuple(sorted(kwargs.items()))
@dataclass
class _BakeRecorder:
copies: list = field(default_factory=list)
unattributed: int = 0
current: Any = None
@contextmanager
def attempt(self) -> Iterator[None]:
copies = len(self.copies)
unattributed, current = self.unattributed, self.current
try:
yield
except BaseException:
del self.copies[copies:]
self.unattributed = unattributed
self.current = current
raise
class LazyWeight(torch.Tensor):
_name: str
_ops: tuple
_recorder: Any
@staticmethod
def __new__(cls, name, shape, dtype, device, ops=(), recorder=None):
t = torch.Tensor._make_wrapper_subclass(
cls, shape, dtype=dtype, device=device, requires_grad=False
)
t._name = name
t._ops = tuple(ops)
t._recorder = recorder
return t
def _meta(self) -> torch.Tensor:
return torch.empty(self.shape, dtype=self.dtype, device="meta")
def _child(self, new_shape, new_dtype, *new_ops) -> LazyWeight:
return LazyWeight(
self._name,
new_shape,
new_dtype,
self.device,
self._ops + new_ops,
self._recorder,
)
@classmethod
def __torch_function__(cls, func, types, args=(), kwargs=None):
kwargs = kwargs or {}
if func is torch.Tensor.copy_:
dest = args[0]
src = args[1] if len(args) > 1 else kwargs.get("src")
if isinstance(src, cls):
rec = src._recorder
if rec is not None and rec.current is not None:
rec.copies.append(
RecordedCopy(
src_name=src._name,
op_chain=src._ops,
param_name=rec.current,
dest_offset=dest.storage_offset(),
dest_shape=tuple(dest.shape),
dest_stride=tuple(dest.stride()),
dest_dtype=dest.dtype,
)
)
elif rec is not None:
rec.unattributed += 1
return dest
op_name = _SUPPORTED_OPS.get(func)
if op_name is not None and args and isinstance(args[0], cls):
return cls._intercept(args[0], func, op_name, tuple(args[1:]), kwargs)
with torch._C.DisableTorchFunctionSubclass():
return func(*args, **kwargs)
@classmethod
def _intercept(cls, self_, func, op_name, args, kwargs):
meta = self_._meta()
with torch._C.DisableTorchFunctionSubclass():
meta_result = func(meta, *args, **kwargs)
base_op = (op_name, tuple(args), _freeze_kwargs(kwargs))
if isinstance(meta_result, torch.Tensor):
return self_._child(meta_result.shape, meta_result.dtype, base_op)
if isinstance(meta_result, (tuple, list)):
return tuple(
self_._child(m.shape, m.dtype, base_op, ("__getitem__", (i,), ()))
for i, m in enumerate(meta_result)
)
raise UnsupportedReshard(
f"{op_name!r} on lazy {self_._name!r} returned a non-tensor "
f"({type(meta_result).__name__}); cannot defer."
)
@classmethod
def __torch_dispatch__(cls, func, types, args=(), kwargs=None):
kwargs = kwargs or {}
for a in list(args) + list(kwargs.values()):
if isinstance(a, cls):
raise UnsupportedReshard(
f"unsupported op {func} on lazy {a._name!r} (chain={a._ops}); "
f"supported: {sorted(_SUPPORTED_OPS.values())} + copy_."
)
return func(*args, **kwargs)
def _install_stamps(
model: torch.nn.Module,
recorder: _BakeRecorder,
default_weight_loader: Callable | None,
) -> list:
saved = []
for name, param in model.named_parameters():
original = getattr(param, "weight_loader", None)
inner = original if original is not None else default_weight_loader
if inner is None:
continue
def make_stamp(inner, name):
@functools.wraps(inner)
def stamp(*a, **kw):
recorder.current = name
try:
return inner(*a, **kw)
finally:
recorder.current = None
return stamp
param.weight_loader = make_stamp(inner, name)
saved.append((param, original))
return saved
def _restore_stamps(saved: list) -> None:
for param, original in saved:
if original is None:
try:
del param.weight_loader
except AttributeError:
pass
else:
param.weight_loader = original
def build_lazy_weights(
manifest: list[tuple[str, Any, tuple]],
) -> dict[str, LazyWeight]:
recorder = _BakeRecorder()
return {
name: LazyWeight(name, torch.Size(shape), dtype, "meta", recorder=recorder)
for name, dtype, shape in manifest
}
def _shared_recorder(weights: dict) -> _BakeRecorder:
recorders: dict = {}
for name, w in weights.items():
if not isinstance(w, LazyWeight) or w._recorder is None:
raise ValueError(
f"capture_weights requires LazyWeights from build_lazy_weights; "
f"{name!r} is a {type(w).__name__}"
)
recorders[id(w._recorder)] = w._recorder
if len(recorders) != 1:
raise ValueError(
"capture_weights expects LazyWeights that share one recorder "
"(build them with build_lazy_weights)"
)
return next(iter(recorders.values()))
def capture_weights(
model: torch.nn.Module,
weights: dict[str, Any],
default_weight_loader: Callable | None = None,
) -> CaptureResult:
recorder = _shared_recorder(weights)
saved = _install_stamps(model, recorder, default_weight_loader)
unsupported: list[str] = []
unsupported_reasons: dict[str, str] = {}
try:
try:
with recorder.attempt():
model.load_weights(list(weights.items()))
except UnsupportedReshard as bulk_error:
logger.warning(
"reshard capture: bulk load_weights raised, retrying per source; "
"bulk cause: %s",
bulk_error,
)
for name, tensor in weights.items():
source = getattr(tensor, "_name", name)
try:
model.load_weights([(name, tensor)])
except UnsupportedReshard as exc:
unsupported.append(source)
unsupported_reasons[source] = str(exc)
finally:
_restore_stamps(saved)
logger.info(
"reshard capture: %d copies, %d unsupported source(s), %d unattributed copy_",
len(recorder.copies),
len(unsupported),
recorder.unattributed,
)
for reason, count in summarize_unsupported(unsupported_reasons):
logger.warning(
"reshard capture: %d source(s) unsupported, cause: %s", count, reason
)
return CaptureResult(
copies=recorder.copies,
unsupported=unsupported,
unattributed=recorder.unattributed,
unsupported_reasons=unsupported_reasons,
)
def capture_geometry(
model: torch.nn.Module,
manifest: list[tuple[str, Any, tuple]],
default_weight_loader: Callable | None = None,
) -> CaptureResult:
return capture_weights(
model,
build_lazy_weights(manifest),
default_weight_loader=default_weight_loader,
)
def convert_source_weights(
convert_native_to_hf: Callable[[dict], dict] | None, manifest: list
) -> dict:
weights = build_lazy_weights(manifest)
if convert_native_to_hf is None:
return weights
hf = convert_native_to_hf(weights)
for name, value in hf.items():
if not isinstance(value, LazyWeight):
raise UnsupportedReshard(
f"{name}: conversion produced a non-traced tensor "
f"({type(value).__name__}); dtype casts / synthesized tensors are "
"not yet supported"
)
return hf