8000 Introduce PauliLindbladMap by DanPuzzuoli · Pull Request #14383 · Qiskit/qiskit · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Introduce PauliLindbladMap #14383

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

Merged
merged 8 commits into from
Jun 4, 2025
Merged

Conversation

DanPuzzuoli
Copy link
Contributor
@DanPuzzuoli DanPuzzuoli commented May 15, 2025

Summary

This is the second PR in a series of 3 introducing QubitSparsePauli, QubitSparsePauliList, and PauliLindbladMap. The first PR, which this PR depends on, is #14283 .

This introduces the data structure of the class PauliLindladMap, building on QubitSparsePauliList.

Details and comments

This depends on #14283

@coveralls
Copy link
coveralls commented May 16, 2025

Pull Request Test Coverage Report for Build 15448742213

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 542 of 561 (96.61%) changed or added relevant lines in 4 files are covered.
  • 464 unchanged lines in 13 files lost coverage.
  • Overall coverage increased (+0.1%) to 87.968%

Changes Missing Coverage Covered Lines Changed/Added Lines %
crates/quantum_info/src/pauli_lindblad_map/pauli_lindblad_map_class.rs 489 508 96.26%
Files with Coverage Reduction New Missed Lines %
crates/circuit/src/symbol_expr.rs 1 75.44%
crates/synthesis/src/two_qubit_decompose.rs 1 91.63%
crates/qasm2/src/lex.rs 3 91.98%
qiskit/transpiler/passes/layout/vf2_utils.py 3 93.33%
qiskit/synthesis/multi_controlled/mcx_synthesis.py 4 97.7%
qiskit/synthesis/discrete_basis/solovay_kitaev.py 10 89.01%
crates/cext/src/exit_codes.rs 12 31.58%
qiskit/qpy/binary_io/parse_sympy_repr.py 14 25.0%
qiskit/qpy/common.py 19 75.24%
crates/transpiler/src/target/mod.rs 35 84.9%
Totals Coverage Status
Change from base Build 15345357897: 0.1%
Covered Lines: 82176
Relevant Lines: 93416

💛 - Coveralls

@DanPuzzuoli DanPuzzuoli marked this pull request as ready for review May 21, 2025 15:06
@DanPuzzuoli DanPuzzuoli requested review from a team as code owners May 21, 2025 15:06
@qiskit-bot
Copy link
Collaborator

One or more of the following people are relevant to this code:

  • @Cryoris
  • @Qiskit/terra-core
  • @ajavadia
  • @levbishop
  • @t-imamichi

This is a roll-up of Dan's work of building the `PauliLindbladMap` on
top of [1].

[1]: 09e0de0: Add QubitSparsePauli and QubitSparsePauliList classes to quantum_info

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>
@jakelishman jakelishman force-pushed the pauli-lindblad-map-v3 branch from 63477fd to a420620 Compare May 30, 2025 11:05
@jakelishman
Copy link
Member

I rebased and rolled up this PR over the just-merged #14283, just to help speed-up the review process from my side - Dan/Sam, if you need to pull locally, your branches will be out of sync, so you'll need to make sure your branches are correctly at the previous tip commit 63477fd (so we don't drop any changes you might have locally), then do git fetch <dan's remote> followed by git reset --hard <dan's remote>/pauli-lindblad-map-v3 to forcibly reset your branches to this.

If you're worried, you can branch off locally first git checkout -b old-pauli-lindblad-map pauli-lindblad-map-v3 before you start the destructive actions on the branch with git reset --hard, but as long as your branch was at the tip 63477fd, there shouldn't be problems.

Copy link
Member
@jakelishman jakelishman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this Dan - I've left some comments on this here. I can't comment too much on the exact maths and whatnot of the object without spending a fair amount more time thinking about it, so I'm largely trusting that the three (four?) of you have got it right for what you wanted.

My biggest concerns here are that the way the Rust/Python split works with with compound object of PauliLindbladMap and the inner QubitSparsePauliList aren't right - there have been problems introduced as this moved beyond the structures that SparseObservable had laid the groundwork for. I've left the comments here for first discussion, and I'll think about more asynchronously about some of the points I raised here.

Comment on lines 907 to 911
#[pyclass(name = "QubitSparsePauli", frozen, module = "qiskit.quantum_info")]
#[derive(Clone, Debug)]
pub struct PyQubitSparsePauli {
inner: QubitSparsePauli,
pub inner: QubitSparsePauli,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unsafe from a memory-safety perspective: you have unsafe blocks in PyQubitSparsePauli that rely on the inner object being immutable (or more specifically, its internal allocations not moving) within the lifetime of the outer object.

Specifically: the methods that return Numpy arrays that are backed by the Rust-space allocations can become memory-unsafe with this, if a user of PyQubitSparsePauli were to use the pub visibility on inner to move it and drop it while a Numpy array was still live. The Python-space lifetimes only protect the existence of the outer PyQubitSparsePauli, and not its inner object.

What do you need the pub for here? We can likely find ways that give you the access you need that remain safe.


As a follow-up: I realise that this is way too easy to get wrong now, and you have to have huge amounts of knowledge in mind about the entirety of the SparseObservable data structures to keep this safe. I'll follow up with a PR to SparseObservable and to all these new structures that modifies how the Numpy array-access sharing is done to make this much harder to get wrong, at the cost of a small runtime penalty that I expect will be close-enough to unmeasurable anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the follow up PR I had instances of wanting to access the rust-space object QubitSparsePauli from within methods of PyPauliLindbladMap.

Comment on lines +443 to +446
pub fn raw_parts_from_sparse_list(
iter: Vec<(String, Vec<u32>)>,
num_qubits: u32,
) -> Result<RawParts, LabelError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to split it out like this. That said, if you prefer, you can provide a safe and no-cost way of achieving this from QubitSparsePauli itself, like so:

impl QubitSparsePauliList {
    pub fn take(self) -> (Vec<Pauli>, Vec<u32>, Vec<usize>) {
        (self.paulis, self.indices, self.boundaries)
    }
}

then you can have leave QubitSparsePauliList::from_raw_parts as is, and have the new callers of raw_parts_from_sparse_list be like

fn whatever(num_qubits: u32) -> Result<T, LabelError> {
    let (paulis, indices, boundaries) = QubitSparsePauliList::from_raw_parts(...)?.take();
}

Having take take self by ownership means that you can absolve yourself of coherence issues in the API after that - it's a way of providing safe API access to completely destructure the struct into its constituent parts without having to give pub access to fields that can't be pub while the struct is still implying the semantics.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason I split this method out is that the same code is needed in PauliLindbladMap.

Comment on lines 31 to 32
/// A Pauli Lindblad map that stores its data in a qubit-sparse format. Note that gamma,
/// probabilities, and non_negative_rates is
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like something's got truncated here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - just completed the sentence.

Comment on lines 74 to 91
/// Create a new Pauli Lindblad map from the raw components that make it up.
pub fn new_from_raw_parts(
num_qubits: u32,
rates: Vec<f64>,
paulis: Vec<Pauli>,
indices: Vec<u32>,
boundaries: Vec<usize>,
) -> Result<Self, CoherenceError> {
if rates.len() + 1 != boundaries.len() {
return Err(CoherenceError::MismatchedTermCount {
rates: rates.len(),
qspl: boundaries.len() - 1,
});
}
let qubit_sparse_pauli_list: QubitSparsePauliList =
QubitSparsePauliList::new(num_qubits, paulis, indices, boundaries)?;
Self::new(rates, qubit_sparse_pauli_list)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more. 10000

The idea of from_raw_parts in SparseObservable is to be as close to a zero-cost constructor as possible.

This method is identical both in function and performance to PauliLindbladMap::new(rates, QubitSparsePauliList::new(num_qubits, paulis, indices, boundaries)?), and it's (imo) more idiomatic rust not to hide that fact, but just have the caller spell it themselves, which it's not much more typing already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True - dropping this method and updating the one place it is used to use your suggested line.

Comment on lines 93 to 112
/// Create a new [PauliLindbladMap] without checking data coherence.
///
/// # Safety
///
/// It is up to the caller to ensure that the data-coherence requirements, as enumerated in the
/// struct-level documentation, have been upheld.
#[inline(always)]
pub unsafe fn new_unchecked(
rates: Vec<f64>,
qubit_sparse_pauli_list: QubitSparsePauliList,
) -> Self {
let (gamma, probabilities, non_negative_rates) = derived_values_from_rates(&rates);
Self {
rates,
qubit_sparse_pauli_list,
gamma,
probabilities,
non_negative_rates,
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically the purpose of unsafe constructors is to allow zero-computation methods of construction to callers who don't have privileged access to the internals of the struct. This method still does some fairly heavy calculations (including some memory allocations), so unless it's super necessary, it's possibly not worth the risk of adding an unsafe method.

I don't think you use this anywhere, so it might be cleanest just to remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does get used in the followup PR, though could easily be replaced. It's true that all it does is not check if rates.len() == qubit_sparse_pauli_list.len().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, deleted this.

Comment on lines 504 to 519
/// Return the pauli labels of the term as string.
///
/// The pauli labels will match the order of :attr:`.GeneratorTerm.indices`, such that the
/// i-th character in the string is applied to the qubit index at ``term.indices[i]``.
///
/// Returns:
/// The non-identity bit terms as concatenated string.
fn pauli_labels<'py>(&self, py: Python<'py>) -> Bound<'py, PyString> {
let string: String = self
.inner
.paulis()
.iter()
.map(|bit| bit.py_label())
.collect();
PyString::new(py, string.as_str())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question about these guys as with QubitSparsePauli (where you deleted it). I imagine you've got more desire for it here? If so: it'd be nice to include an example in the documentation illustrating what you mean on lines 506-507.

Copy link
Contributor Author
@DanPuzzuoli DanPuzzuoli Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I also think this is unnecessary. Deleted :P .

Edit: I had originally deleted this but decided to put it back. I've added an example.

.inner
.read()
.map_err(|_| InnerReadError)?;
let inner = PauliLindbladMap::new(rates.clone(), qubit_sparse_pauli_list.clone())?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably don't need to clone the rates here. It's not super ideal that we have to clone the QubitSparsePauliList on input, but not the end of the world right now - can always be addressed later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay dropping the cloning of the rates. Yeah I don't like cloning the operator list either but I wasn't sure how to get around that. Is it correct that if we demand it is passed as a mutable reference we could guarantee no one else has access to change it anymore? (What would be idiomatic here?)

Comment on lines 1026 to 1031
/// The map's qubit sparse pauli list.
#[getter]
fn get_qubit_sparse_pauli_list(&self) -> PyQubitSparsePauliList {
let inner = self.inner.read().unwrap();
inner.qubit_sparse_pauli_list.clone().into()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is almost certainly not what you're after for performance: the qubit_sparse_pauli_list itself is cloned on each attribute access; it's like you're doing a Python-space deepcopy on the underlying list each time you access the attribute. This is going to make plm.qubit_sparse_pauli_list attribute accesses really expensive in Python space.

It's not immediately clear to me what your best bet is going to be here - you can't just expose internals of Rust objects to Python space safely without having designed in the capability from a fairly early point.

If you can get away with it for the first release, you're probably best making this a regular method (so not a getter), giving it a slightly different name to make it clear it makes a copy, and relying on callers to eat the cost of the copy if they need the internal access. We can potentially work out some new method of structuring the data types later to make it easier to make a raw getter in the future.

In particular: it would be easier to imagine such a situation if the derived quantities from rates aren't stored, but are calculated only at the point of use. That's not necessary, but my first thoughts for improving the attribute access here want it. We can find a way around that if we need to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay sounds good. I've dropped the getter decorator and renamed the method to:

get_qubit_sparse_pauli_list_copy

Comment on lines 554 to 556
/// .. math::
///
/// \Lambda = \prod_{P \in K}\exp\left(\lambda_P(P \cdot P - \cdot)\right).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style: here and everywhere in the docs, I find the \cdot very hard to read; to me, it's a multiplication marker. Perhaps \circ is a better choice? Compare this one using \cdot:

$$ \Lambda = \prod_{P \in K}\exp\left(\lambda_P(P \cdot P - \cdot)\right) $$

to this one using \circ:

$$ \Lambda\bigl[\circ\bigr] = \prod_{P \in K}\exp\left(\lambda_P(P \circ P - \circ)\right) $$

Copy link
Contributor Author
@DanPuzzuoli DanPuzzuoli Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly about this personally. I naturally prefer \cdot as I've always used \circ to refer to composition, but I also agree that in this case the \cdot is weird looking appearing by itself after a minus sign. I'll change it to \circ. I've also changed the $\Lambda \rightarrow \Lambda\bigl[\circ\bigr]$.

Comment on lines 1010 to 1024
/// The probabilities for the map.
#[getter]
fn get_probabilities(slf_: Bound<Self>) -> Bound<PyArray1<f64>> {
let borrowed = &slf_.borrow();
let inner = borrowed.inner.read().unwrap();
let probabilities = inner.probabilities();
let arr = ::ndarray::aview1(probabilities);
// SAFETY: in order to call this function, the lifetime of `self` must be managed by Python.
// We tie the lifetime of the array to `slf_`, and there are no public ways to modify the
// `Box<[u32]>` allocation (including dropping or reallocating it) other than the entire
// object getting dropped, which Python will keep safe.
let out = unsafe { PyArray1::borrow_from_array(&arr, slf_.into_any()) };
out.readwrite().make_nonwriteable();
out
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safety guarantees on these really don't hold in the same way that they did in the smaller scale objects. I'm a bit worried about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on how to resolve this?

Copy link
Contributor Author
@DanPuzzuoli DanPuzzuoli Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up dropping the #[getter] decorator so that this no longer looks like an attribute. I've done the same with get_gamma.

@DanPuzzuoli
Copy link
Contributor Author
DanPuzzuoli commented Jun 2, 2025

@jakelishman I believe I've addressed all of the comments except for the final one about concern with the get_probabilities method. Any thoughts on what to do with this?

I've also added the method get_inner to PyQubitSparsePauli to return the inner, and no longer directly access it (it is no longer public).

@DanPuzzuoli DanPuzzuoli requested a review from jakelishman June 3, 2025 15:39
This defines the interface of these functions to be regulard methods
that compute quantities.  This includes having `probabilities` return a
copy of a Numpy array, which is what will be needed in follow-ups
anyway; we don't want the Python interface to leak that certain
quantities are actually currently cached (since that will change in the
future).

A couple of Rust-space methods are made more "Rust-y" as a side effect.
Copy link
Member
@jakelishman jakelishman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think this is all safe for the interface, in preparation for more completely moving to #14528 in Qiskit 2.2.

Comment on lines +37 to +48
/// The rates of each abstract term in the generator sum. This has as many elements as
/// terms in the sum.
rates: Vec<f64>,
/// A list of qubit sparse Paulis corresponding to the rates
qubit_sparse_pauli_list: QubitSparsePauliList,
/// The gamma parameter.
gamma: f64,
/// Probability of application of a given Pauli operator in product form. Note that if the
/// corresponding rate is less than 0, this is a quasi-probability.
probabilities: Vec<f64>,
/// List of boolean values for the statement rate >= 0 for each rate in rates.
non_negative_rates: Vec<bool>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the future: we intend to split these out in a future version of PauliLindbladMap, to make the objects lighter to manipulate, especially want sampling under different rates/masks. See #14528.

They're staying in the object for this PR, because it still works, and we're just not exposing them publicly to Python space in the interface.

@jakelishman jakelishman enabled auto-merge June 4, 2025 17:27
@jakelishman jakelishman added this pull request to the merge queue Jun 4, 2025
Merged via the queue into Qiskit:main with commit 1e25f42 Jun 4, 2025
26 checks passed
@eliarbel eliarbel added the Changelog: New Feature Include in the "Added" section of the changelog label Jun 5, 2025
rahaman-quantum pushed a commit to rahaman-quantum/qiskit that referenced this pull request Jun 20, 2025
< 6851 div class="text-right ml-1"> 557c7b7
* Introduce PauliLindbladMap

This is a roll-up of Dan's work of building the `PauliLindbladMap` on
top of [1].

[1]: 09e0de0: Add QubitSparsePauli and QubitSparsePauliList classes to quantum_info

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>

* making suggested changes

* formatting and re-adding pauli_labels method

* formatting

* fixing forgotten inner issue

* Fix minor documentation copy/paste

* Make `gamma()` and `probabilities()` regular computing methods

This defines the interface of these functions to be regulard methods
that compute quantities.  This includes having `probabilities` return a
copy of a Numpy array, which is what will be needed in follow-ups
anyway; we don't want the Python interface to leak that certain
quantities are actually currently cached (since that will change in the
future).

A couple of Rust-space methods are made more "Rust-y" as a side effect.

* Fix clippy

---------

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>
Co-authored-by: Jake Lishman <jake.lishman@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Changelog: New Feature Include in the "Added" section of the changelog
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants
You can’t perform that action at this time.
0