From 008dde3d56a5e9fa560519be6b76748f6e3a4532 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Mon, 8 Jul 2024 18:25:34 +0200 Subject: [PATCH] Improve the performance of the `ProductFormula` synthesizers (#12724) * [WIP] adds the output argument to the internal atomic evolution * meta: modernize type hints * refactor: change callable structure of atomic evolution This changes the structure of the `atomic_evolution` callable in the `ProductFormula` synthesis class. This is motivated by the significant performance improvements that can be obtained by appending to the existing circuit directly rather than building out individual evolution circuits and iteratively composing them. * refactor: deprecate the legacy atomic_evolution signature * refactor: add the wrap argument to ProductFormula This can be used to recover the previous behavior in which the single individually evolved Pauli terms get wrapped into gate objects. * fix: insert the missing barriers between LieTrotter repetitions * refactor: align SuzukiTrotter and LieTrotter * Propoagate deprecation notice * fix: the labels of wrapped Pauli evolutions * fix: respect the insert_barriers setting * docs: add a release note * Apply suggestions from code review Co-authored-by: Julien Gacon * fix: missing `wrap` forward in SuzukiTrotter * docs: improve documentation of the `atomic_evolution` argument Co-authored-by: Julien Gacon * docs: also document the deprecated form of `atomic_evolution` * docs: call out ProductFormula docs in release note * refactor: change to PendingDeprecationWarning * refactor: explicitly convert to Gate when wrapping This is slightly faster than the `.compose`-based operation done previously as it performs fewer checks. Thanks to @jakelishman for the suggestion offline. * Update qiskit/synthesis/evolution/lie_trotter.py Co-authored-by: Julien Gacon * docs: update after pending deprecation --------- Co-authored-by: Julien Gacon --- qiskit/circuit/quantumcircuit.py | 7 +- .../evolution/evolution_synthesis.py | 6 +- qiskit/synthesis/evolution/lie_trotter.py | 65 +++++-- qiskit/synthesis/evolution/product_formula.py | 166 ++++++++++++------ qiskit/synthesis/evolution/qdrift.py | 50 ++++-- qiskit/synthesis/evolution/suzuki_trotter.py | 76 ++++---- ...formula-improvements-1bc40650151cf107.yaml | 25 +++ .../circuit/library/test_evolution_gate.py | 45 ++++- 8 files changed, 315 insertions(+), 125 deletions(-) create mode 100644 releasenotes/notes/product-formula-improvements-1bc40650151cf107.yaml diff --git a/qiskit/circuit/quantumcircuit.py b/qiskit/circuit/quantumcircuit.py index 6d41e6fdcd25..e2acf9dc2026 100644 --- a/qiskit/circuit/quantumcircuit.py +++ b/qiskit/circuit/quantumcircuit.py @@ -1596,11 +1596,12 @@ def inverse(self, annotated: bool = False) -> "QuantumCircuit": ) return inverse_circ - def repeat(self, reps: int) -> "QuantumCircuit": + def repeat(self, reps: int, *, insert_barriers: bool = False) -> "QuantumCircuit": """Repeat this circuit ``reps`` times. Args: reps (int): How often this circuit should be repeated. + insert_barriers (bool): Whether to include barriers between circuit repetitions. Returns: QuantumCircuit: A circuit containing ``reps`` repetitions of this circuit. @@ -1616,8 +1617,10 @@ def repeat(self, reps: int) -> "QuantumCircuit": inst: Instruction = self.to_gate() except QiskitError: inst = self.to_instruction() - for _ in range(reps): + for i in range(reps): repeated_circ._append(inst, self.qubits, self.clbits) + if insert_barriers and i != reps - 1: + repeated_circ.barrier() return repeated_circ diff --git a/qiskit/synthesis/evolution/evolution_synthesis.py b/qiskit/synthesis/evolution/evolution_synthesis.py index f904f457e49b..9bf7ff35f60d 100644 --- a/qiskit/synthesis/evolution/evolution_synthesis.py +++ b/qiskit/synthesis/evolution/evolution_synthesis.py @@ -12,8 +12,10 @@ """Evolution synthesis.""" +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any class EvolutionSynthesis(ABC): @@ -32,7 +34,7 @@ def synthesize(self, evolution): raise NotImplementedError @property - def settings(self) -> Dict[str, Any]: + def settings(self) -> dict[str, Any]: """Return the settings in a dictionary, which can be used to reconstruct the object. Returns: diff --git a/qiskit/synthesis/evolution/lie_trotter.py b/qiskit/synthesis/evolution/lie_trotter.py index 6d73e077bf90..1a01675a6782 100644 --- a/qiskit/synthesis/evolution/lie_trotter.py +++ b/qiskit/synthesis/evolution/lie_trotter.py @@ -12,10 +12,15 @@ """The Lie-Trotter product formula.""" -from typing import Callable, Optional, Union, Dict, Any +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any import numpy as np from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.quantum_info.operators import SparsePauliOp, Pauli +from qiskit.utils.deprecation import deprecate_arg from .product_formula import ProductFormula @@ -47,14 +52,32 @@ class LieTrotter(ProductFormula): `arXiv:math-ph/0506007 `_ """ + @deprecate_arg( + name="atomic_evolution", + since="1.2", + predicate=lambda callable: callable is not None + and len(inspect.signature(callable).parameters) == 2, + deprecation_description=( + "The 'Callable[[Pauli | SparsePauliOp, float], QuantumCircuit]' signature of the " + "'atomic_evolution' argument" + ), + additional_msg=( + "Instead you should update your 'atomic_evolution' function to be of the following " + "type: 'Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None]'." + ), + pending=True, + ) def __init__( self, reps: int = 1, insert_barriers: bool = False, cx_structure: str = "chain", - atomic_evolution: Optional[ - Callable[[Union[Pauli, SparsePauliOp], float], QuantumCircuit] - ] = None, + atomic_evolution: ( + Callable[[Pauli | SparsePauliOp, float], QuantumCircuit] + | Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None] + | None + ) = None, + wrap: bool = False, ) -> None: """ Args: @@ -62,12 +85,20 @@ def __init__( insert_barriers: Whether to insert barriers between the atomic evolutions. cx_structure: How to arrange the CX gates for the Pauli evolutions, can be ``"chain"``, where next neighbor connections are used, or ``"fountain"``, - where all qubits are connected to one. - atomic_evolution: A function to construct the circuit for the evolution of single - Pauli string. Per default, a single Pauli evolution is decomposed in a CX chain - and a single qubit Z rotation. + where all qubits are connected to one. This only takes effect when + ``atomic_evolution is None``. + atomic_evolution: A function to apply the evolution of a single :class:`.Pauli`, or + :class:`.SparsePauliOp` of only commuting terms, to a circuit. The function takes in + three arguments: the circuit to append the evolution to, the Pauli operator to + evolve, and the evolution time. By default, a single Pauli evolution is decomposed + into a chain of ``CX`` gates and a single ``RZ`` gate. + Alternatively, the function can also take Pauli operator and evolution time as + inputs and returns the circuit that will be appended to the overall circuit being + built. + wrap: Whether to wrap the atomic evolutions into custom gate objects. This only takes + effect when ``atomic_evolution is None``. """ - super().__init__(1, reps, insert_barriers, cx_structure, atomic_evolution) + super().__init__(1, reps, insert_barriers, cx_structure, atomic_evolution, wrap) def synthesize(self, evolution): # get operators and time to evolve @@ -75,27 +106,22 @@ def synthesize(self, evolution): time = evolution.time # construct the evolution circuit - evolution_circuit = QuantumCircuit(operators[0].num_qubits) + single_rep = QuantumCircuit(operators[0].num_qubits) if not isinstance(operators, list): pauli_list = [(Pauli(op), np.real(coeff)) for op, coeff in operators.to_list()] else: pauli_list = [(op, 1) for op in operators] - # if we only evolve a single Pauli we don't need to additionally wrap it - wrap = not (len(pauli_list) == 1 and self.reps == 1) - for i, (op, coeff) in enumerate(pauli_list): - evolution_circuit.compose( - self.atomic_evolution(op, coeff * time / self.reps), wrap=wrap, inplace=True - ) + self.atomic_evolution(single_rep, op, coeff * time / self.reps) if self.insert_barriers and i != len(pauli_list) - 1: - evolution_circuit.barrier() + single_rep.barrier() - return evolution_circuit.repeat(self.reps).decompose() + return single_rep.repeat(self.reps, insert_barriers=self.insert_barriers).decompose() @property - def settings(self) -> Dict[str, Any]: + def settings(self) -> dict[str, Any]: """Return the settings in a dictionary, which can be used to reconstruct the object. Returns: @@ -113,4 +139,5 @@ def settings(self) -> Dict[str, Any]: "reps": self.reps, "insert_barriers": self.insert_barriers, "cx_structure": self._cx_structure, + "wrap": self._wrap, } diff --git a/qiskit/synthesis/evolution/product_formula.py b/qiskit/synthesis/evolution/product_formula.py index bf1282481afc..df38a2a541ab 100644 --- a/qiskit/synthesis/evolution/product_formula.py +++ b/qiskit/synthesis/evolution/product_formula.py @@ -12,12 +12,17 @@ """A product formula base for decomposing non-commuting operator exponentials.""" -from typing import Callable, Optional, Union, Any, Dict +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any from functools import partial import numpy as np from qiskit.circuit.parameterexpression import ParameterExpression from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.quantum_info import SparsePauliOp, Pauli +from qiskit.utils.deprecation import deprecate_arg from .evolution_synthesis import EvolutionSynthesis @@ -28,15 +33,33 @@ class ProductFormula(EvolutionSynthesis): :obj:`.LieTrotter` and :obj:`.SuzukiTrotter` inherit from this class. """ + @deprecate_arg( + name="atomic_evolution", + since="1.2", + predicate=lambda callable: callable is not None + and len(inspect.signature(callable).parameters) == 2, + deprecation_description=( + "The 'Callable[[Pauli | SparsePauliOp, float], QuantumCircuit]' signature of the " + "'atomic_evolution' argument" + ), + additional_msg=( + "Instead you should update your 'atomic_evolution' function to be of the following " + "type: 'Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None]'." + ), + pending=True, + ) def __init__( self, order: int, reps: int = 1, insert_barriers: bool = False, cx_structure: str = "chain", - atomic_evolution: Optional[ - Callable[[Union[Pauli, SparsePauliOp], float], QuantumCircuit] - ] = None, + atomic_evolution: ( + Callable[[Pauli | SparsePauliOp, float], QuantumCircuit] + | Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None] + | None + ) = None, + wrap: bool = False, ) -> None: """ Args: @@ -45,10 +68,18 @@ def __init__( insert_barriers: Whether to insert barriers between the atomic evolutions. cx_structure: How to arrange the CX gates for the Pauli evolutions, can be ``"chain"``, where next neighbor connections are used, or ``"fountain"``, - where all qubits are connected to one. - atomic_evolution: A function to construct the circuit for the evolution of single - Pauli string. Per default, a single Pauli evolution is decomposed in a CX chain - and a single qubit Z rotation. + where all qubits are connected to one. This only takes effect when + ``atomic_evolution is None``. + atomic_evolution: A function to apply the evolution of a single :class:`.Pauli`, or + :class:`.SparsePauliOp` of only commuting terms, to a circuit. The function takes in + three arguments: the circuit to append the evolution to, the Pauli operator to + evolve, and the evolution time. By default, a single Pauli evolution is decomposed + into a chain of ``CX`` gates and a single ``RZ`` gate. + Alternatively, the function can also take Pauli operator and evolution time as + inputs and returns the circuit that will be appended to the overall circuit being + built. + wrap: Whether to wrap the atomic evolutions into custom gate objects. This only takes + effect when ``atomic_evolution is None``. """ super().__init__() self.order = order @@ -58,15 +89,27 @@ def __init__( # user-provided atomic evolution, stored for serialization self._atomic_evolution = atomic_evolution self._cx_structure = cx_structure + self._wrap = wrap # if atomic evolution is not provided, set a default if atomic_evolution is None: - atomic_evolution = partial(_default_atomic_evolution, cx_structure=cx_structure) + self.atomic_evolution = partial( + _default_atomic_evolution, cx_structure=cx_structure, wrap=wrap + ) + + elif len(inspect.signature(atomic_evolution).parameters) == 2: + + def wrap_atomic_evolution(output, operator, time): + definition = atomic_evolution(operator, time) + output.compose(definition, wrap=wrap, inplace=True) + + self.atomic_evolution = wrap_atomic_evolution - self.atomic_evolution = atomic_evolution + else: + self.atomic_evolution = atomic_evolution @property - def settings(self) -> Dict[str, Any]: + def settings(self) -> dict[str, Any]: """Return the settings in a dictionary, which can be used to reconstruct the object. Returns: @@ -85,15 +128,18 @@ def settings(self) -> Dict[str, Any]: "reps": self.reps, "insert_barriers": self.insert_barriers, "cx_structure": self._cx_structure, + "wrap": self._wrap, } def evolve_pauli( + output: QuantumCircuit, pauli: Pauli, - time: Union[float, ParameterExpression] = 1.0, + time: float | ParameterExpression = 1.0, cx_structure: str = "chain", - label: Optional[str] = None, -) -> QuantumCircuit: + wrap: bool = False, + label: str | None = None, +) -> None: r"""Construct a circuit implementing the time evolution of a single Pauli string. For a Pauli string :math:`P = \{I, X, Y, Z\}^{\otimes n}` on :math:`n` qubits and an @@ -106,79 +152,91 @@ def evolve_pauli( Since only a single Pauli string is evolved the circuit decomposition is exact. Args: + output: The circuit object to which to append the evolved Pauli. pauli: The Pauli to evolve. time: The evolution time. cx_structure: Determine the structure of CX gates, can be either ``"chain"`` for next-neighbor connections or ``"fountain"`` to connect directly to the top qubit. + wrap: Whether to wrap the single Pauli evolutions into custom gate objects. label: A label for the gate. - - Returns: - A quantum circuit implementing the time evolution of the Pauli. """ num_non_identity = len([label for label in pauli.to_label() if label != "I"]) # first check, if the Pauli is only the identity, in which case the evolution only # adds a global phase if num_non_identity == 0: - definition = QuantumCircuit(pauli.num_qubits, global_phase=-time) + output.global_phase -= time # if we evolve on a single qubit, if yes use the corresponding qubit rotation elif num_non_identity == 1: - definition = _single_qubit_evolution(pauli, time) + _single_qubit_evolution(output, pauli, time, wrap) # same for two qubits, use Qiskit's native rotations elif num_non_identity == 2: - definition = _two_qubit_evolution(pauli, time, cx_structure) + _two_qubit_evolution(output, pauli, time, cx_structure, wrap) # otherwise do basis transformation and CX chains else: - definition = _multi_qubit_evolution(pauli, time, cx_structure) - - definition.name = f"exp(it {pauli.to_label()})" + _multi_qubit_evolution(output, pauli, time, cx_structure, wrap) - return definition - -def _single_qubit_evolution(pauli, time): - definition = QuantumCircuit(pauli.num_qubits) +def _single_qubit_evolution(output, pauli, time, wrap): + dest = QuantumCircuit(1) if wrap else output # Note that all phases are removed from the pauli label and are only in the coefficients. # That's because the operators we evolved have all been translated to a SparsePauliOp. + qubits = [] + label = "" for i, pauli_i in enumerate(reversed(pauli.to_label())): + idx = 0 if wrap else i if pauli_i == "X": - definition.rx(2 * time, i) + dest.rx(2 * time, idx) + qubits.append(i) + label += "X" elif pauli_i == "Y": - definition.ry(2 * time, i) + dest.ry(2 * time, idx) + qubits.append(i) + label += "Y" elif pauli_i == "Z": - definition.rz(2 * time, i) + dest.rz(2 * time, idx) + qubits.append(i) + label += "Z" - return definition + if wrap: + gate = dest.to_gate(label=f"exp(it {label})") + qubits = [output.qubits[q] for q in qubits] + output.append(gate, qargs=qubits, copy=False) -def _two_qubit_evolution(pauli, time, cx_structure): +def _two_qubit_evolution(output, pauli, time, cx_structure, wrap): # Get the Paulis and the qubits they act on. # Note that all phases are removed from the pauli label and are only in the coefficients. # That's because the operators we evolved have all been translated to a SparsePauliOp. labels_as_array = np.array(list(reversed(pauli.to_label()))) qubits = np.where(labels_as_array != "I")[0] + indices = [0, 1] if wrap else qubits labels = np.array([labels_as_array[idx] for idx in qubits]) - definition = QuantumCircuit(pauli.num_qubits) + dest = QuantumCircuit(2) if wrap else output # go through all cases we have implemented in Qiskit if all(labels == "X"): # RXX - definition.rxx(2 * time, qubits[0], qubits[1]) + dest.rxx(2 * time, indices[0], indices[1]) elif all(labels == "Y"): # RYY - definition.ryy(2 * time, qubits[0], qubits[1]) + dest.ryy(2 * time, indices[0], indices[1]) elif all(labels == "Z"): # RZZ - definition.rzz(2 * time, qubits[0], qubits[1]) + dest.rzz(2 * time, indices[0], indices[1]) elif labels[0] == "Z" and labels[1] == "X": # RZX - definition.rzx(2 * time, qubits[0], qubits[1]) + dest.rzx(2 * time, indices[0], indices[1]) elif labels[0] == "X" and labels[1] == "Z": # RXZ - definition.rzx(2 * time, qubits[1], qubits[0]) + dest.rzx(2 * time, indices[1], indices[0]) else: # all the others are not native in Qiskit, so use default the decomposition - definition = _multi_qubit_evolution(pauli, time, cx_structure) + _multi_qubit_evolution(output, pauli, time, cx_structure, wrap) + return - return definition + if wrap: + gate = dest.to_gate(label=f"exp(it {''.join(labels)})") + qubits = [output.qubits[q] for q in qubits] + output.append(gate, qargs=qubits, copy=False) -def _multi_qubit_evolution(pauli, time, cx_structure): +def _multi_qubit_evolution(output, pauli, time, cx_structure, wrap): # get diagonalizing clifford cliff = diagonalizing_clifford(pauli) @@ -198,14 +256,16 @@ def _multi_qubit_evolution(pauli, time, cx_structure): break # build the evolution as: diagonalization, reduction, 1q evolution, followed by inverses - definition = QuantumCircuit(pauli.num_qubits) - definition.compose(cliff, inplace=True) - definition.compose(chain, inplace=True) - definition.rz(2 * time, target) - definition.compose(chain.inverse(), inplace=True) - definition.compose(cliff.inverse(), inplace=True) + dest = QuantumCircuit(pauli.num_qubits) if wrap else output + dest.compose(cliff, inplace=True) + dest.compose(chain, inplace=True) + dest.rz(2 * time, target) + dest.compose(chain.inverse(), inplace=True) + dest.compose(cliff.inverse(), inplace=True) - return definition + if wrap: + gate = dest.to_gate(label=f"exp(it {pauli.to_label()})") + output.append(gate, qargs=output.qubits, copy=False) def diagonalizing_clifford(pauli: Pauli) -> QuantumCircuit: @@ -313,16 +373,12 @@ def cnot_fountain(pauli: Pauli) -> QuantumCircuit: return chain -def _default_atomic_evolution(operator, time, cx_structure): +def _default_atomic_evolution(output, operator, time, cx_structure, wrap): if isinstance(operator, Pauli): # single Pauli operator: just exponentiate it - evolution_circuit = evolve_pauli(operator, time, cx_structure) + evolve_pauli(output, operator, time, cx_structure, wrap) else: # sum of Pauli operators: exponentiate each term (this assumes they commute) pauli_list = [(Pauli(op), np.real(coeff)) for op, coeff in operator.to_list()] - name = f"exp(it {[pauli.to_label() for pauli, _ in pauli_list]})" - evolution_circuit = QuantumCircuit(operator.num_qubits, name=name) for pauli, coeff in pauli_list: - evolution_circuit.compose(evolve_pauli(pauli, coeff * time, cx_structure), inplace=True) - - return evolution_circuit + evolve_pauli(output, pauli, coeff * time, cx_structure, wrap) diff --git a/qiskit/synthesis/evolution/qdrift.py b/qiskit/synthesis/evolution/qdrift.py index d4326903b4ba..7c68f66dc8ea 100644 --- a/qiskit/synthesis/evolution/qdrift.py +++ b/qiskit/synthesis/evolution/qdrift.py @@ -12,11 +12,15 @@ """QDrift Class""" +from __future__ import annotations + +import inspect import math -from typing import Union, Optional, Callable +from collections.abc import Callable import numpy as np from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.quantum_info.operators import SparsePauliOp, Pauli +from qiskit.utils.deprecation import deprecate_arg from .product_formula import ProductFormula from .lie_trotter import LieTrotter @@ -32,15 +36,33 @@ class QDrift(ProductFormula): `arXiv:quant-ph/1811.08017 `_ """ + @deprecate_arg( + name="atomic_evolution", + since="1.2", + predicate=lambda callable: callable is not None + and len(inspect.signature(callable).parameters) == 2, + deprecation_description=( + "The 'Callable[[Pauli | SparsePauliOp, float], QuantumCircuit]' signature of the " + "'atomic_evolution' argument" + ), + additional_msg=( + "Instead you should update your 'atomic_evolution' function to be of the following " + "type: 'Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None]'." + ), + pending=True, + ) def __init__( self, reps: int = 1, insert_barriers: bool = False, cx_structure: str = "chain", - atomic_evolution: Optional[ - Callable[[Union[Pauli, SparsePauliOp], float], QuantumCircuit] - ] = None, - seed: Optional[int] = None, + atomic_evolution: ( + Callable[[Pauli | SparsePauliOp, float], QuantumCircuit] + | Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None] + | None + ) = None, + seed: int | None = None, + wrap: bool = False, ) -> None: r""" Args: @@ -48,13 +70,21 @@ def __init__( insert_barriers: Whether to insert barriers between the atomic evolutions. cx_structure: How to arrange the CX gates for the Pauli evolutions, can be ``"chain"``, where next neighbor connections are used, or ``"fountain"``, where all - qubits are connected to one. - atomic_evolution: A function to construct the circuit for the evolution of single - Pauli string. Per default, a single Pauli evolution is decomposed in a CX chain - and a single qubit Z rotation. + qubits are connected to one. This only takes effect when + ``atomic_evolution is None``. + atomic_evolution: A function to apply the evolution of a single :class:`.Pauli`, or + :class:`.SparsePauliOp` of only commuting terms, to a circuit. The function takes in + three arguments: the circuit to append the evolution to, the Pauli operator to + evolve, and the evolution time. By default, a single Pauli evolution is decomposed + into a chain of ``CX`` gates and a single ``RZ`` gate. + Alternatively, the function can also take Pauli operator and evolution time as + inputs and returns the circuit that will be appended to the overall circuit being + built. seed: An optional seed for reproducibility of the random sampling process. + wrap: Whether to wrap the atomic evolutions into custom gate objects. This only takes + effect when ``atomic_evolution is None``. """ - super().__init__(1, reps, insert_barriers, cx_structure, atomic_evolution) + super().__init__(1, reps, insert_barriers, cx_structure, atomic_evolution, wrap) self.sampled_ops = None self.rng = np.random.default_rng(seed) diff --git a/qiskit/synthesis/evolution/suzuki_trotter.py b/qiskit/synthesis/evolution/suzuki_trotter.py index a18b30720364..e03fd27e26d4 100644 --- a/qiskit/synthesis/evolution/suzuki_trotter.py +++ b/qiskit/synthesis/evolution/suzuki_trotter.py @@ -12,12 +12,16 @@ """The Suzuki-Trotter product formula.""" -from typing import Callable, Optional, Union +from __future__ import annotations + +import inspect +from collections.abc import Callable import numpy as np from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.quantum_info.operators import SparsePauliOp, Pauli +from qiskit.utils.deprecation import deprecate_arg from .product_formula import ProductFormula @@ -51,15 +55,33 @@ class SuzukiTrotter(ProductFormula): `arXiv:math-ph/0506007 `_ """ + @deprecate_arg( + name="atomic_evolution", + since="1.2", + predicate=lambda callable: callable is not None + and len(inspect.signature(callable).parameters) == 2, + deprecation_description=( + "The 'Callable[[Pauli | SparsePauliOp, float], QuantumCircuit]' signature of the " + "'atomic_evolution' argument" + ), + additional_msg=( + "Instead you should update your 'atomic_evolution' function to be of the following " + "type: 'Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None]'." + ), + pending=True, + ) def __init__( self, order: int = 2, reps: int = 1, insert_barriers: bool = False, cx_structure: str = "chain", - atomic_evolution: Optional[ - Callable[[Union[Pauli, SparsePauliOp], float], QuantumCircuit] - ] = None, + atomic_evolution: ( + Callable[[Pauli | SparsePauliOp, float], QuantumCircuit] + | Callable[[QuantumCircuit, Pauli | SparsePauliOp, float], None] + | None + ) = None, + wrap: bool = False, ) -> None: """ Args: @@ -68,10 +90,17 @@ def __init__( insert_barriers: Whether to insert barriers between the atomic evolutions. cx_structure: How to arrange the CX gates for the Pauli evolutions, can be ``"chain"``, where next neighbor connections are used, or ``"fountain"``, where all qubits are - connected to one. - atomic_evolution: A function to construct the circuit for the evolution of single - Pauli string. Per default, a single Pauli evolution is decomposed in a CX chain - and a single qubit Z rotation. + connected to one. This only takes effect when ``atomic_evolution is None``. + atomic_evolution: A function to apply the evolution of a single :class:`.Pauli`, or + :class:`.SparsePauliOp` of only commuting terms, to a circuit. The function takes in + three arguments: the circuit to append the evolution to, the Pauli operator to + evolve, and the evolution time. By default, a single Pauli evolution is decomposed + into a chain of ``CX`` gates and a single ``RZ`` gate. + Alternatively, the function can also take Pauli operator and evolution time as + inputs and returns the circuit that will be appended to the overall circuit being + built. + wrap: Whether to wrap the atomic evolutions into custom gate objects. This only takes + effect when ``atomic_evolution is None``. Raises: ValueError: If order is not even """ @@ -81,7 +110,7 @@ def __init__( "Suzuki product formulae are symmetric and therefore only defined " "for even orders." ) - super().__init__(order, reps, insert_barriers, cx_structure, atomic_evolution) + super().__init__(order, reps, insert_barriers, cx_structure, atomic_evolution, wrap) def synthesize(self, evolution): # get operators and time to evolve @@ -97,32 +126,13 @@ def synthesize(self, evolution): # construct the evolution circuit single_rep = QuantumCircuit(operators[0].num_qubits) - first_barrier = False - - for op, coeff in ops_to_evolve: - # add barriers - if first_barrier: - if self.insert_barriers: - single_rep.barrier() - else: - first_barrier = True - - single_rep.compose(self.atomic_evolution(op, coeff), wrap=True, inplace=True) - - evolution_circuit = QuantumCircuit(operators[0].num_qubits) - first_barrier = False - - for _ in range(self.reps): - # add barriers - if first_barrier: - if self.insert_barriers: - single_rep.barrier() - else: - first_barrier = True - evolution_circuit.compose(single_rep, inplace=True) + for i, (op, coeff) in enumerate(ops_to_evolve): + self.atomic_evolution(single_rep, op, coeff) + if self.insert_barriers and i != len(ops_to_evolve) - 1: + single_rep.barrier() - return evolution_circuit + return single_rep.repeat(self.reps, insert_barriers=self.insert_barriers).decompose() @staticmethod def _recurse(order, time, pauli_list): diff --git a/releasenotes/notes/product-formula-improvements-1bc40650151cf107.yaml b/releasenotes/notes/product-formula-improvements-1bc40650151cf107.yaml new file mode 100644 index 000000000000..fc9713e7a436 --- /dev/null +++ b/releasenotes/notes/product-formula-improvements-1bc40650151cf107.yaml @@ -0,0 +1,25 @@ +--- +features_circuits: + - | + Added the ``insert_barriers`` keyword argument to the + :meth:`~.QuantumCircuit.repeat` method. Setting it to ``True`` will insert + barriers between circuit repetitions. +features_synthesis: + - | + Added the ``wrap`` keyword argument to the :class:`.ProductFormula` classes + which (when enabled) wraps individual Pauli evolution terms. This can be + useful when visualizing circuits. +upgrade_synthesis: + - | + The ``atomic_evolution`` argument to :class:`.ProductFormula` (and its + subclasses) has a new function signature. Rather than taking some Pauli + operator and time coefficient and returning the evolution circuit, the new + function takes in an existing circuit and should append the evolution of the + provided Pauli and given time to this circuit. This new implementation + benefits from significantly better performance. + - | + :class:`.LieTrotter` and :class:`.SuzukiTrotter` no longer wrap the + individually evolved Pauli terms into gate definitions. If you rely on a + certain decomposition level of your circuit, you have to remove one level of + :meth:`~.QuantumCircuit.decompose` or add the ``wrap=True`` keyword argument + to your synthesis object. diff --git a/test/python/circuit/library/test_evolution_gate.py b/test/python/circuit/library/test_evolution_gate.py index 88b0529ca7c2..a7c9a73abb5f 100644 --- a/test/python/circuit/library/test_evolution_gate.py +++ b/test/python/circuit/library/test_evolution_gate.py @@ -20,6 +20,7 @@ from qiskit.circuit import QuantumCircuit, Parameter from qiskit.circuit.library import PauliEvolutionGate from qiskit.synthesis import LieTrotter, SuzukiTrotter, MatrixExponential, QDrift +from qiskit.synthesis.evolution.product_formula import cnot_chain, diagonalizing_clifford from qiskit.converters import circuit_to_dag from qiskit.quantum_info import Operator, SparsePauliOp, Pauli, Statevector from test import QiskitTestCase # pylint: disable=wrong-import-order @@ -130,7 +131,7 @@ def test_suzuki_trotter_manual(self): expected.ry(2 * p_4 * time, 0) expected.rx(p_4 * time, 0) - self.assertEqual(evo_gate.definition.decompose(), expected) + self.assertEqual(evo_gate.definition, expected) @data( (X + Y, 0.5, 1, [(Pauli("X"), 0.5), (Pauli("X"), 0.5)]), @@ -175,11 +176,15 @@ def energy(evo): self.assertAlmostEqual(energy(exact), np.average(qdrift_energy), places=2) - def test_passing_grouped_paulis(self): + @data(True, False) + def test_passing_grouped_paulis(self, wrap): """Test passing a list of already grouped Paulis.""" grouped_ops = [(X ^ Y) + (Y ^ X), (Z ^ I) + (Z ^ Z) + (I ^ Z), (X ^ X)] - evo_gate = PauliEvolutionGate(grouped_ops, time=0.12, synthesis=LieTrotter()) - decomposed = evo_gate.definition.decompose() + evo_gate = PauliEvolutionGate(grouped_ops, time=0.12, synthesis=LieTrotter(wrap=wrap)) + if wrap: + decomposed = evo_gate.definition.decompose() + else: + decomposed = evo_gate.definition self.assertEqual(decomposed.count_ops()["rz"], 4) self.assertEqual(decomposed.count_ops()["rzz"], 1) self.assertEqual(decomposed.count_ops()["rxx"], 1) @@ -327,6 +332,38 @@ def test_labels_and_name(self): self.assertEqual(evo.name, "PauliEvolution") self.assertEqual(evo.label, f"exp(-it {label})") + def test_atomic_evolution(self): + """Test a custom atomic_evolution.""" + + def atomic_evolution(pauli, time): + cliff = diagonalizing_clifford(pauli) + chain = cnot_chain(pauli) + + target = None + for i, pauli_i in enumerate(reversed(pauli.to_label())): + if pauli_i != "I": + target = i + break + + definition = QuantumCircuit(pauli.num_qubits) + definition.compose(cliff, inplace=True) + definition.compose(chain, inplace=True) + definition.rz(2 * time, target) + definition.compose(chain.inverse(), inplace=True) + definition.compose(cliff.inverse(), inplace=True) + + return definition + + op = (X ^ X ^ X) + (Y ^ Y ^ Y) + (Z ^ Z ^ Z) + time = 0.123 + reps = 4 + with self.assertWarns(PendingDeprecationWarning): + evo_gate = PauliEvolutionGate( + op, time, synthesis=LieTrotter(reps=reps, atomic_evolution=atomic_evolution) + ) + decomposed = evo_gate.definition.decompose() + self.assertEqual(decomposed.count_ops()["cx"], reps * 3 * 4) + if __name__ == "__main__": unittest.main()