Skip to content

Commit fb70814

Browse files
committed
Add infrastructure for gates, instruction, and operations in Rust
This commit adds a native representation of Gates, Instruction, and Operations to rust's circuit module. At a high level this works by either wrapping the Python object in a rust wrapper struct that tracks metadata about the operations (name, num_qubits, etc) and then for other details it calls back to Python to get dynamic details like the definition, matrix, etc. For standard library gates like Swap, CX, H, etc this replaces the on-circuit representation with a new rust enum StandardGate. The enum representation is much more efficient and has a minimal memory footprint (just the enum variant and then any parameters or other mutable state stored in the circuit instruction). All the gate properties such as the matrix, definiton, name, etc are statically defined in rust code based on the enum variant (which represents the gate). The use of an enum to represent standard gates does mean a change in what we store on a CircuitInstruction. To represent a standard gate fully we need to store the mutable properties of the existing Gate class on the circuit instruction as the gate by itself doesn't contain this detail. That means, the parameters, label, unit, duration, and condition are added to the rust side of circuit instrucion. However no Python side access methods are added for these as they're internal only to the Rust code. In Qiskit 2.0 to simplify this storage we'll be able to drop, unit, duration, and condition from the api leaving only label and parameters. But for right now we're tracking all of the fields. To facilitate working with circuits and gates full from rust the setting the `operation` attribute of a `CircuitInstruction` object now transltates the python object to an internal rust representation. For standard gates this translates it to the enum form described earlier, and for other circuit operations 3 new Rust structs: PyGate, PyInstruction, and PyOperation are used to wrap the underlying Python object in a Rust api. These structs cache some commonly accessed static properties of the operation, such as the name, number of qubits, etc. However for dynamic pieces, such as the definition or matrix, callback to python to get a rust representation for those. Similarly whenever the `operation` attribute is accessed from Python it converts it back to the normal Python object representation. For standard gates this involves creating a new instance of a Python object based on it's internal rust representation. For the wrapper structs a reference to the wrapped PyObject is returned. To manage the 4 variants of operation (`StandardGate`, `PyGate`, `PyInstruction`, and `PyOperation`) a new Rust trait `Operation` is created that defines a standard interface for getting the properties of a given circuit operation. This common interface is implemented for the 4 variants as well as the `OperationType` enum which wraps all 4 (and is used as the type for `CircuitInstruction.operation` in the rust code. As everything in the `QuantumCircuit` data model is quite coupled moving the source of truth for the operations to exist in Rust means that more of the underlying `QuantumCircuit`'s responsibility has to move to Rust as well. Primarily this involves the `ParameterTable` which was an internal class for tracking which instructions in the circuit have a `ParameterExpression` parameter so that when we go to bind parameters we can lookup which operations need to be updated with the bind value. Since the representation of those instructions now lives in Rust and Python only recieves a ephemeral copy of the instructions the ParameterTable had to be reimplemented in Rust to track the instructions. This new parameter table maps the Parameter's uuid (as a u128) as a unique identifier for each parameter and maps this to a positional index in the circuit data to the underlying instruction using that parameter. This is a bit different from the Python parameter table which was mapping a parameter object to the id of the operation object using that parmaeter. This also leads to a difference in the binding mechanics as the parameter assignment was done by reference in the old model, but now we need to update the entire instruction more explicitly in rust. Additionally, because the global phase of a circuit can be parameterized the ownership of global phase is moved from Python into Rust in this commit as well. After this commit the only properties of a circuit that are not defined in Rust for the source of truth are the bits (and vars) of the circuit, and when creating circuits from rust this is what causes a Python interaction to still be required. This commit does not translate the full standard library of gates as that would make the pull request huge, instead this adds the basic infrastructure for having a more efficient standard gate representation on circuits. There will be follow up pull requests to add the missing gates and round out support in rust. The goal of this pull request is primarily to add the infrastructure for representing the full circuit model (and dag model in the future) in rust. By itself this is not expected to improve runtime performance (if anything it will probably hurt performance because of extra type conversions) but it is intended to enable writing native circuit manipulations in Rust, including transpiler passes without needing involvement from Python. Longer term this should greatly improve the runtime performance and reduce the memory overhead of Qiskit. But, this is just an early step towards that goal, and is more about unlocking the future capability. The next steps after this commit are to finish migrating the standard gate library and also update the `QuantumCircuit` methods to better leverage the more complete rust representation (which should help offset the performance penalty introduced by this). Fixes: Qiskit#12205
1 parent 581f247 commit fb70814

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2952
-602
lines changed

Cargo.lock

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ license = "Apache-2.0"
1616
[workspace.dependencies]
1717
indexmap.version = "2.2.6"
1818
hashbrown.version = "0.14.0"
19+
num-complex = "0.4"
20+
ndarray = "^0.15.6"
21+
numpy = "0.21.0"
22+
smallvec = "1.13"
23+
1924
# Most of the crates don't need the feature `extension-module`, since only `qiskit-pyext` builds an
2025
# actual C extension (the feature disables linking in `libpython`, which is forbidden in Python
2126
# distributions). We only activate that feature when building the C extension module; we still need

crates/accelerate/Cargo.toml

+4-4
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,29 @@ doctest = false
1111

1212
[dependencies]
1313
rayon = "1.10"
14-
numpy = "0.21.0"
14+
numpy.workspace = true
1515
rand = "0.8"
1616
rand_pcg = "0.3"
1717
rand_distr = "0.4.3"
1818
ahash = "0.8.11"
1919
num-traits = "0.2"
20-
num-complex = "0.4"
20+
num-complex.workspace = true
2121
num-bigint = "0.4"
2222
rustworkx-core = "0.14"
2323
faer = "0.18.2"
2424
itertools = "0.12.1"
2525
qiskit-circuit.workspace = true
2626

2727
[dependencies.smallvec]
28-
version = "1.13"
28+
workspace = true
2929
features = ["union"]
3030

3131
[dependencies.pyo3]
3232
workspace = true
3333
features = ["hashbrown", "indexmap", "num-complex", "num-bigint", "smallvec"]
3434

3535
[dependencies.ndarray]
36-
version = "^0.15.6"
36+
workspace = true
3737
features = ["rayon", "approx-0_5"]
3838

3939
[dependencies.approx]

crates/circuit/Cargo.toml

+12-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,15 @@ doctest = false
1111

1212
[dependencies]
1313
hashbrown.workspace = true
14-
pyo3.workspace = true
14+
num-complex.workspace = true
15+
ndarray.workspace = true
16+
numpy.workspace = true
17+
lazy_static = "1.4"
18+
19+
[dependencies.pyo3]
20+
workspace = true
21+
features = ["hashbrown", "indexmap", "num-complex", "num-bigint", "smallvec"]
22+
23+
[dependencies.smallvec]
24+
workspace = true
25+
features = ["union"]

crates/circuit/README.md

+63
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,66 @@ The Rust-based data structures for circuits.
44
This currently defines the core data collections for `QuantumCircuit`, but may expand in the future to back `DAGCircuit` as well.
55

66
This crate is a very low part of the Rust stack, if not the very lowest.
7+
8+
The data model exposed by this crate is as follows.
9+
10+
## CircuitData
11+
12+
The core representation of a quantum circuit in Rust is the `CircuitData` struct. This containts the list
13+
of instructions that are comprising the circuit. Each element in this list is modeled by a
14+
`CircuitInstruction` struct. The `CircuitInstruction` contains the operation object and it's operands.
15+
This includes the parameters and bits. It also contains the potential mutable state of the Operation representation from the legacy Python data model; namely `duration`, `unit`, `condition`, and `label`.
16+
In the future we'll be able to remove all of that except for label.
17+
18+
At rest a `CircuitInstruction` is compacted into a `PackedInstruction` which caches reused qargs
19+
in the instructions to reduce the memory overhead of `CircuitData`. The `PackedInstruction` objects
20+
get unpacked back to `CircuitInstruction` when accessed for a more convienent working form.
21+
22+
Additionally the `CircuitData` contains a `param_table` field which is used to track parameterized
23+
instructions that are using python defined `ParmaeterExpression` objects for any parameters and also
24+
a global phase field which is used to track the global phase of the circuit.
25+
26+
## Operation Model
27+
28+
In the circuit crate all the operations used in a `CircuitInstruction` are part of the `OperationType`
29+
enum. The `OperationType` enum has four variants which are used to define the different types of
30+
operation objects that can be on a circuit:
31+
32+
- `StandardGate`: a rust native representation of a member of the Qiskit standard gate library. This is
33+
an `enum` that enuerates all the gates in the library and statically defines all the gate properties
34+
except for gates that take parameters,
35+
- `PyGate`: A struct that wraps a gate outside the standard library defined in Python. This struct wraps
36+
a `Gate` instance (or subclass) as a `PyObject`. The static properties of this object (such as name,
37+
number of qubits, etc) are stored in Rust for performance but the dynamic properties such as
38+
the matrix or definition are accessed by calling back into Python to get them from the stored
39+
`PyObject`
40+
- `PyInstruction`: A struct that wraps an instruction defined in Python. This struct wraps an
41+
`Instruction` instance (or subclass) as a `PyObject`. The static properties of this object (such as
42+
name, number of qubits, etc) are stored in Rust for performance but the dynamic properties such as
43+
the definition are accessed by calling back into Python to get them from the stored `PyObject`. As
44+
the primary difference between `Gate` and `Instruction` in the python data model are that `Gate` is a
45+
specialized `Instruction` subclass that represents unitary operations the primary difference between
46+
this and `PyGate` are that `PyInstruction` will always return `None` when it's matrix is accessed.
47+
- `PyOperation`: A struct that wraps an operation defined in Python. This struct wraps an `Operation`
48+
instance (or subclass)` as a `PyObject`. The static properties of this object (such as name, number
49+
of qubits, etc) are stored in Rust for performance. As `Operation` is the base abstract interface
50+
definition of what can be put on a circuit this is mostly just a container for custom Python objects.
51+
Anything that's operating on a bare operation will likely need to access it via the `PyObject`
52+
manually because the interface doesn't define many standard properties outside of what's cached in
53+
the struct.
54+
55+
There is also an `Operation` trait defined which defines the common access pattern interface to these
56+
4 types along with the `OperationType` parent. This trait defined methods to access the standard data
57+
model attributes of operations in Qiskit. This includes things like the name, number of qubits, the matrix, the definition, etc.
58+
59+
## ParameterTable
60+
61+
The `ParameterTable` struct is used to track which circuit instructions are using `ParameterExpression`
62+
objects for any of their parameters. The Python space `ParameterExpression` is comprised of a symengine
63+
symbolic expression that defines operations using `Parameter` objects. Each `Parameter` is modeled by
64+
a uuid and a name to uniquely identify it. The parameter table maps the `Parameter` objects to the
65+
`CircuitInstruction` in the `CircuitData` that are using them. The `Parameter` comprised of 3 `HashMaps` internally that map the uuid (as `u128`, which is accesible in Python by using `uuid.int`) to the `ParameterEntry`, the `name` to the uuid, and the uuid to the PyObject for the actual `Parameter`.
66+
67+
The `ParameterEntry` is just a `HashSet` of 2-tuples with usize elements. The two usizes represent the instruction index in the `CircuitData` and the index of the for the `CircuitInstruction.params` field of
68+
a give instruction where the given `Parameter` is used in the circuit. If the instruction index is
69+
`usize::MAX` that points to the global phase property of the circuit instead of a `CircuitInstruction`.

0 commit comments

Comments
 (0)