From 6ac2b04e5f884d8c1f5dbe12ef5f9fbb82719fbc Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Thu, 30 Mar 2023 14:24:22 -0400 Subject: [PATCH] Add MinimumPoint transpiler pass (#9612) * Add MinimumPoint transpiler pass This commit adds a new transpiler pass MinimumPoint which is used to find a local minimum point from the property set between executions of the pass. This is similar to the existing FixedPoint pass but will find the minimum fixed point over the past n exeuctions as opposed to finding a when two subsequent exectuions are at the same point. This is then used in optimization level 3 because the 2q unitary synthesis optimization that is part of the optimization loop can occasionally get stuck oscillating between multiple different equivalent decompositions which prevents the fixed point condition from ever being reached. By checking that we've reached the minimum over the last 5 executions we ensure we're reaching an exit condition in this situation. Fixes #5832 Fixes #9177 (the underlying cause of the optimization loop's inability to converge at optimization level 3 is not fixed here, there is still a root cause of instability in UnitarySynthesis this just changes the loop exit condition so we're never stuck in an infinte loop) * Doc copy-paste error cleanups * Rework logic to track state with a dataclass This commit reworks the logic of the pass to track the state via a dataclass instead of using separate property set fields. This cleans up the code for dealing with checking the current state relative to earlier iterations by making the access just attributes instead of secret strings. At the same time the tests were updated to handle this new data structure comments were added to better explain the logic flow being tested. * Fix doc typo * Apply suggestions from code review Co-authored-by: Jake Lishman * Update slots syntax for dataclass to be compatible with Python < 3.10 * Update property set check in tests --------- Co-authored-by: Jake Lishman --- qiskit/transpiler/passes/__init__.py | 2 + qiskit/transpiler/passes/utils/__init__.py | 1 + .../transpiler/passes/utils/minimum_point.py | 118 +++++++ .../transpiler/preset_passmanagers/level3.py | 17 +- ...d-minimum-point-pass-09cf9a9eec86fd48.yaml | 10 + test/python/compiler/test_transpiler.py | 2 +- test/python/transpiler/test_minimum_point.py | 289 ++++++++++++++++++ 7 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 qiskit/transpiler/passes/utils/minimum_point.py create mode 100644 releasenotes/notes/add-minimum-point-pass-09cf9a9eec86fd48.yaml create mode 100644 test/python/transpiler/test_minimum_point.py diff --git a/qiskit/transpiler/passes/__init__.py b/qiskit/transpiler/passes/__init__.py index dd4689827943..662eb6c3324c 100644 --- a/qiskit/transpiler/passes/__init__.py +++ b/qiskit/transpiler/passes/__init__.py @@ -172,6 +172,7 @@ RemoveFinalMeasurements DAGFixedPoint FixedPoint + MinimumPoint ContainsInstruction GatesInBasis ConvertConditionsToIfOps @@ -283,6 +284,7 @@ from .utils import MergeAdjacentBarriers from .utils import DAGFixedPoint from .utils import FixedPoint +from .utils import MinimumPoint from .utils import Error from .utils import RemoveBarriers from .utils import ContainsInstruction diff --git a/qiskit/transpiler/passes/utils/__init__.py b/qiskit/transpiler/passes/utils/__init__.py index 311bae726514..c69244b20b4a 100644 --- a/qiskit/transpiler/passes/utils/__init__.py +++ b/qiskit/transpiler/passes/utils/__init__.py @@ -27,6 +27,7 @@ from .contains_instruction import ContainsInstruction from .gates_basis import GatesInBasis from .convert_conditions_to_if_ops import ConvertConditionsToIfOps +from .minimum_point import MinimumPoint # Utility functions from . import control_flow diff --git a/qiskit/transpiler/passes/utils/minimum_point.py b/qiskit/transpiler/passes/utils/minimum_point.py new file mode 100644 index 000000000000..8ccfa4a7f951 --- /dev/null +++ b/qiskit/transpiler/passes/utils/minimum_point.py @@ -0,0 +1,118 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Check if the DAG has reached a relative semi-stable point over previous runs.""" + +from copy import deepcopy +from dataclasses import dataclass +import math +from typing import Tuple + +from qiskit.dagcircuit.dagcircuit import DAGCircuit +from qiskit.transpiler.basepasses import TransformationPass + + +class MinimumPoint(TransformationPass): + """Check if the DAG has reached a relative semi-stable point over previous runs + + This pass is similar to the :class:`~.FixedPoint` transpiler pass and is intended + primarily to be used to set a loop break condition in the property set. + However, unlike the :class:`~.FixedPoint` class which only sets the + condition if 2 consecutive runs have the same value property set value + this pass is designed to find a local minimum and use that instead. This + pass is designed for an optimization loop where a fixed point may never + get reached (for example if synthesis is used and there are multiple + equivalent outputs for some cases). + + This pass will track the state of fields in the property set over its past + executions and set a boolean field when either a fixed point is reached + over the backtracking depth or selecting the minimum value found if the + backtracking depth is reached. To do this it stores a deep copy of the + current minimum DAG in the property set and when ``backtrack_depth`` number + of executions is reached since the last minimum the output dag is set to + that copy of the earlier minimum. + + Fields used by this pass in the property set are (all relative to the ``prefix`` + argument): + + * ``{prefix}_minimum_point_state`` - Used to track the state of the minimpoint search + * ``{prefix}_minimum_point`` - This value gets set to ``True`` when either a fixed point + is reached over the ``backtrack_depth`` executions, or ``backtrack_depth`` was exceeded + and an earlier minimum is restored. + """ + + def __init__(self, property_set_list, prefix, backtrack_depth=5): + """Initialize an instance of this pass + + Args: + property_set_list (list): A list of property set keys that will + be used to evaluate the local minimum. The values of these + property set keys will be used as a tuple for comparison + prefix (str): The prefix to use for the property set key that is used + for tracking previous evaluations + backtrack_depth (int): The maximum number of entries to store. If + this number is reached and the next iteration doesn't have + a decrease in the number of values the minimum of the previous + n will be set as the output dag and ``minimum_point`` will be set to + ``True`` in the property set + """ + super().__init__() + self.property_set_list = property_set_list + + self.backtrack_name = f"{prefix}_minimum_point_state" + self.minimum_reached = f"{prefix}_minimum_point" + self.backtrack_depth = backtrack_depth + + def run(self, dag): + """Run the MinimumPoint pass on `dag`.""" + score = tuple(self.property_set[x] for x in self.property_set_list) + state = self.property_set[self.backtrack_name] + + # The pass starts at None and the first iteration doesn't set a real + # score so the overall loop is treated as a do-while to ensure we have + # at least 2 iterations. + if state is None: + self.property_set[self.backtrack_name] = _MinimumPointState( + dag=None, score=(math.inf,) * len(self.property_set_list), since=0 + ) + # If the score of this execution is worse than the previous execution + # increment 'since' since we have not found a new minimum point + elif score > state.score: + state.since += 1 + if state.since == self.backtrack_depth: + self.property_set[self.minimum_reached] = True + return self.property_set[self.backtrack_name].dag + # If the score has decreased (gotten better) then this iteration is + # better performing and this iteration should be the new minimum state. + # So update the state to be this iteration and reset counter + elif score < state.score: + state.since = 1 + state.score = score + state.dag = deepcopy(dag) + # If the current execution is equal to the previous minimum value then + # we've reached an equivalent fixed point and we should use this iteration's + # dag as the output and set the property set flag that we've found a minimum + # point. + elif score == state.score: + self.property_set[self.minimum_reached] = True + return dag + + return dag + + +@dataclass +class _MinimumPointState: + __slots__ = ("dag", "score", "since") + + dag: DAGCircuit + score: Tuple[float, ...] + since: int diff --git a/qiskit/transpiler/preset_passmanagers/level3.py b/qiskit/transpiler/preset_passmanagers/level3.py index f9d270e34fc8..34dc592afc89 100644 --- a/qiskit/transpiler/preset_passmanagers/level3.py +++ b/qiskit/transpiler/preset_passmanagers/level3.py @@ -28,7 +28,7 @@ from qiskit.transpiler.passes import DenseLayout from qiskit.transpiler.passes import NoiseAdaptiveLayout from qiskit.transpiler.passes import SabreLayout -from qiskit.transpiler.passes import FixedPoint +from qiskit.transpiler.passes import MinimumPoint from qiskit.transpiler.passes import Depth from qiskit.transpiler.passes import Size from qiskit.transpiler.passes import RemoveResetInZeroState @@ -151,11 +151,14 @@ def _vf2_match_not_found(property_set): # 8. Optimize iteratively until no more change in depth. Removes useless gates # after reset and before measure, commutes gates and optimizes contiguous blocks. - _depth_check = [Depth(recurse=True), FixedPoint("depth")] - _size_check = [Size(recurse=True), FixedPoint("size")] + _minimum_point_check = [ + Depth(recurse=True), + Size(recurse=True), + MinimumPoint(["depth", "size"], "optimization_loop"), + ] def _opt_control(property_set): - return (not property_set["depth_fixed_point"]) or (not property_set["size_fixed_point"]) + return not property_set["optimization_loop_minimum_point"] _opt = [ Collect2qBlocks(), @@ -249,7 +252,7 @@ def _unroll_condition(property_set): ConditionalController(unroll, condition=_unroll_condition), ] - optimization.append(_depth_check + _size_check) + optimization.append(_minimum_point_check) if (coupling_map and not coupling_map.is_symmetric) or ( target is not None and target.get_non_global_operation_names(strict_direction=True) ): @@ -261,13 +264,13 @@ def _unroll_condition(property_set): ] if optimization is not None: optimization.append( - _opt + _unroll_if_out_of_basis + _depth_check + _size_check, + _opt + _unroll_if_out_of_basis + _minimum_point_check, do_while=_opt_control, ) else: pre_optimization = common.generate_pre_op_passmanager(remove_reset_in_zero=True) optimization.append( - _opt + _unroll_if_out_of_basis + _depth_check + _size_check, do_while=_opt_control + _opt + _unroll_if_out_of_basis + _minimum_point_check, do_while=_opt_control ) else: optimization = plugin_manager.get_passmanager_stage( diff --git a/releasenotes/notes/add-minimum-point-pass-09cf9a9eec86fd48.yaml b/releasenotes/notes/add-minimum-point-pass-09cf9a9eec86fd48.yaml new file mode 100644 index 000000000000..8cbddaed6881 --- /dev/null +++ b/releasenotes/notes/add-minimum-point-pass-09cf9a9eec86fd48.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Added a new transpiler pass, :class:`~.MinimumPoint` which is used primarily as + a pass to check a loop condition in a :class:`~.PassManager`. This pass will + track the state of fields in the property set over its past executions and set + a boolean field when either a fixed point is reached over the backtracking depth + or selecting the minimum value found if the backtracking depth is reached. This + is an alternative to the :class:`~.FixedPoint` which simply checks for a fixed + value in a property set field between subsequent executions. diff --git a/test/python/compiler/test_transpiler.py b/test/python/compiler/test_transpiler.py index 15cec11eeb5a..aa43025f3d2f 100644 --- a/test/python/compiler/test_transpiler.py +++ b/test/python/compiler/test_transpiler.py @@ -1364,7 +1364,7 @@ def test_transpile_optional_registers(self, optimization_level): out = transpile(qc, FakeBoeblingen(), optimization_level=optimization_level) self.assertEqual(len(out.qubits), FakeBoeblingen().configuration().num_qubits) - self.assertEqual(out.clbits, clbits) + self.assertEqual(len(out.clbits), len(clbits)) @data(0, 1, 2, 3) def test_translate_ecr_basis(self, optimization_level): diff --git a/test/python/transpiler/test_minimum_point.py b/test/python/transpiler/test_minimum_point.py new file mode 100644 index 000000000000..0213427982af --- /dev/null +++ b/test/python/transpiler/test_minimum_point.py @@ -0,0 +1,289 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""MinimumPoint pass testing""" + +import math + +from qiskit.transpiler.passes import MinimumPoint +from qiskit.dagcircuit import DAGCircuit +from qiskit.test import QiskitTestCase + + +class TestMinimumPointtPass(QiskitTestCase): + """Tests for MinimumPoint pass.""" + + def test_minimum_point_reached_fixed_point_single_field(self): + """Test a fixed point is reached with a single field.""" + + min_pass = MinimumPoint(["depth"], prefix="test") + dag = DAGCircuit() + min_pass.property_set["depth"] = 42 + min_pass.run(dag) + # After first iteration state is only initialized but not populated + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 0) + self.assertEqual((math.inf,), state.score) + self.assertIsNone(state.dag) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Second iteration + min_pass.run(dag) + # After second iteration the state is initialized + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual(state.score, (42,)) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Third iteration + out_dag = min_pass.run(dag) + # After 3rd iteration we've reached a fixed point equal to our minimum + # ooint so we return the minimum dag + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual((42,), state.score) + self.assertTrue(min_pass.property_set["test_minimum_point"]) + # In case of fixed point we don't return copy but the state of the dag + # after the fixed point so only assert equality + self.assertEqual(out_dag, state.dag) + + def test_minimum_point_reached_fixed_point_multiple_fields(self): + """Test a fixed point is reached with a multiple fields.""" + min_pass = MinimumPoint(["fidelity", "depth", "size"], prefix="test") + dag = DAGCircuit() + min_pass.property_set["fidelity"] = 0.875 + min_pass.property_set["depth"] = 15 + min_pass.property_set["size"] = 20 + min_pass.run(dag) + # After first iteration state is only initialized but not populated + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 0) + self.assertEqual((math.inf, math.inf, math.inf), state.score) + self.assertIsNone(state.dag) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Two + min_pass.run(dag) + # After second iteration the state is initialized + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual(state.score, (0.875, 15, 20)) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Three + out_dag = min_pass.run(dag) + # After 3rd iteration we've reached a fixed point equal to our minimum + # ooint so we return the minimum dag + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual(state.score, (0.875, 15, 20)) + self.assertTrue(min_pass.property_set["test_minimum_point"]) + # In case of fixed point we don't return copy but the state of the dag + # after the fixed point so only assert equality + self.assertEqual(out_dag, state.dag) + + def test_min_over_backtrack_range(self): + """Test minimum returned over backtrack depth.""" + min_pass = MinimumPoint(["fidelity", "depth", "size"], prefix="test") + dag = DAGCircuit() + min_pass.property_set["fidelity"] = 0.875 + min_pass.property_set["depth"] = 15 + min_pass.property_set["size"] = 20 + min_pass.run(dag) + # After first iteration state is only initialized but not populated + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 0) + self.assertEqual((math.inf, math.inf, math.inf), state.score) + self.assertIsNone(state.dag) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Two + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 25 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # After second iteration we've set a current minimum state + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration three + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 45 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # After third iteration score is worse than minimum point just bump since + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 2) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration four + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + min_pass.run(dag) + # After fourth iteration score is worse than minimum point just bump since + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 3) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration five + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + min_pass.run(dag) + # After fifth iteration score is worse than minimum point just bump since + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 4) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration six + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + out_dag = min_pass.run(dag) + # After sixth iteration score is worse, but we've reached backtrack depth and the + # dag copy is returned + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 5) + self.assertEqual((0.775, 25, 35), state.score) + self.assertTrue(min_pass.property_set["test_minimum_point"]) + self.assertIs(out_dag, state.dag) + + def test_min_reset_backtrack_range(self): + """Test minimum resets backtrack depth.""" + min_pass = MinimumPoint(["fidelity", "depth", "size"], prefix="test") + dag = DAGCircuit() + min_pass.property_set["fidelity"] = 0.875 + min_pass.property_set["depth"] = 15 + min_pass.property_set["size"] = 20 + min_pass.run(dag) + # After first iteration state is only initialized but not populated + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 0) + self.assertEqual((math.inf, math.inf, math.inf), state.score) + self.assertIsNone(state.dag) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration two: + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 25 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # After second iteration we've set a current minimum state + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration three: + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 45 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # Third iteration the score is worse (because of depth increasing) so do + # not set new minimum point + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 2) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration four: + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + min_pass.run(dag) + # Fourth iteration the score is also worse than minmum although depth + # is better than iteration three it's still higher than the minimum point + # Also size has increased:. Do not update minimum point and since is increased + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 3) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration five + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + min_pass.run(dag) + # Fifth iteration the score is also worse than minmum although the same + # with previous iteration. This means do not update minimum point and bump since + # value + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 4) + self.assertEqual((0.775, 25, 35), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Six + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 10 + min_pass.property_set["size"] = 10 + min_pass.run(dag) + # Sixth iteration the score is lower (fidelity is the same but depth and size decreased) + # set new minimum point + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 1) + self.assertEqual((0.775, 10, 10), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration seven + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 25 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # Iteration seven the score is worse than the minimum point. Do not update minimum point + # and since is bumped + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 2) + self.assertEqual((0.775, 10, 10), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Eight + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 45 + min_pass.property_set["size"] = 35 + min_pass.run(dag) + # Iteration eight the score is worse than the minimum point. Do not update minimum point + # and since is bumped + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 3) + self.assertEqual((0.775, 10, 10), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration Nine + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + min_pass.run(dag) + # Iteration nine the score is worse than the minium point. Do not update minimum point + # and since is bumped + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 4) + self.assertEqual((0.775, 10, 10), state.score) + self.assertIsNone(min_pass.property_set["test_minimum_point"]) + + # Iteration 10 + min_pass.property_set["fidelity"] = 0.775 + min_pass.property_set["depth"] = 36 + min_pass.property_set["size"] = 40 + out_dag = min_pass.run(dag) + # Iteration 10 score is worse, but we've reached the set backtrack + # depth of 5 iterations since the last minimum so we exit here + state = min_pass.property_set["test_minimum_point_state"] + self.assertEqual(state.since, 5) + self.assertEqual((0.775, 10, 10), state.score) + self.assertTrue(min_pass.property_set["test_minimum_point"]) + self.assertIs(out_dag, state.dag)