Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -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
|
||||
+1085
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+1037
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+733
@@ -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
|
||||
+1821
File diff suppressed because it is too large
Load Diff
+1447
File diff suppressed because it is too large
Load Diff
+14
@@ -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 = "%"
|
||||
+546
@@ -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
|
||||
+58
@@ -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]
|
||||
+30
@@ -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))
|
||||
+155
@@ -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
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)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
from .core import dispatch
|
||||
from .dispatcher import (
|
||||
Dispatcher,
|
||||
halt_ordering,
|
||||
MDNotImplementedError,
|
||||
restart_ordering,
|
||||
)
|
||||
+146
@@ -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)
|
||||
+92
@@ -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"
|
||||
+461
@@ -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
|
||||
+132
@@ -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))})"
|
||||
+96
@@ -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
|
||||
"""
|
||||
+419
@@ -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
|
||||
+90
@@ -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],
|
||||
)
|
||||
Reference in New Issue
Block a user