-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Port classical expressions (Expr
) to Rust.
#14068
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
Conversation
Pull Request Test Coverage Report for Build 14601151850Details
💛 - Coveralls |
One or more of the following people are relevant to this code:
|
Cargo.toml
Outdated
@@ -30,6 +30,7 @@ approx = "0.5" | |||
itertools = "0.13.0" | |||
ahash = "0.8.11" | |||
rayon = "1.10" | |||
uuid = { version = "1.16", features = ["v4"] } |
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.
Should we add the fast-rng
feature too? Iirc it depends on rand which we're already using so there shouldn't be a huge compile time impact
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.
I've done a quick passthrough over the code here, it looks promising. I have a couple of questions regarding some methods and docstrings.
pub enum BinaryOp { | ||
BitAnd = 1, | ||
BitOr = 2, | ||
BitXor = 3, | ||
LogicAnd = 4, | ||
LogicOr = 5, | ||
Equal = 6, | ||
NotEqual = 7, | ||
Less = 8, | ||
LessEqual = 9, | ||
Greater = 10, | ||
GreaterEqual = 11, | ||
ShiftLeft = 12, | ||
ShiftRight = 13, | ||
Add = 14, | ||
Sub = 15, | ||
Mul = 16, | ||
Div = 17, | ||
} |
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.
Is there a reason why we're specifying the discriminants for this enumeration? I'm assuming you're doing this to avoid having a 0
case representing a default which syntactically shouldn't make sense.
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.
Good question. The reason is that the exact values are part of our QPY serialization format. While it isn't this enum that gets serialized directly (it's the Binary.Op
Python-side sister enum), I'm using bytemuck
to convert between this enum and the Python one, so these need to have the same values.
/// they are public in the sister Python enum `_BinaryOp` in `expr.py` | ||
/// and used in our QPY serialization format. | ||
/// | ||
/// WARNING: If you add more, **be sure to update expr.py**, too. |
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.
Maybe add that the CheckedBitPattern
trait implementation also needs to be modified.
#[new] | ||
#[pyo3(text_signature = "(target, index, type)")] | ||
fn new(py: Python, target: Expr, index: Expr, ty: Type) -> PyResult<Py<Self>> { | ||
let constant = target.is_const() && index.is_const(); |
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.
I noticed that we're saving this boolean a lot when it seems to just be obtained by the method is_const
of the underlying Expr
being saved. Could we instead compute these in-place by just creating an is_const
method for the Expr
in question here or is it easier to just store this attribute?
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.
That's a good observation. We could recursively call is_const
on any child Expr
s from the implementation of is_const
itself rather than doing this in the constructor, but I didn't like the idea of is_const
being more expensive for larger expression trees. By pre-computing it in the constructor, we avoid this.
#[new] | ||
fn new0(var: &Bound<PyAny>, name: String) -> PyResult<Py<Self>> { | ||
Py::new( | ||
var.py(), | ||
( | ||
PyStretch(Stretch { | ||
uuid: var.getattr(intern!(var.py(), "int"))?.extract()?, | ||
name, | ||
}), | ||
PyExpr(ExprKind::Stretch), | ||
), | ||
) | ||
} | ||
|
||
/// Generate a new named stretch variable. | ||
#[classmethod] | ||
fn new(cls: &Bound<'_, pyo3::types::PyType>, name: String) -> PyResult<Py<Self>> { | ||
Py::new( | ||
cls.py(), | ||
( | ||
PyStretch(Stretch { | ||
uuid: Uuid::new_v4().as_u128(), | ||
name, | ||
}), | ||
PyExpr(ExprKind::Stretch), | ||
), | ||
) | ||
} |
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.
About these two constructors for stretch
. What is the first one used for? And also, since both of them seem to do a lot of the same construction (with the only difference being how they extract their respective uuid's. Should we maybe separate the construction into a different method? Or is too much for such little difference.
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.
The first one is actually just the Python "new", e.g. what gets invoked with Python code like: Stretch(uuid.uuid4(), "a")
. The second one is for use like: Stretch.new("a")
. I probably should have named new0
as py_new
instead, since we have the pattern elsewhere.
Regarding the duplication, I'm not sure it'd be helpful to abstract this. It's all pretty declarative without much logic, so IMO we'd be making things more obscure.
/// they are public in the sister Python enum `_UnaryOp` in `expr.py` | ||
/// and used in our QPY serialization format. | ||
/// | ||
/// WARNING: If you add more, **be sure to update expr.py**, too. |
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.
... and the CheckedBitPattern
trait :)
fn new0(var: &Bound<PyAny>, ty: Type, name: Option<String>) -> PyResult<Py<Self>> { | ||
let v = if let Some(name) = name { | ||
Var::Standalone { | ||
uuid: var.getattr(intern!(var.py(), "int"))?.extract()?, | ||
name, | ||
ty, | ||
} | ||
} else if let Ok(register) = var.extract::<ClassicalRegister>() { | ||
Var::Register { register, ty } | ||
} else { | ||
Var::Bit { | ||
bit: var.extract()?, | ||
} | ||
}; | ||
Py::new(var.py(), (PyVar(v), PyExpr(ExprKind::Var))) | ||
} | ||
|
||
/// Generate a new named variable that owns its own backing storage. | ||
#[classmethod] | ||
fn new(cls: &Bound<'_, pyo3::types::PyType>, name: String, ty: Type) -> PyResult<Py<Self>> { | ||
Py::new( | ||
cls.py(), | ||
( | ||
PyVar(Var::Standalone { | ||
uuid: Uuid::new_v4().as_u128(), | ||
name, | ||
ty, | ||
}), | ||
PyExpr(ExprKind::Var), | ||
), | ||
) | ||
} |
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.
Seeing that these two constructors refer to generating vars of different styles (one being new style while the other two refer to older styles). Are there plans for removing the old style in the future, or are the new style vars just an additional use case?
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.
I believe the old style is here to stay, at least for the foreseeable future (also, note that these docstrings I've simply moved from the Python code to their place here).
The "old-style" is pretty much just Var
s backed by classical registers and classical bits. The "new-style" ones use the Store
instruction to assign values to named variables (which we use UUIDs to track uniquely).
class _Singleton(type): | ||
"""Metaclass to make the child, which should take zero initialization arguments, a singleton | ||
object.""" | ||
|
||
def _get_singleton_instance(cls): | ||
return cls._INSTANCE | ||
|
||
@classmethod | ||
def __prepare__(mcs, name, bases): # pylint: disable=unused-argument | ||
return {"__new__": mcs._get_singleton_instance} | ||
|
||
@staticmethod | ||
def __new__(cls, name, bases, namespace): | ||
out = super().__new__(cls, name, bases, namespace) | ||
out._INSTANCE = object.__new__(out) # pylint: disable=invalid-name | ||
return out |
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.
I'm assuming we don't use this anymore because of how we've implemented Type
from Rust. But I imagined this was used to discriminate against some types. Knowing that the other types are "stateless" the only one that wasn't singleton was/is Unit
as it contains a u16
.
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.
I know there's a test somewhere that asserts that the Bool
type is a singleton. But, our code doesn't actually rely on this anywhere from what I could tell.
...but, all of the types that were singletons in Python I've actually made to be singletons in Rust as well. You'll notice I'm caching these instances at the top of types.rs
🙂.
static BOOL_TYPE: GILOnceCell<Py<PyBool>> = GILOnceCell::new();
static DURATION_TYPE: GILOnceCell<Py<PyDuration>> = GILOnceCell::new();
static FLOAT_TYPE: GILOnceCell<Py<PyFloat>> = GILOnceCell::new();
I thought about also caching common Uint
types like Uint(8)
or Uint(16)
, or having a lazily populated table of cached Uint
s of various sizes, but decided it'd be overkill (esp. since we don't have that today).
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.
This looks about ready to merge. I just have one comment on the naming convention based on something you mentioned before.
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!
Summary
Ports the
Expr
andType
class hierarchies to Rust.Details and comments
I've done my best here to create a Rust-first interface that is also fully interoperable with the existing Python API, even though the Python type hierarchies use inheritance and polymorphism for node visitation etc.
The Rust code is organized into a new
classical
mod with two submodule,expr
andtype
. For both of these, I've defined pure Rust types that are entirely separate from theirpyclass
counterparts. For example, theExpr
enum represents an expression in Rust, while a separatePyExpr
struct is defined as apyclass
, which wraps the Rust enum. This is necessary for an ergonomic Rust API, since PyO3 has limitations with using complex enums like this one as apyclass
. I followed this pattern for all ported classes for consistency.The primary Rust types introduced here are the enums
Expr
andType
:expr::Expr
enumRepresentation of an expression in native Rust.
Variants which themselves contain
Expr
s are wrapped in aBox
. For example, theBinary
expression contains two expressions, aleft
and aright
:Rather than wrapping both
left
andright
in their ownBox
s, theExpr::Binary
variant wrapsBinary
. This cutsBox
usage in half.Binary.Op
andUnary.Op
Since these inner class enums are part of the public Python API, I had to do some odd stuff to ensure they still behave exactly like Python enums. This is because PyO3 enums do not support the same interface as Python enums (they don't use the
EnumType
metaclass like Python enums do), e.g., they are not iterable.The trick I've done is to define a parallel set of private enums in Python that get returned when accessing the
Op
class attributes ofBinary
andUnary
. WhenOp
is accessed, it loads the Python-defined enum viaimports::{BINARY_OP, UNARY_OP}
. Further, these are wrapped in Python descriptors to avoid a cyclic dependency.types::Type
enumRepresentation of an expression's type in Rust.
To-do
Expr
.Type
.Expr
andType
(i.e. wire things up).Use new native Rust types in=> deferred to separate PRCircuitData
andDAGCircuit
.