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

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
@@ -0,0 +1,5 @@
# Copyright (c) Facebook, Inc. and its 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.
@@ -0,0 +1,5 @@
# Copyright (c) Facebook, Inc. and its 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.
@@ -0,0 +1,191 @@
import json
import logging
from typing import Any
from torch._logging import trace_structured
from torch.fx import Graph, Node
log: logging.Logger = logging.getLogger(__name__)
def create_joint_graph_node_information(
joint_graph: Graph,
recomputable_node_info: dict[str, int],
) -> dict[str, Any]:
joint_graph_node_information: dict[str, Any] = {}
for i, joint_graph_node in enumerate(joint_graph.nodes):
is_recomputable_candidate: bool = (
joint_graph_node.name in recomputable_node_info
)
tensor_meta = joint_graph_node.meta.get("tensor_meta")
# pyrefly: ignore [implicit-any]
shape = getattr(tensor_meta, "shape", []) if tensor_meta else []
node_info: dict[str, Any] = {
"index": i,
"name": joint_graph_node.name,
"is_recomputable_candidate": is_recomputable_candidate,
"target": str(joint_graph_node.target),
"shape": str(shape),
"input_arguments": [inp.name for inp in joint_graph_node.all_input_nodes],
"stack_trace": joint_graph_node.meta.get("stack_trace", ""),
}
if is_recomputable_candidate:
idx: int = recomputable_node_info[joint_graph_node.name]
node_info["recomputable_candidate_info"] = {
"recomputable_node_idx": idx,
}
joint_graph_node_information[joint_graph_node.name] = node_info
return joint_graph_node_information
def create_joint_graph_edges(joint_graph: Graph) -> list[tuple[str, str]]:
joint_graph_edges: list[tuple[str, str]] = [
(inp.name, node.name)
for node in joint_graph.nodes
for inp in node.all_input_nodes
]
return joint_graph_edges
def create_activation_checkpointing_logging_structure_payload(
joint_graph: Graph,
joint_graph_node_information: dict[str, Any],
joint_graph_edges: list[tuple[str, str]],
all_recomputable_banned_nodes: list[Node],
expected_runtime: float,
saved_node_idxs: list[int],
recomputable_node_idxs: list[int],
memories_banned_nodes: list[int],
normalized_memories_banned_nodes: list[float],
runtimes_banned_nodes: list[float],
min_cut_saved_values: list[Node],
) -> dict[str, Any]:
"""
Creates a structured payload for logging activation checkpointing information.
Args:
joint_graph: The computational graph representing operations.
joint_graph_node_information: Dictionary containing information about nodes in the joint graph.
joint_graph_edges: List of edges in the joint graph represented as tuples of node names.
all_recomputable_banned_nodes: List of nodes that are banned from recomputation.
expected_runtime: Expected runtime of the computation.
saved_node_idxs: Indices of nodes that are saved (not recomputed).
recomputable_node_idxs: Indices of nodes that can be recomputed.
memories_banned_nodes: Memory usage values (in absolute units) for banned nodes.
normalized_memories_banned_nodes: Normalized memory usage values for banned nodes,
used as input to the knapsack algorithm.
runtimes_banned_nodes: Runtime values for banned nodes, used as input to the
knapsack algorithm.
min_cut_saved_values: List of nodes saved by the min-cut algorithm.
Returns:
A dictionary containing structured logging information for activation checkpointing.
"""
activation_checkpointing_logging_structure_payload: dict[str, Any] = {
"Joint Graph Size": len(joint_graph.nodes),
"Joint Graph Edges": {
"Total": len(joint_graph_edges),
"Edges": joint_graph_edges,
},
"Joint Graph Node Information": joint_graph_node_information,
"Recomputable Banned Nodes Order": [
node.name for node in all_recomputable_banned_nodes
],
"Expected Runtime": expected_runtime,
"Knapsack Saved Nodes": saved_node_idxs,
"Knapsack Recomputed Nodes": recomputable_node_idxs,
"Absolute Memories": memories_banned_nodes,
"Knapsack Input Memories": normalized_memories_banned_nodes,
"Knapsack Input Runtimes": runtimes_banned_nodes,
"Min Cut Solution Saved Values": [node.name for node in min_cut_saved_values],
}
return activation_checkpointing_logging_structure_payload
def create_structured_trace_for_min_cut_info(
joint_graph: Graph,
all_recomputable_banned_nodes: list[Node],
saved_node_idxs: list[int],
recomputable_node_idxs: list[int],
expected_runtime: float,
memories_banned_nodes: list[int],
normalized_memories_banned_nodes: list[float],
runtimes_banned_nodes: list[float],
min_cut_saved_values: list[Node],
) -> None:
"""
Creates a structured trace for minimum cut information in the graph.
Args:
joint_graph: The computational graph representation.
all_recomputable_banned_nodes: List of nodes that can be recomputed.
saved_node_idxs: Indices of nodes that are saved in memory.
recomputable_node_idxs: Indices of nodes that are recomputed.
expected_runtime: Expected runtime for the computation.
memories_banned_nodes: Memory requirements for each banned node in bytes.
normalized_memories_banned_nodes: Normalized memory requirements for each banned node
(typically scaled between 0 and 1 for relative comparison).
runtimes_banned_nodes: Runtime costs associated with each banned node.
min_cut_saved_values: Nodes that are saved as part of the minimum cut solution.
"""
# Create a dictionary to store recomputable node information
recomputable_node_info: dict[str, int] = {
node.name: idx for idx, node in enumerate(all_recomputable_banned_nodes)
}
# Create joint graph node information
joint_graph_node_information = create_joint_graph_node_information(
joint_graph, recomputable_node_info
)
# Update node information with recomputable candidate details
for node_name, node_info in joint_graph_node_information.items():
if node_info["is_recomputable_candidate"]:
idx = recomputable_node_info[node_name]
node_info["recomputable_candidate_info"]["memory"] = memories_banned_nodes[
idx
]
node_info["recomputable_candidate_info"]["runtime"] = runtimes_banned_nodes[
idx
]
node_info["recomputable_candidate_info"]["is_saved"] = (
idx in saved_node_idxs
)
node_info["recomputable_candidate_info"]["is_recomputed"] = (
idx in recomputable_node_idxs
)
# Create joint graph edges
joint_graph_edges = create_joint_graph_edges(joint_graph)
# Create activation checkpointing logging structure payload
activation_checkpointing_logging_structure_payload = (
create_activation_checkpointing_logging_structure_payload(
joint_graph=joint_graph,
joint_graph_node_information=joint_graph_node_information,
joint_graph_edges=joint_graph_edges,
all_recomputable_banned_nodes=all_recomputable_banned_nodes,
expected_runtime=expected_runtime,
saved_node_idxs=saved_node_idxs,
recomputable_node_idxs=recomputable_node_idxs,
memories_banned_nodes=memories_banned_nodes,
normalized_memories_banned_nodes=normalized_memories_banned_nodes,
runtimes_banned_nodes=runtimes_banned_nodes,
min_cut_saved_values=min_cut_saved_values,
)
)
# Create structured trace
trace_structured(
"artifact",
metadata_fn=lambda: {"name": "min_cut_information", "encoding": "json"},
payload_fn=lambda: json.dumps(
activation_checkpointing_logging_structure_payload
),
)
@@ -0,0 +1,319 @@
from typing import Any
import networkx as nx
from torch.fx import Graph, Node
class GraphInfoProvider:
"""
This class provides information about the graph, such as the nodes, edges, and their runtime and memory requirements.
It also provides methods to create graphs from the information provided.
"""
__RECOMPUTABLE_NODE_ONLY_GRAPH = "recomputable_node_only_graph"
__RECOMPUTABLE_NODE_ONLY_GRAPH_WITH_LARGER_GRAPH_CONTEXT = (
"recomputable_node_only_graph_with_larger_graph_context"
)
__FULL_NX_JOINT_GRAPH = "full_nx_joint_graph"
__SIMPLIFIED_FX_JOINT_GRAPH = "fx_joint_graph"
def __init__(
self,
graph_nodes_in_order: list[str],
graph_edges: list[tuple[str, str]],
all_recomputable_banned_nodes: list[str],
all_node_runtimes: dict[str, float] | None = None,
all_node_memories: dict[str, float] | None = None,
recorded_knapsack_input_memories: list[float] | None = None,
recorded_knapsack_input_runtimes: list[float] | None = None,
joint_graph: Graph | None = None,
) -> None:
self.graph_nodes_in_order = graph_nodes_in_order
self.graph_edges = graph_edges
self.all_node_runtimes: dict[str, float] = dict()
if all_node_runtimes is None:
if recorded_knapsack_input_runtimes is None:
raise ValueError(
"Either all_node_runtimes or recorded_knapsack_input_runtimes must be provided."
)
self.all_node_runtimes = {
node: recorded_knapsack_input_runtimes[i]
for i, node in enumerate(all_recomputable_banned_nodes)
}
else:
self.all_node_runtimes.update(all_node_runtimes)
self.all_node_memories: dict[str, float] = dict()
if all_node_memories is None:
if recorded_knapsack_input_memories is None:
raise ValueError(
"Either all_node_memories or recorded_knapsack_input_memories must be provided."
)
self.all_node_memories = {
node: recorded_knapsack_input_memories[i]
for i, node in enumerate(all_recomputable_banned_nodes)
}
else:
self.all_node_memories.update(all_node_memories)
self.all_recomputable_banned_nodes = all_recomputable_banned_nodes
self.all_recomputable_banned_nodes_set = set(all_recomputable_banned_nodes)
self.recorded_knapsack_input_memories = recorded_knapsack_input_memories
self.recorded_knapsack_input_runtimes = recorded_knapsack_input_runtimes
self._lazily_initialized_graphs: dict[str, Any] = {
self.__RECOMPUTABLE_NODE_ONLY_GRAPH: None,
self.__RECOMPUTABLE_NODE_ONLY_GRAPH_WITH_LARGER_GRAPH_CONTEXT: None,
self.__FULL_NX_JOINT_GRAPH: None,
self.__SIMPLIFIED_FX_JOINT_GRAPH: None,
}
@classmethod
def inialize_from_graph(
cls,
joint_graph: Graph,
all_recomputable_banned_nodes: list[Node],
recorded_knapsack_input_memories: list[float],
recorded_knapsack_input_runtimes: list[float],
) -> "GraphInfoProvider":
"""
Enables initialization from a joint graph.
"""
graph_nodes_in_order = [node.name for node in joint_graph.nodes]
graph_edges = [
(node.name, user.name) for node in joint_graph.nodes for user in node.users
]
all_recomputable_banned_node_names = [
node.name for node in all_recomputable_banned_nodes
]
return cls(
graph_nodes_in_order=graph_nodes_in_order,
graph_edges=graph_edges,
all_recomputable_banned_nodes=all_recomputable_banned_node_names,
recorded_knapsack_input_memories=recorded_knapsack_input_memories,
recorded_knapsack_input_runtimes=recorded_knapsack_input_runtimes,
joint_graph=joint_graph,
)
@property
def recomputable_node_only_graph(self) -> nx.DiGraph:
if self._lazily_initialized_graphs[self.__RECOMPUTABLE_NODE_ONLY_GRAPH] is None:
self._lazily_initialized_graphs[self.__RECOMPUTABLE_NODE_ONLY_GRAPH] = (
self._create_recomputable_node_only_graph()
)
return self._lazily_initialized_graphs[self.__RECOMPUTABLE_NODE_ONLY_GRAPH]
@property
def recomputable_node_only_graph_with_larger_graph_context(self) -> nx.DiGraph:
if (
self._lazily_initialized_graphs[
self.__RECOMPUTABLE_NODE_ONLY_GRAPH_WITH_LARGER_GRAPH_CONTEXT
]
is None
):
self._lazily_initialized_graphs[
self.__RECOMPUTABLE_NODE_ONLY_GRAPH_WITH_LARGER_GRAPH_CONTEXT
] = self._create_recomputable_node_only_graph_with_larger_graph_context()
return self._lazily_initialized_graphs[
self.__RECOMPUTABLE_NODE_ONLY_GRAPH_WITH_LARGER_GRAPH_CONTEXT
]
@property
def full_joint_nx_graph(self) -> nx.DiGraph:
if self._lazily_initialized_graphs[self.__FULL_NX_JOINT_GRAPH] is None:
self._lazily_initialized_graphs[self.__FULL_NX_JOINT_GRAPH] = (
self._create_full_joint_graph()
)
return self._lazily_initialized_graphs[self.__FULL_NX_JOINT_GRAPH]
@property
def simplified_fx_joint_graph(self) -> Graph:
if self._lazily_initialized_graphs[self.__SIMPLIFIED_FX_JOINT_GRAPH] is None:
self._lazily_initialized_graphs[self.__SIMPLIFIED_FX_JOINT_GRAPH] = (
self._recreate_psuedo_joint_graph()
)
return self._lazily_initialized_graphs[self.__SIMPLIFIED_FX_JOINT_GRAPH]
def get_non_ac_peak_memory(self) -> float:
return sum(
self.all_node_memories[node_name]
for node_name in self.all_recomputable_banned_nodes_set
)
def get_theoretical_max_runtime(self) -> float:
return sum(
self.all_node_runtimes[node_name]
for node_name in self.all_recomputable_banned_nodes_set
)
def get_knapsack_memory_input(self) -> list[float]:
return (
self.recorded_knapsack_input_memories
if self.recorded_knapsack_input_memories
else [
self.all_node_memories[node_name]
for node_name in self.all_recomputable_banned_nodes
]
)
def get_knapsack_runtime_input(self) -> list[float]:
return (
self.recorded_knapsack_input_runtimes
if self.recorded_knapsack_input_runtimes
else [
self.all_node_runtimes[node_name]
for node_name in self.all_recomputable_banned_nodes
]
)
def _create_recomputable_node_only_graph(self) -> nx.DiGraph:
graph = nx.DiGraph()
for recomputable_node in self.all_recomputable_banned_nodes:
graph.add_node(recomputable_node)
for a, b in self.graph_edges:
if (
a in self.all_recomputable_banned_nodes_set
and b in self.all_recomputable_banned_nodes_set
):
graph.add_edge(a, b)
return graph
def _create_recomputable_node_only_graph_with_larger_graph_context(
self,
) -> nx.DiGraph:
# Create a dictionary to store the reachable nodes for each node
all_recomputable_banned_nodes_set = set(self.all_recomputable_banned_nodes)
reachable_nodes = {}
for node in all_recomputable_banned_nodes_set:
# Use BFS to find all reachable nodes
predecessors = dict(nx.bfs_predecessors(self.full_joint_nx_graph, node))
reachable_recomputable_nodes = set(predecessors.keys()).intersection(
all_recomputable_banned_nodes_set
)
reachable_nodes[node] = reachable_recomputable_nodes
# Create the candidate graph
candidate_graph = nx.DiGraph()
candidate_graph.add_nodes_from(all_recomputable_banned_nodes_set)
for node1 in all_recomputable_banned_nodes_set:
for node2 in reachable_nodes[node1]:
# Check if there is an overlapping path
overlapping_path = False
for intermediate_node in reachable_nodes[node1]:
if (
intermediate_node != node2
and node2 in reachable_nodes[intermediate_node]
):
overlapping_path = True
break
if not overlapping_path:
candidate_graph.add_edge(node1, node2)
return candidate_graph
def _create_full_joint_graph(self) -> nx.DiGraph:
graph = nx.DiGraph()
for node in self.graph_nodes_in_order:
if node == "output":
continue
graph.add_node(node)
for a, b in self.graph_edges:
if a == "output" or b == "output":
continue
graph.add_edge(a, b)
return graph
def _recreate_psuedo_joint_graph(self) -> Graph:
# Create a dictionary to store the dependencies of each node
node_dependencies: dict[str, list[str]] = {
node: [] for node in self.graph_nodes_in_order
}
for a, b in self.graph_edges:
if a not in node_dependencies or b not in node_dependencies:
raise ValueError(f"Edge ({a}, {b}) references a non-existent node.")
node_dependencies[b].append(a)
joint_graph = Graph()
# Create nodes in the graph
nodes: dict[str, Node] = {}
for node_name in self.graph_nodes_in_order:
input_nodes = [nodes[dep] for dep in node_dependencies[node_name]]
if input_nodes:
node = joint_graph.call_function(lambda *x: x, tuple(input_nodes))
node.name = node_name
else:
node = joint_graph.placeholder(node_name)
nodes[node_name] = node
return joint_graph
def _visualize_recomputable_candidate_graph_with_larger_context(
self,
layout_k: float = 0.5,
layout_iterations: int = 30,
) -> None:
"""
Visualize the recomputable candidate graph with larger context.
"""
from matplotlib import cm, colors as mcolors, pyplot as plt
pos = nx.spring_layout(
self.recomputable_node_only_graph_with_larger_graph_context,
k=layout_k,
iterations=layout_iterations,
)
# pos = nx.spectral_layout(graph_with_indirect_edges)
plt.figure(figsize=(20, 15))
# Create a dictionary for node labels using the index
labels = {
node: self.recomputable_node_only_graph_with_larger_graph_context.nodes[
node
].get("index", node)
for node in self.recomputable_node_only_graph_with_larger_graph_context.nodes
}
# Extract memory values and normalize them
norm = mcolors.Normalize(
vmin=min(self.get_knapsack_memory_input()),
vmax=max(self.get_knapsack_memory_input()),
)
cmap = cm.viridis # type: ignore[attr-defined]
# Assign colors based on memory
node_colors = [
cmap(
norm(
float(
self.recomputable_node_only_graph_with_larger_graph_context.nodes[
node
]["memory"]
)
)
)
for node in self.recomputable_node_only_graph_with_larger_graph_context.nodes
]
# Draw the graph with parsed nodes only
nx.draw_networkx_nodes(
self.recomputable_node_only_graph_with_larger_graph_context,
pos,
node_color=node_colors,
node_size=300,
label="Parsed Nodes",
)
nx.draw_networkx_edges(
self.recomputable_node_only_graph_with_larger_graph_context,
pos,
arrows=True,
arrowsize=10,
)
nx.draw_networkx_labels(
self.recomputable_node_only_graph_with_larger_graph_context,
pos,
labels=labels,
font_size=8,
font_weight="bold",
)
plt.title("Memory Colour Coded Dependency Graph for Recomputable Nodes")
plt.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), label="Memory")
plt.show()
@@ -0,0 +1,267 @@
import torch
def greedy_knapsack(
memory: list[float], runtimes: list[float], max_memory: float
) -> tuple[float, list[int], list[int]]:
n = len(runtimes)
items = list(range(n))
# Sort items based on the ratio of runtime to memory in descending order
items = sorted(items, key=lambda i: runtimes[i] / memory[i], reverse=True)
total_memory = 0.0
total_runtime = 0.0
items_to_save = []
items_to_allow_recomputing = []
for i in items:
if total_memory + memory[i] <= max_memory:
total_memory += memory[i]
total_runtime += runtimes[i]
items_to_save.append(i)
else:
items_to_allow_recomputing.append(i)
return total_runtime, items_to_save, items_to_allow_recomputing
def ilp_knapsack(
memory: list[float], runtimes: list[float], max_memory: float
) -> tuple[float, list[int], list[int]]:
import numpy as np
try:
from scipy.optimize import Bounds, LinearConstraint, milp
except ImportError:
raise RuntimeError(
"To use the ILP for memory budget checkpointing you need to install scipy"
) from None
np_memory = np.array(memory)
np_runtimes = np.array(runtimes)
c = -np_runtimes # type: ignore[operator]
memory_constraint = LinearConstraint(A=np_memory, ub=np.array(max_memory))
constraints = [memory_constraint]
integrality = np.ones_like(c)
res = milp(
c=c, constraints=constraints, integrality=integrality, bounds=Bounds(0, 1)
)
if not res.success:
raise RuntimeError("Somehow scipy solving failed")
items_to_save = []
items_to_allow_recomputing = []
for idx, i in enumerate(res.x):
if i == 1:
items_to_save.append(idx)
else:
items_to_allow_recomputing.append(idx)
return -res.fun, items_to_save, items_to_allow_recomputing
def dp_knapsack(
memory: list[float], runtime: list[float], max_memory: float
) -> tuple[float, list[int], list[int]]:
# Scaling factor to convert floating point weights to integers
S = 10000
# Quantize the memory weights
quantized_memory = torch.tensor(
[round(m * S) for m in memory], dtype=torch.long, device="cpu"
)
runtimes = torch.tensor(runtime, dtype=torch.float32, device="cpu")
# Quantized pseudopolynomial DP for 0-1 Knapsack
quantized_max_memory = round(max_memory * S)
n = len(memory)
# Initialize the DP table
# TODO(chilli): I think if needed, this memory can be optimized with sliding
# window trick + Hirschberg trick:
# https://codeforces.com/blog/entry/47247?#comment-316200
dp = torch.zeros(
(n + 1, quantized_max_memory + 1), dtype=torch.float32, device="cpu"
)
for i in range(1, n + 1):
current_memory = quantized_memory[i - 1]
current_runtime = runtimes[i - 1]
# Copy the previous row
dp[i, :] = dp[i - 1, :]
# Update dp[i, j] for all j >= current_memory
if current_memory == 0:
dp[i, :] = dp[i - 1, :] + current_runtime
else:
dp[i, current_memory:] = torch.maximum(
dp[i - 1, current_memory:],
dp[i - 1, :-current_memory] + current_runtime,
)
# Backtrack to find the items included in the knapsack
saved_items = []
recomputable_items = []
j: int = quantized_max_memory
for i in range(n, 0, -1):
if dp[i][j] != dp[i - 1][j]:
saved_items.append(i - 1) # Include this item (indexing from 0)
j -= int(quantized_memory[i - 1].item())
else:
recomputable_items.append(i - 1)
saved_items.reverse() # To get items in the order they were added
# The maximum runtime that can be achieved within the max_memory constraint
max_runtime = dp[n][quantized_max_memory].item()
return max_runtime, saved_items, recomputable_items
def dp_knapsack_sliding_hirschberg(
memory: list[float], runtime: list[float], max_memory: float
) -> tuple[float, list[int], list[int]]:
# Scaling factor to convert floating point weights to integers
S = 10000
# q_ prefix stands for quantized
q_memory = [int(round(m * S)) for m in memory]
runtimes = [float(v) for v in runtime]
q_max_memory = int(round(max_memory * S))
q_memory_length = len(q_memory)
if q_memory_length == 0:
return 0.0, [], []
item_indices = list(range(q_memory_length))
dp_profile_size = q_max_memory + 1
# Current DP profile (row)
dp_profile = torch.zeros(dp_profile_size, dtype=torch.float32, device="cpu")
# Store a candidate for next dp_profile - current dp row + item
candidate_profile = torch.empty(dp_profile_size, dtype=torch.float32, device="cpu")
left_profile = torch.empty(dp_profile_size, dtype=torch.float32, device="cpu")
right_profile = torch.empty(dp_profile_size, dtype=torch.float32, device="cpu")
saved_items: list[int] = []
recomputable_items: list[int] = []
# Explicit stack to optimize memory and avoid recursion
# Stack stores segments as (start index, end index, capacity for segment)
stack: list[tuple[int, int, int]] = [(0, q_memory_length, q_max_memory)]
# LIFO
while stack:
start, end, capacity = stack.pop()
length = end - start
if length == 0:
continue
# Leaf
if length == 1:
index = item_indices[start]
memory_item = q_memory[index]
runtime_item = runtimes[index]
if memory_item <= capacity and runtime_item > 0.0:
saved_items.append(index)
else:
recomputable_items.append(index)
continue
# Split the segment into two halves
middle = start + (length // 2)
left_start, left_end = middle, end
right_start, right_end = start, middle
# Assign items to both halves
left_items = item_indices[left_start:left_end]
right_items = item_indices[right_start:right_end]
# Working only on items allowed by segment's capacity
capacity = capacity + 1
dp_view = dp_profile[:capacity]
candidate_view = candidate_profile[:capacity]
left_dp_local = left_profile[:capacity]
right_dp_local = right_profile[:capacity]
# Left part
dp_view.zero_()
for index in left_items:
memory_item = q_memory[index]
runtime_item = runtimes[index]
if memory_item == 0:
# Weight is 0, so add it to all capacities; a "free lunch", essentially
dp_view.add_(runtime_item)
continue
# If item is too heavy, we skip it
if memory_item >= capacity:
continue
# Add the current item so we can then pick the highest value
dp_view_candidate = candidate_view[: capacity - memory_item]
torch.add(dp_view[:-memory_item], runtime_item, out=dp_view_candidate)
# Take the highest - either previous (without current) or with current
torch.maximum(
dp_view[memory_item:], dp_view_candidate, out=dp_view[memory_item:]
)
# Store the left profile
left_dp_local.copy_(dp_view)
# Right part
dp_view.zero_()
for index in right_items:
memory_item = q_memory[index]
runtime_item = runtimes[index]
if memory_item == 0:
dp_view.add_(runtime_item)
continue
if memory_item >= capacity:
continue
dp_view_candidate = candidate_view[: capacity - memory_item]
torch.add(dp_view[:-memory_item], runtime_item, out=dp_view_candidate)
torch.maximum(
dp_view[memory_item:], dp_view_candidate, out=dp_view[memory_item:]
)
# Store the reversed right profile
right_dp_local.copy_(dp_view.flip(-1))
# In-place compute item-wise sum of left and right to pick the split point where the sum is highest
left_dp_local.add_(right_dp_local)
# Pick the index of highest value of a pair, which we then use as a split point
best_split = int(torch.argmax(left_dp_local).item())
left_capacity = best_split
right_capacity = capacity - best_split
# Clamp (might be removed if we're 100% sure that there is no edge case that will mess up the indices math)
if left_capacity < 0:
left_capacity = 0
if right_capacity < 0:
right_capacity = 0
if left_capacity > q_max_memory:
left_capacity = q_max_memory
if right_capacity > q_max_memory:
right_capacity = q_max_memory
# Push right then left, so left is processed next
stack.append((right_start, right_end, right_capacity))
stack.append((left_start, left_end, left_capacity))
saved_items = sorted(saved_items)
recomputable_items = sorted(recomputable_items)
max_runtime = sum(runtime[i] for i in saved_items)
recomputable_items.reverse()
return max_runtime, saved_items, recomputable_items
@@ -0,0 +1,282 @@
import operator
from collections import deque
from collections.abc import Callable
import networkx as nx
from torch._functorch._activation_checkpointing.graph_info_provider import (
GraphInfoProvider,
)
class KnapsackEvaluator:
"""
This class evaluates the theoretical runtime and peak memory usage of a given checkpointing strategy.
It takes in a graph and a list of nodes that are saved and recomputed, and then simulates the
backward pass to calculate the peak memory usage.
"""
def __init__(
self,
graph_info_provider: GraphInfoProvider,
) -> None:
self._graph_info_provider = graph_info_provider
def _get_backward_memory_from_topologically_sorted_graph(
self,
node_graph: nx.DiGraph,
node_memories: dict[str, float],
saved_nodes_set: set[str],
peak_memory_after_forward_pass: float,
) -> list[tuple[float, str]]:
"""
Simulates the backward pass and keeps track of the peak memory usage.
High Level Steps:
1. Set Initial Peak/Current Memory
Allows you to set the peak memory after the forward pass, but typically this is
the sum of the estimated memory of the saved nodes.
2. Perform a reverse topological sort of the node_graph.
If full graph is defined then will sort the full graph and only process the subset
of nodes in the node_graph.
3. Iterate through the sorted graph nodes.
If the node is saved then just drop it's memory from current memory.
If the node is not saved then add it's memory to current memory and then traverse it's
predecessors to simulate recomuptation chain. Will check if new peak memory after all
predecessors are processed.
Args:
node_graph (nx.DiGraph): A directed graph representing the recomputable forward nodes.
saved_nodes_set (Set[str]): A set of node names that are saved.
peak_memory_after_forward_pass (float): The peak memory usage after the forward pass.
"""
current_memory = [
(peak_memory_after_forward_pass, "Initial Peak/Current Memory")
]
already_computed = set()
sorted_nodes = list(reversed(list(nx.topological_sort(node_graph))))
dependencies_computed = set()
for node in sorted_nodes:
if node in saved_nodes_set or node in already_computed:
current_memory.append(
(
current_memory[-1][0] - node_memories[node],
f"Dropping Node(already saved): {node}",
)
)
continue
already_computed.add(node)
current_memory.append(
(
current_memory[-1][0] + node_memories[node],
f"Recomputing Node: {node}",
)
)
# Create a queue of dependencies required for recomputation
predecessor_queue = deque(
[
dependency
# pyrefly: ignore [bad-unpacking]
for dependency, v in node_graph.in_edges(node)
if dependency not in already_computed
]
)
while predecessor_queue:
dep = predecessor_queue.popleft()
already_computed.add(dep)
dependencies_computed.add(dep)
current_memory.append(
(
current_memory[-1][0] + node_memories[dep],
f"Recomputing Predecessor of {node}: {dep}",
)
)
# Add predecessors of the predecessor to the queue if they haven't been recomputed yet
# pyrefly: ignore [bad-unpacking]
for dependency_of_dependency, _ in node_graph.in_edges(dep):
if (
dependency_of_dependency in already_computed
or dependency_of_dependency in saved_nodes_set
or dependency_of_dependency in predecessor_queue
):
continue
predecessor_queue.append(dependency_of_dependency)
dependencies_computed.clear()
current_memory.append(
(current_memory[-1][0] - node_memories[node], f"Dropping Node: {node}")
)
return current_memory
def _validate_all_indexes_accounted_for_in_provided_output(
self, saved_nodes_idxs: list[int], recomputable_node_idxs: list[int]
) -> None:
"""
Validate that all indexes are accounted for in the provided output.
This function checks that the union of saved nodes and recomputable nodes
covers all candidate nodes without any overlaps.
"""
recomputable_node_idxs_set = set(recomputable_node_idxs)
saved_nodes_idxs_set = set(saved_nodes_idxs)
all_candidate_nodes_idxs = set(
range(len(self._graph_info_provider.all_recomputable_banned_nodes))
)
# Check that there are no overlaps between saved nodes and recomputable nodes
if len(recomputable_node_idxs_set.intersection(saved_nodes_idxs_set)) != 0:
raise AssertionError(
f"Saved nodes and recomputable nodes cannot have any overlaps, "
f"but found overlap: {recomputable_node_idxs_set.intersection(saved_nodes_idxs_set)}"
)
# Check that all candidate nodes are accounted for
if (
recomputable_node_idxs_set.union(saved_nodes_idxs_set)
!= all_candidate_nodes_idxs
):
raise AssertionError(
f"All candidate nodes must be accounted for in the provided output, "
f"got union={recomputable_node_idxs_set.union(saved_nodes_idxs_set)}, "
f"expected={all_candidate_nodes_idxs}"
)
def evaluate_knapsack_output(
self,
saved_nodes_idxs: list[int],
recomputable_node_idxs: list[int],
account_for_backward_pass: bool = False,
) -> dict[str, float]:
"""
Evaluate the theoretical runtime and peak memory usage of a given checkpointing strategy.
Args:
- saved_nodes_idxs (List[int]): The indices of nodes that are saved.
- recomputable_node_idxs (List[int]): The indices of nodes that need to be recomputed.
"""
self._validate_all_indexes_accounted_for_in_provided_output(
saved_nodes_idxs, recomputable_node_idxs
)
recomputation_runtime = sum(
self._graph_info_provider.all_node_runtimes[
self._graph_info_provider.all_recomputable_banned_nodes[node]
]
for node in recomputable_node_idxs
)
if account_for_backward_pass:
memory_list = self._get_backward_memory_from_topologically_sorted_graph(
node_graph=self._graph_info_provider.recomputable_node_only_graph_with_larger_graph_context,
saved_nodes_set={
self._graph_info_provider.all_recomputable_banned_nodes[i]
for i in saved_nodes_idxs
},
node_memories=self._graph_info_provider.all_node_memories,
peak_memory_after_forward_pass=sum(
self._graph_info_provider.all_node_memories[
self._graph_info_provider.all_recomputable_banned_nodes[i]
]
for i in saved_nodes_idxs
),
)
peak_memory = max(memory_list, key=operator.itemgetter(0))[0]
else:
peak_memory = sum(
self._graph_info_provider.all_node_memories[
self._graph_info_provider.all_recomputable_banned_nodes[node]
]
for node in saved_nodes_idxs
)
return {
"peak_memory": peak_memory,
"recomputation_runtime": recomputation_runtime,
"non_ac_peak_memory": self._graph_info_provider.get_non_ac_peak_memory(),
"theoretical_max_runtime": self._graph_info_provider.get_theoretical_max_runtime(),
"percentage_of_theoretical_peak_memory": peak_memory
/ self._graph_info_provider.get_non_ac_peak_memory(),
"percentage_of_theoretical_peak_runtime": recomputation_runtime
/ self._graph_info_provider.get_theoretical_max_runtime(),
}
def evaluate_distribution_of_results_for_knapsack_algo(
self,
knapsack_algo: Callable[
[list[float], list[float], float], tuple[float, list[int], list[int]]
],
memory_budget_values: list[float],
) -> list[dict[str, float]]:
"""
Evaluates the distribution of results for a given knapsack algorithm.
Args:
knapsack_algo (Callable): The knapsack algorithm to use for evaluation.
memory_budget_values (List[float]): A list of memory budgets to evaluate.
"""
results = []
for memory_budget in memory_budget_values:
_, saved_nodes, recomputed_nodes = knapsack_algo(
self._graph_info_provider.get_knapsack_memory_input(),
self._graph_info_provider.get_knapsack_runtime_input(),
memory_budget,
)
result = self.evaluate_knapsack_output(
saved_nodes_idxs=saved_nodes,
recomputable_node_idxs=recomputed_nodes,
)
result["memory_budget"] = memory_budget
results.append(result)
return results
def get_knee_point_memory_budget(
self,
knapsack_algo: Callable[
[list[float], list[float], float], tuple[float, list[int], list[int]]
],
max_mem_budget: float = 0.1,
min_mem_budget: float = 0.001,
iterations: int = 100,
) -> float:
"""
Finds the memory budget at the knee point in the Pareto frontier.
The knee point is defined as the point where the trade-off between
runtime and memory usage is optimal.
Args:
knapsack_algo (callable): Knapsack algorithm to use for evaluation.
max_mem_budget (float, optional): Maximum memory budget. Defaults to 0.1.
min_mem_budget (float, optional): Minimum memory budget. Defaults to 0.001.
iterations (int, optional): Number of memory budgets to evaluate. Defaults to 100.
Returns:
float: Memory budget at the knee point.
"""
results = self.evaluate_distribution_of_results_for_knapsack_algo(
knapsack_algo=knapsack_algo,
memory_budget_values=[
min_mem_budget
+ i * (max_mem_budget - min_mem_budget) / (iterations - 1)
for i in range(iterations)
],
)
runtime_values = [
result["percentage_of_theoretical_peak_runtime"] for result in results
]
memory_values = [
result["percentage_of_theoretical_peak_memory"] for result in results
]
runtime_range = max(runtime_values) - min(runtime_values)
memory_range = max(memory_values) - min(memory_values)
if runtime_range == 0 or memory_range == 0:
return max_mem_budget
# Normalize values
runtime_min = min(runtime_values)
memory_min = min(memory_values)
runtime_norm = [
(value - runtime_min) / runtime_range for value in runtime_values
]
memory_norm = [(value - memory_min) / memory_range for value in memory_values]
# Calculate Euclidean distance
distances = [
(runtime_norm[i] ** 2 + memory_norm[i] ** 2) ** 0.5
for i in range(len(runtime_norm))
]
# Find the knee point(shortest distance from the origin)
knee_index = distances.index(min(distances))
return results[knee_index]["memory_budget"]
@@ -0,0 +1,206 @@
"""AC rematerialize pass: Duplicates recompute nodes for backward, then DCE removes unused forward versions."""
import itertools
import logging
from typing import Any, overload
import torch
import torch.fx as fx
from torch._functorch.compile_utils import raise_getitems
from torch._functorch.partitioners import (
cleanup_recompute_tags,
force_save_bw_mutation_src,
has_recomputable_ops,
has_recomputable_rng_ops,
is_not_collective,
must_recompute,
)
log = logging.getLogger(__name__)
_EMPTY_CUSTOM_META: dict[str, object] = {}
def is_impure_node_for_dce(node: fx.Node) -> bool:
# Check for special collectives that should be treated as pure
if not is_not_collective(node):
# It's a collective (wait_tensor, all_gather_into_tensor, etc.)
# Treat as pure - can be eliminated if unused
return False
# For everything else, fall back to the DEFAULT logic
# This is what eliminate_dead_code() calls when is_impure_node=None
impure_random = True
if torch._guards.TracingContext.try_get():
impure_random = torch._inductor.config.fallback_random
return node.is_impure(impure_random)
def _is_backward_node(node: fx.Node, use_phase: bool = False) -> bool:
"""Check if node is in backward region.
If use_phase is True, only checks custom["phase"] == "backward"
(user annotation). Otherwise falls back to node.meta["autograd_backward"],
which Dynamo adds when tracing torch.autograd.grad.
"""
custom = node.meta.get("custom", _EMPTY_CUSTOM_META)
if use_phase:
return custom.get("phase") == "backward"
return node.meta.get("autograd_backward", False)
def _has_user_phase_annotation(gm: fx.GraphModule) -> bool:
"""Check if any node has the user-level phase: backward annotation."""
return any(
node.meta.get("custom", _EMPTY_CUSTOM_META).get("phase") == "backward"
for node in gm.graph.nodes
)
def _collect_backward_region_data(
gm: fx.GraphModule,
) -> tuple[bool, int | None, int | None, int]:
use_phase = _has_user_phase_annotation(gm)
bwd_start: int | None = None
bwd_end: int | None = None
num_regions = 0
in_backward = False
for idx, node in enumerate(gm.graph.nodes):
is_bwd = _is_backward_node(node, use_phase=use_phase)
if is_bwd:
if bwd_start is None:
bwd_start = idx
bwd_end = idx + 1
if not in_backward:
num_regions += 1
in_backward = is_bwd
return use_phase, bwd_start, bwd_end, num_regions
def remat_using_tags_for_fwd_loss_bwd_graph(gm: fx.GraphModule) -> fx.GraphModule:
"""
Duplicate recompute nodes for backward use. DCE removes unused forward versions.
Backward regions are identified by custom["phase"] == "backward" (user
annotation) or node.meta["autograd_backward"] == True (set automatically when
Dynamo traces torch.autograd.grad). When the user provides phase
annotations, only those annotated regions are used.
Only a single contiguous backward region is supported. If multiple disjoint
backward regions are detected, an error is raised. Consecutive backward
operations without non-backward nodes between them are treated as a single
backward region.
"""
if not has_recomputable_ops(gm):
return gm
use_phase, bwd_start, bwd_end, num_regions = _collect_backward_region_data(gm)
if num_regions > 1:
if use_phase:
raise RuntimeError(
f"Detected {num_regions} disjoint backward regions annotated with "
'phase: "backward" but remat only supports a single backward region. '
"Please ensure only one contiguous region is annotated."
)
raise RuntimeError(
f"Detected {num_regions} disjoint backward regions in the graph but remat only supports "
"a single backward region. This can happen when non-backward computation appears "
"between backward sections. Please annotate the real backward with "
'torch.fx.traceback.annotate({"phase": "backward"}).'
)
if bwd_start is None:
return gm
if bwd_end is None:
raise AssertionError(
"backward region should end somewhere when there was explicit backward region start."
)
order = {node: idx for idx, node in enumerate(gm.graph.nodes)}
if has_recomputable_rng_ops(gm):
raise RuntimeError(
"Activation checkpoint rematerialization in `forward-loss-backward` graph does not support RNG ops "
"in recompute regions. Please move RNG operations outside "
"of recompute regions, or use joint graph mode (where partitioner handles RNG)."
)
# Use partitioner pass to normalize AC node tags.
gm = cleanup_recompute_tags(gm, is_default_partition=True)
force_save_bw_mutation_src(gm)
new_graph = fx.Graph()
env: dict[fx.Node, fx.Node] = {}
recomputed_nodes: dict[fx.Node, fx.Node] = {}
# Insert forward nodes
for node in itertools.islice(gm.graph.nodes, 0, bwd_start):
env[node] = new_graph.node_copy(node, lambda x: env[x])
@overload
def remat_input(x: fx.Node) -> fx.Node: ...
@overload
def remat_input(x: Any) -> Any: ...
def remat_input(x: object) -> object:
# fx.Node can have args that are primitive types (e.g. int, float, bool)
if not isinstance(x, fx.Node):
return x
return recomputed_nodes.get(x, env[x])
def gather_recompute_deps(node: fx.Node) -> set[fx.Node]:
deps: set[fx.Node] = set()
def _gather(n: fx.Node) -> None:
if n in deps or n in recomputed_nodes or not must_recompute(n):
return
deps.add(n)
for inp in n.all_input_nodes:
_gather(inp)
# Can't call _gather(node) directly: node itself may not be must_recompute
# (e.g. backward nodes), so _gather would return early without visiting inputs.
for inp in node.all_input_nodes:
_gather(inp)
return deps
# Insert backward nodes
for node in itertools.islice(gm.graph.nodes, bwd_start, bwd_end):
# Gather all deps that need to be recomputed for this node
deps = gather_recompute_deps(node)
# Insert deps in forward order (guaranteed disjoint from already-inserted)
# This is not as inefficient as it looks, because we only add fresh dependencies
# when they are not yet processed as recomputed nodes.
new_deps = sorted(deps, key=lambda n: order[n])
if new_deps:
log.debug(
"To compute backward node %s, recomputing [%s]",
node.name,
", ".join(dep.name for dep in new_deps),
)
for dep in new_deps:
dup = new_graph.node_copy(dep, remat_input)
dup.name = dep.name + "_recomputed"
recomputed_nodes[dep] = dup
env[node] = new_graph.node_copy(node, remat_input)
for node in itertools.islice(gm.graph.nodes, bwd_end, None):
env[node] = new_graph.node_copy(node, lambda x: env[x])
new_gm = torch.fx.GraphModule(gm, new_graph)
# DCE with custom is_impure_node (like default_partition)
# Treats certain collectives as pure while delegating to default impurity logic
new_gm.graph.eliminate_dead_code(is_impure_node=is_impure_node_for_dce)
# raise_getitems pass for better memory (like default_partition)
new_gm = raise_getitems(new_gm)
new_gm.recompile()
return new_gm
@@ -0,0 +1,5 @@
# Copyright (c) Facebook, Inc. and its 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.
@@ -0,0 +1,813 @@
"""Activation offloading for memory optimization during compilation.
This module provides functionality to offload activations to CPU during the forward
pass and reload them during the backward pass, reducing GPU memory usage. It can be
applied to graphs produced by both AOT Autograd partitioners and make_fx-based tracing.
"""
import logging
import operator
from dataclasses import dataclass
import torch
import torch.fx as fx
from torch._functorch._activation_offloading.offload_ops import ( # noqa: F401 -- registers ao::offload, ao::reload, ao::wait_tensor ops
offload,
reload,
wait_tensor,
)
from torch._inductor.fx_passes.overlap_scheduling import benchmark_node, is_compute_node
from torch._subclasses.fake_tensor import extract_tensor_metadata
from torch.utils._ordered_set import OrderedSet
from .. import config
from ..partitioners import _size_of, get_default_op_list, OpTypes
log: logging.Logger = logging.getLogger(__name__)
# Node name prefixes for offload/reload operations
# NOTE: right now we are using these prefixes as identifiers for offload/reload
CPU_OFFLOAD_PREFIX = "cpu_offload_"
GPU_RELOAD_PREFIX = "gpu_reload_"
def _find_all_effective_users(node: fx.Node, op_types: OpTypes) -> OrderedSet[fx.Node]:
"""Find all effective users of a node, where view ops extend the lifetime
of the original node. If a user is a view op, recursively find users of
the view."""
effective_users: OrderedSet[fx.Node] = OrderedSet()
for user in node.users:
if user.op == "output":
continue
effective_users.add(user)
if op_types.is_view(user):
effective_users.update(_find_all_effective_users(user, op_types))
return effective_users
@dataclass
class ReloadNodeInfo:
"""
Information about backward reload related nodes for each reload operation.
Pattern: ao.reload → ao.wait_tensor
- Reload group (ao.reload): Performs the actual asynchronous data transfer.
Can be moved earlier in the graph to overlap with computation.
- Wait node (ao.wait_tensor): Synchronization point that blocks until the data
transfer completes. Must remain at the point where the data is first needed.
"""
reload_group_nodes: list[fx.Node]
wait_event_node: fx.Node
transfer_size_bytes: int
transfer_time_ms: float
@dataclass
class ReloadQueueEntry:
"""
Entry in the reload queue for prefetch scheduling.
Attributes:
pattern: The reload pattern information
remaining_time_ms: Remaining overlap time needed in milliseconds
"""
pattern: ReloadNodeInfo
remaining_time_ms: float
def offload_activation_fw(graph: fx.Graph) -> None:
"""
Insert CPU offload operations in the forward pass graph.
Offload operations are placed after the last effective use of each tensor marked
for offloading. This ensures the tensor is no longer needed on the GPU before
transferring it to CPU memory.
NOTE: An alternative approach would offload tensors immediately after generation
to maximize compute-communication overlap. However, this requires additional
synchronization to ensure tensor deletion (which occurs on the default stream)
waits for the asynchronous offload operation to complete. This would necessitate
more complex tracking to separate operation scheduling from memory cleanup.
Args:
graph: The forward graph to modify
"""
op_types: OpTypes = get_default_op_list()
output_node: fx.Node = graph.find_nodes(op="output")[0]
# pyrefly: ignore [bad-assignment]
fwd_outputs: tuple[fx.Node, ...] = output_node.args[
0
] # pyrefly: ignore [bad-assignment]
node_to_offload: dict[fx.Node, fx.Node] = dict()
node_to_index: dict[fx.Node, int] = {
node: idx for idx, node in enumerate(graph.nodes)
}
for node in fwd_outputs:
if node.meta.get("saved_for_offloading", False) is False:
continue
# Find insertion point, which is the last use
if all_effective_users := _find_all_effective_users(node, op_types):
last_user = max(all_effective_users, key=lambda n: node_to_index[n])
else:
last_user: fx.Node = node
# Insert the CPU offload operation after the last user
with graph.inserting_after(last_user):
cpu_node: fx.Node = graph.call_function(
torch.ops.prims.device_put.default,
args=(node, torch.device("cpu")),
kwargs={"non_blocking": True},
name=CPU_OFFLOAD_PREFIX + str(node.name),
)
cpu_node.meta["val"] = node.meta["val"].to(torch.device("cpu"))
cpu_node.meta["tensor_meta"] = extract_tensor_metadata(cpu_node.meta["val"])
node_to_offload[node] = cpu_node
# Update the return node args
output_node.update_arg(
0, tuple(node_to_offload.get(node, node) for node in fwd_outputs)
)
def reload_activation_bw(graph: fx.Graph) -> None:
"""
Insert GPU reload operations in the backward pass graph.
Reload operations are placed before the first use of each offloaded tensor,
transferring it from CPU back to GPU memory before it's needed for computation.
Args:
graph: The backward graph to modify
"""
node_to_index: dict[fx.Node, int] = {
node: idx for idx, node in enumerate(graph.nodes)
}
output_node: fx.Node = graph.find_nodes(op="output")[0]
for node in graph.find_nodes(op="placeholder"):
if node.meta.get("saved_for_offloading", False) is False:
continue
# Find insertion point, which is the first use or output node if no users
# The later should not happen, but inserting before output node is safe
insert_point: fx.Node = (
min(node.users.keys(), key=lambda n: node_to_index[n])
if node.users
else output_node
)
# Insert the GPU reload operation before the first user
original_device: torch.Device = node.meta["original_device"]
with graph.inserting_before(insert_point):
gpu_node: fx.Node = graph.call_function(
torch.ops.prims.device_put.default,
args=(node, original_device),
kwargs={"non_blocking": True},
name=str(node.name).replace(CPU_OFFLOAD_PREFIX, GPU_RELOAD_PREFIX),
)
gpu_node.meta["val"] = node.meta["val"].to(original_device)
gpu_node.meta["tensor_meta"] = extract_tensor_metadata(gpu_node.meta["val"])
# Replace all uses of the CPU tensor with the GPU tensor
for user in list(node.users.keys()):
if user != gpu_node:
user.replace_input_with(node, gpu_node)
def offload_activation_fw_async(graph: fx.Graph) -> None:
"""Insert async CPU offload operations in the forward pass graph.
Uses ao.offload + ao.wait_tensor ops which encapsulate stream management
internally, producing a clean 2-node IR per offloaded tensor.
"""
op_types: OpTypes = get_default_op_list()
output_node: fx.Node = graph.find_nodes(op="output")[0]
# pyrefly: ignore [bad-assignment]
fwd_outputs: tuple[fx.Node, ...] = output_node.args[
0
] # pyrefly: ignore [bad-assignment]
node_to_offload: dict[fx.Node, fx.Node] = dict()
node_to_index: dict[fx.Node, int] = {
node: idx for idx, node in enumerate(graph.nodes)
}
if not any(n.meta.get("saved_for_offloading", False) for n in fwd_outputs):
return
for node in fwd_outputs:
if node.meta.get("saved_for_offloading", False) is False:
continue
if all_effective_users := _find_all_effective_users(node, op_types):
last_user = max(all_effective_users, key=lambda n: node_to_index[n])
else:
last_user: fx.Node = node
with graph.inserting_after(last_user):
offload_node: fx.Node = graph.call_function(
torch.ops.ao.offload.default,
args=(node,),
name=f"async_{CPU_OFFLOAD_PREFIX}{node.name}",
)
offload_node.meta["val"] = node.meta["val"].to(torch.device("cpu"))
offload_node.meta["tensor_meta"] = extract_tensor_metadata(
offload_node.meta["val"]
)
# The keepalive=node arg extends the GPU tensor's lifetime in the
# graph so the allocator doesn't reclaim it before the async D2H
# copy completes.
with graph.inserting_after(offload_node):
wait_node: fx.Node = graph.call_function(
torch.ops.ao.wait_tensor.default,
args=(offload_node, node),
name=CPU_OFFLOAD_PREFIX + str(node.name),
)
wait_node.meta["val"] = offload_node.meta["val"]
wait_node.meta["tensor_meta"] = offload_node.meta["tensor_meta"]
node_to_offload[node] = wait_node
output_node.update_arg(
0, tuple(node_to_offload.get(node, node) for node in fwd_outputs)
)
def reload_activation_bw_async(graph: fx.Graph) -> None:
"""Insert async GPU reload operations in the backward pass graph.
Uses ao.reload + ao.wait_tensor ops which encapsulate stream management internally,
producing a clean 2-node IR per reloaded tensor.
"""
node_to_index: dict[fx.Node, int] = {
node: idx for idx, node in enumerate(graph.nodes)
}
nodes_to_reload = [
n
for n in graph.find_nodes(op="placeholder")
if n.meta.get("saved_for_offloading", False)
]
if not nodes_to_reload:
return
for node in nodes_to_reload:
if not node.users:
raise RuntimeError(
f"Offloaded tensor {node.name} has no users in the backward graph"
)
insert_point: fx.Node = min(node.users.keys(), key=lambda n: node_to_index[n])
original_device: torch.device = node.meta["original_device"]
with graph.inserting_before(insert_point):
reload_node: fx.Node = graph.call_function(
torch.ops.ao.reload.default,
args=(node, original_device),
name=f"async_{str(node.name).replace(CPU_OFFLOAD_PREFIX, GPU_RELOAD_PREFIX)}",
)
reload_node.meta["val"] = node.meta["val"].to(original_device)
reload_node.meta["tensor_meta"] = extract_tensor_metadata(
reload_node.meta["val"]
)
wait_node: fx.Node = graph.call_function(
torch.ops.ao.wait_tensor.default,
args=(reload_node,),
name=str(node.name).replace(CPU_OFFLOAD_PREFIX, GPU_RELOAD_PREFIX),
)
wait_node.meta["val"] = reload_node.meta["val"]
wait_node.meta["tensor_meta"] = reload_node.meta["tensor_meta"]
for user in list(node.users.keys()):
if user != reload_node:
user.replace_input_with(node, wait_node)
def can_offload(
node: fx.Node,
fwd_outputs: OrderedSet[fx.Node],
model_outputs: OrderedSet[fx.Node],
static_lifetime_input_nodes: OrderedSet[fx.Node],
) -> bool:
"""
Determine if a node can be offloaded to CPU.
Args:
node: The node to check
fwd_outputs: Forward module outputs, including model outputs and activations
model_outputs: Model outputs
NOTE: Additional context for the logic behind these offloading checks:
* fwd_outputs: Only saved intermediate tensors should be offloaded.
* model_outputs / static_lifetime_input_nodes: Tensors that may be accessed outside
the compiled region (e.g., model outputs, static inputs) cannot be offloaded as
they must remain accessible beyond the scope of the compiled graph.
* views / getitems: Offloading such nodes can lead to segmentation faults.
* contiguous: Offloading non-contiguous tensors causes CPU-side stride changes
during both forward and backward passes when using the Inductor backend. While
these stride changes cancel each other out, they introduce significant compute
overhead. This is due to the contiguity check in ir.py (see link below).
TODO: This restriction could potentially be bypassed in the future.
Reference: https://github.com/pytorch/pytorch/blob/44ac69388a4a5eb463dbd2a13f00d1e3b924566c/torch/_inductor/ir.py#L3214
Additional criteria to consider for offloading optimization:
* Tensor size: Small tensors may not fully utilize available bandwidth, reducing the
efficiency gains from offloading.
* Position in forward/backward graph: Activations generated near the end of the forward
pass are typically consumed near the beginning of the backward pass. Offloading such
tensors may be counterproductive since they are quickly reloaded, not having sufficient
time to overlap the transfer with computation.
"""
log.debug(f"Checking node {node.name} for offloading...") # noqa: G004
op_types: OpTypes = get_default_op_list()
if node not in fwd_outputs:
log.debug("\tSkipped! Can only offload nodes in fwd_module_outputs.")
return False
if node in model_outputs:
log.debug("\tSkipped! Cannot offload model outputs.")
return False
if node in static_lifetime_input_nodes:
log.debug("\tSkipped! Cannot offload static input nodes.")
return False
if op_types.is_view(node):
log.debug("\tSkipped! Cannot offload views.")
return False
if node.target == operator.getitem:
log.debug("\tSkipped! Cannot offload getitems.")
return False
if hasattr(node, "meta") and "val" in node.meta:
if (
isinstance(val := node.meta["val"], torch.Tensor)
and not val.is_contiguous()
):
log.debug("\tSkipped! Cannot offload non-contiguous tensors.")
return False
log.debug("\tGood!")
return True
def choose_offload_sets(
fwd_module: fx.GraphModule,
num_fwd_outputs: int,
static_lifetime_input_nodes: OrderedSet[fx.Node],
) -> bool:
"""
Decide which nodes will be offloaded based on the marked nodes and feasibility.
Marks nodes with "saved_for_offloading" if they should and can be offloaded.
Args:
fwd_module: Forward graph module
bwd_module: Backward graph module
num_fwd_outputs: Number of forward outputs
Returns:
bool: Whether activation offloading should be performed
"""
fwd_outputs: OrderedSet[fx.Node] = OrderedSet(
fwd_module.graph.find_nodes(op="output")[0].args[0]
)
model_outputs: OrderedSet[fx.Node] = OrderedSet(
fwd_module.graph.find_nodes(op="output")[0].args[0][:num_fwd_outputs]
)
should_perform_offloading = False
for node in fwd_module.graph.nodes:
if node.meta.get("should_offload", False) and can_offload(
node, fwd_outputs, model_outputs, static_lifetime_input_nodes
):
node.meta["saved_for_offloading"] = True
node.meta["original_device"] = node.meta["val"].device
should_perform_offloading = True
return should_perform_offloading
def offload_chosen_sets(
fwd_module: fx.GraphModule,
bwd_module: fx.GraphModule,
) -> None:
"""
Add offload and reload nodes to the forward and backward graphs.
This function adds device_put operations without any stream handling.
Args:
fwd_module: Forward module graph
bwd_module: Backward module graph
"""
# Add offload nodes in forward graph
offload_activation_fw(fwd_module.graph)
# Update backward graph inputs to be offloaded tensors
bwd_inputs: dict[str, fx.Node] = {
node.name: node for node in bwd_module.graph.find_nodes(op="placeholder")
}
for fwd_node in fwd_module.graph.find_nodes(op="output")[0].args[0]:
if CPU_OFFLOAD_PREFIX not in fwd_node.name:
continue
bwd_node: fx.Node = bwd_inputs[fwd_node.name.replace(CPU_OFFLOAD_PREFIX, "")]
with bwd_module.graph.inserting_after(bwd_node):
bwd_offload_node: fx.Node = bwd_module.graph.placeholder(name=fwd_node.name)
bwd_offload_node.meta.update(fwd_node.meta)
bwd_offload_node.meta["saved_for_offloading"] = True
bwd_offload_node.meta["original_device"] = bwd_node.meta["val"].device
bwd_node.replace_all_uses_with(bwd_offload_node)
bwd_module.graph.erase_node(bwd_node)
# Add reload nodes in backward graph
reload_activation_bw(bwd_module.graph)
def offload_chosen_sets_async(
fwd_module: fx.GraphModule,
bwd_module: fx.GraphModule,
) -> None:
"""
Add async offload and reload nodes using ao ops.
Uses ao.offload/ao.reload + ao.wait_tensor which encapsulate stream management,
instead of device_put + explicit stream operations. Can be applied to
partitioned forward/backward graphs or to a joint graph produced by make_fx.
"""
offload_activation_fw_async(fwd_module.graph)
# Replace backward graph placeholders with their offloaded (CPU) counterparts.
# For each offloaded forward output, find the matching backward input and swap
# it with a new placeholder carrying the CPU tensor's metadata, then mark it
# for reloading.
bwd_inputs: dict[str, fx.Node] = {
node.name: node for node in bwd_module.graph.find_nodes(op="placeholder")
}
for fwd_node in fwd_module.graph.find_nodes(op="output")[0].args[0]:
if CPU_OFFLOAD_PREFIX not in fwd_node.name:
continue
bwd_node: fx.Node = bwd_inputs[fwd_node.name.replace(CPU_OFFLOAD_PREFIX, "")]
with bwd_module.graph.inserting_after(bwd_node):
bwd_offload_node: fx.Node = bwd_module.graph.placeholder(name=fwd_node.name)
bwd_offload_node.meta.update(fwd_node.meta)
bwd_offload_node.meta["saved_for_offloading"] = True
bwd_offload_node.meta["original_device"] = bwd_node.meta["val"].device
bwd_node.replace_all_uses_with(bwd_offload_node)
bwd_module.graph.erase_node(bwd_node)
reload_activation_bw_async(bwd_module.graph)
def activation_offload_sink_wait_async(fwd_module: fx.GraphModule) -> None:
"""Sink ao.wait_tensor operations for offload completion to the end of the graph.
This allows computation to overlap with offload operations.
NOTE: Sinking waits to the end delays GPU memory release of the source
tensor (kept alive via the wait's keepalive arg) until the end of the
compiled graph. For per-layer compile this is fine (one layer's worth of
memory), but for full-model compile this means offloaded GPU tensors are
not freed until the entire forward pass completes.
"""
graph: fx.Graph = fwd_module.graph
output_node: fx.Node = graph.find_nodes(op="output")[0]
wait_nodes_to_sink: list[fx.Node] = [
node
for node in graph.nodes
if node.op == "call_function"
and node.target == torch.ops.ao.wait_tensor.default
and isinstance(node.args[0], fx.Node)
and node.args[0].op == "call_function"
and node.args[0].target == torch.ops.ao.offload.default
]
# prepend moves the node from its current position (no manual removal needed)
for wait_node in wait_nodes_to_sink:
output_node.prepend(wait_node)
def activation_reload_prefetch_async(bwd_module: fx.GraphModule) -> None:
"""
Prefetch backward reload operations by moving ao.reload nodes earlier
in the graph to overlap data transfer with computation, while keeping
ao.wait_tensor at its original position.
"""
graph: fx.Graph = bwd_module.graph
nodes_list: list[fx.Node] = list(graph.nodes)
# Identify reload + wait pairs
reload_patterns: dict[fx.Node, ReloadNodeInfo] = {}
for node in graph.nodes:
if not (
node.op == "call_function" and node.target == torch.ops.ao.reload.default
):
continue
wait_node = next(
(u for u in node.users if u.target == torch.ops.ao.wait_tensor.default),
None,
)
if wait_node is None:
continue
transfer_size_bytes: int = _calculate_transfer_size(node)
transfer_time_ms: float = _estimate_transfer_time_in_ms(transfer_size_bytes)
reload_patterns[node] = ReloadNodeInfo(
reload_group_nodes=[node],
wait_event_node=wait_node,
transfer_size_bytes=transfer_size_bytes,
transfer_time_ms=transfer_time_ms,
)
reorder_for_prefetch(nodes_list, reload_patterns)
def _calculate_transfer_size(device_put_node: fx.Node) -> int:
"""Calculate the size in bytes of data being transferred."""
# ao.offload(tensor) -> tensor at args[0]
# ao.reload(tensor, device) -> tensor at args[0]
if device_put_node.target in (
torch.ops.ao.offload.default,
torch.ops.ao.reload.default,
):
return _size_of(device_put_node.args[0]) # pyrefly: ignore [bad-argument-type]
raise ValueError(f"Unexpected transfer op: {device_put_node.target}")
def _estimate_transfer_time_in_ms(transfer_size_bytes: int) -> float:
"""Estimate transfer time in milliseconds based on size and bandwidth.
Uses config.activation_offload_cpu_gpu_bw (GB/s) which should be set by
the user to match their hardware.
"""
return (
transfer_size_bytes / (1024**3) * 1_000 / config.activation_offload_cpu_gpu_bw
)
def identify_reload_patterns(
graph: fx.Graph, nodes_list: list[fx.Node], node_to_idx: dict[fx.Node, int]
) -> dict[fx.Node, ReloadNodeInfo]:
"""
Identify backward reload patterns in the graph.
Pattern: fork → wait_stream → device_put → record_event → join → wait_event
This uses position-based matching since these nodes are inserted together in
add_backward_reload_stream_ops() in a specific order. Since stream operations
do not have data dependencies between them, they are unsuitable for subgroup
pattern matching type of checks.
Returns a dict mapping device_put node to ReloadNodeInfo containing:
- reload_group_nodes: fork → wait_stream → device_put → record_event → join
- wait_event_node: the wait_event node
- transfer_size_bytes: size of data being transferred
- transfer_time_ms: estimated transfer time in milliseconds
"""
patterns: dict[fx.Node, ReloadNodeInfo] = {}
# Find all GPU reload device_put nodes whose inputs are placeholder nodes
reload_nodes: list[fx.Node] = [
node
for node in graph.find_nodes(
op="call_function", target=torch.ops.prims.device_put.default
)
if GPU_RELOAD_PREFIX in node.name
and (
node.args
and isinstance(node.args[0], fx.Node)
and node.args[0].op == "placeholder"
)
]
# Extract patterns for each reload device_put node
for reload_node in reload_nodes:
reload_node_idx: int = node_to_idx[reload_node]
fork_node: fx.Node = nodes_list[reload_node_idx - 2]
wait_stream_node: fx.Node = nodes_list[reload_node_idx - 1]
record_event_node: fx.Node = nodes_list[reload_node_idx + 1]
join_node: fx.Node = nodes_list[reload_node_idx + 2]
wait_event_node: fx.Node = nodes_list[reload_node_idx + 3]
# Validate the nodes are what we expect
# Removed in follow-up commit
_validate_pattern_nodes( # noqa: F821 # pyrefly: ignore [unknown-name]
fork_node,
wait_stream_node,
record_event_node,
join_node,
wait_event_node,
)
# Calculate transfer size and time
transfer_size_bytes: int = _calculate_transfer_size(reload_node)
transfer_time_ms: float = _estimate_transfer_time_in_ms(transfer_size_bytes)
patterns[reload_node] = ReloadNodeInfo(
reload_group_nodes=[
fork_node,
wait_stream_node,
reload_node,
record_event_node,
join_node,
],
wait_event_node=wait_event_node,
transfer_size_bytes=transfer_size_bytes,
transfer_time_ms=transfer_time_ms,
)
return patterns
def reorder_for_prefetch(
nodes_list: list[fx.Node],
reload_patterns: dict[fx.Node, ReloadNodeInfo],
) -> None:
"""
Reorder nodes to prefetch reload operations by directly manipulating the graph.
This follows the algorithm as follows:
- Go through nodes in reverse order
- When encountering a reload pattern, add it to a queue with its transfer time
- When encountering a compute node, use its runtime to satisfy overlap requirements
- Place reload patterns when their overlap requirement is satisfied
- When encountering placeholder nodes, flush queue as reloads cannot move before inputs
"""
# Build a set of all nodes in reload groups for quick lookup
reload_group_nodes_set: set[fx.Node] = set()
for pattern in reload_patterns.values():
reload_group_nodes_set.update(pattern.reload_group_nodes)
# Queue to hold reload group nodes waiting to be placed (FIFO)
reload_queue: list[ReloadQueueEntry] = []
# Loop through nodes in reverse
for node in reversed(nodes_list):
if node.op == "output":
continue
elif node.op == "placeholder":
# Flush queue - place all remaining reloads after the last placeholder
while reload_queue:
entry: ReloadQueueEntry = reload_queue.pop(0)
for reload_group_node in reversed(entry.pattern.reload_group_nodes):
node.append(reload_group_node)
break
elif node in reload_patterns:
pattern: ReloadNodeInfo = reload_patterns[node]
reload_queue.append(
ReloadQueueEntry(
pattern=pattern, remaining_time_ms=pattern.transfer_time_ms
)
)
elif node in reload_group_nodes_set:
continue
else:
if not reload_queue:
continue
compute_runtime_ms: float = (
benchmark_node(node) if is_compute_node(node) else 0
)
reload_queue[0].remaining_time_ms -= compute_runtime_ms
# Pop and place reload if its remaining time is satisfied (<= 0)
if reload_queue[0].remaining_time_ms <= 0:
entry: ReloadQueueEntry = reload_queue.pop(0)
for reload_group_node in entry.pattern.reload_group_nodes:
node.prepend(reload_group_node)
def activation_offload_sink_wait(fwd_module: fx.GraphModule) -> None:
"""
Sink wait_event operations for offload completion to the end of the graph.
This function identifies wait_event nodes for offload completion and moves them
to the end of the graph, allowing computation to overlap with offload operations.
Args:
fwd_module: Forward module graph
"""
graph: fx.Graph = fwd_module.graph
nodes_list: list[fx.Node] = list(graph.nodes)
node_to_idx: dict[fx.Node, int] = {node: idx for idx, node in enumerate(nodes_list)}
# Find all CPU offload device_put nodes
offload_nodes: list[fx.Node] = [
node
for node in graph.find_nodes(
op="call_function", target=torch.ops.prims.device_put.default
)
if CPU_OFFLOAD_PREFIX in node.name
]
# Collect all wait_event nodes that need to be moved
wait_nodes_to_sink: list[fx.Node] = []
for offload_node in offload_nodes:
offload_idx: int = node_to_idx[offload_node]
wait_event_node: fx.Node = nodes_list[offload_idx + 3]
# Validate it's actually a wait_event node
if not (
wait_event_node.op == "call_function"
and wait_event_node.target == torch.ops.streams.wait_event.default
):
raise ValueError(
f"Expected wait_event node three positions after {offload_node.name}"
)
wait_nodes_to_sink.append(wait_event_node)
# Find the output node, and move all wait_event nodes to just before the output node
output_node: fx.Node = graph.find_nodes(op="output")[0]
for wait_node in wait_nodes_to_sink:
output_node.prepend(wait_node)
def activation_reload_prefetch(bwd_module: fx.GraphModule) -> None:
"""
Prefetch backward reload operations by moving them earlier in the graph
to overlap communication with computation.
This function identifies backward reload patterns (fork → wait_stream → device_put →
record_event → join) and moves them earlier in the execution order to overlap
the data transfer with computation, while keeping the wait_event at its original
position.
Args:
bwd_module: Backward module graph
"""
graph: fx.Graph = bwd_module.graph
nodes_list: list[fx.Node] = list(graph.nodes)
node_to_idx: dict[fx.Node, int] = {node: idx for idx, node in enumerate(nodes_list)}
# Step 1: Identify reload patterns
reload_patterns: dict[fx.Node, ReloadNodeInfo] = identify_reload_patterns(
graph, nodes_list, node_to_idx
)
# Step 2: Reorder nodes by directly manipulating the graph
reorder_for_prefetch(nodes_list, reload_patterns)
def enable_activation_offloading(
fwd_module: fx.GraphModule,
bwd_module: fx.GraphModule,
num_fwd_outputs: int,
static_lifetime_input_nodes: OrderedSet[fx.Node],
) -> None:
"""
Main entry point for activation offloading.
Args:
fwd_module: Forward module graph
bwd_module: Backward module graph
num_fwd_outputs: Number of forward outputs
"""
# Step 1: Decide which nodes to offload and mark them
should_perform_offloading: bool = choose_offload_sets(
fwd_module,
num_fwd_outputs,
static_lifetime_input_nodes,
)
if not should_perform_offloading:
return
# Step 2: Add offload and reload nodes to the graphs
if config.activation_offload_separate_stream:
# Use async ao ops (2 nodes each: offload/reload + wait_tensor)
offload_chosen_sets_async(fwd_module, bwd_module)
if config.activation_offload_sink_wait:
activation_offload_sink_wait_async(fwd_module)
if config.activation_reload_prefetch:
activation_reload_prefetch_async(bwd_module)
else:
# Use synchronous device_put (1 node each)
offload_chosen_sets(fwd_module, bwd_module)
fwd_module.graph.lint()
bwd_module.graph.lint()
@@ -0,0 +1,180 @@
"""Custom ops for async activation offloading between GPU and CPU.
These ops encapsulate stream management internally, producing a clean 2-node
IR pattern (offload/reload + wait_tensor) similar to c10d functional collectives.
A single dedicated transfer stream handles all D2H/H2D copies.
Completion events are keyed by output tensor data_ptr() and stored in a
module-level registry, so ``ao.wait_tensor`` takes only the tensor itself
(plus an optional keepalive).
Offload pattern:
cpu_tensor = ao.offload(gpu_tensor)
cpu_tensor = ao.wait_tensor(cpu_tensor, gpu_tensor)
(keepalive arg extends gpu_tensor lifetime past the async D2H copy)
Reload pattern:
gpu_tensor = ao.reload(cpu_tensor, device)
gpu_tensor = ao.wait_tensor(gpu_tensor)
"""
import torch
from torch._library.custom_ops import custom_op
from torch.fx import has_side_effect
# --- Global transfer stream (one per device, lazily created) ---
_transfer_streams: dict[torch.device, torch.Stream] = {}
def _get_or_create_transfer_stream(device: torch.device) -> torch.Stream:
if device not in _transfer_streams:
_transfer_streams[device] = torch.Stream(device=device)
return _transfer_streams[device]
# --- Wait registry: maps data_ptr() -> (completion_event, device) ---
# Created by ao.offload / ao.reload, consumed (popped) by ao.wait_tensor.
# Not thread-safe — graph execution is single-threaded Python.
_wait_registry: dict[int, tuple[torch.Event, torch.device]] = {}
def _register_wait(tensor: torch.Tensor, device: torch.device) -> torch.Event:
"""Create an event for an async transfer and register it for wait_tensor."""
event = torch.Event()
_wait_registry[tensor.data_ptr()] = (event, device)
return event
def _pop_wait(tensor: torch.Tensor) -> tuple[torch.Event, torch.device]:
key = tensor.data_ptr()
try:
return _wait_registry.pop(key)
except KeyError:
raise RuntimeError(
f"ao.wait_tensor: no pending transfer for tensor with data_ptr={key}. "
"Every ao.wait_tensor must be paired with a preceding ao.offload or ao.reload."
) from None
def _clear_wait_registry() -> None:
_wait_registry.clear()
@custom_op("ao::offload", mutates_args=())
def offload(tensor: torch.Tensor) -> torch.Tensor:
"""Async offload a GPU tensor to CPU on the dedicated transfer stream.
Callers MUST pair this with an ``ao.wait_tensor`` that passes the source GPU
tensor as ``keepalive`` to extend its lifetime past the async D2H copy.
Do NOT use ``record_stream`` — it causes memory fragmentation and
unbounded memory growth.
Uses pinned-memory allocation + copy_ so the transfer is compatible
with CUDA graph capture.
"""
device = tensor.device
transfer_stream = _get_or_create_transfer_stream(device)
current_stream = torch.accelerator.current_stream(device)
transfer_stream.wait_stream(current_stream)
torch.accelerator.set_stream(transfer_stream)
result = torch.empty_like(tensor, device="cpu", pin_memory=True)
completion_event = _register_wait(result, device)
result.copy_(tensor, non_blocking=True)
transfer_stream.record_event(completion_event)
torch.accelerator.set_stream(current_stream)
return result
@offload.register_fake
def _(tensor: torch.Tensor) -> torch.Tensor:
return torch.empty_like(tensor, device="cpu")
@custom_op("ao::reload", mutates_args=())
def reload(
tensor: torch.Tensor,
device: torch.device,
) -> torch.Tensor:
"""Async reload a CPU tensor to GPU on the dedicated transfer stream.
The GPU tensor is allocated on the compute stream to avoid cross-stream
allocator ownership issues. The H2D copy runs on the transfer stream.
The completion event is keyed by the output tensor's data_ptr.
"""
transfer_stream = _get_or_create_transfer_stream(device)
current_stream = torch.accelerator.current_stream(device)
# Allocate on compute stream so the allocator tracks ownership correctly
result = torch.empty_like(tensor, device=device)
completion_event = _register_wait(result, device)
transfer_stream.wait_stream(current_stream)
torch.accelerator.set_stream(transfer_stream)
result.copy_(tensor, non_blocking=True)
transfer_stream.record_event(completion_event)
torch.accelerator.set_stream(current_stream)
return result
@reload.register_fake
def _(
tensor: torch.Tensor,
device: torch.device,
) -> torch.Tensor:
return torch.empty_like(tensor, device=device)
# ao::wait_tensor is defined via torch.library with an aliasing schema so the
# output can alias the input (custom_op forbids this).
#
# Uses CompositeExplicitAutograd (single impl for all devices) because the
# offload case has mixed-device args: ``tensor`` is CPU (the offload result)
# while ``keepalive`` is CUDA (the source GPU tensor). A single impl avoids
# relying on device-priority dispatch ordering.
#
# Synchronization details (completion event, device) are looked up from
# ``_wait_registry`` keyed on ``tensor.data_ptr()``.
#
# ``keepalive`` is not read by the op — its sole purpose is to create a graph
# dependency that extends the tensor's lifetime in the FX graph. For offload,
# this keeps the source GPU tensor alive until the compute stream has waited
# on the D2H completion event, preventing the allocator from reclaiming it
# while the async copy is still in flight.
_lib = torch.library.Library("ao", "DEF")
_lib.define("wait_tensor(Tensor(a) tensor, Tensor? keepalive=None) -> Tensor(a)")
@torch.library.impl("ao::wait_tensor", "CompositeExplicitAutograd")
def _ao_wait_tensor(
tensor: torch.Tensor,
keepalive: torch.Tensor | None = None,
) -> torch.Tensor:
completion_event, device = _pop_wait(tensor)
current_stream = torch.accelerator.current_stream(device)
current_stream.wait_event(completion_event)
return tensor
@torch.library.register_fake("ao::wait_tensor")
def _ao_wait_tensor_fake(
tensor: torch.Tensor,
keepalive: torch.Tensor | None = None,
) -> torch.Tensor:
return tensor
has_side_effect(torch.ops.ao.wait_tensor.default)
def wait_tensor(
tensor: torch.Tensor,
keepalive: torch.Tensor | None = None,
) -> torch.Tensor:
"""Callable wrapper so ``wait_tensor`` can be imported by name for op registration."""
return torch.ops.ao.wait_tensor.default(tensor, keepalive)
@@ -0,0 +1,5 @@
# Copyright (c) Facebook, Inc. and its 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.
@@ -0,0 +1,737 @@
"""
This module provides result classes for AOT Autograd compilation.
Similar to how torch._inductor.output_code provides OutputCode classes for inductor
compilation results, this module provides AOTAutogradResult classes that represent
the compiled artifacts produced by AOT Autograd.
These results are:
- Serializable: can be saved/loaded from disk without recompilation
- Addressable: can be stored in caches with keys for later retrieval
- Reusable: can be used for both caching and ahead-of-time compilation (precompile)
The main result types are:
- GenericAOTAutogradResult: Abstract base for all AOT Autograd results
- AOTAutogradResult: Regular result that references FxGraphCache entries
- BundledAOTAutogradResult: Result that bundles the entire compiled code directly
"""
from __future__ import annotations
import json
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from copy import copy
from dataclasses import dataclass
from typing import Any, Generic, TYPE_CHECKING, TypeVar
import torch
from torch._dynamo.precompile_context import BackendCacheArtifact
from torch._inductor.codecache import FxGraphCache
from torch._inductor.output_code import (
CompiledFxGraph,
CompiledFxGraphConstants,
OutputCode,
)
from torch._inductor.utils import should_use_remote_fx_graph_cache
from torch._logging import getArtifactLogger
from .runtime_wrappers import (
AOTDispatchAutograd,
AOTDispatchAutogradCompileSpec,
AOTDispatchSubclassWrapper,
CachedAutogradLazyBackwardCompileInfo,
CompilerWrapper,
FunctionalizedRngRuntimeWrapper,
post_compile,
RuntimeWrapper,
SerializableCompiledFunction,
SubclassMeta,
)
from .schemas import AOTAutogradCacheInfo # noqa: F401
from .utils import simple_wraps
if TYPE_CHECKING:
from torch._inductor.compile_fx import _CompileFxKwargs
from .schemas import AOTConfig, ViewAndMutationMeta
log = logging.getLogger(__name__)
aot_graphs_log = getArtifactLogger(__name__, "aot_graphs")
TOut = TypeVar("TOut", bound=OutputCode)
class InductorOutput(ABC, Generic[TOut]):
"""
Class representing a single inductor output
"""
@abstractmethod
def pre_save(self) -> None: ...
@abstractmethod
def load(self, example_inputs: Sequence[Any]) -> TOut: ...
@abstractmethod
def post_compile(self, result: TOut, fx_config: _CompileFxKwargs) -> TOut: ...
TOutputCode = TypeVar("TOutputCode", bound=OutputCode)
@dataclass
class BundledOutputCodeLoadable(InductorOutput[TOutputCode], Generic[TOutputCode]):
"""
A generic wrapper for OutputCode objects that are bundled directly in the cache
(rather than looked up via FxGraphCache).
This works for any OutputCode subclass (CompiledFxGraph, RegionalOutputCode, etc.)
"""
result: TOutputCode
def pre_save(self) -> None:
disk_result = copy(self.result)
disk_result.prepare_for_serialization()
self.result = disk_result
return
def load(self, example_inputs: Sequence[Any]) -> TOutputCode:
self.example_inputs = example_inputs
return self.result
def post_compile(
self, result: TOutputCode, fx_config: _CompileFxKwargs
) -> TOutputCode:
constants = CompiledFxGraphConstants()
# Special handling for CompiledFxGraph - needs FxGraphCache.cache_hit_post_compile
if isinstance(result, CompiledFxGraph):
graph, cache_info = FxGraphCache.cache_hit_post_compile(
result, {}, constants
)
if graph is None:
raise RuntimeError("Failed to reload cache entry from disk")
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "fx_graph_bundled_cache_hit", # always a hit
"encoding": "json",
},
payload_fn=lambda: json.dumps(cache_info),
)
result = graph # type: ignore[assignment]
result.compile_region_name = ( # pyrefly: ignore[missing-attribute]
fx_config.get("compile_region_name")
)
# Run normal post compile
result.post_compile(self.example_inputs, constants, fx_config)
# Let the CUDAGraph policy do outer-level wrapping (e.g. wrapping
# an entire RegionalOutputCode as a single CUDA graph instead of
# per-inner-region).
import torch._inductor.config as _inductor_config
policy = _inductor_config.cudagraph_policy
if policy is not None:
result = policy.wrap_output(result)
return result
# Backwards compatibility alias
CompiledFxGraphLoadable: type[BundledOutputCodeLoadable[CompiledFxGraph]] = (
BundledOutputCodeLoadable[CompiledFxGraph]
)
@dataclass
class FxGraphCacheLoadable(InductorOutput[CompiledFxGraph]):
fx_graph_cache_info: tuple[str, list[str]]
fx_graph_guard_expr: str | None
def pre_save(self) -> None:
return
def _is_backward(self) -> bool:
return False
def load(self, example_inputs: Sequence[Any]) -> CompiledFxGraph:
from .autograd_cache import FXGraphCacheMiss
# [Note: AOTAutogradCache and FXGraphCache Guard interactions]
# As mentioned, AOTAutograd takes in the symint inputs from dynamo's list of arguments.
# FXGraphCache serializes guards that are needed in the shape_env based on these symint inputs to the graph.
# The invariant that AOTAutograd uses here is that the sources for symints given to it by dynamo are exactly
# the same as the ones it passes to inductor, for both the forward and backward passes.
# (This does not mean that the tensor values passed in are the same: only that their symints are).
# That is, AOTAutograd and Inductor never create new guards based on symints with different sources
# than those passed to it by inductor.
# We pass the post compile function, which sets various fx_config boxed values,
# so we can call it only after we're sure both forward and backward have
# Clear CompiledTritonKernels before loading from FXGraphCache
torch._inductor.async_compile.CompiledTritonKernels.cache_clear()
remote_cache = None
constants = CompiledFxGraphConstants()
if should_use_remote_fx_graph_cache():
remote_cache = FxGraphCache.get_remote_cache()
(cache_key, debug_lines) = self.fx_graph_cache_info
def check_exact_guard_match(guard_expr: str, _hints: Any) -> bool:
"""
AOTAutogradCache tracks its own guards, so we just need to treat these guard expressions as a second
cache key of sorts: we just check for equality, i.e. the FXGraphCache entry with
the exact same guards as we originally saved into the cache.
"""
return guard_expr == self.fx_graph_guard_expr
result, cache_info = FxGraphCache.load_with_key(
cache_key,
debug_lines,
example_inputs,
local=True,
remote_cache=remote_cache,
is_backward=self._is_backward(),
constants=constants,
evaluate_guards=check_exact_guard_match,
)
if result is None:
log.info("FXGraphCache cache miss for key %s", self.fx_graph_cache_info)
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "fx_graph_cache_miss", # always a hit
"encoding": "json",
},
payload_fn=lambda: json.dumps(cache_info),
)
raise FXGraphCacheMiss
# No need to log chromium event because AOTAutograd will log that immediately for us
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "fx_graph_cache_hit", # always a hit
"encoding": "json",
},
payload_fn=lambda: json.dumps(cache_info),
)
self.example_inputs = example_inputs
self.constants = constants
return result
def post_compile(
self, result: CompiledFxGraph, fx_config: _CompileFxKwargs
) -> CompiledFxGraph:
"""
Called after FXGraphCacheLoadable.load, mutates fx_config
"""
result.compile_region_name = fx_config.get( # pyrefly: ignore[bad-assignment]
"compile_region_name"
)
result.post_compile(self.example_inputs, self.constants, fx_config)
import torch._inductor.config as _inductor_config
policy = _inductor_config.cudagraph_policy
if policy is not None:
result = policy.wrap_output(result)
return result
@dataclass
class CompiledForward(FxGraphCacheLoadable):
"""
Cacheable entry for a forward function
"""
def _is_backward(self) -> bool:
return False
@dataclass
class GenericCompiledBackward(InductorOutput[TOut]):
# Used by AOTDispatchAutograd.post_compile
backward_state_indices: list[int]
num_symints_saved_for_bw_: int
def post_compile(self, result: TOut, fx_config: _CompileFxKwargs) -> TOut:
# The concrete post_compile comes from the loadable mixin in each subclass MRO.
compiled_bw = super().post_compile( # pyrefly: ignore[missing-attribute]
result, fx_config
)
# See note [Wrapping bw_compiler in disable]
# This is done by _wrapped_bw_compiler in torch/_dynamo/backends/common.py
# But since on cache hit we do not call the bw_compiler, we need to reapply the disable
return torch._dynamo.disable( # type: ignore[return-value]
compiled_bw, reason="do not trace generated backwards pass"
)
@dataclass
class CompiledBackward(GenericCompiledBackward[CompiledFxGraph], FxGraphCacheLoadable):
"""
Cacheable entry for a backward function
"""
def _is_backward(self) -> bool:
return True
# Generic bundled forward/backward classes that work with any OutputCode type
@dataclass
class BundledCompiledForward(
BundledOutputCodeLoadable[TOutputCode], Generic[TOutputCode]
):
"""
Generic forward function for bundled compilation.
Works with any OutputCode type (CompiledFxGraph, RegionalOutputCode, etc.)
"""
@dataclass
class BundledCompiledBackward(
GenericCompiledBackward[TOutputCode],
BundledOutputCodeLoadable[TOutputCode],
Generic[TOutputCode],
):
"""
Generic backward function for bundled compilation.
Works with any OutputCode type (CompiledFxGraph, RegionalOutputCode, etc.)
"""
@dataclass
class SerializedGraphModule:
fn: Callable[[dict[Any, Any], str], torch.nn.Module]
args: tuple[Any, ...]
def __init__(self, gm: torch.fx.GraphModule) -> None:
self.fn, self.args = gm.__reduce__()
def deserialize(self) -> torch.fx.GraphModule:
gm = self.fn(*self.args)
if not isinstance(gm, torch.fx.GraphModule):
raise AssertionError(f"expected fx.GraphModule, got {type(gm)}")
return gm
def serialize_graph_module(gm: torch.fx.GraphModule) -> SerializedGraphModule:
# NOTE: mutates the graph module
gm.meta = {}
for node in gm.graph.nodes:
# pyrefly: ignore [implicit-any]
node.meta = {}
return SerializedGraphModule(gm)
TForward = TypeVar("TForward", bound="InductorOutput[Any]")
TBackward = TypeVar("TBackward", bound="GenericCompiledBackward[Any]")
@dataclass
class GenericAOTAutogradResult(Generic[TForward, TBackward]):
"""A single result from AOT Autograd compilation, genericized by Forward and Backward types.
A TForward is always an InductorOutput of some sort, which represents the
forward graph of the compile.
A TBackward is an InductorOutput + metadata about the backward, useful for specific
backward-only wrappers. This type is encapsulated by GenericCompiledBackward.
Each AOTAutogradResult is essentially parameterized by 1. the method of loading
from the cache (either Bundled or UnBundled), and 2. The type of the output. For now,
the only type of output we support is Python Wrapper output, i.e. OutputCode.CompiledFxGraph,
but the same technique works for C++ wrapper code; we'd just add an extra InductorOutput type.
"""
# Forward and Backward info
compiled_fw: TForward
compiled_bw: TBackward | None
# Code of the joint graph using print_readable()
# Used for logging purposes
aot_joint_graph_str: str | None
aot_forward_graph_str: str | None
aot_backward_graph_str: str | None
# Runtime_metadata saved right before compilation
runtime_metadata: ViewAndMutationMeta
# Wrappers that run after each aot_dispatch_* function
dispatch_wrappers: list[CompilerWrapper]
# Used by AOTSubclassWrapper
maybe_subclass_meta: SubclassMeta | None
num_fw_outs_saved_for_bw: int | None
# Used by RuntimeWrapper
indices_of_inps_to_detach: list[int]
# Time taken to trace/compile the forward
# forward_time_taken includes AOTAutograd tracing time + inductor compilation time
# backward_time_taken is essentially just the time inductor took to compile
forward_time_taken_ns: int
backward_time_taken_ns: int
# Used by standalone_compile
sanitized_aot_config: AOTConfig
guards_expr: str | None
# Used by Compiled Autograd
serialized_bw_module: SerializedGraphModule | None
def pre_save(self) -> None:
"""
Perform any preparations to make the result ready for serialization.
"""
self.compiled_fw.pre_save()
if self.compiled_bw is not None:
self.compiled_bw.pre_save()
def _log_cached_graphs(self, aot_config: AOTConfig) -> None:
if not aot_config.enable_log:
return
if self.aot_joint_graph_str is not None:
torch._logging.trace_structured(
"aot_joint_graph", payload_fn=lambda: self.aot_joint_graph_str
)
aot_graphs_log.info(
"Joint graph (from cache)\n\n%s", self.aot_joint_graph_str
)
if self.aot_forward_graph_str is not None:
from torchgen.utils import dataclass_repr
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "aot_forward_graph_fw_metadata",
"encoding": "string",
},
payload_fn=lambda: dataclass_repr(self.runtime_metadata),
)
if self.maybe_subclass_meta is not None:
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "aot_forward_graph_fw_subclass_metadata",
"encoding": "string",
},
payload_fn=lambda: dataclass_repr(self.maybe_subclass_meta),
)
# It's called an inference graph if not running with autograd
has_backward = self.aot_backward_graph_str is not None
torch._logging.trace_structured(
"aot_forward_graph" if has_backward else "aot_inference_graph",
payload_fn=lambda: self.aot_forward_graph_str,
)
aot_graphs_log.info(
"Forward graph (from cache)\n\n%s",
self.aot_forward_graph_str,
)
if self.aot_backward_graph_str is not None:
torch._logging.trace_structured(
"aot_backward_graph", payload_fn=lambda: self.aot_backward_graph_str
)
aot_graphs_log.info(
"Backward graph (from cache)\n\n%s",
self.aot_backward_graph_str,
)
def _load_and_post_compile(
self,
args: list[torch.Tensor],
fx_config: _CompileFxKwargs,
) -> tuple[Callable[..., Any], Callable[..., Any] | None, bool]:
from torch._dynamo.utils import CompileEventLogger
compiled_fw_func = self.compiled_fw.load(args)
if self.compiled_bw is not None:
compiled_bw_func = self.compiled_bw.load(args)
needs_autograd = True
CompileEventLogger.try_add_pt2_compile(
"backend_compile", dispatch_mode="autograd"
)
# Now that we've loaded forward and backward, call post compile on both
# This avoids setting things like BoxedBools in fx_config until
# after both forward and backward cache hit
fw_fx_config: _CompileFxKwargs = {
**fx_config,
"is_backward": False,
}
bw_fx_config: _CompileFxKwargs = {
**fx_config,
"is_backward": True,
}
compiled_fw_func = self.compiled_fw.post_compile(
compiled_fw_func, fw_fx_config
)
compiled_bw_func = self.compiled_bw.post_compile(
compiled_bw_func, bw_fx_config
)
return compiled_fw_func, compiled_bw_func, needs_autograd
inference_fx_config: _CompileFxKwargs = {
**fx_config,
"is_backward": False,
}
needs_autograd = False
CompileEventLogger.try_add_pt2_compile(
"backend_compile", dispatch_mode="inference"
)
compiled_fw_func = self.compiled_fw.post_compile(
compiled_fw_func, inference_fx_config
)
return compiled_fw_func, None, needs_autograd
def _apply_runtime_wrappers(
self,
compiled_fw_func: Callable[..., Any],
compiled_bw_func: Callable[..., Any] | None,
needs_autograd: bool,
aot_config: AOTConfig,
) -> Callable[..., Any]:
from torch._dynamo.utils import CompileEventLogger
compiled_fw_func = AOTDispatchSubclassWrapper(
trace_joint=needs_autograd,
fw_only=None,
maybe_subclass_meta=self.maybe_subclass_meta,
num_fw_outs_saved_for_bw=self.num_fw_outs_saved_for_bw,
).post_compile(
compiled_fw_func, aot_config, runtime_metadata=self.runtime_metadata
)
req_subclass_dispatch = self.maybe_subclass_meta is not None
CompileEventLogger.try_add_pt2_compile(
"backend_compile", requires_subclass_dispatch=req_subclass_dispatch
)
# In autograd case, functionalizedRngWrapper should not modify outs
return_new_outs = not needs_autograd
compiled_fw_func = FunctionalizedRngRuntimeWrapper(
return_new_outs=return_new_outs
).post_compile(
compiled_fw_func, aot_config, runtime_metadata=self.runtime_metadata
)
# pyrefly: ignore [missing-attribute]
compiled_fw_func._boxed_call = True
disable_amp = torch._C._is_any_autocast_enabled()
if needs_autograd:
if self.compiled_bw is None:
raise AssertionError("compiled_bw must not be None when needs_autograd")
cached_lazy_backward = None
if self.serialized_bw_module is not None:
cached_lazy_backward = CachedAutogradLazyBackwardCompileInfo(
self.serialized_bw_module.deserialize
)
# This function is run on both cache miss and cache hit, either here
# or in aot_dispatch_autograd. On a cache hit,
# 1. the bw is already compiled
# 2. we don't need to save to the cache again
# so those corresponding arguments are set to None.
compile_spec = AOTDispatchAutogradCompileSpec(
compiled_fw_func=compiled_fw_func,
compiled_bw_func=compiled_bw_func,
maybe_subclass_meta=self.maybe_subclass_meta,
num_symints_saved_for_bw=self.compiled_bw.num_symints_saved_for_bw_,
backward_state_indices=self.compiled_bw.backward_state_indices,
disable_amp=disable_amp,
indices_of_inps_to_detach=self.indices_of_inps_to_detach,
lazy_backward_info=cached_lazy_backward,
aot_config=aot_config,
fw_metadata=self.runtime_metadata,
try_save_cache_entry=None,
)
compiled_function = AOTDispatchAutograd.post_compile(compile_spec)
else:
compiled_function = RuntimeWrapper(
indices_of_inps_to_detach=self.indices_of_inps_to_detach,
trace_joint=False,
disable_amp=disable_amp,
).post_compile(
compiled_fw_func, aot_config, runtime_metadata=self.runtime_metadata
)
# Add serialization function back onto object
compiled_function, _ = post_compile(
self.dispatch_wrappers,
compiled_function,
aot_config,
runtime_metadata=self.runtime_metadata,
)
return compiled_function
def _check_guards(self, args: list[torch.Tensor]) -> None:
if self.guards_expr:
from .autograd_cache import AOTAutogradCache
symints = AOTAutogradCache._filter_backed_symints(args)
check = bool(AOTAutogradCache.evaluate_guards(self.guards_expr, symints))
if check is not True:
raise AssertionError(f"guards check failed: {check}")
# Turn result into the original callable
def wrap_post_compile(
self,
args: list[torch.Tensor],
aot_config: AOTConfig,
fx_config: _CompileFxKwargs,
) -> Callable[..., Any]:
"""
This function takes a result and carefully reconstructs the original callable
that AOTAutograd returned the first time it was run. It does this by running the various
post compile steps that AOTAutograd runs on its compiled artifact after running the fw/bw compilers.
In the inference path, this consists of the Subclass, FunctionalzedRngRuntime, and RuntimeWrappers.
In the autograd path, this consists of AOTAutogradDispatch.post_compile.
The steps here should match exactly the steps that are run in aot_dispatch_base and aot_dispatch_autograd.
Notably absent from the cached path are:
- DebugAssertWrapper
- FakifiedOutWrapper
Which we'll handle separately later on, if necessary.
"""
from torch._dynamo.utils import dynamo_timed
self._log_cached_graphs(aot_config)
with dynamo_timed("AOTAutogradCache.inductor_load"):
compiled_fw_func, compiled_bw_func, needs_autograd = (
self._load_and_post_compile(args, fx_config)
)
compiled_function = self._apply_runtime_wrappers(
compiled_fw_func, compiled_bw_func, needs_autograd, aot_config
)
# Now that we're pretty sure it's a successful load, add guards
# to the existing shape environment from the cache.
self._check_guards(args)
return compiled_function
class AOTAutogradResult(GenericAOTAutogradResult[CompiledForward, CompiledBackward]):
"""
Regular AOTAutogradResult: saves the forward/backward FxGraphCache keys
and looks them up in FxGraphCache on load
"""
class BundledAOTAutogradResult(
GenericAOTAutogradResult[
BundledCompiledForward[TOutputCode], BundledCompiledBackward[TOutputCode]
],
Generic[TOutputCode],
):
"""
Generic AOTAutogradResult where we bundle the entire OutputCode directly
(rather than looking it up via FxGraphCache).
This works with any OutputCode type:
- CompiledFxGraph: Traditional inductor compilation
- RegionalOutputCode: Regional inductor compilation with GraphPickler serialization
- Any future OutputCode subclasses
Type parameter:
TOutputCode: The OutputCode subclass (e.g., CompiledFxGraph, RegionalOutputCode)
Usage with CompiledFxGraph:
entry = BundledAOTAutogradResult[CompiledFxGraph](
compiled_fw=BundledCompiledForward(result=CompiledFxGraph(...)),
compiled_bw=BundledCompiledBackward(
result=CompiledFxGraph(...),
backward_state_indices=[...],
num_symints_saved_for_bw_=...,
),
...
)
Usage with RegionalOutputCode:
entry = BundledAOTAutogradResult[RegionalOutputCode](
compiled_fw=BundledCompiledForward(result=RegionalOutputCode(gm)),
compiled_bw=BundledCompiledBackward(
result=RegionalOutputCode(gm),
backward_state_indices=[...],
num_symints_saved_for_bw_=...,
),
...
)
"""
def deserialize_bundled_cache_entry(
entry: BundledAOTAutogradResult[Any],
) -> Callable[..., Any]:
from copy import deepcopy
from torch._inductor.cudagraph_utils import BoxedDeviceIndex
from torch._inductor.utils import BoxedBool
# In the precompile use case, guards are already serialized
# by dynamo, so we don't need to add them to the environment
entry.guards_expr = None
# TODO: this isn't exactly right, because cudagraphs needs to be a shared config
# which is set by compile_fx. But in precompile, we never actually call compile_fx
# so we don't have a place to track cudagraphs here.
cudagraphs = BoxedBool(torch._inductor.config.triton.cudagraphs)
boxed_forward_device_index = BoxedDeviceIndex(None)
# We need to make a clean copy of the cache entry
# in case it needs to be serialized again
serializable_copy = deepcopy(entry)
from torch._subclasses import FakeTensorMode
from torch.fx.experimental.symbolic_shapes import ShapeEnv
context = torch._guards.TracingContext.try_get()
if context is None:
# Create a clean environment when running fx graph post compile
# if one is not available
context = torch._guards.TracingContext(FakeTensorMode(shape_env=ShapeEnv()))
with torch._guards.tracing(context):
compiled_fn = entry.wrap_post_compile(
[],
entry.sanitized_aot_config,
{
"cudagraphs": cudagraphs,
"boxed_forward_device_index": boxed_forward_device_index,
},
)
# Ensure the deserialized cache entry is still serializable
compiled_fn = SerializableCompiledFunction(compiled_fn, lambda: serializable_copy)
# TODO: this ignores flat_params, which can exist
# if inline_builtin_nn_modules=False
@simple_wraps(compiled_fn)
def forward(*runtime_args: Any) -> Any:
return compiled_fn(list(runtime_args))
if not hasattr(compiled_fn, "serialize"):
raise AssertionError("compiled_fn must have serialize attribute")
forward.serialize = compiled_fn.serialize # type: ignore[attr-defined]
return forward
@dataclass
# pyrefly: ignore [implicit-any]
class BundledAOTAutogradCacheArtifact(BackendCacheArtifact[Callable]):
# pyrefly: ignore [implicit-any]
def after_deserialization(self) -> Callable:
return deserialize_bundled_cache_entry(self.content)
@@ -0,0 +1,883 @@
from __future__ import annotations
"""
This module is one of the analysis modules - it takes as input a function or graph
and some preexisting properties, and returns some data that is useful for deciding
how to further proceed with compilation or construct runtime wrappers.
In particular, the analysis here constructs view and mutation metadata from running
a functionalized version of the graph under compilation.
"""
import collections
import contextlib
import logging
from typing import Any, TYPE_CHECKING
import torch
import torch.utils._pytree as pytree
from torch import Tensor
from torch._guards import detect_fake_mode
from torch._library.opaque_object import is_opaque_type
from torch._logging import getArtifactLogger
from torch._subclasses.functional_tensor import FunctionalTensor, FunctionalTensorMode
from torch._subclasses.meta_utils import safe_is_leaf
from torch.fx.experimental.proxy_tensor import disable_autocast_cache
from torch.fx.experimental.symbolic_shapes import is_concrete_int
from torch.multiprocessing.reductions import StorageWeakRef
from torch.utils._python_dispatch import (
is_traceable_wrapper_subclass,
transform_subclass,
)
from .descriptors import (
AOTInput,
AOTOutput,
InputMutationAOTOutput,
IntermediateBaseAOTOutput,
PlainAOTOutput,
TangentAOTInput,
)
from .functional_utils import (
are_all_mutations_hidden_from_autograd,
are_all_mutations_under_no_grad_or_inference_mode,
from_fun,
has_data_mutation,
has_metadata_mutation,
MetadataKey,
to_fun,
ViewMetaSequence,
was_inductor_storage_resized,
)
from .schemas import (
InputAliasInfo,
MemoryFormatMeta,
MutationType,
OutputAliasInfo,
OutputType,
ViewAndMutationMeta,
)
from .subclass_utils import create_subclass_meta
from .utils import _get_autocast_states, KNOWN_TYPES, simple_wraps, strict_zip
if TYPE_CHECKING:
from collections.abc import Callable
zip = strict_zip
log = logging.getLogger(__name__)
static_input_logger = getArtifactLogger("torch._dynamo", "cudagraph_static_inputs")
# Note [Tangents memory format]
# We assume tangents memory format to be similar to corresponding output's memory_format.
# The idea is that we are technically making a guess about the strides of our tangents,
# while we trace out the joint.
# If runtime specified tangents will not have the same memory format as predicted traced tangents,
# we coerce them at runtime to traced tangents memory format.
# Coercing and collecting traced tangents memory format in one recursive traversal
def coerce_tangent_and_suggest_memory_format(
x: Tensor,
) -> tuple[Any, MemoryFormatMeta | list[Any] | None, bool]:
updated = False
if not isinstance(x, Tensor):
return x, None, updated
out = x.detach()
is_subclass = is_traceable_wrapper_subclass(out)
memory_format = MemoryFormatMeta.from_tensor(out)
# pyrefly: ignore [missing-attribute]
if memory_format.memory_format is not None:
was = out
# pyrefly: ignore [bad-argument-type]
out = out.contiguous(memory_format=memory_format.memory_format)
updated = was is not out
# For subclass we keep memory format of outer strides at the beginning of the list
out_memory_format = [memory_format] if is_subclass else memory_format
# Note [Tangents memory format, Part 2]
# In the same way that "what strides do we assigns to our tangents" is a question
# that we can not answer (and therefore have to guess) as we trace the backward ahead-of-time,
# The same applies to any tensor subclass metadata, when we have tangents that are subclasses.
# To handle this situation, we have two new methods that a tensor subclass can implement:
# (1) __coerce_tangent_metadata__(self)
# Given a subclass with "non-standard" metadata, turn it into a new subclass with "normal" metadata.
# The main example here is a DTensor with the "_Partial" placement.
# If we have a forward output with a _Partial placement, and corresponding tangent
# with a Replicate/Shard placement, we have no way to convert the tangent "back" to a _Partial placement.
# This method lets us avoid the problem entirely by allowing subclasses to ensure that we can never
# have a tangent with "problematic" metadata, that we cannot convert to.
# (1) __coerce_same_metadata_as_tangent__(self, metadata)
# Given a subclass, and a target differing metadata,
# convert self to have the same metadata as the target.
# With DTensor being the main example, we can use this to convert a DTensor with a Replicate()
# placement into one with a Shard() placement, in the case that we "guessed wrong",
# and traced tangents with a Shard() placement at compile time.
#
if is_subclass and hasattr(out, "__coerce_tangent_metadata__"):
out = out.__coerce_tangent_metadata__() # type: ignore[attr-defined]
if is_subclass:
# pyrefly: ignore [missing-attribute]
attrs = out.__tensor_flatten__()[0]
for attr in attrs:
elem = getattr(out, attr)
(
new_elem,
new_elem_memory_format,
elem_updated,
) = coerce_tangent_and_suggest_memory_format(elem)
# pyrefly: ignore [missing-attribute]
out_memory_format.append(new_elem_memory_format)
if elem_updated:
setattr(out, attr, new_elem)
return out, out_memory_format, updated
# This is a version of functionalization that is specifically designed
# for the AOTAutograd use case.
#
# Unlike functorch's variant, this doesn't use the functorch level system,
# instead it directly uses PyTorch's conventional dispatcher to hit the
# functionalization key. In particular, this means that FunctionalTensorWrapper
# can have autograd data stored directly on it.
#
# In typical AOTAutograd usage, the dispatch key order will look like:
#
# Autograd - Functionalization ~~~~> Proxy Mode - Fake Tensor
# outer tensor inner tensor
#
# Returns:
# - ViewAndMutationMeta, telling us metadata about the inputs and outputs, and
# The list of outputs from the forward, but **only** the outputs that we need
# to pass in as tangents into the backward.
# Specifically, aliased outputs from the forward get regenerated, and don't participate
# in the compiled backward function.
def run_functionalized_fw_and_collect_metadata(
f: Callable[..., Any],
*,
flat_args_descs: list[AOTInput],
keep_input_mutations: bool,
# Note: this is guaranteed to be set when running under dynamo
static_input_indices: list[int] | None = None,
pre_dispatch: bool = False,
) -> Callable[..., ViewAndMutationMeta]:
memo: dict[Tensor, Tensor] = {}
# TODO: see if we can rewrite this to be more accurate using
# overload
def _to_fun(t: object) -> object:
if isinstance(t, Tensor):
if t in memo:
return memo[t]
r = to_fun(t)
memo[t] = r
return r
else:
return t
@simple_wraps(f)
def inner(*flat_args: Any) -> ViewAndMutationMeta:
# This function is meant to be run with the forward, which expects a flat list of tensor/symint/other args.
if not all(
isinstance(a, tuple(KNOWN_TYPES)) or is_opaque_type(type(a))
for a in flat_args
):
raise AssertionError("all flat_args must be KNOWN_TYPES or opaque types")
input_info: list[InputAliasInfo] = []
output_info: list[OutputAliasInfo] = []
prior_grad_enabled = torch.is_grad_enabled()
prior_autocast_states = _get_autocast_states()
# See Note [Disabling Functionalize TLS Above Python Functionalization]
disable_above = torch._C._ExcludeDispatchKeyGuard(
torch._C.DispatchKeySet(torch._C.DispatchKey.Functionalize)
)
# It doesn't matter if we run this under predispatch or not because it is
# only for figuring out metadata
mode = FunctionalTensorMode(_allow_token_discovery=True)
suppress_pending = contextlib.nullcontext()
fake_mode = detect_fake_mode()
if fake_mode and (shape_env := fake_mode.shape_env):
suppress_pending = shape_env.ignore_fresh_unbacked_symbols()
with disable_above, mode, suppress_pending, disable_autocast_cache():
# precondition: The passed in function already handles unflattening inputs + flattening outputs
flat_f_args = pytree.tree_map(_to_fun, flat_args)
flat_f_args_descs = flat_args_descs
flat_f_outs = f(*flat_f_args)
# Assert that f does NOT have an AOTOutputs in it, easy mistake to
# make! You need to drop the second output before calling this
# function
if pytree.tree_any(lambda x: isinstance(x, AOTOutput), flat_f_outs):
raise AssertionError(
f"{f} returned AOTOutput when it shouldn't. Did you remember to wrap the "
"function with without_output_descs before passing it here?"
)
# NB: this is just to setup the input descriptors, we will
# recreate these descriptors (with the same convention!) when we
# actually do the trace
flat_f_outs_descs = [PlainAOTOutput(i) for i in range(len(flat_f_outs))]
# We didn't do any tracing, so we don't need to process the
# unbacked symbols, they will just disappear into the ether.
# Also, prevent memoization from applying.
if fake_mode:
fake_mode.epoch += 1
fake_mode.reset_nt_tensor_id_counter()
if prior_autocast_states != _get_autocast_states():
raise RuntimeError(
"AOTAutograd does not support tracing graphs that mutate the autocast state. "
"Dynamo will only insert autocast context managers (e.g. with torch.autocast(..)) into the graph, "
"which will unwind all of their mutations to autocast state before the graph exits. "
"If you encounter this error while using torch.compile, please file a bug."
)
# Inspect the state of the input tensor functional wrapper to detect input mutation info
# If inp[i] has a metadata-only mutation, then maybe_inputs_with_mutated_metadata[i] contains the updated version
for arg, f_arg in zip(flat_args, flat_f_args):
# NB: Mutation of non-contiguous tensor subclass input can result in a mismatch in
# strides between the functionalized arg inner tensors and non-functionalized arg inner
# tensors. This is a problem as the inner tensor stride change may not be reflected
# correctly in the outer tensor, so disallow this for now.
mutates_data = has_data_mutation(f_arg)
mutates_metadata = has_metadata_mutation(
f_arg, arg, check_only_storage_mutation=False
)
if mutates_metadata and is_traceable_wrapper_subclass(arg):
raise RuntimeError(
"Metadata mutations are currently not allowed on tensor subclasses"
)
mutates_storage_metadata = has_metadata_mutation(
f_arg, arg, check_only_storage_mutation=True
)
mutations_hidden_from_autograd = are_all_mutations_hidden_from_autograd(
f_arg
)
mutations_under_no_grad_or_inference_mode = (
mutates_data
and are_all_mutations_under_no_grad_or_inference_mode(f_arg)
)
mutation_inductor_storage_resize = was_inductor_storage_resized(f_arg)
if mutates_storage_metadata:
mutates_data = False
requires_grad = isinstance(f_arg, torch.Tensor) and f_arg.requires_grad
input_info.append(
InputAliasInfo(
is_leaf=isinstance(arg, Tensor) and safe_is_leaf(arg),
mutates_data=mutates_data,
mutates_metadata=mutates_metadata,
mutations_hidden_from_autograd=mutations_hidden_from_autograd,
mutates_storage_metadata=mutates_storage_metadata,
mutations_under_no_grad_or_inference_mode=mutations_under_no_grad_or_inference_mode,
mutation_inductor_storage_resize=mutation_inductor_storage_resize,
requires_grad=requires_grad,
keep_input_mutations=keep_input_mutations,
)
)
# If a function involves creating a tensor, and returning a view of it, such that its _base is the intermediate,
# We need to make sure our graph returns the _base as a graph output, and we manually recreate the view
# to return to the user. Why? The backend compiler is free to (incorrectly) not set requires_grad
# on the base tensor, but we are obligated to properly set requires-gradness on the real output.
inp_storage_refs = {
StorageWeakRef(inpt.untyped_storage()): idx
for idx, inpt in enumerate(flat_f_args)
if isinstance(inpt, Tensor)
}
# We need inp tensor id's to be able to tell if an outputs **are** inputs.
inp_tensor_ids = {id(inpt) for inpt in flat_f_args if isinstance(inpt, Tensor)}
# We need output tensor id's to tell if any output._base` attributes **are** other outputs.
# (This is also a dict because we need to know that output's index, so we can regenerate
# the alias from it).
out_tensor_ids = {id(o): i for i, o in enumerate(flat_f_outs)}
# Keep track of which outputs alias other outputs
out_tensor_alias_counts: collections.defaultdict[StorageWeakRef | None, int] = (
collections.defaultdict(int)
)
# This tells us, for a given group of outputs that alias each other,
# whether they e.g. all came from an unbind call
num_aliased_tensors_that_are_multi_output_views: collections.defaultdict[
StorageWeakRef | None, int
] = collections.defaultdict(int)
out_storage_to_metadata_key_to_tensors: collections.defaultdict[
StorageWeakRef | None,
collections.defaultdict[MetadataKey, set[torch.Tensor]],
] = collections.defaultdict(lambda: collections.defaultdict(set))
curr_storage = None
for o in flat_f_outs:
if isinstance(o, torch.Tensor):
curr_storage = StorageWeakRef(o.untyped_storage())
out_tensor_alias_counts[curr_storage] += 1
# Note: [AOTAutograd: differentiable outputs that alias each other from a multi-output view call]
# This is an optimization on top of the "alias of intermediates" logic,
# which you can read more about under Note [AOT Autograd: outputs aliasing inputs or intermediates!]
#
# Before describing the optimization: this is important for AOTAutograd to have good
# perf around, multi-output views. HOWEVER:
# - There is a more generic change to AOTAutograd that we'd like to make, that subsumes this case,
# around using pre-dispatch tracing to partition out a graph so we can faithfully replay all
# views without having to regenerate them at runtime.
# - It's loosely described in this doc (more details will be added soon):
# https://docs.google.com/document/d/1DlfFq8TKbuAn2zyJxLfoW-X1qkkm5PLdHFtySo03QAk/edit
# - Once that change lands, we should just rip out this "optimization", since:
# (1) It will be fully unnecessary
# (2) Although it is only a few lines of code, it is a bit difficult to reason about
# its correctness with the autograd engine in all cases.
#
#
# What is this optimization? Consider the below case:
# def f(x):
# intermediate = x.mul(2)
# # x and intermediate here require grad
# o1, o2, ... o10 = intermediate.unbind(-1)
# return intermediate, o1, o2, ... o10
# Now, the "intermediate base" handling in AOTAutograd implies that we must do the following:
# (1) return "intermediate as an extra output of the compiled graph
# (2) regenerate each aliased output off of "intermediate", **outside** of the autograd.Function.
# The reason AOTAutograd ordinarily does this is for safety: the autograd engine needs to know
# that o1 through o10 are all aliased, and if we blindly return o1 through o10 from the autograd.Function,
# this information will be hidden.
# In particular, mutating one alias might require autograd to update autograd metadata on the other aliases
# (like their grad_fn, for example, when the autograd engine needs to do view-replay).
#
# However, intermediate_base logic can be bad for backward performance (we sometimes generate
# as_strided calls during the intermediate base logic, which can have a slow backward formula).
# Is it possible to find a set of conditions where it is **safe** to hide the output aliasing from autograd?
#
# For a set of outputs of the graph that alias each other, o_1...o_k, consider:
# (1) They came from the same multi-output view op, e.g. o_1, ..., o_k = intermediate.unbind(0)
# (2) If there are any other aliases of o_1 through o_k (in the example above, intermediate),
# **at most** 1 can escape from the graph (e.g. there is not some other graph input/output
# o_other, that aliases these outputs)
# (3) o_1...o_k all require_grad, they all share the same ._base, and their ._base requires grad.
# This condition is important because it's what causes slowness in the intermediate_base
# codepath of aot_autograd. Ordinarily, o_1...o_k would all get a grad_fn, and
# aot_autograd's view-replay might give each output an AsStridedBackward as its grad_fn.
# "K" AsStridedBackward calls will be *much* slower than a single UnbindBackward.
# In this setup, is it possible to mutate one of the outputs o_i in a way that would affect the autograd meta
# of the other aliases?
#
# Claim: No! Consider a few example (which I'm pretty sure cover all cases of mutation w.r.t. autograd):
# (a) What happens if we mutate any of o_1 through o_k directly?
# Autograd raises an error:
# "RuntimeError: Output 0 of UnbindBackward0 is a view and is being modified inplace. This view is
# the output of a function that returns multiple views. Such functions do not allow the output
# views to be modified inplace. You should replace the inplace operation by an out-of-place one."
# (b) What if we take a view of o_k and mutate it, o_k.view(o_k.shape).mul_(2)?
# Autograd raises the same error- the "multi-output-view"ness of an alias propagates to future views.
# (c) What if we mutate o_k under no_grad?
# Autograd raises the same error
# (d) What if we detach and mutate, e.g. o_k.detach().mul_(2)?
# Autograd allows this, *but* autograd updates all alias's grad_fn's to be error functions when accessed.
# Autograd raises the same error
# (e) What if we try to mutate another alias of o_1...o_k, that was **not** created from a multi-output view?
# We promised that there is at most **one** such alias, e.g. intermediate in the example above.
# You can mutate intermediate, but in eager mode this will change the grad_fn of o_1...o_k
# to be error fn's.
# Since intermediate was the *only* non-multi-output-alias, there are no other aliases
# of `intermediate` around that were produced by the compiled fn and have a valid grad_fn.
#
# Coming back to this optimization:
# Given that it is not possible for mutating one of these aliases to affect the autograd metadata of another alias
# without causing an error in eager mode, we will simple hide the aliasing from autograd during torch.compile
# if all of the above conditions are met.
# This has the slight downside that it's possible to write some "bad" code that autograd will raise an error on
# in eager but fail to during torch.compile, but it has the benefit that this code has much better performance.
# NOTE: if and when we eventually update AOTAutograd to do the "view graph slicing" defined here:
# https://docs.google.com/document/d/1DlfFq8TKbuAn2zyJxLfoW-X1qkkm5PLdHFtySo03QAk/edit,
# then this optimization will probably matter less and might be ok to remove.
is_cur_tensor_multi_out_view = isinstance(
o, FunctionalTensor
) and torch._functionalize_is_multi_output_view( # type: ignore[attr-defined]
o.elem
)
if is_cur_tensor_multi_out_view:
num_aliased_tensors_that_are_multi_output_views[curr_storage] += 1
if o.requires_grad:
out_storage_to_metadata_key_to_tensors[curr_storage][
MetadataKey.make(o)
].add(o)
# maps the id of an intermediate base to its index in the output of the compiled forward
intermediate_base_tensor_id_to_output_idx: dict[int, int] = {}
intermediate_bases: list[torch.Tensor] = []
intermediate_bases_descs: list[AOTInput] = []
# Why Do We Care If Storage Changed?
# It's important to understand the implications of storage changes in complex scenarios. Take this example:
#
# def f(x):
# x_storage = x.untyped_storage()
# non_leaf_tensor = torch.ones(4, requires_grad=True).clone()
#
# # Using no_grad() and _unsafe_preserve_version_counter to simulate the .data = operation
# with torch.no_grad(), torch.autograd._unsafe_preserve_version_counter(x):
# x.set_(non_leaf_tensor.untyped_storage())
#
# out = x.view(-1)
#
# # Restoring x to its original storage, again simulating .data = operation
# with torch.no_grad(), torch.autograd._unsafe_preserve_version_counter(x):
# x.set_(x_storage)
#
# return out
#
# In this scenario, 'x' and 'out' have different shapes and are stored at different memory addresses, aka no aliasing.
# However, due to how set_() and more specificlaly, set is functionalized, is defined to preserve eager semantics,
# the autograd engine mistakenly assumes that 'x' and 'out' are aliased, treating 'x' as 'out._base'.
# This misinterpretation leads to an 'alias_of_input' flag, causing an unnecessary as_strided() call to be generated,
# which could lead to issues later in the code.
for o, desc in zip(flat_f_outs, flat_f_outs_descs):
functional_tensor_storage_changed = isinstance(
o, FunctionalTensor
) and torch._functionalize_was_storage_changed( # type: ignore[attr-defined]
o.elem
)
curr_storage = (
None
if not isinstance(o, torch.Tensor)
else StorageWeakRef(o.untyped_storage())
)
outs_with_identical_metadata_that_require_grad: list[torch.Tensor] = (
[]
if not isinstance(o, Tensor)
else [
curr
for curr in out_storage_to_metadata_key_to_tensors[curr_storage][
MetadataKey.make(o)
]
if o is not curr
]
)
# See Note [Accessing .grad_fn on FunctionalTensor]
# In-place operations on views will trigger a lazy rebase of the autograd graph;
# this runs during access to the .grad_fn. The rebase logic will invoke view ops
# on FunctionalTensors, so we must enable a FunctionalTensorMode here to ensure
# these op calls succeed.
grad_fn = None
if isinstance(o, Tensor):
with FunctionalTensorMode():
grad_fn = o.grad_fn
is_result_of_custom_autograd_fn = False
# Need to check for both custom cpp (CppFunction) and python (BackwardCFunction)
# autograd fns
if type(grad_fn).__name__ == "CppFunction":
is_result_of_custom_autograd_fn = True
if isinstance(grad_fn, torch.autograd.function.BackwardCFunction):
is_result_of_custom_autograd_fn = True
if not isinstance(o, Tensor):
output_type = OutputType.non_alias
base_idx = None
elif (
curr_storage in inp_storage_refs
and grad_fn is not None
and is_result_of_custom_autograd_fn
):
output_type = OutputType.custom_function_view
base_idx = None
elif (
curr_storage in inp_storage_refs
and not functional_tensor_storage_changed
):
# pyrefly: ignore [bad-index, index-error]
base_idx = inp_storage_refs[curr_storage]
is_input_tensor = id(o) in inp_tensor_ids
num_aliased_outs = out_tensor_alias_counts[curr_storage]
num_multi_output_view_outs = (
num_aliased_tensors_that_are_multi_output_views[curr_storage]
)
num_aliased_outs_that_are_not_multi_output_views = (
num_aliased_outs - num_multi_output_view_outs
)
if (
grad_fn is not None
and num_aliased_outs_that_are_not_multi_output_views == 0
):
# See Note: [AOTAutograd: differentiable outputs that alias each other from a multi-output view call]
# In particular, given:
# def f(x):
# return list(x.unbind(0))
# The main reason we ordinarily try to regenerate these output aliases outside of the
# compiled autograd.Function is because if any of the outputs are later mutated,
# autograd needs to perform view-replay to regenerate them.
# However, autograd does not allow users to mutate multi-output views
# in any way that can change the autograd metadata of other aliases.
# So we hide this aliasing from autograd here.
log.debug(
"Encountered AOTAutograd case: differentiable outputs that \
alias each other from a multi-output view call"
)
output_type = OutputType.non_alias
elif is_input_tensor:
output_type = OutputType.is_input
else:
output_type = OutputType.alias_of_input
elif functional_tensor_storage_changed and id(o) in inp_tensor_ids:
# When there is a set_() on an input, we cannot rely on checking storages
# to detect if we are returning an input (since the inputs storage is different)
if curr_storage is None:
raise AssertionError("curr_storage must not be None")
base_idx = inp_storage_refs[curr_storage]
output_type = OutputType.is_input
# We only need to handle the intermediate base case when both
# the intermediate base and the output require gradients.
# See Note [AOT Autograd: outputs aliasing inputs or intermediates!]
elif o._base is not None and o.requires_grad and o._base.requires_grad:
num_aliased_outs = out_tensor_alias_counts[curr_storage]
num_multi_output_view_outs = (
num_aliased_tensors_that_are_multi_output_views[curr_storage]
)
num_aliased_outs_that_are_not_multi_output_views = (
num_aliased_outs - num_multi_output_view_outs
)
# Note: [AOTAutograd: differentiable outputs that alias each other from a multi-output view call]
if (
out_tensor_alias_counts[curr_storage] == 1
or num_aliased_outs_that_are_not_multi_output_views <= 1
):
# Note [Intermediate Bases Optimization]
# Normally if we have an output that aliases an intermediate,
# we need to add the extra "intermediate base" logic further down
# to prevent autograd from yelling at us if the user later tries to
# mutate that output.
# However, the common case here is if we have an output that aliases an intermediate,
# but doesn't alias any other outputs.
# In that case, autograd shouldn't have to worry about the aliasing at all
# (if that output is mutated, there are no other live aliases for autograd to worry about).
# The "intermediate bases" can hurt inductor perf by forcing more variables to become outputs.
# So as an optimization, we won't do intermediate base handling in this case.
# Instead, we'll hide the aliasing from autograd using aten._unsafe_view().
if (
out_tensor_alias_counts[curr_storage] != 1
and num_aliased_outs_that_are_not_multi_output_views <= 1
):
log.debug(
"Encountered AOTAutograd case: differentiable outputs that alias each other \
from a multi-output view call"
)
output_type = OutputType.unsafe_view_alias
base_idx = None
else:
# First, check if o's ._base is an existing output
maybe_existing_out_idx = out_tensor_ids.get(id(o._base))
if maybe_existing_out_idx is not None:
# Special case where the output is an alias of a graph intermediate, but that intermediate
# is itself also a user output.
output_type = (
OutputType.alias_of_intermediate_base_is_user_output
)
base_idx = maybe_existing_out_idx
else:
# Next, check if o's ._base is an intermediate base that we already returned
maybe_existing_base_output_idx = (
intermediate_base_tensor_id_to_output_idx.get(id(o._base))
)
if maybe_existing_base_output_idx is not None:
output_type = OutputType.alias_of_intermediate
base_idx = maybe_existing_base_output_idx
else:
# Otherwise, take o._base and explicitly return it as an output in the compiled graph
new_out_idx = len(intermediate_bases)
base_idx = new_out_idx
# Indicate to the logic later on (when we trace the joint)
# that this particular output should get it's ._base appended to the forward graph outputs
output_type = (
OutputType.alias_of_intermediate_save_as_output
)
intermediate_base_tensor_id_to_output_idx[id(o._base)] = (
new_out_idx
)
intermediate_bases.append(o._base)
# NB: The desc we picked here is guaranteed to be
# synchronized with the one in
# graph_capture_wrappers.py because we
# SPECIFICALLY notated this output as
# alias_of_intermediate_save_as_output
intermediate_bases_descs.append(
TangentAOTInput(IntermediateBaseAOTOutput(desc))
)
elif (
# See https://github.com/pytorch/pytorch/issues/100348 for this case.
# This protects against the specific case where a user fn returns (output, output.detach())
out_tensor_alias_counts[curr_storage] > 1
and len(outs_with_identical_metadata_that_require_grad) > 0
and not o.requires_grad
):
# In theory we could use any of these tensors to regenerate the aliased outputs from,
# since they all alias each other and have identical metadata
out_alias = outs_with_identical_metadata_that_require_grad[0]
existing_out_idx = out_tensor_ids[id(out_alias)]
output_type = OutputType.alias_of_intermediate_base_is_user_output
base_idx = existing_out_idx
else:
output_type = OutputType.non_alias
base_idx = None
if isinstance(o, torch.Tensor):
dynamic_dims = {
i for i, s in enumerate(o.shape) if not is_concrete_int(s)
}
else:
dynamic_dims = None
# Save the current FunctionalTensor output.
#
# This will be used at runtime for reconstructing output views from
# their respective base tensors.
#
# The FunctionalTensor will be saved if one of the 2 conditions below
# is true:
view_meta_sequence = None
if (
# 1. If the output_type is either of:
# (i) alias_of_intermediate;
# (ii) alias_of_intermediate_save_as_output; or
# (iii) alias_of_intermediate_base_is_user_output.
#
# No need to worry about in-place view operations here, since
# this functionalization step elimitates mutations.
#
# i.e. we have access to the actual base tensor, before the
# in-place operation was applied.
output_type
in (
OutputType.alias_of_intermediate,
OutputType.alias_of_intermediate_save_as_output,
OutputType.alias_of_intermediate_base_is_user_output,
)
) or (
# 2. If the output_type is alias_of_input, and no in-place view
# operationthe was run on the input (base tensor).
#
# In this case, we need to check for metadata mutation because
# the runtime explicitly reconstructs the inputs, before actually
# reconstructing the outputs. Due to in-place view operations, the
# fully reconstructed input may not be this output base tensor
# anymore.
output_type == OutputType.alias_of_input
and base_idx is not None
and not input_info[base_idx].mutates_metadata
):
if isinstance(o, FunctionalTensor):
view_meta_sequence = ViewMetaSequence(o)
requires_grad = isinstance(o, torch.Tensor) and o.requires_grad
out_info = OutputAliasInfo(
output_type=output_type,
raw_type=type(o),
base_idx=base_idx,
dynamic_dims=dynamic_dims,
requires_grad=requires_grad,
# A view created under no_grad() inherits requires_grad from
# its base but has no grad_fn and does not participate in
# differentiation.
requires_grad_for_backward=requires_grad
and (o._base is None or grad_fn is not None),
view_meta_sequence=view_meta_sequence,
)
output_info.append(out_info)
# See Note [AOT Autograd: Views to avoid tangents aliasing inputs]
def view_avoid_dupes_with_primals(t: object) -> object:
if isinstance(t, Tensor) and is_traceable_wrapper_subclass(t):
return transform_subclass(
t, lambda _, inner_t: view_avoid_dupes_with_primals(inner_t)
)
if isinstance(t, Tensor):
return t.view(t.shape)
return t
# This analysis function returns *only* the outputs that are meant to be tangents to the backwards.
# Anything that aliases (inputs returned in the fw due to metadata mutations, or outputs that alias inputs/intermediates)
# are *regenerated* later, and not used directly in the autograd graph
def _plain_fake_tensor_like_subclass(x: Any) -> torch.Tensor:
# pyrefly: ignore [bad-context-manager]
with detect_fake_mode():
return torch.empty(
x.shape, dtype=x.dtype, device=x.device, layout=x.layout
)
def _is_subclass_mutated_input_tangent_always_subclass(inp: object) -> bool:
return (
isinstance(inp, torch.nested._internal.nested_tensor.NestedTensor)
or torch._functorch.config.disable_guess_zero_tangent_for_mutated_input_subclass
)
f_input_tangents_pairs = [
# Note: [AOTAutograd Tangent Subclassness for mutated inputs]
# Generally when creating tangents to trace with, we assume that tangents will have
# the same subclass-ness as their forward outs
# however: for tangents that correspond to input mutations, in practice it is more likely
# that these tangents will be plain tensors of zeros at runtime, so we tweak our guess
# to assume that these tangents should always be plaint tensors.
# Example:
# def f(x):
# x.mul_(2)
# return x + 1
# out = f(x)
# out.sum().backward()
# In the above code, we will have a tangent "x_updated_tangent",
# which will be a plain tensor of zeros, *unless* x is used in some compute after executing f
#
# However, there are exceptions to this logic. If a view is created from mutated input and is used in backward,
# The tangent for this subclass input will be a subclass tensor.
# Example:
# def f(a, b):
# a.mul_(2)
# b.mul_(3)
# return b.view(b.shape), a + b
# a_out, b_out = f(..., Subclass)
# (a * b).sum().backward()
#
# We can not deduce it easily now, so introducing a debug config to be able to turn off this for specific cases.
# NJT guarantees to have its tangent as NJT, because it has dedicated integration in Autograd
# See torch/csrc/autograd/python_function.cpp, use_zeros_like.
(
(
_plain_fake_tensor_like_subclass(inp)
if is_traceable_wrapper_subclass(inp)
and not _is_subclass_mutated_input_tangent_always_subclass(inp)
else inp
),
TangentAOTInput(InputMutationAOTOutput(inp_desc)),
)
for inp, inp_desc, info in zip(flat_f_args, flat_f_args_descs, input_info)
if info.mutation_type == MutationType.MUTATED_OUT_GRAPH
and info.mutates_data
and info.requires_grad
]
f_input_tangents, f_input_tangents_descs = (
[x[0] for x in f_input_tangents_pairs],
[x[1] for x in f_input_tangents_pairs],
)
f_output_tangents_pairs = [
(o, TangentAOTInput(desc))
for o, info, desc in zip(flat_f_outs, output_info, flat_f_outs_descs)
if info.output_type
in [
OutputType.non_alias,
OutputType.unsafe_view_alias,
OutputType.custom_function_view,
]
and issubclass(info.raw_type, torch.Tensor)
and info.requires_grad_for_backward
]
f_output_tangents, f_output_tangents_descs = (
[x[0] for x in f_output_tangents_pairs],
[x[1] for x in f_output_tangents_pairs],
)
# intermediate bases are also included in the backward graph
f_tangents = f_input_tangents + f_output_tangents + intermediate_bases
f_tangents_descs = (
f_input_tangents_descs + f_output_tangents_descs + intermediate_bases_descs
)
# TODO: I'm pretty sure you don't need a tree_map here
traced_tangents = pytree.tree_map(from_fun, f_tangents)
traced_tangents = pytree.tree_map(
view_avoid_dupes_with_primals, traced_tangents
)
traced_tangents = [
coerce_tangent_and_suggest_memory_format(tt)[0]
for i, tt in enumerate(traced_tangents)
]
# NB: update this if the maps above ever change structure.
# Also, it might be helpful to add coercion information to the tangent desc!
traced_tangents_descs = f_tangents_descs
nonlocal static_input_indices
static_input_indices = static_input_indices or []
if torch._dynamo.compiled_autograd.in_compiled_autograd_region:
passed_indices = set(static_input_indices)
static_input_indices = [
i
for i, arg in enumerate(flat_args)
if (isinstance(arg, torch.nn.Parameter) or i in passed_indices)
]
static_input_logger.debug(
"static input indices metadata analysis: %s", static_input_indices
)
f_mutated_inputs = [
inp
for inp, info in zip(flat_f_args, input_info)
if info.mutation_type == MutationType.MUTATED_OUT_GRAPH
]
# Build the full list of forward graph outputs so the subclass wrapping
# code knows exactly which graph outputs to wrap back into subclasses.
# Including intermediate_bases unconditionally is safe: they are only
# populated when outputs require grad (line ~539), so they are naturally
# empty during pure inference. In the "downgrade from training to
# inference" path, num_intermediate_bases > 0 is already gated behind
# `assert not req_subclass_dispatch` (aot_autograd.py), so the subclass
# wrapping code that consumes subclass_fw_graph_out_meta never sees them.
f_fw_graph_outs = [*f_mutated_inputs, *flat_f_outs, *intermediate_bases]
fw_graph_outs = pytree.tree_map(from_fun, f_fw_graph_outs)
grad_enabled_mutation = None
if torch.is_grad_enabled() != prior_grad_enabled:
grad_enabled_mutation = torch.is_grad_enabled()
torch.set_grad_enabled(
prior_grad_enabled
) # Restore the prior state after tracing it
log.debug(
(
"grad_mode mutation encountered in graph. "
"Will emit mutation epilogue, to set grad_mode=%s"
),
grad_enabled_mutation,
)
subclass_inp_meta = create_subclass_meta(flat_args)
subclass_fw_graph_out_meta = create_subclass_meta(fw_graph_outs)
subclass_tangent_meta = create_subclass_meta(
traced_tangents, count_symints=False, with_memory_format=True
)
metadata = ViewAndMutationMeta(
input_info=input_info,
output_info=output_info,
num_intermediate_bases=len(intermediate_bases),
keep_input_mutations=keep_input_mutations,
traced_tangents=traced_tangents,
traced_tangents_descs=traced_tangents_descs,
subclass_inp_meta=subclass_inp_meta,
subclass_fw_graph_out_meta=subclass_fw_graph_out_meta,
subclass_tangent_meta=subclass_tangent_meta,
grad_enabled_mutation=grad_enabled_mutation,
static_input_indices=static_input_indices,
tokens=mode._tokens,
)
return metadata
return inner
@@ -0,0 +1,782 @@
"""
AOTAutograd descriptors are a path-like data structure (similar to pytree
paths and sources) that describe the semantic meaning of an input/output to FX
graphs. Although you may know the input/output meaning at the top level of
the original function you traced, because we have many graph capture wrappers
that change the calling convention, it can be difficult to tell how these
correspond to the actual FX graph you get back, to say nothing about the extra
arguments/outputs for tangents, gradients, etc. Descriptors describe the meaning
of arguments.
Examples
--------
Before we talk about the precise semantics, it's helpful to look at some
examples to get some intuition for the meaning of descriptors. Here are some
input descriptors you might find on the joint FX graph:
* PlainAOTInput(idx=0) - the first input from the original callable, as is
* ParamAOTInput(target="mod.weight") - the parameter with FQN mod.weight
* TangentAOTInput(output=PlainAOTOutput(idx=1)) - the input tangent
corresponding to the gradients for the second output in the forward graph
* ViewBaseAOTInput(base_of=PlainAOTInput(idx=0)) - it turned out the first
input was actually a (differentiable) view of a tensor which aliased with
another input tensor. We replaced this input with a single input for the
base of all of these inputs, replacing the original inputs (one of which is
mentioned in base_of). We would generate a GradAOTOutput for *this* input
(and not the original PlainAOTInputs!) If you have a joint graph where a
view base like this is undesirable, you can eliminate this by cloning
the views outside of the compiled region (assuming you aren't mutating this
tensor).
* SubclassGetAttrAOTInput(base=AOTInput(idx=0), attr="inner") - this tensor
corresponds to the "inner" tensor from the tensor subclass that is at the
first index. In general, joint graphs from AOTAutograd never take tensor
subclasses as inputs; they are always unpacked into their constituent plain
tensor pieces; use the descriptors to identify the parts of the tensor that
are related. Note that this can be nested (if you have nested tensor
subclasses!)
Here are some output descriptors you might find on the Joint FX graph:
* PlainAOTOutput(idx=0) - the first output from the original forward function,
as is
* GradAOTOutput(grad_of=PlainAOTInput(idx=1)) - the computed gradient for the
second input to the graph, an output of the backward graph
* InputMutationAOTOutput(mutated_input=PlainAOTInput(idx=0)) - when the first
input is mutated, the new value to be copied into the first input of the
graph. Sometimes, these outputs can be elided and the ``copy_`` is done directly
in the graph (controlled by keep_input_mutations), but if the input
mutation must be differentiated through we always generate an output like this
* IntermediateBaseAOTOutput(base_of=PlainAOTOutput(idx=0)) - if we return
multiple outputs which alias each other, we instead replace them with a single
output tensor representing the base of all the aliases. This output indicates
it is the base for /one/ of those original outputs. If this is undesirable in
the joint graph, clone all outputs before returning from the graph.
* SubclassGetAttrAOTOutput(base=PlainAOTOutput(idx=0), idx="inner") - this
tensor correspondings to the inner tensor of the first original output which
is a tensor subclass. This and other subclass components of that output will
get repacked into a tensor subclass.
High level semantics
--------------------
OK, let's formally define a descriptor. Intuitively, suppose we have::
def wrapped_graph(*args):
ret = graph(*in_transform(args))
return out_transform(ret)
Then the descriptor for input[i] to graph describes a function fin_i such that::
fin_i(args) == in_transform(args)[i]
and the descriptor for output[j] from graph describes a function fout_j such that::
fout_j(out_transform(ret)) == ret[j]
AKA input descriptors tell you how to get from outer inputs to inner inputs,
while output descriptors tell you how to get from outer outputs to inner
outputs (inverse data flow!)
We haven't said anything about what these transformations actually do. There
are three major transformations AOTAutograd does (performed in this order):
* View/mutation handling
* Autograd
* Subclasses
So intuitively, descriptors are built like this:
1. **PlainAOTInput, PlainAOTOutput.**
We start off descriptors describing the exact inputs/outputs of the
original flattened user function. This user function is assumed to already
be flattened; you would chain on pytree KeyPaths to further describe where
in the pytree each input/output lived if you needed to deal with
unflattened functions: this can be done from userland on top of
descriptors, so the main descriptors mechanism doesn't handle it.
2. **SyntheticBaseAOTInput, ViewBaseAOTInput, MetadataMutationAOTOutput,
InputMutationAOTOutput, IntermediateBaseAOTOutput**
We deal with mutations and aliasing by removing duplicate PlainAOTInputs
and introduce some new artificial inputs/outputs. These inputs do not
have a straightforward correspondence to the original user inputs, but if
you are implementing a pass that doesn't care about the exact semantics of
inputs, you should handle all of these uniformly in the same way as regular
inputs.
3. **TangentAOTInput, GradAOTOutput**
We deal with autograd by introducing a tangent input for every
differentiable AOTOutput (including the new ones introduced above), and a
gradient output for every differentiable AOTInput (also including new ones
introduced above.) The arguments to these AOTInput/AOTOutput can ONLY be
the ones we already have above (from steps 1-2). As AOTAutograd does not
currently support double backwards, you never have tangents of grads or
vice versa (but in the future we could!)
4. **SubclassGetAttrAOTInput, SubclassGetAttrAOTOutput, et al.**
We deal with subclasses by introducing flattened inputs/outputs (including
potentially symbolic sizes/strides) for every AOTInput/AOTOutput that was a
subclass. As above, the arguments to these AOTInput/AOTOutput can ONLY be
the ones we have above (from steps 1-3). Recursive subclasses are
supported, so these descriptors can nest with each other (so descriptors
from step 4 are fair game as well.)
5. **ForwardTokenAOTInput, ForwardTokenAOTOutput, BackwardTokenAOTInput, BackwardTokenAOTOutput.**
Some extra token inputs/outputs get added, these are synthetic and are just here to
prevent DCE/reordering.
The important thing about the pipeline is that descriptors can ONLY be
created from top-to-bottom. So for example, you can have::
SubclassGetAttrAOTInput(TangentAOTInput(PlainAOTOutput(...))) # OK
As you can see that PlainAOTOutput -> TangentAOTInput ->
SubclassGetAttrAOTInput is consistent with the pipeline ordering), but you can
NEVER have::
TangentAOTInput(SubclassGetAttrAOTOutput(PlainAOTOutput(...)) # BAD
This is inconsistent; we always do autograd BEFORE we process subclasses!
Similarly, for example, this is illegal::
GradAOTOutput(SubclassGetAttrAOTInput(PlainAOTInput(...))) # BAD
It is illegal because subclasses are handled *after* create joint during
wrapper construction. Instead, you would have::
SubclassGetAttrAOTOutput(GradAOTOutput(PlainAOTInput(...))) # OK
This intuitively captures the fact that we always to autograd directly on the
subclass, rather than after desugaring the subclass into its inner tensors.
Descriptor index
----------------
Here is a list of all AOTInput/AOTOutput, organized by how likely you need to
handle them:
* AOTInput
* Important:
* PlainAOTInput (the primals!)
* ParamAOTInput
* TangentAOTInput
* SubclassGetAttrAOTInput et al. (if you use subclasses)
* View related (can be eliminated by cloning inputs to graph; if you don't
eliminate them, make sure to handle pairing them with GradAOTOutput):
* ViewBaseAOTInput
* SyntheticBaseAOTInput
* Non-tensor, mostly just ignore them:
* DummyAOTInput
* PhiloxForwardSeedAOTInput
* PhiloxForwardBaseOffsetAOTInput
* PhiloxBackwardSeedAOTInput
* PhiloxBackwardBaseOffsetAOTInput
* ForwardTokenAOTInput
* BackwardTokenAOTInput
* AOTOutput
* Important:
* PlainAOTOutput
* GradAOTOutput
* SubclassGetAttrAOTOutput et al. (if you use subclasses)
* More obscure (if not eliminated, make sure you handle pairing them with
TangentAOTInput):
* InputMutationAOTOutput (can be eliminated if mutations are non-differentiable)
* IntermediateBaseAOTOutput (can be eliminated by cloning outputs of graph)
* MetadataMutationAOTOutput (uhh, just don't mutate metadata?)
* Non-tensor, mostly just ignore them:
* PhiloxUpdatedForwardOffsetAOTOutput
* PhiloxUpdatedBackwardOffsetAOTOutput
* ForwardTokenAOTOutput
* BackwardTokenAOTOutput
* DummyAOTOutput
For convenience, we also have DifferentiableAOTInput and
DifferentiableAOTOutput to help you classify which inputs/outputs can be
wrapped by GradAOTOutput/TangentAOTInput (respectively), which are essentially
all tensor AOTInput/AOTOutput excluding the subclass descriptors.
Implementation details
----------------------
The stylized view above is good for understanding how to interpret
descriptors, but the way that descriptors are generated in code is a bit more
complicated. Specifically, AOTAutograd is structured as a series of wrappers
on the original user function, which are composed together to form the final
function to trace. As a result of this, AOTAutograd ends up first building
the full AOTInputs for a function to be traced (as it builds the wrappers and
modifies the flat arguments to be compatible with the new input signature of
the wrapper), and then in reverse builds up the AOTOutput as it is tracing.
There is one major exception to this general idea of "build AOTInput first",
and then "build AOTOutput second": when we create TangentAOTInput, we need to
reference AOTOutputs (which output we are the tangents of) which we generally
haven't created yet. There's two ways we deal with this:
- After the precompile steps (dedup and synthetic base handling), we do an
initial pass to collect forward metadata that produces the initial set of
PlainAOTOutputs which we use to create the tangent inputs.
- We also sometimes just violate causality and predict that an AOTOutput will
be created in a particular way at some later point in time when we build an
AOTInput.
As of July 2025, here is an exhaustive description of how inputs/outputs
traverse the wrappers from AOTAutograd, and what descriptors can be introduced
at these phases.
::
Build wrappers (FLOWS DOWN) Run trace (FLOWS UP)
-------------------------------------------------------------------------------------------------
Begin PlainAOTInput (n/a)
ParamAOTInput
Precompile dedupe (remove dupes) (nothing)
Precompile synthetic base SyntheticBaseAOTInput MetadataMutationAOTOutput
ViewBaseAOTInput
Forward metadata trace PlainAOTOutput (n/a)
MetadataMutationAOTOutput
Prepare for autograd (nothing) InputMutationAOTOutput
IntermediateBaseAOTOutput
Create joint TangentAOTInput GradAOTOutput
w/ InputMutationAOTOutput
w/ IntermediateBaseAOTOutput
Precompile subclass SubclassGetAttrAOTInput et al. SubclassGetAttrAOTOutput et al.
Effect tokens ForwardTokenAOTInput ForwardTokenAOTOutput
BackwardTokenAOTInput BackwardTokenAOTOutput
End (n/a) PlainAOTOutput
It can be helpful to separately write down the input flow and the output flow
for ease of understanding the data flow:
* Input desc propagation (happens as we build wrappers)
* [IN] Begin with original calling convention (PlainAOTInput, ParamAOTInput)
* [IN] Precompile dedupe: (removes duplicate AOTInputs)
* [IN] Precompile synthetic base: SyntheticBaseAOTInput, ViewBaseAOTInput
* Forward metadata trace (mini output desc propagation)
* [OUT] Original output convention: PlainAOTOutput
* [OUT] Precompile synthetic base: MetadataMutationAOTOutput
* [IN] Prepare for autograd: (nothing)
* [IN] Create joint: TangentAOTInput (potentially w/
IntermediateBaseAOTOutput, InputMutationAOTOutput)
* [IN] Precompile subclass: SubclassGetAttrAOTInput et al.
* [IN] Effect tokens: ForwardTokenAOTInput, BackwardTokenAOTInput
(Note: BackwardTokenAOTInput is technically generated not by a wrapper but
actually done by token_discovery which implicitly adds extra arguments
to the FX trace on-the-fly.)
* Trigger a trace with the modified inputs on the wrapper
* Output desc propagation (happens as we unwind from the user function call in trace)
* [OUT] Begin with original calling convention: PlainAOTOutput
* [OUT] Effect tokens: ForwardTokenAOTOutput, BackwardTokenAOTOutput
* [OUT] Precompile subclass: SubclassGetAttrAOTOutput et al.
* [OUT] Create joint: GradAOTOutput
* [OUT] Prepare for autograd: InputMutationAOTOutput, IntermediateBaseAOTOutput
* [OUT] Precompile synthetic base: MetadataMutationAOTOutput
* [OUT] Precompile dedupe: (nothing)
"""
import dataclasses
# TODO: the is_* predicates are a little suspicious because (1) they're not
# used by anything and (2) they always report False even when a parameter got
# swizzled into a view base or deduped with a non-parameter. It is pretty
# difficult to exercise these cases but it's not clear if you will write code
# that works correctly in those cases.
@dataclasses.dataclass(frozen=True)
class AOTInput:
"""Describes where an input from an AOTAutograd produced FX graph comes from"""
def expr(self) -> str:
raise NotImplementedError("Subclasses must implement expr()")
def is_param(self) -> bool:
"""True if this input is a parameter or derived from a parameter (e.g., subclass attr)"""
return False
def is_buffer(self) -> bool:
"""True if this input is a buffer or derived from a buffer (e.g., subclass attr)"""
return False
def is_tangent(self) -> bool:
"""True if this input is a tangent or derived from a tangent (e.g., subclass attr)"""
return False
# Note: Currently, our typing discipline for differentiable versus not is not
# very good, so feel free to rely on runtime tests instead.
@dataclasses.dataclass(frozen=True)
class DifferentiableAOTInput(AOTInput):
"""A subclass that classifies AOTInput that can be wrapped by GradAOTOutput"""
@dataclasses.dataclass(frozen=True)
class AOTOutput:
"""Describes where an output from an AOTAutograd produced FX graph will
eventually be bundled into the final output"""
def expr(self) -> str:
raise NotImplementedError("Subclasses must implement expr()")
def is_grad(self) -> bool:
"""True if this output is a grad or derived from a grad (e.g., subclass attr)"""
return False
@dataclasses.dataclass(frozen=True)
class DifferentiableAOTOutput(AOTOutput):
"""A subclass that classifies AOTOutput that can be wrapped by TangentAOTInput"""
# ------------
# AOTInput
# ------------
@dataclasses.dataclass(frozen=True)
class ParamAOTInput(DifferentiableAOTInput):
"""The input is a parameter, whose FQN is target"""
target: str
def expr(self) -> str:
return f"self.get_parameter({self.target!r})"
def is_param(self) -> bool:
return True
def is_buffer(self) -> bool:
return False
@dataclasses.dataclass(frozen=True)
class BufferAOTInput(DifferentiableAOTInput):
"""The input is a buffer, whose FQN is target"""
target: str
def expr(self) -> str:
return f"self.get_buffer({self.target!r})"
def is_param(self) -> bool:
return False
def is_buffer(self) -> bool:
return True
@dataclasses.dataclass(frozen=True)
class DummyAOTInput(AOTInput):
"""In some circumstances, we want to call into a function that expects AOTInput, but
we don't actually care about that logic (most typically, because some code is being used
for both compile-time and run-time; AOTInput processing is not needed in this situation.
Pass a dummy in this situation; but it is better to just have a version of the function
that doesn't have this at all."""
idx: int
def expr(self) -> str:
return f"__dummy{self.idx}"
@dataclasses.dataclass(frozen=True)
class PlainAOTInput(DifferentiableAOTInput):
"""The input is a plain input, corresponding to a particular positional index.
Note that AOTInput is always relative to a function with a *flat* calling convention,
e.g., as accepted by `aot_module_simplified`. There are some AOTAutograd APIs that
flatten pytrees, and we don't record PyTree key paths from the flattening (but we
could and should!)
"""
idx: int
def expr(self) -> str:
return f"args[{self.idx}]"
@dataclasses.dataclass(frozen=True)
class SubclassGetAttrAOTInput(AOTInput):
"""Subclass inputs get unpacked into their constituent pieces before going into an FX
graph. This tells you which particular attribute of the subclass this particular
input corresponds to (of the 'base' originally subclass argument.)
"""
base: AOTInput
attr: str
def expr(self) -> str:
return f"{self.base.expr()}.{self.attr}"
def is_param(self) -> bool:
return self.base.is_param()
def is_buffer(self) -> bool:
return self.base.is_buffer()
def is_tangent(self) -> bool:
return self.base.is_tangent()
@dataclasses.dataclass(frozen=True)
class SubclassSizeAOTInput(AOTInput):
"""Which subclass this particular outer size SymInt input (at dim idx) came from."""
base: AOTInput
idx: int
def expr(self) -> str:
return f"{self.base.expr()}.size({self.idx})"
@dataclasses.dataclass(frozen=True)
class SubclassStrideAOTInput(AOTInput):
"""Which subclass this particular outer stride SymInt input (at dim idx) came from."""
base: AOTInput
idx: int
def expr(self) -> str:
return f"{self.base.expr()}.stride({self.idx})"
@dataclasses.dataclass(frozen=True)
class ViewBaseAOTInput(DifferentiableAOTInput):
"""
When multiple differentiable inputs are views of the same input, AOTAutograd will replace all of these
views with a single input representing the base. If this is undesirable, you can clone the views
example inputs before passing them into AOTAutograd.
TODO: In principle we could report ALL of the inputs who this is a base of.
"""
base_of: AOTInput
def expr(self) -> str:
return f"{self.base_of.expr()}._base"
@dataclasses.dataclass(frozen=True)
class SyntheticBaseAOTInput(DifferentiableAOTInput):
"""This is similar to ViewBaseAOTInput, but this happens when none of the views were differentiable, so
we weren't able to get our hands on the true original view and constructed a synthetic one instead
for the sake of autograd.
"""
base_of: AOTInput
def expr(self) -> str:
return f"__make_synthetic_base({self.base_of.expr()})"
@dataclasses.dataclass(frozen=True)
class PhiloxForwardSeedAOTInput(AOTInput):
"""The seed for functionalized Philox RNG calls, specifically for forward graph."""
def expr(self) -> str:
return "__philox_forward_seed"
@dataclasses.dataclass(frozen=True)
class PhiloxForwardBaseOffsetAOTInput(AOTInput):
"""The offset for functionalized Philox RNG calls, specifically for forward graph."""
def expr(self) -> str:
return "__philox_forward_base_offset"
@dataclasses.dataclass(frozen=True)
class PhiloxBackwardSeedAOTInput(AOTInput):
"""The seed for functionalized Philox RNG calls, specifically for backward graph."""
def expr(self) -> str:
return "__philox_backward_seed"
@dataclasses.dataclass(frozen=True)
class PhiloxBackwardBaseOffsetAOTInput(AOTInput):
"""The offset for functionalized Philox RNG calls, specifically for backward graph."""
def expr(self) -> str:
return "__philox_backward_base_offset"
@dataclasses.dataclass(frozen=True)
class ForwardTokenAOTInput(AOTInput):
"""The world token which is threaded through side-effectful operations"""
idx: int
def expr(self) -> str:
return f"__forward_token{self.idx}"
@dataclasses.dataclass(frozen=True)
class BackwardTokenAOTInput(AOTInput):
"""The world token which is threaded through side-effectful operations, for backwards"""
idx: int
def expr(self) -> str:
return f"__backward_token{self.idx}"
# Technically the "output" here is redundant, tangents always correspond to
# outputs
# NB: this is marked differentiable as it /would/ be differentiable if we
# support double backwards, but we never generate this today because we
# don't support double backwards.
@dataclasses.dataclass(frozen=True)
class TangentAOTInput(DifferentiableAOTInput):
"""An input to the joint graph representing the tangent of an output."""
output: DifferentiableAOTOutput
def __post_init__(self) -> None:
if not isinstance(self.output, DifferentiableAOTOutput):
raise AssertionError(
f"expected output to be DifferentiableAOTOutput, got {type(self.output)}"
)
def expr(self) -> str:
return f"__output_tangent({self.output.expr()})"
def is_tangent(self) -> bool:
return True
# ------------
# AOTOutput
# ------------
@dataclasses.dataclass(frozen=True)
class PlainAOTOutput(DifferentiableAOTOutput):
"""A plain tensor output at position idx of the output tuple"""
idx: int
def expr(self) -> str:
return f"output[{self.idx}]"
@dataclasses.dataclass(frozen=True)
class InputMutationAOTOutput(DifferentiableAOTOutput):
"""The mutated value of an input tensor, returned so we can appropriately propagate autograd."""
mutated_input: AOTInput
def expr(self) -> str:
return f"__input_mutation({self.mutated_input.expr()})"
@dataclasses.dataclass(frozen=True)
class IntermediateBaseAOTOutput(DifferentiableAOTOutput):
"""An intermediate base of multiple outputs which alias each other. We only report ONE of
the outputs that contributed to this base"""
base_of: "AOTOutput"
def expr(self) -> str:
return f"__intermediate_base({self.base_of.expr()})"
# TODO: it's a little dodgy this is differentiable lol, but we do generate
# these BEFORE autograd is handled
@dataclasses.dataclass(frozen=True)
class MetadataMutationAOTOutput(DifferentiableAOTOutput):
idx: int
def expr(self) -> str:
return f"__aliased_arg_with_metadata_mutation{self.idx}"
# NB: this is marked differentiable as it /would/ be differentiable if we
# support double backwards, but we never generate this today because we
# don't support double backwards.
@dataclasses.dataclass(frozen=True)
class GradAOTOutput(DifferentiableAOTOutput):
"""An output representing the computed gradient for a differentiable input, in the joint graph"""
grad_of: DifferentiableAOTInput
def __post_init__(self) -> None:
if not isinstance(self.grad_of, DifferentiableAOTInput):
raise AssertionError(
f"expected grad_of to be DifferentiableAOTInput, got {type(self.grad_of)}"
)
def expr(self) -> str:
return f"__grad({self.grad_of.expr()})"
def is_grad(self) -> bool:
return True
@dataclasses.dataclass(frozen=True)
class PhiloxUpdatedForwardOffsetAOTOutput(AOTOutput):
"""The final offset from the functionalized RNG calls, forward only"""
def expr(self) -> str:
return "__philox_updated_forward_offset"
@dataclasses.dataclass(frozen=True)
class PhiloxUpdatedBackwardOffsetAOTOutput(AOTOutput):
"""The final offset from the functionalized RNG calls, backward only"""
def expr(self) -> str:
return "__philox_updated_backward_offset"
@dataclasses.dataclass(frozen=True)
class ForwardTokenAOTOutput(AOTOutput):
"""The world token output for side-effectful calls, returned so we cannot DCE it, forward only"""
idx: int
def expr(self) -> str:
return f"__forward_token{self.idx}"
@dataclasses.dataclass(frozen=True)
class BackwardTokenAOTOutput(AOTOutput):
"""The world token output for side-effectful calls, returned so we cannot DCE it, backward only"""
idx: int
def expr(self) -> str:
return f"__backward_token{self.idx}"
# These are seemingly symmetric with their AOTInput counterparts. The way to
# think about it is that a subclass could be an input or an output, and they
# get exploded into plain tensors on the way in and out. So we need
# descriptors for both.
@dataclasses.dataclass(frozen=True)
class SubclassGetAttrAOTOutput(AOTOutput):
"""This output will be bundled into a subclass at this location"""
base: AOTOutput
attr: str
def expr(self) -> str:
return f"{self.base.expr()}.{self.attr}"
def is_grad(self) -> bool:
return self.base.is_grad()
@dataclasses.dataclass(frozen=True)
class SubclassSizeAOTOutput(AOTOutput):
"""This output size will be bundled into a subclass at this location"""
base: AOTOutput
idx: int
def expr(self) -> str:
return f"{self.base.expr()}.size({self.idx})"
@dataclasses.dataclass(frozen=True)
class SubclassStrideAOTOutput(AOTOutput):
"""This output stride will be bundled into a subclass at this location"""
base: AOTOutput
idx: int
def expr(self) -> str:
return f"{self.base.expr()}.stride({self.idx})"
@dataclasses.dataclass(frozen=True)
class DummyAOTOutput(AOTOutput):
"""For cases when you don't actually care about descriptor propagation, do not use under normal
circumstances."""
idx: int
def expr(self) -> str:
return f"__dummy{self.idx}"
@dataclasses.dataclass(frozen=True)
class SavedForBackwardsAOTOutput(AOTOutput):
idx: int
def expr(self) -> str:
return f"__saved_for_backwards_{self.idx}"
# Note [Activations with no version counter checks in eager]
# In eager, when a tensor is saved for backward, the autograd engine
# generally performs version counter checks when grabbing the activation
# at backward time.
# One exception is in a custom autograd.Function: if the user stashes a tensor
# for use in the backward using `ctx.foo = foo`, rather than
# `ctx.save_for_backward(foo)`, then the autograd engine will not know
# to perform any checks.
# In torch.compile, we handle autograd by tracing through it ahead of time,
# and wrapping the fw + bw graphs into a custom autograd.Function.
# In order to provide parity with eager around VC checks, though, we need
# to know which activations had gotten this "no VC checks" treatment in eager,
# so we can plumb this info into the AOTAutograd runtime,
# and avoid calling ctx.save_for_backward on these tensors.
#
# This dataclass tells us that a given AOTOutput corresponds to:
# - An activation output from the forward graph
# - that should *not* have its version counter checked at runtime in the backward.
# This is done by stashing this activation on the ctx object directly
@dataclasses.dataclass(frozen=True)
class SavedForBackwardsNoVcCheckAOTOutput(AOTOutput):
idx: int
def expr(self) -> str:
return f"__saved_for_backwards_no_vc_check_{self.idx}"
@@ -0,0 +1,421 @@
from __future__ import annotations
import warnings
from contextlib import contextmanager
from typing import Any, cast, TYPE_CHECKING
import torch
import torch.utils._pytree as pytree
from torch._guards import detect_fake_mode
from torch._library.opaque_object import is_opaque_type
from torch._opaque_base import OpaqueBase
from torch._subclasses import FakeTensor, FakeTensorMode
from torch.fx.experimental.proxy_tensor import _pytree_subclasses_that_lose_info
from torch.fx.experimental.symbolic_shapes import ShapeEnv
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
from .. import config
from .descriptors import BufferAOTInput, DifferentiableAOTInput, ParamAOTInput
from .schemas import AOTConfig, FakifiedFlatArgs
if TYPE_CHECKING:
from collections.abc import Generator, KeysView
static_inputs_log = torch._logging.getArtifactLogger(
__name__, "cudagraph_static_inputs"
)
def process_inputs(
flat_args: list[Any],
aot_config: AOTConfig,
fake_mode: FakeTensorMode,
shape_env: ShapeEnv | None,
ignore_shape_env: bool = False,
) -> tuple[FakifiedFlatArgs, list[int]]:
"""Convert real tensor inputs into fake tensors for AOT autograd tracing.
Called at compile time (not runtime) to produce the fake inputs that AOT
autograd traces through. Each real tensor is converted to a FakeTensor
via ``fake_mode.from_tensor``, preserving shape, dtype, device, and
symbolic shape information from the ShapeEnv. Non-tensor inputs (ints,
SymInts, ScriptObjects) are converted or passed through as appropriate.
Tensor subclass inputs (DTensor, etc.) are fakified recursively by
walking their ``__tensor_flatten__`` attrs. AsyncCollectiveTensors are
resolved via ``trigger_wait()`` before fakification so they don't appear
in the traced metadata (see below).
Called from ``aot_function``, ``aot_module_simplified``, and
``aot_export_module`` — anywhere AOT autograd needs fake inputs before
graph capture.
Returns:
A tuple of (fakified_args, act_input_indices) where act_input_indices
records which positions held AsyncCollectiveTensors. These indices are
stored on ViewAndMutationMeta so that the runtime wrapper can emit
direct trigger_wait() calls on those positions.
"""
# Resolve AsyncCollectiveTensors before tracing. ACTs are transient
# eager-mode wrappers for async collective overlap; if they leak into the
# traced graph as input types, AOT autograd records them in
# SubclassCreationMeta for output tangent metadata. At runtime, autograd
# produces plain tensor tangents, causing a type mismatch. Unwrapping
# here prevents ACT from appearing in the traced metadata.
try:
from torch.distributed._functional_collectives import AsyncCollectiveTensor
except ImportError:
AsyncCollectiveTensor = None
act_input_indices: list[int] = []
if AsyncCollectiveTensor is not None:
for i, a in enumerate(flat_args):
if isinstance(a, AsyncCollectiveTensor):
act_input_indices.append(i)
flat_args[i] = a.trigger_wait()
with fake_mode:
def convert(idx: int, x: Any) -> Any:
nonlocal ignore_shape_env
if shape_env is not None and not ignore_shape_env:
from torch._dynamo.source import ConstantSource
if isinstance(x, int):
# We always specialize on scalar values in export.
if aot_config.is_export:
return x
source = ConstantSource(f"sym_{idx}")
return shape_env.create_symintnode(
shape_env.create_symbol(x, source, positive=x >= 0),
hint=x,
source=source,
)
if isinstance(x, torch.ScriptObject) or is_opaque_type(type(x)):
return torch._library.fake_class_registry.maybe_to_fake_obj(
fake_mode, x
)
if not isinstance(x, torch.Tensor):
return x
if isinstance(x, FakeTensor):
# In the case of cross compilation we will have example inputs
# with a different fake mode than our tracing fake mode.
# In these cases we want to clone the fake tensor into our
# inner fake mode.
if x.fake_mode is not fake_mode:
return fake_mode.from_tensor(x)
return x
if is_traceable_wrapper_subclass(x):
attrs, _ = x.__tensor_flatten__()
# See if all inner tensors are FakeTensors from this mode
all_this_fake = True
for a in attrs:
match getattr(x, a):
case FakeTensor() as v:
if v.fake_mode is not fake_mode:
# FakeTensor subclass from a different mode.
# Fall through to refakify.
all_this_fake = False
break
case torch.Tensor():
all_this_fake = False
break
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
if all_this_fake:
return x
# see note [Tensor Fakification and Symbol Caching]
symbolic_context = None
source = None
trace = True
if tracing_context := torch._guards.TracingContext.try_get():
if x in tracing_context.tensor_to_context:
symbolic_context = tracing_context.tensor_to_context[x]
source = symbolic_context.tensor_source
# We already fakeified this tensor in Dynamo, don't
# dump the trace for it again
trace = False
if (
idx < aot_config.num_params_buffers
and config.static_weight_shapes
and not symbolic_context
):
# TODO: Ensure that this codepath is never exercised from
# Dynamo
return fake_mode.from_tensor(x, static_shapes=True)
result = fake_mode.from_tensor(
x,
static_shapes=ignore_shape_env,
symbolic_context=symbolic_context,
source=source,
trace=trace,
)
return result
return FakifiedFlatArgs(
[convert(idx, x) for idx, x in enumerate(flat_args)]
), act_input_indices
def construct_fake_mode(
flat_args: list[Any], aot_config: AOTConfig
) -> tuple[FakeTensorMode, ShapeEnv | None]:
fake_mode = detect_fake_mode(flat_args)
if fake_mode is None:
shape_env = ShapeEnv() if aot_config.dynamic_shapes else None
fake_mode = FakeTensorMode(shape_env=shape_env)
else:
shape_env = fake_mode.shape_env
return (fake_mode, shape_env)
def _try_get_metadata_from_dynamo(
mod: torch.nn.Module,
param_keys: KeysView[str],
full_args_num: int,
full_args_descs: list[DifferentiableAOTInput],
) -> tuple[list[torch._guards.Source | None] | None, list[int]]:
"""
Metadata is forwarded from Dynamo to AOTDispatch via special fields on GraphModule.
We first verify that `mod` does come from Dynamo, then we handle cases where
metadata might be missing.
Returns:
aot_autograd_arg_pos_to_source: used to dedup params and their guards
static_input_indices: used to identify static inputs for cudagraphs
"""
# Note [Assumption on Dynamo Metadata]
# This function assumes a graph module from dynamo provides `dynamo_compiled_id`,
# _param_name_to_source, and every placeholder node has `_dynamo_source` attributes.
# When gm is modified (e.g., DDPOptimizer via split_module), metadata needs to
# be propagated in order to be recognized as a dynamo graph
if not (isinstance(mod, torch.fx.GraphModule) and "dynamo_compile_id" in mod.meta):
# graph was not captured by dynamo
return None, []
if not hasattr(mod, "_param_name_to_source"):
# is from export
static_input_indices = [
i
for i, node in enumerate(full_args_descs)
if isinstance(node, (ParamAOTInput, BufferAOTInput))
]
return None, static_input_indices
# We now know this came from dynamo, and (1) we care about guards,
# so setting up aot_autograd_arg_pos_to_source for downstream dedup guards
# can now be done safely. (2) Dynamo logic protects the 1:1 sizing below.
# Additionally, we mark static indices for cudagraphs.
param_name_to_source = cast(
dict[str, torch._guards.Source], mod._param_name_to_source
)
seen_sources = set()
aot_autograd_arg_pos_to_source: list[torch._guards.Source | None] = []
static_input_indices = []
# Collect the new inputs lifted by aotdispatch
for i, name in enumerate(param_keys):
if name not in param_name_to_source:
raise AssertionError(f"{name} not found in param_name_to_source")
source = param_name_to_source[name]
if source in seen_sources:
raise AssertionError(f"source {source} already in seen_sources")
if source is None:
raise AssertionError(f"source must not be None for {name}")
seen_sources.add(source)
aot_autograd_arg_pos_to_source.append(source)
static_input_indices.append(i)
# Collect the dynamo graph inputs
# TODO(mlazos): Revisit if this is still needed. With Dynamo install ID
# matched tensors back into the Fx graph, this might not be necessary.
for pos, node in enumerate(mod.graph.find_nodes(op="placeholder")):
if not hasattr(node, "_dynamo_source"):
raise AssertionError(f"node {node} must have _dynamo_source attribute")
source = node._dynamo_source
# `source`` specifies the source from user code. ddp optimizer may have
# intermediate values becoming submodule placeholders which does not
# have a source
if source is not None and source in seen_sources:
raise AssertionError(f"source {source} already in seen_sources")
seen_sources.add(source)
aot_autograd_arg_pos_to_source.append(source)
source_name = source.name if source else str(source)
# input[i] in dynamo is now:
# input[i + len(extra_params)] in AOT,
# where extra_params are the params/buffers that dynamo baked into the
# OutputGraph
actual_pos = pos + len(param_keys)
if "tensor_dict" in node.meta and node.meta["tensor_dict"].get(
"_dynamo_static_input_type", None
):
static_inputs_log.debug(
"Adding static input pos %s for source %s", actual_pos, source_name
)
static_input_indices.append(actual_pos)
else:
static_inputs_log.debug(
"Non-static input pos %s for source %s", actual_pos, source_name
)
if full_args_num != len(aot_autograd_arg_pos_to_source):
raise AssertionError(
f"full_args_num={full_args_num} != len(aot_autograd_arg_pos_to_source)={len(aot_autograd_arg_pos_to_source)}"
)
return aot_autograd_arg_pos_to_source, static_input_indices
@contextmanager
def _detect_attribute_assignment(mod: torch.nn.Module) -> Generator[None, None, None]:
# Do not allow assignment of tensor attributes during export unless
# the attribute is registered as a buffer.
NN_MODULE_STD_ATTRS = [
"_backward_hooks",
"_backward_pre_hooks",
"_buffers",
"_forward_hooks",
"_forward_hooks_always_called",
"_forward_hooks_with_kwargs",
"_forward_pre_hooks",
"_forward_pre_hooks_with_kwargs",
"_is_full_backward_hook",
"_load_state_dict_post_hooks",
"_load_state_dict_pre_hooks",
"_modules",
"_non_persistent_buffers_set",
"_parameters",
"_state_dict_hooks",
"_state_dict_pre_hooks",
"training",
]
NN_MODULE_LAZY_STD_ATTRS = [
"_initialize_hook",
"_load_hook",
]
STD_ATTRS = {
*NN_MODULE_STD_ATTRS,
*NN_MODULE_LAZY_STD_ATTRS,
}
def _get_attributes(mod: torch.nn.Module) -> dict[str, Any]:
# return any attributes of a module that are not standard attributes
return {k: v for k, v in mod.__dict__.items() if k not in STD_ATTRS}
def _get_all_module_attributes(mod: torch.nn.Module) -> dict[str, dict[str, Any]]:
# return attributes from all modules and submodules
result = {}
for name, submodule in mod.named_modules():
result[name] = _get_attributes(submodule)
return result
def _restore_all_module_attributes(
mod: torch.nn.Module, snapshot: dict[str, dict[str, Any]]
) -> None:
# restore attributes to all modules and submodules
for name, submodule in mod.named_modules():
if name in snapshot:
submodule.__dict__.update(snapshot[name])
# save state of attributes before enter
snapshot = pytree.tree_map(
lambda x: x,
_get_all_module_attributes(mod),
is_leaf=lambda x: type(x) in _pytree_subclasses_that_lose_info,
)
try:
yield
finally:
# after exit, compare state of attributes with snapshot
# to detect which tensor attributes were assigned
def _collect_assigned_tensor_attributes(
snapshot: dict[str, dict[str, Any]], new_attrs: dict[str, dict[str, Any]]
) -> list[str]:
assigned_tensor_attributes = []
def _compare_values(path: str, old_val: Any, new_val: Any) -> None:
"""Recursively compare values, handling containers."""
# Same object, no change
if old_val is new_val:
return
if old_val is None or new_val is None:
if isinstance(new_val, torch.Tensor):
assigned_tensor_attributes.append(path)
return
# Check if it's a tensor that was reassigned
if isinstance(new_val, torch.Tensor):
assigned_tensor_attributes.append(path)
return
# Handle dict containers
if isinstance(old_val, dict) and isinstance(new_val, dict):
all_keys = set(old_val.keys()) | set(new_val.keys())
for key in all_keys:
old_item = old_val.get(key)
new_item = new_val.get(key)
_compare_values(f"{path}[{key!r}]", old_item, new_item)
return
# Handle list/tuple containers
if isinstance(old_val, (list, tuple)) and isinstance(
new_val, (list, tuple)
):
# Different lengths = mutation happened
max_len = max(len(old_val), len(new_val))
for i in range(max_len):
old_item = old_val[i] if i < len(old_val) else None
new_item = new_val[i] if i < len(new_val) else None
_compare_values(f"{path}[{i}]", old_item, new_item)
return
# For other types, just check if they're different objects
# (we don't care about non-tensor mutations)
for module_name in snapshot.keys() | new_attrs.keys():
old_module_attrs = snapshot.get(module_name, {})
new_module_attrs = new_attrs.get(module_name, {})
for attr_name in old_module_attrs.keys() | new_module_attrs.keys():
module_prefix = f"self.{module_name}." if module_name else "self."
full_path = f"{module_prefix}{attr_name}"
old_val = old_module_attrs.get(attr_name)
new_val = new_module_attrs.get(attr_name)
_compare_values(full_path, old_val, new_val)
return assigned_tensor_attributes
new_attrs = _get_all_module_attributes(mod)
assigned_tensor_attributes = _collect_assigned_tensor_attributes(
snapshot, new_attrs
)
# restore state of all attributes (including, e.g., of primitive types)
_restore_all_module_attributes(mod, snapshot)
if assigned_tensor_attributes:
if len(assigned_tensor_attributes) > 1:
noun, verb = "attributes", "were"
else:
noun, verb = "attribute", "was"
warnings.warn(
f"The tensor {noun} {', '.join(assigned_tensor_attributes)} {verb} assigned during export. "
"Such attributes must be registered as buffers using the `register_buffer` API "
"(https://pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module.register_buffer).",
stacklevel=2,
)
@@ -0,0 +1,658 @@
"""
This file contains utilities related to functionalization in AOTAutograd:
1. converting to/from functional tensors
2. detecting Tensor mutations - both metadata and Tensor value
3. regenerating/replaying views from their base
4. checking if a graph is functional i.e. whether it contains any mutation ops
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, TypeGuard
import torch
from torch import Tensor
from torch._C import _functionalization
from torch._logging import getArtifactLogger
from torch._opaque_base import OpaqueBase
from torch._subclasses.fake_tensor import FakeTensor
from torch._subclasses.functional_tensor import FunctionalTensor
from torch._subclasses.meta_utils import is_sparse_any
from torch.fx.experimental.symbolic_shapes import guard_or_false, sym_eq, SymIntEqByExpr
from torch.multiprocessing.reductions import StorageWeakRef
from torch.utils._python_dispatch import (
is_traceable_wrapper_subclass,
transform_subclass,
)
aot_joint_log = getArtifactLogger(__name__, "aot_joint_graph")
def to_fun(t: object) -> Any:
if isinstance(t, Tensor):
if is_traceable_wrapper_subclass(t):
# See Note [Functionalization always runs last]
# This means that if we want to "functionalize" a subclass, we need to ensure that the functional wrapper
# goes at the bottom.
# recurse here, so we can support nested wrapper subclasses
out = transform_subclass(t, lambda _, inner_t: to_fun(inner_t))
torch._mirror_autograd_meta_to(t, out) # type: ignore[attr-defined]
return out
else:
return FunctionalTensor.to_functional(t)
else:
return t
def sync_functional_tensor(t: torch.Tensor) -> None:
if is_traceable_wrapper_subclass(t):
attrs, _ctx = t.__tensor_flatten__() # type: ignore[attr-defined]
for attr in attrs:
match getattr(t, attr):
case Tensor() as inner:
sync_functional_tensor(inner)
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
else:
torch._sync(t)
# When subclasses are involved, t here will usually look something like:
# SubclassA(SubclassB(FunctionalTensor(_to_fun_tensor(FakeTensor))))
def from_fun(t: object) -> object:
if isinstance(t, Tensor) and is_traceable_wrapper_subclass(t):
# See Note [Functionalization always runs last]
# This means that if we want to "functionalize" a subclass, we need to ensure that the functional wrapper
# goes at the bottom.
# recurse here, so we can support nested wrapper subclasses
out = transform_subclass(t, lambda _, inner_t: from_fun(inner_t))
torch._mirror_autograd_meta_to(t, out) # type: ignore[attr-defined]
return out
if not isinstance(t, FunctionalTensor):
# quick sanity assert
if isinstance(t, torch.Tensor):
if torch._is_functional_tensor(t): # type: ignore[attr-defined]
raise AssertionError("expected non-functional tensor")
return t
sync_functional_tensor(t)
return torch._from_functional_tensor(t.elem)
def is_fun(t: object) -> TypeGuard[FunctionalTensor | Tensor]:
if isinstance(t, Tensor) and is_traceable_wrapper_subclass(t):
# See Note [Functionalization always runs last]
# This means that if we want to "functionalize" a subclass, we need to ensure that the functional wrapper
# goes at the bottom.
# recurse here, so we can support nested wrapper subclasses
t_attrs, _ = t.__tensor_flatten__() # type: ignore[attr-defined]
got_fun: bool | None = None
for attr in t_attrs:
match getattr(t, attr):
case Tensor() as v:
fun = is_fun(v)
if got_fun is None:
got_fun = fun
elif got_fun != fun:
raise AssertionError(
"mixed functional/non-functional inner tensors"
)
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return got_fun or False
return isinstance(t, FunctionalTensor)
# t here is either
# (1) A FunctionalTensor(_to_functional_tensor(FakeTensor))
# (2) A traceable tensor subclass that holds a FunctionalTensor
# (3) Not a tensor
def has_data_mutation(t: object) -> bool:
if is_traceable_wrapper_subclass(t):
attrs, _ = t.__tensor_flatten__()
# A tensor subclass was updated if any of its inner elements were updated
for attr in attrs:
match getattr(t, attr):
case Tensor() as v:
if has_data_mutation(v):
return True
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return False
else:
if isinstance(t, torch.Tensor):
if not isinstance(t, FunctionalTensor):
raise AssertionError(f"expected FunctionalTensor, got {type(t)}")
return torch._functionalize_has_data_mutation(t.elem) # type: ignore[attr-defined]
return False
def are_all_mutations_hidden_from_autograd(t: object) -> bool:
if is_traceable_wrapper_subclass(t):
attrs, _ = t.__tensor_flatten__()
# If all inner elements are mutations hidden from autograd, then it is a mutation hidden from autograd.
for attr in attrs:
match getattr(t, attr):
case Tensor() as v:
if not are_all_mutations_hidden_from_autograd(v):
return False
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return True
elif isinstance(t, torch.Tensor):
if not isinstance(t, FunctionalTensor):
raise AssertionError(f"expected FunctionalTensor, got {type(t)}")
return torch._functionalize_are_all_mutations_hidden_from_autograd(t.elem)
else:
return False
def are_all_mutations_under_no_grad_or_inference_mode(t: torch.Tensor) -> bool:
if is_traceable_wrapper_subclass(t):
attrs, _ = t.__tensor_flatten__()
for attr in attrs:
match getattr(t, attr):
case Tensor() as v:
if not are_all_mutations_under_no_grad_or_inference_mode(v):
return False
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return True
else:
if not isinstance(t, FunctionalTensor):
raise AssertionError(f"expected FunctionalTensor, got {type(t)}")
return torch._functionalize_are_all_mutations_under_no_grad_or_inference_mode(
t.elem
)
def was_inductor_storage_resized(t: object) -> bool:
if is_traceable_wrapper_subclass(t):
attrs, _ = t.__tensor_flatten__()
for attr in attrs:
match getattr(t, attr):
case Tensor() as v:
if was_inductor_storage_resized(v):
raise RuntimeError(
f"storage resizing is not supported on tensor subclass: {type(t)}"
)
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return False
elif not isinstance(t, torch.Tensor):
return False
else:
if not isinstance(t, FunctionalTensor):
raise AssertionError(f"expected FunctionalTensor, got {type(t)}")
return torch._functionalize_was_inductor_storage_resized(t.elem)
# f_arg here is either
# (1) A FunctionalTensor(_to_functional_tensor(FakeTensor))
# (2) A traceable tensor subclass that holds a FunctionalTensor
# (3) Not a tensor
# Assumption: arg promises to be the "original" tensor wrapped by f_arg
# Note: "storage mutations" coming from set_() are a type of metadata mutation. So:
# - check_only_storage_mutation=True: only return true if there was a storage mutation
# - check_only_storage_mutation=Flse: return true if there was any metadata mutation (including a storage mutation)
def has_metadata_mutation(
f_arg: object, arg: object, *, check_only_storage_mutation: bool
) -> bool:
if is_traceable_wrapper_subclass(f_arg):
attrs, _ = f_arg.__tensor_flatten__()
# A tensor subclass was updated if any of its inner elements were updated
for attr in attrs:
match getattr(f_arg, attr):
case Tensor():
f_inner_t = getattr(f_arg, attr)
inner_t = getattr(arg, attr)
if has_metadata_mutation(
f_inner_t,
inner_t,
check_only_storage_mutation=check_only_storage_mutation,
):
return True
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return False
else:
if not isinstance(f_arg, torch.Tensor):
if isinstance(arg, torch.Tensor):
raise AssertionError(
f"f_arg is not a Tensor but arg is: {type(f_arg)} vs {type(arg)}"
)
return False
if not isinstance(f_arg, FunctionalTensor):
raise AssertionError(
f"expected FunctionalTensor for f_arg, got {type(f_arg)}"
)
if not isinstance(arg, FakeTensor):
raise AssertionError(f"expected FakeTensor for arg, got {type(arg)}")
arg_after = torch._from_functional_tensor(f_arg.elem)
# This is true if the current tensor experienced at least one set_() call
maybe_storage_changed = torch._functionalize_was_storage_changed(f_arg.elem) # type: ignore[attr-defined]
# However, multiple set_() calls can cancel out. So we also check whether the
# storage of the tensor has changed.
# Note: if an input experienced two set_() calls that cancel out, **and**
# it experiences an data mutation, we pessimistically think that the set_()
# call is necessary here. We could in theory fix this, but this will
# hopefully never happen in user code, and is not needed for fsdp.
if is_sparse_any(arg):
# TODO:add sparse tensors support to functionalization
same_storages = False
else:
same_storages = StorageWeakRef(arg.untyped_storage()) == StorageWeakRef(
arg_after.untyped_storage()
)
has_storage_metadata_mutation = maybe_storage_changed and not same_storages
if check_only_storage_mutation:
return has_storage_metadata_mutation
# storage metadata mutation is a type of metadata mutation, so return true if we saw one
if has_storage_metadata_mutation:
return True
maybe_metadata_mutated = torch._functionalize_has_metadata_mutation(f_arg.elem) # type: ignore[attr-defined]
# This is true if the current tensor experienced at least one metadata mutation.
# So if false, we know there was no metadata mutation
if not maybe_metadata_mutated:
return False
# However, multi metadata mutations can cancel out.
# So we also check if the concrete sizes/strides on the tensor have changed.
same_sizes = arg.shape == arg_after.shape
same_strides = arg.stride() == arg_after.stride()
same_offsets = arg.storage_offset() == arg_after.storage_offset()
has_metadata_mutation_ = maybe_metadata_mutated and not (
same_sizes and same_strides and same_offsets
)
# We consider a tensor to have been metadata mutated if its storage was mutated through a set_() call.
return has_metadata_mutation_
def gen_alias_from_base(
aliased_base_tensor: Tensor,
target_meta_tensor: Tensor,
target_requires_grad: bool,
target_view_meta_sequence: ViewMetaSequence | None = None,
*,
replay_views: bool,
) -> Tensor:
# Patch the correct requires_grad field of the output tensor, depending on whether:
# (i) the reconstructed output (out) was came from a tensor that requires grad or not;
# and (ii) the concrete returned output does require grad or not.
def patch_requires_grad(out: Tensor) -> Tensor:
if aliased_base_tensor.requires_grad and not target_requires_grad:
out = out.detach()
elif not aliased_base_tensor.requires_grad and target_requires_grad:
out.requires_grad_(True)
return out
# If provided, use the target functional tensor for replaying the views.
#
# In summary, we use the fact that FunctionalTensorWrapper saves the view
# functions applied to itself (collected during functionalization) so as
# to replay them (view functions) on the aliased_base_tensor.
if (
replay_views
and target_view_meta_sequence is not None
and not any(vm.has_symbolic_inputs for vm in target_view_meta_sequence.sequence)
):
out = _functionalization.apply_view_meta_sequence(
aliased_base_tensor, target_view_meta_sequence.sequence
)
# If re-applying the ViewMeta sequence succeeded, there should be no more
# problems going forward. We just check we got to the target shape and
# patch requires_grad flag.
if out.shape != target_meta_tensor.shape:
raise AssertionError(
"incorrect out shape after application of ViewMeta sequence: "
f"{tuple(out.shape)} (actual) vs {tuple(target_meta_tensor.shape)} (expected)"
)
return patch_requires_grad(out)
# Try to do view-replay if possible.
# fall back to .as_strided() if we can't.
if target_meta_tensor._base is not None:
# The base that we want to replay our view off of might have a different shape than the view's original base.
b = target_meta_tensor._base
abt = aliased_base_tensor
# Don't unnecessarily call as_strided if nothing changed; as_strided's
# backward is poorly implemented and slow
if abt is not b and (
abt.size() != b.size()
or abt.stride() != b.stride()
or abt.storage_offset() != b.storage_offset()
):
reshaped_base_tensor = aliased_base_tensor.as_strided(
b.size(), b.stride(), b.storage_offset()
)
else:
reshaped_base_tensor = aliased_base_tensor
out = target_meta_tensor._view_func(reshaped_base_tensor) # type: ignore[attr-defined]
# This shape mismatch can happen due to a bug in inplace/view handling in autograd.
# Try putting a breakpoint here and running
# `test/functorch/test_aotdispatch TestAOTAutograd.test_output_all_alias_types`
# Also, https://github.com/pytorch/pytorch/issues/49825
#
# As a stopgap, we'll fall back to as_strided.
if out is not None and out.shape == target_meta_tensor.shape:
return patch_requires_grad(out)
size = target_meta_tensor.size()
stride = target_meta_tensor.stride()
storage_offset = target_meta_tensor.storage_offset()
if aliased_base_tensor.is_complex() and not target_meta_tensor.is_complex():
aliased_out = torch.view_as_real(aliased_base_tensor).as_strided(
size, stride, storage_offset
)
elif not aliased_base_tensor.is_complex() and target_meta_tensor.is_complex():
aliased_out = torch.view_as_complex(aliased_base_tensor).as_strided(
size, stride, storage_offset
)
else:
aliased_out = aliased_base_tensor.as_strided(size, stride, storage_offset)
# For outputs aliasing inputs, we need to check if the requires-gradness has changed.
aliased_out = patch_requires_grad(aliased_out)
# For outputs aliasing inputs, we need to check if the dtype has changed.
# as_strided() is the "most generic" view, but it does not cover cross-dtype views
if aliased_out.dtype != target_meta_tensor.dtype:
aliased_out = aliased_out.view(target_meta_tensor.dtype)
return aliased_out
def has_same_metadata(t1: Tensor, t2: Tensor) -> bool:
return (
guard_or_false(sym_eq(t1.size(), t2.size()))
and guard_or_false(t1.layout == t2.layout)
and (
is_sparse_any(t1)
or (
guard_or_false(sym_eq(t1.stride(), t2.stride()))
and guard_or_false(t1.storage_offset() == t2.storage_offset())
)
)
and t1.is_conj() == t2.is_conj()
and t1.is_neg() == t2.is_neg()
)
@dataclass(frozen=True)
class MetadataKey:
"""
This should be equal whenever has_same_metadata would return True
"""
size: tuple[SymIntEqByExpr, ...]
layout: torch.layout
is_sparse: bool
# these are empty when is_sparse
stride: tuple[SymIntEqByExpr, ...] | None
storage_offset: SymIntEqByExpr | None
is_conj: bool
is_neg: bool
@staticmethod
def make(t: Tensor) -> MetadataKey:
is_sparse = is_sparse_any(t)
return MetadataKey(
size=tuple(SymIntEqByExpr(s) for s in t.size()),
layout=t.layout,
is_sparse=is_sparse,
stride=None if is_sparse else tuple(SymIntEqByExpr(s) for s in t.stride()),
storage_offset=None if is_sparse else SymIntEqByExpr(t.storage_offset()),
is_conj=t.is_conj(),
is_neg=t.is_neg(),
)
# ViewMeta sequence wrapper for equality comparisons.
#
# Even though we can compare each ViewMeta instance, we compare the resulting
# tensor metadata, instead. That's because the creation of synthetic bases + the
# re-generation of input views might end-up creating a different sequence of
# ViewMeta that is semantically equivalent. i.e. gets to a tensor with the same
# metadata.
#
# Therefore, we store what the end result should look like as serializable
# metadata.
#
# When logging, this class should look like:
#
# ViewMetaSequence(view, select_int, slice_Tensor)
#
# i.e. a parenthesized list of view operations within that ViewMeta sequence.
class ViewMetaSequence:
def __init__(self, tensor: FunctionalTensor) -> None:
if not torch._is_functional_tensor(tensor.elem):
raise AssertionError("expected tensor.elem to be a functional tensor")
self.sequence = _functionalization.get_view_meta_sequence(tensor.elem)
self.metadata = MetadataKey.make(tensor)
def __repr__(self) -> str:
suffix = len("_ViewMeta")
types = ", ".join(type(vm).__name__[:-suffix] for vm in self.sequence)
return f"ViewMetaSequence({types})"
def __eq__(self, other: object) -> bool:
# If other is None, then it probably means that we weren't able to recreate
# the ViewMeta sequence. One example is when we update the view metadata by
# calling: create_synthetic_base_metadata.
if other is None:
return True
# Comparison against any other type is not implemented.
if not isinstance(other, ViewMetaSequence):
return NotImplemented
return self.metadata == other.metadata
# new_arg and arg here are either:
# (1) both a FakeTensor
# (2) both a traceable tensor subclass that holds a FakeTensor
# Pre-condition: the two args are the "old" and "new" inputs from running functionalization.
# When we run functionalization and wrap our inputs into FunctionalTensors,
# we can detect whether or not an input was mutated by checking to see if the inner tensor has changed
#
# Normally it would be enough just to check if arg is new_arg, which is normally enough for functionalization
# to confirm that inputs were not mutated when running the user's model with functionalization on.
# But when we have subclass inputs, we can't rely on that:
# `from_fun(to_fun(x)) is x` will return False, because the call to `from_fun` constructs
# a brand new subclass instance: we are calling __tensor_unflatten__, and going
# from Subclass(FakeTensor) to Subclass(FunctionalTensor(FakeTensor))
def was_tensor_updated(arg: torch.Tensor, new_arg: torch.Tensor) -> bool:
if is_traceable_wrapper_subclass(arg):
if not is_traceable_wrapper_subclass(new_arg):
raise AssertionError(
f"expected new_arg to be traceable wrapper subclass, got {type(new_arg)}"
)
attrs, _ = arg.__tensor_flatten__()
new_attrs, _ = new_arg.__tensor_flatten__()
if attrs != new_attrs:
raise AssertionError(f"attrs mismatch: {attrs} != {new_attrs}")
# A tensor subclass was updated if any of its inner elements were updated
for attr in attrs:
match getattr(arg, attr):
case Tensor() as v:
if was_tensor_updated(v, getattr(new_arg, attr)):
return True
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return False
else:
return arg is not new_arg
# new_arg and arg here are either:
# (1) both a FakeTensor
# (2) both a traceable tensor subclass that holds a FakeTensor
# Pre-condition: the two args are the "old" and "new" inputs from running functionalization.
# When we run functionalization and wrap our inputs into FunctionalTensors,
# we can detect whether or not an input was mutated by checking to see if the inner tensor has changed,
# but shares storage with the old input
def was_tensor_metadata_updated(arg: Any, new_arg: Any) -> bool:
if is_traceable_wrapper_subclass(arg):
if not is_traceable_wrapper_subclass(new_arg):
raise AssertionError(
f"expected new_arg to be traceable wrapper subclass, got {type(new_arg)}"
)
attrs, _ = arg.__tensor_flatten__()
new_attrs, _ = new_arg.__tensor_flatten__()
if attrs != new_attrs:
raise AssertionError(f"attrs mismatch: {attrs} != {new_attrs}")
# A tensor subclass was updated if any of its inner elements were updated
for attr in attrs:
match getattr(arg, attr):
case Tensor() as v:
if was_tensor_metadata_updated(v, getattr(new_arg, attr)):
return True
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
return False
else:
return arg is not new_arg and StorageWeakRef(
arg.untyped_storage()
) == StorageWeakRef(new_arg.untyped_storage())
# Returns the number of detected copy_
def _is_functional_graph(fx_g: torch.fx.Graph) -> tuple[str | None, int]:
allowed_mutation_ops = [
torch.ops.aten.copy_.default,
torch.ops.aten.set_.source_Tensor,
]
if hasattr(torch.ops.fsdp, "copy_"):
allowed_mutation_ops.append(torch.ops.fsdp.copy_.default)
placeholders = set()
mutation_count = 0
# NB: It would also be nice to verify that the mutations all happen at the
# end, but we also do some administrative views after mutations so this
# isn't actually true. (TODO: Could this cause problems for Inductor?)
error = None
for n in fx_g.nodes:
if n.op == "placeholder":
placeholders.add(n)
if isinstance(n.target, torch._ops.OpOverload):
if n.target in allowed_mutation_ops:
# Can only copy_/set_ into an input
# this is mostly a hack to avoid failing XLA tests.
# See https://github.com/pytorch/pytorch/pull/122434#issuecomment-2101012113
if "set_buffer_donor_" not in str(n.args[0]):
if n.args[0] not in placeholders:
error = f"n={str(n)}, n.args[0]={str(n.args[0])}, placeholders={str(placeholders)}, graph={str(fx_g)}"
mutation_count += 1
else:
if n.target._schema.is_mutable:
error = f"aot_autograd expected to have an entirely functional graph, but found {n.format_node()}"
return error, mutation_count
def assert_functional_graph(fx_g: torch.fx.Graph) -> int:
error, mutation_count = _is_functional_graph(fx_g)
if error is not None:
raise AssertionError(error)
return mutation_count
def propagate_input_mutation_stacktraces(fx_g: torch.fx.Graph) -> None:
placeholders = set()
for n in fx_g.nodes:
if n.op == "placeholder":
placeholders.add(n)
if isinstance(n.target, torch._ops.OpOverload):
if n.target is torch.ops.aten.copy_.default:
# Can only copy_ into an input, and can only do so once
if "set_buffer_donor_" not in str(n.args[0]):
if n.args[0] not in placeholders:
raise AssertionError(
f"n={str(n)}, n.args[0]={str(n.args[0])}, placeholders={str(placeholders)}, graph={str(fx_g)}"
)
placeholders.remove(n.args[0])
copy_from_node = n.args[1]
# Pre-condition: every node has a "stack_trace" field in its meta,
# but copy_() nodes do not (since we manually added them during functionalization).
# Instead, we manually propagate here.
if "stack_trace" in copy_from_node.meta:
n.meta["stack_trace"] = copy_from_node.meta["stack_trace"]
def _check_if_mutation_can_be_in_graph(
keep_input_mutations: bool,
mutates_data: bool,
mutates_metadata: bool,
mutations_hidden_from_autograd: bool,
mutations_under_no_grad_or_inference_mode: bool,
mutates_storage_metadata: bool,
mutation_inductor_storage_resize: bool,
requires_grad: bool,
) -> bool:
if keep_input_mutations:
in_graph = (
mutates_data or mutates_storage_metadata or mutation_inductor_storage_resize
) and (
(not mutates_metadata and not requires_grad)
or mutations_hidden_from_autograd
or mutations_under_no_grad_or_inference_mode
)
else:
in_graph = False
# See Note [set_() Input Mutations in AOTAutograd]
# If there was a `set_()`, we require that all mutations were under no_grad,
# so we can (safely) emit the set_() in the graph at runtime
# resize_() gets the same treatment
if mutation_inductor_storage_resize or mutates_storage_metadata:
op_name = "resize_" if mutation_inductor_storage_resize else "set_"
if not in_graph:
raise AssertionError(f"""\
Encountered a {op_name} on a graph input, but the input has other mutations that we cannot
keep in the graph. This is not supported today. Current state:
keep_input_mutations={keep_input_mutations}
mutates_data={mutates_data}
mutates_metadata={mutates_metadata}
mutations_hidden_from_autograd={mutations_hidden_from_autograd}
mutations_under_no_grad_or_inference_mode={mutations_under_no_grad_or_inference_mode}
mutation_inductor_storage_resize={mutation_inductor_storage_resize}
requires_grad={requires_grad}""")
return in_graph
@@ -0,0 +1,324 @@
"""
This module contains utility functions for working with joint FX graphs with descriptors
that are produced by AOTAutograd. They will NOT work on generic FX graphs. See also
:func:`torch._functorch.aot_autograd.aot_export_joint_with_descriptors`. We also
recommend reading :mod:torch._functorch._aot_autograd.descriptors`.
"""
from typing import NoReturn
import torch.fx as fx
from .descriptors import (
AOTInput,
AOTOutput,
BufferAOTInput,
DifferentiableAOTInput,
DifferentiableAOTOutput,
GradAOTOutput,
ParamAOTInput,
PlainAOTInput,
PlainAOTOutput,
SubclassGetAttrAOTInput,
SubclassGetAttrAOTOutput,
TangentAOTInput,
)
def _raise_autograd_subclass_not_implemented(
n: fx.Node, desc: AOTInput | AOTOutput
) -> NoReturn:
raise RuntimeError(
"Subclasses are currently not supported by this function, but a desugared subclass input "
f"was found at {n} ({desc}). The problem is "
"that there may not necessarily be a 1-1 correspondence between primals/tangents/outputs/grads "
"when subclasses are involved: for example, the primal might be a plain tensor "
"but the tangent a tensor subclass that desugared into multiple plain tensors. "
"It is not clear what exactly you would like this function to do in this case "
"(Collect all nodes for the subclass together? Match up the inner nodes if "
"subclasses match exactly?) If you have a concrete use case, please file an "
"issue so we can understand it and design an API that works for your case."
)
def get_all_input_and_grad_nodes(
g: fx.Graph,
) -> dict[DifferentiableAOTInput, tuple[fx.Node, fx.Node | None]]:
"""
Given a joint graph with descriptors (meta['desc'] on placeholders and
output), returns the node for every input and its corresponding grad
output node if it exists. These tuples are in a dict that is indexed by
the AOTInput descriptor that describes the input.
NB: *all* forward tensor inputs are returned, including non-differentiable
inputs (which simply have a None grad), so it is safe to use this function
to perform operations on all inputs. (Non-tensor inputs like symbolic
integers, tokens or RNG state are NOT traversed by this function.)
Args:
g: The FX joint graph with descriptors
Returns:
A dictionary mapping each DifferentiableAOTInput descriptor to a tuple
containing:
- The input node itself
- The grad (output) node if it exists, None otherwise
Raises:
RuntimeError: If the joint graph has subclass tensor inputs/outputs; this
is not supported by API as there is not necessarily a 1-1 correspondence
between inputs and grads when subclasses are involved.
"""
input_index: dict[DifferentiableAOTInput, tuple[fx.Node, fx.Node | None]] = {}
for n in g.nodes:
if n.op == "placeholder":
desc = n.meta["desc"]
# Skip inputs that cannot possibly be differentiable
if not isinstance(desc, DifferentiableAOTInput):
continue
if isinstance(desc, SubclassGetAttrAOTInput):
_raise_autograd_subclass_not_implemented(n, desc)
input_index[desc] = (n, None)
elif n.op == "output":
if "desc" not in n.meta:
raise AssertionError(f"'desc' not in n.meta for {n}: {n.meta}")
desc = n.meta["desc"]
for sub_n, sub_desc in zip(n.args[0], desc):
if isinstance(sub_desc, SubclassGetAttrAOTOutput):
_raise_autograd_subclass_not_implemented(sub_n, sub_desc)
if isinstance(sub_desc, GradAOTOutput):
inp, grad = input_index[sub_desc.grad_of]
if grad is not None:
raise AssertionError(
f"grad already set for {sub_n}, {sub_desc}, {input_index}"
)
input_index[sub_desc.grad_of] = (inp, sub_n)
return input_index
def get_all_output_and_tangent_nodes(
g: fx.Graph,
) -> dict[DifferentiableAOTOutput, tuple[fx.Node, fx.Node | None]]:
"""Get all output nodes and their corresponding tangent nodes from a joint graph.
Similar to get_all_input_and_grad_nodes, but returns output nodes paired with
their tangent nodes (if they exist). This function traverses the graph to find
all differentiable outputs and matches them with their corresponding tangent
inputs used in forward-mode autodiff.
NB: *all* forward tensor output sare turned, including non-differentiable outputs,
so you can use this function to perform operations on all outputs.
Args:
g: The FX joint graph with descriptors
Returns:
A dictionary mapping each DifferentiableAOTOutput descriptor to a tuple
containing:
- The output node itself
- The tangent (input) node if it exists, None otherwise
Raises:
RuntimeError: If the joint graph has subclass tensor inputs/outputs; this
is not supported by API as there is not necessarily a 1-1 correspondence
between outputs and tangents when subclasses are involved.
"""
output_index: dict[DifferentiableAOTOutput, tuple[fx.Node, fx.Node | None]] = {}
for n in g.nodes:
if n.op == "output":
desc = n.meta["desc"]
for sub_n, sub_d in zip(n.args[0], desc):
# Skip outputs that cannot possibly be differentiable
if not isinstance(sub_d, DifferentiableAOTOutput):
continue
if isinstance(sub_d, SubclassGetAttrAOTOutput):
_raise_autograd_subclass_not_implemented(sub_n, sub_d)
output_index[sub_d] = (sub_n, None)
for n in g.nodes:
if n.op == "placeholder":
desc = n.meta["desc"]
if isinstance(desc, SubclassGetAttrAOTInput):
_raise_autograd_subclass_not_implemented(n, desc)
if isinstance(desc, TangentAOTInput):
out, tangent = output_index[desc.output]
if tangent is not None:
raise AssertionError(
f"tangent already set for {n}, {desc}, {output_index}"
)
output_index[desc.output] = (out, n)
return output_index
def get_param_and_grad_nodes(
graph: fx.Graph,
) -> dict[ParamAOTInput, tuple[fx.Node, fx.Node | None]]:
"""Get parameter nodes and their corresponding gradient nodes from a joint graph.
Args:
graph: The FX joint graph with descriptors
Returns:
A dictionary mapping each ParamAOTInput descriptor to a tuple containing:
- The parameter input node
- The gradient (output) node if it exists, None otherwise
"""
return {
desc: (n, g)
for desc, (n, g) in get_all_input_and_grad_nodes(graph).items()
if isinstance(desc, ParamAOTInput)
}
def get_plain_input_and_grad_nodes(
graph: fx.Graph,
) -> dict[PlainAOTInput, tuple[fx.Node, fx.Node | None]]:
"""Get plain input nodes and their corresponding gradient nodes from a joint graph.
Args:
graph: The FX joint graph with descriptors
Returns:
A dictionary mapping each PlainAOTInput descriptor to a tuple containing:
- The plain input node
- The gradient (output) node if it exists, None otherwise
"""
return {
desc: (n, g)
for desc, (n, g) in get_all_input_and_grad_nodes(graph).items()
if isinstance(desc, PlainAOTInput)
}
def get_plain_output_and_tangent_nodes(
graph: fx.Graph,
) -> dict[PlainAOTOutput, tuple[fx.Node, fx.Node | None]]:
"""Get plain output nodes and their corresponding tangent nodes from a joint graph.
Args:
graph: The FX joint graph with descriptors
Returns:
A dictionary mapping each PlainAOTOutput descriptor to a tuple containing:
- The plain output node
- The tangent (input) node if it exists, None otherwise
"""
return {
desc: (n, g)
for desc, (n, g) in get_all_output_and_tangent_nodes(graph).items()
if isinstance(desc, PlainAOTOutput)
}
def _raise_fqn_subclass_not_implemented(
n: fx.Node, desc: AOTInput | AOTOutput
) -> NoReturn:
raise RuntimeError(
"Subclasses are currently not supported by this function, but a desugared subclass input "
f"was found at {n} ({desc}). The problem is "
"that there may not necessarily be a 1-1 correspondence between a FQN and a plain tensor "
"when subclasses are involved: for example, a parameter that is a subclass "
"would desugar into multiple plain tensors, which we can't uniquely assign the "
"FQN to. It's not clear what you want the API to do in this case: do you want to "
"instead return a struct of nodes showing how to assemble the subclass? But you "
"don't (directly) have the metadata for the subclass? If you have a concrete use "
"case, please file an issue so we can understand it and design an API that works for your case."
)
def get_named_param_nodes(graph: fx.Graph) -> dict[str, fx.Node]:
"""Get parameter nodes mapped by their fully qualified names.
This function traverses the graph to find all parameter input nodes and
returns them in a dictionary where keys are the parameter names (FQNs)
and values are the corresponding FX nodes.
Args:
graph: The FX joint graph with descriptors
Returns:
A dictionary mapping parameter names (str) to their corresponding FX nodes.
Raises:
RuntimeError: If subclass tensors are encountered (not yet supported), as
with subclasses a FQN does not necessarily map to a single plain tensor.
"""
r = {}
for n in graph.nodes:
if n.op == "placeholder":
desc = n.meta["desc"]
if isinstance(desc, SubclassGetAttrAOTInput):
_raise_fqn_subclass_not_implemented(n, desc)
elif isinstance(desc, ParamAOTInput):
r[desc.target] = n
return r
def get_named_buffer_nodes(graph: fx.Graph) -> dict[str, fx.Node]:
"""Get buffer nodes mapped by their fully qualified names.
This function traverses the graph to find all buffer input nodes and
returns them in a dictionary where keys are the buffer names (FQNs)
and values are the corresponding FX nodes.
Args:
graph: The FX joint graph with descriptors
Returns:
A dictionary mapping buffer names (str) to their corresponding FX nodes.
Raises:
RuntimeError: If subclass tensors are encountered (not yet supported), as
with subclasses a FQN does not necessarily map to a single plain tensor.
"""
r = {}
for n in graph.nodes:
if n.op == "placeholder":
desc = n.meta["desc"]
if isinstance(desc, SubclassGetAttrAOTInput):
_raise_fqn_subclass_not_implemented(n, desc)
elif isinstance(desc, BufferAOTInput):
r[desc.target] = n
return r
def get_param_nodes(graph: fx.Graph) -> list[fx.Node]:
"""Get all parameter nodes from a graph as a list.
You can rely on this providing the correct order of parameters you need
to feed into the joint graph (at the very beginning of the argument list,
before buffers).
Args:
graph: The FX joint graph with descriptors
Returns:
A list of FX nodes representing all parameters in the graph.
Raises:
RuntimeError: If subclass tensors are encountered (not yet supported), as
it is not clear if you wanted each individual constituent piece of the
subclasses, or have them grouped up in some way.
"""
return list(get_named_param_nodes(graph).values())
def get_buffer_nodes(graph: fx.Graph) -> list[fx.Node]:
"""Get all buffer nodes from a graph as a list.
You can rely on this providing the correct order of buffers you need
to feed into the joint graph (after parameters).
Args:
graph: The FX joint graph with descriptors
Returns:
A list of FX nodes representing all buffers in the graph.
Raises:
RuntimeError: If subclass tensors are encountered (not yet supported), as
it is not clear if you wanted each individual constituent piece of the
subclasses, or have them grouped up in some way.
"""
return list(get_named_buffer_nodes(graph).values())
@@ -0,0 +1,585 @@
"""
This module dispatches the graphs to either the forward-only or joint compilation
pathways, taking into account the AOTConfig and the collected ViewAndMutationMetadata.
"""
import contextlib
import dataclasses
from collections.abc import Callable
from typing import Any
import torch
import torch.utils._pytree as pytree
import torch.utils.dlpack
from torch._dispatch.python import enable_python_dispatcher
from torch._dynamo.utils import detect_fake_mode, lazy_format_graph_code
from torch._logging import getArtifactLogger, trace_structured
from torch._subclasses.functional_tensor import FunctionalTensorMode
from torch.fx.experimental.proxy_tensor import make_fx
from torchgen.utils import dataclass_repr
from .. import config
from .descriptors import AOTInput, BackwardTokenAOTInput
from .functional_utils import (
assert_functional_graph,
propagate_input_mutation_stacktraces,
)
from .graph_capture_wrappers import (
aot_dispatch_subclass,
create_functionalized_fn,
create_joint,
fn_input_mutations_to_outputs,
fn_prepped_for_autograd,
handle_effect_tokens_fn,
)
from .schemas import AOTConfig, FxValue, SubclassMeta, TraceFn, ViewAndMutationMeta
from .streams import (
assign_backward_streams,
assign_epilogue_copy_streams,
insert_backward_syncs,
populate_fw_metadata_with_stream_indices,
sync_deallocations,
wrap_all_sync_nodes_with_control_deps,
)
from .utils import (
call_and_expect_output_descs,
copy_fwd_metadata_to_bw_nodes,
fn_wrappers,
register_buffer_assignment_hook,
root_module_when_exporting_non_strict,
simple_wraps,
unlift_tokens,
)
aot_graphs_log = getArtifactLogger(__name__, "aot_graphs")
def _extract_tangent_source_stack_traces(
fx_g: torch.fx.GraphModule,
fw_metadata: ViewAndMutationMeta,
) -> None:
from .descriptors import PlainAOTOutput, TangentAOTInput
if not fw_metadata.traced_tangents_descs:
return
output_node = list(fx_g.graph.nodes)[-1]
all_outputs = output_node.args[0]
stack_traces: list[str | None] = []
got_one = False
for desc in fw_metadata.traced_tangents_descs:
stack_trace = None
if isinstance(desc, TangentAOTInput):
output_desc = desc.output
if isinstance(output_desc, PlainAOTOutput) and output_desc.idx < len(
all_outputs
):
output_arg = all_outputs[output_desc.idx]
if isinstance(output_arg, torch.fx.Node):
stack_trace = output_arg.meta.get("stack_trace", None)
got_one = True
stack_traces.append(stack_trace)
if got_one:
fw_metadata.tangent_source_stack_traces = stack_traces
def _create_graph(
f: Callable[..., Any],
args: list[torch.Tensor],
args_descs: list[AOTInput]
| None = None, # keep compat with old clients; maybe we should split into two impls
*,
aot_config: AOTConfig,
) -> torch.fx.GraphModule:
# FunctionalTensorMode must be enabled here.
# See Note [Accessing .grad_fn on FunctionalTensor]
out_descs = None
if args_descs is None:
inner_f = f
else:
@simple_wraps(f)
def inner_f(*args: Any) -> Any:
nonlocal out_descs
if out_descs is not None:
raise AssertionError("out_descs must be None")
out, out_descs = call_and_expect_output_descs(f, args)
return out
if aot_config.disable_functionalization:
ctx = contextlib.nullcontext()
else:
ctx = FunctionalTensorMode( # type: ignore[assignment]
pre_dispatch=aot_config.pre_dispatch,
export=aot_config.is_export,
# Allow token discovery for joint fn tracing as tokens can be used in backward.
_allow_token_discovery=True,
)
with (
enable_python_dispatcher(),
ctx,
):
fx_g = make_fx(
inner_f,
decomposition_table=aot_config.decompositions,
record_module_stack=True,
pre_dispatch=aot_config.pre_dispatch,
_disable_torch_fn_metadata_mode=aot_config._disable_torch_fn_metadata_mode,
)(*args)
if args_descs is not None:
flat_args_descs, _ = pytree.tree_flatten(args_descs)
flat_out_descs, _ = pytree.tree_flatten(out_descs)
# Unfortunately, flat_args_descs is not guaranteed to match the
# number of actual arguments that show up on the FX graph.
# Specifically, allow_token_discovery=True means that we will
# silently add extra token arguments to the backwards graph.
#
# Although there are a few ways to detect what these tokens are,
# we are going to settle for something dodgy but simple to
# implement: match tangents_token placeholders specifically,
# as these are the only placeholders that are created by token
# discovery (NB: there is NO other code that treats this name
# as load bearing, so this is a bit naughty!)
#
# I originally wanted to detect tokens in exactly the same way
# that they are detected at normal runtime, but to be honest
# the normal runtime detection is pretty strange: it seems the
# backward tokens are not reliably at the end of the argument list
# but *precede* the RNG arguments (I don't understand why this is
# the case). And in unlift_tokens, token arguments are detected
# by seeing if they feed into an effects call! Dastardly. Why
# didn't we just introduce a new type.
i = 0
j = 0
for n in fx_g.graph.nodes:
if n.op == "placeholder":
if n.name.startswith("tangents_token"):
n.meta["desc"] = BackwardTokenAOTInput(j)
j += 1
else:
if i >= len(flat_args_descs):
raise AssertionError(
f"i={i} >= len(flat_args_descs)={len(flat_args_descs)}: "
f"fn_wrappers={fn_wrappers(inner_f)}, "
f"placeholders={[n for n in fx_g.graph.nodes if n.op == 'placeholder']}"
)
n.meta["desc"] = flat_args_descs[i]
i += 1
elif n.op == "output":
n.meta["desc"] = flat_out_descs
return fx_g
# TODO: Refactor the following code so detach() persists item_memo
def _detach_and_copy_item_memo(t: torch.Tensor) -> torch.Tensor:
detached_t = t.detach()
if hasattr(t, "item_memo"):
# pyrefly: ignore[missing-attribute]
detached_t.item_memo = t.item_memo
return detached_t
@dataclasses.dataclass
class _GraphCaptureTracingResult:
fn_to_trace: Callable[..., Any]
flat_args: Any
flat_args_descs: Any
maybe_subclass_meta: SubclassMeta | None
def _detach_traced_inputs(flat_args: Any) -> Any:
if detect_fake_mode():
detach_tensor = _detach_and_copy_item_memo
else:
def detach_tensor(t: torch.Tensor) -> torch.Tensor:
return t.detach()
return pytree.tree_map_only(torch.Tensor, detach_tensor, flat_args)
def _prepare_graph_capture_tracing(
fn_to_trace: Callable[..., Any],
flat_args: Any,
flat_args_descs: Any,
flat_fn: TraceFn,
*,
fw_metadata: ViewAndMutationMeta,
aot_config: AOTConfig,
trace_joint: bool,
joint_fn_handle: Any | None = None,
) -> _GraphCaptureTracingResult:
if aot_config.disable_functionalization:
updated_flat_args, updated_flat_args_descs = flat_args, flat_args_descs
else:
fn_to_trace, updated_flat_args, updated_flat_args_descs = (
create_functionalized_fn(
fn_to_trace,
flat_args,
flat_args_descs,
meta=fw_metadata,
aot_config=aot_config,
trace_joint=trace_joint,
joint_fn_handle=joint_fn_handle,
)
)
subclass_tracing_info = aot_dispatch_subclass(
fn_to_trace,
updated_flat_args,
updated_flat_args_descs,
is_joint_structure=trace_joint,
meta=fw_metadata,
fw_only=flat_fn,
)
fn_to_trace = subclass_tracing_info.plain_tensor_trace_fn
updated_flat_args = subclass_tracing_info.plain_tensor_args
updated_flat_args_descs = subclass_tracing_info.plain_tensor_args_descs
if not aot_config.disable_functionalization:
fn_to_trace, updated_flat_args, updated_flat_args_descs = (
handle_effect_tokens_fn(
fn_to_trace,
updated_flat_args,
updated_flat_args_descs,
meta=fw_metadata,
trace_joint=trace_joint,
)
)
return _GraphCaptureTracingResult(
fn_to_trace=fn_to_trace,
flat_args=updated_flat_args,
flat_args_descs=updated_flat_args_descs,
maybe_subclass_meta=subclass_tracing_info.maybe_subclass_meta,
)
def _create_graph_and_save_traced_inputs(
fn_to_trace: Callable[..., Any],
flat_args: Any,
flat_args_descs: Any,
*,
aot_config: AOTConfig,
) -> tuple[torch.fx.GraphModule, Any]:
saved_flat_args = _detach_traced_inputs(flat_args)
return (
_create_graph(fn_to_trace, flat_args, flat_args_descs, aot_config=aot_config),
saved_flat_args,
)
def aot_dispatch_base_graph(
flat_fn: TraceFn,
flat_args: list[FxValue],
flat_args_descs: list[AOTInput],
aot_config: AOTConfig,
*,
fw_metadata: ViewAndMutationMeta,
) -> tuple[torch.fx.GraphModule, list[FxValue], list[AOTInput], SubclassMeta | None]:
# aot_dispatch_base requires functionalization, but doesn't need to handle as many cases as the autograd case.
# The cases that aot_dispatch_base doesn't need to handle include:
# - outputs that are aliases of graph intermediates
# - outputs that are aliases of graph inputs
# While cases that it does need to handle include:
# - input mutations (including when inputs are aliases of each other)
# - input metadata mutations
fn_to_trace = fn_input_mutations_to_outputs(
flat_fn,
flat_args_descs,
fw_metadata,
keep_data_input_mutations=aot_config.keep_inference_input_mutations,
)
# TODO: replace with AOTDispatchSubclassWrapper once we refactor
# fn_input_mutations_to_outputs and create_functionalized_fn
# into CompilerWrappers.
tracing_state = _prepare_graph_capture_tracing(
fn_to_trace,
flat_args,
flat_args_descs,
flat_fn,
fw_metadata=fw_metadata,
aot_config=aot_config,
trace_joint=False,
)
fn_to_trace = tracing_state.fn_to_trace
updated_flat_args_subclasses_desugared = tracing_state.flat_args
updated_flat_args_subclasses_desugared_descs = tracing_state.flat_args_descs
maybe_subclass_meta = tracing_state.maybe_subclass_meta
aot_graphs_log.debug(
"aot_config id: %s, fw_metadata=%s,subclass_metadata=%s",
aot_config.aot_id,
fw_metadata,
maybe_subclass_meta,
)
# We track buffer assignments when exporting in non-strict mode.
# (In contrast, strict mode errors on any attribute assignment.)
mod_when_exporting_non_strict = root_module_when_exporting_non_strict(flat_fn)
if aot_config.is_export and mod_when_exporting_non_strict is not None:
# For any buffer that is assigned, we want to associate it to the final proxy node
# that it is assigned to. This node can then be added as a buffer mutation output.
assigned_buffers: dict[str, str] = {}
hook = register_buffer_assignment_hook(
mod_when_exporting_non_strict, assigned_buffers
)
(
fw_module,
saved_updated_flat_args_subclasses_desugared,
) = _create_graph_and_save_traced_inputs(
fn_to_trace,
updated_flat_args_subclasses_desugared,
updated_flat_args_subclasses_desugared_descs,
aot_config=aot_config,
)
saved_updated_flat_args_subclasses_desugared_descs = (
updated_flat_args_subclasses_desugared_descs
)
if aot_config.is_export and mod_when_exporting_non_strict is not None:
# We update metadata to consider any assigned buffers as buffer mutations.
i = len(dict(mod_when_exporting_non_strict.named_parameters()))
for name, _ in mod_when_exporting_non_strict.named_buffers():
if name in assigned_buffers and not fw_metadata.input_info[i].mutates_data: # type: ignore[possibly-undefined]
fw_metadata.input_info[i] = dataclasses.replace(
fw_metadata.input_info[i], mutates_data=True
)
fw_metadata.num_mutated_inp_runtime_indices += 1
i += 1
# We add nodes corresponding to buffer assignments as output nodes in the graph.
add_nodes = []
output_node = list(fw_module.graph.nodes)[-1]
for name in assigned_buffers.values(): # type: ignore[possibly-undefined]
for node in fw_module.graph.nodes:
if node.name == name:
add_nodes.append(node)
node.users[output_node] = None
output_node.args = ((*add_nodes, *output_node.args[0]),)
hook.remove() # type: ignore[possibly-undefined]
# As long as we opted to remove input mutations, then
# there should be *NO* mutating ops in the graph at this point.
if not aot_config.disable_functionalization:
copy_count = assert_functional_graph(fw_module.graph)
assign_epilogue_copy_streams(fw_module)
# Wrap sync nodes with control_deps to prevent reordering
wrap_all_sync_nodes_with_control_deps(fw_module)
# Populate fw_metadata with stream indices from the compiled graph
populate_fw_metadata_with_stream_indices(fw_module, fw_metadata)
fw_module.graph.eliminate_dead_code()
fw_module.recompile()
copy_count2 = assert_functional_graph(fw_module.graph)
propagate_input_mutation_stacktraces(fw_module.graph)
if copy_count != copy_count2:
raise AssertionError(
f"copy_count={copy_count} != copy_count2={copy_count2}"
)
else:
fw_module.graph.eliminate_dead_code()
# See Note [Side-Effectful Tokens in AOTAutograd]
num_tokens = len(fw_metadata.tokens)
if num_tokens != 0 and config.unlift_effect_tokens:
unlift_tokens(fw_module, fw_metadata, aot_config)
saved_updated_flat_args_subclasses_desugared = (
saved_updated_flat_args_subclasses_desugared[num_tokens:]
)
saved_updated_flat_args_subclasses_desugared_descs = (
saved_updated_flat_args_subclasses_desugared_descs[num_tokens:]
)
if aot_config.enable_log:
aot_graphs_log.info(
"%s",
lazy_format_graph_code(
"Forward graph",
fw_module,
aot_config.aot_id,
include_stride=True,
include_device=True,
colored=True,
# For more expanded output set this to True (but can't default
# to this because it affects tests):
expanded_def=False,
),
)
trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "aot_forward_graph_fw_metadata",
"encoding": "string",
},
payload_fn=lambda: dataclass_repr(fw_metadata),
)
if maybe_subclass_meta is not None:
trace_structured(
"artifact",
metadata_fn=lambda: {
"name": "aot_forward_graph_fw_subclass_metadata",
"encoding": "string",
},
payload_fn=lambda: dataclass_repr(maybe_subclass_meta),
)
trace_structured(
"aot_inference_graph",
payload_fn=lambda: fw_module.print_readable(
print_output=False,
include_stride=True,
include_device=True,
expanded_def=True,
),
)
# TODO: should factor this into a separate function for export that always only returns just the graph.
if aot_config.is_export and maybe_subclass_meta is not None:
raise AssertionError(
"aot_export_module does not support tensor subclass inputs for now."
)
return (
fw_module,
saved_updated_flat_args_subclasses_desugared,
saved_updated_flat_args_subclasses_desugared_descs,
maybe_subclass_meta,
)
# Has the precondition that there
# are no duplicate arguments in flat_args (e.g., the same Tensor
# object never shows up twice. However, two tensor inputs MAY alias
# the same storage, so long as they have separate TensorImpls.)
def aot_dispatch_autograd_graph(
flat_fn: TraceFn,
flat_args: list[Any],
flat_args_descs: list[AOTInput],
aot_config: AOTConfig,
*,
fw_metadata: ViewAndMutationMeta,
) -> tuple[
torch.fx.GraphModule,
tuple[list[Any], list[Any]],
tuple[list[AOTInput], list[AOTInput]],
SubclassMeta | None,
]:
# NB: flat_fn here is the original user function (as far as
# aot_module_simplified is concerned)
# traced_tangents corresponds to the set of outputs in the traced forward that should get grad_outputs in the traced backward.
# It includes outputs of the original forward, *and* any updated inputs due to input mutations.
# However, it does *not* include any outputs that are aliases of inputs or intermediates, or any metadata-only input mutations.
joint_inputs = (flat_args, fw_metadata.traced_tangents)
joint_inputs_descs = (flat_args_descs, fw_metadata.traced_tangents_descs)
fn_prepared_for_autograd = fn_prepped_for_autograd(
flat_fn,
flat_args_descs,
fw_metadata,
aot_config,
)
joint_fn_to_trace = create_joint(
fn_prepared_for_autograd, flat_args_descs, aot_config=aot_config
)
# pyrefly: ignore[missing-attribute]
joint_fn_handle = joint_fn_to_trace.handle
# TODO: replace with AOTDispatchSubclassWrapper once we refactor
# fn_input_mutations_to_outputs and create_functionalized_fn
# into CompilerWrappers.
tracing_state = _prepare_graph_capture_tracing(
joint_fn_to_trace,
joint_inputs,
joint_inputs_descs,
flat_fn,
fw_metadata=fw_metadata,
aot_config=aot_config,
trace_joint=True,
joint_fn_handle=joint_fn_handle,
)
joint_fn_to_trace = tracing_state.fn_to_trace
updated_joint_inputs = tracing_state.flat_args
updated_joint_inputs_descs = tracing_state.flat_args_descs
maybe_subclass_meta = tracing_state.maybe_subclass_meta
# When we call _create_graph, this may mutate the metadata of joint
# inputs. But callers are expecting to get the original joint inputs. So
# we make aliases of all the inputs to make sure we have a copy that
# doesn't get modified.
#
# This destroys requires_grad/grad_fn information. However, backends
# beneath AOTAutograd are indifferent to this information, so it doesn't
# matter.
fx_g, saved_updated_joint_inputs = _create_graph_and_save_traced_inputs(
joint_fn_to_trace,
updated_joint_inputs,
updated_joint_inputs_descs,
aot_config=aot_config,
)
# Redundant with the check above, but worth having in case tracing introduced
# a fake tensor. Unlikely.
# See Note: [Fake Modules and AOTAutograd]
torch._dynamo.utils.assert_no_fake_params_or_buffers(fx_g)
# Have to copy before eliminate_dead_code otherwise the
# fw node match might be erased
copy_fwd_metadata_to_bw_nodes(fx_g)
# After copying metadata, assign streams to gradient accumulation nodes
assign_backward_streams(fx_g)
assign_epilogue_copy_streams(fx_g)
# Insert syncs for newly assigned backward streams
insert_backward_syncs(fx_g)
# Sync deallocations for tensors where the stream w/ their last usage
# is distinct from their allocation stream
sync_deallocations(fx_g)
# Wrap sync nodes with control_deps to prevent reordering
# (must be after sync_deallocations which inserts additional sync nodes)
wrap_all_sync_nodes_with_control_deps(fx_g)
# Populate fw_metadata with stream indices from the compiled graph
# NB: This needs to be done after the above stream assignments
populate_fw_metadata_with_stream_indices(fx_g, fw_metadata)
# this helps users identify which forward output to call .detach() on.
_extract_tangent_source_stack_traces(fx_g, fw_metadata)
fx_g.graph.eliminate_dead_code()
if not aot_config.disable_functionalization:
# There should be *NO* mutating ops in the graph at this point.
assert_functional_graph(fx_g.graph)
fx_g.recompile()
# TODO: in AOTAutograd, we create metadata like _indices_of_inps_to_detach to detect
# when we need to manually detach() some inputs in the forward.
# Higher order ops might eventually need to do the same.
if aot_config.is_export and maybe_subclass_meta is not None:
raise AssertionError(
"aot_export_module does not support tensor subclass inputs for now."
)
return (
fx_g,
saved_updated_joint_inputs,
updated_joint_inputs_descs,
maybe_subclass_meta,
)
@@ -0,0 +1,54 @@
from collections.abc import Iterator, MutableMapping
from typing import Generic, TypeVar
K = TypeVar("K")
V = TypeVar("V")
# Used for fast next key access (using the fact that the dict is ordered)
# Note: doesn't support deletion but we don't need it!
class IndexedDict(MutableMapping[K, V], Generic[K, V]):
"""A dict that maintains insertion order with O(1) index access."""
__slots__ = ("_dict", "_keys", "_key_to_index")
def __init__(self) -> None:
self._dict: dict[K, V] = {}
self._keys: list[K] = [] # typing: ignore[bad-override]
self._key_to_index: dict[K, int] = {}
def __setitem__(self, key: K, value: V) -> None:
if key not in self._dict:
self._key_to_index[key] = len(self._keys)
self._keys.append(key)
self._dict[key] = value
def __getitem__(self, key: K) -> V:
return self._dict[key]
def __delitem__(self, key: K) -> None:
raise NotImplementedError("Deletion not supported for IndexedDict")
def __len__(self) -> int:
return len(self._dict)
def __iter__(self) -> Iterator[K]:
return iter(self._keys)
def __contains__(self, key: object) -> bool:
return key in self._dict
def next_key(self, key: K) -> K | None:
"""Get the next key in insertion order. O(1)."""
idx = self._key_to_index.get(key)
if idx is not None and idx + 1 < len(self._keys):
return self._keys[idx + 1]
return None
def prev_key(self, key: K) -> K | None:
"""Get the previous key in insertion order. O(1)."""
idx = self._key_to_index.get(key)
if idx is not None and idx > 0:
return self._keys[idx - 1]
return None
@@ -0,0 +1,496 @@
"""
This module is one of the analysis modules - it takes as input a function or graph
and some preexisting properties, and returns some data that is useful for deciding
how to further proceed with compilation or construct runtime wrappers.
In particular, the following analyses are provided:
1. Refine the view and mutation metadata collected previously - removing duplicate
inputs or mapping views to their bases.
2. We also analyze the function signature for export graphs.
"""
import contextlib
import itertools
from typing import Any
import torch
import torch.utils._pytree as pytree
from torch import Tensor
from torch._C._dynamo.guards import compute_overlapping_tensors
from torch._functorch._aot_autograd.schemas import PlainTensorMeta
from torch._guards import StorageOverlap
from torch._subclasses.functional_tensor import FunctionalTensor
from torch.fx.experimental.symbolic_shapes import is_concrete_int
from .collect_metadata_analysis import coerce_tangent_and_suggest_memory_format
from .descriptors import AOTInput, InputMutationAOTOutput, TangentAOTInput
from .schemas import (
AOTConfig,
BackwardSignature,
GraphSignature,
InputAliasInfo,
MemoryFormatMeta,
OutputAliasInfo,
OutputType,
ViewAndMutationMeta,
)
from .utils import strict_zip
zip = strict_zip
def remove_dupe_metadata(
m: ViewAndMutationMeta,
keep_arg_mask: list[bool],
add_dupe_map: list[int],
) -> ViewAndMutationMeta:
if len(m.input_info) != len(keep_arg_mask):
raise AssertionError(
f"len(m.input_info)={len(m.input_info)} != len(keep_arg_mask)={len(keep_arg_mask)}"
)
# Easy invariant: the first argument should never be a dupe (it will be kept)
if len(keep_arg_mask) == 0 or not keep_arg_mask[0]:
raise AssertionError(
"keep_arg_mask must be non-empty and keep_arg_mask[0] must be True"
)
# Filter dupe'd mutated inputs out of traced_tangents
num_data_mutations = len([x for x in m.input_info if x.mutates_data])
other_traced_tangents = m.traced_tangents[num_data_mutations:]
inp_traced_tangents = m.traced_tangents[:num_data_mutations]
other_traced_tangents_descs = m.traced_tangents_descs[num_data_mutations:]
inp_traced_tangents_descs = m.traced_tangents_descs[:num_data_mutations]
filtered_inp_traced_tangents = [
# See Note [Tangents memory format]
x
for i, x in enumerate(inp_traced_tangents)
if keep_arg_mask[m.mutated_inp_runtime_indices[i]]
]
filtered_inp_traced_tangents_descs = [
x_desc
for i, x_desc in enumerate(inp_traced_tangents_descs)
if keep_arg_mask[m.mutated_inp_runtime_indices[i]]
]
traced_tangents = filtered_inp_traced_tangents + other_traced_tangents
traced_tangents_descs = (
filtered_inp_traced_tangents_descs + other_traced_tangents_descs
)
if m.subclass_tangent_meta is None:
raise AssertionError("m.subclass_tangent_meta must not be None")
subclass_tangent_meta = [
PlainTensorMeta(
0, memory_format=MemoryFormatMeta(memory_format=torch.contiguous_format)
)
] * len(filtered_inp_traced_tangents) + m.subclass_tangent_meta[num_data_mutations:]
return ViewAndMutationMeta(
input_info=[x for i, x in enumerate(m.input_info) if keep_arg_mask[i]],
# For outputs that are views of inputs, we store the index of the input that the output
# was generated from. Need to update that index to account for removed dupes.
output_info=[
OutputAliasInfo(
output_type=o.output_type,
raw_type=o.raw_type,
dynamic_dims=o.dynamic_dims,
base_idx=None if o.base_idx is None else add_dupe_map[o.base_idx],
requires_grad=o.requires_grad,
requires_grad_for_backward=o.requires_grad_for_backward,
view_meta_sequence=o.view_meta_sequence,
)
for o in m.output_info
],
num_intermediate_bases=m.num_intermediate_bases,
keep_input_mutations=m.keep_input_mutations,
traced_tangents=traced_tangents,
traced_tangents_descs=traced_tangents_descs,
# We are guaranteed not to get here, since dupes are not supported today with subclass inputs.
subclass_inp_meta=[],
subclass_fw_graph_out_meta=[],
subclass_tangent_meta=subclass_tangent_meta,
)
# Given our ViewAndMutation metadata, this fn constructs a new set of metadata,
# after adding synthetic base arguments to the function.
# Most of the work in this fn is slogging through all of the metadata corresponding to inputs,
# and updating it with our synthetic base calling convention.
#
# When config.debug_assert is set, we automatically regenerate the metadata
# and compare it to this output for sanity.
#
# In addition to the updated metadata, also return the list of input indices
# that will need to be updated in the synthetic base epilogue
def create_synthetic_base_metadata(
m: ViewAndMutationMeta,
# Maps each outer argument idx to its inner idx (or, if this outer arg is generated from a
# synthetic base, you get a tuple of (i, TensorMeta), telling you the base tensor idx, and view metadata)
synthetic_base_info: list[int | tuple[int, torch.Tensor]],
outer_args: list[Any],
inner_args: list[Any],
inner_args_desc: list[AOTInput],
) -> tuple[ViewAndMutationMeta, list[int]]:
# maps inner arg indices to outer arg indices
synthetic_base_to_indices: dict[int, list[int]] = {}
for inner_idx in range(len(inner_args)):
outer_aliased_indices_of_current_base_arg = [
outer_idx
for outer_idx, inner_idx_or_tuple in enumerate(synthetic_base_info)
if (isinstance(inner_idx_or_tuple, int) and inner_idx_or_tuple == inner_idx)
or (
isinstance(inner_idx_or_tuple, tuple)
and inner_idx_or_tuple[0] == inner_idx
)
]
synthetic_base_to_indices[inner_idx] = outer_aliased_indices_of_current_base_arg
# given the requires_grad info on mutated inputs,
# generate the requires_grad info on those same mutated inputs, but after constructing synthetic bases.
# pyrefly: ignore [implicit-any]
input_infos = []
for outer_indices in synthetic_base_to_indices.values():
# leaf-ness should be all-or-nothing for aliased tensor.
# (aka if "a" and "b" are views, then a.is_leaf == b.is_leaf)
any_leaf = any(m.input_info[x].is_leaf for x in outer_indices)
all_leaf = all(m.input_info[x].is_leaf for x in outer_indices)
if any_leaf != all_leaf:
raise AssertionError(
f"any_leaf={any_leaf} != all_leaf={all_leaf} for outer_indices={outer_indices}"
)
mutates_data = (
True
if len(outer_indices) > 1
else m.input_info[outer_indices[0]].mutates_data
)
mutates_metadata = (
False
if len(outer_indices) > 1
else m.input_info[outer_indices[0]].mutates_metadata
)
requires_grad = any(m.input_info[x].requires_grad for x in outer_indices)
mutations_under_no_grad_or_inference_mode = all(
m.input_info[x].mutations_under_no_grad_or_inference_mode
for x in outer_indices
)
mutation_inductor_storage_resize = all(
m.input_info[x].mutation_inductor_storage_resize for x in outer_indices
)
inpt_info = InputAliasInfo(
# If len(outer_indices) > 1, then this input is a synthetic base.
# The invariant is that to the rest of aot autograd, synthetic bases only show up if
# one of their aliases gets a data mutation. And if any of their aliases get metadata
# mutations, they will be hidden from the rest of aot autograd.
mutates_data=mutates_data,
mutates_metadata=mutates_metadata,
mutations_hidden_from_autograd=all(
m.input_info[x].mutations_hidden_from_autograd for x in outer_indices
),
mutates_storage_metadata=(
False
if len(outer_indices) > 1
else m.input_info[outer_indices[0]].mutates_storage_metadata
),
mutations_under_no_grad_or_inference_mode=mutations_under_no_grad_or_inference_mode,
mutation_inductor_storage_resize=mutation_inductor_storage_resize,
is_leaf=any_leaf,
requires_grad=requires_grad,
keep_input_mutations=m.keep_input_mutations,
)
input_infos.append(inpt_info)
# Find any inputs that fulfill the following criteria:
# (1) They are part of a synthetic base (because they alias another input,
# and at least one input experiences a data mutation)
# (2) They experience a metadata mutation
outer_aliased_arg_idx_with_metadata_mutations = [
outer_idx
for outer_idx, inpt_info in enumerate(m.input_info)
if inpt_info.mutates_metadata
and not isinstance(synthetic_base_info[outer_idx], int)
]
# grab the original requires grad info on the outputs, except the ones from the mutated inputs
input_metadata_output_info = [
OutputAliasInfo(
output_type=OutputType.alias_of_input,
raw_type=FunctionalTensor,
dynamic_dims={
i
for i, s in enumerate(outer_args[outer_idx].shape)
if not is_concrete_int(s)
},
base_idx=synthetic_base_info[outer_idx][0], # type: ignore[index]
requires_grad=(requires_grad := outer_args[outer_idx].requires_grad),
requires_grad_for_backward=requires_grad,
)
for outer_idx in outer_aliased_arg_idx_with_metadata_mutations
]
existing_output_infos = []
for o in m.output_info:
new_base_idx = (
None
if o.base_idx is None
else (
synthetic_base_info[o.base_idx]
if isinstance(synthetic_base_info[o.base_idx], int)
else synthetic_base_info[o.base_idx][0] # type: ignore[index]
)
)
# If base_idx is changed for OutputType.is_input, we need to update the output type to reflect the change
new_output_type = (
OutputType.alias_of_input
if o.output_type == OutputType.is_input and o.base_idx != new_base_idx
else o.output_type
)
existing_output_infos.append(
OutputAliasInfo(
output_type=new_output_type,
raw_type=o.raw_type,
dynamic_dims=o.dynamic_dims,
# Map the input idx pre-synthetic-bases to the new idx post-synthetic-bases
base_idx=new_base_idx, # type: ignore[arg-type]
requires_grad=o.requires_grad,
requires_grad_for_backward=o.requires_grad_for_backward,
view_meta_sequence=o.view_meta_sequence,
)
)
inner_mutated_tangents_and_memory_formats = [
# See Note [Tangents memory format]
(
coerce_tangent_and_suggest_memory_format(x),
TangentAOTInput(InputMutationAOTOutput(x_desc)),
)
for inner_idx, (x, x_desc) in enumerate(zip(inner_args, inner_args_desc))
if input_infos[inner_idx].mutates_data and input_infos[inner_idx].requires_grad
]
inner_mutated_tangents = [
x[0][0] for x in inner_mutated_tangents_and_memory_formats
]
inner_mutated_tangents_descs = [
x[1] for x in inner_mutated_tangents_and_memory_formats
]
inner_mutated_tangents_memory_formats = [
x[0][1] for x in inner_mutated_tangents_and_memory_formats
]
output_info = existing_output_infos + input_metadata_output_info
# Regenerate traced tangents to include mutated inputs including synthetic bases
traced_tangents = (
inner_mutated_tangents + m.traced_tangents[len(inner_mutated_tangents) :]
)
traced_tangents_descs = (
inner_mutated_tangents_descs
+ m.traced_tangents_descs[len(inner_mutated_tangents) :]
)
if m.subclass_tangent_meta is None:
raise AssertionError("m.subclass_tangent_meta must not be None")
subclass_tangent_meta = [
# pyrefly: ignore[bad-argument-type]
PlainTensorMeta(0, memory_format=x)
for x in inner_mutated_tangents_memory_formats
] + m.subclass_tangent_meta[len(inner_mutated_tangents) :]
return (
ViewAndMutationMeta(
input_info=input_infos,
output_info=output_info,
num_intermediate_bases=m.num_intermediate_bases,
keep_input_mutations=m.keep_input_mutations,
traced_tangents=traced_tangents,
traced_tangents_descs=traced_tangents_descs,
# We are guaranteed not to get here, since synthetic_base codepaths are not supported today with subclass inputs.
subclass_inp_meta=[],
subclass_fw_graph_out_meta=[],
subclass_tangent_meta=subclass_tangent_meta,
),
outer_aliased_arg_idx_with_metadata_mutations,
)
def compute_overlapping_inputs(
aot_config: AOTConfig, fwd_inputs: list[Any], aliased_input_indices: list[int]
) -> set[int]:
num_aliases = len(aliased_input_indices)
shape_env = None
maybe_suppress_guards = contextlib.nullcontext
tracing_context = torch._guards.TracingContext.try_get()
if tracing_context is not None:
if tracing_context.fake_mode is None:
raise AssertionError("tracing_context.fake_mode must not be None")
shape_env = tracing_context.fake_mode.shape_env
# Check whether we can actually get the dynamo sources from within AOTAutograd.
if aot_config.aot_autograd_arg_pos_to_source and shape_env is not None:
maybe_suppress_guards = shape_env.suppress_guards # type: ignore[assignment]
# Check whether there are any symbolic values being used.
# We do this for 2 reasons:
# 1. StorageOverlap guard is only issued whenever dynamic shapes is turned on
# 2. Triggers the fast-path for computing storage overlapping
symbolic = any(
isinstance(x, torch.SymInt)
for i in aliased_input_indices
for x in [
*fwd_inputs[i].shape,
*fwd_inputs[i].stride(),
fwd_inputs[i].storage_offset(),
]
)
if torch._inductor.config.is_fbcode():
if symbolic and num_aliases > 400:
from torch._subclasses.fake_tensor import (
UnsupportedMutationAliasingException,
)
from torch._utils_internal import justknobs_check
msg = f"Encountered {num_aliases} dynamic, aliased/mutated inputs, consider setting dynamic=False"
if justknobs_check(
"pytorch/compiler:aliased_inputs_with_mutation_and_dyn_shapes_killswitch",
False,
):
raise UnsupportedMutationAliasingException(msg)
with maybe_suppress_guards():
aliased_fwd_inputs = [fwd_inputs[i] for i in aliased_input_indices]
actual_aliased_indices = {
aliased_input_indices[i]
for i in compute_overlapping_tensors(aliased_fwd_inputs, symbolic=symbolic)
}
# Add the StorageOverlap AOTAutograd guard only if we are actually keeping track of
# dynamo sources inside AOTAutograd.
if (
tracing_context is not None
# Make sure dynamic shapes is currently being used.
and symbolic
# We check that we have more than 1 aliased tensor, which should be true at
# this point, anyway.
and num_aliases > 1
and aot_config.aot_autograd_arg_pos_to_source
):
no_overlap_indices = list(set(aliased_input_indices) - actual_aliased_indices)
overlapping_sources = [
aot_config.aot_autograd_arg_pos_to_source[i] for i in actual_aliased_indices
]
non_overlapping_sources = [
aot_config.aot_autograd_arg_pos_to_source[i] for i in no_overlap_indices
]
tracing_context.guards_context.aotautograd_guards.append(
StorageOverlap(overlapping_sources, non_overlapping_sources)
)
return actual_aliased_indices
def _graph_input_names(gm: torch.fx.GraphModule) -> list[str]:
return [node.name for node in gm.graph.find_nodes(op="placeholder")]
def _graph_output_names(gm: torch.fx.GraphModule) -> list[Any]:
output_node = next(iter(reversed(gm.graph.nodes)))
if output_node.op != "output" or len(output_node.args) != 1:
raise AssertionError(
f"expected output node with 1 arg, got op={output_node.op}, args={len(output_node.args)}"
)
return_args = output_node.args[0]
return [getattr(return_arg, "name", None) for return_arg in return_args]
def create_graph_signature(
fx_g: torch.fx.GraphModule,
fw_metadata: ViewAndMutationMeta,
in_spec: pytree.TreeSpec,
out_spec: pytree.TreeSpec,
*,
user_args_flat: list[Tensor],
params_and_buffers_flat: list[Tensor],
param_names: list[str],
buffer_names: list[str],
trace_joint: bool,
num_user_fw_outs: int | None,
loss_index: int | None,
) -> GraphSignature:
# Retrieve graph input names
graph_input_names = _graph_input_names(fx_g)
# Retrieve graph output names
graph_output_names = _graph_output_names(fx_g)
num_params_buffers = len(param_names) + len(buffer_names)
num_tokens = len(fw_metadata.tokens)
# We have enough restrictions on the graph (no de-duping, synthetic bases, etc),
# Such that # graph inps = # user inps + # params + # buffers
num_user_args = len(graph_input_names) - num_params_buffers - num_tokens
if trace_joint:
if num_user_fw_outs is None:
raise AssertionError(
"num_user_fw_outs must not be None when trace_joint=True"
)
num_fw_outs = num_user_fw_outs + fw_metadata.num_mutated_inp_runtime_indices
backward_output_names = graph_output_names[num_fw_outs:]
grad_index = itertools.count(0)
gradients_to_parameters = {
backward_output_names[next(grad_index)]: param_names[i]
for i, param in enumerate(params_and_buffers_flat)
if param.requires_grad
}
gradients_to_user_inputs = {
backward_output_names[next(grad_index)]: graph_input_names[
i + len(params_and_buffers_flat)
]
for i, user_input in enumerate(user_args_flat)
if user_input.requires_grad
}
if len(gradients_to_parameters) + len(gradients_to_user_inputs) != len(
backward_output_names
):
raise AssertionError(
f"len(gradients_to_parameters)={len(gradients_to_parameters)} + "
f"len(gradients_to_user_inputs)={len(gradients_to_user_inputs)} != "
f"len(backward_output_names)={len(backward_output_names)}"
)
# Check that we have fully accounted for all graph outputs
if loss_index is None:
raise AssertionError("loss_index must not be None")
backward_signature = BackwardSignature(
gradients_to_parameters,
gradients_to_user_inputs,
graph_output_names[loss_index],
)
else:
backward_signature = None
num_user_fw_outs = (
len(graph_output_names)
- fw_metadata.num_mutated_inp_runtime_indices
- num_tokens
)
return GraphSignature.from_tracing_metadata(
in_spec=in_spec,
out_spec=out_spec,
graph_input_names=graph_input_names,
graph_output_names=graph_output_names,
view_mutation_metadata=fw_metadata,
named_parameters=param_names,
named_buffers=buffer_names,
num_user_inputs=num_user_args,
num_user_outputs=num_user_fw_outs,
trace_joint=trace_joint,
loss_index=loss_index,
backward_signature=backward_signature,
)
@@ -0,0 +1,166 @@
"""
Contains utils for logging in AOTAutograd, including managing the names of the graphs under
compilation, capturing user-friendly tracebacks, and debug messages.
"""
import collections
from collections.abc import Callable, Generator, Iterator
from contextlib import contextmanager
from typing import Any
import torch
import torch.fx.traceback as fx_traceback
from .schemas import AOTConfig
# This is a list since looking forward, we can have this arbitrarily nested.
graph_being_compiled: list[str] = []
# TODO: It would be nice to reset the numbering every time aot_id goes
# up, but this is annoying to do right now (because we don't know if
# an aot_id will come back from the dead), so right now this also happens
# to be a globally unique number too (at the cost of wobbling if you change
# how the graphs compile)
nth_graph: int = 0
model_name: str = "model"
def set_model_name(name: str) -> None:
global model_name
model_name = name
def get_aot_compilation_context() -> tuple[list[str], str, int]:
return list(graph_being_compiled), model_name, nth_graph
def get_aot_graph_name() -> str:
"""
Returns the name of the graph being compiled.
"""
global model_name, graph_being_compiled, nth_graph
return f"{model_name}__{'_'.join(graph_being_compiled)}_{nth_graph}"
get_graph_being_compiled = get_aot_graph_name
@contextmanager
def track_graph_compiling(
aot_config: AOTConfig, graph_name: str
) -> Generator[None, None, None]:
global graph_being_compiled
# TODO: Don't shove the aot_id in here; set it in the context
graph_being_compiled = [f"{aot_config.aot_id}_{graph_name}"]
old_name = None
if tracing_context := torch._guards.TracingContext.try_get():
old_name = tracing_context.aot_graph_name
tracing_context.aot_graph_name = graph_being_compiled
has_tracing_context = True
else:
has_tracing_context = False
try:
yield
finally:
global nth_graph
nth_graph += 1
graph_being_compiled = []
if has_tracing_context:
if tracing_context := torch._guards.TracingContext.try_get():
tracing_context.aot_graph_name = old_name
# Set up hooks so that during backward the fx's stack_trace is properly set
callback_set = False
def setup_stacktrace_preservation_hooks(roots: list[torch.autograd.graph.Node]) -> None:
def iter_graph(
roots: list[torch.autograd.graph.Node],
) -> Iterator[torch.autograd.graph.Node]:
if not roots:
return
seen = set()
q = collections.deque()
for node in roots:
if node is not None and node not in seen:
seen.add(node)
q.append(node)
while q:
node = q.popleft()
for fn, _idx in node.next_functions:
if fn in seen or fn is None:
continue
seen.add(fn)
q.append(fn)
yield node
def get_callback(saved_stack_: list[str]) -> Callable[[], None]:
def callback() -> None:
global callback_set
fx_traceback.set_stack_trace(saved_stack_)
callback_set = False
return callback
def get_prehook(stack_: list[str], seq_nr: int) -> Callable[[Any], None]:
def prehook(grad_output: Any) -> None:
global callback_set
if not callback_set:
torch.autograd.variable.Variable._execution_engine.queue_callback( # type: ignore[attr-defined]
get_callback(fx_traceback.format_stack())
)
callback_set = True
fx_traceback.set_stack_trace(stack_)
fx_traceback.set_grad_fn_seq_nr(seq_nr)
fx_traceback._mark_autograd_backward()
return prehook
def get_posthook(
special_stack_: list[str], seq_nr: int
) -> Callable[[Any, Any], None]:
def posthook(grad_input: Any, grad_output: Any) -> None:
fx_traceback.set_stack_trace(special_stack_)
fx_traceback.reset_grad_fn_seq_nr()
fx_traceback._reset_autograd_backward()
return posthook
for node in iter_graph(roots):
# pyrefly: ignore[missing-attribute]
forward_node_stack = node.metadata.get("traceback_", [])
node.register_prehook(get_prehook(forward_node_stack, node._sequence_nr()))
special_stack = forward_node_stack.copy()
special_stack.append(fx_traceback.GRADIENT_ACC_SPECIAL_STACK)
node.register_hook(get_posthook(special_stack, node._sequence_nr()))
def setup_stacktrace_preservation_hooks_from_tensors(outputs: Any) -> None:
roots = [
t.grad_fn
for t in (outputs if isinstance(outputs, (list, tuple)) else (outputs,))
if isinstance(t, torch.Tensor) and t.grad_fn is not None
]
if roots:
setup_stacktrace_preservation_hooks(roots)
def describe_input(i: int, aot_config: AOTConfig) -> str:
if i < aot_config.num_params_buffers:
return f"parameter/buffer {i}"
else:
return f"input {i - aot_config.num_params_buffers}"
def format_guard_bug_msg(aot_config: AOTConfig, expected: str) -> str:
return (
f"At compilation time, graph {aot_config.aot_id} was compiled under the "
f"assumption that {expected}, but at runtime this was not the case. "
"This indicates a guard bug in AOTAutograd or Dynamo, please file a bug to PyTorch."
)
@@ -0,0 +1,596 @@
import operator
from typing import Any, TYPE_CHECKING, TypeAlias
import torch.fx
import torch.fx.traceback
import torch.utils._pytree as pytree
from torch._dynamo.graph_utils import _get_flat_args
from torch._dynamo.variables.streams import get_current_stream, new_event
from torch.fx.node import map_arg
from torch.utils._runtime_estimation import (
_FLOAT_TYPES,
_IGNORE_OPS,
get_compute_time,
get_transfer_time,
)
if TYPE_CHECKING:
from .schemas import ViewAndMutationMeta # noqa: TC004
from .indexed_dict import IndexedDict
aten = torch.ops.aten
Node: TypeAlias = torch.fx.Node
Graph: TypeAlias = torch.fx.Graph
_SYNC_OPS = (
torch.ops.streams.record_event.default,
torch.ops.streams.wait_event.default,
torch.ops.streams.synchronize_event.default,
torch.ops.streams.synchronize_device.default,
torch.ops.streams.synchronize_stream.default,
)
def get_roofline_estimate(node: Node) -> float:
if node.op != "call_function":
raise AssertionError(f"non-func node in roofline estimate: {node.op}")
def map_value(x: Any) -> Any:
return x.meta.get("value", x) if isinstance(x, Node) else x
func = node.target
if func in _IGNORE_OPS:
return 0.0
mapped_args = torch.fx.map_arg(node.args, map_value)
mapped_kwargs = torch.fx.map_arg(node.kwargs, map_value)
flat_args_kwargs = [map_value(x) for x in _get_flat_args(node, {})]
flat_outs, _ = pytree.tree_flatten(node.meta.get("value", node))
out = node.meta.get("value", node)
out_dtypes = {
t.dtype
for t in flat_outs
if isinstance(t, torch.Tensor) and t.dtype in _FLOAT_TYPES
}
return (
max(
get_transfer_time(flat_args_kwargs, flat_outs),
get_compute_time(func, mapped_args, mapped_kwargs, out, out_dtypes),
)
/ 1e6
)
def is_gradient_acc(node: Node) -> bool:
return node.meta.get("is_gradient_acc", False)
def is_bwd_node(node: Node) -> bool:
tag = node.meta.get("partitioner_tag")
return tag == "is_backward" or tag == "must_be_in_backward"
def get_device(node: Node) -> torch.device:
return node.meta["val"].device
def get_stream(node: Node) -> int | None:
maybe_annotation = node.meta.get("custom", None)
if maybe_annotation is not None:
return node.meta["custom"].get("stream", None)
else:
return None
def get_stream_or_current_stream(node: Node) -> int:
ind = get_stream(node)
if ind is None:
ind = get_current_stream(get_device(node))
return ind
def set_stream(node: Node, ind: int) -> None:
if "custom" in node.meta:
node.meta["custom"].update({"stream": ind})
else:
node.meta["custom"] = {"stream": ind}
def insert_record_event_after_node(graph: Graph, node: Node, event_ind: int) -> Node:
with graph.inserting_after(node):
node = graph.call_function(
torch.ops.streams.record_event.default,
(
event_ind,
get_stream_or_current_stream(node),
),
)
node.meta["partitioner_tag"] = "must_be_in_backward"
return node
def insert_wait_event_before_node(graph: Graph, node: Node, event_ind: int) -> Node:
with graph.inserting_before(node):
node = graph.call_function(
torch.ops.streams.wait_event.default,
(
event_ind,
get_stream_or_current_stream(node),
),
)
node.meta["partitioner_tag"] = "must_be_in_backward"
return node
def populate_stream_timeline(
stream_to_timeline: dict[int | None, IndexedDict[Node, float]],
graph: Graph,
stream_index: int | None,
) -> IndexedDict[Node, float]:
if stream_index not in stream_to_timeline:
stream_to_timeline[stream_index] = IndexedDict()
total_time = 0.0
for node in graph.nodes:
# mlazos: not sure if we should include forward here too but don't think it matters
if (
node.op == "call_function"
and is_bwd_node(node)
and get_stream(node) == stream_index
):
total_time += get_roofline_estimate(node)
stream_to_timeline[stream_index][node] = (
total_time # NB: total time includes the node's runtime
)
return stream_to_timeline[stream_index]
# NB: we start all estimates at 0, estimating the total runtime of each stream with timestamps at each node
# we then try and use these timestamps to estimate when to deallocate tensors used in side streams
# See https://docs.pytorch.org/docs/stable/generated/torch.Tensor.record_stream.html#torch.Tensor.record_stream
# for details on the problem being addressed. Rather than using the automatic memory management approach of record_stream
# we attempt to find the point which to deallocate based on the estimated timestamps.
def handle_synced_deallocation(
graph: Graph,
stream_to_exec_trace: dict[int | None, IndexedDict[Node, float]],
node: Node,
last_usage: Node,
) -> None:
if not is_bwd_node(node):
raise AssertionError(
"synced allocations should only be handled on backward nodes"
)
if not is_bwd_node(last_usage):
raise AssertionError(
"synced allocations should only be handled on backward nodes"
)
allocating_stream = get_stream(node)
side_stream = get_stream(last_usage)
if allocating_stream == side_stream:
raise AssertionError(
"allocating and side stream should be different for synced deallocations"
)
if not torch.cuda.is_available():
# fallback to record_stream in this case
with graph.inserting_after(node):
graph.call_function(
torch.ops.streams.record_stream.default,
(
node,
get_stream_or_current_stream(last_usage),
),
{},
)
node.meta["partitioner_tag"] = "must_be_in_backward"
allocating_stream_trace = populate_stream_timeline(
stream_to_exec_trace, graph, allocating_stream
)
side_stream_trace = populate_stream_timeline(
stream_to_exec_trace, graph, side_stream
)
alloc_ptr = node
target_side_stream_time = side_stream_trace[last_usage]
# linear search from first usage of tensor to a point in time after the side stream has finished
while alloc_ptr is not None:
alloc_time = allocating_stream_trace[alloc_ptr]
if alloc_time >= target_side_stream_time:
break
elif alloc_time < target_side_stream_time:
next_ptr = allocating_stream_trace.next_key(alloc_ptr)
if next_ptr is not None:
alloc_ptr = next_ptr
else:
break
wait_event = new_event()
record_node = insert_record_event_after_node(graph, last_usage, wait_event)
with graph.inserting_after(max(alloc_ptr, record_node)):
graph.call_function(
torch.ops.streams.sync_dealloc.default,
(wait_event, get_stream_or_current_stream(alloc_ptr), node),
{},
)
node.meta["partitioner_tag"] = "must_be_in_backward"
def insert_sync(
graph: Graph,
consumer: Node,
producer: Node,
node_to_wait_event_ind: dict[Node, int],
) -> None:
if producer not in node_to_wait_event_ind:
node_to_wait_event_ind[producer] = new_event()
insert_record_event_after_node(
graph, producer, node_to_wait_event_ind[producer]
)
insert_wait_event_before_node(graph, consumer, node_to_wait_event_ind[producer])
def assign_backward_streams(gm: torch.fx.GraphModule) -> None:
"""Assigns backward streams to gradient accumulation nodes"""
# NB: iterate in reverse order to more closely match eager
# the user node stream will be populated first
for node in reversed(list(gm.graph.nodes)):
if is_gradient_acc(node):
# Accumulation stream selection. Follow the rules from top to bottom to determine the accumulation stream:
# 1. Match first stream assignment of the first user with a stream
# 2. Match first stream assignment encountered in the args from left to right
# This differs from eager in some cases:
# Specifically the eager code uses the autograd node to determine the stream,
# crucially this does not necessarily correspond to the FX graph node. For example,
# in the backward for an add node with a constant we will passthrough and during backward tracing,
# no op will be added to the FX graph, so our stream assignment will differ in this case.
gradients = _get_flat_args(node, {})
users = list(node.users.keys())
# All gradients will be on same device, they will be coerced if they were not with a .to() node
for neighbor in users + gradients:
ind = get_stream(neighbor)
if ind is not None:
set_stream(node, ind)
break
def insert_backward_syncs(gm: torch.fx.GraphModule) -> None:
"""Inserts stream syncs for backward nodes if consumer and producer are on different streams"""
node_to_wait_event_ind: dict[Node, int] = {}
for node in gm.graph.nodes:
if node.op == "call_function" and is_bwd_node(node):
flat_args = _get_flat_args(node, {})
cur_node_stream = get_stream(node)
for arg in flat_args:
if arg.op == "call_function" and is_bwd_node(arg):
arg_stream = get_stream(arg)
if arg_stream != cur_node_stream and get_device(arg).type != "cpu":
insert_sync(gm.graph, node, arg, node_to_wait_event_ind)
def sync_deallocations(gm: torch.fx.GraphModule) -> None:
"""Handles https://docs.pytorch.org/docs/stable/generated/torch.Tensor.record_stream.html#torch.Tensor.record_stream"""
# Note: this is only needed if the last usage of a tensor is on a stream other than
# the stream the tensor was allocated on
# an estimated timestamp from the beginning of graph execution (assuming 0 CPU overhead)
# I think this is fine because you should have large tensors if you're using streams
# although perhaps I could add a constant 10us per op ahead of the first stream op?
# a trace of all the nodes running in a given stream
stream_to_exec_trace: dict[int | None, IndexedDict[Node, float]] = {}
for node in gm.graph.nodes:
if node.op == "call_function" and is_bwd_node(node):
allocating_stream = get_stream(node)
users = list(node.users.keys())
if not users:
continue
last_user = max(user for user in users)
if last_user.op == "output":
continue
side_stream = get_stream(last_user)
if allocating_stream != side_stream:
handle_synced_deallocation(
gm.graph, stream_to_exec_trace, node, last_user
)
def assign_epilogue_copy_streams(gm: torch.fx.GraphModule) -> None:
for epi_copy in gm.graph.find_nodes(op="call_function", target=aten.copy_.default):
arg_stream = get_stream(epi_copy.args[1])
copy_stream = get_stream(epi_copy)
if arg_stream != copy_stream:
set_stream(epi_copy, get_stream_or_current_stream(epi_copy.args[1]))
def populate_fw_metadata_with_stream_indices(
gm: torch.fx.GraphModule, fw_metadata: "ViewAndMutationMeta"
) -> None:
"""
Populates fw_metadata.mutated_inp_stream_indices with stream indices from the compiled graph.
The forward graph outputs are structured as:
(*mutated_inputs, *user_outputs, *intermediate_bases, *saved_tensors, *saved_symints)
We extract the stream index for each mutated input from the graph's output node.
"""
num_mutated_inps = fw_metadata.num_mutated_inp_runtime_indices
if num_mutated_inps == 0:
fw_metadata.mutated_inp_stream_indices = []
return
# Find the output node in the graph
output_node = None
for node in gm.graph.find_nodes(op="output"):
output_node = node
break
if output_node is None:
raise AssertionError(
"No output node found in the graph when extracting stream indices"
)
# The output node's args[0] is a tuple/list of all outputs
output_args = output_node.args[0]
# Extract stream indices for the first num_mutated_inps outputs
stream_indices = []
for i in range(num_mutated_inps):
if i < len(output_args):
output_arg = output_args[i]
# Get the stream index from the node metadata
stream_idx = (
get_stream(output_arg)
if isinstance(output_arg, torch.fx.Node)
else None
)
stream_indices.append(stream_idx)
else:
stream_indices.append(None)
fw_metadata.mutated_inp_stream_indices = stream_indices
def _wrap_sync_node(
gm: torch.fx.GraphModule,
sync_node: Node,
deps_before_sync: list[Node],
visited: set[Node],
) -> tuple[Node, list[Node]]:
"""
Core logic: wrap a single sync node in control_deps.
Returns (control_deps_node, passthrough_getitems) where passthrough_getitems
are the getitem nodes that thread dependencies through the control_deps node.
``visited`` is the set of nodes at or before the sync node in graph order,
used to distinguish pre-sync vs post-sync users.
"""
from torch._inductor.fx_passes.control_dependencies import (
_create_subgraph_for_node,
control_deps,
get_subgraph_name,
)
graph = gm.graph
# Use dep.users to find deps with uses after the sync — avoids a forward walk.
deps_with_uses_after_sync = [
dep
for dep in deps_before_sync
if any(user not in visited for user in dep.users)
]
# Create subgraph that executes sync and passes through only used dependencies
subgraph_module = _create_subgraph_for_node(
graph, sync_node, deps_with_uses_after_sync
)
subgraph_attr_name = get_subgraph_name(gm, sync_node.name)
setattr(gm, subgraph_attr_name, subgraph_module)
# Create control_deps call
# Note: sync nodes (record_event/wait_event) only take int args, no Node args.
with graph.inserting_before(sync_node):
get_subgraph = graph.get_attr(subgraph_attr_name)
control_deps_node = graph.call_function(
control_deps,
args=(
tuple(deps_before_sync), # additional_deps (all deps for ordering)
get_subgraph, # subgraph
*deps_with_uses_after_sync, # only pass through deps that are used
),
kwargs={},
)
# Mark newly created nodes as visited so subsequent syncs don't
# misclassify them as "after the sync" during replacement.
visited.add(get_subgraph)
visited.add(control_deps_node)
# The output is (sync_result, *deps_with_uses_after_sync)
# Create getitem nodes only for dependencies that have uses after sync
replacements: dict[Node, Node] = {}
with graph.inserting_after(control_deps_node):
for i, dep in enumerate(deps_with_uses_after_sync):
getitem_node = graph.call_function(
operator.getitem,
args=(control_deps_node, i + 1), # +1 because index 0 is sync result
)
getitem_node.meta.update(dep.meta)
replacements[dep] = getitem_node
visited.add(getitem_node)
# Replace uses of dependencies that come after sync_node.
# Use map_arg to handle nested structures (e.g. output node's list args).
for dep, getitem_node in replacements.items():
for user in list(dep.users.keys()):
if user is control_deps_node:
continue
if user in visited:
continue
# Don't replace forward outputs in the output node — they belong
# to the forward partition and must not reference backward nodes.
if user.op == "output" and not is_bwd_node(dep):
continue
def _replace(n: Node) -> Node:
return getitem_node if n is dep else n
user.args = map_arg(user.args, _replace)
user.kwargs = map_arg(user.kwargs, _replace)
# Remove original sync node
sync_node.replace_all_uses_with(control_deps_node)
graph.erase_node(sync_node)
return control_deps_node, list(replacements.values())
def wrap_all_sync_nodes_with_control_deps(gm: torch.fx.GraphModule) -> None:
"""
Single-pass wrap of all sync nodes in control_deps.
Iterates through the graph once, accumulating per-stream node lists.
When a sync node is encountered, it is wrapped using the accumulated deps
for that stream, then the deps are reset to the control_deps node
(maintaining the ordering chain for subsequent syncs on the same stream).
"""
graph = gm.graph
if len(graph.nodes) == 0:
raise RuntimeError("Expected a non-empty graph")
stream_to_nodes: dict[int | None, list[Node]] = {}
# Maps event_index -> control_deps node that wrapped its record_event,
# so the corresponding wait_event/synchronize_event can depend on the record.
event_to_ctrl: dict[int, Node] = {}
# Maps event_index -> getitem nodes threaded through record_event's control_deps,
# so synchronize_event can thread them through to subsequent ops.
event_to_passthrough: dict[int, list[Node]] = {}
# Maps event_index -> stream that the event was recorded on,
# so synchronize_event can infer its stream.
event_to_stream: dict[int, int | None] = {}
visited: set[Node] = set()
found_sync = False
# Walk the node linked-list manually so we can mutate the graph
# (wrapping sync nodes inserts/erases nodes) without losing our place.
node = next(iter(graph.nodes))
while node.op != "root":
next_node = node.next
visited.add(node)
if node.op == "call_function":
if node.target in _SYNC_OPS:
# synchronize_device and synchronize_stream block the CPU,
# so all subsequent kernel launches are host-ordered after
# them. Treat both as full barriers across all streams.
if node.target in (
torch.ops.streams.synchronize_device.default,
torch.ops.streams.synchronize_stream.default,
):
all_stream_deps: list[Node] = [
n for nodes in stream_to_nodes.values() for n in nodes
]
if all_stream_deps:
found_sync = True
_wrap_sync_node(gm, node, all_stream_deps, visited)
stream_to_nodes.clear()
node = next_node
continue
event_index: int = node.args[0] # type: ignore[assignment]
# synchronize_event blocks the CPU thread, so it acts
# as a barrier across all streams. Collect deps from every
# stream and reset them all afterward. If the event was
# recorded externally, thread the graph inputs through so
# that any post-sync uses depend on the synchronize.
if node.target is torch.ops.streams.synchronize_event.default:
sync_stream: int | None = event_to_stream.get(event_index)
all_stream_deps: list[Node] = [
n for nodes in stream_to_nodes.values() for n in nodes
]
if event_index not in event_to_stream:
placeholders = [n for n in graph.nodes if n.op == "placeholder"]
deps_before_sync = [*placeholders, *all_stream_deps]
else:
deps_before_sync = all_stream_deps
else:
sync_stream = node.args[1] # type: ignore[assignment]
deps_before_sync = list(stream_to_nodes.get(sync_stream, ()))
# Nodes without explicit stream annotation (custom.stream=None)
# run on the current/default stream. Include them when the sync
# op references a stream, since the unannotated nodes are
# implicitly on that stream.
if None in stream_to_nodes and sync_stream is not None:
deps_before_sync.extend(stream_to_nodes[None])
# For wait_event and synchronize_event, add a cross-event
# dependency on the matching record_event's control_deps node
# so they cannot be reordered before the record.
if (
node.target
in (
torch.ops.streams.wait_event.default,
torch.ops.streams.synchronize_event.default,
)
and event_index in event_to_ctrl
):
deps_before_sync = [
event_to_ctrl[event_index],
*deps_before_sync,
]
# For synchronize_event, also include the getitem nodes
# threaded through record_event's control_deps. This ensures
# subsequent ops that depend on recorded values get rewired
# through synchronize_event.
if (
node.target is torch.ops.streams.synchronize_event.default
and event_index in event_to_passthrough
):
deps_before_sync = [
*deps_before_sync,
*event_to_passthrough[event_index],
]
if deps_before_sync:
found_sync = True
ctrl_node, passthrough = _wrap_sync_node(
gm, node, deps_before_sync, visited
)
else:
ctrl_node = None
passthrough: list[torch.fx.Node] = []
if node.target is torch.ops.streams.record_event.default:
event_to_stream[event_index] = sync_stream
if ctrl_node is not None:
event_to_ctrl[event_index] = ctrl_node
event_to_passthrough[event_index] = passthrough
# Reset: ops between this sync and the next will accumulate
# fresh. Ordering with prior ops is already enforced because
# their uses were rewired through getitems from control_deps.
if node.target is torch.ops.streams.synchronize_event.default:
stream_to_nodes.clear()
else:
stream_to_nodes[sync_stream] = []
if None in stream_to_nodes:
stream_to_nodes[None] = []
elif "val" in node.meta:
stream = get_stream(node)
stream_to_nodes.setdefault(stream, []).append(node)
node = next_node
if found_sync:
gm.recompile()
@@ -0,0 +1,397 @@
"""
Codegen for AOTDispatchSubclassWrapper.
Generates a Python function that replaces the data-driven
runtime_unwrap_tensor_subclasses / wrap_tensor_subclasses loop with
a straight-line function where all metadata (indices, attr names,
subclass types, symint positions) is baked in at compile time.
"""
import functools
import keyword
import logging
from collections.abc import Callable, Iterable
import torch
from torch import SymInt
from .schemas import OpaqueMeta, PlainTensorMeta, SubclassCreationMeta
log = logging.getLogger(__name__)
def _is_symint_placeholder(x: None | int | SymInt) -> bool:
"""Check whether a size/stride entry is symbolic and needs a runtime value.
Works both before make_runtime_safe() (entries are SymInt) and after
(symbolic entries replaced with None, nested ints with -1).
"""
if x is None:
return True
if isinstance(x, SymInt) and not x.node.is_nested_int():
return True
return False
def _compute_placeholders(outer: Iterable[None | int | SymInt]) -> list[bool]:
return [_is_symint_placeholder(s) for s in outer]
def _safe_attr_access(var: str, attr: str) -> str:
if attr.isidentifier() and not keyword.iskeyword(attr):
return f"{var}.{attr}"
return f"getattr({var}, {attr!r})"
class _CodegenState:
"""Accumulates lines of generated source and global bindings."""
def __init__(self) -> None:
self.lines: list[str] = []
self.globals: dict[str, object] = {}
self._name_counter: int = 0
def emit(self, line: str, indent: int = 1) -> None:
self.lines.append(" " * indent + line)
def fresh_name(self, prefix: str) -> str:
name = f"{prefix}_{self._name_counter}"
self._name_counter += 1
return name
def add_global(self, name: str, value: object) -> str:
self.globals[name] = value
return name
def _codegen_unwrap_subclass(
state: _CodegenState,
meta: SubclassCreationMeta,
var: str,
indent: int = 1,
include_symints: bool = True,
) -> None:
"""Emit code to recursively unwrap a single subclass input."""
for attr, attr_meta in meta.attrs.items():
match attr_meta:
case PlainTensorMeta() | OpaqueMeta():
state.emit(
f"unwrapped_args.append({_safe_attr_access(var, attr)})",
indent=indent,
)
case SubclassCreationMeta():
inner_var = state.fresh_name("_inner")
state.emit(
f"{inner_var} = {_safe_attr_access(var, attr)}", indent=indent
)
_codegen_unwrap_subclass(
state,
attr_meta,
inner_var,
indent=indent,
include_symints=include_symints,
)
# Emit symint extraction
if include_symints:
size_placeholders = _compute_placeholders(meta.outer_size)
stride_placeholders = _compute_placeholders(meta.outer_stride)
has_size_symints = any(size_placeholders)
has_stride_symints = any(stride_placeholders)
if has_size_symints or has_stride_symints:
size_var = state.fresh_name("_size")
state.emit(f"{size_var} = {var}.size()", indent=indent)
for i, is_sym in enumerate(size_placeholders):
if is_sym:
state.emit(f"unwrapped_args.append({size_var}[{i}])", indent=indent)
stride_var = state.fresh_name("_stride")
state.emit(f"{stride_var} = {var}.stride()", indent=indent)
for i, is_sym in enumerate(stride_placeholders):
if is_sym:
state.emit(
f"unwrapped_args.append({stride_var}[{i}])", indent=indent
)
def _concrete_value(val: None | int | SymInt) -> int:
"""Get the concrete int value for a non-symbolic size/stride entry.
Used for entries that are NOT symbolic placeholders, meaning they are
concrete ints or nested ints (represented as -1 after make_runtime_safe).
"""
if isinstance(val, int):
return val
# Before make_runtime_safe: nested ints are SymInts; use -1 as dummy.
# After make_runtime_safe: they're already -1.
if isinstance(val, SymInt) and val.node.is_nested_int():
return -1
raise AssertionError(f"Expected concrete int, got {type(val)}: {val}")
def _codegen_wrap_subclass(
state: _CodegenState,
meta: SubclassCreationMeta,
out_idx_ref: list[int],
) -> str:
"""Emit code to reconstruct one subclass output. Returns the variable name."""
inner_dict_var = state.fresh_name("_out_inner")
entries: list[str] = []
for attr, attr_meta in meta.attrs.items():
match attr_meta:
case PlainTensorMeta() | OpaqueMeta():
idx = out_idx_ref[0]
out_idx_ref[0] += 1
entries.append(f"{attr!r}: unwrapped_outs[{idx}]")
case SubclassCreationMeta():
nested_var = _codegen_wrap_subclass(state, attr_meta, out_idx_ref)
entries.append(f"{attr!r}: {nested_var}")
state.emit(f"{inner_dict_var} = {{{', '.join(entries)}}}")
# Reconstruct outer_size and outer_stride
size_placeholders = _compute_placeholders(meta.outer_size)
stride_placeholders = _compute_placeholders(meta.outer_stride)
def _build_tuple(
outer: Iterable[None | int | SymInt], placeholders: list[bool]
) -> str:
parts: list[str] = []
for val, is_sym in zip(outer, placeholders):
if is_sym:
idx = out_idx_ref[0]
out_idx_ref[0] += 1
parts.append(f"unwrapped_outs[{idx}]")
else:
parts.append(repr(_concrete_value(val)))
if len(parts) == 1:
return f"({parts[0]},)"
return f"({', '.join(parts)})"
size_expr = _build_tuple(meta.outer_size, size_placeholders)
stride_expr = _build_tuple(meta.outer_stride, stride_placeholders)
type_name = state.add_global(
state.fresh_name("_subclass_type"),
meta.original_subclass_type or type(meta.original_subclass),
)
meta_name = state.add_global(state.fresh_name("_meta"), meta.meta)
result_var = state.fresh_name("_out")
state.emit(
f"{result_var} = {type_name}.__tensor_unflatten__("
f"{inner_dict_var}, {meta_name}, {size_expr}, {stride_expr})"
)
return result_var
def _emit_output_wrapping(
state: _CodegenState,
out_metas: list[PlainTensorMeta | SubclassCreationMeta],
) -> tuple[list[str], int]:
"""Emit wrapping code for output metas.
Returns (result_exprs, num_args_tallied) where result_exprs are Python
expression strings referencing each wrapped output.
"""
out_idx_ref = [0]
result_exprs: list[str] = []
num_args_tallied = 0
for meta in out_metas:
if isinstance(meta, PlainTensorMeta):
result_exprs.append(f"unwrapped_outs[{meta.unwrapped_idx}]")
num_args_tallied += 1
out_idx_ref[0] = max(out_idx_ref[0], meta.unwrapped_idx + 1)
else:
result_var = _codegen_wrap_subclass(state, meta, out_idx_ref)
result_exprs.append(result_var)
num_args_tallied += meta.arg_count
return result_exprs, num_args_tallied
def _emit_input_unwrapping(
state: _CodegenState,
inp_metas: list[PlainTensorMeta | SubclassCreationMeta],
frozen_inp_indices: frozenset[int] = frozenset(),
include_symints: bool = True,
) -> None:
"""Emit unwrapping code for input metas into unwrapped_args.
Caller must have already emitted ``unwrapped_args = []``.
"""
for i, meta in enumerate(inp_metas):
if isinstance(meta, PlainTensorMeta):
state.emit(f"unwrapped_args.append(args[{i}])")
elif i in frozen_inp_indices:
# Frozen by inductor freezing: constant already baked into graph.
state.emit("unwrapped_args.append(None)")
else:
inp_var = state.fresh_name("_inp")
type_name = state.add_global(
state.fresh_name("_expected_type"),
meta.original_subclass_type or type(meta.original_subclass),
)
state.emit(f"{inp_var} = args[{i}]")
state.emit(
f"assert type({inp_var}) is {type_name}, "
f"f'expected {{{type_name}}}, got {{type({inp_var})}}'",
)
_codegen_unwrap_subclass(
state, meta, inp_var, indent=1, include_symints=include_symints
)
def _codegen_subclass_wrapper_source(
inp_metas: list[PlainTensorMeta | SubclassCreationMeta],
out_metas: list[PlainTensorMeta | SubclassCreationMeta],
num_fw_outs_saved_for_bw: int | None,
frozen_inp_indices: frozenset[int] = frozenset(),
act_input_indices: list[int] | None = None,
) -> tuple[str, dict[str, object]]:
"""Generate source and globals for a subclass wrapper.
Returns (source, globals_dict). The globals_dict will NOT contain
``compiled_fn`` — the caller is responsible for adding it before exec.
"""
state = _CodegenState()
state.emit("def inner_fn(args):", indent=0)
# --- Resolve AsyncCollectiveTensors ---
# ACTs are transient eager-mode wrappers for async collective overlap.
# Inductor triton kernels bypass __torch_dispatch__, so we must call
# trigger_wait() before the compiled graph uses the data.
if act_input_indices:
for i in act_input_indices:
state.emit(f"args[{i}] = args[{i}].trigger_wait()")
# --- Input unwrapping ---
state.emit("unwrapped_args = []")
_emit_input_unwrapping(state, inp_metas, frozen_inp_indices=frozen_inp_indices)
# Pass through any trailing args not covered by inp_metas
# (e.g. rng seed/offset added by FunctionalizedRngRuntimeWrapper).
num_inp_metas = len(inp_metas)
state.emit(f"unwrapped_args.extend(args[{num_inp_metas}:])")
state.emit("args.clear()")
# --- Call compiled function ---
state.emit("unwrapped_outs = compiled_fn(unwrapped_args)")
# --- Output wrapping ---
result_exprs, num_args_tallied = _emit_output_wrapping(state, out_metas)
result_tuple = f"({', '.join(result_exprs)},)" if result_exprs else "()"
if num_fw_outs_saved_for_bw is not None:
state.emit(
f"return {result_tuple} + tuple(unwrapped_outs[{num_args_tallied}:])"
)
else:
state.emit(f"return {result_tuple}")
source = "\n".join(state.lines)
return source, state.globals
def _codegen_subclass_wrap_source(
out_metas: list[PlainTensorMeta | SubclassCreationMeta],
) -> tuple[str, dict[str, object]]:
"""Generate source for wrapping flat outputs into subclasses.
Used for the backward epilogue. Shares output-wrapping logic with
_codegen_subclass_wrapper_source via _emit_output_wrapping.
"""
state = _CodegenState()
state.emit("def wrap_fn(unwrapped_outs):", indent=0)
result_exprs, _ = _emit_output_wrapping(state, out_metas)
result_tuple = f"({', '.join(result_exprs)},)" if result_exprs else "()"
state.emit(f"return {result_tuple}")
source = "\n".join(state.lines)
return source, state.globals
def _compile_and_exec_source(
source: str,
globals_dict: dict[str, object],
fn_name: str,
artifact_name: str,
wrapped_fn: Callable[..., object] | None = None,
) -> Callable[..., object]:
"""Compile generated source, exec it, and return the named function.
If wrapped_fn is provided, applies functools.update_wrapper so that
__wrapped__ and __dict__ (e.g. _fx_graph_cache_key) propagate to the
generated function.
"""
if log.isEnabledFor(logging.DEBUG):
log.debug("Generated %s:\n%s", artifact_name, source)
torch._logging.trace_structured(
"artifact",
metadata_fn=lambda: {
"name": artifact_name,
"encoding": "string",
},
payload_fn=lambda: source,
)
code = compile(source, f"<{artifact_name}>", "exec")
local_dict: dict[str, object] = {}
exec(code, globals_dict, local_dict) # noqa: S102
fn = local_dict[fn_name]
if wrapped_fn is not None:
functools.update_wrapper(fn, wrapped_fn) # type: ignore[arg-type]
return fn # type: ignore[return-value]
def codegen_backward_subclass_fns(
grad_input_metas: list[PlainTensorMeta | SubclassCreationMeta] | None = None,
) -> tuple[Callable[..., object], Callable[..., object] | None]:
"""Generate codegen'd unwrap and wrap functions for the backward pass.
Returns (unwrap_fn, wrap_fn). unwrap_fn is used by the backward prologue
to unwrap non-tangent subclass inputs (always an identity in AOT dispatch
since the compiled forward operates on unwrapped inner tensors). wrap_fn
is used by the backward epilogue to wrap flat grad inputs back into
subclasses; it is None when grad_input_metas is None.
"""
source = "def unwrap_fn(args):\n return list(args)"
globals_dict: dict[str, object] = {}
unwrap_fn = _compile_and_exec_source(
source, globals_dict, "unwrap_fn", "backward_subclass_unwrap"
)
wrap_fn = None
if grad_input_metas is not None:
wrap_source, wrap_globals = _codegen_subclass_wrap_source(grad_input_metas)
wrap_fn = _compile_and_exec_source(
wrap_source, wrap_globals, "wrap_fn", "backward_subclass_wrapper"
)
return unwrap_fn, wrap_fn
def codegen_subclass_wrapper(
compiled_fn: Callable[..., object],
inp_metas: list[PlainTensorMeta | SubclassCreationMeta],
out_metas: list[PlainTensorMeta | SubclassCreationMeta],
num_fw_outs_saved_for_bw: int | None,
frozen_inp_indices: frozenset[int] = frozenset(),
act_input_indices: list[int] | None = None,
) -> Callable[..., object]:
"""Generate a specialized wrapper function for subclass unwrap/wrap."""
source, globals_dict = _codegen_subclass_wrapper_source(
inp_metas,
out_metas,
num_fw_outs_saved_for_bw,
frozen_inp_indices,
act_input_indices=act_input_indices,
)
globals_dict["compiled_fn"] = compiled_fn
return _compile_and_exec_source(
source, globals_dict, "inner_fn", "subclass_wrapper", wrapped_fn=compiled_fn
)
@@ -0,0 +1,138 @@
from __future__ import annotations
import dataclasses
import itertools
from typing import Any, TYPE_CHECKING
import torch
from torch._library.opaque_object import is_opaque_reference_type
from torch._opaque_base import OpaqueBase
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
from .schemas import OpaqueMeta
if TYPE_CHECKING:
from collections.abc import Iterable
# This is technically very similar to SubclassCreatingMeta
# in aot_autograd, but we don't need all the stuff in there
# so just recreated a new dataclass.
@dataclasses.dataclass
class SubclassCreationMeta:
start_idx: int
num_tensors: int
class_type: Any
# None means the attr is a plain tensor (base case of recursion)
attrs: dict[str, SubclassCreationMeta | OpaqueMeta | None]
metadata: Any
outer_size: Iterable[None | int | torch.SymInt]
outer_stride: Iterable[None | int | torch.SymInt]
class UnwrapTensorSubclass(torch.nn.Module):
def forward(self, *tensors) -> torch.Tensor: # type: ignore[no-untyped-def]
todo: list[torch.Tensor | OpaqueBase] = list(tensors)
def _unwrap_tensor_subclasses(subclass_meta, tensors, offset): # type: ignore[no-untyped-def]
if subclass_meta is None:
return tensors[offset], offset + 1
inner_tensors = {}
for attr, meta in subclass_meta.attrs.items():
if isinstance(meta, OpaqueMeta):
inner_tensors[attr] = tensors[offset]
offset += 1
else:
built_tensor, offset = _unwrap_tensor_subclasses(
meta, tensors, offset
)
inner_tensors[attr] = built_tensor
rebuilt = subclass_meta.class_type.__tensor_unflatten__(
inner_tensors,
subclass_meta.metadata,
subclass_meta.outer_size,
subclass_meta.outer_stride,
)
return rebuilt, offset
return _unwrap_tensor_subclasses(self.subclass_meta, todo, 0)[0]
def right_inverse(self, tensor: torch.Tensor) -> list[torch.Tensor | OpaqueBase]:
if type(tensor) is torch.Tensor:
raise AssertionError("tensor must be a subclass, not torch.Tensor")
plain_tensors: list[torch.Tensor | OpaqueBase] = []
def _create_subclass_meta(tensor, idx, plain_tensor_container): # type: ignore[no-untyped-def]
if type(tensor) is torch.Tensor:
plain_tensor_container.append(tensor)
return None, idx + 1
inner_tensors_attrnames, metadata = tensor.__tensor_flatten__() # type: ignore[attr-defined]
new_idx = idx
attr_to_meta: dict[str, SubclassCreationMeta | OpaqueMeta | None] = {}
for attr in inner_tensors_attrnames:
val = getattr(tensor, attr)
match val:
case OpaqueBase():
if not is_opaque_reference_type(type(val)):
raise ValueError(
f"{type(val).__name__!r} found in tensor attrs of "
f"{type(tensor).__name__}.__tensor_flatten__(). "
"Only tensors and reference-type opaques are allowed "
"in tensor attrs."
)
attr_to_meta[attr] = OpaqueMeta()
plain_tensor_container.append(val)
new_idx += 1
case torch.Tensor():
subclass_meta, new_idx = _create_subclass_meta(
val, new_idx, plain_tensor_container
)
attr_to_meta[attr] = subclass_meta
case _:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(val)}"
)
return (
SubclassCreationMeta(
start_idx=idx,
num_tensors=new_idx - idx,
class_type=type(tensor),
attrs=attr_to_meta,
metadata=metadata,
outer_size=tensor.size(),
outer_stride=tensor.stride(),
),
new_idx,
)
self.subclass_meta = _create_subclass_meta(tensor, 0, plain_tensors)[0]
return plain_tensors
def unwrap_tensor_subclass_parameters(module: torch.nn.Module) -> torch.nn.Module:
"""
Model transformation that replaces all the parameters that are subclasses to plain tensors.
This reduces runtime overhead of flattening/unflattening the parameters.
This transformation adds parametrization with `torch.nn.utils.parametrize`.
The FQNs of the subclass parameters will be changed and state_dict will become incompatible with the original model.
E.g.
Original model state_dict: {"p1": torch.testing._internal.TwoTensor}
becomes: {"parametrizations.p2.original0": torch.Tensor, "parametrizations.p2.original1": torch.Tensor}
"""
for name, tensor in itertools.chain(
list(module.named_parameters(recurse=False)),
# pyrefly: ignore [bad-argument-type, no-matching-overload]
list(module.named_buffers(recurse=False)),
):
if is_traceable_wrapper_subclass(tensor):
torch.nn.utils.parametrize.register_parametrization(
module, name, UnwrapTensorSubclass()
)
for child in module.children():
unwrap_tensor_subclass_parameters(child)
return module
@@ -0,0 +1,655 @@
"""
This file contains utilities for tracing through __torch_dispatch__ based tensor subclasses and modes.
AOTAutograd's responsibility is to trace through all pytorch capabilities that live in the pytorch dispatcher,
and this includes tensor subclasses that implement __torch_dispatch__.
"""
import collections
import typing
from collections.abc import Callable, Iterable, Sequence
from typing import Any, TypeGuard, TypeVar
import torch
import torch.utils._pytree as pytree
from torch import SymInt, Tensor
from torch._library.fake_class_registry import maybe_unwrap_fake_script_object
from torch._library.opaque_object import is_opaque_reference_type
from torch._opaque_base import OpaqueBase
from torch._subclasses.fake_tensor import get_plain_tensors
from torch.types import IntLikeType
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
from .descriptors import (
AOTInput,
AOTOutput,
DummyAOTInput,
SubclassGetAttrAOTInput,
SubclassGetAttrAOTOutput,
SubclassSizeAOTInput,
SubclassSizeAOTOutput,
SubclassStrideAOTInput,
SubclassStrideAOTOutput,
)
from .schemas import (
FakifiedFlatArgs,
FxValue,
MutationType,
OpaqueMeta,
PlainTensorMeta,
SubclassCreationMeta,
ViewAndMutationMeta,
)
from .utils import strict_zip
zip = strict_zip
T = TypeVar("T", bound=torch.Tensor)
def requires_subclass_dispatch(
args: FakifiedFlatArgs, fw_metadata: ViewAndMutationMeta
) -> bool:
args_flattened = pytree.arg_tree_leaves(*args)
any_subclass_args = any(
is_traceable_wrapper_subclass(x)
for x in args_flattened
if isinstance(x, Tensor)
)
from torch._functorch._aot_autograd.schemas import SubclassCreationMeta
any_subclass_outputs = any(
type(x) is SubclassCreationMeta for x in fw_metadata.subclass_fw_graph_out_meta
)
# This tells us whether or not we need to perform any unwrapping/wrapping of tensor subclasses at runtime.
return bool(any_subclass_args or any_subclass_outputs)
from .schemas import MemoryFormatMeta
def maybe_suggest_memory_format(
t: Tensor, with_memory_format: bool
) -> MemoryFormatMeta | None:
if not with_memory_format:
return None
return MemoryFormatMeta.from_tensor(t)
def get_subclass_typing_container(
tensor_subclass: torch.Tensor,
) -> dict[type[torch.Tensor], list[type[torch.Tensor]]]:
"""
Given a subclass, returns a recursive dictionary mapping each
inner tensors to its' subclass types.
"""
def _get_types_for_subclass(tensor_subclass: torch.Tensor) -> None:
if not is_traceable_wrapper_subclass(tensor_subclass):
return
tracker[type(tensor_subclass)].append(tensor_subclass)
inner_keys, _ = tensor_subclass.__tensor_flatten__()
for key in inner_keys:
match getattr(tensor_subclass, key):
case torch.Tensor() as inner_value:
_get_types_for_subclass(inner_value)
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
tracker: dict[Any, list[Any]] = collections.defaultdict(list)
_get_types_for_subclass(tensor_subclass)
return tracker
def create_subclass_metadata(
a: Any,
start_idx: int,
count_symints: bool,
with_memory_format: bool = False,
) -> tuple[Any, int]:
if not is_traceable_wrapper_subclass(a):
idx = start_idx + 1
return (
PlainTensorMeta(
idx,
memory_format=maybe_suggest_memory_format(a, with_memory_format),
),
idx,
)
inner_keys, metadata = a.__tensor_flatten__()
new_start_idx = start_idx
attrs: dict[str, SubclassCreationMeta | PlainTensorMeta | OpaqueMeta] = {}
for key in inner_keys:
inner_value = getattr(a, key)
match inner_value:
case OpaqueBase():
# During tracing, opaques are wrapped in FakeScriptObject;
# unwrap to check the real type.
real_type = type(maybe_unwrap_fake_script_object(inner_value))
if not is_opaque_reference_type(real_type):
raise RuntimeError(
f"{real_type.__name__!r} found in tensor attrs of "
f"{type(a).__name__}.__tensor_flatten__(). "
"Only tensors and reference-type opaques are allowed "
"in tensor attrs."
)
attrs[key] = OpaqueMeta()
new_start_idx += 1
case Tensor():
new_subclass_meta, new_start_idx = create_subclass_metadata(
inner_value,
new_start_idx,
count_symints=count_symints,
with_memory_format=with_memory_format,
)
attrs[key] = new_subclass_meta
case _:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(inner_value)}"
)
# It *must* be because is_traceable_wrapper_subclass() - but mypy is not smart.
if not isinstance(a, Tensor):
raise AssertionError(f"expected Tensor, got {type(a)}")
new_start_idx = (
new_start_idx
+ count_symints * len(enumerate_filter_symints(a.size()))
+ count_symints * len(enumerate_filter_symints(a.stride()))
)
return (
SubclassCreationMeta(
flat_tensor_start_idx=start_idx,
arg_count=new_start_idx - start_idx,
included_subclass_symints=count_symints,
attrs=attrs,
meta=metadata,
outer_size=a.size(), # type: ignore[attr-defined, arg-type]
outer_stride=a.stride(), # type: ignore[arg-type]
original_subclass=a,
memory_format=maybe_suggest_memory_format(a, with_memory_format),
),
new_start_idx,
)
# Given a flat list of arguments, some of which may be tensor subclasses,
# computes metadata about "how to reconstruct the current list of subclasses,
# if we were given their flattened dense tensors instead"
def create_subclass_meta(
curr_args: list[Any] | tuple[Any, ...],
*,
count_symints: bool = True,
with_memory_format: bool = False,
) -> list[PlainTensorMeta | SubclassCreationMeta]:
idx = 0
infos: list[PlainTensorMeta | SubclassCreationMeta] = []
for a in curr_args:
if is_traceable_wrapper_subclass(a):
if not isinstance(a, Tensor):
raise AssertionError(
f"expected Tensor for traceable wrapper subclass, got {type(a)}"
)
start_idx = idx
subclass_meta, _ = create_subclass_metadata(
a,
start_idx,
count_symints=count_symints,
with_memory_format=with_memory_format,
)
infos.append(subclass_meta)
cnt = subclass_meta.arg_count
else:
infos.append(
PlainTensorMeta(
idx,
memory_format=maybe_suggest_memory_format(a, with_memory_format),
)
)
cnt = 1
idx += cnt
return infos
def enumerate_filter_symints(lst: Iterable[IntLikeType]) -> list[tuple[int, SymInt]]:
# Capture all SymInts from the iterable.
def symint_check(s: IntLikeType) -> TypeGuard[SymInt]:
return isinstance(s, SymInt) and not s.node.is_nested_int()
return [(i, s) for i, s in enumerate(lst) if symint_check(s)]
def compute_symint_placeholders(lst: Iterable[None | int | SymInt]) -> list[bool]:
# Non-nested symints are replaced with None in `make_runtime_safe()`
return [s is None for s in lst]
# Intended to make it easier to define function that is
# either (AOTInput -> AOTInput) or (AOTOutput -> AOTOutput)
# but not the other combos
AOTDescriptor = TypeVar("AOTDescriptor", AOTInput, AOTOutput)
# This function takes in a pytree of arguments and unwraps any tensor
# subclasses.
#
# NOTE: The reason for "append_symints":
#
# * At compile time: we append extra symint args when unwrapping primals
# (but not tangents, because they should always share symints with primals).
# We also append extra symints when unwrapping the subclass outputs of the
# traced function, so we can return them as extra outputs
#
# * At runtime: we similarly append subclass sizes when we unwrap subclass
# primals (but not tangents) on entry to the forward. See the runtime version of
# this function below.
def unwrap_tensor_subclasses(
wrapped_args: list[FxValue],
wrapped_args_descs: Sequence[AOTDescriptor],
*,
append_symints: bool,
) -> tuple[list[FxValue], list[AOTDescriptor]]:
def _maybe_fakeify_opaque(v: Any) -> Any:
# Registered opaque types need to be wrapped as FakeScriptObject for
# compile-time FX tracing (proxy slot tracking, hashability, etc.).
if isinstance(v, OpaqueBase):
from torch._guards import detect_fake_mode
from torch._library.fake_class_registry import maybe_to_fake_obj
from torch._library.opaque_object import is_opaque_type
fake_mode = detect_fake_mode()
if fake_mode is not None and is_opaque_type(type(v)):
return maybe_to_fake_obj(fake_mode, v)
return v
def flatten_subclass(
t: FxValue,
desc: AOTDescriptor,
*,
out: tuple[list[FxValue], list[AOTDescriptor]],
) -> None:
# unwrap a subclass into plain tensors and their size/stride if "append_symint"
# is True
if not is_traceable_wrapper_subclass(t):
out[0].append(_maybe_fakeify_opaque(t))
out[1].append(desc)
return
attrs, _ = t.__tensor_flatten__()
SubclassGetAttr: Callable[[AOTInput | AOTOutput, str], AOTDescriptor]
SubclassSize: Callable[[AOTInput | AOTOutput, int], AOTDescriptor]
SubclassStride: Callable[[AOTInput | AOTOutput, int], AOTDescriptor]
if isinstance(desc, AOTInput):
SubclassGetAttr = SubclassGetAttrAOTInput # type: ignore[bad-assignment]
SubclassSize = SubclassSizeAOTInput # type: ignore[bad-assignment]
SubclassStride = SubclassStrideAOTInput # type: ignore[bad-assignment]
else:
SubclassGetAttr = SubclassGetAttrAOTOutput # type: ignore[bad-assignment]
SubclassSize = SubclassSizeAOTOutput # type: ignore[bad-assignment]
SubclassStride = SubclassStrideAOTOutput # type: ignore[bad-assignment]
for attr in attrs:
inner_value = getattr(t, attr)
n_desc: Any = SubclassGetAttr(desc, attr)
flatten_subclass(inner_value, n_desc, out=out)
if append_symints:
sizes = enumerate_filter_symints(t.size())
strides = enumerate_filter_symints(t.stride())
out[0].extend(s for _, s in sizes)
out[0].extend(s for _, s in strides)
out[1].extend(SubclassSize(desc, i) for i, _ in sizes)
out[1].extend(SubclassStride(desc, i) for i, _ in strides)
xs_inner: list[FxValue] = []
descs_inner: list[AOTDescriptor] = []
for x, desc in zip(wrapped_args, wrapped_args_descs):
flatten_subclass(x, desc, out=(xs_inner, descs_inner))
return xs_inner, descs_inner
# subclass_metas is needed at runtime to compute which indices are symints in
# the outer_size/outer_stride
def runtime_unwrap_tensor_subclasses(
wrapped_args: list[Tensor | int],
*,
append_symints: bool,
subclass_metas: list[PlainTensorMeta | SubclassCreationMeta] | None = None,
) -> list[int | Tensor | SymInt | OpaqueBase]:
def flatten_subclass(
x: Tensor,
subclass_meta: PlainTensorMeta | SubclassCreationMeta | OpaqueMeta | None,
*,
out: list[OpaqueBase | SymInt | Tensor | int],
) -> list[OpaqueBase | SymInt | Tensor | int]:
if not is_traceable_wrapper_subclass(x):
out.append(x)
return out
if not isinstance(x, Tensor):
raise AssertionError(f"expected Tensor, got {type(x)}")
if not isinstance(subclass_meta, SubclassCreationMeta):
raise AssertionError("subclass_meta should be a SubclassCreationMeta")
attrs, _ = x.__tensor_flatten__()
for attr in attrs:
inner_value = getattr(x, attr)
match inner_value:
case OpaqueBase():
out.append(inner_value)
case Tensor():
inner_meta = subclass_meta.attrs.get(attr)
flatten_subclass(inner_value, inner_meta, out=out)
case _:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(inner_value)}"
)
if append_symints:
# outer_size
size = x.size()
symint_placeholders = compute_symint_placeholders(subclass_meta.outer_size)
if len(size) != len(symint_placeholders):
raise AssertionError(
f"size length mismatch: {len(size)} != {len(symint_placeholders)}"
)
out.extend(
[r for (r, is_symint) in zip(size, symint_placeholders) if is_symint]
)
# outer_stride
stride = x.stride()
symint_placeholders = compute_symint_placeholders(
subclass_meta.outer_stride
)
if len(stride) != len(symint_placeholders):
raise AssertionError(
f"stride length mismatch: {len(stride)} != {len(symint_placeholders)}"
)
out.extend(
[r for (r, is_symint) in zip(stride, symint_placeholders) if is_symint]
)
return out
xs_inner: list[int | Tensor | SymInt | OpaqueBase] = []
if append_symints:
if subclass_metas is None:
raise AssertionError(
"subclass_metas must not be None when append_symints is True"
)
for idx, x in enumerate(wrapped_args):
if not is_traceable_wrapper_subclass(x):
xs_inner.append(x)
continue
if subclass_metas is None:
get_plain_tensors(typing.cast(Tensor, x), out=xs_inner)
else:
subclass_meta = subclass_metas[idx]
if not isinstance(subclass_meta, SubclassCreationMeta):
raise AssertionError(
f"expected SubclassCreationMeta, got {type(subclass_meta)}"
)
flatten_subclass(typing.cast(Tensor, x), subclass_meta, out=xs_inner)
return xs_inner
def unwrap_tensor_subclasses_with_indices_to_original(
wrapped_args: list[Any],
) -> tuple[list[Any], list[int]]:
ret_unwrapped = []
ret_indices_to_original = []
for i, a in enumerate(wrapped_args):
a_unwrapped, _ = unwrap_tensor_subclasses(
[a], [DummyAOTInput(9999)], append_symints=False
)
ret_unwrapped.extend(a_unwrapped)
n = len(a_unwrapped)
ret_indices_to_original.extend([i] * n)
return ret_unwrapped, ret_indices_to_original
def remap_unwrapped_subclass_arg_indices(
wrapped_args: list[Any], static_input_indices: list[int]
) -> list[int]:
static_input_indices_set = set(static_input_indices)
new_ind = 0
remapped_static_indices = []
for i, arg in enumerate(wrapped_args):
num_indices = 1
if is_traceable_wrapper_subclass(arg):
num_indices = (
len(get_plain_tensors(typing.cast(Tensor, arg), out=[]))
+ len(enumerate_filter_symints(arg.size()))
+ len(enumerate_filter_symints(arg.stride()))
)
for _ in range(num_indices):
if i in static_input_indices_set:
remapped_static_indices.append(new_ind)
new_ind += 1
return remapped_static_indices
# Turns a flattened list of tensor arguments into (maybe) subclass tensors.
# This function is used both at trace time and runtime, so we have an is_runtime flag telling us which context we're in.
def wrap_tensor_subclasses(
unwrapped_args: Sequence[Any],
*,
subclass_metas: list[PlainTensorMeta | SubclassCreationMeta],
num_fw_outs_saved_for_bw: int | None = None,
included_subclass_symints: bool = False,
is_runtime: bool = False,
make_subclass_override: Callable[..., Any] | None = None,
) -> tuple[Any, ...]:
# pyrefly: ignore [implicit-any]
wrapped_args = []
num_args_tallied = 0
for subclass_meta in subclass_metas:
if isinstance(subclass_meta, PlainTensorMeta):
wrapped_args.append(unwrapped_args[subclass_meta.unwrapped_idx])
num_args_tallied += 1
else:
if not isinstance(subclass_meta, SubclassCreationMeta):
raise AssertionError(
f"expected SubclassCreationMeta, got {type(subclass_meta)}"
)
if subclass_meta.included_subclass_symints != included_subclass_symints:
raise AssertionError(
f"included_subclass_symints mismatch: {subclass_meta.included_subclass_symints} != {included_subclass_symints}"
)
if make_subclass_override:
wrapped_args.append(
make_subclass_override(subclass_meta, is_runtime, unwrapped_args)
)
else:
wrapped_args.append(
subclass_meta.creation_fn(
unwrapped_args,
is_runtime=is_runtime,
)
)
num_args_tallied += subclass_meta.arg_count
# Note: [Partitioner handling for Subclasses, Part 2]
# At the beginning of AOTAutograd, we collect metadata on the inputs and outputs of the user fw,
# to figure out which inputs/outputs are subclasses, and how to reconstruct the subclasses after flattening them.
#
# When this function is called at runtime in the forward,
# we have been passed a list of (flattened) dense-tensor fw-outs, and need to reconstruct any subclass fw outs.
#
# One reasonable question that you should ask: when should the dense_tensor -> subclass_tensor wrapping happen?
# Answer: we do it **inside of our compiled autograd.Function**.
# This seems like morally the right place: autograd happens above subclass desugaring,
# so autograd should see actual tensor subclasses at runtime, and not flattened dense tensors.
#
# This causes a tricky interaction though: when we run the min-cut partitioner to divvy up the joint graph
# into a forward and backward graph, we end up with some activations that show up as extra outputs
# in the compiled forward graph, that are **not** user outputs.
# These activations are not visible to the user, and so there's no need for us to wrap them back into subclasses.
#
# On top of that, when we first computed subclass metadata (in `run_functionalized_fw_and_collect_metadata`),
# we computed subclass metadata on every forward output, but this did **not** include activations
# created by the partitioner.
# as a result, `unwrapped_args` here will correspond to (*unwrapped_user_fw_outs, *activations),
# but `subclass_metas` will only correspond to subclass metadata on `user_fw_outs`.
# We then need to make sure that we return (*wrapped_user_fw_outs, *activations).
if num_fw_outs_saved_for_bw is not None:
if len(unwrapped_args) != num_args_tallied + num_fw_outs_saved_for_bw:
raise AssertionError(
f"Expected the number actual unwrapped-subclass outputs {len(unwrapped_args)} to equal "
f"the number of args calculated from subclasses ({num_args_tallied}) plus the number of "
f"additional activations saved for the backward pass ({num_fw_outs_saved_for_bw})"
)
activations = unwrapped_args[num_args_tallied:]
if isinstance(wrapped_args, tuple) and isinstance(activations, tuple):
return wrapped_args + activations
return tuple(list(wrapped_args) + list(activations))
else:
if len(unwrapped_args) != num_args_tallied:
raise AssertionError(
f"Expected {len(unwrapped_args)} == {num_args_tallied}"
)
return tuple(wrapped_args)
# Given a bunch of "dense" tensor arguments, this function (potentially) wraps them into tensor subclasses.
# This function carefully handles the inference vs. joint cases:
# - when is_joint_structure is True, args is (primals, tangents)
# - when is_joint_structure is False, args is [*primals]
def wrap_tensor_subclasses_maybe_joint(
unwrapped_args: Sequence[Any],
*,
is_joint_structure: bool,
meta: ViewAndMutationMeta,
) -> tuple[Any, ...]:
# Since this function is reused for both inference and joint graphs,
if is_joint_structure:
if not (isinstance(unwrapped_args, tuple) and len(unwrapped_args) == 2):
unwrapped_len = (
len(unwrapped_args)
if isinstance(unwrapped_args, (tuple, list))
else "N/A"
)
raise AssertionError(
f"expected tuple of length 2 for joint structure, "
f"got {type(unwrapped_args)} with length {unwrapped_len}"
)
if not (
isinstance(unwrapped_args[0], (tuple, list))
and isinstance(unwrapped_args[1], (tuple, list))
):
raise AssertionError(
f"expected primals and tangents to be tuple or list, got {type(unwrapped_args[0])} and {type(unwrapped_args[1])}"
)
primals, tangents = unwrapped_args[0], unwrapped_args[1]
wrapped_primals = wrap_tensor_subclasses(
primals,
subclass_metas=meta.subclass_inp_meta,
included_subclass_symints=True,
)
wrapped_tangents = wrap_tensor_subclasses(
tangents,
subclass_metas=meta.subclass_tangent_meta,
included_subclass_symints=False,
)
return (wrapped_primals, wrapped_tangents)
else:
wrapped_args = wrap_tensor_subclasses(
unwrapped_args,
subclass_metas=meta.subclass_inp_meta,
included_subclass_symints=True,
)
return wrapped_args
def compute_inner_mutated_inp_indices_from_subclass_meta(
fw_metadata: ViewAndMutationMeta,
inner_metadata: ViewAndMutationMeta,
) -> list[int]:
# Note: [Recomputing subclass mutation handling]
#
# Generally, if a subclass requires grad, its components will not require grad.
# But for the purposes of tracking returned tensors, we should treat those component
# tensors as if they require grad.
#
# For example, if the subclass tensor requires grad and will be mutated in a way that
# requires us to handle the mutation outside of the graph, we need to return it
# from the forward graph. The inner_meta data won't consider the component tensors
# as if they need to be returned, because they don't require grad; but really, we
# should handle those tensors the same way we handle the subclass tensor itself; i.e.
# if we'd include the subclass tensor as part of the outputs, then we should also
# include the component tensors.
#
# To do this, we patch num_mutated_inp_runtime_indices below by expanding the inputs
# from the outer subclass tensors and propagating
updated_input_info = []
inner_idx = 0
if not fw_metadata.subclass_inp_meta:
# Sometimes we don't have subclass info, e.g. synthetic_base codepaths
return inner_metadata.mutated_inp_runtime_indices
if len(fw_metadata.subclass_inp_meta) != len(fw_metadata.input_info):
raise AssertionError(
f"subclass_inp_meta length ({len(fw_metadata.subclass_inp_meta)}) != input_info length ({len(fw_metadata.input_info)})"
)
for outer_idx, inp_meta in enumerate(fw_metadata.subclass_inp_meta):
if isinstance(inp_meta, PlainTensorMeta):
if outer_idx >= len(fw_metadata.input_info):
raise AssertionError(
f"outer_idx ({outer_idx}) >= len(fw_metadata.input_info) ({len(fw_metadata.input_info)})"
)
if inner_metadata is not None:
if inner_idx >= len(inner_metadata.input_info):
raise AssertionError(
f"inner_idx ({inner_idx}) >= len(inner_metadata.input_info) ({len(inner_metadata.input_info)})"
)
if (
inner_metadata.input_info[inner_idx]
!= fw_metadata.input_info[outer_idx]
):
raise AssertionError(
f"input_info mismatch at inner_idx={inner_idx}, outer_idx={outer_idx}: "
f"{inner_metadata.input_info[inner_idx]} != {fw_metadata.input_info[outer_idx]}"
)
updated_input_info.append(fw_metadata.input_info[outer_idx])
inner_idx += 1
else:
if inp_meta.original_subclass is None:
raise AssertionError(
"inp_meta.original_subclass must not be None for SubclassCreationMeta"
)
for _ in range(inp_meta.arg_count):
updated_input_info.append(fw_metadata.input_info[outer_idx])
inner_idx += 1
if inner_metadata is not None:
if len(inner_metadata.input_info) != len(updated_input_info):
raise AssertionError(
f"inner_metadata.input_info length ({len(inner_metadata.input_info)}) "
f"!= updated_input_info length ({len(updated_input_info)})"
)
return [
i
for i, inp in enumerate(updated_input_info)
if inp.mutation_type == MutationType.MUTATED_OUT_GRAPH
]
@@ -0,0 +1,835 @@
"""
Contains various utils for AOTAutograd, including those for handling collections.
"""
import copy
import dataclasses
import logging
import operator
import warnings
from collections.abc import Callable, Sequence
from contextlib import nullcontext
from functools import partial, wraps
from typing import Any, overload, TYPE_CHECKING
from typing_extensions import ParamSpec, TypeVar, TypeVarTuple, Unpack
import torch
import torch.utils._pytree as pytree
from torch._library.fake_class_registry import FakeScriptObject
from torch._library.opaque_object import is_opaque_value
from torch._logging import getArtifactLogger
from torch._subclasses.fake_tensor import FakeTensor
from torch._subclasses.functional_tensor import FunctionalTensor
from torch.fx.experimental._backward_state import BackwardState
from torch.fx.experimental.proxy_tensor import py_sym_types
_T = TypeVar("_T")
if TYPE_CHECKING:
from .schemas import AOTConfig, ViewAndMutationMeta
KNOWN_TYPES = [
torch.Tensor,
BackwardState,
int,
str,
float,
bool,
type(None),
*py_sym_types,
FakeScriptObject,
torch.ScriptObject,
]
aot_graphs_effects_log = getArtifactLogger(__name__, "aot_graphs_effects")
annotation_log = getArtifactLogger(__name__, "annotation")
strict_zip = partial(zip, strict=True)
def partial_flatten_asdict(obj: object) -> Any:
if dataclasses.is_dataclass(obj):
return {
field.name: getattr(obj, field.name) for field in dataclasses.fields(obj)
}
elif isinstance(obj, (list, tuple)):
return obj.__class__([partial_flatten_asdict(item) for item in obj])
elif isinstance(obj, dict):
return {k: partial_flatten_asdict(v) for k, v in obj.items()}
else:
return obj
@overload
def normalize_as_list(x: _T) -> list[_T]: ...
@overload
def normalize_as_list(x: tuple[_T, ...]) -> list[_T]: ...
@overload
def normalize_as_list(x: list[_T]) -> list[_T]: ...
def normalize_as_list(x: object) -> list[object]:
if isinstance(x, tuple):
return list(x)
elif isinstance(x, list):
return x
return [x]
def _get_autocast_states() -> list[Any]:
return [
torch.is_autocast_enabled("cuda"),
torch.is_autocast_enabled("cpu"),
torch.get_autocast_dtype("cuda"),
torch.get_autocast_dtype("cpu"),
torch.is_autocast_cache_enabled(),
]
def make_boxed_func(f: Callable[..., Any]) -> Callable[[list[Any]], Any]:
@simple_wraps(f)
def g(args: list[Any]) -> Any:
return f(*args)
# pyrefly: ignore[missing-attribute]
g._boxed_call = True
return g
def make_boxed_compiler(
compiler: Callable[..., Any],
) -> Callable[..., Any]:
@wraps(compiler)
def f(fx_g: Any, inps: Any) -> Any:
out_f = compiler(fx_g, inps)
fx_g = make_boxed_func(out_f)
return fx_g
return f
def call_func_at_runtime_with_args(
f: Callable[..., Any],
args: Sequence[Any],
steal_args: bool = False,
disable_amp: bool = False,
) -> list[Any]:
if not steal_args:
args = list(args)
if not isinstance(args, list):
raise AssertionError(f"args must be a list, got {type(args)}")
context = torch._C._DisableAutocast if disable_amp else nullcontext
with context():
if getattr(f, "_boxed_call", False):
out = normalize_as_list(f(args))
else:
# TODO: Please remove soon
# https://github.com/pytorch/pytorch/pull/83137#issuecomment-1211320670
warnings.warn(
"Your compiler for AOTAutograd is returning a function that doesn't take boxed arguments. "
"Please wrap it with functorch.compile.make_boxed_func or handle the boxed arguments yourself. "
"See https://github.com/pytorch/pytorch/pull/83137#issuecomment-1211320670 for rationale.",
stacklevel=2,
)
out = normalize_as_list(f(*args))
return out
# Inspired by autodidax (thanks!)
class PytreeThunk:
spec: pytree.TreeSpec | None = None
# These are some kinda dumb microoptimizations that save about 3-4 us of overhead.
is_simple: bool | None = (
None # if the output spec is a tuple/list, we won't bother unflattening it.
)
is_really_simple: bool | None = None # if the output spec is a LeafSpec
def set(self, spec: pytree.TreeSpec) -> None:
if not (self.spec is None or self.spec == spec):
raise AssertionError(f"spec mismatch: existing={self.spec}, new={spec}")
if spec is None:
raise AssertionError("spec must not be None")
self.spec: pytree.TreeSpec = spec
if self.spec.type in {tuple, list} and all(
child.is_leaf() for child in spec.children()
):
self.is_simple = True
if self.spec.is_leaf():
self.is_really_simple = True
def unflatten(self, x: Sequence[Any]) -> Any:
if self.is_really_simple:
return x[0]
if self.is_simple:
return x
if self.spec is None:
raise AssertionError("spec must be set before calling unflatten")
return pytree.tree_unflatten(x, self.spec)
# Creates a function that returns flattened inputs and outputs
# Also returns the output tree spec, which is needed to recover the "unflattened"
# output tree structure later.
def create_tree_flattened_fn(
fn: Callable[..., Any],
args: Sequence[Any],
kwargs: dict[str, Any] | None = None,
) -> tuple[Callable[..., list[Any]], PytreeThunk]:
if kwargs is None:
kwargs = {}
# Save the args_spec for flat_tensor_args to unflatten while tracing
_, tensor_args_spec = pytree.tree_flatten((args, kwargs))
out_spec = PytreeThunk()
def flat_fn(*flat_args: Any) -> list[Any]:
# The input are flattened tensor args. Prepare the args in the
# order that original function expects. Add static args as well.
# They will appear as tensor constants in the traced graph.
nonlocal out_spec
args, kwargs = pytree.tree_unflatten(flat_args, tensor_args_spec)
tree_out = fn(*args, **kwargs)
flat_out, spec = pytree.tree_flatten(tree_out)
for i in flat_out:
is_known_type = isinstance(i, tuple(KNOWN_TYPES)) or is_opaque_value(i)
if not is_known_type:
raise RuntimeError(
f"Found {type(i)} in output, which is not a known type. "
"If this type holds tensors, you need to register a pytree for it. "
"See https://github.com/pytorch/functorch/issues/475 for a brief "
"explanation why. If you don't need to register a pytree, please "
"leave a comment explaining your use case and we'll make this more "
"ergonomic to deal with"
)
out_spec.set(spec)
return flat_out
# Can't use functools.wraps here because the wrapper has different
# calling convention
if hasattr(fn, "_orig_mod"):
# pyrefly: ignore[missing-attribute]
flat_fn._orig_mod = fn._orig_mod
return flat_fn, out_spec
# This function takes in a tensor t, and returns one of t, t.view(), or t.clone().
# When tracing the joint forward + backward, for any inputs in the graph that are mutated,
# we need to clone them first (and similarly for metadata-only mutations, we need to view them first).
# The idea is that when we trace the backward, we need to pass in the *original* primals
# to autograd.grad(), before they were mutated.
# Note: when we have synthetic base inputs, we need to clone them *before* creating views off of them.
# This means that "idx" here represents the index of the (potentially) synthetic base.
# What we need to do is:
# (1) map the current (post-synthetic-base calling convention) input argument index
# to int index pre-synthetic-base-calling-convention.
# (2) There could be multiple, if this index corresponds to a synthetic base
# that has multiple input aliases.
# (3) If any of those corresponding inputs get metadata mutations, then we clone the base.
def maybe_to_fresh_input(idx: int, t: Any, meta: "ViewAndMutationMeta") -> Any:
if not isinstance(t, torch.Tensor):
return t
if idx in meta.mutated_inp_runtime_indices:
# We only need to bother cloning mutated inputs that participate in autograd.
if meta.input_info[idx].requires_grad and meta.input_info[idx].mutates_data:
# Make sure the primal we pass to autograd.grad()
# sees the tensor before the mutation
return t.clone()
if meta.input_info[idx] and meta.input_info[idx].mutates_metadata:
# Make sure the primal we pass to autograd.grad()
# sees the tensor before the metadata mutation
return t.view(t.shape)
return t
def is_with_effects(node: torch.fx.Node) -> bool:
if (
node.op == "call_function"
and node.target is torch.ops.higher_order.with_effects
):
return True
elif (
node.op == "call_function"
and node.target is torch.ops.higher_order.invoke_subgraph
):
# Check if subgraph has effects by looking in the cache
from torch._guards import InvokeSubgraphCache, TracingContext
tracing_ctx = TracingContext.try_get()
if tracing_ctx:
invoke_subgraph_cache = tracing_ctx.hop_dispatch_set_cache.get_cache(
torch.ops.higher_order.invoke_subgraph
)
if invoke_subgraph_cache:
if not isinstance(invoke_subgraph_cache, InvokeSubgraphCache):
raise AssertionError(
f"expected InvokeSubgraphCache, got {type(invoke_subgraph_cache)}"
)
# pyrefly: ignore[bad-argument-type]
effects = invoke_subgraph_cache.get_effects(node.args[1])
return effects is not None
return False
def unlift_tokens(
fw_module: torch.fx.GraphModule,
fw_metadata: "ViewAndMutationMeta",
aot_config: "AOTConfig",
bw_module: torch.fx.GraphModule | None = None,
) -> None:
# Remove the tokens from the inputs/outputs of the graph since inductor does
# not want these extra inputs/outputs, and replace them with
# _make_token() to create a token, and _sink_tokens() to collect the
# tokens. See Note [Side-Effectful Tokens in AOTAutograd]
# Logic:
# 1. In the case of with_effects:
# Before:
# ```
# def forward(self, token, arg1_1):
# with_effects = torch.ops.higher_order.with_effects(token, ...)
# getitem = with_effects[0]
# getitem_1 = with_effects[0]
# return (getitem, getitem_1)
# ```
#
# After:
# ```
# def forward(self, arg1_1):
# _make_token_default = torch.ops.prims._make_token.default()
# with_effects = torch.ops.higher_order.with_effects(_make_token_default, ...)
# getitem = with_effects[0]
# getitem_1 = with_effects[0]
# _sink_tokens_default = torch.ops.prims._sink_tokens.default([getitem]);
# return (getitem_1,)
# ```
#
# 2. In the case of an invoke_subgraph node, we will use the
# InvokeSubgraphCache to determine if the subgraph has effects. Then we will
# turn it into a `with_effects` node. This is so that at the toplevel graph,
# the nodes will have the correct with_effects threading. We will apply this
# pass recursively to submodules so the tokens will be removed from the
# subgraph's inputs.
#
# Before:
# ```
# def forward(self, token, arg1_1):
# repeated_subgraph0 = self.repeated_subgraph0
# invoke_subgraph = torch.ops.higher_order.invoke_subgraph(
# repeated_subgraph0, 'subgraph_0', token, x, arg1_1)
# getitem = invoke_subgraph[0]
# getitem_1 = invoke_subgraph[1]
# return (getitem, getitem1)
# ```
#
# After:
# ```
# def forward(self, arg1_1):
# _make_token_default = torch.ops.prims._make_token.default()
# repeated_subgraph0 = self.repeated_subgraph0
# with_effects_1 = torch.ops.higher_order.with_effects(
# _make_token_default, torch.ops.higher_order.invoke_subgraph,
# repeated_subgraph0, 'subgraph_0', arg1_1)
# getitem = with_effects_1[0]
# getitem_1 = with_effects_1[1]; with_effects_1 = None
# _sink_tokens_default = torch.ops.prims._sink_tokens.default([getitem])
# return (getitem_1,)
# ```
#
# 3. The toplevel module should have the following invariants:
# forward:
# expected_num_erased_inputs == len(fw_metadata.tokens)
# expected_num_erased_outputs == len(fw_metadata.tokens)
# backward:
# expected_num_erased_inputs == fw_metadata.num_backward_tokens
# expected_num_erased_outputs == fw_metadata.num_backward_tokens
num_forward_tokens = len(fw_metadata.tokens)
num_backward_tokens = fw_metadata.num_backward_tokens
def replace_input_token_with_make_token(
module: torch.fx.GraphModule, node: torch.fx.Node
) -> None:
with module.graph.inserting_before(node):
new_token_node = module.graph.call_function(
torch.ops.prims._make_token.default, ()
)
new_token_node.meta["val"] = torch.tensor([])
new_token_node.meta["tensor_meta"] = torch.tensor([])
node.replace_all_uses_with(new_token_node)
module.graph.erase_node(node)
def get_output_tokens(node: torch.fx.Node) -> set[torch.fx.Node]:
output_tokens = set()
for user in list(node.users.keys()):
# Check if this is a getitem accessing index 0 (the token)
if (
user.op == "call_function"
and user.target is operator.getitem
and len(user.args) > 1
and user.args[1] == 0
):
# Check if this getitem is used in an output
for user_user in list(user.users.keys()):
if user_user.op == "output":
output_tokens.add(user)
return output_tokens
def _unlift_tokens_from_module_helper(
module: torch.fx.GraphModule,
subgraph_str: str,
expected_num_erased: int | None,
) -> None:
input_token_nodes = set()
output_token_nodes = set()
for node in module.graph.nodes:
if (
node.op == "call_function"
and node.target is torch.ops.higher_order.with_effects
):
if node.args[0].op == "placeholder":
input_token_nodes.add(node.args[0])
replace_input_token_with_make_token(module, node.args[0])
tokens_from_with_effects = get_output_tokens(node)
output_token_nodes = output_token_nodes | tokens_from_with_effects
elif (
node.op == "call_function"
and node.target is torch.ops.higher_order.invoke_subgraph
):
subgraph_node, identifier, *operands = node.args
# Check if subgraph has effects by looking in the cache
from torch._guards import InvokeSubgraphCache, TracingContext
effects = None
tracing_ctx = TracingContext.try_get()
if tracing_ctx:
invoke_subgraph_cache = (
tracing_ctx.hop_dispatch_set_cache.get_cache(
torch.ops.higher_order.invoke_subgraph
)
)
if invoke_subgraph_cache:
if not isinstance(invoke_subgraph_cache, InvokeSubgraphCache):
raise AssertionError(
f"expected InvokeSubgraphCache, got {type(invoke_subgraph_cache)}"
)
effects = invoke_subgraph_cache.get_effects(identifier)
if effects is not None:
# Wrap invoke_subgraph with with_effects
# Before: invoke_subgraph(subgraph, id, token, *args) -> (token_out, result)
# After: with_effects(token, invoke_subgraph, subgraph, id, *args) -> (token_out, result)
#
# Note: The subgraph itself will be unlifted separately when we iterate
# through named_modules() below.
num_tokens = len(effects)
if num_tokens != 1:
raise AssertionError(
f"Multiple token subgraph NYI, got {num_tokens} tokens"
)
token_args = operands[:num_tokens]
non_token_args = operands[num_tokens:]
# Create with_effects wrapper around invoke_subgraph
# with_effects(token, op, *args) where op is invoke_subgraph
# Pass the subgraph and non-token args to invoke_subgraph
with module.graph.inserting_before(node):
new_node = module.graph.call_function(
torch.ops.higher_order.with_effects,
# pyrefly: ignore [bad-argument-type]
(
token_args[0], # pyrefly: ignore[bad-argument-type]
torch.ops.higher_order.invoke_subgraph,
subgraph_node,
identifier,
*tuple(non_token_args),
),
)
node.replace_all_uses_with(new_node)
new_node.meta = node.meta
module.graph.erase_node(node)
for token in token_args:
if token.op == "placeholder":
input_token_nodes.add(token)
replace_input_token_with_make_token(module, token)
# Get output tokens from the new with_effects node
tokens_from_invoke_subgraph = get_output_tokens(new_node)
output_token_nodes = (
output_token_nodes | tokens_from_invoke_subgraph
)
if not output_token_nodes and not input_token_nodes:
return
output_node = next(reversed(module.graph.find_nodes(op="output")))
if output_node is None:
raise AssertionError("output node not found in graph")
with module.graph.inserting_before(output_node):
module.graph.call_function(
torch.ops.prims._sink_tokens.default,
(list(output_token_nodes),),
)
new_out_args = tuple(
[out for out in output_node.args[0] if out not in output_token_nodes]
)
output_node.args = (new_out_args,)
if expected_num_erased:
if len(input_token_nodes) != expected_num_erased:
raise AssertionError(
f"{subgraph_str} num_erased_inputs:{len(input_token_nodes)} "
f"{input_token_nodes} != expected {expected_num_erased} \n"
f"{fw_module.print_readable(print_output=False)}"
)
if len(output_token_nodes) != expected_num_erased:
raise AssertionError(
f"{subgraph_str} num_erased_outs:{len(output_token_nodes)} "
f"{output_token_nodes} != expected {expected_num_erased} \n"
f"{fw_module.print_readable(print_output=False)}"
)
module.recompile()
def unlift_tokens_from_module(
module: torch.fx.GraphModule, subgraph_str: str, expected_num_erased: int
) -> None:
for name, m in module.named_modules():
if isinstance(m, torch.fx.GraphModule):
if name == "":
_unlift_tokens_from_module_helper(
m, subgraph_str, expected_num_erased
)
else:
# Subgraph -- we may or may not have effects applied
_unlift_tokens_from_module_helper(m, f"{subgraph_str}_{name}", None)
if num_forward_tokens > 0:
if aot_config.enable_log:
from torch._dynamo.utils import lazy_format_graph_code
aot_graphs_effects_log.debug(
"%s",
lazy_format_graph_code(
"Forward graph before unlifting tokens",
fw_module,
aot_config.aot_id,
include_stride=True,
include_device=True,
colored=True,
),
)
unlift_tokens_from_module(
fw_module,
"forward",
num_forward_tokens,
)
if bw_module is not None and num_backward_tokens > 0:
if aot_config.enable_log:
from torch._dynamo.utils import lazy_format_graph_code
aot_graphs_effects_log.debug(
"%s",
lazy_format_graph_code(
"Backward graph before unlifting tokens",
bw_module,
aot_config.aot_id,
include_stride=True,
include_device=True,
colored=True,
),
)
unlift_tokens_from_module(bw_module, "backward", num_backward_tokens)
# This is sad, but we need to update the metadata to get rid of
# the tokens.
fw_metadata.tokens = {}
fw_metadata.num_backward_tokens = 0
def root_module_when_exporting_non_strict(
flat_fn: Callable[..., Any],
) -> torch.nn.Module | None:
# When exporting in non-strict mode, we wrap the root module in a specific pattern.
# See `_aot_export_non_strict` in torch.export._trace.py.
# We look for that wrapping pattern here.
if hasattr(flat_fn, "_orig_mod") and hasattr(flat_fn._orig_mod, "_export_root"):
return flat_fn._orig_mod._export_root
else:
return None
def _is_forward_node_with_seq_nr(node: torch.fx.Node) -> bool:
# For now, assume that if nn_module_stack_metadata is populated, this
# node is from the forward. Ignore nodes without `seq_nr`.
# TODO(future): there is likely a less brittle way to do this by walking
# the descendants of graph inputs corresponding to fwd inputs, didn't
# seem obvious at first glance on how to partition graph inputs into
# fwd vs bwd without relying on string names.
return node.meta.get("partitioner_tag") != "is_backward" and "seq_nr" in node.meta
def _is_backward_node_with_seq_nr(node: torch.fx.Node) -> bool:
# For now, assume that if nn_module_stack_metadata is not populated,
# this node is from the backward. Ignore nodes without `seq_nr`.
# TODO(future): there is likely a less brittle way to do this, same
# as with the forward.
return node.meta.get("partitioner_tag") == "is_backward" and "seq_nr" in node.meta
def _collect_fwd_nodes_from_subgraph(
fx_g: torch.fx.GraphModule, fwd_seq_nr_to_node: dict[str, torch.fx.Node]
) -> None:
"""Collect forward nodes from a single subgraph into the global mapping."""
for node in fx_g.graph.nodes:
if not _is_forward_node_with_seq_nr(node):
continue
seq_nr = node.meta["seq_nr"]
if seq_nr in fwd_seq_nr_to_node:
# If we already saw an op with the current `seq_nr`, that means
# that the current op did not create an autograd node, and there
# is no corresponding backward node, so we skip.
continue
fwd_seq_nr_to_node[seq_nr] = node
def _copy_metadata_to_bw_nodes_in_subgraph(
fx_g: torch.fx.GraphModule, fwd_seq_nr_to_node: dict[str, torch.fx.Node]
) -> None:
"""Copy metadata from forward nodes to backward nodes in a single subgraph."""
for node in fx_g.graph.nodes:
annotation_log.debug("node: %s", node.name)
seq_nr = node.meta.get("seq_nr")
annotation_log.debug("seq_nr: %s", seq_nr)
if not _is_backward_node_with_seq_nr(node):
continue
# We exclude gradient accumulation nodes from copying tags
if node.meta.get("is_gradient_acc", False):
annotation_log.debug("is_gradient_acc")
continue
# fwd_node should always exist, but handle non-existence just in case
fwd_node = fwd_seq_nr_to_node.get(node.meta["seq_nr"])
if fwd_node is not None:
node.meta["fwd_nn_module_stack"] = fwd_node.meta.get("nn_module_stack")
node.meta["fwd_source_fn_stack"] = fwd_node.meta.get("source_fn_stack")
# TODO: better to change to a specific field of custom?
custom = fwd_node.meta.get("custom")
if custom is not None:
node.meta["custom"] = copy.deepcopy(custom)
def copy_fwd_metadata_to_bw_nodes(fx_g: torch.fx.GraphModule) -> None:
"""
Input: `fx_g` which contains the joint fwd+bwd FX graph created by
aot_autograd.
This function walks the graph and copies over metadata from forward nodes
to backward nodes, using the `seq_nr` field as a one-to-many mapping
from forward node to backward node. This metadata is useful for performance
profiling and debugging.
This function supports matching forward and backward nodes across different
subgraphs (e.g., in recursive submodules from HOPs), enabling backward nodes
in any submodule to match forward nodes in any submodule.
"""
# Build a global mapping of seq_nr to forward nodes across all subgraphs
fwd_seq_nr_to_node: dict[str, torch.fx.Node] = {}
# First pass: collect all forward nodes from all subgraphs
for submod in fx_g.modules():
if isinstance(submod, torch.fx.GraphModule):
_collect_fwd_nodes_from_subgraph(submod, fwd_seq_nr_to_node)
if annotation_log.isEnabledFor(logging.DEBUG):
for k, v in fwd_seq_nr_to_node.items():
annotation_log.debug("forward:: key: %s, value: %s", k, v)
# Second pass: copy metadata to backward nodes in all subgraphs
# using the global forward mapping
for submod in fx_g.modules():
if isinstance(submod, torch.fx.GraphModule):
_copy_metadata_to_bw_nodes_in_subgraph(submod, fwd_seq_nr_to_node)
def register_buffer_assignment_hook(
mod: torch.nn.Module, assigned_buffers: dict[str, str]
) -> Any:
"""
Register a hook that intercepts buffer assignments.
This is used to detect when a buffer is assigned to, and then we can
map that buffer to the corresponding proxy node in the graph.
"""
def _map_assigned_buffer_to_proxy(
_mod: torch.nn.Module, name: str, buffer: Any
) -> Any:
# We intercept buffer assignments on the root module through this hook.
if _mod._buffers is mod._buffers:
# either buffer is a functional tensor, which wraps a fake tensor
if isinstance(buffer, FunctionalTensor):
buffer = buffer.from_functional()
# or buffer is a fake tensor
if not isinstance(buffer, FakeTensor):
raise AssertionError(f"expected FakeTensor, got {type(buffer)}")
# The fake tensor in turn is associated with a proxy node.
proxy_mode = torch.fx.experimental.proxy_tensor.get_proxy_mode()
if proxy_mode is None:
raise AssertionError("proxy_mode must not be None")
proxy = torch.fx.experimental.proxy_tensor.get_proxy_slot(
buffer, proxy_mode.tracer
).proxy.node
# We map the assigned buffer to this proxy node.
assigned_buffers[name] = proxy.name
return buffer
return torch.nn.modules.module.register_module_buffer_registration_hook(
_map_assigned_buffer_to_proxy
)
def contain_metadata_mutation_ops(module: torch.fx.GraphModule) -> bool:
"""
Checks if the module contains any metadata mutation ops.
"""
for node in module.graph.nodes:
if (
node.op == "call_function"
and hasattr(node.target, "tags")
and torch.Tag.inplace_view in node.target.tags
):
return True
return False
def get_cuda_generator_meta_val(device_idx: int) -> Any:
"""
Get a generator value to use as a meta val
newly cloned generator will not contain tensors. it is only Generators that are
registered to a CUDAGraph that contain tensors. since this does not contain Tensor
it is fine to use in the meta.
"""
return torch.cuda.default_generators[device_idx].clone_state()
def top_saved_tensors_hooks() -> Any:
return torch._C._autograd._top_saved_tensors_default_hooks(True)
def saved_tensors_hooks_are_inlineable(hooks: Any) -> bool:
if not hooks:
return False
pack, unpack = hooks
return isinstance(pack, torch.fx.GraphModule) and isinstance(
unpack, torch.fx.GraphModule
)
_P = ParamSpec("_P")
_T = TypeVar("_T")
_S = TypeVar("_S")
def without_output_descs(f: Callable[_P, tuple[_T, _S]]) -> Callable[_P, _T]:
@wraps(f)
@simple_wraps(f)
def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
return f(*args, **kwargs)[0]
return inner
_P2 = ParamSpec("_P2")
_R = TypeVar("_R")
_R2 = TypeVar("_R2")
def simple_wraps(
f: Callable[_P, _R],
) -> Callable[[Callable[_P2, _R2]], Callable[_P2, _R2]]:
# NB: omit ('__module__', '__name__', '__qualname__') for ease of
# debugging
return wraps(f, assigned=("__doc__", "__annotations__", "__type_params__"))
_Ts = TypeVarTuple("_Ts")
def call_and_expect_output_descs(
fn: Callable[[*_Ts], tuple[Any, Any]], args: tuple[Unpack[_Ts]]
) -> tuple[Any, Any]:
from .descriptors import AOTOutput
outs_pair = fn(*args)
if not (isinstance(outs_pair, tuple) and len(outs_pair) == 2):
raise AssertionError(
f"expected tuple of length 2, got {type(outs_pair)} with value {outs_pair}"
)
outs, outs_descs = outs_pair
# The Tensor tests protects against the test when there are no outputs
out_vals, out_spec = pytree.tree_flatten(outs)
out_desc_vals, out_desc_spec = pytree.tree_flatten(outs_descs)
if out_spec != out_desc_spec:
raise AssertionError(
f"output spec mismatch: {fn_wrappers(fn)}, outs={outs}, outs_descs={outs_descs}, "
f"out_spec={out_spec}, out_desc_spec={out_desc_spec}"
)
if any(isinstance(x, AOTOutput) for x in out_vals):
raise AssertionError(
f"unexpected AOTOutput in out_vals: {fn_wrappers(fn)}, outs={outs}, "
f"outs_descs={outs_descs}, out_vals={out_vals}"
)
if not all(
isinstance(d, AOTOutput)
for (x, d) in zip(out_vals, out_desc_vals)
if isinstance(x, (torch.Tensor, torch.SymInt)) or type(x) is int
):
raise AssertionError(
f"expected all descriptors to be AOTOutput: {fn_wrappers(fn)}, outs={outs}, "
f"outs_descs={outs_descs}, out_vals={out_vals}, out_desc_vals={out_desc_vals}"
)
return outs_pair
def fn_wrappers(fn: Callable[..., Any]) -> list[Callable[..., Any]]:
fns = [fn]
f = fn
while hasattr(f, "__wrapped__"):
f = f.__wrapped__
fns.append(f)
return fns
def _is_primal(node: torch.fx.Node) -> bool:
return (
node.op == "placeholder"
and "tangents" not in str(node.target)
and not _is_bwd_seed_offset(node)
and not _is_fwd_seed_offset(node)
)
def _is_tangent(node: torch.fx.Node) -> bool:
return node.op == "placeholder" and "tangents" in str(node.target)
def _is_bwd_seed_offset(node: torch.fx.Node) -> bool:
return node.op == "placeholder" and (
"bwd_seed" in str(node.target) or "bwd_base_offset" in str(node.target)
)
def _is_fwd_seed_offset(node: torch.fx.Node) -> bool:
return node.op == "placeholder" and (
"fwd_seed" in str(node.target) or "fwd_base_offset" in str(node.target)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
# NOTE: We allow Dynamo to see this file (via torch/_dynamo/trace_rules.py) so that it can
# trace through functorch transforms.
# Currently, we can't allow Dynamo to see `eager_transforms.py`/`vmap.py` as that break a lot of thing
# and there isn't a mechanism to selectively expose only some functions (eg. grad) from a file
# to Dynamo.
from __future__ import annotations
import functools
from typing import Any, TYPE_CHECKING
from typing_extensions import ParamSpec, TypeVar
from torch._functorch.utils import argnums_t, exposed_in
from torch._functorch.vmap import (
_check_out_dims_is_int_or_int_pytree,
_check_randomness_arg,
_chunked_vmap,
_process_batched_inputs,
Callable,
in_dims_t,
out_dims_t,
vmap_impl,
)
if TYPE_CHECKING:
from collections.abc import Iterable
import torch
_P = ParamSpec("_P")
_R = TypeVar("_R")
# vmap(func)(inputs) wraps all Tensor inputs to be batched in BatchedTensors,
# sends those into func, and then unwraps the output BatchedTensors. Operations
# on BatchedTensors perform the batched operations that the user is asking for.
#
# vmap's randomness behavior differs from JAX's, which would require a PRNG key
# to be passed everywhere.
@exposed_in("torch.func")
def vmap(
func: Callable[_P, _R],
in_dims: in_dims_t = 0,
out_dims: out_dims_t = 0,
randomness: str = "error",
*,
chunk_size: int | None = None,
) -> Callable[_P, _R]:
"""
vmap is the vectorizing map; ``vmap(func)`` returns a new function that
maps ``func`` over some dimension of the inputs. Semantically, vmap
pushes the map into PyTorch operations called by ``func``, effectively
vectorizing those operations.
vmap is useful for handling batch dimensions: one can write a function
``func`` that runs on examples and then lift it to a function that can
take batches of examples with ``vmap(func)``. vmap can also be used to
compute batched gradients when composed with autograd.
.. note::
:func:`torch.vmap` is aliased to :func:`torch.func.vmap` for
convenience. Use whichever one you'd like.
Args:
func (function): A Python function that takes one or more arguments.
Must return one or more Tensors.
in_dims (int or nested structure): Specifies which dimension of the
inputs should be mapped over. ``in_dims`` should have a
structure like the inputs. If the ``in_dim`` for a particular
input is None, then that indicates there is no map dimension.
Default: 0.
out_dims (int or Tuple[int]): Specifies where the mapped dimension
should appear in the outputs. If ``out_dims`` is a Tuple, then
it should have one element per output. Default: 0.
randomness (str): Specifies whether the randomness in this
vmap should be the same or different across batches. If 'different',
the randomness for each batch will be different. If 'same', the
randomness will be the same across batches. If 'error', any calls to
random functions will error. Default: 'error'. WARNING: this flag
only applies to random PyTorch operations and does not apply to
Python's random module or numpy randomness.
chunk_size (None or int): If None (default), apply a single vmap over inputs.
If not None, then compute the vmap :attr:`chunk_size` samples at a time.
Note that :attr:`chunk_size=1` is equivalent to computing the vmap with a for-loop.
If you run into memory issues computing the vmap, please try a non-None chunk_size.
Returns:
Returns a new "batched" function. It takes the same inputs as
``func``, except each input has an extra dimension at the index
specified by ``in_dims``. It takes returns the same outputs as
``func``, except each output has an extra dimension at the index
specified by ``out_dims``.
.. warning:
:func:`vmap` works best with functional-style code. Please do not
perform any side-effects in ``func``, with the exception of
in-place PyTorch operations. Examples of side-effects include mutating
Python data structures and assigning values to variables not captured
in ``func``.
One example of using :func:`vmap` is to compute batched dot products. PyTorch
doesn't provide a batched ``torch.dot`` API; instead of unsuccessfully
rummaging through docs, use :func:`vmap` to construct a new function.
>>> torch.dot # [D], [D] -> []
>>> batched_dot = torch.func.vmap(torch.dot) # [N, D], [N, D] -> [N]
>>> x, y = torch.randn(2, 5), torch.randn(2, 5)
>>> batched_dot(x, y)
:func:`vmap` can be helpful in hiding batch dimensions, leading to a simpler
model authoring experience.
>>> batch_size, feature_size = 3, 5
>>> weights = torch.randn(feature_size, requires_grad=True)
>>>
>>> def model(feature_vec):
>>> # Very simple linear model with activation
>>> return feature_vec.dot(weights).relu()
>>>
>>> examples = torch.randn(batch_size, feature_size)
>>> result = torch.vmap(model)(examples)
:func:`vmap` can also help vectorize computations that were previously difficult
or impossible to batch. One example is higher-order gradient computation.
The PyTorch autograd engine computes vjps (vector-Jacobian products).
Computing a full Jacobian matrix for some function f: R^N -> R^N usually
requires N calls to ``autograd.grad``, one per Jacobian row. Using :func:`vmap`,
we can vectorize the whole computation, computing the Jacobian in a single
call to ``autograd.grad``.
>>> # Setup
>>> N = 5
>>> f = lambda x: x**2
>>> x = torch.randn(N, requires_grad=True)
>>> y = f(x)
>>> I_N = torch.eye(N)
>>>
>>> # Sequential approach
>>> jacobian_rows = [torch.autograd.grad(y, x, v, retain_graph=True)[0]
>>> for v in I_N.unbind()]
>>> jacobian = torch.stack(jacobian_rows)
>>>
>>> # vectorized gradient computation
>>> def get_vjp(v):
>>> return torch.autograd.grad(y, x, v)
>>> jacobian = torch.vmap(get_vjp)(I_N)
:func:`vmap` can also be nested, producing an output with multiple batched dimensions
>>> torch.dot # [D], [D] -> []
>>> batched_dot = torch.vmap(
... torch.vmap(torch.dot)
... ) # [N1, N0, D], [N1, N0, D] -> [N1, N0]
>>> x, y = torch.randn(2, 3, 5), torch.randn(2, 3, 5)
>>> batched_dot(x, y) # tensor of size [2, 3]
If the inputs are not batched along the first dimension, ``in_dims`` specifies
the dimension that each inputs are batched along as
>>> torch.dot # [N], [N] -> []
>>> batched_dot = torch.vmap(torch.dot, in_dims=1) # [N, D], [N, D] -> [D]
>>> x, y = torch.randn(2, 5), torch.randn(2, 5)
>>> batched_dot(
... x, y
... ) # output is [5] instead of [2] if batched along the 0th dimension
If there are multiple inputs each of which is batched along different dimensions,
``in_dims`` must be a tuple with the batch dimension for each input as
>>> torch.dot # [D], [D] -> []
>>> batched_dot = torch.vmap(torch.dot, in_dims=(0, None)) # [N, D], [D] -> [N]
>>> x, y = torch.randn(2, 5), torch.randn(5)
>>> batched_dot(
... x, y
... ) # second arg doesn't have a batch dim because in_dim[1] was None
If the input is a Python struct, ``in_dims`` must be a tuple containing a struct
matching the shape of the input:
>>> f = lambda dict: torch.dot(dict["x"], dict["y"])
>>> x, y = torch.randn(2, 5), torch.randn(5)
>>> input = {"x": x, "y": y}
>>> batched_dot = torch.vmap(f, in_dims=({"x": 0, "y": None},))
>>> batched_dot(input)
By default, the output is batched along the first dimension. However, it can be batched
along any dimension by using ``out_dims``
>>> f = lambda x: x**2
>>> x = torch.randn(2, 5)
>>> batched_pow = torch.vmap(f, out_dims=1)
>>> batched_pow(x) # [5, 2]
For any function that uses kwargs, the returned function will not batch the kwargs but will
accept kwargs
>>> x = torch.randn([2, 5])
>>> def fn(x, scale=4.):
>>> return x * scale
>>>
>>> batched_pow = torch.vmap(fn)
>>> assert torch.allclose(batched_pow(x), x * 4)
>>> batched_pow(x, scale=x) # scale is not batched, output has shape [2, 2, 5]
.. note::
vmap does not provide general autobatching or handle variable-length
sequences out of the box.
"""
from torch.compiler import is_compiling
_check_randomness_arg(randomness)
if not (chunk_size is None or chunk_size > 0):
raise ValueError(
f"vmap: chunk_size should be None or greater than 0. (got {chunk_size})"
)
def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R:
# pyrefly: ignore[bad-argument-type]
return vmap_impl(
# pyrefly: ignore[bad-argument-type]
func,
in_dims,
out_dims,
randomness,
chunk_size,
*args,
**kwargs,
)
if not is_compiling():
wrapped = functools.wraps(func)(wrapped)
return wrapped
def chunk_vmap(
func: Callable[_P, _R],
in_dims: in_dims_t = 0,
out_dims: out_dims_t = 0,
randomness: str = "error",
chunks: int = 2,
) -> Callable[_P, _R]:
"""
chunk_vmap is the vectorizing map (vmap) using chunks of input data. It is a mix of vmap (which vectorizes
everything) and map (which executes things sequentially). ``chunk_vmap`` vectorizes the input with number of
chunks at a time. For more details about vectorizing map, see :func:`vmap`.
.. note::
Please use :func:`vmap` with ``chunk_size`` argument instead of this API.
Args:
func (function): A Python function that takes one or more arguments.
Must return one or more Tensors.
in_dims (int or nested structure): Specifies which dimension of the
inputs should be mapped over. ``in_dims`` should have a
structure like the inputs. If the ``in_dim`` for a particular
input is None, then that indicates there is no map dimension.
Default: 0.
out_dims (int or Tuple[int]): Specifies where the mapped dimension
should appear in the outputs. If ``out_dims`` is a Tuple, then
it should have one element per output. Default: 0.
randomness (str): Specifies whether the randomness in this
vmap should be the same or different across batches. If 'different',
the randomness for each batch will be different. If 'same', the
randomness will be the same across batches. If 'error', any calls to
random functions will error. Default: 'error'. WARNING: this flag
only applies to random PyTorch operations and does not apply to
Python's random module or numpy randomness.
chunks (int): Number of chunks to use to split the input data. Default is 2.
If equals to 1 then :func:`vmap` is called.
Returns:
Returns a new "batched" function. It takes the same inputs as
``func``, except each input has an extra dimension at the index
specified by ``in_dims``. It takes returns the same outputs as
``func``, except each output has an extra dimension at the index
specified by ``out_dims``.
"""
_check_randomness_arg(randomness)
if chunks == 1:
return vmap(func, in_dims=in_dims, out_dims=out_dims, randomness=randomness)
def _get_chunk_flat_args(
flat_args_: Iterable[Any],
flat_in_dims_: Iterable[int | None],
chunks_: int,
) -> Iterable[Any]:
flat_args_chunks = tuple(
t.chunk(chunks_, dim=in_dim)
if in_dim is not None
else [
t,
]
* chunks_
for t, in_dim in zip(flat_args_, flat_in_dims_)
)
# transpose chunk dim and flatten structure
# chunks_flat_args is a list of flatten args
chunks_flat_args = zip(*flat_args_chunks)
return chunks_flat_args
@functools.wraps(func)
def wrapped_with_chunks(*args: _P.args, **kwargs: _P.kwargs) -> _R:
_check_out_dims_is_int_or_int_pytree(out_dims, func)
_, flat_in_dims, flat_args, args_spec = _process_batched_inputs(
in_dims, args, func
)
# Chunk flat arguments
chunks_flat_args = _get_chunk_flat_args(flat_args, flat_in_dims, chunks)
# Apply vmap on chunks
return _chunked_vmap(
# pyrefly: ignore[bad-argument-type]
func,
flat_in_dims,
chunks_flat_args,
args_spec,
out_dims,
randomness,
**kwargs,
)
return wrapped_with_chunks
# TODO: Improve the return type of this function
@exposed_in("torch.func")
def grad(
func: Callable[_P, Any], argnums: argnums_t = 0, has_aux: bool = False
) -> Callable[_P, Any]:
"""``grad`` operator helps computing gradients of ``func`` with respect to the
input(s) specified by ``argnums``. This operator can be nested to
compute higher-order gradients.
Args:
func (Callable): A Python function that takes one or more arguments.
Must return a single-element Tensor. If specified ``has_aux`` equals ``True``,
function can return a tuple of single-element Tensor and other auxiliary objects:
``(output, aux)``.
argnums (int or Tuple[int]): Specifies arguments to compute gradients with respect to.
``argnums`` can be single integer or tuple of integers. Default: 0.
has_aux (bool): Flag indicating that ``func`` returns a tensor and other
auxiliary objects: ``(output, aux)``. Default: False.
Returns:
Function to compute gradients with respect to its inputs. By default, the output of
the function is the gradient tensor(s) with respect to the first argument.
If specified ``has_aux`` equals ``True``, tuple of gradients and output auxiliary objects
is returned. If ``argnums`` is a tuple of integers, a tuple of output gradients with
respect to each ``argnums`` value is returned.
Example of using ``grad``:
>>> # xdoctest: +SKIP
>>> from torch.func import grad
>>> x = torch.randn([])
>>> cos_x = grad(lambda x: torch.sin(x))(x)
>>> assert torch.allclose(cos_x, x.cos())
>>>
>>> # Second-order gradients
>>> neg_sin_x = grad(grad(lambda x: torch.sin(x)))(x)
>>> assert torch.allclose(neg_sin_x, -x.sin())
When composed with ``vmap``, ``grad`` can be used to compute per-sample-gradients:
>>> # xdoctest: +SKIP
>>> from torch.func import grad, vmap
>>> batch_size, feature_size = 3, 5
>>>
>>> def model(weights, feature_vec):
>>> # Very simple linear model with activation
>>> assert feature_vec.dim() == 1
>>> return feature_vec.dot(weights).relu()
>>>
>>> def compute_loss(weights, example, target):
>>> y = model(weights, example)
>>> return ((y - target) ** 2).mean() # MSELoss
>>>
>>> weights = torch.randn(feature_size, requires_grad=True)
>>> examples = torch.randn(batch_size, feature_size)
>>> targets = torch.randn(batch_size)
>>> inputs = (weights, examples, targets)
>>> grad_weight_per_example = vmap(grad(compute_loss), in_dims=(None, 0, 0))(
... *inputs
... )
Example of using ``grad`` with ``has_aux`` and ``argnums``:
>>> # xdoctest: +SKIP
>>> from torch.func import grad
>>> def my_loss_func(y, y_pred):
>>> loss_per_sample = (0.5 * y_pred - y) ** 2
>>> loss = loss_per_sample.mean()
>>> return loss, (y_pred, loss_per_sample)
>>>
>>> fn = grad(my_loss_func, argnums=(0, 1), has_aux=True)
>>> y_true = torch.rand(4)
>>> y_preds = torch.rand(4, requires_grad=True)
>>> out = fn(y_true, y_preds)
>>> # > output is ((grads w.r.t y_true, grads w.r.t y_preds), (y_pred, loss_per_sample))
.. note::
Using PyTorch ``torch.no_grad`` together with ``grad``.
Case 1: Using ``torch.no_grad`` inside a function:
>>> # xdoctest: +SKIP
>>> def f(x):
>>> with torch.no_grad():
>>> c = x ** 2
>>> return x - c
In this case, ``grad(f)(x)`` will respect the inner ``torch.no_grad``.
Case 2: Using ``grad`` inside ``torch.no_grad`` context manager:
>>> # xdoctest: +SKIP
>>> with torch.no_grad():
>>> grad(f)(x)
In this case, ``grad`` will respect the inner ``torch.no_grad``, but not the
outer one. This is because ``grad`` is a "function transform": its result
should not depend on the result of a context manager outside of ``f``.
"""
# To avoid cyclical dependency.
import torch._functorch.eager_transforms as eager_transforms
from torch.compiler import is_compiling
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> tuple[Any, torch.Tensor]:
return eager_transforms.grad_impl(func, argnums, has_aux, args, kwargs)
if not is_compiling():
wrapper = functools.wraps(func)(wrapper)
return wrapper
# TODO: Improve the return type of this function
@exposed_in("torch.func")
def grad_and_value(
func: Callable[_P, Any], argnums: argnums_t = 0, has_aux: bool = False
) -> Callable[_P, tuple[Any, Any]]:
"""
Returns a function to compute a tuple of the gradient and primal, or
forward, computation.
Args:
func (Callable): A Python function that takes one or more arguments.
Must return a single-element Tensor. If specified ``has_aux``
equals ``True``, function can return a tuple of single-element
Tensor and other auxiliary objects: ``(output, aux)``.
argnums (int or Tuple[int]): Specifies arguments to compute gradients
with respect to. ``argnums`` can be single integer or tuple of
integers. Default: 0.
has_aux (bool): Flag indicating that ``func`` returns a tensor and
other auxiliary objects: ``(output, aux)``. Default: False.
Returns:
Function to compute a tuple of gradients with respect to its inputs
and the forward computation. By default, the output of the function is
a tuple of the gradient tensor(s) with respect to the first argument
and the primal computation. If specified ``has_aux`` equals
``True``, tuple of gradients and tuple of the forward computation with
output auxiliary objects is returned. If ``argnums`` is a tuple of
integers, a tuple of a tuple of the output gradients with respect to
each ``argnums`` value and the forward computation is returned.
See :func:`grad` for examples
"""
from torch._functorch import eager_transforms
from torch.compiler import is_compiling
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> tuple[Any, torch.Tensor]:
return eager_transforms.grad_and_value_impl(
func, argnums, has_aux, args, kwargs
)
if not is_compiling():
wrapper = functools.wraps(func)(wrapper)
return wrapper
@@ -0,0 +1,909 @@
from __future__ import annotations
from typing import Any, NamedTuple, TYPE_CHECKING
from typing_extensions import ParamSpec, TypeVar
import torch
import torch.utils._pytree as pytree
from torch._C._functorch import (
_unwrap_for_grad,
_wrap_for_grad,
current_level,
TransformType,
)
from torch._functorch.apis import vmap
from torch._functorch.utils import enable_single_level_autograd_function
from torch._functorch.vmap import (
_add_batch_dim,
_broadcast_to_and_flatten,
restore_vmap,
unwrap_batched,
wrap_batched,
)
from torch._ops import HigherOrderOperator
from torch.autograd.forward_ad import _set_fwd_grad_enabled
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
from torch._functorch.pyfunctorch import FuncTorchInterpreter, VmapInterpreter
_P = ParamSpec("_P")
_R = TypeVar("_R")
# autograd.Function technically runs before the regular PyTorch dispatcher.
# This is how features like autocast and torch_dispatch (e.g. PythonTLSSnapshot)
# work with it. One day we might decide to change this, but until then,
# we need to give the illusion that autograd.Function runs before those things.
#
# We do this by using creating a custom HigherOrderOperator that only functorch
# dispatches specially.
class CustomFunctionHigherOrderOperator(HigherOrderOperator):
def __init__(self) -> None:
super().__init__("custom_function_call")
def __call__(
self,
autograd_function: type[torch.autograd.Function],
*args: _P.args,
**kwargs: _P.kwargs,
) -> Any:
# When custom_function_call is done dispatching through functorch,
# it should just invoke the autograd.Function. This is consistent
# with the autograd.Function behavior of being invoked before the
# PyTorch dispatcher.
#
# This will lead us into trouble later down the line, but this is
# pre-existing. There is an invariant that a function traced by
# make_fx should have the same behavior when provided the same
# Tensor. However, make_fx sees autograd.Function as a composite
# (because autograd.Function happens before the Python dispatch key)
# and only traces the forward pass.
if torch._C._are_functorch_transforms_active():
# pyrefly: ignore [missing-attribute]
return super().__call__(autograd_function, *args, **kwargs)
return autograd_function.apply(*args, **kwargs)
# "custom_function_call"
# This is the mechanism for an autograd.Function that works with functorch transforms.
# It wraps an autograd.Function; interactions with functorch transforms are defined
# via PyDispatcher and HigherOrderOperator rather than through the traditional PyTorch
# dispatcher.
custom_function_call = CustomFunctionHigherOrderOperator()
# The grad rule for custom_function_call is to construct a new _SingleLevelFunction
# (autograd.Function that only works with a single layer (level) of functorch) that:
# - unwraps the inputs
# - redispatches to custom_function_call
# - wraps the outputs
# and whose backward pass calls the original autograd.Function's backward.
#
# Why do we need to redispatch to custom_function_call?
# -----------------------------------------------------
# This is consistent with how ATen operators work with functorch's grad transform:
# they always redispatch to the original operator.
# Consider torch.sin, and let's say we do grad0(grad1(torch.sin))(x)
#
# grad1 will:
# - set up the autograd graph
# - unwrap the inputs
# - redispatch to at::sin (*)
# - rewrap the outputs on the return
#
# On the redispatch in (*), grad0 will:
# - set up the autograd graph
# - unwrap the inputs
# - redispatch to at::sin
# - rewrap the outputs on the return
#
# To "set up the autograd graph", we generate a _SingleLevelFunction
# and apply it.
@custom_function_call.py_impl(TransformType.Grad)
@custom_function_call.py_impl(TransformType.Jvp)
def custom_function_call_grad(
interpreter: FuncTorchInterpreter,
autograd_function: type[torch.autograd.Function],
*operands: Any,
) -> Any:
Generated = generate_single_level_function(interpreter, autograd_function)
with enable_single_level_autograd_function():
# pyrefly: ignore [missing-attribute]
flat_out = Generated.apply(*operands)
return flat_out
def generate_single_level_function(
interpreter: FuncTorchInterpreter,
autograd_function: type[torch.autograd.Function],
) -> type[torch.autograd.function._SingleLevelFunction]:
level = interpreter.level()
def forward(*operands: Any) -> Any:
unwrapped_operands = pytree.tree_map_only(
torch.Tensor, lambda x: _unwrap_for_grad(x, level), operands
)
# Both enable_grad() and _set_fwd_grad_enabled() are necessary no matter
# the transform. _SingleLevelFunction will turn off both fwd and bwd
# gradient computation and we need to turn it back on here.
with torch.enable_grad(), _set_fwd_grad_enabled(True), interpreter.lower():
unwrapped_output = custom_function_call(
autograd_function, *unwrapped_operands
)
# See NOTE [mark_dirty object identity check]
def wrap_fn(output: torch.Tensor) -> torch.Tensor:
return _wrap_for_grad(output, level)
return wrap_outputs_maintaining_identity(
unwrapped_output, unwrapped_operands, operands, wrap_fn
)
def setup_context(ctx: Any, inputs: Any, output: Any) -> Any:
return autograd_function.setup_context(ctx, inputs, output)
# backward is only used if the transform is TransformType.Grad
def backward(ctx: Any, *grads: Any) -> Any:
result = autograd_function.backward(ctx, *grads)
return result
# jvp is only used if the transform is TransformType.Jvp
def jvp(ctx: Any, *tangents: Any) -> Any:
result = autograd_function.jvp(ctx, *tangents)
return result
# This is the sequence of magic words to dynamically generate a Subclass with
# a given name. A Tensor's .grad_fn field has a class name that is the original
# autograd.Function's name + Backward, so we do this to generate some
# meaningful name.
name = f"{autograd_function.__name__}Generated"
Generated = type(
name,
(torch.autograd.function._SingleLevelFunction,),
{
"forward": staticmethod(forward),
"backward": staticmethod(backward),
"jvp": staticmethod(jvp),
"setup_context": staticmethod(setup_context),
},
)
return Generated
# wrap_outputs_maintaining_identity handles outputs from the vmap,
# backward (vjp), and jvp staticmethod. The way it distinguishes
# between the vmap case and the {backward, jvp} case is if the out_dims
# are specified or not.
#
# NB: we cannot use out_dims=None as the deciding factor. This because
# out_dims=None can still happen in the vmap staticmethod! What the
# user is saying in that case is that their output does not have a
# dimension that is being vmapped over, which is valid.
NO_OUT_DIMS = "not specified"
# NOTE [mark_dirty object identity check]
# autograd.Function's ctx.mark_dirty expect a returned input
# to have the same object identity as the input.
# Mode-only functorch will greatly simplify this logic.
def wrap_outputs_maintaining_identity(
outputs: Any,
unwrapped_inputs: Any,
orig_inputs: Any,
wrap_fn: Callable[..., Any],
out_dims: Any = NO_OUT_DIMS,
) -> Any:
flat_unwrapped_inputs = pytree.arg_tree_leaves(*unwrapped_inputs)
flat_orig_inputs = pytree.arg_tree_leaves(*orig_inputs)
unwrapped_input_to_orig_input = {
id(unwrapped): orig
for unwrapped, orig in zip(flat_unwrapped_inputs, flat_orig_inputs)
}
flat_outputs, spec = pytree.tree_flatten(outputs)
result = []
out_dims_specified = out_dims != NO_OUT_DIMS
flat_out_dims = None
if out_dims_specified:
flat_out_dims = _broadcast_to_and_flatten(out_dims, spec)
# _broadcast_to_and_flatten returns None if it is unable to broadcast.
# TODO: update following link from master to stable once that's out
if flat_out_dims is None:
raise RuntimeError(
f"The autograd.Function's vmap staticmethod returned an "
f"incompatible (output, out_dims) tuple. "
f"Expected out_dims={out_dims} "
f"to be compatible with the structure of `output`. "
f"out_dims has structure {pytree.tree_flatten(out_dims)[1]} "
f"but output has structure {spec}. "
f"For more details, please see "
f"https://pytorch.org/docs/main/notes/extending.func.html"
)
for i, output in enumerate(flat_outputs):
if not isinstance(output, torch.Tensor):
result.append(output)
continue
if id(output) in unwrapped_input_to_orig_input:
result.append(unwrapped_input_to_orig_input[id(output)])
continue
if out_dims_specified:
if flat_out_dims is None:
raise AssertionError(
"flat_out_dims must not be None when out_dims is specified"
)
result.append(wrap_fn(output, flat_out_dims[i]))
else:
result.append(wrap_fn(output))
return pytree.tree_unflatten(result, spec)
# NOTE: [functorch vjp and autograd interaction]
# There's an edge case with the functorch vjp and autograd interaction
# that will eventually be fixed by mode-only functorch.
# The TL;DR is that there's no way to unwrap a dead GradTensorWrapper,
# so we (the framework) need to do it manually. Regular PyTorch operators
# automatically do so this is consistent.
#
# class MyExp(torch.autograd.Function):
# @staticmethod
# def forward(x):
# return x.exp()
#
# @staticmethod
# def setup_context(ctx, inputs, output):
# y = output
# ctx.save_for_backward(y)
#
# @staticmethod
# def backward(gy):
# y, = ctx.saved_tensors()
# return MyMul.apply(gy, y)
#
# x = torch.randn([], requires_grad=True)
# gy = torch.randn([], requires_grad=True)
# _, vjp_fn = vjp(MySin.apply, x)
# result = vjp_fn(gy)
#
# MyMul is an autograd.Function that is not shown here.
# It saves a `y` for backward (since gy requires grad).
#
# in vjp_fn(gy), we get:
# > MyMul.apply(gy, GradTensorWrapper(y, level=dead))
# Because the y that is saved for backward by MyExp is a GradTensorWrapper
# but is now dead since we are outside the vjp context.
#
# PyTorch dispatcher operations, upon seeing a dead GradTensorWrapper,
# will automatically unwrap the GradTensorWrapper when applied.
# But since autograd.Function technically sits above the regular PyTorch
# dispatcher, it doesn't get this treatment. So we manually do
# the unwrapping to be consistent with regular PyTorch dispatcher operations.
class VmapInfo(NamedTuple):
batch_size: int
randomness: str
def has_overridden_vmap_rule(
autograd_function: type[torch.autograd.Function],
) -> bool:
return autograd_function.vmap is not torch.autograd.Function.vmap
def validate_vmap_returns_tuple_of_two_elements(result: Any) -> None:
base_error_msg = (
"Expected the vmap staticmethod to have two returns, an output "
"and out_dims with pytree structure compatible with the output. "
)
if not isinstance(result, tuple):
raise RuntimeError(base_error_msg + f"Got a {type(result)} instead")
if not len(result) == 2:
raise RuntimeError(base_error_msg + f"Got {len(result)} returns instead")
@custom_function_call.py_impl(TransformType.Vmap)
def custom_function_call_vmap(
interpreter: VmapInterpreter,
autograd_function: type[torch.autograd.Function],
*operands: Any,
**kwargs: Any,
) -> Any:
if any(
isinstance(val, torch.Tensor)
for val in torch.utils._pytree.tree_flatten(kwargs)[0]
):
raise NotImplementedError(
f"Run vmap on autograd.Function with kwarg-only Tensor args. "
f"Please do not pass kwarg-only Tensors to autograd.Function. "
f"Got: {kwargs}"
)
if autograd_function.generate_vmap_rule:
if has_overridden_vmap_rule(autograd_function):
# TODO: Update link to stable once that's out
# https://github.com/pytorch/pytorch/issues/92029
raise RuntimeError(
f"You tried to vmap over {autograd_function.__name__}, but "
f"it has both generate_vmap_rule=True and an overridden vmap "
f"staticmethod. Please set generate_vmap_rule=False or delete "
f"the overridden vmap staticmethod to avoid ambiguity. "
f"For more details, please see "
f"https://pytorch.org/docs/main/notes/extending.func.html"
)
return custom_function_call_vmap_generate_rule(
interpreter, autograd_function, *operands
)
if not has_overridden_vmap_rule(autograd_function):
# TODO: Update link to stable once that's out
# https://github.com/pytorch/pytorch/issues/92029
raise RuntimeError(
f"You tried to vmap over {autograd_function.__name__}, but "
f"it does not have vmap support. Please override and implement the "
f"vmap staticmethod or set generate_vmap_rule=True. "
f"For more details, please see "
f"https://pytorch.org/docs/main/notes/extending.func.html"
)
return custom_function_call_vmap_helper(
interpreter, autograd_function.vmap, autograd_function, *operands, **kwargs
)
def custom_function_call_vmap_helper(
interpreter: VmapInterpreter,
vmap_function: Callable[..., Any],
op: Any,
*operands: Any,
**kwargs: Any,
) -> Any:
current_level = interpreter.level()
info = VmapInfo(
batch_size=interpreter.batch_size(),
randomness=interpreter.randomness(),
)
# We're either in the autograd.Function case (vmap staticmethod)
# or the torch.library.register_vmap case.
autograd_function_case = isinstance(op, torch.autograd.function.FunctionMeta)
def lower_to_next() -> Any:
if autograd_function_case:
return interpreter.lower()
else:
return torch._C._ExcludeDispatchKeyGuard(
torch._C.DispatchKeySet(torch._C.DispatchKey.FuncTorchBatched)
)
unwrapped_operands, in_dims = unwrap_batched(operands, current_level)
# If none of the tensors are batched at the current level, then we skip the
# current level. This saves the user from needing to handle this case in
# their vmap staticmethod (and is consistent with our C++ batching rule API)
if pytree.tree_all(lambda dim: dim is None, in_dims):
with lower_to_next():
if autograd_function_case:
return custom_function_call(op, *operands)
else:
return op(*operands, **kwargs)
with lower_to_next():
result = vmap_function(info, in_dims, *unwrapped_operands, **kwargs)
validate_vmap_returns_tuple_of_two_elements(result)
unwrapped_output, out_dims = result
# See NOTE [mark_dirty object identity check]
def wrap_fn(output: torch.Tensor, out_dim: int | None) -> torch.Tensor:
return (
output
if out_dim is None
else _add_batch_dim(output, out_dim, current_level)
)
return wrap_outputs_maintaining_identity(
unwrapped_output, unwrapped_operands, operands, wrap_fn, out_dims=out_dims
)
def unpack_outputs(outputs: tuple[Any, ...]) -> tuple[Any, Any]:
out_dims = outputs[-1]
if isinstance(out_dims, tuple):
outputs = outputs[:-1]
else:
outputs = outputs[0]
return outputs, out_dims
def custom_function_call_vmap_generate_rule(
interpreter: VmapInterpreter,
autograd_function: type[torch.autograd.Function],
*operands: Any,
) -> Any:
unwrapped_operands, in_dims = unwrap_batched(operands, interpreter.level())
vmapped_function = vmapify_autograd_function(
autograd_function,
in_dims,
interpreter.batch_size(),
interpreter.randomness(),
)
with interpreter.lower():
outputs = custom_function_call(vmapped_function, *unwrapped_operands)
if not isinstance(outputs, tuple):
raise AssertionError(f"expected outputs to be a tuple, got {type(outputs)}")
outputs, out_dims = unpack_outputs(outputs)
return wrap_batched(outputs, out_dims, interpreter.level())
@custom_function_call.py_impl(TransformType.Functionalize)
def custom_function_call_functionalize(
interpreter: FuncTorchInterpreter,
autograd_function: type[torch.autograd.Function],
generate_vmap_rule: bool,
*operands: Any,
) -> Any:
raise RuntimeError("NYI: Functionalize rule for custom_function_call")
def vmapify_autograd_function(
autograd_function: type[torch.autograd.Function],
in_dims: Any,
batch_size: int,
randomness: str,
) -> type[torch.autograd.Function]:
def forward(*operands: Any) -> Any:
outputs, out_dims = restore_vmap(
autograd_function.forward, in_dims, batch_size, randomness
)(*operands)
if isinstance(outputs, torch.Tensor):
return outputs, out_dims
else:
return *outputs, out_dims
def setup_context(ctx: Any, inputs: Any, outputs: Any) -> None:
outputs, out_dims = unpack_outputs(outputs)
key = id(Generated)
def inner(inputs: Any, outputs: Any) -> None:
# wrapped_ctx.save_for_backward will:
# - unwrap batchedtensors into (tensor, bdim)
# - save_for_backward(*unwrapped_tensors)
# - assign the bdims to wrapped_ctx._pt_saved_tensors_bdims
wrapped_ctx = CtxCustomSave(ctx, current_level())
autograd_function.setup_context(wrapped_ctx, inputs, outputs)
# input_shapes are used for reductify later to reduce expanded gradients
# to the correct shape.
# See NOTE: [Why can't we rely on autograd to reduce expanded gradients?]
# for more details
input_shapes = tuple(
inp.shape if isinstance(inp, torch.Tensor) else None for inp in inputs
)
if not hasattr(ctx, "_pt_input_shapes"):
# pyrefly: ignore [implicit-any]
ctx._pt_input_shapes = {}
ctx._pt_input_shapes.update({key: input_shapes})
if not hasattr(ctx, "_pt_saved_tensors_bdims_stack"):
# pyrefly: ignore [implicit-any]
ctx._pt_saved_tensors_bdims_stack = {}
ctx._pt_saved_tensors_bdims_stack.update(
{key: (wrapped_ctx._pt_saved_tensors_bdims)}
)
# See NOTE: [Why do we need to run setup_context under a vmap?]
restore_vmap(
inner,
(in_dims, out_dims),
batch_size,
randomness,
)(inputs, outputs)
if not hasattr(ctx, "_pt_out_dims"):
# pyrefly: ignore [implicit-any]
ctx._pt_out_dims = {}
ctx._pt_out_dims.update({key: out_dims})
def jvp(ctx: Any, *tangents: Any) -> Any:
key = id(Generated)
def jvp_no_context(saved_tensors: Any, tangents: Any) -> Any:
wrapped_ctx = CtxWithSavedTensors(ctx, saved_tensors)
return autograd_function.jvp(wrapped_ctx, *tangents)
tangent_in_dims = get_tangents_in_dims(in_dims, tangents)
out_tangents, out_tangents_dims = restore_vmap(
jvp_no_context,
(ctx._pt_saved_tensors_bdims_stack[key], tangent_in_dims),
batch_size,
randomness,
)(ctx.saved_tensors, tangents)
result = reductify(
out_tangents, out_tangents_dims, ctx._pt_out_dims[key], batch_size
)
if isinstance(result, torch.Tensor):
return result, None
else:
return *result, None
def backward(ctx: Any, *grad_outputs: Any) -> Any:
key = id(Generated)
grad_outputs_ = grad_outputs[:-1]
grad_outputs_in_dims = ctx._pt_out_dims[key]
if not isinstance(grad_outputs_in_dims, tuple):
grad_outputs_in_dims = (grad_outputs_in_dims,)
grad_outputs_in_dims = tuple(
in_dim if grad_output is not None else None
for grad_output, in_dim in zip(grad_outputs_, grad_outputs_in_dims)
)
def backward_no_context(inputs: Any) -> Any:
saved_tensors, grad_outputs = inputs
wrapped_ctx = CtxWithSavedTensors(ctx, saved_tensors)
return autograd_function.backward(wrapped_ctx, *grad_outputs)
grad_ins, grad_ins_dims = restore_vmap(
backward_no_context,
((ctx._pt_saved_tensors_bdims_stack[key], grad_outputs_in_dims),),
batch_size,
randomness,
)((ctx.saved_tensors, grad_outputs_))
result = reductify(
grad_ins, grad_ins_dims, in_dims, batch_size, ctx._pt_input_shapes[key]
)
return result
name = f"Vmapped{autograd_function.__name__}"
Generated = type(
name,
(torch.autograd.Function,),
{
"forward": staticmethod(forward),
"backward": staticmethod(backward),
"jvp": staticmethod(jvp),
"setup_context": staticmethod(setup_context),
"generate_vmap_rule": True,
},
)
return Generated
# tangents might be None, so we need to replace
# the corresponding in_dims with None.
def get_tangents_in_dims(input_dims: Any, tangents: tuple[Any, ...]) -> Any:
flat_in_dims, spec = pytree.tree_flatten(input_dims)
flat_tangents = pytree.arg_tree_leaves(*tangents)
result = [
None if tangent is None else in_dim
for in_dim, tangent in zip(flat_in_dims, flat_tangents)
]
return pytree.tree_unflatten(result, spec)
# NOTE: [Why do we need to run setup_context under a vmap?]
# Consider the following autograd.Function
#
# class Sum(torch.autograd.Function):
# @staticmethod
# def forward(x):
# return x.sum()
# @staticmethod
# def setup_context(ctx, inputs, outputs):
# ctx.x_shape = inputs[0]
# @staticmethod
# def backward(ctx, gy):
# return gy.expand(ctx.x_shape)
#
# x = torch.randn(B, 4)
# in_dims = 0
# vmap(Sum.apply, in_dims)(x)
#
# Let's assume for a moment that we didn't vmap setup_context in VmappedSum:
#
# class VmappedSum(torch.autograd.Function):
# @staticmethod
# def forward(x):
# return vmap(Sum.forward, in_dims)(x)
#
# @staticmethod
# def setup_context(ctx, inputs, outputs):
# Sum.setup_context(ctx, inputs, outputs)
#
# @staticmethod
# def backward(ctx, gy):
# def backward_no_context(gy):
# return gy.expand(ctx.x_shape)
#
# dims = (0,)
# gx = vmap(backward_no_context, dims)(gy)
# return gx
#
# We end up saving [B, 4] as x_shape. In the backward, gy has shape [B],
# and we're doing:
#
# def backward_no_context(gy):
# return gy.expand([B, 4])
#
# gx = vmap(backward_no_context, dims)(gy: "Tensor[B]")
#
# This gives us the wrong result (gx has shape [B, B, 4], but it should
# have shape [4]). Performing vmap over setup_context means the shape
# saved has shape [4] and leads to a correct result shape for gx.
# Wraps a ctx object. Forwards all attr accesses to the underlying object
# except for the attrs in _pt_attrs
class WrappedCtx:
_pt_reserved_attrs: tuple[str, ...] = ("_pt_reserved_attrs", "_pt_inner_ctx")
def __init__(self, ctx: Any) -> None:
if not isinstance(ctx, WrappedCtx):
reserved_attrs = type(self)._pt_reserved_attrs
for name in reserved_attrs:
if not hasattr(ctx, name):
continue
raise RuntimeError(
f"PyTorch reserves the {reserved_attrs} field on ctx. "
"Please name your fields on ctx something else to avoid name "
"collision."
)
self._pt_inner_ctx = ctx
def __getattr__(self, name: str) -> Any:
return getattr(self._pt_inner_ctx, name)
def __setattr__(self, name: str, value: Any) -> None:
if name in type(self)._pt_reserved_attrs:
self.__dict__[name] = value
return
return setattr(self._pt_inner_ctx, name, value)
# Wraps ctx to create a new ctx object that overrides saved_tensors.
class CtxWithSavedTensors(WrappedCtx):
_pt_reserved_attrs = ("_pt_new_saved_tensors", *WrappedCtx._pt_reserved_attrs)
def __init__(self, ctx: Any, new_saved_tensors: Sequence[torch.Tensor]) -> None:
super().__init__(ctx)
self._pt_new_saved_tensors = new_saved_tensors
@property
def saved_tensors(self) -> Sequence[torch.Tensor]:
return self._pt_new_saved_tensors
class CtxCustomSave(WrappedCtx):
_pt_reserved_attrs = (
"_pt_saved_tensors_bdims",
"_pt_current_level",
*WrappedCtx._pt_reserved_attrs,
)
def __init__(self, ctx: Any, current_level: int) -> None:
super().__init__(ctx)
self._pt_saved_tensors_bdims: tuple[Any, ...] = ()
self._pt_current_level = current_level
def save_for_backward(self, *tensors: torch.Tensor) -> None:
unwrapped_tensors, bdims = unwrap_batched(tensors, self._pt_current_level)
self._pt_inner_ctx.save_for_backward(*unwrapped_tensors)
self._pt_saved_tensors_bdims = bdims
def save_for_forward(self, *tensors: torch.Tensor) -> None:
unwrapped_tensors, bdims = unwrap_batched(tensors, self._pt_current_level)
self._pt_inner_ctx.save_for_forward(*unwrapped_tensors)
self._pt_saved_tensors_bdims = bdims
def reductify(
grad_input: torch.Tensor | tuple[torch.Tensor, ...],
grad_input_bdim: int | tuple[int, ...],
input_bdim: int | tuple[int, ...],
batch_size: int,
target_shape_without_bdim_to_reduce_to: Any = None,
) -> tuple[Any, ...]:
if not isinstance(grad_input, tuple):
grad_input = (grad_input,)
if not isinstance(grad_input_bdim, tuple):
grad_input_bdim = (grad_input_bdim,)
if not isinstance(input_bdim, tuple):
input_bdim = (input_bdim,)
if target_shape_without_bdim_to_reduce_to is None:
target_shape_without_bdim_to_reduce_to = len(grad_input) * (None,)
result = tuple(
reductify_leaf(gi, gi_bdim, i_bdim, batch_size, maybe_ishape)
for gi, gi_bdim, i_bdim, maybe_ishape in zip(
grad_input,
grad_input_bdim,
input_bdim,
target_shape_without_bdim_to_reduce_to,
)
)
return result
def reductify_leaf(
grad_input: torch.Tensor | None,
grad_input_bdim: int | None,
input_bdim: int | None,
batch_size: int,
target_shape_without_bdim_to_reduce_to: Any = None,
) -> torch.Tensor | None:
if grad_input is None:
return None
if grad_input_bdim is None and input_bdim is None:
return grad_input
if grad_input_bdim is not None and input_bdim is None:
return grad_input.sum(grad_input_bdim)
# NOTE: [Why can't we rely on autograd to reduce expanded gradients?]
# For reverse-mode AD,
# given a grad_input and input, it is valid for the user to return a
# grad_input that has a broadcasted shape when compared to the input.
# In this situation, autograd automatically reduces the grad_input to
# the shape of the input.
#
# However, when input_bdim is not None, we have problems.
#
# [example 1]
# grad_input: Tensor[3, 4], input: Tensor[B, 4]
# We can expand grad_input to Tensor[B, 3, 4], but that isn't broadcastable
# from [B, 4].
#
# [example 2]
# grad_input: Tensor[3, B, 4], input: Tensor[B, 4]
# We can swizzle grad_input to Tensor[B, 3, 4], but that isn't broadcastable
# from [B, 4].
#
# This means that we need to also reduce the grad_input to the shape of the
# input. This behavior is controlled by the `target_shape_without_bdim_to_reduce_to` flag;
# if not-None then we do the reducing manually, otherwise, we do not do a reduction.
if input_bdim is None:
raise AssertionError("input_bdim must not be None")
if grad_input_bdim is None:
grad_input = grad_input.unsqueeze(input_bdim)
new_shape = list(grad_input.shape)
new_shape[input_bdim] = batch_size
grad_input = grad_input.expand(new_shape)
grad_input_bdim = input_bdim
if target_shape_without_bdim_to_reduce_to is not None:
return vmap(
torch.Tensor.sum_to_size,
in_dims=(grad_input_bdim, None),
out_dims=input_bdim,
)(grad_input, target_shape_without_bdim_to_reduce_to)
if input_bdim != grad_input_bdim:
grad_input = grad_input.movedim(grad_input_bdim, input_bdim)
return grad_input
def autograd_function_forward_rewritten(
original_forward: Callable[_P, _R],
original_setup_context: Callable[..., Any],
) -> Callable[..., _R]:
def new_forward(ctx: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R:
output = original_forward(*args, **kwargs)
original_setup_context(ctx, args, output)
return output
return new_forward
class AutogradFunctionApply(HigherOrderOperator):
def __init__(self) -> None:
super().__init__("autograd_function_apply")
def __call__(
self,
fwd: torch.fx.GraphModule,
bwd: torch.fx.GraphModule,
*fwd_args: Any,
**fwd_kwargs: Any,
) -> Any:
saved_values: Iterable[Any] | None = None
non_differentiable_idx = fwd_kwargs["non_differentiable_idx"]
saved_for_backward_idx = fwd_kwargs["saved_for_backward_idx"]
class ApplyTemplate(torch.autograd.Function):
@staticmethod
def forward(*args: Any, **kwargs: Any) -> Any:
nonlocal saved_values
# The Interpreter here is required to propagate metadata
# from the dynamo graph body to the local_map graph body.
# This is required for fx_traceback.annotate for work.
output, saved_values = torch.fx.Interpreter(fwd).run(*args)
# See Note [Activations with no version counter checks in eager]
# Mark tensors that came from ctx.save_for_backward with metadata.
# This allows AOT autograd to distinguish between tensors saved via
# save_for_backward vs those stashed directly on ctx (e.g., ctx.x = x).
from torch.fx.experimental.proxy_tensor import _get_proxies
for idx, t in enumerate(saved_values):
if idx not in saved_for_backward_idx:
for proxy in _get_proxies(t):
proxy.node.meta["saved_tensor_with_no_vc_check"] = True
return output
@staticmethod
def setup_context(ctx: Any, inputs: tuple[Any, ...], output: Any) -> None:
# If users call ctx.mark_non_differentiable() in the original fwd function.
if len(non_differentiable_idx) > 0:
non_differentiable_output = []
for i, x in enumerate(output):
if i in non_differentiable_idx:
non_differentiable_output.append(x)
ctx.mark_non_differentiable(*non_differentiable_output)
@staticmethod
def backward(ctx: Any, *grad: Any) -> Any:
# The Interpreter here is required to propagate metadata
# from the dynamo graph body to the local_map graph body.
# This is required for fx_traceback.annotate for work.
if saved_values is None:
raise AssertionError("saved_values must not be None")
return torch.fx.Interpreter(bwd).run(*grad, *saved_values)
return ApplyTemplate.apply(*fwd_args)
autograd_function_apply = AutogradFunctionApply()
class DynamoAutogradFunctionTraceHelper:
@staticmethod
def fwd_trace_helper(orig_fwd: Callable[_P, Any]) -> Callable[_P, Any]:
# autograd.Function forward does more than just running the forward method. Most
# of this logic is in C++. Here, we rewrite that functionality in python and let
# Dynamo trace it.
def inner(*args: _P.args, **kwargs: _P.kwargs) -> Any:
with torch.no_grad():
outs = orig_fwd(*args, **kwargs)
# Handle the case where if the input is passed on directly to the output, we call view_as
# Refer to https://github.com/pytorch/pytorch/blob/main/torch/csrc/autograd/custom_function.cpp#L254
tensor_args = {arg for arg in args if isinstance(arg, torch.Tensor)}
if isinstance(outs, torch.Tensor):
if outs in tensor_args:
return outs.view_as(outs)
else:
return outs
new_outs = []
for out in outs:
if isinstance(out, torch.Tensor):
if out in tensor_args:
new_outs.append(out.view_as(out))
else:
new_outs.append(out)
else:
new_outs.append(out)
return tuple(new_outs)
# TODO - there is missing functionality here, where
# autograd.Function overwrites the requires_grad_ of the output
# tensors depending on the `mark_non_differentiable`. Currently,
# this is handled hackily in Dynamo, where we just overwrite the
# variable trackers requires_grad. Refer to the function -
# overwrite_tensor_vt_requires_grad
return inner
@@ -0,0 +1,27 @@
import torch.nn as nn
from torch._functorch.utils import exposed_in
def batch_norm_without_running_stats(module: nn.Module) -> None:
if (
isinstance(module, nn.modules.batchnorm._BatchNorm)
and module.track_running_stats
):
module.running_mean = None
module.running_var = None
module.num_batches_tracked = None
module.track_running_stats = False
@exposed_in("torch.func")
def replace_all_batch_norm_modules_(root: nn.Module) -> nn.Module:
"""
In place updates :attr:`root` by setting the ``running_mean`` and ``running_var`` to be None and
setting track_running_stats to be False for any nn.BatchNorm module in :attr:`root`
"""
# base case
batch_norm_without_running_stats(root)
for obj in root.modules():
batch_norm_without_running_stats(obj)
return root
@@ -0,0 +1,245 @@
from __future__ import annotations
import contextlib
import json
import operator
import os
import time
from contextlib import AbstractContextManager
from typing import Any, TYPE_CHECKING
from typing_extensions import TypeVar
import torch
from torch.profiler import profile, ProfilerActivity
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
_R = TypeVar("_R")
def synchronize() -> None:
pass
def dump_chrome_trace(
f: Callable[[tuple[Any, ...]], _R],
input_: tuple[Any, ...],
trace_filename: str,
optimize_ctx: AbstractContextManager[Any],
activities: Sequence[ProfilerActivity],
num_runs: int = 1,
devices: list[str] | None = None,
kwargs_for_f: dict[str, Any] | None = None,
kwargs_for_profiler: dict[str, Any] | None = None,
) -> float:
"""
Output the chrome trace of running f(input_, **kwargs_for_f) with [optimize_ctx]
[num_runs] times to [trace_filename].
[activities] are the activities that the profiler will record, e.g. ProfilerActivity.CUDA.
Return total runtime without the profiler
Outputs to trace_filename
"""
if devices is None:
devices = ["cuda"]
global synchronize
if devices != ["cpu"] and torch.cuda.is_available():
synchronize = torch.cuda.synchronize
if kwargs_for_f is None:
kwargs_for_f = {}
if kwargs_for_profiler is None:
kwargs_for_profiler = {}
with optimize_ctx:
torch.manual_seed(1337)
for _ in range(5): # warmup runs
f(input_, **kwargs_for_f)
synchronize()
torch.manual_seed(1337)
t0 = time.perf_counter()
for _ in range(num_runs):
f(input_, **kwargs_for_f)
synchronize()
t1 = time.perf_counter()
timing = t1 - t0
with profile(activities=activities, **kwargs_for_profiler) as prof:
with optimize_ctx:
synchronize()
torch.manual_seed(1337)
for _ in range(num_runs):
f(input_, **kwargs_for_f)
synchronize()
prof.export_chrome_trace(trace_filename)
return timing
def get_chrome_trace_events(filename: str) -> list[dict[str, Any]]:
with open(filename) as f:
data = json.load(f)
events = data["traceEvents"]
return events
def is_gpu_compute_event(event: dict[str, Any]) -> bool:
global gpu_pids
return (
"pid" in event
and event["pid"] in gpu_pids
and "ph" in event
and event["ph"] == "X"
)
def get_sorted_gpu_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
sorted_gpu_events: list[dict[str, Any]] = []
for event in events:
if not is_gpu_compute_event(event):
continue
sorted_gpu_events.append(event)
return sorted(sorted_gpu_events, key=operator.itemgetter("ts"))
def get_duration(sorted_gpu_events: list[dict[str, Any]]) -> int:
if len(sorted_gpu_events) == 0:
return 0
event = sorted_gpu_events[0]
current_end_time = event["ts"] + event["dur"]
total_duration = event["dur"]
for event in sorted_gpu_events[1:]:
start_time = max(event["ts"], current_end_time)
end_time = event["ts"] + event["dur"]
total_duration = total_duration + max(end_time - start_time, 0)
current_end_time = max(current_end_time, end_time)
return total_duration
def get_sorted_gpu_mm_conv_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
def is_mm_conv_event(event: dict[str, Any]) -> bool:
return "name" in event and (
"gemm" in event["name"]
or "conv" in event["name"]
or "cutlass" in event["name"]
or "wgrad" in event["name"]
)
gpu_events = get_sorted_gpu_events(events)
sorted_events: list[dict[str, Any]] = []
for event in gpu_events:
if not is_mm_conv_event(event):
continue
sorted_events.append(event)
return sorted_events
gpu_pids: list[Any] = []
def compute_utilization(filename: str, total_length: float) -> tuple[float, float]:
"""
Process the chrome traces outputs by the pytorch profiler to compute GPU Utilization
and percent of times spent on matmul and convolution
Args:
filename(str): Name of chrome traces file produced by pytorch profiler
total_length(float): total length of the process without profiler in second
Return:
tuple: (GPU Utilization, percent of time spent on matmul and convolution)
"""
events = get_chrome_trace_events(filename)
# get pids of GPU events
global gpu_pids
gpu_pids = []
for event in events:
if "name" not in event:
continue
if event["name"] == "process_labels" and "GPU" in event["args"]["labels"]:
gpu_pids.append(event["pid"])
total_length = total_length * 1e6
sorted_gpu_events = get_sorted_gpu_events(events)
utilization = get_duration(sorted_gpu_events) / total_length
sorted_gpu_mm_conv_events = get_sorted_gpu_mm_conv_events(events)
mm_conv_utilization = get_duration(sorted_gpu_mm_conv_events) / total_length
return utilization, mm_conv_utilization
def benchmark_utilization(
f: Callable[[tuple[Any, ...]], _R],
input_: tuple[Any, ...],
trace_folder: str,
optimize_ctx: AbstractContextManager[Any] | None = None,
trace_file_name: str = "tmp_chrome_trace",
num_runs: int = 1,
) -> tuple[float, float]:
"""
Benchmark the GPU Utilization and percent of time spent on matmul and convolution operations of
running f(input_, **kwargs_for_f) with [optimize_ctx] [num_runs] times.
It will produce a chrome trace file in trace_folder/trace_file_name.json
Example:
```
def f(a):
return a.sum()
a = torch.rand(2**20, device="cuda")
utilization, mm_conv_utilization = benchmark_utilization(
f, a, "tmp", trace_file_name="tmp_chrome_trace"
)
```
Args:
f: function to benchmark
input_: input to :attr:`f`
trace_folder: name of the folder to store the chrome trace
optimize_ctx: the context in which f will run
trace_file_name: name of the dumped chrome trace file, default to "tmp_chrome_trace"
num_runs: number of times to run f, excluding the warm-up runs, default to 1.
Return:
tuple: (GPU Utilization, percent of time spent on matmul and convolution)
"""
isExist = os.path.exists(trace_folder)
if not isExist:
os.makedirs(trace_folder)
print("create folder " + trace_folder)
if optimize_ctx is None:
optimize_ctx = contextlib.nullcontext()
chrome_trace_file_name = os.path.join(trace_folder, trace_file_name + ".json")
total_length = dump_chrome_trace(
f,
input_,
chrome_trace_file_name,
optimize_ctx,
[ProfilerActivity.CUDA],
num_runs=num_runs,
devices=["cuda"],
)
utilization, mm_conv_utilization = compute_utilization(
chrome_trace_file_name, total_length
)
return utilization, mm_conv_utilization
@@ -0,0 +1,229 @@
from __future__ import annotations
import operator
from typing import Any, TYPE_CHECKING
import sympy
import torch
import torch.fx as fx
from torch.fx.experimental.symbolic_shapes import free_unbacked_symbols
from torch.multiprocessing.reductions import StorageWeakRef
from torch.utils import _pytree as pytree
from torch.utils._pytree import tree_flatten
if TYPE_CHECKING:
from collections.abc import Callable
from torch._ops import OpOverloadPacket
from torch.utils._pytree import TreeSpec
aten = torch.ops.aten
def get_aten_target(node: fx.Node) -> OpOverloadPacket | Callable[..., Any] | str:
if hasattr(node.target, "overloadpacket"):
return node.target.overloadpacket
return node.target
rand_ops = [
aten.dropout,
aten._fused_dropout,
aten._standard_gamma,
aten.bernoulli,
aten.multinomial,
aten.native_dropout,
aten.normal,
aten.poisson,
aten.binomial,
aten.rrelu,
aten.rand_like,
aten.rand,
aten.randint,
aten.randn,
aten.randperm,
]
# return a new copy of torch.fx.graph.Graph with CSE applied to the input graph
def fx_graph_cse(fx_g: torch.fx.graph.Graph) -> fx.Graph:
new_graph = fx.Graph()
env: dict[
fx.Node, fx.Node
] = {} # map from node in the old graph to node in the new graph
hash_env: dict[
tuple[str, int], fx.Node
] = {} # map from hash to a node in the new graph
token_map: dict[tuple[str, int], dict[str, Any]] = {} # map from hash to token
from torch._inductor.pattern_matcher import (
compute_mutation_region_ids,
same_mutation_regions,
)
compute_mutation_region_ids(fx_g) # type: ignore[arg-type]
# Make a set of separate storages returned from the output, which will be preserved
# when pruning. This prevents us from deduplicating returned tensors which have
# experienced identical operations, but are separate data structures in eager mode.
output_node: fx.Node = list(fx_g.nodes)[-1]
if output_node.op != "output":
raise AssertionError(
f"expected output_node.op to be 'output', got '{output_node.op}'"
)
def checkable_node(node: fx.Node) -> bool:
"""We can evaluate only nodes that represent tensors with defined storage."""
if "val" not in node.meta or not isinstance(node.meta["val"], torch.Tensor):
return False
try:
node.meta["val"].untyped_storage()
except NotImplementedError:
return False
return True
output_storages = {
StorageWeakRef(n.meta["val"].untyped_storage())
for n in output_node.all_input_nodes
if checkable_node(n)
}
nodes_that_alias_outputs = {
n
for n in fx_g.nodes
if checkable_node(n)
and StorageWeakRef(n.meta["val"].untyped_storage()) in output_storages
}
for n in fx_g.nodes:
# The placeholder, output, and get_attr nodes are copied to the new graph without change
# do not CSE away random operations
if (
n.op == "placeholder"
or n.op == "output"
or n.op == "get_attr"
or get_aten_target(n) in rand_ops
# aten.empty is non-deterministic, so don't CSE it.
# Also, aten.empty is almost always fusible into its consumer,
# so it's not worth CSEing.
or get_aten_target(n) is aten.empty
or n in nodes_that_alias_outputs
# This CSE pass currently doesn't handle re-propagation of unbacked
# meta where it'll sometimes eliminate a _local_scalar_dense but not
# replace the meta of downstream users. eg. one bug we've seen is:
#
# _local_scalar_dense_11: "Sym(u14)" = torch.ops.aten._local_scalar_dense.default(select_10);
# sym_sum_2: "Sym(u19 + u20 + u21)" = torch.sym_sum((_local_scalar_dense_11, _local_scalar_dense_12, _local_scalar_dense_13)) # noqa: B950
#
# Notice how _local_scalar_dense_11 is u14 but sym_sum_2's meta is incorrectly the old
# pre-cse value of u19.
or (
"val" in n.meta
and isinstance(n.meta["val"], sympy.Symbol)
and free_unbacked_symbols(n.meta["val"])
)
):
new_node = new_graph.node_copy(n, lambda x: env[x])
env[n] = new_node
else: # n.op == 'call_function', should never see n.op == 'call_module' or 'call_method'
# substitute args and kwargs members to their mapping in env if exists
# specs can be used to reconstruct nested list/dictionaries
def substitute(
arg_list: list[Any] | tuple[Any, ...],
) -> tuple[tuple[Any, ...], TreeSpec]:
arg_list, spec = tree_flatten(arg_list)
for i in range(len(arg_list)):
v = arg_list[i]
if isinstance(v, torch.fx.node.Node) and v in env:
arg_list[i] = env[v]
if isinstance(v, (torch.SymBool, torch.SymInt, torch.SymFloat)):
arg_list[i] = v.node
return tuple(arg_list), spec
args, args_spec = substitute(n.args)
kwargs, kwargs_spec = substitute(n.kwargs)
# each token corresponds to a unique node
# nodes with the same token can be substituted
token = {
"target": n.target,
"args": args,
"args_spec": args_spec,
"kwargs": kwargs,
"kwargs_spec": kwargs_spec,
}
# hash substituted args to a number, do not hash specs because specs are not hashable
# We need to add type into hash to avoid situations like:
# hash((primals_2, 1.0)) == hash((primals_2, 1))
hash_arg = hash(
(tuple((a, type(a)) for a in args), tuple((a, type(a)) for a in kwargs))
)
hash_val = (n.target, hash_arg)
# check if a node has a substitute and can be eliminated
hash_val_in_hash_env = hash_val in hash_env
overwrite_due_to_mutation = False
if hash_val_in_hash_env and token_map[hash_val] == token:
duplicate_n_prev = hash_env[hash_val]
if same_mutation_regions(n, duplicate_n_prev):
env[n] = duplicate_n_prev
continue
else:
# any futures duplicates should replace with n, not duplicate_n_prev
overwrite_due_to_mutation = True
new_node = new_graph.node_copy(n, lambda x: env[x])
env[n] = new_node
if overwrite_due_to_mutation or not hash_val_in_hash_env:
hash_env[hash_val] = new_node
token_map[hash_val] = token
return new_graph
def raise_getitems(gm: fx.GraphModule) -> fx.GraphModule:
# Pre-create a list of nodes to iterate over, as modifying the node order
# during the loop can lead to infinite loops if not handled properly.
getitem_nodes = list(
gm.graph.find_nodes(op="call_function", target=operator.getitem)
)
# loop through getitem nodes in the graph and raise them to the parent node
# in reverse order to preserve their original relative order
for node in reversed(getitem_nodes):
if len(node.all_input_nodes) != 1:
raise AssertionError(
f"expected node {node.name} to have 1 input node, got {len(node.all_input_nodes)}"
)
parent = node.all_input_nodes[0]
parent.append(node)
gm.recompile()
return gm
def strip_overloads(gm: fx.GraphModule) -> None:
"""
Modifies the target of graph nodes in :attr:`gm` to strip overloads.
Args:
gm(fx.GraphModule): The input Fx graph module to be modified
"""
for node in gm.graph.nodes:
if isinstance(node.target, torch._ops.OpOverload):
node.target = node.target.overloadpacket
gm.recompile()
def get_placeholders(graph: fx.Graph) -> fx.graph._node_list:
return graph.find_nodes(op="placeholder")
def get_outputs(graph: fx.Graph) -> list[fx.Node]:
for node in graph.find_nodes(op="output"):
return pytree.tree_leaves(node.args[0])
raise AssertionError("No output node found")
@@ -0,0 +1,501 @@
from __future__ import annotations
import copy
import logging
import os
import pickle
import random
from contextlib import contextmanager
from functools import partial
from typing import Any, TYPE_CHECKING
from typing_extensions import ParamSpec, TypeVar
import sympy
import torch
import torch.fx as fx
import torch.nn as nn
import torch.utils._pytree as pytree
from torch import SymInt
from torch._decomp import get_decompositions
from torch.fx.experimental.symbolic_shapes import bind_symbols
from .aot_autograd import aot_function, aot_module, make_boxed_compiler
from .compile_utils import strip_overloads
from .partitioners import (
default_partition,
draw_graph,
min_cut_rematerialization_partition,
)
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Sequence
from torch.fx.node import Node
from torch.types import IntLikeType
_P = ParamSpec("_P")
_R = TypeVar("_R")
log = logging.getLogger(__name__)
# These canonicalization are needed here (and not decompositions), as the ops
# we're trying to canonicalize to CompositeImplicitAutograd.
def _canonicalize(fx_g: fx.GraphModule) -> fx.GraphModule:
for node in fx_g.graph.find_nodes(
op="call_function", target=torch.ops.aten._to_copy
):
node.target = torch.ops.aten.to
fx_g.recompile()
return fx_g
@contextmanager
def _disable_jit_autocast() -> Generator[None, None, None]:
# pyrefly: ignore [missing-attribute]
old_jit_autocast_flag = torch._C._jit_set_autocast_mode(False)
try:
yield
finally:
# pyrefly: ignore [missing-attribute]
torch._C._jit_set_autocast_mode(old_jit_autocast_flag)
@make_boxed_compiler
def ts_compile(fx_g: fx.GraphModule, inps: Sequence[Any]) -> torch.jit.ScriptModule:
"""
Compiles the :attr:`fx_g` with Torchscript compiler.
.. warning::
This API is experimental and likely to change.
Args:
fx_g(fx.GraphModule): The input Fx graph module to be compiled.
Returns:
Torch scripted model.
"""
with _disable_jit_autocast():
strip_overloads(fx_g)
for node in fx_g.graph.find_nodes(
op="call_function", target=torch.ops.aten._to_copy
):
if len(node.args) == 1 and len(node.kwargs) == 1 and "dtype" in node.kwargs:
node.target = torch.ops.aten.to
for node in fx_g.graph.nodes:
new_kwargs = {}
for k, v in node.kwargs.items():
if isinstance(v, torch.device):
v = v.type
new_kwargs[k] = v
node.kwargs = new_kwargs
fx_g.graph.lint()
fx_g.recompile()
f = torch.jit.script(fx_g)
# pyrefly: ignore [missing-attribute]
torch._C._jit_pass_remove_mutation(f.graph)
f = torch.jit.freeze(f.eval())
f = torch.jit.optimize_for_inference(f)
if not any(isinstance(t, torch._subclasses.FakeTensor) for t in inps):
f(*inps)
return f
def _draw_graph_compile(
fx_g: fx.GraphModule, _: Any, name: str, clear_meta: bool = True
) -> fx.GraphModule:
print(fx_g.code)
draw_graph(fx_g, name, clear_meta=clear_meta)
return fx_g
def draw_graph_compile(
name: str,
) -> Callable[[fx.GraphModule, list[Any]], fx.GraphModule]:
return make_boxed_compiler(partial(_draw_graph_compile, name=name))
@make_boxed_compiler
def nop(fx_g: fx.GraphModule, _: Any) -> fx.GraphModule:
"""
Returns the :attr:`fx_g` Fx graph module as it is. This is a no-op compiler
and can be used to check accuracy.
.. warning::
This API is experimental and likely to change.
"""
return fx_g
class DebugInterpreter(fx.Interpreter):
def run(
self,
*args: Any,
initial_env: dict[Node, Any] | None = None,
enable_io_processing: bool = True,
) -> Any:
self.symbol_mapping = bind_symbols(
# pyrefly: ignore[bad-argument-type]
self.module,
*args,
)
return super().run(
*args, initial_env=initial_env, enable_io_processing=enable_io_processing
)
def run_node(self, n: Node) -> Any:
def subst_symint(ni: IntLikeType) -> int:
if not isinstance(ni, SymInt):
return ni
r = sympy.expand(ni.node.expr.xreplace(self.symbol_mapping))
if not r.is_number:
raise AssertionError(f"expected r to be a number, got {r}")
return int(r)
def subst_symint_tuple(nis: tuple[IntLikeType, ...]) -> tuple[int, ...]:
return tuple(subst_symint(ni) for ni in nis)
def check_significant_strides(a: torch.Tensor, b: torch.Tensor) -> bool:
if subst_symint(a.numel()) > 0:
for idx in range(a.ndim):
if (
subst_symint(a.stride(idx)) != b.stride(idx)
and subst_symint(a.size(idx)) > 1
):
return False
return True
def check(nv: torch.Tensor, rv: torch.Tensor, desc: Callable[[], str]) -> None:
if not callable(desc):
raise AssertionError(f"expected desc to be callable, got {type(desc)}")
if nv.dtype != rv.dtype:
raise AssertionError(f"{desc()}: {nv.dtype} != {rv.dtype}")
if subst_symint_tuple(nv.size()) != rv.size():
raise AssertionError(
f"{desc()}: {nv.size()} aka {subst_symint_tuple(nv.size())} != {rv.size()}"
)
same_strides = check_significant_strides(nv, rv)
if not same_strides:
raise AssertionError(
f"{desc()}: {nv.stride()} aka {subst_symint_tuple(nv.stride())} != {rv.stride()}"
)
r = super().run_node(n)
if "val" in n.meta:
n_vals, _n_spec = pytree.tree_flatten(n.meta["val"])
r_vals, _r_spec = pytree.tree_flatten(r)
# TODO: There is some sort of problem where we record that an
# operator returned a tuple/list, and then later it turns out the
# real version of the operator returned a list/tuple. Need to
# figure out what's actually going on here, the error itself is
# harmless enough as we only getitem out the outputs.
# assert n_spec == r_spec, f"{n_spec} != {r_spec}"
if len(n_vals) != len(r_vals):
raise AssertionError(f"{len(n_vals)} != {len(r_vals)}")
for i, nv, rv in zip(range(len(n_vals)), n_vals, r_vals):
if not isinstance(rv, torch.Tensor):
continue
check(nv, rv, lambda: f"output {i} where {self.symbol_mapping}")
return r
@make_boxed_compiler
def debug_nop(
fx_g: fx.GraphModule, _: Any
) -> Callable[[DebugInterpreter, Any, dict[Node, Any] | None, bool], Any]:
"""
Returns a (slow) interpreter over the FX graph module that also checks
various debugging properties (e.g., that tracing strides matched real
strides.)
"""
return DebugInterpreter(fx_g).run
@make_boxed_compiler
def simple_ts_compile(fx_g: fx.GraphModule, _: Any) -> torch.jit.ScriptModule:
strip_overloads(fx_g)
f = torch.jit.script(fx_g)
f = torch.jit.freeze(f.eval())
return f
def nnc_jit(f: Callable[..., Any]) -> Callable[..., Any]:
return aot_function(f, simple_ts_compile)
aten = torch.ops.aten
default_decompositions = {
aten.detach,
aten.gelu_backward,
aten.leaky_relu_backward,
aten.sigmoid_backward,
aten.threshold_backward,
aten.hardtanh_backward,
aten.hardsigmoid_backward,
aten.hardswish_backward,
aten.tanh_backward,
aten.silu_backward,
aten.elu_backward,
aten.cudnn_batch_norm,
aten.cudnn_batch_norm_backward,
aten.masked_fill.Scalar,
aten.masked_fill.Tensor,
aten.elu,
aten.leaky_relu,
aten.hardtanh,
aten.hardswish,
aten.hardsigmoid,
aten.conj_physical,
aten.is_same_size,
}
# pyrefly: ignore[bad-argument-type]
default_decompositions = get_decompositions(default_decompositions)
@make_boxed_compiler
def print_compile(fx_g: fx.GraphModule, _: Any) -> fx.GraphModule:
print(fx_g.code)
return fx_g
def memory_efficient_fusion(
fn: Callable[_P, _R] | nn.Module,
**kwargs: Any,
) -> Callable[_P, _R] | nn.Module:
"""
Wrapper function over :func:`aot_function` and :func:`aot_module` to perform
memory efficient fusion. It uses the
:func:`min_cut_rematerialization_partition` partitioner to perform efficient
recomputation. It uses NVFuser to compile the generated forward and backward
graphs.
.. warning::
This API is experimental and likely to change.
Args:
fn (Union[Callable, nn.Module]): A Python function or a ``nn.Module``
that takes one or more arguments. Must return one or more Tensors.
**kwargs: Any other overrides you want to make to the settings
Returns:
Returns a ``Callable`` or ``nn.Module`` that retains the eager behavior
of the original :attr:`fn`, but whose forward and backward graphs have
gone through recomputation optimizations, and the graphs have been
compiled with nvfuser.
"""
config = {
"fw_compiler": ts_compile,
"bw_compiler": ts_compile,
"partition_fn": min_cut_rematerialization_partition,
"decompositions": default_decompositions,
}
config.update(kwargs)
if isinstance(fn, torch.nn.Module):
return aot_module(fn, **config) # pyrefly: ignore[bad-argument-type]
else:
return aot_function(fn, **config) # pyrefly: ignore[bad-argument-type]
def debug_compile(
fx_g: fx.GraphModule, inps: Sequence[torch.Tensor]
) -> torch.jit.ScriptModule:
fx_g.to_folder("foo")
print(
f"""
##############################################################
# To minimize FX graph, copy and paste the below and run it #
##############################################################
import torch
import torch.fx as fx
from functorch.compile import minifier, check_nvfuser_subprocess, check_nvfuser_correctness_subprocess
inps = {[(i.shape, i.dtype) for i in inps]}
inps = [torch.ones(shape, dtype=dtype, device='cuda') for (shape, dtype) in inps]
from foo import FxModule
mod = FxModule().cuda()
with torch.jit.fuser("fuser2"):
# check_nvfuser_subprocess can be replaced with check_nvfuser_correctness_subprocess
minifier(fx.symbolic_trace(mod), inps, check_nvfuser_subprocess)
"""
)
# pyrefly: ignore[missing-import, missing-module-attribute]
from foo import FxModule
FxModule().cuda()(*inps)
return ts_compile(fx_g, inps)
graph_index: int = 0
def get_inputs(input_data_path: str) -> list[torch.Tensor]:
"""
Return a random input for the given inputs meta generated from _save_fx_default.
"""
inputs: list[torch.Tensor] = []
with open(input_data_path, "rb") as f:
inputs_meta = pickle.load(f)
inputs = []
for meta in inputs_meta:
if len(meta) == 1:
type = meta
input_ = type(random.random())
else:
type, shape, _stride, dtype, device = meta
if dtype in {
torch.int,
torch.int32,
torch.int64,
torch.bool,
torch.int,
torch.uint8,
int,
float,
}:
input_ = torch.randint(0, 1, shape, dtype=dtype, device=device)
else:
input_ = torch.rand(shape, dtype=dtype, device=device)
inputs.append(input_)
return inputs
def _save_fx_default(
current_name: str,
folder_name: str,
dump_example_input: bool,
gm: torch.fx.GraphModule,
example_inputs: list[torch.Tensor],
) -> nn.Module:
"""
The forward, backward, and joint computation graph will be stored in
{folder_name}/{current_name}/{current_name}_forward_{graph_index},
{folder_name}/{current_name}/{current_name}_backward_{graph_index}, and
{folder_name}/{current_name}/{current_name}_joint_{graph_index} respectively.
The input shape of the graphs will be stored in the .input files.
These files can be loaded with pickle,
and is a list of format (type, shape, stride, dtype, device).
In the case of type = int or float, it is just (type,).
For joint graph input, it is a nested list [[],[]]
where the two inner lists have the same format.
If dump_example_input is True, example_inputs will be stored in .pt file.
Since each function might produce multiple graphs,
the graph_index is used to distinguish difference graphs
"""
from functorch.compile import aot_module_simplified
def get_input_meta(args: Any) -> list[Any]:
input_meta = []
if len(args) > 0 and isinstance(args[0], tuple): # joint input
input_meta += get_input_meta(args[0])
input_meta += get_input_meta(args[1])
return input_meta
for arg in args:
if type(arg) is int or type(arg) is float:
input_meta.append((type(arg),))
else:
input_meta.append(
(type(arg), arg.shape, arg.stride(), arg.dtype, arg.device)
)
return input_meta
def graph_saver_helper(
gm_to_save: fx.GraphModule, args: Any, type_name: str
) -> None:
global graph_index
if len(gm_to_save.graph.nodes) == 0:
log.log(
logging.WARNING,
"No nodes in graph {%s}_{%s}_{%s}.",
current_name,
type_name,
graph_index,
)
return
gm = copy.deepcopy(gm_to_save)
gm.graph.set_codegen(torch.fx.graph.CodeGen()) # remove codegen
gm.recompile()
input_meta = get_input_meta(args)
os.makedirs(f"{folder_name}/{current_name}", exist_ok=True)
gm.to_folder(
f"{folder_name}/{current_name}/{current_name}_{type_name}_{graph_index}"
)
with open(
f"{folder_name}/{current_name}/{current_name}_{type_name}_{graph_index}/{current_name}_{type_name}_{graph_index}.input",
"wb",
) as f:
pickle.dump(input_meta, f)
if dump_example_input:
torch.save(
args,
f"{folder_name}/{current_name}/{current_name}_{type_name}_{graph_index}/{current_name}_{type_name}_{graph_index}.pt", # noqa: B950
) # noqa: E501
def graph_saver_forward(
gm: fx.GraphModule, example_inputs: list[torch.Tensor]
) -> fx.GraphModule:
graph_saver_helper(gm, example_inputs, "forward")
return gm
def graph_saver_backward(
gm: fx.GraphModule, example_inputs: list[torch.Tensor]
) -> fx.GraphModule:
graph_saver_helper(gm, example_inputs, "backward")
global graph_index
graph_index += 1
return gm
def graph_saver_joint(
gm: fx.GraphModule, joint_args: list[torch.Tensor]
) -> tuple[fx.GraphModule, fx.GraphModule]:
graph_saver_helper(gm, joint_args, "joint")
return default_partition(gm, joint_args) # pyrefly: ignore[missing-argument]
# pyrefly: ignore[bad-return]
return aot_module_simplified(
gm,
example_inputs,
fw_compiler=graph_saver_forward, # pyrefly: ignore[bad-argument-type]
bw_compiler=graph_saver_backward, # pyrefly: ignore[bad-argument-type]
partition_fn=graph_saver_joint,
decompositions=default_decompositions, # pyrefly: ignore[bad-argument-type]
)
# WARNING: This isn't tested anywhere!!
def graph_dumper_aot(
current_name: str, folder_name: str, dump_example_input: bool = False
) -> Callable[[bool, nn.Module], Any]:
"""
Dump the forward, backward, and joint computation graph.
Example Usage:
save_fx_func = graph_dumper_aot(current_name, folder_name, dump_example_input = False)
optimize_ctx = torchdynamo.optimize(
save_fx_func
)
with torch.enable_grad():
with optimize_ctx:
result = forward_and_backward_pass(model, example_inputs)
"""
global graph_index
graph_index = 0
return partial(_save_fx_default, current_name, folder_name, dump_example_input)
@@ -0,0 +1,444 @@
# Copyright (c) Facebook, Inc. and its 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.
from collections.abc import Callable
"""
Global flags for aot autograd
"""
import os
import sys
from typing import Literal, TYPE_CHECKING
from torch.utils._config_module import Config, install_config_module
# [@compile_ignored: debug]
_save_config_ignore = [
# callable not serializable
"joint_custom_pass",
# callable configs with uuid() for caching, or raw callables
"activation_memory_budget_runtime_estimator",
"activation_memory_budget_solver",
]
# Converts torch rng ops to their functional philox rng equivalents. Note that
# we functionalize only CUDA rng ops today.
functionalize_rng_ops = False
# can be useful for debugging if we are incorrectly creating meta fake tensors
fake_tensor_allow_meta = os.environ.get("FAKE_ALLOW_META", "1") != "0"
# Enables optional asserts in hotpath code to check for errors. If
# you are seeing weird accuracy problems, try turning this on.
# This is currently off by default as it will harm tracing time,
# but it is on by default for aot_eager.
debug_assert = False
debug_partitioner = os.environ.get("AOT_PARTITIONER_DEBUG", "0") != "0"
# See # NOTE [Export custom triton op]
decompose_custom_triton_ops = True
static_weight_shapes = True
# See https://github.com/pytorch/pytorch/issues/141881
# Tells partitioner that parameters are free to save for backward.
treat_parameters_as_free_to_save = True
# Applies CSE to the graph before partitioning
cse = True
from torch._environment import is_fbcode
enable_autograd_cache: bool = Config(
justknob="pytorch/remote_cache:enable_local_autograd_cache",
env_name_force="TORCHINDUCTOR_AUTOGRAD_CACHE",
default=True,
)
autograd_cache_allow_custom_autograd_functions: bool = Config(
env_name_force="TORCHINDUCTOR_AUTOGRAD_CACHE_ALLOW_CUSTOM_AUTOGRAD", default=False
)
# For now, this is just for enabling unit testing in test_aot_autograd_cache.py
# We will either make this the default with AOTAutogradCache, or
# we'll just use it in the precompile flow. So there's no
# need to add env vars or make it configurable
bundled_autograd_cache: bool = False
bypass_autograd_cache_key: bool = False
# Whether or not to normalize placeholder names in graphs
# from dynamo in AOTAutogradCache
autograd_cache_normalize_inputs = not is_fbcode()
# Enable debug mode at first invocation to check if custom ops are valid.
# When enabled, this checks that custom operators don't violate aliasing constraints.
#
# check_custom_op_aliasing: Controls whether to run the custom op aliasing check at all.
# - When True: The check runs on first invocation of compiled functions.
# - When False: The check is skipped entirely.
#
# error_on_custom_op_aliasing: Controls behavior when a violation is detected.
# Only has effect when check_custom_op_aliasing is True.
# - When True: Raises RuntimeError on aliasing violations.
# - When False: Emits UserWarning on aliasing violations.
#
# Deprecated: Custom ops returning aliased outputs is deprecated and will
# become an error in PyTorch 2.12. Currently error_on_custom_op_aliasing
# is True only in CI.
check_custom_op_aliasing = True
error_on_custom_op_aliasing = bool(os.getenv("CI"))
def remote_autograd_cache_default() -> bool | None:
if os.environ.get("TORCHINDUCTOR_AUTOGRAD_REMOTE_CACHE") == "1":
return True
if os.environ.get("TORCHINDUCTOR_AUTOGRAD_REMOTE_CACHE") == "0":
return False
return None
enable_remote_autograd_cache = remote_autograd_cache_default()
# When AOTAutograd regenerates aliased graph outputs,
# attempt to use functionalization's view-replay logic
# before falling back to the autograd engine's view replay or as_strided.
# This can have some perf implications
# (although for many models this will not matter).
# (1) If you have many view ops chained together, replaying all of them
# at runtime can have more overhead compared to a single as_strided call
# (2) If you are doing training, AsStridedBackward is quite slow,
# and the individual view op backward formulas will likely be faster.
# (3) Some backends like XLA do not support as_strided
# Temporary hack: disable this flag for internal
# (needed to fix an internal issue while avoiding bumping XLA pin)
# eventually: either default this config to false completely
# once XLA pin update works,
# or default config to true and fix relevant bugs
# View replay is currently not compatible with AOTAutogradCache, since
# FunctionalTensors are not serializable. We'll need to make them
# serializable before enabling warm cache with this config turned on.
view_replay_for_aliased_outputs = not is_fbcode()
# Restricts the amount of computation AOTAutograd can do.
# NB: We have essentially disabled this heuristic now. However, this is kept
# here for now in case it's useful. Setting it low can artificially reduce the
# amount of recomputation AOTAutograd performs, although not in any kind of
# principled way.
max_dist_from_bw = 1000
# Bans recomputation of nodes that are reading from nodes that are far before
# the current node
ban_recompute_used_far_apart = True
# Breaks up long chain of fusible ops, as otherwise we can have an arbitrarily
# long chain of recomputation in the backwards pass.
ban_recompute_long_fusible_chains = True
# Bans recomputation of nodes that must be materialized in the backwards pass
# (used by a non-fusible node)
ban_recompute_materialized_backward = True
# Chooses to ban recomputation of nodes based off an allowlist. Setting it to
# False changes it to use a denylist. Main change is on operators like
# sort/pool/stuff that isn't cheap enough to be fusible for free but also isn't
# that expensive
ban_recompute_not_in_allowlist = True
# Chooses to ban recomputation of reductions. This is generally a good idea, as
# the result of reductions is generally very small but recomputing reductions in
# a fusion can be expensive.
ban_recompute_reductions = True
# Prevents the partitioner from ever saving views (i.e. always recompute them).
# Generally a good idea since views are free to recompute.
recompute_views = False
# Set this flag to enable considering non-built-in ops, including triton and custom
# ops, for recomputation during the knapsack optimization solver.
is_non_builtin_to_include = False
# Rematerialize AC nodes for graphs with forward+loss+backward in one graph.
# This optimization minimizes activation checkpoint node lifetimes by computing them
# just-in-time. For AC nodes only used in backward, they are deferred to backward region
# instead of being computed and saved in forward. This reduces peak memory usage.
# Note: This only applies to forward+loss+backward graphs where torch.autograd.grad is allowed
# in the graph. Joint graphs (standard AOTAutograd) use the partitioner instead.
remat_using_tags_for_fwd_loss_bwd_graph = True
# By default, the partitioner is purely trying to optimize for runtime (although
# it should always use less memory than eager)
# This knob controls the partitioner to make that tradeoff for you, choosing the
# fastest option that saves less activations than the memory budget.
# Specifically, 0.0 corresponds to the activation memory from applying
# activation checkpointing to the full compiled region, and 1.0 corresponds to
# the activation memory from the default runtime-optimized strategy. So, 0.4
# would result in a strategy that saves 40% of the activations compared to the
# default strategy.
# It solves a 0-1 knapsack to find the minimum recompute necessary to stay below
# the activation memory budget.
# NOTE: This *cannot* be treated as
activation_memory_budget = 1.0
# This controls how we estimate the runtime when deciding what the cheapest
# operators to recompute are. The 3 options are
# "flops": Bases it off of the flop count provided by torch.utils.flop_counter
# "profile": Benchmarks each operator to come up with a runtime
# "testing": Returns 1 for everything
activation_memory_budget_runtime_estimator = "flops"
# This controls the solver used for the 0-1 knapsack. By default we use a
# quantized DP solution ("dp"). The other approaches are a "greedy", an "ilp"
# (which has a scipy dependency) and "dp_knapsack_sliding_hirschberg", which
# used memory-efficient quantized DP solution
activation_memory_budget_solver = "dp"
# This dumps out a SVG visualization of the expected runtime vs. activation
# memory tradeoffs for all memory budget values from 0 to 1 in increments of
# 0.5. See an example here:
# https://github.com/pytorch/pytorch/pull/126320#discussion_r1625104015
visualize_memory_budget_pareto = (
os.environ.get("PARTITIONER_MEMORY_BUDGET_PARETO", "0") == "1"
)
# This controls the directory in which to dump the SVG plot with the pareto
# frontier of the activation checkpointing memory-vs-runtime tradeoffs.
memory_budget_pareto_dir = os.environ.get("PARTITIONER_MEMORY_BUDGET_PARETO_DIR")
# Sets all of the ban_recompute heuristics to False except ban_recompute_reductions
# Generally, this will probably result in some memory improvement, but at the
# cost of some performance
aggressive_recomputation = False
# activation offloading enablement (testing purpose)
enable_activation_offloading = False
# activation offloading with separate CUDA stream
activation_offload_separate_stream = False
# activation offloading wait sinking when using separate stream (fwd graph)
activation_offload_sink_wait = False
# activation reloading with prefetching when using separate streams (bwd graph)
activation_reload_prefetch = False
# CPU ↔ GPU bandwidth in GB/s, used to estimate transfer times for prefetch
# scheduling. This is hardware-specific and should be set by the user.
activation_offload_cpu_gpu_bw: float = 50.0
# If FakeTensor.data_ptr() should error.
# This option is independent of AOTAutograd and torch.compile, but our policy
# is to turn it off during torch.compile.
fake_tensor_allow_unsafe_data_ptr_access = True
# Unlifts effect tokens from the inputs/outputs in the traced graph and instead
# inserts make_token/sink_token calls in the graph to create tokens and then
# sink them at the end. Note that this means the graph is no longer functional
# which may lead to silent errors unless the backend knows how to handle the
# tokens.
unlift_effect_tokens = False
# NOTE: [The default layout constraint for custom operators.]
# This must be the name of one of the layout constraint tags
# (that is, one of {"needs_fixed_stride_order", "flexible_layout"}),
# If the custom op does not have a layout constraint tag already
# then we assume the following applies.
#
# This config is respected by Inductor and we recommend other backends also
# respect it.
# This config is in torch._functorch and not torch._inductor because it affects
# ProxyTensor tracing.
custom_op_default_layout_constraint: Literal[
"needs_exact_strides", "needs_fixed_stride_order", "flexible_layout"
] = "needs_exact_strides"
# Run aot eager decomp partition with CrossRefFakeMode
# options = False, "all", "custom_ops"
fake_tensor_crossref = False
# This mode specifies that we should also keep track of the real
# tensor along with the fake tensor, and do real compute. While
# seemingly this eliminates the whole point of fake tensors, there are
# two obvious use cases for it:
#
# 1. When users call item()/other data dependent operations,
# if we propagate_real_tensors we are able to determine what
# the true value is and keep going.
#
# 2. It can be useful for testing, when you want to see if the fake
# and real tensors agree with each other. (Note that there are
# currently known inaccuracies in how we clone real tensors, that
# would have to be tightened up for this to be useful in this
# case.)
#
# Note that fake tensors are typically understood to be cheap to store
# indefinitely, so we tend to hold on to them longer than we would
# hold onto the real tensors. So we also support you explicitly
# deallocating the real tensor associated with a fake tensor, at which
# point we will stop propagating real tensors.
#
# One more thing: when you provide a real tensor to fakeify, we will
# clone it, so that we can safely perform mutations on it if necessary.
# This will increase live memory usage. This could potentially be
# optimized by using COW. We also currently do not faithfully
# maintain autograd metadata on the real tensor; this is fine because
# AOTAutograd will only use the fake tensor to determine leafness/etc
# of tensors in question.
fake_tensor_propagate_real_tensors = False
# AOTDispatcher traces out a backward graph at the time of the forward pass.
# This flag controls whether or not that backward graph gets autocast behavior
# applied to it.
#
# The options are either:
# - "same_as_forward". We assume that the backward of the torch.compile'ed region
# will be run under the same autocast context manager that the region was run
# under. This is equivalent to running the following code in eager:
#
# with torch.amp.autocast(...):
# y = region(x)
# ...
# z.backward()
#
# - "off". We assume that the backward of the torch.compile'd region will
# not be run under any autocast context managers.
# This is equivalent to running the following code in eager:
#
# with torch.amp.autocast(...):
# y = region(x)
# ...
# z.backward()
#
# - or a list of kwargs dicts that represent an autocast context manager to turn
# on during the backward pass.
#
# e.g. [{"device_type": "cuda"}] is equivalent to running the following code in eager:
#
# y = region(x)
# ...
# with torch.amp.autocast(device="cuda"):
# z.backward()
backward_pass_autocast = "same_as_forward"
# This controls whether we collect donated buffers. This flag must be set
# False if a user wants to retain_graph=True for backward.
donated_buffer = not is_fbcode()
# Controls the default graph output format used by draw_graph
# Supported formats are defined here https://graphviz.org/docs/outputs/
torch_compile_graph_format = os.environ.get("TORCH_COMPILE_GRAPH_FORMAT", "svg")
# Valid only if fake_tensor_propagate_real_tensors = True; if a fake-real
# kernel mismatch is detected, bypasses by making a fake kernel from the
# real tensor outputs.
generate_fake_kernels_from_real_mismatches = False
# When there are device mismatches in FakeTensor device propagation,
# prefer a specific device type over others. This is particularly useful
# in full compiled mode where intermediate tensors with device mismatches
# represent only logical differences during compilation - these intermediate
# tensors will never physically materialize in the binary execution, so the
# device mismatch is not a real runtime concern. Enabling this allows the
# compiler to proceed with compilation by choosing the preferred device type
# for consistency. For example, set to "mtia" to prefer MTIA devices over
# CPU, or "cuda" to prefer CUDA devices over CPU.
fake_tensor_prefer_device_type: str | None = None
# CUDAGraph safe run_with_rng functionalization.
# TODO: turn on by default
graphsafe_rng_functionalization = True
# Whether or not to eagerly compile the backward
# used by AOT compile and other settings
# TODO: once AOT compile calls aot autograd directly instead of
# through compile_fx, we can remove this
force_non_lazy_backward_lowering = False
# only for testing, used to turn functionalization off in AOTDispatcher
_test_disable_functionalization = True
# Error on BypassAOTAutogradCache instead of just a warning
# Used for tests
strict_autograd_cache = False
# Note [Recomputing collectives in the partitioner]
# The purpose of this config is as follows:
# - We have many passes in the compiler (min-cut partitioning, DCE, etc)
# which can reorder or delete duplicate nodes in the graph
# - If any of these passes reorder/delete/duplicate a collective
# in a setting where the compiler is being run independently on multiple
# ranks, we run the risk that the compiler will make a different decision on
# different ranks, resulting in a NCCL hang when using torch.compile
# To handle this, we will (by default) ensure that collectives are not modified
# by the compiler.
#
# A few examples:
# - don't dead-code-eliminate collectives
# (in case they are dead on rank i but not rank j)
# - don't recompute collectives in partitioning
# (in case we recompute on rank i but not rank j)
#
# Today this flag **must** be set to false, but eventually
# we want the option to set it to true.
# In order to potentially optimize collectives, we'll need the compiler
# to broadcast information across ranks at compile time to ensure
# that any decisions on collectives are made consistently.
unsafe_allow_optimization_of_collectives = False
# See Note [AOTAutograd Tangent Subclassness for mutated inputs]
# TODO(ivankobzarev): Remove this config, being able to deduce it compile time.
disable_guess_zero_tangent_for_mutated_input_subclass = False
# See Note [Tangents memory format]
# By default tangents strideness is guessed to be contiguous,
# At runtime non contiguous tangents will be coerced to be contiguous.
# This config changes this guess for tangents strides to be the same as outputs.
# TODO(ivankobzarev): Remove this config once extra memory usage is investigated.
guess_tangent_strides_as_outputs = not is_fbcode()
# This is a temporary config to ensure all ranks take the same decision in the partitioner
# it will ultimately be removed once we share size_hints across ranks through compiler collectives
_sync_decision_cross_ranks = False
# By default apply inlined saved_tensors_hooks only for "donated" buffers.
# "donated" buffers are invisible to the user, they are intermediates of the forward graph.
# Applying saved tensors hooks for memory optimizations only for intermediates
# guarantees that original saved tensors could be deallocated.
# This config enables saved_tensors_hooks are applied for **all** saved tensors,
# that could include inputs, parameters, outputs.
# "donated" - applied only to saved intermediates of the graph
# "no_static" - applied to all saved but not "static"
# (this includes parameters and user marked as static)
# "all" - no filtering, everything saved for backward.
saved_tensors_hooks_filtering_mode = "donated"
# This callback is invoked on the joint graph before partitioning
joint_custom_pass: Callable = None # type: ignore[assignment]
force_autograd_cache = False
# Note [Selective Decomposition]
# This config allows selective decomposition of certain operators in the graph.
# When True, it does NOT decompose any nodes, except those nodes that users explicitly
# annotated with regional inductor compile. Please read torch.fx.passes.regional_inductor
# on to explicitly annotate. This is currently only used by inductor lite mode.
selective_decompose: bool = False
if TYPE_CHECKING:
from torch.utils._config_typing import * # noqa: F401, F403
# adds patch, save_config, invalid config checks, etc
install_config_module(sys.modules[__name__])
@@ -0,0 +1,188 @@
"""
The APIs in this file are exposed as `functorch.*`. They are thin wrappers
around the torch.func.* APIs that have deprecation warnings -- we're trying
to move people to the torch.func.* equivalents.
NB: We don't use *args, **kwargs in the signatures because that changes the
documentation.
"""
from __future__ import annotations
import textwrap
import warnings
from typing import Any, TYPE_CHECKING
import torch._functorch.apis as apis
import torch._functorch.eager_transforms as _impl
import torch._functorch.make_functional as _nn_impl
import torch.nn as nn
if TYPE_CHECKING:
from collections.abc import Callable
from torch._functorch.eager_transforms import argnums_t
from torch._functorch.vmap import in_dims_t, out_dims_t
def get_warning(
api: str, new_api: str | None = None, replace_newlines: bool = False
) -> str:
if new_api is None:
new_api = f"torch.func.{api}"
warning = (
f"We've integrated functorch into PyTorch. As the final step of the \n"
f"integration, `functorch.{api}` is deprecated as of PyTorch \n"
f"2.0 and will be deleted in a future version of PyTorch >= 2.3. \n"
f"Please use `{new_api}` instead; see the PyTorch 2.0 release notes \n"
f"and/or the `torch.func` migration guide for more details \n"
f"https://pytorch.org/docs/main/func.migrating.html"
)
if replace_newlines:
warning = warning.replace("\n", "")
return warning
def warn_deprecated(api: str, new_api: str | None = None) -> None:
warning = get_warning(api, new_api, replace_newlines=True)
warnings.warn(warning, FutureWarning, stacklevel=3)
def setup_docs(
functorch_api: Callable[..., Any],
torch_func_api: Callable[..., Any] | None = None,
new_api_name: str | None = None,
) -> None:
api_name = functorch_api.__name__
if torch_func_api is None:
torch_func_api = getattr(_impl, api_name)
# See https://docs.python.org/3/using/cmdline.html#cmdoption-OO
if torch_func_api.__doc__ is None:
return
warning = get_warning(api_name, new_api_name)
warning_note = "\n.. warning::\n\n" + textwrap.indent(warning, " ")
warning_note = textwrap.indent(warning_note, " ")
functorch_api.__doc__ = torch_func_api.__doc__ + warning_note
def vmap(
func: Callable[..., Any],
in_dims: in_dims_t = 0,
out_dims: out_dims_t = 0,
randomness: str = "error",
*,
chunk_size: int | None = None,
) -> Callable[..., Any]:
warn_deprecated("vmap", "torch.vmap")
return apis.vmap(func, in_dims, out_dims, randomness, chunk_size=chunk_size)
def grad(
func: Callable[..., Any], argnums: argnums_t = 0, has_aux: bool = False
) -> Callable[..., Any]:
warn_deprecated("grad")
return apis.grad(func, argnums, has_aux)
def grad_and_value(
func: Callable[..., Any], argnums: argnums_t = 0, has_aux: bool = False
) -> Callable[..., Any]:
warn_deprecated("grad_and_value")
return apis.grad_and_value(func, argnums, has_aux)
def vjp(func: Callable[..., Any], *primals: Any, has_aux: bool = False) -> Any:
warn_deprecated("vjp")
return _impl.vjp(func, *primals, has_aux=has_aux)
def jvp(
func: Callable[..., Any],
primals: Any,
tangents: Any,
*,
strict: bool = False,
has_aux: bool = False,
) -> Any:
warn_deprecated("jvp")
return _impl.jvp(func, primals, tangents, strict=strict, has_aux=has_aux)
def jacrev(
func: Callable[..., Any],
argnums: int | tuple[int, ...] = 0,
*,
has_aux: bool = False,
chunk_size: int | None = None,
_preallocate_and_copy: bool = False,
) -> Callable[..., Any]:
warn_deprecated("jacrev")
return _impl.jacrev(
func,
argnums,
has_aux=has_aux,
chunk_size=chunk_size,
_preallocate_and_copy=_preallocate_and_copy,
)
def jacfwd(
func: Callable[..., Any],
argnums: argnums_t = 0,
has_aux: bool = False,
*,
randomness: str = "error",
) -> Callable[..., Any]:
warn_deprecated("jacfwd")
return _impl.jacfwd(func, argnums, has_aux, randomness=randomness)
def hessian(func: Callable[..., Any], argnums: int = 0) -> Callable[..., Any]:
warn_deprecated("hessian")
return _impl.hessian(func, argnums=argnums)
def functionalize(
func: Callable[..., Any], *, remove: str = "mutations"
) -> Callable[..., Any]:
warn_deprecated("functionalize")
return _impl.functionalize(func, remove=remove)
def make_functional(model: nn.Module, disable_autograd_tracking: bool = False) -> Any:
warn_deprecated("make_functional", "torch.func.functional_call")
return _nn_impl.make_functional(model, disable_autograd_tracking)
def make_functional_with_buffers(
model: nn.Module, disable_autograd_tracking: bool = False
) -> Any:
warn_deprecated("make_functional_with_buffers", "torch.func.functional_call")
return _nn_impl.make_functional_with_buffers(model, disable_autograd_tracking)
def combine_state_for_ensemble(models: list[nn.Module]) -> Any:
warn_deprecated("combine_state_for_ensemble", "torch.func.stack_module_state")
return _nn_impl.combine_state_for_ensemble(models)
setup_docs(vmap, apis.vmap, "torch.vmap")
setup_docs(grad, apis.grad)
setup_docs(grad_and_value, apis.grad_and_value)
setup_docs(vjp)
setup_docs(jvp)
setup_docs(jacrev)
setup_docs(jacfwd)
setup_docs(hessian)
setup_docs(functionalize)
setup_docs(make_functional, _nn_impl.make_functional, "torch.func.functional_call")
setup_docs(
make_functional_with_buffers, _nn_impl.make_functional, "torch.func.functional_call"
)
setup_docs(
combine_state_for_ensemble,
_nn_impl.combine_state_for_ensemble,
"torch.func.stack_module_state",
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import torch
import torch.nn as nn
from torch import Tensor
from torch._functorch.utils import exposed_in
@exposed_in("torch.func")
def functional_call(
module: torch.nn.Module,
parameter_and_buffer_dicts: dict[str, Tensor] | Sequence[dict[str, Tensor]],
args: Any = None,
kwargs: dict[str, Any] | None = None,
*,
tie_weights: bool = True,
strict: bool = False,
) -> Any:
r"""Performs a functional call on the module by replacing the module parameters
and buffers with the provided ones.
.. note:: If the module has active parametrizations, passing a value in the
:attr:`parameter_and_buffer_dicts` argument with the name set to the regular parameter
name will completely disable the parametrization.
If you want to apply the parametrization function to the value passed
please set the key as ``{submodule_name}.parametrizations.{parameter_name}.original``.
.. note:: If the module performs in-place operations on parameters/buffers, these will be reflected
in the ``parameter_and_buffer_dicts`` input.
Example::
>>> a = {'foo': torch.zeros(())}
>>> # xdoctest: +SKIP
>>> mod = Foo() # does self.foo = self.foo + 1
>>> print(mod.foo) # tensor(0.)
>>> functional_call(mod, a, torch.ones(()))
>>> print(mod.foo) # tensor(0.)
>>> print(a['foo']) # tensor(1.)
.. note:: If the module has tied weights, whether or not functional_call respects the tying is determined by the
tie_weights flag.
Example::
>>> a = {'foo': torch.zeros(())}
>>> # xdoctest: +SKIP
>>> mod = Foo() # has both self.foo and self.foo_tied which are tied. Returns x + self.foo + self.foo_tied
>>> print(mod.foo) # tensor(1.)
>>> mod(torch.zeros(())) # tensor(2.)
>>> functional_call(mod, a, torch.zeros(())) # tensor(0.) since it will change self.foo_tied too
>>> functional_call(mod, a, torch.zeros(()), tie_weights=False) # tensor(1.)--self.foo_tied is not updated
>>> new_a = {'foo': torch.zeros(()), 'foo_tied': torch.zeros(())}
>>> functional_call(mod, new_a, torch.zeros()) # tensor(0.)
An example of passing multiple dictionaries
.. code-block:: python
a = (
{"weight": torch.ones(1, 1)},
{"buffer": torch.zeros(1)},
) # two separate dictionaries
mod = nn.Bar(1, 1) # return self.weight @ x + self.buffer
print(mod.weight) # tensor(...)
print(mod.buffer) # tensor(...)
x = torch.randn((1, 1))
print(x)
functional_call(mod, a, x) # same as x
print(mod.weight) # same as before functional_call
And here is an example of applying the grad transform over the parameters
of a model.
.. code-block:: python
import torch
import torch.nn as nn
from torch.func import functional_call, grad
x = torch.randn(4, 3)
t = torch.randn(4, 3)
model = nn.Linear(3, 3)
def compute_loss(params, x, t):
y = functional_call(model, params, x)
return nn.functional.mse_loss(y, t)
grad_weights = grad(compute_loss)(dict(model.named_parameters()), x, t)
.. note:: If the user does not need grad tracking outside of grad transforms, they can detach all of the
parameters for better performance and memory usage
Example::
>>> detached_params = {k: v.detach() for k, v in model.named_parameters()}
>>> grad_weights = grad(compute_loss)(detached_params, x, t)
>>> grad_weights.grad_fn # None--it's not tracking gradients outside of grad
This means that the user cannot call ``grad_weight.backward()``. However, if they don't need autograd tracking
outside of the transforms, this will result in less memory usage and faster speeds.
Args:
module (torch.nn.Module): the module to call
parameters_and_buffer_dicts (Dict[str, Tensor] or tuple of Dict[str, Tensor]): the parameters that will be used in
the module call. If given a tuple of dictionaries, they must have distinct keys so that all dictionaries can
be used together
args (Any or tuple): arguments to be passed to the module call. If not a tuple, considered a single argument.
kwargs (dict): keyword arguments to be passed to the module call
tie_weights (bool, optional): If True, then parameters and buffers tied in the original model will be treated as
tied in the reparameterized version. Therefore, if True and different values are passed for the tied
parameters and buffers, it will error. If False, it will not respect the originally tied parameters and
buffers unless the values passed for both weights are the same. Default: True.
strict (bool, optional): If True, then the parameters and buffers passed in must match the parameters and
buffers in the original module. Therefore, if True and there are any missing or unexpected keys, it will
error. Default: False.
Returns:
Any: the result of calling ``module``.
"""
if isinstance(parameter_and_buffer_dicts, dict):
parameters_and_buffers = parameter_and_buffer_dicts
elif isinstance(parameter_and_buffer_dicts, Sequence):
if not all(isinstance(d, dict) for d in parameter_and_buffer_dicts):
raise ValueError(
"Expected all elements of parameter_and_buffer_dicts to be dictionaries"
)
all_keys = [k for d in parameter_and_buffer_dicts for k in d]
all_keys_counter: dict[str, int] = {}
for k in all_keys:
v = all_keys_counter.get(k, 0)
all_keys_counter[k] = v + 1
repeated_keys = [key for key, n in all_keys_counter.items() if n > 1]
if len(repeated_keys) > 0:
raise ValueError(
f"{repeated_keys} appeared in multiple dictionaries; behavior of functional call is ambiguous"
)
parameters_and_buffers = {
k: v for d in parameter_and_buffer_dicts for k, v in d.items()
}
else:
raise ValueError(
f"Expected parameter_and_buffer_dicts to be a dict, or a list/tuple of dicts, "
f"but got {type(parameter_and_buffer_dicts)}"
)
return nn.utils.stateless._functional_call(
module,
parameters_and_buffers,
args,
kwargs,
tie_weights=tie_weights,
strict=strict,
)
@exposed_in("torch.func")
def stack_module_state(
models: Sequence[nn.Module] | nn.ModuleList,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""stack_module_state(models) -> params, buffers
Prepares a list of torch.nn.Modules for ensembling with :func:`vmap`.
Given a list of ``M`` ``nn.Modules`` of the same class, returns two dictionaries
that stack all of their parameters and buffers together, indexed by name.
The stacked parameters are optimizable (i.e. they are new leaf nodes in the
autograd history that are unrelated to the original parameters and can be
passed directly to an optimizer).
Here's an example of how to ensemble over a very simple model:
.. code-block:: python
num_models = 5
batch_size = 64
in_features, out_features = 3, 3
models = [torch.nn.Linear(in_features, out_features) for i in range(num_models)]
data = torch.randn(batch_size, 3)
def wrapper(params, buffers, data):
return torch.func.functional_call(models[0], (params, buffers), data)
params, buffers = stack_module_state(models)
output = vmap(wrapper, (0, 0, None))(params, buffers, data)
assert output.shape == (num_models, batch_size, out_features)
When there's submodules, this follows state dict naming conventions
.. code-block:: python
import torch.nn as nn
class Foo(nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
hidden = 4
self.l1 = nn.Linear(in_features, hidden)
self.l2 = nn.Linear(hidden, out_features)
def forward(self, x):
return self.l2(self.l1(x))
num_models = 5
in_features, out_features = 3, 3
models = [Foo(in_features, out_features) for i in range(num_models)]
params, buffers = stack_module_state(models)
print(list(params.keys())) # "l1.weight", "l1.bias", "l2.weight", "l2.bias"
.. warning::
All of the modules being stacked together must be the same (except for
the values of their parameters/buffers). For example, they should be in the
same mode (training vs eval).
"""
if len(models) == 0:
raise RuntimeError("stack_module_state: Expected at least one model, got 0.")
if not (all(m.training for m in models) or all(not m.training for m in models)):
raise RuntimeError(
"stack_module_state: Expected all models to have the same training/eval mode."
)
model0_typ = type(models[0])
if not all(type(m) is model0_typ for m in models):
raise RuntimeError(
"stack_module_state: Expected all models to be of the same class."
)
all_params = [dict(model.named_parameters()) for model in models]
params = {
k: construct_stacked_leaf(tuple(params[k] for params in all_params), k)
for k in all_params[0]
}
all_buffers = [dict(model.named_buffers()) for model in models]
buffers = {
k: construct_stacked_leaf(tuple(buffers[k] for buffers in all_buffers), k)
for k in all_buffers[0]
}
return params, buffers
def construct_stacked_leaf(
tensors: tuple[Tensor, ...] | list[Tensor], name: str
) -> Tensor:
all_requires_grad = all(t.requires_grad for t in tensors)
none_requires_grad = all(not t.requires_grad for t in tensors)
if not all_requires_grad and not none_requires_grad:
raise RuntimeError(
f"Expected {name} from each model to have the same .requires_grad"
)
result = torch.stack(tensors)
if all_requires_grad:
result = result.detach().requires_grad_()
return result
@@ -0,0 +1,562 @@
from __future__ import annotations
import copy
import math
import os
import sys
from dataclasses import dataclass
from functools import partial, wraps
from typing import Any, TYPE_CHECKING
import torch
import torch.fx as fx
from torch.hub import tqdm
from torch.multiprocessing.reductions import StorageWeakRef
from torch.utils._content_store import ContentStoreWriter
from .compile_utils import get_outputs, get_placeholders
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
is_tuple = object()
@dataclass
class LoadTensorMeta:
size: tuple[int, ...]
stride: tuple[int, ...]
dtype: torch.dtype
device: torch.device
class ConcreteProp(torch.fx.Interpreter):
def __init__(
self,
mod: fx.GraphModule,
*,
writer: ContentStoreWriter | None = None,
skip_offload: bool = False,
) -> None:
super().__init__(mod)
self.writer = writer
self.skip_offload = skip_offload
self.seen_storages: set[StorageWeakRef] = set()
self.pbar: Any = None
def run_node(self, n: fx.Node) -> Any:
self.pbar.update(1)
r = super().run_node(n)
name = n.name
if isinstance(r, torch.Tensor):
if self.writer is None:
n.meta["concrete_value"] = r
else:
if StorageWeakRef(r.untyped_storage()) in self.seen_storages:
# Refuse to offload tensors which alias other live
# tensors, because this will violate operator contracts
n.meta["concrete_value"] = None
else:
if not self.skip_offload:
self.writer.write_tensor(os.path.join("eager", name), r)
n.meta["concrete_value"] = LoadTensorMeta(
r.size(), r.stride(), r.dtype, r.device
)
self.seen_storages.add(StorageWeakRef(r.untyped_storage()))
else:
n.meta["concrete_value"] = is_tuple
return r
def propagate(self, *args: Any) -> Any:
mod = self.module
if not isinstance(mod, fx.GraphModule):
raise AssertionError(f"expected fx.GraphModule, got {type(mod)}")
with tqdm(
desc="Saving intermediates for delta debugging",
total=len(mod.graph.nodes),
disable=self.writer is None,
) as pbar:
self.pbar = pbar
r = super().run(*args)
if not self.skip_offload:
pbar.set_description(
"Saved! To skip next time, run with --skip-saving-eager-intermediates"
)
return r
def is_load_tensor_node(node: fx.Node) -> bool:
return (
node.op == "call_function"
and node.target is torch.ops.debugprims.load_tensor.default
)
# inplace modifies node/inps
def _convert_node_to_placeholder(
graph: fx.Graph, node: fx.Node, inps: list[torch.Tensor]
) -> bool:
if node.op == "output" or node.op == "placeholder":
return False
if is_load_tensor_node(node):
return False
concrete_val = node.meta.get("concrete_value", None)
if isinstance(concrete_val, torch.Tensor):
node.op = "placeholder"
node.target = node.name
node.args = ()
node.kwargs = {}
inps.append(concrete_val)
return True
elif concrete_val is None:
return False
elif concrete_val is is_tuple:
r = False
for tuple_user in list(node.users):
r = _convert_node_to_placeholder(graph, tuple_user, inps) or r
# NB: We must not erase the node at this point, because
# we are iterating over the nodes and this would change
# the iteration order
# graph.erase_node(node)
return r
elif isinstance(concrete_val, LoadTensorMeta):
node.op = "call_function"
node.target = torch.ops.debugprims.load_tensor.default
node.args = (
os.path.join("eager", node.name),
concrete_val.size,
concrete_val.stride,
)
node.kwargs = {
"device": concrete_val.device,
"dtype": concrete_val.dtype,
}
return True
return False
def create_minified_hlo_graph(
minified_fx_graph: fx.GraphModule, inputs: Sequence[torch.Tensor]
) -> None:
"""
Takes minified FX graph as primary input, and ports it to HLO via StableHLO
Provides minified HLO graph as output, and archive them to local directory
"""
hlo_dir = f"{os.getcwd()}/hlo_files"
os.makedirs(hlo_dir, exist_ok=True)
from torch_xla.stablehlo import save_torch_model_as_stablehlo
save_torch_model_as_stablehlo(minified_fx_graph, inputs, hlo_dir)
def dump_state(fx_g: fx.GraphModule, inps: Sequence[torch.Tensor]) -> None:
print(
f"""
# Working Repro with {len(fx_g.graph.nodes)} nodes
inps = {[(i.shape, i.dtype, i.device.type) for i in inps]}
inps = [torch.zeros(())] + [torch.ones(shape, dtype=dtype, device=device) for (shape, dtype, device) in inps]
{fx_g.code}
"""
)
def is_power_of_two(n: int) -> bool:
if n == 0:
return False
return (n & (n - 1)) == 0
@dataclass
class ReproState:
graph: fx.Graph
inps: Sequence[torch.Tensor]
def __post_init__(self) -> None:
ph_nodes = get_placeholders(self.graph)
if len(ph_nodes) != len(self.inps):
raise AssertionError(
f"len(ph_nodes)={len(ph_nodes)} != len(self.inps)={len(self.inps)}"
)
def minifier(
fail_f: fx.GraphModule,
inps: Sequence[torch.Tensor],
module_fails: Callable[[fx.GraphModule, Sequence[torch.Tensor]], bool],
dump_state: Callable[[fx.GraphModule, Sequence[torch.Tensor]], None] = dump_state,
*,
save_dir: str | None = None,
offload_to_disk: bool = False,
skip_offload: bool = False,
skip_sanity: bool = False,
max_granularity: int | None = None,
) -> tuple[fx.GraphModule, Sequence[torch.Tensor]]:
"""
Minimizes a FX graph with given inputs, such that the resulting FX graph still returns True for module_fails.
Does 2 main strategies:
1. Truncates suffix: Removes some suffix from the graph and sets a new output.
2. Delta Debugging: Tries replacing half of the graph with inputs. If fails,
tries replacing quarter of the graph, etc.
>>> # xdoctest: +SKIP(failing)
>>> failing_function = fx.symbolic_trace(f)
>>> minimize(failing_function, [torch.randn(5)], lambda fx_g, inps: fx_g(*inps))
note: module_fails returns True if it fails.
"""
failing_graph = fail_f.graph
cur_size = len(failing_graph.nodes)
if max_granularity is not None and not is_power_of_two(max_granularity):
raise RuntimeError(f"max_granularity {max_granularity} not power of two")
num_queries = 0
def deepcopy_fx_graph(fx_graph: fx.Graph) -> fx.Graph:
return fx.GraphModule(fail_f, copy.deepcopy(fx_graph)).graph
def graph_fails(graph: fx.Graph, inps: Sequence[torch.Tensor]) -> bool:
nonlocal num_queries
graph = copy.deepcopy(graph)
num_queries += 1
mod = fx.GraphModule(fail_f, graph)
mod.graph.lint()
return module_fails(mod, inps)
writer = None
if offload_to_disk:
if save_dir is None:
raise AssertionError("save_dir must not be None when offload_to_disk=True")
writer = ContentStoreWriter(save_dir)
ConcreteProp(fail_f, writer=writer, skip_offload=skip_offload).propagate(*inps)
if not skip_sanity and not graph_fails(failing_graph, inps):
raise RuntimeError("Input graph did not fail the tester")
print(f"Started off with {cur_size} nodes", file=sys.stderr)
def _register_strategy(
strategy: Callable[[fx.Graph, Sequence[torch.Tensor], int], ReproState | None],
name: str,
) -> Callable[[ReproState, int], ReproState | None]:
@wraps(strategy)
def new_func(old_state: ReproState, granularity: int = 1) -> ReproState | None:
print(file=sys.stderr)
print(
f"Strategy: {name} (G: {granularity}) "
f"({len(old_state.graph.nodes)} nodes, {len(old_state.inps)} inputs)",
file=sys.stderr,
)
new_state = strategy(
deepcopy_fx_graph(old_state.graph), list(old_state.inps), granularity
)
if new_state is not None:
new_nodes = len(new_state.graph.nodes)
old_nodes = len(old_state.graph.nodes)
new_inps = len(new_state.inps)
old_inps = len(old_state.inps)
new_outs = len(get_outputs(new_state.graph))
old_outs = len(get_outputs(old_state.graph))
progress_made = False
if new_nodes < old_nodes:
progress_made = True
print(
f"SUCCESS: Went from {old_nodes} to {new_nodes} nodes",
file=sys.stderr,
)
if new_inps > old_inps:
progress_made = True
print(
f"SUCCESS: Went from {old_inps} to {new_inps} inputs",
file=sys.stderr,
)
if new_outs < old_outs:
progress_made = True
print(
f"SUCCESS: Went from {old_outs} to {new_outs} outputs",
file=sys.stderr,
)
if not progress_made:
raise RuntimeError("Success raised but no progress made?")
if not graph_fails(new_state.graph, new_state.inps):
print(
"WARNING: Something went wrong, not applying this minification",
file=sys.stderr,
)
return None
return new_state
else:
print(f"FAIL: {name}", file=sys.stderr)
return None
return new_func
def register_strategy(
name: str,
) -> Callable[
[Callable[[fx.Graph, Sequence[torch.Tensor], int], ReproState | None]],
Callable[[ReproState, int], ReproState | None],
]:
return partial(_register_strategy, name=name)
@register_strategy("Truncate suffix")
def remove_suffix(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
tested: set[int] = set()
new_graph = fx.Graph()
env: dict[fx.Node, fx.Node] = {}
for idx, node in enumerate(cur_graph.nodes):
new_node = new_graph.node_copy(node, lambda x: env[x])
if node.op not in ["placeholder", "output"]:
# If idx is divisible by (granularity * 2), it would have been checked already.
if (
idx % granularity == 0
and (idx % (granularity * 2) != 0)
and idx not in tested
):
output_node = new_graph.output((new_node,))
if len(new_graph.nodes) < len(cur_graph.nodes) and graph_fails(
new_graph, cur_inps
):
return ReproState(new_graph, cur_inps)
else:
tested.add(idx)
new_graph.erase_node(output_node)
env[node] = new_node
return None
@register_strategy("Remove outputs")
def remove_outputs(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
granularity = max(1, granularity // 2)
output: fx.Node | None = None
for idx, node in enumerate(cur_graph.nodes):
node.idx = idx # type: ignore[attr-defined]
if node.op == "output":
output = node
break
if output is None:
return None
if isinstance(output.args[0], fx.Node):
return None
# output.args[0] is a tuple/list of nodes when returning multiple outputs
output_args_raw = output.args[0]
if not isinstance(output_args_raw, (list, tuple)):
raise AssertionError(
f"expected output_args_raw to be list or tuple, got {type(output_args_raw)}"
)
output_args = sorted(
output_args_raw,
key=lambda x: x.idx if isinstance(x, fx.Node) else int(1e9), # type: ignore[attr-defined]
)
if len(output_args) == 1:
return None
for idx in range(0, len(output_args), granularity):
output.args = (output_args[:idx] + output_args[idx + granularity :],)
if graph_fails(cur_graph, cur_inps):
return ReproState(cur_graph, cur_inps)
return None
def remove_unused_inputs_unchecked(cur_state: ReproState) -> ReproState | None:
cur_graph = cur_state.graph
cur_inps = cur_state.inps
ph_nodes = list(get_placeholders(cur_graph))
if len(ph_nodes) != len(cur_inps):
raise AssertionError(
f"len(ph_nodes)={len(ph_nodes)} != len(cur_inps)={len(cur_inps)}"
)
new_inps: list[torch.Tensor] = []
for idx in range(len(ph_nodes)):
if len(ph_nodes[idx].users) == 0:
cur_graph.erase_node(ph_nodes[idx])
else:
new_inps.append(cur_inps[idx])
if len(new_inps) < len(cur_inps):
return ReproState(cur_graph, new_inps)
return None
def remove_unused_inputs_checked(cur_state: ReproState) -> ReproState | None:
new_state = remove_unused_inputs_unchecked(cur_state)
if new_state is not None and graph_fails(new_state.graph, new_state.inps):
return new_state
return None
def _remove_unused_wrapper(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
return remove_unused_inputs_checked(ReproState(cur_graph, cur_inps))
remove_unused_inputs = register_strategy("Remove unused inputs")(
_remove_unused_wrapper
)
@register_strategy("Eliminate dead code")
def eliminate_dead_code(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
if cur_graph.eliminate_dead_code() and graph_fails(cur_graph, cur_inps):
return ReproState(cur_graph, cur_inps)
return None
def _consolidate_placeholders(
cur_graph: fx.Graph, inps: list[torch.Tensor]
) -> fx.Graph:
new_graph = fx.Graph()
env = {}
seen_non_placeholder = False
# Move all placeholders to the front; also, if any load_tensor
# is at the front, convert it into an input (because it can be live
# all the time)
for node in cur_graph.nodes:
if node.op == "placeholder":
new_node = new_graph.node_copy(node, lambda x: env[x])
env[node] = new_node
elif not seen_non_placeholder and is_load_tensor_node(node):
new_node = new_graph.placeholder(node.name)
env[node] = new_node
inps.append(
torch.ops.debugprims.load_tensor.default(*node.args, **node.kwargs)
)
else:
seen_non_placeholder = True
# Move everyone else
for node in cur_graph.nodes:
if node not in env:
new_node = new_graph.node_copy(node, lambda x: env[x])
env[node] = new_node
return new_graph
@register_strategy("Delta Debugging")
def delta_debugging(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
num_nodes = len(cur_graph.nodes)
for start_range in range(0, num_nodes, granularity):
is_removing = False
new_graph = deepcopy_fx_graph(cur_graph)
new_inps = list(cur_inps[:])
end_range = min(num_nodes, start_range + granularity)
for idx in range(start_range, end_range):
new_node = list(new_graph.nodes)[idx]
if _convert_node_to_placeholder(new_graph, new_node, new_inps):
is_removing = True
if not is_removing:
continue
new_graph.eliminate_dead_code()
new_graph = _consolidate_placeholders(new_graph, new_inps)
new_state = remove_unused_inputs_unchecked(ReproState(new_graph, new_inps))
if new_state is None:
new_state = ReproState(new_graph, new_inps)
if graph_fails(new_state.graph, new_state.inps):
return ReproState(new_state.graph, new_state.inps)
return None
@register_strategy("Consolidate Inputs")
def consolidate_inputs(
cur_graph: fx.Graph, cur_inps: Sequence[torch.Tensor], granularity: int
) -> ReproState | None:
old_len = len(cur_inps)
new_inps = list(cur_inps[:])
cur_graph = _consolidate_placeholders(cur_graph, new_inps)
if len(cur_inps) > old_len and graph_fails(cur_graph, new_inps):
return ReproState(cur_graph, new_inps)
return None
failing_state = ReproState(failing_graph, inps)
def try_granularity(
failing_state: ReproState, granularity: int, use_non_granular: bool
) -> ReproState | None:
print(f"Trying granularity {granularity}", file=sys.stderr)
strategies = []
num_nodes = len(failing_state.graph.nodes)
num_outputs = len(get_outputs(failing_state.graph))
if num_outputs > num_nodes // 2:
strategies += [remove_outputs]
if use_non_granular:
strategies += [
eliminate_dead_code,
remove_unused_inputs,
consolidate_inputs,
]
strategies += [remove_suffix, delta_debugging]
for strategy in strategies:
new_state = strategy(failing_state, granularity)
if new_state is not None:
return new_state
return None
while True:
dump_state(fx.GraphModule(fail_f, failing_state.graph), failing_state.inps)
granularity = int(2 ** (math.floor(math.log2(len(failing_state.graph.nodes)))))
if max_granularity is not None:
granularity = min(max_granularity, granularity)
new_state = try_granularity(failing_state, granularity, use_non_granular=True)
if new_state is not None:
failing_state = new_state
continue
granularity //= 2
has_progress = False
while granularity >= 1:
new_state = try_granularity(
failing_state, granularity, use_non_granular=False
)
if new_state is not None:
failing_state = new_state
has_progress = True
break
granularity //= 2
if has_progress:
continue
new_state = remove_outputs(failing_state, 1)
if new_state is not None:
failing_state = new_state
continue
break
if not graph_fails(failing_state.graph, failing_state.inps):
raise RuntimeError("Uh oh, something went wrong :( Final graph is not failing")
print(f"Made {num_queries} queries", file=sys.stderr)
failing_fx = fx.GraphModule(fail_f, failing_state.graph)
# If XLA debugging environment is enabled, create minified HLO graph as well
if "XLA_HLO_DEBUG" in os.environ:
create_minified_hlo_graph(failing_fx, failing_state.inps)
dump_state(failing_fx, failing_state.inps)
print("Wrote minimal repro out to repro.py", file=sys.stderr)
return failing_fx, failing_state.inps
@@ -0,0 +1,669 @@
# Copyright (c) Facebook, Inc. and its 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.
from __future__ import annotations
import copy
from typing import Any, NoReturn, TYPE_CHECKING
import torch
import torch.nn as nn
from torch import Tensor
from torch.nn.utils._named_member_accessor import NamedMemberAccessor
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
# Utilities to make nn.Module "functional"
# In particular the goal is to be able to provide a function that takes as input
# the parameters and evaluate the nn.Module using fixed inputs.
def raise_parameter_tying_error() -> NoReturn:
raise RuntimeError(
"make_functional(module): we don't yet support models that "
"do parameter tying (also sometimes known as weight sharing). "
"Please try to rewrite your model by replacing all instances of the "
"tied parameter with another and/or comment your support in "
"https://github.com/pytorch/functorch/issues/446"
)
def create_names_map(
named_params: dict[str, Tensor] | Iterable[tuple[str, Tensor]],
tied_named_params: dict[str, Tensor] | Iterable[tuple[str, Tensor]],
) -> dict[str, list[str]]:
"""
named_params is a dictionary of tensors: {'A': A, 'B': B}
tied_named_params is another dictionary of tensors {'A': A, 'B': B, 'B_tied': B}
with potentially tied (or 'duplicated') tensors
This function creates a mapping from the names in named_params to the
names in tied_named_params: {'A': ['A'], 'B': ['B', 'B_tied']}.
"""
named_params_dict = dict(named_params)
tied_named_params_dict = dict(tied_named_params)
tensors_dict_keys = set(named_params_dict.keys())
tied_tensors_dict_keys = set(tied_named_params_dict.keys())
if not tensors_dict_keys.issubset(tied_tensors_dict_keys):
raise AssertionError(
f"tensors_dict_keys {tensors_dict_keys} is not a subset of "
f"tied_tensors_dict_keys {tied_tensors_dict_keys}"
)
tensor_to_mapping: dict[Tensor, tuple[str, list[str]]] = {}
for key, tensor in named_params_dict.items():
tensor_to_mapping[tensor] = (key, [])
for key, tensor in tied_named_params_dict.items():
if tensor not in tensor_to_mapping:
raise AssertionError(
f"tensor for key '{key}' not found in tensor_to_mapping"
)
tensor_to_mapping[tensor][1].append(key)
return dict(tensor_to_mapping.values())
def _extract_members(
mod: nn.Module,
named_members: Callable[..., Iterable[tuple[str, Tensor]]],
subclass: Callable[[Tensor], Tensor],
) -> tuple[tuple[Tensor, ...], tuple[str, ...], dict[str, list[str]]]:
all_named_members = tuple(named_members(remove_duplicate=False))
unique_named_members = tuple(named_members(remove_duplicate=True))
names_map = create_names_map(unique_named_members, all_named_members)
# Remove all the members in the model
# pyrefly: ignore [implicit-any]
memo = {}
accessor = NamedMemberAccessor(mod)
for name, p in all_named_members:
if p not in memo:
memo[p] = subclass(torch.empty_like(p, device="meta"))
replacement = memo[p]
accessor.set_tensor(name, replacement)
if len(unique_named_members) == 0:
names, params = (), ()
else:
names, params = zip(*unique_named_members) # type: ignore[assignment]
return params, names, names_map
def extract_weights(
mod: nn.Module,
) -> tuple[tuple[Tensor, ...], tuple[str, ...], dict[str, list[str]]]:
"""
This function removes all the Parameters from the model and
return them as a tuple as well as their original attribute names.
The weights must be re-loaded with `load_weights` before the model
can be used again.
Note that this function modifies the model in place and after this
call, mod.parameters() will be empty.
"""
return _extract_members(mod, mod.named_parameters, nn.Parameter)
def extract_buffers(
mod: nn.Module,
) -> tuple[tuple[Tensor, ...], tuple[str, ...], dict[str, list[str]]]:
return _extract_members(mod, mod.named_buffers, lambda x: x)
def load_weights(
mod: nn.Module,
names: Sequence[str],
params: Sequence[Tensor],
as_params: bool = False,
) -> None:
"""
Reload a set of weights so that `mod` can be used again to perform a forward pass.
Note that the `params` are regular Tensors (that can have history) and so are left
as Tensors. This means that mod.parameters() will still be empty after this call.
"""
accessor = NamedMemberAccessor(mod)
if as_params:
params = [nn.Parameter(p) for p in params]
accessor.set_tensors(names, params)
def _swap_state(
mod: nn.Module, names_map: dict[str, list[str]], elems: Iterable[Tensor]
) -> list[Tensor]:
result: list[Tensor] = []
accessor = NamedMemberAccessor(mod)
for (_, attr_names), elem in zip(names_map.items(), elems):
for i, attr_name in enumerate(attr_names):
if i == 0:
result.append(accessor.swap_tensor(attr_name, elem))
else:
accessor.set_tensor(attr_name, elem)
return result
def load_buffers(
mod: nn.Module,
names: Sequence[str],
buffers: Sequence[Tensor],
as_params: bool = False,
) -> None:
accessor = NamedMemberAccessor(mod)
accessor.set_tensors(names, buffers)
def load_state(
model: nn.Module,
weights: Sequence[Tensor],
weight_names: Sequence[str],
buffers: Sequence[Tensor] = (),
buffer_names: Sequence[str] = (),
) -> nn.Module:
"""load_state(model, weights, weight_names, buffers=(), buffer_names=()) -> model
load_state takes `weights` and `buffers` and assigns them to the model.
This is the inverse operation of `make_functional_deprecated_v1`.
"""
if len(weight_names) != len(weights):
raise AssertionError(
f"len(weight_names)={len(weight_names)} != len(weights)={len(weights)}"
)
load_weights(model, weight_names, weights)
if len(buffers) > 0:
if len(buffer_names) != len(buffers):
raise AssertionError(
f"len(buffer_names)={len(buffer_names)} != len(buffers)={len(buffers)}"
)
load_buffers(model, buffer_names, buffers)
return model
def make_functional_deprecated_v1(
model: nn.Module,
) -> tuple[tuple[Tensor, ...], Callable[..., Any], tuple[str, ...]]:
"""make_functional_deprecated_v1(model) -> weights, func, weight_names
Given an nn.Module, make_functional_deprecated_v1 extracts the state (weights)
and returns a functional version of the model, `func`. This makes
it so that it is possible use transforms over the parameters of
`model`.
`func` can be invoked as follows:
```
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
weights, func, _ = make_functional_deprecated_v1(model)
func(weights, (x,))
```
And here is an example of applying the grad transform:
```
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
weights, _, func = make_functional_deprecated_v1(model)
grad_weights = grad(func)(weights, (x,))
```
To put the state back into a model, use `load_state`.
"""
buffers = list(model.buffers())
if len(buffers) > 0:
raise RuntimeError(
"make_functional_deprecated_v1(model): `model` has buffers. Please use "
"make_functional_with_buffers_deprecated_v1(model) instead."
)
weights, descriptors, _ = extract_weights(model)
def fun(weights: tuple[Tensor, ...], data: tuple[Any, ...]) -> Any:
mutable_model = copy.deepcopy(model)
load_weights(mutable_model, descriptors, weights)
return mutable_model(*data)
return weights, fun, descriptors
def make_functional_with_buffers_deprecated_v1(
model: nn.Module,
) -> tuple[
tuple[Tensor, ...],
tuple[Tensor, ...],
Callable[..., Any],
tuple[str, ...],
tuple[str, ...],
]:
"""make_functional_with_buffers_deprecated_v1(model) -> weights, buffers, func, weight_names, buffer_names
Given an nn.Module, make_functional_with_buffers_deprecated_v1 extracts the state (weights and buffers)
and returns a functional version of the model, `func`.
`func` can be invoked as follows:
```
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
weights, buffers, func, _, _ = make_functional_with_buffers_deprecated_v1(model)
func(weights, buffers, (x,))
```
And here is an example of applying the grad transform:
```
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
weights, buffers, func, _, _ = make_functional_with_buffers_deprecated_v1(model)
func(weights, buffers, (x,))
grad_weights = grad(func)(weights, buffers, (x,))
```
To put the state back into a model, use `load_state`.
"""
weights, weight_descriptors, _ = extract_weights(model)
buffers, buf_descriptors, _ = extract_buffers(model)
def fun(
weights: tuple[Tensor, ...],
buffers: tuple[Tensor, ...],
data: tuple[Any, ...],
) -> Any:
mutable_model = copy.deepcopy(model)
load_weights(mutable_model, weight_descriptors, weights)
load_buffers(mutable_model, buf_descriptors, buffers)
return mutable_model(*data)
return weights, buffers, fun, weight_descriptors, buf_descriptors
class FunctionalModuleWithBuffers(nn.Module):
"""
This is the callable object returned by :func:`make_functional_with_buffers`.
"""
def __init__(
self,
stateless_model: nn.Module,
param_names: tuple[str, ...],
buffer_names: tuple[str, ...],
param_names_map: dict[str, list[str]],
buffer_names_map: dict[str, list[str]],
) -> None:
super().__init__()
self.stateless_model = stateless_model
self.param_names = param_names
self.buffer_names = buffer_names
self.all_names_map = dict(param_names_map)
self.all_names_map.update(buffer_names_map)
@staticmethod
def _create_from(
model: nn.Module, disable_autograd_tracking: bool = False
) -> tuple[FunctionalModuleWithBuffers, tuple[Tensor, ...], tuple[Tensor, ...]]:
# TODO: We don't need to copy the model to create a stateless copy
model_copy = copy.deepcopy(model)
params, param_names, param_names_map = extract_weights(model_copy)
buffers, buffer_names, buffer_names_map = extract_buffers(model_copy)
if disable_autograd_tracking:
for param in params:
param.requires_grad_(False)
return (
FunctionalModuleWithBuffers(
model_copy, param_names, buffer_names, param_names_map, buffer_names_map
),
params,
buffers,
)
def forward(
self,
params: Iterable[Tensor],
buffers: Iterable[Tensor],
*args: Any,
**kwargs: Any,
) -> Any:
# Temporarily load the state back onto self.stateless_model
old_state = _swap_state(
self.stateless_model,
self.all_names_map,
tuple(params) + tuple(buffers),
)
try:
return self.stateless_model(*args, **kwargs)
finally:
# Remove the loaded state on self.stateless_model
_swap_state(self.stateless_model, self.all_names_map, old_state)
class FunctionalModule(nn.Module):
"""
This is the callable object returned by :func:`make_functional`.
"""
def __init__(
self,
stateless_model: nn.Module,
param_names: tuple[str, ...],
names_map: dict[str, list[str]],
) -> None:
super().__init__()
self.stateless_model = stateless_model
self.param_names = param_names
self.names_map = names_map
@staticmethod
def _create_from(
model: nn.Module, disable_autograd_tracking: bool = False
) -> tuple[FunctionalModule, tuple[Tensor, ...]]:
# TODO: We don't need to copy the model to create a stateless copy
model_copy = copy.deepcopy(model)
params, param_names, names_map = extract_weights(model_copy)
if disable_autograd_tracking:
for param in params:
param.requires_grad_(False)
return FunctionalModule(model_copy, param_names, names_map), params
def forward(self, params: Iterable[Tensor], *args: Any, **kwargs: Any) -> Any:
# Temporarily load the state back onto self.stateless_model
old_state = _swap_state(self.stateless_model, self.names_map, params)
try:
return self.stateless_model(*args, **kwargs)
finally:
# Remove the loaded state on self.stateless_model
_swap_state(self.stateless_model, self.names_map, old_state)
def make_functional(
model: nn.Module, disable_autograd_tracking: bool = False
) -> tuple[FunctionalModule, tuple[Tensor, ...]]:
"""make_functional(model, disable_autograd_tracking=False) -> func, params
Given a ``torch.nn.Module``, :func:`make_functional` extracts the state
(params) and returns a functional version of the model, ``func``. This
makes it so that it is possible use transforms over the parameters of
``model``.
``func`` can be invoked as follows:
.. code-block:: python
import torch
import torch.nn as nn
from functorch import make_functional
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
func, params = make_functional(model)
func(params, x)
And here is an example of applying the grad transform over the parameters
of a model.
.. code-block:: python
import torch
import torch.nn as nn
from functorch import make_functional, grad
x = torch.randn(4, 3)
t = torch.randn(4, 3)
model = nn.Linear(3, 3)
func, params = make_functional(model)
def compute_loss(params, x, t):
y = func(params, x)
return nn.functional.mse_loss(y, t)
grad_weights = grad(compute_loss)(params, x, t)
If the model has any buffers, please use :func:`make_functional_with_buffers` instead.
Args:
model (torch.nn.Module): Input model.
disable_autograd_tracking (bool): Flag to disable gradients tracking for output parameters.
The returned params are unrelated to the set of params from the original model. If False (default),
the params will have ``requires_grad=True`` on them (aka they will be trackable with regular
PyTorch autograd), matching the requires_grad-ness of the params from the original model.
Otherwise, the returned params will have ``requires_grad=False``. Default, False.
If you plan on using regular PyTorch autograd (e.g., if you want to call ``.backward()`` or
``torch.autograd.grad()``, then set ``disable_autograd_tracking=False``.
Otherwise, if you're only planning on using functorch's gradient transforms,
then please set ``disable_autograd_tracking=True`` to avoid unnecessarily tracking
history with PyTorch autograd.
"""
buffers = list(model.buffers())
if len(buffers) > 0:
raise RuntimeError(
"make_functional(model): `model` has buffers. Please use "
"make_functional_with_buffers(model) instead."
)
return FunctionalModule._create_from(
model, disable_autograd_tracking=disable_autograd_tracking
)
def make_functional_with_buffers(
model: nn.Module, disable_autograd_tracking: bool = False
) -> tuple[FunctionalModuleWithBuffers, tuple[Tensor, ...], tuple[Tensor, ...]]:
"""make_functional_with_buffers(model, disable_autograd_tracking=False) -> func, params, buffers
Given a ``torch.nn.Module``, make_functional_with_buffers extracts the
state (params and buffers) and returns a functional version of the model
``func`` that can be invoked like a function.
``func`` can be invoked as follows:
.. code-block:: python
import torch
import torch.nn as nn
from functorch import make_functional_with_buffers
x = torch.randn(4, 3)
model = nn.Linear(3, 3)
func, params, buffers = make_functional_with_buffers(model)
func(params, buffers, x)
And here is an example of applying the grad transform over the parameters
of a model:
.. code-block:: python
import torch
import torch.nn as nn
from functorch import make_functional_with_buffers, grad
x = torch.randn(4, 3)
t = torch.randn(4, 3)
model = nn.Linear(3, 3)
func, params, buffers = make_functional_with_buffers(model)
def compute_loss(params, buffers, x, t):
y = func(params, buffers, x)
return nn.functional.mse_loss(y, t)
grad_weights = grad(compute_loss)(params, buffers, x, t)
Args:
model (torch.nn.Module): Input model.
disable_autograd_tracking (bool): Flag to disable gradients tracking for output parameters.
The returned params are unrelated to the set of params from the original model. If False (default),
the params will have ``requires_grad=True`` on them (aka they will be trackable with regular
PyTorch autograd), matching the requires_grad-ness of the params from the original model.
Otherwise, the returned params will have ``requires_grad=False``. Default, False.
If you plan on using regular PyTorch autograd (e.g., if you want to call ``.backward()`` or
``torch.autograd.grad()``, then set ``disable_autograd_tracking=False``.
Otherwise, if you're only planning on using functorch's gradient transforms,
then please set ``disable_autograd_tracking=True`` to avoid unnecessarily tracking
history with PyTorch autograd.
"""
return FunctionalModuleWithBuffers._create_from(
model, disable_autograd_tracking=disable_autograd_tracking
)
def transpose_stack(
tuple_of_tuple_of_tensors: tuple[tuple[Tensor, ...], ...],
) -> tuple[Tensor, ...]:
tuple_of_tuple_of_tensors = tuple(zip(*tuple_of_tuple_of_tensors))
results = tuple(
torch.stack(shards).detach() for shards in tuple_of_tuple_of_tensors
)
return results
def combine_state_for_ensemble(
models: Sequence[nn.Module],
) -> tuple[FunctionalModuleWithBuffers, tuple[Tensor, ...], tuple[Tensor, ...]]:
"""combine_state_for_ensemble(models) -> func, params, buffers
Prepares a list of torch.nn.Modules for ensembling with :func:`vmap`.
Given a list of ``M`` ``nn.Modules`` of the same class, stacks all of their
parameters and buffers together to make ``params`` and ``buffers``.
Each parameter and buffer in the result will have an additional dimension
of size ``M``.
:func:`combine_state_for_ensemble` also returns ``func``, a functional
version of one of the models in :attr:`models`. One cannot directly run
``func(params, buffers, *args, **kwargs)`` directly, you probably want to
use ``vmap(func, ...)(params, buffers, *args, **kwargs)``
Here's an example of how to ensemble over a very simple model:
.. code-block:: python
num_models = 5
batch_size = 64
in_features, out_features = 3, 3
models = [torch.nn.Linear(in_features, out_features) for i in range(num_models)]
data = torch.randn(batch_size, 3)
fmodel, params, buffers = combine_state_for_ensemble(models)
output = vmap(fmodel, (0, 0, None))(params, buffers, data)
assert output.shape == (num_models, batch_size, out_features)
.. warning::
All of the modules being stacked together must be the same (except for
the values of their parameters/buffers). For example, they should be in the
same mode (training vs eval).
This API is subject to change -- we're investigating better ways to
create ensembles and would love your feedback how to improve this.
"""
if len(models) == 0:
raise RuntimeError(
"combine_state_for_ensemble: Expected at least one model, got 0."
)
if not (all(m.training for m in models) or all(not m.training for m in models)):
raise RuntimeError(
"combine_state_for_ensemble: Expected all models to "
"have the same training/eval mode."
)
model0_typ = type(models[0])
if not all(type(m) is model0_typ for m in models):
raise RuntimeError(
"combine_state_for_ensemble: Expected all models to be of the same class."
)
funcs, params, buffers = zip(
*[make_functional_with_buffers(model) for model in models]
)
params = transpose_stack(params)
buffers = transpose_stack(buffers)
return funcs[0], params, buffers
def functional_init(
model_class: type[nn.Module],
ensemble_shape: tuple[()] | tuple[int, ...] = (),
device: torch.types.Device = "cpu",
) -> Callable[..., tuple[tuple[Tensor, ...], Callable[..., Any], tuple[str, ...]]]:
def wrapped(
*args: Any, **kwargs: Any
) -> tuple[tuple[Tensor, ...], Callable[..., Any], tuple[str, ...]]:
if len(ensemble_shape) >= 2:
raise ValueError("NYI: ensemble_shape with more than 1 element")
if len(ensemble_shape) == 0:
model = model_class(*args, **kwargs).to(device)
return make_functional_deprecated_v1(model)
num_models = ensemble_shape[0] # type: ignore[misc]
if num_models <= 0:
raise ValueError(f"num_models {num_models} should be > 0")
# NB: Not very efficient, more of a POC
models = tuple(
model_class(*args, **kwargs).to(device) for _ in range(num_models)
)
_, fn, names = make_functional_deprecated_v1(model_class(*args, **kwargs))
weights = tuple(make_functional_deprecated_v1(model)[0] for model in models)
weights = tuple(zip(*weights))
weights = tuple(torch.stack(shards).detach() for shards in weights)
return weights, fn, names
return wrapped
def functional_init_with_buffers(
model_class: type[nn.Module],
ensemble_shape: tuple[()] | tuple[int, ...] = (),
device: torch.types.Device = "cpu",
) -> Callable[
...,
tuple[
tuple[Tensor, ...],
tuple[Tensor, ...],
Callable[..., Any],
tuple[str, ...],
tuple[str, ...],
]
| tuple[tuple[Tensor, ...], Callable[..., Any], tuple[str, ...]],
]:
def wrapped(
*args: Any, **kwargs: Any
) -> (
tuple[
tuple[Tensor, ...],
tuple[Tensor, ...],
Callable[..., Any],
tuple[str, ...],
tuple[str, ...],
]
| tuple[tuple[Tensor, ...], Callable[..., Any], tuple[str, ...]]
):
if len(ensemble_shape) >= 2:
raise ValueError("NYI: ensemble_shape with more than 1 element")
if len(ensemble_shape) == 0:
model = model_class(*args, **kwargs).to(device)
return make_functional_deprecated_v1(model)
num_models = ensemble_shape[0] # type: ignore[misc]
if num_models <= 0:
raise ValueError(f"num_models {num_models} should be > 0")
# NB: Not very efficient, more of a POC
models = tuple(
model_class(*args, **kwargs).to(device) for _ in range(num_models)
)
(
_,
_,
fn,
weight_names,
buffer_names,
) = make_functional_with_buffers_deprecated_v1(model_class(*args, **kwargs))
weights, buffers = zip(
*tuple(
make_functional_with_buffers_deprecated_v1(model)[:2]
for model in models
)
)
weights = tuple(zip(*weights))
weights = tuple(torch.stack(shards).detach() for shards in weights)
buffers = tuple(zip(*buffers))
buffers = tuple(torch.stack(shards).detach() for shards in buffers)
return weights, buffers, fn, weight_names, buffer_names
return wrapped
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
# Copyright (c) Facebook, Inc. and its 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.
"""
This module contains pre-dispatch wrappers for functorch operations
that enable proper tracing in PT2 non-strict export/compile fx graph.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from torch._C._functorch import (
_add_batch_dim as _add_batch_dim_impl,
_remove_batch_dim as _remove_batch_dim_impl,
_vmap_decrement_nesting as _vmap_decrement_nesting_impl,
_vmap_increment_nesting as _vmap_increment_nesting_impl,
)
if TYPE_CHECKING:
import threading
def _add_batch_dim(self: torch.Tensor, batch_dim: int, level: int) -> torch.Tensor:
"""
Thin wrapper around torch._C._add_batch_dim that is used to proxy in
PT2 export/compile fx graph
"""
from torch._export.utils import _maybe_find_pre_dispatch_tf_mode_for_export
mode = _maybe_find_pre_dispatch_tf_mode_for_export()
batch_dim = self.ndim + batch_dim if batch_dim < 0 else batch_dim
if mode:
return torch.overrides.handle_torch_function(
_add_batch_dim, (self,), self, batch_dim, level
)
res = _add_batch_dim_impl(self, batch_dim, level)
return res
def _remove_batch_dim(
self: torch.Tensor, level: int, batch_size: int, out_dim: int
) -> torch.Tensor:
"""
Thin wrapper around torch._C._remove_batch_dim that is used to proxy in
PT2 export/compile fx graph
"""
from torch._export.utils import _maybe_find_pre_dispatch_tf_mode_for_export
mode = _maybe_find_pre_dispatch_tf_mode_for_export()
if mode:
return torch.overrides.handle_torch_function(
_remove_batch_dim, (self,), self, level, batch_size, out_dim
)
res = _remove_batch_dim_impl(self, level, batch_size, out_dim)
return res
def _vmap_increment_nesting(batch_size: int, randomness: str) -> int:
"""
Thin wrapper around torch._C._vmap_increment_nesting that is used
to proxy in export/compile graph
"""
from torch._export.utils import _maybe_find_pre_dispatch_tf_mode_for_export
mode = _maybe_find_pre_dispatch_tf_mode_for_export()
if mode:
return torch.overrides.handle_torch_function(
_vmap_increment_nesting, (batch_size,), batch_size, randomness
)
res = _vmap_increment_nesting_impl(batch_size, randomness)
return res
def _vmap_decrement_nesting() -> int:
"""
Thin wrapper around torch._C._vmap_increment_nesting that is used
to proxy in export/compile graph
"""
from torch._export.utils import _maybe_find_pre_dispatch_tf_mode_for_export
mode = _maybe_find_pre_dispatch_tf_mode_for_export()
if mode:
return torch.overrides.handle_torch_function(
_vmap_decrement_nesting,
(),
)
return _vmap_decrement_nesting_impl()
# Global variables for lazy_load_decompositions
DECOMPOSITIONS_LOADED: bool = False
DECOMPOSITIONS_LOCK: threading.Lock | None = None
VMAP_DECOMPOSITIONS_LIB: torch.library.Library | None = None
def lazy_load_decompositions() -> None:
"""
Lazy loading of vmap decompositions with pre-dispatch support.
"""
from torch._export.utils import _maybe_find_pre_dispatch_tf_mode_for_export
mode = _maybe_find_pre_dispatch_tf_mode_for_export()
if mode:
return torch.overrides.handle_torch_function(lazy_load_decompositions, ())
global DECOMPOSITIONS_LOADED, DECOMPOSITIONS_LOCK, VMAP_DECOMPOSITIONS_LIB
if DECOMPOSITIONS_LOADED:
return
# Initialize lock if needed
if DECOMPOSITIONS_LOCK is None:
import threading
DECOMPOSITIONS_LOCK = threading.Lock()
with DECOMPOSITIONS_LOCK:
if DECOMPOSITIONS_LOADED:
return
import os
if not (os.environ.get("PYTORCH_JIT", "1") == "1" and __debug__):
DECOMPOSITIONS_LOADED = True
return
# use an alternate way to register an operator into the decomposition table
# _register_jit_decomposition doesn't work for some operators, e.g. addr,
# because the Tensor types generated cannot be unioned by torchscript
# decomp should be type OpOverload
VMAP_DECOMPOSITIONS_LIB = torch.library.Library(
"aten", "IMPL", "FuncTorchBatched"
)
from torch._decomp import decomposition_table
def _register_python_decomposition_vmap(decomp: torch._ops.OpOverload) -> None:
if VMAP_DECOMPOSITIONS_LIB is None:
raise AssertionError("VMAP_DECOMPOSITIONS_LIB must not be None")
if decomp in decomposition_table:
VMAP_DECOMPOSITIONS_LIB.impl(decomp, decomposition_table[decomp])
else:
raise RuntimeError(f"could not find decomposition for {decomp}")
_register_python_decomposition_vmap(torch.ops.aten.mse_loss_backward.default)
_register_python_decomposition_vmap(
torch.ops.aten.smooth_l1_loss_backward.default
)
_register_python_decomposition_vmap(torch.ops.aten.huber_loss_backward.default)
_register_python_decomposition_vmap(torch.ops.aten.nll_loss_forward.default)
_register_python_decomposition_vmap(torch.ops.aten.nll_loss2d_forward.default)
_register_python_decomposition_vmap(torch.ops.aten.nll_loss_backward.default)
_register_python_decomposition_vmap(torch.ops.aten.nll_loss2d_backward.default)
_register_python_decomposition_vmap(torch.ops.aten.addr.default)
DECOMPOSITIONS_LOADED = True
@@ -0,0 +1,338 @@
from __future__ import annotations
import contextlib
from abc import ABC, abstractmethod
from functools import cached_property
from typing import Any, TYPE_CHECKING
import torch
import torch.utils._pytree as pytree
from torch._C._functorch import (
CFunctionalizeInterpreterPtr,
CGradInterpreterPtr,
CInterpreter,
CJvpInterpreterPtr,
CVmapInterpreterPtr,
pop_dynamic_layer_stack,
push_dynamic_layer_stack,
RandomnessType,
TransformType,
)
from torch.autograd.forward_ad import _set_fwd_grad_enabled
if TYPE_CHECKING:
from collections.abc import Generator
"""
This file contains the functorch integration with PyDispatcher.
PyDispatcher does not understand functorch's DynamicLayerStack dispatching
logic because it is entirely implemented in C++ in the fallbacks for two
dispatch keys, FuncTorchDynamicLayer{Front, Back}Mode (PyDispatcher is unable
to directly reuse C++ boxed fallbacks).
Instead of trying to hammer PyDispatcher into understanding those fallbacks,
we re-implement the logic of peeking the top of the stack for an interpreter,
selecting the interpreter to dispatch on, etc, in Python. This leads to a
simpler design.
The main difference between C++ functorch and PyDispatcher's functorch logic
is that:
- C++ functorch needs to manually tweak dispatch keys to ping-pong between
DynamicLayerFrontMode and DynamicLayerBackMode.
- PyDispatcher's functorch logic pops an Interpreter from the top of the stack
and asks it to execute the rule associated with the Interpreter.
In C++ we do the ping-pong because e.g. vmap rules are associated with the
batched DispatchKey, but in PyDispatcher we are able to avoid this by asking
the user to register a batching rule directly to a transform that an
interpreter then invokes.
"""
# FuncTorchInterpreter is the Python version of Interpreter (recall that
# the DynamicLayerStack is a stack of interpreters).
# It is a wrapper around the actual C++ Interpreter object.
#
# Keep the methods in sync with aten/src/ATen/functorch/Interpreter.h
class FuncTorchInterpreter(ABC):
def __init__(self, cptr: Any) -> None:
self._cptr = cptr
# Process an operation. eg for vmap, this is invoking a batching rule.
# Conceptually this is analogous to Interpreter::process in C++
@abstractmethod
def process(self, op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
pass
# lower an operation from this Interpreter to the next Interpreter on the stack.
# Concretely, this involves temporarily popping the current Interpreter.
# Conceptually this is analogous to Interpreter::sendToNextInterpreter in C++
def lower(self) -> contextlib.AbstractContextManager[Any]:
return temporarily_pop_interpreter_stack()
def level(self) -> int:
return self._cptr.level()
def key(self) -> TransformType:
return self._cptr.key()
def get_state(self) -> tuple[Any, ...]:
raise NotImplementedError
def check_state(self, state: tuple[Any, ...]) -> bool:
return state == self.get_state()
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
state.pop("_cptr", None)
return state
@contextlib.contextmanager
def temporarily_pop_interpreter_stack() -> Generator[None, None, None]:
try:
saved = pop_dynamic_layer_stack()
yield
finally:
push_dynamic_layer_stack(saved)
@contextlib.contextmanager
def temporarily_clear_interpreter_stack() -> Generator[list[Any], None, None]:
stack: list[Any] = []
try:
while torch._C._functorch.peek_interpreter_stack() is not None:
stack.append(pop_dynamic_layer_stack())
yield list(stack)
finally:
while stack:
push_dynamic_layer_stack(stack.pop())
@contextlib.contextmanager
def temporarily_restore_interpreter_stack(
stack: list[Any] | None,
) -> Generator[None, None, None]:
pushed: list[Any] = []
if stack is None:
return
try:
for s in reversed(stack):
push_dynamic_layer_stack(s)
pushed.append(s)
yield
finally:
for _ in reversed(pushed):
# TODO: would be nice to assert that the layers are the same, but
# Python object identity is not preserved
pop_dynamic_layer_stack()
class VmapInterpreter(FuncTorchInterpreter):
def __init__(self, cdata: CInterpreter) -> None:
if cdata.key() != TransformType.Vmap:
raise AssertionError(f"expected TransformType.Vmap, got {cdata.key()}")
# NOTE: [Interpreter cdata vs cptr]
# cdata is a generic CInterpreter. We wrap it in a CVmapInterpreterPtr
# so that we can access methods specific to the vmap interpreter
self._cdata = cdata
@cached_property
# pyrefly: ignore [bad-override]
def _cptr(self) -> CVmapInterpreterPtr:
return CVmapInterpreterPtr(self._cdata)
def process(self, op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
kernel = op.functorch_table[TransformType.Vmap]
return kernel(self, *args, **kwargs)
def batch_size(self) -> int:
return self._cptr.batchSize()
def randomness(self) -> str:
typ = self._cptr.randomness()
if typ == RandomnessType.Error:
return "error"
elif typ == RandomnessType.Same:
return "same"
elif typ == RandomnessType.Different:
return "different"
raise RuntimeError(f"Unknown RandomnessType: {typ}")
def get_state(self) -> tuple[Any, ...]:
return (self.key().name, self.level(), self.randomness())
@contextlib.contextmanager
def nested(
*contexts: contextlib.AbstractContextManager[Any],
) -> Generator[tuple[contextlib.AbstractContextManager[Any], ...], None, None]:
with contextlib.ExitStack() as stack:
for ctx in contexts:
stack.enter_context(ctx)
yield contexts
class GradInterpreter(FuncTorchInterpreter):
def __init__(self, cdata: CInterpreter) -> None:
if cdata.key() != TransformType.Grad:
raise AssertionError(f"expected TransformType.Grad, got {cdata.key()}")
# See NOTE: [Interpreter cdata vs cptr]
self._cdata = cdata
@cached_property
# pyrefly: ignore [bad-override]
def _cptr(self) -> CGradInterpreterPtr:
return CGradInterpreterPtr(self._cdata)
def lift(
self, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
args, kwargs = pytree.tree_map_only(
torch.Tensor, self._cptr.lift, [args, kwargs]
)
return args, kwargs
def process(self, op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
kernel = op.functorch_table[TransformType.Grad]
args, kwargs = self.lift(args, kwargs)
return kernel(self, *args, **kwargs)
# GradInterpreter has custom lower because of the no_grad interaction
# See NOTE [grad and vjp interaction with no_grad]
# This logic is mirrored from C++ GradInterpreterPtr::sendToNextInterpreter
def lower(self) -> contextlib.AbstractContextManager[Any]:
prev_grad_mode = self.prev_grad_mode()
if not prev_grad_mode:
return nested(torch.no_grad(), super().lower())
return super().lower()
def prev_grad_mode(self) -> bool:
return self._cptr.prevGradMode()
def get_state(self) -> tuple[Any, ...]:
return (self.key().name, self.level(), self.prev_grad_mode())
class JvpInterpreter(FuncTorchInterpreter):
def __init__(self, cdata: CInterpreter) -> None:
if cdata.key() != TransformType.Jvp:
raise AssertionError(f"expected TransformType.Jvp, got {cdata.key()}")
# See NOTE: [Interpreter cdata vs cptr]
self._cdata = cdata
@cached_property
# pyrefly: ignore [bad-override]
def _cptr(self) -> CJvpInterpreterPtr:
return CJvpInterpreterPtr(self._cdata)
def lift(
self, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
args, kwargs = pytree.tree_map_only(
torch.Tensor, self._cptr.lift, [args, kwargs]
)
return args, kwargs
def process(self, op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
kernel = op.functorch_table[TransformType.Jvp]
args, kwargs = self.lift(args, kwargs)
return kernel(self, *args, **kwargs)
# Jvp has custom lower because of the no_fwd_grad interaction
# See NOTE [grad and vjp interaction with no_grad] for related info.
# This logic is mirrored from C++ JvpInterpreterPtr::sendToNextInterpreter
def lower(self) -> contextlib.AbstractContextManager[Any]:
prev_fwd_grad_mode = self.prev_fwd_grad_mode()
if not prev_fwd_grad_mode:
return nested(_set_fwd_grad_enabled(False), super().lower())
return super().lower()
def prev_fwd_grad_mode(self) -> bool:
return self._cptr.prevFwdGradMode()
def get_state(self) -> tuple[Any, ...]:
return (self.key().name, self.level(), self.prev_fwd_grad_mode())
class FunctionalizeInterpreter(FuncTorchInterpreter):
def __init__(self, cdata: CInterpreter) -> None:
if cdata.key() != TransformType.Functionalize:
raise AssertionError(
f"expected TransformType.Functionalize, got {cdata.key()}"
)
self._cdata = cdata
@cached_property
# pyrefly: ignore [bad-override]
def _cptr(self) -> CFunctionalizeInterpreterPtr:
return CFunctionalizeInterpreterPtr(self._cdata)
def process(self, op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
kernel = op.functorch_table[TransformType.Functionalize]
return kernel(self, *args, **kwargs)
def functionalize_add_back_views(self) -> bool:
return self._cptr.functionalizeAddBackViews()
def get_state(self) -> tuple[Any, ...]:
return (self.key().name, self.level())
def coerce_cinterpreter(cinterpreter: CInterpreter) -> FuncTorchInterpreter:
key = cinterpreter.key()
if key == TransformType.Grad:
return GradInterpreter(cinterpreter)
if key == TransformType.Vmap:
return VmapInterpreter(cinterpreter)
if key == TransformType.Jvp:
return JvpInterpreter(cinterpreter)
if key == TransformType.Functionalize:
return FunctionalizeInterpreter(cinterpreter)
raise RuntimeError(f"NYI: PyDispatcher has not implemented support for {key}")
def retrieve_current_functorch_interpreter() -> FuncTorchInterpreter:
interpreter = torch._C._functorch.peek_interpreter_stack()
if interpreter is None:
raise AssertionError("interpreter must not be None")
return coerce_cinterpreter(interpreter)
def retrieve_all_functorch_interpreters() -> list[FuncTorchInterpreter]:
cis = torch._C._functorch.get_interpreter_stack()
if cis is None:
return []
return [coerce_cinterpreter(ci) for ci in cis]
def compare_functorch_state(states: list[tuple[Any, ...]]) -> bool:
# There are four possible cases covered here:
# 1. Current stack empty AND stack when generated not empty -> Invalidate
# 2. Current stack not empty AND stack when generated empty -> Invalidate
# 3. Current stack and generated stack empty -> Valid FX graph
# 4. Current stack and generated stack not empty -> Valid if both states match
peek = torch._C._functorch.peek_interpreter_stack()
if (peek is None and len(states) != 0) or (peek is not None and len(states) == 0):
return False
cis = retrieve_all_functorch_interpreters()
return len(cis) == len(states) and all(
ci.check_state(state) for ci, state in zip(cis, states)
)
def dispatch_functorch(op: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
interpreter = retrieve_current_functorch_interpreter()
# In traditional PyTorch operators, DispatchKey::FuncTorchTensorWrapper's
# unwrap_dead_tensors fallback handles unwrapping dead tensor wrappers.
# PyDispatcher sidesteps the PyTorch dispatcher when dealing with functorch
# transforms, so we manually unwrap the dead tensors here.
# This logic won't need to exist when we have mode-only functorch.
args, kwargs = pytree.tree_map_only(
torch.Tensor, torch._C._functorch.unwrap_if_dead, (args, kwargs)
)
return interpreter.process(op, args, kwargs)
@@ -0,0 +1,15 @@
# Copyright (c) Facebook, Inc. and its 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.
__all__ = ["make_fx", "dispatch_trace", "PythonKeyTracer", "pythonkey_decompose"]
from torch.fx.experimental.proxy_tensor import (
decompose,
dispatch_trace,
make_fx,
PythonKeyTracer,
)
pythonkey_decompose = decompose
@@ -0,0 +1,23 @@
# Copyright (c) Facebook, Inc. and its 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 warnings
# TODO: remove this file when the migration of the pytree utility is done
from torch.utils._pytree import tree_map_, treespec_pprint
__all__ = ["tree_map_", "treespec_pprint"]
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"`torch._functorch.pytree_hacks` is deprecated and will be removed in a future release. "
"Please `use torch.utils._pytree` instead.",
DeprecationWarning,
stacklevel=2,
)
@@ -0,0 +1,631 @@
from __future__ import annotations
"""
From https://docs.google.com/spreadsheets/d/12R3nCOLskxPYjjiNkdqy4OdQ65eQp_htebXGODsjSeA/edit#gid=0
Try to keep this list in sync with that.
"""
import operator
top_torch: list[tuple[str, int]] = [
("t", 6837449),
("tensor", 585786),
("mode", 462182),
("cat", 394818),
("max", 368038),
("zeros", 329495),
("load", 327756),
("no_grad", 294694),
("save", 265130),
("from_numpy", 243063),
("manual_seed", 165044),
("ones", 153696),
("randn", 150796),
("stack", 133358),
("sum", 130772),
("arange", 98087),
("rand", 94715),
("mean", 88546),
("exp", 73883),
("zeros_like", 72831),
("min", 72248),
("sigmoid", 66798),
("log", 62135),
("matmul", 47811),
("clamp", 45304),
("sqrt", 44911),
("abs", 43535),
("tanh", 42793),
("empty", 40311),
("argmax", 38435),
("bmm", 33984),
("pow", 33571),
("norm", 31125),
("mm", 30995),
("is_tensor", 29546),
("ones_like", 29512),
("nonzero", 28681),
("full", 28373),
("unsqueeze", 27911),
("where", 26585),
("randperm", 26450),
("eye", 24342),
("mul", 23236),
("topk", 22537),
("as_tensor", 21967),
("sort", 21412),
("squeeze", 20863),
("randint", 20771),
("linspace", 20041),
("add", 19201),
("transpose", 18663),
("split", 18325),
("gather", 17904),
("set_grad_enabled", 16013),
("sin", 15669),
("cos", 15562),
("div", 15513),
("index_select", 14866),
("multinomial", 14331),
("flatten", 14267),
("isnan", 14170),
("randn_like", 13096),
("eq", 12680),
("einsum", 12480),
("round", 12367),
("floor", 11628),
("allclose", 11000),
("reshape", 10605),
("diag", 10167),
("chunk", 9581),
("std", 9379),
("set_default_tensor_type", 9281),
("triu", 8559),
("meshgrid", 8292),
("set_num_threads", 8126),
("unique", 7964),
("full_like", 7780),
("tril", 7538),
("dot", 7275),
("sign", 6943),
("equal", 6916),
("normal", 6750),
("cumsum", 6556),
("dist", 6058),
("isfinite", 6030),
("gt", 5935),
("set_printoptions", 5888),
("range", 5491),
("empty_like", 5351),
("flip", 5342),
("masked_select", 5341),
("bernoulli", 5262),
("atan", 5253),
("var", 5247),
("prod", 5200),
("erf", 5088),
("inverse", 5072),
("addmm", 4854),
("logsumexp", 4582),
("fft", 4436),
("lt", 4421),
("log2", 4316),
("enable_grad", 4238),
("rand_like", 4187),
("argsort", 3972),
("seed", 3932),
("mv", 3547),
("ger", 3309),
("ge", 3248),
("atan2", 3210),
("ceil", 3202),
("ne", 3075),
("bincount", 3063),
("acos", 3055),
("rsqrt", 3031),
("svd", 3029),
("numel", 3003),
("log1p", 2840),
("unbind", 2808),
("le", 2714),
("isinf", 2707),
("cross", 2646),
("set_default_dtype", 2536),
("argmin", 2535),
("sparse_coo_tensor", 2489),
("log10", 2304),
("kthvalue", 2192),
("set_rng_state", 2158),
("get_rng_state", 1996),
("get_default_dtype", 1879),
("det", 1868),
("qr", 1864),
("histc", 1852),
("symeig", 1832),
("trace", 1801),
("median", 1795),
("addcmul", 1751),
("remainder", 1717),
("baddbmm", 1693),
("lgamma", 1665),
("repeat_interleave", 1598),
("fmod", 1576),
("reciprocal", 1575),
("tan", 1560),
("initial_seed", 1532),
("take", 1529),
("stft", 1487),
("get_num_threads", 1477),
("real", 1459),
("cholesky", 1406),
("quantize_per_tensor", 1392),
("diag_embed", 1364),
("lerp", 1363),
("asin", 1345),
("eig", 1333),
("trunc", 1290),
("diagonal", 1287),
("cosh", 1279),
("rfft", 1269),
("cumprod", 1260),
("addr", 1211),
("roll", 1198),
("narrow", 1188),
("digamma", 1172),
("square", 1163),
("sinh", 1131),
("logspace", 1084),
("broadcast_tensors", 1070),
("irfft", 1013),
("frac", 997),
("hann_window", 994),
("solve", 989),
("logdet", 977),
("expm1", 968),
("cdist", 946),
("addmv", 903),
("randint_like", 888),
("tensordot", 888),
("ifft", 877),
("true_divide", 854),
("erfinv", 830),
("addcdiv", 819),
("addbmm", 813),
("renorm", 781),
("pinverse", 753),
("isclose", 740),
("erfc", 729),
("is_storage", 725),
("triangular_solve", 723),
("rot90", 709),
("logical_not", 686),
("geqrf", 681),
("slogdet", 677),
("lu", 665),
("hamming_window", 659),
("orgqr", 651),
("ormqr", 622),
("is_floating_point", 602),
("diagflat", 562),
("cholesky_solve", 559),
("tril_indices", 552),
("chain_matmul", 551),
("triu_indices", 548),
("angle", 522),
("poisson", 505),
("matrix_power", 485),
("unique_consecutive", 471),
("quantize_per_channel", 465),
("std_mean", 458),
("bartlett_window", 447),
("var_mean", 428),
("lstsq", 421),
("logical_and", 419),
("mvlgamma", 411),
("blackman_window", 400),
("bitwise_not", 395),
("cholesky_inverse", 388),
("as_strided", 384),
("floor_divide", 353),
("cartesian_prod", 321),
("lu_solve", 317),
("set_flush_denormal", 310),
("empty_strided", 283),
("logical_xor", 282),
("polygamma", 282),
("logical_or", 280),
("set_num_interop_threads", 278),
("combinations", 274),
("trapz", 270),
("matrix_rank", 260),
("lu_unpack", 255),
("result_type", 244),
("conj", 231),
("cummax", 230),
("lobpcg", 229),
("bitwise_xor", 217),
("promote_types", 213),
("get_num_interop_threads", 211),
("cummin", 205),
("bitwise_and", 198),
("dequantize", 192),
("bitwise_or", 191),
("imag", 191),
("can_cast", 184),
("istft", 180),
("compiled_with_cxx11_abi", 159),
("is_complex", 151),
("block_diag", 136),
("pca_lowrank", 124),
("absolute", 122),
("svd_lowrank", 108),
("neg", 2),
]
top_nn_functional: list[tuple[str, int]] = [
("nn.functional.softmax", 10522),
("nn.functional.relu", 8572),
("nn.functional.interpolate", 7277),
("nn.functional.pad", 5207),
("nn.functional.log_softmax", 4699),
("nn.functional.normalize", 2338),
("nn.functional.cross_entropy", 2083),
("nn.functional.grid_sample", 1970),
("nn.functional.one_hot", 1967),
("nn.functional.mse_loss", 1920),
("nn.functional.conv2d", 1593),
("nn.functional.dropout", 1516),
("nn.functional.softplus", 1385),
("nn.functional.sigmoid", 1128),
("nn.functional.linear", 1036),
("nn.functional.gelu", 930),
("nn.functional.avg_pool2d", 899),
("nn.functional.max_pool2d", 876),
("nn.functional.nll_loss", 863),
("nn.functional.embedding", 737),
("nn.functional.tanh", 664),
("nn.functional.leaky_relu", 640),
("nn.functional.adaptive_avg_pool2d", 633),
("nn.functional.cosine_similarity", 627),
("nn.functional.unfold", 609),
("nn.functional.conv1d", 596),
("nn.functional.binary_cross_entropy_with_logits", 591),
("nn.functional.l1_loss", 571),
("nn.functional.binary_cross_entropy", 492),
("nn.functional.elu", 416),
("nn.functional.batch_norm", 413),
("nn.functional.upsample", 413),
("nn.functional.fold", 305),
("nn.functional.affine_grid", 298),
("nn.functional.max_pool1d", 297),
("nn.functional.torch", 294),
("nn.functional.threshold", 263),
("nn.functional.smooth_l1_loss", 262),
("nn.functional.pairwise_distance", 253),
("nn.functional.logsigmoid", 243),
("nn.functional.adaptive_max_pool2d", 235),
("nn.functional.relu6", 213),
("nn.functional.pixel_shuffle", 209),
("nn.functional.avg_pool3d", 203),
("nn.functional.bilinear", 203),
("nn.functional.conv_transpose2d", 201),
("nn.functional.gumbel_softmax", 197),
("nn.functional.max_unpool2d", 196),
("nn.functional.kl_div", 191),
("nn.functional.hardtanh", 189),
("nn.functional.ctc_loss", 185),
("nn.functional.layer_norm", 178),
("nn.functional.conv3d", 172),
("nn.functional.max_unpool3d", 167),
("nn.functional.hardshrink", 165),
("nn.functional.hardswish", 156),
("nn.functional.selu", 156),
("nn.functional.glu", 155),
("nn.functional.assert_int_or_pair", 150),
("nn.functional.hardsigmoid", 146),
("nn.functional.upsample_bilinear", 146),
("nn.functional.max_pool3d", 140),
("nn.functional.adaptive_avg_pool3d", 139),
("nn.functional.instance_norm", 124),
("nn.functional.embedding_bag", 122),
("nn.functional.upsample_nearest", 110),
("nn.functional.avg_pool1d", 105),
("nn.functional.prelu", 102),
("nn.functional.celu", 92),
("nn.functional.dropout2d", 86),
("nn.functional.hinge_embedding_loss", 82),
("nn.functional.softsign", 81),
("nn.functional.max_unpool1d", 74),
("nn.functional.silu", 74),
("nn.functional.softshrink", 70),
("nn.functional.leaky_relu_", 68),
("nn.functional.softmin", 67),
("nn.functional.channel_shuffle", 66),
("nn.functional.multilabel_margin_loss", 66),
("nn.functional.dropout3d", 65),
("nn.functional.multi_margin_loss", 65),
("nn.functional.lp_pool2d", 64),
("nn.functional.conv_transpose1d", 62),
("nn.functional.triplet_margin_loss", 62),
("nn.functional.tanhshrink", 61),
("nn.functional.adaptive_max_pool1d", 59),
("nn.functional.cosine_embedding_loss", 58),
("nn.functional.multi_head_attention_forward", 58),
("nn.functional.max_pool1d_with_indices", 53),
("nn.functional.poisson_nll_loss", 53),
("nn.functional.margin_ranking_loss", 52),
("nn.functional.soft_margin_loss", 52),
("nn.functional.adaptive_max_pool3d", 51),
("nn.functional.group_norm", 51),
("nn.functional.local_response_norm", 51),
("nn.functional.multilabel_soft_margin_loss", 51),
("nn.functional.relu_", 50),
("nn.functional.alpha_dropout", 49),
("nn.functional.feature_alpha_dropout", 49),
("nn.functional.lp_pool1d", 49),
("nn.functional.adaptive_max_pool1d_with_indices", 48),
("nn.functional.adaptive_max_pool2d_with_indices", 48),
("nn.functional.adaptive_max_pool3d_with_indices", 48),
("nn.functional.fractional_max_pool2d", 48),
("nn.functional.fractional_max_pool2d_with_indices", 48),
("nn.functional.fractional_max_pool3d", 48),
("nn.functional.fractional_max_pool3d_with_indices", 48),
("nn.functional.max_pool2d_with_indices", 48),
("nn.functional.max_pool3d_with_indices", 48),
("nn.functional.handle_torch_function", 47),
("nn.functional.has_torch_function", 47),
("nn.functional.adaptive_avg_pool1d", 43),
("nn.functional.pdist", 43),
("nn.functional.rrelu_", 37),
("nn.functional.elu_", 34),
("nn.functional.boolean_dispatch", 33),
("nn.functional.hardtanh_", 26),
("nn.functional.triplet_margin_with_distance_loss", 23),
("nn.functional.selu_", 20),
("nn.functional.pixel_unshuffle", 19),
("nn.functional.conv_transpose3d", 18),
("nn.functional.gaussian_nll_loss", 15),
("nn.functional.has_torch_function_unary", 15),
("nn.functional.has_torch_function_variadic", 15),
("nn.functional.celu_", 13),
("nn.functional.huber_loss", 7),
("nn.functional.mish", 4),
("nn.functional.threshold_", 3),
("nn.functional.grad", 2),
("nn.functional.conv_tbc", 1),
("nn.functional.math", 1),
]
top_nn_module: list[tuple[str, int, str | None]] = [
("nn.Module", 927129, None),
("nn.Linear", 530688, "nn.functional.linear"),
("nn.Sequential", 384968, None),
("nn.Conv2d", 383320, "nn.functional.conv2d"),
("nn.ReLU", 318877, "nn.functional.relu"),
("nn.BatchNorm2d", 233265, "nn.functional.batch_norm"),
("nn.Dropout", 179268, "nn.functional.dropout"),
("nn.ModuleList", 171225, None),
("nn.Parameter", 153291, None),
("nn.CrossEntropyLoss", 152696, "nn.functional.cross_entropy"),
("nn.MaxPool2d", 138619, "nn.functional.max_pool2d"),
("nn.Embedding", 111844, "nn.functional.embedding"),
("nn.DataParallel", 104238, None),
("nn.MSELoss", 82954, "nn.functional.mse_loss"),
("nn.Sigmoid", 75810, "nn.functional.sigmoid"),
("nn.LeakyReLU", 65632, "nn.functional.leaky_relu"),
("nn.BatchNorm1d", 65374, "nn.functional.batch_norm"),
("nn.Softmax", 65114, "nn.functional.softmax"),
("nn.Tanh", 59445, "nn.functional.tanh"),
("nn.AdaptiveAvgPool2d", 59071, "nn.functional.adaptive_avg_pool2d"),
("nn.AvgPool2d", 58377, "nn.functional.avg_pool2d"),
("nn.ConvTranspose2d", 57524, "nn.functional.conv_transpose2d"),
("nn.LSTM", 57411, None),
("nn.Conv1d", 41108, "nn.functional.conv1d"),
("nn.LayerNorm", 36089, "nn.functional.layer_norm"),
("nn.BCELoss", 34005, "nn.functional.binary_cross_entropy"),
("nn.Upsample", 32527, "nn.functional.interpolate"),
("nn.BCEWithLogitsLoss", 29944, "nn.functional.binary_cross_entropy_with_logits"),
("nn.GRU", 25421, None),
("nn.Dropout2d", 23512, "nn.functional.dropout2d"),
("nn.LogSoftmax", 22897, "nn.functional.log_softmax"),
("nn.L1Loss", 22778, "nn.functional.l1_loss"),
("nn.GroupNorm", 22183, "nn.functional.group_norm"),
("nn.NLLLoss", 21751, "nn.functional.nll_loss"),
("nn.Conv3d", 20874, "nn.functional.conv3d"),
("nn.Identity", 17911, None),
("nn.InstanceNorm2d", 16426, "nn.functional.instance_norm"),
("nn.BatchNorm3d", 16378, "nn.functional.batch_norm"),
("nn.PReLU", 13472, "nn.functional.prelu"),
("nn.ReLU6", 12622, "nn.functional.relu6"),
("nn.ELU", 12508, "nn.functional.elu"),
("nn.LSTMCell", 10885, None),
("nn.Flatten", 10384, "torch.flatten"),
("nn.ModuleDict", 10255, None),
("nn.ReflectionPad2d", 9954, "nn.functional.pad"),
("nn.MaxPool3d", 9526, "nn.functional.max_pool3d"),
("nn.MaxPool1d", 9154, "nn.functional.max_pool1d"),
("nn.RNN", 9154, None),
("nn.ZeroPad2d", 8847, "nn.functional.pad"),
("nn.ParameterList", 7702, None),
("nn.SyncBatchNorm", 6814, None),
("nn.PixelShuffle", 6571, "nn.functional.pixel_shuffle"),
("nn.SmoothL1Loss", 6517, "nn.functional.smooth_l1_loss"),
("nn.Hardswish", 6458, "nn.functional.hardswish"),
("nn.AdaptiveMaxPool2d", 6071, "nn.functional.adaptive_max_pool2d"),
("nn.SELU", 6043, "nn.functional.selu"),
("nn.ConvTranspose3d", 6039, "nn.functional.conv_transpose3d"),
("nn.GRUCell", 5840, None),
("nn.ReplicationPad2d", 5600, "nn.functional.pad"),
("nn.KLDivLoss", 5541, "nn.functional.kl_div"),
("nn.ConvTranspose1d", 5183, "nn.functional.conv_transpose1d"),
("nn.Softplus", 5120, "nn.functional.softplus"),
("nn.SiLU", 4895, "nn.functional.silu"),
("nn.AvgPool3d", 4523, "nn.functional.avg_pool3d"),
("nn.CosineSimilarity", 4058, "nn.functional.cosine_similarity"),
("nn.GELU", 3932, "nn.functional.gelu"),
("nn.UpsamplingBilinear2d", 3673, "nn.functional.interpolate"),
("nn.InstanceNorm1d", 3658, "nn.functional.instance_norm"),
("nn.Transformer", 3604, None),
("nn.MultiheadAttention", 3435, "nn.functional.multi_head_attention_forward"),
("nn.AvgPool1d", 3195, "nn.functional.avg_pool1d"),
("nn.Dropout3d", 2964, "nn.functional.dropout3d"),
("nn.AdaptiveAvgPool3d", 2915, "nn.functional.adaptive_avg_pool3d"),
("nn.InstanceNorm3d", 2893, "nn.functional.instance_norm"),
("nn.Hardtanh", 2613, "nn.functional.hardtanh"),
("nn.MarginRankingLoss", 2568, "nn.functional.margin_ranking_loss"),
("nn.GLU", 2526, "nn.functional.glu"),
("nn.AdaptiveAvgPool1d", 2481, "nn.functional.adaptive_avg_pool1d"),
("nn.EmbeddingBag", 2344, "nn.functional.embedding_bag"),
("nn.TransformerEncoderLayer", 2292, None),
("nn.TransformerEncoder", 2091, None),
("nn.MaxUnpool2d", 2031, "nn.functional.max_unpool2d"),
("nn.UpsamplingNearest2d", 2004, "nn.functional.interpolate"),
("nn.ConstantPad1d", 1904, "nn.functional.pad"),
("nn.ConstantPad2d", 1791, "nn.functional.pad"),
("nn.CTCLoss", 1789, "nn.functional.ctc_loss"),
("nn.AdaptiveMaxPool1d", 1713, "nn.functional.adaptive_max_pool1d"),
("nn.AdaptiveLogSoftmaxWithLoss", 1665, None),
("nn.Bilinear", 1664, "nn.functional.bilinear"),
("nn.RNNCell", 1653, None),
("nn.MultiLabelSoftMarginLoss", 1624, "nn.functional.multilabel_soft_margin_loss"),
("nn.Unfold", 1452, "nn.functional.unfold"),
("nn.RReLU", 1431, "nn.functional.rrelu"),
("nn.CosineEmbeddingLoss", 1357, "nn.functional.cosine_embedding_loss"),
("nn.LocalResponseNorm", 1331, "nn.functional.local_response_norm"),
("nn.Softmax2d", 1300, "nn.functional.softmax"),
("nn.PairwiseDistance", 1241, "nn.functional.pairwise_distance"),
("nn.LogSigmoid", 1235, "nn.functional.logsigmoid"),
("nn.TripletMarginLoss", 1230, "nn.functional.triplet_margin_loss"),
("nn.RNNBase", 1133, None),
("nn.Threshold", 1043, "nn.functional.threshold"),
("nn.AdaptiveMaxPool3d", 1025, "nn.functional.adaptive_max_pool3d"),
("nn.CELU", 1018, "nn.functional.celu"),
("nn.NLLLoss2d", 966, "nn.functional.nll_loss"),
("nn.Softsign", 877, "nn.functional.softsign"),
("nn.ReplicationPad1d", 862, "nn.functional.pad"),
("nn.SoftMarginLoss", 856, "nn.functional.soft_margin_loss"),
("nn.ParameterDict", 742, None),
("nn.ReflectionPad1d", 731, "nn.functional.pad"),
("nn.Softshrink", 713, "nn.functional.softshrink"),
("nn.AlphaDropout", 710, "nn.functional.alpha_dropout"),
("nn.Tanhshrink", 681, "nn.functional.tanhshrink"),
("nn.PoissonNLLLoss", 676, "nn.functional.poisson_nll_loss"),
("nn.MaxUnpool3d", 660, "nn.functional.max_unpool3d"),
("nn.Fold", 630, "nn.functional.fold"),
("nn.MultiMarginLoss", 622, "nn.functional.multi_margin_loss"),
("nn.TransformerDecoderLayer", 614, None),
("nn.TransformerDecoder", 607, None),
("nn.Hardshrink", 592, "nn.functional.hardshrink"),
("nn.ConstantPad3d", 582, "nn.functional.pad"),
("nn.MultiLabelMarginLoss", 580, "nn.functional.multilabel_margin_loss"),
("nn.LPPool2d", 550, "nn.functional.lp_pool2d"),
("nn.Softmin", 537, "nn.functional.softmin"),
("nn.MaxUnpool1d", 518, "nn.functional.max_unpool1d"),
("nn.FractionalMaxPool2d", 484, "nn.functional.fractional_max_pool2d"),
("nn.Hardsigmoid", 477, "nn.functional.hardsigmoid"),
("nn.ReplicationPad3d", 470, "nn.functional.pad"),
("nn.HingeEmbeddingLoss", 442, "nn.functional.hinge_embedding_loss"),
("nn.LPPool1d", 386, "nn.functional.lp_pool1d"),
("nn.FractionalMaxPool3d", 252, "nn.functional.fractional_max_pool3d"),
("nn.Container", 217, None),
("nn.Unflatten", 206, "nn.functional.unflatten"),
("nn.FeatureAlphaDropout", 136, "nn.functional.feature_alpha_dropout"),
(
"nn.TripletMarginWithDistanceLoss",
107,
"nn.functional.triplet_margin_with_distance_loss",
),
("nn.ChannelShuffle", 90, "nn.functional.channel_shuffle"),
("nn.RNNCellBase", 88, None),
("nn.LazyLinear", 81, "nn.functional.linear"),
("nn.UninitializedParameter", 60, None),
("nn.CrossMapLRN2d", 59, None),
("nn.GaussianNLLLoss", 55, "nn.functional.gaussian_nll_loss"),
("nn.PixelUnshuffle", 45, "nn.functional.pixel_unshuffle"),
("nn.Mish", 31, "nn.functional.mish"),
("nn.ReflectionPad3d", 22, "nn.functional.pad"),
("nn.HuberLoss", 18, "nn.functional.huber_loss"),
("nn.LazyConv2d", 15, None),
("nn.LazyConv1d", 9, None),
("nn.LazyConv3d", 8, None),
("nn.LazyConvTranspose1d", 8, None),
("nn.LazyConvTranspose2d", 8, None),
("nn.LazyConvTranspose3d", 8, None),
("nn.LazyBatchNorm1d", 3, None),
("nn.LazyBatchNorm2d", 3, None),
("nn.LazyBatchNorm3d", 3, None),
("nn.UninitializedBuffer", 3, None),
]
# No rankings because these are a little hard to get rankings for
method_only_ops: list[str] = [
"bfloat16",
"bool",
"byte",
"char",
"contiguous",
"cpu",
"cuda",
"detach",
"double",
"expand",
"expand_as",
"float",
"get_device",
"half",
"hardshrink",
"index_add",
"index_copy",
"index_fill",
"index_put",
"int",
"is_contiguous",
"is_pinned",
"is_set_to",
"is_shared",
"is_signed",
"item",
"long",
"masked_scatter",
"masked_fill",
"narrow_copy",
"numpy",
"pin_memory",
"repeat",
"reshape_as",
"select",
"short",
"storage_offset",
"sum_to_size",
"to",
"to_mkldnn",
"tolist",
"type",
"type_as",
"unfold",
"view",
"view_as",
]
def get_nn_functional_top_list() -> list[tuple[str, int]]:
top_nn_functional_: dict[str, int] = dict(top_nn_functional)
for _, count, functional_name in top_nn_module:
if functional_name is None:
continue
if functional_name == "torch.flatten":
continue
if functional_name not in top_nn_functional_:
top_nn_functional_[functional_name] = count
else:
top_nn_functional_[functional_name] += count
top_nn_functional_list = list(top_nn_functional_.items())
top_nn_functional_list.sort(key=operator.itemgetter(1), reverse=True)
return top_nn_functional_list
usage_count: dict[str, int] = dict(get_nn_functional_top_list())
usage_count.update(top_torch)
@@ -0,0 +1,40 @@
import contextlib
from collections.abc import Generator
from typing import Any
import torch
from torch._C._functorch import (
get_single_level_autograd_function_allowed,
set_single_level_autograd_function_allowed,
unwrap_if_dead,
)
from torch.utils._exposed_in import exposed_in
__all__ = [
"exposed_in",
"argnums_t",
"enable_single_level_autograd_function",
"unwrap_dead_wrappers",
]
@contextlib.contextmanager
def enable_single_level_autograd_function() -> Generator[None, None, None]:
try:
prev_state = get_single_level_autograd_function_allowed()
set_single_level_autograd_function_allowed(True)
yield
finally:
set_single_level_autograd_function_allowed(prev_state)
def unwrap_dead_wrappers(args: tuple[Any, ...]) -> tuple[Any, ...]:
# NB: doesn't use tree_map_only for performance reasons
result = tuple(
unwrap_if_dead(arg) if isinstance(arg, torch.Tensor) else arg for arg in args
)
return result
argnums_t = int | tuple[int, ...]
@@ -0,0 +1,567 @@
# Copyright (c) Facebook, Inc. and its 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.
from __future__ import annotations
import contextlib
import functools
import itertools
from collections.abc import Callable # noqa: TC003
from functools import partial
from typing import Any, cast, NoReturn, TYPE_CHECKING
from typing_extensions import ParamSpec, TypeVar
import torch
from torch import Tensor
from torch._C._functorch import is_batchedtensor
from torch._functorch.predispatch import (
_add_batch_dim,
_remove_batch_dim,
_vmap_decrement_nesting,
_vmap_increment_nesting,
lazy_load_decompositions,
)
from torch.utils._pytree import (
_broadcast_to_and_flatten,
tree_flatten,
tree_map_,
tree_unflatten,
TreeSpec,
)
if TYPE_CHECKING:
from collections.abc import Generator, Iterable
_P = ParamSpec("_P")
_R = TypeVar("_R")
in_dims_t = int | tuple[Any, ...]
out_dims_t = int | tuple[int, ...] | None
def doesnt_support_saved_tensors_hooks(f: Callable[_P, _R]) -> Callable[_P, _R]:
message = (
"torch.func.{grad, vjp, jacrev, hessian} don't yet support saved tensor hooks. "
"Please open an issue with your use case."
)
@functools.wraps(f)
def fn(*args: _P.args, **kwargs: _P.kwargs) -> _R:
with torch.autograd.graph.disable_saved_tensors_hooks(message):
return f(*args, **kwargs)
return fn
# Checks that all args-to-be-batched have the same batch dim size
def _validate_and_get_batch_size(
flat_in_dims: list[int | None], flat_args: list[Any]
) -> int:
batch_sizes = [
arg.size(in_dim)
for in_dim, arg in zip(flat_in_dims, flat_args)
if in_dim is not None
]
if len(batch_sizes) == 0:
raise ValueError("vmap: Expected at least one Tensor to vmap over")
if batch_sizes and any(size != batch_sizes[0] for size in batch_sizes):
raise ValueError(
f"vmap: Expected all tensors to have the same size in the mapped "
f"dimension, got sizes {batch_sizes} for the mapped dimension"
)
return batch_sizes[0]
def _num_outputs(batched_outputs: Tensor | tuple[Tensor, ...]) -> int:
if isinstance(batched_outputs, tuple):
return len(batched_outputs)
return 1
# If value is a tuple, check it has length `num_elements`.
# If value is not a tuple, make a tuple with `value` repeated `num_elements` times
def _as_tuple(
value: tuple[_R, ...] | _R,
num_elements: int,
error_message_lambda: Callable[[], str],
) -> tuple[_R, ...]:
if not isinstance(value, tuple):
return (value,) * num_elements
if len(value) != num_elements:
raise ValueError(error_message_lambda())
return value
def _process_batched_inputs(
in_dims: in_dims_t, args: tuple[Any, ...], func: Callable[..., Any]
) -> tuple[int, list[int | None], list[Any], TreeSpec]:
if not isinstance(in_dims, int) and not isinstance(in_dims, tuple):
raise ValueError(
f"vmap({_get_name(func)}, in_dims={in_dims}, ...)(<inputs>): "
f"expected `in_dims` to be int or a (potentially nested) tuple "
f"matching the structure of inputs, got: {type(in_dims)}."
)
if len(args) == 0:
raise ValueError(
f"vmap({_get_name(func)})(<inputs>): got no inputs. Maybe you forgot to add "
f"inputs, or you are trying to vmap over a function with no inputs. "
f"The latter is unsupported."
)
flat_args, args_spec = tree_flatten(args)
flat_in_dims = _broadcast_to_and_flatten(in_dims, args_spec)
if flat_in_dims is None:
raise ValueError(
f"vmap({_get_name(func)}, in_dims={in_dims}, ...)(<inputs>): "
f"in_dims is not compatible with the structure of `inputs`. "
f"in_dims has structure {tree_flatten(in_dims)[1]} but inputs "
f"has structure {args_spec}."
)
for i, (arg, in_dim) in enumerate(zip(flat_args, flat_in_dims)):
if not isinstance(in_dim, int) and in_dim is not None:
raise ValueError(
f"vmap({_get_name(func)}, in_dims={in_dims}, ...)(<inputs>): "
f"Got in_dim={in_dim} for an input but in_dim must be either "
f"an integer dimension or None."
)
if isinstance(in_dim, int) and not isinstance(arg, Tensor):
raise ValueError(
f"vmap({_get_name(func)}, in_dims={in_dims}, ...)(<inputs>): "
f"Got in_dim={in_dim} for an input but the input is of type "
f"{type(arg)}. We cannot vmap over non-Tensor arguments, "
f"please use None as the respective in_dim"
)
if in_dim is not None and (in_dim < -arg.dim() or in_dim >= arg.dim()):
raise ValueError(
f"vmap({_get_name(func)}, in_dims={in_dims}, ...)(<inputs>): "
f"Got in_dim={in_dim} for some input, but that input is a Tensor "
f"of dimensionality {arg.dim()} so expected in_dim to satisfy "
f"-{arg.dim()} <= in_dim < {arg.dim()}."
)
if in_dim is not None and in_dim < 0:
flat_in_dims[i] = in_dim % arg.dim()
return (
_validate_and_get_batch_size(flat_in_dims, flat_args),
flat_in_dims,
flat_args,
args_spec,
)
# Creates BatchedTensors for every Tensor in arg that should be batched.
# Returns the (potentially) batched arguments and the batch_size.
# TODO: See if we can explain how flat works to the type checker
def _create_batched_inputs(
flat_in_dims: list[int | None],
flat_args: list[Any],
vmap_level: int,
args_spec: TreeSpec,
) -> tuple[Any, ...]:
# See NOTE [Ignored _remove_batch_dim, _add_batch_dim]
batched_inputs = [
arg if in_dim is None else _add_batch_dim(arg, in_dim, vmap_level)
for in_dim, arg in zip(flat_in_dims, flat_args)
]
return tree_unflatten(batched_inputs, args_spec)
def _maybe_remove_batch_dim(
name: str,
batched_output: Any,
vmap_level: int,
batch_size: int,
out_dim: int | None,
) -> torch.Tensor:
if out_dim is None:
if isinstance(batched_output, torch.Tensor) and is_batchedtensor(
batched_output
):
raise ValueError(
f"vmap({name}, ...): `{name}` can not return a "
f"BatchedTensor when out_dim is None"
)
return batched_output
# out_dim is non None
if not isinstance(batched_output, torch.Tensor):
raise ValueError(
f"vmap({name}, ...): `{name}` must only return "
f"Tensors, got type {type(batched_output)}. "
"Did you mean to set out_dims= to None for output?"
)
return _remove_batch_dim(batched_output, vmap_level, batch_size, out_dim)
# Undos the batching (and any batch dimensions) associated with the `vmap_level`.
def _unwrap_batched(
batched_outputs: Tensor | tuple[Tensor, ...],
out_dims: out_dims_t,
vmap_level: int,
batch_size: int,
func: Callable[..., Any],
) -> tuple[Any, ...]:
flat_batched_outputs, output_spec = tree_flatten(batched_outputs)
def incompatible_error() -> NoReturn:
raise ValueError(
f"vmap({_get_name(func)}, ..., out_dims={out_dims})(<inputs>): "
f"out_dims is not compatible with the structure of `outputs`. "
f"out_dims has structure {tree_flatten(out_dims)[1]} but outputs "
f"has structure {output_spec}."
)
flat_out_dims: list[int | None] = []
if isinstance(batched_outputs, torch.Tensor):
# Some weird edge case requires us to spell out the following
# see test_out_dims_edge_case
if isinstance(out_dims, int):
flat_out_dims = [out_dims]
elif isinstance(out_dims, tuple) and len(out_dims) == 1:
flat_out_dims = list(out_dims)
elif out_dims is None:
flat_out_dims = [out_dims]
else:
incompatible_error()
else:
broadcast_result = _broadcast_to_and_flatten(out_dims, output_spec)
if broadcast_result is None:
incompatible_error()
else:
flat_out_dims = broadcast_result
flat_outputs = [
_maybe_remove_batch_dim(
_get_name(func), batched_output, vmap_level, batch_size, out_dim
)
for batched_output, out_dim in zip(flat_batched_outputs, flat_out_dims)
]
return tree_unflatten(flat_outputs, output_spec)
def _check_int_or_none(x: Any, func: Callable[..., Any], out_dims: out_dims_t) -> None:
if isinstance(x, int):
return
if x is None:
return
raise ValueError(
f"vmap({_get_name(func)}, ..., out_dims={out_dims}): `out_dims` must be "
f"an int, None or a python collection of ints representing where in the outputs the "
f"vmapped dimension should appear."
)
def _check_out_dims_is_int_or_int_pytree(
out_dims: out_dims_t, func: Callable[..., Any]
) -> None:
if isinstance(out_dims, int):
return
tree_map_(partial(_check_int_or_none, func=func, out_dims=out_dims), out_dims)
def _get_name(func: Callable[..., Any]) -> str:
if hasattr(func, "__name__"):
return func.__name__
if isinstance(func, functools.partial):
return f"functools.partial({_get_name(func.func)}, ...)"
# Not all callables have __name__, in fact, only static functions/methods
# do. A callable created via nn.Module, to name one example, doesn't have a
# __name__.
return repr(func)
def vmap_impl(
func: Callable[_P, Tensor | tuple[Tensor, ...]],
in_dims: in_dims_t,
out_dims: out_dims_t,
randomness: str,
chunk_size: int | None,
*args: _P.args,
**kwargs: _P.kwargs,
) -> Any:
lazy_load_decompositions()
_check_out_dims_is_int_or_int_pytree(out_dims, func)
batch_size, flat_in_dims, flat_args, args_spec = _process_batched_inputs(
in_dims, args, func
)
if chunk_size is not None:
chunks_flat_args = _get_chunked_inputs(
flat_args, flat_in_dims, batch_size, chunk_size
)
return _chunked_vmap(
func,
flat_in_dims,
chunks_flat_args,
args_spec,
out_dims,
randomness,
**kwargs,
)
# If chunk_size is not specified.
return _flat_vmap(
func,
batch_size,
flat_in_dims,
flat_args,
args_spec,
out_dims,
randomness,
**kwargs,
)
def get_chunk_sizes(total_elems: int, chunk_size: int) -> list[int]:
n_chunks = total_elems // chunk_size
chunk_sizes = [chunk_size] * n_chunks
# remainder chunk
remainder = total_elems % chunk_size
if remainder != 0:
chunk_sizes.append(remainder)
return chunk_sizes
def _get_chunked_inputs(
flat_args: list[Any],
flat_in_dims: list[int | None],
batch_size: int,
chunk_size: int | None,
) -> Iterable[tuple[Any, ...]]:
split_idxs = (batch_size,)
if chunk_size is not None:
chunk_sizes = get_chunk_sizes(batch_size, chunk_size)
split_idxs = tuple(itertools.accumulate(chunk_sizes))
flat_args_chunks = tuple(
(
t.tensor_split(split_idxs, dim=in_dim)
if in_dim is not None
else [
t,
]
* len(split_idxs)
)
for t, in_dim in zip(flat_args, flat_in_dims)
)
# transpose chunk dim and flatten structure
# chunks_flat_args is a list of flatten args
chunks_flat_args = zip(*flat_args_chunks)
return chunks_flat_args
def _flatten_chunks_output(
chunks_output_: list[Any],
) -> tuple[list[tuple[Any, ...]], TreeSpec]:
# chunks_output is a list of chunked outputs
# flatten chunked outputs:
flat_chunks_output: list[list[Any]] = []
arg_spec: TreeSpec | None = None
for output in chunks_output_:
flat_output, arg_specs = tree_flatten(output)
flat_chunks_output.append(flat_output)
if arg_spec is None:
arg_spec = arg_specs
# transpose chunk dim and flatten structure
# flat_output_chunks is flat list of chunks
flat_output_chunks = list(zip(*flat_chunks_output))
if arg_spec is None:
raise AssertionError("arg_spec must not be None")
return flat_output_chunks, arg_spec
def _concat_chunked_outputs(
out_dims: out_dims_t,
arg_spec: TreeSpec,
flat_output_chunks: list[tuple[Any, ...] | None],
) -> list[Tensor]:
# concat chunks on out_dim
flat_out_dims = _broadcast_to_and_flatten(out_dims, arg_spec)
if flat_out_dims is None:
raise AssertionError("flat_out_dims must not be None")
if len(flat_out_dims) != len(flat_output_chunks):
raise AssertionError(
f"len(flat_out_dims)={len(flat_out_dims)} != len(flat_output_chunks)={len(flat_output_chunks)}"
)
flat_output: list[Tensor] = []
for idx, out_dim in enumerate(flat_out_dims):
chunk = flat_output_chunks[idx]
if chunk is None:
raise AssertionError(f"chunk at index {idx} must not be None")
flat_output.append(torch.cat(chunk, dim=out_dim))
# release tensors
flat_output_chunks[idx] = None
return flat_output
# Applies vmap on chunked_input and returns concatenated output over the chunks.
def _chunked_vmap(
func: Callable[_P, Tensor | tuple[Tensor, ...]],
flat_in_dims: list[int | None],
chunks_flat_args: Iterable[tuple[Any, ...]],
args_spec: TreeSpec,
out_dims: out_dims_t,
randomness: str,
**kwargs: Any,
) -> Any:
chunks_output: list[Any] = []
rs = torch.get_rng_state() if randomness == "same" else None
for flat_args_tuple in chunks_flat_args:
flat_args = list(flat_args_tuple)
batch_size = _validate_and_get_batch_size(flat_in_dims, flat_args)
# The way we compute split the input in `_get_chunked_inputs`,
# we may get a tensor with `0` batch-size. We skip any computation
# in that case.
# Eg.
# >>> chunk_size = 1
# >>> batch_size = 6
# >>> t = torch.zeros(batch_size, 1)
# >>> t.tensor_split([1, 2, 3, 4, 5, 6])
# (tensor([[0.]]), tensor([[0.]]), tensor([[0.]]), tensor([[0.]]),
# tensor([[0.]]), tensor([[0.]]), tensor([], size=(0, 1)))
if batch_size == 0:
continue
if rs is not None:
torch.set_rng_state(rs)
chunks_output.append(
_flat_vmap(
func,
batch_size,
flat_in_dims,
flat_args,
args_spec,
out_dims,
randomness,
**kwargs,
)
)
flat_output_chunks, arg_spec = _flatten_chunks_output(chunks_output)
# chunked output tensors are held by both `flat_output_chunks` and `chunks_output`.
# eagerly remove the reference from `chunks_output`.
del chunks_output
# concat chunks on out_dim
# Note: We use cast since flat_output_chunks is modified in _concat_chunked_outputs
# to set elements to None after processing
flat_output = _concat_chunked_outputs(
out_dims, arg_spec, cast(list[tuple[Any, ...] | None], flat_output_chunks)
)
# finally unflatten the output
return tree_unflatten(flat_output, arg_spec)
# Vmap refactored helper functions:
def _check_randomness_arg(randomness: str) -> None:
if randomness not in ["error", "different", "same"]:
raise RuntimeError(
f"Only allowed values for randomness are 'error', 'different', or 'same'. Got {randomness}"
)
@contextlib.contextmanager
def vmap_increment_nesting(
batch_size: int, randomness: str
) -> Generator[int, None, None]:
try:
vmap_level = _vmap_increment_nesting(batch_size, randomness)
yield vmap_level
finally:
_vmap_decrement_nesting()
def _flat_vmap(
func: Callable[..., Tensor | tuple[Tensor, ...]],
batch_size: int,
flat_in_dims: list[int | None],
flat_args: list[Any],
args_spec: TreeSpec,
out_dims: out_dims_t,
randomness: str,
**kwargs: Any,
) -> Any:
with vmap_increment_nesting(batch_size, randomness) as vmap_level:
batched_inputs = _create_batched_inputs(
flat_in_dims, flat_args, vmap_level, args_spec
)
batched_outputs = func(*batched_inputs, **kwargs)
return _unwrap_batched(batched_outputs, out_dims, vmap_level, batch_size, func)
# `restore_vmap` is a private helper function. It is vmap but has the following
# differences:
# - instead of returning outputs, it returns an (outputs, out_dims) tuple.
# out_dims is a pytree of same shape as outputs and contains Optional[int]
# specifying where the vmapped dimension, if it exists, is in the corresponding output.
# - does no validation on in_dims or inputs (vmap expects at least one Tensor to be vmapped).
# restore_vmap allows for no inputs to have the vmap dimension
# - does no validation on outputs (vmap expects only Tensor outputs)
# restore_vmap allows for return of arbitrary outputs (not just Tensors)
#
# The TL;DR is that restore_vmap is more general than vmap and has a slightly
# different API. The relaxations are so that we can "pause" vmap in the middle
# of its execution and then "restore" it later (this is what we do in
# the generate_vmap_rule=True implementation of autograd.Function).
#
# restore_vmap can be technically used in the implementation of vmap, but doing
# that refactor is a bit technically challenging because:
# - vmap couples the tensor-wrapping code with error checking
# - vmap's tensor unwrapping code is in C++; we would need to rewrite part of it
# in python because it overlaps with unwrap_batched
def restore_vmap(
func: Callable[..., _R], in_dims: in_dims_t, batch_size: int, randomness: str
) -> Callable[..., tuple[Any, Any]]:
def inner(*args: Any, **kwargs: Any) -> tuple[Any, Any]:
with vmap_increment_nesting(batch_size, randomness) as vmap_level:
batched_inputs = wrap_batched(args, in_dims, vmap_level)
batched_outputs = func(*batched_inputs, **kwargs)
return unwrap_batched(batched_outputs, vmap_level)
return inner
def wrap_batched(
args: tuple[Any, ...], bdims: in_dims_t, level: int
) -> tuple[Any, ...]:
flat_args, spec = tree_flatten(args)
flat_bdims = _broadcast_to_and_flatten(bdims, spec)
if flat_bdims is None:
raise AssertionError("flat_bdims must not be None")
result = _create_batched_inputs(flat_bdims, flat_args, level, spec)
return result
def unwrap_batched(args: Any, level: int) -> tuple[Any, Any]:
flat_args, spec = tree_flatten(args)
if len(flat_args) == 0:
return args, ()
result = [
(
torch._C._functorch._unwrap_batched(arg, level)
if isinstance(arg, torch.Tensor)
else (arg, None)
)
for arg in flat_args
]
output, bdims = zip(*result)
return tree_unflatten(output, spec), tree_unflatten(bdims, spec)