Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
import zipfile
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from typing_extensions import deprecated
|
||||
|
||||
import torch
|
||||
import torch.utils._pytree as pytree
|
||||
from torch.fx.passes.infra.pass_base import PassResult
|
||||
from torch.types import FileLike
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdditionalInputs",
|
||||
"Constraint",
|
||||
"CustomDecompTable",
|
||||
"default_decompositions",
|
||||
"Dim",
|
||||
"dims",
|
||||
"draft_export",
|
||||
"export",
|
||||
"ExportBackwardSignature",
|
||||
"ExportedProgram",
|
||||
"ExportGraphSignature",
|
||||
"FlatArgsAdapter",
|
||||
"load",
|
||||
"ModuleCallEntry",
|
||||
"ModuleCallSignature",
|
||||
"register_dataclass",
|
||||
"save",
|
||||
"ShapesCollection",
|
||||
"unflatten",
|
||||
"UnflattenedModule",
|
||||
]
|
||||
|
||||
# To make sure export specific custom ops are loaded
|
||||
import torch.export.custom_ops
|
||||
|
||||
from ._state_dict_utils import _restore_state_dict
|
||||
from .decomp_utils import CustomDecompTable
|
||||
from .dynamic_shapes import AdditionalInputs, Constraint, Dim, dims, ShapesCollection
|
||||
from .exported_program import (
|
||||
default_decompositions,
|
||||
ExportedProgram,
|
||||
ModuleCallEntry,
|
||||
ModuleCallSignature,
|
||||
)
|
||||
from .graph_signature import ExportBackwardSignature, ExportGraphSignature
|
||||
from .unflatten import FlatArgsAdapter, unflatten, UnflattenedModule
|
||||
|
||||
|
||||
PassType = Callable[[torch.fx.GraphModule], PassResult | None]
|
||||
|
||||
log: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def export(
|
||||
mod: torch.nn.Module,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
dynamic_shapes: dict[str, Any] | tuple[Any, ...] | list[Any] | None = None,
|
||||
strict: bool = False,
|
||||
preserve_module_call_signature: tuple[str, ...] = (),
|
||||
prefer_deferred_runtime_asserts_over_guards: bool = False,
|
||||
) -> ExportedProgram:
|
||||
"""
|
||||
:func:`export` takes any nn.Module along with example inputs, and produces a traced graph representing
|
||||
only the Tensor computation of the function in an Ahead-of-Time (AOT) fashion,
|
||||
which can subsequently be executed with different inputs or serialized. The
|
||||
traced graph (1) produces normalized operators in the functional ATen operator set
|
||||
(as well as any user-specified custom operators), (2) has eliminated all Python control
|
||||
flow and data structures (with certain exceptions), and (3) records the set of
|
||||
shape constraints needed to show that this normalization and control-flow elimination
|
||||
is sound for future inputs.
|
||||
|
||||
**Soundness Guarantee**
|
||||
|
||||
While tracing, :func:`export()` takes note of shape-related assumptions
|
||||
made by the user program and the underlying PyTorch operator kernels.
|
||||
The output :class:`ExportedProgram` is considered valid only when these
|
||||
assumptions hold true.
|
||||
|
||||
Tracing makes assumptions on the shapes (not values) of input tensors.
|
||||
Such assumptions must be validated at graph capture time for :func:`export`
|
||||
to succeed. Specifically:
|
||||
|
||||
- Assumptions on static shapes of input tensors are automatically validated without additional effort.
|
||||
- Assumptions on dynamic shape of input tensors require explicit specification
|
||||
by using the :func:`Dim` API to construct dynamic dimensions and by associating
|
||||
them with example inputs through the ``dynamic_shapes`` argument.
|
||||
|
||||
If any assumption can not be validated, a fatal error will be raised. When that happens,
|
||||
the error message will include suggested fixes to the specification that are needed
|
||||
to validate the assumptions. For example :func:`export` might suggest the
|
||||
following fix to the definition of a dynamic dimension ``dim0_x``, say appearing in the
|
||||
shape associated with input ``x``, that was previously defined as ``Dim("dim0_x")``::
|
||||
|
||||
dim = Dim("dim0_x", max=5)
|
||||
|
||||
This example means the generated code requires dimension 0 of input ``x`` to be less
|
||||
than or equal to 5 to be valid. You can inspect the suggested fixes to dynamic dimension
|
||||
definitions and then copy them verbatim into your code without needing to change the
|
||||
``dynamic_shapes`` argument to your :func:`export` call.
|
||||
|
||||
Args:
|
||||
mod: We will trace the forward method of this module.
|
||||
|
||||
args: Example positional inputs.
|
||||
|
||||
kwargs: Optional example keyword inputs.
|
||||
|
||||
dynamic_shapes:
|
||||
An optional argument where the type should either be:
|
||||
1) a dict from argument names of ``f`` to their dynamic shape specifications,
|
||||
2) a tuple that specifies dynamic shape specifications for each input in original order.
|
||||
If you are specifying dynamism on keyword args, you will need to pass them in the order that
|
||||
is defined in the original function signature.
|
||||
|
||||
The dynamic shape of a tensor argument can be specified as either
|
||||
(1) a dict from dynamic dimension indices to :func:`Dim` types, where it is
|
||||
not required to include static dimension indices in this dict, but when they are,
|
||||
they should be mapped to None; or (2) a tuple / list of :func:`Dim` types or None,
|
||||
where the :func:`Dim` types correspond to dynamic dimensions, and static dimensions
|
||||
are denoted by None. Arguments that are dicts or tuples / lists of tensors are
|
||||
recursively specified by using mappings or sequences of contained specifications.
|
||||
|
||||
strict: When disabled (default), the export function will trace the program through
|
||||
Python runtime, which by itself will not validate some of the implicit assumptions
|
||||
baked into the graph. It will still validate most critical assumptions like shape
|
||||
safety. When enabled (by setting ``strict=True``), the export function will trace
|
||||
the program through TorchDynamo which will ensure the soundness of the resulting
|
||||
graph. TorchDynamo has limited Python feature coverage, thus you may experience more
|
||||
errors. Note that toggling this argument does not affect the resulting IR spec to be
|
||||
different and the model will be serialized in the same way regardless of what value
|
||||
is passed here.
|
||||
|
||||
preserve_module_call_signature: A list of submodule paths for which the original
|
||||
calling conventions are preserved as metadata. The metadata will be used when calling
|
||||
torch.export.unflatten to preserve the original calling conventions of modules.
|
||||
|
||||
Returns:
|
||||
An :class:`ExportedProgram` containing the traced callable.
|
||||
|
||||
**Acceptable input/output types**
|
||||
|
||||
Acceptable types of inputs (for ``args`` and ``kwargs``) and outputs include:
|
||||
|
||||
- Primitive types, i.e. ``torch.Tensor``, ``int``, ``float``, ``bool`` and ``str``.
|
||||
- Dataclasses, but they must be registered by calling :func:`register_dataclass` first.
|
||||
- (Nested) Data structures comprising of ``dict``, ``list``, ``tuple``, ``namedtuple`` and
|
||||
``OrderedDict`` containing all above types.
|
||||
|
||||
"""
|
||||
from ._trace import _export
|
||||
|
||||
if not isinstance(mod, torch.nn.Module):
|
||||
raise ValueError(
|
||||
f"Expected `mod` to be an instance of `torch.nn.Module`, got {type(mod)}."
|
||||
)
|
||||
if isinstance(mod, torch.jit.ScriptModule):
|
||||
raise ValueError(
|
||||
"Exporting a ScriptModule is not supported. "
|
||||
"Maybe try converting your ScriptModule to an ExportedProgram "
|
||||
"using `TS2EPConverter(mod, args, kwargs).convert()` instead."
|
||||
)
|
||||
|
||||
try:
|
||||
return _export(
|
||||
mod,
|
||||
args,
|
||||
kwargs,
|
||||
dynamic_shapes,
|
||||
strict=strict,
|
||||
preserve_module_call_signature=preserve_module_call_signature,
|
||||
pre_dispatch=True,
|
||||
prefer_deferred_runtime_asserts_over_guards=prefer_deferred_runtime_asserts_over_guards,
|
||||
)
|
||||
except Exception as e:
|
||||
draft_export_msg = (
|
||||
"The error above occurred when calling torch.export.export. If you would "
|
||||
"like to view some more information about this error, and get a list "
|
||||
"of all other errors that may occur in your export call, you can "
|
||||
"replace your `export()` call with `draft_export()`."
|
||||
)
|
||||
|
||||
# For errors that we know can be caught by draft-export, add the message
|
||||
# to ask users to try out draft-export
|
||||
if isinstance(
|
||||
e,
|
||||
(
|
||||
torch.fx.experimental.symbolic_shapes.GuardOnDataDependentSymNode,
|
||||
torch._subclasses.fake_tensor.UnsupportedOperatorException,
|
||||
torch._dynamo.exc.UserError,
|
||||
torch.fx.experimental.symbolic_shapes.ConstraintViolationError,
|
||||
),
|
||||
):
|
||||
new_msg = str(e) + "\n\n" + draft_export_msg
|
||||
e.args = (new_msg,)
|
||||
elif isinstance(e, RuntimeError) and "no fake impl registered" in str(e):
|
||||
new_msg = str(e) + "\n\n" + draft_export_msg
|
||||
e.args = (new_msg,)
|
||||
raise e
|
||||
|
||||
|
||||
DEFAULT_PICKLE_PROTOCOL = 2
|
||||
|
||||
|
||||
def save(
|
||||
ep: ExportedProgram,
|
||||
f: FileLike,
|
||||
*,
|
||||
extra_files: dict[str, Any] | None = None,
|
||||
opset_version: dict[str, int] | None = None,
|
||||
pickle_protocol: int = DEFAULT_PICKLE_PROTOCOL,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
.. warning::
|
||||
Under active development, saved files may not be usable in newer versions
|
||||
of PyTorch.
|
||||
|
||||
Saves an :class:`ExportedProgram` to a file-like object. It can then be
|
||||
loaded using the Python API :func:`torch.export.load <torch.export.load>`.
|
||||
|
||||
Args:
|
||||
ep (ExportedProgram): The exported program to save.
|
||||
|
||||
f (str | os.PathLike[str] | IO[bytes]) A file-like object (has to
|
||||
implement write and flush) or a string containing a file name.
|
||||
|
||||
extra_files (Optional[Dict[str, Any]]): Map from filename to contents
|
||||
which will be stored as part of f.
|
||||
|
||||
opset_version (Optional[Dict[str, int]]): A map of opset names
|
||||
to the version of this opset
|
||||
|
||||
pickle_protocol: can be specified to override the default protocol
|
||||
|
||||
Example::
|
||||
|
||||
import torch
|
||||
import io
|
||||
|
||||
|
||||
class MyModule(torch.nn.Module):
|
||||
def forward(self, x):
|
||||
return x + 10
|
||||
|
||||
|
||||
ep = torch.export.export(MyModule(), (torch.randn(5),))
|
||||
|
||||
# Save to file
|
||||
torch.export.save(ep, "exported_program.pt2")
|
||||
|
||||
# Save to io.BytesIO buffer
|
||||
buffer = io.BytesIO()
|
||||
torch.export.save(ep, buffer)
|
||||
|
||||
# Save with extra files
|
||||
extra_files = {"foo.txt": b"bar".decode("utf-8")}
|
||||
torch.export.save(ep, "exported_program.pt2", extra_files=extra_files)
|
||||
|
||||
"""
|
||||
if not isinstance(ep, ExportedProgram):
|
||||
raise TypeError(
|
||||
f"The 'ep' parameter must be an instance of 'ExportedProgram', got '{type(ep).__name__}' instead."
|
||||
)
|
||||
|
||||
from torch.export.pt2_archive._package import package_pt2
|
||||
|
||||
package_pt2(
|
||||
f,
|
||||
exported_programs={"model": ep},
|
||||
extra_files=extra_files,
|
||||
pickle_protocol=pickle_protocol,
|
||||
opset_version=opset_version,
|
||||
)
|
||||
|
||||
|
||||
def load(
|
||||
f: FileLike,
|
||||
*,
|
||||
extra_files: dict[str, Any] | None = None,
|
||||
expected_opset_version: dict[str, int] | None = None,
|
||||
) -> ExportedProgram:
|
||||
"""
|
||||
|
||||
.. warning::
|
||||
Under active development, saved files may not be usable in newer versions
|
||||
of PyTorch.
|
||||
|
||||
.. warning::
|
||||
:func:`torch.export.load()` uses pickle under the hood to load models. **Never load data from an untrusted source.**
|
||||
|
||||
Loads an :class:`ExportedProgram` previously saved with
|
||||
:func:`torch.export.save <torch.export.save>`.
|
||||
|
||||
Args:
|
||||
f (str | os.PathLike[str] | IO[bytes]): A file-like object (has to
|
||||
implement write and flush) or a string containing a file name.
|
||||
|
||||
extra_files (Optional[Dict[str, Any]]): The extra filenames given in
|
||||
this map would be loaded and their content would be stored in the
|
||||
provided map.
|
||||
|
||||
expected_opset_version (Optional[Dict[str, int]]): A map of opset names
|
||||
to expected opset versions
|
||||
|
||||
Returns:
|
||||
An :class:`ExportedProgram` object
|
||||
|
||||
Example::
|
||||
|
||||
import torch
|
||||
import io
|
||||
|
||||
# Load ExportedProgram from file
|
||||
ep = torch.export.load("exported_program.pt2")
|
||||
|
||||
# Load ExportedProgram from io.BytesIO object
|
||||
with open("exported_program.pt2", "rb") as f:
|
||||
buffer = io.BytesIO(f.read())
|
||||
buffer.seek(0)
|
||||
ep = torch.export.load(buffer)
|
||||
|
||||
# Load with extra files.
|
||||
extra_files = {"foo.txt": ""} # values will be replaced with data
|
||||
ep = torch.export.load("exported_program.pt2", extra_files=extra_files)
|
||||
print(extra_files["foo.txt"])
|
||||
print(ep(torch.randn(5)))
|
||||
"""
|
||||
if isinstance(f, (str, os.PathLike)):
|
||||
f = os.fspath(f)
|
||||
|
||||
extra_files = extra_files or {}
|
||||
|
||||
from torch.export.pt2_archive._package import load_pt2, PT2ArchiveContents
|
||||
|
||||
try:
|
||||
pt2_contents = load_pt2(
|
||||
f,
|
||||
expected_opset_version=expected_opset_version,
|
||||
)
|
||||
except RuntimeError:
|
||||
log.warning("Ran into the following error when deserializing", exc_info=True)
|
||||
pt2_contents = PT2ArchiveContents({}, {}, {})
|
||||
|
||||
if len(pt2_contents.exported_programs) > 0 or len(pt2_contents.extra_files) > 0:
|
||||
for k, v in pt2_contents.extra_files.items():
|
||||
extra_files[k] = v
|
||||
|
||||
return pt2_contents.exported_programs["model"]
|
||||
|
||||
# TODO: For backward compatibility, we support loading a zip file from 2.7. Delete this path in 2.9(?)
|
||||
with zipfile.ZipFile(f, "r") as zipf:
|
||||
if "version" not in zipf.namelist():
|
||||
raise RuntimeError(
|
||||
"We ran into an error when deserializing the saved file. "
|
||||
"Please check the warnings above for possible errors. "
|
||||
)
|
||||
|
||||
log.warning(
|
||||
"Trying to deserialize for the older format. This version of file is "
|
||||
"deprecated. Please generate a new pt2 saved file."
|
||||
)
|
||||
|
||||
# Check the version
|
||||
version = zipf.read("version").decode().split(".")
|
||||
from torch._export.serde.schema import (
|
||||
SCHEMA_VERSION, # todo change archive version to schema version
|
||||
)
|
||||
|
||||
if len(version) != len(SCHEMA_VERSION):
|
||||
raise AssertionError(
|
||||
"Version in the saved file has incorrect length, double check if the file is generated by torch.export.save()"
|
||||
)
|
||||
if version[0] != str(SCHEMA_VERSION[0]):
|
||||
raise RuntimeError(
|
||||
f"Serialized version {version} does not match our current "
|
||||
f"schema version {SCHEMA_VERSION}."
|
||||
)
|
||||
|
||||
from torch._export.serde.serialize import deserialize, SerializedArtifact
|
||||
|
||||
# Load serialized_ep and serialized_state_dict from the zip file
|
||||
|
||||
serialized_exported_program: bytes | None = None
|
||||
serialized_state_dict: bytes | None = None
|
||||
serialized_constants: bytes | None = None
|
||||
serialized_example_inputs: bytes | None = None
|
||||
|
||||
for file_info in zipf.infolist():
|
||||
file_content = zipf.read(file_info.filename)
|
||||
|
||||
if file_info.filename == "serialized_exported_program.json":
|
||||
serialized_exported_program = file_content
|
||||
elif file_info.filename == "serialized_state_dict.json":
|
||||
warnings.warn("This version of file is deprecated", stacklevel=2)
|
||||
serialized_state_dict = file_content
|
||||
elif file_info.filename == "serialized_constants.json":
|
||||
warnings.warn("This version of file is deprecated", stacklevel=2)
|
||||
serialized_constants = file_content
|
||||
elif file_info.filename == "serialized_state_dict.pt":
|
||||
serialized_state_dict = file_content
|
||||
elif file_info.filename == "serialized_constants.pt":
|
||||
serialized_constants = file_content
|
||||
elif file_info.filename == "serialized_example_inputs.pt":
|
||||
serialized_example_inputs = file_content
|
||||
elif file_info.filename.startswith("extra_files"):
|
||||
filename = file_info.filename.split("/", 1)[1]
|
||||
extra_files[filename] = file_content.decode("utf-8")
|
||||
|
||||
if serialized_exported_program is None:
|
||||
raise AssertionError("serialized_exported_program is None")
|
||||
if serialized_state_dict is None:
|
||||
raise AssertionError("serialized_state_dict is None")
|
||||
if serialized_constants is None:
|
||||
raise AssertionError("serialized_constants is None")
|
||||
if serialized_example_inputs is None:
|
||||
raise AssertionError("serialized_example_inputs is None")
|
||||
artifact: SerializedArtifact = SerializedArtifact(
|
||||
serialized_exported_program,
|
||||
serialized_state_dict,
|
||||
serialized_constants,
|
||||
serialized_example_inputs,
|
||||
)
|
||||
|
||||
# Deserialize ExportedProgram
|
||||
ep = deserialize(artifact, expected_opset_version)
|
||||
|
||||
return ep
|
||||
|
||||
|
||||
def draft_export(
|
||||
mod: torch.nn.Module,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
dynamic_shapes: dict[str, Any] | tuple[Any, ...] | list[Any] | None = None,
|
||||
preserve_module_call_signature: tuple[str, ...] = (),
|
||||
strict: bool = False,
|
||||
prefer_deferred_runtime_asserts_over_guards: bool = False,
|
||||
) -> ExportedProgram:
|
||||
"""
|
||||
A version of torch.export.export which is designed to consistently produce
|
||||
an ExportedProgram, even if there are potential soundness issues, and to
|
||||
generate a report listing the issues found.
|
||||
"""
|
||||
from ._draft_export import draft_export
|
||||
|
||||
return draft_export(
|
||||
mod=mod,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
dynamic_shapes=dynamic_shapes,
|
||||
preserve_module_call_signature=preserve_module_call_signature,
|
||||
strict=strict,
|
||||
prefer_deferred_runtime_asserts_over_guards=prefer_deferred_runtime_asserts_over_guards,
|
||||
)
|
||||
|
||||
|
||||
def register_dataclass(
|
||||
cls: type[Any],
|
||||
*,
|
||||
serialized_type_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Registers a dataclass as a valid input/output type for :func:`torch.export.export`.
|
||||
|
||||
Args:
|
||||
cls: the dataclass type to register
|
||||
serialized_type_name: The serialized name for the dataclass. This is
|
||||
required if you want to serialize the pytree TreeSpec containing this
|
||||
dataclass.
|
||||
|
||||
Example::
|
||||
|
||||
import torch
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputDataClass:
|
||||
feature: torch.Tensor
|
||||
bias: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputDataClass:
|
||||
res: torch.Tensor
|
||||
|
||||
|
||||
torch.export.register_dataclass(InputDataClass)
|
||||
torch.export.register_dataclass(OutputDataClass)
|
||||
|
||||
|
||||
class Mod(torch.nn.Module):
|
||||
def forward(self, x: InputDataClass) -> OutputDataClass:
|
||||
res = x.feature + x.bias
|
||||
return OutputDataClass(res=res)
|
||||
|
||||
|
||||
ep = torch.export.export(Mod(), (InputDataClass(torch.ones(2, 2), 1),))
|
||||
print(ep)
|
||||
|
||||
"""
|
||||
pytree.register_dataclass(cls, serialized_type_name=serialized_type_name)
|
||||
@@ -0,0 +1,545 @@
|
||||
import getpass
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch._logging._internal
|
||||
import torch.utils._pytree as pytree
|
||||
from torch._dynamo.exc import UserError, UserErrorType
|
||||
from torch._export.passes.insert_custom_op_guards import (
|
||||
get_op_profiles,
|
||||
insert_custom_op_guards,
|
||||
OpProfile,
|
||||
)
|
||||
from torch._utils_internal import log_draft_export_usage
|
||||
|
||||
from ._trace import _export, get_ep_stats
|
||||
from .dynamic_shapes import _DimHint, _DimHintType, Dim
|
||||
from .exported_program import ExportedProgram
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FailureType(IntEnum):
|
||||
MISSING_FAKE_KERNEL = 1
|
||||
DATA_DEPENDENT_ERROR = 2
|
||||
GUARD_ADDED = 3
|
||||
MISMATCHED_FAKE_KERNEL = 4
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
def prettify_stack(stack: list[dict[str, str]], str_to_filename: dict[int, str]) -> str:
|
||||
res = ""
|
||||
for frame in stack:
|
||||
if frame["filename"] not in str_to_filename:
|
||||
continue
|
||||
|
||||
res += f"""
|
||||
File {str_to_filename[frame["filename"]]}, lineno {frame["line"]}, in {frame["name"]}""" # type: ignore[index]
|
||||
|
||||
res += f"\n {stack[-1]['loc']}"
|
||||
return res
|
||||
|
||||
|
||||
def prettify_frame_locals(
|
||||
loc: str, locals: dict[str, Any], symbols: dict[str, Any]
|
||||
) -> str:
|
||||
local_str = "\n".join(f" {k}: {v}" for k, v in locals.items())
|
||||
res = f"""
|
||||
Locals:
|
||||
{local_str}
|
||||
"""
|
||||
if any(v is not None for v in symbols.values()):
|
||||
symbol_str = "\n".join(
|
||||
f" {k}: {v}" for k, v in symbols.items() if v is not None
|
||||
)
|
||||
res += f"""
|
||||
Symbols:
|
||||
{symbol_str}
|
||||
"""
|
||||
return res
|
||||
|
||||
|
||||
def get_loc(filename: str, lineno: int) -> str | None:
|
||||
try:
|
||||
with open(filename) as f:
|
||||
for i, line in enumerate(f):
|
||||
if i == lineno - 1:
|
||||
return line.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class FailureReport:
|
||||
def __init__(
|
||||
self, failure_type: FailureType, data: dict[str, Any], xfail: bool = False
|
||||
) -> None:
|
||||
self.failure_type: FailureType = failure_type
|
||||
self.data: dict[str, Any] = data
|
||||
self.xfail: bool = xfail
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FailureReport(failure_type={self.failure_type}, xfail={self.xfail}, data={self.data})"
|
||||
|
||||
def print(self, str_to_filename: dict[int, str]) -> str:
|
||||
if self.failure_type == FailureType.MISSING_FAKE_KERNEL:
|
||||
op = self.data["op"]
|
||||
|
||||
return f"""Missing fake kernel.
|
||||
torch.ops.{op} is missing a fake kernel implementation.
|
||||
|
||||
Please refer to https://docs.google.com/document/d/1_W62p8WJOQQUzPsJYa7s701JXt0qf2OfLub2sbkHOaU/edit#heading=h.ahugy69p2jmz for more detailed instructions on how to write a meta implementation.
|
||||
""" # noqa: B950
|
||||
|
||||
elif self.failure_type == FailureType.GUARD_ADDED:
|
||||
locals_info = (
|
||||
prettify_frame_locals(**self.data["frame_locals"])
|
||||
if self.data["frame_locals"]
|
||||
else ""
|
||||
)
|
||||
return f"""Guard Added.
|
||||
A guard was added during tracing, which might've resulted in some incorrect
|
||||
tracing or constraint violation error.
|
||||
Specifically, this guard was added: {self.data["expr"]}, where {self.data["symbol_to_sources"]}.
|
||||
This occurred at the following stacktrace: {prettify_stack(self.data["user_stack"], str_to_filename)}:
|
||||
{locals_info}
|
||||
And the following framework stacktrace: {prettify_stack(self.data["stack"], str_to_filename)}\n
|
||||
Because of this, we have modified the dynamic shapes structure to be the
|
||||
following. You can also use torch.export.Dim.AUTO instead to specify your
|
||||
dynamic shapes, and we will automatically infer the dynamism for you.
|
||||
```
|
||||
dynamic_shapes = {self.data["new_dynamic_shapes"]}
|
||||
```
|
||||
"""
|
||||
|
||||
elif self.failure_type == FailureType.DATA_DEPENDENT_ERROR:
|
||||
locals_info = (
|
||||
prettify_frame_locals(**self.data["frame_locals"])
|
||||
if self.data["frame_locals"]
|
||||
else ""
|
||||
)
|
||||
return f"""Data dependent error.
|
||||
When exporting, we were unable to evaluate the value of `{self.data["expr"]}`.
|
||||
This was encountered {self.data["occurrences"]} times.
|
||||
This occurred at the following user stacktrace: {prettify_stack(self.data["user_stack"], str_to_filename)}
|
||||
{locals_info}
|
||||
And the following framework stacktrace: {prettify_stack(self.data["stack"], str_to_filename)}\n
|
||||
As a result, it was specialized to a constant (e.g. `{self.data["result"]}` in the 1st occurrence), and asserts were inserted into the graph.
|
||||
|
||||
Please add `torch._check(...)` to the original code to assert this data-dependent assumption.
|
||||
Please refer to https://docs.google.com/document/d/1kZ_BbB3JnoLbUZleDT6635dHs88ZVYId8jT-yTFgf3A/edit#heading=h.boi2xurpqa0o for more details.
|
||||
""" # noqa: B950
|
||||
|
||||
elif self.failure_type == FailureType.MISMATCHED_FAKE_KERNEL:
|
||||
op = self.data["op"]
|
||||
reason = self.data["reason"]
|
||||
return f"""Mismatched fake kernel.
|
||||
torch.ops.{op} has a fake kernel implementation, but it has incorrect behavior, based on the real kernel.
|
||||
The reason for the mismatch is: {reason}.
|
||||
|
||||
Please refer to https://docs.google.com/document/d/1_W62p8WJOQQUzPsJYa7s701JXt0qf2OfLub2sbkHOaU/edit#heading=h.ahugy69p2jmz for more detailed instructions on how to write a fake implementation.
|
||||
""" # noqa: B950
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown failure type: {self.failure_type}")
|
||||
|
||||
|
||||
class DraftExportReport:
|
||||
def __init__(
|
||||
self,
|
||||
failures: list[FailureReport],
|
||||
str_to_filename: dict[int, str],
|
||||
expressions_created: dict[int, dict[str, Any]],
|
||||
op_profiles: dict[str, set[OpProfile]],
|
||||
):
|
||||
self.failures: list[FailureReport] = failures
|
||||
self.str_to_filename = str_to_filename
|
||||
self.expressions_created: dict[int, dict[str, Any]] = expressions_created
|
||||
self.op_profiles = op_profiles
|
||||
|
||||
def successful(self) -> bool:
|
||||
return len(self.failures) == 0 or all(
|
||||
failure.xfail for failure in self.failures
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DraftExportReport({self.failures})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
WARNING_COLOR = "\033[93m"
|
||||
GREEN_COLOR = "\033[92m"
|
||||
END_COLOR = "\033[0m"
|
||||
|
||||
if self.successful():
|
||||
return f"""{GREEN_COLOR}
|
||||
##############################################################################################
|
||||
Congratuations: No issues are found during export, and it was able to soundly produce a graph.
|
||||
You can now change back to torch.export.export()
|
||||
##############################################################################################
|
||||
{END_COLOR}"""
|
||||
|
||||
error = f"""{WARNING_COLOR}
|
||||
###################################################################################################
|
||||
WARNING: {len(self.failures)} issue(s) found during export, and it was not able to soundly produce a graph.
|
||||
Please follow the instructions to fix the errors.
|
||||
###################################################################################################
|
||||
|
||||
"""
|
||||
|
||||
for i, failure in enumerate(self.failures):
|
||||
error += f"{i + 1}. {failure.print(self.str_to_filename)}\n"
|
||||
error += END_COLOR
|
||||
return error
|
||||
|
||||
def apply_suggested_fixes(self) -> None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpressionCreatedNode:
|
||||
result_id: int
|
||||
argument_ids: list[int]
|
||||
record: dict[str, object]
|
||||
visited: bool = False
|
||||
|
||||
|
||||
class LogRecord:
|
||||
def __init__(self) -> None:
|
||||
self.log_count: dict[int, int] = {}
|
||||
self.logs: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def _hash(self, element: tuple[str, dict[str, Any]]) -> int:
|
||||
key, data = element
|
||||
|
||||
if key == "missing_fake_kernel":
|
||||
return hash((key, data["op"]))
|
||||
elif key == "mismatched_fake_kernel":
|
||||
return hash((key, data["op"], data["reason"]))
|
||||
elif key == "propagate_real_tensors_provenance":
|
||||
return hash((key, json.dumps(data["user_stack"])))
|
||||
elif key == "guard_added":
|
||||
return hash((key, json.dumps(data["user_stack"])))
|
||||
elif key == "create_unbacked_symbol":
|
||||
return hash((key, json.dumps(data["user_stack"])))
|
||||
|
||||
return hash((key, json.dumps(data)))
|
||||
|
||||
def try_add(self, element: tuple[str, dict[str, str]]) -> bool:
|
||||
hash_value = self._hash(element)
|
||||
if hash_value in self.log_count:
|
||||
self.log_count[hash_value] += 1
|
||||
return False
|
||||
|
||||
self.log_count[hash_value] = 1
|
||||
self.logs.append(element)
|
||||
return True
|
||||
|
||||
def get_log_count(self, element: tuple[str, dict[str, Any]]) -> int:
|
||||
return self.log_count[self._hash(element)]
|
||||
|
||||
|
||||
class CaptureStructuredTrace(torch._logging._internal.LazyTraceHandler):
|
||||
def __init__(self) -> None:
|
||||
self.specific_log_keys = [
|
||||
"str",
|
||||
"exported_program",
|
||||
"propagate_real_tensors_provenance",
|
||||
"guard_added",
|
||||
"missing_fake_kernel",
|
||||
"mismatched_fake_kernel",
|
||||
"expression_created",
|
||||
"create_unbacked_symbol",
|
||||
]
|
||||
self.log_record: LogRecord = LogRecord()
|
||||
self.expression_created_logs: dict[int, ExpressionCreatedNode] = {}
|
||||
self.symbol_to_expressions: dict[str, list[dict[str, Any]]] = {}
|
||||
self.logger = logging.getLogger("torch.__trace")
|
||||
self.prev_get_dtrace = False
|
||||
|
||||
if root_dir := os.environ.get(torch._logging._internal.DTRACE_ENV_VAR):
|
||||
super().__init__(root_dir)
|
||||
else:
|
||||
sanitized_username = re.sub(r'[\\/:*?"<>|]', "_", getpass.getuser())
|
||||
root_dir = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
"export_" + sanitized_username,
|
||||
)
|
||||
super().__init__(root_dir)
|
||||
|
||||
self.setFormatter(torch._logging._internal.TorchLogsFormatter(trace=True))
|
||||
|
||||
def __enter__(self) -> "CaptureStructuredTrace":
|
||||
self.log_record = LogRecord()
|
||||
self.expression_created_logs = {}
|
||||
|
||||
# Remove the lazy trace handler if it exists
|
||||
possible_lazy_trace_handlers = [
|
||||
handler
|
||||
for handler in self.logger.handlers
|
||||
if isinstance(handler, torch._logging._internal.LazyTraceHandler)
|
||||
]
|
||||
for handler in possible_lazy_trace_handlers:
|
||||
self.logger.removeHandler(handler)
|
||||
|
||||
self.logger.addHandler(self)
|
||||
self.prev_get_dtrace = torch._logging._internal.GET_DTRACE_STRUCTURED
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
torch._logging._internal.GET_DTRACE_STRUCTURED = True
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[no-untyped-def]
|
||||
self.log_record = LogRecord()
|
||||
self.expression_created_logs = {}
|
||||
self.logger.removeHandler(self)
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
torch._logging._internal.GET_DTRACE_STRUCTURED = self.prev_get_dtrace
|
||||
self.prev_get_dtrace = False
|
||||
|
||||
def emit(self, record: Any) -> None:
|
||||
def _log_expression_created(
|
||||
emit_func: Callable[[Any], None], sym_node_id: int
|
||||
) -> None:
|
||||
# Log all the relevant expression_created logs
|
||||
if sym_node_id is None:
|
||||
return
|
||||
if res := self.expression_created_logs.get(sym_node_id, None):
|
||||
# Don't log the expression if we have already
|
||||
# printed it beforehand
|
||||
if not res.visited:
|
||||
res.visited = True
|
||||
for arg in res.argument_ids:
|
||||
_log_expression_created(emit_func, arg)
|
||||
|
||||
emit_func(res.record)
|
||||
|
||||
metadata = record.metadata
|
||||
for key in self.specific_log_keys:
|
||||
if key in metadata:
|
||||
if self.log_record.try_add((key, metadata[key])):
|
||||
if key == "expression_created":
|
||||
# We don't want to log all expression_created logs, only
|
||||
# the ones that are relevant to the
|
||||
# guards/propagate_real_tensor
|
||||
self.expression_created_logs[metadata[key]["result_id"]] = (
|
||||
ExpressionCreatedNode(
|
||||
metadata[key]["result_id"],
|
||||
metadata[key].get("argument_ids", []),
|
||||
record,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
elif key == "propagate_real_tensors_provenance":
|
||||
_log_expression_created(
|
||||
super().emit, metadata[key].get("expr_node_id")
|
||||
)
|
||||
|
||||
elif key == "guard_added":
|
||||
if len(metadata[key]["symbol_to_sources"]) == 0:
|
||||
# We only want to include guards added that are relevant to
|
||||
# the symbolic shapes corresponding to the inputs which were
|
||||
# specified in the dynamic_shapes arg. These have a source.
|
||||
return
|
||||
elif metadata[key]["prefix"] == "runtime_assert":
|
||||
# This should've been captured by a
|
||||
# propagate_real_tensors log
|
||||
return
|
||||
|
||||
_log_expression_created(
|
||||
super().emit, metadata[key].get("expr_node_id")
|
||||
)
|
||||
|
||||
super().emit(record)
|
||||
|
||||
|
||||
def draft_export(
|
||||
mod: torch.nn.Module,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
dynamic_shapes: dict[str, Any] | tuple[Any] | list[Any] | None = None,
|
||||
preserve_module_call_signature: tuple[str, ...] = (),
|
||||
strict: bool = False,
|
||||
pre_dispatch: bool = True,
|
||||
prefer_deferred_runtime_asserts_over_guards: bool = False,
|
||||
) -> ExportedProgram:
|
||||
start_time = time.time()
|
||||
kwargs = kwargs or {}
|
||||
dynamic_shapes = dynamic_shapes or {}
|
||||
|
||||
constraint_violation_msg = None
|
||||
capture_structured_log = CaptureStructuredTrace()
|
||||
|
||||
with (
|
||||
torch._functorch.config.patch(
|
||||
fake_tensor_propagate_real_tensors=True,
|
||||
generate_fake_kernels_from_real_mismatches=True,
|
||||
),
|
||||
capture_structured_log,
|
||||
):
|
||||
try:
|
||||
new_shapes = None
|
||||
ep = _export(
|
||||
mod,
|
||||
args,
|
||||
kwargs,
|
||||
dynamic_shapes=dynamic_shapes,
|
||||
strict=strict,
|
||||
pre_dispatch=pre_dispatch,
|
||||
preserve_module_call_signature=preserve_module_call_signature,
|
||||
prefer_deferred_runtime_asserts_over_guards=prefer_deferred_runtime_asserts_over_guards,
|
||||
)
|
||||
except Exception as exc:
|
||||
if (
|
||||
isinstance(exc, UserError)
|
||||
and exc.error_type == UserErrorType.CONSTRAINT_VIOLATION
|
||||
):
|
||||
constraint_violation_msg = exc.msg
|
||||
|
||||
def convert_dim_to_auto(dim: Any) -> Any:
|
||||
if isinstance(dim, Dim):
|
||||
return Dim.AUTO(min=dim.min, max=dim.max)
|
||||
elif isinstance(dim, _DimHint) and dim.type == _DimHintType.DYNAMIC:
|
||||
return Dim.AUTO(min=dim.min, max=dim.max)
|
||||
return dim
|
||||
|
||||
new_shapes = pytree.tree_map(convert_dim_to_auto, dynamic_shapes)
|
||||
ep = _export(
|
||||
mod,
|
||||
args,
|
||||
kwargs,
|
||||
dynamic_shapes=new_shapes,
|
||||
strict=strict,
|
||||
pre_dispatch=pre_dispatch,
|
||||
preserve_module_call_signature=preserve_module_call_signature,
|
||||
prefer_deferred_runtime_asserts_over_guards=prefer_deferred_runtime_asserts_over_guards,
|
||||
)
|
||||
else:
|
||||
log_draft_export_usage(
|
||||
error=True,
|
||||
export_time=time.time() - start_time,
|
||||
strict=strict,
|
||||
message=str(exc),
|
||||
type=f"{type(exc).__name__}.{type(exc).__qualname__}",
|
||||
)
|
||||
raise exc
|
||||
|
||||
torch._logging.dtrace_structured("exported_program", payload_fn=lambda: str(ep))
|
||||
|
||||
str_to_filename: dict[int, str] = {}
|
||||
failures: list[FailureReport] = []
|
||||
incorrect_custom_ops: set[str] = set()
|
||||
expressions_created: dict[int, dict[str, Any]] = {}
|
||||
|
||||
for log_name, log_contents in capture_structured_log.log_record.logs:
|
||||
failure_type = None
|
||||
|
||||
if log_name == "str":
|
||||
str_to_filename[log_contents[1]] = log_contents[0] # type: ignore[index]
|
||||
continue
|
||||
|
||||
elif log_name == "propagate_real_tensors_provenance":
|
||||
log_contents["occurrences"] = (
|
||||
capture_structured_log.log_record.get_log_count(
|
||||
(log_name, log_contents)
|
||||
)
|
||||
)
|
||||
|
||||
failure_type = FailureType.DATA_DEPENDENT_ERROR
|
||||
|
||||
elif log_name == "guard_added":
|
||||
if new_shapes is None:
|
||||
continue
|
||||
|
||||
failure_type = FailureType.GUARD_ADDED
|
||||
log_contents["new_dynamic_shapes"] = new_shapes
|
||||
elif log_name == "missing_fake_kernel":
|
||||
failure_type = FailureType.MISSING_FAKE_KERNEL
|
||||
incorrect_custom_ops.add(log_contents["op"])
|
||||
|
||||
elif log_name == "mismatched_fake_kernel":
|
||||
failure_type = FailureType.MISMATCHED_FAKE_KERNEL
|
||||
incorrect_custom_ops.add(log_contents["op"])
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
if failure_type is None:
|
||||
raise AssertionError("failure_type cannot be None at this point")
|
||||
failures.append(
|
||||
FailureReport(
|
||||
failure_type,
|
||||
log_contents,
|
||||
)
|
||||
)
|
||||
|
||||
for k, v in capture_structured_log.expression_created_logs.items():
|
||||
if v.visited:
|
||||
expressions_created[k] = v.record
|
||||
|
||||
op_profiles = get_op_profiles(ep.graph_module, incorrect_custom_ops)
|
||||
report = DraftExportReport(
|
||||
failures, str_to_filename, expressions_created, op_profiles
|
||||
)
|
||||
|
||||
# Add asserts around custom ops
|
||||
insert_custom_op_guards(ep.graph_module, incorrect_custom_ops)
|
||||
|
||||
ep._report = report
|
||||
if not report.successful():
|
||||
log_filename = capture_structured_log.stream.name
|
||||
|
||||
warning_msg = f"""
|
||||
###################################################################################################
|
||||
WARNING: {len(report.failures)} issue(s) found during export, and it was not able to soundly produce a graph.
|
||||
To view the report of failures in an html page, please run the command:
|
||||
`tlparse {log_filename} --export`
|
||||
Or, you can view the errors in python by inspecting `print(ep._report)`.
|
||||
"""
|
||||
|
||||
if len(report.op_profiles) > 0:
|
||||
warning_msg += f"""
|
||||
While tracing we found {len(report.op_profiles)} operator(s) which do not have a fake kernel registered.
|
||||
If you intend to retrace the exported graph or run it with fake tensors, please run it under the
|
||||
following context manager, which will register a fake kernel for those operators.
|
||||
```
|
||||
with torch._library.fake_profile.unsafe_generate_fake_kernels(ep._report.op_profiles):
|
||||
# run with fake tensors
|
||||
```
|
||||
"""
|
||||
|
||||
warning_msg += """#################################################################################################"""
|
||||
|
||||
log.warning(warning_msg)
|
||||
|
||||
else:
|
||||
log.info(
|
||||
"""
|
||||
##############################################################################################
|
||||
Congratuations: No issues are found during export, and it was able to soundly produce a graph.
|
||||
You can now change back to torch.export.export()
|
||||
##############################################################################################
|
||||
"""
|
||||
)
|
||||
|
||||
log_draft_export_usage(
|
||||
error=False,
|
||||
export_time=time.time() - start_time,
|
||||
strict=strict,
|
||||
constraint_violations=constraint_violation_msg,
|
||||
report=ep._report,
|
||||
**get_ep_stats(ep),
|
||||
)
|
||||
return ep
|
||||
@@ -0,0 +1,113 @@
|
||||
import gc
|
||||
import types
|
||||
import typing
|
||||
import weakref
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
from torch.fx.experimental.symbolic_shapes import TrackedFake
|
||||
|
||||
|
||||
"""
|
||||
These functions are used to detect potential fake tensor leakage when using PT2 export.
|
||||
See NOTE [export non-strict fake tensor leak detection]
|
||||
|
||||
There are some complications that made this logic overly complicated:
|
||||
1) Python 3.10 and Python 3.12 have different ways of implementing referrer so
|
||||
we need to account for whether it is ref.__dict__ or the real ref object
|
||||
|
||||
2) There are some internal PT2 references to fake tensors like `TrackedFake`
|
||||
3) closures, generators, and bound methods can hold fake tensors.
|
||||
4) global object can hold onto a fake tensor
|
||||
|
||||
In general, these utils are our last resort to detect fake tensors. if the leak happens
|
||||
within the model attributes, we have a separate mechanism to detect. This tool relies a bit
|
||||
on garbage collector internal details, so I think it is unsafe to turn on by default, hence
|
||||
this tool should be used as debugging tool.
|
||||
"""
|
||||
|
||||
|
||||
# Things we never want to flag as leaks
|
||||
_SKIP_TYPES = (
|
||||
types.FrameType,
|
||||
types.ModuleType,
|
||||
)
|
||||
|
||||
|
||||
def _is_globals_or_locals(obj: typing.Any) -> bool:
|
||||
# These comparisons only make sense within this frame; still cheap to check.
|
||||
return obj is globals() or obj is locals()
|
||||
|
||||
|
||||
def _is_tracked_fake(obj: typing.Any) -> TypeIs[TrackedFake]:
|
||||
return isinstance(obj, TrackedFake)
|
||||
|
||||
|
||||
def _is_gm_meta_like_dict(d: dict, o: typing.Any) -> bool:
|
||||
# Hope gm.meta was a custom dict we can assert on
|
||||
return d.get("val") is o
|
||||
|
||||
|
||||
def _dict_is_attr_of_tracked_fake(d: dict) -> bool:
|
||||
"""
|
||||
Python 3.10 quirk: sometimes the referrer is obj.__dict__ instead of obj.
|
||||
Check if this dict is exactly the __dict__ of a TrackedFake.
|
||||
"""
|
||||
for parent in gc.get_referrers(d):
|
||||
if (
|
||||
hasattr(parent, "__dict__")
|
||||
and parent.__dict__ is d
|
||||
and _is_tracked_fake(parent)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_legit_leaks_from_referrers(active_fakes: weakref.WeakSet) -> weakref.WeakSet:
|
||||
legit_leak: weakref.WeakSet = weakref.WeakSet()
|
||||
|
||||
# This is so that we don't falsely flag generator to be holding fake tensor
|
||||
fake_list = list(active_fakes)
|
||||
fake_list_id = id(fake_list)
|
||||
|
||||
for act in fake_list:
|
||||
# Track by id to avoid processing duplicate referrers
|
||||
seen = set()
|
||||
# Assume it's a leak unless we find only ignorable referrers
|
||||
flagged = False
|
||||
|
||||
for r in gc.get_referrers(act):
|
||||
rid = id(r)
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
|
||||
# Skip our own fake_list
|
||||
if rid == fake_list_id:
|
||||
continue
|
||||
|
||||
# Fast-path: skip obvious non-owners
|
||||
if _is_globals_or_locals(r):
|
||||
continue
|
||||
if isinstance(r, _SKIP_TYPES):
|
||||
continue
|
||||
if _is_tracked_fake(r):
|
||||
# TrackedFake should be ignored
|
||||
continue
|
||||
|
||||
# Handle dicts carefully (Python 3.10 sometimes shows __dict__)
|
||||
if isinstance(r, dict):
|
||||
if _is_gm_meta_like_dict(r, act):
|
||||
continue
|
||||
if _dict_is_attr_of_tracked_fake(r):
|
||||
continue
|
||||
flagged = True
|
||||
break
|
||||
|
||||
# Any other referrer we don't explicitly whitelist counts as a leak
|
||||
flagged = True
|
||||
break
|
||||
|
||||
if flagged:
|
||||
legit_leak.add(act)
|
||||
|
||||
return legit_leak
|
||||
@@ -0,0 +1,333 @@
|
||||
import contextlib
|
||||
from collections.abc import Generator
|
||||
|
||||
import torch
|
||||
from torch._decomp import global_decomposition_table
|
||||
from torch._decomp.decompositions import _rnn_helper, gather_params, gru_cell, lstm_cell
|
||||
from torch._higher_order_ops.while_loop import while_loop
|
||||
|
||||
|
||||
def one_layer_while_loop_lstm(inp, hidden, params, has_biases, reverse=False):
|
||||
"""
|
||||
1 layer fn for while loop LSTM
|
||||
|
||||
Args:
|
||||
inp: Input tensor of shape (seq_len, batch, input_size)
|
||||
hidden: Tuple of (hx, cx) hidden states
|
||||
params: List of weight and bias tensors
|
||||
has_biases: Whether biases are included
|
||||
reverse: Whether to process sequence in reverse
|
||||
|
||||
Returns:
|
||||
Tuple of (output, (final_hx, final_cx))
|
||||
"""
|
||||
ih_weight = params[0]
|
||||
hh_weight = params[1]
|
||||
ih_bias = params[2] if has_biases else None
|
||||
hh_bias = params[3] if has_biases else None
|
||||
hr_weight = (
|
||||
params[4] if len(params) == 5 else params[2] if len(params) == 3 else None
|
||||
)
|
||||
|
||||
hx = hidden[0].unsqueeze(0)
|
||||
cx = hidden[1].unsqueeze(0)
|
||||
|
||||
precomputed_input = torch.nn.functional.linear(inp, ih_weight, ih_bias)
|
||||
precomputed_input = precomputed_input.flip(0) if reverse else precomputed_input
|
||||
|
||||
# while loop rewrite
|
||||
step_output = torch.empty(
|
||||
precomputed_input.size(0),
|
||||
*tuple(hx.shape[1:]),
|
||||
dtype=hx.dtype,
|
||||
device=hx.device,
|
||||
)
|
||||
|
||||
def cond_fn(i, out, hx, cx):
|
||||
return i < precomputed_input.size(0)
|
||||
|
||||
def body_fn(idx, out, hx, cx):
|
||||
# Extract the integer value from idx and constrain it for data-dependent indexing
|
||||
i = idx.item()
|
||||
torch._check_is_size(i)
|
||||
torch._check_is_size(i, max=precomputed_input.size(0) - 1)
|
||||
hx, cx = lstm_cell(
|
||||
precomputed_input[i], hx, cx, hh_weight, hh_bias, hr_weight, chunk_dim=2
|
||||
)
|
||||
out = out.clone()
|
||||
# Squeeze the first dimension before storing (lstm_cell preserves the unsqueezed dim)
|
||||
out[i] = hx.squeeze(0)
|
||||
return idx + 1, out, hx, cx
|
||||
|
||||
cnt = torch.tensor(0, dtype=torch.int64)
|
||||
_, out, final_hx, final_cx = while_loop(
|
||||
cond_fn, body_fn, [cnt, step_output, hx, cx]
|
||||
)
|
||||
if reverse:
|
||||
out = out.flip(0)
|
||||
|
||||
# Use squeeze(1) to match original implementation
|
||||
return out, (final_hx.squeeze(1), final_cx.squeeze(1))
|
||||
|
||||
|
||||
def lstm_while_loop_impl(
|
||||
input,
|
||||
hx,
|
||||
params,
|
||||
has_biases,
|
||||
num_layers,
|
||||
dropout,
|
||||
train,
|
||||
bidirectional,
|
||||
batch_first,
|
||||
):
|
||||
"""
|
||||
LSTM implementation using while_loop for export compatibility.
|
||||
|
||||
This is a drop-in replacement for the default LSTM decomposition that uses
|
||||
while_loop instead of Python loops, making it more suitable for torch.export.
|
||||
|
||||
Args:
|
||||
input: Input tensor
|
||||
hx: Tuple of (h0, c0) hidden states
|
||||
params: List of weight and bias tensors
|
||||
has_biases: Whether biases are included
|
||||
num_layers: Number of LSTM layers
|
||||
dropout: Dropout probability
|
||||
train: Training mode
|
||||
bidirectional: Whether to use bidirectional LSTM
|
||||
batch_first: Whether batch dimension is first
|
||||
|
||||
Returns:
|
||||
Tuple of (output, h_n, c_n)
|
||||
"""
|
||||
if len(hx) != 2:
|
||||
raise AssertionError("lstm expects two hidden states")
|
||||
params = gather_params(params, has_biases, hx[0].size(2) != hx[1].size(2))
|
||||
hidden = list(zip(hx[0], hx[1]))
|
||||
layer_fn = one_layer_while_loop_lstm
|
||||
out, final_hiddens = _rnn_helper(
|
||||
input,
|
||||
hidden,
|
||||
params,
|
||||
has_biases,
|
||||
num_layers,
|
||||
dropout,
|
||||
train,
|
||||
bidirectional,
|
||||
batch_first,
|
||||
layer_fn,
|
||||
)
|
||||
final_hiddens = list(zip(*final_hiddens))
|
||||
return out, torch.stack(final_hiddens[0], 0), torch.stack(final_hiddens[1], 0)
|
||||
|
||||
|
||||
def one_layer_while_loop_gru(inp, hidden, params, has_biases, reverse=False):
|
||||
"""
|
||||
1 layer fn for while loop GRU
|
||||
|
||||
Args:
|
||||
inp: Input tensor of shape (seq_len, batch, input_size)
|
||||
hidden: Hidden state tensor
|
||||
params: List of weight and bias tensors
|
||||
has_biases: Whether biases are included
|
||||
reverse: Whether to process sequence in reverse
|
||||
|
||||
Returns:
|
||||
Tuple of (output, final_hidden)
|
||||
"""
|
||||
ih_weight = params[0]
|
||||
hh_weight = params[1]
|
||||
ih_bias = params[2] if has_biases else None
|
||||
hh_bias = params[3] if has_biases else None
|
||||
|
||||
precomputed_input = torch.nn.functional.linear(inp, ih_weight, ih_bias)
|
||||
precomputed_input = precomputed_input.flip(0) if reverse else precomputed_input
|
||||
cur_hidden = hidden.unsqueeze(0)
|
||||
|
||||
# while loop rewrite
|
||||
step_output = torch.empty(
|
||||
precomputed_input.size(0),
|
||||
*tuple(cur_hidden.shape[1:]),
|
||||
dtype=cur_hidden.dtype,
|
||||
device=cur_hidden.device,
|
||||
)
|
||||
|
||||
def cond_fn(i, out, cur_hidden):
|
||||
return i < precomputed_input.size(0)
|
||||
|
||||
def body_fn(idx, out, cur_hidden):
|
||||
# Extract the integer value from idx and constrain it for data-dependent indexing
|
||||
i = idx.item()
|
||||
torch._check_is_size(i)
|
||||
torch._check_is_size(i, max=precomputed_input.size(0) - 1)
|
||||
cur_hidden = gru_cell(
|
||||
precomputed_input[i], cur_hidden, ih_weight, ih_bias, hh_weight, hh_bias
|
||||
)
|
||||
out = out.clone()
|
||||
out[i] = cur_hidden.squeeze(0)
|
||||
return idx + 1, out, cur_hidden
|
||||
|
||||
cnt = torch.tensor(0, dtype=torch.int64)
|
||||
_, out, final_hidden = while_loop(cond_fn, body_fn, [cnt, step_output, cur_hidden])
|
||||
if reverse:
|
||||
out = out.flip(0)
|
||||
|
||||
return out, final_hidden.squeeze(0)
|
||||
|
||||
|
||||
def gru_while_loop_impl(
|
||||
input,
|
||||
hx,
|
||||
params,
|
||||
has_biases,
|
||||
num_layers,
|
||||
dropout,
|
||||
train,
|
||||
bidirectional,
|
||||
batch_first,
|
||||
):
|
||||
"""
|
||||
GRU implementation using while_loop for export compatibility.
|
||||
|
||||
This is a drop-in replacement for the default GRU decomposition that uses
|
||||
while_loop instead of Python loops, making it more suitable for torch.export.
|
||||
|
||||
Args:
|
||||
input: Input tensor
|
||||
hx: Hidden state tensor
|
||||
params: List of weight and bias tensors
|
||||
has_biases: Whether biases are included
|
||||
num_layers: Number of GRU layers
|
||||
dropout: Dropout probability
|
||||
train: Training mode
|
||||
bidirectional: Whether to use bidirectional GRU
|
||||
batch_first: Whether batch dimension is first
|
||||
|
||||
Returns:
|
||||
Tuple of (output, h_n)
|
||||
"""
|
||||
params = gather_params(params, has_biases, False)
|
||||
hidden = list(hx.unbind(0))
|
||||
layer_fn = one_layer_while_loop_gru
|
||||
out, final_hiddens = _rnn_helper(
|
||||
input,
|
||||
hidden,
|
||||
params,
|
||||
has_biases,
|
||||
num_layers,
|
||||
dropout,
|
||||
train,
|
||||
bidirectional,
|
||||
batch_first,
|
||||
layer_fn,
|
||||
)
|
||||
return out, torch.stack(final_hiddens, 0)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _register_rnn_while_loop_decomposition(
|
||||
rnn_op, rnn_impl
|
||||
) -> Generator[None, None, None]:
|
||||
"""
|
||||
Generic context manager for registering while_loop-based RNN decompositions.
|
||||
|
||||
Args:
|
||||
rnn_op: The aten operation to patch (e.g., torch.ops.aten.lstm.input)
|
||||
rnn_impl: The while_loop-based implementation function
|
||||
|
||||
Note:
|
||||
This is an internal helper. Use register_lstm_while_loop_decomposition()
|
||||
or register_gru_while_loop_decomposition() instead.
|
||||
"""
|
||||
registry = global_decomposition_table["post_autograd"]
|
||||
|
||||
# Save the original decomposition if it exists
|
||||
original_decomp = registry.get(rnn_op, None)
|
||||
|
||||
# Save the original py_kernel if it exists
|
||||
original_py_kernel = rnn_op.py_kernels.get(
|
||||
torch._C.DispatchKey.CompositeImplicitAutograd, None
|
||||
)
|
||||
|
||||
try:
|
||||
# Register our while_loop-based implementation
|
||||
registry[rnn_op] = rnn_impl
|
||||
rnn_op.py_kernels[torch._C.DispatchKey.CompositeImplicitAutograd] = rnn_impl
|
||||
yield
|
||||
finally:
|
||||
# Restore the original decomposition
|
||||
if original_decomp is not None:
|
||||
registry[rnn_op] = original_decomp
|
||||
else:
|
||||
# If there was no original, remove our registration
|
||||
registry.pop(rnn_op, None)
|
||||
|
||||
# Restore the original py_kernel
|
||||
if original_py_kernel is not None:
|
||||
rnn_op.py_kernels[torch._C.DispatchKey.CompositeImplicitAutograd] = (
|
||||
original_py_kernel
|
||||
)
|
||||
else:
|
||||
# If there was no original, remove our registration
|
||||
rnn_op.py_kernels.pop(torch._C.DispatchKey.CompositeImplicitAutograd, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def register_lstm_while_loop_decomposition() -> Generator[None, None, None]:
|
||||
"""
|
||||
Context manager that temporarily registers the while_loop-based LSTM decomposition.
|
||||
|
||||
The while_loop-based decomposition is more suitable for export and graph-based
|
||||
execution, as it avoids Python control flow that cannot be captured in the graph.
|
||||
This should support dynamic sequence lengths, however as while_loop does not
|
||||
support Autograd yet, an ExportedProgram created with this will not be trainable.
|
||||
|
||||
Usage::
|
||||
|
||||
from torch.export._patches import register_lstm_while_loop_decomposition
|
||||
from torch.export import export
|
||||
|
||||
with register_lstm_while_loop_decomposition():
|
||||
# Export your model with LSTM
|
||||
ep = export(model, (x, h0, c0))
|
||||
|
||||
Note:
|
||||
This context manager temporarily modifies the global decomposition table
|
||||
and py_kernels registration. The original registrations are restored when
|
||||
exiting the context.
|
||||
"""
|
||||
with _register_rnn_while_loop_decomposition(
|
||||
torch.ops.aten.lstm.input, lstm_while_loop_impl
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def register_gru_while_loop_decomposition() -> Generator[None, None, None]:
|
||||
"""
|
||||
Context manager that temporarily registers the while_loop-based GRU decomposition.
|
||||
|
||||
The while_loop-based decomposition is more suitable for export and graph-based
|
||||
execution, as it avoids Python control flow that cannot be captured in the graph.
|
||||
This should support dynamic sequence lengths, however as while_loop does not
|
||||
support Autograd yet, an ExportedProgram created with this will not be trainable.
|
||||
|
||||
Usage::
|
||||
|
||||
from torch.export._patches import register_gru_while_loop_decomposition
|
||||
from torch.export import export
|
||||
|
||||
with register_gru_while_loop_decomposition():
|
||||
# Export your model with GRU
|
||||
ep = export(model, (x, h0))
|
||||
|
||||
Note:
|
||||
This context manager temporarily modifies the global decomposition table
|
||||
and py_kernels registration. The original registrations are restored when
|
||||
exiting the context.
|
||||
"""
|
||||
with _register_rnn_while_loop_decomposition(
|
||||
torch.ops.aten.gru.input, gru_while_loop_impl
|
||||
):
|
||||
yield
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
|
||||
import torch
|
||||
from torch._higher_order_ops.auto_functionalize import (
|
||||
auto_functionalized,
|
||||
auto_functionalized_v2,
|
||||
)
|
||||
from torch._inductor.fx_passes.post_grad import decompose_auto_functionalized
|
||||
from torch.export import ExportedProgram
|
||||
from torch.fx import Graph
|
||||
|
||||
|
||||
def remove_self_clone(graph: Graph) -> None:
|
||||
for node in graph.nodes:
|
||||
if node.target is torch.ops.aten.copy_.default and node.args[0] == node.args[1]:
|
||||
node.replace_all_uses_with(node.args[0])
|
||||
graph.erase_node(node)
|
||||
|
||||
|
||||
def unsafe_remove_auto_functionalized_pass(
|
||||
ep: ExportedProgram,
|
||||
) -> ExportedProgram:
|
||||
"""
|
||||
This pass removes an instances of the higher order op 'auto_functionalized',
|
||||
and modifies the calling EP inplace to have the original mutator op.
|
||||
This pass doesn't perform safety checks to make sure that this inplace mutation is safe.
|
||||
"""
|
||||
|
||||
with ep.graph_module._set_replace_hook(ep.graph_signature.get_replace_hook()):
|
||||
for module in ep.graph_module.modules():
|
||||
if not isinstance(module, torch.fx.GraphModule):
|
||||
continue
|
||||
for node in ep.graph.nodes:
|
||||
if (
|
||||
node.op == "call_function" and node.target is auto_functionalized
|
||||
) or (
|
||||
node.op == "call_function" and node.target is auto_functionalized_v2
|
||||
):
|
||||
func = node.args[0]
|
||||
if not isinstance(func, torch._ops.OpOverload):
|
||||
raise AssertionError(
|
||||
f"Expected func to be an OpOverload, but got {type(func)}"
|
||||
)
|
||||
# re-inplace everything
|
||||
node.meta["only_clone_these_tensors"] = []
|
||||
decompose_auto_functionalized(ep.graph)
|
||||
remove_self_clone(ep.graph)
|
||||
ep.graph.eliminate_dead_code()
|
||||
|
||||
return ep
|
||||
@@ -0,0 +1,237 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import operator
|
||||
|
||||
import torch
|
||||
from torch._higher_order_ops.effects import _get_schema, with_effects
|
||||
|
||||
from .exported_program import ExportedProgram
|
||||
from .graph_signature import (
|
||||
CustomObjArgument,
|
||||
InputKind,
|
||||
InputSpec,
|
||||
OutputKind,
|
||||
OutputSpec,
|
||||
TokenArgument,
|
||||
)
|
||||
|
||||
|
||||
def _get_custom_obj_for_node(node, inputs_to_lifted_custom_objs, constants):
|
||||
"""Extract the custom object from a node's arguments."""
|
||||
custom_obj_node = node
|
||||
custom_obj_meta = custom_obj_node.meta["val"] # type: ignore[union-attr]
|
||||
if not isinstance(custom_obj_meta, CustomObjArgument):
|
||||
raise AssertionError(
|
||||
f"Expected custom_obj_meta to be a CustomObjArgument, but got {type(custom_obj_meta)}"
|
||||
)
|
||||
|
||||
if custom_obj_meta.fake_val:
|
||||
return custom_obj_meta.fake_val
|
||||
elif custom_obj_node.name in inputs_to_lifted_custom_objs: # type: ignore[union-attr]
|
||||
return constants[inputs_to_lifted_custom_objs[custom_obj_node.name]] # type: ignore[union-attr]
|
||||
else:
|
||||
raise RuntimeError(f"Unable to find custom obj for node {node}")
|
||||
|
||||
|
||||
def _replace_with_effects_node(
|
||||
node, ep, inputs_to_lifted_custom_objs, output_tokens, input_tokens, module
|
||||
):
|
||||
"""Replace a with_effects node with the underlying function call."""
|
||||
# Get the input nodes
|
||||
token_node, func, *node_args = node.args
|
||||
if token_node.op == "placeholder":
|
||||
input_tokens.append(token_node)
|
||||
|
||||
if not isinstance(func, (torch._ops.OpOverload, torch._ops.HigherOrderOperator)):
|
||||
raise AssertionError(
|
||||
f"Expected func to be an OpOverload or HigherOrderOperator, but got {type(func)}"
|
||||
)
|
||||
|
||||
# Get the schema for the function
|
||||
if func is torch.ops.higher_order.call_torchbind:
|
||||
custom_obj = _get_custom_obj_for_node(
|
||||
node_args[0], inputs_to_lifted_custom_objs, ep.constants
|
||||
)
|
||||
schema = _get_schema(func, [custom_obj] + node_args[1:])
|
||||
else:
|
||||
schema = _get_schema(func, node_args)
|
||||
|
||||
# Create the replacement node
|
||||
with module.graph.inserting_before(node):
|
||||
new_node = module.graph.call_function(func, tuple(node_args), node.kwargs)
|
||||
|
||||
# Update getitem nodes that extract outputs from with_effects
|
||||
for user in list(node.users.keys()):
|
||||
if user.target is not operator.getitem:
|
||||
raise AssertionError(
|
||||
f"Expected user target to be operator.getitem, but got {user.target}"
|
||||
)
|
||||
# getitem(with_effects, 0) is the token node
|
||||
if user.args[1] == 0:
|
||||
for user_user in list(user.users.keys()):
|
||||
if user_user.op == "output":
|
||||
output_tokens.append(user)
|
||||
|
||||
# Copy metadata from old node to new node
|
||||
for k, v in node.meta.items():
|
||||
new_node.meta[k] = v
|
||||
if k == "unbacked_bindings":
|
||||
# Remove the extra layer for effect token
|
||||
old_bindings = new_node.meta[k]
|
||||
new_bindings = {
|
||||
k: path[1:] if path else path for k, path in old_bindings.items()
|
||||
}
|
||||
new_node.meta[k] = new_bindings
|
||||
|
||||
# Fix up the getitem nodes based on return count
|
||||
if len(schema.returns) == 1:
|
||||
# Single return: replace getitem(with_effects, 1) with the node itself
|
||||
for user in list(node.users.keys()):
|
||||
if user.args[1] == 1:
|
||||
user.replace_all_uses_with(new_node)
|
||||
new_node.meta["val"] = node.meta["val"][1]
|
||||
elif len(schema.returns) > 1:
|
||||
# Multiple returns: shift getitem indices down by 1
|
||||
for user in list(node.users.keys()):
|
||||
if user.args[1] >= 1:
|
||||
user.args = (new_node, user.args[1] - 1)
|
||||
new_node.meta["val"] = node.meta["val"][1:]
|
||||
else:
|
||||
# No returns
|
||||
if len(schema.returns) != 0:
|
||||
raise AssertionError(
|
||||
f"Expected schema.returns to be empty, but got {len(schema.returns)} returns"
|
||||
)
|
||||
if len(new_node.users) != 0:
|
||||
raise AssertionError(
|
||||
f"Expected new_node to have no users, but got {len(new_node.users)} users"
|
||||
)
|
||||
new_node.meta["val"] = None
|
||||
|
||||
|
||||
def _replace_invoke_subgraph_node(node, module, output_tokens, input_tokens):
|
||||
"""Replace an invoke_subgraph node to remove the token argument."""
|
||||
if node.args[0].op != "get_attr":
|
||||
raise AssertionError(
|
||||
f"Expected node.args[0].op to be 'get_attr', but got {node.args[0].op}"
|
||||
)
|
||||
submod = getattr(module, node.args[0].target)
|
||||
if not submod.meta.get("has_with_effects", False):
|
||||
return
|
||||
|
||||
# Remove token from inputs
|
||||
subgraph, identifier, token, *operands = node.args
|
||||
node.args = (subgraph, identifier, *operands)
|
||||
if token.op == "placeholder":
|
||||
input_tokens.append(token)
|
||||
|
||||
# Update getitem nodes to account for removed token output
|
||||
for user in list(node.users.keys()):
|
||||
if user.args[1] >= 1:
|
||||
user.args = (node, user.args[1] - 1)
|
||||
elif user.args[1] == 0:
|
||||
for user_user in list(user.users.keys()):
|
||||
if user_user.op == "output":
|
||||
output_tokens.append(user)
|
||||
|
||||
|
||||
def _remove_effect_tokens(ep: ExportedProgram) -> ExportedProgram:
|
||||
"""
|
||||
Removes the existence of tokens from the exported program, including:
|
||||
- Removes the input and output tokens
|
||||
- Replaces with_effects(token, func, args) with just func(args)
|
||||
|
||||
This function does an inplace modification on the given ExportedProgram.
|
||||
"""
|
||||
inputs_to_lifted_custom_objs = ep.graph_signature.inputs_to_lifted_custom_objs
|
||||
|
||||
# mark submodules with effects as having effects. This will be used in the following pass to remove effects from subgraphs
|
||||
for _, module in ep.graph_module.named_modules():
|
||||
if not isinstance(module, torch.fx.GraphModule):
|
||||
continue
|
||||
|
||||
with_effect_nodes = [
|
||||
node for node in module.graph.nodes if node.target is with_effects
|
||||
]
|
||||
if len(with_effect_nodes) > 0:
|
||||
module.meta["has_with_effects"] = True
|
||||
|
||||
# Process each module with the replace hook to ensure graph signature is updated
|
||||
with ep.graph_module._set_replace_hook(ep.graph_signature.get_replace_hook()):
|
||||
for _, module in ep.graph_module.named_modules():
|
||||
if not isinstance(module, torch.fx.GraphModule):
|
||||
continue
|
||||
|
||||
input_tokens = []
|
||||
output_tokens = []
|
||||
|
||||
# Process with_effects and invoke_subgraph nodes
|
||||
for node in module.graph.nodes:
|
||||
if node.target is with_effects:
|
||||
_replace_with_effects_node(
|
||||
node,
|
||||
ep,
|
||||
inputs_to_lifted_custom_objs,
|
||||
output_tokens,
|
||||
input_tokens,
|
||||
module,
|
||||
)
|
||||
elif node.target is torch.ops.higher_order.invoke_subgraph:
|
||||
_replace_invoke_subgraph_node(
|
||||
node, module, output_tokens, input_tokens
|
||||
)
|
||||
|
||||
# Remove tokens from the output node
|
||||
if len(output_tokens) > 0:
|
||||
output_node = next(reversed(module.graph.find_nodes(op="output")))
|
||||
output_args = output_node.args[0]
|
||||
if len(output_args) < len(output_tokens):
|
||||
raise AssertionError(
|
||||
f"{output_args} output arguments found\n"
|
||||
f"{output_tokens} output tokens found\n"
|
||||
f"{module.graph}"
|
||||
)
|
||||
output_node.args = (tuple(output_args[len(output_tokens) :]),)
|
||||
|
||||
module.graph.eliminate_dead_code()
|
||||
|
||||
# Remove tokens from the input placeholders
|
||||
for node in module.graph.nodes:
|
||||
if node.op == "placeholder" and node in input_tokens:
|
||||
module.graph.erase_node(node)
|
||||
|
||||
module.recompile()
|
||||
|
||||
num_tokens: int = 0
|
||||
input_token_names: list[str] = []
|
||||
new_input_specs: list[InputSpec] = []
|
||||
for inp in ep.graph_signature.input_specs:
|
||||
if inp.kind == InputKind.TOKEN:
|
||||
num_tokens += 1
|
||||
if not isinstance(inp.arg, TokenArgument):
|
||||
raise AssertionError(
|
||||
f"Expected inp.arg to be a TokenArgument, but got {type(inp.arg)}"
|
||||
)
|
||||
input_token_names.append(inp.arg.name)
|
||||
else:
|
||||
new_input_specs.append(inp)
|
||||
|
||||
num_out_tokens: int = 0
|
||||
new_output_specs: list[OutputSpec] = []
|
||||
output_token_names: list[OutputSpec] = []
|
||||
for out in ep.graph_signature.output_specs:
|
||||
if out.kind == OutputKind.TOKEN:
|
||||
num_out_tokens += 1
|
||||
output_token_names.append(out.arg.name)
|
||||
else:
|
||||
new_output_specs.append(out)
|
||||
|
||||
# Update graph signature
|
||||
ep.graph_signature.input_specs = new_input_specs
|
||||
ep.graph_signature.output_specs = new_output_specs
|
||||
|
||||
if num_tokens != num_out_tokens:
|
||||
raise AssertionError(
|
||||
f"Number of input tokens ({num_tokens}) does not match output tokens ({num_out_tokens})"
|
||||
)
|
||||
|
||||
return ep
|
||||
@@ -0,0 +1,47 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import torch
|
||||
from torch.fx.experimental.proxy_tensor import ProxyTorchDispatchMode
|
||||
from torch.overrides import TorchFunctionMode
|
||||
|
||||
|
||||
class AutogradStateOpsFailSafeguard(TorchFunctionMode):
|
||||
"""
|
||||
Detect grad state ops during exporting the graph and fail the process by
|
||||
raising an error, to avoid unexpected behavior. Those grad mode ops could be:
|
||||
`torch.no_grad`
|
||||
`torch.enable_grad`
|
||||
`torch.set_grad_enabled`
|
||||
|
||||
Export with predispatch mode is exempted.
|
||||
"""
|
||||
|
||||
def __torch_function__(self, func, types, args=(), kwargs=None):
|
||||
kwargs = kwargs or {}
|
||||
unsupported_grad_mode_ops = [
|
||||
torch._C._set_grad_enabled,
|
||||
]
|
||||
# It's only enabled while tracing, by confirming the torch dispatch mode is
|
||||
# any active PROXY. This is to allow the autograd ops out of tracing.
|
||||
current_state = torch._C.is_grad_enabled()
|
||||
if func in unsupported_grad_mode_ops:
|
||||
if len(args) != 1:
|
||||
raise AssertionError(
|
||||
f"Expected exactly 1 argument for grad mode op, but got {len(args)}"
|
||||
)
|
||||
changed_state = args[0]
|
||||
mode = torch._C._get_dispatch_mode(torch._C._TorchDispatchModeKey.PROXY)
|
||||
# Intend to check if it's not the pre_dispatch mode. It's allowed to use
|
||||
# autograd ops in pre_dispatch mode, e.g. `torch.no_grad`
|
||||
if (
|
||||
mode
|
||||
and isinstance(mode, ProxyTorchDispatchMode)
|
||||
and not mode.pre_dispatch
|
||||
and changed_state != current_state
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Encountered autograd state manager op {func} trying to change global autograd state "
|
||||
"while exporting. This is unsafe because we don't capture this op in torch.export "
|
||||
"today, hence we can't reflect the user intention soundly. You can fix this by "
|
||||
"adding a torch.no_grad() context around the export call."
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
@@ -0,0 +1,187 @@
|
||||
# mypy: allow-untyped-defs
|
||||
"""
|
||||
State dict utilities for torch.export.
|
||||
|
||||
This module provides utilities for restoring state dicts to traced modules,
|
||||
ensuring that FQNs (Fully Qualified Names) match the original module structure.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.fx
|
||||
|
||||
|
||||
def _get_underlying_module(
|
||||
module_or_method: torch.nn.Module | Callable[..., Any],
|
||||
) -> torch.nn.Module:
|
||||
"""Extract the underlying nn.Module from either a module or a bound method.
|
||||
|
||||
Args:
|
||||
module_or_method: Either an nn.Module or a bound method of an nn.Module.
|
||||
|
||||
Returns:
|
||||
The underlying nn.Module.
|
||||
|
||||
Raises:
|
||||
TypeError: If module_or_method is neither an nn.Module nor a bound method.
|
||||
"""
|
||||
if isinstance(module_or_method, torch.nn.Module):
|
||||
return module_or_method
|
||||
# Handle bound methods (e.g., module.method)
|
||||
if (
|
||||
mod_self := getattr(module_or_method, "__self__", None)
|
||||
) is not None and isinstance(mod_self, torch.nn.Module):
|
||||
return mod_self
|
||||
raise TypeError(
|
||||
f"Expected nn.Module or bound method of nn.Module, got {type(module_or_method)}"
|
||||
)
|
||||
|
||||
|
||||
def _clear_traced_params_buffers(
|
||||
traced_module: torch.fx.GraphModule, const_keys: Sequence[str]
|
||||
) -> None:
|
||||
"""Remove all parameters and buffers from traced module before restoring.
|
||||
|
||||
For constants (parameters/buffers that don't need FQN mapping), this function
|
||||
removes them from the _buffers dict and re-assigns them as direct attributes.
|
||||
This ensures constants don't show up as buffers in the state dict.
|
||||
|
||||
Args:
|
||||
traced_module: The traced GraphModule to clean up.
|
||||
const_keys: List of keys that represent constants to be cleared.
|
||||
"""
|
||||
for key in const_keys:
|
||||
if key not in traced_module._buffers:
|
||||
raise AssertionError(f"Key {key} not found in traced_module._buffers")
|
||||
# We don't want constants to show up as a buffer in the state dict.
|
||||
# Instead they should just be a direct attribute.
|
||||
buffer = traced_module._buffers[key]
|
||||
del traced_module._buffers[key]
|
||||
# Note: setattr will register the value per nn.Module rules:
|
||||
# - If it's a Tensor, it'll be re-registered as a buffer (ends up back in _buffers).
|
||||
# - Otherwise, it becomes a plain attribute (not part of state_dict).
|
||||
setattr(traced_module, key, buffer)
|
||||
|
||||
|
||||
def _restore_state_dict(
|
||||
original_module: torch.nn.Module | Callable[..., Any],
|
||||
traced_module: torch.fx.GraphModule,
|
||||
) -> None:
|
||||
"""
|
||||
Restores the state dict of the traced module to match the original module exactly.
|
||||
|
||||
This function ensures that:
|
||||
1. Parameters and buffers in the traced module use the same FQNs (Fully Qualified Names)
|
||||
as the original module.
|
||||
2. The ordering of parameters/buffers matches the original module.
|
||||
3. Graph nodes referencing the old names are updated to use the correct FQNs.
|
||||
|
||||
This is useful after using functional tracing APIs (like dynamo_graph_capture_for_export)
|
||||
that may flatten parameter/buffer names.
|
||||
|
||||
Args:
|
||||
original_module: The original nn.Module (or a bound method of one) that was traced.
|
||||
traced_module: The traced fx.GraphModule whose state dict needs to be restored.
|
||||
|
||||
Example::
|
||||
|
||||
import torch
|
||||
from torch._dynamo.functional_export import _dynamo_graph_capture_for_export
|
||||
from torch.export import _restore_state_dict
|
||||
|
||||
|
||||
class Model(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.layer = torch.nn.Linear(10, 10)
|
||||
|
||||
def forward(self, x):
|
||||
return self.layer(x)
|
||||
|
||||
|
||||
model = Model()
|
||||
gm = _dynamo_graph_capture_for_export(model)(torch.randn(1, 10))
|
||||
|
||||
# Before: gm may have flattened names like "p_layer_weight"
|
||||
# After: gm will have proper FQNs like "layer.weight"
|
||||
_restore_state_dict(model, gm)
|
||||
"""
|
||||
# Extract the underlying module if a bound method was passed
|
||||
module = _get_underlying_module(original_module)
|
||||
|
||||
# Build ID-based lookups for traced module params/buffers
|
||||
# Collect all data first to avoid modifying during iteration
|
||||
traced_params: dict[int, tuple[str, torch.nn.Parameter]] = {}
|
||||
for name, param in traced_module.named_parameters(remove_duplicate=False):
|
||||
traced_params[id(param)] = (name, param)
|
||||
|
||||
traced_buffers: dict[int, tuple[str, torch.Tensor]] = {}
|
||||
for name, buffer in traced_module.named_buffers(remove_duplicate=False):
|
||||
traced_buffers[id(buffer)] = (name, buffer)
|
||||
|
||||
# Collect original module's parameters and buffers upfront to avoid
|
||||
# issues with shared tensor objects during iteration
|
||||
orig_params_list: list[tuple[str, torch.nn.Parameter]] = list(
|
||||
module.named_parameters(remove_duplicate=False)
|
||||
)
|
||||
orig_buffers_list: list[tuple[str, torch.Tensor]] = list(
|
||||
module.named_buffers(remove_duplicate=False)
|
||||
)
|
||||
|
||||
# Build mapping from old names to new names for graph node updates
|
||||
name_mapping: dict[str, str] = {}
|
||||
|
||||
# Track which traced names we've processed
|
||||
processed_traced_names: set[str] = set()
|
||||
|
||||
# Restore parameters in the order they appear in original module
|
||||
for orig_name, orig_param in orig_params_list:
|
||||
if id(orig_param) in traced_params:
|
||||
# This param exists in traced module - restore it with original FQN
|
||||
traced_name, traced_param = traced_params[id(orig_param)]
|
||||
processed_traced_names.add(traced_name)
|
||||
if traced_name != orig_name:
|
||||
# Only reassign if the name is different
|
||||
torch.fx.graph_module._assign_attr(
|
||||
traced_param, traced_module, orig_name
|
||||
)
|
||||
torch.fx.graph_module._del_attr(traced_module, traced_name)
|
||||
name_mapping[traced_name] = orig_name
|
||||
else:
|
||||
# This param doesn't exist in traced module - add it
|
||||
torch.fx.graph_module._assign_attr(orig_param, traced_module, orig_name)
|
||||
|
||||
# Restore buffers in the order they appear in original module
|
||||
for orig_name, orig_buffer in orig_buffers_list:
|
||||
if id(orig_buffer) in traced_buffers:
|
||||
# This buffer exists in traced module - restore it with original FQN
|
||||
traced_name, traced_buffer = traced_buffers[id(orig_buffer)]
|
||||
processed_traced_names.add(traced_name)
|
||||
if traced_name != orig_name:
|
||||
# Only reassign if the name is different
|
||||
torch.fx.graph_module._assign_attr(
|
||||
orig_buffer, traced_module, orig_name
|
||||
)
|
||||
torch.fx.graph_module._del_attr(traced_module, traced_name)
|
||||
name_mapping[traced_name] = orig_name
|
||||
else:
|
||||
# This buffer doesn't exist in traced module - add it
|
||||
torch.fx.graph_module._assign_attr(orig_buffer, traced_module, orig_name)
|
||||
|
||||
param_names = [v[0] for v in traced_params.values()]
|
||||
buffer_names = [v[0] for v in traced_buffers.values()]
|
||||
# Constants are traced params/buffers that weren't matched to any original param/buffer
|
||||
const_keys = list(
|
||||
set(param_names + buffer_names).difference(processed_traced_names)
|
||||
)
|
||||
|
||||
_clear_traced_params_buffers(traced_module, const_keys)
|
||||
|
||||
# Update get_attr nodes in the graph to use the correct FQNs
|
||||
for node in traced_module.graph.nodes:
|
||||
if node.op == "get_attr" and node.target in name_mapping:
|
||||
node.target = name_mapping[node.target]
|
||||
|
||||
traced_module.recompile()
|
||||
@@ -0,0 +1,472 @@
|
||||
import logging
|
||||
import operator
|
||||
import types
|
||||
from collections import defaultdict
|
||||
|
||||
import torch
|
||||
import torch.fx._pytree as fx_pytree
|
||||
import torch.utils._pytree as pytree
|
||||
from torch.export.exported_program import (
|
||||
ConstantArgument,
|
||||
ExportedProgram,
|
||||
ModuleCallSignature,
|
||||
)
|
||||
from torch.fx.passes.tools_common import legalize_graph, NodeList
|
||||
from torch.fx.passes.utils.fuser_utils import erase_nodes, fuse_as_graphmodule
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_getitem_users(node: torch.fx.Node) -> set[torch.fx.Node]:
|
||||
node_users = list(node.users.keys())
|
||||
getitem_users = set()
|
||||
for user in node_users:
|
||||
if user.op == "output":
|
||||
continue
|
||||
|
||||
if not (user.op == "call_function" and user.target is operator.getitem):
|
||||
raise AssertionError(
|
||||
f"Expected getitem node as user for {node}, instead got {user}"
|
||||
)
|
||||
getitem_users.update(list(user.users.keys()))
|
||||
return getitem_users
|
||||
|
||||
|
||||
def _try_remove_connecting_pytrees(curr_module_node: torch.fx.Node) -> None:
|
||||
"""
|
||||
We want to try to remove extraneous pytree flatten/unflatten calls between modules
|
||||
calls. Instead of having the following:
|
||||
graph():
|
||||
...
|
||||
%foo : [num_users=1] = call_module[target=foo](args = (%getitem_1, %getitem_2), kwargs = {})
|
||||
%tree_flatten_spec : [num_users=1] = call_function[target=torch.fx._pytree.tree_flatten_spec](args = (%foo, %_spec_1), kwargs = {})
|
||||
%getitem_4 : [num_users=1] = call_function[target=operator.getitem](args = (%tree_flatten_spec, 0), kwargs = {})
|
||||
%tree_unflatten_1 : [num_users=2] = call_function[target=torch.utils._pytree.tree_unflatten](args = ([%getitem_4], %_spec_2), kwargs = {})
|
||||
%getitem_5 : [num_users=1] = call_function[target=operator.getitem](args = (%tree_unflatten_1, 0), kwargs = {})
|
||||
%getitem_7 : [num_users=0] = call_function[target=operator.getitem](args = (%tree_unflatten_1, 1), kwargs = {})
|
||||
%getitem_6 : [num_users=1] = call_function[target=operator.getitem](args = (%getitem_5, 0), kwargs = {})
|
||||
%bar : [num_users=1] = call_module[target=bar](args = (%getitem_6,), kwargs = {})
|
||||
...
|
||||
|
||||
We could do the following, if we know that all the outputs of `foo` feed into `bar`:
|
||||
graph():
|
||||
...
|
||||
%foo : [num_users=1] = call_module[target=foo](args = (%getitem_1, %getitem_2), kwargs = {})
|
||||
%bar : [num_users=1] = call_module[target=bar](args = (%getitem_6,), kwargs = {})
|
||||
...
|
||||
|
||||
Currently this optimization only works for the case where all of the outputs
|
||||
of `foo` go directly into `bar`, and `bar` has no other inputs.
|
||||
""" # noqa: B950
|
||||
|
||||
log.debug("Trying to remove pytrees for module call %s", curr_module_node)
|
||||
|
||||
curr_module_users = list(curr_module_node.users.keys())
|
||||
if len(curr_module_users) != 1:
|
||||
raise AssertionError(
|
||||
f"Expected only one user for module node, instead got {list(curr_module_users)}"
|
||||
)
|
||||
flatten_node = curr_module_users[0]
|
||||
if not (
|
||||
flatten_node.op == "call_function"
|
||||
and flatten_node.target is fx_pytree.tree_flatten_spec
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Expected flatten_node to be a call_function with target tree_flatten_spec, "
|
||||
f"but got op={flatten_node.op}, target={flatten_node.target}"
|
||||
)
|
||||
|
||||
flatten_getitem_users = _get_getitem_users(flatten_node)
|
||||
if len(flatten_getitem_users) != 1:
|
||||
log.debug(
|
||||
"More than one user found for flatten node, %s: %s. "
|
||||
"Unable to fuse it with another unflatten call.",
|
||||
flatten_node,
|
||||
flatten_getitem_users,
|
||||
)
|
||||
return
|
||||
|
||||
unflatten_node = next(iter(flatten_getitem_users))
|
||||
if not (
|
||||
unflatten_node.op == "call_function"
|
||||
and unflatten_node.target is pytree.tree_unflatten
|
||||
):
|
||||
log.debug(
|
||||
"Flatten node %s's user is not a pytree.tree_unflatten. "
|
||||
"Instead it is: %s. Passing...",
|
||||
flatten_node,
|
||||
unflatten_node,
|
||||
)
|
||||
return
|
||||
|
||||
for i, arg in enumerate(unflatten_node.args[0]): # type: ignore[union-attr,arg-type]
|
||||
if arg not in flatten_node.users:
|
||||
log.debug(
|
||||
"Module %s's outputs are not all directly used as inputs to "
|
||||
"the subsequent module. Unable to fuse the connecting "
|
||||
"flatten/unflatten. The inputs to the subsequent module are: %s. ",
|
||||
curr_module_node,
|
||||
unflatten_node.args[0],
|
||||
)
|
||||
return
|
||||
|
||||
if not (
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
arg.op == "call_function"
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
and arg.target is operator.getitem
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
and arg.args[1] == i
|
||||
):
|
||||
log.debug(
|
||||
"Module %s's outputs are not all directly used in the same "
|
||||
"order as outputted. Unable to fuse the connecting "
|
||||
"flatten/unflatten. The inputs to the "
|
||||
"subsequent module are: %s. ",
|
||||
curr_module_node,
|
||||
unflatten_node.args[0],
|
||||
)
|
||||
return
|
||||
|
||||
# Unflatten has two levels of getitem, because it gets the args and kwargs
|
||||
unflatten_getitem_getitem_users = set()
|
||||
unflatten_getitem_users = _get_getitem_users(unflatten_node)
|
||||
for unflatten_getitem_user in unflatten_getitem_users:
|
||||
unflatten_getitem_getitem_users.update(
|
||||
list(unflatten_getitem_user.users.keys())
|
||||
)
|
||||
|
||||
if len(unflatten_getitem_getitem_users) != 1:
|
||||
log.debug(
|
||||
"More than one user found for unflatten node, %s: %s. "
|
||||
"Unable to fuse it with another flatten call.",
|
||||
unflatten_node,
|
||||
unflatten_getitem_getitem_users,
|
||||
)
|
||||
return
|
||||
|
||||
next_module_node = next(iter(unflatten_getitem_getitem_users))
|
||||
if next_module_node.op != "call_module":
|
||||
log.debug(
|
||||
"Unflatten node %s's user is not a call_module. "
|
||||
"Instead it is: %s. Passing...",
|
||||
unflatten_node,
|
||||
next_module_node,
|
||||
)
|
||||
return
|
||||
|
||||
# Directly put the outputs of the current module into the next module
|
||||
next_module_node.args = (curr_module_node,)
|
||||
|
||||
|
||||
def _remove_extraneous_pytrees(gm: torch.fx.GraphModule) -> None:
|
||||
"""
|
||||
Remove extraneous pytree flatten/unflatten calls.
|
||||
|
||||
We try a couple of optimizations here:
|
||||
1. Remove pytree flatten/unflatten calls between modules
|
||||
2. TODO: Remove module's in_spec + initial unflatten call
|
||||
3. TODO: Remove module's out_spec + final flatten call
|
||||
"""
|
||||
|
||||
for node in gm.graph.nodes:
|
||||
if node.op == "call_module" and node.target != "_guards_fn":
|
||||
_try_remove_connecting_pytrees(node)
|
||||
|
||||
gm.graph.eliminate_dead_code()
|
||||
|
||||
|
||||
def _construct_inputs(
|
||||
gm: torch.fx.GraphModule,
|
||||
signature: ModuleCallSignature,
|
||||
node_name_map: dict[str, torch.fx.Node],
|
||||
) -> tuple[list[torch.fx.Node], dict[str, torch.fx.Node]]:
|
||||
tree_unflatten_args: list[torch.fx.Node | None] = []
|
||||
for input_ in signature.inputs:
|
||||
if isinstance(input_, ConstantArgument) and input_.value is None:
|
||||
# Constants should be directly embedded into the graph and not used
|
||||
# as inputs
|
||||
tree_unflatten_args.append(None)
|
||||
elif input_.name not in node_name_map:
|
||||
# For unused inputs
|
||||
tree_unflatten_args.append(None)
|
||||
else:
|
||||
tree_unflatten_args.append(node_name_map[input_.name])
|
||||
|
||||
# Insert unflatten call
|
||||
from .unflatten import _generate_unflatten
|
||||
|
||||
unflatten_node = _generate_unflatten(gm, tree_unflatten_args, signature.in_spec)
|
||||
|
||||
if signature.in_spec.num_children != 2:
|
||||
raise AssertionError(
|
||||
f"Expected in_spec to have 2 children, but got {signature.in_spec.num_children}"
|
||||
)
|
||||
if signature.in_spec.type is not tuple:
|
||||
raise AssertionError(
|
||||
f"Expected in_spec type to be tuple, but got {signature.in_spec.type}"
|
||||
)
|
||||
args_spec, kwargs_spec = signature.in_spec.children()
|
||||
if args_spec.type is not tuple:
|
||||
raise AssertionError(
|
||||
f"Expected args_spec type to be tuple, but got {args_spec.type}"
|
||||
)
|
||||
if kwargs_spec.type is not dict:
|
||||
raise AssertionError(
|
||||
f"Expected kwargs_spec type to be dict, but got {kwargs_spec.type}"
|
||||
)
|
||||
|
||||
args_node = gm.graph.call_function(operator.getitem, (unflatten_node, 0))
|
||||
args_nodes = [
|
||||
gm.graph.call_function(operator.getitem, (args_node, i))
|
||||
for i in range(args_spec.num_children)
|
||||
]
|
||||
kwargs_node = gm.graph.call_function(operator.getitem, (unflatten_node, 1))
|
||||
kwargs_nodes = {
|
||||
k: gm.graph.call_function(operator.getitem, (kwargs_node, k))
|
||||
for k in kwargs_spec.context
|
||||
}
|
||||
return args_nodes, kwargs_nodes
|
||||
|
||||
|
||||
def _insert_call_module(
|
||||
gm: torch.fx.GraphModule,
|
||||
args_nodes: list[torch.fx.Node],
|
||||
kwargs_nodes: dict[str, torch.fx.Node],
|
||||
module_to_swap: torch.nn.Module,
|
||||
name: str,
|
||||
) -> torch.fx.Node:
|
||||
from .unflatten import _assign_attr, _AttrKind
|
||||
|
||||
_assign_attr(module_to_swap, gm, name, _AttrKind.MODULE)
|
||||
module_node = gm.graph.call_module(name, tuple(args_nodes), kwargs_nodes) # type: ignore[arg-type]
|
||||
return module_node
|
||||
|
||||
|
||||
def _deconstruct_outputs(
|
||||
gm: torch.fx.GraphModule,
|
||||
signature: ModuleCallSignature,
|
||||
module_node: torch.fx.Node,
|
||||
node_name_map: dict[str, torch.fx.Node],
|
||||
orig_outputs: tuple[torch.fx.Node, ...],
|
||||
) -> None:
|
||||
from .unflatten import _generate_flatten_spec
|
||||
|
||||
flatten_node = _generate_flatten_spec(gm, module_node, signature.out_spec)
|
||||
|
||||
for i, orig_output in enumerate(orig_outputs):
|
||||
# Use Proxy to record getitem access.
|
||||
proxy_out = torch.fx.Proxy(flatten_node)[i].node # type: ignore[index]
|
||||
orig_output.replace_all_uses_with(proxy_out, propagate_meta=True)
|
||||
|
||||
node_name_map[orig_output.name] = proxy_out
|
||||
|
||||
|
||||
def _swap_module_helper(
|
||||
gm: torch.fx.GraphModule,
|
||||
modules_to_swap: dict[str, torch.nn.Module],
|
||||
module_call_graph: dict[str, ModuleCallSignature],
|
||||
) -> torch.fx.GraphModule:
|
||||
log.debug("Starting graph:")
|
||||
log.debug(gm.graph)
|
||||
|
||||
legalize_graph(gm)
|
||||
|
||||
partitions: dict[str, NodeList] = defaultdict(list)
|
||||
|
||||
node_name_map: dict[str, torch.fx.Node] = {
|
||||
node.name: node for node in gm.graph.nodes
|
||||
}
|
||||
|
||||
# TODO: Handle the duplicate module case
|
||||
for node in gm.graph.nodes:
|
||||
if nn_module_stack := node.meta.get("nn_module_stack"):
|
||||
for path, _ in nn_module_stack.values():
|
||||
if path in modules_to_swap:
|
||||
partitions[path].append(node)
|
||||
break
|
||||
|
||||
for name, nodes in partitions.items():
|
||||
"""
|
||||
Given a graph like the following, and we want to swap out the submodule "foo":
|
||||
graph():
|
||||
%x : [num_users=1] = placeholder[target=x]
|
||||
%y : [num_users=2] = placeholder[target=y]
|
||||
%add : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%y, %x), kwargs = {}), nn_module_stack = {"foo": ("foo", torch.nn.Module)}
|
||||
%sub : [num_users=1] = call_function[target=torch.ops.aten.sub.Tensor](args = (%y, %add), kwargs = {}), nn_module_stack = {"bar": ("bar", torch.nn.Module)}
|
||||
return (sub,)
|
||||
|
||||
We will first partition out foo's subgraph:
|
||||
graph():
|
||||
%x : [num_users=1] = placeholder[target=x]
|
||||
%y : [num_users=2] = placeholder[target=y]
|
||||
%add : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%y, %x), kwargs = {})
|
||||
return add
|
||||
|
||||
And then insert an unflatten + call_module + flatten to replace the subgraph:
|
||||
graph():
|
||||
%x : [num_users=1] = placeholder[target=x]
|
||||
%y : [num_users=1] = placeholder[target=y]
|
||||
|
||||
%_spec_0 : [num_users=1] = get_attr[target=_spec_0]
|
||||
%tree_unflatten : [num_users=2] = call_function[target=torch.utils._pytree.tree_unflatten](args = ([%x, %y], %_spec_0), kwargs = {})
|
||||
%getitem : [num_users=2] = call_function[target=operator.getitem](args = (%tree_unflatten, 0), kwargs = {})
|
||||
%getitem_1 : [num_users=1] = call_function[target=operator.getitem](args = (%getitem, 0), kwargs = {})
|
||||
%getitem_2 : [num_users=1] = call_function[target=operator.getitem](args = (%getitem, 1), kwargs = {})
|
||||
%getitem_3 : [num_users=0] = call_function[target=operator.getitem](args = (%tree_unflatten, 1), kwargs = {})
|
||||
%foo : [num_users=0] = call_module[target=foo](args = (%getitem_1, %getitem_2), kwargs = {})
|
||||
%_spec_1 : [num_users=1] = get_attr[target=_spec_1]
|
||||
%tree_flatten_spec : [num_users=1] = call_function[target=torch.fx._pytree.tree_flatten_spec](args = (None, %_spec_1), kwargs = {})
|
||||
%getitem_4 : [num_users=1] = call_function[target=operator.getitem](args = (%tree_flatten_spec, 0), kwargs = {})
|
||||
|
||||
%sub : [num_users=1] = call_function[target=torch.ops.aten.sub.Tensor](args = (%y, %getitem_4), kwargs = {})
|
||||
return (%sub,)
|
||||
|
||||
The `tree_unflatten` call will construct tensor inputs into the input
|
||||
format needed by the swapped eager module.
|
||||
The `call_module` node should now reference the swapped torch.nn.Module.
|
||||
The `tree_flatten_spec` call will deconstruct the eager outputs of the
|
||||
swapped module into tensors.
|
||||
""" # noqa: B950
|
||||
|
||||
submod_name = name.replace(".", "_")
|
||||
sub_gm, orig_inputs, orig_outputs = fuse_as_graphmodule(
|
||||
gm, nodes, f"fused_{submod_name}"
|
||||
)
|
||||
|
||||
log.debug("Fused subgraph nodes:")
|
||||
log.debug(sub_gm.graph)
|
||||
|
||||
signature: ModuleCallSignature = module_call_graph[name]
|
||||
|
||||
args_nodes, kwargs_nodes = _construct_inputs(gm, signature, node_name_map)
|
||||
module_node = _insert_call_module(
|
||||
gm, args_nodes, kwargs_nodes, modules_to_swap[name], name
|
||||
)
|
||||
_deconstruct_outputs(gm, signature, module_node, node_name_map, orig_outputs)
|
||||
|
||||
erase_nodes(gm, nodes)
|
||||
|
||||
log.debug("Swapped graph:")
|
||||
log.debug(gm.graph)
|
||||
|
||||
legalize_graph(gm)
|
||||
|
||||
log.debug("Before removing extraneous pytrees:")
|
||||
log.debug(gm.graph)
|
||||
|
||||
_remove_extraneous_pytrees(gm)
|
||||
log.debug("After removing extraneous pytrees:")
|
||||
log.debug(gm.graph)
|
||||
|
||||
gm.recompile()
|
||||
|
||||
return gm
|
||||
|
||||
|
||||
def _fix_input_output_signature(
|
||||
gm: torch.fx.GraphModule, signature: ModuleCallSignature
|
||||
) -> None:
|
||||
"""
|
||||
Given the unlifted module from calling ep.module(), we want to remove the
|
||||
pytree processing from the graph module's PyTreeCodeGen and instead make it
|
||||
nodes inside of the graph. This allows us to do some optimizations, like
|
||||
remove these pytree calls if it is unnecessary, and makes the PyTree part
|
||||
more obvious to graph passes.
|
||||
"""
|
||||
from torch.export.unflatten import _generate_flatten, _generate_unflatten
|
||||
|
||||
# Remove the registered pytree codegen because we will take care of it
|
||||
# through inserting pytree nodes into the graph
|
||||
gm.graph._codegen = torch.fx.graph.CodeGen()
|
||||
|
||||
old_placeholders = [node for node in gm.graph.nodes if node.op == "placeholder"]
|
||||
|
||||
new_placeholders = []
|
||||
forward_arg_names = signature.forward_arg_names
|
||||
if forward_arg_names is None:
|
||||
forward_arg_names = []
|
||||
if signature.in_spec.num_children != 2:
|
||||
raise AssertionError(
|
||||
f"Expected in_spec to have 2 children, but got {signature.in_spec.num_children}"
|
||||
)
|
||||
arg_spec = signature.in_spec.child(0)
|
||||
kwarg_spec = signature.in_spec.child(1)
|
||||
if arg_spec.type is not tuple:
|
||||
raise AssertionError(
|
||||
f"Expected arg_spec type to be tuple, but got {arg_spec.type}"
|
||||
)
|
||||
if kwarg_spec.type is not dict:
|
||||
raise AssertionError(
|
||||
f"Expected kwarg_spec type to be dict, but got {kwarg_spec.type}"
|
||||
)
|
||||
for i in range(arg_spec.num_children):
|
||||
forward_arg_names.append(f"arg_{i}")
|
||||
forward_arg_names.extend(kwarg_spec.context)
|
||||
|
||||
for arg in forward_arg_names:
|
||||
with gm.graph.inserting_before(old_placeholders[0]):
|
||||
new_placeholders.append(gm.graph.placeholder(arg))
|
||||
|
||||
# Insert flatten call for the inputs
|
||||
with gm.graph.inserting_before(old_placeholders[0]):
|
||||
flat_node = _generate_flatten(gm, tuple(new_placeholders))
|
||||
for i, old_placeholder in enumerate(old_placeholders):
|
||||
old_placeholder.op = "call_function"
|
||||
old_placeholder.target = operator.getitem
|
||||
old_placeholder.args = (flat_node, i)
|
||||
|
||||
# Insert unflatten call for the outputs
|
||||
output_node = next(node for node in gm.graph.nodes if node.op == "output")
|
||||
with gm.graph.inserting_before(output_node):
|
||||
unflat = _generate_unflatten(gm, output_node.args[0], signature.out_spec)
|
||||
output_node.args = (unflat,)
|
||||
|
||||
gm.recompile()
|
||||
|
||||
|
||||
def _swap_modules(
|
||||
ep: ExportedProgram, modules_to_swap: dict[str, torch.nn.Module]
|
||||
) -> torch.fx.GraphModule:
|
||||
"""
|
||||
Unlifts the given ExportedProgram into a fx.GraphModule, and then swaps
|
||||
previously traced modules with new eager modules specified. Returns a
|
||||
fx.GraphModule with a custom forward function.
|
||||
|
||||
Args:
|
||||
ep (ExportedProgram): Exported program to modify
|
||||
modules_to_swap (Dict[str, torch.nn.Module]): Mapping from module fqn to
|
||||
eager module to swap with. The specified module fqn should have also
|
||||
been specified in the `preserve_module_call_signature` argument to
|
||||
torch.export so that we know how to restore the calling convention
|
||||
to this argument.
|
||||
run_with_interpreter: Whether or not to run the graph using
|
||||
fx.Interpreter. Setting to true will help result in better error
|
||||
messages and easier debugging, but it has found to result in a QPS
|
||||
drop.
|
||||
"""
|
||||
module_call_graph = {
|
||||
entry.fqn: entry.signature for entry in ep.module_call_graph if entry.signature
|
||||
}
|
||||
|
||||
gm = ep.module()
|
||||
gm.validate_inputs = False # type: ignore[assignment]
|
||||
gm.graph.eliminate_dead_code() # type: ignore[operator, union-attr]
|
||||
if not isinstance(gm, torch.fx.GraphModule):
|
||||
raise AssertionError(
|
||||
f"Expected gm to be a torch.fx.GraphModule, but got {type(gm)}"
|
||||
)
|
||||
_fix_input_output_signature(gm, ep.module_call_graph[0].signature)
|
||||
|
||||
gm.module_call_graph = ep.module_call_graph
|
||||
gm.train = types.MethodType(type(gm).train, gm) # type: ignore[assignment]
|
||||
gm.eval = types.MethodType(type(gm).eval, gm) # type: ignore[assignment]
|
||||
|
||||
if not isinstance(gm, torch.fx.GraphModule):
|
||||
raise AssertionError(
|
||||
f"Expected gm to be a torch.fx.GraphModule, but got {type(gm)}"
|
||||
)
|
||||
gm = _swap_module_helper(gm, modules_to_swap, module_call_graph)
|
||||
|
||||
return gm
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from torch.utils._pytree import Context, TreeSpec
|
||||
|
||||
|
||||
def reorder_kwargs(user_kwargs: dict[str, Any], spec: TreeSpec) -> dict[str, Any]:
|
||||
"""Reorder user-provided kwargs to match the order in `spec`. `spec` is
|
||||
expected to be the in_spec of an exported program, i.e. the spec that
|
||||
results from flattening `(args, kwargs)`.
|
||||
|
||||
We need this to provide consistent input ordering, such so that users can
|
||||
pass in foo(a=a, b=b) OR foo(b=b, a=a) and receive the same result.
|
||||
"""
|
||||
# Make sure that the spec is actually shaped like (args, kwargs)
|
||||
if spec.type is not tuple:
|
||||
raise AssertionError(f"Expected spec type to be tuple, but got {spec.type}")
|
||||
if spec.num_children != 2:
|
||||
raise AssertionError(
|
||||
f"Expected spec to have 2 children, but got {spec.num_children}"
|
||||
)
|
||||
kwargs_spec = spec.child(1)
|
||||
if kwargs_spec.type is not dict:
|
||||
raise AssertionError(
|
||||
f"Expected kwargs_spec type to be dict, but got {kwargs_spec.type}"
|
||||
)
|
||||
|
||||
if set(user_kwargs) != set(kwargs_spec.context):
|
||||
raise ValueError(
|
||||
f"Ran into a kwarg keyword mismatch: "
|
||||
f"Got the following keywords {list(user_kwargs)} but expected {kwargs_spec.context}"
|
||||
)
|
||||
|
||||
reordered_kwargs = {}
|
||||
for kw in kwargs_spec.context:
|
||||
reordered_kwargs[kw] = user_kwargs[kw]
|
||||
|
||||
return reordered_kwargs
|
||||
|
||||
|
||||
def is_equivalent(
|
||||
spec1: TreeSpec,
|
||||
spec2: TreeSpec,
|
||||
equivalence_fn: Callable[[type | None, Context, type | None, Context], bool],
|
||||
) -> bool:
|
||||
"""Customizable equivalence check for two TreeSpecs.
|
||||
|
||||
Arguments:
|
||||
spec1: The first TreeSpec to compare
|
||||
spec2: The second TreeSpec to compare
|
||||
equivalence_fn: A function to determine the equivalence of two
|
||||
TreeSpecs by examining their types and contexts. It will be called like:
|
||||
|
||||
equivalence_fn(spec1.type, spec1.context, spec2.type, spec2.context)
|
||||
|
||||
This function will be applied recursively to all children.
|
||||
|
||||
Returns:
|
||||
True if the two TreeSpecs are equivalent, False otherwise.
|
||||
"""
|
||||
if not equivalence_fn(spec1.type, spec1.context, spec2.type, spec2.context):
|
||||
return False
|
||||
|
||||
# Recurse on children
|
||||
if spec1.num_children != spec2.num_children:
|
||||
return False
|
||||
|
||||
for child_spec1, child_spec2 in zip(spec1.children(), spec2.children()):
|
||||
if not is_equivalent(child_spec1, child_spec2, equivalence_fn):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,890 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import copy
|
||||
import inspect
|
||||
import math
|
||||
import warnings
|
||||
from collections.abc import Sequence
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
import sympy
|
||||
|
||||
import torch
|
||||
import torch.utils._pytree as pytree
|
||||
from torch._export.non_strict_utils import (
|
||||
_enter_enable_graph_inputs_of_type_nn_module,
|
||||
_exit_enable_graph_inputs_of_type_nn_module,
|
||||
_get_graph_inputs_of_type_nn_module,
|
||||
)
|
||||
from torch._export.passes.add_runtime_assertions_for_constraints_pass import (
|
||||
_convert_range_to_int,
|
||||
)
|
||||
from torch._export.utils import _check_input_constraints_for_graph
|
||||
from torch.export.unflatten import _assign_attr, _AttrKind
|
||||
from torch.fx.experimental.proxy_tensor import _pytree_subclasses_that_lose_info
|
||||
from torch.fx.graph import _PyTreeCodeGen, _PyTreeInfo
|
||||
from torch.fx.traceback import NodeSource, NodeSourceAction
|
||||
from torch.utils._sympy.solve import try_solve
|
||||
from torch.utils._sympy.value_ranges import ValueRanges
|
||||
|
||||
from ._remove_effect_tokens_pass import _remove_effect_tokens
|
||||
from ._tree_utils import reorder_kwargs
|
||||
from .exported_program import (
|
||||
ExportedProgram,
|
||||
ExportGraphSignature,
|
||||
InputKind,
|
||||
OutputKind,
|
||||
)
|
||||
|
||||
|
||||
def eq_spec(self: pytree.TreeSpec, other: pytree.TreeSpec) -> bool:
|
||||
"""
|
||||
Refinement of TreeSpec.__eq__ where, e.g., torch.Size(...) matches tuple(...).
|
||||
See _pytree_subclasses_that_lose_info in proxy_tensor.py for more details.
|
||||
"""
|
||||
|
||||
def _normalize_type(t):
|
||||
return str(_pytree_subclasses_that_lose_info.get(t, t))
|
||||
|
||||
def _match_normalized_structure(a, b):
|
||||
if a is b:
|
||||
return True
|
||||
if _normalize_type(a.type) != _normalize_type(b.type):
|
||||
return False
|
||||
if a.type is dict and b.type is dict:
|
||||
# in the case of dict, the context is list of keys and we allow the keys to be in any order
|
||||
if set(a.context) != set(b.context):
|
||||
return False
|
||||
elif a.context != b.context:
|
||||
return False
|
||||
if a.num_children != b.num_children:
|
||||
return False
|
||||
return all(
|
||||
_match_normalized_structure(a, b)
|
||||
for a, b in zip(a.children(), b.children())
|
||||
)
|
||||
|
||||
return _match_normalized_structure(self, other)
|
||||
|
||||
|
||||
def _check_inputs_match(args, kwargs, in_spec: pytree.TreeSpec) -> list:
|
||||
reordered_kwargs = reorder_kwargs(kwargs, in_spec)
|
||||
flat_args_with_path, received_spec = pytree.tree_flatten_with_path(
|
||||
(args, reordered_kwargs)
|
||||
)
|
||||
|
||||
if not eq_spec(received_spec, in_spec):
|
||||
raise ValueError( # noqa: B904
|
||||
"Trying to flatten user inputs with exported input tree spec: \n"
|
||||
f"{in_spec}\n"
|
||||
"but actually got inputs with tree spec of: \n"
|
||||
f"{received_spec}.\n"
|
||||
"Please check that the inputs have the same number and type of "
|
||||
"args and kwargs as the ones you used when tracing."
|
||||
)
|
||||
|
||||
return flat_args_with_path
|
||||
|
||||
|
||||
def _force_ep_signature_match(ep_guards_code: list[str], input_paths):
|
||||
# TODO (tmanlaibaatar)
|
||||
# This is band-aid solution to export new tracer replacing
|
||||
# shape env sources to flat_args. The real fix should be replacing
|
||||
# shape env sources to original user sources but this is quite
|
||||
# involved because you need to carefully construct new sources using
|
||||
# dynamo and replace all instances of it inside shape env. But it is
|
||||
# lot easier to manipulate after we turn them into strings and only
|
||||
# time we use these guards is during retracing or running exported program,
|
||||
# so it is probably ok to have "not useful" guards on ep for now.
|
||||
name_mapping = {}
|
||||
for idx, path in enumerate(input_paths):
|
||||
name_mapping[f"L['flat_args'][{idx}]"] = f"L{pytree.keystr(path)}"
|
||||
|
||||
new_guards_code = []
|
||||
for guard in ep_guards_code:
|
||||
for old_name, new_name in name_mapping.items():
|
||||
guard = guard.replace(old_name, new_name)
|
||||
new_guards_code.append(guard)
|
||||
|
||||
return new_guards_code
|
||||
|
||||
|
||||
def _force_gm_signature_match(ep_guards_code: list[str], signature):
|
||||
"""
|
||||
The signature of the originally exported module may not match
|
||||
the signature of the unlifted graph module extracted from the
|
||||
exported program. The guards code extracted from the exported
|
||||
program is based on the former, but the generated guards fn is
|
||||
based on the latter; thus we need to reconcile any such diff.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Handle case where signatures may differ in var args.
|
||||
orig_arg_names = set()
|
||||
for g in ep_guards_code:
|
||||
# match substrings of the form L['<name>'][<number>]
|
||||
orig_arg_names.update(re.findall(r"L\[\'([^\']+)\'\]\[([0-9]+)\]", g))
|
||||
|
||||
sig_arg_names = set()
|
||||
for n in signature.parameters:
|
||||
# match substrings of the form <name>_<number>
|
||||
sig_arg_names.update(re.findall(r"(.+)_([0-9]+)", n))
|
||||
|
||||
# replace L['<name>'][<number>] with L['<name>_<number>']
|
||||
new_guards_code = ep_guards_code
|
||||
for match in orig_arg_names:
|
||||
if match in sig_arg_names:
|
||||
base, idx = match
|
||||
new_guards_code = [
|
||||
g.replace(f"L['{base}'][{idx}]", f"L['{base}_{idx}']")
|
||||
for g in new_guards_code
|
||||
]
|
||||
|
||||
return new_guards_code
|
||||
|
||||
|
||||
def _convert_guards_code_to_fn(
|
||||
guards_code: list[str],
|
||||
paths_of_placeholders: list[pytree.KeyPath],
|
||||
):
|
||||
"""
|
||||
Generates Python code given guards code and paths of placeholders.
|
||||
We assume that, based on source information,
|
||||
- the tracer generates the guards code
|
||||
- the input spec generates the paths of placeholders.
|
||||
|
||||
Example:
|
||||
|
||||
Suppose we are given the guards code "L['z']['k'].size()[1] == 3"
|
||||
and we are given that ['z']['k'] is the path of placeholder #2.
|
||||
Then we will generate:
|
||||
```
|
||||
torch._assert(
|
||||
args[2].size()[0] == 3,
|
||||
"Guard failed: z['k'].size()[0] == 3",
|
||||
)
|
||||
```
|
||||
|
||||
FAQ: Why do we generate code based on (flattened) args instead of
|
||||
the original (unflattened) inputs? Because this would require
|
||||
inserting an additional pytree.unflatten call in our graph.
|
||||
|
||||
FAQ: Why do we not emit RuntimeError on guard failure as we used to?
|
||||
Because it is inconvenient :/, get used to AssertionError instead.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
from torch.fx.experimental.symbolic_shapes import SYMPY_INTERP
|
||||
|
||||
actual_guards_code = []
|
||||
shadow_guards_code = []
|
||||
for c in guards_code:
|
||||
a, s = c, c
|
||||
for idx, path in enumerate(paths_of_placeholders):
|
||||
# e.g., replace L['z']['k'] with args[2] for Python code (actual)
|
||||
a = a.replace("L" + pytree.keystr(path), f"args[{idx}]")
|
||||
# e.g., replace L['z']['k'] with z['k'] for error message (shadow)
|
||||
s = s.replace(
|
||||
"L" + pytree.keystr(path),
|
||||
path[0].key + pytree.keystr(path[1:]), # type: ignore[attr-defined]
|
||||
)
|
||||
actual_guards_code.append(a)
|
||||
shadow_guards_code.append(s.replace("\n", ""))
|
||||
|
||||
# generate function code as str
|
||||
code_str = "\ndef _(*args):\n"
|
||||
for actual, shadow in zip(actual_guards_code, shadow_guards_code):
|
||||
# printing guards code may potentially introduce redundant parens;
|
||||
# we can normalize them out for readability by parsing/unparsing
|
||||
# NOTE: this is not necessary for correctness, just deemed desirable
|
||||
_shadow = ast.unparse(ast.parse(shadow, mode="eval"))
|
||||
# actual code and shadow error message
|
||||
code_str += f' torch._assert({actual}, "Guard failed: {_shadow}")\n'
|
||||
code_str += " return\n"
|
||||
|
||||
# populate namespace with sympy globals, materialize function (named `_`)
|
||||
namespace = {**SYMPY_INTERP}
|
||||
exec(code_str, namespace)
|
||||
|
||||
# create and return a module whose forward is the materialized function
|
||||
# NOTE: we want Dynamo to trace through this module, to repopulate guards:
|
||||
# otherwise we would lose them when retracing
|
||||
# NOTE: calling this module will be a side effect (no users): so it must
|
||||
# be marked impure to avoid being not cleaned up by DCE
|
||||
guards_fn = GuardsFn()
|
||||
guards_fn.forward = torch._dynamo.dont_skip_tracing(namespace["_"]) # type: ignore[call-overload, method-assign]
|
||||
guards_fn._is_impure = True # type: ignore[assignment]
|
||||
return guards_fn
|
||||
|
||||
|
||||
@torch._dynamo.disable
|
||||
def _check_input_constraints_for_module(self, args, kwargs):
|
||||
flat_args_with_path = _check_inputs_match(args, kwargs, self._in_spec)
|
||||
_check_input_constraints_for_graph(
|
||||
self.graph.find_nodes(op="placeholder"),
|
||||
flat_args_with_path,
|
||||
self.range_constraints,
|
||||
)
|
||||
|
||||
|
||||
def _check_input_constraints_pre_hook(self, args, kwargs):
|
||||
# preserve current behavior for clients that do not want any validation
|
||||
if not self.validate_inputs:
|
||||
return
|
||||
|
||||
# when a guards function exists, assume that the graph does calls it!
|
||||
# so we do not need to check input constraints...but we still want
|
||||
# to check inputs match, otherwise we'd get obscure pytree errors
|
||||
if hasattr(self, "_guards_fn"):
|
||||
_check_inputs_match(args, kwargs, self._in_spec)
|
||||
return
|
||||
|
||||
# NOTE: for some reason, Dynamo is tracing into this, we should see why and
|
||||
# put compile at the right place. Until then, we can skip the input
|
||||
# constraint checks.
|
||||
if not torch.compiler.is_dynamo_compiling():
|
||||
_check_input_constraints_for_module(self, args, kwargs)
|
||||
|
||||
|
||||
def _unlift_inputs_as_getattr(
|
||||
gm: torch.fx.GraphModule,
|
||||
lifted_inputs: Sequence[str | None],
|
||||
) -> tuple[dict[str, torch.fx.Node], dict[str, torch.fx.Node]]:
|
||||
"""
|
||||
Unlift inputs referring to params/buffers/constants as getattr nodes in the
|
||||
graph
|
||||
"""
|
||||
unlifted_name_to_node = {}
|
||||
input_name_to_node = {}
|
||||
|
||||
placeholder_nodes = [node for node in gm.graph.nodes if node.op == "placeholder"]
|
||||
if len(lifted_inputs) != len(placeholder_nodes):
|
||||
raise AssertionError(
|
||||
f"Number of lifted inputs ({len(lifted_inputs)}) does not match "
|
||||
f"placeholder nodes ({len(placeholder_nodes)})"
|
||||
)
|
||||
for input_node, lifted_node in zip(placeholder_nodes, lifted_inputs):
|
||||
if lifted_node is None:
|
||||
input_name_to_node[input_node.name] = input_node
|
||||
|
||||
else:
|
||||
with gm.graph.inserting_after(input_node):
|
||||
# It is fine to ignore this warning because
|
||||
# it is guaranteed that we will populate this
|
||||
# attr later.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
getattr_node = gm.graph.get_attr(lifted_node)
|
||||
input_node.replace_all_uses_with(getattr_node)
|
||||
metadata = input_node.meta
|
||||
gm.graph.erase_node(input_node)
|
||||
getattr_node.meta = metadata
|
||||
getattr_node.meta["from_node"] = [
|
||||
NodeSource(
|
||||
input_node,
|
||||
"ExportedProgram.module().unlift()",
|
||||
[NodeSourceAction.CREATE, NodeSourceAction.REPLACE],
|
||||
)
|
||||
]
|
||||
unlifted_name_to_node[lifted_node] = getattr_node
|
||||
|
||||
return unlifted_name_to_node, input_name_to_node
|
||||
|
||||
|
||||
def _insert_copy_for_mutations(
|
||||
gm: torch.fx.GraphModule,
|
||||
mutated_outputs: Sequence[str | None],
|
||||
unlifted_name_to_node: dict[str, torch.fx.Node],
|
||||
input_name_to_node: dict[str, torch.fx.Node],
|
||||
) -> None:
|
||||
"""
|
||||
Find the all the buffers and inputs that were mutated and insert copy_
|
||||
operators to reflect mutations.
|
||||
"""
|
||||
output_node = gm.graph.output_node()
|
||||
outputs = pytree.tree_flatten(output_node.args)[0]
|
||||
if len(outputs) != len(mutated_outputs):
|
||||
raise AssertionError(
|
||||
f"Number of outputs ({len(outputs)}) does not match "
|
||||
f"mutated outputs ({len(mutated_outputs)})"
|
||||
)
|
||||
|
||||
user_output_nodes = []
|
||||
return_nodes_to_copy = {}
|
||||
for return_node, mutated_node_name in zip(outputs, mutated_outputs):
|
||||
if mutated_node_name is None:
|
||||
user_output_nodes.append(return_node)
|
||||
continue
|
||||
|
||||
if mutated_node_name in unlifted_name_to_node:
|
||||
mutated_node = unlifted_name_to_node[mutated_node_name]
|
||||
elif mutated_node_name in input_name_to_node:
|
||||
mutated_node = input_name_to_node[mutated_node_name]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Could not find {mutated_node_name} in either buffer or input nodes"
|
||||
)
|
||||
|
||||
with gm.graph.inserting_before(output_node):
|
||||
copy_node = gm.graph.call_function(
|
||||
torch.ops.aten.copy_.default, (mutated_node, return_node)
|
||||
)
|
||||
return_nodes_to_copy[return_node] = copy_node
|
||||
|
||||
output_args = tuple(
|
||||
return_nodes_to_copy.get(node, node) for node in user_output_nodes
|
||||
)
|
||||
with gm.graph.inserting_before(output_node):
|
||||
# Only return user outputs
|
||||
new_output = gm.graph.output(output_args)
|
||||
output_node.replace_all_uses_with(new_output)
|
||||
gm.graph.erase_node(output_node)
|
||||
new_output.name = output_node.name
|
||||
new_output.meta.update(output_node.meta)
|
||||
new_output.meta["from_node"] = [
|
||||
NodeSource(
|
||||
output_node,
|
||||
"ExportedProgram.module().unlift()",
|
||||
[NodeSourceAction.CREATE, NodeSourceAction.REPLACE],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _get_codegen(
|
||||
in_spec: pytree.TreeSpec,
|
||||
out_spec: pytree.TreeSpec | None,
|
||||
forward_arg_names: list[str] | None = None,
|
||||
) -> _PyTreeCodeGen:
|
||||
"""
|
||||
Create the codegen for the graph module based on the in/out specs
|
||||
"""
|
||||
if forward_arg_names:
|
||||
names = forward_arg_names
|
||||
elif (
|
||||
in_spec.type is tuple
|
||||
and in_spec.num_children == 2
|
||||
and in_spec.child(0).type is tuple
|
||||
and in_spec.child(1).type is dict
|
||||
):
|
||||
# if in_spec contains the args (tuple) and kwargs (dict)
|
||||
names = [f"arg_{i}" for i in range(in_spec.child(0).num_children)]
|
||||
# add kwarg names
|
||||
names.extend(in_spec.child(1).context)
|
||||
else:
|
||||
names = [f"arg_{i}" for i in range(in_spec.num_children)]
|
||||
|
||||
return _PyTreeCodeGen(
|
||||
_PyTreeInfo(
|
||||
names,
|
||||
in_spec,
|
||||
out_spec,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unlift(
|
||||
gm: torch.fx.GraphModule,
|
||||
lifted_inputs: Sequence[str | None],
|
||||
mutated_outputs: Sequence[str | None],
|
||||
in_spec: pytree.TreeSpec,
|
||||
out_spec: pytree.TreeSpec | None,
|
||||
forward_arg_names: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
lifted_inputs: A list matching the graph module's input nodes. For
|
||||
an input node that is referring to a lifted parameter/buffer, this
|
||||
list will contain the fqn the corresponding attribute. Otherwise, this
|
||||
list will contain None. This is used to unlift the lifted parameters as
|
||||
get_attr nodes.
|
||||
|
||||
mutated_outputs: A list matching the graph module's output nodes. For
|
||||
an output node that is referring to a mutated buffer or user input, this
|
||||
list will contain the name of the corresponding buffer or user input
|
||||
that needs to be mutated. Otherwise, this list will contain None. This
|
||||
is used to re-insert an inplace copy_ operator to copy the mutated
|
||||
values back to the original node.
|
||||
"""
|
||||
unlifted_name_to_node, input_name_to_node = _unlift_inputs_as_getattr(
|
||||
gm, lifted_inputs
|
||||
)
|
||||
_insert_copy_for_mutations(
|
||||
gm, mutated_outputs, unlifted_name_to_node, input_name_to_node
|
||||
)
|
||||
gm.graph._codegen = _get_codegen(in_spec, out_spec, forward_arg_names)
|
||||
gm.graph.lint()
|
||||
gm.recompile()
|
||||
return gm
|
||||
|
||||
|
||||
def _register_attrs_to_new_gm(
|
||||
new_gm: torch.fx.GraphModule,
|
||||
graph_signature: ExportGraphSignature,
|
||||
state_dict: dict[str, Any],
|
||||
constants: dict[str, Any],
|
||||
) -> None:
|
||||
non_persistent_buffers = set(graph_signature.non_persistent_buffers)
|
||||
for name in graph_signature.buffers:
|
||||
if name in non_persistent_buffers:
|
||||
persistent = False
|
||||
value = constants[name]
|
||||
else:
|
||||
persistent = True
|
||||
value = state_dict[name]
|
||||
_assign_attr(
|
||||
value, new_gm, name, attr_kind=_AttrKind.BUFFER, persistent=persistent
|
||||
)
|
||||
for name in graph_signature.parameters:
|
||||
value = state_dict[name]
|
||||
_assign_attr(
|
||||
value,
|
||||
new_gm,
|
||||
name,
|
||||
attr_kind=_AttrKind.PARAMETER,
|
||||
)
|
||||
|
||||
# Technically this doesn't account for the aliased multiple constants but
|
||||
# it is ok because we have a separate pass later in the stack that populates
|
||||
# the final gm.
|
||||
for name in chain(
|
||||
graph_signature.lifted_custom_objs, graph_signature.lifted_tensor_constants
|
||||
):
|
||||
value = constants[name]
|
||||
_assign_attr(
|
||||
value,
|
||||
new_gm,
|
||||
name,
|
||||
attr_kind=_AttrKind.CONSTANT,
|
||||
)
|
||||
|
||||
|
||||
class _StatefulGraphModuleFactory(type):
|
||||
"""
|
||||
Metaclass that ensures a private constructor for _StatefulGraphModule
|
||||
"""
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
raise TypeError(
|
||||
f"{cls.__module__}.{cls.__qualname__} has no public constructor. "
|
||||
)
|
||||
|
||||
def _create(cls, root, graph, range_constraints=None):
|
||||
return super().__call__(
|
||||
root,
|
||||
graph,
|
||||
range_constraints=range_constraints,
|
||||
)
|
||||
|
||||
|
||||
class _StatefulGraphModule(torch.fx.GraphModule, metaclass=_StatefulGraphModuleFactory):
|
||||
def __init__(self, root, graph, range_constraints=None):
|
||||
super().__init__(root, graph)
|
||||
# Need to fix up non-persistent buffers.
|
||||
self.range_constraints = range_constraints or []
|
||||
self.validate_inputs = True
|
||||
|
||||
|
||||
def _create_stateful_graph_module(
|
||||
plain_graph_module: torch.fx.GraphModule,
|
||||
range_constraints,
|
||||
ep: ExportedProgram,
|
||||
) -> _StatefulGraphModule:
|
||||
stateful_gm = _StatefulGraphModule._create(
|
||||
plain_graph_module,
|
||||
plain_graph_module.graph,
|
||||
range_constraints=range_constraints,
|
||||
)
|
||||
|
||||
module_types = _get_graph_inputs_of_type_nn_module(ep.example_inputs)
|
||||
stateful_gm.register_forward_pre_hook(
|
||||
lambda *args, **kwargs: _enter_enable_graph_inputs_of_type_nn_module(
|
||||
module_types
|
||||
)
|
||||
)
|
||||
stateful_gm.register_forward_pre_hook(
|
||||
_check_input_constraints_pre_hook, with_kwargs=True
|
||||
)
|
||||
|
||||
stateful_gm.register_forward_hook(
|
||||
lambda *args, **kwargs: _exit_enable_graph_inputs_of_type_nn_module(
|
||||
module_types
|
||||
),
|
||||
always_call=True,
|
||||
)
|
||||
|
||||
# When we have a constant that has requires_grad=True, we need to detach it
|
||||
# when we unlift as the tensors that require gradients should be registered
|
||||
# via parameters. But this is problematic when we have aliasing two constants
|
||||
# because when we call detach, they will become different tensors. This dict
|
||||
# keeps track of this logic.
|
||||
original_tensor_to_detached_tensor = {}
|
||||
|
||||
# Fix up lifted tensor constants.
|
||||
# fx.GraphModule() constructor silently turns a constant attribute of plain_graph_module
|
||||
# into a buffer in stateful_gm and creates an inconsistency with graph_signature.
|
||||
# We fix this by de-registering these buffers in lifted_tensor_constants
|
||||
# and call _assign_attr(attr_kind=CONSTANT) to register them as constants.
|
||||
for constant_fqn in ep.graph_signature.lifted_tensor_constants:
|
||||
# Sometimes, the constant can require gradient, this is probably a bug in user code,
|
||||
# e.g. `self.const = torch.randn(2, 2, requires_grad=True)`.
|
||||
# We call detach on the constant_val since they're tensor constants and we don't need to
|
||||
# compute their gradients anyway.
|
||||
# Users should properly register it as parameter if they want it to require gradient.
|
||||
buffer = stateful_gm.get_buffer(constant_fqn)
|
||||
if buffer.requires_grad:
|
||||
warnings.warn(
|
||||
f"A model attribute `{constant_fqn}` requires gradient. "
|
||||
f"but it's not properly registered as a parameter. "
|
||||
f"torch.export will detach it and treat it as a constant tensor "
|
||||
f"but please register it as parameter instead.",
|
||||
stacklevel=2,
|
||||
)
|
||||
detached_buffer = buffer.detach()
|
||||
original_tensor_to_detached_tensor[buffer] = detached_buffer
|
||||
buffer = detached_buffer
|
||||
*prefix, field = constant_fqn.rsplit(".")
|
||||
submod = torch.fx.graph_module._get_attr_via_attr_list(stateful_gm, prefix)
|
||||
delattr(submod, field)
|
||||
_assign_attr(buffer, stateful_gm, constant_fqn, attr_kind=_AttrKind.CONSTANT)
|
||||
|
||||
# Constants are not preserved well when we create a new GraphModule unlike param/buffers
|
||||
for const_name, value in ep.constants.items():
|
||||
if not torch.fx.graph_module._has_attr(stateful_gm, const_name):
|
||||
if isinstance(value, torch.Tensor):
|
||||
if value.requires_grad:
|
||||
warnings.warn(
|
||||
f"A model attribute `{const_name}` requires gradient "
|
||||
f"but it's not properly registered as a parameter. "
|
||||
f"torch.export will detach it and treat it as a constant tensor "
|
||||
f"but please register it as parameter instead.",
|
||||
stacklevel=2,
|
||||
)
|
||||
if value in original_tensor_to_detached_tensor:
|
||||
value = original_tensor_to_detached_tensor[value]
|
||||
else:
|
||||
detached_value = value.detach()
|
||||
original_tensor_to_detached_tensor[value] = detached_value
|
||||
value = detached_value
|
||||
_assign_attr(
|
||||
value,
|
||||
stateful_gm,
|
||||
const_name,
|
||||
attr_kind=_AttrKind.CONSTANT,
|
||||
)
|
||||
|
||||
# Fix up non-persistent buffers. torch.fx does not distinguish between
|
||||
# persistent and non-persistent buffers, so we must restore that distinction
|
||||
# here.
|
||||
for buffer in ep.graph_signature.non_persistent_buffers:
|
||||
_assign_attr(
|
||||
plain_graph_module.get_buffer(buffer),
|
||||
stateful_gm,
|
||||
buffer,
|
||||
attr_kind=_AttrKind.BUFFER,
|
||||
persistent=False,
|
||||
)
|
||||
|
||||
return stateful_gm
|
||||
|
||||
|
||||
def _get_input_paths(example_inputs, signature):
|
||||
"""
|
||||
Generate paths of placeholders, needed for generating the guards function.
|
||||
|
||||
NOTE: Here we make use of the example inputs used for export as well as
|
||||
the signature of the unlifted graph module (not preserved by export).
|
||||
"""
|
||||
|
||||
args, kwargs = example_inputs
|
||||
binded = signature.bind(*args, **kwargs)
|
||||
binded.apply_defaults()
|
||||
ctx = binded.arguments
|
||||
flat_example_inputs_with_paths = pytree.tree_leaves_with_path(ctx)
|
||||
return [path for path, _ in flat_example_inputs_with_paths]
|
||||
|
||||
|
||||
def _replace_sources(result_str: str, flat_input_paths: list[Any]):
|
||||
"""
|
||||
Given user specified input paths, maybe fix up the guard string
|
||||
to reflect user path instead of tracer path.
|
||||
"""
|
||||
name_mapping = {}
|
||||
for idx, path in enumerate(flat_input_paths):
|
||||
name_mapping[f"L['flat_args'][{idx}]"] = f"L{pytree.keystr(path)}"
|
||||
|
||||
replace = result_str
|
||||
for key, val in name_mapping.items():
|
||||
replace = replace.replace(key, val)
|
||||
return replace
|
||||
|
||||
|
||||
def _get_input_guards_for_graph(
|
||||
placeholders: list[torch.fx.Node],
|
||||
range_constraints: dict[sympy.Symbol, ValueRanges],
|
||||
paths_for_placeholders: list[pytree.KeyPath],
|
||||
):
|
||||
"""
|
||||
Guards generated by the tracer include conditions observed in code, but
|
||||
but do not include some additional checks we typically do in export.
|
||||
For example, when dynamic shapes get specialized, are specified to be
|
||||
within a range, or are specified to be in some equational relation,
|
||||
corresponding input invalidation is done within a pre_hook, specifically,
|
||||
`_check_input_constraints_for_graph`.
|
||||
|
||||
Here we generate guards corresponding to the checks that happen in
|
||||
`_check_input_constraints_for_graph`, and add them to the guards already
|
||||
generated by the tracer. In the future, it may be worthwhile to separate
|
||||
them so that we can allow clients to turn off one but not the other.
|
||||
(Looking at you, AOTI.)
|
||||
|
||||
NOTE: We should eventually reconcile this logic with `build_guards` that
|
||||
is used by AOT Precompile.
|
||||
"""
|
||||
|
||||
deferred_expressions = []
|
||||
new_guards_code = []
|
||||
sources: dict[sympy.Expr, str] = {}
|
||||
|
||||
def handle_symint(expr, src):
|
||||
if len(expr.free_symbols) == 1:
|
||||
# complex equations (e.g., involving derived dims) need to
|
||||
# handled later, since we may not have enough information
|
||||
# just as we are passing through the placeholders in order
|
||||
deferred_expressions.append((src, expr))
|
||||
if expr in sources:
|
||||
# expressions that appear in multiple sources should force
|
||||
# inputs corresponding to those sources to be equal
|
||||
# e.g., x.shape[0] == y.shape[1]
|
||||
orig_src = sources[expr]
|
||||
new_guards_code.append(f"{src} == {orig_src}")
|
||||
else:
|
||||
sources[expr] = src
|
||||
# process value ranges as elsewhere in export
|
||||
min_val, max_val = _convert_range_to_int(range_constraints[expr])
|
||||
if min_val > 2:
|
||||
new_guards_code.append(f"{src} >= {min_val}")
|
||||
if max_val < math.inf:
|
||||
new_guards_code.append(f"{src} <= {max_val}")
|
||||
|
||||
for placeholder, path in zip(placeholders, paths_for_placeholders):
|
||||
src = "L" + pytree.keystr(path)
|
||||
meta = placeholder.meta["val"]
|
||||
# specializations
|
||||
if isinstance(meta, int):
|
||||
new_guards_code.append(f"{src} == {meta}")
|
||||
if isinstance(meta, float):
|
||||
if meta == math.inf:
|
||||
new_guards_code.append(f"{src} == math.inf")
|
||||
elif meta == -math.inf:
|
||||
new_guards_code.append(f"{src} == -math.inf")
|
||||
else:
|
||||
new_guards_code.append(f"{src} == {meta}")
|
||||
elif isinstance(meta, str):
|
||||
new_guards_code.append(f"{src} == '{meta}'")
|
||||
# range constraints and equalities
|
||||
elif isinstance(meta, torch.SymInt) and meta.node.expr in range_constraints:
|
||||
handle_symint(meta.node.expr, src)
|
||||
elif isinstance(meta, torch.Tensor):
|
||||
for i, dim in enumerate(meta.shape):
|
||||
src = "L" + pytree.keystr(path) + f".size()[{i}]"
|
||||
if isinstance(dim, int):
|
||||
# specializations
|
||||
new_guards_code.append(f"{src} == {dim}")
|
||||
elif (
|
||||
isinstance(dim, torch.SymInt) and dim.node.expr in range_constraints
|
||||
):
|
||||
# range constraints and equalities
|
||||
handle_symint(dim.node.expr, src)
|
||||
|
||||
unification_map: dict[sympy.Symbol, sympy.Expr] = {}
|
||||
py_printer = torch.utils._sympy.printers.PythonPrinter()
|
||||
|
||||
# process complex equations (e.g., involving derived dims)
|
||||
for src, expr in deferred_expressions:
|
||||
# we know this is the only symbol in expr (see check above)
|
||||
symbol = next(iter(expr.free_symbols))
|
||||
if symbol in sources:
|
||||
# if s0 is already known to be directly sourced from inputs,
|
||||
# e.g., z.shape[2], we do not need to do anything further
|
||||
# (assume we have already processed constraints on s0 above)
|
||||
continue
|
||||
|
||||
# otherwise s0 has some "hidden" source like 'dim'
|
||||
# example: src = y.shape[1], expr = s0 + 1
|
||||
if symbol in unification_map:
|
||||
# suppose that we already know that s0 = x.shape[0] * 2
|
||||
# so we can emit the guard: x.shape[0] * 2 + 1 = y.shape[1]
|
||||
substitution = expr.subs(unification_map)
|
||||
new_guards_code.append(
|
||||
py_printer.doprint(sympy.Eq(substitution, sympy.Symbol(src)))
|
||||
)
|
||||
else:
|
||||
# we do not yet know what s0 is, but given s0 + 1 = y.shape[1],
|
||||
# we can solve for s0...now knowing that s0 = y.shape[1] - 1
|
||||
solution = try_solve(sympy.Eq(expr, sympy.Symbol(src)), symbol)
|
||||
if solution is not None:
|
||||
definition = solution[1]
|
||||
unification_map[symbol] = definition
|
||||
|
||||
return new_guards_code
|
||||
|
||||
|
||||
def _ok_to_generate_guards_fn():
|
||||
patterns = [
|
||||
"executorch",
|
||||
"modai",
|
||||
"on_device_ai",
|
||||
"torchao",
|
||||
]
|
||||
# force check_guards=False for files matching `patterns`
|
||||
# because they have too many calls to .module() and
|
||||
# do not like any call modules in the graph
|
||||
# TODO: fix these files to handle guard fns
|
||||
frame = inspect.currentframe()
|
||||
while frame is not None:
|
||||
if any(path in frame.f_code.co_filename for path in patterns):
|
||||
return False
|
||||
frame = frame.f_back
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _unlift_exported_program_lifted_states(
|
||||
ep: ExportedProgram, check_guards=True
|
||||
) -> torch.fx.GraphModule:
|
||||
check_guards = check_guards and _ok_to_generate_guards_fn()
|
||||
|
||||
source_node_dict = {
|
||||
node.name: node for node in ep.graph.nodes if node.op != "placeholder"
|
||||
}
|
||||
# placeholder node name might change after deepcopy
|
||||
placeholder_source_node_dict = {
|
||||
node.target: node for node in ep.graph.nodes if node.op == "placeholder"
|
||||
}
|
||||
|
||||
new_gm = torch.fx.GraphModule(ep.graph_module, copy.deepcopy(ep.graph))
|
||||
new_gm.meta.update(ep.graph_module.meta)
|
||||
ep = copy.copy(ep)
|
||||
ep._graph_signature = ExportGraphSignature(
|
||||
ep._graph_signature.input_specs, ep._graph_signature.output_specs
|
||||
)
|
||||
ep._graph_module = new_gm
|
||||
|
||||
# TODO T206340015
|
||||
if ep.verifiers[0].dialect != "TRAINING":
|
||||
ep = _remove_effect_tokens(ep)
|
||||
|
||||
_register_attrs_to_new_gm(new_gm, ep.graph_signature, ep.state_dict, ep.constants)
|
||||
forward_arg_names = (
|
||||
sig.forward_arg_names if (sig := ep.module_call_graph[0].signature) else None
|
||||
)
|
||||
lifted_inputs: list[str | None] = [
|
||||
(
|
||||
in_spec.target
|
||||
if in_spec.kind
|
||||
in (
|
||||
InputKind.BUFFER,
|
||||
InputKind.CONSTANT_TENSOR,
|
||||
InputKind.PARAMETER,
|
||||
InputKind.CUSTOM_OBJ,
|
||||
)
|
||||
else None
|
||||
)
|
||||
for in_spec in ep.graph_signature.input_specs
|
||||
]
|
||||
|
||||
mutated_outputs: list[str | None] = [
|
||||
(
|
||||
out_spec.target
|
||||
if out_spec.kind
|
||||
in (
|
||||
OutputKind.BUFFER_MUTATION,
|
||||
OutputKind.USER_INPUT_MUTATION,
|
||||
OutputKind.PARAMETER_MUTATION,
|
||||
)
|
||||
else None
|
||||
)
|
||||
for out_spec in ep.graph_signature.output_specs
|
||||
]
|
||||
|
||||
for node in new_gm.graph.nodes:
|
||||
source_node = None
|
||||
if node.op == "placeholder":
|
||||
source_node = placeholder_source_node_dict.get(node.target)
|
||||
else:
|
||||
if node.name in source_node_dict:
|
||||
source_node = source_node_dict.get(node.name)
|
||||
node.meta["from_node"] = [
|
||||
NodeSource(
|
||||
source_node,
|
||||
"ExportedProgram.module()",
|
||||
NodeSourceAction.CREATE,
|
||||
)
|
||||
]
|
||||
|
||||
if ep.call_spec.in_spec is None:
|
||||
raise AssertionError("ep.call_spec.in_spec cannot be None")
|
||||
new_gm = _unlift(
|
||||
new_gm,
|
||||
lifted_inputs,
|
||||
mutated_outputs,
|
||||
ep.call_spec.in_spec,
|
||||
ep.call_spec.out_spec,
|
||||
forward_arg_names=forward_arg_names,
|
||||
)
|
||||
unlift_gm = _create_stateful_graph_module(new_gm, ep.range_constraints, ep)
|
||||
unlift_gm.meta.update(ep.graph_module.meta)
|
||||
|
||||
# create a _guards_fn submodule and insert a call to it after placeholders
|
||||
graph = unlift_gm.graph
|
||||
placeholders = graph.find_nodes(op="placeholder")
|
||||
if check_guards and placeholders and ep.example_inputs:
|
||||
sig = inspect.signature(unlift_gm.forward)
|
||||
input_paths = _get_input_paths(
|
||||
ep.example_inputs,
|
||||
sig,
|
||||
)
|
||||
|
||||
# TODO (tmanlaibaatar)
|
||||
# This is band-aid solution to export new tracer replacing
|
||||
# shape env sources to flat_args. The real fix should be replacing
|
||||
# shape env sources to original user sources but this is quite
|
||||
# involved because you need to carefully construct new sources using
|
||||
# dynamo and replace all instances of it inside shape env. But it is
|
||||
# lot easier to manipulate after we turn them into strings and only
|
||||
# time we use these guards is during retracing or running exported program,
|
||||
# so it is probably ok to have "not useful" guards on ep for now.
|
||||
ep_guards = []
|
||||
for guard in ep._guards_code:
|
||||
ep_guards.append(_replace_sources(guard, input_paths))
|
||||
|
||||
guards_code = _get_input_guards_for_graph(
|
||||
placeholders, ep.range_constraints, input_paths
|
||||
)
|
||||
|
||||
ep_guards_code = _force_ep_signature_match(ep._guards_code, input_paths)
|
||||
ep_guards_code = _force_gm_signature_match(ep_guards_code, sig)
|
||||
guards_code.extend(ep_guards_code)
|
||||
unlift_gm._guards_fn = _convert_guards_code_to_fn(guards_code, input_paths)
|
||||
|
||||
root_nn_module_stack = torch.fx._utils.first_call_function_nn_module_stack(
|
||||
graph
|
||||
)
|
||||
with graph.inserting_after(placeholders[-1]):
|
||||
node = graph.call_module("_guards_fn", tuple(placeholders))
|
||||
node.meta["nn_module_stack"] = root_nn_module_stack
|
||||
|
||||
unlift_gm.recompile()
|
||||
|
||||
return unlift_gm
|
||||
|
||||
|
||||
class GuardsFn(torch.nn.Module):
|
||||
"""
|
||||
Module class for guard functions.
|
||||
"""
|
||||
|
||||
def forward(self, *args):
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
import torch
|
||||
|
||||
|
||||
class _WrapperModule(torch.nn.Module):
|
||||
def __init__(self, f): # type: ignore[no-untyped-def]
|
||||
super().__init__()
|
||||
self.f = f
|
||||
|
||||
def forward(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return self.f(*args, **kwargs)
|
||||
@@ -0,0 +1,16 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
__all__ = ["ScriptObjectMeta"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScriptObjectMeta:
|
||||
"""
|
||||
Metadata which is stored on nodes representing ScriptObjects.
|
||||
"""
|
||||
|
||||
# Key into constants table to retrieve the real ScriptObject.
|
||||
constant_name: str
|
||||
|
||||
class_fqn: str
|
||||
@@ -0,0 +1,56 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import importlib
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
lib = torch.library.Library("export", "FRAGMENT") # noqa: TOR901
|
||||
|
||||
lib.define(
|
||||
"access_subclass_inner_tensor(Tensor src_subclass_tensor, str attr) -> Tensor"
|
||||
)
|
||||
|
||||
|
||||
@torch.library.impl(lib, "access_subclass_inner_tensor", "Autograd")
|
||||
# When running under torch.inference_mode(), we seem to skip AUtograd key
|
||||
# so we should desugar this op as soon as we start tracing to post-dispatch.
|
||||
@torch.library.impl(lib, "access_subclass_inner_tensor", "Python")
|
||||
def _access_subclass_inner_tensor(
|
||||
src_subclass_tensor: torch.Tensor, attr: str
|
||||
) -> torch.Tensor:
|
||||
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
|
||||
|
||||
if not is_traceable_wrapper_subclass(src_subclass_tensor):
|
||||
raise AssertionError(
|
||||
f"Expected src_subclass_tensor to be a traceable wrapper subclass, "
|
||||
f"but got {type(src_subclass_tensor)}"
|
||||
)
|
||||
val = getattr(src_subclass_tensor, attr, None)
|
||||
if val is None or not isinstance(val, torch.Tensor):
|
||||
raise RuntimeError(
|
||||
f"Attribute {attr} is not a tensor or doesn't exist in {src_subclass_tensor}"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def _call_custom_autograd_function_in_pre_dispatch(function_cls_name, *args, **kwargs):
|
||||
"""
|
||||
Import a custom autograd function by string name and call it. This is pretty bad
|
||||
because:
|
||||
1) There is no schema
|
||||
|
||||
Ideally we should automatically wrap custom autograd functions with a custom op, but
|
||||
that is too much work because we need to schematize custom autograd functions. For now,
|
||||
we just hackily put it in the IR.
|
||||
"""
|
||||
# Parse module and class name
|
||||
module_name, class_name = function_cls_name.rsplit(".", 1)
|
||||
|
||||
# Import the module and get the class
|
||||
module = importlib.import_module(module_name)
|
||||
function_cls = getattr(module, class_name)
|
||||
if not hasattr(function_cls, "apply"):
|
||||
raise AssertionError(
|
||||
f"Expected function class {function_cls_name} to have 'apply' method"
|
||||
)
|
||||
return function_cls.apply(*args, **kwargs)
|
||||
@@ -0,0 +1,160 @@
|
||||
# mypy: allow-untyped-defs
|
||||
from collections.abc import Callable
|
||||
|
||||
import torch
|
||||
from torch._export.utils import (
|
||||
_collect_all_valid_cia_ops,
|
||||
_collect_all_valid_cia_ops_for_aten_namespace,
|
||||
_get_decomp_for_cia,
|
||||
_is_aten_op,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["CustomDecompTable"]
|
||||
|
||||
|
||||
"""
|
||||
Core ATen ops with Composite Implicit Autograd dispatch that should be excluded from decomposition
|
||||
by default. The decomposition logic should eventually exclude all core-tagged CIA ops, but until all
|
||||
backends are ready, this list allows opt-in one at a time.
|
||||
"""
|
||||
PRESERVED_ATEN_CIA_OPS = {
|
||||
torch.ops.aten.upsample_bilinear2d.vec,
|
||||
torch.ops.aten.upsample_nearest2d.vec,
|
||||
# NB: don't use the C++ decomp, because it is not functional!
|
||||
torch.ops.aten.silu_backward.default,
|
||||
torch.ops.aten.mish_backward.default,
|
||||
torch.ops.aten._fused_rms_norm.default,
|
||||
}
|
||||
|
||||
|
||||
class CustomDecompTable(dict[torch._ops.OperatorBase, Callable]):
|
||||
"""
|
||||
This is a custom dictionary that is specifically used for handling decomp_table in export.
|
||||
The reason we need this is because in the new world, you can only *delete* an op from decomp
|
||||
table to preserve it. This is problematic for custom ops because we don't know when the custom
|
||||
op will actually be loaded to the dispatcher. As a result, we need to record the custom ops operations
|
||||
until we really need to materialize it (which is when we run decomposition pass.)
|
||||
|
||||
Invariants we hold are:
|
||||
1. All aten decomp is loaded at the init time
|
||||
2. We materialize ALL ops when user ever reads from the table to make it more likely
|
||||
that dispatcher picks up the custom op.
|
||||
3. If it is write operation, we don't necessarily materialize
|
||||
4. We load the final time during export, right before calling run_decompositions()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from torch._decomp import _core_aten_decompositions_post_autograd
|
||||
|
||||
# For aten ops, we load them up in the beginning
|
||||
self.decomp_table = _core_aten_decompositions_post_autograd()
|
||||
|
||||
for op in _collect_all_valid_cia_ops_for_aten_namespace():
|
||||
if op not in PRESERVED_ATEN_CIA_OPS and op not in self.decomp_table:
|
||||
self.decomp_table[op] = _get_decomp_for_cia(op)
|
||||
|
||||
# This is to track the *pending* deleted custom ops that haven't been materialized yet
|
||||
self.deleted_custom_ops = set()
|
||||
# When this is true, there shouldn't be any pending operations in the table.
|
||||
self.has_materialized = False
|
||||
|
||||
def __getitem__(self, key):
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.__getitem__(key)
|
||||
|
||||
def __setitem__(self, key, value) -> None:
|
||||
self.decomp_table.__setitem__(key, value)
|
||||
|
||||
if key in self.deleted_custom_ops:
|
||||
self.deleted_custom_ops.remove(key)
|
||||
|
||||
def keys(self):
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.keys()
|
||||
|
||||
def __delitem__(self, key) -> None:
|
||||
self.pop(key)
|
||||
|
||||
def update(self, other_dict): # type: ignore[override]
|
||||
for k, v in other_dict.items():
|
||||
self.decomp_table.__setitem__(k, v)
|
||||
|
||||
def __missing__(self, key) -> bool:
|
||||
return not self.__contains__(key)
|
||||
|
||||
def __contains__(self, key) -> bool:
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.__contains__(key)
|
||||
|
||||
def __len__(self) -> int:
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.__len__()
|
||||
|
||||
def __iter__(self):
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.__iter__()
|
||||
|
||||
def __reversed__(self):
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.__reversed__()
|
||||
|
||||
def copy(self) -> "CustomDecompTable":
|
||||
new_dict = CustomDecompTable()
|
||||
new_dict.decomp_table = self.decomp_table.copy()
|
||||
new_dict.deleted_custom_ops = self.deleted_custom_ops.copy()
|
||||
new_dict.has_materialized = self.has_materialized
|
||||
return new_dict
|
||||
|
||||
def pop(self, *args):
|
||||
def _pop_if_can(key):
|
||||
if _is_aten_op(key):
|
||||
return self.decomp_table.pop(key)
|
||||
|
||||
if key in self.decomp_table:
|
||||
# Even if we materialized it, we should add it to the deleted
|
||||
# custom ops list so that when we materialize next time,
|
||||
# we should respect user's intention.
|
||||
self.deleted_custom_ops.add(key)
|
||||
return self.decomp_table.pop(key)
|
||||
|
||||
if key in self.deleted_custom_ops:
|
||||
raise KeyError(f"{key} doesn't exist in the table")
|
||||
|
||||
self.deleted_custom_ops.add(key)
|
||||
# We would come here when user pops off something that is
|
||||
# not in the table. In this case, we just pretend that it
|
||||
# was in the table.
|
||||
return _get_decomp_for_cia(key)
|
||||
|
||||
if len(args) == 1:
|
||||
return _pop_if_can(args[0])
|
||||
|
||||
if len(args) == 2:
|
||||
try:
|
||||
return _pop_if_can(args[0])
|
||||
except KeyError:
|
||||
return args[1]
|
||||
|
||||
def items(self):
|
||||
self._materialize_if_needed()
|
||||
return self.decomp_table.items()
|
||||
|
||||
def materialize(self) -> dict[torch._ops.OperatorBase, Callable]:
|
||||
for op in _collect_all_valid_cia_ops():
|
||||
if _is_aten_op(op):
|
||||
continue
|
||||
elif op in self.decomp_table:
|
||||
continue
|
||||
elif op not in self.deleted_custom_ops:
|
||||
self.decomp_table[op] = _get_decomp_for_cia(op)
|
||||
|
||||
self.has_materialized = True
|
||||
self.deleted_custom_ops = set()
|
||||
return {**self.decomp_table}
|
||||
|
||||
def _materialize_if_needed(self) -> None:
|
||||
if not self.has_materialized:
|
||||
self.materialize()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
import copy
|
||||
import dataclasses
|
||||
import functools
|
||||
import os
|
||||
import types
|
||||
import typing
|
||||
import typing_extensions
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from torch.export.experimental._utils import _get_main_cpp_file, _get_make_file
|
||||
from torch.export.exported_program import _decompose_exported_program
|
||||
from torch.utils._ordered_set import OrderedSet
|
||||
|
||||
|
||||
_InputT = typing_extensions.ParamSpec("_InputT")
|
||||
_RetT = typing.TypeVar("_RetT")
|
||||
|
||||
|
||||
__all__ = [] # type: ignore[var-annotated]
|
||||
|
||||
|
||||
def _copy_graph_module_and_signature(
|
||||
ep: torch.export.ExportedProgram,
|
||||
) -> tuple[torch.fx.GraphModule, torch.export.graph_signature.ExportGraphSignature]:
|
||||
# copy.deepcopy lets the objects override __deepcopy__ methods with graph_copy() and node_copy(),
|
||||
# and this can break placeholder names in some particular cases.
|
||||
# For example, node copying will avoid Python keywords like 'input', suffixing and renaming to 'input_1'.
|
||||
# So we manually overwrite placeholder names by reading the old graph.
|
||||
gm = copy.deepcopy(ep.graph_module)
|
||||
new_graph_signature = copy.deepcopy(ep.graph_signature)
|
||||
|
||||
# iterate over old/new graph modules
|
||||
for old_gm, new_gm in zip(ep.graph_module.modules(), gm.modules()): # type: ignore[union-attr]
|
||||
old_phs = [node for node in old_gm.graph.nodes if node.op == "placeholder"]
|
||||
new_phs = [node for node in new_gm.graph.nodes if node.op == "placeholder"]
|
||||
# iterate over placeholders
|
||||
if len(old_phs) != len(new_phs):
|
||||
raise AssertionError(
|
||||
f"Number of old placeholders ({len(old_phs)}) does not match "
|
||||
f"new placeholders ({len(new_phs)})"
|
||||
)
|
||||
for old_node, new_node in zip(old_phs, new_phs):
|
||||
new_node.name = old_node.name
|
||||
|
||||
return gm, new_graph_signature
|
||||
|
||||
|
||||
def _remove_detach_pass(
|
||||
gm: torch.fx.GraphModule, sig: torch.export.graph_signature.ExportGraphSignature
|
||||
) -> None:
|
||||
with gm._set_replace_hook(sig.get_replace_hook()):
|
||||
for node in list(reversed(gm.graph.nodes)):
|
||||
if node.op != "call_function":
|
||||
continue
|
||||
if (
|
||||
node.target is torch.ops.aten.detach.default
|
||||
and len(node.users) == 1
|
||||
and next(iter(node.users)).target is torch.ops.aten.detach.default
|
||||
):
|
||||
next(iter(node.users)).replace_all_uses_with(node)
|
||||
|
||||
gm.graph.eliminate_dead_code()
|
||||
gm.recompile()
|
||||
|
||||
|
||||
def _export_forward_backward(
|
||||
ep: torch.export.ExportedProgram, joint_loss_index: int = 0
|
||||
) -> torch.export.ExportedProgram:
|
||||
"""
|
||||
WARNING: This API is highly unstable and will be subject to change in the future.
|
||||
"""
|
||||
from torch._decomp import core_aten_decompositions
|
||||
|
||||
ep = _decompose_exported_program(
|
||||
ep,
|
||||
cia_to_decomp={},
|
||||
python_decomp_table=core_aten_decompositions(),
|
||||
joint_loss_index=joint_loss_index,
|
||||
# For serialization purpose, we don't want to decompose custom triton ops.
|
||||
# If users would like to decompose custom triton ops, they could do it
|
||||
# with run_decompositions() API.
|
||||
decompose_custom_triton_ops=False,
|
||||
)
|
||||
gm, new_graph_signature = _copy_graph_module_and_signature(ep)
|
||||
_remove_detach_pass(gm, new_graph_signature)
|
||||
|
||||
return ep._update(gm, new_graph_signature)
|
||||
|
||||
|
||||
def _sticky_export(
|
||||
forward_func: typing.Callable[_InputT, _RetT],
|
||||
dynamic_shapes_callback: typing.Callable[
|
||||
_InputT, list[typing.Any] | dict[str, typing.Any] | tuple[typing.Any, ...]
|
||||
]
|
||||
| None = None,
|
||||
) -> typing.Callable[_InputT, _RetT]:
|
||||
"""
|
||||
Lazily export the model on first forward call.
|
||||
Usage:
|
||||
model.forward = _sticky_export(model.forward, dynamic_shapes_callback=callback)
|
||||
"""
|
||||
model = forward_func.__self__ # type: ignore[attr-defined]
|
||||
original_forward = forward_func.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(forward_func)
|
||||
def wrapper(*args: _InputT.args, **kwargs: _InputT.kwargs) -> _RetT:
|
||||
# Unpatch forward to avoid recursion during export
|
||||
model.forward = types.MethodType(original_forward, model)
|
||||
|
||||
dynamic_shapes_spec = None
|
||||
if dynamic_shapes_callback:
|
||||
dynamic_shapes_spec = dynamic_shapes_callback(*args, **kwargs)
|
||||
|
||||
try:
|
||||
exported = torch.export.export(
|
||||
model,
|
||||
args,
|
||||
kwargs,
|
||||
dynamic_shapes=dynamic_shapes_spec,
|
||||
).module()
|
||||
wrapper._exported_artifact = exported # type: ignore[attr-defined]
|
||||
finally:
|
||||
# Restore the wrapper after export
|
||||
model.forward = wrapper
|
||||
|
||||
return exported(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _ExportMethod:
|
||||
overloads: dict[str, torch.export.ExportedProgram]
|
||||
fallbacks: list[torch.export.ExportedProgram]
|
||||
|
||||
|
||||
class _ExportPackage:
|
||||
"""
|
||||
An export package is a collection of torch.export()-ed PyTorch models consisting of
|
||||
a list of exported methods and their corresponding overloads. ExportPackage is introduced
|
||||
on top of torch.export() to support the following use cases:
|
||||
- Exporting a model with multiple methods if a model has multiple independent parts.
|
||||
- Exporting a function with multiple overloads based on tensor shapes or other metadata.
|
||||
|
||||
ExportPackage is designed to contain multiple methods (associated with method names) and for
|
||||
each method, it can have multiple overloads (associated with overload names).
|
||||
|
||||
Here is an example of the data structure for an ExportPackage:
|
||||
```
|
||||
ExportPackage(
|
||||
methods={
|
||||
"decoder": ExportMethod(
|
||||
overloads={
|
||||
"prefill": ExportedProgram(...),
|
||||
"decode": ExportedProgram(...),
|
||||
},
|
||||
fallbacks=[],
|
||||
),
|
||||
"encoder": ExportMethod(overloads={}, fallbacks=[ExportedProgram(...)]),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
To export a model into an ExportPackage, users can use the exporter API provided by ExportPackage.
|
||||
Exporter is a decorator that takes a callable and returns a wrapper. The wrapper will export the
|
||||
function into an ExportPackage, when it's invoked with some sample inputs (similar to how
|
||||
torch.compile() works). For more details, please refer to the document on .exporter() method.
|
||||
|
||||
This design allows users to decouple the exported callables from the actual sample inputs which can
|
||||
be helpful for use cases where the exported callable is hidden behind helper functions or when sample
|
||||
inpusts are hard to get.
|
||||
|
||||
NOTE: This is an experimental API and anything can be changed in the future.
|
||||
|
||||
Example usage:
|
||||
```
|
||||
def fn(x):
|
||||
return x + 1
|
||||
|
||||
def main(f, x):
|
||||
x += 1
|
||||
ret = f(x)
|
||||
return ret + 1
|
||||
|
||||
package = ExportPackage()
|
||||
main(package.exporter(fn), torch.randn(3, 2))
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.methods: dict[str, _ExportMethod] = {}
|
||||
|
||||
def _exporter(
|
||||
self,
|
||||
method: str,
|
||||
fn: typing.Callable[_InputT, _RetT],
|
||||
*,
|
||||
fallback: str = "once",
|
||||
) -> typing.Callable[_InputT, _RetT]:
|
||||
"""
|
||||
A function/module decorator that sets up a callable to be exported later invoked.
|
||||
By default the exporter will only trigger torch.export for once and error on
|
||||
later invocations. To customize this behavior, users have the following two options:
|
||||
1. Call .define_overload() method on the returned wrapper to define an overload.
|
||||
2. Adjust the fallback policy using `fallback` argument.
|
||||
|
||||
An "overload" is a named branch for an ExportMethod with a user defined precondition,
|
||||
typically based on input tensor shapes. It's up to a downstream backend implementation
|
||||
of ExportMethod to respect the precondition later in inference.
|
||||
|
||||
define_overload() takes arguments like the following:
|
||||
- A name, for indexing purposes in a backend.
|
||||
- A callable (spec) that:
|
||||
- Has the same model input signature as the original model code.
|
||||
- Returns an optional dynamic shape spec.
|
||||
|
||||
Exporter will only export an overload when the spec callable successfully returns
|
||||
a result without raising AssertionError.
|
||||
|
||||
For example:
|
||||
```
|
||||
package = ExportPackage()
|
||||
|
||||
|
||||
def prefill(x, xa, kv_cache):
|
||||
assert x.shape[1] == 3
|
||||
assert kv_cache == {}
|
||||
|
||||
|
||||
def decode(x, xa, kv_cache):
|
||||
assert x.shape[1] > 1
|
||||
assert len(kv_cache) > 0
|
||||
return {...} # dynamic shape specs here
|
||||
|
||||
|
||||
exporter = (
|
||||
package.exporter(decoder)
|
||||
.define_overload("prefill", prefill)
|
||||
.define_overload("decode", decode)
|
||||
)
|
||||
```
|
||||
|
||||
A "fallback" is exported when no overload precondition matches a given set of sample
|
||||
inputs. Overloads should
|
||||
Fallbacks don't have names and are ordered in a list. It's up to a backend to decide
|
||||
which fallback is used amony multiple ones.
|
||||
|
||||
A reference backend implementation of ExportMethod may look like the following:
|
||||
```
|
||||
def execute(method: ExportMethod, *args, **kwargs):
|
||||
for overload in method.overloads:
|
||||
if match_precondition(overload, *args, **kwargs):
|
||||
return execute_overload(overload, *args, **kwargs)
|
||||
for fallback in method.fallbacks:
|
||||
if match_precondition(fallback, *args, **kwargs):
|
||||
return execute_fallback(fallback, *args, **kwargs)
|
||||
```
|
||||
|
||||
Args:
|
||||
method(str): The method name for an exported part of PyTorch model. This
|
||||
will be saved together with the exported/compiled artifacts
|
||||
in any serialization format and can be used as the key to
|
||||
index ExportPackage methods later.
|
||||
fn(callable): A PyTorch function/module to be exported.
|
||||
fallback(str): The fallback policy to decide when to call torch.export
|
||||
- "once" is the default policy. Under this policy a PyTorch program is assumed
|
||||
to be only called once later and an error will be raised for subsequent
|
||||
runs.
|
||||
- "error" means the ExportMethod will never have any fallbacks, meaning
|
||||
users should define all the possible overloads ahead of time.
|
||||
|
||||
"""
|
||||
|
||||
fallbacks: list[torch.export.ExportedProgram] = []
|
||||
specs: dict[str, typing.Callable[_InputT, typing.Any]] = {}
|
||||
overloads: dict[str, torch.export.ExportedProgram] = {}
|
||||
self.methods[method] = _ExportMethod(fallbacks=fallbacks, overloads=overloads)
|
||||
|
||||
@functools.wraps(fn)
|
||||
def _exporter_context(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
import torch.export._wrapper_utils
|
||||
|
||||
model: torch.nn.Module
|
||||
if not isinstance(fn, torch.nn.Module):
|
||||
model = torch.export._wrapper_utils._WrapperModule(fn)
|
||||
else:
|
||||
model = fn
|
||||
|
||||
for k, v in specs.items():
|
||||
try:
|
||||
if isinstance(fn, torch.nn.Module):
|
||||
dynamic_shapes = v(fn, *args, **kwargs) # type: ignore[arg-type]
|
||||
else:
|
||||
# pyrefly: ignore [invalid-param-spec]
|
||||
dynamic_shapes = v(*args, **kwargs)
|
||||
except AssertionError:
|
||||
continue
|
||||
if k not in overloads:
|
||||
ep = torch.export.export(
|
||||
model, args, kwargs, dynamic_shapes=dynamic_shapes
|
||||
)
|
||||
overloads[k] = ep
|
||||
ep = overloads[k]
|
||||
return ep.module()(*args, **kwargs)
|
||||
|
||||
if fallback == "error":
|
||||
raise RuntimeError(
|
||||
f"Exporter: Cannot export fallback {fn} when fallback policy is set to 'error',"
|
||||
+ "please specify an overload or adjust the fallback policy."
|
||||
)
|
||||
elif fallback == "once":
|
||||
if len(fallbacks) > 0:
|
||||
raise RuntimeError(
|
||||
f"Exporter: Cannot export {fn} more than once, "
|
||||
+ "please specify an overload or adjust the fallback policy."
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown fallback policy: {fallback}")
|
||||
ep = torch.export.export(model, args, kwargs)
|
||||
|
||||
fallbacks.append(ep)
|
||||
return ep.module()(*args, **kwargs)
|
||||
|
||||
if isinstance(fn, torch.nn.Module):
|
||||
_exporter_context = torch._dynamo.eval_frame.OptimizedModule( # type: ignore[assignment] # noqa: F811
|
||||
fn,
|
||||
lambda _: _exporter_context, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _define_overload(
|
||||
overload: str, spec: typing.Callable[_InputT, typing.Any]
|
||||
) -> typing.Any:
|
||||
if overload in specs:
|
||||
raise AssertionError(f"Overload '{overload}' already exists in specs")
|
||||
if not callable(spec):
|
||||
raise AssertionError(f"spec must be callable, but got {type(spec)}")
|
||||
if not overload.isidentifier():
|
||||
raise AssertionError(
|
||||
f"Overload '{overload}' is not a valid Python identifier"
|
||||
)
|
||||
specs[overload] = spec
|
||||
return _exporter_context
|
||||
|
||||
if hasattr(fn, "_define_overload"):
|
||||
raise AssertionError("fn already has a '_define_overload' attribute")
|
||||
_exporter_context._define_overload = _define_overload # type: ignore[attr-defined]
|
||||
|
||||
# pyrefly: ignore [bad-return]
|
||||
return _exporter_context
|
||||
|
||||
@property
|
||||
def _method_overloads(
|
||||
self,
|
||||
) -> typing.Iterator[tuple[str, torch.export.ExportedProgram]]:
|
||||
for method, method_data in self.methods.items():
|
||||
for overload, ep in method_data.overloads.items():
|
||||
yield f"{method}:{overload}", ep
|
||||
|
||||
def _compiled_and_package(
|
||||
self,
|
||||
f: torch.types.FileLike,
|
||||
standalone: bool = False,
|
||||
package_example_inputs: bool = False,
|
||||
) -> None:
|
||||
options: dict[str, typing.Any] = {
|
||||
"aot_inductor.package": True,
|
||||
"aot_inductor.package_cpp_only": True,
|
||||
"always_keep_tensor_constants": True,
|
||||
# we'll change this back to False once we enable weight deduping for standalone mode
|
||||
"aot_inductor.package_constants_in_so": standalone,
|
||||
"aot_inductor_mode.compile_standalone": standalone,
|
||||
}
|
||||
aoti_files_map = {}
|
||||
model_names = []
|
||||
device_type = "cpu"
|
||||
for name, ep in self._method_overloads:
|
||||
name = name.replace(":", "__")
|
||||
model_names.append(name)
|
||||
options["aot_inductor.model_name_for_generated_files"] = name
|
||||
aoti_files = torch._inductor.aot_compile(
|
||||
ep.module(), # type: ignore[arg-type]
|
||||
ep.example_inputs[0],
|
||||
kwargs=ep.example_inputs[1],
|
||||
options=options,
|
||||
)
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
aoti_files_map[name] = aoti_files
|
||||
|
||||
from torch._inductor.package import package
|
||||
|
||||
pt2_path = package.package_aoti(
|
||||
f,
|
||||
aoti_files_map, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if not standalone:
|
||||
return
|
||||
|
||||
if not isinstance(pt2_path, str):
|
||||
raise AssertionError(
|
||||
f"Expected pt2_path to be a string, but got {type(pt2_path)}"
|
||||
)
|
||||
base_directory = os.path.dirname(pt2_path)
|
||||
package_name = os.path.basename(pt2_path)[:-4]
|
||||
with zipfile.ZipFile(pt2_path, "r") as zip_ref:
|
||||
zip_ref.extractall(base_directory)
|
||||
|
||||
example_inputs_map: dict[str, int] | None = (
|
||||
{} if package_example_inputs else None
|
||||
)
|
||||
for name, ep in self._method_overloads:
|
||||
name = name.replace(":", "__")
|
||||
# TODO: also dump kwargs
|
||||
# TODO: currently only support list of Tensors and they need to be on the same device
|
||||
if not ep.example_inputs:
|
||||
continue
|
||||
|
||||
device_types: OrderedSet[str] = OrderedSet()
|
||||
|
||||
for inp in ep.example_inputs[0]:
|
||||
if isinstance(inp, torch.Tensor):
|
||||
device_types.add(inp.device.type)
|
||||
device_types.discard("cpu")
|
||||
if len(device_types) > 1:
|
||||
raise AssertionError(
|
||||
"Does not support mixing {}".format("+".join(list(device_types)))
|
||||
)
|
||||
device_type = "cpu" if len(device_types) == 0 else device_types.pop()
|
||||
|
||||
if package_example_inputs:
|
||||
if example_inputs_map is None:
|
||||
raise AssertionError(
|
||||
"example_inputs_map cannot be None when package_example_inputs is True"
|
||||
)
|
||||
example_inputs_map[name] = len(ep.example_inputs[0])
|
||||
for i, t in enumerate(ep.example_inputs[0]):
|
||||
path = Path(base_directory) / f"{name}_input_{i}.pt"
|
||||
torch.save(t, path)
|
||||
|
||||
cmake_file_str = _get_make_file(
|
||||
package_name, model_names, device_type=device_type
|
||||
)
|
||||
|
||||
with open(Path(base_directory) / "CMakeLists.txt", "w") as file:
|
||||
file.write(cmake_file_str)
|
||||
|
||||
main_file_str = _get_main_cpp_file(
|
||||
package_name, model_names, example_inputs_map, device_type=device_type
|
||||
)
|
||||
with open(Path(base_directory) / "main.cpp", "w") as file:
|
||||
file.write(main_file_str)
|
||||
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
|
||||
import torch
|
||||
from torch._inductor.utils import IndentedBuffer
|
||||
|
||||
|
||||
__all__ = [] # type: ignore[var-annotated]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_main_cpp_file(
|
||||
package_name: str,
|
||||
model_names: list[str],
|
||||
example_inputs_map: dict[str, int] | None,
|
||||
device_type: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generates a main.cpp file for AOTInductor standalone models in the specified package.
|
||||
|
||||
Args:
|
||||
package_name (str): Name of the package containing the models.
|
||||
model_names (List[str]): List of model names to include in the generated main.cpp.
|
||||
cuda (bool): Whether to generate code with CUDA support.
|
||||
example_inputs_map (Optional[Dict[str, List[Tensor]]]): A mapping from model name to
|
||||
its list of example input tensors. If provided, the generated main.cpp will
|
||||
load and run these inputs.
|
||||
|
||||
Returns:
|
||||
str: The contents of the generated main.cpp file as a string.
|
||||
"""
|
||||
|
||||
ib = IndentedBuffer()
|
||||
|
||||
ib.writelines(
|
||||
[
|
||||
"#include <dlfcn.h>",
|
||||
"#include <fstream>",
|
||||
"#include <iostream>",
|
||||
"#include <memory>",
|
||||
"#include <torch/torch.h>",
|
||||
"#include <vector>",
|
||||
"#include <torch/csrc/inductor/aoti_torch/tensor_converter.h>",
|
||||
]
|
||||
)
|
||||
if device_type == "cuda":
|
||||
if torch.version.hip:
|
||||
ib.writelines(
|
||||
[
|
||||
"#include <hip/hip_runtime.h>",
|
||||
]
|
||||
)
|
||||
|
||||
else:
|
||||
ib.writelines(
|
||||
[
|
||||
"#include <cuda.h>",
|
||||
"#include <cuda_runtime_api.h>",
|
||||
]
|
||||
)
|
||||
for model_name in model_names:
|
||||
ib.writeline(
|
||||
f'#include "{package_name}/data/aotinductor/{model_name}/{model_name}.h"'
|
||||
)
|
||||
|
||||
ib.newline()
|
||||
for model_name in model_names:
|
||||
ib.writeline(f"using torch::aot_inductor::AOTInductorModel{model_name};")
|
||||
|
||||
ib.writelines(
|
||||
[
|
||||
"using torch::aot_inductor::ConstantHandle;",
|
||||
"using torch::aot_inductor::ConstantMap;",
|
||||
"",
|
||||
"int main(int argc, char* argv[]) {",
|
||||
]
|
||||
)
|
||||
|
||||
with ib.indent():
|
||||
ib.writeline(f'std::string device_str = "{device_type}";')
|
||||
ib.writeline("try {")
|
||||
|
||||
with ib.indent():
|
||||
ib.writeline("c10::Device device(device_str);")
|
||||
|
||||
if example_inputs_map is not None:
|
||||
# TODO: add device
|
||||
for i, model_name in enumerate(model_names):
|
||||
num_inputs = example_inputs_map[model_name]
|
||||
|
||||
ib.writeline(f"// Load input tensors for model {model_name}")
|
||||
ib.writeline(f"std::vector<at::Tensor> input_tensors{i + 1};")
|
||||
ib.writeline(f"for (int j = 0; j < {num_inputs}; ++j) {{")
|
||||
with ib.indent():
|
||||
ib.writeline(
|
||||
f'std::string filename = "{model_name}_input_" + std::to_string(j) + ".pt";'
|
||||
)
|
||||
ib.writeline("std::ifstream in(filename, std::ios::binary);")
|
||||
ib.writeline("if (!in.is_open()) {")
|
||||
with ib.indent():
|
||||
ib.writeline(
|
||||
'std::cerr << "Failed to open file: " << filename << std::endl;'
|
||||
)
|
||||
ib.writeline("return 1;")
|
||||
ib.writeline("}")
|
||||
ib.writeline(
|
||||
"std::vector<char> buffer((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());"
|
||||
)
|
||||
ib.writeline(
|
||||
"torch::IValue ivalue = torch::pickle_load(buffer);"
|
||||
)
|
||||
ib.writeline(
|
||||
f"input_tensors{i + 1}.push_back(ivalue.toTensor().to(device));"
|
||||
)
|
||||
ib.writeline("}")
|
||||
ib.newline()
|
||||
|
||||
ib.newline()
|
||||
ib.writeline("\n// Create array of input handles")
|
||||
for i in range(len(model_names)):
|
||||
ib.writelines(
|
||||
[
|
||||
f"auto input_handles{i + 1} =",
|
||||
f" torch::aot_inductor::unsafe_alloc_new_handles_from_tensors(input_tensors{i + 1});",
|
||||
]
|
||||
)
|
||||
|
||||
ib.writeline("\n// Create array for output handles")
|
||||
for i in range(len(model_names)):
|
||||
ib.writeline(f"AtenTensorHandle output_handle{i + 1};")
|
||||
|
||||
ib.writeline("\n// Create and load models")
|
||||
for i, model_name in enumerate(model_names):
|
||||
ib.writelines(
|
||||
[
|
||||
f"auto constants_map{i + 1} = std::make_shared<ConstantMap>();",
|
||||
f"auto constants_array{i + 1} = std::make_shared<std::vector<ConstantHandle>>();",
|
||||
f"auto model{i + 1} = std::make_unique<AOTInductorModel{model_name}>(",
|
||||
f" std::move(constants_map{i + 1}),",
|
||||
f" std::move(constants_array{i + 1}),",
|
||||
" device_str,",
|
||||
f' "{package_name}/data/aotinductor/{model_name}/");',
|
||||
f"model{i + 1}->load_constants();",
|
||||
]
|
||||
)
|
||||
|
||||
if example_inputs_map is not None:
|
||||
ib.writeline("\n// Run the models")
|
||||
for i in range(len(model_names)):
|
||||
ib.writeline(
|
||||
f"torch::aot_inductor::DeviceStreamType stream{i + 1} = nullptr;"
|
||||
)
|
||||
ib.writeline(
|
||||
f"model{i + 1}->run(&input_handles{i + 1}[0], &output_handle{i + 1}, stream{i + 1}, nullptr);"
|
||||
)
|
||||
|
||||
ib.writeline("\n// Convert output handles to tensors")
|
||||
for i in range(len(model_names)):
|
||||
ib.writelines(
|
||||
[
|
||||
f"auto output_tensor{i + 1} =",
|
||||
f" torch::aot_inductor::alloc_tensors_by_stealing_from_handles(&output_handle{i + 1}, 1);",
|
||||
]
|
||||
)
|
||||
|
||||
ib.writeline("\n// Validate outputs")
|
||||
for i in range(len(model_names)):
|
||||
ib.writeline(
|
||||
f"""std::cout << "output_tensor{i + 1}\\n" << output_tensor{i + 1} << std::endl;"""
|
||||
)
|
||||
ib.writeline(
|
||||
f"""torch::save(output_tensor{i + 1}, "output_tensor{i + 1}.pt");"""
|
||||
)
|
||||
|
||||
ib.writeline("return 0;")
|
||||
|
||||
ib.writelines(
|
||||
[
|
||||
"} catch (const std::exception &e) {",
|
||||
]
|
||||
)
|
||||
with ib.indent():
|
||||
ib.writeline('std::cerr << "Error: " << e.what() << std::endl;')
|
||||
ib.writeline("return 1;")
|
||||
|
||||
ib.writeline("}")
|
||||
ib.writeline("}")
|
||||
|
||||
return ib.getvalue()
|
||||
|
||||
|
||||
def _get_make_file(package_name: str, model_names: list[str], device_type: str) -> str:
|
||||
ib = IndentedBuffer()
|
||||
|
||||
ib.writelines(
|
||||
[
|
||||
"cmake_minimum_required(VERSION 3.10)",
|
||||
"project(TestProject)",
|
||||
"",
|
||||
"set(CMAKE_CXX_STANDARD 20)",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
from torch._inductor.config import test_configs
|
||||
|
||||
if test_configs.use_libtorch:
|
||||
ib.writeline("find_package(Torch REQUIRED)")
|
||||
|
||||
if device_type == "cuda":
|
||||
if torch.version.hip:
|
||||
ib.writeline("find_package(hip REQUIRED)")
|
||||
else:
|
||||
ib.writeline("find_package(CUDA REQUIRED)")
|
||||
|
||||
ib.newline()
|
||||
for model_name in model_names:
|
||||
ib.writeline(f"add_subdirectory({package_name}/data/aotinductor/{model_name}/)")
|
||||
|
||||
ib.writeline("\nadd_executable(main main.cpp)")
|
||||
if device_type == "cuda":
|
||||
if torch.version.hip:
|
||||
ib.writeline("target_compile_definitions(main PRIVATE USE_HIP)")
|
||||
else:
|
||||
ib.writeline("target_compile_definitions(main PRIVATE USE_CUDA)")
|
||||
elif device_type == "xpu":
|
||||
ib.writeline("target_compile_definitions(main PRIVATE USE_XPU)")
|
||||
|
||||
model_libs = " ".join(model_names)
|
||||
ib.writeline(f"target_link_libraries(main PRIVATE torch {model_libs})")
|
||||
|
||||
if device_type == "cuda":
|
||||
if torch.version.hip:
|
||||
ib.writeline("target_link_libraries(main PRIVATE hip::host)")
|
||||
else:
|
||||
ib.writeline("target_link_libraries(main PRIVATE cuda ${CUDA_LIBRARIES})")
|
||||
elif device_type == "xpu":
|
||||
ib.writeline("target_link_libraries(main PRIVATE sycl ze_loader)")
|
||||
return ib.getvalue()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,759 @@
|
||||
# mypy: allow-untyped-defs
|
||||
import dataclasses
|
||||
from collections.abc import Collection, Mapping
|
||||
from enum import auto, Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from torch._library.fake_class_registry import FakeScriptObject
|
||||
from torch._library.opaque_object import get_opaque_type_name, is_opaque_type
|
||||
from torch._subclasses.fake_tensor import is_fake
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import torch
|
||||
from torch._functorch._aot_autograd.schemas import GraphSignature
|
||||
|
||||
__all__ = [
|
||||
"ConstantArgument",
|
||||
"CustomObjArgument",
|
||||
"ExportBackwardSignature",
|
||||
"ExportGraphSignature",
|
||||
"InputKind",
|
||||
"InputSpec",
|
||||
"OutputKind",
|
||||
"OutputSpec",
|
||||
"SymIntArgument",
|
||||
"SymFloatArgument",
|
||||
"SymBoolArgument",
|
||||
"TensorArgument",
|
||||
]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TensorArgument:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenArgument:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SymIntArgument:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SymFloatArgument:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SymBoolArgument:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CustomObjArgument:
|
||||
name: str
|
||||
class_fqn: str
|
||||
fake_val: FakeScriptObject | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ConstantArgument:
|
||||
name: str
|
||||
value: int | float | bool | str | None
|
||||
|
||||
|
||||
ArgumentSpec = (
|
||||
TensorArgument
|
||||
| SymIntArgument
|
||||
| SymFloatArgument
|
||||
| SymBoolArgument
|
||||
| ConstantArgument
|
||||
| CustomObjArgument
|
||||
| TokenArgument
|
||||
)
|
||||
|
||||
|
||||
class InputKind(Enum):
|
||||
USER_INPUT = auto()
|
||||
PARAMETER = auto()
|
||||
BUFFER = auto()
|
||||
CONSTANT_TENSOR = auto()
|
||||
CUSTOM_OBJ = auto()
|
||||
TOKEN = auto()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class InputSpec:
|
||||
kind: InputKind
|
||||
arg: ArgumentSpec
|
||||
target: str | None
|
||||
persistent: bool | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.kind == InputKind.BUFFER:
|
||||
if self.persistent is None:
|
||||
raise AssertionError("Failed to specify persistent flag on BUFFER.")
|
||||
if not isinstance(
|
||||
self.arg,
|
||||
(
|
||||
TensorArgument,
|
||||
SymIntArgument,
|
||||
SymFloatArgument,
|
||||
SymBoolArgument,
|
||||
ConstantArgument,
|
||||
CustomObjArgument,
|
||||
TokenArgument,
|
||||
),
|
||||
):
|
||||
raise AssertionError(f"expected valid arg type, got {type(self.arg)}")
|
||||
|
||||
def __str__(self):
|
||||
target = "" if self.target is None else f" target='{self.target}'"
|
||||
persistent = "" if self.persistent is None else f" persistent={self.persistent}"
|
||||
return f"{str(self.arg.name)}: {str(self.kind.name)}{target}{persistent}"
|
||||
|
||||
|
||||
class OutputKind(Enum):
|
||||
USER_OUTPUT = auto()
|
||||
LOSS_OUTPUT = auto()
|
||||
BUFFER_MUTATION = auto()
|
||||
PARAMETER_MUTATION = auto()
|
||||
GRADIENT_TO_PARAMETER = auto()
|
||||
GRADIENT_TO_USER_INPUT = auto()
|
||||
USER_INPUT_MUTATION = auto()
|
||||
TOKEN = auto()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class OutputSpec:
|
||||
kind: OutputKind
|
||||
arg: ArgumentSpec
|
||||
target: str | None
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(
|
||||
self.arg,
|
||||
(
|
||||
TensorArgument,
|
||||
SymIntArgument,
|
||||
SymFloatArgument,
|
||||
SymBoolArgument,
|
||||
ConstantArgument,
|
||||
TokenArgument,
|
||||
CustomObjArgument,
|
||||
),
|
||||
):
|
||||
raise AssertionError(f"expected valid arg type, got {self.arg}")
|
||||
|
||||
def __str__(self):
|
||||
target = "" if self.target is None else f" target='{self.target}'"
|
||||
return f"{str(self.arg.name)}: {str(self.kind.name)}{target}"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ExportBackwardSignature:
|
||||
gradients_to_parameters: dict[str, str]
|
||||
gradients_to_user_inputs: dict[str, str]
|
||||
loss_output: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ExportGraphSignature:
|
||||
"""
|
||||
:class:`ExportGraphSignature` models the input/output signature of Export Graph,
|
||||
which is a fx.Graph with stronger invariants guarantees.
|
||||
|
||||
Export Graph is functional and does not access "states" like parameters
|
||||
or buffers within the graph via ``getattr`` nodes. Instead, :func:`export`
|
||||
guarantees that parameters, buffers, and constant tensors are lifted out of
|
||||
the graph as inputs. Similarly, any mutations to buffers are not included
|
||||
in the graph either, instead the updated values of mutated buffers are
|
||||
modeled as additional outputs of Export Graph.
|
||||
|
||||
The ordering of all inputs and outputs are::
|
||||
|
||||
Inputs = [*parameters_buffers_constant_tensors, *flattened_user_inputs]
|
||||
Outputs = [*mutated_inputs, *flattened_user_outputs]
|
||||
|
||||
e.g. If following module is exported::
|
||||
|
||||
class CustomModule(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super(CustomModule, self).__init__()
|
||||
|
||||
# Define a parameter
|
||||
self.my_parameter = nn.Parameter(torch.tensor(2.0))
|
||||
|
||||
# Define two buffers
|
||||
self.register_buffer("my_buffer1", torch.tensor(3.0))
|
||||
self.register_buffer("my_buffer2", torch.tensor(4.0))
|
||||
|
||||
def forward(self, x1, x2):
|
||||
# Use the parameter, buffers, and both inputs in the forward method
|
||||
output = (
|
||||
x1 + self.my_parameter
|
||||
) * self.my_buffer1 + x2 * self.my_buffer2
|
||||
|
||||
# Mutate one of the buffers (e.g., increment it by 1)
|
||||
self.my_buffer2.add_(1.0) # In-place addition
|
||||
|
||||
return output
|
||||
|
||||
|
||||
mod = CustomModule()
|
||||
ep = torch.export.export(mod, (torch.tensor(1.0), torch.tensor(2.0)))
|
||||
|
||||
Resulting Graph is non-functional::
|
||||
|
||||
graph():
|
||||
%p_my_parameter : [num_users=1] = placeholder[target=p_my_parameter]
|
||||
%b_my_buffer1 : [num_users=1] = placeholder[target=b_my_buffer1]
|
||||
%b_my_buffer2 : [num_users=2] = placeholder[target=b_my_buffer2]
|
||||
%x1 : [num_users=1] = placeholder[target=x1]
|
||||
%x2 : [num_users=1] = placeholder[target=x2]
|
||||
%add : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%x1, %p_my_parameter), kwargs = {})
|
||||
%mul : [num_users=1] = call_function[target=torch.ops.aten.mul.Tensor](args = (%add, %b_my_buffer1), kwargs = {})
|
||||
%mul_1 : [num_users=1] = call_function[target=torch.ops.aten.mul.Tensor](args = (%x2, %b_my_buffer2), kwargs = {})
|
||||
%add_1 : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%mul, %mul_1), kwargs = {})
|
||||
%add_ : [num_users=0] = call_function[target=torch.ops.aten.add_.Tensor](args = (%b_my_buffer2, 1.0), kwargs = {})
|
||||
return (add_1,)
|
||||
|
||||
Resulting ExportGraphSignature of the non-functional Graph would be::
|
||||
|
||||
# inputs
|
||||
p_my_parameter: PARAMETER target='my_parameter'
|
||||
b_my_buffer1: BUFFER target='my_buffer1' persistent=True
|
||||
b_my_buffer2: BUFFER target='my_buffer2' persistent=True
|
||||
x1: USER_INPUT
|
||||
x2: USER_INPUT
|
||||
|
||||
# outputs
|
||||
add_1: USER_OUTPUT
|
||||
|
||||
To get a functional Graph, you can use :func:`run_decompositions`::
|
||||
|
||||
mod = CustomModule()
|
||||
ep = torch.export.export(mod, (torch.tensor(1.0), torch.tensor(2.0)))
|
||||
ep = ep.run_decompositions()
|
||||
|
||||
Resulting Graph is functional::
|
||||
|
||||
graph():
|
||||
%p_my_parameter : [num_users=1] = placeholder[target=p_my_parameter]
|
||||
%b_my_buffer1 : [num_users=1] = placeholder[target=b_my_buffer1]
|
||||
%b_my_buffer2 : [num_users=2] = placeholder[target=b_my_buffer2]
|
||||
%x1 : [num_users=1] = placeholder[target=x1]
|
||||
%x2 : [num_users=1] = placeholder[target=x2]
|
||||
%add : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%x1, %p_my_parameter), kwargs = {})
|
||||
%mul : [num_users=1] = call_function[target=torch.ops.aten.mul.Tensor](args = (%add, %b_my_buffer1), kwargs = {})
|
||||
%mul_1 : [num_users=1] = call_function[target=torch.ops.aten.mul.Tensor](args = (%x2, %b_my_buffer2), kwargs = {})
|
||||
%add_1 : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%mul, %mul_1), kwargs = {})
|
||||
%add_2 : [num_users=1] = call_function[target=torch.ops.aten.add.Tensor](args = (%b_my_buffer2, 1.0), kwargs = {})
|
||||
return (add_2, add_1)
|
||||
|
||||
Resulting ExportGraphSignature of the functional Graph would be::
|
||||
|
||||
# inputs
|
||||
p_my_parameter: PARAMETER target='my_parameter'
|
||||
b_my_buffer1: BUFFER target='my_buffer1' persistent=True
|
||||
b_my_buffer2: BUFFER target='my_buffer2' persistent=True
|
||||
x1: USER_INPUT
|
||||
x2: USER_INPUT
|
||||
|
||||
# outputs
|
||||
add_2: BUFFER_MUTATION target='my_buffer2'
|
||||
add_1: USER_OUTPUT
|
||||
|
||||
"""
|
||||
|
||||
input_specs: list[InputSpec]
|
||||
output_specs: list[OutputSpec]
|
||||
|
||||
# A list of parameters uniquely identified by mangled fully qualified name
|
||||
@property
|
||||
def parameters(self) -> Collection[str]:
|
||||
return tuple(
|
||||
s.target
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.PARAMETER
|
||||
if isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# A list of buffers uniquely identified by mangled fully qualified name
|
||||
@property
|
||||
def buffers(self) -> Collection[str]:
|
||||
return tuple(
|
||||
s.target
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.BUFFER
|
||||
if isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def non_persistent_buffers(self) -> Collection[str]:
|
||||
return tuple(
|
||||
s.target
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.BUFFER
|
||||
if s.persistent is False
|
||||
if isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# A list of lifted constant tensors
|
||||
@property
|
||||
def lifted_tensor_constants(self) -> Collection[str]:
|
||||
return tuple(
|
||||
s.target
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.CONSTANT_TENSOR
|
||||
if isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def lifted_custom_objs(self) -> Collection[str]:
|
||||
return tuple(
|
||||
s.target
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.CUSTOM_OBJ
|
||||
if isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# Graph node names of pytree-flattened inputs of original program
|
||||
@property
|
||||
def user_inputs(self) -> Collection[int | float | bool | str | None]:
|
||||
user_inputs: list[int | float | bool | str | None] = []
|
||||
for s in self.input_specs:
|
||||
if s.kind != InputKind.USER_INPUT:
|
||||
continue
|
||||
|
||||
if isinstance(
|
||||
s.arg,
|
||||
(
|
||||
TensorArgument,
|
||||
SymIntArgument,
|
||||
SymFloatArgument,
|
||||
SymBoolArgument,
|
||||
CustomObjArgument,
|
||||
),
|
||||
):
|
||||
user_inputs.append(s.arg.name)
|
||||
elif isinstance(s.arg, ConstantArgument):
|
||||
user_inputs.append(s.arg.value)
|
||||
else:
|
||||
raise RuntimeError(f"{s.arg} is not a valid user inputs")
|
||||
return tuple(user_inputs)
|
||||
|
||||
# Graph node names of pytree-flattened outputs of original program
|
||||
# For joint-graph purposes, will include the loss output.
|
||||
@property
|
||||
def user_outputs(self) -> Collection[int | float | bool | str | None]:
|
||||
user_outputs: list[int | float | bool | str | None] = []
|
||||
for s in self.output_specs:
|
||||
if s.kind not in [
|
||||
OutputKind.USER_OUTPUT,
|
||||
OutputKind.LOSS_OUTPUT,
|
||||
]:
|
||||
continue
|
||||
|
||||
if isinstance(
|
||||
s.arg,
|
||||
(TensorArgument, SymIntArgument, SymFloatArgument, SymBoolArgument),
|
||||
):
|
||||
user_outputs.append(s.arg.name)
|
||||
elif isinstance(s.arg, ConstantArgument):
|
||||
user_outputs.append(s.arg.value)
|
||||
elif isinstance(s.arg, CustomObjArgument):
|
||||
user_outputs.append(s.arg.name)
|
||||
else:
|
||||
raise RuntimeError(f"{s.arg} is not a valid user output")
|
||||
return tuple(user_outputs)
|
||||
|
||||
# A dictionary mapping graph input node names to parameters. If a graph input
|
||||
# name is found in this dictionary, it is guaranteed to be a lifted parameter.
|
||||
@property
|
||||
def inputs_to_parameters(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.PARAMETER
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# A dictionary mapping graph input node names to buffers. If a graph input
|
||||
# name is found in this dictionary, it is guaranteed to be a lifted buffer.
|
||||
@property
|
||||
def inputs_to_buffers(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target) # type: ignore[union-attr, misc]
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.BUFFER
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# A dictionary mapping graph output node names to buffers that are mutated in the
|
||||
# original program. Buffers that are not mutated will not be found in this dictionary.
|
||||
@property
|
||||
def buffers_to_mutate(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.output_specs
|
||||
if s.kind == OutputKind.BUFFER_MUTATION
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters_to_mutate(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.output_specs
|
||||
if s.kind == OutputKind.PARAMETER_MUTATION
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def user_inputs_to_mutate(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.output_specs
|
||||
if s.kind == OutputKind.USER_INPUT_MUTATION
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
# A dictionary mapping graph input node names to lifted tensor constants.
|
||||
@property
|
||||
def inputs_to_lifted_tensor_constants(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.CONSTANT_TENSOR
|
||||
and isinstance(s.arg, TensorArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def inputs_to_lifted_custom_objs(self) -> Mapping[str, str]:
|
||||
return _immutable_dict(
|
||||
(s.arg.name, s.target)
|
||||
for s in self.input_specs
|
||||
if s.kind == InputKind.CUSTOM_OBJ
|
||||
and isinstance(s.arg, CustomObjArgument)
|
||||
and isinstance(s.target, str)
|
||||
)
|
||||
|
||||
@property
|
||||
def backward_signature(self) -> ExportBackwardSignature | None:
|
||||
loss_output = None
|
||||
gradients_to_parameters: dict[str, str] = {}
|
||||
gradients_to_user_inputs: dict[str, str] = {}
|
||||
for spec in self.output_specs:
|
||||
if spec.kind == OutputKind.LOSS_OUTPUT:
|
||||
if loss_output is not None:
|
||||
raise AssertionError("multiple LOSS_OUTPUT specs found")
|
||||
if not isinstance(spec.arg, TensorArgument):
|
||||
raise AssertionError(
|
||||
f"expected TensorArgument for LOSS_OUTPUT, got {type(spec.arg)}"
|
||||
)
|
||||
loss_output = spec.arg.name
|
||||
elif spec.kind == OutputKind.GRADIENT_TO_PARAMETER:
|
||||
if not isinstance(spec.target, str):
|
||||
raise AssertionError(
|
||||
f"expected str target for GRADIENT_TO_PARAMETER, got {type(spec.target)}"
|
||||
)
|
||||
if not isinstance(spec.arg, TensorArgument):
|
||||
raise AssertionError(
|
||||
f"expected TensorArgument for GRADIENT_TO_PARAMETER, got {type(spec.arg)}"
|
||||
)
|
||||
gradients_to_parameters[spec.arg.name] = spec.target
|
||||
elif spec.kind == OutputKind.GRADIENT_TO_USER_INPUT:
|
||||
if not isinstance(spec.target, str):
|
||||
raise AssertionError(
|
||||
f"expected str target for GRADIENT_TO_USER_INPUT, got {type(spec.target)}"
|
||||
)
|
||||
if not isinstance(spec.arg, TensorArgument):
|
||||
raise AssertionError(
|
||||
f"expected TensorArgument for GRADIENT_TO_USER_INPUT, got {type(spec.arg)}"
|
||||
)
|
||||
gradients_to_user_inputs[spec.arg.name] = spec.target
|
||||
|
||||
if loss_output is None:
|
||||
return None
|
||||
|
||||
return ExportBackwardSignature(
|
||||
loss_output=loss_output,
|
||||
gradients_to_parameters=gradients_to_parameters,
|
||||
gradients_to_user_inputs=gradients_to_user_inputs,
|
||||
)
|
||||
|
||||
# Map from assertion dependency token index to assertion dep token output
|
||||
# name in output. The shape of output after aot_autograd will be like:
|
||||
# (updated_inputs, user_outputs, dep_token).
|
||||
@property
|
||||
def assertion_dep_token(self) -> Mapping[int, str] | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def input_tokens(self) -> Collection[str]:
|
||||
input_tokens = []
|
||||
for s in self.input_specs:
|
||||
if s.kind == InputKind.TOKEN:
|
||||
if not isinstance(s.arg, TokenArgument):
|
||||
raise AssertionError(
|
||||
f"expected TokenArgument for TOKEN kind, got {type(s.arg)}"
|
||||
)
|
||||
input_tokens.append(s.arg.name)
|
||||
return tuple(input_tokens)
|
||||
|
||||
@property
|
||||
def output_tokens(self) -> Collection[str]:
|
||||
output_tokens = []
|
||||
for s in self.output_specs:
|
||||
if s.kind == OutputKind.TOKEN:
|
||||
if not isinstance(s.arg, TokenArgument):
|
||||
raise AssertionError(
|
||||
f"expected TokenArgument for TOKEN kind, got {type(s.arg)}"
|
||||
)
|
||||
output_tokens.append(s.arg.name)
|
||||
return tuple(output_tokens)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
assertion_dep_token = self.assertion_dep_token
|
||||
if assertion_dep_token is None:
|
||||
return
|
||||
if len(assertion_dep_token) != 1:
|
||||
raise AssertionError(
|
||||
f"expected exactly 1 assertion_dep_token, got {len(assertion_dep_token)}"
|
||||
)
|
||||
assertion_dep_token_index = next(iter(assertion_dep_token.keys()))
|
||||
expected_index = len(self.user_outputs) + len(self.buffers_to_mutate)
|
||||
if expected_index != assertion_dep_token_index:
|
||||
raise AssertionError(
|
||||
f"expected assertion_dep_token_index to be {expected_index}, got {assertion_dep_token_index}"
|
||||
)
|
||||
|
||||
def replace_all_uses(self, old: str, new: str):
|
||||
"""
|
||||
Replace all uses of the old name with new name in the signature.
|
||||
"""
|
||||
if not isinstance(old, str):
|
||||
raise AssertionError(f"expected old to be str, got {type(old)}")
|
||||
if not isinstance(new, str):
|
||||
raise AssertionError(f"expected new to be str, got {type(new)}")
|
||||
arg_types = (
|
||||
TensorArgument,
|
||||
SymIntArgument,
|
||||
SymFloatArgument,
|
||||
SymBoolArgument,
|
||||
CustomObjArgument,
|
||||
TokenArgument,
|
||||
)
|
||||
for o in self.output_specs:
|
||||
if isinstance(o.arg, arg_types):
|
||||
if o.arg.name == old:
|
||||
o.arg.name = new
|
||||
for i in self.input_specs:
|
||||
if isinstance(i.arg, arg_types):
|
||||
if i.arg.name == old:
|
||||
i.arg.name = new
|
||||
|
||||
def get_replace_hook(self, replace_inputs=False):
|
||||
def _(old, new, user):
|
||||
if user.op == "output":
|
||||
self.replace_all_uses(old.name, new)
|
||||
if replace_inputs and old.op == "placeholder":
|
||||
self.replace_all_uses(old.name, new)
|
||||
|
||||
return _
|
||||
|
||||
def __str__(self):
|
||||
input_specs = "\n".join(str(s) for s in self.input_specs)
|
||||
output_specs = "\n".join(str(s) for s in self.output_specs)
|
||||
return f"\n# inputs\n{input_specs}\n\n# outputs\n{output_specs}\n"
|
||||
|
||||
|
||||
def _immutable_dict(items):
|
||||
"""
|
||||
Creates a mapping where items cannot be added, deleted, or updated.
|
||||
NOTE: The immutability is shallow (like tuple is an immutable collection).
|
||||
"""
|
||||
from types import MappingProxyType
|
||||
|
||||
return MappingProxyType(dict(items))
|
||||
|
||||
|
||||
def _make_argument_spec(node, token_names) -> ArgumentSpec:
|
||||
from torch import ScriptObject, SymBool, SymFloat, SymInt
|
||||
from torch._library.fake_class_registry import FakeScriptObject
|
||||
|
||||
if isinstance(node, (int, bool, float, type(None), str)):
|
||||
# For const outputs we just directly return this
|
||||
return ConstantArgument(name="", value=node)
|
||||
|
||||
if "val" not in node.meta:
|
||||
raise AssertionError(
|
||||
f"{node} is not a constant or a node with a 'val' metadata field"
|
||||
)
|
||||
val = node.meta["val"]
|
||||
if node.name in token_names:
|
||||
return TokenArgument(name=node.name)
|
||||
elif is_fake(val):
|
||||
return TensorArgument(name=node.name)
|
||||
elif isinstance(val, SymInt):
|
||||
return SymIntArgument(name=node.name)
|
||||
elif isinstance(val, SymFloat):
|
||||
return SymFloatArgument(name=node.name)
|
||||
elif isinstance(val, SymBool):
|
||||
return SymBoolArgument(name=node.name)
|
||||
elif isinstance(val, ScriptObject):
|
||||
return CustomObjArgument(name=node.name, class_fqn=val._type().qualified_name()) # type: ignore[attr-defined]
|
||||
elif isinstance(val, FakeScriptObject):
|
||||
return CustomObjArgument(
|
||||
name=node.name, class_fqn=val.script_class_name, fake_val=val
|
||||
)
|
||||
elif is_opaque_type(type(val)):
|
||||
return CustomObjArgument(
|
||||
name=node.name, class_fqn=get_opaque_type_name(type(val)), fake_val=val
|
||||
)
|
||||
elif isinstance(val, (int, bool, str, float, type(None))):
|
||||
return ConstantArgument(name=node.name, value=val)
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Encountered an unsupported object of type {type(val)} "
|
||||
f"while writing the metadata for exported program"
|
||||
)
|
||||
|
||||
|
||||
def _convert_to_export_graph_signature(
|
||||
graph_signature: "GraphSignature",
|
||||
gm: "torch.fx.GraphModule",
|
||||
non_persistent_buffers: set[str],
|
||||
) -> "ExportGraphSignature":
|
||||
from torch.utils import _pytree as pytree
|
||||
|
||||
is_joint = graph_signature.backward_signature is not None
|
||||
|
||||
# unpack objects
|
||||
user_inputs = set(graph_signature.user_inputs)
|
||||
inputs_to_parameters = graph_signature.inputs_to_parameters
|
||||
inputs_to_buffers = graph_signature.inputs_to_buffers
|
||||
user_outputs = set(graph_signature.user_outputs)
|
||||
buffer_mutations = graph_signature.buffers_to_mutate
|
||||
parameter_mutations = graph_signature.parameters_to_mutate
|
||||
user_input_mutations = graph_signature.user_inputs_to_mutate
|
||||
grad_params = (
|
||||
graph_signature.backward_signature.gradients_to_parameter # type: ignore[union-attr]
|
||||
if is_joint
|
||||
else {}
|
||||
)
|
||||
grad_user_inputs = (
|
||||
graph_signature.backward_signature.gradients_to_user_inputs # type: ignore[union-attr]
|
||||
if is_joint
|
||||
else {}
|
||||
)
|
||||
loss_output = (
|
||||
graph_signature.backward_signature.loss_output # type: ignore[union-attr]
|
||||
if is_joint
|
||||
else None
|
||||
)
|
||||
input_tokens = graph_signature.input_tokens
|
||||
output_tokens = graph_signature.output_tokens
|
||||
|
||||
inputs = [
|
||||
_make_argument_spec(node, input_tokens)
|
||||
for node in gm.graph.nodes
|
||||
if node.op == "placeholder"
|
||||
]
|
||||
outputs = [
|
||||
_make_argument_spec(node, output_tokens)
|
||||
for node in pytree.tree_leaves(next(iter(reversed(gm.graph.nodes))).args)
|
||||
]
|
||||
|
||||
def to_input_spec(inp: ArgumentSpec) -> InputSpec:
|
||||
if isinstance(inp, TokenArgument):
|
||||
return InputSpec(kind=InputKind.TOKEN, arg=inp, target=None)
|
||||
|
||||
if not isinstance(inp, TensorArgument):
|
||||
return InputSpec(kind=InputKind.USER_INPUT, arg=inp, target=None)
|
||||
name = inp.name
|
||||
if name in user_inputs:
|
||||
return InputSpec(kind=InputKind.USER_INPUT, arg=inp, target=None)
|
||||
elif name in inputs_to_parameters:
|
||||
return InputSpec(
|
||||
kind=InputKind.PARAMETER,
|
||||
arg=inp,
|
||||
target=inputs_to_parameters[name], # type: ignore[index]
|
||||
)
|
||||
elif name in inputs_to_buffers:
|
||||
return InputSpec(
|
||||
kind=InputKind.BUFFER,
|
||||
arg=inp,
|
||||
target=inputs_to_buffers[name], # type: ignore[index]
|
||||
persistent=(inputs_to_buffers[name] not in non_persistent_buffers), # type: ignore[index]
|
||||
)
|
||||
else:
|
||||
raise AssertionError(f"Unknown tensor input kind: {name}")
|
||||
|
||||
def to_output_spec(idx: int, o: ArgumentSpec) -> OutputSpec:
|
||||
if isinstance(o, TokenArgument):
|
||||
return OutputSpec(kind=OutputKind.TOKEN, arg=o, target=None)
|
||||
|
||||
if not isinstance(o, TensorArgument):
|
||||
return OutputSpec(kind=OutputKind.USER_OUTPUT, arg=o, target=None)
|
||||
name = o.name
|
||||
if idx < len(buffer_mutations) + len(parameter_mutations) + len(
|
||||
user_input_mutations
|
||||
) + len(output_tokens):
|
||||
if name in buffer_mutations:
|
||||
return OutputSpec(
|
||||
kind=OutputKind.BUFFER_MUTATION,
|
||||
arg=o,
|
||||
target=buffer_mutations[name], # type: ignore[index]
|
||||
)
|
||||
elif name in parameter_mutations:
|
||||
return OutputSpec(
|
||||
kind=OutputKind.PARAMETER_MUTATION,
|
||||
arg=o,
|
||||
target=parameter_mutations[name], # type: ignore[index]
|
||||
)
|
||||
elif name in user_input_mutations:
|
||||
return OutputSpec(
|
||||
kind=OutputKind.USER_INPUT_MUTATION,
|
||||
arg=o,
|
||||
target=user_input_mutations[name], # type: ignore[index]
|
||||
)
|
||||
else:
|
||||
raise AssertionError(f"Unknown tensor mutation kind: {name}")
|
||||
else:
|
||||
if name in user_outputs:
|
||||
return OutputSpec(kind=OutputKind.USER_OUTPUT, arg=o, target=None)
|
||||
|
||||
elif name in grad_params:
|
||||
return OutputSpec(
|
||||
kind=OutputKind.GRADIENT_TO_PARAMETER,
|
||||
arg=o,
|
||||
target=grad_params[name],
|
||||
)
|
||||
elif name in grad_user_inputs:
|
||||
return OutputSpec(
|
||||
kind=OutputKind.GRADIENT_TO_USER_INPUT,
|
||||
arg=o,
|
||||
target=grad_user_inputs[name],
|
||||
)
|
||||
elif name == loss_output:
|
||||
return OutputSpec(kind=OutputKind.LOSS_OUTPUT, arg=o, target=None)
|
||||
|
||||
else:
|
||||
raise AssertionError(f"Unknown tensor output kind: {name}")
|
||||
|
||||
input_specs = [to_input_spec(inp) for inp in inputs]
|
||||
output_specs = [to_output_spec(idx, o) for idx, o in enumerate(outputs)]
|
||||
return ExportGraphSignature(input_specs=input_specs, output_specs=output_specs)
|
||||
@@ -0,0 +1,97 @@
|
||||
from typing import Union
|
||||
|
||||
import torch
|
||||
import torch.utils._pytree as pytree
|
||||
from torch.export.exported_program import ExportedProgram
|
||||
|
||||
|
||||
__all__ = ["move_to_device_pass"]
|
||||
|
||||
|
||||
def move_to_device_pass(
|
||||
ep: ExportedProgram, location: torch.device | str | dict[str, str]
|
||||
) -> ExportedProgram:
|
||||
"""
|
||||
Move the exported program to the given device.
|
||||
|
||||
Args:
|
||||
ep (ExportedProgram): The exported program to move.
|
||||
location (Union[torch.device, str, Dict[str, str]]): The device to move the exported program to.
|
||||
If a string, it is interpreted as a device name.
|
||||
If a dict, it is interpreted as a mapping from
|
||||
the existing device to the intended one
|
||||
|
||||
Returns:
|
||||
ExportedProgram: The moved exported program.
|
||||
"""
|
||||
|
||||
def _get_new_device(
|
||||
curr_device: torch.device,
|
||||
location: torch.device | str | dict[str, str],
|
||||
) -> str:
|
||||
if isinstance(location, dict):
|
||||
if str(curr_device) in location:
|
||||
return location[str(curr_device)]
|
||||
else:
|
||||
return str(curr_device)
|
||||
else:
|
||||
return str(location)
|
||||
|
||||
# move all the state_dict
|
||||
for k, v in ep.state_dict.items():
|
||||
if isinstance(v, torch.nn.Parameter):
|
||||
ep._state_dict[k] = torch.nn.Parameter(
|
||||
v.to(_get_new_device(v.device, location)),
|
||||
v.requires_grad,
|
||||
)
|
||||
else:
|
||||
ep._state_dict[k] = v.to(_get_new_device(v.device, location))
|
||||
|
||||
# move all the constants
|
||||
for k, v in ep.constants.items():
|
||||
if isinstance(v, torch.Tensor):
|
||||
ep._constants[k] = v.to(_get_new_device(v.device, location))
|
||||
|
||||
# move example_inputs if they exist
|
||||
if ep.example_inputs is not None:
|
||||
args, kwargs = ep.example_inputs
|
||||
moved_args = pytree.tree_map_only(
|
||||
torch.Tensor,
|
||||
lambda tensor: tensor.to(_get_new_device(tensor.device, location)),
|
||||
args,
|
||||
)
|
||||
moved_kwargs = pytree.tree_map_only(
|
||||
torch.Tensor,
|
||||
lambda tensor: tensor.to(_get_new_device(tensor.device, location)),
|
||||
kwargs,
|
||||
)
|
||||
ep._example_inputs = (moved_args, moved_kwargs)
|
||||
|
||||
for m in ep.graph_module.modules():
|
||||
if isinstance(m, torch.fx.GraphModule):
|
||||
for node in m.graph.nodes:
|
||||
# move all the nodes kwargs with burnt-in device
|
||||
if "device" in node.kwargs:
|
||||
kwargs = node.kwargs.copy()
|
||||
kwargs["device"] = _get_new_device(kwargs["device"], location)
|
||||
node.kwargs = kwargs
|
||||
|
||||
if (
|
||||
node.op == "call_function"
|
||||
and node.target is torch.ops.aten.to.device
|
||||
):
|
||||
args = list(node.args)
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
args[1] = _get_new_device(args[1], location)
|
||||
node.args = tuple(args)
|
||||
|
||||
# move all the tensor metadata
|
||||
node.meta["val"] = pytree.tree_map(
|
||||
lambda v: v.to(_get_new_device(v.device, location))
|
||||
if isinstance(v, torch.Tensor)
|
||||
else v,
|
||||
node.meta.get("val"),
|
||||
)
|
||||
|
||||
ep.validate()
|
||||
return ep
|
||||
@@ -0,0 +1,4 @@
|
||||
from ._package import is_pt2_package, PT2ArchiveReader, PT2ArchiveWriter
|
||||
|
||||
|
||||
__all__ = ["PT2ArchiveWriter", "PT2ArchiveReader", "is_pt2_package"]
|
||||
File diff suppressed because it is too large
Load Diff
+213
@@ -0,0 +1,213 @@
|
||||
import collections
|
||||
import warnings
|
||||
|
||||
import torch
|
||||
from torch._subclasses.fake_tensor import FakeTensor
|
||||
from torch.utils._ordered_set import OrderedSet
|
||||
|
||||
|
||||
def _end_ptr(tensor: torch.Tensor) -> int:
|
||||
if tensor.nelement():
|
||||
stop = tensor.view(-1)[-1].data_ptr() + tensor.element_size()
|
||||
else:
|
||||
stop = tensor.data_ptr()
|
||||
return stop
|
||||
|
||||
|
||||
class TensorProperties:
|
||||
def __init__(self, tensor: torch.Tensor):
|
||||
self.is_fake = isinstance(tensor, FakeTensor)
|
||||
self.is_contiguous = tensor.is_contiguous()
|
||||
self.storage_ptr = None
|
||||
self.storage_size = None
|
||||
self.start = None
|
||||
self.end = None
|
||||
|
||||
if not self.is_fake:
|
||||
# only get the storage pointer for real tensors
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
self.storage_ptr = tensor.untyped_storage().data_ptr()
|
||||
if self.is_contiguous:
|
||||
# only get storage size and start/end pointers for contiguous tensors
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
self.storage_size = tensor.untyped_storage().nbytes()
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
self.start = tensor.data_ptr()
|
||||
# pyrefly: ignore [bad-assignment]
|
||||
self.end = _end_ptr(tensor)
|
||||
|
||||
# info to recover tensor
|
||||
self.shape = tensor.shape
|
||||
self.stride = tensor.stride()
|
||||
self.offset = tensor.storage_offset()
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""
|
||||
Whether the tensor completely overlaps with its underlying storage
|
||||
"""
|
||||
if self.is_fake:
|
||||
# Theoretically, fake tensors should not appear in weights
|
||||
# But we handle this corner case to make it always complete
|
||||
return True
|
||||
if not self.is_contiguous:
|
||||
return False
|
||||
|
||||
if self.storage_ptr is None:
|
||||
raise AssertionError("storage_ptr cannot be None for complete check")
|
||||
if self.storage_size is None:
|
||||
raise AssertionError("storage_size cannot be None for complete check")
|
||||
if self.start is None:
|
||||
raise AssertionError("start cannot be None for complete check")
|
||||
if self.end is None:
|
||||
raise AssertionError("end cannot be None for complete check")
|
||||
return (
|
||||
self.start == self.storage_ptr
|
||||
and self.end == self.storage_ptr + self.storage_size
|
||||
)
|
||||
|
||||
|
||||
class Weights(dict):
|
||||
"""
|
||||
A dictionary mapping from weight name to a tuple of (tensor, TensorProperties).
|
||||
tensor represents the actual initial value of the weight.
|
||||
TensorProperties represents the properties of the weight that are needed to recover the weight.
|
||||
|
||||
We use two separate entries because `tensor` could be a clone of the original weight tensor,
|
||||
so it doesn't have the same property as the original weight (such as underlying storage pointer).
|
||||
"""
|
||||
|
||||
def __init__(self, weight_dict: dict[str, tuple[torch.Tensor, TensorProperties]]):
|
||||
super().__init__(weight_dict)
|
||||
|
||||
def get_weight(self, name: str) -> tuple[torch.Tensor, TensorProperties]:
|
||||
return self[name]
|
||||
|
||||
def get_weight_properties(self, name: str) -> TensorProperties:
|
||||
return self[name][1]
|
||||
|
||||
|
||||
def get_complete_tensor(
|
||||
group: OrderedSet[tuple[str, str]], models_weights: dict[str, Weights]
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Given a group of (model_name, weight_name) pairs whose tensors share the same
|
||||
underlying storage, return the complete (maximal) tensor covering that storage
|
||||
region.
|
||||
|
||||
This function handles two cases:
|
||||
|
||||
1. If any tensor in the group is already marked as complete, return it directly.
|
||||
2. Otherwise, all tensors in the group are assumed to be slices of a larger,
|
||||
contiguous tensor backed by the same storage. In this case, reconstruct
|
||||
the complete tensor by taking the union of their storage ranges. This assumes
|
||||
all tensors in the group have the same dtype.
|
||||
|
||||
Args:
|
||||
group: Set of (model_name, weight_name) tuples identifying tensors that share storage.
|
||||
models_weights: Dictionary mapping model names to their Weights objects.
|
||||
|
||||
Returns:
|
||||
The complete tensor (either found directly or reconstructed from slices).
|
||||
|
||||
Example:
|
||||
# Tensors a, b, c share storage:
|
||||
# a = full_tensor[0:5] -> start=addr_0, end=addr_5
|
||||
# b = full_tensor[3:8] -> start=addr_3, end=addr_8
|
||||
# c = full_tensor -> complete tensor
|
||||
|
||||
# Case 1: If c is in group -> return c
|
||||
# Case 2: If only a, b in group -> reconstruct from addr_0 to addr_8
|
||||
"""
|
||||
|
||||
if len(group) == 0:
|
||||
raise AssertionError("group cannot be empty")
|
||||
|
||||
start_addr = None
|
||||
end_addr = None
|
||||
for model_name, weight_name in group:
|
||||
tensor_property = models_weights[model_name].get_weight_properties(weight_name)
|
||||
|
||||
# Case 1: Found a complete tensor.
|
||||
if tensor_property.is_complete():
|
||||
return models_weights[model_name].get_weight(weight_name)[0]
|
||||
|
||||
# Case 2: Track the widest boundary across all slices.
|
||||
if tensor_property.start is not None:
|
||||
start_addr = (
|
||||
tensor_property.start
|
||||
if start_addr is None
|
||||
else min(start_addr, tensor_property.start)
|
||||
)
|
||||
if tensor_property.end is not None:
|
||||
end_addr = (
|
||||
tensor_property.end
|
||||
if end_addr is None
|
||||
else max(end_addr, tensor_property.end)
|
||||
)
|
||||
|
||||
# Case 2: Reconstruct complete tensor from slices.
|
||||
# Pick any tensor from the group as a reference (they all share the same storage).
|
||||
warnings.warn(
|
||||
"No complete tensor found in the group! Returning the first one. "
|
||||
"This may cause issues when your weights are not on CPU.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
model_name, weight_name = next(iter(group))
|
||||
reference_tensor = models_weights[model_name].get_weight(weight_name)[0]
|
||||
|
||||
# If no boundary information available (e.g., FakeTensor), return reference tensor as is.
|
||||
if start_addr is None and end_addr is None:
|
||||
return reference_tensor
|
||||
|
||||
# Validate that we have both boundaries.
|
||||
if start_addr is None or end_addr is None:
|
||||
raise AssertionError(
|
||||
f"Inconsistent boundary information: start={start_addr}, end={end_addr}. "
|
||||
"Unable to reconstruct complete tensor from group."
|
||||
)
|
||||
|
||||
# Reconstruct a view over the full contiguous storage range.
|
||||
storage = reference_tensor.untyped_storage()
|
||||
total_size_bytes = end_addr - storage.data_ptr()
|
||||
element_size = reference_tensor.element_size()
|
||||
# It assumes all tensors in the group have the same dtype.
|
||||
total_size = total_size_bytes // element_size
|
||||
|
||||
# Validate alignment: size must be multiples of element_size.
|
||||
if total_size_bytes % element_size != 0:
|
||||
raise AssertionError(
|
||||
f"Total size ({total_size_bytes} bytes) is not aligned with "
|
||||
f"element size ({element_size} bytes). Cannot reconstruct tensor safely. "
|
||||
f"Expected size to be a multiple of {element_size}."
|
||||
)
|
||||
|
||||
# Reconstruct a tensor that spans the needed storage range, the metadata will be handled separately.
|
||||
return torch.tensor(
|
||||
[], device=reference_tensor.device, dtype=reference_tensor.dtype
|
||||
).set_(
|
||||
storage,
|
||||
0,
|
||||
torch.Size([total_size]),
|
||||
(),
|
||||
)
|
||||
|
||||
|
||||
def group_weights(all_weights: dict[str, Weights]) -> list[OrderedSet[tuple[str, str]]]:
|
||||
"""
|
||||
Group weights that share the same underlying storage.
|
||||
|
||||
Returns a list of sets, each set contains a tuple of (model_name, weight_name).
|
||||
"""
|
||||
|
||||
weights_dict: dict[tuple[int, torch.dtype], OrderedSet[tuple[str, str]]] = (
|
||||
collections.defaultdict(OrderedSet)
|
||||
) # (storage_key, dtype) -> set(weight)
|
||||
|
||||
for model_name, weights in all_weights.items():
|
||||
for weight_name, (tensor, properties) in weights.items():
|
||||
weights_dict[(properties.storage_ptr, tensor.dtype)].add(
|
||||
(model_name, weight_name)
|
||||
)
|
||||
|
||||
return list(weights_dict.values())
|
||||
@@ -0,0 +1,35 @@
|
||||
# Defined in torch/csrc/export/pt2_archive_constants.h
|
||||
from torch._C._export import pt2_archive_constants
|
||||
|
||||
|
||||
AOTINDUCTOR_DIR: str = pt2_archive_constants.AOTINDUCTOR_DIR
|
||||
ARCHIVE_FORMAT_PATH: str = pt2_archive_constants.ARCHIVE_FORMAT_PATH
|
||||
ARCHIVE_FORMAT_VALUE: str = pt2_archive_constants.ARCHIVE_FORMAT_VALUE
|
||||
ARCHIVE_ROOT_NAME: str = pt2_archive_constants.ARCHIVE_ROOT_NAME
|
||||
ARCHIVE_VERSION_PATH: str = pt2_archive_constants.ARCHIVE_VERSION_PATH
|
||||
ARCHIVE_VERSION_VALUE: str = pt2_archive_constants.ARCHIVE_VERSION_VALUE
|
||||
CONSTANTS_DIR: str = pt2_archive_constants.CONSTANTS_DIR
|
||||
CONSTANTS_CONFIG_FILENAME_FORMAT: str = (
|
||||
pt2_archive_constants.CONSTANTS_CONFIG_FILENAME_FORMAT
|
||||
)
|
||||
CUSTOM_OBJ_FILENAME_PREFIX: str = pt2_archive_constants.CUSTOM_OBJ_FILENAME_PREFIX
|
||||
EXECUTORCH_DIR: str = pt2_archive_constants.EXECUTORCH_DIR
|
||||
EXTRA_DIR: str = pt2_archive_constants.EXTRA_DIR
|
||||
MODELS_DIR: str = pt2_archive_constants.MODELS_DIR
|
||||
MODELS_FILENAME_FORMAT: str = pt2_archive_constants.MODELS_FILENAME_FORMAT
|
||||
MODULE_INFO_PATH: str = pt2_archive_constants.MODULE_INFO_PATH
|
||||
MTIA_DIR: str = pt2_archive_constants.MTIA_DIR
|
||||
SAMPLE_INPUTS_DIR: str = pt2_archive_constants.SAMPLE_INPUTS_DIR
|
||||
SAMPLE_INPUTS_FILENAME_FORMAT: str = pt2_archive_constants.SAMPLE_INPUTS_FILENAME_FORMAT
|
||||
TENSOR_CONSTANT_FILENAME_PREFIX: str = (
|
||||
pt2_archive_constants.TENSOR_CONSTANT_FILENAME_PREFIX
|
||||
)
|
||||
WEIGHTS_CONFIG_FILENAME_FORMAT: str = (
|
||||
pt2_archive_constants.WEIGHTS_CONFIG_FILENAME_FORMAT
|
||||
)
|
||||
WEIGHT_FILENAME_PREFIX: str = pt2_archive_constants.WEIGHT_FILENAME_PREFIX
|
||||
WEIGHTS_DIR: str = pt2_archive_constants.WEIGHTS_DIR
|
||||
XL_MODEL_WEIGHTS_DIR: str = pt2_archive_constants.XL_MODEL_WEIGHTS_DIR
|
||||
XL_MODEL_WEIGHTS_PARAM_CONFIG_PATH: str = (
|
||||
pt2_archive_constants.XL_MODEL_WEIGHTS_PARAM_CONFIG_PATH
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user