Skip to content

Commit

Permalink
Track runtime variables in control-flow builders (#10977)
Browse files Browse the repository at this point in the history
* Track runtime variables in control-flow builders

This adds support for all the new classical runtime variables through
the control-flow builder interface.  In particular, most usefully it
automatically manages the scoping rules for new declarations and inner
variable accesses, and ensures that its built scopes automatically close
over any variables used within them (including making sure nested scopes
do the same thing).

The builder API is factored out a little into an explicit interface
object, with `QuantumCircuit` getting an explicit implementation of
that.  This is done because the number of separate API methods we would
have needed to pass around / infer was getting overly large, and this
allows us to just use standard virtual dispatch to automatically do the
right thing.

Python doesn't have a way to have an object implement an
interface other than by structural (duck) typing, so to avoid name
leakage and collisions, we instead make `QuantumCircuit`'s
implementation a friend class that handles the inner state on its
behalf.

Not everything control-flow-builder related is factored out into the API
because it didn't seem overly useful to do this, especially when the
overridden behaviour would just have been to throw exceptions.

* 🇺🇸

Co-authored-by: Matthew Treinish <[email protected]>

* More descriptive variable name

* Remove superfluous private statement

---------

Co-authored-by: Matthew Treinish <[email protected]>
  • Loading branch information
jakelishman and mtreinish authored Nov 30, 2023
1 parent c0702bf commit a79e879
Show file tree
Hide file tree
Showing 3 changed files with 732 additions and 182 deletions.
242 changes: 200 additions & 42 deletions qiskit/circuit/controlflow/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
# having a far more complete builder of all circuits, with more classical control and creation, in
# the future.

from __future__ import annotations

import abc
import itertools
import typing
from typing import Callable, Collection, Iterable, List, FrozenSet, Tuple, Union, Optional
from typing import Collection, Iterable, List, FrozenSet, Tuple, Union, Optional, Sequence

from qiskit.circuit.classical import expr
from qiskit.circuit.classicalregister import Clbit, ClassicalRegister
from qiskit.circuit.exceptions import CircuitError
from qiskit.circuit.instruction import Instruction
Expand All @@ -36,6 +38,124 @@
import qiskit


class CircuitScopeInterface(abc.ABC):
"""An interface that circuits and builder blocks explicitly fulfill, which contains the primitive
methods of circuit construction and object validation.
This allows core circuit methods to be applied to the currently open builder scope, and allows
the builders to hook into all places where circuit resources might be used. This allows the
builders to track the resources being used, without getting in the way of
:class:`.QuantumCircuit` doing its own thing.
"""

__slots__ = ()

@property
@abc.abstractmethod
def instructions(self) -> Sequence[CircuitInstruction]:
"""Indexable view onto the :class:`.CircuitInstruction`s backing this scope."""

@abc.abstractmethod
def append(self, instruction: CircuitInstruction) -> CircuitInstruction:
"""Low-level 'append' primitive; this may assume that the qubits, clbits and operation are
all valid for the circuit.
Abstraction of :meth:`.QuantumCircuit._append` (the low-level one, not the high-level).
Args:
instruction: the resource-validated instruction context object.
Returns:
the instruction context object actually appended. This is not required to be the same
as the object given (but typically will be).
"""

@abc.abstractmethod
def resolve_classical_resource(
self, specifier: Clbit | ClassicalRegister | int
) -> Clbit | ClassicalRegister:
"""Resolve a single bit-like classical-resource specifier.
A resource refers to either a classical bit or a register, where integers index into the
classical bits of the greater circuit.
This is called whenever a classical bit or register is being used outside the standard
:class:`.Clbit` usage of instructions in :meth:`append`, such as in a legacy two-tuple
condition.
Args:
specifier: the classical resource specifier.
Returns:
the resolved resource. This cannot be an integer any more; an integer input is resolved
into a classical bit.
Raises:
CircuitError: if the resource cannot be used by the scope, such as an out-of-range index
or a :class:`.Clbit` that isn't actually in the circuit.
"""

@abc.abstractmethod
def add_uninitialized_var(self, var: expr.Var):
"""Add an uninitialized variable to the circuit scope.
The general circuit context is responsible for ensuring the variable is initialized. These
uninitialized variables are guaranteed to be standalone.
Args:
var: the variable to add, if valid.
Raises:
CircuitError: if the variable cannot be added, such as because it invalidly shadows or
redefines an existing name.
"""

@abc.abstractmethod
def remove_var(self, var: expr.Var):
"""Remove a variable from the locals of this scope.
This is only called in the case that an exception occurred while initializing the variable,
and is not exposed to users.
Args:
var: the variable to remove. It can be assumed that this was already the subject of an
:meth:`add_uninitialized_var` call.
"""

@abc.abstractmethod
def use_var(self, var: expr.Var):
"""Called for every standalone classical runtime variable being used by some circuit
instruction.
The given variable is guaranteed to be a stand-alone variable; bit-like resource-wrapping
variables will have been filtered out and their resources given to
:meth:`resolve_classical_resource`.
Args:
var: the variable to validate.
Returns:
the same variable.
Raises:
CircuitError: if the variable is not valid for this scope.
"""

@abc.abstractmethod
def get_var(self, name: str) -> Optional[expr.Var]:
"""Get the variable (if any) in scope with the given name.
This should call up to the parent scope if in a control-flow builder scope, in case the
variable exists in an outer scope.
Args:
name: the name of the symbol to lookup.
Returns:
the variable if it is found, otherwise ``None``.
"""


class InstructionResources(typing.NamedTuple):
"""The quantum and classical resources used within a particular instruction.
Expand Down Expand Up @@ -169,7 +289,7 @@ def repeat(self, n):
raise CircuitError("Cannot repeat a placeholder instruction.")


class ControlFlowBuilderBlock:
class ControlFlowBuilderBlock(CircuitScopeInterface):
"""A lightweight scoped block for holding instructions within a control-flow builder context.
This class is designed only to be used by :obj:`.QuantumCircuit` as an internal context for
Expand Down Expand Up @@ -199,24 +319,26 @@ class ControlFlowBuilderBlock:
"""

__slots__ = (
"instructions",
"_instructions",
"qubits",
"clbits",
"registers",
"global_phase",
"_allow_jumps",
"_resource_requester",
"_parent",
"_built",
"_forbidden_message",
"_vars_local",
"_vars_capture",
)

def __init__(
self,
qubits: Iterable[Qubit],
clbits: Iterable[Clbit],
*,
parent: CircuitScopeInterface,
registers: Iterable[Register] = (),
resource_requester: Callable,
allow_jumps: bool = True,
forbidden_message: Optional[str] = None,
):
Expand All @@ -238,26 +360,22 @@ def __init__(
uses *exactly* the same set of resources. We cannot verify this from within the
builder interface (and it is too expensive to do when the ``for`` op is made), so we
fail safe, and require the user to use the more verbose, internal form.
resource_requester: A callback function that takes in some classical resource specifier,
and returns a concrete classical resource, if this scope is allowed to access that
resource. In almost all cases, this should be a resolver from the
:obj:`.QuantumCircuit` that this scope is contained in. See
:meth:`.QuantumCircuit._resolve_classical_resource` for the normal expected input
here, and the documentation of :obj:`.InstructionSet`, which uses this same
callback.
parent: The scope interface of the containing scope.
forbidden_message: If a string is given here, a :exc:`.CircuitError` will be raised on
any attempts to append instructions to the scope with this message. This is used by
pseudo scopes where the state machine of the builder scopes has changed into a
position where no instructions should be accepted, such as when inside a ``switch``
but outside any cases.
"""
self.instructions: List[CircuitInstruction] = []
self._instructions: List[CircuitInstruction] = []
self.qubits = set(qubits)
self.clbits = set(clbits)
self.registers = set(registers)
self.global_phase = 0.0
self._vars_local = {}
self._vars_capture = {}
self._allow_jumps = allow_jumps
self._resource_requester = resource_requester
self._parent = parent
self._built = False
self._forbidden_message = forbidden_message

Expand All @@ -275,9 +393,11 @@ def allow_jumps(self):
"""
return self._allow_jumps

@property
def instructions(self):
return self._instructions

def append(self, instruction: CircuitInstruction) -> CircuitInstruction:
"""Add an instruction into the scope, keeping track of the qubits and clbits that have been
used in total."""
if self._forbidden_message is not None:
raise CircuitError(self._forbidden_message)

Expand All @@ -293,50 +413,77 @@ def append(self, instruction: CircuitInstruction) -> CircuitInstruction:
" because it is not in a loop."
)

self.instructions.append(instruction)
self._instructions.append(instruction)
self.qubits.update(instruction.qubits)
self.clbits.update(instruction.clbits)
return instruction

def request_classical_resource(self, specifier):
"""Resolve a single classical resource specifier into a concrete resource, raising an error
if the specifier is invalid, and track it as now being used in scope.
Args:
specifier (Union[Clbit, ClassicalRegister, int]): a specifier of a classical resource
present in this circuit. An ``int`` will be resolved into a :obj:`.Clbit` using the
same conventions that measurement operations on this circuit use.
Returns:
Union[Clbit, ClassicalRegister]: the requested resource, resolved into a concrete
instance of :obj:`.Clbit` or :obj:`.ClassicalRegister`.
Raises:
CircuitError: if the resource is not present in this circuit, or if the integer index
passed is out-of-bounds.
"""
def resolve_classical_resource(self, specifier):
if self._built:
raise CircuitError("Cannot add resources after the scope has been built.")
# Allow the inner resolve to propagate exceptions.
resource = self._resource_requester(specifier)
resource = self._parent.resolve_classical_resource(specifier)
if isinstance(resource, Clbit):
self.add_bits((resource,))
else:
self.add_register(resource)
return resource

def add_uninitialized_var(self, var: expr.Var):
if self._built:
raise CircuitError("Cannot add resources after the scope has been built.")
# We can shadow a name if it was declared in an outer scope, but only if we haven't already
# captured it ourselves yet.
if (previous := self._vars_local.get(var.name)) is not None:
if previous == var:
raise CircuitError(f"'{var}' is already present in the scope")
raise CircuitError(f"cannot add '{var}' as its name shadows the existing '{previous}'")
if var.name in self._vars_capture:
raise CircuitError(f"cannot add '{var}' as its name shadows the existing '{previous}'")
self._vars_local[var.name] = var

def remove_var(self, var: expr.Var):
if self._built:
raise RuntimeError("exception handler 'remove_var' called after scope built")
self._vars_local.pop(var.name)

def get_var(self, name: str):
if (out := self._vars_local.get(name)) is not None:
return out
return self._parent.get_var(name)

def use_var(self, var: expr.Var):
if (local := self._vars_local.get(var.name)) is not None:
if local == var:
return
raise CircuitError(f"cannot use '{var}' which is shadowed by the local '{local}'")
if self._vars_capture.get(var.name) == var:
return
if self._parent.get_var(var.name) != var:
raise CircuitError(f"cannot close over '{var}', which is not in scope")
self._parent.use_var(var)
self._vars_capture[var.name] = var

def iter_local_vars(self):
"""Iterator over the variables currently declared in this scope."""
return self._vars_local.values()

def iter_captured_vars(self):
"""Iterator over the variables currently captured in this scope."""
return self._vars_capture.values()

def peek(self) -> CircuitInstruction:
"""Get the value of the most recent instruction tuple in this scope."""
if not self.instructions:
if not self._instructions:
raise CircuitError("This scope contains no instructions.")
return self.instructions[-1]
return self._instructions[-1]

def pop(self) -> CircuitInstruction:
"""Get the value of the most recent instruction in this scope, and remove it from this
object."""
if not self.instructions:
if not self._instructions:
raise CircuitError("This scope contains no instructions.")
return self.instructions.pop()
return self._instructions.pop()

def add_bits(self, bits: Iterable[Union[Qubit, Clbit]]):
"""Add extra bits to this scope that are not associated with any concrete instruction yet.
Expand Down Expand Up @@ -421,10 +568,18 @@ def build(
# We start off by only giving the QuantumCircuit the qubits we _know_ it will need, and add
# more later as needed.
out = QuantumCircuit(
list(self.qubits), list(self.clbits), *self.registers, global_phase=self.global_phase
list(self.qubits),
list(self.clbits),
*self.registers,
global_phase=self.global_phase,
captures=self._vars_capture.values(),
)
for var in self._vars_local.values():
# The requisite `Store` instruction to initialise the variable will have been appended
# into the instructions.
out.add_uninitialized_var(var)

for instruction in self.instructions:
for instruction in self._instructions:
if isinstance(instruction.operation, InstructionPlaceholder):
operation, resources = instruction.operation.concrete_instruction(
all_qubits, all_clbits
Expand Down Expand Up @@ -483,11 +638,14 @@ def copy(self) -> "ControlFlowBuilderBlock":
a semi-shallow copy of this object.
"""
out = type(self).__new__(type(self))
out.instructions = self.instructions.copy()
out._instructions = self._instructions.copy()
out.qubits = self.qubits.copy()
out.clbits = self.clbits.copy()
out.registers = self.registers.copy()
out.global_phase = self.global_phase
out._vars_local = self._vars_local.copy()
out._vars_capture = self._vars_capture.copy()
out._parent = self._parent
out._allow_jumps = self._allow_jumps
out._forbidden_message = self._forbidden_message
return out
Loading

0 comments on commit a79e879

Please sign in to comment.