Skip to content

Commit c911205

Browse files
kevinhartmanmtreinishraynelfssElePTalexanderivrii
committed
Port DAGCircuit to Rust
This commit migrates the entirety of the `DAGCircuit` class to Rust. It fully replaces the Python version of the class. The primary advantage of this migration is moving from a Python space rustworkx directed graph representation to a Rust space petgraph (the upstream library for rustworkx) directed graph. Moving the graph data structure to rust enables us to directly interact with the DAG directly from transpiler passes in Rust in the future. This will enable a significant speed-up in those transpiler passes. Additionally, this should also improve the memory footprint as the DAGCircuit no longer stores `DAGNode` instances, and instead stores a lighter enum NodeType, which simply contains a `PackedInstruction` or the wire objects directly. Internally, the new Rust-based `DAGCircuit` uses a `petgraph::StableGraph` with node weights of type `NodeType` and edge weights of type `Wire`. The NodeType enum contains variants for `QubitIn`, `QubitOut`, `ClbitIn`, `ClbitOut`, and `Operation`, which should save us from all of the `isinstance` checking previously needed when working with `DAGNode` Python instances. The `Wire` enum contains variants `Qubit`, `Clbit`, and `Var`. As the full Qiskit data model is not rust-native at this point while all the class code in the `DAGCircuit` exists in Rust now, there are still sections that rely on Python or actively run Python code via Rust to function. These typically involve anything that uses `condition`, control flow, classical vars, calibrations, bit/register manipulation, etc. In the future as we either migrate this functionality to Rust or deprecate and remove it this can be updated in place to avoid the use of Python. API access from Python-space remains in terms of `DAGNode` instances to maintain API compatibility with the Python implementation. However, internally, we convert to and deal in terms of NodeType. When the user requests a particular node via lookup or iteration, we inflate an ephemeral `DAGNode` based on the internal `NodeType` and give them that. This is very similar to what was done in Qiskit#10827 when porting CircuitData to Rust. As part of this porting there are a few small differences to keep in mind with the new Rust implementation of DAGCircuit. The first is that the topological ordering is slightly different with the new DAGCircuit. Previously, the Python version of `DAGCircuit` using a lexicographical topological sort key which was basically `"0,1,0,2"` where the first `0,1` are qargs on qubit indices `0,1` for nodes and `0,2` are cargs on clbit indices `0,2`. However, the sort key has now changed to be `(&[Qubit(0), Qubit(1)], &[Clbit(0), Clbit(2)])` in rust in this case which for the most part should behave identically, but there are some edge cases that will appear where the sort order is different. It will always be a valid topological ordering as the lexicographical key is used as a tie breaker when generating a topological sort. But if you're relaying on the exact same sort order there will be differences after this PR. The second is that a lot of undocumented functionality in the DAGCircuit which previously worked because of Python's implicit support for interacting with data structures is no longer functional. For example, previously the `DAGCircuit.qubits` list could be set directly (as the circuit visualizers previously did), but this was never documented as supported (and would corrupt the DAGCircuit). Any functionality like this we'd have to explicit include in the Rust implementation and as they were not included in the documented public API this PR opted to remove the vast majority of this type of functionality. The last related thing might require future work to mitigate is that this PR breaks the linkage between `DAGNode` and the underlying `DAGCirucit` object. In the Python implementation the `DAGNode` objects were stored directly in the `DAGCircuit` and when an API method returned a `DAGNode` from the DAG it was a shared reference to the underlying object in the `DAGCircuit`. This meant if you mutated the `DAGNode` it would be reflected in the `DAGCircuit`. This was not always a sound usage of the API as the `DAGCircuit` was implicitly caching many attributes of the DAG and you should always be using the `DAGCircuit` API to mutate any nodes to prevent any corruption of the `DAGCircuit`. However, now as the underlying data store for nodes in the DAG are no longer the python space objects returned by `DAGCircuit` methods mutating a `DAGNode` will not make any change in the underlying `DAGCircuit`. This can come as quite the surprise at first, especially if you were relying on this side effect, even if it was unsound. It's also worth noting that 2 large pieces of functionality from rustworkx are included in this PR. These are the new files `rustworkx_core_vnext` and `dot_utils` which are rustworkx's VF2 implementation and its dot file generation. As there was not a rust interface exposed for this functionality from rustworkx-core there was no way to use these functions in rustworkx. Until these interfaces added to rustworkx-core in future releases we'll have to keep these local copies. The vf2 implementation is in progress in Qiskit/rustworkx#1235, but `dot_utils` might make sense to keep around longer term as it is slightly modified from the upstream rustworkx implementation to directly interface with `DAGCircuit` instead of a generic graph. Co-authored-by: Matthew Treinish <[email protected]> Co-authored-by: Raynel Sanchez <[email protected]> Co-authored-by: Elena Peña Tapia <[email protected]> Co-authored-by: Alexander Ivrii <[email protected]> Co-authored-by: Eli Arbel <[email protected]> Co-authored-by: John Lapeyre <[email protected]> Co-authored-by: Jake Lishman <[email protected]>
1 parent c7e7016 commit c911205

Some content is hidden

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

53 files changed

+8901
-2978
lines changed

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ license = "Apache-2.0"
1616
[workspace.dependencies]
1717
bytemuck = "1.16"
1818
indexmap.version = "2.3.0"
19-
hashbrown.version = "0.14.0"
19+
hashbrown.version = "0.14.5"
2020
num-bigint = "0.4"
2121
num-complex = "0.4"
2222
ndarray = "^0.15.6"
2323
numpy = "0.21.0"
2424
smallvec = "1.13"
2525
thiserror = "1.0"
26+
rustworkx-core = "0.15"
27+
approx = "0.5"
28+
itertools = "0.13.0"
2629
ahash = "0.8.11"
2730

2831
# Most of the crates don't need the feature `extension-module`, since only `qiskit-pyext` builds an

crates/accelerate/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ rand_distr = "0.4.3"
1818
ahash.workspace = true
1919
num-traits = "0.2"
2020
num-complex.workspace = true
21+
rustworkx-core.workspace = true
2122
num-bigint.workspace = true
22-
rustworkx-core = "0.15"
2323
faer = "0.19.1"
24-
itertools = "0.13.0"
24+
itertools.workspace = true
2525
qiskit-circuit.workspace = true
2626
thiserror.workspace = true
2727

@@ -38,7 +38,7 @@ workspace = true
3838
features = ["rayon", "approx-0_5"]
3939

4040
[dependencies.approx]
41-
version = "0.5"
41+
workspace = true
4242
features = ["num-complex"]
4343

4444
[dependencies.hashbrown]

crates/accelerate/src/convert_2q_block_matrix.rs

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ use qiskit_circuit::circuit_instruction::CircuitInstruction;
2727
use qiskit_circuit::dag_node::DAGOpNode;
2828
use qiskit_circuit::gate_matrix::ONE_QUBIT_IDENTITY;
2929
use qiskit_circuit::imports::QI_OPERATOR;
30-
use qiskit_circuit::operations::{Operation, OperationRef};
30+
use qiskit_circuit::operations::Operation;
3131

3232
use crate::QiskitError;
3333

3434
fn get_matrix_from_inst<'py>(
3535
py: Python<'py>,
3636
inst: &'py CircuitInstruction,
3737
) -> PyResult<Array2<Complex64>> {
38-
if let Some(mat) = inst.op().matrix(&inst.params) {
38+
if let Some(mat) = inst.operation.matrix(&inst.params) {
3939
Ok(mat)
4040
} else if inst.operation.try_standard_gate().is_some() {
4141
Err(QiskitError::new_err(
@@ -124,29 +124,7 @@ pub fn change_basis(matrix: ArrayView2<Complex64>) -> Array2<Complex64> {
124124
trans_matrix
125125
}
126126

127-
#[pyfunction]
128-
pub fn collect_2q_blocks_filter(node: &Bound<PyAny>) -> Option<bool> {
129-
let Ok(node) = node.downcast::<DAGOpNode>() else {
130-
return None;
131-
};
132-
let node = node.borrow();
133-
match node.instruction.op() {
134-
gate @ (OperationRef::Standard(_) | OperationRef::Gate(_)) => Some(
135-
gate.num_qubits() <= 2
136-
&& node
137-
.instruction
138-
.extra_attrs
139-
.as_ref()
140-
.and_then(|attrs| attrs.condition.as_ref())
141-
.is_none()
142-
&& !node.is_parameterized(),
143-
),
144-
_ => Some(false),
145-
}
146-
}
147-
148127
pub fn convert_2q_block_matrix(m: &Bound<PyModule>) -> PyResult<()> {
149128
m.add_wrapped(wrap_pyfunction!(blocks_to_matrix))?;
150-
m.add_wrapped(wrap_pyfunction!(collect_2q_blocks_filter))?;
151129
Ok(())
152130
}

crates/accelerate/src/euler_one_qubit_decomposer.rs

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ pub fn compute_error_list(
743743
.iter()
744744
.map(|node| {
745745
(
746-
node.instruction.op().name().to_string(),
746+
node.instruction.operation.name().to_string(),
747747
smallvec![], // Params not needed in this path
748748
)
749749
})
@@ -988,10 +988,11 @@ pub fn optimize_1q_gates_decomposition(
988988
.iter()
989989
.map(|node| {
990990
if let Some(err_map) = error_map {
991-
error *= compute_error_term(node.instruction.op().name(), err_map, qubit)
991+
error *=
992+
compute_error_term(node.instruction.operation.name(), err_map, qubit)
992993
}
993994
node.instruction
994-
.op()
995+
.operation
995996
.matrix(&node.instruction.params)
996997
.expect("No matrix defined for operation")
997998
})
@@ -1043,22 +1044,6 @@ fn matmul_1q(operator: &mut [[Complex64; 2]; 2], other: Array2<Complex64>) {
10431044
];
10441045
}
10451046

1046-
#[pyfunction]
1047-
pub fn collect_1q_runs_filter(node: &Bound<PyAny>) -> bool {
1048-
let Ok(node) = node.downcast::<DAGOpNode>() else {
1049-
return false;
1050-
};
1051-
let node = node.borrow();
1052-
let op = node.instruction.op();
1053-
op.num_qubits() == 1
1054-
&& op.num_clbits() == 0
1055-
&& op.matrix(&node.instruction.params).is_some()
1056-
&& match &node.instruction.extra_attrs {
1057-
None => true,
1058-
Some(attrs) => attrs.condition.is_none(),
1059-
}
1060-
}
1061-
10621047
pub fn euler_one_qubit_decomposer(m: &Bound<PyModule>) -> PyResult<()> {
10631048
m.add_wrapped(wrap_pyfunction!(params_zyz))?;
10641049
m.add_wrapped(wrap_pyfunction!(params_xyx))?;
@@ -1072,7 +1057,6 @@ pub fn euler_one_qubit_decomposer(m: &Bound<PyModule>) -> PyResult<()> {
10721057
m.add_wrapped(wrap_pyfunction!(compute_error_one_qubit_sequence))?;
10731058
m.add_wrapped(wrap_pyfunction!(compute_error_list))?;
10741059
m.add_wrapped(wrap_pyfunction!(optimize_1q_gates_decomposition))?;
1075-
m.add_wrapped(wrap_pyfunction!(collect_1q_runs_filter))?;
10761060
m.add_class::<OneQubitGateSequence>()?;
10771061
m.add_class::<OneQubitGateErrorMap>()?;
10781062
m.add_class::<EulerBasis>()?;

crates/circuit/Cargo.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,29 @@ name = "qiskit_circuit"
1010
doctest = false
1111

1212
[dependencies]
13+
rayon = "1.10"
14+
ahash.workspace = true
15+
rustworkx-core.workspace = true
1316
bytemuck.workspace = true
14-
hashbrown.workspace = true
1517
num-complex.workspace = true
1618
ndarray.workspace = true
1719
numpy.workspace = true
1820
thiserror.workspace = true
21+
approx.workspace = true
22+
itertools.workspace = true
1923

2024
[dependencies.pyo3]
2125
workspace = true
2226
features = ["hashbrown", "indexmap", "num-complex", "num-bigint", "smallvec"]
2327

28+
[dependencies.hashbrown]
29+
workspace = true
30+
features = ["rayon"]
31+
32+
[dependencies.indexmap]
33+
workspace = true
34+
features = ["rayon"]
35+
2436
[dependencies.smallvec]
2537
workspace = true
2638
features = ["union"]

crates/circuit/src/bit_data.rs

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use pyo3::prelude::*;
1717
use pyo3::types::PyList;
1818
use std::fmt::Debug;
1919
use std::hash::{Hash, Hasher};
20+
use std::mem::swap;
2021

2122
/// Private wrapper for Python-side Bit instances that implements
2223
/// [Hash] and [Eq], allowing them to be used in Rust hash-based
@@ -81,17 +82,6 @@ pub struct BitData<T> {
8182
cached: Py<PyList>,
8283
}
8384

84-
pub struct BitNotFoundError<'py>(pub(crate) Bound<'py, PyAny>);
85-
86-
impl<'py> From<BitNotFoundError<'py>> for PyErr {
87-
fn from(error: BitNotFoundError) -> Self {
88-
PyKeyError::new_err(format!(
89-
"Bit {:?} has not been added to this circuit.",
90-
error.0
91-
))
92-
}
93-
}
94-
9585
impl<T> BitData<T>
9686
where
9787
T: From<BitType> + Copy,
@@ -139,14 +129,19 @@ where
139129
pub fn map_bits<'py>(
140130
&self,
141131
bits: impl IntoIterator<Item = Bound<'py, PyAny>>,
142-
) -> Result<impl Iterator<Item = T>, BitNotFoundError<'py>> {
132+
) -> PyResult<impl Iterator<Item = T>> {
143133
let v: Result<Vec<_>, _> = bits
144134
.into_iter()
145135
.map(|b| {
146136
self.indices
147137
.get(&BitAsKey::new(&b))
148138
.copied()
149-
.ok_or_else(|| BitNotFoundError(b))
139+
.ok_or_else(|| {
140+
PyKeyError::new_err(format!(
141+
"Bit {:?} has not been added to this circuit.",
142+
b
143+
))
144+
})
150145
})
151146
.collect();
152147
v.map(|x| x.into_iter())
@@ -168,7 +163,7 @@ where
168163
}
169164

170165
/// Adds a new Python bit.
171-
pub fn add(&mut self, py: Python, bit: &Bound<PyAny>, strict: bool) -> PyResult<()> {
166+
pub fn add(&mut self, py: Python, bit: &Bound<PyAny>, strict: bool) -> PyResult<T> {
172167
if self.bits.len() != self.cached.bind(bit.py()).len() {
173168
return Err(PyRuntimeError::new_err(
174169
format!("This circuit's {} list has become out of sync with the circuit data. Did something modify it?", self.description)
@@ -193,6 +188,29 @@ where
193188
bit
194189
)));
195190
}
191+
Ok(idx.into())
192+
}
193+
194+
pub fn remove_indices<I>(&mut self, py: Python, indices: I) -> PyResult<()>
195+
where
196+
I: IntoIterator<Item = T>,
197+
{
198+
let mut indices_sorted: Vec<usize> = indices
199+
.into_iter()
200+
.map(|i| <BitType as From<T>>::from(i) as usize)
201+
.collect();
202+
indices_sorted.sort();
203+
204+
for index in indices_sorted.into_iter().rev() {
205+
self.cached.bind(py).del_item(index)?;
206+
let bit = self.bits.remove(index);
207+
self.indices.remove(&BitAsKey::new(bit.bind(py)));
208+
}
209+
// Update indices.
210+
for (i, bit) in self.bits.iter().enumerate() {
211+
self.indices
212+
.insert(BitAsKey::new(bit.bind(py)), (i as BitType).into());
213+
}
196214
Ok(())
197215
}
198216

@@ -203,3 +221,37 @@ where
203221
self.bits.clear();
204222
}
205223
}
224+
225+
pub struct Iter<'a, T> {
226+
_data: &'a BitData<T>,
227+
index: usize,
228+
}
229+
230+
impl<'a, T> Iterator for Iter<'a, T>
231+
where
232+
T: From<BitType>,
233+
{
234+
type Item = T;
235+
236+
fn next(&mut self) -> Option<Self::Item> {
237+
let mut index = self.index + 1;
238+
swap(&mut self.index, &mut index);
239+
let index: Option<BitType> = index.try_into().ok();
240+
index.map(|i| From::from(i))
241+
}
242+
}
243+
244+
impl<'a, T> IntoIterator for &'a BitData<T>
245+
where
246+
T: From<BitType>,
247+
{
248+
type Item = T;
249+
type IntoIter = Iter<'a, T>;
250+
251+
fn into_iter(self) -> Self::IntoIter {
252+
Iter {
253+
_data: self,
254+
index: 0,
255+
}
256+
}
257+
}

0 commit comments

Comments
 (0)