-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add control-flow builder interface #7282
Conversation
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.
Pull Request Test Coverage Report for Build 1531202224
💛 - Coveralls |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider this just a first pass since there are some things here I'm not yet up to speed on. Specifically, I'm not sure I fully understand how the placeholder stuff works, or even what the semantics are of having additional instructions within a block following a break_loop
or continue_loop
. I would have expected that to be an error 😄.
Purely from an API perspective, I'm not sure I'm entirely sold on IfContext
returning an ElseContext
when entered. This seems somewhat unexpected to me on its own, but is a bit more uncomfortable IMO since it is expected to be used outside of the context that created it.
with qc.if_test(c) as else_:
...
with else_: # <-- uncomfortable
...
In most languages other than Python, I would expect else_
to be undefined here. In Python, I would expect accessing this object to be an error, since it would already be "closed", for some definition of closed.
As a user, I think I would be more comfortable with a DSL that worked like this:
with qc.if_test(c) as cond:
with cond.body:
...
with cond.else_body:
...
To improve readability, we could make the type of cond
implement iterable, so we could do decomposition, or just make it a tuple
or namedtuple
:
with qc.if_test(c) as (if_, else_):
with if_:
...
with else_:
...
Perhaps the context managers of if_
and else_
here could be the same (e.g. BasicBlockContext
) and the interesting stuff could all happen in the __exit__
of the root level IfElseContext
. I'm not sure if that might simplify some of this code, but if it could, it might be especially nice to consider.
If the consensus is that nesting context managers like this would be a pain for users, I might still recommend that context managers returned by QuantumCircuit
methods return a decomposable object when they're entered, since this would be more easily extensible, and feels less weird. I.e., the context manager returns some object that represents the IfContext
when entered, and that thing provides a means to defining an else block, perhaps as well as some other things.
with qc.if_test(c) as (else_,):
...
with else_:
...
For the main comment: I'd agree that in a sanely scoped language I'm not a huge fan of the "extra" context manager because it feels a bit more like a library convenience than a programmer one - it makes the circuit look as though you've entered two scopes to create an For the thing about having the return value of with qc.if_test(...) as else_:
...
with else_.if_test(...) as else_:
...
with else_:
... (the second with qc.if_test(...) as outer_else:
...
with outer_else:
with qc.if_test(...) as inner_else:
...
with inner_else:
... which is along a similar vein. I think that the regular |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall, this LGTM. I have a few design-related suggestions for the internal implementation, but these wouldn't change the interface as you have it, so should you choose to investigate them, I believe it'd be fine to merge this PR as is for the release, and then do a follow-up PR with any corresponding refactorings.
I'm not a huge fan of the "extra" context manager because it feels a bit more like a library convenience than a programmer one.
Good point. If this pattern is used elsewhere and not out of place for Python programmers, I agree that it's better we go for whatever is easiest to use.
Design thoughts
From a design perspective, the current implementation is very flexible, but there are a lot of moving parts. Specifically, the separation between scopes and contexts. Currently, it seems like a concerted effort between construct-specific context classes and QuantumCircuit
is necessary to manage scoping, which makes it difficult to understand control flow building without combing through a bunch of files. Correct me if I'm wrong, but it seems like there's a 1:1 relationship between entered contexts and scopes. I wonder if that means we can just use the scoping built into Python with
statements, rather than keeping our own scope stack around.
With that in mind, I wonder if a more monolithic approach could work. Just spit-balling, but perhaps such a thing would look like:
- A single re-entrant context class (not in the threading sense),
ControlFlowContext
, which would be our monolith, tracking control flow state and pending instructions. - To handle specific control flow operations,
ControlFlowContext
would use a state pattern, with state classes for each control flow instruction that share a common interface. This interface could define things like validations, how to determine the bits to use when built, what instruction to emit, whatControlFlowContext
should return when entered, etc. QuantumCircuit
would hold a single reference toControlFlowContext
(initialized with a specialNone
state) and return it on calls to control flow instructions, after setting the corresponding state class on it.- All instruction appends are routed through the
ControlFlowContext
, which calls the circuit append if in specialNone
state, else appends to current scope. - For an
if_test
, we'd still return theControlFlowContext
on entry. This would be the same handle you got by originally callingqc.if_test
. The difference is the context would have advanced to the "else" state by the time you enter it again.
I'm sure this is overly-simplistic and would need more thought, but the main idea would be to 1) get rid of explicit scope tracking and just use the program stack and 2) encapsulate control flow building logic and state management into a single class so it's easily understandable and can be ripped out if we move away from this API in the future.
For the main design comment: if you wanted to, you could avoid a stack in this design in the same way. When you're using these interfaces, I chose to make it an explicit stack rather than a single object to make it clearer how the data structure works - I thought having the "steal and replace" pattern like that would actually be less clear, and it'd be much harder to debug what state you program is in in a crash, because all the state would be tied up in a bunch of different objects in a linked-list type structure, rather than a clear stack. It was also so that I think perhaps the difference is that I considered I considered having |
Basically, I'm fine with your point 2, but I actively designed against your point 1 because I thought it'd make debugging and current-program-state inspection more difficult. We could swap to that form, but I'm not sure I see the benefit. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. I'll add a follow-up comment shortly to continue our discussion 😄.
Yep, I think that's right. I agree that it could be helpful for debugging to keep track of the current context hierarchy as well. Thinking about it a bit more, I was more interested in envisioning a way we could manipulate scope tracking from a single place, which is more in line with point 2.
I think this is what I would naturally expect as well with Sticking with the current design, each context class directly manipulates the scope stack, and needs to remember to call
I was envisioning handling this by using a state pattern, which (at least in my head) wouldn't add duplication. Maybe something like this from the perspective of class QuantumCircuit:
def __init__():
...
self.flow_context = FlowContext(append_to=self.data)
...
def if_test():
...
if true_body is None:
# no need to specify anything about scoping here, i.e. in_loop,
# since this would be managed internally by `flow_context`.
self.flow_context.set_state(IfState(condition, label=label))
return self.flow_context
Quite possibly! I'm really not sure. It's easy for me to comment from a safe distance on these things, but I'd need to put code to file to actually see what holds water. I'm making these suggestions only to give you things to consider from your vantage point as the implementer. The current design is fine with me if there don't seem to be any obvious benefits to changing it to you! |
This branch contains the fix that this test depended on.
I think these are all good points - I also wasn't entirely satisfied with how much "friend" access I'd gave the context managers over I also was wary of messing too much with I think we should keep talking about this, after the release as well, with a view to potentially changing some of the stuff to match what you're saying. I'd certainly be in favour of removing the friend access from the contexts, and if we can do the state machine in a neat way while still allowing convenient debugging and runtime inspection of the stack, I think I'd be in favour of it - it feels like the correct direction for managing control flow. The duplication I had in mind was that I thought your monolith would basically end up with a |
Oh yeah, about the potential for a base class to handle body = block.build(qubits, clbits)
circuit.append(body, qubits, clbits) |
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.
1287a04
to
1513b3b
Compare
Ah yeah, I suppose that would be necessary. I don't like constructing extra objects either, but it is nice in that we keep control-flow-based decisions out of
That's fair. I didn't have anything concrete in mind, but I was curious if there might be some way to pull context enter/exit stuff (i.e. scope stack manipulation, building, and instruction appending) up into a base class and then have data-oriented abstract methods for concrete classes to implement. It sounds like a clean separation might not exist, so let's just put a pin in it in case we come back to investigate the refactoring stuff from earlier in this thread. Also, yay! Merged! :D |
Summary
This adds a builder interface for control-flow operations on
QuantumCircuit
(such asForLoopOp
,IfElseOp
, andWhileLoopOp
).The interface uses the same circuit methods, but they are now overloaded
so that if the
body
parameter is not given, they return a contextmanager. 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:
This will produce a
ForLoopOp
onqc
, which knows that qubit 0 is theonly resource used within the loop body. These context managers can be
nested, and will correctly determine their widths. You can use
break_loop
andcontinue_loop
within a context, and it will expand tobe 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, ifdesired, can be used to create an
else
block, such as byThe manager will ensure that the
if
andelse
bodies are defined overthe same set of resources (the user does not need to do this).
This commit also ensures that instances of
ParameterExpression
addedto a circuit inside all control flow instructions will correctly
propagate up to the top-level circuit.
Details and comments
The length of this PR is a little misleading. Of the nearly 4000 modifications, 2326 of them are adding new tests (these new tests take about ~1 sec to run in total), and much of the rest is comments and documentation. The actual implementation is about 600 logical lines of code.
The most important new objects added are:
ControlFlowBuilderBlock
, inqiskit/circuit/controlflow/builder.py
, which is a lightweight scope manager, for tracking instructions and resources used inside a classical scope.ForLoopContext
,WhileLoopContext
,IfContext
andElseContext
, each in the same file as their associatedOp
version from Add Instruction subclasses for control flow. #7123. These are public in the sense that users will use the instances, but users should not instantiate them, nor need to call any of their methods.QuantumCircuit.if_test
,.for_loop
and.while_loop
(but not.if_else
) are overloaded so that if a body is not supplied, they return the context managers instead (ElseContext
is only created when anIfContext
object is entered).QuantumCircuit
gained a private_control_flow_scopes
per-instance stack (list
), which is managed by the context managers, as C++-ish "friends" ofQuantumCircuit
.*The builder interface roughly works as follows:
QuantumCircuit.append
is updated to tail-call eitherQuantumCircuit._append
orControlFlowBuilderBlock.append
as appropriate for the stack state.ControlFlowBuilderBlock
) tracks resources that are used by circuit methods (includingQuantumCircuit.append
) orInstructionSet.c_if
(using the callback mechanism introduced in Fix resource resolution inInstructionSet.c_if
#7255).QuantumCircuit
instance, using all the qubits and clbits that were used during the block. The context manager then creates theForLoopOp
(or whatever), and appends it to the circuit. It should only block the resources that the block actually uses, and no more.break_loop
,continue_loop
, and anyif_test
blocks inside loops have unknown widths at the time they are appended. This is handled by a lightweight, privateInstructionPlaceholder
class which originally claims to use zero qubits and clbits. OnControlFlowBuilderBlock.build
, these placeholder instructions are built into concrete versions, since at this point the width is known.Further, the "else" context manager extends the "if" statement from a previous manager:
IfContext
manager returns anElseContext
on entry, so the convention istrue_body
and the newfalse_body
circuits have the same widths, taking care that the order of the bits is the same in both.The context managers all check the exception status on exit, and make the circuit safe by popping off their incomplete states if the contexts are left via exception. The "else" manager re-instates the previous "if" instruction in this case, and resets itself so the user can attempt to re-enter it if required.
*: If absolutely desired, I can add a layer of indirection so that they are provided an "API" object which is populated by
QuantumCircuit
, to ensure that they can only perform the actions they need to. I thought this may make the code harder to read for now, though.**: I elected not to put in logic to produce a concrete instance if they don't contain placeholders.
IfContext
could never return a concrete instance anyway (because on exit, it doesn't know what will happen in a possibleElseContext
after it), and I don't think there's any benefit to this; it adds more complexity for no performance gain.This also fixes #7280 - I found that issue while writing a test for the new interfaces, so I fixed it as part of this PR by making
QuantumCircuit._update_parameter_table
andQuantumCircuit._assign_parameters
handle parameters that are themselves circuits. I didn't add a release note for this bugfix, because the bug was never released.