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,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