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,27 @@
import torch.fx
class BackwardState:
"""
BackwardState is used to pass Python hooks from the forwards pass
into the backwards pass in Dynamo+Compiled Autograd.
It is created by TorchDynamo and has special handling there.
Dynamo will pass an empty BackwardState to the forwards, then populate
members on it (via setattr) only after the forwards graph is finished.
Later on, in CompileAutograd we will inline and add the needed guards
on the BackwardState.
BackwardState is identified and has special handling in AOTAutograd.
During AOTAutograd:
1) BackwardState is an input to the forwards graph
2) It must only be used in the backwards
3) It will be empty in the forwards
4) In the forwards we add a wrapper to save it
5) In the backwards it becomes an input
6) There can only be one per graph
BackwardState requires CompiledAutograd.
"""
proxy: torch.fx.Proxy
@@ -0,0 +1,134 @@
import enum
import os
import sys
from torch.utils._config_module import Config, install_config_module
# [@compile_ignored: debug] Fails hard instead of graph breaking on guard on data dependent errors.
no_data_dependent_graph_break = (
os.environ.get("TORCHDYNAMO_NO_DATA_DEPENDENT_GRAPH_BREAK", "0") == "1"
)
# [@compile_ignored: debug] Uses z3 for validating the guard optimizations transformations.
translation_validation = (
os.environ.get("TORCHDYNAMO_TRANSLATION_VALIDATION", "0") == "1"
)
# Timeout (in milliseconds) for z3 finding a solution.
# [@compile_ignored: debug]
translation_validation_timeout = int(
os.environ.get("TORCHDYNAMO_TRANSLATION_VALIDATION_TIMEOUT", "600000")
)
# Disables bisection for translation validation.
#
# Translation validation bisection is enabled by default, if translation validation
# is also enabled. This should help finding guard simplification issues. However,
# since validation uses Z3 for bisecting, it might take a lot of time.
#
# Set this configuration option so as to avoid bisecting.
# [@compile_ignored: debug]
translation_validation_no_bisect = (
os.environ.get("TORCHDYNAMO_TRANSLATION_NO_BISECT", "0") == "1"
)
# Checks whether replaying ShapeEnv events on a freshly constructed one yields
# the a ShapeEnv with the same state. This should be used only in testing.
check_shape_env_recorded_events = False
# TODO: Perhaps consider allowing unions for the configs below (so you can hit
# multiple reps at the same time)
# Give extended debug information if the string representation of a guard
# matches this. For example, set this to "Ne(s0, 10)" and whenever we issue
# this guard, we will generate full Python and C++ backtrace
# [@compile_ignored: debug]
extended_debug_guard_added = os.environ.get(
"TORCHDYNAMO_EXTENDED_DEBUG_GUARD_ADDED", None
)
# Give extended debug information when a particular symbol is allocated. For
# example, set this to "u2" and whenever we create this symbol, we will
# generate full Python and C++ backtrace
# [@compile_ignored: debug]
extended_debug_create_symbol = os.environ.get(
"TORCHDYNAMO_EXTENDED_DEBUG_CREATE_SYMBOL", None
)
# Give extended debug information (C++ backtrace) for all extended debug
# settings as well as errors. The C++ backtrace is slow and very spammy so we
# don't include it by default even when you're requesting extended debug.
# [@compile_ignored: debug]
extended_debug_cpp = os.environ.get("TORCHDYNAMO_EXTENDED_DEBUG_CPP", "") != ""
# Give extended debug information (line of code) when a torch function
# is called during export. This is useful for showing progress and detecting
# where export might be stuck. Currently only works for strict=False.
# [@compile_ignored: debug]
extended_debug_current_loc = (
os.environ.get("TORCHEXPORT_EXTENDED_DEBUG_CURRENT_LOC", "0") == "1"
)
# [@compile_ignored: debug] Show a warning for every specialization
print_specializations = False
# wraps (un)equalities with 'Not' class after recording the correct expression
# in the FX graph. This should incorrectly construct the divisible and replacement
# lists, and incorrectly issue guards.
inject_EVALUATE_EXPR_flip_equality_TESTING_ONLY = False
# [@compile_ignored: debug] Validate that ShapeEnv's version key is updated correctly
validate_shape_env_version_key = False
# If we produce more than this many guards on a symbol, force the symbol to
# get specialized and bail out if this many guards mention this particular
# symbol. This may be slightly more aggressive than the true number of guards
# issued (as we test if we've hit the limit on-the-fly, whereas we may
# do further simplifications at final guard issuance time that make guards
# irrelevant.)
symbol_guard_limit_before_specialize: int | None = None
# This flag changes whether we should use the same symbolic variable to represent input sizes that are the same.
use_duck_shape = True
# Controls the registration of torch.nonzero() on the meta device.
# When True, nonzero returns a tensor with shape (self.numel(), self.dim())
# assuming all elements are none-zero.
# Default is False to prevent unintended registration. Set to True to enable.
meta_nonzero_assume_all_nonzero = False
# Applies size-oblivious reasoning to backed symbols. This allocates a [0, inf] range for backed size symbols,
# and relies on size-oblivious semantics to avoid 0/1 specialization guards by marking them size-like.
# Currently an experimental option for export.
backed_size_oblivious = False
# Skip dtype check in meta registrations. Only used for systems that does its own dtype checking.
skip_dtype_check_in_meta_registrations = False
# Experimental: If True, graph module will register fx metadata during recompile()
enrich_profiler_metadata: bool = Config( # type: ignore[var-annotated]
default=False,
env_name_default="TORCH_ENRICH_RPOFILER_STACK_TRACE",
)
# When True, log a warning instead of raising PendingUnbackedSymbolNotFound exception
# when pending unbacked symbols are not found in returned outputs.
# The worst that can happen is an error somewhere else in the stack where we expect
# to locate an unbacked binding. Or a runtime assertion not being lowered in the output
# code.
soft_pending_unbacked_not_found_error = False
# When True, aggressively return fallback values in guard_or opting into
# guard-free semantics. This optimizes tracing time when symbolic reasoning
# is expensive. Since guard_or_X already have a general path to take, we
# can skip expensive static evaluation and just return the fallback value directly.
# This is usually safe because the fallback represents a valid code path that
# could be taken anyway.
# See AggressiveGuardFreeMode below for valid values.
aggressive_guard_free_semantics = 0
install_config_module(sys.modules[__name__])
class AggressiveGuardFreeMode(enum.IntEnum):
DISABLED = 0
VALUE_RANGE_ANALYSIS = 1 # use bound_sympy before returning fallback
SKIP_RANGE_ANALYSIS = 2 # skip range analysis entirely, just return fallback_value
@@ -0,0 +1,78 @@
from typing import * # noqa: F403
# Python version of c10/core/ConstantSymNodeImpl.cpp
# This needs to exist because the Python version of nested int is not compatible
# with the C++ version of constant symnode.
class ConstantIntNode:
def __init__(self, val: int):
self.val = val
def is_constant(self) -> bool:
return True
def maybe_as_int(self) -> int:
return self.val
def is_int(self) -> bool:
return True
def is_float(self) -> bool:
return False
def is_bool(self) -> bool:
return False
def is_nested_int(self) -> bool:
return False
def clone(self) -> "ConstantIntNode":
return self
def _str(self) -> str:
return str(self.val)
def __str__(self) -> str:
return self._str()
def __repr__(self) -> str:
return self._str()
def _graph_repr(self) -> str:
return self._str()
def add(self, other: Any) -> Any:
return other.add(self)
def sub(self, other: Any) -> Any:
return other.neg().add(self.val)
def mul(self, other: Any) -> Any:
return other.mul(self)
def eq(self, other: Any) -> Any:
return other.eq(self)
def ne(self, other: Any) -> Any:
return other.ne(self)
def gt(self, other: Any) -> Any:
return other.lt(self)
def lt(self, other: Any) -> Any:
return other.gt(self)
def le(self, other: Any) -> Any:
return other.ge(self)
def ge(self, other: Any) -> Any:
return other.le(self)
def is_symbolic(self) -> bool:
return False
def constant_int(self) -> int:
return self.val
def guard_int(self, file: str, line: int) -> int:
return self.val
@@ -0,0 +1,119 @@
import re
from collections.abc import Callable
from typing import Any
import torch
from torch.utils._pytree import tree_flatten_with_path, tree_map
KeyPath = tuple[Any, ...]
NonTensorShapeFn = Callable[[int | float], tuple[Any, ...]]
__all__ = [
"normalize_source_name",
"module_to_nested_dict",
"track_dynamism_across_examples",
"clone_and_convert_to_meta",
]
def normalize_source_name(name: str) -> str:
# Match attribute access like .x and replace with ['x']
return re.sub(r"\.([a-zA-Z_][a-zA-Z0-9_]*)", r"['\1']", name)
def module_to_nested_dict(module: torch.nn.Module) -> dict[str, Any]:
"""Recursively converts an nn.Module into a nested dictionary with explicit 'parameters' and 'modules' keys."""
self_dict: dict[str, Any] = {}
self_dict["_parameters"] = {}
self_dict["_modules"] = {}
for attr_name in dir(module):
try:
if not attr_name.startswith("_") and not callable(
getattr(module, attr_name)
):
attr_value = getattr(module, attr_name)
if (
not isinstance(attr_value, torch.nn.Module)
and isinstance(attr_value, (int, float, torch.Tensor))
and type(attr_value) is not bool
):
self_dict[attr_name] = attr_value
except NotImplementedError:
# Skip attributes that raise NotImplementedError since they won't
# contain any dynamism anyways.
continue
for name, param in module.named_parameters(recurse=False):
self_dict["_parameters"][name] = param
for name, buffer in module.named_buffers(recurse=False):
self_dict["_parameters"][name] = buffer
for name, submodule in module.named_children():
self_dict["_modules"][name] = module_to_nested_dict(submodule)
return self_dict
def track_dynamism_across_examples(
example_inputs: list[Any],
) -> dict[Any, Any]:
"""
This function analyzes a list of example inputs to determine the dynamism of their shapes.
It tracks whether the dimensions of tensors or non-tensor values change across
different examples. The function returns a dictionary where each key represents
a path to a value in the input examples, and the corresponding value is a tuple
indicating which dimensions are dynamic (i.e., change across examples). This
helps in understanding how the structure of data varies across different instances.
"""
tracking: dict[KeyPath, tuple[list[set[Any]], bool]] = {}
for ex in example_inputs:
if "self" in ex and isinstance(ex["self"], torch.nn.Module):
ex["self"] = module_to_nested_dict(ex["self"])
leaves_with_paths, _ = tree_flatten_with_path(ex)
for key_path, value in leaves_with_paths:
if not isinstance(value, (int, float, torch.Tensor)):
continue
if isinstance(value, torch.Tensor):
shape: tuple[int | float, ...] = tuple(value.shape)
is_tensor = True
else:
shape = (value,)
is_tensor = False
if key_path not in tracking:
tracking[key_path] = ([set() for _ in range(len(shape))], is_tensor)
else:
dim_sets, flag = tracking[key_path]
if flag != is_tensor:
pass
while len(dim_sets) < len(shape):
dim_sets.append(set())
for i, dim in enumerate(shape):
tracking[key_path][0][i].add(dim)
output: dict[Any, Any] = {}
for key_path, (dim_sets, _is_tensor) in tracking.items():
final_dyn = tuple(len(s) > 1 for s in dim_sets)
key_str = "L" + "".join(f"{str(k)}" for k in key_path)
key = key_path[0].key # type: ignore[attr-defined]
if key not in output:
output[key] = {}
output[key][key_str] = final_dyn
return output
def clone_and_convert_to_meta(example_input: Any) -> Any:
"""
This function takes a list of example inputs and for each tensor, clones it and converts it to device=meta.
For non-tensor values, it keeps the reference. It uses pytree to handle nested structures recursively.
"""
def transform_fn(value: Any) -> Any:
if isinstance(value, torch.Tensor):
return value.clone().to(device="meta")
return value
return tree_map(transform_fn, example_input)
@@ -0,0 +1,444 @@
"""
Size hinting utilities for symbolic shape expressions.
This module contains the core logic for resolving symbolic expressions to
concrete integer hints. Two strategies are provided:
- _guarding_hint_or_throw_base: strict, only uses backed symbol hints, throws on
unbacked symbols. Use for correctness-critical guarding decisions.
- _optimization_hint_base: permissive, uses heuristics and fallbacks for unbacked
symbols. Use for performance optimization decisions.
"""
from __future__ import annotations
import logging
import sys
from typing import Any, TYPE_CHECKING
import sympy
from torch.utils._sympy.numbers import int_oo
log = logging.getLogger(__name__)
# Maximum number of free symbols in an expression before we skip
# sympy.factor() in optimization_hint process for unbacked.
# Factoring polynomials with many variables is expensive.
SYMPY_FACTOR_MAX_FREE_SYMBOLS = 50
if TYPE_CHECKING:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
def _sympy_subs(expr: sympy.Basic, replacements: dict[sympy.Expr, Any]) -> sympy.Basic:
"""
When the passed replacement symbol v is a string, it is converted to a symbol with name v that
have the same replaced expression integer and nonnegative properties.
"""
def to_symbol(replaced: sympy.Expr, replacement: sympy.Expr | str) -> sympy.Symbol:
if not isinstance(replaced, sympy.Expr):
raise AssertionError(
f"Expected sympy.Expr key, got {type(replaced)}: {replaced}"
)
if isinstance(replacement, str):
return sympy.Symbol(
replacement,
integer=replaced.is_integer, # type: ignore[attr-defined]
nonnegative=replaced.is_nonnegative, # type: ignore[attr-defined]
)
else:
return replacement
# xreplace is faster than subs, but is way more picky
return sympy.sympify(expr).xreplace(
{k: to_symbol(k, v) for k, v in replacements.items()}
)
def _maybe_realize_expr(
expr: sympy.Basic, nan_fallback: int | None
) -> int | bool | None:
"""
Handle special sympy values in hinting APIs.
Returns:
- True/False for sympy.true/sympy.false (preserves bool type)
- Raises ValueError for complex numbers
- sys.maxsize for positive infinity
- -sys.maxsize for negative infinity
- fallback for NaN
- None if no special handling needed
"""
if expr is sympy.true:
return True
if expr is sympy.false:
return False
try:
return int(expr)
except (TypeError, ValueError):
pass
if isinstance(expr, sympy.Expr):
if expr.has(sympy.I):
raise ValueError(
f"_maybe_realize_expr received a complex expression: {expr}. "
"Tensor dimensions cannot be complex numbers."
)
if expr in (int_oo, sympy.oo):
return sys.maxsize
if expr in (-int_oo, -sympy.oo):
return -sys.maxsize
if nan_fallback is not None and (expr is sympy.nan or expr.has(sympy.nan)):
return nan_fallback
return None
def _guarding_hint_or_throw_base(
shape_env: ShapeEnv,
expr: sympy.Expr | sympy.Basic | int | bool,
precomputed_replacements: dict[sympy.Expr, sympy.Symbol],
) -> int | bool:
"""
Return a concrete integer hint for an expression that is safe to use for guarding.
This function evaluates the expression using only backed-symbols hints. Unlike
_optimization_hint_base(), this function does NOT use heuristics or fallback values
for unbacked symbols.
Use this when you need a hint value that will be used for a guarding decision.
Args:
shape_env: The ShapeEnv instance.
expr: A sympy expression or integer to evaluate.
precomputed_replacements: Precomputed replacements for PRECOMPUTED_SIZE symbols.
Returns:
The concrete integer value of the expression based on backed symbol hints.
Raises:
GuardOnDataDependentSymNode: If the expression contains unbacked symbols
(data-dependent values) that cannot be resolved to concrete values.
See Also:
_optimization_hint_base: For cases where fallback/heuristic values are acceptable
for unbacked symbols.
"""
from torch.fx.experimental.symbolic_shapes import (
has_free_unbacked_symbols,
symbol_is_type,
SymT,
)
# sympy.expand() doesn't work with boolean expressions like Or/And
if isinstance(expr, sympy.Expr):
expr = sympy.expand(expr).xreplace(shape_env.replacements)
else:
expr = sympy.sympify(expr).xreplace(shape_env.replacements)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
result = _maybe_realize_expr(expr, None)
if result is not None:
return result
if not isinstance(expr, sympy.Basic):
raise RuntimeError("isinstance(expr, sympy.Basic)", expr, type(expr))
if any(symbol_is_type(s, SymT.PRECOMPUTED_SIZE) for s in expr.free_symbols): # type: ignore[attr-defined]
expr = _sympy_subs(expr, precomputed_replacements)
# TODO do we need sympy_subs, or just xreplace
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
if has_free_unbacked_symbols(expr):
# Note: we could do better here and call
# _maybe_evaluate_static(orig_expr, compute_hint=True)
# but is it worth the overhead? probably not.
raise shape_env._make_data_dependent_error(expr, expr)
result = _maybe_realize_expr(expr, None)
if result is None:
raise RuntimeError("unexpected None!", expr)
return result
def _get_unbacked_replacements(shape_env: ShapeEnv) -> dict[sympy.Expr, sympy.Expr]:
"""Builds a mapping from unbacked expressions to canonical equivalents
using a union-find algorithm over deferred runtime asserts.
Used by optimization_hint to resolve unbacked symbols to consistent values."""
from collections import defaultdict
from torch.fx.experimental.symbolic_shapes import has_free_unbacked_symbols
from torch.utils._ordered_set import OrderedSet
if shape_env._unbacked_replacements is not None:
return shape_env._unbacked_replacements
class CanonicalExprFinder:
"""
A disjoint-set/union-find data structure that can return the
"canonical" expression for a group of equivalent expressions.
- The canonical expression must come from the input eq_graph.
- The heuristics used to choose a leader determines which
expression becomes the canonical expression.
"""
def __init__(self, eq_graph: dict[sympy.Expr, OrderedSet[sympy.Expr]]):
self.eq_graph = eq_graph
self.expressions = list(eq_graph.keys())
self.reverse_expressions = {
expr: i for i, expr in enumerate(self.expressions)
}
self.leader = list(range(len(self.expressions)))
self.size = [1] * len(self.expressions)
self._build_canonical_expr_mapping()
def _build_canonical_expr_mapping(self):
for expr, edges in self.eq_graph.items():
for adj in edges:
self.union_expr(expr, adj)
def union_expr(self, a: sympy.Expr, b: sympy.Expr):
return self.union(self.reverse_expressions[a], self.reverse_expressions[b])
def union(self, a: int, b: int):
rootA = self.find(a)
rootB = self.find(b)
if rootA == rootB:
return False
leader, other = self.choose_leader(rootA, rootB)
self.leader[other] = leader
self.size[leader] += self.size[other]
return True
def find_expr(self, expr: sympy.Expr):
parent = self.find(self.reverse_expressions[expr])
return self.expressions[parent]
def find(self, x: int):
if self.leader[x] != x:
self.leader[x] = self.find(self.leader[x])
return self.leader[x]
def choose_leader(self, a: int, b: int):
"""
The leader will become the canonical expression.
Returns a (leader, follower) tuple.
Heuristics:
1. Backed expression or constants preferred over unbacked expr
2. Simpler sub-expr when one contains the other
3. Higher frequency across equalities from deferred runtime assertions
4. Size of the set
5. Fallback to sympy.Basic.compare
"""
def _choose(x: int, y: int) -> bool:
lhs, rhs = self.expressions[x], self.expressions[y]
any_unbacked_lhs = has_free_unbacked_symbols(lhs)
any_unbacked_rhs = has_free_unbacked_symbols(rhs)
if any_unbacked_lhs != any_unbacked_rhs:
return bool(any_unbacked_rhs)
if lhs.has(rhs):
return False
elif rhs.has(lhs):
return True
degrees_lhs = len(self.eq_graph[lhs])
degrees_rhs = len(self.eq_graph[rhs])
if degrees_lhs != degrees_rhs:
return degrees_lhs > degrees_rhs
if self.size[x] != self.size[y]:
return self.size[x] > self.size[y]
return lhs.compare(rhs) == -1
if _choose(a, b):
return a, b
return b, a
# Build an undirected graph using ShapeEnv's deferred runtime assertions.
shape_env._equality_graph = defaultdict(OrderedSet)
for assertions in shape_env.deferred_runtime_asserts.values():
for assertion in assertions:
if not isinstance(assertion.expr, sympy.Equality):
continue
lhs = sympy.sympify(assertion.expr.lhs)
rhs = sympy.sympify(assertion.expr.rhs)
shape_env._equality_graph[lhs].add(rhs)
shape_env._equality_graph[rhs].add(lhs)
uf = CanonicalExprFinder(shape_env._equality_graph)
shape_env._unbacked_replacements = {}
for expr in shape_env._equality_graph:
canonical_expr = uf.find_expr(expr)
if expr != canonical_expr:
shape_env._unbacked_replacements[expr] = canonical_expr
return shape_env._unbacked_replacements
def _sub_unbacked_exprs(shape_env: ShapeEnv, expr: sympy.Expr) -> sympy.Expr:
"""Substitute unbacked expressions with canonical equivalents.
Used by optimization_hint to maximize consistency when hinting unbacked symbols."""
replacements = _get_unbacked_replacements(shape_env)
# consider making this threshold configurable
sub_cnt_limit = 30
sub_cnt = 0
while sub_cnt < sub_cnt_limit:
new_expr = expr.subs(replacements)
if new_expr == expr:
break
if len(new_expr.free_symbols) <= SYMPY_FACTOR_MAX_FREE_SYMBOLS:
expr = sympy.factor(new_expr)
else:
expr = new_expr
sub_cnt += 1
else:
log.warning("Substitution limit (%d) reached w/ %s", sub_cnt_limit, expr)
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
expr = _sympy_subs(expr, shape_env.var_to_hint_override)
return expr
def _optimization_hint_base(
shape_env: ShapeEnv,
expr: sympy.Expr | int,
precomputed_replacements: dict[sympy.Expr, sympy.Symbol],
fallback: int | None = None,
) -> int:
"""
Return a concrete integer hint for an expression using heuristics.
This function should be used for non-guarding based optimizations.
It will hint unbacked symbols using user provided optimization hints.
If not provided, fallback will be used along with some heuristics
that try to maximize consistency with the shape environment.
Args:
shape_env: The ShapeEnv instance.
expr: A sympy expression or integer to evaluate.
precomputed_replacements: Precomputed replacements for PRECOMPUTED_SIZE symbols.
fallback: Fallback value for unbacked symbols. If None, reads from config.
Returns:
A concrete integer hint for the expression.
"""
from torch.fx.experimental.symbolic_shapes import (
has_free_unbacked_symbols,
symbol_is_type,
SymT,
)
# Read config at call time to respect runtime patches (e.g., in tests)
if fallback is None:
from torch._inductor.config import unbacked_symint_fallback
fallback = unbacked_symint_fallback
# to have expanded (Identity free) expr stored in original
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
original = expr
# sympy.expand() doesn't work with boolean expressions like Or/And
if isinstance(expr, sympy.Expr):
expr = expr.xreplace(shape_env.replacements)
else:
expr = sympy.sympify(expr).xreplace(shape_env.replacements)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
# Replace backed symbols with their hints, leaving unbacked symbols alone.
result = _maybe_realize_expr(expr, None)
if result is not None:
return result
if not isinstance(expr, sympy.Expr):
raise RuntimeError("isinstance(expr, sympy.Expr)", expr)
if any(symbol_is_type(s, SymT.PRECOMPUTED_SIZE) for s in expr.free_symbols): # type: ignore[attr-defined]
expr = _sympy_subs(expr, precomputed_replacements)
expr = _sympy_subs(expr, shape_env.backed_var_to_val)
if isinstance(expr, sympy.Expr):
expr = expr.expand(identity=True)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
expr = _sympy_subs(expr, shape_env.var_to_hint_override)
result = _maybe_realize_expr(expr, fallback)
if result is not None:
return result
# If unbacked symbols remain, try to substitute them using heuristics
# that maximize consistency with the shape environment.
if has_free_unbacked_symbols(expr):
# Make sure to substitute with the factored version
# e.g. 10*(s0 + u0) instead of 10*s0 + 10*u0
if (
isinstance(original, sympy.Expr)
and len(original.free_symbols) <= SYMPY_FACTOR_MAX_FREE_SYMBOLS
):
original = sympy.factor(original)
expr = _sub_unbacked_exprs(shape_env, original)
# For multiple expressions that depend on an unbacked symint,
# we want to compute them consistently for a size hint we have chosen.
# So, recursively compute expressions via size hints of contained symbols.
# For example: u1 * u2 - 10 ==> fallback * fallback - 10
if not isinstance(expr, sympy.Expr):
raise RuntimeError(f"Expected sympy Expr, got {type(expr)}: {expr}")
free_symbols = expr.free_symbols
# Constrain fallback per-symbol based on var_to_range bounds
size_dict = {}
for s in free_symbols:
sym_fallback = fallback
vr = shape_env.var_to_range.get(s, None)
if vr is not None:
if isinstance(vr.lower, (int, sympy.Integer)):
sym_fallback = max(sym_fallback, int(vr.lower))
if isinstance(vr.upper, (int, sympy.Integer)):
sym_fallback = min(sym_fallback, int(vr.upper))
size_dict[s] = sym_fallback
try:
final_result = expr.subs(size_dict)
except ZeroDivisionError:
# Expressions like ModularIndexing(x, u1, 4) crash during subs()
# when u1 is substituted with 0, because sympy eagerly evaluates
# (x // 0) % 4. This can happen when an unbacked symbol with
# var_to_range lower=0 is used as a divisor (e.g. from
# _dynamic_reshape_indexer) and the fallback also maps to 0.
# Return fallback in that case.
return fallback if fallback is not None else 0
final_result = _maybe_realize_expr(final_result, fallback)
if final_result is None:
raise RuntimeError(f"Failed to realize expression to int: {expr}")
return final_result
@@ -0,0 +1,394 @@
# mypy: allow-untyped-defs
import re
from collections.abc import Callable
import torch.fx
from torch.fx.node import map_arg
from torch.fx.passes.split_module import split_module
__all__ = [
"FoldedGraphModule",
"get_unique_attr_name_in_module",
"split_const_subgraphs",
]
class FoldedGraphModule(torch.fx.GraphModule):
"""
FoldedGraphModule is a GraphModule which also contains another
`const_subgraph_module` representing a subgraph which has all const attr
inputs and which can be run once before running the main standard
`graph`. The `const_output_names` are the ordered list names of attrs which
represent what each respective output from the const_subgraph should be set
on which attrs.
"""
def __init__(
self,
root: torch.nn.Module,
graph: torch.fx.Graph,
const_subgraph: torch.fx.Graph | None = None,
fx_const_folded_attrs_name: str | None = None,
device_for_folded_attrs: str = "cuda",
):
super().__init__(root, graph)
self.const_subgraph_module = (
None
if const_subgraph is None
else torch.fx.GraphModule(root, const_subgraph)
)
self.has_folding_been_run = False
self.fx_const_folded_attrs_name = fx_const_folded_attrs_name
self.device_for_folded_attrs = device_for_folded_attrs
def __call__(self, *args, **kwargs):
if not self.has_folding_been_run:
self.run_folding()
return super().__call__(*args)
def run_folding(self):
# If there's no const subgraph module or attr output names to use, return
# early as there is no const folding to perform.
if (
self.const_subgraph_module is None
or self.fx_const_folded_attrs_name is None
):
return
if self.has_folding_been_run:
raise AssertionError("Folding has already been run")
self.has_folding_been_run = True
# Actually run const folding subgraph. Note that single attr const fold
# subgraphs output a single Tensor while multiple outputs are returned as
# Tuple[Tensor,].
folded_attrs = self.const_subgraph_module()
def _create_param(i):
return torch.nn.Parameter(
i.detach().clone()
if not isinstance(i, int)
else torch.Tensor([i]).to(device=self.device_for_folded_attrs),
requires_grad=i.requires_grad if isinstance(i, torch.Tensor) else False,
)
params = (
torch.nn.ParameterList([_create_param(i) for i in folded_attrs])
if isinstance(folded_attrs, tuple)
else _create_param(folded_attrs)
)
setattr(self, self.fx_const_folded_attrs_name, params)
def _inline_module(
gm: torch.fx.GraphModule, inline_mod_name: str, run_dce: bool = True
) -> dict[torch.fx.Node, torch.fx.Node]:
"""
Given `gm` and some graph module which is called with target name `inline_mod_name`,
this helper will inline all of the nodes from that called graph module into `gm`.
Returns a mapping from subgraph nodes to the newly created/mapped nodes in gm.
"""
# Fetch the inner graph module that we want to inline inside `gm`.
inline_mod = dict(gm.named_modules())[inline_mod_name]
if not isinstance(inline_mod, torch.fx.GraphModule):
raise AssertionError(f"Expected GraphModule, got {type(inline_mod)}")
call_mod_node_to_replace = None
for node in gm.graph.nodes:
if node.op == "call_module" and node.target == inline_mod_name:
call_mod_node_to_replace = node
break
if call_mod_node_to_replace is None:
raise AssertionError(f"Could not find call_module node for {inline_mod_name}")
# Now actually do the swap. Note that we have to keep track of new nodes that are
# copied into `gm` -- we do this via replacement_mapping.
call_mod_args = call_mod_node_to_replace.args
call_mod_kwargs = call_mod_node_to_replace.kwargs
replacement_mapping: dict[torch.fx.Node, torch.fx.Node] = {}
ph_count = 0
def replacement_fn(node):
new_node = replacement_mapping[node]
new_node.meta = node.meta.copy()
return new_node
for inline_node in inline_mod.graph.nodes:
if inline_node.op == "placeholder":
replacement_mapping[inline_node] = (
call_mod_kwargs[inline_node.name]
if inline_node.name in call_mod_kwargs
else call_mod_args[ph_count]
)
ph_count += 1
continue
if inline_node.op == "output":
outputs = inline_node.args[0]
output_replacements = map_arg(outputs, replacement_fn)
# If output is a tuple, we need to handle getitem users specially.
# Capture users before replace_all_uses_with modifies them.
getitem_users: list[torch.fx.Node] = []
if isinstance(output_replacements, (list, tuple)):
import operator
getitem_users = [
user
for user in call_mod_node_to_replace.users
if user.op == "call_function"
and user.target is operator.getitem
and isinstance(user.args[1], int)
]
call_mod_node_to_replace.replace_all_uses_with(output_replacements)
# Inline getitem nodes that now index into the tuple literal
for user in getitem_users:
idx = user.args[1]
if not isinstance(idx, int):
raise AssertionError(f"Expected int index, got {type(idx)}")
user.replace_all_uses_with(output_replacements[idx])
gm.graph.erase_node(user)
replacement_mapping[user] = output_replacements[idx]
continue
with gm.graph.inserting_before(call_mod_node_to_replace):
new_node = gm.graph.node_copy(inline_node, replacement_fn)
replacement_mapping[inline_node] = new_node
# Explicitly remove the module that was just inlined,
# this module may contain impure ops so cannot be dead code eliminated,
# this module is unneeded as it's just inlined back to main graph.
gm.graph.erase_node(call_mod_node_to_replace)
if run_dce:
gm.graph.eliminate_dead_code()
return replacement_mapping
def get_unique_attr_name_in_module(mod_traced: torch.fx.GraphModule, name: str) -> str:
"""
Make sure the name is unique (in a module) and can represents an attr.
"""
# Delete all characters that are illegal in a Python identifier.
name = re.sub("[^0-9a-zA-Z_]+", "_", name)
if name[0].isdigit():
name = f"_{name}"
# Now make sure it is in fact unique to the module by incrementing suffix value.
while hasattr(mod_traced, name):
match = re.match(r"(.*)_(\d+)$", name)
if match is None:
name = name + "_1"
else:
base, num = match.group(1, 2)
name = f"{base}_{int(num) + 1}"
return name
def split_const_subgraphs(
module: torch.nn.Module | torch.fx.GraphModule,
skip_folding_node_fn: Callable[[torch.fx.Node], bool] | None = None,
device_for_folded_attrs: str = "cpu",
) -> FoldedGraphModule:
"""
Looks through `module` for any nodes that have all constant attribute inputs
and separates them out into their own constant subgraph, and returns a
FoldedGraphModule which runs that constant subgraph on the first run to set
attributes on the module prior to running the non-constant portion of the
graph.
"""
import sympy
if not isinstance(module, torch.fx.GraphModule):
mod_traced = torch.fx.symbolic_trace(module)
else:
mod_traced = module
def _subgraph_has_impure_ops(module: torch.fx.GraphModule) -> bool:
"""
Return True if a GraphModule type subgraph contains any impure op, else False.
"""
if not isinstance(module, torch.fx.GraphModule):
raise AssertionError(
"caller should only pass GraphModule to subgraph_has_impure_ops check"
)
for node in module.graph.nodes:
if node.op == "call_function" and node.is_impure():
return True
if (
node.op == "call_module"
# pyrefly: ignore [not-callable]
and (submodule := module.get_submodule(node.target))
and isinstance(submodule, torch.fx.GraphModule)
):
return _subgraph_has_impure_ops(submodule)
return False
# Build up a list of const_nodes, defined as nodes that are themselves
# get_attrs, or have all get_attr or other constant node inputs.
const_nodes: set[torch.fx.Node] = set()
found_const_folding = False
for node in mod_traced.graph.nodes:
# Skip over placeholders/outputs because they can't be const folded and
# we don't want to add tags to them.
if node.op in {"placeholder", "output"}:
continue
# If the node itself is constant, or all of its inputs are constant,
# then tag it as constant.
if node.op != "get_attr" and not set(node.all_input_nodes).issubset(
const_nodes
):
continue
# If provided skip folding function says to skip, then skip.
if skip_folding_node_fn and skip_folding_node_fn(node):
continue
# Skip folding side-effectful functions
if node.is_impure():
continue
# Skip folding nodes that have symbolic fill_value
if isinstance(node.kwargs.get("fill_value", None), sympy.Expr):
continue
# Skip folding submodules that have impure ops
if (
node.op == "call_module"
# pyrefly: ignore [not-callable]
and (target_mod := mod_traced.get_submodule(node.target))
and isinstance(target_mod, torch.fx.GraphModule)
and _subgraph_has_impure_ops(target_mod)
):
continue
# Must be a constant foldable node at this point.
const_nodes.add(node)
if node.op != "get_attr":
found_const_folding = True
# If we did not find any const folding then return early without a const fold subgraph.
if not found_const_folding:
return FoldedGraphModule(mod_traced, mod_traced.graph)
# Partition the module into two: submod_0 for constant folding subgraph, and
# submod_1 for the rest.
def mod_partition(node: torch.fx.Node):
return 0 if node in const_nodes else 1
split = split_module(mod_traced, module, mod_partition)
const_mod_name, non_const_mod_name = "submod_0", "submod_1"
# Safely get submod_1 in case there are no non-const nodes
const_gm, non_const_gm = split.submod_0, getattr(split, non_const_mod_name, None)
# The module that a call_module node refers to gets copied to submodules during split.
# The path to the module also gets inlined, i.e. mod.a.b -> mod_a_b. Here we need to
# attach inlined modules to `split` as it's the owning module now.
for node in non_const_gm.graph.nodes if non_const_gm else []:
if node.op == "call_module":
setattr(split, node.target, getattr(non_const_gm, node.target))
for node in const_gm.graph.nodes:
if node.op == "call_module":
setattr(split, node.target, getattr(const_gm, node.target))
# split_module currently does not use get_attrs for attrs. Instead it passes
# them in as args from the parent module, which used get_attrs. Here we set
# them as get_attrs inside const_gm, allowing for running folding without
# somehow a priori knowing the attrs that should be passed as args. We can
# unconditionally do this for all placeholders because we know all
# placeholders to const_gm must be constants accessible via get_attr.
call_const_gm_args = None
for node in split.graph.nodes:
if node.op == "call_module":
if node.target == const_mod_name:
call_const_gm_args = node.args
break
if call_const_gm_args is None:
raise AssertionError("Could not find call_module node for const_gm")
# Here we do the actual replacement of placeholders to get_attrs. Note that here we
# set the const_gm.graph into a new root_const_gm with split as the root module,
# because we are fetching attributes directly from the root module, instead of
# fetching them from const_gm. Example: The const_gm must have some format like:
# graph():
# %inp : [num_users=1] = placeholder[target=const_inp]
# %add : [num_users=1] = call_function[target=operator.add](args = (%inp, %inp), kwargs = {})
# return add
# We replace that with the following, which does not have any placeholders:
# graph():
# %inp_1 : [num_users=1] = get_attr[target=const_inp]
# %add : [num_users=1] = call_function[target=operator.add](args = (%inp_1, %inp_1), kwargs = {})
# return add
root_const_gm = torch.fx.GraphModule(split, const_gm.graph)
# The order of placeholders in the const_gm graph should match the order of
# args in the outer module, so we can simply use an index for the
# placeholder mapping
ph_idx = 0
for node in root_const_gm.graph.nodes:
if node.op == "output":
multiple_outputs = isinstance(node.args[0], tuple)
continue
if node.op != "placeholder":
continue
if ph_idx >= len(call_const_gm_args):
raise AssertionError(
f"Placeholder index {ph_idx} out of range for args "
f"(len={len(call_const_gm_args)})"
)
in_node = call_const_gm_args[ph_idx]
ph_idx += 1
if in_node.op != "get_attr":
raise AssertionError(f"Expected get_attr, got {in_node.op}")
with root_const_gm.graph.inserting_before(node):
new_node = root_const_gm.graph.get_attr(in_node.target)
new_node.meta = node.meta.copy()
node.replace_all_uses_with(new_node)
root_const_gm.graph.erase_node(node)
if "multiple_outputs" not in locals():
raise AssertionError("multiple_outputs not set in loop")
# Now find the call to const_gm inside split, and replace it with a getattr to the
# folded tensor(s) that result from constant folding. Note that we don't need to
# worry about whether this is one or more tensors because the original graph
# correctly uses getitem to extract individual tensors if there are multiple folded.
fx_const_folded_attrs_name = get_unique_attr_name_in_module(
mod_traced, "_FX_CONST_FOLDED_ATTRS"
)
setattr(
split,
fx_const_folded_attrs_name,
torch.nn.ParameterList() if multiple_outputs else torch.nn.Parameter(), # type: ignore[possibly-undefined]
)
for node in split.graph.nodes:
if node.op == "call_module" and node.target == const_mod_name:
with node.graph.inserting_before(node):
folded_attrs = node.graph.get_attr(fx_const_folded_attrs_name)
folded_attrs.meta = node.meta.copy()
node.replace_all_uses_with(folded_attrs)
break
# Finally, inline the non-constant submod (if it exists) into the split submod.
# This is so that the original caller who may have passed in a graph module will
# get back out a graph module whose graph is traced to the same granularity.
if hasattr(split, non_const_mod_name):
_inline_module(split, non_const_mod_name)
split.graph.eliminate_dead_code()
return FoldedGraphModule(
split,
split.graph,
root_const_gm.graph,
fx_const_folded_attrs_name,
device_for_folded_attrs,
)
@@ -0,0 +1,33 @@
from collections.abc import Sequence
import torch.fx as fx
__all__ = ["set_trace"]
def set_trace(gm: fx.GraphModule) -> fx.GraphModule:
"""
Sets a breakpoint in `gm`'s generated python code. It drops into pdb when
`gm` gets run.
Args:
gm: graph module to insert breakpoint. It is then recompiled for it to
take effect.
Returns:
the `gm` with breakpoint inserted.
"""
def insert_pdb(body: Sequence[str]) -> list[str]:
return ["import pdb; pdb.set_trace()\n", *body]
with gm.graph.on_generate_code(
make_transformer=lambda cur_transform: (
# new code transformer to register
lambda body: (insert_pdb(cur_transform(body) if cur_transform else body))
)
):
gm.recompile()
return gm
@@ -0,0 +1,180 @@
# mypy: allow-untyped-defs
import itertools
import operator
import torch
from torch.fx._symbolic_trace import symbolic_trace
from torch.fx.node import Node
from torch.fx.passes.tools_common import legalize_graph
def split_result_tensors(
result: torch.Tensor, inputs: list[torch.Tensor]
) -> tuple[torch.Tensor, ...]:
"""
A free function for use in the merge_matmul graph transformation below that
splits the output from a merged matmul into the individual results for each
input tensor.
Arguments:
result: The merged matmul result tensor.
inputs: The list of inputs that were merged into one for the matmul.
Returns:
List of matmul results for each input tensor.
"""
# When fx tracer is running, x.shape[0] will be torch.fx.Attribute but we
# need an int even when tracing
if isinstance(result, torch.fx.Proxy):
splits = [0] * len(inputs)
else:
splits = [x.shape[0] for x in inputs]
return torch.split(result, splits)
def may_depend_on(a: Node, b: Node, search_depth: int = 6):
"""
Determine if one node depends on another in a torch.fx.Graph.
Arguments:
a: The node that may have a dependency on b.
b: The node that a may have a dependency on.
search_depth: In the case of an indirect dependency, this function
searches upto this many nodes away in search of a
data dependency. If none is found, the function
makes the conservative assumption that there is a
dependency.
Returns:
True if a may depend on b, False if it definitely does not.
"""
# Equivalence is defined as dependence.
if a == b:
return True
# If a has no inputs, it cannot depend on b.
if len(a.all_input_nodes) == 0:
return False
# If the search depth has been exhausted and no conclusion has been
# reached, assume that there is a data dependency.
if search_depth == 0:
return True
# Recursively check all inputs of a.
for inp in a.all_input_nodes:
if may_depend_on(inp, b, search_depth - 1):
return True
return False
def are_nodes_independent(nodes: list[Node]):
"""
Check if all of the given nodes are pairwise-data independent.
Arguments:
nodes: The nodes to check for data dependencies.
Returns:
True if any pair in nodes has a data dependency.
"""
# For each pair in nodes:
for i, j in itertools.combinations(nodes, 2):
if may_depend_on(i, j) or may_depend_on(j, i):
return False
return True
def merge_matmul(in_mod: torch.nn.Module):
"""
A graph transformation that merges matrix multiplication operations that share the same right-hand
side operand into one large matrix multiplication.
::
____ _________ _________
---- | | | | M| A * C |
M| A | T| B | * K| C | = |---------|
---- , | | | | T| B * C |
K ---- --------- ---------
K R R
"""
gm = symbolic_trace(in_mod)
rhs_users: dict[Node, list[Node]] = {}
lhs_users: dict[Node, list[Node]] = {}
# Populate rhs_users and lhs_users - maps from LHS/RHS matrix multiply operands to
# the matmul of which they are the LHS/RHS.
for node in gm.graph.nodes:
if node.op != "call_function" or node.target is not torch.matmul:
continue
lhs, rhs = node.args
# TODO: Properly handle aliasing caused by get_attr. For now,
# use the attribute name as the operand if the node is a
# get_attr.
lhs = lhs.target if lhs.op == "get_attr" else lhs
rhs = rhs.target if rhs.op == "get_attr" else rhs
lhs_users.setdefault(lhs, []).append(node)
rhs_users.setdefault(rhs, []).append(node)
for rhs, mms in rhs_users.items():
# There must be at least matmuls for a merge to make sense.
if len(mms) < 2:
continue
# All matmuls must not depend on each other directly or indirectly
# in order for the merge to be possible.
if not are_nodes_independent(mms):
continue
lhs_vals = [mm.args[0] for mm in mms]
# Merge the matmul.
# Collect a list of LHS operands and the single RHS operand.
lhs = [gm.graph.get_attr(l) if isinstance(l, str) else l for l in lhs_vals]
rhs = gm.graph.get_attr(rhs) if isinstance(rhs, str) else rhs
# Concatenate all the LHS operands.
merge_mm_cat = gm.graph.call_function(torch.cat, (lhs,), {})
# Multiply the concatenated LHS operands with the one RHS. This will produce
# the same results as all the individual matmuls involving rhs in the original graph,
# but they will all be concatenated together.
merge_mm = gm.graph.call_function(
torch.matmul,
(
merge_mm_cat,
rhs,
),
{},
)
# Split the result of the merged matmul using the shapes of the LHS operands
# to ascertain how large each chunk should be.
merge_mm_split = gm.graph.call_function(
split_result_tensors, (merge_mm, lhs), {}
)
merge_mm_res = [
gm.graph.call_function(operator.getitem, (merge_mm_split, out), {})
for out in range(len(lhs))
]
# Replace all uses of the original, unmerged matmuls with the equivalent split chunk from the merged matmul.
for old, new in zip(mms, merge_mm_res):
old.replace_all_uses_with(new)
gm.graph.erase_node(old)
# All of the new nodes created above were inserted at the end, so we need to sort
# the nodes topologically to make sure all definitions precede uses.
legalize_graph(gm)
gm.recompile()
gm.graph.lint()
return gm
@@ -0,0 +1,330 @@
# mypy: allow-untyped-defs
import builtins
import functools
import warnings
from collections.abc import Callable
from typing import Any
import torch
import torch.fx
def embedding_override(self, input):
return torch.empty(*input.shape, self.weight.shape[-1], device="meta")
def nn_layernorm_override(self, input):
return input
def torch_relu_override(x):
return x
def torch_nn_relu_override(self, x):
return x
def functional_relu_override(x, inplace=False):
if inplace:
raise AssertionError(
"dont support inplace functional.relu for metatensor analysis"
)
return x
def torch_where_override(condition, x, y):
# torch.where returns the broadcasted tensor of condition, x, and y,
# so hack it by using addition
return condition.to(device="meta") + x.to(device="meta") + y.to(device="meta")
def torch_abs_override(input, *, out=None):
if out is not None:
raise AssertionError("Dont support in-place abs for MetaTensor analysis")
return input
manual_meta_overrides: dict[Callable, Callable] = {
torch.nn.Embedding: embedding_override,
torch.nn.LayerNorm: nn_layernorm_override,
torch.relu: torch_relu_override,
torch.nn.functional.relu: functional_relu_override,
torch.nn.ReLU: torch_nn_relu_override,
torch.where: torch_where_override,
torch.abs: torch_abs_override,
}
def gen_constructor_wrapper(target):
@functools.wraps(target)
def wrapper(*args, **kwargs):
proxy = None
def check_has_proxy(v):
if isinstance(v, torch.fx.Proxy):
nonlocal proxy
proxy = v
torch.fx.node.map_aggregate(args, check_has_proxy)
torch.fx.node.map_aggregate(kwargs, check_has_proxy)
if proxy is not None:
return proxy.tracer.create_proxy("call_function", target, args, kwargs)
else:
return target(*args, **kwargs)
return wrapper, target
class MetaProxy(torch.fx.Proxy):
def install_tensor_meta(self, tensor_meta):
self._tensor_meta = tensor_meta
def size(self, dim=None):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.size(*[dim] if dim else [])
return self.tracer.create_proxy(
"call_method", "size", (self, dim) if dim else (self,), {}
)
def dim(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.dim()
return self.tracer.create_proxy("call_method", "dim", (self,), {})
@property
def shape(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.shape
return self.tracer.create_proxy(
"call_function", builtins.getattr, (self, "shape"), {}
)
@property
def dtype(self):
if hasattr(self, "_tensor_meta") and self._tensor_meta is not None:
return self._tensor_meta.dtype
return self.tracer.create_proxy(
"call_function", builtins.getattr, (self, "dtype"), {}
)
@property
def device(self):
# Hack so we can track when devices are used. During meta-tensor propagation,
# replace these values with a constant 'meta'
return MetaDeviceAttribute(self, "device")
def __getattr__(self, k):
if k == "_tensor_meta":
return self.__getattribute__(k)
# note: not added to the graph yet, if this is a method call
# we peephole optimize to the method invocation
return MetaAttribute(self, k)
class MetaAttribute(MetaProxy):
def __init__(self, root, attr: str):
self.root = root
self.attr = attr
self.tracer = root.tracer
self._node = None
@property
def node(self): # type: ignore[override]
# the node for attributes is added lazily, since most will just be method calls
# which do not rely on the getitem call
if self._node is None:
self._node = self.tracer.create_proxy(
"call_function", getattr, (self.root, self.attr), {}
).node
return self._node
def __call__(self, *args, **kwargs):
return self.tracer.create_proxy(
"call_method", self.attr, (self.root,) + args, kwargs
)
class MetaDeviceAttribute(MetaAttribute):
pass
def proxys_to_metas(v):
if isinstance(v, MetaDeviceAttribute):
return "meta"
if isinstance(v, torch.fx.Proxy):
if not isinstance(v, MetaProxy):
raise AssertionError(f"Expected MetaProxy but got {type(v)}")
if not hasattr(v, "_tensor_meta"):
raise AssertionError("MetaProxy does not have an associated meta")
return v._tensor_meta
return v
class MetaTracer(torch.fx.Tracer):
allow_insert_stateless_mods: bool = True
_TORCH_METHODS_TO_PATCH = ["arange", "zeros", "ones", "full_like", "eye"]
def create_proxy(
self,
kind,
target,
args,
kwargs,
name=None,
type_expr=None,
proxy_factory_fn=None,
):
rv = super().create_proxy(
kind,
target,
args,
kwargs,
name,
type_expr,
# pyrefly: ignore [bad-argument-type]
proxy_factory_fn,
)
if kind == "placeholder" and target in self.meta_args:
rv.install_tensor_meta(self.meta_args[target])
return rv
if target in self.orig_fns:
# NOTE: tensor constructors in PyTorch define the `device` argument as
# *kwargs-only*. That is why this works. If you add methods to
# _TORCH_METHODS_TO_PATCH that do not define `device` as kwarg-only,
# this will break and you will likely see issues where we cannot infer
# the size of the output.
if "device" in kwargs:
kwargs["device"] = "meta"
try:
args_metas = torch.fx.node.map_aggregate(args, proxys_to_metas)
kwargs_metas = torch.fx.node.map_aggregate(kwargs, proxys_to_metas)
if kind == "call_function":
meta_target = manual_meta_overrides.get(target, target)
meta_out = meta_target(*args_metas, **kwargs_metas)
elif kind == "call_method":
meta_target = getattr(args_metas[0], target) # type: ignore[index]
meta_out = meta_target(*args_metas[1:], **kwargs_metas) # type: ignore[index]
elif kind == "call_module":
if not hasattr(self, "orig_forward"):
raise AssertionError("orig_forward not set for call_module")
self._disable_module_getattr = True
try:
mod = self.root.get_submodule(target)
mod_type = type(mod)
if mod_type in manual_meta_overrides:
meta_out = manual_meta_overrides[mod_type](
mod, *args_metas, **kwargs_metas
) # type: ignore[misc, arg-type]
else:
meta_out = self.orig_forward(*args_metas, **kwargs_metas)
finally:
self._disable_module_getattr = False
elif kind == "get_attr":
self._disable_module_getattr = True
try:
attr_itr = self.root
atoms = target.split(".")
for atom in atoms:
attr_itr = getattr(attr_itr, atom)
if not isinstance(attr_itr, torch.Tensor):
raise AssertionError(f"Expected Tensor, got {type(attr_itr)}")
meta_out = attr_itr.to(device="meta")
finally:
self._disable_module_getattr = False
else:
return rv
# TODO
if not isinstance(rv, torch.fx.Proxy):
raise AssertionError("Dont support composite output yet")
rv.install_tensor_meta(meta_out)
except Exception as e:
warnings.warn(f"Could not compute metadata for {kind} target {target}: {e}")
return rv
def getattr(self, attr, attr_val, parameter_proxy_cache):
if getattr(self, "_disable_module_getattr", False):
return attr_val
else:
return super().getattr(attr, attr_val, parameter_proxy_cache)
def call_module(self, m, forward, args, kwargs):
self.orig_forward = forward
return super().call_module(m, forward, args, kwargs)
def _insert_module_as_submodule(self, mod: torch.nn.Module) -> str:
"""
Helper method which tries to insert a module that was not declared as submodule.
"""
idx = 0
mod_name = mod.__class__.__name__.lower()
path = f"{mod_name}_{idx}"
while hasattr(self.root, path):
path = f"{mod_name}_{idx}"
idx += 1
self.root.add_module(path, mod)
return path
def path_of_module(self, mod: torch.nn.Module) -> str:
try:
return super().path_of_module(mod)
except NameError:
if (
self.allow_insert_stateless_mods
and len(list(mod.parameters())) == 0
and len(list(mod.buffers())) == 0
):
path = self._insert_module_as_submodule(mod)
self.prev_module = path
return path
raise
def proxy(self, node):
return MetaProxy(node, self)
def trace(self, root, meta_args: dict[str, torch.Tensor], concrete_args=None): # type: ignore[override]
if not isinstance(meta_args, dict):
raise AssertionError(f"Expected dict for meta_args, got {type(meta_args)}")
self.meta_args = meta_args
self.patched_torch_methods = {
target: gen_constructor_wrapper(getattr(torch, target))
for target in self._TORCH_METHODS_TO_PATCH
}
self.orig_fns = set()
for name, (wrapper, orig) in self.patched_torch_methods.items():
setattr(torch, name, wrapper)
self.orig_fns.add(orig)
try:
graph = super().trace(root, concrete_args)
graph._tracer_extras = {"meta_args": meta_args}
return graph
finally:
for name, (_, orig) in self.patched_torch_methods.items():
setattr(torch, name, orig)
def symbolic_trace(
root: torch.nn.Module | Callable[..., Any],
meta_args: dict[str, torch.Tensor] | None = None,
concrete_args: dict[str, Any] | None = None,
) -> torch.fx.GraphModule:
tracer = MetaTracer()
graph = tracer.trace(root, meta_args, concrete_args) # type: ignore[arg-type]
name = (
root.__class__.__name__ if isinstance(root, torch.nn.Module) else root.__name__
)
gm = torch.fx.GraphModule(tracer.root, graph, name)
return gm
@@ -0,0 +1,733 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = [
"ApplyBroadcasting",
"BinConstraintD",
"BinConstraintT",
"BinaryConstraint",
"BVar",
"CalcConv",
"CalcMaxPool",
"CalcProduct",
"CanReshape",
"Conj",
"Constraint",
"DGreatestUpperBound",
"Disj",
"DVar",
"F",
"GetItem",
"GetItemTensor",
"IndexSelect",
"Prod",
"T",
"TGreatestUpperBound",
"Transpose",
"TVar",
"is_algebraic_expression",
"is_bool_expr",
"is_dim",
]
from torch.fx.experimental.migrate_gradual_types.operation import (
op_add,
op_div,
op_eq,
op_gt,
op_lt,
op_mod,
op_mul,
op_neq,
op_sub,
)
from torch.fx.tensor_type import _DynType, Dyn, TensorType
class Constraint:
pass
class Conj(Constraint):
def __init__(self, conjuncts: Sequence[Constraint]) -> None:
"""
:param conjuncts: Conjunction of constraints
"""
self.conjucts = list(conjuncts)
def __eq__(self, other: object) -> bool:
if isinstance(other, Conj):
return self.conjucts == other.conjucts
else:
return False
def __repr__(self) -> str:
return f"And({self.conjucts})"
class Disj(Constraint):
def __init__(self, disjuncts: Sequence[Constraint]) -> None:
"""
:param disjuncts: Disjunction of constraints
"""
self.disjuncts = list(disjuncts)
def __eq__(self, other: object) -> bool:
if isinstance(other, Disj):
return self.disjuncts == other.disjuncts
else:
return False
def __repr__(self) -> str:
return f"Or({self.disjuncts})"
class Prod(Constraint):
def __init__(self, products: Sequence[DVar | int | _DynType]) -> None:
"""
:param products: lists of dimensions to multiply
"""
self.products = list(products)
def __eq__(self, other: object) -> bool:
if isinstance(other, Prod):
return self.products == other.products
else:
return False
def __repr__(self) -> str:
return f"Product({self.products})"
class T(Constraint):
"""
True
"""
def __init__(self) -> None:
pass
def __eq__(self, other: object) -> bool:
return isinstance(other, T)
def __repr__(self) -> str:
return "True"
class F(Constraint):
"""
False
"""
def __init__(self) -> None:
pass
def __eq__(self, other: object) -> bool:
return isinstance(other, F)
def __repr__(self) -> str:
return "False"
class BinaryConstraint(Constraint):
"""
Represents all binary operations
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
"""
:param lhs: lhs of the constraint
:param rhs: rhs of the constraint
:param op: string representing the operation
"""
self.lhs = lhs
self.rhs = rhs
self.op = op
def __eq__(self, other: object) -> bool:
if isinstance(other, BinaryConstraint):
return (
self.lhs == other.lhs and self.rhs == other.rhs and self.op == other.op
)
else:
return False
def __repr__(self) -> str:
return f"({self.lhs} {self.op} {self.rhs})"
class BinConstraintT(BinaryConstraint):
"""
Binary constraints about tensors
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
if not (
(isinstance(lhs, (TVar, TensorType, int)) or lhs == Dyn)
and (isinstance(rhs, (TVar, TensorType, int)) or rhs == Dyn)
):
raise AssertionError(f"Invalid types: lhs={type(lhs)}, rhs={type(rhs)}")
super().__init__(lhs, rhs, op)
class BinConstraintD(BinaryConstraint):
"""
Binary constraints about dimensions
"""
def __init__(self, lhs: _Operand, rhs: _Operand, op: str | None) -> None:
if not (is_algebraic_expression(lhs) or is_dim(lhs) or is_bool_expr(lhs)):
raise AssertionError(f"Invalid lhs type: {type(lhs)}")
if not (is_algebraic_expression(rhs) or is_dim(rhs) or is_bool_expr(rhs)):
raise AssertionError(f"Invalid rhs type: {type(rhs)}")
super().__init__(lhs, rhs, op)
class TGreatestUpperBound(Constraint):
"""
Greatest Upper bound for tensors with dynamic type
"""
def __init__(self, res: TVar, rhs1: TVar, rhs2: TVar) -> None:
"""
:param res: tensor variable that stores the result of the output
:param rhs1: tensor or tensor variable
:param rhs2: tensor or tensor variabke
"""
self.res = res
self.rhs1 = rhs1
self.rhs2 = rhs2
def __repr__(self) -> str:
return f"{self.res} = {self.rhs1}\u2294*{self.rhs2}"
def __eq__(self, other: object) -> bool:
if isinstance(other, TGreatestUpperBound):
return (
self.res == other.res
and self.rhs1 == other.rhs1
and self.rhs2 == other.rhs2
)
else:
return False
class DGreatestUpperBound(Constraint):
"""
Greatest Upper bound for dimensions
"""
def __init__(
self,
res: DVar | int | _DynType,
rhs1: DVar | int | _DynType,
rhs2: DVar | int | _DynType,
) -> None:
"""
:param res: Dimension variable to store the result
:param rhs1: dimension variable 1
:param rhs2: dimension variable 2
"""
if not is_dim(res):
raise AssertionError(f"Expected dimension for res, got {type(res)}")
if not is_dim(rhs1):
raise AssertionError(f"Expected dimension for rhs1, got {type(rhs1)}")
if not is_dim(rhs2):
raise AssertionError(f"Expected dimension for rhs2, got {type(rhs2)}")
self.res = res
self.rhs1 = rhs1
self.rhs2 = rhs2
def __repr__(self) -> str:
return f"{self.res} = {self.rhs1}\u2294{self.rhs2}"
def __eq__(self, other: object) -> bool:
if isinstance(other, DGreatestUpperBound):
return (
self.res == other.res
and self.rhs1 == other.rhs1
and self.rhs2 == other.rhs2
)
else:
return False
class CanReshape(Constraint):
"""
can_reshape constraint
"""
def __init__(self, src: TVar, target: TensorType) -> None:
"""
:param src: tensor variable
:param target: tensor
"""
self.src = src
self.target = target
def __repr__(self) -> str:
return f"can-reshape({self.src}, {self.target})"
def __eq__(self, other: object) -> bool:
if isinstance(other, CanReshape):
return self.src == other.src and self.target == other.target
else:
return False
class IndexSelect(Constraint):
def __init__(
self,
tensor_size: int,
input_var: TVar,
dim_replace: DVar | _DynType,
index: int,
output: TVar,
) -> None:
"""
Args:
input_var: input to index_select
tensor_size: tensor size we are considering
dim_replace: the dimension of the output at "index"
index: location of the dimensions to replace in the input
output: variable to store the result
"""
if not isinstance(input_var, TVar):
raise AssertionError(f"Expected TVar, got {type(input_var)}")
if not isinstance(output, TVar):
raise AssertionError(f"Expected TVar, got {type(output)}")
if not (isinstance(dim_replace, DVar) or dim_replace == Dyn):
raise AssertionError(f"Expected DVar or Dyn, got {type(dim_replace)}")
if not isinstance(index, int):
raise AssertionError(f"Expected int, got {type(index)}")
self.input_var = input_var
self.tensor_size = tensor_size
self.dim_replace = dim_replace
self.index = index
self.output = output
def __repr__(self) -> str:
return (
f" {self.output} = "
f"IndexSelect({self.input_var}, "
f"tensor_size: {self.tensor_size}, "
f"{self.dim_replace}, "
f"{self.index})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, IndexSelect):
return (
self.tensor_size == other.tensor_size
and self.dim_replace == other.dim_replace
and self.index == other.index
and self.output == other.output
and self.input_var == other.input_var
)
else:
return False
class Transpose(Constraint):
def __init__(
self, tensor_size: int, input_var: TVar, index1: int, index2: int, output: TVar
) -> None:
"""
Args:
tensor_size: current tensor size
input_var: variable to hold input
index1: dimension 1
index2: dimension 2
output: output that stores result
"""
if not isinstance(input_var, TVar):
raise AssertionError(f"Expected TVar, got {type(input_var)}")
if not isinstance(output, TVar):
raise AssertionError(f"Expected TVar, got {type(output)}")
if not isinstance(index1, int):
raise AssertionError(f"Expected int, got {type(index1)}")
if not isinstance(index2, int):
raise AssertionError(f"Expected int, got {type(index2)}")
self.input_var = input_var
self.tensor_size = tensor_size
self.index1 = index1
self.index2 = index2
self.output = output
def __repr__(self) -> str:
return (
f" {self.output} = "
f"Transpose({self.input_var}, "
f"tensor_size: {self.tensor_size}, "
f"{self.index1}, "
f"{self.index2})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, Transpose):
return (
self.tensor_size == other.tensor_size
and self.index1 == other.index1
and self.index2 == other.index2
and self.output == other.output
and self.input_var == other.input_var
)
else:
return False
class GetItem(Constraint):
def __init__(
self, tensor_size: int, index: int, res: DVar, input_var: TVar
) -> None:
"""
Constraint for getting item given a tensor size
:param tensor_size: actual number
:param index: actual number representing the index
:param res: dimension variable to carry the item we get
:param input_var: a tensor variable from which we will get item
"""
if not isinstance(res, DVar):
raise AssertionError(f"Expected DVar, got {type(res)}")
self.res = res
self.tensor_size = tensor_size
self.index = index
self.input_var = input_var
def __repr__(self) -> str:
return f" {self.res} = GetItem({self.input_var}, tensor_size: {self.tensor_size}, {self.index})"
def __eq__(self, other: object) -> bool:
if isinstance(other, GetItem):
return (
self.res == other.res
and self.tensor_size == other.tensor_size
and self.index == other.index
and self.input_var == other.input_var
)
else:
return False
class GetItemTensor(Constraint):
def __init__(
self,
tensor_size: int,
index_tuple: tuple[None | slice, ...],
res: TVar,
input_var: TVar,
) -> None:
"""
Constraint for getting item given a tensor size
However, when the argument is a tuple, we will
expect a tensor
:param tensor_size: actual number representing the rank
:param index_tuple: tuple for indexing
:param res: tensor variable to carry the item we get
:param input_var: a tensor variable from which we will get item
"""
if not isinstance(res, TVar):
raise AssertionError(f"Expected TVar, got {type(res)}")
self.res = res
self.tensor_size = tensor_size
self.index_tuple = index_tuple
self.input_var = input_var
def __repr__(self) -> str:
return f" {self.res} = GetItemT({self.input_var}, tensor_size: {self.tensor_size}, {self.index_tuple})"
def __eq__(self, other: object) -> bool:
if isinstance(other, GetItemTensor):
return (
self.res == other.res
and self.tensor_size == other.tensor_size
and self.index_tuple == other.index_tuple
and self.input_var == other.input_var
)
else:
return False
class CalcConv(Constraint):
def __init__(
self,
conv_result: TVar,
input_var: TVar,
c_out: int,
kernel: int | tuple[int, int],
padding: int | tuple[int, int],
stride: int | tuple[int, int],
dilation: int | tuple[int, int],
matching_constraint_vars: list[DVar],
) -> None:
"""
:param conv_result: the convolution result
:param input_var: input to convolution
:param c_out: output channel type
:param kernel: kernel tuple
"""
self.conv_result = conv_result
self.input_var = input_var
self.c_out = c_out
self.kernel = kernel
self.padding = padding
self.stride = stride
self.dilation = dilation
self.matching_constraint = matching_constraint_vars
def __repr__(self) -> str:
return (
f"{self.conv_result} ="
f" calc-conv({self.input_var},"
f" {self.c_out}, {self.kernel}, "
f"{self.padding}, {self.stride},"
f" {self.dilation})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcConv):
return (
self.conv_result == other.conv_result
and self.input_var == other.input_var
and self.c_out == other.c_out
and self.kernel == other.kernel
and self.padding == other.padding
and self.stride == other.stride
and self.dilation == other.dilation
and self.matching_constraint == other.matching_constraint
)
else:
return False
class CalcMaxPool(Constraint):
def __init__(
self,
maxpool_result: TVar,
input_var: TVar,
kernel: int | tuple[int, int],
padding: int | tuple[int, int],
stride: int | tuple[int, int],
dilation: int | tuple[int, int],
matching_constraint_vars: list[DVar],
) -> None:
"""
:param maxpool_result: the result of maxpool
:param input_var: input to convolution
:param kernel: kernel tuple
"""
self.maxpool_result = maxpool_result
self.input_var = input_var
self.kernel = kernel
self.padding = padding
self.stride = stride
self.dilation = dilation
self.matching_constraint = matching_constraint_vars
def __repr__(self) -> str:
return (
f"{self.maxpool_result} ="
f" calc-maxpool({self.input_var},"
f" {self.kernel}, "
f"{self.padding}, {self.stride},"
f" {self.dilation})"
)
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcMaxPool):
return (
self.maxpool_result == other.maxpool_result
and self.input_var == other.input_var
and self.kernel == other.kernel
and self.padding == other.padding
and self.stride == other.stride
and self.dilation == other.dilation
and self.matching_constraint == other.matching_constraint
)
else:
return False
class ApplyBroadcasting(Constraint):
def __init__(self, res1: TVar, res2: TVar, input1: TVar, input2: TVar) -> None:
"""
:param res1: resulting tensor 1
:param res2: resulting tensor 2
:param input1: tensor variable 1
:param input2: tensor variable 2
"""
self.res1 = res1
self.res2 = res2
self.input1 = input1
self.input2 = input2
def __eq__(self, other: object) -> bool:
if isinstance(other, ApplyBroadcasting):
return (
self.res1 == other.res1
and self.res2 == other.res2
and self.input1 == other.input1
and self.input2 == other.input2
)
else:
return False
def __repr__(self) -> str:
return (
f"{self.res1}, {self.res2} ="
f" apply-broadcasting({self.input1},"
f" {self.input2})"
)
class CalcProduct(Constraint):
"""
Given correct dimensions, calculate the product for flatten accounting for Dyn
"""
def __init__(
self, start: int, end: int, flattened: TVar, dims_to_flatten: list[DVar]
) -> None:
"""
:param start: start index
:param end: end index
:param flattened: variable to store the product
:param dims_to_flatten: the type which we will flatten
"""
if not isinstance(dims_to_flatten, list):
raise AssertionError(f"Expected list, got {type(dims_to_flatten)}")
if not isinstance(flattened, TVar):
raise AssertionError(f"Expected TVar, got {type(flattened)}")
if not isinstance(start, int):
raise AssertionError(f"Expected int, got {type(start)}")
if not isinstance(end, int):
raise AssertionError(f"Expected int, got {type(end)}")
self.start = start
self.end = end
self.dims_to_flatten = dims_to_flatten
self.flattened = flattened
def __eq__(self, other: object) -> bool:
if isinstance(other, CalcProduct):
return (
self.start == other.start
and self.end == other.end
and self.dims_to_flatten == other.dims_to_flatten
and self.flattened == other.flattened
)
else:
return False
def __repr__(self) -> str:
return f"{self.flattened} = CalcProduct({self.start}, {self.end}, {self.dims_to_flatten})"
class TVar:
"""
Tensor variable with no tensor constructor
"""
def __init__(self, tvar: int) -> None:
"""
:param tvar: tensor variable
"""
self.tvar = tvar
def __repr__(self) -> str:
return f"TV({self.tvar})"
def __eq__(self, other: object) -> bool:
if isinstance(other, TVar):
return self.tvar == other.tvar
else:
return False
class DVar:
"""
Dimension variable
"""
def __init__(self, c: int) -> None:
"""
:param c: character or number
"""
self.c = c
def __repr__(self) -> str:
return f"DV({self.c})"
def __eq__(self, other: object) -> bool:
if isinstance(other, DVar):
return self.c == other.c
else:
return False
class BVar:
"""
Boolean variable
"""
def __init__(self, c: int) -> None:
"""
:param c: character or number
"""
self.c = c
def __repr__(self) -> str:
return f"BV({self.c})"
def __eq__(self, other: object) -> bool:
if isinstance(other, BVar):
return self.c == other.c
else:
return False
_Operand: TypeAlias = (
TVar
| TensorType
| DVar
| int
| float
| bool
| _DynType
| BinConstraintD
| Prod
| BVar
| Conj
| Disj
| None
)
def is_algebraic_expression(constraint: object) -> bool:
if isinstance(constraint, BinConstraintD):
return constraint.op in [op_add, op_sub, op_div, op_mul, op_mod]
else:
return isinstance(constraint, Prod)
def is_bool_expr(constraint: object) -> bool:
if isinstance(constraint, BinConstraintD):
return constraint.op in [op_gt, op_lt, op_neq, op_eq]
else:
return isinstance(constraint, (BVar, Conj, Disj))
def is_dim(d: object) -> bool:
return isinstance(d, (DVar, int)) or d == Dyn
@@ -0,0 +1,14 @@
op_add = "+"
op_sub = "-"
op_mul = "*"
op_div = "/"
op_eq = "="
op_neq = "!="
op_imp = "=>"
op_matching = "\u22b3" # (contains)
op_consistency = "~"
op_precision = "\u2291" # (square image of or equal to)
op_leq = "\u2264" # less-than or equal to
op_lt = "<"
op_gt = ">"
op_mod = "%"
@@ -0,0 +1,546 @@
from typing import Any, TypeAlias
import torch
__all__ = [
"evaluate_conditional_with_constraints",
"iterate_till_fixed_point",
"transform_algebraic_expression",
"transform_all_constraints",
"transform_all_constraints_trace_time",
"transform_dimension",
"transform_to_z3",
"transform_var",
]
# z3 is an optional dependency with no type stubs, so we use aliases for its types.
_Z3Expr: TypeAlias = Any
_Z3Result: TypeAlias = Any
from torch.fx.experimental.migrate_gradual_types.constraint import (
BinConstraintD,
BinConstraintT,
BVar,
Conj,
Constraint,
Disj,
DVar,
F,
is_algebraic_expression,
is_bool_expr,
is_dim,
Prod,
T,
TVar,
)
from torch.fx.experimental.migrate_gradual_types.constraint_generator import (
ConstraintGenerator,
)
from torch.fx.experimental.migrate_gradual_types.constraint_transformation import (
transform_constraint,
)
from torch.fx.experimental.migrate_gradual_types.operation import (
op_add,
op_div,
op_eq,
op_gt,
op_leq,
op_lt,
op_mod,
op_mul,
op_neq,
op_sub,
)
from torch.fx.graph import Graph
from torch.fx.node import Node
from torch.fx.tensor_type import _DynType, Dyn, TensorType
try:
import z3 # type: ignore[import]
from torch.fx.experimental.migrate_gradual_types.z3_types import (
D,
tensor_type,
z3_dyn,
)
HAS_Z3 = True
def transform_to_z3(
constraint: Constraint, counter: int, dimension_dict: dict[int, int]
) -> tuple[_Z3Expr, int]:
if isinstance(constraint, Conj):
conjuncts = []
for c in constraint.conjucts:
new_c, counter = transform_to_z3(c, counter, dimension_dict)
conjuncts.append(new_c)
return z3.And(conjuncts), counter
elif isinstance(constraint, Disj):
disjuncts = []
for c in constraint.disjuncts:
new_c, counter = transform_to_z3(c, counter, dimension_dict)
disjuncts.append(new_c)
return z3.Or(disjuncts), counter
elif isinstance(constraint, T):
return True, counter
elif isinstance(constraint, F):
return False, counter
elif isinstance(constraint, BinConstraintT):
if constraint.op == op_eq:
lhs, counter = transform_var(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_var(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return (lhs == rhs), counter
else:
raise NotImplementedError("Method not yet implemented")
elif isinstance(constraint, BinConstraintD):
if constraint.op == op_eq:
if isinstance(constraint.lhs, BVar) and is_bool_expr(constraint.rhs):
transformed_rhs, counter = transform_to_z3(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
transformed_lhs = z3.Bool(constraint.lhs.c)
return transformed_lhs == transformed_rhs, counter
elif is_dim(constraint.lhs) and is_dim(constraint.rhs):
# with dimension transformations we consider the encoding
lhs, counter = transform_dimension(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_dimension(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs == rhs, counter
else:
# then we have an algebraic expression which means that we disregard the
# first element of the encoding
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs == rhs, counter
# The assumption here is that the LHS and RHS must be dimensions
elif constraint.op == op_neq:
if not is_dim(constraint.lhs):
raise AssertionError("Expected lhs to be a dimension")
if not is_dim(constraint.rhs):
raise AssertionError("Expected rhs to be a dimension")
lhs, counter = transform_dimension(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_dimension(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
if constraint.rhs == Dyn or constraint.lhs == Dyn:
if constraint.rhs == Dyn:
return lhs.arg(0) == 1, counter
else:
return rhs.arg(0) == 1, counter
# if one of the instances is a number
elif isinstance(constraint.lhs, int) or isinstance(constraint.rhs, int):
if isinstance(constraint.lhs, int):
return (
z3.Or(
[
rhs.arg(0) == 0,
z3.And([rhs.arg(0) == 1, lhs.arg(1) != rhs.arg(1)]),
]
),
counter,
)
else:
return (
z3.Or(
[
lhs.arg(0) == 0,
z3.And([lhs.arg(0) == 1, lhs.arg(1) != rhs.arg(1)]),
]
),
counter,
)
else:
return (
z3.Or(
[
z3.And([lhs.arg(0) == 0, rhs.arg(0) != 0]),
z3.And([lhs.arg(0) != 0, rhs.arg(0) == 0]),
z3.And(
[
lhs.arg(0) != 0,
rhs.arg(0) != 0,
lhs.arg(1) != rhs.arg(1),
]
),
]
),
counter,
)
elif constraint.op == op_leq:
# if the dimensions are not dyn, this will come into effect
# there would have been another constraint specifying if a given dimension
# is dyn or not
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs <= rhs, counter
elif constraint.op == op_gt:
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs > rhs, counter
elif constraint.op == op_lt:
if not (is_dim(constraint.lhs) and is_dim(constraint.rhs)):
raise AssertionError("Expected both lhs and rhs to be dimensions")
lhs, counter = transform_algebraic_expression(
constraint.lhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
constraint.rhs, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return lhs < rhs, counter
else:
raise NotImplementedError("operation not yet implemented")
else:
raise NotImplementedError("Operation not yet implemented")
def transform_var(
tensor: TVar | TensorType | _DynType,
counter: int,
dimension_dict: dict[int, int],
) -> tuple[_Z3Expr, int]:
"""
Transforms tensor variables to a format understood by z3
Args:
tensor: Tensor variable or a tensor type potentially with variable dimensions
Returns: Transformed variable to a z3 format
"""
if isinstance(tensor, TensorType):
res: list[_Z3Expr] = []
for t in tensor.__args__:
transformed, counter = transform_dimension(t, counter, dimension_dict)
res.append(transformed)
if len(res) > 4:
raise AssertionError(f"Expected res length <= 4, got {len(res)}")
if len(tensor.__args__) == 1:
return tensor_type.tensor1(res[0]), counter
elif len(tensor.__args__) == 2:
return tensor_type.tensor2(res[0], res[1]), counter
elif len(tensor.__args__) == 3:
return tensor_type.tensor3(res[0], res[1], res[2]), counter
elif len(tensor.__args__) == 4:
return tensor_type.tensor4(res[0], res[1], res[2], res[3]), counter
else:
raise AssertionError(
f"Unexpected tensor args length: {len(tensor.__args__)}"
)
elif tensor == Dyn:
return z3_dyn, counter
elif isinstance(tensor, TVar):
return z3.Const(tensor.tvar, tensor_type), counter
else:
raise NotImplementedError(f"Unsupported tensor type: {type(tensor)}")
def transform_dimension(
dimension: DVar | int | _DynType, counter: int, dimension_dict: dict[int, int]
) -> tuple[_Z3Expr, int]:
"""
Takes a dimension variable or a number and transforms it to a tuple
according to our scheme
Args:
dimension: The dimension to be transformed
counter: variable tracking
Returns: tuple and the current counter
"""
if dimension == Dyn:
counter += 1
return D(0, z3.Int(counter)), counter
elif isinstance(dimension, int):
return D(1, dimension), counter
elif isinstance(dimension, DVar):
if dimension.c in dimension_dict:
return (
D(z3.Int(dimension_dict[dimension.c]), z3.Int(dimension.c)),
counter,
)
else:
counter += 1
dimension_dict[dimension.c] = counter
return D(z3.Int(counter), z3.Int(dimension.c)), counter
else:
raise NotImplementedError(f"Unsupported dimension type: {type(dimension)}")
def transform_algebraic_expression(
expr: DVar | int | _DynType | Prod | BinConstraintD,
counter: int,
dimension_dict: dict[int, int],
) -> tuple[_Z3Expr, int]:
"""
Transforms an algebraic expression to z3 format
Args:
expr: An expression is either a dimension variable or an algebraic-expression
Returns: the transformed expression
"""
if not (is_algebraic_expression(expr) or is_dim(expr)):
raise AssertionError("Expected algebraic expression or dimension")
if is_dim(expr):
transformed, counter = transform_dimension(
expr, # pyrefly: ignore[bad-argument-type]
counter,
dimension_dict,
)
return transformed.arg(1), counter
elif isinstance(expr, Prod):
dims = []
for dim in expr.products:
if not is_dim(dim):
raise AssertionError("Expected dimension in Prod")
d, counter = transform_dimension(dim, counter, dimension_dict)
dims.append(d.arg(1))
return z3.Product(dims), counter
elif is_algebraic_expression(expr):
lhs, counter = transform_algebraic_expression(
expr.lhs, # pyrefly: ignore[missing-attribute]
counter,
dimension_dict,
)
rhs, counter = transform_algebraic_expression(
expr.rhs, # pyrefly: ignore[missing-attribute]
counter,
dimension_dict,
)
if expr.op == op_sub: # pyrefly: ignore[missing-attribute]
c = lhs - rhs
elif expr.op == op_add:
c = lhs + rhs
elif expr.op == op_div:
c = lhs / rhs
elif expr.op == op_mul:
c = lhs * rhs
elif expr.op == op_mod:
c = lhs % rhs
else:
raise NotImplementedError("operation not yet implemented")
return c, counter
else:
raise RuntimeError
def transform_all_constraints(traced: torch.nn.Module, counter: int = 0) -> _Z3Expr:
"""
Given a trace, generates constraints and transforms them to z3 format
"""
dimension_dict: dict[int, int] = {}
generator = ConstraintGenerator(traced)
new_constraints, counter = generator.generate_constraints(counter)
new_constraints, counter = iterate_till_fixed_point(new_constraints, counter)
transformed, counter = transform_to_z3(new_constraints, counter, dimension_dict)
# print(transformed)
return transformed
def iterate_till_fixed_point(
constraints: Constraint, counter: int
) -> tuple[Constraint, int]:
"""
Transform constraints till reaching a fixed point
"""
old_c = None
while old_c != constraints:
old_c = constraints
constraints, counter = transform_constraint(constraints, counter)
return constraints, counter
def transform_all_constraints_trace_time(
tracer_root: torch.nn.Module, graph: Graph, node: Node, counter: int = 0
) -> tuple[_Z3Expr, _Z3Expr]:
"""
Takes a node and a graph and generates two sets of constraints.
One set constraints the node's constraints and another set
constraints the negation of the node's constraints
Args:
tracer_root: the root for getting the module instances
graph: the graph so far in the tracing process
node: node that represents a conditional
counter: variable tracking
Returns: Two sets of constraints. One with a conjunction with the
the conditional constraint and the other with a conjunction with
its negation.
"""
dimension_dict: dict[int, int] = {}
generator = ConstraintGenerator(tracer_root, graph)
new_constraints, counter = generator.generate_constraints(counter)
condition_constraint = new_constraints.conjucts[-1]
# we know the constraint is a conjunction where the last constraint is about the conditional
# so remove the last constraint
new_constraints.conjucts = new_constraints.conjucts[:-1]
# transform precision, matching, consistency till obtaining a fixed point
new_constraints, counter = iterate_till_fixed_point(new_constraints, counter)
# since the function returns a list of one element, we get the first element
# we are only interested in the RHS in this case because the LHS just stores
# the result
# we make sure the constraint is of the form:
# c = b where b is a boolean expression
# and we consider b (constraint.rhs) for transformation
if not isinstance(condition_constraint, BinConstraintD):
raise TypeError(type(condition_constraint))
if not isinstance(condition_constraint.lhs, BVar):
raise AssertionError(f"Expected BVar, got {type(condition_constraint.lhs)}")
if not is_bool_expr(condition_constraint.rhs):
raise AssertionError("Expected bool expression for rhs")
if not isinstance(condition_constraint.rhs, Constraint):
raise TypeError(type(condition_constraint.rhs))
condition_constraint_rhs = condition_constraint.rhs
# transform the condition constraint
condition_constraint_rhs, counter = iterate_till_fixed_point(
condition_constraint_rhs, counter
)
transformed, counter = transform_to_z3(new_constraints, counter, dimension_dict)
transformed_condition_constraint, counter = transform_to_z3(
condition_constraint_rhs, counter, dimension_dict
)
negation_transformed_condition_constraint = z3.Not(
transformed_condition_constraint
)
return z3.And([transformed, transformed_condition_constraint]), z3.And(
[transformed, negation_transformed_condition_constraint]
)
def evaluate_conditional_with_constraints(
tracer_root: torch.nn.Module,
graph: Graph,
node: Node,
counter: int = 0,
user_constraints: _Z3Expr | None = None,
) -> tuple[_Z3Result, _Z3Result]:
"""
Given an IR and a node representing a conditional, evaluate the conditional
and its negation
Args:
tracer_root: Tracer root for module instances
node: The node to be evaluated
Returns: the results of evaluating the condition and the negation with
the rest of the constraints
"""
(
transformed_positive,
transformed_negative,
) = transform_all_constraints_trace_time(tracer_root, graph, node, counter)
s = z3.Solver()
s.add(transformed_positive)
if user_constraints is not None:
s.add(user_constraints)
condition = s.check()
s = z3.Solver()
s.add(transformed_negative)
if user_constraints is not None:
s.add(user_constraints)
negation = s.check()
return condition, negation
except ImportError:
HAS_Z3 = False
@@ -0,0 +1,58 @@
from torch.fx.experimental.migrate_gradual_types.constraint import (
BinConstraintD,
BVar,
DVar,
TVar,
)
from torch.fx.experimental.migrate_gradual_types.operation import op_leq
def gen_tvar(curr: int) -> tuple[TVar, int]:
"""
Generate a tensor variable
:param curr: The current counter
:return: a tensor variable and the updated counter
"""
curr += 1
return TVar(curr), curr
def gen_dvar(curr: int) -> tuple[DVar, int]:
"""
Generate a dimension variable
:param curr: the current counter
:return: a dimension variable and an updated counter
"""
curr += 1
return DVar(curr), curr
def gen_bvar(curr: int) -> tuple[BVar, int]:
"""
Generate a boolean variable
:param curr: the current counter
:return: a boolean variable and an updated counter
"""
curr += 1
return BVar(curr), curr
def gen_tensor_dims(n: int, curr: int) -> tuple[list[DVar], int]:
"""
Generate a list of tensor dimensions
:param n: the number of dimensions
:param curr: the current counter
:return: a list of dimension variables and an updated counter
"""
dims = []
for _ in range(n):
dvar, curr = gen_dvar(curr)
dims.append(dvar)
return dims, curr
def gen_nat_constraints(list_of_dims: list[DVar]) -> list[BinConstraintD]:
"""
Generate natural number constraints for dimensions
"""
return [BinConstraintD(0, d, op_leq) for d in list_of_dims]
@@ -0,0 +1,30 @@
try:
import z3 # type: ignore[import]
HAS_Z3 = True
# dynamic type
dyn = z3.DeclareSort("Dyn")
dyn_type = z3.Const("dyn", dyn)
# dimension
dim = z3.Datatype("dim")
dim.declare("dim", ("0", z3.IntSort()), ("1", z3.IntSort()))
dim = dim.create()
# tensors
tensor_type = z3.Datatype("TensorType")
tensor_type.declare("Dyn", ("dyn", dyn))
tensor_type.declare("tensor1", ("0", dim))
tensor_type.declare("tensor2", ("0", dim), ("1", dim))
tensor_type.declare("tensor3", ("0", dim), ("1", dim), ("2", dim))
tensor_type.declare("tensor4", ("0", dim), ("1", dim), ("2", dim), ("3", dim))
tensor_type = tensor_type.create()
# create dimension
D = dim.dim
z3_dyn = tensor_type.Dyn(dyn_type)
except ImportError:
HAS_Z3 = False
@@ -0,0 +1,168 @@
# mypy: allow-untyped-defs
import operator
from collections.abc import Callable
from typing import Any
import torch
import torch.fx
import torch.fx as fx
from torch.fx import Proxy, Transformer
from torch.fx.node import Argument, map_aggregate, Node, Target
from torch.fx.operator_schemas import (
create_type_hint,
normalize_function,
normalize_module,
)
from .schema_type_annotation import AnnotateTypesWithSchema
class NormalizeArgs(Transformer):
"""
Normalize arguments to Python targets. This means that
`args/kwargs` will be matched up to the module/functional's
signature and rewritten to exclusively kwargs in positional order
if `normalize_to_only_use_kwargs` is true. Also populates default
values. Does not support positional-only parameters or varargs
parameters (*args, **kwargs).
If the nodes have 'type' metadata, it will use it to disambiguate
overloads. Otherwise, it will throw an error.
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = NormalizeArgs(traced).transform()
"""
def __init__(
self, module: torch.fx.GraphModule, normalize_to_only_use_kwargs: bool = True
):
super().__init__(module)
self.node_map: dict[Proxy, Node] = {}
self.normalize_to_only_use_kwargs = normalize_to_only_use_kwargs
def run_node(self, n: Node) -> Any:
args, kwargs = self.fetch_args_kwargs_from_env(n)
def get_type(arg):
if isinstance(arg, fx.Node):
return n.meta.get("type")
return type(arg)
arg_types = map_aggregate(n.args, get_type)
if not isinstance(arg_types, tuple):
raise AssertionError(f"Expected tuple, got {type(arg_types)}")
arg_types = tuple(create_type_hint(i) for i in arg_types)
kwarg_types = {k: get_type(v) for k, v in kwargs.items()}
if n.op == "call_function":
out = self.call_function(n.target, args, kwargs, arg_types, kwarg_types)
else:
out = super().run_node(n)
if n.op != "output":
self.node_map[out] = n
out.node.meta = n.meta
out.node.type = n.type
return out
def call_function(
self,
target: Target,
args: tuple[Argument, ...],
kwargs: dict[str, Any],
arg_types: tuple[Any, ...] | None = None,
kwarg_types: dict[str, Any] | None = None,
):
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
new_args_and_kwargs = normalize_function(
target,
args, # type: ignore[arg-type]
kwargs,
arg_types, # type: ignore[arg-type]
kwarg_types,
self.normalize_to_only_use_kwargs,
)
if new_args_and_kwargs:
new_args, new_kwargs = new_args_and_kwargs
return self.tracer.create_proxy(
"call_function", target, new_args, new_kwargs
)
else:
return super().call_function(target, args, kwargs)
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
new_args_and_kwargs = normalize_module(
self.module,
target,
args, # type: ignore[arg-type]
kwargs,
self.normalize_to_only_use_kwargs,
)
if new_args_and_kwargs:
new_args, new_kwargs = new_args_and_kwargs
return super().call_module(target, new_args, new_kwargs)
else:
return super().call_module(target, args, kwargs)
class NormalizeOperators(AnnotateTypesWithSchema):
"""
Normalize callsites that are different ways of "spelling" the same
invocation into a single, canonical call. Currently supports:
1. Normalize operators (e.g. operator.add) to the `torch` ops they
ultimately invoke (e.g. torch.add) when it is possible to statically
reason that
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = NormalizeOperators(traced).transform()
"""
binary_magic_method_remap: dict[
Callable[[Any, Any], Any], Callable[[Any, Any], Any]
] = {
torch.add: operator.add,
torch.mul: operator.mul,
torch.sub: operator.sub,
torch.div: operator.truediv,
torch.floor_divide: operator.floordiv,
torch.remainder: operator.mod,
torch.eq: operator.eq,
torch.ne: operator.ne,
torch.lt: operator.lt,
torch.le: operator.le,
torch.gt: operator.gt,
torch.ge: operator.ge,
}
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
# Normalize operators according to the magic methods implemented on tensors here:
# https://github.com/pytorch/pytorch/blob/28c5d90b679c6b38bf4183ec99f16d933c2f1bcd/tools/autograd/templates/python_variable_methods.cpp#L1137 # noqa: B950
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
if target in self.binary_magic_method_remap:
if len(args) != 2:
return super().call_function(target, args, kwargs)
lhs, rhs = args
return super().call_function(
target=self.binary_magic_method_remap[target],
args=(lhs, rhs),
kwargs={},
)
return super().call_function(target, args, kwargs)
@@ -0,0 +1,498 @@
# mypy: allow-untyped-defs
import copy
import logging
import operator
import time
from collections import defaultdict
from collections.abc import Iterable
from enum import Enum
from typing import Any, cast
import torch
import torch.fx as fx
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.mkldnn as th_mkldnn
from torch.fx.node import Argument, Target
from torch.fx.passes.shape_prop import ShapeProp
from torch.nn.utils.fusion import fuse_conv_bn_eval, fuse_linear_bn_eval
__all__ = [
"matches_module_pattern",
"replace_node_module",
"fuse",
"remove_dropout",
"extract_subgraph",
"modules_to_mkldnn",
"reset_modules",
"MklSubgraph",
"gen_mkl_autotuner",
"use_mkl_length",
"UnionFind",
"optimize_for_inference",
]
def _parent_name(target: str) -> tuple[str, str]:
"""
Splits a qualname into parent path and last atom.
For example, `foo.bar.baz` -> (`foo.bar`, `baz`)
"""
*parent, name = target.rsplit(".", 1)
return parent[0] if parent else "", name
# Works for length 2 patterns with 2 modules
def matches_module_pattern(
pattern: Iterable[type], node: fx.Node, modules: dict[str, Any]
):
if len(node.args) == 0:
return False
nodes: tuple[Any, fx.Node] = (node.args[0], node)
for expected_type, current_node in zip(pattern, nodes):
if not isinstance(current_node, fx.Node):
return False
if current_node.op != "call_module":
return False
if not isinstance(current_node.target, str):
return False
if current_node.target not in modules:
return False
if type(modules[current_node.target]) is not expected_type:
return False
return True
def replace_node_module(
node: fx.Node, modules: dict[str, Any], new_module: torch.nn.Module
):
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
parent_name, name = _parent_name(node.target)
modules[node.target] = new_module
setattr(modules[parent_name], name, new_module)
def fuse(model: torch.nn.Module, inplace=False, no_trace=False) -> torch.nn.Module:
"""
Fuses convolution/BN and linear/BN layers for inference purposes.
Will deepcopy your model by default, but can modify the model inplace as well.
"""
patterns = [
(nn.Conv1d, nn.BatchNorm1d),
(nn.Conv2d, nn.BatchNorm2d),
(nn.Conv3d, nn.BatchNorm3d),
(nn.Linear, nn.BatchNorm1d),
]
if not inplace:
model = copy.deepcopy(model)
if not no_trace or not isinstance(model, torch.fx.GraphModule):
fx_model = fx.symbolic_trace(model)
else:
fx_model = model
modules = dict(fx_model.named_modules())
new_graph = copy.deepcopy(fx_model.graph)
for pattern in patterns:
for node in new_graph.nodes:
if matches_module_pattern(pattern, node, modules):
if len(node.args[0].users) > 1:
# Output of conv/linear is used by other nodes
continue
first_layer = modules[node.args[0].target]
bn = modules[node.target]
if not bn.track_running_stats:
continue
if pattern[0] in [nn.Conv1d, nn.Conv2d, nn.Conv3d]:
fused_layer = fuse_conv_bn_eval(first_layer, bn)
else: # nn.Linear
fused_layer = fuse_linear_bn_eval(first_layer, bn)
replace_node_module(node.args[0], modules, fused_layer)
node.replace_all_uses_with(node.args[0])
new_graph.erase_node(node)
return fx.GraphModule(fx_model, new_graph)
def remove_dropout(model: nn.Module) -> nn.Module:
"""
Removes all dropout layers from the module.
"""
fx_model = fx.symbolic_trace(model)
class DropoutRemover(torch.fx.Transformer):
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
if isinstance(self.submodules[target], nn.Dropout):
if len(args) != 1:
raise AssertionError(f"Expected 1 arg for Dropout, got {len(args)}")
return args[0]
else:
return super().call_module(target, args, kwargs)
return DropoutRemover(fx_model).transform()
def extract_subgraph(
orig_module: nn.Module,
nodes: list[fx.Node],
inputs: list[fx.Node],
outputs: list[fx.Node],
):
"""
Given lists of nodes from an existing graph that represent a subgraph, returns a submodule that executes that subgraph.
"""
new_graph = fx.Graph()
env: dict[fx.Node, fx.Node] = {}
for input in inputs:
new_node = new_graph.placeholder(input.name)
env[input] = new_node
for node in nodes:
new_node = new_graph.node_copy(node, lambda x: env[x])
env[node] = new_node
new_graph.output([env[output] for output in outputs])
new_graph.lint()
return fx.GraphModule(orig_module, new_graph)
mkldnn_supported = [
nn.Conv2d,
nn.Linear,
nn.BatchNorm2d,
nn.ReLU,
nn.MaxPool2d,
nn.AvgPool2d,
nn.AdaptiveAvgPool2d,
torch.relu,
torch.transpose,
torch.sigmoid,
F.relu,
F.avg_pool2d,
F.adaptive_avg_pool2d,
]
# These are operators that may not be convertible into MKLDNN ops (e.g. the
# args are scalar values). Thus, we only include them in the subgraph if their
# arguments are already in MKLDNN.
# TODO: Determine whether this can be removed after type inference.
mkldnn_supported_unknown = [operator.add, operator.mul]
mkldnn_map = {
nn.Conv2d: th_mkldnn.MkldnnConv2d,
nn.Linear: th_mkldnn.MkldnnLinear,
nn.BatchNorm2d: lambda a, _: th_mkldnn.MkldnnBatchNorm(a),
}
def modules_to_mkldnn(nodes: list[fx.Node], modules: dict[str, nn.Module]):
"""
For each node, if it's a module that can be preconverted into MKLDNN,
then we do so and create a mapping to allow us to convert from the MKLDNN
version of the module to the original.
"""
old_modules: dict[nn.Module, nn.Module] = {}
for node in nodes:
if node.op == "call_module":
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
cur_module = modules[node.target]
if type(cur_module) in mkldnn_map:
# pyrefly: ignore [bad-index, index-error]
new_module = mkldnn_map[type(cur_module)](cur_module, torch.float)
if not isinstance(new_module, nn.Module):
raise AssertionError(f"Expected nn.Module, got {type(new_module)}")
old_modules[new_module] = copy.deepcopy(cur_module)
replace_node_module(node, modules, new_module)
return old_modules
def reset_modules(
nodes: list[fx.Node],
modules: dict[str, nn.Module],
old_modules: dict[nn.Module, nn.Module],
):
"""
Maps each module that's been changed with `modules_to_mkldnn` back to its
original.
"""
for node in nodes:
if node.op == "call_module":
if not isinstance(node.target, str):
raise AssertionError(f"Expected str target, got {type(node.target)}")
cur_module = modules[node.target]
if cur_module in old_modules:
replace_node_module(node, modules, old_modules[cur_module])
class MklSubgraph:
def __init__(self, fx_graph: fx.Graph):
self.fx_graph = fx_graph
self.nodes: list[fx.Node] = []
self.start_nodes: list[fx.Node] = []
self.end_nodes: list[fx.Node] = []
def gen_mkl_autotuner(example_inputs, iters=10, warmup=1):
"""
This generates a heuristic that can be passed into `optimize_for_inference` that
determines whether a subgraph should be run in MKL by running it with the example_inputs.
Example usage:
heuristic = gen_mkl_autotuner(example_inputs, iters=10)
fast_model = optimization.optimize_for_inference(model, heuristic)
"""
fx_model = None
old_modules = None
def use_mkl_heuristic(graph: MklSubgraph) -> bool:
nonlocal fx_model, old_modules
input_nodes = graph.start_nodes
if fx_model is None:
fx_model = graph.fx_graph.owning_module
old_modules = graph.fx_graph.old_modules # type: ignore[attr-defined]
ShapeProp(fx_model).propagate(example_inputs)
sample_inputs = [torch.randn(node.shape) for node in input_nodes] # type: ignore[attr-defined]
output_args = cast(list[fx.Node], [node.args[0] for node in graph.end_nodes])
submodule = extract_subgraph(fx_model, graph.nodes, input_nodes, output_args)
def benchmark(f):
for _ in range(warmup):
f()
begin = time.time()
for _ in range(iters):
f()
return time.time() - begin
mkl_time = benchmark(
lambda: [
i.to_dense() for i in submodule(*[i.to_mkldnn() for i in sample_inputs])
]
)
reset_modules(
submodule.graph.nodes,
dict(submodule.named_modules()),
# pyrefly: ignore [bad-argument-type]
old_modules,
)
no_mkl_time = benchmark(lambda: submodule(*sample_inputs))
return mkl_time < no_mkl_time
return use_mkl_heuristic
def use_mkl_length(graph: MklSubgraph) -> bool:
"""
This is a heuristic that can be passed into `optimize_for_inference` that
determines whether a subgraph should be run in MKL by checking if there
are more than 2 nodes in it
"""
return len(graph.nodes) > 2
class UnionFind:
def __init__(self, n):
self.parent: list[int | None] = [None] * n
self.size: list[int] = [0] * n
def make_set(self, v: int):
self.parent[v] = v
self.size[v] = 1
def find(self, v: int) -> int:
par = self.parent[v]
if v == par:
return v
if par is None:
raise AssertionError("Parent is None")
self.parent[v] = self.find(par)
return cast(int, self.parent[v])
def join(self, a: int, b: int):
a, b = self.find(a), self.find(b)
if a == b:
return a
if self.size[a] < self.size[b]:
a, b = b, a
self.parent[b] = a
self.size[a] += self.size[b]
def optimize_for_inference(
model: torch.nn.Module,
pass_config: dict[str, Any] | None = None,
tracer: type[fx.Tracer] = fx.Tracer,
) -> torch.nn.Module:
"""
Performs a set of optimization passes to optimize a model for the
purposes of inference. Specifically, the passes that are run are:
1. Conv/BN fusion
2. Dropout removal
3. MKL layout optimizations
The third optimization takes a function `use_mkl_heuristic` that's used
to determine whether a subgraph should be explicitly run in MKL layout.
Note: As FX does not currently handle aliasing, this pass currently
assumes nothing aliases. If that isn't true, use at your own risk.
"""
default_pass_config = {
"conv_bn_fuse": True,
"remove_dropout": True,
"mkldnn_layout_optimize": {"heuristic": use_mkl_length},
}
if pass_config is None:
pass_config = {}
default_pass_config.update(pass_config)
if default_pass_config["conv_bn_fuse"]:
model = fuse(model)
if default_pass_config["remove_dropout"]:
model = remove_dropout(model)
if default_pass_config["mkldnn_layout_optimize"] is False:
return model
if not isinstance(default_pass_config["mkldnn_layout_optimize"], dict):
raise RuntimeError("mkldnn_layout_optimize config is not a dict")
if "heuristic" not in default_pass_config["mkldnn_layout_optimize"]:
raise RuntimeError("Heuristic not found in mkldnn_layout_optimize config")
use_mkl_heuristic = default_pass_config["mkldnn_layout_optimize"]["heuristic"]
cur_tracer = tracer()
fx_graph = cur_tracer.trace(copy.deepcopy(model))
fx.GraphModule(cur_tracer.root, fx_graph)
modules: dict[str, nn.Module] = dict(model.named_modules())
class MklSupport(Enum):
NO = 1
YES = 2
UNKNOWN = 3
# Inserts to_mkldnn and to_dense around every node we want to be a MKLDNN node.
# If the op is in `mkldnn_supported` then we always treat it as a MKLDNN node.
# However, if it's in `mkldnn_supported_unknown`, then we only treat it as
# a MKLDNN node if its inputs are MKLDNN nodes.
for node in list(fx_graph.nodes):
supports_mkldnn = MklSupport.NO
if node.op == "call_module":
cur_module = modules[node.target]
if type(cur_module) in mkldnn_supported:
supports_mkldnn = MklSupport.YES
sample_parameter = next(cur_module.parameters(), None)
if sample_parameter is not None:
if sample_parameter.dtype != torch.float:
raise AssertionError(
"this pass is only for torch.float modules"
)
if sample_parameter.device != torch.device("cpu"):
raise AssertionError("this pass is only for CPU modules")
elif node.op == "call_function":
if node.target in mkldnn_supported:
supports_mkldnn = MklSupport.YES
elif node.target in mkldnn_supported_unknown:
supports_mkldnn = MklSupport.UNKNOWN
if supports_mkldnn != MklSupport.NO:
if supports_mkldnn == MklSupport.UNKNOWN:
if not any(arg.target == "to_dense" for arg in node.args):
continue
with fx_graph.inserting_before(node):
mkldnn_args = fx.map_arg(
node.args, lambda n: fx_graph.call_method("to_mkldnn", (n,))
)
node.args = cast(tuple[fx.node.Argument], mkldnn_args)
with fx_graph.inserting_after(node):
dense_x = fx_graph.create_node("call_method", "to_dense", (node,))
node.replace_all_uses_with(dense_x)
dense_x.args = (node,)
# Does pre-conversion of all modules into MKLDNN (when possible)
old_modules = modules_to_mkldnn(list(fx_graph.nodes), modules)
fx_graph.old_modules = old_modules # type: ignore[attr-defined]
# optimizes all a -> to_dense -> to_mkldnn -> b patterns into a -> b
for node in fx_graph.nodes:
if node.op == "call_method" and node.target == "to_dense":
prv_node = node.args[0]
users = list(node.users)
for user in users:
if user.op == "call_method" and user.target == "to_mkldnn":
user.replace_all_uses_with(prv_node)
fx_graph.erase_node(user)
if len(node.users) == 0:
fx_graph.erase_node(node)
num_nodes = len(fx_graph.nodes)
uf = UnionFind(num_nodes)
def get_color(n):
if hasattr(n, "color"): # Current node is part of a MKL subgraph
return uf.find(n.color)
if hasattr(n, "start_color"): # Current node is input to MKL subgraph
return uf.find(n.start_color)
return None
# This code is to find each MKLDNN subgraph. Each MKLDNN subgraph consists
# of input nodes (which are only `to_mkldnn` calls), output nodes
# (`to_dense` calls), and intermediate nodes, which are run entirely on
# MKLDNN layout tensors.
#
# Specifically, this code does a flood fill on a directed acyclic graph
# (DAG), starting from each possible "start node" (i.e: `to_mkldnn` nodes).
# If every node only had one input, this would be sufficient. However, in
# the case that a node has multiple inputs coming from different start
# nodes (i.e. colors), we need to join these 2 colors into 1. That's done
# using a Disjoint Set Union.
for cur_idx, node in enumerate(fx_graph.nodes):
if node.op == "call_method" and node.target == "to_mkldnn":
node.start_color = cur_idx
uf.make_set(cur_idx)
elif node.op == "call_method" and node.target == "to_dense":
if get_color(node.args[0]) is None:
raise AssertionError("Expected color for to_dense input")
node.end_color = get_color(node.args[0])
else:
cur_colors = [
get_color(i)
for i in node.all_input_nodes
if isinstance(i, fx.Node)
if get_color(i) is not None
]
if len(cur_colors) == 0:
continue
if any(i is None for i in cur_colors):
raise AssertionError("Found None in cur_colors")
cur_colors = sorted(cur_colors)
node.color = cur_colors[0]
for other_color in cur_colors[1:]:
uf.join(cur_colors[0], other_color)
mkldnn_graphs: dict[int, MklSubgraph] = defaultdict(lambda: MklSubgraph(fx_graph))
for node in fx_graph.nodes:
if hasattr(node, "color"):
mkldnn_graphs[uf.find(node.color)].nodes.append(node)
if hasattr(node, "start_color"):
mkldnn_graphs[uf.find(node.start_color)].start_nodes.append(node)
if hasattr(node, "end_color"):
mkldnn_graphs[uf.find(node.end_color)].end_nodes.append(node)
# Now that we have all the subgraphs, we need to decide which MKLDNN
# subgraphs we actually want to keep in MKLDNN.
for graph in mkldnn_graphs.values():
if not use_mkl_heuristic(graph):
for node in graph.start_nodes + graph.end_nodes:
prv = node.args[0]
node.replace_all_uses_with(prv) # type: ignore[arg-type]
fx_graph.erase_node(node)
reset_modules(graph.nodes, modules, old_modules)
mkldnn_conversions = 0
for node in fx_graph.nodes:
if node.target == "to_mkldnn" or node.target == "to_dense":
mkldnn_conversions += 1
logging.getLogger(__name__).info("mkldnn conversions: %s", mkldnn_conversions)
fx_graph.lint()
result = fx.GraphModule(model, fx_graph)
return result
@@ -0,0 +1,317 @@
# mypy: allow-untyped-defs
from enum import Enum
from typing import NamedTuple
from torch.fx.node import map_arg, Node
class Partition:
"""Partition class contains all the information about an individual partition.
It also provides necessary methods for manipulation the partition.
"""
def __init__(self, partition_id: int) -> None:
self.nodes: set[Node] = set()
self.partition_id = partition_id
self.parents: set[Partition] = set()
self.children: set[Partition] = set()
self.bfs_level: int = -1
self.used_mem_bytes: int = 0
self.logical_device_ids: list[int] = []
def __str__(self):
return str(self.partition_id)
def recalculate_mem_size(self):
self.used_mem_bytes = 0
for node in self.nodes:
self.used_mem_bytes += get_extra_size_of(node, self.nodes)
def add_node(self, node):
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Add current node's input nodes if they are placeholder or constants
for n in input_nodes:
if n.op in {"placeholder", "get_attr"}:
self.nodes.add(n)
self.nodes.add(node)
self.recalculate_mem_size()
def remove_node(self, node):
# Remove a node only if the node is in the partition
if node in self.nodes:
self.nodes.remove(node)
# Collect the node's input nodes
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Check if an input node is a placeholder or get_attr,
# and this input node is not used by some other nodes in this partition,
# the remove this input node
for input_node in input_nodes:
if all(
n not in self.nodes for n in input_node.users
) and input_node.op in {"placeholder", "get_attr"}:
self.nodes.remove(input_node)
self.recalculate_mem_size()
class Device(NamedTuple):
name: str
available_mem_bytes: int
logical_id: int
class NodeLatency(NamedTuple):
# Latency due to the memory bandwidth
mem_latency_sec: float
# Latency due to the computation
computer_latency_sec: float
class PartitionLatency(NamedTuple):
# Sum of all nodes' memory latency on the critical path
mem_latency_sec: float
# Sum of all nodes' compute latency on the critical path
computer_latency_sec: float
# Latency of the critical path
overall_latency_sec: float
class PartitionMode(Enum):
size_based = 0
sparse_nn = 1
cost_aware = 2
kl_based = 3
aot_based = 4
class PartitionerConfig(NamedTuple):
devices: list[Device]
mode: PartitionMode = PartitionMode.size_based
transfer_rate_bytes_per_sec: float = 0.0
node_to_latency_mapping: dict[Node, NodeLatency] = {}
node_to_partition_mapping: dict[Node, int] = {}
partition_to_logical_device_mapping: dict[int, list[int]] = {}
# Saturate host by replicating partitions to the remaining idle devices.
saturate_host: bool = False
def get_extra_size_of(node: Node, nodes: set[Node]) -> int:
"""Given a node and a set of nodes,
this function return the extra size that needed
if this node is included in this set.
"""
# Find all its input nodes
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# Calculate total size of related nodes
total_size_of_input_nodes = 0
for n in input_nodes:
# Make sure this node hasn't been in this set yet
if n not in nodes:
size_bytes = getattr(n, "size_bytes", None)
if size_bytes:
total_size_of_input_nodes += size_bytes.output_size
else:
raise RuntimeError("node has no size_bytes attr")
# Don't forget the op node itself
size_bytes = getattr(node, "size_bytes", None)
if size_bytes:
total_size_of_input_nodes += size_bytes.total_size
else:
raise RuntimeError("node has no size_bytes attr")
return total_size_of_input_nodes
def get_latency_of_one_partition(
partition: Partition, node_to_latency_mapping: dict[Node, NodeLatency]
) -> PartitionLatency:
"""Given a partition and its nodes' latency, return a PartitionLatency for this partition"""
def get_top_nodes(partition: Partition) -> list[Node]:
"""Given a partition, return a list of nodes on the top bfs level"""
top_nodes: list[Node] = []
for node in partition.nodes:
# Skip placeholder and get_attr nodes
if node.op in {"placeholder", "get_attr"}:
continue
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
# If a node has no input nodes in this partition,
# or its input nodes in this partition are placeholders and get_attrs
# this node is on the top bfs level in this partition
if not any(
n in partition.nodes and n.op not in {"placeholder", "get_attr"}
for n in input_nodes
):
top_nodes.append(node)
return top_nodes
def dfs_helper(node: Node, partition_latency) -> PartitionLatency:
"""Given a top node of a partition, this function returns
the latency of the critical path in the partition
"""
node_latency = node_to_latency_mapping[node]
# Calculate the current overall latency of the partition
overall_latency_sec = partition_latency.overall_latency_sec + max(
node_latency.computer_latency_sec, node_latency.mem_latency_sec
)
# Update the mem latency of this path
mem_latency_sec = (
partition_latency.mem_latency_sec + node_latency.mem_latency_sec
)
# Update the compute latency of this path
computer_latency_sec = (
partition_latency.computer_latency_sec + node_latency.computer_latency_sec
)
# Get all users of this node that are in this partition
users = set(node.users).intersection(partition.nodes)
if users:
max_latency = PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
)
for n in users:
# Get new partition latency recursively
new_partition_latency = dfs_helper(
n,
PartitionLatency(
mem_latency_sec, computer_latency_sec, overall_latency_sec
),
)
if (
new_partition_latency.overall_latency_sec
> max_latency.overall_latency_sec
):
max_latency = new_partition_latency
return max_latency
# If there is no user, the node is at bottom of the partition
return PartitionLatency(
mem_latency_sec, computer_latency_sec, overall_latency_sec
)
# Main part starts
# Get all top level nodes of this partition
top_nodes = get_top_nodes(partition)
critical_path_latency = PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
)
# Go through all top nodes and find the largest latency (critical pass latency)
for node in top_nodes:
partition_latency = dfs_helper(
node,
PartitionLatency(
mem_latency_sec=0.0, computer_latency_sec=0.0, overall_latency_sec=0.0
),
)
if (
partition_latency.overall_latency_sec
> critical_path_latency.overall_latency_sec
):
critical_path_latency = partition_latency
return critical_path_latency
def get_partition_to_latency_mapping(
partitions: list[Partition], node_to_latency_mapping: dict[Node, NodeLatency]
) -> dict[Partition, PartitionLatency]:
"""Given all the partitions and node_to_latency_mapping dictionary,
return a mapping dictionary of each partition to its overall latency
"""
partition_to_latency_mapping: dict[Partition, PartitionLatency] = {}
# Go through each partition and get its latency
for partition in partitions:
partition_latency = get_latency_of_one_partition(
partition, node_to_latency_mapping
)
partition_to_latency_mapping[partition] = partition_latency
return partition_to_latency_mapping
def get_comm_latency_between(
parent_partition: Partition,
child_partition: Partition,
transfer_rate_bytes_per_sec: float,
):
"""Given two partitions (parent and child),
calculate the communication latency between the two.
"""
# If two partitions are on the same device, the comm latency is 0.
if (
parent_partition.logical_device_ids != []
and child_partition.logical_device_ids != []
and parent_partition.logical_device_ids == child_partition.logical_device_ids
):
return 0.0
# Keep tracking the communication size between parent and child
comm_size = 0
# Keep tracking all the counted node
visited_nodes = set()
# Go through all nodes in the child partition
# If a node has input nodes from the parent partition,
# the output size of those input nodes will be counted
# and added to comm_size
for node in child_partition.nodes:
input_nodes: dict[Node, None] = {}
map_arg(node.args, input_nodes.setdefault)
map_arg(node.kwargs, input_nodes.setdefault)
for n in input_nodes:
if n in parent_partition.nodes and n not in visited_nodes:
size_bytes = getattr(n, "size_bytes", None)
if size_bytes is not None:
comm_size += size_bytes.output_size
visited_nodes.add(n)
return comm_size / transfer_rate_bytes_per_sec
def get_latency_of_partitioned_graph(
partitions: list[Partition],
partition_to_latency_mapping: dict[Partition, PartitionLatency],
transfer_rate_bytes_per_sec: float,
):
"""Given all partitions in a graph, find the critical path among all partitions
and return its latency as the latency of the whole graph
"""
def dfs_helper(partition: Partition, latency_so_far_sec: float) -> float:
"""This function helps to recursively get the latency of a path of partitions"""
# Update latency by adding current partition's latency
latency_so_far_sec += partition_to_latency_mapping[
partition
].overall_latency_sec
if partition.children:
max_latency_sec = 0.0
for child in partition.children:
# Calculate latency between
comm_latency_sec = get_comm_latency_between(
partition, child, transfer_rate_bytes_per_sec
)
new_latency_sec = dfs_helper(
child, latency_so_far_sec + comm_latency_sec
)
if new_latency_sec > max_latency_sec:
max_latency_sec = new_latency_sec
return max_latency_sec
return latency_so_far_sec
def get_top_partitions(partitions: list[Partition]) -> list[Partition]:
"""This function is to return all the partitions without parents
as the starting points of all the paths
"""
# If a partition has no parents, then it is a top partition
top_partitions = [
partition for partition in partitions if len(partition.parents) == 0
]
return top_partitions
top_partitions = get_top_partitions(partitions)
critical_path_latency_sec = 0.0
for partition in top_partitions:
latency_sec = dfs_helper(partition, 0.0)
if latency_sec > critical_path_latency_sec:
critical_path_latency_sec = latency_sec
return critical_path_latency_sec
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
from __future__ import annotations
import functools
import inspect
import itertools
import logging
from dataclasses import dataclass
from typing import Any, ParamSpec, TYPE_CHECKING, TypeVar
_P = ParamSpec("_P")
_R = TypeVar("_R")
import torch
import torch.utils._pytree as pytree
if TYPE_CHECKING:
from collections.abc import Callable
from torch.fx.experimental.symbolic_shapes import ShapeEnv, TrackedFake
log = logging.getLogger(__name__)
trace_shape_events_log = torch._logging.getArtifactLogger(
__name__, "trace_shape_events"
)
__all__ = [
"ShapeEnvEvent",
"record_shapeenv_event",
"replay_shape_env_events",
"FakeTensorMeta",
"shape_env_check_state_equal",
"NotEqualError",
]
# [Note: Recording ShapeEnv Events]
# =================================
#
# What is a ShapeEnv event?
# -------------------------
# We consider a ShapeEnv event every function call (ShapeEnv method or
# independent function) that modifies the state of the ShapeEnv instance.
# Such calls are recorded alongside their positional and keyword arguments,
# so that it may be replayed over a different ShapeEnv instance.
#
# See [Note: ShapeEnv State Equality] for what is considered the state
# of a ShapeEnv instance.
#
# What is it for?
# ---------------
# ShapeEnv events recording is used for reconstructing the ShapeEnv in an
# arbitrary state in time.
#
# Being able to arbitrarily replay events like so is useful, mainly for
# translation validation bisection. i.e. if a ValidationException has been
# raised, find the earliest point in time where the translation validation
# fails.
#
# Besides that, it also allows us to inspect the given instance and,
# for example, check the guards that would actually be issued at that point.
#
# What kind of arguments can be stored in an event?
# -------------------------------------------------
# There's no specific rule for what cannot be used as an argument.
# That said, pay special attention to the following cases:
#
# 1. Tensor inputs: there are some tests that check whether the inputs
# were garbage collected after execution. These will fail if there's
# an event that is holding a reference to those inputs.
#
# 2. ShapeEnv arguments: if there is an argument of ShapeEnv type, that
# will be automatically replaced by the new given ShapeEnv instance.
#
# 3. SymTypes arguments: they also hold references to ShapeEnv. So,
# whenever we see them, we create a new instance, replacing the
# ShapeEnv reference.
#
# 4. FX nodes: specifically, FX nodes from the FX graph for symbolic
# shapes. That argument must be replaced when replaying the event at
# ShapeEnvEvent.run, since it has to reference a node from the given
# instance, and not from the recorded instance.
# Event class for reconstructing ShapeEnv at arbitrary time.
#
# Represents a method call that mutates ShapeEnv in a way that affects the
# issued guards, when ShapeEnv.produce_guards is called.
@dataclass
class ShapeEnvEvent:
# ShapeEnv method.
f: Callable[..., Any]
# Arguments and keyword arguments called with.
args: list[object] | None = None
kwargs: dict[str, Any] | None = None
# List of tracked_fakes at the time the method was called.
tracked_fakes: list[TrackedFake] | None = None
# Name of the captured event.
# Used for special handling of particular methods.
name: str | None = None
# Replay itself, but using shape_env as self.
def run(self, shape_env: ShapeEnv | None = None) -> Any:
from torch.fx.experimental.symbolic_shapes import (
is_symbolic,
ShapeEnv,
SymTypes,
)
# Special handling for the constructor event.
if self.f is ShapeEnv:
if not (
shape_env is None and self.args is None and self.kwargs is not None
):
raise AssertionError(
"ShapeEnv constructor requires shape_env=None, args=None, kwargs set"
)
return ShapeEnv(**self.kwargs)
if shape_env is None:
raise AssertionError("shape_env is required for non-constructor events")
args = list(self.args or [])
kwargs = dict(self.kwargs or {})
# Replace any argument of type ShapeEnv by the given one.
args, kwargs = pytree.tree_map_only(
ShapeEnv, lambda _: shape_env, (args, kwargs)
)
# Replace any argument of type SymTypes by a new instance,
# replacing its ShapeEnv reference.
args, kwargs = pytree.tree_map_only(
lambda x: isinstance(x, SymTypes) and is_symbolic(x),
lambda a: type(a)(a.node.with_shape_env(shape_env)),
(args, kwargs),
)
# Converts FX nodes using the mapping argument.
def maybe_convert_node(x: Any) -> Any:
if not isinstance(x, torch.fx.Node):
# Don't do anything to x if it's not an FX node.
return x
# If, at some point, we created an FX node, it means that translation validation is on.
# It also means we are building an FX graph for symbolic shapes at shape_env.graph, and
# we are tracking node names at shape_env.name_to_node.
if not hasattr(shape_env, "name_to_node"):
raise AssertionError("shape_env missing name_to_node attribute")
name_to_node = shape_env.name_to_node # type: ignore[attr-defined]
if x.name not in name_to_node:
raise AssertionError(f"Node {x.name} not found in name_to_node")
return name_to_node[x.name]
# Replaces the value of an specific argument by the result of fn.
def replacearg(index: int, key: str, fn: Callable[..., Any]) -> None:
if index < len(args):
args[index] = fn(args[index])
if key in kwargs:
kwargs[key] = fn(kwargs[key])
if self.is_create_fx_call_function():
# ShapeEnv.create_fx_call_function:
# "args" parameter is a tuple of FX nodes from the FX graph of the old ShapeEnv.
# They must be replaced, since a "call_function" FX node with this tuple as argument
# will be added to the FX graph of the new shape_env.
replacearg(
index=2,
key="args",
fn=lambda args: tuple(maybe_convert_node(a) for a in args),
)
if self.is_evaluate_expr() or self.is_defer_runtime_assert():
# ShapeEnv.evaluate_expr and ShapeEnv.guard_or_defer_runtime_assert:
# "fx_node" parameter is an (optional) FX node that represents the evaluate expression.
# They must be replaced, since it will be part of a "call_function" FX node for
# torch._assert, which will be added to the FX graph of the new shape_env.
replacearg(index=3, key="fx_node", fn=maybe_convert_node)
# Actually call the method with the converted arguments.
return self.f(*args, **kwargs)
def __str__(self) -> str:
name = self.name if self.name is not None else self.f.__name__
return f"event: {name} ({self.args}, {self.kwargs})"
def is_create_fx_call_function(self) -> bool:
return self.name == "_create_fx_call_function"
def is_evaluate_expr(self) -> bool:
return self.name == "evaluate_expr"
def is_defer_runtime_assert(self) -> bool:
return self.name == "guard_or_defer_runtime_assert"
NEST = 0
# Extracts a ShapeEnv instance inside args and kwargs.
# Specifically, it looks for:
# 1. ShapeEnv arguments
# 2. SymInt, SymFloat, or SymBool arguments
# If we find more than one object of any of the above types, we
# also check that the ShapeEnv instance is the same for all of them.
def _extract_shape_env_and_assert_equal(
args: tuple[object, ...] | list[object], kwargs: dict[str, object]
) -> ShapeEnv | None:
from torch.fx.experimental.symbolic_shapes import is_symbolic, ShapeEnv, SymTypes
def assert_equal(old: ShapeEnv | None, new: ShapeEnv) -> ShapeEnv:
if old is not None:
if old is not new:
raise AssertionError("call with different ShapeEnv")
return new
shape_env = None
for val in itertools.chain(args, kwargs.values()):
if isinstance(val, ShapeEnv):
shape_env = assert_equal(shape_env, val)
if isinstance(val, SymTypes) and is_symbolic(val):
shape_env = assert_equal(shape_env, val.node.shape_env)
return shape_env
# Decorator for recording the given function as a replayable event.
#
# This decorator should be used at every function that mutates the state of
# ShapeEnv in some way that affects the resulting issued guards (i.e. when
# ShapeEnv.produce_guards is called).
#
# save_tracked_fakes: saves a snapshot of the TrackedFake list.
# This is used when calling ShapeEnv.produce_guards at arbitrary points in time.
#
# name: the name of the function being recorded. Normally (and by default) this
# is taken from the decorated function but can be set if you need to override
# it.
#
# When to save the list of TrackedFake?
# =====================================
# We should save the list of TrackedFake whenever the translation validation
# bisection may actually stop and call the produce_guards method at the moment
# right after the recorded function was played. In other words, since the
# bisection bisects through torch._assert calls, we should save in all methods
# that adds a torch._assert call to the symbolic shapes FX graph.
#
# At the moment, there are 2 methods that save the list:
# - ShapeEnv.evaluate_expr
# - ShapeEnv.guard_or_defer_runtime_assert
def record_shapeenv_event(
*, save_tracked_fakes: bool = False, name: str | None = None
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
def decorator(fn: Callable[_P, _R]) -> Callable[_P, _R]:
if not callable(fn):
raise AssertionError(f"Expected callable, got {type(fn)}")
args = inspect.getfullargspec(fn).args
if not (args and args[0] == "self"):
raise AssertionError(
"record_shapeenv_event should only wrap methods on ShapeEnv; refactor your "
"code so that it calls into a method on ShapeEnv"
)
nonlocal name
if name is None:
name = fn.__name__
@functools.wraps(fn)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
if not isinstance(args[0], ShapeEnv):
raise AssertionError(f"Expected ShapeEnv, got {type(args[0])}")
global NEST
trace_shape_events_log.debug(
"%scall %s(*%r, **%r)", " " * NEST, name, args[1:], kwargs
)
NEST += 1
def retlog(r: _R) -> _R:
trace_shape_events_log.debug("%s-> %s", " " * (NEST - 1), r)
return r
shape_env = args[0]
try:
if not shape_env.should_record_events or shape_env.is_recording: # type: ignore[has-type]
# If ShapeEnv is already recording an event, call the wrapped
# function directly.
#
# NB: here, we skip the check of whether all ShapeEnv instances
# are equal, in favor of a faster dispatch.
return retlog(fn(*args, **kwargs))
# Retrieve an instance of ShapeEnv.
# Assumption: the collection of args and kwargs may not reference
# different ShapeEnv instances.
self = _extract_shape_env_and_assert_equal(args, kwargs)
# If we are calling this function without any ShapeEnv instance
# alive in its arguments, we don't record and call the original.
if self is None:
return retlog(fn(*args, **kwargs))
# Otherwise, start recording and call the function.
with self._recording():
# Take a snapshot of the current tracked_fakes.
tracked_fakes = (
self._snapshot_tracked_fakes() if save_tracked_fakes else None
)
# Record the event for 'fn'.
event = ShapeEnvEvent(
fn,
list(args),
kwargs,
tracked_fakes,
name=name,
)
# Play the event on this ShapeEnv.
# NB: It's important to put the event first, because running
# the event can trigger internal events that must be ordered
# after this event. However, if an exception happens, we do
# NOT want to have the event in the list, so pop it off from
# the record if an error happened
self.events.append(event)
try:
return retlog(event.run(self))
except Exception:
self.events.pop()
raise
except Exception:
if not shape_env.should_record_events or shape_env.is_recording:
# If ShapeEnv is disabled or already recording an event, re-raise the exception without logging.
raise
log.error( # noqa: G201
"failed while running %s(*%s, **%s)",
name,
args[1:],
kwargs,
exc_info=log.isEnabledFor(logging.INFO),
)
raise
finally:
NEST -= 1
return wrapper
return decorator
# Replays the ShapeEnvEvents list.
# It assumes the first event is the constructor call.
#
# fn: transforms an old FX node into one corresponding to the newly created ShapeEnv.
def replay_shape_env_events(events: list[ShapeEnvEvent]) -> ShapeEnv:
from torch.fx.experimental.symbolic_shapes import ShapeEnv
constructor_event = events[0]
if constructor_event.f != ShapeEnv:
raise AssertionError(
f"First event must be ShapeEnv constructor, got {constructor_event.f}"
)
# Constructs the new ShapeEnv.
shape_env = constructor_event.run()
for event in events[1:]:
try:
# Actually replays each event.
# We need to call create_mapping_fn every time, since the node list might
# change after each event is replayed.
event.run(shape_env)
except Exception:
log.error("failed when running event: %s", event)
raise
return shape_env
# FakeTensor metadata.
# This is to be used in place of FakeTensor placeholders when calling
# ShapeEnv.produce_guards.
@dataclass
class FakeTensorMeta:
tensor_size: tuple[int | torch.SymInt, ...]
tensor_stride: tuple[int | torch.SymInt, ...]
tensor_storage_offset: int | torch.SymInt
is_nested: bool
def size(self) -> tuple[int | torch.SymInt, ...]:
return self.tensor_size
def stride(self) -> tuple[int | torch.SymInt, ...]:
return self.tensor_stride
def storage_offset(self) -> int | torch.SymInt:
return self.tensor_storage_offset
def dim(self) -> int:
return len(self.tensor_size)
@staticmethod
def from_fake(fake: torch.Tensor) -> FakeTensorMeta:
return FakeTensorMeta(
fake.size(), fake.stride(), fake.storage_offset(), fake.is_nested
)
# [Note: ShapeEnv State Equality]
# ===============================
#
# What is considered ShapeEnv state?
# ----------------------------------
# We consider to be the state of a ShapeEnv instance everything that
# is not in the inline tuple inside remove_nonstate_variables function.
# That is: the fields within ShapeEnv that modify the flow of execution
# of the program.
#
# So, for example: the replacements field might influence on how an
# expression is simplified. That, in turn, may result in a guard being
# statically known (i.e. not added).
#
# On the other hand, var_to_stack serves only changes what is printed
# in the screen, i.e. used only for debugging purposes. Therefore, we
# should not consider it when comparing states.
#
# What to do on NotEqualError?
# ----------------------------
# Here are a few possible causes for getting a NotEqualError raised:
#
# 1. New field that does not belong in the ShapeEnv state.
# For example: log field of type ShapeEnvLoggerAdapter. Different
# ShapeEnv instances will always have different ShapeEnvLoggerAdapter
# instances, i.e. equality comparison would fail.
# Solution: add it to the inlined tuple inside remove_nonstate_variables
# function inside check_equal method.
#
# 2. New field that is not directly comparable across instances.
# For example: guards field of type List[ShapeGuard]. More specifically,
# the ShapeGuard type holds an expression and a stack information
# for debugging purposes. When replaying the even on a new ShapeEnv
# instance, the stack would be different, which would trigger this error.
# Solution: add a special case to the map_value function inside
# check_equal function.
#
# 3. Mutation of ShapeEnv on some not recorded function.
# If a mutation of the state of ShapeEnv happens inside a function
# that is not recorded (or that no caller in the stack is recorded),
# then, the replayed ShapeEnv won't catch that.
# Solution: decorate the function with record_shape_env_event.
# Checks whether the state of two ShapeEnv are equal w.r.t. the guards
# returned by ShapeEnv.produce_guards.
def shape_env_check_state_equal(
env1: ShapeEnv,
env2: ShapeEnv,
non_state_variable_names: tuple[str, ...],
map_value: Callable[[str, object], object],
) -> None:
# Collect and remove variables that don't necessarily represent the state
# of a ShapeEnv. Note: we copy the dictionary so that we don't modify the
# instance itself.
env1_vars = vars(env1).copy()
env2_vars = vars(env2).copy()
for v in non_state_variable_names:
if v in env1_vars:
env1_vars.pop(v)
if v in env2_vars:
env2_vars.pop(v)
# Function for transforming the mismatched values into string.
# Needed, since dict and set entries order might not be the same every time.
def value_to_str(value: Any) -> str:
if isinstance(value, dict):
return (
"{"
+ ", ".join(f"{k}: {value[k]}" for k in sorted(value.keys(), key=str))
+ "}"
)
if isinstance(value, set):
return "{" + ", ".join(f"{v}" for v in sorted(value)) + "}"
return str(value)
# Compares env1_vars with env2_vars.
# Here, we allow the value of each field to be mapped, so that we appropriately
# compare the two values.
def compare_vars(
map_value: Callable[[str, object], object],
) -> list[tuple[str, str, str]]:
env1_set, env2_set = set(env1_vars), set(env2_vars)
# First, compare the set of keys in each vars dictionary.
if env1_set != env2_set:
raise NotEqualError(
"field set mismatch:",
[
(
"found unique fields:",
str(sorted(env1_set - env2_set)),
str(sorted(env2_set - env1_set)),
),
],
)
# Then, sort the keys, and compare the mapped values of each key.
sorted_keys = list(env1_set)
sorted_keys.sort()
mapped_dict = [
(k, map_value(k, env1_vars[k]), map_value(k, env2_vars[k]))
for k in sorted_keys
]
# Return a list of tuples representing the fields that did not match
# alongside their respective mapped values.
return [
(f"{k}: values don't match.", value_to_str(val1), value_to_str(val2))
for k, val1, val2 in mapped_dict
if val1 != val2
]
# Accumulate the mismatching fields.
errors = compare_vars(map_value)
if len(errors) > 0:
raise NotEqualError("field values don't match:", errors)
class NotEqualError(Exception):
def __init__(
self,
msg: str,
mismatched: list[tuple[str, str, str]],
) -> None:
details = "\n".join(
[
"\n".join(
[
f"==> {inner_msg}",
f" > Left: {str1}",
f" > Right: {str2}",
]
)
for inner_msg, str1, str2 in mismatched
]
)
super().__init__(
f"""\
ShapeEnv not equal: {msg}
{details}
"""
)
@@ -0,0 +1,16 @@
class Equality:
def __init__(self, lhs: object, rhs: object):
self.lhs = lhs
self.rhs = rhs
def __str__(self) -> str:
return f"{self.lhs} = {self.rhs}"
def __repr__(self) -> str:
return f"{self.lhs} = {self.rhs}"
def __eq__(self, other: object) -> bool:
if isinstance(other, Equality):
return self.lhs == other.lhs and self.rhs == other.rhs
else:
return False
@@ -0,0 +1,147 @@
# mypy: allow-untyped-decorators
# mypy: allow-untyped-defs
import ast
import copy
import functools
import inspect
import textwrap
from collections.abc import Callable
from types import FunctionType
from typing import Any, cast
import torch
from torch._sources import normalize_source_lines
from torch.fx._symbolic_trace import Tracer
from torch.fx.graph import Graph
class AST_Rewriter(ast.NodeTransformer):
"""
Take a FunctionType object representing a `forward` method, then
perform an AST rewrite to swap out nodes that are not symbolically
traceable with a callsite to the FX alternative.
To support swapping out an AST node, define a new `visit` method on
that node. For more details, see:
https://docs.python.org/3/library/ast.html#ast.NodeTransformer
"""
# This function checks for new keys added in the globals dict. TorchDynamo
# can insert new keys in the global dict and upset the check. Therefore, put
# a disable here. This function is an optimization pass and not really
# suitable for dynamo tracing anyways.
@torch._dynamo.disable
def rewrite(self, fn: FunctionType):
# Normalize the source lines
sourcelines, _ = inspect.getsourcelines(fn)
sourcelines = normalize_source_lines(sourcelines)
source = "".join(sourcelines)
normalized_str = textwrap.dedent(source)
# Rewrite the original AST
source_ast = ast.parse(normalized_str)
dest_ast = ast.fix_missing_locations(self.visit(source_ast))
# Pull out the compiled function from the newly-created Module
code = compile(dest_ast, "", "exec")
globals_dict = copy.copy(fn.__globals__)
keys_before = set(globals_dict.keys())
exec(code, globals_dict)
new_keys = list(set(globals_dict.keys()) - keys_before)
if len(new_keys) != 1:
raise AssertionError(f"Expected 1 new key, got {len(new_keys)}")
fn_compiled = globals_dict[new_keys[0]]
# return the compiled function with the original globals
def change_func_globals(f, globals):
"""Based on https://stackoverflow.com/a/13503277/2988730 (@unutbu)"""
# __globals__ is a private member of the function class
# so we have to copy the function, f, all of its member, except f.__globals__
g = FunctionType(
f.__code__,
globals,
name=f.__name__,
argdefs=f.__defaults__,
closure=f.__closure__,
)
g = functools.update_wrapper(g, f)
g.__kwdefaults__ = copy.copy(f.__kwdefaults__) # type:ignore[attr-defined]
return g
# Return the correct FunctionType object
return change_func_globals(fn_compiled, globals=fn.__globals__)
def visit_Assert(self, node):
"""
Swap out the Assert node (Python's `assert`) with a callsite to the
symbolically-traceable torch._assert function
"""
# Create the Call node
n = ast.parse("torch._assert()", mode="eval")
if not isinstance(n, ast.Expression):
raise AssertionError(f"Expected ast.Expression, got {type(n)}")
call_node = n.body
if not isinstance(call_node, ast.Call):
raise AssertionError(f"Expected ast.Call, got {type(call_node)}")
msg = node.msg if node.msg else ast.Constant(value="", kind=None)
call_node.args = [node.test, msg]
# Ensure that the new node conforms to the Python AST grammar
expr_wrapper = ast.Expr(value=call_node)
# Return the new Call node to signify that we want to use it as
# a replacement for the original _assert node
return ast.copy_location(expr_wrapper, node)
def visit_AnnAssign(self, node):
"""
Swap out Python's AnnAssign with an Assign node where the annotation function is called.
Example:
Original:
y: Tensor_Type(1,2,3, Dyn) = f2(x)
Output:
y = annotate(f2(x),Tensor_Type((1,2,3,Dyn)))
"""
return ast.Assign(
targets=[node.target],
value=ast.Call(
func=ast.Name(id="annotate", ctx=ast.Load()),
args=[node.value, node.annotation],
keywords=[],
),
)
class RewritingTracer(Tracer):
def trace(
self,
root: torch.nn.Module | Callable,
concrete_args: dict[str, Any] | None = None,
) -> Graph:
return super().trace(_rewrite(root), concrete_args)
def _rewrite(fn: torch.nn.Module | Callable) -> torch.nn.Module | Callable:
if isinstance(fn, torch.nn.Module):
# Rewrite this module's `forward` as well as the `forward`s of
# all of this module's recursive descendents. Return the new,
# rewritten module hierarchy.
def rewrite_module(m: torch.nn.Module):
class RewrittenModule(torch.nn.Module):
def __init__(self, orig):
super().__init__()
for k, v in orig.__dict__.items():
if isinstance(v, torch.nn.Module):
self.__dict__[k] = copy.copy(rewrite_module(v))
else:
self.__dict__[k] = copy.copy(v)
RewrittenModule.forward = AST_Rewriter().rewrite(
cast(FunctionType, m.forward)
)
return RewrittenModule(m)
return rewrite_module(fn)
else:
# Rewrite this single free function
return AST_Rewriter().rewrite(cast(FunctionType, fn))
@@ -0,0 +1,155 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import inspect
from typing import Any, TYPE_CHECKING
import torch
import torch.fx
from torch._jit_internal import boolean_dispatched
from torch.fx import Transformer
from torch.fx.operator_schemas import _torchscript_type_to_python_type
if TYPE_CHECKING:
from torch.fx.graph_module import GraphModule
from torch.fx.node import Argument, Target
class AnnotateTypesWithSchema(Transformer):
"""
Use Python function signatures to annotate types for `Nodes` within an FX graph.
This pulls out Python function signatures for:
1. Standard `torch.nn` Module calls
2. `torch.nn.functional` calls
3. Attribute fetches via `get_attr`
Example usage:
m = torchvision.models.resnet18()
traced = torch.fx.symbolic_trace(m)
traced = AnnotateTypesWithSchema(traced).transform()
"""
def __init__(
self,
module: GraphModule,
annotate_functionals: bool = True,
annotate_modules: bool = True,
annotate_get_attrs: bool = True,
):
super().__init__(module)
self.annotate_functionals = annotate_functionals
self.annotate_modules = annotate_modules
self.annotate_get_attrs = annotate_get_attrs
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
python_ret_type = None
if self.annotate_functionals and target.__module__ == "torch.nn.functional":
target_for_analysis = target
if target in boolean_dispatched:
# HACK: `boolean_dispatch` as used in `torch.nn.functional` makes it so that we have
# a 2-way dispatch based on a boolean value. Here we check that the `true` and `false`
# branches of the dispatch have exactly the same signature. If they do, use the `true`
# branch signature for analysis. Otherwise, leave this un-normalized
if isinstance(target, str):
raise AssertionError("target should not be a string here")
dispatched = boolean_dispatched[target]
if_true, if_false = dispatched["if_true"], dispatched["if_false"]
# TODO: can we emit the union of these? What are the implications on TorchScript
# compilation?
if (
inspect.signature(if_true).return_annotation
!= inspect.signature(if_false).return_annotation
):
return super().call_function(target, args, kwargs)
target_for_analysis = if_true
python_ret_type = self._extract_python_return_type(target_for_analysis)
return_proxy = super().call_function(target, args, kwargs)
return_proxy.node.type = (
return_proxy.node.type if return_proxy.node.type else python_ret_type
)
return return_proxy
def call_module(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
):
python_ret_type = None
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
submod = self.fetch_attr(target)
if self.annotate_modules and hasattr(submod.__class__, "__name__"):
classname = submod.__class__.__name__
if getattr(torch.nn, classname, None) == submod.__class__:
python_ret_type = self._extract_python_return_type(submod.forward)
return_proxy = super().call_module(target, args, kwargs)
return_proxy.node.type = (
return_proxy.node.type if return_proxy.node.type else python_ret_type
)
return return_proxy
def get_attr(
self,
target: torch.fx.node.Target,
args: tuple[Argument, ...],
kwargs: dict[str, Any],
):
attr_proxy = super().get_attr(target, args, kwargs)
if self.annotate_get_attrs:
module_itr = self.module
if not isinstance(target, str):
raise AssertionError(f"Expected str target, got {type(target)}")
atoms = target.split(".")
for i, atom in enumerate(atoms):
if not hasattr(module_itr, atom):
raise RuntimeError(
f"Node referenced nonextent target {'.'.join(atoms[:i])}!"
)
module_itr = getattr(module_itr, atom)
maybe_inferred_ts_type = torch._C._jit_try_infer_type(module_itr)
if maybe_inferred_ts_type.success():
python_type = _torchscript_type_to_python_type(
maybe_inferred_ts_type.type()
)
attr_proxy.node.type = (
python_type if not attr_proxy.node.type else attr_proxy.node.type
)
return attr_proxy
def _extract_python_return_type(self, target: Target) -> Any | None:
"""
Given a Python call target, try to extract the Python return annotation
if it is available, otherwise return None
Args:
target (Callable): Python callable to get return annotation for
Returns:
Optional[Any]: Return annotation from the `target`, or None if it was
not available.
"""
if not callable(target):
raise AssertionError(f"Expected callable target, got {type(target)}")
try:
sig = inspect.signature(target)
except (ValueError, TypeError):
return None
return (
sig.return_annotation
if sig.return_annotation is not inspect.Signature.empty
else None
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
# mypy: disable-error-code=attr-defined
from .core import reify, unify # noqa: F403
from .more import unifiable # noqa: F403
from .variable import isvar, Var, var, variables, vars # noqa: F403
@@ -0,0 +1,141 @@
# mypy: allow-untyped-defs
from collections.abc import Iterator # type: ignore[import]
from functools import partial
from .dispatch import dispatch
from .unification_tools import assoc # type: ignore[import]
from .utils import transitive_get as walk
from .variable import isvar
__all__ = ["reify", "unify"]
###############
# Reification #
###############
@dispatch(Iterator, dict)
def _reify(t, s):
return map(partial(reify, s=s), t)
# return (reify(arg, s) for arg in t)
_reify
@dispatch(tuple, dict) # type: ignore[no-redef]
def _reify(t, s):
return tuple(reify(iter(t), s))
_reify
@dispatch(list, dict) # type: ignore[no-redef]
def _reify(t, s):
return list(reify(iter(t), s))
_reify
@dispatch(dict, dict) # type: ignore[no-redef]
def _reify(d, s):
return {k: reify(v, s) for k, v in d.items()}
_reify
@dispatch(object, dict) # type: ignore[no-redef]
def _reify(o, s):
return o # catch all, just return the object
def reify(e, s):
"""Replace variables of expression with substitution
>>> # xdoctest: +SKIP
>>> x, y = var(), var()
>>> e = (1, x, (3, y))
>>> s = {x: 2, y: 4}
>>> reify(e, s)
(1, 2, (3, 4))
>>> e = {1: x, 3: (y, 5)}
>>> reify(e, s)
{1: 2, 3: (4, 5)}
"""
if isvar(e):
return reify(s[e], s) if e in s else e
return _reify(e, s)
###############
# Unification #
###############
seq = tuple, list, Iterator
@dispatch(seq, seq, dict) # type: ignore[arg-type]
def _unify(u, v, s):
if len(u) != len(v):
return False
for uu, vv in zip(u, v): # avoiding recursion
s = unify(uu, vv, s)
if s is False:
return False
return s
#
# @dispatch((set, frozenset), (set, frozenset), dict)
# def _unify(u, v, s):
# i = u & v
# u = u - i
# v = v - i
# return _unify(sorted(u), sorted(v), s)
#
#
# @dispatch(dict, dict, dict)
# def _unify(u, v, s):
# if len(u) != len(v):
# return False
# for key, uval in iteritems(u):
# if key not in v:
# return False
# s = unify(uval, v[key], s)
# if s is False:
# return False
# return s
#
#
# @dispatch(object, object, dict)
# def _unify(u, v, s):
# return False # catch all
@dispatch(object, object, dict)
def unify(u, v, s): # no check at the moment
"""Find substitution so that u == v while satisfying s
>>> x = var("x")
>>> unify((1, x), (1, 2), {})
{~x: 2}
"""
u = walk(u, s)
v = walk(v, s)
if u == v:
return s
if isvar(u):
return assoc(s, u, v)
if isvar(v):
return assoc(s, v, u)
return _unify(u, v, s)
unify
@dispatch(object, object) # type: ignore[no-redef]
def unify(u, v):
return unify(u, v, {})
@@ -0,0 +1,8 @@
from functools import partial
from .multipledispatch import dispatch as _dispatch # type: ignore[import]
namespace = {} # type: ignore[var-annotated]
dispatch = partial(_dispatch, namespace=namespace)
@@ -0,0 +1,129 @@
# mypy: allow-untyped-defs
from .core import reify, unify # type: ignore[attr-defined]
from .unification_tools import first, groupby # type: ignore[import]
from .utils import _toposort, freeze
from .variable import isvar
class Dispatcher:
def __init__(self, name):
self.name = name
self.funcs = {}
self.ordering = []
def add(self, signature, func):
self.funcs[freeze(signature)] = func
self.ordering = ordering(self.funcs)
def __call__(self, *args, **kwargs):
func, _ = self.resolve(args)
return func(*args, **kwargs)
def resolve(self, args):
n = len(args)
for signature in self.ordering:
if len(signature) != n:
continue
s = unify(freeze(args), signature)
if s is not False:
result = self.funcs[signature]
return result, s
raise NotImplementedError(
"No match found. \nKnown matches: "
+ str(self.ordering)
+ "\nInput: "
+ str(args)
)
def register(self, *signature):
def _(func):
self.add(signature, func)
return self
return _
class VarDispatcher(Dispatcher):
"""A dispatcher that calls functions with variable names
>>> # xdoctest: +SKIP
>>> d = VarDispatcher("d")
>>> x = var("x")
>>> @d.register("inc", x)
... def f(x):
... return x + 1
>>> @d.register("double", x)
... def f(x):
... return x * 2
>>> d("inc", 10)
11
>>> d("double", 10)
20
"""
def __call__(self, *args, **kwargs):
func, s = self.resolve(args)
d = {k.token: v for k, v in s.items()}
return func(**d)
global_namespace = {} # type: ignore[var-annotated]
def match(*signature, **kwargs):
namespace = kwargs.get("namespace", global_namespace)
dispatcher = kwargs.get("Dispatcher", Dispatcher)
def _(func):
name = func.__name__
if name not in namespace:
namespace[name] = dispatcher(name)
d = namespace[name]
d.add(signature, func)
return d
return _
def supercedes(a, b):
"""``a`` is a more specific match than ``b``"""
if isvar(b) and not isvar(a):
return True
s = unify(a, b)
if s is False:
return False
s = {k: v for k, v in s.items() if not isvar(k) or not isvar(v)}
if reify(a, s) == a:
return True
if reify(b, s) == b:
return False
# Taken from multipledispatch
def edge(a, b, tie_breaker=hash):
"""A should be checked before B
Tie broken by tie_breaker, defaults to ``hash``
"""
if supercedes(a, b):
if supercedes(b, a):
return tie_breaker(a) > tie_breaker(b)
else:
return True
return False
# Taken from multipledispatch
def ordering(signatures):
"""A sane ordering of signatures to check, first to last
Topological sort of edges as given by ``edge`` and ``supercedes``
"""
signatures = list(map(tuple, signatures))
edges = [(a, b) for a in signatures for b in signatures if edge(a, b)]
edges = groupby(first, edges)
for s in signatures:
if s not in edges:
edges[s] = []
edges = {k: [b for a, b in v] for k, v in edges.items()} # type: ignore[attr-defined, assignment]
return _toposort(edges)
@@ -0,0 +1,131 @@
# mypy: allow-untyped-defs
from .core import ( # type: ignore[attr-defined]
_reify as core_reify,
_unify as core_unify,
reify,
unify,
)
from .dispatch import dispatch
__all__ = ["unifiable", "reify_object", "unify_object"]
def unifiable(cls):
"""Register standard unify and reify operations on class
This uses the type and __dict__ or __slots__ attributes to define the
nature of the term
See Also:
>>> # xdoctest: +SKIP
>>> class A(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
>>> unifiable(A)
<class 'unification.more.A'>
>>> x = var("x")
>>> a = A(1, 2)
>>> b = A(1, x)
>>> unify(a, b, {})
{~x: 2}
"""
core_unify.add((cls, cls, dict), unify_object) # type: ignore[attr-defined]
core_reify.add((cls, dict), reify_object) # type: ignore[attr-defined]
return cls
#########
# Reify #
#########
def reify_object(o, s):
"""Reify a Python object with a substitution
>>> # xdoctest: +SKIP
>>> class Foo(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
...
... def __str__(self):
... return "Foo(%s, %s)" % (str(self.a), str(self.b))
>>> x = var("x")
>>> f = Foo(1, x)
>>> print(f)
Foo(1, ~x)
>>> print(reify_object(f, {x: 2}))
Foo(1, 2)
"""
if hasattr(o, "__slots__"):
return _reify_object_slots(o, s)
else:
return _reify_object_dict(o, s)
def _reify_object_dict(o, s):
obj = object.__new__(type(o))
d = reify(o.__dict__, s)
if d == o.__dict__:
return o
obj.__dict__.update(d)
return obj
def _reify_object_slots(o, s):
attrs = [getattr(o, attr) for attr in o.__slots__]
new_attrs = reify(attrs, s)
if attrs == new_attrs:
return o
else:
newobj = object.__new__(type(o))
for slot, attr in zip(o.__slots__, new_attrs):
setattr(newobj, slot, attr)
return newobj
@dispatch(slice, dict)
def _reify(o, s):
"""Reify a Python ``slice`` object"""
return slice(*reify((o.start, o.stop, o.step), s))
#########
# Unify #
#########
def unify_object(u, v, s):
"""Unify two Python objects
Unifies their type and ``__dict__`` attributes
>>> # xdoctest: +SKIP
>>> class Foo(object):
... def __init__(self, a, b):
... self.a = a
... self.b = b
...
... def __str__(self):
... return "Foo(%s, %s)" % (str(self.a), str(self.b))
>>> x = var("x")
>>> f = Foo(1, x)
>>> g = Foo(1, 2)
>>> unify_object(f, g, {})
{~x: 2}
"""
if type(u) is not type(v):
return False
if hasattr(u, "__slots__"):
return unify(
[getattr(u, slot) for slot in u.__slots__],
[getattr(v, slot) for slot in v.__slots__],
s,
)
else:
return unify(u.__dict__, v.__dict__, s)
@dispatch(slice, slice, dict)
def _unify(u, v, s):
"""Unify a Python ``slice`` object"""
return unify((u.start, u.stop, u.step), (v.start, v.stop, v.step), s)
@@ -0,0 +1,7 @@
from .core import dispatch
from .dispatcher import (
Dispatcher,
halt_ordering,
MDNotImplementedError,
restart_ordering,
)
@@ -0,0 +1,146 @@
# mypy: allow-untyped-defs
import operator
from .utils import _toposort, groupby
from .variadic import isvariadic
__all__ = [
"AmbiguityWarning",
"supercedes",
"consistent",
"ambiguous",
"ambiguities",
"super_signature",
"edge",
"ordering",
]
class AmbiguityWarning(Warning):
pass
def supercedes(a, b):
"""A is consistent and strictly more specific than B"""
if len(a) < len(b):
# only case is if a is empty and b is variadic
return not a and len(b) == 1 and isvariadic(b[-1])
elif len(a) == len(b):
return all(map(issubclass, a, b))
else:
# len(a) > len(b)
p1 = 0
p2 = 0
while p1 < len(a) and p2 < len(b):
cur_a = a[p1]
cur_b = b[p2]
if not (isvariadic(cur_a) or isvariadic(cur_b)):
if not issubclass(cur_a, cur_b):
return False
p1 += 1
p2 += 1
elif isvariadic(cur_a):
if p1 != len(a) - 1:
raise AssertionError(
f"Expected p1={p1} to equal len(a)-1={len(a) - 1}"
)
return p2 == len(b) - 1 and issubclass(cur_a, cur_b)
elif isvariadic(cur_b):
if p2 != len(b) - 1:
raise AssertionError(
f"Expected p2={p2} to equal len(b)-1={len(b) - 1}"
)
if not issubclass(cur_a, cur_b):
return False
p1 += 1
return p2 == len(b) - 1 and p1 == len(a)
def consistent(a, b):
"""It is possible for an argument list to satisfy both A and B"""
# Need to check for empty args
if not a:
return not b or isvariadic(b[0])
if not b:
return not a or isvariadic(a[0])
# Non-empty args check for mutual subclasses
if len(a) == len(b):
return all(issubclass(aa, bb) or issubclass(bb, aa) for aa, bb in zip(a, b))
else:
p1 = 0
p2 = 0
while p1 < len(a) and p2 < len(b):
cur_a = a[p1]
cur_b = b[p2]
if not issubclass(cur_b, cur_a) and not issubclass(cur_a, cur_b):
return False
if not (isvariadic(cur_a) or isvariadic(cur_b)):
p1 += 1
p2 += 1
elif isvariadic(cur_a):
p2 += 1
elif isvariadic(cur_b):
p1 += 1
# We only need to check for variadic ends
# Variadic types are guaranteed to be the last element
return (
isvariadic(cur_a) # type: ignore[possibly-undefined]
and p2 == len(b)
or isvariadic(cur_b) # type: ignore[possibly-undefined]
and p1 == len(a)
)
def ambiguous(a, b):
"""A is consistent with B but neither is strictly more specific"""
return consistent(a, b) and not (supercedes(a, b) or supercedes(b, a))
def ambiguities(signatures):
"""All signature pairs such that A is ambiguous with B"""
signatures = list(map(tuple, signatures))
return {
(a, b)
for a in signatures
for b in signatures
if hash(a) < hash(b)
and ambiguous(a, b)
and not any(supercedes(c, a) and supercedes(c, b) for c in signatures)
}
def super_signature(signatures):
"""A signature that would break ambiguities"""
n = len(signatures[0])
if not all(len(s) == n for s in signatures):
raise AssertionError("All signatures must have the same length")
return [max((type.mro(sig[i]) for sig in signatures), key=len)[0] for i in range(n)]
def edge(a, b, tie_breaker=hash):
"""A should be checked before B
Tie broken by tie_breaker, defaults to ``hash``
"""
# A either supersedes B and B does not supersede A or if B does then call
# tie_breaker
return supercedes(a, b) and (
not supercedes(b, a) or tie_breaker(a) > tie_breaker(b)
)
def ordering(signatures):
"""A sane ordering of signatures to check, first to last
Topological sort of edges as given by ``edge`` and ``supercedes``
"""
signatures = list(map(tuple, signatures))
edges = [(a, b) for a in signatures for b in signatures if edge(a, b)]
edges = groupby(operator.itemgetter(0), edges)
for s in signatures:
if s not in edges:
edges[s] = []
edges = {k: [b for a, b in v] for k, v in edges.items()} # type: ignore[assignment, attr-defined]
return _toposort(edges)
@@ -0,0 +1,92 @@
# mypy: allow-untyped-defs
import inspect
from collections.abc import Callable
from typing import Any, TypeVar
from typing_extensions import TypeVarTuple, Unpack
from .dispatcher import Dispatcher, MethodDispatcher
global_namespace = {} # type: ignore[var-annotated]
__all__ = ["dispatch", "ismethod"]
T = TypeVar("T")
Ts = TypeVarTuple("Ts")
def dispatch(
*types: Unpack[Ts], **kwargs: Any
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""Dispatch function on the types of the inputs
Supports dispatch on all non-keyword arguments.
Collects implementations based on the function name. Ignores namespaces.
If ambiguous type signatures occur a warning is raised when the function is
defined suggesting the additional method to break the ambiguity.
Example:
>>> # xdoctest: +SKIP
>>> @dispatch(int)
... def f(x):
... return x + 1
>>> @dispatch(float)
... def f(x):
... return x - 1
>>> # xdoctest: +SKIP
>>> f(3)
4
>>> f(3.0)
2.0
>>> # Specify an isolated namespace with the namespace keyword argument
>>> my_namespace = {}
>>> @dispatch(int, namespace=my_namespace)
... def foo(x):
... return x + 1
>>> # Dispatch on instance methods within classes
>>> class MyClass(object):
... @dispatch(list)
... def __init__(self, data):
... self.data = data
...
... @dispatch(int)
... def __init__(self, datum):
... self.data = [datum]
>>> MyClass([1, 2, 3]).data
[1, 2, 3]
>>> MyClass(3).data
[3]
"""
namespace = kwargs.get("namespace", global_namespace)
types_tuple: tuple[type, ...] = tuple(types) # type: ignore[arg-type]
def _df(func):
name = func.__name__
if ismethod(func):
dispatcher = inspect.currentframe().f_back.f_locals.get( # type: ignore[union-attr]
name, # type: ignore[union-attr]
MethodDispatcher(name),
)
else:
if name not in namespace:
namespace[name] = Dispatcher(name)
dispatcher = namespace[name]
dispatcher.add(types_tuple, func)
return dispatcher
return _df
def ismethod(func):
"""Is func a method?
Note that this has to work as the method is defined but before the class is
defined. At this stage methods look like functions.
"""
if hasattr(inspect, "signature"):
signature = inspect.signature(func)
return signature.parameters.get("self", None) is not None
else:
spec = inspect.getfullargspec(func) # type: ignore[union-attr, assignment]
return spec and spec.args and spec.args[0] == "self"
@@ -0,0 +1,461 @@
# mypy: allow-untyped-defs
import inspect
import itertools as itl
from typing_extensions import deprecated
from warnings import warn
from .conflict import ambiguities, AmbiguityWarning, ordering, super_signature
from .utils import expand_tuples
from .variadic import isvariadic, Variadic
__all__ = [
"MDNotImplementedError",
"ambiguity_warn",
"halt_ordering",
"restart_ordering",
"variadic_signature_matches_iter",
"variadic_signature_matches",
"Dispatcher",
"source",
"MethodDispatcher",
"str_signature",
"warning_text",
]
class MDNotImplementedError(NotImplementedError):
"""A NotImplementedError for multiple dispatch"""
def ambiguity_warn(dispatcher, ambiguities):
"""Raise warning when ambiguity is detected.
Parameters
----------
dispatcher : Dispatcher
The dispatcher on which the ambiguity was detected
ambiguities : set
Set of type signature pairs that are ambiguous within this dispatcher
See Also
--------
Dispatcher.add
warning_text
"""
warn(warning_text(dispatcher.name, ambiguities), AmbiguityWarning)
@deprecated(
"`halt_ordering` is deprecated, you can safely remove this call.",
category=FutureWarning,
)
def halt_ordering():
"""Deprecated interface to temporarily disable ordering."""
@deprecated(
"`restart_ordering` is deprecated, if you would like to eagerly order the dispatchers, "
"you should call the `reorder()` method on each dispatcher.",
category=FutureWarning,
)
def restart_ordering(on_ambiguity=ambiguity_warn):
"""Deprecated interface to temporarily resume ordering."""
def variadic_signature_matches_iter(types, full_signature):
"""Check if a set of input types matches a variadic signature.
Notes
-----
The algorithm is as follows:
Initialize the current signature to the first in the sequence.
For each type in ``types``:
- If the current signature is variadic
- If the type matches the signature, yield True
- Else, try to get the next signature.
If no signatures are left we can't possibly have a match,
so yield False.
- Else, yield True if the type matches the current signature.
Get the next signature.
"""
sigiter = iter(full_signature)
sig = next(sigiter)
for typ in types:
matches = issubclass(typ, sig)
yield matches
if not isvariadic(sig):
# we're not matching a variadic argument, so move to the next
# element in the signature
sig = next(sigiter)
else:
try:
sig = next(sigiter)
except StopIteration:
if not isvariadic(sig):
raise AssertionError("Expected variadic signature") from None
yield True
else:
# We have signature items left over, so all of our arguments
# haven't matched
yield False
def variadic_signature_matches(types, full_signature):
# No arguments always matches a variadic signature
if not full_signature:
raise AssertionError("full_signature is empty")
return all(variadic_signature_matches_iter(types, full_signature))
class Dispatcher:
"""Dispatch methods based on type signature
Use ``dispatch`` to add implementations
Examples
--------
>>> # xdoctest: +SKIP("bad import name")
>>> from multipledispatch import dispatch
>>> @dispatch(int)
... def f(x):
... return x + 1
>>> @dispatch(float)
... def f(x):
... return x - 1
>>> f(3)
4
>>> f(3.0)
2.0
"""
__slots__ = "__name__", "name", "funcs", "_ordering", "_cache", "doc"
def __init__(self, name, doc=None):
self.name = self.__name__ = name
self.funcs = {}
self.doc = doc
self._cache = {}
def register(self, *types, **kwargs):
"""register dispatcher with new implementation
>>> # xdoctest: +SKIP
>>> f = Dispatcher("f")
>>> @f.register(int)
... def inc(x):
... return x + 1
>>> @f.register(float)
... def dec(x):
... return x - 1
>>> @f.register(list)
... @f.register(tuple)
... def reverse(x):
... return x[::-1]
>>> f(1)
2
>>> f(1.0)
0.0
>>> f([1, 2, 3])
[3, 2, 1]
"""
def _df(func):
self.add(types, func, **kwargs) # type: ignore[call-arg]
return func
return _df
@classmethod
def get_func_params(cls, func):
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
return sig.parameters.values()
@classmethod
def get_func_annotations(cls, func):
"""get annotations of function positional parameters"""
params = cls.get_func_params(func)
if params:
Parameter = inspect.Parameter
params = (
param
for param in params
if param.kind
in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
)
annotations = tuple(param.annotation for param in params)
if all(ann is not Parameter.empty for ann in annotations):
return annotations
def add(self, signature, func):
"""Add new types/method pair to dispatcher
>>> # xdoctest: +SKIP
>>> D = Dispatcher("add")
>>> D.add((int, int), lambda x, y: x + y)
>>> D.add((float, float), lambda x, y: x + y)
>>> D(1, 2)
3
>>> D(1, 2.0)
Traceback (most recent call last):
...
NotImplementedError: Could not find signature for add: <int, float>
>>> # When ``add`` detects a warning it calls the ``on_ambiguity`` callback
>>> # with a dispatcher/itself, and a set of ambiguous type signature pairs
>>> # as inputs. See ``ambiguity_warn`` for an example.
"""
# Handle annotations
if not signature:
annotations = self.get_func_annotations(func)
if annotations:
signature = annotations
# Handle union types
if any(isinstance(typ, tuple) for typ in signature):
for typs in expand_tuples(signature):
self.add(typs, func)
return
new_signature = []
for index, typ in enumerate(signature, start=1):
if not isinstance(typ, (type, list)):
str_sig = ", ".join(
c.__name__ if isinstance(c, type) else str(c) for c in signature
)
raise TypeError(
f"Tried to dispatch on non-type: {typ}\n"
f"In signature: <{str_sig}>\n"
f"In function: {self.name}"
)
# handle variadic signatures
if isinstance(typ, list):
if index != len(signature):
raise TypeError("Variadic signature must be the last element")
if len(typ) != 1:
raise TypeError(
"Variadic signature must contain exactly one element. "
"To use a variadic union type place the desired types "
"inside of a tuple, e.g., [(int, str)]"
)
# pyrefly: ignore [bad-specialization]
new_signature.append(Variadic[typ[0]])
else:
new_signature.append(typ)
self.funcs[tuple(new_signature)] = func
self._cache.clear()
try:
del self._ordering
except AttributeError:
pass
@property
def ordering(self):
try:
return self._ordering
except AttributeError:
return self.reorder()
def reorder(self, on_ambiguity=ambiguity_warn):
self._ordering = od = ordering(self.funcs)
amb = ambiguities(self.funcs)
if amb:
on_ambiguity(self, amb)
return od
def __call__(self, *args, **kwargs):
types = tuple(type(arg) for arg in args)
try:
func = self._cache[types]
except KeyError as e:
func = self.dispatch(*types)
if not func:
raise NotImplementedError(
f"Could not find signature for {self.name}: <{str_signature(types)}>"
) from e
self._cache[types] = func
try:
return func(*args, **kwargs)
except MDNotImplementedError as e:
funcs = self.dispatch_iter(*types)
next(funcs) # burn first
for func in funcs:
try:
return func(*args, **kwargs)
except MDNotImplementedError:
pass
raise NotImplementedError(
"Matching functions for "
f"{self.name}: <{str_signature(types)}> found, but none completed successfully",
) from e
def __str__(self):
return f"<dispatched {self.name}>"
__repr__ = __str__
def dispatch(self, *types):
"""Determine appropriate implementation for this type signature
This method is internal. Users should call this object as a function.
Implementation resolution occurs within the ``__call__`` method.
>>> # xdoctest: +SKIP
>>> from multipledispatch import dispatch
>>> @dispatch(int)
... def inc(x):
... return x + 1
>>> implementation = inc.dispatch(int)
>>> implementation(3)
4
>>> print(inc.dispatch(float))
None
See Also:
``multipledispatch.conflict`` - module to determine resolution order
"""
if types in self.funcs:
return self.funcs[types]
try:
return next(self.dispatch_iter(*types))
except StopIteration:
return None
def dispatch_iter(self, *types):
n = len(types)
for signature in self.ordering:
if len(signature) == n and all(map(issubclass, types, signature)):
result = self.funcs[signature]
yield result
elif len(signature) and isvariadic(signature[-1]):
if variadic_signature_matches(types, signature):
result = self.funcs[signature]
yield result
@deprecated(
"`resolve()` is deprecated, use `dispatch(*types)`", category=FutureWarning
)
def resolve(self, types):
"""Determine appropriate implementation for this type signature
.. deprecated:: 0.4.4
Use ``dispatch(*types)`` instead
"""
return self.dispatch(*types)
def __getstate__(self):
return {"name": self.name, "funcs": self.funcs}
def __setstate__(self, d):
self.name = d["name"]
self.funcs = d["funcs"]
self._ordering = ordering(self.funcs)
self._cache = {}
@property
def __doc__(self): # type: ignore[override]
docs = [f"Multiply dispatched method: {self.name}"]
if self.doc:
docs.append(self.doc)
other = []
for sig in self.ordering[::-1]:
func = self.funcs[sig]
if func.__doc__:
s = f"Inputs: <{str_signature(sig)}>\n"
s += "-" * len(s) + "\n"
s += func.__doc__.strip()
docs.append(s)
else:
other.append(str_signature(sig))
if other:
docs.append("Other signatures:\n " + "\n ".join(other))
return "\n\n".join(docs)
def _help(self, *args):
return self.dispatch(*map(type, args)).__doc__
def help(self, *args, **kwargs):
"""Print docstring for the function corresponding to inputs"""
print(self._help(*args))
def _source(self, *args):
func = self.dispatch(*map(type, args))
if not func:
raise TypeError("No function found")
return source(func)
def source(self, *args, **kwargs):
"""Print source code for the function corresponding to inputs"""
print(self._source(*args))
def source(func):
s = f"File: {inspect.getsourcefile(func)}\n\n"
s = s + inspect.getsource(func)
return s
class MethodDispatcher(Dispatcher):
"""Dispatch methods based on type signature
See Also:
Dispatcher
"""
__slots__ = ("obj", "cls")
@classmethod
def get_func_params(cls, func):
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
return itl.islice(sig.parameters.values(), 1, None)
def __get__(self, instance, owner):
self.obj = instance
self.cls = owner
return self
def __call__(self, *args, **kwargs):
types = tuple(type(arg) for arg in args)
func = self.dispatch(*types)
if not func:
raise NotImplementedError(
f"Could not find signature for {self.name}: <{str_signature(types)}>"
)
return func(self.obj, *args, **kwargs)
def str_signature(sig):
"""String representation of type signature
>>> str_signature((int, float))
'int, float'
"""
return ", ".join(cls.__name__ for cls in sig)
def warning_text(name, amb):
"""The text for ambiguity warnings"""
text = f"\nAmbiguities exist in dispatched function {name}\n\n"
text += "The following signatures may result in ambiguous behavior:\n"
for pair in amb:
text += "\t" + ", ".join("[" + str_signature(s) + "]" for s in pair) + "\n"
text += "\n\nConsider making the following additions:\n\n"
text += "\n\n".join(
[
"@dispatch(" + str_signature(super_signature(s)) + f")\ndef {name}(...)"
for s in amb
]
)
return text
@@ -0,0 +1,132 @@
# mypy: allow-untyped-defs
from collections import OrderedDict
__all__ = ["raises", "expand_tuples", "reverse_dict", "groupby", "typename"]
def raises(err, lamda): # codespell:ignore lamda
try:
lamda() # codespell:ignore lamda
return False
except err:
return True
def expand_tuples(L):
"""
>>> expand_tuples([1, (2, 3)])
[(1, 2), (1, 3)]
>>> expand_tuples([1, 2])
[(1, 2)]
"""
if not L:
return [()]
elif not isinstance(L[0], tuple):
rest = expand_tuples(L[1:])
return [(L[0],) + t for t in rest]
else:
rest = expand_tuples(L[1:])
return [(item,) + t for t in rest for item in L[0]]
# Taken from theano/theano/gof/sched.py
# Avoids licensing issues because this was written by Matthew Rocklin
def _toposort(edges):
"""Topological sort algorithm by Kahn [1] - O(nodes + vertices)
inputs:
edges - a dict of the form {a: {b, c}} where b and c depend on a
outputs:
L - an ordered list of nodes that satisfy the dependencies of edges
>>> _toposort({1: (2, 3), 2: (3,)})
[1, 2, 3]
>>> # Closely follows the wikipedia page [2]
>>> # [1] Kahn, Arthur B. (1962), "Topological sorting of large networks",
>>> # Communications of the ACM
>>> # [2] http://en.wikipedia.org/wiki/Toposort#Algorithms
"""
incoming_edges = reverse_dict(edges)
incoming_edges = OrderedDict((k, set(val)) for k, val in incoming_edges.items())
S = OrderedDict.fromkeys(v for v in edges if v not in incoming_edges)
L = []
while S:
n, _ = S.popitem()
L.append(n)
for m in edges.get(n, ()):
if n not in incoming_edges[m]:
raise AssertionError(f"Expected {n} in incoming_edges[{m}]")
incoming_edges[m].remove(n)
if not incoming_edges[m]:
S[m] = None
if any(incoming_edges.get(v, None) for v in edges):
raise ValueError("Input has cycles")
return L
def reverse_dict(d):
"""Reverses direction of dependence dict.
>>> d = {"a": (1, 2), "b": (2, 3), "c": ()}
>>> reverse_dict(d) # doctest: +SKIP
{1: ('a',), 2: ('a', 'b'), 3: ('b',)}
.. note::
dict order are not deterministic. As we iterate on the
input dict, it make the output of this function depend on the
dict order. So this function output order should be considered
as undeterministic.
"""
result = OrderedDict() # type: ignore[var-annotated]
for key in d:
for val in d[key]:
# pyrefly: ignore [unsupported-operation]
result[val] = result.get(val, ()) + (key,)
return result
# Taken from toolz
# Avoids licensing issues because this version was authored by Matthew Rocklin
def groupby(func, seq):
"""Group a collection by a key function
>>> names = ["Alice", "Bob", "Charlie", "Dan", "Edith", "Frank"]
>>> groupby(len, names) # doctest: +SKIP
{3: ['Bob', 'Dan'], 5: ['Alice', 'Edith', 'Frank'], 7: ['Charlie']}
>>> iseven = lambda x: x % 2 == 0
>>> groupby(iseven, [1, 2, 3, 4, 5, 6, 7, 8]) # doctest: +SKIP
{False: [1, 3, 5, 7], True: [2, 4, 6, 8]}
See Also:
``countby``
"""
d = OrderedDict() # type: ignore[var-annotated]
for item in seq:
key = func(item)
if key not in d:
d[key] = []
d[key].append(item)
return d
def typename(type):
"""Get the name of `type`.
Parameters
----------
type : Union[Type, Tuple[Type]]
Returns
-------
str
The name of `type` or a tuple of the names of the types in `type`.
Examples
--------
>>> typename(int)
'int'
>>> typename((int, float))
'(int, float)'
"""
try:
return type.__name__
except AttributeError:
if len(type) == 1:
return typename(*type)
return f"({', '.join(map(typename, type))})"
@@ -0,0 +1,96 @@
# mypy: allow-untyped-defs
from .utils import typename
__all__ = ["VariadicSignatureType", "isvariadic", "VariadicSignatureMeta", "Variadic"]
class VariadicSignatureType(type):
# checking if subclass is a subclass of self
def __subclasscheck__(cls, subclass):
other_type = subclass.variadic_type if isvariadic(subclass) else (subclass,)
return subclass is cls or all(
issubclass(other, cls.variadic_type) # type: ignore[attr-defined]
for other in other_type
)
def __eq__(cls, other):
"""
Return True if other has the same variadic type
Parameters
----------
other : object (type)
The object (type) to check
Returns
-------
bool
Whether or not `other` is equal to `self`
"""
return isvariadic(other) and set(cls.variadic_type) == set(other.variadic_type) # type: ignore[attr-defined]
def __hash__(cls):
return hash((type(cls), frozenset(cls.variadic_type))) # type: ignore[attr-defined]
def isvariadic(obj):
"""Check whether the type `obj` is variadic.
Parameters
----------
obj : type
The type to check
Returns
-------
bool
Whether or not `obj` is variadic
Examples
--------
>>> # xdoctest: +SKIP
>>> isvariadic(int)
False
>>> isvariadic(Variadic[int])
True
"""
return isinstance(obj, VariadicSignatureType)
class VariadicSignatureMeta(type):
"""A metaclass that overrides ``__getitem__`` on the class. This is used to
generate a new type for Variadic signatures. See the Variadic class for
examples of how this behaves.
"""
def __getitem__(cls, variadic_type):
if not (isinstance(variadic_type, (type, tuple)) or type(variadic_type)):
raise ValueError(
"Variadic types must be type or tuple of types"
" (Variadic[int] or Variadic[(int, float)]"
)
if not isinstance(variadic_type, tuple):
variadic_type = (variadic_type,)
return VariadicSignatureType(
f"Variadic[{typename(variadic_type)}]",
(),
dict(variadic_type=variadic_type, __slots__=()),
)
class Variadic(metaclass=VariadicSignatureMeta):
"""A class whose getitem method can be used to generate a new type
representing a specific variadic signature.
Examples
--------
>>> # xdoctest: +SKIP
>>> Variadic[int] # any number of int arguments
<class 'multipledispatch.variadic.Variadic[int]'>
>>> Variadic[(int, str)] # any number of one of int or str arguments
<class 'multipledispatch.variadic.Variadic[(int, str)]'>
>>> issubclass(int, Variadic[int])
True
>>> issubclass(int, Variadic[(int, str)])
True
>>> issubclass(str, Variadic[(int, str)])
True
>>> issubclass(float, Variadic[(int, str)])
False
"""
@@ -0,0 +1,419 @@
# mypy: allow-untyped-defs
import collections
import operator
from collections.abc import Mapping
from functools import reduce
__all__ = [
"merge",
"merge_with",
"valmap",
"keymap",
"itemmap",
"valfilter",
"keyfilter",
"itemfilter",
"assoc",
"dissoc",
"assoc_in",
"update_in",
"get_in",
]
def _get_factory(f, kwargs):
factory = kwargs.pop("factory", dict)
if kwargs:
raise TypeError(
f"{f.__name__}() got an unexpected keyword argument '{kwargs.popitem()[0]}'"
)
return factory
def merge(*dicts, **kwargs):
"""Merge a collection of dictionaries
>>> merge({1: "one"}, {2: "two"})
{1: 'one', 2: 'two'}
Later dictionaries have precedence
>>> merge({1: 2, 3: 4}, {3: 3, 4: 4})
{1: 2, 3: 3, 4: 4}
See Also:
merge_with
"""
if len(dicts) == 1 and not isinstance(dicts[0], Mapping):
dicts = dicts[0]
factory = _get_factory(merge, kwargs)
rv = factory()
for d in dicts:
rv.update(d)
return rv
def merge_with(func, *dicts, **kwargs):
"""Merge dictionaries and apply function to combined values
A key may occur in more than one dict, and all values mapped from the key
will be passed to the function as a list, such as func([val1, val2, ...]).
>>> merge_with(sum, {1: 1, 2: 2}, {1: 10, 2: 20})
{1: 11, 2: 22}
>>> merge_with(first, {1: 1, 2: 2}, {2: 20, 3: 30}) # doctest: +SKIP
{1: 1, 2: 2, 3: 30}
See Also:
merge
"""
if len(dicts) == 1 and not isinstance(dicts[0], Mapping):
dicts = dicts[0]
factory = _get_factory(merge_with, kwargs)
result = factory()
for d in dicts:
for k, v in d.items():
if k not in result:
result[k] = [v]
else:
result[k].append(v)
return valmap(func, result, factory)
def valmap(func, d, factory=dict):
"""Apply function to values of dictionary
>>> bills = {"Alice": [20, 15, 30], "Bob": [10, 35]}
>>> valmap(sum, bills) # doctest: +SKIP
{'Alice': 65, 'Bob': 45}
See Also:
keymap
itemmap
"""
rv = factory()
rv.update(zip(d.keys(), map(func, d.values())))
return rv
def keymap(func, d, factory=dict):
"""Apply function to keys of dictionary
>>> bills = {"Alice": [20, 15, 30], "Bob": [10, 35]}
>>> keymap(str.lower, bills) # doctest: +SKIP
{'alice': [20, 15, 30], 'bob': [10, 35]}
See Also:
valmap
itemmap
"""
rv = factory()
rv.update(zip(map(func, d.keys()), d.values()))
return rv
def itemmap(func, d, factory=dict):
"""Apply function to items of dictionary
>>> accountids = {"Alice": 10, "Bob": 20}
>>> itemmap(reversed, accountids) # doctest: +SKIP
{10: "Alice", 20: "Bob"}
See Also:
keymap
valmap
"""
rv = factory()
rv.update(map(func, d.items()))
return rv
def valfilter(predicate, d, factory=dict):
"""Filter items in dictionary by value
>>> iseven = lambda x: x % 2 == 0
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> valfilter(iseven, d)
{1: 2, 3: 4}
See Also:
keyfilter
itemfilter
valmap
"""
rv = factory()
for k, v in d.items():
if predicate(v):
rv[k] = v
return rv
def keyfilter(predicate, d, factory=dict):
"""Filter items in dictionary by key
>>> iseven = lambda x: x % 2 == 0
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> keyfilter(iseven, d)
{2: 3, 4: 5}
See Also:
valfilter
itemfilter
keymap
"""
rv = factory()
for k, v in d.items():
if predicate(k):
rv[k] = v
return rv
def itemfilter(predicate, d, factory=dict):
"""Filter items in dictionary by item
>>> def isvalid(item):
... k, v = item
... return k % 2 == 0 and v < 4
>>> d = {1: 2, 2: 3, 3: 4, 4: 5}
>>> itemfilter(isvalid, d)
{2: 3}
See Also:
keyfilter
valfilter
itemmap
"""
rv = factory()
for item in d.items():
if predicate(item):
k, v = item
rv[k] = v
return rv
def assoc(d, key, value, factory=dict):
"""Return a new dict with new key value pair
New dict has d[key] set to value. Does not modify the initial dictionary.
>>> assoc({"x": 1}, "x", 2)
{'x': 2}
>>> assoc({"x": 1}, "y", 3) # doctest: +SKIP
{'x': 1, 'y': 3}
"""
d2 = factory()
d2.update(d)
d2[key] = value
return d2
def dissoc(d, *keys, **kwargs):
"""Return a new dict with the given key(s) removed.
New dict has d[key] deleted for each supplied key.
Does not modify the initial dictionary.
>>> dissoc({"x": 1, "y": 2}, "y")
{'x': 1}
>>> dissoc({"x": 1, "y": 2}, "y", "x")
{}
>>> dissoc({"x": 1}, "y") # Ignores missing keys
{'x': 1}
"""
factory = _get_factory(dissoc, kwargs)
d2 = factory()
if len(keys) < len(d) * 0.6:
d2.update(d)
for key in keys:
if key in d2:
del d2[key]
else:
remaining = set(d)
remaining.difference_update(keys)
for k in remaining:
d2[k] = d[k]
return d2
def assoc_in(d, keys, value, factory=dict):
"""Return a new dict with new, potentially nested, key value pair
>>> purchase = {
... "name": "Alice",
... "order": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> assoc_in(purchase, ["order", "costs"], [0.25, 1.00]) # doctest: +SKIP
{'credit card': '5555-1234-1234-1234',
'name': 'Alice',
'order': {'costs': [0.25, 1.00], 'items': ['Apple', 'Orange']}}
"""
return update_in(d, keys, lambda x: value, value, factory)
def update_in(d, keys, func, default=None, factory=dict):
"""Update value in a (potentially) nested dictionary
inputs:
d - dictionary on which to operate
keys - list or tuple giving the location of the value to be changed in d
func - function to operate on that value
If keys == [k0,..,kX] and d[k0]..[kX] == v, update_in returns a copy of the
original dictionary with v replaced by func(v), but does not mutate the
original dictionary.
If k0 is not a key in d, update_in creates nested dictionaries to the depth
specified by the keys, with the innermost value set to func(default).
>>> inc = lambda x: x + 1
>>> update_in({"a": 0}, ["a"], inc)
{'a': 1}
>>> transaction = {
... "name": "Alice",
... "purchase": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> update_in(transaction, ["purchase", "costs"], sum) # doctest: +SKIP
{'credit card': '5555-1234-1234-1234',
'name': 'Alice',
'purchase': {'costs': 1.75, 'items': ['Apple', 'Orange']}}
>>> # updating a value when k0 is not in d
>>> update_in({}, [1, 2, 3], str, default="bar")
{1: {2: {3: 'bar'}}}
>>> update_in({1: "foo"}, [2, 3, 4], inc, 0)
{1: 'foo', 2: {3: {4: 1}}}
"""
ks = iter(keys)
k = next(ks)
rv = inner = factory()
rv.update(d)
for key in ks:
if k in d:
d = d[k]
dtemp = factory()
dtemp.update(d)
else:
d = dtemp = factory()
inner[k] = inner = dtemp
k = key
if k in d:
inner[k] = func(d[k])
else:
inner[k] = func(default)
return rv
def get_in(keys, coll, default=None, no_default=False):
"""Returns coll[i0][i1]...[iX] where [i0, i1, ..., iX]==keys.
If coll[i0][i1]...[iX] cannot be found, returns ``default``, unless
``no_default`` is specified, then it raises KeyError or IndexError.
``get_in`` is a generalization of ``operator.getitem`` for nested data
structures such as dictionaries and lists.
>>> transaction = {
... "name": "Alice",
... "purchase": {"items": ["Apple", "Orange"], "costs": [0.50, 1.25]},
... "credit card": "5555-1234-1234-1234",
... }
>>> get_in(["purchase", "items", 0], transaction)
'Apple'
>>> get_in(["name"], transaction)
'Alice'
>>> get_in(["purchase", "total"], transaction)
>>> get_in(["purchase", "items", "apple"], transaction)
>>> get_in(["purchase", "items", 10], transaction)
>>> get_in(["purchase", "total"], transaction, 0)
0
>>> get_in(["y"], {}, no_default=True)
Traceback (most recent call last):
...
KeyError: 'y'
See Also:
itertoolz.get
operator.getitem
"""
try:
return reduce(operator.getitem, keys, coll)
except (KeyError, IndexError, TypeError):
if no_default:
raise
return default
def getter(index):
if isinstance(index, list):
if len(index) == 1:
index = index[0]
return lambda x: (x[index],)
elif index:
return operator.itemgetter(*index)
else:
return lambda x: ()
else:
return operator.itemgetter(index)
def groupby(key, seq):
"""Group a collection by a key function
>>> names = ["Alice", "Bob", "Charlie", "Dan", "Edith", "Frank"]
>>> groupby(len, names) # doctest: +SKIP
{3: ['Bob', 'Dan'], 5: ['Alice', 'Edith', 'Frank'], 7: ['Charlie']}
>>> iseven = lambda x: x % 2 == 0
>>> groupby(iseven, [1, 2, 3, 4, 5, 6, 7, 8]) # doctest: +SKIP
{False: [1, 3, 5, 7], True: [2, 4, 6, 8]}
Non-callable keys imply grouping on a member.
>>> groupby(
... "gender",
... [
... {"name": "Alice", "gender": "F"},
... {"name": "Bob", "gender": "M"},
... {"name": "Charlie", "gender": "M"},
... ],
... ) # doctest:+SKIP
{'F': [{'gender': 'F', 'name': 'Alice'}],
'M': [{'gender': 'M', 'name': 'Bob'},
{'gender': 'M', 'name': 'Charlie'}]}
Not to be confused with ``itertools.groupby``
See Also:
countby
"""
if not callable(key):
key = getter(key)
d = collections.defaultdict(lambda: [].append) # type: ignore[var-annotated]
for item in seq:
d[key(item)](item)
rv = {}
for k, v in d.items():
rv[k] = v.__self__ # type: ignore[var-annotated, attr-defined]
return rv
def first(seq):
"""The first element in a sequence
>>> first("ABC")
'A'
"""
return next(iter(seq))
@@ -0,0 +1,113 @@
# mypy: allow-untyped-defs
__all__ = ["hashable", "transitive_get", "raises", "reverse_dict", "xfail", "freeze"]
def hashable(x):
try:
hash(x)
return True
except TypeError:
return False
def transitive_get(key, d):
"""Transitive dict.get
>>> d = {1: 2, 2: 3, 3: 4}
>>> d.get(1)
2
>>> transitive_get(1, d)
4
"""
while hashable(key) and key in d:
key = d[key]
return key
def raises(err, lamda): # codespell:ignore lamda
try:
lamda() # codespell:ignore lamda
return False
except err:
return True
# Taken from theano/theano/gof/sched.py
# Avoids licensing issues because this was written by Matthew Rocklin
def _toposort(edges):
"""Topological sort algorithm by Kahn [1] - O(nodes + vertices)
inputs:
edges - a dict of the form {a: {b, c}} where b and c depend on a
outputs:
L - an ordered list of nodes that satisfy the dependencies of edges
>>> # xdoctest: +SKIP
>>> _toposort({1: (2, 3), 2: (3,)})
[1, 2, 3]
Closely follows the wikipedia page [2]
[1] Kahn, Arthur B. (1962), "Topological sorting of large networks",
Communications of the ACM
[2] http://en.wikipedia.org/wiki/Toposort#Algorithms
"""
incoming_edges = reverse_dict(edges)
incoming_edges = {k: set(val) for k, val in incoming_edges.items()}
S = {v for v in edges if v not in incoming_edges}
L = []
while S:
n = S.pop()
L.append(n)
for m in edges.get(n, ()):
if n not in incoming_edges[m]:
raise AssertionError(f"Expected {n} in incoming_edges[{m}]")
incoming_edges[m].remove(n)
if not incoming_edges[m]:
S.add(m)
if any(incoming_edges.get(v) for v in edges):
raise ValueError("Input has cycles")
return L
def reverse_dict(d):
"""Reverses direction of dependence dict.
>>> d = {"a": (1, 2), "b": (2, 3), "c": ()}
>>> reverse_dict(d) # doctest: +SKIP
{1: ('a',), 2: ('a', 'b'), 3: ('b',)}
.. note::
dict order are not deterministic. As we iterate on the
input dict, it make the output of this function depend on the
dict order. So this function output order should be considered
as undeterministic.
"""
result = {} # type: ignore[var-annotated]
for key in d:
for val in d[key]:
# pyrefly: ignore [unsupported-operation]
result[val] = result.get(val, ()) + (key,)
return result
def xfail(func):
try:
func()
raise Exception("XFailed test passed") # pragma:nocover # noqa: TRY002
except Exception:
pass
def freeze(d):
"""Freeze container to hashable form
>>> freeze(1)
1
>>> freeze([1, 2])
(1, 2)
>>> freeze({1: 2}) # doctest: +SKIP
frozenset([(1, 2)])
"""
if isinstance(d, dict):
return frozenset(map(freeze, d.items()))
if isinstance(d, set):
return frozenset(map(freeze, d))
if isinstance(d, (tuple, list)):
return tuple(map(freeze, d))
return d
@@ -0,0 +1,90 @@
# mypy: allow-untyped-defs
from contextlib import contextmanager
from .dispatch import dispatch
from .utils import hashable
_global_logic_variables = set() # type: ignore[var-annotated]
_glv = _global_logic_variables
class Var:
"""Logic Variable"""
_id = 1
def __new__(cls, *token):
if len(token) == 0:
token = f"_{Var._id}" # type: ignore[assignment]
Var._id += 1
elif len(token) == 1:
token = token[0]
obj = object.__new__(cls)
obj.token = token # type: ignore[attr-defined]
return obj
def __str__(self):
return "~" + str(self.token) # type: ignore[attr-defined]
__repr__ = __str__
def __eq__(self, other):
return type(self) is type(other) and self.token == other.token # type: ignore[attr-defined]
def __hash__(self):
return hash((type(self), self.token)) # type: ignore[attr-defined]
def var():
return lambda *args: Var(*args)
def vars():
return lambda n: [var() for i in range(n)]
@dispatch(Var)
def isvar(v):
return True
isvar
@dispatch(object) # type: ignore[no-redef]
def isvar(o):
return _glv and hashable(o) and o in _glv
@contextmanager
def variables(*variables):
"""
Context manager for logic variables
Example:
>>> # xdoctest: +SKIP("undefined vars")
>>> from __future__ import with_statement
>>> with variables(1):
... print(isvar(1))
True
>>> print(isvar(1))
False
>>> # Normal approach
>>> from unification import unify
>>> x = var("x")
>>> unify(x, 1)
{~x: 1}
>>> # Context Manager approach
>>> with variables("x"):
... print(unify("x", 1))
{'x': 1}
"""
old_global_logic_variables = _global_logic_variables.copy()
_global_logic_variables.update(set(variables))
try:
yield
finally:
_global_logic_variables.clear()
_global_logic_variables.update(old_global_logic_variables)
@@ -0,0 +1,124 @@
# mypy: allow-untyped-defs
from torch.fx.experimental.graph_gradual_typechecker import Refine
from torch.fx.experimental.unification import unify, Var # type: ignore[attr-defined]
from torch.fx.tensor_type import TensorType
def infer_symbolic_types_single_pass(traced):
"""
Calls our symbolic inferencer once.
"""
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
def infer_symbolic_types(traced):
"""
Calls our symbolic inferencer twice.
This is useful when one pass is not enough
to infer all the information such as the case
for broadcasting.
"""
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
r = Refine(traced)
r.refine()
mgu = unify_eq(r.constraints)
substitute_all_types(traced.graph, mgu)
r.symbolic_relations()
def convert_eq(list_of_eq):
"""
Convert equality constraints in the right format
to be used by unification library.
"""
lhs = []
rhs = []
for eq in list_of_eq:
lhs.append(eq.lhs)
rhs.append(eq.rhs)
return tuple(lhs), tuple(rhs)
def unify_eq(list_of_eq):
"""
Apply unification to a set of
equality constraints
"""
lhs, rhs = convert_eq(list_of_eq)
return unify(lhs, rhs)
def substitute_solution_one_type(mapping, t):
"""
Apply the most general unifier to a type
"""
if isinstance(t, Var):
if t in mapping:
return mapping[t]
else:
return t
elif isinstance(t, TensorType):
new_type = []
for typ in t.__args__:
if typ in mapping:
new_type.append(mapping[typ])
else:
new_type.append(typ)
return TensorType(tuple(new_type))
elif isinstance(t, list):
new_type = []
for typ in t:
new_type.append(substitute_solution_one_type(mapping, typ))
return new_type
elif isinstance(t, tuple):
new_type = []
for typ in t:
new_type.append(substitute_solution_one_type(mapping, typ))
return tuple(new_type)
else:
return t
def substitute_all_types(graph, mapping):
"""
Apply the most general unifier to all types in a graph
till reaching a fixed point. If the input and output graph
are the same, we converge.
"""
flag = True
while flag:
flag = False
for k in mapping:
old_mapping_val = mapping[k]
if mapping[k] in mapping:
new_key = mapping[k]
mapping[k] = mapping[new_key]
if old_mapping_val != mapping[k]:
flag = True
for n in graph.nodes:
n.type = substitute_solution_one_type(mapping, n.type)
def check_for_type_equality(g1, g2):
"""
A check equality to be used in fixed points.
We do not use graph equality but instead type
equality.
"""
for n, m in zip(g1.nodes, g2.nodes):
if n.type != m.type:
return False
return True
@@ -0,0 +1,892 @@
# mypy: allow-untyped-defs
import builtins
import functools
import logging
import math
import operator
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import sympy
import torch
import torch.fx
import torch.fx.traceback as fx_traceback
from torch._dynamo.exc import TorchDynamoException
from torch._dynamo.utils import dynamo_timed
from torch.fx.node import Argument, Target
from torch.utils._sympy.interp import sympy_interp
log = logging.getLogger(__name__)
try:
import z3 # type: ignore[import]
# Translation Validation for Dynamo guards
# ========================================
#
# Checks whether optimizations applied to the collected guards are
# valid. In other words, whether the guard function we actually run
# does not have false positives (unsound).
#
# In order to do so, we build the guards using 2 different information
# attached to each 'SymNode':
# 1. SymPy expressions
# 2. FX nodes
#
# SymPy expressions have implicit optimizations baked within itself,
# which may have a few bugs. On the other hand, we build the FX graph
# manually, with no optimizations enabled. This gives us access to
# the "ground truth".
#
# We then convert into Z3 expressions both the SymPy expressions
# (see [Note: SympyToZ3]) that reach 'ShapeEnv.produce_guards' function
# and the FX nodes (see [Note: PopulateValidator]) that go through
# 'ShapeEnv.evaluate_expr' function. Finally, we run the validation.
# (see [Note: TranslationValidator])
# Better Z3 to string implementation (for a small fraction of Z3).
#
# Here are the things we clean before showing the Z3 expression:
# - Rename a few ops (e.g. "Distinct" ==> "!=")
#
# - Ignore ToInt and ToReal operations:
# usually they don't really matter
#
# - Transform (ToInt (/ ...)) into (idiv ...):
# this is the pattern for floor division
#
# - Collect a chain of the same operations into one
def z3str(e: z3.ExprRef) -> str:
if not z3.is_expr(e):
raise AssertionError(f"unsupported expression type: {e}")
def get_args_str(e: z3.ExprRef) -> list[str]:
return [z3str(e.arg(i)) for i in range(e.num_args())]
# First, we simplify the given expression.
# This is done using rewriting rules, so shouldn't take long.
e = z3.simplify(e)
# Only support function applications.
# Even Z3 "variables" are, in fact, function applications.
if not z3.is_app(e):
raise ValueError(f"can't print Z3 expression: {e}")
if z3.is_int_value(e) or z3.is_rational_value(e):
return e.as_string() # type: ignore[attr-defined]
decl = e.decl()
kind = decl.kind()
op = str(decl)
args = get_args_str(e)
if kind == z3.Z3_OP_POWER:
op = "pow"
elif kind in (z3.Z3_OP_ADD, z3.Z3_OP_MUL):
# Collect the arguments of chains of ADD and MUL.
# This is safe, since they are associative.
def collect_str_args(e):
if not (z3.is_app(e) and e.decl().kind() == kind):
return [z3str(e)]
else:
return [
x
for i in range(e.num_args())
for x in collect_str_args(e.arg(i))
]
args = collect_str_args(e)
elif kind == z3.Z3_OP_NOT:
# Revert some conversions that z3.simplify applies:
# - a != b ==> (Not (== a b)) ==> (!= a b)
# - a < b ==> (Not (<= b a)) ==> (> b a)
# - a > b ==> (Not (<= a b)) ==> (> a b)
if e.num_args() != 1:
raise AssertionError(f"Expected 1 arg, got {e.num_args()}")
arg = e.arg(0)
if not z3.is_app(arg):
raise AssertionError("Expected z3 app")
argkind = arg.decl().kind()
logic_inverse = {
z3.Z3_OP_EQ: "!=",
z3.Z3_OP_LE: ">",
z3.Z3_OP_GE: "<",
}
if argkind in logic_inverse:
op = logic_inverse[argkind]
args = get_args_str(arg)
elif kind in (z3.Z3_OP_TO_INT, z3.Z3_OP_TO_REAL):
if e.num_args() != 1:
raise AssertionError(f"Expected 1 arg, got {e.num_args()}")
argstr = z3str(e.arg(0))
# Check if it's the floor division pattern.
if argstr.startswith("(/"):
return "(idiv" + argstr[2:]
# Otherwise, just ignore it.
return argstr
elif kind == z3.Z3_OP_UNINTERPRETED:
if e.num_args() != 0:
raise AssertionError(f"Expected 0 args, got {e.num_args()}")
return str(decl)
string = op + " " + " ".join(args)
return f"({string.rstrip()})"
# We need to convert to/from BitVec in order to use z3 bitwise ops.
# We assume that integers are 64 bit.
# If all args are boolean, then use the boolean bitwise op implementation instead, if provided.
def _bitwise_op(bitwise_func, bool_func):
@functools.wraps(bitwise_func)
def wrapper(self, *args):
if bool_func is not None and all(
isinstance(arg, z3.BoolRef) for arg in args
):
return bool_func(*args)
wrapped_args = tuple(z3.Int2BV(a, 64) for a in args)
return z3.BV2Int(bitwise_func(*wrapped_args))
return wrapper
# Implementation of Python semantics as Z3 expressions.
#
# Z3 Real-Int theory has operators with semantics that differ that of
# Python. Therefore, in order to get it right, we need to implement
# the (Python) semantics we are relying on in Z3.
@dataclass
class _Z3Ops:
# Validator used for adding assertions as needed.
# e.g. div(a, b) requires b != 0.
validator: "TranslationValidator"
# The 2 functions below are used for conditionally casting between
# integer and reals.
#
# Returns a real expression from 'x'.
@staticmethod
def to_real(x: z3.ArithRef) -> z3.ArithRef:
return x if x.is_real() else z3.ToReal(x)
# Returns an integer expression from 'x'.
@staticmethod
def to_int(x: z3.ArithRef) -> z3.ArithRef:
return x if x.is_int() else z3.ToInt(x)
def sym_sum(self, args: z3.ArithRef) -> z3.ArithRef:
return sum(args) # pyrefly: ignore [no-matching-overload]
# Implements Python division semantics.
def div(self, numerator: z3.ArithRef, denominator: z3.ArithRef) -> z3.ArithRef:
self.validator.add_assertion(denominator != 0) # type: ignore[arg-type]
return _Z3Ops.to_real(numerator) / _Z3Ops.to_real(denominator)
def floor(self, number: z3.ArithRef) -> z3.ArithRef:
# Z3 ToInt function rounds a real number towards negative infinity.
return _Z3Ops.to_int(number)
# Python semantics for 'FloorDiv' states that before applying the floor
# function, the operands are converted to their common type.
def floordiv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
cast_result_to_real = numerator.is_real() or denominator.is_real()
result = _Z3Ops.to_int(self.div(numerator, denominator))
# Since the 'result' is already an integer, we just have to check
# whether we should cast it to real.
return _Z3Ops.to_real(result) if cast_result_to_real else result
def ceil(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.If(self.floor(number) < number, self.floor(number + 1), number) # type: ignore[return-value]
def trunc(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.If(number >= 0, self.floor(number), self.ceil(number)) # type: ignore[return-value]
def max(self, a: z3.ArithRef, b: z3.ArithRef) -> z3.ArithRef:
return z3.If(a > b, a, b) # type: ignore[return-value]
def min(self, a: z3.ArithRef, b: z3.ArithRef) -> z3.ArithRef:
return z3.If(a < b, a, b) # type: ignore[return-value]
# Python semantics for 'Mod' is defined as: p % q = p - floordiv(p, q) * q
# It should work with both integer and reals.
def mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return p - self.floordiv(p, q) * q
def pow(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
# Z3 can't handle complex numbers very well.
self.validator.add_assertion(z3.Or(base != 0, exp > 0)) # type: ignore[arg-type]
return base**exp
def sqrt(self, number: z3.ArithRef) -> z3.ArithRef:
# Square-root:
# 1. Only work with reals
number = _Z3Ops.to_real(number)
# 2. The number should be positive or zero.
# Otherwise, Z3 returns 'unknown'.
self.validator.add_assertion(number >= 0)
return number**0.5
def abs(self, number: z3.ArithRef) -> z3.ArithRef:
return z3.Abs(number)
def round_to_int(self, number: z3.ArithRef) -> z3.ArithRef:
# Pythons builtin 'round' implements the 'round half to even' strategy
# See https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even
# z3 has an equivalent z3.fpRoundToIntegral(z3.RoundNearestTiesToEven(), ...), but this only applies to
# floating point numbers, which is different from real numbers that we are dealing with here.
# Instead, we implement 'round half to even' in terms of 'round half up' (floor(x + 0.5)) and
# 'round half down' (ceil(x - 0.5)).
# Assuming 'round half up' is the default case, we need to correct ..., -3.5, -1.5, 0.5, 2.5, 4.5, ...
# to round down, i.e. use the 'round half down' strategy
return z3.If(
self.mod(number, z3.IntVal(2)) == 0.5,
self.ceil(number - 0.5),
self.floor(number + 0.5),
)
bitwise_and = _bitwise_op(operator.and_, z3.And)
bitwise_or = _bitwise_op(operator.or_, z3.Or)
lshift = _bitwise_op(operator.lshift, None)
rshift = _bitwise_op(operator.rshift, None)
# Lifts a callable to be used in Z3.
#
# This function replaces the given 'op' by a function that:
#
# 1. Lifts the arguments into Z3 (i.e. make them inhabitants of Z3)
#
# 2. Calls an operation that corresponds to 'op', but works with Z3
# inhabitants (left as is if it works as is)
def z3op(op: Callable, validator: "TranslationValidator") -> Callable:
# Operations that have booleans as their argument.
# This is needed because the argument of some FX nodes were
# literal integers, instead of booleans. So, whenever this flag
# is set, we also convert ints to booleans.
boolean_ops = {operator.not_}
as_bool = op in boolean_ops
# Lifts the function into 'z3.ExprRef' domain.
def lift(func):
def wrap(a) -> z3.ExprRef:
if isinstance(a, (z3.ArithRef, z3.BoolRef)):
return a
# Convert it into a Z3 value, if it is some of the supported
# types below.
if isinstance(a, bool) or (as_bool and isinstance(a, int)):
return z3.BoolVal(bool(a))
if isinstance(a, (int, sympy.Integer)):
return z3.IntVal(int(a))
if isinstance(a, (float, sympy.Float)):
return z3.RealVal(float(a))
raise ValueError(f"can't lift type: {type(a)}")
@functools.wraps(func)
def wrapper(*args):
# Lifts the arguments into a list of Z3 inhabitants.
if len(args) == 1 and isinstance(args[0], (list, tuple)):
wrapped_args = (tuple(wrap(a) for a in args[0]),)
else:
wrapped_args = tuple(wrap(a) for a in args)
# Run the function on the Z3 expressions.
return func(*wrapped_args)
return wrapper
ops = _Z3Ops(validator)
replacement_map = {
# Operator module.
operator.not_: lift(z3.Not),
operator.and_: lift(ops.bitwise_and),
operator.or_: lift(ops.bitwise_or),
operator.lshift: lift(ops.lshift),
operator.rshift: lift(ops.rshift),
operator.floordiv: lift(ops.floordiv),
operator.truediv: lift(ops.div),
operator.mod: lift(ops.mod),
operator.abs: lift(ops.abs),
builtins.round: lift(ops.round_to_int),
# Math module.
math.ceil: lift(ops.ceil),
math.floor: lift(ops.floor),
math.trunc: lift(ops.trunc),
# Torch module.
torch.sym_float: lift(ops.to_real),
torch.sym_max: lift(ops.max),
torch.sym_min: lift(ops.min),
torch.sym_sum: lift(ops.sym_sum),
torch.sym_ite: lift(lambda b, t, f: z3.If(b, t, f)),
torch._sym_sqrt: lift(ops.sqrt), # type: ignore[attr-defined]
# Not lifted because we only use this function as a
# marker for adding the expression as validator input.
torch._assert: torch._assert,
}
return replacement_map[op] if op in replacement_map else lift(op)
# Processes an FX graph, populating the given validator.
#
# [Note: PopulateValidator]
# This class walks through each node in the FX graph, translating
# them into the Z3 world.
#
# Then, whenever it finds an 'torch._assert' call_function operation,
# it adds the Z3 expression corresponding to the argument as validator
# input.
class PopulateValidator(torch.fx.Interpreter):
def __init__(self, graph: torch.fx.Graph, validator: "TranslationValidator"):
# Reference to the translation validator.
self.validator = validator
# Build the graph module and call `Interpreter` constructor.
module = torch.fx.GraphModule(root={}, graph=graph)
super().__init__(module, garbage_collect_values=True)
def placeholder(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
symbol = fx_traceback.get_current_meta()["symbol"]
return self.validator.z3var(symbol)
def call_function(
self, target: Target, args: tuple[Argument, ...], kwargs: dict[str, Any]
) -> Any:
if target is not torch._assert:
# Lift and runs the node target function
return super().call_function(z3op(target, self.validator), args, kwargs) # type: ignore[arg-type]
# Adds the Z3 expression corresponding to the first argument
# as a validator input.
if len(args) != 1:
raise AssertionError(
f"expected 1 argument on assertion. Got: {len(args)} "
)
self.validator.add_source_expr(args[0]) # type: ignore[arg-type]
# Translates SymPy expressions into Z3 expressions.
#
# [Note: SympyToZ3]
# At the time of the translation, all free variables present in the
# SymPy expression being translated must be already mapped to a Z3
# integer variable.
class SympyToZ3:
OPERATOR_HANDLES = {"add", "mul", "eq", "ne", "lt", "gt", "le", "ge"}
def __init__(
self,
validator: "TranslationValidator",
) -> None:
self._validator = validator
self._ops = _Z3Ops(self._validator)
def constant(self, value: Any, dtype: torch.dtype) -> z3.ExprRef:
# TODO: Probably OK to relax this and allow lower precision
if dtype is torch.int64:
return z3.IntVal(int(value))
if dtype is torch.double:
return z3.RealVal(float(value))
if dtype is torch.bool:
return z3.BoolVal(bool(value))
raise ValueError(f"unsupported dtype (SympyToZ3): {dtype}")
def to_dtype(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
if dtype == torch.float64:
return z3.ToReal(x)
raise NotImplementedError(f"to_dtype {dtype} NYI")
def trunc_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return z3.ToInt(x)
def round_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.round_to_int(x)
def int_truediv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.div(numerator, denominator)
def truediv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.div(numerator, denominator)
def floordiv(
self, numerator: z3.ArithRef, denominator: z3.ArithRef
) -> z3.ArithRef:
return self._ops.floordiv(numerator, denominator)
def div(self, numerator: z3.ArithRef, denominator: z3.ArithRef) -> z3.ArithRef:
return self._ops.floordiv(numerator, denominator)
def pow(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
return self._ops.pow(base, exp)
def pow_by_natural(self, base: z3.ArithRef, exp: z3.ArithRef) -> z3.ArithRef:
return self._ops.pow(base, exp)
def mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return self._ops.mod(p, q)
def python_mod(self, p: z3.ArithRef, q: z3.ArithRef) -> z3.ArithRef:
return self._ops.mod(p, q)
def ceil_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.ceil(x)
def floor_to_int(self, x: z3.ArithRef, dtype: torch.dtype) -> z3.ArithRef:
return self._ops.floor(x)
def __getattr__(self, name: str) -> Any:
REPLACEMENT = {
"and_": z3.And,
"or_": z3.Or,
"not_": z3.Not,
"bitwise_and": self._ops.bitwise_and,
"bitwise_or": self._ops.bitwise_or,
"lshift": self._ops.lshift,
"rshift": self._ops.rshift,
"floor": self._ops.floor,
"ceil": self._ops.ceil,
"minimum": self._ops.min,
"maximum": self._ops.max,
}
if name in REPLACEMENT:
return REPLACEMENT[name]
if name in self.OPERATOR_HANDLES:
return getattr(operator, name)
raise AttributeError(f"unhandled operator: {name}")
def run(self, expr: sympy.Basic) -> z3.ExprRef:
return sympy_interp(self, self._validator.symbols, expr) # type: ignore[arg-type]
# Dynamo guards translation validator.
#
# [Note: TranslationValidator]
# Verifies whether the guards issued by 'ShapeEnv.produce_guards' are sound.
# That is: whether those (target) guards only yield TRUE whenever the original,
# unoptimized, (source) guards yield TRUE.
#
# More concretely, given 'source' and 'target' guard expressions, we wish to
# check whether the following expression holds:
#
# Not(And(source)) AND And(target)
#
# i.e. whether there is an assignment of the free variables where the opposite
# happens: target is TRUE, but source is FALSE.
class TranslationValidator:
def __init__(self) -> None:
log.debug("new instance")
# Mapping of SymPy symbols to Z3 variables.
self.symbols: dict[sympy.Symbol, z3.ExprRef] = {}
# Set of source Z3 expressions.
# They represent the generated guards without any kind of
# simplification or transformation.
self._source_exprs: set[z3.BoolRef] = set()
# Set of target Z3 expressions.
# They represent the actual checked guards at runtime. They might
# be simplified or transformed versions of the source guards.
self._target_exprs: set[z3.BoolRef] = set()
# Set of Z3 expressions representing assertions over both the
# source and target expressions.
self._assertions: set[z3.BoolRef] = set()
# Retrieves the corresponding Z3 variable.
def z3var(self, symbol: sympy.Symbol) -> z3.ExprRef:
if symbol not in self.symbols:
raise AssertionError(f"Z3 variable not found for: {symbol}")
return self.symbols[symbol]
# Create a variable in Z3 of 'type' for 'symbol', if it doesn't already exists.
def add_var(self, symbol: sympy.Symbol, type: type) -> z3.ExprRef:
if symbol in self.symbols:
return self.symbols[symbol]
log.debug("new variable: %s (%s)", symbol.name, type.__name__)
if type is int:
var = z3.Int(symbol.name)
# If 'symbol' is positive (SymPy assumption), we have to
# convey it to Z3 as well.
if symbol.is_positive: # type: ignore[attr-defined]
self._target_exprs.add(var > 0)
elif type is float:
var = z3.Real(symbol.name)
elif type is bool:
var = z3.Bool(symbol.name)
else:
raise RuntimeError(f"unsupported type for Z3 variable: {type}")
self.symbols[symbol] = var
return var
# Checks whether all symbols were already added.
def _check_freesymbols(self, e: sympy.Basic) -> None:
for s in e.free_symbols:
if not isinstance(s, sympy.Symbol):
raise AssertionError(f"Expected sympy.Symbol, got {type(s)}")
# Call 'z3var' just to check whether there's already a
# Z3 variable corresponding to 's'.
self.z3var(s)
def to_z3_boolean_expr(self, e: sympy.Basic) -> z3.BoolRef:
z3expr = SympyToZ3(self).run(e)
if not isinstance(z3expr, z3.BoolRef):
raise AssertionError(f"expected boolean expression. Got: {z3expr}")
return z3expr
def add_source_expr(self, e: z3.BoolRef) -> None:
if e not in self._source_exprs:
log.debug("add source guard: %s", z3str(e))
self._source_exprs.add(e)
def add_target_expr(self, e: "sympy.logic.boolalg.Boolean") -> None:
self._check_freesymbols(e)
z3expr = self.to_z3_boolean_expr(e)
if e not in self._target_exprs:
log.debug("add target guard: %s", z3str(z3expr))
self._target_exprs.add(z3expr)
def add_assertion(self, e: z3.BoolRef | sympy.Basic) -> None:
if isinstance(e, sympy.Basic):
self._check_freesymbols(e)
ref = self.to_z3_boolean_expr(e)
else:
ref = e
if not isinstance(ref, z3.BoolRef):
raise AssertionError(f"Expected z3.BoolRef, got {type(ref)}")
if ref not in self._assertions:
log.debug("add assertion: %s", z3str(ref))
self._assertions.add(ref)
def validate(self) -> None:
with dynamo_timed("TranslationValidator.validate"):
return self._validate()
def _validate(self) -> None:
if len(self._source_exprs) == 0 or len(self._target_exprs) == 0:
# If there are no source/target expressions, there's nothing we really
# wish to prove. So, we just return.
return None
# Here, we use "QF_NRA" logic for the solver:
# "Quantifier-free Non-linear Real Arithmetic".
#
# Most of the guards expressions have:
# 1. arithmetic between integer and reals
# 2. no quantifiers
# 3. potentially non-linear.
#
# Although there's also "QF_NIRA" (mixed integer-real arithmetic),
# "QF_NRA" seems to work better on 'dynamo/test_dynamic_shapes.py'.
solver = z3.SolverFor("QF_NRA")
# Set a timeout for finding a solution.
solver.set(timeout=translation_validation_timeout())
# Add all the assertions to the solver.
for assertion in self._assertions:
solver.add(assertion)
# "Is there any case where it's TRUE for the target expressions,
# but FALSE for the source expressions?"
solver.add(z3.Not(z3.And(*self._source_exprs)))
solver.add(*self._target_exprs)
log.debug("translation validation: start")
r = solver.check()
if r == z3.sat:
# Target expressions are unsound.
# Log the found model and the source expressions that failed.
model = solver.model()
raise ValidationException(
model,
self._assertions,
self._target_exprs,
failed_source_exprs=[
inp for inp in self._source_exprs if not model.evaluate(inp)
],
)
else:
if r == z3.unknown:
# Could not find a solution. It didn't fail, but it also
# didn't succeed. Canceling the validation execution (keyboard
# interrupt) also gets to this branch.
log.warning(
"translation validation: could not validate: got z3.unknown"
)
else:
# Target expressions are sound.
if r != z3.unsat:
raise AssertionError(f"Expected z3.unsat, got {r}")
log.debug("translation validation: success")
except ImportError:
_HAS_Z3 = False
__all__ = [
"translation_validation_enabled",
"translation_validation_timeout",
"ValidationException",
"BisectValidationException",
]
else:
_HAS_Z3 = True
__all__ = [
"z3str",
"z3op",
"PopulateValidator",
"SympyToZ3",
"TranslationValidator",
"translation_validation_enabled",
"translation_validation_timeout",
"ValidationException",
"BisectValidationException",
]
from torch.fx.experimental import _config as config
def translation_validation_enabled() -> bool:
# Checks every time this function is called, in case the Dynamo
# option is set, but Z3 is not installed.
_assert_z3_installed_if_tv_set()
return _HAS_Z3 and config.translation_validation
def translation_validation_timeout() -> int:
return config.translation_validation_timeout
def _assert_z3_installed_if_tv_set():
if not (_HAS_Z3 or not config.translation_validation):
raise AssertionError(
"translation validation requires Z3 package. Please, either install "
"z3-solver or disable translation validation."
)
class ValidationException(TorchDynamoException):
def __init__(self, model, assertions, target_exprs, failed_source_exprs):
if not _HAS_Z3:
raise AssertionError("Z3 is required")
def symbolstr(sym) -> str:
return f"{sym}: {model[sym]}"
def joinlines(xs) -> str:
return "\n".join(f" ==> {x}" for x in xs)
model_str = joinlines(sorted(map(symbolstr, model)))
assertions_str = joinlines(sorted(map(z3str, assertions)))
target_exprs_str = joinlines(sorted(map(z3str, target_exprs)))
failed_source_exprs_str = joinlines(sorted(map(z3str, failed_source_exprs)))
self.msg = "translation validation failed."
self.details = f"""\
Model:
{model_str}
Assertions:
{assertions_str}
Target Expressions:
{target_exprs_str}
Failed Source Expressions:
{failed_source_exprs_str}"""
def __str__(self):
return f"{self.msg}\n\n{self.details}"
class BisectValidationException(TorchDynamoException):
def __init__(self, validation_exc, expr, failed_action, traced_node):
self.msg = f"translation validation failed when {failed_action}: {expr}"
self.details = f"""\
Failure occurred while running node:
{traced_node.format_node()}
{validation_exc.details}"""
def __str__(self):
return f"{self.msg}\n\n{self.details}"
# Checks when this module is loaded.
_assert_z3_installed_if_tv_set()
# Translation validation bisection.
#
# Bisect into the torch._assert nodes recorded in the shape_env FX graph, and raise
# the earliest ValidationException.
#
# As guards are added by ShapeEnv.evaluate_expr calls, some simplification errors
# might be silently happening. This function tries to nail down exactly at which
# point things went wrong from a validation perspective.
def bisect(shape_env):
from torch.fx.experimental.recording import (
FakeTensorMeta,
replay_shape_env_events,
ShapeEnvEvent,
)
from torch.fx.experimental.symbolic_shapes import (
CURRENT_NODE_KEY,
ShapeEnv,
SHAPEENV_EVENT_KEY,
)
events = shape_env.events
# Retrieves the ShapeEnvEvent associated with node.
def get_node_event(node: torch.fx.Node) -> ShapeEnvEvent:
if SHAPEENV_EVENT_KEY not in node.meta:
raise AssertionError("SHAPEENV_EVENT_KEY not in node.meta")
return events[node.meta[SHAPEENV_EVENT_KEY]]
# Creates a new instance of fake, but updating every symbolic value's ShapeEnv
# reference to the one given as argument.
#
# This is needed so as not to simplify a symbolic expression using a ShapeEnv
# "from the future", where it may have a different set of replacements.
def new_with_shape_env(shape_env: ShapeEnv, fake) -> Any:
if isinstance(fake, int):
return fake
if isinstance(fake, torch.SymInt):
return torch.SymInt(fake.node.with_shape_env(shape_env))
if isinstance(fake, torch.SymFloat):
return torch.SymFloat(fake.node.with_shape_env(shape_env))
if not isinstance(fake, FakeTensorMeta):
raise AssertionError(f"Expected FakeTensorMeta, got {type(fake)}")
return FakeTensorMeta(
tuple(new_with_shape_env(shape_env, s) for s in fake.size()),
tuple(new_with_shape_env(shape_env, s) for s in fake.stride()),
new_with_shape_env(shape_env, fake.storage_offset()),
fake.is_nested,
)
# Checks whether the given shape_env fails when produce_guards is called.
def check_shapeenv_fails(
shape_env: ShapeEnv, tracked_fakes: list[Any] | None
) -> ValidationException | None:
if tracked_fakes is None:
raise AssertionError("tracked_fakes is None")
try:
# This produce_guards call is a best-effort replication, since we
# don't populate EqualityConstraint list. Reason: we would also have
# to save OutputGraph.tracked_fakes_id_to_source.
shape_env.produce_guards(
[new_with_shape_env(shape_env, a.fake) for a in tracked_fakes],
[a.source for a in tracked_fakes],
input_contexts=[a.symbolic_context for a in tracked_fakes],
)
return None
except ValidationException as e:
return e
# Checks whether the ShapeEnv reconstructed by replaying the events until
# node is created fails when produce_guards is called.
def check_node_fails(node: torch.fx.Node) -> ValidationException | None:
number = node.meta[SHAPEENV_EVENT_KEY]
# Reconstruct shape_env until the event at event_number.
shape_env = replay_shape_env_events(events[: number + 1])
shape_env.graph.lint()
return check_shapeenv_fails(shape_env, events[number].tracked_fakes)
last_exception = check_shapeenv_fails(
shape_env, shape_env._snapshot_tracked_fakes()
)
if not last_exception:
# We don't actually fail due to a produce_guards call.
# Stop and don't bisect.
log.info("translation validation succeeded: no errors found.")
return
if not shape_env.should_record_events or config.translation_validation_no_bisect:
# Bisection is off.
# Return the last ValidationException we got.
raise last_exception
# Cache the raised exception (if any) at each bisection point.
exception = {}
# Bisection happens on the assertion nodes of the recorded FX graph for
# dynamic shapes.
assert_nodes = [
node for node in shape_env.graph.nodes if node.target is torch._assert
]
# Preparing the indices for binary search.
# The overall invariants are
# - for all i < left, assert_node[i] doesn't fail
# - for all i >= right, assert_node[i] fails
# - `right in exception` always holds
# - `left <= right` always holds
left, mid, right = 0, 0, len(assert_nodes) - 1
exception[right] = check_node_fails(assert_nodes[right])
while left < right:
mid = (left + right) // 2
node = assert_nodes[mid]
log.debug("bisecting at %s: %s", mid, get_node_event(node))
# Check whether the new shape_env raises a ValidationException or not.
exception[mid] = check_node_fails(node)
if exception[mid]:
right = mid
else:
left = mid + 1
if not (left in exception and isinstance(exception[left], ValidationException)):
raise AssertionError("Expected ValidationException at bisect result")
node = assert_nodes[left]
event = get_node_event(node)
if event.is_evaluate_expr():
failed_action = "evaluating"
else:
if not event.is_defer_runtime_assert():
raise AssertionError(f"unexpected event type: {event}")
failed_action = "adding runtime assert"
args = event.args
if args is None:
raise AssertionError("event.args is None")
if len(args) < 2:
raise AssertionError(
f"bisecting expects {event.name} to have at least 2 positional arguments. "
f"Got: {len(args)}"
)
if not isinstance(args[1], sympy.Basic):
raise AssertionError(
f"bisecting expects {event.name} to have a SymPy expression as its second "
f"argument. Got: {type(args[1])}"
)
raise BisectValidationException(
exception[left],
expr=args[1],
failed_action=failed_action,
traced_node=node.meta[CURRENT_NODE_KEY],
)