From 9252713e59fe2c621874ca92a32bf1e25bbf6cfe Mon Sep 17 00:00:00 2001 From: Julien Gacon Date: Wed, 14 Aug 2024 12:14:10 +0200 Subject: [PATCH 1/4] Fix error message upon misalignment in `PadDynamicalDecoupling` (#12952) * Fix misalignment msg * slightly better formatting * typo --- .../scheduling/padding/dynamical_decoupling.py | 12 +++++++++--- ...ix-dd-misalignment-msg-76fe16e5eb4ae670.yaml | 7 +++++++ .../transpiler/test_dynamical_decoupling.py | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/fix-dd-misalignment-msg-76fe16e5eb4ae670.yaml diff --git a/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py b/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py index 8c3ea87c8578..0e351161f7df 100644 --- a/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py +++ b/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py @@ -20,7 +20,7 @@ from qiskit.circuit.delay import Delay from qiskit.circuit.library.standard_gates import IGate, UGate, U3Gate from qiskit.circuit.reset import Reset -from qiskit.dagcircuit import DAGCircuit, DAGNode, DAGInNode, DAGOpNode +from qiskit.dagcircuit import DAGCircuit, DAGNode, DAGInNode, DAGOpNode, DAGOutNode from qiskit.quantum_info.operators.predicates import matrix_equal from qiskit.synthesis.one_qubit import OneQubitEulerDecomposer from qiskit.transpiler.exceptions import TranspilerError @@ -331,8 +331,7 @@ def _pad( if time_interval % self._alignment != 0: raise TranspilerError( f"Time interval {time_interval} is not divisible by alignment {self._alignment} " - f"between DAGNode {prev_node.name} on qargs {prev_node.qargs} and {next_node.name} " - f"on qargs {next_node.qargs}." + f"between {_format_node(prev_node)} and {_format_node(next_node)}." ) if not self.__is_dd_qubit(dag.qubits.index(qubit)): @@ -430,3 +429,10 @@ def _resolve_params(gate: Gate) -> tuple: else: params.append(p) return tuple(params) + + +def _format_node(node: DAGNode) -> str: + """Util to format the DAGNode, DAGInNode, and DAGOutNode.""" + if isinstance(node, (DAGInNode, DAGOutNode)): + return f"{node.__class__.__name__} on qarg {node.wire}" + return f"DAGNode {node.name} on qargs {node.qargs}" diff --git a/releasenotes/notes/fix-dd-misalignment-msg-76fe16e5eb4ae670.yaml b/releasenotes/notes/fix-dd-misalignment-msg-76fe16e5eb4ae670.yaml new file mode 100644 index 000000000000..4634fe48ae9b --- /dev/null +++ b/releasenotes/notes/fix-dd-misalignment-msg-76fe16e5eb4ae670.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Fixed a bug in :class:`.PadDynamicalDecoupling`, which previously + did not correctly display the error message that a delay is not + pulse-aligned, if the previous or following node was an input/output + node. Now, the error message is correctly displayed. diff --git a/test/python/transpiler/test_dynamical_decoupling.py b/test/python/transpiler/test_dynamical_decoupling.py index 5f11ede3a3e8..38c5c3c2d4ab 100644 --- a/test/python/transpiler/test_dynamical_decoupling.py +++ b/test/python/transpiler/test_dynamical_decoupling.py @@ -1051,6 +1051,23 @@ def test_paramaterized_global_phase(self): self.assertEqual(qc.global_phase + np.pi, pm.run(qc).global_phase) + def test_misalignment_at_boundaries(self): + """Test the correct error message is raised for misalignments at In/Out nodes.""" + # a circuit where the previous node is DAGInNode, and the next DAGOutNode + circuit = QuantumCircuit(1) + circuit.delay(101) + + dd_sequence = [XGate(), XGate()] + pm = PassManager( + [ + ALAPScheduleAnalysis(self.durations), + PadDynamicalDecoupling(self.durations, dd_sequence, pulse_alignment=2), + ] + ) + + with self.assertRaises(TranspilerError): + _ = pm.run(circuit) + if __name__ == "__main__": unittest.main() From 11e769f30de7edc56cc5c01f6a679c283989ad62 Mon Sep 17 00:00:00 2001 From: Julien Gacon Date: Wed, 14 Aug 2024 13:52:08 +0200 Subject: [PATCH 2/4] Auxiliary qubit tracking in ``HighLevelSynthesis`` (#12911) * Ancilla tracking for HLS Co-authored-by: AlexanderIvrii * rm trailing print * fix round 1 of review comments * Sasha's review comments * fix type hint, improve comments --------- Co-authored-by: AlexanderIvrii --- qiskit/circuit/add_control.py | 6 +- .../multi_control_rotation_gates.py | 2 +- qiskit/circuit/library/standard_gates/p.py | 3 +- qiskit/compiler/transpiler.py | 3 + qiskit/synthesis/unitary/qsd.py | 4 +- .../passes/synthesis/high_level_synthesis.py | 511 +++++++++++------- .../passes/synthesis/qubit_tracker.py | 132 +++++ qiskit/transpiler/passmanager_config.py | 5 + .../preset_passmanagers/builtin_plugins.py | 5 + .../transpiler/preset_passmanagers/common.py | 14 +- .../generate_preset_pass_manager.py | 4 + .../hls-with-ancillas-d6792b41dfcf4aac.yaml | 18 + .../transpiler/test_high_level_synthesis.py | 156 +++++- .../transpiler/test_passmanager_config.py | 1 + 14 files changed, 661 insertions(+), 203 deletions(-) create mode 100644 qiskit/transpiler/passes/synthesis/qubit_tracker.py create mode 100644 releasenotes/notes/hls-with-ancillas-d6792b41dfcf4aac.yaml diff --git a/qiskit/circuit/add_control.py b/qiskit/circuit/add_control.py index 98c9b4d4e452..04bb934a0211 100644 --- a/qiskit/circuit/add_control.py +++ b/qiskit/circuit/add_control.py @@ -137,7 +137,7 @@ def control( gate.definition.data[0].operation.params[0], q_control, q_target[bit_indices[qargs[0]]], - use_basis_gates=True, + use_basis_gates=False, ) elif gate.name == "ry": controlled_circ.mcry( @@ -146,14 +146,14 @@ def control( q_target[bit_indices[qargs[0]]], q_ancillae, mode="noancilla", - use_basis_gates=True, + use_basis_gates=False, ) elif gate.name == "rz": controlled_circ.mcrz( gate.definition.data[0].operation.params[0], q_control, q_target[bit_indices[qargs[0]]], - use_basis_gates=True, + use_basis_gates=False, ) continue elif gate.name == "p": diff --git a/qiskit/circuit/library/standard_gates/multi_control_rotation_gates.py b/qiskit/circuit/library/standard_gates/multi_control_rotation_gates.py index 6e31c99005b3..8746e51c48db 100644 --- a/qiskit/circuit/library/standard_gates/multi_control_rotation_gates.py +++ b/qiskit/circuit/library/standard_gates/multi_control_rotation_gates.py @@ -200,7 +200,7 @@ def _mcsu2_real_diagonal( circuit.h(target) if use_basis_gates: - circuit = transpile(circuit, basis_gates=["p", "u", "cx"]) + circuit = transpile(circuit, basis_gates=["p", "u", "cx"], qubits_initially_zero=False) return circuit diff --git a/qiskit/circuit/library/standard_gates/p.py b/qiskit/circuit/library/standard_gates/p.py index cb2c19bf51e9..39eda0ab923a 100644 --- a/qiskit/circuit/library/standard_gates/p.py +++ b/qiskit/circuit/library/standard_gates/p.py @@ -377,7 +377,8 @@ def _define(self): q_target = self.num_ctrl_qubits new_target = q_target for k in range(self.num_ctrl_qubits): - qc.mcrz(lam / (2**k), q_controls, new_target, use_basis_gates=True) + # Note: it's better *not* to run transpile recursively + qc.mcrz(lam / (2**k), q_controls, new_target, use_basis_gates=False) new_target = q_controls.pop() qc.p(lam / (2**self.num_ctrl_qubits), new_target) else: # in this case type(lam) is ParameterValueType diff --git a/qiskit/compiler/transpiler.py b/qiskit/compiler/transpiler.py index fd93fde5989f..929089c4ac41 100644 --- a/qiskit/compiler/transpiler.py +++ b/qiskit/compiler/transpiler.py @@ -66,6 +66,7 @@ def transpile( # pylint: disable=too-many-return-statements optimization_method: Optional[str] = None, ignore_backend_supplied_default_methods: bool = False, num_processes: Optional[int] = None, + qubits_initially_zero: bool = True, ) -> _CircuitT: """Transpile one or more circuits, according to some desired transpilation targets. @@ -284,6 +285,7 @@ def callback_func(**kwargs): ``num_processes`` in the user configuration file, and the ``QISKIT_NUM_PROCS`` environment variable. If set to ``None`` the system default or local user configuration will be used. + qubits_initially_zero: Indicates whether the input circuit is zero-initialized. Returns: The transpiled circuit(s). @@ -386,6 +388,7 @@ def callback_func(**kwargs): init_method=init_method, optimization_method=optimization_method, dt=dt, + qubits_initially_zero=qubits_initially_zero, ) out_circuits = pm.run(circuits, callback=callback, num_processes=num_processes) diff --git a/qiskit/synthesis/unitary/qsd.py b/qiskit/synthesis/unitary/qsd.py index b6b31aaa4fec..41a1fb77356c 100644 --- a/qiskit/synthesis/unitary/qsd.py +++ b/qiskit/synthesis/unitary/qsd.py @@ -255,7 +255,9 @@ def _apply_a2(circ): from qiskit.circuit.library.generalized_gates.unitary import UnitaryGate decomposer = two_qubit_decompose_up_to_diagonal - ccirc = transpile(circ, basis_gates=["u", "cx", "qsd2q"], optimization_level=0) + ccirc = transpile( + circ, basis_gates=["u", "cx", "qsd2q"], optimization_level=0, qubits_initially_zero=False + ) ind2q = [] # collect 2q instrs for i, instruction in enumerate(ccirc.data): diff --git a/qiskit/transpiler/passes/synthesis/high_level_synthesis.py b/qiskit/transpiler/passes/synthesis/high_level_synthesis.py index 6974b1cce06c..247898182cba 100644 --- a/qiskit/transpiler/passes/synthesis/high_level_synthesis.py +++ b/qiskit/transpiler/passes/synthesis/high_level_synthesis.py @@ -162,12 +162,15 @@ from __future__ import annotations import typing -from typing import Optional, Union, List, Tuple, Callable, Sequence +from functools import partial +from collections.abc import Callable import numpy as np import rustworkx as rx +from qiskit.circuit.annotated_operation import Modifier from qiskit.circuit.operation import Operation +from qiskit.circuit.instruction import Instruction from qiskit.converters import circuit_to_dag, dag_to_circuit from qiskit.transpiler.basepasses import TransformationPass from qiskit.circuit.quantumcircuit import QuantumCircuit @@ -212,6 +215,7 @@ ) from .plugin import HighLevelSynthesisPluginManager, HighLevelSynthesisPlugin +from .qubit_tracker import QubitTracker if typing.TYPE_CHECKING: from qiskit.dagcircuit import DAGOpNode @@ -268,7 +272,7 @@ def __init__( self, use_default_on_unspecified: bool = True, plugin_selection: str = "sequential", - plugin_evaluation_fn: Optional[Callable[[QuantumCircuit], int]] = None, + plugin_evaluation_fn: Callable[[QuantumCircuit], int] | None = None, **kwargs, ): """Creates a high-level-synthesis config. @@ -304,7 +308,7 @@ def set_methods(self, hls_name, hls_methods): class HighLevelSynthesis(TransformationPass): - """Synthesize higher-level objects and unroll custom definitions. + r"""Synthesize higher-level objects and unroll custom definitions. The input to this pass is a DAG that may contain higher-level objects, including abstract mathematical objects (e.g., objects of type :class:`.LinearFunction`), @@ -345,19 +349,35 @@ class HighLevelSynthesis(TransformationPass): abstract mathematical objects and annotated operations, without descending into the gate ``definitions``. This is consistent with the older behavior of the pass, allowing to synthesize some higher-level objects using plugins and leaving the other gates untouched. + + The high-level-synthesis passes information about available auxiliary qubits, and whether their + state is clean (defined as :math:`|0\rangle`) or dirty (unknown state) to the synthesis routine + via the respective arguments ``"num_clean_ancillas"`` and ``"num_dirty_ancillas"``. + If ``qubits_initially_zero`` is ``True`` (default), the qubits are assumed to be in the + :math:`|0\rangle` state. When appending a synthesized block using auxiliary qubits onto the + circuit, we first use the clean auxiliary qubits. + + .. note:: + + Synthesis methods are assumed to maintain the state of the auxiliary qubits. + Concretely this means that clean auxiliary qubits must still be in the :math:`|0\rangle` + state after the synthesized block, while dirty auxiliary qubits are re-used only + as dirty qubits. + """ def __init__( self, - hls_config: Optional[HLSConfig] = None, - coupling_map: Optional[CouplingMap] = None, - target: Optional[Target] = None, + hls_config: HLSConfig | None = None, + coupling_map: CouplingMap | None = None, + target: Target | None = None, use_qubit_indices: bool = False, - equivalence_library: Optional[EquivalenceLibrary] = None, - basis_gates: Optional[List[str]] = None, + equivalence_library: EquivalenceLibrary | None = None, + basis_gates: list[str] | None = None, min_qubits: int = 0, + qubits_initially_zero: bool = True, ): - """ + r""" HighLevelSynthesis initializer. Args: @@ -376,6 +396,9 @@ def __init__( Ignored if ``target`` is also specified. min_qubits: The minimum number of qubits for operations in the input dag to translate. + qubits_initially_zero: Indicates whether the qubits are initially in the state + :math:`|0\rangle`. This allows the high-level-synthesis to use clean auxiliary qubits + (i.e. in the zero state) to synthesize an operation. """ super().__init__() @@ -390,6 +413,7 @@ def __init__( self._coupling_map = coupling_map self._target = target self._use_qubit_indices = use_qubit_indices + self.qubits_initially_zero = qubits_initially_zero if target is not None: self._coupling_map = self._target.build_coupling_map() self._equiv_lib = equivalence_library @@ -418,151 +442,221 @@ def run(self, dag: DAGCircuit) -> DAGCircuit: TranspilerError: when the transpiler is unable to synthesize the given DAG (for instance, when the specified synthesis method is not available). """ + qubits = tuple(dag.find_bit(q).index for q in dag.qubits) + if self.qubits_initially_zero: + clean, dirty = set(qubits), set() + else: + clean, dirty = set(), set(qubits) + + tracker = QubitTracker(qubits=qubits, clean=clean, dirty=dirty) + return self._run(dag, tracker) + + def _run(self, dag: DAGCircuit, tracker: QubitTracker) -> DAGCircuit: + # Start by analyzing the nodes in the DAG. This for-loop is a first version of a potentially + # more elaborate approach to find good operation/ancilla allocations. It greedily iterates + # over the nodes, checking whether we can synthesize them, while keeping track of the + # qubit states. It does not trade-off allocations and just gives all available qubits + # to the current operation (a "the-first-takes-all" approach). + synthesized_nodes = {} + + for node in dag.topological_op_nodes(): + qubits = tuple(dag.find_bit(q).index for q in node.qargs) + synthesized = None + used_qubits = None + + # check if synthesis for the operation can be skipped + if ( + dag.has_calibration_for(node) + or len(node.qargs) < self._min_qubits + or node.is_directive() + or self._definitely_skip_node(node, qubits) + ): + pass - # copy dag_op_nodes because we are modifying the DAG below - dag_op_nodes = dag.op_nodes() - - for node in dag_op_nodes: - if node.is_control_flow(): - node.op = control_flow.map_blocks(self.run, node.op) - continue + # next check control flow + elif node.is_control_flow(): + node.op = control_flow.map_blocks( + partial(self._run, tracker=tracker.copy()), node.op + ) - if node.is_directive(): - continue + # now we are free to synthesize + else: + # this returns the synthesized operation and the qubits it acts on -- note that this + # may be different than the original qubits, since we may use auxiliary qubits + synthesized, used_qubits = self._synthesize_operation(node.op, qubits, tracker) + + # if the synthesis changed the operation (i.e. it is not None), store the result + # and mark the operation qubits as used + if synthesized is not None: + synthesized_nodes[node] = (synthesized, used_qubits) + tracker.used(qubits) # assumes that auxiliary are returned in the same state + + # if the synthesis did not change anything, just update the qubit tracker + # other cases can be added: swaps, controlled gates (e.g. if control is 0), ... + else: + if node.op.name in ["id", "delay", "barrier"]: + pass # tracker not updated, these are no-ops + elif node.op.name == "reset": + tracker.reset(qubits) # reset qubits to 0 + else: + tracker.used(qubits) # any other op used the clean state up + + # we did not change anything just return the input + if len(synthesized_nodes) == 0: + return dag + + # Otherwise we will rebuild with the new operations. Note that we could also + # check if no operation changed in size and substitute in-place, but rebuilding is + # generally as fast or faster, unless very few operations are changed. + out = dag.copy_empty_like() + index_to_qubit = dict(enumerate(dag.qubits)) + + for node in dag.topological_op_nodes(): + if node in synthesized_nodes: + op, qubits = synthesized_nodes[node] + qargs = tuple(index_to_qubit[index] for index in qubits) + if isinstance(op, Operation): + out.apply_operation_back(op, qargs, cargs=[]) + continue + + if isinstance(op, QuantumCircuit): + op = circuit_to_dag(op, copy_operations=False) + + if isinstance(op, DAGCircuit): + qubit_map = { + qubit: index_to_qubit[index] for index, qubit in zip(qubits, op.qubits) + } + clbit_map = dict(zip(op.clbits, node.cargs)) + for sub_node in op.op_nodes(): + out.apply_operation_back( + sub_node.op, + tuple(qubit_map[qarg] for qarg in sub_node.qargs), + tuple(clbit_map[carg] for carg in sub_node.cargs), + ) + out.global_phase += op.global_phase + else: + raise RuntimeError(f"Unexpected synthesized type: {type(op)}") + else: + out.apply_operation_back(node.op, node.qargs, node.cargs, check=False) - if dag.has_calibration_for(node) or len(node.qargs) < self._min_qubits: - continue + return out - qubits = ( - [dag.find_bit(x).index for x in node.qargs] if self._use_qubit_indices else None + def _synthesize_operation( + self, + operation: Operation, + qubits: tuple[int], + tracker: QubitTracker, + ) -> tuple[QuantumCircuit | Operation | DAGCircuit | None, list[int] | None]: + # Try to synthesize the operation. We'll go through the following options: + # (1) Annotations: if the operator is annotated, synthesize the base operation + # and then apply the modifiers. Returns a circuit (e.g. applying a power) + # or operation (e.g adding control on an X gate). + # (2) High-level objects: try running the battery of high-level synthesis plugins (e.g. + # if the operation is a Clifford). Returns a circuit. + # (3) Unrolling custom definitions: try defining the operation if it is not yet + # in the set of supported instructions. Returns a circuit. + # If any of the above were triggered, we will recurse and go again through these steps + # until no further change occurred. At this point, we convert circuits to DAGs (the final + # possible return type). If there was no change, we just return ``None``. + synthesized = None + + # Try synthesizing via AnnotatedOperation. This is faster than an isinstance check + # but a bit less safe since someone could create operations with a ``modifiers`` attribute. + if len(modifiers := getattr(operation, "modifiers", [])) > 0: + # The base operation must be synthesized without using potential control qubits + # used in the modifiers. + num_ctrl = sum( + mod.num_ctrl_qubits for mod in modifiers if isinstance(mod, ControlModifier) ) + baseop_qubits = qubits[num_ctrl:] # reminder: control qubits are the first ones + baseop_tracker = tracker.copy(drop=qubits[:num_ctrl]) # no access to control qubits - if self._definitely_skip_node(node, qubits): - continue - - decomposition, modified = self._recursively_handle_op(node.op, qubits) + # get qubits of base operation + synthesized_base_op, _ = self._synthesize_operation( + operation.base_op, baseop_qubits, baseop_tracker + ) + if synthesized_base_op is None: + synthesized_base_op = operation.base_op + elif isinstance(synthesized_base_op, DAGCircuit): + synthesized_base_op = dag_to_circuit(synthesized_base_op) - if not modified: - continue + synthesized = self._apply_annotations(synthesized_base_op, operation.modifiers) - if isinstance(decomposition, QuantumCircuit): - dag.substitute_node_with_dag( - node, circuit_to_dag(decomposition, copy_operations=False) + # If it was no AnnotatedOperation, try synthesizing via HLS or by unrolling. + else: + # Try synthesis via HLS -- which will return ``None`` if unsuccessful. + indices = qubits if self._use_qubit_indices else None + if len(hls_methods := self._methods_to_try(operation.name)) > 0: + synthesized = self._synthesize_op_using_plugins( + hls_methods, + operation, + indices, + tracker.num_clean(qubits), + tracker.num_dirty(qubits), ) - elif isinstance(decomposition, DAGCircuit): - dag.substitute_node_with_dag(node, decomposition) - elif isinstance(decomposition, Operation): - dag.substitute_node(node, decomposition) - return dag + # If HLS did not apply, or was unsuccessful, try unrolling custom definitions. + if synthesized is None and not self._top_level_only: + synthesized = self._unroll_custom_definition(operation, indices) - def _definitely_skip_node(self, node: DAGOpNode, qubits: Sequence[int] | None) -> bool: - """Fast-path determination of whether a node can certainly be skipped (i.e. nothing will - attempt to synthesise it) without accessing its Python-space `Operation`. + if synthesized is None: + # if we didn't synthesize, there was nothing to unroll, so just set the used qubits + used_qubits = qubits - This is tightly coupled to `_recursively_handle_op`; it exists as a temporary measure to - avoid Python-space `Operation` creation from a `DAGOpNode` if we wouldn't do anything to the - node (which is _most_ nodes).""" - return ( - # The fast path is just for Rust-space standard gates (which excludes - # `AnnotatedOperation`). - node.is_standard_gate() - # If it's a controlled gate, we might choose to do funny things to it. - and not node.is_controlled_gate() - # If there are plugins to try, they need to be tried. - and not self._methods_to_try(node.name) - # If all the above constraints hold, and it's already supported or the basis translator - # can handle it, we'll leave it be. - and ( - self._instruction_supported(node.name, qubits) - # This uses unfortunately private details of `EquivalenceLibrary`, but so does the - # `BasisTranslator`, and this is supposed to just be temporary til this is moved - # into Rust space. - or ( - self._equiv_lib is not None - and equivalence.Key(name=node.name, num_qubits=node.num_qubits) - in self._equiv_lib._key_to_node_index + else: + # if it has been synthesized, recurse and finally store the decomposition + if isinstance(synthesized, Operation): + re_synthesized, qubits = self._synthesize_operation( + synthesized, qubits, tracker.copy() ) - ) - ) - - def _instruction_supported(self, name: str, qubits: Sequence[int]) -> bool: - qubits = tuple(qubits) if qubits is not None else None - # include path for when target exists but target.num_qubits is None (BasicSimulator) - if self._target is None or self._target.num_qubits is None: - return name in self._device_insts - return self._target.instruction_supported(operation_name=name, qargs=qubits) - - def _recursively_handle_op( - self, op: Operation, qubits: Optional[List] = None - ) -> Tuple[Union[QuantumCircuit, DAGCircuit, Operation], bool]: - """Recursively synthesizes a single operation. - - Note: the reason that this function accepts an operation and not a dag node - is that it's also used for synthesizing the base operation for an annotated - gate (i.e. no dag node is available). + if re_synthesized is not None: + synthesized = re_synthesized + used_qubits = qubits + + elif isinstance(synthesized, QuantumCircuit): + aux_qubits = tracker.borrow(synthesized.num_qubits - len(qubits), qubits) + used_qubits = qubits + tuple(aux_qubits) + as_dag = circuit_to_dag(synthesized, copy_operations=False) + + # map used qubits to subcircuit + new_qubits = [as_dag.find_bit(q).index for q in as_dag.qubits] + qubit_map = dict(zip(used_qubits, new_qubits)) + + synthesized = self._run(as_dag, tracker.copy(qubit_map)) + if synthesized.num_qubits() != len(used_qubits): + raise RuntimeError( + f"Mismatching number of qubits, using {synthesized.num_qubits()} " + f"but have {len(used_qubits)}." + ) - There are several possible results: + else: + raise RuntimeError(f"Unexpected synthesized type: {type(synthesized)}") - - The given operation is unchanged: e.g., it is supported by the target or is - in the equivalence library - - The result is a quantum circuit: e.g., synthesizing Clifford using plugin - - The result is a DAGCircuit: e.g., when unrolling custom gates - - The result is an Operation: e.g., adding control to CXGate results in CCXGate - - The given operation could not be synthesized, raising a transpiler error + if synthesized is not None and used_qubits is None: + raise RuntimeError("Failed to find qubit indices on", synthesized) - The function returns the result of the synthesis (either a quantum circuit or - an Operation), and, as an optimization, a boolean indicating whether - synthesis did anything. + return synthesized, used_qubits - The function is recursive, for example synthesizing an annotated operation - involves synthesizing its "base operation" which might also be - an annotated operation. - """ - - # WARNING: if adding new things in here, ensure that `_definitely_skip_node` is also - # up-to-date. - - # Try to apply plugin mechanism - decomposition = self._synthesize_op_using_plugins(op, qubits) - if decomposition is not None: - return decomposition, True - - # Handle annotated operations - decomposition = self._synthesize_annotated_op(op) - if decomposition: - return decomposition, True - - # Don't do anything else if processing only top-level - if self._top_level_only: - return op, False - - # For non-controlled-gates, check if it's already supported by the target - # or is in equivalence library - controlled_gate_open_ctrl = isinstance(op, ControlledGate) and op._open_ctrl - if not controlled_gate_open_ctrl: - if self._instruction_supported(op.name, qubits) or ( - self._equiv_lib is not None and self._equiv_lib.has_entry(op) - ): - return op, False + def _unroll_custom_definition( + self, inst: Instruction, qubits: list[int] | None + ) -> QuantumCircuit | None: + # check if the operation is already supported natively + if not (isinstance(inst, ControlledGate) and inst._open_ctrl): + # include path for when target exists but target.num_qubits is None (BasicSimulator) + inst_supported = self._instruction_supported(inst.name, qubits) + if inst_supported or (self._equiv_lib is not None and self._equiv_lib.has_entry(inst)): + return None # we support this operation already + # if not, try to get the definition try: - # extract definition - definition = op.definition - except TypeError as err: - raise TranspilerError( - f"HighLevelSynthesis was unable to extract definition for {op.name}: {err}" - ) from err - except AttributeError: - # definition is None - definition = None + definition = inst.definition + except (TypeError, AttributeError) as err: + raise TranspilerError(f"HighLevelSynthesis was unable to define {inst.name}.") from err if definition is None: - raise TranspilerError(f"HighLevelSynthesis was unable to synthesize {op}.") + raise TranspilerError(f"HighLevelSynthesis was unable to synthesize {inst}.") - dag = circuit_to_dag(definition, copy_operations=False) - dag = self.run(dag) - return dag, True + return definition def _methods_to_try(self, name: str): """Get a sequence of methods to try for a given op name.""" @@ -581,19 +675,30 @@ def _methods_to_try(self, name: str): return [] def _synthesize_op_using_plugins( - self, op: Operation, qubits: List - ) -> Union[QuantumCircuit, None]: + self, + hls_methods: list, + op: Operation, + qubits: list[int] | None, + num_clean_ancillas: int = 0, + num_dirty_ancillas: int = 0, + ) -> QuantumCircuit | None: """ Attempts to synthesize op using plugin mechanism. - Returns either the synthesized circuit or None (which occurs when no - synthesis methods are available or specified). + + The arguments ``num_clean_ancillas`` and ``num_dirty_ancillas`` specify + the number of clean and dirty qubits available to synthesize the given + operation. A synthesis method does not need to use these additional qubits. + + Returns either the synthesized circuit or None (which may occur + when no synthesis methods is available or specified, or when there is + an insufficient number of auxiliary qubits). """ hls_plugin_manager = self.hls_plugin_manager best_decomposition = None best_score = np.inf - for method in self._methods_to_try(op.name): + for method in hls_methods: # There are two ways to specify a synthesis method. The more explicit # way is to specify it as a tuple consisting of a synthesis algorithm and a # list of additional arguments, e.g., @@ -622,6 +727,10 @@ def _synthesize_op_using_plugins( else: plugin_method = plugin_specifier + # Set the number of available clean and dirty auxiliary qubits via plugin args. + plugin_args["num_clean_ancillas"] = num_clean_ancillas + plugin_args["num_dirty_ancillas"] = num_dirty_ancillas + decomposition = plugin_method.run( op, coupling_map=self._coupling_map, @@ -648,77 +757,85 @@ def _synthesize_op_using_plugins( return best_decomposition - def _synthesize_annotated_op(self, op: Operation) -> Union[Operation, None]: + def _apply_annotations( + self, synthesized: Operation | QuantumCircuit, modifiers: list[Modifier] + ) -> QuantumCircuit: """ Recursively synthesizes annotated operations. Returns either the synthesized operation or None (which occurs when the operation is not an annotated operation). """ - if isinstance(op, AnnotatedOperation): - # Recursively handle the base operation - # This results in QuantumCircuit, DAGCircuit or Gate - synthesized_op, _ = self._recursively_handle_op(op.base_op, qubits=None) - - if isinstance(synthesized_op, AnnotatedOperation): - raise TranspilerError( - "HighLevelSynthesis failed to synthesize the base operation of" - " an annotated operation." + for modifier in modifiers: + if isinstance(modifier, InverseModifier): + # Both QuantumCircuit and Gate have inverse method + synthesized = synthesized.inverse() + + elif isinstance(modifier, ControlModifier): + # Both QuantumCircuit and Gate have control method, however for circuits + # it is more efficient to avoid constructing the controlled quantum circuit. + if isinstance(synthesized, QuantumCircuit): + synthesized = synthesized.to_gate() + + synthesized = synthesized.control( + num_ctrl_qubits=modifier.num_ctrl_qubits, + label=None, + ctrl_state=modifier.ctrl_state, + annotated=False, ) - for modifier in op.modifiers: - # If we have a DAGCircuit at this point, convert it to QuantumCircuit - if isinstance(synthesized_op, DAGCircuit): - synthesized_op = dag_to_circuit(synthesized_op, copy_operations=False) - - if isinstance(modifier, InverseModifier): - # Both QuantumCircuit and Gate have inverse method - synthesized_op = synthesized_op.inverse() - - elif isinstance(modifier, ControlModifier): - # Both QuantumCircuit and Gate have control method, however for circuits - # it is more efficient to avoid constructing the controlled quantum circuit. - if isinstance(synthesized_op, QuantumCircuit): - synthesized_op = synthesized_op.to_gate() - - synthesized_op = synthesized_op.control( - num_ctrl_qubits=modifier.num_ctrl_qubits, - label=None, - ctrl_state=modifier.ctrl_state, - annotated=False, + if isinstance(synthesized, AnnotatedOperation): + raise TranspilerError( + "HighLevelSynthesis failed to synthesize the control modifier." ) - if isinstance(synthesized_op, AnnotatedOperation): - raise TranspilerError( - "HighLevelSynthesis failed to synthesize the control modifier." - ) + elif isinstance(modifier, PowerModifier): + # QuantumCircuit has power method, and Gate needs to be converted + # to a quantum circuit. + if not isinstance(synthesized, QuantumCircuit): + synthesized = _instruction_to_circuit(synthesized) - # Unrolling - synthesized_op, _ = self._recursively_handle_op(synthesized_op) - - elif isinstance(modifier, PowerModifier): - # QuantumCircuit has power method, and Gate needs to be converted - # to a quantum circuit. - if isinstance(synthesized_op, QuantumCircuit): - qc = synthesized_op - else: - qc = QuantumCircuit(synthesized_op.num_qubits, synthesized_op.num_clbits) - qc.append( - synthesized_op, - range(synthesized_op.num_qubits), - range(synthesized_op.num_clbits), - ) + synthesized = synthesized.power(modifier.power) - qc = qc.power(modifier.power) - synthesized_op = qc.to_gate() + else: + raise TranspilerError(f"Unknown modifier {modifier}.") - # Unrolling - synthesized_op, _ = self._recursively_handle_op(synthesized_op) + return synthesized - else: - raise TranspilerError(f"Unknown modifier {modifier}.") + def _definitely_skip_node(self, node: DAGOpNode, qubits: tuple[int] | None) -> bool: + """Fast-path determination of whether a node can certainly be skipped (i.e. nothing will + attempt to synthesise it) without accessing its Python-space `Operation`. - return synthesized_op - return None + This is tightly coupled to `_recursively_handle_op`; it exists as a temporary measure to + avoid Python-space `Operation` creation from a `DAGOpNode` if we wouldn't do anything to the + node (which is _most_ nodes).""" + return ( + # The fast path is just for Rust-space standard gates (which excludes + # `AnnotatedOperation`). + node.is_standard_gate() + # If it's a controlled gate, we might choose to do funny things to it. + and not node.is_controlled_gate() + # If there are plugins to try, they need to be tried. + and not self._methods_to_try(node.name) + # If all the above constraints hold, and it's already supported or the basis translator + # can handle it, we'll leave it be. + and ( + self._instruction_supported(node.name, qubits) + # This uses unfortunately private details of `EquivalenceLibrary`, but so does the + # `BasisTranslator`, and this is supposed to just be temporary til this is moved + # into Rust space. + or ( + self._equiv_lib is not None + and equivalence.Key(name=node.name, num_qubits=node.num_qubits) + in self._equiv_lib._key_to_node_index + ) + ) + ) + + def _instruction_supported(self, name: str, qubits: tuple[int] | None) -> bool: + # include path for when target exists but target.num_qubits is None (BasicSimulator) + if self._target is None or self._target.num_qubits is None: + return name in self._device_insts + return self._target.instruction_supported(operation_name=name, qargs=qubits) class DefaultSynthesisClifford(HighLevelSynthesisPlugin): @@ -1137,3 +1254,9 @@ def run(self, high_level_object, coupling_map=None, target=None, qubits=None, ** return decomposition return None + + +def _instruction_to_circuit(inst: Instruction) -> QuantumCircuit: + circuit = QuantumCircuit(inst.num_qubits, inst.num_clbits) + circuit.append(inst, circuit.qubits, circuit.clbits) + return circuit diff --git a/qiskit/transpiler/passes/synthesis/qubit_tracker.py b/qiskit/transpiler/passes/synthesis/qubit_tracker.py new file mode 100644 index 000000000000..f3dd34b7df31 --- /dev/null +++ b/qiskit/transpiler/passes/synthesis/qubit_tracker.py @@ -0,0 +1,132 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. + +"""A qubit state tracker for synthesizing operations with auxiliary qubits.""" + +from __future__ import annotations +from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass +class QubitTracker: + """Track qubits (by global index) and their state. + + The states are distinguished into clean (meaning in state :math:`|0\rangle`) or dirty (an + unknown state). + """ + + # This could in future be extended to track different state types, if necessary. + # However, using sets of integers here is much faster than e.g. storing a dictionary with + # {index: state} entries. + qubits: tuple[int] + clean: set[int] + dirty: set[int] + + def num_clean(self, active_qubits: Iterable[int] | None = None): + """Return the number of clean qubits, not considering the active qubits.""" + # this could be cached if getting the set length becomes a performance bottleneck + return len(self.clean.difference(active_qubits or set())) + + def num_dirty(self, active_qubits: Iterable[int] | None = None): + """Return the number of dirty qubits, not considering the active qubits.""" + return len(self.dirty.difference(active_qubits or set())) + + def borrow(self, num_qubits: int, active_qubits: Iterable[int] | None = None) -> list[int]: + """Get ``num_qubits`` qubits, excluding ``active_qubits``.""" + active_qubits = set(active_qubits or []) + available_qubits = [qubit for qubit in self.qubits if qubit not in active_qubits] + + if num_qubits > (available := len(available_qubits)): + raise RuntimeError(f"Cannot borrow {num_qubits} qubits, only {available} available.") + + # for now, prioritize returning clean qubits + available_clean = [qubit for qubit in available_qubits if qubit in self.clean] + available_dirty = [qubit for qubit in available_qubits if qubit in self.dirty] + + borrowed = available_clean[:num_qubits] + return borrowed + available_dirty[: (num_qubits - len(borrowed))] + + def used(self, qubits: Iterable[int], check: bool = True) -> None: + """Set the state of ``qubits`` to used (i.e. False).""" + qubits = set(qubits) + + if check: + if len(untracked := qubits.difference(self.qubits)) > 0: + raise ValueError(f"Setting state of untracked qubits: {untracked}. Tracker: {self}") + + self.clean -= qubits + self.dirty |= qubits + + def reset(self, qubits: Iterable[int], check: bool = True) -> None: + """Set the state of ``qubits`` to 0 (i.e. True).""" + qubits = set(qubits) + + if check: + if len(untracked := qubits.difference(self.qubits)) > 0: + raise ValueError(f"Setting state of untracked qubits: {untracked}. Tracker: {self}") + + self.clean |= qubits + self.dirty -= qubits + + def drop(self, qubits: Iterable[int], check: bool = True) -> None: + """Drop qubits from the tracker, meaning that they are no longer available.""" + qubits = set(qubits) + + if check: + if len(untracked := qubits.difference(self.qubits)) > 0: + raise ValueError(f"Dropping untracked qubits: {untracked}. Tracker: {self}") + + self.qubits = tuple(qubit for qubit in self.qubits if qubit not in qubits) + self.clean -= qubits + self.dirty -= qubits + + def copy( + self, qubit_map: dict[int, int] | None = None, drop: Iterable[int] | None = None + ) -> "QubitTracker": + """Copy self. + + Args: + qubit_map: If provided, apply the mapping ``{old_qubit: new_qubit}`` to + the qubits in the tracker. Only those old qubits in the mapping will be + part of the new one. + drop: If provided, drop these qubits in the copied tracker. This argument is ignored + if ``qubit_map`` is given, since the qubits can then just be dropped in the map. + """ + if qubit_map is None and drop is not None: + remaining_qubits = [qubit for qubit in self.qubits if qubit not in drop] + qubit_map = dict(zip(remaining_qubits, remaining_qubits)) + + if qubit_map is None: + clean = self.clean.copy() + dirty = self.dirty.copy() + qubits = self.qubits # tuple is immutable, no need to copy + else: + clean, dirty = set(), set() + for old_index, new_index in qubit_map.items(): + if old_index in self.clean: + clean.add(new_index) + elif old_index in self.dirty: + dirty.add(new_index) + else: + raise ValueError(f"Unknown old qubit index: {old_index}. Tracker: {self}") + + qubits = tuple(qubit_map.values()) + + return QubitTracker(qubits, clean=clean, dirty=dirty) + + def __str__(self) -> str: + return ( + f"QubitTracker({len(self.qubits)}, clean: {self.num_clean()}, dirty: {self.num_dirty()})" + + f"\n\tclean: {self.clean}" + + f"\n\tdirty: {self.dirty}" + ) diff --git a/qiskit/transpiler/passmanager_config.py b/qiskit/transpiler/passmanager_config.py index d3ba1e60a8eb..4c53bc47b31c 100644 --- a/qiskit/transpiler/passmanager_config.py +++ b/qiskit/transpiler/passmanager_config.py @@ -42,6 +42,7 @@ def __init__( hls_config=None, init_method=None, optimization_method=None, + qubits_initially_zero=True, ): """Initialize a PassManagerConfig object @@ -84,6 +85,8 @@ def __init__( init_method (str): The plugin name for the init stage plugin to use optimization_method (str): The plugin name for the optimization stage plugin to use. + qubits_initially_zero (bool): Indicates whether the input circuit is + zero-initialized. """ self.initial_layout = initial_layout self.basis_gates = basis_gates @@ -104,6 +107,7 @@ def __init__( self.unitary_synthesis_plugin_config = unitary_synthesis_plugin_config self.target = target self.hls_config = hls_config + self.qubits_initially_zero = qubits_initially_zero @classmethod def from_backend(cls, backend, _skip_target=False, **pass_manager_options): @@ -189,5 +193,6 @@ def __str__(self): f"\ttiming_constraints: {self.timing_constraints}\n" f"\tunitary_synthesis_method: {self.unitary_synthesis_method}\n" f"\tunitary_synthesis_plugin_config: {self.unitary_synthesis_plugin_config}\n" + f"\tqubits_initially_zero: {self.qubits_initially_zero}\n" f"\ttarget: {str(self.target).replace(newline, newline_tab)}\n" ) diff --git a/qiskit/transpiler/preset_passmanagers/builtin_plugins.py b/qiskit/transpiler/preset_passmanagers/builtin_plugins.py index 7e3e2611546f..d9c272f9d268 100644 --- a/qiskit/transpiler/preset_passmanagers/builtin_plugins.py +++ b/qiskit/transpiler/preset_passmanagers/builtin_plugins.py @@ -103,6 +103,7 @@ def pass_manager(self, pass_manager_config, optimization_level=None) -> PassMana pass_manager_config.unitary_synthesis_method, pass_manager_config.unitary_synthesis_plugin_config, pass_manager_config.hls_config, + pass_manager_config.qubits_initially_zero, ) elif optimization_level == 1: init = PassManager() @@ -121,6 +122,7 @@ def pass_manager(self, pass_manager_config, optimization_level=None) -> PassMana pass_manager_config.unitary_synthesis_method, pass_manager_config.unitary_synthesis_plugin_config, pass_manager_config.hls_config, + pass_manager_config.qubits_initially_zero, ) init.append( InverseCancellation( @@ -149,6 +151,7 @@ def pass_manager(self, pass_manager_config, optimization_level=None) -> PassMana pass_manager_config.unitary_synthesis_method, pass_manager_config.unitary_synthesis_plugin_config, pass_manager_config.hls_config, + pass_manager_config.qubits_initially_zero, ) init.append(ElidePermutations()) init.append(RemoveDiagonalGatesBeforeMeasure()) @@ -200,6 +203,7 @@ def pass_manager(self, pass_manager_config, optimization_level=None) -> PassMana unitary_synthesis_method=pass_manager_config.unitary_synthesis_method, unitary_synthesis_plugin_config=pass_manager_config.unitary_synthesis_plugin_config, hls_config=pass_manager_config.hls_config, + qubits_initially_zero=pass_manager_config.qubits_initially_zero, ) @@ -217,6 +221,7 @@ def pass_manager(self, pass_manager_config, optimization_level=None) -> PassMana unitary_synthesis_method=pass_manager_config.unitary_synthesis_method, unitary_synthesis_plugin_config=pass_manager_config.unitary_synthesis_plugin_config, hls_config=pass_manager_config.hls_config, + qubits_initially_zero=pass_manager_config.qubits_initially_zero, ) diff --git a/qiskit/transpiler/preset_passmanagers/common.py b/qiskit/transpiler/preset_passmanagers/common.py index b0e9eae57b03..f01639ed115e 100644 --- a/qiskit/transpiler/preset_passmanagers/common.py +++ b/qiskit/transpiler/preset_passmanagers/common.py @@ -187,6 +187,7 @@ def generate_unroll_3q( unitary_synthesis_method="default", unitary_synthesis_plugin_config=None, hls_config=None, + qubits_initially_zero=True, ): """Generate an unroll >3q :class:`~qiskit.transpiler.PassManager` @@ -202,8 +203,10 @@ def generate_unroll_3q( configuration, this is plugin specific refer to the specified plugin's documentation for how to use. hls_config (HLSConfig): An optional configuration class to use for - :class:`~qiskit.transpiler.passes.HighLevelSynthesis` pass. - Specifies how to synthesize various high-level objects. + :class:`~qiskit.transpiler.passes.HighLevelSynthesis` pass. + Specifies how to synthesize various high-level objects. + qubits_initially_zero (bool): Indicates whether the input circuit is + zero-initialized. Returns: PassManager: The unroll 3q or more pass manager @@ -228,6 +231,7 @@ def generate_unroll_3q( equivalence_library=sel, basis_gates=basis_gates, min_qubits=3, + qubits_initially_zero=qubits_initially_zero, ) ) # If there are no target instructions revert to using unroll3qormore so @@ -414,6 +418,7 @@ def generate_translation_passmanager( unitary_synthesis_method="default", unitary_synthesis_plugin_config=None, hls_config=None, + qubits_initially_zero=True, ): """Generate a basis translation :class:`~qiskit.transpiler.PassManager` @@ -439,6 +444,8 @@ def generate_translation_passmanager( hls_config (HLSConfig): An optional configuration class to use for :class:`~qiskit.transpiler.passes.HighLevelSynthesis` pass. Specifies how to synthesize various high-level objects. + qubits_initially_zero (bool): Indicates whether the input circuit is + zero-initialized. Returns: PassManager: The basis translation pass manager @@ -466,6 +473,7 @@ def generate_translation_passmanager( use_qubit_indices=True, equivalence_library=sel, basis_gates=basis_gates, + qubits_initially_zero=qubits_initially_zero, ), BasisTranslator(sel, basis_gates, target), ] @@ -490,6 +498,7 @@ def generate_translation_passmanager( use_qubit_indices=True, basis_gates=basis_gates, min_qubits=3, + qubits_initially_zero=qubits_initially_zero, ), Unroll3qOrMore(target=target, basis_gates=basis_gates), Collect2qBlocks(), @@ -512,6 +521,7 @@ def generate_translation_passmanager( target=target, use_qubit_indices=True, basis_gates=basis_gates, + qubits_initially_zero=qubits_initially_zero, ), ] else: diff --git a/qiskit/transpiler/preset_passmanagers/generate_preset_pass_manager.py b/qiskit/transpiler/preset_passmanagers/generate_preset_pass_manager.py index a9a9a5e2f029..353ad8c50b15 100644 --- a/qiskit/transpiler/preset_passmanagers/generate_preset_pass_manager.py +++ b/qiskit/transpiler/preset_passmanagers/generate_preset_pass_manager.py @@ -59,6 +59,7 @@ def generate_preset_pass_manager( init_method=None, optimization_method=None, dt=None, + qubits_initially_zero=True, *, _skip_target=False, ): @@ -233,6 +234,8 @@ def generate_preset_pass_manager( plugin is not used. You can see a list of installed plugins by using :func:`~.list_stage_plugins` with ``"optimization"`` for the ``stage_name`` argument. + qubits_initially_zero (bool): Indicates whether the input circuit is + zero-initialized. Returns: StagedPassManager: The preset pass manager for the given options @@ -376,6 +379,7 @@ def generate_preset_pass_manager( "hls_config": hls_config, "init_method": init_method, "optimization_method": optimization_method, + "qubits_initially_zero": qubits_initially_zero, } if backend is not None: diff --git a/releasenotes/notes/hls-with-ancillas-d6792b41dfcf4aac.yaml b/releasenotes/notes/hls-with-ancillas-d6792b41dfcf4aac.yaml new file mode 100644 index 000000000000..b70a032733c4 --- /dev/null +++ b/releasenotes/notes/hls-with-ancillas-d6792b41dfcf4aac.yaml @@ -0,0 +1,18 @@ +--- +features_transpiler: + - | + A new argument ``qubits_initially_zero`` has been added to :func:`qiskit.compiler.transpile`, + :func:`.generate_preset_pass_manager`, and to :class:`~.PassManagerConfig`. + If set to ``True``, the qubits are assumed to be initially in the state :math:`|0\rangle`, + potentially allowing additional optimization opportunities for individual transpiler passes. + - | + The constructor for :class:`.HighLevelSynthesis` transpiler pass now accepts an + additional argument ``qubits_initially_zero``. If set to ``True``, the pass assumes that the + qubits are initially in the state :math:`|0\rangle`. In addition, the pass keeps track of + clean and dirty auxiliary qubits throughout the run, and passes this information to plugins + via kwargs ``num_clean_ancillas`` and ``num_dirty_ancillas``. +upgrade_transpiler: + - | + The :func:`~qiskit.compiler.transpile` now assumes that the qubits are initially in the state + :math:`|0\rangle`. To avoid this assumption, one can set the argument ``qubits_initially_zero`` + to ``False``. diff --git a/test/python/transpiler/test_high_level_synthesis.py b/test/python/transpiler/test_high_level_synthesis.py index d1ea21cde544..97c0b58bb377 100644 --- a/test/python/transpiler/test_high_level_synthesis.py +++ b/test/python/transpiler/test_high_level_synthesis.py @@ -28,6 +28,7 @@ Parameter, Operation, EquivalenceLibrary, + Delay, ) from qiskit.circuit.classical import expr, types from qiskit.circuit.library import ( @@ -42,6 +43,7 @@ CU3Gate, CU1Gate, QFTGate, + IGate, ) from qiskit.circuit.library.generalized_gates import LinearFunction from qiskit.quantum_info import Clifford @@ -220,6 +222,39 @@ def method(self, op_name, method_name): return self.plugins[plugin_name]() +class MockPlugin(HighLevelSynthesisPlugin): + """A mock HLS using auxiliary qubits.""" + + def run(self, high_level_object, coupling_map=None, target=None, qubits=None, **options): + """Run a mock synthesis for high_level_object being anything with a num_qubits property. + + Replaces the high_level_objects by a layer of X gates, applies S gates on clean + ancillas and T gates on dirty ancillas. + """ + + num_action_qubits = high_level_object.num_qubits + num_clean = options["num_clean_ancillas"] + num_dirty = options["num_dirty_ancillas"] + num_qubits = num_action_qubits + num_clean + num_dirty + decomposition = QuantumCircuit(num_qubits) + decomposition.x(range(num_action_qubits)) + if num_clean > 0: + decomposition.s(range(num_action_qubits, num_action_qubits + num_clean)) + if num_dirty > 0: + decomposition.t(range(num_action_qubits + num_clean, num_qubits)) + + return decomposition + + +class EmptyPlugin(HighLevelSynthesisPlugin): + """A mock plugin returning None (i.e. a failed synthesis).""" + + def run(self, high_level_object, coupling_map=None, target=None, qubits=None, **options): + """Elaborate code to return None :)""" + return None + + +@ddt class TestHighLevelSynthesisInterface(QiskitTestCase): """Tests for the synthesis plugin interface.""" @@ -481,7 +516,7 @@ def test_target_gets_passed_to_plugins(self): [ HighLevelSynthesis( hls_config=hls_config, - target=GenericBackendV2(num_qubits=5, basis_gates=["u", "cx"]).target, + target=GenericBackendV2(num_qubits=5, basis_gates=["u", "cx", "id"]).target, ) ] ) @@ -514,6 +549,111 @@ def test_qubits_get_passed_to_plugins(self): # plugin should see qubits and complete without errors. pm_use_qubits_true.run(qc) + def test_ancilla_arguments(self): + """Test ancillas are correctly labelled.""" + gate = Gate(name="duckling", num_qubits=5, params=[]) + hls_config = HLSConfig(duckling=[MockPlugin()]) + + qc = QuantumCircuit(10) + qc.h([0, 8, 9]) # the two last H gates yield two dirty ancillas + qc.barrier() + qc.append(gate, range(gate.num_qubits)) + + pm = PassManager([HighLevelSynthesis(hls_config=hls_config)]) + + synthesized = pm.run(qc) + + count = synthesized.count_ops() + self.assertEqual(count.get("x", 0), gate.num_qubits) # gate qubits + self.assertEqual(count.get("s", 0), qc.num_qubits - gate.num_qubits - 2) # clean + self.assertEqual(count.get("t", 0), 2) # dirty + + def test_ancilla_noop(self): + """Test ancillas states are not affected by no-ops.""" + gate = Gate(name="duckling", num_qubits=1, params=[]) + hls_config = HLSConfig(duckling=[MockPlugin()]) + pm = PassManager([HighLevelSynthesis(hls_config)]) + + noops = [Delay(100), IGate()] + for noop in noops: + qc = QuantumCircuit(2) + qc.append(noop, [1]) # this noop should still yield a clean ancilla + qc.barrier() + qc.append(gate, [0]) + + synthesized = pm.run(qc) + count = synthesized.count_ops() + with self.subTest(noop=noop): + self.assertEqual(count.get("x", 0), gate.num_qubits) # gate qubits + self.assertEqual(count.get("s", 0), 1) # clean ancilla + self.assertEqual(count.get("t", 0), 0) # dirty ancilla + + @data(True, False) + def test_ancilla_reset(self, reset): + """Test ancillas are correctly freed after a reset operation.""" + gate = Gate(name="duckling", num_qubits=1, params=[]) + hls_config = HLSConfig(duckling=[MockPlugin()]) + pm = PassManager([HighLevelSynthesis(hls_config)]) + + qc = QuantumCircuit(2) + qc.h(1) + if reset: + qc.reset(1) # the reset frees the ancilla qubit again + qc.barrier() + qc.append(gate, [0]) + + synthesized = pm.run(qc) + count = synthesized.count_ops() + + expected_clean = 1 if reset else 0 + expected_dirty = 1 - expected_clean + + self.assertEqual(count.get("x", 0), gate.num_qubits) # gate qubits + self.assertEqual(count.get("s", 0), expected_clean) # clean ancilla + self.assertEqual(count.get("t", 0), expected_dirty) # clean ancilla + + def test_ancilla_state_maintained(self): + """Test ancillas states are still dirty/clean after they've been used.""" + gate = Gate(name="duckling", num_qubits=1, params=[]) + hls_config = HLSConfig(duckling=[MockPlugin()]) + pm = PassManager([HighLevelSynthesis(hls_config)]) + + qc = QuantumCircuit(3) + qc.h(2) # the final ancilla is dirty + qc.barrier() + qc.append(gate, [0]) + qc.append(gate, [0]) + + # the ancilla states should be unchanged after the synthesis, i.e. qubit 1 is always + # clean (S gate) and qubit 2 is always dirty (T gate) + ref = QuantumCircuit(3) + ref.h(2) + ref.barrier() + for _ in range(2): + ref.x(0) + ref.s(1) + ref.t(2) + + self.assertEqual(ref, pm.run(qc)) + + def test_synth_fails_definition_exists(self): + """Test the case that a synthesis fails but the operation can be unrolled.""" + + circuit = QuantumCircuit(1) + circuit.ry(0.2, 0) + + config = HLSConfig(ry=[EmptyPlugin()]) + hls = HighLevelSynthesis(hls_config=config) + + with self.subTest("nothing happened w/o basis gates"): + out = hls(circuit) + self.assertEqual(out, circuit) + + hls = HighLevelSynthesis(hls_config=config, basis_gates=["u"]) + with self.subTest("unrolled w/ basis gates"): + out = hls(circuit) + self.assertEqual(out.count_ops(), {"u": 1}) + class TestPMHSynthesisLinearFunctionPlugin(QiskitTestCase): """Tests for the PMHSynthesisLinearFunction plugin for synthesizing linear functions.""" @@ -1743,6 +1883,20 @@ def test_unrolling_parameterized_composite_gates(self): self.assertEqual(circuit_to_dag(expected), out_dag) + def test_unroll_with_clbit(self): + """Test unrolling a custom definition that has qubits and clbits.""" + block = QuantumCircuit(1, 1) + block.h(0) + block.measure(0, 0) + + circuit = QuantumCircuit(1, 1) + circuit.append(block.to_instruction(), [0], [0]) + + hls = HighLevelSynthesis(basis_gates=["h", "measure"]) + out = hls(circuit) + + self.assertEqual(block, out) + class TestGate(Gate): """Mock one qubit zero param gate.""" diff --git a/test/python/transpiler/test_passmanager_config.py b/test/python/transpiler/test_passmanager_config.py index ebac6a410b7f..341da357d37a 100644 --- a/test/python/transpiler/test_passmanager_config.py +++ b/test/python/transpiler/test_passmanager_config.py @@ -140,6 +140,7 @@ def test_str(self): \ttiming_constraints: None \tunitary_synthesis_method: default \tunitary_synthesis_plugin_config: None +\tqubits_initially_zero: True \ttarget: Target: Basic Target \tNumber of qubits: None \tInstructions: From 592c5f4599c6988be9c8aba7ea20841ebf141b7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 13:13:22 +0000 Subject: [PATCH 3/4] Bump indexmap from 2.3.0 to 2.4.0 (#12955) Bumps [indexmap](https://github.com/indexmap-rs/indexmap) from 2.3.0 to 2.4.0. - [Changelog](https://github.com/indexmap-rs/indexmap/blob/master/RELEASES.md) - [Commits](https://github.com/indexmap-rs/indexmap/compare/2.3.0...2.4.0) --- updated-dependencies: - dependency-name: indexmap dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b2ee803ee4a..0a590fc1fd9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,9 +550,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "indexmap" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" +checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" dependencies = [ "equivalent", "hashbrown 0.14.5", diff --git a/Cargo.toml b/Cargo.toml index 5fffe8ff4dd7..13ea3ead5584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ license = "Apache-2.0" # Each crate can add on specific features freely as it inherits. [workspace.dependencies] bytemuck = "1.16" -indexmap.version = "2.3.0" +indexmap.version = "2.4.0" hashbrown.version = "0.14.0" num-bigint = "0.4" num-complex = "0.4" From 2d3db9a6e66fd70a5406ab94d49fb3b0b29f9225 Mon Sep 17 00:00:00 2001 From: Jake Lishman Date: Wed, 14 Aug 2024 16:35:22 +0100 Subject: [PATCH 4/4] Allow immutable borrow to access `QuantumCircuit.parameters` (#12918) * Allow immutable borrow to access `QuantumCircuit.parameters` `QuantumCircuit.parameters` is logically a read-only operation on `QuantumCircuit`. For efficiency in multiple calls to `assign_parameters`, we actually cache the sort order of the internal `ParameterTable` on access. This is purely a caching effect, and should not leak out to users. The previous implementation took a Rust-space mutable borrow out in order to (potentially) mutate the cache. This caused problems if multiple Python threads attempted to call `assign_parameters` simultaneously; it was possible for one thread to give up the GIL during its initial call to `CircuitData::copy` (so an immutable borrow was still live), allowing another thread to continue on to the getter `CircuitData::get_parameters`, which required a mutable borrow, which failed due to the paused thread in `copy`. This moves the cache into a `RefCell`, allowing the parameter getters to take an immutable borrow as the receiver. We now write the cache out only if we *can* take the mutable borrow out necessary. This can mean that other threads will have to repeat the work of re-sorting the parameters, because their borrows were blocking the saving of the cache, but this will not cause failures. The methods on `ParameterTable` that invalidate the cache all require a mutable borrow on the table itself. This makes it impossible for an immutable borrow to exist simultaneously on the cache, so these methods should always succeed to acquire the cache lock to invalidate it. * Use `RefCell::get_mut` where possible In several cases, the previous code was using the runtime-checked `RefCell::borrow_mut` in locations that can be statically proven to be safe to take the mutable reference. Using the correct function for this makes the logic clearer (as well as technically removing a small amount of runtime overhead). * Use `OnceCell` instead of `RefCell` `OnceCell` has less runtime checking than `RefCell` (only whether it is initialised or not, which is an `Option` check), and better represents the dynamic extensions to the borrow checker that we actually need for the caching in this method. All methods that can invalidate the cache all necessarily take `&mut ParameterTable` already, since they will modify Rust-space data. A `OnceCell` can be deinitialised through a mutable reference, so this is fine. The only reason a `&ParameterTable` method would need to mutate the cache is to create it, which is the allowed set of `OnceCell` operations. --- crates/circuit/src/circuit_data.rs | 2 +- crates/circuit/src/parameter_table.rs | 129 +++++++++++++++----------- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/crates/circuit/src/circuit_data.rs b/crates/circuit/src/circuit_data.rs index 68267121b5a8..2feada21d85c 100644 --- a/crates/circuit/src/circuit_data.rs +++ b/crates/circuit/src/circuit_data.rs @@ -459,7 +459,7 @@ impl CircuitData { /// Get a (cached) sorted list of the Python-space `Parameter` instances tracked by this circuit /// data's parameter table. #[getter] - pub fn get_parameters<'py>(&mut self, py: Python<'py>) -> Bound<'py, PyList> { + pub fn get_parameters<'py>(&self, py: Python<'py>) -> Bound<'py, PyList> { self.param_table.py_parameters(py) } diff --git a/crates/circuit/src/parameter_table.rs b/crates/circuit/src/parameter_table.rs index 38cabf10c69f..36a7cea7a9a8 100644 --- a/crates/circuit/src/parameter_table.rs +++ b/crates/circuit/src/parameter_table.rs @@ -10,6 +10,8 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. +use std::cell::OnceCell; + use hashbrown::hash_map::Entry; use hashbrown::{HashMap, HashSet}; use thiserror::Error; @@ -123,18 +125,17 @@ pub struct ParameterTable { by_name: HashMap, /// Additional information on any `ParameterVector` instances that have elements in the circuit. vectors: HashMap, - /// Sort order of the parameters. This is lexicographical for most parameters, except elements - /// of a `ParameterVector` are sorted within the vector by numerical index. We calculate this - /// on demand and cache it; an empty `order` implies it is not currently calculated. We don't - /// use `Option` so we can re-use the allocation for partial parameter bindings. + /// Cache of the sort order of the parameters. This is lexicographical for most parameters, + /// except elements of a `ParameterVector` are sorted within the vector by numerical index. We + /// calculate this on demand and cache it. /// - /// Any method that adds or a removes a parameter is responsible for invalidating this cache. - order: Vec, + /// Any method that adds or removes a parameter needs to invalidate this. + order_cache: OnceCell>, /// Cache of a Python-space list of the parameter objects, in order. We only generate this /// specifically when asked. /// - /// Any method that adds or a removes a parameter is responsible for invalidating this cache. - py_parameters: Option>, + /// Any method that adds or removes a parameter needs to invalidate this. + py_parameters_cache: OnceCell>, } impl ParameterTable { @@ -194,8 +195,6 @@ impl ParameterTable { None }; self.by_name.insert(name.clone(), uuid); - self.order.clear(); - self.py_parameters = None; let mut uses = HashSet::new(); if let Some(usage) = usage { uses.insert_unique_unchecked(usage); @@ -206,6 +205,7 @@ impl ParameterTable { element, object: param_ob.clone().unbind(), }); + self.invalidate_cache(); } } Ok(uuid) @@ -231,19 +231,20 @@ impl ParameterTable { } /// Get the (maybe cached) Python list of the sorted `Parameter` objects. - pub fn py_parameters<'py>(&mut self, py: Python<'py>) -> Bound<'py, PyList> { - if let Some(py_parameters) = self.py_parameters.as_ref() { - return py_parameters.clone_ref(py).into_bound(py); - } - self.ensure_sorted(); - let out = PyList::new_bound( - py, - self.order - .iter() - .map(|uuid| self.by_uuid[uuid].object.clone_ref(py).into_bound(py)), - ); - self.py_parameters = Some(out.clone().unbind()); - out + pub fn py_parameters<'py>(&self, py: Python<'py>) -> Bound<'py, PyList> { + self.py_parameters_cache + .get_or_init(|| { + PyList::new_bound( + py, + self.order_cache + .get_or_init(|| self.sorted_order()) + .iter() + .map(|uuid| self.by_uuid[uuid].object.bind(py).clone()), + ) + .unbind() + }) + .bind(py) + .clone() } /// Get a Python set of all tracked `Parameter` objects. @@ -251,23 +252,18 @@ impl ParameterTable { PySet::new_bound(py, self.by_uuid.values().map(|info| &info.object)) } - /// Ensure that the `order` field is populated and sorted. - fn ensure_sorted(&mut self) { - // If `order` is already populated, it's sorted; it's the responsibility of the methods of - // this struct that mutate it to invalidate the cache. - if !self.order.is_empty() { - return; - } - self.order.reserve(self.by_uuid.len()); - self.order.extend(self.by_uuid.keys()); - self.order.sort_unstable_by_key(|uuid| { + /// Get the sorted order of the `ParameterTable`. This does not access the cache. + fn sorted_order(&self) -> Vec { + let mut out = self.by_uuid.keys().copied().collect::>(); + out.sort_unstable_by_key(|uuid| { let info = &self.by_uuid[uuid]; if let Some(vec) = info.element.as_ref() { (&self.vectors[&vec.vector_uuid].name, vec.index) } else { (&info.name, 0) } - }) + }); + out } /// Add a use of a parameter to the table. @@ -310,9 +306,8 @@ impl ParameterTable { vec_entry.remove_entry(); } } - self.order.clear(); - self.py_parameters = None; entry.remove_entry(); + self.invalidate_cache(); } Ok(()) } @@ -337,26 +332,28 @@ impl ParameterTable { (vector_info.refcount > 0).then_some(vector_info) }); } - self.order.clear(); - self.py_parameters = None; + self.invalidate_cache(); Ok(info.uses) } /// Clear this table, yielding the Python parameter objects and their uses in sorted order. + /// + /// The clearing effect is eager and not dependent on the iteration. pub fn drain_ordered( - &'_ mut self, - ) -> impl Iterator, HashSet)> + '_ { - self.ensure_sorted(); + &mut self, + ) -> impl ExactSizeIterator, HashSet)> { + let order = self + .order_cache + .take() + .unwrap_or_else(|| self.sorted_order()); + let by_uuid = ::std::mem::take(&mut self.by_uuid); self.by_name.clear(); self.vectors.clear(); - self.py_parameters = None; - self.order.drain(..).map(|uuid| { - let info = self - .by_uuid - .remove(&uuid) - .expect("tracked UUIDs should be consistent"); - (info.object, info.uses) - }) + self.py_parameters_cache.take(); + ParameterTableDrain { + order: order.into_iter(), + by_uuid, + } } /// Empty this `ParameterTable` of all its contents. This does not affect the capacities of the @@ -365,8 +362,12 @@ impl ParameterTable { self.by_uuid.clear(); self.by_name.clear(); self.vectors.clear(); - self.order.clear(); - self.py_parameters = None; + self.invalidate_cache(); + } + + fn invalidate_cache(&mut self) { + self.order_cache.take(); + self.py_parameters_cache.take(); } /// Expose the tracked data for a given parameter as directly as possible to Python space. @@ -401,9 +402,33 @@ impl ParameterTable { visit.call(&info.object)? } // We don't need to / can't visit the `PyBackedStr` stores. - if let Some(list) = self.py_parameters.as_ref() { + if let Some(list) = self.py_parameters_cache.get() { visit.call(list)? } Ok(()) } } + +struct ParameterTableDrain { + order: ::std::vec::IntoIter, + by_uuid: HashMap, +} +impl Iterator for ParameterTableDrain { + type Item = (Py, HashSet); + + fn next(&mut self) -> Option { + self.order.next().map(|uuid| { + let info = self + .by_uuid + .remove(&uuid) + .expect("tracked UUIDs should be consistent"); + (info.object, info.uses) + }) + } + + fn size_hint(&self) -> (usize, Option) { + self.order.size_hint() + } +} +impl ExactSizeIterator for ParameterTableDrain {} +impl ::std::iter::FusedIterator for ParameterTableDrain {}