From 8365dc4f962854a7d192647308e89c96a93fad2f Mon Sep 17 00:00:00 2001 From: Etienne Wodey Date: Tue, 28 May 2024 16:48:23 +0200 Subject: [PATCH 1/2] Add experimental QIR to AQT API converter. --- .mypy.ini | 3 + poetry.lock | 38 ++++- pyproject.toml | 5 + qiskit_aqt_provider/qir_to_aqt.py | 221 +++++++++++++++++++++++++++++ test/test_qir_to_aqt.py | 224 ++++++++++++++++++++++++++++++ 5 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 qiskit_aqt_provider/qir_to_aqt.py create mode 100644 test/test_qir_to_aqt.py diff --git a/.mypy.ini b/.mypy.ini index 7872087..ffb9c13 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -109,5 +109,8 @@ ignore_missing_imports = True [mypy-qiskit_experiments.*] ignore_missing_imports = True +[mypy-qiskit_qir.*] +ignore_missing_imports = True + [mypy-scipy.*] ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index b22376e..9fbec8a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2724,6 +2724,23 @@ files = [ [package.extras] test = ["covdefaults (>=2.3)", "pytest (>=8.2)", "pytest-cov (>=5)"] +[[package]] +name = "pyqir" +version = "0.10.2" +description = "PyQIR parses, generates and evaluates the Quantum Intermediate Representation." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyqir-0.10.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c36bb36e8350bfa1c19a4dbca4532de68b7359909f67f28ddaa9fa645f9b7cdd"}, + {file = "pyqir-0.10.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2da2f25fed9048f3dcd80659abc1836456f9b476cd211fadb981a24f45d25af8"}, + {file = "pyqir-0.10.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:51f9e54f7e8c420a794fea8320f29f0bf03ca008c28c3e49e2d39a01da801673"}, + {file = "pyqir-0.10.2-cp38-abi3-manylinux_2_31_x86_64.whl", hash = "sha256:e7e610b715b9cdbf507ee45b6b0f5d7731cda75369a57a09dd2fe6fe8e234da3"}, + {file = "pyqir-0.10.2-cp38-abi3-win_amd64.whl", hash = "sha256:e994938521af707d60af579e684af8271882f9b852916da4f6b4c535718d5e82"}, +] + +[package.extras] +test = ["pytest (>=7.2.0,<7.3.0)"] + [[package]] name = "pyspnego" version = "0.10.2" @@ -3235,6 +3252,24 @@ cvx = ["cvxpy"] gurobi = ["gurobipy"] matplotlib = ["matplotlib"] +[[package]] +name = "qiskit-qir" +version = "0.5.0" +description = "Qiskit to QIR translator" +optional = false +python-versions = ">=3.8" +files = [ + {file = "qiskit-qir-0.5.0.tar.gz", hash = "sha256:5635837234974fdf6f1f6863231493f1fcdd571dc5283b480fe45ef69c9c86fd"}, + {file = "qiskit_qir-0.5.0-py2.py3-none-any.whl", hash = "sha256:646c924919d24562d4640973f0284d32e9e2e8c63b6352b12e98f1bce89b1e8e"}, +] + +[package.dependencies] +pyqir = ">=0.10.0,<0.11.0" +qiskit = ">=1.0.0,<2.0" + +[package.extras] +test = ["pytest"] + [[package]] name = "qiskit-sphinx-theme" version = "1.16.1" @@ -4337,8 +4372,9 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more [extras] examples = ["qiskit-algorithms", "qiskit-optimization"] +qir = ["pyqir"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "76bac5a46ffd48e0959502153724f583278cb8a20be749aeec3be4708b2ebd4e" +content-hash = "4a15a619ed1c8a622cbff2738d2e17226379b870100bd47328abdb1b12b7c850" diff --git a/pyproject.toml b/pyproject.toml index b1f79fd..33da7a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ python = ">=3.9,<3.13" httpx = ">=0.24.0" platformdirs = ">=3" pydantic = ">=2.5.0" +pyqir = { version = "^0.10.2", optional = true } python-dotenv = ">=1" qiskit = "^1" qiskit-aer = ">=0.13.2" @@ -89,6 +90,7 @@ pytest-httpx = "^0.22.0" pytest-mock = "^3.11.1" pytest-sugar = "^0.9.6" qiskit-experiments = "^0.6.0" +qiskit-qir = "^0.5.0" qiskit-sphinx-theme = ">=1.16.1" qiskit = { version = "^1", extras = [ "visualization", @@ -109,6 +111,9 @@ examples = [ "qiskit-algorithms", "qiskit-optimization", ] +qir = [ + "pyqir", +] [tool.ruff] target-version = "py39" diff --git a/qiskit_aqt_provider/qir_to_aqt.py b/qiskit_aqt_provider/qir_to_aqt.py new file mode 100644 index 0000000..ef88233 --- /dev/null +++ b/qiskit_aqt_provider/qir_to_aqt.py @@ -0,0 +1,221 @@ +# (C) Copyright Alpine Quantum Technologies GmbH 2024. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Experimental QIR to AQT API conversion routines.""" + +import dataclasses +import math +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Union + +import pyqir + +from qiskit_aqt_provider import api_models + + +def qir_to_aqt_circuit(module: pyqir.Module) -> api_models.Circuit: + """Convert a QIR module representing a quantum circuit to an equivalent AQT API payload. + + Args: + module: QIR module to convert. + + Returns: + AQT API payload that corresponds to the first entry point found in the QIR module. + + Limitations: + - only the first entry point found is converted. Other entry points are ignored. + - only the following gates are supported: RX, RY, RZ, X, Y, Z, RXX. + - no classical code is allowed (including non QIS function calls). + - except for the initialization routine, no quantum runtime operations are allowed. + - measurements are bunched at the end of the circuit. + - in the QIR logic, qubits targeted by a measurement cannot be operated on after the measurement. + """ + if (error_msg := module.verify()) is not None: # pragma: no cover + msg = f"Invalid LLVM module: {error_msg}" + raise ValueError(msg) + + # TODO?: support multiple entry points (one circuit per entry point) + entry_points = [func for func in module.functions if pyqir.is_entry_point(func)] + if not entry_points: + msg = "No entry point found." + raise ValueError(msg) + + ctx = _Context() + ops = list(_traverse_function(entry_points[0], ctx)) + + if not ctx.measured_qubits: + msg = "No measurement operation found." + raise ValueError(msg) + + ops.append(api_models.Operation.measure()) + + return api_models.Circuit(root=ops) + + +def load_llvm_module(data: Union[str, bytes]) -> pyqir.Module: + """Load a QIR module, from LLVM bitcode or human-readable representation. + + Args: + data: QIR module to load. If a string, assume human-readable format, else + LLVM bitcode. + """ + ctx = pyqir.Context() + + if isinstance(data, str): + return pyqir.Module.from_ir(ctx, data) + + return pyqir.Module.from_bitcode(ctx, data) + + +@dataclass +class _Context: + """Context for AQT JSON emission.""" + + measured_qubits: set[int] = dataclasses.field(default_factory=set) + + +def _traverse_function( + func: pyqir.Function, context: _Context +) -> Iterator[api_models.OperationModel]: + """Traverse a function definition and emit AQT API operations for the found QIS calls.""" + for bb in func.basic_blocks: + for inst in bb.instructions: + if isinstance(inst, pyqir.Call): + yield from _convert_call(inst, context) + elif inst.opcode == pyqir.Opcode.RET: + break + else: + msg = f"Unsupported instruction: {inst}" + raise ValueError(msg) + + +# C901 (complexity too high): complexity is high but structure mostly flat due to instructions dispatch. +def _convert_call(inst: pyqir.Call, context: _Context) -> Iterator[api_models.OperationModel]: # noqa: C901 + """Emit AQT API operations for a given QIR call instruction.""" + func_name = inst.callee.name + + def ensure_can_operate_on_qubit(qubit: int) -> int: + if qubit in context.measured_qubits: + msg = f"Cannot operate on {qubit=}: was already measured." + raise ValueError(msg) + + return qubit + + if func_name == "__quantum__rt__initialize": + return # ignore, no-op + elif func_name == "__quantum__qis__rx__body": + angle_arg, qubit_arg = inst.args + assert isinstance(angle_arg, pyqir.FloatConstant) # noqa: S101 + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + angle = angle_arg.value + + theta = math.atan2(math.sin(angle), math.cos(angle)) + phi = math.pi if theta < 0.0 else 0.0 + + yield api_models.Operation.r( + phi=phi / math.pi, + theta=abs(theta) / math.pi, + qubit=ensure_can_operate_on_qubit(_extract_qubit_id(qubit_arg)), + ) + elif func_name == "__quantum__qis__x__body": + (qubit_arg,) = inst.args + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + + yield api_models.Operation.r( + phi=0.0, + theta=1.0, + qubit=ensure_can_operate_on_qubit(_extract_qubit_id(qubit_arg)), + ) + elif func_name == "__quantum__qis__ry__body": + angle_arg, qubit_arg = inst.args + assert isinstance(angle_arg, pyqir.FloatConstant) # noqa: S101 + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + angle = angle_arg.value + + theta = math.atan2(math.sin(angle), math.cos(angle)) + phi = math.pi / 2 + (math.pi if theta < 0.0 else 0.0) + + yield api_models.Operation.r( + phi=phi / math.pi, + theta=abs(theta) / math.pi, + qubit=ensure_can_operate_on_qubit(_extract_qubit_id(qubit_arg)), + ) + elif func_name == "__quantum__qis__y__body": + (qubit_arg,) = inst.args + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + + yield api_models.Operation.r( + phi=0.5, + theta=1.0, + qubit=ensure_can_operate_on_qubit(_extract_qubit_id(qubit_arg)), + ) + elif func_name == "__quantum__qis__rz__body": + angle_arg, qubit_arg = inst.args + assert isinstance(angle_arg, pyqir.FloatConstant) # noqa: S101 + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + angle = angle_arg.value + + phi = math.atan2(math.sin(angle), math.cos(angle)) + + yield api_models.Operation.rz( + phi=phi / math.pi, + qubit=ensure_can_operate_on_qubit(_extract_qubit_id(qubit_arg)), + ) + elif func_name == "__quantum__qis__z__body": + (qubit_arg,) = inst.args + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + + yield api_models.Operation.rz( + phi=1.0, + qubit=_extract_qubit_id(qubit_arg), + ) + # no cover: missing tooling support for emitting / manipulating RXX gates + # in pyqir/qiskit_qir. + elif func_name == "__quantum__qis__rxx__body": # pragma: no cover + angle_arg, qubit0_arg, qubit1_arg = inst.args + assert isinstance(angle_arg, pyqir.FloatConstant) # noqa: S101 + assert isinstance(qubit0_arg, pyqir.Constant) # noqa: S101 + assert isinstance(qubit1_arg, pyqir.Constant) # noqa: S101 + angle = angle_arg.value + + if angle < 0 or angle > math.pi / 2: + msg = "__quantum__qis__rxx__body angle must be in [0, π/2]." + raise ValueError(msg) + + yield api_models.Operation.rxx( + theta=angle / math.pi, + qubits=[ + ensure_can_operate_on_qubit(_extract_qubit_id(qubit0_arg)), + ensure_can_operate_on_qubit(_extract_qubit_id(qubit1_arg)), + ], + ) + elif func_name == "__quantum__qis__mz__body": + qubit_arg, _ = inst.args + assert isinstance(qubit_arg, pyqir.Constant) # noqa: S101 + + context.measured_qubits.add(_extract_qubit_id(qubit_arg)) + else: + # TODO: support local functions + msg = f"Unsupported function: {func_name}" + raise ValueError(msg) + + +def _extract_qubit_id(value: pyqir.Constant) -> int: + """Extract a Qubit identifier from a LLVM constant. + + Raises: + ValueError: no identifier could be extracted. + """ + if (qubit_id := pyqir.qubit_id(value)) is None: # pragma: no cover + msg = "Invalid Qubit struct pointer." + raise ValueError(msg) + + return qubit_id diff --git a/test/test_qir_to_aqt.py b/test/test_qir_to_aqt.py new file mode 100644 index 0000000..24c29ff --- /dev/null +++ b/test/test_qir_to_aqt.py @@ -0,0 +1,224 @@ +# (C) Copyright Alpine Quantum Technologies 2023 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for the experimental QIR to AQT JSON API converter. + +Note: no tests involve RXXGate because (as of 2024-05-28): +- qiskit_qir doesn't support RXXGate as native gate, thus emitting unsupported CNOT/H calls. +- pyqir has no builder for this gate. +- writing LLVM IR by hand is tedious and brittle. +""" + +import textwrap +from collections import namedtuple +from math import pi + +import pyqir +import pytest +from qiskit import QuantumCircuit +from qiskit_qir import to_qir_module + +from qiskit_aqt_provider import api_models +from qiskit_aqt_provider.circuit_to_aqt import ( + aqt_to_qiskit_circuit, + qiskit_to_aqt_circuit, +) +from qiskit_aqt_provider.qir_to_aqt import load_llvm_module, qir_to_aqt_circuit +from qiskit_aqt_provider.test.circuits import ( + assert_circuits_equivalent, +) + + +def test_empty_circuit() -> None: + """Valid circuits contain at least one measurement operation.""" + qc = QuantumCircuit(2) + mod, _ = to_qir_module(qc, record_output=False) + + with pytest.raises(ValueError, match=r"No measurement operations? found"): + qir_to_aqt_circuit(mod) + + +def test_measure_only_circuit() -> None: + """Circuit with only measurement operations are valid.""" + qc = QuantumCircuit(2) + qc.measure_all() + mod, _ = to_qir_module(qc, record_output=False) + + aqt_circ = qir_to_aqt_circuit(mod) + assert aqt_circ == api_models.Circuit(root=[api_models.Operation.measure()]) + + +def test_operate_after_measurement() -> None: + """On a given qubit, operations after measurement are invalid.""" + qc = QuantumCircuit(1) + qc.measure_all() + qc.x(0) + mod, _ = to_qir_module(qc, record_output=False) + + with pytest.raises(ValueError, match=r"Cannot operate on qubit=0"): + qir_to_aqt_circuit(mod) + + +def test_unsupported_gates() -> None: + """CX (CNOT) gates are not supported.""" + qc = QuantumCircuit(2) + qc.cx(0, 1) + qc.measure_all() + + mod, _ = to_qir_module(qc, record_output=False) + + with pytest.raises(ValueError, match=r"Unsupported function: (.*)__qis__cnot"): + qir_to_aqt_circuit(mod) + + +def test_equivalent_on_single_qubit_basis_gates() -> None: + """Example circuit with single-qubit native gates. + + Check that the AQT JSON payload is the same with the direct + exporter and the QIR converter. + """ + # The input circuit for both cases in not exactly the same + # because: + # - qiskit_qir.to_qir_module doesn't support RGate as native gate. + # - the direct conversion needs native gate, so RXGate, RYGate, etc. are not supported. + Arg = namedtuple("Arg", "angle,axis,qubit") # noqa: PYI024 + + args = [ + Arg(pi / 2, "x", 0), + Arg(pi / 4, "x", 1), + Arg(pi / 7, "y", 0), + Arg(pi / 3, "z", 1), + ] + + qc = QuantumCircuit(2) + for arg in args: + if arg.axis == "x": + qc.rx(arg.angle, arg.qubit) + elif arg.axis == "y": + qc.ry(arg.angle, arg.qubit) + elif arg.axis == "z": + qc.rz(arg.angle, arg.qubit) + qc.measure_all() + + mod, _ = to_qir_module(qc, record_output=False) + from_qir = qir_to_aqt_circuit(mod) + + qc = QuantumCircuit(2) + for arg in args: + if arg.axis in ("x", "y"): + phi = 0.0 if arg.axis == "x" else pi / 2 + qc.r(arg.angle, phi, arg.qubit) + else: + qc.rz(arg.angle, arg.qubit) + qc.measure_all() + + direct = qiskit_to_aqt_circuit(qc) + + assert from_qir == direct + + +def test_unsupported_classical_control_flow() -> None: + """Classical control flow is not supported.""" + qir = textwrap.dedent(r""" + source_filename = "testcase" + + define void @entry() #0 { + entry: + call void @__quantum__rt__initialize(i8* null) + br i1 1, label %then, label %else + + then: + ret void + + else: + ret void + } + + declare void @__quantum__rt__initialize(i8*) + + attributes #0 = {"entry_point"} + """) + + mod = load_llvm_module(qir) + + with pytest.raises(ValueError, match=r"Unsupported instruction(.*)br"): + qir_to_aqt_circuit(mod) + + +def test_no_entry_point() -> None: + """At least one entry point is required.""" + qir = textwrap.dedent(r""" + source_filename = "testcase" + + define void @entry() { + entry: + call void @__quantum__rt__initialize(i8* null) + ret void + } + + declare void @__quantum__rt__initialize(i8*) + """) + + mod = load_llvm_module(qir) + + with pytest.raises(ValueError, match="No entry point found"): + qir_to_aqt_circuit(mod) + + +@pytest.mark.parametrize("from_bitcode", [True, False]) +def test_load_from_bitcode_or_human_readable(from_bitcode: bool) -> None: + """Load QIR input from bitcode or human-readable format.""" + mod = pyqir.SimpleModule("testcase", num_qubits=2, num_results=2) + qis = pyqir.BasicQisBuilder(mod.builder) + + qis.x(mod.qubits[0]) + qis.y(mod.qubits[1]) + qis.mz(mod.qubits[0], mod.results[0]) + qis.mz(mod.qubits[1], mod.results[1]) + + # equivalent circuit in native gates + qc = QuantumCircuit(2) + qc.r(pi, 0, 0) + qc.r(pi, pi / 2, 1) + qc.measure_all() + + loaded_mod = load_llvm_module(mod.bitcode() if from_bitcode else mod.ir()) + + aqt_circ = qir_to_aqt_circuit(loaded_mod) + ref_aqt_circ = qiskit_to_aqt_circuit(qc) + + assert aqt_circ == ref_aqt_circ + + +def test_demo_supported_single_qubit_qis_instructions() -> None: + """Demo support single-qubit operations. + + Round-trip the test circuit from and to Qiskit, through + QIR and the AQT JSON format. Check that the circuits are + equivalent. + """ + base_qc = QuantumCircuit(1) + base_qc.rx(pi / 3, 0) + base_qc.x(0) + base_qc.ry(pi / 5, 0) + base_qc.y(0) + base_qc.rz(pi / 12, 0) + base_qc.z(0) + + qc = base_qc.copy() + qc.measure_all() + + mod, _ = to_qir_module(qc, record_output=False) + aqt_circ = qir_to_aqt_circuit(mod) + + round_tripped = aqt_to_qiskit_circuit(aqt_circ, 1) + round_tripped.remove_final_measurements() + + assert_circuits_equivalent(round_tripped, base_qc) From 3bcb5d0811fbc7e3d36bd7bd948585e7b40c626b Mon Sep 17 00:00:00 2001 From: Etienne Wodey Date: Tue, 28 May 2024 17:22:54 +0200 Subject: [PATCH 2/2] changelog: add entry for #162 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e3599..95177f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +* Add an experimental QIR to AQT API converter (#162) + ## qiskit-aqt-provider v1.5.0 * Docs: add examples on setting run options in primitives (#156)