diff --git a/test/unit_test/circle/passes/__init__.py b/test/unit_test/circle/passes/__init__.py index e69de29b..64036152 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__: list[str] = [] 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..64036152 --- /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__: list[str] = [] 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..64036152 --- /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__: list[str] = [] 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..64036152 --- /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__: list[str] = [] 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..64036152 --- /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__: list[str] = [] 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..64036152 --- /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__: list[str] = [] 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..64036152 --- /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__: 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 new file mode 100644 index 00000000..c2ec7db7 --- /dev/null +++ b/test/unit_test/circle/passes/optimization/remove/test_layout_ops.py @@ -0,0 +1,374 @@ +# 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.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, + _is_reshape_op, + _is_transpose_op, + 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(" 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 + + outputs = as_indices(getattr(transpose_op, "outputs", None)) + if len(outputs) != 1: + 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): + # 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 + return False 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)):