From 8a3eb6a64db93b9e56f552d1600662fa8c7e611f Mon Sep 17 00:00:00 2001 From: seongwoo Date: Mon, 27 Jul 2026 12:12:50 +0900 Subject: [PATCH 1/3] [optimization] Implement RemoveRedundantLayoutOpsPass Introduce the optimization pass that removes redundant Reshape and Transpose operations. TICO-DCO-1.0-Signed-off-by: seongwoo --- test/unit_test/circle/passes/__init__.py | 15 + .../circle/passes/cleanup/__init__.py | 15 + .../passes/{ => cleanup}/test_cleanup.py | 0 .../passes/{ => cleanup}/test_manager.py | 0 .../circle/passes/optimization/__init__.py | 15 + .../passes/optimization/canon/__init__.py | 15 + .../passes/optimization/fold/__init__.py | 15 + .../passes/optimization/fusion/__init__.py | 15 + .../passes/optimization/remove/__init__.py | 15 + .../optimization/remove/test_layout_ops.py | 322 ++++++++++++++++++ tico/circle/passes/__init__.py | 2 + tico/circle/passes/optimization/__init__.py | 21 ++ .../passes/optimization/canon/__init__.py | 15 + .../passes/optimization/fold/__init__.py | 15 + .../passes/optimization/fusion/__init__.py | 15 + .../passes/optimization/remove/__init__.py | 21 ++ .../passes/optimization/remove/layout_ops.py | 279 +++++++++++++++ 17 files changed, 795 insertions(+) create mode 100644 test/unit_test/circle/passes/cleanup/__init__.py rename test/unit_test/circle/passes/{ => cleanup}/test_cleanup.py (100%) rename test/unit_test/circle/passes/{ => cleanup}/test_manager.py (100%) create mode 100644 test/unit_test/circle/passes/optimization/__init__.py create mode 100644 test/unit_test/circle/passes/optimization/canon/__init__.py create mode 100644 test/unit_test/circle/passes/optimization/fold/__init__.py create mode 100644 test/unit_test/circle/passes/optimization/fusion/__init__.py create mode 100644 test/unit_test/circle/passes/optimization/remove/__init__.py create mode 100644 test/unit_test/circle/passes/optimization/remove/test_layout_ops.py create mode 100644 tico/circle/passes/optimization/__init__.py create mode 100644 tico/circle/passes/optimization/canon/__init__.py create mode 100644 tico/circle/passes/optimization/fold/__init__.py create mode 100644 tico/circle/passes/optimization/fusion/__init__.py create mode 100644 tico/circle/passes/optimization/remove/__init__.py create mode 100644 tico/circle/passes/optimization/remove/layout_ops.py diff --git a/test/unit_test/circle/passes/__init__.py b/test/unit_test/circle/passes/__init__.py index e69de29b..bbe3a0b3 100644 --- a/test/unit_test/circle/passes/__init__.py +++ b/test/unit_test/circle/passes/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/cleanup/__init__.py b/test/unit_test/circle/passes/cleanup/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/cleanup/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/test_cleanup.py b/test/unit_test/circle/passes/cleanup/test_cleanup.py similarity index 100% rename from test/unit_test/circle/passes/test_cleanup.py rename to test/unit_test/circle/passes/cleanup/test_cleanup.py diff --git a/test/unit_test/circle/passes/test_manager.py b/test/unit_test/circle/passes/cleanup/test_manager.py similarity index 100% rename from test/unit_test/circle/passes/test_manager.py rename to test/unit_test/circle/passes/cleanup/test_manager.py diff --git a/test/unit_test/circle/passes/optimization/__init__.py b/test/unit_test/circle/passes/optimization/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/optimization/canon/__init__.py b/test/unit_test/circle/passes/optimization/canon/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/canon/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/optimization/fold/__init__.py b/test/unit_test/circle/passes/optimization/fold/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/fold/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/optimization/fusion/__init__.py b/test/unit_test/circle/passes/optimization/fusion/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/fusion/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/optimization/remove/__init__.py b/test/unit_test/circle/passes/optimization/remove/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/remove/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py new file mode 100644 index 00000000..f8e86d52 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py @@ -0,0 +1,322 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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 struct +import unittest +from unittest.mock import Mock + +from tico.circle.passes.optimization.remove.layout_ops import ( + _check_perm, + _get_const_data, + _is_reshape_op, + _is_transpose_op, + RemoveRedundantLayoutOpsPass, +) + + +class TestPermutationHelpers(unittest.TestCase): + """Test permutation checking helper functions.""" + + def test_check_perm_identity(self): + """Test identity permutation.""" + # perm = [0, 1, 2] + result = _check_perm([0, 1, 2], [0, 1, 2]) + self.assertTrue(result) + + def test_check_perm_inverse_2d(self): + """Test 2D inverse permutation (swap).""" + # perm1 = [1, 0], perm2 = [1, 0] + result = _check_perm([1, 0], [1, 0]) + self.assertTrue(result) + + def test_check_perm_inverse_3d(self): + """Test 3D inverse permutation.""" + # perm1 = [0, 2, 1], perm2 = [0, 2, 1] + result = _check_perm([0, 2, 1], [0, 2, 1]) + self.assertTrue(result) + + def test_check_perm_non_inverse(self): + """Test non-inverse permutations.""" + result = _check_perm([1, 0, 2], [0, 2, 1]) + self.assertFalse(result) + + def test_check_perm_length_mismatch(self): + """Test permutations of different lengths.""" + result = _check_perm([1, 0], [0, 2, 1]) + self.assertFalse(result) + + +class TestOperatorDetection(unittest.TestCase): + """Test operator type detection.""" + + def test_is_reshape_valid(self): + """Test valid Reshape operator detection.""" + operator = Mock() + operator.opcodeIndex = 0 + + opcode = Mock() + opcode.builtinCode = 26 # RESHAPE + + result = _is_reshape_op(operator, [opcode]) + self.assertTrue(result) + + def test_is_reshape_invalid_type(self): + """Test non-Reshape operator.""" + operator = Mock() + operator.opcodeIndex = 0 + + opcode = Mock() + opcode.builtinCode = 1 # ADD + + result = _is_reshape_op(operator, [opcode]) + self.assertFalse(result) + + def test_is_reshape_invalid_index(self): + """Test Reshape with invalid opcode index.""" + operator = Mock() + operator.opcodeIndex = 5 + + result = _is_reshape_op(operator, []) + self.assertFalse(result) + + def test_is_transpose_valid(self): + """Test valid Transpose operator detection.""" + operator = Mock() + operator.opcodeIndex = 0 + + opcode = Mock() + opcode.builtinCode = 54 # TRANSPOSE + + result = _is_transpose_op(operator, [opcode]) + self.assertTrue(result) + + def test_is_transpose_invalid_type(self): + """Test non-Transpose operator.""" + operator = Mock() + operator.opcodeIndex = 0 + + opcode = Mock() + opcode.builtinCode = 26 # RESHAPE + + result = _is_transpose_op(operator, [opcode]) + self.assertFalse(result) + + +class TestConstDataExtraction(unittest.TestCase): + """Test constant data extraction from graph.""" + + def test_get_const_data_valid(self): + """Test extracting valid int32 constant data.""" + graph = Mock() + + tensor = Mock() + tensor.buffer = 1 + subgraph = Mock() + subgraph.tensors = [None, tensor] + graph.subgraph = subgraph + + data = struct.pack(" output=2 + transpose1 = Mock() + transpose1.inputs = [0, 1] + transpose1.opcodeIndex = 0 + + # transpose2: input=2, perm=3 -> should cancel with transpose1 + transpose2 = Mock() + transpose2.inputs = [2, 3] + transpose2.opcodeIndex = 0 + + graph.subgraph.operators = [transpose1, transpose2] + graph.producer = Mock(return_value=0) + + opcode = Mock() + opcode.builtinCode = 54 # TRANSPOSE + graph.model.operatorCodes = [opcode] + + result = pass_obj._remove_redundant_transpose(graph, transpose2, [opcode]) + + self.assertTrue(result) + self.assertEqual(transpose2.inputs[0], 0) + + def test_remove_redundant_transpose_missing_perm(self): + """Test Transpose with missing permutation data.""" + pass_obj = RemoveRedundantLayoutOpsPass() + + graph = Mock() + graph.subgraph = Mock() + graph.subgraph.tensors = [None] # Minimal tensors list + graph.subgraph.operators = [Mock()] + + graph.model = Mock() + graph.model.buffers = [None] # Minimal buffers list + graph.model.operatorCodes = [] + + transpose = Mock() + transpose.inputs = [0, 1] + graph.producer = Mock(return_value=None) + + result = pass_obj._remove_redundant_transpose(graph, transpose, []) + + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tico/circle/passes/__init__.py b/tico/circle/passes/__init__.py index bc2a5e13..602e6185 100644 --- a/tico/circle/passes/__init__.py +++ b/tico/circle/passes/__init__.py @@ -19,6 +19,7 @@ CirclePassManagerResult, CirclePassStrategy, ) +from tico.circle.passes.optimization import RemoveRedundantLayoutOpsPass __all__ = [ "CirclePass", @@ -28,4 +29,5 @@ "CirclePassManagerResult", "CirclePassResult", "CirclePassStrategy", + "RemoveRedundantLayoutOpsPass", ] diff --git a/tico/circle/passes/optimization/__init__.py b/tico/circle/passes/optimization/__init__.py new file mode 100644 index 00000000..63d5e92a --- /dev/null +++ b/tico/circle/passes/optimization/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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 tico.circle.passes.optimization.remove.layout_ops import ( + RemoveRedundantLayoutOpsPass, +) + +__all__ = [ + "RemoveRedundantLayoutOpsPass", +] diff --git a/tico/circle/passes/optimization/canon/__init__.py b/tico/circle/passes/optimization/canon/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/tico/circle/passes/optimization/canon/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/tico/circle/passes/optimization/fold/__init__.py b/tico/circle/passes/optimization/fold/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/tico/circle/passes/optimization/fold/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/tico/circle/passes/optimization/fusion/__init__.py b/tico/circle/passes/optimization/fusion/__init__.py new file mode 100644 index 00000000..bbe3a0b3 --- /dev/null +++ b/tico/circle/passes/optimization/fusion/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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. + +__all__ = [] diff --git a/tico/circle/passes/optimization/remove/__init__.py b/tico/circle/passes/optimization/remove/__init__.py new file mode 100644 index 00000000..63d5e92a --- /dev/null +++ b/tico/circle/passes/optimization/remove/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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 tico.circle.passes.optimization.remove.layout_ops import ( + RemoveRedundantLayoutOpsPass, +) + +__all__ = [ + "RemoveRedundantLayoutOpsPass", +] diff --git a/tico/circle/passes/optimization/remove/layout_ops.py b/tico/circle/passes/optimization/remove/layout_ops.py new file mode 100644 index 00000000..74b667a3 --- /dev/null +++ b/tico/circle/passes/optimization/remove/layout_ops.py @@ -0,0 +1,279 @@ +# Copyright (c) 2026 Samsung Electronics Co., Ltd. All Rights Reserved +# +# 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 __future__ import annotations + +from typing import TYPE_CHECKING + +from tico.circle.graph import as_indices, as_list +from tico.circle.passes.base import CirclePass, CirclePassContext, CirclePassResult + +if TYPE_CHECKING: + from tico.circle.document import CircleDocument + + +def _get_builtin_code(operator: any) -> int: + """Extract the builtin operator code from an operator.""" + builtin_options = getattr(operator, "builtinOptions", None) + if builtin_options is None: + return -1 + # The builtin operator type is stored in the BuiltinOperator enum + # We need to extract it from the operator's BuiltinOptions + builtin_ops = getattr(operator, "opcodeIndex", -1) + return int(builtin_ops) if builtin_ops is not None else -1 + + +def _is_reshape_op(operator: any, operator_codes: list[any]) -> bool: + """Check if operator is a Reshape operation.""" + try: + opcode_index = int(getattr(operator, "opcodeIndex", -1)) + if opcode_index < 0 or opcode_index >= len(operator_codes): + return False + opcode = operator_codes[opcode_index] + builtin_code = int(getattr(opcode, "builtinCode", -1)) + # BuiltinOperator::RESHAPE = 26 + return builtin_code == 26 + except (TypeError, ValueError, AttributeError): + return False + + +def _is_transpose_op(operator: any, operator_codes: list[any]) -> bool: + """Check if operator is a Transpose operation.""" + try: + opcode_index = int(getattr(operator, "opcodeIndex", -1)) + if opcode_index < 0 or opcode_index >= len(operator_codes): + return False + opcode = operator_codes[opcode_index] + builtin_code = int(getattr(opcode, "builtinCode", -1)) + # BuiltinOperator::TRANSPOSE = 54 + return builtin_code == 54 + except (TypeError, ValueError, AttributeError): + return False + + +def _check_perm(first_perm: list[int], second_perm: list[int]) -> bool: + """Check if first_perm[second_perm[i]] == i for all i. + + This verifies if composing two permutations results in identity. + """ + if len(first_perm) != len(second_perm): + return False + for i in range(len(second_perm)): + if first_perm[second_perm[i]] != i: + return False + return True + + +def _get_const_data(graph: any, tensor_index: int) -> list[int] | None: + """Extract constant data from a tensor, returns list of ints or None.""" + tensors = as_list(graph.subgraph.tensors) + if tensor_index < 0 or tensor_index >= len(tensors): + return None + + tensor = tensors[tensor_index] + buffer_index = int(getattr(tensor, "buffer", 0) or 0) + if buffer_index <= 0: + return None + + buffers = as_list(graph.model.buffers) + if buffer_index >= len(buffers): + return None + + buffer = buffers[buffer_index] + if buffer is None: + return None + + data = getattr(buffer, "data", None) + if data is None: + return None + + try: + # Convert buffer data to list of ints + import struct + + result = [] + for i in range(0, len(data), 4): + if i + 4 <= len(data): + value = struct.unpack(" CirclePassResult: + """Remove redundant layout operations from all subgraphs.""" + + changes = 0 + + for subgraph_index in range(document.subgraph_count): + graph = document.graph(subgraph_index) + subgraph = graph.subgraph + operators = as_list(subgraph.operators) + operator_codes = as_list(document.model.operatorCodes) + + if not operators: + continue + + # Process operators: reshape operations + for operator in operators: + if _is_reshape_op(operator, operator_codes): + if self._remove_redundant_reshape(graph, operator): + changes += 1 + + # Process operators: transpose operations + for operator in operators: + if _is_transpose_op(operator, operator_codes): + if self._remove_redundant_transpose( + graph, operator, operator_codes + ): + changes += 1 + + context.logger.debug( + "RemoveRedundantLayoutOpsPass removed %d redundant layout operations.", + changes, + ) + return CirclePassResult( + modified=changes > 0, + changes=changes, + ) + + def _remove_redundant_reshape(self, graph: any, reshape_op: any) -> bool: + """Remove redundant consecutive Reshape operations. + + Pattern: + input → Reshape → Reshape → output + + Simplification: + input → Reshape → output + + Args: + graph: CircleGraph object with model structure + reshape_op: Current Reshape operator + + Returns: + True if optimization was applied, False otherwise + """ + inputs = as_indices(getattr(reshape_op, "inputs", None)) + if len(inputs) < 1: + return False + + input_tensor = inputs[0] + producer = graph.producer(input_tensor) + + if producer is None: + return False + + operators = as_list(graph.subgraph.operators) + operator_codes = as_list(graph.model.operatorCodes) + + if producer >= len(operators): + return False + + producer_op = operators[producer] + + # Check if producer is also a Reshape + if not _is_reshape_op(producer_op, operator_codes): + return False + + # Get producer's input + producer_inputs = as_indices(getattr(producer_op, "inputs", None)) + if len(producer_inputs) < 1: + return False + + # Connect current reshape to producer's input, skipping intermediate reshape + reshape_op.inputs = [producer_inputs[0]] + list(inputs[1:]) + return True + + def _remove_redundant_transpose( + self, graph: any, transpose_op: any, operator_codes: list[any] + ) -> bool: + """Remove or fuse consecutive Transpose operations. + + Patterns: + 1. Inverse transposes cancel out: + Transpose(perm1) → Transpose(perm2) where perm1[perm2[i]] == i + + 2. General composition: + Transpose(perm1) → Transpose(perm2) → Transpose(composite) + + Args: + graph: CircleGraph object with model structure + transpose_op: Current Transpose operator + operator_codes: List of operator codes from model + + Returns: + True if optimization was applied, False otherwise + """ + inputs = as_indices(getattr(transpose_op, "inputs", None)) + if len(inputs) < 2: + return False + + input_tensor = inputs[0] + perm_tensor = inputs[1] + + producer = graph.producer(input_tensor) + + if producer is None: + return False + + operators = as_list(graph.subgraph.operators) + + if producer >= len(operators): + return False + + producer_op = operators[producer] + + # Check if producer is also a Transpose + if not _is_transpose_op(producer_op, operator_codes): + return False + + producer_inputs = as_indices(getattr(producer_op, "inputs", None)) + if len(producer_inputs) < 2: + return False + + producer_perm_tensor = producer_inputs[1] + + # Extract permutation data + perm_data = _get_const_data(graph, perm_tensor) + producer_perm_data = _get_const_data(graph, producer_perm_tensor) + + if perm_data is None or producer_perm_data is None: + return False + + # Check if the composition is identity (inverse transpose) + if _check_perm(perm_data, producer_perm_data): + # The two transposes cancel out, connect to producer's input + main_input = producer_inputs[0] + transpose_op.inputs = [main_input] + list(inputs[1:]) + return True + + # TODO: Implement general composition optimization + # This would create a new permutation constant for the composite transpose + return False From f8ab73b707f963c4c4a075366602c459c8e38b2c Mon Sep 17 00:00:00 2001 From: seongwoo Date: Mon, 27 Jul 2026 13:53:26 +0900 Subject: [PATCH 2/3] fix lint. --- test/unit_test/circle/passes/__init__.py | 2 +- test/unit_test/circle/passes/cleanup/__init__.py | 2 +- .../circle/passes/optimization/__init__.py | 2 +- .../circle/passes/optimization/canon/__init__.py | 2 +- .../circle/passes/optimization/fold/__init__.py | 2 +- .../circle/passes/optimization/fusion/__init__.py | 2 +- .../circle/passes/optimization/remove/__init__.py | 2 +- .../passes/optimization/remove/test_layout_ops.py | 4 ++-- tico/circle/passes/optimization/canon/__init__.py | 2 +- tico/circle/passes/optimization/fold/__init__.py | 2 +- tico/circle/passes/optimization/fusion/__init__.py | 2 +- .../passes/optimization/remove/layout_ops.py | 14 +++++++------- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/test/unit_test/circle/passes/__init__.py b/test/unit_test/circle/passes/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/__init__.py +++ b/test/unit_test/circle/passes/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/cleanup/__init__.py b/test/unit_test/circle/passes/cleanup/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/cleanup/__init__.py +++ b/test/unit_test/circle/passes/cleanup/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/__init__.py b/test/unit_test/circle/passes/optimization/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/optimization/__init__.py +++ b/test/unit_test/circle/passes/optimization/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/canon/__init__.py b/test/unit_test/circle/passes/optimization/canon/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/optimization/canon/__init__.py +++ b/test/unit_test/circle/passes/optimization/canon/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/fold/__init__.py b/test/unit_test/circle/passes/optimization/fold/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/optimization/fold/__init__.py +++ b/test/unit_test/circle/passes/optimization/fold/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/fusion/__init__.py b/test/unit_test/circle/passes/optimization/fusion/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/optimization/fusion/__init__.py +++ b/test/unit_test/circle/passes/optimization/fusion/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/remove/__init__.py b/test/unit_test/circle/passes/optimization/remove/__init__.py index bbe3a0b3..64036152 100644 --- a/test/unit_test/circle/passes/optimization/remove/__init__.py +++ b/test/unit_test/circle/passes/optimization/remove/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py index f8e86d52..1c7b45ac 100644 --- a/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py +++ b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py @@ -179,11 +179,11 @@ def test_run_with_mock_document(self): graph = Mock() graph.subgraph = Mock() # Make operators iterable for as_list() - operators = [] + operators: list[object] = [] graph.subgraph.operators = operators # Make operatorCodes iterable - operator_codes = [] + operator_codes: list[object] = [] model = Mock() model.operatorCodes = operator_codes document.model = model diff --git a/tico/circle/passes/optimization/canon/__init__.py b/tico/circle/passes/optimization/canon/__init__.py index bbe3a0b3..64036152 100644 --- a/tico/circle/passes/optimization/canon/__init__.py +++ b/tico/circle/passes/optimization/canon/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/tico/circle/passes/optimization/fold/__init__.py b/tico/circle/passes/optimization/fold/__init__.py index bbe3a0b3..64036152 100644 --- a/tico/circle/passes/optimization/fold/__init__.py +++ b/tico/circle/passes/optimization/fold/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/tico/circle/passes/optimization/fusion/__init__.py b/tico/circle/passes/optimization/fusion/__init__.py index bbe3a0b3..64036152 100644 --- a/tico/circle/passes/optimization/fusion/__init__.py +++ b/tico/circle/passes/optimization/fusion/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__all__ = [] +__all__: list[str] = [] diff --git a/tico/circle/passes/optimization/remove/layout_ops.py b/tico/circle/passes/optimization/remove/layout_ops.py index 74b667a3..bed332f1 100644 --- a/tico/circle/passes/optimization/remove/layout_ops.py +++ b/tico/circle/passes/optimization/remove/layout_ops.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from tico.circle.graph import as_indices, as_list from tico.circle.passes.base import CirclePass, CirclePassContext, CirclePassResult @@ -23,7 +23,7 @@ from tico.circle.document import CircleDocument -def _get_builtin_code(operator: any) -> int: +def _get_builtin_code(operator: Any) -> int: """Extract the builtin operator code from an operator.""" builtin_options = getattr(operator, "builtinOptions", None) if builtin_options is None: @@ -34,7 +34,7 @@ def _get_builtin_code(operator: any) -> int: return int(builtin_ops) if builtin_ops is not None else -1 -def _is_reshape_op(operator: any, operator_codes: list[any]) -> bool: +def _is_reshape_op(operator: Any, operator_codes: list[Any]) -> bool: """Check if operator is a Reshape operation.""" try: opcode_index = int(getattr(operator, "opcodeIndex", -1)) @@ -48,7 +48,7 @@ def _is_reshape_op(operator: any, operator_codes: list[any]) -> bool: return False -def _is_transpose_op(operator: any, operator_codes: list[any]) -> bool: +def _is_transpose_op(operator: Any, operator_codes: list[Any]) -> bool: """Check if operator is a Transpose operation.""" try: opcode_index = int(getattr(operator, "opcodeIndex", -1)) @@ -75,7 +75,7 @@ def _check_perm(first_perm: list[int], second_perm: list[int]) -> bool: return True -def _get_const_data(graph: any, tensor_index: int) -> list[int] | None: +def _get_const_data(graph: Any, tensor_index: int) -> list[int] | None: """Extract constant data from a tensor, returns list of ints or None.""" tensors = as_list(graph.subgraph.tensors) if tensor_index < 0 or tensor_index >= len(tensors): @@ -164,7 +164,7 @@ def run( changes=changes, ) - def _remove_redundant_reshape(self, graph: any, reshape_op: any) -> bool: + def _remove_redundant_reshape(self, graph: Any, reshape_op: Any) -> bool: """Remove redundant consecutive Reshape operations. Pattern: @@ -212,7 +212,7 @@ def _remove_redundant_reshape(self, graph: any, reshape_op: any) -> bool: return True def _remove_redundant_transpose( - self, graph: any, transpose_op: any, operator_codes: list[any] + self, graph: Any, transpose_op: Any, operator_codes: list[Any] ) -> bool: """Remove or fuse consecutive Transpose operations. From 0154e77d86d6ceeecaefe2664366522a9ec98079 Mon Sep 17 00:00:00 2001 From: seongwoo Date: Mon, 27 Jul 2026 16:50:32 +0900 Subject: [PATCH 3/3] fix bug. --- .../optimization/remove/test_layout_ops.py | 154 ++++++++++++------ test/unit_test/circle/test_rewrite.py | 27 ++- .../passes/optimization/remove/layout_ops.py | 19 ++- tico/circle/rewrite.py | 76 +++++++++ 4 files changed, 220 insertions(+), 56 deletions(-) diff --git a/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py index 1c7b45ac..c2ec7db7 100644 --- a/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py +++ b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py @@ -16,6 +16,9 @@ import unittest from unittest.mock import Mock +from tico.circle.document import CircleDocument +from tico.circle.passes.base import CirclePassContext +from tico.circle.passes.cleanup import DeadCodeEliminationPass from tico.circle.passes.optimization.remove.layout_ops import ( _check_perm, _get_const_data, @@ -24,6 +27,56 @@ RemoveRedundantLayoutOpsPass, ) +from test.unit_test.circle.fixture import ( + FakeBuffer, + FakeModel, + FakeOperator, + FakeOperatorCode, + FakeSignatureDef, + FakeSubGraph, + FakeTensor, + FakeTensorMap, +) + + +def _make_inverse_transpose_document() -> CircleDocument: + """Create a non-square graph containing two inverse transposes.""" + permutation = struct.pack(" output=2 - transpose1 = Mock() - transpose1.inputs = [0, 1] - transpose1.opcodeIndex = 0 - - # transpose2: input=2, perm=3 -> should cancel with transpose1 - transpose2 = Mock() - transpose2.inputs = [2, 3] - transpose2.opcodeIndex = 0 - - graph.subgraph.operators = [transpose1, transpose2] - graph.producer = Mock(return_value=0) - - opcode = Mock() - opcode.builtinCode = 54 # TRANSPOSE - graph.model.operatorCodes = [opcode] - - result = pass_obj._remove_redundant_transpose(graph, transpose2, [opcode]) - - self.assertTrue(result) - self.assertEqual(transpose2.inputs[0], 0) + """Test inverse Transpose cancellation and dead-code removal.""" + document = _make_inverse_transpose_document() + subgraph = document.subgraph() + second_transpose = subgraph.operators[1] + context = CirclePassContext() + + result = RemoveRedundantLayoutOpsPass().run(document, context) + + self.assertTrue(result.modified) + self.assertEqual(result.changes, 1) + self.assertEqual(second_transpose.inputs, [2, 3]) + self.assertEqual(subgraph.outputs, [0]) + self.assertEqual( + document.model.signatureDefs[0].outputs[0].tensorIndex, + 0, + ) + self.assertEqual(subgraph.tensors[subgraph.outputs[0]].shape, [2, 3]) + self.assertTrue(document.verify(raise_on_error=False).ok) + + dce_result = DeadCodeEliminationPass().run(document, context) + + self.assertTrue(dce_result.modified) + self.assertEqual(subgraph.operators, []) + self.assertTrue(document.verify(raise_on_error=False).ok) + + def test_inverse_transpose_preserves_shared_first_transpose(self): + """Test that a shared first Transpose remains live after cancellation.""" + document = _make_inverse_transpose_document() + subgraph = document.subgraph() + first_transpose = subgraph.operators[0] + + fanout_operator = FakeOperator(opcodeIndex=1, inputs=[2], outputs=[5]) + subgraph.tensors.append(FakeTensor("fanout_output", shape=[3, 2])) + subgraph.operators.append(fanout_operator) + subgraph.outputs.append(5) + document.model.operatorCodes.append(FakeOperatorCode(builtinCode=0)) + document.model.signatureDefs[0].outputs.append( + FakeTensorMap("fanout_output", 5) + ) + + context = CirclePassContext() + RemoveRedundantLayoutOpsPass().run(document, context) + DeadCodeEliminationPass().run(document, context) + + self.assertEqual(len(subgraph.operators), 2) + self.assertIs(subgraph.operators[0], first_transpose) + self.assertIs(subgraph.operators[1], fanout_operator) + self.assertEqual(subgraph.outputs, [0, 5]) + self.assertTrue(document.verify(raise_on_error=False).ok) def test_remove_redundant_transpose_missing_perm(self): """Test Transpose with missing permutation data.""" @@ -311,6 +362,7 @@ def test_remove_redundant_transpose_missing_perm(self): transpose = Mock() transpose.inputs = [0, 1] + transpose.outputs = [2] graph.producer = Mock(return_value=None) result = pass_obj._remove_redundant_transpose(graph, transpose, []) diff --git a/test/unit_test/circle/test_rewrite.py b/test/unit_test/circle/test_rewrite.py index 4404650a..cad5a652 100644 --- a/test/unit_test/circle/test_rewrite.py +++ b/test/unit_test/circle/test_rewrite.py @@ -15,7 +15,7 @@ import unittest from tico.circle.errors import CircleRewriteError -from tico.circle.rewrite import compact_model, keep_subgraphs +from tico.circle.rewrite import compact_model, keep_subgraphs, replace_tensor_uses from test.unit_test.circle.fixture import make_test_document @@ -36,6 +36,31 @@ def test_compaction_remaps_tensors_signatures_and_shared_buffers(self): self.assertEqual(len(document.model.operatorCodes), 2) self.assertTrue(document.verify(raise_on_error=False).ok) + def test_replace_tensor_uses_remaps_consumers_outputs_and_signatures(self): + """Test remapping consumer and output-interface tensor references.""" + document = make_test_document() + subgraph = document.subgraph(0) + subgraph.outputs = [2] + document.model.signatureDefs[0].outputs[0].tensorIndex = 2 + + stats = replace_tensor_uses( + document.model, + subgraph_index=0, + old_tensor_index=2, + new_tensor_index=0, + ) + + self.assertTrue(stats.modified) + self.assertEqual(stats.remapped_references, 3) + self.assertEqual(subgraph.operators[0].outputs, [2]) + self.assertEqual(subgraph.operators[1].inputs, [0, 1]) + self.assertEqual(subgraph.outputs, [0]) + self.assertEqual( + document.model.signatureDefs[0].outputs[0].tensorIndex, + 0, + ) + self.assertTrue(document.verify(raise_on_error=False).ok) + def test_retained_control_flow_reference_to_dropped_subgraph_is_rejected(self): document = make_test_document() diff --git a/tico/circle/passes/optimization/remove/layout_ops.py b/tico/circle/passes/optimization/remove/layout_ops.py index bed332f1..b923a43c 100644 --- a/tico/circle/passes/optimization/remove/layout_ops.py +++ b/tico/circle/passes/optimization/remove/layout_ops.py @@ -18,6 +18,7 @@ from tico.circle.graph import as_indices, as_list from tico.circle.passes.base import CirclePass, CirclePassContext, CirclePassResult +from tico.circle.rewrite import replace_tensor_uses if TYPE_CHECKING: from tico.circle.document import CircleDocument @@ -235,6 +236,10 @@ def _remove_redundant_transpose( if len(inputs) < 2: return False + outputs = as_indices(getattr(transpose_op, "outputs", None)) + if len(outputs) != 1: + return False + input_tensor = inputs[0] perm_tensor = inputs[1] @@ -269,10 +274,16 @@ def _remove_redundant_transpose( # Check if the composition is identity (inverse transpose) if _check_perm(perm_data, producer_perm_data): - # The two transposes cancel out, connect to producer's input - main_input = producer_inputs[0] - transpose_op.inputs = [main_input] + list(inputs[1:]) - return True + # Bypass the inverse pair at the second Transpose output. Dead-code + # elimination removes the second Transpose and removes the first one + # when it has no other consumers. + replacement = replace_tensor_uses( + graph.model, + subgraph_index=graph.subgraph_index, + old_tensor_index=outputs[0], + new_tensor_index=producer_inputs[0], + ) + return replacement.modified # TODO: Implement general composition optimization # This would create a new permutation constant for the composite transpose diff --git a/tico/circle/rewrite.py b/tico/circle/rewrite.py index 9cf85d6c..58522b32 100644 --- a/tico/circle/rewrite.py +++ b/tico/circle/rewrite.py @@ -103,6 +103,82 @@ def _remap_vector( return remapped, changes +def _replace_tensor_index( + value: Any, + old_index: int, + new_index: int, +) -> tuple[list[int], int]: + """Replace a tensor index in a vector and return the number of changes.""" + original = as_indices(value) + remapped = [new_index if index == old_index else index for index in original] + return remapped, sum(old != new for old, new in zip(original, remapped)) + + +def replace_tensor_uses( + model: Any, + *, + subgraph_index: int, + old_tensor_index: int, + new_tensor_index: int, +) -> RewriteStats: + """Replace consumer and output-boundary uses of a tensor. + + Operator outputs, subgraph inputs, and intermediates are left unchanged so + dead-code elimination can remove producers that become unreachable. + Existing ``CircleGraph`` consumer indexes must be rebuilt after this rewrite. + """ + subgraphs = as_list(model.subgraphs) + subgraph_index = _valid_index( + int(subgraph_index), + len(subgraphs), + "tensor-use replacement subgraph", + ) + subgraph = subgraphs[subgraph_index] + tensor_count = len(as_list(subgraph.tensors)) + old_tensor_index = _valid_index( + int(old_tensor_index), + tensor_count, + f"subgraphs[{subgraph_index}].old_tensor_index", + ) + new_tensor_index = _valid_index( + int(new_tensor_index), + tensor_count, + f"subgraphs[{subgraph_index}].new_tensor_index", + ) + if old_tensor_index == new_tensor_index: + return RewriteStats() + + replacements = 0 + for operator in as_list(subgraph.operators): + inputs, changes = _replace_tensor_index( + getattr(operator, "inputs", None), + old_tensor_index, + new_tensor_index, + ) + if changes: + operator.inputs = inputs + replacements += changes + + outputs, changes = _replace_tensor_index( + getattr(subgraph, "outputs", None), + old_tensor_index, + new_tensor_index, + ) + if changes: + subgraph.outputs = outputs + replacements += changes + + for signature in as_list(getattr(model, "signatureDefs", None)): + if int(getattr(signature, "subgraphIndex", -1)) != subgraph_index: + continue + for tensor_map in as_list(getattr(signature, "outputs", None)): + if int(getattr(tensor_map, "tensorIndex", -1)) == old_tensor_index: + tensor_map.tensorIndex = new_tensor_index + replacements += 1 + + return RewriteStats(remapped_references=replacements) + + def _signature_tensor_maps(signature: Any) -> Iterator[tuple[str, Any]]: for field_name in ("inputs", "outputs"): for tensor_map in as_list(getattr(signature, field_name, None)):