-
Notifications
You must be signed in to change notification settings - Fork 16
fix: support physical qubits in QASM3 to QIR conversion #290
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,12 +23,14 @@ | |
| """ | ||
|
|
||
| import logging | ||
| import re | ||
| from typing import Any, Callable, List, Optional, Union | ||
|
|
||
| import openqasm3.ast as qasm3_ast | ||
| import pyqir | ||
| import pyqir.rt | ||
| from openqasm3.ast import UnaryOperator | ||
| from openqasm3.visitor import QASMVisitor | ||
| from pyqir import qis | ||
|
|
||
| from qbraid_qir._pyqir_compat import pointer_id, qubit_pointer_type | ||
|
|
@@ -42,6 +44,53 @@ | |
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| _PHYSICAL_QUBIT_RE = re.compile(r"^\$(\d+)$") | ||
|
|
||
|
|
||
| def _physical_qubit_index(node: Any) -> Optional[int]: | ||
| """Return the hardware index of a physical qubit reference, else None. | ||
|
|
||
| Physical qubits are written "$n" in OpenQASM 3 and survive unrolling as a plain | ||
| ``Identifier`` (they have no declaring register), unlike virtual qubits which | ||
| become ``IndexedIdentifier``. The index is carried in the name: "$3" is qubit 3. | ||
| """ | ||
| if not isinstance(node, qasm3_ast.Identifier): | ||
| return None | ||
|
|
||
| match = _PHYSICAL_QUBIT_RE.match(node.name) | ||
| return int(match.group(1)) if match else None | ||
|
|
||
|
|
||
| class _HighestPhysicalQubit(QASMVisitor): | ||
| """Finds the largest physical qubit index addressed anywhere in a program.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self.highest = -1 | ||
|
|
||
| # Name and signature are fixed by openqasm3's QASMVisitor dispatch. | ||
| def visit_Identifier( # pylint: disable=invalid-name,unused-argument | ||
| self, node: qasm3_ast.Identifier, context: Any = None | ||
| ) -> None: | ||
| index = _physical_qubit_index(node) | ||
| if index is not None: | ||
| self.highest = max(self.highest, index) | ||
|
|
||
|
|
||
| def _required_qubits(module: QasmQIRModule) -> int: | ||
| """Number of qubits the entry point must declare. | ||
|
|
||
| A program that addresses physical qubits declares no qubit register, so pyqasm | ||
| reports ``num_qubits == 0`` for it after unrolling. Physical indices are absolute | ||
| hardware addresses, so the entry point has to cover the highest one used: a program | ||
| touching only "$7" still needs 8 qubits for "$7" to be a valid QIR qubit id. | ||
| """ | ||
| finder = _HighestPhysicalQubit() | ||
| for statement in module.qasm_program.unrolled_ast.statements: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will mean that we are going over the complete AST just for qubit count calculation which seems wasteful. Is it possible to derive this count from the qasm module object? Or keep a property |
||
| finder.visit(statement) | ||
|
|
||
| return max(module.num_qubits, finder.highest + 1) | ||
|
|
||
|
|
||
| class QasmQIRVisitor(QIRVisitor): | ||
| """A profile-aware visitor for converting OpenQASM 3 programs to QIR. | ||
|
|
||
|
|
@@ -81,6 +130,7 @@ def __init__( | |
| self._global_creg_size_map: dict[str, int] = {} | ||
| self._custom_gates: dict[str, qasm3_ast.QuantumGateDefinition] = {} | ||
| self._barrier_qubits: set[pyqir.Constant] = set() | ||
| self._required_qubit_count: int = 0 | ||
|
|
||
| # Configuration | ||
| self._initialize_runtime: bool = initialize_runtime | ||
|
|
@@ -129,10 +179,12 @@ def visit_qasm3_module(self, module: QasmQIRModule) -> None: | |
| # Set qir_profiles based on the profile being used | ||
| qir_profiles = "adaptive" if self._profile.name == "AdaptiveExecution" else "base" | ||
|
|
||
| self._required_qubit_count = _required_qubits(module) | ||
|
|
||
| entry = pyqir.entry_point( | ||
| self._llvm_module, | ||
| module.name, | ||
| qasm3_module.num_qubits, | ||
| self._required_qubit_count, | ||
| qasm3_module.num_clbits, | ||
| qir_profiles=qir_profiles, | ||
| ) | ||
|
|
@@ -206,18 +258,36 @@ def _get_op_bits(self, operation: Any, qubits: bool = True) -> list[pyqir.Consta | |
| Unionlist[pyqir.Constant] : The bits for the operation. | ||
| """ | ||
| qir_bits = [] | ||
| bit_list = [] | ||
| bit_list: list[Any] = [] | ||
| if isinstance(operation, qasm3_ast.QuantumMeasurementStatement): | ||
| assert operation.target is not None | ||
| if operation.target is None: | ||
| raise_qasm3_error( | ||
| "Measurement result must be assigned to a classical bit, " | ||
| "e.g. 'c[0] = measure q[0];'", | ||
| span=operation.span, | ||
| ) | ||
| bit_list = [operation.measure.qubit] if qubits else [operation.target] | ||
| else: | ||
| bit_list = ( | ||
| operation.qubits if isinstance(operation.qubits, list) else [operation.qubits] | ||
| ) | ||
|
|
||
| for bit in bit_list: | ||
| # as we have unrolled qasm3, we can assume that the bit is an IndexedIdentifier | ||
| assert isinstance(bit, qasm3_ast.IndexedIdentifier) | ||
| # Physical qubits ("$n") are not backed by a declared register: they carry | ||
| # their hardware index in the identifier itself, so "$3" is qubit 3. Qiskit | ||
| # emits these when a circuit is transpiled against a backend. | ||
| physical_id = _physical_qubit_index(bit) if qubits else None | ||
| if physical_id is not None: | ||
| qir_bits.append(pyqir.qubit(self._llvm_module.context, physical_id)) | ||
| continue | ||
|
|
||
| # Everything else is register-backed and, post-unroll, indexed. | ||
| if not isinstance(bit, qasm3_ast.IndexedIdentifier): | ||
| kind = "qubit" if qubits else "classical bit" | ||
| raise_qasm3_error( | ||
| f"Unsupported {kind} operand of type {type(bit).__name__}", | ||
| span=getattr(bit, "span", None), | ||
| ) | ||
| reg_name = bit.name.name | ||
|
|
||
| assert isinstance(bit.indices, list) and len(bit.indices) == 1 | ||
|
|
@@ -275,9 +345,12 @@ def _visit_measurement(self, statement: qasm3_ast.QuantumMeasurementStatement) - | |
| """ | ||
| logger.debug("Visiting measurement statement '%s'", str(statement)) | ||
|
|
||
| source = statement.measure.qubit | ||
| target = statement.target | ||
| assert source and target | ||
| if statement.target is None: | ||
| raise_qasm3_error( | ||
| "Measurement result must be assigned to a classical bit, " | ||
| "e.g. 'c[0] = measure q[0];'", | ||
| span=statement.span, | ||
| ) | ||
| source_ids = self._get_op_bits(statement, qubits=True) | ||
| target_ids = self._get_op_bits(statement, qubits=False) | ||
| measurement_func = self._profile.get_measurement_function() | ||
|
|
@@ -325,7 +398,12 @@ def _barrier_applicable(self) -> bool: | |
| if self._profile.restrictions.subset_barriers_allowed: | ||
| return True | ||
|
|
||
| total_qubit_count = sum(self._global_qreg_size_map.values()) | ||
| # Physical qubits belong to no declared register, so the register size map is | ||
| # empty for those programs and the entry point's qubit count is the only | ||
| # measure of how many qubits a full barrier has to cover. | ||
| total_qubit_count = max( | ||
| sum(self._global_qreg_size_map.values()), self._required_qubit_count | ||
| ) | ||
| return len(self._barrier_qubits) == total_qubit_count | ||
|
|
||
| def _check_and_apply_barrier(self) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| # Copyright 2025 qBraid | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """ | ||
| Module containing unit tests for QASM3 programs that address physical (hardware) | ||
| qubits, e.g. "$0", rather than declaring a qubit register. | ||
|
|
||
| Physical qubits are valid OpenQASM 3 and are what Qiskit emits when a circuit is | ||
| transpiled against a backend (``qasm3.dumps(transpile(circuit, backend))``). They | ||
| carry their index in the identifier itself, so "$3" is hardware qubit 3 and maps | ||
| directly onto QIR qubit 3. | ||
|
|
||
| """ | ||
|
|
||
| import openqasm3.ast as qasm3_ast | ||
| import pytest | ||
|
|
||
| from qbraid_qir.qasm3 import qasm3_to_qir | ||
| from qbraid_qir.qasm3.exceptions import Qasm3ConversionError | ||
| from qbraid_qir.qasm3.visitor import QasmQIRVisitor | ||
| from tests.qir_utils import ( | ||
| check_attributes, | ||
| check_measure_op, | ||
| check_single_qubit_gate_op, | ||
| check_two_qubit_gate_op, | ||
| ) | ||
|
|
||
|
|
||
| def test_physical_qubit_gates_and_measurement(): | ||
| """A bell pair on physical qubits lowers to QIR addressing qubits 0 and 1.""" | ||
| qasm3_string = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| bit[2] meas; | ||
| h $0; | ||
| cx $0, $1; | ||
| meas[0] = measure $0; | ||
| meas[1] = measure $1; | ||
| """ | ||
| result = qasm3_to_qir(qasm3_string) | ||
| generated_qir = str(result).splitlines() | ||
|
|
||
| check_attributes(generated_qir, 2, 2) | ||
| check_single_qubit_gate_op(generated_qir, 1, [0], "h") | ||
| check_two_qubit_gate_op(generated_qir, 1, [[0, 1]], "cx") | ||
| check_measure_op(generated_qir, 2, [0, 1], [0, 1]) | ||
|
|
||
|
|
||
| def test_physical_qubit_index_is_preserved(): | ||
| """Physical qubit indices are hardware addresses, so "$3" stays qubit 3 and | ||
| the entry point must declare enough qubits to cover the highest index used.""" | ||
| qasm3_string = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| bit[2] meas; | ||
| h $3; | ||
| cx $3, $7; | ||
| meas[0] = measure $3; | ||
| meas[1] = measure $7; | ||
| """ | ||
| result = qasm3_to_qir(qasm3_string) | ||
| generated_qir = str(result).splitlines() | ||
|
|
||
| # Highest index is 7, so the entry point requires 8 qubits. | ||
| check_attributes(generated_qir, 8, 2) | ||
| check_single_qubit_gate_op(generated_qir, 1, [3], "h") | ||
| check_two_qubit_gate_op(generated_qir, 1, [[3, 7]], "cx") | ||
| check_measure_op(generated_qir, 2, [3, 7], [0, 1]) | ||
|
|
||
|
|
||
| def test_qiskit_style_transpiled_program(): | ||
| """The shape Qiskit emits after transpiling against a backend: physical | ||
| qubits, a barrier, and register-indexed measurements.""" | ||
| qasm3_string = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| bit[3] meas; | ||
| rz(pi / 2) $0; | ||
| sx $0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should add the check for the single qubit op as well in the final qasm |
||
| cz $0, $1; | ||
| cz $1, $2; | ||
| barrier $0, $1, $2; | ||
| meas[0] = measure $0; | ||
| meas[1] = measure $1; | ||
| meas[2] = measure $2; | ||
| """ | ||
| result = qasm3_to_qir(qasm3_string) | ||
| generated_qir = str(result).splitlines() | ||
|
|
||
| check_attributes(generated_qir, 3, 3) | ||
| check_two_qubit_gate_op(generated_qir, 2, [[0, 1], [1, 2]], "cz") | ||
| check_measure_op(generated_qir, 3, [0, 1, 2], [0, 1, 2]) | ||
|
|
||
|
|
||
| def test_physical_qubit_reset(): | ||
| """Reset on a physical qubit targets the same hardware index.""" | ||
| qasm3_string = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| bit[1] meas; | ||
| h $2; | ||
| reset $2; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add check for |
||
| meas[0] = measure $2; | ||
| """ | ||
| result = qasm3_to_qir(qasm3_string) | ||
| generated_qir = str(result).splitlines() | ||
|
|
||
| check_attributes(generated_qir, 3, 1) | ||
| check_single_qubit_gate_op(generated_qir, 1, [2], "h") | ||
| check_measure_op(generated_qir, 1, [2], [0]) | ||
|
|
||
|
|
||
| def test_measurement_without_target_raises_conversion_error(): | ||
| """'measure q;' parses but has no classical target to record into. It must | ||
| report that, not raise a bare AssertionError with an empty message.""" | ||
| qasm3_string = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| qubit[2] q; | ||
| bit[2] c; | ||
| h q[0]; | ||
| measure q; | ||
| """ | ||
| with pytest.raises(Qasm3ConversionError, match="must be assigned to a classical bit"): | ||
| qasm3_to_qir(qasm3_string) | ||
|
|
||
|
|
||
| def test_unsupported_operand_raises_conversion_error(): | ||
| """An operand the visitor cannot lower raises a Qasm3ConversionError naming | ||
| the problem, never a bare AssertionError with an empty message.""" | ||
| visitor = QasmQIRVisitor() | ||
| operation = qasm3_ast.QuantumGate( | ||
| modifiers=[], | ||
| name=qasm3_ast.Identifier(name="h"), | ||
| arguments=[], | ||
| # Neither an IndexedIdentifier (virtual) nor a "$n" Identifier (physical). | ||
| qubits=[qasm3_ast.Identifier(name="q")], | ||
| ) | ||
|
|
||
| with pytest.raises(Qasm3ConversionError, match="Unsupported qubit operand"): | ||
| visitor._get_op_bits(operation) # pylint: disable=protected-access | ||
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.
As mentioned in the comment below, better to move the parsing semantics to pyqasm