# # 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 typing import List import numpy as np import onnx import onnx_graphsurgeon as gs import pytest from onnx_graphsurgeon.ir.function import Function from onnx_graphsurgeon.ir.graph import Graph from onnx_graphsurgeon.ir.node import Node from onnx_graphsurgeon.ir.tensor import Constant, LazyValues, Tensor, Variable from onnx_graphsurgeon.logger import G_LOGGER from onnx_graphsurgeon.util import misc from onnx_graphsurgeon.util.exception import OnnxGraphSurgeonException from onnx_graphsurgeon.util.misc import SynchronizedList from onnx_models import const_foldable, shape_cast_elision G_LOGGER.severity = G_LOGGER.ULTRA_VERBOSE @Graph.register() def shape(self, inp): return self.layer(op="Shape", inputs=[inp], outputs=["shape_out"])[0] @Graph.register() def cast(self, inp, to): return self.layer(op="Cast", inputs=[inp], outputs=["cast_out"], attrs={"to": to})[ 0 ] @Graph.register() def constant(self, values): return self.layer( op="Constant", inputs=[], outputs=["constant_out"], attrs={"value": Constant("values", values)}, )[0] @Graph.register() def identity(self, inp): out = self.layer(op="Identity", inputs=[inp], outputs=["identity_out"])[0] out.dtype = inp.dtype return out @Graph.register() def relu(self, inp): out = self.layer(op="Relu", inputs=[inp], outputs=["relu_out"])[0] out.dtype = inp.dtype return out @Graph.register() def add(self, a, b, name=None): outputs = [Variable(name=name)] if name else ["add_out"] out = self.layer(op="Add", inputs=[a, b], outputs=outputs)[0] out.dtype = a.dtype or b.dtype return out @Graph.register() def mul(self, a, b, name=None): outputs = [Variable(name=name)] if name else ["mul_out"] out = self.layer(op="Mul", inputs=[a, b], outputs=outputs)[0] out.dtype = a.dtype or b.dtype return out @Graph.register() def less(self, a, b, name=None): outputs = [Variable(name=name)] if name else ["less_out"] out = self.layer(op="Less", inputs=[a, b], outputs=outputs)[0] out.dtype = bool return out # A fake op that can be used to ensure things work even when there is an invalid # node present in the model. @Graph.register() def fake(self, inp, name=None): outputs = [Variable(name=name)] if name else ["fake_out"] out = self.layer(op="Fake", inputs=[inp], outputs=outputs)[0] out.dtype = inp.dtype return out @gs.Graph.register() def gather(self, data, indices): return self.layer(op="Gather", inputs=[data, indices], outputs=["gather_out"])[0] @gs.Graph.register() def slice(self, data, starts=None, ends=None, axes=None, steps=None): inputs = [] for inp in [data, starts, ends, axes, steps]: if inp is None: break inputs.append(inp) return self.layer(op="Slice", inputs=inputs, outputs=["slice_out"])[0] @gs.Graph.register() def nested(self, inp, graph): return self.layer( op="Nested", inputs=[inp], outputs=["nested_out"], attrs={"body": graph} )[0] @gs.Graph.register() def if_op(self, cond, then_graph, else_graph): return self.layer( op="If", inputs=[cond], outputs=["if_out"], attrs={"then_branch": then_graph, "else_branch": else_graph}, )[0] @gs.Graph.register() def tile(self, inp, repeats): out = self.layer(op="Tile", inputs=[inp, repeats], outputs=["tile_out"])[0] out.dtype = inp.dtype return out @gs.Graph.register() def dequantize_linear(self, inp, scale, zero_point, axis=1): out = self.layer( op="DequantizeLinear", inputs=[inp, scale, zero_point], outputs=["dequantize_linear_out"], attrs={"axis": axis}, )[0] out.dtype = np.float32 return out @gs.Graph.register() def quantize_linear(self, inp, out_scale, out_zero_point, axis=1): out = self.layer( op="QuantizeLinear", inputs=[inp, out_scale, out_zero_point], outputs=["quantize_linear_out"], attrs={"axis": axis}, )[0] out.dtype = np.int8 return out @gs.Graph.register() def pad(self, data, pads, constant_value=None): constant_value = misc.default_value(constant_value, Variable.empty()) out = self.layer( op="Pad", inputs=[data, pads, constant_value], outputs=["pad_out"] )[0] out.dtype = data.dtype return out @gs.Graph.register() def softmax(self, data, axis=None): attrs = {} if axis is not None: attrs["axis"] = axis out = self.layer(op="Softmax", inputs=[data], outputs=["softmax_out"], attrs=attrs)[ 0 ] out.dtype = data.dtype return out # Generates a graph where an outer node has no outputs except # within the subgraph. ONNX-GS should recognize that the node # is being used, and should not remove it during cleanup(). def make_nested_graph(): inp = Variable("input") id_out = Variable("id_out") identity = Node(op="Identity", inputs=[inp], outputs=[id_out]) # Subgraph outputs come from the parent node, but nodes in the subgraph # can use nodes from the outer graphs too. subgraph_inputs = [Variable("subgraph_inp")] subgraph_id_out = Variable("subgraph_id_out") subgraph_outputs = [Variable("subgraph_out")] subgraph_identity0 = Node(op="Identity", inputs=[id_out], outputs=[subgraph_id_out]) subgraph_identity1 = Node( op="Identity", inputs=[subgraph_id_out], outputs=subgraph_outputs ) subgraph = Graph( nodes=[subgraph_identity0, subgraph_identity1], inputs=subgraph_inputs, outputs=subgraph_outputs, ) nested_out = Variable("nested_out") nested_node = Node( op="Nested", attrs={"body": subgraph}, inputs=[inp], outputs=[nested_out] ) return Graph(nodes=[identity, nested_node], inputs=[inp], outputs=[nested_out]) @pytest.fixture def nested_graph(): yield make_nested_graph() @pytest.fixture def very_nested_graph(): inner_subgraph_1 = Graph(name="inner_subgraph_1") inner_subgraph_2 = Graph(name="inner_subgraph_2") inner_subgraph_3 = Graph(name="inner_subgraph_3") outer_subgraph_1 = Graph(name="subgraph1") outer_subgraph_2 = Graph( name="subgraph2", nodes=[Node("Add", attrs={"x": inner_subgraph_1, "y": inner_subgraph_2})], ) outer_subgraph_3 = Graph( name="subgraph3", nodes=[Node("Add", attrs={"x": inner_subgraph_3, "y": 3.14})] ) node_1 = Node( op="Add", attrs={ "x": outer_subgraph_1, "y": outer_subgraph_2, "z": 5, "w": outer_subgraph_3, }, ) node_2 = Node(op="Add", attrs={"x": outer_subgraph_3}) return Graph(nodes=[node_1, node_2], name="main_graph") class TestBasic(object): def test_generate_name(self): graph = Graph() generated_names = set() existing_names = { "name_{}".format(i) for i in range(50, 150) } # names_50 to names_149 num_names = 100 for idx in range(num_names): generated_names.add(graph._generate_name("name", existing_names)) assert len(generated_names) == num_names # 100 unique generated_names assert ( len(generated_names.intersection(existing_names)) == 0 ) # no generated_names in existing_names expected_names = {"name_{}".format(i) for i in range(0, 50)} expected_names.update({"name_{}".format(i) for i in range(150, 200)}) assert ( generated_names == expected_names ) # expect 'names_0' to 'names_49', 'names_150' to 'names_199' def test_equal(self, nested_graph): assert nested_graph == nested_graph def test_equal_inputs_unequal(self): g0 = make_nested_graph() g1 = make_nested_graph() g0.inputs.append(Variable("test")) assert not (g0 == g1) def test_equal_outputs_unequal(self): g0 = make_nested_graph() g1 = make_nested_graph() g0.outputs.append(Variable("test")) assert not (g0 == g1) def test_equal_nested_unequal(self): g0 = make_nested_graph() g1 = make_nested_graph() # Changing the nested subgraph should make the graphs unequal g0.nodes[1].inputs[0].name = "subgraph_inp_modified" assert not (g0 == g1) def test_subgraphs_not_recursive(self, very_nested_graph): unrelated_graph = Graph(name="unrelated") subgraph_names = {subgraph.name for subgraph in very_nested_graph.subgraphs()} assert subgraph_names == {"subgraph1", "subgraph2", "subgraph3"} def test_subgraphs_recursive(self, very_nested_graph): unrelated_graph = Graph(name="unrelated") subgraph_names = { subgraph.name for subgraph in very_nested_graph.subgraphs(recursive=True) } assert subgraph_names == { "subgraph1", "subgraph2", "subgraph3", "inner_subgraph_1", "inner_subgraph_2", "inner_subgraph_3", } class TestRegister(object): def test_register(self): @Graph.register() def fake_add(self, a, b): return self.layer(op="Add", inputs=[a, b], outputs=["add_out"]) graph = Graph() [output] = graph.fake_add("a", "b") assert "add_out" in output.name assert len(graph.nodes) == 1 assert graph.nodes[-1].op == "Add" def test_register_opset(self): @Graph.register(opsets=[11]) def fake_add(self, a, b): return self.layer(op="Add", inputs=[a, b], outputs=["add_out"]) @Graph.register(opsets=[10]) def fake_add(self, a, b): return self.layer(op="Add-10", inputs=[a, b], outputs=["add_out"]) graph = Graph() [output] = graph.fake_add("a", "b") assert "add_out" in output.name assert len(graph.nodes) == 1 assert graph.nodes[-1].op == "Add" graph_opset10 = Graph(opset=10) [output] = graph_opset10.fake_add("a", "b") assert "add_out" in output.name assert len(graph_opset10.nodes) == 1 assert graph_opset10.nodes[-1].op == "Add-10" def test_register_name_conflict(self): @Graph.register() def fake_mul(self, a, b): return self.layer( op="Add", domain="domain1", inputs=[a, b], outputs=["mul_out"] ) func = Function("fake_mul", domain="domain2") graph = Graph(functions=[func]) graph.fake_mul("a", "b") assert len(graph.nodes) == 1 assert graph.nodes[0].domain == "domain1" class TestLayer(object): def test_layer_default_naming(self): node1 = Node( name="onnx_graphsurgeon_node_0", op="Identity" ) # injecting default name node2 = Node( name="onnx_graphsurgeon_node_1", op="Identity" ) # injecting default name again graph = Graph(nodes=[node1, node2]) graph.layer( op="Identity" ) # new default name should be onnx_graphsurgeon_node_2 assert graph.nodes[-1].name == "onnx_graphsurgeon_node_2" graph.layer( op="Identity" ) # new default name should be onnx_graphsurgeon_node_3 assert graph.nodes[-1].name == "onnx_graphsurgeon_node_3" def test_layer_with_attrs(self): graph = Graph() outputs = graph.layer(op="Add", name="node", attrs={"fake_attr": 0}) assert len(graph.nodes) == 1 assert graph.nodes[-1].op == "Add" assert graph.nodes[-1].name == "node" assert graph.nodes[-1].attrs["fake_attr"] == 0 def test_layer_with_tensors(self): x0 = Variable("x0") x1 = Variable("x1") y0 = Variable("y0") y1 = Variable("y1") graph = Graph() outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1]) assert outputs == [y0, y1] assert len(graph.nodes) == 1 assert graph.nodes[-1].inputs == [x0, x1] assert graph.nodes[-1].outputs == outputs def test_layer_with_strings(self): x0 = "x0" x1 = "x1" y0 = "y0" y1 = "y1" graph = Graph() outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1]) assert len(graph.nodes) == 1 assert [ prefix in tensor.name for prefix, tensor in zip([x0, x1], graph.nodes[-1].inputs) ] assert [ prefix in tensor.name for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs) ] assert graph.nodes[-1].outputs == outputs def test_layer_with_arrays(self): x0 = np.array([1]) x1 = np.array([1]) y0 = "y0" y1 = "y1" graph = Graph() outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1]) assert [ prefix in tensor.name for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs) ] assert len(graph.nodes) == 1 assert graph.nodes[-1].inputs[0].values == x0 assert graph.nodes[-1].inputs[1].values == x1 assert graph.nodes[-1].outputs == outputs def test_layer_with_iterables(self): x0 = [1] x1 = (1,) y0 = "y0" y1 = "y1" graph = Graph() outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1]) assert [ prefix in tensor.name for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs) ] assert len(graph.nodes) == 1 assert graph.nodes[-1].inputs[0].values == x0 assert graph.nodes[-1].inputs[1].values == x1 assert graph.nodes[-1].outputs == outputs class TestFunctionCall(object): def make_graph_and_func(self): func_output = Variable("test_output", shape=[1, 2, 3], dtype=np.int32) func = Function( "TestFunction", inputs=[Variable("test_input")], outputs=[func_output] ) graph = Graph(functions=[func]) return graph, func_output def check_outputs_match(self, func_output, node_outputs): assert len(node_outputs) == 1 output = node_outputs[0] assert output is not func_output assert output.name assert output.shape == func_output.shape assert output.dtype == func_output.dtype def test_function_default_outputs(self): graph, func_output = self.make_graph_and_func() # No outputs given, but they should be created. outputs = graph.TestFunction(inputs=["input"]) self.check_outputs_match(func_output, outputs) def test_function_default_outputs_string_names(self): graph, func_output = self.make_graph_and_func() # Output name given, it should be preserved. outputs = graph.TestFunction(inputs=["input"], outputs=["output"]) self.check_outputs_match(func_output, outputs) assert outputs[0].name.startswith("output") def test_function_default_outputs_existing_tensor(self): graph, _ = self.make_graph_and_func() # Output tensor provided, it should not be changed. existing_tensor = Variable("output", shape=[2, 3, 4], dtype=np.float32) outputs = graph.TestFunction(inputs=["input"], outputs=[existing_tensor]) assert outputs[0] is existing_tensor def tensors_linear_graph(): inputs = [Variable(name="x")] intermediate0 = Variable(name="intermediate0") intermediate1 = Variable(name="intermediate1") intermediate2 = Variable(name="intermediate2") outputs = [Variable(name="y")] tensors = inputs + [intermediate0, intermediate1, intermediate2] + outputs tensors = {tensor.name: tensor for tensor in tensors} # Nodes are NOT in topo order. nodes = [ Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]), Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]), Node(op="Add", name="Test2", inputs=[intermediate1], outputs=[intermediate2]), Node(op="Add", name="Test3", inputs=[intermediate2], outputs=outputs), ] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), nodes, tensors class TestTensors(object): # Calling `tensors()` should not modify tensors in the graph. def test_tensors_does_not_modify_tensors(self): graph, _, _ = tensors_linear_graph() graph_tensors = graph.tensors() # Generate a new graph to compare against _, _, tensors = tensors_linear_graph() assert set(tensors.keys()) == set(graph_tensors.keys()) for name, tensor in tensors.items(): graph_tensor = graph_tensors[name] assert tensor == graph_tensor assert tensor.inputs == graph_tensor.inputs assert tensor.outputs == graph_tensor.outputs # Check that tensors includes tensors not attached to nodes def test_tensors_includes_non_node_tensors(self): X = Constant("X", values=np.ones(shape=(64, 64), dtype=np.float32)) graph = Graph(inputs=[], outputs=[X]) tensor_map = graph.tensors() assert "X" in tensor_map assert tensor_map["X"] == X def test_tensors_check_duplicates(self): inputs = [Variable(name="x")] outputs = [Variable(name="x")] # Distinct tensors with the same name nodes = [ Node(op="Add", name="Test", inputs=inputs, outputs=outputs), ] graph = Graph(nodes=nodes, inputs=inputs, outputs=outputs) with pytest.raises(OnnxGraphSurgeonException): graph.tensors(check_duplicates=True) def test_tensors_with_duplicates_check_disabled(self): inputs = [Variable(name="x")] outputs = [Variable(name="x")] # Distinct tensors with the same name nodes = [ Node(op="Add", name="Test", inputs=inputs, outputs=outputs), ] graph = Graph(nodes=nodes, inputs=inputs, outputs=outputs) # This should *not* throw graph.tensors(check_duplicates=False) def toposort_linear_graph(): inputs = [Variable(name="x")] intermediate0 = Variable(name="intermediate0") intermediate1 = Variable(name="intermediate1") intermediate2 = Variable(name="intermediate2") outputs = [Variable(name="y")] # Nodes are NOT in topo order. nodes = [ Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]), Node(op="Add", name="Test2", inputs=[intermediate1], outputs=[intermediate2]), Node(op="Add", name="Test3", inputs=[intermediate2], outputs=outputs), Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]), ] expected_node_order = [nodes[0], nodes[3], nodes[1], nodes[2]] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order # Graph structure: # x # | # Test0 # | # intermediate0 # | \ # Test1 Test2 # | | # intermediate1 intermediate2 # \ / # Test3 # | # y def toposort_diamond_graph(): inputs = [Variable(name="x")] intermediate0 = Variable(name="intermediate0") intermediate1 = Variable(name="intermediate1") intermediate2 = Variable(name="intermediate2") outputs = [Variable(name="y")] # Nodes are NOT in topo order: nodes = [ Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]), # If the end of the diamond shape occurs before the branches, it can trigger a path # where a false cycle is detected - this test guards against that. Node( op="Add", name="Test3", inputs=[intermediate1, intermediate2], outputs=outputs, ), Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]), Node(op="Add", name="Test2", inputs=[intermediate0], outputs=[intermediate2]), ] expected_node_order = [nodes[0], nodes[2], nodes[3], nodes[1]] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order # Graph structure: # x # | # Test0 -> out0 (graph output) # | # out0 # | # Test1 -> out1 (graph output) # | # out1 # | # Test2 -> out2 (graph_output) def toposort_multi_tier_output_graph(): inputs = [Variable(name="x")] outputs = [Variable(name="out0"), Variable(name="out1"), Variable(name="out2")] out0, out1, out2 = outputs nodes = [ Node(op="Add", name="Test2", inputs=[out1], outputs=[out2]), Node(op="Add", name="Test0", inputs=inputs, outputs=[out0]), Node(op="Add", name="Test1", inputs=[out0], outputs=[out1]), ] expected_node_order = [nodes[1], nodes[2], nodes[0]] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order # Graph structure: # x2 x1 # | | # Test0 # | # int0 x0 # | / # Test1 # | # int1 x3 # | / # Test2 -> out (graph_output) def toposort_multi_tier_input_graph(): inputs = [ Variable(name="x0"), Variable(name="x1"), Variable(name="x2"), Variable(name="x3"), ] int0, int1 = [Variable(name="intermediate0"), Variable(name="intermediate1")] outputs = [Variable(name="out")] x0, x1, x2, x3 = inputs nodes = [ Node(op="Add", name="Test2", inputs=[int1, x3], outputs=outputs), Node(op="Add", name="Test0", inputs=[x2, x1], outputs=[int0]), Node(op="Add", name="Test1", inputs=[int0, x0], outputs=[int1]), ] expected_node_order = [nodes[1], nodes[2], nodes[0]] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order # Graph structure: # x0 # | # Add # | # x1 # | # Relu # | # x2 # # If: # Then: # x1 x2 # | | # Add # | # res # Else: # x1 x2 # | | # Add # | # res # | # out # # In this graph, the subgraph of If implicitly depends on x1/x2 from the outer graph, so the parent If # node must come after the outer Add/ReLU nodes. # If we fail to consider such implicit inputs, the If will remain the first node. def toposort_implicit_subgraph_inputs_graph(): def make_var(name): return Variable(name, shape=(1, 1), dtype=np.float32) # Main graph inputs = [make_var("x0")] const = Constant(name="const", values=np.array([[1.5]], dtype=np.float32)) cond = Constant(name="cond", values=np.array([True])) x1, x2 = [make_var("x1"), make_var("x2")] outputs = [make_var("out")] # Subgraphs for If subgraph_outputs = [make_var("res")] subgraph_nodes = [ Node(op="Add", name="SubgraphTest0", inputs=[x1, x2], outputs=subgraph_outputs) ] subgraph = Graph(nodes=subgraph_nodes, outputs=subgraph_outputs) nodes = [ Node( op="If", name="Test2", inputs=[cond], outputs=outputs, attrs={"then_branch": subgraph, "else_branch": subgraph}, ), Node(op="Relu", name="Test1", inputs=[x1], outputs=[x2]), Node(op="Add", name="Test0", inputs=inputs + [const], outputs=[x1]), ] expected_node_order = [nodes[2], nodes[1], nodes[0]] return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order TOPOSORT_TEST_CASES = [ toposort_linear_graph, toposort_diamond_graph, toposort_multi_tier_output_graph, toposort_multi_tier_input_graph, toposort_implicit_subgraph_inputs_graph, ] class TestToposort(object): @staticmethod def make_single_node_function(name: str, dependencies: List[str]) -> Function: """ Create a function which uses all the functions given in 'dependencies'. """ func = Function(name, inputs=[Variable("input")], outputs=[Variable("output")]) if not dependencies: return func intermediate = func.inputs[0] for i, dep in enumerate(dependencies): new_intermediate = Variable(f"inter_{i}") func.nodes.append( Node( dep, domain=Function.DEFAULT_DOMAIN, inputs=[intermediate], outputs=[new_intermediate], ) ) intermediate = new_intermediate func.outputs = [intermediate] return func @pytest.mark.parametrize("toposort_test_case", TOPOSORT_TEST_CASES) def test_topologically_sort(self, toposort_test_case): graph, expected_node_order = toposort_test_case() assert graph.nodes != expected_node_order graph.toposort() assert graph.nodes == expected_node_order @pytest.mark.parametrize("toposort_test_case", TOPOSORT_TEST_CASES) def test_toposort_nested(self, toposort_test_case): subgraph, expected_node_order = toposort_test_case() assert subgraph.nodes != expected_node_order # Wrap the graph within a subgraph inp = Variable("input") id_out = Variable("id_out") identity = Node(op="Identity", inputs=[inp], outputs=[id_out]) # Make the subgraph take an input from the outer graph node # If toposort tries to take the node id, it'll fault. subgraph.nodes[0].inputs.append(id_out) out = Variable("output") nested = Node( op="Nested", inputs=[id_out], outputs=[out], attrs={"subgraph": subgraph} ) graph = Graph(nodes=[identity, nested], inputs=[inp], outputs=[out]) graph.toposort(recurse_subgraphs=True) assert subgraph.nodes == expected_node_order def test_function(self): graph, expected_node_order = toposort_multi_tier_input_graph() func = Function( "Test", nodes=graph.nodes, inputs=graph.inputs, outputs=graph.outputs ) func.toposort() assert func.nodes == expected_node_order def test_function_order(self): # Check that toposort re-orders functions in topological order. func1 = self.make_single_node_function("func1", []) func2 = self.make_single_node_function("func2", ["func1"]) func3 = self.make_single_node_function("func3", ["func2"]) func4 = self.make_single_node_function("func4", ["func3", "func2"]) funcs = [func3, func2, func4, func1] graph = Graph(functions=funcs) graph.toposort() assert graph.functions == [func1, func2, func3, func4] def test_function_circular_dep_simple(self): func = self.make_single_node_function("func", ["func"]) graph = Graph(functions=[func]) try: graph.toposort() assert False, "Should have raised" except OnnxGraphSurgeonException: pass def test_function_circular_dep_complicated(self): # Circular dependency [func2 -> func3 -> func4 -> func2] func1 = self.make_single_node_function("func1", ["func2", "func5"]) func2 = self.make_single_node_function("func2", ["func3"]) func3 = self.make_single_node_function("func3", ["func4", "func5"]) func4 = self.make_single_node_function("func4", ["func2", "func5"]) func5 = self.make_single_node_function("func5", []) funcs = [func3, func2, func4, func1, func5] graph = Graph(functions=funcs) try: graph.toposort() assert False, "Should have raised" except OnnxGraphSurgeonException: pass def build_basic_graph(): inputs = [Variable(name="x")] outputs = [Variable(name="y")] nodes = [ Node(op="Add", name="Test", inputs=inputs, outputs=outputs), ] return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10) def build_two_layer_graph(): inputs = [Variable(name="x")] intermediate_tensor = Variable(name="intermediate") outputs = [Variable(name="y")] nodes = [ Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate_tensor]), Node(op="Add", name="Test1", inputs=[intermediate_tensor], outputs=outputs), ] return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10) def build_two_layer_graph_multiple_io(): inputs = [Variable(name="x0"), Variable(name="x1")] intermediate_tensor = Variable(name="intermediate") outputs = [Variable(name="y0"), Variable(name="y1")] nodes = [ Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate_tensor]), Node(op="Add", name="Test1", inputs=[intermediate_tensor], outputs=outputs), ] return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10) def build_function_with_unused_node(): func = Function("Test") A = Variable("A", dtype=np.float32, shape=(1, 1)) B = Variable("B", dtype=np.float32, shape=(1, 1)) X = Variable("X", dtype=np.float32, shape=(1, 1)) Y = Variable("Y", dtype=np.float32, shape=(1, 1)) func.inputs = [A, X] func.outputs = [B] func.nodes = [ Node(op="Sin", inputs=[X], outputs=[Y]), # this node is unused Node(op="Cos", inputs=[A], outputs=[B]), ] return func CLEANUP_TEST_CASES = [ build_basic_graph(), build_two_layer_graph(), build_two_layer_graph_multiple_io(), ] class TestCleanup(object): @pytest.mark.parametrize("graph", CLEANUP_TEST_CASES) def test_get_used_node_ids(self, graph): graph_used_nodes = copy.copy(graph.nodes) graph_used_tensors = copy.copy(list(graph.tensors().values())) unused_tensor = Variable(name="Unused") unused_node = Node( op="Unused", inputs=[graph.inputs[0]], outputs=[unused_tensor] ) graph.nodes.append(unused_node) with graph.node_ids(): used_node_ids, used_tensors = graph._get_used_node_ids() assert len(used_node_ids) == len(graph.nodes) - 1 assert all([node.id in used_node_ids for node in graph_used_nodes]) assert unused_node.id not in used_node_ids assert unused_tensor not in used_tensors assert all( [used_tensor in used_tensors for used_tensor in graph_used_tensors] ) def test_multi_tier(self): graph, _ = toposort_multi_tier_output_graph() tensor = graph.outputs.pop() unused_node = tensor.inputs[0] graph.cleanup() # Should remove just the Test2 node as out1 is still an output. assert unused_node not in graph.nodes assert len(graph.nodes) == 2 assert len(graph.outputs) == 2 tensor_map = graph.tensors() assert tensor.name not in tensor_map def test_remove_unused_node_outputs(self): graph, _ = toposort_linear_graph() graph.toposort() graph_output = graph.outputs[0] dummy = Variable("dummy") # Add unused tensor to a node in the middle of the graph. # Since it does not contribute to graph outputs, it should be removed. graph.nodes[1].outputs.append(dummy) graph.cleanup(remove_unused_node_outputs=True) assert dummy not in graph.nodes[1].outputs assert graph.outputs[0] == graph_output # Graoh outputs will never be removed def test_graph_input_producers(self): graph, _ = toposort_linear_graph() tensor_map = graph.tensors() assert "x" in tensor_map graph.inputs = [tensor_map["intermediate0"]] graph.cleanup() cleaned_tensor_map = graph.tensors() assert "x" not in cleaned_tensor_map @pytest.mark.parametrize("remove_unused_graph_inputs", [True, False]) def test_independent_path(self, remove_unused_graph_inputs): graph, _ = toposort_linear_graph() # Build out a path totally unrelated to rest of the graph indep0 = Variable(name="indep0") indep1 = Variable(name="indep1") node = Node(op="IndepTest", inputs=[indep0], outputs=[indep1]) graph.nodes.append(node) graph.inputs.append(indep0) graph.cleanup(remove_unused_graph_inputs=remove_unused_graph_inputs) assert indep0 not in graph.inputs or not remove_unused_graph_inputs assert node not in graph.nodes or not remove_unused_graph_inputs tensor_map = graph.tensors() assert indep0.name not in tensor_map or not remove_unused_graph_inputs assert indep1.name not in tensor_map or not remove_unused_graph_inputs def test_nested_graph(self, nested_graph): nested_node = nested_graph.nodes[1] nested_inp = nested_node.inputs[0] nested_out = nested_node.outputs[0] subgraph = nested_node.attrs["body"] assert "id_out" in nested_graph.tensors() nested_graph.cleanup(recurse_subgraphs=True) # Clean up should not remove a tensor whose only output node is a subgraph. assert "id_out" in nested_graph.tensors() # Clean up should not modify the nested nodes inputs or outputs assert nested_node.inputs == [nested_inp] assert nested_node.outputs == [nested_out] # Next we'll clean up the subgraph by recursing from the top-level assert subgraph.nodes subgraph.outputs.clear() nested_graph.cleanup(recurse_subgraphs=True) assert not subgraph.nodes def test_node_used_only_in_nested_graph(self): X = Variable("X", dtype=np.float32, shape=(1,)) Y = Variable("Y", dtype=np.float32, shape=(1,)) graph = Graph(inputs=[X, Y]) X_p = graph.identity( X ) # X_p is only used by the subgraph, not in the outer graph. subgraph_inp = Variable("subgraph_input", dtype=np.float32, shape=(1,)) subgraph = Graph(inputs=[subgraph_inp]) subgraph.outputs = [subgraph.add(subgraph_inp, X_p)] graph.outputs = [graph.nested(Y, subgraph)] graph.cleanup(remove_unused_graph_inputs=True) assert graph.nodes[0].op == "Identity" assert graph.nodes[0].inputs == [X] def test_input_is_output(self): graph = Graph() A = Variable("A", dtype=np.float32, shape=(1, 1)) B = Variable("B", dtype=np.float32, shape=(1, 1)) C = graph.add(A, B) graph.inputs = [A, B] graph.outputs = [C, B, A] # Out of order w/ respect to Add node inputs # Graph should remain unchanged after cleanup, including I/O tensors. graph.cleanup() assert graph.inputs == [A, B] assert graph.outputs == [C, B, A] assert len(graph.nodes) == 1 assert graph.nodes[0].inputs == [A, B] assert graph.nodes[0].outputs == [C] def test_function(self): func = build_function_with_unused_node() func.cleanup() assert {i.name for i in func.inputs} == {"A", "X"} assert {o.name for o in func.outputs} == {"B"} assert len(func.nodes) == 1 def test_graph_cleans_up_function(self): graph = Graph() func = build_function_with_unused_node() graph.functions.append(func) # Cleaning up the graph should by default also cleanup the function. graph.cleanup() assert {i.name for i in func.inputs} == {"A", "X"} assert {o.name for o in func.outputs} == {"B"} assert len(func.nodes) == 1 class TestCopy(object): def test_basic(self): graph = Graph( nodes=[Node(op="Test")], inputs=[Variable("test")], outputs=[Variable("test")], name="test-name", doc_string="test-docstring", import_domains=["fake-import-domain"], opset=-1, ) new_graph = graph.copy() assert new_graph == graph assert new_graph.nodes == graph.nodes assert new_graph.inputs == graph.inputs assert new_graph.outputs == graph.outputs assert new_graph.name == graph.name assert new_graph.doc_string == graph.doc_string assert new_graph.import_domains == graph.import_domains assert new_graph.opset == graph.opset def test_copy(self): def make_graph(): graph, _ = toposort_multi_tier_output_graph() graph.outputs.pop() # Deep copy should work with empty tensors graph.nodes[0].inputs.append(Variable.empty()) graph.nodes[0].outputs.append(Variable.empty()) return graph graph = make_graph() new_graph = graph.copy() assert graph == new_graph # Running cleanup on the first graph should not affect the copy graph.cleanup() assert graph != new_graph assert new_graph == make_graph() def test_copy_with_subgraph(self, nested_graph): new_graph = nested_graph.copy() assert new_graph == nested_graph new_subgraph = new_graph.nodes[1].attrs["body"] id_out = new_subgraph.nodes[0].inputs[0] assert id_out.name == "id_out" assert len(id_out.inputs) == 1 assert id_out.inputs[0].op == "Identity" assert id_out.inputs[0].inputs[0].name == "input" new_subgraph.nodes[0].outputs.clear() new_subgraph.nodes[1].inputs.clear() subgraph = nested_graph.nodes[1].attrs["body"] assert subgraph.nodes[0].outputs assert subgraph.nodes[1].inputs new_graph.outputs.clear() new_graph.cleanup() assert nested_graph.outputs assert len(nested_graph.nodes) == 2 assert len(subgraph.nodes) == 2 # If the subgraph has a tensor with the same name as the outer graph, # the subgraph copy should include a copy of the subgraph tensor, not the outer # graph tensor. def test_copy_with_subgraph_dup_tensors(self): inp = Variable("input", dtype=np.float32, shape=(4, 5)) graph = Graph(inputs=[inp]) # We'll use shape to distinguish inner/outer tensor subgraph_inp = Variable("input", dtype=np.float32, shape=(1, 2)) subgraph = Graph(inputs=[subgraph_inp]) graph.outputs = [graph.nested(inp, subgraph)] graph_copy = graph.copy() assert graph_copy.nodes[0].attrs["body"].inputs[0].shape == (1, 2) def test_copy_with_subgraph_dup_const_tensors(self): inp = Constant("input", values=np.ones(dtype=np.float32, shape=(4, 5))) graph = Graph() # We'll use shape to distinguish inner/outer tensor subgraph_inp = Constant("input", values=np.ones(dtype=np.float32, shape=(1, 2))) subgraph = Graph() subgraph.outputs = [subgraph.identity(subgraph_inp)] graph.outputs = [graph.nested(inp, subgraph)] graph_copy = graph.copy() assert graph_copy.nodes[0].attrs["body"].nodes[0].inputs[0].shape == (1, 2) def test_function(self): func = Function( "Test", domain="onnx-gs.test", nodes=[Node(op="Add")], inputs=[Variable("input")], outputs=[Variable("output")], doc_string="docstring", opset=15, import_domains=["test"], attrs={"attr1": None, "attr2": np.array([1, 2, 3])}, ) func_copy = func.copy() assert func.name == func_copy.name assert func.domain == func_copy.domain assert func.nodes == func_copy.nodes assert func.inputs == func_copy.inputs assert func.outputs == func_copy.outputs assert func.doc_string == func_copy.doc_string assert func.opset == func_copy.opset assert func.import_domains == func_copy.import_domains assert func.attrs["attr1"] == func_copy.attrs["attr1"] assert np.all(func.attrs["attr2"] == func_copy.attrs["attr2"]) assert func.nodes is not func_copy.nodes assert func.inputs is not func_copy.inputs assert func.outputs is not func_copy.outputs assert func.attrs is not func_copy.attrs assert func.nodes[0] is not func_copy.nodes[0] assert func.inputs[0] is not func_copy.inputs[0] assert func.outputs[0] is not func_copy.outputs[0] assert func.attrs["attr2"] is not func_copy.attrs["attr2"] @pytest.fixture def simple_foldable(): # Graph: # c = (a + b) # output = input + c # Should fold to: # output = input + c weights = np.ones(shape=(1, 3), dtype=np.float32) graph = Graph(ir_version=10) inp = Variable("input", shape=(1, 3), dtype=np.float32) c = graph.add(weights, weights, name="c") out = graph.add(inp, c, name="out") graph.inputs = [inp] graph.outputs = [out] yield graph @pytest.fixture def one_hop_foldable(): # Graph: # c = (a + b) # e = (c + d) # output = input + e # Should fold to: # output = input + e weights = np.ones(shape=(1, 3), dtype=np.float32) graph = Graph(ir_version=10) inp = Variable("input", shape=(1, 3), dtype=np.float32) c = graph.add(weights, weights, name="c") e = graph.add(c, weights, name="e") out = graph.add(inp, e) graph.inputs = [inp] graph.outputs = [out] yield graph @pytest.fixture def foldable_with_invalid_node(): # Graph # c = (a + b) # e = fake(d) # f = (e + c) # out = inp + f # # c should be folded even though e is the output of an # invalid node. weights = np.ones(shape=(1, 3), dtype=np.float32) graph = Graph(ir_version=10) inp = Variable("input", shape=(1, 3), dtype=np.float32) c = graph.add(weights, weights, name="c") e = graph.fake(weights, name="e") f = graph.add(e, c, name="f") out = graph.add(inp, f, name="output") graph.inputs = [inp] graph.outputs = [out] yield graph @pytest.fixture def foldable_with_local_functions(): dtype = np.float32 counter = 0 def const(): nonlocal counter counter += 1 return Constant(f"constant_{counter}", np.ones(1, dtype=np.float32)) func_inner = Function("FuncInner", ir_version=10) func_outer = Function("FuncOuter", ir_version=10) funcs = [func_inner, func_outer] # func_inner(x) = x + 1 func_inner.inputs = [Variable("input", dtype=dtype)] func_inner.outputs = [Variable("output", dtype=dtype)] func_inner.nodes = [ Node( "Add", inputs=[func_inner.inputs[0], const()], outputs=[func_inner.outputs[0]], ) ] # func_outer(x) = func_inner(1) * x func_outer.inputs = [Variable("input", dtype=dtype)] func_outer.functions = [func_inner] func_outer_intermediate = func_outer.FuncInner(inputs=[const()])[0] func_outer.outputs = [func_outer.mul(func_outer.inputs[0], func_outer_intermediate)] # a = func_inner(input) # b = func_outer(input) # c = 1 + 1 # d = a + b # e = c + d # output = e + c graph = Graph( inputs=[Variable("graph_input", dtype=dtype)], functions=funcs, ir_version=10 ) var0 = graph.FuncInner(inputs=[const()])[0] var1 = graph.FuncOuter(inputs=[const()])[0] var2 = graph.add(const(), const()) var3 = graph.add(var0, var1) var4 = graph.add(var2, var3) graph.outputs = [graph.add(var2, var4)] graph.outputs[0].dtype = dtype yield graph class TestFoldConstants(object): @pytest.mark.parametrize("partitioning", [None, "basic", "recursive"]) def test_basic(self, simple_foldable, partitioning): inp = simple_foldable.inputs[0] simple_foldable.fold_constants(partitioning=partitioning).cleanup( remove_unused_graph_inputs=True ) # Extra node should be removed assert len(simple_foldable.nodes) == 1 assert simple_foldable.nodes[0].inputs[0] == inp assert simple_foldable.nodes[0].inputs[1].name == "c" # Value should be computed correctly assert np.all( simple_foldable.nodes[0].inputs[1].values == np.ones(shape=(1, 3), dtype=np.float32) * 2 ) def test_one_hop(self, one_hop_foldable): inp = one_hop_foldable.inputs[0] one_hop_foldable.fold_constants().cleanup() # Extra nodes should be removed assert len(one_hop_foldable.nodes) == 1 assert one_hop_foldable.nodes[0].inputs[0] == inp assert one_hop_foldable.nodes[0].inputs[1].name == "e" # Value should be computed correctly assert np.all( one_hop_foldable.nodes[0].inputs[1].values == np.ones(shape=(1, 3), dtype=np.float32) * 3 ) def test_with_invalid_nodes(self, foldable_with_invalid_node): foldable_with_invalid_node.fold_constants(partitioning="recursive").cleanup() tensor_map = foldable_with_invalid_node.tensors() assert len(foldable_with_invalid_node.nodes) == 3 assert foldable_with_invalid_node.nodes[0].op == "Fake" assert foldable_with_invalid_node.nodes[1].op == "Add" assert foldable_with_invalid_node.nodes[2].op == "Add" assert np.all( tensor_map["c"].values == (np.ones(shape=(1, 3), dtype=np.float32) * 2) ) def test_with_invalid_nodes_no_recursive(self, foldable_with_invalid_node): # No folding should take place without recursive partitioning original = foldable_with_invalid_node.copy() assert foldable_with_invalid_node.fold_constants() == original def test_no_foldable_constants(self): inp0 = Variable("input0", shape=(1, 3), dtype=np.float32) inp1 = Variable("input1", shape=(1, 3), dtype=np.float32) out = Variable("output", shape=(1, 3), dtype=np.float32) nodes = [Node("Add", inputs=[inp0, inp1], outputs=[out])] graph = Graph(nodes=nodes, inputs=[inp0, inp1], outputs=[out], ir_version=10) graph.fold_constants().cleanup() assert len(graph.nodes) == 1 assert graph.nodes[0].inputs == [inp0, inp1] def test_const_node(self): graph = Graph(ir_version=10) values = np.ones((1, 3, 3), dtype=np.int64) graph.outputs = [graph.constant(values=values)] assert isinstance(graph.outputs[0], Variable) graph.fold_constants().cleanup() assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == values) assert not graph.nodes def test_shape_of_constant_tensor(self): graph = Graph(ir_version=10) values = np.ones((1, 3, 3), dtype=np.int64) const = Constant("const", values=values) graph.outputs = [graph.shape(const)] graph.fold_constants().cleanup() assert not graph.nodes assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == (1, 3, 3)) def test_shape_of_constant_node(self): graph = Graph(ir_version=10) values = np.ones((1, 3, 3), dtype=np.int64) const = graph.constant(values=values) graph.outputs = [graph.shape(const)] graph.fold_constants().cleanup() assert not graph.nodes assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == (1, 3, 3)) # Cannot fold shape nodes if they have dynamically shaped inputs. def test_shape_of_variable_tensor_dynamic_shape(self): var = Variable("var", dtype=np.float32, shape=("", -1, 0, 4)) graph = Graph(inputs=[var], ir_version=10) graph.outputs = [graph.shape(var)] graph.fold_constants().cleanup() assert len(graph.nodes) == 1 assert graph.nodes[0].op == "Shape" assert isinstance(graph.outputs[0], Variable) def test_shape_of_variable_tensor_static_shape(self): var = Variable("var", dtype=np.float32, shape=(1, 3, 4)) graph = Graph(inputs=[var], ir_version=10) graph.inputs = [var] graph.outputs = [graph.shape(var)] graph.fold_constants().cleanup() assert not graph.nodes assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == (1, 3, 4)) def test_shape_of_variable_tensor_multiple_shapes(self): graph = Graph(ir_version=10) var = Variable("var", dtype=np.float32, shape=(1, 3, 4)) var2 = Variable("var2", dtype=np.float32, shape=tuple()) # Scalar graph.inputs = [var, var2] graph.outputs = [graph.shape(var), graph.identity(var), graph.shape(var2)] graph.fold_constants().cleanup() assert len(graph.nodes) == 1 assert graph.nodes[0].op == "Identity" assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == (1, 3, 4)) assert isinstance(graph.outputs[2], Constant) assert np.all(graph.outputs[2].values == tuple()) def test_shape_of_variable_tensor_static_shape_no_fold(self): graph = Graph(ir_version=10) var = Variable("var", dtype=np.float32, shape=(1, 3, 4)) graph.inputs = [var] graph.outputs = [graph.shape(var)] graph.fold_constants(fold_shapes=False).cleanup() assert len(graph.nodes) == 1 assert graph.nodes[0].op == "Shape" assert isinstance(graph.outputs[0], Variable) # Constant folding should not cause constant tensors in the model to be loaded. def test_no_load_constants(self): graph = gs.import_onnx(const_foldable().load()) new_graph = graph.fold_constants() def check_no_const_loaded(graph): num_lazy_constants = 0 for tensor in graph.tensors().values(): if isinstance(tensor, Constant) and isinstance( tensor._values, LazyValues ): num_lazy_constants += 1 assert ( num_lazy_constants == 3 ) # Graph starts with 3 constants - none should be loaded. check_no_const_loaded(graph) check_no_const_loaded(new_graph) @pytest.mark.parametrize( "shape, indices", [ (("batch", 3, "height", "width"), 1), # Scalar indices case (None, 1), # Shape not inferered case (("batch", 3, "height", "width"), [1]), (("batch", 3, "height", 224), [1, 3]), (("batch", 3, 224, 224), [1, 2, 3]), ], ) def test_shape_gather(self, shape, indices): indices = np.array(indices) inp = Variable("input", dtype=np.float32, shape=shape) graph = Graph(inputs=[inp], ir_version=10) inp_shape = graph.shape(inp) shape_part = graph.gather(inp_shape, indices=indices) graph.outputs = [ graph.add(shape_part, shape_part), graph.gather(inp_shape, indices=[0]), graph.gather(inp_shape, indices=np.array(0)), ] graph.fold_constants() if shape is not None: assert isinstance(graph.outputs[0], Constant) expected_shape = np.array(shape)[indices].astype(np.int64) * 2 assert np.all(graph.outputs[0].values == expected_shape) else: assert isinstance(graph.outputs[0], Variable) assert isinstance(graph.outputs[1], Variable) assert isinstance(graph.outputs[2], Variable) @pytest.mark.parametrize( "shape, starts, ends, axes, steps, expected", [ ( ("batch", 3, "height", "width"), 1, 2, 0, 1, [3], ), # Scalar starts/ends case (("batch", 3, "height", "width"), [1], [2], [0], [1], [3]), ( ("batch", 3, 5, "width"), [1], [-1], [0], [1], [3, 5], ), # Negative ends case ( ("batch", 3, 5, 7), [1], [2000], [0], [1], [3, 5, 7], ), # Past end, ends case (("batch", 3, 5, 7), [-2], [4], [0], [1], [5, 7]), # Negative starts case (("batch", 3, 5, 7), [-2], [4], [1], [1], None), # Non-zero axes case (("batch", 3, 5, "width"), [-2], [4], [1], [1], None), # Dynamic case (("batch", 3, 5, 7), [1], [4], [0], [2], [3, 7]), # Non-one steps case (("batch", 3, 5, 7), [4], [0], [0], [-1], [7, 5, 3]), # Negative steps case ], ) def test_shape_slice(self, shape, starts, ends, axes, steps, expected): inp = Variable("input", dtype=np.float32, shape=shape) graph = Graph(inputs=[inp], ir_version=10) inp_shape = graph.shape(inp) graph.outputs = [ graph.slice( inp_shape, np.array(starts), np.array(ends), axes=np.array(axes), steps=np.array(steps), ) ] graph.fold_constants() if expected: assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == expected) else: assert isinstance(graph.outputs[0], Variable) # In the single input case, we should derive starts/ends/axes/steps from the attributes. def test_shape_slice_single_input(self): inp = Variable("input", dtype=np.int64, shape=(5, 6, 3, 2)) graph = Graph(inputs=[inp], ir_version=10) inp_shape = graph.shape(inp) graph.outputs = [graph.slice(inp_shape)] slice_node = graph.outputs[0].inputs[0] slice_node.attrs = { "axes": [0], "starts": [1], "ends": [3], "steps": [2], } graph.fold_constants() assert isinstance(graph.outputs[0], Constant) assert np.all(graph.outputs[0].values == inp.shape[1:3:2]) def test_with_variable_conditional(self): cond = gs.Variable("cond", dtype=bool, shape=(1,)) X = gs.Variable("X", dtype=np.float32, shape=(1,)) Y = gs.Constant("Y", values=np.ones((1,), dtype=np.float32)) graph = Graph(inputs=[X, cond], ir_version=10) then_graph = Graph(name="Then", ir_version=10) then_graph.outputs = [then_graph.add(Y, Y)] else_graph = Graph(name="Else", ir_version=10) else_graph.outputs = [else_graph.add(X, else_graph.add(Y, Y))] graph.outputs = [graph.if_op(cond, then_graph, else_graph)] graph.fold_constants() graph.cleanup() assert len(then_graph.nodes) == 0 assert np.all(then_graph.outputs[0].values == (Y.values * 2)) assert len(else_graph.nodes) == 1 assert isinstance(else_graph.nodes[0].inputs[1], Constant) assert np.all(else_graph.nodes[0].inputs[1].values == (Y.values * 2)) @pytest.mark.parametrize("cond_value", [True, False]) @pytest.mark.parametrize("flatten", [True, False]) def test_flatten_static_conditional(self, flatten, cond_value): cond = gs.Constant("cond", values=np.array([cond_value], dtype=bool)) X = gs.Variable("X", dtype=np.float32, shape=(1,)) Y = gs.Variable("Y", dtype=np.float32, shape=(1,)) graph = Graph(inputs=[X, cond], ir_version=10) then_graph = Graph(name="Then", ir_version=10) then_graph.outputs = [then_graph.relu(then_graph.add(Y, Y))] else_graph = Graph(name="Else", ir_version=10) else_graph.outputs = [else_graph.add(X, else_graph.add(Y, Y))] if_out = graph.if_op(cond, then_graph, else_graph) graph.outputs = [if_out] graph.fold_constants(flatten_subgraphs=flatten) graph.cleanup() if flatten: assert len(graph.nodes) == 2 assert graph.nodes[0].op == "Add" assert graph.nodes[1].op == "Relu" if cond_value else "Add" subgraph = then_graph if cond_value else else_graph # Make sure subgraph intermediate tensors are renamed assert graph.nodes[0].outputs[0].name == "add_out_0_subg_0_{:}".format( subgraph.name ) assert graph.outputs[0].inputs[0] == subgraph.nodes[-1] assert subgraph.nodes[-1] == graph.nodes[-1] else: assert len(graph.nodes) == 1 assert len(graph.nodes) == 1 assert graph.nodes[0].op == "If" assert graph.outputs[0].inputs[0] == graph.nodes[-1] assert graph.outputs == [if_out] def test_const_inp_but_non_foldable_nested_graph(self): cond = gs.Constant("cond", values=np.array(True)) X = gs.Variable("X", dtype=np.float32, shape=(1,)) graph = Graph(inputs=[X], ir_version=10) then_graph = Graph(name="Then", ir_version=10) then_graph.outputs = [then_graph.add(X, X)] else_graph = Graph(name="Else", ir_version=10) else_graph.outputs = [else_graph.add(X, else_graph.add(X, X))] # Even though if_op looks foldable because it has all constant inputs, # it's not, since its subgraphs depend on variables in the outer scope. graph.outputs = [graph.if_op(cond, then_graph, else_graph)] # This should not raise because the `If` node should be excluded from # constant folding. graph.fold_constants(error_ok=False, flatten_subgraphs=False).cleanup() assert graph.nodes[0].op == "If" assert len(then_graph.nodes) == 1 assert len(else_graph.nodes) == 2 def test_cast_elision(self): graph = gs.import_onnx(shape_cast_elision().load()) graph.fold_constants().cleanup() assert not any(node.op == "Cast" for node in graph.nodes) def test_cast_elision_int64(self): X = gs.Variable("X", dtype=np.int64, shape=(1,)) graph = Graph(inputs=[X], ir_version=10) casted_x = graph.cast(X, to=onnx.TensorProto.DataType.FLOAT) add_out = graph.add(casted_x, casted_x) graph.outputs = [graph.cast(add_out, to=onnx.TensorProto.DataType.INT64)] graph.fold_constants().cleanup() assert graph.nodes[0].op == "Add" # Make sure we're lowering constant nodes before running cast elision def test_cast_elision_with_constant_node(self): inp = gs.Variable("inp", dtype=np.int64, shape=(1,)) graph = Graph(inputs=[inp], ir_version=10) casted_inp = graph.cast(inp, to=onnx.TensorProto.DataType.FLOAT) add_out = graph.add(casted_inp, graph.constant(np.array([2], dtype=np.float32))) casted_out = graph.cast(add_out, to=onnx.TensorProto.DataType.INT64) casted_out.dtype = np.int64 graph.outputs = [casted_out] graph.fold_constants().cleanup() assert [node.op for node in graph.nodes] == ["Add"] add_const_inp = graph.nodes[0].inputs[1] assert isinstance(add_const_inp, Constant) assert ( add_const_inp.dtype == np.int64 ) # Should have been casted to match dtype of other inputs. # For a graph like: # # inp # | # Cast # | # Add # | # Cast # | # out # # 1. We cannot remove the initial `Cast` if it is used outside the `Add` node # 2. We cannot perform cast elision at all if the original output of the `Add` node is # used outside the subsequent `Cast` node. # @pytest.mark.parametrize("use_as_graph_output", [True, False], ids=["graph", ""]) @pytest.mark.parametrize("use_in_other_node", [True, False], ids=["node", ""]) # Whether to apply the effects of the first two parameters to the input `Cast` node or to the `Add` node. @pytest.mark.parametrize( "apply_to_input_cast", [True, False], ids=["input", "output"] ) def test_cast_elision_multi_use_cast( self, use_as_graph_output, use_in_other_node, apply_to_input_cast ): X = gs.Variable("X", dtype=np.int32, shape=(1,)) graph = Graph(inputs=[X], ir_version=10) casted_x = graph.cast(X, to=onnx.TensorProto.DataType.FLOAT) add_out = graph.add(casted_x, casted_x) uncasted_x = graph.cast(add_out, to=onnx.TensorProto.DataType.INT32) graph.outputs = [uncasted_x] mutli_use_tensor = casted_x if apply_to_input_cast else add_out if use_in_other_node: graph.outputs.append(graph.identity(mutli_use_tensor)) if use_as_graph_output: graph.outputs.append(mutli_use_tensor) print(graph) graph.fold_constants().cleanup() ops = [node.op for node in graph.nodes] if use_as_graph_output or use_in_other_node: if apply_to_input_cast: assert graph.nodes[1].inputs[0] == X assert graph.nodes[1].outputs[0] == uncasted_x assert ops == ["Cast", "Add"] + ( ["Identity"] if use_in_other_node else [] ) else: assert ops == ["Cast", "Add", "Cast"] + ( ["Identity"] if use_in_other_node else [] ) else: assert ops == ["Add"] @pytest.mark.parametrize( # If layer1_num_bytes is larger than layer0_num_bytes, then it must be a multiple. "size_threshold, layer0_num_bytes, layer0_should_fold, layer1_num_bytes, layer1_should_fold", [ # No size threshold - everything should fold. ( None, 2, True, 4, True, ), # Monotonically increasing but under size threshold - everything should fold. ( 8, 2, True, 4, True, ), # Increasing then decreasing, but under size threshold - everything should fold. ( 8, 2, True, 1, True, ), # All tensors over size threshold - nothing should fold. ( 1, 2, False, 4, False, ), # Second tensor over size threshold - only first tensor should fold. ( 3, 2, True, 4, False, ), # First tensor over size threshold - second tensor should still fold. ( 3, 4, False, 2, True, ), ], ) @pytest.mark.parametrize("push_into_subgraph", [True, False], ids=["subgraph", ""]) def test_folding_size_threshold( self, size_threshold, layer0_num_bytes, layer0_should_fold, layer1_num_bytes, layer1_should_fold, push_into_subgraph, ): graph = Graph(ir_version=10) shape = (1,) layer0_repeats = layer0_num_bytes // misc.volume(shape) layer0 = graph.tile(np.ones(shape, dtype=np.int8), repeats=[layer0_repeats]) layer0.inputs[0].name = "Layer0" if layer1_num_bytes > layer0_num_bytes: layer1_repeats = layer1_num_bytes // layer0_num_bytes layer1 = graph.tile(layer0, repeats=[layer1_repeats]) else: layer1 = graph.slice(layer0, starts=[0], ends=[layer1_num_bytes]) layer1.inputs[0].name = "Layer1" graph.outputs = [layer1] # Make sure size_threshold option is propagated into subgraphs. if push_into_subgraph: cond = gs.Variable("cond", dtype=bool, shape=tuple()) outer_graph = Graph(inputs=[cond]) outer_graph.if_op(cond, then_graph=graph, else_graph=graph) outer_graph.fold_constants(size_threshold=size_threshold) else: graph.fold_constants(size_threshold=size_threshold) # When a tensor is folded, it is disconnected from its producer nodes assert len(graph.nodes[0].outputs) == (0 if layer0_should_fold else 1) assert len(graph.nodes[1].outputs) == (0 if layer1_should_fold else 1) @pytest.mark.parametrize("op", ["Q", "DQ"]) @pytest.mark.parametrize("add_intermediate_layer", [True, False]) def test_no_fold_qdq(self, op, add_intermediate_layer): dtype = np.float32 if op == "Q" else np.int8 inp = gs.Constant("input", np.ones(shape=(1, 3, 5, 5), dtype=dtype)) graph = Graph(inputs=[inp], opset=13, ir_version=10) if add_intermediate_layer: inp = graph.identity(inp) qdq_func = graph.quantize_linear if op == "Q" else graph.dequantize_linear graph.outputs = [ qdq_func(inp, 1.2, np.array(0, dtype=np.int8)) ] # Arbitrary scale and zero-point graph.fold_constants().cleanup() assert len(graph.nodes) == 1 assert ( graph.nodes[0].op == "QuantizeLinear" if op == "Q" else "DequantizeLinear" ) @pytest.mark.parametrize( "should_exclude_node_func,expected_node_names", [ ( lambda node: True, [ "onnx_graphsurgeon_node_1", "onnx_graphsurgeon_node_3", "onnx_graphsurgeon_node_5", "onnx_graphsurgeon_node_7", ], ), ( lambda node: node.name == "onnx_graphsurgeon_node_5", ["onnx_graphsurgeon_node_5", "onnx_graphsurgeon_node_7"], ), ( lambda node: node.op == "Add", [ "onnx_graphsurgeon_node_1", "onnx_graphsurgeon_node_3", "onnx_graphsurgeon_node_5", "onnx_graphsurgeon_node_7", ], ), ( lambda node: node.op == "Relu", [ "onnx_graphsurgeon_node_3", "onnx_graphsurgeon_node_5", "onnx_graphsurgeon_node_7", ], ), ], ) def test_custom_should_exclude_node( self, should_exclude_node_func, expected_node_names ): inp = gs.Constant("input", np.ones(shape=(1, 3, 5, 5), dtype=np.float32)) graph = Graph(inputs=[inp], ir_version=10) add_0 = graph.add(inp, inp) # onnx_graphsurgeon_node_1 -> add_out_0 relu_0 = graph.relu(add_0) # onnx_graphsurgeon_node_3 -> relu_out_2 add_1 = graph.add(relu_0, relu_0) # onnx_graphsurgeon_node_5 -> add_out_4 relu_1 = graph.relu(add_1) # onnx_graphsurgeon_node_7 -> relu_out_6 graph.outputs = [relu_1] graph.fold_constants(should_exclude_node=should_exclude_node_func).cleanup() assert [node.name for node in graph.nodes] == expected_node_names def test_omitted_optional_inputs_ignored(self): # An omitted optional input will show up as a `Variable` with no name. # This should *not* prevent us from folding nodes where all other inputs are constants. data = gs.Constant("data", np.ones(shape=(3, 5, 5), dtype=np.float32)) pads = gs.Constant("pads", np.zeros(shape=(6,), dtype=np.int64)) graph = Graph(ir_version=10) pad_0 = graph.pad(data, pads, constant_value=None) graph.outputs = [pad_0] assert pad_0.inputs[0].inputs[2] == Variable.empty() assert len(graph.nodes) == 1 graph.fold_constants().cleanup() assert len(graph.nodes) == 0 assert isinstance(graph.outputs[0], gs.Constant) def test_function(self, simple_foldable): func = Function( "Test", nodes=simple_foldable.nodes, inputs=simple_foldable.inputs, outputs=simple_foldable.outputs, ir_version=10, ) assert len(func.nodes) == 2 func.fold_constants().cleanup() assert len(func.nodes) == 1 def test_function_with_attributes(self): # Nodes that reference function attributes shouldn't be folded. input = Variable("input", dtype=np.float32) func = Function( "Test", inputs=[input], attrs={"softmax_axis": -1}, ir_version=10 ) x = func.softmax( np.float32([[1, 2, 3]]), axis=Node.AttributeRef("softmax_axis", int) ) y = func.softmax(np.float32([[4, 5, 6]]), axis=-1) z = func.add(x, y) func.outputs += [func.add(input, z)] # Only one node should get folded. assert len(func.nodes) == 4 func.fold_constants().cleanup() assert len(func.nodes) == 3 def test_functions_inside_functions(self, foldable_with_local_functions): graph = foldable_with_local_functions graph.toposort() graph.fold_constants(error_ok=False) graph.cleanup() assert len(graph.inputs) == 1 assert len(graph.outputs) == 1 assert isinstance(graph.outputs[0], Constant) assert graph.outputs[0].values == 8 def test_function_with_unused_input(self, foldable_with_local_functions): # Constant folding should still work correctly when a function has unused inputs. graph = foldable_with_local_functions func_outer = graph.functions[1] func_outer.nodes[1].inputs[0] = gs.Constant( "Constant_99", values=np.array([3], dtype=np.float32) ) graph.toposort().fold_constants(error_ok=False).cleanup() assert len(graph.inputs) == 1 assert len(graph.outputs) == 1 assert isinstance(graph.outputs[0], Constant) assert graph.outputs[0].values == 12 def test_inner_function_with_unused_input(self, foldable_with_local_functions): graph = foldable_with_local_functions func_inner = graph.functions[0] func_inner.nodes[0].inputs[0] = gs.Constant( "Constant_99", values=np.array([3], dtype=np.float32) ) graph.toposort().fold_constants(error_ok=False).cleanup() assert len(graph.inputs) == 1 assert len(graph.outputs) == 1 assert isinstance(graph.outputs[0], Constant) assert graph.outputs[0].values == 12 def test_function_with_subgraph(self, foldable_with_local_functions): graph = foldable_with_local_functions dtype = graph.outputs[0].dtype func = Function("func_with_subgraph", ir_version=10) func.inputs = [Variable("input")] then_graph = Graph(name="Then", ir_version=10) then_graph.outputs = [then_graph.identity(func.inputs[0])] else_graph = Graph(name="Else", ir_version=10) else_graph.functions = graph.functions else_graph.outputs = else_graph.FuncInner( inputs=[func.inputs[0]], outputs=["else_out"] ) cond = func.less(func.inputs[0], Constant("Zero", np.zeros(1, dtype=dtype))) func.outputs = [func.if_op(cond, then_graph, else_graph)] graph.functions.append(func) graph.outputs = graph.func_with_subgraph( inputs=[graph.outputs[0]], outputs=["new_output"] ) graph.toposort().fold_constants(error_ok=False).cleanup() assert len(graph.inputs) == 1 assert len(graph.outputs) == 1 assert isinstance(graph.outputs[0], Constant) assert graph.outputs[0].values == 9 def test_function_with_complicated_attrs(self): # Types of attributes we should test: # 1) Has no default value # 2) Has default value which is used # 3) Has default value which is overridden # 4) Confusing attribute name / reference name mappings dtype = np.float32 opset = 18 func = Function( "complicated_func", inputs=[Variable("input", dtype=dtype)], opset=opset, ir_version=10, ) variables = [Variable(f"var{i}", dtype=dtype) for i in range(5)] func.nodes.append( Node( "ConstantOfShape", attrs={"value": Node.AttributeRef("ConstantOfShape_value", Tensor)}, inputs=[func.inputs[0]], outputs=[variables[0]], # shape [2, 3, 4] ) ) func.nodes.append( Node( "ReduceSum", attrs={"keepdims": Node.AttributeRef("keepdims", int)}, inputs=[ variables[0], Constant("ReduceSum_axis", np.array([1], dtype=int)), ], outputs=[variables[1]], # shape [2, 1, 4] when keepDims=True ) ) func.nodes.append( Node( "Flatten", attrs={"axis": Node.AttributeRef("Flatten_axis", np.int32)}, inputs=[variables[1]], outputs=[variables[2]], # shape [2, 4] when axis=1 ) ) func.nodes.append( Node( "Concat", attrs={"axis": Node.AttributeRef("axis", np.int64)}, inputs=[ variables[2], Constant("to_concat", values=np.ones((2, 1), dtype=dtype)), ], outputs=[variables[3]], # shape [2, 5] when axis=-1 ) ) func.nodes.append( Node( "Concat", attrs={"axis": Node.AttributeRef("Concat_axis", int)}, inputs=[ variables[3], Constant("to_concat_2", values=2 * np.ones((2, 1), dtype=dtype)), ], outputs=[variables[4]], # shape [2, 6] when axis=-1 ) ) func.outputs = [variables[4]] func.attrs = { "ConstantOfShape_value": None, # no default value "axis": None, # no default value "Flatten_axis": 0, "keepdims": True, "Concat_axis": -1, } graph = Graph(opset=opset, functions=[func], ir_version=10) input = Constant("shape", values=np.array([2, 3, 4], dtype=int)) output = graph.complicated_func( inputs=[input], outputs=["output"], attrs={ "ConstantOfShape_value": Constant( "three", values=np.array([3], dtype=dtype) ), "axis": -1, "Flatten_axis": 2, # overrides default value }, )[0] output.dtype = dtype graph.inputs = [input] graph.outputs = [output] graph.fold_constants(error_ok=False).cleanup() assert len(graph.inputs) == 1 assert len(graph.outputs) == 1 assert isinstance(graph.outputs[0], Constant) assert np.all( graph.outputs[0].values == np.array([[9, 9, 9, 9, 1, 2], [9, 9, 9, 9, 1, 2]]) ) class TestIO(object): def test_io_cannot_be_sync_list_on_init(self): inp = Variable("input0", shape=(1, 3), dtype=np.float32) out = Variable("input1", shape=(1, 3), dtype=np.float32) node = Node("Add", inputs=[inp], outputs=[out]) assert isinstance(node.inputs, SynchronizedList) assert isinstance(node.outputs, SynchronizedList) graph = Graph(nodes=[node], inputs=node.inputs, outputs=node.outputs) assert not isinstance(graph.inputs, SynchronizedList) assert not isinstance(graph.outputs, SynchronizedList) def test_io_cannot_be_sync_list_on_assign(self): inp = Variable("input0", shape=(1, 3), dtype=np.float32) out = Variable("input1", shape=(1, 3), dtype=np.float32) node = Node("Add", inputs=[inp], outputs=[out]) assert isinstance(node.inputs, SynchronizedList) assert isinstance(node.outputs, SynchronizedList) graph = Graph(nodes=[node], inputs=[], outputs=[]) graph.inputs = node.inputs graph.outputs = node.outputs assert not isinstance(graph.inputs, SynchronizedList) assert not isinstance(graph.outputs, SynchronizedList) class TestFunctionList(object): def test_shared_function_list(self): subgraph = Graph() node = Node("Test", attrs={"test_attr": subgraph}) main_graph = Graph(nodes=[node]) new_func = Function("func") main_graph.functions.append(new_func) assert len(main_graph.functions) == 1 assert len(subgraph.functions) == 1 assert subgraph.functions[0] == new_func def test_set_function_list(self): subgraph = Graph() node = Node("Test", attrs={"test_attr": subgraph}) main_graph = Graph(nodes=[node]) new_func = Function("func") main_graph.functions = [new_func] assert len(main_graph.functions) == 1 assert len(subgraph.functions) == 1 assert subgraph.functions[0] == new_func def test_merge_funcs(self): func1 = Function("func1", domain="domain1") func2 = Function("func1", domain="domain2") func3 = Function("func2", domain="domain1") func4 = Function("func2", domain="domain2") all_funcs = [func1, func2, func3, func4] subgraph1 = Graph(functions=[func1, func2]) node1 = Node("Test", attrs={"test_attr": subgraph1}) subgraph2 = Graph(functions=[func2, func3], nodes=[node1]) node2 = Node("Test", attrs={"test_attr": subgraph2}) main_graph = Graph(functions=[func3, func4], nodes=[node2]) assert len(main_graph.functions) == len(all_funcs) assert len(subgraph1.functions) == len(all_funcs) assert len(subgraph2.functions) == len(all_funcs) new_func = Function("func3") main_graph.functions.append(new_func) assert new_func in subgraph1.functions assert new_func in subgraph2.functions