Unify Python custom instruction kinds#15652
Conversation
In Qiskitgh-15277[^1] we unified the internal storage of the three variants of Python operation in a single `PyOperationTypes` enum, where each variant wraps the same `PyInstruction` type. We didn't change the handling within the `OperationRef` view object, to avoid modifying how all the downstream matching would happen. As we move towards removing the Python structures from `qiskit-circuit` entirely, we will replace the field with a custom operation. We will have to decide how much of a type-level split between different objects we want to maintain, and the "custom instruction" code will all have down some form of dynamic-typing path. As a stepping stone towards that, this commit moves the `PyOperationTypes` enum discriminant into `PyInstruction` itself, which has the knock-on effect of requiring `OperationRef` to reflect the same backing structure. This was mentioned as a possibility in the original PR, but not attempted at the time due to worries of less ergonomic matching. In practice, the matching is actually easier overall in this form because of the significant de-deduplication in most cases; in many cases, the handling of Python-space objects is independent of their types. With the `kind` field now inside `PyInstruction`, all the methods of `PyInstruction` like `matrix`, which were _already_ fallible can now simply handle the `Gate`/`Instruction`/`Operation` split themselves, so there's no risk of a forgotten match causing trouble. The legacy three-fold `Gate`/`Instruction`/`Operation` split is already a relic of Python space that Rust _mostly_ shouldn't need to care about, particularly the `Operation` element of it. It may well make sense in future custom gates to have a way of checking for "unitary" vs "non-unitary" without requesting the matrix, but this is compatible with `PyInstruction` being the single implementation of a "custom" gate from Rust's perspective to wrap all Python objects. [^1]: 7deafbf: Combine Python types in PackedInstructions
|
One or more of the following people are relevant to this code:
|
gadial
left a comment
There was a problem hiding this comment.
Overall looks good to me. Left some comments - there might be some small fixes required.
| OperationRef::Unitary(unitary) => unitary.clone().into(), | ||
| OperationRef::PauliProductMeasurement(ppm) => ppm.clone().into(), | ||
| } | ||
| self.instruction.operation.py_deepcopy(py, None)? |
There was a problem hiding this comment.
Not sure I understand why we can now ignore OperationRef::ControlFlow etc.
There was a problem hiding this comment.
We aren't ignoring them, we're just delegating the match to the PackedOperation method that does the same thing - it's just a de-duplication thing.
| let definition = match self.instruction.operation.view() { | ||
| OperationRef::StandardGate(g) => g.definition(self.instruction.params_view()), | ||
| OperationRef::Gate(g) => g.definition(), | ||
| OperationRef::Instruction(i) => i.definition(), |
There was a problem hiding this comment.
Previously Operation was caught by the catch-all? So now the behavior was changed?
There was a problem hiding this comment.
This is one of the core changes and arguments for/against unifying into PyCustom rather than three separate kinds that everywhere has to know what the semantics are. Now, because all these methods (definition in this case) were fallible already, even for Gate and Instruction which "supported" them, there's just a catch within PyCustom::definition that knows to return None if the kind is an operation without performing an invalid Python call, so call sites don't have to have that baked-in knowledge of the Python semantics.
| fn try_matrix(&self) -> Option<Array2<Complex64>> { | ||
| match self.op() { | ||
| OperationRef::StandardGate(g) => g.matrix(self.params_view()), | ||
| OperationRef::Gate(g) => g.matrix(), |
There was a problem hiding this comment.
Also here, Operation and Instruction are treated separately?
There was a problem hiding this comment.
Same as before, but matrix is perhaps more contentious than definition. There's an argument that Gate and Instruction could/should be separate types in the custom-gates system that Ray's working on (but Operation almost certainly shouldn't), and this PR was part of an exploration of that.
Personally, I think I'm currently in the camp that the "unitary" marker needn't be a type-level marker (i.e. no need to separate Gate and Instruction at the type level for custom gates) - I think there's value in it being dynamically decided by a class, but I could be convinced.
| Ok(Some(Self::Instruction)) | ||
| } else { | ||
| ob.is_subclass(imports::OPERATION.get_bound(py)) | ||
| .map(|ok| ok.then_some(Self::Operation)) |
There was a problem hiding this comment.
Seems we don't return informative error if we fail?
There was a problem hiding this comment.
Returning Ok(None) is (imo) completely informative from a programmatic perspective - it's not an error to call this function with a Python type object that isn't in the Operation hierarchy, it just doesn't correspond to a PyOpKind.
| /// returns 2^num_ctrl_bits-1 (the '11...1' state) if the gate has not control state data | ||
| pub fn ctrl_state(&self) -> u32 { | ||
| if self.kind != PyOpKind::Gate { | ||
| return 0; |
There was a problem hiding this comment.
I don't think we should return 0 here. Note the comment: returns 2^num_ctrl_bits-1 (the '11...1' state) if the gate has not control state data
There was a problem hiding this comment.
If num_ctrl_bits is 0 (which it would be if catch triggers), then
Arguably this method (and num_ctrl_bits) are completely ill defined for self.kind != PyOpKind::Gate, but that was pre-existing.
There was a problem hiding this comment.
Right... But I'd still feel more comfortable is the returned result relied on num_ctrl_qubits explicitly, due to my fear of the unknown tomorrow where we decide to change one and forget the other.
| PyValueError::new_err(format!("Could not find operation data for {}", name)) | ||
| })?; | ||
| let OperationRef::PyCustom(inst) = operation.view() else { | ||
| panic!( |
There was a problem hiding this comment.
I think our goal is to avoid panics inside QPY code? Or is it relevant only for reader, not writer?
There was a problem hiding this comment.
It's 100% true for the reader (and actually I need to talk to you about that in general - there are loads of panics currently possible in the Rust QPY, mostly via expect and unwrap calls), but this case is a panic because it corresponds purely to an internal logic error earlier in the code that can't be triggered just because of untrusted input. This function must only be called (by ourselves) with keys that correspond to PyCustom instructions - if we do something other than that, there's an error in our own QPY code that's completely impossible to recover from.
The previous version of this function didn't panic, but it silently let the invalid call pass, which is going to at best produce an invalid QPY payload that fails to deserialise (so we can spot the problem) but at worst produce a valid QPY payload that fails to roundtrip the circuit correctly.
I was trying to avoid making larger-scale changes here, but really this should be handled at the type-system level in the arguments passed to this function.
jakelishman
left a comment
There was a problem hiding this comment.
Thanks for the review Gadi. This PR is an "on-hold" PR though, because it's based on #15649, which should be reviewed and merged separately and before this one.
| OperationRef::Unitary(unitary) => unitary.clone().into(), | ||
| OperationRef::PauliProductMeasurement(ppm) => ppm.clone().into(), | ||
| } | ||
| self.instruction.operation.py_deepcopy(py, None)? |
There was a problem hiding this comment.
We aren't ignoring them, we're just delegating the match to the PackedOperation method that does the same thing - it's just a de-duplication thing.
| let definition = match self.instruction.operation.view() { | ||
| OperationRef::StandardGate(g) => g.definition(self.instruction.params_view()), | ||
| OperationRef::Gate(g) => g.definition(), | ||
| OperationRef::Instruction(i) => i.definition(), |
There was a problem hiding this comment.
This is one of the core changes and arguments for/against unifying into PyCustom rather than three separate kinds that everywhere has to know what the semantics are. Now, because all these methods (definition in this case) were fallible already, even for Gate and Instruction which "supported" them, there's just a catch within PyCustom::definition that knows to return None if the kind is an operation without performing an invalid Python call, so call sites don't have to have that baked-in knowledge of the Python semantics.
| fn try_matrix(&self) -> Option<Array2<Complex64>> { | ||
| match self.op() { | ||
| OperationRef::StandardGate(g) => g.matrix(self.params_view()), | ||
| OperationRef::Gate(g) => g.matrix(), |
There was a problem hiding this comment.
Same as before, but matrix is perhaps more contentious than definition. There's an argument that Gate and Instruction could/should be separate types in the custom-gates system that Ray's working on (but Operation almost certainly shouldn't), and this PR was part of an exploration of that.
Personally, I think I'm currently in the camp that the "unitary" marker needn't be a type-level marker (i.e. no need to separate Gate and Instruction at the type level for custom gates) - I think there's value in it being dynamically decided by a class, but I could be convinced.
| Ok(Some(Self::Instruction)) | ||
| } else { | ||
| ob.is_subclass(imports::OPERATION.get_bound(py)) | ||
| .map(|ok| ok.then_some(Self::Operation)) |
There was a problem hiding this comment.
Returning Ok(None) is (imo) completely informative from a programmatic perspective - it's not an error to call this function with a Python type object that isn't in the Operation hierarchy, it just doesn't correspond to a PyOpKind.
| /// returns 2^num_ctrl_bits-1 (the '11...1' state) if the gate has not control state data | ||
| pub fn ctrl_state(&self) -> u32 { | ||
| if self.kind != PyOpKind::Gate { | ||
| return 0; |
There was a problem hiding this comment.
If num_ctrl_bits is 0 (which it would be if catch triggers), then
Arguably this method (and num_ctrl_bits) are completely ill defined for self.kind != PyOpKind::Gate, but that was pre-existing.
| PyValueError::new_err(format!("Could not find operation data for {}", name)) | ||
| })?; | ||
| let OperationRef::PyCustom(inst) = operation.view() else { | ||
| panic!( |
There was a problem hiding this comment.
It's 100% true for the reader (and actually I need to talk to you about that in general - there are loads of panics currently possible in the Rust QPY, mostly via expect and unwrap calls), but this case is a panic because it corresponds purely to an internal logic error earlier in the code that can't be triggered just because of untrusted input. This function must only be called (by ourselves) with keys that correspond to PyCustom instructions - if we do something other than that, there's an error in our own QPY code that's completely impossible to recover from.
The previous version of this function didn't panic, but it silently let the invalid call pass, which is going to at best produce an invalid QPY payload that fails to deserialise (so we can spot the problem) but at worst produce a valid QPY payload that fails to roundtrip the circuit correctly.
I was trying to avoid making larger-scale changes here, but really this should be handled at the type-system level in the arguments passed to this function.
alexanderivrii
left a comment
There was a problem hiding this comment.
I took an (honestly rather quick) look at the PR. The idea to combine Py {Gate, Instruction, Operation} into a single PyCustom makes sense to me. Ergonomically-wise, whenever we need to do the same thing in each of the 3 cases, the PR makes the code shorter by removing deduplication. On the other hand, whenever we need a specific variant (usually PyGate), the match statements become a bit longer and harder to read. Creating a Python object from within Rust space did not change much (except for specifying the op kind of the operation). Overall, I am in favor of this, provided that it helps Ray's thread of work.
One general question: how do you see ControlledGate/AnnotatedOperation in Rust in the future?
| pub fn num_ctrl_qubits(&self) -> Option<u32> { | ||
| if self.kind != PyOpKind::Gate { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Completely unrelated to this PR: why do we need the methods num_ctrl_qubits and ctrl_state? This seems to bring up closer to ControlledGate, which I don't think we want in the Rust space.
| // The `definition` attribute isn't part of the `Operation` interface, so it's invalid for | ||
| // us to access it. | ||
| if self.kind == PyOpKind::Operation { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Hmm, while I mostly agree with the comment above, we have not really prevented from including custom definitions for Operations, so I am wondering if this might break expectations.
| let gate_ob = match inst.op.view() { | ||
| OperationRef::PyCustom(PyInstruction { | ||
| kind: PyOpKind::Gate, | ||
| ob, | ||
| .. | ||
| }) => ob, |
There was a problem hiding this comment.
This is somewhat less ergonomic than it was before.
Summary
In gh-152771 we unified the internal storage of the three variants of Python operation in a single
PyOperationTypesenum, where each variant wraps the samePyInstructiontype. We didn't change the handling within theOperationRefview object, to avoid modifying how all the downstream matching would happen.As we move towards removing the Python structures from
qiskit-circuitentirely, we will replace the field with a custom operation. We will have to decide how much of a type-level split between different objects we want to maintain, and the "custom instruction" code will all have down some form of dynamic-typing path.As a stepping stone towards that, this commit moves the
PyOperationTypesenum discriminant intoPyInstructionitself, which has the knock-on effect of requiringOperationRefto reflect the same backing structure. This was mentioned as a possibility in the original PR, but not attempted at the time due to worries of less ergonomic matching.In practice, the matching is actually easier overall in this form because of the significant de-deduplication in most cases; in many cases, the handling of Python-space objects is independent of their types. With the
kindfield now insidePyInstruction, all the methods ofPyInstructionlikematrix, which were already fallible can now simply handle theGate/Instruction/Operationsplit themselves, so there's no risk of a forgotten match causing trouble.The legacy three-fold
Gate/Instruction/Operationsplit is already a relic of Python space that Rust mostly shouldn't need to care about, particularly theOperationelement of it. It may well make sense in future custom gates to have a way of checking for "unitary" vs "non-unitary" without requesting the matrix, but this is compatible withPyInstructionbeing the single implementation of a "custom" gate from Rust's perspective to wrap all Python objects.Details and comments
This includes a roll-up of #15649 right now, because I found that failure while making this PR.
This PR can also be thought of as preliminary work to Ray's work on making it possible to define custom instructions from Rust. We don't have to merge this, but it's worth considering from an API perspective about how we'll want to present a "unitary"/"non-unitary" split in Rust space for matching.
The diff is a bit inflated because of substantial changes to
assign_parametersto make thematchergonomics more reliable, and to correctly handleStandardInstruction::Delaywithout using the general-purpose Python-dependent path. I could separate that change out into a preceding PR, if preferred.Footnotes
7deafbf: Combine Python types in PackedInstructions ↩