Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
@@ -0,0 +1,116 @@
r'''
FX is a toolkit for developers to use to transform ``nn.Module``
instances. FX consists of three main components: a **symbolic tracer,**
an **intermediate representation**, and **Python code generation**. A
demonstration of these components in action:
::
import torch
# Simple module for demonstration
class MyModule(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.param = torch.nn.Parameter(torch.rand(3, 4))
self.linear = torch.nn.Linear(4, 5)
def forward(self, x):
return self.linear(x + self.param).clamp(min=0.0, max=1.0)
module = MyModule()
from torch.fx import symbolic_trace
# Symbolic tracing frontend - captures the semantics of the module
symbolic_traced: torch.fx.GraphModule = symbolic_trace(module)
# High-level intermediate representation (IR) - Graph representation
print(symbolic_traced.graph)
"""
graph():
%x : [num_users=1] = placeholder[target=x]
%param : [num_users=1] = get_attr[target=param]
%add : [num_users=1] = call_function[target=operator.add](args = (%x, %param), kwargs = {})
%linear : [num_users=1] = call_module[target=linear](args = (%add,), kwargs = {})
%clamp : [num_users=1] = call_method[target=clamp](args = (%linear,), kwargs = {min: 0.0, max: 1.0})
return clamp
"""
# Code generation - valid Python code
print(symbolic_traced.code)
"""
def forward(self, x):
param = self.param
add = x + param; x = param = None
linear = self.linear(add); add = None
clamp = linear.clamp(min = 0.0, max = 1.0); linear = None
return clamp
"""
The **symbolic tracer** performs "symbolic execution" of the Python
code. It feeds fake values, called Proxies, through the code. Operations
on these Proxies are recorded. More information about symbolic tracing
can be found in the :func:`symbolic_trace` and :class:`Tracer`
documentation.
The **intermediate representation** is the container for the operations
that were recorded during symbolic tracing. It consists of a list of
Nodes that represent function inputs, callsites (to functions, methods,
or :class:`torch.nn.Module` instances), and return values. More information
about the IR can be found in the documentation for :class:`Graph`. The
IR is the format on which transformations are applied.
**Python code generation** is what makes FX a Python-to-Python (or
Module-to-Module) transformation toolkit. For each Graph IR, we can
create valid Python code matching the Graph's semantics. This
functionality is wrapped up in :class:`GraphModule`, which is a
:class:`torch.nn.Module` instance that holds a :class:`Graph` as well as a
``forward`` method generated from the Graph.
Taken together, this pipeline of components (symbolic tracing ->
intermediate representation -> transforms -> Python code generation)
constitutes the Python-to-Python transformation pipeline of FX. In
addition, these components can be used separately. For example,
symbolic tracing can be used in isolation to capture a form of
the code for analysis (and not transformation) purposes. Code
generation can be used for programmatically generating models, for
example from a config file. There are many uses for FX!
Several example transformations can be found at the
`examples <https://github.com/pytorch/examples/tree/master/fx>`__
repository.
'''
from torch.fx import immutable_collections
from torch.fx._symbolic_trace import ( # noqa: F401
PH,
ProxyableClassMeta,
symbolic_trace,
Tracer,
wrap,
)
from torch.fx.graph import CodeGen, Graph # noqa: F401
from torch.fx.graph_module import GraphModule
from torch.fx.interpreter import Interpreter, Transformer
from torch.fx.node import has_side_effect, map_arg, Node
from torch.fx.proxy import Proxy
from torch.fx.subgraph_rewriter import replace_pattern
__all__ = [
"symbolic_trace",
"Tracer",
"wrap",
"Graph",
"GraphModule",
"Interpreter",
"Transformer",
"Node",
"Proxy",
"replace_pattern",
"has_side_effect",
"map_arg",
]
@@ -0,0 +1,42 @@
import textwrap
from collections.abc import Callable
from typing import Any, TypeVar
_BACK_COMPAT_OBJECTS: dict[Any, None] = {}
_MARKED_WITH_COMPATIBILITY: dict[Any, None] = {}
_T = TypeVar("_T")
def compatibility(is_backward_compatible: bool) -> Callable[[_T], _T]:
if is_backward_compatible:
def mark_back_compat(fn: _T) -> _T:
docstring = textwrap.dedent(getattr(fn, "__doc__", None) or "")
docstring += """
.. note::
Backwards-compatibility for this API is guaranteed.
"""
fn.__doc__ = docstring
_BACK_COMPAT_OBJECTS.setdefault(fn)
_MARKED_WITH_COMPATIBILITY.setdefault(fn)
return fn
return mark_back_compat
else:
def mark_not_back_compat(fn: _T) -> _T:
docstring = textwrap.dedent(getattr(fn, "__doc__", None) or "")
docstring += """
.. warning::
This API is experimental and is *NOT* backward-compatible.
"""
fn.__doc__ = docstring
_MARKED_WITH_COMPATIBILITY.setdefault(fn)
return fn
return mark_not_back_compat
@@ -0,0 +1,947 @@
import contextlib
import dataclasses
import importlib
import io
import itertools
import pickle
import weakref
from abc import abstractmethod
from collections.abc import Callable, Generator
from typing import Any, NewType, TypeVar
from typing_extensions import override, Self
from torch.utils._import_utils import import_dill
dill = import_dill()
if dill is not None:
pickle = dill # noqa: F811
import torch
import torch.utils._pytree as pytree
from torch._guards import TracingContext
from torch._inductor.standalone_compile import AOTCompiledArtifact
from torch._library.fake_class_registry import FakeScriptObject
from torch._subclasses.fake_tensor import FakeTensor, FakeTensorMode, Tensor
from torch._subclasses.meta_utils import (
MetaConverter,
MetaTensorDesc,
MetaTensorDescriber,
)
from torch.fx.experimental.sym_node import SymNode
from torch.fx.experimental.symbolic_shapes import ShapeEnv
from torch.utils._mode_utils import no_dispatch
_SymNodeT = TypeVar("_SymNodeT", torch.SymInt, torch.SymFloat)
def _ops_filter_safe(name: str) -> bool:
"""
An ops filter which allows pickle-safe ops. Pickle-safe ops are built-in
ones where it will be possible to unpickle on any machine which has PyTorch.
"""
# TODO: This list is pretty pessimistic right now. What's the full list?
return name.startswith(
(
"torch.ops.aten",
"torch.ops.fbgemm",
)
)
def _node_metadata_key_filter_safe(key: str) -> bool:
"""
A metadata filter which allows pickle-safe node metadata. These often times contain
stacks with pointers to unserializable objects, so we clear them out.
"""
return key not in ["source_fn_stack", "nn_module_stack", "fwd_source_fn_stack"]
@dataclasses.dataclass
class Options:
# A filter for which ops will cause the pickler to raise a
# BypassFxGraphCache exception. If None then all ops are allowed.
ops_filter: Callable[[str], bool] | None = _ops_filter_safe
node_metadata_key_filter: Callable[[str], bool] | None = (
_node_metadata_key_filter_safe
)
# If True, raw torch.fx.Node objects encountered during pickling will be
# silently replaced with None instead of raising an AssertionError.
ignore_raw_node: bool = False
def _unpickle_as_none() -> None:
return None
def _unpickle_as_weakref(referent: object) -> weakref.ref[object]:
return weakref.ref(referent)
def _unpickle_as_dead_weakref() -> Callable[[], None]:
return lambda: None
@contextlib.contextmanager
def patch_pytree_map_over_slice() -> Generator[None]:
if slice in pytree.SUPPORTED_NODES:
yield
return
pytree._private_register_pytree_node(
slice, lambda x: ([x.start, x.stop, x.step], None), lambda x, c: slice(*x)
)
try:
yield
finally:
pytree._deregister_pytree_node(slice)
# pyrefly: ignore [invalid-inheritance]
class GraphPickler(pickle.Pickler):
"""
GraphPickler is a Pickler which helps pickling fx graph - in particular
GraphModule.
"""
def __init__(self, file: io.BytesIO, options: Options | None = None) -> None:
if dill is not None:
super().__init__(file, byref=True)
else:
super().__init__(file)
self.options = options or Options()
# This abomination is so we can pass external decoding state to the
# unpickler functions. We serialize _unpickle_state as a persistent
# external item and when we deserialize it we return the common state
# object.
self._unpickle_state = _UnpickleStateToken(object())
# This is used to describe tensors. It needs to be common across the
# pickle so that duplicates and views are properly handled.
self._meta_tensor_describer = MetaTensorDescriber(copy_data=False)
@override
# pyrefly: ignore [bad-override]
def reducer_override(
self, obj: object
) -> tuple[Callable[..., Any], tuple[Any, ...]]:
# This function is supposed to return either NotImplemented (meaning to
# do the default pickle behavior) or a pair of (unpickle callable, data
# to pass to unpickle).
# We could instead teach individual classes how to pickle themselves but
# that has a few problems:
#
# 1. If we have some special needs (maybe for this use-case we don't
# want to fully serialize every field) then we're adding private
# details to a public interface.
#
# 2. If we need to have some common shared data (such as a
# FakeTensorMode) which is passed to each value it's harder to
# support.
# These are the types that need special handling. See the individual
# *PickleData classes for details on pickling that particular type.
if isinstance(obj, FakeTensor):
return _TensorPickleData.reduce_helper(self, obj)
elif isinstance(obj, torch.fx.GraphModule):
return _GraphModulePickleData.reduce_helper(self, obj)
elif isinstance(obj, (torch._ops.OperatorBase, torch._ops.OpOverloadPacket)):
return _OpPickleData.reduce_helper(self, obj)
elif isinstance(obj, ShapeEnv):
return _ShapeEnvPickleData.reduce_helper(self, obj)
elif isinstance(obj, torch.SymInt):
return _SymNodePickleData.reduce_helper(self, obj)
elif isinstance(obj, torch._guards.TracingContext):
return _TracingContextPickleData.reduce_helper(self, obj)
elif isinstance(obj, FakeScriptObject):
from torch._library.opaque_object import is_opaque_value_type
real_obj = object.__getattribute__(obj, "real_obj")
if real_obj is not None and is_opaque_value_type(type(real_obj)):
# Use default pickling; value-type opaques are picklable.
return NotImplemented
# Reference-type FakeScriptObjects can't be default-pickled.
return (_unpickle_as_none, ())
elif isinstance(obj, weakref.ref):
# Serialize weakrefs properly: if the referent is alive,
# serialize it and reconstruct the weakref on unpickle.
# If the referent is dead, unpickle as a dead-weakref-like callable.
referent = obj()
if referent is not None:
return (_unpickle_as_weakref, (referent,))
else:
return (_unpickle_as_dead_weakref, ())
else:
# We should never get a raw Node!
if isinstance(obj, torch.fx.Node):
if self.options.ignore_raw_node:
return (_unpickle_as_none, ())
raise AssertionError("Unexpected raw Node during pickling")
if reduce := _TorchNumpyPickleData.reduce_helper(self, obj):
return reduce
# returning `NotImplemented` causes pickle to revert to the default
# behavior for this object.
return NotImplemented
@override
# pyrefly: ignore [bad-override]
def persistent_id(self, obj: object) -> str | None:
if obj is self._unpickle_state:
return "unpickle_state"
else:
return None
@classmethod
def dumps(cls, obj: object, options: Options | None = None) -> bytes:
"""
Pickle an object.
"""
with patch_pytree_map_over_slice(), io.BytesIO() as stream:
pickler = cls(stream, options)
pickler.dump(obj)
return stream.getvalue()
@staticmethod
def loads(data: bytes, fake_mode: FakeTensorMode) -> object:
"""
Unpickle an object.
"""
from torch._dynamo.utils import dynamo_timed
with patch_pytree_map_over_slice(), dynamo_timed("GraphPickler.loads"):
state = _UnpickleState(fake_mode)
with io.BytesIO(data) as stream:
unpickler = _GraphUnpickler(stream, state)
return unpickler.load()
@classmethod
def debug_dumps(
cls,
obj: object,
options: "Options | None" = None,
*,
max_depth: int = 80,
max_iter_items: int = 50,
verbose: bool = True,
) -> str | None:
"""
Find the first leaf that GraphPickler.dumps cannot serialize and return its path.
This is GraphPickler-aware and avoids infinite loops by:
- Traversing builtin containers directly (dict/list/tuple/set) instead of
exploring their __reduce_ex__ tuples.
- Only using __reduce_ex__ / __reduce__ for "opaque" objects.
- Bounding recursion depth and iterator expansion.
Args:
obj: The object to attempt to pickle and debug.
options: Optional Options instance for the GraphPickler.
max_depth: Maximum recursion depth before stopping traversal.
max_iter_items: Maximum number of items to materialize from iterators.
verbose: If True, prints detailed traversal information.
Returns:
A string representing the path to the first unpicklable leaf,
or None if the object is fully picklable.
"""
options = options or Options()
pickler = cls(io.BytesIO(), options)
visited: set[int] = set()
def log(msg: str) -> None:
if verbose:
print(msg)
def fail_exc(o: Any) -> BaseException | None:
try:
cls.dumps(o, options)
return None
except Exception as e:
return e
def walk(o: Any, path: str, depth: int) -> str | None:
if depth > max_depth:
log(f"{' ' * depth}Depth limit at {path} ({type(o)})")
return path + " (depth_limit)"
key = id(o)
if key in visited:
return None
visited.add(key)
indent = " " * depth
log(f"{indent}Walking: {path} ({type(o)})")
e = fail_exc(o)
if e is None:
log(f"{indent}✓ Pickles fine alone")
return None
log(f"{indent}[FAIL pickle] {type(o)} -> {e}")
# 1) Builtin containers: walk contents directly (do NOT call __reduce_ex__)
if isinstance(o, dict):
for k, v in o.items():
bad = walk(v, f"{path}[{k!r}]", depth + 1)
if bad:
return bad
return path
if isinstance(o, (list, tuple)):
for i, v in enumerate(o):
bad = walk(v, f"{path}[{i}]", depth + 1)
if bad:
return bad
return path
if isinstance(o, (set, frozenset)):
for i, v in enumerate(o):
bad = walk(v, f"{path}[{i}]", depth + 1)
if bad:
return bad
return path
# 2) Iterator types: materialize a bounded prefix
if hasattr(o, "__iter__") and type(o).__name__.endswith("iterator"):
try:
prefix = list(itertools.islice(iter(o), max_iter_items + 1))
except Exception:
prefix = None
if prefix is not None:
if len(prefix) > max_iter_items:
log(
f"{indent}⚠ Iterator has more than {max_iter_items} items, "
f"only checking first {max_iter_items}"
)
prefix = prefix[:max_iter_items]
for i, v in enumerate(prefix):
bad = walk(v, f"{path}[{i}]", depth + 1)
if bad:
return bad
return path
# 3) GraphPickler reducer_override
try:
red = pickler.reducer_override(o)
log(f"{indent}reducer_override -> {type(red)}")
except Exception as e2:
log(f"{indent}💥 reducer_override crashed: {e2}")
return path
if red is not NotImplemented:
_, args = red
log(f"{indent}Using custom reduce, args={len(args)}")
for i, a in enumerate(args):
bad = walk(a, f"{path}.reduce_args[{i}]", depth + 1)
if bad:
return bad
# 4) Dataclasses
if dataclasses.is_dataclass(o):
for f in dataclasses.fields(o):
try:
v = getattr(o, f.name)
except Exception:
return f"{path}.{f.name}"
bad = walk(v, f"{path}.{f.name}", depth + 1)
if bad:
return bad
return path
# 5) __getstate__ and __dict__/__slots__
getstate = getattr(o, "__getstate__", None)
if callable(getstate):
try:
state = getstate()
log(f"{indent}__getstate__ -> {type(state)}")
except Exception as e3:
log(f"{indent}💥 __getstate__ failed: {e3}")
return path + ".__getstate__()"
bad = walk(state, path + ".__getstate__()", depth + 1)
if bad:
return bad
if hasattr(o, "__dict__"):
for name, v in vars(o).items():
bad = walk(v, f"{path}.{name}", depth + 1)
if bad:
return bad
return path
if hasattr(o, "__slots__"):
for slot in o.__slots__:
if hasattr(o, slot):
bad = walk(getattr(o, slot), f"{path}.{slot}", depth + 1)
if bad:
return bad
return path
# 6) Last resort: reduce protocol for non-container / opaque objects
reduce_tuple = None
try:
if hasattr(o, "__reduce_ex__"):
reduce_tuple = o.__reduce_ex__(pickle.HIGHEST_PROTOCOL)
log(f"{indent}__reduce_ex__ -> {type(reduce_tuple)}")
elif hasattr(o, "__reduce__"):
reduce_tuple = o.__reduce__()
log(f"{indent}__reduce__ -> {type(reduce_tuple)}")
except Exception as e4:
log(f"{indent}💥 reduce protocol failed: {e4}")
return path
if isinstance(reduce_tuple, tuple):
for i, part in enumerate(reduce_tuple):
if part is None:
continue
bad = walk(part, f"{path}.__reduce__[{i}]", depth + 1)
if bad:
return bad
return path
bad = walk(obj, "root", 0)
return bad
class _UnpickleState:
def __init__(self, fake_mode: FakeTensorMode) -> None:
self.fake_mode = fake_mode
self.meta_converter: MetaConverter[FakeTensor] = MetaConverter()
# This token is passed when pickling to indicate that we want to use the
# unpickler's _UnpickleState as a parameter in that position.
_UnpickleStateToken = NewType("_UnpickleStateToken", object)
# pyrefly: ignore [invalid-inheritance]
class _GraphUnpickler(pickle.Unpickler):
def __init__(self, stream: io.BytesIO, unpickle_state: _UnpickleState) -> None:
super().__init__(stream)
self._unpickle_state = unpickle_state
@override
# pyrefly: ignore [bad-override]
def persistent_load(self, pid: object) -> object:
if pid == "unpickle_state":
return self._unpickle_state
else:
raise pickle.UnpicklingError("Invalid persistent ID")
class _ShapeEnvPickleData:
data: dict[str, object]
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, obj: ShapeEnv
) -> tuple[
Callable[[Self, _UnpickleState], ShapeEnv], tuple[Self, _UnpickleStateToken]
]:
return cls.unpickle, (cls(obj), pickler._unpickle_state)
def __init__(self, env: ShapeEnv) -> None:
# In theory pickle should recognize that a given ShapeEnv was already
# pickled and reuse the resulting _ShapeEnvPickleData (so two objects
# pointing at the same ShapeEnv get the same ShapeEnv out).
if env._translation_validation_enabled:
raise AssertionError("Translation validation must be disabled for pickling")
self.data = env.__dict__.copy()
del self.data["tracked_fakes"]
del self.data["fake_tensor_cache"]
def unpickle(self, unpickle_state: _UnpickleState) -> ShapeEnv:
# Fill in the existing ShapeEnv rather than creating a new one
if not unpickle_state.fake_mode:
raise AssertionError("unpickle_state.fake_mode is not set")
if not unpickle_state.fake_mode.shape_env:
raise AssertionError("unpickle_state.fake_mode.shape_env is not set")
for k, v in self.data.items():
setattr(unpickle_state.fake_mode.shape_env, k, v)
return unpickle_state.fake_mode.shape_env
class _SymNodePickleData:
@classmethod
def reduce_helper(
cls,
pickler: GraphPickler,
obj: _SymNodeT,
) -> tuple[
Callable[[Self, _UnpickleState], _SymNodeT], tuple[Self, _UnpickleStateToken]
]:
args = (cls(obj.node), pickler._unpickle_state)
if isinstance(obj, torch.SymInt):
# pyrefly: ignore [bad-return]
return _SymNodePickleData.unpickle_sym_int, args
else:
raise NotImplementedError(f"Unhandled SymNode type {type(obj)}")
def __init__(self, node: SymNode) -> None:
self.expr = node._expr
self.shape_env = node.shape_env
self.pytype = node.pytype
self.hint = node._hint
def _to_sym_node(self) -> SymNode:
if self.shape_env is None:
raise AssertionError("shape_env is None")
return SymNode(self.expr, self.shape_env, self.pytype, self.hint)
def unpickle_sym_int(self, unpickle_state: _UnpickleState) -> torch.SymInt:
return torch.SymInt(self._to_sym_node())
class _TensorPickleData:
metadata: MetaTensorDesc[FakeTensor]
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, obj: FakeTensor
) -> tuple[
Callable[[Self, _UnpickleState], FakeTensor], tuple[Self, _UnpickleStateToken]
]:
return cls.unpickle, (
cls(pickler._meta_tensor_describer, obj),
pickler._unpickle_state,
)
def __init__(self, describer: MetaTensorDescriber, t: Tensor) -> None:
# THINGS TO WORRY ABOUT:
# 1. Need to make sure that two tensors with the same id end up with the
# same id on the other side of the wire.
metadata = describer.describe_tensor(t)
# view_func is fine if it's either None or a _FakeTensorViewFunc. A
# custom one (which is basically a lambda) can't be serialized.
if metadata.view_func and not isinstance(
metadata.view_func, torch._subclasses.meta_utils._FakeTensorViewFunc
):
raise AssertionError(
f"view_func must be None or _FakeTensorViewFunc, got "
f"{type(metadata.view_func)}"
)
self.metadata = dataclasses.replace(metadata, fake_mode=None)
# Some debugging/verification
for k in MetaTensorDesc._UNSERIALIZABLE:
if k in ("fake_mode", "view_func"):
continue
if getattr(self.metadata, k) is not None:
raise AssertionError(f"not None: {k}: {getattr(self.metadata, k)}")
def unpickle(self, unpickle_state: _UnpickleState) -> FakeTensor:
# TODO: make common w/ _output_from_cache_entry() in fake_tensor.py?
metadata = dataclasses.replace(
self.metadata,
fake_mode=unpickle_state.fake_mode,
)
# also need to set the fake_mode on the base of a tensor if it's a view
if metadata.is_view and metadata.base is not None:
new_base = dataclasses.replace(
metadata.base,
fake_mode=unpickle_state.fake_mode,
)
metadata = dataclasses.replace(metadata, base=new_base)
def with_fake(
make_meta_t: Callable[[], torch.Tensor], device: torch.device | str
) -> FakeTensor:
with no_dispatch():
return FakeTensor(
unpickle_state.fake_mode,
make_meta_t(),
# pyrefly: ignore [bad-argument-type]
device,
)
return unpickle_state.meta_converter.meta_tensor(
metadata,
unpickle_state.fake_mode.shape_env,
with_fake,
None,
None,
)
class _TorchNumpyPickleData:
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, obj: object
) -> (
tuple[
Callable[[Self, _UnpickleState], object], tuple[Self, _UnpickleStateToken]
]
| None
):
if data := cls.from_object(obj):
return (cls.unpickle, (data, pickler._unpickle_state))
else:
return None
def __init__(self, mod: str, name: str) -> None:
self.mod = mod
self.name = name
def unpickle(self, unpickle_state: _UnpickleState) -> Callable[..., object]:
np = getattr(importlib.import_module(self.mod), self.name)
return torch._dynamo.variables.misc.get_np_to_tnp_map()[np]
@classmethod
def from_object(cls, tnp: object) -> Self | None:
if not callable(tnp):
return None
tnp_to_np = torch._dynamo.variables.misc.get_tnp_to_np_map()
try:
if not (np := tnp_to_np.get(tnp)):
return None
except TypeError:
return None
if not (mod := getattr(np, "__module__", None)):
mod = "numpy"
if not (name := getattr(np, "__name__", None)):
return None
# pyrefly: ignore [unbound-name]
if np != getattr(importlib.import_module(mod), name):
raise AssertionError(
f"Numpy object mismatch for {mod}.{name}" # pyrefly: ignore [unbound-name]
)
# pyrefly: ignore [unbound-name]
return cls(mod, name)
class _GraphModulePickleData:
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, obj: torch.fx.GraphModule
) -> tuple[
Callable[[Self, _UnpickleState], torch.fx.GraphModule],
tuple[Self, _UnpickleStateToken],
]:
return cls.unpickle, (
cls(obj, pickler.options),
pickler._unpickle_state,
)
def __init__(self, gm: torch.fx.GraphModule, options: Options) -> None:
# Need to do this to ensure the code is created for later pickling.
if isinstance(gm, torch.fx._lazy_graph_module._LazyGraphModule):
_python_code = gm._real_recompile()
else:
_python_code = gm.recompile()
if hasattr(gm, "__getstate__"):
self.gm_dict = gm.__getstate__()
else:
self.gm_dict = gm.__dict__.copy()
del self.gm_dict["_graph"]
self.graph = _GraphPickleData(gm._graph, options)
def unpickle(self, unpickle_state: _UnpickleState) -> torch.fx.GraphModule:
gm = torch.fx.GraphModule.__new__(torch.fx.GraphModule)
gm.__dict__ = self.gm_dict
gm._graph = self.graph.unpickle(gm, unpickle_state)
return gm
class _NodePickleData:
def __init__(
self,
node: torch.fx.Node,
mapping: dict[torch.fx.Node, "_NodePickleData"],
options: Options,
) -> None:
self.args = pytree.tree_map_only(torch.fx.Node, lambda n: mapping[n], node.args)
self.kwargs = pytree.tree_map_only(
torch.fx.Node, lambda n: mapping[n], node.kwargs
)
# -- self.graph = node.graph
self.name = node.name
self.op = node.op
self.target = _OpPickleData.pickle(node.target, options)
# self.input_nodes = node._input_nodes
# self.users = node.users
self.type = node.type
# self.sort_key = node._sort_key
# self.repr_fn = node._repr_fn
# self.meta = node.meta
self.meta = {
k: v
for k, v in node.meta.items()
if (
not options.node_metadata_key_filter
or options.node_metadata_key_filter(k)
)
}
def unpickle(
self,
graph: torch.fx.Graph,
mapping: dict["_NodePickleData", torch.fx.Node],
unpickle_state: _UnpickleState,
) -> torch.fx.Node:
args = pytree.tree_map_only(_NodePickleData, lambda n: mapping[n], self.args)
kwargs = pytree.tree_map_only(
_NodePickleData, lambda n: mapping[n], self.kwargs
)
target = self.target.unpickle(unpickle_state)
if not (callable(target) or isinstance(target, str)):
raise AssertionError(f"target must be callable or str, got {type(target)}")
node = graph.create_node(self.op, target, args, kwargs, self.name, self.type)
node.meta = self.meta
return node
class _OpPickleData:
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, op: object
) -> tuple[Callable[[_UnpickleState], object], tuple[_UnpickleStateToken]]:
result = cls.pickle(op, pickler.options)
return (result.unpickle, (pickler._unpickle_state,))
@classmethod
def pickle(cls, op: object, options: Options) -> "_OpPickleData":
if isinstance(op, str):
return _OpStrPickleData(op)
if isinstance(getattr(op, "__wrapped__", None), AOTCompiledArtifact):
if not hasattr(op, "__wrapped__"):
raise AssertionError("op missing __wrapped__ attribute")
artifact = op.__wrapped__
if not isinstance(artifact, AOTCompiledArtifact):
raise AssertionError(
f"Expected AOTCompiledArtifact, got {type(artifact)}"
)
return _OpPrecompiledPickleData(artifact)
name = torch.fx.Node._pretty_print_target(op)
if isinstance(op, torch._ops.OpOverload):
return cls._pickle_op(name, _OpOverloadPickleData, options)
elif isinstance(op, torch._ops.OpOverloadPacket):
return cls._pickle_op(name, _OpOverloadPacketPickleData, options)
elif name.startswith(_OpFunctionPickleData.SUPPORTED_ROOTS):
root, detail = name.split(".", 1)
return _OpFunctionPickleData(root, detail)
else:
# TODO: raise a BypassFxGraphCache so we will just bypass this one...
raise NotImplementedError(f"TARGET: {type(op)} {op} {name}")
@staticmethod
def _pickle_op(
name: str,
datacls: type["_OpOverloadPickleData"] | type["_OpOverloadPacketPickleData"],
options: Options,
) -> "_OpPickleData":
if (ops_filter := options.ops_filter) and not ops_filter(name):
from torch._inductor.codecache import BypassFxGraphCache
raise BypassFxGraphCache(f"Unable to pickle non-standard op: {name}")
return datacls(name)
@abstractmethod
def unpickle(self, unpickle_state: _UnpickleState) -> object:
pass
@classmethod
def _lookup_global_by_name(cls, name: str) -> object:
"""
Like `globals()[name]` but supports dotted names.
"""
if "." in name:
mod, rest = name.split(".", 1)
root = globals()[mod]
return cls._getattr_by_name(root, rest)
else:
return globals()[name]
@staticmethod
def _getattr_by_name(root: object, name: str) -> object:
"""
Like `getattr(root, name)` but supports dotted names.
"""
while "." in name:
mod, name = name.split(".", 1)
root = getattr(root, mod)
return getattr(root, name)
class _OpStrPickleData(_OpPickleData):
def __init__(self, name: str) -> None:
self.name = name
def unpickle(self, unpickle_state: _UnpickleState) -> str:
return self.name
class _OpOverloadPickleData(_OpPickleData):
def __init__(self, name: str) -> None:
self.name = name
def unpickle(self, unpickle_state: _UnpickleState) -> torch._ops.OpOverload:
obj = self._lookup_global_by_name(self.name)
if not isinstance(obj, torch._ops.OpOverload):
raise AssertionError(f"Expected OpOverload, got {type(obj)}")
return obj
class _OpOverloadPacketPickleData(_OpPickleData):
def __init__(self, name: str) -> None:
self.name = name
def unpickle(self, unpickle_state: _UnpickleState) -> torch._ops.OpOverloadPacket:
obj = self._lookup_global_by_name(self.name)
if not isinstance(obj, torch._ops.OpOverloadPacket):
raise AssertionError(f"Expected OpOverloadPacket, got {type(obj)}")
return obj
class _OpPrecompiledPickleData(_OpPickleData):
def __init__(self, artifact: AOTCompiledArtifact) -> None:
self.contents = artifact.serialize()
def unpickle(self, unpickle_state: _UnpickleState) -> object:
precompiled_artifact = AOTCompiledArtifact.deserialize(self.contents)
import functools
@functools.wraps(precompiled_artifact)
def wrapped(*args: Any) -> Any:
return precompiled_artifact(*args)
return wrapped
class _OpFunctionPickleData(_OpPickleData):
"""
Supports pickling a set of standard/common functions
These must be prefixed with the full namespace in order to properly
be pickled (i.e `einops.rearrange` and not `from einops import rearrange`)
"""
# Static variable listing supported root names
SUPPORTED_ROOTS = ("builtins.", "math.", "torch.", "operator.", "einops.")
def __init__(self, root: str, name: str) -> None:
self.root = root
self.name = name
def unpickle(self, unpickle_state: _UnpickleState) -> object:
if self.root == "builtins":
return __builtins__.get(self.name) # type: ignore[attr-defined]
elif self.root == "math":
import math
return self._getattr_by_name(math, self.name)
elif self.root == "torch":
return self._getattr_by_name(torch, self.name)
elif self.root == "operator":
import operator
return self._getattr_by_name(operator, self.name)
elif self.root == "einops":
import einops
return self._getattr_by_name(einops, self.name)
else:
raise NotImplementedError
class _GraphPickleData:
def __init__(self, graph: torch.fx.Graph, options: Options) -> None:
self.tracer_cls = graph._tracer_cls
self.tracer_extras = graph._tracer_extras
nodes: dict[torch.fx.Node, _NodePickleData] = {}
for node in graph.nodes:
nodes[node] = _NodePickleData(node, nodes, options)
self.nodes = tuple(nodes.values())
self._codegen = graph._codegen
# Unpickled variables:
# self._used_names = graph._used_names
# -- self._insert = self._root.prepend
# self._len = graph._len
# self._graph_namespace = graph._graph_namespace
# self._owning_module = graph._owning_module
# self._co_fields: Dict[str, Any] = graph._co_fields
# -- self._find_nodes_lookup_table = _FindNodesLookupTable()
def unpickle(
self, gm: torch.fx.GraphModule, unpickle_state: _UnpickleState
) -> torch.fx.Graph:
graph = torch.fx.Graph(gm, self.tracer_cls, self.tracer_extras)
nodes: dict[_NodePickleData, torch.fx.Node] = {}
for nd in self.nodes:
nodes[nd] = nd.unpickle(graph, nodes, unpickle_state)
if hasattr(self, "_codegen"):
graph._codegen = self._codegen
return graph
class _TracingContextPickleData:
@classmethod
def reduce_helper(
cls, pickler: GraphPickler, obj: torch._guards.TracingContext
) -> tuple[
Callable[[Self, _UnpickleState], torch._guards.TracingContext],
tuple[Self, _UnpickleStateToken],
]:
return (
cls.unpickle,
(
cls(obj),
pickler._unpickle_state,
),
)
def __init__(self, context: TracingContext) -> None:
# TODO: Do we really need all of this?
self.module_context = context.module_context
self.frame_summary_stack = context.frame_summary_stack
self.loc_in_frame = context.loc_in_frame
self.aot_graph_name = context.aot_graph_name
self.params_flat = context.params_flat
self.params_flat_unwrap_subclasses = context.params_flat_unwrap_subclasses
self.params_unwrapped_to_flat_index = context.params_unwrapped_to_flat_index
self.output_strides = context.output_strides
self.force_unspec_int_unbacked_size_like = (
context.force_unspec_int_unbacked_size_like
)
# Not saved (because it's difficult and maybe not needed?):
# self.fw_metadata = context.fw_metadata
# self.guards_context = None
# self.global_context = None
# self.fake_mode = None
# self.fakify_first_call = None
# self.hop_dispatch_set_cache = None
# self.tensor_to_context = context.tensor_to_context
def unpickle(self, unpickle_state: _UnpickleState) -> TracingContext:
context = TracingContext(unpickle_state.fake_mode)
context.module_context = self.module_context
context.frame_summary_stack = self.frame_summary_stack
context.loc_in_frame = self.loc_in_frame
context.aot_graph_name = self.aot_graph_name
context.params_flat = self.params_flat
context.params_flat_unwrap_subclasses = self.params_flat_unwrap_subclasses
context.params_unwrapped_to_flat_index = self.params_unwrapped_to_flat_index
context.output_strides = self.output_strides
context.force_unspec_int_unbacked_size_like = (
self.force_unspec_int_unbacked_size_like
)
return context
@@ -0,0 +1,190 @@
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any, TYPE_CHECKING
from torch.fx.graph_module import (
_format_import_block,
GraphModule,
reduce_graph_module,
reduce_package_graph_module,
)
from torch.package import PackageExporter, sys_importer
from ._compatibility import compatibility
if TYPE_CHECKING:
from torch.fx.graph import PythonCode
_use_lazy_graph_module_flag = False
_force_skip_lazy_graph_module_flag = False
@compatibility(is_backward_compatible=False)
@contextmanager
def _force_skip_lazy_graph_module() -> Iterator[None]:
"""
Skip using lazy graph module disregarding the setting of _use_lazy_graph_module.
Use to skip _LazyGraphModule when testing inductor torchscript related backend.
torch.jit.script a _LazyGraphModule results in following error:
https://gist.github.com/shunting314/5143654c8084aed84ecd19b818258a69
"""
try:
global _force_skip_lazy_graph_module_flag
prior = _force_skip_lazy_graph_module_flag
_force_skip_lazy_graph_module_flag = True
yield
finally:
_force_skip_lazy_graph_module_flag = prior
@compatibility(is_backward_compatible=False)
@contextmanager
def _use_lazy_graph_module(should_use: bool) -> Iterator[None]:
try:
global _use_lazy_graph_module_flag
prior = _use_lazy_graph_module_flag
_use_lazy_graph_module_flag = (
should_use and not _force_skip_lazy_graph_module_flag
)
yield
finally:
_use_lazy_graph_module_flag = prior
@compatibility(is_backward_compatible=False)
def _get_graph_module_cls() -> type[GraphModule]:
return _LazyGraphModule if _use_lazy_graph_module_flag else GraphModule
def _make_graph_module(
*args: Any, graph_module_cls: type[GraphModule] | None = None, **kwargs: Any
) -> GraphModule:
if graph_module_cls is None:
graph_module_cls = _get_graph_module_cls()
return graph_module_cls(*args, **kwargs)
@compatibility(is_backward_compatible=False)
class _LazyGraphModule(GraphModule):
"""
The main difference between _LazyGraphModule and GraphModule is how recompile happens.
GraphModule will do a 'recompile' call to generate python code and the forward method when it's
constructed. Later on if the graph get updated, recompile method can be called again to refresh
the saved python code and forward method.
However in some cases especially in inductor, the recompilation can be a waste since we never
check the python code for the graph module or call its forward method. A few more concreate
examples regarding pattern matching fx passes in inductor:
1. some passes will update the graph to be compiled and then call recompile on the GraphModule.
2. some passes will trace small pattern function to search it in the graph being compiled and
replace the match with the traced graph of a replacement function. The pattern graph and
replacement graph are quite small but there are large amount of them. Doing GraphModule.recompile
for them in GraphModule.__init__ is also a waste of time.
However simply skip calling GraphModule.recompile in these scenarios is also dangeruous.
People may want to check the python code or call the GraphModule's forward method for debugging purposes.
The way _LazyGraphModule solves it is, we override the recompile method to just mark the
need for recompilation but does not do the actual recompilation. Later on if people really
access the compiled python code or call the GraphModule's forward method, we do the real
recompilation.
"""
@classmethod
def from_graphmodule(cls, gm: GraphModule) -> GraphModule:
if isinstance(gm, _LazyGraphModule):
return gm
else:
return _LazyGraphModule(gm, gm.graph)
@staticmethod
def force_recompile(gm: GraphModule) -> None:
"""
Sometimes we need force a recompile as a workaround
- we want to do the real recompilation before symbolic_trace to avoid error:
https://gist.github.com/shunting314/75549c2e82ae07ac1139c94a3583d259
"""
if isinstance(gm, _LazyGraphModule):
gm.real_recompile()
def real_recompile(self) -> None:
if self._needs_recompile():
self._real_recompile()
@classmethod
def _needs_recompile(cls) -> bool:
return cls.forward is cls._lazy_forward
def _lazy_forward(self, *args: Any, **kwargs: Any) -> Any:
# Call self.real_recompile() rather than self._real_recompile() here.
# The _lazy_forward method may be saved and call repeatedly.
# Calling self.real_recompile can make sure we skip recompilation if
# we have already done so.
self.real_recompile()
if self._needs_recompile():
raise AssertionError("Recompilation required after real_recompile()")
# call `__call__` rather than 'forward' since recompilation may
# install a wrapper for `__call__` to provide a customized error
# message.
return self(*args, **kwargs)
forward = _lazy_forward
def __reduce_package__(
self, exporter: PackageExporter
) -> tuple[Any, tuple[Any, str]]:
"""
Follow GraphModule.__reduce__ but call 'self._real_recompile' rather
than 'self.recompile' since for a _LazyGraphModule, self.recompile just
mark the need of recompilation and does not return the PythonCode object.
"""
python_code = self._real_recompile()
dict_without_graph = self.__dict__.copy()
dict_without_graph["_graphmodule_cls_name"] = self.__class__.__name__
del dict_without_graph["_graph"]
generated_module_name = f"fx-generated._{exporter.get_unique_id()}"
import_block = _format_import_block(python_code.globals, exporter.importer)
module_code = import_block + self.code
exporter.save_source_string(generated_module_name, module_code)
return (
reduce_package_graph_module,
(dict_without_graph, generated_module_name),
)
def __reduce__(self) -> tuple[Any, tuple[Any, str]]:
"""
Follow GraphModule.__reduce__ but call 'self._real_recompile' rather
than 'self.recompile' since for a _LazyGraphModule, self.recompile just
mark the need of recompilation and does not return the PythonCode object.
"""
python_code = self._real_recompile()
dict_without_graph = self.__dict__.copy()
import_block = _format_import_block(python_code.globals, sys_importer)
del dict_without_graph["_graph"]
return (reduce_graph_module, (dict_without_graph, import_block))
def _real_recompile(self) -> "PythonCode":
return super().recompile()
@classmethod
def recompile(cls) -> None: # pyrefly: ignore[bad-override]
cls.forward = cls._lazy_forward
@property
def code(self) -> str:
self.real_recompile()
return super().code
def __str__(self) -> str:
"""
str(GraphModule) will access the _code attribute. Make sure recompile
happens so _code attribute is available.
"""
self.real_recompile()
return super().__str__()
@@ -0,0 +1,118 @@
from collections import namedtuple
from collections.abc import Callable
from typing import Any, TypeVar
from typing_extensions import NamedTuple
import torch.return_types
from torch.utils._pytree import PyTree, tree_flatten, TreeSpec
FlattenFnSpec = Callable[[PyTree, TreeSpec], list[Any]]
FlattenFnExactMatchSpec = Callable[[PyTree, TreeSpec], bool]
# Keep deprecated alias for backward compatibility
FlattenFuncSpec = FlattenFnSpec # deprecated
FlattenFuncExactMatchSpec = FlattenFnExactMatchSpec # deprecated
SUPPORTED_NODES: dict[type[Any], FlattenFnSpec] = {}
SUPPORTED_NODES_EXACT_MATCH: dict[type[Any], FlattenFnExactMatchSpec | None] = {}
_T = TypeVar("_T")
_K = TypeVar("_K")
_V = TypeVar("_V")
def register_pytree_flatten_spec(
cls: type[Any],
flatten_fn_spec: FlattenFnSpec,
flatten_fn_exact_match_spec: FlattenFnExactMatchSpec | None = None,
) -> None:
SUPPORTED_NODES[cls] = flatten_fn_spec
SUPPORTED_NODES_EXACT_MATCH[cls] = flatten_fn_exact_match_spec
def _deregister_pytree_flatten_spec(
cls: type[Any],
) -> None:
del SUPPORTED_NODES[cls]
del SUPPORTED_NODES_EXACT_MATCH[cls]
def tree_flatten_spec(
pytree: PyTree,
spec: TreeSpec,
) -> list[Any]:
if spec.is_leaf():
return [pytree]
# I guess these exist for BC, FC reasons.
# In general, we should be able to directly
# use pytree tree flattener to flatten them,
# as export serializes the pytree separately.
# Will remove it in follow up PR.
if spec.type in SUPPORTED_NODES:
flatten_fn_spec = SUPPORTED_NODES[spec.type]
child_pytrees = flatten_fn_spec(pytree, spec)
result: list[Any] = []
for child, child_spec in zip(child_pytrees, spec.children()):
flat = tree_flatten_spec(child, child_spec)
result += flat
return result
flat_result, real_spec = tree_flatten(pytree)
if spec != real_spec:
raise RuntimeError(
f"Real spec {real_spec} of object {pytree} is different from expected spec {spec}. "
f"Please file an issue at https://github.com/pytorch/pytorch/issues/new?template=bug-report.yml"
)
return flat_result
def _dict_flatten_spec(d: dict[_K, _V], spec: TreeSpec) -> list[_V]:
return [d[k] for k in spec.context]
def _list_flatten_spec(d: list[_T], spec: TreeSpec) -> list[_T]:
return [d[i] for i in range(spec.num_children)]
def _tuple_flatten_spec(d: tuple[_T, ...], spec: TreeSpec) -> list[_T]:
return [d[i] for i in range(spec.num_children)]
def _namedtuple_flatten_spec(d: NamedTuple, spec: TreeSpec) -> list[Any]:
return [d[i] for i in range(spec.num_children)]
def _dict_flatten_spec_exact_match(d: dict[_K, _V], spec: TreeSpec) -> bool:
return len(d) == spec.num_children
def _list_flatten_spec_exact_match(d: list[_T], spec: TreeSpec) -> bool:
return len(d) == spec.num_children
def _tuple_flatten_spec_exact_match(d: tuple[_T, ...], spec: TreeSpec) -> bool:
return len(d) == spec.num_children
def _namedtuple_flatten_spec_exact_match(d: NamedTuple, spec: TreeSpec) -> bool:
return len(d) == spec.num_children
register_pytree_flatten_spec(dict, _dict_flatten_spec, _dict_flatten_spec_exact_match)
register_pytree_flatten_spec(list, _list_flatten_spec, _list_flatten_spec_exact_match)
register_pytree_flatten_spec(
tuple,
_tuple_flatten_spec,
_tuple_flatten_spec_exact_match,
)
for return_type in torch.return_types.all_return_types:
register_pytree_flatten_spec(
return_type,
_tuple_flatten_spec,
_tuple_flatten_spec_exact_match,
)
register_pytree_flatten_spec(
namedtuple, # type: ignore[arg-type]
_namedtuple_flatten_spec,
_namedtuple_flatten_spec_exact_match,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import sys
from typing import Any
import torch
from torch._logging import LazyString
def lazy_format_graph_code(
name: str, gm: torch.fx.GraphModule, maybe_id: int | None = None, **kwargs: Any
) -> LazyString:
"""
Returns a LazyString that formats the graph code.
"""
def format_name() -> str:
if maybe_id is not None:
return f"{name} {maybe_id}"
else:
return name
if "print_output" not in kwargs:
kwargs["print_output"] = False
if "colored" in kwargs:
try:
if not sys.stdout.isatty():
kwargs["colored"] = False
except AttributeError:
kwargs["colored"] = False
return LazyString(
lambda: _format_graph_code(
f"===== {format_name()} =====\n",
gm.forward.__code__.co_filename,
gm.print_readable(**kwargs),
)
)
def _format_graph_code(name: str, filename: str, graph_str: str) -> str:
"""
Returns a string that formats the graph code.
"""
return f"TRACED GRAPH\n {name} {filename} {graph_str}\n"
def first_call_function_nn_module_stack(graph: torch.fx.Graph) -> dict[str, Any] | None:
"""
Returns the nn_module_stack of the first call_function node.
"""
for node in graph.nodes:
if node.op == "call_function" and "nn_module_stack" in node.meta:
return node.meta["nn_module_stack"]
return None
def get_node_context(node: torch.fx.Node, num_nodes: int = 2) -> str:
"""
Returns a string of the last num_nodes nodes in the graph.
"""
node_contexts = []
cur = node
for _ in range(num_nodes):
# cast to str to handle None return value
node_contexts.append(str(cur.format_node()))
if cur.op == "root":
break
cur = cur.prev
return "\n".join(node_contexts[::-1])
@@ -0,0 +1,40 @@
from typing import Any
from torch.fx.proxy import Proxy
from ._compatibility import compatibility
__all__ = ["annotate"]
@compatibility(is_backward_compatible=False)
def annotate(val: Any, type: type) -> Any:
"""
Annotates a Proxy object with a given type.
This function annotates a val with a given type if a type of the val is a torch.fx.Proxy object
Args:
val (object): An object to be annotated if its type is torch.fx.Proxy.
type (object): A type to be assigned to a given proxy object as val.
Returns:
The given val.
Raises:
RuntimeError: If a val already has a type in its node.
"""
if isinstance(val, Proxy):
if val.node.type:
raise RuntimeError(
f"Tried to annotate a value that already had a type on it!"
f" Existing type is {val.node.type} "
f"and new type is {type}. "
f"This could happen if you tried to annotate a function parameter "
f"value (in which case you should use the type slot "
f"on the function signature) or you called "
f"annotate on the same value twice"
)
else:
val.node.type = type
return val
else:
return val
@@ -0,0 +1,11 @@
# Whether to disable showing progress on compilation passes
# Need to add a new config otherwise will get a circular import if dynamo config is imported here
disable_progress = True
# If True this also shows the node names in each pass, for small models this is great but larger models it's quite noisy
verbose_progress = False
# When True, skip collecting stack traces during tracing. This avoids the cost
# of CapturedTraceback.extract() and symbolization for every FX node, but means
# node.meta["stack_trace"] will be unset, degrading error messages and debugging info.
do_not_emit_stack_traces = False
@@ -0,0 +1,27 @@
import torch.fx
class BackwardState:
"""
BackwardState is used to pass Python hooks from the forwards pass
into the backwards pass in Dynamo+Compiled Autograd.
It is created by TorchDynamo and has special handling there.
Dynamo will pass an empty BackwardState to the forwards, then populate
members on it (via setattr) only after the forwards graph is finished.
Later on, in CompileAutograd we will inline and add the needed guards
on the BackwardState.
BackwardState is identified and has special handling in AOTAutograd.
During AOTAutograd:
1) BackwardState is an input to the forwards graph
2) It must only be used in the backwards
3) It will be empty in the forwards
4) In the forwards we add a wrapper to save it
5) In the backwards it becomes an input
6) There can only be one per graph
BackwardState requires CompiledAutograd.
"""
proxy: torch.fx.Proxy
@@ -0,0 +1,134 @@
import enum
import os
import sys
from torch.utils._config_module import Config, install_config_module
# [@compile_ignored: debug] Fails hard instead of graph breaking on guard on data dependent errors.
no_data_dependent_graph_break = (
os.environ.get("TORCHDYNAMO_NO_DATA_DEPENDENT_GRAPH_BREAK", "0") == "1"
)
# [@compile_ignored: debug] Uses z3 for validating the guard optimizations transformations.
translation_validation = (
os.environ.get("TORCHDYNAMO_TRANSLATION_VALIDATION", "0") == "1"
)
# Timeout (in milliseconds) for z3 finding a solution.
# [@compile_ignored: debug]
translation_validation_timeout = int(
os.environ.get("TORCHDYNAMO_TRANSLATION_VALIDATION_TIMEOUT", "600000")
)
# Disables bisection for translation validation.
#
# Translation validation bisection is enabled by default, if translation validation
# is also enabled. This should help finding guard simplification issues. However,
# since validation uses Z3 for bisecting, it might take a lot of time.
#
# Set this configuration option so as to avoid bisecting.
# [@compile_ignored: debug]
translation_validation_no_bisect = (
os.environ.get("TORCHDYNAMO_TRANSLATION_NO_BISECT", "0") == "1"
)
# Checks whether replaying ShapeEnv events on a freshly constructed one yields
# the a ShapeEnv with the same state. This should be used only in testing.
check_shape_env_recorded_events = False
# TODO: Perhaps consider allowing unions for the configs below (so you can hit
# multiple reps at the same time)
# Give extended debug information if the string representation of a guard
# matches this. For example, set this to "Ne(s0, 10)" and whenever we issue
# this guard, we will generate full Python and C++ backtrace
# [@compile_ignored: debug]
extended_debug_guard_added = os.environ.get(
"TORCHDYNAMO_EXTENDED_DEBUG_GUARD_ADDED", None
)
# Give extended debug information when a particular symbol is allocated. For
# example, set this to "u2" and whenever we create this symbol, we will
# generate full Python and C++ backtrace
# [@compile_ignored: debug]
extended_debug_create_symbol = os.environ.get(
"TORCHDYNAMO_EXTENDED_DEBUG_CREATE_SYMBOL", None
)
# Give extended debug information (C++ backtrace) for all extended debug
# settings as well as errors. The C++ backtrace is slow and very spammy so we
# don't include it by default even when you're requesting extended debug.
# [@compile_ignored: debug]
extended_debug_cpp = os.environ.get("TORCHDYNAMO_EXTENDED_DEBUG_CPP", "") != ""
# Give extended debug information (line of code) when a torch function
# is called during export. This is useful for showing progress and detecting
# where export might be stuck. Currently only works for strict=False.
# [@compile_ignored: debug]
extended_debug_current_loc = (
os.environ.get("TORCHEXPORT_EXTENDED_DEBUG_CURRENT_LOC", "0") == "1"
)
# [@compile_ignored: debug] Show a warning for every specialization
print_specializations = False
# wraps (un)equalities with 'Not' class after recording the correct expression
# in the FX graph. This should incorrectly construct the divisible and replacement
# lists, and incorrectly issue guards.
inject_EVALUATE_EXPR_flip_equality_TESTING_ONLY = False
# [@compile_ignored: debug] Validate that ShapeEnv's version key is updated correctly
validate_shape_env_version_key = False
# If we produce more than this many guards on a symbol, force the symbol to
# get specialized and bail out if this many guards mention this particular
# symbol. This may be slightly more aggressive than the true number of guards
# issued (as we test if we've hit the limit on-the-fly, whereas we may
# do further simplifications at final guard issuance time that make guards
# irrelevant.)
symbol_guard_limit_before_specialize: int | None = None
# This flag changes whether we should use the same symbolic variable to represent input sizes that are the same.
use_duck_shape = True
# Controls the registration of torch.nonzero() on the meta device.
# When True, nonzero returns a tensor with shape (self.numel(), self.dim())
# assuming all elements are none-zero.
# Default is False to prevent unintended registration. Set to True to enable.
meta_nonzero_assume_all_nonzero = False
# Applies size-oblivious reasoning to backed symbols. This allocates a [0, inf] range for backed size symbols,
# and relies on size-oblivious semantics to avoid 0/1 specialization guards by marking them size-like.
# Currently an experimental option for export.
backed_size_oblivious = False
# Skip dtype check in meta registrations. Only used for systems that does its own dtype checking.
skip_dtype_check_in_meta_registrations = False
# Experimental: If True, graph module will register fx metadata during recompile()
enrich_profiler_metadata: bool = Config( # type: ignore[var-annotated]
default=False,
env_name_default="TORCH_ENRICH_RPOFILER_STACK_TRACE",
)
# When True, log a warning instead of raising PendingUnbackedSymbolNotFound exception
# when pending unbacked symbols are not found in returned outputs.
# The worst that can happen is an error somewhere else in the stack where we expect
# to locate an unbacked binding. Or a runtime assertion not being lowered in the output
# code.
soft_pending_unbacked_not_found_error = False
# When True, aggressively return fallback values in guard_or opting into
# guard-free semantics. This optimizes tracing time when symbolic reasoning
# is expensive. Since guard_or_X already have a general path to take, we
# can skip expensive static evaluation and just return the fallback value directly.
# This is usually safe because the fallback represents a valid code path that
# could be taken anyway.
# See AggressiveGuardFreeMode below for valid values.
aggressive_guard_free_semantics = 0
install_config_module(sys.modules[__name__])
class AggressiveGuardFreeMode(enum.IntEnum):
DISABLED = 0
VALUE_RANGE_ANALYSIS = 1 # use bound_sympy before returning fallback
SKIP_RANGE_ANALYSIS = 2 # skip range analysis entirely, just return fallback_value
@@ -0,0 +1,78 @@
from typing import * # noqa: F403
# Python version of c10/core/ConstantSymNodeImpl.cpp
# This needs to exist because the Python version of nested int is not compatible
# with the C++ version of constant symnode.
class ConstantIntNode:
def __init__(self, val: int):
self.val = val
def is_constant(self) -> bool:
return True
def maybe_as_int(self) -> int:
return self.val
def is_int(self) -> bool:
return True
def is_float(self) -> bool:
return False
def is_bool(self) -> bool:
return False
def is_nested_int(self) -> bool:
return False
def clone(self) -> "ConstantIntNode":
return self
def _str(self) -> str:
return str(self.val)
def __str__(self) -> str:
return self._str()
def __repr__(self) -> str:
return self._str()
def _graph_repr(self) -> str:
return self._str()
def add(self, other: Any) -> Any:
return other.add(self)
def sub(self, other: Any) -> Any:
return other.neg().add(self.val)
def mul(self, other: Any) -> Any:
return other.mul(self)
def eq(self, other: Any) -> Any:
return other.eq(self)
def ne(self, other: Any) -> Any:
return other.ne(self)
def gt(self, other: Any) -> Any:
return other.lt(self)
def lt(self, other: Any) -> Any:
return other.gt(self)
def le(self, other: Any) -> Any:
return other.ge(self)
def ge(self, other: Any) -> Any:
return other.le(self)
def is_symbolic(self) -> bool:
return False
def constant_int(self) -> int:
return self.val
def guard_int(self, file: str, line: int) -> int:
return self.val
@@ -0,0 +1,119 @@
import re
from collections.abc import Callable
from typing import Any
import torch
from torch.utils._pytree import tree_flatten_with_path, tree_map
KeyPath = tuple[Any, ...]
NonTensorShapeFn = Callable[[int | float], tuple[Any, ...]]
__all__ = [
"normalize_source_name",
"module_to_nested_dict",
"track_dynamism_across_examples",
"clone_and_convert_to_meta",
]
def normalize_source_name(name: str) -> str:
# Match attribute access like .x and replace with ['x']
return re.sub(r"\.([a-zA-Z_][a-zA-Z0-9_]*)", r"['\1']", name)
def module_to_nested_dict(module: torch.nn.Module) -> dict[str, Any]:
"""Recursively converts an nn.Module into a nested dictionary with explicit 'parameters' and 'modules' keys."""
self_dict: dict[str, Any] = {}
self_dict["_parameters"] = {}
self_dict["_modules"] = {}
for attr_name in dir(module):
try:
if not attr_name.startswith("_") and not callable(
getattr(module, attr_name)
):
attr_value = getattr(module, attr_name)
if (
not isinstance(attr_value, torch.nn.Module)
and isinstance(attr_value, (int, float, torch.Tensor))
and type(attr_value) is not bool
):
self_dict[attr_name] = attr_value
except NotImplementedError:
# Skip attributes that raise NotImplementedError since they won't
# contain any dynamism anyways.
continue
for name, param in module.named_parameters(recurse=False):
self_dict["_parameters"][name] = param
for name, buffer in module.named_buffers(recurse=False):
self_dict["_parameters"][name] = buffer
for name, submodule in module.named_children():
self_dict["_modules"][name] = module_to_nested_dict(submodule)
return self_dict
def track_dynamism_across_examples(
example_inputs: list[Any],
) -> dict[Any, Any]:
"""
This function analyzes a list of example inputs to determine the dynamism of their shapes.
It tracks whether the dimensions of tensors or non-tensor values change across
different examples. The function returns a dictionary where each key represents
a path to a value in the input examples, and the corresponding value is a tuple
indicating which dimensions are dynamic (i.e., change across examples). This
helps in understanding how the structure of data varies across different instances.
"""
tracking: dict[KeyPath, tuple[list[set[Any]], bool]] = {}
for ex in example_inputs:
if "self" in ex and isinstance(ex["self"], torch.nn.Module):
ex["self"] = module_to_nested_dict(ex["self"])
leaves_with_paths, _ = tree_flatten_with_path(ex)
for key_path, value in leaves_with_paths:
if not isinstance(value, (int, float, torch.Tensor)):
continue
if isinstance(value, torch.Tensor):
shape: tuple[int | float, ...] = tuple(value.shape)
is_tensor = True
else:
shape = (value,)
is_tensor = False
if key_path not in tracking:
tracking[key_path] = ([set() for _ in range(len(shape))], is_tensor)
else:
dim_sets, flag = tracking[key_path]
if flag != is_tensor:
pass
while len(dim_sets) < len(shape):
dim_sets.append(set())
for i, dim in enumerate(shape):
tracking[key_path][0][i].add(dim)
output: dict[Any, Any] = {}
for key_path, (dim_sets, _is_tensor) in tracking.items():
final_dyn = tuple(len(s) > 1 for s in dim_sets)
key_str = "L" + "".join(f"{str(k)}" for k in key_path)
key = key_path[0].key # type: ignore[attr-defined]
if key not in output:
output[key] = {}
output[key][key_str] = final_dyn
return output
def clone_and_convert_to_meta(example_input: Any) -> Any:
"""
This function takes a list of example inputs and for each tensor, clones it and converts it to device=meta.
For non-tensor values, it keeps the reference. It uses pytree to handle nested structures recursively.
"""
def transform_fn(value: Any) -> Any:
if isinstance(value, torch.Tensor):
return value.clone().to(device="meta")
return value
return tree_map(transform_fn, example_input)
@@ -0,0 +1,444 @@
"""
Size hinting utilities for symbolic shape expressions.
This module contains the core logic for resolving symbolic expressions to
concrete integer hints. Two strategies are provided:
- _guarding_hint_or_throw_base: strict, only uses backed symbol hints, throws on
unbacked symbols. Use for correctness-critical guarding decisions.
- _optimization_hint_base: permissive, uses heuristics and fallbacks for unbacked
symbols. Use for performance optimization decisions.
"""
from __future__ import annotations
import logging
import sys
from typing import Any, TYPE_CHECKING
import sympy
from torch.utils._sympy.numbers import int_oo
log = logging.getLogger(__name__)
# Maximum number of free symbols in an expression before we skip
# sympy.factor() in optimization_hint process for unbacked.
# Factoring polynomials with many variables is expensive.
SYMPY_FACTOR_MAX_FREE_SYMBOLS = 50
if TYPE_CHECKING:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
def _sympy_subs(expr: sympy.Basic, replacements: dict[sympy.Expr, Any]) -> sympy.Basic:
"""
When the passed replacement symbol v is a string, it is converted to a symbol with name v that
have the same replaced expression integer and nonnegative properties.
"""
def to_symbol(replaced: sympy.Expr, replacement: sympy.Expr | str) -> sympy.Symbol:
if not isinstance(replaced, sympy.Expr):
raise AssertionError(
f"Expected sympy.Expr key, got {type(replaced)}: {replaced}"
)
if isinstance(replacement, str):
return sympy.Symbol(
replacement,
integer=replaced.is_integer, # type: ignore[attr-defined]
nonnegative=replaced.is_nonnegative, # type: ignore[attr-defined]
)
else:
return replacement
# xreplace is faster than subs, but is way more picky
return sympy.sympify(expr).xreplace(
{k: to_symbol(k, v) for k, v in replacements.items()}
)
def _maybe_realize_expr(
expr: sympy.Basic, nan_fallback: int | None
) -> int | bool | None:
"""
Handle special sympy values in hinting APIs.
Returns:
- True/False for sympy.true/sympy.false (preserves bool type)
- Raises ValueError for complex numbers
- sys.maxsize for positive infinity
- -sys.maxsize for negative infinity
- fallback for NaN
- None if no special handling needed
"""
if expr is sympy.true:
return True
if expr is sympy.false:
return False
try:
return int(expr)
except (TypeError, ValueError):
pass
if isinstance(expr, sympy.Expr):
if expr.has(sympy.I):
raise ValueError(
f"_maybe_realize_expr received a complex expression: {expr}. "
"Tensor dimensions cannot be complex numbers."
)
if expr in (int_oo, sympy.oo):
return sys.maxsize
if expr in (-int_oo, -sympy.oo):
return -sys.maxsize
if nan_fallback is not None and (expr is sympy.nan or expr.has(sympy.nan)):
return nan_fallback
return None
def _guarding_hint_or_throw_base(
shape_env: ShapeEnv,
expr: sympy.Expr | sympy.Basic | int | bool,
precomputed_replacements: dict[sympy.Expr, sympy.Symbol],
) -> int | bool:
"""
Return a concrete integer hint for an expression that is safe to use for guarding.
This function evaluates the expression using only backed-symbols hints. Unlike
_optimization_hint_base(), this function does NOT use heuristics or fallback values
for unbacked symbols.
Use this when you need a hint value that will be used for a guarding decision.
Args:
shape_env: The ShapeEnv instance.
expr: A sympy expression or integer to evaluate.
precomputed_replacements: Precomputed replacements for PRECOMPUTED_SIZE symbols.
Returns:
The concrete integer value of the expression based on backed symbol hints.
Raises:
GuardOnDataDependentSymNode: If the expression contains unbacked symbols
(data-dependent values) that cannot be resolved to concrete values.
See Also:
_optimization_hint_base: For cases where fallback/heuristic values are acceptable
for unbacked symbols.
"""
from torch.fx.experimental.symbolic_shapes import (
has_free_unbacked_symbols,
symbol_is_type,
SymT,
)
# sympy.expand() doesn't work with boolean expressions like Or/And
if isinstance(expr, sympy.Expr):
expr = sympy.expand(expr).xreplace(shape_env.replacements)
else:
expr = sympy.sympify(expr).xreplace(shape_env.replacements)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
result = _maybe_realize_expr(expr, None)
if result is not None:
return result
if not isinstance(expr, sympy.Basic):
raise RuntimeError("isinstance(expr, sympy.Basic)", expr, type(expr))
if any(symbol_is_type(s, SymT.PRECOMPUTED_SIZE) for s in expr.free_symbols): # type: ignore[attr-defined]
expr = _sympy_subs(expr, precomputed_replacements)
# TODO do we need sympy_subs, or just xreplace
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
if has_free_unbacked_symbols(expr):
# Note: we could do better here and call
# _maybe_evaluate_static(orig_expr, compute_hint=True)
# but is it worth the overhead? probably not.
raise shape_env._make_data_dependent_error(expr, expr)
result = _maybe_realize_expr(expr, None)
if result is None:
raise RuntimeError("unexpected None!", expr)
return result
def _get_unbacked_replacements(shape_env: ShapeEnv) -> dict[sympy.Expr, sympy.Expr]:
"""Builds a mapping from unbacked expressions to canonical equivalents
using a union-find algorithm over deferred runtime asserts.
Used by optimization_hint to resolve unbacked symbols to consistent values."""
from collections import defaultdict
from torch.fx.experimental.symbolic_shapes import has_free_unbacked_symbols
from torch.utils._ordered_set import OrderedSet
if shape_env._unbacked_replacements is not None:
return shape_env._unbacked_replacements
class CanonicalExprFinder:
"""
A disjoint-set/union-find data structure that can return the
"canonical" expression for a group of equivalent expressions.
- The canonical expression must come from the input eq_graph.
- The heuristics used to choose a leader determines which
expression becomes the canonical expression.
"""
def __init__(self, eq_graph: dict[sympy.Expr, OrderedSet[sympy.Expr]]):
self.eq_graph = eq_graph
self.expressions = list(eq_graph.keys())
self.reverse_expressions = {
expr: i for i, expr in enumerate(self.expressions)
}
self.leader = list(range(len(self.expressions)))
self.size = [1] * len(self.expressions)
self._build_canonical_expr_mapping()
def _build_canonical_expr_mapping(self):
for expr, edges in self.eq_graph.items():
for adj in edges:
self.union_expr(expr, adj)
def union_expr(self, a: sympy.Expr, b: sympy.Expr):
return self.union(self.reverse_expressions[a], self.reverse_expressions[b])
def union(self, a: int, b: int):
rootA = self.find(a)
rootB = self.find(b)
if rootA == rootB:
return False
leader, other = self.choose_leader(rootA, rootB)
self.leader[other] = leader
self.size[leader] += self.size[other]
return True
def find_expr(self, expr: sympy.Expr):
parent = self.find(self.reverse_expressions[expr])
return self.expressions[parent]
def find(self, x: int):
if self.leader[x] != x:
self.leader[x] = self.find(self.leader[x])
return self.leader[x]
def choose_leader(self, a: int, b: int):
"""
The leader will become the canonical expression.
Returns a (leader, follower) tuple.
Heuristics:
1. Backed expression or constants preferred over unbacked expr
2. Simpler sub-expr when one contains the other
3. Higher frequency across equalities from deferred runtime assertions
4. Size of the set
5. Fallback to sympy.Basic.compare
"""
def _choose(x: int, y: int) -> bool:
lhs, rhs = self.expressions[x], self.expressions[y]
any_unbacked_lhs = has_free_unbacked_symbols(lhs)
any_unbacked_rhs = has_free_unbacked_symbols(rhs)
if any_unbacked_lhs != any_unbacked_rhs:
return bool(any_unbacked_rhs)
if lhs.has(rhs):
return False
elif rhs.has(lhs):
return True
degrees_lhs = len(self.eq_graph[lhs])
degrees_rhs = len(self.eq_graph[rhs])
if degrees_lhs != degrees_rhs:
return degrees_lhs > degrees_rhs
if self.size[x] != self.size[y]:
return self.size[x] > self.size[y]
return lhs.compare(rhs) == -1
if _choose(a, b):
return a, b
return b, a
# Build an undirected graph using ShapeEnv's deferred runtime assertions.
shape_env._equality_graph = defaultdict(OrderedSet)
for assertions in shape_env.deferred_runtime_asserts.values():
for assertion in assertions:
if not isinstance(assertion.expr, sympy.Equality):
continue
lhs = sympy.sympify(assertion.expr.lhs)
rhs = sympy.sympify(assertion.expr.rhs)
shape_env._equality_graph[lhs].add(rhs)
shape_env._equality_graph[rhs].add(lhs)
uf = CanonicalExprFinder(shape_env._equality_graph)
shape_env._unbacked_replacements = {}
for expr in shape_env._equality_graph:
canonical_expr = uf.find_expr(expr)
if expr != canonical_expr:
shape_env._unbacked_replacements[expr] = canonical_expr
return shape_env._unbacked_replacements
def _sub_unbacked_exprs(shape_env: ShapeEnv, expr: sympy.Expr) -> sympy.Expr:
"""Substitute unbacked expressions with canonical equivalents.
Used by optimization_hint to maximize consistency when hinting unbacked symbols."""
replacements = _get_unbacked_replacements(shape_env)
# consider making this threshold configurable
sub_cnt_limit = 30
sub_cnt = 0
while sub_cnt < sub_cnt_limit:
new_expr = expr.subs(replacements)
if new_expr == expr:
break
if len(new_expr.free_symbols) <= SYMPY_FACTOR_MAX_FREE_SYMBOLS:
expr = sympy.factor(new_expr)
else:
expr = new_expr
sub_cnt += 1
else:
log.warning("Substitution limit (%d) reached w/ %s", sub_cnt_limit, expr)
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
expr = _sympy_subs(expr, shape_env.var_to_hint_override)
return expr
def _optimization_hint_base(
shape_env: ShapeEnv,
expr: sympy.Expr | int,
precomputed_replacements: dict[sympy.Expr, sympy.Symbol],
fallback: int | None = None,
) -> int:
"""
Return a concrete integer hint for an expression using heuristics.
This function should be used for non-guarding based optimizations.
It will hint unbacked symbols using user provided optimization hints.
If not provided, fallback will be used along with some heuristics
that try to maximize consistency with the shape environment.
Args:
shape_env: The ShapeEnv instance.
expr: A sympy expression or integer to evaluate.
precomputed_replacements: Precomputed replacements for PRECOMPUTED_SIZE symbols.
fallback: Fallback value for unbacked symbols. If None, reads from config.
Returns:
A concrete integer hint for the expression.
"""
from torch.fx.experimental.symbolic_shapes import (
has_free_unbacked_symbols,
symbol_is_type,
SymT,
)
# Read config at call time to respect runtime patches (e.g., in tests)
if fallback is None:
from torch._inductor.config import unbacked_symint_fallback
fallback = unbacked_symint_fallback
# to have expanded (Identity free) expr stored in original
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
original = expr
# sympy.expand() doesn't work with boolean expressions like Or/And
if isinstance(expr, sympy.Expr):
expr = expr.xreplace(shape_env.replacements)
else:
expr = sympy.sympify(expr).xreplace(shape_env.replacements)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
# Replace backed symbols with their hints, leaving unbacked symbols alone.
result = _maybe_realize_expr(expr, None)
if result is not None:
return result
if not isinstance(expr, sympy.Expr):
raise RuntimeError("isinstance(expr, sympy.Expr)", expr)
if any(symbol_is_type(s, SymT.PRECOMPUTED_SIZE) for s in expr.free_symbols): # type: ignore[attr-defined]
expr = _sympy_subs(expr, precomputed_replacements)
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
expr = _sympy_subs(expr, shape_env.var_to_hint_override)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
# If unbacked symbols remain, try to substitute them using heuristics
# that maximize consistency with the shape environment.
if has_free_unbacked_symbols(expr):
# Make sure to substitute with the factored version
# e.g. 10*(s0 + u0) instead of 10*s0 + 10*u0
if (
isinstance(original, sympy.Expr)
and len(original.free_symbols) <= SYMPY_FACTOR_MAX_FREE_SYMBOLS
):
original = sympy.factor(original)
expr = _sub_unbacked_exprs(shape_env, original)
# For multiple expressions that depend on an unbacked symint,
# we want to compute them consistently for a size hint we have chosen.
# So, recursively compute expressions via size hints of contained symbols.
# For example: u1 * u2 - 10 ==> fallback * fallback - 10
if not isinstance(expr, sympy.Expr):
raise RuntimeError(f"Expected sympy Expr, got {type(expr)}: {expr}")
free_symbols = expr.free_symbols
# Constrain fallback per-symbol based on var_to_range bounds
size_dict = {}
for s in free_symbols:
sym_fallback = fallback
vr = shape_env.var_to_range.get(s, None)
if vr is not None:
if isinstance(vr.lower, (int, sympy.Integer)):
sym_fallback = max(sym_fallback, int(vr.lower))
if isinstance(vr.upper, (int, sympy.Integer)):
sym_fallback = min(sym_fallback, int(vr.upper))
size_dict[s] = sym_fallback
try:
final_result = expr.subs(size_dict)
except ZeroDivisionError:
# Expressions like ModularIndexing(x, u1, 4) crash during subs()
# when u1 is substituted with 0, because sympy eagerly evaluates
# (x // 0) % 4. This can happen when an unbacked symbol with
# var_to_range lower=0 is used as a divisor (e.g. from
# _dynamic_reshape_indexer) and the fallback also maps to 0.
# Return fallback in that case.
return fallback if fallback is not None else 0
final_result = _maybe_realize_expr(final_result, fallback)
if final_result is None:
raise RuntimeError(f"Failed to realize expression to int: {expr}")
return final_result
@@ -0,0 +1,394 @@
# mypy: allow-untyped-defs
import re
from collections.abc import Callable
import torch.fx
from torch.fx.node import map_arg
from torch.fx.passes.split_module import split_module
__all__ = [
"FoldedGraphModule",
"get_unique_attr_name_in_module",
"split_const_subgraphs",
]
class FoldedGraphModule(torch.fx.GraphModule):
"""
FoldedGraphModule is a GraphModule which also contains another
`const_subgraph_module` representing a subgraph which has all const attr
inputs and which can be run once before running the main standard
`graph`. The `const_output_names` are the ordered list names of attrs which
represent what each respective output from the const_subgraph should be set
on which attrs.
"""
def __init__(
self,
root: torch.nn.Module,
graph: torch.fx.Graph,
const_subgraph: torch.fx.Graph | None = None,
fx_const_folded_attrs_name: str | None = None,
device_for_folded_attrs: str = "cuda",
):
super().__init__(root, graph)
self.const_subgraph_module = (
None
if const_subgraph is None
else torch.fx.GraphModule(root, const_subgraph)
)
self.has_folding_been_run = False
self.fx_const_folded_attrs_name = fx_const_folded_attrs_name
self.device_for_folded_attrs = device_for_folded_attrs
def __call__(self, *args, **kwargs):
if not self.has_folding_been_run:
self.run_folding()
return super().__call__(*args)
def run_folding(self):
# If there's no const subgraph module or attr output names to use, return
# early as there is no const folding to perform.
if (
self.const_subgraph_module is None
or self.fx_const_folded_attrs_name is None
):
return
if self.has_folding_been_run:
raise AssertionError("Folding has already been run")
self.has_folding_been_run = True
# Actually run const folding subgraph. Note that single attr const fold
# subgraphs output a single Tensor while multiple outputs are returned as
# Tuple[Tensor,].
folded_attrs = self.const_subgraph_module()
def _create_param(i):
return torch.nn.Parameter(
i.detach().clone()
if not isinstance(i, int)
else torch.Tensor([i]).to(device=self.device_for_folded_attrs),
requires_grad=i.requires_grad if isinstance(i, torch.Tensor) else False,
)
params = (
torch.nn.ParameterList([_create_param(i) for i in folded_attrs])
if isinstance(folded_attrs, tuple)
else _create_param(folded_attrs)
)
setattr(self, self.fx_const_folded_attrs_name, params)
def _inline_module(
gm: torch.fx.GraphModule, inline_mod_name: str, run_dce: bool = True
) -> dict[torch.fx.Node, torch.fx.Node]:
"""
Given `gm` and some graph module which is called with target name `inline_mod_name`,
this helper will inline all of the nodes from that called graph module into `gm`.
Returns a mapping from subgraph nodes to the newly created/mapped nodes in gm.
"""
# Fetch the inner graph module that we want to inline inside `gm`.
inline_mod = dict(gm.named_modules())[inline_mod_name]
if not isinstance(inline_mod, torch.fx.GraphModule):
raise AssertionError(f"Expected GraphModule, got {type(inline_mod)}")
call_mod_node_to_replace = None
for node in gm.graph.nodes:
if node.op == "call_module" and node.target == inline_mod_name:
call_mod_node_to_replace = node
break
if call_mod_node_to_replace is None:
raise AssertionError(f"Could not find call_module node for {inline_mod_name}")
# Now actually do the swap. Note that we have to keep track of new nodes that are
# copied into `gm` -- we do this via replacement_mapping.
call_mod_args = call_mod_node_to_replace.args
call_mod_kwargs = call_mod_node_to_replace.kwargs
replacement_mapping: dict[torch.fx.Node, torch.fx.Node] = {}
ph_count = 0
def replacement_fn(node):
new_node = replacement_mapping[node]
new_node.meta = node.meta.copy()
return new_node
for inline_node in inline_mod.graph.nodes:
if inline_node.op == "placeholder":
replacement_mapping[inline_node] = (
call_mod_kwargs[inline_node.name]
if inline_node.name in call_mod_kwargs
else call_mod_args[ph_count]
)
ph_count += 1
continue
if inline_node.op == "output":
outputs = inline_node.args[0]
output_replacements = map_arg(outputs, replacement_fn)
# If output is a tuple, we need to handle getitem users specially.
# Capture users before replace_all_uses_with modifies them.
getitem_users: list[torch.fx.Node] = []
if isinstance(output_replacements, (list, tuple)):
import operator
getitem_users = [
user
for user in call_mod_node_to_replace.users
if user.op == "call_function"
and user.target is operator.getitem
and isinstance(user.args[1], int)
]
call_mod_node_to_replace.replace_all_uses_with(output_replacements)
# Inline getitem nodes that now index into the tuple literal
for user in getitem_users:
idx = user.args[1]
if not isinstance(idx, int):
raise AssertionError(f"Expected int index, got {type(idx)}")
user.replace_all_uses_with(output_replacements[idx])
gm.graph.erase_node(user)
replacement_mapping[user] = output_replacements[idx]
continue
with gm.graph.inserting_before(call_mod_node_to_replace):
new_node = gm.graph.node_copy(inline_node, replacement_fn)
replacement_mapping[inline_node] = new_node
# Explicitly remove the module that was just inlined,
# this module may contain impure ops so cannot be dead code eliminated,
# this module is unneeded as it's just inlined back to main graph.
gm.graph.erase_node(call_mod_node_to_replace)
if run_dce:
gm.graph.eliminate_dead_code()
return replacement_mapping
def get_unique_attr_name_in_module(mod_traced: torch.fx.GraphModule, name: str) -> str:
"""
Make sure the name is unique (in a module) and can represents an attr.
"""
# Delete all characters that are illegal in a Python identifier.
name = re.sub("[^0-9a-zA-Z_]+", "_", name)
if name[0].isdigit():
name = f"_{name}"
# Now make sure it is in fact unique to the module by incrementing suffix value.
while hasattr(mod_traced, name):
match = re.match(r"(.*)_(\d+)$", name)
if match is None:
name = name + "_1"
else:
base, num = match.group(1, 2)
name = f"{base}_{int(num) + 1}"
return name
def split_const_subgraphs(
module: torch.nn.Module | torch.fx.GraphModule,
skip_folding_node_fn: Callable[[torch.fx.Node], bool] | None = None,
device_for_folded_attrs: str = "cpu",
) -> FoldedGraphModule:
"""
Looks through `module` for any nodes that have all constant attribute inputs
and separates them out into their own constant subgraph, and returns a
FoldedGraphModule which runs that constant subgraph on the first run to set
attributes on the module prior to running the non-constant portion of the
graph.
"""
import sympy
if not isinstance(module, torch.fx.GraphModule):
mod_traced = torch.fx.symbolic_trace(module)
else:
mod_traced = module
def _subgraph_has_impure_ops(module: torch.fx.GraphModule) -> bool:
"""
Return True if a GraphModule type subgraph contains any impure op, else False.
"""
if not isinstance(module, torch.fx.GraphModule):
raise AssertionError(
"caller should only pass GraphModule to subgraph_has_impure_ops check"
)
for node in module.graph.nodes:
if node.op == "call_function" and node.is_impure():
return True
if (
node.op == "call_module"
# pyrefly: ignore [not-callable]
and (submodule := module.get_submodule(node.target))
and isinstance(submodule, torch.fx.GraphModule)
):
return _subgraph_has_impure_ops(submodule)
return False
# Build up a list of const_nodes, defined as nodes that are themselves
# get_attrs, or have all get_attr or other constant node inputs.
const_nodes: set[torch.fx.Node] = set()
found_const_folding = False
for node in mod_traced.graph.nodes:
# Skip over placeholders/outputs because they can't be const folded and
# we don't want to add tags to them.
if node.op in {"placeholder", "output"}:
continue
# If the node itself is constant, or all of its inputs are constant,
# then tag it as constant.
if node.op != "get_attr" and not set(node.all_input_nodes).issubset(
const_nodes
):
continue
# If provided skip folding function says to skip, then skip.
if skip_folding_node_fn and skip_folding_node_fn(node):
continue
# Skip folding side-effectful functions
if node.is_impure():
continue
# Skip folding nodes that have symbolic fill_value
if isinstance(node.kwargs.get("fill_value", None), sympy.Expr):
continue
# Skip folding submodules that have impure ops
if (
node.op == "call_module"
# pyrefly: ignore [not-callable]
and (target_mod := mod_traced.get_submodule(node.target))
and isinstance(target_mod, torch.fx.GraphModule)
and _subgraph_has_impure_ops(target_mod)
):
continue
# Must be a constant foldable node at this point.
const_nodes.add(node)
if node.op != "get_attr":
found_const_folding = True
# If we did not find any const folding then return early without a const fold subgraph.
if not found_const_folding:
return FoldedGraphModule(mod_traced, mod_traced.graph)
# Partition the module into two: submod_0 for constant folding subgraph, and
# submod_1 for the rest.
def mod_partition(node: torch.fx.Node):
return 0 if node in const_nodes else 1
split = split_module(mod_traced, module, mod_partition)
const_mod_name, non_const_mod_name = "submod_0", "submod_1"
# Safely get submod_1 in case there are no non-const nodes
const_gm, non_const_gm = split.submod_0, getattr(split, non_const_mod_name, None)
# The module that a call_module node refers to gets copied to submodules during split.
# The path to the module also gets inlined, i.e. mod.a.b -> mod_a_b. Here we need to
# attach inlined modules to `split` as it's the owning module now.
for node in non_const_gm.graph.nodes if non_const_gm else []:
if node.op == "call_module":
setattr(split, node.target, getattr(non_const_gm, node.target))
for node in const_gm.graph.nodes:
if node.op == "call_module":
setattr(split, node.target, getattr(const_gm, node.target))
# split_module currently does not use get_attrs for attrs. Instead it passes
# them in as args from the parent module, which used get_attrs. Here we set
# them as get_attrs inside const_gm, allowing for running folding without
# somehow a priori knowing the attrs that should be passed as args. We can
# unconditionally do this for all placeholders because we know all
# placeholders to const_gm must be constants accessible via get_attr.
call_const_gm_args = None
for node in split.graph.nodes:
if node.op == "call_module":
if node.target == const_mod_name:
call_const_gm_args = node.args
break
if call_const_gm_args is None:
raise AssertionError("Could not find call_module node for const_gm")
# Here we do the actual replacement of placeholders to get_attrs. Note that here we
# set the const_gm.graph into a new root_const_gm with split as the root module,
# because we are fetching attributes directly from the root module, instead of
# fetching them from const_gm. Example: The const_gm must have some format like:
# graph():
# %inp : [num_users=1] = placeholder[target=const_inp]
# %add : [num_users=1] = call_function[target=operator.add](args = (%inp, %inp), kwargs = {})
# return add
# We replace that with the following, which does not have any placeholders:
# graph():
# %inp_1 : [num_users=1] = get_attr[target=const_inp]
# %add : [num_users=1] = call_function[target=operator.add](args = (%inp_1, %inp_1), kwargs = {})
# return add
root_const_gm = torch.fx.GraphModule(split, const_gm.graph)
# The order of placeholders in the const_gm graph should match the order of
# args in the outer module, so we can simply use an index for the
# placeholder mapping
ph_idx = 0
for node in root_const_gm.graph.nodes:
if node.op == "output":
multiple_outputs = isinstance(node.args[0], tuple)
continue
if node.op != "placeholder":
continue
if ph_idx >= len(call_const_gm_args):
raise AssertionError(
f"Placeholder index {ph_idx} out of range for args "
f"(len={len(call_const_gm_args)})"
)
in_node = call_const_gm_args[ph_idx]
ph_idx += 1
if in_node.op != "get_attr":
raise AssertionError(f"Expected get_attr, got {in_node.op}")
with root_const_gm.graph.inserting_before(node):
new_node = root_const_gm.graph.get_attr(in_node.target)
new_node.meta = node.meta.copy()
node.replace_all_uses_with(new_node)
root_const_gm.graph.erase_node(node)
if "multiple_outputs" not in locals():
raise AssertionError("multiple_outputs not set in loop")
# Now find the call to const_gm inside split, and replace it with a getattr to the
# folded tensor(s) that result from constant folding. Note that we don't need to
# worry about whether this is one or more tensors because the original graph
# correctly uses getitem to extract individual tensors if there are multiple folded.
fx_const_folded_attrs_name = get_unique_attr_name_in_module(
mod_traced, "_FX_CONST_FOLDED_ATTRS"
)
setattr(
split,
fx_const_folded_attrs_name,
torch.nn.ParameterList() if multiple_outputs else torch.nn.Parameter(), # type: ignore[possibly-undefined]
)
for node in split.graph.nodes:
if node.op == "call_module" and node.target == const_mod_name:
with node.graph.inserting_before(node):
folded_attrs = node.graph.get_attr(fx_const_folded_attrs_name)
folded_attrs.meta = node.meta.copy()
node.replace_all_uses_with(folded_attrs)
break
# Finally, inline the non-constant submod (if it exists) into the split submod.
# This is so that the original caller who may have passed in a graph module will
# get back out a graph module whose graph is traced to the same granularity.
if hasattr(split, non_const_mod_name):
_inline_module(split, non_const_mod_name)
split.graph.eliminate_dead_code()
return FoldedGraphModule(
split,
split.graph,
root_const_gm.graph,
fx_const_folded_attrs_name,
device_for_folded_attrs,
)
@@ -0,0 +1,33 @@
from collections.abc import Sequence
import torch.fx as fx
__all__ = ["set_trace"]
def set_trace(gm: fx.GraphModule) -> fx.GraphModule:
"""
Sets a breakpoint in `gm`'s generated python code. It drops into pdb when
`gm` gets run.
Args:
gm: graph module to insert breakpoint. It is then recompiled for it to
take effect.
Returns:
the `gm` with breakpoint inserted.
"""
def insert_pdb(body: Sequence[str]) -> list[str]:
return ["import pdb; pdb.set_trace()\n", *body]
with gm.graph.on_generate_code(
make_transformer=lambda cur_transform: (
# new code transformer to register
lambda body: (insert_pdb(cur_transform(body) if cur_transform else body))
)
):
gm.recompile()
return gm
@@ -0,0 +1,180 @@
# mypy: allow-untyped-defs
import itertools
import operator
import torch
from torch.fx._symbolic_trace import symbolic_trace
from torch.fx.node import Node
from torch.fx.passes.tools_common import legalize_graph
def split_result_tensors(
result: torch.Tensor, inputs: list[torch.Tensor]
) -> tuple[torch.Tensor, ...]:
"""
A free function for use in the merge_matmul graph transformation below that
splits the output from a merged matmul into the individual results for each
input tensor.
Arguments:
result: The merged matmul result tensor.
inputs: The list of inputs that were merged into one for the matmul.
Returns:
List of matmul results for each input tensor.
"""
# When fx tracer is running, x.shape[0] will be torch.fx.Attribute but we
# need an int even when tracing
if isinstance(result, torch.fx.Proxy):
splits = [0] * len(inputs)
else:
splits = [x.shape[0] for x in inputs]
return torch.split(result, splits)
def may_depend_on(a: Node, b: Node, search_depth: int = 6):
"""
Determine if one node depends on another in a torch.fx.Graph.
Arguments:
a: The node that may have a dependency on b.
b: The node that a may have a dependency on.
search_depth: In the case of an indirect dependency, this function
searches upto this many nodes away in search of a
data dependency. If none is found, the function
makes the conservative assumption that there is a
dependency.
Returns:
True if a may depend on b, False if it definitely does not.
"""
# Equivalence is defined as dependence.
if a == b:
return True
# If a has no inputs, it cannot depend on b.
if len(a.all_input_nodes) == 0:
return False
# If the search depth has been exhausted and no conclusion has been
# reached, assume that there is a data dependency.
if search_depth == 0:
return True
# Recursively check all inputs of a.
for inp in a.all_input_nodes:
if may_depend_on(inp, b, search_depth - 1):
return True
return False
def are_nodes_independent(nodes: list[Node]):
"""
Check if all of the given nodes are pairwise-data independent.
Arguments:
nodes: The nodes to check for data dependencies.
Returns:
True if any pair in nodes has a data dependency.
"""
# For each pair in nodes:
for i, j in itertools.combinations(nodes, 2):
if may_depend_on(i, j) or may_depend_on(j, i):
return False
return True
def merge_matmul(in_mod: torch.nn.Module):
"""
A graph transformation that merges matrix multiplication operations that share the same right-hand
side operand into one large matrix multiplication.
::
____ _________ _________
---- | | | | M| A * C |
M| A | T| B | * K| C | = |---------|
---- , | | | | T| B * C |
K ---- --------- ---------
K R R
"""
gm = symbolic_trace(in_mod)
rhs_users: dict[Node, list[Node]] = {}
lhs_users: dict[Node, list[Node]] = {}
# Populate rhs_users and lhs_users - maps from LHS/RHS matrix multiply operands to
# the matmul of which they are the LHS/RHS.
for node in gm.graph.nodes:
if node.op != "call_function" or node.target is not torch.matmul:
continue
lhs, rhs = node.args
# TODO: Properly handle aliasing caused by get_attr. For now,
# use the attribute name as the operand if the node is a
# get_attr.
lhs = lhs.target if lhs.op == "get_attr" else lhs
rhs = rhs.target if rhs.op == "get_attr" else rhs
lhs_users.setdefault(lhs, []).append(node)
rhs_users.setdefault(rhs, []).append(node)
for rhs, mms in rhs_users.items():
# There must be at least matmuls for a merge to make sense.
if len(mms) < 2:
continue
# All matmuls must not depend on each other directly or indirectly
# in order for the merge to be possible.
if not are_nodes_independent(mms):
continue
lhs_vals = [mm.args[0] for mm in mms]
# Merge the matmul.
# Collect a list of LHS operands and the single RHS operand.
lhs = [gm.graph.get_attr(l) if isinstance(l, str) else l for l in lhs_vals]
rhs = gm.graph.get_attr(rhs) if isinstance(rhs, str) else rhs
# Concatenate all the LHS operands.
merge_mm_cat = gm.graph.call_function(torch.cat, (lhs,), {})
# Multiply the concatenated LHS operands with the one RHS. This will produce
# the same results as all the individual matmuls involving rhs in the original graph,
# but they will all be concatenated together.
merge_mm = gm.graph.call_function(
torch.matmul,
(
merge_mm_cat,
rhs,
),
{},
)
# Split the result of the merged matmul using the shapes of the LHS operands
# to ascertain how large each chunk should be.
merge_mm_split = gm.graph.call_function(
split_result_tensors, (merge_mm, lhs), {}
)
merge_mm_res = [
gm.graph.call_function(operator.getitem, (merge_mm_split, out), {})
for out in range(len(lhs))
]
# Replace all uses of the original, unmerged matmuls with the equivalent split chunk from the merged matmul.
for old, new in zip(mms, merge_mm_res):
old.replace_all_uses_with(new)
gm.graph.erase_node(old)
# All of the new nodes created above were inserted at the end, so we need to sort
# the nodes topologically to make sure all definitions precede uses.
legalize_graph(gm)
gm.recompile()
gm.graph.lint()
return gm
@@ -0,0 +1,330 @@
# mypy: allow-untyped-defs
import builtins
import functools
import warnings
from collections.abc import Callable
from typing import Any
import torch
import torch.fx
def embedding_override(self, input):
return torch.empty(*input.shape, self.weight.shape[-1], device="meta")
def nn_layernorm_override(self, input):
return input
def torch_relu_override(x):
return x
def torch_nn_relu_override(self, x):
return x
def functional_relu_override(x, inplace=False):
if inplace:
raise AssertionError(
"dont support inplace functional.relu for metatensor analysis"
)
return x
def torch_where_override(condition, x, y):
# torch.where returns the broadcasted tensor of condition, x, and y,
# so hack it by using addition
return condition.to(device="meta") + x.to(device="meta") + y.to(device="meta")
def torch_abs_override(input, *, out=None):
if out is not None:
raise AssertionError("Dont support in-place abs for MetaTensor analysis")
return input
manual_meta_overrides: dict[Callable, Callable] = {
torch.nn.Embedding: embedding_override,
torch.nn.LayerNorm: nn_layernorm_override,
torch.relu: torch_relu_override,
torch.nn.functional.relu: functional_relu_override,
torch.nn.ReLU: torch_nn_relu_override,
torch.where: torch_where_override,
torch.abs: torch_abs_override,
}
def gen_constructor_wrapper(target):
@functools.wraps(target)
def wrapper(*args, **kwargs):
proxy = None
def check_has_proxy(v):
if isinstance(v, torch.fx.Proxy):
nonlocal proxy
proxy = v
torch.fx.node.map_aggregate(args, check_has_proxy)
torch.fx.node.map_aggregate(kwargs, check_has_proxy)
if proxy is not None:
return proxy.tracer.create_proxy("call_function", target, args, kwargs)
else:
return target(*args, **kwargs)
return wrapper, target
class MetaProxy(torch.fx.Proxy):
def install_tensor_meta(self, tensor_meta):
self._tensor_meta = tensor_meta
def size(self, dim=None):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.size(*[dim] if dim else [])
return self.tracer.create_proxy(
"call_method", "size", (self, dim) if dim else (self,), {}
)
def dim(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.dim()
return self.tracer.create_proxy("call_method", "dim", (self,), {})
@property
def shape(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.shape
return self.tracer.create_proxy(
"call_function", builtins.getattr, (self, "shape"), {}
)
@property
def dtype(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.dtype
return self.tracer.create_proxy(
"call_function", builtins.getattr, (self, "dtype"), {}
)
@property
def device(self):
# Hack so we can track when devices are used. During meta-tensor propagation,
# replace these values with a constant 'meta'
return MetaDeviceAttribute(self, "device")
def __getattr__(self, k):
if k == "_tensor_meta":
return self.__getattribute__(k)
# note: not added to the graph yet, if this is a method call
# we peephole optimize to the method invocation
return MetaAttribute(self, k)
class MetaAttribute(MetaProxy):
def __init__(self, root, attr: str):
self.root = root
self.attr = attr
self.tracer = root.tracer
self._node = None
@property
def node(self): # type: ignore[override]
# the node for attributes is added lazily, since most will just be method calls
# which do not rely on the getitem call
if self._node is None:
self._node = self.tracer.create_proxy(
"call_function", getattr, (self.root, self.attr), {}
).node
return self._node
def __call__(self, *args, **kwargs):
return self.tracer.create_proxy(
"call_method", self.attr, (self.root,) + args, kwargs
)
class MetaDeviceAttribute(MetaAttribute):
pass
def proxys_to_metas(v):
if isinstance(v, MetaDeviceAttribute):
return "meta"
if isinstance(v, torch.fx.Proxy):
if not isinstance(v, MetaProxy):
raise AssertionError(f"Expected MetaProxy but got {type(v)}")
if not hasattr(v, "_tensor_meta"):
raise AssertionError("MetaProxy does not have an associated meta")
return v._tensor_meta
return v
class MetaTracer(torch.fx.Tracer):
allow_insert_stateless_mods: bool = True
_TORCH_METHODS_TO_PATCH = ["arange", "zeros", "ones", "full_like", "eye"]
def create_proxy(
self,
kind,
target,
args,
kwargs,
name=None,
type_expr=None,
proxy_factory_fn=None,
):
rv = super().create_proxy(
kind,
target,
args,
kwargs,
name,
type_expr,
# pyrefly: ignore [bad-argument-type]
proxy_factory_fn,
)
if kind == "placeholder" and target in self.meta_args:
rv.install_tensor_meta(self.meta_args[target])
return rv
if target in self.orig_fns:
# NOTE: tensor constructors in PyTorch define the `device` argument as
# *kwargs-only*. That is why this works. If you add methods to
# _TORCH_METHODS_TO_PATCH that do not define `device` as kwarg-only,
# this will break and you will likely see issues where we cannot infer
# the size of the output.
if "device" in kwargs:
kwargs["device"] = "meta"
try:
args_metas = torch.fx.node.map_aggregate(args, proxys_to_metas)
kwargs_metas = torch.fx.node.map_aggregate(kwargs, proxys_to_metas)
if kind == "call_function":
meta_target = manual_meta_overrides.get(target, target)
meta_out = meta_target(*args_metas, **kwargs_metas)
elif kind == "call_method":
meta_target = getattr(args_metas[0], target) # type: ignore[index]
meta_out = meta_target(*args_metas[1:], **kwargs_metas) # type: ignore[index]
elif kind == "call_module":
if not hasattr(self, "orig_forward"):
raise AssertionError("orig_forward not set for call_module")
self._disable_module_getattr = True
try:
mod = self.root.get_submodule(target)
mod_type = type(mod)
if mod_type in manual_meta_overrides:
meta_out = manual_meta_overrides[mod_type](
mod, *args_metas, **kwargs_metas
) # type: ignore[misc, arg-type]
else:
meta_out = self.orig_forward(*args_metas, **kwargs_metas)
finally:
self._disable_module_getattr = False
elif kind == "get_attr":
self._disable_module_getattr = True
try:
attr_itr = self.root
atoms = target.split(".")
for atom in atoms:
attr_itr = getattr(attr_itr, atom)
if not isinstance(attr_itr, torch.Tensor):
raise AssertionError(f"Expected Tensor, got {type(attr_itr)}")
meta_out = attr_itr.to(device="meta")
finally:
self._disable_module_getattr = False
else:
return rv
# TODO
if not isinstance(rv, torch.fx.Proxy):
raise AssertionError("Dont support composite output yet")
rv.install_tensor_meta(meta_out)
except Exception as e:
warnings.warn(f"Could not compute metadata for {kind} target {target}: {e}")
return rv
def getattr(self, attr, attr_val, parameter_proxy_cache):
if getattr(self, "_disable_module_getattr", False):
return attr_val
else:
return super().getattr(attr, attr_val, parameter_proxy_cache)
def call_module(self, m, forward, args, kwargs):
self.orig_forward = forward
return super().call_module(m, forward, args, kwargs)
def _insert_module_as_submodule(self, mod: torch.nn.Module) -> str:
"""
Helper method which tries to insert a module that was not declared as submodule.
"""
idx = 0
mod_name = mod.__class__.__name__.lower()
path = f"{mod_name}_{idx}"
while hasattr(self.root, path):
path = f"{mod_name}_{idx}"
idx += 1
self.root.add_module(path, mod)
return path
def path_of_module(self, mod: torch.nn.Module) -> str:
try:
return super().path_of_module(mod)
except NameError:
if (
self.allow_insert_stateless_mods
and len(list(mod.parameters())) == 0
and len(list(mod.buffers())) == 0
):
path = self._insert_module_as_submodule(mod)
self.prev_module = path
return path
raise
def proxy(self, node):
return MetaProxy(node, self)
def trace(self, root, meta_args: dict[str, torch.Tensor], concrete_args=None): # type: ignore[override]
if not isinstance(meta_args, dict):
raise AssertionError(f"Expected dict for meta_args, got {type(meta_args)}")
self.meta_args = meta_args
self.patched_torch_methods = {
target: gen_constructor_wrapper(getattr(torch, target))
for target in self._TORCH_METHODS_TO_PATCH
}
self.orig_fns = set()
for name, (wrapper, orig) in self.patched_torch_methods.items():
setattr(torch, name, wrapper)
self.orig_fns.add(orig)
try:
graph = super().trace(root, concrete_args)
graph._tracer_extras = {"meta_args": meta_args}
return graph
finally:
for name, (_, orig) in self.patched_torch_methods.items():
setattr(torch, name, orig)
def symbolic_trace(
root: torch.nn.Module | Callable[..., Any],
meta_args: dict[str, torch.Tensor] | None = None,
concrete_args: dict[str, Any] | None = None,
) -> torch.fx.GraphModule:
tracer = MetaTracer()
graph = tracer.trace(root, meta_args, concrete_args) # type: ignore[arg-type]
name = (
root.__class__.__name__ if isinstance(root, torch.nn.Module) else root.__name__
)
gm = torch.fx.GraphModule(tracer.root, graph, name)
return gm
@@ -0,0 +1,733 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = [
"ApplyBroadcasting",
"BinConstraintD",
"BinConstraintT",
"BinaryConstraint",
"BVar",
"CalcConv",
"CalcMaxPool",
"CalcProduct",
"CanReshape",
"Conj",
"Constraint",
"DGreatestUpperBound",
"Disj",
"DVar",
"F",
"GetItem",
"GetItemTensor",
"IndexSelect",
"Prod",
"T",
"TGreatestUpperBound",
"Transpose",
"TVar",
"is_algebraic_expression",
"is_bool_expr",
"is_dim",
]
from torch.fx.experimental.migrate_gradual_types.operation import (
op_add,
op_div,
op_eq,
op_gt,
op_lt,
op_mod,
op_mul,
op_neq,
op_sub,
)
from torch.fx.tensor_type import _DynType, Dyn, TensorType
class Constraint:
pass
class Conj(Constraint):
def __init__(self, conjuncts: Sequence[Constraint]) -> None:
"""
:param conjuncts: Conjunction of constraints
"""
self.conjucts = list(conjuncts)
def __eq__(self, other: object) -> bool:
if isinstance(other, Conj):
return self.conjucts == other.conjucts
else:
return False
def __repr__(self) -> str:
return f"And({self.conjucts})"
class Disj(Constraint):
def __init__(self, disjuncts: Sequence[Constraint]) -> None:
"""
:param disjuncts: Disjunction of constraints
"""
self.disjuncts = list(disjuncts)
def __eq__(self, other: object) -> bool:
if isinstance(other, Disj):
return self.disjuncts == other.disjuncts
else:
return False
def __repr__(self) -> str:
return f"Or({self.disjuncts})"
class Prod(Constraint):
def __init__(self, products: Sequence[DVar | int | _DynType]) -> None:
"""
:param products: lists of dimensions to multiply
"""
self.products = list(products)
def __eq__(self, other: object) -> bool:
if isinstance(other, Prod):
return self.products == other.products
else:
return False
def __repr__(self) -> str:
return f"Product({self.products})"
class T(Constraint):
"""
True
"""
def __init__(self) -> None:
pass
def __eq__(self, other: object) -> bool:
return isinstance(other, T)
def __repr__(self) -> str:
return "True"
class F(Constraint):
"""
False
"""
def __init__(self) -> None:
pass
def __eq__(self, other: object) -> bool:
return isinstance(other, F)
def __repr__(self) -> str:
return "False"
class BinaryConstraint(Constraint):
"""
Represents all binary operations
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
"""
:param lhs: lhs of the constraint
:param rhs: rhs of the constraint
:param op: string representing the operation
"""
self.lhs = lhs
self.rhs = rhs
self.op = op
def __eq__(self, other: object) -> bool:
if isinstance(other, BinaryConstraint):
return (
self.lhs == other.lhs and self.rhs == other.rhs and self.op == other.op
)
else:
return False
def __repr__(self) -> str:
return f"({self.lhs} {self.op} {self.rhs})"
class BinConstraintT(BinaryConstraint):
"""
Binary constraints about tensors
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
if not (
(isinstance(lhs, (TVar, TensorType, int)) or lhs == Dyn)
and (isinstance(rhs, (TVar, TensorType, int)) or rhs == Dyn)
):
raise AssertionError(f"Invalid types: lhs={type(lhs)}, rhs={type(rhs)}")
super().__init__(lhs, rhs, op)
class BinConstraintD(BinaryConstraint):
"""
Binary constraints about dimensions
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
if not (is_algebraic_expression(lhs) or is_dim(lhs) or is_bool_expr(lhs)):
raise AssertionError(f"Invalid lhs type: {type(lhs)}")
if not (is_algebraic_expression(rhs) or is_dim(rhs) or is_bool_expr(rhs)):
raise AssertionError(f"Invalid rhs type: {type(rhs)}")
super().__init__(lhs, rhs, op)
class TGreatestUpperBound(Constraint):
"""
Greatest Upper bound for tensors with dynamic type
"""
def __init__(self, res: TVar, rhs1: TVar, rhs2: TVar) -> None:
"""
:param res: tensor variable that stores the result of the output
:param rhs1: tensor or tensor variable
:param rhs2: tensor or tensor variabke
"""
self.res = res
self.rhs1 = rhs1
self.rhs2 = rhs2
def __repr__(self) -> str:
return f"{self.res} = {self.rhs1}\u2294*{self.rhs2}"
def __eq__(self, other: object) -> bool:
if isinstance(other, TGreatestUpperBound):
return (
self.res == other.res
and self.rhs1 == other.rhs1
and self.rhs2 == other.rhs2
)
else:
return False
class DGreatestUpperBound(Constraint):
"""
Greatest Upper bound for dimensions
"""
def __init__(
self,
res: DVar | int | _DynType,
rhs1: DVar | int | _DynType,
rhs2: DVar | int | _DynType,
) -> None:
"""
:param res: Dimension variable to store the result
:param rhs1: dimension variable 1
:param rhs2: dimension variable 2
"""
if not is_dim(res):
raise AssertionError(f"Expected dimension for res, got {type(res)}")
if not is_dim(rhs1):
raise AssertionError(f"Expected dimension for rhs1, got {type(rhs1)}")
if not is_dim(rhs2):
raise AssertionError(f"Expected dimension for rhs2, got {type(rhs2)}")
self.res = res
self.rhs1 = rhs1
self.rhs2 = rhs2
def __repr__(self) -> str:
return f"{self.res} = {self.rhs1}\u2294{self.rhs2}"
def __eq__(self, other: object) -> bool:
if isinstance(other, DGreatestUpperBound):
return (
self.res == other.res
and self.rhs1 == other.rhs1
and self.rhs2 == other.rhs2
)
else:
return False
class CanReshape(Constraint):
"""
can_reshape constraint
"""
def __init__(self, src: TVar, target: TensorType) -> None:
"""
:param src: tensor variable
:param target: tensor
"""
self.src = src
self.target = target
def __repr__(self) -> str:
return f"can-reshape({self.src}, {self.target})"
def __eq__(self, other: object) -> bool:
if isinstance(other, CanReshape):
return self.src == other.src and self.target == other.target
else:
return False
class IndexSelect(Constraint):
def __init__(
self,
tensor_size: int,
input_var: TVar,
dim_replace: DVar | _DynType,
index: int,
output: TVar,
) -> None:
"""
Args:
input_var: input to index_select
tensor_size: tensor size we are considering
dim_replace: the dimension of the output at "index"
index: location of the dimensions to replace in the input
output: variable to store the result
"""
if not isinstance(input_var, TVar):
raise AssertionError(f"Expected TVar, got {type(input_var)}")
if not isinstance(output, TVar):
raise AssertionError(f"Expected TVar, got {type(output)}")
if not (isinstance(dim_replace, DVar) or dim_replace == Dyn):
raise AssertionError(f"Expected DVar or Dyn, got {type(dim_replace)}")
if not isinstance(index, int):
raise AssertionError(f"Expected int, got {type(index)}")
self.input_var = input_var
self.tensor_size = tensor_size
self.dim_replace = dim_replace
self.index = index
self.output = output
def __repr__(self) -> str:
return (
f" {self.output} = "
f"IndexSelect({self.input_var}, "
f"tensor_size: {self.tensor_size}, "
f"{self.dim_replace}, "
f"{self.index})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, IndexSelect):
return (
self.tensor_size == other.tensor_size
and self.dim_replace == other.dim_replace
and self.index == other.index
and self.output == other.output
and self.input_var == other.input_var
)
else:
return False
class Transpose(Constraint):
def __init__(
self, tensor_size: int, input_var: TVar, index1: int, index2: int, output: TVar
) -> None:
"""
Args:
tensor_size: current tensor size
input_var: variable to hold input
index1: dimension 1
index2: dimension 2
output: output that stores result
"""
if not isinstance(input_var, TVar):
raise AssertionError(f"Expected TVar, got {type(input_var)}")
if not isinstance(output, TVar):
raise AssertionError(f"Expected TVar, got {type(output)}")
if not isinstance(index1, int):
raise AssertionError(f"Expected int, got {type(index1)}")
if not isinstance(index2, int):
raise AssertionError(f"Expected int, got {type(index2)}")
self.input_var = input_var
self.tensor_size = tensor_size
self.index1 = index1
self.index2 = index2
self.output = output
def __repr__(self) -> str:
return (
f" {self.output} = "
f"Transpose({self.input_var}, "
f"tensor_size: {self.tensor_size}, "
f"{self.index1}, "
f"{self.index2})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, Transpose):
return (
self.tensor_size == other.tensor_size
and self.index1 == other.index1
and self.index2 == other.index2
and self.output == other.output
and self.input_var == other.input_var
)
else:
return False
class GetItem(Constraint):
def __init__(
self, tensor_size: int, index: int, res: DVar, input_var: TVar
) -> None:
"""
Constraint for getting item given a tensor size
:param tensor_size: actual number
:param index: actual number representing the index
:param res: dimension variable to carry the item we get
:param input_var: a tensor variable from which we will get item
"""
if not isinstance(res, DVar):
raise AssertionError(f"Expected DVar, got {type(res)}")
self.res = res
self.tensor_size = tensor_size
self.index = index
self.input_var = input_var
def __repr__(self) -> str:
return f" {self.res} = GetItem({self.input_var}, tensor_size: {self.tensor_size}, {self.index})"
def __eq__(self, other: object) -> bool:
if isinstance(other, GetItem):
return (
self.res == other.res
and self.tensor_size == other.tensor_size
and self.index == other.index
and self.input_var == other.input_var
)
else:
return False
class GetItemTensor(Constraint):
def __init__(
self,
tensor_size: int,
index_tuple: tuple[None | slice, ...],
res: TVar,
input_var: TVar,
) -> None:
"""
Constraint for getting item given a tensor size
However, when the argument is a tuple, we will
expect a tensor
:param tensor_size: actual number representing the rank
:param index_tuple: tuple for indexing
:param res: tensor variable to carry the item we get
:param input_var: a tensor variable from which we will get item
"""
if not isinstance(res, TVar):
raise AssertionError(f"Expected TVar, got {type(res)}")
self.res = res
self.tensor_size = tensor_size
self.index_tuple = index_tuple
self.input_var = input_var
def __repr__(self) -> str:
return f" {self.res} = GetItemT({self.input_var}, tensor_size: {self.tensor_size}, {self.index_tuple})"
def __eq__(self, other: object) -> bool:
if isinstance(other, GetItemTensor):
return (
self.res == other.res
and self.tensor_size == other.tensor_size
and self.index_tuple == other.index_tuple
and self.input_var == other.input_var
)
else:
return False
class CalcConv(Constraint):
def __init__(
self,
conv_result: TVar,
input_var: TVar,
c_out: int,
kernel: int | tuple[int, int],
padding: int | tuple[int, int],
stride: int | tuple[int, int],
dilation: int | tuple[int, int],
matching_constraint_vars: list[DVar],
) -> None:
"""
:param conv_result: the convolution result
:param input_var: input to convolution
:param c_out: output channel type
:param kernel: kernel tuple
"""
self.conv_result = conv_result
self.input_var = input_var
self.c_out = c_out
self.kernel = kernel
self.padding = padding
self.stride = stride
self.dilation = dilation
self.matching_constraint = matching_constraint_vars
def __repr__(self) -> str:
return (
f"{self.conv_result} ="
f" calc-conv({self.input_var},"
f" {self.c_out}, {self.kernel}, "
f"{self.padding}, {self.stride},"
f" {self.dilation})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcConv):
return (
self.conv_result == other.conv_result
and self.input_var == other.input_var
and self.c_out == other.c_out
and self.kernel == other.kernel
and self.padding == other.padding
and self.stride == other.stride
and self.dilation == other.dilation
and self.matching_constraint == other.matching_constraint
)
else:
return False
class CalcMaxPool(Constraint):
def __init__(
self,
maxpool_result: TVar,
input_var: TVar,
kernel: int | tuple[int, int],
padding: int | tuple[int, int],
stride: int | tuple[int, int],
dilation: int | tuple[int, int],
matching_constraint_vars: list[DVar],
) -> None:
"""
:param maxpool_result: the result of maxpool
:param input_var: input to convolution
:param kernel: kernel tuple
"""
self.maxpool_result = maxpool_result
self.input_var = input_var
self.kernel = kernel
self.padding = padding
self.stride = stride
self.dilation = dilation
self.matching_constraint = matching_constraint_vars
def __repr__(self) -> str:
return (
f"{self.maxpool_result} ="
f" calc-maxpool({self.input_var},"
f" {self.kernel}, "
f"{self.padding}, {self.stride},"
f" {self.dilation})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcMaxPool):
return (
self.maxpool_result == other.maxpool_result
and self.input_var == other.input_var
and self.kernel == other.kernel
and self.padding == other.padding
and self.stride == other.stride
and self.dilation == other.dilation
and self.matching_constraint == other.matching_constraint
)
else:
return False
class ApplyBroadcasting(Constraint):
def __init__(self, res1: TVar, res2: TVar, input1: TVar, input2: TVar) -> None:
"""
:param res1: resulting tensor 1
:param res2: resulting tensor 2
:param input1: tensor variable 1
:param input2: tensor variable 2
"""
self.res1 = res1
self.res2 = res2
self.input1 = input1
self.input2 = input2
def __eq__(self, other: object) -> bool:
if isinstance(other, ApplyBroadcasting):
return (
self.res1 == other.res1
and self.res2 == other.res2
and self.input1 == other.input1
and self.input2 == other.input2
)
else:
return False
def __repr__(self) -> str:
return (
f"{self.res1}, {self.res2} ="
f" apply-broadcasting({self.input1},"
f" {self.input2})"
)
class CalcProduct(Constraint):
"""
Given correct dimensions, calculate the product for flatten accounting for Dyn
"""
def __init__(
self, start: int, end: int, flattened: TVar, dims_to_flatten: list[DVar]
) -> None:
"""
:param start: start index
:param end: end index
:param flattened: variable to store the product
:param dims_to_flatten: the type which we will flatten
"""
if not isinstance(dims_to_flatten, list):
raise AssertionError(f"Expected list, got {type(dims_to_flatten)}")
if not isinstance(flattened, TVar):
raise AssertionError(f"Expected TVar, got {type(flattened)}")
if not isinstance(start, int):
raise AssertionError(f"Expected int, got {type(start)}")
if not isinstance(end, int):
raise AssertionError(f"Expected int, got {type(end)}")
self.start = start
self.end = end
self.dims_to_flatten = dims_to_flatten
self.flattened = flattened
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcProduct):
return (
self.start == other.start
and self.end == other.end
and self.dims_to_flatten == other.dims_to_flatten
and self.flattened == other.flattened
)
else:
return False
def __repr__(self) -> str:
return f"{self.flattened} = CalcProduct({self.start}, {self.end}, {self.dims_to_flatten})"
class TVar:
"""
Tensor variable with no tensor constructor
"""
def __init__(self, tvar: int) -> None:
"""
:param tvar: tensor variable
"""
self.tvar = tvar
def __repr__(self) -> str:
return f"TV({self.tvar})"
def __eq__(self, other: object) -> bool:
if isinstance(other, TVar):
return self.tvar == other.tvar
else:
return False
class DVar:
"""
Dimension variable
"""
def __init__(self, c: int) -> None:
"""
:param c: character or number
"""
self.c = c
def __repr__(self) -> str:
return f"DV({self.c})"
def __eq__(self, other: object) -> bool:
if isinstance(other, DVar):
return self.c == other.c
else:
return False
class BVar:
"""
Boolean variable
"""
def __init__(self, c: int) -> None:
"""
:param c: character or number
"""
self.c = c
def __repr__(self) -> str:
return f"BV({self.c})"
def __eq__(self, other: object) -> bool:
if isinstance(other, BVar):
return self.c == other.c
else:
return False
_Operand: TypeAlias = (
TVar
| TensorType
| DVar
| int
| float
| bool
| _DynType
| BinConstraintD
| Prod
| BVar
| Conj
| Disj
| None
)
def is_algebraic_expression(constraint: object) -> bool:
if isinstance(constraint, BinConstraintD):
return constraint.op in [op_add, op_sub, op_div, op_mul, op_mod]
else:
return isinstance(constraint, Prod)
def is_bool_expr(constraint: object) -> bool:
if isinstance(constraint, BinConstraintD):
return constraint.op in [op_gt, op_lt, op_neq, op_eq]
else:
return isinstance(constraint, (BVar, Conj, Disj))
def is_dim(d: object) -> bool:
return isinstance(d, (DVar, int)) or d == Dyn
@@ -0,0 +1,14 @@
op_add = "+"
op_sub = "-"
op_mul = "*"
op_div = "/"
op_eq = "="
op_neq = "!="
op_imp = "=>"
op_matching = "\u22b3" # (contains)
op_consistency = "~"
op_precision = "\u2291" # (square image of or equal to)
op_leq = "\u2264" # less-than or equal to
op_lt = "<"
op_gt = ">"
op_mod = "%"
@@ -0,0 +1,546 @@
from typing import Any, TypeAlias
import torch
__all__ = [
"evaluate_conditional_with_constraints",
"iterate_till_fixed_point",
"transform_algebraic_expression",
"transform_all_constraints",
"transform_all_constraints_trace_time",
"transform_dimension",
"transform_to_z3",
"transform_var",
]
# z3 is an optional dependency with no type stubs, so we use aliases for its types.
_Z3Expr: TypeAlias = Any
_Z3Result: TypeAlias = Any
from torch.fx.experimental.migrate_gradual_types.constraint import (
BinConstraintD,
BinConstraintT,
BVar,
Conj,
Constraint,
Disj,
DVar,
F,
is_algebraic_expression,
is_bool_expr,
is_dim,
Prod,
T,
TVar,
)
from torch.fx.experimental.migrate_gradual_types.constraint_generator import (
ConstraintGenerator,
)
from torch.fx.experimental.migrate_gradual_types.constraint_transformation import (
transform_constraint,
)
from torch.fx.experimental.migrate_gradual_types.operation import (
op_add,
op_div,
op_eq,
op_gt,
op_leq,
op_lt,
op_mod,
op_mul,
op_neq,
op_sub,
)
from torch.fx.graph import Graph
from torch.fx.node import Node
from torch.fx.tensor_type import _DynType, Dyn, TensorType
try:
import z3 # type: ignore[import]
from torch.fx.experimental.migrate_gradual_types.z3_types import (
D,
tensor_type,
z3_dyn,
)
HAS_Z3 = True
def transform_to_z3(
constraint: Constraint, counter: int, dimension_dict: dict[int, int]
) -> tuple[_Z3Expr, int]:
if isinstance(constraint, Conj):
conjuncts = []
for c in constraint.conjucts:
new_c, counter = transform_to_z3(c, counter, dimension_dict)
conjuncts.append(new_c)
return z3.And(conjuncts), counter
elif isinstance(constraint, Disj):
disjuncts = []
for c in constraint.disjuncts:
new_c, counter = transform_to_z3(c, counter, dimension_dict)
disjuncts.append(new_c)
return z3.Or(disjuncts), counter
elif isinstance(constraint, T):
return True, counter
elif isinstance(constraint, F):
return False, counter
elif isinstance(constraint, BinConstraintT):
if constraint.op == op_eq:
lhs, counter = transform_var(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_var(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return (lhs == rhs), counter
else:
raise NotImplementedError("Method not yet implemented")
elif isinstance(constraint, BinConstraintD):
if constraint.op == op_eq:
if isinstance(constraint.lhs, BVar) and is_bool_expr(constraint.rhs):
transformed_rhs, counter = transform_to_z3(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
transformed_lhs = z3.Bool(constraint.lhs.c)
return transformed_lhs == transformed_rhs, counter
elif is_dim(constraint.lhs) and is_dim(constraint.rhs):
# with dimension transformations we consider the encoding
lhs, counter = transform_dimension(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_dimension(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs == rhs, counter
else:
# then we have an algebraic expression which means that we disregard the
# first element of the encoding
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs == rhs, counter
# The assumption here is that the LHS and RHS must be dimensions
elif constraint.op == op_neq:
if not is_dim(constraint.lhs):
raise AssertionError("Expected lhs to be a dimension")
if not is_dim(constraint.rhs):
raise AssertionError("Expected rhs to be a dimension")
lhs, counter = transform_dimension(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_dimension(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
if constraint.rhs == Dyn or constraint.lhs == Dyn:
if constraint.rhs == Dyn:
return lhs.arg(0) == 1, counter
else:
return rhs.arg(0) == 1, counter
# if one of the instances is a number
elif isinstance(constraint.lhs, int) or isinstance(constraint.rhs, int):
if isinstance(constraint.lhs, int):
return (
z3.Or(
[
rhs.arg(0) == 0,
z3.And([rhs.arg(0) == 1, lhs.arg(1) != rhs.arg(1)]),
]
),
counter,
)
else:
return (
z3.Or(
[
lhs.arg(0) == 0,
z3.And([lhs.arg(0) == 1, lhs.arg(1) != rhs.arg(1)]),
]
),
counter,
)
else:
return (
z3.Or(
[
z3.And([lhs.arg(0) == 0, rhs.arg(0) != 0]),
z3.And([lhs.arg(0) != 0, rhs.arg(0) == 0]),
z3.And(
[
lhs.arg(0) != 0,
rhs.arg(0) != 0,
lhs.arg(1) != rhs.arg(1),
]
),
]
),
counter,
)
elif constraint.op == op_leq:
# if the dimensions are not dyn, this will come into effect
# there would have been another constraint specifying if a given dimension
# is dyn or not
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs <= rhs, counter
elif constraint.op == op_gt:
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs > rhs, counter
elif constraint.op == op_lt:
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs < rhs, counter
else:
raise NotImplementedError("operation not yet implemented")
else:
raise NotImplementedError("Operation not yet implemented")
def transform_var(
tensor: TVar | TensorType | _DynType,
counter: int,
dimension_dict: dict[int, int],
) -> tuple[_Z3Expr, int]:
"""
Transforms tensor variables to a format understood by z3
Args:
tensor: Tensor variable or a tensor type potentially with variable dimensions
Returns: Transformed variable to a z3 format
"""
if isinstance(tensor, TensorType):
res: list[_Z3Expr] = []
for t in tensor.__args__:
transformed, counter = transform_dimension(t, counter, dimension_dict)
res.append(transformed)
if len(res) > 4:
raise AssertionError(f"Expected res length <= 4, got {len(res)}")
if len(tensor.__args__) == 1:
return tensor_type.tensor1(res[0]), counter
elif len(tensor.__args__) == 2:
return tensor_type.tensor2(res[0], res[1]), counter
elif len(tensor.__args__) == 3:
return tensor_type.tensor3(res[0], res[1], res[2]), counter
elif len(tensor.__args__) == 4:
return tensor_type.tensor4(res[0], res[1], res[2], res[3]), counter
else:
raise AssertionError(
f"Unexpected tensor args length: {len(tensor.__args__)}"
)
elif tensor == Dyn:
return z3_dyn, counter
elif isinstance(tensor, TVar):
return z3.Const(tensor.tvar, tensor_type), counter
else:
raise NotImplementedError(f"Unsupported tensor type: {type(tensor)}")
def transform_dimension(
dimension: DVar | int | _DynType, counter: int, dimension_dict: dict[int, int]
) -> tuple[_Z3Expr, int]:
"""
Takes a dimension variable or a number and transforms it to a tuple
according to our scheme
Args:
dimension: The dimension to be transformed
counter: variable tracking
Returns: tuple and the current counter
"""
if dimension == Dyn:
counter += 1
return D(0, z3.Int(counter)), counter
elif isinstance(dimension, int):
return D(1, dimension), counter
elif isinstance(dimension, DVar):
if dimension.c in dimension_dict:
return (
D(z3.Int(dimension_dict[dimension.c]), z3.Int(dimension.c)),
counter,
)
else:
counter += 1
dimension_dict[dimension.c] = counter
return D(z3.Int(counter), z3.Int(dimension.c)), counter
else:
raise NotImplementedError(f"Unsupported dimension type: {type(dimension)}")
def transform_algebraic_expression(
expr: DVar | int | _DynType | Prod | BinConstraintD,
counter: int,
dimension_dict: dict[int, int],
) -> tuple[_Z3Expr, int]:
"""
Transforms an algebraic expression to z3 format
Args:
expr: An expression is either a dimension variable or an algebraic-expression
Returns: the transformed expression
"""
if not (is_algebraic_expression(expr) or is_dim(expr)):
raise AssertionError("Expected algebraic expression or dimension")
if is_dim(expr):
transformed, counter = transform_dimension(
expr, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return transformed.arg(1), counter
elif isinstance(expr, Prod):
dims = []
for dim in expr.products:
if not is_dim(dim):
raise AssertionError("Expected dimension in Prod")
d, counter = transform_dimension(dim, counter, dimension_dict)
dims.append(d.arg(1))
return z3.Product(dims), counter
elif is_algebraic_expression(expr):
lhs, counter = transform_algebraic_expression(
expr.lhs, # pyrefly: ignore[missing-attribute]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
expr.rhs, # pyrefly: ignore[missing-attribute]
counter,
dimension_dict,
)
if expr.op == op_sub: # pyrefly: ignore[missing-attribute]
c = lhs - rhs
elif expr.op == op_add:
c = lhs + rhs
elif expr.op == op_div:
c = lhs / rhs
elif expr.op == op_mul:
c = lhs * rhs
elif expr.op == op_mod:
c = lhs % rhs
else:
raise NotImplementedError("operation not yet implemented")
return c, counter
else:
raise RuntimeError
def transform_all_constraints(traced: torch.nn.Module, counter: int = 0) -> _Z3Expr:
"""
Given a trace, generates constraints and transforms them to z3 format
"""
dimension_dict: dict[int, int] = {}
generator = ConstraintGenerator(traced)
new_constraints, counter = generator.generate_constraints(counter)
new_constraints, counter = iterate_till_fixed_point(new_constraints, counter)
transformed, counter = transform_to_z3(new_constraints, counter, dimension_dict)
# print(transformed)
return transformed
def iterate_till_fixed_point(
constraints: Constraint, counter: int
) -> tuple[Constraint, int]:
"""
Transform constraints till reaching a fixed point
"""
old_c = None
while old_c != constraints:
old_c = constraints
constraints, counter = transform_constraint(constraints, counter)
return constraints, counter
def transform_all_constraints_trace_time(
tracer_root: torch.nn.Module, graph: Graph, node: Node, counter: int = 0
) -> tuple[_Z3Expr, _Z3Expr]:
"""
Takes a node and a graph and generates two sets of constraints.
One set constraints the node's constraints and another set
constraints the negation of the node's constraints
Args:
tracer_root: the root for getting the module instances
graph: the graph so far in the tracing process
node: node that represents a conditional
counter: variable tracking
Returns: Two sets of constraints. One with a conjunction with the
the conditional constraint and the other with a conjunction with
its negation.
"""
dimension_dict: dict[int, int] = {}
generator = ConstraintGenerator(tracer_root, graph)
new_constraints, counter = generator.generate_constraints(counter)
condition_constraint = new_constraints.conjucts[-1]
# we know the constraint is a conjunction where the last constraint is about the conditional
# so remove the last constraint
new_constraints.conjucts = new_constraints.conjucts[:-1]
# transform precision, matching, consistency till obtaining a fixed point
new_constraints, counter = iterate_till_fixed_point(new_constraints, counter)
# since the function returns a list of one element, we get the first element
# we are only interested in the RHS in this case because the LHS just stores
# the result
# we make sure the constraint is of the form:
# c = b where b is a boolean expression
# and we consider b (constraint.rhs) for transformation
if not isinstance(condition_constraint, BinConstraintD):
raise TypeError(type(condition_constraint))
if not isinstance(condition_constraint.lhs, BVar):
raise AssertionError(f"Expected BVar, got {type(condition_constraint.lhs)}")
if not is_bool_expr(condition_constraint.rhs):
raise AssertionError("Expected bool expression for rhs")
if not isinstance(condition_constraint.rhs, Constraint):
raise TypeError(type(condition_constraint.rhs))
condition_constraint_rhs = condition_constraint.rhs
# transform the condition constraint
condition_constraint_rhs, counter = iterate_till_fixed_point(
condition_constraint_rhs, counter
)
transformed, counter = transform_to_z3(new_constraints, counter, dimension_dict)
transformed_condition_constraint, counter = transform_to_z3(
condition_constraint_rhs, counter, dimension_dict
)
negation_transformed_condition_constraint = z3.Not(
transformed_condition_constraint
)
return z3.And([transformed, transformed_condition_constraint]), z3.And(
[transformed, negation_transformed_condition_constraint]
)
def evaluate_conditional_with_constraints(
tracer_root: torch.nn.Module,
graph: Graph,
node: Node,
counter: int = 0,
user_constraints: _Z3Expr | None = None,
) -> tuple[_Z3Result, _Z3Result]:
"""
Given an IR and a node representing a conditional, evaluate the conditional
and its negation
Args:
tracer_root: Tracer root for module instances
node: The node to be evaluated
Returns: the results of evaluating the condition and the negation with
the rest of the constraints
"""
(
transformed_positive,
transformed_negative,
) = transform_all_constraints_trace_time(tracer_root, graph, node, counter)
s = z3.Solver()
s.add(transformed_positive)
if user_constraints is not None:
s.add(user_constraints)
condition = s.check()
s = z3.Solver()
s.add(transformed_negative)
if user_constraints is not None:
s.add(user_constraints)
negation = s.check()
return condition, negation
except ImportError:
HAS_Z3 = False
@@ -0,0 +1,58 @@
from torch.fx.experimental.migrate_gradual_types.constraint import (
BinConstraintD,
BVar,
DVar,
TVar,
)
from torch.fx.experimental.migrate_gradual_types.operation import op_leq
def gen_tvar(curr: int) -> tuple[TVar, int]:
"""
Generate a tensor variable
:param curr: The current counter
:return: a tensor variable and the updated counter
"""
curr += 1
return TVar(curr), curr
def gen_dvar(curr: int) -> tuple[DVar, int]:
"""
Generate a dimension variable
:param curr: the current counter
:return: a dimension variable and an updated counter
"""
curr += 1
return DVar(curr), curr
def gen_bvar(curr: int) -> tuple[BVar, int]:
"""
Generate a boolean variable
:param curr: the current counter
:return: a boolean variable and an updated counter
"""
curr += 1
return BVar(curr), curr
def gen_tensor_dims(n: int, curr: int) -> tuple[list[DVar], int]:
"""
Generate a list of tensor dimensions
:param n: the number of dimensions
:param curr: the current counter
:return: a list of dimension variables and an updated counter
"""
dims = []
for _ in range(n):
dvar, curr = gen_dvar(curr)
dims.append(dvar)
return dims, curr
def gen_nat_constraints(list_of_dims: list[DVar]) -> list[BinConstraintD]:
"""
Generate natural number constraints for dimensions
"""
return [BinConstraintD(0, d, op_leq) for d in list_of_dims]
@@ -0,0 +1,30 @@
try:
import z3 # type: ignore[import]
HAS_Z3 = True
# dynamic type
dyn = z3.DeclareSort("Dyn")
dyn_type = z3.Const("dyn", dyn)
# dimension
dim = z3.Datatype("dim")
dim.declare("dim", ("0", z3.IntSort()), ("1", z3.IntSort()))
dim = dim.create()
# tensors
tensor_type = z3.Datatype("TensorType")
tensor_type.declare("Dyn", ("dyn", dyn))
tensor_type.declare("tensor1", ("0", dim))
tensor_type.declare("tensor2", ("0", dim), ("1", dim))
tensor_type.declare("tensor3", ("0", dim), ("1", dim), ("2", dim))
tensor_type.declare("tensor4", ("0", dim), ("1", dim), ("2", dim), ("3", dim))
tensor_type = tensor_type.create()
# create dimension
D = dim.dim
z3_dyn = tensor_type.Dyn(dyn_type)
except ImportError:
HAS_Z3 = False
@@ -0,0 +1,168 @@
# mypy: allow-untyped-defs
import operator
from collections.abc import Callable
from typing import Any
import torch
import torch.fx
import torch.fx as fx
from torch.fx import Proxy, Transformer
from torch.fx.node import Argument, map_aggregate, Node, Target
from torch.fx.operator_schemas import (
create_type_hint,
normalize_function,
normalize_module,
)
from .schema_type_annotation import AnnotateTypesWithSchema
class NormalizeArgs(Transformer):
"""
Normalize arguments to Python targets. This means that
`args/kwargs` will be matched up to the module/functional's
signature and rewritten to exclusively kwargs in positional order
if `normalize_to_only_use_kwargs` is true. Also populates default
values. Does not support positional-only parameters or varargs
parameters (*args, **kwargs).
If the nodes have 'type' metadata, it will use it to disambiguate
overloads. Otherwise, it will throw an error.
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = NormalizeArgs(traced).transform()
"""
def __init__(
self, module: torch.fx.GraphModule, normalize_to_only_use_kwargs: bool = True
):
super().__init__(module)
self.node_map: dict[Proxy, Node] = {}
self.normalize_to_only_use_kwargs = normalize_to_only_use_kwargs
def run_node(self, n: Node) -> Any:
args, kwargs = self.fetch_args_kwargs_from_env(n)
def get_type(arg):
if isinstance(arg, fx.Node):
return n.meta.get("type")
return type(arg)
arg_types = map_aggregate(n.args, get_type)
if not isinstance(arg_types, tuple):
raise AssertionError(f"Expected tuple, got {type(arg_types)}")
arg_types = tuple(create_type_hint(i) for i in arg_types)
kwarg_types = {k: get_type(v) for k, v in kwargs.items()}
if n.op == "call_function":
out = self.call_function(n.target, args, kwargs, arg_types, kwarg_types)
else:
out = super().run_node(n)
if n.op != "output":
self.node_map[out] = n
out.node.meta = n.meta
out.node.type = n.type
return out
def call_function(
self,
target: Target,
args: tuple[Argument, ...],
kwargs: dict[str, Any],
arg_types: tuple[Any, ...] | None = None,
kwarg_types: dict[str, Any] | None = None,
):
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
new_args_and_kwargs = normalize_function(
target,
args, # type: ignore[arg-type]
kwargs,
arg_types, # type: ignore[arg-type]
kwarg_types,
self.normalize_to_only_use_kwargs,
)
if new_args_and_kwargs:
new_args, new_kwargs = new_args_and_kwargs
return self.tracer.create_proxy(
"call_function", target, new_args, new_kwargs
)
else:
return super().call_function(target, args, kwargs)
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
new_args_and_kwargs = normalize_module(
self.module,
target,
args, # type: ignore[arg-type]
kwargs,
self.normalize_to_only_use_kwargs,
)
if new_args_and_kwargs:
new_args, new_kwargs = new_args_and_kwargs
return super().call_module(target, new_args, new_kwargs)
else:
return super().call_module(target, args, kwargs)
class NormalizeOperators(AnnotateTypesWithSchema):
"""
Normalize callsites that are different ways of "spelling" the same
invocation into a single, canonical call. Currently supports:
1. Normalize operators (e.g. operator.add) to the `torch` ops they
ultimately invoke (e.g. torch.add) when it is possible to statically
reason that
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = NormalizeOperators(traced).transform()
"""
binary_magic_method_remap: dict[
Callable[[Any, Any], Any], Callable[[Any, Any], Any]
] = {
torch.add: operator.add,
torch.mul: operator.mul,
torch.sub: operator.sub,
torch.div: operator.truediv,
torch.floor_divide: operator.floordiv,
torch.remainder: operator.mod,
torch.eq: operator.eq,
torch.ne: operator.ne,
torch.lt: operator.lt,
torch.le: operator.le,
torch.gt: operator.gt,
torch.ge: operator.ge,
}
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
# Normalize operators according to the magic methods implemented on tensors here:
# https://github.com/pytorch/pytorch/blob/28c5d90b679c6b38bf4183ec99f16d933c2f1bcd/tools/autograd/templates/python_variable_methods.cpp#L1137 # noqa: B950
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
if target in self.binary_magic_method_remap:
if len(args) != 2:
return super().call_function(target, args, kwargs)
lhs, rhs = args
return super().call_function(
target=self.binary_magic_method_remap[target],
args=(lhs, rhs),
kwargs={},
)
return super().call_function(target, args, kwargs)
@@ -0,0 +1,498 @@
# mypy: allow-untyped-defs
import copy
import logging
import operator
import time
from collections import defaultdict
from collections.abc import Iterable
from enum import Enum
from typing import Any, cast
import torch
import torch.fx as fx
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.mkldnn as th_mkldnn
from torch.fx.node import Argument, Target
from torch.fx.passes.shape_prop import ShapeProp
from torch.nn.utils.fusion import fuse_conv_bn_eval, fuse_linear_bn_eval
__all__ = [
"matches_module_pattern",
"replace_node_module",
"fuse",
"remove_dropout",
"extract_subgraph",
"modules_to_mkldnn",
"reset_modules",
"MklSubgraph",
"gen_mkl_autotuner",
"use_mkl_length",
"UnionFind",
"optimize_for_inference",
]
def _parent_name(target: str) -> tuple[str, str]:
"""
Splits a qualname into parent path and last atom.
For example, `foo.bar.baz` -> (`foo.bar`, `baz`)
"""
*parent, name = target.rsplit(".", 1)
return parent[0] if parent else "", name
# Works for length 2 patterns with 2 modules
def matches_module_pattern(
pattern: Iterable[type], node: fx.Node, modules: dict[str, Any]
):
if len(node.args) == 0:
return False
nodes: tuple[Any, fx.Node] = (node.args[0], node)
for expected_type, current_node in zip(pattern, nodes):
if not isinstance(current_node, fx.Node):
return False
if current_node.op != "call_module":
return False
if not isinstance(current_node.target, str):
return False
if current_node.target not in modules:
return False
if type(modules[current_node.target]) is not expected_type:
return False
return True
def replace_node_module(
node: fx.Node, modules: dict[str, Any], new_module: torch.nn.Module
):
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
parent_name, name = _parent_name(node.target)
modules[node.target] = new_module
setattr(modules[parent_name], name, new_module)
def fuse(model: torch.nn.Module, inplace=False, no_trace=False) -> torch.nn.Module:
"""
Fuses convolution/BN and linear/BN layers for inference purposes.
Will deepcopy your model by default, but can modify the model inplace as well.
"""
patterns = [
(nn.Conv1d, nn.BatchNorm1d),
(nn.Conv2d, nn.BatchNorm2d),
(nn.Conv3d, nn.BatchNorm3d),
(nn.Linear, nn.BatchNorm1d),
]
if not inplace:
model = copy.deepcopy(model)
if not no_trace or not isinstance(model, torch.fx.GraphModule):
fx_model = fx.symbolic_trace(model)
else:
fx_model = model
modules = dict(fx_model.named_modules())
new_graph = copy.deepcopy(fx_model.graph)
for pattern in patterns:
for node in new_graph.nodes:
if matches_module_pattern(pattern, node, modules):
if len(node.args[0].users) > 1:
# Output of conv/linear is used by other nodes
continue
first_layer = modules[node.args[0].target]
bn = modules[node.target]
if not bn.track_running_stats:
continue
if pattern[0] in [nn.Conv1d, nn.Conv2d, nn.Conv3d]:
fused_layer = fuse_conv_bn_eval(first_layer, bn)
else: # nn.Linear
fused_layer = fuse_linear_bn_eval(first_layer, bn)
replace_node_module(node.args[0], modules, fused_layer)
node.replace_all_uses_with(node.args[0])
new_graph.erase_node(node)
return fx.GraphModule(fx_model, new_graph)
def remove_dropout(model: nn.Module) -> nn.Module:
"""
Removes all dropout layers from the module.
"""
fx_model = fx.symbolic_trace(model)
class DropoutRemover(torch.fx.Transformer):
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
if isinstance(self.submodules[target], nn.Dropout):
if len(args) != 1:
raise AssertionError(f"Expected 1 arg for Dropout, got {len(args)}")
return args[0]
else:
return super().call_module(target, args, kwargs)
return DropoutRemover(fx_model).transform()
def extract_subgraph(
orig_module: nn.Module,
nodes: list[fx.Node],
inputs: list[fx.Node],
outputs: list[fx.Node],
):
"""
Given lists of nodes from an existing graph that represent a subgraph, returns a submodule that executes that subgraph.
"""
new_graph = fx.Graph()
env: dict[fx.Node, fx.Node] = {}
for input in inputs:
new_node = new_graph.placeholder(input.name)
env[input] = new_node
for node in nodes:
new_node = new_graph.node_copy(node, lambda x: env[x])
env[node] = new_node
new_graph.output([env[output] for output in outputs])
new_graph.lint()
return fx.GraphModule(orig_module, new_graph)
mkldnn_supported = [
nn.Conv2d,
nn.Linear,
nn.BatchNorm2d,
nn.ReLU,
nn.MaxPool2d,
nn.AvgPool2d,
nn.AdaptiveAvgPool2d,
torch.relu,
torch.transpose,
torch.sigmoid,
F.relu,
F.avg_pool2d,
F.adaptive_avg_pool2d,
]
# These are operators that may not be convertible into MKLDNN ops (e.g. the
# args are scalar values). Thus, we only include them in the subgraph if their
# arguments are already in MKLDNN.
# TODO: Determine whether this can be removed after type inference.
mkldnn_supported_unknown = [operator.add, operator.mul]
mkldnn_map = {
nn.Conv2d: th_mkldnn.MkldnnConv2d,
nn.Linear: th_mkldnn.MkldnnLinear,
nn.BatchNorm2d: lambda a, _: th_mkldnn.MkldnnBatchNorm(a),
}
def modules_to_mkldnn(nodes: list[fx.Node], modules: dict[str, nn.Module]):
"""
For each node, if it's a module that can be preconverted into MKLDNN,
then we do so and create a mapping to allow us to convert from the MKLDNN
version of the module to the original.
"""
old_modules: dict[nn.Module, nn.Module] = {}
for node in nodes:
if node.op == "call_module":
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
cur_module = modules[node.target]
if type(cur_module) in mkldnn_map:
# pyrefly: ignore [bad-index, index-error]
new_module = mkldnn_map[type(cur_module)](cur_module, torch.float)
if not isinstance(new_module, nn.Module):
raise AssertionError(f"Expected nn.Module, got {type(new_module)}")
old_modules[new_module] = copy.deepcopy(cur_module)
replace_node_module(node, modules, new_module)
return old_modules
def reset_modules(
nodes: list[fx.Node],
modules: dict[str, nn.Module],
old_modules: dict[nn.Module, nn.Module],
):
"""
Maps each module that's been changed with `modules_to_mkldnn` back to its
original.
"""
for node in nodes:
if node.op == "call_module":
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
cur_module = modules[node.target]
if cur_module in old_modules:
replace_node_module(node, modules, old_modules[cur_module])
class MklSubgraph:
def __init__(self, fx_graph: fx.Graph):
self.fx_graph = fx_graph
self.nodes: list[fx.Node] = []
self.start_nodes: list[fx.Node] = []
self.end_nodes: list[fx.Node] = []
def gen_mkl_autotuner(example_inputs, iters=10, warmup=1):
"""
This generates a heuristic that can be passed into `optimize_for_inference` that
determines whether a subgraph should be run in MKL by running it with the example_inputs.
Example usage:
heuristic = gen_mkl_autotuner(example_inputs, iters=10)
fast_model = optimization.optimize_for_inference(model, heuristic)
"""
fx_model = None
old_modules = None
def use_mkl_heuristic(graph: MklSubgraph) -> bool:
nonlocal fx_model, old_modules
input_nodes = graph.start_nodes
if fx_model is None:
fx_model = graph.fx_graph.owning_module
old_modules = graph.fx_graph.old_modules # type: ignore[attr-defined]
ShapeProp(fx_model).propagate(example_inputs)
sample_inputs = [torch.randn(node.shape) for node in input_nodes] # type: ignore[attr-defined]
output_args = cast(list[fx.Node], [node.args[0] for node in graph.end_nodes])
submodule = extract_subgraph(fx_model, graph.nodes, input_nodes, output_args)
def benchmark(f):
for _ in range(warmup):
f()
begin = time.time()
for _ in range(iters):
f()
return time.time() - begin
mkl_time = benchmark(
lambda: [
i.to_dense() for i in submodule(*[i.to_mkldnn() for i in sample_inputs])
]
)
reset_modules(
submodule.graph.nodes,
dict(submodule.named_modules()),
# pyrefly: ignore [bad-argument-type]
old_modules,
)
no_mkl_time = benchmark(lambda: submodule(*sample_inputs))
return mkl_time < no_mkl_time
return use_mkl_heuristic
def use_mkl_length(graph: MklSubgraph) -> bool:
"""
This is a heuristic that can be passed into `optimize_for_inference` that
determines whether a subgraph should be run in MKL by checking if there
are more than 2 nodes in it
"""
return len(graph.nodes) > 2
class UnionFind:
def __init__(self, n):
self.parent: list[int | None] = [None] * n
self.size: list[int] = [0] * n
def make_set(self, v: int):
self.parent[v] = v
self.size[v] = 1
def find(self, v: int) -> int:
par = self.parent[v]
if v == par:
return v
if par is None:
raise AssertionError("Parent is None")
self.parent[v] = self.find(par)
return cast(int, self.parent[v])
def join(self, a: int, b: int):
a, b = self.find(a), self.find(b)
if a == b:
return a
if self.size[a] < self.size[b]:
a, b = b, a
self.parent[b] = a
self.size[a] += self.size[b]
def optimize_for_inference(
model: torch.nn.Module,
pass_config: dict[str, Any] | None = None,
tracer: type[fx.Tracer] = fx.Tracer,
) -> torch.nn.Module:
"""
Performs a set of optimization passes to optimize a model for the
purposes of inference. Specifically, the passes that are run are:
1. Conv/BN fusion
2. Dropout removal
3. MKL layout optimizations
The third optimization takes a function `use_mkl_heuristic` that's used
to determine whether a subgraph should be explicitly run in MKL layout.
Note: As FX does not currently handle aliasing, this pass currently
assumes nothing aliases. If that isn't true, use at your own risk.
"""
default_pass_config = {
"conv_bn_fuse": True,
"remove_dropout": True,
"mkldnn_layout_optimize": {"heuristic": use_mkl_length},
}
if pass_config is None:
pass_config = {}
default_pass_config.update(pass_config)
if default_pass_config["conv_bn_fuse"]:
model = fuse(model)
if default_pass_config["remove_dropout"]:
model = remove_dropout(model)
if default_pass_config["mkldnn_layout_optimize"] is False:
return model
if not isinstance(default_pass_config["mkldnn_layout_optimize"], dict):
raise RuntimeError("mkldnn_layout_optimize config is not a dict")
if "heuristic" not in default_pass_config["mkldnn_layout_optimize"]:
raise RuntimeError("Heuristic not found in mkldnn_layout_optimize config")
use_mkl_heuristic = default_pass_config["mkldnn_layout_optimize"]["heuristic"]
cur_tracer = tracer()
fx_graph = cur_tracer.trace(copy.deepcopy(model))
fx.GraphModule(cur_tracer.root, fx_graph)
modules: dict[str, nn.Module] = dict(model.named_modules())
class MklSupport(Enum):
NO = 1
YES = 2
UNKNOWN = 3
# Inserts to_mkldnn and to_dense around every node we want to be a MKLDNN node.
# If the op is in `mkldnn_supported` then we always treat it as a MKLDNN node.
# However, if it's in `mkldnn_supported_unknown`, then we only treat it as
# a MKLDNN node if its inputs are MKLDNN nodes.
for node in list(fx_graph.nodes):
supports_mkldnn = MklSupport.NO
if node.op == "call_module":
cur_module = modules[node.target]
if type(cur_module) in mkldnn_supported:
supports_mkldnn = MklSupport.YES
sample_parameter = next(cur_module.parameters(), None)
if sample_parameter is not None:
if sample_parameter.dtype != torch.float:
raise AssertionError(
"this pass is only for torch.float modules"
)
if sample_parameter.device != torch.device("cpu"):
raise AssertionError("this pass is only for CPU modules")
elif node.op == "call_function":
if node.target in mkldnn_supported:
supports_mkldnn = MklSupport.YES
elif node.target in mkldnn_supported_unknown:
supports_mkldnn = MklSupport.UNKNOWN
if supports_mkldnn != MklSupport.NO:
if supports_mkldnn == MklSupport.UNKNOWN:
if not any(arg.target == "to_dense" for arg in node.args):
continue
with fx_graph.inserting_before(node):
mkldnn_args = fx.map_arg(
node.args, lambda n: fx_graph.call_method("to_mkldnn", (n,))
)
node.args = cast(tuple[fx.node.Argument], mkldnn_args)
with fx_graph.inserting_after(node):
dense_x = fx_graph.create_node("call_method", "to_dense", (node,))
node.replace_all_uses_with(dense_x)
dense_x.args = (node,)
# Does pre-conversion of all modules into MKLDNN (when possible)
old_modules = modules_to_mkldnn(list(fx_graph.nodes), modules)
fx_graph.old_modules = old_modules # type: ignore[attr-defined]
# optimizes all a -> to_dense -> to_mkldnn -> b patterns into a -> b
for node in fx_graph.nodes:
if node.op == "call_method" and node.target == "to_dense":
prv_node = node.args[0]
users = list(node.users)
for user in users:
if user.op == "call_method" and user.target == "to_mkldnn":
user.replace_all_uses_with(prv_node)
fx_graph.erase_node(user)
if len(node.users) == 0:
fx_graph.erase_node(node)
num_nodes = len(fx_graph.nodes)
uf = UnionFind(num_nodes)
def get_color(n):
if hasattr(n, "color"): # Current node is part of a MKL subgraph
return uf.find(n.color)
if hasattr(n, "start_color"): # Current node is input to MKL subgraph
return uf.find(n.start_color)
return None
# This code is to find each MKLDNN subgraph. Each MKLDNN subgraph consists
# of input nodes (which are only `to_mkldnn` calls), output nodes
# (`to_dense` calls), and intermediate nodes, which are run entirely on
# MKLDNN layout tensors.
#
# Specifically, this code does a flood fill on a directed acyclic graph
# (DAG), starting from each possible "start node" (i.e: `to_mkldnn` nodes).
# If every node only had one input, this would be sufficient. However, in
# the case that a node has multiple inputs coming from different start
# nodes (i.e. colors), we need to join these 2 colors into 1. That's done
# using a Disjoint Set Union.
for cur_idx, node in enumerate(fx_graph.nodes):
if node.op == "call_method" and node.target == "to_mkldnn":
node.start_color = cur_idx
uf.make_set(cur_idx)
elif node.op == "call_method" and node.target == "to_dense":
if get_color(node.args[0]) is None:
raise AssertionError("Expected color for to_dense input")
node.end_color = get_color(node.args[0])
else:
cur_colors = [
get_color(i)
for i in node.all_input_nodes
if isinstance(i, fx.Node)
if get_color(i) is not None
]
if len(cur_colors) == 0:
continue
if any(i is None for i in cur_colors):
raise AssertionError("Found None in cur_colors")
cur_colors = sorted(cur_colors)
node.color = cur_colors[0]
for other_color in cur_colors[1:]:
uf.join(cur_colors[0], other_color)
mkldnn_graphs: dict[int, MklSubgraph] = defaultdict(lambda: MklSubgraph(fx_graph))
for node in fx_graph.nodes:
if hasattr(node, "color"):
mkldnn_graphs[uf.find(node.color)].nodes.append(node)
if hasattr(node, "start_color"):
mkldnn_graphs[uf.find(node.start_color)].start_nodes.append(node)
if hasattr(node, "end_color"):
mkldnn_graphs[uf.find(node.end_color)].end_nodes.append(node)
# Now that we have all the subgraphs, we need to decide which MKLDNN
# subgraphs we actually want to keep in MKLDNN.
for graph in mkldnn_graphs.values():
if not use_mkl_heuristic(graph):
for node in graph.start_nodes + graph.end_nodes:
prv = node.args[0]
node.replace_all_uses_with(prv) # type: ignore[arg-type]
fx_graph.erase_node(node)
reset_modules(graph.nodes, modules, old_modules)
mkldnn_conversions = 0
for node in fx_graph.nodes:
if node.target == "to_mkldnn" or node.target == "to_dense":
mkldnn_conversions += 1
logging.getLogger(__name__).info("mkldnn conversions: %s", mkldnn_conversions)
fx_graph.lint()
result = fx.GraphModule(model, fx_graph)
return result
@@ -0,0 +1,317 @@
# mypy: allow-untyped-defs
from enum import Enum
from typing import NamedTuple
from torch.fx.node import map_arg, Node
class Partition:
"""Partition class contains all the information about an individual partition.
It also provides necessary methods for manipulation the partition.
"""
def __init__(self, partition_id: int) -> None:
self.nodes: set[Node] = set()
self.partition_id = partition_id
self.parents: set[Partition] = set()
self.children: set[Partition] = set()
self.bfs_level: int = -1
self.used_mem_bytes: int = 0
self.logical_device_ids: list[int] = []
def __str__(self):
return str(self.partition_id)
def recalculate_mem_size(self):
self.used_mem_bytes = 0
for node in self.nodes:
self.used_mem_bytes += get_extra_size_of(node, self.nodes)
def add_node(self, node):
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Add current node's input nodes if they are placeholder or constants
for n in input_nodes:
if n.op in {"placeholder", "get_attr"}:
self.nodes.add(n)
self.nodes.add(node)
self.recalculate_mem_size()
def remove_node(self, node):
# Remove a node only if the node is in the partition
if node in self.nodes:
self.nodes.remove(node)
# Collect the node's input nodes
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Check if an input node is a placeholder or get_attr,
# and this input node is not used by some other nodes in this partition,
# the remove this input node
for input_node in input_nodes:
if all(
n not in self.nodes for n in input_node.users
) and input_node.op in {"placeholder", "get_attr"}:
self.nodes.remove(input_node)
self.recalculate_mem_size()
class Device(NamedTuple):
name: str
available_mem_bytes: int
logical_id: int
class NodeLatency(NamedTuple):
# Latency due to the memory bandwidth
mem_latency_sec: float
# Latency due to the computation
computer_latency_sec: float
class PartitionLatency(NamedTuple):
# Sum of all nodes' memory latency on the critical path
mem_latency_sec: float
# Sum of all nodes' compute latency on the critical path
computer_latency_sec: float
# Latency of the critical path
overall_latency_sec: float
class PartitionMode(Enum):
size_based = 0
sparse_nn = 1
cost_aware = 2
kl_based = 3
aot_based = 4
class PartitionerConfig(NamedTuple):
devices: list[Device]
mode: PartitionMode = PartitionMode.size_based
transfer_rate_bytes_per_sec: float = 0.0
node_to_latency_mapping: dict[Node, NodeLatency] = {}
node_to_partition_mapping: dict[Node, int] = {}
partition_to_logical_device_mapping: dict[int, list[int]] = {}
# Saturate host by replicating partitions to the remaining idle devices.
saturate_host: bool = False
def get_extra_size_of(node: Node, nodes: set[Node]) -> int:
"""Given a node and a set of nodes,
this function return the extra size that needed
if this node is included in this set.
"""
# Find all its input nodes
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Calculate total size of related nodes
total_size_of_input_nodes = 0
for n in input_nodes:
# Make sure this node hasn't been in this set yet
if n not in nodes:
size_bytes = getattr(n, "size_bytes", None)
if size_bytes:
total_size_of_input_nodes += size_bytes.output_size
else:
raise RuntimeError("node has no size_bytes attr")
# Don't forget the op node itself
size_bytes = getattr(node, "size_bytes", None)
if size_bytes:
total_size_of_input_nodes += size_bytes.total_size
else:
raise RuntimeError("node has no size_bytes attr")
return total_size_of_input_nodes
def get_latency_of_one_partition(
partition: Partition, node_to_latency_mapping: dict[Node, NodeLatency]
) -> PartitionLatency:
"""Given a partition and its nodes' latency, return a PartitionLatency for this partition"""
def get_top_nodes(partition: Partition) -> list[Node]:
"""Given a partition, return a list of nodes on the top bfs level"""
top_nodes: list[Node] = []
for node in partition.nodes:
# Skip placeholder and get_attr nodes
if node.op in {"placeholder", "get_attr"}:
continue
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# If a node has no input nodes in this partition,
# or its input nodes in this partition are placeholders and get_attrs
# this node is on the top bfs level in this partition
if not any(
n in partition.nodes and n.op not in {"placeholder", "get_attr"}
for n in input_nodes
):
top_nodes.append(node)
return top_nodes
def dfs_helper(node: Node, partition_latency) -> PartitionLatency:
"""Given a top node of a partition, this function returns
the latency of the critical path in the partition
"""
node_latency = node_to_latency_mapping[node]
# Calculate the current overall latency of the partition
overall_latency_sec = partition_latency.overall_latency_sec + max(
node_latency.computer_latency_sec, node_latency.mem_latency_sec
)
# Update the mem latency of this path
mem_latency_sec = (
partition_latency.mem_latency_sec + node_latency.mem_latency_sec
)
# Update the compute latency of this path
computer_latency_sec = (
partition_latency.computer_latency_sec + node_latency.computer_latency_sec
)
# Get all users of this node that are in this partition
users = set(node.users).intersection(partition.nodes)
if users:
max_latency = PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
)
for n in users:
# Get new partition latency recursively
new_partition_latency = dfs_helper(
n,
PartitionLatency(
mem_latency_sec, computer_latency_sec, overall_latency_sec
),
)
if (
new_partition_latency.overall_latency_sec
> max_latency.overall_latency_sec
):
max_latency = new_partition_latency
return max_latency
# If there is no user, the node is at bottom of the partition
return PartitionLatency(
mem_latency_sec, computer_latency_sec, overall_latency_sec
)
# Main part starts
# Get all top level nodes of this partition
top_nodes = get_top_nodes(partition)
critical_path_latency = PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
)
# Go through all top nodes and find the largest latency (critical pass latency)
for node in top_nodes:
partition_latency = dfs_helper(
node,
PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
),
)
if (
partition_latency.overall_latency_sec
> critical_path_latency.overall_latency_sec
):
critical_path_latency = partition_latency
return critical_path_latency
def get_partition_to_latency_mapping(
partitions: list[Partition], node_to_latency_mapping: dict[Node, NodeLatency]
) -> dict[Partition, PartitionLatency]:
"""Given all the partitions and node_to_latency_mapping dictionary,
return a mapping dictionary of each partition to its overall latency
"""
partition_to_latency_mapping: dict[Partition, PartitionLatency] = {}
# Go through each partition and get its latency
for partition in partitions:
partition_latency = get_latency_of_one_partition(
partition, node_to_latency_mapping
)
partition_to_latency_mapping[partition] = partition_latency
return partition_to_latency_mapping
def get_comm_latency_between(
parent_partition: Partition,
child_partition: Partition,
transfer_rate_bytes_per_sec: float,
):
"""Given two partitions (parent and child),
calculate the communication latency between the two.
"""
# If two partitions are on the same device, the comm latency is 0.
if (
parent_partition.logical_device_ids != []
and child_partition.logical_device_ids != []
and parent_partition.logical_device_ids == child_partition.logical_device_ids
):
return 0.0
# Keep tracking the communication size between parent and child
comm_size = 0
# Keep tracking all the counted node
visited_nodes = set()
# Go through all nodes in the child partition
# If a node has input nodes from the parent partition,
# the output size of those input nodes will be counted
# and added to comm_size
for node in child_partition.nodes:
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
for n in input_nodes:
if n in parent_partition.nodes and n not in visited_nodes:
size_bytes = getattr(n, "size_bytes", None)
if size_bytes is not None:
comm_size += size_bytes.output_size
visited_nodes.add(n)
return comm_size / transfer_rate_bytes_per_sec
def get_latency_of_partitioned_graph(
partitions: list[Partition],
partition_to_latency_mapping: dict[Partition, PartitionLatency],
transfer_rate_bytes_per_sec: float,
):
"""Given all partitions in a graph, find the critical path among all partitions
and return its latency as the latency of the whole graph
"""
def dfs_helper(partition: Partition, latency_so_far_sec: float) -> float:
"""This function helps to recursively get the latency of a path of partitions"""
# Update latency by adding current partition's latency
latency_so_far_sec += partition_to_latency_mapping[
partition
].overall_latency_sec
if partition.children:
max_latency_sec = 0.0
for child in partition.children:
# Calculate latency between
comm_latency_sec = get_comm_latency_between(
partition, child, transfer_rate_bytes_per_sec
)
new_latency_sec = dfs_helper(
child, latency_so_far_sec + comm_latency_sec
)
if new_latency_sec > max_latency_sec:
max_latency_sec = new_latency_sec
return max_latency_sec
return latency_so_far_sec
def get_top_partitions(partitions: list[Partition]) -> list[Partition]:
"""This function is to return all the partitions without parents
as the starting points of all the paths
"""
# If a partition has no parents, then it is a top partition
top_partitions = [
partition for partition in partitions if len(partition.parents) == 0
]
return top_partitions
top_partitions = get_top_partitions(partitions)
critical_path_latency_sec = 0.0
for partition in top_partitions:
latency_sec = dfs_helper(partition, 0.0)
if latency_sec > critical_path_latency_sec:
critical_path_latency_sec = latency_sec
return critical_path_latency_sec
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
from __future__ import annotations
import functools
import inspect
import itertools
import logging
from dataclasses import dataclass
from typing import Any, ParamSpec, TYPE_CHECKING, TypeVar
_P = ParamSpec("_P")
_R = TypeVar("_R")
import torch
import torch.utils._pytree as pytree
if TYPE_CHECKING:
from collections.abc import Callable
from torch.fx.experimental.symbolic_shapes import ShapeEnv, TrackedFake
log = logging.getLogger(__name__)
trace_shape_events_log = torch._logging.getArtifactLogger(
__name__, "trace_shape_events"
)
__all__ = [
"ShapeEnvEvent",
"record_shapeenv_event",
"replay_shape_env_events",
"FakeTensorMeta",
"shape_env_check_state_equal",
"NotEqualError",
]
# [Note: Recording ShapeEnv Events]
# =================================
#
# What is a ShapeEnv event?
# -------------------------
# We consider a ShapeEnv event every function call (ShapeEnv method or
# independent function) that modifies the state of the ShapeEnv instance.
# Such calls are recorded alongside their positional and keyword arguments,
# so that it may be replayed over a different ShapeEnv instance.
#
# See [Note: ShapeEnv State Equality] for what is considered the state
# of a ShapeEnv instance.
#
# What is it for?
# ---------------
# ShapeEnv events recording is used for reconstructing the ShapeEnv in an
# arbitrary state in time.
#
# Being able to arbitrarily replay events like so is useful, mainly for
# translation validation bisection. i.e. if a ValidationException has been
# raised, find the earliest point in time where the translation validation
# fails.
#
# Besides that, it also allows us to inspect the given instance and,
# for example, check the guards that would actually be issued at that point.
#
# What kind of arguments can be stored in an event?
# -------------------------------------------------
# There's no specific rule for what cannot be used as an argument.
# That said, pay special attention to the following cases:
#
# 1. Tensor inputs: there are some tests that check whether the inputs
# were garbage collected after execution. These will fail if there's
# an event that is holding a reference to those inputs.
#
# 2. ShapeEnv arguments: if there is an argument of ShapeEnv type, that
# will be automatically replaced by the new given ShapeEnv instance.
#
# 3. SymTypes arguments: they also hold references to ShapeEnv. So,
# whenever we see them, we create a new instance, replacing the
# ShapeEnv reference.
#
# 4. FX nodes: specifically, FX nodes from the FX graph for symbolic
# shapes. That argument must be replaced when replaying the event at
# ShapeEnvEvent.run, since it has to reference a node from the given
# instance, and not from the recorded instance.
# Event class for reconstructing ShapeEnv at arbitrary time.
#
# Represents a method call that mutates ShapeEnv in a way that affects the
# issued guards, when ShapeEnv.produce_guards is called.
@dataclass
class ShapeEnvEvent:
# ShapeEnv method.
f: Callable[..., Any]
# Arguments and keyword arguments called with.
args: list[object] | None = None
kwargs: dict[str, Any] | None = None
# List of tracked_fakes at the time the method was called.
tracked_fakes: list[TrackedFake] | None = None
# Name of the captured event.
# Used for special handling of particular methods.
name: str | None = None
# Replay itself, but using shape_env as self.
def run(self, shape_env: ShapeEnv | None = None) -> Any:
from torch.fx.experimental.symbolic_shapes import (
is_symbolic,
ShapeEnv,
SymTypes,
)
# Special handling for the constructor event.
if self.f is ShapeEnv:
if not (
shape_env is None and self.args is None and self.kwargs is not None
):
raise AssertionError(
"ShapeEnv constructor requires shape_env=None, args=None, kwargs set"
)
return ShapeEnv(**self.kwargs)
if shape_env is None:
raise AssertionError("shape_env is required for non-constructor events")
args = list(self.args or [])
kwargs = dict(self.kwargs or {})
# Replace any argument of type ShapeEnv by the given one.
args, kwargs = pytree.tree_map_only(
ShapeEnv, lambda _: shape_env, (args, kwargs)
)
# Replace any argument of type SymTypes by a new instance,
# replacing its ShapeEnv reference.
args, kwargs = pytree.tree_map_only(
lambda x: isinstance(x, SymTypes) and is_symbolic(x),
lambda a: type(a)(a.node.with_shape_env(shape_env)),
(args, kwargs),
)
# Converts FX nodes using the mapping argument.
def maybe_convert_node(x: Any) -> Any:
if not isinstance(x, torch.fx.Node):
# Don't do anything to x if it's not an FX node.
return x
# If, at some point, we created an FX node, it means that translation validation is on.
# It also means we are building an FX graph for symbolic shapes at shape_env.graph, and
# we are tracking node names at shape_env.name_to_node.
if not hasattr(shape_env, "name_to_node"):
raise AssertionError("shape_env missing name_to_node attribute")
name_to_node = shape_env.name_to_node # type: ignore[attr-defined]
if x.name not in name_to_node:
raise AssertionError(f"Node {x.name} not found in name_to_node")
return name_to_node[x.name]
# Replaces the value of an specific argument by the result of fn.
def replacearg(index: int, key: str, fn: Callable[..., Any]) -> None:
if index < len(args):
args[index] = fn(args[index])
if key in kwargs:
kwargs[key] = fn(kwargs[key])
if self.is_create_fx_call_function():
# ShapeEnv.create_fx_call_function:
# "args" parameter is a tuple of FX nodes from the FX graph of the old ShapeEnv.
# They must be replaced, since a "call_function" FX node with this tuple as argument
# will be added to the FX graph of the new shape_env.
replacearg(
index=2,
key="args",
fn=lambda args: tuple(maybe_convert_node(a) for a in args),
)
if self.is_evaluate_expr() or self.is_defer_runtime_assert():
# ShapeEnv.evaluate_expr and ShapeEnv.guard_or_defer_runtime_assert:
# "fx_node" parameter is an (optional) FX node that represents the evaluate expression.
# They must be replaced, since it will be part of a "call_function" FX node for
# torch._assert, which will be added to the FX graph of the new shape_env.
replacearg(index=3, key="fx_node", fn=maybe_convert_node)
# Actually call the method with the converted arguments.
return self.f(*args, **kwargs)
def __str__(self) -> str:
name = self.name if self.name is not None else self.f.__name__
return f"event: {name} ({self.args}, {self.kwargs})"
def is_create_fx_call_function(self) -> bool:
return self.name == "_create_fx_call_function"
def is_evaluate_expr(self) -> bool:
return self.name == "evaluate_expr"
def is_defer_runtime_assert(self) -> bool:
return self.name == "guard_or_defer_runtime_assert"
NEST = 0
# Extracts a ShapeEnv instance inside args and kwargs.
# Specifically, it looks for:
# 1. ShapeEnv arguments
# 2. SymInt, SymFloat, or SymBool arguments
# If we find more than one object of any of the above types, we
# also check that the ShapeEnv instance is the same for all of them.
def _extract_shape_env_and_assert_equal(
args: tuple[object, ...] | list[object], kwargs: dict[str, object]
) -> ShapeEnv | None:
from torch.fx.experimental.symbolic_shapes import is_symbolic, ShapeEnv, SymTypes
def assert_equal(old: ShapeEnv | None, new: ShapeEnv) -> ShapeEnv:
if old is not None:
if old is not new:
raise AssertionError("call with different ShapeEnv")
return new
shape_env = None
for val in itertools.chain(args, kwargs.values()):
if isinstance(val, ShapeEnv):
shape_env = assert_equal(shape_env, val)
if isinstance(val, SymTypes) and is_symbolic(val):
shape_env = assert_equal(shape_env, val.node.shape_env)
return shape_env
# Decorator for recording the given function as a replayable event.
#
# This decorator should be used at every function that mutates the state of
# ShapeEnv in some way that affects the resulting issued guards (i.e. when
# ShapeEnv.produce_guards is called).
#
# save_tracked_fakes: saves a snapshot of the TrackedFake list.
# This is used when calling ShapeEnv.produce_guards at arbitrary points in time.
#
# name: the name of the function being recorded. Normally (and by default) this
# is taken from the decorated function but can be set if you need to override
# it.
#
# When to save the list of TrackedFake?
# =====================================
# We should save the list of TrackedFake whenever the translation validation
# bisection may actually stop and call the produce_guards method at the moment
# right after the recorded function was played. In other words, since the
# bisection bisects through torch._assert calls, we should save in all methods
# that adds a torch._assert call to the symbolic shapes FX graph.
#
# At the moment, there are 2 methods that save the list:
# - ShapeEnv.evaluate_expr
# - ShapeEnv.guard_or_defer_runtime_assert
def record_shapeenv_event(
*, save_tracked_fakes: bool = False, name: str | None = None
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
def decorator(fn: Callable[_P, _R]) -> Callable[_P, _R]:
if not callable(fn):
raise AssertionError(f"Expected callable, got {type(fn)}")
args = inspect.getfullargspec(fn).args
if not (args and args[0] == "self"):
raise AssertionError(
"record_shapeenv_event should only wrap methods on ShapeEnv; refactor your "
"code so that it calls into a method on ShapeEnv"
)
nonlocal name
if name is None:
name = fn.__name__
@functools.wraps(fn)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
if not isinstance(args[0], ShapeEnv):
raise AssertionError(f"Expected ShapeEnv, got {type(args[0])}")
global NEST
trace_shape_events_log.debug(
"%scall %s(*%r, **%r)", " " * NEST, name, args[1:], kwargs
)
NEST += 1
def retlog(r: _R) -> _R:
trace_shape_events_log.debug("%s-> %s", " " * (NEST - 1), r)
return r
shape_env = args[0]
try:
if not shape_env.should_record_events or shape_env.is_recording: # type: ignore[has-type]
# If ShapeEnv is already recording an event, call the wrapped
# function directly.
#
# NB: here, we skip the check of whether all ShapeEnv instances
# are equal, in favor of a faster dispatch.
return retlog(fn(*args, **kwargs))
# Retrieve an instance of ShapeEnv.
# Assumption: the collection of args and kwargs may not reference
# different ShapeEnv instances.
self = _extract_shape_env_and_assert_equal(args, kwargs)
# If we are calling this function without any ShapeEnv instance
# alive in its arguments, we don't record and call the original.
if self is None:
return retlog(fn(*args, **kwargs))
# Otherwise, start recording and call the function.
with self._recording():
# Take a snapshot of the current tracked_fakes.
tracked_fakes = (
self._snapshot_tracked_fakes() if save_tracked_fakes else None
)
# Record the event for 'fn'.
event = ShapeEnvEvent(
fn,
list(args),
kwargs,
tracked_fakes,
name=name,
)
# Play the event on this ShapeEnv.
# NB: It's important to put the event first, because running
# the event can trigger internal events that must be ordered
# after this event. However, if an exception happens, we do
# NOT want to have the event in the list, so pop it off from
# the record if an error happened
self.events.append(event)
try:
return retlog(event.run(self))
except Exception:
self.events.pop()
raise
except Exception:
if not shape_env.should_record_events or shape_env.is_recording:
# If ShapeEnv is disabled or already recording an event, re-raise the exception without logging.
raise
log.error( # noqa: G201
"failed while running %s(*%s, **%s)",
name,
args[1:],
kwargs,
exc_info=log.isEnabledFor(logging.INFO),
)
raise
finally:
NEST -= 1
return wrapper
return decorator
# Replays the ShapeEnvEvents list.
# It assumes the first event is the constructor call.
#
# fn: transforms an old FX node into one corresponding to the newly created ShapeEnv.
def replay_shape_env_events(events: list[ShapeEnvEvent]) -> ShapeEnv:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
constructor_event = events[0]
if constructor_event.f != ShapeEnv:
raise AssertionError(
f"First event must be ShapeEnv constructor, got {constructor_event.f}"
)
# Constructs the new ShapeEnv.
shape_env = constructor_event.run()
for event in events[1:]:
try:
# Actually replays each event.
# We need to call create_mapping_fn every time, since the node list might
# change after each event is replayed.
event.run(shape_env)
except Exception:
log.error("failed when running event: %s", event)
raise
return shape_env
# FakeTensor metadata.
# This is to be used in place of FakeTensor placeholders when calling
# ShapeEnv.produce_guards.
@dataclass
class FakeTensorMeta:
tensor_size: tuple[int | torch.SymInt, ...]
tensor_stride: tuple[int | torch.SymInt, ...]
tensor_storage_offset: int | torch.SymInt
is_nested: bool
def size(self) -> tuple[int | torch.SymInt, ...]:
return self.tensor_size
def stride(self) -> tuple[int | torch.SymInt, ...]:
return self.tensor_stride
def storage_offset(self) -> int | torch.SymInt:
return self.tensor_storage_offset
def dim(self) -> int:
return len(self.tensor_size)
@staticmethod
def from_fake(fake: torch.Tensor) -> FakeTensorMeta:
return FakeTensorMeta(
fake.size(), fake.stride(), fake.storage_offset(), fake.is_nested
)
# [Note: ShapeEnv State Equality]
# ===============================
#
# What is considered ShapeEnv state?
# ----------------------------------
# We consider to be the state of a ShapeEnv instance everything that
# is not in the inline tuple inside remove_nonstate_variables function.
# That is: the fields within ShapeEnv that modify the flow of execution
# of the program.
#
# So, for example: the replacements field might influence on how an
# expression is simplified. That, in turn, may result in a guard being
# statically known (i.e. not added).
#
# On the other hand, var_to_stack serves only changes what is printed
# in the screen, i.e. used only for debugging purposes. Therefore, we
# should not consider it when comparing states.
#
# What to do on NotEqualError?
# ----------------------------
# Here are a few possible causes for getting a NotEqualError raised:
#
# 1. New field that does not belong in the ShapeEnv state.
# For example: log field of type ShapeEnvLoggerAdapter. Different
# ShapeEnv instances will always have different ShapeEnvLoggerAdapter
# instances, i.e. equality comparison would fail.
# Solution: add it to the inlined tuple inside remove_nonstate_variables
# function inside check_equal method.
#
# 2. New field that is not directly comparable across instances.
# For example: guards field of type List[ShapeGuard]. More specifically,
# the ShapeGuard type holds an expression and a stack information
# for debugging purposes. When replaying the even on a new ShapeEnv
# instance, the stack would be different, which would trigger this error.
# Solution: add a special case to the map_value function inside
# check_equal function.
#
# 3. Mutation of ShapeEnv on some not recorded function.
# If a mutation of the state of ShapeEnv happens inside a function
# that is not recorded (or that no caller in the stack is recorded),
# then, the replayed ShapeEnv won't catch that.
# Solution: decorate the function with record_shape_env_event.
# Checks whether the state of two ShapeEnv are equal w.r.t. the guards
# returned by ShapeEnv.produce_guards.
def shape_env_check_state_equal(
env1: ShapeEnv,
env2: ShapeEnv,
non_state_variable_names: tuple[str, ...],
map_value: Callable[[str, object], object],
) -> None:
# Collect and remove variables that don't necessarily represent the state
# of a ShapeEnv. Note: we copy the dictionary so that we don't modify the
# instance itself.
env1_vars = vars(env1).copy()
env2_vars = vars(env2).copy()
for v in non_state_variable_names:
if v in env1_vars:
env1_vars.pop(v)
if v in env2_vars:
env2_vars.pop(v)
# Function for transforming the mismatched values into string.
# Needed, since dict and set entries order might not be the same every time.
def value_to_str(value: Any) -> str:
if isinstance(value, dict):
return (
"{"
+ ", ".join(f"{k}: {value[k]}" for k in sorted(value.keys(), key=str))
+ "}"
)
if isinstance(value, set):
return "{" + ", ".join(f"{v}" for v in sorted(value)) + "}"
return str(value)
# Compares env1_vars with env2_vars.
# Here, we allow the value of each field to be mapped, so that we appropriately
# compare the two values.
def compare_vars(
map_value: Callable[[str, object], object],
) -> list[tuple[str, str, str]]:
env1_set, env2_set = set(env1_vars), set(env2_vars)
# First, compare the set of keys in each vars dictionary.
if env1_set != env2_set:
raise NotEqualError(
"field set mismatch:",
[
(
"found unique fields:",
str(sorted(env1_set - env2_set)),
str(sorted(env2_set - env1_set)),
),
],
)
# Then, sort the keys, and compare the mapped values of each key.
sorted_keys = list(env1_set)
sorted_keys.sort()
mapped_dict = [
(k, map_value(k, env1_vars[k]), map_value(k, env2_vars[k]))
for k in sorted_keys
]
# Return a list of tuples representing the fields that did not match
# alongside their respective mapped values.
return [
(f"{k}: values don't match.", value_to_str(val1), value_to_str(val2))
for k, val1, val2 in mapped_dict
if val1 != val2
]
# Accumulate the mismatching fields.
errors = compare_vars(map_value)
if len(errors) > 0:
raise NotEqualError("field values don't match:", errors)
class NotEqualError(Exception):
def __init__(
self,
msg: str,
mismatched: list[tuple[str, str, str]],
) -> None:
details = "\n".join(
[
"\n".join(
[
f"==> {inner_msg}",
f" > Left: {str1}",
f" > Right: {str2}",
]
)
for inner_msg, str1, str2 in mismatched
]
)
super().__init__(
f"""\
ShapeEnv not equal: {msg}
{details}
"""
)
@@ -0,0 +1,16 @@
class Equality:
def __init__(self, lhs: object, rhs: object):
self.lhs = lhs
self.rhs = rhs
def __str__(self) -> str:
return f"{self.lhs} = {self.rhs}"
def __repr__(self) -> str:
return f"{self.lhs} = {self.rhs}"
def __eq__(self, other: object) -> bool:
if isinstance(other, Equality):
return self.lhs == other.lhs and self.rhs == other.rhs
else:
return False
@@ -0,0 +1,147 @@
# mypy: allow-untyped-decorators
# mypy: allow-untyped-defs
import ast
import copy
import functools
import inspect
import textwrap
from collections.abc import Callable
from types import FunctionType
from typing import Any, cast
import torch
from torch._sources import normalize_source_lines
from torch.fx._symbolic_trace import Tracer
from torch.fx.graph import Graph
class AST_Rewriter(ast.NodeTransformer):
"""
Take a FunctionType object representing a `forward` method, then
perform an AST rewrite to swap out nodes that are not symbolically
traceable with a callsite to the FX alternative.
To support swapping out an AST node, define a new `visit` method on
that node. For more details, see:
https://docs.python.org/3/library/ast.html#ast.NodeTransformer
"""
# This function checks for new keys added in the globals dict. TorchDynamo
# can insert new keys in the global dict and upset the check. Therefore, put
# a disable here. This function is an optimization pass and not really
# suitable for dynamo tracing anyways.
@torch._dynamo.disable
def rewrite(self, fn: FunctionType):
# Normalize the source lines
sourcelines, _ = inspect.getsourcelines(fn)
sourcelines = normalize_source_lines(sourcelines)
source = "".join(sourcelines)
normalized_str = textwrap.dedent(source)
# Rewrite the original AST
source_ast = ast.parse(normalized_str)
dest_ast = ast.fix_missing_locations(self.visit(source_ast))
# Pull out the compiled function from the newly-created Module
code = compile(dest_ast, "", "exec")
globals_dict = copy.copy(fn.__globals__)
keys_before = set(globals_dict.keys())
exec(code, globals_dict)
new_keys = list(set(globals_dict.keys()) - keys_before)
if len(new_keys) != 1:
raise AssertionError(f"Expected 1 new key, got {len(new_keys)}")
fn_compiled = globals_dict[new_keys[0]]
# return the compiled function with the original globals
def change_func_globals(f, globals):
"""Based on https://stackoverflow.com/a/13503277/2988730 (@unutbu)"""
# __globals__ is a private member of the function class
# so we have to copy the function, f, all of its member, except f.__globals__
g = FunctionType(
f.__code__,
globals,
name=f.__name__,
argdefs=f.__defaults__,
closure=f.__closure__,
)
g = functools.update_wrapper(g, f)
g.__kwdefaults__ = copy.copy(f.__kwdefaults__) # type:ignore[attr-defined]
return g
# Return the correct FunctionType object
return change_func_globals(fn_compiled, globals=fn.__globals__)
def visit_Assert(self, node):
"""
Swap out the Assert node (Python's `assert`) with a callsite to the
symbolically-traceable torch._assert function
"""
# Create the Call node
n = ast.parse("torch._assert()", mode="eval")
if not isinstance(n, ast.Expression):
raise AssertionError(f"Expected ast.Expression, got {type(n)}")
call_node = n.body
if not isinstance(call_node, ast.Call):
raise AssertionError(f"Expected ast.Call, got {type(call_node)}")
msg = node.msg if node.msg else ast.Constant(value="", kind=None)
call_node.args = [node.test, msg]
# Ensure that the new node conforms to the Python AST grammar
expr_wrapper = ast.Expr(value=call_node)
# Return the new Call node to signify that we want to use it as
# a replacement for the original _assert node
return ast.copy_location(expr_wrapper, node)
def visit_AnnAssign(self, node):
"""
Swap out Python's AnnAssign with an Assign node where the annotation function is called.
Example:
Original:
y: Tensor_Type(1,2,3, Dyn) = f2(x)
Output:
y = annotate(f2(x),Tensor_Type((1,2,3,Dyn)))
"""
return ast.Assign(
targets=[node.target],
value=ast.Call(
func=ast.Name(id="annotate", ctx=ast.Load()),
args=[node.value, node.annotation],
keywords=[],
),
)
class RewritingTracer(Tracer):
def trace(
self,
root: torch.nn.Module | Callable,
concrete_args: dict[str, Any] | None = None,
) -> Graph:
return super().trace(_rewrite(root), concrete_args)
def _rewrite(fn: torch.nn.Module | Callable) -> torch.nn.Module | Callable:
if isinstance(fn, torch.nn.Module):
# Rewrite this module's `forward` as well as the `forward`s of
# all of this module's recursive descendents. Return the new,
# rewritten module hierarchy.
def rewrite_module(m: torch.nn.Module):
class RewrittenModule(torch.nn.Module):
def __init__(self, orig):
super().__init__()
for k, v in orig.__dict__.items():
if isinstance(v, torch.nn.Module):
self.__dict__[k] = copy.copy(rewrite_module(v))
else:
self.__dict__[k] = copy.copy(v)
RewrittenModule.forward = AST_Rewriter().rewrite(
cast(FunctionType, m.forward)
)
return RewrittenModule(m)
return rewrite_module(fn)
else:
# Rewrite this single free function
return AST_Rewriter().rewrite(cast(FunctionType, fn))
@@ -0,0 +1,155 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import inspect
from typing import Any, TYPE_CHECKING
import torch
import torch.fx
from torch._jit_internal import boolean_dispatched
from torch.fx import Transformer
from torch.fx.operator_schemas import _torchscript_type_to_python_type
if TYPE_CHECKING:
from torch.fx.graph_module import GraphModule
from torch.fx.node import Argument, Target
class AnnotateTypesWithSchema(Transformer):
"""
Use Python function signatures to annotate types for `Nodes` within an FX graph.
This pulls out Python function signatures for:
1. Standard `torch.nn` Module calls
2. `torch.nn.functional` calls
3. Attribute fetches via `get_attr`
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = AnnotateTypesWithSchema(traced).transform()
"""
def __init__(
self,
module: GraphModule,
annotate_functionals: bool = True,
annotate_modules: bool = True,
annotate_get_attrs: bool = True,
):
super().__init__(module)
self.annotate_functionals = annotate_functionals
self.annotate_modules = annotate_modules
self.annotate_get_attrs = annotate_get_attrs
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
python_ret_type = None
if self.annotate_functionals and target.__module__ == "torch.nn.functional":
target_for_analysis = target
if target in boolean_dispatched:
# HACK: `boolean_dispatch` as used in `torch.nn.functional` makes it so that we have
# a 2-way dispatch based on a boolean value. Here we check that the `true` and `false`
# branches of the dispatch have exactly the same signature. If they do, use the `true`
# branch signature for analysis. Otherwise, leave this un-normalized
if isinstance(target, str):
raise AssertionError("target should not be a string here")
dispatched = boolean_dispatched[target]
if_true, if_false = dispatched["if_true"], dispatched["if_false"]
# TODO: can we emit the union of these? What are the implications on TorchScript
# compilation?
if (
inspect.signature(if_true).return_annotation
!= inspect.signature(if_false).return_annotation
):
return super().call_function(target, args, kwargs)
target_for_analysis = if_true
python_ret_type = self._extract_python_return_type(target_for_analysis)
return_proxy = super().call_function(target, args, kwargs)
return_proxy.node.type = (
return_proxy.node.type if return_proxy.node.type else python_ret_type
)
return return_proxy
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
python_ret_type = None
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
submod = self.fetch_attr(target)
if self.annotate_modules and hasattr(submod.__class__, "__name__"):
classname = submod.__class__.__name__
if getattr(torch.nn, classname, None) == submod.__class__:
python_ret_type = self._extract_python_return_type(submod.forward)
return_proxy = super().call_module(target, args, kwargs)
return_proxy.node.type = (
return_proxy.node.type if return_proxy.node.type else python_ret_type
)
return return_proxy
def get_attr(
self,
target: torch.fx.node.Target,
args: tuple[Argument, ...],
kwargs: dict[str, Any],
):
attr_proxy = super().get_attr(target, args, kwargs)
if self.annotate_get_attrs:
module_itr = self.module
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
atoms = target.split(".")
for i, atom in enumerate(atoms):
if not hasattr(module_itr, atom):
raise RuntimeError(
f"Node referenced nonextent target {'.'.join(atoms[:i])}!"
)
module_itr = getattr(module_itr, atom)
maybe_inferred_ts_type = torch._C._jit_try_infer_type(module_itr)
if maybe_inferred_ts_type.success():
python_type = _torchscript_type_to_python_type(
maybe_inferred_ts_type.type()
)
attr_proxy.node.type = (
python_type if not attr_proxy.node.type else attr_proxy.node.type
)
return attr_proxy
def _extract_python_return_type(self, target: Target) -> Any | None:
"""
Given a Python call target, try to extract the Python return annotation
if it is available, otherwise return None
Args:
target (Callable): Python callable to get return annotation for
Returns:
Optional[Any]: Return annotation from the `target`, or None if it was
not available.
"""
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
try:
sig = inspect.signature(target)
except (ValueError, TypeError):
return None
return (
sig.return_annotation
if sig.return_annotation is not inspect.Signature.empty
else None
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
# mypy: disable-error-code=attr-defined
from .core import reify, unify # noqa: F403
from .more import unifiable # noqa: F403
from .variable import isvar, Var, var, variables, vars # noqa: F403
@@ -0,0 +1,141 @@
# mypy: allow-untyped-defs
from collections.abc import Iterator # type: ignore[import]
from functools import partial
from .dispatch import dispatch
from .unification_tools import assoc # type: ignore[import]
from .utils import transitive_get as walk
from .variable import isvar
__all__ = ["reify", "unify"]
###############
# Reification #
###############
@dispatch(Iterator, dict)
def _reify(t, s):
return map(partial(reify, s=s), t)
# return (reify(arg, s) for arg in t)
_reify
@dispatch(tuple, dict) # type: ignore[no-redef]
def _reify(t, s):
return tuple(reify(iter(t), s))
_reify
@dispatch(list, dict) # type: ignore[no-redef]
def _reify(t, s):
return list(reify(iter(t), s))
_reify
@dispatch(dict, dict) # type: ignore[no-redef]
def _reify(d, s):
return {k: reify(v, s) for k, v in d.items()}
_reify
@dispatch(object, dict) # type: ignore[no-redef]
def _reify(o, s):
return o # catch all, just return the object
def reify(e, s):
"""Replace variables of expression with substitution
>>> # xdoctest: +SKIP
>>> x, y = var(), var()
>>> e = (1, x, (3, y))
>>> s = {x: 2, y: 4}
>>> reify(e, s)
(1, 2, (3, 4))
>>> e = {1: x, 3: (y, 5)}
>>> reify(e, s)
{1: 2, 3: (4, 5)}
"""
if isvar(e):
return reify(s[e], s) if e in s else e
return _reify(e, s)
###############
# Unification #
###############
seq = tuple, list, Iterator
@dispatch(seq, seq, dict) # type: ignore[arg-type]
def _unify(u, v, s):
if len(u) != len(v):
return False
for uu, vv in zip(u, v): # avoiding recursion
s = unify(uu, vv, s)
if s is False:
return False
return s
#
# @dispatch((set, frozenset), (set, frozenset), dict)
# def _unify(u, v, s):
# i = u & v
# u = u - i
# v = v - i
# return _unify(sorted(u), sorted(v), s)
#
#
# @dispatch(dict, dict, dict)
# def _unify(u, v, s):
# if len(u) != len(v):
# return False
# for key, uval in iteritems(u):
# if key not in v:
# return False
# s = unify(uval, v[key], s)
# if s is False:
# return False
# return s
#
#
# @dispatch(object, object, dict)
# def _unify(u, v, s):
# return False # catch all
@dispatch(object, object, dict)
def unify(u, v, s): # no check at the moment
"""Find substitution so that u == v while satisfying s
>>> x = var("x")
>>> unify((1, x), (1, 2), {})
{~x: 2}
"""
u = walk(u, s)
v = walk(v, s)
if u == v:
return s
if isvar(u):
return assoc(s, u, v)
if isvar(v):
return assoc(s, v, u)
return _unify(u, v, s)
unify
@dispatch(object, object) # type: ignore[no-redef]
def unify(u, v):
return unify(u, v, {})
@@ -0,0 +1,8 @@
from functools import partial
from .multipledispatch import dispatch as _dispatch # type: ignore[import]
namespace = {} # type: ignore[var-annotated]
dispatch = partial(_dispatch, namespace=namespace)
@@ -0,0 +1,129 @@
# mypy: allow-untyped-defs
from .core import reify, unify # type: ignore[attr-defined]
from .unification_tools import first, groupby # type: ignore[import]
from .utils import _toposort, freeze
from .variable import isvar
class Dispatcher:
def __init__(self, name):
self.name = name
self.funcs = {}
self.ordering = []
def add(self, signature, func):
self.funcs[freeze(signature)] = func
self.ordering = ordering(self.funcs)
def __call__(self, *args, **kwargs):
func, _ = self.resolve(args)
return func(*args, **kwargs)
def resolve(self, args):
n = len(args)
for signature in self.ordering:
if len(signature) != n:
continue
s = unify(freeze(args), signature)
if s is not False:
result = self.funcs[signature]
return result, s
raise NotImplementedError(
"No match found. \nKnown matches: "
+ str(self.ordering)
+ "\nInput: "
+ str(args)
)
def register(self, *signature):
def _(func):
self.add(signature, func)
return self
return _
class VarDispatcher(Dispatcher):
"""A dispatcher that calls functions with variable names
>>> # xdoctest: +SKIP
>>> d = VarDispatcher("d")
>>> x = var("x")
>>> @d.register("inc", x)
... def f(x):
... return x + 1
>>> @d.register("double", x)
... def f(x):
... return x * 2
>>> d("inc", 10)
11
>>> d("double", 10)
20
"""
def __call__(self, *args, **kwargs):
func, s = self.resolve(args)
d = {k.token: v for k, v in s.items()}
return func(**d)
global_namespace = {} # type: ignore[var-annotated]
def match(*signature, **kwargs):
namespace = kwargs.get("namespace", global_namespace)
dispatcher = kwargs.get("Dispatcher", Dispatcher)
def _(func):
name = func.__name__
if name not in namespace:
namespace[name] = dispatcher(name)
d = namespace[name]
d.add(signature, func)
return d
return _
def supercedes(a, b):
"""``a`` is a more specific match than ``b``"""
if isvar(b) and not isvar(a):
return True
s = unify(a, b)
if s is False:
return False
s = {k: v for k, v in s.items() if not isvar(k) or not isvar(v)}
if reify(a, s) == a:
return True
if reify(b, s) == b:
return False
# Taken from multipledispatch
def edge(a, b, tie_breaker=hash):
"""A should be checked before B
Tie broken by tie_breaker, defaults to ``hash``
"""
if supercedes(a, b):
if supercedes(b, a):
return tie_breaker(a) > tie_breaker(b)
else:
return True
return False
# Taken from multipledispatch
def ordering(signatures):
"""A sane ordering of signatures to check, first to last
Topological sort of edges as given by ``edge`` and ``supercedes``
"""
signatures = list(map(tuple, signatures))
edges = [(a, b) for a in signatures for b in signatures if edge(a, b)]
edges = groupby(first, edges)
for s in signatures:
if s not in edges:
edges[s] = []
edges = {k: [b for a, b in v] for k, v in edges.items()} # type: ignore[attr-defined, assignment]
return _toposort(edges)
@@ -0,0 +1,131 @@
# mypy: allow-untyped-defs
from .core import ( # type: ignore[attr-defined]
_reify as core_reify,
_unify as core_unify,
reify,
unify,
)
from .dispatch import dispatch
__all__ = ["unifiable", "reify_object", "unify_object"]
def unifiable(cls):
"""Register standard unify and reify operations on class
This uses the type and __dict__ or __slots__ attributes to define the
nature of the term
See Also:
>>> # xdoctest: +SKIP
>>> class A(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
>>> unifiable(A)
<class 'unification.more.A'>
>>> x = var("x")
>>> a = A(1, 2)
>>> b = A(1, x)
>>> unify(a, b, {})
{~x: 2}
"""
core_unify.add((cls, cls, dict), unify_object) # type: ignore[attr-defined]
core_reify.add((cls, dict), reify_object) # type: ignore[attr-defined]
return cls
#########
# Reify #
#########
def reify_object(o, s):
"""Reify a Python object with a substitution
>>> # xdoctest: +SKIP
>>> class Foo(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
...
... def __str__(self):
... return "Foo(%s, %s)" % (str(self.a), str(self.b))
>>> x = var("x")
>>> f = Foo(1, x)
>>> print(f)
Foo(1, ~x)
>>> print(reify_object(f, {x: 2}))
Foo(1, 2)
"""
if hasattr(o, "__slots__"):
return _reify_object_slots(o, s)
else:
return _reify_object_dict(o, s)
def _reify_object_dict(o, s):
obj = object.__new__(type(o))
d = reify(o.__dict__, s)
if d == o.__dict__:
return o
obj.__dict__.update(d)
return obj
def _reify_object_slots(o, s):
attrs = [getattr(o, attr) for attr in o.__slots__]
new_attrs = reify(attrs, s)
if attrs == new_attrs:
return o
else:
newobj = object.__new__(type(o))
for slot, attr in zip(o.__slots__, new_attrs):
setattr(newobj, slot, attr)
return newobj
@dispatch(slice, dict)
def _reify(o, s):
"""Reify a Python ``slice`` object"""
return slice(*reify((o.start, o.stop, o.step), s))
#########
# Unify #
#########
def unify_object(u, v, s):
"""Unify two Python objects
Unifies their type and ``__dict__`` attributes
>>> # xdoctest: +SKIP
>>> class Foo(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
...
... def __str__(self):
... return "Foo(%s, %s)" % (str(self.a), str(self.b))
>>> x = var("x")
>>> f = Foo(1, x)
>>> g = Foo(1, 2)
>>> unify_object(f, g, {})
{~x: 2}
"""
if type(u) is not type(v):
return False
if hasattr(u, "__slots__"):
return unify(
[getattr(u, slot) for slot in u.__slots__],
[getattr(v, slot) for slot in v.__slots__],
s,
)
else:
return unify(u.__dict__, v.__dict__, s)
@dispatch(slice, slice, dict)
def _unify(u, v, s):
"""Unify a Python ``slice`` object"""
return unify((u.start, u.stop, u.step), (v.start, v.stop, v.step), s)
@@ -0,0 +1,7 @@
from .core import dispatch
from .dispatcher import (
Dispatcher,
halt_ordering,
MDNotImplementedError,
restart_ordering,
)
@@ -0,0 +1,146 @@
# mypy: allow-untyped-defs
import operator
from .utils import _toposort, groupby
from .variadic import isvariadic
__all__ = [
"AmbiguityWarning",
"supercedes",
"consistent",
"ambiguous",
"ambiguities",
"super_signature",
"edge",
"ordering",
]
class AmbiguityWarning(Warning):
pass
def supercedes(a, b):
"""A is consistent and strictly more specific than B"""
if len(a) < len(b):
# only case is if a is empty and b is variadic
return not a and len(b) == 1 and isvariadic(b[-1])
elif len(a) == len(b):
return all(map(issubclass, a, b))
else:
# len(a) > len(b)
p1 = 0
p2 = 0
while p1 < len(a) and p2 < len(b):
cur_a = a[p1]
cur_b = b[p2]
if not (isvariadic(cur_a) or isvariadic(cur_b)):
if not issubclass(cur_a, cur_b):
return False
p1 += 1
p2 += 1
elif isvariadic(cur_a):
if p1 != len(a) - 1:
raise AssertionError(
f"Expected p1={p1} to equal len(a)-1={len(a) - 1}"
)
return p2 == len(b) - 1 and issubclass(cur_a, cur_b)
elif isvariadic(cur_b):
if p2 != len(b) - 1:
raise AssertionError(
f"Expected p2={p2} to equal len(b)-1={len(b) - 1}"
)
if not issubclass(cur_a, cur_b):
return False
p1 += 1
return p2 == len(b) - 1 and p1 == len(a)
def consistent(a, b):
"""It is possible for an argument list to satisfy both A and B"""
# Need to check for empty args
if not a:
return not b or isvariadic(b[0])
if not b:
return not a or isvariadic(a[0])
# Non-empty args check for mutual subclasses
if len(a) == len(b):
return all(issubclass(aa, bb) or issubclass(bb, aa) for aa, bb in zip(a, b))
else:
p1 = 0
p2 = 0
while p1 < len(a) and p2 < len(b):
cur_a = a[p1]
cur_b = b[p2]
if not issubclass(cur_b, cur_a) and not issubclass(cur_a, cur_b):
return False
if not (isvariadic(cur_a) or isvariadic(cur_b)):
p1 += 1
p2 += 1
elif isvariadic(cur_a):
p2 += 1
elif isvariadic(cur_b):
p1 += 1
# We only need to check for variadic ends
# Variadic types are guaranteed to be the last element
return (
isvariadic(cur_a) # type: ignore[possibly-undefined]
and p2 == len(b)
or isvariadic(cur_b) # type: ignore[possibly-undefined]
and p1 == len(a)
)
def ambiguous(a, b):
"""A is consistent with B but neither is strictly more specific"""
return consistent(a, b) and not (supercedes(a, b) or supercedes(b, a))
def ambiguities(signatures):
"""All signature pairs such that A is ambiguous with B"""
signatures = list(map(tuple, signatures))
return {
(a, b)
for a in signatures
for b in signatures
if hash(a) < hash(b)
and ambiguous(a, b)
and not any(supercedes(c, a) and supercedes(c, b) for c in signatures)
}
def super_signature(signatures):
"""A signature that would break ambiguities"""
n = len(signatures[0])
if not all(len(s) == n for s in signatures):
raise AssertionError("All signatures must have the same length")
return [max((type.mro(sig[i]) for sig in signatures), key=len)[0] for i in range(n)]
def edge(a, b, tie_breaker=hash):
"""A should be checked before B
Tie broken by tie_breaker, defaults to ``hash``
"""
# A either supersedes B and B does not supersede A or if B does then call
# tie_breaker
return supercedes(a, b) and (
not supercedes(b, a) or tie_breaker(a) > tie_breaker(b)
)
def ordering(signatures):
"""A sane ordering of signatures to check, first to last
Topological sort of edges as given by ``edge`` and ``supercedes``
"""
signatures = list(map(tuple, signatures))
edges = [(a, b) for a in signatures for b in signatures if edge(a, b)]
edges = groupby(operator.itemgetter(0), edges)
for s in signatures:
if s not in edges:
edges[s] = []
edges = {k: [b for a, b in v] for k, v in edges.items()} # type: ignore[assignment, attr-defined]
return _toposort(edges)
@@ -0,0 +1,92 @@
# mypy: allow-untyped-defs
import inspect
from collections.abc import Callable
from typing import Any, TypeVar
from typing_extensions import TypeVarTuple, Unpack
from .dispatcher import Dispatcher, MethodDispatcher
global_namespace = {} # type: ignore[var-annotated]
__all__ = ["dispatch", "ismethod"]
T = TypeVar("T")
Ts = TypeVarTuple("Ts")
def dispatch(
*types: Unpack[Ts], **kwargs: Any
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""Dispatch function on the types of the inputs
Supports dispatch on all non-keyword arguments.
Collects implementations based on the function name. Ignores namespaces.
If ambiguous type signatures occur a warning is raised when the function is
defined suggesting the additional method to break the ambiguity.
Example:
>>> # xdoctest: +SKIP
>>> @dispatch(int)
... def f(x):
... return x + 1
>>> @dispatch(float)
... def f(x):
... return x - 1
>>> # xdoctest: +SKIP
>>> f(3)
4
>>> f(3.0)
2.0
>>> # Specify an isolated namespace with the namespace keyword argument
>>> my_namespace = {}
>>> @dispatch(int, namespace=my_namespace)
... def foo(x):
... return x + 1
>>> # Dispatch on instance methods within classes
>>> class MyClass(object):
... @dispatch(list)
... def __init__(self, data):
... self.data = data
...
... @dispatch(int)
... def __init__(self, datum):
... self.data = [datum]
>>> MyClass([1, 2, 3]).data
[1, 2, 3]
>>> MyClass(3).data
[3]
"""
namespace = kwargs.get("namespace", global_namespace)
types_tuple: tuple[type, ...] = tuple(types) # type: ignore[arg-type]
def _df(func):
name = func.__name__
if ismethod(func):
dispatcher = inspect.currentframe().f_back.f_locals.get( # type: ignore[union-attr]
name, # type: ignore[union-attr]
MethodDispatcher(name),
)
else:
if name not in namespace:
namespace[name] = Dispatcher(name)
dispatcher = namespace[name]
dispatcher.add(types_tuple, func)
return dispatcher
return _df
def ismethod(func):
"""Is func a method?
Note that this has to work as the method is defined but before the class is
defined. At this stage methods look like functions.
"""
if hasattr(inspect, "signature"):
signature = inspect.signature(func)
return signature.parameters.get("self", None) is not None
else:
spec = inspect.getfullargspec(func) # type: ignore[union-attr, assignment]
return spec and spec.args and spec.args[0] == "self"
@@ -0,0 +1,461 @@
# mypy: allow-untyped-defs
import inspect
import itertools as itl
from typing_extensions import deprecated
from warnings import warn
from .conflict import ambiguities, AmbiguityWarning, ordering, super_signature
from .utils import expand_tuples
from .variadic import isvariadic, Variadic
__all__ = [
"MDNotImplementedError",
"ambiguity_warn",
"halt_ordering",
"restart_ordering",
"variadic_signature_matches_iter",
"variadic_signature_matches",
"Dispatcher",
"source",
"MethodDispatcher",
"str_signature",
"warning_text",
]
class MDNotImplementedError(NotImplementedError):
"""A NotImplementedError for multiple dispatch"""
def ambiguity_warn(dispatcher, ambiguities):
"""Raise warning when ambiguity is detected.
Parameters
----------
dispatcher : Dispatcher
The dispatcher on which the ambiguity was detected
ambiguities : set
Set of type signature pairs that are ambiguous within this dispatcher
See Also
--------
Dispatcher.add
warning_text
"""
warn(warning_text(dispatcher.name, ambiguities), AmbiguityWarning)
@deprecated(
"`halt_ordering` is deprecated, you can safely remove this call.",
category=FutureWarning,
)
def halt_ordering():
"""Deprecated interface to temporarily disable ordering."""
@deprecated(
"`restart_ordering` is deprecated, if you would like to eagerly order the dispatchers, "
"you should call the `reorder()` method on each dispatcher.",
category=FutureWarning,
)
def restart_ordering(on_ambiguity=ambiguity_warn):
"""Deprecated interface to temporarily resume ordering."""
def variadic_signature_matches_iter(types, full_signature):
"""Check if a set of input types matches a variadic signature.
Notes
-----
The algorithm is as follows:
Initialize the current signature to the first in the sequence.
For each type in ``types``:
- If the current signature is variadic
- If the type matches the signature, yield True
- Else, try to get the next signature.
If no signatures are left we can't possibly have a match,
so yield False.
- Else, yield True if the type matches the current signature.
Get the next signature.
"""
sigiter = iter(full_signature)
sig = next(sigiter)
for typ in types:
matches = issubclass(typ, sig)
yield matches
if not isvariadic(sig):
# we're not matching a variadic argument, so move to the next
# element in the signature
sig = next(sigiter)
else:
try:
sig = next(sigiter)
except StopIteration:
if not isvariadic(sig):
raise AssertionError("Expected variadic signature") from None
yield True
else:
# We have signature items left over, so all of our arguments
# haven't matched
yield False
def variadic_signature_matches(types, full_signature):
# No arguments always matches a variadic signature
if not full_signature:
raise AssertionError("full_signature is empty")
return all(variadic_signature_matches_iter(types, full_signature))
class Dispatcher:
"""Dispatch methods based on type signature
Use ``dispatch`` to add implementations
Examples
--------
>>> # xdoctest: +SKIP("bad import name")
>>> from multipledispatch import dispatch
>>> @dispatch(int)
... def f(x):
... return x + 1
>>> @dispatch(float)
... def f(x):
... return x - 1
>>> f(3)
4
>>> f(3.0)
2.0
"""
__slots__ = "__name__", "name", "funcs", "_ordering", "_cache", "doc"
def __init__(self, name, doc=None):
self.name = self.__name__ = name
self.funcs = {}
self.doc = doc
self._cache = {}
def register(self, *types, **kwargs):
"""register dispatcher with new implementation
>>> # xdoctest: +SKIP
>>> f = Dispatcher("f")
>>> @f.register(int)
... def inc(x):
... return x + 1
>>> @f.register(float)
... def dec(x):
... return x - 1
>>> @f.register(list)
... @f.register(tuple)
... def reverse(x):
... return x[::-1]
>>> f(1)
2
>>> f(1.0)
0.0
>>> f([1, 2, 3])
[3, 2, 1]
"""
def _df(func):
self.add(types, func, **kwargs) # type: ignore[call-arg]
return func
return _df
@classmethod
def get_func_params(cls, func):
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
return sig.parameters.values()
@classmethod
def get_func_annotations(cls, func):
"""get annotations of function positional parameters"""
params = cls.get_func_params(func)
if params:
Parameter = inspect.Parameter
params = (
param
for param in params
if param.kind
in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
)
annotations = tuple(param.annotation for param in params)
if all(ann is not Parameter.empty for ann in annotations):
return annotations
def add(self, signature, func):
"""Add new types/method pair to dispatcher
>>> # xdoctest: +SKIP
>>> D = Dispatcher("add")
>>> D.add((int, int), lambda x, y: x + y)
>>> D.add((float, float), lambda x, y: x + y)
>>> D(1, 2)
3
>>> D(1, 2.0)
Traceback (most recent call last):
...
NotImplementedError: Could not find signature for add: <int, float>
>>> # When ``add`` detects a warning it calls the ``on_ambiguity`` callback
>>> # with a dispatcher/itself, and a set of ambiguous type signature pairs
>>> # as inputs. See ``ambiguity_warn`` for an example.
"""
# Handle annotations
if not signature:
annotations = self.get_func_annotations(func)
if annotations:
signature = annotations
# Handle union types
if any(isinstance(typ, tuple) for typ in signature):
for typs in expand_tuples(signature):
self.add(typs, func)
return
new_signature = []
for index, typ in enumerate(signature, start=1):
if not isinstance(typ, (type, list)):
str_sig = ", ".join(
c.__name__ if isinstance(c, type) else str(c) for c in signature
)
raise TypeError(
f"Tried to dispatch on non-type: {typ}\n"
f"In signature: <{str_sig}>\n"
f"In function: {self.name}"
)
# handle variadic signatures
if isinstance(typ, list):
if index != len(signature):
raise TypeError("Variadic signature must be the last element")
if len(typ) != 1:
raise TypeError(
"Variadic signature must contain exactly one element. "
"To use a variadic union type place the desired types "
"inside of a tuple, e.g., [(int, str)]"
)
# pyrefly: ignore [bad-specialization]
new_signature.append(Variadic[typ[0]])
else:
new_signature.append(typ)
self.funcs[tuple(new_signature)] = func
self._cache.clear()
try:
del self._ordering
except AttributeError:
pass
@property
def ordering(self):
try:
return self._ordering
except AttributeError:
return self.reorder()
def reorder(self, on_ambiguity=ambiguity_warn):
self._ordering = od = ordering(self.funcs)
amb = ambiguities(self.funcs)
if amb:
on_ambiguity(self, amb)
return od
def __call__(self, *args, **kwargs):
types = tuple(type(arg) for arg in args)
try:
func = self._cache[types]
except KeyError as e:
func = self.dispatch(*types)
if not func:
raise NotImplementedError(
f"Could not find signature for {self.name}: <{str_signature(types)}>"
) from e
self._cache[types] = func
try:
return func(*args, **kwargs)
except MDNotImplementedError as e:
funcs = self.dispatch_iter(*types)
next(funcs) # burn first
for func in funcs:
try:
return func(*args, **kwargs)
except MDNotImplementedError:
pass
raise NotImplementedError(
"Matching functions for "
f"{self.name}: <{str_signature(types)}> found, but none completed successfully",
) from e
def __str__(self):
return f"<dispatched {self.name}>"
__repr__ = __str__
def dispatch(self, *types):
"""Determine appropriate implementation for this type signature
This method is internal. Users should call this object as a function.
Implementation resolution occurs within the ``__call__`` method.
>>> # xdoctest: +SKIP
>>> from multipledispatch import dispatch
>>> @dispatch(int)
... def inc(x):
... return x + 1
>>> implementation = inc.dispatch(int)
>>> implementation(3)
4
>>> print(inc.dispatch(float))
None
See Also:
``multipledispatch.conflict`` - module to determine resolution order
"""
if types in self.funcs:
return self.funcs[types]
try:
return next(self.dispatch_iter(*types))
except StopIteration:
return None
def dispatch_iter(self, *types):
n = len(types)
for signature in self.ordering:
if len(signature) == n and all(map(issubclass, types, signature)):
result = self.funcs[signature]
yield result
elif len(signature) and isvariadic(signature[-1]):
if variadic_signature_matches(types, signature):
result = self.funcs[signature]
yield result
@deprecated(
"`resolve()` is deprecated, use `dispatch(*types)`", category=FutureWarning
)
def resolve(self, types):
"""Determine appropriate implementation for this type signature
.. deprecated:: 0.4.4
Use ``dispatch(*types)`` instead
"""
return self.dispatch(*types)
def __getstate__(self):
return {"name": self.name, "funcs": self.funcs}
def __setstate__(self, d):
self.name = d["name"]
self.funcs = d["funcs"]
self._ordering = ordering(self.funcs)
self._cache = {}
@property
def __doc__(self): # type: ignore[override]
docs = [f"Multiply dispatched method: {self.name}"]
if self.doc:
docs.append(self.doc)
other = []
for sig in self.ordering[::-1]:
func = self.funcs[sig]
if func.__doc__:
s = f"Inputs: <{str_signature(sig)}>\n"
s += "-" * len(s) + "\n"
s += func.__doc__.strip()
docs.append(s)
else:
other.append(str_signature(sig))
if other:
docs.append("Other signatures:\n " + "\n ".join(other))
return "\n\n".join(docs)
def _help(self, *args):
return self.dispatch(*map(type, args)).__doc__
def help(self, *args, **kwargs):
"""Print docstring for the function corresponding to inputs"""
print(self._help(*args))
def _source(self, *args):
func = self.dispatch(*map(type, args))
if not func:
raise TypeError("No function found")
return source(func)
def source(self, *args, **kwargs):
"""Print source code for the function corresponding to inputs"""
print(self._source(*args))
def source(func):
s = f"File: {inspect.getsourcefile(func)}\n\n"
s = s + inspect.getsource(func)
return s
class MethodDispatcher(Dispatcher):
"""Dispatch methods based on type signature
See Also:
Dispatcher
"""
__slots__ = ("obj", "cls")
@classmethod
def get_func_params(cls, func):
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
return itl.islice(sig.parameters.values(), 1, None)
def __get__(self, instance, owner):
self.obj = instance
self.cls = owner
return self
def __call__(self, *args, **kwargs):
types = tuple(type(arg) for arg in args)
func = self.dispatch(*types)
if not func:
raise NotImplementedError(
f"Could not find signature for {self.name}: <{str_signature(types)}>"
)
return func(self.obj, *args, **kwargs)
def str_signature(sig):
"""String representation of type signature
>>> str_signature((int, float))
'int, float'
"""
return ", ".join(cls.__name__ for cls in sig)
def warning_text(name, amb):
"""The text for ambiguity warnings"""
text = f"\nAmbiguities exist in dispatched function {name}\n\n"
text += "The following signatures may result in ambiguous behavior:\n"
for pair in amb:
text += "\t" + ", ".join("[" + str_signature(s) + "]" for s in pair) + "\n"
text += "\n\nConsider making the following additions:\n\n"
text += "\n\n".join(
[
"@dispatch(" + str_signature(super_signature(s)) + f")\ndef {name}(...)"
for s in amb
]
)
return text
@@ -0,0 +1,132 @@
# mypy: allow-untyped-defs
from collections import OrderedDict
__all__ = ["raises", "expand_tuples", "reverse_dict", "groupby", "typename"]
def raises(err, lamda): # codespell:ignore lamda
try:
lamda() # codespell:ignore lamda
return False
except err:
return True
def expand_tuples(L):
"""
>>> expand_tuples([1, (2, 3)])
[(1, 2), (1, 3)]
>>> expand_tuples([1, 2])
[(1, 2)]
"""
if not L:
return [()]
elif not isinstance(L[0], tuple):
rest = expand_tuples(L[1:])
return [(L[0],) + t for t in rest]
else:
rest = expand_tuples(L[1:])
return [(item,) + t for t in rest for item in L[0]]
# Taken from theano/theano/gof/sched.py
# Avoids licensing issues because this was written by Matthew Rocklin
def _toposort(edges):
"""Topological sort algorithm by Kahn [1] - O(nodes + vertices)
inputs:
edges - a dict of the form {a: {b, c}} where b and c depend on a
outputs:
L - an ordered list of nodes that satisfy the dependencies of edges
>>> _toposort({1: (2, 3), 2: (3,)})
[1, 2, 3]
>>> # Closely follows the wikipedia page [2]
>>> # [1] Kahn, Arthur B. (1962), "Topological sorting of large networks",
>>> # Communications of the ACM
>>> # [2] http://en.wikipedia.org/wiki/Toposort#Algorithms
"""
incoming_edges = reverse_dict(edges)
incoming_edges = OrderedDict((k, set(val)) for k, val in incoming_edges.items())
S = OrderedDict.fromkeys(v for v in edges if v not in incoming_edges)
L = []
while S:
n, _ = S.popitem()
L.append(n)
for m in edges.get(n, ()):
if n not in incoming_edges[m]:
raise AssertionError(f"Expected {n} in incoming_edges[{m}]")
incoming_edges[m].remove(n)
if not incoming_edges[m]:
S[m] = None
if any(incoming_edges.get(v, None) for v in edges):
raise ValueError("Input has cycles")
return L
def reverse_dict(d):
"""Reverses direction of dependence dict.
>>> d = {"a": (1, 2), "b": (2, 3), "c": ()}
>>> reverse_dict(d) # doctest: +SKIP
{1: ('a',), 2: ('a', 'b'), 3: ('b',)}
.. note::
dict order are not deterministic. As we iterate on the
input dict, it make the output of this function depend on the
dict order. So this function output order should be considered
as undeterministic.
"""
result = OrderedDict() # type: ignore[var-annotated]
for key in d:
for val in d[key]:
# pyrefly: ignore [unsupported-operation]
result[val] = result.get(val, ()) + (key,)
return result
# Taken from toolz
# Avoids licensing issues because this version was authored by Matthew Rocklin
def groupby(func, seq):
"""Group a collection by a key function
>>> names = ["Alice", "Bob", "Charlie", "Dan", "Edith", "Frank"]
>>> groupby(len, names) # doctest: +SKIP
{3: ['Bob', 'Dan'], 5: ['Alice', 'Edith', 'Frank'], 7: ['Charlie']}
>>> iseven = lambda x: x % 2 == 0
>>> groupby(iseven, [1, 2, 3, 4, 5, 6, 7, 8]) # doctest: +SKIP
{False: [1, 3, 5, 7], True: [2, 4, 6, 8]}
See Also:
``countby``
"""
d = OrderedDict() # type: ignore[var-annotated]
for item in seq:
key = func(item)
if key not in d:
d[key] = []
d[key].append(item)
return d
def typename(type):
"""Get the name of `type`.
Parameters
----------
type : Union[Type, Tuple[Type]]
Returns
-------
str
The name of `type` or a tuple of the names of the types in `type`.
Examples
--------
>>> typename(int)
'int'
>>> typename((int, float))
'(int, float)'
"""
try:
return type.__name__
except AttributeError:
if len(type) == 1:
return typename(*type)
return f"({', '.join(map(typename, type))})"
@@ -0,0 +1,96 @@
# mypy: allow-untyped-defs
from .utils import typename
__all__ = ["VariadicSignatureType", "isvariadic", "VariadicSignatureMeta", "Variadic"]
class VariadicSignatureType(type):
# checking if subclass is a subclass of self
def __subclasscheck__(cls, subclass):
other_type = subclass.variadic_type if isvariadic(subclass) else (subclass,)
return subclass is cls or all(
issubclass(other, cls.variadic_type) # type: ignore[attr-defined]
for other in other_type
)
def __eq__(cls, other):
"""
Return True if other has the same variadic type
Parameters
----------
other : object (type)
The object (type) to check
Returns
-------
bool
Whether or not `other` is equal to `self`
"""
return isvariadic(other) and set(cls.variadic_type) == set(other.variadic_type) # type: ignore[attr-defined]
def __hash__(cls):
return hash((type(cls), frozenset(cls.variadic_type))) # type: ignore[attr-defined]
def isvariadic(obj):
"""Check whether the type `obj` is variadic.
Parameters
----------
obj : type
The type to check
Returns
-------
bool
Whether or not `obj` is variadic
Examples
--------
>>> # xdoctest: +SKIP
>>> isvariadic(int)
False
>>> isvariadic(Variadic[int])
True
"""
return isinstance(obj, VariadicSignatureType)
class VariadicSignatureMeta(type):
"""A metaclass that overrides ``__getitem__`` on the class. This is used to
generate a new type for Variadic signatures. See the Variadic class for
examples of how this behaves.
"""
def __getitem__(cls, variadic_type):
if not (isinstance(variadic_type, (type, tuple)) or type(variadic_type)):
raise ValueError(
"Variadic types must be type or tuple of types"
" (Variadic[int] or Variadic[(int, float)]"
)
if not isinstance(variadic_type, tuple):
variadic_type = (variadic_type,)
return VariadicSignatureType(
f"Variadic[{typename(variadic_type)}]",
(),
dict(variadic_type=variadic_type, __slots__=()),
)
class Variadic(metaclass=VariadicSignatureMeta):
"""A class whose getitem method can be used to generate a new type
representing a specific variadic signature.
Examples
--------
>>> # xdoctest: +SKIP
>>> Variadic[int] # any number of int arguments
<class 'multipledispatch.variadic.Variadic[int]'>
>>> Variadic[(int, str)] # any number of one of int or str arguments
<class 'multipledispatch.variadic.Variadic[(int, str)]'>
>>> issubclass(int, Variadic[int])
True
>>> issubclass(int, Variadic[(int, str)])
True
>>> issubclass(str, Variadic[(int, str)])
True
>>> issubclass(float, Variadic[(int, str)])
False
"""
@@ -0,0 +1,419 @@
# mypy: allow-untyped-defs
import collections
import operator
from collections.abc import Mapping
from functools import reduce
__all__ = [
"merge",
"merge_with",
"valmap",
"keymap",
"itemmap",
"valfilter",
"keyfilter",
"itemfilter",
"assoc",
"dissoc",
"assoc_in",
"update_in",
"get_in",
]
def _get_factory(f, kwargs):
factory = kwargs.pop("factory", dict)
if kwargs:
raise TypeError(
f"{f.__name__}() got an unexpected keyword argument '{kwargs.popitem()[0]}'"
)
return factory
def merge(*dicts, **kwargs):
"""Merge a collection of dictionaries
>>> merge({1: "one"}, {2: "two"})
{1: 'one', 2: 'two'}
Later dictionaries have precedence
>>> merge({1: 2, 3: 4}, {3: 3, 4: 4})
{1: 2, 3: 3, 4: 4}
See Also:
merge_with
"""
if len(dicts) == 1 and not isinstance(dicts[0], Mapping):
dicts = dicts[0]
factory = _get_factory(merge, kwargs)
rv = factory()
for d in dicts:
rv.update(d)
return rv
def merge_with(func, *dicts, **kwargs):
"""Merge dictionaries and apply function to combined values
A key may occur in more than one dict, and all values mapped from the key
will be passed to the function as a list, such as func([val1, val2, ...]).
>>> merge_with(sum, {1: 1, 2: 2}, {1: 10, 2: 20})
{1: 11, 2: 22}
>>> merge_with(first, {1: 1, 2: 2}, {2: 20, 3: 30}) # doctest: +SKIP
{1: 1, 2: 2, 3: 30}
See Also:
merge
"""
if len(dicts) == 1 and not isinstance(dicts[0], Mapping):
dicts = dicts[0]
factory = _get_factory(merge_with, kwargs)
result = factory()
for d in dicts:
for k, v in d.items():
if k not in result:
result[k] = [v]
else:
result[k].append(v)
return valmap(func, result, factory)
def valmap(func, d, factory=dict):
"""Apply function to values of dictionary
>>> bills = {"Alice": [20, 15, 30], "Bob": [10, 35]}
>>> valmap(sum, bills) # doctest: +SKIP
{'Alice': 65, 'Bob': 45}
See Also:
keymap
itemmap
"""
rv = factory()
rv.update(zip(d.keys(), map(func, d.values())))
return rv
def keymap(func, d, factory=dict):
"""Apply function to keys of dictionary
>>> bills = {"Alice": [20, 15, 30], "Bob": [10, 35]}
>>> keymap(str.lower, bills) # doctest: +SKIP
{'alice': [20, 15, 30], 'bob': [10, 35]}
See Also:
valmap
itemmap
"""
rv = factory()
rv.update(zip(map(func, d.keys()), d.values()))
return rv
def itemmap(func, d, factory=dict):
"""Apply function to items of dictionary
>>> accountids = {"Alice": 10, "Bob": 20}
>>> itemmap(reversed, accountids) # doctest: +SKIP
{10: "Alice", 20: "Bob"}
See Also:
keymap
valmap
"""
rv = factory()
rv.update(map(func, d.items()))
return rv
def valfilter(predicate, d, factory=dict):
"""Filter items in dictionary by value
>>> iseven = lambda x: x % 2 == 0
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> valfilter(iseven, d)
{1: 2, 3: 4}
See Also:
keyfilter
itemfilter
valmap
"""
rv = factory()
for k, v in d.items():
if predicate(v):
rv[k] = v
return rv
def keyfilter(predicate, d, factory=dict):
"""Filter items in dictionary by key
>>> iseven = lambda x: x % 2 == 0
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> keyfilter(iseven, d)
{2: 3, 4: 5}
See Also:
valfilter
itemfilter
keymap
"""
rv = factory()
for k, v in d.items():
if predicate(k):
rv[k] = v
return rv
def itemfilter(predicate, d, factory=dict):
"""Filter items in dictionary by item
>>> def isvalid(item):
... k, v = item
... return k % 2 == 0 and v < 4
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> itemfilter(isvalid, d)
{2: 3}
See Also:
keyfilter
valfilter
itemmap
"""
rv = factory()
for item in d.items():
if predicate(item):
k, v = item
rv[k] = v
return rv
def assoc(d, key, value, factory=dict):
"""Return a new dict with new key value pair
New dict has d[key] set to value. Does not modify the initial dictionary.
>>> assoc({"x": 1}, "x", 2)
{'x': 2}
>>> assoc({"x": 1}, "y", 3) # doctest: +SKIP
{'x': 1, 'y': 3}
"""
d2 = factory()
d2.update(d)
d2[key] = value
return d2
def dissoc(d, *keys, **kwargs):
"""Return a new dict with the given key(s) removed.
New dict has d[key] deleted for each supplied key.
Does not modify the initial dictionary.
>>> dissoc({"x": 1, "y": 2}, "y")
{'x': 1}
>>> dissoc({"x": 1, "y": 2}, "y", "x")
{}
>>> dissoc({"x": 1}, "y") # Ignores missing keys
{'x': 1}
"""
factory = _get_factory(dissoc, kwargs)
d2 = factory()
if len(keys) < len(d) * 0.6:
d2.update(d)
for key in keys:
if key in d2:
del d2[key]
else:
remaining = set(d)
remaining.difference_update(keys)
for k in remaining:
d2[k] = d[k]
return d2
def assoc_in(d, keys, value, factory=dict):
"""Return a new dict with new, potentially nested, key value pair
>>> purchase = {
... "name": "Alice",
... "order": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> assoc_in(purchase, ["order", "costs"], [0.25, 1.00]) # doctest: +SKIP
{'credit card': '5555-1234-1234-1234',
'name': 'Alice',
'order': {'costs': [0.25, 1.00], 'items': ['Apple', 'Orange']}}
"""
return update_in(d, keys, lambda x: value, value, factory)
def update_in(d, keys, func, default=None, factory=dict):
"""Update value in a (potentially) nested dictionary
inputs:
d - dictionary on which to operate
keys - list or tuple giving the location of the value to be changed in d
func - function to operate on that value
If keys == [k0,..,kX] and d[k0]..[kX] == v, update_in returns a copy of the
original dictionary with v replaced by func(v), but does not mutate the
original dictionary.
If k0 is not a key in d, update_in creates nested dictionaries to the depth
specified by the keys, with the innermost value set to func(default).
>>> inc = lambda x: x + 1
>>> update_in({"a": 0}, ["a"], inc)
{'a': 1}
>>> transaction = {
... "name": "Alice",
... "purchase": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> update_in(transaction, ["purchase", "costs"], sum) # doctest: +SKIP
{'credit card': '5555-1234-1234-1234',
'name': 'Alice',
'purchase': {'costs': 1.75, 'items': ['Apple', 'Orange']}}
>>> # updating a value when k0 is not in d
>>> update_in({}, [1, 2, 3], str, default="bar")
{1: {2: {3: 'bar'}}}
>>> update_in({1: "foo"}, [2, 3, 4], inc, 0)
{1: 'foo', 2: {3: {4: 1}}}
"""
ks = iter(keys)
k = next(ks)
rv = inner = factory()
rv.update(d)
for key in ks:
if k in d:
d = d[k]
dtemp = factory()
dtemp.update(d)
else:
d = dtemp = factory()
inner[k] = inner = dtemp
k = key
if k in d:
inner[k] = func(d[k])
else:
inner[k] = func(default)
return rv
def get_in(keys, coll, default=None, no_default=False):
"""Returns coll[i0][i1]...[iX] where [i0, i1, ..., iX]==keys.
If coll[i0][i1]...[iX] cannot be found, returns ``default``, unless
``no_default`` is specified, then it raises KeyError or IndexError.
``get_in`` is a generalization of ``operator.getitem`` for nested data
structures such as dictionaries and lists.
>>> transaction = {
... "name": "Alice",
... "purchase": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> get_in(["purchase", "items", 0], transaction)
'Apple'
>>> get_in(["name"], transaction)
'Alice'
>>> get_in(["purchase", "total"], transaction)
>>> get_in(["purchase", "items", "apple"], transaction)
>>> get_in(["purchase", "items", 10], transaction)
>>> get_in(["purchase", "total"], transaction, 0)
0
>>> get_in(["y"], {}, no_default=True)
Traceback (most recent call last):
...
KeyError: 'y'
See Also:
itertoolz.get
operator.getitem
"""
try:
return reduce(operator.getitem, keys, coll)
except (KeyError, IndexError, TypeError):
if no_default:
raise
return default
def getter(index):
if isinstance(index, list):
if len(index) == 1:
index = index[0]
return lambda x: (x[index],)
elif index:
return operator.itemgetter(*index)
else:
return lambda x: ()
else:
return operator.itemgetter(index)
def groupby(key, seq):
"""Group a collection by a key function
>>> names = ["Alice", "Bob", "Charlie", "Dan", "Edith", "Frank"]
>>> groupby(len, names) # doctest: +SKIP
{3: ['Bob', 'Dan'], 5: ['Alice', 'Edith', 'Frank'], 7: ['Charlie']}
>>> iseven = lambda x: x % 2 == 0
>>> groupby(iseven, [1, 2, 3, 4, 5, 6, 7, 8]) # doctest: +SKIP
{False: [1, 3, 5, 7], True: [2, 4, 6, 8]}
Non-callable keys imply grouping on a member.
>>> groupby(
... "gender",
... [
... {"name": "Alice", "gender": "F"},
... {"name": "Bob", "gender": "M"},
... {"name": "Charlie", "gender": "M"},
... ],
... ) # doctest:+SKIP
{'F': [{'gender': 'F', 'name': 'Alice'}],
'M': [{'gender': 'M', 'name': 'Bob'},
{'gender': 'M', 'name': 'Charlie'}]}
Not to be confused with ``itertools.groupby``
See Also:
countby
"""
if not callable(key):
key = getter(key)
d = collections.defaultdict(lambda: [].append) # type: ignore[var-annotated]
for item in seq:
d[key(item)](item)
rv = {}
for k, v in d.items():
rv[k] = v.__self__ # type: ignore[var-annotated, attr-defined]
return rv
def first(seq):
"""The first element in a sequence
>>> first("ABC")
'A'
"""
return next(iter(seq))
@@ -0,0 +1,113 @@
# mypy: allow-untyped-defs
__all__ = ["hashable", "transitive_get", "raises", "reverse_dict", "xfail", "freeze"]
def hashable(x):
try:
hash(x)
return True
except TypeError:
return False
def transitive_get(key, d):
"""Transitive dict.get
>>> d = {1: 2, 2: 3, 3: 4}
>>> d.get(1)
2
>>> transitive_get(1, d)
4
"""
while hashable(key) and key in d:
key = d[key]
return key
def raises(err, lamda): # codespell:ignore lamda
try:
lamda() # codespell:ignore lamda
return False
except err:
return True
# Taken from theano/theano/gof/sched.py
# Avoids licensing issues because this was written by Matthew Rocklin
def _toposort(edges):
"""Topological sort algorithm by Kahn [1] - O(nodes + vertices)
inputs:
edges - a dict of the form {a: {b, c}} where b and c depend on a
outputs:
L - an ordered list of nodes that satisfy the dependencies of edges
>>> # xdoctest: +SKIP
>>> _toposort({1: (2, 3), 2: (3,)})
[1, 2, 3]
Closely follows the wikipedia page [2]
[1] Kahn, Arthur B. (1962), "Topological sorting of large networks",
Communications of the ACM
[2] http://en.wikipedia.org/wiki/Toposort#Algorithms
"""
incoming_edges = reverse_dict(edges)
incoming_edges = {k: set(val) for k, val in incoming_edges.items()}
S = {v for v in edges if v not in incoming_edges}
L = []
while S:
n = S.pop()
L.append(n)
for m in edges.get(n, ()):
if n not in incoming_edges[m]:
raise AssertionError(f"Expected {n} in incoming_edges[{m}]")
incoming_edges[m].remove(n)
if not incoming_edges[m]:
S.add(m)
if any(incoming_edges.get(v) for v in edges):
raise ValueError("Input has cycles")
return L
def reverse_dict(d):
"""Reverses direction of dependence dict.
>>> d = {"a": (1, 2), "b": (2, 3), "c": ()}
>>> reverse_dict(d) # doctest: +SKIP
{1: ('a',), 2: ('a', 'b'), 3: ('b',)}
.. note::
dict order are not deterministic. As we iterate on the
input dict, it make the output of this function depend on the
dict order. So this function output order should be considered
as undeterministic.
"""
result = {} # type: ignore[var-annotated]
for key in d:
for val in d[key]:
# pyrefly: ignore [unsupported-operation]
result[val] = result.get(val, ()) + (key,)
return result
def xfail(func):
try:
func()
raise Exception("XFailed test passed") # pragma:nocover # noqa: TRY002
except Exception:
pass
def freeze(d):
"""Freeze container to hashable form
>>> freeze(1)
1
>>> freeze([1, 2])
(1, 2)
>>> freeze({1: 2}) # doctest: +SKIP
frozenset([(1, 2)])
"""
if isinstance(d, dict):
return frozenset(map(freeze, d.items()))
if isinstance(d, set):
return frozenset(map(freeze, d))
if isinstance(d, (tuple, list)):
return tuple(map(freeze, d))
return d
@@ -0,0 +1,90 @@
# mypy: allow-untyped-defs
from contextlib import contextmanager
from .dispatch import dispatch
from .utils import hashable
_global_logic_variables = set() # type: ignore[var-annotated]
_glv = _global_logic_variables
class Var:
"""Logic Variable"""
_id = 1
def __new__(cls, *token):
if len(token) == 0:
token = f"_{Var._id}" # type: ignore[assignment]
Var._id += 1
elif len(token) == 1:
token = token[0]
obj = object.__new__(cls)
obj.token = token # type: ignore[attr-defined]
return obj
def __str__(self):
return "~" + str(self.token) # type: ignore[attr-defined]
__repr__ = __str__
def __eq__(self, other):
return type(self) is type(other) and self.token == other.token # type: ignore[attr-defined]
def __hash__(self):
return hash((type(self), self.token)) # type: ignore[attr-defined]
def var():
return lambda *args: Var(*args)
def vars():
return lambda n: [var() for i in range(n)]
@dispatch(Var)
def isvar(v):
return True
isvar
@dispatch(object) # type: ignore[no-redef]
def isvar(o):
return _glv and hashable(o) and o in _glv
@contextmanager
def variables(*variables):
"""
Context manager for logic variables
Example:
>>> # xdoctest: +SKIP("undefined vars")
>>> from __future__ import with_statement
>>> with variables(1):
... print(isvar(1))
True
>>> print(isvar(1))
False
>>> # Normal approach
>>> from unification import unify
>>> x = var("x")
>>> unify(x, 1)
{~x: 1}
>>> # Context Manager approach
>>> with variables("x"):
... print(unify("x", 1))
{'x': 1}
"""
old_global_logic_variables = _global_logic_variables.copy()
_global_logic_variables.update(set(variables))
try:
yield
finally:
_global_logic_variables.clear()
_global_logic_variables.update(old_global_logic_variables)
@@ -0,0 +1,124 @@
# mypy: allow-untyped-defs
from torch.fx.experimental.graph_gradual_typechecker import Refine
from torch.fx.experimental.unification import unify, Var # type: ignore[attr-defined]
from torch.fx.tensor_type import TensorType
def infer_symbolic_types_single_pass(traced):
"""
Calls our symbolic inferencer once.
"""
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
def infer_symbolic_types(traced):
"""
Calls our symbolic inferencer twice.
This is useful when one pass is not enough
to infer all the information such as the case
for broadcasting.
"""
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
r.symbolic_relations()
def convert_eq(list_of_eq):
"""
Convert equality constraints in the right format
to be used by unification library.
"""
lhs = []
rhs = []
for eq in list_of_eq:
lhs.append(eq.lhs)
rhs.append(eq.rhs)
return tuple(lhs), tuple(rhs)
def unify_eq(list_of_eq):
"""
Apply unification to a set of
equality constraints
"""
lhs, rhs = convert_eq(list_of_eq)
return unify(lhs, rhs)
def substitute_solution_one_type(mapping, t):
"""
Apply the most general unifier to a type
"""
if isinstance(t, Var):
if t in mapping:
return mapping[t]
else:
return t
elif isinstance(t, TensorType):
new_type = []
for typ in t.__args__:
if typ in mapping:
new_type.append(mapping[typ])
else:
new_type.append(typ)
return TensorType(tuple(new_type))
elif isinstance(t, list):
new_type = []
for typ in t:
new_type.append(substitute_solution_one_type(mapping, typ))
return new_type
elif isinstance(t, tuple):
new_type = []
for typ in t:
new_type.append(substitute_solution_one_type(mapping, typ))
return tuple(new_type)
else:
return t
def substitute_all_types(graph, mapping):
"""
Apply the most general unifier to all types in a graph
till reaching a fixed point. If the input and output graph
are the same, we converge.
"""
flag = True
while flag:
flag = False
for k in mapping:
old_mapping_val = mapping[k]
if mapping[k] in mapping:
new_key = mapping[k]
mapping[k] = mapping[new_key]
if old_mapping_val != mapping[k]:
flag = True
for n in graph.nodes:
n.type = substitute_solution_one_type(mapping, n.type)
def check_for_type_equality(g1, g2):
"""
A check equality to be used in fixed points.
We do not use graph equality but instead type
equality.
"""
for n, m in zip(g1.nodes, g2.nodes):
if n.type != m.type:
return False
return True
@@ -0,0 +1,892 @@
# mypy: allow-untyped-defs
import builtins
import functools
import logging
import math
import operator
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import sympy
import torch
import torch.fx
import torch.fx.traceback as fx_traceback
from torch._dynamo.exc import TorchDynamoException
from torch._dynamo.utils import dynamo_timed
from torch.fx.node import Argument, Target
from torch.utils._sympy.interp import sympy_interp
log = logging.getLogger(__name__)
try:
import z3 # type: ignore[import]
# Translation Validation for Dynamo guards
# ========================================
#
# Checks whether optimizations applied to the collected guards are
# valid. In other words, whether the guard function we actually run
# does not have false positives (unsound).
#
# In order to do so, we build the guards using 2 different information
# attached to each 'SymNode':
# 1. SymPy expressions
# 2. FX nodes
#
# SymPy expressions have implicit optimizations baked within itself,
# which may have a few bugs. On the other hand, we build the FX graph
# manually, with no optimizations enabled. This gives us access to
# the "ground truth".
#
# We then convert into Z3 expressions both the SymPy expressions
# (see [Note: SympyToZ3]) that reach 'ShapeEnv.produce_guards' function
# and the FX nodes (see [Note: PopulateValidator]) that go through
# 'ShapeEnv.evaluate_expr' function. Finally, we run the validation.
# (see [Note: TranslationValidator])
# Better Z3 to string implementation (for a small fraction of Z3).
#
# Here are the things we clean before showing the Z3 expression:
# - Rename a few ops (e.g. "Distinct" ==> "!=")
#
# - Ignore ToInt and ToReal operations:
# usually they don't really matter
#
# - Transform (ToInt (/ ...)) into (idiv ...):
# this is the pattern for floor division
#
# - Collect a chain of the same operations into one
def z3str(e: z3.ExprRef) -> str:
if not z3.is_expr(e):
raise AssertionError(f"unsupported expression type: {e}")
def get_args_str(e: z3.ExprRef) -> list[str]:
return [z3str(e.arg(i)) for i in range(e.num_args())]
# First, we simplify the given expression.
# This is done using rewriting rules, so shouldn't take long.
e = z3.simplify(e)
# Only support function applications.
# Even Z3 "variables" are, in fact, function applications.
if not z3.is_app(e):
raise ValueError(f"can't print Z3 expression: {e}")
if z3.is_int_value(e) or z3.is_rational_value(e):
return e.as_string() # type: ignore[attr-defined]
decl = e.decl()
kind = decl.kind()
op = str(decl)
args = get_args_str(e)
if kind == z3.Z3_OP_POWER:
op = "pow"
elif kind in (z3.Z3_OP_ADD, z3.Z3_OP_MUL):
# Collect the arguments of chains of ADD and MUL.
# This is safe, since they are associative.
def collect_str_args(e):
if not (z3.is_app(e) and e.decl().kind() == kind):
return [z3str(e)]
else:
return [
x
for i in range(e.num_args())
for x in collect_str_args(e.arg(i))
]
args = collect_str_args(e)
elif kind == z3.Z3_OP_NOT:
# Revert some conversions that z3.simplify applies:
# - a != b ==> (Not (== a b)) ==> (!= a b)
# - a < b ==> (Not (<= b a)) ==> (> b a)
# - a > b ==> (Not (<= a b)) ==> (> a b)
if e.num_args() != 1:
raise AssertionError(f"Expected 1 arg, got {e.num_args()}")
arg = e.arg(0)
if not z3.is_app(arg):
raise AssertionError("Expected z3 app")
argkind = arg.decl().kind()
logic_inverse = {
z3.Z3_OP_EQ: "!=",
z3.Z3_OP_LE: ">",
z3.Z3_OP_GE: "<",
}
if argkind in logic_inverse:
op = logic_inverse[argkind]
args = get_args_str(arg)
elif kind in (z3.Z3_OP_TO_INT, z3.Z3_OP_TO_REAL):
if e.num_args() != 1:
raise AssertionError(f"Expected 1 arg, got {e.num_args()}")
argstr = z3str(e.arg(0))
# Check if it's the floor division pattern.
if argstr.startswith("(/"):
return "(idiv" + argstr[2:]
# Otherwise, just ignore it.
return argstr
elif kind == z3.Z3_OP_UNINTERPRETED:
if e.num_args() != 0:
raise AssertionError(f"Expected 0 args, got {e.num_args()}")
return str(decl)
string = op + " " + " ".join(args)
return f"({string.rstrip()})"
# We need to convert to/from BitVec in order to use z3 bitwise ops.
# We assume that integers are 64 bit.
# If all args are boolean, then use the boolean bitwise op implementation instead, if provided.
def _bitwise_op(bitwise_func, bool_func):
@functools.wraps(bitwise_func)
def wrapper(self, *args):
if bool_func is not None and all(
isinstance(arg, z3.BoolRef) for arg in args
):
return bool_func(*args)
wrapped_args = tuple(z3.Int2BV(a, 64) for a in args)
return z3.BV2Int(bitwise_func(*wrapped_args))
return wrapper
# Implementation of Python semantics as Z3 expressions.
#
# Z3 Real-Int theory has operators with semantics that differ that of
# Python. Therefore, in order to get it right, we need to implement
# the (Python) semantics we are relying on in Z3.
@dataclass
class _Z3Ops:
# Validator used for adding assertions as needed.
# e.g. div(a, b) requires b != 0.
validator: "TranslationValidator"
# The 2 functions below are used for conditionally casting between
# integer and reals.
#
# Returns a real expression from 'x'.
@staticmethod
def to_real(x: z3.ArithRef) -> z3.ArithRef:
return x if x.is_real() else z3.ToReal(x)
# Returns an integer expression from 'x'.
@staticmethod
def to_int(x: z3.ArithRef) -> z3.ArithRef:
return x if x.is_int() else z3.ToInt(x)
def sym_sum(self, args: z3.ArithRef) -> z3.ArithRef:
return sum(args) # pyrefly: ignore [no-matching-overload]
# Implements Python division semantics.
def div(self, numerator: z3.ArithRef, denominator: z3.ArithRef) -> z3.ArithRef:
self.validator.add_assertion(denominator != 0) # type: ignore[arg-type]
return _Z3Ops.to_real(numerator) / _Z3Ops.to_real(denominator)
def floor(self, number: z3.ArithRef) -> z3.ArithRef:
# Z3 ToInt function rounds a real number towards negative infinity.
return _Z3Ops.to_int(number)
# Python semantics for 'FloorDiv' states that before applying the floor
# function, the operands are converted to their common type.
def floordiv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
cast_result_to_real = numerator.is_real() or denominator.is_real()
result = _Z3Ops.to_int(self.div(numerator, denominator))
# Since the 'result' is already an integer, we just have to check
# whether we should cast it to real.
return _Z3Ops.to_real(result) if cast_result_to_real else result
def ceil(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.If(self.floor(number) < number, self.floor(number + 1), number) # type: ignore[return-value]
def trunc(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.If(number >= 0, self.floor(number), self.ceil(number)) # type: ignore[return-value]
def max(self, a: z3.ArithRef, b: z3.ArithRef) -> z3.ArithRef:
return z3.If(a > b, a, b) # type: ignore[return-value]
def min(self, a: z3.ArithRef, b: z3.ArithRef) -> z3.ArithRef:
return z3.If(a < b, a, b) # type: ignore[return-value]
# Python semantics for 'Mod' is defined as: p % q = p - floordiv(p, q) * q
# It should work with both integer and reals.
def mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return p - self.floordiv(p, q) * q
def pow(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
# Z3 can't handle complex numbers very well.
self.validator.add_assertion(z3.Or(base != 0, exp > 0)) # type: ignore[arg-type]
return base**exp
def sqrt(self, number: z3.ArithRef) -> z3.ArithRef:
# Square-root:
# 1. Only work with reals
number = _Z3Ops.to_real(number)
# 2. The number should be positive or zero.
# Otherwise, Z3 returns 'unknown'.
self.validator.add_assertion(number >= 0)
return number**0.5
def abs(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.Abs(number)
def round_to_int(self, number: z3.ArithRef) -> z3.ArithRef:
# Pythons builtin 'round' implements the 'round half to even' strategy
# See https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even
# z3 has an equivalent z3.fpRoundToIntegral(z3.RoundNearestTiesToEven(), ...), but this only applies to
# floating point numbers, which is different from real numbers that we are dealing with here.
# Instead, we implement 'round half to even' in terms of 'round half up' (floor(x + 0.5)) and
# 'round half down' (ceil(x - 0.5)).
# Assuming 'round half up' is the default case, we need to correct ..., -3.5, -1.5, 0.5, 2.5, 4.5, ...
# to round down, i.e. use the 'round half down' strategy
return z3.If(
self.mod(number, z3.IntVal(2)) == 0.5,
self.ceil(number - 0.5),
self.floor(number + 0.5),
)
bitwise_and = _bitwise_op(operator.and_, z3.And)
bitwise_or = _bitwise_op(operator.or_, z3.Or)
lshift = _bitwise_op(operator.lshift, None)
rshift = _bitwise_op(operator.rshift, None)
# Lifts a callable to be used in Z3.
#
# This function replaces the given 'op' by a function that:
#
# 1. Lifts the arguments into Z3 (i.e. make them inhabitants of Z3)
#
# 2. Calls an operation that corresponds to 'op', but works with Z3
# inhabitants (left as is if it works as is)
def z3op(op: Callable, validator: "TranslationValidator") -> Callable:
# Operations that have booleans as their argument.
# This is needed because the argument of some FX nodes were
# literal integers, instead of booleans. So, whenever this flag
# is set, we also convert ints to booleans.
boolean_ops = {operator.not_}
as_bool = op in boolean_ops
# Lifts the function into 'z3.ExprRef' domain.
def lift(func):
def wrap(a) -> z3.ExprRef:
if isinstance(a, (z3.ArithRef, z3.BoolRef)):
return a
# Convert it into a Z3 value, if it is some of the supported
# types below.
if isinstance(a, bool) or (as_bool and isinstance(a, int)):
return z3.BoolVal(bool(a))
if isinstance(a, (int, sympy.Integer)):
return z3.IntVal(int(a))
if isinstance(a, (float, sympy.Float)):
return z3.RealVal(float(a))
raise ValueError(f"can't lift type: {type(a)}")
@functools.wraps(func)
def wrapper(*args):
# Lifts the arguments into a list of Z3 inhabitants.
if len(args) == 1 and isinstance(args[0], (list, tuple)):
wrapped_args = (tuple(wrap(a) for a in args[0]),)
else:
wrapped_args = tuple(wrap(a) for a in args)
# Run the function on the Z3 expressions.
return func(*wrapped_args)
return wrapper
ops = _Z3Ops(validator)
replacement_map = {
# Operator module.
operator.not_: lift(z3.Not),
operator.and_: lift(ops.bitwise_and),
operator.or_: lift(ops.bitwise_or),
operator.lshift: lift(ops.lshift),
operator.rshift: lift(ops.rshift),
operator.floordiv: lift(ops.floordiv),
operator.truediv: lift(ops.div),
operator.mod: lift(ops.mod),
operator.abs: lift(ops.abs),
builtins.round: lift(ops.round_to_int),
# Math module.
math.ceil: lift(ops.ceil),
math.floor: lift(ops.floor),
math.trunc: lift(ops.trunc),
# Torch module.
torch.sym_float: lift(ops.to_real),
torch.sym_max: lift(ops.max),
torch.sym_min: lift(ops.min),
torch.sym_sum: lift(ops.sym_sum),
torch.sym_ite: lift(lambda b, t, f: z3.If(b, t, f)),
torch._sym_sqrt: lift(ops.sqrt), # type: ignore[attr-defined]
# Not lifted because we only use this function as a
# marker for adding the expression as validator input.
torch._assert: torch._assert,
}
return replacement_map[op] if op in replacement_map else lift(op)
# Processes an FX graph, populating the given validator.
#
# [Note: PopulateValidator]
# This class walks through each node in the FX graph, translating
# them into the Z3 world.
#
# Then, whenever it finds an 'torch._assert' call_function operation,
# it adds the Z3 expression corresponding to the argument as validator
# input.
class PopulateValidator(torch.fx.Interpreter):
def __init__(self, graph: torch.fx.Graph, validator: "TranslationValidator"):
# Reference to the translation validator.
self.validator = validator
# Build the graph module and call `Interpreter` constructor.
module = torch.fx.GraphModule(root={}, graph=graph)
super().__init__(module, garbage_collect_values=True)
def placeholder(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
symbol = fx_traceback.get_current_meta()["symbol"]
return self.validator.z3var(symbol)
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
if target is not torch._assert:
# Lift and runs the node target function
return super().call_function(z3op(target, self.validator), args, kwargs) # type: ignore[arg-type]
# Adds the Z3 expression corresponding to the first argument
# as a validator input.
if len(args) != 1:
raise AssertionError(
f"expected 1 argument on assertion. Got: {len(args)} "
)
self.validator.add_source_expr(args[0]) # type: ignore[arg-type]
# Translates SymPy expressions into Z3 expressions.
#
# [Note: SympyToZ3]
# At the time of the translation, all free variables present in the
# SymPy expression being translated must be already mapped to a Z3
# integer variable.
class SympyToZ3:
OPERATOR_HANDLES = {"add", "mul", "eq", "ne", "lt", "gt", "le", "ge"}
def __init__(
self,
validator: "TranslationValidator",
) -> None:
self._validator = validator
self._ops = _Z3Ops(self._validator)
def constant(self, value: Any, dtype: torch.dtype) -> z3.ExprRef:
# TODO: Probably OK to relax this and allow lower precision
if dtype is torch.int64:
return z3.IntVal(int(value))
if dtype is torch.double:
return z3.RealVal(float(value))
if dtype is torch.bool:
return z3.BoolVal(bool(value))
raise ValueError(f"unsupported dtype (SympyToZ3): {dtype}")
def to_dtype(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
if dtype == torch.float64:
return z3.ToReal(x)
raise NotImplementedError(f"to_dtype {dtype} NYI")
def trunc_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return z3.ToInt(x)
def round_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.round_to_int(x)
def int_truediv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.div(numerator, denominator)
def truediv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.div(numerator, denominator)
def floordiv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.floordiv(numerator, denominator)
def div(self, numerator: z3.ArithRef, denominator: z3.ArithRef) -> z3.ArithRef:
return self._ops.floordiv(numerator, denominator)
def pow(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
return self._ops.pow(base, exp)
def pow_by_natural(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
return self._ops.pow(base, exp)
def mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return self._ops.mod(p, q)
def python_mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return self._ops.mod(p, q)
def ceil_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.ceil(x)
def floor_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.floor(x)
def __getattr__(self, name: str) -> Any:
REPLACEMENT = {
"and_": z3.And,
"or_": z3.Or,
"not_": z3.Not,
"bitwise_and": self._ops.bitwise_and,
"bitwise_or": self._ops.bitwise_or,
"lshift": self._ops.lshift,
"rshift": self._ops.rshift,
"floor": self._ops.floor,
"ceil": self._ops.ceil,
"minimum": self._ops.min,
"maximum": self._ops.max,
}
if name in REPLACEMENT:
return REPLACEMENT[name]
if name in self.OPERATOR_HANDLES:
return getattr(operator, name)
raise AttributeError(f"unhandled operator: {name}")
def run(self, expr: sympy.Basic) -> z3.ExprRef:
return sympy_interp(self, self._validator.symbols, expr) # type: ignore[arg-type]
# Dynamo guards translation validator.
#
# [Note: TranslationValidator]
# Verifies whether the guards issued by 'ShapeEnv.produce_guards' are sound.
# That is: whether those (target) guards only yield TRUE whenever the original,
# unoptimized, (source) guards yield TRUE.
#
# More concretely, given 'source' and 'target' guard expressions, we wish to
# check whether the following expression holds:
#
# Not(And(source)) AND And(target)
#
# i.e. whether there is an assignment of the free variables where the opposite
# happens: target is TRUE, but source is FALSE.
class TranslationValidator:
def __init__(self) -> None:
log.debug("new instance")
# Mapping of SymPy symbols to Z3 variables.
self.symbols: dict[sympy.Symbol, z3.ExprRef] = {}
# Set of source Z3 expressions.
# They represent the generated guards without any kind of
# simplification or transformation.
self._source_exprs: set[z3.BoolRef] = set()
# Set of target Z3 expressions.
# They represent the actual checked guards at runtime. They might
# be simplified or transformed versions of the source guards.
self._target_exprs: set[z3.BoolRef] = set()
# Set of Z3 expressions representing assertions over both the
# source and target expressions.
self._assertions: set[z3.BoolRef] = set()
# Retrieves the corresponding Z3 variable.
def z3var(self, symbol: sympy.Symbol) -> z3.ExprRef:
if symbol not in self.symbols:
raise AssertionError(f"Z3 variable not found for: {symbol}")
return self.symbols[symbol]
# Create a variable in Z3 of 'type' for 'symbol', if it doesn't already exists.
def add_var(self, symbol: sympy.Symbol, type: type) -> z3.ExprRef:
if symbol in self.symbols:
return self.symbols[symbol]
log.debug("new variable: %s (%s)", symbol.name, type.__name__)
if type is int:
var = z3.Int(symbol.name)
# If 'symbol' is positive (SymPy assumption), we have to
# convey it to Z3 as well.
if symbol.is_positive: # type: ignore[attr-defined]
self._target_exprs.add(var > 0)
elif type is float:
var = z3.Real(symbol.name)
elif type is bool:
var = z3.Bool(symbol.name)
else:
raise RuntimeError(f"unsupported type for Z3 variable: {type}")
self.symbols[symbol] = var
return var
# Checks whether all symbols were already added.
def _check_freesymbols(self, e: sympy.Basic) -> None:
for s in e.free_symbols:
if not isinstance(s, sympy.Symbol):
raise AssertionError(f"Expected sympy.Symbol, got {type(s)}")
# Call 'z3var' just to check whether there's already a
# Z3 variable corresponding to 's'.
self.z3var(s)
def to_z3_boolean_expr(self, e: sympy.Basic) -> z3.BoolRef:
z3expr = SympyToZ3(self).run(e)
if not isinstance(z3expr, z3.BoolRef):
raise AssertionError(f"expected boolean expression. Got: {z3expr}")
return z3expr
def add_source_expr(self, e: z3.BoolRef) -> None:
if e not in self._source_exprs:
log.debug("add source guard: %s", z3str(e))
self._source_exprs.add(e)
def add_target_expr(self, e: "sympy.logic.boolalg.Boolean") -> None:
self._check_freesymbols(e)
z3expr = self.to_z3_boolean_expr(e)
if e not in self._target_exprs:
log.debug("add target guard: %s", z3str(z3expr))
self._target_exprs.add(z3expr)
def add_assertion(self, e: z3.BoolRef | sympy.Basic) -> None:
if isinstance(e, sympy.Basic):
self._check_freesymbols(e)
ref = self.to_z3_boolean_expr(e)
else:
ref = e
if not isinstance(ref, z3.BoolRef):
raise AssertionError(f"Expected z3.BoolRef, got {type(ref)}")
if ref not in self._assertions:
log.debug("add assertion: %s", z3str(ref))
self._assertions.add(ref)
def validate(self) -> None:
with dynamo_timed("TranslationValidator.validate"):
return self._validate()
def _validate(self) -> None:
if len(self._source_exprs) == 0 or len(self._target_exprs) == 0:
# If there are no source/target expressions, there's nothing we really
# wish to prove. So, we just return.
return None
# Here, we use "QF_NRA" logic for the solver:
# "Quantifier-free Non-linear Real Arithmetic".
#
# Most of the guards expressions have:
# 1. arithmetic between integer and reals
# 2. no quantifiers
# 3. potentially non-linear.
#
# Although there's also "QF_NIRA" (mixed integer-real arithmetic),
# "QF_NRA" seems to work better on 'dynamo/test_dynamic_shapes.py'.
solver = z3.SolverFor("QF_NRA")
# Set a timeout for finding a solution.
solver.set(timeout=translation_validation_timeout())
# Add all the assertions to the solver.
for assertion in self._assertions:
solver.add(assertion)
# "Is there any case where it's TRUE for the target expressions,
# but FALSE for the source expressions?"
solver.add(z3.Not(z3.And(*self._source_exprs)))
solver.add(*self._target_exprs)
log.debug("translation validation: start")
r = solver.check()
if r == z3.sat:
# Target expressions are unsound.
# Log the found model and the source expressions that failed.
model = solver.model()
raise ValidationException(
model,
self._assertions,
self._target_exprs,
failed_source_exprs=[
inp for inp in self._source_exprs if not model.evaluate(inp)
],
)
else:
if r == z3.unknown:
# Could not find a solution. It didn't fail, but it also
# didn't succeed. Canceling the validation execution (keyboard
# interrupt) also gets to this branch.
log.warning(
"translation validation: could not validate: got z3.unknown"
)
else:
# Target expressions are sound.
if r != z3.unsat:
raise AssertionError(f"Expected z3.unsat, got {r}")
log.debug("translation validation: success")
except ImportError:
_HAS_Z3 = False
__all__ = [
"translation_validation_enabled",
"translation_validation_timeout",
"ValidationException",
"BisectValidationException",
]
else:
_HAS_Z3 = True
__all__ = [
"z3str",
"z3op",
"PopulateValidator",
"SympyToZ3",
"TranslationValidator",
"translation_validation_enabled",
"translation_validation_timeout",
"ValidationException",
"BisectValidationException",
]
from torch.fx.experimental import _config as config
def translation_validation_enabled() -> bool:
# Checks every time this function is called, in case the Dynamo
# option is set, but Z3 is not installed.
_assert_z3_installed_if_tv_set()
return _HAS_Z3 and config.translation_validation
def translation_validation_timeout() -> int:
return config.translation_validation_timeout
def _assert_z3_installed_if_tv_set():
if not (_HAS_Z3 or not config.translation_validation):
raise AssertionError(
"translation validation requires Z3 package. Please, either install "
"z3-solver or disable translation validation."
)
class ValidationException(TorchDynamoException):
def __init__(self, model, assertions, target_exprs, failed_source_exprs):
if not _HAS_Z3:
raise AssertionError("Z3 is required")
def symbolstr(sym) -> str:
return f"{sym}: {model[sym]}"
def joinlines(xs) -> str:
return "\n".join(f" ==> {x}" for x in xs)
model_str = joinlines(sorted(map(symbolstr, model)))
assertions_str = joinlines(sorted(map(z3str, assertions)))
target_exprs_str = joinlines(sorted(map(z3str, target_exprs)))
failed_source_exprs_str = joinlines(sorted(map(z3str, failed_source_exprs)))
self.msg = "translation validation failed."
self.details = f"""\
Model:
{model_str}
Assertions:
{assertions_str}
Target Expressions:
{target_exprs_str}
Failed Source Expressions:
{failed_source_exprs_str}"""
def __str__(self):
return f"{self.msg}\n\n{self.details}"
class BisectValidationException(TorchDynamoException):
def __init__(self, validation_exc, expr, failed_action, traced_node):
self.msg = f"translation validation failed when {failed_action}: {expr}"
self.details = f"""\
Failure occurred while running node:
{traced_node.format_node()}
{validation_exc.details}"""
def __str__(self):
return f"{self.msg}\n\n{self.details}"
# Checks when this module is loaded.
_assert_z3_installed_if_tv_set()
# Translation validation bisection.
#
# Bisect into the torch._assert nodes recorded in the shape_env FX graph, and raise
# the earliest ValidationException.
#
# As guards are added by ShapeEnv.evaluate_expr calls, some simplification errors
# might be silently happening. This function tries to nail down exactly at which
# point things went wrong from a validation perspective.
def bisect(shape_env):
from torch.fx.experimental.recording import (
FakeTensorMeta,
replay_shape_env_events,
ShapeEnvEvent,
)
from torch.fx.experimental.symbolic_shapes import (
CURRENT_NODE_KEY,
ShapeEnv,
SHAPEENV_EVENT_KEY,
)
events = shape_env.events
# Retrieves the ShapeEnvEvent associated with node.
def get_node_event(node: torch.fx.Node) -> ShapeEnvEvent:
if SHAPEENV_EVENT_KEY not in node.meta:
raise AssertionError("SHAPEENV_EVENT_KEY not in node.meta")
return events[node.meta[SHAPEENV_EVENT_KEY]]
# Creates a new instance of fake, but updating every symbolic value's ShapeEnv
# reference to the one given as argument.
#
# This is needed so as not to simplify a symbolic expression using a ShapeEnv
# "from the future", where it may have a different set of replacements.
def new_with_shape_env(shape_env: ShapeEnv, fake) -> Any:
if isinstance(fake, int):
return fake
if isinstance(fake, torch.SymInt):
return torch.SymInt(fake.node.with_shape_env(shape_env))
if isinstance(fake, torch.SymFloat):
return torch.SymFloat(fake.node.with_shape_env(shape_env))
if not isinstance(fake, FakeTensorMeta):
raise AssertionError(f"Expected FakeTensorMeta, got {type(fake)}")
return FakeTensorMeta(
tuple(new_with_shape_env(shape_env, s) for s in fake.size()),
tuple(new_with_shape_env(shape_env, s) for s in fake.stride()),
new_with_shape_env(shape_env, fake.storage_offset()),
fake.is_nested,
)
# Checks whether the given shape_env fails when produce_guards is called.
def check_shapeenv_fails(
shape_env: ShapeEnv, tracked_fakes: list[Any] | None
) -> ValidationException | None:
if tracked_fakes is None:
raise AssertionError("tracked_fakes is None")
try:
# This produce_guards call is a best-effort replication, since we
# don't populate EqualityConstraint list. Reason: we would also have
# to save OutputGraph.tracked_fakes_id_to_source.
shape_env.produce_guards(
[new_with_shape_env(shape_env, a.fake) for a in tracked_fakes],
[a.source for a in tracked_fakes],
input_contexts=[a.symbolic_context for a in tracked_fakes],
)
return None
except ValidationException as e:
return e
# Checks whether the ShapeEnv reconstructed by replaying the events until
# node is created fails when produce_guards is called.
def check_node_fails(node: torch.fx.Node) -> ValidationException | None:
number = node.meta[SHAPEENV_EVENT_KEY]
# Reconstruct shape_env until the event at event_number.
shape_env = replay_shape_env_events(events[: number + 1])
shape_env.graph.lint()
return check_shapeenv_fails(shape_env, events[number].tracked_fakes)
last_exception = check_shapeenv_fails(
shape_env, shape_env._snapshot_tracked_fakes()
)
if not last_exception:
# We don't actually fail due to a produce_guards call.
# Stop and don't bisect.
log.info("translation validation succeeded: no errors found.")
return
if not shape_env.should_record_events or config.translation_validation_no_bisect:
# Bisection is off.
# Return the last ValidationException we got.
raise last_exception
# Cache the raised exception (if any) at each bisection point.
exception = {}
# Bisection happens on the assertion nodes of the recorded FX graph for
# dynamic shapes.
assert_nodes = [
node for node in shape_env.graph.nodes if node.target is torch._assert
]
# Preparing the indices for binary search.
# The overall invariants are
# - for all i < left, assert_node[i] doesn't fail
# - for all i >= right, assert_node[i] fails
# - `right in exception` always holds
# - `left <= right` always holds
left, mid, right = 0, 0, len(assert_nodes) - 1
exception[right] = check_node_fails(assert_nodes[right])
while left < right:
mid = (left + right) // 2
node = assert_nodes[mid]
log.debug("bisecting at %s: %s", mid, get_node_event(node))
# Check whether the new shape_env raises a ValidationException or not.
exception[mid] = check_node_fails(node)
if exception[mid]:
right = mid
else:
left = mid + 1
if not (left in exception and isinstance(exception[left], ValidationException)):
raise AssertionError("Expected ValidationException at bisect result")
node = assert_nodes[left]
event = get_node_event(node)
if event.is_evaluate_expr():
failed_action = "evaluating"
else:
if not event.is_defer_runtime_assert():
raise AssertionError(f"unexpected event type: {event}")
failed_action = "adding runtime assert"
args = event.args
if args is None:
raise AssertionError("event.args is None")
if len(args) < 2:
raise AssertionError(
f"bisecting expects {event.name} to have at least 2 positional arguments. "
f"Got: {len(args)}"
)
if not isinstance(args[1], sympy.Basic):
raise AssertionError(
f"bisecting expects {event.name} to have a SymPy expression as its second "
f"argument. Got: {type(args[1])}"
)
raise BisectValidationException(
exception[left],
expr=args[1],
failed_action=failed_action,
traced_node=node.meta[CURRENT_NODE_KEY],
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
from collections.abc import Iterable
from typing import Any, NoReturn, TypeVar
from typing_extensions import Self
from torch.utils._pytree import (
_dict_flatten,
_dict_flatten_with_keys,
_dict_unflatten,
_list_flatten,
_list_flatten_with_keys,
_list_unflatten,
Context,
register_pytree_node,
)
from ._compatibility import compatibility
__all__ = ["immutable_list", "immutable_dict"]
_help_mutation = """
If you are attempting to modify the kwargs or args of a torch.fx.Node object,
instead create a new copy of it and assign the copy to the node:
new_args = ... # copy and mutate args
node.args = new_args
""".strip()
_T = TypeVar("_T")
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")
def _no_mutation(self: Any, *args: Any, **kwargs: Any) -> NoReturn:
raise TypeError(
f"{type(self).__name__!r} object does not support mutation. {_help_mutation}",
)
@compatibility(is_backward_compatible=True)
class immutable_list(list[_T]):
"""An immutable version of :class:`list`."""
__delitem__ = _no_mutation
__iadd__ = _no_mutation
__imul__ = _no_mutation
__setitem__ = _no_mutation
append = _no_mutation
clear = _no_mutation
extend = _no_mutation
insert = _no_mutation
pop = _no_mutation
remove = _no_mutation
reverse = _no_mutation
sort = _no_mutation
def __hash__(self) -> int: # type: ignore[override]
return hash(tuple(self))
def __reduce__(self) -> tuple[type[Self], tuple[tuple[_T, ...]]]:
return (type(self), (tuple(self),))
@compatibility(is_backward_compatible=True)
class immutable_dict(dict[_KT, _VT]):
"""An immutable version of :class:`dict`."""
__delitem__ = _no_mutation
__ior__ = _no_mutation
__setitem__ = _no_mutation
clear = _no_mutation
pop = _no_mutation
popitem = _no_mutation
setdefault = _no_mutation
update = _no_mutation # type: ignore[assignment]
def __hash__(self) -> int: # type: ignore[override]
return hash(frozenset(self.items()))
def __reduce__(self) -> tuple[type[Self], tuple[tuple[tuple[_KT, _VT], ...]]]:
return (type(self), (tuple(self.items()),))
# Register immutable collections for PyTree operations
def _immutable_list_flatten(d: immutable_list[_T]) -> tuple[list[_T], Context]:
return _list_flatten(d)
def _immutable_list_unflatten(
values: Iterable[_T],
context: Context,
) -> immutable_list[_T]:
return immutable_list(_list_unflatten(values, context))
def _immutable_dict_flatten(d: immutable_dict[Any, _VT]) -> tuple[list[_VT], Context]:
return _dict_flatten(d)
def _immutable_dict_unflatten(
values: Iterable[_VT],
context: Context,
) -> immutable_dict[Any, _VT]:
return immutable_dict(_dict_unflatten(values, context))
register_pytree_node(
immutable_list,
_immutable_list_flatten,
_immutable_list_unflatten,
serialized_type_name="torch.fx.immutable_collections.immutable_list",
flatten_with_keys_fn=_list_flatten_with_keys,
)
register_pytree_node(
immutable_dict,
_immutable_dict_flatten,
_immutable_dict_unflatten,
serialized_type_name="torch.fx.immutable_collections.immutable_dict",
flatten_with_keys_fn=_dict_flatten_with_keys,
)
@@ -0,0 +1,670 @@
import inspect
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import torch
import torch.fx.traceback as fx_traceback
from torch._logging import LazyString, trace_structured
from torch.hub import tqdm
from . import config
from ._compatibility import compatibility
from ._lazy_graph_module import _make_graph_module
from ._symbolic_trace import Tracer
from .graph import Graph
from .graph_module import GraphModule
from .node import Argument, map_aggregate, map_arg, Node, Target
from .proxy import Proxy
log = logging.getLogger(__name__)
__all__ = ["Interpreter", "Transformer"]
def _format_fx_node(n: Node) -> str:
"""
Format a torch.fx.Node into a human-readable string for debug logging.
Args:
n (torch.fx.Node): The FX node being executed.
Returns:
str: A formatted string describing the node operation, including its
name, target, positional arguments, and keyword arguments.
"""
module_prefix = getattr(n.target, "__module__", "")
module_prefix = f"{module_prefix}." if module_prefix else ""
# Handle positional and keyword arguments
args = ", ".join(map(str, n.args))
kwargs = ", ".join(f"{k}={v}" for k, v in n.kwargs.items())
joined = ", ".join(filter(None, [args, kwargs]))
return (
f"{n.name} = {module_prefix}{getattr(n.target, '__name__', n.target)}({joined})"
)
@compatibility(is_backward_compatible=True)
class Interpreter:
"""
An Interpreter executes an FX graph Node-by-Node. This pattern
can be useful for many things, including writing code
transformations as well as analysis passes.
Methods in the Interpreter class can be overridden to customize
the behavior of execution. The map of overridable methods
in terms of call hierarchy::
run()
+-- run_node
+-- placeholder()
+-- get_attr()
+-- call_function()
+-- call_method()
+-- call_module()
+-- output()
Example:
Suppose we want to swap all instances of ``torch.neg`` with
``torch.sigmoid`` and vice versa (including their ``Tensor``
method equivalents). We could subclass Interpreter like so::
class NegSigmSwapInterpreter(Interpreter):
def call_function(
self, target: Target, args: Tuple, kwargs: Dict
) -> Any:
if target is torch.sigmoid:
return torch.neg(*args, **kwargs)
return super().call_function(target, args, kwargs)
def call_method(self, target: Target, args: Tuple, kwargs: Dict) -> Any:
if target == "neg":
call_self, *args_tail = args
return call_self.sigmoid(*args_tail, **kwargs)
return super().call_method(target, args, kwargs)
def fn(x):
return torch.sigmoid(x).neg()
gm = torch.fx.symbolic_trace(fn)
input = torch.randn(3, 4)
result = NegSigmSwapInterpreter(gm).run(input)
torch.testing.assert_close(result, torch.neg(input).sigmoid())
Args:
module (torch.nn.Module): The module to be executed
garbage_collect_values (bool): Whether to delete values after their last
use within the Module's execution. This ensures optimal memory usage during
execution. This can be disabled to, for example, examine all of the intermediate
values in the execution by looking at the ``Interpreter.env`` attribute.
graph (Optional[Graph]): If passed, the interpreter will execute this
graph instead of `module.graph`, using the provided `module`
argument to satisfy any requests for state.
"""
@compatibility(is_backward_compatible=True)
def __init__(
self,
module: torch.nn.Module,
garbage_collect_values: bool = True,
graph: Graph | None = None,
) -> None:
self.module = module
self.submodules = dict(self.module.named_modules())
if graph is not None:
self.graph = graph
else:
self.graph = self.module.graph # type: ignore[assignment]
self.env: dict[Node, Any] = {}
self.name = "Interpreter"
self.garbage_collect_values = garbage_collect_values
self.extra_traceback = True
if self.garbage_collect_values:
# Run through reverse nodes and record the first instance of a use
# of a given node. This represents the *last* use of the node in the
# execution order of the program, which we will use to free unused
# values
node_to_last_use: dict[Node, Node] = {}
self.user_to_last_uses: dict[Node, list[Node]] = {}
def register_last_uses(n: Node, user: Node) -> None:
if n not in node_to_last_use:
node_to_last_use[n] = user
self.user_to_last_uses.setdefault(user, []).append(n)
for node in reversed(self.graph.nodes):
for n in node._input_nodes:
register_last_uses(n, node)
@compatibility(is_backward_compatible=True)
def run(
self,
*args: Any,
initial_env: dict[Node, Any] | None = None,
enable_io_processing: bool = True,
) -> Any:
"""
Run `module` via interpretation and return the result.
Args:
*args: The arguments to the Module to run, in positional order
initial_env (Optional[Dict[Node, Any]]): An optional starting environment for execution.
This is a dict mapping `Node` to any value. This can be used, for example, to
pre-populate results for certain `Nodes` so as to do only partial evaluation within
the interpreter.
enable_io_processing (bool): If true, we process the inputs and outputs with graph's process_inputs and
process_outputs function first before using them.
Returns:
Any: The value returned from executing the Module
"""
self.env = initial_env if initial_env is not None else {}
# Positional function args are consumed left-to-right by
# `placeholder` nodes. Use an iterator to keep track of
# position and extract those values.
if enable_io_processing:
args = self.graph.process_inputs(*args)
self.args_iter: Iterator[Any] = iter(args)
pbar = tqdm(
total=len(self.graph.nodes),
desc=f"{self.name}: {str(list(self.graph.nodes)) if config.verbose_progress else ''}",
initial=0,
position=0,
leave=True,
disable=config.disable_progress,
delay=0,
)
for node in self.graph.nodes:
pbar.update(1)
if node in self.env:
# Short circuit if we have this value. This could
# be used, for example, for partial evaluation
# where the caller has pre-populated `env` with
# values for a subset of the program.
continue
try:
self.env[node] = self.run_node(node)
except Exception as e:
if self.extra_traceback:
msg = f"While executing {node.format_node()}"
msg = f"{e.args[0]}\n\n{msg}" if e.args else str(msg)
msg += f"\nOriginal traceback:\n{node.stack_trace}"
if (
isinstance(self.module, GraphModule)
and self.module.graph is not None
and isinstance(self.module.graph, torch.fx.Graph)
):
trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "fx_interpreter_error",
"encoding": "string",
},
payload_fn=lambda: (
f"{msg}\nGraphModule: "
f"{self.module.print_readable(print_output=False, include_stride=True)}" # type: ignore[operator]
),
)
msg += "\nUse tlparse to see full graph. "
msg += "(https://github.com/pytorch/tlparse?tab=readme-ov-file#tlparse-parse-structured-pt2-logs)"
e.args = (msg,) + e.args[1:]
if isinstance(e, KeyError):
raise RuntimeError(*e.args) from e
raise
if self.garbage_collect_values:
for to_delete in self.user_to_last_uses.get(node, []):
del self.env[to_delete]
if node.op == "output":
output_val = self.env[node]
return (
self.graph.process_outputs(output_val)
if enable_io_processing
else output_val
)
@compatibility(is_backward_compatible=True)
def boxed_run(self, args_list: list[Any]) -> Any:
"""
Run `module` via interpretation and return the result. This uses the "boxed"
calling convention, where you pass a list of arguments, which will be cleared
by the interpreter. This ensures that input tensors are promptly deallocated.
"""
# Collect placeholder nodes first
placeholder_nodes = [n for n in self.graph.nodes if n.op == "placeholder"]
# Check argument count
if len(args_list) != len(placeholder_nodes):
detail = (
"extra arguments"
if len(args_list) > len(placeholder_nodes)
else "missing arguments"
)
raise RuntimeError(
f"Interpreter.boxed_run expected {len(placeholder_nodes)} arguments for placeholders "
f"but received {len(args_list)} ({detail})"
)
# Assign arguments to placeholders
env = dict(zip(placeholder_nodes, args_list))
args_list.clear()
return self.run(initial_env=env)
@contextmanager
def _set_current_node(self, node: Node) -> Iterator[None]:
with fx_traceback.set_current_meta(
node, f"Interpreter_{self.__class__.__name__}"
):
yield
@compatibility(is_backward_compatible=True)
def run_node(self, n: Node) -> Any:
"""
Run a specific node ``n`` and return the result.
Calls into placeholder, get_attr, call_function,
call_method, call_module, or output depending
on ``node.op``
Args:
n (Node): The Node to execute
Returns:
Any: The result of executing ``n``
"""
log.debug("run_node %s", LazyString(lambda: _format_fx_node(n)))
with self._set_current_node(n):
args, kwargs = self.fetch_args_kwargs_from_env(n)
if not isinstance(args, tuple):
raise AssertionError(f"Expected args to be tuple, got {type(args)}")
if not isinstance(kwargs, dict):
raise AssertionError(f"Expected kwargs to be dict, got {type(kwargs)}")
return getattr(self, n.op)(n.target, args, kwargs)
# Main Node running APIs
@compatibility(is_backward_compatible=True)
def placeholder(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute a ``placeholder`` node. Note that this is stateful:
``Interpreter`` maintains an internal iterator over
arguments passed to ``run`` and this method returns
next() on that iterator.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Returns:
Any: The argument value that was retrieved.
"""
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
if target.startswith("*"):
# For a starred parameter e.g. `*args`, retrieve all
# remaining values from the args list.
return list(self.args_iter)
else:
try:
return next(self.args_iter)
except StopIteration as si:
if len(args) > 0:
return args[0]
else:
raise RuntimeError(
f"Expected positional argument for parameter {target}, but one was not passed in!"
) from si
@compatibility(is_backward_compatible=True)
def get_attr(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute a ``get_attr`` node. Will retrieve an attribute
value from the ``Module`` hierarchy of ``self.module``.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Return:
Any: The value of the attribute that was retrieved
"""
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
return self.fetch_attr(target)
@compatibility(is_backward_compatible=True)
def call_function(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute a ``call_function`` node and return the result.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Return
Any: The value returned by the function invocation
"""
if isinstance(target, str):
raise AssertionError("target should not be a string for call_function")
# Execute the function and return the result
return target(*args, **kwargs)
@compatibility(is_backward_compatible=True)
def call_method(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute a ``call_method`` node and return the result.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Return
Any: The value returned by the method invocation
"""
# args[0] is the `self` object for this method call
self_obj, *args_tail = args
# Execute the method and return the result
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
return getattr(self_obj, target)(*args_tail, **kwargs)
@compatibility(is_backward_compatible=True)
def call_module(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute a ``call_module`` node and return the result.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Return
Any: The value returned by the module invocation
"""
# Retrieve executed args and kwargs values from the environment
# Execute the method and return the result
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
submod = self.fetch_attr(target)
return submod(*args, **kwargs)
@compatibility(is_backward_compatible=True)
def output(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
"""
Execute an ``output`` node. This really just retrieves
the value referenced by the ``output`` node and returns it.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
Return:
Any: The return value referenced by the output node
"""
return args[0]
# Helper methods
@compatibility(is_backward_compatible=True)
def fetch_attr(self, target: str) -> Any:
"""
Fetch an attribute from the ``Module`` hierarchy of ``self.module``.
Args:
target (str): The fully-qualified name of the attribute to fetch
Return:
Any: The value of the attribute.
"""
target_atoms = target.split(".")
attr_itr = self.module
for i, atom in enumerate(target_atoms):
if not hasattr(attr_itr, atom):
raise RuntimeError(
f"Node referenced nonexistent target {'.'.join(target_atoms[: i + 1])}"
)
attr_itr = getattr(attr_itr, atom)
return attr_itr
@compatibility(is_backward_compatible=True)
def fetch_args_kwargs_from_env(
self, n: Node
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""
Fetch the concrete values of ``args`` and ``kwargs`` of node ``n``
from the current execution environment.
Args:
n (Node): The node for which ``args`` and ``kwargs`` should be fetched.
Return:
Tuple[Tuple, Dict]: ``args`` and ``kwargs`` with concrete values for ``n``.
"""
args = self.map_nodes_to_values(n.args, n)
if not isinstance(args, tuple):
raise AssertionError(f"Expected args to be tuple, got {type(args)}")
kwargs = self.map_nodes_to_values(n.kwargs, n)
if not isinstance(kwargs, dict):
raise AssertionError(f"Expected kwargs to be dict, got {type(kwargs)}")
return args, kwargs
@compatibility(is_backward_compatible=True)
def map_nodes_to_values(self, args: Argument, n: Node) -> Argument:
"""
Recursively descend through ``args`` and look up the concrete value
for each ``Node`` in the current execution environment.
Args:
args (Argument): Data structure within which to look up concrete values
n (Node): Node to which ``args`` belongs. This is only used for error reporting.
"""
def load_arg(n_arg: Node) -> Any:
if n_arg not in self.env:
raise RuntimeError(
f"Node {n} referenced nonexistent value {n_arg}! Run Graph.lint() "
f"to diagnose such issues"
)
return self.env[n_arg]
return map_arg(args, load_arg)
@compatibility(is_backward_compatible=True)
class Transformer(Interpreter):
"""
``Transformer`` is a special type of interpreter that produces a
new ``Module``. It exposes a ``transform()`` method that returns
the transformed ``Module``. ``Transformer`` does not require
arguments to run, as ``Interpreter`` does. ``Transformer`` works
entirely symbolically.
Example:
Suppose we want to swap all instances of ``torch.neg`` with
``torch.sigmoid`` and vice versa (including their ``Tensor``
method equivalents). We could subclass ``Transformer`` like so::
class NegSigmSwapXformer(Transformer):
def call_function(
self,
target: "Target",
args: Tuple[Argument, ...],
kwargs: Dict[str, Any],
) -> Any:
if target is torch.sigmoid:
return torch.neg(*args, **kwargs)
return super().call_function(target, args, kwargs)
def call_method(
self,
target: "Target",
args: Tuple[Argument, ...],
kwargs: Dict[str, Any],
) -> Any:
if target == "neg":
call_self, *args_tail = args
return call_self.sigmoid(*args_tail, **kwargs)
return super().call_method(target, args, kwargs)
def fn(x):
return torch.sigmoid(x).neg()
gm = torch.fx.symbolic_trace(fn)
transformed: torch.nn.Module = NegSigmSwapXformer(gm).transform()
input = torch.randn(3, 4)
torch.testing.assert_close(transformed(input), torch.neg(input).sigmoid())
Args:
module (GraphModule): The ``Module`` to be transformed.
"""
@compatibility(is_backward_compatible=True)
def __init__(self, module: GraphModule) -> None:
super().__init__(module)
self.new_graph = Graph()
self.new_graph.set_codegen(module.graph._codegen)
class TransformerTracer(Tracer):
def __init__(self, graph: Graph) -> None:
super().__init__()
self.graph = graph
self.tensor_attrs: dict[torch.Tensor, str] = {} # type: ignore[assignment]
def is_leaf_module(self, _: torch.nn.Module, __: str) -> bool:
return True
self.tracer = TransformerTracer(self.new_graph)
self.tracer.root = module
@compatibility(is_backward_compatible=True)
def placeholder(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Proxy:
"""
Execute a ``placeholder`` node. In ``Transformer``, this is
overridden to insert a new ``placeholder`` into the output
graph.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
"""
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
default_value = next(iter(args)) if args else inspect.Signature.empty
return Proxy(
self.new_graph.placeholder(target, default_value=default_value), self.tracer
)
@compatibility(is_backward_compatible=True)
def get_attr(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Proxy:
"""
Execute a ``get_attr`` node. In ``Transformer``, this is
overridden to insert a new ``get_attr`` node into the output
graph.
Args:
target (Target): The call target for this node. See
`Node <https://pytorch.org/docs/main/fx.html#torch.fx.Node>`__ for
details on semantics
args (Tuple): Tuple of positional args for this invocation
kwargs (Dict): Dict of keyword arguments for this invocation
"""
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
return self.tracer.create_proxy("get_attr", target, args, kwargs)
@compatibility(is_backward_compatible=True)
def call_module(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
# Override so that the leaf module policy from `self.tracer` is respected.
if not isinstance(target, str):
raise AssertionError(f"Expected target to be str, got {type(target)}")
submod = self.fetch_attr(target)
return self.tracer.call_module(submod, submod.forward, args, kwargs)
@compatibility(is_backward_compatible=True)
def call_function(
self, target: "Target", args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
# Override so that functions that were wrapped are still wrapped.
return self.tracer.create_proxy("call_function", target, args, kwargs)
@compatibility(is_backward_compatible=True)
def transform(self) -> GraphModule:
"""
Transform ``self.module`` and return the transformed
``GraphModule``.
"""
with fx_traceback.preserve_node_meta():
result = super().run(enable_io_processing=False)
if result is not None:
def strip_proxy(a: Argument | Proxy) -> Any:
return a.node if isinstance(a, Proxy) else a
new_output_node = self.new_graph.output(map_aggregate(result, strip_proxy))
# also preserve the metadata from the old output node, if it exists
old_output_node = list(self.graph.nodes)[-1]
if old_output_node.op != "output":
raise AssertionError(
f"Expected output node, got op={old_output_node.op}"
)
for k, v in old_output_node.meta.items():
new_output_node.meta[k] = v
return _make_graph_module(self.module, self.new_graph)
@@ -0,0 +1,912 @@
# Nodes represent a definition of a value in our graph of operators.
import builtins
import inspect
import logging
import operator
import types
import typing
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any, Optional, TYPE_CHECKING, TypeAlias, Union
from typing_extensions import ParamSpec, TypeVar
import torch
from torch._C import _fx_map_aggregate, _fx_map_arg, _NodeBase
from torch.fx.operator_schemas import (
ArgsKwargsPair,
normalize_function,
normalize_module,
)
from torch.utils._dtype_abbrs import dtype_abbrs
from .._ops import ops as _ops
from ._compatibility import compatibility
if TYPE_CHECKING:
from .graph import Graph
__all__ = ["Node", "map_arg", "map_aggregate", "has_side_effect"]
log = logging.getLogger(__name__)
BaseArgumentTypes = Union[ # noqa: UP007
str,
int,
float,
bool,
complex,
torch.dtype,
torch.Tensor,
torch.device,
torch.memory_format,
torch.layout,
torch._ops.OpOverload,
torch.SymInt,
torch.SymBool,
torch.SymFloat,
]
base_types = typing.get_args(BaseArgumentTypes)
Target: TypeAlias = Callable[..., Any] | str
Argument = Optional[ # noqa: UP007, UP045
Union[
tuple["Argument", ...],
Sequence["Argument"],
Mapping[str, "Argument"],
slice, # Slice[Argument, Argument, Argument], but slice is not a templated type in typing
range,
"Node",
BaseArgumentTypes,
]
]
# pyrefly: ignore [invalid-annotation]
ArgumentT = TypeVar("ArgumentT", bound=Argument)
_P = ParamSpec("_P")
_R = TypeVar("_R")
_legal_ops = dict.fromkeys(
[
"placeholder",
"call_method",
"call_module",
"call_function",
"get_attr",
"output",
"root",
]
)
# Dynamo is unable to trace global set[Callable].__contains__.
# See https://github.com/pytorch/pytorch/issues/145761. Since we only have
# a handful of ops so switch to list of callables.
_side_effectful_need_to_be_preserved_pre_dispatch: list[Callable[..., Any]] = [
torch._C._set_grad_enabled,
torch.amp._enter_autocast,
torch.amp._exit_autocast,
]
# TODO: Either refactor this into 2 functions 1 dce for functional graphs and 1 dce for all graphs,
# or add logic to correctly mark all inplace ops as side effectful.
#
# NOTE: For new operators, please do not add to this set!
# Instead, consider using the effects system via
# torch.library._register_effectful_op() for operators.
#
# This _side_effectful_functions set is only for:
# - Legacy functions that aren't operators (e.g., profiler ops, asserts)
# - Things that cannot be marked via the normal effects system
_side_effectful_functions: set[Callable[..., Any]] = {
torch._assert,
torch._assert_async,
_ops.aten._assert_async.msg,
_ops.aten._assert_scalar.default,
_ops.aten._assert_tensor_metadata.default,
_ops.aten.sym_constrain_range.default,
_ops.aten.sym_constrain_range_for_size.default,
_ops.profiler._record_function_enter,
_ops.profiler._record_function_enter.default,
_ops.profiler._record_function_enter_new,
_ops.profiler._record_function_enter_new.default,
_ops.profiler._record_function_exit,
_ops.profiler._record_function_exit._RecordFunction,
_ops.inductor.accumulate_grad_.default,
operator.setitem,
*_side_effectful_need_to_be_preserved_pre_dispatch,
}
if hasattr(_ops.inductor, "resize_storage_bytes_"):
_side_effectful_functions.add(_ops.inductor.resize_storage_bytes_.default)
@compatibility(is_backward_compatible=False)
def has_side_effect(fn: Callable[_P, _R]) -> Callable[_P, _R]:
"""
Registers a function to not be dead code eliminated by
fx.graph.eliminate_dead_code
NOTE: For new operators, please do not add to this set!
Instead, consider using the effects system via
torch.library._register_effectful_op() for operators.
This _side_effectful_functions set is only for:
- Legacy functions that aren't operators (e.g., profiler ops, asserts)
- Things that cannot be marked via the normal effects system
"""
_side_effectful_functions.add(fn)
return fn
# this is fixed on master, WAR for 1.5
def _find_module_of_method(orig_method: Callable[..., Any]) -> str:
name = orig_method.__name__
module = orig_method.__module__
if module is not None:
return module
for guess in [torch, torch.nn.functional]:
if getattr(guess, name, None) is orig_method:
return guess.__name__
raise RuntimeError(f"cannot find module for {orig_method}")
# Borrowed from CPython typing module
# https://github.com/python/cpython/blob/f90dc36c15d7fee0efaf6d39e97be0bdf2683e93/Lib/typing.py#L156
def _type_repr(obj: object) -> str:
"""Return the repr() of an object, special-casing types (internal helper).
If obj is a type, we return a shorter version than the default
type.__repr__, based on the module and qualified name, which is
typically enough to uniquely identify a type. For everything
else, we fall back on repr(obj).
"""
# Extension: If we don't ignore GenericAlias then `list[int]` will print
# simply "list".
if isinstance(obj, type) and not isinstance(obj, types.GenericAlias):
if obj.__module__ == "builtins":
return obj.__qualname__
return f"{obj.__module__}.{obj.__qualname__}"
if obj is ...:
return "..."
if isinstance(obj, types.FunctionType):
return obj.__name__
return repr(obj)
def _get_qualified_name(func: Callable[..., Any]) -> str:
# things like getattr just appear in builtins
if getattr(builtins, func.__name__, None) is func:
return func.__name__
# torch.Tensor.{fn}
if (
isinstance(func, (types.MethodDescriptorType, types.WrapperDescriptorType))
and func is getattr(torch.Tensor, func.__name__, None)
) or (
func.__module__ == torch._tensor.__name__
and func.__qualname__ == f"Tensor.{func.__name__}"
):
return f"torch.Tensor.{func.__name__}"
name = func.__name__
if name == "<lambda>":
# For lambdas, try to get their defining name in the module
try:
name = inspect.getsource(func).split("=")[0].strip()
except Exception as e:
raise RuntimeError("Unable to represent lambda") from e
module = _find_module_of_method(func)
module = module.replace(
"torch._ops", "torch.ops"
) # WAR for bug in how torch.ops assigns module
# Fixup segment_reduce mismatch
if module == "torch" and name == "segment_reduce":
name = "_" + name
if module == "torch.nn.functional" and name in ("_ScalingType", "_SwizzleType"):
name = name.removeprefix("_")
return f"{module}.{name}"
def _format_arg(arg: object, max_list_len: float = float("inf")) -> str:
if hasattr(arg, "_custom_fx_repr_fn"):
return arg._custom_fx_repr_fn()
elif isinstance(arg, list):
items = ", ".join(
_format_arg(a) for idx, a in enumerate(arg) if idx < max_list_len
)
maybe_len = (
"" if len(arg) < max_list_len + 1 else f", ...[total_len={len(arg)}]"
)
return f"[{items}{maybe_len}]"
elif isinstance(arg, tuple):
items = ", ".join(
_format_arg(a) for idx, a in enumerate(arg) if idx < max_list_len
)
maybe_len = (
"" if len(arg) < max_list_len + 1 else f", ...[total_len={len(arg)}]"
)
maybe_comma = "," if len(arg) == 1 else ""
return f"({items}{maybe_comma}{maybe_len})"
elif isinstance(arg, dict):
items_str = ", ".join(f"{k}: {_format_arg(v)}" for k, v in arg.items())
return f"{{{items_str}}}"
if isinstance(arg, Node):
return "%" + str(arg)
else:
return str(arg)
@compatibility(is_backward_compatible=True)
class Node(_NodeBase):
"""
``Node`` is the data structure that represents individual operations within
a ``Graph``. For the most part, Nodes represent callsites to various entities,
such as operators, methods, and Modules (some exceptions include nodes that
specify function inputs and outputs). Each ``Node`` has a function specified
by its ``op`` property. The ``Node`` semantics for each value of ``op`` are as follows:
- ``placeholder`` represents a function input. The ``name`` attribute specifies the name this value will take on.
``target`` is similarly the name of the argument. ``args`` holds either: 1) nothing, or 2) a single argument
denoting the default parameter of the function input. ``kwargs`` is don't-care. Placeholders correspond to
the function parameters (e.g. ``x``) in the graph printout.
- ``get_attr`` retrieves a parameter from the module hierarchy. ``name`` is similarly the name the result of the
fetch is assigned to. ``target`` is the fully-qualified name of the parameter's position in the module hierarchy.
``args`` and ``kwargs`` are don't-care
- ``call_function`` applies a free function to some values. ``name`` is similarly the name of the value to assign
to. ``target`` is the function to be applied. ``args`` and ``kwargs`` represent the arguments to the function,
following the Python calling convention
- ``call_module`` applies a module in the module hierarchy's ``forward()`` method to given arguments. ``name`` is
as previous. ``target`` is the fully-qualified name of the module in the module hierarchy to call.
``args`` and ``kwargs`` represent the arguments to invoke the module on, *excluding the self argument*.
- ``call_method`` calls a method on a value. ``name`` is as similar. ``target`` is the string name of the method
to apply to the ``self`` argument. ``args`` and ``kwargs`` represent the arguments to invoke the module on,
*including the self argument*
- ``output`` contains the output of the traced function in its ``args[0]`` attribute. This corresponds to the "return" statement
in the Graph printout.
"""
_args: tuple["Argument", ...]
_kwargs: dict[str, "Argument"]
graph: "Graph"
# unique name of value being created
name: str
# the kind of operation = placeholder|call_method|call_module|call_function|get_attr
op: str
# for method/module/function, the name of the method/module/function/attr
# being invoked, e.g add, layer1, or torch.add
target: "Target"
# All `Node`-valued inputs. Key is the Node, value is don't-care.
# The public API for this is `all_input_nodes`, this private attribute
# should not be accessed directly.
_input_nodes: dict["Node", None]
# All of the nodes that use the value produced by this Node
# Note one user may correspond to several uses, e.g. the node for ``x + x``
# would appear once here, but represents two uses.
# Is a dict to act as an "ordered set". Keys are significant, value dont-care
users: dict["Node", None]
# Type expression representing the output value of this node.
# This should contain the same class of Type objects that would appear
# as type annotations for function inputs/outputs.
#
# For placeholder nodes, this value will be used to type-annotate the
# generated function parameters.
# For the return node, this value will be used to type-annotate the
# generated function return type. (Note this is a special case. ``return``
# does not produce a value, it's more of a notation. Thus, this value
# describes the type of args[0] in the ``return`` node.
type: Any | None
_sort_key: Any
# If set, use this fn to print this node
_repr_fn: Callable[["Node"], str] | None
# Dictionary to store metadata passes need to do their
# transformations. This metadata is preserved across node copies
meta: dict[str, Any]
@compatibility(is_backward_compatible=True)
def __init__(
self,
graph: "Graph",
name: str,
op: str,
target: "Target",
args: tuple["Argument", ...],
kwargs: dict[str, "Argument"],
return_type: Any | None = None,
) -> None:
"""
Instantiate an instance of ``Node``. Note: most often, you want to use the
Graph APIs, i.e. ``Graph.call_module``, ``Graph.call_method``, etc. rather
than instantiating a ``Node`` directly.
Args:
graph (Graph): The ``Graph`` to which this ``Node`` should belong.
name (str): The name to which the output of this ``Node`` should be assigned
op (str): The opcode for this ``Node``. Can be one of 'placeholder',
'call_method', 'call_module', 'call_function', 'get_attr',
'output'
target ('Target'): The target this op should call. See the broader
``Node`` docstring for more details.
args (Tuple['Argument']): The args to be passed to ``target``
kwargs (Dict[str, 'Argument']): The kwargs to be passed to ``target``
return_type (Optional[Any]): The python type expression representing the
type of the output of this node. This field can be used for
annotation of values in the generated code or for other types
of analyses.
"""
if op == "call_function":
if not callable(target):
raise ValueError(
f"Node [graph = {graph}, name = '{name}'] target {target} has type {torch.typename(target)} "
"but a Callable is expected"
)
else:
if op not in _legal_ops:
raise AssertionError(f"op '{op}' is not in _legal_ops")
if not isinstance(target, str):
raise ValueError(
f"Node [graph = {graph}, name = '{name}'] target {target} has type {torch.typename(target)} "
"but a str is expected"
)
super().__init__(graph, name, op, target, return_type)
self._update_args_kwargs(args, kwargs)
def __getstate__(self) -> dict[str, Any]:
return {
**self.__dict__,
"graph": self.graph,
"name": self.name,
"op": self.op,
"target": self.target,
"type": self.type,
"_sort_key": self._sort_key,
"_args": self._args,
"_kwargs": self._kwargs,
"_erased": self._erased,
"_prev": self._prev,
"_next": self._next,
"_input_nodes": self._input_nodes,
"users": self.users,
"_repr_fn": self._repr_fn,
"meta": self.meta,
}
def __setstate__(self, state: dict[str, Any]) -> None:
for k, v in state.items():
setattr(self, k, v)
@property
def next(self) -> "Node":
"""
Returns the next ``Node`` in the linked list of Nodes.
Returns:
The next ``Node`` in the linked list of Nodes.
"""
return self._next
@property
def prev(self) -> "Node":
"""
Returns the previous ``Node`` in the linked list of Nodes.
Returns:
The previous ``Node`` in the linked list of Nodes.
"""
return self._prev
@compatibility(is_backward_compatible=True)
def prepend(self, x: "Node") -> None:
"""
Insert x before this node in the list of nodes in the graph. Example::
Before: p -> self
bx -> x -> ax
After: p -> x -> self
bx -> ax
Args:
x (Node): The node to put before this node. Must be a member of the same graph.
"""
self._prepend(x)
@compatibility(is_backward_compatible=True)
def append(self, x: "Node") -> None:
"""
Insert ``x`` after this node in the list of nodes in the graph.
Equivalent to ``self.next.prepend(x)``
Args:
x (Node): The node to put after this node. Must be a member of the same graph.
"""
self._next._prepend(x)
@property
def args(self) -> tuple[Argument, ...]:
"""
The tuple of arguments to this ``Node``. The interpretation of arguments
depends on the node's opcode. See the :class:`Node` docstring for more
information.
Assignment to this property is allowed. All accounting of uses and users
is updated automatically on assignment.
"""
return self._args
@args.setter
def args(self, a: tuple[Argument, ...]) -> None:
"""
Set the tuple of arguments to this Node. The interpretation of arguments
depends on the node's opcode. See the ``fx.Graph`` docstring for more
information.
"""
# DO NOT CALL `_update_args_kwargs` directly. The correct way to
# set `args` is via direct assignment, i.e. `node.args = new_args`
self._update_args_kwargs(a, self._kwargs)
@property
def kwargs(self) -> dict[str, Argument]:
"""
The dict of keyword arguments to this ``Node``. The interpretation of arguments
depends on the node's opcode. See the :class:`Node` docstring for more
information.
Assignment to this property is allowed. All accounting of uses and users
is updated automatically on assignment.
"""
return self._kwargs
@kwargs.setter
def kwargs(self, k: dict[str, Argument]) -> None:
"""
Set the dict of kwargs to this Node. The interpretation of arguments
depends on the node's opcode. See the ``fx.Graph`` docstring for more
information.
"""
# DO NOT CALL `_update_args_kwargs` directly. The correct way to
# set `args` is via direct assignment, i.e. `node.kwargs = new_kwargs`
self._update_args_kwargs(self._args, k)
@property
def all_input_nodes(self) -> list["Node"]:
"""
Return all Nodes that are inputs to this Node. This is equivalent to
iterating over ``args`` and ``kwargs`` and only collecting the values that
are Nodes.
Returns:
List of ``Nodes`` that appear in the ``args`` and ``kwargs`` of this
``Node``, in that order.
"""
return list(self._input_nodes.keys())
@compatibility(is_backward_compatible=True)
def update_arg(self, idx: int, arg: Argument) -> None:
"""
Update an existing positional argument to contain the new value
``arg``. After calling, ``self.args[idx] == arg``.
Args:
idx (int): The index into ``self.args`` of the element to update
arg (Argument): The new argument value to write into ``args``
"""
args = list(self.args)
args[idx] = arg
self.args = tuple(args)
@compatibility(is_backward_compatible=True)
def insert_arg(self, idx: int, arg: Argument) -> None:
"""
Insert an positional argument to the argument list with given index.
Args:
idx (int): The index of the element in ``self.args`` to be inserted before.
arg (Argument): The new argument value to insert into ``args``
"""
if not (0 <= idx <= len(self.args)):
raise AssertionError(
f"insert_args index must be between 0 and len(self.args), got {idx}"
)
args_left = self.args[:idx]
args_right = self.args[idx:]
self._args = args_left + (arg,) + args_right
_new_input_nodes: dict[Node, None] = {}
_fx_map_arg(arg, _new_input_nodes.setdefault)
for new_use in _new_input_nodes:
if new_use not in self._input_nodes:
self._input_nodes.setdefault(new_use)
new_use.users.setdefault(self)
@compatibility(is_backward_compatible=True)
def update_kwarg(self, key: str, arg: Argument) -> None:
"""
Update an existing keyword argument to contain the new value
``arg``. After calling, ``self.kwargs[key] == arg``.
Args:
key (str): The key in ``self.kwargs`` of the element to update
arg (Argument): The new argument value to write into ``kwargs``
"""
self.kwargs = {**self.kwargs, key: arg}
@property
def stack_trace(self) -> str | None:
"""
Return the Python stack trace that was recorded during tracing, if any.
When traced with fx.Tracer, this property is usually populated by
`Tracer.create_proxy`. To record stack traces during tracing for debug purposes,
set `record_stack_traces = True` on the `Tracer` instance.
When traced with dynamo, this property will be populated by default by
`OutputGraph.create_proxy`.
stack_trace would have the innermost frame at the end of the string.
"""
return self.meta.get("stack_trace", None)
@stack_trace.setter
def stack_trace(self, trace: str | None) -> None:
self.meta["stack_trace"] = trace
def __repr__(self) -> str:
if self._repr_fn:
return self._repr_fn(self)
return self.name
@staticmethod
def _pretty_print_target(target: object) -> str:
"""
Make target printouts more user-friendly.
1) builtins will be printed as `builtins.xyz`
2) operators will be printed as `operator.xyz`
3) other callables will be printed with qualified name, e.g. torch.add
"""
if isinstance(target, str):
return target
if hasattr(target, "__module__"):
name = getattr(target, "__name__", None)
if name is None:
# Just to be defensive, if we don't have `__name__`, get the
# qualname. Not sure if this happens for any members of `operator`
# or `builtins`. This fallback path is not as good, since e.g.
# things in `operator` have `_operator` as their __module__.
# TODO: THIS IS BROKEN: _get_qualified_name calls `__name__`
return _get_qualified_name(target) # type: ignore[arg-type]
if target.__module__ == "builtins":
return f"builtins.{name}"
elif target.__module__ == "_operator":
return f"operator.{name}"
return _get_qualified_name(target) # type: ignore[arg-type]
@compatibility(is_backward_compatible=True)
def format_node(
self,
placeholder_names: list[str] | None = None,
maybe_return_typename: list[str] | None = None,
*,
include_tensor_metadata: bool = False,
) -> str | None:
"""
Return a descriptive string representation of ``self``.
This method can be used with no arguments as a debugging
utility.
This function is also used internally in the ``__str__`` method
of ``Graph``. Together, the strings in ``placeholder_names``
and ``maybe_return_typename`` make up the signature of the
autogenerated ``forward`` function in this Graph's surrounding
GraphModule. ``placeholder_names`` and ``maybe_return_typename``
should not be used otherwise.
Args:
placeholder_names: A list that will store formatted strings
representing the placeholders in the generated
``forward`` function. Internal use only.
maybe_return_typename: A single-element list that will store
a formatted string representing the output of the
generated ``forward`` function. Internal use only.
include_tensor_metadata: Whether to include tensor metadata
Returns:
str: If 1) we're using ``format_node`` as an internal helper
in the ``__str__`` method of ``Graph``, and 2) ``self``
is a placeholder Node, return ``None``. Otherwise,
return a descriptive string representation of the
current Node.
"""
if self.op == "placeholder":
if not isinstance(self.target, str):
raise AssertionError(
f"Expected target to be str for placeholder, got {type(self.target)}"
)
arg_str = self.target
arg_str += arg_str + f": {_type_repr(self.type)}" if self.type else ""
if placeholder_names:
placeholder_names.append(arg_str)
return None
maybe_typename = f"{_type_repr(self.type)} " if self.type else ""
default_val = "(default=" + str(self.args[0]) + ")" if self.args else ""
return f"%{self.name} : {maybe_typename}[num_users={len(self.users)}] = {self.op}[target={self.target}]{default_val}"
elif self.op == "get_attr":
maybe_typename = (
f"{_type_repr(self.type)} " if self.type is not None else ""
)
return (
f"%{self.name} : {maybe_typename}[num_users={len(self.users)}] = "
f"{self.op}[target={self._pretty_print_target(self.target)}]"
)
elif self.op == "output":
if self.type and maybe_return_typename:
maybe_return_typename[0] = f" -> {_type_repr(self.type)}"
return f"return {self.args[0]}"
else:
def stringify_shape(shape: Iterable[Any]) -> str:
return f"[{', '.join([str(x) for x in shape])}]"
meta_val = self.meta.get(
"val",
self.meta.get("tensor_meta", self.meta.get("example_value", None)),
)
type_annotation = ""
if (
include_tensor_metadata
and isinstance(meta_val, torch.Tensor)
and meta_val.layout
not in (
torch.sparse_csc,
torch.sparse_csr,
)
):
stride_annotation = f"{stringify_shape(meta_val.stride())}"
device_annotation = f"{meta_val.device}"
type_annotation = (
f'Tensor "{dtype_abbrs[meta_val.dtype]}{stringify_shape(meta_val.shape)}'
f'{stride_annotation}{device_annotation}"'
)
else:
type_annotation = (
f"{_type_repr(self.type)} " if self.type is not None else ""
)
return (
f"%{self.name} : {type_annotation}[num_users={len(self.users)}] = "
f"{self.op}[target={self._pretty_print_target(self.target)}]("
f"args = {_format_arg(self.args)}, kwargs = {_format_arg(self.kwargs)})"
)
@compatibility(is_backward_compatible=True)
def replace_all_uses_with(
self,
replace_with: "Node",
delete_user_cb: Callable[["Node"], bool] | None = None,
*,
propagate_meta: bool = False,
) -> list["Node"]:
"""
Replace all uses of ``self`` in the Graph with the Node ``replace_with``.
Args:
replace_with (Node): The node to replace all uses of ``self`` with.
delete_user_cb (Callable): Callback that is called to determine
whether a given user of the self node should be removed.
propagate_meta (bool): Whether or not to copy all properties
on the .meta field of the original node onto the replacement node.
For safety, this is only valid to do if the replacement node
doesn't already have an existing .meta field.
Returns:
The list of Nodes on which this change was made.
"""
if propagate_meta:
if len(replace_with.meta) != 0:
raise AssertionError(
"Called node.replace_all_uses_with(replace_with, propagate_meta=True), "
"but replace_with already has .meta keys"
)
for k, v in self.meta.items():
replace_with.meta[k] = v
to_process = [*self.users]
replace_hooks = getattr(self.graph.owning_module, "_replace_hooks", None)
result = []
for use_node in to_process:
if delete_user_cb is not None and not delete_user_cb(use_node):
continue
result.append(use_node)
if replace_hooks:
for replace_hook in replace_hooks:
replace_hook(old=self, new=replace_with.name, user=use_node)
use_node._replace_input_with(self, replace_with)
return result
@compatibility(is_backward_compatible=False)
def is_impure(self, impure_random: bool = True) -> bool:
"""
Returns whether this op is impure, i.e. if its op is a placeholder or
output, or if a call_function or call_module which is impure.
Args:
impure_random (bool): Whether to treat rand op as impure.
Returns:
bool: If the op is impure or not.
"""
# Placeholders and outputs are always impure for DCE purposes
if self.op in {"placeholder", "output"}:
return True
# Check if an impure module.
if self.op == "call_module":
if self.graph.owning_module is None:
raise AssertionError(
"self.graph.owning_module not set for purity check"
)
target_mod = self.graph.owning_module.get_submodule(self.target)
if target_mod is None:
raise AssertionError(
f"Did not find expected submodule target {self.target}"
)
# NOTE: here we can end up considering GraphModule submodules pure,
# even if they contain impure ops. It may not be safe to change
# because this function is used by graph.eliminate_dead_code,
# and some users depend on current elimination behavior.
return getattr(target_mod, "_is_impure", False)
# For call_function, delegate to the unified has_side_effects function
if self.op == "call_function":
from torch._library.utils import is_impure
return is_impure(
self.target, # pyrefly: ignore[bad-argument-type]
args=self.args,
kwargs=self.kwargs,
impure_random=impure_random,
)
return False
@compatibility(is_backward_compatible=False)
def normalized_arguments(
self,
root: torch.nn.Module,
arg_types: tuple[Any] | None = None,
kwarg_types: dict[str, Any] | None = None,
normalize_to_only_use_kwargs: bool = False,
) -> ArgsKwargsPair | None:
"""
Returns normalized arguments to Python targets. This means that
`args/kwargs` will be matched up to the module/functional's
signature and return exclusively kwargs in positional order
if `normalize_to_only_use_kwargs` is true.
Also populates default values. Does not support positional-only
parameters or varargs parameters.
Supports module calls.
May require `arg_types` and `kwarg_types` in order to disambiguate overloads.
Args:
root (torch.nn.Module): Module upon which to resolve module targets.
arg_types (Optional[Tuple[Any]]): Tuple of arg types for the args
kwarg_types (Optional[Dict[str, Any]]): Dict of arg types for the kwargs
normalize_to_only_use_kwargs (bool): Whether to normalize to only use kwargs.
Returns:
Returns NamedTuple ArgsKwargsPair, or `None` if not successful.
"""
if self.op == "call_function":
if not callable(self.target):
raise AssertionError(
f"Expected callable target, got {type(self.target)}"
)
return normalize_function(
self.target,
self.args, # type: ignore[arg-type]
self.kwargs,
arg_types,
kwarg_types,
normalize_to_only_use_kwargs=normalize_to_only_use_kwargs,
)
elif self.op == "call_module":
if not isinstance(self.target, str):
raise AssertionError(
f"Expected str target for call_module, got {type(self.target)}"
)
return normalize_module(
root,
self.target,
self.args, # type: ignore[arg-type]
self.kwargs,
normalize_to_only_use_kwargs=normalize_to_only_use_kwargs,
)
return None
@compatibility(is_backward_compatible=True)
def replace_input_with(self, old_input: "Node", new_input: "Node") -> None:
"""
Loop through input nodes of ``self``, and replace all instances of
``old_input`` with ``new_input``.
Args:
old_input (Node): The old input node to be replaced.
new_input (Node): The new input node to replace ``old_input``.
"""
m = self.graph.owning_module
if getattr(m, "_replace_hooks", None):
for replace_hook in m._replace_hooks:
replace_hook(old=old_input, new=new_input.name, user=self)
self._replace_input_with(old_input, new_input)
def _rename(self, candidate: str) -> None:
if candidate == self.name:
return
name = self.graph._graph_namespace.create_name(candidate, None)
self.name = name
self.graph._graph_namespace._rename_object(self, name)
def __setattr__(self, name: str, value: Any) -> None:
if name == "name" and hasattr(self, "name"):
m = self.graph.owning_module
if getattr(m, "_replace_hooks", None):
if not isinstance(value, str):
raise AssertionError(f"Expected value to be str, got {type(value)}")
for user in self.users:
for replace_hook in m._replace_hooks:
replace_hook(old=self, new=value, user=user)
update = False
if (
hasattr(self, name)
and hasattr(self.graph, "_find_nodes_lookup_table")
and self in self.graph._find_nodes_lookup_table
):
update = True
self.graph._find_nodes_lookup_table.remove(self)
object.__setattr__(self, name, value)
if update:
self.graph._find_nodes_lookup_table.insert(self)
@compatibility(is_backward_compatible=True)
def map_arg(a: ArgumentT, fn: Callable[[Node], Argument]) -> ArgumentT:
"""
Apply fn recursively to each Node appearing in arg.
arg may be a list, tuple, slice, or dict with string keys: the return value will
have the same type and structure.
"""
if not callable(fn):
raise AssertionError("torch.fx.map_arg(a, fn): fn must be a callable")
return _fx_map_arg(a, fn)
@compatibility(is_backward_compatible=True)
def map_aggregate(a: ArgumentT, fn: Callable[[Argument], Argument]) -> ArgumentT:
"""
Apply fn recursively to each object appearing in arg.
arg may be a list, tuple, slice, or dict with string keys: the return value will
have the same type and structure.
"""
return _fx_map_aggregate(a, fn)
@@ -0,0 +1,623 @@
import enum
import inspect
import numbers
import types
import typing
import warnings
from collections.abc import Callable
from typing import Any, cast, Literal, NamedTuple, overload, TYPE_CHECKING
import torch
from torch._jit_internal import boolean_dispatched
from torch._ops import OpOverload, OpOverloadPacket
from torch.utils._inspect import _fast_bind
from ._compatibility import compatibility
if TYPE_CHECKING:
from .node import Argument
__all__ = [
"ArgsKwargsPair",
"check_for_mutable_operation",
"get_signature_for_torch_op",
"create_type_hint",
"type_matches",
"normalize_function",
"normalize_module",
]
@compatibility(is_backward_compatible=False)
class ArgsKwargsPair(NamedTuple):
"""
Simple named tuple for wrapping args/kwargs pairs.
"""
args: tuple[Any, ...]
kwargs: dict[str, Any]
_manual_overrides: dict[Callable[..., Any], list[inspect.Signature]] = {}
def _nonzero_schemas() -> list[inspect.Signature]:
signatures = []
def nonzero(self: torch.Tensor) -> None:
pass
signatures.append(inspect.signature(nonzero))
def nonzero(self: torch.Tensor, *, as_tuple: bool) -> None: # type: ignore[no-redef]
pass
signatures.append(inspect.signature(nonzero))
return signatures
_manual_overrides[torch.nonzero] = _nonzero_schemas()
class _FakeGlobalNamespace:
def __getattr__(self, name: str) -> types.ModuleType:
if name == "torch":
return torch
raise RuntimeError("Expected a torch namespace lookup")
_type_eval_globals = {
"Tensor": torch.Tensor,
"Device": torch.device,
"Layout": torch.layout,
"number": numbers.Number,
"Future": torch.jit.Future,
"AnyEnumType": enum.Enum,
"QScheme": torch.qscheme,
"__torch__": _FakeGlobalNamespace(),
"NoneType": type(None),
"Storage": torch.UntypedStorage,
"t": typing.TypeVar("t"),
"PyObject": Any,
}
for k in dir(typing):
_type_eval_globals[k] = getattr(typing, k)
def _torchscript_type_to_python_type(ts_type: "torch._C.JitType") -> Any:
"""
Convert a TorchScript type to a Python type (including subtypes) via
eval'ing the annotation_str. _type_eval_globals sets up expressions
like "List" and "Future" to map to actual types (typing.List and jit.Future)
"""
return eval(ts_type.annotation_str, _type_eval_globals)
def _torchscript_schema_to_signature_impl(
ts_schema: torch._C.FunctionSchema,
) -> inspect.Signature:
from inspect import Parameter
parameters: list[Parameter] = []
for arg in ts_schema.arguments:
arg_type = _torchscript_type_to_python_type(arg.type)
default = arg.default_value if arg.has_default_value() else Parameter.empty
# TODO: Figure out if this is safe. It seems like when generating the type signatures for
# PythonArgParser, we emit signatures with `input` instead of `self` as the first tensor
# argument name. Downstream, if someone converts that positional argument to a keyword
# argument, the name mismatch will break things, so here we're going to normalize the
# name to "input"
name = arg.name if arg.name != "self" else "input"
kind = (
Parameter.KEYWORD_ONLY
if arg.kwarg_only
else Parameter.POSITIONAL_OR_KEYWORD
)
# "from" is a keyword therefore it must be a POSITIONAL_ONLY argument
if name == "from":
if kind != Parameter.POSITIONAL_OR_KEYWORD:
raise AssertionError(f"Expected POSITIONAL_OR_KEYWORD, got {kind}")
# ParameterKind type is internal implementation detail to inspec package
# which makes it hard to do type annotation
kind = Parameter.POSITIONAL_ONLY # type: ignore[assignment]
# This renders all previous arguments to positional only
for idx, p in enumerate(parameters):
if p.kind != Parameter.POSITIONAL_OR_KEYWORD:
raise AssertionError(
f"Expected POSITIONAL_OR_KEYWORD for param {p.name}, got {p.kind}"
)
parameters[idx] = Parameter(
name=p.name,
kind=Parameter.POSITIONAL_ONLY,
default=p.default,
annotation=p.annotation,
)
parameters.append(
Parameter(name=name, kind=kind, default=default, annotation=arg_type)
)
return_types = [
_torchscript_type_to_python_type(ret.type) for ret in ts_schema.returns
]
if len(return_types) == 0:
return_type = None
elif len(return_types) == 1:
return_type = return_types[0]
else:
return_type = tuple(return_types)
return inspect.Signature(parameters, return_annotation=return_type)
_SCHEMA_TO_SIGNATURE_CACHE: dict[tuple[str, str], inspect.Signature] = {}
def _torchscript_schema_to_signature(
ts_schema: torch._C.FunctionSchema,
) -> inspect.Signature:
# Cached as it's called in the hot path of FakeTensor dispatch
cache_key = ts_schema.name, ts_schema.overload_name
cache_val = _SCHEMA_TO_SIGNATURE_CACHE.get(cache_key)
if cache_val is not None:
return cache_val
res = _torchscript_schema_to_signature_impl(ts_schema)
_SCHEMA_TO_SIGNATURE_CACHE[cache_key] = res
return res
@compatibility(is_backward_compatible=False)
def check_for_mutable_operation(
target: Callable[..., Any],
args: tuple["Argument", ...],
kwargs: dict[str, "Argument"],
) -> None:
signatures, schemas = get_signature_for_torch_op(target, return_schemas=True)
if signatures and schemas:
matched_schemas: list[tuple[inspect.Signature, torch._C.FunctionSchema]] = []
# Iterate through all of the schema until we find one that matches
# If one matches, populate `new_args_and_kwargs` with the new args/kwargs
# values. If none matches, `new_args_and_kwargs` will be None
for candidate_signature, schema in zip(signatures, schemas):
try:
_fast_bind(candidate_signature, *args, **kwargs)
matched_schemas.append((candidate_signature, schema))
except TypeError:
continue
def throw_if_mutable(schema: torch._C.FunctionSchema) -> None:
if schema.is_mutable:
raise RuntimeError(
f"Tried to trace mutable operation {schema}. FX only supports functional "
f"code, so operations that mutate operands in-place (e.g. via `out` arguments) "
f"are not supported"
)
if len(matched_schemas) == 0:
# Did not match any schema. Cannot check for mutation
pass
elif len(matched_schemas) == 1:
# Matched exactly one schema, unambiguous
_, schema_to_check = matched_schemas[0]
throw_if_mutable(schema_to_check)
else:
# Ambiguous schema match. Since mutability checking is best effort,
# do nothing.
pass
@overload
def get_signature_for_torch_op(
op: Callable[..., Any], return_schemas: Literal[True]
) -> tuple[list[inspect.Signature] | None, list[torch._C.FunctionSchema] | None]: ...
@overload
def get_signature_for_torch_op(
op: Callable[..., Any], return_schemas: Literal[False] = ...
) -> list[inspect.Signature] | None: ...
@compatibility(is_backward_compatible=False)
def get_signature_for_torch_op(
op: Callable[..., Any], return_schemas: bool = False
) -> (
list[inspect.Signature]
| tuple[list[inspect.Signature] | None, list[torch._C.FunctionSchema] | None]
| None
):
"""
Given an operator on the `torch` namespace, return a list of `inspect.Signature`
objects corresponding to the overloads of that op.. May return `None` if a signature
could not be retrieved.
Args:
op (Callable): An operator on the `torch` namespace to look up a signature for
Returns:
Optional[List[inspect.Signature]]: A list of signatures for the overloads of this
operator, or None if the operator signatures could not be retrieved. If
return_schemas=True, returns a tuple containing the optional Python signatures
and the optional TorchScript Function signature
"""
if isinstance(op, OpOverload):
schemas = [op._schema]
elif isinstance(op, OpOverloadPacket):
schemas = [getattr(op, overload)._schema for overload in op.overloads()]
else:
override = _manual_overrides.get(op)
if override:
return (override, None) if return_schemas else None
aten_fn = torch.jit._builtins._find_builtin(op)
if aten_fn is None:
return (None, None) if return_schemas else None
schemas = torch._C._jit_get_schemas_for_operator(aten_fn)
signatures = [_torchscript_schema_to_signature(schema) for schema in schemas]
return (signatures, schemas) if return_schemas else signatures
@compatibility(is_backward_compatible=False)
def create_type_hint(x: object) -> object:
"""
Produces a type hint for the given argument.
The :func:`create_type_hint` looks for a type hint compatible with the input argument `x`.
If `x` is a `list` or `tuple`, it looks for an object in the list whose type is a superclass
of the rest, and uses that as `base_type` for the `List` or `Tuple` to be returned.
If no such object is found, it defaults to `List[Any]`.
If `x` is neither a `list` nor a `tuple`, it returns `x`.
"""
try:
if isinstance(x, (list, tuple)):
# todo(chilli): Figure out the right way for mypy to handle this
if isinstance(x, list):
def ret_type(x: Any) -> Any:
return list[x] # type: ignore[valid-type]
else:
def ret_type(x: Any) -> Any:
return tuple[x, ...] # type: ignore[valid-type]
if len(x) == 0:
return ret_type(Any)
base_type = x[0]
for t in x:
if issubclass(t, base_type):
continue
elif issubclass(base_type, t):
base_type = t
else:
return ret_type(Any)
return ret_type(base_type)
except Exception:
# We tried to create a type hint for list but failed.
warnings.warn(
f"We were not able to successfully create type hint from the type {x}"
)
return x
@compatibility(is_backward_compatible=False)
def type_matches(signature_type: Any, argument_type: Any) -> bool:
sig_origin_type = getattr(signature_type, "__origin__", signature_type)
if signature_type is argument_type:
return True
# Union types in signature. Given type needs to match one of the
# contained types in the Union
if sig_origin_type is typing.Union and signature_type != argument_type:
sig_contained = signature_type.__args__
return any(type_matches(c, argument_type) for c in sig_contained)
if getattr(signature_type, "__origin__", None) is list:
sig_el_type = signature_type.__args__[0]
# int can be promoted to list[int]
if argument_type is int and sig_el_type is int:
return True
if not inspect.isclass(sig_el_type):
warnings.warn(
f"Does not support nested parametric types, got {signature_type}. Please file a bug."
)
return False
if getattr(argument_type, "__origin__", None) is list:
return issubclass(argument_type.__args__[0], sig_el_type)
def is_homogeneous_tuple(t: object) -> bool:
if typing.get_origin(t) is not tuple:
return False
contained = typing.get_args(t)
if contained == ((),): # Tuple[()].__args__ == ((),) for some reason
return True
return all((c is Ellipsis) or issubclass(c, sig_el_type) for c in contained)
# Tuple[T] is accepted for List[T] parameters
return is_homogeneous_tuple(argument_type)
# Dtype is an int in schemas
if signature_type is int and argument_type is torch.dtype:
return True
if signature_type is numbers.Number and argument_type in {int, float}:
return True
if inspect.isclass(argument_type) and inspect.isclass(signature_type):
return issubclass(argument_type, signature_type)
return False
@compatibility(is_backward_compatible=False)
def _normalize_function_or_error(
target: Callable[..., Any],
args: tuple[Any, ...],
kwargs: dict[str, Any] | None = None,
arg_types: tuple[Any] | None = None,
kwarg_types: dict[str, Any] | None = None,
normalize_to_only_use_kwargs: bool = False,
) -> ArgsKwargsPair:
"""
Wrapper around normalize_function that never returns None, but
loudly errors instead
"""
res = normalize_function(
target, args, kwargs, arg_types, kwarg_types, normalize_to_only_use_kwargs
)
if res is None:
raise RuntimeError(
f"Failed to normalize function {target} with args {args} and kwargs {kwargs}"
)
else:
return res
@compatibility(is_backward_compatible=False)
def normalize_function(
target: Callable[..., Any],
args: tuple[Any, ...],
kwargs: dict[str, Any] | None = None,
arg_types: tuple[Any] | None = None,
kwarg_types: dict[str, Any] | None = None,
normalize_to_only_use_kwargs: bool = False,
) -> ArgsKwargsPair | None:
"""
Returns normalized arguments to PyTorch functions. This means that
`args/kwargs` will be matched up to the functional's
signature and return exclusively kwargs in positional order if
`normalize_to_only_use_kwargs` is True.
Also populates default values. Does not support positional-only
parameters or varargs parameters (*args, **kwargs). Does not support modules.
May require `arg_types` and `kwarg_types` in order to disambiguate overloads.
Args:
target (Callable): Function that we are normalizing
args (Tuple[Any]): Tuple of args to the function
kwargs (Optional[Dict[str, Any]]): Dict of kwargs to the function
arg_types (Optional[Tuple[Any]]): Tuple of arg types for the args
kwarg_types (Optional[Dict[str, Any]]): Dict of arg types for the kwargs
normalize_to_only_use_kwargs (bool): Whether to normalize to only use kwargs.
Returns:
Returns normalized_args_and_kwargs, or `None` if not successful.
"""
if kwargs is None:
kwargs = {}
new_args_and_kwargs = None
if (
not isinstance(target, types.BuiltinFunctionType)
and not (isinstance(target, (OpOverloadPacket, OpOverload)))
and hasattr(target, "_op")
):
# ExecuTorch's EdgeOpOverload are a wrapper around PyTorch's OpOverload,
# so we can unwrap it here to get its schema
# Can't import EdgeOpOverload directly because of a circular dependency,
# so checking for "_op" existing is the next best thing.
target = target._op
# Repeat the condition after checking for the inner _op field.
if not isinstance(target, types.BuiltinFunctionType) and not (
isinstance(target, (OpOverloadPacket, OpOverload))
):
target_for_analysis = target
if target in boolean_dispatched:
# HACK: `boolean_dispatch` as used in `torch.nn.functional` makes it so that we have
# a 2-way dispatch based on a boolean value. Here we check that the `true` and `false`
# branches of the dispatch have exactly the same signature. If they do, use the `true`
# branch signature for analysis. Otherwise, leave this un-normalized
if isinstance(target, str):
raise AssertionError("target should not be a string here")
dispatched = boolean_dispatched[target]
if_true, if_false = dispatched["if_true"], dispatched["if_false"]
if (
inspect.signature(if_true).parameters
!= inspect.signature(if_false).parameters
):
return None
target_for_analysis = if_true
if not callable(target_for_analysis):
raise AssertionError(
f"target_for_analysis must be callable, got {type(target_for_analysis)}"
)
sig = inspect.signature(inspect.unwrap(target_for_analysis))
new_args_and_kwargs = _args_kwargs_to_normalized_args_kwargs(
sig, args, kwargs, normalize_to_only_use_kwargs
)
else:
if not callable(target):
raise AssertionError(f"target must be callable, got {type(target)}")
torch_op_schemas = get_signature_for_torch_op(target)
matched_schemas: list[inspect.Signature] = []
if torch_op_schemas:
# Iterate through all of the schema until we find one that matches
# If one matches, populate `new_args_and_kwargs` with the new args/kwargs
# values. If none matches, `new_args_and_kwargs` will be None
for candidate_signature in torch_op_schemas:
try:
_fast_bind(candidate_signature, *args, **kwargs)
matched_schemas.append(candidate_signature)
except TypeError:
continue
if len(matched_schemas) == 0:
# Did not match any schema. Cannot normalize
pass
elif len(matched_schemas) == 1:
# Matched exactly one schema, unambiguous
new_args_and_kwargs = _args_kwargs_to_normalized_args_kwargs(
matched_schemas[0], args, kwargs, normalize_to_only_use_kwargs
)
else:
if arg_types is not None or kwarg_types is not None:
arg_types = arg_types if arg_types else cast(tuple[Any], ())
kwarg_types = kwarg_types if kwarg_types else {}
for candidate_signature in torch_op_schemas:
sig_matches = True
try:
bound_types = _fast_bind(
candidate_signature, *arg_types, **kwarg_types
)
for arg_name, arg_type in bound_types.arguments.items():
param = candidate_signature.parameters[arg_name]
sig_matches = sig_matches and type_matches(
param.annotation, arg_type
)
except TypeError:
sig_matches = False
if sig_matches:
new_args_and_kwargs = (
_args_kwargs_to_normalized_args_kwargs(
candidate_signature,
args,
kwargs,
normalize_to_only_use_kwargs,
)
)
break
else:
# Matched more than one schema. In this situation, the caller must provide the types of
# the arguments of the overload they expect.
schema_printouts = "\n".join(
str(schema) for schema in matched_schemas
)
raise RuntimeError(
f"Tried to normalize arguments to {torch.typename(target)} but "
f"the schema match was ambiguous! Please provide argument types to "
f"the normalize_arguments() call. Available schemas:\n{schema_printouts}"
)
return new_args_and_kwargs
@compatibility(is_backward_compatible=False)
def normalize_module(
root: torch.nn.Module,
target: str,
args: tuple[Any],
kwargs: dict[str, Any] | None = None,
normalize_to_only_use_kwargs: bool = False,
) -> ArgsKwargsPair | None:
"""
Returns normalized arguments to PyTorch modules. This means that
`args/kwargs` will be matched up to the functional's
signature and return exclusively kwargs in positional order if
`normalize_to_only_use_kwargs` is True.
Also populates default values. Does not support positional-only
parameters or varargs parameters (*args, **kwargs).
Args:
root (nn.Module): root module upon which we query modules
target (Callable): Function that we are normalizing
args (Tuple[Any]): Tuple of args to the function
kwargs (Optional[Dict[str, Any]]): Dict of kwargs to the function
normalize_to_only_use_kwargs (bool): Whether to normalize to only use kwargs.
Returns:
Returns normalized_args_and_kwargs, or `None` if not successful.
"""
try:
submod = root.get_submodule(target)
except AttributeError as e:
raise RuntimeError(
f"Tried to normalize node with target {target} but root did not "
f"have that target!"
) from e
if hasattr(submod.__class__, "__name__"):
classname = submod.__class__.__name__
if getattr(torch.nn, classname, None) == submod.__class__:
sig = inspect.signature(inspect.unwrap(submod.forward))
if kwargs is None:
kwargs = {}
new_args_and_kwargs = _args_kwargs_to_normalized_args_kwargs(
sig, args, kwargs, normalize_to_only_use_kwargs
)
return new_args_and_kwargs
return None
def _args_kwargs_to_normalized_args_kwargs(
sig: inspect.Signature,
args: tuple[Any, ...],
kwargs: dict[str, Any],
normalize_to_only_use_kwargs: bool,
) -> ArgsKwargsPair | None:
"""
Given a call target, args, and kwargs, return the arguments normalized into
an ArgsKwargsPair, or None if the type signature is not supported by
this normalization.
Args:
sig (inspect.Signature): Signature object for the target
args (Tuple): Arguments that appear at the callsite for `target`
kwargs (Dict): Keyword arguments that appear at the callsite for `target`
normalize_to_only_use_kwargs (bool): Whether to normalize to only use kwargs.
Returns:
Optional[ArgsKwargsPair]: Normalized args and kwargs for `target`, or `None` if
this target is not supported.
"""
# Don't currently support positional-only
# or varargs (*args, **kwargs) signatures
supported_parameter_types = {
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
}
if any(p.kind not in supported_parameter_types for p in sig.parameters.values()):
# Add an exception for one signature, which is common for random/uniform, i.e.:
# Tensor(a!) self, float from=0, float to=1, *, Generator? generator=None
# `from` is Python keyword and as such functions with that signature should have
# positional-only args, but at the same time they could be dispatched as kwargs
if list(sig.parameters.keys()) != ["input", "from", "to", "generator"]:
return None
bound_args = _fast_bind(sig, *args, **kwargs)
bound_args.apply_defaults()
new_kwargs: dict[str, Any] = {}
new_args: list[Any] = []
for i, param in enumerate(sig.parameters):
if not normalize_to_only_use_kwargs and i < len(args):
new_args.append(bound_args.arguments[param])
else:
new_kwargs[param] = bound_args.arguments[param]
return ArgsKwargsPair(tuple(new_args), new_kwargs)
@@ -0,0 +1,15 @@
from . import (
graph_drawer,
graph_manipulation,
net_min_base,
operator_support,
param_fetch,
regional_inductor,
reinplace,
runtime_assert,
shape_prop,
split_module,
split_utils,
splitter_base,
tools_common,
)
@@ -0,0 +1,428 @@
from __future__ import annotations
import logging
import os
from typing import Any, TYPE_CHECKING
from sympy import Integer, Number, Symbol
from sympy.logic.boolalg import BooleanAtom
import torch
import torch.fx as fx
from torch._dynamo.exc import TensorifyScalarRestartAnalysis
from torch._dynamo.symbolic_convert import TensorifyState
from torch._dynamo.utils import get_metrics_context
from torch._prims_common import get_computation_dtype
from torch._subclasses.fake_tensor import FakeTensor
from torch._utils_internal import justknobs_check
from torch.fx._utils import lazy_format_graph_code
from torch.fx.experimental.symbolic_shapes import (
guard_scalar,
has_free_symbols,
ShapeEnv,
)
# TODO: refactor
from torch.fx.passes.runtime_assert import _get_sym_val
from torch.fx.proxy import MetaProxy
from torch.utils._sympy.interp import _run_sympy_handler, sympy_interp
from torch.utils._sympy.reference import TensorReferenceAnalysis
from torch.utils._sympy.symbol import symbol_is_type, SymT
if TYPE_CHECKING:
from torch._subclasses import fake_tensor
from torch.fx.graph_module import GraphModule
__all__: list[str] = []
log = logging.getLogger(__name__)
graph_code_log = torch._logging.getArtifactLogger(__name__, "graph_code_verbose")
# The general shape of this transformation is to look for Tensor operations
# that take a backed SymFloat as an argument, and then redo them as tensor
# compute (with ints and tensors as inputs). For example, add(Tensor, Scalar)
# can be translated into add(Tensor, Tensor). Because Dynamo has already
# arranged for floats to be Tensor inputs to the graph, for typical float
# compute you can entirely translate the Python float operations into Tensor
# operations with only Tensor inputs.
#
# This pass is also responsible for doing CSE on the fly as we do this, since
# you don't want to keep recomputing the same quantity over and over again if
# it's used multiple times.
#
# This pass runs on the JOINT graph produced by AOT Autograd, prior to partitioning.
# The primary goal of this pass is to eliminate floats by replacing TensorScalar
# operations with TensorTensor operations and then Dead Code Elimination (DCE) of
# the item calls, which effectively removes the floats.
#
# This needs to happen before partitioning because it influences partitioning decisions,
# specifically by ensuring that we don't need to save floats across partitions.
# Additionally, there is a separate pass that changes which device computations
# occur on. That pass must be run after this one, but still before partitioning.
#
# HISTORY NOTE: Originally, I wanted to formulate this pass as pushing item()
# calls down, transforming float compute into int compute as we went. If you
# manage to eliminate all float compute, this ends up being equivalent, but
# there is a critical difference when some floats cannot be eliminated: when
# we call item() on them, what should it's SymFloat be? Ideally, it would
# be the same backed SymFloat we had before. But without symbolic expression
# propagation on tensor quantities, repropagating would instead give you an
# unbacked SymFloat. Maybe it is a good idea to implement symbolic propagation
# on 0d scalar tensors, but I decided to go for something simpler to start.
#
# The boring stuff:
#
# * What operators can I Tensor-ify? (Anything with a Scalar argument)
# * How do I Tensor-ify a SymFloat sympy expression (Sympy -> Op Handler -> Tensor)
#
# TODO: make sure this runs before CPU->CUDA pass for cudagraph friendliness
SUPPORTED_OPS = {
torch.ops.aten.mul.Tensor: torch.ops.aten.mul.Tensor,
torch.ops.aten.add.Tensor: torch.ops.aten.add.Tensor,
torch.ops.aten.sub.Tensor: torch.ops.aten.sub.Tensor,
torch.ops.aten.div.Tensor: torch.ops.aten.div.Tensor,
torch.ops.aten.gt.Scalar: torch.ops.aten.gt.Tensor,
torch.ops.aten.lt.Scalar: torch.ops.aten.lt.Tensor,
torch.ops.aten.ge.Scalar: torch.ops.aten.ge.Tensor,
torch.ops.aten.le.Scalar: torch.ops.aten.le.Tensor,
torch.ops.aten.eq.Scalar: torch.ops.aten.eq.Tensor,
torch.ops.aten.ne.Scalar: torch.ops.aten.ne.Tensor,
}
@torch.fx._compatibility.compatibility(is_backward_compatible=False)
def tensorify_python_scalars(
gm: GraphModule, shape_env: ShapeEnv, fake_mode: fake_tensor.FakeTensorMode
) -> None:
"""
Converts Python scalar operations into Tensor operations within the graph. This pass looks for
Tensor operations that involve SymFloat arguments and transforms them into equivalent operations
that use only Tensor inputs.
Args:
gm: The FX graph module representing the computation graph.
shape_env: The shape environment responsible for symbolic shape tracking and propagation
during graph transformations.
Returns:
None
"""
knob = True
if (env := os.getenv("TENSORIFY_PYTHON_SCALARS")) is not None:
if env in ("0", "FALSE"):
knob = False
else:
knob = justknobs_check("pytorch/compiler:tensorify_python_scalars")
if not knob:
return None
# This pass uses MetaProxy which relies on __torch_function__.
# DisableTorchFunctionSubclass may be active here (see #177088),
# so re-enable dispatch for MetaProxy ops.
with torch._C._EnableTorchFunction():
return _tensorify_impl(gm, shape_env, fake_mode)
def _tensorify_impl(
gm: GraphModule,
shape_env: ShapeEnv,
fake_mode: fake_tensor.FakeTensorMode,
) -> None:
"""Helper fn in tensorify_python_scalars so the caller can wrap
with _EnableTorchFunction (#180906).
"""
import sympy
graph = gm.graph
tracer = fx.proxy.GraphAppendingTracer(graph)
expr_to_sym_proxy: dict[sympy.Expr, MetaProxy] = {}
expr_to_tensor_proxy: dict[sympy.Expr, MetaProxy] = {}
tensorified_symbols: set[sympy.Symbol] = set()
should_restart = False
first_non_placeholder = None
placeholders = set()
for node in graph.nodes:
if node.op != "placeholder":
first_non_placeholder = node
break
else:
placeholders.add(node)
Analysis = TensorReferenceAnalysis
def _sympy_interp(expr: sympy.Expr) -> MetaProxy:
# sympy_interp() with hash consing, and special handling for
# generating constants correctly
# hash cons
if isinstance(expr, Symbol) and expr not in expr_to_tensor_proxy:
# This is guaranteed to be populated by invariant established by
# insert_deferred_runtime_asserts
expr_to_tensor_proxy[expr] = torch.ops.aten.scalar_tensor.default(
expr_to_sym_proxy[expr]
)
# cache constants, why not
if isinstance(expr, (Integer, Number, BooleanAtom)):
dtype = None
c: bool | int | float
if isinstance(expr, BooleanAtom):
dtype = torch.bool
c = bool(expr)
elif isinstance(expr, sympy.Integer):
dtype = torch.int64
c = int(expr)
elif isinstance(expr, sympy.Number):
dtype = torch.float64
c = float(expr)
node = graph.call_function(
torch.ops.aten.scalar_tensor.default,
# pyrefly: ignore [unbound-name]
(c,),
{"dtype": dtype},
)
with fake_mode:
# pyrefly: ignore [unbound-name]
node.meta["val"] = torch.ops.aten.scalar_tensor.default(c, dtype=dtype)
expr_to_tensor_proxy[expr] = MetaProxy(
node,
tracer=tracer,
fake_mode=fake_mode,
)
if expr in expr_to_tensor_proxy:
return expr_to_tensor_proxy[expr]
# don't cache
if isinstance(expr, Symbol):
return sympy_interp(Analysis, expr_to_tensor_proxy, expr) # type: ignore[arg-type]
# hash cons on arguments, run expr handler
expr_to_tensor_proxy[expr] = _run_sympy_handler(
Analysis,
[_sympy_interp(arg) for arg in expr.args], # type: ignore[arg-type]
expr,
)
return expr_to_tensor_proxy[expr]
failed_tensorify_ops: set[str] = set()
nodes = list(graph.nodes)
for i, node in enumerate(nodes[:-1]):
with graph.inserting_before(
nodes[i + 1] if node not in placeholders else first_non_placeholder
):
# Look for tensor.item() calls on placeholders
if (
node is not None
and node.op == "call_function"
and node.target is torch.ops.aten._local_scalar_dense.default
):
source_tensor = node.args[0].meta["val"]
dtype = source_tensor.dtype
if not isinstance(node.args[0], fx.Node):
raise AssertionError(f"Expected fx.Node, got {node.args[0]}")
s = node.meta["val"].node.expr
expr_to_sym_proxy[s] = MetaProxy(
node, tracer=tracer, fake_mode=fake_mode
)
# only tensorify if the dtype is floating point
if not dtype.is_floating_point:
continue
expr_to_tensor_proxy[s] = MetaProxy(
node.args[0], tracer=tracer, fake_mode=fake_mode
)
if len(source_tensor.shape) != 0:
# .item() always produces a scalar value, even when it is
# called on a size-1 tensor with rank > 0. Preserve that 0-d
# semantics before tensorifying the scalar expression so
# later tensor math and autograd tangents do not keep an
# accidental length-1 dimension.
expr_to_tensor_proxy[s] = torch.ops.aten.reshape.default(
expr_to_tensor_proxy[s], []
)
# Upcast the float tensor to torch.float64 to avoid precision problem
expr_to_tensor_proxy[s] = torch.ops.prims.convert_element_type.default(
expr_to_tensor_proxy[s], torch.float64
)
# pyrefly: ignore [bad-argument-type]
elif (sym_expr := _get_sym_val(node)) is not None:
if sym_expr not in expr_to_sym_proxy and not isinstance(
sym_expr, (sympy.Number, sympy.logic.boolalg.BooleanAtom)
):
expr_to_sym_proxy[sym_expr] = MetaProxy(
# pyrefly: ignore [bad-argument-type]
node,
tracer=tracer,
fake_mode=fake_mode,
)
# Specialize all dimensions that contain symfloats. Here's
# an example test that requires this:
# PYTORCH_OPINFO_SAMPLE_INPUT_INDEX=4 python test/inductor/test_torchinductor_opinfo.py TestInductorOpInfoCUDA.test_comprehensive_nn_functional_interpolate_bicubic_cuda_float32 # noqa: B950
val = node.meta.get("val")
if isinstance(val, FakeTensor):
for dim in val.shape:
if isinstance(dim, torch.SymInt):
for s in dim.node.expr.free_symbols:
name = str(s)
if symbol_is_type(
s, SymT.FLOAT
) and not TensorifyState.should_specialize(name):
# In principle, we could support float input that
# is used to do size compute. The problem is that
# we don't actually want to tensorify the compute
# in this case, which means we need codegen support for
# all symfloats.
TensorifyState.specialize(name)
should_restart = True
# Look for functions to convert
if node.op == "call_function" and (
replacement_op := SUPPORTED_OPS.get(node.target)
):
args: list[Any] = []
transform = False
compute_dtype = get_computation_dtype(node.meta["val"].dtype)
for a in node.args:
if (
isinstance(a, fx.Node)
and "val" in a.meta
and isinstance(zf := a.meta["val"], torch.SymFloat)
):
transform = True
try:
proxy = _sympy_interp(zf.node.expr)
except NotImplementedError:
transform = False
break
# We use _expr instead of expr b/c we want the symbol not the replacement
tensorified_symbols.add(a.meta["val"].node._expr)
# The upcasting is irrelevant when the compute dtype is bool. This happens
# in cases where we are tensorifying a comparison operator such as
# torch.ops.aten.gt.Tensor
if (
compute_dtype != torch.bool
and proxy.node.meta["val"].dtype != compute_dtype
):
proxy = torch.ops.prims.convert_element_type.default(
proxy, compute_dtype
)
args.append(proxy)
elif isinstance(a, fx.Node):
args.append(MetaProxy(a, tracer=tracer, fake_mode=fake_mode))
else:
args.append(a)
if transform:
replacement_proxy = replacement_op(*args)
if compute_dtype != node.meta["val"].dtype:
replacement_proxy = (
torch.ops.prims.convert_element_type.default(
replacement_proxy,
node.meta["val"].dtype,
)
)
node.replace_all_uses_with(replacement_proxy.node)
graph.erase_node(node)
metrics_context = get_metrics_context()
if metrics_context.in_progress():
metrics_context.set(
"tensorify_float_success", True, overwrite=True
)
else:
for a in node.args:
if (
isinstance(a, fx.Node)
and "val" in a.meta
and isinstance(zf := a.meta["val"], torch.SymFloat)
):
failed_tensorify_ops.update(str(node.target))
log.info("Failed to tensorify %s", node.target)
# Now do one more pass that specializes all symfloats we didn't manage
# to tensorify away.
for node in reversed(graph.nodes):
if node.op == "output" or node.op == "placeholder":
continue
with graph.inserting_before(node):
if len(node.users) == 0 and not node.is_impure():
graph.erase_node(node)
continue
if isinstance(
(val := node.meta.get("val")),
(torch.SymFloat, torch.SymInt, torch.SymBool),
):
if has_free_symbols(val.node.expr) and all(
symbol_is_type(s, SymT.FLOAT) for s in val.node.expr.free_symbols
):
# If all symbols are backed symfloats, we can just specialize the whole node
# and get more precise guards. eg.
#
# zf = a.item()
# zf2 = zf // 2
# op(.. zf2 ..)
#
# It's better to guard on zf // 2 == 2.0 than zf == 5.0
node.replace_all_uses_with(guard_scalar(val))
graph.erase_node(node)
# Sometimes by the time we get to tensorify, there have already been
# specializations, eg. in python_arg_parser.h. In these cases,
# placeholder nodes no longer have a reference to their original
# symfloat and thus we need to deduce specializations have happened
# via shape_env.replacements. NB: there's an important invariant here
# that symfloats keep consistent names across restarts.
for k, v in shape_env.backed_var_to_val.items():
if symbol_is_type(k, SymT.FLOAT) and isinstance(v, sympy.core.numbers.Float):
name = str(k)
if (
not TensorifyState.should_specialize(name)
and k not in tensorified_symbols
):
TensorifyState.specialize(name)
should_restart = True
if should_restart:
# Sledgehammer time. Restart dynamo analysis, keeping track of which input sources
# are no longer needed and should be specialized. Restarting analysis is necessary
# because we need to instruct Dynamo to NOT make these as inputs.
metrics_context = get_metrics_context()
if metrics_context.in_progress():
metrics_context.set(
"tensorify_float_failure", failed_tensorify_ops, overwrite=True
)
metrics_context.set("tensorify_float_success", True, overwrite=True)
raise TensorifyScalarRestartAnalysis
graph_code_log.debug(
"%s", lazy_format_graph_code("tensorify_python_scalars", gm, colored=True)
)
@@ -0,0 +1,73 @@
import operator
import torch
def annotate_getitem_nodes(graph: torch.fx.Graph) -> None:
"""
Annotate the type of getitem nodes, inferred from the type of sequence node.
If sequence node is not annotated with a type, do nothing.
Currently support getitem nodes from tuple, list, and NamedTuple sequence node.
This is helpful since annotations on local names within function are lost during FX transforms.
Adding back known type annotation for getitem nodes to improve jit scriptability.
Args:
graph (Graph): The graph to be annotated
"""
for node in graph.nodes:
if node.target is operator.getitem:
sequence_node, index_node = node.args
if not sequence_node.type:
continue
# container types
if hasattr(sequence_node.type, "_name"):
parameterized_types = sequence_node.type.__args__
if sequence_node.type._name == "Tuple":
if len(parameterized_types) == 2 and isinstance(
parameterized_types[1], type(...)
):
node.type = parameterized_types[0]
else:
if len(parameterized_types) <= index_node:
raise AssertionError(
f"Index {index_node} out of range for parameterized_types "
f"(len={len(parameterized_types)})"
)
node_type = parameterized_types[index_node]
node.type = node_type
elif sequence_node.type._name == "List":
if len(parameterized_types) != 1:
raise AssertionError(
f"Expected 1 parameterized type, got {len(parameterized_types)}"
)
node.type = parameterized_types[0]
# Generic Alias Type
elif hasattr(sequence_node.type, "__origin__"):
parameterized_types = sequence_node.type.__args__
if sequence_node.type.__origin__ is tuple:
if len(parameterized_types) == 2 and isinstance(
parameterized_types[1], type(...)
):
node.type = parameterized_types[0]
else:
if len(parameterized_types) <= index_node:
raise AssertionError(
f"Index {index_node} out of range for parameterized_types "
f"(len={len(parameterized_types)})"
)
node_type = parameterized_types[index_node]
node.type = node_type
elif sequence_node.type.__origin__ is list:
if len(parameterized_types) != 1:
raise AssertionError(
f"Expected 1 parameterized type, got {len(parameterized_types)}"
)
node.type = parameterized_types[0]
# NamedTuple type
elif hasattr(sequence_node.type, "__annotations__"):
if sequence_node.type == torch.Tensor:
continue
sequence_node_field_types = sequence_node.type.__annotations__
field_name = sequence_node.type._fields[index_node]
node.type = sequence_node_field_types[field_name]
@@ -0,0 +1,61 @@
# mypy: allow-untyped-defs
import operator
import torch
from torch.fx.passes.fake_tensor_prop import FakeTensorProp
from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner
from torch.fx.passes.operator_support import OperatorSupport
from torch.fx.passes.tools_common import CALLABLE_NODE_OPS
from torch.utils import _pytree as pytree
class CudaGraphsSupport(OperatorSupport):
# TODO: why is submodules passed here
def is_node_supported(self, submodules, node: torch.fx.Node) -> bool:
if node.op not in CALLABLE_NODE_OPS:
return False
if node.target is torch.ops.aten.embedding_dense_backward.default:
return False
if node.target is operator.getitem:
return True
found_not_cuda = False
def meta_fk(meta):
return meta["val"] if "val" in meta else meta["fake_result"]
def find_not_cuda(t):
nonlocal found_not_cuda
if isinstance(t, torch.Tensor) and t.device.type != "cuda":
found_not_cuda = True
for n in node.all_input_nodes:
pytree.tree_map_(find_not_cuda, meta_fk(n.meta))
pytree.tree_map_(find_not_cuda, meta_fk(node.meta))
# NB: factory function is accounted for because the result would be
# cpu or cuda
return not found_not_cuda
def partition_cudagraphs(gm, inputs):
"""
Partition an FX graph into sub-GraphModules that can be validly run under
CUDA graphs. For a subgraph to be runnable under CUDA, all of the operations
must involve CUDA tensors only/
"""
FakeTensorProp(gm).propagate(*inputs)
supported_ops = CudaGraphsSupport()
# TODO: single node partition may be wrong due to the pessimization
# from copying in and out the data. Check in benchmarks, perhaps
partitioner = CapabilityBasedPartitioner(
gm, supported_ops, allows_single_node_partition=True
)
partitions = partitioner.propose_partitions()
fused_graph = partitioner.fuse_partitions(partitions)
return fused_graph
@@ -0,0 +1,155 @@
# mypy: allow-untyped-defs
from typing import Any
import torch
from torch.fx import Graph, GraphModule, Node
from torch.fx.passes.infra.pass_base import PassBase, PassResult
from torch.utils._pytree import tree_flatten
aten = torch.ops.aten
# stateful ops are banned from CSE
rand_ops = {
aten.dropout,
aten._fused_dropout,
aten._standard_gamma,
aten.bernoulli,
aten.multinomial,
aten.native_dropout,
aten.normal,
aten.poisson,
aten.binomial,
aten.rrelu,
aten.rand_like,
aten.rand,
aten.randint,
aten.randn,
aten.randperm,
} # noqa: E501,B950
inplace_ops = {
aten.add_,
aten.sub_,
aten.mul_,
aten.div_,
aten.pow_,
aten.lerp_,
aten.relu_,
aten.sigmoid_,
aten.tanh_,
} # noqa: E501
@torch.fx._compatibility.compatibility(is_backward_compatible=False)
def get_CSE_banned_ops():
return rand_ops.union(inplace_ops)
@torch.fx._compatibility.compatibility(is_backward_compatible=False)
class CSEPass(PassBase):
def __init__(self, banned_ops=None):
"""
This version of CSE Pass aims to be dialect agnostic, and it's implemented purely based on the connectivity between fx.Node.
For functional dialects, user would only need to specify the random ops in ban list.
Warning: CSE Pass cannot be safely applied on a FX graph in non-functional dialects.
If your dialect contains stateful operators, please customized the banned_ops.
"""
if banned_ops is None:
banned_ops = set()
self.banned_ops = banned_ops
super().__init__()
def call(self, graph_module: GraphModule) -> PassResult:
"""
Return a new copy of torch.fx.GraphModule with CSE applied to the input graph
Example usage:
from torch.fx.experimental.proxy_tensor import make_fx
def f(a):
b = a * a
c = a * a
return b+c
p = CSEPass()
traced_graph = make_fx(f)(torch.tensor(1))
print(traced_graph)
result = p(traced_graph)
print(result.graph_module)
"""
def get_aten_target(node):
if hasattr(node.target, "overloadpacket"):
return node.target.overloadpacket
return node.target
modified = False
new_graph = Graph()
env: dict[
Node, Node
] = {} # map from node in the old graph to node in the new graph
hash_env: dict[
tuple[torch._ops.OpOverload, int], Node
] = {} # map from hash to a node in the new graph
token_map: dict[
tuple[torch._ops.OpOverload, int], dict[str, Any]
] = {} # map from hash to token
for n in graph_module.graph.nodes:
# The placeholder, output, and get_attr nodes are copied to the new graph without change
# do not CSE away random operations
if (
n.op == "placeholder"
or n.op == "output"
or n.op == "get_attr"
or get_aten_target(n) in self.banned_ops
):
new_node = new_graph.node_copy(n, lambda x: env[x])
env[n] = new_node
else: # n.op == 'call_function', should never see n.op == 'call_module' or 'call_method'
# substitute args and kwargs members to their mapping in env if exists
# specs can be used to reconstruct nested list/dictionaries
def substitute(arg_list):
arg_list, spec = tree_flatten(arg_list)
for i in range(len(arg_list)):
v = arg_list[i]
if isinstance(v, Node) and v in env:
arg_list[i] = env[v]
return tuple(arg_list), spec
args, args_spec = substitute(n.args)
kwargs, kwargs_spec = substitute(n.kwargs)
# each token corresponds to a unique node
# nodes with the same token can be substituted
token = {
"target": n.target,
"args": args,
"args_spec": args_spec,
"kwargs": kwargs,
"kwargs_spec": kwargs_spec,
}
# hash substituted args to a number, do not hash specs because specs are not hashable
hash_arg = hash((args, kwargs))
hash_val = (n.target, hash_arg)
# check if a node has a substitute and can be eliminated
hash_val_in_hash_env = hash_val in hash_env
if hash_val_in_hash_env and token_map[hash_val] == token:
modified = True # substitution happens and the graph is modified
env[n] = hash_env[hash_val]
continue
new_node = new_graph.node_copy(n, lambda x: env[x])
env[n] = new_node
if not hash_val_in_hash_env:
hash_env[hash_val] = new_node
token_map[hash_val] = token
csed_gm = GraphModule(graph_module, new_graph)
return PassResult(csed_gm, modified)
@@ -0,0 +1,113 @@
# mypy: allow-untyped-defs
import torch.fx
from torch._subclasses.fake_tensor import FakeTensor, FakeTensorMode
from torch.fx import Node
from torch.fx._compatibility import compatibility
from torch.fx.experimental.proxy_tensor import py_sym_types, snapshot_fake
from torch.fx.node import map_aggregate
from torch.utils._ordered_set import OrderedSet
__all__ = ["FakeTensorProp"]
@compatibility(is_backward_compatible=False)
class FakeTensorProp(torch.fx.Interpreter):
"""
Execute an FX graph Node-by-Node and record a fake tensor representing
the metadata for the node. Unlike ShapeProp, (1) this propagation
is cheap--it does the propagation with meta tensors which do not actually
store data, and (2) the fake tensors have much more fine grained information,
e.g., they have accurate alias information that can be consulted by looking
at the storages.
Args:
module (GraphModule): The module to be executed
mode (Optional[FakeTensorMode]): The dispatch mode used to execute computation indicated by each FX Node.
"""
def __init__(
self, module: torch.fx.GraphModule, mode: FakeTensorMode | None = None
):
super().__init__(module)
if mode is None:
mode = FakeTensorMode()
self._mode = mode
mode.epoch += 1
mode.reset_nt_tensor_id_counter()
self.seen_subgraphs: OrderedSet[str] = OrderedSet()
def run_node(self, n: Node):
from torch.fx.experimental.symbolic_shapes import (
compute_unbacked_bindings,
rebind_unbacked,
)
if (
n.op == "call_function"
and n.target is torch.ops.higher_order.invoke_subgraph
and n.args[1] not in self.seen_subgraphs
):
# Prevent redundant fake tensor prop for invoke_subgraphs. Note that
# there is also fake tensor caching for the entire subgraph. This
# happens the next time we call `run_node` for the same subgraph,
# which goes through super.run_node and caches the fake tensor prop.
# Therefore, we are propagating fake tensor through the subgraphs
# twice.
if not isinstance(n.args[1], str):
raise AssertionError(f"Expected str, got {type(n.args[1])}")
if not (
isinstance(n.args[0], torch.fx.Node)
and n.args[0].op == "get_attr"
and isinstance(n.args[0].target, str)
):
raise AssertionError(
"Expected n.args[0] to be a get_attr Node with str target"
)
self.seen_subgraphs.add(n.args[1])
operands = n.args[2:]
example_inputs = []
for operand in operands:
if not (isinstance(operand, torch.fx.Node) and "val" in operand.meta):
raise AssertionError("Expected Node with 'val' in meta")
example_inputs.append(operand.meta["val"])
return FakeTensorProp(
getattr(self.module, n.args[0].target), mode=self._mode
).propagate(*example_inputs)
result = super().run_node(n)
rebind_unbacked(self._mode.shape_env, n, result)
def extract_val(obj):
if isinstance(obj, FakeTensor):
return snapshot_fake(obj)
elif isinstance(obj, torch.Tensor):
# TODO: How is it possible that we get a non fake tensor? We
# should be running under the mode...
return snapshot_fake(self._mode.from_tensor(obj, static_shapes=True))
elif isinstance(obj, py_sym_types):
return obj
else:
return None
meta = map_aggregate(result, extract_val)
if meta is not None:
n.meta["val"] = meta
if (shape_env := self._mode.shape_env) and (
symbol_to_path := compute_unbacked_bindings(shape_env, result)
):
n.meta["unbacked_bindings"] = symbol_to_path
return result
def propagate(self, *args):
fake_args = [
self._mode.from_tensor(a) if isinstance(a, torch.Tensor) else a
for a in args
]
return self.propagate_dont_convert_inputs(*fake_args)
def propagate_dont_convert_inputs(self, *args):
with self._mode:
return super().run(*args)
@@ -0,0 +1,507 @@
# mypy: allow-untyped-defs
import hashlib
from itertools import chain
from types import ModuleType
from typing import Any, TYPE_CHECKING
import torch
import torch.fx
from torch.fx._compatibility import compatibility
from torch.fx.graph import _parse_stack_trace
from torch.fx.node import _format_arg, _get_qualified_name
from torch.fx.operator_schemas import normalize_function
from torch.fx.passes.shape_prop import TensorMetadata
if TYPE_CHECKING:
import pydot
HAS_PYDOT = True
else:
pydot: ModuleType | None
try:
import pydot
HAS_PYDOT = True
except ModuleNotFoundError:
HAS_PYDOT = False
pydot = None
__all__ = ["FxGraphDrawer"]
_COLOR_MAP = {
"placeholder": '"AliceBlue"',
"call_module": "LemonChiffon1",
"get_param": "Yellow2",
"get_attr": "LightGrey",
"output": "PowderBlue",
}
_HASH_COLOR_MAP = [
"CadetBlue1",
"Coral",
"DarkOliveGreen1",
"DarkSeaGreen1",
"GhostWhite",
"Khaki1",
"LavenderBlush1",
"LightSkyBlue",
"MistyRose1",
"MistyRose2",
"PaleTurquoise2",
"PeachPuff1",
"Salmon",
"Thistle1",
"Thistle3",
"Wheat1",
]
_WEIGHT_TEMPLATE = {
"fillcolor": "Salmon",
"style": '"filled,rounded"',
"fontcolor": "#000000",
}
if HAS_PYDOT:
@compatibility(is_backward_compatible=False)
class FxGraphDrawer:
"""
Visualize a torch.fx.Graph with graphviz
Basic usage:
g = FxGraphDrawer(symbolic_traced, "resnet18")
g.get_dot_graph().write_svg("a.svg")
"""
def __init__(
self,
graph_module: torch.fx.GraphModule,
name: str,
ignore_getattr: bool = False,
ignore_parameters_and_buffers: bool = False,
skip_node_names_in_args: bool = True,
parse_stack_trace: bool = False,
dot_graph_shape: str | None = None,
normalize_args: bool = False,
):
self._name = name
self.dot_graph_shape = (
dot_graph_shape if dot_graph_shape is not None else "record"
)
self.normalize_args = normalize_args
_WEIGHT_TEMPLATE["shape"] = self.dot_graph_shape
self._dot_graphs = {
name: self._to_dot(
graph_module,
name,
ignore_getattr,
ignore_parameters_and_buffers,
skip_node_names_in_args,
parse_stack_trace,
)
}
for node in graph_module.graph.nodes:
if node.op != "call_module":
continue
leaf_node = self._get_leaf_node(graph_module, node)
if not isinstance(leaf_node, torch.fx.GraphModule):
continue
self._dot_graphs[f"{name}_{node.target}"] = self._to_dot(
leaf_node,
f"{name}_{node.target}",
ignore_getattr,
ignore_parameters_and_buffers,
skip_node_names_in_args,
parse_stack_trace,
)
def get_dot_graph(self, submod_name=None) -> pydot.Dot:
"""
Visualize a torch.fx.Graph with graphviz
Example:
>>> # xdoctest: +REQUIRES(module:pydot)
>>> # xdoctest: +REQUIRES(module:ubelt)
>>> # define module
>>> class MyModule(torch.nn.Module):
>>> def __init__(self) -> None:
>>> super().__init__()
>>> self.linear = torch.nn.Linear(4, 5)
>>> def forward(self, x):
>>> return self.linear(x).clamp(min=0.0, max=1.0)
>>> module = MyModule()
>>> # trace the module
>>> symbolic_traced = torch.fx.symbolic_trace(module)
>>> # setup output file
>>> import ubelt as ub
>>> dpath = ub.Path.appdir("torch/tests/FxGraphDrawer").ensuredir()
>>> fpath = dpath / "linear.svg"
>>> # draw the graph
>>> g = FxGraphDrawer(symbolic_traced, "linear")
>>> g.get_dot_graph().write_svg(fpath)
"""
if submod_name is None:
return self.get_main_dot_graph()
else:
return self.get_submod_dot_graph(submod_name)
def get_main_dot_graph(self) -> pydot.Dot:
return self._dot_graphs[self._name]
def get_submod_dot_graph(self, submod_name) -> pydot.Dot:
return self._dot_graphs[f"{self._name}_{submod_name}"]
def get_all_dot_graphs(self) -> dict[str, pydot.Dot]:
return self._dot_graphs
def _get_node_style(self, node: torch.fx.Node) -> dict[str, str]:
template = {
"shape": self.dot_graph_shape,
"fillcolor": "#CAFFE3",
"style": '"filled,rounded"',
"fontcolor": "#000000",
}
if node.op in _COLOR_MAP:
template["fillcolor"] = _COLOR_MAP[node.op]
else:
# Use a random color for each node; based on its name so it's stable.
target_name = node._pretty_print_target(node.target)
target_hash = int(
hashlib.md5(
target_name.encode(), usedforsecurity=False
).hexdigest()[:8],
16,
)
template["fillcolor"] = _HASH_COLOR_MAP[
target_hash % len(_HASH_COLOR_MAP)
]
return template
def _get_leaf_node(
self, module: torch.nn.Module, node: torch.fx.Node
) -> torch.nn.Module:
py_obj = module
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
atoms = node.target.split(".")
for atom in atoms:
if not hasattr(py_obj, atom):
raise RuntimeError(
str(py_obj) + " does not have attribute " + atom + "!"
)
py_obj = getattr(py_obj, atom)
return py_obj
def _typename(self, target: Any) -> str:
if isinstance(target, torch.nn.Module):
ret = torch.typename(target)
elif isinstance(target, str):
ret = target
else:
ret = _get_qualified_name(target)
# Escape "{" and "}" to prevent dot files like:
# https://gist.github.com/SungMinCho/1a017aab662c75d805c5954d62c5aabc
# which triggers `Error: bad label format (...)` from dot
return ret.replace("{", r"\{").replace("}", r"\}")
# shorten path to avoid drawing long boxes
# for full path = '/home/weif/pytorch/test.py'
# return short path = 'pytorch/test.py'
def _shorten_file_name(
self,
full_file_name: str,
truncate_to_last_n: int = 2,
):
splits = full_file_name.split("/")
if len(splits) >= truncate_to_last_n:
return "/".join(splits[-truncate_to_last_n:])
return full_file_name
def _get_node_label(
self,
module: torch.fx.GraphModule,
node: torch.fx.Node,
skip_node_names_in_args: bool,
parse_stack_trace: bool,
) -> str:
def _get_str_for_args_kwargs(arg):
if isinstance(arg, tuple):
prefix, suffix = r"|args=(\l", r",\n)\l"
arg_strs_list = [_format_arg(a, max_list_len=8) for a in arg]
elif isinstance(arg, dict):
prefix, suffix = r"|kwargs={\l", r",\n}\l"
arg_strs_list = [
f"{k}: {_format_arg(v, max_list_len=8)}" for k, v in arg.items()
]
else: # Fall back to nothing in unexpected case.
return ""
# Strip out node names if requested.
if skip_node_names_in_args:
arg_strs_list = [a for a in arg_strs_list if "%" not in a]
if len(arg_strs_list) == 0:
return ""
arg_strs = prefix + r",\n".join(arg_strs_list) + suffix
if len(arg_strs_list) == 1:
arg_strs = arg_strs.replace(r"\l", "").replace(r"\n", "")
return arg_strs.replace("{", r"\{").replace("}", r"\}")
label = "{" + f"name=%{node.name}|op_code={node.op}\n"
if node.op == "call_module":
leaf_module = self._get_leaf_node(module, node)
label += r"\n" + self._typename(leaf_module) + r"\n|"
extra = ""
if hasattr(leaf_module, "__constants__"):
extra = r"\n".join(
[
f"{c}: {getattr(leaf_module, c)}"
for c in leaf_module.__constants__ # type: ignore[union-attr]
] # type: ignore[union-attr]
)
label += extra + r"\n"
else:
label += f"|target={self._typename(node.target)}" + r"\n"
if self.normalize_args:
try:
args, kwargs = normalize_function( # type: ignore[misc]
node.target, # type: ignore[arg-type]
node.args, # type: ignore[arg-type]
node.kwargs,
normalize_to_only_use_kwargs=True,
)
except Exception:
# Fallback to not normalizing if there's an exception.
# Some functions need overloads specified to normalize.
args, kwargs = node.args, node.kwargs
else:
args, kwargs = node.args, node.kwargs
if len(args) > 0:
label += _get_str_for_args_kwargs(args)
if len(kwargs) > 0:
label += _get_str_for_args_kwargs(kwargs)
label += f"|num_users={len(node.users)}" + r"\n"
tensor_meta = node.meta.get("tensor_meta")
label += self._tensor_meta_to_label(tensor_meta)
# for original fx graph
# print buf=buf0, n_origin=6
buf_meta = node.meta.get("buf_meta", None)
if buf_meta is not None:
label += f"|buf={buf_meta.name}" + r"\n"
label += f"|n_origin={buf_meta.n_origin}" + r"\n"
# for original fx graph
# print file:lineno code
if parse_stack_trace and node.stack_trace is not None:
parsed_stack_trace = _parse_stack_trace(node.stack_trace)
fname = self._shorten_file_name(parsed_stack_trace.file)
label += (
f"|file={fname}:{parsed_stack_trace.lineno} {parsed_stack_trace.code}"
+ r"\n"
)
return label + "}"
def _tensor_meta_to_label(self, tm) -> str:
if tm is None:
return ""
elif isinstance(tm, TensorMetadata):
return self._stringify_tensor_meta(tm)
elif isinstance(tm, list):
result = ""
for item in tm:
result += self._tensor_meta_to_label(item)
return result
elif isinstance(tm, dict):
result = ""
for v in tm.values():
result += self._tensor_meta_to_label(v)
return result
elif isinstance(tm, tuple):
result = ""
for item in tm:
result += self._tensor_meta_to_label(item)
return result
else:
raise RuntimeError(f"Unsupported tensor meta type {type(tm)}")
def _stringify_tensor_meta(self, tm: TensorMetadata) -> str:
result = ""
if not hasattr(tm, "dtype"):
print("tm", tm)
result += "|" + "dtype" + "=" + str(tm.dtype) + r"\n"
result += "|" + "shape" + "=" + str(tuple(tm.shape)) + r"\n"
result += "|" + "requires_grad" + "=" + str(tm.requires_grad) + r"\n"
result += "|" + "stride" + "=" + str(tm.stride) + r"\n"
if tm.is_quantized:
if tm.qparams is None:
raise AssertionError("qparams is None for quantized tensor")
if "qscheme" not in tm.qparams:
raise AssertionError("qscheme not in qparams")
qscheme = tm.qparams["qscheme"]
if qscheme in {
torch.per_tensor_affine,
torch.per_tensor_symmetric,
}:
result += "|" + "q_scale" + "=" + str(tm.qparams["scale"]) + r"\n"
result += (
"|"
+ "q_zero_point"
+ "="
+ str(tm.qparams["zero_point"])
+ r"\n"
)
elif qscheme in {
torch.per_channel_affine,
torch.per_channel_symmetric,
torch.per_channel_affine_float_qparams,
}:
result += (
"|"
+ "q_per_channel_scale"
+ "="
+ str(tm.qparams["scale"])
+ r"\n"
)
result += (
"|"
+ "q_per_channel_zero_point"
+ "="
+ str(tm.qparams["zero_point"])
+ r"\n"
)
result += (
"|"
+ "q_per_channel_axis"
+ "="
+ str(tm.qparams["axis"])
+ r"\n"
)
else:
raise RuntimeError(f"Unsupported qscheme: {qscheme}")
result += "|" + "qscheme" + "=" + str(tm.qparams["qscheme"]) + r"\n"
return result
def _get_tensor_label(self, t: torch.Tensor) -> str:
return str(t.dtype) + str(list(t.shape)) + r"\n"
# when parse_stack_trace=True
# print file:lineno code
def _to_dot(
self,
graph_module: torch.fx.GraphModule,
name: str,
ignore_getattr: bool,
ignore_parameters_and_buffers: bool,
skip_node_names_in_args: bool,
parse_stack_trace: bool,
) -> pydot.Dot:
"""
Actual interface to visualize a fx.Graph. Note that it takes in the GraphModule instead of the Graph.
If ignore_parameters_and_buffers is True, the parameters and buffers
created with the module will not be added as nodes and edges.
"""
# "TB" means top-to-bottom rank direction in layout
dot_graph = pydot.Dot(name, rankdir="TB")
buf_name_to_subgraph = {}
for node in graph_module.graph.nodes:
if ignore_getattr and node.op == "get_attr":
continue
style = self._get_node_style(node)
dot_node = pydot.Node(
node.name,
label=self._get_node_label(
graph_module, node, skip_node_names_in_args, parse_stack_trace
),
**style, # type: ignore[arg-type]
)
current_graph = dot_graph
buf_meta = node.meta.get("buf_meta", None)
if buf_meta is not None and buf_meta.n_origin > 1:
buf_name = buf_meta.name
if buf_name not in buf_name_to_subgraph:
buf_name_to_subgraph[buf_name] = pydot.Cluster(
buf_name, label=buf_name
)
current_graph = buf_name_to_subgraph.get(buf_name) # type: ignore[assignment]
# pyrefly: ignore [missing-attribute]
current_graph.add_node(dot_node)
def get_module_params_or_buffers():
for pname, ptensor in chain(
leaf_module.named_parameters(),
# pyrefly: ignore [bad-argument-type]
leaf_module.named_buffers(),
):
pname1 = node.name + "." + pname
label1 = (
pname1 + "|op_code=get_" + "parameter"
if isinstance(ptensor, torch.nn.Parameter)
else "buffer" + r"\l"
)
dot_w_node = pydot.Node(
pname1,
label="{" + label1 + self._get_tensor_label(ptensor) + "}",
**_WEIGHT_TEMPLATE, # type: ignore[arg-type]
)
dot_graph.add_node(dot_w_node)
dot_graph.add_edge(pydot.Edge(pname1, node.name))
if node.op == "call_module":
leaf_module = self._get_leaf_node(graph_module, node)
if not ignore_parameters_and_buffers and not isinstance(
leaf_module, torch.fx.GraphModule
):
get_module_params_or_buffers()
for subgraph in buf_name_to_subgraph.values():
subgraph.set("color", "royalblue")
subgraph.set("penwidth", "2")
dot_graph.add_subgraph(subgraph) # type: ignore[arg-type]
for node in graph_module.graph.nodes:
if ignore_getattr and node.op == "get_attr":
continue
for user in node.users:
dot_graph.add_edge(pydot.Edge(node.name, user.name))
return dot_graph
else:
if not TYPE_CHECKING:
@compatibility(is_backward_compatible=False)
class FxGraphDrawer:
def __init__(
self,
graph_module: torch.fx.GraphModule,
name: str,
ignore_getattr: bool = False,
ignore_parameters_and_buffers: bool = False,
skip_node_names_in_args: bool = True,
parse_stack_trace: bool = False,
dot_graph_shape: str | None = None,
normalize_args: bool = False,
):
raise RuntimeError(
"FXGraphDrawer requires the pydot package to be installed. Please install "
"pydot through your favorite Python package manager."
)
@@ -0,0 +1,117 @@
# mypy: allow-untyped-defs
from typing import Any, NamedTuple
import torch
from torch.fx._compatibility import compatibility
from torch.fx.graph import Graph
from torch.fx.graph_module import GraphModule
from torch.fx.node import map_arg, Node, Target
from torch.fx.passes.shape_prop import ShapeProp
__all__ = [
"replace_target_nodes_with",
"size_bytes",
"get_size_of_all_nodes",
"get_tensor_meta",
"get_size_of_node",
]
@compatibility(is_backward_compatible=False)
def replace_target_nodes_with(
fx_module: GraphModule,
old_op: str,
old_target: Target,
new_op: str,
new_target: Target,
):
"""
Modifies all nodes in fx_module.graph.nodes which match the specified op code
and target, and updates them to match the new op code and target.
"""
new_graph = Graph()
val_map: dict[Node, Node] = {}
for node in fx_module.graph.nodes:
if node.op == old_op and node.target == old_target:
args = map_arg(node.args, lambda n: val_map[n])
kwargs = map_arg(node.kwargs, lambda n: val_map[n])
if not isinstance(args, tuple):
raise AssertionError(f"Expected tuple, got {type(args)}")
if not isinstance(kwargs, dict):
raise AssertionError(f"Expected dict, got {type(kwargs)}")
val_map[node] = new_graph.create_node(
new_op, new_target, args, kwargs, node.name
)
else:
val_map[node] = new_graph.node_copy(node, lambda n: val_map[n])
fx_module.graph = new_graph
@compatibility(is_backward_compatible=False)
class size_bytes(NamedTuple):
output_size: int
total_size: int
@compatibility(is_backward_compatible=False)
def get_size_of_all_nodes(
fx_module: GraphModule, args: list[torch.Tensor] | None = None
) -> None:
"""Given a fx graph module, update each node with its total size (weights + bias + output)
and its output_size(output). For a non-module node, the total size is the output size.
return total size"""
if args is not None:
# Mark shape and dtype for each node (node.shape and node.dtype)
ShapeProp(fx_module).propagate(*args)
# Calculate the total size of the whole fx graph
for node in fx_module.graph.nodes:
if node.op == "output":
break
node.size_bytes = get_size_of_node(fx_module, node)
return
@compatibility(is_backward_compatible=False)
def get_tensor_meta(node: Node) -> Any:
tensor_meta = node.meta.get("tensor_meta")
if not tensor_meta:
raise RuntimeError(
f"Node {node} has no tensor metadata associated with it! "
f"Check that shape propagation has run."
)
return tensor_meta
@compatibility(is_backward_compatible=False)
def get_size_of_node(fx_module: GraphModule, node: Node) -> size_bytes:
"""Given a node with node.dtype and node.shape, return its total size and its output size.
total_size = weights + bias + output_size
"""
# Total num of elements
total_num_of_elems = 0
# For a module, consider all parameters
if node.op == "call_module":
submodule_dict = dict(fx_module.named_modules())
submodule = submodule_dict[node.target]
parameters = submodule.named_parameters()
# Parameters are named tuples
for _name, p in parameters:
total_num_of_elems += p.numel()
# Don't forget the output size
# node.shape is the shape of this node's output
tensor_meta = get_tensor_meta(node)
output_elem = tensor_meta.shape.numel()
total_num_of_elems += output_elem
# Assume for now if it's quantized then it's qint8 or quint8
if tensor_meta.is_quantized:
size_per_elem_bytes = torch._empty_affine_quantized(
[], dtype=tensor_meta.dtype
).element_size()
else:
size_per_elem_bytes = torch.tensor([], dtype=tensor_meta.dtype).element_size()
total_size = size_per_elem_bytes * total_num_of_elems
output_size = size_per_elem_bytes * output_elem
return size_bytes(output_size, total_size)
@@ -0,0 +1,248 @@
# mypy: allow-untyped-defs
import os
from collections.abc import Callable
from typing import TypeVar
from torch.fx import Graph, Node
from torch.fx._compatibility import compatibility
from torch.fx.graph_module import GraphModule
from torch.fx.traceback import NodeSource, NodeSourceAction
T = TypeVar("T")
from .graph_drawer import FxGraphDrawer
__all__ = ["GraphTransformObserver"]
@compatibility(is_backward_compatible=False)
class GraphTransformObserver:
__pass_count = 0
def __init__(
self,
gm: GraphModule,
passname: str,
subsystem: str | None = None,
log_url: str | None = None,
):
"""
log_url is inferred to be torch._inductor.config.trace.log_url_for_graph_xform unless otherwise specified
"""
from torch._inductor import config as inductor_config
self.gm = gm
self.passname = passname
self.subsystem = subsystem
if log_url is None:
log_url = inductor_config.trace.log_url_for_graph_xform
self.log_url = log_url
self.active = (
self.log_url is not None
or inductor_config.trace.provenance_tracking_level == 1
)
if self.active:
self.erased_nodes: set[str] = set()
self.created_nodes: set[str] = set()
self.name_to_node: dict[str, Node] = {}
# record graph modules deepcopied from self.gm, so we can remove hooks on them when exiting the context
self.copied_gms: list[GraphModule] = []
self._node_creation_hook = self.get_node_creation_hook()
self._node_erase_hook = self.get_node_erase_hook()
self._node_replace_hook = self.get_node_replace_hook()
self._deepcopy_hook = self.get_deepcopy_hook()
# If log_url is None, we don't log anything
if self.log_url is None:
return
GraphTransformObserver.__pass_count += 1
self.input_dot_graph = FxGraphDrawer(
self.gm,
self.passname,
ignore_getattr=True,
ignore_parameters_and_buffers=True,
).get_dot_graph()
@classmethod
def get_current_pass_count(cls):
return cls.__pass_count
def apply_gm_pass(self, pass_fn: Callable[[GraphModule], T]) -> T | None:
from torch._dynamo.utils import dynamo_timed
with self:
if self._check_disable_pass():
return None
with dynamo_timed(
f"pass.{self.subsystem}.{self.passname}"
if self.subsystem
else f"pass.{self.passname}"
):
return pass_fn(self.gm)
def apply_graph_pass(self, pass_fn: Callable[[Graph], T]) -> T | None:
from torch._dynamo.utils import dynamo_timed
with self:
if self._check_disable_pass():
return None
with dynamo_timed(
f"pass.{self.subsystem}.{self.passname}"
if self.subsystem
else f"pass.{self.passname}"
):
return pass_fn(self.gm.graph)
def _check_disable_pass(self):
from torch._inductor import config as inductor_config
if self.passname.upper() in inductor_config.disabled_passes.upper():
return True
if self.subsystem is None:
return False
debug_info = lambda: self.passname # noqa: E731
from torch._inductor.compiler_bisector import CompilerBisector
return CompilerBisector.disable_subsystem(
"inductor", self.subsystem, debug_info
)
def __enter__(self):
if not self.active:
return self
self.gm._register_create_node_hook(self._node_creation_hook)
self.gm._register_erase_node_hook(self._node_erase_hook)
self.gm._register_replace_node_hook(self._node_replace_hook)
self.gm._register_deepcopy_hook(self._deepcopy_hook)
self.erased_nodes.clear()
self.created_nodes.clear()
self.name_to_node.clear()
self.copied_gms.clear()
for node in self.gm.graph.nodes:
self.name_to_node[node.name] = node
return self
def __exit__(self, type, value, tb):
if not self.active:
return
for gm in self.copied_gms + [self.gm]:
gm._unregister_create_node_hook(self._node_creation_hook)
gm._unregister_erase_node_hook(self._node_erase_hook)
gm._unregister_replace_node_hook(self._node_replace_hook)
gm._unregister_deepcopy_hook(self._deepcopy_hook)
if self.log_url is None:
return
if len(self.created_nodes) > 0 or len(self.erased_nodes) > 0:
for e in self.input_dot_graph.get_node_list():
if e.get_name() in self.erased_nodes:
e.obj_dict["attributes"]["fillcolor"] = "yellow"
else:
e.obj_dict["attributes"]["fillcolor"] = "grey"
if self.log_url is None:
raise AssertionError("log_url is not set")
self.input_dot_graph.write(
os.path.join(
self.log_url,
f"pass_{GraphTransformObserver.__pass_count}_{self.passname}_input_graph.dot",
)
)
output_dot_graph = FxGraphDrawer(
self.gm,
self.passname,
ignore_getattr=True,
ignore_parameters_and_buffers=True,
).get_dot_graph()
for e in output_dot_graph.get_node_list():
if e.get_name() in self.created_nodes:
e.obj_dict["attributes"]["fillcolor"] = "yellow"
else:
e.obj_dict["attributes"]["fillcolor"] = "grey"
output_dot_graph.write(
os.path.join(
self.log_url,
f"pass_{GraphTransformObserver.__pass_count}_{self.passname}_output_graph.dot",
)
)
def get_node_creation_hook(self):
# We have to return a function instead of using a class method directly
# to avoid max recursion issue when deepcopy a graph module within the context manager.
def on_node_creation(node):
self.created_nodes.add(node.name)
self.name_to_node[node.name] = node
source = NodeSource(None, self.passname, NodeSourceAction.CREATE)
if "from_node" not in node.meta:
node.meta["from_node"] = [source]
else:
node.meta["from_node"].append(source)
return on_node_creation
def get_node_erase_hook(self):
def on_node_erase(node):
self.erased_nodes.add(node.name)
self.name_to_node.pop(node.name, None)
return on_node_erase
def get_node_replace_hook(self):
def on_node_replace(old: Node, new: str, user: Node):
# Update node meta when replacing old node with new node
new_node = self.name_to_node.get(new, None)
if not new_node:
return
if not isinstance(new_node, Node):
raise AssertionError(f"Expected Node, got {type(new_node)}")
# replace hook is called once for each user of old
# this avoids adding duplicated source nodes
added_nodes = {s.name for s in new_node.meta.get("from_node", [])}
if old.name in added_nodes:
return
action = [NodeSourceAction.REPLACE]
if new_node.name in self.created_nodes:
action.append(NodeSourceAction.CREATE)
def created_this_pass(source):
return source.pass_name == self.passname and source.action == [
NodeSourceAction.CREATE
]
# remove redundant source added on node creation
new_from_node = new_node.meta.get("from_node", [])
new_from_node = [
source for source in new_from_node if not created_this_pass(source)
]
# add new source
new_node_source = NodeSource(old, self.passname, action)
new_from_node.append(new_node_source)
new_node.meta["from_node"] = new_from_node
return on_node_replace
def get_deepcopy_hook(self):
def on_deepcopy(gm):
self.copied_gms.append(gm)
return on_deepcopy
@@ -0,0 +1 @@
from . import pass_manager
@@ -0,0 +1,412 @@
# mypy: allow-untyped-defs
import collections
import itertools
import logging
import operator
from collections.abc import Iterable, Sequence
from torch.fx.graph_module import GraphModule
from torch.fx.node import _get_qualified_name, Node
from torch.fx.passes.operator_support import OperatorSupportBase
from torch.fx.passes.utils.fuser_utils import fuse_by_partitions
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
class Partition:
def __init__(
self,
id: int | None = None,
nodes: Iterable[Node] | None = None,
node_orders: Iterable[int] | None = None,
):
self.id = id
self.nodes: dict[Node, int | None] = {}
if nodes is not None:
if node_orders is None:
self.nodes = dict.fromkeys(nodes, None)
else:
nodes_list = list(nodes)
node_orders_list = list(node_orders)
if len(nodes_list) != len(node_orders_list):
raise AssertionError(
"nodes and node_orders must have the same length"
)
self.nodes = dict(zip(nodes_list, node_orders_list))
def __repr__(self) -> str:
return str(self.nodes)
def add_node(self, node: Node, node_order: int | None = None):
self.nodes.update({node: node_order})
def remove_node(self, node: Node):
del self.nodes[node]
def size(self):
return len(self.nodes)
class _DependencyViewer:
def __init__(self, graph_module: GraphModule):
self.downstreams = collections.defaultdict(set)
for node in reversed(graph_module.graph.nodes):
for output_node in node.users:
# add output_node and output_node's downstream dependency
self.downstreams[node].add(output_node)
self.downstreams[node].update(self.downstreams[output_node])
def downstreams_of(self, node: Node) -> set[Node]:
return self.downstreams[node]
class CapabilityBasedPartitioner:
def __init__(
self,
graph_module: GraphModule,
operator_support: OperatorSupportBase,
allows_single_node_partition: bool = False,
non_compute_ops: Sequence[str] | None = None,
allowed_single_node_partition_ops: Sequence[str] | None = None,
) -> None:
self.graph_module = graph_module
self.operator_support = operator_support
self.allows_single_node_partition = allows_single_node_partition
self.non_compute_ops = non_compute_ops if non_compute_ops is not None else []
self.allowed_single_node_partition_ops = (
allowed_single_node_partition_ops
if allowed_single_node_partition_ops is not None
else []
)
self.dependency_viewer = _DependencyViewer(graph_module)
def _is_node_supported(self, node: Node) -> bool:
return self.operator_support.is_node_supported(
dict(self.graph_module.named_modules()), node
)
def propose_partitions(self) -> list[Partition]:
# partition_map is a mapping from partition id to a set of partition id's.
# The value set contains all the partition ids that can be reached by doing a
# DFS starting from the partition id in the key.
partition_map: dict[int, set] = collections.defaultdict(set)
# assumptions: nodes in candidate list is sorted in topological order
assignment: dict[Node, int] = {} # mapping from node to partition_id
partitions_by_id: dict[
int, Partition
] = {} # mapping from partition_id to partition
nodes_order: dict[
Node, int
] = {} # mapping from nodes to reversed topological order
partitions_order: dict[
int, int
] = {} # mapping from partition_id to minimum topo order of nodes in partition
partition_users: dict[
int, set
] = {} # mapping from partition_id to partition users
new_partition_id = itertools.count()
# try to merge partition other_id into partition self_id
# merge only happens if the end graph doesn't contain cyclic dependency
# returns `True` when merge happens, `False` otherwise.
def maybe_merge_partition(self_id: int, other_id: int):
# merged_nodes is the union of nodes in two partition to-be-merged
self_nodes = partitions_by_id[self_id].nodes
other_nodes = partitions_by_id[other_id].nodes
def dfs_iter_find_cycle(all_user_nodes: set[Node]):
for user_node in all_user_nodes:
visited_partition_ids = set()
for path_node in self.dependency_viewer.downstreams_of(user_node):
# If any of the nodes in the dfs path of this node are in the merged_nodes
# list then there is a cycle in the graph.
if path_node in self_nodes or path_node in other_nodes:
return True
# If any of the nodes in the dfs path of this node are in the assignment
# map then we have to make sure that the partitions that these nodes belong
# to do not form a cycle with the current partitions being merged. This means
# iterating through all the nodes in all the parititons that are traversed in
# the dfs path and checking if they are in the merged_nodes list.
if path_node in assignment:
partition_id = assignment[path_node]
# If the partition id has already been visited then we know that it doesn't
# form a cycle with the current partitions being merged.
if partition_id in visited_partition_ids:
continue
p_map = partition_map[partition_id]
if self_id in p_map or other_id in p_map:
return True
visited_partition_ids.add(partition_id)
return False
# find new partition users if merge.
all_user_nodes = partition_users[self_id] | partition_users[other_id]
all_user_nodes.difference_update(other_nodes, self_nodes)
# check if merge would create cyclic dependency.
if dfs_iter_find_cycle(all_user_nodes):
# return false indicating cyclic dependency found and
# merge is aborted
return self_id, False
# merge the smaller partition into the larger.
merge_id, removed_id = self_id, other_id
if len(self_nodes) < len(other_nodes):
merge_id, removed_id = removed_id, merge_id
# no cyclic dependency found, move forward with the merge
# updating partition nodes
partitions_by_id[merge_id].nodes.update(partitions_by_id[removed_id].nodes)
# updating assignment map
for node in partitions_by_id[removed_id].nodes:
assignment[node] = merge_id
# delete other partition
del partitions_by_id[removed_id]
partitions_order[merge_id] = min(
partitions_order[merge_id], partitions_order[removed_id]
)
del partitions_order[removed_id]
partition_map[merge_id] = partition_map[merge_id].union(
partition_map[removed_id]
)
del partition_map[removed_id]
partition_users[merge_id] = all_user_nodes
del partition_users[removed_id]
return merge_id, True
def merge_single_node(node: Node, node_order: int | None, id: int | None):
def _update_partition_map(node: Node, id: int):
# Iterate through all the users of this node and update the partition map to indicate
# that there is a path from the partition id of this node to the target partition id.
for user_node in node.users:
target_id = assignment.get(user_node)
if target_id is not None:
partition_map[id].add(target_id)
partition_map[id].update(partition_map[target_id])
if node in assignment:
partitions_by_id[assignment[node]].remove_node(node)
if id is None:
assignment.pop(node)
elif id not in partitions_by_id:
assignment[node] = id
if node_order is None:
raise AssertionError("node_order is required for new partitions")
partitions_by_id[id] = Partition(
id=id, nodes=[node], node_orders=[node_order]
)
partition_users[id] = set(node.users)
_update_partition_map(node, id)
else:
assignment[node] = id
partitions_by_id[id].add_node(node, node_order)
logger.debug("Proposing partitions...")
for node_order, node in enumerate(reversed(self.graph_module.graph.nodes)):
# use Dict as an ordered set to ensure deterministic partitioning result, don't care value
merge_candidates: dict[int, None] = {}
# Note a limited horizontal fusion is enabled:
# when `node` is not supported, the code below attempts to fuse consumer of `node`.
#
# I don't see a need to add a knob to disable horizontal fusion yet, we can short-cut
# the fusion by adding an `else` block here to skip horizontal fusion.
if self._is_node_supported(node) and node not in assignment:
partition_id = next(new_partition_id)
nodes_order[node] = partition_id
partitions_order[partition_id] = partition_id
merge_single_node(node, node_order, partition_id)
merge_candidates[partition_id] = None
# merge all possible partitions
for partition_id, _ in sorted(
partitions_order.items(), key=operator.itemgetter(1)
):
merge_candidates[partition_id] = None
merge_candidates_list = list(merge_candidates.keys())
if len(merge_candidates_list) > 1:
self_id = merge_candidates_list[0]
for other_id in merge_candidates_list[1:]:
# note: merge partitions if it doesn't create cyclic dependency
# in the graph, otherwise, this is a no-op
self_id, _ = maybe_merge_partition(self_id, other_id)
# sort partition nodes based on descending node order
for partition in partitions_by_id.values():
partition.nodes = dict(
sorted(
partition.nodes.items(), key=operator.itemgetter(1), reverse=True
)
)
# post processing to re-assign "getitem" nodes into upstream partition
# Run iteratively until no more changes, to handle nested getitem chains
# (e.g., getitem_619 = getitem_618[0] where getitem_618 = with_effects_167[1])
logger.debug("Reassigning getitem nodes to its producer node's partition...")
while True:
nodes_reassignment: dict[Node, int] = {}
for node in self.graph_module.graph.nodes:
is_tuple_output = True
for user in node.users:
if (
user.op != "call_function"
or _get_qualified_name(user.target) != "_operator.getitem"
): # type: ignore[arg-type]
is_tuple_output = False
break
# node has tuple outputs, re-assign all following getitem node into node's partition
if is_tuple_output:
id = assignment.get(node) # type: ignore[arg-type]
for user in node.users:
if assignment.get(user) != id: # type: ignore[arg-type]
nodes_reassignment[user] = id # type: ignore[assignment]
# no more re-assignments
if not nodes_reassignment:
break
for node, id in nodes_reassignment.items():
merge_single_node(node, None, id)
# filter out single node partitions
if not self.allows_single_node_partition:
logger.debug("Filtering out single node partitions...")
default_non_compute_ops = {"torch.ops.aten.view", "_operator.getitem"}
non_compute_ops = default_non_compute_ops.union(set(self.non_compute_ops))
partitions_to_remove: list[int] = []
for id, partition in partitions_by_id.items():
compute_node_count = 0
for node in partition.nodes:
if node.op == "call_function":
if not callable(node.target):
raise AssertionError(
f"Expected callable target, got {type(node.target)}"
)
if _get_qualified_name(node.target) not in non_compute_ops:
compute_node_count += 1
if (
_get_qualified_name(node.target)
in self.allowed_single_node_partition_ops
):
compute_node_count += 1
if compute_node_count <= 1:
partitions_to_remove.append(id)
for id in partitions_to_remove:
del partitions_by_id[id]
logger.debug("Partitions proposed:")
for id, partition in partitions_by_id.items():
logger.debug(
"partition #%s: %s", id, [node.name for node in partition.nodes]
)
return [
partition for partition in partitions_by_id.values() if partition.size() > 0
]
def fuse_partitions(
self, partitions: list[Partition], prefix: str = "fused_"
) -> GraphModule:
logger.debug("Fusing partitions...")
# fuse_by_partitions expects partitions in List[Dict[Node, None]]: [ {node0 : None}, {node1 : None} ]
return fuse_by_partitions(
self.graph_module,
[partition.nodes for partition in partitions],
prefix=prefix,
)
# remove non-compute-ops that sits at the boundary of a partition.
def remove_bookend_non_compute_ops(self, partitions: list[Partition]):
non_compute_ops = set(self.non_compute_ops)
def is_non_compute_node(node: Node):
return (
node.op == "call_function"
and _get_qualified_name(node.target) in non_compute_ops # type: ignore[arg-type]
)
# cache transparent nodes
transparent_input_nodes: dict[Node, bool] = {}
transparent_output_nodes: dict[Node, bool] = {}
def is_transparent_input_node(
node: Node, partition: set[Node], removed_nodes: set[Node]
):
if (
node.op == "placeholder"
or (node not in partition)
or (node in removed_nodes)
):
return True
if node in transparent_input_nodes:
return transparent_input_nodes[node]
if is_non_compute_node(node):
for input_n in node.all_input_nodes:
if not is_transparent_input_node(input_n, partition, removed_nodes):
transparent_input_nodes[node] = False
return False
transparent_input_nodes[node] = True
return True
transparent_input_nodes[node] = False
return False
def is_transparent_output_node(
node: Node, partition: set[Node], removed_nodes: set[Node]
):
if (
node.op == "placeholder"
or (node not in partition)
or (node in removed_nodes)
):
return True
if node in transparent_output_nodes:
return transparent_output_nodes[node]
if is_non_compute_node(node):
for output_n in node.users:
if not is_transparent_output_node(
output_n, partition, removed_nodes
):
transparent_output_nodes[node] = False
return False
transparent_output_nodes[node] = True
return True
transparent_output_nodes[node] = False
return False
for partition in partitions:
# Note it's ok to use `set` here, since we are only query if a node
# has been removed. We are NEVER going to iterate on nodes inside
# the set.
remove_node: set[Node] = set()
for node in partition.nodes:
if is_non_compute_node(node) and (
is_transparent_input_node(node, set(partition.nodes), remove_node)
or is_transparent_output_node(
node, set(partition.nodes), remove_node
)
):
remove_node.add(node)
if len(remove_node) != 0:
for node in remove_node:
partition.nodes.pop(node, None)
def partition_and_fuse(self, prefix: str = "fused_") -> GraphModule:
partitions = self.propose_partitions()
fused_gm = self.fuse_partitions(partitions, prefix=prefix)
return fused_gm
@@ -0,0 +1,78 @@
# mypy: allow-untyped-defs
import abc
from collections import namedtuple
from torch.fx._compatibility import compatibility
from torch.fx.graph_module import GraphModule
__all__ = ["PassResult", "PassBase"]
@compatibility(is_backward_compatible=False)
# pyrefly: ignore [invalid-inheritance]
class PassResult(namedtuple("PassResult", ["graph_module", "modified"])):
"""
Result of a pass:
graph_module: The modified graph module
modified: A flag for if the pass has modified the graph module
"""
__slots__ = ()
def __new__(cls, graph_module, modified):
return super().__new__(cls, graph_module, modified)
@compatibility(is_backward_compatible=False)
class PassBase(abc.ABC):
"""
Base interface for implementing passes.
It is required to implement the `call` function so that we can directly
pass instances of the Pass directly to the PassManager and call them as a
function.
We can directly pass an instance of a class implementing this interface into
the PassManager's `passes` attribute.
"""
def __call__(self, graph_module: GraphModule) -> PassResult | None:
"""
Runs the precondition check, the pass itself, and the postcondition check.
"""
self.requires(graph_module)
res = self.call(graph_module)
self.ensures(graph_module)
return res
@abc.abstractmethod
def call(self, graph_module: GraphModule) -> PassResult | None:
"""
The pass that is run through the given graph module. To implement a
pass, it is required to implement this function.
Args:
graph_module: The graph module we will run a pass on
"""
def requires(self, graph_module: GraphModule) -> None: # noqa: B027
"""
This function will be called before the pass is run and will check that
the given graph module contains the preconditions needed to run the
pass. It is not required to implement this function.
Args:
graph_module: The graph module we will run checks on
"""
def ensures(self, graph_module: GraphModule) -> None: # noqa: B027
"""
This function will be called after the pass is run and will check that
the given graph module contains the postconditions needed to run the
pass. It is not required to implement this function.
Args:
graph_module: The graph module we will run checks on
"""
@@ -0,0 +1,309 @@
# mypy: allow-untyped-defs
import inspect
import logging
from collections.abc import Callable
from functools import wraps
from queue import Queue
import torch.nn as nn
from torch.fx._compatibility import compatibility
from torch.fx.graph_module import GraphModule
from torch.fx.passes.infra.pass_base import PassResult
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
__all__ = ["pass_result_wrapper", "this_before_that_pass_constraint", "PassManager"]
@compatibility(is_backward_compatible=False)
def pass_result_wrapper(fn: Callable) -> Callable:
"""
Wrapper for passes which currently do not return a PassResult.
This wrapper makes them return a PassResult containing the modified object
and True for the "modified" flag.
Args:
fn (Callable[Module, Any])
Returns:
wrapped_fn (Callable[Module, PassResult])
"""
if fn is None:
# pyrefly: ignore [bad-return]
return None
@wraps(fn)
def wrapped_fn(gm):
res = fn(gm)
if res is None:
return PassResult(gm, True)
if isinstance(res, PassResult):
return res
elif isinstance(res, nn.Module):
return PassResult(res, True)
if not inspect.isfunction(fn):
wrapped_fn.__name__ = type(fn).__name__
return wrapped_fn
def _validate_pass_schedule_constraint(
constraint: Callable[[Callable, Callable], bool], passes: list[Callable]
) -> None:
for i, a in enumerate(passes):
for j, b in enumerate(passes[i + 1 :]):
if constraint(a, b):
continue
raise RuntimeError(
f"pass schedule constraint violated. Expected {a} before {b}"
f" but found {a} at index {i} and {b} at index{j} in pass"
f" list."
)
def _topological_sort_passes(
passes: list[Callable], constraints: list[Callable]
) -> list[Callable]:
"""
Args
passes: Passes that we are ordering
constraints: Constraints applied on these passes
Returns
A sorted list of callables and a boolean of if a circular dependency
existed
"""
if len(constraints) == 0:
return passes
# Construct a graph mapping nodes to a list of their users
graph: dict[Callable, list[Callable]] = {p: [] for p in passes}
indegree_map: dict[Callable, int] = dict.fromkeys(passes, 0)
candidates: Queue = Queue()
for a in passes:
for b in passes:
if a == b:
continue
for constraint in constraints:
if not constraint(a, b):
graph[b].append(a)
indegree_map[a] += 1
if indegree_map[a] == 0:
candidates.put(a)
visited: dict[Callable, bool] = dict.fromkeys(passes, False)
sorted_passes: list[Callable] = []
while not candidates.empty():
p = candidates.get()
sorted_passes.append(p)
visited[p] = True
for n in graph[p]:
if not visited[n]:
indegree_map[n] -= 1
if indegree_map[n] == 0:
candidates.put(n)
# Check if there are unvisited nodes (aka cycles in the graph)
cycle_passes = list(filter(lambda p: indegree_map[p] != 0, indegree_map.keys()))
if len(cycle_passes) != 0:
error = (
f"Circular dependency detected within the following passes: {cycle_passes}"
)
raise RuntimeError(error)
return sorted_passes
@compatibility(is_backward_compatible=False)
def this_before_that_pass_constraint(this: Callable, that: Callable) -> Callable:
"""
Defines a partial order ('depends on' function) where ``this`` must occur
before ``that``.
For example, the following pass list and constraint list would be invalid::
passes = [pass_b, pass_a]
constraints = [this_before_that_pass_constraint(pass_a, pass_b)]
Args:
this (Callable): pass which should occur first
that (Callable): pass which should occur later
Returns:
depends_on (Callable[[Object, Object], bool])
"""
def depends_on(a: Callable, b: Callable):
return a != that or b != this
return depends_on
@compatibility(is_backward_compatible=False)
class PassManager:
"""
Construct a PassManager.
Collects passes and constraints. This defines the pass schedule, manages
pass constraints and pass execution.
Args:
passes (Optional[List[Callable]]): List of passes. A pass is a
callable which modifies an object and returns a PassResult
constraint (Optional[List[Callable]]): List of constraints. A
constraint is a callable which takes two passes (A, B) and returns
True if A depends on B and False otherwise. See implementation of
`this_before_that_pass_constraint` for example.
steps (int): Max number of times we run the passes (default = 1).
run_checks_after_each_pass (bool): Whether to run checks and linting
after each pass
suppress_check_failures (bool): Whether to raise errors when running
checks
"""
passes: list[Callable[[nn.Module], PassResult]]
constraints: list[Callable[[Callable, Callable], bool]]
_validated: bool = False
steps: int = 1
def __init__(
self,
passes=None,
constraints=None,
steps=None,
run_checks_after_each_pass: bool = False,
suppress_check_failures: bool = False,
):
self.passes = passes or []
self.constraints = constraints or []
if steps:
self.steps = steps
self.run_checks_after_each_pass = run_checks_after_each_pass
self.suppress_check_failures = suppress_check_failures
def add_pass(self, _pass: Callable):
"""
Adds a pass into the current list of passes.
"""
self.passes.append(_pass)
self._validated = False
def add_constraint(self, constraint: Callable):
"""
Adds a constraint into the current list of constraints.
"""
self.constraints.append(constraint)
self._validated = False
def validate_constraints(self):
"""
Validates that current pass schedule defined by `self.passes` is valid
according to all constraints in `self.constraints`
"""
if self._validated:
return
for constraint in self.constraints:
_validate_pass_schedule_constraint(constraint, self.passes)
self._validated = True
def solve_constraints(self):
"""
Finds a valid traversal order based on the given constraints and orders
the passes based on this order.
If a circular dependency exists between the constraints and steps = 1,
then we will raise an error because if steps != 1 this means that we
will re-run the passes, allowing for circular dependencies.
"""
self.passes = _topological_sort_passes(self.passes, self.constraints)
self._validated = True
def add_checks(self, check: Callable) -> None:
"""
Adds a function which takes runs various checks on a given graph module.
This function is run before and after each pass if the
`run_checks_after_each_pass` flag is enabled.
"""
sig = inspect.signature(check)
if len(list(sig.parameters.values())) != 1:
raise TypeError(
"PassManager check function should only take in one variable, a module"
)
setattr(self, "check", check) # noqa: B010
def check(self, module: nn.Module) -> None:
pass
def __call__(self, module: nn.Module) -> PassResult:
"""
Runs a list of passes in the order based on `self.passes` on the given
graph module. Each time a pass is run, checks and linting will be run on
the graph module if `run_checks_after_each_pass` is set.
If the module is a graph module, we will run the list of passes until
the graph stops changing, or until `steps` number of times.
"""
# Order the passes based on the constraints
if not self._validated:
self.solve_constraints()
# Check graph invariants
self.check(module)
# Run the set of passes `steps` number of times or until the graph stops
# changing
overall_modified = False
for _ in range(self.steps):
modified = False
# Run the set of passes on the graph module
for i, fn in enumerate(self.passes):
fn_name = fn.__name__ if inspect.isfunction(fn) else type(fn).__name__
logger.debug("Running pass '%s'", fn_name)
try:
res = fn(module)
if not isinstance(res, PassResult) and not hasattr(
res, "graph_module"
):
raise TypeError(
f"The result of the pass {fn_name} should be type PassResult."
+ "Please wrap it with pass_result_wrapper()"
)
module = res.graph_module
modified = modified or res.modified
if isinstance(module, GraphModule):
logger.debug("Graph after pass '%s': %s", fn_name, module.graph)
module.recompile()
# Check graph invariants
if self.run_checks_after_each_pass:
self.check(module)
except Exception as e:
prev_pass_names = [
p.__name__ if inspect.isfunction(p) else type(p).__name__
for p in self.passes[:i]
]
msg = f"An error occurred when running the '{fn_name}' pass after the following passes: {prev_pass_names}"
raise Exception(msg) from e # noqa: TRY002
# If the graph no longer changes, then we can stop running these passes
overall_modified = overall_modified or modified
if not modified:
break
return PassResult(module, overall_modified)
@@ -0,0 +1,984 @@
# mypy: allow-untyped-defs
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
import torch
import torch.fx
from torch.fx._compatibility import compatibility
from torch.fx.node import map_arg
from .shape_prop import ShapeProp
from .split_utils import split_by_tags
from .tools_common import (
CALLABLE_NODE_OPS,
FxNetAccFusionsFinder,
Names,
NodeList,
NodeSet,
TensorOrTensors,
Tensors,
)
__all__ = [
"FxNetMinimizerBadModuleError",
"FxNetMinimizerRunFuncError",
"FxNetMinimizerResultMismatchError",
]
_LOGGER = logging.getLogger(__name__)
@compatibility(is_backward_compatible=False)
class FxNetMinimizerBadModuleError(Exception):
"""
Raised if failed to split out a minimize module
"""
@compatibility(is_backward_compatible=False)
class FxNetMinimizerRunFuncError(Exception):
"""
Raised if error occurs during run_a or run_b functions
"""
@compatibility(is_backward_compatible=False)
class FxNetMinimizerResultMismatchError(Exception):
"""
Raised if comparing function thinks the results are mismatching.
"""
@dataclass
class _MinimizerSettingBase:
"""
Args:
`accumulate_error`: Instead of using a's input for both converted module to verify
, use the previous outputs of each converted module as input to accumulate the
errors.
`traverse_method`: "sequential" or "binary" or "accumulate"
Determine the way of traverse the nodes in FX module.
`find_all`: Minimizer will go through the entire model and return all problematic nodes.
`return_intermediate`: If true, when using `run_nodes()` function to run the
model, intermediate results of all the ops will be returned as output.
`all_outputs`: If true, when using `_run_and_compare()` function,
all the output nodes in the subgraph will be used for comparison.
"""
accumulate_error: bool = False
traverse_method: str = "sequential"
find_all: bool = False
return_intermediate: bool = False
all_outputs: bool = False
def __str__(self):
settings_str = "FX Minimizer Settings:\n"
for k, v in vars(self).items():
settings_str += f"\t{k}: {v}\n"
return settings_str
class _MinimizerBase:
"""
This class is used to automatically find problematic nodes in a model. It takes a FX
graphmodule and generate some submodules while traverse the graph. Then two functions
`run_a` and `run_b` will be used to run the same submodule and a function `compare_fn`
will be used to compare the results.
Currently we provides two ways to traverse the graph and generate submodules.
1. Sequential traversal: this will traverse the graph node by node and generate
one submodule with one single node.
2. Binary searching: this will do a binary search style traversal on the graph.
For internal Users, a guide can be found here https://fb.quip.com/HDtuAgiKGfkP.
"""
def __init__(
self,
module: torch.fx.GraphModule,
sample_input: Tensors,
compare_fn: Callable[
[TensorOrTensors, TensorOrTensors, Names], tuple[float, bool]
],
settings: _MinimizerSettingBase,
module_exporter: Callable[[Tensors, torch.fx.GraphModule, str], None]
| None = None,
exclusion_fn: Callable[[NodeList, int, int], None] | None = None,
):
if not isinstance(module, torch.fx.GraphModule):
raise AssertionError(f"Expected GraphModule, got {type(module)}")
self.module = module
self.sample_input = sample_input
self.compare_fn = compare_fn
self.module_exporter = module_exporter
self.settings = settings
self.exclusion_fn = exclusion_fn
# Stores outputs of run_a function
self.a_outputs: dict[str, Any] = {}
# Stores outputs of run_b function
self.b_outputs: dict[str, Any] = {}
# Stores the results of compare_fn
self.results: dict[Any, Any] = {}
# Stores the report for the runs
self.reports: list[list[str]] = []
# Current iteration
self.iteration: int = 0
callable_nodes = {
node for node in self.module.graph.nodes if node.op in CALLABLE_NODE_OPS
}
self.run_shape_prop()
self.fusions = FxNetAccFusionsFinder(self.module, callable_nodes)()
# Check if number of input in sample_input matches the number of placeholders
placeholders = [
node.name for node in self.module.graph.nodes if node.op == "placeholder"
]
if len(placeholders) != len(self.sample_input):
raise AssertionError(
f"Placeholder count ({len(placeholders)}) does not match "
f"sample_input count ({len(self.sample_input)})"
)
# Store sample_input
for i, name in enumerate(placeholders):
self.a_outputs[name] = sample_input[i]
self.b_outputs[name] = sample_input[i]
def run_shape_prop(self) -> None:
"""
Helper function to run shape propagation on module. Can be overridden by
subclasses for custom shape propagation logic.
"""
ShapeProp(self.module).propagate(*self.sample_input)
def run_a(
self, mod: torch.fx.GraphModule, inputs: Tensors, report_idx: int = -1
) -> TensorOrTensors:
"""
Run `mod` with `inputs` and generate output. The output will be compared with
output of run_b().
"""
raise RuntimeError("run_a() is not implemented.")
def run_b(
self, mod: torch.fx.GraphModule, inputs: Tensors, report_idx: int = -1
) -> TensorOrTensors:
"""
Run `mod` with `inputs` and generate output. The output will be compared with
output of run_a().
"""
raise RuntimeError("run_b() is not implemented.")
def _store_outputs(
self,
a_result: TensorOrTensors,
b_result: TensorOrTensors,
submodule: torch.fx.GraphModule,
):
"""
Store the outputs of self.run_a() and self.run_b() into self.a_outputs and
self.b_outputs, so that we can use them when execute preceding nodes that
use those outputs as inputs.
Args:
a_result: Output of self.run_a(). Could be a tensor or tensors.
b_result: Output of self.run_b(). Could be a tensor or tensors.
submodule: The module that generates a_result and b_result.
"""
output_node = next(
node for node in submodule.graph.nodes if node.op == "output"
)
# Only one output
if isinstance(output_node.args[0], torch.fx.Node):
self.a_outputs[output_node.args[0].name] = a_result
self.b_outputs[output_node.args[0].name] = b_result
# Multiple outputs
else:
for i, arg in enumerate(output_node.args[0]):
self.a_outputs[arg.name] = a_result[i]
self.b_outputs[arg.name] = b_result[i]
def _get_submod_inputs(
self, main_module: torch.fx.GraphModule, submod_path: str
) -> tuple[Tensors, Tensors]:
"""
Try get submodule inputs from stored outputs. If not found then use
torch_glow.get_submod_inputs to get the inputs.
If accumulate_error is False, use a_input for run_a() and run_b()
otherwise use a_input for run_a and b_input for run_b.
Args:
main_module: Top-levlel fx module.
submod_path: Path to the submodule we want to run and compare results.
Returns:
a_input: List of tensor(s) that will be used by run_a() as submodule inputs.
b_input: List of tensor(s) that will be used by run_b() as submodule inputs.
"""
a_input = []
b_input = []
submodule = getattr(main_module, submod_path)
placeholders = [
node.name for node in submodule.graph.nodes if node.op == "placeholder"
]
# If all placeholder can be found in stored outputs, use stored
# outputs as inputs. Otherwise, use `torch_glow.get_submod_inputs`
# to get the inputs.
if set(placeholders) <= self.a_outputs.keys():
for name in placeholders:
a_input.append(self.a_outputs[name])
b_input.append(self.b_outputs[name])
else:
if self.settings.accumulate_error:
print(f"Can't find previous stored outputs named {placeholders}!")
def get_inputs(self: torch.nn.Module, inputs: Any):
nonlocal a_input
a_input = inputs
# Use forward hook to get the inputs to the submodule
handle = submodule.register_forward_pre_hook(get_inputs)
main_module(*self.sample_input)
handle.remove()
b_input = a_input
if not self.settings.accumulate_error:
return a_input, a_input
return a_input, b_input
def _tag_nodes(self, selected_nodes: NodeSet):
"""
Tag selected nodes with tag "minimize". Nodes with the same tags will
be split to the same submodule afterwards.
Args:
selected_nodes: Nodes that we want to minimize. We will tag those nodes
with "minimize", all preceding nodes with "main_0" and all following
nodes with "main_1".
"""
for node in self.module.graph.nodes:
if node.op not in CALLABLE_NODE_OPS:
continue
if node in selected_nodes:
node.tag = "minimize"
elif any(
n.tag in {"minimize", "main_1"}
for n in node.all_input_nodes
if n.op in CALLABLE_NODE_OPS
):
node.tag = "main_1"
else:
node.tag = "main_0"
def _build_submodule(self, nodes: NodeSet) -> tuple[torch.fx.GraphModule, str]:
"""
Split self.module so that one submodule consists of `nodes` and only `nodes`.
Args:
nodes: Nodes that we want to include in the minimize submodule.
Returns:
split_module (torch.fx.GraphModule): the module after split.
submodule_name (str): the name of the submodule that consists of `nodes`.
"""
# Color provided nodes
self._tag_nodes(nodes)
# Split module based on coloring
split_module = split_by_tags(self.module, ["main_0", "minimize", "main_1"])
# Find submodule containing colored nodes
submodule_name: str = ""
for child_name, _ in split_module.named_children(): # type: ignore[union-attr]
# Skip submodules we're not interested in at the moment
if "minimize" not in child_name:
continue
if submodule_name == "":
submodule_name = child_name
else:
raise FxNetMinimizerBadModuleError(
f"Expected only one minimize submodule with nodes {nodes}"
)
if submodule_name == "":
raise FxNetMinimizerBadModuleError(
f"Minimize submodule was not found with nodes {nodes}"
)
return split_module, submodule_name # type: ignore[return-value]
def _run_and_compare(
self,
split_module: torch.fx.GraphModule,
submod_name: str,
output_names: Names,
report_idx: int = -1,
):
"""
Run the submodule in `split_module` that has name `submod_name`
using `self.run_a` and `self.run_b` and compare their results.
Args:
split_module: Main module that contains the minimize submodule.
submod_name: Name of the minimize submodule.
output_names: Names of the node we want to output. If None, we
will use the original output.
"""
submodule = getattr(split_module, submod_name)
a_input, b_input = self._get_submod_inputs(split_module, submod_name)
if len(self.reports) == 0:
self.reports.append([])
self.iteration = 1
report = self.reports[report_idx if report_idx >= 0 else self.iteration - 1]
report.append("Run and compare ...")
if output_names and not self.settings.all_outputs:
output_nodes: NodeList = []
for node in submodule.graph.nodes:
if node.op == "output":
submodule.graph.erase_node(node)
if node.name in output_names:
output_nodes.append(node)
submodule.graph.output(
output_nodes[0] if len(output_nodes) == 1 else tuple(output_nodes)
)
submodule.graph.lint()
submodule.recompile()
# Use name of args in output node as key to store comparison result
for node in submodule.graph.nodes:
if node.op == "output":
result_key = map_arg(node.args, lambda x: x.name)
try:
a_result = self.run_a(submodule, a_input, report_idx)
b_result = self.run_b(submodule, b_input, report_idx)
self._store_outputs(a_result, b_result, submodule)
except Exception as e:
report.append(f"Exception raised when running {submod_name}: {e}")
raise FxNetMinimizerRunFuncError( # noqa: B904
f"Exception raised when running {submod_name}: {e}"
)
# Compare results
names: Names = output_names
if output_names is None:
names = [str(v) for v in result_key] # type: ignore[possibly-undefined]
numeric_result, bool_result = self.compare_fn(a_result, b_result, names)
self.results[result_key] = numeric_result # type: ignore[possibly-undefined]
report.append(f"Numerical accuracy = {numeric_result}")
if not bool_result:
report.append(f"Result mismatch for {result_key}") # type: ignore[possibly-undefined]
if self.module_exporter:
if isinstance(result_key, tuple): # type: ignore[possibly-undefined]
# pyrefly: ignore [unbound-name]
result_key = result_key[-1]
# If the result is still a tuple (happens in non-sequential mode),
# we only use the first element as name.
if isinstance(result_key, tuple): # type: ignore[possibly-undefined]
# pyrefly: ignore [unbound-name]
result_key = str(result_key[0])
# pyre-ignore[29]: not a function
self.module_exporter(
a_input,
submodule,
# pyrefly: ignore [unbound-name]
result_key + "_cpu",
)
# pyre-ignore[29]: not a function
self.module_exporter(
b_input,
submodule,
# pyrefly: ignore [unbound-name]
result_key + "_acc",
)
raise FxNetMinimizerResultMismatchError(f"Result mismatch for {result_key}") # type: ignore[possibly-undefined]
def _binary_search_impl(
self, all_nodes: NodeList, start_idx: int, end_idx: int
) -> NodeSet:
"""
Recursive binary search implementation.
"""
culprits: NodeSet = set()
nodes: NodeList = all_nodes[start_idx:end_idx]
report: list[str] = []
if self.exclusion_fn is not None:
self.exclusion_fn(nodes, start_idx, end_idx)
if len(nodes) == 0:
report = ["All nodes are excluded by user"]
self.reports.append(report)
return culprits
first_node_name = nodes[0].name
output_node_name = nodes[-1].name
self.iteration += 1
self.reports.append(report)
report.append(f"Binary search iteration {self.iteration}")
report.append(
f"From node index {start_idx}:{first_node_name} to {end_idx - 1}:{output_node_name}. "
f"Size of the interested node list is {len(nodes)}"
)
cur_nodes: NodeSet = set(nodes)
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(split_module, submod_name, [output_node_name])
except (FxNetMinimizerRunFuncError, FxNetMinimizerResultMismatchError):
if len(nodes) == 1:
report.append(
f"This is the last node in the sub-module. "
f"Search in the current branch is successful with culprit = {cur_nodes}."
)
self.print_report(report)
return cur_nodes
report.append(
"Proceed to split and lower the halves of the current "
"sub-module individually."
)
self.print_report(report)
mid = len(nodes) // 2
culprits = self._binary_search_impl(all_nodes, start_idx, start_idx + mid)
if len(culprits) != 0 and not self.settings.find_all:
return culprits
culprits = self._binary_search_impl(all_nodes, start_idx + mid, end_idx)
if len(culprits) == 0:
report.append(
f"Further split and lowering found no errors. "
f"Unable to minimize the submodule with list of nodes: {nodes}"
)
self.print_report(report)
return culprits
else:
report.append("No discrepancy found.")
self.print_report(report)
return set()
def _binary_traverse(self, nodes: NodeList) -> NodeSet:
"""
Binary search on `nodes` for culprit.
"""
return self._binary_search_impl(nodes, 0, len(nodes))
def _sequential_traverse(self, nodes: NodeList) -> NodeSet:
"""
Traverse `nodes` one by one and determine if any of them is a culprit.
"""
culprits: NodeSet = set()
for node in nodes:
report: list[str] = []
self.reports.append(report)
self.iteration += 1
report.append(f"Sequential traverse iteration {self.iteration}.")
report.append(f"Visit node: {node.name}")
_LOGGER.info("Visit node: %s", node.name)
node_list: NodeList = [node]
if self.exclusion_fn is not None:
self.exclusion_fn(node_list, -1, -1)
if len(node_list) == 0:
report.append(f"User exclusion : {node.name}")
self.print_report(report)
if not self.settings.find_all:
return culprits
else:
continue
cur_nodes: NodeSet = {node}
if node in self.fusions:
cur_nodes = self.fusions[node]
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(split_module, submod_name, [node.name])
self.print_report(report)
except FxNetMinimizerResultMismatchError:
culprits.add(node)
report.append(f"Found culprit from numeric error: {node}")
self.print_report(report)
if not self.settings.find_all:
return culprits
except FxNetMinimizerRunFuncError:
culprits.update(cur_nodes)
report.append(f"Found culprit from run error: {node}")
self.print_report(report)
if not self.settings.find_all:
return culprits
return culprits
def _block_traverse_impl(
self, nodes: NodeList, start_idx: int, end_idx: int, find_last_node: bool
) -> int | None:
"""
Recursive block search implementation.
find_last_node: If True, search for the last node which result in numerics difference
if False: find first node in sorted node list
"""
report: list[str] = []
mid = (start_idx + end_idx) // 2
cur_nodes_list: NodeList = nodes[: mid + 1] if find_last_node else nodes[mid:]
if self.exclusion_fn:
self.exclusion_fn(cur_nodes_list, -1, -1)
cur_nodes = set(cur_nodes_list)
first_node_name = cur_nodes_list[0].name
last_node_name = cur_nodes_list[-1].name
target_node_name = last_node_name if find_last_node else first_node_name
self.iteration += 1
self.reports.append(report)
report.extend(
[
"=" * 30,
f"Block search iteration {self.iteration}",
]
)
report.extend(
[
f"Search for {'last' if find_last_node else 'first'} node in culprits",
f"From node index {start_idx}:{nodes[start_idx].name} to {end_idx}:{nodes[end_idx].name}. ",
f"Subgraph constructed by {first_node_name} to {last_node_name}",
f"Targeting node: {target_node_name}",
f"Size of the interested node list is {end_idx - start_idx + 1}",
]
)
report_idx = len(self.reports) - 1
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(
split_module, submod_name, [last_node_name], report_idx
)
except (FxNetMinimizerResultMismatchError, FxNetMinimizerRunFuncError):
report.append(
f"Culprits found from node {first_node_name} to {last_node_name}."
)
if start_idx == mid == end_idx:
report.extend(
[
"This is the last node in the sub-module. ",
"Search in the current branch is successful with node :",
f"{start_idx}, node name: {nodes[start_idx].name}.",
]
)
self.print_report(report)
return start_idx
report.append(
"Proceed to split and lower the halves of the current "
"sub-module individually."
)
self.print_report(report)
if find_last_node:
return self._block_traverse_impl(nodes, start_idx, mid, find_last_node)
else:
return self._block_traverse_impl(
nodes, mid + 1, end_idx, find_last_node
)
else:
report.append(
f"Culprits not found from node start to {mid}:{nodes[mid].name}."
)
if start_idx == mid == end_idx:
# We did not find anything if the pointers have not moved
if (start_idx == 0 and not find_last_node) or (
start_idx == len(nodes) - 1 and find_last_node
):
report.append(
f"At {'last' if find_last_node else 'first'} node, no culprits found."
)
self.print_report(report)
return None
# Otherwise, we have converged on the border between discrepancy and valid
return start_idx + (1 if find_last_node else -1)
report.append(
"Proceed to split and lower the halves of the current "
"sub-module individually."
)
self.print_report(report)
if find_last_node:
return self._block_traverse_impl(
nodes, mid + 1, end_idx, find_last_node
)
else:
return self._block_traverse_impl(nodes, start_idx, mid, find_last_node)
def _block_traverse(self, nodes: NodeList, find_last_node: bool | None) -> NodeSet:
"""
Traverse topologically sorted node list
Find minimum block (start_idx, end_idx) which contains the culprit
1st pass: search for end_idx by finding the last node in culprit block
where Numerical accuracy (0, end_idx) > threshold
2nd pass: search for start_idx by finding the first node in culprit block
where Numerical accuracy (start_idx, end_idx) < threshold
Form minimum block by (start_idx - 1, end_idx)
"""
culprits: NodeSet = set()
first_node_name = nodes[0].name
last_node_name = nodes[-1].name
last_node_report = [f"Block search from {first_node_name} to {last_node_name}"]
last_node_report.append("*" * 50)
self.reports.append(last_node_report)
start_idx = 0
end_idx = len(nodes) - 1
final_start_idx: int | None = start_idx
final_end_idx: int | None = end_idx
run_both = find_last_node is None
# step 1: find (0, end_idx) of culprit block
if run_both or find_last_node:
last_node_report.append("Start searching for last node in culprit")
self.print_report(last_node_report)
final_end_idx = self._block_traverse_impl(nodes, start_idx, end_idx, True)
if final_end_idx is None:
last_node_report.append("No culprits found")
self.print_report(last_node_report)
return culprits
last_node_report.extend(
[
"Finish Pass 1",
f"Find end_idx = {final_end_idx}:{nodes[final_end_idx].name}",
]
)
self.print_report(last_node_report)
# step 2: reduce culprit block to (start_idx, end_idx)
if run_both or not find_last_node:
first_node_report = ["Start searching for first node in culprit"]
self.print_report(first_node_report)
final_start_idx = self._block_traverse_impl(
nodes[0 : end_idx + 1], start_idx, final_end_idx or end_idx, False
)
if final_start_idx is None:
last_node_report.append("No culprits found")
self.print_report(last_node_report)
return culprits
first_node_report.append("*" * 50)
self.reports.append(first_node_report)
first_node_report.extend(
[
"Finish Pass 2",
f"Find start_idx = {final_start_idx}:{nodes[final_start_idx].name}",
]
)
self.print_report(first_node_report)
# step 3: form module with minimum culprits. These indexes are guaranteed to exist
range_start, range_end = cast(int, final_start_idx), cast(int, final_end_idx)
culprits.update(nodes[range_start : range_end + 1])
result_report = [
f"Finish searching, found minimum block ({nodes[range_start]},{nodes[range_end]})"
]
self.reports.append(result_report)
self.print_report(result_report)
return culprits
def _defined_traverse(self, nodes: NodeList) -> NodeSet:
"""
run user defined `nodes` and determine if it is a culprit.
"""
culprits: NodeSet = set()
if self.exclusion_fn is not None:
self.exclusion_fn(nodes, -1, -1)
if len(nodes) == 0:
report = ["All nodes are excluded by user"]
self.reports.append(report)
return culprits
first_node_name = nodes[0].name
output_node_name = nodes[-1].name
report = [f"Defined graph from {first_node_name} to {output_node_name}"]
cur_nodes: NodeSet = set(nodes)
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(split_module, submod_name, [output_node_name])
self.print_report(report)
except (FxNetMinimizerResultMismatchError, FxNetMinimizerRunFuncError):
report.append(f"Found culprit {cur_nodes}")
self.print_report(report)
return culprits
return culprits
def _accumulate_traverse(self, nodes: NodeList) -> NodeSet:
culprits: NodeSet = set()
nodes_to_run: NodeSet = set()
# find_all is not supported for accumulate traversal because all the
# ops run on NNPI. So we return after the first op that raises error.
if self.settings.find_all:
print("'Find All' mode is not supported in accumulate traversal.")
return culprits
for node in nodes:
report: list[str] = []
self.reports.append(report)
self.iteration += 1
report.append(f"Accumulate traverse iteration {self.iteration}.")
nodes_to_run.add(node)
node_name = node.name
if node_name is not None and isinstance(node_name, tuple):
node_name = node_name[0]
if node_name is None or not isinstance(node_name, str):
raise AssertionError(f"minimize: node_name: {node_name}")
report.append(f"Add node: {node_name}")
try:
split_module, submod_name = self._build_submodule(nodes_to_run)
self._run_and_compare(split_module, submod_name, [node_name])
self.print_report(report)
except (FxNetMinimizerResultMismatchError, FxNetMinimizerRunFuncError):
culprits.add(node)
report.append(f"Found culprit {node}")
self.print_report(report)
return culprits
return culprits
def _skip_traverse_impl(
self, all_nodes: NodeList, start_idx: int, end_idx: int
) -> NodeSet:
"""
Skip certain nodes in graph based on settings
"""
culprits: NodeSet = set()
nodes: NodeList = all_nodes[start_idx:end_idx]
cur_nodes: NodeSet = set(nodes)
if self.exclusion_fn is not None:
self.exclusion_fn(nodes, start_idx, end_idx)
cur_nodes = set(nodes)
else:
for node in nodes:
if node in self.fusions:
cur_nodes.update(self.fusions[node])
report: list[str] = []
self.reports.append(report)
self.iteration += 1
report.append(f" Nodes block {self.iteration}.")
report.append(
f"From node index {start_idx} to {end_idx - 1}. "
f"Size of the interested node list is {len(nodes)}"
)
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(split_module, submod_name, [])
except FxNetMinimizerResultMismatchError:
culprits.update(cur_nodes)
report.append(f"Found culprit from numeric error: {cur_nodes}")
self.print_report(report)
return culprits
except FxNetMinimizerRunFuncError:
culprits.update(cur_nodes)
report.append(f"Found culprit from run error: {cur_nodes}")
self.print_report(report)
return culprits
else:
report.append("No discrepancy found.")
self.print_report(report)
return set()
def _skip_traverse(self, all_nodes: NodeList, skip_nodes: list) -> NodeSet:
"""
Skip certain nodes in graph based on settings
"""
start_idx = 0
num_nodes = len(all_nodes)
idx = 0
culprits = set()
while idx < num_nodes:
node = all_nodes[idx]
if node.name in skip_nodes: # skip the node
if idx > start_idx:
culprits = self._skip_traverse_impl(all_nodes, start_idx, idx)
start_idx = idx + 1
elif idx == num_nodes - 1 and start_idx <= idx: # last node
culprits = self._skip_traverse_impl(all_nodes, start_idx, idx + 1)
idx += 1
return culprits
def _collect_nodes(self, start: str | None, end: str | None) -> NodeList:
"""
Collect nodes in the model that between nodes with name of `start` and `end`.
These two nodes are also included.
"""
nodes: NodeList = []
add_node = start is None
for node in self.module.graph.nodes:
if node.op not in CALLABLE_NODE_OPS:
continue
if node.name == start:
add_node = True
if add_node:
nodes.append(node)
if node.name == end:
break
return nodes
def run_nodes(self, start: str | None = None, end: str | None = None):
"""
Run part of the model from `start` node to `end` node. If `start` is None
then we start from the beginning of the model. If `end` is None then we
stop at the end of the model.
Args:
start: The name of the node which is the first node of the submodule
we want to run. If set to None, then we'll start with the first
node of the model.
end: The name of the node which is the last node of the submodule we
want to run. If set to None, we'll end with the last node of the
model.
"""
nodes = self._collect_nodes(start, end)
cur_nodes = set(nodes)
for node in nodes:
if node in self.fusions:
cur_nodes.update(self.fusions[node])
output_names = []
if self.settings.return_intermediate:
output_names = [node.name for node in nodes]
try:
split_module, submod_name = self._build_submodule(cur_nodes)
self._run_and_compare(split_module, submod_name, output_names)
except (
FxNetMinimizerRunFuncError,
FxNetMinimizerResultMismatchError,
) as e:
print(e)
def print_report(self, report: list[str]):
for i in range(len(report)):
if i > 0:
print(" . " + report[i])
else:
print(report[i])
def print_reports(self):
for report in self.reports:
self.print_report(report)
def minimize(
self,
start: str | None = None,
end: str | None = None,
skip_nodes: list | None = None,
find_last_node: bool | None = None,
) -> NodeSet:
"""
Minimizing the model from node with name `start` to node with name `end` base
on self.settings. Find culprits that causes FxNetMinimizerRunFuncError or
FxNetMinimizerResultMismatchError errors.
Args:
start: The name of the node where we want to start minimizing. If set
to None, then we'll start with the first node of the model.
end: The name of the node where we want to terminate minimizing. If
set to None, we'll end with the last node of the model.
skip_nodes: The names of nodes where we want to skip during minimizing.
It'll create subgraphs without these skip nodes under the hood.
Only applicable in mode "skip".
find_last_node: True if only last_node of a culprits is needed in mode "block".
False if only the first_node of a culprits is needed.
Only applicable in mode "block".
Returns:
nodes: A list of nodes that causes FxNetMinimizerRunFuncError or
FxNetMinimizerResultMismatchError errors during minimizing.
"""
print(self.settings)
print(self.module.graph)
nodes = self._collect_nodes(start, end)
if self.settings.traverse_method == "sequential":
return self._sequential_traverse(nodes)
if self.settings.traverse_method == "binary":
return self._binary_traverse(nodes)
if self.settings.traverse_method == "accumulate":
return self._accumulate_traverse(nodes)
if self.settings.traverse_method == "skip":
if skip_nodes is None:
raise RuntimeError(
"'skip_nodes' can't be None when 'traverse_method' is 'skip'."
)
return self._skip_traverse(nodes, skip_nodes)
if self.settings.traverse_method == "defined":
return self._defined_traverse(nodes)
if self.settings.traverse_method == "block":
return self._block_traverse(nodes, find_last_node)
raise RuntimeError(f"Unknown traverse method {self.settings.traverse_method}!")
@@ -0,0 +1,231 @@
# mypy: allow-untyped-defs
import abc
import typing as t
import torch
import torch.fx
from torch.fx._compatibility import compatibility
from .shape_prop import TensorMetadata
from .tools_common import CALLABLE_NODE_OPS, get_node_target
__all__ = [
"OperatorSupportBase",
"OperatorSupport",
"create_op_support",
"chain",
"OpSupports",
"any_chain",
]
# fx.Node.target typename, as returned by `get_node_target()`
TargetTypeName = str
# Arguments' dtypes for a given node, see `OperatorSupport`
SupportedArgumentDTypes = (
tuple[
t.Sequence[t.Sequence[torch.dtype]],
dict[str, t.Sequence[torch.dtype]],
]
| None
)
SupportDict = t.Mapping[TargetTypeName, SupportedArgumentDTypes]
@compatibility(is_backward_compatible=False)
class OperatorSupportBase(abc.ABC):
"""Interface for determining if a fx.Node is supported by a backend"""
@abc.abstractmethod
def is_node_supported(
self, submodules: t.Mapping[str, torch.nn.Module], node: torch.fx.Node
) -> bool:
raise NotImplementedError
@compatibility(is_backward_compatible=False)
class OperatorSupport(OperatorSupportBase):
"""
`_support_dict` maps node.target typename to supported inputs dtypes.
node.target typename is retrieved using helper function `get_node_target()`
If supported inputs dtypes is None, it means any dtype is supported, else
we should see a tuple like (([dtypes], ...), {"name":[dtypes], ...}).
The first tuple ([dtypes], ...) indicates what dtypes are supported for
inputs in node.args and the second dict {"name": [dtypes], ...} indicates
what dtypes are supported for inputs in node.kwargs.
For inputs in args, if we don't want to check it, we can put None there,
e.g. (None, [torch.float]) indicates that we don't care about the type of
the first input in args. And for inputs in kwargs, if not listed, will not
be checked.
"""
_support_dict: SupportDict
def __init__(self, support_dict: SupportDict | None = None):
self._support_dict = support_dict or {}
def is_node_supported(
self, submodules: t.Mapping[str, torch.nn.Module], node: torch.fx.Node
) -> bool:
"""
Args:
`submodules`: mapping from module name to the module. This can be
retrieved by calling model.named_modules().
`node`: a Fx node that we want to determine whether it's supported.
Returns:
`is_supported`: whether the arg `node` is supported.
"""
if node.op not in CALLABLE_NODE_OPS:
return True
target = get_node_target(submodules, node)
# Target not found in _support_dict meaning that we don't support this op at all
if target not in self._support_dict:
return False
# The rule for target is None meaning that we accept any dtype
if self._support_dict[target] is None:
return True
args_dtypes, kwargs_dtypes = self._support_dict[target] # type: ignore[misc]
# Check args dtypes
for i, dtypes in enumerate(args_dtypes):
if len(node.args) <= i:
break
# None indicates we don't care about the dtype of args[i]
if dtypes is None:
continue
# If arg is not a node then we don't check it
if not isinstance(node.args[i], torch.fx.Node):
continue
arg_dtype = _get_arg_dtype(node.args[i]) # type: ignore[arg-type]
if arg_dtype not in dtypes:
return False
# Check kwargs dtypes
for k, dtypes in kwargs_dtypes.items():
if k not in node.kwargs:
continue
# If arg is not a node then we don't check it
if not isinstance(node.kwargs[k], torch.fx.Node):
continue
kwarg_dtype = _get_arg_dtype(node.kwargs[k]) # type: ignore[arg-type]
if kwarg_dtype not in dtypes:
return False
return True
# ======================================================================
# Functional interfaces and utils for defining basic operator support logic
# and composing them into more complex ones
# ======================================================================
IsNodeSupported = t.Callable[[t.Mapping[str, torch.nn.Module], torch.fx.Node], bool]
@compatibility(is_backward_compatible=False)
def create_op_support(is_node_supported: IsNodeSupported) -> OperatorSupportBase:
"""Wraps a `IsNodeSupported` function into an `OperatorSupportBase` instance
`IsNodeSupported` has the same call signature as
`OperatorSupportBase.is_node_supported`
"""
class FunctionalOperatorSupport(OperatorSupportBase):
def is_node_supported(
self, submodules: t.Mapping[str, torch.nn.Module], node: torch.fx.Node
) -> bool:
return is_node_supported(submodules, node)
return FunctionalOperatorSupport()
@compatibility(is_backward_compatible=False)
def chain(*op_support: OperatorSupportBase) -> OperatorSupportBase:
"""Combines a sequence of `OperatorSupportBase` instances to form a single `OperatorSupportBase`
instance by evaluating each input `OperatorSupportBase` instance, and returns False if
any of it reports False.
"""
def _chain(submods, node) -> bool:
return all(x.is_node_supported(submods, node) for x in op_support)
return create_op_support(_chain)
@compatibility(is_backward_compatible=False)
def any_chain(*op_support: OperatorSupportBase) -> OperatorSupportBase:
"""Combines a sequence of `OperatorSupportBase` instances to form a single `OperatorSupportBase`
instance by evaluating each input `OperatorSupportBase` instance, and returns True if
any of it reports True.
"""
def _any_chain(submods, node) -> bool:
return any(x.is_node_supported(submods, node) for x in op_support)
return create_op_support(_any_chain)
@compatibility(is_backward_compatible=False)
class OpSupports:
"""A set of atomic `OperatorSupportBase` instances that can be combined together
to form more complex operator support logic.
"""
@classmethod
def decline_if_input_dtype(cls, dtype: torch.dtype) -> OperatorSupportBase:
"""Report a node as non-supported, if any of its arguments is of dtype"""
def _decline_if_input_dtype(
submodules: t.Mapping[str, torch.nn.Module],
node: torch.fx.Node,
) -> bool:
for arg in node.all_input_nodes:
arg_dtype = _get_arg_dtype(arg)
if arg_dtype == dtype:
return False
return True
return create_op_support(_decline_if_input_dtype)
@classmethod
def decline_if_node_in_names(cls, disallow_set: set[str]) -> OperatorSupportBase:
"""
If a node has a name that is in the disallow set, reported it as non-supported.
"""
def _decline_if_node_in_names(
submodules: t.Mapping[str, torch.nn.Module],
node: torch.fx.Node,
) -> bool:
return node.name not in disallow_set
return create_op_support(_decline_if_node_in_names)
def _get_arg_dtype(arg: torch.fx.Node) -> t.Any:
if not isinstance(arg, torch.fx.Node):
raise AssertionError(f"Expected torch.fx.Node, got {type(arg)}")
tensor_meta = arg.meta.get("tensor_meta") # type: ignore[union-attr]
dtype = (
tensor_meta.dtype
if isinstance(tensor_meta, TensorMetadata)
else arg.meta["type"]
)
return dtype
@@ -0,0 +1,97 @@
from collections.abc import Callable
from typing import Any
import torch
import torch.nn as nn
from torch.fx._compatibility import compatibility
from torch.fx.graph_module import GraphModule
__all__ = [
"default_matching",
"extract_attrs_for_lowering",
"lift_lowering_attrs_to_nodes",
]
# Matching method matches the attribute name of current version to the attribute name of `target_version`
@compatibility(is_backward_compatible=False)
def default_matching(name: str, target_version: int) -> str:
"""Default matching method"""
return name
# This dict maps the nn.Module class name to the attribute name list that we want to fetch for lowering.
# The first integer in the tuple is the version number of the nn.Module class when we create the parameter list.
# If there's a version mismatch then it means the parameter names in the book might be mismatched with nn.Module.
module_fetch_book: dict[type, tuple[int, list[str], Callable[[str, int], str]]] = {
torch.nn.modules.linear.Linear: (1, ["weight", "bias"], default_matching),
torch.nn.modules.conv.Conv2d: (
1,
[
"weight",
"bias",
"kernel_size",
"stride",
"padding",
"dilation",
"groups",
"padding_mode",
],
default_matching,
),
torch.nn.modules.batchnorm.BatchNorm2d: (
2,
["weight", "bias", "running_mean", "running_var", "eps"],
default_matching,
),
torch.nn.modules.pooling.AdaptiveAvgPool2d: (1, [], default_matching),
torch.nn.modules.pooling.MaxPool2d: (
1,
["kernel_size", "stride", "padding", "dilation", "return_indices", "ceil_mode"],
default_matching,
),
torch.nn.modules.activation.ReLU: (1, ["inplace"], default_matching),
}
@compatibility(is_backward_compatible=False)
def extract_attrs_for_lowering(mod: nn.Module) -> dict[str, Any]:
"""If `mod` is in `module_fetch_book`, fetch the mod's attributes that in the `module_fetch_book`
after checking module's version is compatible with the `module_fetch_book`.
"""
attrs_for_lowering: dict[str, Any] = {}
attrs_for_lowering["name"] = torch.typename(mod)
if type(mod) in module_fetch_book:
version, param_to_fetch, matching_method = module_fetch_book[type(mod)]
if version < mod._version:
raise RuntimeError(
f"Fetcher version {version} try to fetch {torch.typename(mod)} version {mod._version}, "
"please upgrade the module_fetch_book, open an issue and @842974287 "
"or report a bug to AIACC team directly."
)
for attr in param_to_fetch:
attrs_for_lowering[attr] = getattr(mod, matching_method(attr, mod._version))
else:
raise RuntimeError(
f"{torch.typename(mod)} is not in the module_fetch_book yet, "
"please add it to the module_fetch_book, open an issue and @842974287 "
"or report a bug to AIACC team directly."
)
return attrs_for_lowering
@compatibility(is_backward_compatible=False)
def lift_lowering_attrs_to_nodes(fx_module: GraphModule) -> None:
"""Recursively traverse all `fx_module` nodes and fetch the module's attributes if the node is a leaf module."""
submodules = dict(fx_module.named_modules())
for node in fx_module.graph.nodes:
if node.op == "call_module":
if isinstance(submodules[node.target], GraphModule):
lift_lowering_attrs_to_nodes(submodules[node.target])
else:
node.attrs_for_lowering = extract_attrs_for_lowering(
submodules[node.target]
)
@@ -0,0 +1,250 @@
# mypy: allow-untyped-defs
import logging
from collections.abc import Callable
from functools import wraps
from inspect import unwrap
logger = logging.getLogger(__name__)
__all__ = [
"PassManager",
"inplace_wrapper",
"log_hook",
"loop_pass",
"this_before_that_pass_constraint",
"these_before_those_pass_constraint",
]
# for callables which modify object inplace and return something other than
# the object on which they act
def inplace_wrapper(fn: Callable) -> Callable:
"""
Convenience wrapper for passes which modify an object inplace. This
wrapper makes them return the modified object instead.
Args:
fn (Callable[Object, Any])
Returns:
wrapped_fn (Callable[Object, Object])
"""
@wraps(fn)
def wrapped_fn(gm):
fn(gm)
return gm
return wrapped_fn
def log_hook(fn: Callable, level=logging.INFO) -> Callable:
"""
Logs callable output.
This is useful for logging output of passes. Note ``inplace_wrapper`` replaces
the pass output with the modified object. If we want to log the original
output, apply this wrapper before ``inplace_wrapper``.
Example::
def my_pass(d: Dict) -> bool:
changed = False
if "foo" in d:
d["foo"] = "bar"
changed = True
return changed
pm = PassManager(passes=[inplace_wrapper(log_hook(my_pass))])
Args:
fn (Callable[Type1, Type2])
level: logging level (e.g. logging.INFO)
Returns:
wrapped_fn (Callable[Type1, Type2])
"""
@wraps(fn)
def wrapped_fn(gm):
val = fn(gm)
logger.log(level, "Ran pass %s\t Return value: %s", fn, val)
return val
return wrapped_fn
def loop_pass(
base_pass: Callable,
n_iter: int | None = None,
predicate: Callable | None = None,
):
"""
Convenience wrapper for passes which need to be applied multiple times.
Exactly one of `n_iter`or `predicate` must be specified.
Args:
base_pass (Callable[Object, Object]): pass to be applied in loop
n_iter (int, optional): number of times to loop pass
predicate (Callable[Object, bool], optional):
"""
if not ((n_iter is not None) ^ (predicate is not None)):
raise AssertionError("Exactly one of `n_iter`or `predicate` must be specified.")
@wraps(base_pass)
def new_pass(source):
output = source
if n_iter is not None and n_iter > 0:
for _ in range(n_iter):
output = base_pass(output)
elif predicate is not None:
while predicate(output):
output = base_pass(output)
else:
raise RuntimeError(
f"loop_pass must be given positive int n_iter (given "
f"{n_iter}) xor predicate (given {predicate})"
)
return output
return new_pass
# Pass Schedule Constraints:
#
# Implemented as 'depends on' operators. A constraint is satisfied iff a list
# has a valid partial ordering according to this comparison operator.
def _validate_pass_schedule_constraint(
constraint: Callable[[Callable, Callable], bool], passes: list[Callable]
):
for i, a in enumerate(passes):
for j, b in enumerate(passes[i + 1 :]):
if constraint(a, b):
continue
raise RuntimeError(
f"pass schedule constraint violated. Expected {a} before {b}"
f" but found {a} at index {i} and {b} at index{j} in pass"
f" list."
)
def this_before_that_pass_constraint(this: Callable, that: Callable):
"""
Defines a partial order ('depends on' function) where `this` must occur
before `that`.
"""
def depends_on(a: Callable, b: Callable):
return a != that or b != this
return depends_on
def these_before_those_pass_constraint(these: Callable, those: Callable):
"""
Defines a partial order ('depends on' function) where ``these`` must occur
before ``those``. Where the inputs are 'unwrapped' before comparison.
For example, the following pass list and constraint list would be invalid::
passes = [
loop_pass(pass_b, 3),
loop_pass(pass_a, 5),
]
constraints = [these_before_those_pass_constraint(pass_a, pass_b)]
Args:
these (Callable): pass which should occur first
those (Callable): pass which should occur later
Returns:
depends_on (Callable[[Object, Object], bool])
"""
def depends_on(a: Callable, b: Callable):
return unwrap(a) != those or unwrap(b) != these
return depends_on
class PassManager:
"""
Construct a PassManager.
Collects passes and constraints. This defines the pass schedule, manages
pass constraints and pass execution.
Args:
passes (Optional[List[Callable]]): list of passes. A pass is a
callable which modifies an object and returns modified object
constraint (Optional[List[Callable]]): list of constraints. A
constraint is a callable which takes two passes (A, B) and returns
True if A depends on B and False otherwise. See implementation of
`this_before_that_pass_constraint` for example.
"""
passes: list[Callable]
constraints: list[Callable]
_validated: bool = False
def __init__(
self,
passes=None,
constraints=None,
):
self.passes = passes or []
self.constraints = constraints or []
@classmethod
def build_from_passlist(cls, passes):
pm = PassManager(passes)
# TODO(alexbeloi): add constraint management/validation
return pm
def add_pass(self, _pass: Callable):
self.passes.append(_pass)
self._validated = False
def add_constraint(self, constraint):
self.constraints.append(constraint)
self._validated = False
def remove_pass(self, _passes: list[str]):
if _passes is None:
return
passes_left = [ps for ps in self.passes if ps.__name__ not in _passes]
self.passes = passes_left
self._validated = False
def replace_pass(self, _target, _replacement):
passes_left = []
for ps in self.passes:
if ps.__name__ == _target.__name__:
passes_left.append(_replacement)
else:
passes_left.append(ps)
self.passes = passes_left
self._validated = False
def validate(self):
"""
Validates that current pass schedule defined by `self.passes` is valid
according to all constraints in `self.constraints`
"""
if self._validated:
return
for constraint in self.constraints:
_validate_pass_schedule_constraint(constraint, self.passes)
self._validated = True
def __call__(self, source):
self.validate()
out = source
for _pass in self.passes:
out = _pass(out)
return out
@@ -0,0 +1,288 @@
# mypy: allow-untyped-defs
import contextlib
import functools
import logging
from collections.abc import Iterator
import torch
from torch.fx._compatibility import compatibility
logger = logging.getLogger(__name__)
__all__ = ["regional_inductor"]
# standalone_inductor returns a callable class object - this does not sit well
# with Fx graph node op call_function which expects a function. So this is just
# a wrapper function to make Fx graph codegen happy.
def _dummy_wrapper(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
return fn(*args, **kwargs)
return inner
@contextlib.contextmanager
def _disable_remat_for_regional_subcompile() -> Iterator[None]:
# In torch.compile, regional_inductor subcompiles run after the enclosing
# non-strict full graph has already been partitioned, so any graph-SAC
# remat pass has already run before we reach this nested compile.
# Rerunning remat here can see stage-2-reordered backward nodes that
# violate remat's contiguous-backward-region assumption.
with torch._functorch.config.patch(remat_using_tags_for_fwd_loss_bwd_graph=False):
yield
def _compile_submod(gm, prefix):
from torch._inductor.standalone_compile import AOTCompiledArtifact
for node in gm.graph.nodes:
if node.op == "call_module" and node.target.startswith(prefix):
fake_inputs = []
for inp_node in node.all_input_nodes:
if hasattr(inp_node, "meta") and "val" in inp_node.meta:
fake_inputs.append(inp_node.meta["val"])
else:
raise RuntimeError(
f"Partition is bad because non fake tensor value is seen {inp_node}"
)
submod = getattr(gm, node.target)
# Get inductor configs from annotation
# TODO we should change partition when there are multiple differently
# annotated regions.
inductor_options = {}
for sub_node in submod.graph.nodes:
if hasattr(sub_node, "meta") and sub_node.meta.get("custom", None):
custom = sub_node.meta["custom"]
if isinstance(custom, dict) and "compile_with_inductor" in custom:
compile_value = custom["compile_with_inductor"]
if (
isinstance(compile_value, dict)
and "inductor_configs" in compile_value
):
inductor_options = compile_value["inductor_configs"]
break
# Log the options being used
logger.info(
"Compiling submodule %s with inductor options: %s",
node.target,
inductor_options,
)
# Apply config patches before compilation
import torch._inductor.config as inductor_config
# Validate that all config keys exist
for key in inductor_options:
if not hasattr(inductor_config, key):
raise ValueError(
f"Invalid inductor config key '{key}' in regional_inductor annotation. "
f"Available config keys can be found in torch._inductor.config"
)
with (
inductor_config.patch(inductor_options),
_disable_remat_for_regional_subcompile(),
):
compiled_fn = torch._inductor.standalone_compile(
submod,
fake_inputs,
dynamic_shapes="from_tracing_context",
aot=True,
)
if not isinstance(compiled_fn, AOTCompiledArtifact):
raise AssertionError(
f"Expected AOTCompiledArtifact, got {type(compiled_fn)}"
)
# _dummy_wrapper is to make call_function happy
compiled_submod = _dummy_wrapper(compiled_fn)
with gm.graph.inserting_after(node):
new_node = gm.graph.call_function(
compiled_submod, args=node.args, kwargs=node.kwargs
)
new_node.meta = node.meta
node.replace_all_uses_with(new_node)
gm.graph.erase_node(node)
del gm._modules[node.target]
gm.recompile()
return gm
def _needs_inductor_compile(node: torch.fx.Node):
return (
node.op not in ("placeholder", "output")
and hasattr(node, "meta")
and node.meta.get("custom", None)
and "compile_with_inductor" in node.meta["custom"]
)
class _RegionScooper:
"""
Scoops out the inductor marked regions. It does NOT compile them.
"""
@staticmethod
def scoop_regions(gm):
from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner
from torch.fx.passes.operator_support import create_op_support
from torch.fx.passes.utils.fuser_utils import fuse_by_partitions
# Group tagged nodes by region ID. The region ID comes from the
# optional "inductor_region" key inside the compile_with_inductor
# annotation. When absent, all tagged nodes share a single default region
_DEFAULT_REGION = object()
regions: dict[object, set[torch.fx.Node]] = {}
for node in gm.graph.nodes:
if _needs_inductor_compile(node):
compile_value = node.meta["custom"]["compile_with_inductor"]
if (
isinstance(compile_value, dict)
and "inductor_region" in compile_value
):
rid = compile_value["inductor_region"]
else:
rid = _DEFAULT_REGION
regions.setdefault(rid, set()).add(node)
if not regions:
logger.info("No inductor marked nodes found")
return gm
# Run CapabilityBasedPartitioner per region to get cycle-safe partitions
# without merging across region boundaries.
def _is_in_region(region_nodes):
def is_node_supported(_submodules, node):
return node in region_nodes
return is_node_supported
all_partitions: list[dict[torch.fx.Node, int | None]] = []
for region_nodes in regions.values():
support = create_op_support(_is_in_region(region_nodes))
partitioner = CapabilityBasedPartitioner(
gm, support, allows_single_node_partition=True
)
for partition in partitioner.propose_partitions():
all_partitions.append(partition.nodes)
return fuse_by_partitions(
gm,
all_partitions,
prefix="__marked_inductor_submod",
always_return_tuple=True,
)
@staticmethod
def recursively_scoop_regions(gm, _processed=None):
if _processed is None:
_processed = set()
for node in gm.graph.find_nodes(op="get_attr"):
if _needs_inductor_compile(node):
# If the get_attr itself is marked for compile, the outer graph will
# take care of it. If we dont do that, we end up with nested
# regional inductor compiles that do not work well.
continue
submod = getattr(gm, node.target)
# Track by id: multiple get_attr nodes may reference the same GraphModule
if (
isinstance(submod, torch.fx.GraphModule)
and id(submod) not in _processed
):
_processed.add(id(submod))
_RegionScooper.recursively_scoop_regions(submod, _processed)
return _RegionScooper.scoop_regions(gm)
def __call__(self, gm):
with torch.fx.traceback.preserve_node_meta(enable=False):
return _RegionScooper.recursively_scoop_regions(gm)
class _RegionCompiler:
"""
Compiles the scooped out regions.
"""
@staticmethod
def compile_region(gm):
from torch.fx.graph import _BoxedCodeGen
gm = _compile_submod(gm, "__marked_inductor_submod")
gm.graph.set_codegen(_BoxedCodeGen())
gm.recompile()
return gm
@staticmethod
def recursively_compile_regions(gm):
# Find if the graph module has a scooped out region
found_region = False
for node in gm.graph.find_nodes(op="call_module"):
submod = getattr(gm, node.target)
if isinstance(submod, torch.fx.GraphModule):
if node.target.startswith("__marked_inductor_submod"):
found_region = True
# Recurse through the subgraphs
for node in gm.graph.find_nodes(op="get_attr"):
submod = getattr(gm, node.target)
if isinstance(submod, torch.fx.GraphModule):
_RegionCompiler.recursively_compile_regions(submod)
if found_region:
return _RegionCompiler.compile_region(gm)
return gm
def __call__(self, gm):
with torch.fx.traceback.preserve_node_meta(enable=False):
return _RegionCompiler.recursively_compile_regions(gm)
def _create_inductor_marked_regions(gm):
with torch.fx.traceback.preserve_node_meta(enable=False):
return _RegionScooper()(gm)
def _compile_inductor_marked_regions(gm):
with torch.fx.traceback.preserve_node_meta(enable=False):
return _RegionCompiler()(gm)
@compatibility(is_backward_compatible=False)
def regional_inductor(gm, *example_args):
"""
Scoops out inductor marked regions and compiles them with inductor.
Inductor options should be provided via the annotation API::
with fx_traceback.annotate(
{
"compile_with_inductor": {
"inductor_configs": {
"max_autotune": True,
"triton.cudagraphs": False,
}
}
}
):
...
"""
# fuser utils create new nodes using create_proxy which retains the seq_nr
# metadata and cause issues
with torch.fx.traceback.preserve_node_meta(enable=False):
gm = _create_inductor_marked_regions(gm)
gm = _compile_inductor_marked_regions(gm)
if torch._functorch.config.force_autograd_cache:
from torch._inductor.output_code import RegionalOutputCode
gm = RegionalOutputCode(gm)
return gm
@@ -0,0 +1,174 @@
# mypy: allow-untyped-defs
import copy
import logging
from collections import defaultdict
import torch
from torch._inductor.standalone_compile import AOTCompiledArtifact
from torch.compiler._cache import CacheArtifactManager
from torch.fx._compatibility import compatibility
from torch.fx.passes.regional_inductor import (
_disable_remat_for_regional_subcompile,
_dummy_wrapper,
)
logger = logging.getLogger(__name__)
__all__ = ["regional_inductor_invoke_subgraph"]
def _compile_submod(
gm: torch.fx.GraphModule, subgraph: str, subgraph_users: list[torch.fx.Node]
):
"""
Compiles subgraph submodule in gm. subgraph is used by subgraph_users.
subgraph_users must all be torch.ops.higher_order.invoke_subgraph HOP.
"""
submod = getattr(gm, subgraph)
compile_config = None
fake_inputs = []
# We use the first user for compile configs and inputs
sub_node = subgraph_users[0]
if not _needs_inductor_compile(sub_node):
raise AssertionError("sub_node does not need inductor compile")
compile_config = sub_node.meta["custom"]["nested_region_config"]
if sub_node.meta.get("partitioner_tag") == "is_forward":
compile_fn = compile_config.fw_compiler
else:
compile_fn = compile_config.bw_compiler
for inp_node in sub_node.all_input_nodes[
1:
]: # exlucde the graph module input to torch.ops.higher_order.invoke_subgraph
if hasattr(inp_node, "meta") and "val" in inp_node.meta:
fake_inputs.append(inp_node.meta["val"])
else:
raise RuntimeError(
f"Partition is bad because non fake tensor value is seen {inp_node}"
)
# Log the options being used
logger.info(
"Compiling submodule %s with inductor options: %s",
subgraph,
compile_config,
)
def get_compiled_fn():
context = torch._guards.TracingContext.get()
if context.fake_mode is None:
raise AssertionError("context.fake_mode is None")
context = torch._guards.TracingContext(context.fake_mode)
with (
torch._guards.tracing(context),
CacheArtifactManager.with_fresh_cache(),
torch._functorch.config.patch("bundled_autograd_cache", True),
_disable_remat_for_regional_subcompile(),
):
# compile_fx can mutate gm
gm = copy.deepcopy(submod)
compiled_fn = compile_fn(gm, fake_inputs)
return compiled_fn
compiled_fn = get_compiled_fn()
if not isinstance(compiled_fn, AOTCompiledArtifact):
raise AssertionError(f"Expected AOTCompiledArtifact, got {type(compiled_fn)}")
# _dummy_wrapper is to make call_function happy
compiled_submod = _dummy_wrapper(compiled_fn)
for node in subgraph_users:
with gm.graph.inserting_after(node):
new_node = gm.graph.call_function(
# exclude graph nodes input args
compiled_submod,
args=node.args[2:],
kwargs=node.kwargs,
)
new_node.meta = node.meta
node.replace_all_uses_with(new_node)
gm.graph.erase_node(node)
gm.recompile()
return gm
def _needs_inductor_compile(node: torch.fx.Node):
# TODO: maybe we could change to check
# node.meta.get("partitioner_tag") != "is_forward"
# if the tag is relibable
return (
node.op not in ("placeholder", "output")
and hasattr(node, "meta")
and node.meta.get("custom", None)
and node.meta["custom"].get("nested_region_config", None)
and node.meta["custom"]["nested_region_config"].fw_compiler
and node.meta.get("partitioner_tag") != "is_backward"
) or (
node.op not in ("placeholder", "output")
and hasattr(node, "meta")
and node.meta.get("custom", None)
and node.meta["custom"].get("nested_region_config", None)
and node.meta["custom"]["nested_region_config"].bw_compiler
and node.meta.get("partitioner_tag") == "is_backward"
)
def _compile_invoke_subgraph_nodes_with_inductor(gm):
map_subgraph_to_nodes = defaultdict(list)
subgraphs: set[str] = set()
for node in gm.graph.find_nodes(
op="call_function", target=torch.ops.higher_order.invoke_subgraph
):
if not _needs_inductor_compile(node):
continue
if node.args[0].op != "get_attr":
raise AssertionError(f"Expected get_attr, got {node.args[0].op}")
subgraph_name = node.args[0].target
if not isinstance(subgraph_name, str):
raise AssertionError(f"Expected str, got {type(subgraph_name)}")
subgraphs.add(subgraph_name)
map_subgraph_to_nodes[subgraph_name].append(node)
for subgraph in subgraphs:
gm = _compile_submod(gm, subgraph, map_subgraph_to_nodes[subgraph])
return gm
def _recursive_compile_invoke_subgraph_nodes(gm):
for node in gm.graph.find_nodes(op="get_attr"):
if _needs_inductor_compile(node):
# If the get_attr itself is marked for compile, the outer graph will
# take care of it. If we dont do that, we end up with nested
# regional inductor compiles that do not work well.
continue
submod = getattr(gm, node.target)
if isinstance(submod, torch.fx.GraphModule):
_recursive_compile_invoke_subgraph_nodes(submod)
return _compile_invoke_subgraph_nodes_with_inductor(gm)
@compatibility(is_backward_compatible=False)
def regional_inductor_invoke_subgraph(gm, *example_args):
"""
Compile invoke_subgraph nodes if they have custom compiler specified
in node.meta["nested_region_config"].bw_compiler or fw_compiler
"""
# fuser utils create new nodes using create_proxy which retains the seq_nr
# metadata and cause issues
with torch.fx.traceback.preserve_node_meta(enable=False):
compiled_gm = _recursive_compile_invoke_subgraph_nodes(gm)
# TODO: might not need this boxed_nop after we switch to _RegionCompiler
return torch._dynamo.backends.debugging.boxed_nop(
compiled_gm, example_inputs=[]
)
@@ -0,0 +1,801 @@
# mypy: allow-untyped-defs
import _operator
import itertools
from collections import defaultdict
from collections.abc import Callable
from enum import Enum
from typing import Any
import torch
from torch._subclasses.fake_tensor import FakeTensor, FakeTensorMode
from torch.fx import Node
from torch.fx._compatibility import compatibility
from torch.multiprocessing.reductions import StorageWeakRef
from torch.utils import _pytree as pytree
from torch.utils._pytree import tree_map_only
__all__ = ["reinplace"]
class _ViewType(Enum):
NonView = 0
SingleOutputView = 1
MultiOutputView = 2
def _is_view_op(tgt):
if tgt is not None and isinstance(tgt, torch._ops.OpOverload):
schema = tgt._schema
if len(schema.arguments) > 0:
first_arg = schema.arguments[0]
# check if op is a view
return (
first_arg.alias_info is not None and not first_arg.alias_info.is_write
)
def _get_view_type(tgt) -> _ViewType:
if tgt is not None and isinstance(tgt, torch._ops.OpOverload):
schema = tgt._schema
if len(schema.arguments) > 0:
first_arg = schema.arguments[0]
# check if op is a view
if first_arg.alias_info is not None and not first_arg.alias_info.is_write:
# check if op is a multi-output view
if "*" in first_arg.alias_info.after_set:
return _ViewType.MultiOutputView
else:
return _ViewType.SingleOutputView
return _ViewType.NonView
# Stores a bunch of metadata related to functionalization each node.
# Relevant metadata:
# n.meta['fake_result']: FakeTensor (same type as the output of the node, but with FakeTenors instead of Tensors)
# The fake tensor output from running the current node
# n.meta['view_of']: Node
# If the current node n is a view of some base tensor, the 'view_of' field tells us which
# view node was used to generate the current node (a view tensor).
# This information actually makes `fake_result` redundant, but we can use `fake_result`
# to sanity check that our aliasing information is correct.
@compatibility(is_backward_compatible=False)
class _FunctionalizationMetadataProp(torch.fx.Interpreter):
def run_node(self, node: Node):
self.node_counter += 1
result = super().run_node(node)
node.meta["fake_result"] = result
node.meta["node_idx"] = self.node_counter
# (1) Update metadata with the list of nodes that are used by this node
# copy_() doesn't read from its first argument; it writes to it, overwriting previous data.
# We don't want to treat it as "being used as an input".
node_args = node.args
if node.target is torch.ops.aten.copy_.default:
node_args = node_args[1:]
# (2) Update metadata to track aliasing information about view tensor nodes.
if node.op == "call_function":
view_type = _get_view_type(node.target)
if view_type == _ViewType.SingleOutputView:
if not isinstance(node.args[0], Node):
raise AssertionError(f"Expected Node, got {type(node.args[0])}")
node.meta["view_of"] = node.args[0]
elif view_type == _ViewType.MultiOutputView:
self.multi_output_view_nodes[node] = node.args[0]
# Check if we returned a multi-output view,
# and we're now grabbing the individual views from the output.
#
# For multi-output views, we want to map each output view to the base,
# but this mapping involves two separate nodes in FX IR.
# e.g. "a, b = x_1.split(...)" becomes:
# %split_tensor : [num_users=2] = call_function[target=torch.ops.aten.split.Tensor](args = (%x_1, 2), kwargs = {})
# %getitem : [num_users=1] = call_function[target=operator.getitem](args = (%split_tensor, 0), kwargs = {})
# %getitem_1 : [num_users=1] = call_function[target=operator.getitem](args = (%split_tensor, 1), kwargs = {})
# And we'd like to set:
# getitem1.meta['view_of'] = x_1
elif node.target is _operator.getitem:
list_arg = node.args[0]
maybe_base_of_view = self.multi_output_view_nodes.get(list_arg, None)
if maybe_base_of_view is not None:
# Note: we could also track indexing info here for multi-output views.
# I don't think this metadata is strictly needed for de-functionalization.
if not isinstance(maybe_base_of_view, Node):
raise AssertionError(
f"Expected Node, got {type(maybe_base_of_view)}"
)
node.meta["view_of"] = maybe_base_of_view
if "view_of" in node.meta:
# We're linking the current node with its first argument as views.
# Assert here that this is actually the case, and their storages are the same.
if not isinstance(node.meta["fake_result"], FakeTensor):
raise AssertionError("Expected FakeTensor in fake_result")
if not isinstance(node.meta["view_of"].meta["fake_result"], FakeTensor):
raise AssertionError("Expected FakeTensor in view_of fake_result")
view_storage = StorageWeakRef(node.meta["fake_result"]._typed_storage())
base_storage = StorageWeakRef(
node.meta["view_of"].meta["fake_result"]._typed_storage()
)
if view_storage != base_storage:
raise AssertionError("view_storage != base_storage")
return result
def propagate(self, *args):
self.multi_output_view_nodes = {}
self.node_counter = -1
with FakeTensorMode() as mode:
fake_args = [
mode.from_tensor(a) if isinstance(a, torch.Tensor) else a for a in args
]
return super().run(*fake_args)
def _schemas_match(functional_schema, inplace_schema):
names_match = (
inplace_schema.name.endswith("_")
and inplace_schema.name[:-1] == functional_schema.name
)
arg_types_match = len(functional_schema.arguments) == len(
inplace_schema.arguments
) and all(
a1.type == a2.type
for a1, a2 in zip(functional_schema.arguments, inplace_schema.arguments)
)
# for the inplace op, its first argument should be mutable
if not (
inplace_schema.arguments[0].alias_info is not None
and inplace_schema.arguments[0].alias_info.is_write
):
raise AssertionError("First argument of inplace op must be mutable")
# and its remaining arguments shouldn't be.
if not all(a.alias_info is None for a in inplace_schema.arguments[1:]):
raise AssertionError("Remaining arguments of inplace op must not be mutable")
return names_match and arg_types_match
# TODO: this should be beefed up to be able to properly re-inplace with:
# - mutating ops (e.g. _fused_moving_avg_obs_fq_helper)
# - out= ops (e.g. angle -> angle.out)
# TODO: we should also figure this info out using torchgen.
def _maybe_get_inplace_op(op):
# __module__ seems broken; it returns torch._ops.aten which doesn't exist
if not isinstance(op, torch._ops.OpOverload):
return None
# Some view ops have inplace variants (as_strided_, etc),
# but we do NOT want the reinplacing pass to directly add these into the program.
# (they'll require extra special handling, aren't aren't really useful for perf anyway)
if _is_view_op(op):
return None
op_namespace = op.__module__.split(".")[-1]
op_base_name = op.overloadpacket.__name__
maybe_namespace_module = getattr(torch.ops, op_namespace)
maybe_inplace_op = (
None
if maybe_namespace_module is None
else getattr(maybe_namespace_module, f"{op_base_name}_", None)
)
if maybe_inplace_op is None:
return None
inplace_overloads = [
getattr(maybe_inplace_op, overload_name)
for overload_name in maybe_inplace_op.overloads()
]
inplace_overloads_with_matching_schemas = [
f for f in inplace_overloads if _schemas_match(op._schema, f._schema)
]
# Just because foo() and foo_() are both existing operators,
# They aren't guaranteed to have compatible schemas.
# For example, pow.Scalar(Scalar self, Tensor exponent) has no valid inplace variant,
# Even though several overloads of pow_ exist.
if len(inplace_overloads_with_matching_schemas) == 0:
return None
if len(inplace_overloads_with_matching_schemas) != 1:
raise AssertionError(
f"Expected exactly 1 matching inplace overload, got "
f"{len(inplace_overloads_with_matching_schemas)}"
)
inplace_op = inplace_overloads_with_matching_schemas[0]
return inplace_op
_VIEW_INVERSE_MAP: dict[Callable[..., Any], Callable[..., Any]] = {
torch.ops.aten.diagonal_scatter.default: torch.ops.aten.diagonal.default,
torch.ops.aten.select_scatter.default: torch.ops.aten.select.int,
torch.ops.aten.slice_scatter.default: torch.ops.aten.slice.Tensor,
torch.ops.aten.as_strided_scatter.default: torch.ops.aten.as_strided.default,
}
# This function, given a set of set of (aliased) tensor nodes,
# Returns any nodes in the graph that *use* any of the aliases, that occur *after* op_index
# in the node ordering.
def _get_all_later_node_usages(tensor_aliases: set[Node], op_index: int):
def _add_if_tensor(x, set_):
if isinstance(x, FakeTensor):
set_.add(StorageWeakRef(x._typed_storage()))
nodes_used_after = set()
for t in tensor_aliases:
# get all nodes that use the current alias
usage_nodes = t.users
for n in usage_nodes:
# We only care about usages after the current node
if "node_idx" not in n.meta or n.meta["node_idx"] <= op_index:
continue
# We also don't care about intermediate view ops.
# They only matter if their output is then used elsewhere
# (either in an out-of-place op, or as an output to the function).
if n in tensor_aliases:
if (
isinstance(n.target, torch._ops.OpOverload)
or n.target is _operator.getitem
):
continue
nodes_used_after.add(n)
return nodes_used_after
# Given an op that we're trying to re-inplace, "b = foo(a)",
# And given a {view}_scatter op that shows up later in the graph, "y = {view}_scatter(base, x, args...)"
# Then re-inplacing `foo()` would allow us to remove the `{view}_scatter` op entirely, IF:
# If there are any aliases in the alias_set(a) that satisfy:
# (1) The base of "alias", "alias_base", has the same size/stride/offset metadata as "base"
# (2) The output of running {view}(alias, args...) gives you the same size/stride/offset metadata
# as "alias"
def _get_view_inverse_node_usages(
later_node_usages: set[Node], self_aliases: set[Node]
) -> set[Node]:
def matching_view_metadata(a, b):
return (
a.size() == b.size()
and a.stride() == b.stride()
and a.storage_offset() == b.storage_offset()
)
view_inverse_nodes = set()
# Go through them in node order, so we can see chains of view_scatter ops.
for n in sorted(later_node_usages, key=lambda x: x.meta["node_idx"]):
if n.target not in _VIEW_INVERSE_MAP:
continue
base = n.args[0]
mutated_view = n.args[1]
if not isinstance(base, Node):
raise AssertionError(f"Expected Node for base, got {type(base)}")
if not isinstance(base.meta["fake_result"], FakeTensor):
raise AssertionError("Expected FakeTensor in base.meta['fake_result']")
if not isinstance(mutated_view, Node):
raise AssertionError(
f"Expected Node for mutated_view, got {type(mutated_view)}"
)
if not isinstance(mutated_view.meta["fake_result"], FakeTensor):
raise AssertionError(
"Expected FakeTensor in mutated_view.meta['fake_result']"
)
if isinstance(n.target, str):
raise AssertionError("n.target should not be a string")
# Check that this view_inverse op actually corresponds to taking doing the inverse
# of one of our existing self_alias nodes.
original_view = _VIEW_INVERSE_MAP[n.target]
for self_alias in self_aliases:
# We're looking for some alias of the self arg, "alias",
# that was created from some op `alias = foo(base, args...)`
# such that the current _scatter op "inverts" that foo call.
# We can check that by running the original op again, and checking that the strides match.
if "view_of" not in self_alias.meta:
continue
self_alias_base = self_alias.meta["view_of"]
try:
# The we're trying to reuse the args from the view_scatter call inside of the corresponding
# view op, which might throw. This just indicates that view_scatter op isn't a valid inverse
# of the current alias we're looking at.
view_replay_metadata = original_view(
self_alias_base.meta["fake_result"], *n.args[2:], **n.kwargs
)
expected_metadata = self_alias.meta["fake_result"]
# If the alias and its base both have matching metadata, then this view_scatter op is valid to re-inplace.
if matching_view_metadata(
self_alias_base.meta["fake_result"], base.meta["fake_result"]
) and matching_view_metadata(view_replay_metadata, expected_metadata):
view_inverse_nodes.add(n)
except Exception:
continue
return view_inverse_nodes
@compatibility(is_backward_compatible=True)
def reinplace(gm, *sample_args):
r"""
Given an fx.GraphModule, modifies it to perform "reinplacing",
mutating the nodes of the graph.
We look for out-of-place op call sites like ``b = a.add(...)``,
and convert them to be inplace (``b = a.add_(...)``),
as long as the input to the current operator ("a") isn't reused
anywhere later in the graph.
This pass currently expects to operate on a **functional, ATen** graph.
This can be obtained by running ``make_fx(functionalize(f))``.
Sample inputs are needed to determine aliasing relationships of the inputs.
In general, we can't reinplace node ``b = a.add(...)`` if "a" aliases any of the
inputs to the program.
Given a node ``b = foo(a, args...)`` the algorithm for re-inplacing is as follows:
**(1)** Perform some initial checks on the metadata of "a" and "args..."
that can disqualify them from being reinplaced.
- **(1a)** Check that the self argument we're attempting to reinplace
has acceptable dtype/size metadata to reinplace with.
For example, if we have::
a = torch.ones(1)
b = torch.ones(10)
out = torch.add(a, b)
We can't turn that into ``a.add_(b)`` because that would require resizing "a".
Similarly, we can't convert ``torch.ge(a, b)`` into ``a.ge_(b)``,
because that would require changing a's dtype (from e.g. float32 to bool).
Note that in this specific example, we could technically do better..
If we see the pattern::
a_1 = a.ge(b)
a_2 = aten._to_copy(a_1, a.dtype)
Then this should be valid to completely re-inplace
(this is exactly what functionalization will emit when it sees ``a.ge_(b)``).
This optimization is only really important for user programs
that directly use inplace comparison ops though.
We also cannot re-inplace on tensors that have overlapping memory,
e.g. ``torch.ones(1).expand(4, 4).add_(1)``.
- **(1b)** Check if "a" is an alias of any of the program inputs.
If it is, skip and move to the next node.
Inplace'ing an op that would cause it to mutate a program is not sound,
because that would be a side effect visible to the user.
NOTE: there's a future optimization that we should make:
if "a" is a (alias of a) program input, but later in the program
there is a node that looks like ``a.copy_(...)``,
then re-inplacing is ok to do - we are temporarily reusing a's buffer,
which will later be overwritten by the ``copy_()`` call.
This will be an important optimization to have for programs that mutate
their inputs. It currently isn't implemented though.
- **(1c)** Check if "a" and "args..." alias.
For example, re-inplacing to create code like the below
isn't guaranteed to be sound::
aten.mul_(a, a)
**(2)** Check that "a" and all of its outstanding aliases are not used anywhere
later in the graph. If this is the case, then it's safe to re-inplace
to ``b = foo_(a)``.
There are a few caveats to this, explained in more detail below:
- (a) If "a" is used later as an argument to a view op, that is okay.
It's only a problem if "a" (or that view) is later passed
into a normal operator, or if it is returned as the program output.
- (b) If "a" is a repeat argument in ``foo()``, then don't reinplace.
Most ATen kernels don't make any guarantees that this is sound,
e.g. if you do ``aten.mul_(a, a)``.
So we'll just ban re-inplacing in this case.
- (c) If "a" is used as an input into a view "inverse" / "scatter"
operator, it is potentially fine to re-inplace
(and remove that scatter operator from the graph).
See below for a more detailed example.
NOTE: there is an optimization in this step that is crucial
to fully recovering performance from functionalization.
Given this program::
def f(x):
a = torch.ops.aten.add(x, x)
b = torch.ops.aten.diagonal(a)
torch.ops.aten.fill_(b, 0)
return d
Functionalization will emit the following::
def f(x):
a = torch.ops.aten.add(x, x)
b = torch.ops.aten.diagonal(a, 0, 1)
b_updated = torch.ops.aten.fill(b, 0)
a_updated = torch.ops.aten.diagonal_scatter(a, b_updated, 0, 1)
return a_updated
Ordinarily, we would not be able to reinplace the fill,
because "b" aliases with "a" which is used by the diagonal_scatter call.
"re-inplacing" is on the hook for figuring out that it is ok to
completely remove the expensive diagonal_scatter call, if we re-inplace
the add().
So, for every ``alias in alias_set(a)``, instead of checking
that "alias" is not used anywhere later in the graph,
we check that EITHER:
- (a) alias is not used anywhere later in the graph, OR
- (b) alias is used exactly once later on in the graph,
in the following op::
out = foo_scatter(alias, x, args...)
where the following must hold:
- (i) ``foo_scatter`` is the "inverse" operator for foo.
This only applies to "foo" ops that are view operators,
which view into a subset of the original tensor's memory.
In practice, there are ~4 operators where this applies::
diagonal -> diagonal_scatter
slice -> slice_scatter
select -> select_scatter
as_strided -> as_strided_scatter
- (ii) "args..." are the same between the ``foo()`` and
``foo_scatter()`` calls.
**(3)** Perform the actual re-inplacing on foo!
(3b) is the common case, but special care is needed for
``{view}_scatter`` (3a).
- **(3a)** ``{view}_scatter`` ops.
Consider this program::
a = torch.zeros(2, 2)
b = torch.ones(2)
a[0] = b
Post functionalization, that will look like::
a = torch.zeros(2)
b = torch.ones(1)
a_updated = torch.select_scatter(a, b, 0, 0)
In this case though, there is no "functional" op to re-inplace!
Instead, we'd like to directly remove the select_scatter call.
We already know from (3) that this is valid,
because "a" has no later usages in the graph.
We perform the re-inplacing on the ``{view}_scatter`` op like so.
Before::
a_updated = torch.select_scatter(a, b, args...)
After::
a_slice = a.select(a, args...)
a_slice.copy_(b)
- **(3b)** Otherwise, replace the functional op with its inplace variant.
Before::
b = foo(a, args...)
After::
a.foo_(args...)
**(4)** Finally, after converting either::
# Before: # After:
b = foo(a) foo_(a)
or::
# Before:
b = {slice}_scatter(a, mutated_slice, args...)
# After:
slice = {slice}(a, args...)
slice.copy_(mutated_slice)
We now need to find all later nodes that use "b" as an argument
and update them to take in "a" instead.
Note that for the majority of inplace ops, this isn't actually necessary
(because most inplace ops return "self" as their output).
This isn't generally true for all mutable ops though, which is why
we need to actually replace all of the arguments.
We also need to update our metadata of ``Dict[StorageWeakRef, Set[Node]]``,
that maps a given tensor storage to the set of all nodes that take in that
storage as an input.
Specifically, re-inplacing ``b = foo(a)`` causes "a" and "b"'s sets to get
fused together.
**(5)** Any ``view_inverse/scatter`` nodes that were identified as
"it's ok to ignore them" during step (3) get manually deleted from the graph.
Their outputs are no longer used, so technically standard DCE would be able
to do this, but we can no longer run FX's DCE pass now that we have mutable
ops in the graph.
"""
_FunctionalizationMetadataProp(gm).propagate(*sample_args)
# Useful debug printing
# def _print(x):
# if isinstance(x, FakeTensor):
# print(f'fake_result: {StorageWeakRef(x._typed_storage()).cdata}')
# for n in gm.graph.nodes:
# print(n.format_node())
# if hasattr(n, 'meta'):
# print(f'node_idx: {n.meta["node_idx"]}')
# if 'fake_result' in n.meta:
# tree_map(_print, n.meta['fake_result'])
# if 'view_of' in n.meta:
# print(f'view_of: {str(n.meta["view_of"])}')
# print()
# We need to know which nodes correspond to inputs (or their aliases)
# so we know not to re-inplace them.
# NOTE: later, we'll need to add an optimization for fully recovering performance
# on programs that mutate inputs.
input_storages = {
StorageWeakRef(node.meta["fake_result"]._typed_storage())
for node in gm.graph.nodes
if (
node.op == "placeholder"
and isinstance(node.meta["fake_result"], torch.Tensor)
)
}
# We also need to know for a given node, what are all of its aliasing nodes.
storage_to_nodes: dict[StorageWeakRef, set[Node]] = defaultdict(set)
for n in gm.graph.nodes:
if "fake_result" in n.meta:
# Tree-mapping because some ops can return lists of tensors.
def _add_to_map(x):
if isinstance(x, FakeTensor):
storage_to_nodes[StorageWeakRef(x._typed_storage())].add(n)
pytree.tree_map_(_add_to_map, n.meta["fake_result"])
# inplace-ify functional ops, subject to the constraints written below.
all_later_view_inverse_nodes_to_delete = set()
for node in gm.graph.nodes:
if node.op == "call_function":
# Today, the re-inplace pass on directly acts on:
# - functional ops with an inplace variant
# - {view}_scatter ops that can be potentially removed from the graph.
# Both of these ops take in tensor first args, so filtering on this condition
# makes the later code simpler.
# We should revisit this at some point though, particularly when we also want
# the reinplacer to be able to handle out= and mutable operators
# and tensorlist first args (like `_foreach_` ops).
if not isinstance(node.target, torch._ops.OpOverload):
continue
if len(node.target._schema.arguments) < 1:
continue
if type(node.target._schema.arguments[0].type) is not torch.TensorType:
continue
# Step 1a: Check that the self argument we're attempting to reinplace
# has the same size/stride as the output.
# For example, we shouldn't try to reinplace torch.add(scalar_tensor, larger_tensor)
# As it would require resizing scalar_tensor.
# (We could potentially swizzle this into larger_tensor.add_(scalar_tensor),
# this is probably an optimization to revisit later).
self_arg = node.args[0]
self_flattened = pytree.tree_leaves(self_arg.meta["fake_result"])
node_flattened = pytree.tree_leaves(node.meta["fake_result"])
self_has_wrong_metadata = False
if len(self_flattened) == len(node_flattened):
for self_meta, node_meta in zip(self_flattened, node_flattened):
if self_meta.numel() != node_meta.numel():
self_has_wrong_metadata = True
if self_meta.dtype != node_meta.dtype:
self_has_wrong_metadata = True
# We also cannot re-inplace on tensors that have internal memory overlap.
# e.g. torch.ones(1).expand(4, 4).add_(1)
if torch._debug_has_internal_overlap(self_meta) == 1:
self_has_wrong_metadata = True
# Here, we (optimistically) assume that a.resize(b) is valid to re-inplace,
# Since users should never really be calling the functional "torch.ops.aten.resize"
# op directly in their programs.
if self_has_wrong_metadata and node.target != torch.ops.aten.resize.default:
continue
# Step 1b: ensure that the op we're trying to re-inplace isn't a program input
self_arg_storage = StorageWeakRef(
self_arg.meta["fake_result"]._typed_storage()
)
if self_arg_storage in input_storages:
# TODO: later, add the optimization for handling `copy_()` calls in the graph.
continue
if len([x for x in node.args if x is self_arg]) > 1:
# Step 1c:
# Calling stuff like aten.mul_(a, a) isn't guaranteed to be sound,
# so we prevent re-inplacing in this case.
continue
self_arg_storage = StorageWeakRef(
self_arg.meta["fake_result"]._typed_storage()
)
self_aliases = storage_to_nodes[self_arg_storage]
# First, we find all later usages of any of the aliases of self_arg.
later_node_usages = _get_all_later_node_usages(
self_aliases, node.meta["node_idx"]
)
# Then, we check if any of those later usages are actually view_scatter ops
# that are safe to fully remove.
later_view_inverse_node_usages = _get_view_inverse_node_usages(
later_node_usages, self_aliases
)
# Step 2: Check to see if the input to the op is reused later in the graph.
# If not (same goes for its aliases), then this op is safe to re-in place.
# This is a slightly roundabout way to check that there are no later usages of the current self argument.
# (later_view_inverse_node_usages corresponds to "view_scatter" nodes that we are allowed to delete)
can_reinplace = len(later_node_usages - later_view_inverse_node_usages) == 0
if not can_reinplace:
continue
# Step 3a: Special handling for when we see *_scatter operators.
# When we see an operator like `b = torch.slice_scatter(a, ...)`,
# instead of trying to "inplace" it into a.slice_scatter_(..._),
# we would prefer to remove it from the graph entirely,
# and instead copy_() the slice directly into the larger tensor.
# See the description of the algorithm for a full example.
if (
node.target in _VIEW_INVERSE_MAP
and node not in all_later_view_inverse_nodes_to_delete
):
view_op = _VIEW_INVERSE_MAP[node.target]
# Before:
# base_updated = torch.ops.aten.slice_scatter.default(base, mutated_slice, args...)
# After:
# slice = torch.ops.aten.slice.default(base, args...)
# slice.copy_(mutated_slice)
with gm.graph.inserting_before(node):
mutated_slice_node = node.args[1]
remaining_slice_args = node.args[2:]
slice_node = gm.graph.create_node(
"call_function",
view_op,
(self_arg,) + tuple(remaining_slice_args),
node.kwargs,
)
gm.graph.create_node(
"call_function",
torch.ops.aten.copy_.default,
(
slice_node,
mutated_slice_node,
),
{},
)
# Add the slice_scatter node to our "nodes to delete" list.
all_later_view_inverse_nodes_to_delete.add(node)
else:
# Step 3b: Check to see if this operator has an inplace variant.
maybe_inplace_op = _maybe_get_inplace_op(node.target)
if maybe_inplace_op is None:
continue
# And if so, replace it with its inplace variant.
node.target = maybe_inplace_op
# At this point, 'storage_to_nodes' will be stale.
# Now that we're inplacing `b = foo(a)`, we need to effectively
# union together the dict values for b and a's storage.
# Hmm... morally I think we also want to keep the `fake_result` metadata
# up to date here, but I'm not sure how easy it is to do.
# Maybe it's fine to wait until the end of the pass to update it.
curr_node_storage = StorageWeakRef(
node.meta["fake_result"]._typed_storage()
)
storage_to_nodes[self_arg_storage].update(
storage_to_nodes[curr_node_storage]
)
storage_to_nodes[curr_node_storage].update(
storage_to_nodes[self_arg_storage]
)
# Need to remember the view_scatter view nodes we found so we can remove them alter.
all_later_view_inverse_nodes_to_delete.update(
later_view_inverse_node_usages
)
# Step 4:
# Now that we've replaced b = a.foo() with a.foo_(),
# We need to replace any later usages of "b" with "a"
for old in itertools.chain([node], later_view_inverse_node_usages):
new = old.args[0]
nodes_to_update = [
n for n in old.users if n.meta["node_idx"] > node.meta["node_idx"]
]
for node_to_update in nodes_to_update:
def replace_arg(a):
if a == old:
return new
return a
# First, replace usages of "b" with "a"
node_to_update.args = tree_map_only(
Node, replace_arg, node_to_update.args
)
node_to_update.kwargs = tree_map_only(
Node, replace_arg, node_to_update.kwargs
)
# Second, update our storage_to_nodes data structure.
old_flattened_res = pytree.tree_leaves(old.meta["fake_result"])
node_flattened_res = pytree.tree_leaves(
node_to_update.meta["fake_result"]
)
old_res_storage = {
StorageWeakRef(x._typed_storage())
for x in old_flattened_res
if isinstance(x, FakeTensor)
}
node_res_storage = {
StorageWeakRef(x._typed_storage())
for x in node_flattened_res
if isinstance(x, FakeTensor)
}
# This will happen if we're updating a view op, e.g.
# e.g. replacing
# x = view(old)
# x = view(new)
# When that happens, we need to make sure to keep our
# storage mapping up to date.
#
# We're checking for len(...) == 1 here because all view ops are guaranteed to return either a single tensor,
# or multiple tensors that all share the same storage.
# We can't just check equality because we might encounter FX nodes that return zero tensor outputs.
if (
len(old_res_storage) == 1
and len(node_res_storage) == 1
and old_res_storage == node_res_storage
):
# pyrefly: ignore [missing-attribute]
new_flattened_res = pytree.tree_leaves(new.meta["fake_result"])
new_res_storage = {
StorageWeakRef(x._typed_storage())
for x in new_flattened_res
if isinstance(x, FakeTensor)
}
if len(new_res_storage) != 1:
raise AssertionError(
f"Expected 1 storage, got {len(new_res_storage)}"
)
(new_ref,) = new_res_storage
(node_ref,) = node_res_storage
# Technically, "old_ref" and all its aliases will remain
# in our mapping.
# That should be fine though, since we deleted "old"
# from the graph at this point.
storage_to_nodes[node_ref].update(storage_to_nodes[new_ref])
storage_to_nodes[new_ref].update(storage_to_nodes[node_ref])
# Step 4: delete any _scatter nodes that we de-functionalized
# Need to take care not to delete any of these nodes until after *all* modifications
# to the graph are finished.
for to_delete in all_later_view_inverse_nodes_to_delete:
gm.graph.erase_node(to_delete)
gm.recompile()
return gm
@@ -0,0 +1,680 @@
# mypy: allow-untyped-defs
import functools
import logging
import operator
import sys
from typing import Any, Optional, TYPE_CHECKING
# Import sympy and ShapeEnv during TYPE_CHECKING since importing sympy is slow
if TYPE_CHECKING:
import sympy
from torch.fx.experimental.symbolic_shapes import ShapeEnv
else:
ShapeEnv = Any
import torch
import torch.utils._pytree as pytree
from torch import fx
from torch._subclasses.meta_utils import is_sparse_any
from torch.fx._compatibility import compatibility
from torch.fx._utils import lazy_format_graph_code
from torch.fx.experimental.proxy_tensor import py_sym_types
from torch.fx.experimental.sym_node import SymNode
from torch.fx.graph_module import GraphModule
__all__ = ["insert_deferred_runtime_asserts"]
log = logging.getLogger(__name__)
graph_code_log = torch._logging.getArtifactLogger(__name__, "graph_code_verbose")
def _get_example_value(node: fx.Node) -> str | None:
"""
Get the example value key for a node, since dynamo uses "example_value"
while non-strict export uses "val.
"""
if "example_value" in node.meta:
return node.meta["example_value"]
elif "val" in node.meta:
return node.meta["val"]
else:
return None
def _get_sym_val(node: fx.Node) -> Optional["sympy.Expr"]:
val = _get_example_value(node)
if isinstance(val, py_sym_types):
return val.node.expr
return None
@compatibility(is_backward_compatible=True)
def insert_deferred_runtime_asserts(
gm: GraphModule,
shape_env: ShapeEnv,
name: str,
export: bool = False,
) -> None:
"""
During tracing, we may have discovered that some data-dependent values
had runtime assert on them; e.g., torch.empty(x.item()) induces a runtime
that x.item() >= 0. These asserts can happen unpredictably during fake
tensor propagation, so we cannot conveniently insert them into the FX graph
when they occur. Instead, we accumulate them in the ShapeEnv, and in this
pass insert them into the graph as proper tests.
This pass also deduplicates size-related computation, CSE-ing ops that produce
symbolic values and/or are involved in runtime asserts. Additionally, shape calls
(size/stride/storage_offset) are turned into compute on input sizes if possible,
allowing intermediate tensors to be freed earlier. For example, here dynamo will
DCE the cat and repeat calls:
z = torch.cat([x, x], dim=0) # 2*s0
w = z.repeat(y.shape[0]) # 2*s0*s1
_w = w.shape[0]
# something with _w, but not w ...
# turns into ->
_w0 = 2 * s0
_w = _w0 * s1
# where s0, s1 are either SymInt graph inputs, or the result of added size calls
Redundant torch._check or torch.ops.aten._assert_scalar.default calls that assert
the same expression, and redundant constrain_range calls are also deduplicated.
Additionally, because single-symbol bound checks (e.g. u0 >= 0, u0 <= 5) accumulate
information in the ShapeEnv, the ShapeEnv contains min/max bounds for each symbol,
and we delete all previous calls, adding bound checks at the end of this pass.
"""
# Import sympy locally
import sympy
from torch._export.passes._node_metadata_hook import _set_node_metadata_hook
from torch.fx.experimental.symbolic_shapes import (
_get_placeholder_expr,
_has_uninterpretable_sympy_function,
CallMethodKey,
ConvertIntKey,
DivideByKey,
free_symbols,
InnerTensorKey,
resolve_unbacked_bindings,
)
from torch.utils._sympy.numbers import int_oo
from torch.utils._sympy.reference import (
OptimizedPythonReferenceAnalysis,
PythonReferenceAnalysis,
)
from torch.utils._sympy.value_ranges import ValueRanges
# TODO: Request simplification on runtime asserts before emitting them
ras_by_symbol = shape_env.deferred_runtime_asserts.copy()
graph = gm.graph
tracer = fx.proxy.GraphAppendingTracer(graph)
graph_code_log.debug(
"%s",
lazy_format_graph_code(
f"pre insert_deferred_runtime_asserts {name}", gm, colored=True
),
)
# We are going to mutate the dict
expr_to_proxy: dict[sympy.Expr, fx.Proxy] = {}
placeholders = set()
first_non_placeholder = None
for node in graph.nodes:
if node.op != "placeholder":
first_non_placeholder = node
break
else:
placeholders.add(node)
def _is_intermediate_tensor_sym_call(node: fx.Node) -> bool:
"""
If a size/stride/storage offset call on an intermediate tensor,
we can try to compute the value from input shapes instead.
"""
return (
(val := _get_sym_val(node)) is not None
and not isinstance(val, sympy.Number)
# this holds back from reifying anything in torch.utils._sympy.functions.py that's unsupported
and not _has_uninterpretable_sympy_function(val)
and any(
isinstance(arg, fx.Node)
and isinstance(_get_example_value(arg), (torch.Tensor, torch.Size))
and arg.op != "placeholder"
for arg in node.args
)
)
# Figure out what key to use, val or example_value
val_key = "val"
for node in graph.nodes:
if "example_value" in node.meta:
val_key = "example_value"
break
elif "val" in node.meta:
break
# Note: DO NOT register one _set_node_metadata_hook(_node_metadata_hook)
# for each nodes in the graph.
# _set_node_metadata_hook is expensive and this can cause compile
# time to regress significantly.
def _node_metadata_hook(
node: torch.fx.Node,
stack_trace: str | None = None,
nn_module_stack: dict[str, Any] | None = None,
custom: dict[str, Any] | None = None,
skip_val: bool = False,
) -> None:
if not skip_val:
fake_args = pytree.tree_map(
lambda arg: (
_get_example_value(arg) if isinstance(arg, torch.fx.Node) else arg
),
node.args,
)
try:
target = node.target
if node.op == "call_method":
if not isinstance(node.target, str):
raise AssertionError(
f"Expected str target, got {type(node.target)}"
)
target = getattr(fake_args[0], node.target)
fake_args = fake_args[1:]
node.meta[val_key] = target(*fake_args) # type: ignore[operator]
except NotImplementedError:
# This can happen when attempting to reify a symbol with an unsupported call_function node,
# e.g. with NestedTensors + sym_size.int via match_symbol().
# This seems to be fine, as the node gets CSE'd and deleted later in favor of a SymInt graph input.
pass
if stack_trace is not None:
node.meta["stack_trace"] = stack_trace
if nn_module_stack is not None:
node.meta["nn_module_stack"] = nn_module_stack
if custom is not None:
node.meta["custom"] = custom
# Track asserts/checks we've added
added_asserts: set[sympy.Expr] = set()
constrained_unbacked_symbols: set[sympy.Symbol] = set()
Analysis = PythonReferenceAnalysis if export else OptimizedPythonReferenceAnalysis
def _sympy_interp(expr_to_proxy, expr):
# sympy_interp() with hash consing
from sympy import Integer, Number, Symbol
from sympy.logic.boolalg import BooleanAtom
from torch.utils._sympy.interp import _run_sympy_handler, sympy_interp
# hash cons
if expr in expr_to_proxy:
return expr_to_proxy[expr]
# base cases, don't cache
if isinstance(expr, (Integer, Number, Symbol, BooleanAtom)):
return sympy_interp(Analysis, expr_to_proxy, expr)
# hash cons on arguments, run expr handler
expr_to_proxy[expr] = _run_sympy_handler(
Analysis,
[_sympy_interp(expr_to_proxy, arg) for arg in expr.args],
expr,
)
return expr_to_proxy[expr]
def _is_bound_expr_for_symbol(expr: "sympy.Expr") -> bool:
# This is probably unnecessary, but since torch._check() calls for single-symbol bounds
# like u0 >= 0, 10 >= u0 accumulate range info in the ShapeEnv, we designate these calls as redundant
# and instead add 2 runtime asserts at the end of this pass, if the min/max bounds are non-trivial.
if len(expr.args) != 2 or expr.func not in (sympy.LessThan, sympy.GreaterThan):
return False
lhs, rhs = expr.args
return (isinstance(lhs, sympy.Symbol) and isinstance(rhs, sympy.Number)) or (
isinstance(rhs, sympy.Symbol) and isinstance(lhs, sympy.Number)
)
def add_runtime_asserts(ras):
for ra in ras:
if (
# redundant
ra.expr in added_asserts
# if we've already added a constrain_range call for this symbol,
# then single-symbol bound asserts like u0 >= 0, u0 <= 5 are redundant.
or (
len(ra.expr.free_symbols) == 1
and next(iter(ra.expr.free_symbols)) in constrained_unbacked_symbols
and _is_bound_expr_for_symbol(ra.expr)
)
# don't try to reify sympy functions we can't turn into FX nodes
or _has_uninterpretable_sympy_function(ra.expr)
):
continue
log.debug("inserting runtime assert %s", ra.expr)
# Need to process ALL free symbols, not just unbacked ones
fvs = free_symbols(ra.expr)
missing = fvs - expr_to_proxy.keys()
if missing:
i1 = min(missing, key=str)
# TODO: Remove relaxing assert on unbacked_symint https://github.com/pytorch/pytorch/issues/119689
# assert shape_env.is_unbacked_symint(i1), i1
ras_by_symbol.setdefault(i1, []).append(ra)
else:
# Convert the sympy expression into a sequence of FX
# nodes
with _set_node_metadata_hook(
gm,
functools.partial(
_node_metadata_hook,
stack_trace=node.meta.get("stack_trace"),
nn_module_stack=node.meta.get("nn_module_stack"),
# nodes added in `apply_runtime_assertion_pass` will have the same annotation
# as the input node to the assertion
custom=node.meta.get("custom"),
),
):
res = _sympy_interp(expr_to_proxy, ra.expr).node
graph.call_function(
torch.ops.aten._assert_scalar.default,
# TODO: use ra.msg here, but it's pretty
# useless right now
(
res,
f"Runtime assertion failed for expression {ra.expr} on node '{res}'",
),
)
added_asserts.add(ra.expr)
nodes = list(graph.nodes)
for i, node in enumerate(nodes[:-1]):
# Placeholders can match symbols, but when we destructure them
# with size we have to make sure we insert the nodes after all
# the placeholders
with graph.inserting_before(
nodes[i + 1] if node not in placeholders else first_non_placeholder
):
# Unfortunately, this logic still must remain because manual
# make_fx calls may not explicitly bind all symbolic ints as
# arguments to the function, so we must infer it from the other
# arguments
if (
node in placeholders
and (example_value := _get_example_value(node)) is not None
):
def match_symbol(symint, cb):
if (
isinstance(symint, torch.SymInt)
and isinstance(symint.node, SymNode)
and isinstance(
s := _get_placeholder_expr(symint.node), sympy.Symbol
)
and s not in expr_to_proxy
):
with _set_node_metadata_hook(
gm,
functools.partial(
_node_metadata_hook,
stack_trace=node.meta.get("stack_trace"),
nn_module_stack=node.meta.get("nn_module_stack"),
# nodes added in `apply_runtime_assertion_pass` will have the same annotation
# as the input node to the assertion
custom=node.meta.get("custom"),
),
):
expr_to_proxy[s] = fx.Proxy(cb(), tracer=tracer)
log.debug("expr_to_proxy[%s] = %s", s, expr_to_proxy[s])
match_symbol(example_value, lambda: node)
if isinstance(t := example_value, torch.Tensor):
for i, s in enumerate(t.size()):
match_symbol(
s,
lambda: graph.call_function(
torch.ops.aten.sym_size.int, (node, i)
),
)
if not is_sparse_any(t):
for i, s in enumerate(t.stride()):
match_symbol(
s,
lambda: graph.call_function(
torch.ops.aten.sym_stride.int, (node, i)
),
)
match_symbol(
t.storage_offset(),
lambda: graph.call_function(
torch.ops.aten.sym_storage_offset.default, (node,)
),
)
# Handle asserts that aren't associated with any symbol. This
# doesn't really have to be in the loop as it will only run once,
# it just needs to happen right after the placeholders.
# insert this after placeholders & added sym nodes, and before non-placeholders.
if node == first_non_placeholder:
add_runtime_asserts(ras_by_symbol.pop(None, [])) # type: ignore[call-overload]
# deduplicate asserts already present in graph, and remove trivial asserts
if node.target in (
torch._check,
torch.ops.aten._assert_scalar.default,
):
cond = node.args[0] if node.args else node.kwargs.get("cond")
if (
cond == True # noqa: E712
or (assert_expr := _get_sym_val(cond)) in expr_to_proxy
and assert_expr in added_asserts
):
arg = cond
gm.graph.erase_node(node)
if isinstance(arg, fx.Node) and not arg.users:
gm.graph.erase_node(arg)
else:
added_asserts.add(assert_expr) # type: ignore[arg-type]
# hash cons, replace function calls that return torch.SymInts with direct references to
# FX nodes built up to reify the sympy expression.
if (
node.op != "placeholder"
and (sym_expr := _get_sym_val(node)) is not None
):
# this guards against deleting calls like item() that produce new untracked symbols
def has_new_untracked_symbols():
# pyrefly: ignore [missing-attribute]
for symbol in sym_expr.free_symbols:
if symbol not in expr_to_proxy:
return True
return False
# this guards against deleting calls that produce unbacked bindings we haven't yet seen.
# in this case looking at sym_expr.free_symbols might not be enough, if the example value has a hint
# (is backed), but produces an unbacked symbol. In this case keep the node alive.
resolved_unbacked_bindings = resolve_unbacked_bindings(
shape_env, node.meta.get("unbacked_bindings", {})
)
def has_new_unbacked_bindings():
if resolved_unbacked_bindings is None:
raise AssertionError("resolved_unbacked_bindings is None")
for key in resolved_unbacked_bindings:
if key not in expr_to_proxy:
return True
return False
# maybe re-reify expression, replace current node
if (
sym_expr in expr_to_proxy
or ( # example value is redundant
_is_intermediate_tensor_sym_call(node)
# shape call on intermediate tensor, turn into computation on input shapes
and not has_new_untracked_symbols()
)
) and not has_new_unbacked_bindings():
if _is_intermediate_tensor_sym_call(
node
): # reify from input shapes
with _set_node_metadata_hook(
gm,
functools.partial(
_node_metadata_hook,
stack_trace=node.meta.get("stack_trace"),
nn_module_stack=node.meta.get("nn_module_stack"),
# nodes added in `apply_runtime_assertion_pass` will have the same annotation
# as the input node to the assertion
custom=node.meta.get("custom"),
),
):
expr_to_proxy[sym_expr] = _sympy_interp(
expr_to_proxy,
sym_expr,
) # type: ignore[arg-type]
# won't try DCE-ing tensor compute here
hash_node = expr_to_proxy[sym_expr].node # type: ignore[arg-type]
node.replace_all_uses_with(hash_node)
gm.graph.erase_node(node)
log.debug(
"CSE node %s -> %s for expr %s",
node,
hash_node,
sym_expr,
)
# store node in hash cons, don't delete/replace
elif sym_expr not in expr_to_proxy and not isinstance(
sym_expr,
(sympy.Number, sympy.logic.boolalg.BooleanAtom),
): # don't hash cons primitives
expr_to_proxy[sym_expr] = fx.Proxy(node, tracer=tracer) # type: ignore[arg-type]
# We add sym_constrain_range calls for symbols later in any case if they're size-like or range-constrained,
# so calls before that are redundant.
if node.target in (
torch.ops.aten.sym_constrain_range.default,
torch.ops.aten.sym_constrain_range_for_size.default,
):
gm.graph.erase_node(node)
defs = []
# AOTAutograd will create new symbols as the unbacked_bindings keys, which PropagateSymInts will set as
# equivalent, but the refinement calls we perform in this pass may struggle with associating the two.
# More concretely, when re-exporting/tracing, constraining only the new symbol may not communicate enough
# information about the old symbol when we re-export, raising errors on data-dependent guards.
# Call resolve_unbacked_bindings() to get the original symbol if present, otherwise we take it as is.
if unbacked_bindings := resolve_unbacked_bindings(
shape_env, node.meta.get("unbacked_bindings")
):
for s, keypath in unbacked_bindings.items():
defs.append(s)
# TODO: some CSE when generating these nodes can probably
# help reduce graph size and improve compile time
def go(node, keypath):
if keypath == ():
return node
if (
len(keypath) >= 2
and isinstance(keypath[0], CallMethodKey)
and isinstance(keypath[1], pytree.SequenceKey)
):
if keypath[0].name == "size":
return go(
graph.call_function(
torch.ops.aten.sym_size.int,
(node, keypath[1].idx),
),
keypath[2:],
)
if keypath[0].name == "stride":
return go(
graph.call_function(
torch.ops.aten.sym_stride.int,
(node, keypath[1].idx),
),
keypath[2:],
)
return go(
graph.call_method(
keypath[0].name, (node, keypath[1].idx)
),
keypath[2:],
)
elif isinstance(keypath[0], CallMethodKey):
if keypath[0].name == "storage_offset":
return go(
graph.call_function(
torch.ops.aten.sym_storage_offset.default,
(node,),
),
keypath[1:],
)
return go(
graph.call_method(keypath[0].name, (node,)), keypath[1:]
)
elif isinstance(keypath[0], pytree.SequenceKey):
return go(
graph.call_function(
operator.getitem, (node, keypath[0].idx)
),
keypath[1:],
)
elif isinstance(keypath[0], ConvertIntKey):
return go(
graph.call_function(torch.sym_ite, (node, 1, 0)),
keypath[1:],
)
elif isinstance(keypath[0], DivideByKey):
# TODO: need to assert divisibility
return go(
graph.call_function(
operator.floordiv, (node, keypath[0].divisor)
),
keypath[1:],
)
elif isinstance(keypath[0], InnerTensorKey):
return go(
graph.call_function(
getattr, (node, keypath[0].inner_name)
),
keypath[1:],
)
else:
raise AssertionError(f"unrecognized keypath {keypath}")
if s not in expr_to_proxy:
with _set_node_metadata_hook(
gm,
functools.partial(
_node_metadata_hook,
stack_trace=node.meta.get("stack_trace"),
nn_module_stack=node.meta.get("nn_module_stack"),
# nodes added in `apply_runtime_assertion_pass` will have the same annotation
# as the input node to the assertion
custom=node.meta.get("custom"),
),
):
expr_to_proxy[s] = fx.Proxy(
go(node, keypath), tracer=tracer
)
log.debug("expr_to_proxy[%s] = %s", s, expr_to_proxy[s])
for i0 in defs:
ras = ras_by_symbol.pop(i0, [])
# Before we perform any asserts, first apply range
# refinement. This is important, because if we are going
# to retrace the graph (and we typically are if we send
# the graph to AOTAutograd), we need to make sure we apply
# range refinement (ala _check_is_size) first, BEFORE we
# run any of the asserts. Otherwise, we may decide to
# perform substitutions based on the asserts which we then
# can't back out, because value ranges can only be applied
# to asserts.)
#
# A perhaps better long term plan is to avoid this order
# dependence by making it possible to refine ranges on
# arbitrary expressions, not just symbols. But it is not
# so easy to make use of this information, see
# https://twitter.com/ezyang/status/1745801370299482492
# We actually made an attempt at this in
# https://github.com/pytorch/pytorch/pull/119043
# which didn't work.
#
# Another ideas for how to do this:
# - Have bound_sympy be the source of truth of the ranges of any expression
# - Cache intermediate results for every subexpression of bound_sympy
# - This cache should be possible to edit to refine ranges
#
# One issue with this proposal is that if
# we have a bound on 2x, we are not going to be able to
# apply it for 4x. Similarly, we may have bounds for an
# equivalent expression that we are not applying because
# it's not a perfect match (e.g. x < y vs y > x)".
#
# The first issue we already have it and it's impossible
# to solve in general, so any implementation on a best
# effort basis should do.
#
# The second issue is a preexisting one. It can be mitigated
# with a normalization algorithm. In general, it may also
# be on a best effort basis, but since our grammar is not
# terribly difficult, chances are we could even fully
# normalize SymPy expressions... who knows.
if i0 in constrained_unbacked_symbols:
continue # constrain symbol just once
vr = shape_env.var_to_range[i0]
if vr.is_int and vr.upper == sys.maxsize - 1:
# treat upper bound == sys.maxsize - 1 for int symbols as +oo
# to avoid redundant runtime assert
vr = ValueRanges(vr.lower, int_oo)
if not shape_env._default_unspecified_value_range().issubset(vr):
# The runtime range is constrained, so add a runtime
# assert and also explicitly refine the range
# (refinement should not be necessary once runtime
# asserts cause refinement, but that's NYI)
def convert(s):
if s in (int_oo, -int_oo):
return None
try:
return int(s)
except TypeError:
return None
with _set_node_metadata_hook(
gm,
functools.partial(
_node_metadata_hook,
stack_trace=node.meta.get("stack_trace"),
nn_module_stack=node.meta.get("nn_module_stack"),
# nodes added in `apply_runtime_assertion_pass` will have the same annotation
# as the input node to the assertion
custom=node.meta.get("custom"),
),
):
if (min_val := convert(vr.lower)) is not None:
ge = _sympy_interp(expr_to_proxy, i0 >= min_val).node
graph.call_function(
torch.ops.aten._assert_scalar.default,
(
ge,
f"Runtime assertion failed for expression {i0 >= min_val} on node '{ge}'",
),
)
added_asserts.add(i0 >= min_val)
if (max_val := convert(vr.upper)) is not None:
le = _sympy_interp(expr_to_proxy, i0 <= max_val).node
graph.call_function(
torch.ops.aten._assert_scalar.default,
(
le,
f"Runtime assertion failed for expression {i0 <= max_val} on node '{le}'",
),
)
added_asserts.add(i0 <= max_val)
constrained_unbacked_symbols.add(i0)
add_runtime_asserts(ras)
# delete unused reified symbols
for expr, proxy in expr_to_proxy.items():
if (
isinstance(expr, sympy.Symbol)
and proxy.node.op != "placeholder" # keep placeholders intact
and not proxy.node.users
):
log.debug("deleting unused reified symbol for %s", expr)
gm.graph.erase_node(proxy.node)
@@ -0,0 +1,230 @@
# mypy: ignore-errors
import traceback
from typing import Any, NamedTuple
import torch
import torch.fx
from torch._dispatch.python import enable_python_dispatcher
from torch._guards import detect_fake_mode
from torch._prims_common import is_contiguous_for_memory_format_or_false
from torch._subclasses.meta_utils import is_sparse_any
from torch.fx._compatibility import compatibility
from torch.fx.node import map_aggregate, Node
__all__ = ["TensorMetadata", "ShapeProp"]
@compatibility(is_backward_compatible=True)
class TensorMetadata(NamedTuple):
# TensorMetadata is a structure containing pertinent information
# about a tensor within a PyTorch program.
# General Tensor metadata
shape: torch.Size
dtype: torch.dtype
requires_grad: bool
stride: tuple[int, ...]
memory_format: torch.memory_format | None
# Quantization metadata
is_quantized: bool
qparams: dict[str, Any]
# When include_contiguity is True, we will set contiguity when its always true for the tensor.
# Some tensors can represent both contiguous and non-contiguous tensors. e.g: (u0, u1) with (u2, u3).
# In such situation contiguity is not set. We could also make it a tri-state i.e: (def_contiguous,
# def_not_contiguous and unknown).
def _extract_tensor_metadata(
result: torch.Tensor, include_contiguity=True
) -> TensorMetadata:
"""
Extract a TensorMetadata NamedTuple describing `result`.
"""
shape = result.shape
dtype = result.dtype
requires_grad = result.requires_grad
stride = result.stride() if not is_sparse_any(result) else ()
memory_format = None
if include_contiguity and not is_sparse_any(result):
memory_formats = (
torch.contiguous_format,
torch.channels_last,
torch.channels_last_3d,
)
for query_format in memory_formats:
if is_contiguous_for_memory_format_or_false(
result, memory_format=query_format
):
memory_format = query_format
break
is_quantized = result.is_quantized
qparams: dict[str, Any] = {}
if is_quantized:
qscheme = result.qscheme()
qparams["qscheme"] = qscheme
if qscheme in (torch.per_tensor_affine, torch.per_tensor_symmetric):
qparams["scale"] = result.q_scale() # type: ignore[assignment]
qparams["zero_point"] = result.q_zero_point() # type: ignore[assignment]
elif qscheme in (
torch.per_channel_affine,
torch.per_channel_affine_float_qparams,
torch.per_channel_symmetric,
):
# In this branch, scale and zero_point are expected to be tensors,
# we store the values as immutable_list in TensorMetadata for
# easier serialization downstream
qparams["scale"] = result.q_per_channel_scales().tolist() # type: ignore[assignment]
qparams["zero_point"] = result.q_per_channel_zero_points().tolist() # type: ignore[assignment]
qparams["axis"] = result.q_per_channel_axis() # type: ignore[assignment]
return TensorMetadata(
shape, dtype, requires_grad, stride, memory_format, is_quantized, qparams
)
@compatibility(is_backward_compatible=True)
class ShapeProp(torch.fx.Interpreter):
"""
Execute an FX graph Node-by-Node and
record the shape and type of the result
into the corresponding node.
Example:
In this example, we record the shape
and data type of a module given
an example input ``torch.randn(50, D_in)``.
We print the name, shape and dtype of each node.
class TwoLayerNet(torch.nn.Module):
def __init__(self, D_in, H, D_out):
super().__init__()
self.linear1 = torch.nn.Linear(D_in, H)
self.linear2 = torch.nn.Linear(H, D_out)
def forward(self, x):
h_relu = self.linear1(x).clamp(min=0)
y_pred = self.linear2(h_relu)
return y_pred
N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
model = TwoLayerNet(D_in, H, D_out)
gm = torch.fx.symbolic_trace(model)
sample_input = torch.randn(50, D_in)
ShapeProp(gm).propagate(sample_input)
for node in gm.graph.nodes:
print(node.name, node.meta['tensor_meta'].dtype,
node.meta['tensor_meta'].shape)
The output of this code is:
x torch.float32 torch.Size([50, 1000])
linear1 torch.float32 torch.Size([50, 100])
clamp_1 torch.float32 torch.Size([50, 100])
linear2 torch.float32 torch.Size([50, 10])
output torch.float32 torch.Size([50, 10])
Args:
module (GraphModule): The module to be executed
fake_mode (FakeTensorMode): A fake mode for copying the gm
"""
def __init__(self, gm, fake_mode=None):
super().__init__(gm)
if fake_mode is None:
fake_mode = detect_fake_mode()
if fake_mode is not None:
from torch._dynamo.utils import deepcopy_to_fake_tensor
# Note:
# We need fake execution cause the inputs are fake, however, we cannot fakify the module
# - because we need to write to the tensor_meta of the real module. So we fakify to
# produce a result (L131 below), to extract tensor meta, and then keep going.
#
# If we were to fakify, we would write to the wrong node, and then downstream fusion
# would be missing the tensor_meta.
#
# See torch/_inductor/overrides.py for where this is called upstream of fusion.
self.fake_module = deepcopy_to_fake_tensor(self.module, fake_mode)
self.fake_mode = fake_mode
else:
self.fake_module = None
self.fake_mode = None
self.real_module = self.module
def run_node(self, n: Node) -> Any:
from torch.fx.experimental.symbolic_shapes import (
compute_unbacked_bindings,
rebind_unbacked,
)
try:
if self.fake_module is not None:
# Hacky swap. Alternatively, we could do this with overriding
# call_module and get_attr.
self.module = self.fake_module
try:
if self.fake_mode is not None:
with self.fake_mode, enable_python_dispatcher():
result = super().run_node(n)
rebind_unbacked(self.fake_mode.shape_env, n, result)
else:
result = super().run_node(n)
finally:
self.module = self.real_module
except Exception as e:
traceback.print_exc()
raise RuntimeError(
f"ShapeProp error for: node={n.format_node()} with meta={n.meta}"
) from e
found_tensor = False
def extract_tensor_meta(obj):
if isinstance(obj, torch.Tensor):
nonlocal found_tensor
found_tensor = True
return _extract_tensor_metadata(obj)
else:
return obj
meta = map_aggregate(result, extract_tensor_meta)
if found_tensor:
n.meta["tensor_meta"] = meta
if self.fake_mode:
if (shape_env := self.fake_mode.shape_env) and (
symbol_to_path := compute_unbacked_bindings(shape_env, result)
):
n.meta["unbacked_bindings"] = symbol_to_path
n.meta["type"] = type(result)
return result
def propagate(self, *args):
"""
Run `module` via interpretation and return the result and
record the shape and type of each node.
Args:
*args (Tensor): the sample input.
Returns:
Any: The value returned from executing the Module
"""
if self.fake_mode is not None:
fake_args = [
self.fake_mode.from_tensor(t) if isinstance(t, torch.Tensor) else t
for t in args
]
else:
fake_args = args
return super().run(*fake_args)
@@ -0,0 +1,682 @@
# mypy: allow-untyped-defs
import inspect
import logging
from collections import OrderedDict
from collections.abc import Callable
from typing import Any
import torch
from torch.fx._compatibility import compatibility
from torch.fx._lazy_graph_module import _make_graph_module
from torch.fx._utils import lazy_format_graph_code
from torch.fx.graph_module import GraphModule
from torch.fx.node import Node
__all__ = ["Partition", "split_module"]
log = _LOGGER = logging.getLogger(__name__)
@compatibility(is_backward_compatible=True)
class Partition:
def __init__(self, name: str):
self.name: str = name
self.submod_name = f"submod_{name}"
self.node_names: list[str] = []
self.inputs: dict[str, None] = {}
self.outputs: dict[str, None] = {}
self.dependencies: dict[str, None] = {}
self.dependents: dict[str, None] = {}
self.graph: torch.fx.graph.Graph = torch.fx.graph.Graph()
self.environment: dict[Node, Node] = {}
self.targets: dict[str, Any] = {}
def __repr__(self) -> str:
return (
f"name: {self.name},\n"
f" nodes: {self.node_names},\n"
f" inputs: {self.inputs},\n"
f" outputs: {self.outputs},\n"
f" partitions depended on: {self.dependencies},\n"
f" partition dependents: {self.dependents}"
)
def _get_attr_from_qualname(mod: torch.nn.Module, qualname: str) -> Any:
attr_val = mod
for atom in qualname.split("."): # type: ignore[union-attr]
if not hasattr(attr_val, atom):
raise AttributeError(f"Node target {qualname} not found!")
attr_val = getattr(attr_val, atom)
return attr_val
# Creates subgraphs out of main graph
@compatibility(is_backward_compatible=True)
def split_module(
m: GraphModule,
root_m: torch.nn.Module,
split_callback: Callable[[Node], int],
qualname_map: dict[str, str] | None = None,
keep_original_order: bool | None = False,
keep_original_node_name: bool | None = False,
keep_original_input_name: bool = True,
*,
partition_affix: str | None = None,
tuple_return: bool = False,
):
"""
Creates subgraphs out of main graph
Args:
m (GraphModule): Graph module to split
root_m (torch.nn.Module): root nn module. Not currently used. Included
because the root nn module is usually transformed via
torch.fx._symbolic_trace.symbolic_trace (see example below)
split_callback (Callable[[Node], int]): Callable function
that maps a given Node instance to a numeric partition identifier.
split_module will use this function as the policy for which operations
appear in which partitions in the output Module.
qualname_map: Optional[Dict[str, str]]: optional output parameter that returns a
mapping from new target names in the module after split to old target
names in the original module.
keep_original_order: Optional[bool]: keep the original order of the GraphModule
or use the Topological order of the new constructed GraphModule
keep_original_node_name: Optional[bool]: If the partitioned graphs should
have the same node names as the original graph.
keep_original_input_name: bool: If the partitioned graphs should
have the same input names as the original graph.
partition_affix: Optional[str]: If specified, the submodules' names will contain
the affix, e.g. "submod_<affix>_<idx>".
tuple_return: bool: If True, submodule outputs are always wrapped in a tuple,
even when there is only a single output value. This makes all subgraphs
conform to the convention expected by ``torch._inductor.compile_fx``.
Returns:
GraphModule: the module after split.
Example:
This is a sample setup:
import torch
from torch.fx._symbolic_trace import symbolic_trace
from torch.fx.graph_module import GraphModule
from torch.fx.node import Node
from torch.fx.passes.split_module import split_module
class MyModule(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.param = torch.nn.Parameter(torch.rand(3, 4))
self.linear = torch.nn.Linear(4, 5)
def forward(self, x, y):
z = self.linear(x + self.param).clamp(min=0.0, max=1.0)
w = self.linear(y).clamp(min=0.0, max=1.0)
return z + w
# symbolically trace model
my_module = MyModule()
my_module_traced = symbolic_trace(my_module)
# random mod partitioning
partition_counter = 0
NPARTITIONS = 3
def mod_partition(node: Node):
global partition_counter
partition = partition_counter % NPARTITIONS
partition_counter = (partition_counter + 1) % NPARTITIONS
return partition
# split module in module with submodules
module_with_submodules = split_module(
my_module_traced, my_module, mod_partition
)
Output looks like this. Original graph is broken into partitions
> print(module_with_submodules)
GraphModule(
(submod_0): GraphModule(
(linear): Linear(in_features=4, out_features=5, bias=True)
)
(submod_1): GraphModule(
(linear): Linear(in_features=4, out_features=5, bias=True)
)
(submod_2): GraphModule()
)
def forward(self, x, y):
param = self.param
submod_0 = self.submod_0(x, param, y); x = param = y = None
getitem = submod_0[0]
getitem_1 = submod_0[1]; submod_0 = None
submod_1 = self.submod_1(getitem, getitem_1); getitem = getitem_1 = None
getitem_2 = submod_1[0]
getitem_3 = submod_1[1]; submod_1 = None
submod_2 = self.submod_2(getitem_2, getitem_3); getitem_2 = getitem_3 = None
return submod_2
Output of split module is the same as output of input traced module.
This is an example within a test setting:
> orig_out = my_module_traced(x, y)
> submodules_out = module_with_submodules(x, y)
> self.assertEqual(orig_out, submodules_out)
True
"""
log.debug(
"%s",
lazy_format_graph_code("pre split_module", m, colored=True),
)
def construct_graph(
node: Node,
base_mod_env: dict[str, Node],
base_mod_attrs: dict[str, torch.fx.graph_module.GraphModule],
):
if node.op == "placeholder":
default_value = (
node.args[0] if len(node.args) > 0 else inspect.Signature.empty
)
if keep_original_node_name:
args = (
() if default_value is inspect.Signature.empty else (default_value,)
)
base_mod_env[node.name] = base_mod_graph.create_node(
"placeholder",
node.name,
args=args, # type: ignore[arg-type]
type_expr=node.type,
)
else:
base_mod_env[node.name] = base_mod_graph.placeholder(
node.target, # type: ignore[arg-type]
type_expr=node.type,
default_value=default_value,
)
base_mod_env[node.name].meta = node.meta.copy()
elif node.op == "get_attr":
base_mod_env[node.name] = base_mod_graph.get_attr(node.target) # type: ignore[arg-type]
base_mod_env[node.name].meta = node.meta.copy()
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
attr_val = _get_attr_from_qualname(m, node.target)
base_mod_attrs[node.target] = attr_val # type: ignore[index]
return base_mod_env, base_mod_attrs
import sympy
partitions: dict[str, Partition] = {}
orig_nodes: dict[str, Node] = {}
symbol_to_node: dict[sympy.Symbol, Node] = {}
def record_cross_partition_use(def_node: Node, use_node: Node | None):
from torch.fx.experimental.symbolic_shapes import free_symbols
defined = getattr(def_node, "_fx_partition", None)
used = getattr(use_node, "_fx_partition", None)
log.debug(
"record_cross_partition_use %s (%s) %s (%s)",
def_node.name,
defined,
use_node.name if use_node is not None else "-",
used,
)
if defined != used:
if defined is not None:
def_partition = partitions[defined]
def_partition.outputs.setdefault(def_node.name)
if used is not None:
def_partition.dependents.setdefault(used)
if used is not None:
use_partition = partitions[used]
use_partition.inputs.setdefault(def_node.name)
# We have made def_node an input to the use_partition. If
# this input has symbolic symbols in its size, those also must
# be made as inputs to the partition
if (def_val := def_node.meta.get("example_value")) is not None:
for s in sorted(free_symbols(def_val), key=str):
s_node = symbol_to_node[s]
use_partition.inputs.setdefault(s_node.name)
if symbol_to_node[s].op != "placeholder":
# If the node that defines the symbol is not a
# placeholder, we must make it an output of the
# partition. Note that this may be in a different
# partition than defined! Although, this doesn't
# really make a difference for correctness, since
# defined is guaranteed to have the symbol in
# scope and can return it; you just get less
# optimal codegen in this case.
s_defined = getattr(s_node, "_fx_partition", None)
if s_defined is not None:
s_def_partition = partitions[s_defined]
s_def_partition.outputs.setdefault(s_node.name)
s_def_partition.dependents.setdefault(used)
use_partition.dependencies.setdefault(s_defined)
if defined is not None:
use_partition.dependencies.setdefault(defined)
def instantiate_node_partition_mapping(node):
partition_idx = split_callback(node)
partition_name = str(partition_idx)
if partition_affix is not None:
# For example, if user specifies partition_affix = "pp", then the
# partition name will be "pp_0", "pp_1", etc
partition_name = "_".join([partition_affix, partition_name])
log.debug(
"instantiate_node_partition_mapping %s (%s)", node.name, partition_name
)
# add node to partitions
partition = partitions.get(partition_name)
if partition is None:
partitions[partition_name] = partition = Partition(partition_name)
partition.node_names.append(node.name)
node._fx_partition = partition_name
# Global State Nodes are nodes which by their global state effects,
# "taint" all downstream nodes while they are active.
GLOBAL_STATE_NODES = [
torch.amp._enter_autocast,
torch.amp._exit_autocast,
torch._C._set_grad_enabled,
]
# For grad regions:
# ------------------------
# 1. first region: we do nothing
# 2. subsequent regions: we insert the set_grad at the beginning
grad_regions: OrderedDict[Node, set[int]] = OrderedDict()
# For autocast regions:
# ------------------------
# 1. first region: we will only insert the _exit at the end
# 2. intermediate regions: we will insert both the
# _enter at the beginning and _exit at the end
# 3. last region: we will only insert _enter at the beginning
# We will do so in the order in which the autocasts were instantiated.
autocast_regions: OrderedDict[Node, set[int]] = OrderedDict()
autocast_exits: dict[Node, Node | None] = {}
active_grad = None
active_autocasts = set()
for node in m.graph.nodes:
# This will prefer placeholder bindings, because those come first.
# This is a little dangerous though: it is possible that an unbacked
# symbol is used without any binding site for it, in which case we
# will get a KeyError not able to find it. I'd like to fix this by
# having passes.runtime_assert establish some invariants that I can
# rely on later, but this needs some extra work. Quick fix first.
# See https://github.com/pytorch/pytorch/issues/130534
if (
(val := node.meta.get("example_value")) is not None
and isinstance(val, (torch.SymInt, torch.SymFloat))
and isinstance(s0 := val.node.expr, sympy.Symbol)
and s0 not in symbol_to_node
):
symbol_to_node[val.node.expr] = node
if node.op in ["placeholder", "get_attr", "output"]:
continue
instantiate_node_partition_mapping(node)
if node.op == "call_function" and node.target in GLOBAL_STATE_NODES:
if node.target is torch._C._set_grad_enabled:
if len(node.args) != 1:
raise AssertionError(
f"Expected 1 arg for _set_grad_enabled, got {len(node.args)}"
)
if not isinstance(node.args[0], bool):
raise AssertionError(f"Expected bool arg, got {type(node.args[0])}")
active_grad = node
grad_regions[active_grad] = set({split_callback(node)})
elif node.target is torch.amp._enter_autocast:
# Should all be python constants
if not all(not isinstance(arg, Node) for arg in node.args):
raise AssertionError(
"Expected all args to be python constants, not Nodes"
)
active_autocasts.add(node)
autocast_regions[node] = set({split_callback(node)})
autocast_exits[node] = None
elif node.target is torch.amp._exit_autocast:
if len(node.args) != 1:
raise AssertionError(
f"Expected 1 arg for _exit_autocast, got {len(node.args)}"
)
autocast_regions[node.args[0]].add(split_callback(node))
active_autocasts.remove(node.args[0])
autocast_exits[node.args[0]] = node
if active_grad is not None:
grad_regions[active_grad].add(split_callback(node))
for a in active_autocasts:
autocast_regions[a].add(split_callback(node))
if not all(v is not None for v in autocast_exits.values()):
raise AssertionError("autocast must exit")
# pyrefly: ignore [bad-assignment]
autocast_regions = {k: sorted(v) for k, v in autocast_regions.items()}
# pyrefly: ignore [bad-assignment]
grad_regions = {k: sorted(v) for k, v in grad_regions.items()}
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("autocast_regions: %s", autocast_regions)
_LOGGER.debug("grad_regions: %s", grad_regions)
assert_monotonically_increasing = bool(autocast_regions) or bool(grad_regions)
# split nodes into partitions
highest_partition = -1
for node in m.graph.nodes:
orig_nodes[node.name] = node
# TODO currently placeholders/parameters aren't put into random partitions,
# rather they're added to the graphs where they are used down below
if node.op in ["placeholder", "get_attr"]:
continue
if node.op == "output":
torch.fx.graph.map_arg(
node.args[0], lambda n: record_cross_partition_use(n, None)
)
continue
if assert_monotonically_increasing:
pid = split_callback(node)
if highest_partition > pid:
raise AssertionError(
"autocast or set_grad_enabled require monotonically increasing "
f"partitions: highest: {highest_partition}, this node's: {pid}"
)
highest_partition = pid
# do not capture cross-partition dependencies for global state nodes as they will be
# self-contained - their setup and unwind will be isolated to each partition submodule.
if node.target not in GLOBAL_STATE_NODES:
torch.fx.graph.map_arg(
node.args, lambda def_node: record_cross_partition_use(def_node, node)
)
torch.fx.graph.map_arg(
node.kwargs, lambda def_node: record_cross_partition_use(def_node, node)
) # noqa: B950
original_partition_order = list(partitions.keys())
# find partitions with no dependencies
root_partitions: list[str] = []
for partition_name, partition in partitions.items():
if not len(partition.dependencies):
root_partitions.append(partition_name)
# check partitions for circular dependencies and create topological partition ordering
sorted_partitions: list[str] = []
while root_partitions:
root_partition = root_partitions.pop()
sorted_partitions.append(root_partition)
for dependent in partitions[root_partition].dependents:
partitions[dependent].dependencies.pop(root_partition) # noqa: B909
if not partitions[dependent].dependencies:
root_partitions.append(dependent)
if len(sorted_partitions) != len(partitions):
raise RuntimeError("cycle exists between partitions!")
# Enter prelude
for regions_mapping in [autocast_regions, grad_regions]:
for node, regions in regions_mapping.items():
if len(regions) == 0:
raise AssertionError("Expected at least one region for node")
# pyrefly: ignore [bad-index]
partitions[str(regions[0])].environment[node] = node
# pyrefly: ignore [bad-index, index-error]
# pyrefly: ignore [bad-index, index-error]
for r in regions[1:]:
partition = partitions[str(r)]
new_node = partition.graph.create_node(
op=node.op,
target=node.target,
args=tuple(arg for arg in node.args),
kwargs={},
type_expr=node.type,
)
new_node.meta = (
node.meta.copy()
) # is it really a good idea to copy this?
partition.environment[node] = new_node
# add placeholders to partition inputs
for partition_name in sorted_partitions:
partition = partitions[partition_name]
new_inputs: dict[str, None] = {}
counter = 0
for inp in partition.inputs:
orig_node = orig_nodes[inp]
# We don't pass in get_attr nodes as inputs to the partition, but
# instead set them as targets and use getattr within the module
def add_placeholder():
if keep_original_input_name:
name = inp
else:
nonlocal counter
name = f"arg_{counter}"
counter += 1
placeholder = partition.graph.placeholder(
name,
type_expr=orig_nodes[inp].type,
)
new_inputs[inp] = None
return placeholder
if orig_node.op == "get_attr":
if not isinstance(orig_node.target, str):
raise AssertionError(
f"Expected str target, got {type(orig_node.target)}"
)
orig_attr = _get_attr_from_qualname(m, orig_node.target)
if isinstance(orig_attr, torch.nn.Module):
placeholder = partition.graph.get_attr(orig_node.target)
partition.targets[orig_node.target] = orig_attr
else:
placeholder = add_placeholder()
else:
placeholder = add_placeholder()
placeholder.meta = orig_nodes[inp].meta.copy()
partition.environment[orig_nodes[inp]] = placeholder
partition.inputs = new_inputs
# Transform nodes and collect targets for partition's submodule
for node in m.graph.nodes:
if hasattr(node, "_fx_partition"):
partition = partitions[node._fx_partition]
# swap out old graph nodes in kw/args with references to new nodes in this submodule
environment = partition.environment
gathered_args = torch.fx.graph.map_arg(node.args, lambda n: environment[n])
gathered_kwargs = torch.fx.graph.map_arg(
node.kwargs, lambda n: environment[n]
)
if node.op not in ["call_module", "get_attr"]:
target = node.target
else:
target_attr = _get_attr_from_qualname(m, node.target)
target = node.target.replace(".", "_")
partition.targets[target] = target_attr
# Fill in the passed-in mapping from new qualname to old qualname
if qualname_map is not None:
# When creating the split module later, the submodules will have
# path prefix matching the corresponding partition's submod_name
qualname = f"{partition.submod_name}.{target}"
qualname_map[qualname] = node.target
if not isinstance(gathered_args, tuple):
raise AssertionError(
f"Expected tuple for gathered_args, got {type(gathered_args)}"
)
if not isinstance(gathered_kwargs, dict):
raise AssertionError(
f"Expected dict for gathered_kwargs, got {type(gathered_kwargs)}"
)
name = node.name if keep_original_node_name else None
new_node = partition.graph.create_node(
op=node.op,
target=target,
args=gathered_args,
kwargs=gathered_kwargs,
type_expr=node.type,
name=name,
)
new_node.meta = node.meta.copy()
partition.environment[node] = new_node
# Exit epilogue
for regions_mapping in [autocast_regions]:
for node in reversed(regions_mapping):
regions = regions_mapping[node]
if len(regions) == 0:
raise AssertionError("Expected at least one region")
# pyrefly: ignore [bad-index, index-error]
for r in regions[:-1]:
partition = partitions[str(r)]
exit_node = autocast_exits[node]
if exit_node is None:
raise AssertionError("Missing exit node")
new_node = partition.graph.create_node(
op=exit_node.op,
target=exit_node.target,
args=(partition.environment[node],),
kwargs={},
type_expr=exit_node.type,
)
new_node.meta = (
exit_node.meta.copy()
) # is it really a good idea to copy this?
# original module environment dict mapping node names to nodes
orig_mod_env: dict[str, Node] = {}
# Set up values to construct base module
base_mod_env: dict[str, Node] = {}
base_mod_graph: torch.fx.graph.Graph = torch.fx.graph.Graph()
base_mod_attrs: dict[str, torch.fx.graph_module.GraphModule] = {}
if not keep_original_order:
for node in m.graph.nodes:
base_mod_env, base_mod_attrs = construct_graph(
node, base_mod_env, base_mod_attrs
)
else:
# Go through the graph to construct the mapping dict
for node in m.graph.nodes:
orig_mod_env[node.name] = node
# Do some things iterating over the partitions in topological order again:
# 1) Finish off submodule Graphs by setting corresponding outputs
# 2) Construct GraphModules for each submodule
# 3) Construct the base graph by emitting calls to those submodules in
# topological order or original order specified by keep_original_order
construct_order_partitions = (
sorted_partitions if not keep_original_order else original_partition_order
)
already_constructed_attr_nodes = set()
# We actually need to insert the placeholder nodes in the original order
# otherwise graph signature will be wrong.
original_order = [node for node in m.graph.nodes if node.op == "placeholder"]
for partition_name in construct_order_partitions:
partition = partitions[partition_name]
# Set correct output values
output_vals = tuple(
partition.environment[orig_nodes[name]] for name in partition.outputs
)
if len(output_vals) == 1 and not tuple_return:
partition.graph.output(output_vals[0])
else:
partition.graph.output(output_vals)
if keep_original_order:
# first get the attr nodes required by this partition
orig_mod_attr_nodes: list[Node] = [
orig_mod_env[key]
for key in partition.inputs
if key not in original_order
]
for node in original_order:
if node in already_constructed_attr_nodes:
continue # already added this attr to the base graph
base_mod_env, _based_mod_attrs = construct_graph(
node, base_mod_env, base_mod_attrs
)
already_constructed_attr_nodes.add(node)
# Construct GraphModule for this partition
for node in orig_mod_attr_nodes: # type: ignore[attr-defined]
if node in already_constructed_attr_nodes:
continue
base_mod_env, base_mod_attrs = construct_graph(
node, base_mod_env, base_mod_attrs
)
already_constructed_attr_nodes.add(node)
base_mod_attrs[partition.submod_name] = _make_graph_module(
partition.targets, partition.graph
) # noqa: B950
# Emit call in base graph to this submodule
output_val = base_mod_graph.call_module(
partition.submod_name,
tuple(base_mod_env[name] for name in partition.inputs),
)
num_outputs = len(partition.outputs)
if num_outputs > 1 or (num_outputs == 1 and tuple_return):
# Unpack return values from submodule
output_val_proxy = torch.fx.proxy.Proxy(output_val)
for i, output_name in enumerate(partition.outputs):
base_mod_env[output_name] = output_val_proxy[i].node # type: ignore[index]
elif num_outputs == 1:
base_mod_env[next(iter(partition.outputs))] = output_val
# When keep_original_order=True and if the graph doesn't have any
# `call_function` node then `base_mod_graph`, `base_mod_env` and `base_mod_attrs`
# are never populated.
# For this case, we call `construct_graph` here which takes care of updating them.
if keep_original_order and not base_mod_env:
for node in m.graph.nodes:
base_mod_env, base_mod_attrs = construct_graph(
node, base_mod_env, base_mod_attrs
)
# Add output node to `base_mod_graph` (i.e. the split graph) which will be returned.
for node in m.graph.nodes:
if node.op == "output":
base_mod_graph.output(
torch.fx.graph.map_arg(node.args[0], lambda n: base_mod_env[n.name])
) # noqa: B950
ret = _make_graph_module(base_mod_attrs, base_mod_graph)
log.debug(
"%s",
lazy_format_graph_code("post split_module", ret, colored=True),
)
return ret
@@ -0,0 +1,519 @@
# mypy: allow-untyped-defs
import copy
from dataclasses import dataclass, field
import torch.fx
from torch.fx._compatibility import compatibility
from torch.fx.graph import map_arg
from torch.fx.passes.utils import HolderModule, lift_subgraph_as_module
from .tools_common import CALLABLE_NODE_OPS, is_node_output_tensor, NodeList
__all__ = [
"getattr_recursive",
"setattr_recursive",
"Component",
"split_by_tags",
"move_non_tensor_nodes_on_boundary",
]
@compatibility(is_backward_compatible=False)
def getattr_recursive(obj, name):
for layer in name.split("."):
if isinstance(obj, torch.nn.ModuleList):
if hasattr(obj, "_modules") and layer in obj._modules:
obj = obj._modules[layer]
else:
return None
elif hasattr(obj, layer):
obj = getattr(obj, layer)
else:
return None
return obj
@compatibility(is_backward_compatible=False)
def setattr_recursive(obj, attr, value):
if "." not in attr:
setattr(obj, attr, value)
else:
layer = attr.split(".")
setattr_recursive(getattr(obj, layer[0]), ".".join(layer[1:]), value)
@compatibility(is_backward_compatible=False)
@dataclass
class Component:
"""
A component serves as a container for a subgraph we want to create afterwards.
"""
graph: torch.fx.Graph
order: int
name: str
# Stores the placeholder nodes in `graph`.
input_placeholders: list = field(default_factory=list)
# Store the nodes in original graph that are placeholder in `graph`.
orig_inputs: list = field(default_factory=list)
# Store the nodes in original graph that are outputs in `graph`.
orig_outputs: list = field(default_factory=list)
# Mapping from get_attr node in original graph to get_attr node in `graph`.
getattr_maps: dict[torch.fx.Node, torch.fx.Node] = field(default_factory=dict)
constructor_args: list[str] = field(default_factory=list)
gm: torch.fx.GraphModule | None = None
@compatibility(is_backward_compatible=False)
def split_by_tags(
gm: torch.fx.GraphModule,
tags: list[str],
return_fqn_mapping: bool = False,
return_tuple: bool = False,
GraphModuleCls: type[torch.fx.GraphModule] = torch.fx.GraphModule,
) -> torch.fx.GraphModule | tuple[torch.fx.GraphModule, dict[str, str]]:
"""
Splits a GraphModule using tags on its graph nodes. We honor the order of
tags. For example, we have tags = ["a", "b", "c"], the function will create
the initial submodules in the order of "a", "b", "c".
To set a tag:
gm.graph.nodes[idx].tag = "mytag"
This will result in all nodes with the same tag being extracted and placed in their
own submodule. For placeholder, output and get_attr node, the tag is ignored. placeholder
and output nodes are created when needed while get_attr nodes get copied to submodules
where they are used.
Given the following module def:
class SimpleModule(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.linear1 = torch.nn.Linear(...)
self.linear2 = torch.nn.Linear(...)
self.linear3 = torch.nn.Linear(...)
def forward(self, in1, in2):
r1 = self.linear1(in1)
r2 = self.linear2(in2)
r3 = torch.cat([r1, r2])
return self.linear3(r3)
Marking the node corresponding to in1 with the tag sc.REQUEST_ONLY.lower() results in the following split:
ro:
def forward(self, in1):
self = self.root
linear1 = self.linear1(in1)
return linear1
main:
def forward(self, in2, linear1):
self = self.root
linear2 = self.linear2(in2)
cat_1 = torch.cat([linear1, linear2])
linear3 = self.linear3(cat_1)
return linear3
main:
def forward(self, in1, in2):
self = self.root
ro_0 = self.ro_0(in1)
main_1 = self.main_1(in2, ro_0)
return main_1
Returns:
split_gm: torch fx graph after split
orig_to_split_fqn_mapping: a map between the original fqn and the fqn
after split for call_module and get_attr.
"""
def flatten(x: torch.fx.node.Argument) -> NodeList:
"""
Stores nodes in x to a list and returns the list.
"""
r: NodeList = []
map_arg(x, r.append)
return r
# Mapping from node in original module to node in created submodule.
node_remapping: dict[torch.fx.Node, torch.fx.Node] = {}
# Mapping from node in original module or created submodules to
# corresponding component.
node_to_component: dict[torch.fx.Node, Component] = {}
# Mapping from tag to the corresponding component.
tag_to_component: dict[str, Component] = {}
# Stores all components.
all_components: list[Component] = []
# Stores nodes that will be used in main graph.
used_in_main: dict[torch.fx.Node, None] = {}
# Main graph after split.
main_g = torch.fx.Graph()
# Mapping from node in original module to node in main graph after split.
main_remapping: dict[torch.fx.Node, torch.fx.Node] = {}
# Output node of original module.
output_node: torch.fx.Node | None = None
# Create a component for each tag, we don't expect to create other components afterwards.
for tag in tags:
comp = Component(torch.fx.Graph(), len(all_components), f"{tag}")
all_components.append(comp)
tag_to_component[tag] = comp
# Traverse the nodes in original graph and take care of them.
for node in gm.graph.nodes:
if node.op == "output":
if output_node is not None:
raise RuntimeError("Multiple output nodes in graph!")
output_node = node
continue
# Placeholders in the original graph get copied to main graph.
if node.op == "placeholder":
main_remapping[node] = main_g.placeholder(node.name, type_expr=node.type)
main_remapping[node].meta = copy.copy(node.meta)
continue
# Get_attr nodes are ignored because we are not tagging them.
# Instead, we copy them directly to the submodules use them afterwards.
if node.op == "get_attr":
continue
# Now we process callable nodes which are nodes with op of call_module,
# call_function or call_method. Every callable nodes should be tagged.
if not hasattr(node, "tag"):
raise AssertionError(f"Node does not have tag: {node.format_node()}")
upstream_components = [
node_to_component[x]
for x in flatten(node.args) + flatten(node.kwargs)
if x.op not in {"placeholder", "get_attr"}
]
comp = tag_to_component[node.tag]
node_to_component[node] = comp
# Max order of upperstream components.
mx = max((c.order for c in upstream_components), default=0)
# Expect the component for `node` has higher order then its upstream components.
if comp.order < mx:
raise AssertionError(
f"Component {comp.name} order must be >= max of its upstream components, "
f"order={comp.order} and max={mx}"
)
# Map a input of `node` to nodes in the component's graph.
def remap_func(x):
# If input is a get_attr node, copy it to current component's graph.
# Returns the get_attr node in current component's graph.
if x.op == "get_attr":
if x not in comp.getattr_maps:
comp.getattr_maps[x] = comp.graph.get_attr(
x.target, type_expr=x.type
)
comp.getattr_maps[x].meta = copy.copy(x.meta)
return comp.getattr_maps[x]
# If input is not a placeholder, it should have been put into a component
# already. If it's the current component then we return the corresponding
# node in the component.
if x.op != "placeholder" and node_to_component[x] == comp:
return node_remapping[x]
# If input is a placeholder or it's in other components, we want to make it
# as a placeholder in current component's graph.
if x not in comp.orig_inputs:
comp.orig_inputs.append(x)
placeholder = comp.graph.placeholder(x.name, type_expr=x.type)
placeholder.meta = copy.copy(x.meta)
comp.input_placeholders.append(placeholder)
used_in_main[x] = None
return comp.input_placeholders[comp.orig_inputs.index(x)]
n = comp.graph.node_copy(node, remap_func)
n.tag = node.tag # type: ignore[attr-defined]
node_remapping[node] = n
node_to_component[n] = comp
if output_node is None:
raise RuntimeError("Graph had no output node!")
for x in flatten(output_node.args[0]):
if x.op == "get_attr":
# We don't need components mapping for nodes of type "get_attr"
# that are consumed by the output. Only need to make sure we create
# corresponding counterparts in the resulting graph.
main_remapping[x] = main_g.get_attr(x.name, type_expr=x.type)
else:
# All component results consumed by the output node should be
# marked as "used in main".
used_in_main[x] = None
# If a node is used in main graph then we mark it as an output in the component
# it belongs to.
for n in used_in_main:
if n.op != "placeholder":
node_to_component[n].orig_outputs.append(n)
# Now we create a graphmodule for each component.
orig_to_split_fqn_mapping: dict[str, str] = {}
for comp in all_components:
outs = tuple(map(node_remapping.__getitem__, comp.orig_outputs))
if return_tuple:
comp.graph.output(outs)
else:
# Take care of the args of FX output node. If there's a single
# output then the output node args is like (output_single), else
# if there're multiple outputs then the output node args is like
# ((output_0, output_1, ...)).
comp.graph.output(outs[0] if len(outs) == 1 else outs)
comp.gm, comp_orig_to_split_fqn_mapping = lift_subgraph_as_module(
gm, subgraph=comp.graph, comp_name=comp.name
)
orig_to_split_fqn_mapping.update(comp_orig_to_split_fqn_mapping)
# Create a call_module node in main graph.
main_node = main_g.call_module(
comp.name,
args=tuple(map(main_remapping.__getitem__, comp.orig_inputs)),
kwargs=None,
)
if len(outs) == 1 and not return_tuple:
main_remapping[comp.orig_outputs[0]] = main_node
else:
for i, o in enumerate(comp.orig_outputs):
# Use Proxy to record getitem access.
main_remapping[o] = torch.fx.Proxy(main_node)[i].node # type: ignore[index]
main_g.output(map_arg(output_node.args[0], main_remapping.__getitem__))
main_root = HolderModule({comp.name: comp.gm for comp in all_components})
main_g._codegen = gm.graph._codegen
# If the output nodes consumes get_attr directly in the original graph,
# then we need to make sure get_attr is copied to the new graph.
for x in flatten(output_node.args[0]):
if x.op == "get_attr":
setattr(main_root, x.name, getattr_recursive(gm, x.target)) # type: ignore[arg-type]
result_gm = GraphModuleCls(main_root, main_g)
if return_fqn_mapping:
return result_gm, orig_to_split_fqn_mapping
return result_gm
@compatibility(is_backward_compatible=False)
def move_non_tensor_nodes_on_boundary(subgraphs) -> None:
"""
Move non-tensor nodes on the boundary between subgraphs.
For each subgraph:
1. Find nodes whose type is not tensor and any of its children is in another
subgraph, put them in a queue for next step
2. Do a BFS on those nodes in the queue, and run a DFS for each node, let's say node X and it is in subgraph A:
a. if it is in to_subgraph, return (continue DFS)
b. if it is in from_subgraph, collect the nodes to nodes_to_move, and continue DFS
c. otherwise, this means it cannot be moved
d. also check if node X's parent should be put into the queue. (The queue may
have duplicated nodes, just process the node once)
Args:
subgraphs: List of subgraphs containing nodes to be processed
"""
# Create a mapping from node to subgraph for quick lookup
node_to_subgraph: dict[torch.fx.Node, int] = {}
for i, subgraph in enumerate(subgraphs):
for node in subgraph.nodes:
node_to_subgraph[node] = i
def get_children_in_graph(node: torch.fx.Node) -> list[torch.fx.Node]:
"""Get children nodes that are in callable ops and in some subgraph"""
return [
user
for user in node.users
if user.op in CALLABLE_NODE_OPS and user in node_to_subgraph
]
def get_parents_in_graph(node: torch.fx.Node) -> list[torch.fx.Node]:
"""Get parent nodes that are in callable ops and in some subgraph"""
return [
arg
for arg in node.all_input_nodes
if arg.op in CALLABLE_NODE_OPS and arg in node_to_subgraph
]
def has_children_in_other_subgraph(
node: torch.fx.Node, current_subgraph_idx: int
) -> bool:
"""
Check if the node has any children in a subgraph different from current_subgraph_idx.
This is the requirement used in both step 1 and step d.
"""
children = get_children_in_graph(node)
return any(
node_to_subgraph[child] != current_subgraph_idx for child in children
)
def can_move_node_and_dependencies(
node: torch.fx.Node, from_subgraph: int, to_subgraph: int
) -> tuple[bool, set[torch.fx.Node]]:
"""
Check if node and its dependencies can be moved from from_subgraph to to_subgraph.
Returns (can_move, nodes_to_move)
For node X, do a DFS on its descendants, for each node:
- if it is in to_subgraph, return (continue DFS)
- if it is in from_subgraph, collect the nodes to nodes_to_move, and continue DFS
- otherwise, this means it cannot be moved
"""
nodes_to_move = set()
visited = set()
can_move = True
def dfs(current_node):
nonlocal can_move, nodes_to_move
if current_node in visited:
return
visited.add(current_node)
# Check current node's subgraph
if current_node not in node_to_subgraph:
return # Skip nodes not in any subgraph
current_subgraph = node_to_subgraph[current_node]
if current_subgraph == to_subgraph:
# If it is in to_subgraph, just end DFS
return
elif current_subgraph == from_subgraph:
# If it is in from_subgraph, collect it and continue DFS
nodes_to_move.add(current_node)
else:
# Otherwise, this means it cannot be moved
can_move = False
return
# Continue DFS on children
children = get_children_in_graph(current_node)
for child in children:
if can_move: # Only continue if we haven't already failed
dfs(child)
# Start DFS from the original node
dfs(node)
return can_move, nodes_to_move
# For each subgraph, find non-tensor nodes with children in other subgraphs
for subgraph_idx, subgraph in enumerate(subgraphs):
# non acc nodes cannot be moved to downstream acc graph, so skip
if not subgraph.is_acc:
continue
# Step 1: Find non-tensor nodes with children in other subgraphs
queue: list[torch.fx.Node] = []
processed: set[torch.fx.Node] = set()
for node in subgraph.nodes:
# Check if node is non-tensor
if is_node_output_tensor(node):
continue
# Check if node meets step 1 requirement: any children in another subgraph
if has_children_in_other_subgraph(node, subgraph_idx):
queue.append(node)
# Step 2: BFS to move nodes that meet the criteria
while queue:
current_node = queue.pop(0)
# Skip if already processed (queue may have duplicates)
if current_node in processed:
continue
processed.add(current_node)
# Skip if node is no longer in this subgraph (may have been moved)
if (
current_node not in node_to_subgraph
or node_to_subgraph[current_node] != subgraph_idx
):
continue
children = get_children_in_graph(current_node)
if len(children) == 0:
raise AssertionError(
"Only node that has children in other subgraph can be moved"
)
# Find target subgraph. The children should all be in the same subgraph except current subgraph
target_subgraph_candidates = set()
for child in children:
child_subgraph = node_to_subgraph[child]
if child_subgraph != subgraph_idx:
target_subgraph_candidates.add(child_subgraph)
# If multiple children live in different subgraphs, the node cannot be moved. User needs to find other ways to move it.
if len(target_subgraph_candidates) != 1:
print(
f"Cannot move non-tensor node {current_node.name} on boundary because it has children in multiple subgraphs"
)
continue
target_subgraph = target_subgraph_candidates.pop()
# Check if we can move this node and its dependencies
can_move, nodes_to_move = can_move_node_and_dependencies(
current_node, subgraph_idx, target_subgraph
)
if can_move:
# Move all nodes in nodes_to_move to target subgraph
for node_to_move in nodes_to_move:
# Remove from current subgraph
subgraph.nodes.remove(node_to_move)
# Add to target subgraph
subgraphs[target_subgraph].nodes.append(node_to_move)
# Update mapping
node_to_subgraph[node_to_move] = target_subgraph
print(
f"In order move the non-tensor node {current_node.name} on boundary, "
f"moved node {node_to_move.name} from {'acc' if subgraph.is_acc else 'gpu'}_{subgraph_idx} "
f"to {'acc' if subgraphs[target_subgraph].is_acc else 'gpu'}_{target_subgraph}"
)
# Add parents to the queue if they're non-tensor and not already processed
# and meet the requirement from step 1 (any children in another subgraph)
parents = get_parents_in_graph(current_node)
for parent in parents:
if (
not is_node_output_tensor(parent)
and parent not in processed
and parent in node_to_subgraph
and node_to_subgraph[parent] == subgraph_idx
):
# Check if parent meets step 1 requirement: any children in another subgraph
if not has_children_in_other_subgraph(parent, subgraph_idx):
raise AssertionError(
f"Parent {parent.name} should have children in another subgraph"
)
queue.append(parent)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,504 @@
import unittest
from typing import Any
from unittest.mock import patch, PropertyMock
import torch
import torch.fx
from torch.fx.passes.split_utils import move_non_tensor_nodes_on_boundary
from torch.fx.passes.splitter_base import Subgraph
class TestMoveNonTensorNodesOnBoundary(unittest.TestCase):
def setUp(self) -> None:
"""Set up test fixtures."""
self.graph = torch.fx.Graph()
def _create_mock_node(
self, name: str, op: str, target: Any = None, is_tensor: bool = True
) -> torch.fx.Node:
"""Helper to create a mock FX node with necessary attributes."""
if op == "placeholder":
node = self.graph.placeholder(name)
elif op == "call_function":
target = target or torch.add
node = self.graph.call_function(target, args=())
elif op == "call_module":
target = target or "linear"
node = self.graph.call_module(target)
elif op == "call_method":
target = target or "relu"
node = self.graph.call_method(target)
elif op == "output":
node = self.graph.output(())
else:
node = self.graph.call_function(torch.add, args=())
node.op = op
node.name = name
# Mock meta attribute for tensor type checking
if is_tensor:
node.meta = {"type": torch.Tensor}
else:
node.meta = {"type": int} # Non-tensor type
# Mock users dict (Node.users is dict[Node, None])
node.users = {}
# Initialize the _input_nodes dict (Node._input_nodes is dict[Node, None])
node._input_nodes = {}
return node
def test_move_non_tensor_nodes_basic_case(self) -> None:
"""Test basic case where non-tensor node should be moved."""
# Create nodes
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
node2 = self._create_mock_node("node2", "call_function", is_tensor=True)
node3 = self._create_mock_node("node3", "call_function", is_tensor=True)
# Set up relationships: node1 -> node2, node1 -> node3
node1.users = {node2: None, node3: None}
node2._input_nodes = {node1: None}
node3._input_nodes = {node1: None}
# Create subgraphs
subgraph1 = Subgraph(nodes=[node1], is_acc=True)
subgraph2 = Subgraph(nodes=[node2, node3], is_acc=True)
subgraphs = [subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
# Mock is_node_output_tensor to return appropriate values
mock_is_tensor.side_effect = lambda node: node.name != "node1"
# Mock all_input_nodes property for all nodes
with (
patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs,
patch.object(
type(node3), "all_input_nodes", new_callable=PropertyMock
) as mock_node3_inputs,
):
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
mock_node3_inputs.return_value = list(node3._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify node1 was moved from subgraph1 to subgraph2
self.assertNotIn(node1, subgraph1.nodes)
self.assertIn(node1, subgraph2.nodes)
self.assertIn(node2, subgraph2.nodes)
self.assertIn(node3, subgraph2.nodes)
def test_no_movement_for_tensor_nodes(self) -> None:
"""Test that tensor nodes are not moved."""
# Create tensor nodes
node1 = self._create_mock_node("node1", "call_function", is_tensor=True)
node2 = self._create_mock_node("node2", "call_function", is_tensor=True)
# Set up relationship
node1.users = {node2: None}
node2._input_nodes = {node1: None}
# Create subgraphs
subgraph1 = Subgraph(nodes=[node1], is_acc=True)
subgraph2 = Subgraph(nodes=[node2], is_acc=True)
subgraphs = [subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.return_value = True # All nodes are tensor nodes
with patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs:
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify no movement occurred
self.assertIn(node1, subgraph1.nodes)
self.assertIn(node2, subgraph2.nodes)
def test_no_movement_for_non_acc_subgraph(self) -> None:
"""Test that nodes in non-acc subgraphs are not processed."""
# Create non-tensor node
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
node2 = self._create_mock_node("node2", "call_function", is_tensor=True)
# Set up relationship
node1.users = {node2: None}
node2._input_nodes = {node1: None}
# Create subgraphs - first one is not acc
subgraph1 = Subgraph(nodes=[node1], is_acc=False)
subgraph2 = Subgraph(nodes=[node2], is_acc=True)
subgraphs = [subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.side_effect = lambda node: node.name != "node1"
with patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs:
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify no movement occurred because subgraph1 is not acc
self.assertIn(node1, subgraph1.nodes)
self.assertIn(node2, subgraph2.nodes)
def test_multiple_target_subgraphs_no_movement(self) -> None:
"""Test that nodes with children in multiple different subgraphs don't get moved."""
# Create nodes
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
node2 = self._create_mock_node("node2", "call_function", is_tensor=True)
node3 = self._create_mock_node("node3", "call_function", is_tensor=True)
# Set up relationships: node1 -> node2 (subgraph2), node1 -> node3 (subgraph3)
node1.users = {node2: None, node3: None}
node2._input_nodes = {node1: None}
node3._input_nodes = {node1: None}
# Create subgraphs
subgraph1 = Subgraph(nodes=[node1], is_acc=True)
subgraph2 = Subgraph(nodes=[node2], is_acc=True)
subgraph3 = Subgraph(nodes=[node3], is_acc=True)
subgraphs = [subgraph1, subgraph2, subgraph3]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.side_effect = lambda node: node.name != "node1"
with (
patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs,
patch.object(
type(node3), "all_input_nodes", new_callable=PropertyMock
) as mock_node3_inputs,
):
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
mock_node3_inputs.return_value = list(node3._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify no movement occurred because node1 has children in multiple subgraphs
self.assertIn(node1, subgraph1.nodes)
self.assertIn(node2, subgraph2.nodes)
self.assertIn(node3, subgraph3.nodes)
def test_dependency_chain_movement(self) -> None:
"""Test movement of a chain of dependent non-tensor nodes."""
# Create chain: node1 -> node2 -> node3 -> node4
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
node2 = self._create_mock_node("node2", "call_function", is_tensor=False)
node3 = self._create_mock_node("node3", "call_function", is_tensor=False)
node4 = self._create_mock_node("node4", "call_function", is_tensor=True)
# Set up relationships
node1.users = {node2: None}
node2.users = {node3: None}
node3.users = {node4: None}
node1._input_nodes = {} # node1 has no inputs
node2._input_nodes = {node1: None}
node3._input_nodes = {node2: None}
node4._input_nodes = {node3: None}
# Create subgraphs: nodes 1-3 in subgraph1, node4 in subgraph2
subgraph1 = Subgraph(nodes=[node1, node2, node3], is_acc=True)
subgraph2 = Subgraph(nodes=[node4], is_acc=True)
subgraphs = [subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.side_effect = lambda node: node.name == "node4"
with (
patch.object(
type(node1), "all_input_nodes", new_callable=PropertyMock
) as mock_node1_inputs,
patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs,
patch.object(
type(node3), "all_input_nodes", new_callable=PropertyMock
) as mock_node3_inputs,
patch.object(
type(node4), "all_input_nodes", new_callable=PropertyMock
) as mock_node4_inputs,
):
mock_node1_inputs.return_value = list(node1._input_nodes.keys())
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
mock_node3_inputs.return_value = list(node3._input_nodes.keys())
mock_node4_inputs.return_value = list(node4._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Debug: print what actually happened
print(f"subgraph1 after move: {[n.name for n in subgraph1.nodes]}")
print(f"subgraph2 after move: {[n.name for n in subgraph2.nodes]}")
# Based on the algorithm, only node3 should be moved because it's the only one
# with children in another subgraph. The function only moves nodes that meet strict criteria.
# Let's adjust the expectations based on actual algorithm behavior
# We expect that some nodes get moved, but not necessarily all
self.assertLessEqual(
len(subgraph1.nodes), 3
) # Some nodes should be moved
self.assertGreaterEqual(
len(subgraph2.nodes), 1
) # At least node4 should be there
def test_parent_node_processing(self) -> None:
"""Test that parent nodes are added to processing queue when appropriate."""
# Create chain: parent -> node1 -> child
parent = self._create_mock_node("parent", "call_function", is_tensor=False)
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
child = self._create_mock_node("child", "call_function", is_tensor=True)
# Set up relationships
parent.users = {node1: None}
node1.users = {child: None}
parent._input_nodes = {} # parent has no inputs
node1._input_nodes = {parent: None}
child._input_nodes = {node1: None}
# Create subgraphs
subgraph1 = Subgraph(nodes=[parent, node1], is_acc=True)
subgraph2 = Subgraph(nodes=[child], is_acc=True)
subgraphs = [subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.side_effect = lambda node: node.name == "child"
with (
patch.object(
type(parent), "all_input_nodes", new_callable=PropertyMock
) as mock_parent_inputs,
patch.object(
type(node1), "all_input_nodes", new_callable=PropertyMock
) as mock_node1_inputs,
patch.object(
type(child), "all_input_nodes", new_callable=PropertyMock
) as mock_child_inputs,
):
mock_parent_inputs.return_value = list(parent._input_nodes.keys())
mock_node1_inputs.return_value = list(node1._input_nodes.keys())
mock_child_inputs.return_value = list(child._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# The algorithm may not move all nodes. Let's verify node1 is moved
# since it has children in another subgraph
self.assertIn(
child, subgraph2.nodes
) # child should remain in subgraph2
# Allow flexibility in how many nodes are moved based on algorithm behavior
self.assertLessEqual(
len(subgraph1.nodes), 2
) # Some nodes should be moved
def test_empty_subgraphs(self) -> None:
"""Test handling of empty subgraphs."""
subgraphs = [Subgraph(nodes=[], is_acc=True), Subgraph(nodes=[], is_acc=True)]
# Should not raise any exceptions
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify subgraphs remain empty
self.assertEqual(len(subgraphs[0].nodes), 0)
self.assertEqual(len(subgraphs[1].nodes), 0)
def test_single_subgraph(self) -> None:
"""Test handling of single subgraph - no movement should occur."""
node1 = self._create_mock_node("node1", "call_function", is_tensor=False)
node2 = self._create_mock_node("node2", "call_function", is_tensor=True)
node1.users = {node2: None}
node2._input_nodes = {node1: None}
subgraph1 = Subgraph(nodes=[node1, node2], is_acc=True)
subgraphs = [subgraph1]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
mock_is_tensor.side_effect = lambda node: node.name != "node1"
with patch.object(
type(node2), "all_input_nodes", new_callable=PropertyMock
) as mock_node2_inputs:
mock_node2_inputs.return_value = list(node2._input_nodes.keys())
# Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Verify no movement occurred (only one subgraph)
self.assertIn(node1, subgraph1.nodes)
self.assertIn(node2, subgraph1.nodes)
def test_third_subgraph_crossing_blocks_movement(self) -> None:
"""Test that movement is blocked when dependency path crosses through an intermediate subgraph.
This tests a critical failure path (lines 411-414): during DFS, when we encounter
a node that's neither in from_subgraph nor to_subgraph, can_move should be False.
Scenario:
Subgraph 0 (ACC): [node_a] (non-tensor) ---> [node_c] (subgraph 2, target)
|
v
[node_b] (TENSOR, subgraph 0) - tensor so won't be queued independently
|
v
Subgraph 1 (ACC): [node_d] (subgraph 1, THIRD SUBGRAPH!)
Subgraph 2 (ACC): [node_c] (target subgraph)
Key insight: node_b must be a TENSOR node so it won't be added to the processing
queue independently (only non-tensor nodes are queued). However, the DFS from
node_a will still traverse through node_b and encounter node_d in subgraph 1.
When processing node_a:
- target_subgraph = 2 (node_c is the only child in another subgraph)
- DFS from node_a (from=0, to=2):
- node_a (subgraph 0) -> add to nodes_to_move, continue DFS on children
- DFS node_b (subgraph 0) -> add to nodes_to_move, continue DFS on children
- DFS node_d (subgraph 1) -> NOT from (0), NOT to (2) -> can_move = False!
- Movement blocked due to third subgraph crossing
"""
# Setup: Create nodes
# IMPORTANT: node_b is TENSOR so it won't be independently added to the queue
node_a = self._create_mock_node("node_a", "call_function", is_tensor=False)
node_b = self._create_mock_node(
"node_b", "call_function", is_tensor=True
) # TENSOR!
node_c = self._create_mock_node("node_c", "call_function", is_tensor=True)
node_d = self._create_mock_node("node_d", "call_function", is_tensor=True)
# Set up relationships:
# node_a -> node_b (same subgraph), node_a -> node_c (target subgraph)
# node_b -> node_d (third subgraph - this causes the failure!)
node_a.users = {node_b: None, node_c: None}
node_b.users = {node_d: None}
node_c.users = {}
node_d.users = {}
node_a._input_nodes = {}
node_b._input_nodes = {node_a: None}
node_c._input_nodes = {node_a: None}
node_d._input_nodes = {node_b: None}
# Create three subgraphs:
# - node_a, node_b in subgraph 0 (ACC)
# - node_d in subgraph 1 (ACC) - the "third" subgraph that blocks movement
# - node_c in subgraph 2 (ACC) - the target subgraph
subgraph0 = Subgraph(nodes=[node_a, node_b], is_acc=True)
subgraph1 = Subgraph(nodes=[node_d], is_acc=True) # Third subgraph!
subgraph2 = Subgraph(nodes=[node_c], is_acc=True) # Target subgraph
subgraphs = [subgraph0, subgraph1, subgraph2]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
# Only node_a is non-tensor; node_b, node_c, node_d are all tensor
mock_is_tensor.side_effect = lambda node: node.name != "node_a"
with (
patch.object(
type(node_a), "all_input_nodes", new_callable=PropertyMock
) as mock_node_a_inputs,
patch.object(
type(node_b), "all_input_nodes", new_callable=PropertyMock
) as mock_node_b_inputs,
patch.object(
type(node_c), "all_input_nodes", new_callable=PropertyMock
) as mock_node_c_inputs,
patch.object(
type(node_d), "all_input_nodes", new_callable=PropertyMock
) as mock_node_d_inputs,
):
mock_node_a_inputs.return_value = list(node_a._input_nodes.keys())
mock_node_b_inputs.return_value = list(node_b._input_nodes.keys())
mock_node_c_inputs.return_value = list(node_c._input_nodes.keys())
mock_node_d_inputs.return_value = list(node_d._input_nodes.keys())
# Execute: Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Assert: node_a should NOT be moved because DFS encounters node_d
# in subgraph 1 (third subgraph), which triggers can_move = False
self.assertIn(node_a, subgraph0.nodes)
self.assertIn(node_b, subgraph0.nodes)
self.assertIn(node_c, subgraph2.nodes)
self.assertIn(node_d, subgraph1.nodes)
def test_acc_to_cpu_movement(self) -> None:
"""Test movement from ACC subgraph to CPU/GPU subgraph.
This tests that non-tensor nodes can be moved from ACC to CPU/GPU subgraphs,
as mentioned in the function's help text about acc->gpu boundary.
Scenario:
Subgraph 0 (ACC): [node_a] (non-tensor)
|
Subgraph 1 (CPU): [node_b] # Should move node_a from ACC to CPU
"""
# Setup: Create nodes where non-tensor node_a in ACC subgraph has child in CPU subgraph
node_a = self._create_mock_node("node_a", "call_function", is_tensor=False)
node_b = self._create_mock_node("node_b", "call_function", is_tensor=True)
# Set up relationship: node_a -> node_b
node_a.users = {node_b: None}
node_a._input_nodes = {}
node_b._input_nodes = {node_a: None}
# Create subgraphs: node_a in ACC subgraph, node_b in CPU subgraph
subgraph_acc = Subgraph(nodes=[node_a], is_acc=True)
subgraph_cpu = Subgraph(nodes=[node_b], is_acc=False) # CPU/GPU subgraph
subgraphs = [subgraph_acc, subgraph_cpu]
with patch(
"torch.fx.passes.split_utils.is_node_output_tensor"
) as mock_is_tensor:
# node_a is non-tensor; node_b is tensor
mock_is_tensor.side_effect = lambda node: node.name == "node_b"
with (
patch.object(
type(node_a), "all_input_nodes", new_callable=PropertyMock
) as mock_node_a_inputs,
patch.object(
type(node_b), "all_input_nodes", new_callable=PropertyMock
) as mock_node_b_inputs,
):
mock_node_a_inputs.return_value = list(node_a._input_nodes.keys())
mock_node_b_inputs.return_value = list(node_b._input_nodes.keys())
# Execute: Call the function
move_non_tensor_nodes_on_boundary(subgraphs)
# Assert: node_a should be moved from ACC subgraph to CPU subgraph
# because it's a non-tensor node with children in the CPU subgraph
self.assertNotIn(node_a, subgraph_acc.nodes)
self.assertIn(node_a, subgraph_cpu.nodes)
self.assertIn(node_b, subgraph_cpu.nodes)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
import unittest
from ..pass_manager import (
inplace_wrapper,
PassManager,
these_before_those_pass_constraint,
this_before_that_pass_constraint,
)
class TestPassManager(unittest.TestCase):
def test_pass_manager_builder(self) -> None:
passes = [lambda x: 2 * x for _ in range(10)]
pm = PassManager(passes)
pm.validate()
def test_this_before_that_pass_constraint(self) -> None:
passes = [lambda x: 2 * x for _ in range(10)]
pm = PassManager(passes)
# add unfulfillable constraint
pm.add_constraint(this_before_that_pass_constraint(passes[-1], passes[0]))
self.assertRaises(RuntimeError, pm.validate)
def test_these_before_those_pass_constraint(self) -> None:
passes = [lambda x: 2 * x for _ in range(10)]
constraint = these_before_those_pass_constraint(passes[-1], passes[0])
pm = PassManager([inplace_wrapper(p) for p in passes])
# add unfulfillable constraint
pm.add_constraint(constraint)
self.assertRaises(RuntimeError, pm.validate)
def test_two_pass_managers(self) -> None:
"""Make sure we can construct the PassManager twice and not share any
state between them"""
passes = [lambda x: 2 * x for _ in range(3)]
constraint = these_before_those_pass_constraint(passes[0], passes[1])
pm1 = PassManager()
for p in passes:
pm1.add_pass(p)
pm1.add_constraint(constraint)
output1 = pm1(1)
self.assertEqual(output1, 2**3)
passes = [lambda x: 3 * x for _ in range(3)]
constraint = these_before_those_pass_constraint(passes[0], passes[1])
pm2 = PassManager()
for p in passes:
pm2.add_pass(p)
pm2.add_constraint(constraint)
output2 = pm2(1)
self.assertEqual(output2, 3**3)
@@ -0,0 +1,397 @@
# mypy: allow-untyped-defs
import collections
import heapq
import operator
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import torch
import torch.fx
from torch.fx._compatibility import compatibility
from torch.fx.node import _get_qualified_name
__all__ = [
"get_acc_ops_name",
"get_node_target",
"is_node_output_tensor",
"FxNetAccFusionsFinder",
"legalize_graph",
"stable_topological_sort",
]
Tensors = tuple[torch.Tensor] | list[torch.Tensor]
TensorOrTensors = torch.Tensor | Tensors
NodeList = list[torch.fx.Node]
NodeSet = set[torch.fx.Node]
Names = list[str]
CALLABLE_NODE_OPS = {"call_module", "call_function", "call_method"}
@compatibility(is_backward_compatible=False)
def get_acc_ops_name(k):
if isinstance(k, str):
return k
elif k.__module__ and "acc_ops" in k.__module__:
return f"acc_ops.{k.__name__}"
else:
module = k.__module__.replace(
"torch._ops", "torch.ops"
) # WAR for bug in how torch.ops assigns module
return f"{module if module else ''}.{k.__name__}"
@compatibility(is_backward_compatible=False)
def get_node_target(
submodules: Mapping[str, torch.nn.Module], node: torch.fx.Node
) -> str:
"""
Given a `node` returns its target typename.
For "call_method" node, return node.target which is the name of that method being called.
This could potential lead to conflict but should be okay because normally it's on a tensor.
For "call_function" node, return typename of node.target.
For "call_module" node, return typename of the module that node.target point to.
If seeing "_VariableFunctionsClass" in the target name string, it will be replaced by
"torch". e.g. _VariableFunctionsClass.relu would become torch.relu.
"""
if node.op not in CALLABLE_NODE_OPS:
raise AssertionError(
"Expect op types of "
+ ", ".join(CALLABLE_NODE_OPS)
+ f", but found {node.op}"
)
if node.op == "call_module":
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
submod = submodules[node.target]
submod_type = getattr(submod, "_base_class_origin", type(submod))
return get_acc_ops_name(submod_type)
elif node.op == "call_function":
target: Any = node.target
return (
f"acc_ops.{target.__name__}"
if target.__module__ is not None and "acc_ops" in target.__module__
else _get_qualified_name(target)
)
else:
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
return node.target
@compatibility(is_backward_compatible=False)
def is_node_output_tensor(node: torch.fx.Node) -> bool:
"""Checks if the node output produces a Tensor or not.
NOTE: This requires to run `ShapeProp` on the containing fx graph before
calling this function. This is because it works by checking the `type`
metadata on the node. This metadata is produced by the `ShapeProp`.
"""
type_ = node.meta.get("type", None)
return type_ is not None and issubclass(type_, torch.Tensor)
@compatibility(is_backward_compatible=False)
class FxNetAccFusionsFinder:
"""
Finds groups of connected ACC nodes that pass non-tensor data between each other.
Such groups are called fusion groups.
"""
def __init__(self, module: torch.fx.GraphModule, acc_nodes: NodeSet):
self.module = module
self.nodes = list(module.graph.nodes)
self.acc_nodes = acc_nodes
self.node_index = {node: i for i, node in enumerate(self.nodes)}
@dataclass
class FusionGroup:
# The smallest idx of nodes in the fusion group after topological sorting all the nodes in the model.
top_node_idx: int
# Nodes in this fusion group.
nodes: NodeSet
# Inputs to this fusion group.
inputs: NodeSet
# Nodes that in the fusion group that haven't been processed yet.
nodes_need_process: NodeSet
def add_node(self, node):
"""
Add a node to fusion group.
"""
if node in self.nodes:
return
self.nodes_need_process.add(node)
self.nodes.add(node)
self.inputs.discard(node)
self.inputs.update(
{
n
for n in node.all_input_nodes
if n.op in CALLABLE_NODE_OPS and n not in self.nodes
}
)
def recursive_add_node(
self,
fusion_group: "FxNetAccFusionsFinder.FusionGroup",
inputs: NodeSet | NodeList,
visited: NodeSet | None = None,
):
"""
Start from inputs and going reverse topological order. If any upstream node
is in the fusion group, add all the nodes in this path to fusion group.
"""
for arg in inputs:
# skip the node if already seen
if visited is not None:
if arg in visited:
continue
visited.add(arg)
# Skip placeholder and get_attr because they won't be in the fusion group.
if arg.op not in CALLABLE_NODE_OPS:
continue
# If the node has smaller idx, it's already an upstream node of the fusion
# group. We don't need to check it anymore.
if self.node_index[arg] < fusion_group.top_node_idx:
continue
# If the node is in the fusion group, return True.
if arg in fusion_group.nodes:
return True
# Check the upstream nodes of the node, if any of them is in the fusion group
# we'll add this node to fusion group and return True.
if self.recursive_add_node(fusion_group, arg.all_input_nodes, visited):
fusion_group.add_node(arg)
return True
return False
def __call__(self) -> dict[torch.fx.Node, NodeSet]:
result: dict[torch.fx.Node, NodeSet] = {}
acc_nodes = list(self.acc_nodes)
for node in acc_nodes:
if node in result:
continue
if node.op not in CALLABLE_NODE_OPS:
continue
if "tensor_meta" in node.meta:
continue
if node not in self.acc_nodes:
continue
fusion_group: FxNetAccFusionsFinder.FusionGroup = self.FusionGroup(
top_node_idx=self.node_index[node],
nodes={node},
inputs=set(node.all_input_nodes),
nodes_need_process={node},
)
while fusion_group.nodes_need_process:
node = fusion_group.nodes_need_process.pop()
self.recursive_add_node(
fusion_group,
fusion_group.inputs,
visited=set(),
)
# Optionally add downstream nodes
if "tensor_meta" not in node.meta:
for user in node.users:
if user.op not in CALLABLE_NODE_OPS:
continue
if user in fusion_group.nodes:
continue
fusion_group.add_node(user)
self.recursive_add_node(
fusion_group,
fusion_group.inputs,
visited=set(),
)
# Add some upstream nodes
for arg in node.all_input_nodes:
if arg.op not in CALLABLE_NODE_OPS:
continue
if "tensor_meta" in arg.meta:
continue
if arg in fusion_group.nodes:
continue
fusion_group.add_node(arg)
fusion_group.top_node_idx = min(
fusion_group.top_node_idx, self.node_index[arg]
)
self.recursive_add_node(
fusion_group,
fusion_group.inputs,
visited=set(),
)
if not (set(fusion_group.nodes) <= self.acc_nodes):
self.acc_nodes -= fusion_group.nodes
else:
for n in fusion_group.nodes:
result[n] = fusion_group.nodes
return result
@compatibility(is_backward_compatible=False)
def legalize_graph(gm: torch.fx.GraphModule) -> torch.fx.GraphModule:
"""
Replace the graph of the given GraphModule with one that contains the same nodes as the
original, but in topologically sorted order.
This is used by the merge_matmul transformation below, which disturbs the topologically sorted
order of its input GraphModule, so that this order is restored before further transformation.
Arguments:
gm: The graph module to topologically sort. It is modified in-place.
Returns:
The graph module in-place sorted
Warning:
This topological sort is NOT stable, it will NOT preserve the original node order.
If you need a stable topological sort, use stable_topological_sort instead.
"""
# These operators are used for making runtime assertions before any
# data-dependent operators occur. We want to prioritize sorting these to
# ensure that these assertions appear before any data-dependent operations
# in the graph.
PRIORITIZED_OPS = [
operator.add,
operator.mul,
operator.sub,
operator.floordiv,
operator.truediv,
operator.mod,
operator.le,
operator.lt,
operator.ge,
operator.gt,
operator.eq,
operator.ne,
torch.ops.aten.sym_constrain_range.default,
torch.ops.aten.sym_constrain_range_for_size.default,
torch.ops.aten._assert_async.msg,
torch.ops.aten.scalar_tensor.default,
torch.ops.aten._assert_scalar.default,
]
indeg = dict.fromkeys(gm.graph.nodes, 0)
new_graph = torch.fx.Graph()
# Track how many unfulfilled dependencies each node has
for node in gm.graph.nodes:
for user in node.users:
indeg[user] += 1
queue: collections.deque = collections.deque()
# Add all nodes with no dependencies to the queue
for node in gm.graph.nodes:
if indeg[node] == 0:
queue.append(node)
env: dict[torch.fx.Node, torch.fx.Node] = {}
# Pop nodes from the queue, and add nodes that have had all their
# dependencies fulfilled
while len(queue) > 0:
cur = queue.popleft()
env[cur] = new_graph.node_copy(cur, lambda x: env[x])
for user in cur.users:
indeg[user] -= 1
if indeg[user] == 0:
if user.op == "call_function" and user.target in PRIORITIZED_OPS:
queue.appendleft(user)
else:
queue.append(user)
# If the new graph's size is not as large as the old one, then there must be
# a cycle (i.e. some node's dependencies were not satisfied.)
if len(new_graph.nodes) < len(gm.graph.nodes):
raise RuntimeError(
f"Input graph has cycles, unable to add {[node for node in indeg if indeg[node] != 0]}"
)
new_graph._codegen = gm.graph._codegen
gm.graph = new_graph
return gm
@compatibility(is_backward_compatible=False)
def stable_topological_sort(gm: torch.fx.GraphModule) -> torch.fx.GraphModule:
"""
Replace the graph of the given GraphModule with one that contains the same nodes as the
original, but in topologically sorted order while preserving the original node order
as much as possible.
This function performs a stable topological sort where nodes appear in an order that:
1. Respects data dependencies (topological ordering)
2. Preserves the original node order when there are no dependency constraints
The algorithm uses Kahn's algorithm with a priority queue: nodes with all dependencies
satisfied are added to a min-heap, ordered by their original position. This ensures
we always process the earliest node in the original order among ready nodes.
Arguments:
gm: The graph module to topologically sort. It is modified in-place.
Returns:
The graph module in-place sorted
"""
indeg = dict.fromkeys(gm.graph.nodes, 0)
new_graph = torch.fx.Graph()
# Build node to original index mapping
node_to_id: dict[torch.fx.Node, int] = {
node: idx for idx, node in enumerate(gm.graph.nodes)
}
# Track how many unfulfilled dependencies each node has
for node in gm.graph.nodes:
for user in node.users:
indeg[user] += 1
# Priority queue: (original_index, node)
# Use min-heap to always process the node with smallest original index
ready_queue: list[tuple[int, torch.fx.Node]] = []
for node in gm.graph.nodes:
if indeg[node] == 0:
heapq.heappush(ready_queue, (node_to_id[node], node))
env: dict[torch.fx.Node, torch.fx.Node] = {}
# Process nodes
while ready_queue:
# Pop node with smallest original index
_, cur = heapq.heappop(ready_queue)
env[cur] = new_graph.node_copy(cur, lambda x: env[x])
# Update in-degrees and add newly ready nodes
for user in cur.users:
indeg[user] -= 1
if indeg[user] == 0:
heapq.heappush(ready_queue, (node_to_id[user], user))
# Check if all nodes were processed
if len(new_graph.nodes) != len(gm.graph.nodes):
raise AssertionError(
f"Input graph has cycles, unable to add {[node for node in indeg if indeg[node] != 0]}"
)
new_graph._codegen = gm.graph._codegen
gm.graph = new_graph
return gm
@@ -0,0 +1 @@
from .common import compare_graphs, HolderModule, lift_subgraph_as_module
@@ -0,0 +1,95 @@
# mypy: allow-untyped-defs
from torch.fx._compatibility import compatibility
from torch.fx.graph import Graph
from torch.fx.graph_module import GraphModule
from torch.fx.passes.utils.matcher_utils import SubgraphMatcher
from torch.nn import Module
__all__ = ["HolderModule", "lift_subgraph_as_module", "compare_graphs"]
@compatibility(is_backward_compatible=False)
class HolderModule(Module):
"""
HolderModule is used to copy all the attributes from original module to submodules
that uses the attributes
"""
def __init__(self, d):
super().__init__()
for k, v in d.items():
self.add_module(k, v)
@compatibility(is_backward_compatible=False)
def lift_subgraph_as_module(
gm: GraphModule,
subgraph: Graph,
comp_name: str = "",
class_name: str = "GraphModule",
) -> tuple[GraphModule, dict[str, str]]:
"""
Create a GraphModule for subgraph, which copies the necessary attributes from the original parent graph_module.
Args:
gm (GraphModule): parent graph module
subgraph (Graph): a valid subgraph that contains copied nodes from the parent graph
comp_name (str): name for the new component
class_name (str): name for the submodule
"""
# Loop through all module calls (call_module) and param fetches (get_attr)
# in this component, creating HolderModules as necessary to match the path.
# e.g. if in the original module there's a get_attr node fetches "conv.weight".
# We create a HolderModule as root -> add a HolderModule named "conv" ->
# make "weight" a attribute of "conv" HolderModule and point to conv.weight in
# the original module.
submodule = HolderModule({})
orig_to_split_fqn_mapping: dict[str, str] = {}
for n in subgraph.nodes:
if n.op not in ("call_module", "get_attr"):
continue
target = n.target
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
target_name_parts = target.split(".")
curr = submodule
orig_gm = gm
for name in target_name_parts[:-1]:
if not hasattr(curr, name):
curr.add_module(name, HolderModule({}))
curr = getattr(curr, name)
orig_gm = getattr(orig_gm, name)
leaf_node_name = target_name_parts[-1]
leaf_node = getattr(orig_gm, leaf_node_name)
orig_to_split_fqn_mapping[target] = f"{comp_name}.{target}"
# Relies on custom __setattr__ magic.
setattr(curr, leaf_node_name, leaf_node)
return GraphModule(submodule, subgraph, class_name), orig_to_split_fqn_mapping
@compatibility(is_backward_compatible=False)
def compare_graphs(left: Graph, right: Graph) -> bool:
"""
Return True if two graphs are identical, i.e they
- have the same number of outputs in the same order
- have the same number of inputs in the same order
- have the same set of nodes, and identical connectivity
"""
matcher = SubgraphMatcher(left, match_output=True, match_placeholder=True)
matches = matcher.match(right)
return len(matches) > 0
@@ -0,0 +1,303 @@
import copy
import heapq
import torch.fx
from torch.fx._compatibility import compatibility
from torch.fx.graph import Graph
from torch.fx.graph_module import GraphModule
from torch.fx.node import Node
from torch.fx.passes.tools_common import legalize_graph, NodeList, NodeSet # noqa: F401
from torch.fx.passes.utils import lift_subgraph_as_module # type: ignore[attr-defined]
@compatibility(is_backward_compatible=False)
def topo_sort(nodes: NodeList) -> NodeList:
# Stable topological sort: among nodes with no dependency between them,
# preserve their relative order in the input list. This uses a min-heap
# keyed by original position instead of a FIFO queue.
indegree_map = dict.fromkeys(nodes, 0)
position = {node: i for i, node in enumerate(nodes)}
candidates: list[tuple[int, Node]] = []
for node in nodes:
for n in node.all_input_nodes:
if n in indegree_map:
indegree_map[node] += 1
if indegree_map[node] == 0:
heapq.heappush(candidates, (position[node], node))
sorted_nodes: NodeList = []
while candidates:
_, node = heapq.heappop(candidates)
sorted_nodes.append(node)
for n in node.users:
if n in indegree_map:
indegree_map[n] -= 1
if indegree_map[n] == 0:
heapq.heappush(candidates, (position[n], n))
if len(nodes) != len(sorted_nodes):
raise AssertionError(
"topological sorted nodes doesn't have same length as input nodes"
)
return sorted_nodes
@compatibility(is_backward_compatible=False)
def validate_partition(partition: NodeList) -> bool:
# verify the partition doesn't form a dependency cycle in the original graph
# returns True for valid partition, False for invalid
partition_set = set(partition)
outputs: NodeList = []
for node in partition_set:
for user_node in node.users:
if user_node not in partition_set:
# external user node, need to expose as an output
outputs.append(user_node)
# Perform BFS on the partition outputs.
# If it reaches a node within the partition, then it found a cycle.
# This function takes the ownership of `root_nodes` and may modify it.
def bfs_find_cycle(root_nodes: NodeList) -> bool:
# Set used to exclude nodes that have already been visited.
# If a node has been visited, that node and all its children have
# been checked for cycles.
visited: NodeSet = set()
# Start with `root_nodes` and traverse through (toward child nodes)
# their connected sub-graph. Nodes in `visited` won't be added
# to `queue` again.
queue: NodeList = root_nodes
while queue:
current = queue.pop()
visited.add(current)
if current in partition_set:
# Started from partition's `output` nodes, and reached
# another node in partition. Cycle!
return True
for user_node in current.users:
if user_node in visited:
continue
queue.append(user_node)
# `root_nodes` don't cause cycle.
return False
# Use all output nodes as roots to traverse
# the graph to check cycles.
if bfs_find_cycle(outputs):
return False
return True
@compatibility(is_backward_compatible=False)
def fuse_as_graphmodule(
gm: GraphModule,
nodes: NodeList,
module_name: str,
partition_lookup_table: dict[Node, int | None] | None = None,
*,
always_return_tuple: bool = False,
) -> tuple[GraphModule, tuple[Node, ...], tuple[Node, ...]]:
"""
Fuse nodes in graph_module into a GraphModule.
Args:
gm (GraphModule): target graph_module
nodes (List[Node]): list of nodes in `gm` to fuse, where the node must be topologically sorted
module_name: class name for the fused GraphModule
partition_lookup_table (Optional[Dict[Node, None]]): optional dict of nodes to speed up lookup
always_return_tuple (bool): whether to always return a tuple, even if there is only one output
Returns:
fused_gm (GraphModule): fused graph module, where its node is a copy of `nodes` in `gm`
original_inputs (Tuple[Node, ...]): input nodes to `nodes` in original `gm`
original_outputs (Tuple[Node, ...]): consumer nodes of `nodes` in original `gm`
"""
# assumption: nodes are already sorted in topo order
for node in nodes:
if node.graph.owning_module is not gm:
raise AssertionError(
f"{node} doesn't belong to passed in graph module {gm._get_name()}"
)
if node._erased:
raise AssertionError(f"{node} has been removed from owning graph")
if node not in gm.graph._find_nodes_lookup_table:
raise AssertionError(
f"{node} is not found in graph module {gm._get_name()}"
)
# validates partition doesn't introduce dependency circles in the graph
if not validate_partition(nodes):
raise AssertionError("Invalid partition, found dependency cycles")
# if no dict of partition nodes is provided, reconstruct it by nodes list to reduce lookup time
if partition_lookup_table is None:
partition_lookup_table = dict.fromkeys(nodes)
subgraph = Graph()
node_to_placeholder: dict[
Node, Node
] = {} # mapping of nodes from old graph to placeholder in new graph
node_map: dict[Node, Node] = {} # mapping of nodes from old graph to new graph
# handles inputs through graph.node_copy's arg_transform functions
def remap_inputs(x: Node) -> Node:
if x.op == "get_attr":
# TODO: do we really need copy the get_attr node into the graph?
# do something here
pass
if x in partition_lookup_table:
# x is inside subgraph, return the copied node
# the node should have been copied already, as we are copying graph in the topological order
return node_map[x]
if x not in node_to_placeholder:
# x is not in subgraph, create a new placeholder for subgraph
placeholder_node = subgraph.placeholder(x.name, type_expr=x.type)
# copy all meta fields, even if some fields might be irrelevant for the placeholder node
placeholder_node.meta = copy.copy(x.meta)
node_to_placeholder[x] = placeholder_node
return node_to_placeholder[x]
# copy nodes in topological order
for node in nodes:
new_node = subgraph.node_copy(node, remap_inputs)
node_map[node] = new_node
# handles outputs
output_mapping: dict[Node, Node] = {} # mapping from old output to new outputs
for node in nodes:
for user_node in node.users:
if user_node not in partition_lookup_table:
# external user node, need to expose as an output
output_mapping[node] = node_map[node]
# outs contain nodes in the new subgraph
outs = tuple(output_mapping.values())
if always_return_tuple:
# always return a tuple, even if there is only one output
subgraph.output(outs)
else:
# If there's a single output then return it directly, otherwise return a tuple.
subgraph.output(outs[0] if len(outs) == 1 else outs)
# lint to ensure correctness
subgraph.lint() # type: ignore[no-untyped-call]
fused_gm: GraphModule
fused_gm, _ = lift_subgraph_as_module(
gm, subgraph, comp_name="", class_name=module_name
)
# sub_gm's input nodes in the original module
original_inputs: tuple[Node, ...] = tuple(node_to_placeholder.keys())
# sub_gm's outputs node in the original module
original_outputs: tuple[Node, ...] = tuple(output_mapping.keys())
return fused_gm, original_inputs, original_outputs
@compatibility(is_backward_compatible=False)
def insert_subgm(
gm: GraphModule,
sub_gm: GraphModule,
orig_inputs: tuple[Node, ...],
orig_outputs: tuple[Node, ...],
insertion_point: Node | None = None,
) -> GraphModule:
# add sub_gm into gm
submodule_name = sub_gm.__class__.__name__
gm.add_submodule(submodule_name, sub_gm)
# Use provided insertion point, or fall back to last output node for backwards compat
if insertion_point is None:
for node in reversed(gm.graph.nodes):
if node in orig_outputs:
insertion_point = node
break
if insertion_point is None:
raise AssertionError(
"Cannot determine insertion point: no insertion_point provided and "
"orig_outputs is empty. Pass the last partition node as insertion_point."
)
# Create a call_module node in main graph.
with gm.graph.inserting_after(insertion_point):
module_node = gm.graph.call_module(
submodule_name, args=orig_inputs, kwargs=None
)
output_node = sub_gm.graph.output_node()
# Replace uses of original outputs with the fused module outputs.
# If there are no external outputs, skip replacement (nothing to replace).
if orig_outputs:
next_node = module_node.next
with gm.graph.inserting_before(next_node):
if len(orig_outputs) == 1 and not isinstance(output_node.args[0], tuple):
# main_remapping[comp.orig_outputs[0]] = module_node
orig_outputs[0].replace_all_uses_with(module_node, propagate_meta=True)
else:
for i, orig_output in enumerate(orig_outputs):
# Use Proxy to record getitem access.
proxy_out = torch.fx.Proxy(module_node)[i].node # type: ignore[index]
orig_output.replace_all_uses_with(proxy_out, propagate_meta=True)
module_node.meta["val"] = tuple(
orig_output.meta.get("val", None) for orig_output in orig_outputs
)
return gm
@compatibility(is_backward_compatible=False)
def erase_nodes(gm: GraphModule, nodes: NodeList) -> None:
# erase original nodes in inversed topological order
for node in reversed(nodes):
gm.graph.erase_node(node)
@compatibility(is_backward_compatible=False)
def fuse_by_partitions(
gm: GraphModule,
partitions: list[dict[Node, int | None]],
prefix: str = "fused_",
always_return_tuple: bool = False,
) -> GraphModule:
for partition_id, partition in enumerate(partitions):
sorted_nodes = topo_sort(list(partition))
submodule_name = prefix + str(partition_id)
sub_gm, orig_inputs, orig_outputs = fuse_as_graphmodule(
gm,
sorted_nodes,
submodule_name,
partition,
always_return_tuple=always_return_tuple,
)
insert_subgm(gm, sub_gm, orig_inputs, orig_outputs, sorted_nodes[-1])
erase_nodes(gm, sorted_nodes)
torch.fx.passes.tools_common.stable_topological_sort(gm)
gm.graph.lint()
return gm
@@ -0,0 +1,449 @@
# mypy: allow-untyped-defs
import copy
import logging
import os
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
import torch
from torch.fx import Graph, Node
from torch.fx._compatibility import compatibility
__all__ = ["SubgraphMatcher", "InternalMatch"]
# Set`PYTORCH_MATCHER_LOGLEVEL=INFO` to see debug logs
def _init_logger():
logger = logging.getLogger(__name__)
level = os.environ.get("PYTORCH_MATCHER_LOGLEVEL", "WARNING").upper()
logger.setLevel(level)
console = logging.StreamHandler()
formatter = logging.Formatter("%(filename)s > %(message)s")
console.setFormatter(formatter)
console.setLevel(level)
# add the handlers to the logger
logger.addHandler(console)
logger.propagate = False
return logger
logger = _init_logger()
@compatibility(is_backward_compatible=False)
@dataclass
class InternalMatch:
# Nodes from which the match was found
anchors: list[Node]
# Maps nodes in the pattern subgraph to nodes in the larger graph
nodes_map: dict[Node, Node] = field(default_factory=dict)
# nodes in target graph that are matched placeholder in pattern
placeholder_nodes: list[Node] = field(default_factory=list)
# nodes in matched subgraph returned by output
returning_nodes: list[Node] = field(default_factory=list)
# map from a string name to a node in the target graph
# only available if the matcher is `SubgraphMatcherWithNameNodesMap`
name_node_map: dict[str, Node] = field(default_factory=dict)
def __copy__(self):
return InternalMatch(
anchors=self.anchors,
nodes_map=self.nodes_map.copy(),
placeholder_nodes=self.placeholder_nodes.copy(),
returning_nodes=self.returning_nodes.copy(),
)
@compatibility(is_backward_compatible=False)
class SubgraphMatcher:
def __init__(
self,
pattern: Graph,
match_output: bool = False,
match_placeholder: bool = False,
remove_overlapping_matches: bool = True,
ignore_literals: bool = False,
) -> None:
"""
Args:
pattern: the targeted matching pattern, represented in fx.Graph.
match_output: If True, output node in the pattern graph will be treated as a part of the targeted pattern.
If False, output node is ignored during match.
match_placeholder: If True, placeholder node in the pattern graph will be treated as a part of
the targeted pattern. If False, placeholder nodes will be used a wildcard.
remove_overlapping_matches: If True, in the case of overlapping matches, only the first match
will be returned.
ignore_literals: If True, will not check if literals are equal and
will instead treat them as wildcards.
"""
self.pattern = pattern
self.match_output = match_output
self.match_placeholder = match_placeholder
self.remove_overlapping_matches = remove_overlapping_matches
self.ignore_literals = ignore_literals
if len(pattern.nodes) == 0:
raise ValueError(
"SubgraphMatcher cannot be initialized with an empty pattern"
)
for node in pattern.nodes:
if node.op != "output" and not node.is_impure():
if len(node.users) == 0:
raise AssertionError(
"SubgraphMatcher cannot be initialized with an pattern with dead code"
)
# TODO: assert pattern is a connected graph
self.pattern_placeholder_nodes = [
n for n in pattern.nodes if n.op == "placeholder"
]
output_node = next(iter(reversed(pattern.nodes)))
# nodes returned by outputs
self.pattern_returning_nodes: list[Node] = output_node.all_input_nodes
self.pattern_anchors: list[Node] = []
if match_output:
self.pattern_anchors = [output_node]
else:
# If a node has output_node as the ONLY user, then this node is a graph sink,
# and should be matched against as an anchor
self.pattern_anchors = [
n for n in output_node.all_input_nodes if len(n.users) == 1
]
def _match_attributes(self, pn: Node, gn: Node) -> bool:
# Attributes matching is complicated. Right now we only support matching constant tensor
if not isinstance(pn.target, str):
raise AssertionError(f"pn.target {pn.target} must be a string.")
if not isinstance(gn.target, str):
raise AssertionError(f"gn.target {gn.target} must be a string.")
pn_value = torch.fx.graph_module._get_attr(pn.graph.owning_module, pn.target)
gn_value = torch.fx.graph_module._get_attr(gn.graph.owning_module, gn.target)
if type(pn_value) is not type(gn_value):
return False
# Don't require exact match on tensor values.
if isinstance(pn_value, torch.Tensor):
return isinstance(gn_value, torch.Tensor)
else:
raise RuntimeError(f"Unsupported type {pn_value} when matching attributes")
# pyrefly: ignore [unreachable]
return False
def _nodes_are_equal(self, pn: Node, gn: Node, node_name_match: str = "") -> bool:
# if exact match for placeholder is not required, then use placeholder as a wildcard
if not self.match_placeholder and pn.op == "placeholder":
return True
if node_name_match and node_name_match in gn.name:
return True
if pn.op == gn.op:
if pn.op == "placeholder" or pn.op == "output":
return True
elif pn.op == "get_attr":
return self._match_attributes(pn, gn)
return pn.target == gn.target
return False
def _is_contained(self, nodes_map: dict[Node, Node]) -> bool:
# `lookup` represents all the nodes in `original_graph`
# that are part of `pattern`
# Placeholders can be used by other nodes in the graphs
lookup: dict[Node, Node] = {
gn: pn for pn, gn in nodes_map.items() if pn.op != "placeholder"
}
for gn, pn in lookup.items():
# nodes returned by output are allowed to be used in other areas of the graph
if pn in self.pattern_returning_nodes:
continue
for user in gn.users:
# If this node has users that were not in `lookup`, then it must leak out of the
# pattern subgraph
if user not in lookup:
return False
return True
def _remove_overlapping_matches(
self, matches: list[InternalMatch]
) -> list[InternalMatch]:
non_overlapping_matches: list[InternalMatch] = []
nodes_matched: set[Node] = set()
for match in matches:
found_overlap = False
for pn, gn in match.nodes_map.items():
if pn.op not in {"placeholder", "output"} and gn in nodes_matched:
found_overlap = True
break
if not found_overlap:
non_overlapping_matches.append(match)
for pn, gn in match.nodes_map.items():
if pn.op not in {"placeholder", "output"}:
nodes_matched.add(gn)
return non_overlapping_matches
def _match_literals(self, pn: Any, gn: Any, match: InternalMatch) -> bool:
if isinstance(pn, Node) and isinstance(gn, Node):
raise AssertionError("pn and gn cannot both be Node")
if isinstance(pn, Node) and not isinstance(gn, Node):
if pn.op == "placeholder":
# Check if we've already matched these nodes in the current
# traversal
if pn in match.nodes_map:
return match.nodes_map[pn] == gn
match.nodes_map[pn] = gn
return True
else:
return False
elif not isinstance(pn, Node) and isinstance(gn, Node):
return False
else:
return type(gn) is type(pn) and gn == pn
def _match_nodes(
self, pn: Node, gn: Node, match: InternalMatch, node_name_match: str = ""
) -> bool:
logger.info(" matching %s to %s", pn, gn)
if not (isinstance(pn, Node) and isinstance(gn, Node)):
raise AssertionError(f"pn and gn must be Node, pn: {pn}, gn: {gn}")
# Check if we've already matched these nodes in the current
# traversal
if pn in match.nodes_map:
return match.nodes_map[pn] == gn
# TODO: use a more efficient way to check if gn is matched before: two-way dict
if gn in match.nodes_map.values():
return False
if not self._nodes_are_equal(pn, gn, node_name_match):
return False
# Optimistically mark `pn` as a match for `gn`, and save a local copy of match
saved_match = copy.copy(match)
match.nodes_map[pn] = gn
# Placeholder is a wildcard and can be matched with any python object
# (including list/tuple)
if pn.op == "placeholder":
return True
# Recursively traverse upwards to check if `pn` is a true
# match for `gn`
match_found = True
def _match_args(args1: list | tuple, args2: list | tuple) -> bool:
if len(args1) != len(args2):
return False
for a1, a2 in zip(args1, args2):
if isinstance(a1, Node) and isinstance(a2, Node):
matched = self._match_nodes(a1, a2, match)
elif isinstance(a1, (list, tuple)) and isinstance(a2, (list, tuple)):
matched = _match_args(a1, a2)
else:
matched = (
self._match_literals(a1, a2, match) or self.ignore_literals
)
if not matched:
return False
return True
# Flatten all args/kwargs into 1 list of args
pn_args, gn_args = None, None
if (
(
len(pn.args) != len(gn.args)
or list(pn.kwargs.keys()) != list(gn.kwargs.keys())
)
and pn.op == "call_function"
and isinstance(pn.target, torch._ops.OpOverload)
):
args_schema = pn.target._schema.arguments
def get_all_arguments(orig_args, orig_kwargs):
all_args = []
for i, schema in enumerate(args_schema):
if schema.name in orig_kwargs:
all_args.append(orig_kwargs[schema.name])
elif not schema.kwarg_only and i < len(orig_args):
all_args.append(orig_args[i])
else:
all_args.append(schema.default_value)
return all_args
pn_args = get_all_arguments(pn.args, pn.kwargs)
gn_args = get_all_arguments(gn.args, gn.kwargs)
elif len(pn.args) == len(gn.args) and list(pn.kwargs.keys()) == list(
gn.kwargs.keys()
):
pn_args = list(pn.args)
gn_args = list(gn.args)
pn_args.extend(list(pn.kwargs.values()))
gn_args.extend(list(gn.kwargs.values()))
else:
match_found = False
match_found = (
match_found
and pn_args is not None
and gn_args is not None
and _match_args(pn_args, gn_args)
)
if not match_found:
# revert to saved_match before matching with current node
match = copy.copy(saved_match)
return False
return True
def match(self, graph: Graph, node_name_match: str = "") -> list[InternalMatch]:
"""
Returns:
The matched subgraphs.
The returned subgraph would be fully self-contained, meaning the nodes (except placeholder
and nodes returned by output) can only be consumed by nodes within the matched subgraph.
Subgraph pattern matcher is implemented with the backtracking style in the following steps:
1. We first identify all the anchor nodes in the pattern graph. The anchor nodes
are the "sinks" (nodes with no user other than the output node) of the pattern graph.
One pattern graph could have multiple anchors if it has multiple return values.
2. In the target graph, we identify the potential candidate nodes that can be matched
with each anchor. These anchor-candidate pairs are the starting points for
pairwise per-node matching.
3. For each anchor-candidate pair, we simultaneously traverse backwards (DFS) in both
pattern and target graphs. For every pattern nodes along traversal path, we compare it
against the target nodes. In case any comparison failed, the match for this anchor-candidate
pair fails. A match is found when DFS completes traversing the graph. See `self._match_nodes`
for more details.
4. In the case of multiple anchors, every anchor will need to find a match using step 3.
In addition, the matches found between anchors need to have a common intersection node
in order for the match to be valid. This is implemented with backtracking. See `backtracking`
for more details.
Notice: graph traversal must be done in the reverser order because a tensor can have multiple
consumers, but can only have a single producer. Only with reverser order, we can we jointly
traverse the pattern and target graph in a deterministic path.
Warning: In theory, this backtracking algorithm have an **exponential** time complexity. However,
in practice, it's unlikely to blow up.
"""
from torch.fx.passes.utils.fuser_utils import validate_partition
# find candidate nodes to match with pattern anchors
match_candidates: dict[Node, list[Node]] = defaultdict(list)
for pattern_anchor in self.pattern_anchors:
for node in graph.nodes:
if self._nodes_are_equal(pattern_anchor, node, node_name_match):
match_candidates[pattern_anchor].append(node)
match_candidates_list = list(match_candidates.items())
logger.info("Initial match_candidates_list: %s\n", match_candidates_list)
matches: list[InternalMatch] = []
def backtracking(anchor_index, match):
if anchor_index == len(match_candidates_list):
match.placeholder_nodes = [
match.nodes_map[pn] for pn in self.pattern_placeholder_nodes
]
match.returning_nodes = [
match.nodes_map[pn] for pn in self.pattern_returning_nodes
]
matches.append(match)
logger.info("Found a match: %s\n", match)
return
pattern_anchor, candidate_nodes = match_candidates_list[anchor_index]
saved_match = copy.copy(match)
for node in candidate_nodes:
logger.info("Trying to match anchor %s to %s", pattern_anchor, node)
match_found = self._match_nodes(
pattern_anchor, node, match, node_name_match
)
if match_found:
# match next anchor
backtracking(anchor_index + 1, match)
else:
logger.info(
"Failed to match anchor %s to %s\n", pattern_anchor, node
)
# revert to saved_match before matching with current anchor
match = copy.copy(saved_match)
match = InternalMatch(anchors=self.pattern_anchors)
if match_candidates_list:
backtracking(0, match)
# filter out the matches where the subgraph is not fully_contained
before = len(matches)
matches = [match for match in matches if self._is_contained(match.nodes_map)]
after = len(matches)
if before != after:
logger.info(
"Filtered out %s matches because they are not fully contained",
before - after,
)
# filter out the matches that form a cycle if the subgraph is fused
valid_matches = []
for match in matches:
matched_compute_nodes = [
gn
for pn, gn in match.nodes_map.items()
if pn.op not in {"placeholder", "output"}
]
if validate_partition(matched_compute_nodes):
valid_matches.append(match)
if len(valid_matches) != len(matches):
logger.info(
"Filtered out %s matches because \
matched subgraph would form a cycle if fused",
len(matches) - len(valid_matches),
)
if self.remove_overlapping_matches:
before = len(valid_matches)
matches = self._remove_overlapping_matches(valid_matches)
after = len(matches)
if before != after:
logger.info(
"Filtered out %s matches because matched subgraphs are overlapping",
before - after,
)
logger.info("Matches returned: %s", matches)
return matches
@@ -0,0 +1,116 @@
from torch.fx import Graph, GraphModule, Node
from torch.fx._compatibility import compatibility
from .matcher_utils import InternalMatch, SubgraphMatcher
__all__ = ["SubgraphMatcherWithNameNodeMap"]
def _split_to_graph_and_name_node_map(
gm: GraphModule,
) -> tuple[GraphModule, dict[str, Node]]:
from torch.fx.graph import _PyTreeInfo
from torch.utils._pytree import tree_flatten, tree_unflatten
name_node_map = {}
for n in gm.graph.nodes:
if n.op == "output":
if gm._out_spec is None:
raise AssertionError("gm._out_spec is None")
output = tree_unflatten(n.args[0], gm._out_spec)
if not isinstance(output, tuple):
raise AssertionError("Expecting the pattern graph to return a tuple")
if len(output) < 2:
raise AssertionError(
"Expecting the pattern graph to have at least two outputs"
)
*out, name_node_map = output
flattened, out_spec = tree_flatten(out)
if not isinstance(name_node_map, dict):
raise AssertionError(
"Expecting the input graph to have a dict output as the last element"
)
n.args = (flattened,)
orig_pytree_info = gm._graph._codegen.pytree_info # type: ignore[attr-defined]
gm._graph._codegen.pytree_info = _PyTreeInfo( # type: ignore[attr-defined]
orig_pytree_info.orig_args, orig_pytree_info.in_spec, out_spec
)
gm.recompile()
return gm, name_node_map
@compatibility(is_backward_compatible=False)
class SubgraphMatcherWithNameNodeMap(SubgraphMatcher):
"""Extends SubgraphMatcher to support querying the matched subgraph nodes through node name,
this requires pattern to have specific format (returning and additional dictionary at the output,
that has node name as key, and the node in the pattern graph as value, see Example for more details)
Difference with SubgraphMatcher is that it takes a `pattern_gm` GraphModule as input during
initialization since we need to modify the graph (which requires `recompile` the GraphModule)
Example::
def pattern(x, weight):
conv = F.conv2d(x, weight)
relu = F.relu(conv)
return relu, {"conv": conv, "relu": relu}
def target_graph(x, weight):
conv = F.conv2d(x, weight)
relu = F.relu(conv)
relu *= 2
return relu
pattern_gm = export(pattern, example_inputs).module()
target_gm = export(target_graph, example_inputs).module()
matcher = SubgraphMatcherWithNameNodeMap(pattern_gm)
matches = matcher.match(target_gm)
for match in matches:
match.name_node_map["conv"].meta["annotation"] = ...
"""
def __init__(
self,
pattern_gm: GraphModule,
match_output: bool = False,
match_placeholder: bool = False,
remove_overlapping_matches: bool = True,
ignore_literals: bool = False,
) -> None:
pattern_gm, name_node_map = _split_to_graph_and_name_node_map(pattern_gm)
self.name_node_map = name_node_map
super().__init__(
pattern_gm.graph,
match_output,
match_placeholder,
remove_overlapping_matches,
ignore_literals,
)
def match(self, graph: Graph, node_name_match: str = "") -> list[InternalMatch]:
"""The returned InternalMatch will have name_node_map populated with a map
from node name (str) to the target node, e.g.
{"conv": target_conv_ndoe, "relu": target_relu_node}
this requires the pattern graph returns an additional
output of node name to node, e.g. instead of:
```
def pattern(...):
...
return relu
```
we should do:
```
def pattern(...):
...
return relu, {"conv": conv, "relu": relu}
``` instead
"""
internal_matches = super().match(graph, node_name_match)
for internal_match in internal_matches:
for k, n in self.name_node_map.items():
internal_match.name_node_map[k] = internal_match.nodes_map[n]
return internal_matches
@@ -0,0 +1,191 @@
import logging
import os
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from torch.fx._compatibility import compatibility
from torch.fx.graph import Graph
from torch.fx.node import Node
__all__ = ["get_source_partitions", "check_subgraphs_connected", "SourcePartition"]
# Set`PYTORCH_MATCHER_LOGLEVEL=INFO` to see debug logs
def _init_logger() -> logging.Logger:
logger = logging.getLogger(__name__)
level = os.environ.get("PYTORCH_MATCHER_LOGLEVEL", "WARNING").upper()
logger.setLevel(level)
console = logging.StreamHandler()
formatter = logging.Formatter("%(filename)s > %(message)s")
console.setFormatter(formatter)
console.setLevel(level)
# add the handlers to the logger
logger.addHandler(console)
logger.propagate = False
return logger
logger = _init_logger()
@compatibility(is_backward_compatible=False)
@dataclass
class SourcePartition:
# Nodes in a particular partition
nodes: list[Node]
# The source these nodes decomposed from
source: Any
# Nodes in the graph that are needed as inputs to the partition
# These do not include the params of the partition
input_nodes: list[Node] = field(default_factory=list)
# Nodes in the partition that are being used by nodes outside of the
# partition
output_nodes: list[Node] = field(default_factory=list)
# Parameters that are being used
params: list[Node] = field(default_factory=list)
@compatibility(is_backward_compatible=False) # type: ignore[misc]
def get_source_partitions(
graph: Graph,
wanted_sources: list[Any],
filter_fn: Callable[[Node], bool] | None = None,
) -> dict[Any, list[SourcePartition]]:
"""
Args:
graph: The graph we want to partition
wanted_sources: List of sources of nodes that were decomposed from this
source. This can be a function (ex. torch.nn.functional.linear) or a
leaf module type (ex. torch.nn.Linear).
Returns:
Dictionary mapping sources that were given to a list of SourcePartitions
that correspond to the list of nodes that were decomposed from the given
source.
"""
modules: dict[type, dict[str, list[Node]]] = {}
def add_to_partition(src: Any, fqn: str, node: Node) -> None:
diff_modules = modules.setdefault(src, {})
partition = diff_modules.setdefault(fqn, [])
partition.append(node)
for node in graph.nodes:
# The metadata source_fn should contain a tuple of a unique name for the
# source, and the source function if the node is decomposed from a
# function, or the type of module if the node is decomposed from a leaf
# module
# TODO: Bypass "torch_fn" when "source_fn_stack" because now "torch_fn" can
# be different from "source_fn_stack", for example for the add_ node
# decomposed from batch norm. We should remove the check on "source_fn_stack"
# after we fix "torch_fn". T199561090
source_fn_st = node.meta.get("source_fn_stack", None)
if source_fn_st is None:
matched = False
torch_fn = node.meta.get("torch_fn", None)
if torch_fn is not None:
node_fqn, source_fn = torch_fn
source_fn_name = source_fn.split(".")[1]
if source_fn_name in wanted_sources:
add_to_partition(source_fn_name, node_fqn, node)
matched = True
# Fallback: when source_fn_stack is not populated (e.g. strict=False export),
# use nn_module_stack to resolve the originating module type.
# Only apply to call_function nodes to avoid incorrectly including
# placeholder, get_attr, or output nodes in partitions.
if not matched and node.op == "call_function":
nn_module_stack = node.meta.get("nn_module_stack", None)
if nn_module_stack:
# Get the innermost module (last entry in the ordered dict)
innermost_fqn, innermost_cls = list(nn_module_stack.values())[-1]
for src in wanted_sources:
if isinstance(src, type):
if isinstance(innermost_cls, type) and issubclass(
innermost_cls, src
):
add_to_partition(src, innermost_fqn, node)
break
elif isinstance(innermost_cls, str):
src_str = src.__module__ + "." + src.__qualname__
if innermost_cls == src_str:
add_to_partition(src, innermost_fqn, node)
break
elif innermost_cls == src:
add_to_partition(src, innermost_fqn, node)
break
if source_fn_st is not None:
source_fn = source_fn_st[-1]
if source_fn[1] in wanted_sources:
add_to_partition(source_fn[1], source_fn[0], node)
def make_partition(nodes: list[Node], module_type: type) -> SourcePartition:
input_nodes = set()
output_nodes = set()
params = set()
for node in nodes:
for arg in node.args:
if isinstance(arg, Node) and arg not in nodes and arg.op != "get_attr":
input_nodes.add(arg)
if node.op == "get_attr":
params.add(node)
# get_attr nodes won't be output nodes
continue
for user in node.users:
if user not in nodes:
output_nodes.add(node)
return SourcePartition(
nodes,
module_type,
list(input_nodes),
list(output_nodes),
list(params), # type: ignore[arg-type]
)
ret: dict[type[Any], list[SourcePartition]] = {}
if filter_fn:
# for each partition, we apply filter_fn to filter out all partitions that doesn't satisfy the
# filter condition
filtered_modules = {}
for tp, name_to_partition in modules.items():
filtered_name_to_partition = {
name: partition
for name, partition in name_to_partition.items()
if all(map(filter_fn, partition))
}
filtered_modules[tp] = filtered_name_to_partition
modules = filtered_modules
for k, v in modules.items():
ret[k] = [make_partition(partition, k) for partition in v.values()]
return ret
@compatibility(is_backward_compatible=False) # type: ignore[misc]
def check_subgraphs_connected(
subgraph1: SourcePartition, subgraph2: SourcePartition
) -> bool:
"""
Given two subgraphs A and B (in the form of a list of nodes), checks if
A has nodes connecting to at least one node in B -- aka there exists a node
in B that uses a node in A (not the other way around).
"""
for node in reversed(subgraph1.nodes):
for user in node.users:
if user in subgraph2.nodes:
return True
return False
@@ -0,0 +1,973 @@
import collections
import copy
import dis
import enum
import inspect
import logging
import operator
import sys
import traceback
import types
from collections import OrderedDict
from collections.abc import Callable, Iterator
from dataclasses import fields, is_dataclass
from typing import Any, cast, TypeVar
from typing_extensions import Never
import torch
import torch.fx.traceback as fx_traceback
from torch._C import _fx_map_arg as map_arg
from torch._library.opaque_object import is_opaque_value_type
from torch._logging import getArtifactLogger
from torch.utils._pytree import tree_map_
from torch.utils._traceback import CapturedTraceback
from ._compatibility import compatibility
from .graph import Graph, magic_methods, reflectable_magic_methods
from .immutable_collections import immutable_dict, immutable_list
from .node import Argument, base_types, Node, Target
from .operator_schemas import check_for_mutable_operation
__all__ = [
"TracerBase",
"GraphAppendingTracer",
"TraceError",
"Proxy",
"MetaProxy",
"Attribute",
"ParameterProxy",
"Scope",
"ScopeContextManager",
]
log = logging.getLogger(__name__)
annotation_log = getArtifactLogger(__name__, "annotation")
def _is_arbitrary_callable(obj: object) -> bool:
"""
Returns True if obj is an arbitrary callable (function, lambda, method, etc.)
that requires special tracing to handle. These cannot be symbolically traced
using the standard Proxy mechanism.
"""
import functools
import types
return isinstance(
obj,
(
types.FunctionType,
types.MethodType,
types.BuiltinFunctionType,
types.BuiltinMethodType,
functools.partial,
),
)
def _find_arbitrary_callable(
args: tuple[object, ...], kwargs: dict[str, object]
) -> object:
"""
Recursively searches args and kwargs for any arbitrary callable.
Returns the first arbitrary callable found, or None if none exist.
"""
found = None
_T = TypeVar("_T")
def check(obj: _T) -> _T:
nonlocal found
if found is not None:
return obj
if _is_arbitrary_callable(obj):
found = obj
return obj
tree_map_(check, args)
tree_map_(check, kwargs)
return found
@compatibility(is_backward_compatible=False)
class Scope:
"""Scope object that records the module path and the module type
of a module. Scope is used to track the information of the module
that contains a Node in a Graph of GraphModule. For example::
class Sub(torch.nn.Module):
def forward(self, x):
# This will be a call_method Node in GraphModule,
# scope for this would be (module_path="sub", module_type=Sub)
return x.transpose(1, 2)
class M(torch.nn.Module):
def __init__(self) -> None:
self.sub = Sub()
def forward(self, x):
# This will be a call_method Node as well,
# scope for this would be (module_path="", None)
x = x.transpose(1, 2)
x = self.sub(x)
return x
"""
def __init__(self, module_path: str, module_type: Any) -> None:
super().__init__()
self.module_path = module_path
self.module_type = module_type
@compatibility(is_backward_compatible=False)
class ScopeContextManager:
"""A context manager to track the Scope of Node during symbolic tracing.
When entering a forward function of a Module, we'll update the scope information of
the current module, and when we exit, we'll restore the previous scope information.
"""
def __init__(
self,
scope: Scope,
current_scope: Scope,
) -> None:
super().__init__()
# Keep a copy of prev scope to restore on exit
self._prev_scope = copy.copy(scope)
# Update scope to current scope
scope.module_path = current_scope.module_path
scope.module_type = current_scope.module_type
# Save a reference so we can restore it
self._scope = scope
def __enter__(self) -> Scope:
return self._scope
def __exit__(self, *args: object) -> None:
self._scope.module_path = self._prev_scope.module_path
self._scope.module_type = self._prev_scope.module_type
return
_COPY_META_FIELDS = [
"nn_module_stack",
"torch_fn",
"source_fn_stack",
"original_aten",
"recompute",
"ac_graph_id",
"has_backward_hook",
"from_node",
"quantization_tag", # TODO deprecated
"_numeric_debug_handle", # TODO deprecated
"custom",
"partitioner_tag",
]
@compatibility(is_backward_compatible=True)
class TracerBase:
graph: Graph
record_stack_traces: bool = False
# When record_stack_traces is True, only reocrd stack traces
# with forward function names.
# This helps when we want stack trace back to model code
_record_forward_stack_traces_only: bool = False
# Feature flag for mutable schema checking
# Enableby default in 1.12
check_mutable_operations: bool = False
# Feature flag for assert tracing
trace_asserts: bool = False
# Feature flag for proxying accesses to buffer values
proxy_buffer_attributes: bool = False
# Name of the function to be traced. It will only be used when
# ``root`` is an instance of ``nn.Module``
traced_func_name: str = "forward"
# Maps the containing module's name to the operator name
scope: Scope
# Records the module call stack
module_stack: OrderedDict[str, tuple[str, Any]]
# Mapping of node name to module scope
node_name_to_scope: dict[str, tuple[str, type]]
@compatibility(is_backward_compatible=True)
def create_node(
self,
kind: str,
target: Target,
args: tuple[Argument, ...],
kwargs: dict[str, Argument],
name: str | None = None,
type_expr: Any | None = None,
) -> Node:
"""
Inserts a graph node given target, args, kwargs, and name.
This method can be overridden to do extra checking, validation, or
modification of values used in node creation. For example, one might
want to disallow in-place operations from being recorded.
"""
if kind == "call_function" and self.check_mutable_operations:
target_fn = cast(Callable[..., Any], target)
check_for_mutable_operation(target_fn, args, kwargs)
node = self.graph.create_node(kind, target, args, kwargs, name, type_expr)
# TODO node_name_to_scope will be depreciated in favor of
# node.meta['nn_module_stack']
self.node_name_to_scope[node.name] = (
self.scope.module_path,
self.scope.module_type,
)
# Optionally set stack trace on the created Node for debugging purposes
if fx_traceback.has_preserved_node_meta():
current_meta: dict[str, Any] = fx_traceback.get_current_meta()
stack_trace = current_meta.get("stack_trace")
if stack_trace:
node.stack_trace = stack_trace
if fx_traceback.GRADIENT_ACC_SPECIAL_STACK in stack_trace:
node.meta["is_gradient_acc"] = True
node.meta["autograd_backward"] = True
# Explicitly set the stack_trace, nn_module_stack and source_fn on the node.meta
# If other meta fields are needed, they can be added here
for field in _COPY_META_FIELDS:
if field in current_meta:
node.meta[field] = copy.copy(current_meta[field])
new_seq_nr = _get_seq_nr(node.name)
if new_seq_nr is not None:
annotation_log.debug(
"Assigning new_seq_nr %s to %s", new_seq_nr, node.name
)
node.meta["seq_nr"] = new_seq_nr
# See Note [Functionalization View Replay Annotation]
# Overriding some node meta with the original node meta of the
# regenerated node.
replay_node: Node | None = fx_traceback.get_current_replay_node()
if replay_node is not None:
node.meta["is_functional_regenerated"] = True
if "custom" in replay_node.meta:
node.meta["custom"] = replay_node.meta.get("custom")
if "stack_trace" in replay_node.meta:
node.stack_trace = replay_node.meta.get("stack_trace")
if current_meta.get("autograd_backward", False):
node.meta["autograd_backward"] = True
elif self.module_stack:
node.meta["nn_module_stack"] = copy.copy(self.module_stack)
if self.record_stack_traces and not node.stack_trace:
user_stack_summary = CapturedTraceback.extract().summary()
if user_stack_summary:
user_stack_summary = self._filter_traceback_frames(user_stack_summary)
if user_stack_summary:
node.stack_trace = "".join(user_stack_summary.format()).strip()
log.debug("create_node %s", node)
return node
def _filter_traceback_frames(
self, user_stack_summary: traceback.StackSummary
) -> traceback.StackSummary:
# This method can be overridden to customize the frame filtering logic
# for the recorded stack trace
user_frames: list[traceback.FrameSummary] = []
if self._record_forward_stack_traces_only:
user_frames = [
frame
for frame in user_stack_summary
if (
frame.name == "forward"
or frame.filename.endswith("torch/__init__.py")
)
]
else:
first_forward = -1
for i, frame in enumerate(user_stack_summary):
if frame.name == "forward":
user_frames = user_stack_summary[i:]
first_forward = i
break
# Not having a "forward" call in the stacktrace implies the
# stacktrace will probably be irrelevant
if first_forward == -1:
user_frames: list[traceback.FrameSummary] = []
from torch.fx.experimental.symbolic_shapes import uninteresting_files
user_frames = [
frame
for frame in user_frames
if frame.filename not in uninteresting_files()
]
return traceback.StackSummary.from_list(user_frames)
@compatibility(is_backward_compatible=True)
def proxy(self, node: Node) -> "Proxy":
return Proxy(node, self)
@compatibility(is_backward_compatible=True)
def create_proxy(
self,
kind: str,
target: Target,
args: tuple[Any, ...],
kwargs: dict[str, Any],
name: str | None = None,
type_expr: Any | None = None,
proxy_factory_fn: Callable[[Node], "Proxy"] | None = None,
) -> "Proxy":
"""
Create a Node from the given arguments, then return the Node
wrapped in a Proxy object.
If kind = 'placeholder', then we're creating a Node that
represents the parameter of a function. If we need to encode
a default parameter, we use the ``args`` tuple. ``args`` is
otherwise empty for ``placeholder`` Nodes.
"""
args_ = self.create_arg(args)
kwargs_ = self.create_arg(kwargs)
if not isinstance(args_, tuple):
raise AssertionError(f"Expected args_ to be tuple, got {type(args_)}")
if not isinstance(kwargs_, dict):
raise AssertionError(f"Expected kwargs_ to be dict, got {type(kwargs_)}")
node = self.create_node(kind, target, args_, kwargs_, name, type_expr)
if not proxy_factory_fn:
proxy = self.proxy(node)
else:
proxy = proxy_factory_fn(node)
return proxy
def _find_user_frame(self) -> types.FrameType | None:
"""
Find the Python stack frame executing the user code during
symbolic tracing.
"""
# We have to do a little dance here. Basically, walk up the callstack and
# record the first frame not in the pytorch source. This is the frame executing
# the user code during tracing.
frame = inspect.currentframe()
pt_files = [
"torch/fx/proxy.py",
"torch/fx/_symbolic_trace.py",
"torch/fx/experimental/proxy_tensor.py",
"torch/_ops.py",
"torch/_tensor.py",
"torch/utils/_python_dispatch.py",
"torch/_prims_common/wrappers.py",
"torch/_refs/__init__.py",
"torch/_refs/nn/functional/__init__.py",
"torch/utils/_stats.py",
]
while frame:
frame = frame.f_back
if frame and all(
not frame.f_code.co_filename.endswith(file) for file in pt_files
):
break
if not frame:
return None
return frame
@compatibility(is_backward_compatible=True)
def create_arg(self, a: Any) -> Argument:
"""
A method that lowers the objects seen as arguments during symbolic evaluation
into Argument types that can be stored in IR.
Can be override to support more trace-specific types.
"""
# IMPORTANT: Are you here because you are trying to proxy a new type into
# the graph? Please Please Please contact someone on the PyTorch Compiler team;
# the considerations are subtle.
#
# 1) When you add a new type, all of the downstream consumers and pass writers
# need to handle the new type. torch.fx is intended to be easy to write
# passes for, so we will push back against new types.
# 2) In torch.compile's IR, there are only specific operations that go
# into the graph. In particular, Tensor operations should go into the graph,
# but non-Tensor operations shouldn't. What that means is that constructors
# for new types *SHOULD NOT* become nodes in the FX graph.
handler = _create_arg_bypass.get(type(a))
if handler is not None:
# this is just a performance optimization and can be removed if needed
# for common types, we have a fast path to avoid isinstance() overhead
# this doesn't remove the checks below since we need to handle subclasses
return handler(self, a)
if isinstance(a, Proxy):
return a.node # most common arg type goes first
elif hasattr(a, "__fx_create_arg__"):
return a.__fx_create_arg__(self)
# aggregates
elif isinstance(a, tuple):
if hasattr(a, "_fields"):
# NamedTuple constructors don't seem to like getting a generator
# expression as an argument to their constructor, so build this
# intermediate tuple and unpack it into the NamedTuple constructor
args = [self.create_arg(elem) for elem in a]
return type(a)(*args) # type: ignore[arg-type]
return type(a)([self.create_arg(elem) for elem in a])
elif isinstance(a, list):
return [self.create_arg(elem) for elem in a]
elif isinstance(a, dict):
return _create_arg_dict(self, a)
elif isinstance(a, slice):
return slice(
self.create_arg(a.start),
self.create_arg(a.stop),
self.create_arg(a.step),
)
elif isinstance(a, range):
return range( # pyrefly: ignore[no-matching-overload]
self.create_arg(a.start), # pyrefly: ignore[bad-argument-type]
self.create_arg(a.stop), # pyrefly: ignore[bad-argument-type]
self.create_arg(a.step), # pyrefly: ignore[bad-argument-type]
)
elif isinstance(a, (torch._ops.OpOverload, torch._ops.HigherOrderOperator)):
return a # pyrefly: ignore[bad-return]
elif is_opaque_value_type(type(a)):
return a
elif is_dataclass(a):
kwargs = {
field.name: self.create_arg(getattr(a, field.name))
for field in fields(a)
}
return self.create_node("call_function", a.__class__, (), kwargs)
elif isinstance(a, (*base_types, enum.Enum)) or a is None or a is ...:
return a # pyrefly: ignore[bad-return]
raise NotImplementedError(f"argument of type: {type(a)}")
@compatibility(is_backward_compatible=True)
def to_bool(self, obj: "Proxy") -> bool:
"""Called when a proxy object is being converted to a boolean, such as
when used in control flow. Normally we don't know what to do because
we don't know the value of the proxy, but a custom tracer can attach more
information to the graph node using create_node and can choose to return a value.
"""
raise TraceError(
"symbolically traced variables cannot be used as inputs to control flow"
)
@compatibility(is_backward_compatible=True)
def iter(self, obj: "Proxy") -> Iterator:
"""Called when a proxy object is being iterated over, such as
when used in control flow. Normally we don't know what to do because
we don't know the value of the proxy, but a custom tracer can attach more
information to the graph node using create_node and can choose to return an iterator.
"""
raise TraceError(
"Proxy object cannot be iterated. This can be "
"attempted when the Proxy is used in a loop or"
" as a *args or **kwargs function argument. "
"See the torch.fx docs on pytorch.org for a "
"more detailed explanation of what types of "
"control flow can be traced, and check out the"
" Proxy docstring for help troubleshooting "
"Proxy iteration errors"
)
@compatibility(is_backward_compatible=True)
def keys(self, obj: "Proxy") -> "Proxy":
"""Called when a proxy object is has the keys() method called.
This is what happens when ** is called on a proxy. This should return an
iterator it ** is suppose to work in your custom tracer.
"""
return Attribute(obj, "keys")()
def _get_seq_nr(node_name: str = "") -> int | None:
"""
Returns the seq_nr node meta for the current proxy node that we're creating.
The seq_nr number in node meta is related to but not the same as the "sequence number"
in autograd.
We use the seq_nr to correlate forward and backward nodes in the traced FX graphs
(e.g. in copy_fwd_metadata_to_bw_nodes).
The corresponding forward and backward FX graph nodes should have the same seq_nr.
The ordering of the seq_nr in the graph does not indicate when the node is executed.
For example, the nodes in the invoke_subgraph HOP's subgraph may be called multiple
times by different HOP nodes, but these nodes only have a single seq_nr, which may be
smaller, the same, or larger than the calling HOP.
`node_name` is the name of the node that we're creating. It is used for logging only.
"""
current_meta: dict[str, Any] = fx_traceback.get_current_meta()
new_seq_nr = None
# The sequence_nr increments every time a new autograd Node
# is created. During the FWD pass we store the sequence_nr
# corresponding to the last autograd Node created on this fx
# node's meta. A single aten op can create multiple autograd
# nodes as is the case with in-place foreach ops. During the
# BWD pass we retrieve the sequence_nr stored on the current
# executing autograd Node. See NOTE [ Sequence Number ].
if current_meta.get("in_grad_fn", 0) > 0:
# This branch is used to get seq_nr for backward nodes
annotation_log.debug("%s: seq_nr from current_meta grad_fn_seq_nr", node_name)
new_seq_nr = current_meta["grad_fn_seq_nr"][-1]
elif torch.fx.traceback._is_preserving_node_seq_nr():
# Special case where we preserve seq_nr from currently tracing node
# Used to preserve seq_nr when re-tracing subgraphs in HOP
annotation_log.debug("%s: seq_nr from current_meta seq_nr", node_name)
new_seq_nr = current_meta.get("seq_nr")
else:
# Here we decrement to account for the sequence_nr having
# just been incremented while tracing this lowered aten op.
# This branch is used to get seq_nr for forward nodes
new_seq_nr = torch.autograd._get_sequence_nr() - 1
if not torch.fx.traceback._is_preserving_node_seq_nr():
# See Note [Functionalization View Replay Annotation]
# Overriding some node meta with the original node meta of the
# regenerated node.
replay_node: Node | None = fx_traceback.get_current_replay_node()
if replay_node is not None:
if "seq_nr" in replay_node.meta:
annotation_log.debug("%s: seq_nr from replay_node", node_name)
new_seq_nr = replay_node.meta["seq_nr"]
return new_seq_nr
# used in Proxy object when just appending to the graph while not tracing.
@compatibility(is_backward_compatible=True)
class GraphAppendingTracer(TracerBase):
def __init__(self, graph: Graph) -> None:
super().__init__()
self.graph = graph
self.scope = Scope("", None)
self.module_stack: OrderedDict[str, tuple[str, Any]] = collections.OrderedDict()
self.node_name_to_scope: dict[str, tuple[str, type]] = {}
@compatibility(is_backward_compatible=False)
def assert_fn(x: object) -> None:
if not x:
raise AssertionError("Assertion failed")
@compatibility(is_backward_compatible=True)
class TraceError(ValueError):
pass
@compatibility(is_backward_compatible=True)
class Proxy:
"""
``Proxy`` objects are ``Node`` wrappers that flow through the
program during symbolic tracing and record all the operations
(``torch`` function calls, method calls, operators) that they touch
into the growing FX Graph.
If you're doing graph transforms, you can wrap your own ``Proxy``
method around a raw ``Node`` so that you can use the overloaded
operators to add additional things to a ``Graph``.
``Proxy`` objects cannot be iterated. In other words, the symbolic
tracer will throw an error if a ``Proxy`` is used in a loop or as
an ``*args``/``**kwargs`` function argument.
There are two main ways around this:
1. Factor out the untraceable logic into a top-level function and
use ``fx.wrap`` on it.
2. If the control flow is static (i.e. the loop trip count is
based on some hyperparameter), the code can be kept in its original
position and refactored into something like::
for i in range(self.some_hyperparameter):
indexed_item = proxied_value[i]
For a more detailed description into the Proxy internals, check out
the "Proxy" section in `torch/fx/README.md`
"""
@compatibility(is_backward_compatible=True)
def __init__(self, node: Node, tracer: "TracerBase | None" = None) -> None:
if tracer is None:
# This allows you to create a Proxy object around a raw Node
tracer = GraphAppendingTracer(node.graph)
self.tracer = tracer
self.node = node
def __repr__(self) -> str:
return f"Proxy({self.node.name})"
def __getattr__(self, k: str) -> "Attribute":
# note: not added to the graph yet, if this is a method call
# we peephole optimize to the method invocation
return Attribute(self, k)
def __getstate__(self) -> dict[str, Any]:
return self.__dict__
def __deepcopy__(self, memo: dict[int, Any]) -> "Proxy":
# We have to explicitly override this method, because otherwise deepcopy
# will go to __getattr__(self, "__deepcopy__") and return a
# Attribute(__deepcopy__), and may go into an infinite loop in some cases.
import copy
new_dict: dict[str, Any] = {}
for k, v in self.__dict__.items():
try:
new_obj = copy.deepcopy(v, memo)
except Exception:
log.warning(
"Shallow copy %s of Proxy because it cannot be deepcopied. "
"Proxy is created for node %s",
k,
self.node.name,
)
new_obj = copy.copy(v)
new_dict[k] = new_obj
if "node" not in new_dict:
raise AssertionError("'node' not in new_dict during proxy unpickling")
if "tracer" not in new_dict:
raise AssertionError("'tracer' not in new_dict during proxy unpickling")
new_proxy = Proxy(new_dict["node"], new_dict["tracer"])
for k, v in new_dict.items():
new_proxy.__dict__[k] = v
return new_proxy
def __setstate__(self, d: dict[str, Any]) -> None:
# This is called when being unpickled/loaded.
self.__dict__ = d
def __call__(self, *args: Any, **kwargs: Any) -> "Proxy":
return self.tracer.create_proxy(
"call_method", "__call__", (self,) + args, kwargs
)
def __iter__(self) -> Iterator["Proxy"]:
frame = inspect.currentframe()
if frame is None:
raise AssertionError("inspect.currentframe() returned None")
calling_frame = frame.f_back
if calling_frame is None:
raise AssertionError("frame.f_back is None")
inst_list = list(dis.get_instructions(calling_frame.f_code))
if sys.version_info >= (3, 11):
from bisect import bisect_left
inst_idx = bisect_left(
inst_list, calling_frame.f_lasti, key=lambda x: x.offset
)
else:
inst_idx = calling_frame.f_lasti // 2
inst = inst_list[inst_idx]
if inst.opname == "UNPACK_SEQUENCE":
return (self[i] for i in range(inst.argval)) # type: ignore[index]
return self.tracer.iter(self)
def __abs__(self) -> "Proxy":
return self.tracer.create_proxy("call_function", operator.abs, (self,), {})
def __bool__(self) -> bool:
if self.tracer.trace_asserts:
# check if this boolean is used in an assertion, bytecode pattern for assertions
# is pretty stable for Python 3.7--3.9
frame = inspect.currentframe()
if frame is None:
raise AssertionError("inspect.currentframe() returned None")
calling_frame = frame.f_back
if calling_frame is None:
raise AssertionError("frame.f_back is None")
insts = list(dis.get_instructions(calling_frame.f_code))
if sys.version_info >= (3, 11):
from bisect import bisect_left
cur = bisect_left(insts, calling_frame.f_lasti, key=lambda x: x.offset)
else:
cur = calling_frame.f_lasti // 2
inst = insts[cur]
if inst.opname == "POP_JUMP_IF_TRUE":
first = insts[cur + 1]
if inst.arg is None:
raise AssertionError("inst.arg is None for POP_JUMP_IF_TRUE")
last = insts[inst.arg // 2 - 1]
starts_with_assert = (
first.opname == "LOAD_GLOBAL"
and first.argval == "AssertionError"
or first.opname == "LOAD_ASSERTION_ERROR"
)
if starts_with_assert and last.opname == "RAISE_VARARGS":
self.tracer.create_proxy("call_function", assert_fn, (self,), {})
return True
return self.tracer.to_bool(self)
@compatibility(is_backward_compatible=True)
def keys(self) -> "Proxy":
return self.tracer.keys(self)
def __len__(self) -> int:
raise RuntimeError(
"'len' is not supported in symbolic tracing by default. If you want "
"this call to be recorded, please call torch.fx.wrap('len') at "
"module scope"
)
@classmethod
def __torch_function__(
cls,
orig_method: Callable[..., Any],
types: tuple[type, ...],
args: tuple[Any, ...] | None = None,
kwargs: dict[str, Any] | None = None,
) -> "Proxy":
args = args if args else ()
kwargs = kwargs if kwargs else {}
tracers: dict[TracerBase, None] = {}
def find_tracer(a: Any) -> None:
if isinstance(a, cls):
tracers[a.tracer] = None
tree_map_(find_tracer, args)
tree_map_(find_tracer, kwargs)
if len(tracers) > 1:
raise RuntimeError(
f"Found multiple different tracers {list(tracers.keys())} while "
f"trying to trace operations {orig_method}"
)
tracer = next(iter(tracers.keys()))
if isinstance(orig_method, torch._C.ScriptMethod):
args = (orig_method.owner,) + args
return tracer.create_proxy("call_method", orig_method.name, args, kwargs)
if torch.overrides.is_tensor_method_or_property(orig_method):
return tracer.create_proxy(
"call_method", orig_method.__name__, args, kwargs
)
else:
if isinstance(orig_method, torch._ops.HigherOrderOperator):
bad_callable = _find_arbitrary_callable(args, kwargs)
if bad_callable is not None:
raise RuntimeError(
f"Unable to symbolically trace the HigherOrderOperator "
f"{orig_method._name} because it received an arbitrary "
f"callable argument {bad_callable}. Use make_fx or dynamo "
f"tracing instead."
)
return tracer.create_proxy(
"call_function",
orig_method,
args,
kwargs,
name=tracer.graph._target_to_str(orig_method.__name__),
)
@compatibility(is_backward_compatible=False)
class MetaProxy(Proxy):
"""
A Proxy subclass that propagates metadata (meta['val']) during graph tracing.
"""
def __init__(
self, node: Node, tracer: "TracerBase | None" = None, fake_mode: Any = None
) -> None:
super().__init__(node, tracer)
self.fake_mode = fake_mode
def __repr__(self) -> str:
return f"MetaProxy({self.node.name})"
@classmethod
def __torch_function__(
cls,
orig_method: Callable[..., Any],
types: tuple[type, ...],
args: tuple[Any, ...] | None = None,
kwargs: dict[str, Any] | None = None,
) -> "MetaProxy":
args = args if args else ()
kwargs = kwargs if kwargs else {}
meta_proxy = None
for arg in args:
if isinstance(arg, MetaProxy):
meta_proxy = arg
break
if meta_proxy is None:
raise AssertionError(
"No MetaProxy found in arguments, but one is expected."
)
proxy = super().__torch_function__(orig_method, types, args, kwargs)
with meta_proxy.fake_mode:
proxy.node.meta["val"] = orig_method(
*[a.node.meta["val"] if isinstance(a, Proxy) else a for a in args],
**kwargs,
)
return MetaProxy(proxy.node, proxy.tracer, meta_proxy.fake_mode)
@compatibility(is_backward_compatible=True)
class Attribute(Proxy):
@compatibility(is_backward_compatible=True)
def __init__(self, root: Proxy, attr: str) -> None:
self.root = root
self.attr = attr
self.tracer = root.tracer
self._node: Node | None = None
@property
def node(self) -> Node: # pyrefly: ignore[bad-override]
# the node for attributes is added lazily, since most will just be method calls
# which do not rely on the getitem call
if self._node is None:
self._node = self.tracer.create_proxy(
"call_function", getattr, (self.root, self.attr), {}
).node
return self._node
def __call__(self, *args: Any, **kwargs: Any) -> "Proxy":
return self.tracer.create_proxy(
"call_method", self.attr, (self.root,) + args, kwargs
)
@compatibility(is_backward_compatible=False)
class ParameterProxy(Proxy):
"""
A special proxy which lets "shape", "size", "dim", and a few other
attribute accesses pass through to the underlying module parameter object,
so that conditional tests on these attributes will not throw exception during tracing
"""
def __init__(
self, tracer: TracerBase, node: Node, name: str, param: torch.nn.Parameter
) -> None:
super().__init__(node, tracer)
if not isinstance(param, torch.nn.Parameter):
raise AssertionError(f"Expected Parameter, got {type(param)}")
self.param = param
self.name = name
def __repr__(self) -> str:
return f"ParameterProxy({self.name})"
@property
def shape(self) -> torch.Size:
return self.param.shape
def size(self) -> torch.Size:
return self.param.size()
def dim(self) -> int:
return self.param.dim()
@property
def ndim(self) -> int:
return self.param.ndim
def numel(self) -> int:
return self.param.numel()
def nelement(self) -> int:
return self.param.nelement()
for method in magic_methods:
def _scope(method: str) -> None:
def impl(*args: Any, **kwargs: Any) -> "Proxy":
tracer = args[0].tracer
target = getattr(operator, method)
return tracer.create_proxy("call_function", target, args, kwargs)
impl.__name__ = method
as_magic = f"__{method.strip('_')}__"
setattr(Proxy, as_magic, impl)
_scope(method)
def _define_reflectable(orig_method_name: str) -> None:
method_name = f"__r{orig_method_name.strip('_')}__"
def impl(self: "Proxy", rhs: Any) -> "Proxy":
target = getattr(operator, orig_method_name)
return self.tracer.create_proxy("call_function", target, (rhs, self), {})
impl.__name__ = method_name
impl.__qualname__ = method_name
setattr(Proxy, method_name, impl)
for orig_method_name in reflectable_magic_methods:
_define_reflectable(orig_method_name)
def _no_nodes_error(arg: Argument) -> Never:
raise RuntimeError(
"Keys for dictionaries used as an argument cannot contain a "
f"Node. Got key: {arg}"
)
def _create_arg_dict(self: TracerBase, a: dict[Any, Any]) -> dict[Any, Argument]:
r: dict[Any, Argument] = {}
for k, v in a.items():
if not isinstance(k, str):
# Check for invalid dict keys. We do not want a Proxy to appear
# anywhere within the key. Since keys can be collection types,
# we iterate through the key with map_arg
k = self.create_arg(k)
map_arg(k, _no_nodes_error)
r[k] = self.create_arg(v)
return r
_create_arg_bypass = {
t: lambda self, a: a
for t in [
*base_types,
type(None),
type(...),
torch._ops.OpOverload,
torch._ops.HigherOrderOperator,
]
}
_create_arg_bypass[Proxy] = lambda self, a: a.node
_create_arg_bypass[tuple] = lambda self, a: tuple(self.create_arg(elem) for elem in a)
_create_arg_bypass[list] = lambda self, a: [self.create_arg(elem) for elem in a]
_create_arg_bypass[dict] = _create_arg_dict
_create_arg_bypass[immutable_list] = _create_arg_bypass[list]
_create_arg_bypass[immutable_dict] = _create_arg_bypass[dict]

Some files were not shown because too many files have changed in this diff Show More