Skip to content

Commit

Permalink
Add control-flow builder interface (#7282)
Browse files Browse the repository at this point in the history
* Add control-flow builder interface

This adds a builder interface for control-flow operations on
`QuantumCircuit` (such as `ForLoopOp`, `IfElseOp`, and `WhileLoopOp`).
The interface uses the same circuit methods, but they are now overloaded
so that if the ``body`` parameter is not given, they return a context
manager.  Entering one of these context managers pushes a scope into the
circuit, and captures all gate calls (and other scopes) and the
resources these use, and builds up the relevant operation at the end.
For example, you can now do:

    qc = QuantumCircuit(2, 2)
    with qc.for_loop(None, range(5)) as i:
        qc.rx(i * math.pi / 4, 0)

This will produce a `ForLoopOp` on `qc`, which knows that qubit 0 is the
only resource used within the loop body.  These context managers can be
nested, and will correctly determine their widths.  You can use
`break_loop` and `continue_loop` within a context, and it will expand to
be the correct width for its containing loop, even if it is nested in
further `if_test` blocks.

The `if_test` context manager provides a chained manager which, if
desired, can be used to create an `else` block, such as by

    qreg = QuantumRegister(2)
    creg = ClassicalRegister(2)
    qc = QuantumCircuit(qreg, creg)
    qc.h(0)
    qc.cx(0, 1)
    qc.measure(0, 0)
    with qc.if_test((creg, 0)) as else_:
        qc.x(1)
    with else_:
        qc.z(1)

The manager will ensure that the `if` and `else` bodies are defined over
the same set of resources.

This commit also ensures that instances of `ParameterExpression` added
to a circuit inside _all_ control flow instructions will correctly
propagate up to the top-level circuit.

* Fix linter complaints

* Fix typos

Co-authored-by: Kevin Hartman <kevin@hart.mn>

* Add extra error check

* Remove useless early return

* Document qubits, clbits in ControlFlowBlockBuilder.__init__

* Remove comment that is likely to stagnate

* Add extra else test

* Improve developer documentation in InstructionPlaceholder

* Remove expected failure from test

This branch contains the fix that this test depended on.

* Remove unused import

* Change order of for_loop parameters

This changes the parameter order of `QuantumCircuit.for_loop` to be

    indexset, [loop_parameter, [body, qubits, clbits]]

whereas previously it was

    loop_parameter, indexset, [body, qubits, clbits]

Similar changes were made within the constructor of `ForLoopOp` and its
parameters.  This is to improve ergonomics of the builder interface,
where it is not generally necessary to specify a loop variable, since
one is allocated for the user.

Co-authored-by: Kevin Hartman <kevin@hart.mn>
  • Loading branch information
jakelishman and kevinhartman authored Dec 2, 2021
1 parent efbf436 commit 9ba51b5
Show file tree
Hide file tree
Showing 16 changed files with 3,889 additions and 191 deletions.
7 changes: 3 additions & 4 deletions qiskit/circuit/controlflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@


from .control_flow import ControlFlowOp
from .if_else import IfElseOp
from .continue_loop import ContinueLoopOp
from .break_loop import BreakLoopOp

from .if_else import IfElseOp
from .while_loop import WhileLoopOp
from .for_loop import ForLoopOp

from .continue_loop import ContinueLoopOp
from .break_loop import BreakLoopOp
21 changes: 20 additions & 1 deletion qiskit/circuit/controlflow/break_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Optional

from qiskit.circuit.instruction import Instruction
from .builder import InstructionPlaceholder


class BreakLoopOp(Instruction):
Expand Down Expand Up @@ -43,5 +44,23 @@ class BreakLoopOp(Instruction):
"""

def __init__(self, num_qubits: int, num_clbits: int, label: Optional[str] = None):

super().__init__("break_loop", num_qubits, num_clbits, [], label=label)


class BreakLoopPlaceholder(InstructionPlaceholder):
"""A placeholder instruction for use in control-flow context managers, when the number of qubits
and clbits is not yet known."""

def __init__(self, *, label: Optional[str] = None):
super().__init__("break_loop", 0, 0, [], label=label)

def concrete_instruction(self, qubits, clbits):
return (
self._copy_mutable_properties(BreakLoopOp(len(qubits), len(clbits), label=self.label)),
tuple(qubits),
tuple(clbits),
)

def placeholder_resources(self):
# Is it just me, or does this look like an owl?
return ((), ())
389 changes: 389 additions & 0 deletions qiskit/circuit/controlflow/builder.py

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions qiskit/circuit/controlflow/condition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# 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.

"""Functions for dealing with classical conditions."""

from typing import Tuple, Union

from qiskit.circuit.classicalregister import ClassicalRegister, Clbit
from qiskit.circuit.exceptions import CircuitError


def validate_condition(
condition: Tuple[Union[ClassicalRegister, Clbit], int]
) -> Tuple[Union[ClassicalRegister, Clbit], int]:
"""Validate that a condition is in a valid format and return it, but raise if it is invalid.
Args:
condition: the condition to be tested for validity.
Raises:
CircuitError: if the condition is not in a valid format.
Returns:
The same condition as passed, if it was valid.
"""
try:
bits, value = condition
if isinstance(bits, (ClassicalRegister, Clbit)) and isinstance(value, int):
return (bits, value)
except (TypeError, ValueError):
pass
raise CircuitError(
"A classical condition should be a 2-tuple of `(ClassicalRegister | Clbit, int)`,"
f" but received '{condition!r}'."
)


def condition_bits(condition: Tuple[Union[ClassicalRegister, Clbit], int]) -> Tuple[Clbit, ...]:
"""Return the classical resources used by ``condition`` as a tuple of :obj:`.Clbit`.
This is useful when the exact set of bits is required, rather than the logical grouping of
:obj:`.ClassicalRegister`, such as when determining circuit blocking.
Args:
condition: the valid condition to extract the bits from.
Returns:
a tuple of all classical bits used in the condition.
"""
return (condition[0],) if isinstance(condition[0], Clbit) else tuple(condition[0])
22 changes: 21 additions & 1 deletion qiskit/circuit/controlflow/continue_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Optional

from qiskit.circuit.instruction import Instruction
from .builder import InstructionPlaceholder


class ContinueLoopOp(Instruction):
Expand Down Expand Up @@ -43,5 +44,24 @@ class ContinueLoopOp(Instruction):
"""

def __init__(self, num_qubits: int, num_clbits: int, label: Optional[str] = None):

super().__init__("continue_loop", num_qubits, num_clbits, [], label=label)


class ContinueLoopPlaceholder(InstructionPlaceholder):
"""A placeholder instruction for use in control-flow context managers, when the number of qubits
and clbits is not yet known."""

def __init__(self, *, label: Optional[str] = None):
super().__init__("continue_loop", 0, 0, [], label=label)

def concrete_instruction(self, qubits, clbits):
return (
self._copy_mutable_properties(
ContinueLoopOp(len(qubits), len(clbits), label=self.label)
),
tuple(qubits),
tuple(clbits),
)

def placeholder_resources(self):
return ((), ())
108 changes: 103 additions & 5 deletions qiskit/circuit/controlflow/for_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ class ForLoopOp(ControlFlowOp):
the set of integer values provided in ``indexset``.
Parameters:
indexset: A collection of integers to loop over.
loop_parameter: The placeholder parameterizing ``body`` to which
the values from ``indexset`` will be assigned.
indexset: A collection of integers to loop over.
body: The loop body to be repeatedly executed.
label: An optional label for identifying the instruction.
Expand All @@ -51,16 +51,16 @@ class ForLoopOp(ControlFlowOp):

def __init__(
self,
loop_parameter: Union[Parameter, None],
indexset: Iterable[int],
loop_parameter: Union[Parameter, None],
body: QuantumCircuit,
label: Optional[str] = None,
):
num_qubits = body.num_qubits
num_clbits = body.num_clbits

super().__init__(
"for_loop", num_qubits, num_clbits, [loop_parameter, indexset, body], label=label
"for_loop", num_qubits, num_clbits, [indexset, loop_parameter, body], label=label
)

@property
Expand All @@ -69,7 +69,7 @@ def params(self):

@params.setter
def params(self, parameters):
loop_parameter, indexset, body = parameters
indexset, loop_parameter, body = parameters

if not isinstance(loop_parameter, (Parameter, type(None))):
raise CircuitError(
Expand Down Expand Up @@ -111,8 +111,106 @@ def params(self, parameters):
# Preserve ranges so that they can be exported as OpenQASM3 ranges.
indexset = indexset if isinstance(indexset, range) else tuple(indexset)

self._params = [loop_parameter, indexset, body]
self._params = [indexset, loop_parameter, body]

@property
def blocks(self):
return (self._params[2],)


class ForLoopContext:
"""A context manager for building up ``for`` loops onto circuits in a natural order, without
having to construct the loop body first.
Within the block, a lot of the bookkeeping is done for you; you do not need to keep track of
which qubits and clbits you are using, for example, and a loop parameter will be allocated for
you, if you do not supply one yourself. All normal methods of accessing the qubits on the
underlying :obj:`~QuantumCircuit` will work correctly, and resolve into correct accesses within
the interior block.
You generally should never need to instantiate this object directly. Instead, use
:obj:`.QuantumCircuit.for_loop` in its context-manager form, i.e. by not supplying a ``body`` or
sets of qubits and clbits.
Example usage::
import math
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 1)
with qc.for_loop(None, range(5)) as i:
qc.rx(i * math.pi/4, 0)
qc.cx(0, 1)
qc.measure(0, 0)
qc.break_loop().c_if(0)
This context should almost invariably be created by a :meth:`.QuantumCircuit.for_loop` call, and
the resulting instance is a "friend" of the calling circuit. The context will manipulate the
circuit's defined scopes when it is entered (by pushing a new scope onto the stack) and exited
(by popping its scope, building it, and appending the resulting :obj:`.ForLoopOp`).
"""

# Class-level variable keep track of the number of auto-generated loop variables, so we don't
# get naming clashes.
_generated_loop_parameters = 0

__slots__ = (
"_circuit",
"_generate_loop_parameter",
"_loop_parameter",
"_indexset",
"_label",
"_used",
)

def __init__(
self,
circuit: QuantumCircuit,
indexset: Iterable[int],
loop_parameter: Optional[Parameter] = None,
*,
label: Optional[str] = None,
):
self._circuit = circuit
self._generate_loop_parameter = loop_parameter is None
self._loop_parameter = loop_parameter
# We can pass through `range` instances because OpenQASM 3 has native support for this type
# of iterator set.
self._indexset = indexset if isinstance(indexset, range) else tuple(indexset)
self._label = label
self._used = False

def __enter__(self):
if self._used:
raise CircuitError("A for-loop context manager cannot be re-entered.")
self._used = True
self._circuit._push_scope()
if self._generate_loop_parameter:
self._loop_parameter = Parameter(f"_loop_i_{self._generated_loop_parameters}")
type(self)._generated_loop_parameters += 1
return self._loop_parameter

def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
# If we're leaving the context manager because an exception was raised, there's nothing
# to do except restore the circuit state.
self._circuit._pop_scope()
return False
scope = self._circuit._pop_scope()
# Loops do not need to pass any further resources in, because this scope itself defines the
# extent of ``break`` and ``continue`` statements.
body = scope.build(scope.qubits, scope.clbits)
# We always bind the loop parameter if the user gave it to us, even if it isn't actually
# used, because they requested we do that by giving us a parameter. However, if they asked
# us to auto-generate a parameter, then we only add it if they actually used it, to avoid
# using unnecessary resources.
if self._generate_loop_parameter and self._loop_parameter not in body.parameters:
loop_parameter = None
else:
loop_parameter = self._loop_parameter
self._circuit.append(
ForLoopOp(self._indexset, loop_parameter, body, label=self._label),
tuple(body.qubits),
tuple(body.clbits),
)
return False
Loading

0 comments on commit 9ba51b5

Please sign in to comment.