-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Implementation of a Limited QASM3 Exporter except controlflow and Classical Variables in Rust #14226
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
…low and scope switching
…low beucase we might not support controlflow
One or more of the following people are relevant to this code:
|
Pull Request Test Coverage Report for Build 15285103341Warning: 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
💛 - Coveralls |
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.
Thanks for this. I haven't looked through entirely completely yet, but some top-level comments I had from a quick play:
- I think you might have run your performance numbers with a debug build? I see rather better performance than you did.
- The order that gate definitions (like
xx_plus_yy
andsxdg
in the example) is non-deterministic - it can change if you restartpython
. We want it to be deterministic, but more importantly, the order is very important - in the example,sxdg
must be defined beforexx_plus_yy
, becausexx_plus_yy
usessxdg
. - The AST doesn't interact with any sort of symbol table, and it takes most things by ownership, including (and most importantly)
Identifier
. So when we're outputting a circuit with many many gates, we're first allocating lots and lots ofString
instances with the same value, then copying them over into the output. It will likely be much faster to have the AST be defined in reference to a symbol table, so the string representations of identifiers are stored separately, and the AST only stores an integer index into the table. You can do this completely separately if you like, or you might find anInterner<str>
fromqiskit_circuit::interner
to be a quick way to get something good enough.
@jakelishman
|
A syntactic (but not logical) merge conflict in `qiskit_circuit::bit` was trivial, but a logical merge conflict caused by the PyO3 0.25 update needed some actual code changes.
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.
Thanks for the work on this Kento, and the updates from the last review. In general, given this is in the "experimental" interface, and I've been sat on it a long time, I want to move to merge as-is, based solely on the observable user behaviour. That said, I think there's a few places in the code where I'd look to improvements, for any of runtime performance, ease of future maintenance, and code organisation. I've left some comments to these effects, which we might want to convert into full issues to track potential follow-up work (alongside obviously making it feature complete).
I fixed up the last merge conflicts, including some parsing of the Python-space kwargs that I'm not 100% sure how it worked before, but I assume is something that changed in a PyO3 update.
About Identifier
vs interning: there should be no difference in safety, because as long as every Interned<str>
refers to keys in the same Interner
, equality comparisons between strings and interned keys are the same thing, just comparisons between interned keys (and the memory taken to store them) are much faster. The trouble of comparing names between scopes is the same whether we use an explicit string or a key. I'm happy for that to be a follow-up, though.
The review comments largely relate to the high-level API and the design of the data structures, and have some places we might want to change in the future in follow-ups as this drives towards being the primary exporter. I didn't look at the actual implementation of the QASM3Builder
methods in any significant detail - I know a lot of it is ported from the Python version (which comes with a lot of tech debt itself), and it's pretty dependent on the data structures, so if those change at all, a lot of the discussion of the individual methods would become obsolete.
#[derive(Debug, Clone)] | ||
pub struct IntegerLiteral(pub(crate) i32); |
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.
There are likely problems ahead of us if we're limiting integers to 32 bits (registers are in theory arbitrary sized), but we can look at changing the internal representation of that in the future.
#[allow(dead_code)] | ||
#[derive(Debug, Clone)] | ||
pub struct BitstringLiteral { | ||
pub value: String, | ||
pub width: u32, | ||
} |
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.
A bitstring literal can always be represented by a (big) integer, so strictly some u64
or unsigned BigInteger
would be more efficient here, but again, we can look at that later.
#[derive(Debug, Clone)] | ||
pub struct DurationLiteral { | ||
pub value: f64, | ||
pub unit: DurationUnit, | ||
} | ||
|
||
#[derive(Debug, Clone)] | ||
pub enum DurationUnit { | ||
Nanosecond, | ||
Microsecond, | ||
Millisecond, | ||
Second, | ||
Sample, | ||
} |
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.
Since this PR was written, we gained a more Rust-native way to represent durations in qiskit_circuit
, so in the future we can swap to that.
fn start_line(&mut self) { | ||
write!(self.stream, "{}", self.indent.repeat(self.current_indent)).unwrap(); | ||
} |
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.
All the way through the printer: panicking in Rust is generally supposed to be for un-recoverable errors, particularly in a library, like Qiskit. We should ideally return a ::std::io::Result<()>
from each of these functions, and bubble up the failure properly to Python space.
if let Some(version) = &node.version { | ||
self.visit(&Node::Version(version)) | ||
}; | ||
for include in node.includes.iter() { | ||
self.visit(&Node::Include(include)); | ||
} |
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.
Very minor: wrapping a Version
object in a Node::Version
in order to dispatch it back down to Version
just ends up being a bit of a waste of time (though I'd imagine the compiler will actually optimise it out here) - there's no particular reason not to just call visit_version
directly (and so on for other similar examples).
// These are the prefixes used for the loose qubit and bit names. | ||
lazy_static! { | ||
static ref BIT_PREFIX: &'static str = "_bit"; | ||
} | ||
lazy_static! { | ||
static ref QUBIT_PREFIX: &'static str = "_qubit"; | ||
} | ||
|
||
// These are the prefixes used for the gate parameters and qubits. | ||
lazy_static! { | ||
static ref GATE_PARAM_PREFIX: &'static str = "_gate_p"; | ||
} | ||
lazy_static! { | ||
static ref GATE_QUBIT_PREFIX: &'static str = "_gate_q"; | ||
} |
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.
These ones don't need to be lazy - const BIT_PREFIX: &str = "_bit";
would work fine.
#[derive(Debug, Clone)] | ||
struct DumpOptions { | ||
includes: Vec<String>, | ||
basis_gates: Vec<String>, | ||
disable_constants: bool, | ||
allow_aliasing: bool, | ||
indent: String, | ||
} | ||
|
||
impl Default for DumpOptions { | ||
fn default() -> Self { | ||
Self { | ||
includes: vec!["stdgates.inc".to_string()], | ||
basis_gates: vec![], | ||
disable_constants: true, | ||
allow_aliasing: false, | ||
indent: " ".to_string(), | ||
} | ||
} | ||
} |
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.
If these are just the options that go into making an Exporter
, would it have made sense to skip the intermediate structure and go straight to having constructors of Exporter
? Exporter
itself is just a configuration definition for QASM3Builder
, and this is a configuration definition for Exporter
.
#[pyo3(signature = (circuit, /, kwargs=None))] | ||
pub fn dumps( | ||
_py: Python, | ||
circuit: &Bound<PyAny>, | ||
kwargs: Option<&Bound<PyDict>>, | ||
) -> PyResult<String> { |
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.
Making a fake kwargs
dictionary like this is a bit unusual for a Rust interface. You can specify keyword args properly using the pyo3(signature = ...)
derive macro, like you have, including specifying defaults. But here, since it's only called directly by Python space that already sets all the defaults (and only intended to be called that way), I'm not sure that the kwargs
indirection is achieving anything over just making all the configuration options explicitly required.
crates/qasm3/src/lib.rs
Outdated
#[pyfunction] | ||
#[pyo3(signature = (circuit,stream, /, kwargs=None))] | ||
pub fn dump( | ||
_py: Python, | ||
circuit: &Bound<PyAny>, | ||
stream: &Bound<PyAny>, | ||
kwargs: Option<&Bound<PyDict>>, | ||
) -> PyResult<()> { | ||
let mut options = DumpOptions::default(); | ||
|
||
if let Some(kw) = kwargs { | ||
let kw = kw.as_ref(); | ||
if let Ok(val) = kw.get_item("includes") { | ||
options.includes = val.extract::<Vec<String>>()?; | ||
} | ||
if let Ok(val) = kw.get_item("basis_gates") { | ||
options.basis_gates = val.extract::<Vec<String>>()?; | ||
} | ||
if let Ok(val) = kw.get_item("disable_constants") { | ||
options.disable_constants = val.extract::<bool>()?; | ||
} | ||
if let Ok(val) = kw.get_item("allow_aliasing") { | ||
options.allow_aliasing = val.extract::<bool>()?; | ||
} | ||
if let Ok(val) = kw.get_item("indent") { | ||
options.indent = val.extract::<String>()?; | ||
} | ||
} | ||
let circuit_data = circuit | ||
.getattr("_data")? | ||
.downcast::<CircuitData>()? | ||
.borrow(); | ||
|
||
let islayout = !circuit.getattr("layout")?.is_none(); | ||
|
||
let exporter = exporter::Exporter::new( | ||
options.includes, | ||
options.basis_gates, | ||
options.disable_constants, | ||
options.allow_aliasing, | ||
options.indent, | ||
); | ||
|
||
let mut output = Vec::new(); | ||
exporter | ||
.dump(&circuit_data, islayout, &mut output) | ||
.map_err(|err| { | ||
QASM3ImporterError::new_err(format!( | ||
"failed to export circuit using qasm3.dump_experimental: {:?}", | ||
err | ||
)) | ||
})?; | ||
|
||
let output_str = String::from_utf8(output) | ||
.map_err(|e| QASM3ImporterError::new_err(format!("invalid utf-8 output: {:?}", e)))?; | ||
|
||
stream.call_method1("write", (output_str,))?; | ||
|
||
Ok(()) | ||
} |
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.
Accepting a text-mode stream in the Python-space dump
isn't going to transition well to Rust - if we're given an open Python-space stream, we almost certainly should handle it by creating a full string from Rust and then copying it across to Python space, unless this is a performance pipeline we really want to optimise, in which case we should make a Rust object that implements Write
, and buffers writes out to the underlying Python stream so we aren't paying a huge runtime penalty for going Rust -> Python all the time (like how general file-backed streams also buffer writes).
For the time being, I feel like the whole Rust-space dump
is unnecessary, and we could achieve the same thing just in the Python-space wrapper, with it calling Rust-space dumps
.
return _qasm3.dumps( | ||
circuit, | ||
{ | ||
"includes": includes, | ||
"basis_gates": basis_gates, | ||
"disable_constants": disable_constants, | ||
"alias_classical_registers": alias_classical_registers, | ||
"allow_aliasing": allow_aliasing, | ||
"indent": indent, | ||
}, | ||
) |
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 is the strange calling convention I mentioned in the previous review comment - it'd be more natural if these were just regular keyword arguments, even if it is just an internal function.
…ssical Variables in Rust (Qiskit#14226) * draft for qasm3exporter * refactoring exporter and ast * wip: simplify symbol table * Remove unused struct * Add #[allow(dead_code)] to ast.rs * Remove scopes from symbol_table because we might not support controlflow and scope switching * Remove qubit_map, clbit_map, and some functions for handling controlflow beucase we might not support controlflow * Add tests and format * format * Added registers to BitLocations * Edited ast.rs * Exporter.rs for standard gates * Fixed exporter.py * printer.rs * Add test * format * python format * Fix dumps from python * refactoring exporter.rs * Fix printer.py * Reorder each functions * Replace hashmap with indexmap in using gates in symboltable --------- Co-authored-by: Jake Lishman <jake.lishman@ibm.com>
Fixes #13145 and Continue with #13666
Summary
This PR introduces and integrates several key modules for a Rust-based quantum computing framework, laying the foundation for exporting circuits in QASM 3.0 format. The modules added are:
1. printer.rs:
• Implements functionality for formatted output generation of AST or other hierarchical structures.
2. symbols.rs:
• Manages a symbol table used for mapping variable names to their corresponding entities.
• Handling bit info, register info, and gate info in the symbol table.
• Provides helper methods for creating, binding, and looking up symbols with robust error handling.
3. ast.rs:
• Defines the AST structure for representing quantum programs.
• Contains data structures such as nodes, expressions, and declarations that model the core components of quantum circuit descriptions.
4. exporter.rs:
• Implements a builder for generating QASM 3.0 programs.
• Integrates with Python-based tools (via pyo3) to extract circuit data and parameters.
Details and comments
Current Limitations
The current implementation has limited QASM 3.0 output functionality and relies on partial Python integration. The following features are not yet supported:
• Control Flow Constructs: While QASM 3.0 supports control flow, such as loops and conditionals, these are not yet implemented.
• Classical Variables: Handling classical variables is not included in the current architecture.
• Not standard Gates: Generating QASM for not standard gates and mapping them to Qiskit’s standard gates is pending.
These tasks require further discussion to determine whether they should be included in the scope of Qiskit Issue #13145.
What is Pending
1. Support for:
• Control flow constructs like loops, conditionals, and subroutines.
• Advanced QASM 3.0 features, including classical variable.
Difference from previous PR(#13666 )
• Support for Registers.
• Support for all the standard gates defined by qiskit.
• Support for custom gates.
• Add tests in test_exporter.py.
We can export the below circuit.
Output is here: