# # 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. # import copy from collections import OrderedDict from polygraphy import mod, util from polygraphy.common import TensorMetadata from polygraphy.datatype import DataType from polygraphy.logger import G_LOGGER, LogMode gs = mod.lazy_import("onnx_graphsurgeon") onnx = mod.lazy_import("onnx") onnx_numpy_helper = mod.lazy_import("onnx.numpy_helper") def get_num_nodes(model): def _get_num_graph_nodes(graph): num_nodes = len(graph.node) for node in graph.node: for attr in node.attribute: if attr.type == onnx.AttributeProto.GRAPH: num_nodes += _get_num_graph_nodes(attr.g) elif attr.type == onnx.AttributeProto.GRAPHS: for subgraph in attr.graphs: num_nodes += _get_num_graph_nodes(subgraph) return num_nodes return _get_num_graph_nodes(model.graph) def all_tensor_names(model, include_inputs=None): include_inputs = util.default(include_inputs, False) all_outputs = [ output for node in model.graph.node if node.op_type != "Constant" for output in node.output ] if include_inputs: all_outputs += [inp.name for inp in model.graph.input] all_outputs = util.unique_list(all_outputs) return all_outputs def _check_has_tensors(model, outputs): all_outputs = all_tensor_names(model, include_inputs=True) util.check_sequence_contains( all_outputs, outputs, name="the model", items_name="outputs", check_extra=False ) def mark_outputs(model, outputs): # Clear the old outputs while model.graph.output: model.graph.output.pop() outputs = util.unique_list(outputs) _check_has_tensors(model, outputs) value_info_map = {t.name: t for t in model.graph.value_info} out_tensors = [] for output in outputs: value_info = value_info_map.get( output, onnx.helper.make_empty_tensor_value_info(output) ) out_tensors.append(value_info) G_LOGGER.ultra_verbose(f"Marked output tensors in ONNX model: {out_tensors}") model.graph.output.extend(out_tensors) return model def mark_layerwise(model): # Add all non-constant node outputs as graph outputs model = mark_outputs(model, all_tensor_names(model)) return model def unmark_outputs(model, outputs): outputs = util.unique_list(outputs) _check_has_tensors(model, outputs) cur_outputs = [] while model.graph.output: cur_outputs.append(model.graph.output.pop()) cur_outputs = list(reversed(cur_outputs)) # Preserve ordering for out in cur_outputs: if out.name not in outputs: model.graph.output.extend([out]) return model def get_shape(tensor): shape = [] if isinstance(tensor, onnx.TensorProto): shape = tensor.dims else: for dim in tensor.type.tensor_type.shape.dim: if dim.HasField("dim_param"): shape.append(dim.dim_param) elif dim.HasField("dim_value"): shape.append(dim.dim_value) else: shape.append(-1) return shape def get_dtype(tensor): if isinstance(tensor, onnx.TensorProto): onnx_type = tensor.data_type else: onnx_type = tensor.type.tensor_type.elem_type return DataType.from_dtype(onnx_type, source_module="onnx") def get_values(tensor): try: return onnx_numpy_helper.to_array(tensor) except Exception as err: G_LOGGER.error( f"Failed to load weights.\nNote: Error was: {err}", mode=LogMode.ONCE ) return "" def get_tensor_metadata(tensors): metadata = TensorMetadata() for tensor in tensors: metadata.add(name=tensor.name, dtype=get_dtype(tensor), shape=get_shape(tensor)) return metadata def get_input_metadata(graph): # Some "inputs" are actually weights with initalizers, so we need to eliminate those. initializer_names = {tensor.name for tensor in graph.initializer} input_tensors = [ tensor for tensor in graph.input if tensor.name not in initializer_names ] return get_tensor_metadata(input_tensors) def get_output_metadata(graph): return get_tensor_metadata(graph.output) def str_from_onnx(model, show_layers=None, show_attrs=None, show_weights=None): """ Converts an ONNX Graph to a human-readable representation Args: graph (onnx.GraphProto): The onnx graph. show_layers (bool): Whether to display per-layer information. show_attrs (bool): Whether to display per-layer attributes. show_weights (bool): Whether to display the value of weights. Returns: str """ show_layers = util.default(show_layers, False) show_attrs = util.default(show_attrs, False) show_weights = util.default(show_weights, False) def get_opset(): default_opset = "Unknown" other_opsets = {} for info in model.opset_import: if not info.domain: default_opset = info.version else: other_opsets[info.domain] = info.version return default_opset, other_opsets default_opset, other_opsets = get_opset() onnx_str = "" onnx_str += f"Name: {model.graph.name} | ONNX Opset: {default_opset}" if other_opsets: onnx_str += f" | Other Opsets: {other_opsets}" onnx_str += "\n\n" onnx_str += str_from_onnx_graph( model.graph, tensors={}, show_layers=show_layers, show_attrs=show_attrs, show_weights=show_weights, ) return onnx_str def str_from_onnx_graph( graph, tensors, show_layers, show_attrs, show_weights, indent_level=0 ): input_metadata = get_input_metadata(graph) output_metadata = get_output_metadata(graph) initializer_metadata = get_tensor_metadata(graph.initializer) # Subgraph inputs should remain separate from each other, hence copy the tensors map tensors = copy.copy(tensors) tensors.update(get_tensor_metadata(graph.value_info)) tensors.update(initializer_metadata) tensors.update(input_metadata) tensors.update(output_metadata) graph_type = "Graph" if indent_level == 0 else "Subgraph" onnx_str = "" if show_attrs and graph.doc_string: onnx_str += f"---- Docstring ----\n{graph.doc_string}\n\n" onnx_str += ( f"---- {len(input_metadata)} {graph_type} Input(s) ----\n{input_metadata}\n\n" ) onnx_str += f"---- {len(output_metadata)} {graph_type} Output(s) ----\n{output_metadata}\n\n" onnx_str += f"---- {len(initializer_metadata)} Initializer(s) ----\n" if show_weights: for init in graph.initializer: onnx_str += f"Initializer | {init.name} [dtype={get_dtype(init)}, shape={get_shape(init)}] | Values:\n{util.indent_block(str(get_values(init)))}\n\n" if not graph.initializer: onnx_str += "{}\n\n" elif show_layers: onnx_str += str(initializer_metadata) onnx_str += "\n\n" else: onnx_str += "\n" def get_names_and_meta(names): names_lst = [] metadata = TensorMetadata() for name in names: dtype, shape = tensors.get(name, (None, None)) if name in initializer_metadata: name = f"Initializer | {name}" names_lst.append(name) metadata.add(name=name, dtype=dtype, shape=shape) return names_lst, metadata # Maps values from the AttributeType enum to their string representations, e.g., {1: "FLOAT"} ATTR_TYPE_MAPPING = dict( zip( onnx.AttributeProto.AttributeType.values(), onnx.AttributeProto.AttributeType.keys(), ) ) # Maps an ONNX attribute to the corresponding Python property ONNX_PYTHON_ATTR_MAPPING = { "FLOAT": "f", "INT": "i", "STRING": "s", "TENSOR": "t", "GRAPH": "g", "FLOATS": "floats", "INTS": "ints", "STRINGS": "strings", } def attrs_to_dict(attrs): attr_dict = OrderedDict() for attr in attrs: def process_attr(attr_str: str): processed = getattr(attr, ONNX_PYTHON_ATTR_MAPPING[attr_str]) if attr_str == "STRING": processed = processed.decode() elif attr_str == "TENSOR": tensor_str = f"Tensor: [dtype={get_dtype(processed)}, shape={get_shape(processed)}]" if show_weights: tensor_str += " | Values:\n" + util.indent_block( str(get_values(processed)) ) processed = tensor_str elif attr_str == "GRAPH": processed = "\n" + str_from_onnx_graph( processed, tensors, indent_level=indent_level + 2, show_layers=show_layers, show_attrs=show_attrs, show_weights=show_weights, ) elif attr_str == "FLOATS" or attr_str == "INTS": # Proto hacky list to normal Python list processed = [p for p in processed] elif attr_str == "STRINGS": processed = [p.decode() for p in processed] return processed if attr.type in ATTR_TYPE_MAPPING: attr_str = ATTR_TYPE_MAPPING[attr.type] if attr_str in ONNX_PYTHON_ATTR_MAPPING: attr_dict[attr.name] = process_attr(attr_str) else: G_LOGGER.warning( f"Attribute of type {attr_str} is currently unsupported. Skipping attribute." ) else: G_LOGGER.warning( f"Attribute type: {attr.type} was not recognized. Was the graph generated with a newer IR version than the installed `onnx` package? Skipping attribute." ) return attr_dict onnx_str += f"---- {len(graph.node)} Node(s) ----\n" if show_layers: for index, node in enumerate(graph.node): input_names, input_meta = get_names_and_meta(node.input) output_names, output_meta = get_names_and_meta(node.output) onnx_str += util.str_from_layer( "Node", index, node.name, node.op_type, input_names, input_meta, output_names, output_meta, ) if show_attrs: attrs = attrs_to_dict(node.attribute) if attrs: onnx_str += util.indent_block("---- Attributes ----") + "\n" for key, val in attrs.items(): attr_str = "" if node.name: attr_str += f"{node.name}." onnx_str += util.indent_block(f"{attr_str}{key} = {val}") + "\n" onnx_str += "\n" return util.indent_block(onnx_str, indent_level) ## ## ONNX-GraphSurgeon utilities ## def meta_from_gs_tensors(tensors): """Get TensorMetadata from a list of ONNX-GraphSurgeon tensors""" meta = TensorMetadata() for tensor in tensors: meta.add(tensor.name, tensor.dtype, tensor.shape) return meta def set_shapes_from_layerwise_meta(graph, layerwise_meta): """ Args: graph (gs.Graph): An ONNX graphsurgeon graph. layerwise_meta (TensorMetadata): Metadata for tensors in the graph. """ for tensor in graph.tensors().values(): if isinstance(tensor, gs.Variable) and tensor.name in layerwise_meta: tensor.shape = layerwise_meta[tensor.name].shape tensor.dtype = DataType.to_dtype( DataType.from_dtype(layerwise_meta[tensor.name].dtype), "onnx" ) def lower_constant_nodes(graph): """Converts the outputs of Constant nodes into constant tensors, removing the nodes""" remove_nodes = set() with graph.node_ids(): for node in graph.nodes: if node.op == "Constant" and "value" in node.attrs: node.outputs[0].to_constant(node.attrs["value"].values) remove_nodes.add(node.id) # Iterate from the end so we don't shift the list under us. for node_id in sorted(remove_nodes, reverse=True): del graph.nodes[node_id] return graph def get_unbounded_dds_tensors(graph): graph.toposort() # A dict of operators that might produce a output tensor with unbounded DDS, when the value of the input tensor # at the corresponding index is a runtime value. For example, "Range" => "1" means that if the input 1 of the Range # operator is a runtime value, e.g. not a const tensor or an initializer, then the Range output tensor size is unbounded. dispatcher_dict = { "Range": [1], # the limit input of the Range operator "Pad": [1], # the pads input of the Pad operator "Resize": [3], # the sizes input of the Resize operator "Tile": [1], # the repeats input of the Tile operator "Expand": [1], # the shape input of the Expand operator } # Check if the given operator produces a output tensor with unbounded DDS. def check_op(node, const_tensor_set): # Check if the operator is inside the dispatcher dict. if node.op in dispatcher_dict: input_idx_list = dispatcher_dict[node.op] for input_idx in input_idx_list: if input_idx < len(node.inputs): input_tensor = node.inputs[input_idx] # Check if the corresponding input tensor is a runtime value and its producer is not Min operator. # If a tensor is produced by a Min operator, its upper bound has already been set. if ( input_tensor.name not in const_tensor_set and len(input_tensor.inputs) >= 1 and input_tensor.inputs[0].op != "Min" ): return input_tensor return None # Find all constant tensors. def get_const_tensors(graph): return { tensor.name for tensor in graph.tensors().values() if isinstance(tensor, gs.Constant) } # Find all dynamic shape symbols, customers will set upper bounds for these symbols when building the model in TensorRT. def get_dynamic_shapes(graph): dynamic_shape_set = set() for tensor in graph.inputs: for shape in tensor.shape: if isinstance(shape, str): dynamic_shape_set.add(shape) return dynamic_shape_set # Find all tensors with unbounded DDS. def get_target_tensors(graph): # Find dynamic shapes, these shapes should have upper bounds in TensorRT. dynamic_shape_set = get_dynamic_shapes(graph) # Find const tensors. For those operators in the dispatch dict, constant inputs will not introduce outputs with unbounded DDS. const_tensor_set = get_const_tensors(graph) # Our target is to find those input tensors that cause its consumer nodes generated unbounded outputs. # If a tensor has named dimensions that appeared before in its symbolic shape, it means that the shape is *not* data dependent, # and so will have an upper bound. target_tensor_names = set() target_tensor_list = [] for node in graph.nodes: check_node = False # Check if the node's output contains a new introduced dynamic shape. for tensor in node.outputs: # Always check nodes if tensor.shape is None. # This happens when the symbolic inference does not work correctly due to some restrictions. if tensor.shape is None: check_node = True else: for shape in tensor.shape: # If a shape is a dynamic shape, then it is a str. # Only check the node that first introduced the dynamic shape. if isinstance(shape, str) and shape not in dynamic_shape_set: dynamic_shape_set.add(shape) check_node = True # Check if the node will generate an unbounded output size. if check_node: target_tensor = check_op(node, const_tensor_set) # Avoid duplication. if ( target_tensor is not None and target_tensor.name not in target_tensor_names ): target_tensor_names.add(target_tensor.name) target_tensor_list.append(target_tensor) return target_tensor_list return get_target_tensors(graph)