From 69792ffd7c10876dd20a704e490d22847cfd57b6 Mon Sep 17 00:00:00 2001 From: Olivia Di Matteo Date: Sat, 24 Aug 2024 13:35:30 -0700 Subject: [PATCH] Relocate equivalence checker. Add a test suite for it. --- ionizer/transforms.py | 71 ++++++++++++------------ ionizer/utils.py | 21 +++++++ tests/test_transforms.py | 50 +++++++++++------ tests/test_utils.py | 116 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 53 deletions(-) diff --git a/ionizer/transforms.py b/ionizer/transforms.py index f37c232..c509085 100644 --- a/ionizer/transforms.py +++ b/ionizer/transforms.py @@ -20,6 +20,13 @@ performs end-to-end transpilation and optimization of circuits. It calls a number of helper transforms which can also be used individually. +While all transforms should preserve the behaviour of the circuit, they contain +a mechanism for under-the-hood equivalence checking (up to a global phase) +through the ``verify_equivalence`` flag. When set, an error will be raised if +the transpiled circuit is not equivalent to the original. This flag is ``False`` +by default as equivalence is checked at the unitary matrix level. For more details, see +:func:`ionizer.utils.flag_non_equivalence`. + """ from typing import Sequence, Callable @@ -32,7 +39,7 @@ from pennylane.tape import QuantumTape from pennylane.transforms.optimization.optimization_utils import find_next_gate -from .utils import are_mats_equivalent, rescale_angles, extract_gpi2_gpi_gpi2_angles +from .utils import flag_non_equivalence, rescale_angles, extract_gpi2_gpi_gpi2_angles from .decompositions import decomp_map from .ops import GPI, GPI2 from .identity_hunter import ( @@ -41,25 +48,6 @@ ) -def _check_equivalence(tape1, tape2): - """Check equivalence of two tapes up to a global phase. - - Args: - tape1 (pennylane.QuantumTape): a quantum tape - tape2 (pennylane.QuantumTape): quantum tape to compare with ``tape1`` - - Raises: - ValueError if the two circuits are not equivalent. - """ - # Compute matrix representation using a consistent wire order - joint_wires = qml.wires.Wires.all_wires([tape1.wires, tape2.wires]) - matrix_1 = qml.matrix(tape1, wire_order=joint_wires) - matrix_2 = qml.matrix(tape2, wire_order=joint_wires) - - if not are_mats_equivalent(matrix_1, matrix_2): - raise ValueError("Quantum circuits are not equivalent after transform.") - - @qml.transform def commute_through_ms_gates( tape: QuantumTape, direction="right", verify_equivalence=False @@ -82,8 +70,9 @@ def commute_through_ms_gates( tape (pennylane.QuantumTape): A quantum tape to transform. direction (str): Either ``"right"`` (default) or ``"left"`` to indicate the direction gates should move (from a circuit diagram perspective). - verify_equivalence (bool): Whether to perform equivalence checking of - the circuit before and after the transform. + verify_equivalence (bool): Whether to perform background equivalence + checking (up to global phase) of the circuit before and after the + transform. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], @@ -174,7 +163,7 @@ def circuit(): new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots) if verify_equivalence: - _check_equivalence(tape, new_tape) + flag_non_equivalence(tape, new_tape) def null_postprocessing(results): """A postprocessing function returned by a transform that only converts the batch of results @@ -203,9 +192,10 @@ def virtualize_rz_gates(tape: QuantumTape, verify_equivalence=False) -> (Sequenc .. math:: RZ(\phi) = GPI(0) GPI(-\phi/2). Args: - tape (pennylane.QuantumTape): A quantum tape to transform. - verify_equivalence (bool): Whether to perform equivalence checking of - the circuit before and after the transform. + tape (pennylane.QuantumTape): A quantum tape to transform + verify_equivalence (bool): Whether to perform background equivalence + checking (up to global phase) of the circuit before and after the + transform. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], @@ -321,7 +311,7 @@ def circuit(): new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots) if verify_equivalence: - _check_equivalence(tape, new_tape) + flag_non_equivalence(tape, new_tape) def null_postprocessing(results): """A postprocessing function returned by a transform that only converts the batch of results @@ -351,8 +341,9 @@ def single_qubit_fusion_gpi(tape: QuantumTape, verify_equivalence=False) -> (Seq Args: tape (pennylane.QuantumTape): A quantum tape to transform. - verify_equivalence (bool): Whether to perform equivalence checking of - the circuit before and after the transform. + verify_equivalence (bool): Whether to perform background equivalence + checking (up to global phase) of the circuit before and after the + transform. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], @@ -467,7 +458,7 @@ def circuit(): new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots) if verify_equivalence: - _check_equivalence(tape, new_tape) + flag_non_equivalence(tape, new_tape) def null_postprocessing(results): """A postprocessing function returned by a transform that only converts the batch of results @@ -486,8 +477,9 @@ def convert_to_gpi(tape: QuantumTape, exclude_list=None, verify_equivalence=Fals tape (pennylane.QuantumTape): A quantum tape to transform. exclude_list (list[str]): A list of names of gates to exclude from conversion (see the ionize transform for an example). - verify_equivalence (bool): Whether to perform equivalence checking of - the circuit before and after the transform. + verify_equivalence (bool): Whether to perform background equivalence + checking (up to global phase) of the circuit before and after the + transform. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], @@ -555,7 +547,7 @@ def stop_at(op): new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots) if verify_equivalence: - _check_equivalence(tape, new_tape) + flag_non_equivalence(tape, new_tape) def null_postprocessing(results): """A postprocessing function returned by a transform that only converts the batch of results @@ -581,10 +573,17 @@ def ionize(tape: QuantumTape, verify_equivalence=False) -> (Sequence[QuantumTape :math:`MS` gates, and perform simplification based on a database of circuit identities. + .. note:: + + When ``verify_equivalence`` is set to ``True``, equivalence checking up + to a global phase is performed with respect to the initial and final + circuit matrices only. It is not checked for intermediate transforms. + Args: tape (pennylane.QuantumTape): A quantum tape to transform. - verify_equivalence (bool): Whether to perform equivalence checking of - the circuit before and after the transform. + verify_equivalence (bool): Whether to perform background equivalence + checking (up to global phase) of the circuit before and after the + transform. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], @@ -642,7 +641,7 @@ def stop_at(op): new_tape = type(tape)(optimized_tape[0].operations, tape.measurements, shots=tape.shots) if verify_equivalence: - _check_equivalence(tape, new_tape) + flag_non_equivalence(tape, new_tape) def null_postprocessing(results): """A postprocessing function returned by a transform that only converts the batch of results diff --git a/ionizer/utils.py b/ionizer/utils.py index 6c16d26..5141ce2 100644 --- a/ionizer/utils.py +++ b/ionizer/utils.py @@ -18,6 +18,7 @@ Utility functions. """ +import pennylane as qml import numpy as np from pennylane import math @@ -54,6 +55,26 @@ def are_mats_equivalent(unitary1, unitary2): return False +def flag_non_equivalence(tape1, tape2): + """Check equivalence of two circuits up to a global phase. + + Args: + tape1 (pennylane.QuantumTape): a quantum tape + tape2 (pennylane.QuantumTape): quantum tape to compare with ``tape1`` + + Raises: + ValueError if the two circuits are not equivalent. + """ + # Compute matrix representation using a consistent wire order + joint_wires = qml.wires.Wires.all_wires([tape1.wires, tape2.wires]) + matrix_1 = qml.matrix(tape1, wire_order=joint_wires) + matrix_2 = qml.matrix(tape2, wire_order=joint_wires) + + if not are_mats_equivalent(matrix_1, matrix_2): + raise ValueError("Quantum circuits are not equivalent after transform.") + + + def rescale_angles(angles, renormalize_for_json=False): r"""Rescale gate rotation angles into a fixed range. diff --git a/tests/test_transforms.py b/tests/test_transforms.py index 20e3fa5..69ec56d 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -178,7 +178,8 @@ def expected_qfunc(): qml.matrix(expected_tape, wire_order=range(3)), ) - def test_commutation_two_qubits_multiple_ms_left(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_commutation_two_qubits_multiple_ms_left(self, verify_equivalence): """Test case where gates on one of the qubits commutes in leftward direction.""" def qfunc(): @@ -195,7 +196,9 @@ def expected_qfunc(): GPI2(np.pi, wires=2) MS(wires=[0, 2]) - transformed_qfunc = partial(commute_through_ms_gates, direction="left")(qfunc) + transformed_qfunc = partial( + commute_through_ms_gates, direction="left", verify_equivalence=verify_equivalence + )(qfunc) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() expected_tape = qml.tape.make_qscript(expected_qfunc)() @@ -267,7 +270,8 @@ def qfunc(): qml.matrix(transformed_tape, wire_order=[0, 1]), ) - def test_rz_gpi_multiqubit(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_rz_gpi_multiqubit(self, verify_equivalence): """Test that RZ gates are virtualized on multiple qubits.""" def qfunc(): @@ -278,7 +282,9 @@ def qfunc(): tape = qml.tape.make_qscript(qfunc)() - transformed_qfunc = virtualize_rz_gates(qfunc) + transformed_qfunc = partial(virtualize_rz_gates, verify_equivalence=verify_equivalence)( + qfunc + ) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() assert len(transformed_tape.operations) == 4 @@ -291,7 +297,8 @@ def qfunc(): qml.matrix(transformed_tape, wire_order=[0, 1]), ) - def test_rz_gpi_multiqubit_multims(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_rz_gpi_multiqubit_multims(self, verify_equivalence): """Test that RZ gates are virtualized on multiple qubits with gates in between.""" def qfunc(): @@ -304,7 +311,9 @@ def qfunc(): tape = qml.tape.make_qscript(qfunc)() - transformed_qfunc = virtualize_rz_gates(qfunc) + transformed_qfunc = partial(virtualize_rz_gates, verify_equivalence=verify_equivalence)( + qfunc + ) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() assert len(transformed_tape.operations) == 6 @@ -396,7 +405,8 @@ def qfunc(): qml.matrix(transformed_tape, wire_order=range(3)), ) - def test_fusion_four_gates(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_fusion_four_gates(self, verify_equivalence): """Test that more than three gates get properly fused.""" def qfunc(): @@ -416,7 +426,9 @@ def qfunc(): tape = qml.tape.make_qscript(qfunc)() - transformed_qfunc = single_qubit_fusion_gpi(qfunc) + transformed_qfunc = partial(single_qubit_fusion_gpi, verify_equivalence=verify_equivalence)( + qfunc + ) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() assert len(transformed_tape.operations) == len(tape.operations) - 2 @@ -429,7 +441,8 @@ def qfunc(): class TestConvertToGPI: """Tests that operations on a tape are correctly converted to GPI gates.""" - def test_convert_tape_to_gpi_known_gates(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_convert_tape_to_gpi_known_gates(self, verify_equivalence): """Test that known gates are correctly converted to GPI gates.""" def qfunc(): @@ -440,7 +453,7 @@ def qfunc(): tape = qml.tape.make_qscript(qfunc)() - transformed_qfunc = convert_to_gpi(qfunc) + transformed_qfunc = partial(convert_to_gpi, verify_equivalence=verify_equivalence)(qfunc) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() assert all(op.name in ["GPI", "GPI2", "MS"] for op in transformed_tape.operations) @@ -449,7 +462,8 @@ def qfunc(): qml.matrix(transformed_tape, wire_order=range(3)), ) - def test_convert_tape_to_gpi_known_gates_exclusion(self): + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_convert_tape_to_gpi_known_gates_exclusion(self, verify_equivalence): """Test that known gates are correctly converted to GPI gates and excluded gates are kept as-is.""" @@ -461,7 +475,9 @@ def qfunc(): tape = qml.tape.make_qscript(qfunc)() - transformed_qfunc = partial(convert_to_gpi, exclude_list=["RY"])(qfunc) + transformed_qfunc = partial( + convert_to_gpi, exclude_list=["RY"], verify_equivalence=verify_equivalence + )(qfunc) transformed_tape = qml.tape.make_qscript(transformed_qfunc)() assert all(op.name in ["GPI", "GPI2", "MS", "RY"] for op in transformed_tape.operations) @@ -502,8 +518,7 @@ def qfunc(): qml.matrix(transformed_tape, wire_order=range(4)), ) - @pytest.mark.parametrize("verify_equivalence", [True, False]) - def test_ionize_qnode(self, verify_equivalence): + def test_ionize_qnode(self): """Test ionize transform on a QNode.""" dev = qml.device("default.qubit", wires=5) @@ -521,7 +536,7 @@ def normal_qnode(): return qml.expval(qml.PauliX(0) @ qml.PauliY(wires=1)) @qml.qnode(dev) - @partial(ionize, verify_equivalence=verify_equivalence) + @ionize def ionized_qnode(): quantum_function() return qml.expval(qml.PauliX(0) @ qml.PauliY(wires=1)) @@ -545,7 +560,8 @@ def ionized_qnode(): ], ) def test_ionize_parametrized_qnode(self, params, verify_equivalence): - """Test ionize transform on a QNode.""" + """Test ionize transform on a QNode including parametrized gates. + Verify also that equivalence is preserved.""" dev = qml.device("default.qubit", wires=5) def quantum_function(params): @@ -572,7 +588,7 @@ def ionized_qnode(params): print(normal_qnode.qtape) print(ionized_qnode.qtape) - + assert all(op.name in ["GPI", "GPI2", "MS"] for op in ionized_qnode.qtape.operations) assert are_mats_equivalent( qml.matrix(normal_qnode, wire_order=range(4))(params), diff --git a/tests/test_utils.py b/tests/test_utils.py index d920234..173b3c7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,6 +4,8 @@ import pytest +from functools import partial + import numpy as np import pennylane as qml from pennylane import math @@ -11,6 +13,7 @@ from ionizer.ops import GPI, GPI2, MS from ionizer.utils import ( are_mats_equivalent, + flag_non_equivalence, rescale_angles, extract_gpi2_gpi_gpi2_angles, tape_to_json, @@ -19,6 +22,119 @@ from test_decompositions import single_qubit_unitaries # pylint: disable=wrong-import-order +@qml.transform +def add_bad_gates(tape, verify_equivalence=False): + """A transform that behaves incorrectly. + + Used to test the equivalence checking mechanism. Since all our + implemented transforms preserve equivalence, we create this "bad" transform, + which has the same structure as the others, to validate that an error is + raised when the transformed circuit is not equivalent. + """ + + new_operations = [] + for op in tape.operations: + new_operations.append(op) + new_operations.append(op) + + new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots) + + if verify_equivalence: + flag_non_equivalence(tape, new_tape) + + def null_postprocessing(results): + return results[0] + + return [new_tape], null_postprocessing + + +class TestEquivalenceMechanism: + + def test_equivalence_tape(self): + """Test that non-equivalence is correctly detected when a transform is + applied to tapes.""" + + with qml.tape.QuantumTape() as tape: + qml.Hadamard(wires=0) + qml.CNOT(wires=[0, 1]) + + # Will pass without issue + add_bad_gates(tape) + + with pytest.raises(ValueError, match="not equivalent after transform"): + _ = partial(add_bad_gates, verify_equivalence=True)(tape) + + def test_equivalence_qfunc(self): + """Test that non-equivalence is correctly detected for quantum + function transforms.""" + + def qfunc(): + qml.Hadamard(wires=0) + qml.CNOT(wires=[0, 1]) + return qml.probs(wires=[0, 1]) + + transformed_qfunc = add_bad_gates(qfunc) + _ = qml.tape.make_qscript(transformed_qfunc)() + + with pytest.raises(ValueError, match="not equivalent after transform"): + transformed_qfunc = partial(add_bad_gates, verify_equivalence=True)(qfunc) + _ = qml.tape.make_qscript(transformed_qfunc)() + + def test_equivalence_qnode(self): + """Test that non-equivalence is correctly detected for transforms applied to QNodes.""" + dev = qml.device("default.qubit", wires=2) + + @qml.qnode(dev) + def circuit(): + qml.Hadamard(wires=0) + qml.CNOT(wires=[0, 1]) + return qml.probs(wires=[0, 1]) + + # Will pass without issue; note that the QNode must be called for the + # transform to execute. + transformed_qnode = add_bad_gates(circuit) + transformed_qnode() + + with pytest.raises(ValueError, match="not equivalent after transform"): + transformed_qnode = partial(add_bad_gates, verify_equivalence=True)(circuit) + transformed_qnode() + + def test_equivalence_qnode_default(self): + """Test that non-equivalence is not detected if we do not add the flag + in the decorator.""" + dev = qml.device("default.qubit", wires=2) + + @qml.qnode(dev) + @add_bad_gates + def circuit(): + qml.Hadamard(wires=0) + qml.CNOT(wires=[0, 1]) + return qml.probs(wires=[0, 1]) + + circuit() + + @pytest.mark.parametrize("verify_equivalence", [True, False]) + def test_equivalence_composition(self, verify_equivalence): + """Test that non-equivalence is correctly detected for a bad transform applied + before a good one in a QNode.""" + dev = qml.device("default.qubit", wires=2) + + @qml.qnode(dev) + @partial(add_bad_gates, verify_equivalence=verify_equivalence) + @qml.transforms.cancel_inverses + def circuit(): + qml.Hadamard(wires=0) + qml.Hadamard(wires=0) + qml.CNOT(wires=[0, 1]) + return qml.probs(wires=[0, 1]) + + if verify_equivalence is True: + with pytest.raises(ValueError, match="not equivalent after transform"): + circuit() + else: + circuit() + + class TestMatrixAngleUtilities: """Test utility functions for matrix and angle manipulations."""