611 lines
24 KiB
Python
611 lines
24 KiB
Python
#
|
|
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
|
|
from typing import Dict, List, Union
|
|
import copy
|
|
|
|
from onnx_graphsurgeon.ir.graph import Constant, Graph, Node
|
|
from onnx_graphsurgeon.logger import G_LOGGER
|
|
|
|
|
|
class PatternMapping(dict):
|
|
"""
|
|
Represents a graph pattern mapping result.
|
|
"""
|
|
|
|
def __init__(self, onnx_node=None) -> None:
|
|
super().__init__()
|
|
self.onnx_node = onnx_node
|
|
|
|
self.inputs = []
|
|
self.outputs = []
|
|
if onnx_node is not None:
|
|
self.inputs = onnx_node.inputs
|
|
self.outputs = onnx_node.outputs
|
|
|
|
self.constants = dict() # constant name -> onnx tensor mapping
|
|
|
|
def set_input_onnx_tensor(self, onnx_tensor, index):
|
|
length = len(self.inputs)
|
|
for _ in range(index - length + 1):
|
|
self.inputs.append(None)
|
|
if (
|
|
self.inputs[index] is not None
|
|
and self.inputs[index].name != onnx_tensor.name
|
|
):
|
|
return False # This input tensor has been set up by another onnx tensor
|
|
self.inputs[index] = onnx_tensor
|
|
return True
|
|
|
|
def set_output_onnx_tensor(self, onnx_tensor, index):
|
|
length = len(self.outputs)
|
|
for _ in range(index - length + 1):
|
|
self.outputs.append(None)
|
|
if (
|
|
self.outputs[index] is not None
|
|
and self.outputs[index].name != onnx_tensor.name
|
|
):
|
|
return False # This output tensor has been set up by another onnx tensor
|
|
self.outputs[index] = onnx_tensor
|
|
return True
|
|
|
|
def set_constant_onnx_tensor(self, onnx_tensor, name):
|
|
if name in self.constants and self.constants[name].name != onnx_tensor.name:
|
|
return False
|
|
self.constants[name] = onnx_tensor
|
|
return True
|
|
|
|
def _get_node(self):
|
|
return self.onnx_node
|
|
|
|
def get(self, name: str):
|
|
"""
|
|
Retrieve a pattern-to-graph mapping given the pattern node name.
|
|
|
|
Args:
|
|
name (str): The name of the pattern node. The pattern node can be a single op node or a subpattern.
|
|
|
|
Returns:
|
|
PatternMapping for a subpattern node or gs.Node for a single op node.
|
|
"""
|
|
if self[name].onnx_node is not None:
|
|
return self[name].onnx_node
|
|
|
|
return self[name]
|
|
|
|
def __str__(self) -> str:
|
|
if self.onnx_node is None:
|
|
return (
|
|
"{"
|
|
+ str.join(
|
|
", ", [f"{key}: {str(value)}" for key, value in self.items()]
|
|
)
|
|
+ "}"
|
|
)
|
|
return self.onnx_node.name
|
|
|
|
|
|
class GraphPattern:
|
|
"""
|
|
Represent a graph pattern.
|
|
|
|
Example:
|
|
::
|
|
|
|
pattern = GraphPattern()
|
|
conv = pattern.add("Conv")
|
|
leaky_relu = pattern.add("LeakyReLU", inputs=[conv], check_func=lambda node: node.attrs["alpha"] < 1.0)
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.op = None # op (str)
|
|
self.check_func = None # callback function for single node
|
|
# pattern node name -> GraphPattern nodes(single or subpattern)
|
|
self.nodes: Dict[str, "GraphPattern"] = dict()
|
|
# pattern node name -> input tensors
|
|
self.node_inputs: Dict[str, List[int]] = dict()
|
|
# pattern node name -> output tensors
|
|
self.node_outputs: Dict[str, List[int]] = dict()
|
|
self.num_tensors = 0 # number of all tensors in the pattern
|
|
self.tensor_inputs: Dict[int, List[str]] = dict() # tensor id -> input node
|
|
self.tensor_outputs: Dict[int, List[str]] = dict() # tensor id -> output nodes
|
|
self.input_tensors: List[int] = [] # a list of input tensor ids of this pattern
|
|
self.output_tensors: List[int] = []
|
|
# tensor id -> tensor name of constant tensors.
|
|
self.constant_tensors: Dict[int, str] = dict()
|
|
|
|
def _add_tensor(self, input_node=None) -> int:
|
|
tensor_id = self.num_tensors
|
|
self.tensor_inputs[tensor_id] = []
|
|
if input_node is not None:
|
|
self.tensor_inputs[tensor_id].append(input_node)
|
|
self.tensor_outputs[tensor_id] = []
|
|
|
|
self.num_tensors += 1
|
|
return tensor_id
|
|
|
|
def variable(self) -> int:
|
|
"""
|
|
Add a variable tensor without a input node - This tensor will be an input tensor of this graph pattern.
|
|
|
|
Return:
|
|
int: the tensor id.
|
|
"""
|
|
tensor_id = self._add_tensor()
|
|
self.input_tensors.append(tensor_id)
|
|
return tensor_id
|
|
|
|
def constant(self, name=None) -> int:
|
|
"""
|
|
Add a constant tensor. If name is not provided, a default name will be assigned.
|
|
|
|
Args:
|
|
name(str): the constant tensor name
|
|
|
|
Return:
|
|
int: the tensor id.
|
|
"""
|
|
tensor_id = self._add_tensor()
|
|
if name is None:
|
|
name = f"unnamed_constant_tensor_{tensor_id}"
|
|
self.constant_tensors[tensor_id] = name
|
|
return tensor_id
|
|
|
|
def set_output_tensors(self, output_tensors) -> None:
|
|
for tensor_id in output_tensors:
|
|
assert tensor_id in self.tensor_inputs
|
|
self.output_tensors = output_tensors
|
|
|
|
def _init_single_node(self, op, check_func=None) -> None:
|
|
self.op = op
|
|
self.check_func = check_func
|
|
|
|
def add(
|
|
self,
|
|
name: str,
|
|
op: Union["GraphPattern", str],
|
|
check_func=None,
|
|
inputs=None,
|
|
num_output_tensors=1,
|
|
):
|
|
"""
|
|
Add an op node or a subpattern node to the current pattern.
|
|
|
|
Args:
|
|
name (str): the node name.
|
|
op (Union[GraphPattern, str]): the GraphPattern instance if adding a subpattern node or the op name if adding a single op node.
|
|
check_func (function): the callback function for additional matching rules of an op node if adding a single op node.
|
|
inputs (list): the list of input tensors. If this node is a sub-pattern, the sequence of this list should align with the sequence of the sub-pattern's input tensors.
|
|
num_output_tensors (int): number of output tensors
|
|
|
|
Return:
|
|
tuple(int) or int or None: output tensors.
|
|
"""
|
|
assert self.op is None
|
|
assert name not in self.nodes
|
|
|
|
if inputs is None:
|
|
inputs = []
|
|
|
|
if isinstance(op, str):
|
|
op_name = op
|
|
op = GraphPattern()
|
|
op._init_single_node(op_name, check_func)
|
|
|
|
self.nodes[name] = op
|
|
|
|
self.node_inputs[name] = inputs
|
|
|
|
self.node_outputs[name] = []
|
|
for _ in range(num_output_tensors):
|
|
self.node_outputs[name].append(self._add_tensor(input_node=name))
|
|
|
|
for input in inputs:
|
|
self.tensor_outputs[input].append(name)
|
|
|
|
if len(self.node_outputs[name]) == 0:
|
|
return None
|
|
elif len(self.node_outputs[name]) == 1:
|
|
return self.node_outputs[name][0]
|
|
return tuple(self.node_outputs[name])
|
|
|
|
def _get_inbound(self, tensor_index):
|
|
if len(self.input_tensors) > tensor_index:
|
|
tensor_id = self.input_tensors[tensor_index]
|
|
if len(self.tensor_outputs[tensor_id]):
|
|
inbound_node = self.tensor_outputs[tensor_id][0]
|
|
return tensor_id, inbound_node
|
|
return None, None
|
|
|
|
def _get_outbound(self, tensor_index):
|
|
if len(self.output_tensors) > tensor_index:
|
|
tensor_id = self.output_tensors[tensor_index]
|
|
if len(self.tensor_inputs[tensor_id]):
|
|
outbound_node = self.tensor_inputs[tensor_id][0]
|
|
return tensor_id, outbound_node
|
|
return None, None
|
|
|
|
def _single_node_match(self, onnx_node: Node) -> bool:
|
|
assert self.op is not None
|
|
with G_LOGGER.indent():
|
|
if self.op != onnx_node.op:
|
|
G_LOGGER.info(
|
|
"No match because: Op did not match. Node op was: {:} but pattern op was: {:}.".format(
|
|
onnx_node.op, self.op
|
|
)
|
|
)
|
|
return False
|
|
if self.check_func is not None:
|
|
if not self.check_func(onnx_node):
|
|
G_LOGGER.info("No match because: check_func returned false.")
|
|
return False
|
|
G_LOGGER.info(
|
|
"Single node is matched: {:}, {:}".format(self.op, onnx_node.name)
|
|
)
|
|
return True
|
|
|
|
def _get_tensor_index_for_node(
|
|
self, node: str, tensor_id: int, is_node_input: bool
|
|
):
|
|
if is_node_input:
|
|
return self.node_inputs[node].index(tensor_id)
|
|
else:
|
|
return self.node_outputs[node].index(tensor_id)
|
|
|
|
def get_inbound_or_outbound_onnx_node(
|
|
self, mapping: PatternMapping, is_inbound: bool, tensor_index: int
|
|
):
|
|
if self.op is not None:
|
|
onnx_node = mapping._get_node()
|
|
return onnx_node
|
|
if is_inbound:
|
|
inbound_tensor, inbound_node = self._get_inbound(tensor_index)
|
|
if inbound_node is not None:
|
|
return self.nodes[inbound_node].get_inbound_or_outbound_onnx_node(
|
|
mapping[inbound_node],
|
|
is_inbound=True,
|
|
tensor_index=self._get_tensor_index_for_node(
|
|
inbound_node, inbound_tensor, is_node_input=True
|
|
),
|
|
)
|
|
|
|
else:
|
|
outbound_tensor, outbound_node = self._get_outbound(tensor_index)
|
|
if outbound_node is not None:
|
|
return self.nodes[outbound_node].get_inbound_or_outbound_onnx_node(
|
|
mapping[outbound_node],
|
|
is_inbound=False,
|
|
tensor_index=self._get_tensor_index_for_node(
|
|
outbound_node, outbound_tensor, is_node_input=False
|
|
),
|
|
)
|
|
return None
|
|
|
|
# Match an onnx node and its subgraph with the current pattern.
|
|
def match(
|
|
self,
|
|
onnx_node: Node,
|
|
from_inbound: bool,
|
|
from_tensor_index: int,
|
|
mapped_onnx_nodes: set,
|
|
onnx_graph_output_tensors: set,
|
|
):
|
|
if onnx_node.id in mapped_onnx_nodes:
|
|
return None
|
|
if self.op is not None: # is single node
|
|
if self._single_node_match(onnx_node):
|
|
mapped_onnx_nodes.add(onnx_node.id)
|
|
return PatternMapping(onnx_node=onnx_node)
|
|
else:
|
|
return None
|
|
|
|
initial_node = None
|
|
if from_inbound:
|
|
from_tensor, initial_node = self._get_inbound(from_tensor_index)
|
|
else:
|
|
from_tensor, initial_node = self._get_outbound(from_tensor_index)
|
|
assert initial_node is not None
|
|
|
|
mapping = PatternMapping()
|
|
match = self._match_node(
|
|
initial_node,
|
|
onnx_node,
|
|
from_tensor,
|
|
mapping,
|
|
mapped_onnx_nodes,
|
|
onnx_graph_output_tensors,
|
|
from_inbound,
|
|
)
|
|
if match:
|
|
# If we entered this subpattern via an inbound boundary tensor, ensure
|
|
# that all internal consumers of the boundary input tensor are covered.
|
|
# This supports cases where a single subpattern input fans out to multiple
|
|
# internal nodes.
|
|
if from_inbound:
|
|
# Determine the boundary ONNX tensor corresponding to this inbound edge
|
|
tensor_index_for_node = self._get_tensor_index_for_node(
|
|
initial_node, from_tensor, is_node_input=True
|
|
)
|
|
if tensor_index_for_node >= len(onnx_node.inputs):
|
|
return None
|
|
boundary_onnx_tensor = onnx_node.inputs[tensor_index_for_node]
|
|
|
|
# All internal consumer nodes of the boundary input tensor
|
|
internal_consumers = list(self.tensor_outputs.get(from_tensor, []))
|
|
|
|
# Build candidate ONNX consumer nodes for the boundary tensor
|
|
candidate_onnx_consumers = list(getattr(boundary_onnx_tensor, "outputs", []))
|
|
used_consumer_ids = set()
|
|
|
|
# Mark already mapped consumers as used if applicable
|
|
for consumer_node in internal_consumers:
|
|
if consumer_node in mapping:
|
|
inbound_tensor_index = self._get_tensor_index_for_node(
|
|
consumer_node, from_tensor, is_node_input=True
|
|
)
|
|
inbound_mapped = self.nodes[consumer_node].get_inbound_or_outbound_onnx_node(
|
|
mapping[consumer_node], is_inbound=True, tensor_index=inbound_tensor_index
|
|
)
|
|
if inbound_mapped is None:
|
|
return None
|
|
# Ensure the mapped node is indeed one of the boundary tensor's consumers
|
|
if all(c.id != inbound_mapped.id for c in candidate_onnx_consumers):
|
|
return None
|
|
used_consumer_ids.add(inbound_mapped.id)
|
|
|
|
# For remaining internal consumers not yet mapped, try to match them to unused ONNX consumers
|
|
for consumer_node in internal_consumers:
|
|
if consumer_node in mapping:
|
|
continue
|
|
matched_this_consumer = False
|
|
for c in candidate_onnx_consumers:
|
|
if c.id in used_consumer_ids or c.id in mapped_onnx_nodes:
|
|
continue
|
|
# Try to extend mapping with this branch. Use copies to allow backtracking on failure.
|
|
mapping_copy = copy.copy(mapping)
|
|
mapped_onnx_nodes_copy = set(mapped_onnx_nodes)
|
|
ok = self._match_node(
|
|
consumer_node,
|
|
c,
|
|
from_tensor,
|
|
mapping_copy,
|
|
mapped_onnx_nodes_copy,
|
|
onnx_graph_output_tensors,
|
|
from_inbound=True,
|
|
)
|
|
if ok:
|
|
mapping.clear()
|
|
for _k, _v in mapping_copy.items():
|
|
mapping[_k] = _v
|
|
mapping.inputs = mapping_copy.inputs
|
|
mapping.outputs = mapping_copy.outputs
|
|
mapping.constants = mapping_copy.constants
|
|
mapped_onnx_nodes = mapped_onnx_nodes_copy
|
|
used_consumer_ids.add(c.id)
|
|
matched_this_consumer = True
|
|
break
|
|
if not matched_this_consumer:
|
|
return None
|
|
|
|
return mapping
|
|
else:
|
|
return None
|
|
|
|
# Match an onnx node and its subgraph with a starting pattern node(can be a subpattern node or a single node) and its subgraph. This is the actual dfs.
|
|
def _match_node(
|
|
self,
|
|
node_name: str,
|
|
onnx_node: Node,
|
|
from_tensor: int,
|
|
mapping: PatternMapping,
|
|
mapped_onnx_nodes: set,
|
|
onnx_graph_output_tensors: set,
|
|
from_inbound: bool,
|
|
) -> bool:
|
|
with G_LOGGER.indent():
|
|
G_LOGGER.info(
|
|
"Checking node: {:} against pattern node: {:}.".format(
|
|
onnx_node.name, node_name
|
|
)
|
|
)
|
|
tensor_index_for_node = self._get_tensor_index_for_node(
|
|
node_name, from_tensor, is_node_input=from_inbound
|
|
)
|
|
subgraph_mapping = self.nodes[node_name].match(
|
|
onnx_node,
|
|
from_inbound,
|
|
tensor_index_for_node,
|
|
mapped_onnx_nodes,
|
|
onnx_graph_output_tensors,
|
|
)
|
|
if subgraph_mapping is not None:
|
|
mapping[node_name] = subgraph_mapping
|
|
else:
|
|
return False
|
|
|
|
input_onnx_tensors = subgraph_mapping.inputs
|
|
if len(input_onnx_tensors) != len(self.node_inputs[node_name]):
|
|
return False # Number of node inputs should equal to number of input onnx tensors of the node.
|
|
for node_input_tensor, onnx_tensor in zip(
|
|
self.node_inputs[node_name], input_onnx_tensors
|
|
):
|
|
if onnx_tensor is None:
|
|
return False
|
|
# tensor paired up.
|
|
if node_input_tensor in self.input_tensors:
|
|
if not mapping.set_input_onnx_tensor(
|
|
onnx_tensor, self.input_tensors.index(node_input_tensor)
|
|
):
|
|
return False # this tensor is mapped to another onnx tensor
|
|
continue
|
|
if node_input_tensor in self.constant_tensors:
|
|
if not isinstance(onnx_tensor, Constant):
|
|
return False # constant tensor not match
|
|
if not mapping.set_constant_onnx_tensor(
|
|
onnx_tensor, self.constant_tensors[node_input_tensor]
|
|
):
|
|
# this constant tensor is mapped to another onnx tensor
|
|
return False
|
|
continue
|
|
if len(self.tensor_inputs[node_input_tensor]) != len(onnx_tensor.inputs):
|
|
return False
|
|
for input_node, input_onnx_node in zip(
|
|
self.tensor_inputs[node_input_tensor], onnx_tensor.inputs
|
|
):
|
|
# dfs ends when revisiting a node. We need to check if the edges are matched.
|
|
if input_node in mapping:
|
|
outbound_tensor_index = self._get_tensor_index_for_node(
|
|
input_node, node_input_tensor, is_node_input=False
|
|
)
|
|
outbound_onnx_node_of_input_node = self.nodes[
|
|
input_node
|
|
].get_inbound_or_outbound_onnx_node(
|
|
mapping[input_node],
|
|
is_inbound=False,
|
|
tensor_index=outbound_tensor_index,
|
|
)
|
|
if (
|
|
outbound_onnx_node_of_input_node is None
|
|
or outbound_onnx_node_of_input_node.name != input_onnx_node.name
|
|
):
|
|
return False
|
|
continue
|
|
match = self._match_node(
|
|
input_node,
|
|
input_onnx_node,
|
|
node_input_tensor,
|
|
mapping,
|
|
mapped_onnx_nodes,
|
|
onnx_graph_output_tensors,
|
|
from_inbound=False,
|
|
)
|
|
if not match:
|
|
return False
|
|
|
|
output_onnx_tensors = subgraph_mapping.outputs
|
|
if len(output_onnx_tensors) != len(self.node_outputs[node_name]):
|
|
return False # Number of node outputs should be equal to number of output onnx tensors of the node.
|
|
for node_output_tensor, onnx_tensor in zip(
|
|
self.node_outputs[node_name], output_onnx_tensors
|
|
):
|
|
if onnx_tensor is None:
|
|
return False
|
|
# tensor matched
|
|
if node_output_tensor in self.output_tensors:
|
|
if not mapping.set_output_onnx_tensor(
|
|
onnx_tensor, self.output_tensors.index(node_output_tensor)
|
|
):
|
|
return False # this tensor is mapped to another onnx tensor
|
|
continue
|
|
if onnx_tensor.name in onnx_graph_output_tensors:
|
|
return False # The pattern tensor is not an output but the onnx tensor is an output tensor of the onnx graph.
|
|
|
|
# Flexible consumer matching: each pattern consumer must map to some ONNX consumer,
|
|
# but the ONNX tensor may have additional consumers we can ignore.
|
|
pattern_consumers = list(self.tensor_outputs[node_output_tensor])
|
|
candidate_consumers = list(getattr(onnx_tensor, "outputs", []))
|
|
used_candidate_ids = set()
|
|
|
|
# First validate already mapped consumers
|
|
for output_node in pattern_consumers:
|
|
if output_node in mapping:
|
|
inbound_tensor_index = self._get_tensor_index_for_node(
|
|
output_node, node_output_tensor, is_node_input=True
|
|
)
|
|
inbound_onnx_node_of_output_node = self.nodes[
|
|
output_node
|
|
].get_inbound_or_outbound_onnx_node(
|
|
mapping[output_node],
|
|
is_inbound=True,
|
|
tensor_index=inbound_tensor_index,
|
|
)
|
|
if (
|
|
inbound_onnx_node_of_output_node is None
|
|
or all(c.id != inbound_onnx_node_of_output_node.id for c in candidate_consumers)
|
|
):
|
|
return False
|
|
used_candidate_ids.add(inbound_onnx_node_of_output_node.id)
|
|
|
|
# Then match remaining consumers greedily with backtracking via copies
|
|
for output_node in pattern_consumers:
|
|
if output_node in mapping:
|
|
continue
|
|
matched_output_consumer = False
|
|
for oc in candidate_consumers:
|
|
if oc.id in used_candidate_ids or oc.id in mapped_onnx_nodes:
|
|
continue
|
|
mapping_copy = copy.copy(mapping)
|
|
mapped_onnx_nodes_copy = set(mapped_onnx_nodes)
|
|
ok = self._match_node(
|
|
output_node,
|
|
oc,
|
|
node_output_tensor,
|
|
mapping_copy,
|
|
mapped_onnx_nodes_copy,
|
|
onnx_graph_output_tensors,
|
|
from_inbound=True,
|
|
)
|
|
if ok:
|
|
# Adopt mapping changes in-place so caller's reference sees updates
|
|
mapping.clear()
|
|
for _k, _v in mapping_copy.items():
|
|
mapping[_k] = _v
|
|
mapping.inputs = mapping_copy.inputs
|
|
mapping.outputs = mapping_copy.outputs
|
|
mapping.constants = mapping_copy.constants
|
|
mapped_onnx_nodes = mapped_onnx_nodes_copy
|
|
used_candidate_ids.add(oc.id)
|
|
matched_output_consumer = True
|
|
break
|
|
if not matched_output_consumer:
|
|
return False
|
|
# For non-pattern-output tensors, disallow extra external consumers.
|
|
# All consumers of this ONNX tensor must be accounted for by the pattern.
|
|
if node_output_tensor not in self.output_tensors:
|
|
for oc in candidate_consumers:
|
|
if oc.id not in used_candidate_ids:
|
|
return False
|
|
return True
|
|
|
|
def match_all(self, graph: Graph) -> List[PatternMapping]:
|
|
"""
|
|
Find all the matched instances of subgraph with the current pattern in the given graph.
|
|
|
|
Args:
|
|
graph (Graph): the graph to match.
|
|
|
|
Return:
|
|
List[PatternMapping]: list of mappings.
|
|
"""
|
|
mappings = []
|
|
onnx_graph_output_tensors = set(tensor.name for tensor in graph.outputs)
|
|
with graph.node_ids():
|
|
for node in graph.nodes:
|
|
G_LOGGER.info("Start a subgraph matching...")
|
|
mapped_onnx_nodes = set()
|
|
mapping = self.match(
|
|
node,
|
|
from_inbound=True,
|
|
from_tensor_index=0,
|
|
mapped_onnx_nodes=mapped_onnx_nodes,
|
|
onnx_graph_output_tensors=onnx_graph_output_tensors,
|
|
)
|
|
if mapping is not None:
|
|
G_LOGGER.info("Found a matched subgraph!")
|
|
mappings.append(mapping)
|
|
return mappings
|